ARTICLE DETAIL

资讯详情

深耕网站SEO优化与搜索引擎排名提升的一线实战洞察。

Spring Boot并发问题排查:从竞态条件到实战修复

Spring Boot并发问题排查:从竞态条件到实战修复 最近在排查一个线上问题时遇到了一个极其诡异的现象同一个接口在完全相同的请求参数下有时返回正确结果有时却抛出不同的异常。更让人头疼的是这些异常五花八门从空指针到数据库连接超时再到序列化错误几乎不重样。团队里有人苦笑着说“这bug怎么跟开盲盒似的每次点一下都不知道会抽出什么‘惊喜’。”这当然不是真的“盲盒”而是典型的多线程并发问题与资源竞争导致的非确定性故障。在分布式系统、高并发场景下这类“薛定谔的bug”并不少见。它们难以稳定复现给问题定位带来了巨大挑战。本文将深入剖析这类“盲盒式bug”的成因并通过一个完整的Spring Boot实战案例演示如何系统性地复现、诊断和解决这类问题。无论你是正在被类似问题困扰的开发者还是想提前构建防御体系的架构师这篇文章都将提供一套可落地的排查思路和工具链。1. 这篇文章真正要解决的问题我们真正要解决的不是某个具体的空指针或超时异常而是在并发环境下由共享资源竞争、非线程安全操作或外部依赖状态不一致所引发的、表现随机且难以稳定复现的软件缺陷。这类问题的危害极大破坏性隐蔽在测试环境尤其是低并发下可能完全无法暴露。一旦上线在流量洪峰下随机爆发直接导致线上事故。排查成本极高由于无法稳定复现传统的“修改代码-复现问题-验证修复”的调试闭环失效。开发者往往需要花费大量时间分析日志、猜测可能的原因陷入“打地鼠”式的被动救火。影响系统可信度用户会认为系统不稳定、不可靠损害产品口碑。本文将从一个模拟的“积分抽奖”业务场景出发构建一个包含典型并发漏洞的Spring Boot服务。然后我们将一步步使用JUnit 5、TestContainers、Apache JMeter等工具设计可重复的并发测试用例并利用Arthas、线程转储Thread Dump和精细化日志进行现场诊断。最终不仅修复这个特定的“盲盒bug”更总结出一套适用于日常开发的并发问题防御性编程与排查方法论。2. 核心概念竞态条件与数据竞争要理解“盲盒bug”必须先掌握两个核心概念竞态条件Race Condition和数据竞争Data Race。很多人将两者混为一谈但它们在精确诊断时有所不同。2.1 竞态条件Race Condition定义系统的输出或行为依赖于事件或线程的执行时序。即使每个线程单独执行都是正确的但由于执行顺序的不确定性导致最终结果错误。通俗比喻两个人线程同时操作一个共享的智能电表共享资源进行充值。充值逻辑是“读取当前余额 - 加上充值金额 - 写回新余额”。如果两人几乎同时读取余额比如都是100元然后分别加上50元和30元最后写回无论谁先写最终余额都可能是150元或130元而不是正确的180元。结果依赖于“读”和“写”操作的时序。关键点即使没有真正的“数据竞争”也可能因为逻辑时序问题产生错误。2.2 数据竞争Data Race定义当至少两个线程并发地访问同一个共享变量且其中至少有一个访问是写操作并且这些访问没有通过同步机制来排序。Java内存模型JMM视角它违反了happens-before原则导致一个线程可能看到另一个线程部分更新的数据脏读或更新丢失。通俗比喻还是那个电表但这次我们不看充值逻辑只看“写”这个动作。如果两个写操作同时发生硬件层面的存储可能发生交错导致最终存储的数据是损坏的、不可预测的。与竞态条件的关系数据竞争是导致竞态条件的一种常见原因但并非唯一原因。存在数据竞争几乎必然导致未定义行为是必须消除的。在我们的“积分抽奖”场景中我们将主要面对由数据竞争引发的、表现为竞态条件的各类异常。3. 环境准备与项目搭建我们创建一个名为concurrent-bug-demo的 Spring Boot 项目来模拟问题。3.1 技术栈与版本JDK: 17 (LTS版本对虚拟线程等新并发特性有更好支持)Spring Boot: 3.1.x构建工具: Maven数据库: MySQL 8.0 (使用TestContainers在测试中动态运行)测试框架: JUnit 5, TestContainers压力测试工具: Apache JMeter诊断工具: Arthas3.2 项目初始化与核心依赖使用 Spring Initializr 生成项目或手动创建pom.xml。?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.1.5/version relativePath/ /parent groupIdcom.example/groupId artifactIdconcurrent-bug-demo/artifactId version0.0.1-SNAPSHOT/version nameconcurrent-bug-demo/name descriptionDemo for concurrent race condition bug/description properties java.version17/java.version testcontainers.version1.19.1/testcontainers.version /properties dependencies !-- Spring Boot Web -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- Spring Data JPA -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency !-- MySQL Driver -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- Lombok -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency !-- Test Dependencies -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency dependency groupIdorg.testcontainers/groupId artifactIdtestcontainers/artifactId version${testcontainers.version}/version scopetest/scope /dependency dependency groupIdorg.testcontainers/groupId artifactIdjunit-jupiter/artifactId version${testcontainers.version}/version scopetest/scope /dependency dependency groupIdorg.testcontainers/groupId artifactIdmysql/artifactId version${testcontainers.version}/version scopetest/scope /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId configuration excludes exclude groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /exclude /excludes /configuration /plugin /plugins /build /project3.3 数据模型与Repository创建一个简单的“用户积分”实体和抽奖记录实体。// 文件路径src/main/java/com/example/concurrentbugdemo/entity/UserPoints.java package com.example.concurrentbugdemo.entity; import jakarta.persistence.*; import lombok.Data; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import java.time.LocalDateTime; Entity Table(name user_points) Data public class UserPoints { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name user_id, unique true, nullable false) private Long userId; Column(name points, nullable false) private Integer points 0; Version // 乐观锁版本号 private Integer version; CreationTimestamp private LocalDateTime createTime; UpdateTimestamp private LocalDateTime updateTime; }// 文件路径src/main/java/com/example/concurrentbugdemo/repository/UserPointsRepository.java package com.example.concurrentbugdemo.repository; import com.example.concurrentbugdemo.entity.UserPoints; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Optional; Repository public interface UserPointsRepository extends JpaRepositoryUserPoints, Long { OptionalUserPoints findByUserId(Long userId); // 这是一个有问题的自定义更新方法用于模拟bug Modifying Query(UPDATE UserPoints up SET up.points up.points :delta WHERE up.userId :userId) int addPoints(Param(userId) Long userId, Param(delta) Integer delta); }4. 构造“盲盒Bug”有问题的抽奖服务现在我们编写一个存在并发问题的抽奖服务。问题隐藏在“扣减积分并抽奖”这个事务中。// 文件路径src/main/java/com/example/concurrentbugdemo/service/impl/BuggyLotteryService.java package com.example.concurrentbugdemo.service.impl; import com.example.concurrentbugdemo.entity.UserPoints; import com.example.concurrentbugdemo.repository.UserPointsRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import javax.annotation.PostConstruct; import java.util.Random; import java.util.concurrent.ConcurrentHashMap; Service Slf4j RequiredArgsConstructor public class BuggyLotteryService { private final UserPointsRepository userPointsRepository; // 模拟一个缓存存储用户抽奖状态。问题点之一非原子操作。 private final ConcurrentHashMapLong, Boolean userLotteryStatus new ConcurrentHashMap(); // 问题点之二共享的可变Random实例在多线程下可能产生性能问题或序列化问题如果被错误共享 private final Random random new Random(); /** * 有问题的抽奖方法存在多个并发漏洞。 * 1. 检查与扣积分非原子性。 * 2. 使用非线程安全的共享Random虽然Random线程安全但共享可能引发性能瓶颈和序列化错觉。 * 3. 缓存状态管理存在竞态条件。 */ Transactional public String drawLottery(Long userId, Integer cost) { // 漏洞1非原子性的“检查-扣减” UserPoints userPoints userPointsRepository.findByUserId(userId) .orElseThrow(() - new RuntimeException(用户不存在)); if (userPoints.getPoints() cost) { throw new RuntimeException(积分不足); } // 模拟一个耗时操作放大并发窗口 try { Thread.sleep(random.nextInt(50)); // 随机睡眠模拟业务逻辑处理时间 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 漏洞2使用Repository有问题的自定义更新方法它直接执行JPQL更新绕过了乐观锁和对象状态管理。 int updatedRows userPointsRepository.addPoints(userId, -cost); if (updatedRows ! 1) { // 在高并发下可能因为数据竞争导致更新了0行或多行实际上这个JPQL更新在并发下可能产生更新丢失。 log.error(更新积分受影响行数异常: {}, updatedRows); // 这里没有抛异常事务会提交吗会因为Transactional默认只对RuntimeException回滚。 // 这可能导致积分被扣了但下面抽奖没执行。 } // 漏洞3缓存状态竞态条件。两个线程可能同时看到null然后都认为自己可以设置状态。 if (userLotteryStatus.putIfAbsent(userId, true) ! null) { // 如果已经存在理论上应该阻止重复抽奖。但在高并发下putIfAbsent之前的状态判断可能已经失效。 throw new RuntimeException(请勿重复抽奖); } // 模拟抽奖逻辑 int prizeIndex random.nextInt(10); String prize; try { prize getPrize(prizeIndex); // 假设这里可能因为共享Random的状态或内部竞争极小概率下抛出异常 } finally { // 漏洞4finally块中清理状态但如果上面的getPrize抛异常状态可能被清理而积分已扣。 userLotteryStatus.remove(userId); } return prize; } private String getPrize(int index) { String[] prizes {谢谢参与, 10积分, 50积分, 100积分, 优惠券, 实物奖品A, 实物奖品B, 盲盒bug体验券, 系统异常, 未知奖品}; if (index 0 || index prizes.length) { // 模拟一个因随机数异常导致的错误 throw new IllegalArgumentException(奖品索引越界: index); } return prizes[index]; } PostConstruct public void initUser() { // 初始化一个测试用户拥有1000积分 if (userPointsRepository.findByUserId(1L).isEmpty()) { UserPoints user new UserPoints(); user.setUserId(1L); user.setPoints(1000); userPointsRepository.save(user); } } }对应的Controller// 文件路径src/main/java/com/example/concurrentbugdemo/controller/LotteryController.java package com.example.concurrentbugdemo.controller; import com.example.concurrentbugdemo.service.impl.BuggyLotteryService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; RestController RequestMapping(/api/lottery) RequiredArgsConstructor public class LotteryController { private final BuggyLotteryService lotteryService; PostMapping(/draw) public String draw(RequestParam Long userId, RequestParam(defaultValue 100) Integer cost) { return lotteryService.drawLottery(userId, cost); } }应用配置文件application.yml:# 文件路径src/main/resources/application.yml spring: datasource: url: jdbc:mysql://localhost:3306/concurrent_demo?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true jdbc: batch_size: 20 logging: level: com.example.concurrentbugdemo: DEBUG5. 并发测试让“盲盒Bug”稳定复现问题代码已就位。接下来我们需要一种方法来稳定地触发它。单元测试很难模拟真实并发我们将使用JUnit 5 RepeatedTestExecutorService来构造并发场景并用TestContainers提供隔离的数据库环境。5.1 编写并发集成测试// 文件路径src/test/java/com/example/concurrentbugdemo/service/impl/BuggyLotteryServiceConcurrentTest.java package com.example.concurrentbugdemo.service.impl; import com.example.concurrentbugdemo.entity.UserPoints; import com.example.concurrentbugdemo.repository.UserPointsRepository; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import java.util.ArrayList; import java.util.List; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; SpringBootTest Testcontainers // 启用TestContainers支持 Slf4j class BuggyLotteryServiceConcurrentTest { // 定义MySQL容器使用最新MySQL 8镜像 Container static final MySQLContainer? mysql new MySQLContainer(mysql:8.0) .withDatabaseName(concurrent_test) .withUsername(test) .withPassword(test); Autowired private BuggyLotteryService buggyLotteryService; Autowired private UserPointsRepository userPointsRepository; // 在容器启动后动态替换数据源URL DynamicPropertySource static void registerPgProperties(DynamicPropertyRegistry registry) { registry.add(spring.datasource.url, mysql::getJdbcUrl); registry.add(spring.datasource.username, mysql::getUsername); registry.add(spring.datasource.password, mysql::getPassword); } BeforeEach void setUp() { // 每个测试前清理并初始化数据 userPointsRepository.deleteAll(); UserPoints user new UserPoints(); user.setUserId(1L); user.setPoints(1000); userPointsRepository.save(user); log.info(初始化用户积分: 1000); } /** * 模拟10个线程同时抽奖每次抽奖消耗100积分。 * 理论上用户有1000积分最多成功抽奖10次。 * 但由于并发bug可能会出现成功次数超过10次、积分扣成负数、抛出各种异常等情况。 */ RepeatedTest(5) // 重复执行5次观察非确定性 DisplayName(并发抽奖压力测试 - 暴露盲盒Bug) void testConcurrentDrawLottery() throws InterruptedException { final Long userId 1L; final int costPerDraw 100; final int threadCount 10; final int totalDraws 10; // 正好把积分用完 ExecutorService executorService Executors.newFixedThreadPool(threadCount); CountDownLatch startLatch new CountDownLatch(1); CountDownLatch endLatch new CountDownLatch(totalDraws); // 用于收集结果和异常 ListFutureString futures new ArrayList(); AtomicInteger successCount new AtomicInteger(0); AtomicInteger exceptionCount new AtomicInteger(0); ListString exceptions new CopyOnWriteArrayList(); for (int i 0; i totalDraws; i) { FutureString future executorService.submit(() - { try { startLatch.await(); // 所有线程等待统一开始 String result buggyLotteryService.drawLottery(userId, costPerDraw); successCount.incrementAndGet(); log.debug(抽奖成功结果: {}, result); return result; } catch (Exception e) { exceptionCount.incrementAndGet(); String errorMsg String.format([线程%s] %s: %s, Thread.currentThread().getName(), e.getClass().getSimpleName(), e.getMessage()); exceptions.add(errorMsg); log.warn(抽奖异常: {}, errorMsg); return null; } finally { endLatch.countDown(); } }); futures.add(future); } // 统一发令模拟并发请求 startLatch.countDown(); // 等待所有线程执行完毕 endLatch.await(10, TimeUnit.SECONDS); executorService.shutdown(); // 获取最终用户积分 UserPoints finalUserPoints userPointsRepository.findByUserId(userId).orElseThrow(); int finalPoints finalUserPoints.getPoints(); // 输出测试结果 log.info( 测试结果 ); log.info(成功次数: {}, successCount.get()); log.info(异常次数: {}, exceptionCount.get()); log.info(最终用户积分: {}, finalPoints); log.info(出现的异常类型:); exceptions.forEach(log::info); // 断言这里我们故意不设严格断言先观察现象 // 正常情况 successCount 10, finalPoints 0 // 异常情况可能 successCount 10 (超卖), finalPoints 0 (积分透支) log.warn(注意由于存在并发Bug以下断言可能失败这正是我们要分析的); assertTrue(successCount.get() 10, 抽奖成功次数不应超过理论最大值); assertTrue(finalPoints 0, 用户积分不应为负数); // 清理线程池 executorService.shutdownNow(); } }5.2 运行测试并观察“盲盒”现象在IDE中运行上述测试类。由于RepeatedTest(5)它会自动运行5次。观察控制台日志你可能会看到每次运行结果都不一样这就是“盲盒Bug”的体现运行1成功10次异常0次最终积分0。 (看似正常)运行2成功8次异常2次RuntimeException: 请勿重复抽奖最终积分200。 (积分没扣完)运行3成功12次异常1次最终积分-200 (出现了超卖和积分透支严重bug)运行4成功5次异常5次包含IllegalArgumentException: 奖品索引越界、RuntimeException: 积分不足等最终积分500。 (出现了数组越界异常这是共享Random状态被干扰的假象之一)运行5直接抛出了DataIntegrityViolationException或死锁错误。每次点击“运行”就像在抽盲盒你无法预知会看到哪种错误组合。这正是线上复杂并发问题的缩影。6. 诊断与排查使用工具定位问题根源当测试可以稳定复现非确定性错误后我们需要深入诊断。仅靠日志可能不够我们需要更强大的工具。6.1 使用Arthas进行在线诊断Arthas是阿里开源的Java诊断工具非常适合在线排查并发问题。假设我们的Spring Boot应用已启动在8080端口。启动Arthas:java -jar arthas-boot.jar选择对应的Java进程编号。监控方法调用: 在Arthas控制台我们可以监控有问题的drawLottery方法查看调用参数、返回值和异常。watch com.example.concurrentbugdemo.service.impl.BuggyLotteryService drawLottery {params, returnObj, throwExp} -n 5 -x 3这会在方法被调用时打印前5次调用的参数、返回值和异常。查看线程状态和锁竞争:thread # 查看所有线程 thread -b # 找出当前阻塞其他线程的线程查找死锁 dashboard # 查看实时仪表盘观察线程池状态追踪某个特定问题请求: 如果日志显示某个请求出现了“奖品索引越界”我们可以用tt(Time Tunnel)命令记录下该方法的调用详情然后进行详细分析。tt -t com.example.concurrentbugdemo.service.impl.BuggyLotteryService drawLottery -n 100 # 执行一段时间后会记录下100次调用。找到索引越界的那次调用的INDEX。 tt -i INDEX -p # 重新调用该方法并观察执行路径6.2 分析线程转储Thread Dump当应用出现死锁或长时间卡顿时线程转储是救命稻草。可以通过JDK自带的jstack或Arthas获取。# 使用jstack jstack -l pid thread_dump.log # 使用Arthas thread dump /tmp/thread_dump.log打开thread_dump.log搜索BLOCKED、WAITING状态的线程以及deadlock关键词。重点查看我们业务线程池中的线程分析它们卡在哪个锁上。在我们的例子中可能会发现多个线程在等待数据库行锁或userLotteryStatus这个ConcurrentHashMap内部的锁。6.3 精细化日志与TraceId在代码中添加更精细的日志并集成SLF4J MDC为每个请求分配唯一的traceId。这样可以将分散的日志串联起来看清一个请求的完整生命周期尤其是在并发交错时。// 添加一个过滤器或拦截器 Component public class TraceIdFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String traceId UUID.randomUUID().toString().substring(0, 8); MDC.put(traceId, traceId); try { chain.doFilter(request, response); } finally { MDC.clear(); } } }在logback-spring.xml中配置日志模式包含%X{traceId}。这样同一个请求的所有日志都带有相同的traceId便于在日志聚合平台如ELK中筛选和分析。7. 修复“盲盒Bug”从根源解决并发问题诊断完成后我们针对发现的每个漏洞进行修复。7.1 修复漏洞1检查与扣积分非原子性问题先select查询积分再update扣减中间存在时间窗口。解决方案使用数据库的乐观锁或悲观锁在单条SQL中完成条件判断和更新。方案A使用乐观锁推荐并发度高我们的实体UserPoints已经添加了Version注解。修复Service中的逻辑Transactional public String drawLotteryFixed(Long userId, Integer cost) { // 使用乐观锁先查询带版本号的对象 UserPoints userPoints userPointsRepository.findByUserId(userId) .orElseThrow(() - new RuntimeException(用户不存在)); if (userPoints.getPoints() cost) { throw new RuntimeException(积分不足); } // 在内存中计算新值 userPoints.setPoints(userPoints.getPoints() - cost); try { // save方法会检查版本号如果版本号不一致数据已被其他事务修改会抛出ObjectOptimisticLockingFailureException userPointsRepository.save(userPoints); } catch (ObjectOptimisticLockingFailureException e) { // 乐观锁冲突重试或返回特定错误 log.warn(积分扣减发生乐观锁冲突用户ID: {}, userId); throw new RuntimeException(系统繁忙请重试); } // ... 后续抽奖逻辑 }方案B使用悲观锁SELECT ... FOR UPDATEQuery(SELECT up FROM UserPoints up WHERE up.userId :userId) Lock(LockModeType.PESSIMISTIC_WRITE) // 添加悲观写锁 OptionalUserPoints findByUserIdForUpdate(Long userId);然后在事务中先调用此方法锁定行再进行判断和更新。注意这会降低并发度可能增加死锁风险。7.2 修复漏洞2有问题的JPQL更新方法问题addPoints方法直接执行JPQL更新绕过了JPA的一级缓存和乐观锁机制可能导致更新丢失。解决方案弃用自定义JPQL更新统一使用乐观锁方案方案A或使用Modifying查询时确保条件包含版本号。如果必须用JPQL应改为Modifying Query(UPDATE UserPoints up SET up.points up.points :delta, up.version up.version 1 WHERE up.userId :userId AND up.version :currentVersion) int addPointsWithVersion(Param(userId) Long userId, Param(delta) Integer delta, Param(currentVersion) Integer currentVersion);调用时需传入当前版本号并根据返回值判断是否更新成功。7.3 修复漏洞3缓存状态竞态条件问题ConcurrentHashMap的putIfAbsent虽然是原子的但我们的业务逻辑检查积分、扣积分、设置状态不是原子的。可能在设置状态前另一个线程已经走完了大部分流程。解决方案将“扣积分”和“设置抽奖状态”合并为一个原子操作。最简洁的方式是利用数据库的唯一约束或分布式锁。这里我们用数据库实现新建一张user_lottery_record表有id,user_id,lottery_time,prize等字段。在user_id和lottery_time比如按天上创建唯一索引。抽奖业务逻辑变为开启事务。扣减积分使用乐观锁。插入抽奖记录。如果因为唯一约束冲突插入失败则回滚事务并返回“请勿重复抽奖”。提交事务。 这样数据库的唯一约束保证了幂等性整个操作在事务内是原子的。7.4 修复漏洞4finally块中的状态清理问题问题如果getPrize抛异常状态被清理但积分已扣导致用户积分扣除却无奖品。解决方案将状态清理移到事务提交后或者将整个方法包括状态管理放在一个分布式事务语义中。更简单的做法是依赖数据库事务。如果抽奖逻辑失败我们直接抛出异常让SpringTransactional回滚整个事务积分扣减也会回滚。这样缓存状态就不应该先于事务提交而被设置。或者使用“预占-确认”模式先设置一个“处理中”状态事务成功后再改为“已完成”失败则清理。7.5 修复漏洞5共享Random实例问题虽然Random是线程安全的但在高并发下共享一个实例可能成为性能瓶颈内部CAS竞争。更关键的是它可能给开发者造成“这个类线程安全”的错觉而忽略了其内部状态变化对业务逻辑的间接影响如我们模拟的索引越界虽然概率极低。解决方案对于简单的随机数需求使用ThreadLocalRandom.current()。它是每个线程独立的性能更高。int prizeIndex ThreadLocalRandom.current().nextInt(10);修复后的核心方法概览Service Slf4j RequiredArgsConstructor public class FixedLotteryService { private final UserPointsRepository userPointsRepository; private final LotteryRecordRepository lotteryRecordRepository; // 新的抽奖记录Repo Transactional(rollbackFor Exception.class) // 明确指定所有异常都回滚 public String drawLotteryFixed(Long userId, Integer cost) { // 1. 乐观锁扣减积分 UserPoints userPoints userPointsRepository.findByUserId(userId) .orElseThrow(() - new BusinessException(用户不存在)); if (userPoints.getPoints() cost) { throw new BusinessException(积分不足); } userPoints.setPoints(userPoints.getPoints() - cost); try { userPointsRepository.save(userPoints); // 依赖乐观锁 } catch (ObjectOptimisticLockingFailureException e) { throw new BusinessException(系统繁忙请重试); } // 2. 插入抽奖记录利用数据库唯一约束防重 LotteryRecord record new LotteryRecord(); record.setUserId(userId); record.setLotteryTime(LocalDate.now()); // 按天防重 record.setCost(cost); try { lotteryRecordRepository.save(record); } catch (DataIntegrityViolationException e) { // 唯一约束冲突说明今天已抽过 throw new BusinessException(今日已抽奖); } // 3. 执行抽奖逻辑使用ThreadLocalRandom int prizeIndex ThreadLocalRandom.current().nextInt(PRIZES.length); String prize PRIZES[prizeIndex]; record.setPrize(prize); lotteryRecordRepository.save(record); // 更新奖品信息 return prize; } // ... 其他代码 }8. 验证修复编写稳定的并发测试修复后我们修改之前的测试类使用FixedLotteryService并增强断言。Test DisplayName(并发抽奖压力测试 - 验证修复) void testConcurrentDrawLotteryFixed() throws InterruptedException { // ... 相同的并发测试框架设置 ... // 将 buggyLotteryService 替换为 fixedLotteryService // ... // 修改断言现在结果必须是确定性的 assertEquals(10, successCount.get(), 成功次数必须等于理论最大值); assertEquals(0, exceptionCount.get(), 异常次数必须为0); assertEquals(0, finalPoints, 最终积分必须为0); assertTrue(exceptions.isEmpty(), 不应出现任何异常); // 还可以验证数据库记录数 long recordCount lotteryRecordRepository.countByUserId(userId); assertEquals(10, recordCount, 抽奖记录数必须为10); }多次运行此测试结果应该始终一致成功10次异常0次积分归零。盲盒被打开了里面的奖品变得确定。9. 最佳实践与工程建议通过这个案例我们可以总结出避免“盲盒式Bug”的工程最佳实践识别共享状态时刻警惕你的服务中是否存在被多个线程或请求共享的可变数据静态变量、缓存、数据库行、文件等。缩小同步范围如果必须共享使用最细粒度的锁或并发容器如ConcurrentHashMap并尽量缩短持有锁的时间。优先使用无状态设计这是根本解决方案。让每次请求处理不依赖于上一次请求留下的状态。如果必须有状态将其外置到数据库、Redis等外部存储并利用其原子操作如Redis的INCR/DECR、SETNX或事务。数据库是最后的防线乐观锁适用于读多写少冲突不频繁的场景。通过Version实现。悲观锁适用于写多冲突频繁的场景。使用SELECT ... FOR UPDATE。唯一约束实现幂等性的利器。事务隔离级别理解READ COMMITTED、REPEATABLE READ、SERIALIZABLE的区别根据业务选择。设计可重试的幂等接口对于支付、扣减等关键操作通过唯一业务号如订单号操作类型保证即使重复请求也不会产生副作用。完善的监控与告警监控数据库慢查询、锁等待。监控应用线程池活跃度、队列大小。监控关键业务的成功率、耗时分布。对异常比例飙升建立告警。混沌工程在测试环境定期注入延迟、异常模拟网络抖动、依赖服务失败提前发现系统的脆弱点。“盲盒Bug”并不可怕可怕的是对其视而不见或心存侥幸。通过严谨的并发设计、完善的测试策略包括并发测试和强大的排查工具我们可以将系统的确定性牢牢掌握在自己手中。从今天起审视你代码中的共享变量用并发测试验证你的核心业务流程让每一次部署都胸有成竹而非忐忑地等待“盲盒”开启。
返回列表