欢迎光临
我们一直在努力

两人同时抢一座,票卖给谁?——我在影院票务里用事务和行锁钉死超卖

🥰个人主页:会编程的土豆(欢迎来访) 💎作者简介:后端学习者 ❄️个人专栏:数据结构与算法,数据库,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() }()

要点:

  • 之后所有 SQL 必须用 tx.,不能混用 db.DB.
  • defer Rollback():任何提前 return err 都会回滚
  • 成功路径会 Commit();Commit 后再 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

    守卫:

  • 是本人
  • 仍是待支付
  • 未过 pay_deadline
  • 然后:

    • 订单 → 已支付
    • 座位 → 已售,清空 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:双浏览器抢同一座(必做)

  • 启动服务,准备 user 与另一账号(或注册第二个)
  • 两端打开同一场次选座页
  • F12 Network 打开,方便看 /api/order/create
  • 同时选中同一座位并提交
  • 期望: 一个 code=0;另一个 code!=0 且 message 含锁定/已售
  • 查库:
  • 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. 复盘、局限、下一步

    做成了什么

    • 并发下同一座位互斥锁定
    • 下单原子:订单 + 锁座一起成功或失败
    • 与支付/超时路径在订单行上串行化(闭环在下一篇)

    局限

  • 多座位未排序,存在死锁可能
  • 缺少自动化并发测试,目前手工双端
  • 错误信息未码化
  • 单机假设清晰,未引入分布式锁
  • 下一步

  • seatIDs 排序加锁
  • 写一个并发 HTTP 小脚本当回归
  • 把锁等待超时(innodb_lock_wait_timeout)与用户提示做得更友好
  • 简历句(可直接用)

    独立实现影院票务选座下单:针对并发抢座,使用 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
    赞(0)
    未经允许不得转载:171主机测评 » 两人同时抢一座,票卖给谁?——我在影院票务里用事务和行锁钉死超卖
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址