第一次接触数据库事务时,我也被那些专业术语搞得晕头转向。直到有一次线上支付系统出了bug,用户付款后订单状态却没更新,我才真正明白事务的重要性。简单来说,事务就是一组要么全部成功,要么全部失败的数据库操作,就像你网购时"付款+减库存+生成订单"必须作为一个整体执行。
最经典的例子就是银行转账:
sql复制START TRANSACTION;
UPDATE accounts SET balance = balance - 1000 WHERE user_id = 'A';
UPDATE accounts SET balance = balance + 1000 WHERE user_id = 'B';
COMMIT;
如果执行到第二条语句时数据库崩溃,没有事务机制的话就会出现A账户扣款成功但B账户未到账的严重事故。事务的ACID特性就是为解决这类问题而生的:
在实际开发中,我们最常遇到的问题是隔离性。比如电商秒杀场景,100人同时抢10件商品,如果没有合理的事务隔离,就可能出现超卖。我曾经就遇到过明明库存显示还剩1件,却成功卖出3件的灵异事件——这就是典型的隔离级别设置不当导致的幻读问题。
MySQL提供了四种隔离级别,就像四道不同强度的防火墙。很多开发者只知道默认的可重复读(REPEATABLE READ),却不知道不同场景该如何选择。去年我们系统从读已提交切换到可重复读时,就曾导致大量死锁,后来花了三周才彻底解决。
这是最宽松的级别,相当于完全不设防。我做过一个实验:
sql复制-- 会话A
START TRANSACTION;
UPDATE products SET stock = 50 WHERE id = 1; -- 未提交
-- 会话B
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT stock FROM products WHERE id = 1; -- 居然能读到50!
这种读到"脏数据"的情况就叫脏读。虽然性能最好,但实际项目几乎不会用,除非你能接受显示"即将到账"这样的临时状态。
Oracle的默认级别,解决了脏读问题。但在一个账单统计系统中,我遇到过这样的问题:
sql复制-- 9:00 查询余额为1000元
SELECT balance FROM accounts WHERE user_id = 'A';
-- 9:01 其他事务转账并提交
UPDATE accounts SET balance = 500 WHERE user_id = 'A';
COMMIT;
-- 9:02 再次查询变成500元
SELECT balance FROM accounts WHERE user_id = 'A';
这就是不可重复读,同一事务内两次读取结果不一致。对于对账系统这种需要数据快照的场景就很致命。
MySQL的默认级别,通过多版本并发控制(MVCC)实现。我在用户积分系统用过:
sql复制-- 会话A
START TRANSACTION;
SELECT points FROM users WHERE id = 1; -- 返回100
-- 会话B
UPDATE users SET points = 200 WHERE id = 1;
COMMIT;
-- 会话A再次查询仍返回100
SELECT points FROM users WHERE id = 1;
但这里有个坑:如果执行UPDATE users SET points = points - 10,实际扣减的是真实值200而不是看到的100!这就是MVCC的"当前读"特性。
最严格的级别,相当于单线程执行。我们曾在财务系统试过:
sql复制-- 会话A
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
SELECT * FROM transactions WHERE amount > 1000; -- 加共享锁
-- 会话B
INSERT INTO transactions VALUES(null, 1500); -- 被阻塞直到会话A提交
虽然绝对安全,但并发性能下降10倍不止。除非是涉及资金结算的核心系统,否则慎用。
去年双十一大促时,我们的订单系统出现了诡异现象:有些用户明明支付成功,订单却显示"待付款"。经过排查,正是事务隔离导致的问题。
在可重复读级别下:
sql复制-- 会话A
START TRANSACTION;
SELECT COUNT(*) FROM orders WHERE user_id = 100; -- 返回3
-- 会话B
INSERT INTO orders VALUES(null, 100, 'paid');
COMMIT;
-- 会话A
SELECT COUNT(*) FROM orders WHERE user_id = 100; -- 仍返回3
UPDATE orders SET status = 'shipped' WHERE user_id = 100; -- 竟然更新了4行!
这就是幻读:虽然查询看不到新增记录,但更新却能影响它们。解决方案有两种:
sql复制SELECT * FROM orders WHERE user_id = 100 FOR UPDATE;
这是我们遇到的一个真实死锁案例:
sql复制-- 会话A
START TRANSACTION;
UPDATE products SET stock = stock - 1 WHERE id = 1; -- 持有id=1的行锁
UPDATE products SET stock = stock - 1 WHERE id = 2; -- 等待会话B释放锁
-- 会话B
START TRANSACTION;
UPDATE products SET stock = stock - 1 WHERE id = 2; -- 持有id=2的行锁
UPDATE products SET stock = stock - 1 WHERE id = 1; -- 等待会话A释放锁
MySQL检测到死锁后会自动回滚其中一个事务。通过SHOW ENGINE INNODB STATUS可以查看死锁日志。
在千万级用户系统中,我们这样优化事务:
START TRANSACTION READ ONLYSET TRANSACTION ISOLATION LEVEL READ COMMITTED经过多次踩坑,我总结出这套选型策略:
sql复制UPDATE products SET stock = stock - 1
WHERE id = 1 AND stock >= 1;
实际配置时,可以在my.cnf中设置默认级别:
ini复制[mysqld]
transaction-isolation = READ-COMMITTED
记得在调整隔离级别后,用以下命令验证:
sql复制SELECT @@global.transaction_isolation, @@session.transaction_isolation;
不同业务场景需要不同的事务策略,没有放之四海而皆准的方案。我在处理一个跨国电商系统时,就曾为不同地区的业务定制了不同的事务隔离级别。关键是要理解业务对数据一致性的真实需求,而不是盲目追求最高隔离级别。