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/缓存.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)