线上环境的p95的数据的性能很差,通过trace链路分析出『均是由于数据库的sql执行的RT较长导致』,这样就比较奇怪了,同样的sql为什么会这么不稳定;
尤其在服务发布或流量徒增的过程中,问题现象更加明显;通过DBA的分析得出结论『数据库在有突增的新建连接时会影响sql的执行』,。
针对线上集群,单个数据库端口连接数非常多,每次业务有新的发布会创建比较多的连接,首先会导致『新建连接比较慢』,其次会导致『新建连接过程中会影响已有链接的RT』
建连慢
建连慢的主要原因在于tcp的建连队列大小,如果设置的队列过小会导致丢弃部分建连的请求,导致服务不可用;
https://stackoverflow.com/questions/36594400/what-is-backlog-in-tcp-connections
调整内核配置参数,解决单端口连接过多的问题:
建连RT影响
建连登录认证也是一个sql,正常都会进入sql队列,与正常的sql在一个队列中,所以会影响正常的sql执行;
https://dev.mysql.com/doc/refman/8.0/en/thread-pool-tuning.html
通过减小timmer检测间隔并提高连接池的超时时间,调整线程的创建和回收策略来优化RT的问题:
由于以上配置会由于减少线程的回收和增加线程的创建频率均会对占用一定的服务器资源和性能,因此在评估这些参数的时候可以逐渐增加或减少该值,并在当前数据库负载的情况下找到一个合理的值;
mysql的默认建连数据是xxx,实际可以按照数据库的性能做一定的调整;
数据库的连接会占用服务器的cpu和内存,所以谨慎的限制数据库client和server的连接数是非常重要的;
根据经验,32核64G内存的数据库可以配置到万级别的数据库连接数量,同时会有上千台的服务器与单分片的数据库保持着连接没,例如,1000台服务器,没台服务的连接池配置为50,则整体5W的连接数;
数据库连接数较多情况下,可能会导致那个数据库夯住:
mysql线程池采用分组管理,连接按照分组算法分配到不同的分组,此后相同请求会在次分组内调度线程执行;此外线程池使用Timer线程定期检查分组运行的情况,并为阻塞的group分配线程。
group分组
timer线程
timer线程会定期扫描各个group检查是否被阻塞,当发现group内有任务堆积,就会给group创建新的worker进行处理;
工作流程
thread_handling
thread_pool_size
thread_pool_oversubscribe
thread_pool_high_prio_mode
thread_pool_high_prio_tickets
thread_pool_idle_timeout
thread_pool_max_threads
thread_pool_stall_limit
职责不清:前后端没有明显的业务边界,业务逻辑耦合严重;
职责不清会让需求拆解和分析造成困扰,也会造成逻辑的前后端的高度耦合,提高后期的维护成本。
由于页面的任何改动都需要前后端的支持,整体改动的开发联调工作量以及排期冲突问题都难以接受。
页面性能:前后端交互逻辑由于年久失修,存在大量的无效请求和重复请求;
由于页面存在大量的无效业务逻辑和资源请求,导致页面渲染性能较差,影响用户体验;
后端的无效资源请求对服务器和数据库都会造成不必要的压力,属于资源的浪费
;
O1:开发提效
从根本上解决前后端耦合的问题,前端仅关注组件化通用能力的建设,业务逻辑全部内聚在后端;
页面的动态渲染功能实现配置化、定制化,用于满足不同改动量的需求;
O2:体验优化
后端接口调用链路优化,提高资源利用率,减少无效请求和无效的处理逻辑;
页面资源依赖优化,从整体页面的视角来分析解决页面渲染性能的问题;
考虑到页面逻辑比较复杂,需要人肉来分析识别有效的业务逻辑,因此拆分两个阶段来完成目标:
阶段1:梳理前端的业务逻辑,优化无效的资源请求,业务逻辑内聚在后端
阶段2:页面组件化schema设计,实现组件spi扩展、资源自动装配等能力
阶段1主要解决了页面渲染层面业务逻辑的耦合问题,初步实现了前后端分离的目标。
业务逻辑的梳理是关键,为了避免逻辑的遗漏,通过开发和测试的不同视角来分析和对比业务逻辑:
为了实现页面的自动降级和分段加载,需要对页面整体做一次组件化抽象,拆分思路如下:
基于上下文的思想实现页面依赖资源的统一管理,避免资源的重复加载造成的性能问题;
1 | public class Context { |
阶段2主要解决组件的扩展能力以及资源管理的进一步优化。
写时复制
的资源管理组件的通用化设计不仅包含展示层面的组件设计,也包含表单、联动的设计模式。
组件的抽象定义是为了解决组件的复用性,通过定义通用的展示、表单组件,实现自定义的页面组件渲染能力。
为了提高页面的扩展能力,对于通用组建提供基于SPI的扩展点,业务上可以很快的实现组件的重载和扩展,同时对不同的业务实现业务的隔离。
在第一阶段,虽然把资源管理独立出来,但对于不同场景下的资源加载并没有单独优化,因此考虑从资源管理上入手,利用写时复制的思想来解决资源惰性加载的能力,并通过线程cache的手段避免资源的频繁加载,最终实现一个无需使用者关心的资源中心。
Schema加载
1 | // Schema解析起接口定义,通过实现ISchemaParser接口来注册加载新的组件 |
schema转换
1 | // Schema转换接口定义,通过实现ISchemaTransfer来实现转换器的注册 |
资源上下文管理
1 | public class Context { |
基于spi的模块化设计
1、定义展示类型,由于区分不同场景下使用的模块信息;
1 | public interface IView { |
2、定义组件spi,以预下单为例,抽象出标题、价格、数量、规格等能力,并指定页面类型为预下单;
1 | public interface IPreOrderView<C extends BaseContext> extends IView, ICustomMatch { |
3、实现spi,主要是利用资源上下文来获取相关资源,渲染模块信息;
1 |
|
4、为了实现扩展性,按照默认+扩展的设计思路,优先定义了两种实现策略;
1 | // 默认实现 |
5、基于构造器的思想,利用资源上线来构件通用的组件,
1 | public interface IViewBuilder<T extends IView, C extends ViewContext> { |
页面定义,利用多个spi来组装一个完整的页面结构
1 | // 使用构造器来构造页面 |
6、为了实现组件的扩展能力,实现了自定义的匹配接口;
1 | public interface IMatch { |
为了降低重构后页面整体的风险,通过组件隔离、页面灰度、整体页面监控等多个手段来解决页面的问题发现和降级问题。
隔离
为了提高整体页面的可用性,通过隔离不同组件的加载和渲染,保证整体页面基本可用的状态。
灰度
灰度策略一般选用用户id作为灰度维度,但针对复杂的页面来说,测试很难充分的覆盖每一种业务场景,因此可以考虑基于场景来灰度,具体可以从最基础的业务场景出发进行灰度,逐渐放量到不同的业务场景。
监控
监控方面,可以从前端监控和后端监控两个方面入手:
前端监控可以覆盖用户的实际使用场景,包含页面的打开成功率、页面打开耗时、组件渲染成功率等。
后端监控可以从系统整体性能和异常发现角度来考虑,包含接口的RT、组件的渲染成功率、组件的渲染异常等角度来发现问题。
简单的form标单很容易通过schema这套协议来实现,但对于复杂表单联动很难通过简单的schema协议来实现;
利用后端来维护整个页面的状态来实现多表单的联动。
目前我们通过后端来维护整个页面的渲染逻辑,也就是包含多个组件的联动关系由后端整体下发,举个例子:
经过上面这个过程,有没有发现什么问题?
页面联动的逻辑前端并不感知,依赖前端的每一次操作都会向后端请求,获取整个页面的最新状态;
由于引入了页面状态,会导致后端代码变得及其复杂,在实践过程中,前端的工作量整体上是有略微的降低,但后端的工作量会明显的提高;
]]>什么是流程编排的核心要解决的问题?
流程可编排
是流程引擎的核心,是解决服务流程的核心;
流程定义清晰
是流程引擎的关键,只有清晰的流程才能保证编排的可维护性;
流程易于扩展
是流程引擎的核心竞争力,只有更好的扩展能力才能让流程的编排效率提升;
BPM(Business Process Manager、业务流程管理)
主要用于管理复杂业务关系以及业务流程。
BPMN(Business Process Modeling Notation)
是BPM的一种建模语言,BMPN2.0是最新版本。
工作流引擎以Activiti
为代表,使用BPMN2.0标准定义进行编排管理;
功能完善、相对重量级的产品如下:
功能基本可用,相对轻量级的产品如下:
流程编排的可视化是降低编排复杂度的关键,除了传统的XML的编排方式,可视化的编排方式更加易于维护。
这里推荐使用开源的bmpmn-js
来实现页面可视化。
https://github.com/bpmn-io/bpmn-js
如果需要对其功能点和扩展方式进行进一步的了解,可以在其使用实例中了解扩展的写法。
https://github.com/bpmn-io/bpmn-js-examples
目标: 我们到底要解决什么问题?
传统的工作流引擎常用于企业内部审批流、工作流等方面,注重业务流程的编排和扩展,
而此次要设计的流程引擎是关注代码逻辑流程的编排,
因此,不考虑使用繁琐的Activiti流程,而采用类似liteflow的方式来实现基础的流程编排功能;
流程节点
流程节点是编排的最小单元。
1 | /** 节点定义 **/ |
节点能力
节点能力适用于扩展流程节点的功能,例如节点重试,
1 | /** 重试能力 **/ |
流程链
流程链用于存储流程节点,也就是编排的作用。
1 | public class FlowChain<C extends IFlowContext> { |
流程定义
流程是由流程链以及执行方法组成。
1 | /** 流程定义 **/ |
流程扩展
在流程定义中,应该可以发现流程节点会分为默认节点和扩展节点,其中扩展节点又可以分为互斥节点和共享节点;
1 | /** 扩展类型 **/ |
Kafka通过数据写入的多个副本来实现数据的持久化与高可用。
为了保证消息的成功投递,可以把消息投递的整个生命周期分为三部分:
① 生产消息
:消息从Producer传递到Broker;
② 存储消息
:消息写入到Broker的磁盘;
③ 消费消息
:消息从Broker传递到Comsumer。
存储消息
是数据持久化范畴,而如何保证消息不丢
是多副本设计的初衷。
设计目标
考虑到Kafka不同场景的应用,设计目标包括:
① 可配置的持久化策略
:高吞吐量/低时延,低吞吐量/高时延(通过调整备份副本的个数);
② 自动的副本备份策略
:自动调整备份,自动化的集群扩容能力。
强一致
需要保证数据写入多个备份,从而导致写入时延的增加;弱一致
不需要保证备份写入,会导致数据存在软状态,从而获得更好的性能。
解决问题
多副本场景下,存在两个关键问题:
① 副本分片策略
:副本该如何保存与分配到不同的Broker;
② 消息广播策略
:消息该如何广播到所有的副本。
多副本的数据复制可以分为同步复制
与异步复制
。
无论那种复制策略都需要考虑以下问题:
① 如何实现数据广播
;
② 如何权衡数据复制的份数
;
③ 副本宕机的异常处理
;
④ 副本宕机恢复数据
的一致性如何保证。
常见的复制策略分为两种:主备复制
与选举复制
。
Primary-Backup Replication
主备复制
的场景下,主节点在写入数据完成后会阻塞等待所有从节点
的写入完成。
在从节点写入的过程中,如果发生故障(宕机、断网、超时)就需要主节点来协调管理可用的从节点列表
。
对于故障恢复后的从节点,需要从主节点拉取最新的数据才能重新加入集群。
如果副本数量为N,则主备策略最多可以容忍N-1个节点异常。
Quorum-Based Replication
选举复制
的场景下,主节点在写入数据完成后会阻塞等待多数从节点
的写入完成。
选举的特点在于不需要等待所有从节点的写入。
考虑到集群数量是预设的,由于半数以上的写入策略,从而最大的异常容忍度是可知的。
如果副本数量为2N+1,则选举策略最多可以容忍N个节点异常。
那么,两种策略如何权衡?
主要从时延性能
、可用性
两方面考虑,
① 选举复制只需要写入大部分节点就可返回,因此选举复制的时延低;
② 主备复制可以容忍更多数量的异常节点;
③ 选举复制至少最多容忍半数的异常节点。
考虑到,副本数量会直接影响到磁盘的有效使用率,Kafka选择主备来尽量减少磁盘空间的浪费
。
Kafka使用主备
作为同步复制策略。
主节点维护一个同步副本的列表(In-Sync Replicas(ISR))
,用于记录与主节点保持同步的从节点
。
为了维护多个副本的可用性,Kafka使用Zookeeper
来保存Leader节点
与ISR
。
数据存储分为两部分:本地日志
与偏移量
。
其中,LEO(The Log End Offset)
记录当前日志的最新偏移量,HW(High Watermark)
记录最后一次Commited的偏移量。
LEO是用来标记日志同步的进度,而HW是用来标记Commited的进度。
Client通过Zookeeper获取目标分区最新的Leader节点。
1 | Zookeeper |
副本之间的通信采用Socket直连的方式来提高性能。
主节点在接收到数据后会优先写入本地磁盘(日志),然后同步写入多个副本。
主节点(Partition)的写入是有序的,但不同主节点之间写入的顺序是无序的。
消息的写入判断条件是ISR列表中所有从节点均写入完成
。
为了提高副本的写入效率,仅保证消息写入到副本节点的内存(不保证持久化到磁盘)。
通过提供可配置化的Flush策略
来提供不同的应用场景:同步刷盘
与异步刷盘
。
读操作仅从Leader主节点读取数据,而主节点是根据Zookeeper来获取的。
Follower Failure
从节点异常情况下,会被主节点从ISR列表中移除,直到从节点恢复。
从节点的恢复不仅需要机器恢复正常,也需要数据恢复到同步状态。
因此,ISR列表是Kafka实现主备自动切换的关键
。
Leader Failure
主节点异常的三种场景:
① 未写入本地日志(无影响);
② 写入本地日志,未写入从节点;
③ 写入本地日志,写入部分从节点;
由于第①种场景中没有持有化数据,因此不会存在主从切换数据不一致的问题。
为了实现主从切换后数据的一致性,被选举为Leader的节点一定拥有最新的数据,
Kafka的主备切换主节点的选举策略如下:
① Zookeeper中注册ISR信息,用来保证元数据的一致性;
② 基于Zookeeper的临时节点来选举主节点,用来保证Leader的唯一性;
③ 利用Zookeeper的Watcher功能实现自动的选举功能;
④ Leader需要保证ISR中节点的同步进度,即时移除超时等异常的从节点。
异步复制是数据写入主节点后直接返回给Client,通过异步的方式同步数据到从节点。
因此,异步复制不能保证数据的持久化,当发生主从切换时可能造成数据的不一致(重复消息、丢消息)。
]]>Kafka
是一个高吞吐量
、低延时
、可伸缩
的分布式
消息系统。
传统的消息系统一般作为消息总线
,用于处理异步的数据流。
然而,这种消息总线的处理方式并不适合当下的日志处理
的场景,究其原因在于:
① 传统的消息系统关注于
消息投递
,大量的日志会导致系统的过载。
在分布式场景下,消息投递的保证是通过Sync + ACK
的方式来实现的。
因此,在传统消息系统中,消息投递过程是同步
的,无法支持大量日志流的场景。
② 传统的消息系统大多都不是以
吞吐量
为作为首要目标,使用TCP
作为消息投递的通信方式。
TCP
是可靠的通信协议,并不适用于高吞吐量
为目标的消息通信方式。
③ 传统的消息系统
缺少分布式的支持
。
对于海量的日志消息,单机必然无法满足,多机的环境下的分区存储
是不可避免的。
④ 传统的消息系统是建立在
消息即时消费的假设
之上,从而缺少对于大量消息持久化存储的能力。
对于海量的日志消息,消息的消费不一定是即时的,从而可能导致大量的消息堆积,
这也就是第③点的问题,如何通过分区
来支持海量消息的堆积
问题。
近几年海量日志处理的场景下产生一些日志聚合工具。
Scribe
是Facebook用于收集日志数据的中间件,它基于Socket来收集日志数据,并通过日志聚合定期写入HDFS。
也就是说,Scribe是用于解决日志写入HDFS性能不足的一种手段。
Flume
是Cloudera开发的日志聚合中间件,支持pipes
与sinks
的灵活扩展以及分布式
的支持。
HedWig
是Yahoo开发的一个分布式的发布订阅
系统,主要用于记录消息的消费记录。
Kafka引入了Broker
用于消息的存储。
为了实现海量数据的存储,Kafka集群一般包含多个Broker。
为了实现负载均衡,每一个Topic
会被分为多个Partition
,每一个Broker
会存储一个或多个Partion
。
Partition
实际上是一个逻辑日志,它由于含多个Segment
(物理日志)组成,每个Segment
的大小约为1GB
。
每当一个生产者发布一条消息到某个Partition
时,该条消息会追到到该Partition
的最后一个Segment
日志中。
为了实现高性能,Kafka先内存缓存消息再flush磁盘,刷盘策略有:
① 内存缓存到指定数量的消息后自动flush磁盘;
② 固定时间阈值自动flush磁盘。
在Kafka中,消息没有分配一个唯一固定的消息ID,而是采用一个基于日志的逻辑的偏移量来定位消息。
偏移量的使用可以避免磁盘的随机查找,通过偏移量可以准确的确定消息的位置。
对于消息的顺序写入,消息的定位
不仅仅要记录消息的偏移量(Offset)
,还要记录消息的长度(Length)
。
偏移量(Offset)
虽然是不连续的(记录消息的起始位置),但可以保证单调递增。
Kafka使用在内存维护了一个Offset的内存索引
,记录了每个Segment内第一个消息的偏移量
。
高效传输
为了实现高效的数据传输,Kafka的设计如下,
① 消息的批量发送与拉取
主要用于减少请求次数;
② 直接使用PageCache
来避免的内存缓存;
③ 没有使用内存,因此避免了不必要的GC
;
④ 使用sendfile
减少内存拷贝。
无状态的Broker
Broker是
无状态
的,其仅仅作为消息的存储,并不会保留任何Consumer的消费情况。
Broker的无状态特性可以降低消息系统的复杂性。
由于Broker的无状态,无法感知消息消费的情况,也就无法即时清除,因此一般采用定期清除策略来清除历史消息。
此外,基于Broker的无状态,Consumer可以实现消费的回退以及重试
。
路由
消息投递到Partition的路由策略
有:
① 随机选择:基于Random的随机选择;
② 哈希路由:基于PartitionKey
的路由策略;
并行
Kafka使用Partition作为最小的并行处理单元
。
一个Partition仅能被一个Consumer消费,因此,Partition的数量要多于Consumer的数量。
协作
为了实现多个无状态的Broker之间的协作,引入了Zookeeper
。
① Broker的注册与监控
;
② 基于Zookeeper的监控来做rebalance
;
③ 维护Consumer的消费进度(Offert)
。
Broker/Consumer的注册是动态的(存在扩容与缩容的情况),使用ZK的临时节点
。
由于Broker是无状态的,因此Consumer的Offset的维护使用ZK的持久化节点
。
rebalance
当监听到ZK中Broker/Consumer的注册信息有变化时,对应的Consumer就会发起rebalance
。
rebalance是按照group
来分组的,每个group
维护着自己的Offset。
第一个监听到变化的Consumer会释放所有Partition再进行rebalance。
Partition的占用必然是通过ZK实现的。
Kafka仅支持至少一次
的消息投递。
仅一次
的消息投递需要两阶段提交(2PC)
的支持,这与高吞吐量的目标不相符。
由于Partition是最小的并行处理单元,消息在Partition内部是有序的,
因此,通过PartitionKey的路由策略可以保证一定的顺序消费。
本文仅是Kafka论文的记录,其内容与目前Kafka版本的实现可能有些不同。
本文还缺少对于Replication
的介绍,会在后面的文章中有分析介绍。
对于Broker的无状态特性,Kafka后期并没有完全维持,例如,Consumer的Offset就从Zookeeper中迁移至Broker中。
Kafka作为独立的中间件,需要Zookeeper作为协调者来说有些太重了,因此社区正打算干掉ZK,例如,KIP-500打算把元数据都利用TOPIC来存储。
此外,随着Kafka的应用越来越广泛,消息投递的语义也不仅仅支持至少一次
这种场景了,此时就需要考虑CAP
理论了。
关键词:链表、多级索引、动态结构
跳表是基于链表建立多级索引的动态数据结构。
在链表的基础之上,跳表利用空间换时间的方法,把单链表的时间复杂度从O(n)
降低到跳表的O(log(n))
。
在跳表索引的构建上,需要维护索引大小平衡性,避免退化为单链表。
Redis中ZSET
也被称为有序集合(Sorted Set)
,是通过跳表来实现的。
Redis为什么使用跳表来实现ZSET?
① Redis并不是仅使用跳表来实现;
在数据量比较少时使用ziplist来实现,当数据量比较多的时候才会采用skiplist实现;
② 基于双向链表的跳表很容易实现ZRANGE
与ZRERANGE
,而红黑树等树形结构就没这么容易;
③ 跳表节点的插入与删除的复杂度更低,而红黑树等平衡树都需要旋转平衡操作。
在高并发场景下跳表性能更加,不过针对Redis来说单线程的数据变更来说红黑树的平衡旋转应该不是瓶颈。
下面将简单介绍下redis跳表的结构
、节点插入
、节点排序
以及范围检索
。
ZSET是通过两部分组成:KV字典(dict)
与跳表索引(zskiplist)
。
1 | /* ZSET结构体 */ |
其中,KV字典(dict)
是基于哈希的数据存储的基础结构,跳表索引(zskiplist)
是基于跳表的数据遍历搜索结构。
1 | /* 跳表节点 */ |
zslGetRank
是跳表中用于获取指定元素排名的功能。
1 | /* 获取排名 */ |
zslInsert
是跳表的插入逻辑。
插入流程大体如下:
位置搜索
:从跳表的最高层到最底层开始遍历,记录每层的跳跃节点以及移动位置;构造新节点
:随机生成一个level用于构造带插入节点;初始化新增层级
:如果level大于原有最大层级,则需要对新增的层级初始化;节点加入到跳跃表
:把待插入节点的层级节点加入到跳表层级的每一层,并重新计算span;更新跳表指针
:更新节点的backward。1 | /* 插入数据 */ |
zrangeGenericCommand
是跳表的范围查询逻辑。
范围查询的流程大体如下:
计算遍历位置以及长度
:主要在于start
、end
、rangelen
的边界条件以及内容计算;搜索目标范围的起始节点
:利用zslGetElementByRank
获取正序或倒序序列的起始位置;以起始节点开始遍历指定范围的节点
:基于上一步计算的起始节点开始循环遍历(基于双向链表),需要注意遍历的方向;1 | /* 范围查询 ZRANGE */ |
InnoDB实现了两种标准的行锁(row-level locks)
:
共享锁(shared(S) locks
)允许持有该锁的事务读取
数据行;
排它锁(exclusive(X) locks
)允许持有该锁的事务更新或删除
数据行。
共享锁被称为共享的原因在于:多个事务可以同时持有共享锁,从而实现数据的并发读取。
【例1】针对同一行数据r行,假设事务T1已持有该行的共享锁
,则事务T2的请求加锁结果如下:
共享锁
,则会立即生效,此时,事务T1与事务T2均持有r行的共享锁;排它锁
,由于锁冲突,导致事务T2进入阻塞等待状态(直到事务T1释放锁)。【例2】针对同一行数据r行,假设事务T1已经持有该行的排他锁
,则事务T2无论请求共享锁
还是排它锁
都会进入阻塞等待状态(直到事务T1释放锁)。
InnoDB实现了多粒度的锁(multiple granularity locking)
,用于支持行锁与表锁的共存。
意向锁(Intention Locks)
是表锁(table-level locks)
,用于表示事务稍后需要哪种类型的锁(共享的或排他的)来锁定表中的某一行。
intention shared lock (IS)
)表示事务打算对表中的各个行设置共享锁
;intention exclusive lock (IX)
)表示事务打算对表中的各个行设置排他锁
。意向锁与行锁的加锁规则
S锁(row-level locks)
之前,必须先获取表上的IS锁(table-level locks)或更强的锁
;X锁(row-level locks)
之前,必须先获取表上的IX锁(table-level locks)
。意向锁与表锁之间的兼容规则
- | X | IX | S | IS |
---|---|---|---|---|
X | Conflict | Conflict | Conflict | Conflict |
IX | Conflict | Compatible | Conflict | Compatible |
S | Conflict | Conflict | Compatible | Compatible |
IS | Conflict | Compatible | Compatible | Compatible |
【例】如果事务T1已经持有表的IX锁
,则事务T2获取不同表锁
的结果如下:
X锁或S锁
,则会造成锁冲突,事务T2进入阻塞等待(直到事务T1释放表的IX锁);IX锁或IS锁
,由于兼容规则,事务T2可以正常获取IX锁或IS锁
。注意
意向锁
与表锁
、行锁
的兼容规则的不同。
以下为用于测试初始化的SQL语句,
1 | # 建表 |
记录锁(record locks)
是索引上的一种行锁。
如下面的sql,会对c1=10
的行设置记录锁record lock
,阻止其他事务对id=0
的行进行插入、更新、删除
。
1 | SELECT * FROM t WHERE id = 0 FOR UPDATE; |
InnoDB使用聚簇索引(clustered index)
来存储数据,该索引的构建方式如下:
① InnoDB使用表中显示的主键(primary key)
来构建聚簇索引;
② 如果没有主键,InnoDB会尝试寻找一个非空(NOT NULL)
的唯一索引(UNIQUE INDEX)
来构建聚簇索引;
③ 如果表没有主键且没有非空的唯一索引,InnoDB会为表生成一个隐式的ROW-ID
的自增字段,构建名为GEN_CLUST_INDEX
的聚簇索引。
聚簇索引的特点在于把所有的数据存储在叶子节点
,实现了数据与索引的分离
。
正常来说,数据占用的空间往往远大于索引,考虑不同索引实现的场景:
【场景1】如果采用BTree来实现,由于BTree的数据与索引是存储在同一个节点上,所以:
【场景2】如果采用B+Tree来实现,由于B+Tree的数据与索引是分离的,所以:
除了聚簇索引,其他搜索都被称为二级索引
。
二级索引中不会包含行的所有数据,仅会包括关联行的主键。
也就是说,二级索引的检索过程为:先检索二级索引,找到目标行的主键索引KEY,然后在检索聚簇索引,最终找到目标行的数据。
由于二级索引需要关联聚簇索引的KEY,因此聚簇索引的主键选择上不应该选择过大的数据结构。
有一点疑问:为什么二级索引不采用BTree实现?
如果采用BTree实现的话,指定的目标查询不会遍历到叶子节点,这是BTree的优势。
但是如果需要范围查询的时候,性能就不如B+Tree这种实现了,这应该是采用该索引的原因。实际上,MySQL InnoDB可以设置两种索引类型:
Btree
和Hash
,如果该索引不会有顺序查询,使用Hash
索引更合适。
间隙锁(gap locks)
是对索引记录之间(两个记录之间、第一个记录之前、最后一个记录之后)间隙的锁。
间隙锁的作用在于防止其他事务操作当前间隙内的数据
。
【例】下面的SQL会增加一个间隙锁
,区间为(10, 20),因此,其他事务无法在间隙内插入、更新或删除,例如插入15会被阻塞。
1 | SELECT * FROM t WHERE c BETWEEN 10 and 20 FOR UPDATE |
针对不同的索引类型,会出现不同的加锁情况:
- 对于
唯一索引(UNIQUE INDEX)
来说,锁定某行时仅需要锁定数据行(不需要间隙锁的)
;- 对于
普通索引(INDEX)
来说,锁定某行时不仅需要锁定数据行
,还要锁定前后间隙
;
对于测试数据来说,索引(不同字段之间数据值相同)存在的间隙有:(-∞, 0)
、(0, 5)
、(5, 10)
、(10, +∞)
。
不同的事务的间隙锁可以共存(甚至X锁与S锁),例如,在区间(0, 5)上,事务A持有S锁,事务B持有X锁。
在读已提交(READ COMMITTED
)的隔离级别下,间隙锁不再用于搜索与索引扫描,仅用于外键约束检查和重复键检查。
在初始化测试数据的条件下,考虑c为普通索引,加锁结果如下:
1 | # 事务A:加排它锁成功,间隙锁(0,5)与(5,10)、记录锁{5} |
c=5
是一个普通索引的加锁行为,不仅对当前的行加记录锁,而且会对两侧的间隙加锁(左侧是间隙锁,右侧为临键锁)。
临键锁的退化:
① 在RR级别下,加锁的单位为
临键锁
;② 针对二级索引的等值(c=5)的加锁情况,会按照索引顺序继续寻找不同的行,进而退化为
间隙锁
,例如事务F
并没有阻塞。
在初始化测试数据的条件下,考虑c为普通索引,加锁结果如下:
1 | # 事务A:加排它锁成功,间隙锁(0, 5)、记录锁{5}、临键锁(5, 10] |
between
类似于一个范围查询,会把范围内的都加锁(无论是行还是间隙)。
注意事务D的语句会被blocking,也证明了非等值语句的情况下,临键锁是不会退化的。
临键锁(next-key locks)
是记录锁与间隙锁的一种组合,锁住索引记录以及索引记录之前的间隙((左开右闭的区间]
)。
RR级别下,加锁的最小单元是
临键锁(next-key locks)
,都是按照左开右闭的区间来加锁的。不过,也存在锁降级的情况:
① 唯一索引下,降级为行锁
② 普通索引下,如果是等值查询的话,第二个
临键锁(next-key locks)
会退化为间隙锁(gap locks)
。
对于测试数据中c索引来说,临键锁(不同字段之间数据值相同)的区间有:(-∞, 0]
、(0, 5]
、(5, 10]
、(10, +∞)
。
锁降级:唯一索引上,临键锁会降级为记录锁(不包括左右边缘)。
临键锁仅发生在可重复度(REPEATABLE READ )
的隔离级别,主要解决了幻读(phantom read)
。
唯一索引上的锁降级情况如下,
1 | # 事务A:加排他锁成功,记录锁{5} |
在RR隔离级别下,在测试数据初始化的条件下,考虑c为普通索引,锁操作如下,
1 | # 事务A:加排它锁成功,间隙锁(0,5)与(5,10)、记录锁{5} |
c=5
为普通索引上的加锁,包括间隙锁(0, 5)、记录锁{5}、间隙锁(5, 10)(等值查询情况下的锁退化)。
在RR隔离级别下,在测试数据初始化的条件下,考虑d为唯一索引,锁操作如下,
1 | # 事务A:加排他锁成功,记录锁{5} |
在RR隔离级别下,在测试数据初始化的条件下,考虑c为普通索引,锁操作如下,
1 | # 事务A:加排他锁成功,间隙锁(5, 10),记录锁{10},临键锁(10, +∞) |
在RR隔离级别下,在测试数据初始化的条件下,考虑d为唯一索引,锁操作如下,
1 | # 事务A:加排他锁成功,间隙锁(5, 10), 记录锁{10}, 临键锁(10, +∞) |
插入意图锁(insert intention locks)
是一种间隙锁,当执行插入(insert)
操作是触发。
【例1】以下SQL会加入插入意向锁,锁住的间隙是(0, 5),
1 | insert into t values(3, 3, 3); |
多事务的插入意向锁
考虑到插入意向锁是间隙锁,因此,不同事务的插入意向锁可以共存。
不同事务同时持有相同间隙的插入意向锁的情况下,如果插入的位置不同(值不同),此时各个事务会正常执行插入操作,
否则,如果出现插入到相同位置,该事务会一直等待,直到插入该位置的事务释放锁。
【例2】存在三个事务,分别在初始化的测试数据集上尝试写入数据,具体如下,
1 | # 事务A:持有插入意向锁成功,锁定区间(0, 5),此时会给3增加行锁 |
上面发现,相同位置数据的数据会提示S锁,插入不应该是X锁么?
对于唯一索引来说,插入前需要先检查是否存在重复数据,此时X锁先会降级为S锁来实现当前读。
自增锁(auto-inc locks)
是表锁(table-level locks)
,用于实现自增主键。
由于自增锁属于表锁,性能必然很差,因此,考虑到自增主键使用的场景,自增锁的锁定范围并不是整个事务,而是锁定的insert sql
语句级别,
也就说,不同的事务之间,在insert
时是交替完成的,虽然是表锁但对事务的并发插入并没有太大的影响。
幻读(phantom reads)
是由于不同事务之间新增数据导致的前后数量不一致的问题。
幻读
是insert引起的,不可重复读
是由update或delete引起的。
幻读
产生原因在于新增的,不可重复读
产生的原因在于更新。
在RR隔离级别下,通过临键锁(next-key locks)
解决了幻读。
select for update
属于当前读(排它锁),而select
属于快照读(无锁)。
select lock in share mode
属于共享锁,无法与排它锁共存。
即使InnoDB实现了MVCC解决了幻读的情况,但不同事务之间仍然存在先后插入数据的冲突问题。
在测试数据初始化的条件下,观察以下加锁结果:
1 | # 事务A:快照读,不会加锁 |
死锁(dead locks)
是由于多个事务相互持有互相等待的锁导致的。
insert-insert-insert-rollback 三个事务写入的场景下的死锁
在测试数据初始化的条件下,观察以下加锁结果:
1 | # 事务A:加插入意向锁(0,5),id=3的排他锁(X锁) |
由于两个事务持有同一个记录的S锁,彼此等待对方释放S锁,进而进入死锁。
update-insert 两个事务写入重叠锁区间场景下的死锁
在测试数据初始化的条件下,观察以下加锁结果:
1 | # 事务A:加排他锁成功,间隙锁(0,5)、记录锁{5}、临键锁(5, 10] |
S X X
1 | # 事务A:id=5 S锁 |
1 | # 事务A:id=5 S锁 |
临键锁会分为两段加锁:加间隙锁
与加记录锁
。
1 | # 事务A:间隙锁(0, 5)(5, 10),记录锁{5} |
limit
对加锁范围仍然有影响:仅会对扫描到的数据范围进行加锁
。
针对唯一索引的limit场景如下,
1 | # 事务A:扫描id>0的数据,由于limit仅为1,则仅会锁住id=5的这条数据 |
针对非唯一索引的limit场景如下,
1 | # 事务A:扫描c>0的数据,由于limit仅为1,间隙锁(0, 5),行锁{5} 由于limit1,不会锁住5后面的区间 |
数据库上的锁都是为了避免数据并发更新导致的问题,这与系统中所使用锁的初衷是一样的。
在InnoDB上,锁都是建立索引的基础之上,这里包括聚簇索引、二级索引等。
对于唯一索引来说,针对每个特定的值的遍历与查询是可以明确目标的,因此,不需要间隙锁,仅使用记录锁就可以了。
对于普通索引来说,针对某个特定的值的遍历与查询是不固定的,可能存在多个相同的值,此时就需要锁定目标值的前后,防止加入相同的值。
间隙锁是可以共存的,即使是冲突的锁,临键锁解决了幻读的问题。
插入意向锁是间隙锁,需要注意插入冲突的判断条件是位置是否相同,即使是相同范围的间隙锁,虽然插入位置不同也不会造成冲突。
对于自增锁来说,虽然是表锁,但锁定的时间仅限于插入SQL语句,不会跟随整个事务,因此,性能并没有那么差。
]]>缓存
是提高系统性能的关键,也必然会带来数据一致性
的问题。
常见的缓存大概有以下几种,
使用用户本地缓存来减少服务端的请求压力,会造成客户端与服务端数据不一致的问题。
客户端缓存可以有效的降低服务端的压力,例如,抢购以及电商大促时常常先把一些静态数据缓存到客户端本地。
利用分布式的边缘服务器
对服务端的数据进行缓存,客户端就近获取数据
。
cdn缓存可以理解为利用中间件(分布式缓存)对服务端整体缓存,同样会造成数据不一致的问题。
cdn缓存一般用于静态数据,例如,前端相关的css、js,商品详情页的静态数据。
nginx缓存一般仅用于服务端的静态数据的缓存,一般常用于前端相关的css/js等。
本地缓存是解决高并发的终极手段,但一定会存在数据不一致的问题。
大量的本地缓存会占用大量的业务内存,合理的设置LRU以及热点数据的评估是使用本地缓存的关键问题。
为了实现高并发,往往会采用数据的最终一致性。
分布式缓存是目前微服务下最常用的缓存手段。
在一些QPS不高的业务下,并不需要本地缓存,仅仅使用分布式缓存就能实现很好的性能。
由于分布式缓存引入了新的中间件,会增加整个链路的网络IO开销。
常见的分布式缓存有Redis、Memcached。
缓存更新的方式大体可以分为两种:同步更新
与异步更新
。
由于更新的异步化,写入数据的同时不能保证数据的缓存刷新,因此,很难保证数据的强一致性。
同步更新大多适用于数据强一致性,而异步更新大多适用于数据的最终一致性。
以下几种实例中,均使用Memcached
作为分布式缓存,MySQL
作为持久化存储。
此方案是最基础的分布式缓存方案,关键在于同步的DB写入与缓存刷新。
read
优先读取缓存,如果不存在,则回源到DB,重新加载到缓存。
write
优先写DB,写成功后同步清理缓存。
read
同上,优先读取缓存,如果不存在,则回源到DB,重新加载到缓存。
write
仅写DB,但写入成功后会直接返回,触发异步线程来做缓存的更新,存在缓存的不一致问题。
如图所示,与同步更新相比,异步线程不能保证数据更新的时效性,如果线程出现异常会导致长时间的缓存不一致
的问题。
此外,如何设置合理的异步线程池
也是一个关键问题。
read
同上,优先读取缓存,如果不存在,则回源到DB,重新加载到缓存。
write
仅写DB,但写入成功后会直接返回,使用mq作为异步操作持久化的媒介,从而解决异步线程池的问题,但仍然无法解决性能上的问题。
如图所示,使用RocketMQ
中间件来作为异步化的媒介,通过Consumer
来触发缓存的刷新。
read
同上,优先读取缓存,如果不存在,则回源到DB,重新加载到缓存。
write
仅写DB,但写入成功后会直接返回,使用binlog来避免对于mq中间件的依赖,订阅binlog日志来实现缓存的生成。
这种方案引入了binlog日志,从复杂度上降低了直接的依赖,但也引入了间接的依赖。
Instagram
才用了这种方式来实现高性能的服务,具体可参考Scaling Instagram Infrastructure。
read
优先读本地缓存,如果不存在,则回源到DB,重新加载到本地缓存。
此方案不需要两次网络IO(分布式缓存的Request与Response),性能上一定是更好的,但一致性很难保证。
write
优先写DB,写入成功并更新本地缓存后会直接返回。
为了解决分布式场景下本地缓存的同步更新,引入了Redis
的发布订阅机制。
此方案仅更新了本地缓存,如果可以保证该用户请求均路由到同一机器,是可以解决当前用户可见性问题。
考虑到中间件的可靠性与时延问题,增加了SyncScheduler
来实现定时同步机制。
read
仅读取缓存,缓存数据的加载是通过缓存中间件来实现,服务不会直接与DB交互。
write
仅写缓存,缓存需要持久化策略,通过binlog通过到MySQL。
此方案使用分布式缓存作为
数据库的前置缓存
,通过实现缓存的持久化机制来同步数据到DB。此方案需要具有缓存中间件定制化开发的能力,缓存异常恢复也是实现高可用服务的关键。
read
需要路由的支持,实现用户的特定路由,从而达到某个用户的所有操作均打到同一台机器上。
由于每台机器仅接受特定用户的数据,可以通过引入Zookeeper
来获取一定的标识来预加载特定的数据。
仅读取本地缓存,通过SyncSheduler
模块主动或定时拉取缓存数据。
write
仅写本地缓存,需要同步写入到数据库,从而保证数据的持久化。
此方案是基于路由能力,实现一套特定路由方案的缓存一致性策略。
通过保证用户请求路由的一致性,使用服务内存缓存特定数据,从而利用分布式服务来代替分布式缓存,从而满足数据的一致性。
这种方案没有具体实施过,特定的路由规则必然带来额外的复杂度。
这种方案在大型网站的库存系统中是否有过实现也未可知。
缓存更新
是分布式场景下无法避免的问题,大体可以分为同步
与异步
。
高性能与数据一致性往往不可兼得,强一致性与最终一致性之间如何取舍
是架构设计的必经之路。
项目设计之初并不需要复杂的缓存设计,在满足性能的前提下架构的设计越简单越好。
如果DB的性能足以满足当前的需求,就不必引入分布式缓存。
如果分布式缓存的性能足以满足当前的需求,就不必引入本地缓存。
如果同步同步更新的性能满足当前的需求,就不必引入异步更新策略。
如果对数据的实时性要求不高,就没必要一定要同步调用,可以通过各种异步策略来实现。
如果线程池的方式满足当前的需求,就没必要引入为的中间件。
…
总之,缓存设计简单就好。
虚拟机中线程状态并不是操作系统真实的线程状态。
Thread.State
属于虚拟机状态,与系统线程状态没有任何关系。
1 | /** |
RUNNABLE
状态需要注意一下,包含了就绪等待
与运行
两种实际的运行情况。
BLOCKED
与Monitor Lock
有关。
WAITING
/TIMED_WAITING
也同样是基于Monitor
实现。
1 | class ObjectMonitor { |
Linux
仅有定义Process State
,也就是进程状态,具体包含五个状态:
TASK_RUNNING(运行)
TASK_INTERRUPTIBLE(中断)
TASK_UNINTERUPTIBLE(不可中断)
_TASK_TRACED(跟踪)
_TASK_STOPPED(停止)
Thread.yield
用于向调度器提示当前线程自愿让出处理器资源。
1 | /** |
Thread.yield
不会改变RUNNABLE
的线程状态,只是会让出当前处理器进入就绪状态,等待下一次时间分片的分配执行。
1 | JVM_ENTRY(void, JVM_Yield(JNIEnv *env, jclass threadClass)) |
Thread.join
是让当前线程进入BLOCKED
状态。
1 | public final synchronized void join(long millis) |
从源码中可知,Thread.join
是利用synchronized
的Monitor
来实现的线程的阻塞。
Thread.sleep
是让当前线程进入TIMED_WAITING
状态。
1 | /** |
下面是JVM中源码实现,
首先,调用thread->osthread()->set_state(SLEEPING)
,设置当前线程的状态,
然后,调用os::sleep(thread, millis, true)
,触发os::sleep
功能。
os::sleep
的大概流程:
- 挂起进程(或线程)并修改其运行状态;
- 用
os::sleep
提供的参数来设置一个定时器;- 当定时器会触发,内核收到中断后修改进程(或线程)的运行状态(进入就绪队列等待调度)。
1 | JVM_ENTRY(void, JVM_Sleep(JNIEnv* env, jclass threadClass, jlong millis)) |
源码中SLEEPING
状态对应着JVMTI
状态如下,实际上就是State.TIMED_WAITING
状态。
1 | // Java Thread Status for JVMTI and M&M use. |
Netty
是一个基于Java NIO
的高性能的通信框架。
Netty是Reactor
的一种实现,通过分离Boss线程
与Worker线程
实现高性能的IO操作。
Selector
是事件驱动的核心,是基于select/epoll/kqueue
等实现。
BossGroup
负责不断循环执行select
来获取当前已经就绪的事件。
WorkerGroup
负责具体的IO操作(OP_READ
/OP_WRITE
等)。
注意:BossGroup中线程的数量与绑定的端口有关,如果仅绑定一个端口,线程数为1即可。
ServerBootstrapAcceptor
负责ParentGroup
到ChildGroup
之间Channel
的传递.
Channel
是IO操作的媒介,Netty中的Channel与JDK中NIO是一一映射的,
Channel
是NIO通信的基础,selector
是需要注册到Channel
来能实现事件驱动的绑定。
Netty实现了一套pipeline
以支持IO流的自定义处理。
如图所示,pipeline
保存了用于此Channel的所有Handler,
其中,可以被分为两种:
ChannelInboundHandler
:处理读操作(对应着请求)的处理器ChannelOutboundHandler
:处理写操作(对应着回复)的处理器channel.read()
是Channel的读操作,调用findContextInbound
获取Inbound处理器,
channel.forceFlush()
是Channel的写入操作,调用findContextOutbound
获取outbound处理器。
在使用过程中,需要注意Inbound/outbound的顺序。
那么,pipeline到底什么用,举个例子:
ByteToMessageDecoder(ChannelInboundHandler)
对读到的数据进行解码,MessageToByteEncoder(ChannelOutboundHandler)
来对回复的数据进行编码。NioSocketChannel & NioServerSocketChannel
使用SelectorProvider
来获取当前平台默认的事件驱动模型。
1 | SelectorProvider DEFAULT_SELECTOR_PROVIDER = SelectorProvider.provider() |
SelectionKey
是selector
获取到已就绪的事件,利用processSelectedKey
实现读写事件的分发处理。
1 | void processSelectedKey(SelectionKey k, AbstractNioChannel ch) { |
EventLoop
是Netty中用于绑定Channel
并处理Channel IO
的实现。
NioEventLoop继承自SingleThreadEventExecutor
,也就是说,每一个NioEventLoop就是一个单线程
。
SingleThreadEventExecutor
是一个单线程的线程池,利用队列实现任务的非阻塞的异步执行
。
volatile int state
,会涉及到UnSafe.CAS
操作Queue<Runnable> taskQueue
PriorityQueue<ScheduledFutureTask<?>> scheduledTaskQueue
任务执行execute(Runnable task)
会把当前任务添加到taskQueue
,最后通过runAllTasks
来触发任务的执行。
上图为NioEventLoop.run
函数的大体内容,主要分为两部分,
任务执行
:遍历触发taskQueue
的缓存的未执行的任务事件遍历
:通过selector.select()
获取已就绪的事件EventLoopGroup
是用来管理EventLoop
的,类似于线程池的管理。
EventLoopGroup继承自EventExecutorGroup
,它实现了整个事件池的管理。
MultithreadEventLoopGroup
是EventExecutorGroup的抽象实现,负责管理事件执行器的生命周期,
主要变量有:
EventExecutor[] children
EventExecutorChooserFactory.EventExecutorChooser chooser
EventExecutor
是基于AbstractExecutorService
的实现,等价于Reator模型中的Handler
。
Netty会根据chooser
逻辑来选择一个事件执行器来处理Channel上的事件。
ByteBuf
是Netty提供的一套高性能Byte缓冲区。
读写索引
ByteBuf
使用readIndex
与writeIndex
来记录读写情况。
ByteBuffer
使用position、limit、capacity
来记录读写情况,通过flip切换读写。
动态扩展
ByteBuf
当内存大小不足以写入数据会触发空间扩容(ensureWritable0
)。
ByteBuffer
的容量固定,超出容量大小会报错。
零拷贝
CompositeByteBuf
是多个ByteBuf的逻辑拼接,使用Component数组
来存储ByteBuf的引用,不会产生复制。
当Component数组大小不足时候,支持自动扩容数组大小,此时的扩容也仅仅涉及到引用的复制。
Netty是典型的Reactor模型,
除了自己的思考以外,内容来源如下:
Distributed locks with Redis
How to do distributed locking
Is Redlock safe?
通常,大家利用Redis实现分布式锁最简单的方案是命令SETNX
。
SETNX
全称为SET IF NOT EXIST
,也就是说如果不存在则设置该值。
那为什么SETNX
可以实现分布式锁的功能呢?
由于Redis的单线程(无锁化的命令执行)特性,可以保证不会出现多个客户端同时执行SETNX的情况。
在Redlock之前,Redis分布式锁大体包含以下四种实现方式,
不过仔细考量下这种实现方式就会发现一些问题,具体如下:
① 加锁成功后没有释放锁,导致死锁
,
1 | // 加锁 |
在上面的业务逻辑
中如果出现业务异常导致无法触发释放锁
的操作,从而导致LOCK_KEY
无法释放,
此时,其他业务线程就会一致等待LOCK_KEY
的释放,从而造成死锁
。
② 为解决死锁的问题,加锁后立即设置超时时间,降低死锁可能性
,
1 | // 加锁 |
与①中不同之处在于新增了[设置默认时间]
,避免了业务逻辑异常导致无法执行释放锁
的操作,
但仍然存在执行加锁成功但设置默认时间失败
的情况,可理解为加锁与过期设置操作的非原子性
,也会造成死锁
。
③ 利用时间特性来生成特定的LOCK_KEY来解决非原子性操作
,
1 | // 生成特定的时间锁 |
利用时间来生成特定的时间锁来解决死锁问题,但是会生成大量无效KEY。
由于与时间相关,业务逻辑的处理时间可能大于锁的有效期
,就会造成业务逻辑还没有处理完的同时其他线程就可以竞争『同一把锁』
。
『同一把锁』是指原始锁是同样的,也就是未执行
FUNC(LOCK_KEY, TIME)
之前的LOCK_KEY
。当执行完
LOCK_KEY = FUNC(LOCK_KEY, TIME)
后,LOCK_KEY将与TIME相关,也就是说不同时段使用不同的逻辑锁
。
④ 使用SET EX NX来解决SETNX与EXPIRE非原子性的问题
,
此方法需要Redis版本大于等于v2.6.12
,如下,
1 | // 加锁并设置过期时间 |
通过该命令很好的解决了方法②中的不足,也不许引入③中的复杂逻辑,实现了设置与过期的原子性操作
,
但其仍然没有解决业务逻辑超长的执行时间导致的锁自动过期的问题
。
上面讨论的实际上都是如何解决互斥
,那么如何保证分布式锁的容错性
并没有讨论。
假设线程锁定的默认时间 > Master节点宕机恢复时间
,考虑以下主备场景,
以上步骤中,Master节点宕机
是关键,再主备场景下主从存在延时,
假设Master节点
宕机前未将A线程的加锁数据同步至Slave节点
,
那么,此时Slave切换为Master后中缺少A线程的加锁数据,从而C线程获取了锁造成了资源的竞争。
最近由于所谓的政治舆论压力,作者将
master-slave
改为了master-replica
。
Redlock
主要就是解决分布式场景下互斥
、死锁
以及容错
这三个关键问题。
Redlock核心思想:基于Quorum机制,采用大于一半的投票方式来实现分布式锁。
Redlock具体步骤如下:
① 采用N(奇数)台相互独立
的Redis实例参与投票
;
② Client随机生成random_val
,向实例发送set(key, random_val, nx=True, px=TTL)
命令的方式获取投票结果
;
每次循环调用Redis实例与上面讲到的单实例情况下分布式锁的实现大体相同,其中,
TTL是业务方来控制的,不设置的话有可能导致死锁
。
③ 由于①中说明存在多个实例,则Client端通过顺序请求
的方式来获取多个实例的投票。
为了避免某个实例问题导致的阻塞情况,增加一个
默认的超时时间以防止长时间的阻塞
。
④ 完成③中的投票后,统计投票结果以及投票过程的耗时,当通过半数(N/2+1)的(设置)成功投票以及超时校验后则认为成功加锁
;
不仅仅需要过半的成功投票,还需要校验整个加锁过程的耗时是否超时,其中锁默认的有效期(defaultValidityTime)是业务相关的。
当④中成功获取锁后,锁的有效期需要减去投票过程的耗时:
validityTime = TTL - elapsedTime
。
⑤ Client加锁失败
的话需要释放掉已经设置成功的实例
。
加锁失败的原因有
加锁超时
、投票未过半
两种情况,无论那种情况都需要释放掉已经设置的锁(这里会尝试释放所有实例的锁)。
原文Safety arguments
中讨论了Redlock是如何保证加锁的安全性。
锁定策略
通过半数投票
机制来实现锁定策略,具体实现如下,
1 | # N为实例数量,m为投票数量 |
锁定时间
锁的实际有效期计算公式如下,
1 | # TTL为锁的过期时间,T1为开始加锁时间,T2为结束加锁时间,CLOCK_DRIFT为时钟偏移 |
可以理解为:
在尝试加锁的开始已经属于锁定时间的一部分
,锁的具体锁定时间与加锁过程耗时有关。
此外,如果任务优先执行完会立即释放锁,从而实际锁定时间可能会小于MIN_VALIDITY
。
锁的释放
锁的释放也包括两种情况:主动释放
与自动释放
。
自动释放是利用Redis自动过期实现的,而主动释放是基于Client主动触发来实现的,具体如下,
1 | if processTime < MIN_VALIDITY: |
在Client删除实例的LOCK_KEY时会去判断VAL值是否是该节点之前设置过的。
STW
Garbage Collector(垃圾回收器)
是用于帮助应用自动回收已过期的内存空间的一种辅助工具。
如何衡量一个垃圾回收器的性能的好坏,大多体现在如何保证更小Stop The World
的。
先看下面这个场景,当STW时间大于持有锁的有效时间时,就会造成分布式锁的异常。
如图所示,Stop The World
导致了线程执行时延,
线程从暂停中恢复时已经超过了锁的有效期,从而导致锁失效,此时另外一个线程尝试并成功获取锁,造成资源竞争。
原文提到:
STW恢复后再次验证锁的有效性
来避免锁定超时的情况。但这里其实存在一个问题:
如果进程执行了一部分逻辑怎么办?
在MySQL中,基于undolog与redview的MVCC实现了事务的功能。
那么在分布式的场景下业务如何保证锁失效导致的资源竞争的问题,是支持业务回滚?还是支持垃圾数据的容错?
实际上,无论进程在做什么都应该有一个耗时的上限,而这个上限是由具体的业务场景决定的(甚至需要考虑STW的时间)。
Blocking
除了垃圾回收造成的长时间STW,进程运行过程中会遇到资源抢占等一系列造成阻塞的问题。
例如,磁盘容量不足、内存容量不足、IO阻塞、CPU的异常抢占策略等等,都有可能造成进程长时间的Blocking。
Martin提出fencing token
来解决上面延时导致的过期问题。
fencing token
可以理解为zookeeper中自增的zxid
或znode的版本号
。zookeeper中,zxid是用于实现事务的全局有序,而znode的版本号是用来解决资源竞争的新老问题(也就是fancing token)。
fencing token
是用于解决新老版本的问题,在分布式锁场景下用于解决锁异常情况下的有效性问题。
fencing token
一定是全局唯一
且全局有序
,
全局唯一
是为了保证锁的唯一性,不允许存在两个进程获取到同一把锁;全局有序
是为了保证锁的有效性,总会认为最后持有的锁有效,也就是所谓的ID最大的有效。Antirez并不认为Redlock存在这个问题,由于Redlock实现了随机数的VAL机制保证了唯一性,已经满足了锁竞争的场景。
如果进程获取锁当前的VAL与本地设置的VAL不同,那么自然可以认为锁已经被抢占了。
Martin提出Redlock锁的有效期与Redis实例的时钟强依赖,而Redis实例的时钟是不可靠的。
假设一种场景,假设共五个Redis实例1~5,两个客户端A与B参与投票:
因此,当时钟发生异常的情况下,客户端A与客户端B均成功获取锁,导致分布式锁失效。
此外,linux提供了两种获取系统时间:CLOCK_REALTIME
与CLOCK_MONOTONIC
参考,
CLOCK_REALTIME
:会发生向前或向后的时钟跳跃,包括认为修改与NTP的影响;CLOCK_MONOTONIC
:只会收到NTP的影响,合理的运维可以尽量避免此问题。Redis使用
CLOCK_REALTIME
,存在时钟跳跃的风险。
Redis作者antirez对Martin提出的不安全问题给出了自己的理由。
Redlock是一种基于客户端实现分布式锁的思路,不仅仅适用于redis。
Redlock的目标用于是哪些使用原本使用Redis作为分布式锁的用户,提高原有分布式锁的容错性。
类似于Zookeeper,只要满足半数投票就可以认为加锁成功,允许部分机器出现异常情况。
Redlock无法保证绝对的安全性,这也是它可以实现高性能的关键。
常见的共识系统为了避免时钟问题,使用fencing token
来的全局有序来保证绝对的安全性,而如何保证全局有序又将成为为系统性能的瓶颈。
虽然很久之前就有打算对这块内容整理一下,不过一拖再拖。
微服务场景下通常会涉及到分布式锁的问题,常用的实现有:
其中,基于数据库来实现可以解决了大部分的场景,ZK/ETCD提供了强一致性的保证,Redis提供了更好的性能。
如何选择还是要看业务场景,分布式锁对于微服务来说等同于JVM的STW、PYTHON中的GIL,无锁话设计才是性能提升的关键。
https://zhuanlan.zhihu.com/p/151436396
https://dbaplus.cn/news-159-3080-1.html
https://zhuanlan.zhihu.com/p/76294773
HTTPS是基于SSL/TLS
来加密的HTTP安全协议。
如图所示,HTTPS协议可分为两部分:安全认证
与加密传输
。
安全认证
Client & Server 利用CA证书验证、RSA非对称加密实现传输密钥的安全传递。
1> Client发起认证申请,向Server请求CA证书
及RSA公钥
;
2> Server接收到认证申请,向Client端下发CA证书
及RSA公钥
;
3> Client验证CA证书
的有效性(签名校验、有效期校验等);
4> Client生成AES密钥
,向Server发送RSA公钥
加密过的AES密钥
;
5> Server接受到加密过的AES密钥
,利用RSA私钥
解密得到AES密钥
。
加密传输
Client & Server 利用AES对称加密实现数据的加密与解密。
1> Client利用AES密钥
加密Request请求;
2> Server利用AES密钥
解密接受到的请求,再次利用AES密钥
加密Response返回;
3> Client端利用AES密钥
解密接受到的返回。
]]>那为什么需要使用两种加密方式呢?
由于RSA适用于不适用于大文件的加密,会导致整个加密过程很缓慢,相反,AES会好很多。
全文基于MySQLV8.0版本。
InnoDB的每一行数据都隐藏了四个字段,具体如下:
DB_TRX_ID
:记录更新该行最后一次的事务ID
。事务ID是不断递增的,最新的事务ID永远大于原有事务ID,便于校验事务是否已经过期。
DB_ROLL_PTR
:记录该行回滚段(Undo Log)的指针
,用于查找该行的历史数据。
DB_ROW_ID
:在没有主键的情况下生成的隐藏单调自增ID
。
被物理删除的DB_ROW_ID会被重用,。
DELELE_BIT
:用于标识该记录是否被删除
。DELELE_BIT状态位属于逻辑删除,MySQL利用后台运行
purge
线程来实现异步的物理删除。
其中,DB_TRX_ID
与DB_ROLL_PTR
是实现MVCC的关键。
undolog
是事务回滚与可重复读的关键。
undolog
属于逻辑日志,包含每行数据的所有版本的数据,用于支持事务与MVCC特性。
1 | /* undo结构 */ |
undo_list
是一个链表结构,链表节点正是trx_undo_t
,也就是说UndoLog在内存里是按照链表存储的。
此外,每一个trx_undo_t
都绑定了对应的事务ID
、操作类型
以及对应的回滚段
。
redolog
属于物理日志,被称为重做日志,记录数据的修改,防止数据丢失,主要用于数据库重启恢复的时候被使用。为了提高持久化效率,可用
Redo Log Buffer(重做日志缓冲)
来缓存变更,达到阈值大小或定期flush到Redo Log File(重做日志文件)
。为确保每次日志都能写入到事务日志文件,每次日志缓存的写入都会调用操作系统
fsync操作
(将OS Buffer中的日志刷到磁盘上的Log File中)。
trx_sys_t
可以被称为事务管理系统,包含MVCC
、事务
以及回滚段
的管理。
利用volatile
来实现待分配事务max_trx_id的可见性
。
活跃的用户事务存储在mysql_trx_list
事务链表中。
1 |
|
Rsegs
回滚段是由trx_rseg_t
组成的数组,也就是说事务管理系统是由多个回滚段组成的
。
1 | /* trx_rseg_t类型为节点的数组 */ |
trx_rseg_t
是事务回滚段的基本结构,是组成回滚段的基本单元。
UndoLog的存储是基于Rollback Segment实现的。
从回滚段的结构中可以得到:UndoLog
是被分为update
与insert
两种类型的。
1 | /* 事务回滚段 */ |
trx_undo_t
的具体结构在上面已经介绍过了,是具体存储UndoLog的结构。
ReadView
是用来判断undolog中多版本数据的可见性。
每一个事务都包含一个
ReadView
。
trx_t
是事务的结构体,每一个事务都包含一个read_view
字段。
1 | /* 事务 */ |
1 | /* ReadView事务可见范围 */ |
InnoDB每次创建一个事务,都会创建一个包含当前活跃事务列表以及保证事务可见性的ReadView。
事务的可见性的判断规则如下:
①
m_ids
中大于m_low_limit_id
的事务不可见;
②m_ids
中小于m_up_limit_id
的事务可见。
针对相同事务的不同隔离级别,m_low_limit_id
与m_up_limit_id
是不同的,
innodb-multi-versioning
trx0sys_8h_source
trx0trx_8h_source
read0types_8h_source
InnoDB事务分析-undo log
InnoDB的事务分析-MVCC
InnoDB 内核阅读笔记(十二)- 事务处理
线程的创建与消耗释有成本的,大体可以分为:CPU资源的消耗
与内存资源的消耗
。
CPU资源的消耗
CPU资源的消耗是指大量的线程会对CPU进行竞争抢占,大量的线程上下文切换会导致CPU的吞吐量降低
。
那么,该如何设置线程数呢?
Using profiling, you can estimate the ratio of waiting time (WT) to service time (ST) for a typical request.
If we call this ratio WT/ST, for an N-processor system, you’ll want to have approximately N*(1+WT/ST) threads
to keep the processors fully utilized. 来源
假设等待时间为WT,实际运行时间为ST,CPU核心数为N,则最佳线程数Num为:
Num = N * (1 + WT / ST)
考虑两种CPU运算:IO密集型
与CPU密集型
。
对于IO密集型来说,线程实际执行的时间很短,假设线程执行时间与线程等待时间近似,
此时,WT/ST≈1
、Num=N*(1+1)=2*N
。
对于CPU密集型来说,线程实际的执行时间很长,会远大于线程等待时间,
此时,WT/ST≈0
、Num=N*(1+0)=N
,
但由于线程切换是基于等待线程的,因此需要增加一个等待线程来避免新线程创建导致的CPU空闲,
因此,Num = N + 1
上面的计算都是基于CPU充分使用的前提下,那么如果保证CPU性能的冗余来估计线程数量呢?
Num = N * U * (1 + WT / ST)
,其中,U为CPU的目标使用率。
内存资源的消耗
首先,线程的申请包括线程资源的申请
、线程资源的初始化
以及线程资源的释放
。
Java中线程独占的资源有
虚拟机栈
、本地方法栈
与计数器
。
除了线程本身的内存占用,线程执行的任务也会占用大量的内存资源。
线程任务占用的内存与具体的业务逻辑有关,如果使用大量的并发线程必然导致内存资源的不足。
例如,一个报表系统,通过接收MQ消息,定时触发报表的生成与上传,
其中,MQ消息接受后会把报表生成与上传的任务放到线程池中异步执行,
但由于近期数据量翻倍增长,导致报表数据翻倍,从而每个任务占用的内存也翻倍了,
在使用原有的线程池时,同样的线程数会占用翻倍的内存,从而产生内存不足的情况。
线程执行由于需要资源的申请、初始化,一定会带有一定的时延。
为什么需要使用线程池?
- 线程池降低资源(CPU与内存)消耗;
- 线程池提高任务响应速度;
- 线程池提供线程的可管理性;
- 线程池提供扩展能力(任务拒绝策略)。
线程池的核心原理是线程资源复用
与线程资源的隔离与管理
。
线程池通过保证一定数量存活的线程来实现资源复用。
线程复用的优点:
① 避免资源的频繁申请与释放,提高CPU与内存的使用效率;
② 避免资源申请的时延,提高任务响应能力。
线程池通过限制存活线程的数量以及任务队列来实现资源的隔离与管理。
资源的隔离
是指不同的业务场景使用不同的线程池,从而实现线程资源的隔离。
资源的管理
是指线程池可以控制线程的数量、缓存任务的数量以及任务执行的顺序。
资源隔离与管理的优点:
① 避免无限的线程申请导致资源耗尽的情况;
② 协调任务线程与工作线程对CPU资源的竞争,避免大量任务线程对工作线程的资源(CPU与Memory)抢占。
核心线程数:默认存活的线程数量CoreSize
;
最大线程数:最大存活的线程数量MaxPoolSize
;
空闲线程存活时间:核心线程之外的空闲线程最长存活时间KeepliveTime
;
以上三个参数是用来设置线程池中存活线程的数量,是影响线程池的吞吐量以及任务执行时延的关键。
任务队列:队列类型与大小WorkQueue(n)
;
任务拒绝策略:当线程池无法及时响应任务时的执行策略(仅配置有界队列才会触发)。
以上两个参数是用来设置线程池可以缓存任务的数量以及过多任务的拒绝策略,是影响线程池的最大响应能力以及降级策略的关键。
线程池的实现原理如图所示,
线程调度策略
线程池的调度是基于任务队列实现的。
基于任务队列,线程池实现了任务与线程解耦。
任务队列负责任务的存储以及分配,工作线程负责任务的执行。
任务投递到线程池会优先加入任务队列,工作线程会从任务队列竞争获取待处理的任务。
具体调度规则如下:
任务分配策略
任务分配模式是基于任务队列
与拒绝策略
组成。
任务队列可以分为三类:有界队列
、无界队列
和同步移交队列
。
有界队列是指队列大小有限
,缓存任务的数量存在上限,当缓存数量达到上限时会触发拒绝策略
。
无界队列是指队列大小无限
,如果线程池的吞吐量不足会导致缓存任务不断增多,由于无界队列不会触发拒绝策略
,因此,可能导致内存不足
。
同步队列是指任务直接交给工作线程,不存在任务缓存的情况,提交任务会阻塞至空闲线程的认领
。
无界队列:LinkedBlockingQueue、DelayedWorkQueue
有界队列:ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue
同步队列:SynchronousQueue
newFixedThreadPool
使用LinkedBlockingQueue
作为无界队列(存在OOM的可能),线程数量固定,因此,此线程池的吞吐量有限。
newCachedThreadPool
使用SynchronousQueue
作为同步队列,虽然不存在无限增大的任务队列,但是存在无限增加的线程,因此,此线程池的线程数量不可控。
newScheduledThreadPool
使用DelayedWorkQueue
作为无界队列,线程数量无限,任务数量与线程数量均不可控,因此,此线程池也存在很大的安全隐患。
阿里巴巴开发者手册规范:
线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,避免资源耗尽的风险。
线程分类
线程一般可以分为两种类型:IO密集型
与计算密集型
。
IO密集型不会占用大量的CPU资源,大部分线程处于IO阻塞的情况,因此可以使用更多的线程来处理任务。
计算密集型会占用大量的CPU资源,线程切换只会影响计算效率,因此不建议使用大量的并发线程。
CPU与内存使用率
大量的计算密集型线程会导致CPU的抢占,会降低CPU的使用效率,导致更大的平均时延。
大量的IO密集型线程由于IO阻塞会占用更多的内存资源,会降低内存的使用效率,可能造成OOM。
吞吐量
吞吐量是指线程池在单位时间内可以执行的任务数量。
工作线程是影响任务并发量的关键因素,也就是如何设计CoreSize
与MaxCoreSize
是影响线程池吞吐量的关键。
此外,业务任务往往会占用大量的内存资源,从而也需要考虑是否存在核心线程数过多导致的资源浪费
。
如果一个业务请求逻辑会涉及多个资源调用,可以使用线程池来并行请求多个资源。
虽然这种方式一定会降低资源请求的时延,但也会导致线程数量的倍数增长。
队列的选择
一般建议使用有界队列,由于可能触发任务丢弃策略,因此需要自定义实现降级策略。
线程数该如何选择?
吞吐量的计算公式为:TPS = 完成任务数量 / 完成的时间
大量高并发任务请求会造成线程池吞吐量不足的情况,需要考虑降级策略。
增大线程数量可以提高任务并行能力,从而提供线程池的吞吐量,这种用法其实是很危险的,属于空间换时间的一种策略。
也存在资源浪费的问题,由于前面集中资源出现问题,导致后面的资源无需加载的情况。
是否采取这样的方式与具体的业务场景有关,线程池是否可控,是否需要考虑降级策略等等。
数据合聚合与处理任务,由于数据量的不断增大,原有顺序执行的方式所需要的时间成倍增加,因此,需要拆分为多个子任务并行执行。
后台任务一般属于CPU密集型,线程数应该与CPU数量一致,过多线程只会增加CPU的抢占。
并行任务可能会占用多倍的内存,因此需要仔细计算内存占用情况是否会导致机器内存不足。
线程池只是一种资源集中管理与优化的手段,它并不能解决资源不足与资源竞争的问题。
在线程池无法支持当前任务的情况下,需要提前设计适当的降级策略。
线程池的降级策略实际可以理解为线程池的拒绝策略
。
那么如何设计线程池的拒绝策略?
① 动态调整线程池配置,线程池的配置应该与具体的业务场景有关。
在高并发的场景下,动态调整线程池的配置对机器CPU、内存会造成不同情况的影响,严重情况下导致系统不可用。
② 异常报警,是异常监控的必要手段。
③ 任务持久化到DB、MQ,利用异步线程、延迟消息等方式再次触发。
Java线程池实现原理及其在美团业务中的实践
如何合理地估算线程池大小?
Java线程池-ThreadPoolExecutor原理分析
Thread pools and work queues
Thread Pools in Java
Oracle ThreadPoolExecutor
Replication
被称为主从
架构,通过增加复制副本来提高数据的容错性与服务的可用性。
数据的同步方式有AOF与RDB
,其中RDB
属于内存镜像的全量同步,AOF
属于基于操作日志的增量同步。
优点
缺点
Sentinel
被称为哨兵
,通过增加额外的监控系统来实现主从的动态切换,解决Replication
的问题。
由于Sentinel仍然存在这但点问题,因此一般会采用多个Sentinel来实现监控集群,实现高可用的双重保护。
优点
缺点
Codis
是第三方实现的一套高可用方案,利用中间件实现Redis的集群功能。
其中,codis proxy
被设计为无状态,可利用HAProxy
或Zookeeper
实现高可用。
优点
缺点
Cluster
是无中心化
架构,集群中的各个节点相互通信达成共识,并把客户端请求通过slot
路由到目标数据节点。
优点
缺点
Redis
实现了一套事件驱动器AE
,理由也很简单,逻辑简单可控。
Memcached
的事件驱动时基于libevent
的。
aeEventLoop
是整个事件驱动的核心,事件的注册与触发都基于此。
1 | /* 事件循环结构体 */ |
aeFileEvent
是具体的事件结构,其中,包含了事件类型
与对应的处理函数
。
1 |
|
aeCreateEventLoop
是用于初始化事件循环结构体。
setsize
是Redis支持句柄的数量,在eventloop初始化时用于初始化事件的存储大小
。
1 | /* 初始化事件循环 */ |
Redis
支持多个地址端口的绑定,文件句柄都存储在server.ipfd
,
1 | int ipfd[CONFIG_BINDADDR_MAX]; // 用于监听客户端请求的文件句柄 |
aeCreateFileEvent
是用于为监听的句柄创建事件及其对应的处理器,其中,aeApiAddEvent
存在多种IO多路复用的实现。
1 | /* 为句柄创建事件 */ |
acceptTcpHandler
是Socket.Accept
接收到新的客户端请求的处理器,用于设置后续的可读事件,
1 | /* 处理监听到的Accept请求 */ |
acceptCommonHandler
是用于Accept后用于初始化客户端并设置可读事件
的处理器。
1 | static void acceptCommonHandler(int fd, int flags, char *ip) { |
到此,
Redis
服务端的Socket绑定与监听
、客户端初始化
等初始化逻辑已经分析完毕。
Redis的处理过程是单线程的
,事件驱动的核心就在aeMain
这个循环体内。
基于内核提供的
select/poll/epoll
来轮询事件实现循环执行。
1 | void aeMain(aeEventLoop *eventLoop) { |
aeProcessEvents
是具体的处理逻辑,包括了事件获取
、事件分发
。
1 | /* 事件处理函数 */ |
从上面的源码分析得出:
事件的收集工作是系统负责的,Redis仅通过每次的循环来不断拿到最新的触发事件
。
AE事件驱动的原理:IO多路复用
在上面的处理过程中,使用了aeApiPoll
这个函数,按不同平台的实现方式有:
ae_evport
:Solaris 10ae_kqueue
:OS X / FreeBSDae_select
:通用的ae_epoll
:LinuxWhat are the underlying differences among select, epoll, kqueue, and evport?
Select
最多支持1024个文件句柄,由于每次都需要遍历所有的操作符的状态,因此,时间复杂度是O(n);
Evport
、Epoll
、KQueue
支持更多的文件句柄,基于系统的实现策略不需要遍历操作符,时间复杂度是O(1)。
这里仅分析基于epoll
的实现。
1 | static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) { |
1 | // AE事件获取 |
读取
readQueryFromClient
是从客户端读取请求数据的处理函数,
1 | // 每次读取Buffer的默认大小:16K |
执行
processInputBuffer
是用于解析客户端请求的Buffer并调用processCommand
执行对应的操作,
1 | /* 处理缓存内的请求数据 */ |
processInlineBuffer
与processMultibulkBuffer
是用于命令解析的,如果解析成功则执行命令,否则,进入下一轮循环读取剩余Buffer。
1 | /* 执行命令 */ |
call
是在没有事务的情况下,直接执行,
1 | /* 执行命令 */ |
写入
addReply
是用于给客户端返回执行结果,
1 | /* 请求返回 */ |
clientInstallWriteHandler
是客户端回写处理器,它仅会把写入命令缓存到写入队列
中,并不会直接返回结果
。
1 | /* 把客户端加入等待回写队列 */ |
此时,Redis内部对客户端的请求处理完毕,还没有发现哪里完成了最后结果的写入。
再回头看一下
beforeSleep
到底做了什么?
1 | /* 每次循环一次执行一次 */ |
writeToClient
是用于回写数据的处理函数,回写数据位于client->bufpos
与client->reply
,
1 | /* 回写数据 */ |
在看下afterloop到底做了什么,
1 | void afterSleep(struct aeEventLoop *eventLoop) { |
加全局锁的目的是为了避免模块与Redis框架并发读写数据的问题
。
call
命令会自动调用addReply
进行数据的回写,
以set
命令为例,对应的函数为setGenericCommand
,
1 | void setGenericCommand(redisClient *c, int flags, robj *key, |
DDD(Domain Driven Design,领域驱动设计)
是基于业务领域实现的一种软件架构方法。
DDD
的作用是将业务架构
转变为系统架构和技术架构
。
按照架构角度的不同,可分为如下三类:
业务架构
:业务视角,描述业务模块以及关系;业务架构的原型往往是业务方最先提出的,包含大体的业务需求。
完整的业务架构图是由业务、产品、技术在不断的需求迭代与架构演进不断完善的。
应用架构
:应用(系统)视角,描述应用系统及其依赖关系;应用架构图是架构演进的结果。
随着业务的发展,会带来组织划分与应用拆分,应用架构也在不断演进。
技术架构
:技术视角,描述应用技术选型及其功能作用。技术架构是当前系统(应用)的技术细节的描述,包括技术选型以及其应用场景。
技术架构是面向技术人员的,用于了解软件设计的细节。
开发视角的转变:从单纯的技术视角
转变为业务视角
。
开发人员往往从技术角度来剖析需求,大部分情况缺少从业务角度的全局思考,对业务与架构的演进缺少前瞻性导致架构的局限性。
尤其在业务开展的初级,开发人员对业务理解不够深入。
领域专家
是指那些掌握领域内知识的人,对该领域有很深入的理解。
产品
、技术
、业务
都可以是领域专家,其中,业务
一般掌握着最真实的领域知识。
与领域专家
沟通学习是架构设计的基础,无论是产品、技术、架构师,都需要与领域专家深入的学习领域知识。
由于领域专家并不一定是产品、技术,因此与领域专家的沟通必然存在一定问题(行业不同)。
通用语言
是解决领域专家与产品开发沟通的关键。
让大家都能听懂的专业名词,例如,SPU是商品,SKU是某种规格的商品。
基于通用语言,与领域专家沟通学习领域知识,抽象出一套领域知识模型。
限界上下文
是领域的边界,也是领域模型的边界。
康威定律
可以作为领域划分的一种规则。组织与领域按照一一对应的关系来划分,实现领域的高内聚。
领域内可以包含多个限界上下文
,此时,可以划分为多个细分子域
。
根据细分子域的不同性质,又可以划分为:
核心子域
:领域的核心,包含该领域内的核心领域知识;支撑子域
:用于支撑核心子域;通用子域
:公共的功能模块。领域模型需要不断演进来满足业务的迭代。
业务迭代会造成领域模型的拆分、增加,都会对原有架构产生巨大的挑战。
领域划分是从全局来考虑架构结构的划分,属于架构顶层功能模块的划分。
对于开发人员来说,细分子域的领域模型的构建
才是软件实现的关键。
开发人员不仅仅不要掌握细分子域,也需要了解和掌握整理领域的划分。
实体
是指具有唯一标识
的对象,例如,User(实体)包含唯一的用户ID。
值对象
是指不包含唯一标识
的对象且不可修改
,例如,Address对于其他用户来说,只是用户的一个属性,不可修改。
实体与值对象的区别在于唯一性
。
例如,不同规格的商品属于不同实体;规格只是一种属性,对于商品来说属于值对象。
聚合
是指一组相关对象的集合,聚合根
作为聚合根节点,并由实体
与值对象
组成。
聚合属于领域内知识的子集
,可包含多个子域知识,用于对外提供领域服务。
领域模型
是指领域知识的抽象描述
,用于指导软件架构设计。
领域事件
是指领域内发生事件的描述
,用于驱动关联领域的变更,可采用发布订阅模式实现。
领域服务
是指领域对外提供的查询或更新服务
,用于外部调用。
领域建模的步骤
康威定律
康威定律
是指系统结构应尽量的与组织结构保持一致。
对于多个团队来说,按照团队划分顶层领域边界;
对于团队内部来说,按照小组来划分细分子域的边界。
软件架构上一般包括为CQRS
、六边形架构(适配器架构)
、分层架构
。
CQRS
CQRS
被称为命令和查询责任分离
,通过拆分读写模型来实现避免资源竞争。
CQRS按照读写
把模型拆分为:
命令 - 领域模型
:用于持久化,包括领域行为,结构相对简单,事务支持;查询 - 视图模型
:用于展示,属于非规范的模型,不包括领域行为;通知 - 领域事件
:用于发布领域变更事件,包含领域行为。六边形架构(适配器架构)
六边形架构(适配器架构)
是基于适配器的设计模式的架构模型,
其主要特性如下:
依赖倒置
原则来分离技术框架接口来实现相互依赖
防腐层与适配器
来实现外部技术框架的依赖六边形架构屏蔽了外部技术框架的细节,内部功能高内聚,外部依赖低耦合
。
分层架构
分层架构
是最常见的架构模型,按照功能层级拆分为:
用户层
:聚合多个顶层领域的数据,用于用户展示;应用层
:聚合多个细分子域的数据,用于对外提供服务;领域层
:细分子域的领域模型,可发布领域事件、执行领域行为,包括实体阈值对象的定义;基础设施层
:提供持久化、防腐层等基础中间件的功能。架构也被称为软件架构
,是软件结构的抽象描述
。
架构是由架构元素
与元素之间的关系
组成,元素的划分、选型、交互
是架构的关键。
架构元素:组件/服务的划分与技术选型。
元素之间的关系:组件/服务之间的关联关系与交互方式。
架构的目标是解决利益相关者的关注点
(参考)。
利益相关者
是与当前架构有之间或间接关系的人,也就是当前架构的目标用户,例如,业务方、产品、开发、测试等等。关注点
是利益相关者
对于当前软件的认知及痛点。
解决不仅仅是解决当前的问题,还要有前瞻性(对未来业务变化的考量
)。
这也就是保证架构的可迭代、可演进。
架构是有生命的
。
好的架构生命周期很长,支持业务的快速迭代,不断演进、进化,
相反,不好的架构没办法支持业务的迭代,不得不得对架构进行重构、重写。
领域建模
好的架构离不开好的领域建模
。
领域建模
是架构设计的基础,它确保了架构设计的边界。
通过领域建模将领域知识转化为软件架构,这里离不开领域专家
的配合。
架构不是凭空想象的,是基于
对领域知识(领域专家提供)建模
的基础之上来设计的。
对于大部分人来说,当提及领域建模时往往觉得时高大上、遥不可及的事情,事实并非如此。
领域建模是业务高度抽象的产物,范围可大可小,但需要建模者充分理解并调研领域知识。
对领域知识理解不足,造成的建模不准确?
不要怕,最好的建模通过演进实现的,试错修正、试错优化。
真正的需求
架构设计的目标是通过构建合理的元素关系来满足用户的需求
。
由于视角的不同,利益相关者的关注点可能各不相同。
那么,好的架构一定是基于以下两点:
① 发现所有直接或间接的利益相关者;
② 沟通与理解利益相关者的关注点。
尽可能收集利益相关者的关注点
作为架构设计的基础。
如何把关注点转换为领域知识
是架构设计的必经之路。
最小化利益冲突
在沟通过程中,利益关注者之间往往会出现利益冲突的情况。
如何最小化或避免利益冲突也属于架构设计的一部分。
好的架构是基于充分的调研与思考,保持利益相关者之间的利益平衡
。
大中台小前台
的架构设计上是存在业务方与技术方的利益冲突
。前台业务方考虑的是
快速迭代试错
,而中台技术方考虑的是平台的稳定性与通用性
。
什么是合理架构
?
① 满足大多数利益相关者的关注点
(功能点);
大多数意味着存在取舍,
合理的取舍
也是架构设计的一部分。
② 最少的开发成本、最快的上线速度
,满足业务方快速迭代试错;
快与质量差没有任何关系,
不要以快为不合格的架构设计而找理由
。
③ 可持续演进
的架构,需要经得起业务的不断演进。
刚好够用且可持续优化
即为合理,一切不以现状为基础的设计均属于过渡设计
。
开发人员,往往会把追求极致挂在嘴边,但是,
追求极致
实际上也是一种过渡设计。
追求极致必然会增加功能的复杂度,无论是人力成本还是时间成本都是无法容忍的。
架构设计需要从公司当前的业务、人员、成本等多方面考虑
,避免吹毛求疵、过渡设计。
"技术债"
这个词很流行,但不是所有的技术债都是无法避免的。架构的演进伴随着技术债的填补,也伴随着新的技术债的产生,但要
时刻警惕非必要技术债的产生
。
没有理想的架构,只有最合适的架构
。
无论哪个公司都会经历从简到繁的架构演变
的过程。
架构的演进
是建立在合理架构的基础之上。
SOLID设计原则主要用于解决如何构建可持续性的软件架构。
架构的演进不仅包含业务逻辑上的演进
,也包括领域模型的演进
。
业务逻辑上的演进
是指随着需求的不断迭代对原有业务逻辑的影响。
领域模型的演进
是指随着需求的不断迭代对原有领域模型有了更加清晰的定义,它是构建可持续演进架构
的基础。
架构设计的关键问题是划清边界
,而架构演进的关键问题是领域模型的不断演进
。
领域演进
随着业务的演进,领域模型会面临拆分的问题,同时也会产生各个细分领域的领域专家。
细分领域的领域专家
是细分领域建模的关键,也是细分领域架构设计的关键。
与此同时,架构也会面临一次拆分,不同的细分领域负责各自的架构设计,架构设计更加聚焦,这也是架构演进的必然结果。
闭环
是指拥有一套完整的反馈机制,架构闭环
是指拥有一套完整的系统监控与预警机制。
架构演进包括两部分:性能演进
与逻辑演进
。
逻辑演进与性能演进是不和分割的。
逻辑的演进会推动性能的演进,性能演进是为了保证逻辑的演进。
架构闭环是性能演进的必要条件。
评估架构性能瓶颈、监测系统运行情况是性能演进
的关键。
康伟定律
:架构与组织是一一对应的关系。
好的架构是依赖优秀的人来实现的
,而优秀的人是依赖好的文化来吸引的
。
公司中总能听到一些提效的要素,例如,工具、技术、流程。
我们要使用最先进的工具;
我们要使用最牛逼的技术;
我们要推广最高效的流程。真的是这样吗?
架构设计时需要考虑架构维护成本,一般需要按照组织来拆解架构(组织 : 架构 = 1 : 1)。
按组织拆解架构的好处在于架构的高内聚
,也就是说以组织为单位划分架构设计与演进的职责
。
组织内部的架构设计更加聚焦,领域知识也更加聚焦,
从而,领域模型更加清晰,架构设计也会相对轻松(由于目标更聚焦了)。
]]>读写分离
是通过分离数据库的读写操作
,通过横向扩展的能力来提高读性能
。
如图所示,Master
称为主库,仅处理数据库的写操作
;Slaver
称为从库,仅处理数据库的读操作
。
读写分离的实现可划分为两类:基于客户端实现
与基于中间件实现
。
与分库分表的实现思路相似。
两种实现的原理都在于请求的动态路由
,根据请求的分类"读、写或事务"
来动态的路由到指定的数据库实例。
基于客户端实现
基于客户端实现
是通过嵌入业务层来实现请求路由的功能,
优点是性能好
,
缺点是升级困难
、问题排查难
、客户端复杂
。
由于路由功能完全内嵌在业务应用,日志也会分散在不同的业务应用,因此,问题的上报与排查都需要业务方深入合作。
基于中间件实现
基于中间件实现
是通过中间件拦截请求转发(动态路由)
到指定的数据库实例,
与基于客户端实现相反,
优点是升级方便
、问题排查容易
、客户端无感知
,
缺点是性能损耗大
、业务方可能存在读异常
、引入新的单点问题
。
路由功能的实现与业务应用无关,中间件可以做到无感知的升级,而且由于路由日志集中在中间件,排查问题更加容易。
MySQL主从同步
是利用同步binlog日志同步以及操作重放实现
的数据同步。
binlog
:MySQL数据库的二进制日志,用于记录用户对数据库变更操作的SQL语句
。
步骤:
① 当Slaver连接到Master时,Master会为Slaver开启binlog dump线程
;
binlog dump线程
用于读取binlog信息同步到Slaver
。
② Slaver会创建I/O 线程
以及来处理binlog dump线程
的数据,写入relay log
;
relay log
是为了避免同步数据过程中的异常,导致数据的丢失,在半同步复制
中有介绍。
③ Slaver还会创建SQL 线程
用来解析relay log
重放操作写入数据,完成数据同步。
同步过程中,
SQL解析执行一定是单线程的
,否则,会造成执行顺序错乱影响数据一致性。
主从同步是通过异步线程同步数据,属于最终一致性
的实现方案,因此,必然存在主从不一致的
问题。
异步模式
异步模式
是最基础的同步方式,必然存在延迟。
为了解决异步模式
的延时问题,MySQL提出了下面几种方法来解决这个问题。
半同步模式(semi-sync)
半同步模式
是通过强制写入relay log
来保证至少有一台从库完成了数据同步
。
仅保证写入
relay log
的延迟,无法保证写入数据的延迟,因此,半同步模式不能彻底解决问题。
对于一主一从的读写分离的情况下,此方法可以使同步延迟的问题忽略不记。
但在一主多从的读写分离的情况下,此方法就不一定会生效了,可能存在某个从库没有即使同步数据。
全同步模式
全同步模式
是在半同步的基础之上保证全部从库同步数据。
这里的保证是relay log的写入还是数据的写入?
仍然是relay log(I/O线程与SQL线程不应该再次通信),只要日志flush到磁盘就不会出现数据丢失的情况。
并行模式
并行模式
是通过增加SQL线程
来实现并行读取relay log
实现库级别的并行
。
背景
并发写入数据可能造成主从延迟增大。
解决
① 增加数据缓存
,更新数据的同时写入缓存(读全部从缓存取数据)。
② 开启半同步、同步复制
,降低时延问题,但会影响写入性能。
③ 强制路由到主库
。
④ 优化业务逻辑,对于异步操作通过使用延迟队列
、重试
的机制来解决延迟问题。
常用的方式如下,
第一种:ParNew(新生代)
、CMS(老年代)
第二种:G1(新生轻与老年代)
按照GC范围的不同,GC可分为:MinorGC(YoungGC)
、MajorGC(FullGC)
、OldGC
、MixedGC
。
MinorGC
是新生代GC
,在分代回收中负责堆内存年轻代的回收。
触发条件
当
Eden区空间不足
就会触发MinorGC
。
在分代回收中,如果年轻代的空间不足会导致年轻代的垃圾回收。
回收算法
由于新生代的对象生命周期较短,垃圾回收也会相对频繁,因此需要考虑垃圾回收的效率。
考虑到新生代朝生夕死
的特点,新生代采用复制算法
,例如,ParNew中仅复制存活数据到Survivor
。
具体步骤
① 扫描新生代以及老年代;
由于存在
跨代引用
,因此需要扫描老年代。CMS使用CardTable来记录新老代之间的跨代引用,
G1使用RememberSet(占堆的20%)来记录Region之间的跨代引用。
② 复制存活对象到Survivor区,增加对象年龄;
复制属于耗时操作,但由于
Eden区对象朝生夕死
的特点,需要复制的对象并不多。。
③ 年龄达到阈值,晋升至老年代。
注意:
单次MinorGC时间更多取决于GC后存活对象的数量
。
OldGC
是老年代GC
。
CMS
属于OldGC
,仅对分代回收中的老年代进行老几回收,一般配合新生代ParNew
。
触发条件
① 手动触发System.gc()
;
② 老年代的使用率达到阈值;
回收算法
由于老年代的对象生命周期较长,存活的对象比例高,因此无法使用复制算法。
CMS
采用标记清除算法
,同样可以开启内存整理
功能来避免内存空间碎片的问题。
标记清除中常见的问题:
STW(Stop The World)
。
STW(Stop The World)
是为了保证标记的一致性,避免标记过程中出现遗漏的情况。
STW并不是必须的,但是在极端情况下,遗漏可能会导致大量的内存泄露,甚至导致宕机。
具体步骤
① Initial Mark
:从GC Root
开始标记可达对象,触发STW
;
什么是
GC Root
?Local variables are kept alive by the stack of a thread.
Active Java threads are always considered live objects and are therefore GC roots.
Static variables are referenced by their classes.
JNI References are Java objects that the native code has created as part of a JNI call.
② Cocurrent Mark
:根据①中标记的可达对象并发遍历标记
相关联的对象状态;
③ Remark
:重新标记在并发标记阶段发生变化的对象,触发STW
;
④ Concurrent Sweep
:在对象标记的基础之上,清理老年代非可达对象。
MajorGC
是FullGC
,不仅会触发OldGC
,也会触发YoungGC
。
MajorGC
是收集整个堆内存。
MajorGC
会自动触发MinorGC
。
MixedGC
是新生代与老年代的混合GC
,它不属于FullGC
。
MixedGC
是建立在YoungGC
的基础之上再回收部分老年代的内存
。
G1
属于MixedGC
。
G1
通过划分Region
来实现增量回收。
触发条件
当E区
无法分配新的对象内存时会触发G1中的YoungGC
。
当使用率大于InitiatingHeapOccupancyPercent
会触发MixedGC
。
当Metaspace内存不足
时会触发MixedGC
。
回收算法
YongGC
选定所有年轻代Region
进行回收,使用复制算法
。
MixedGC
不仅选定所有年轻代Region
,还会根据global concurrent marking
统计得出收集后收益高的若干老年代Region
来选择,最终采用标记清除算法
。
G1
保证在用户指定的开销目标范围内尽可能选择收益高的老年代Region
进行回收。因此,
MixedGC
不是FullGC
。
当MixedGC无法解决老年代内存不足
时,会降级为SerialGC(FullGC)
。
具体步骤
① Initial Mark
:同CMS的步骤①;
② Cocurrent Mark
:同CMS的步骤②;
SATB
是GC开始时活着的对象的一个快照(通过Root Tracing
得到),用于维持并发GC的正确性。
③ Remark
:与CMS的区别在于重新标记的范围不同
:G1仅需要扫描SATB(snapshot-at-the-beginning, 起始快照)
;
CMS Remark
的扫描范围不仅包括SATB
,而且会扫描整个根集合。
④ Clean up/Copy
:在对象标记的基础之上,清理新生代全部非可达
对象与老年代部分非可达
对象。
FullGC产生的原因有以下四种,
MetaSpcae空间不足与自动扩容
在Java8中,当MetaSpace空间内存不足
时,会触发FullGC来尝试清理掉无用的内存。
为了避免这种情况,可以通过增大MetaSpace空间大小
与预设足够大的MetaSpace空间避免动态扩容
。
CMS的promotion failed与concurrent mode failure
MinorGC
时,如果Survivor
空间不足,对象会直接进入老年代,
但由于老年代有碎片或者剩余空间不足
导致没有足够空间存储晋升对象,就会产生promotion failed
。
promotion failed
会导致GC降级为SerialGC(Old)
。
解决方法:
① 增大Survivor大小;
内存整理
属于耗时操作,会造成STW
。
② 设置老年代的内存碎片整理
功能以及合理的整理周期
。
CMS GC
时,如果由于某种原因,业务线程直接在老年代内分配对象,但老年代没有足够的空间,就会产生concurrent mode failure
。
同样,
concurrent mode failure
会导致GC降级为SerialGC(Old)
。
解决方法:
① 增大老年代大小
,避免老年代的内存不足;
② 设置老年代的合理回收阈值
,尽早释放内存空间,保证老年代的剩余空间大小;
Young GC晋升的平均大小大于老年代的剩余空间
当老年代的内存空间存在内存不足的风险
时,会触发FullGC,如果频繁出现此类情况,需要关注晋升对象大小
以及是否存在内存泄露
的情况。
主动触发Full GC(System.gc)
应用通过调用System.gc()
来触发FullGC
。
-Xms & -Xmx
-Xms
: 初始堆大小。
-Xmx
: 最大堆大小。
一般情况下设置为相同值,避免内存扩展。
-Xmn
-Xmn
: 新生代大小。
Sun官方推荐配置为整个堆的3/8
,但是不同的业务场景应该不同。
对于Web应用3/8设置并不合理
,原因在于:
每个请求的生命周期较小,尤其对于高并发的场景下,大量的并发会导致新生代快速填满;
由于新生代内存不足,请求对象直接进入老年代,这部分对象并不会被MinorGC清理,从而造成内存空间的浪费。
① 增大Eden增大触发间隔:
② 增大Eden对单次MinorGC时间的影响不大:
复制是一个耗时的操作,但Eden区由于对象的生命周期较小,需要复制的对象也不会增加太多。
因此,
增加Eden区大小可以提高新生代内存回收(MinorGC)的效率
。
-XX:MetaspaceSize & -XX:MaxMetaspaceSize
该用于设置元空间的大小,存在内存不足
与自动扩容
的情况,可能造成MajorGC
。
64位JVM默认20M,最大值为宿主机内存大小。
-CMSScavengeBeforeRemark
该配置时用来保证Remark前强制进行一次MinorGC
,从而减少Remark的时间
。
考虑到新生代对象的生命周期很短,在
触发CMS之前强制JVM执行一次Minor GC
,清理掉无效的对象,避免大范围的Remark
。
-XX:UseCMSCompactAtFullCollection & -XX:CMSFullGCBeforeCompaction
该配置是用于解决promotion failed
,
分别为开启开启CMS GC的内存整理功能
和设置CMS GC的内存整理频次
,
从而减少内存碎片造成的内存不足。
-XX:CMSInitiatingOccupancyFraction & -XX:+UseCMSInitiatingOccupancyOnly
该配置是用于解决cocurrent mode failed
,
分别为设定CMS在对内存占用率达到X%的时候开始GC
和设置JVM回收阈值(不基于运行时收集的数据来启动CMS垃圾收集周期)
,
从而避免垃圾回收不及时造成的内存不足。
-XX:NewRatio
该配置是用来设置新生代与老年代的比例
(默认2)。
调整新生代的大小可避免大量短生命周期对象进入老年代。
-XX:MaxGCPauseMillis=n
该配置是用来设置最大GC停顿时间的目标
,主要是为了降低STW对应用的影响
。
虽然设置了最大GC停顿时间,但这只是JVM自动回收优化的目标,不保证每次都会低于该配置的值。
-XX:G1ReservePercent
该配置是用来设置堆内存保留空间的大小
,用以降低Eden晋升失败
的可能性。
当对内存空间大小达到堆内存预留值会触发MixedGC。
-XX:G1HeapRegionSize
该配置是用来设置堆Region的大小
,默认1~32。
在特殊的业务场景下可能存在小生命周期的大对象的产生,可以通过此设置优化Region大小解决大对象直接进入老年代的问题
。
美团的案例
https://www.dynatrace.com/resources/ebooks/javabook/how-garbage-collection-works/
https://hllvm-group.iteye.com/group/topic/44381#post-272188