模块 10第 3 学期 · 可靠性与扩展12 海报条目
分布式系统的正确性
登录后可在不同设备间同步进度。在此之前,进度只保存在当前浏览器中。
为什么重要
并发 bug 不会在演示时出现。它们出现的方式是:客户被扣了两次款,一张优惠券被兑换了三次,同一个座位卖给了两个人,或者月底余额对不上账。在金融科技领域,这些不只是 bug,而是监管事件。
学习目标
- 识别竞态条件,并用原子操作、约束和锁来修复。
- 充分理解复制、CAP 和最终一致性,能够围绕它们设计用户体验。
- 知道什么时候需要分布式锁和 leader 选举,以及它们的陷阱。
- 避免死锁。
- 在存在时钟偏差的情况下正确处理时间。
- 诊断内存泄漏和垃圾回收停顿。
课程内容
10.1 竞态条件(39, 43)
经典形态是先检查后执行(check-then-act):读一个值,做判断,再写入。在读和写之间,别人已经动了手。 修复方法,按优先顺序:
- 原子语句:
UPDATE accounts SET balance = balance - 100 WHERE id = $1 AND balance >= 100,并检查受影响的行数。 - 数据库约束: 唯一索引让第二次插入直接失败。
- 乐观锁(版本号列),适合低竞争场景。
- 悲观锁(
SELECT ... FOR UPDATE),适合高竞争场景。 在应用代码中:长期运行的 Node 进程里的共享可变状态会被所有请求共享;模块级变量并不是按用户隔离的。线程安全(43) 在 Go、Java、Python async 和 worker 中同样重要。
10.2 死锁(40)
两个事务各自持有对方需要的锁。预防办法:始终按一致的顺序加锁(例如按 ID 升序),保持事务简短,并设置锁超时。Postgres 会检测到死锁并中止其中一个事务;你的代码必须重试。
10.3 复制、CAP 与最终一致性(32, 34, 35, 95)
- 复制(32): 在多个节点上保存副本,以保证持久性并扩展读能力。异步副本会有延迟。
- 网络分区(95) 一定会发生。分区期间,CAP 指出系统必须做出选择:拒绝部分请求(一致性),或者用可能过期的数据响应(可用性)。
- 最终一致性(35): 副本会随时间收敛。对设计的影响:保证读到自己的写入(用户刚写完时把他的读请求路由到主库),展示“处理中”状态,绝不根据有延迟的副本做涉及资金的决定。
- 账本和余额:强一致、单一事实来源、只追加的复式记账记录。
10.4 分布式锁与 leader 选举(33, 38)
- 当多个实例不能同时做同一件事时就需要它(只允许一个定时任务执行者、一个分区只有一个消费者)。
- 优先使用数据库原生方案:Postgres advisory lock、基于
SKIP LOCKED的任务队列。 - Redis 锁需要过期时间(TTL)和 fencing token:一个被暂停的进程可能在锁过期后醒来,仍然去写数据。存储层必须拒绝过期的 token。
- Leader 选举(Raft、etcd、ZooKeeper、Kubernetes leases)通常是直接使用基础设施提供的能力,而不是自己造。
10.5 时间与时钟偏差(96)
- 机器时钟会漂移;绝不要用不同服务器的挂钟时间来给事件排序。
- 用数据库生成的时间戳或序列号来排序;用单调时钟来测量时长。
- Token 过期和 webhook 时间戳校验需要留一点偏差容忍度。
10.6 内存泄漏与垃圾回收(41, 42)
- 托管语言中的泄漏通常是你一直持有的引用:没有上限、不断增长的缓存,从未移除的事件监听器,全局数组,未关闭的流。
- 症状:内存稳步上涨,然后 OOM 重启;长时间 GC 停顿导致延迟飙升。
- 工具:堆快照(Node 用 Chrome DevTools)、
--inspect、按实例的内存指标、有上限的 LRU 缓存。
实验 10:双花挑战
- 做一个钱包:充值、转账、提现。写一个同时发起 200 笔并发转账的测试。看着 AI 写的朴素版本凭空丢钱或多出钱。
- 用原子更新和约束修复它;改成复式记账账本,保证所有分录之和始终为零。
- 用两笔顺序相反的转账制造死锁;通过统一加锁顺序修复。
- 从有延迟的副本读取数据(模拟延迟),复现“读不到自己的写入”的 bug;修复它。
- 用堆快照找出一个事先埋好的内存泄漏。
完成标准(Definition of Done)
AI 提示词包
- “找出这个代码库中所有先检查后执行的代码序列。针对每一处,展示竞态是怎么发生的,并给出原子化的修复。”
- “把这段余额逻辑重写成复式记账账本,并附上不变量测试。”