知识点
目录
[TOC]
TCP协议的可靠性机制
一段数据经由传输层从源节点送到目的节点的过程中,要经过网络中的有大量路由节点。节点可能发生故障,这可能会带来报文丢失或报文重复发送(网络二将军问题)的问题。不同的报文可能走不同的路径到达目的节点,这可能会带来报文乱序到达的问题。这些不可靠因素归结起来就是:丢包、重包、乱序。而TCP的设计就是为了解决这些问题。
1)TCP解决丢包问题利用的是ACK+重发机制:每个报文都有序号,接收端收到一个报文后要向发送端回执ACK=xxx(报文序号)+1,若发送端没有收到这个回执,就会重发这个序号为xxx的报文,直到收到回执。这种思想整体上来讲是对的,但是逐一对数据包确认效率太低,实际上TCP对此进行了改进,比如接收端同时几乎同时接收到了序号为098、099、097的报文,它只需回执ACK=099+1即可,代表099及之前的报文都已收到。
2)TCP解决重包问题利用的是报文序号,若接收端已经发送了ACK=099+1,代表099及之前的报文都已收到,当接收端再次收到序号为095的保温时,直接丢弃即可。
3)TCP解决乱序问题利用的还是报文序号,例如接收端收到了序号为066、067、069、070的包,会先回执ACK=067+1,然后等待序号为068的报文。由于发送端只收到ACK=067+1,会重传068及之后的报文,直到接收端拿到068及之后的报文进行回执。这个过程可能会发生报文的重复发送,按照2)去重即可。
分布式系统中的消息中间件解决消息的丢失、重复、乱序也用到了和TCP类似的思想。
TCP连接建立的三次握手过程
+--------+ +--------+
|sender | |receiver|
+--------+ +--------+
| |
PING1| ------------SYN seq=x----------> |
| <------SYN seq=y, ACK=x+1------- |PONG1+PING2
PONG2| -------------ACK=y+1-----------> |
| |
为什么需要三次握手,看起来好像两次就够了?
TCP建立的是一个双向的传输通道,因此双方都要对对方的存在做一次试探,试探要得到回执。讲得通俗一点,双方都要对对方来一次ping-pong。这就是上图的三次握手过程。
注:ACK和SYN可以合在一起发送,所以receiver可以一次性PONG1+PING2。
TCP连接断开的四次挥手过程
+--------+ +--------+
|sender | |receiver|
+--------+ +--------+
| |
PING1| -------FIN seq=x+2 ACK=y+1-----> |
| <------------ACK=x+3------------ |PONG1
| <----------FIN seq=y+1---------- |PING2
PONG2| -------------ACK=y+2-----------> |
| |
那为什么断开连接要四次挥手?
TCP建立的是一个双向的传输通道,因此双方都要主动撒一次手,并且得到对方的确认。
注:ACK和FIN不能合在一起发送,所以receiver不可以一次性PONG1+PING2。
InnoDB的事务与锁
多个事务的并发会出现以下几个问题:
1)脏读:事务A读取了一条记录的值,然后基于这个值做业务逻辑,在事务A提交之前,事务B回滚了该记录,导致事务A读到的这条记录是一个脏数据;
2)不可重复读:在同一个事务里面,两次读到同一行记录,但结果不一样,因为另一个事务在此期间对该条记录进行update操作;
3)幻读:在同一个事务里面,同样的select查询语句执行两次,得到的记录条数不一样,因为另一个事务在此期间对表记录进行insert/delete操作;
4)丢失更新:两个事务同时修改同一条记录,事务A的修改被事务B覆盖了。
为了解决这些问题,数据库设置了不同的事务隔离级别。包括:
1)读未提交:
2)读已提交:只解决了脏读问题
3)可重复读:解决了脏读、不可重复读、幻读
4)串行化:解决了脏读、不可重复读、幻读、丢失更新
以上四种隔离级别一级比一级严格,通常读未提交和串行化不会被数据库查询引擎采用,因为前者什么隔离措施都没有做,后者将事务完全按照先后顺序依次执行,导致数据库的事务吞吐量太小。
MySQL的InnoDB引擎采用的是可重复读隔离级别。这就导致使用者要自己处理丢失更新问题,例如在下表(tbl)中
user_id | balance |
---|---|
1 | 30 |
2 | 80 |
两个事务操纵同一条记录,一个充钱一个扣钱,伪代码如下:
# 事务A
start transaction
int b = select balance from tbl where user_id = 1
b = b + 50
update tbl set balance = b where user_id = 1
commit
# 事务B
start transaction
int b = select balance from tbl where user_id = 1
b = b - 50
update tbl set balance = b where user_id = 1
commit
在事务A与B并发的情况下,由于事务A与B的多条语句之间可能被交叉执行,因此最后balance的值可能是80、-20、30。解决这个问题有如下的几种方法:
1)利用单条SQL语句的原子性改写事务代码,例如:
# 事务A
start transaction
update tbl set balance = balance + 50 where user_id = 1
commit
# 事务B
start transaction
update tbl set balance = balance - 50 where user_id = 1
commit
2)使用悲观锁(也就是select xxx for update语句),悲观锁在读记录之前就上锁,伪代码如下:
# 事务A
start transaction
int b = select balance from tbl where user_id = 1 for update
b = b + 50
update tbl set balance = b where user_id = 1
commit
# 事务B
start transaction
int b = select balance from tbl where user_id = 1 for update
b = b - 50
update tbl set balance = b where user_id = 1
commit
# 之所以说悲观锁降低了事务的并发度,是因为单个记录被锁住之后,其他访问该记录的事务都会被阻塞
# 另外,使用悲观锁,会导致死锁问题(例如事务A锁住user_id = 1的记录后,在Commit之前发生故障
# 会导致锁无法被释放)
3)使用乐观锁,即利用版本号 + CAS操作来控制,这需要对表加一个版本号字段(可以使用version、flag、tag等来命名)
user_id | balance | version |
---|---|---|
1 | 30 | |
2 | 80 |
有了版本号字段,就可以使用下面的伪代码来规避丢失更新问题:
# 事务A
while(!success) {
start transaction
int b, v1 = select balance, version from tbl where user_id = 1
b = b + 50
int v2 = v1 + 1
success = update tbl set balance = b, version = v2 where user_id = 1 and version = v1
commit
}
# 事务B
while(!success) {
start transaction
int b, v1 = select balance, version from tbl where user_id = 1
b = b - 50
int v2 = v1 + 1
success = update tbl set balance = b, version = v2 where user_id = 1 and version = v1
commit
}
# CAS的核心思想是:记录读出来的时候带一个版本号v1,然后在内存里面修改(包括修改业务字段和更
# 新版本号,比如更新成v2),当再写回去的时候,如果发现版本号不是v1,说明自己在修改期间有别的
# 事务修改了该记录,则放弃当前修改。重新读取记录,重新修改记录以及更新版本号,重新对比版本号,
# 如此不断重试
# 使用乐观锁,即利用版本号 + CAS操作的时候,有一个要点:version的比较、balance的更新、version
# 的更新要写在同一个SQL语句中,使用单条SQL的原子性保证整个过程的原子性
4)使用分布式锁,以上方法3)只能解决同一张表的事务操作场景中的丢失更新问题,但是一个事务可能跨表进行操作,例如:
start transaction
select xxx from tbl1
select yyy from tbl2
根据tbl1和tbl2的查询结果进行逻辑计算,然后用计算结果更新tbl3
update tbl3
commit
要实现update tbl3的同时,tbl1和tbl2是锁住的状态,不能让其他事务修改,这种情况可以考虑使用分布式锁。
实现读写并发往往有三种策略:互斥锁、读写锁、COW
1)互斥锁:一个数据对象上面只有一把锁,任何时候只能由一个线程持有锁,其他线程被阻塞。这种策略能够实现写写互斥、读写互斥、读读互斥;
2)读写锁:一个数据对象上面只有一把锁,但是有两个视图${TO-COMPREHENSION}。这种策略能够实现写写互斥、读写互斥、读读并发;
3)COW:CopyOnWrite,写的时候,把该数据对象拷贝一份,在拷贝对象上进行写操作,等写完之后,再用拷贝对象代替原始对象(将引用从原始对象指向拷贝对象),读的时候读取原始对象,这种策略能够实现:读读并发、读写并发、写写并发。
以上三种策略,并发度越来越高。MySQL中的InnoDB引擎就利用了COW策略实现了MVCC,以提高数据库的查询并发度。
每个事务修改某个记录之前,都会先把这个记录拷贝一份出来,每一次修改,就是一个版本,因此InnoDB维护了数据从旧到新的每一个版本,各个版本之间的记录通过链表串联。也正是因为每条记录都有多个版本,每个版本都和一个事务相关联,因此才容易实现事务的隔离性。
要实现多个事务同时操纵同一条记录的并发特性,要实现并发过程中的“读未提交”、“可重复读”的事务隔离级别,就不能让事务读取到正在被修改的数据,只能读取与该事务关联的历史版本,这体现了COW思想。
InnoDB的这个特性叫做多版本并发控制,即MVCC。InnoDB正是有了MVCC的特性,通常的select xxx from语句都是不加锁的,读取的全部是数据的历史版本,从而支持高并发的查询,这种读,叫做“快照读”;与之相对的是“当前读”,使用当前读的语句包括:select xxx from yyy for update语句、select xxx from yyy lock in share mode语句、insert / update / delete语句。
MVCC解决了快照读和写之间的并发,但是对于写与写之间、当前读与写之间的并发,MVCC就无能为力了。这时候就需要用到锁。MySQL的官方文档列出了InnoDB的七种锁:共享锁 / 排它锁、意向锁、记录锁、间隙锁、临键锁、插入意向锁、自增锁。(MySQL的官方文档从不同的维度划分,得到了这七种锁。乐观锁与悲观锁是对行锁的另一种维度的划分。)
1)共享锁 / 排它锁:共享锁(S)与排它锁(X)是读写锁的另外一种叫法,共享锁即读锁,支持读读之间并发;排它锁就是写锁,保证读写之间不能并发、写写之间也不能并发。一个事务读记录的时候,对该记录添加共享锁,其他事务也可以继续添加共享锁,但不能添加排它锁,从而使得共享锁支持读读并发;一个事务写记录的时候,对该记录添加排他锁,其他事务既不能再对该记录添加共享锁,也不能添加排它锁,只能阻塞,从而保证读写之间不能并发、写写之间也不能并发。InnoDB通常对行添加共享锁 / 排它锁,但有些场景会对整个表加共享锁 / 排它锁,如DDL语句。
2)意向锁:意向锁是一种表锁,具体分为意向排它锁(IX)和意向共享锁(IS)。当一个事务要给某表的某记录添加共享锁的时候,必先给这个表添加IS(IS可重入,有多少个S,IS就重入多少次);当一个事务要给某表的某记录添加排他锁的时候,必先给这个表添加IX(IX可重入,有多少个X,IX就重入多少次)。反之,行共享锁 / 排它锁撤销的时候,意向锁的重入次数也会相应减掉,直至为0,意向锁撤销。有了意向锁,事务在给整个表加共享锁 / 排它锁的时候,就不用遍历所有记录以确定表中是否存在行的共享锁 / 排它锁,只需看表上是否存在意向锁即可,这样就很容易避免表锁与行锁的冲突。
3)记录锁:即行锁,相对来讲还有表锁,间隙锁(Gap Lock,锁住一个范围内的记录,也可以叫做范围锁),这些锁的叫法反映了锁的粒度。
4)间隙锁:按照锁的粒度划分,除了有行锁、表锁之外,还有一种用于锁住范围的间隙锁(Gap Lock)。锁Gap和锁行密切相关,Gap Lock肯定建立在某一行的基准之上,Gap Lock锁住一个范围时不包含记录本身,一个事务用Gap Lock锁住一个表区间时,可以避免另一个事务在这个区间上插入新纪录。一个事务操作表记录时是否需要加间隙锁,这和事务的隔离级别密切相关。之所以要锁Gap,一个主要目的是避免幻读,如果事务的隔离级别允许幻读,则不需要使用间隙锁。另外,间隙锁往往针对非唯一索引,因为对唯一索引(例如主键索引、唯一字段上的辅助索引)的每次修改都可以具体定位到哪条或哪几条记录,不需要锁Gap。
5)临键锁(Next-Key Lock):临键锁可以看成是行锁和间隙锁的综合,不仅锁住某个记录,也会锁住该记录之前的范围。
6)插入意向锁:插入意向锁也算作一种间隙锁,专门针对Insert操作。多个事务在同一索引、同一个范围内的记录可以并发插入,即插入意向锁之间并不互相阻碍。
7)自增锁:例如有两个事务:
事务A 事务B
insert into tbl1 values xxx
insert into tbl1 values xxx
insert into t1 values xxx
select xxx from tbl1
insert into tbl1 values xxx
在事务A中,连续进行了两次记录插入,并且表tbl1使用了自增主键。因此事务A中最后一条语句查询出来的结果里,我们期望看到插入的两条记录的id递增且连续。但是由于事务B和事务A的语句可能会交叉执行,且事务B中也在插入记录,因此最后事务A中看到的两条插入的记录的id是不连续的,这不符合主键自增的要求。因此,需要一种自增锁来保证同一个事务中插入的递增列的值是连续的。
InnoDB设计了如此多类型的锁,是因为不同的SQL语句、不同的事务并发场景、不同的事务隔离级别、不同的索引类型,所需求的锁的特性可能都不一样,针对具体场景使用合适的锁,才能更好保证事务的隔离、查询的并发、查询的速度等等。
InnoDB通过MVCC + 锁实现了事务的隔离性和并发性。
利用数据库锁进行数据操作时,无论是显式的锁还是隐式的锁,若使用不当会导致死锁问题。利用有向图对事务对锁的持有进行建模,利用有向图的环检测算法可以检测死锁。
下面给出一个数据库发生死锁的实例:
事务A 事务B
delete from tbl1 where id = 1
update tbl2 set xxx where id = 2
update tbl2 set xxx where id = 2
delete from tbl1 where id = 1
再给出一个较为隐晦的死锁实例:
事务A 事务B
delete from tbl1 where id = 1
update tbl1 set xxx where id = 5
insert into tbl1 values xxx
insert into tbl1 values xxx
# 在InnoDB使用可重复读的事务隔离级别时,以上两个事务的执行可能会产生死锁。原因是insert操作
# 会对tbl1加间隙锁,导致两个事务死锁。${TO-COMPREHENSION}
InnoDB的Redo Log和Undo Log
ARIES算法是实现事务的理论基础。
InnoDB通过Redo Log + Undo Log实现了事务的原子性和持久性,我们先从简单的持久性的实现开始理解。
事务的持久性是说,一旦事务提交完成,对记录的修改就持久化到存储介质(如磁盘)上。如果一个事务要修改多张表中的多条记录,多条记录分布在不同的page中,对应于磁盘的不同位置。如果每个事务都直接写磁盘,一次事务提交就要做很多次随机磁盘IO操作,性能达不到要求。为此,可以使用write-ahead log的思路:先在内存中写事务,然后写日志,然后后台任务把内存中的数据异步刷到磁盘上(写日志在先,刷磁盘在后)。由于写日志是不断追加的过程,因此不需要多次随机磁盘IO,这个过程很快。
InnoDB中,write-ahead log就是Redo Log。在InnoDB中,不仅内存中的事务刷到磁盘的过程是异步的,就连追加日志的过程都是异步的。
Redo Log采用逻辑结构 + 物理结构相结合的方式来组织起来。在决定使用这种逻辑结构 + 物理结构相结合的组织方式之前,我们先来看下Redo Log有哪些备选方式:
1)类似Binlog的statement的格式,记录事务中的原始SQL,这是一种逻辑记法;
2)类似Binlog的RAW格式,记录每张表的每条记录的修改前的值、修改后的值,大概为:
{表,行,修改前的值,修改后的值}
这也是一种逻辑记法;
3)记录被修改的每个Page的字节数据,若一个Page有多处被修改,则产生多个记录。大概为:
[
{pageId, offset1, len1, 修改之前的值, 修改之后的值},
{pageId, offset2, len2, 修改之前的值, 修改之后的值}
]
这是一种物理记法。
Redo Log最终综合了物理记法和逻辑记法,先以Page为单位记录日志,每个Page中再记录哪一行被修改了。原因是纯粹的逻辑日志宕机后不好恢复,需要对日志内容进行一定的语法和逻辑分析;纯粹的物理日志数据体积又太大,不便传输和存储。
Redo Log文件逻辑上是一个无限长的线性字节流,在innoDB中,使用LSN(Log Sequence Number)记录这个逻辑上的字节流中的日志字节数。但物理上Redo Log文件是多个Block组成的循环线性表,通过覆写循环利用文件空间,之所以能覆写,是因为旧的事务对Page的修改一旦落盘,旧事务的日志就可以抹掉了。innoBD实现了逻辑上的日志LSN和物理上的Log Block之间的映射。
MySQL的Binlog与主从复制
在MySQL中,可以使用多种存储引擎。其中最常用的InnoDB引擎支持事务,Redo Log和Undo Log就是InnoDB里面的工具,用于实现事务。而Binlog是MySQL层面的东西,用于实现主从复制,与使用的存储引擎无关。
通过监听并解析Mater的Binlog,也可以实现将MySQL中的数据同步到其他应用组件中(比如更新缓存)的效果。
在不发生宕机的情况下,未提交的事务和已回滚的事务是不写入Binlog日志中的,只有提交成功的事务才写入Binlog日志。这一点和Redo Log不一样,Redo Log中会记录未提交、已回滚的事务内容。
Binlog是一种逻辑日志——例如Binlog的statement格式记录原始SQL语句、RAW格式记录某一行修改前后的值——且一个事务的日志在Binlog中是连续排列的,因此要求每个事务都要串行地写入,这意味着每个事务在写Binlog之前都要排他地锁住Binlog,这会导致写的效率很低。MySQL5.6之后,通过pipline技术异步地批量化将已提交的事务内容写入Binlog。
一个事务的提交既要写Binlog日志又要写Redo Log日志,如何保证双写的原子性?一个写成功,写另外一个时发生宕机,重启后如何处理?在讨论这个问题之前,先说下Binlog自身写入的原子性问题:Binlog刷盘到一半,出现宕机,这个问题和Redo Log的写入原子性是同样的问题,通过类似于checksum的办法或者Binlog中的结束标记来判断出某个事务的Binlog这是不是不完整的Binlog,从而把不完整的部分截掉。对于客户端来说,此时宕机,事务肯定是没有提交成功的,所以截掉也没问题。下面来讲如何保证双写Binlog和Redo Log的原子性。由于双写Binlog和Redo Log发生在同一台机器上,这其实是一个内部分布式事务,可以使用两阶段提交法来实现双写的原子性。简单来说就是:
1)第一阶段(准备阶段):MySQL Server要求innoDB完成将事务内容写入Redo Log中的工作,只等事务提交;以及,MySQL Server完成Binlog内容写入内存的工作,只等刷盘。两个都准备好之后,会向MySQL Server发送OK反馈,MySQL Server紧接着执行第二阶段。
2)第二阶段(提交阶段):收到客户端的Commit指令,MySQL Server先将内存中的Binlog刷盘,然后让innoDB执行事务的提交。两个都完成之后,会向MySQL Server发送OK反馈,两阶段提交结束。
若双写Binlog和Redo Log的过程中发生宕机,处理思路为:
1)若宕机发生在第一阶段,此时Binlog还在内存中,宕机导致全部消失。而Redo Log记录了未提交的日志,MySQL Server重启后感知到Binlog中不存在Redo Log中记录的未提交事务,会自行回滚未提交事务的Redo Log日志;
2)若宕机发生在第二阶段,Binlog写了一半,innoDB还未执行提交,MySQL Server重启后会对Binlog做截断,对Redo Log中记录的未提交事务做回滚;
3)若宕机发生在第二阶段,Binlog写入成功,innoDB还未执行提交,MySQL Server重启后会通过checksum的办法或者Binlog中的结束标记感知到Binlog写入成功,紧接着对Binlog中存在的、但Redo Log未提交的事务发起提交。
在MySQL的Master / Slave集群模式中,有三种主从复制模式:
1)同步复制:所有的Slave都收到Master发送的Binlog,并且接收完,Master才认为事务提交成功,再对客户端返回成功。这种方式最安全,但是性能很差;
2)异步复制:只要Master事务提交成功,就对客户端返回成功。后台线程异步地将Binlog发送给Slave,然后Slave回放Binlog。这种方式性能最好,但是可能会导致数据丢失;
3)半同步复制:Master事务提交后,同时把Binlog同步给Slave,只要有部分(数量可以配置)Slave收到了Binlog,就认为事务提交成功,对客户端返回。
对于半异步复制,如果Slave超时后还未返回,也会退化为异步复制。所以无论是异步复制还是半异步复制,都无法严格保证主从中的数据完全一致,主从复制的延迟会导致主节点宕机后部分数据未来得及同步到从节点,从而丢失数据。但是主节点宕机后,还是要立即切换到从节点,保证服务的可用(牺牲一致性保证可用性),数据的丢失可以通过后续的人工干预来补偿。
要保证MySQL数据不丢失,replication是一个很好的解决方案,而MySQL也提供了一套强大的replication机制。只是我们需要知道,为了性能考量,replication是采用的asynchronous模式,也就是写入的数据并不会同步更新到slave上面,如果这时候master当机,我们仍然可能会面临数据丢失的风险。
为了解决这个问题,我们可以使用semi-synchronous replication,semi-synchronous replication的原理很简单,当master处理完一个事务,它会等待至少一个支持semi-synchronous的slave确认收到了该事件并将其写入relay-log之后,才会返回。这样即使master当机,最少也有一个slave获取到了完整的数据。
但是,semi-synchronous并不是100%的保证数据不会丢失,如果master在完成事务并将其发送给slave的时候崩溃,仍然可能造成数据丢失。只是相比于传统的异步复制,semi-synchronous replication能极大地提升数据安全。更为重要的是,它并不慢,MHA的作者都说他们在facebook的生产环境中使用了semi-synchronous(这里),所以我觉得真心没必要担心它的性能问题,除非你的业务量级已经完全超越了facebook或者google。在这篇文章里面已经提到,MySQL 5.7之后已经使用了Loss-Less Semi-Synchronous replication,所以丢数据的概率已经很小了。
如果真的想完全保证数据不会丢失,现阶段一个比较好的办法就是使用gelera,一个MySQL集群解决方案,它通过同时写三份的策略来保证数据不会丢失。笔者没有任何使用gelera的经验,只是知道业界已经有公司将其用于生产环境中,性能应该也不是问题。但gelera对MySQL代码侵入性较强,可能对某些有代码洁癖的同学来说不合适了。
我们还可以使用drbd来实现MySQL数据复制,MySQL官方文档有一篇文档有详细介绍,但笔者并未采用这套方案,MHA的作者写了一些采用drdb的问题,在这里,仅供参考。
多副本与一致性
多副本可以提高系统的可用性,对于网关和服务器这种无状态的服务,做多个副本比较简单,加机器就行了。但是对缓存组件、数据库或者消息组件这样的有状态的机器,如果做多个副本,需要考虑多副本状态(数据)的一致性。
对于缓存组件的多副本,可以利用消息中间件的Pub / Sub机制,每台缓存机器都订阅数据源消息,一旦数据源发生改变,产生的消息被多台缓存机器接收,然后各自更新缓存,这是一种思路。另外,使用支持集群化部署的缓存组件——例如redis——时,则可以利用Master / Slave集群模式来实现高可用。但主从复制往往采用异步过程来实现,因此主节点宕机后,切换到从节点会出现一定的数据丢失,但这也是可以接受的,因为缓存的定位就不严格要求和源数据一致,因此才有了缓存的更新算法、置换算法等。另外,也可以通过双写和多写的方式来实现缓存多副本的更新,保持缓存多副本的一致。
对于数据库如MySQL,Master / Slave集群模式下主从节点的复制多采用异步或者半异步的方式,因为同步方式虽然能保证主从数据的严格一致,但是复制的性能太低。对于半异步复制,如果Slave超时后还未返回,也会退化为异步复制。所以无论是异步复制还是半异步复制,都无法严格保证主从中的数据完全一致。但是主节点宕机后,还是要立即切换到从节点,保证服务的可用(牺牲一致性保证可用性),数据的丢失可以通过后续的人工干预来补偿。
对于消息组件如Kafka,一个Partition通常会至少指定三个副本,为此Kafka专门设计了一种ISR的算法,在多个副本之间同步消息。虽然有多个副本,但在某些情况下,当发生Master / Slave的切换时,还是会丢失消息,这是不可避免的。
隔离、限流、熔断、降级
隔离是指将系统或资源分隔开,在系统发生故障的时候能够限制故障的传播和影响范围,避免雪球越滚越大。典型的隔离策略有四种:数据隔离、机器隔离、线程池隔离、信号量隔离。
1)数据隔离:区分业务的核心数据和边缘数据,分开存储,数据库分库就符合这一策略。伴随着数据的分割,业务也会做拆分。
2)机器隔离(调用者隔离):对于一个服务来说,调用者也有重要和不重要之分,机器隔离策略要求给重要的调用者部署专门的服务机器,避免和不重要的调用者竞争。成熟的RPC框架往往具备调用者隔离功能,通过简单的配置就可以让RPC框架根据调用方的标识把调用方的请求引到一组固定的服务机器中。
3)线程池隔离:Netflix的开源项目Hystrix就使用了线程池隔离策略。比如在微服务系统中,服务A要调用服务B的某个接口,服务A不会直接同步地调用服务B的接口,而是委托一个线程池进行调用,当线程池中的线程都被占用且线程池等待队列已满时,线程池就会抛出异常,拒绝服务A新的请求,从而保证服务A的业务处理线程不会全部用于等待服务B。
4)信号量隔离:一个机器能提供的线程池和线程数量是有限的,过多的线程在进行线程切换的时候会耗费大量的资源。信号量隔离是Hystrix的另外一种隔离方法,它比线程池隔离要轻量,使用信号量隔离不需要额外的线程池,信号量本身是一个数字,代表当前访问某个资源的并发数。调用服务接口的线程会先获取该信号量(信号量数值增加1),调用结束后释放该信号量(信号量数值减少1),当信号量达到最大值时,调用服务接口的线程无法再获取到信号量,从而放弃,而不是阻塞等待。
限流有技术层面上的限流和业务层面上的限流:
1)技术层面上的限流:包括限制并发数和限制速率(QPS)两类。例如数据库连接池、线程池、Nginx的limit_conn模块都是通过限制并发数来限制对资源的访问;例如Guava的RateLimiter、Nginx的limit_req模块都是通过限制速率来限制对资源的访问。一个服务在压力测试环节被测出能够承受的最大QPS是1000,那就可以通过限制速率的方式来限流,一般来说成熟的RPC框架都有相应的配置,对每个接口进行限流。限制并发数的算法很简单,比如设置连接池的连接数、线程池的线程数等等;限制速率的算法有令牌桶法和漏斗法,令牌桶法以固定速率向令牌桶中放令牌,拿到令牌的请求才能访问资源,令牌被抢光后,请求被拒绝。漏斗法以固定的速率放行访问资源的请求,请求过多时,多余的请求被拒绝。令牌桶法限制的是平均流速,漏斗法限制的是最大流速。
2)业务层面上的限流:在秒杀系统中,一件商品的库存是1000,开抢后,有20000个请求发送过来,那么可以在系统中国预置1000个票据(ticket),并要求只有拿到票据的请求才能抢到商品,这是一种业务上的限流方式。具体到技术上,可以使用redis实现分布式锁来提供票据分发功能。
熔断有两种策略:一种是根据请求失败率,另一种是根据响应时间。
1)根据请求失败率做熔断:比如在微服务系统中,服务A要调用服务B的接口,如果短时间内出现大量超时或报错,则服务A直接开启熔断,不再调用服务B。过一段时间内,关闭熔断进行重试,若还是出现大量的超时或报错,继续开启熔断。这符合快速失败原则。例如Hystrix就提供了几个配置熔断器的参数:
参数 | 含义 |
---|---|
circuitBreaker.requestVolumeThreshold | 滑动窗口的大小,默认20 |
circuitBreaker.sleepWindowInMilliseconds | 过多长时间,再次进行调用重试,默认5000 |
circuitBreaker.errorThresholdPercentage | 失败率,默认50% |
三个参数放一起,其含义为:每20个请求中,有50%失败时,就会打开熔断,不再调用目标服务。5000毫秒后重新尝试调用目标服务,以决定是重新打开熔断还是解除熔断。
2)根据响应时间做熔断:阿里的Sentinel还提供了另外的一种思路:根据平均响应时间进行熔断。当目标服务的平均响应时间超过阈值后,目标服务进入准降级状态。接下来如果5个请求的响应时间持续超过该阈值,那么接下来的时间窗口内,对这个方法的调用都会自动地返回。
降级是一种兜底方案,它放弃系统的边缘功能,保留核心功能,以尽最大程度保持系统核心的可用性。例如,微信具备在线文字聊天、语音聊天、视频聊天、支付、朋友圈、搜索引擎等多种功能,当网络发生故障时,可以舍弃视频、语音、朋友圈、搜索等非核心功能,保证文字聊天和金融系统的可用性;再例如,电商平台首页具备推荐系统,当推荐系统宕机时,可以切换回不具备个性化的商品列表首页,保证核心购物系统的可用性。
相比于隔离、限流、熔断,降级是一个更具业务色彩的术语。
分布式事务解决方案
2PC是一种实现分布式事务的简单模型,在2PC中有两个角色:事务协调者和事务参与者。具体到一个服务访问多个数据库的场景中,数据库就是事务参与者,服务就是事务协调者。这两个阶段是:
1)准备阶段:协调者向各个参与者发起询问请求:“我要执行分布式事务了,这个事务涉及到的资源是……,你们准备好各自的资源(即各自执行本地事务到待提交阶段)”。各个参与者恢复yes(表示已准备好,允许执行事务)或no或超时。
2)提交阶段:如果各个参与者回复的都是yes,则协调者向所有参与者发起事务提交操作,然后所有参与者收到后各自执行本地提交操作并向协调者发送ACK;如果任何一个参与者回复no或者超时,则所有参与者各自回滚事务并向协调者发送ACK。
要实现2PC,所有的参与者都要实现三个接口:Prepare、Commit、Rollback,这就是XA协议。
2PC存在如下的问题:
1)性能差,在准备阶段,要等待所有的参与者返回,才能进入阶段二。在这期间,各个参与者上面的相关资源被排他地锁住,影响了各个参与者的本地事务并发度;
2)准备阶段完成后,如果协调者宕机,所有的参与者都收不到提交或回滚指令,导致所有参与者“不知所措”;
3)在提交阶段,协调者向所有的参与者发送了提交指令,如果一个参与者未返回ACK,那么协调者不知道这个参与者内部发生了什么,也就无法决定下一步是否进行全体参与者的回滚。
2PC之后又出现了3PC进一步加强了整个事务过程的可靠性,但是3PC同样无法应对类似的宕机问题。
2PC除了性能和可靠性上存在问题,它的适用场景也很局限,它要求参与者实现了XA协议,例如实现了XA协议的数据库作为参与者可以使用2PC。但是在多个系统服务利用api接口相互调用的时候,就不遵守XA协议了,这时候2PC就不适用了。所以2PC在分布式应用场景中很少使用。
在分布式应用场景中,实现分布式事务的一种思路是:利用消息中间件达到最终一致性。这里先给出一种错误的实现方式。
+--------+ (*1) +--------+ (*2) +--------+
|SystemA |-------->|MQ |-------->|SystemB |
+--------+ +--------+ +--------+
| |
扣钱 加钱
| |
v v
+--------+ +--------+
|DB1 | |DB2 |
+--------+ +--------+
注释
(*1):发送加钱消息
(*2):消费加钱消息
这种实现方式将系统A的“发送加钱消息”和“从DB扣钱”两个步骤放在一个事务中进行,若消息发送失败则回滚事务,撤销从DB扣钱。这样做的缺陷为:
1)存在网络的2将军问题,发送消息失败,对于发送方来说,可能是消息中间件没有收到消息,也可能是中间件收到了消息,但向发送方响应的ACK由于网络问题没有被发送方收到。因此系统A贸然进行事务回滚,撤销从DB扣钱,是不对的。
2)把网络调用放在数据库事务里,可能会因为网络延迟产生数据库长事务,影响数据库的并发度。
接着给出正确的实现方式。
+--------+
|SystemA |
+--------+
|
|在账号余额表扣钱 && 写消息表
v
+--------+ (*1) +--------+ (*2) +--------+
|DB1 |-------->|MQ |-------->|SystemB |
| | | |<--------| |
+--------+ +--------+ (*3) +--------+
DB1包含消息表和账号余额表 |
|在账号余额表加钱 && 写判重表
v
+--------+
|DB2 |
+--------+
DB2包含判重表和账号余额表
注释
(*1):后台任务读取消息表发送到MQ,若失败则不断尝试
(*2):消费加钱消息
(*3):ACK消费
如上图所示,利用消息中间件如Kafka来实现分布式转账过程的最终一致性。对这幅图做以下说明:
1)系统A中,“在账号余额表扣钱 && 写消息表”这个过程要在一个事务中完成,保证过程的原子性。同样,系统B中,“在账号余额表加钱 && 写判重表”这个过程也要在一个事务中完成。
2)系统A中有一个后台程序,源源不断地把消息表中的消息传送给消息中间件。如果失败了,也会不断尝试重传。由于存在网络2将军问题,即当系统A发送给消息中间件的消息网络超时时,这时候消息中间件可能收到了消息但响应ACK失败,也可能没收到,系统A会再次发送该消息,直至消息中间件响应ACK成功,这样可能发生消息的重复发送,不过没关系,只要保证消息不丢失,不乱序就行,后面系统B会做去重处理。
3)消息中间件向系统B推送某消息,系统B成功处理完成后会向中间件响应ACK,系统B收到这个ACK才认为系统B成功处理了这条消息,否则会重复推送该消息。但是有这样的情形:系统B成功处理了消息,向中间件发送的ACK在网络传输中由于网络故障丢失了,导致中间件没有收到ACK重新推送了该消息。这也要靠系统B的消息去重特性来避免消息重复消费。
4)在2)和3)中提到了两种导致系统B重复收到消息的原因,一是生产者重复生产,二是中间件重传。为了实现业务的幂等性,系统B中维护了一张判重表,这张表中记录了被成功处理的消息的id。系统B每次接收到新的消息都先判断消息是否被成功处理过,若是的话不再重复处理。
通过这种设计,实现了消息在发送方不丢失,消息在接收方不被重复消费,联合起来就是消息不漏不重,严格实现了系统A和系统B的最终一致性。
在上一个解决方案中,系统A要维护消息表和后台任务,不断扫表消息表,导致消息的处理和业务逻辑耦合。RocketMQ的“事务消息”特性可以解决这一问题。
+----------+ (*1) +--------+ (*3) +--------+
|SystemA |-------->|RocketMQ|-------->|SystemB |
| |-------->| |<--------| |
+----------+ (*2) +--------+ (*4) +--------+
| ^ | |
| | (*5) | |
| +---------------+ |
扣钱 加钱 && 写判重表
| |
v v
+--------+ +--------+
|DB1 | |DB2 |
+--------+ +--------+
DB1包含账号余额表 DB2包含判重表和账号余额表
注释
(*1):prepare消息
(*2):confirm消息
(*3):消费加钱消息
(*4):ACK消费
(*5):异常消息,定期回调
Rocket不是提供一个简单的发送接口,而是把消息的发送拆成了两个阶段,prepare和confirm阶段。具体使用方法如下:
1)步骤一:系统A调用prepare接口,预发送消息。此时消息保存在中间件里,但消息中间件还不会把消息推给消费方,消息只是暂存在中间件。
2)步骤二:系统A更新数据库,完成扣钱。
3)步骤三:系统A调用confirm接口,确认消息发送,此时中间件才会把消息推给消费方进行消费。
显然这里有两种异常场景:
1)场景一:步骤一成功,步骤二成功,步骤三超时或者失败。
2)场景二:步骤一成功,步骤二超时或者失败,步骤三不会执行。
这两种异常场景,利用的就是Rocket的“事务消息”特性,具体来说就是:RocketMQ会定期扫描所有的预发送但还没有confirm的消息,回调给发送方,询问这条消息是要发出去,还是要取消。发送方根据自己的业务数据,判断这条消息是应该发出去(DB扣款成功了),还是要取消(DB扣款失败了)。
对比上部分给出的基于MQ + 本地消息表的方案,会发现RocketMQ其实是把“扫描本地消息表”这件事从系统A中剥离出来,不让业务系统去做,而是交由消息中间件完成。
至于消费端的判重,和上部分的策略一样。
2PC通常用来解决多个数据库之间的事务问题,比较局限。现代企业多采用分布式的SOA服务,因此更多的是要解决多个服务之间的分布式事务问题。
TCC是一种解决多个服务之间的分布式事务问题的方案。TCC是Try、Confirm、Cancel三个词的缩写,其本质是一个应用层面上的2PC,同样分成两个阶段:
1)阶段一:准备阶段,协调者调用所有的服务提供的try接口,将整个事务涉及到的资源锁定住,锁定成功向协调者返回yes。
2)阶段二:提交阶段,若所有的服务都返回yes,则进行提交阶段,协调者调用所有服务的confirm接口,各个服务进行事务提交。如果有任何一个服务在阶段一返回no或者超时,则协调者调用所有服务的cancel接口。
这里有个关键问题,既然TCC是一种服务层面上的2PC,它是如何解决2PC无法应对宕机问题的缺陷的呢?答案是不断重试。由于try操作锁住了事务涉及的所有资源,保证了业务操作的所有前置条件得到满足,因此无论是confirm阶段失败还是cancel阶段失败都能通过不断重试直至confirm或cancel成功(所谓成功就是所有的服务都对confirm或者cancel返回了ACK)。
这里还有个关键问题,在不断重试confirm和cancel的过程中(考虑到网络二将军问题的存在)有可能重复进行了confirm或cancel,因此还要再保证confirm和cancel操作具有幂等性。
另外有一种类似TCC的事务解决方案,借助事务状态表来实现。假设要在一个分布式事务中实现调用订单服务生成订单、调用库存服务扣减库存、调用物流服务启动发货三个过程。在这种方案中,协调者维护一张如下的事务状态表:
分布式事务ID | 事务内容 | 事务状态 |
---|---|---|
global_dis_trx_id_1 | 操作1:调用订单服务生成订单<br />操作2:调用库存服务扣减库存<br />操作3:调用物流服务启动发货 | 状态1:初始<br />状态2:操作1成功<br />状态3:操作1、2成功<br />状态4:操作1、2、3成功 |
初始状态为1,每成功调用一个服务则更新一次状态,最后所有的服务调用成功,状态更新到4。
有了这张表,就可以启动一个后台任务,扫描这张表中事务的状态,如果一个分布式事务一直(设置一个事务周期阈值)未到状态4,说明这条事务没有成功执行,于是可以重新调用订单服务生成订单、调用库存服务扣减库存、调用物流服务启动发货。直至所有的调用成功,事务状态到4。
如果多次重试仍未使得状态到4,可以将事务状态置为error,通过人工介入进行干预。
由于存在服务的调用重试,因此每个服务的接口要根据全局的分布式事务ID做幂等。
下面给出利用对账来补偿数据的思路。
以上给出的分布式事务的解决方案,无论是基于MQ + 消息表的最终一致性方案,还是TCC方案,还是事务状态表方案,关注的都是实现事务的过程,即都是在保证整个分布式事务的“过程的原子性”。
所有的过程都会产生结果,从结果也会反推出过程。对账就是根据结果反推过程中出现的问题,从而对数据进行修补。
例如,新员工入职,HR使用HRMS系统录入新员工信息,新员工信息同时也要同步到研发管理系统、资产管理系统等一系列公司系统中。使用分布式事务的解决方案可以这么做:调用HRMS接口插入员工信息时,同时调用研发管理系统接口、资产管理系统接口插入员工信息,利用分布式事务保证整个过程的原子性和一致性。而利用对账法可以这么做:调用HRMS系统接口插入员工信息,返回。后面,研发管理系统、资产管理系统等会轮询HRMS的数据库(基准库)中有哪些员工的信息不存在于自身数据库(校准库)中,对于不存在的员工信息,读取并插入到自身数据库中。
对账分为增量对账和全量对账。
研究以上方案,可以发现:
1)“最终一致性”是一种异步方法,数据的一致有一定延迟
2)TCC是一种同步方法,但TCC需要两个阶段,性能损耗较大
3)事务状态表也是一种同步方法,但每次都要记录事务流水,要更新事务状态,很繁琐,新能损耗也很大
4)对账是一种事后干预过程
在高并发场景中,要求一个系统能承受大量的分布式事务并发进行。所以需要一个同步方案,既能让系统之间保持一致性,又能具备很高的性能,支持高并发。下面给出一种对分布式事务的妥协方案:若一致性 + 基于状态的补偿。
这里以电商系统的下单场景来解释这种方案,下单场景如下:协调者调用订单服务产生订单,并调用库存服务扣减库存。即一个分布式事务内容如下:
begin
调用订单服务产生订单
调用库存服务扣减库存
end
如果使用基于MQ + 消息表的最终一致性分布式事务方案,由于通知库存服务扣减库存是异步的,因此扣减不及时可能会出现超卖的问题;如果使用基于TCC的分布式事务方案,意味着一次下单要调用两次订单服务(try、confirm / cancel)和两次库存服务(try、confirm / cancel),性能较差;如果使用基于事务状态表的分布式事务方案,需要不断刷新事务状态表,性能也很差。
既要满足高并发,又要达到一致性,鱼与熊掌不可兼得,可以使用一种弱一致性方案。可以有两种做法:
1)先扣库存,再创建订单,这种做法产生三种结果:
扣库存 | 创建订单 | 返回结果 | |
---|---|---|---|
结果1 | 成功 | 成功 | 成功 |
结果2 | 成功 | 失败 | 失败 |
结果3 | 失败 | 不执行 | 失败 |
2)先创建订单,再扣库存,这种做法也产生三种结果:
创建订单 | 扣库存 | 返回结果 | |
---|---|---|---|
结果1 | 成功 | 成功 | 成功 |
结果2 | 成功 | 失败 | 失败 |
结果3 | 失败 | 不执行 | 失败 |
无论是哪种做法,失败只可能导致库存的多扣,避免了超卖问题。对于多扣的库存,可以通过补偿措施进行库存释放,这个补偿措施就是上面说的对账。
其实上面的妥协方案还可以再妥协一点,那整个过程就是:先扣减库存,再创建订单,不做补偿措施。并且库存服务提供回滚接口,若创建订单失败,则重试创建订单,重试几次都失败了,则回滚库存的扣减,如果回滚不成功,则记录错误,发出告警,进行人工干预修复。通常来说,只要各个服务的业务逻辑没有漏洞,并且可用性很高,重试、回滚之后失败的概率很小,所以这种原始的办法通过牺牲一致性来保证系统的并发度,还是可以接受的。
CAP把BASE理论
系统的高并发、高可用和一致性这三个问题并不是孤立的,而是相互影响的。CAP和BASE描述了这种影响。
1)CAP:
1.1)一致性:事务一致性、多副本一致性
1.2)可用性:系统能保证正常提供服务,包括抵抗高并发等性能压力
1.3)分区容忍性:分布式系统中,会出现数据分片和任务分片,一旦出现网络故障(网络分区 / 分裂,partition),分片(节点)之间要能抵抗这种故障
2)BASE:
2.1)基本可用:系统能保证正常提供服务,允许限流、降级、熔断,但至少核心业务要正常服务
2.2)软状态:实现事务一致性、多副本一致性允许存在中间状态
2.3)最终一致性:事务的最终一致性、多副本的最终一致性
在大规模分布式场景下,P往往是一个必然存在的事实,只能在C和A之间权衡,因此大部分情况下我们设计的是CP系统或者AP系统。CP系统(例如zk基于zab协议的多节点同步)追求强一致性,牺牲了一部分性能(并发性能差);AP系统(例如kafka主从复制、mysql主从复制)追求高可用,牺牲了一定的数据一致性。
P并不是通过牺牲C或A换取的,而是要通过网络基础设施的稳定性来保证。但是即便网络基础设施100%可靠,信息的传输也需要时间,在数据的传输期间,不一致一致性一定会存在。因此现实世界不存在强一致性。
信息的传递需要时间,这就一定会带来延迟,延迟一定会导致不一致;信息所反映的世界在变化,传播中的信息可能已经过时;信息传输的通道是不可靠的。以上三点,导致CAP只是一个理论模型,作为对CAP模型的修正,BASE模型反映了客观世界的真实情况。
分布式锁解决方案
基于zk的临时节点可以实现分布式锁,客户端每次加锁其实就是创建一个临时节点(成功创建某个path的临时节点的客户端拿到锁),释放锁则删除临时节点。zk和客户端之间通过心跳来保活连接,zk检测到客户端心跳停止后,会自动删除客户端创建的临时节点,从而避免死锁。但是基于zk临时节点的分布式锁解决方案有如下的问题:
1)zk因为利用了zab保证多节点(这里的节点是zk server instance,而不是zk中的znode)数据同步的一致性,因此在高并发场景下,zk的QPS不足;
2)因为zk使用心跳探测客户端是否存活,若客户端因网络故障或长时间Full GC未及时将心跳发给zk,zk会误判客户端下线,从而删除节点(释放锁)。锁释放后会被其他客户端拿到,从而导致两个客户端拿到同一把锁。
redis的并发性能比zk好,所以更多情况下被用来实现分布式锁。但是这种基于redis的分布式锁解决方案也有明显的问题存在:
1)redis的主从复制是异步的,由于没有实现zab这样的一致性协议,如果主节点宕机,切换到从节点,部分锁数据可能丢失,从而导致多个客户端拿到同一把锁;
2)因为客户端和redis之间没有心跳保活,若客户端宕机,会导致拿到的锁无法被释放。解决这种死锁问题的方法是给锁设置过期时间 + 客户端定时给锁续期;
3)若客户端遇到Full GC被阻塞,并且GC时间超过了锁的存活时长,则锁会被redis释放,从而导致其他客户端再次获得这把锁。
网络二将军问题
发送者给接受者发送一个HTTP请求,或者mysql客户端向mysql服务器发送一条插入语句,然后超时了没有得到响应。请问服务器是写入成功了还是失败了?答案是不确定。
1)可能请求由于网络故障根本没有送到服务器,因此写入失败;
2)可能服务器收到了,也写入成功了,但是向客户端发送响应前服务器宕机了;
3)可能服务器收到了,也写入成功了,也向客户端发送了响应,但是由于网络故障未送到客户端。
无论哪种场景,在客户端看来都是一样的结果:它发出的请求没有得到响应。为了确保服务端成功写入数据,客户端只能重发请求,直至接收到服务端的响应。
但这往往又会导致请求的重复发送。
以上问题被称为网络二将军问题。
缓存的应用模式
CAP、CASP
1)CAP:Cache Aside Pattern:使用这种模式,在代码中既要写操作缓存的代码,又要写操作数据源的代码。例如先读缓存,未命中的话去数据源获取数据,同时更新或者失效缓存。
2)CASP:Cache As Source Pattern:使用这种模式,在代码中只要写操作缓存的代码,操作数据源由缓存组件来自动进行,例如ehcache、guava cheche都支持CASP。CASP有三种实现:
2.1)Read-Through:用在读数据的场景,业务代码直接调用缓存组件读缓存,缓存不命中则缓存组件回到数据源取数据,取回后写入缓存,然后返回给调用者。
2.2)Write-Through:用在写数据的场景,业务代码直接调用缓存组件写数据(而不是调用数据库Dao接口),缓存组件会先回到数据源更新数据,然后更新相应的缓存数据。
2.3)Write-Behind:用在写数据的场景,不同于Write-Through的是,Write-Behind是异步模式,异步化之后可以实现数据源更新操作的合并和批量化。
无论哪一种模式,都旨在保持缓存和数据源的一致。