🥰个人主页:会编程的土豆(欢迎来访)
💎作者简介:后端学习者 ❄️个人专栏:数据结构与算法,数据库,leetcode ✨那些你一个人走过的夜路,终将化作照亮未来的光



在 Go + MySQL 影院票务里,用「事务 + SELECT … FOR UPDATE」把并发抢座做成可演示、可复现、可讲清的正确性方案
项目: 影院票务 GO(net/http + MySQL InnoDB) 系列下文: 订单超时与座位释放(生命周期的另一半)
1. 开场:面试官听腻了的一句话
「我做了一个购票系统,能选座下单」
这句话的问题不是假,而是没有信息量 面试官无法判断:你是做了个表单提交,还是真的碰过「两人抢一座」
我换成这样讲:
座位是稀缺资源。我在 MySQL 事务里对座位行加 FOR UPDATE,保证同一时刻只有一个事务能完成锁定;锁定只是租约,支付成功才变已售;租约到期由定时任务回收。 互斥靠行锁,生命周期靠状态机——两件事分开设计,合在一起才构成正确购票。
下面所有内容,都是为了让你配得上这段话
2. 业务建模:先把「状态」想清楚
2.1 座位为什么不是简单库存?
普通库存:
UPDATE products SET stock = stock – 1 WHERE id=? AND stock > 0
座位:
- 每一个格子是独立商品
- 冲突结果是「两个人同一张票」——业务事故级
- 真实流程几乎一定有待支付窗口(锁定 ≠ 已售)
2.2 三态(写进 models 的常量)
SeatFree = 0 空闲
SeatLocked = 1 锁定(有人下单未支付)
SeatSold = 2 已售(已支付)
迁移:
0 ──下单成功──→ 1 ──支付成功──→ 2
↘ 取消/超时 ──→ 0
不允许: 0 直接到 2(跳过支付窗);2 回到 1(本项目无退票);别人持有的 1 被你改成你的 1(抢锁)
2.3 两层锁(本文最重要的概念模型)
很多人以为「用了 FOR UPDATE」就结束了。其实有两层:
| 数据库行锁 | SELECT … FOR UPDATE | 事务进行期间(通常毫秒~几十毫秒) | 并发下「读改写」不打架 |
| 业务锁定 | status=1 + lock_user_id + lock_until | 支付窗口(如 15 分钟) | 事务提交后别人仍不能买 |
行锁释放后,如果没有业务锁定字段,座位会立刻变回可被他人下单——待支付订单就失去意义。
用更直白的比喻:
行锁像更衣室隔间门闩:你换衣服时别人推不开。 业务锁像你用手环占了柜子:你出去上厕所这几分钟,柜子仍是你的。 超时任务像闭馆清柜:手环过期,柜子腾出来。
3. 超卖时间线:无锁时到底发生了什么?
假设座位 S 初始 status=0。
3.1 危险代码的心智模型(先查再改)
请求A 请求B
───── ─────
BEGIN
读 S → 空闲
BEGIN
读 S → 空闲
改 S → 锁定(A)
COMMIT
改 S → 锁定(B)
COMMIT
结果可能是:
- B 覆盖 A(最后写入获胜),A 的订单指向已不属于他的座
- 或两人都以「成功」返回,数据库却只有一个 lock_user(更撕裂)
- 或出现两个订单都以为占了 S
根因名称:TOCTOU(检查时成立,使用时已变)。
3.2 单线程为什么测不出来?
因为你的两次点击被浏览器、操作系统排成了几乎串行。 并发 bug 的残酷之处在于:功能测试全绿,压力一来才爆。
所以后文我会给「双浏览器实验」——这是博客里的「可验证产出」。
4. 方案取舍:我否决了什么?
4.1 方案 A:进程内 Mutex
var seatMu sync.Mutex
seatMu.Lock()
// 读库改库
seatMu.Unlock()
| 写起来快 | 多进程/多实例失效 |
| 锁粒度难对齐「某一座位」 | |
| DB 与内存两套真相 |
课设单进程能蒙混,但我要练数据层正确性 → 否决。
4.2 方案 B:乐观锁 / 条件更新
UPDATE seats
SET status=1, lock_user_id=?, version=version+1
WHERE id=? AND status=0 AND version=?
RowsAffected==0 表示抢失败。
| 持锁短 | 热门场次冲突是常态,重试多 |
| 吞吐潜力好 | 多座位下单要处理部分成功更麻烦 |
| 初学不如悲观锁好讲时间线 |
票是挤着抢的资源 → 我更想要失败路径清晰 → 不作为主方案。 (我会在面试里承认:库存宽松场景我可能选 B。)
4.3 方案 C:悲观锁 FOR UPDATE(采用)
SELECT … FROM seats WHERE id=? AND schedule_id=? FOR UPDATE
| 正确性 | 强 |
| 可讲述性 | 强 |
| 热门座位 | 会排队——业务上合理 |
| 风险 | 事务过大/死锁 |
一句话选择理由: 稀缺 + 冲突可预期 → 悲观锁对齐业务;课设优先正确性与表达力。
5. 从点击到 SQL:完整调用链(仔细)
把「前端点一下」拆到数据库,面试官很爱听这个。
① 浏览器 seats.html
JS: selectedSeats 里收集 seat.id
POST /api/order/create
body: { "schedule_id": 12, "seat_ids": [101,102] }
② handlers.APICreateOrder
– currentUser() 取登录用户(无用户直接 Fail)
– DecodeJSON 解析 seat_ids
– GetSchedule:场次是否存在、是否取消、是否已开场
– 计算 lockUntil = now + SeatLockMinutes
– 计算 payDeadline = now + OrderPayMinutes
– 调用 dao.CreateOrderWithSeats(…)
③ dao.CreateOrderWithSeats
– BEGIN
– 对每个 seat_id: SELECT … FOR UPDATE + 校验
– INSERT orders
– UPDATE seats 为锁定
– COMMIT
④ 返回 JSON { code:0, data: order }
前端 alert 成功并跳转 /orders
分层的意义在这里体现:
- Handler 管:身份、参数、场次业务规则、超时配置
- Dao 管:原子性与互斥
- 前端管:体验(不能当安全边界)
如果有人把 FOR UPDATE 写在 Handler 里东一段西一段,基本说明层次糊了。
Handler 侧关键片段(逻辑)
未登录 → Fail
场次无效/已开始 → Fail
CreateOrderWithSeats 返回 err → 把 err.Error() 给前端
成功 → OK(order)
前端看到「座位已被锁定」后,应重新 loadSeats 刷新座位图——我们代码里失败分支会重新拉座位。
6. 表结构:每一列为什么存在?
seats 表(与锁座强相关的列):
| id | 座位行主键 | 前端传这个锁定 |
| schedule_id | 属于哪一场 | 不同场次同位置是不同行 |
| row_no/col_no | 展示「几排几座」 | 订单文案、错误提示 |
| status | 0/1/2 | 核心状态机 |
| lock_user_id | 谁锁的 | 区分「自己的锁」和「别人的锁」 |
| lock_until | 锁到期 | 兜底释放;也可展示 |
| order_id | 关联订单 | 支付/取消/超时按订单批量改座位 |
orders 相关:
| order_no | 给人看的单号 |
| user_id | 归属与鉴权 |
| schedule_id | 哪一场 |
| seat_info | 展示冗余「3排5座,3排6座」 |
| amount | 总价 |
| status | 待支付/已支付/取消/超时 |
| pay_deadline | 支付截止(权威时间) |
设计选择说明:
- seat_info 冗余是为了订单列表好读;是否可卖仍以 seats 为准。
- 座位按场次复制一份(8×12 场次就有 96 行),而不是「影厅物理座 + 状态表」二表——课设更直观;后者也是工业常见建模,我清楚差异。
唯一约束:
UNIQUE KEY uk_seat (schedule_id, row_no, col_no)
保证同一场不会出现两个「3排5座」行。
7. 逐行精读:CreateOrderWithSeats
7.1 函数签名在说什么?
func CreateOrderWithSeats(
userID, scheduleID int64,
seatIDs []int64,
price float64,
lockUntil, payDeadline time.Time,
) (*models.Order, error)
- price 是单价(来自场次),总价在函数内 price * len(seatIDs)
- lockUntil / payDeadline 由 Handler 按配置计算后传入——dao 不读环境变量,保持纯、好测
7.2 空座位列表
if len(seatIDs) == 0 {
return nil, errors.New("请选择座位")
}
防御性校验。前端也会拦,但后端必须再拦。
7.3 Begin + defer Rollback
tx, err := db.DB.Begin()
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
要点:
常见致命错误:
// 错误示范:锁在 tx 上,更新却走 db.DB
tx.QueryRow(`… FOR UPDATE`)
db.DB.Exec(`UPDATE seats …`) // 另一连接,锁没用!
7.4 循环锁座:FOR UPDATE
err := tx.QueryRow(`
SELECT status, row_no, col_no, lock_user_id
FROM seats
WHERE id=? AND schedule_id=?
FOR UPDATE`, sid, scheduleID).Scan(&status, &rowNo, &colNo, &lockUID)
为什么 WHERE 同时有 id 和 schedule_id?
- id 已经唯一,理论上够
- 加上 schedule_id 是防御伪造:客户端乱传其它场次座位 id,对不上就当不存在/失败
- 表达意图:「我锁的是这场里的这个座」
FOR UPDATE 的效果(InnoDB):
- 当前事务持有该行排他锁
- 其它事务对同行 FOR UPDATE / 更新会等待
- 直到你 Commit 或 Rollback
Scan 到 sql.NullInt64 的原因:lock_user_id 可为 NULL(空闲时)。
7.5 校验分支(逐个含义)
if status == SeatSold {
return nil, errors.New("座位已售出: " + SeatLabel(…))
}
if status == SeatLocked && (!lockUID.Valid || lockUID.Int64 != userID) {
return nil, errors.New("座位已被锁定: " + SeatLabel(…))
}
三种座位对当前用户的含义:
| 0 空闲 | 无人占用 | 能 |
| 1 锁定且是自己 | 可能重复提交/刷新 | 允许(视为自己的锁) |
| 1 锁定且是别人 | 别人的租约 | 不能 |
| 2 已售 | 已成交 | 不能 |
「允许锁自己的座」有利于:用户重复点击、网络重试时不那么脆。 (更严谨可做幂等 token;课设用「本人锁定可继续」够用。)
读到空闲/本人锁定后,把 3排5座 推进 labels,供订单展示。
7.6 先 INSERT 订单
order := &models.Order{
OrderNo: utils.OrderNo(),
UserID: userID,
ScheduleID: scheduleID,
SeatInfo: strings.Join(labels, ","),
Amount: price * float64(len(seatIDs)),
Status: OrderPending,
PayDeadline: payDeadline,
}
tx.Exec(`INSERT INTO orders(…) VALUES(…)`)
order.ID, _ = res.LastInsertId()
此时座位尚未改成锁定。 如果这里失败,前面的行锁会随 Rollback 释放,座位仍空闲——干净。
订单号用时间戳+纳米后缀,课设够用;高并发可换雪花/号段。
7.7 再 UPDATE 座位为锁定
for _, sid := range seatIDs {
tx.Exec(`
UPDATE seats
SET status=?, lock_user_id=?, lock_until=?, order_id=?
WHERE id=?`,
SeatLocked, userID, lockUntil, order.ID, sid)
}
此时才写入业务锁字段。 order_id 反指订单,方便支付时:
UPDATE seats SET status=已售 … WHERE order_id=?
7.8 Commit
if err := tx.Commit(); err != nil {
return nil, err
}
return order, nil
Commit 成功 = 对外公布真相:订单存在 + 座位锁定。 在此之前,其它事务即便窥探,也看不到未提交数据(隔离级别默认下)。
8. 有锁之后的并发时间线(对照第 3 节)
座位 S 空闲;A、B 同时抢。
A B
BEGIN
SELECT S FOR UPDATE (持锁)
校验:空闲,通过
BEGIN
SELECT S FOR UPDATE (等待A…)
INSERT order_A
UPDATE S → 锁定(A)
COMMIT (放锁)
(被唤醒,读到 status=锁定, user=A)
校验失败:座位已被锁定
Rollback
期望用户体验:
- A:下单成功,去支付
- B:提示座位被锁定,座位图刷新后看到该座不可点
这就是「可演示的正确性」。
9. 支付为什么也要锁订单行?
PayOrder 里:
SELECT status, user_id, pay_deadline FROM orders WHERE id=? FOR UPDATE
守卫:
然后:
- 订单 → 已支付
- 座位 → 已售,清空 lock 字段
9.1 假想竞态:支付 vs 超时清理
若双方都不锁订单行:
清理读到:待支付且过期 → 准备释放座位
支付读到:待支付未过期(时钟边界)→ 准备已售
两者交错 → 可能「钱付了座没了」或「座售了单超时」
两边都 FOR UPDATE 同一订单后,迁移串行,只会发生:
- 要么支付赢,要么超时赢
两者都合法,系统保持自洽。
9.2 和下单锁的对称性
| 下单 | 座位行 | 防两人锁同一座 |
| 支付/取消/超时 | 订单行 | 防状态机双边同时改 |
记住这张表,答辩几乎够用。
10. 错误写法对照
10.1 无事务的「看起来能跑」
// 错误:两步不是原子的
db.Exec(`INSERT INTO orders …`)
db.Exec(`UPDATE seats SET status=1 …`) // 若这里失败,订单已存在
10.2 事务但无 FOR UPDATE
tx.QueryRow(`SELECT status FROM seats WHERE id=?`) // 普通读
// … 另一事务也可能读到空闲
tx.Exec(`UPDATE seats SET status=1 …`)
10.3 FOR UPDATE 但更新跑错连接
见第 7.3 节——最隐蔽。
10.4 信任前端禁用座位
// 前端把已售座位 disabled——攻击者仍可 POST seat_ids
后端必须再校验。
10.5 正确骨架(缩略)
tx.Begin()
defer tx.Rollback()
for _, id := range seatIDs {
tx.QueryRow(`SELECT … FOR UPDATE`).Scan(…)
// 校验
}
tx.Exec(`INSERT orders`)
tx.Exec(`UPDATE seats …`)
tx.Commit()
11. 可复现实验与排障清单
实验 A:双浏览器抢同一座(必做)
SELECT id,status,lock_user_id,order_id FROM seats WHERE id=?;
SELECT id,order_no,status,seat_info FROM orders ORDER BY id DESC LIMIT 5;
实验 B:观察等待(可选调试)
在 Commit 前临时 time.Sleep(5*time.Second),B 请求会卡住接近 5 秒。 演示完立刻删除 Sleep。
实验 C:场次校验
对已开场场次下单,应在 Handler 层被拒(不必进入锁座)。
排障清单
| 总是超卖 | 是否真走 CreateOrderWithSeats?是否混用 db.DB?表是不是 InnoDB? |
| 偶尔脏数据 | 是否有别的接口也在改 seats? |
| 锁很久 | 事务里是否有 Slow 调用/Sleep/远程 HTTP? |
| 死锁报错 | 多座位是否逆序加锁?看第 12 节 |
| 前端成功但库不对 | 是否看错环境库?是否连错 DSN? |
引擎确认:
SHOW TABLE STATUS WHERE Name='seats';
— Engine 应为 InnoDB
MyISAM 无行级事务锁,课设若误建成 MyISAM,FOR UPDATE 行为不符合预期。
12. InnoDB 与死锁
12.1 为什么强调 InnoDB?
- 支持事务
- 支持行锁
- 支持我们 schema 里的外键
12.2 多座位死锁典型
事务A: 锁座1 → 锁座2
事务B: 锁座2 → 锁座1
MySQL 检测到死锁,回滚其中一方,应用收到错误。
工程修复:
sort.Slice(seatIDs, func(i,j int) bool { return seatIDs[i] < seatIDs[j] })
然后再依次 FOR UPDATE。 或一条:
SELECT … FROM seats WHERE id IN (…) AND schedule_id=? ORDER BY id FOR UPDATE
诚实声明: 我当前课设版尚未做排序加锁,依赖死锁检测;这是明确可改进点。 面试官听到「我知道风险和下一步」会加分,而不是扣分。
12.3 事务要短
事务内禁止:
- 调第三方支付 HTTP 等待用户扫码
- 大规模无关查询
- Sleep
锁持有越久,热座位排队越长,吞吐越差。
13. 答辩 / 面试追问 12 题(带着答)
Q1:FOR UPDATE 一定要放在事务里吗? 要。锁的生命周期与事务绑定;离开事务谈 FOR UPDATE 没有工程意义。
Q2:串行化读(普通 SELECT)够不够? 不够。普通读不加排他锁,两个事务仍可同时读到空闲再各自更新。
Q3:为什么不直接下单改已售? 因为要有支付窗;锁定与成交分离才能取消/超时。
Q4:行锁释放后为什么别人还买不到? 因为 status 仍是锁定,业务锁还在;直到支付或超时释放。
Q5:如何证明没超卖? 双开实验 + 查 lock_user_id 唯一;必要时 Sleep 观察等待。
Q6:前端禁用座位算防护吗? 不算。只是体验。安全以服务端事务校验为准。
Q7:MD5 密码和这个有关吗? 无关。别把所有关键词塞一个项目亮点里;亮点要尖。
Q8:Redis 分布式锁要不要上? 单机 MySQL 行锁足够课设。多数据中心/分库再评估。不为简历关键词强行上。
Q9:隔离级别是什么? InnoDB 默认 RR。我们主要依赖明确行锁,而不是靠幻读细节硬撑。
Q10:订单 insert 放锁座前还是后? 我先锁再插单再改座:先互斥,再留订单,再写业务锁。也可辩论;关键是同事务。
Q11:失败返回中文 err 好不好? 课设直观。进阶应稳定错误码,便于 i18n 与监控。
Q12:和秒杀的关系? 同属共享资源互斥;座位是「一行一物」,大秒杀常要缓存与队列。我的问题落在地图的「强一致行锁」格。
14. 复盘、局限、下一步
做成了什么
- 并发下同一座位互斥锁定
- 下单原子:订单 + 锁座一起成功或失败
- 与支付/超时路径在订单行上串行化(闭环在下一篇)
局限
下一步
简历句(可直接用)
独立实现影院票务选座下单:针对并发抢座,使用 MySQL 事务与 SELECT FOR UPDATE 行锁保证座位互斥;区分「事务行锁」与「支付窗口业务锁定」,并与支付校验形成状态闭环,避免超卖;通过双端同座实验验证一人成功、一人失败。
如果你只看一篇票务博客,看这篇就够当「深点」; 然后请读系列下文《锁座后不付钱就跑》,把生命周期补齐——否则你只做对了一半。
附录:关键代码索引
| 路由 | handlers/routes.go → /api/order/create |
| 入参与场次校验 | handlers/ticket.go → APICreateOrder |
| 锁座事务 | dao/order.go → CreateOrderWithSeats |
| 支付 | dao/order.go → PayOrder |
| 状态常量 | models/models.go |
| 座位表 | sql/schema.sql → seats / orders |




