库存明明还有,却超卖了几十单:两个请求同时读到"还剩 1 件",然后都卖了出去——我踩的"更新丢失"的坑
这是一次让运营和财务都炸了锅的超卖事故。我做的一个商品库存系统,逻辑看起来天衣无缝:用户下单时,先查一下这个商品还有没有库存,如果有(库存 > 0),就扣减库存、生成订单。我自测了无数遍,单个用户下单,库存增减都分毫不差。可上线后,赶上一波抢购,出事了:一个只有 100 件库存的爆款商品,系统却生成了 130 多个订单——超卖了 30 多单!那些多出来的订单,根本没有货可发,运营只能一个个去道歉、退款,焦头烂额。
我紧急排查,百思不得其解:我明明在扣库存前,查了库存够不够才扣的呀,怎么会超卖?直到我把"高并发"这个因素考虑进来,模拟了多个请求同时下单的场景,才终于揪出了那个隐藏极深的真凶——这是一个并发场景下极其经典的问题,叫"更新丢失(Lost Update)"。当两个(或更多)请求几乎同时到达时,它们会同时读到"库存还剩 1 件"这个相同的值;然后,它们各自都判断"库存够(1 > 0)",于是都执行了扣减、都生成了订单——可实际上,这 1 件库存,被卖了两次!其中一个请求的扣减,把另一个请求的扣减给"覆盖"了,导致一次扣减"丢失"了。在抢购的高并发下,这种"同时读到、同时卖出"的情况大量发生,就酿成了几十单的超卖。
故障现场:两个请求,卖了同一件库存
我把出问题的扣库存逻辑,简化一下。你能看出那个"查"和"扣"之间的"危险缝隙"吗?
# 扣库存下单(有"更新丢失"问题的版本)
def place_order(product_id):
# 第1步: 查询当前库存
stock = db.query("SELECT stock FROM product WHERE id = ?", product_id)
# 第2步: 判断库存够不够
if stock > 0:
# 第3步: 扣减库存
db.execute("UPDATE product SET stock = ? WHERE id = ?", stock - 1, product_id)
# 第4步: 生成订单
create_order(product_id)
return "下单成功"
else:
return "库存不足"
# 高并发下的"更新丢失"(假设库存只剩 1 件):
# 时刻T1: 请求A 查询库存 → 读到 stock = 1
# 时刻T2: 请求B 查询库存 → 也读到 stock = 1 (A还没来得及扣!)
# 时刻T3: 请求A 判断 1 > 0, 扣减 → UPDATE stock = 0
# 时刻T4: 请求B 判断 1 > 0, 扣减 → UPDATE stock = 0 (它用的还是它读到的1!)
# → A 和 B 都成功下单了! 但库存只有 1 件 → 超卖 1 件!
# → B 的扣减, 基于一个"过期的"库存值(1), 覆盖了 A 的扣减结果 → "更新丢失"!
看清这个时序,我才明白超卖是怎么发生的。问题的核心,藏在"查询库存"(第1步)和"扣减库存"(第3步)这两个动作之间的那条"缝隙"里。在单个用户、串行执行时,这两步紧挨着,中间没人插手,毫无问题。可在高并发下,多个请求是交错执行的——请求 A 刚"查"到库存是 1、还没来得及"扣",请求 B 就也"查"到了库存是 1(因为 A 还没扣、库存确实还是 1);然后 A 和 B,各自拿着自己读到的那个"1",都判断"库存够",于是都执行了扣减、都生成了订单。问题就在于:请求 B 的扣减(UPDATE stock = 0),用的是它在第1步读到的那个已经过期的库存值(1)——它根本不知道,在它"查"和"扣"之间,请求 A 已经把库存扣成 0 了。于是,B 的这次扣减,就基于一个错误的、过期的认知,"覆盖"掉了 A 的扣减结果——A 的那次扣减,等于"丢失"了。这就是"更新丢失":一个事务的更新,被另一个基于'过期数据'的事务覆盖掉了。最终的结果,是 1 件库存,被 A 和 B 卖了两次,超卖了。在抢购那种"成百上千个请求同时砸进来"的高并发下,这种"同时读到旧库存、各自扣减"的情况会大量、密集地发生,几十单的超卖,就这么累积出来了。
第一件事:搞懂"更新丢失"的本质——读写之间的竞态
定位到根源,我必须把"更新丢失"这个并发问题的本质,彻底想透:"更新丢失",本质是一种"竞态条件(race condition)"。它发生在"读取-修改-写入(read-modify-write)"这种操作模式上:多个并发的执行者,先读取一个共享的值,基于这个值做计算/判断,再把结果写回。如果在'读'和'写'之间,这个共享的值被别人改了,而你却还在用你那个'读到的旧值'去算、去写,你的写入,就会基于过期数据,覆盖掉别人的修改——这就是更新丢失。
"更新丢失"的本质: read-modify-write(读-改-写)模式下的竞态。
危险的模式: "先读, 再基于读到的值改, 再写回"
读: stock = SELECT stock (读到 1)
改: if stock > 0: new = stock - 1 (算出 0)
写: UPDATE stock = new (写回 0)
→ "读"和"写"之间有"时间缝隙", 在这缝隙里, 别人可能改了 stock!
→ 你却还用你"读到的旧值"去写, 就覆盖了别人的修改。
类比: 两个人同时编辑一个文档(没锁)
- 你打开文档(读), 看到余额 100
- 同事也打开(读), 也看到 100
- 你改成 100-30=70, 保存(写)
- 同事改成 100-50=50, 保存(写) ← 覆盖了你! 你的-30丢了!
- 实际应该是 100-30-50=20, 结果却是 50 → 更新丢失!
这一类问题, 在任何"并发 + 共享数据 + 读改写"的场景都会出现:
- 库存扣减(本文)、余额变动、计数器、点赞数、积分 ...
→ 凡是"先查再改"的并发操作, 都要警惕"更新丢失"!
原理终于清晰了。"更新丢失"的本质,是一种发生在"读取-修改-写入(read-modify-write)"模式上的"竞态条件"。这个危险的模式是:你先读取一个共享的值,然后基于这个值做计算或判断,最后把结果写回。问题就出在"读"和"写"之间,存在一条"时间缝隙"——在这条缝隙里,这个共享的值,完全可能被另一个并发的执行者改掉了;可你,却毫不知情,还在用你最初"读到的那个旧值"去做计算、去写回——于是,你的写入,就基于过期的数据,覆盖掉了别人在这条缝隙里所做的修改,导致别人的那次更新,凭空"丢失"了。我用一个生动的类比理解了它:两个人同时编辑一个没加锁的文档,都看到余额 100,你改成 70 保存、同事改成 50 保存——同事的保存覆盖了你的,你那笔"减 30"就丢了,最终结果是 50,而正确应该是 100-30-50=20。而最关键的认知是:这一类"更新丢失"的问题,绝不只发生在库存扣减上——它会在任何"并发 + 共享数据 + 先读再改"的场景里出现:余额变动、计数器、点赞数、积分累加……凡是"先查询、再基于查询结果修改"的并发操作,都潜藏着"更新丢失"的风险。我那个库存超卖,只是这一大类并发问题的一个具体样本。
第二件事:正解——用"原子更新"或"乐观锁",消除读写缝隙
搞懂了根因——"读和写之间的缝隙,让别人的修改被覆盖"——正解就清晰了:要么,把"读-改-写"合并成一个原子的操作,让它不可分割、中间没有缝隙;要么,在写回时检查"我读到的值,有没有被别人改过",如果改过就不写(乐观锁)。核心目标,是消除那条危险的"读写缝隙"。
-- 正解1: 原子更新 —— 把"判断"和"扣减"合并进一条 UPDATE (最简单可靠!)
UPDATE product SET stock = stock - 1
WHERE id = ? AND stock > 0; -- ← 关键: 把"库存>0"的判断, 放进 UPDATE 的 WHERE 里!
-- 这一条 SQL, 是【原子】的: 数据库会保证它"判断+扣减"一气呵成, 中间没缝隙。
-- stock = stock - 1: 是在数据库里"基于当前最新值"减1, 而非"读到的旧值-1"。
-- WHERE stock > 0: 只有库存真的>0, 才会扣 ——
-- 然后看"影响行数": 影响1行=扣成功; 影响0行=库存不足, 没扣(没超卖!)
affected = db.execute(...)
if affected == 1:
create_order(...) # 扣成功才下单
else:
return "库存不足" # 没扣到, 说明库存已被别人抢光, 不超卖!
-- 正解2: 乐观锁(版本号) —— 写时检查"值有没有被改过"
-- 给表加一个 version 字段。读的时候带上 version, 写的时候校验 version。
SELECT stock, version FROM product WHERE id = ?; -- 读到 stock=1, version=5
-- 扣减时, 要求 version 还是 5(说明期间没人改过):
UPDATE product SET stock = stock - 1, version = version + 1
WHERE id = ? AND version = 5; -- ← 只有 version 还是5(没被改), 才扣!
-- 如果别人先改了, version 已变成 6, 这条 WHERE version=5 就匹配不到 → 影响0行
-- → 扣失败, 说明发生了并发冲突, 可以重试或返回失败。
-- (适合"读到的数据, 要在应用层做复杂处理后再写回"的场景)
-- 正解3: 悲观锁(SELECT ... FOR UPDATE) —— 读的时候就"锁住"这行
-- BEGIN;
-- SELECT stock FROM product WHERE id = ? FOR UPDATE; -- 锁住这行, 别人改不了
-- 判断、扣减 ...
-- COMMIT; -- 释放锁
-- 别的请求想动这行, 必须等你的锁释放 → 强制串行, 不会有竞态。
-- 缺点: 加锁有性能开销, 并发高时会有等待。
这几个正解,都从根本上消除了那条危险的"读写缝隙",只是思路不同。正解1(原子更新)是最简单、最推荐的:把"判断库存够不够"和"扣减库存"这两步,合并进一条 UPDATE 语句——UPDATE ... SET stock = stock - 1 WHERE stock > 0。这条 SQL 是原子的(数据库保证它"判断 + 扣减"一气呵成、中间没缝隙),而且 stock = stock - 1 是在数据库里基于"当前最新值"减 1(而非应用层"读到的旧值"减 1),WHERE stock > 0 又保证了只有库存真的大于 0 才扣;然后看这条 SQL 的"影响行数"——影响 1 行就是扣成功、可以下单,影响 0 行就是库存已被抢光、没扣到(绝不超卖)。正解2(乐观锁):给表加个 version 字段,读的时候记下 version,写的时候校验 WHERE version = 读到的值——如果期间别人改过、version 变了,这条 UPDATE 就匹配不到、影响 0 行,说明发生了冲突,可以重试;它适合"读到数据后要在应用层做复杂处理再写回"的场景。正解3(悲观锁):用 SELECT ... FOR UPDATE,在读的时候就把这行锁住,别人想改必须等你的锁释放,强制串行、杜绝竞态,代价是加锁的性能开销。这几个正解,库存扣减这种简单场景首选'原子更新'(最简单可靠);需要应用层复杂处理的用'乐观锁';并发不高、一致性要求极强的用'悲观锁'。而它们共同的精髓,是'消除读写之间的缝隙'——要么把读改写合并成原子操作(没有缝隙),要么在写时校验值没被改过(发现缝隙里的修改),要么干脆锁住(不让别人钻进缝隙)。
下面这张图,对比了"读改写有缝隙"和"原子更新无缝隙"两条路径:
这张图的对比很清楚:左边红色那条,"先读、应用层判断、再写"的模式,读写之间有缝隙,两个请求都读到旧库存、都扣减,基于过期值覆盖、更新丢失、超卖;右边绿色那条,用一条 UPDATE ... WHERE stock > 0 把判断和扣减原子地合并,数据库保证无缝隙、只有一个能扣成功,看影响行数判断、不超卖。两条路的根本分野,在于你有没有消除那条"读写缝隙"。
第三件事:并发数据问题的"全家桶"——不只更新丢失
填平了更新丢失这个坑,我系统地梳理了一遍"并发访问共享数据"会遇到的各种经典问题,发现更新丢失只是其中一员:
并发数据问题"全家桶"(都源于"多个执行者同时访问共享数据"):
# 1. 更新丢失(Lost Update): 一个更新被另一个基于旧值的更新覆盖(本文)
# 解法: 原子更新 / 乐观锁 / 悲观锁
# 2. 脏读(Dirty Read): 读到了别的事务"还没提交"的数据(可能回滚)
# 解法: 提高隔离级别到 READ COMMITTED 以上
# 3. 不可重复读(Non-repeatable Read): 同一事务内两次读同一行, 值不一样了
# (因为期间别的事务改了它并提交了)
# 解法: REPEATABLE READ 隔离级别
# 4. 幻读(Phantom Read): 同一事务内两次查询, 行数不一样了
# (因为期间别的事务插入/删除了符合条件的行)
# 解法: SERIALIZABLE, 或间隙锁
# 这些问题, 数据库用"事务隔离级别"来权衡解决:
# READ UNCOMMITTED < READ COMMITTED < REPEATABLE READ < SERIALIZABLE
# 隔离级别越高 → 越安全(问题越少), 但并发性能越低(锁越多)。
# 关键: 隔离级别不是"越高越好", 而是"够用就好"——
# 在"数据正确性"和"并发性能"之间, 根据业务做权衡。
# (而"更新丢失"这种, 即使高隔离级别, 在"读改写"模式下也要靠原子/锁来防)
这一梳理,让我对"并发数据"问题有了全局的认识。更新丢失,只是"多个执行者同时访问共享数据"这一大类问题里的一员;它还有几个著名的"兄弟":脏读(读到别的事务还没提交、可能回滚的数据)、不可重复读(同一事务内两次读同一行,值变了)、幻读(同一事务内两次查询,行数变了)。而数据库,主要是用"事务隔离级别"这个机制,来权衡和解决这些问题的:从低到高,READ UNCOMMITTED < READ COMMITTED < REPEATABLE READ < SERIALIZABLE——隔离级别越高,能防住的并发问题越多、越安全,但因为要加更多的锁、并发性能就越低。这里有一个重要的认知:隔离级别不是"越高越好",而是"够用就好"——你要在"数据正确性"和"并发性能"之间,根据你的业务,做一个审慎的权衡。而值得特别注意的是:"更新丢失"这种问题,比较特殊——即使你用了较高的隔离级别,在"先读后写"的应用层模式下,它依然可能发生(因为读和写是两条独立的语句、中间有缝隙),所以,它通常需要靠'原子更新''乐观锁''悲观锁'这些手段来专门防范,而非仅仅依赖隔离级别。理解了这一整套'并发数据问题 + 隔离级别 + 锁'的知识体系,你才能在面对各种并发场景时,选对正确的'武器'来保证数据的正确。
第四件事:乐观锁 vs 悲观锁,到底该选哪个?
知道了乐观锁和悲观锁两种方案,我接着纠结:实战中到底该用哪个?我把它们的思路、优缺点、适用场景,认真对比了一番:
乐观锁 vs 悲观锁: 两种应对并发冲突的哲学
# 悲观锁(Pessimistic): "悲观地"假设"冲突一定会发生", 所以先锁住再操作
# 做法: SELECT ... FOR UPDATE, 读的时候就加锁, 别人必须等
# 思路: "我先把门锁上, 你们都别动, 等我弄完再说"
# 优点: 强一致, 简单直接, 不会有冲突(因为串行了)
# 缺点: 加锁有开销, 并发高时大家排队等, 吞吐量低; 锁用不好会死锁
# 适合: 冲突【频繁】、且操作复杂的场景(冲突多, 与其重试不如直接锁)
# 乐观锁(Optimistic): "乐观地"假设"冲突很少发生", 所以先不锁, 提交时才检查
# 做法: 用 version/时间戳, 提交时校验"有没有被改过", 冲突了就重试
# 思路: "我先不锁, 大胆地改; 提交时发现被人抢先了, 我再重试一次"
# 优点: 不加锁, 并发性能高, 没冲突时效率很高
# 缺点: 冲突多时, 会反复重试, 反而更慢; 要写重试逻辑
# 适合: 冲突【较少】的场景(读多写少), 大部分时候一次就成功
# 怎么选? 看"冲突的概率":
# - 冲突频繁(如秒杀同一个商品) → 悲观锁 / 或专门的方案(如 Redis 预扣)
# - 冲突较少(如改个人资料) → 乐观锁
# - 简单的数值增减(库存/计数) → 原子更新(本质是数据库帮你做了锁)
类比理解:
悲观锁 = 上厕所先锁门(假设有人会来抢)
乐观锁 = 不锁门, 但出来时检查有没有人进来过(假设没人来)
这一对比,让我对"乐观锁 vs 悲观锁"的选择,有了清晰的判断依据。它们是应对并发冲突的两种哲学,核心区别在于"对冲突发生概率的假设"不同:悲观锁"悲观地"假设"冲突一定会发生",所以先锁住、再操作(SELECT ... FOR UPDATE)——它的思路是"我先把门锁上,你们都别动";优点是强一致、简单直接(串行了就不会冲突),缺点是加锁有开销、并发高时大家排队等、吞吐量低,适合"冲突频繁、操作复杂"的场景。乐观锁则"乐观地"假设"冲突很少发生",所以先不锁、大胆地改,提交时才检查(用 version 校验)——它的思路是"我先不锁、大胆改,提交时发现被抢先了再重试";优点是不加锁、并发性能高,缺点是冲突多时会反复重试反而更慢、且要写重试逻辑,适合"冲突较少(读多写少)"的场景。所以,选择的关键,是看"冲突发生的概率":冲突频繁(如抢同一个秒杀商品)用悲观锁或专门方案;冲突较少(如改个人资料)用乐观锁;而简单的数值增减(库存、计数器),则首选'原子更新'(本质是让数据库在那一行上,帮你做了最高效的锁)。一个生动的类比是:悲观锁像'上厕所先锁门'(假设有人会来抢),乐观锁像'不锁门、但出来时检查有没有人进来过'(假设没人来)。理解了这层'对冲突概率的不同假设',你就能为每个具体场景,选对那把最合适的'锁'。把乐观锁和悲观锁的对比整理成一张表:
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 假设 | 冲突一定会发生 | 冲突很少发生 |
| 做法 | 先锁住再操作 | 不锁, 提交时校验版本 |
| 性能 | 低(排队等锁) | 高(无冲突时) |
| 适合 | 冲突频繁 | 冲突较少(读多写少) |
| 代价 | 锁开销/可能死锁 | 冲突多时反复重试 |
第五件事:并发安全,要从"单线程思维"升级到"并发思维"
这次踩坑,在思维层面给了我最大的触动——它逼着我从"单线程思维"升级到了"并发思维"。我把这次思维升级的核心,沉淀了下来:
从"单线程思维"到"并发思维"的升级:
# 单线程思维(我犯错时的思维):
# - 假设代码是"一步一步、顺序"执行的, 中间没人插手
# - "我先查、再判断、再扣", 逻辑很顺, 看起来没问题
# - 自测(单个用户)也确实没问题
# → 这种思维, 在单线程下成立, 但在并发下, 是【危险的幻觉】!
# 并发思维(正确的思维):
# - 假设"在你的任何两步操作之间, 都可能有别的执行者插进来"
# - 时刻问: "如果在这一步和下一步之间, 别人改了共享数据, 会怎样?"
# - 对每一个"读改写"的地方, 都警惕"竞态"
# → 这种思维, 才能写出在并发下也正确的代码。
# 并发思维的几个核心问题:
# 1. 这段代码会被并发执行吗? (多请求/多线程/多进程)
# 2. 它有没有访问"共享的数据"? (数据库/全局变量/缓存)
# 3. 是不是"读改写"模式? 读和写之间有缝隙吗?
# 4. 如果缝隙里别人改了数据, 我的逻辑还对吗?
# 5. 怎么消除这个缝隙? (原子操作/锁/CAS)
核心: 并发, 是把"顺序执行"的确定性, 打破了;
并发思维, 就是要时刻假设"任何时刻都可能有别人在并行地动同一份数据",
并为这种"交错执行"的可能, 设计好正确的同步与防护。
这次思维上的升级,是这次踩坑给我最深远的收获。我犯错时的思维,是一种"单线程思维"——我下意识地假设,我的代码是"一步一步、顺序地"执行的,中间不会有人插手:"我先查库存、再判断够不够、再扣减",逻辑非常顺、看起来天衣无缝,而我自测(单个用户)也确实没问题。可这种"顺序执行"的假设,在单线程下成立,在并发下,就是一个危险的幻觉!而正确的,是"并发思维"——它假设:"在你的任何两步操作之间,都可能有别的执行者,'插进来'并行地动同一份共享数据。"带着这种思维,你会时刻警惕地问自己:"如果在这一步和下一步之间,别人改了共享数据,我的逻辑还对吗?"这套"并发思维",有几个核心的自问:这段代码会被并发执行吗?它有没有访问共享数据?是不是"读改写"模式、读和写之间有缝隙吗?如果缝隙里别人改了数据,我的逻辑还对吗?怎么消除这个缝隙?我那个超卖 bug,正是因为我用"单线程思维"写了一个会"并发执行"的逻辑——我假设了"查"和"扣"之间没人插手,可并发恰恰打破了这个假设。并发,本质上是打破了"顺序执行"的那份确定性;而并发思维,就是要时刻假设"任何时刻,都可能有别人在并行地动同一份数据",并为这种"交错执行"的可能,设计好正确的同步与防护。从'假设一切顺序执行'的单线程思维,升级到'假设随时有人并行插手'的并发思维,是写出在高并发下也正确的代码的、最根本的一次认知跃迁。把"单线程思维"和"并发思维"对比成一张表:
| 维度 | 单线程思维(危险) | 并发思维(正确) |
|---|---|---|
| 执行假设 | 顺序执行, 中间没人插手 | 任何两步间都可能有人插入 |
| 对共享数据 | 默认我读到的就是最新的 | 警惕读到的随时可能过期 |
| 读改写 | 觉得很顺, 没问题 | 警惕读写缝隙里的竞态 |
| 测试 | 单用户自测就放心 | 必须模拟并发压测 |
| 结果 | 并发下出诡异 bug | 并发下依然正确 |
一张"并发更新该怎么防丢失"的决策图
把这次踩坑沉淀成一张图。每当你要在并发下更新共享数据时,照着它选对方案:
这张图的核心:简单数值增减首选原子更新(SET x=x-1 WHERE x>0);要复杂处理时,冲突少用乐观锁(version 校验)、冲突频繁用悲观锁(FOR UPDATE)。把"任何并发的读改写,都用原子操作或锁来消除竞态"变成本能,那个"更新丢失、超卖"的坑就再也碰不到你。
我立下的几条并发更新规矩
这次"更新丢失导致超卖"的事故后,我给自己立了几条规矩:
- 并发数值增减用原子更新:库存、计数器等并发增减,用
UPDATE x=x-1 WHERE x>0,看影响行数判断,绝不"先查再改"。 - 读改写必防竞态:任何"先读、再基于读到的值改、再写"的并发操作,都要用原子操作/乐观锁/悲观锁消除缝隙。
- 按冲突概率选锁:冲突少用乐观锁(version),冲突频繁用悲观锁(FOR UPDATE)。
- 用并发思维审视代码:写涉及共享数据的代码,时刻问"两步之间别人改了数据会怎样"。
- 懂隔离级别但别只靠它:理解事务隔离级别,但知道"更新丢失"在读改写模式下还要靠原子/锁专门防。
- 必须并发压测:涉及并发的逻辑,绝不只单用户自测,必须模拟高并发压测,验证数据正确。
- 关键数据加约束兜底:库存等关键字段加 CHECK/无符号约束,作为绝不超卖的最后一道防线。
这几条里,第一条"并发数值增减用原子更新"是直接根治这次 bug 的核心。而贯穿所有规矩的那条主线,是对"看不见的并发"的警觉。我这次栽这么大跟头,根子上是我对"我的代码会被并发地执行"这件事,毫无警觉——我写代码、自测时,脑子里想的都是"一个用户、顺序地走完整个流程",完全没有"成百上千个用户,同时、交错地执行这段代码"的画面感。而'并发',恰恰是一种'看不见'的东西——它不在你的某一行代码里,而在'多个执行流交错执行'的、那个你看不见的、动态的运行时画面里。一段代码,单看它的文本,你看不出任何'并发问题';只有当你脑中浮现出'多个它,同时在跑、交错地访问同一份数据'的画面时,那些隐藏的竞态、那些危险的读写缝隙,才会显现出来。培养起对这种'看不见的并发'的画面感和警觉,是写出并发安全代码的根本——而这,需要你主动地、刻意地,在写每一段会被并发执行的代码时,都在脑中'放映'一遍那个'多个执行流交错'的动态画面。
写在最后:看见那个"看不见的并发世界"
这次被"更新丢失"教育的经历,给我一个挺深刻的启示:我们写的代码,是静态的、顺序的文本;可它运行起来,尤其是在高并发的环境里,却是一个动态的、多个执行流交错纵横的'活的世界'。而很多最隐蔽、最难缠的 bug,恰恰不藏在那个'静态的文本'里,而藏在那个'动态的、并发的运行时世界'里——你盯着代码文本看一万遍,也看不出问题,因为问题不在文本里,而在'多个它同时运行、交错访问数据'的那个动态过程里。我那个超卖 bug,正是如此:那段扣库存的代码,文本上挑不出任何毛病,逻辑顺畅、自测通过;它的问题,只存在于"成百上千个请求,同时、交错地执行它"的那个我'看不见'的并发世界里。我之所以踩坑,正是因为我只盯着静态的代码文本,而没能'看见'那个动态的并发世界。
想通这一点,我对编程里"动态思维"的重要性,有了更深的体会。写代码,我们面对的是静态的文本;可代码真正的'生命',在于它运行起来的那个动态过程。而一个成熟的程序员,必须具备一种'透过静态的代码文本,想象出它动态运行画面'的能力——尤其是在并发、异步、分布式这些'动态性'极强的领域。他不能只满足于'代码文本看起来对',而要在脑中'放映'出代码运行的动态过程:这段代码被并发执行时,是什么画面?这些异步操作的执行顺序,是怎样交错的?这个分布式调用,在网络抖动、节点宕机时,会经历怎样的动态时序?只有'看见'了这个动态的运行时世界,你才能预见到那些只在'动态过程'中才会暴露的问题(竞态、死锁、时序错乱),并提前防范。'从静态的代码文本,看见动态的运行时世界'——这种动态的、过程性的想象力,是从'会写代码'走向'能驾驭复杂系统'的一项关键能力。
所以,如果你也想写出在并发、在复杂动态环境下也可靠的代码,我想把这次踩坑最想说的话送给你:别只盯着那个静态的、顺序的代码文本,要努力去'看见'它运行起来的、那个动态的、并发交错的世界。写一段会被并发执行的代码时,在脑中放映一遍'多个它同时跑、交错访问数据'的画面;写异步代码时,想象那些操作执行顺序的各种可能的交错;写分布式逻辑时,推演各种网络异常、节点故障下的动态时序。因为代码的 bug,尤其是那些最隐蔽、最致命的 bug,常常不藏在你能一眼看到的静态文本里,而藏在那个你必须用想象力才能'看见'的、动态的运行时世界里;而能不能'看见'这个动态世界、能不能在脑中预演它的种种交错与异常,正是区分'能写出在理想顺序下工作的代码'和'能写出在真实动态世界里也可靠的代码'的、那道关键的分水岭。那 30 多个超卖的订单,最终教给我的,正是这份'看见动态并发世界'的能力——它让我懂得,写代码不能只看它'静静躺在那里'的样子,更要看见它'活起来、奔跑起来、与无数个自己交错碰撞'的样子;唯有'看见'了那个看不见的并发世界,你才能真正地,为它的种种动态可能,写下正确而周全的应对。
—— 别看了 · 2026