题目描述1
2
3假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
解答:
递推公式:
设f(n)为n阶楼梯的爬法f(n)=f(n-1)+f(n-2) n>2
f(n)=1 n=1
f(n)=2 n=2
到达第n阶楼梯,有两种方法,一种是从第n-1阶楼梯,走一步到;另一种是从n-2阶楼梯,走两步到。
根据以上的递归公式,我们很容易写出下面的代码:1
2
3
4
5
6
7
8
9
10
11
12public int climbStairs(int n) {
if (n<3){
return n;
}
int[] dp=new int[n+1];
dp[1]=1;
dp[2]=2;
for(int i=3;i<=n;i++){
dp[i]=dp[i-1]+dp[i-2];
}
return dp[n];
}
通过观察,不难发现我们只需要保存两个状态即可.1
2
3
4
5
6
7
8
9
10
11
12
13public int climbStairs(int n) {
if (n<3){
return n;
}
int p1=2; //f(n-1)
int p2=1; //f(n-2)
for(int i=3;i<=n;i++){
int cur=p1+p2;
p2=p1;
p1=cur;
}
return p1;
}
题目描述:1
2
3你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
解答:
设小偷走到第n个房屋旁,他的最大收入为f(n),
那么有递推公式:f(n)=max(f(n-2)+s[n],f(n-1))
其中s[n]第n个房子可以窃到金额。
这个公式的含义是,当小偷走到第n个房子时,他其实有两种选择,偷或上家偷了不偷这家。小偷只需要选一个最优方案即可。
根据递推公式我们很容易得到以下的代码:
1 | public int rob(int[] nums) { |
实际上我们只需要保存两个状态即可,也无需这么多的特判代码。简化后的代码如下:
1 | public int rob(int[] nums) { |
这道题还有一些延申题目:
题目描述1
2
3你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
解答:
这道题目和上面题目的不同在于房子是环形排布的。也就是说,如果第一个房子被偷了,那么最后一个房子就不能被偷。也就是说第一个房子偷不偷可以影响最后一个房子的情况,我们只需要把两种情况都计算一下即可。
1 | public int rob(int[] nums) { |
题目描述1
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
解答:
设以下标n结尾的子序和为f(n)
因此我们可以得到递推公式:f(n)=max(f(n-1),0)+nums[n]
1 | public int maxSubArray(int[] nums) { |
从递推公式中可以知道我们需要保存前一个状态,我们可以直接利用原数组即可。
题目描述:1
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
解答:
设总金额n找零所需的最少硬币数为f(n)
那么我们可以得到递推公式f(n)=min(f(n-c))+1 c 为硬币面值
1 | public int coinChange(int[] coins, int amount) { |
题目描述:1
2
3给定一个三角形,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。
相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。
解答:1
2
3
4
5
6[
[2],
[3,4],
[6,5,7],
[4,1,8,3]
]
假如到达第i层的第j个位置,那么只能从第i-1层的j-1/j位置到达。
我们设到达第i层第j个节点的最小路径和为dp[i][j]
那么我们可以得到以下递推公式:dp[i][j]=min(dp[i-1][j-1],dp[i-1][j])+d[i][j]
值得注意的是两边要特殊处理。
1 | public int minimumTotal(List<List<Integer>> triangle) { |
题目描述:1
2
3
4
5
6给定一个无序的整数数组,找到其中最长上升子序列的长度。
示例:
输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。
解答:
1 | public int lengthOfLIS(int[] nums) { |
题目描述:1
2
3
4
5
6
7
8
9
10
11
12
13
14给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:每次只能向下或者向右移动一步。
示例:
输入:
[
[1,3,1],
[1,5,1],
[4,2,1]
]
输出: 7
解释: 因为路径 1→3→1→1→1 的总和最小。
解答:
我们假设到达坐标(i,j)的最小路径和为f(i,j)
因为到达(i,j)要么从上面过来,要么从左边过来。
因此我们可以得到这样的递推公式:f(i,j)=min(f(i-1,j),f(i,j-1))+grid[i][j]
注意第一行和第一列要特殊处理。
1 | public int minPathSum(int[][] grid) { |
题目描述:1
2
3
4
5
6
7
8一些恶魔抓住了公主(P)并将她关在了地下城的右下角。地下城是由 M x N 个房间组成的二维网格。我们英勇的骑士(K)最初被安置在左上角的房间里,他必须穿过地下城并通过对抗恶魔来拯救公主。
骑士的初始健康点数为一个正整数。如果他的健康点数在某一时刻降至 0 或以下,他会立即死亡。
有些房间由恶魔守卫,因此骑士在进入这些房间时会失去健康点数(若房间里的值为负整数,则表示骑士将损失健康点数);其他房间要么是空的(房间里的值为 0),要么包含增加骑士健康点数的魔法球(若房间里的值为正整数,则表示骑士将增加健康点数)。
为了尽快到达公主,骑士决定每次只向右或向下移动一步。
编写一个函数来计算确保骑士能够拯救到公主所需的最低初始健康点数。
解答:
这道题我们不能从左上角向右下角进行动态规划;我们需要从右下角到左上角进行动态规划。
设从(i,j)到终点所需的最小初始值为dp[i][j]
也就是说当我们到达坐标(i,j)时,只要路径和不小于dp[i][j]就能到达终点。
我们可以得到状态转移方程:dp[i][j]=max(min(dp[i+1][j],dp[i][j+1])-dungeon(i,j),1)
1 | public int calculateMinimumHP(int[][] dungeon) { |
1 | public class MyServer { |
在启动Netty服务器之前创建了两个NioEventLoopGroup
那么我们首先来分析它们的创建过程:
NioEventLoopGroup
的实例化最终调用了它的父类的构造器:
1 | protected MultithreadEventExecutorGroup(int nThreads, Executor executor, |
newChild
的实现如下:
1 | protected EventLoop newChild(Executor executor, Object... args) throws Exception { |
以上就是NioEventLoopGroup
的实现,在启动Netty服务端的时候,创建了两个NioEventLoopGroup
,分别是boosGroup
和workerGroup
,它们本质上是一样的,只是作用不同。
1 | public ServerBootstrap group(EventLoopGroup parentGroup, EventLoopGroup childGroup) { |
从这里开始两个NioEventLoopGroup
的作用开始不同了。
通过层层调用最终来到了doBind
方法:
1 | private ChannelFuture doBind(final SocketAddress localAddress) { |
在进行doBind
最开始就进行了Channel
的绑定和初始化工作
1 | final ChannelFuture initAndRegister() { |
1 | void init(Channel channel) { |
该方法主要做Channel
的初始化工作,如果我们在启动前设置了参数,这里也会传递过去。
完成Channel
的初始化工作之后就需要对Channel
进行注册:
1 |
|
而doRegister
方法完成了最终的注册工作1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected void doRegister() throws Exception {
boolean selected = false;
for (;;) {
try {
selectionKey = javaChannel().register(eventLoop().unwrappedSelector(), 0, this);
return;
} catch (CancelledKeyException e) {
if (!selected) {
eventLoop().selectNow();
selected = true;
} else {
throw e;
}
}
}
}
这样就把Channle
注册到了boss线程的selector
多路复用器上,完成了channel
的初始化和注册。
那么Server端是何时启动监听呢,其实通过上述代码会发现,每个Channel(不管server还是client)在运行期间,全局绑定一个唯一的线程不变(NioEventLoop),Netty所有的I/O操作都是和这个channel对应NioEventLoop进行操作,也就是很多步骤都会有一个eventLoop.inEventLoop()的判断,判断是否在这个channel对应的线程中,如果不在,则会执行eventLoop.execute(new Runnable() {}这步操作时,会判断IO线程是否启动,如果没有启动,会启动IO线程:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22private void execute(Runnable task, boolean immediate) {
boolean inEventLoop = inEventLoop();
addTask(task);
if (!inEventLoop) {
startThread();
if (isShutdown()) {
boolean reject = false;
try {
if (removeTask(task)) {
reject = true;
}
} catch (UnsupportedOperationException e) {
}
if (reject) {
reject();
}
}
}
if (!addTaskWakesUp && immediate) {
wakeup(inEventLoop);
}
}
最终会调用NioEventLoop
的run方法:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88protected void run() {
int selectCnt = 0;
for (;;) {
try {
int strategy;
try {
strategy = selectStrategy.calculateStrategy(selectNowSupplier, hasTasks());
switch (strategy) {
case SelectStrategy.CONTINUE:
continue;
case SelectStrategy.BUSY_WAIT:
case SelectStrategy.SELECT:
long curDeadlineNanos = nextScheduledTaskDeadlineNanos();
if (curDeadlineNanos == -1L) {
curDeadlineNanos = NONE; // nothing on the calendar
}
nextWakeupNanos.set(curDeadlineNanos);
try {
if (!hasTasks()) {
strategy = select(curDeadlineNanos);
}
} finally {
nextWakeupNanos.lazySet(AWAKE);
}
// fall through
default:
}
} catch (IOException e) {
rebuildSelector0();
selectCnt = 0;
handleLoopException(e);
continue;
}
selectCnt++;
cancelledKeys = 0;
needsToSelectAgain = false;
final int ioRatio = this.ioRatio;
boolean ranTasks;
if (ioRatio == 100) {
try {
if (strategy > 0) {
processSelectedKeys();
}
} finally {
// Ensure we always run tasks.
ranTasks = runAllTasks();
}
} else if (strategy > 0) {
final long ioStartTime = System.nanoTime();
try {
processSelectedKeys();
} finally {
final long ioTime = System.nanoTime() - ioStartTime;
ranTasks = runAllTasks(ioTime * (100 - ioRatio) / ioRatio);
}
} else {
ranTasks = runAllTasks(0); // This will run the minimum number of tasks
}
if (ranTasks || strategy > 0) {
if (selectCnt > MIN_PREMATURE_SELECTOR_RETURNS && logger.isDebugEnabled()) {
logger.debug("Selector.select() returned prematurely {} times in a row for Selector {}.",
selectCnt - 1, selector);
}
selectCnt = 0;
} else if (unexpectedSelectorWakeup(selectCnt)) { // Unexpected wakeup (unusual case)
selectCnt = 0;
}
} catch (CancelledKeyException e) {
if (logger.isDebugEnabled()) {
logger.debug(CancelledKeyException.class.getSimpleName() + " raised by a Selector {} - JDK bug?",
selector, e);
}
} catch (Throwable t) {
handleLoopException(t);
}
try {
if (isShuttingDown()) {
closeAll();
if (confirmShutdown()) {
return;
}
}
} catch (Throwable t) {
handleLoopException(t);
}
}
}
到此整个Netty服务端就启动了。
]]>XSS攻击即跨站点脚本攻击(cross site script),指黑客通过篡改网页,注入恶意HTML脚本,在用户浏览网页时,控制用户浏览器进行恶意操作的一种攻击方式。
常见的XSS攻击的类型有两种:
消毒
XSS攻击者一般都是通过在请求中嵌入恶意脚本达到攻击的目的,这些脚本一般用户输入中不使用的,如果进行过滤和消毒处理,即对某些html危险字符转义,如”>””<”等,就可以防范大部分攻击。
HttpOnly
即浏览器禁止页面JavaScript访问带有HttpOnly属性的Cookie。该方法可以防止XSS攻击者窃取Cookie,对于存放敏感信息的Cookie,如用户认证信息等,可通过对该Cookie添加HttpOnly属性,避免被攻击脚本窃取。
注入攻击主要有两种形式,SQL注入攻击和OS注入攻击。
SQL注入攻击的原理就是攻击者在HTTP请求中注入恶意SQL命令,服务器用请求参数构造数据库SQL命令时,恶意SQL被一起构造,并在数据库中执行。SQL注入攻击需要用户对数据库得结构有所了解才能进行,攻击者获取数据库表结构的手段有:网站开源的代码,错误回显,盲注。
消毒
和防止XSS攻击一样,请求参数消毒是一种比较简单粗暴又有效的手段。通过正则匹配,过滤请求数据中可能注入的SQL。
参数绑定
使用预编译手段,绑定参数是最好的防SQL注入方法。目前很多数据层很多框架都提供SQL预编译和参数绑定。
CSRF(cross site request frogery,跨站点请求伪造),攻击者通过跨站请求,以合法用户的身份进行非常操作,如转账交易、发表评论等。CSRF的主要手段是利用跨站请求,在用户不支持的情况下,以用户的身份伪造请求。核心是利用了浏览器Cookie或服务器Session策略,盗取用户身份。
CSRF的防范手段主要是识别请求者身份。
表单Token
CSRF是一个伪造用户请求的操作,所以需要构造用户请求的所有参数才可以。表单Token通过在请求参数中增加随机数的办法来阻止攻击者获得所有请求参数。服务器检查请求参数中Token的值是否存在并且正确请求提交者是否合法。
验证码
在请求提交时,需要用户输入验证码,以避免在用户不知情的情况下被攻击者伪造请求。
Referer check
HTTP请求头的Referer域中记录着请求来源,可通过检查请求来源,验证其是否合法。
错误回显,许多web服务器默认是打开异常信息输出的,即服务器端未处理的异常堆栈信息回直接输出到客户端浏览器,这种方式虽然对程序调试和错误报告有好处,但同时也给黑客造成可乘之机。
未调式程序或其它不恰当的原因,有时程序开发人员会在PHP,JSP等服务器页面程序中使用HTML注释语法进行程序注释,这些HTML注释信息会显示在客户端浏览器给黑客攻击便利。
一般网站都会有文件上传功能,设置头像、分享视频、上传附件等。如果上传的是可执行的程序,并通过该程序获得服务器端命令执行能力,那么攻击者几乎可以在服务器上为所欲为。最有效的防范手段是设置上传文件白名单,只允许上传可靠的文件类型。此外还可以修改文件名、使用专门的存储手段等,保护服务器避免受上传文件攻击。
攻击者在请求的URL中使用相对路径,遍历系统未开放的目录和文件。防御防范主要是讲JS、CSS等资源文件部署在独立的服务器,使用独立域名,其它文件不使用静态URL范围,到你太参数不包含文件路径信息。
]]>SpringBoot相较于Spring的一大进步就是它简化了配置。SpringBoot遵循”约定优于配置”的原则,使用注解对一些常规的配置项做默认配置,减少或不使用xml配置。Springboot还提供了大量的starter,只需引入一个starter,就可以直接使用框架。
在SpringBoot
启动类上添加的SpringBootApplication
注解。这个注解实际上是一个复合注解。
1 | (ElementType.TYPE) |
我们主要关注@SpringBootConfiguration
,@EnableAutoConfiguration
,@ComponeScan
.@SpringBootConfiguration
注解的底层是@Configuration
注解,即支持JavaConfig的方式来进行配置。
@EnableAutoConfiguration
注解的作用就是开启自动配置功能。
@ComponentScan
注解的作用就是烧苗当前类所属的package
,将@Controller
、@Service
、@Component
、@Repository
等注解所表示的类加载到IOC容器中。
通过对这几个注解的分析,我们可以知道自动配置工作主要是由EnableAutoConfiguration
注解来实现的。
该注解的定义如下:
1 | (ElementType.TYPE) |
该注解使用@Import
向IOC容器中注入了AutoConfigurationImportSelector
类。
该类中提供了获取所有候选的配置的方法:1
2
3
4
5
6
7protected List<String> getCandidateConfigurations(AnnotationMetadata metadata, AnnotationAttributes attributes) {
List<String> configurations = SpringFactoriesLoader.loadFactoryNames(getSpringFactoriesLoaderFactoryClass(),
getBeanClassLoader());
Assert.notEmpty(configurations, "No auto configuration classes found in META-INF/spring.factories. If you "
+ "are using a custom packaging, make sure that file is correct.");
return configurations;
}
通过loadFactoryNames
方法拿到了一个List
,这个方法中传入了一个getSpringFactoriesLoaderFactoryClass()
,这个方法,实际上就是获取了标记了@EnableAutoConfiguratioin
注解的类。1
2
3protected Class<?> getSpringFactoriesLoaderFactoryClass() {
return EnableAutoConfiguration.class;
}
当我们的SpringBoot项目启动的时候,会先导入AutoConfigurationImportSelector,这个类会帮我们选择所有候选的配置,我们需要导入的配置都是SpringBoot帮我们写好的一个一个的配置类,那么这些配置类的位置,存在与META-INF/spring.factories文件中,通过这个文件,Spring可以找到这些配置类的位置,于是去加载其中的配置。
DispatcherServlet
,它也是一个Servlet
,执行doService
request
对象中。doDispatch(request,response)
方法getHandler
方法获取对应的Handler
getHandlerAdapter
拿到对应的HandlerAdapter
PreHandler
,如果拦截器的PreHandeler
返回false,则直接返回HandlerAdapter
对象的handler
得到ModelAndView
对象postHandle
方法processDispatchResult
对结果进行处理,其内部调用了拦截器的afterCompletion
方法获取Handler
是通过getHandler
方法来获取的。1
2
3
4
5
6
7
8
9
10
11
12
13protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
for (HandlerMapping hm : this.handlerMappings) {
if (logger.isTraceEnabled()) {
logger.trace(
"Testing handler map [" + hm + "] in DispatcherServlet with name '" + getServletName() + "'");
}
HandlerExecutionChain handler = hm.getHandler(request);
if (handler != null) {
return handler;
}
}
return null;
}
这段代码看起来非常的简单,遍历handlerMappings
,从HandlerMapping
从获取HandlerExecutionChain
即我们的handler
.
这个HandlerExecutionChain
中包含了handler
和HandlerInterceptor
数组。也就是我们拿到handler
实际上是一个处理链。
SpringMVC的handler
的实现方式比较的多,比如通过继承Controller
的,基于注解控制器方式,HttpRequestHandler
的方式。因为handler
的实现方式不同,因此调用的方式也就不确定了。因此引入了HandlerAdapter
来进行适配。HandlerAdapter
接口有三个方法:1
2
3
4//判断当前的HandlerAdapter是否支持HandlerMethod
boolean supports(Object handler);
ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception;
long getLastModified(HttpServletRequest request, Object handler);
获取HandlerAdapter
是通过getHandlerAdapter
方法来获取的。通过对HandlerAdapter
使用原因的分析,我们可以直到所谓获取对应的HandlerAdapter
实际上从HandlerAdapter
列表中找出一个支持当前handler
的。
1 | protected HandlerAdapter getHandlerAdapter(Object handler) throws ServletException { |
该方法就是简单的遍历HandlerAdapter
列表,从中找出一个支持当前handler
的,并返回。
1 | public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) |
最终来到了invokerhandlerMethod
方法了:
1 | protected ModelAndView invokeHandlerMethod(HttpServletRequest request, HttpServletResponse response, Object handler) |
最后调用mappedHandler.applyPostHandle(processedRequest, response, mv);
进行后处理。后处理的过程就是调用所有的后置拦截器进行处理。
实现Filter
接口
1 | "filterOne", urlPatterns = {"/*"}) (filterName = |
实现HandlerInterceptor
接口
1 | public class MyInterceptor implements HandlerInterceptor { |
在springmvc
的配置文件中进行配置
1 | <mvc:interceptors> |
Filter
过滤器访问较大,配置在web.xml
Interceptor
范围比较小,配置在springmvc
springmvc
处理之前,首先要处理web.xml
Channel
包含4个状态:
ChannelUnregistered | Channel已经被创建,但还未注册到EventLoop |
---|---|
ChannelRegistered | Channel已经被注册到EventLoop |
ChannelActive | Channel处于活动状态,它现在可以接受和发送数据了 |
ChannelInactive | Channel没有连接到远程节点 |
ChannelHandler
接口定义了一系列生命周期操作:
类型 | 描述 |
---|---|
handlerAdded | 当把ChannelHandler添加到ChannelPipeline中时要调用 |
handlerRemoved | 当从ChannelPipeline中移除ChannelHandler时被调用 |
exceptionCaught | 当处理过程中在ChannelPipeline中有错误产生时调用 |
每当调用ChannelInboundHandler.channelRead()
或者ChannelOutboundHandler.write()
方法来处理数据时,都需要确保没有任何的资源泄漏。Netty使用引用计数来处理池化的ByteBuf
.所以在完全使用某个ByteBuf
之后,调整其引用计数是很重要的。
Netty提供了class ResourceLeakDetector
,它可以对应用程序缓冲区分配做大约1%的采样率进行内存泄漏检测。相关的开销是非常的小的。
级别 | 描述 |
---|---|
DISABLED | 禁止内存泄漏检测 |
SIMPLE | 使用1%的默认采样率检测并报告任何发现的泄漏。(默认) |
ADVANCED | 使用默认的采样率,报告所发现的任何的泄漏以及对应的消息被访问的位置。 |
PARANOID | 类是于ADVANCED,但是其间会对每次访问都进行采样。这会对性能有较大的影响。 |
泄漏检测级别可以通过JVM启动选项来设置:java -Dio.netty.leakDetectionLevel=ADVANCED
ChannelPipeline
是一个拦截流经Channel
的入站和出站事件的ChannelHandler
实例链。每一个新创建的Channel都会被分配给一个新的ChannelPipeline,这项关联式永久性的,Channel既不能附加另外一个ChannelPipeline,也不能分离其当前的。
根据事件的起源事件将会被分为ChannelInboundHandler
或者ChannelOutboundHandler
处理。
ChannelPipeline
可以通过添加、删除或者替换其它的ChannelHandler
来实时地修改ChannelPipeline
的布局。ChannelPipeline
还提供了访问ChannelHandler
的操作:
ChannelPipeline
的API公开了用于调用入站和出站操作的附加方法。
ChannelHandlerContext
代表了ChannelHandler
和ChannelPipeline
之间的关联。每当有ChannelHandler
添加到ChannelPipeline
中时,都会创建ChannelHandlerContext
。ChannelHandlerContext
的主要功能就是管理它所关联的ChannelHandler
和在同一ChannelPipeline
中的其它ChannelHandler
之间的交互。
Netty提供了几种方式来处理入站和出站过程中出现的异常。
如果需要处理入站异常,需要在对应的ChannelInboundHandler
中重写exceptionCaught
方法。1
2
3
4
5
6
7
8public class InboundExceptionHandler extends ChannelInboundHandlerAdapter{
public void exceptionCaught(ChannelHandlerContext ctx,
Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
一般将异常处理的ChannelHandler
放到ChannelPipeline
的最后一个位置。这样在整个处理路链中无论哪个环节出了异常都可以得到处理。因为exceptionCaught()
的默认实现是将异常转发给下一个HandlerHandler
.
处理出站异常基于以下的机制:
ChannelFuture
,注册到ChannelFuture
的ChannelFutureListener
将在操作完成时痛殴之该操作时注册成功还是出错了ChannelOutboundHandler
上的方法都会传入一个ChannelPromise
的实例,作为ChannelFuture
的子类,ChannelPromise
也可以被分配用于异步通知的监听器。但是,ChannelPromise
还具有提供立即通知的可写方法。处理方法:1
2
3
4
5
6
7
8
9
10ChannelFuture future = channel.write(someMessage);
future.addListener(new ChannelFutureListener() {
public void operationComplete(ChannelFuture f) {
if (!f.isSuccess()) {
f.cause().printStackTrace();
f.channel().close();
}
}
});
另一种方法:
1 | public class OutboundExceptionHandler extends ChannelOutboundHandlerAdapter { |
HTTP重定向负载均衡需要一台重定向服务器。它的功能就是根据用户的HTTP请求根据负载均衡算法选择一个真实的服务器地址,并将服务器地址信息写入到重定向响应中返回给用户浏览器。用户浏览器再获取到响应之后,根据返回的信息,重新发送一个请求到真实的服务器上。
优点:
实现比较简单
缺点:
浏览器需要请求两次服务器才能完成一次访问,性能较差。
DNS域名解析负载均衡是在进行域名解析的时候分局负载均衡算法选择一个合适的I{P地址返回,这样来实现负载均衡。大型网站一般将DNS域名解析负载均衡作为第一级负载均衡的手段
优点:
缺点;
通过反向代理服务器来选择合适的服务器作为目的服务器进行请求的转发。来实现负载均衡
IP负载均衡又称为网络层负载均衡,它和原理就是通过内核驱动更改IP的目的地址来完成数据负载均衡。
原理图:
IP负载均衡在内核进程完成数据分发,处理性能得到了很好的提高。但是由于所有请求和响应都要经过负载均衡服务器,集群的最大响应数据吞吐量将受到负载均衡服务器网卡带宽的限制。
数据链路层负载均衡通过修改通信协议数据包的mac地址进行负载均衡。
这种三角传输模式的链路层负载均衡是目前大型网站使用比较广泛的负载均衡手段。在Linux平台下最好的链路层负载均衡开源产品时LVS(Linux Virtual Server)。
将请求按照顺序轮流的分配到后端服务器上,它均衡地对待后端的每一台服务器,而不关心服务器实际的连接数和当前的系统负载。
通过随机算法,根据后端服务器的列表大小指来随机的选择其中的一台服务器进行访问。
源地址哈希的思想是根据获取客户端的IP,通过hash函数计算得到的一个数值,用改数值对服务器列表的大小进行取模运算,得到的结果便是客服端要访问的服务器的序列。采用源地址哈希法进行负载均衡,同一IP地址的客户端,当后端服务器列表不变时,它每次都会映射到同一台后端服务器进行访问。
不同的后端服务器可能机器的配置和当前系统的负载并不相同,因此它们的抗压能力也不相同。给配置高、负载低的机器配置更高的权重,让其处理更多的请;而配置低、负载高的机器,给其分配较低的权重,降低其系统负载,加权轮询能很好地处理这一问题,并将请求顺序且按照权重分配到后端。
与加权轮询法一样,加权随机法也根据后端机器的配置,系统的负载分配不同的权重。不同的是,它是按照权重随机请求后端服务器,而非顺序。
最小连接数算法比较灵活和智能,由于后端服务器的配置不尽相同,对于请求的处理有快有慢,它是根据后端服务器当前的连接情况,动态地选取其中当前积压连接数最少的一台服务器来处理当前的请求,尽可能地提高后端服务的利用效率,将负责合理地分流到每一台服务器。
url_hash
加权随机
一致性hash算法
ByteBuf
是Netty的数据容器,它解决了JDK API的局限性,能为网络应用程序的开发者提供更好的API支持。ByteBuf
API的优点如下:
BuyteBuffer
的flip()
方法ByteBuf
内部维护了两个不同的索引,一个用于读取,一个用于写入。
最常见的ByteBuf
模式,是将数据存储到JVM的堆空间中。这种模式被称为支持数组。它能够在没有使用池化的情况下,提供较为快速的分配和释放。1
2
3
4
5
6
7
8ByteBuf heapBuf = ...;
//检查是否是数组支撑
if (heapBuf.hasArray()) {
byte[] array = heapBuf.array();
int offset = heapBuf.arrayOffset() + heapBuf.readerIndex();
int length = heapBuf.readableBytes();
handleArray(array, offset, length);
}
直接缓冲区是指内存空间是通过本地调用分配而来的。因此直接缓冲区的内容将驻留在堆外。
通过使用直接缓冲区,避免了依次将JVM堆中的缓冲区复制到直接缓冲区的国产,因此效率更高。但是直接缓冲区的创建和释放的成本比较高。1
2
3
4
5
6
7
8
9ByteBuf directBuf = ...;
if (!directBuf.hasArray()) {
//如果不是数组支撑,那么就是一个直接缓冲区
int length = directBuf.readableBytes();
byte[] array = new byte[length];
//从直接缓冲区中读取数据到array中
directBuf.getBytes(directBuf.readerIndex(), array);
handleArray(array, 0, length);
}
复合缓冲区可以为多个ByteBuf
提供一个聚合视图。我们可以根据需要添加或删除ByteBuf
实例。Netty通过ByteBuf
的一个子类CompositeByteBuf
来实现复合缓冲区。
1 | public static void byteBufComposite() { |
和普通的Java数组一样,ByteBuf
的索引也是从零开始的。1
2
3
4
5ByteBuf buffer = ...;
for (int i = 0; i < buffer.capacity(); i++) {
byte b = buffer.getByte(i);
System.out.println((char)b);
}
ByteBuf
同时具有读索引和写索引,因此两个索引把ByteBuf
分为了三个部分。分贝是可丢弃字节区,可读字节区,可写字节区。
查找ByteBuf
指定的指。可以利用indexOf()
来直接查询,也可以利用ByteProcessor
作为参数来查找某个指定的值。
1 | public static void byteProcessor() { |
派生缓冲区为ByteBuf
提供了以专门的方式来呈现其内容的视图,这类视图是通过以下方法被创建的:
1 | duplicate(); |
ByteBufHolder
是ByteBuf
的容器,可以通过子类实现ByteBufHolder
接口,根据自身需要添加自己需要的数据字段。可以用于自定义缓冲区类型扩展字段。ByteBufHolder
接口提供了几种用于访问底层数据和引用计数的方法。1
2
3content();//返回持有的所有的ByteBuf
copy();//返回一个深拷贝
duplicate();//返回一个浅拷贝
为了降低分配和释放内存的开销,Netty通过ByteBufAllocator
实现ByteBuf
池化。
如何获取ByteBufAllocator
实例:
1 | Channel channel = ...; |
在不能获取到ByteBufAllocator
中情况下,可以使用Unpooled
获取缓冲区。
引用计数是一种通过在某个对象所持有的资源不再被其它对象引用时释放该对象所持有的资源来优化内存使用和性能的计数。
一个ReferencceCounted
实现的实例通常以活动的引用计数为1作为开始。只要引用计数大于0,旧能保证对象不会被释放。当活动引用的数量减少到0时,该实例就会被释放。
1 | ByteBuf buffer = ... |
零拷贝是指在操作数据的时候,不需要将数据buffer从一个内存区域拷贝到另一个内存区域,因为少了一次内存的拷贝,因此CPU的效率就得到了较大的提升。
OS层面的零拷贝通常是指避免在用户态与内核态之间来回进行数据拷贝。比如Linux提供了mmap
系统调用,它可以将用户内存空间映射到内核空间。这样用户对这段内存空间的操作就可以直接反映到内核。
Netty层面的零拷贝主要体现在这几个方法:
CompositeByteBuf
,它可以将多个ByteBuf
合并为一个逻辑上的ByteBif
,避免了ByteBuf
之间的拷贝。通过wrap
操作,我们可以将byte[]
数组,ByteBuf
、ByteBuffer
包装为一个ByteBuf
对象,进而避免了拷贝操作。
1 | byte[] bytes = ... |
ByteBuf
支持 slice
操作, 因此可以将 ByteBuf
分解为多个共享同一个存储区域的 ByteBuf
, 避免了内存的拷贝.
1 | ByteBuf byteBuf = ... |
通过 FileRegion
包装的FileChannel.tranferTo
实现文件传输, 可以直接将文件缓冲区的数据发送到目标 Channel
, 避免了传统通过循环 write 方式导致的内存拷贝问题.
1 | RandomAccessFile srcFile = new RandomAccessFile(srcFileName, "r"); |
Netty的线程模型实际上就是Reactor模型的一种实现。
Reactor模型是基于事件驱动开发的,核心组成部分是一个Reactor和一个线程池,其中Reactor负责监听和分配事件,线程池负责处理事件。根据Reactor的数量有线程池的数量,又可以将Reactor分为三种模型:
selector
轮询连接,收到事件后,通过dispatch
进行分发。Acceptor
处理,Accepter
通过accept
接受连接,并创建一个Headler
来处理连接后的各种事件。Headelr
进行处理。selector
监控连接事件,收到事件后通过dispatch
进行分发。Accepter
负责处理,它会通过accept
接受请求,并创建一个Headler
来处理后序事件,而Headler
只负责相应事件,不进行业务操作,也就是只进行read
读取数据和write
写出数据,业务处理是交给线程池进行处理。Handler
进行转发。Reactor
,每个Reactor
都有自己的selector
选择器,线程和dispatch
mainReactor
通过自己的selector
监控连接建立事件,收到事件后通过Accepter
接受,将任务分配给某个子线程。subReactor
将mainReactor
分配的连接加入连接队列中通过自己的selector
进行监听,并创建一个Handler
用于处理后序事件。Handler
完成read
->业务处理->send
的完整业务流程。在Netty中主要是通过NioEventLoopGroup
线程池来实现具体的线程模型的。
单线程模型就是指定一个线程执行客户端连接和读写操作,也就是在一个Reactor
中完成。对应的实现方式就是将NioEventLoopGroup
线程数设置为1.
Netty中是这样构造单线程模型的:
1 | NioEventLoopGroup group = new NioEventLoopGroup(1); |
多线程模型就是当Reactor
进行客户端的连接处理,然后业务处理交由线程池来执行。
Netty中是这样构造多线程模型的:
1 | NioEventLoopGroup eventGroup = new NioEventLoopGroup(); |
主从多线程模型是有多个Reactor
,也就是有多个selector
,所以我们定义一个bossGroup
和一个workGroup
在Netty中是这样构建主从多线程模型的:
1 | NioEventLoopGroup bossGroup = new NioEventLoopGroup(); |
相较于多线程模型,主从多线程模型不会遇到处理连接的瓶颈问题。在多线程模型下,因为只有一个NIO的Acceptor
来处理连接请求,所以会出现性能瓶颈。
在Netty线程模型中,NioEventLoop
是比较关键的类。下面我们对它的实现进行分析。
它的继承关系图如下:
NioEventLoop
需要处理网络IO请求,因此有一个多路复用器Selector
:
1 | private Selector selector; |
并且在构造方法中完成了初始化:
1 | NioEventLoop(NioEventLoopGroup parent, Executor executor, SelectorProvider selectorProvider, |
在NioEventLoop
中run()
方法比较的关键:
1 | protected void run() { |
重建Selector
的方法如下:
1 | private void rebuildSelector0() { |
处理IO请求的是由processSelectedKey
完成的,它的实现如下:
1 | private void processSelectedKey(SelectionKey k, AbstractNioChannel ch) { |
1 | private final class NioMessageUnsafe extends AbstractNioUnsafe { |
当 NioEventLoop 读取数据的时候会委托给 Channel 中的 unsafe 对象进行读取数据。
Unsafe中真正读取数据是交由 ChannelPipeline 来处理。
ChannelPipeline 中是注册的我们自定义的 Handler,然后由 ChannelPipeline中的 Handler 一个接一个的处理请求的数据。
作者:jijs
链接:https://www.jianshu.com/p/9e5e45a23309
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
内存管理无非就是解决三个问题:
现在的内存管理方案就是引入虚拟内存这一中间层。虚拟内存位于程序和物理内存之间,程序只能看见虚拟内存,不能直接访问物理内存。每个程序都有自己独立的虚拟地址空间,这样就做到了进程地址空间的隔离。
引入了虚拟地址技术后,我们需要解决如何将虚拟地址映射到物理地址。这主要有分段和分页两种技术。
这种方法的基本思路是将程序所需要的内存地址空间大小的虚拟空间映射到某个物理地址空间。
分段机制使得每个进程具有独立的进程地址空间,保证了地址空间的隔离性。同时它也解决了程序重定位的问题,因为程序始终是在虚拟地址空间下运行的。
分段机制同样也存在许多的问题,因为它映射的粒度太大,是以程序为单位的,如果内存不足,那么只能换出整个程序。这样内存的使用效率就很低。
分页机制就是将内存地址空间分为若干各很小的固定大小的页,每一页的大小由内存来决定。这样映射的粒度更小了,根据局部性原理,我们只需要在内存中保存少部分的页,大部分的页都可以换到磁盘中去。
页式存储管理能够有效的提高内存利用率,而分段存储管理能反映程序的逻辑结构并有利于段的共享
段页式管理就是将程序分为多个逻辑段,在每个段里面又进行分页,即将分段和分页组合起来使用。
在段页式系统中,作业的逻辑地址分为三部分:段号、页号和页内偏移量。
为了实现地址变化,系统为每个进程维护了一个段表,每个分段又有一个页表。段表中包含段号、页表长度和页表的起始地址。页表中包含页号和块号。系统还有一个段表寄存器,存储段表的起始地址和段表长度。
在进行地址变化式,通过段表查到页表的起始地址,然后再通过页表查到物理块的地址。
]]>最初在Unit系统中,在使用fork()
系统调用创建子进程的时候,会复制父进程的整个地址空间并把复制的那一份分配给子进程。这种情况比较耗时。因为它需要:
创建一个地址空间的这种方法涉及许多内存访问,消耗许多CPU周期,并且完全破环了高速缓存中的内容。在大多数情况下,这种做法常常时毫无意义的,因为许多子进程通过装入一个新的程序开始它们的执行,这样就完全丢弃了所继承的地址空间。
写时复制技术时一种可以推迟甚至避免拷贝数据的技术。内核不需要复制整个地址空间,而是让父子进程共享同一个地址空间,只用在需要写入的时候才会复制地址空间,从而使各个进程拥有自己的地址空间。
写时复制
内核只为新生成的子进程创建虚拟空间结构,它们复制于父进程的虚拟空间结构,但是不为这些段分配物理内存,它们共享父进程的空间,当父进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间。
vfork()
这个方案直接利用父进程的虚拟地址空间,vfork()
并不会把父进程的地址空间完全复制给子进程,因为子进程会立即调用exec
或exit
,也就不会访问该地址空间了。在子进程调用exec
之前,它在父进程空间中运行。vfork()
保证子进程先运行,在子进程调用exec
或exit
之后父进程才能调度运行。
进程就是处于执行期的程序。进程是处于执行期的程序以及相关资源的总称。
线程是进程中的活动对象,每个线程都拥有一个独立的程序计数器、进程栈和一组进程寄存器。
内核调度的对象是线程而不是进程。
内核吧进程的列表存放在叫做任务队列的双向循环列表中。链表中每一项的类型都是task_struct
、被称为进程描述符。该结构包含了内核管理一个进程所需的所有信息。
Linux通过slab分配器分配task_struct结构,这样能够达到对象复用和缓存着色的目的。
在2.6之前为了减少对寄存器的使用,task_struct
存储在内核栈的尾端,而在2.6之后使用slab分配器动态生成task_stuct
,所以只需要在栈第或栈顶创建一个thread_info
,它的内部包含了task_struct
等信息。
每个任务的thread_info
结构在它的内核栈的尾端分配。结构中task
域中存放的是指向该任务实际task_struct
的指针。
内核通过一个唯一的进程标识符或PID来标识每个进程。
在内核中,任务访问通常需要获得指向其task_struct
的指针。内核使用current
宏可以计算出当前进程task_struct
的指针。
进程描述符中state域描述了进程的当前状态。系统中每个进程都必然处于五种状态中的一种。
ptrace
对调试程序进行跟踪。一般的程序是在用户空间执行,当程序进行了系统调用或触发了某一异常,那么它就会陷入到内核空间。此时,我们称内核“代表进程执行”并处于进程上下文中。在此上下问中current宏是有效的。
在Unix操作系统中进程的创建分为两步完成,第一步是调用fork()
方法拷贝当前进程创建一个子进程(父子进程的唯一区别就是PID),然后调用exec()
方法载入可执行文件并开始执行。
而Linux对整个进程的创建做出了优化:
写时拷贝
写时拷贝时一种可以推迟甚至免除拷贝数据的技术,在创建进程的时候,内核并不需要再一开始就复制整个进程地址空间,而是让父子进程共享一个拷贝。只有再需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是所,资源的复制只有在需要写入的时候才进行,在此之前,只是以只读的方式共享,这种技术使得地址空间上的页的拷贝被推迟到实际发生写入的时候才进行,在页根本不会被写入的情况下(比如fork后立即调用exec)它们就无需复制了。
也就是只有进程空间的各段的内容要发生变化时,才将父进程的内容复制一份给子进程。
fork()
Linux通过clone()
系统调用来实现fork()
。然后clone()
去调用do_fork()
。do_fork()
完成了创建中的大部分工作,该函数调用了copy_process()
函数,然后让进程开始运行。copy_process()
函数完成的工作如下:
dup_task_struct()
为新建成创建一个内核栈,thread_info
结构和task_struct
,这些只与当前进程的只相同。此时,子进程和父进程的描述符使完全相同的。TASK_UNINTERRUPTIBLE
,以保证它不会投入运行。copy_process()
调用copy_flags()
以更新task_struct
的flag成员。alloc_pid()
为新进程分配一个有效的PID。clone()
的参数标志,决定拷贝或共享打开的资源。copy_process
做扫尾工作并返回一个指向子进程的指针。vfork()
除了不拷贝父进程的页表项外,vfork()
系统调用和fork()
的功能相同。子进程作为父进程的一个单独的线程在他的地址空间里运行,父进程被阻塞,直到子进程退出或执行exec()
.
vfork()
系统调用的实现是通过项clone()
系统调用实现的:
copy_process()
时,task_struct
的vfor_done
成员被设置为NULL。do_fork()
时,如果给定特别标志,则vfork_done
会指向特定地址。vfork_done
指针向它发送信号。mm_release()
时,该函数用于进程退出内存地址空间,并且检查vfork_done
是否为空,如果不为空,则会向父进程发送信号。do_fork(),
父进程醒来并返回。线程机制时现代编程技术中常用的一种抽象概念,该机制提供了在同一程序内共享内存地址空间运行的一组线程。这些线程还可以共享打开的文件或其它资源,线程机制支持并发程序设计技术,在多处理器系统上,它也能保证真正的并行处理。
在Linux中的内核实现中,它不严格区分线程和进程,线程仅仅被视为一个与其它进程共享某些资源的进程。
线程的创建于普通进程的创建类似,只不过在调用clone()
的时候徐娅传递一些参数标志来指明需要共享的资源。
内核进程需要在后台执行一些操作,这些操作由内核线程来完成。
内核线程和普通的进程的区别在于内核线程没有独立的地址空间。它们只在内核空间运行,从来不切换到用户空间去。内核进程和普通进程一样,可以被调度,也可以被抢占。内核线程也只能由其它内核线程创建。
当一个进程终结时,内核必须要释放它所占有的资源并通知其父进程。
进程终结时,最终大多都是通过do_exit()
来完成的。它主要完成了以下工作:
task_struct
中的标志成员设置为PF_EXITING
del_timer_sync()
删除任一内核定时器,根据返回的结果,保证没有定时器在排队,也没有定时器处理程序在运行。acct_update_integerals()
来输出记账信息。exit_mm()
函数释放进程占用mm_struct
,如果没有别的进程使用它,就释放。sem_exit()
函数,如果进程排队等候IPC信号,它则离开队列。exit_files()
和exit_fs()
,以分别递减文件描述符,文件系统数据的引用计数。如果引用计数降为0,那么它代表没有进程使用相应的资源,此时就可以释放。task_struct
的exit_code
成员中的任务退出代码置为由exit()
提供的退出代码。exit_notify()
向父进程发送行信号,给子进程重新找养父,养父为线程组中的其它进程或者init进程,并把进程状态设置为EXIT_ZOMBIE
.do_exit()
调用schedule()
切换到新的进程,因为处于EXIT_ZOMBIE
状态的进程不会再调度,所以这是进程所执行的最后一段代码。do_exit()
永不返回。调用do_exit()
之后,尽管线程已经僵死不能再运行了,但是系统还是保留了它们的进程描述符。这样做的好处就是有办法再进程终结后仍然能够获得它的信息。
当需要释放进程描述符的时候,会调用release_task()
.
如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父进程,否则这些成为孤儿的进程就会在退出时永远处于僵死状态,白白的消耗内存。
处理孤儿进程的方案是,首先会尝试在当前线程组内找一个线程作为父进程,如果不行,就让init
做它们的父进程。
多任务操作系统就是能够同时并发地交互执行多个进程地操作系统。
多任务系统可以划分为两类:非抢占式多任务和抢占式多任务。Linux提供了抢占式多任务模式。
在此模式下,由调度程序来决定什么时候停止一个进程的运行,以便其它进程能够得到执行的机会。这个强制挂起的动作叫做抢占。进程在被抢占之前能够运行的时间式预先分配号的,叫做进程的时间篇。
在非抢占式多任务模式下,除非进程自己主动停止运行,否则它会一直执行。进程主动挂起自己的操作称为让步。
Linux2.5之后采用了一种叫做O(1)
的调度程序。
进程可以分为IO消耗型和处理器消耗性。前者指进程的大部分时间用来提交IO请求或是等待IO请求。因此,这样的进程进程处于可运行的状态,但通常都是运行短短的一会,因为它在等待更多的IO请求时最后总会阻塞。
而处理器消耗型进程把时间大多用在执行代码上,除非被抢占,否则它门通常都一直不停的运行,因为它们没有太多的IO需求。
调递策略通常要在两个矛盾的目标中间寻找平衡:进程响应迅速(响应时间短)和最大系统利用率(高吞吐率)。
调度算法中最基本的一类就是基于优先级的调度。这是一种根据进程的价值和对处理器时间的需求来对进程分级的想法。
Linux采用了两种不同的优先级范围,第一种是用nice值,它的范围是从-20到+19
,nice值越小,优先级越高。第二种范围是实时范围,其值是可配置的,默认情况下它的变化范围是从0到99,值越大,优先级越大。
实时优先级和nice优先级处于互不相交的两个范畴。
时间片是一个数值,它表示进程在被抢占前所能持续运行的时间。调度策略必须规定一个默认的时间片。
一个处于就绪状态的进程能够进入运行态的完全是由进程优先级和是否有时间片决定的。
Linux中提供了一种CFS调度算法,即完全公平调度算法。CFS的实现思路就是根据进程的权重分配运行时间。这里的权重其实是和进程的nice值之间有一一对应的关系,可以通过全局数组prio_to_weight
来转换。
$$
分配给进程的运行时间=调度周期*进程权重/所有进程权重之和
$$
调度器实体结构
CFS不再有时间片的概念,但是它也必须维护每个进程运行的时间记账,因为它需要确保每个进程只在分配给它的时间片内运行。CFS使用调度器实体结构来最终进程运行记账。
虚拟实时
vruntime
变量存放进程的虚拟运行时间,该运行时间的计算是经过了所有可运行进程总数的标准化。
进程选择
当CFS需要选择下一个运行进程是,它会挑一个具有最小vruntime
的进程。CFS使用红黑树来组织可运行进程队列,并利用其迅速找到vruntime
值最小的进程。
等待队列
休眠通过等待队列进行处理。等待队列是由等待某些事件发生的进程组成的简单链表。
唤醒
唤醒操作通过函数wake_up()
进行,它会唤醒指定的等待队列上的所有进程。它调用函数wake_up()
进行,它会唤醒指定的等待队列上的所有进程。
用户抢占
用户抢占在以下情况下产生:
内核抢占
内核抢占会发生在:
schedule()
集群容错方案 | 解释 |
---|---|
Failover(默认) | 快速失败,只发起一次调用,失败立即报错。通常用于非幂等性的写操作,比如新增记录。 |
Failsafe | 安全失败,出现异常时,直接忽略,通常用于写入日志 |
Failback | 失败自动恢复,后台记录失败请求,定时重发。通常用于消息通知等。 |
Forking | 并行调用多个服务器,只要有一个成功即放hi。通常用于对实时性要求较高的读操作。 |
Broadcast | 广播所有的调用者,逐个调用,任意一台报错即报错。通常用于通知所有的提供者更新缓存本地资源信息。 |
它的配置方式是这样的:
1 | <dubbo:service cluster="failsafe" /> |
Dubbo内部提供了4种负载均衡算法。
1 | <dubbo:service interface="com.alibaba.hello.api.WorldService" version="1.0.0" ref="helloService" |
将服务接口、服务模型、服务异常均放在API包中,因为服务模型和异常也是API的一部分,这样做服务分包原则:重用发布等价原则(REP),共同重用原则(CRP)
服务接口应该尽可能大粒度,每个服务方法应代表一个功能,而不是某功能的一个步骤,否则将面临分布式事务问题,而Dubbo并未提供分布式事务解决方案。
每个接口都应该定义版本号,为后序不兼容升级提供可能。
服务接口增加方法,或服务模型增加字段,可向后兼容,删除方法或删除字段,将不兼容,枚举类型新增字段也不兼容,需要通过变更版本号升级。
如果是完备集,可以使用Enum
如果后期可能会有变更,建议使用String
代替。
服务参数和返回值建议使用POJO对象。
建议使用异常汇报错误,而不是返回错误码,异常信息能够携带更多信息,并且语义更加友好。
不要只是因为是 Dubbo 调用,而把调用 try...catch
起来。try...catch
应该加上合适的回滚边界上。
Provider 端需要对输入参数进行校验。如有性能上的考虑,服务实现者可以考虑在 API 包上加上服务 Stub 类来完成检验。
因为作为服务的提供方,比服务消费方更清楚服务的性能。在服务提供端配置后,消费端不配置则会使用服务提供端的配置。如果在消费端进行配置,那么对服务端来讲就是不可控的。
一个配置示例:
1 | <dubbo:service interface="com.alibaba.hello.api.HelloService" version="1.0.0" ref="helloService" |
建议在服务提供端配置的消费端属性有:timeout
、retries
、 loadbalance
actives
比如这样:1
2
3
4
5<dubbo:protocol threads="200" />
<dubbo:service interface="com.alibaba.hello.api.HelloService" version="1.0.0" ref="helloService"
executes="200" >
<dubbo:method name="findAllPerson" executes="50" />
</dubbo:service>
建议在服务提供端配置的提供端属性有:threads
(服务线程池的大小)、executes
(一个服务提供者并行执行请求的上限)
1 | <select id = “ selectPerson” parameterType = “ int” resultType = “ hashmap” > |
这里有两点需要注意的,一是区分#
和$
的区别.#
的底层是基于预编译语句来实现了,这样可以避免SQL注入的风险。而$
在底层是通过字符串的直接拼接来实现了,因此有SQL注入的风险。
上面是常见的select
的用法,实际上它还有许多其它的属性供我们选用。
比如:
1 | <select |
下面列举了一些相对比较常用的属性:
属性 | 描述 |
---|---|
id | |
parameterType | 将传递到该语句中的参数的标准类名或别名 |
resultType | 从该语句返回的结果预期的标准类名或别名 |
resultMap | 对外部resultMap的引用,用于结果集映射 |
flushCache | 若将该属性设置为true,那么调用此语句时会刷新本地和二级缓存 |
useCache | 将此属性设置为true,这该语句的结果会被缓存到二级缓存中。默认值为true |
timeout | 超时时间 |
fetchSize | 设置成批的返回的行数 |
statementType | 设置statement的类型,有STATEMENT ,PREPARED 或CALLABLE .默认是第二种,即PreparedStatement |
这三个标签常见的用法如下:
1 | <insert |
一些属性的用法补充:
属性 | 解释 |
---|---|
useGeneratedKeys | 当设置为true的时候,使用数据库内部生成的主键。默认为false |
keyProperty | 该属性一般与useGeneratedKeys 结合使用,它标识的是Java对象的属性名。配置了该属性之后,会将数据库中自动生成的主键存到对应的java属性中。 |
keyColumn | 这几个属性一般是结合使用的,keyColumn指定数据库主键字段名。 |
sql片段是mybatis动态sql的基础。它的使用方法如下:
1 | <sql id="userColumns"> ${alias}.id,${alias}.username,${alias}.password </sql> |
这里通过属性,为sql片段传递数据,使用起来非常的灵活:
1 | <sql id="sometable"> |
结果集映射是mybatis非常重要的特性
它的简单的使用方法如下:
1 | <resultMap id="userResultMap" type="User"> |
在<resultMap>
还可以使用<constructor>
标签,通过构造器完成结果集Java对象的映射。
1 | <constructor> |
用于一对一关联查询结果映射的<assocation>
标签的使用方式:
1 | <resultMap id="blogResult" type="Blog"> |
另一个例子:
1 | <resultMap id="blogResult" type="Blog"> |
一对多的关系映射的标签<collection>
标签的使用方式:
1 | <collection property="posts" ofType="domain.blog.Post"> |
1 | private List<Post> posts; |
另一个例子是这样的:
1 | <resultMap id="blogResult" type="Blog"> |
鉴别器<discriminator>
,它可以根据
1 | <resultMap id="vehicleResult" type="Vehicle"> |
1 | <select id="findActiveBlogWithTitleLike" |
1 | <select id="findActiveBlogLike" |
1 | <trim prefix="WHERE" prefixOverrides="AND |OR "> |
1 | <select id="findActiveBlogLike" |
1 | <update id="updateAuthorIfNecessary"> |
1 | <select id="selectPostIn" resultType="domain.blog.Post"> |
Dubbo集群容错方面的源码包括四个部分,分别式服务目录Directory、服务路由Router、集群Cluster和负载均衡LoadBalance。
它们之间的关系是这样的:
服务目录中存储了服务提供者有关的信息,通过服务目录,服务消费者可以获取到服务提供者的信息,比如IP、端口、服务协议等。通过这些信息,服务消费者就可以进行远程服务调用了。服务提供者的信息是有变动的,因此服务目录中的信息也有要做相应的变更。
而服务目录中的信息,其实又是从注册中心中获取的,然后根据从注册中心中获取的信息为每条配置信息生成一个Invoker
对象。
因此简单来讲服务目录就是一个会根据注册中心的有关信息进行相应调整的Invoker
集合。
Dubbo中服务目录的继承体系如图:
针对服务目录,我们主要分析一个AbstractDirectory
和它的两个子类。
下面我们来看AbstractDirectory
的具体实现:
1 | public List<Invoker<T>> list(Invocation invocation) throws RpcException { |
AbstractDirectory
的list
方法,主要完成两件事情:
doList
获取Invoker
列表Router
的getUrl
返回值为空与否,以及runtime参数决定是否进行服务路由。这里的doList
方法其实是一个模板方法,由它的子类来负责具体的实现。
那么下面我们就来看一看它的两个子类是如何实现这个方法的。
StaticDirectory
即静态服务目录,它内部存放的Invoker
集合是不会变动的。它的源码实现如下:
1 | public class StaticDirectory<T> extends AbstractDirectory<T> { |
它的实现非常的简单。
RegistryDirectory
是一种动态服务目录,它会根据注册中心中服务配置的变化而动态的变化。因此RegistryDirectory
中比较关键的点就在于,它是如何进行Invoker
列举的?它是如何接收服务配置信息变更的?它是如何刷新Invoker
列表的。
1 | public List<Invoker<T>> doList(Invocation invocation) { |
Invoker
的列举逻辑还是比较简单的,主要就是从localMethodInvokerMap
中获取对应的Invoker
RegistryDirectory
是一个动态服务目录,会随注册中心配置的变化而进行动态调整,因此RegistryDirectory
实现了NotifyListener
接口,通过这个接口获取注册中心变更通知。
1 | public synchronized void notify(List<URL> urls) { |
notify
方法首先是根据 url
的 category
参数对 url
进行分门别类存储,然后通过 toRouters
和 toConfigurators
将url
列表转成 Router
和Configurator
列表。最后调用 refreshInvoker
方法刷新 Invoker
列表。
refreshInvoker 方法是保证 RegistryDirectory 随注册中心变化而变化的关键所在。
1 | private void refreshInvoker(List<URL> invokerUrls) { |
refreshInvoker
方法首先会根据入参 invokerUrls
的数量和协议头判断是否禁用所有的服务,如果禁用,则将 forbidden
设为 true
,并销毁所有的 Invoker
。若不禁用,则将 url
转成 Invoker
,得到 <url, Invoker>
的映射关系。然后进一步进行转换,得到 <methodName, Invoker 列表>
映射关系。之后进行多组 Invoker
合并操作,并将合并结果赋值给 methodInvokerMap
。methodInvokerMap
变量在 doList
方法中会被用到,doList
会对该变量进行读操作,在这里是写操作。当新的 Invoker
列表生成后,还要一个重要的工作要做,就是销毁无用的 Invoker
,避免服务消费者调用已下线的服务的服务。
到此就实现了Invoker
的刷新。
服务路由就是包含一条路由规则,路由规则决定了服务消费者的调用目标,即规定了服务消费者可调用可调用哪些服务提供者。Dubbo目前提供了三种服务路由实现,分别是条件路ConditionRouter
、脚本路由ScriptRounter
和标签路路由TagRounter
。其中条件路由是我们最常用的。
下面我们就以条件路由为例进行源码分析。
条件路由规则有两个条件组成,分别用于对服务消费者和提供者进行匹配。比如有这样一条规则:
host=10.20.153.10 => host=12.20.153.11
这条规则表明IP10.20.153.10
的服务消费者只能调用IP为10.20.153.11机器上的服务,不可调用其它机器上的服务。条件路由规则的格式如下:
1 | [服务消费者匹配条件] => [服务提供者匹配条件] |
路由规则是一条字符串表达式,在进行路由之前会先进行条件表达式解析,具体的解析过程这里就不看源码了。
只需要知道通过解之后,得到一个Map<String, MatchPair> condition
.解析后的信息,以这样的格式进行表示:
1 | { |
服务路由的入口方法是ConditionRouter
的route
方法,该方法定义在Router
接口中,实现代码如下:
1 | public <T> List<Invoker<T>> route(List<Invoker<T>> invokers, URL url, Invocation invocation) throws RpcException { |
route
方法先是调用 matchWhen
对服务消费者进行匹配,如果匹配失败,直接返回 Invoker
列表。如果匹配成功,再对服务提供者进行匹配,匹配逻辑封装在了 matchThen
方法中。
为了避免单点故障,现在应用通常至少会部署在两台服务器上。对于一些负载比较高的服务,会部署更多的服务器。对于服务消费者来说,同一环境下出现了多个服务提供者。这时会出现一个问题,服务消费者需要决定选择哪个服务提供者进行调用。另外服务调用失败时的处理措施也是需要考虑的。为了处理这些问题,Dubbo定义了集群接口Cluster
以及Cluster Invoker
.集群Cluster
用途是将多个服务提供者合并为一个 Cluster Invoker
,并将这个 Invoker
暴露给服务消费者。这样一来,服务消费者只需通过这个 Invoker
进行远程调用即可,至于具体调用哪个服务提供者,以及调用失败后如何处理等问题,现在都交给集群模块去处理。集群模块是服务提供者和服务消费者的中间层,为服务消费者屏蔽了服务提供者的情况,这样服务消费者就可以专心处理远程调用相关事宜。
集群工作过程可分为两个阶段,第一个阶段是在服务消费者初始化期间,集群 Cluster
实现类为服务消费者创建 Cluster Invoker
实例,即上图中的merge
操作。
第二个阶段是在服务消费者进行远程调用时。以 FailoverClusterInvoker
为例,该类型 Cluster Invoker
首先会调用Directory
的list
方法列举 Invoker
列表(可将 Invoker
简单理解为服务提供者)。Directory
的用途是保存 Invoker
,可简单类比为 List<Invoker>
。其实现类 RegistryDirectory
是一个动态服务目录,可感知注册中心配置的变化,它所持有的 Invoker
列表会随着注册中心内容的变化而变化。每次变化后,RegistryDirectory
会动态增删 Invoker
,并调用 Router
的 route
方法进行路由,过滤掉不符合路由规则的 Invoker
。当 FailoverClusterInvoker
拿到Directory
返回的 Invoker
列表后,它会通过LoadBalance
从 Invoker
列表中选择一个 Invoker
。最后 FailoverClusterInvoker
会将参数传给 LoadBalance
选择出的 Invoker
实例的 invoke
方法,进行真正的远程调用。
Dubbo集群提供了以下几种容错机制:
1 |
|
Cluster
的实现类负责生成Cluster Invoker
.
1 | public class FailoverCluster implements Cluster { |
它的实现类的逻辑比较简单.
我们首先从各种 Cluster Invoker
的父类 AbstractClusterInvoker
源码开始说起。前面说过,集群工作过程可分为两个阶段,第一个阶段是在服务消费者初始化期间,即服务引出。第二个阶段是在服务消费者进行远程调用时,此时AbstractClusterInvoker
的 invoke
方法会被调用。列举 Invoker
,负载均衡等操作均会在此阶段被执行。因此下面先来看一下 invoke
方法的逻辑。
1 | public Result invoke(final Invocation invocation) throws RpcException { |
AbstractClusterInvoker
的 invoke
方法主要用于列举Invoker
,以及加载LoadBalance
,最后在调用模板方法doInvoke
进行后序操作。
下面我们来看FailoverClusterInvoker
是如何实现doInvoke
的,它在调用失败后,会自动切换Invoke
进行重试。它是缺省的Cluster Invoker
实现。
1 | public class FailoverClusterInvoker<T> extends AbstractClusterInvoker<T> { |
FailoverClusterInvoker
的 doInvoke
方法首先是获取重试次数,然后根据重试次数进行循环调用,失败后进行重试。在 for 循环内,首先是通过负载均衡组件选择一个 Invoker
,然后再通过这个 Invoker
的 invoke
方法进行远程调用。如果失败了,记录下异常,并进行重试。重试时会再次调用父类的list
方法列举 Invoker
。
在选择Invoker
的时候,使用了select
方法主要就是对粘滞连接特性的处理。它的实现如下:
1 | protected Invoker<T> select(LoadBalance loadbalance, Invocation invocation, List<Invoker<T>> invokers, List<Invoker<T>> selected) throws RpcException { |
从这段代码我们也可以轻松的明白什么是粘滞连接。
在这个方法中又调用了doSelect
方法,这个方法的作用就是根据负载均衡策略选择合适的Invoker
.
1 | private Invoker<T> doSelect(LoadBalance loadbalance, Invocation invocation, List<Invoker<T>> invokers, List<Invoker<T>> selected) throws RpcException { |
doSelect
主要做了两件事,第一是通过负载均衡组件选择 Invoker
。第二是,如果选出来的 Invoker
不稳定,或不可用,此时需要调用 reselect
方法进行重选。若 reselect
选出来的 Invoker
为空,此时定位 invoker
在invokers
列表中的位置 index
,然后获取index + 1
处的 invoker,这也可以看做是重选逻辑的一部分。
负责重选的reselect
方法的实现如下:
1 | private Invoker<T> reselect(LoadBalance loadbalance, Invocation invocation, |
reselect
方法总结下来其实只做了两件事情,第一是查找可用的 Invoker
,并将其添加到 reselectInvokers
集合中。第二,如果 reselectInvokers
不为空,则通过负载均衡组件再次进行选择.
还有一些容错处理的实现类,这里就不分析了。
LoadBalance
中文意思为负载均衡,它的职责是将网络请求,或者其他形式的负载“均摊”到不同的机器上。避免集群中部分服务器压力过大,而另一些服务器比较空闲的情况。通过负载均衡,可以让每台服务器获取到适合自己处理能力的负载。
Dubbo提供了4种负载均衡的实现:
RandomLoadBalance
:基于权重随机算法LeastActiveLoadBalance
:基于最少活跃连接数算法ConsistentHashLoadBalance
:基于一致性hash算法RoundRobinLoadBalance
:基于加权轮询算法在Dubbo种所有的负载均衡策略均是AbstractLoadBalance
的子类,该类实现了LoadBalance
接口,并封装了一些公共逻辑。
下面我们来分析一下AbstractLoadBalance
中的公共逻辑。
整个负载均衡的入口方法select
的实现:
1 |
|
它还提供了计算服务提供者权重的计算方法getWeight
1 | protected int getWeight(Invoker<?> invoker, Invocation invocation) { |
上面是权重的计算过程,该过程主要用于保证当服务运行时长小于服务预热时间时,对服务进行降权,避免让服务在启动之初就处于高负载状态。服务预热是一个优化手段,与此类似的还有 JVM 预热。主要目的是让服务启动后“低功率”运行一段时间,使其效率慢慢提升至最佳状态。
下面我们就以Dubbo的默认负载均衡策略RandomLoadBalance
的实现为例来分析这些负载均衡策略是如何实现的。
RandomLoadBalance
是加权随机算法的具体实现,它的算法思想很简单。假设我们有一组服务器 servers = [A, B, C]
,他们对应的权重为 weights = [5, 3, 2]
,权重总和为10。现在把这些权重值平铺在一维坐标值上,[0, 5)
区间属于服务器 A
,[5, 8)
区间属于服务器 B
,[8, 10)
区间属于服务器 C
。接下来通过随机数生成器生成一个范围在[0, 10)
之间的随机数,然后计算这个随机数会落到哪个区间上.基于这个思路,它的代码实现也是非常简单的。
1 | public class RandomLoadBalance extends AbstractLoadBalance { |
到此这个Dubbo集群的源码就分析完毕了。
]]>Dubbo服务调用的基本过程如图:
首先服务消费者通过代理对象Proxy发起远程调用,接着通过网络客户端将编码后的请求发送给服务提供方的网络层上。Server收到请求之后,首先要做的就是对数据包进行解码。然后将解码后的请求发送至分发器,再由分发器将请求发送到指定的线程池上,最后由线程池调用具体的服务。这就是一个远程调用请求的发送过程。
我们知道服务消费端是通过为接口生成的代理对象进行服务调用的,他的代理对象的实现如下:
1 | public class proxy0 implements ClassGenerator.DC, EchoService, DemoService { |
这个代理类的实现逻辑比较简单,首先将参数存储到数组中,然后调用InvocationHandler
的实现类的invoke
方法,然后得到一个调用结果,最后将这个结果返回给调用端。
也就是说这个代理类只做了三件事情:
invoke
方法,进行服务调用下面我们来分析这个InvocationHandler
的实现类的代码实现;
1 | public class InvokerInvocationHandler implements InvocationHandler { |
这个类的invoke
的实现逻辑非常的简单,如果调用的是Object中的一些方法,那么直接进行处理即可(这些方法根本不需要远程调用),否则通过Invoker
接口的实现类的invoke
方法进行远程调用。
这里的Invoker
的实现类其实是MockClusterInvoker
,它的内部封装了服务降级的逻辑,下面我们来看它的具体实现:
1 | public class MockClusterInvoker<T> implements Invoker<T> { |
这里主要是对服务降级的处理,如果没有配置服务降级的逻辑,那么直接进行远程调用;如果服务降级信息配置为force
那么直接降级,不发起远程调用;如果服务降级信息配置为fail
,那么会尝试进行远程调用,如果失败那么就进行服务降级逻辑。
在这里我们就不深究这个服务降级的实现了,主要来看一看远程调用。在这个类中进行远程调用使用的是实现了Invoker
接口的AbstractInvoker
。
1 | public abstract class AbstractInvoker<T> implements Invoker<T> { |
上面的代码的主要逻辑就是将信息添加到RpcInvocation#attachment
,然后调用doInvoke
执行后序逻辑。doInvoke
是一个抽象方法,由子类DubboInvoker
实现。
1 | public class DubboInvoker<T> extends AbstractInvoker<T> { |
这段代码包含了Dubbo对同步调用和一步调用的处理逻辑。Dubbo实现同步调用和异步调用比较关键的点是由谁负责调用ResponseFuture
的get
方法,同步调用模式下是由框架来调用get
方法的,而异步调用是由用户来调用get
方法的。
这个方法主要完成的就是利用client
进行远程调用,并处理同步调用和异步调用的细节。
下面我们来看一看ResponseFuture
的一个默认实现DefaultFuture
.
1 | public class DefaultFuture implements ResponseFuture { |
其实这段代码非常逻辑非常的简单,如果服务消费者还没有收到结果时,那么调用get
方法,就会被阻塞。同步调用模式下,是由框架来执行get
方法的,它会阻塞直至收到结果。而异步模式下将该对象封装到FutureAdapter
对象中,然后设置到RpcContext
中,供用户使用。这个适配器的主要作用就是将Dubbo
的ResponseFuture
和JDK
中的Future
进行适配,这样用户可以调用Future
的get
方法的时候经过了FutureAdapter
的适配,最终调用ResponseFuture
的get
方法。
到此,整个执行的流程如下:
通过之前的源码分析,我们可以看出服务调用是通过client
发送请求完成的,下面我们就来分析,这个请求发送的具体流程。
在之前提到的client
其实就是实现ExchangeClient
接口的ReferenceCountExchangeClient
.
1 | final class ReferenceCountExchangeClient implements ExchangeClient { |
ReferenceCountExchangeClient
内部主要进行的是引用计数的处理,其它均调用的是被装饰对象的相关方法。
1 | public class HeaderExchangeClient implements ExchangeClient { |
HeaderExchangeClient
封装了心跳检测逻辑,然后通过调用HeaderExchangeChannel
对象的同签名方法。下面我们来分析HeaderExchangeChannel
的代码实现:
1 | final class HeaderExchangeChannel implements ExchangeChannel { |
这个类的request
方法,首先定义了一个Request
对象,然后再将该对象传给NettyClient
的send
方法,进行后序的调用。而NettyClient
本身并没有实现send
方法,这个方法是通过继承AbstractPeer
得到得。
1 | public abstract class AbstractPeer implements Endpoint, ChannelHandler { |
再默认得情况下,Dubbo
使用得是Netty作为底层得通信框架,下面我们来分析一下
NettyClient类中
getChannel`方法得实现逻辑。
1 | public class NettyClient extends AbstractClient { |
拿到NettyChannel
实例之后,就可以进行后序的调用。
1 | public void send(Object message, boolean sent) throws RemotingException { |
到此调用请求就发出去了,当然再Netty中还有出站数据的编码操作,这里就不分析了。
整个调用路径是这个样子的:
1 | proxy0#sayHello(String) |
前面说过,默认情况下 Dubbo 使用 Netty 作为底层的通信框架。Netty 检测到有数据入站后,首先会通过解码器对数据进行解码,并将解码后的数据传递给下一个入站处理器的指定方法。
这里直接分析请求数据的解码逻辑,忽略中间过程.
1 | public class ExchangeCodec extends TelnetCodec { |
上面方法通过检测消息头中的魔数是否与规定的魔数相等,提前拦截掉非常规数据包,比如通过 telnet 命令行发出的数据包。接着再对消息体长度,以及可读字节数进行检测。最后调用 decodeBody 方法进行后续的解码工作,ExchangeCodec 中实现了 decodeBody 方法,但因其子类 DubboCodec 覆写了该方法,所以在运行时 DubboCodec 中的 decodeBody 方法会被调用。下面我们来看一下该方法的代码。
1 | public class DubboCodec extends ExchangeCodec implements Codec2 { |
如上,decodeBody 对部分字段进行了解码,并将解码得到的字段封装到 Request 中。随后会调用 DecodeableRpcInvocation 的 decode 方法进行后续的解码工作。此工作完成后,可将调用方法名、attachment、以及调用参数解析出来。下面我们来看一下 DecodeableRpcInvocation 的 decode 方法逻辑。
1 | public class DecodeableRpcInvocation extends RpcInvocation implements Codec, Decodeable { |
上面的方法通过反序列化将诸如 path、version、调用方法名、参数列表等信息依次解析出来,并设置到相应的字段中,最终得到一个具有完整调用信息的 DecodeableRpcInvocation 对象。
到这里,请求数据解码的过程就分析完了。
解码器将数据包解析成 Request 对象后,NettyHandler 的 messageReceived 方法紧接着会收到这个对象,并将这个对象继续向下传递。这期间该对象会被依次传递给 NettyServer、MultiMessageHandler、HeartbeatHandler 以及 AllChannelHandler。最后由 AllChannelHandler 将该对象封装到 Runnable 实现类对象中,并将 Runnable 放入线程池中执行后续的调用逻辑。整个调用栈如下:
1 | NettyHandler#messageReceived(ChannelHandlerContext, MessageEvent) |
Dubbo将底层通信框架中接收请求的线程称为IO线程。如果一些事件处理逻辑可以很快执行完,比如只再内存打一个标记,此时直接在IO线程上执行该段逻辑即可。但如果事件的处理逻辑比较耗时,比如该段逻辑发起数据库查询或者HTTP请求。此时我就不应该让事件处理逻辑在IO线程上执行,而是应该派发到线程池中执行。原因也很简单,IO线程主要用于接收请求,如果IO线程被占满,将导致它请求接收新的请求。
在前文提到的原理图中,Dispatcher
就是线程派发器。它的真实职责是创建具有线程派发能力的Channelhandler
,比如AllChannelHandler
,MessageOnlyChannelHandler
和ExecutionChannelHandler
等,其本身并不具有线程派发能力。
Dubbo支持的不同线程派发策略
策略 | 用途 |
---|---|
all(默认) | 所有消息都派发到线程池,包括请求,响应,连接事件,断开事件等。 |
direct | 所有消息都不派发的线程池,全部在IO线程上直接执行。 |
message | 只是请求和响应消息派发到线程池,其它消息均在IO线程上执行 |
execution | 只有请求派发到线程池,不含响应。其它消息均在IO线程上执行。 |
connection | 在IO线程上,将连接断开事件放入队列,有序逐个执行,其它消息派发到线程池。 |
在默认配置下,Dubbo使用all
派发策略,即将所有的消息都派发到线程池中。下面我们来分析一下AllChannelHandler
的代码。
1 | public class AllChannelHandler extends WrappedChannelHandler { |
请求对象会被封装 ChannelEventRunnable 中,ChannelEventRunnable 将会是服务调用过程的新起点。所以接下来我们以 ChannelEventRunnable 为起点向下探索。
我们从ChannelEventRunnable
开始分析。
1 | public class ChannelEventRunnable implements Runnable { |
ChannelEventRunnable
仅仅是一个中转站,它的run方法中并不包含具体的调用逻辑,仅用于将参数传给其它的ChannelHandler
对象进行处理,该对象类型为DecodeHandler
.
1 | public class DecodeHandler extends AbstractChannelHandlerDelegate { |
DecodeHandler
主要是包含了一些解码逻辑。之前提到请求解码可以在IO线程上执行,也可以在线程池中执行,这取决于运行时配置。DecodeHandler
存在意义就是保证请求或响应对象可在线程池中被解码。解码完毕后,完全解码后的Request
对象会继续先后传递,下一站是HeaderExchangeHandler
.
1 | public class HeaderExchangeHandler implements ChannelHandlerDelegate { |
到这里,我们看到了比较清晰的请求和响应逻辑。对于双向通信,HeaderExchangeHandler 首先向后进行调用,得到调用结果。然后将调用结果封装到 Response 对象中,最后再将该对象返回给服务消费方。如果请求不合法,或者调用失败,则将错误信息封装到 Response 对象中,并返回给服务消费方。接下来我们继续向后分析,把剩余的调用过程分析完。下面分析定义在 DubboProtocol 类中的匿名类对象逻辑,如下:
1 | public class DubboProtocol extends AbstractProtocol { |
以上逻辑用于获取与指定服务对应的 Invoker 实例,并通过 Invoker 的 invoke 方法调用服务逻辑。invoke 方法定义在 AbstractProxyInvoker 中,代码如下。
1 | public abstract class AbstractProxyInvoker<T> implements Invoker<T> { |
如上,doInvoke 是一个抽象方法,这个需要由具体的 Invoker 实例实现。Invoker 实例是在运行时通过 JavassistProxyFactory 创建的,创建逻辑如下:
1 | public class JavassistProxyFactory extends AbstractProxyFactory { |
Wrapper 是一个抽象类,其中 invokeMethod 是一个抽象方法。Dubbo 会在运行时通过 Javassist 框架为 Wrapper 生成实现类,并实现 invokeMethod 方法,该方法最终会根据调用信息调用具体的服务。以 DemoServiceImpl 为例,Javassist 为其生成的代理类如下。
1 | /** Wrapper0 是在运行时生成的,大家可使用 Arthas 进行反编译 */ |
到这里,整个服务调用过程就分析完了。最后把调用过程贴出来,如下:
1 | ChannelEventRunnable#run() |
服务提供方调用指定服务后,会将调用结果封装到 Response 对象中,并将该对象返回给服务消费方。服务提供方也是通过 NettyChannel 的 send 方法将 Response 对象返回。具体的就不再分析了。
服务消费方在收到响应数据后,首先要做的事情是对响应数据进行解码,得到 Response 对象。然后再将该对象传递给下一个入站处理器,这个入站处理器就是 NettyHandler。接下来 NettyHandler 会将这个对象继续向下传递,最后 AllChannelHandler 的 received 方法会收到这个对象,并将这个对象派发到线程池中。这个过程和服务提供方接收请求的过程是一样的,因此这里就不重复分析了。本节我们重点分析两个方面的内容,一是响应数据的解码过程,二是 Dubbo 如何将调用结果传递给用户线程的。下面先来分析响应数据的解码过程。
响应数据解码的逻辑主要封装在DubboCodec
中。
1 | public class DubboCodec extends ExchangeCodec implements Codec2 { |
解码之后,通过DecodeableRpcResult
进行调用结果的反序列化。
1 | public class DecodeableRpcResult extends RpcResult implements Codec, Decodeable { |
响应数据解码完成后,Dubbo 会将响应对象派发到线程池上。要注意的是,线程池中的线程并非用户的调用线程,所以要想办法将响应对象从线程池线程传递到用户线程上。我们之前分析过用户线程在发送完请求后的动作,即调用 DefaultFuture 的 get 方法等待响应对象的到来。当响应对象到来后,用户线程会被唤醒,并通过调用编号获取属于自己的响应对象。下面我们来看一下整个过程对应的代码。
1 | public class HeaderExchangeHandler implements ChannelHandlerDelegate { |
以上逻辑是将响应对象保存到相应的 DefaultFuture 实例中,然后再唤醒用户线程,随后用户线程即可从 DefaultFuture 实例中获取到相应结果。
为什么要有调用编号?
一般情况下,服务消费方会并发调用多个服务,每个用户线程发送请求后,会调用不同 DefaultFuture 对象的 get 方法进行等待。 一段时间后,服务消费方的线程池会收到多个响应对象。这个时候要考虑一个问题,如何将每个响应对象传递给相应的 DefaultFuture 对象,且不出错。答案是通过调用编号。
]]>锁是数据库系统区别于文件系统的一个关键特性,锁机制用于管理对共享资源的并发访问。
latch一般称为闩锁,因为其要求锁定的时间必须非常短,则应用的性能会非常的差。在InnoDB存储引擎中,latch由可以分为matuex(互斥量)和rwlock(读写锁)。其目的是用来保证并发线程操作临界资源的正确性,并且通常没有死锁检查机制。
lock的对象是事务,用来锁定的是数据库中的对象,如表、页、行。并且一般lock的对象仅在事务commit或rollback后进行释放。此外lock是有死锁检查机制的。
InnoDB存储引擎实现了两种标准的行级锁:
InnoDB存储引擎支持多粒度锁定,这种锁定允许事务在行级锁和标记锁上的锁定同时存在。为了支持不同粒度上进行加锁操作,InnoDB存储引擎支持一个额外的锁方式,称为意向锁。意向锁是将锁定的对象分为多个层次,意向锁意味着事务希望在更细粒度上进行加锁。
InnoDB存储引擎支持意向锁设计比较简练,其意向锁即为表级别的锁。设计目的主要是为了在一个事务中揭示下一行将被请求的锁类型。其支持两种意向锁:
一致性非锁定读是指InnoDB存储引擎通过多版本控制的方式来读取当前执行时间数据库中行的数据。如果读取的行正在执行DELETE或UPDATE操作,这式读取操作不会因此区等待行上锁的释放。相反的,InnoDB存储引擎会区读取行的一个快照数据。快照数据是只该行的之前版本的数据,该实现是通过indo断来完成,而undo用来在事务中回滚数据,因此快照数据本身是没有额外的开销。此外,读取快照数据是不需要上锁的,因为没有事务需要对历史的数据进行修改操作。这种并发控制,其实就是多版本并发控制MVCC。
在默认的配置下,事务的隔离级别为可重复读,innoDB存储引擎的select操作使用一致性非锁定读,但是在某些情况下,用户需要显示地对数据库读取操作进行加锁一保证数据逻辑地一致性。而这个时候就需要数据库支持加锁语句。InnoDB存储引擎对于select语句支持两种一致性地锁定读操作。
select … for update(对读取地行加一个X锁)
select …lock in share mode(对读取地行加一个S锁)
在InnoDB存储引擎地内存结构中,对每个含有自镇长只地表都有一个自增长计数器。档含有自增长计数器地表进行插入操作时,这个计数器会被初始化。插入操作会根据这个自增长地计数器加一赋予自增长列。这个实现方式叫 AUTO-INC Locking
.这种锁其实是采用一种特殊地表锁机制,为了提高插入地性能,锁不是在一个事务完成后才释放,而是在完成对自增长值插入地SQL语句后立即释放。
外键的主要用于引用完整性的约束检查,在InnoDB存储引擎中,对于一个外键列,如果没有显示地对这个列加索引,InnoDB存储引擎自动对其加一个索引,因为这样可以避免表锁。
对于外键值地插入或更新,首先要查询父表中地记录,即select父表,但是对于父表的select操作,不是使用一致性非锁定读的方式,因为这样会发生数据不一致的问题,因此这时使用的时select … lock in share mode方式,即主动对父表加一个s锁。如果这时父表上已经加了x锁,子表上的操作会被阻塞。
InnoDB存储引擎有三种行锁的算法,其分别是:
record lock总是会区锁定索引记录,如果innodb存储引擎表在建立的时候没有设置任何一个索引,那么这时InnoDB存储引擎会使用隐式的主键来进行锁定。
幻读问题是指在同一事务中,连续两次同样的sql语句可能导致不同的结果,第二次的sql语句可能会返回之前不存在的行。InnoDB存储引擎采用Next-Key Locking的算法来避免幻读问题。
死锁是指两个或两个以上的事务在执行过程中,因争夺资源而造成的一种互相等待的现象。
解决死锁的一种方案是超时,即当两个事务互相等待时,当一个等待时间超过设置的某一阈值时,其中一个事务进行回滚,另一个继续进行。
这种超时机制虽然简单,但是其仅通过超时后对事务进行回滚的方式来处理,或者说其时根据FIFO的顺序选择回滚对象。当若超时的事务所占的权重较大,如果事务操作更新了很多行,占用了较多的undo log,这时FIFO的方式,就显得不合适了,因为回滚这个事务的时间相对于另一个事务所占用的时间可能会很多。
因此除了超时机制之外,当前数据库还普遍采用了等待图的方式来进行死锁检测。较之超时的解决方案,这是一种更为主动的死锁检测方式。InnoDB存储引擎也采用这种方式。等待图要求数据库保存以下两种信息。
通过上述信息可以够着出一张图,而在这个图种若存在回路,就代表存在死锁,因此资源间互相发生等待。
锁升级时至当前锁粒度降低,举例来说,数据库可以把一个表的1000个行锁升级未页锁,或者间页锁升级未表锁。如果在数据库的设计中,认为锁是一种稀有资源,而且想要避免锁的开销,那数据库中会频繁出现锁升级现象。
InnoDB存储引擎不存在锁升级这个问题,因为其不是根据每个记录来产生行锁的,相反,其根据每个事务访问的每个页对锁进行管理的,采用的是位图的方式,因此不管一个事务锁住页中一个记录还是多个记录,其开销都是一致的。
]]>SpringBoot因为内置了tomcat或jetty服务器,不需要直接部署War文件,所以SpringBoot的程序起点是一个普通的主函数。主函数如下:
1 |
|
整个SpringBoot的启动过程其实都是通过@SpringBootApplication
注解和SpringApplication.run
方法来实现的。
整个启动的过程可以概括为:
META-INF/spring.factories
文件,该文件指明了哪些依赖可以被自动加载。importSelector
类选择加载哪些依赖,使用conditionOn
系列注解排除掉不需要的配置文件比如spring-boot-2.1.8RELEASE.jar
中的spring.factories
文件的内容是整个样子的(节选):
1 | # PropertySource Loaders |
这个文件中的内容最终会被解析为Map<K,List<V>>
这种格式。键和值都是一个类的全限定名。
我们从这段代码开始跟踪SpringApplication.run(SpringbootStudyApplication.class, args);
这段代码经过重重调用最终来到了:
1 | public static ConfigurableApplicationContext run(Class<?>[] primarySources, String[] args) { |
这个方法完成了SpringApplication
的实例化。具体的实例化过程如下:
1 | public SpringApplication(ResourceLoader resourceLoader, Class<?>... primarySources) { |
整个Application
的实例化过程中,下面这两行代码比较关键。
1 | setInitializers((Collection) getSpringFactoriesInstances(ApplicationContextInitializer.class)); |
方法的具体实现如下:
1 | private <T> Collection<T> getSpringFactoriesInstances(Class<T> type, Class<?>[] parameterTypes, Object... args) { |
这里的loadFactoryNames
方法,其实就是从我们之前提到的spring.factories
读取数据,然后以
Map的形式进行存储的,
loadFactoryNames就是从这个
Map`中取数据(类的全限定名)。
读取spring.factories
的具体实现:
1 | private static Map<String, List<String>> loadSpringFactories( ClassLoader classLoader) { |
拿到需要加载的类的全限定名之后,就通过反射进行实例化,然后返回。在Application
的构造器中,拿到这些对象后,存入到List中。
1 | private List<ApplicationContextInitializer<?>> initializers; |
到这个地方,我们已经拿到了所有的依赖类,那么SpringBoot是如何进行自动配置的呢?
其实前面我们看到的源码都是SpingApplication
的实例化,整个实例化过程就完成了依赖类信息,而run
方法其实就是完成装配的。具体的看下面的分析:
1 | public ConfigurableApplicationContext run(String... args) { |
run
方法中最关键的就是refreshContext(context);
。它实际上是调用了refresh
方法,这个方法对应读过Spring源码的同学不会陌生。而我们bean的装配过程实际上就是由它完成的。
1 | public void refresh() throws BeansException, IllegalStateException { |
其中invokeBeanFactoryPostProcessors
会解析@import
注解,并根据@import
的属性进行下一步操作。
1 | protected void invokeBeanFactoryPostProcessors(ConfigurableListableBeanFactory beanFactory) { |
1 | public static void invokeBeanFactoryPostProcessors( |
invokerBeandefintionRegistryPostProcessors
函数对每一个定义类的后置处理器分别进行应用,@Configure
的解析就在这个函数中。
1 | // 从注册表中的配置类派生更多的bean定义 |
进入最关键的类ConfigurationClassPostProcessor
,这个类用户来注册所有的@Configure
和@Bean
, 它的processConfigbeanDefinitions
函数如下:
1 | public void processConfigBeanDefinitions(BeanDefinitionRegistry registry) { |
在解释候选集parser.parse(candidates)
中,会调用suorceClass=doProcessConfigurationClass(configClass,sourceClass)
方法依次解析注解,得到所有的候选集。该方法顺次解析@PropertySource
,@componentScan
,@Import
,@importResource
,@Bean
父类。
解析完成之后,会找到所有以 @PropertySource
、@ComponentScan
、@Import
、@ImportResource
、@Bean
注解的类及其对象,如果有 DeferredImportSelector
,会将其加入到 deferredImportSelectorHandler
中,并调用 this.deferredImportSelectorHandler.process()
对这些 DeferredImportSelector
进行处理。
实际上,在 spring boot 中,容器初始化的时候,主要就是对 AutoConfigurationImportSelector
进行处理。
Spring 会将 AutoConfigurationImportSelector
封装成一个 AutoConfigurationGroup
,用于处理。最终会调用 AutoConfigurationGroup
的 process
方法。
1 |
|
如上,我们可以看到 process
最终调用了我们非常熟悉的函数 SpringFactoriesLoader.loadFactoryNames(getSpringFactoriesLoaderFactoryClass(), getBeanClassLoader());
,该方法以 EnableAutoConfiguration
类为键(org.springframework.boot.autoconfigure.EnableAutoConfiguration
),取得所有的值。
在该函数中,还会调用 configurations = filter(configurations, autoConfigurationMetadata)
方法,将不需要的候选集全部排除。(该方法内部使用 AutoConfigurationImportFilter
的实现类排除)。
我们看一个常见的 configuration
,即 org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration
,这个类中有大量的 @Bean
注解的方法,用来产生 bean,如下:
1 |
|
spring 通过读取所有所需要的 AutoConfiguration
,就可以加载默认的上下文容器,实现自动注入。
@Configuration
作用于类,用于定义配置类,可替换xml配置文件,被注解的类内部包含有一个或多个被@Bean
注解的方法,这些方法会被AnnotationConfigApplicationContext
或AnncationConfigWebApplicationContext
类进行扫描,并用于构建bean定义,初始化spring容器。
@ComponentScan
该注解会扫描@Controller
,@Service
,@Repository
,@component
注解到类到spring容器中。
@SpringBootApplication
该注解包含了@ComponentScan
注解,所以在使用中我们可以通过@SpringBootApplication
注解的scanBasepackages属性进行配置。
@Conditional
该注解作用于类,它可以根据代码中的条件装载不同的bean,在设置注解之前类需要实现Condition
接口,然后对该实现接口的类设置是否装载条件。
@Import
通过导入的方式实现吧实例加入spring容器中,可以在需要时间没有被spring管理的类导入至Spring容器中。
@ImportResource
和@Import
类似,区别就是该注解导入的是配置文件。
Component
该注解是一个元注解,意思是可以注解其它类注解,如@Controller
@Service
@Repository
。带此注解的类被看作组件,当使用基于注解的配置和类路径扫描的时候,这些类就会被实例化。
@SpringBootApplication
这个注解是Spring Boot最核心的注解,用在SpringBoot的主类上,标识这是一个Spring Boot应用。用来开启Spring Boot的各项能力,实际上这个注解@Configuration
,@EnableAutoConfiguration
,@ComponentScan
三个注解的组合.由于这些注解一般都是一起使用的。
@EnableAutoConfiguration
允许Spring Boot自动配置注解,开启这个注解之后,Spring Boot就能根据当前类路径下的包或者类来配置Spring Bean。配置信息是从META-INF/spring.factories
加载的。
创建Maven的普通Java项目
1 | mvn archetype:create |
创建Maven的Web项目:
1 | mvn archetype:create |
编译源代码
1 | mvc compile |
编译测试代码
1 | mvn test-compile |
运行测试
1 | mvc test |
产生site
1 | mvn site |
打包
1 | mvn package |
在本地的仓库中安装jar
1 | mvc install |
清除产生的项目
1 | mvn clean |
上传到私服
1 | mvn deploy |
mvn compile
与mvn install
,mvn deloy
的区别
mvn compile
编译类文件mvn install
包含mvn compile
,mvn package
然后上传到本地仓库mvn deploy
包含mvn install
然后上传到私服新建代码库
1 | git init # 在当前目录生成一个git代码库 |
增加/删除文件
1 | git add [file] # 添加指定文件到暂存区 |
代码提交
1 | git commit -m [message] # 提交暂存区到仓库区 |
分支管理
1 | git branch # 列出所有的本地分支 |
标签
1 | git tag # 列出所有tag |
查看信息
1 | git status # 显示有变更的文件 |
远程同步
1 | git remore update # 更新远程仓库 |
撤销
1 | git checkout [file] #恢复缓冲区的指定文件到工作区 |
当单机的redis节点无法满足要求,按照分区规则把数据分到若干个子集中。
比如有1-100个数据,要保存的三个节点上,那么1-33号数据放在第一个节点上,34-66号数据放在第二个节点上,依此类推。
对键进行hash后,根据哈希码,来进行分区。
常见的分区方式有:
虚拟槽分区的步骤:
1 | 1.把16384槽按照节点数量进行平均分配,由节点进行管理 |
虚拟槽分区的特点:
使用服务器端话管理节点,槽,数据
可以对数据进行打散,又可以保证数据分布均匀。
Redis Cluster是分布式架构,即Redis Cluster中有多个节点,每个节点购汇负责数据读写操作,每个节点之间会进行通信,
节点之间会互相通信
meet操作时节点之间完成相互通信的基础,meet操作有一定的频率和规则。
把16384个槽平均分配给节点进行管理,每个节点只能对自己负责的槽进行读写。由于每个节点之间都彼此通信,每个节点都知道另外节点负责管理的槽范围。
客户端访问任意任意节点时,对数据key按照CRC16规则进行hash运算,然后对运算结果对16383进行取余,如果余数在当前访问的节点管理的槽的范围内,则直接返回对应的数据,否则会告诉客户端去哪个节点获取数据,由客户端去正确的节点获取数据。
保证高可用,每个主节点都有一个从节点,当主节点故障,cluster会按照规则实现主备的高可用
1 | 1. 每个节点通过通信都会共享redis cluster中槽和集群中对应节点的关系 |
对集群进行扩容和缩容时,需要对槽及槽中的数据进行迁移。
当客户端向某个节点发送命令,节点向客户端返回moved异常,告诉客户端数据对应的槽节点信息。如果此时正在进行集群拓展或者缩空操作,当客户端向正确的节点发送命令时,槽及槽中数据已经被迁移到被的节点了,就会返回ask。
步骤:
1 | 1. 客户端向目标节点发送命令,目标节点中的槽已经迁移到别的节点上,此时目标节点会返回ask转向给客户端 |
Redis cluster通过pin/pong消息实现故障发现,不需要sentinel。
ping/pong不仅能传递节点与槽的对应消息,也能传递其它状态,比如:节点主从状态,节点故障等。
主观下线只代表一个节点对另一个节点的判断,不代表所有节点的认知。
1 | 1. 节点1定期发送ping消息给节点2 |
当半数以上持有槽的主节点都标记某节点主观下线时,可以保证判断的公平性。
客观下线流程:
1 | 1. 某个节点接收到其它节点发送的ping信息,如果接收到的ping消息中包含了其它pfail节点,这个节点会将主观下线消息添加到自身的故障列表中,故障列表中包含了当前节点接收到的每一个节点对其它节点的状态信息。 |
1 | 对从节点的资格进行检查,只有难过检查的从节点才可以开始进行故障恢复 |
使偏移量最大的从节点具备优先级成为主节点的条件。
对选举出来的多个从节点进行投票,选出新的主节点
当前从节点取消复制变成离节点。执行cluster del slot撤销故障主节点负责的槽,并执行cluster add slot把这些槽分配给自己
向集群广播自己的pong消息,表明已经替换了故障从节点