连载中 1/20

同一个账户,两家门店同时扣款:JVM 锁管不了的事

2026-06-24 · 1393 阅读 · 0 评论 · 0 赞

连锁化之后的第一笔糊涂账

老王的 503 咖啡馆从一家店开成了三家店,会员储值系统也跟着升级:以前一台服务器包打天下,现在收银、小程序、App 各自是独立服务,后端还部署了三个实例做负载均衡。升级当天下午,会员小赵的储值卡里剩 100 块。他在 A 店点了杯燕麦拿铁(80 块),同一秒,他女朋友在 B 店用同一张家庭卡点了杯美式(30 块)。两笔订单都成功了。月底对账,财务发现这张卡的流水加起来扣了 110,余额却停在了 20——有一笔扣款凭空消失,钱没扣够,咖啡倒是都出了杯。老王把账翻了个底朝天,跑来找我:「我们加了 synchronized 的,怎么会扣错?」

synchronized 的边界:它只管自己这一亩三分地

先给 synchronized 洗个冤:它没坏,它只是够不着。synchronized 和 ReentrantLock 的本质,是在同一个 JVM 进程内对同一把锁对象排队——锁对象躺在堆内存里,线程要进临界区,得先在操作系统层面争这把锁。这套机制在单机时代无往不利,但服务一旦部署三个实例,问题来了:三个实例是三个独立进程,各自的堆内存里各躺一把锁对象,互不知晓,互不干扰。A 实例的线程锁住了 A 实例的锁对象,B 实例的线程路过看了一眼——关我什么事?我锁我自己的。

于是三家门店的请求被负载均衡分到三个实例,同一个账户的扣款请求完全可能落在不同实例上,三把「同名却不同物」的锁各锁各的,互斥形同虚设。单机锁锁的是「同一个 JVM 内的线程」,而分布式系统需要的是「跨进程、跨机器的互斥」——这就是分布式锁存在的全部理由。

数据库行锁为什么没拦住

有人会问:数据库不是有行锁吗?MySQL 实战系列(第 14 篇)讲过,行锁能挡住并发更新同一行。但看我们当年的扣款代码:

// 伪代码:典型的读-判断-写三步走
BigDecimal balance = accountMapper.getBalance(cardNo);   // 第一步:读
if (balance.compareTo(amount) >= 0) {                     // 第二步:判断
    accountMapper.deduct(cardNo, amount);                 // 第三步:写
}

三个请求的 deduct 是 update 语句,确实会拿行锁串行执行——但 getBalance 的读不拿锁(普通 select 走 MVCC 快照读)。A 请求读到余额 100,判断通过,扣 80;B 请求几乎同时读到余额 100(A 还没提交或刚提交),判断也通过,扣 30。两次 update 各自串行执行,没有报错,没有回滚,数据库一切安好——但业务上这笔账已经烂了。「读」和「写」之间隔着网络往返和应用逻辑,这个窗口期的并发就是竞态,行锁锁住了写,锁不住判断。

分布式事务系列第 10 篇讲过的乐观锁版本号能救急(update 带上 version 条件,影响行数为零就重试),但那是「改代码」的路线。很多场景改不动:扣款的判断逻辑散落在老代码里;或者互斥的对象根本不是数据库行——防止定时任务在三个实例上同时跑、防止缓存回源并发击穿(Redis 系列第 5 篇提过互斥锁重建)、防止给同一用户重复发券。这些场景需要一个独立于业务数据之外的、所有进程都能看到的「公共锁」

分布式锁的三个硬指标

分布式锁说穿了很简单:找一个所有进程都够得着的第三方(Redis、ZooKeeper、etcd 都行),在那儿登记「锁被谁拿着」。抢锁就是往第三方写一条标记,释放就是删掉。写个 demo 十分钟,但一把合格的分布式锁要满足三个硬指标,缺一不可:

指标含义不满足会怎样
互斥同一时刻,最多一个客户端持有锁两个实例同时进临界区,扣款乱账重演
防死锁持有人崩了、失联了,锁能自动释放实例扣款时宕机,锁永远不释放,全店业务停摆
可辨识谁加的锁谁能解,别人解不了A 的业务还没做完,B 手快把 A 的锁解了,互斥又破了

这三个指标看着朴素,埋的坑一个比一个深:防死锁要求锁有过期时间,但过期时间短了业务没做完锁先没了,长了持有人崩了全在干等;「可辨识」要求释放锁之前先校验持有者身份,而「校验 + 删除」两个动作又得原子完成。本系列前半程会把这些坑一个一个踩过去。

更深一层:锁只是工具,一致是目的

把镜头拉远一点。分布式事务系列里 TCC 要防悬挂、Seata 要全局锁(第 12 篇)、缓存更新要互斥重建,绕来绕去都在解决同一个问题:多个节点对同一件事,怎么达成一致。分布式锁是这个问题的最直接答案,但它自己也有「信不过」的时刻——Redis 主从异步复制,主节点刚把锁记下来就宕机,从节点升主,锁凭空消失;这正是第 7 篇的主题。再往深走,就要问:ZooKeeper 凭什么让所有节点认可同一个结果?答案是 ZAB 协议;etcd 凭什么?答案是 Raft。一致性协议是分布式锁的地基,地基打在哪,锁的成色就定在哪——这是本系列后半程的主角。

下一篇,我们从最熟悉的朋友 Redis 开始,从 SETNX 这条老命令讲起,看看「一条命令的二十年进化史」——你会看到,分布式锁的第一课,是让「加锁」这个动作真正变成原子的。

503

10 年全栈工程师 · 503咖啡馆主理人

#分布式锁#JVM锁#丢失更新#互斥#503咖啡馆

评论 (0)

相关推荐

连载中 12/20

排查四件套:jstack、jmap、jstat、jcmd 的实战分工

jstack 看线程在干什么,jmap 看堆里装了什么,jstat 看运行时在变什么,jcmd 是统一入口。四把刀各管一段,配合着用没有查不动的现场。

#jstack#jmap#jstat#jcmd#排查工具
2026-09-16 · 5 阅读 · 0 评论 · 0 赞
连载中 11/20

GC 日志:把回收过程翻译成人话

一行 GC 日志里塞着七种信息:谁触发的、收了哪、停了多久、活了哪些。加上 -Xlog 配置,再加上日志分析工具,GC 不再是只能盯监控曲线的黑盒。

#GC日志#Xlog#日志分析#GC监控#Full GC排查
2026-09-16 · 13 阅读 · 0 评论 · 0 赞
连载中 10/20

ZGC:亚毫秒停顿是怎么炼成的

百 G 大堆停顿不到一毫秒,靠的是把搬家全部挪到并发阶段——着色指针让引用自带状态,读屏障让搬运中的对象依然可访问。代价是吞吐与内存,收益是停顿与堆大小解耦。

#ZGC#着色指针#读屏障#亚毫秒停顿#分代ZGC
2026-09-15 · 8 阅读 · 0 评论 · 0 赞