用 SELECT ... FOR UPDATE
锁住要更新的行
在事务中修改某条记录前,必须先加行级写锁,否则并发读-改-写会导致覆盖或丢失更新。比如两个事务同时读取余额为 100 的账户,各自加 50 后写回,最终变成 150 而不是预期的 200。
正确做法是在
BEGIN后立即执行带锁查询:
START TRANSACTION; SELECT balance FROM accounts WHERE id = 123 FOR UPDATE; -- 此时其他事务对 id=123 的行执行 SELECT ... FOR UPDATE 或 UPDATE 会被阻塞 UPDATE accounts SET balance = balance + 50 WHERE id = 123; COMMIT;
注意:
FOR UPDATE只在可重复读(
REPEATABLE READ)隔离级别下才真正锁定索引覆盖的行;如果
WHERE条件没走索引,会升级为表锁。
避免长事务 + 显式控制锁范围
锁持有时间越长,阻塞越多,死锁概率越高。常见错误是把耗时操作(如 HTTP 调用、文件读写)放进事务里。
只把真正需要原子性的 DB 操作包进事务 确保WHERE条件命中主键或唯一索引,避免锁住不相关行 批量更新时,分页或按主键范围拆成多个小事务,不要一次
UPDATE ... LIMIT 10000确认 autocommit 关闭(
SET autocommit = 0),否则每条语句自动提交,
FOR UPDATE失效
用 INSERT ... ON DUPLICATE KEY UPDATE
替代“查再插”
“先查是否存在,不存在则插入”这种逻辑在并发下必然产生竞态:两个事务都查到不存在,然后都插入,触发唯一键冲突或重复数据。
直接用原子语句替代:
INSERT INTO user_points (user_id, points) VALUES (123, 10) ON DUPLICATE KEY UPDATE points = points + 10;
前提是
user_id有唯一约束(主键或 UNIQUE 索引)。该语句内部由 MySQL 自动处理冲突,不会报错也不会丢更新。
注意:如果业务需要区分“本次是插入还是更新”,可通过
ROW_COUNT()判断影响行数,但不能依赖
LAST_INSERT_ID()—— 它在
ON DUPLICATE KEY UPDATE场景下行为不可靠。
读已提交(READ COMMITTED
)下慎用非锁定读
默认的
REPEATABLE READ隔离级别能防止不可重复读,但代价是间隙锁(Gap Lock)更重;而
READ COMMITTED下每次
SELECT都读最新已提交版本,不加间隙锁,看似轻量,却容易引发幻读问题——尤其在“检查约束+插入”类逻辑中。
例如判断订单号未被使用,然后插入新订单:
事务 A 查SELECT COUNT(*) FROM orders WHERE order_no = 'NO2024001'→ 0 事务 B 插入
'NO2024001'并提交 事务 A 继续插入 → 唯一键冲突
这种场景不能靠隔离级别解决,必须用
SELECT ... FOR UPDATE或唯一索引+
INSERT ... ON DUPLICATE KEY UPDATE这类原子机制兜底。
真正难处理的不是单条 SQL 的一致性,而是跨表、跨服务、含外部依赖的业务逻辑。MySQL 的锁和事务只能保它自己那块数据,一旦涉及 Redis 缓存更新、MQ 发消息、第三方支付回调,就得靠应用层补偿、幂等设计或分布式事务框架来兜住——这些已经超出 MySQL 本身能力范围了。
