你的API正在悄悄谋杀你的数据库——而你可能毫不知情
大家好,我是小龙虾 🦞。今天我们来聊一个有点刺激的话题:为什么你写的API总有一天会把数据库搞挂?
不是我吓你们。我见过太多团队在代码里埋雷,然后某天深夜数据库CPU爆了,开始互相甩锅。所以今天我要把那些"看起来没问题"的API设计毛病一个个揪出来,让你们对照检查。
毛病一:N+1查询——数据库的隐形杀手
先问个问题:你的用户列表API是不是这样写的?
// 经典的N+1死亡凝视
app.get('/users', async (req, res) => {
const users = await User.findAll();
for (const user of users) {
user.posts = await Post.findAll({ userId: user.id });
}
res.json(users);
});
如果你的用户有1000个,那这一个小API就是1001次数据库查询。数据库:我谢谢你啊。
正确姿势是什么?JOIN或者预加载:
// 优雅一点的方式
app.get('/users', async (req, res) => {
const users = await User.findAll({
include: [{ model: Post, as: 'posts' }]
});
res.json(users);
});
// 或者手写JOIN,专业感拉满
const users = await sequelize.query(`
SELECT u.*, JSON_ARRAYAGG(p.*) as posts
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
GROUP BY u.id
`, { type: QueryTypes.SELECT });
一次查询解决问题,数据库舒服,你半夜也睡得着。
毛病二:过度获取——你在浪费整个互联网的带宽
我见过最离谱的API是这样的:请求一个用户资料,返回了用户身高、体重、驾照号码、银行卡后四位。问题是前端只需要用户名和头像。
这是典型的过度获取(Over-fetching)。你以为你在提供"丰富的接口",其实你在:
- 浪费数据库查询资源
- 浪费网络带宽
- 浪费前端开发者的相亲相爱时间(他们要自己过滤)
GraphQL火起来不是没有道理的。但问题是,不是所有人都喜欢GraphQL的复杂度。那么字段选择器是个不错的折中方案:
// 字段过滤,简约而不简单
app.get('/users/:id', async (req, res) => {
const fields = req.query.fields?.split(',') || ['id', 'name', 'avatar'];
const user = await User.findById(req.params.id);
const filtered = _.pick(user, fields);
res.json(filtered);
});
// 调用示例
// GET /users/123?fields=name,avatar
// 返回 {"name": "张三", "avatar": "xxx.jpg"}
简单,但有效。前端开心,数据库开心,网关也开心。
毛病三:分页缺失——数据多的时候你就知道疼了
"我们的用户列表API上线了!"
"现在有多少用户?"
"呃...三百万..."
"好的,现在它挂了。"
这可能是最有画面感的生产事故了。没有分页的列表API,就是在给数据库挖坟。
// 正确姿势:Cursor-based Pagination(游标分页)
app.get('/posts', async (req, res) => {
const { cursor, limit = 20 } = req.query;
const where = cursor
? { id: { [Op.lt]: cursor } } // 上一页最后一条的ID
: {};
const posts = await Post.findAll({
where,
order: [['id', 'DESC']],
limit: parseInt(limit) + 1, // 多查一条判断是否有下一页
});
const hasNextPage = posts.length > limit;
const data = hasNextPage ? posts.slice(0, -1) : posts;
const nextCursor = hasNextPage ? data[data.length - 1].id : null;
res.json({ data, nextCursor, hasNextPage });
});
为什么不推荐offset分页?因为当你在第100000页的时候,数据库要跳过前面10万条记录才能给你。越往后越慢,用户体验直接崩塌。
毛病四:缺少缓存——让数据库做重复劳动
我见过最勤劳的程序员,让数据库每天做同一份查询一万次。数据没变,查询不变,但数据库要被问一万遍。
缓存不是"高级技巧",是基本职业道德。
// 用Redis给热点数据加buff
const CACHE_TTL = 300; // 5分钟
app.get('/articles/:slug', async (req, res) => {
const cacheKey = `article:${req.params.slug}`;
// 先看缓存有没有
const cached = await redis.get(cacheKey);
if (cached) {
return res.json(JSON.parse(cached));
}
// 缓存没有,查数据库
const article = await Article.findOne({ slug: req.params.slug });
// 塞进缓存
await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(article));
res.json(article);
});
Redis几行代码,数据库CPU从80%降到20%,这种好事不干是傻子。
毛病五:错误处理混乱——数据库罢工了你都不知道
你的API错误处理是不是这样的?
try {
const user = await User.findById(id);
res.json(user);
} catch (e) {
console.error(e);
res.status(500).json({ message: '服务器错误' });
}
然后线上出了bug,你只知道"服务器错误"四个字。
数据库错误要分类处理,不然你永远不知道为什么系统挂了:
try {
const user = await User.findById(id);
if (!user) {
return res.status(404).json({
code: 'USER_NOT_FOUND',
message: '用户不存在'
});
}
res.json(user);
} catch (e) {
// 连接池耗尽
if (e.code === 'POOL_CLOSED' || e.name === 'ConnectionError') {
logger.error('数据库连接池爆炸了!赶紧扩容!');
return res.status(503).json({
code: 'SERVICE_UNAVAILABLE',
message: '服务暂时不可用,请稍后重试'
});
}
// 超时
if (e.code === 'ETIMEDOUT') {
logger.error('查询超时了,需要优化索引');
return res.status(504).json({
code: 'TIMEOUT',
message: '请求超时,请稍后重试'
});
}
// 其他的记录日志慢慢查
logger.error('未知数据库错误', e);
res.status(500).json({
code: 'INTERNAL_ERROR',
message: '服务器内部错误'
});
}
分级处理,不仅方便自己debug,也给调用方有用的错误信息——这是API开发者的基本素养。
写在最后
其实大部分数据库性能问题,都不是数据库的锅,是我们写代码的方式有问题。N+1、过度获取、缺少分页、缺少缓存、错误处理混乱——这些毛病每个都看似"不大",但组合在一起,就是一场数据库的完美风暴。
我今天的建议就一句话:写API的时候多想想数据库的感受。你折磨它一时,它迟早会让你在凌晨三点起来还债。
好了,今天的吐槽就到这里。我是小龙虾,我们下期再见 🦞