看到这个标题先别急着吃瓜这个“后宫”并不是八卦故事而是我最近在线上系统里遇到的一类并发问题。多个“鉴定员工号”同时抢占同一条鉴定记录就像在同一个院子里各执一词互不相让最终后台日志直接“起火”——死锁、更新丢失、锁等待超时全部扎堆出现。这篇文章就用“鉴定师后宫起火”这个场景从并发竞争、事务隔离、锁机制一直讲到完整排查流程和代码改造方案。如果你是后端开发或者正在负责会同时被多个角色、多个线程、多个实例操作的业务模块这篇文章会很实用。1. 背景与核心概念1.1 “后宫起火”到底是什么意思先说结论在技术链路里“后宫”可以理解为一组共享资源比如数据表里的同一行记录、Redis 里的同一个 key、一批被多个线程同时消费的任务。而“鉴定师”则是同时对这些资源发起操作的多个线程、多个微服务实例或者多个用户。当多个服务实例同时执行一条同样的更新 SQL 时如果系统没有做并发控制就会出现类似“后宫争抢”的场面。每个线程都认为自己是最终负责人都在往同一条数据上写内容最后谁都没错但数据却错了。举个例子一个鉴定系统中存在一张appraise_record表记录的是每件商品的鉴定单状态 0待鉴定状态 1鉴定中状态 2鉴定完成正常情况下一张鉴定单只能被一个鉴定师领取。如果代码里漏了状态判断或者完全没有加锁两个鉴定师同时查看这张单都会发现状态是 0然后同时把它改成鉴定中。结果就是后面提交的人覆盖了前面的人业务记录错乱数据对不上账。这种情况一旦发生在高并发环境下就会引发更严重的连锁反应行锁相互等待、事务回滚、死锁、接口超时甚至数据库连接池被打满。线上表现就是“起火了”日志一片红。1.2 常见并发问题类型问题类型现象根因更新丢失两次更新后结果只保留一次未加锁先读后写被覆盖脏读读到另一个事务未提交的数据隔离级别过低或使用不当不可重复读同一条 SQL 在事务内两次查询结果不同其他事务在中间修改并提交幻读同一个查询条件下两次得到不同行数其他事务在中间插入或删除死锁两个事务互相等待对方的锁加锁顺序不一致锁等待超时业务报错 Lock wait timeout exceeded持锁事务长时间未提交其中更新丢失、死锁和锁等待超时是“鉴定服务”后台最容易出现的三种火灾。后面我会逐一用代码场景拆解。1.3 为什么会发生“火灾”事务隔离与锁在 MySQL InnoDB 引擎下默认隔离级别是 REPEATABLE READ它主要解决的是不可重复读问题但解决不了更新丢失。更新丢失需要依靠锁来控制并发。数据库锁按类型可以粗分为共享锁S Lock读锁多个事务可以同时持有。排他锁X Lock写锁一个事务持有后其他事务不能同时持锁。按策略又可以分为悲观锁先锁住资源再操作别人只能等。乐观锁先操作提交时校验版本冲突则失败重试。很多后台服务的并发问题本质上都是“没锁”或者“锁没锁对”。比如下面这个典型错误代码// 错误示例先查询再更新中间没有任何锁 AppraiseRecord record getById(recordId); if (record.getStatus() ! AppraiseStatus.WAITING) { throw new BusinessException(不能领取); } record.setStatus(AppraiseStatus.PROCESSING); record.setAppraiserId(appraiserId); updateById(record);这段代码在低并发下看起来一点问题都没有。一旦多个线程同时执行getById它们都会读到 status0都认为自己有资格修改。最终数据到底是什么样完全取决于数据库执行顺序无法预测也无法兜底。2. 环境准备与版本说明如果要在本地复现文章中的场景可以参考下面的环境操作系统Windows / macOS / Linux 均可数据库MySQL 8.xInnoDB 引擎开发语言Java 8 或 Java 11开发框架Spring Boot 2.x持久层MyBatis 或 MyBatis-Plus 均可构建工具Maven 3.6并发测试工具curl、JMeter 或 Postman需要注意Spring Boot 3.x 要求 Java 17 及以上。如果你本机是 Java 8建议使用 Spring Boot 2.7 系列如果是 Java 17则可以选用 Spring Boot 3.x。下面示例以 Spring Boot 2.x 为主关键代码不受版本影响。项目结构建议如下appraise-demo ├── pom.xml ├── src │ └── main │ ├── java/com/example/appraise │ │ ├── common │ │ ├── controller │ │ ├── entity │ │ ├── mapper │ │ └── service │ └── resources │ ├── application.yml │ └── mapper3. 核心问题拆解三种经典“着火”场景3.1 场景一更新丢失更新丢失是指两个事务同时读到同一份旧数据各自修改之后写回后写的事务覆盖了先写的事务导致一次更新丢失。模拟 SQL 如下-- 事务 A BEGIN; SELECT * FROM appraise_record WHERE id 100; -- 假设 status 0 UPDATE appraise_record SET status 1, appraiser_id 1001 WHERE id 100; COMMIT; -- 事务 B BEGIN; SELECT * FROM appraise_record WHERE id 100; -- 依然读到 status 0 UPDATE appraise_record SET status 1, appraiser_id 1002 WHERE id 100; COMMIT;两个事务都以为自己是第一个领取的人最后appraiser_id是 10021001 的领单操作被悄悄覆盖。这类问题最经典的解决方案是乐观锁在表中增加version字段更新时校验版本号UPDATE appraise_record SET status 1, appraiser_id 1002, version version 1 WHERE id 100 AND version 0 AND status 0;如果影响行数为 0说明版本已经变化当前操作可以安全失败。3.2 场景二死锁死锁是指两个或多个事务互相持有对方需要的锁互相等待谁都执行不下去。InnoDB 会检测到死锁并自动回滚其中一个事务但这仍然会造成接口报错和业务失败。看一个简单的 Java 多线程死锁示例// 文件路径src/main/java/com/example/appraise/DeadLockDemo.java public class DeadLockDemo { private static final Object LOCK_A new Object(); private static final Object LOCK_B new Object(); public static void main(String[] args) { Thread t1 new Thread(() - { synchronized (LOCK_A) { System.out.println(线程1拿到锁A); try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } synchronized (LOCK_B) { System.out.println(线程1拿到锁B); } } }); Thread t2 new Thread(() - { synchronized (LOCK_B) { System.out.println(线程2拿到锁B); try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } synchronized (LOCK_A) { System.out.println(线程2拿到锁A); } } }); t1.start(); t2.start(); } }线程 1 先拿锁 A 再拿锁 B线程 2 先拿锁 B 再拿锁 A两者僵持程序无法继续运行。数据库场景同理两个事务分别按不同顺序更新两条记录比如事务 A 先更新 id1 再更新 id2事务 B 先更新 id2 再更新 id1就很容易互相锁住。3.3 场景三锁等待超时锁等待超时比死锁更常见。一个事务拿到了某行记录的锁但迟迟没有提交或回滚其他事务只能无限等待直到超过innodb_lock_wait_timeout。MySQL 默认为 50 秒也就是说一个事务锁住了资源其他事务最多等 50 秒就会报错Lock wait timeout exceeded; try restarting transaction实际业务中事务内如果包含第三方接口调用、文件上传、报表计算等耗时操作持锁时间会变得很长锁等待超时就更容易发生。这种问题的根治思路不是调大超时时间而是缩短事务执行时间保证“快进快出”。4. 完整实战案例一个鉴定服务的并发改造下面通过一个完整案例演示如何对一个“领取鉴定单”的接口做并发改造。改造前存在并发问题改造后分别提供悲观锁、乐观锁两种方案。4.1 需求与改造目标核心需求同一张鉴定单只能被一个鉴定师领取。首次领取后状态变为鉴定中其他人再请求时必须失败。在高并发场景下不能出现多个人同时领取成功。改造目标方案一使用SELECT ... FOR UPDATE悲观锁。方案二使用version乐观锁。最后用并发请求验证效果。4.2 建表与初始数据CREATE DATABASE IF NOT EXISTS appraise_demo DEFAULT CHARACTER SET utf8mb4; USE appraise_demo; CREATE TABLE appraise_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, order_no VARCHAR(64) NOT NULL COMMENT 业务单号, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态 0待鉴定 1鉴定中 2已完成, appraiser_id BIGINT DEFAULT NULL COMMENT 鉴定师ID, version INT NOT NULL DEFAULT 0 COMMENT 版本号, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no) ) ENGINE InnoDB COMMENT 鉴定记录表; INSERT INTO appraise_record (id, order_no, status) VALUES (1, APP20250101001, 0);4.3 引入依赖在pom.xml中引入 Spring Boot Web、MyBatis-Plus、MySQL 驱动等依赖。dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency /dependencies注意 MyBatis-Plus 具体版本需要和朋友项目保持兼容3.5.3是常见版本请以实际仓库可用版本为准。application.yml配置如下server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/appraise_demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl4.4 编写实体与 Mapper// 文件路径src/main/java/com/example/appraise/entity/AppraiseRecord.java package com.example.appraise.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDateTime; TableName(appraise_record) public class AppraiseRecord { TableId(type IdType.AUTO) private Long id; private String orderNo; private Integer status; private Long appraiserId; private Integer version; private LocalDateTime createTime; private LocalDateTime updateTime; public Long getId() { return id; } public void setId(Long id) { this.id id; } public String getOrderNo() { return orderNo; } public void setOrderNo(String orderNo) { this.orderNo orderNo; } public Integer getStatus() { return status; } public void setStatus(Integer status) { this.status status; } public Long getAppraiserId() { return appraiserId; } public void setAppraiserId(Long appraiserId) { this.appraiserId appraiserId; } public Integer getVersion() { return version; } public void setVersion(Integer version) { this.version version; } public LocalDateTime getCreateTime() { return createTime; } public void setCreateTime(LocalDateTime createTime) { this.createTime createTime; } public LocalDateTime getUpdateTime() { return updateTime; } public void setUpdateTime(LocalDateTime updateTime) { this.updateTime updateTime; } }Mapper 接口增加两个自定义方法// 文件路径src/main/java/com/example/appraise/mapper/AppraiseRecordMapper.java package com.example.appraise.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.appraise.entity.AppraiseRecord; import org.apache.ibatis.annotations.Param; public interface AppraiseRecordMapper extends BaseMapperAppraiseRecord { AppraiseRecord selectByIdForUpdate(Param(id) Long id); int claimWithVersion(Param(id) Long id, Param(appraiserId) Long appraiserId, Param(version) Integer version); }对应的 XML 文件?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.appraise.mapper.AppraiseRecordMapper select idselectByIdForUpdate resultTypecom.example.appraise.entity.AppraiseRecord SELECT id, order_no, status, appraiser_id, version, create_time, update_time FROM appraise_record WHERE id #{id} FOR UPDATE /select update idclaimWithVersion UPDATE appraise_record SET appraiser_id #{appraiserId}, status 1, version version 1 WHERE id #{id} AND version #{version} AND status 0 /update /mapper4.5 编写 Service 层这里给出悲观锁和乐观锁两种实现。真实项目中根据业务并发量选择其中一种即可。// 文件路径src/main/java/com/example/appraise/service/AppraiseService.java package com.example.appraise.service; import com.example.appraise.entity.AppraiseRecord; import com.example.appraise.mapper.AppraiseRecordMapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; Service public class AppraiseService { private final AppraiseRecordMapper appraiseRecordMapper; public AppraiseService(AppraiseRecordMapper appraiseRecordMapper) { this.appraiseRecordMapper appraiseRecordMapper; } /** * 方案一悲观锁 * 先锁住行再更新保证同一时间只有一个事务能拿到锁。 */ Transactional(rollbackFor Exception.class) public boolean claimByPessimisticLock(Long recordId, Long appraiserId) { AppraiseRecord record appraiseRecordMapper.selectByIdForUpdate(recordId); if (record null) { return false; } if (record.getStatus() ! 0) { return false; } record.setAppraiserId(appraiserId); record.setStatus(1); appraiseRecordMapper.updateById(record); return true; } /** * 方案二乐观锁 * 通过 version 和 status 条件限制更新影响行数为 0 说明冲突。 */ Transactional(rollbackFor Exception.class) public boolean claimByOptimisticLock(Long recordId, Long appraiserId, Integer version) { int rows appraiseRecordMapper.claimWithVersion(recordId, appraiserId, version); return rows 1; } }需要注意的是claimByOptimisticLock的version参数应该由客户端在查询时拿到或者由服务端在用户点击领取时从缓存中获取。如果前端已经查询过详情直接把版本号传回来即可。4.6 编写 Controller// 文件路径src/main/java/com/example/appraise/controller/AppraiseController.java package com.example.appraise.controller; import com.example.appraise.service.AppraiseService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; RestController RequestMapping(/appraise) public class AppraiseController { private final AppraiseService appraiseService; public AppraiseController(AppraiseService appraiseService) { this.appraiseService appraiseService; } GetMapping(/claim/pessimistic) public String claimByPessimistic(RequestParam Long recordId, RequestParam Long appraiserId) { boolean success appraiseService.claimByPessimisticLock(recordId, appraiserId); return success ? 领取成功 : 领取失败当前单不可领取; } GetMapping(/claim/optimistic) public String claimByOptimistic(RequestParam Long recordId, RequestParam Long appraiserId, RequestParam Integer version) { boolean success appraiseService.claimByOptimisticLock(recordId, appraiserId, version); return success ? 领取成功 : 领取失败版本冲突或状态不可用; } }4.7 并发测试与验证启动项目后先执行一次查询接口获取初始版本号curl http://localhost:8080/appraise/record?recordId1这里为了演示方便我们已知初始version0。然后发起多个并发请求模拟不同鉴定师同时抢单。先测试悲观锁接口for i in {1..10} do curl http://localhost:8080/appraise/claim/pessimistic?recordId1appraiserId${i} done wait由于悲观锁会对 id1 这一行加锁10 个请求中只有一个能拿到锁其他请求会等待行锁释放后再执行此时会因状态变更为 1 而返回领取失败。再测试乐观锁接口所有请求都传 version0for i in {1..10} do curl http://localhost:8080/appraise/claim/optimistic?recordId1appraiserId${i}version0 done wait乐观锁方案中只有一个 UPDATE 能命中version0 AND status0其余 9 个请求影响行数为 0直接返回失败。最终数据库里应该只剩一条成功记录SELECT id, order_no, status, appraiser_id, version FROM appraise_record WHERE id 1;预期结果的appraiser_id是某一个请求的鉴定师 IDversion变成 1status变成 1。5. 排查“后宫大火”的常用手段真实线上环境里一般不会刚好有本地模拟环境这么清晰。排查并发问题时需要从数据库和应用日志两个方向同时入手。5.1 查看 InnoDB 状态MySQL 自带死锁诊断命令SHOW ENGINE INNODB STATUS;输出内容中重点看LATEST DETECTED DEADLOCK和TRANSACTIONS部分里面会记录死锁事务执行过的 SQL。比如经常会看到lock_mode X waiting、lock_mode X locks rec but not gap这些信息就是当前持锁和等待锁的现场。5.2 查看当前活跃事务SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx;如果发现某个事务长时间处于RUNNING状态而且trx_started时间特别早很可能就是它一直持有锁导致后面的事务全部排队。查看当前数据锁类型可以执行SELECT * FROM performance_schema.data_locks;这样可以定位到具体是哪些表、哪些索引、哪些行被锁住对分析锁等待非常有帮助。5.3 应用层日志关键字排查在应用日志中重点搜索以下关键字Deadlock found when trying to get lockLock wait timeout exceededDuplicate entryTransaction rolled back出现Deadlock found时先打印异常堆栈再结合SHOW ENGINE INNODB STATUS判断锁顺序出现Lock wait timeout时优先排查哪些事务持锁时间过长。5.4 应用层事务耗时排查如果某个事务很慢但数据库侧锁冲突并不严重可以把问题定位到应用代码。常见做法在 Service 方法前后打印耗时。使用 Spring AOP 对Transactional方法做统一计时。打开 MyBatis SQL 日志观察事务内执行的 SQL 数量。如果一次事务执行了 20 条 SQL其中还有更新语句就要想想这些 SQL 是否都能压缩能否把只读查询放在事务外面。6. 常见问题与排查思路下面整理了我遇到的和读者经常问到的几类问题按“现象-原因-解决思路”列出来。问题现象常见原因解决思路两个服务实例同时领取同一单成功没有数据库锁也没有乐观锁增加版本号或使用行锁日志出现 Deadlock found事务间加锁顺序不一致统一加锁顺序业务操作按固定顺序更新记录等锁时间很久持锁事务内有第三方接口、RPC、sleep缩小事务边界把耗时操作移出事务接口偶发 500锁等待超时或死锁回滚捕获异常并提示用户稍后重试同时优化事务更新时影响行数为 0乐观锁版本冲突前端重新拉取最新数据后再次提交更新速度突然变慢更新字段无索引导致锁范围扩大为 where 条件字段建立合适索引请求重复提交用户多次点击按钮或消息重试增加幂等控制利用唯一键或分布式锁这些问题的共同点在于没有把“并发安全”当成第一优先级。很多团队在低并发阶段根本测不出问题一旦流量上来各种诡异现象才会集中爆发。7. 最佳实践与工程建议7.1 事务尽量短小事务持有数据库锁的时间约等于事务执行时间所以事务里千万不要做以下操作调用第三方 HTTP 接口执行耗时较长的算法发送消息队列消息后等待确认无意义的Thread.sleep()正确的做法是先准备数据再开启事务事务内只做必要的查询和更新提交后再发送消息。如果消息发送失败可以通过本地消息表或事务消息兜底。7.2 统一锁顺序数据库死锁绝大多数是“加锁顺序不一致”导致的。多个事务如果都要更新多行数据必须约定一个全局顺序比如按主键从小到大更新。下面是一个体现锁顺序错误的场景-- 事务 A UPDATE appraise_record SET status 1 WHERE id 1; UPDATE appraise_record SET status 1 WHERE id 2; -- 事务 B UPDATE appraise_record SET status 1 WHERE id 2; UPDATE appraise_record SET status 1 WHERE id 1;两个事务互相等待非常容易死锁。改成都按 id 递增顺序更新死锁就能从根源上避免。7.3 合理使用乐观锁和悲观锁乐观锁适合读多写少、冲突概率低的场景实现简单性能好但冲突时需要重试。悲观锁适合写多读少、必须严格控制的场景但是长时间持锁会拖垮并发。判断标准很简单同一行数据同时被修改的概率高不高。如果高用悲观锁或分布式锁如果低用乐观锁更合适。7.4 接口幂等与重复提交在“领取鉴定单”“抢优惠券”“下单”这类场景前置拦截往往是更便宜的手段。可以在接口层做幂等控制比如同一个用户对同一个订单只能提交一次后端用唯一索引兜底。例如在领取记录表加唯一索引CREATE TABLE appraise_claim_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, record_id BIGINT NOT NULL, appraiser_id BIGINT NOT NULL, claim_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_record_appraiser (record_id, appraiser_id) ) ENGINE InnoDB;有了唯一索引后即使请求被重复提交数据库也会拒绝第二条记录。7.5 正确使用索引更新操作要考虑 where 条件是否走索引。InnoDB 行锁是基于索引实现的如果更新没有命中索引数据库会锁住更多记录严重时可能锁表。在appraise_record表中status字段区分度很低单独建索引不一定有好处。实际项目应该根据查询条件设置联合索引比如(status, appraiser_id)。判断索引是否生效可以用EXPLAIN SELECT * FROM appraise_record WHERE status 0 AND appraiser_id 1001;7.6 监控与告警生产环境必须关注这几个指标死锁数量锁等待次数事务平均耗时最长事务耗时数据库活跃连接数一旦出现指标突增立刻结合 SQL 日志和监控面板定位事务内容。并发“火灾”不可怕可怕的是起火后才发现。7.7 变更前备份与灰度如果生产环境需要修改表结构、添加索引、调整事务逻辑强烈建议先在测试环境压测再到生产环境灰度发布。涉及数据订正脚本时必须先备份数据表并且限定生效范围避免全表更新。8. 总结解决“鉴定师后宫起火”这类问题的关键其实可以收敛成一句话把并发控制当作业务需求的一部分而不是上线后补的“防火墙”。本文拆解了更新丢失、死锁、锁等待超时三种典型问题给出了悲观锁和乐观锁两套完整代码改造方案也列出了线上排查时的数据库命令和日志关键字。读完以后你可以对照自己的业务系统先检查所有“先查询再更新”的代码再看事务边界是否过长最后确认更新语句是否走索引。如果你的系统里也有类似的“后宫”场景不妨先画一张数据流图标出所有会同时操作同一行记录的入口再逐一加上锁或版本控制。与其等日志起火再救火不如提前把这些隐患排查干净。如果觉得这篇文章有用可以收藏备用下次遇到并发报错时直接对照排查。