两个用户同时抢付同一笔钱,数据库怎么保证不出乱子?这个问题每年让全球金融系统多烧掉47亿美元运维成本。一位开发者在测试环境用两行代码复现了经典场景,结果比预期更扎心。
Session 1 和 Session 2 的"抢椅子游戏"
开发者开了两个终端窗口,模拟 Alice 和 Bob 共用账户的极端情况。初始数据很干净:Alice 余额 1000,Bob 500。
第一个会话执行 UPDATE accounts SET balance = balance - 800 WHERE name = 'Alice',扣掉 800,但故意不提交(commit)。这时候 Alice 的余额理论上是 200,但数据库把它锁进了"小黑屋"。
第二个会话紧接着查同一条记录,返回的仍是 1000。未提交的修改不可见——这是隔离性的第一道防线,专业术语叫"防止脏读(dirty read)"。
但真正的冲突在下一步。Session 2 尝试执行 UPDATE ... SET balance = balance - 300,整个终端瞬间卡住,光标疯狂闪烁。没有报错,没有超时提示,只有沉默的等待。
「这就像两个人同时伸手去抓最后一块披萨,先碰到盘子的人攥住不放,另一个人只能干瞪眼。」开发者在笔记里写道。
锁机制:数据库的"交通信号灯"
Session 1 持有的锁(lock)阻塞了 Session 2 的写入请求。这是关系型数据库的默认行为——行级锁(row-level lock)在修改时自动获取,提交或回滚后释放。
当 Session 1 终于执行 commit,Session 2 的等待结束。但它拿到的最新余额已经是 200(800 已扣),再减 300 变成 -100。触发了数据库预设的 CHECK 约束,整个事务回滚(rollback)。
结果符合业务预期:账户不能透支。但过程暴露了一个设计张力:默认的读已提交(Read Committed)隔离级别允许"不可重复读",Session 2 在等待期间看到的数值其实已经变了。
开发者随后调高隔离级别到可串行化(Serializable)。同样的并发操作,这次 Session 2 直接报错失败,而不是无限期等待。数据库选择了"快速失败"而非"静默阻塞"。
隔离级别的四档"安全带"
SQL 标准定义了四个隔离级别,每档都是性能与一致性的权衡:
读未提交(Read Uncommitted)最激进,允许脏读,几乎没人用在生产环境。读已提交是多数数据库的默认档,屏蔽脏读但允许不可重复读。可重复读(Repeatable Read)进一步锁定查询范围,MySQL InnoDB 用它实现 MVCC 多版本控制。可串行化最保守,完全模拟单线程执行,代价是并发吞吐量暴跌。
金融核心系统通常卡在第二档或第三档之间。往上调,TPS(每秒事务数)可能腰斩;往下放,对账时会发现"幽灵数据"。
这次测试的 CHECK 约束拦截了负余额,但现实中的漏洞更隐蔽。2018 年某交易所曾因隔离级别配置错误,出现"双花"漏洞——同一笔 USDT 被同时用于两个订单,损失 4800 万美元。
开发者的"反直觉"发现
整个实验最反常识的点:数据库不会自动帮你"合并"并发修改。balance - 800 和 balance - 300 不是简单算术相加,而是基于不同快照的覆盖写。如果 Session 2 没触发约束,它会用 700(1000-300)直接覆盖 Session 1 的 200,导致 800 凭空消失——这就是"丢失更新(lost update)"。
开发者最后总结:「隔离性不是魔法,是锁、版本链、时间戳的精密协作。你以为的'同时',数据库拆成了纳秒级的先后顺序。」
测试环境的 CHECK 约束救场了,但生产环境没有彩排。当你的支付页面转圈超过 3 秒,背后可能就是某个 Session 在等锁——而用户只会刷新,再点一次,制造更多冲突。
热门跟贴