🥰个人主页:会编程的土豆(欢迎来访)
💎作者简介:后端学习者
❄️个人专栏:数据结构与算法,数据库,leetcode
✨那些你一个人走过的夜路,终将化作照亮未来的光
写给基础一般的同学:假设你不懂 InnoDB、隔离级别
读完目标:知道行锁解决什么问题、怎么写、什么时候用/不用,并能对照自己的FOR UPDATE代码讲清楚
〇、先别急着背名词
你先记住一个生活场景:
图书馆里有一排柜子。
行锁≈ 你正在用某一格柜子时,给这一格挂了「使用中」,别人不能同时往这一格塞东西。
其它柜子不受影响。
对应到数据库:
- 一行数据 = 一格柜子(比如某一个座位那一行)
- 行锁= 暂时不许别人改这一行
- 表锁= 整排柜子全锁(粗暴,别人动哪一行都受影响)
抢票/选座场景里,我们通常要的是:只锁住被抢的那几个座位行,而不是锁整张seats表。
一、为什么需要锁?没有锁会怎样?
数据库经常被很多请求同时访问。
想象两个用户几乎同时做这件事:
1. 读一下:座位是不是空闲? 2. 如果是空闲,就改成「我锁定了」如果中间没有互斥,可能变成:
用户A读到:空闲 用户B读到:空闲 ← A还没改完 用户A改成:A锁定 用户B改成:B锁定 ← 超卖/覆盖这就是并发下的经典问题:检查的时候成立,真正改的时候已经变了。
锁的作用很朴素:
让「读 + 判断 + 改」对同一行变成一个人做完,另一个人再来。
二、行锁是什么?(定义 + 边界)
2.1 一句话定义
行锁(Row Lock):数据库锁住的是表里的某一行(或若干行),而不是整张表。
在 MySQL 的InnoDB引擎里,行锁很常见。
(如果你的表是 MyISAM,行锁支持很弱,课设表一定要用 InnoDB。我们项目 schema 写的就是ENGINE=InnoDB。)
2.2 行锁管多久?
通常管到:
- 事务提交 Commit,或
- 事务回滚 Rollback
也就是说:行锁 lifespan ≈事务的寿命
事务很快结束,锁就很快释放;
你在事务里睡觉、调慢接口,锁就会被你拿很久——别人会卡很久
2.3 行锁「禁止」的是什么?
对同一行,别人想再:
SELECT ... FOR UPDATE(也要独占)UPDATE/DELETE这行
往往会等待,直到你的事务结束。
注意:
- 别人改别的行,通常还能改
- 「绝对不能读」不一定:普通
SELECT在默认隔离下往往还能读到旧的已提交数据(这点后文会再说,初学先抓住「改同一行会等」)
三、和表锁、业务锁的区别(一定要分清)
3.1 行锁 vs 表锁
| 行锁 | 表锁 | |
|---|---|---|
| 锁的范围 | 一行/几行 | 整张表 |
| 并发 | 好(各改各的行) | 差(大家排队) |
| 典型场景 | 改订单、改座位 | 整表维护、某些引擎 |
选座只要锁「被点的座位」,用行锁;没必要锁全表。
3.2 行锁 vs 业务锁定(超容易混)
你项目里其实有两层「锁」:
| 数据库行锁 | 业务锁定字段 | |
|---|---|---|
| 怎么体现 | SELECT ... FOR UPDATE | status=1,lock_user_id,lock_until |
| 管多久 | 事务期间(很短) | 支付窗口(如 15 分钟) |
| 目的 | 并发瞬间不打架 | 提交后座位仍显示被占 |
比喻再强调一次:
- 行锁:你在柜台办手续时,工作人员按住档案不让别人同时改
- 业务锁:办完后给你发「临时占座牌」,15 分钟内座位算你的
只有行锁、没有业务字段:事务一提交,别人立刻又能下单抢。
只有业务字段、没有行锁:两人可能同时读到空闲,一起改成锁定。
两样都要,才是完整的「抢座」。
四、怎么用行锁?(写法)
在 MySQL / InnoDB 里,课设最常用、最好讲的就是:
SELECT ... FROM 表 WHERE 条件 FOR UPDATE;并且它必须放在事务里才有工程意义。
4.1 最小模板(请背这个骨架)
START TRANSACTION; -- 或 Go 里 tx.Begin() SELECT status, lock_user_id FROM seats WHERE id = 101 AND schedule_id = 12 FOR UPDATE; -- 锁住这一行 -- 在这里根据 status 判断 -- 然后 UPDATE / INSERT COMMIT; -- 成功提交,放锁 -- 或 ROLLBACK; -- 失败回滚,放锁Go 里对应:
tx, err := db.Begin() defer func() { _ = tx.Rollback() }() err = tx.QueryRow(` SELECT status, lock_user_id FROM seats WHERE id=? AND schedule_id=? FOR UPDATE`, seatID, scheduleID, ).Scan(&status, &lockUID) // 校验... // tx.Exec(`UPDATE ...`) // tx.Exec(`INSERT ...`) tx.Commit()4.2 几个写法要点
① 一定要用事务对象tx,不要混用
// 正确:查询和更新都走 tx tx.QueryRow(`... FOR UPDATE`) tx.Exec(`UPDATE ...`) // 错误:锁在 tx 上,更新走另一个连接 → 行锁等于白加 tx.QueryRow(`... FOR UPDATE`) db.Exec(`UPDATE ...`) // 危险!② WHERE 尽量点到具体行,并走索引
WHERE id = ? -- 好:锁目标清晰 WHERE schedule_id = 12 -- 可能锁很多行,甚至触发更粗的锁你项目用id=? AND schedule_id=?:目标是具体座位,同时防止乱传别的场次 id。
③ 事务要短
事务里不要:
time.Sleep- 调用微信/支付宝等很久的外部接口
- 超大循环无关操作
锁拿得越久,别人等得越惨。
④ 失败要回滚
Go 里常用:
defer func() { _ = tx.Rollback() }()中途return err会自动撤;成功再Commit()。
五、什么情况下应该用行锁?
下面这些场景,很适合行锁(或同类互斥):
5.1 稀缺资源「谁拿走算谁的」
- 选座、选房态、优惠券一券一人
- 库存扣减(也可用条件更新,但行锁思路同类)
- 号码池取号
特征:同一行不能被两个事务同时成功占有。
5.2 「先读后改」且中间不能插队
模式是:
读当前状态 → 按状态做分支判断 → 写回新状态只要两个人可能对同一行做这三步,就要考虑互斥。FOR UPDATE就是把三步包进同一把锁里。
5.3 状态机迁移要串行
例如订单:
- 用户支付
- 定时任务超时取消
两边都可能改同一订单。对订单行FOR UPDATE,保证同一时刻只有一个迁移成功。
(你项目支付、取消、超时清理都锁了订单行。)
5.4 一句话判断题
问自己:
如果两个请求同时跑到这段逻辑,会不会把同一行改出矛盾结果?
会 → 考虑行锁(或条件更新等互斥手段)。
不会(只是各写各的日志、各插各的评论)→ 通常不必。
六、什么情况下不要乱用行锁?
6.1 纯展示查询
首页列电影、刷座位图给用户看:
SELECT * FROM seats WHERE schedule_id=?一般不要加FOR UPDATE。
原因:展示不需要独占;加了只会让别人更新更慢。
6.2 冲突极少、可以接受重试
例如点赞数 +1,偶尔冲突用乐观锁/重试更轻。
不是所有UPDATE都要先FOR UPDATE。
6.3 可以一条条件更新搞定时
UPDATE seats SET status=1, lock_user_id=? WHERE id=? AND status=0;若影响行数=1 成功,=0 失败——这是另一种互斥(偏乐观/条件写)。
简单扣库存常用这个。
你选座因为要「读很多字段做复杂校验 + 插订单」,用FOR UPDATE更清晰。
6.4 跨很久的用户操作
不要指望:
FOR UPDATE 锁住 等用户掏出手机扫码 5 分钟 再 Commit这会把行锁拿 5 分钟,系统会卡死。
正确做法:事务快速结束;长时间占用用业务字段(锁定到lock_until)
七、结合你的项目:行锁出现在哪?
7.1 下单抢座:锁座位行
tx.QueryRow(` SELECT status,row_no,col_no,lock_user_id FROM seats WHERE id=? AND schedule_id=? FOR UPDATE`, sid, scheduleID)然后:
- 已售 → 失败
- 别人锁定 → 失败
- 通过 → 插订单、把座位改成锁定
两个用户抢同一sid:后到的在FOR UPDATE等待;你提交后他读到新状态,校验失败
7.2 支付 / 取消 / 超时:锁订单行
SELECT status,user_id,pay_deadline FROM orders WHERE id=? FOR UPDATE防止:一边支付、一边超时释放,把状态改乱。
7.3 用一张图串起来
用户点下单 → BEGIN → 对每个座位 FOR UPDATE(行锁) → 校验 → INSERT 订单 → UPDATE 座位为业务锁定 → COMMIT(行锁释放,但 status 仍是锁定) → 15 分钟内别人仍不能买(业务锁) → 支付成功:再锁订单行,座位改已售 → 或超时:锁订单行,座位改回空闲八、慢慢看一段「带行锁」的完整逻辑
用人话对应代码:
① Begin:开启事务(开始办事,还没对外宣布) ② defer Rollback:中途失败就当没发生 ③ FOR UPDATE:按住这个座位档案 ④ 看状态:卖了?别人占了?→ 不行就返回错误(自动回滚) ⑤ 都 OK:创建订单草稿 ⑥ 座位写上「我锁定了、锁到几点、对应哪张单」 ⑦ Commit:对外宣布成功,放开行锁其中③④是行锁发挥作用的时刻;
⑥是业务锁;
⑦之后行锁没了,业务锁还在。
九、常见误区(基础差最容易踩)
误区 1:以为 FOR UPDATE 可以单独写,不需要事务
工程上请永远:事务包住 FOR UPDATE + 后续写
误区 2:以为加了行锁,整张表别人都不能动
只影响冲突的行(简化理解)。其它座位照样能卖
误区 3:以为行锁能代替支付超时
行锁很短。长时间占座必须靠lock_until+ 定时任务
误区 4:前端把按钮禁用了就不需要锁了
前端防的是手滑,防不了并发和抓包。互斥必须在服务器/数据库
误区 5:所有 SELECT 都加 FOR UPDATE
展示接口不要乱加,否则自己把自己锁慢
误区 6:表不是 InnoDB
没有真正的行级事务锁体验。建表看一眼ENGINE=InnoDB
十、怎么自己验证「行锁生效了」?
方法 A:双浏览器(推荐)
两个账号打开同一场次,同时选同一座位下单:
- 一人成功
- 一人提示锁定/已售
方法 B:人为拖慢事务(仅本地调试)
在Commit前临时Sleep(5秒),另一个请求会明显卡住。
测完删掉。
方法 C:查库
成功一方:
SELECT id,status,lock_user_id,order_id FROM seats WHERE id=?;应看到锁定人是成功用户。
十一、行锁相关的兄弟概念(先混个眼熟)
以后学更深会遇到,现在够用知道名字:
| 概念 | 直觉 |
|---|---|
| 共享锁 S / 排他锁 X | 行锁里 FOR UPDATE 偏排他:我改你别改 |
| 死锁 | A 锁1等2,B 锁2等1,数据库踢掉一个 |
| 锁等待超时 | 等太久报错(innodb_lock_wait_timeout) |
| 乐观锁 | 不加长锁,靠版本号/条件更新,冲突就重试 |
| 悲观锁 | 先锁再干活(FOR UPDATE 属于这类) |
你的课设选型是:悲观行锁,适合「抢座位这种冲突可预期」的场景。
十二、什么时候用:速查表
| 场景 | 建议 |
|---|---|
| 两人可能改同一座位/同一订单 | 用行锁(FOR UPDATE)或等价互斥 |
| 下单=读状态+分支+写订单+写座位 | 事务 + 行锁 |
| 支付与超时可能同时改订单 | 对订单行加锁 |
| 首页列表、座位图展示 | 不用 FOR UPDATE |
| 用户扫码等 5 分钟 | 不要持有行锁等;用业务锁定字段 |
| 简单 stock-1 | 可考虑UPDATE ... WHERE stock>0 |
十三、
选座是稀缺资源竞争。我在事务里对座位行使用
SELECT FOR UPDATE行锁,保证同一时刻只有一个事务能完成校验和锁定;事务很快提交。支付窗口靠座位状态和lock_until业务锁定维持,超时任务负责释放。这样既防并发超卖,又不会把行锁拿太久。
十四、小结
- 行锁锁的是一行,解决多人同时改同一行的冲突。
- 常用写法:
SELECT ... FOR UPDATE,必须放在事务里,读写都走同一tx。 - 适合:抢座、库存互斥、状态机并发迁移。
- 不适合:纯展示;也不要用行锁代替长时间业务占座。
- 你项目里:下单锁座位行,支付/超时锁订单行——这就是标准用法