我见过最可怕的线上故障,不是接口500报错,而是接口跑得稳稳当当,所有监控都是绿的,但数据库CPU已经炸到了100%,DBA半夜打电话过来问"你们是不是在跑什么全表扫描"。
这不是段子,这是某次真实的生产事故。问题的根源简单到可笑:一个开发随手写的查询,在数据量小的时候跑得飞快,等数据涨到百万级,直接把从库拖垮了。而接口层面,因为加了try-catch,还把错误日志吞掉了,对外表现就是一个字——稳。
200 OK,数据库已死。这就是我要说的:200不是一切安好,200只是你不知道发生了什么。
一、那些让数据库悄悄窒息的写法
先说最常见的几种"慢性自杀"式代码。不是那种明显的死循环或者跑全表,而是一些看起来很合理,实际上埋了雷的写法。
1. N+1:温水煮青蛙的典范
这问题讲了多少年了,但直到今天我还能在生产代码里看到。典型场景:查出一批订单,然后循环里查每个订单的详情。
// 伪代码
orders = db.query("SELECT id, amount FROM orders WHERE user_id = ?", userId)
for order in orders:
items = db.query("SELECT * FROM order_items WHERE order_id = ?", order.id)
# 搞点事情
100个订单,就是101次数据库往返。在小数据量下这不是问题,等你用户量起来了,这就是一场DDoS攻击,只不过攻击源是你自己的代码。
解决方案简单到离谱:JOIN一下,或者用IN批量查。代码多写两行,但数据库少受罪。
# 批量查询,避免N+1
order_ids = [o.id for o in orders]
items = db.query("SELECT * FROM order_items WHERE order_id IN (?)", order_ids)
2. 索引建了但没用上——最隐蔽的杀手
很多人以为建了索引就万事大吉。too young。有几种情况索引会失效,你可能天天在写,但完全不知道:
-- 隐式类型转换,字符串字段用了数字参数
SELECT * FROM users WHERE phone = 13800138000;
-- 函数操作,索引列被套了函数
SELECT * FROM orders WHERE DATE(created_at) = 2026-07-01;
-- 模糊查询以%开头
SELECT * FROM products WHERE name LIKE %小龙虾%;
第三种最阴险。需求写着"支持按商品名称搜索",开发一拍脑袋用了LIKE %关键词%,以为很智能。实际上只要前面有%,索引就废了。更离谱的是,数据量大了之后,LIKE %keyword%的性能比全表扫描还差,因为它无法利用B+树的任何特性。
解决方案:全文索引,或者换Elasticsearch。
3. 分页offset太大——你在让数据库做无用功
SELECT * FROM posts ORDER BY id DESC LIMIT 1000000, 20;
这行代码的含义是:让数据库先扫描前1000020条,然后丢掉前1000000条,只返回最后20条。随着offset增大,扫描的行数线性增长,延迟也跟着涨,但用户感知到的是"越来越慢"而不是"根本查不动"。
更好的做法是基于游标的分页(keyset pagination),用上一页最后一条的id作为起点:
SELECT * FROM posts
WHERE id < ?
ORDER BY id DESC
LIMIT 20;
无论翻到第几页,查询都是常数时间。这就是Twitter、Facebook用的分页方式。
二、连接池:被忽视的隐形炸弹
说完查询层面的问题,再聊一个更底层的东西——数据库连接池。这玩意儿配置不对,比任何慢查询都致命。
连接池太小,会导致请求排队。想象你有100个并发请求,但数据库连接池最大只有20个,那80个请求就得在那等着,等着等着超时就来了。
连接池太大呢?以为多开点连接就能扛住并发?错。数据库连接本质上是进程/线程,操作系统切换上下文是有成本的。连接太多,CPU光忙着调度线程了,实际的计算时间反而变少。
那连接池多大算合适?有个经验公式:
连接数 = (核心数 * 2) + 有效磁盘数
当然这是PostgreSQL官方文档里的建议。MySQL的公式略有不同。但更重要的是:这个数字不是一成不变的,要结合实际压测来调优。纸上谈兵没用,上生产跑一跑,看监控,看延迟分布,找到那个拐点。
还有一个很多人忽略的问题——连接泄漏。代码里打开连接但忘记关闭,或者某些异常路径没有释放连接。时间一长,连接池被耗干,新的请求再也拿不到连接,直接报错。更要命的是,这种情况在测试环境根本测不出来,因为测试数据量小,连接用完就能及时释放。上了生产,24小时跑着,连接慢慢泄漏,等你发现的时候已经晚了。
三、事务:要么太长,要么太短
事务是个好东西,但用不好就是灾难。
事务太长——锁的噩梦
有人在事务里做了这么几件事:查数据、发HTTP请求、查数据、写数据。这四步串在一起,事务时间就上去了。事务期间,相关行被锁着,其他写操作只能等着。如果批量处理几十上百条,那数据库的锁等待队列就爆炸了。
# 反面教材:事务里混入了不必要的IO操作
BEGIN;
user = db.query("SELECT * FROM users WHERE id = ? FOR UPDATE", userId);
external_data = http.get("http://third-party/api"); # 网络IO,耗时不确定
user.balance += external_data["reward"];
db.execute("UPDATE users SET balance = ? WHERE id = ?", user.balance, userId);
COMMIT;
网络请求是不可控的,可能几百毫秒,可能几秒。把这种东西放进事务里,就是给自己埋雷。
事务太短——数据不一致
另一个极端是完全不用事务,UPDATE语句直接往外抛。这种场景下,如果两步操作之间程序崩了,数据就处在一个不一致的状态。比如先扣钱,再发货,两步之间崩了,钱扣了但货没发,用户的客服电话就打过来了。
结论:事务要刚好够用。事务内的操作应该尽可能少,尽可能快,网络IO和复杂计算一律扔到事务外面。
四、实战建议:怎么在死之前发现问题
说了这么多问题,最后给几点真正管用的实战建议。
第一,慢查询日志必须开。MySQL的slow_query_log,阈值设个500ms,定期review。PostgreSQL有pg_stat_statements,能看到哪些查询执行次数多、耗时久。别嫌麻烦,这是免费的生产监控。
第二,数据库监控不要只看QPS。QPS高不代表有问题,QPS低也不代表没问题。要看平均执行时间、看P99/P999延迟、看锁等待时间、看连接池使用率。这些指标才反映真实用户体验。
第三,每次发布前跑一次全量查询计划分析。EXPLAIN看一下,扫描了多少行,有没有用上索引,数据量大的表重点盯。新功能上线前跑个压测,看看数据库扛不扛得住,别等产品经理说"这个功能很简单"就掉以轻心——越简单的功能,越容易出离谱的SQL。
第四,代码review必须过SQL这一关。不是说你得是个DBA,但至少团队里有人懂点数据库原理。JOIN能不能用上索引,WHERE条件有没有函数,批量操作有没有做分页——这些在review阶段发现,成本比线上事故低三个数量级。
结语
回到开头的问题:为什么接口返回200但数据库可能已经死了?
因为你们分层解耦解得太彻底了——应用层和数据库层完全不知道彼此的处境。数据库在那扛着压力喘气,上面的接口还在往里塞请求,还觉得一切正常。毕竟日志里没有Error嘛。
这种"稳",比任何报错都危险。
所以,下次写SQL之前,先问自己三个问题:会不会走索引?会不会锁太久?会不会把连接池干爆?问完再写,能救你自己的命。
当然,也可能是救DBA一命。人家半夜被叫起来的时候,可不会觉得你的200 OK有多体面。