LinkedBlockingQueue首尾2把锁是如何保证并发安全的?
我们知道,要保证共享变量的多线程并发读写安全需要用同一把锁。但是LinkedBlockingQueue
有putLock和takeLock2把锁,是如何保证并发安全的呢?
先来看下LinkedBlockingQueue源码上的注释:
A variant of the “two lock queue” algorithm
“two lock queue” algorithm的一个变种。那什么是”two lock queue” algorithm呢?
这其实是Maged M. Michael 和 Michael L. Scott在1996年发表的论文 Simple, Fast, and Practical Non-Blocking and Blocking描述的其中一种算法
为了提高并发队列的性能,他们提出了一种lock free的无锁算法(基于CAS)。但是在某些不支持CAS指令的机器上,他们同样提出了优化的算法,就是“two lock queue”算法
如果用一把锁,不管是enqueue入队操作和dequeue出队操作,都需要获取这把唯一的排它锁。在高并发下容易引起激烈竞争导致性能下降。而我们分析下queue的2个主要操作:
可以发现,enqueue和dequeue在大部分情况下都是相互独立的,并没有发生冲突,无需锁住整个队列。我们自然而然就能想到一个锁的常见优化方法:分段锁。所以队列用头尾2个锁可以大大降低冲突的概率
但是用2把锁在临界点会有些不太好处理的逻辑:
这2种情况都需要同时操作修改头尾指针,即需要同时获取head lock
和tail lock
危险的逻辑来了,同时获取多把锁非常容易造成死锁。即使我们小心翼翼的设计这块加锁/解锁顺序,这块代码逻辑就会出现多次加锁/解锁逻辑,性能反而可能会下降
论文的算法既然有个“simple”,我们看看论文的算法是如何处理的:
伪代码(摘自论文)如下:
|
|
可以看到:队列初始化的时候new了一个空节点,这样在首次入队的时候,队列就不再是空队列,无需修改head指针。在只剩最后一个元素的时候也不是只有一个节点,无需修改tail指针。巧妙的避开了同时修改头尾指针的情况
但是这里还是有个并发问题,假设以下执行顺序:
线程A在执行完16行的时候已经将首个元素节点入队了,然而线程B在执行24行的时候很有可能没法立即读到这个最新节点。因为2个线程使用不同锁保护,且没有用类似java中的volatile的手段来保证可见性
也就是说这个算法有个可见性问题:
新入队的元素可能无法被立即读到而顺利出队
这个问题挺明显的,作者不至于没有注意。网上搜索了下,看到有位博主对这段有解释:
好在一般来讲next指针是32位数据,而现代的CPU已经能保证多线程程序中内存对齐了的32位数据读写操作的原子性,而一般来讲编译器会自动帮你对齐32位数据,所以这个不是问题
这位博主的的意思应该是:对于节点指针的赋值是一个原子操作,所以其他线程能立即读到这次操作的最新值
个人不认同这个解释,因为原子性(有关原子性/原子操作后面再专门讲下)并不代表可见性!一个原子操作只是代表这个操作是不可分割,不可中断,其他线程不会读到操作一半的值。最典型的例子:在32位多核机器读写long类型的值可能存在问题,因为写long操作是被拆分成写高32位和写低32位两部分的,不是一个原子操作了。然而即便是读写int这种32位数据,在没有voltail或者锁的保护下,也仍然无法做到可见性!
个人看法:这个算法应该确实存在可见性问题。但是,出现的条件比较严苛,必须要队列为空的时候入队,同时有出队线程在读。然而即使出现这种情况,也只会使出队操作表现为无数据,并不影响queue的正确性:
我们看下LinkedBlockingQueue
是怎么处理这块的吧
先贴下put()
和take()
的源码(jdk-1.8):
|
|
逻辑和论文基本一致,所以看起来也有一样的可见性问题。请看以下执行序列:
在这个执行序列中,线程A本质上已经将节点入队了,且更新了队列容量。但是由于队列节点无法保证可见性,似乎也没法被线程B立即读到。如果真的是这样,第46行代码是很可能会报NullPointerException的,这肯定不是一个库会有的行为
回过头来再仔细看了一遍注释(源码第97行)和代码:
|
|
其实LinkedBlockingQueue
的注释中有专门对于可见性的解释说明:
当一个元素入队,获取了
putLock
并且count
更新后。随后的出队线程保证能读到最新入队的节点需要用以下2种的任意一种方法:
- 方法一:出队线程用时获取
putLock
:这块大家都能理解,相当于获取2把锁,必然能保证可见性- 方法二:获取
takeLock
后,执行n = count.get()
即可保证可见性
之前对于方法二为什么能保证可见性一直没有想明白,为什么执行下count.get()
就能保证节点的可见性。直到最近看到《深入Java虚拟机(第三版)》出了,突然想到volatile的语义(count是个AtomicInteger,对于AtomicInteger的读写本质上都是读写一个volatile变量):
附加规则:如果是写操作,会强制将本处理器的缓存写入了主内存,该写入动作也会引起别的处理器或者别的内核无效化(Invalidate)其缓存
基于第2点语义可以得出:当线程1执行到17后,由于完成了volatile的写操作,将强制将缓存的修改写入主内存,因此对于入队节点的更新操作也会立即写入主内存,并且其他核的相关缓存会失效。因此到线程2执行到43行的时候必然能读到最新的值!
volatile的这个技巧的典型应该场景如下:
|
|
假设线程A执行init()
后,线程B立即执行了start()
。如果没有volatile的第2条语义保证,线程B执行第14行代码,使用的config很可能没有初始化好或者初始化好了,但是对线程B还不可见。这样很可能执行报错!
“two lock queue” 算法并没有保证线程可见性,而使queue的enqueue入队操作对dequeue出队立即可见,存在不可预知的延迟。然而基于此算法的LinkedBlockingQueue
利用volatile的“禁止重排序”语义保证了可见性
举一反三一下,使用同样技巧的还有FutureTask
:
|
|
学习到了:
当使用volatile变量后,其他变量的修改要保证可见性其实已经无需额外使用volatile修饰,只需要在使用的时候优先读写这个volatile即可
另外,看到大哥李(Doug Lea)的博客上说,LinkedBlockingQueue
这段500行的代码写了将近1年。深深的感叹:
]]>并发编程从来都不是一件简单的事,性能优化的路上也没有终点
最近在开发自研日志平台的日志采集agent。这里记录下高可用设计的一些思考和技术细节。
当遇到发送到下游失败需要重试的场景。我们如何合理设计重试?如何优雅重试呢?
首先,我们需要明确重试的本质是什么?
重试的本质是我们认为这个故障是暂时的,而不是永久的,所以我们会去重试
因此,我们需要清晰的定义什么情况下需要重试,什么情况下重试是没有意义的
对于日志采集这个场景来说,为了保证at least once
语义,大部分场景我们都需要无限重试。例如下游超时、限流,甚至宕机等。所以我们只需要定义出不需要重试的几种场景:
通常重试会设置一个上界,例如最大重试次数、最大重试间隔
对于日志采集的场景,为了保证数据的可靠投递,上界选择最大重试间隔更合理。部分情况结合了最大重试次数(例如有限重试的场景:下游未知异常)
具体如何进行重试呢?常见的方式是每次重试失败时都会“休息”一段时间再重试,以避免一窝蜂的过快重试导致下游过大的负担和无谓的资源消耗
而常见的”重修->休息->重试”的算法叫做backoff退避算法。所以什么是backoff呢?
这里用wiki中的Exponential backoff定义:
Exponential backoff is an algorithm that uses feedback to multiplicatively decrease the rate of some process, in order to gradually find an acceptable rate
简单来说:backoff退避算法就是一种利用反馈对某个过程的速率进行成倍的降低,以逐步找到一个可接受的速率的算法
从重试场景看,就是用某种算法,找到一个合理的重试时间,而不是所有异常请求都一窝蜂的直接去重试
backoff使用场景是在网络中的节点发送数据发送冲突的时候,等待一定时间后再发,从而避免频繁的发生冲突。通常作为避免网络堵塞的一部分用于同一数据块的重发策略
想想一下这种场景,某个rpc服务的其中一个节点挂了,突然大规模的流量全部重试打到另一个节点,另一个节点很可能瞬间也被压垮。这也是一种“惊群效应”
另一个场景是,下游服务挂了,上游一直在重试直至下游服务恢复。如果没有backoff,上游全部无限循环重试,也是一种资源浪费
为了避免这些问题,我们希望发生异常的时候不是立即重试,而且等待一定时间后再重试
所以,大部分重试、重发等场景都会利用backoff算法来降低冲突和无谓的资源消耗
backoff基本都需要设定最大重试次数或者最大间隔时间。因为无限次的重试往往没有意义,过长的间隔时间也不利于响应下游的恢复
backoff算法有以下几种常见实现方式:
固定间隔时间的退避算法。每次重试都会间隔固定的interval
时间
interval
不太好设定。设置的过小,下游长时间的故障可能造成大量资源浪费;设置的过大,对于偶现的网络抖动不能及时投递数据给定一个重试等待的最大时间maxInterval
,直接随机一个等待时间出来。范围是[0,maxInterval)
,比较暴力
基于fibonacci数列的退避算法。能较好的避免冲突,及时响应短暂的下游故障
核心算法:
|
|
输出结果如下:
|
|
指数退避算法。也就是每次重试的间隔时间都是指数增长的。那为什么是指数增长呢?
可以从指数分布来看:
指数分布是独立事件的时间间隔的概率分布
指数分布满足下图:
可以看到,随着间隔时间变长,事件的发生概率急剧下降,呈指数式衰减
所以,指数退避算法随着重试次数的增加,时间间隔变长,发生冲突的概率是非常低的。因此很适合作为backoff的一种实现算法
核心代码如下:
|
|
minInterval
: 表示初始的时间间隔。例如10ms
factor
: 表示指数因子。例如2
attempts
: 表示重试的次数
输出结果如下:
|
|
指数抖动退避算法,就是弥补指数退避算法的缺点。每次计算出下一次重试的间隔时间的时候加上一定的随机抖动时间,使同一时间需要重试的请求错开
核心代码:
|
|
minInterval
: 表示初始的时间间隔。例如10ms
factor
: 表示指数因子。例如2
attempts
: 表示重试的次数
jitterFactor
: 表示抖动的因子。例如0.5
输出结果如下:
|
|
自己用go实现了exponential jitter backoff
:传送门
欢迎大家使用和反馈
理解了重试的本质后,我们只需要根据特定场景选择合适的重试backoff算法即可。backoff的实现有很多,大部分场景exponential jitter backoff
都能胜任
分享我参与严选技术工作组的日志平台项目中的时候,在日志收集agent这块遇到的一些问题,深入到每个底层细节和大家谈谈。
日志agent对于使用日志平台的用户来说,是一个黑盒。对于用户来说,agent有些不好的地方:
所以我觉得日志收集agent对大家来说是一个阴暗潮湿的地底世界:
就让我举起火把,照亮所有dark、dirty、creepy的地方。
日志收集我们知道是在宿主服务器通过一个agent来收集日志数据,并且将收集到的数据源源不断的发送到日志平台的下游链路消费。
正是因为日志收集agent是整个日志平台的唯一数据来源,所以日志收集的地位非常重要。一旦日志收集agent出现问题,轻则影响后续链路的报警和查询,重则影响宿主服务器,反客为主,影响更为重要的应用系统。
所以,先来看看我们选型agent的时候有些什么阴暗的地方:
由于日志收集agent的特殊性,我们对于agent的要求优先级由高到低如下:
低耗>稳定>高效>轻量
以此原则,我们逐步推动agent的演进。
其实日志平台一开始并没有对比太多其他的agent方案,直接就是使用的flume作为agent。
why?
基于MVP(minimum viable product)原则,日志平台第一版的选项更看重能快速上线、快速试错、快速验证。而且严选这边其实之前就存在一套日志收集系统是用的flume,我们决定先复用flume,再对其定制。然后其实还有一个更重要的原因:为了兼容一些历史问题(例如收集的日志要写到北京kafka的情况),我一开始不得不沿用flume作为agent的方案。
then?
以为我们agent就此受困于历史的漩涡中束手束脚,止步不前?不存在的,我们同步调研了filebeat的方案。来看看我们对filebeat的调研工作:
先来看下2者之间的对比:
filebeat | flume | |
---|---|---|
语言 | go | java |
包大小 | <10m | >68m |
额外依赖 | 无 | 根据source与sink的不同可能需要额外的依赖包 |
配置复杂度 | 中 | 较高 |
性能 | 高 | 低 |
资源占用 | 低 | 高 |
扩展性 | 低 | 高 |
可靠性 | 高(at-least-once) | 高(at-least-once) |
限流 | 自带,背压敏感协议 | 自定义开发扩展的一个Interceptor |
负载均衡 | 内置 | 内置 |
输入源 | 内置了几个 | 支持多样的输入源,方便的自定义扩展输入源 |
输出源 | 内置了几个 | 内置比较丰富,方便的扩展 |
权衡优劣后,我更倾向于选择filebeat作为日志收集的agent,原因如下:
同时,我们做了filebeat的压测,压测数据如下:
其结果让我们震惊,在内存占用很低的情况下(3%以下),最高cpu占用只有70%,flume(平均145%)的一半不到。这使我们以后的agent方案逐渐向filebeat倾斜。
好了,是时候来点干货了,我们来看看日志收集都有哪些问题?哪些creepy的设计?
agent如何发现哪些日志文件是要被收集的呢?主要有如下几种方式:
access_log\.\d{4}-\d{2}-\d{2}\.\d{2}
)access_log.yyyy-MM-dd.log
)日志平台使用的是占位符匹配的方式,但是后端其实是兼容正则匹配的,这是出于兼容历史的原因,后面将逐步去掉正则的匹配的方式。
解决了如何发现文件后,紧接着就会遇到另一个问题:
直觉做法肯定是轮询目录中的日志文件,显然这不是个完美的方案。因为轮询的周期太长会导致不够实时,太短又会耗CPU。
这真是一个艰难的trade-off
我们来对比下flume(以下所说的flume都是我们基于flume改造定制的yanxuan-flume)和filebeat的做法:
yanxuan-flume:轮询
flume目前是每隔500ms去轮询查找是否有新的日志文件,基本上就我们前面提到的”直觉做法”。实现简单,但是我们很难衡量这个500ms
是否是一个合适合理的值。
fliebeat:OS内核指令+轮询
filebeat的方案就完善优雅很多。依赖OS内核提供的高效指令,分别是:
来通知是否有新文件,并且辅助一个周期相对较长的轮询来避免内核指令的bug(具体参考其man page)),取长补短,低耗与高效兼得
又多了一个使用filebeat的理由
好了,现在我们已经清楚如何发现文件了,那么问题又来了,我们如何知道这个文件是否已经收集过了?如果没有收集完,应该从什么位置开始接着收集?
一般是用一个文件(这里我们称之为点位文件)来记录收集的文件名(包含文件路径)与收集位置(偏移量)的对应关系,key就是文件名称,value就是偏移量。记录到文件的好处是,在机器宕掉后修复,我们还能从文件中恢复出上次采集的位置来继续收集。如下图所示:
那么,点位文件存在什么问题呢?点位文件使用日志文件名称作为key,但是一个日志文件的名称是有可能被更改的,当文件被改名后,由于点位文件中查询不到对应的采集位置,agent会认为是一个全新的日志文件而重头重新收集。所以用文件名称不能识别一个文件。那么问题又来了:
如何识别一个文件,最简单的就是根据文件路径+文件名称。但是我们上面说了,文件很可能被改名。每个文件其实都有个inode属性(可以使用命令stat test.log
查看),这个inode由OS保证同一个device下inode唯一。所以自然而然的我们就会想到用device+inode来唯一确定一个文件。然而inode是会重新分配的,即当我们删除一个文件后,其inode是会被重复利用,分配给新创建的文件。
举个常见例子:假如日志文件配置为保留30天,那30天以前的日志文件是会被自动删除的。当删除30天前的日志文件,其inode正好分配给当天新创建的日志文件,那当天的日志是不会被收集的,因为在点位文件中记录了其采集偏移量。
我们来看看flume和filebeat是怎么做的:
yanxuan-flume:device+inode+首行内容MD5
优点:无需用户干预,能保证唯一识别一个文件
缺点:需要打开文件读取文件内容,而且首行内容MD5还是太暴力了,因为首行很可能是一个超长日志,再加上MD5,不仅耗CPU,而且判断效率有点低。
可以考虑读取首行N个字节的内容md5,但是N到底取多大呢? 越大相同的概率越小,效率越低。反过来,N越小重复的概率越大,效率越高。这又是一个艰难的trade-off啊
filebeat:device+inode
优点:判断效率高。无需打开文件读取内容即可判断
缺点:可能会误判
filebeat提供了一个配置选项来决定何时删除点位文件中的记录:
clean_inactive:72h
表示清除72h不活跃的文件对应的点位文件中的记录。基本上我们的文件都是每天(24h)滚动(rotated)的,那前一天的日志文件是不会写入的,所以设置clean_inactive:72h
是合理的。那为什么不在日志文件被删除后直接删除点位文件中对应的记录呢?因为假如我们的日志文件在一个共享的存储分区中,当这个分区消失了一会(接触不良等情况)又重新出现后,里面的所有日志文件都会重头开始重新收集,因为他们的收集状态已经从点位文件中删除了。
我觉得这是一个合理的”甩锅”给使用者的配置选项。
解决了如何标识文件,如何标识采集状态,那如何判断一个日志文件采集完了呢?采集到末尾返回EOF的时候就算采集完了,可是当采集速度大于日志生产速度的时候,很可能我们采集到末尾返回EOF后,又有新的内容写入。所以,问题就变成:
最简单通用的方案就是轮询要采集的文件,发现文件内容有更新就采集,采集完成后再触发下一次的轮询,既简单又通用。
那具体是轮询什么呢?
相比flume,filebeat又做了一个小优化,每次不会直接就打开文件,而是先比较文件的修改时间再决定是否打开文件进行收集。
不得不感叹,魔鬼在细节!低耗和高效如何兼得,filebeat处处都是细节
好了,知道该什么时候收集了,那我们具体收集的时候会遇到什么问题呢?
目前的agent默认都是单行收集的,即遇到换行符就认为是一条全新的日志。可是很多情况下,我们的一条日志是多行的,比如异常堆栈、格式化后的sql&json等。
那如何判断那几行是属于同一条日志呢?
yanxuan-flume:flume原生是不支持的,我们自己写了个插件,通过配置一条日志的开头字符S来判断。假如一行日志的开头不是S,则认为是和上一行属于同一条日志
filebeat:支持flume类似的方式,同时提供了配置项negate
:true 或 false;默认是false,匹配开头字符S的行合并到上一行;true,不匹配S的行合并到上一行。能够覆盖更多的多行日志场景。
当然还有其他相关配置来兜底合并行可能带来的问题,例如一次最多合并几行和合并行的超时时间来防止可能的内存溢出与卡死
万无一失了吗?想想多行日志的最后一行按照以上的逻辑可以正常收集吗?例如下图所示:
当多行日志收集遇到最后一行怎么收集呢?还是来比较下flume和filebeat的做法:
]]>目前业界貌似没有太好的办法来完美解决这个问题。个人觉得基于filebeat的多行合并的超时时间配置选项能够很大程度缓解这个问题,因为多行日志往往也是一次性写入的,超过一定时间写入的往往都是一条全新的日志。
程序员的自我修养
这里主要和大家分享我在开发DQC(数据质量中心)的过程中,对产品需求、架构设计、编码、自我提升这4个环节的反思与经验。
产品需求阶段,我们能做什么,该做些什么?
我认为有2点很重要:
那如何做到这2点呢?我认为有个非常重要的概念:领域通用语言
领域通用语言(Ubiquitous Language),其实是DDD(Domain-Driven Design,领域驱动设计)中一个非常重要的概念。但是我认为他适用于每个项目的开发流程。
架构设计阶段,我们应该遵循什么原则,应该注意什么?
我认为有一个原则:升维思考,降维攻击
之前网上看过一句话:
你所解决问题的复杂度决定了你技术的高度
抛开业务逻辑自身的复杂度,我认为维度越高,复杂度必然也越高。所以我们要在更高的维度思考,才能得到更大的技术挑战和更快的技术成长。
我认为的高维->低维:跨部门->跨项目->跨节点->跨线(进)程
想想我们做的技术方案、公共服务、基础类库等,能否做到跨线程、跨节点、跨项目甚至跨部门使用呢?
先和大家分享一张图:
上图表达的其实是一个4维人看3维人下楼梯。我们可以看到,4维人可以看到3维人下楼梯的全过程。所以我想说的是,我们在更高的维度,可以看到更全面的信息,看到更多的问题。
如何升维思考,我认为要做好2点:
总是递归的在更高维度思考
例如,写了一段代码,要想想,在跨线程维度,会不会有什么问题?有没有更好的方案适应多线程环境?完了之后再想想在跨节点这个维度是否会有什么问题?有没有适应跨节点维度的更好方案?以此类推…
保持对问题的饥渴和敏感
例如,popo群里的产品或者前端提的问题我往往第一时间去排查,然后总是想,为什么会出现这样的问题?我能否通过技术手段或者架构设计来屏蔽此类问题?
降维攻击这个词出自《三体》,看过《三体》的同学肯定知道。其大体流程为:
(歌者文明)先把自己改造为低维度生物,而后发动降维打击,投掷“二向箔”使得目标空间的一个维度无限蜷缩,这样,原先的那些同维度的生物,就都被无限压缩挂掉……
例如如下图降维攻击地球:
使用二向箔,无限压缩地球,导致地球上的生物都被压成纸片挂掉。
所以,我总结降维攻击的步骤,如下:
再举个通俗例子:
题外话: 这个例子改编自一个网友写的,原文是习武之人戳瞎自己的双眼再苦练,我觉的实在太蠢了,改成戴着眼罩苦练。其实一直不太明白武侠小说中练某种武功为什么会动不动就要自残,比如《xxx宝典》
大家应该都知道,这个例子其实就是《笑傲江湖》里面思过崖的桥段。
所以,降维打击最关键的一步就是别人凭什么愿意跟你进伸手不见五指的小山洞呢?
《笑傲江湖》中的做法是:让别人主动进“小山洞”!告诉整个武林,思过崖里面有各大门派的失传武功秘籍,让大家免费参观。
那我们技术如何降维攻击呢?
KISS(Keep It Simple & Stupid)原则 - 好用方便,这才是我们技术方案的二向箔。其优点如下:
coding的艺术
这里指的是理解你写的代码所需要的思维量。
你写的代码逻辑越清晰,越简洁,理解起来就越容易。
常见错误:
何谓”调包侠”?就是直接使用开源的jar包,而不用自己写相关的代码逻辑。这样有如下好处:
社区支持良好:遇到问题,可以得到社区的支持
所以,做个调包侠,轻松又愉快,高效不费劲,何乐不为呢?
我们这边的测开比其实非常低,一度达到了1:5。所以,QA同学很辛苦,我们要对他们好一点。
如何编写对测试友好的代码呢?我觉得要关注以下2点:
技术道路,如何“打怪”升级?
我经常看到开发在码代码的时候,表情狰狞。其实我在写代码的时候是非常乐在其中的。我总是想着我要设计一段多么牛逼,多么优雅的代码,用上xxx算法,不仅高效而且简洁易懂,高度可扩展。秀出强劲的coding能力、设计能力、架构能力…
秀,就完事了
那我们如何才能享受其中呢?我觉得要有如下2点:
具体怎么去做呢?
这是一个良性的过程。不仅能不断提高我们的技术能力,还能保持代码的活性,不至于变成一团大煤球僵死的代码。
为什么还是有些同学没法享受coding呢?我觉得是缺乏自信!
青天不算高,人心第一高
我觉得自信真的特别重要,即使是迷之自信,也强过没有自信。
缺乏自信的表现大致有2类:
害怕使用、尝试新技术、新设计
这类开发害怕使用尝试新技术,担心出了问题,自己解决不了,影响业务开发的进度。我觉得这个问题非常严重,你不能把你学到的技术应用起来,你永远不知道你是否掌握了,学到的是否对的,你永远没法成长。其实这也反映出一些问题:
觉得自己设计实现的代码或者方案就那样
这类开发觉得自己的代码就是:一顿操作猛如虎,一看代码渣如雏。
我经常和这类人说的是:战略上藐视所有人,战术上重视所有人。什么意思呢?就是说战略上,你要知道,你不比任何人差,你的技术也并不比任务人弱多少,你有什么好虚的呢?但是战术上,每个人都可能会有特别的想法意见值得我们去学习吸收。
总有人要赢,那这个人为什么不能是你呢?
和缺乏自信的人相反,还有一类是超级自信的那种,觉得自己牛逼坏了。那这种情况应该怎么办呢?我们先来看看一张图:
当我们自信心爆炸的时候,我们如何检验自己是在愚昧山峰,还是在平稳高原呢?我觉得要挑战自己。
其实我在工作大概2-3年的时候,突然有一天,我觉得java不就那点东西嘛,我感觉我都会了。什么并发,AQS,锁,自旋锁、重入锁、读写锁、jvm、对象内存分布、gc、框架spring等,我都会啊。我那会觉得我可以做架构师了,然后我做了一个非常明智的决定,我决定去写一个框架,彻底代替spring,以后提到java企业级开发,就是我写的框架了(然后名字都起好了,他不是叫spring嘛,我就叫summer)。然后我大概构思设计了2周,发现我找不到一个点下手,我应该用个什么思想呢?用什么高大上的技术手段呢?然后再过了几天,我突然发现,我连java多线程都不了解了,并发他背后到底做了什么事情?我一度怀疑自己失忆了…
所以,我想说,如果你觉得自己牛逼坏了,不妨多挑战一下自己,比如自己设计个框架啊、写个提升工作效率的小工具等等,你会发现一个不一样的你。
那当我们挑战自己后,陷入迷茫困顿的时候怎么办呢?看书。
付出的是价格,收获的是价值
我那会陷入迷茫的时候,我又做了一个非常明智的决定,看书。所有我感觉我不懂的地方,我都针对的买了相应的书,然后非常庆幸我把大部分书都看完了,至少现在我没有当时的那种啥都不知道,感觉掉入黑洞的焦虑。
如何针对性的看书呢?
我的做法是,一次性买3本相关领域的书。兼听则明,花几十块钱就能学到3位作者花了2-3年总结的知识,我真是太赚了!
看一本书,其实很快
很多开发跟我说,技术书都太厚了,看不完。我想说,看一本书,其实很快。我现在每天中午看10-20分钟,大概能看10页,一个月20天大概能看200页。一本稍微厚点的书也就400多页,2个月就看完了!
]]>重要的不是你学习了多少新东西,而是你改正了多少错误,弥补了多少短板,根据你的需要学习了多少新东西
之前开发的分布式唯一id比较简陋。这段时间重新设计优化,变成一个独立的项目X-UID。这里记录下设计优化的一些东西
基本算法还是基于snowflake。因为需求就是要一个long型数字,snowflake算法简单高效。
算法生成的id结构图:
说明:
核心代码如下(重点关注nextId()方法):
|
|
无法处理时间回拨:一旦发生时间回拨(特别是闰秒的时候),服务不可用或者可能生成重复的id(个人觉得这个最为致命)
以下摘自百度百科:
闰秒,是指为保持协调世界时接近于世界时时刻,由国际计量局统一规定在年底或年中(也可能在季末)对协调世界时增加或减少1秒的调整。
关于闰秒,推荐阅读:传说门
作为一个如此基础的服务,稳定性特别的重要!
瞬时流量高峰,响应时间波动较大。因为生成id的方法整个处于锁的保护下,所有线程串行执行,遇到瞬时大流量,响应时间波动较大
2个策略:
策略存在的问题:
还存在理论上的id重复问题,例如以下执行序列:
当连续遇到时间回拨,workerId耗尽时,我们选择从0开始寻找一个目前没有在使用的workerId,例如0.这时候,workerId是从大变成了小。如果同时当前机器的时间戳仍然小于使用0这个workerId生成的id使用的时间戳(基本不可能发生),那生成的id将会重复。目前没有一个比较好的解决方案,不过我们对此还是采用了一种柔性处理,这个下面讲
其实大部分情况不会有这个量级。纯当挑战下,能否进一步提高性能。
首先就是对原方法的批量改造,即允许一次性生成指定数量的id,核心代码如下:
|
|
之后的关键就是,如何提升性能来应对瞬时大并发。核心思想就是缓存,缓存部分流量低峰或者是平稳期被浪费的id。例如当某个毫秒时刻只有2个生成id的请求,相当于这一毫秒被浪费了4094个id.这部分id完全可以缓存起来应对瞬时大并发。
如下3级缓存架构:
1级缓存是个数组,并发取数据,取空后发布异步任务去2级缓存拉取数据;2级缓存是个阻塞队列,3级缓存其实是文件,会有个线程不断在空闲期调用批量生成id的方法来填充2&3级缓存。并且有另外一个线程不断读取数据推送到2级缓存
结构图如下:
说明:
1级缓存是一个环形数组,默认长度为4096,每个数组槽位采用缓存行填充,避免伪共享(伪共享请参考:传送门)。如何做到环形,就是利用cas让数组下标原子的在0~4095之间循环变动即可
数据填充方式:不是每次被取走就马上填充。而是逻辑上的分为4个分区,也就是每个分区1024个id.当整个分区的id被取走后才发布id填充异步任务。这样有2个好处,减少数组的竞争和批量填充,效率更高。那如何判断这个分区被取空了呢?就是使用(index+1)&1023==0
逻辑与操作代替相对昂贵的取模操作
处理异步拉取任务的线程池做过特定优化:会对任务去重。当同一时刻进来4096*2的id请求时,将会发布8个分区填充的任务,而事实上有一半的任务是重复的(填充同一个分区),浪费id,无谓的加重竞争,所以会对任务去重
拉取任务会先尝试(poll()方法)去阻塞队列里获取数据,如果阻塞队列为空,则调用加锁的批量生成id方法生成
结构图如下:
说明:
3级缓存是文件,默认大小3G≈4亿个id.文件顺序读顺序写,系统维护一个读偏移量,每次读取16kb=2048个id的数据量,充分利用磁盘io的预读。数据读完不删除,只有到数据全部读完的时候才会删除整个文件,重新生成一个新的空白文件
有一个定时任务线程,在空闲期不断的调用加锁的批量生成id的方法,把数据推送到2&3级缓存
还有一个定时任务线程,不断的读取文件的id推送到2级缓存阻塞队列
有了1级环形数组缓存,为何还有2级缓存?
因为1级数组缓存使用了缓存行填充,其实浪费了将近7/8的内存空间,给其分配的空间不宜过大。二级缓存是个阻塞队列,存的是原始的Long型,内存空间利用率高
而且1级缓存的数据其实是采用主动拉的模式,不能很好的利用生成id方法的空闲期
只使用2级缓存,不用1级缓存,怎么样?
效率没有2着结合的高。首先1级缓存的并发竞争点是对一个int型的数组下标的cas操作,比较的短平快。而阻塞队列竞争是在锁的保护下的,效率相对更低
不使用3级文件缓存可以吗?
不使用文件缓存将失去一些HA(高可用方面的保障)。比如文件可以保存更多的id数据,保障了应对更长时间的大并发大流量。其次,之前说的对时间回拨的柔性处理,其实是利用的文件缓存。因为workerId相对属于有限资源(只有1024个),不到万不得已,不能轻易更换。所以,当发现需要更换workerId(或者更换的workerId比当前小)的时候,我们优先读取文件缓存的id
并且当机器意外重启的时候,文件中的id能保证一段时间的高并发流量的使用
如何判定是否处于空闲期?
判定当前等待锁的线程数。默认等待的线程数大于2(可配置,并且会结合可用的cpu核数综合判定)即认为是繁忙的。
电脑配置:MacBook Pro,i5-4核,16G
性能测试使用JMH
吞吐量:
x-uid取的是最低值800w+,正常情况能跑到1200w+.提升了1倍多
这些优化其实就2个人花了2天的时间做的,有些设计还比较混乱,后面会重新整理设计一版。还有一些优化的想法暂时没有实践。比如
先完成,再完美
还有就是一个snowflake无法保证的场景,就是需要全局严格的趋势递增(一般是mq的顺序消息排序),snowflake其实是做不到的,它毫秒内无法保证这点。而X-UID只要调用批量生成id,在同一台拉取足够数量的顺序消息id是能严格保证这点的
]]>类似mysql等数据库偏爱用b+tree这个数据结构作为索引,这是为什么呢?要解释这个原因,必须先讲下计算机组成原理中的磁盘数据存取原理。
这里指普通的机械磁盘。
先看下磁盘的结构:
如上图,磁盘由盘片构成,每个盘片有两面,又称为盘面(Surface),这些盘面覆盖有磁性材料。盘片中央有一个可以旋转的主轴(spindle),他使得盘片以固定的旋转速率旋转,通常是5400转每分钟(Revolution Per Minute,RPM)或者是7200RPM。磁盘包含多个这样的盘片并封装在一个密封的容器内。上图左,展示了一个典型的磁盘表面结构。每个表面是由一组称为磁道(track)的同心圆组成的,每个磁道被划分为了一组扇区(sector).每个扇区包含相等数量的数据位,通常是512子节。扇区之间由一些间隔(gap)隔开,不存储数据。
磁盘的读写操作:
如上图,磁盘用读/写头(磁头)来读写存储在磁性表面的位,而读写头连接到一个传动臂的一端。通过沿着半径轴前后移动传动臂,驱动器可以将读写头定位到任何磁道上,这称之为寻道操作。一旦定位到磁道后,盘片转动,磁道上的每个位经过磁头时,读写磁头就可以感知到位的值,也可以修改值。对磁盘的访问时间分为寻道时间,旋转时间,以及传送时间。
由于存储介质的特性,磁盘本身存取就比主存慢很多,再加上机械运动耗费,因此为了提高效率,要尽量减少磁盘I/O,减少读写操作。为了达到这个目的,磁盘往往不是严格按需读取,而是每次都会预读,即使只需要一个字节,磁盘也会从这个位置开始,顺序向后读取一定长度的数据放入内存。这样做的理论依据是计算机科学中著名的局部性原理:
当一个数据被用到时,其附近的数据也通常会马上被使用。
程序运行期间所需要的数据通常比较集中。
由于磁盘顺序读取的效率很高(不需要寻道时间,只需很少的旋转时间),因此对于具有局部性的程序来说,预读可以提高I/O效率。
预读的长度一般为页(page)的整倍数。页是计算机管理存储器的逻辑块,硬件及操作系统往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页(在许多操作系统中,页的大小通常为4KB),主存和磁盘以页为单位交换数据。当程序要读取的数据不在主存中时,会触发一个缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的起始位置并向后连续读取一页或几页载入内存中,然后异常返回,程序继续运行。
一次磁盘I/O:读n页(因为存在预读),1页包含m个扇区。
先实际看下linux系统(centos)的一个扇区大小和一个页(逻辑块,一次I/O的大小)大小:
|
|
可以看到一个扇区大小是512B,一个页大小为4096B,也就是一个页包含8个扇区
再看看预读的扇区数量:
|
|
这里指的的是最大预读256个扇区,也就是32页。但是OS会有个自适应的过程,一般从4页(16KB)开始,在一定的时间窗口中倍增
比如红黑树。
由于数据库索引其实也是很大的,不可能全部存储在内存中,索引往往以索引文件的形式存储的磁盘上。这样的话,索引查找过程中就要产生磁盘I/O消耗,相对于内存存取,I/O存取的消耗要高几个数量级,所以评价一个数据结构作为索引的优劣最重要的指标就是在查找过程中磁盘I/O操作次数的渐进复杂度
所以,不使用平衡二叉树的原因如下:
举例:
InnoDB存储引擎中页的大小为16KB,我们假设主键类型为BIGINT(占用8个字节,8B),指针类型为8个字节。也就是说一个页(B+Tree中的一个节点)中大概存储16KB/(8B+8B)=1K≈1000个键值。也就是说一个深度为3的B+Tree索引可以维护10^3 * 10^3 * 10^3 = 10亿
条记录,而且磁盘I/O最多3次。
同样10亿条数据,红黑树的深度为30,也就是最多需要30次磁盘I/O才能查询到数据。远高于b+tree
先来看看b-tree和b+tree结构比较图:
一颗b-tree:
一颗b+tree:
b+tree相对于b-tree的区别:
所以,b+tree的优势在于:
优化前,写入速度平均3000条/s,一遇到压测,写入速度骤降,甚至es直接频率gc、oom等;优化后,写入速度平均8000条/s,遇到压测,能在压测结束后30分钟内消化完数据,各项指标回归正常。
not_analyzed
_all
字段:对于日志和apm数据,目前没有场景会使用到index.refresh_interval
:索引刷新间隔,默认为1s。因为不需要如此高的实时性,我们修改为30s – 扩展学习:刷新索引到底要做什么事情?设置段合并的线程数量:
|
|
段合并的计算量庞大,而且还要吃掉大量磁盘I/O。合并在后台定期操作,因为他们可能要很长时间才能完成,尤其是比较大的段
机械磁盘在并发I/O支持方面比较差,所以我们需要降低每个索引并发访问磁盘的线程数。这个设置允许
max_thread_count + 2
个线程同时进行磁盘操作,也就是设置为1
允许三个线程扩展学习:什么是段(segment)?如何合并段?为什么要合并段?(what、how、why)
设置异步刷盘事务日志文件:
|
|
对于日志场景,能够接受部分数据丢失。同时有全量可靠日志存储在hadoop,丢失了也可以从hadoop恢复回来
扩展学习:事务日志translog何时刷盘?具体流程是什么?
elasticsearch.yml中增加如下设置:
|
|
已经索引好的文档会先存放在内存缓存中,等待被写到到段(segment)中。缓存满的时候会触发段刷盘(吃i/o和cpu的操作)。默认最小缓存大小为48m,不太够,最大为堆内存的10%。对于大量写入的场景也显得有点小。
扩展学习:数据写入流程是怎么样的(具体到如何构建索引)?
设置index、merge、bulk、search的线程数和队列数。例如以下elasticsearch.yml设置:
|
|
设置filedata cache大小,例如以下elasticsearch.yml配置:
|
|
filedata cache的使用场景是一些聚合操作(包括排序),构建filedata cache是个相对昂贵的操作。所以尽量能让他保留在内存中
然后日志场景聚合操作比较少,绝大多数也集中在半夜,所以限制了这个值的大小,默认是不受限制的,很可能占用过多的堆内存
扩展学习:什么是filedata?构建流程是怎样的?为什么要用filedata?(what、how、why)
设置节点之间的故障检测配置,例如以下elasticsearch.yml配置:
|
|
大数量写入的场景,会占用大量的网络带宽,很可能使节点之间的心跳超时。并且默认的心跳间隔也相对过于频繁(1s检测一次)
此项配置将大大缓解节点间的超时问题
这里仅仅是记录对我们实际写入有提升的一些配置项,没有针对个别配置项做深入研究。
扩展学习后续填坑。基本都遵循(what、how、why)原则去学习。
]]>年前在v2ex遇到阿里中间件的哥们,内推面试。从2018.02.01开始到今天2018.03.20,一场浩浩荡荡,跨年,持续了1个半月时间的残酷面试终于尘埃落定。遗憾的未能加入阿里中间件部门这个大家庭,让我深感痛惜。
最终未能如愿的原因是“名额有限,有更适合的同学”。这也可能是内推大哥为了顾及我的感受的说辞,可能是最后一面面的不好。
这里凭借隐约的记忆,总结下面试经过。让我自己引以为戒,奋发自强,继续前行
主要问一些java基础。包括集合、多线程、ClassLoader、锁、juc类库等都要知道大致的原理、使用规范、约定等
根据项目,深入探讨。你需要清楚你所做项目的关键细节、优化、特点、原理。所用第三方库&中间件等的原理,即使不知道,也要有自己的想法能够说出如何代替实现!而且需要一定的技术知识的广度,对于如何选型,为何这么选型能够说出自己的理由
个人感觉着重技术深度。
从ConcurrentHashMap一路问到锁&锁优化->LongAdder->伪共享->缓存行填充->cas等诸多技术细节;
从hystrix一路问到原理->自己如何实现->如何优化->响应流编程(reactive streams);
从简单的生产者消费者模式设计到如何高效健壮实现;
等等。
纯coding。
如何倒序输出单向链表?
个人直接想法是用栈先进后出的特点,把链表数据读到栈里然后输出。
有更好的实现方式吗?
仔细一想,确实不够优雅,还好之前刷过一阵子的leetcode,一般能用栈解决的都能用递归搞定。换了一种递归实现:
具体看本人的github传送门
hr面,唯一一次现场面,一直以为是最后一面呢。
给大家抛出几大深坑问题:
这几个问题,大家深思啊,不多说。
大概晚上20:00的时候接的电面。那会刚刚游泳游了大概2,300百米,然后又没有吃饭,肚子咕咕叫。忍着接听。
主要问项目情况,然后根据一个项目,问如果量级扩大1000倍,你会怎么做?有哪些优化措施?高性能&高可用措施?
后面有点饿的眩晕,不知道怎么结束的。。。
感觉阿里更偏重扎实的基础和解决问题的创意与能力。个人感觉自己缺乏大并发、大流量下面对各种复杂问题的处理经验与解决方案,继续沉淀学习吧。
]]>公司使用基于redis setNX的分布式锁偶现失效,对此深入研究一番
大体来说,分布式锁的场景有两种:
为什么强调单节点?因为我们就一个redis主从,没有redis集群。而且redis有官方的分布式锁redlock是基于redis集群的,这个对我们不适用,而且感觉有点过重。
我们一开始的方案是基于setNX(key,value,timeout)。后来发现原来这是jedis的封装,这其实是2个redis命令:setnx+expire
。也就是说这不是个原子操作,很可能setnx成功,但是设置过期时间失败导致锁永远无法释放
翻看redlock的套路才知道,应该这样操作:
获取锁:set key randomValue NX PX 3000
redis的set操作有NX(if not exist)选项和PX(过期时间)选项,可以实现原子操作
释放锁:需要使用LUA脚本实现复合操作的原子性:
|
|
一些疑问:
必须要设置过期时间吗?
必须要。因为假如获取锁的程序阻塞/崩溃/与redis网络异常等情况,锁将永远不能被释放
必须要设置一个随机值吗?
必须要。考虑以下执行序列:
(1) 程序1获取了锁
(2) 程序1假死导致锁超时被释放
(3) 程序2获取了锁
(4) 程序1从假死中恢复,直接释放了锁(没有比较随机值,相当于把程序2的锁给释放了)
(5) 程序2的执行将得不到锁的保护
必须使用lua脚本来释放锁吗?
必须要。因为释放锁需要3步(
get->比较random value->del
),需要保证3个复合操作的原子性。也不能用redis的事务消息,因为redis没有比较和if else这样命令啊…redis要是有类似cas这种操作就好了,compare and delete一步就能搞定
这样处理的redis锁已经是万无一失了吗?其实还存在2个主要问题:
另外redlock还存在另一个问题就是强依赖于几个节点之间的系统时钟,一旦发生时钟跳跃,redlock很可能就失效
很多人说可以用更可靠的zookeeper来解决,那基于zookeeper的分布式锁真的万无一失吗?先来看看zookeeper分布式锁的套路
具体参考官网文档:传送门
获取锁:
(1) 客户端调用create()在/lock下创建临时的且有序的子节点,第一个客户端对应的子节点为/lock/lock-0000000000,第二个为/lock/lock-0000000001,以此类推
(2) 客户端调用getChildren()获取/lock下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得了锁,获取锁流程结束
(3) 否则在前一个节点调用exists()并设置watch监听节点删除消息
(4) 如果exists()返回false,重试第(2)步;否则等待节点删除通知再重试第(2)步直到获取锁
删除锁:删除创建的节点即可
这样设计有以下优点:
zookeeper实现的分布式锁相对于redis确实更完善,功能也更丰富。没有redis过期时间的问题,而且能在需要的时候让锁自动释放。然而redis存在的问题中第2点,zookeeper也依然存在。假设有以下执行序列:
(1) 客户端1创建了节点/lock/lock-0000000000并且获取到了锁
(2) 客户端1进入长时间的GC pause
(3) 客户端1与zookeeper的session过期(心跳检测失败),lock-0000000000被自动删除
(4) 客户端2创建了/lock/lock-0000000001并获得了锁
(5) 客户端1从GC pause中恢复,依然认为自己持有锁
(6) 客户端1和2都认为自己持有锁,就产生了冲突
类似这种假死造成的锁失效问题,redis和zookeeper目前貌似没有完美的解决方案
这里肯定有人会说google的Chubby可以做到完美,那就来看下Chubby对这个问题的解决方案
Chubby分布式锁有2种实现,主要是针对之前提到问题的解决和缓解
获取Chubby锁的时候,锁包含了一个sequencer,里面有个单调递增的数字,要求资源服务器在对资源做修改的时候需要检查这个sequencer
Chubby提供了2种检查方式:
这种实现方式类似乐观锁,有个版本号作为控制。但是要求有个“资源服务器”能在共享资源做修改的时候检查当前的sequencer。也就是说很可能需要修改“资源服务器(比如数据库)”对共享资源的操作方式。我觉得,绝大多数的“资源服务器”都不能做这个修改,这个完美方案不太普适
获取锁的时候,同时会设置一个lock-deploy(默认一分钟)。当Chubby服务端发现客户端被动失效后,并不是立即释放锁,而是会在lock-delay指定的时间内阻止其它客户端获得这个锁。但是正常的Release操作释放的锁可以立刻被再次获取
这种方式相当于牺牲了一定的可用性换来更普适的使用场景
google Chubby的做法貌似更加优雅一点,提供了足够的方案,把选择权留给了使用者。使用者可以根据特定的场景选择特定的解决方案。
但是Chubby没有开源,非常可惜。我们只能借鉴其思想。
个人觉得zookeeper完全可以借鉴Chubby的做法,获取锁的时候同样设置lock-deloy或者使用节点序列号作为Chubby中的sequencer,对共享资源的操作可以原子的比较sequencer
基于rocketmq 4.2.0
先不关注顺序消息和事务消息,后面独立看。
DefaultMQProducer#send(Message)
默认的是同步发送。最终调用的是DefaultMQProducerImpl#sendDefaultImpl
,直接看代码:
|
|
final long invokeID = random.nextLong();
这个invokeID仅在日志输出时标示此次调用,却可能造成性能问题。共享的random虽然是线程安全的,但是每次调用都需要循环cas操作来替换每次的随机种子,高并发下,可能造成线程饥饿
这里建议JDK7及以上使用ThreadLocalRandom
代替。(不过RocketMQ可能是因为要兼容jdk6才没有用,不过我仍然觉得即使jdk6没有现成的类,也应该自己设计一个类似的类,追求性能的路上没有终点~)
最终由MQFaultStrategy#selectOneMessageQueue
处理,源码如下:
|
|
个人感觉这块代码不够整洁。先看以下代码片段:
|
|
同时看下tpInfo.getSendWhichQueue().getAndIncrement()具体的代码:
|
|
这里有2个问题:
Math.abs(index++)
这操作绝大多数情况下没有必要的,因为getAndIncrement()
保证了其为正数。只有当在循环体内,且index取值为[Integer.MAX_VALUE-tpInfo.getMessageQueueList().size(),Integer.MAX_VALUE]
才可能需要,同时后面的if (pos < 0)
也是如此。个人觉得这段直接使用getAndIncrement会更简洁明确一点:
|
|
可以看到,精简之后,出现了对同一个对象连续调用了其实例方法,感觉有点混乱。其实这里本质就是从上一个发送成功的broker选择队列,为何要独立到MQFaultStrategy
中呢?可以仍然由TopicPublishInfo直接出一个方法selectMessageQueueWithBroker
:
|
|
进一步思考,选择一个消息队列的时候,由一个延迟容错的策略类(MQFaultStrategy
)代理,然后基本所有的选择逻辑又都在topic路由类(TopicPublishInfo
)中。总感觉有点奇怪。
个人想法:
MQFaultStrategy
应该设计为由MessageQueueSelector
和SendMessageHook
组合实现,这样就能和DefaultMQProducerImpl#send(Message msg, MessageQueueSelector selector, Object arg)
统一,流程一致。目前是
MQFaultStrategy
强耦合到了默认的消息发送流程中,一方面这个策略类难以被替换,另一方面,和其他重载的的消息发送方法流程不太一致
调用的是DefaultMQProducerImpl#sendKernelImpl
,直接看源码:
|
|
这里感觉最大的问题就是api和spi没有分离好。
扩展点(hook)不一致:有CheckForbiddenHook
和SendMessageHook
这2个hook,目前看CheckForbiddenHook
完全可以由SendMessageHook#sendMessageBefore
实现
可以定义一个发送消息的流程类SendMessageProcesser,定义消息发送的流程。然后定义一个一致的hook类,比如ProducerHook。然后出个注解定义hook类型(before或者after),在流程类中对ProducerHook组装
扩展点(hook)设置略显简陋:hook设置是通过DefaultMQProducerImpl#registerXXXHook
方法add到一个ArrayList中的,这样一方面sdk使用者这法明确添加hook的执行顺序,一方面不能精细设置某个hook必须要在某个hook之前或之后调用
可以出个注解定义顺序,然后使用类似TreeSet来排序
里面的思考都是基于个人初步阅读源码这个前提的看法,欢迎各位大神斧正!
]]>之前开发分布式追踪系统x-apm的时候,确认了2个目标:
针对第2点,用来暂存追踪数据的数据结构碰到了伪共享的问题,导致收集发送的效率不够高,所以使用的缓存行填充。
这里记录下伪共享和缓存行填充的相关内容。
一个典型的cpu cache架构:
访问速度:寄存器<L1 cache<L2 cache<L3 cache<主存
所以,充分利用它的结构和机制,可以有效的提高程序的性能
这里需要注意:一个cpu中的多核共享L3 cache,而L1、L2 cache是每个核心各自拥有的;一个缓存行一般缓存64byte大小的数据
在MESI协议中,每个Cache line有4个状态,可用2个bit表示,它们分别是:
状态 | 描述 |
---|---|
M(Modified) | 这行数据有效,数据被修改了,和主存冲的数据不一致,数据只存在本Cache中 |
E(Exclusive) | 这行数据有效,数据和内存中的数据一致,数据只存在于本Cache中 |
S(Shard) | 这行数据有效,数据和内存中的数据一致,数据存在于很多Cache中 |
I(Invalid) | 这行数据无效 |
M: 被修改(Modified)
该缓存行只被缓存在该CPU的缓存中,并且是被修改过的(dirty),即与主存中的数据不一致,该缓存行中的内存需要在未来的某个时间点(允许其它CPU读取请主存中相应内存之前)写回(write back)主存。
当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。
E: 独享的(Exclusive)
该缓存行只被缓存在该CPU的缓存中,它是未被修改过的(clean),与主存中数据一致。该状态可以在任何时刻当有其它CPU读取该内存时变成共享状态(shared)。
同样地,当CPU修改该缓存行中内容时,该状态可以变成Modified状态。
S: 共享的(Shared)
该状态意味着该缓存行可能被多个CPU缓存,并且各个缓存中的数据与主存数据一致(clean),当有一个CPU修改该缓存行中,
其它CPU中该缓存行可以被作废(变成无效状态(Invalid))。
I: 无效的 (Invalid)
该缓存行数据无效。
M(Modified)和E(Exclusive)状态的Cache line,数据是独有的,不同点在于M状态的数据是dirty的(和内存的不一致),E状态的数据是clean的(和内存的一致)。
S(Shared)状态的Cache line,数据和其他Core的Cache共享。只有clean的数据才能被多个Cache共享。
一个缓存除在Invalid状态外都可以满足cpu的读请求,一个invalid的缓存行必须从主存中读取(变成S或者E状态)来满足该CPU的读请求
数据在缓存中不是以独立的项来存储的,如不是一个单独的变量,也不是一个单独的指针。缓存是由缓存行组成的,通常是64字节,并且它有效地引用主内存中的一块地址。一个Java的long类型是8字节,因此在一个缓存行中可以存8个long类型的变量。
当缓存行加载数据的时候,会同时加载其后连续的一部分数据。所以你可以非常快速的遍历在连续的内存块中分配的任意数据结构。因此如果你数据结构中的项在内存中不是彼此相邻的(比如链表),你将得不到免费缓存加载所带来的优势。并且在这些数据结构中的每一个项都可能会出现缓存未命中
伪共享就是2个不同的数据恰好被加载到同一个缓存行中,不同cpu的核分别去修改该缓存行中的不同数据,却导致了相互竞争同一个缓存行。例如以下例子:
数据X、Y、Z被加载到同一Cache Line中,线程A在Core1修改X,线程B在Core2上修改Y。根据MESI大法,假设是Core1是第一个发起操作的CPU核,Core1上的L1 Cache Line由S(共享)状态变成M(修改,脏数据)状态,然后告知其他的CPU核,图例则是Core2,引用同一地址的Cache Line已经无效了;当Core2发起写操作时,首先导致Core1将X写回主存,Cache Line状态由M变为I(无效),而后才是Core2从主存重新读取该地址内容,Cache Line状态由I变成E(独占),最后进行修改Y操作, Cache Line从E变成M。可见多个线程操作在同一Cache Line上的不同数据,相互竞争同一Cache Line,导致线程彼此牵制影响,变成了串行程序,降低了并发性。
一般解决伪共享的方式就是缓存行填充,将频繁写的变量填充到64byte,不和其他变量加载到同一个缓存行即可。
例如以下代码:(参考Disruptor作者的博客改写而来)
|
|
PaddedLong不继承PaddingLong的时候,即没有使用缓存行填充,程序执行时间甚至2倍于填充后
JDK8后更智能,可以直接使用@sun.misc.Contended来标注需要填充的字段或者类(标注类表示,类中的所有字段都需要填充)。注意,jvm需要添加参数-XX:-RestrictContended才能开启此功能
例如JDK8中的ConcurrentHashMap:
|
|
@sun.misc.Contended虽然很智能,但是需要jvm开启特定参数。对于中间件产品来说可能手动填充更合适。请看以下常见填充方式:
|
|
以上是参考Disruptor作者的填充方式,也是很多开源产品的填充方式。
作者只填充了6个long变量,也就是PaddedLong实例对象的内存占用大小为:16(对象头大小)+6*8(填充变量大小)+1*8(被填充变量大小)=72byte>64byte
可以使用JOL工具(下载传送门)查看对象内存布局来验证我们的预想:
|
|
可以看到填充的对象确实占用了72byte。
看一下作者的解释(传送门):
I do not want the mark word to be in the cache line which can be modified by taking out locks or the garbage collector ageing the object
我不希望在缓存行中的对象头中的“mark word”在设置锁标记或者垃圾回收器在老化对象的时候被修改
这里修改对象头中的锁标记应该能理解,因为当synchronized一个对象的时候,确实会修改对象头中的锁标记,这个也很可能会造成伪共享的问题。
“garbage collector ageing the object”应该指的是对象挨过一次gc存活下来,需要修改对象头中的对象年龄。
对于作者的严谨,我服…
]]>之前开发x-apm的时候,自定义增强一个spring bean的时候,出现了一个奇怪的异常 – 找不到无参构造方法,导致bean初始化失败。原来是bytebuddy这个类库在增强类的时候,会自动增加一个synthetic的构造方法,导致spring无法找打正确的构造方法初始化。这里记录下synthetic方法和bridge方法。
synthetic方法是什么呢?先来看个实际例子:
|
|
当你创建一个嵌套类(内部类)时,顶层类的私有属性和私有方法对内部类是可见的。然而jvm是如何处理这种情况的呢?jvm可不清楚什么是内部嵌套类,什么是顶层类。jvm对所有的类都一视同仁,它都认为是顶级类。所有类都会被编译成顶级类,而那些内部类编译完后会生成…$… class的类文件,如下javac编译:
|
|
当你创建内部类时,他会被编译成顶级类。那顶层类的私有属性和私有方法是如何被外部类访问的呢?
javac是这样解决这个问题的,对于任何private的字段,方法或者构造函数,如果它们也被其它顶层类所使用,就会生成一个synthetic方法。这些synthetic方法是用来访问最初的私有变量/方法/构造函数的。这些方法的生成也很智能:只有确实被外部类用到了,才会生成这样的方法
通过反编译SyntheticTest$A.class:
|
|
有个奇怪的方法SyntheticTest.access$000(this.this$0)
,这个就是java的synthetic方法。可以用java的反射再次验证这个问题:
|
|
输出:
|
|
可以看到access$000
这个方法,确实是一个synthetic方法
synthetic方法就是java编译器(例如javac)为了实现特定需求增加的方法,不存在源码中的
bridge方法又是什么呢?看个简单例子:
|
|
输出如下:
|
|
可以看到,多出来一个方法签名一致,返回类型为Object的bridge方法,这在java语言中是不合法的,不过在jvm中是允许的。那这个bridge方法到底作了什么呢?反编译看看:
|
|
输出如下:
|
|
可以看到,这个birdge不干别的,仅仅就是调用了原始的那个方法。所以这个方法到底有什么用,为什么需要bridge方法?看一下java手册的说明:
When compiling a class or interface that extends a parameterized class or implements a parameterized interface, the compiler may need to create a synthetic method, called a bridge method, as part of the type erasure process. You normally don’t need to worry about bridge methods, but you might be puzzled if one appears in a stack trace.
如果一个类继承了一个范型类或者实现了一个范型接口, 那么编译器在编译这个类的时候就会生成一个叫做桥接方法的混合方法(混合方法简单的说就是由编译器生成的方法, 方法上有synthetic修饰符), 这个方法用于范型的类型安全处理,用户一般不需要关心桥接方法
其实是java为了泛型的向下兼容的一种手段。我们看下另一个例子:
|
|
这个类在泛型擦除后,变成如下形式:
|
|
子类的setData
方法签名和父类的已经不一致了。因此,MyNode.setData
方法其实已经不再重写(override)父类Node.setData
方法了。
为了解决这个问题,并且维持泛型类在泛型擦除后的多态性,java编译器会生成一个bridge方法
]]>服务网格是一个基础设施层,功能在于处理服务间通信,职责是负责实现请求的可靠传递。在实践中,服务网格通常实现为轻量级网络代理,通常与应用程序部署在一起,但是对应用程序透明
可以将它比作是微服务间的TCP/IP,负责服务之间的网络调用、限流、熔断、监控等功能。对于编写应用程序来说一般无须关心TCP/IP这一层(比如通过HTTP协议的RESTful应用),同样使用service mesh也就无须关心服务之间的那些原来是通过应用程序或者其他框架实现的事情,比如spring cloud,现在只要交给service mesh就可以了
另一方面,service mesh更强调由这些代理连接而形成的网络,而不仅仅是一个网络代理(sidecar)。
因为,基于框架或者类库实现的网络代理存在诸多弊端
例如spring cloud:
团队成员学习并吃透这些东西,需要大量时间与精力。然而这些技术是实现微服务化的手段,真正的目标是实现业务。时间人力可能远远不足。
微服务化我们有更艰巨的挑战:微服务拆分、边界设定、设计良好的api等
服务治理常见的功能如下:
Spring Cloud直接提供的功能是远远不够的。很多功能都需要你在Spring Cloud的基础上自己解决
微服务一个重要的特性:就是不同的微服务可以采用最适合的编程语言来编写
然而我们的框架类库,需要提供多少语言的SDK呢?
框架不可能一开始就完美无缺,所有功能都齐备,没有任何BUG,分发出去之后就再也不需要改动,这种理想状态不存在的。必然是1.0、2.0、3.0慢慢升级,功能逐渐增加,BUG逐渐被修复
然而使用者并不能都马上跟进升级,一旦客户端和服务器端版本不一致,就要非常小心维护兼容性
版本兼容性有多复杂?服务端数以百计起,客户端数以千计起,每个的版本都有可能不同。这是一个笛卡尔乘积。但是别忘了,还有一个前面说的编程语言的问题,你还得再乘个N!这种情况下,兼容性测试需要写多少个Case,这几乎是不可能的
ps:sidecar就是框架或者类库的方式
service mesh演进是一个技术栈下移的过程。形成一个独立进程,代理服务所有流量,可单独升级,对应用程序透明
这部分基于本人对service mesh初步认知,属于个人不成熟的想法,欢迎各位大牛斧正!
个人觉得,相比较框架或者类库的形式,service mesh还是有部分不好实现的功能:
熔断后的回落:
例如,service mesh熔断后的回落操作就比较局限。对于service mesh来说,服务故障的真实原因是隐蔽的,服务调用端可能只能捕获调用异常和约定来处理。而基于框架或者类库,我们有更多可选择的优雅降级方式:返回缓存值、返回缺省值甚至去调用其他不同的服务
分布式追踪系统(APM)不能精细控制到本地方法级
语言兼容与代码级精细控制,本身就是一个矛盾点。相比service mesh提供的便利,其缺点可以忽略不计
x-apm开发周期比较短,开发过程中用到的一些知识点没有深入理解,这里记录下apm用到的入口知识点 – javaagent
javaagent是Java中用来增强JVM上的应用的一种方式,这样的agent有机会修改目标应用或者应用所运行的环境。它可通过访问Java Instrumentation API来修改目标应用程序的class字节码
所以,javaagent的主要功能其实类似aop,但是其原理是加载class文件之前做拦截,直接修改字节码,或者运行时动态修改字节码;而aop是生成一个新的代理类
核心api:java.lang.instrument.Instrumentation
Instrumentation
其中一个优点就是能够让我们注册ClassFileTransformers。一个已注册的ClassFileTransformer
将拦截所有应用程序类的加载,并能够访问他们的字节码。同时,也可以修改类的字节码
入口是一个premain方法:
|
|
启动:java -javaagent:/path/to/agent.jar -jar app.jar
摘录自知乎:https://www.zhihu.com/question/41252833/answer/195901726
讨论这个问题需要从香农的信息熵开始。小明在学校玩王者荣耀被发现了,爸爸被叫去开家长会,心里悲屈的很,就想法子惩罚小明。到家后,爸爸跟小明说:既然你犯错了,就要接受惩罚,但惩罚的程度就看你聪不聪明了。这样吧,我们俩玩猜球游戏,我拿一个球,你猜球的颜色,你每猜一次,不管对错,你就一个星期不能玩王者荣耀,当然,猜对,游戏停止,否则继续猜。当然,当答案只剩下两种选择时,此次猜测结束后,无论猜对猜错都能100%确定答案,无需再猜一次,此时游戏停止(因为好多人对策略1的结果有疑问,所以请注意这个条件)。
题目1:爸爸拿来一个箱子,跟小明说:里面有橙、紫、蓝及青四种颜色的小球任意个,各颜色小球的占比不清楚,现在我从中拿出一个小球,你猜我手中的小球是什么颜色?为了使被罚时间最短,小明发挥出最强王者的智商,瞬间就想到了以最小的代价猜出答案,简称策略1,小明的想法是这样的。
在这种情况下,小明什么信息都不知道,只能认为四种颜色的小球出现的概率是一样的。所以,根据策略1,1/4概率是橙色球,小明需要猜两次,1/4是紫色球,小明需要猜两次,其余的小球类似,所以小明预期的猜球次数为:H = 1/4 * 2 + 1/4 * 2 + 1/4 * 2 + 1/4 * 2 = 2
题目2:爸爸还是拿来一个箱子,跟小明说:箱子里面有小球任意个,但其中1/2是橙色球,1/4是紫色球,1/8是蓝色球及1/8是青色球。我从中拿出一个球,你猜我手中的球是什么颜色的?小明毕竟是最强王者,仍然很快得想到了答案,简称策略2,他的答案是这样的。
在这种情况下,小明知道了每种颜色小球的比例,比如橙色占比二分之一,如果我猜橙色,很有可能第一次就猜中了。所以,根据策略2,1/2的概率是橙色球,小明需要猜一次,1/4的概率是紫色球,小明需要猜两次,1/8的概率是蓝色球,小明需要猜三次,1/8的概率是青色球,小明需要猜三次,所以小明预期的猜题次数为:H = 1/2 * 1 + 1/4 * 2 + 1/8 * 3 + 1/8 * 3= 1.75
题目3:其实,爸爸只想让小明意识到自己的错误,并不是真的想罚他,所以拿来一个箱子,跟小明说:里面的球都是橙色,现在我从中拿出一个,你猜我手中的球是什么颜色?最强王者怎么可能不知道,肯定是橙色,小明需要猜0次。上面三个题目表现出这样一种现象:针对特定概率为p的小球,需要猜球的次数 = ,例如题目2中,1/4是紫色球, = 2 次,1/8是蓝色球, = 3次。那么,针对整个整体,预期的猜题次数为: ,这就是信息熵,上面三个题目的预期猜球次数都是由这个公式计算而来,第一题的信息熵为2,第二题的信息熵为1.75,最三题的信息熵为1 * = 0
。
那么信息熵代表着什么含义呢?信息熵代表的是随机变量或整个系统的不确定性,熵越大,随机变量或系统的不确定性就越大。
题目1的熵 > 题目2的熵 > 题目3的熵。在题目1中,小明对整个系统一无所知,只能假设所有的情况出现的概率都是均等的,此时的熵是最大的。
题目2中,小明知道了橙色小球出现的概率是1/2及其他小球各自出现的概率,说明小明对这个系统有一定的了解,所以系统的不确定性自然会降低,所以熵小于2。
题目3中,小明已经知道箱子中肯定是橙色球,爸爸手中的球肯定是橙色的,因而整个系统的不确定性为0,也就是熵为0。
所以,在什么都不知道的情况下,熵会最大,针对上面的题目1~~题目3,这个最大值是2,除此之外,其余的任何一种情况,熵都会比2小。所以,每一个系统都会有一个真实的概率分布,也叫真实分布,题目1的真实分布为(1/4,1/4,1/4,1/4),题目2的真实分布为(1/2,1/4,1/8,1/8),而根据真实分布,我们能够找到一个最优策略,以最小的代价消除系统的不确定性,而这个代价大小就是信息熵,记住,信息熵衡量了系统的不确定性,而我们要消除这个不确定性,所要付出的【最小努力】(猜题次数、编码长度等)的大小就是信息熵。
具体来讲,题目1只需要猜两次就能确定任何一个小球的颜色,题目2只需要猜测1.75次就能确定任何一个小球的颜色。现在回到题目2,假设小明只是钻石段位而已,智商没王者那么高,他使用了策略1,即
爸爸已经告诉小明这些小球的真实分布是(1/2,1/4, 1/8,1/8),但小明所选择的策略却认为所有的小球出现的概率相同,相当于忽略了爸爸告诉小明关于箱子中各小球的真实分布,而仍旧认为所有小球出现的概率是一样的,认为小球的分布为(1/4,1/4,1/4,1/4),这个分布就是非真实分布。此时,小明猜中任何一种颜色的小球都需要猜两次,即1/2 * 2 + 1/4 * 2 + 1/8 * 2 + 1/8 * 2 = 2
。很明显,针对题目2,使用策略1是一个坏的选择,因为需要猜题的次数增加了,从1.75变成了2,小明少玩了1.75的王者荣耀呢。因此,当我们知道根据系统的真实分布制定最优策略去消除系统的不确定性时,我们所付出的努力是最小的,但并不是每个人都和最强王者一样聪明,我们也许会使用其他的策略(非真实分布)去消除系统的不确定性,就好比如我将策略1用于题目2(原来这就是我在白银的原因).
那么,当我们使用非最优策略消除系统的不确定性,所需要付出的努力的大小我们该如何去衡量呢?这就需要引入交叉熵,其用来衡量在给定的真实分布下,使用非真实分布所指定的策略消除系统的不确定性所需要付出的努力的大小。正式的讲,交叉熵的公式为:
,其中p_k
表示真实分布,q_k
表示非真实分布。例如上面所讲的将策略1用于题目2,真实分布:
非真实分布:
交叉熵为:
比最优策略的1.75来得大。因此,交叉熵越低,这个策略就越好,最低的交叉熵也就是使用了真实分布所计算出来的信息熵,因为此时 p_k=q_k
,交叉熵 = 信息熵。
这也是为什么在机器学习中的分类算法中,我们总是最小化交叉熵,因为交叉熵越低,就证明由算法所产生的策略最接近最优策略,也间接证明我们算法所算出的非真实分布越接近真实分布。
最后,我们如何去衡量不同策略之间的差异呢?这就需要用到相对熵,其用来衡量两个取值为正的函数或概率分布之间的差异,即:KL(f(x) || g(x)) = 现在,假设我们想知道某个策略和最优策略之间的差异,我们就可以用相对熵来衡量这两者之间的差异。即,相对熵 = 某个策略的交叉熵 - 信息熵(根据系统真实分布计算而得的信息熵,为最优策略),公式如下:
KL(p || q) = H(p,q) - H(p)
=
所以将策略1用于题目2,所产生的相对熵为2 - 1.75 = 0.25
.
仅以此图,催眠我学习deep learning的动力!
深度学习的实质无非如此:根据模型产生的误差调整模型中的诸多权重,直到误差不能再减少为止
首先Error = Bias + Variance
Error
反映的是整个模型的准确度Bias
反映的是模型在样本上的输出与真实值之间的误差,即模型本身的精准度Variance
反映的是模型每一次输出结果与模型输出期望之间的误差,即模型的精确性在一个实际系统中,Bias与Variance往往是不能兼得的。如果要降低模型的Bias,就一定程度上会提高模型的Variance,反之亦然。造成这种现象的根本原因是,我们总是希望试图用有限训练样本去估计无限的真实数据。当我们更加相信这些数据的真实性,而忽视对模型的先验知识,就会尽量保证模型在训练样本上的准确度,这样可以减少模型的Bias。但是,这样学习到的模型,很可能会失去一定的泛化能力,从而造成过拟合,降低模型在真实数据上的表现,增加模型的不确定性。相反,如果更加相信我们对于模型的先验知识,在学习模型的过程中对模型增加更多的限制,就可以降低模型的variance,提高模型的稳定性,但也会使模型的Bias增大。Bias与Variance两者之间的trade-off(权衡)是机器学习的基本主题之一。
调整模型参数,增加样本数量
减少特征数量,手动选择一些需要保留的特征,正则化(regularization)。常见正则化有L2正则(常用),L1正则
ps:所以正则化就是为了解决过拟合,why?
模型参数的一些例子包括:
参数用来处理输入数据的系数,神经网络在学习过程中不断调整参数,直至能准确预测――此时就得到了一个比较准确的模型
模型超参数的一些例子包括:
每一项超参数就如同一道菜里的一种食材:取决于食材好坏,这道菜也许非常可口,也可能十分难吃……
为什么要有激活函数?加入激活函数是用来加入非线性因素的,解决线性模型所不能解决或者很难解决的问题。参考link
输出层的激活函数相当于逻辑回归函数,例如softmax。该层函数的选择具体取决于你更能容忍哪一类的错误:选择标准过低会增加取伪错误的数量,标准过高会增加弃真错误的数量
归一化方法:把数变为(0,1)之间的小数。主要是为了数据处理方便提出来的,把数据映射到0~1范围之内处理,更加便捷快速。加快收敛,把各个特征的尺度控制在相同的范围内,加快梯度下降
常见的为随机梯度下降(Stochastic Gradient Descent,SGD).是一种用于优化代价函数的常见方法. – 参考Andrew Ng的机器学习课程第2节
即每次迭代时对于权重的调整幅度,亦称步幅。学习速率越高,神经网络“翻越”整个误差曲面的速度就越快,但也更容易错过误差极小点。学习速率较低时,网络更有可能找到极小值,但速度会变得非常慢,因为每次权重调整的幅度都比较小.
其实就是梯度下降每次下降的步幅
动量是另一项决定优化算法向最优值收敛的速度的因素。
如果您想要加快定型速度,可以提高动量。但定型速度加快可能会降低模型的准确率。
更深入来看,动量是一个范围在0~1之间的变量,是矩阵变化速率的导数的因数。它会影响权重随时间变化的速率。
训练网络时,通常先对网络的初始权值按照某种分布进行初始化,如:高斯分布。初始化权值操作对最终网络的性能影响比较大,合适的网络初始权值能够使得损失函数在训练过程中的收敛速度更快,从而获得更好的优化结果。但是按照某类分布随机初始化网络权值时,存在一些不确定因素,并不能保证每一次初始化操作都能使得网络的初始权值处在一个合适的状态。不恰当的初始权值可
能使得网络的损失函数在训练过程中陷入局部最小值,达不到全局最优的状态。因此,如何消除这种不确定性,是训练深度网络是必须解决的一个问题。
momentum 动量能够在一定程度上解决这个问题。momentum 动量是依据物理学的势能与动能之间能量转换原理提出来的。当 momentum 动量越大时,其转换为势能的能量也就越大,就越有可能摆脱局部凹域的束缚,进入全局凹域。momentum 动量主要用在权重更新的时候。
一般,神经网络在更新权值时,采用如下公式: w = w - learning_rate * dw
,引入momentum后,采用如下公式:
v = mu * v - learning_rate * dw
w = w + v
其中,v初始化为0,mu是设定的一个超变量,最常见的设定值是0.9。可以这样理解上式:如果上次的momentum(v)与这次的负梯度方向是相同的,那这次下降的幅度就会加大,从而加速收敛。
隐藏层中的每个节点表示数据集中数据的一项特征。模型的系数按照重要性大小为这些特征赋予权重,随后在每个隐藏层中重新相加,帮助预测。节点的层数更多,网络就能处理更复杂的因素,捕捉更多细节,进而做出更准确的预测。
之所以将中间的层称为“隐藏”层,是因为人们可以看到数据输入神经网络、判定结果输出,但网络内部的数据处理方式和原理并非一目了然。神经网络模型的参数其实就是包含许多数字、计算机可以读取的长向量
下表列出了各种不同的问题和每种问题最适用的神经网络:
数据类型 | 应用案例 | 输入 | 变换 | 神经网络 |
---|---|---|---|---|
文本 | 情感分析 | 词向量 | 高斯修正 | RNTN或DBN(采用移动窗口) |
文本 | 命名实体识别 | 词向量 | 高斯修正 | RNTN或DBN(采用移动窗口) |
文本 | 词性标注 | 词向量 | 高斯修正 | RNTN或DBN(采用移动窗口) |
文本 | 词性标注 | 词向量 | 高斯修正 | RNTN或DBN(采用移动窗口) |
文本 | 语义角色标记 | 词向量 | 高斯修正 | RNTN或DBN(采用移动窗口) |
文档 | 主题建模/语义哈希(无监督) | 词频概率 | 可为二进制 | 深度自动编码器(包装一个DBN或SDA) |
文档 | 文档分类(有监督) | TF-IDF(或词频概率) | 二进制 | 深度置信网络、堆叠式降噪自动编码器 |
图像 | 图像识别 | 二进制 | 二进制(可见及隐藏层) | 深度置信网络 |
图像 | 图像识别 | 连续 | 高斯修正 | 深度置信网络 |
图像 | 多对象识别 | N/A | 高斯修正 | 卷积网络、RNTN(图像向量化) |
图像 | 图像搜索/语义哈希 | N/A | 高斯修正 | 深度自动编码器(包装一个DBN) |
声音 | 语音识别 | N/A | 高斯修正 | 循环网络 |
声音 | 语音识别 | N/A | 高斯修正 | 移动窗口,DBN或卷积网络 |
时间序列 | 预测分析 | N/A | 高斯修正 | 循环网络 |
时间序列 | 预测分析 | N/A | 高斯修正 | 移动窗口,DBN或卷积网络 |
注:高斯修正 = Gaussian Rectified | 语义哈希 = Semantic Hashing
]]>环境调优是先决条件,应对查询和写入都有帮助。重点分享查询,因为目前业务没有高频写入的场景
注意:配置只针对centos,其他系统未做测试。
编辑jvm.options
,添加一下内容:
|
|
注意:具体大小应当<=系统内存的一半,建议直接设置为系统内存的一半
大多数操作系统试图尽可能多地为文件系统缓存使用内存,并急切地交换掉未使用的应用程序内存。这可能会导致JVM堆的部分甚至将其可执行页被交换到磁盘。
交换对于性能、节点稳定性是非常不利的,应该不惜一切代价避免。它可能导致垃圾收集持续数分钟而不是毫秒,并可能导致节点响应缓慢,甚至可能断开与群集的连接。
禁用虚拟内存,并让jvm锁定内存:
/etc/fstab
,注释掉所有包含swap
的行elasticsearch.yml
,添加配置bootstrap.memory_lock: true
验证是否锁定内存成功:
|
|
Lucene 使用了 大量的 文件。 同时,Elasticsearch 在节点和 HTTP 客户端之间进行通信也使用了大量的套接字(注:sockets)。所有这一切都需要足够的文件描述符
先临时设置允许的文件句柄数量:
以上确保在当前session生效,再永久设置:
编辑/etc/security/limits.conf
,添加一行,内容为elasticsearch - nofile 65536
验证是否设置成功:
|
|
Elasticsearch 对各种文件混合使用了 NioFs( 注:非阻塞文件系统)和 MMapFs ( 注:内存映射文件系统)。请确保你配置的最大映射数量,以便有足够的虚拟内存可用于 mmapped 文件
先临时设置:
|
|
再永久修改:
编辑/etc/sysctl.conf
,添加vm.max_map_count=262144
|
|
index(名词):索引 ≈ mysql中的库(database)
type:类型 ≈ mysql中的表
document:文档 ≈ mysql中的一行数据
share:分片 ≈ mysql中的数据分表
replicas:副本,即数据分片备份
index(动词):为文档创建索引
一个索引应设置几个分片,几个副本才合理?
分片副本用来应对不断攀升的吞吐量以及确保数据的安全性.副本可以动态改变,默认为1份副本,比较合理。
当查询吞吐量跟不上时,可以考虑增加副本数量。
分片无法动态指定,只能在创建index的时候指定。(why?)
但是默认设置5个分片,这通常来说属于过度分配,这是作者考虑到数据迁移成本的权衡。
原则就是最小分片。多个分片对于查询和写入都有额外消耗。
什么时候该违反这个原则?
所以,根据“经验法则”,小集群时合理的分片数量==节点数量
elasticsearch的查询可以在主分片和副本分片查询
根据_id查询流程图:
以下是从主分片或者副本分片检索文档的步骤顺序:
Node 1
发送获取请求Node 2
Node 2
将文档返回给Node 1
,然后将文档返回给客户端在处理读取请求时,协调结点在每次请求的时候都会通过轮询所有的副本分片来达到负载均衡
如果查询有全文检索和聚合操作(例如排序),elasticsearch需要每个分片都执行查询,并把结果返回给协调节点,由协调节点进行聚合操作后再返回被客户端。
根据以下公式:
shard = hash(routing) % number_of_primary_shards
routing 是一个可变值,默认是文档的 _id ,也可以设置成一个自定义的值。 routing 通过 hash 函数生成一个数字,然后这个数字再除以 number_of_primary_shards (主分片的数量)后得到 余数
这也是为何副本数量能够动态修改,而分片数量需要创建索引时就确定好 – 因为如果分片数量变化了,那么所有之前路由的值都会无效,文档也再也找不到了
倒排索引为何叫倒排索引?
一个普通的数据库中,一般是以文档ID作为索引,以文档内容作为记录。
而倒排索引指的是将单词或记录作为索引,将文档ID作为记录,这样便可以方便地通过单词或记录查找到其所在的文档。刚好倒过来。
思考:有没有更好快的数据结构?如果有,为何不用?
elasticsearch中数据类型大致可以分为2类:精确值和全文域
精确值如它们听起来那样精确。例如日期或者用户ID,但字符串也可以表示精确值,例如用户名或邮箱地址。对于精确值来讲,Foo和foo是不同的,2014和2014-09-15也是不同的
精确值很容易查询。结果是明确的:要么匹配查询,要么不匹配
全文通常是指非结构化的数据,但这里有一个误解:自然语言是高度结构化的。问题在于自然语言的规则是复杂的,导致计算机难以正确解析
查询全文数据要微妙的多。我们问的不只是“这个文档匹配查询吗”,而是“该文档匹配查询的程度有多大?”换句话说,该文档与给定查询的相关性如何?
我们很少对全文类型的域做精确匹配。相反,我们希望在文本类型的域中搜索。不仅如此,我们还希望搜索能够理解我们的意图:
举例:西红柿&番茄、芝士 & 奶酪
Elasticsearch 接受 from 和 size 参数:
size
显示应该返回的结果数量,默认是 10
from
显示应该跳过的初始结果数量,默认是 0
考虑到分页过深以及一次请求太多结果的情况,结果集在返回之前先进行排序。 但请记住一个请求经常跨越多个分片,每个分片都产生自己的排序结果,这些结果需要进行集中排序以保证整体顺序是正确的
注意:谨慎使用深度分页
理解为什么深度分页是有问题的,我们可以假设在一个有 5 个主分片的索引中搜索。 当我们请求结果的第一页(结果从 1 到 10 ),每一个分片产生前 10 的结果,并且返回给 协调节点 ,协调节点对 50 个结果排序得到全部结果的前 10 个。
现在假设我们请求第 1000 页–结果从 10001 到 10010 。所有都以相同的方式工作除了每个分片不得不产生前10010个结果以外。 然后协调节点对全部 50050 个结果排序最后丢弃掉这些结果中的 50040 个结果。
可以看到,在分布式系统中,对结果排序的成本随分页的深度成指数上升。这就是 web 搜索引擎对任何查询都不要返回超过 1000 个结果的原因
elasticsearch默认限制最多分页10000条数据,可以用index.max_result_window
参数覆盖配置
在公司微服务化转型后,系统被拆分为多个由不同开发团队维护的分布式微服务。随着业务的发展,分布式服务越来越多,其关系越来越复杂。我们亟需一个工具能够梳理内部服务之间的关系,感知上下游服务的形态,快速定位冗长服务调用间的问题。
需要解决的核心问题:
为了解决以上问题,自研了一个分布式追踪系统X-APM(APM = Application Performance Management,应用性能管理).其核心就是调用链:通过一个全局的ID将分布在各个服务节点上的同一次请求串联起来,还原原有的调用关系、追踪系统问题、分析调用数据、统计系统指标。
在阅读google 《dapper》的思想,open traceing的理念后,实现参考了skywalking
各业务系统使用x-apm的agent探针来处理调用链路信息的采集,当采集的信息达到一定量或每隔一段特定的时间时,agent将会把这些信息传送到Flume,通过flume的channel缓冲区流向到指定的sink。目前项目一期已支持将数据保存至ES,并通过UI项目读取ES数据,方便大家在页面上以图表的形式查看各业务系统的调用链路信息。
对于理解分布式追踪系统的领域模型,我强烈建议先阅读open traceing,能够理解在分布式追踪系统中,Trace、Span、Tag等基本概念。
一个trace代表一个潜在的,分布式的,存在并行数据或并行执行轨迹(潜在的分布式、并行)的系统。一个trace可以认为是多个span的有向无环图(DAG)。
一个span代表系统中具有开始时间和执行时长的逻辑运行单元。span之间通过嵌套或者顺序排列建立逻辑因果关系。
|
|
简单解释:
这里只讲agent端的原理。因为后面的数据收集使用flume,数据存储使用elasticsearch,实时流分析使用spark,就不细说了
为了无侵入,数据埋点采用javaagent配合bytebuddy完成。
对所有中间件、框架、类库、本地方法的数据埋点,都采用Plugin的形式实现,做到自动化、可插拔、可配置、低耦合。
延伸:javaagent的what、how、why
由于埋点是动态织入到字节码的功能增强,如何快速高效的收集而不阻塞正常业务系统的流程是个关键。
具体设计考虑:
方案注意点:
最终实现:
以数组为基础,实现一个环形结构的无锁数据缓冲区,批量发送埋点数据。数据结构如下:
实现细节:
如何实现环形的缓存区,只需要让缓存区的位置索引currentIndex
始终在1~bufferSize
内原子变动即可,请看以下示例代码:
|
|
如何实现无锁,核心还是cas操作,请看以下代码示例:
|
|
ps: 这里有个强制约定,bufferSize和everySendSize的大小必须为2^n.因为计算是否需要批量发送的时候要用到%
操作,%
操作是个相对比较昂贵的操作。所以这里我们有个取巧,当计算a%b
,且b=2^n
的时候,a%b==a&(b-1)
,位移操作将高效的多。
为了检验环形数据结构的性能,我们专门写了benchmark,使用openjdk.jmh测试,benchmark代码片段如下:
|
|
cpu额外消耗保持在7%以内,内存基本无变化
tps和响应时间基本无变化
]]>一直对于如何合理的处理InterruptedException不是很清晰.
参考以下链接内容理解:传送门
先来看看InterruptedException的java doc说明:
Thrown when a thread is waiting, sleeping, or otherwise occupied,
and the thread is interrupted, either before or during the activity.
Occasionally a method may wish to test whether the current
thread has been interrupted, and if so, to immediately throw
this exception. The following code can be used to achieve
this effect:
if (Thread.interrupted()) // Clears interrupted status!
throw new InterruptedException();
就是说只有在线程处于Object.wait()
、Thread.sleep()
或者被occupied(应该是指类似LockSupport.park()
),并且线程被中断时会抛出InterruptedException异常。
如果有某个方法希望测试当前线程是否被中断,如果中断就抛出异常,有个推荐的用法:
|
|
当一个方法抛出InterruptedException时,它不仅告诉您它可以抛出一个特定的检查异常,而且还告诉您其他一些事情。例如,它告诉您它是一个阻塞(blocking)方法,如果您响应得当的话,它将尝试消除阻塞并尽早返回。
阻塞方法不同于一般的要运行较长时间的方法。一般方法的完成只取决于它所要做的事情,以及是否有足够多可用的计算资源(CPU周期和内存)。而阻塞方法的完成还取决于一些外部的事件,例如计时器到期,I/O 完成,或者另一个线程的动作(释放一个锁,设置一个标志,或者将一个任务放在一个工作队列中)。一般方法在它们的工作做完后即可结束,而阻塞方法较难于预测,因为它们取决于外部事件。阻塞方法可能影响响应能力,因为难于预测它们何时会结束。
阻塞方法可能因为等不到所等的事件而无法终止,因此令阻塞方法可取消就非常有用(如果长时间运行的非阻塞方法是可取消的,那么通常也非常有用)。可取消操作是指能从外部使之在正常完成之前终止的操作。由Thread提供并受Thread.sleep()和Object.wait()支持的中断机制就是一种取消机制;它允许一个线程请求另一个线程停止它正在做的事情。当一个方法抛出 InterruptedException时,它是在告诉您,如果执行该方法的线程被中断,它将尝试停止它正在做的事情而提前返回,并通过抛出InterruptedException表明它提前返回。 行为良好的阻塞库方法应该能对中断作出响应并抛出InterruptedException,以便能够用于可取消活动中,而不至于影响响应。
每个线程都有一个与之相关联的 Boolean 属性,用于表示线程的中断状态(interrupted status)。中断状态初始时为 false;当另一个线程通过调用 Thread.interrupt() 中断一个线程时,会出现以下两种情况之一。如果那个线程在执行一个低级可中断阻塞方法,例如 Thread.sleep()、 Thread.join() 或 Object.wait(),那么它将取消阻塞并抛出 InterruptedException。否则, interrupt() 只是设置线程的中断状态。 在被中断线程中运行的代码以后可以轮询中断状态,看看它是否被请求停止正在做的事情。中断状态可以通过 Thread.isInterrupted() 来读取,并且可以通过一个名为 Thread.interrupted() 的操作读取和清除。
中断是一种协作机制。当一个线程中断另一个线程时,被中断的线程不一定要立即停止正在做的事情。相反,中断是礼貌地请求另一个线程在它愿意并且方便的时候停止它正在做的事情。有些方法,例如 Thread.sleep(),很认真地对待这样的请求,但每个方法不是一定要对中断作出响应。对于中断请求,不阻塞但是仍然要花较长时间执行的方法可以轮询中断状态,并在被中断的时候提前返回。 您可以随意忽略中断请求,但是这样做的话会影响响应。
中断的协作特性所带来的一个好处是,它为安全地构造可取消活动提供更大的灵活性。我们很少希望一个活动立即停止;如果活动在正在进行更新的时候被取消,那么程序数据结构可能处于不一致状态。中断允许一个可取消活动来清理正在进行的工作,恢复不变量,通知其他活动它要被取消,然后才终止。
如果抛出 InterruptedException 意味着一个方法是阻塞方法,那么调用一个阻塞方法则意味着您的方法也是一个阻塞方法,而且您应该有某种策略来处理 InterruptedException。通常最容易的策略是自己抛出 InterruptedException,如清单 1 中 putTask() 和 getTask() 方法中的代码所示。 这样做可以使方法对中断作出响应,并且只需将 InterruptedException 添加到 throws 子句。
清单 1. 不捕捉 InterruptedException,将它传播给调用者:
|
|
有时候需要在传播异常之前进行一些清理工作。在这种情况下,可以捕捉InterruptedException,执行清理,然后抛出异常。清单 2 演示了这种技术,该代码是用于匹配在线游戏服务中的玩家的一种机制。 matchPlayers() 方法等待两个玩家到来,然后开始一个新游戏。如果在一个玩家已到来,但是另一个玩家仍未到来之际该方法被中断,那么它会将那个玩家放回队列中,然后重新抛出 InterruptedException,这样那个玩家对游戏的请求就不至于丢失。
清单 2. 在重新抛出 InterruptedException 之前执行特定于任务的清理工作:
|
|
有时候抛出 InterruptedException 并不合适,例如当由 Runnable 定义的任务调用一个可中断的方法时,就是如此。在这种情况下,不能重新抛出 InterruptedException,但是您也不想什么都不做。当一个阻塞方法检测到中断并抛出 InterruptedException 时,它清除中断状态。如果捕捉到 InterruptedException 但是不能重新抛出它,那么应该保留中断发生的证据,以便调用栈中更高层的代码能知道中断,并对中断作出响应。该任务可以通过调用 interrupt() 以 “重新中断” 当前线程来完成,如清单 3 所示。至少,每当捕捉到 InterruptedException 并且不重新抛出它时,就在返回之前重新中断当前线程。
清单 3. 捕捉 InterruptedException 后恢复中断状态:
|
|
处理 InterruptedException 时采取的最糟糕的做法是生吞它 —— 捕捉它,然后既不重新抛出它,也不重新断言线程的中断状态。对于不知如何处理的异常,最标准的处理方法是捕捉它,然后记录下它,但是这种方法仍然无异于生吞中断,因为调用栈中更高层的代码还是无法获得关于该异常的信息。(仅仅记录 InterruptedException 也不是明智的做法,因为等到人来读取日志的时候,再来对它作出处理就为时已晚了。) 清单 4 展示了一种使用得很广泛的模式,这也是生吞中断的一种模式:
清单 4. 生吞中断 —— 不要这么做:
|
|
如果不能重新抛出 InterruptedException,不管您是否计划处理中断请求,仍然需要重新中断当前线程,因为一个中断请求可能有多个 “接收者”。标准线程池 (ThreadPoolExecutor)worker 线程实现负责中断,因此中断一个运行在线程池中的任务可以起到双重效果,一是取消任务,二是通知执行线程线程池正要关闭。如果任务生吞中断请求,则 worker 线程将不知道有一个被请求的中断,从而耽误应用程序或服务的关闭。
语言规范中并没有为中断提供特定的语义,但是在较大的程序中,难于维护除取消外的任何中断语义。取决于是什么活动,用户可以通过一个 GUI 或通过网络机制,例如 JMX 或 Web 服务来请求取消。程序逻辑也可以请求取消。例如,一个 Web 爬行器(crawler)如果检测到磁盘已满,它会自动关闭自己,否则一个并行算法会启动多个线程来搜索解决方案空间的不同区域,一旦其中一个线程找到一个解决方案,就取消那些线程。
仅仅因为一个任务是可取消的,并不意味着需要立即 对中断请求作出响应。对于执行一个循环中的代码的任务,通常只需为每一个循环迭代检查一次中断。取决于循环执行的时间有多长,任何代码可能要花一些时间才能注意到线程已经被中断(或者是通过调用 Thread.isInterrupted() 方法轮询中断状态,或者是调用一个阻塞方法)。 如果任务需要提高响应能力,那么它可以更频繁地轮询中断状态。阻塞方法通常在入口就立即轮询中断状态,并且,如果它被设置来改善响应能力,那么还会抛出 InterruptedException。
惟一可以生吞中断的时候是您知道线程正要退出。只有当调用可中断方法的类是 Thread 的一部分,而不是 Runnable 或通用库代码的情况下,才会发生这样的场景,清单 5 演示了这种情况。清单 5 创建一个线程,该线程列举素数,直到被中断,这里还允许该线程在被中断时退出。用于搜索素数的循环在两个地方检查是否有中断:一处是在 while 循环的头部轮询 isInterrupted() 方法,另一处是调用阻塞方法 BlockingQueue.put()。
清单 5. 如果知道线程正要退出的话,则可以生吞中断:
|
|
并非所有的阻塞方法都抛出 InterruptedException。输入和输出流类会阻塞等待 I/O 完成,但是它们不抛出 InterruptedException,而且在被中断的情况下也不会提前返回。然而,对于套接字 I/O,如果一个线程关闭套接字,则那个套接字上的阻塞 I/O 操作将提前结束,并抛出一个 SocketException。java.nio 中的非阻塞 I/O 类也不支持可中断 I/O,但是同样可以通过关闭通道或者请求 Selector 上的唤醒来取消阻塞操作。类似地,尝试获取一个内部锁的操作(进入一个 synchronized 块)是不能被中断的,但是 ReentrantLock 支持可中断的获取模式。
有些任务拒绝被中断,这使得它们是不可取消的。但是,即使是不可取消的任务也应该尝试保留中断状态,以防在不可取消的任务结束之后,调用栈上更高层的代码需要对中断进行处理。清单 6 展示了一个方法,该方法等待一个阻塞队列,直到队列中出现一个可用项目,而不管它是否被中断。为了方便他人,它在结束后在一个 finally 块中恢复中断状态,以免剥夺中断请求的调用者的权利。(它不能在更早的时候恢复中断状态,因为那将导致无限循环 —— BlockingQueue.take() 将在入口处立即轮询中断状态,并且,如果发现中断状态集,就会抛出 InterruptedException。)
清单 6. 在返回前恢复中断状态的不可取消任务:
|
|
由于一些历史原因,目前zookeeper集群(3个节点)和elasticsearch、hadoop部署在一起,导致几个组件相互影响,性能也逐渐下降。甚至出现某个组件异常(例如oom)导致了其他组件不可用。
由于大量项目使用dubbo且依赖zookeeper作为注册中心,zookeeper的不稳定可能是致命的,所以计划先将zookeeper迁出到3台独立的节点,在此记录下迁移方案。
在迁移前有必要了解zookeeper的选举原理,以便更科学的迁移。
zookeeper默认使用快速选举,在此重点了解快速选举:
zookeeper集群的数量应为奇数:
因为根据paxos理论,只有集群中超过半数的节点还存活才能保证集群的一致性。假如目前集群有5个节点,我们最多允许2个节点不可用,因为3>5\2。当集群扩容到6个节点的时候,我们仍然只能最多允许2个节点不可用,到3个节点不可用时,将不满足paxos理论,因为3>6\2不成立。也就是说当集群节点数n为偶数时,其可用性与n-1是一样的,那我们何必多浪费一台机器呢?
由于zookeeper只允许mid大的节点连接到mid小的节点,我们启动zookeeper的顺序应该按照myid小的到myid大的,最后再启动leader节点!
迁移过程中要保证原zookeeper集群还是能提供服务,新zookeeper集群同步老集群的数据,将zookeeper url指向新集群的3个节点,停掉老zookeeper集群。
相当于先扩容zookeeper,然后缩容zookeeper…
原有zookeeper集群(server1、server2、server3)zoo.cfg配置如下:
|
|
使用命令:echo srvr | nc node{?} 2181
检查谁是leader({?}依次替换为1、2、3)
ps:也可以用echo stat | nc node{?} 2181
显示更详细信息
这里假设leader为node2.(按照正常情况,leader也理应是node2)
/data
目录创建mid文件,内容为4配置zoo.cfg,内容如下:
|
|
启动zookeeper:{zookeeperDir}/bin/zkServer.sh start
检查所有节点是否提供服务,且集群中只有一个leader,例如以下命令:
|
|
可以看到Mode表示该节点的角色为leader。依次检查每一个节点,如果没有响应,或者出现多个leader,需要还原整个集群!
/data
目录创建mid文件,内容为5配置zoo.cfg,内容如下:
|
|
启动zookeeper:{zookeeperDir}/bin/zkServer.sh start
检查所有节点是否提供服务,且集群中只有一个leader:
|
|
/data
目录创建mid文件,内容为6配置zoo.cfg,内容如下:
|
|
启动zookeeper:{zookeeperDir}/bin/zkServer.sh start
检查所有节点是否提供服务,且集群中只有一个leader:
|
|
修改节点4的配置如下:
|
|
重启节点4的zookeeper:{zookeeperDir}/bin/zkServer.sh restart
检查所有节点是否提供服务,且集群中只有一个leader:
|
|
同步骤4
修改节点1的配置如下:
|
|
重启节点4的zookeeper:{zookeeperDir}/bin/zkServer.sh restart
检查所有节点是否提供服务,且集群中只有一个leader:
|
|
同步骤6
最后更新leader节点:node2,同步骤6
ps:这时候如果没有读写zookeeper操作,集群的leader将变为节点6(因为节点6的myid最大)
运维修改nginx配置,zookeeper url(例如pro1.zookeeper.so、pro2.zookeeper.so、pro3.zookeeper.so)指向node4,node5,node6
相关业务系统重启(避免cdh缓存)
这一步需要等待所有的业务系统都重启之后。
这时候还是得一台一台关闭(下线),因为假如同时关闭node1和node2,那当重启node3的时候集群将不可用(没有超过集群半数的节点存活)
关闭node1: {zookeeperDir}/bin/zkServer.sh stop
依次修改node2,3,4,5,6的配置,并且重启,配置如下:
|
|
重启后检查所有节点是否提供服务,且集群中只有一个leader。
ps:这时候如果没有读写zookeeper操作,leader将变成node5,因为node6节点重启的时候,集群重新选举,node5的myid最大
关闭node2: {zookeeperDir}/bin/zkServer.sh stop
依次修改node3,4,5,6的配置,并且重启,配置如下:
|
|
重启后检查所有节点是否提供服务,且集群中只有一个leader。
ps:这时候如果没有读写zookeeper操作,leader将重新变成node6
关闭node3: {zookeeperDir}/bin/zkServer.sh stop
依次修改node4,5,6的配置,并且重启,配置如下:
|
|
重启后检查所有节点是否提供服务,且集群中只有一个leader。
ps:这时候如果没有读写zookeeper操作,node5将成为最终的leader
结束。
]]>