diff --git a/notes/MySQL.md b/notes/MySQL.md
index d0bff4b5..918891e1 100644
--- a/notes/MySQL.md
+++ b/notes/MySQL.md
@@ -515,7 +515,7 @@ MySQL 8.0.27 起 replica_parallel_workers 默认为 4,从服务器上实际是
MySQL 默认是异步复制,主服务器提交事务后不等从服务器,从服务器的数据会落后一段时间,称为复制延迟。用户刚修改完资料就去从库读取,可能读到修改前的数据。常用的处理方法:
-- 写入后短时间内的读取,或者对实时性要求高的读取,强制走主库;
+- 写入后短时间内的读取,或者对实时性要求高的读取,强制走主库,保证用户能读到自己刚写入的数据(读己之写);
- 使用半同步复制:至少一个从库把 binlog 写入中继日志后,主库才向客户端返回提交成功,减少主库宕机时丢失的数据,但并不保证从库已经执行完这些修改;
- 监控从库的延迟(SHOW REPLICA STATUS 中的 Seconds_Behind_Source),延迟过大的从库暂时不分配读请求。
diff --git a/notes/pics/cache-aside-race.png b/notes/pics/cache-aside-race.png
index ed18b996..3bc9170b 100644
Binary files a/notes/pics/cache-aside-race.png and b/notes/pics/cache-aside-race.png differ
diff --git a/notes/pics/cache-aside-stale.png b/notes/pics/cache-aside-stale.png
new file mode 100644
index 00000000..c36a8428
Binary files /dev/null and b/notes/pics/cache-aside-stale.png differ
diff --git a/notes/pics/fencing-token.png b/notes/pics/fencing-token.png
index cf89866b..8f5e883d 100644
Binary files a/notes/pics/fencing-token.png and b/notes/pics/fencing-token.png differ
diff --git a/notes/分布式.md b/notes/分布式.md
index f03ec0a9..94196ef6 100644
--- a/notes/分布式.md
+++ b/notes/分布式.md
@@ -59,7 +59,7 @@
存在以下几个问题:
- 锁没有失效时间,如果持有锁的进程崩溃导致解锁失败,其它进程就无法再获得该锁;
-- 只能是非阻塞锁,插入失败直接就报错了,无法重试;
+- 只能是非阻塞锁,插入失败直接就报错了,需要等待锁时只能由调用方循环重试;
- 不可重入,已经获得锁的进程也必须重新获取锁。
### Redis 的 SET NX 指令
@@ -96,10 +96,24 @@ Redis 8.4 新增了 `DELEX lock_key IFEQ unique_value` 指令,可以直接原
- 计算获取锁消耗的时间,只有时间小于锁的过期时间,并且从大多数(N / 2 + 1)实例上获取了锁,才认为获取锁成功;
- 如果获取锁失败,就到每个实例上释放锁。
-RedLock 不要求各节点的时钟同步,但假设各节点的时钟走速大致相同,时钟跳变(例如被 NTP 大幅校正)会让锁提前过期;它也无法应对进程暂停(例如 GC)导致锁过期后客户端仍然认为自己持有锁的情况,因此存在争议。更可靠的做法是让锁服务每次加锁时返回一个单调递增的栅栏令牌(Fencing Token),客户端访问资源时带上令牌,资源拒绝令牌比已处理过的更小的请求。
+RedLock 不要求各节点的时钟同步,但假设各节点的时钟走速大致相同,时钟跳变(例如被 NTP 大幅校正)会让锁提前过期,这是它存在争议的原因之一。
+
+更根本的问题在于,所有带过期时间的锁都可能同时被两个客户端持有:持有锁的客户端发生长时间的进程暂停(例如 GC),或者它发出的请求在网络中延迟了很久,锁过期后另一个客户端已经拿到了锁,前一个客户端的写入却仍然会到达资源。
+
+解决方法是让锁服务每次加锁时返回一个单调递增的栅栏令牌(Fencing Token),客户端访问资源时带上令牌,资源拒绝令牌比已处理过的更小的请求。
+例如订单服务 A 拿到令牌 7 后发生了 12 秒的 Full GC,锁在 10 秒后过期,订单服务 B 拿到令牌 8 并完成写入;A 恢复后带着令牌 7 写入,就会被拒绝。
+
+资源是 MySQL 中的一行时,可以把处理过的最大令牌保存在这一行的 fence 列中,用条件更新完成检查。B 执行下面的语句后 fence 变为 8,A 带着令牌 7 执行同样的语句(条件为 fence <= 7)时影响 0 行:
+
+```sql
+UPDATE inventory SET stock = 97, fence = 8 WHERE sku_id = 10086 AND fence <= 8;
+```
+
+RedLock 使用的随机值不是递增的,算法本身无法提供栅栏令牌;ZooKeeper 则可以使用加锁时创建的有序节点的序号或者事务 ID(zxid),它们都是单调递增的。
+
### Zookeeper 的有序节点
#### 1. Zookeeper 抽象模型
@@ -116,7 +130,7 @@ Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节
#### 3. 监听器
-为一个节点注册监听器,在节点状态发生改变时,会给客户端发送消息。
+为一个节点注册监听器,在节点状态发生改变时,会给客户端发送消息。监听是一次性的,触发之后需要重新注册(3.6 起也支持触发后不删除的持久监听)。
#### 4. 分布式锁实现
@@ -129,7 +143,7 @@ Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节
如果一个已经获得锁的会话超时了,因为创建的是临时节点,所以该会话对应的临时节点会被删除,其它会话就可以获得锁了。可以看到,这种实现方式不会出现数据库的唯一索引实现方式释放锁失败的问题。
-但是如果客户端因为 GC 等原因长时间没有发送心跳,也会导致会话超时,因此同样可能出现两个客户端都认为自己持有锁的情况。
+但是如果客户端因为 GC 等原因长时间没有发送心跳,也会导致会话超时,因此同样可能出现两个客户端都认为自己持有锁的情况,需要配合上文的栅栏令牌。
#### 6. 羊群效应
@@ -139,7 +153,7 @@ Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节
- 数据库:实现简单,但是性能低;
- Redis:性能最高,适合高并发场景,但是在主从切换、进程暂停等极端情况下可能出现两个客户端同时持有锁;
-- Zookeeper:写操作需要同步到大多数节点,性能较低,但是可靠性最高,适合一致性要求高、并发量不大的场景。
+- Zookeeper:写操作需要同步到大多数节点,性能较低,但是可靠性更高,还能提供栅栏令牌,适合一致性要求高、并发量不大的场景。
实际使用中一般直接采用 Redisson、Curator 等成熟的客户端实现。
@@ -162,7 +176,7 @@ Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节
##### 1.1 准备阶段
-协调者询问参与者事务是否执行成功,参与者发回事务执行结果。询问可以看成一种投票,需要参与者都同意才能执行。
+协调者询问每个参与者能否提交。参与者先执行事务,确认在任何情况下都能提交(数据已经写入磁盘、约束检查已经通过)才回复同意,否则回复中止。询问可以看成一种投票,需要参与者都同意才能提交。
@@ -194,7 +208,7 @@ Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节
任意一个节点失败就会导致整个事务失败,没有完善的容错机制。
-三阶段提交(3PC)在 2PC 的基础上增加了一个询问阶段(CanCommit),并且在参与者端也引入了超时机制,参与者在等待提交通知超时后会默认提交,从而减少了阻塞。但是它假设网络延迟有上界,在现实系统中仍然可能出现数据不一致。实践中更好的办法是用共识算法代替单点的协调者。
+三阶段提交(3PC)在 2PC 的基础上增加了一个询问阶段(CanCommit),并且在参与者端也引入了超时机制:参与者收到预提交(PreCommit)之后,如果等待最终通知超时,就默认提交,从而减少了阻塞。但是它假设网络延迟和节点的响应时间都有上界,而现实系统中存在无界的网络延迟和进程暂停,超时并不能说明对方已经失效,因此仍然可能出现数据不一致。实践中更常见的做法是用共识算法把协调者的决定复制到多个节点上,避免单点。
### TCC
@@ -247,7 +261,11 @@ RocketMQ 的事务消息把本地消息表的工作交给了消息队列:生
### 一致性
-CAP 中的一致性指的是强一致性(线性一致性):对系统的一个数据更新成功之后,所有用户都能够读取到最新的值,就像系统中只有一个数据副本一样。
+CAP 中的一致性指的是线性一致性(Linearizability),也称为强一致性:系统看起来就像只有一个数据副本,每个操作都在某个时刻原子地生效。
+
+一个数据更新成功之后,所有用户都能读到新值;并且只要有一个读请求读到了新值,之后的读请求就都不能再读到旧值,即使这时写操作还没有返回。
+
+例如订单 1024 的状态从“待支付”改为“已支付”,写入还在多个副本之间同步时,客服 A 查询看到了“已支付”并告诉客服 B,B 随后查询却看到“待支付”。两次查询分别落在已同步和未同步的副本上,这在线性一致的系统中是不允许的。
注意它和 ACID 中的一致性不是同一个概念,ACID 中的一致性指的是事务执行前后,数据都满足完整性约束。
@@ -272,6 +290,12 @@ CAP 中的一致性指的是强一致性(线性一致性):对系统的一
- 为了保证一致性(CP),无法和其它节点同步的节点需要拒绝请求,也就失去了部分可用性,例如 Zookeeper、etcd;
- 为了保证可用性(AP),所有节点都继续提供服务,但是读取到的数据可能不一致,例如 Eureka、按默认一致性级别使用的 Cassandra。
+需要注意的是,ZooKeeper 只保证写入是线性一致的,读请求由客户端连接的节点直接返回,可能读到旧值,需要读到最新值时要先调用 sync();etcd 从 v3 起默认提供线性一致的读。
+
+Nacos 则两种都有:临时实例使用自研的 Distro 协议,优先保证可用性;持久实例使用基于 Raft 的 JRaft,优先保证一致性。
+
+CAP 只讨论了线性一致性这一种一致性模型和网络分区这一种故障,没有涉及网络延迟、节点宕机等更常见的问题。很多系统既不提供线性一致性,也不满足 CAP 中严格定义的可用性,因此既不是 CP 也不是 AP。与其给系统贴上 CP 或 AP 的标签,不如直接说明它在分区时的行为,例如“少数派一侧拒绝写入,读请求可能返回旧值”。
+
PACELC 是对 CAP 的扩展:发生分区(P)时在可用性(A)和一致性(C)之间权衡,没有分区(E)时则在延迟(L)和一致性(C)之间权衡,因为保证强一致性需要等待节点之间同步数据,会增加响应延迟。
## 四、BASE
@@ -294,7 +318,7 @@ BASE 理论是对 CAP 中一致性和可用性权衡的结果,它的核心思
最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能达到一致的状态。“最终”没有时间上限,因此通常还会提供读己之写(用户能读到自己刚写入的数据)和单调读(不会读到比上次更旧的数据)等保证来减少不一致带来的困扰。
-ACID 要求强一致性,通常运用在传统的数据库系统上。而 BASE 要求最终一致性,通过牺牲强一致性来达到可用性,通常运用在大型分布式系统中。
+ACID 要求每个事务结束时数据都满足约束,并且事务之间相互隔离,通常运用在传统的数据库系统上。而 BASE 允许数据在一段时间内处于中间状态,只要求最终一致,通过牺牲强一致性来达到可用性,通常运用在大型分布式系统中。
在实际的分布式场景中,不同业务单元和组件对一致性的要求是不同的,因此 ACID 和 BASE 往往会结合在一起使用。
@@ -404,7 +428,9 @@ Leader 会周期性的发送心跳包给 Follower。每个 Follower 都设置了
如果 Follower 的日志和 Leader 不一致,Leader 会找到两者最后一致的位置,然后用自己的日志覆盖 Follower 在该位置之后的日志。
-发生网络分区时,少数派一侧的旧 Leader 得不到大多数节点的确认,无法提交日志,多数派一侧会选出新的 Leader。分区恢复后,旧 Leader 发现更大的任期号就会退回 Follower,它未提交的日志被覆盖,因此写入不会出现脑裂。读请求则要另外处理:旧 Leader 如果直接用本地数据回答读请求,可能返回旧数据,所以 Raft 要求 Leader 在处理只读请求前,先与大多数节点交换一次心跳,确认自己仍是 Leader。
+发生网络分区时,少数派一侧的旧 Leader 得不到大多数节点的确认,无法提交日志,多数派一侧会选出新的 Leader。分区恢复后,旧 Leader 发现更大的任期号就会退回 Follower,它未提交的日志被覆盖,因此写入不会出现脑裂。
+
+读请求则要另外处理:旧 Leader 如果直接用本地数据回答读请求,可能返回旧数据,所以 Raft 要求 Leader 在处理只读请求前,先与大多数节点交换一次心跳,确认自己仍是 Leader。另外,新 Leader 刚上任时还不知道哪些日志已经提交,要先提交一条当前任期的空日志,之后才能处理只读请求。
## 七、RPC
@@ -548,6 +574,8 @@ public synchronized long nextId() {
用 8 个线程共生成 400 万个 ID,耗时 1.2 秒,全部不重复,每个线程得到的 ID 都是递增的;去掉“序号用完就等到下一毫秒”这一步,400 万个 ID 中只有约 240 万个不重复(JDK 21 实测,见[附录:实测](#附录实测))。
+需要注意的是,同一个实例生成的 ID 是递增的,但不同实例的时钟之间存在偏差,两个实例生成的 ID 只能大致反映生成的先后顺序。
+
实际使用时还要解决两个问题:
- 机器 ID:每个实例的机器 ID 不能相同,可以在启动时从 ZooKeeper、数据库或配置中心领取;
@@ -604,6 +632,9 @@ generated 4000000 unique=2404324 increasingPerThread=false ms=712
- [Designing Data-Intensive Applications 中文版](https://github.com/vonng/ddia)
- [Distributed Locks with Redis](https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/)
- [How to do distributed locking](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html)
+- [ZooKeeper Programmer's Guide](https://zookeeper.apache.org/doc/current/zookeeperProgrammers.html)
+- [etcd: KV API guarantees](https://etcd.io/docs/v3.5/learning/api_guarantees/)
+- [Nacos 的 Distro 一致性协议](https://nacos.io/en/news/article-nacos-distro-mechanism/)
- [Locks and synchronizers - Redisson](https://redisson.pro/docs/data-and-services/locks-and-synchronizers/)
- [深度剖析 Seata TCC 模式](https://seata.apache.org/zh-cn/blog/seata-tcc/)
- [Seata TCC 模式的空回滚、幂等与悬挂(tcc-fence)](https://seata.apache.org/zh-cn/blog/seata-tcc-fence/)
diff --git a/notes/缓存.md b/notes/缓存.md
index 086bb583..4b9204d6 100644
--- a/notes/缓存.md
+++ b/notes/缓存.md
@@ -162,13 +162,25 @@ CDN 主要有以下优点:
-这种模式仍然存在小概率的不一致,例如删除缓存失败。常用的补救措施:
+先更新数据库再删除缓存,仍然有两种情况会留下旧值:
+
+- 删除缓存失败,例如 Redis 超时;
+- 缓存刚好过期时,读请求从数据库读到旧值后停顿了一段时间(例如 GC),在它写回缓存之前,写请求更新了数据库并删除了缓存,之后读请求把旧值写入缓存。
+
+
+
+第二种情况要求缓存恰好失效,并且读请求从查询数据库到写回缓存的耗时比写请求更新数据库再删除缓存还长,因此概率很小。
+
+用 8 个读进程不停读取同一个键,写入 1000 次:先删缓存再更新数据库有 980 次留下了旧值,先更新数据库再删缓存为 0 次。让缓存在每次写入前过期、读请求写回缓存前随机停顿 0~5 毫秒之后,先更新数据库再删缓存也有 542 次留下了旧值,加上延迟双删后为 0 次(MySQL 8.0、Redis 7.4 实测,见[附录 A4](#a4-缓存一致性的三种写法))。
+
+常用的补救措施:
- 为缓存设置过期时间作为兜底;
-- 延迟双删:更新数据库之后,隔一小段时间再删除一次缓存;
-- 订阅数据库的 binlog(例如使用 Canal)来异步删除缓存,失败时重试。
+- 延迟双删:更新数据库并删除缓存之后,隔一段时间再删除一次,清掉慢读请求写回的旧值,间隔要大于读请求从查询数据库到写回缓存的耗时;
+- 订阅数据库的 binlog(例如使用 Canal)异步删除缓存,失败时重试,业务代码中不再需要删除缓存;
+- 写回缓存时检查期间是否有过删除:Facebook 的 Memcache 在缓存未命中时发给读请求一个租约(lease)令牌,写回时要带上令牌,如果这期间键被删除,令牌就会失效,旧值写不进缓存。
-要保证缓存一致性需要付出很大的代价,缓存数据最好是那些对一致性要求不高的数据,允许缓存数据存在一些脏数据。
+缓存和数据库之间只能做到最终一致,要保证强一致需要付出很大的代价。缓存数据最好是那些对一致性要求不高、能接受短时间读到旧值的数据,余额、库存扣减这类数据应该直接读写数据库。
### 缓存 “无底洞” 现象
@@ -468,6 +480,48 @@ def owner(r, k):
一致性哈希,每个节点 150 个位置:4 个节点的占比 A 27.0%、B 26.1%、C 24.1%、D 22.8%;增加节点 E 后迁移 18.4% 的键
```
+### A4 缓存一致性的三种写法
+
+MySQL 8.0.44,Redis 7.4.1,Python 3.13(pymysql、redis-py)。表中只有一行价格,8 个读进程不停读取:缓存未命中时查询数据库并写回缓存。写入方每次把价格加 1,按三种写法之一处理缓存,延迟双删的间隔为 50 毫秒;每次写入后等在途的读请求结束,检查缓存中是否留下了旧值,留下则计数并删除:
+
+```python
+def reader(flag):
+ while not flag.value:
+ if r.get(KEY) is None:
+ c.execute('select price from cache_demo where id = 1')
+ v = c.fetchone()[0]
+ if READ_DELAY:
+ time.sleep(random.uniform(0, READ_DELAY))
+ r.set(KEY, v)
+
+# 写入方,第 i 次写入
+if EXPIRE:
+ r.delete(KEY) # 模拟缓存刚好过期
+if strategy == 'delete-then-update':
+ r.delete(KEY); c.execute('update cache_demo set price = %s where id = 1', (i,))
+else:
+ c.execute('update cache_demo set price = %s where id = 1', (i,)); r.delete(KEY)
+ if strategy == 'update-then-delete + double delete':
+ time.sleep(0.05); r.delete(KEY)
+```
+
+```text
+ROUNDS=1000 READ_DELAY=0ms EXPIRE=0 readers=8 (processes)
+delete-then-update stale 980/1000 (98.0%)
+update-then-delete stale 0/1000 (0.0%)
+update-then-delete + double delete stale 0/1000 (0.0%)
+
+ROUNDS=1000 READ_DELAY=0ms EXPIRE=1 readers=8 (processes)
+delete-then-update stale 983/1000 (98.3%)
+update-then-delete stale 0/1000 (0.0%)
+update-then-delete + double delete stale 0/1000 (0.0%)
+
+ROUNDS=1000 READ_DELAY=5ms EXPIRE=1 readers=8 (processes)
+delete-then-update stale 1000/1000 (100.0%)
+update-then-delete stale 542/1000 (54.2%)
+update-then-delete + double delete stale 0/1000 (0.0%)
+```
+
## 参考资料
- 杨传辉. 大规模分布式存储系统:原理解析与架构实战 [M]. 机械工业出版社, 2013.
@@ -478,5 +532,6 @@ def owner(r, k):
- [How Aspiration CDN helps to improve your website loading speed?](https://www.aspirationhosting.com/aspiration-cdn/)
- [Redis: UNLINK](https://redis.io/docs/latest/commands/unlink/)
- [Redis CLI](https://redis.io/docs/latest/develop/tools/cli/)
+- [Scaling Memcache at Facebook](https://www.usenix.org/system/files/conference/nsdi13/nsdi13-final170_update.pdf)
- [Caffeine Wiki](https://github.com/ben-manes/caffeine/wiki)
- [Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web](https://dl.acm.org/doi/10.1145/258533.258660)