写在前面:如果把数据库比作一个多人协作的办公室,那么并发控制就是协调大家如何有序工作的规则。想象一下,10个人同时要修改同一份文档,没有协调机制的话,会发生什么?MySQL用锁机制解决了这个问题。
开篇引入:并发世界的混乱与秩序
你正在修改一个文档的关键段落,突然发现内容被人覆盖了——原来是隔壁桌的同事也在修改这份文档。这就是并发控制失效的后果。
在数据库世界,这个问题更加严重。每天,成千上万的请求同时访问同一个数据库表,读取、写入、修改、删除……如果没有有效的协调机制,数据将变得一团糟。
锁(Lock),就是MySQL解决并发冲突的核心机制。它不像乐观锁那样先让你改、发现冲突了再处理,也不像悲观锁那样直接锁定不让别人碰。MySQL的锁机制是一套复杂的系统,需要我们深入理解。
今天,就让我们揭开MySQL并发控制的神秘面纱。
并发控制的两把钥匙:读写锁
MySQL使用两种基本的锁类型来解决并发问题:读锁(Read Lock)和写锁(Write Lock)。
读锁:共享的快乐
读锁是共享的——多个客户端可以同时持有同一个资源的读锁,因为读取不会改变数据。
客户端A:获得了 user 表的读锁 ←──────────┐
客户端B:获得了 user 表的读锁 ←──────────┼─→ 大家都可以读,和平共处
客户端C:获得了 user 表的读锁 ←──────────┘
这就像图书馆的阅览室,你可以和别人一起看书,没人会阻止你。
写锁:独占的孤独
写锁则是排他的。一旦某个事务获得了写锁,其他事务既不能读取(脏读)也不能写入。
客户端A:获得了 user 表的写锁 ←───── 独占!其他人都得等
客户端B:想读? 等待中...
客户端C:想写? 等待中...
这就像只有一个人能进入金库,其他人只能在门外等候。
组合效果
读锁和写锁的组合规则如下:
| 锁类型 | 能否同时存在 |
|---|---|
| 读锁 vs 读锁 | ✅ 可以 |
| 读锁 vs 写锁 | ❌ 互斥 |
| 写锁 vs 写锁 | ❌ 互斥 |
-- Session A:获取读锁
LOCK TABLE users READ;
-- Session B:此时尝试写入会被阻塞
UPDATE users SET name = 'new' WHERE id = 1;
-- 命令挂起,等待读锁释放
锁粒度:锁住整张表还是锁住一行?
锁粒度(Lock Granularity)是指锁的作用范围。选择合适的锁粒度,是并发性能和资源消耗的权衡。
表锁:简单粗暴
表锁(Table Lock)锁定整张表。最典型的是 MyISAM 引擎。
-- MyISAM 表会自动加表锁
INSERT INTO users VALUES (1, 'Alice');
-- 此时其他会话对 users 表的所有写操作都会等待
优点:实现简单,开销小 缺点:并发能力差,一次只能一个人写
表锁的场景:
- 需要对整张表进行操作(如 ALTER TABLE)
- 批量导入数据
- MyISAM 引擎的读操作
行锁:精细化管理
行锁(Row Lock)只锁定某一行或某几行。InnoDB 引擎支持行锁。
-- InnoDB 的行锁
BEGIN;
UPDATE users SET name = 'Bob' WHERE id = 1;
-- 只锁定了 id=1 的行,其他行的读写不受影响
COMMIT;
优点:最大并发度 缺点:开销大,需要跟踪每行锁
页锁:中间地带
一些数据库(如 Microsoft SQL Server)支持页锁,锁定一"页"数据(约8KB)。MySQL 不支持页锁。
锁粒度对比
| 粒度 | 引擎 | 并发度 | 开销 | 适用场景 |
|---|---|---|---|---|
| 表锁 | MyISAM | 低 | 小 | 整表操作、批量导入 |
| 行锁 | InnoDB | 高 | 大 | OLTP 高并发场景 |
| 页锁 | 其他DB | 中 | 中 | 不适用MySQL |
InnoDB的行锁实现
InnoDB 的行锁是通过在索引上标记实现的,而不是锁住数据本身。这是一个重要的细节。
记录锁:锁定一行
-- 锁定 id=1 的记录
SELECT * FROM users WHERE id = 1 FOR UPDATE;
间隙锁:锁定一个范围
-- 锁定 id 在 1-100 之间的"间隙"
SELECT * FROM users WHERE id BETWEEN 1 AND 100 FOR UPDATE;
间隙锁的作用是防止其他事务在这个范围内插入新记录,解决"幻读"问题。
Next-Key Lock:记录锁+间隙锁
这是 InnoDB 默认的锁定方式。锁定索引记录本身,以及它前面的间隙。
-- 在可重复读隔离级别下
SELECT * FROM orders WHERE order_id >= 1000 FOR UPDATE;
-- 锁定 order_id=1000 这条记录
-- 同时锁定 (1000, ∞) 这个间隙
临键锁的边界问题
临键锁(Next-Key Lock)在某些情况下会退化为简单记录锁或间隙锁:
-- 唯一索引的等值查询,退化为记录锁
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 只锁定 id=1 这条记录
-- 范围查询,使用临键锁
SELECT * FROM users WHERE id > 100 FOR UPDATE;
-- 锁定 id > 100 的所有记录及间隙
锁等待与超时
当请求的锁被其他事务持有时,请求者会进入等待状态。
查看锁等待
-- 查看当前锁状态
SHOW ENGINE INNODB STATUS;
-- 查看事务等待的锁
SELECT
trx_id,
trx_state,
trx_mysql_thread_id,
trx_query
FROM information_schema.INNODB_TRX;
-- 查看锁信息
SELECT
lock_id,
lock_type,
lock_mode,
lock_table,
lock_index
FROM information_schema.INNODB_LOCKS;
设置锁等待超时
-- 查看当前配置
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- 设置超时时间(秒)
SET GLOBAL innodb_lock_wait_timeout = 5;
超时后,MySQL 会报错:
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
死锁检测
InnoDB 有自动死锁检测机制。当检测到循环依赖时,会主动回滚代价最小的事务。
-- 查看最近一次死锁信息
SHOW ENGINE INNODB STATUS;
-- 在输出中找到 LATEST DETECTED DEADLOCK 部分
死锁处理策略:回滚持有最少行级排他锁的事务。
-- 模拟死锁场景
-- Session A
BEGIN;
UPDATE users SET name = 'A1' WHERE id = 1; -- 锁住 id=1
UPDATE orders SET status = 'processing' WHERE id = 1; -- 等待 id=1
-- Session B
BEGIN;
UPDATE orders SET status = 'pending' WHERE id = 1; -- 锁住 orders id=1
UPDATE users SET name = 'B1' WHERE id = 1; -- 尝试锁 users id=1,但被A持有
-- 死锁!InnoDB 检测到后会自动回滚其中一个事务
实战:排查锁问题
场景1:长事务导致的锁等待
-- Session A:开启事务但忘记提交
BEGIN;
UPDATE users SET last_login = NOW() WHERE id = 1;
-- 然后去做其他事情,忘记 COMMIT
-- Session B:等待超时
UPDATE users SET name = 'New Name' WHERE id = 1;
-- ERROR 1205: Lock wait timeout exceeded
解决方案:定期提交,避免长事务
-- 使用短事务模式
BEGIN;
UPDATE users SET last_login = NOW() WHERE id = 1;
COMMIT; -- 立即提交,不要等待
场景2:索引缺失导致的锁扩散
如果没有合适的索引,InnoDB 可能会锁定大量行。
-- 表没有索引
ALTER TABLE orders ADD INDEX idx_status(status);
-- 执行查询
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;
-- 有索引:只锁定匹配的行
-- 无索引:全表扫描,锁定所有行!
场景3:大批量更新的锁管理
-- 反面例子:一次性更新100万行
UPDATE orders SET status = 'archived' WHERE status = 'completed';
-- 锁住大量行,其他事务全部等待
-- 正确做法:分批更新
DELIMITER //
CREATE PROCEDURE batch_update()
BEGIN
DECLARE done INT DEFAULT FALSE;
DECLARE batch_size INT DEFAULT 1000;
WHILE done = 0 DO
UPDATE orders
SET status = 'archived'
WHERE status = 'completed'
LIMIT batch_size;
IF ROW_COUNT() < batch_size THEN
SET done = TRUE;
END IF;
COMMIT; -- 每批提交,释放锁
END WHILE;
END //
DELIMITER ;
读锁的特殊场景
共享读锁 vs 排他写锁
-- Session A:获取读锁
LOCK TABLE users READ;
-- Session B:其他会话可以读,但不能写
SELECT * FROM users; -- OK
UPDATE users SET name = 'test' WHERE id = 1; -- 阻塞
写锁优先级
MySQL 默认写锁优先级高于读锁。如果有写锁等待队列,新来的读请求会排在写请求后面。
-- 提高读锁优先级(需要重新编译MySQL)
-- 或使用 LOW_PRIORITY 降低写锁优先级
UPDATE LOW_PRIORITY users SET name = 'test' WHERE id = 1;
锁与事务隔离级别的关系
不同的隔离级别,锁的行为也不一样:
READ COMMITTED:减少锁范围
只锁定当前读取的行,减少锁冲突。
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT * FROM orders WHERE status = 'pending';
-- 只锁定找到的行
REPEATABLE READ(InnoDB默认):临键锁
锁定读取范围内的所有行和间隙,防止幻读。
SERIALIZABLE:强制串行
最严格的隔离级别,可能严重影响并发性能。
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT * FROM users;
-- 实际上会给所有读取的行加锁
性能调优建议
1. 减少锁的范围
-- 使用精确的 WHERE 条件
UPDATE users SET name = 'new' WHERE id = 1; -- 只锁一行
UPDATE users SET name = 'new'; -- 锁整张表!
2. 创建合适的索引
索引不仅提升查询速度,还能减少锁范围。
CREATE INDEX idx_user_status ON orders(user_id, status);
-- 使用复合索引精确定位
3. 避免长事务
-- 使用自动提交
SET autocommit = 1;
-- 或者及时提交
BEGIN;
-- 业务逻辑
COMMIT; -- 不要忘记
4. 监控锁状态
-- 添加监控
SELECT
OBJECT_SCHEMA AS db,
OBJECT_NAME AS table_name,
LOCK_MODE,
LOCK_STATUS,
COUNT(*) AS count
FROM information_schema.INNODB_LOCK_WAITS
GROUP BY 1, 2, 3, 4;
小结
并发控制是数据库的永恒话题。
核心要点:
- 读写锁:读锁共享,写锁独占
- 锁粒度选择:表锁开销小但并发差,行锁并发好但开销大
- InnoDB行锁通过索引实现:没有索引会锁大量行
- 间隙锁防止幻读:锁定记录之间的"空隙"
- 避免长事务:减少锁持有时间
- 死锁自动检测:InnoDB会回滚代价最小的事务
实践建议:
- 使用短事务,及时提交
- 创建合适的索引,减少锁范围
- 监控锁等待和死锁
- 批量操作时分批提交
理解锁机制,才能写出高并发的应用。
延伸阅读
- 《高性能MySQL(第4版)》第1章 - 并发控制部分
- MySQL 8.0 Reference Manual - InnoDB Locking
SHOW ENGINE INNODB STATUS输出解读