1. 先分清矛盾类型:编辑撞编辑、导出撞编辑、导出撞导出
很多人一想到"两个用户同时操作订单,后端怎么办",第一反应就是加锁。但锁是分类型的,盲目加锁只会让系统从"偶发冲突"变成"日常卡死"。我当年在处理这类问题时,第一个动作不是写代码,而是把并发场景拆开,确认到底是谁在跟谁抢。
1.1 写-写冲突:两个用户同时编辑同一张订单
这是最典型的一种冲突。用户A和用户B都打开了同一个订单详情页,页面上显示的都是"金额=100、状态=待审核"。A修改了金额为120并提交,B修改了备注"加急处理"并提交。
从数据库层面看,如果没有任何保护,B的提交会把A的修改覆盖掉。因为B的会话里没有A这次修改的信息,B执行的是一条普普通通的 UPDATE order_info SET remark = '加急处理' WHERE id = 12345,这条语句根本不关心金额字段当前是什么值。最后订单变成了"金额=100、备注=加急处理",A的金额修改彻底丢了。
这就是经典的"丢失更新"问题。它本质上是两个读改写操作交错执行导致的,和时间顺序无关,哪怕A和B的提交只差1毫秒,也一样可能发生。
1.2 读-写冲突:一个在编辑,一个在导出
编辑和导出的冲突稍微隐蔽一点。编辑不会直接破坏导出文件,但会让导出结果"不可信"。
举个例子:财务在10:00:00开始导出当天所有订单,导出程序第一次查询拿到了订单A的金额=100,然后继续查其他订单。就在这个过程中,客服在10:00:05修改了订单A的金额为120并提交。导出程序第二次查询订单B时并不受影响,但如果导出逻辑里还有一条关联查询的SQL,比如查订单A的明细,这时拿到的数据可能就是新的。于是导出的报表里,订单A的主表金额可能是100,明细表数据可能是修改后的,整个文件逻辑上是对不上的。
更严重的情况是,导出程序用了 SELECT ... WHERE status = '待审核' 这一类条件,而编辑操作刚好把某个订单的状态从"待审核"改成了"已审核"。那导出中途和导出结束时的结果集本身就不同,用户拿到文件时会疑惑:为什么这个订单的状态和我系统里看到的不一样?
1.3 读-读冲突:两个用户同时导出
读-读其实是天然并发的,两个用户同时导出同一个订单不会产生数据损坏。但如果导出这个操作本身带着"副产物"——比如导出后有更新订单状态的行为,比如"标记为已导出",那它就不再是纯粹的读操作,而是变成了一个"读-改-写"过程,需要按写冲突来处理。
我遇到过这样的业务:需求文档写着"导出后把订单标记为已导出,防止重复导出"。第一次上线时没多想,结果两个运营同时点导出,后一个导出的文件里根本没有订单数据,因为前一个已经把所有符合条件的订单全部标记成"已导出"了。
所以我的建议是:先按"是否有写操作"来划分,而不是按"操作名称"来划分。编辑、导出后改状态、审核、作废,这些只要有写,就都归类到并发写冲突的处理框架里去。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型:乐观锁、悲观锁、分布式锁各自的边界在哪
理清了冲突类型,下一步就是选方案。后端处理并发冲突,逃不开三大类:乐观锁、悲观锁、分布式锁。没有哪个方案是万能的,选错方案比不选方案更致命。
2.1 乐观锁:用版本号做CAS,适合高并发编辑
乐观锁的核心思路是"不主动锁,更新时校验版本"。业务表里加一个version字段,每次更新都把期望版本号放到WHERE条件里,只有版本号匹配才更新成功,同时版本号加1。
sql复制UPDATE order_info
SET amount = 120, version = version + 1
WHERE id = 12345 AND version = 0;
这条SQL的巧妙之处在于,数据库的行锁机制天然保证了原子性。两个用户同时执行同样的语句时,只有一个人能匹配到version=0,另一个人匹配不到,影响行数是0。程序通过判断 updateCount == 0 就能知道这次更新冲突了。
乐观锁的优点是并发性能好,不阻塞其他事务,特别适合订单编辑这种高频、短事务的操作。缺点是需要业务代码配合处理冲突——你不能假装没发生,必须返回提示让用户知道"你正在基于旧数据修改"。
2.2 悲观锁:SELECT FOR UPDATE,强一致但代价高
悲观锁的思路是"先锁住,别人别想碰"。在事务里执行 SELECT ... FOR UPDATE,数据库会给命中的行加排他锁,其他事务的读-写操作会被阻塞,直到当前事务提交或回滚。
sql复制BEGIN;
SELECT * FROM order_info WHERE id = 12345 FOR UPDATE;
-- 业务处理,修改金额、备注等
UPDATE order_info SET amount = 120 WHERE id = 12345;
COMMIT;
这个方案适合对一致性要求极高、写冲突概率很大的场景,比如库存扣减、账户余额变动。用在订单编辑上当然也行,但代价是并发能力急剧下降。想象一个热门订单同时被客服、财务、运营三个人打开操作,悲观锁会让另外两个人一直等待,如果其中一个事务因为别的原因迟迟不提交,后面的人全部卡住。
我的判断标准是:如果你们的订单编辑并发量确实很大,而且大多数编辑操作只是改个备注、改个地址,直接用悲观锁会白白牺牲吞吐量;乐观锁加友好提示已经足够了。
2.3 分布式锁:多实例部署时的重型武器
前面两种锁都建立在"单库单事务"的假设上。一旦系统拆分成多个微服务实例,或者订单库做了分库分表,JVM锁和数据库本地锁就失效了。两个实例同时读到同一个订单,A实例加锁,B实例根本感知不到。这时候需要分布式锁,一般用Redis或者ZooKeeper来实现。
Redis分布式锁的标准做法是 SET lock:order:12345 uuid NX PX 30000,只允许一个客户端加锁成功,并设置过期时间防止客户端宕机后锁永不释放。Redisson这类客户端内置了看门狗机制,会自动续期,避免业务还没执行完锁就过期了。
分布式锁的适用场景是"跨服务、跨实例"的互斥操作,比如两个不同服务都要处理订单导出任务。但要注意,分布式锁是AP模型下的妥协产物,是在没有数据库事务兜底的情况下做的互斥,不能替代事务本身。
2.4 三种方案的快速对比
| 方案 | 核心机制 | 适用场景 | 并发度 | 主要代价 |
|---|---|---|---|---|
| 乐观锁 | 版本号CAS | 编辑、更新高频短事务 | 高 | 冲突后需重试或提示 |
| 悲观锁 | SELECT FOR UPDATE | 强一致、写冲突概率高 | 低 | 阻塞其他事务,易锁等待 |
| 分布式锁 | Redis/ZK互斥锁 | 多实例部署、跨服务互斥 | 中 | 锁管理复杂,需考虑续期和误删 |
3. 编辑冲突主方案落地:订单表加version并用CAS更新
如果标题里这个场景让我来设计,编辑订单这一块我会毫不犹豫选乐观锁。下面是一套完整的落地实现,不只是代码片段,还包括表结构、异常处理、前端交互的完整闭环。
3.1 表结构设计
在订单主表增加version字段,这是乐观锁的基石。金额、状态等业务字段正常设计,但注意version字段必须有默认值,否则老数据会全部更新失败。
sql复制CREATE TABLE order_info (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL COMMENT '订单号',
amount DECIMAL(10,2) NOT NULL COMMENT '订单金额',
status TINYINT NOT NULL COMMENT '订单状态:1-待审核 2-已审核',
remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_order_no (order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';
这里的version字段用int就够,不用考虑性能,因为每个订单的更新频率有限。真正需要关注的是 updated_at 用数据库自动更新,这样排查冲突时能快速知道这个订单上次是什么时候变的。
3.2 Spring Boot的CAS更新实现
实体类里加version字段:
java复制@Data
public class OrderInfo {
private Long id;
private String orderNo;
private BigDecimal amount;
private Integer status;
private String remark;
private Integer version;
}
更新SQL必须使用version条件,这是整个方案的命门。用MyBatis的XML写:
xml复制<update id="updateByIdWithVersion">
UPDATE order_info
SET amount = #{amount},
status = #{status},
remark = #{remark},
version = version + 1
WHERE id = #{id}
AND version = #{version}
</update>
Service层要捕获"影响行数为0"的情况:
java复制@Service
public class OrderService {
@Resource
private OrderMapper orderMapper;
public void updateOrder(OrderUpdateRequest request) {
OrderInfo current = orderMapper.selectById(request.getOrderId());
if (current == null) {
throw new BizException("订单不存在");
}
// 把前端传来的version原样放到更新条件里
OrderInfo updateObj = new OrderInfo();
updateObj.setId(request.getOrderId());
updateObj.setAmount(request.getAmount());
updateObj.setStatus(request.getStatus());
updateObj.setRemark(request.getRemark());
updateObj.setVersion(request.getVersion());
int count = orderMapper.updateByIdWithVersion(updateObj);
if (count == 0) {
// 这里不要自动重试,直接告诉前端冲突
throw new ConflictException("该订单已被其他同事修改,请刷新后重试");
}
}
}
有个细节容易踩坑:前端必须把"用户打开订单时拿到的version"原样传回后端,而不是后端查一下最新version再更新。如果后端重新查一遍version再更新,那乐观锁等于没加,因为你永远拿的是"最新版本",而别人的修改在查询之后、更新之前就被悄悄吞掉了。
3.3 冲突后的返回处理与前端交互
后端检测到冲突后,不要返回200,建议返回HTTP 409 Conflict,body里带提示信息。前端拿到这个状态码后停掉loading,弹出提示"订单已被他人修改,请点击刷新查看最新数据",同时保留用户已填写的部分内容,不要直接清空表单。
更友好的做法是:提示里带上"修改人和修改时间"。前端传version时,后端在冲突异常里附带最新数据:
java复制@ExceptionHandler(ConflictException.class)
public ResponseEntity<Map<String, Object>> handleConflict(ConflictException e) {
Map<String, Object> body = new HashMap<>();
body.put("code", 409);
body.put("message", e.getMessage());
body.put("latestVersion", orderMapper.selectById(e.getOrderId()).getVersion());
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
}
3.4 为什么放弃悲观锁做编辑?一个压测数据
我之前在订单后台做对比测试,同一个订单的编辑接口,走悲观锁时,200个并发线程下平均响应时间从80ms涨到600ms,数据库锁等待次数飙升。切到乐观锁后,绝大部分请求都能直接成功,只有极少数冲突返回409。这个结果符合预期,因为绝大多数用户的编辑操作是"改改备注、改改地址"这种低冲突概率场景,用悲观锁给所有请求统一排队,纯属浪费。
4. 导出一致性处理:事务快照与异步导出的取舍
编辑冲突解决了,剩下的是导出。要么保证导出数据一致,要么允许导出数据有一点滞后但要逻辑自洽。下面两个思路可以组合使用。
4.1 导出与编辑并发时的脏读问题
先明确一点:导出过程中的"脏读"指的不是事务隔离级别里的未提交读,而是"读到了逻辑上不一致的数据"。比如主表是最新的,明细表是旧的,或者导出前半段是修改前、后半段是修改后。
SELECT 单独看不会被未提交事务的修改影响,只要隔离级别是READ COMMITTED及以上。真正危险的是导出逻辑由多条SQL组成,各条SQL分别读取,每条之间数据库状态可能已经变化。这就像拍一张合影,一个人抓拍一张,结果每个人的表情都不是同一个瞬间的状态。
4.2 使用事务和REPEATABLE READ隔离级别做一致性快照
MySQL的InnoDB引擎有个特性:在REPEATABLE READ隔离级别下,事务内第一次执行普通SELECT时生成一致性快照,后续所有普通SELECT都从快照读取,不会被其他事务已提交的修改影响。利用这个特性,可以把导出的查询逻辑全部放进一个事务里。
java复制@Service
public class OrderExportService {
@Resource
private PlatformTransactionManager transactionManager;
public void exportOrders(Long operatorId, Long orderId) {
// 手动开启事务
TransactionTemplate template = new TransactionTemplate(transactionManager);
template.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ);
template.execute(status -> {
// 第一条查询建立快照
List<OrderInfo> orders = orderMapper.selectOrdersForExport(operatorId, orderId);
// 后续关联查询全部基于快照
List<OrderItem> items = orderItemMapper.selectItemsByOrderId(orderId);
// 生成导出文件
generateFile(orders, items);
return null;
});
}
}
关键点是:事务必须包含"第一次查询到所有查询结束"的完整过程,中间不能执行外部接口调用或大循环,否则事务会被拖得很长,数据库连接被占住不放。导出量小、订单关联数据少的时候,这种方式最简单可靠。
4.3 大订单导出的异步化改造
如果导出数据量大,把所有查询放进一个大事务就不合适了。一个5万行的订单明细表,光生成Excel就可能超过10秒,事务占着连接不说,快照还要占用undo log空间,搞不好就把数据库拖垮。
实际项目里我更喜欢异步导出方案:用户点击导出后,后端创建一个导出任务,返回"导出任务已创建",后台线程去处理数据和生成文件,用户刷新页面看到任务完成后下载文件。
异步导出对一致性要求可以放宽,但至少要保证"逻辑不矛盾"。我的做法是:导出线程启动时先取出订单主表的当前版本号,在后续每个查询条件里都带上"版本号等于该值且更新时间不超过导出开始时间",这样即使订单被编辑,导出的还是导出开始时刻的数据,而不是边导边变的混杂数据。
4.4 导出后标记已导出的状态机处理
如果导出操作本身要修改订单状态,那它就会退化成写操作。处理方式很简单:把这个状态修改也纳入乐观锁或悲观锁的保护。比如:
java复制@Transactional
public void markOrderExported(Long orderId, Integer version) {
int count = orderMapper.markExportedWithVersion(orderId, version);
if (count == 0) {
throw new ConflictException("订单状态已变化,导出操作需要重新生成");
}
}
流程变成:先做数据导出,再更新订单状态。如果状态更新失败,说明别人已经改过订单,这时候宁可让用户重新导出一次,也不能让两个操作互相污染。
5. 真实踩坑记录:锁粒度、死锁、重试熔断一个都不能少
方案设计得再漂亮,落到真实场景还是会遇到各种稀奇古怪的问题。这一节我记录几个自己踩过、也帮别人排查过的坑,每一个都对应线上事故级的教训。
5.1 锁粒度:千万不要锁整个订单表
有个同事图省事,直接在Service方法上加了 synchronized 关键字,心想"反正所有编辑操作都要经过这个方法,锁住就完事了"。结果上线后整个订单编辑接口的并发能力降到0——所有订单、所有用户的操作被强行串行化。一个订单的操作慢两秒,其他订单全部跟着排队。
正确的锁粒度一定是"订单ID"这一行,甚至只需要锁住 WHERE id = ? 命中的行。数据库的行锁本身就是这个粒度,乐观锁的version条件更是天然的行级控制。只有在分布式场景下,才需要把锁的key设计成 order:edit:12345 这种带订单ID的形式。
5.2 死锁:加锁顺序不一致是最大元凶
订单编辑和明细编辑同时发生时,很可能一个事务先锁主表再锁明细表,另一个事务先锁明细表再锁主表,两个事务互相等对方释放锁,直接死锁。数据库会检测到死锁并回滚其中一个事务,但你的代码要处理好这个异常,不要让它变成500错误。
避免死锁的方法很土但很有效:所有涉及多表更新的业务,严格按相同顺序加锁。比如规定"先更新主表,再更新明细表"。如果业务复杂,可以按表名的字典序排列加锁顺序。
另一个重要的点是设置合理的 innodb_lock_wait_timeout。默认50秒在订单这种高频业务里太长了,用户等50秒才看到一个报错,体验极差。我通常设为3秒,超过3秒直接抛出"系统繁忙,请稍后重试",配合前端提示,用户刷新一下就行。
5.3 Redis分布式锁的续期与误删
用Redis锁做订单导出互斥时,有个经典bug:线程A加锁后业务执行超过锁的过期时间,锁自动过期释放;线程B拿到锁开始执行;线程A执行完业务后执行 del lock,直接把线程B的锁删了。这时候线程C又能拿到锁,相当于同时有三个线程在跑,分布式锁形同虚设。
解决误删的办法是:删除锁之前校验value是否还是自己的UUID。Redisson库已经内置了这个逻辑,同时通过看门狗自动续期,避免锁过期。如果不用Redisson,至少要自己写两件事:加锁时value用UUID,删除时用Lua脚本比较再删。
lua复制if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
5.4 乐观锁冲突后的重试策略
乐观锁返回冲突后,最常见的错误是"立刻重试"。如果两个高并发进程互相修改同一订单,冲突方拿到409后马上重试,可能再次冲突,又重试,几个来回就把数据库打满了。最离谱的一次,60个用户同时操作同一个订单,数据库上被乐观锁重试打出了几百条慢查询。
正确的重试策略是:最大重试次数设为3次以内,每次重试间隔做指数退避,比如第一次等100ms,第二次等200ms,第三次等400ms。重试间隙重新读取最新version,再发起更新。如果3次后仍然冲突,直接放弃重试,返回409给前端,让用户手动刷新。不能为了自动解决冲突,把系统搞成自我攻击。
java复制private static final int MAX_RETRY_COUNT = 3;
private static final long BASE_INTERVAL_MS = 100L;
public void updateOrderWithRetry(OrderUpdateRequest request, int retryCount) {
try {
updateOrder(request);
} catch (ConflictException e) {
if (retryCount >= MAX_RETRY_COUNT) {
throw e;
}
// 重新读取最新版本
OrderInfo latest = orderMapper.selectById(request.getOrderId());
request.setVersion(latest.getVersion());
long waitMs = BASE_INTERVAL_MS * (1L << retryCount);
try {
Thread.sleep(waitMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
updateOrderWithRetry(request, retryCount + 1);
}
}
6. 并发验证:用压测和监控数据说服自己
方案写完不等于工作结束。要想确认"两个用户同时编辑和导出订单"真的能被正确兜住,必须做并发验证。靠人肉点页面根本测不出来,因为时间窗口太短。
6.1 并发测试场景设计
我用JMeter搭过一套订单并发冲突的测试场景,核心就三类:
第一个场景是写-写冲突:两个线程同时对一个订单ID发起编辑请求,请求参数里的version相同。预期结果是其中一个返回200,另一个返回409。为了拉大冲突概率,可以在两个请求之间加10ms延迟,模拟真实网络。
第二个场景是组合并发:30个线程同时编辑同一个订单,每个线程请求里带不同的金额和备注。验证最终数据库里的数据一定来自其中一次完整提交,不会出现"金额是A的、备注是B的"这种拼接产物,同时观察每秒完成的事务数和冲突比例。
第三个场景是编辑与导出混跑:一个线程持续修改订单状态,另一个线程持续导出订单报表。导出完成后校验文件里的订单状态和快照时间点是否一致,不允许出现"导出到一半时状态改变"导致的半新半旧数据。
6.2 关键监控指标
压测过程中不要只盯着接口响应时间,还需要拉数据库监控,重点看三类指标:
- 锁等待次数和锁等待时长:这能反映悲观锁或行锁是否造成了严重的资源争抢。
- 死锁次数:一旦出现死锁,查看
SHOW ENGINE INNODB STATUS里的LATEST DETECTED DEADLOCK,看加锁顺序是哪一步出了问题。 - 慢查询数量:如果冲突重试逻辑写得不好,大量重复更新会让慢查询数量暴增。
连接池指标也不能忽略。在一个导出大事务 + 高并发编辑的场景里,线程池连接耗尽是最容易出现的事故。我见过一次线上导出,因为导出SQL太慢,连接池的活跃连接数长时间占满,导致用户编辑订单时根本拿不到数据库连接,报"connection is not available"。
6.3 测试结果分析
理想的结果是:高并发编辑场景下,冲突比例控制在10%以内(说明大多数人操作的是不同订单);冲突请求都能正确返回409,不会出现500;平均响应时间在200ms以内;数据库慢查询数量不增长。
如果冲突比例超过20%,说明这个订单被多个用户反复操作的概率很高,需要从业务层面解决——比如运营和财务可能都在人工处理同一批订单,那就该考虑做操作提醒,而不是单纯靠技术对抗。技术方案只是兜底,真正高效的并发治理往往需要业务配合,比如"订单分配"机制,让一个订单同一时间只允许一个客服跟进。
如果测试中发现死锁,不要慌,死锁日志会告诉你哪两个事务在抢哪两行。通常修复方式就是统一加锁顺序,或者在更新主表前先通过 SELECT id FROM order_info WHERE id = ? FOR UPDATE 预占主表锁。这一步特别管用,因为死锁的本质是锁获取顺序不一致,提前抢占就能让后续所有操作按固定顺序排队。
我在实际项目中,压测完还习惯做一次"拔网线测试":模拟分布式锁的持有者进程突然宕机,验证锁能通过过期时间自动释放,业务恢复正常,不会出现所有操作全部卡死的状态。这一步在分布式锁场景下比压测本身更重要,因为宕机是分布式系统里最容易忽视又最致命的情况。
最后想说的是,并发冲突处理没有一个"一劳永逸"的固定答案。编辑用乐观锁、导出用事务快照、跨服务操作加分布式锁,这套组合并不是拍脑袋定的,而是根据每个操作的冲突概率、并发量、一致性要求逐项权衡出来的。你可以照着这套思路改造自己的订单系统,但一定要知道每一把锁、每一个version字段背后到底在防什么,否则出了线上事故,排查起来会更痛苦。
