锁不是语法,是一种边界

从公共账本、会议室预约、库存和余额几个例子说起,理解锁到底解决什么问题,什么时候需要考虑锁,乐观锁和悲观锁怎么选,以及分布式场景里为什么不能只靠本机锁。

想象一张放在办公室门口的纸质登记表。

谁借走了投影仪,谁拿了会议室钥匙,谁领了最后一盒打印纸,都要在上面写一笔。只有一个人登记时,一切都很正常。名字、时间、物品,写完就走。

麻烦出现在两个人同时来写同一行。

一个人刚写了名字,还没写时间;另一个人已经把那一行划掉,写成了自己的记录。再来一个人,看见表上还有一盒打印纸,就拿走了。最后三个人都觉得自己没错,账本却乱了。

这就是锁要解决的问题。

锁不是某种语言里的高级语法,也不是为了让代码看起来更像“资深工程师写的”。它首先是一条边界:当很多人可能同时修改同一份东西时,系统需要一种办法决定谁先来、谁等待、失败怎么处理,以及最后结果怎么算正确。

现在用 AI 写代码,这个概念反而更值得重新讲一遍。

AI 可以很快写出接口、表单、校验、数据库访问和测试用例。以后它也会越来越会自己判断哪里该加锁、哪里该用事务、哪里该做幂等。但开发者至少要看得懂它为什么这么做。

它加了锁,你要知道锁住的是不是正确的资源。

它没加锁,你要知道这里是不是本来就不需要。

它用了分布式锁,你要知道这是不是一把真正大家都看得见的锁,还是只锁住了自己屋里的门。

AI 可以生成代码,人仍然要决定系统边界

锁解决的不是慢,是乱

很多人第一次接触锁,会把它和性能放在一起想。

这也正常。锁经常会让程序变慢。一个人拿到锁,其他人要等;锁的范围大了,等待的人就更多;锁持有久了,吞吐就掉下来。

但锁最先解决的不是快慢问题,而是正确性问题。

没有锁,或者没有其他并发控制手段,程序可能跑得很快。只是它会很快地把数据写乱。

纸质登记表的例子里,所有人都不用等,当然最快。代价是最后没人知道投影仪到底被谁借走了。会议室预约也是一样。系统显示下午两点还有空,两个团队同时点预约。如果中间没有任何约束,最后同一间会议室可能被订给两拨人。

这不是“页面显示错了”那么简单。

真实业务里,这张登记表可能是库存表、账户余额表、订单状态表、优惠券领取记录、任务队列表。它们都可以被读,也可以被改。一旦多个请求同时修改同一份数据,系统就需要一个说法。

谁先改?

谁后改?

后来的请求发现数据变了,是重试,失败,还是重新读取?

锁就是这些说法里最常见的一种。

什么时候要想到锁

不用看到并发两个字就立刻加锁。

更稳妥的办法,是先问三个问题。

第一,会不会有多个执行单元同时发生?

这里的执行单元不只是 Java 线程。它可以是两个 HTTP 请求,两个后台任务,两个服务实例,两个消息消费者,也可以是两个用户在同一秒点了同一个按钮。

第二,它们会不会访问同一份资源?

同一件商品、同一个账户、同一间会议室、同一张优惠券、同一条任务记录,都算。

第三,里面有没有修改?

只读通常问题不大。麻烦出在至少有一个人要改。两个请求同时查会议室是否空闲,没有关系;两个请求同时把同一间会议室改成“已预约”,就有关系。

这三个问题如果都成立,就要考虑并发控制。

注意,是考虑并发控制,不是立刻手写一把锁。

有些场景用锁合适,有些场景用数据库唯一索引更直接,有些场景用条件更新就够,有些场景应该改成消息队列排队处理。锁是一件工具,不是所有门都要换成防盗门。

锁住的到底是什么

很多初学者会以为锁住的是一段代码。

比如 Java 里写了 synchronized,看起来像是把某个方法或代码块锁住了。这个说法方便,但容易误导。

真正要想的是:这把锁保护的资源是什么?

如果保护的是库存,那同一个商品的库存扣减应该互斥,不同商品之间未必需要互相等待。

如果保护的是账户余额,那同一个账户的扣款和充值要小心,不同账户之间通常不该排成一队。

如果保护的是一间会议室,那 A 会议室和 B 会议室可以各自预约,不需要因为 A 被锁住,B 也不能用。

锁的粒度很关键。

锁太小,挡不住问题。该保护同一个账户余额,却只锁住了某个临时对象,另一个服务实例照样能改数据库。

锁太大,系统就堵。只要有人改一个商品库存,全站所有商品都不能下单,这就像为了防止两个人抢一支笔,把整栋楼停电。

所以锁不是越多越安全,也不是越大越安全。

好锁要刚好保护那份会被写坏的资源。

最小的例子:count++

技术文章里常用 count++ 讲锁。它看起来老套,但确实好用。

count++;

这一行像一个动作,实际可以拆成三步:

读取 count
计算 count + 1
写回 count

如果两个线程同时读到 count = 10,它们各自算出 11,再各自写回 11,最后结果就是 11,不是 12。

库存扣减、余额变更、订单状态流转,本质上都是这个问题的业务版本。

只不过 count++ 错了,可能只是统计数字少 1。余额错了,就不只是数字少 1 了。

问题不在这一行代码有多复杂。

问题在它假装世界上只有一个人在操作。

悲观锁:先关门,再办事

悲观锁的想法很朴素:我认为冲突可能发生,所以先把资源锁住,等我处理完,别人再来。

像进一间只能容纳一个人的小房间。进去先关门,不是因为门外一定有人闯进来,而是这件事如果发生,会很难看。

Java 里的 synchronizedReentrantLock,数据库里的 SELECT ... FOR UPDATE,都可以按这个思路理解。

数据库里常见的写法是:

SELECT balance
FROM account
WHERE id = ?
FOR UPDATE;

UPDATE account
SET balance = balance - ?
WHERE id = ?;

这段 SQL 通常放在同一个事务里。事务提交前,这一行账户记录被锁住。别的事务如果也想改同一行,就要等。

悲观锁适合什么场景?

冲突比较高,数据不能错,失败后重试也不太合适。比如账户扣款、关键订单状态流转、某些强约束的资源占用。

它的代价也很直白。

别人要等。锁持有太久会影响吞吐。多把锁拿取顺序乱了,还可能死锁。悲观锁像临时封路,需要时很好用,封太多就堵车。

一个很实际的经验是:锁里面少做慢事。

尤其不要在拿着数据库行锁时去调远程接口。那就像把别人关在门外,自己在屋里打电话。电话一聊五分钟,门口的人会从礼貌等待变成怀疑人生。

乐观锁:先走,提交时验票

乐观锁的想法相反。

它不提前拦人。大家先做自己的事,提交修改时检查一下:这份数据中途有没有被别人改过?

常见做法是加 version 字段。

更新库存时带上旧版本号:

UPDATE product
SET stock = stock - 1,
    version = version + 1
WHERE id = ?
  AND stock > 0
  AND version = ?;

影响行数是 1,说明更新成功。

影响行数是 0,说明库存不足,或者这条记录已经被别人改过。接下来可以重试,也可以返回失败。

乐观锁适合冲突不高、读多写少、允许失败后重试的场景。比如编辑文章、更新配置、普通库存扣减。

它的问题在失败处理。

很多代码写乐观锁,只写成功路径。更新失败了怎么办?重试几次?用户看到什么?重试会不会把系统打得更忙?

这些不能省。

乐观锁不是“没有锁”。它只是把等待变成了检查,把排队变成了失败后的处理。

还有一个常见词叫 ABA。意思是数据从 A 变成 B,又变回 A,表面看没变,其实中间发生过修改。多数业务里用递增版本号就能避开,不需要一上来钻到底层。

怎么选

悲观锁是先关门,再办事。

乐观锁是先办事,最后验票。

这两个没有谁更高级。脱离场景比较技术,通常会变成名词互殴。名词打得很热闹,线上系统不认。

大致可以这样看:

问题 更可能的选择
冲突很多,失败重试成本高 悲观锁
冲突不多,允许用户重试或系统自动重试 乐观锁
数据绝不能错,比如余额 事务、行锁、流水、幂等一起考虑
只是防止重复创建 唯一索引、幂等 key 往往更直接
多实例部署 本机锁通常不够,要考虑外部约束

工程里有些答案确实只能说“不一定”。真正有用的不是背一个固定选择,而是把条件摆出来。

冲突概率有多高。

错误代价有多大。

失败后能不能重试。

系统是单进程还是多实例。

约束到底放在哪里最可靠。

这些条件说清楚,方案就不会差太远。

多路请求汇到同一份数据前,真正的问题是共享资源会不会被写坏

会议室、库存和余额

生活里最容易理解的是会议室预约。

同一间会议室,同一段时间,只能被一个团队预约。解决办法不一定是手写锁。可以给数据库加唯一约束,比如会议室 ID 加时间段不能重复。谁先写进去谁成功,后写的人失败。

这就像登记表上不允许出现两条相同编号的记录。

库存扣减也类似。

最容易出错的写法是:

先查库存
如果库存大于 0
再扣库存

单人测试没有问题。并发下,两个请求都查到 stock = 1,都觉得可以买,最后就超卖。

更直接的做法,是把判断和修改合成一条 SQL:

UPDATE product
SET stock = stock - 1
WHERE id = ?
  AND stock > 0;

影响行数为 1,扣减成功。

影响行数为 0,库存不足。

这条 SQL 很朴素,但有用。它把“库存必须大于 0”这个约束放在数据库里执行,避免了“先查再改”中间被别人插队。

余额更敏感。

库存超卖,难看,但还能补货、退款、道歉。余额扣错,系统信用就掉了一块。账务系统最怕的不是代码丑,是账对不上。

余额变更通常不能只想着“加锁”。至少还要考虑事务、流水、幂等和唯一索引。

事务保证余额更新和流水写入一起成功或失败。

流水让每一笔变更都有账可查。

幂等保证同一个扣款请求不会因为重试执行两次。

唯一索引让重复请求号进不了库。

这里能看出一个重点:并发控制不是只有锁。

锁只是其中一种办法。

重复提交不是单纯的锁问题

用户连续点两次按钮,浏览器重发,客户端超时重试,消息队列重复投递,这些都会带来重复提交。

比如支付。

一个用户点了支付,网络卡了一下,他又点一次。或者前端只发了一次,但网关重试了。系统如果每来一次请求都新建一笔支付单,后面就热闹了。

很多人第一反应是加锁。

有时可以,但不总是最合适。

重复提交的关键是:同一件事只能被处理一次。这个问题更接近幂等。

幂等这个词听起来有点数学,业务里可以简单理解成:同一张凭证来几次,只认第一次,后面返回同一个结果。

常见做法包括:

  • 前端按钮置灰,用来改善体验,但不能当后端保障。
  • 后端使用 idempotency_key 或请求号。
  • 数据库建立唯一索引,挡住重复请求号。
  • 重复请求返回第一次处理结果。
  • 短时间内的重复点击,可以用 Redis 短期锁兜一下。

锁防的是“同时进来”。

幂等防的是“同一件事被做多次”。

两者相关,但不是一回事。

任务重复执行:门不在同一间屋子里

本地开发时,一个定时任务通常只有一个进程在跑。

线上部署以后,情况就变了。

三个实例,每个实例都有同一个 cron。到点以后,三个实例一起扫数据库。原本想处理一批数据,最后处理了三遍。

这时 Java 的 synchronized 没用。

因为每个实例都有自己的 JVM、自己的内存、自己的锁。A 机器锁住了,B 机器并不知道。

单机锁像一间屋子的门锁。多实例部署以后,是好几间屋子里各自挂了一把锁。你锁了自己的门,隔壁屋的人照样在改同一本远程账本。

这类场景要用大家都看得见的公共门禁。

常见做法有几种:

  • Redis 分布式锁,让同一时间只有一个实例执行。
  • 数据库任务表抢占,谁更新状态成功谁处理。
  • 消息队列,把任务拆成消息,由消费者按规则处理。
  • 调度平台保证同一任务单实例执行。

这里的重点不是背哪个框架,而是知道“本机锁”和“分布式协调”不是一回事。

分布式锁不是豪华版 synchronized

一说多实例,很多人会自然想到 Redis 分布式锁。

它确实常用,但别把它想成 synchronized 的远程豪华版。

分布式锁要考虑很多现实问题:

  • 锁多久过期?
  • 业务执行时间超过锁过期时间怎么办?
  • 服务宕机后锁能不能释放?
  • 要不要续期?
  • 释放锁时会不会删掉别人后来拿到的锁?

最基本的要求是:锁要有过期时间,锁的 value 要有唯一标识,释放时先确认这把锁确实是自己持有的。

否则可能出现很荒唐的情况。

A 拿到锁,业务执行太慢,锁过期了。B 拿到新锁,开始处理。A 终于执行完,一把删掉 B 的锁。

这不是玄学,是真实系统里会发生的普通尴尬。

所以很多场景下,数据库唯一索引、条件更新、任务表抢占,反而比手写分布式锁更稳。

分布式锁能用,但要知道自己拿的是什么。

状态流转:先把路画出来

订单从“待支付”到“已支付”,再到“已发货”,也可能退款、取消、关闭。

这些状态不是随便改的。

待支付可以变成已支付,也可以变成已取消。已发货以后就不能假装回到待支付。退款也不是任意时刻都能发生。

并发情况下,支付回调、用户取消、后台发货、退款请求可能同时到。最后状态乱了,查日志像考古。

这类问题,锁只能解决一部分。

更根本的是状态约束。

常用写法是条件更新:

UPDATE orders
SET status = 'PAID'
WHERE id = ?
  AND status = 'PENDING_PAYMENT';

只有订单还处于待支付时,才能改成已支付。影响行数为 0,就说明状态已经变了,当前操作不能继续假装成功。

这就是状态机的朴素版本。

很多坏代码,就是太自信地 set status = xxx,不管旧状态是什么,直接覆盖。

状态流转里,锁是一道门,状态机是一张路网。只装门,不画路,还是会迷路。

不同语言只是门锁样式不同

Java 有 synchronizedReentrantLockAtomic

Go 有 sync.Mutexsync.RWMutex,也常用 channel 把共享修改变成排队处理。

Python 有 threading.Lock,异步代码里有 asyncio.Lock

Node.js 主线程是事件循环模型,但服务端仍然会遇到多请求并发、多进程、多实例和数据库并发。

不要被“Node 是单线程”这句话骗了。

单线程只能说明一段 JavaScript 代码不会在同一个线程里被两个 CPU 同时执行。它不能阻止两个 HTTP 请求同时更新同一行数据库,也不能阻止两台机器同时跑同一个任务。

语言层面的锁,更多解决进程内的问题。

业务系统里的并发,常常发生在数据库、缓存、消息队列、多个服务实例之间。

所以第一问题不是用什么语言。

第一问题是:共享数据在哪里?并发入口在哪里?正确性由谁保证?

回到 AI

AI 会越来越会写代码,也会越来越会规划方案。

这当然是好事。

但开发者不能因此把基本概念都交出去。因为系统最后坏不坏,往往不取决于代码长得像不像,而取决于约束有没有放对地方。

AI 给一个接口加了 synchronized,如果服务是多实例部署,就要知道这把锁可能只管住当前 JVM。

AI 给库存扣减写了“先查再改”,就要知道这里可能被并发插队。

AI 给重复提交加了 Redis 锁,也要继续看有没有幂等 key 和唯一索引。

AI 给订单状态直接覆盖成 PAID,就要问一句:旧状态是什么?这个流转合法吗?

这不是要每个人都去背 JVM、AQS、JMM、volatile 的细节。那些东西重要,但不是每篇业务代码都要钻到地心。

更常用、更应该先掌握的是这些判断:

  • 有没有共享资源?
  • 有没有并发修改?
  • 错了以后代价多大?
  • 能不能失败重试?
  • 是单进程还是多实例?
  • 约束放在代码、数据库、Redis、队列,还是调度系统里?

这些问题想清楚,就能看懂 AI 给出的方案是不是靠谱。

看不懂这些,AI 写得越快,错误也可能来得越快。

这话不吓人。它只是工程里的常识:车快了,路标和刹车也要跟上。

最后

锁解决的是并发修改共享资源时的数据正确性问题。

悲观锁适合冲突高、不能轻易失败的场景。先关门,再办事。

乐观锁适合冲突低、允许重试的场景。先办事,提交时验票。

分布式场景下,本机锁通常不够。数据库锁、Redis 分布式锁、唯一索引、条件更新、消息队列、任务表抢占,都可能是答案的一部分。

实际业务里,不要一上来问“要不要加锁”。

先问:

  • 这段业务并发下会不会出错?
  • 出错以后影响有多大?
  • 哪个地方最适合放约束?
  • AI 或框架给出的方案,锁住的到底是哪份资源?

锁不是高级概念。

它只是提醒人:系统里有些门,不能让所有人一起挤进去。

至于哪扇门要关,关多久,用钥匙、门禁还是登记簿,那还是人要想明白。