diff --git a/notes/分布式.md b/notes/分布式.md
index 318001b9..ee390f9b 100644
--- a/notes/分布式.md
+++ b/notes/分布式.md
@@ -3,11 +3,14 @@
* [分布式](#分布式)
* [一、分布式锁](#一分布式锁)
* [数据库的唯一索引](#数据库的唯一索引)
- * [Redis 的 SETNX 指令](#redis-的-setnx-指令)
+ * [Redis 的 SET NX 指令](#redis-的-set-nx-指令)
* [Redis 的 RedLock 算法](#redis-的-redlock-算法)
* [Zookeeper 的有序节点](#zookeeper-的有序节点)
+ * [三种实现的对比](#三种实现的对比)
* [二、分布式事务](#二分布式事务)
* [2PC](#2pc)
+ * [TCC](#tcc)
+ * [Saga](#saga)
* [本地消息表](#本地消息表)
* [三、CAP](#三cap)
* [一致性](#一致性)
@@ -24,7 +27,7 @@
* [六、Raft](#六raft)
* [单个 Candidate 的竞选](#单个-candidate-的竞选)
* [多个 Candidate 竞选](#多个-candidate-竞选)
- * [数据同步](#数据同步)
+ * [日志复制](#日志复制)
* [参考](#参考)
@@ -46,26 +49,46 @@
存在以下几个问题:
-- 锁没有失效时间,解锁失败的话其它进程无法再获得该锁;
+- 锁没有失效时间,如果持有锁的进程崩溃导致解锁失败,其它进程就无法再获得该锁;
- 只能是非阻塞锁,插入失败直接就报错了,无法重试;
- 不可重入,已经获得锁的进程也必须重新获取锁。
-### Redis 的 SETNX 指令
+### Redis 的 SET NX 指令
-使用 SETNX(set if not exist)指令插入一个键值对,如果 Key 已经存在,那么会返回 False,否则插入成功并返回 True。
+SETNX(set if not exist)指令插入一个键值对,如果 Key 已经存在,那么插入失败并返回 0,否则插入成功并返回 1。它和数据库的唯一索引类似,保证了只存在一个 Key 的键值对,那么可以用一个 Key 的键值对是否存在来判断是否处于锁定状态。
-SETNX 指令和数据库的唯一索引类似,保证了只存在一个 Key 的键值对,那么可以用一个 Key 的键值对是否存在来判断是否存于锁定状态。
+EXPIRE 指令可以为一个键值对设置一个过期时间,从而避免了释放锁失败的问题。但是 SETNX 和 EXPIRE 是两条指令,不具有原子性,如果执行完 SETNX 之后进程崩溃,那么这个锁就永远不会过期。因此应该使用 SET 指令的 NX 和 PX 选项一次性完成加锁和设置过期时间:
-EXPIRE 指令可以为一个键值对设置一个过期时间,从而避免了数据库唯一索引实现方式中释放锁失败的问题。
+```
+SET lock_key unique_value NX PX 30000
+```
+
+还需要注意以下几点:
+
+- 释放锁:value 设置为客户端的唯一标识,释放锁时先判断 value 是否为自己设置的再删除,避免误删其它客户端的锁,判断和删除需要使用 Lua 脚本保证原子性;
+- 锁续期:如果业务执行时间超过了过期时间,锁会被提前释放,可以使用后台线程定时延长过期时间,例如 Redisson 的看门狗机制默认每 10 秒把过期时间重置为 30 秒;
+- 主从切换:Redis 的主从复制是异步的,如果主节点加锁后还未同步到从节点就宕机,从节点晋升为主节点之后锁就丢失了。
+
+```lua
+if redis.call("get", KEYS[1]) == ARGV[1] then
+ return redis.call("del", KEYS[1])
+else
+ return 0
+end
+```
+
+Redis 8.4 新增了 `DELEX lock_key IFEQ unique_value` 指令,可以直接原子地完成比较并删除的操作,不再需要 Lua 脚本。
### Redis 的 RedLock 算法
使用了多个 Redis 实例来实现分布式锁,这是为了保证在发生单点故障时仍然可用。
-- 尝试从 N 个互相独立 Redis 实例获取锁;
+- 依次尝试从 N 个互相独立的 Redis 实例获取锁,每次请求的超时时间远小于锁的过期时间;
- 计算获取锁消耗的时间,只有时间小于锁的过期时间,并且从大多数(N / 2 + 1)实例上获取了锁,才认为获取锁成功;
- 如果获取锁失败,就到每个实例上释放锁。
+RedLock 依赖各个节点的时钟基本同步,并且无法应对进程暂停(例如 GC)导致锁过期后客户端仍然认为自己持有锁的情况,因此存在争议。更可靠的做法是让锁服务返回一个单调递增的令牌(Fencing Token),被访问的资源拒绝令牌更小的请求。
+
### Zookeeper 的有序节点
#### 1. Zookeeper 抽象模型
@@ -88,16 +111,26 @@ Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节
- 创建一个锁目录 /lock;
- 当一个客户端需要获取锁时,在 /lock 下创建临时的且有序的子节点;
-- 客户端获取 /lock 下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁;否则监听自己的前一个子节点,获得子节点的变更通知后重复此步骤直至获得锁;
+- 客户端获取 /lock 下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁;否则监听自己的前一个子节点,获得子节点的变更通知后重复此步骤直至获得锁;
- 执行业务代码,完成后,删除对应的子节点。
#### 5. 会话超时
如果一个已经获得锁的会话超时了,因为创建的是临时节点,所以该会话对应的临时节点会被删除,其它会话就可以获得锁了。可以看到,这种实现方式不会出现数据库的唯一索引实现方式释放锁失败的问题。
+但是如果客户端因为 GC 等原因长时间没有发送心跳,也会导致会话超时,因此同样可能出现两个客户端都认为自己持有锁的情况。
+
#### 6. 羊群效应
-一个节点未获得锁,只需要监听自己的前一个子节点,这是因为如果监听所有的子节点,那么任意一个子节点状态改变,其它所有子节点都会收到通知(羊群效应,一只羊动起来,其它羊也会一哄而上),而我们只希望它的后一个子节点收到通知。
+一个节点未获得锁,只需要监听自己的前一个子节点,这是因为如果监听所有的子节点,那么任意一个子节点状态改变,其它所有子节点都会收到通知(羊群效应,一只羊动起来,其它羊也会一哄而上),而实际上只需要通知它的后一个子节点。
+
+### 三种实现的对比
+
+- 数据库:实现简单,但是性能低;
+- Redis:性能最高,适合高并发场景,但是在主从切换、进程暂停等极端情况下可能出现两个客户端同时持有锁;
+- Zookeeper:写操作需要同步到大多数节点,性能较低,但是可靠性最高,适合一致性要求高、并发量不大的场景。
+
+实际使用中一般直接采用 Redisson、Curator 等成熟的客户端实现。
## 二、分布式事务
@@ -126,7 +159,7 @@ Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节
如果事务在每个参与者上都执行成功,事务协调者发送通知让参与者提交事务;否则,协调者发送通知让参与者回滚事务。
-需要注意的是,在准备阶段,参与者执行了事务,但是还未提交。只有在提交阶段接收到协调者发来的通知后,才进行提交或者回滚。
+需要注意的是,在准备阶段,参与者执行了事务(写入了 Undo 和 Redo 日志,并且持有锁),但是还未提交。只有在提交阶段接收到协调者发来的通知后,才进行提交或者回滚。
@@ -134,7 +167,7 @@ Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节
##### 2.1 同步阻塞
-所有事务参与者在等待其它参与者响应的时候都处于同步阻塞等待状态,无法进行其它操作。
+所有事务参与者在等待其它参与者响应的时候都处于同步阻塞等待状态,并且一直持有锁,无法进行其它操作。
##### 2.2 单点问题
@@ -148,16 +181,46 @@ Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节
任意一个节点失败就会导致整个事务失败,没有完善的容错机制。
+三阶段提交(3PC)在 2PC 的基础上增加了一个询问阶段(CanCommit),并且在参与者端也引入了超时机制,参与者在等待提交通知超时后会默认提交,从而减少了阻塞,但是仍然可能出现数据不一致。
+
+### TCC
+
+TCC(Try-Confirm-Cancel)是在业务层面实现的两阶段提交,每个操作都需要实现三个接口:
+
+- Try:检查业务并预留资源,例如冻结库存;
+- Confirm:使用预留的资源真正执行业务,例如扣减冻结的库存;
+- Cancel:释放预留的资源,例如解冻库存。
+
+如果所有参与者的 Try 都执行成功,就执行 Confirm,否则执行 Cancel。Confirm 和 Cancel 失败后都需要重试,因此必须实现幂等。
+
+相比于 2PC,TCC 不需要长时间持有数据库的锁,性能更好,但是对业务的侵入性较强。
+
+实现时还需要处理两个问题:
+
+- 空回滚:Try 没有执行,协调者超时后就调用了 Cancel,Cancel 需要识别出这种情况并直接返回成功;
+- 悬挂:Cancel 执行之后,被延迟的 Try 才到达并预留了资源,这些资源再也不会被释放,因此 Try 执行前需要检查该事务是否已经被 Cancel。
+
+通常用一张事务控制表记录每个事务的执行状态来解决这两个问题,幂等也可以通过它实现。
+
+### Saga
+
+Saga 将一个分布式事务拆分为多个本地事务,每个本地事务直接提交,并且都有一个对应的补偿操作。依次执行各个本地事务,如果某一个失败,就逆序执行前面已经提交的本地事务的补偿操作。
+
+Saga 没有预留资源的 Try 阶段,中间状态对其它事务可见,因此没有隔离性。它适合流程长、无法长时间锁定资源的业务,正向操作和补偿操作同样需要实现幂等。
+
### 本地消息表
本地消息表与业务数据表处于同一个数据库中,这样就能利用本地事务来保证在对这两个表的操作满足事务特性,并且使用了消息队列来保证最终一致性。
1. 在分布式事务操作的一方完成写业务数据的操作之后向本地消息表发送一个消息,本地事务能保证这个消息一定会被写入本地消息表中。
-2. 之后将本地消息表中的消息转发到消息队列中,如果转发成功则将消息从本地消息表中删除,否则继续重新转发。
+2. 之后将本地消息表中的消息转发到消息队列中,如果转发成功则将消息从本地消息表中删除(或者标记为已发送),否则继续重新转发。
3. 在分布式事务操作的另一方从消息队列中读取一个消息,并执行消息中的操作。
+由于重新转发会导致消息被重复投递,因此另一方执行的操作需要实现幂等。并且第一方的事务已经提交,无法回滚,所以如果另一方执行失败,只能不断重试或者人工介入处理。
+
+RocketMQ 的事务消息把本地消息表的工作交给了消息队列:生产者先发送一条对消费者不可见的半消息,再执行本地事务,然后根据执行结果通知 Broker 提交或者回滚这条消息。如果 Broker 长时间没有收到通知,会主动回查生产者的本地事务状态。
## 三、CAP
@@ -167,30 +230,32 @@ Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节
### 一致性
-一致性指的是多个数据副本是否能保持一致的特性,在一致性的条件下,系统在执行数据更新操作之后能够从一致性状态转移到另一个一致性状态。
+CAP 中的一致性指的是强一致性(线性一致性):对系统的一个数据更新成功之后,所有用户都能够读取到最新的值,就像系统中只有一个数据副本一样。
-对系统的一个数据更新成功之后,如果所有用户都能够读取到最新的值,该系统就被认为具有强一致性。
+注意它和 ACID 中的一致性不是同一个概念,ACID 中的一致性指的是事务执行前后,数据都满足完整性约束。
### 可用性
-可用性指分布式系统在面对各种异常时可以提供正常服务的能力,可以用系统可用时间占总时间的比值来衡量,4 个 9 的可用性表示系统 99.99% 的时间是可用的。
+可用性指的是每一个发送给未故障节点的请求,都能够在有限的时间内得到非错误的响应,但是不保证响应的是最新的数据。
-在可用性条件下,要求系统提供的服务一直处于可用的状态,对于用户的每一个操作请求总是能够在有限的时间内返回结果。
+平时所说的 4 个 9 的可用性(系统 99.99% 的时间是可用的),是衡量系统可用程度的指标,和 CAP 中的可用性并不完全相同。
### 分区容忍性
网络分区指分布式系统中的节点被划分为多个区域,每个区域内部可以通信,但是区域之间无法通信。
-在分区容忍性条件下,分布式系统在遇到任何网络分区故障的时候,仍然需要能对外提供一致性和可用性的服务,除非是整个网络环境都发生了故障。
+分区容忍性指的是在出现网络分区,也就是节点之间的消息丢失或者延迟的情况下,系统仍然能够继续运行。
### 权衡
-在分布式系统中,分区容忍性必不可少,因为需要总是假设网络是不可靠的。因此,CAP 理论实际上是要在可用性和一致性之间做权衡。
+在分布式系统中,分区容忍性必不可少,因为需要总是假设网络是不可靠的。因此,CAP 理论实际上是在发生网络分区时,要在可用性和一致性之间做权衡。而在没有发生网络分区时,一致性和可用性是可以同时满足的。
-可用性和一致性往往是冲突的,很难使它们同时满足。在多个节点之间进行数据同步时,
+例如两个节点之间发生分区后,一个节点完成了写入,另一个节点收到读请求时,要么返回旧数据(放弃一致性),要么拒绝响应(放弃可用性),不存在第三种选择。
-- 为了保证一致性(CP),不能访问未同步完成的节点,也就失去了部分可用性;
-- 为了保证可用性(AP),允许读取所有节点的数据,但是数据可能不一致。
+- 为了保证一致性(CP),无法和其它节点同步的节点需要拒绝请求,也就失去了部分可用性,例如 Zookeeper、etcd;
+- 为了保证可用性(AP),所有节点都继续提供服务,但是读取到的数据可能不一致,例如 Eureka、Cassandra。
+
+PACELC 是对 CAP 的扩展:发生分区(P)时在可用性(A)和一致性(C)之间权衡,没有分区(E)时则在延迟(L)和一致性(C)之间权衡,因为保证强一致性需要等待节点之间同步数据,会增加响应延迟。
## 四、BASE
@@ -198,7 +263,6 @@ BASE 是基本可用(Basically Available)、软状态(Soft State)和最
BASE 理论是对 CAP 中一致性和可用性权衡的结果,它的核心思想是:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。
-
### 基本可用
指分布式系统在出现故障的时候,保证核心可用,允许损失部分可用性。
@@ -219,53 +283,52 @@ ACID 要求强一致性,通常运用在传统的数据库系统上。而 BASE
## 五、Paxos
-用于达成共识性问题,即对多个节点产生的值,该算法能保证只选出唯一一个值。
+用于解决共识问题,即让多个节点对某个值达成一致。该算法能保证只会选出唯一一个值,并且一旦选定就不会再改变。
主要有三类节点:
- 提议者(Proposer):提议一个值;
- 接受者(Acceptor):对每个提议进行投票;
-- 告知者(Learner):被告知投票的结果,不参与投票过程。
+- 学习者(Learner):被告知投票的结果,不参与投票过程。
+
+一个节点可以同时担任多种角色。
### 执行过程
-规定一个提议包含两个字段:[n, v],其中 n 为序号(具有唯一性),v 为提议值。
+规定一个提议包含两个字段:[n, v],其中 n 为序号(全局唯一并且递增),v 为提议值。
#### 1. Prepare 阶段
-下图演示了两个 Proposer 和三个 Acceptor 的系统中运行该算法的初始过程,每个 Proposer 都会向所有 Acceptor 发送 Prepare 请求。
+Proposer 选择一个序号 n,向所有 Acceptor 发送 Prepare(n) 请求,该请求不需要包含提议值。
-
+Acceptor 接收到 Prepare(n) 请求时:
-当 Acceptor 接收到一个 Prepare 请求,包含的提议为 [n1, v1],并且之前还未接收过 Prepare 请求,那么发送一个 Prepare 响应,设置当前接收到的提议为 [n1, v1],并且保证以后不会再接受序号小于 n1 的提议。
-
-如下图,Acceptor X 在收到 [n=2, v=8] 的 Prepare 请求时,由于之前没有接收过提议,因此就发送一个 [no previous] 的 Prepare 响应,设置当前接收到的提议为 [n=2, v=8],并且保证以后不会再接受序号小于 2 的提议。其它的 Acceptor 类似。
-
-
-
-如果 Acceptor 接收到一个 Prepare 请求,包含的提议为 [n2, v2],并且之前已经接收过提议 [n1, v1]。如果 n1 \> n2,那么就丢弃该提议请求;否则,发送 Prepare 响应,该 Prepare 响应包含之前已经接收过的提议 [n1, v1],设置当前接收到的提议为 [n2, v2],并且保证以后不会再接受序号小于 n2 的提议。
-
-如下图,Acceptor Z 收到 Proposer A 发来的 [n=2, v=8] 的 Prepare 请求,由于之前已经接收过 [n=4, v=5] 的提议,并且 n \> 2,因此就抛弃该提议请求;Acceptor X 收到 Proposer B 发来的 [n=4, v=5] 的 Prepare 请求,因为之前接收到的提议为 [n=2, v=8],并且 2 \<= 4,因此就发送 [n=2, v=8] 的 Prepare 响应,设置当前接收到的提议为 [n=4, v=5],并且保证以后不会再接受序号小于 4 的提议。Acceptor Y 类似。
-
-
+- 如果 n 大于它之前响应过的所有 Prepare 请求的序号,那么就承诺以后不会再接受序号小于 n 的提议,并发送 Prepare 响应。如果它之前已经接受(Accept)过提议,那么响应中需要包含其中序号最大的那个提议;
+- 否则,丢弃该请求。
#### 2. Accept 阶段
-当一个 Proposer 接收到超过一半 Acceptor 的 Prepare 响应时,就可以发送 Accept 请求。
+当一个 Proposer 接收到超过一半 Acceptor 的 Prepare 响应时,就可以发送 Accept(n, v) 请求,其中 v 的取值规则为:
-Proposer A 接收到两个 Prepare 响应之后,就发送 [n=2, v=8] Accept 请求。该 Accept 请求会被所有 Acceptor 丢弃,因为此时所有 Acceptor 都保证不接受序号小于 4 的提议。
+- 如果响应中包含已经被接受过的提议,那么 v 取其中序号最大的提议对应的值;
+- 否则,v 可以取 Proposer 自己的提议值。
-Proposer B 过后也收到了两个 Prepare 响应,因此也开始发送 Accept 请求。需要注意的是,Accept 请求的 v 需要取它收到的最大提议编号对应的 v 值,也就是 8。因此它发送 [n=4, v=8] 的 Accept 请求。
+Acceptor 接收到 Accept(n, v) 请求时,只要它没有对序号大于 n 的 Prepare 请求做过承诺,就接受该提议。
-
+如果 Proposer 没有得到大多数 Acceptor 的接受,就使用更大的序号重新开始 Prepare 阶段。
#### 3. Learn 阶段
-Acceptor 接收到 Accept 请求时,如果序号大于等于该 Acceptor 承诺的最小序号,那么就发送 Learn 提议给所有的 Learner。当 Learner 发现有大多数的 Acceptor 接收了某个提议,那么该提议的提议值就被 Paxos 选择出来。
+Acceptor 接受一个提议之后,会将该提议发送给所有的 Learner。当 Learner 发现有大多数的 Acceptor 接受了同一个提议,那么该提议的提议值就被 Paxos 选择出来。
-
+#### 4. 示例
+
+考虑两个 Proposer A、B 和三个 Acceptor X、Y、Z:
+
+- A 发送 Prepare(2),X 和 Y 做出承诺并响应。A 得到大多数响应之后发送 Accept(2, 8),X 和 Y 接受该提议,此时值 8 已经被选定;
+- 之后 B 发送 Prepare(4),它收到的大多数响应中至少有一个来自 X 或 Y,响应中包含已经接受过的提议 [2, 8],因此 B 只能发送 Accept(4, 8),而不能使用自己的提议值,从而保证了选定的值不会改变。
### 约束条件
@@ -273,23 +336,25 @@ Acceptor 接收到 Accept 请求时,如果序号大于等于该 Acceptor 承
指只有一个提议值会生效。
-因为 Paxos 协议要求每个生效的提议被多数 Acceptor 接收,并且 Acceptor 不会接受两个不同的提议,因此可以保证正确性。
+任意两个大多数集合之间至少有一个公共的 Acceptor。一旦某个提议值被大多数 Acceptor 接受,之后的任何 Proposer 在 Prepare 阶段都一定会从这个公共的 Acceptor 得知该值,从而只能继续提议该值,因此可以保证正确性。
#### 2\. 可终止性
指最后总会有一个提议生效。
-Paxos 协议能够让 Proposer 发送的提议朝着能被大多数 Acceptor 接受的那个提议靠拢,因此能够保证可终止性。
+Paxos 并不能严格保证可终止性:如果两个 Proposer 交替地使用更大的序号发送 Prepare 请求,那么双方的 Accept 请求都会一直被拒绝,形成活锁。实际中通常会选出一个主 Proposer,只由它来发起提议(Multi-Paxos 的做法),或者在重试前随机等待一段时间。
## 六、Raft
-Raft 也是分布式一致性协议,主要是用来竞选主节点。
+Raft 也是分布式共识算法,相比于 Paxos 更容易理解和实现。它将共识问题分解为 Leader 选举、日志复制和安全性三个子问题,etcd、Consul 等系统都使用了 Raft。
- [Raft: Understandable Distributed Consensus](http://thesecretlivesofdata.com/raft)
### 单个 Candidate 的竞选
-有三种节点:Follower、Candidate 和 Leader。Leader 会周期性的发送心跳包给 Follower。每个 Follower 都设置了一个随机的竞选超时时间,一般为 150ms\~300ms,如果在这个时间内没有收到 Leader 的心跳包,就会变成 Candidate,进入竞选阶段。
+有三种节点:Follower、Candidate 和 Leader。Raft 把时间划分为多个任期(Term),任期号单调递增,每个任期从一次选举开始。
+
+Leader 会周期性的发送心跳包给 Follower。每个 Follower 都设置了一个随机的竞选超时时间,一般为 150ms\~300ms,如果在这个时间内没有收到 Leader 的心跳包,就会变成 Candidate,将任期号加 1 并给自己投一票,进入竞选阶段。
- 下图展示一个分布式系统的最初阶段,此时只有 Follower 没有 Leader。Node A 等待一个随机的竞选超时时间之后,没收到 Leader 发来的心跳包,因此进入竞选阶段。
@@ -299,7 +364,7 @@ Raft 也是分布式一致性协议,主要是用来竞选主节点。
-- 其它节点会对请求进行回复,如果超过一半的节点回复了,那么该 Candidate 就会变成 Leader。
+- 每个节点在一个任期内最多只能投一票,先到先得,并且只会投给日志至少和自己一样新的 Candidate,这保证了新的 Leader 一定包含所有已提交的日志。如果 Candidate 获得超过一半节点的选票,就会变成 Leader。
@@ -309,7 +374,7 @@ Raft 也是分布式一致性协议,主要是用来竞选主节点。
### 多个 Candidate 竞选
-- 如果有多个 Follower 成为 Candidate,并且所获得票数相同,那么就需要重新开始投票。例如下图中 Node B 和 Node D 都获得两票,需要重新开始投票。
+- 如果有多个 Follower 同时成为 Candidate,选票可能会被瓜分,导致没有 Candidate 获得超过一半的选票,那么等待竞选超时之后进入下一个任期重新开始投票。例如下图中 Node B 和 Node D 都获得两票,需要重新开始投票。
@@ -317,7 +382,9 @@ Raft 也是分布式一致性协议,主要是用来竞选主节点。
-### 数据同步
+- 如果 Candidate 在竞选过程中收到了任期号不小于自己的 Leader 发来的心跳包,就会承认该 Leader 并变回 Follower。
+
+### 日志复制
- 来自客户端的修改都会被传入 Leader。注意该修改还未被提交,只是写入日志中。
@@ -327,24 +394,34 @@ Raft 也是分布式一致性协议,主要是用来竞选主节点。
-- Leader 会等待大多数的 Follower 也进行了修改,然后才将修改提交。
+- Leader 会等待大多数的节点都写入了该日志,然后才将修改提交,并向客户端返回结果。
-- 此时 Leader 会通知的所有 Follower 让它们也提交修改,此时所有节点的值达成一致。
+- 此时 Leader 会在之后的心跳包中通知所有 Follower 让它们也提交修改,此时所有节点的值达成一致。
+如果 Follower 的日志和 Leader 不一致,Leader 会找到两者最后一致的位置,然后用自己的日志覆盖 Follower 在该位置之后的日志。
+
+发生网络分区时,少数派一侧的旧 Leader 得不到大多数节点的确认,无法提交日志,多数派一侧会选出新的 Leader。分区恢复后,旧 Leader 发现更大的任期号就会退回 Follower,它未提交的日志被覆盖,因此不会出现脑裂。
+
## 参考
- 倪超. 从 Paxos 到 ZooKeeper : 分布式一致性原理与实践 [M]. 电子工业出版社, 2015.
- [Distributed locks with Redis](https://redis.io/topics/distlock)
+- [How to do distributed locking](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html)
+- [Locks and synchronizers - Redisson](https://redisson.pro/docs/data-and-services/locks-and-synchronizers/)
+- [深度剖析 Seata TCC 模式](https://www.seata.io/zh-cn/blog/seata-tcc/)
+- [事务消息 | RocketMQ](https://rocketmq.apache.org/zh/docs/featureBehavior/04transactionmessage/)
+- [An Illustrated Proof of the CAP Theorem](https://mwhittaker.github.io/blog/an_illustrated_proof_of_the_cap_theorem/)
- [浅谈分布式锁](http://www.linkedkeeper.com/detail/blog.action?bid=1023)
- [基于 Zookeeper 的分布式锁](http://www.dengshenyu.com/java/%E5%88%86%E5%B8%83%E5%BC%8F%E7%B3%BB%E7%BB%9F/2017/10/23/zookeeper-distributed-lock.html)
- [聊聊分布式事务,再说说解决方案](https://www.cnblogs.com/savorboard/p/distributed-system-transaction-consistency.html)
- [分布式系统的事务处理](https://coolshell.cn/articles/10910.html)
- [深入理解分布式事务](https://juejin.im/entry/577c6f220a2b5800573492be)
- [What is CAP theorem in distributed database system?](http://www.colooshiki.com/index.php/2017/04/20/what-is-cap-theorem-in-distributed-database-system/)
+- [Paxos Made Simple](https://lamport.azurewebsites.net/pubs/paxos-simple.pdf)
- [NEAT ALGORITHMS - PAXOS](http://harry.me/blog/2014/12/27/neat-algorithms-paxos/)
-- [Paxos By Example](https://angus.nyc/2012/paxos-by-example/)
+- [In Search of an Understandable Consensus Algorithm](https://raft.github.io/raft.pdf)
diff --git a/notes/攻击技术.md b/notes/攻击技术.md
index e0107b4f..168b045f 100644
--- a/notes/攻击技术.md
+++ b/notes/攻击技术.md
@@ -15,6 +15,12 @@
跨站脚本攻击(Cross-Site Scripting, XSS),可以将代码注入到用户浏览的网页上,这种代码包括 HTML 和 JavaScript。
+主要有三种类型:
+
+- 存储型:恶意代码保存在服务器上,例如论坛帖子,所有浏览该页面的用户都会受到攻击;
+- 反射型:恶意代码放在 URL 参数中,服务器直接把它输出到页面上,需要诱导用户点击该 URL;
+- DOM 型:前端 JavaScript 直接把不可信的数据写入 DOM,服务器没有参与。
+
### 攻击原理
例如有一个论坛网站,攻击者可以在上面发布以下内容:
@@ -36,6 +42,7 @@
- 窃取用户的 Cookie
- 伪造虚假的输入表单骗取个人信息
- 显示伪造的文章或者图片
+- 以用户的身份向网站发起请求
### 防范手段
@@ -43,9 +50,9 @@
设置了 HttpOnly 的 Cookie 可以防止 JavaScript 脚本调用,就无法通过 document.cookie 获取用户 Cookie 信息。
-#### 2. 过滤特殊字符
+#### 2. 转义特殊字符
-例如将 `<` 转义为 `<`,将 `>` 转义为 `>`,从而避免 HTML 和 Jascript 代码的运行。
+在把用户输入的数据输出到页面时进行转义,例如将 `<` 转义为 `<`,将 `>` 转义为 `>`,从而避免 HTML 和 JavaScript 代码的运行。输出到 HTML 标签、属性、JavaScript 代码和 URL 中需要使用不同的转义规则。
富文本编辑器允许用户输入 HTML 代码,就不能简单地将 `<` 等字符进行过滤了,极大地提高了 XSS 攻击的可能性。
@@ -87,6 +94,10 @@ alert(/xss/);
> [XSS 过滤在线测试](http://jsxss.com/zh/try.html)
+#### 3. 内容安全策略
+
+通过 Content-Security-Policy 首部告诉浏览器只允许执行来自指定来源的脚本,禁止内联脚本,即使攻击者注入了代码也不会被执行。
+
## 二、跨站请求伪造
### 概念
@@ -100,18 +111,18 @@ XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用
假如一家银行用以执行转账操作的 URL 地址如下:
```
-http://www.examplebank.com/withdraw?account=AccoutName&amount=1000&for=PayeeName。
+http://www.examplebank.com/withdraw?account=AccountName&amount=1000&for=PayeeName
```
那么,一个恶意攻击者可以在另一个网站上放置如下代码:
```
-
。
+
```
-如果有账户名为 Alice 的用户访问了恶意站点,而她之前刚访问过银行不久,登录信息尚未过期,那么她就会损失 1000 美元。
+如果有账户名为 Alice 的用户访问了恶意站点,而之前刚访问过银行不久,登录信息尚未过期,那么就会损失 1000 美元。
-这种恶意的网址可以有很多种形式,藏身于网页中的许多地方。此外,攻击者也不需要控制放置恶意网址的网站。例如他可以将这种地址藏在论坛,博客等任何用户生成内容的网站中。这意味着如果服务器端没有合适的防御措施的话,用户即使访问熟悉的可信网站也有受攻击的危险。
+这种恶意的网址可以有很多种形式,藏身于网页中的许多地方。此外,攻击者也不需要控制放置恶意网址的网站。例如他可以将这种地址藏在论坛,博客等任何用户生成内容的网站中。这意味着如果服务器端没有合适的防御措施的话,用户即使访问熟悉的可信网站也有受攻击的危险。改为 POST 请求也不能解决问题,攻击者可以使用自动提交的表单。
通过例子能够看出,攻击者并不能通过 CSRF 攻击来直接获取用户的账户控制权,也不能直接窃取用户的任何信息。他们能做到的,是欺骗用户浏览器,让其以用户的名义执行操作。
@@ -125,9 +136,13 @@ Referer 首部字段位于 HTTP 报文中,用于标识请求来源的地址。
#### 2. 添加校验 Token
-在访问敏感数据请求时,要求用户浏览器提供不保存在 Cookie 中,并且攻击者无法伪造的数据作为校验。例如服务器生成随机数并附加在表单中,并要求客户端传回这个随机数。
+在访问敏感数据请求时,要求用户浏览器提供不保存在 Cookie 中,并且攻击者无法伪造的数据作为校验。例如服务器生成随机数并附加在表单中,并要求客户端传回这个随机数。攻击者的网站受同源策略限制,无法读取到这个随机数。
-#### 3. 输入验证码
+#### 3. 设置 Cookie 的 SameSite 属性
+
+SameSite=Strict 表示跨站请求不携带 Cookie,Lax 则只允许顶级导航的 GET 请求携带。Chrome 等浏览器的默认值已经是 Lax,上面例子中图片发出的请求不会带上 Cookie。
+
+#### 4. 输入验证码
因为 CSRF 攻击是在用户无意识的情况下发生的,所以要求用户输入验证码可以让用户知道自己正在做的操作。
@@ -168,7 +183,7 @@ strSQL = "SELECT * FROM users;"
#### 1. 使用参数化查询
-Java 中的 PreparedStatement 是预先编译的 SQL 语句,可以传入适当参数并且多次执行。由于没有拼接的过程,因此可以防止 SQL 注入的发生。
+Java 中的 PreparedStatement 是预先编译的 SQL 语句,可以传入适当参数并且多次执行。SQL 语句的结构在预编译时已经确定,传入的参数只会被当作数据,因此可以防止 SQL 注入的发生。
```java
PreparedStatement stmt = connection.prepareStatement("SELECT * FROM users WHERE userid=? AND password=?");
@@ -179,7 +194,11 @@ ResultSet rs = stmt.executeQuery();
#### 2. 单引号转换
-将传入的参数中的单引号转换为连续两个单引号,PHP 中的 Magic quote 可以完成这个功能。
+将传入的参数中的单引号转换为连续两个单引号。PHP 的 Magic Quotes 曾经自动完成这个功能,但是因为不可靠已经被移除,应该使用数据库驱动提供的转义函数,并且只在无法使用参数化查询时使用。
+
+#### 3. 最小权限
+
+应用连接数据库的账号只授予必要的权限,即使发生注入也无法执行删库等危险操作。
## 四、拒绝服务攻击
@@ -187,9 +206,23 @@ ResultSet rs = stmt.executeQuery();
分布式拒绝服务攻击(distributed denial-of-service attack,DDoS),指攻击者使用两个或以上被攻陷的电脑作为“僵尸”向特定的目标发动“拒绝服务”式攻击。
+常见类型:
+
+- 流量型:用海量数据包耗尽目标的带宽,例如 UDP Flood,以及利用 DNS、NTP 等服务的反射放大攻击;
+- 协议型:耗尽服务器的连接资源,例如 SYN Flood 只发送 SYN 报文而不完成三次握手,使服务器维持大量半连接;
+- 应用层:发送大量消耗服务器资源的正常请求,例如 HTTP Flood(CC 攻击)。
+
+防范手段:
+
+- 使用 CDN、高防 IP 等服务对流量进行清洗;
+- 使用 SYN Cookie 应对 SYN Flood,服务器在握手完成之前不为连接分配资源;
+- 在应用层进行限流,并通过验证码区分正常用户和攻击程序。
+
## 参考资料
- [维基百科:跨站脚本](https://zh.wikipedia.org/wiki/%E8%B7%A8%E7%B6%B2%E7%AB%99%E6%8C%87%E4%BB%A4%E7%A2%BC)
- [维基百科:SQL 注入攻击](https://zh.wikipedia.org/wiki/SQL%E8%B3%87%E6%96%99%E9%9A%B1%E7%A2%BC%E6%94%BB%E6%93%8A)
- [维基百科:跨站点请求伪造](https://zh.wikipedia.org/wiki/%E8%B7%A8%E7%AB%99%E8%AF%B7%E6%B1%82%E4%BC%AA%E9%80%A0)
- [维基百科:拒绝服务攻击](https://zh.wikipedia.org/wiki/%E9%98%BB%E6%96%B7%E6%9C%8D%E5%8B%99%E6%94%BB%E6%93%8A)
+- [OWASP Cross Site Scripting Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html)
+- [OWASP Cross-Site Request Forgery Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
diff --git a/notes/消息队列.md b/notes/消息队列.md
index 60507bc9..9fb85cc0 100644
--- a/notes/消息队列.md
+++ b/notes/消息队列.md
@@ -6,11 +6,14 @@
* [发布/订阅](#发布订阅)
* [二、使用场景](#二使用场景)
* [异步处理](#异步处理)
- * [流量削锋](#流量削锋)
+ * [流量削峰](#流量削峰)
* [应用解耦](#应用解耦)
* [三、可靠性](#三可靠性)
* [发送端的可靠性](#发送端的可靠性)
+ * [消息队列的可靠性](#消息队列的可靠性)
* [接收端的可靠性](#接收端的可靠性)
+ * [四、顺序性](#四顺序性)
+ * [五、消息堆积](#五消息堆积)
* [参考资料](#参考资料)
@@ -29,6 +32,8 @@
+Kafka、RocketMQ 等通过消费者组把两种模型结合起来:一条消息会发送给每个订阅它的消费者组,但在一个消费者组内只会被一个消费者消费。
+
发布与订阅模式和观察者模式有以下不同:
- 观察者模式中,观察者和主题都知道对方的存在;而在发布与订阅模式中,生产者与消费者不知道对方的存在,它们之间通过频道进行通信。
@@ -46,7 +51,7 @@
只有在业务流程允许异步处理的情况下才能这么做,例如上面的注册流程中,如果要求用户对验证邮件进行点击之后才能完成注册的话,就不能再使用消息队列。
-### 流量削锋
+### 流量削峰
在高并发的场景下,如果短时间有大量的请求到达会压垮服务器。
@@ -58,23 +63,45 @@
通过使用消息队列,一个模块只需要向消息队列中发送消息,其它模块可以选择性地从消息队列中订阅消息从而完成调用。
+引入消息队列的代价是系统多了一个需要保证高可用的组件,并且需要处理下面的可靠性和顺序性问题。
+
## 三、可靠性
+一条消息要经过发送、存储、消费三个环节,每个环节都可能丢失消息。
+
### 发送端的可靠性
发送端完成操作后一定能将消息成功发送到消息队列中。
实现方法:在本地数据库建一张消息表,将消息数据与业务数据保存在同一数据库实例里,这样就可以利用本地数据库的事务机制。事务提交成功后,将消息表中的消息转移到消息队列中,若转移消息成功则删除消息表中的数据,否则继续重传。
+消息队列在持久化消息之后才向发送端返回确认,发送端没有收到确认就进行重传。
+
+### 消息队列的可靠性
+
+消息队列收到消息后需要持久化到磁盘,并复制到多个副本上,避免单个节点宕机导致消息丢失。
+
### 接收端的可靠性
接收端能够从消息队列成功消费一次消息。
-两种实现方法:
+接收端在处理完成之后才向消息队列确认,处理过程中崩溃的话消息会被重新投递,因此一条消息可能被重复消费,需要做到:
- 保证接收端处理消息的业务逻辑具有幂等性:只要具有幂等性,那么消费多少次消息,最后处理的结果都是一样的。
- 保证消息具有唯一编号,并使用一张日志表来记录已经消费的消息编号。
+## 四、顺序性
+
+消息队列通常只保证单个队列(Kafka 中的分区)内的消息有序,多个队列之间没有顺序保证。
+
+要保证同一业务的消息有序,需要让发送端把这些消息按业务 Key(例如订单号)路由到同一个队列,并且接收端对同一个队列只用一个线程顺序消费。
+
+## 五、消息堆积
+
+消费速度长期低于生产速度时,消息会在队列中堆积。
+
+解决方法是增加消费者数量来提高消费能力,但是消费者数量超过队列(分区)数量之后就不再有效,需要同时增加队列数量。
+
## 参考资料
- [Observer vs Pub-Sub](http://developers-club.com/posts/270339/)
diff --git a/notes/系统设计基础.md b/notes/系统设计基础.md
index 02ffa567..873f12f1 100644
--- a/notes/系统设计基础.md
+++ b/notes/系统设计基础.md
@@ -18,19 +18,19 @@
指某个请求从发出到接收到响应消耗的时间。
-在对响应时间进行测试时,通常采用重复请求的方式,然后计算平均响应时间。
+在对响应时间进行测试时,通常采用重复请求的方式,然后计算平均响应时间。平均值会掩盖少数很慢的请求,因此也常用 P99 等分位值来衡量。
#### 2. 吞吐量
-指系统在单位时间内可以处理的请求数量,通常使用每秒的请求数来衡量。
+指系统在单位时间内可以处理的请求数量,通常使用每秒的请求数(QPS)来衡量。
#### 3. 并发用户数
指系统能同时处理的并发用户请求数量。
-在没有并发存在的系统中,请求被顺序执行,此时响应时间为吞吐量的倒数。例如系统支持的吞吐量为 100 req/s,那么平均响应时间应该为 0.01s。
+三者的关系为:并发数 = 吞吐量 × 响应时间。在没有并发存在的系统中,请求被顺序执行,此时响应时间为吞吐量的倒数。例如系统支持的吞吐量为 100 req/s,那么平均响应时间应该为 0.01s。
-目前的大型系统都支持多线程来处理并发请求,多线程能够提高吞吐量以及缩短响应时间,主要有两个原因:
+目前的大型系统都支持多线程来处理并发请求,多线程能够提高吞吐量,主要有两个原因:
- 多 CPU
- IO 等待时间
@@ -61,6 +61,9 @@
指不断向集群中添加服务器来缓解不断上升的用户并发访问压力和不断增长的数据存储需求。
+- 垂直伸缩:提升单台服务器的硬件配置,存在上限;
+- 水平伸缩:增加服务器的数量,是大型系统的主要方式。
+
### 伸缩性与性能
如果系统存在性能问题,那么单个用户的请求总是很慢的;
@@ -71,7 +74,7 @@
应用服务器只要不具有状态,那么就可以很容易地通过负载均衡器向集群中添加新的服务器。
-关系型数据库的伸缩性通过 Sharding 来实现,将数据按一定的规则分布到不同的节点上,从而解决单台存储服务器的存储空间限制。
+关系型数据库通常先通过主从复制实现读写分离来分担读压力,再通过 Sharding 将数据按一定的规则分布到不同的节点上,从而解决单台存储服务器的存储空间限制。
对于非关系型数据库,它们天生就是为海量数据而诞生,对伸缩性的支持特别好。
@@ -86,25 +89,29 @@
## 四、可用性
+可用性用系统可用时间占总时间的比值来衡量,4 个 9 表示系统 99.99% 的时间是可用的,一年的不可用时间不超过 53 分钟。
+
### 冗余
保证高可用的主要手段是使用冗余,当某个服务器故障时就请求其它服务器。
应用服务器的冗余比较容易实现,只要保证应用服务器不具有状态,那么某个应用服务器故障时,负载均衡器将该应用服务器原先的用户请求转发到另一个应用服务器上,不会对用户有任何影响。
-存储服务器的冗余需要使用主从复制来实现,当主服务器故障时,需要提升从服务器为主服务器,这个过程称为切换。
+存储服务器的冗余需要使用主从复制来实现,当主服务器故障时,需要提升从服务器为主服务器,这个过程称为故障转移(Failover)。
### 监控
对 CPU、内存、磁盘、网络等系统负载信息进行监控,当某个信息达到一定阈值时通知运维人员,从而在系统发生故障之前及时发现问题。
-### 服务降级
+### 限流、降级与熔断
-服务降级是系统为了应对大量的请求,主动关闭部分功能,从而保证核心功能可用。
+- 限流:请求量超过系统的处理能力时,拒绝多余的请求,常用令牌桶和漏桶算法;
+- 降级:主动关闭部分非核心功能,从而保证核心功能可用;
+- 熔断:某个依赖的服务持续出错时,暂时不再调用它而是直接返回默认结果,避免故障沿着调用链扩散。
## 五、安全性
-要求系统在应对各种攻击手段时能够有可靠的应对措施。
+要求系统在应对各种攻击手段时能够有可靠的应对措施,常见的攻击手段和防范方法见 [攻击技术](攻击技术.md)。
## 参考资料
diff --git a/notes/缓存.md b/notes/缓存.md
index f20d1dd5..d764eb08 100644
--- a/notes/缓存.md
+++ b/notes/缓存.md
@@ -28,11 +28,13 @@
### 淘汰策略
-- FIFO(First In First Out):先进先出策略,在实时性的场景下,需要经常访问最新的数据,那么就可以使用 FIFO,使得最先进入的数据(最晚的数据)被淘汰。
+- FIFO(First In First Out):先进先出策略,优先淘汰最早进入缓存的数据。实现简单,但是没有考虑数据的访问情况,命中率较低。
-- LRU(Least Recently Used):最近最久未使用策略,优先淘汰最久未使用的数据,也就是上次被访问时间距离现在最久的数据。该策略可以保证内存中的数据都是热点数据,也就是经常被访问的数据,从而保证缓存命中率。
+- LRU(Least Recently Used):最近最久未使用策略,优先淘汰最久未使用的数据,也就是上次被访问时间距离现在最久的数据。该策略可以保证内存中的数据都是热点数据,也就是经常被访问的数据,从而保证缓存命中率。缺点是偶发的批量访问会把热点数据挤出缓存。
-- LFU(Least Frequently Used):最不经常使用策略,优先淘汰一段时间内使用次数最少的数据。
+- LFU(Least Frequently Used):最不经常使用策略,优先淘汰一段时间内使用次数最少的数据。缺点是过去的热点数据即使很久不再访问也难以被淘汰。
+
+除了容量淘汰,缓存通常还会为数据设置过期时间,到期后自动失效。
## 二、缓存位置
@@ -50,17 +52,17 @@
### 本地缓存
-使用 Guava Cache 将数据缓存在服务器本地内存中,服务器代码可以直接读取本地内存中的缓存,速度非常快。
+使用 Guava Cache、Caffeine 等将数据缓存在服务器本地内存中,服务器代码可以直接读取本地内存中的缓存,速度非常快。
### 分布式缓存
-使用 Redis、Memcache 等分布式缓存将数据缓存在分布式缓存系统中。
+使用 Redis、Memcached 等分布式缓存将数据缓存在分布式缓存系统中。
相对于本地缓存来说,分布式缓存单独部署,可以根据需求分配硬件资源。不仅如此,服务器集群都可以访问分布式缓存,而本地缓存需要在服务器集群之间进行同步,实现难度和性能开销上都非常大。
### 数据库缓存
-MySQL 等数据库管理系统具有自己的查询缓存机制来提高查询效率。
+MySQL 等数据库管理系统具有自己的缓存机制来提高查询效率,例如 InnoDB 的 Buffer Pool 将数据页和索引页缓存在内存中。MySQL 的查询缓存因为命中率低、维护开销大,已经在 8.0 版本中被移除。
### Java 内部的缓存
@@ -74,6 +76,8 @@ CPU 为了解决运算速度与主存 IO 速度不匹配的问题,引入了多
内容分发网络(Content distribution network,CDN)是一种互连的网络系统,它利用更靠近用户的服务器从而更快更可靠地将 HTML、CSS、JavaScript、音乐、图片、视频等静态资源分发给用户。
+用户请求通过 DNS 被调度到最近的 CDN 节点,节点未命中缓存时再回源站获取。
+
CDN 主要有以下优点:
- 更快地将数据分发给用户;
@@ -90,8 +94,17 @@ CDN 主要有以下优点:
解决方案:
-- 对这些不存在的数据缓存一个空数据;
-- 对这类请求进行过滤。
+- 对这些不存在的数据缓存一个空数据,并设置较短的过期时间;
+- 使用布隆过滤器保存所有存在的 Key,对不存在的 Key 的请求直接拒绝。
+
+### 缓存击穿
+
+指的是某个热点数据的缓存过期时,大量对它的请求同时到达数据库。
+
+解决方案:
+
+- 使用互斥锁,只允许一个请求去数据库加载数据并写回缓存,其它请求等待后直接读缓存;
+- 热点数据不设置过期时间,或者由后台线程异步更新。
### 缓存雪崩
@@ -101,19 +114,25 @@ CDN 主要有以下优点:
解决方案:
-- 为了防止缓存在同一时间大面积过期导致的缓存雪崩,可以通过观察用户行为,合理设置缓存过期时间来实现;
-- 为了防止缓存服务器宕机出现的缓存雪崩,可以使用分布式缓存,分布式缓存中每一个节点只缓存部分的数据,当某个节点宕机时可以保证其它节点的缓存仍然可用。
-- 也可以进行缓存预热,避免在系统刚启动不久由于还未将大量数据进行缓存而导致缓存雪崩。
-
+- 为了防止缓存在同一时间大面积过期导致的缓存雪崩,可以在过期时间上加一个随机值,让过期时间分散开;
+- 为了防止缓存服务器宕机出现的缓存雪崩,可以使用分布式缓存,分布式缓存中每一个节点只缓存部分的数据,当某个节点宕机时可以保证其它节点的缓存仍然可用;
+- 也可以进行缓存预热,避免在系统刚启动不久由于还未将大量数据进行缓存而导致缓存雪崩;
+- 对数据库的访问进行限流,即使缓存失效也不至于压垮数据库。
### 缓存一致性
-缓存一致性要求数据更新的同时缓存数据也能够实时更新。
+缓存一致性要求数据更新之后缓存中不能长期保留旧数据。常用的是 Cache Aside 模式:
-解决方案:
+- 读:先读缓存,未命中则读数据库,并将结果写入缓存;
+- 写:先更新数据库,再删除缓存。
-- 在数据更新的同时立即去更新缓存;
-- 在读缓存之前先判断缓存是否是最新的,如果不是最新的先进行更新。
+删除缓存而不是更新缓存,是因为两个并发的写操作到达缓存的顺序可能和到达数据库的顺序不同,导致缓存中是旧值。先更新数据库再删除缓存,是因为反过来的话,删除缓存之后、更新数据库之前的读请求会把旧值重新写入缓存。
+
+这种模式仍然存在小概率的不一致,例如删除缓存失败。常用的补救措施:
+
+- 为缓存设置过期时间作为兜底;
+- 延迟双删:更新数据库之后,隔一小段时间再删除一次缓存;
+- 订阅数据库的 binlog(例如使用 Canal)来异步删除缓存,失败时重试。
要保证缓存一致性需要付出很大的代价,缓存数据最好是那些对一致性要求不高的数据,允许缓存数据存在一些脏数据。
@@ -148,15 +167,15 @@ CDN 主要有以下优点:
## 六、一致性哈希
-Distributed Hash Table(DHT) 是一种哈希分布方式,其目的是为了克服传统哈希分布在服务器节点数量变化时大量数据迁移的问题。
+一致性哈希(Consistent Hashing)是一种哈希分布方式,其目的是为了克服传统哈希分布在服务器节点数量变化时大量数据迁移的问题。
### 基本原理
-将哈希空间 [0, 2n-1] 看成一个哈希环,每个服务器节点都配置到哈希环上。每个数据对象通过哈希取模得到哈希值之后,存放到哈希环中顺时针方向第一个大于等于该哈希值的节点上。
+将哈希空间 [0, 2n-1] 看成一个哈希环,每个服务器节点都通过哈希配置到哈希环上。每个数据对象通过哈希函数得到哈希值之后,存放到哈希环中顺时针方向第一个大于等于该哈希值的节点上。
-一致性哈希在增加或者删除节点时只会影响到哈希环中相邻的节点,例如下图中新增节点 X,只需要将它前一个节点 C 上的数据重新进行分布即可,对于节点 A、B、D 都没有影响。
+一致性哈希在增加或者删除节点时只会影响到哈希环中相邻的节点,例如下图中在 B 和 C 之间新增节点 X,只需要将节点 C 上哈希值位于 B 和 X 之间的数据迁移到 X 上即可,对于节点 A、B、D 都没有影响。
@@ -168,6 +187,8 @@ Distributed Hash Table(DHT) 是一种哈希分布方式,其目的是为了
解决方式是通过增加虚拟节点,然后将虚拟节点映射到真实节点上。虚拟节点的数量比真实节点来得多,那么虚拟节点在哈希环上分布的均匀性就会比原来的真实节点好,从而使得数据分布也更加均匀。
+Redis Cluster 使用的不是一致性哈希,而是把 Key 映射到固定的 16384 个哈希槽上,再把哈希槽分配给各个节点,节点变化时只需要迁移对应的哈希槽。
+
## 七、LRU
以下是基于 双向链表 + HashMap 的 LRU 算法实现,对算法的解释如下:
@@ -175,6 +196,8 @@ Distributed Hash Table(DHT) 是一种哈希分布方式,其目的是为了
- 访问某个节点时,将其从原来的位置删除,并重新插入到链表头部。这样就能保证链表尾部存储的就是最近最久未使用的节点,当节点数量大于缓存最大空间时就淘汰链表尾部的节点。
- 为了使删除操作时间复杂度为 O(1),就不能采用遍历的方式找到某个节点。HashMap 存储着 Key 到节点的映射,通过 Key 就能以 O(1) 的时间得到节点,然后再以 O(1) 的时间将其从双向队列中删除。
+Java 的 LinkedHashMap 内部就是这种结构,把 accessOrder 设为 true 并重写 removeEldestEntry 方法,就可以直接得到一个 LRU 缓存。
+
```java
public class LRU implements Iterable {
diff --git a/notes/集群.md b/notes/集群.md
index 2cc3a7c8..32588068 100644
--- a/notes/集群.md
+++ b/notes/集群.md
@@ -4,8 +4,8 @@
* [一、负载均衡](#一负载均衡)
* [负载均衡算法](#负载均衡算法)
* [1. 轮询(Round Robin)](#1-轮询round-robin)
- * [2. 加权轮询(Weighted Round Robbin)](#2-加权轮询weighted-round-robbin)
- * [3. 最少连接(least Connections)](#3-最少连接least-connections)
+ * [2. 加权轮询(Weighted Round Robin)](#2-加权轮询weighted-round-robin)
+ * [3. 最少连接(Least Connections)](#3-最少连接least-connections)
* [4. 加权最少连接(Weighted Least Connection)](#4-加权最少连接weighted-least-connection)
* [5. 随机算法(Random)](#5-随机算法random)
* [6. 源地址哈希法 (IP Hash)](#6-源地址哈希法-ip-hash)
@@ -19,6 +19,7 @@
* [Sticky Session](#sticky-session)
* [Session Replication](#session-replication)
* [Session Server](#session-server)
+ * [客户端保存](#客户端保存)
@@ -30,7 +31,7 @@
负载均衡器可以用来实现高可用以及伸缩性:
-- 高可用:当某个节点故障时,负载均衡器会将用户请求转发到另外的节点上,从而保证所有服务持续可用;
+- 高可用:负载均衡器通过健康检查发现某个节点故障时,会将用户请求转发到另外的节点上,从而保证所有服务持续可用;
- 伸缩性:根据系统整体负载情况,可以很容易地添加或移除节点。
负载均衡器运行过程包含两个部分:
@@ -53,7 +54,7 @@
-#### 2. 加权轮询(Weighted Round Robbin)
+#### 2. 加权轮询(Weighted Round Robin)
加权轮询是在轮询的基础上,根据服务器的性能差异,为服务器赋予一定的权值,性能高的服务器分配更高的权值。
@@ -61,7 +62,9 @@
-#### 3. 最少连接(least Connections)
+实际实现(例如 Nginx 的平滑加权轮询)会把请求交错发送,避免连续的请求集中到同一台服务器上。
+
+#### 3. 最少连接(Least Connections)
由于每个请求的连接时间不一样,使用轮询或者加权轮询算法的话,可能会让一台服务器当前连接数过大,而另一台服务器的连接过小,造成负载不均衡。
@@ -77,7 +80,7 @@
#### 4. 加权最少连接(Weighted Least Connection)
-在最少连接的基础上,根据服务器的性能为每台服务器分配权重,再根据权重计算出每台服务器能处理的连接数。
+在最少连接的基础上,根据服务器的性能为每台服务器分配权重,将请求发送给“当前连接数 / 权重”最小的服务器。
#### 5. 随机算法(Random)
@@ -91,10 +94,12 @@
源地址哈希通过对客户端 IP 计算哈希值之后,再对服务器数量取模得到目标服务器的序号。
-可以保证同一 IP 的客户端的请求会转发到同一台服务器上,用来实现会话粘滞(Sticky Session)
+可以保证同一 IP 的客户端的请求会转发到同一台服务器上,用来实现会话粘滞(Sticky Session)。
+缺点是同一出口 IP 后面的大量用户会落到同一台服务器上,造成负载不均;服务器数量变化时取模结果会全部改变,可以改用一致性哈希。
+
### 转发实现
#### 1. HTTP 重定向
@@ -132,6 +137,8 @@ HTTP 重定向负载均衡服务器使用某种负载均衡算法计算得到服
在这种负载均衡转发方式下,客户端不直接请求源服务器,因此源服务器不需要外部 IP 地址,而反向代理需要配置内部和外部两套 IP 地址。
+反向代理工作在应用层,属于七层负载均衡,可以根据 URL、Cookie 等内容进行转发,常用的有 Nginx、HAProxy。
+
优点:
- 与其它功能集成在一起,部署简单。
@@ -144,7 +151,7 @@ HTTP 重定向负载均衡服务器使用某种负载均衡算法计算得到服
在操作系统内核进程获取网络数据包,根据负载均衡算法计算源服务器的 IP 地址,并修改请求数据包的目的 IP 地址,最后进行转发。
-源服务器返回的响应也需要经过负载均衡服务器,通常是让负载均衡服务器同时作为集群的网关服务器来实现。
+源服务器返回的响应也需要经过负载均衡服务器,通常是让负载均衡服务器同时作为集群的网关服务器来实现。对应 LVS 的 NAT 模式。
优点:
@@ -158,11 +165,11 @@ HTTP 重定向负载均衡服务器使用某种负载均衡算法计算得到服
在链路层根据负载均衡算法计算源服务器的 MAC 地址,并修改请求数据包的目的 MAC 地址,并进行转发。
-通过配置源服务器的虚拟 IP 地址和负载均衡服务器的 IP 地址一致,从而不需要修改 IP 地址就可以进行转发。也正因为 IP 地址一样,所以源服务器的响应不需要转发回负载均衡服务器,可以直接转发给客户端,避免了负载均衡服务器的成为瓶颈。
+通过配置源服务器的虚拟 IP 地址和负载均衡服务器的 IP 地址一致,从而不需要修改 IP 地址就可以进行转发。也正因为 IP 地址一样,所以源服务器的响应不需要转发回负载均衡服务器,可以直接转发给客户端,避免了负载均衡服务器成为瓶颈。
-这是一种三角传输模式,被称为直接路由。对于提供下载和视频服务的网站来说,直接路由避免了大量的网络传输数据经过负载均衡服务器。
+这是一种三角传输模式,被称为直接路由,对应 LVS 的 DR 模式,要求源服务器和负载均衡服务器位于同一个局域网内。对于提供下载和视频服务的网站来说,直接路由避免了大量的网络传输数据经过负载均衡服务器。
-这是目前大型网站使用最广负载均衡转发方式,在 Linux 平台可以使用的负载均衡服务器为 LVS(Linux Virtual Server)。
+网络层和链路层的方式不解析应用层协议,属于四层负载均衡。链路层是目前大型网站使用最广的负载均衡转发方式,在 Linux 平台可以使用的负载均衡服务器为 LVS(Linux Virtual Server),通常用 LVS 做四层负载均衡,再用 Nginx 做七层负载均衡。
参考:
@@ -179,7 +186,8 @@ HTTP 重定向负载均衡服务器使用某种负载均衡算法计算得到服
缺点:
-- 当服务器宕机时,将丢失该服务器上的所有 Session。
+- 当服务器宕机时,将丢失该服务器上的所有 Session;
+- 服务器不再是无状态的,不利于伸缩。
@@ -190,13 +198,13 @@ HTTP 重定向负载均衡服务器使用某种负载均衡算法计算得到服
缺点:
- 占用过多内存;
-- 同步过程占用网络带宽以及服务器处理器时间。
+- 同步过程占用网络带宽以及服务器处理器时间,因此只适合规模很小的集群。
### Session Server
-使用一个单独的服务器存储 Session 数据,可以使用传统的 MySQL,也使用 Redis 或者 Memcached 这种内存型数据库。
+使用一个单独的服务器存储 Session 数据,可以使用传统的 MySQL,也可以使用 Redis 或者 Memcached 这种内存型数据库。
优点:
@@ -204,10 +212,21 @@ HTTP 重定向负载均衡服务器使用某种负载均衡算法计算得到服
缺点:
-- 需要去实现存取 Session 的代码。
+- 需要去实现存取 Session 的代码(Spring Session 等框架已经封装);
+- Session Server 本身需要保证高可用。
+
+这是目前最常用的方式。
+### 客户端保存
+
+把用户信息签名后放在 Cookie 或者 JWT 中,由客户端保存,服务端只需要验证签名。
+
+缺点:
+
+- 无法主动让某个会话失效。
+
参考:
- [Session Management using Spring Session with JDBC DataStore](https://sivalabs.in/2018/02/session-management-using-spring-session-jdbc-datastore/)