
最近在技术社区看到一个很有意思的讨论标题是“我们的法院不会犯这种错误的吧”。乍一看这似乎是一个关于司法或社会议题的讨论但点进去才发现这其实是一个经典的、在软件开发领域尤其是涉及复杂业务逻辑和状态管理的系统中极其容易踩坑的技术问题的隐喻。这个问题的本质是什么它描述的是这样一种场景在一个多步骤、多状态流转的业务流程中比如订单处理、工单审批、资金结算系统基于当前状态做出了一个逻辑判断或执行了一个操作。然而这个判断所依赖的“当前状态”可能在你读取它、处理它、到最终基于它执行操作的极短间隙内已经被另一个并发的请求或进程修改了。系统“坚信”自己看到的就是“真相”并基于这个“过时的事实”做出了决策从而导致了业务逻辑的错乱。从系统的视角看它“没有犯错”它严格遵循了代码逻辑但从业务结果看这无疑是一个严重的错误。这就是并发编程中的核心挑战之一竞态条件Race Condition以及其最典型的解决方案——锁Locking和事务Transaction的重要性。本文将彻底拆解这个隐喻背后的技术原理从问题现象、根本原因到不同层次的解决方案从应用层到数据库层并提供可落地的代码示例和最佳实践。无论你是后端开发、架构师还是对系统稳定性有要求的开发者理解并解决这类“不会犯的错误”都是构建可靠系统的必修课。1. 这篇文章真正要解决的问题状态“瞬变”引发的业务逻辑事故为什么“法院不会犯错”这个比喻如此贴切因为它精准地描述了开发者在面对偶发、难以复现的线上Bug时的那种困惑与无力感。代码审查时逻辑清晰单测覆盖率很高压测时也一切正常但就是在生产环境的某个高峰时刻会出现几笔让人匪夷所思的错误数据。核心痛点你的系统在高并发环境下对共享资源如数据库中的一条记录、缓存中的一个值、文件系统的一个文件的“读-判断-写”操作序列不再是原子的、连续的。这个序列被打断了中间插入了其他操作的修改。典型场景举例余额扣减用户A余额100元。同时发起两笔90元的支付请求。两个请求同时读取到余额为100都判断“100 90”通过然后分别执行balance 100 - 90。最终余额可能不是预期的-80元错误而是10元仅一次扣减成功造成资损。库存超卖商品库存最后1件。瞬间涌入100个请求都查询到库存为1都判断库存0都生成订单并执行stock stock - 1。结果生成了100个订单库存被扣成负数。状态机覆盖一个工单状态从“处理中”流转到“已完成”。在即将写入“已完成”前另一个运维干预请求将其改回“待处理”。但原流程的写入操作随后执行用“已完成”覆盖了“待处理”导致工单状态丢失流程混乱。这些问题在低并发下极难发现但一旦发生业务影响巨大。本文将带你深入理解其成因并掌握从应用代码到数据库机制的全链路解决方案。2. 基础概念与核心原理竞态条件、原子性与隔离性要解决问题首先得清晰定义问题。2.1 竞态条件 (Race Condition)当两个或多个线程/进程可以同时访问和操作共享数据并且最终的执行结果取决于这些线程/进程执行的精确时序时就发生了竞态条件。关键在于“时序敏感性”同样的输入因为微小的时序差异会导致不同的、非预期的输出。2.2 原子性 (Atomicity) 与 “读-改-写” 操作原子性意味着一个操作要么完全执行要么完全不执行中间状态对外不可见。我们面临的多数问题都可以归结为需要将一个“读-判断-写”的逻辑块变成一个原子操作。非原子操作value read(); if (value 0) { value value - 1; write(value); }目标原子操作原子地 { if (value 0) { value value - 1; } }2.3 事务隔离性 (Transaction Isolation)在数据库层面事务的隔离级别决定了不同事务之间的可见性规则。常见的隔离级别有读未提交 (Read Uncommitted)能读到别的事务未提交的修改。几乎不用。读已提交 (Read Committed)只能读到已提交的数据。这是很多数据库的默认级别但它不能防止“不可重复读”和“幻读”。可重复读 (Repeatable Read)在同一事务内多次读取同一数据的结果是一致的。MySQL InnoDB默认级别通过MVCC实现。串行化 (Serializable)最高的隔离级别强制事务串行执行完全避免并发问题但性能代价最高。“我们的法院不会犯这种错误”这个问题在“读已提交”级别下就很容易发生。事务A读取数据事务B修改并提交后事务A基于旧数据做出的判断和写入就错了。2.4 悲观锁与乐观锁这是解决并发冲突的两种哲学悲观锁认为冲突很可能会发生所以在操作数据前就先上锁阻止其他操作。如SELECT ... FOR UPDATE。乐观锁认为冲突不常发生所以操作数据时不上锁只在提交更新时检查数据是否被其他事务修改过。通常通过版本号version或时间戳实现。3. 环境准备与前置条件为了演示后续的解决方案我们需要一个简单的实验环境。本文将以一个“商品库存扣减”的场景为例使用Java Spring Boot MySQL技术栈。JDK: 版本 8 或以上Maven: 用于项目管理MySQL: 5.7 或以上版本支持事务和行锁IDE: IntelliJ IDEA 或 Eclipse项目框架: Spring Boot 2.x首先创建一个简单的Spring Boot项目并添加依赖。pom.xml关键依赖dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies创建数据库表CREATE DATABASE IF NOT EXISTS concurrency_demo; USE concurrency_demo; CREATE TABLE product ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(255) NOT NULL COMMENT 商品名称, stock int(11) NOT NULL DEFAULT 0 COMMENT 库存, version int(11) NOT NULL DEFAULT 0 COMMENT 版本号用于乐观锁, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO product (name, stock, version) VALUES (测试商品, 100, 0);4. 问题复现不安全的库存扣减代码我们先写一段“会犯错”的代码来模拟问题是如何发生的。实体类Product.javaimport lombok.Data; import javax.persistence.*; Entity Table(name product) Data public class Product { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String name; private Integer stock; private Integer version; // 乐观锁版本号 }Repository 接口ProductRepository.javaimport org.springframework.data.jpa.repository.JpaRepository; public interface ProductRepository extends JpaRepositoryProduct, Long { }一个有问题的服务方法ProductService.javaimport org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import javax.persistence.EntityManager; Service public class ProblematicProductService { Autowired private ProductRepository productRepository; Autowired private EntityManager entityManager; /** * 有并发问题的扣减库存方法 */ Transactional public boolean deductStockUnsafe(Long productId, Integer quantity) { // 1. 查询商品 Product product productRepository.findById(productId).orElseThrow(() - new RuntimeException(商品不存在)); // 2. 判断库存是否充足 (我们的“法院”在这里做出了判断) if (product.getStock() quantity) { throw new RuntimeException(库存不足); } // 模拟复杂的业务逻辑处理耗时增大并发窗口 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 3. 扣减库存 product.setStock(product.getStock() - quantity); // 4. 保存更新 productRepository.save(product); return true; } }并发测试控制器TestController.javaimport org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; RestController public class TestController { Autowired private ProblematicProductService problematicService; GetMapping(/test/unsafe) public String testUnsafeConcurrency(RequestParam(defaultValue 100) int threadCount) throws InterruptedException { Long productId 1L; // 我们之前插入的商品ID int quantityPerRequest 1; // 每个请求扣减1个 ExecutorService executorService Executors.newFixedThreadPool(threadCount); CountDownLatch latch new CountDownLatch(threadCount); for (int i 0; i threadCount; i) { executorService.submit(() - { try { problematicService.deductStockUnsafe(productId, quantityPerRequest); } catch (Exception e) { System.err.println(扣减失败: e.getMessage()); } finally { latch.countDown(); } }); } latch.await(); executorService.shutdown(); return 不安全并发测试请求已发送请检查数据库库存。理论应剩余: (100 - threadCount) , 实际请查询。; } }运行与观察启动应用。访问http://localhost:8080/test/unsafe?threadCount110。我们发起110个并发请求每个扣减1库存理论应失败10个因为库存只有100最终库存应为0。查询数据库SELECT * FROM product WHERE id 1;很可能的结果库存stock是一个负数比如 -5。这意味着发生了严重的超卖。问题分析100个线程几乎同时执行到product.getStock()读到的都是100都判断通过然后都去执行扣减。最后的stock值取决于这些线程save操作的先后覆盖顺序最终结果远小于0。系统每个线程都“依法办事”库存0才扣减但整体结果却是错的。5. 解决方案一数据库悲观锁SELECT ... FOR UPDATE最直接的解决方案是使用悲观锁。在查询时就用FOR UPDATE锁定这条记录直到当前事务结束其他事务无法修改它。创建安全的服务类PessimisticLockProductService.javaimport org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import javax.persistence.EntityManager; import javax.persistence.LockModeType; Service public class PessimisticLockProductService { Autowired private EntityManager entityManager; Autowired private ProductRepository productRepository; /** * 使用悲观锁行锁的扣减库存方法 */ Transactional public boolean deductStockWithPessimisticLock(Long productId, Integer quantity) { // 使用 FOR UPDATE 锁定行。注意必须在事务中且查询的字段有索引通常是主键才能锁行。 Product product entityManager.find(Product.class, productId, LockModeType.PESSIMISTIC_WRITE); if (product null) { throw new RuntimeException(商品不存在); } // 判断库存 if (product.getStock() quantity) { throw new RuntimeException(库存不足); } // 模拟业务处理 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 扣减库存 product.setStock(product.getStock() - quantity); // 保存事务提交时锁释放 productRepository.save(product); return true; } }在控制器中添加新的测试端点Autowired private PessimisticLockProductService pessimisticLockService; GetMapping(/test/pessimistic) public String testPessimisticLock(RequestParam(defaultValue 110) int threadCount) throws InterruptedException { Long productId 1L; // 先将库存重置为100 Product product productRepository.findById(productId).get(); product.setStock(100); productRepository.save(product); int quantityPerRequest 1; ExecutorService executorService Executors.newFixedThreadPool(threadCount); CountDownLatch latch new CountDownLatch(threadCount); for (int i 0; i threadCount; i) { executorService.submit(() - { try { pessimisticLockService.deductStockWithPessimisticLock(productId, quantityPerRequest); } catch (Exception e) { // 预期会有10个请求因库存不足失败 } finally { latch.countDown(); } }); } latch.await(); executorService.shutdown(); Product result productRepository.findById(productId).get(); return 悲观锁测试完成。库存结果: result.getStock() (应为0); }运行与验证 访问http://localhost:8080/test/pessimistic?threadCount110。你会发现最终库存一定是0。请求总耗时明显变长因为线程需要排队等待锁。控制台会打印出10次“库存不足”的异常后10个请求。原理FOR UPDATE会在数据库层面给这条记录加上排他锁X锁。第一个事务拿到锁后其他事务执行同样的SELECT ... FOR UPDATE时会被阻塞直到第一个事务提交或回滚释放锁。这就保证了“读-判断-写”这个序列对于同一条记录是串行化的。6. 解决方案二数据库乐观锁基于版本号乐观锁不阻止其他事务读取而是在更新时检查数据是否被修改过。我们通过一个version字段来实现。创建乐观锁服务OptimisticLockProductService.javaimport org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; Service public class OptimisticLockProductService { Autowired private ProductRepository productRepository; /** * 使用乐观锁的扣减库存方法 */ Transactional public boolean deductStockWithOptimisticLock(Long productId, Integer quantity) { // 可能需要重试机制 int maxRetries 3; for (int i 0; i maxRetries; i) { // 1. 查询商品带版本号 Product product productRepository.findById(productId).orElseThrow(() - new RuntimeException(商品不存在)); // 2. 判断库存 if (product.getStock() quantity) { throw new RuntimeException(库存不足); } // 3. 扣减库存并更新版本号 product.setStock(product.getStock() - quantity); product.setVersion(product.getVersion() 1); // 4. 尝试更新使用版本号作为更新条件 // 注意JPA的save()方法在更新时会使用实体自带的版本号进行乐观锁控制。 // 我们也可以使用自定义的Update Query来更精确控制。 try { productRepository.save(product); // JPA的Version注解会自动处理乐观锁 return true; // 更新成功 } catch (ObjectOptimisticLockingFailureException ex) { // 捕获乐观锁冲突异常 if (i maxRetries - 1) { throw new RuntimeException(并发更新冲突重试 maxRetries 次后失败, ex); } // 等待一小段时间后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 循环继续重新查询 } } return false; } }为了让JPA的Version注解生效我们需要修改实体类Entity Table(name product) Data public class Product { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String name; private Integer stock; Version // 增加此注解 private Integer version; }关键点Version注解的字段会在每次更新时自动递增。JPA在执行save()对应SQLUPDATE时会在WHERE条件中加上id? AND version?。如果受影响的行数为0说明在本次事务读取后版本号已被其他事务修改JPA会抛出ObjectOptimisticLockingFailureException。我们需要捕获这个异常并进行重试。重试时必须重新查询最新数据和版本号。运行与验证 编写类似的测试端点进行并发测试。你会发现最终库存也是0。不会出现超卖。与悲观锁的“阻塞等待”不同乐观锁是“检测-冲突-重试”的模式。在高并发冲突严重的场景下重试次数会很多可能影响性能。但在冲突不频繁的场景如读多写少乐观锁的性能和并发度更好。7. 解决方案三原子化操作UPDATE ... WHERE最高效的解决方案是直接将业务逻辑下沉到数据库用一个SQL语句完成“判断扣减”利用数据库单语句的原子性。我们可以使用自定义的Update Query。在ProductRepository中添加方法public interface ProductRepository extends JpaRepositoryProduct, Long { // 原子化扣减库存 Modifying Query(UPDATE Product p SET p.stock p.stock - :quantity WHERE p.id :id AND p.stock :quantity) int deductStock(Param(id) Long id, Param(quantity) Integer quantity); }创建原子操作服务AtomicUpdateProductService.javaimport org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; Service public class AtomicUpdateProductService { Autowired private ProductRepository productRepository; /** * 使用原子UPDATE操作的扣减库存方法 */ Transactional public boolean deductStockWithAtomicUpdate(Long productId, Integer quantity) { int updatedRows productRepository.deductStock(productId, quantity); if (updatedRows 0) { // 更新成功可以执行后续业务逻辑如创建订单记录 // 注意此时商品实体中的stock字段是旧的需要重新查询或通过返回值计算 return true; } else { // updatedRows 0表示WHERE条件不满足库存不足或商品不存在 // 可以进一步查询区分是库存不足还是商品不存在 Product p productRepository.findById(productId).orElse(null); if (p null) { throw new RuntimeException(商品不存在); } else { throw new RuntimeException(库存不足); } } } }运行与验证 这是性能最好、最简洁的方案。一个SQL语句UPDATE product SET stock stock - 1 WHERE id 1 AND stock 1在数据库层面是原子的。它直接避免了在应用层先读后写可能带来的时间差。并发测试下库存结果绝对正确且没有锁竞争或重试的开销。8. 方案对比与选型建议方案核心思想优点缺点适用场景无保护无代码简单性能最高但结果是错的必然导致数据不一致绝对禁止在生产环境使用悲观锁(FOR UPDATE)“先锁再改”保证强一致性逻辑简单直观并发度低大量线程会阻塞等待可能引发死锁性能有损耗写冲突非常频繁、业务逻辑复杂无法用一句SQL完成的场景乐观锁(版本号)“先改再验”并发度高无阻塞适合读多写少冲突频繁时重试成本高逻辑稍复杂需要处理重试写冲突不频繁或业务逻辑复杂且需要保持应用层对象状态的场景原子操作(UPDATE ... WHERE)“一句搞定”性能最优无锁无重试利用数据库原子性业务逻辑必须能浓缩到一个SQL的WHERE条件中无法在更新前进行复杂的应用层校验首选方案。只要业务允许应尽量采用此方案如扣减库存、增加积分等。选型决策流首先判断你的“读-判断-写”逻辑能否用一个SQL的UPDATE ... SET ... WHERE condition来表达如果能毫不犹豫选择原子操作。如果不行判断逻辑过于复杂涉及多个表或外部服务考虑乐观锁。它适合冲突概率不高的场景能提供更好的整体吞吐量。如果冲突概率极高比如秒杀场景中对极少量库存的争夺或者业务逻辑要求绝对强一致且无法接受重试失败则考虑悲观锁。但必须警惕死锁风险和性能瓶颈。9. 扩展场景与进阶思考9.1 分布式锁Redis/ZooKeeper适用吗当你的服务是多实例部署共享状态不在同一个数据库或者操作的不是数据库资源如文件、外部API调用次数时就需要分布式锁。例如用Redis的SETNX或Redisson客户端实现一个跨JVM的锁。// 伪代码使用Redisson客户端 RLock lock redissonClient.getLock(PRODUCT_LOCK: productId); try { if (lock.tryLock(3, 10, TimeUnit.SECONDS)) { // 等待3秒锁持有10秒 // 执行受保护的“读-判断-写”逻辑 deductStockUnsafe(productId, quantity); // 这里面的数据库操作可能还需要结合数据库事务 } else { throw new RuntimeException(系统繁忙请稍后重试); } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }注意分布式锁解决了跨进程互斥的问题但锁范围内的数据库操作仍然可能面临本文讨论的数据库并发问题。通常需要分布式锁 数据库悲观/乐观锁/原子操作结合使用前者解决应用层全局互斥后者解决数据库层行级并发。9.2 如何选择数据库隔离级别MySQL默认的“可重复读”RR隔离级别配合MVCC在大多数场景下能避免脏读和不可重复读但对于“幻读”和本文讨论的“更新丢失”问题仍然需要显式加锁FOR UPDATE或使用乐观锁/原子操作。对于需要最高一致性的金融类操作可以在事务开始时使用SELECT ... FOR UPDATE这会在RR级别下触发“当前读”并加锁有效防止其他事务修改。对于大多数互联网业务“读已提交”RC隔离级别配合乐观锁或原子操作是吞吐量和一致性之间更好的平衡。9.3 最佳实践与工程建议默认使用原子操作这是最安全、性能最好的方式。设计表结构时就应考虑业务操作能否通过UPDATE ... WHERE完成。明确事务边界Transactional注解要放在服务方法上而不是Repository层。确保“读-判断-写”在一个事务内。控制事务粒度事务不宜过大避免长事务占用连接和锁资源。尽快提交或回滚。设置合理的重试机制对于乐观锁必须有重试上限和退避策略如指数退避避免无限重试。完善的监控与告警监控数据库锁等待、死锁、乐观锁重试次数等指标。这些是系统并发健康度的风向标。压力测试任何涉及共享资源修改的功能都必须进行高并发压力测试提前暴露并发问题。回到我们开头那个问题“我们的法院不会犯这种错误的吧”。在代码的世界里“法院”就是你的程序逻辑“错误”就是并发导致的数据不一致。通过今天的剖析你应该明白不是程序逻辑错了而是我们忽略了“时间”这个维度上的竞争。解决之道就在于运用好锁、事务、原子操作这些工具为你的“法院”在做出判决前安排好一个不容打扰的“合议庭”或者让它在宣判前再次核对一下最新的“证据”版本号。在分布式和高并发成为常态的今天理解并妥善处理这类并发问题是每一位后端开发者从“功能实现者”迈向“系统设计者”的关键一步。建议你将文中的示例代码运行一遍亲自观察不同方案下的数据表现和性能差异感受会更加深刻。