MySQL-锁机制
为什么需要锁?
假设你的银行账户余额1000元,两笔转账同时到账:
没有锁保护的情况:
sql
-- 时间点1: 转账程序A读取余额
SELECT balance FROM account WHERE id = 123; -- 读到1000元
-- 时间点2: 转账程序B也读取余额
SELECT balance FROM account WHERE id = 123; -- 也读到1000元
-- 时间点3: 程序A计算并更新(1000+500=1500)
UPDATE account SET balance = 1500 WHERE id = 123;
-- 时间点4: 程序B计算并更新(1000+300=1300),覆盖了A的更新!
UPDATE account SET balance = 1300 WHERE id = 123;结果:你损失了500元!
有锁保护的情况:
sql
-- 转账程序A
START TRANSACTION;
SELECT balance FROM account WHERE id = 123 FOR UPDATE; -- 加排他锁,读到1000
UPDATE account SET balance = 1500 WHERE id = 123;
COMMIT; -- 释放锁
-- 转账程序B(必须等A完成后才能执行)
START TRANSACTION;
SELECT balance FROM account WHERE id = 123 FOR UPDATE; -- 读到1500
UPDATE account SET balance = 1800 WHERE id = 123;
COMMIT;结果:余额正确为1800元
这就是锁存在的意义:保证并发操作的数据一致性。
MySQL 锁的分类
按锁粒度分类
- 全局锁:锁住整个MySQL实例
- 表锁:锁住单张表
- 行锁:锁住具体的数据行
按锁模式分类
- 共享锁(S) - 读锁,多个事务可同时持有
- 排他锁(X) - 写锁,只能一个事务持有
按实现算法分类
- 记录锁 - 锁定索引记录
- 间隙锁 - 锁定索引记录间的空隙
- Next-Key锁 - 记录锁+间隙锁
全局锁
加锁语法
sql
FLUSH TABLES WITH READ LOCK; -- 加全局读锁
-- 执行备份操作
UNLOCK TABLES; -- 释放锁实际演示
sql
-- 会话1:执行全局锁
FLUSH TABLES WITH READ LOCK;
-- 会话2:所有写操作被阻塞
INSERT INTO users (name) VALUES ('张三'); -- 阻塞等待
UPDATE users SET name = '李四' WHERE id = 1; -- 阻塞等待
DELETE FROM users WHERE id = 2; -- 阻塞等待
-- 会话2:读操作正常
SELECT * FROM users; -- 执行成功现代备份方案
sql
# 推荐使用一致性备份,无需全局锁
mysqldump --single-transaction --master-data=2 database_name > backup.sql表锁
MyISAM的基础,InnoDB的辅助
手动表锁
读锁演示
sql
-- 会话1:获取读锁
LOCK TABLES users READ;
SELECT * FROM users; -- 成功
INSERT INTO users (name) VALUES ('张三'); -- 失败:当前会话不能写
UPDATE users SET name = '李四' WHERE id = 1; -- 失败:当前会话不能写
-- 会话2:其他会话的表现
SELECT * FROM users; -- 成功:可以读
INSERT INTO users (name) VALUES ('王五'); -- 阻塞:不能写
-- 会话1:释放锁
UNLOCK TABLES;写锁演示
sql
-- 会话1:获取写锁
LOCK TABLES users WRITE;
SELECT * FROM users; -- 成功:当前会话可读写
INSERT INTO users (name) VALUES ('张三'); -- 成功:当前会话可读写
-- 会话2:其他会话完全被阻塞
SELECT * FROM users; -- 阻塞等待
INSERT INTO users (name) VALUES ('李四'); -- 阻塞等待
-- 会话1:释放锁
UNLOCK TABLES;自动表锁场景
MyISAM引擎
sql
-- MyISAM表的所有操作都自动使用表锁
SELECT * FROM myisam_table; -- 自动加读锁
UPDATE myisam_table SET name = '张三'; -- 自动加写锁InnoDB的DDL操作
sql
-- 这些DDL操作会使用表锁
ALTER TABLE users ADD COLUMN email VARCHAR(100);
CREATE INDEX idx_name ON users(name);
DROP INDEX idx_name ON users;行锁
InnoDB的核心优势
共享锁(读锁)
sql
-- 会话1:获取共享锁
START TRANSACTION;
SELECT * FROM users WHERE id = 1 FOR SHARE; -- 或 LOCK IN SHARE MODE
-- 会话2:可以读,不能写
SELECT * FROM users WHERE id = 1; -- 成功:可以读
SELECT * FROM users WHERE id = 1 FOR SHARE; -- 成功:可以加共享锁
UPDATE users SET name = '张三' WHERE id = 1; -- 阻塞:不能写
-- 会话1:提交释放锁
COMMIT;排他锁(写锁)
sql
-- 会话1:获取排他锁
START TRANSACTION;
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 会话2:读写都被阻塞
SELECT * FROM users WHERE id = 1; -- 阻塞等待
SELECT * FROM users WHERE id = 1 FOR SHARE; -- 阻塞等待
UPDATE users SET name = '张三' WHERE id = 1; -- 阻塞等待
-- 会话1:提交释放锁
COMMIT;自动行锁
sql
-- 这些DML操作自动获取排他锁
UPDATE users SET name = '张三' WHERE id = 1; -- 自动对id=1加排他锁
DELETE FROM users WHERE id = 1; -- 自动对id=1加排他锁
INSERT INTO users (name) VALUES ('李四'); -- 自动对新记录加排他锁行锁的三种实现算法
记录锁(Record Lock)
只锁定具体的索引记录:
sql
-- 设置读已提交隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
-- 只锁定id=1这一条记录
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 测试效果:
-- 其他记录不受影响,可以正常修改id=2,3,4的记录
-- 但id=1的记录被锁定间隙锁(Gap Lock)
锁定索引记录之间的空隙,防止插入:
sql
-- 可重复读隔离级别下(默认)
START TRANSACTION;
-- 假设users表中id字段有值:1, 5, 10
-- 执行范围查询会触发间隙锁
SELECT * FROM users WHERE id > 5 AND id < 10 FOR UPDATE;
-- 测试间隙锁效果:
-- 在另一个会话中尝试插入
INSERT INTO users (id, name) VALUES (7, '张三'); -- 阻塞:在锁定间隙内
INSERT INTO users (id, name) VALUES (3, '李四'); -- 成功:不在锁定间隙内
INSERT INTO users (id, name) VALUES (12, '王五'); -- 成功:不在锁定间隙内Next-Key锁
记录锁+间隙锁的组合,InnoDB的默认锁定方式:
sql
START TRANSACTION;
-- 范围查询使用Next-Key锁
SELECT * FROM users WHERE id >= 5 FOR UPDATE;
-- 效果:
-- 1. 锁定id>=5的所有现有记录(记录锁)
-- 2. 锁定(5,+∞)的间隙(间隙锁)
-- 3. 防止修改现有记录,也防止插入新记录意向锁
协调表锁和行锁.意向锁是InnoDB自动管理的,无需手动操作:
sql
-- 当你执行
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- InnoDB自动执行:
-- 1. 对users表加意向排他锁(IX)
-- 2. 对id=1的行加排他锁(X)
-- 这样当有人想加表锁时:
LOCK TABLES users WRITE;
-- 系统能快速检测到冲突,无需扫描每一行死锁
死锁产生的过程
sql
-- 事务A
START TRANSACTION;
SELECT * FROM users WHERE id = 1 FOR UPDATE; -- 获得id=1的排他锁
-- 此时尝试获取id=2的锁
SELECT * FROM users WHERE id = 2 FOR UPDATE; -- 等待id=2的锁
-- 事务B(同时执行)
START TRANSACTION;
SELECT * FROM users WHERE id = 2 FOR UPDATE; -- 获得id=2的排他锁
-- 此时尝试获取id=1的锁
SELECT * FROM users WHERE id = 1 FOR UPDATE; -- 等待id=1的锁
-- 死锁形成:
-- 事务A持有id=1锁,等待id=2锁
-- 事务B持有id=2锁,等待id=1锁
-- MySQL检测到死锁,自动回滚其中一个事务避免死锁的方法
统一资源访问顺序
sql
-- 所有事务都按相同顺序获取锁
-- 事务A和B都这样写:
START TRANSACTION;
SELECT * FROM users WHERE id = 1 FOR UPDATE; -- 先锁id小的
SELECT * FROM users WHERE id = 2 FOR UPDATE; -- 再锁id大的
-- 进行业务操作...
COMMIT;控制事务大小和时间
sql
-- 不好的做法:事务太大
START TRANSACTION;
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 执行大量业务逻辑...
callExternalAPI(); -- 调用外部接口
complexCalculation(); -- 复杂计算
UPDATE users SET status = 1 WHERE id = 1;
COMMIT;
-- 好的做法:缩小事务范围
-- 先执行不需要锁的操作
callExternalAPI();
complexCalculation();
-- 再执行需要锁的操作
START TRANSACTION;
SELECT * FROM users WHERE id = 1 FOR UPDATE;
UPDATE users SET status = 1 WHERE id = 1;
COMMIT;使用合适的隔离级别
sql
-- 降低隔离级别减少锁范围
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;实际业务场景应用
场景1:商品库存扣减
sql
-- 悲观锁方案:适用于高并发秒杀
START TRANSACTION;
SELECT stock FROM products WHERE id = 100 FOR UPDATE; -- 锁定商品记录
-- 检查库存充足性
-- 假设查询结果stock = 10
UPDATE products SET stock = stock - 1
WHERE id = 100 AND stock > 0; -- 原子性扣减库存
-- 检查affected_rows确认扣减成功
COMMIT;场景2:用户余额支付
sql
-- 必须使用悲观锁确保余额不为负
START TRANSACTION;
SELECT balance FROM accounts
WHERE user_id = 12345 FOR UPDATE; -- 锁定用户账户
-- 假设查询结果balance = 1000
-- 检查余额充足后扣减
UPDATE accounts SET balance = balance - 100
WHERE user_id = 12345 AND balance >= 100;
-- 检查affected_rows确认扣减成功
COMMIT;场景3:订单流水号生成
sql
-- 方案1:表锁确保严格递增
LOCK TABLES order_sequence WRITE;
UPDATE order_sequence SET current_no = current_no + 1;
SELECT current_no FROM order_sequence;
UNLOCK TABLES;
-- 方案2:行锁性能更好
START TRANSACTION;
SELECT current_no FROM order_sequence WHERE id = 1 FOR UPDATE;
UPDATE order_sequence SET current_no = current_no + 1 WHERE id = 1;
SELECT current_no FROM order_sequence WHERE id = 1;
COMMIT;场景4:高并发点赞
sql
-- 乐观锁方案:允许偶尔失败
UPDATE articles
SET like_count = like_count + 1, version = version + 1
WHERE id = 100 AND version = @current_version;
-- 在应用层检查affected_rows
-- 如果为0则重试锁监控与故障排查
查看当前锁状态
sql
-- 查看正在运行的事务
SELECT
trx_id,
trx_state,
trx_started,
trx_mysql_thread_id,
trx_query
FROM INFORMATION_SCHEMA.INNODB_TRX;
-- MySQL 8.0查看锁等待关系
SELECT
waiting.thread_id AS waiting_thread,
waiting.object_name AS table_name,
waiting.lock_mode AS waiting_lock_mode,
blocking.thread_id AS blocking_thread,
blocking.lock_mode AS blocking_lock_mode
FROM performance_schema.data_lock_waits w
JOIN performance_schema.data_locks waiting
ON w.requesting_engine_lock_id = waiting.engine_lock_id
JOIN performance_schema.data_locks blocking
ON w.blocking_engine_lock_id = blocking.engine_lock_id;死锁分析
sql
-- 查看最近的死锁信息
SHOW ENGINE INNODB STATUS;
-- 在输出中查找LATEST DETECTED DEADLOCK部分
-- 分析死锁涉及的事务和锁资源解决锁等待问题
sql
-- 找到阻塞的会话
SHOW PROCESSLIST;
-- 杀掉长时间运行的会话
KILL CONNECTION 12345; -- 12345是线程ID锁优化最佳实践
1. 选择合适的隔离级别
sql
-- 读已提交:减少锁争用,提高并发
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 可重复读:防止幻读,但锁更多(默认)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;2. 建立合适的索引
sql
-- 不好:没有索引导致锁表
SELECT * FROM users WHERE name = '张三' FOR UPDATE;
-- 好:有索引只锁相关行
CREATE INDEX idx_name ON users(name);
SELECT * FROM users WHERE name = '张三' FOR UPDATE;3. 优化事务设计
sql
-- 原则1:事务要短小
-- 原则2:避免长时间持锁
-- 原则3:按相同顺序访问资源
-- 实际应用:
START TRANSACTION;
-- 只放必须的数据库操作
SELECT * FROM users WHERE id = 1 FOR UPDATE;
UPDATE users SET status = 1 WHERE id = 1;
COMMIT;
-- 其他业务逻辑放在事务外总结
MySQL锁机制的核心要点:
- 理解锁的本质:保证并发操作的数据一致性
- 掌握锁的层次:全局锁 > 表锁 > 行锁,粒度越小并发越好
- 明确锁的模式:共享锁允许并发读,排他锁禁止一切并发
- 熟悉锁的算法:记录锁精确,间隙锁防插入,Next-Key锁防幻读
- 避免死锁:统一访问顺序,控制事务大小
- 监控锁状态:及时发现和解决锁争用问题
记住:锁是双刃剑,既保证了数据一致性,也影响了系统性能。合理使用锁,在数据安全和系统性能之间找到最佳平衡点,这才是数据库优化的关键!
