数据一致性问题剖析与实践(四)——竞态条件竞争导致的一致性问题
一、前言
之前我们讨论了几种场景的一致性问题
- 冗余数据存储中的一致性问题
- 分布式共识中的一致性问题
- 单机事务中的一致性问题
- 分布式事务中的一致性问题
本文将围绕竞态条件竞争中的一致性问题展开讨论分析。
二、 问题定义
竞态条件(Race Condition)是多线程、多进程或分布式环境下常见的棘手问题。简单来说,当多个执行单元(线程、进程等)同时访问并试图修改共享资源,且最终结果依赖于这些执行单元的执行顺序时,竞态条件便会出现 。
例如,在一个多线程的电商库存系统中,多个线程可能同时处理商品的下单操作,都尝试读取当前库存数量,然后进行减 1 操作再写回库存数据。由于线程执行顺序的不确定性,如果没有恰当的同步机制,就可能导致某些下单操作丢失,库存数据不准确。
从本质上讲,竞态条件问题常出现在以下场景:
- 对共享变量的读写操作:共享变量如同多线程环境中的 “公共财产”,当多个线程同时对其进行读取和修改时,就容易引发数据不一致。比如一个银行账户余额作为共享变量,多个取款线程同时读取余额后进行取款操作,最终可能导致账户余额计算错误。
- 非原子操作:像 “读取 - 修改 - 写入” 这类非原子操作,如果没有同步控制,多个线程并发执行时可能会造成数据异常或丢失。以一个简单的计数器为例,若多个线程同时执行 “读取当前计数值,加 1,再写回新值” 的操作,可能会出现计数值增加不足的情况。
- 访问共享资源的顺序问题:当多个线程按照不同顺序访问共享资源时,可能会产生不可预测的结果。例如,线程 A 和线程 B 同时访问一个文件系统,A 尝试创建一个新目录并在其中写入文件,B 尝试在该目录创建之前就写入文件,这就可能导致错误发生。
三、 常见场景
这里举一些业务开发过程中遇到的case,
3.1 case1 对公车型库mq消费没有考虑并发情况导致新增重复车款
对公车型库订阅了上游基础车型库的消息,在消费消息时,由于消息消费逻辑较为复杂,加上消息队列的重试机制,实际场景中出现了——新增重复品牌车型车款的情况。
这个就是没有考虑到并发情况带来的影响。
3.2 case2 车辆证照并发更新Json字段导致写丢失
车辆证照的拓展字段的存储是通过Json字段进行存储的,对于Json字段的更新,是通过读-内存操作-写的方式进行。
在串行执行场景下,该流程可稳定运行;
但是在并发情况下,这就容易造成写丢失的问题。
如上图所示,对于A字段的更新操作,由于没有考虑到并发情况,就会造成写丢失的问题。
四、 思路分析
竞态条件引发的数据一致性问题,其核心矛盾在于多个执行单元对共享资源的无序争夺。最直接的破局思路,便是通过某种机制确保同一时刻仅有一个任务单元能操作共享资源,将并发操作转化为串行执行,从根源上消除竞争冲突。
基于这一理念,悲观锁与乐观锁成为两大主流解决方案,二者虽策略迥异,但目标均指向数据一致性的保障。
4.1 悲观锁:以 “阻塞” 换 “安全” 的保守策略
悲观锁遵循 “先锁后用,独占资源” 的原则,如同给共享资源加上一把 “物理锁”。它假定任何时刻都存在其他线程修改资源的风险,因此在访问共享资源前,线程会先尝试获取锁。一旦成功获取,该线程便拥有资源的独占访问权,其他线程只能处于阻塞等待状态,直至锁被释放。这种策略通过强制串行化操作,能 100% 杜绝竞态条件,但也可能因线程频繁阻塞降低系统并发性能。
常见做法:
- 单机环境
- Java中Synchronized关键字
- Go中的Mutex、RWMutex
- 数据库层面的行锁(比如select for update)
- 分布式环境
- Redis 分布式锁:基于 Redis 的原子操作(如
SETNX
命令)实现锁机制,通过设置唯一标识符和过期时间,避免死锁;**红锁(Redlock)**则是在多个 Redis 实例上获取锁,增强高可用性。 - Zookeeper 分布式锁:利用 Zookeeper 的临时有序节点特性,通过监听节点变化实现公平锁,确保分布式环境下资源访问的一致性。
- 分布式数据OceanBase中的锁
- Redis 分布式锁:基于 Redis 的原子操作(如
回到刚才提到的case1,我们其中一种解法就是在mq消费入口加上该车款id的分布式锁,只有占有该锁,该车款id才能进行处理。此方法相当于针对单一车款id进行串行化操作,避免并发情况。
悲观锁的本质?
从底层逻辑上看,“锁” 并非物理意义上的限制,而是一种标识符或状态标记。
无论是单机还是分布式场景,悲观锁的本质都是通过状态标记实现资源独占:
- 单机场景:依赖内存中的布尔变量或计数器(如 Java 对象头中的锁标记位),借助 CAS(Compare And Swap) 原子操作完成锁的获取与释放。例如,当线程尝试获取锁时,会通过 CAS 将锁标记从
0
(未锁定)改为1
(已锁定),若修改失败则表示锁已被占用。 - 分布式场景:以 Redis 为例,通过
SETNX
(Set If Not Exists)命令在 Redis 服务器上创建键值对作为锁标识,若键已存在则获取失败;Zookeeper 则通过创建临时有序节点,节点的创建顺序即锁的获取顺序,通过监听节点变化实现锁的公平分配。
4.2 乐观锁:以 “试探” 求 “高效” 的激进策略
与悲观锁不同,乐观锁秉持 “先操作,后验证” 的理念,假设多数情况下不会发生并发冲突。线程在更新数据时,会先读取数据及其版本号(或时间戳),在执行修改操作前,再次检查数据是否被其他线程修改。若版本号未变,则提交更新并更新版本号;若版本号已更新,则按预设策略(如重试、回滚)处理。
典型实现-Synchronized自旋锁:
典型实现 - 数据库版本号机制:
回到我们刚才提到的case1,其还有一种解法就是增加唯一键索引,当遇到唯一键冲突时,执行对应的逻辑(消费失败)。纠其本质,其实是一种“先试探后验证”的思路。
针对于3.2提到的case2,因为业务场景更新较少,并发冲突可能性较低,所以可以使用乐观锁的思路去解决。
乐观锁因为缺少加锁的过程,所以在并发量少的情况性能表现较好,但是如果并发量较高,未抢到锁的执行单元一般会进行自旋重试,导致CPU空转,浪费CPU资源。
4.3 悲观锁与乐观锁的思考
悲观锁与乐观锁并非具体的技术工具,而是两种截然不同的并发控制思维范式,它们如同硬币的两面,虽策略迥异,却共同指向 “单一操作串行化” 这一保障数据一致性的核心目标。这种思想层面的抽象,为开发者提供了灵活解决竞态问题的方法论,在不同业务场景下衍生出多样的技术实现。
殊途同归:两种策略的本质共性
无论是悲观锁的 “先锁后用”,还是乐观锁的 “先试后验”,其核心都在于将并行操作转换为串行执行。
悲观锁通过 “阻塞”强制串行化 —— 当线程获取锁后,其他线程只能排队等待,确保同一时刻仅有一个线程访问共享资源;
而乐观锁则采用**“逻辑重试” **机制,允许线程先执行操作,再通过版本号或时间戳校验判断是否存在并发冲突,若冲突则重新执行,间接实现串行化效果。
场景适配:策略选择的关键考量
选择何种策略需深度结合业务场景特性:
- 悲观锁适用场景:适用于写操作频繁、冲突概率高的场景。例如金融系统的账户扣款、电商系统的库存扣减等场景,数据准确性要求极高,即便牺牲部分并发性能,也需通过悲观锁确保操作的原子性,避免出现资金或库存错误。
- 乐观锁适用场景:更适合读多写少、对响应速度敏感的场景。如社交平台的用户动态展示,大量用户并发读取数据,偶尔有少量更新操作。此时采用乐观锁可减少线程阻塞,提升系统吞吐量,即便出现少量冲突,通过重试机制也能快速解决。
五、 开发建议
- 评估并发需求:在设计阶段明确系统是否需要支持高并发,避免过度设计。如果并发量较低,可优先采用简单的同步机制。
- 选择合适的锁机制
- 对于并发冲突频繁的场景,优先使用悲观锁
- 对于读多写少的场景,考虑使用乐观锁或读写锁(如 Go 中的
RWMutex
) - 分布式环境下,根据系统可用性要求选择 Redis 或 Zookeeper 实现分布式锁
- 最小化锁的粒度:因为锁的本质是串行化操作,尽量缩小锁的作用范围,只对必要的共享资源加锁,减少线程阻塞时间,可以提高系统并发性能。
- 异常处理:在使用锁机制时,要做好异常处理,确保锁能够正确释放,避免死锁。如redis分布式锁实现上,利用超时机制,对锁的释放进行兜底,从而避免死锁。
六、小结
竞态条件引发的数据一致性问题,其本质为多执行单元对共享资源的无序并发访问。核心解决思路是将并发操作串行化,主要通过悲观锁和乐观锁两种策略实现。
悲观锁采用 “先锁后用” 的保守策略,通过获取锁来独占资源访问权,强制其他线程阻塞等待,常见实现包括单机环境下的 JavaSynchronized
关键字、Go 的Mutex
,以及分布式环境中的 Redis 和 Zookeeper 分布式锁。其本质是利用状态标记实现资源的独占控制。
乐观锁秉持 “先操作,后验证” 的理念,允许线程先执行操作,通过版本号或时间戳校验判断是否存在并发冲突,若冲突则按策略重试或回滚,典型实现有Synchronized
自旋锁、数据库版本号机制。
实际开发中,需根据业务场景选择合适的锁机制:写操作频繁、冲突概率高的场景优先使用悲观锁;
读多写少、对响应速度要求高的场景更适合乐观锁。
同时,应评估并发需求,最小化锁粒度,做好异常处理,以保障系统的数据一致性和性能。
七、系列总结
至此,本系列文章已经全部结束,回顾梳理一下我们提到的一致性问题:
第一篇文章中,我们提到最典型的一致性问题——冗余数据存储,多份数据存储势必会造成数据一致性问题。首先该不该冗余存储是我们在业务需求开发中必须要评估的问题。其次针对冗余存储的一致性方案,一方面是同步,不论是增量同步(包括多写、异步mq),还是定时任务全量同步,都是基于存储数据的角度去分析;另一方面来说,在冗余存储环境下,我们很难在兼顾性能的前提下去保证冗余存储的一致性,所以另一种设计就是读屏障,我可以设计一些机制(比如读失效、读快照)等实现对外的数据一致性。
和冗余存储常常混在一起的便是分布式共识算法中的一致性问题,与冗余存储不同的点在于,分布式共识强调的是分布式环境中的决策一致性,即在一个范围内的所有节点,如何在一个确定的提案上达成一个统一的结论,可以理解为认知上的一致性。典型算法如paxos、raft,典型实践如Zookeeper、ES集群模块等等。
第四篇文章提到竞态条件下的一致性问题,核心解法就是 执行串行化,主要通过悲观锁和乐观锁两种策略实现。悲观锁采用 “先锁后用” 的保守策略,通过获取锁来独占资源访问权,强制其他线程阻塞等待;乐观锁秉持 “先操作,后验证” 的理念,允许线程先执行操作,通过版本号或时间戳校验判断是否存在并发冲突,若冲突则按策略重试或回滚。在实际开发中,我们应评估并发需求,最小化锁粒度,做好异常处理,以保障系统的数据一致性和性能。
第二、三篇提到一个综合型问题——事务,是一种基于业务场景的综合性问题,在设计方案时其实也会涉及到冗余数据存储和竞态数据竞争的问题。
第二篇提到单机事务,数据库事务的本质是一组数据库操作的集合,这些操作要么全部成功执行,要么全部不执行,以保证数据库从一个一致性状态转换到另一个一致性状态。接着以Mysql为例切入事务的ACID特性,解释Mysql是如何通过保证AID特性最终实现C(Consistent)的。
第三篇围绕分布式事务展开,分布式环境中最大的挑战就是不可靠的网络和时钟,市面上很多关于分布式的解决方案,都是基于原子性——“要么成功,要么失败”去设计实现的,其本质都是通过确认应答模式拉齐认知,通过重试机制来进行可靠性兜底,两种常规的实现思路就是回滚 和 尽最大努力交付。但是要实现分布式事务,仅仅只有原子性还不够,还需要保证分布式环境下的隔离性 和 持久性。
所以,回到我们实际的开发场景,我们在提到一致性问题的时候,需要明确到底是什么场景下的一致性问题,基于不同的场景,结合实际的业务场景,我们才能去设计不同的一致性方案。
此上,如有不正确的地方,欢迎指正。