一、前言
在构建高可靠的软件系统时,数据一致性始终是我们面临的最大挑战之一。在此前的系列文章中,我们已经探讨了几个维度的一致性问题:
- 冗余数据存储中的一致性问题
- 分布式共识算法中的一致性问题
- 单机事务中的一致性问题
- 分布式事务中的一致性问题
本文将从微观的代码执行层面,聊一聊竞态条件(Race Condition)。
很多线上诡异的Bug,比如“库存扣成了负数”、“流水记录重复”、“配置更新丢失”,其背后的始作俑者往往就是竞态条件。本文将深入剖析其成因,结合真实的业务Case,并给出从单机到分布式的完整解决方案。
二、问题定义
2.1 什么是竞态条件
竞态条件,顾名思义,就是多个执行单元(线程、进程或分布式服务节点)为了竞争同一份资源,而产生的时序上的不确定性。
简单来说,当计算的正确性依赖于相对时间顺序或线程的交替执行顺序时,就会发生竞态条件。如果我们的代码没有针对这种不确定的顺序做出防御,系统就会陷入混乱。
最经典的例子莫过于计数器累加。假设我们有一个全局变量 count = 0,两个线程同时执行 count++。在底层汇编或字节码层面,这是一个典型的 RMW (Read-Modify-Write) 操作:
- Read:读取内存中的值到寄存器
- Modify:寄存器值加1
- Write:将寄存器值写回内存
如果没有同步机制,线程A和线程B可能同时读取到0,各自加1后都写入1。预期的结果是2,实际结果却是1。这就造成了更新丢失(Lost Update)。
sequenceDiagram
participant T1 as 线程1
participant T2 as 线程2
participant Store as 共享内存(Count=0)
T1->>Store: 读取Count (得到0)
T2->>Store: 读取Count (得到0)
T1->>T1: 计算 0+1=1
T2->>T2: 计算 0+1=1
T1->>Store: 写入Count (写入1)
T2->>Store: 写入Count (写入1)
Note over Store: 最终结果是1,而不是2
2.2 本质归因
从本质上讲,竞态条件问题通常由以下三个要素共同作用产生:
- 共享资源:存在被多个执行单元共同访问的变量、文件、数据库记录等。
- 可变状态:该资源是可以被修改的,只读资源不存在竞态问题。
- 非原子操作:业务逻辑由多个步骤组成(如“先检查后执行”或“读取后修改”),且这些步骤中间可能被其他线程打断。
三、常见业务场景
3.1 Case 1:MQ重复消费导致的脏数据
场景描述
在一个电商营销系统中,有一个“新用户注册自动送新人券”的功能。系统订阅了用户中心的MQ消息(Topic: User_Register),逻辑非常简单:收到注册消息 -> 查询该用户是否已领过新人券 -> 如果没领过,则调用发券接口。
问题现象
客服接到用户反馈或财务对账时发现,同一个新用户在注册的一瞬间,竟然收到了两张甚至多张一样的新人优惠券。这造成了营销预算的浪费,甚至可能被黑产利用薅羊毛。
原因分析
这是典型的 Check-Then-Act(先检查后执行) 类型的竞态条件。
虽然代码里写了防重逻辑 if (couponRecordService.check(userId) == false),但在高并发下,MQ可能会因网络抖动触发重试,或者微服务扩容导致两个消费者节点同时收到了同一条消息(或极短时间内的两条消息)。
- 消费者A 查询数据库:用户1001有过领券记录吗?-> 无。
- 消费者B 查询数据库:用户1001有过领券记录吗?-> 无(因为A还没来得及写入)。
- 消费者A 执行发券,并写入领券记录。
- 消费者B 执行发券,并写入领券记录。
结果:该用户被发了两张券,产生了重复数据。
sequenceDiagram
participant C1 as 消费者1
participant C2 as 消费者2
participant DB as 数据库/优惠券服务
C1->>DB: 查询用户1001是否已领券
C2->>DB: 查询用户1001是否已领券
DB-->>C1: 返回 false (未领取)
DB-->>C2: 返回 false (未领取)
C1->>DB: 发放优惠券 & 写入记录
C2->>DB: 发放优惠券 & 写入记录
Note right of DB: 用户收到了两张券!
资产超发风险
3.2 Case 2:大Json字段并发更新导致的“写丢失”
场景描述
系统中有一个“车辆证照”表,为了扩展性,我们用一个名为 extra_info 的 JSON 字段存储各种灵活的属性,比如 { "A": 1, "B": 2 }。
业务有两个独立的入口:入口A负责更新属性A,入口B负责更新属性B。
问题现象
运营反馈,明明刚刚在入口B把属性B更新为3了,怎么过了一会又变回了2?
原因分析
对于JSON字段的更新,通常方式是:读取整行数据 -> 反序列化为对象 -> 修改内存对象 -> 序列化为JSON -> Update写回数据库。
这构成了 Read-Modify-Write 模式。
- 线程A读取数据,拿到
{ "A": 1, "B": 2 }。 - 线程B读取数据,拿到
{ "A": 1, "B": 2 }。 - 线程A在内存中把A改为2,准备写入
{ "A": 2, "B": 2 }。 - 线程B在内存中把B改为3,准备写入
{ "A": 1, "B": 3 }。 - 线程A执行Update,数据库变为
{ "A": 2, "B": 2 }。 - 线程B执行Update,数据库变为
{ "A": 1, "B": 3 }。
线程A对属性A的修改,被线程B无情地覆盖了。这就是著名的写偏斜(Write Skew)或第二类丢失更新。
sequenceDiagram
participant TA as 线程A(改A)
participant TB as 线程B(改B)
participant DB as 数据库
TA->>DB: Read (A=1, B=2)
TB->>DB: Read (A=1, B=2)
TA->>TA: 修改 A=2
TB->>TB: 修改 B=3
TA->>DB: Update set json = {A=2, B=2}
TB->>DB: Update set json = {A=1, B=3}
Note right of DB: 线程A的修改丢失了!
四、思路分析与解决方案
解决竞态条件的核心心法只有一句话:将并发操作串行化。
我们需要通过某种机制,确保在同一时刻,只有一个执行单元能对共享资源进行“读取-修改-写入”的完整操作。在技术实现上,主要分为悲观锁和乐观锁两大流派。
4.1 悲观锁:以“阻塞”换“安全”
悲观锁假定并发冲突发生的概率很高,所以在操作数据之前,必须先拿到锁,独占资源。其他线程想要访问,必须排队阻塞。
4.1.1 常见实现方式
1. 单机环境
- Java Synchronized / Lock:这是最基础的手段。对于单机内存中的共享对象,使用
synchronized关键字或者ReentrantLock即可。 - Go Mutex:Go语言中的
sync.Mutex或sync.RWMutex。
2. 数据库层面
- Select For Update:在事务中使用
select * from table where id = ? for update。这会给数据库行加上排他锁(X锁),在事务提交前,其他事务无法读取或修改这行数据。
- 分布式环境
在微服务架构下,应用部署在多台机器上,单机的锁已经失效,我们需要依赖外部组件来实现分布式锁。
- Redis 分布式锁:利用 Redis 的单线程特性。
- 加锁:
SET key value NX PX 10000(如果key不存在则设置,设置过期时间10秒)。 - 解锁:需要使用Lua脚本保证原子性,判断value一致才删除,防止误删别人的锁。
- 红锁(Redlock):在Redis集群环境下,为了防止主从切换导致锁丢失,在多个独立Redis实例上加锁的高可用方案。
- 加锁:
- Zookeeper / Etcd:利用其临时有序节点或Revision机制。ZK的锁一致性更强,但性能略低于Redis。
4.1.2 Case 1 的悲观锁解法
回到MQ重复消费的例子。我们可以在Redis中加一把分布式锁,锁的Key是 lock:car_model:{fashion_id}。
// 伪代码示例
String lockKey = "lock:car_model:" + fashionId;
boolean locked = redis.setNxPx(lockKey, uuid, 5000);
if (locked) {
try {
// 1. 再次查询数据库 (Double Check)
if (db.query(fashionId) == null) {
// 2. 插入
db.insert(data);
}
} finally {
// 3. 释放锁
redis.unlock(lockKey, uuid);
}
} else {
// 获取锁失败,说明有其他线程正在处理,可以选择丢弃或稍后重试
return;
}
通过这把锁,我们将并发的MQ消费变成了串行执行。当线程1拿到锁时,线程2获取失败,从而避免了重复插入。
4.1.3 悲观锁的本质
悲观锁的本质是在访问资源前设置一个状态标记。
- 在单机,这个标记在对象头(Mark Word)里。
- 在数据库,这个标记在数据页的锁位图里。
- 在分布式,这个标记是Redis里的一个Key或ZK里的一个节点。
获取锁的过程,本质上就是利用更底层的原子操作(如CPU的CAS指令、Redis的原子命令)去争抢设置这个标记的权利。
4.2 乐观锁:以“试探”求“高效”
乐观锁假定并发冲突的概率很低,所以不加锁直接执行,只是在最后提交更新的时候,校验一下数据有没有被别人改过。
4.2.1 常见实现方式
- CAS (Compare And Swap)
这是CPU指令级别的支持。逻辑是:我认为内存位置V的值应该是A,如果是,就把他改成B;如果不是(说明被别人改了),那我就不操作,或者重试。
Java中的 AtomicInteger 就是基于CAS实现的自旋锁。
- 数据库版本号机制 (Versioning)
这是业务开发中最常用的方案。在数据库表中增加一个 version 字段。
每次更新时,将版本号作为WHERE条件,并同时将版本号+1。
UPDATE table
SET field = new_value, version = version + 1
WHERE id = ? AND version = old_version;
如果返回的影响行数为1,说明更新成功;如果是0,说明版本号变了,更新失败,需要应用层决定是重试还是报错。
4.2.2 Case 2 的乐观锁解法
对于Json字段并发更新丢失的问题,非常适合用版本号解决。
-
线程A读数据,拿到
version=1。 -
线程B读数据,拿到
version=1。 -
线程A计算完成,执行SQL:
UPDATE car_license SET json=..., version=2 WHERE id=1 AND version=1
执行成功,数据库变成 version=2。
-
线程B计算完成,执行SQL:
UPDATE car_license SET json=..., version=2 WHERE id=1 AND version=1
执行失败! 因为此时数据库里的version已经是2了,不等于WHERE条件里的1。
这样线程B的操作就被拦截了,避免了静默覆盖线程A的数据。线程B捕获到失败后,可以重新读取最新的数据,合并自己的修改,再次尝试提交。
sequenceDiagram
participant TA as 线程A
participant TB as 线程B
participant DB as 数据库
TA->>DB: Read (v=1)
TB->>DB: Read (v=1)
TA->>DB: Update set v=2 where v=1
Note right of DB: 成功,v变为2
TB->>DB: Update set v=2 where v=1
Note right of DB: 失败!影响行数0
TB->>TB: 捕获失败,重新读取重试
TB->>DB: Read (v=2)
TB->>DB: Update set v=3 where v=2
Note right of DB: 成功
4.3 唯一索引:数据库层面的兜底
除了悲观锁和乐观锁,还有一个朴素但强大的工具:数据库唯一约束。
对于Case 1(MQ重复插入),最彻底的解决办法其实是给数据库表的 fashion_id 加上唯一索引。
当两个线程同时插入时,数据库引擎会通过内部的锁机制确保只有一个能成功,另一个会抛出 DuplicateKeyException。
在代码层面,我们捕获这个异常,把它当作“消费成功”处理即可。这种方式不需要引入Redis等外部组件,简单可靠。
4.4 深度对比与思考
悲观锁和乐观锁没有绝对的优劣,只有适用的场景。
| 特性 | 悲观锁 | 乐观锁 |
|---|---|---|
| 核心理念 | 独占、阻塞 | 冲突检测、重试 |
| 适用场景 | 写多读少,并发冲突极高 | 读多写少,并发冲突较低 |
| 优点 | 保证强一致,避免反复重试导致CPU飙升 | 吞吐量高,无死锁风险 |
| 缺点 | 线程阻塞开销大,可能死锁 | 高并发下CAS自旋浪费CPU,ABA问题 |
| 实现复杂度 | 较高(需维护锁状态) | 较低(依赖数据库或CAS) |
思考:锁的本质是串行化
无论是悲观锁的排队等待,还是乐观锁的失败重试,其最终效果都是让原本并行的操作,在时间维度上拉成一条直线。
- 悲观锁是物理串行:大家排好队,一个一个进屋办事。
- 乐观锁是逻辑串行:大家一起冲进屋,最后出门检票时,发现票号不对的回去重新排队。
五、开发建议
作为资深开发,在处理竞态条件时,我有以下几点建议分享:
- 识别临界区,最小化锁粒度
锁是性能的杀手。千万不要为了省事,在整个方法上加 synchronized 或者在整个业务流程头部加分布式锁。
只对修改共享变量的那几行代码加锁。锁的持有时间越短,系统的并发能力越强。
- 优先考虑幂等性设计
在分布式系统中,网络抖动是常态。上游服务调用超时重试、MQ消息重复投递不可避免。
与其到处加锁防御,不如把接口设计成**幂等(Idempotent)**的。比如利用数据库唯一索引、利用状态机流转限制(如订单状态只能从A流转到B),从数据模型层面天然抵御重复操作。
- 警惕“复合操作”的原子性
Java中的 ConcurrentHashMap 是线程安全的,但这不代表对它的复合操作也是安全的。
错误写法:
if (!map.containsKey(key)) {
map.put(key, value);
}
正确写法:
map.putIfAbsent(key, value);
要时刻警惕这种 Check-Then-Act 的代码块。
- 兜底策略不能少
使用分布式锁时,一定要设置合理的过期时间(TTL),防止服务宕机导致锁永远无法释放(死锁)。
使用乐观锁自旋重试时,一定要限制最大重试次数,防止CPU打满。
六、小结
竞态条件是并发编程中无处不在的幽灵,它源于多执行单元对共享资源的无序争夺。
- 我们通过 Case 1 (MQ重复消费) 学习了如何利用 分布式锁(悲观锁) 或 唯一索引 来解决 Check-Then-Act 类问题。
- 我们通过 Case 2 (Json字段并发写) 学习了如何利用 版本号(乐观锁) 来解决 Read-Modify-Write 类问题。
悲观锁适合写冲突激烈的战场,以阻塞换安全;乐观锁适合读多写少的乐土,以重试换性能。
在架构设计中,没有万能的银弹。理解业务场景的并发量级,权衡数据一致性与系统吞吐量,选择最合适的同步机制,才是资深开发者的核心竞争力。希望本文能为你解决并发一致性问题提供清晰的思路。