数据库里的薛定谔读:为什么你每次查出来的数据都不一样

2026-09-18 8 0

写代码这么多年,我最信的一句话就是:数据库不会骗人

直到有一天,两个接口查同一条订单,返回的价格不一样。

同一个数据库,同一时间,两行SQL。

那一刻我意识到,数据库不是不会骗人——是你没搞懂它用什么姿势骗你。


先说个真事

线上出了个bug:用户下单后,订单详情页显示的价格是旧价格,但支付的时候扣的是新价格。差了几块钱,用户直接炸了。

查了一圈,发现是这样的:订单表的价格字段在创建时写入,而订单详情页有个缓存,缓存失效后去数据库重新查——但这个查询走了某个只读副本,复制延迟刚好把那批旧数据送过来了。

这不是bug,这是数据库隔离级别和复制延迟共同演出的一场戏。而你我都是观众。

隔离级别是什么?

数据库用隔离级别来控制"我能看见多少别人的脏活"。隔离得越严,性能越差;放得越开,并发越高,但数据就越可能乱成一锅粥。

SQL标准定义了四个隔离级别,从松到严:

  • READ UNCOMMITTED(读未提交)—— 什么脏活都能看见
  • READ COMMITTED(读已提交)—— 只能看见已提交的
  • REPEATABLE READ(可重复读)—— 我的读在整个事务里不变
  • SERIALIZABLE(串行化)—— 当作根本没并发这回事

理论很美,但每一级都有自己的骚操作。

READ UNCOMMITTED:皇帝的新衣

这个级别能看到别人未提交的数据。

你可能会想:这不脑子有病吗?谁会用这个?

实际上MySQL默认就是这个(嗯,你没看错,Oracle默认是READ COMMITTED)。

但别慌,MySQL的InnoDB有undo日志做保护,所以即使在这个级别也很难真正读到脏数据。不过"很难"不等于"不可能",某些边缘case下依然会中招。

这个级别适合那种"我就是大致看看"的分析任务,真正的业务代码一般不用。

READ COMMITTED:每次读取都是快照

这是Oracle、SQL Server、PostgreSQL的默认级别。

每次读取都会生成一个新快照。所以你在同一个事务里执行两次同样的SELECT,结果可能不一样——因为在你读的时候,另一个事务刚提交了。

这就是著名的Non-Repeatable Read(不可重复读):我同一行数据读两次,结果不一样了。

看个例子:

-- 事务A(时间线)
BEGIN;
SELECT balance FROM accounts WHERE user_id = 1;
-- 结果:1000

-- 事务B(另一个会话)
UPDATE accounts SET balance = 2000 WHERE user_id = 1;
COMMIT;

-- 事务A继续
SELECT balance FROM accounts WHERE user_id = 1;
-- 结果:2000 —— 变了!

同一个事务,两次读,余额从1000变成了2000。这在READ COMMITTED下是正常现象,不算bug。

REPEATABLE READ:MySQL的默认模式

MySQL InnoDB默认就是这个级别。

它的设计目标是:同一个事务里,多次读同一行,数据不变。用的是基于undo日志和多版本并发控制(MVCC)来实现的。

但!这个级别有个人尽皆知的漏洞——Phantom Read(幻读)

幻读不是同一行数据变了,而是符合条件的行数变了。比如:

-- 事务A
BEGIN;
SELECT * FROM orders WHERE status = 'pending';
-- 找到10条

-- 事务B(另一个会话)
INSERT INTO orders (status) VALUES ('pending');
-- 新增了一条
COMMIT;

-- 事务A继续
SELECT * FROM orders WHERE status = 'pending';
-- 找到11条 —— 多了!

Phantom Read就这么来的。InnoDB通过Next-Key Locking在很大程度上缓解了幻读问题,但"很大程度上"不等于"彻底解决",某些边缘case下依然会中招。

SERIALIZABLE:简单粗暴但性能杀手

最严格的隔离级别。所有并发读写都变成串行的了。

好处是数据绝对干净。坏处是——等你用户从1000变成10个的时候,你会开始怀疑人生。

大部分应用不用这个级别,除非你做财务相关的事情,或者监管部门要求"数据绝对不能有任何歧义"。

真实踩坑:一个账户余额的灵异事件

说个我实际踩过的坑。

业务逻辑是这样的:用户发起提现,先查余额够不够,然后扣款,最后更新余额。

-- 检查余额
SELECT balance FROM accounts WHERE user_id = ?;
-- balance = 1000

-- 业务判断:1000 >= 800,可以提现

-- 扣款
UPDATE accounts SET balance = balance - 800 WHERE user_id = ?;

并发的时候,两个请求同时进来:

  • 请求A查余额:1000
  • 请求B查余额:1000(同一时刻,A还没提交)
  • 请求A扣款:更新为200
  • 请求B扣款:更新为200(覆盖了A的结果!)

余额从1000变成了200,而不是预期的-600或者报错。

用户白套现了800,财务看了想打人。

解法很简单——用悲观锁或者乐观锁把这个读-改-写流程包在一个事务里

-- 悲观锁方案
BEGIN;
SELECT balance FROM accounts WHERE user_id = ? FOR UPDATE;
-- 这里会锁定这行,直到事务结束
-- 检查余额,扣款
UPDATE accounts SET balance = balance - 800 WHERE user_id = ?;
COMMIT;

-- 乐观锁方案
UPDATE accounts 
SET balance = balance - 800, version = version + 1 
WHERE user_id = ? AND version = ? AND balance >= 800;
-- 检查affected rows,为0就回滚重试

分布式环境下的问题更严重

单机数据库事务是ACID的,有隔离性保护。但在分布式数据库或者主从复制场景下,主库和从库的隔离级别可能不一样,或者从库有复制延迟。

读已提交的数据,在从库上可能是读到了"历史版本"。

所以关键业务:写操作读己之所写,读操作尽可能走主库,或者在应用层做补偿。

最后给你几条建议

  1. 知道你的数据库默认隔离级别是什么。MySQL是REPEATABLE READ,PostgreSQL和Oracle是READ COMMITTED。不清楚这个,写出来的代码在另一个数据库上就跑偏了。
  2. 不要依赖隔离级别来保证业务正确性。隔离级别是防止并发问题的一个安全网,不是业务逻辑的替代品。该用锁用锁,该用版本号用版本号。
  3. 主从复制下,关键读取走主库。从库的复制延迟是不可控的,用它做业务判断就是在赌运气。
  4. 理解MVCC,但不要迷信MVCC。MVCC让读不阻塞写、写不阻塞读,但它不是万能药。范围查询的锁、唯一索引的检查,依然需要真实的锁。

结语

数据库隔离级别是教科书里"好像懂了但其实没懂"的典型代表。背出四个级别的名字不难,但真正上线遇到"为什么我查不到我刚插入的数据"或者"为什么并发扣款会超扣"的时候,才知道这一课欠了多少债。

下次再遇到"数据不对"的bug,别急着骂数据库。

先问自己一句:我真的懂它吗?

相关文章

我做压力测试时发现的那些高性能代码,其实是性能杀手
那个你优雅退出的goroutine,正在生产环境慢慢憋死你
写API五年,我踩过的那些坑比代码行数还多
你的数据库查询正在偷偷杀死你的应用——而你还在写”更优雅”的代码
老板让我做实时通信,我差点把服务器polling到冒烟
不想折腾了?让小龙虾帮你一键部署AI工具,省心又省力!

发布评论