并发事务 A/B 如何避免互相影响(UPDATE 有交集)
一、核心机制
当事务 A、B 的 UPDATE 操作涉及同一批数据时,MySQL(InnoDB)主要靠三类机制保证"不会互相把数据写乱":
- 锁(Locking) :写操作对目标记录加 排他锁 X 锁,同一时间只允许一个事务改同一行。
- 隔离级别(Isolation) :常见默认是 RR(可重复读) ,配合 MVCC 处理读一致性;写冲突则靠锁串行化。
- 事务原子性与提交(ACID) :要么全部成功提交,要么全部回滚;锁通常在 COMMIT/ROLLBACK 才释放(在事务语句块内)。
二、具体场景:两个事务同时转账(同一账户行发生交集)
2.1正确写法
假设表:
sql
CREATE TABLE account (
id BIGINT PRIMARY KEY,
balance INT NOT NULL
) ENGINE=InnoDB;
场景 1:直接 UPDATE(推荐写法,天然加 X 锁)
事务 A:给 id=1 扣 80
ini
START TRANSACTION;
UPDATE account SET balance = balance - 80 WHERE id = 1;
COMMIT;
事务 B:同时给 id=1 扣 50
ini
START TRANSACTION;
UPDATE account SET balance = balance - 50 WHERE id = 1;
COMMIT;
并发时会发生什么(本质)
- A 执行到
UPDATE ... WHERE id=1时,InnoDB 会对 id=1 这一行 加 X 锁。 - B 也要更新 id=1,因此它也要拿 id=1 的 X 锁,但拿不到,只能 等待(或超时失败)。
- A
COMMIT后释放锁,B 才能继续。
结果:不会出现"两个事务互相覆盖写"的问题。写入顺序被锁强制串行化。
2.1"看似正确但会出错"的写法:先 SELECT 再 UPDATE
1)错误的原因(不加锁的 SELECT 只是快照读)
例子:
sql
START TRANSACTION;
SELECT balance FROM account WHERE id=1; //加一个FOR UPDATE会实现加X锁
UPDATE account SET balance=20 WHERE id=1;
COMMIT;
问题在于:如果 SELECT balance ... 不带 FOR UPDATE / LOCK IN SHARE MODE ,它通常是 MVCC 一致性读(快照读) :
- 不会对数据行加行锁
- 读到的 balance 可能是一个"当时的版本"
- 之后再
UPDATE,并不能保证你基于刚才读到的值做决策时,中间没被别人改过
典型错误:丢失更新(Lost Update)/ 读-改-写竞争。
2)正确做法:读前锁定(SELECT ... FOR UPDATE)
把读改成加锁读:
sql
START TRANSACTION;
SELECT balance FROM account WHERE id=1 FOR UPDATE;
-- 基于 balance 做业务判断
UPDATE account SET balance = balance - 80 WHERE id=1;
COMMIT;
这时:
FOR UPDATE会对匹配行加 X 锁(更准确说:对访问到的记录加锁)- 其他事务想改同一行会被阻塞
- 你后续的业务判断和更新是"在锁保护下完成"的
三、一条sql语句执行时,Mysql做了什么
1)默认行为
-
通常 MySQL 默认
autocommit=1 -
这意味着:每条语句本身就是一个独立事务
- 语句开始:隐式
BEGIN - 语句结束:隐式
COMMIT
- 语句开始:隐式
-
因此锁的生命周期一般是:语句执行期间持有,语句结束立即释放(单条语句场景)
四、不显式开启事务,只执行普通 SELECT,会做什么?
Q1:
sql
SELECT id
FROM user_score
WHERE score >= 60;
最常见情况(InnoDB + RR + 普通 SELECT)
-
一致性读(MVCC/快照读)
- 不加行锁
- 不会锁住
score >= 60的范围 - 不阻塞并发 UPDATE/INSERT,一般也不被并发写阻塞
-
仍然会加 MDL(元数据锁)读锁
- 所有访问表的语句都会拿 MDL(结构层面的锁)
- 防止你在读的时候别人
ALTER TABLE改结构 - 在 autocommit 下,语句结束释放
结论:普通 SELECT 不会锁住 score>=60 的数据行范围。
五、Q2:UPDATE 范围条件时,是"同时锁住所有行"还是"边扫边锁"?锁是行级还是页级?
Q2:
ini
UPDATE user_score SET level = level + 1 WHERE score >= 60;
结论:边扫描边加锁(逐条/逐段)
更细的执行节奏通常是:
- 语句开始(隐式事务开始)
- 利用
idx_score(假设存在)定位到第一个满足score >= 60的索引位置 - 锁住当前索引记录(record/next-key)
- 根据索引项定位到聚簇索引记录,对数据行加 X 锁,执行更新
- 移动到下一条索引记录,重复 3~4
- 扫描结束,语句结束(隐式 COMMIT),释放锁
锁的粒度:主要是行级/索引记录级,不是粗糙页锁
- InnoDB 的核心是 记录锁(record lock) 、间隙锁(gap lock) 、Next-Key 锁(record+gap)
- 在 RR 隔离级别下,范围更新通常会涉及 Next-Key 锁 来防止幻读(尤其是基于二级索引的范围条件)
- 这不是"整页锁住",而是"对索引记录及其间隙做锁定",粒度依然是索引/记录层面
六、补充:两个 UPDATE 有交集时的典型风险与处理
1)死锁(Deadlock)
如果 A 更新行顺序是:先 id=1 再 id=2
B 更新顺序是:先 id=2 再 id=1
就可能互相等待形成死锁。InnoDB 会检测并回滚其中一个事务。
2)实务建议(仅整理,不改你的结论风格)
- 对同一批行的更新尽量保持一致的访问顺序(例如按主键升序)
- "先读后改"的逻辑尽量用
SELECT ... FOR UPDATE或直接用单条原子 UPDATE 表达 - 父表被引用列通常设计为
PRIMARY或UNIQUE