【MySQL深入详解】第02篇:并发控制的艺术——读写锁与锁粒度

2 阅读9分钟

写在前面:如果把数据库比作一个多人协作的办公室,那么并发控制就是协调大家如何有序工作的规则。想象一下,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整表操作、批量导入
行锁InnoDBOLTP 高并发场景
页锁其他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;

小结

并发控制是数据库的永恒话题。

核心要点

  1. 读写锁:读锁共享,写锁独占
  2. 锁粒度选择:表锁开销小但并发差,行锁并发好但开销大
  3. InnoDB行锁通过索引实现:没有索引会锁大量行
  4. 间隙锁防止幻读:锁定记录之间的"空隙"
  5. 避免长事务:减少锁持有时间
  6. 死锁自动检测:InnoDB会回滚代价最小的事务

实践建议

  • 使用短事务,及时提交
  • 创建合适的索引,减少锁范围
  • 监控锁等待和死锁
  • 批量操作时分批提交

理解锁机制,才能写出高并发的应用。


上一篇【第01篇】MySQL架构全景图——从连接请求到结果返回

下一篇【第03篇】事务的本质——ACID与隔离级别深度解读


延伸阅读

  • 《高性能MySQL(第4版)》第1章 - 并发控制部分
  • MySQL 8.0 Reference Manual - InnoDB Locking
  • SHOW ENGINE INNODB STATUS 输出解读