Skip to content

事务与锁:一次下单背后发生了什么

更新: 7/19/2026 字数: 0 字 时长: 0 分钟

你写过下单页面:一个"提交订单"按钮,用户点一下,几秒后跳转到"下单成功"。前端看到的就这么简单。但这一次点击背后,后端要在一瞬间完成扣库存、生成订单、扣优惠券、扣余额一连串操作。万一中间某一步失败了怎么办?两个人同时抢最后一件商品会不会都下单成功?用户手抖点了三下会不会生成三个订单? 这些问题的答案,就藏在"事务"和"锁"这两个概念里。这篇从下单按钮讲起,帮你搞懂它们到底在防什么。

一次下单背后发生了什么

一、先拆一下:点一下"提交订单",后端做了几件事

前端 onClick 里就一行 await createOrder(params),但后端接到这个请求后,通常要连着做好几件事:

  1. 校验:商品还在不在、能不能买、优惠券有没有过期。
  2. 扣库存:库存 -1。
  3. 生成订单:插入一条订单记录。
  4. 扣余额/扣优惠券:如果用了余额或券,对应扣减。
  5. 返回结果:告诉前端"成功了"。

关键点在于:这几步必须被当成"一件事"来对待。 想象一下,如果扣了库存、也生成了订单,但扣余额那一步失败了——用户没付钱,库存和订单却已经生成,这就乱套了。要解决这个"要么全做、要么全不做"的问题,靠的就是事务。而多个用户同时下单抢同一件商品带来的混乱,靠的是。下面分别讲。

二、事务:要么都成功,要么都撤销

事务:要么都成,要么都撤

它解决的下单问题:扣了库存却没生成订单

事务(Transaction) 就是把"扣库存 + 生成订单 + 扣余额"这几步捆成一个不可分割的整体:全部成功才算数(提交 / commit),中间任何一步失败,已经做过的操作全部撤销、回到最初状态(回滚 / rollback)。 绝不会出现"扣了库存但没生成订单"这种中间态。

前端最好懂的类比:Promise.all 的"全有或全无"

js
// 类比:三个操作要么一起成功,要么一起失败
try {
  await Promise.all([扣库存(), 生成订单(), 扣余额()]);
  // 全成功 → commit
} catch (e) {
  // 任意一个失败 → 全部回滚,当作什么都没发生
}

Promise.all 里只要有一个 reject,整体就进 catch——事务的思路和它很像:中途出错,前面做的也不作数。用 SQL 表达是这样:

sql
START TRANSACTION;              -- 开启事务
UPDATE stock SET num = num - 1 WHERE id = 100;
INSERT INTO orders (...) VALUES (...);
UPDATE account SET balance = balance - 50 WHERE user_id = 8;
COMMIT;                         -- 三步都 OK,一起生效
-- 中途任何一步报错 → ROLLBACK,三步全部撤销

前端要记住的一句话

事务保证了后端返回给你的结果是"干净"的:接口返回成功,就是这一串操作全都落库了;返回失败,就是全都没发生,不会有"扣了一半"的脏数据。所以前端在拿到下单失败的响应时,可以放心地提示用户"下单失败,请重试",而不用担心"是不是库存已经被扣了"。

三、锁:防止两个人抢同一件商品(超卖)

锁:防止超卖

它解决的下单问题:库存只剩 1 件,却卖出去 2 单

事务解决了"一个人的一串操作"的完整性,但没解决"多个人同时来"的问题。设想库存只剩 1 件,两个用户几乎同时点了下单:

  • 用户 A 的请求读到库存 = 1,判断"够,可以买"。
  • 用户 B 的请求几乎同一时刻也读到库存 = 1,也判断"够,可以买"。
  • 两个请求都执行库存 -1,结果库存变成 -1,两单都成功了——这就是"超卖"

问题的根源是:两个请求"同时"读到了同一个旧值。前端其实很熟悉这种"并发改同一个状态"的坑——好比两个异步回调同时对同一个变量做 count--,最后结果不对。解决办法就是:让这些请求排队,一个处理完再处理下一个。

两种常见的锁思路(都用前端能懂的方式说)

① 悲观锁——"我先锁住,谁都别动"

处理 A 请求时,直接把这行库存"锁起来",B 请求必须等 A 处理完、释放锁之后才能读。相当于给这件商品挂了个"使用中"的牌子,后面的人排队等。

sql
-- 悲观锁:查库存时就加锁,别人必须等
SELECT num FROM stock WHERE id = 100 FOR UPDATE;
-- 判断够不够 → 扣减 → 提交,提交后锁释放,下一个才能进

类比:就像一个只能一个人进的试衣间,门锁上了,后面的人只能在外面排队。安全,但并发高时大家都在等,吞吐会下降。

② 乐观锁——"我先不锁,提交时检查有没有被人改过"

不加锁,但更新时带上条件:"只有库存还等于我刚才读到的值,才允许扣"。如果被别人先改了,这次更新就影响 0 行,说明冲突了,重试或报错即可。

sql
-- 乐观锁:更新时校验库存没被动过(或库存 > 0)
UPDATE stock SET num = num - 1
WHERE id = 100 AND num > 0;        -- 影响行数为 0 → 说明已被抢光,下单失败

类比:很像前端做的乐观更新——先假设不会冲突,提交时再校验;冲突了就回滚/重试。并发高、冲突少时性能好。

前端要记住的一句话

"锁"的存在意味着:在高并发下,后端会让请求排队或做冲突校验。 所以秒杀、抢购这类场景,前端要有心理预期——部分请求会"抢不到"而下单失败,这是正常的,不是 bug。前端该做的是把"库存不足/请稍后重试"的提示做友好,而不是默认每次点击都能成功。

四、顺带解决前端最关心的:重复点击下单

防止重复下单

用户网络卡,狂点了三下"提交订单",或者点完没反应又点了一次——会不会生成三个订单? 这就是重复下单问题。防护是前后端一起做的:

前端能做的(第一道防线,但不能只靠它):

  • 点击后立刻禁用按钮 / 转 loading,请求回来前不让再点。
  • 请求防抖 / 加提交中标记,拦住短时间内的重复触发。

注意:前端防护挡得住手抖,但挡不住网络重试、接口超时后的自动重发、用户刷新重放。所以后端必须兜底

后端的兜底(真正的保险):幂等

后端会用幂等机制保证"同一个下单请求,执行一次和执行多次结果一样"。常见做法是前端在下单时带一个唯一的请求 ID(幂等键):

js
// 前端:每次「发起一笔新下单」时生成一个唯一 key,重试时复用同一个
const idempotencyKey = crypto.randomUUID();
await createOrder(params, { headers: { 'Idempotency-Key': idempotencyKey } });

后端拿到这个 key,发现已经处理过了,就直接返回上次的结果,而不会再生成一张新订单。这就把"用户点了三下 / 网络重发了三次"收敛成了"只有一张订单"。

五、总结

  • 一次下单 = 一串操作:扣库存、生成订单、扣余额/券要被当成一个整体来对待。
  • 事务解决"一个人的一串操作要完整":要么全部成功(commit),要么全部撤销(rollback),不会有扣了一半的脏数据。类比 Promise.all 的"全有或全无"。
  • 解决"多个人同时抢":防止超卖。悲观锁 = 排队进试衣间(安全但慢),乐观锁 = 提交时校验冲突(类似前端乐观更新,冲突少时快)。
  • 重复下单前端防抖/禁用按钮 + 后端幂等键双重防护,前端挡手抖,后端才是真正兜底。
  • 前端心态调整:秒杀抢购场景下,下单失败是正常现象,把库存不足、请重试的提示做友好,比假设"每次都成功"更重要。

看懂这些之后,下单接口对你就不再是个黑盒了——你会知道后端那几秒里在扣库存、开事务、抢锁,也更清楚前端该在哪儿配合(禁用按钮、带幂等键、做好失败提示)。