关于幂等性,和mysql行级锁两相互配合实现防超卖(预约系统)
预约下单实战:幂等键 + MySQL 行级锁.
适用场景:MySQL 8.0 + InnoDB · Java Spring
@Transactional· 预约 / 库存 / 占座类接口
本文用一份完整的「创建预约单」代码,讲透幂等(idempotencyKey)与SELECT ... FOR UPDATE行级锁,重点说明锁什么时候加、什么时候释放。
前言
做一个「选座位 + 下单」接口,至少要解决两个问题:
- 用户双击 / 网络重试:同一次提交不能产生两条订单 →幂等
- 两人同时抢同一座:只能成功一个 →行级锁 + 占用校验
很多人写得出FOR UPDATE,但不清楚:
- 锁是执行到那一行 SQL 才加,还是进方法就加?
- 锁是语句结束就释放,还是事务结束才释放?
- 有了行锁,还要幂等键干什么?
这篇文章用一段真实风格的createOrder代码,按执行顺序把这些问题讲明白。
一、业务与表结构(上下文)
场景:自习室座位预约。用户选择「某时段 + 某座位」,提交后创建一条BOOKED状态的预约单。
1.1 请求体
publicclassCreateOrderRequest{privateLongtimeSlotId;// 时段 IDprivateLongseatId;// 座位 IDprivateStringidempotencyKey;// 幂等键(可选,客户端生成)// getter / setter 省略}HTTP 示例:
POST/api/reservation/orders{"timeSlotId":12,"seatId":3,"idempotencyKey":"550e8400-e29b-41d4-a716-446655440000"}1.2 核心表(节选)
CREATETABLEreservation_seat(idBIGINTUNSIGNEDPRIMARYKEYAUTO_INCREMENT,resource_idBIGINTUNSIGNEDNOTNULL,seat_noVARCHAR(16)NOTNULL,statusTINYINTNOTNULLDEFAULT1COMMENT'1=启用 0=停用')ENGINE=InnoDB;CREATETABLEreservation_order(idBIGINTUNSIGNEDPRIMARYKEYAUTO_INCREMENT,order_noVARCHAR(32)NOTNULL,user_idBIGINTUNSIGNEDNOTNULL,time_slot_idBIGINTUNSIGNEDNOTNULL,seat_idBIGINTUNSIGNEDNOTNULL,statusENUM('BOOKED','CANCELLED','COMPLETED')NOTNULLDEFAULT'BOOKED',idempotency_keyVARCHAR(64)NULLCOMMENT'幂等键',booked_atDATETIMENOTNULL,UNIQUEKEYuk_order_no(order_no),UNIQUEKEYuk_order_idempotency(idempotency_key))ENGINE=InnoDB;二、完整 createOrder 代码(带注释)
@Service@RequiredArgsConstructorpublicclassReservationService{privatestaticfinalintDAILY_ORDER_LIMIT=2;/** * 创建预约单。 * @Transactional:整段逻辑在一个 MySQL 事务里,行锁持有到 commit/rollback。 */@TransactionalpublicOrderResponsecreateOrder(LonguserId,CreateOrderRequestreq){// ── ① 基础参数 ──if(req.getTimeSlotId()==null||req.getSeatId()==null){thrownewBusinessException(3001,"时段不存在或已关闭");}// ── ② 幂等:同一 key 只创建一次 ──if(StringUtils.hasText(req.getIdempotencyKey())){Optional<ReservationOrder>existing=orderRepository.findByIdempotencyKey(req.getIdempotencyKey());if(existing.isPresent()){returntoResponse(existing.get());// 直接返回,不加锁、不 INSERT}}// ── ③ 加载时段、自习室(普通 SELECT,不加行锁)──ReservationTimeSlotslot=slotRepository.findById(req.getTimeSlotId()).orElseThrow(()->newBusinessException(3001,"时段不存在"));ReservationResourceresource=resourceRepository.findEnabledById(slot.getResourceId()).orElseThrow(()->newBusinessException(3007,"自习室不存在或已下架"));// ── ④ 行级锁:执行到这里才加锁 ──ReservationSeatseat=seatRepository.findByIdForUpdate(req.getSeatId()).orElseThrow(()->newBusinessException(3010,"座位不存在或已停用"));// ── ⑤ 业务规则组合校验 ──if(!seat.getResourceId().equals(resource.getId())||seat.getStatus()!=1){thrownewBusinessException(3010,"座位不存在或已停用");}if(slot.getStartDateTime().isBefore(LocalDateTime.now())){thrownewBusinessException(3009,"时段已过期");}if(orderRepository.existsBookedByUserAndSlot(userId,slot.getId())){thrownewBusinessException(3003,"您已预约该时段");}if(orderRepository.countActiveOrdersByUserAndDate(userId,slot.getSlotDate())>=DAILY_ORDER_LIMIT){thrownewBusinessException(3006,"超过每日预约上限");}if(orderRepository.existsBookedBySlotAndSeat(slot.getId(),seat.getId())){thrownewBusinessException(3011,"该座位在该时段已被预约");}// ── ⑥ 插入订单 ──ReservationOrderorder=newReservationOrder();order.setOrderNo("RS"+System.currentTimeMillis());order.setUserId(userId);order.setTimeSlotId(slot.getId());order.setSeatId(seat.getId());order.setStatus("BOOKED");order.setIdempotencyKey(req.getIdempotencyKey());order.setBookedAt(LocalDateTime.now());orderRepository.save(order);returntoResponse(order);// ── ⑦ 方法结束 → Spring 提交事务 → 释放行锁 ──}}2.1 行锁对应的 Mapper SQL
@MapperpublicinterfaceReservationSeatMapperextendsBaseMapper<ReservationSeat>{@Select("SELECT * FROM reservation_seat WHERE id = #{id} FOR UPDATE")ReservationSeatselectByIdForUpdate(@Param("id")Longid);}Repository 封装:
publicOptional<ReservationSeat>findByIdForUpdate(Longid){returnOptional.ofNullable(mapper.selectByIdForUpdate(id));}三、MySQL 行级锁的生命周期(重点)
3.1 两个时间点,不要搞混
| 问题 | 答案 |
|---|---|
| 什么时候加锁? | 执行到findByIdForUpdate、SQLSELECT ... FOR UPDATE发出并执行时 |
| 什么时候释放? | 整个事务commit 或 rollback 时,不是这条 SELECT 执行完就释放 |
进入 @Transactional 方法 → 事务开始(尚未锁座位) 幂等查询、查时段、查自习室 → 无行锁 执行 SELECT ... FOR UPDATE → ✅ 对 reservation_seat 该行加 X 锁 业务校验、exists 查询、INSERT → 🔒 锁一直持有 return / 抛异常 → commit 或 rollback → 🔓 释放锁常见误解纠正:
- ❌「一进
createOrder就加锁」—— 错,前面幂等、查 slot 都不锁座位。 - ❌「
FOR UPDATE语句执行完就释放」—— 错,InnoDB 行锁绑定事务,绑定到 commit/rollback。 - ✅「加锁 = 执行到那一行;释放 = 事务结束」—— 对。
3.2 为什么必须持锁到 INSERT 完成?
若锁在 SELECT 结束就释放:
事务 A:FOR UPDATE → 查占用「无」→ 释放锁 事务 B:FOR UPDATE → 查占用「无」→ 释放锁 事务 A:INSERT 订单 事务 B:INSERT 订单 ← 同一座位卖出两次(超卖)所以:检查占用 + 插入订单必须在同一把锁、同一个事务里完成。
这也是@Transactional和FOR UPDATE必须一起用的原因。
3.3 并发时间线:两人抢同一座位
时间 用户 A 事务 用户 B 事务 ──────────────────────────────────────────────────────────── T1 BEGIN T2 SELECT ... FOR UPDATE seat#3 ✅ 获得行锁 T3 BEGIN T4 SELECT ... FOR UPDATE seat#3 ⏳ 阻塞等待 T5 existsBooked → 无 T6 INSERT order (seat#3, slot#12) T7 COMMIT 🔓 释放锁 T8 ✅ 获得行锁 T9 existsBooked → 已有 BOOKED T10 抛 3011 / ROLLBACK 🔓 释放锁A 成功,B 失败,不会超卖。
3.4 锁的是什么?锁多久?
| 项 | 说明 |
|---|---|
| 锁的对象 | reservation_seat表中id = seatId的那一行(索引记录) |
| 锁的类型 | 排他锁(X),其他事务不能对该行再FOR UPDATE/UPDATE/DELETE |
| 不同座位 | 锁不同行,互不影响,可并行预约 |
| 持锁时长 | 约等于FOR UPDATE到commit之间的代码 + SQL 耗时 |
注意:锁内不要做慢 HTTP、睡线程、发 MQ,否则其他人抢同一座会长时间阻塞。
3.5 没有 @Transactional 会怎样?
若去掉@Transactional,Spring/MySQL 默认每条 SQL 自动提交:
SELECT ... FOR UPDATE → 自动 commit → 锁立刻释放 INSERT → 另起一个事务检查与插入之间出现空档,防超卖失效。
所以:行锁的生命周期 = 事务的生命周期,事务框架不是可选项。
四、幂等键(idempotencyKey)讲清楚
4.1 解决什么问题?
| 场景 | 无幂等 | 有幂等 |
|---|---|---|
| 用户双击「确认预约」 | 可能 2 次 INSERT | 第 2 次返回第 1 次的订单 |
| 网络超时,客户端重试 | 不敢重试,或重复下单 | 同一 key 重试,结果一致 |
| 网关重复投递 POST | 可能重复占座 | 直接返回已有单 |
幂等保证:同一个 idempotencyKey,执行 N 次和执行 1 次,业务结果一样(只有一条订单)。
4.2 谁生成?怎么传?
后端不生成,由客户端在「用户点击确认」时生成一次 UUID,重试时复用同一个:
// 用户点击「确认预约」时constidempotencyKey=crypto.randomUUID();asyncfunctionsubmitOrder(){returnfetch('/api/reservation/orders',{method:'POST',headers:{'Authorization':'Bearer ...','Content-Type':'application/json'},body:JSON.stringify({timeSlotId:12,seatId:3,idempotencyKey,// 重试时仍用这个,不要重新生成}),});}4.3 服务端逻辑(对应代码 ②)
if(StringUtils.hasText(req.getIdempotencyKey())){varexisting=orderRepository.findByIdempotencyKey(req.getIdempotencyKey());if(existing.isPresent()){returntoResponse(existing.get());// 短路返回,不走 FOR UPDATE}}// 否则继续:加锁 → 校验 → INSERT,并把 key 写入订单数据库兜底:
UNIQUEKEYuk_order_idempotency(idempotency_key)即使应用层有 bug,同一 key 也无法插入两行。
4.4 幂等检查为什么在 FOR UPDATE 之前?
同一 key 的第 2 次请求 → 幂等命中 → 直接 return- 不需要再加行锁、再占座,性能更好。
- 第 1 次请求已成功落库,第 2 次只是「查询已有结果」。
4.5 幂等 vs 行锁:各管哪一层
防什么 典型场景 ───── ────────── 幂等 idempotencyKey 同一次请求被重复执行 双击、超时重试 行锁 FOR UPDATE 不同用户并发抢同一座位 两人同时点 A03 业务规则 3003 同用户主动约两次同时段 换座再约 existsBooked 3011 该座该时段已被占用 与行锁配合防超卖它们是叠加关系,不是二选一。
五、按执行顺序走一遍
步骤 代码位置 是否加行锁 说明 ────────────────────────────────────────────────────────────────── 1 参数非空检查 否 timeSlotId / seatId 2 findByIdempotencyKey 否 命中则 return,全程无锁 3 findById(slot/resource) 否 普通快照读 4 findByIdForUpdate(seat) ✅ 加锁 生命周期开始 5 座位归属、时段过期、3003/3006 持锁中 6 existsBookedBySlotAndSeat 持锁中 占用检查 7 INSERT reservation_order 持锁中 8 return 持锁中 9 @Transactional commit 🔓 释放锁 生命周期结束六、三种失败路径与锁
| 失败点 | 错误码 | 锁何时释放 |
|---|---|---|
| 幂等命中 | —(成功返回旧单) | 从未加锁 |
| 座位停用 / 跨室 | 3010 | rollback → 释放 |
| 时段过期 | 3009 | rollback → 释放 |
| 同时段重复约 | 3003 | rollback → 释放 |
| 每日上限 | 3006 | rollback → 释放 |
| 座位已被占 | 3011 | rollback → 释放 |
| 全部通过 | 0 | commit → 释放 |
只要执行过FOR UPDATE,无论成功还是抛异常,锁都在事务结束时释放。
七、开发自检清单
createOrder有@Transactional,且由 Spring 代理调用(非同类自调用)FOR UPDATE的 WHERE 走主键(WHERE id = ?),避免锁全表- 幂等检查在加锁之前,避免重复请求无效抢锁
- 客户端:一次用户操作一个 key;重试不换 key
- 数据库:
idempotency_key有UNIQUE索引 - 事务内无慢 IO,缩短锁持有时间
- 理解:普通
SELECT(exists 查询)是快照读,防超卖靠 FOR UPDATE + 占用检查 + INSERT 同事务
八、面试标准答法
问:createOrder 怎么防超卖?
1. @Transactional 保证「检查 + 插入」原子性。 2. 对目标座位 SELECT ... FOR UPDATE,并发抢同一座时事务串行化。 3. 事务内查 (time_slot_id, seat_id) 是否已有 BOOKED 订单,有则 3011。 4. 锁在 commit/rollback 时释放,不是 FOR UPDATE 语句结束就释放。 5. 可选 idempotencyKey 防同一请求重复提交,与行锁解决不同维度的问题。问:幂等键和行锁有什么区别?
幂等:同一 idempotencyKey 多次调用,只产生一条订单,解决重试和双击。 行锁:不同用户并发写同一座位,只有一个能 INSERT 成功,解决抢座超卖。九、一句话总结
幂等键 → 请求级去重,在加锁之前短路,防「同一操作执行多次」 FOR UPDATE → 执行到该 SQL 时加锁,事务 commit/rollback 时释放 防超卖 → 锁 + 占用检查 + INSERT 必须在同一个 @Transactional 里把「加锁 = 执行到那一行」和「释放 = 事务结束」记住,行级锁的生命周期就不会乱。