一、事务隔离级别基础
1.1 四种隔离级别概述
-- MySQL事务隔离级别(从低到高) -- 1. READ UNCOMMITTED(读未提交) -- 2. READ COMMITTED(读已提交) -- 3. REPEATABLE READ(可重复读)-- MySQL默认级别 -- 4. SERIALIZABLE(串行化)1.2 并发问题类型
/** * 并发问题分类: * 1. 脏读(Dirty Read):读取到其他事务未提交的数据 * 2. 不可重复读(Non-Repeatable Read):同一事务内两次读取结果不一致 * 3. 幻读(Phantom Read):同一事务内两次查询结果集不一致 * 4. 丢失更新(Lost Update):两个事务更新同一数据,后提交的覆盖了先提交的 */二、各隔离级别详解
2.1 READ UNCOMMITTED(读未提交)
-- 设置隔离级别为读未提交 SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; -- 示例:脏读问题演示 -- 事务A START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE user_id = 1; -- 此时 balance 已修改但未提交 -- 事务B(在另一个连接) SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION; SELECT balance FROM account WHERE user_id = 1; -- 会读取到事务A未提交的修改(脏读) -- 如果事务A回滚,事务B读取的数据就是错误的实际业务场景:
几乎不使用,除非对数据一致性要求极低
可能的用途:实时监控系统,允许数据短暂不一致
2.2 READ COMMITTED(读已提交)
-- 设置隔离级别为读已提交 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 示例:不可重复读问题演示 -- 事务A START TRANSACTION; SELECT balance FROM account WHERE user_id = 1; -- 第一次读取:balance = 1000 -- 事务B START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE user_id = 1; COMMIT; -- 提交修改 -- 事务A再次读取 SELECT balance FROM account WHERE user_id = 1; -- 第二次读取:balance = 900(不可重复读) COMMIT;MVCC(多版本并发控制)实现原理:
-- MySQL在READ COMMITTED下的实现 -- 每行数据有隐藏字段: -- DB_TRX_ID:最后修改该记录的事务ID -- DB_ROLL_PTR:回滚指针,指向undo log中的旧版本 -- DB_ROW_ID:行ID(隐藏主键) -- 事务可见性规则: -- 1. 版本号小于当前事务ID的记录 -- 2. 删除版本号未定义或大于当前事务ID -- 3. 在READ COMMITTED下,每次查询都重新生成ReadView实际业务场景:
Oracle、PostgreSQL的默认级别
适合大多数OLTP系统
报表系统(需要实时最新数据)
对数据实时性要求高的场景
2.3 REPEATABLE READ(可重复读)- MySQL默认
-- MySQL默认隔离级别 SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- 示例:解决不可重复读 -- 事务A START TRANSACTION; SELECT balance FROM account WHERE user_id = 1; -- 第一次读取:balance = 1000 -- 事务B START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE user_id = 1; COMMIT; -- 事务A再次读取 SELECT balance FROM account WHERE user_id = 1; -- 第二次读取:balance = 1000(可重复读,读取的是快照) COMMIT;幻读问题演示:
-- 事务A:统计账户数量 START TRANSACTION; SELECT COUNT(*) FROM account WHERE balance > 0; -- 返回:5 -- 事务B:插入新账户 START TRANSACTION; INSERT INTO account(user_id, balance) VALUES (6, 500); COMMIT; -- 事务A:再次统计 SELECT COUNT(*) FROM account WHERE balance > 0; -- 在REPEATABLE READ下,返回仍然是:5(没有幻读) -- 但如果执行UPDATE/INSERT,可能会看到"幻影行"间隙锁(Gap Lock)解决幻读:
-- 事务A START TRANSACTION; -- 使用SELECT ... FOR UPDATE添加间隙锁 SELECT * FROM account WHERE id > 100 FOR UPDATE; -- 这会锁定id>100的所有记录和间隙 -- 事务B试图插入 INSERT INTO account(id, user_id) VALUES (101, 6); -- 会被阻塞,直到事务A提交 -- 查看当前锁信息 SHOW ENGINE INNODB STATUS;实际业务场景:
财务系统(需要事务内数据一致性)
对账系统(统计期间数据不能变化)
需要稳定数据视图的应用
2.4 SERIALIZABLE(串行化)
-- 串行化隔离级别 SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- 所有SELECT语句都会隐式添加LOCK IN SHARE MODE -- 事务A START TRANSACTION; SELECT balance FROM account WHERE user_id = 1; -- 自动加共享锁 -- 事务B START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE user_id = 1; -- 会被阻塞,直到事务A提交实际业务场景:
银行转账(需要绝对数据一致性)
库存扣减(超高并发时需要串行化)
敏感数据操作(如密码重置)
三、MVCC(多版本并发控制)深度解析
3.1 MVCC实现原理
-- InnoDB MVCC数据结构示例 CREATE TABLE account ( id INT PRIMARY KEY, user_id INT, balance DECIMAL(10,2), -- 隐藏字段 -- DB_TRX_ID: 6字节,最后修改事务ID -- DB_ROLL_PTR: 7字节,回滚指针 -- DB_ROW_ID: 6字节,隐藏主键 -- DELETE BIT: 删除标记 ); -- ReadView创建时机: -- READ COMMITTED: 每次SELECT都创建新的ReadView -- REPEATABLE READ: 第一次SELECT时创建ReadView,整个事务复用 -- 可见性判断算法: -- 1. 如果DB_TRX_ID < up_limit_id,可见(事务开始前已提交) -- 2. 如果DB_TRX_ID >= low_limit_id,不可见(事务开始后开始的) -- 3. 如果DB_TRX_ID在活跃事务列表中,不可见(未提交) -- 4. 否则可见3.2 Undo Log链示例
-- 假设原始数据 -- id=1, balance=1000, DB_TRX_ID=10 -- 事务20修改 UPDATE account SET balance = 900 WHERE id = 1; -- 新版本:balance=900, DB_TRX_ID=20, 回滚指针指向旧版本 -- 事务30修改 UPDATE account SET balance = 800 WHERE id = 1; -- 新版本:balance=800, DB_TRX_ID=30, 回滚指针指向事务20的版本 -- 版本链: -- 当前版本(事务30) ← 事务20版本 ← 事务10版本四、实际业务问题与解决方案
4.1 电商库存超卖问题
-- 问题场景:高并发下库存扣减 -- 错误做法(存在超卖风险) START TRANSACTION; SELECT stock FROM product WHERE id = 1; -- 假设stock = 10 IF stock > 0 THEN UPDATE product SET stock = stock - 1 WHERE id = 1; END IF; COMMIT; -- 解决方案1:使用SELECT ... FOR UPDATE(悲观锁) START TRANSACTION; -- 加行锁,阻止其他事务读取 SELECT stock FROM product WHERE id = 1 FOR UPDATE; -- 此时其他事务的SELECT ... FOR UPDATE会被阻塞 IF stock > 0 THEN UPDATE product SET stock = stock - 1 WHERE id = 1; END IF; COMMIT; -- 解决方案2:使用乐观锁(版本控制) ALTER TABLE product ADD version INT DEFAULT 0; START TRANSACTION; SELECT stock, version FROM product WHERE id = 1; -- 假设stock=10, version=1 IF stock > 0 THEN UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = 1 AND version = 1; -- 如果影响行数为0,说明版本已变,需要重试 END IF; COMMIT; -- 解决方案3:直接UPDATE判断 START TRANSACTION; UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock > 0; -- 返回影响行数,如果为0表示库存不足 COMMIT;4.2 银行转账并发问题
-- 场景:A向B转账,需要保证原子性和一致性 -- 问题:并发转账可能导致余额错误 -- 解决方案:使用SERIALIZABLE隔离级别或精心设计的事务 SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; -- 关键:按固定顺序加锁,避免死锁 -- 总是先锁id小的账户 SELECT * FROM account WHERE id IN (1, 2) ORDER BY id FOR UPDATE; -- 检查A账户余额 SELECT balance FROM account WHERE id = 1; -- 执行转账 UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; -- 记录流水 INSERT INTO transfer_log(from_id, to_id, amount) VALUES (1, 2, 100); COMMIT;4.3 报表统计不一致问题
-- 场景:生成财务报表时,数据被其他事务修改 -- 要求:统计期间数据必须一致 -- 解决方案1:使用REPEATABLE READ + 开始时间点 SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; -- 记录开始时间 SET @report_time = NOW(); -- 统计逻辑(所有查询看到的是同一时间点的快照) SELECT SUM(balance) FROM account; SELECT COUNT(*) FROM transfer_log WHERE create_time < @report_time; COMMIT; -- 解决方案2:使用备份或从库查询 -- 在从库上使用REPEATABLE READ,不影响主库性能 START TRANSACTION; SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- ... 统计查询 COMMIT;4.4 消息队列消费幂等性问题
-- 场景:消息重复消费,需要保证幂等性 -- 问题:重复处理同一消息 CREATE TABLE message_consumed ( id BIGINT PRIMARY KEY AUTO_INCREMENT, message_id VARCHAR(64) UNIQUE, -- 消息唯一ID status TINYINT DEFAULT 0, consume_time DATETIME ); -- 消费消息时的幂等处理 START TRANSACTION; -- 方案1:先插入,利用唯一索引保证幂等 INSERT IGNORE INTO message_consumed(message_id, consume_time) VALUES ('msg_123', NOW()); -- 如果插入成功(影响行数>0),则处理消息 IF ROW_COUNT() > 0 THEN -- 执行业务逻辑 CALL process_business_logic('msg_123'); UPDATE message_consumed SET status = 1 WHERE message_id = 'msg_123'; END IF; COMMIT;五、死锁分析与解决
5.1 死锁场景模拟
-- 事务A START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE id = 1; -- 持有id=1的锁 -- 事务B(同时执行) START TRANSACTION; UPDATE account SET balance = balance - 200 WHERE id = 2; -- 持有id=2的锁 -- 事务A继续 UPDATE account SET balance = balance + 100 WHERE id = 2; -- 等待事务B释放id=2的锁 -- 事务B继续 UPDATE account SET balance = balance + 200 WHERE id = 1; -- 等待事务A释放id=1的锁 -- 死锁发生!5.2 死锁检测与解决
-- 查看死锁日志 SHOW ENGINE INNODB STATUS; -- 死锁日志示例: -- LATEST DETECTED DEADLOCK -- *** (1) TRANSACTION: -- TRANSACTION 3100, ACTIVE 2 sec starting index read -- mysql tables in use 1, locked 1 -- LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) -- MySQL thread id 10, OS thread handle 1234, query id 100 updating -- UPDATE account SET balance = balance + 100 WHERE id = 2 -- 预防死锁策略: -- 1. 按相同顺序访问资源 -- 2. 减少事务执行时间 -- 3. 使用低隔离级别(READ COMMITTED) -- 4. 添加合理的索引,减少锁范围 -- 代码层面的解决方案 START TRANSACTION; -- 总是按id顺序加锁 SELECT * FROM account WHERE id IN (1, 2) ORDER BY id FOR UPDATE; -- 执行更新操作 UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; COMMIT;5.3 间隙锁死锁问题
-- 间隙锁导致的死锁场景 -- 表结构 CREATE TABLE orders ( id INT PRIMARY KEY, user_id INT, amount DECIMAL(10,2), KEY idx_user_id (user_id) ); -- 事务A START TRANSACTION; -- 对user_id=100加间隙锁(锁定100-200之间的间隙) SELECT * FROM orders WHERE user_id = 150 FOR UPDATE; -- 事务B START TRANSACTION; -- 对user_id=200加间隙锁(锁定100-200之间的间隙) SELECT * FROM orders WHERE user_id = 180 FOR UPDATE; -- 事务A尝试插入 INSERT INTO orders(id, user_id) VALUES (1, 160); -- 等待事务B的间隙锁 -- 事务B尝试插入 INSERT INTO orders(id, user_id) VALUES (2, 170); -- 等待事务A的间隙锁 -- 死锁!六、性能优化与最佳实践
6.1 隔离级别选择建议
-- 选择合适隔离级别的决策流程 /** * 决策树: * 1. 需要避免脏读?是 → 至少READ COMMITTED * 2. 需要避免不可重复读?是 → 至少REPEATABLE READ * 3. 需要避免幻读?是 → SERIALIZABLE * 4. 并发性能要求高?是 → 考虑降低隔离级别 * 5. 有明确的锁策略?是 → 可以使用较低隔离级别+手动加锁 */ -- 各隔离级别适用场景总结: -- READ UNCOMMITTED: 统计类只读查询,允许脏数据 -- READ COMMITTED: 大多数OLTP系统,需要实时数据 -- REPEATABLE READ: 财务系统,对账系统,需要稳定视图 -- SERIALIZABLE: 银行核心交易,需要绝对一致性6.2 事务设计最佳实践
-- 实践1:保持事务短小 -- 错误示例:长事务 START TRANSACTION; -- 复杂业务逻辑 -- 网络调用 -- 文件操作 -- 大量计算 COMMIT; -- 事务持续时间太长 -- 正确示例:拆分事务 -- 事务1:数据准备 START TRANSACTION; INSERT INTO temp_data SELECT ...; COMMIT; -- 事务2:业务处理 START TRANSACTION; UPDATE ...; COMMIT; -- 实践2:避免在事务中执行SELECT ... FOR UPDATE时扫描大量数据 -- 错误示例 START TRANSACTION; SELECT * FROM orders WHERE create_time > '2023-01-01' FOR UPDATE; -- 可能锁定大量行,导致性能问题 -- 正确示例:分批处理 DECLARE done INT DEFAULT FALSE; DECLARE batch_size INT DEFAULT 100; DECLARE offset INT DEFAULT 0; WHILE NOT done DO START TRANSACTION; SELECT * FROM orders WHERE create_time > '2023-01-01' LIMIT batch_size OFFSET offset FOR UPDATE; -- 处理逻辑 COMMIT; SET offset = offset + batch_size; -- 检查是否完成 END WHILE;6.3 监控与调优
-- 监控当前事务 SELECT * FROM information_schema.INNODB_TRX; -- 监控锁信息 SELECT * FROM information_schema.INNODB_LOCKS; SELECT * FROM information_schema.INNODB_LOCK_WAITS; -- 监控长事务 SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) as duration_seconds, trx_state, trx_query FROM information_schema.INNODB_TRX WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60 -- 超过60秒的事务 ORDER BY trx_started; -- 设置长事务超时 SET SESSION innodb_lock_wait_timeout = 50; -- 锁等待超时50秒 SET SESSION innodb_rollback_on_timeout = ON; -- 超时自动回滚七、实际案例分析
7.1 电商秒杀系统
-- 秒杀场景下的库存扣减优化 -- 表设计 CREATE TABLE seckill_stock ( product_id BIGINT PRIMARY KEY, stock INT NOT NULL, version INT DEFAULT 0, sale_date DATE ); -- 方案1:使用乐观锁+重试机制 DELIMITER // CREATE PROCEDURE seckill_purchase( IN p_product_id BIGINT, IN p_user_id BIGINT, OUT p_result INT ) BEGIN DECLARE v_stock INT; DECLARE v_version INT; DECLARE retry_count INT DEFAULT 0; DECLARE max_retry INT DEFAULT 3; purchase_retry: REPEAT START TRANSACTION; -- 查询当前库存和版本 SELECT stock, version INTO v_stock, v_version FROM seckill_stock WHERE product_id = p_product_id FOR UPDATE; -- 悲观锁,防止其他事务同时修改 IF v_stock <= 0 THEN ROLLBACK; SET p_result = 0; -- 库存不足 LEAVE purchase_retry; END IF; -- 更新库存 UPDATE seckill_stock SET stock = stock - 1, version = version + 1 WHERE product_id = p_product_id AND version = v_version; -- 检查是否更新成功 IF ROW_COUNT() = 1 THEN -- 创建订单 INSERT INTO seckill_order(product_id, user_id, create_time) VALUES (p_product_id, p_user_id, NOW()); COMMIT; SET p_result = 1; -- 成功 LEAVE purchase_retry; ELSE ROLLBACK; SET retry_count = retry_count + 1; -- 等待随机时间后重试 DO SLEEP(RAND() * 0.1); END IF; UNTIL retry_count >= max_retry END REPEAT; IF retry_count >= max_retry THEN SET p_result = -1; -- 重试失败 END IF; END// DELIMITER ;7.2 金融对账系统
-- 对账系统需要数据一致性 SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; -- 记录对账开始时间 SET @reconcile_time = NOW(); -- 创建对账快照表 CREATE TEMPORARY TABLE reconcile_snapshot AS SELECT a.account_no, a.balance as db_balance, b.balance as external_balance FROM account a LEFT JOIN external_system b ON a.account_no = b.account_no WHERE a.update_time <= @reconcile_time; -- 执行对账逻辑 SELECT account_no, db_balance, external_balance, CASE WHEN ABS(db_balance - external_balance) > 0.01 THEN 'MISMATCH' ELSE 'MATCH' END as status FROM reconcile_snapshot; -- 记录对账结果 INSERT INTO reconcile_log(reconcile_time, total_count, mismatch_count) SELECT @reconcile_time, COUNT(*), SUM(CASE WHEN ABS(db_balance - external_balance) > 0.01 THEN 1 ELSE 0 END) FROM reconcile_snapshot; COMMIT;7.3 社交系统点赞功能
-- 点赞功能,需要避免重复点赞 CREATE TABLE likes ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, post_id BIGINT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_post (user_id, post_id) -- 唯一约束防止重复 ); -- 点赞操作 START TRANSACTION; -- 尝试插入,利用唯一约束保证幂等性 INSERT IGNORE INTO likes(user_id, post_id) VALUES (123, 456); -- 如果插入成功,更新帖子点赞数 IF ROW_COUNT() > 0 THEN UPDATE posts SET like_count = like_count + 1 WHERE id = 456; END IF; COMMIT; -- 取消点赞 START TRANSACTION; DELETE FROM likes WHERE user_id = 123 AND post_id = 456; -- 如果删除成功,更新帖子点赞数 IF ROW_COUNT() > 0 THEN UPDATE posts SET like_count = GREATEST(0, like_count - 1) WHERE id = 456; END IF; COMMIT;八、常见问题与陷阱
8.1 自动提交陷阱
-- MySQL默认autocommit=1,每条语句都是一个事务 -- 可能导致意想不到的问题 -- 关闭自动提交 SET autocommit = 0; -- 显式控制事务 START TRANSACTION; -- 业务逻辑 COMMIT; -- 或 ROLLBACK; -- 恢复自动提交 SET autocommit = 1;8.2 隐式提交操作
-- 以下语句会隐式提交当前事务: -- 1. DDL语句(CREATE, ALTER, DROP等) -- 2. 用户权限管理语句 -- 3. 锁表语句(LOCK TABLES, UNLOCK TABLES) -- 错误示例 START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE id = 1; -- 这个语句会提交事务! CREATE INDEX idx_balance ON account(balance); UPDATE account SET balance = balance + 100 WHERE id = 2; -- 如果这里出错,第一个UPDATE已经提交,无法回滚! COMMIT;8.3 大事务问题
-- 大事务可能导致的问题: -- 1. 锁持有时间长,阻塞其他事务 -- 2. 产生大量undo log,占用磁盘 -- 3. 主从复制延迟 -- 4. 回滚时间长 -- 解决方案:拆分大事务 -- 原始大事务 START TRANSACTION; -- 处理10万条数据 UPDATE large_table SET status = 1 WHERE condition; COMMIT; -- 可能执行几分钟 -- 拆分为小批次 SET autocommit = 0; SET @rows_affected = 1; WHILE @rows_affected > 0 DO START TRANSACTION; UPDATE large_table SET status = 1 WHERE condition AND status = 0 LIMIT 1000; SET @rows_affected = ROW_COUNT(); COMMIT; -- 短暂暂停,减少对系统影响 DO SLEEP(0.1); END WHILE; SET autocommit = 1;总结
MySQL事务隔离级别的选择需要权衡一致性、并发性能和数据准确性:
READ UNCOMMITTED:几乎不用,除非特殊场景
READ COMMITTED:适合大多数OLTP系统,需要实时数据
REPEATABLE READ(MySQL默认):需要稳定数据视图的场景
SERIALIZABLE:需要绝对一致性的关键系统
最佳实践建议:
优先使用REPEATABLE READ,配合合理的锁策略
事务尽量短小,避免长事务
合理使用索引,减少锁竞争
监控长事务和死锁,及时优化
根据业务特点选择合适隔离级别,不要盲目追求高隔离级别
性能优化要点:
热点数据使用乐观锁+重试机制
批量操作使用分批次处理
避免事务中的网络I/O和复杂计算
使用从库进行统计查询,减轻主库压力