尧图网站建设 尧图网络
  • 首页
  • 关于我们
  • 服务项目
  • 案例展示
  • 建站流程
  • 资讯中心
  • 联系我们
首页/资讯中心/详情

关于幂等性,和mysql行级锁两相互配合实现防超卖(预约系统)

关于幂等性,和mysql行级锁两相互配合实现防超卖(预约系统)
📅 发布时间:2026/7/7 1:42:34

预约下单实战:幂等键 + MySQL 行级锁.

适用场景:MySQL 8.0 + InnoDB · Java Spring@Transactional· 预约 / 库存 / 占座类接口
本文用一份完整的「创建预约单」代码,讲透幂等(idempotencyKey)与SELECT ... FOR UPDATE行级锁,重点说明锁什么时候加、什么时候释放。


前言

做一个「选座位 + 下单」接口,至少要解决两个问题:

  1. 用户双击 / 网络重试:同一次提交不能产生两条订单 →幂等
  2. 两人同时抢同一座:只能成功一个 →行级锁 + 占用校验

很多人写得出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 🔓 释放锁 生命周期结束

六、三种失败路径与锁

失败点错误码锁何时释放
幂等命中—(成功返回旧单)从未加锁
座位停用 / 跨室3010rollback → 释放
时段过期3009rollback → 释放
同时段重复约3003rollback → 释放
每日上限3006rollback → 释放
座位已被占3011rollback → 释放
全部通过0commit → 释放

只要执行过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 里

把「加锁 = 执行到那一行」和「释放 = 事务结束」记住,行级锁的生命周期就不会乱。


相关新闻

  • 在阿里云机器上 安装python3.11
  • 微软计划裁员约4800人,涉及销售和Xbox游戏业务部门
  • 如何快速上手kail_dnn_adapter:从安装到运行的完整指南

最新新闻

  • 轻盈美学合规选品:机构与代理商应关注哪些方面
  • 五新智能:以成套智能装备赋能全球工程建设
  • YOLOv8猫狗品种识别检测系统(项目源码+YOLO数据集+模型权重+UI界面+python+深度学习+环境配置+目标检测)
  • 正规电商页面内嵌支付钓鱼攻击机理与全域协同防御研究
  • 批量译制成本测算:量大采购下的单位成本与套餐模型
  • 如何快速构建企业级Vue3后台系统:Element-Plus-Admin完整实战指南

日新闻

  • Android逆向分析全能助手:集成化工具链与自动化工作流设计
  • 面搜索(Faceted Search)原理与工程实践指南
  • 神经网络调参避坑指南:从5个常见Loss曲线形态定位超参数问题

周新闻

  • 基于YOLOv12的番茄成熟度智能检测系统开发
  • 终极RimWorld模组管理指南:用RimSort告别模组冲突烦恼
  • AI Agent框架开发:从理论到实践的完整指南

月新闻

  • 2026年6月公司网站搭建最新热门渠道测评:四大低成本/零代码平台对比+避坑
  • 【Linux】Linux arm 编译QT程序,出现expected “}“报错
  • 【MATLAB例程】四基站二维AOA定位与距离辅助增强对比仿真。基于角度观测和测距修正的固定目标平面定位精度分析

关于尧图

  • 公司简介
  • 团队介绍
  • 企业文化
  • 荣誉资质

服务项目

  • 定制开发
  • 电商建站
  • UI 设计
  • 运维服务

快速链接

  • 案例展示
  • 建站流程
  • 常见问题
  • 资讯中心

联系方式

  • 📍北京市朝阳区互联网产业园 A 座 10 层
  • 📞400-888-8888
  • ✉️contact@rkmt.cn
  • 🕐周一至周日 9:00-21:00

© 2024 北京尧图网络科技有限公司 版权所有 | 京 ICP 备 XXXXXXXX 号