10 个架构陷阱
他山之石
2025-10-13 15:44:38
达到首批百万用户是最好的压力测试——它会迫使那些微小的设计选择暴露出大问题。我见过一些团队快速推出功能,然后在流量、数据或边缘情况激增时碰壁。本文列出了我发现团队在早期最容易陷入的10个架构陷阱,每个陷阱在规模扩大后为何会变得有害,以及你现在就可以实施的明确、实用的解决方案。我会让内容简洁、坦诚且具有可操作性,并附上简短的代码片段和简洁的UML图,让这些想法更加具体。
- 不要过早拆分为微服务。
- 为数据增长做好规划(避免使用OFFSET分页/对单体数据库的假设)。
- 避免长的同步调用链——使用队列和后台工作程序。
- 提早构建可观测性(指标+日志+追踪)。
- 确保表结构安全迁移(先扩展后收缩)。
- 为面向外部的操作(支付、Webhook 等)设计幂等性。
- 保持身份验证的无状态性(或集中管理会话存储)。
- 使用缓存旁路时要小心进行失效处理,小心惊群效应。
- 使用功能标志(feature flags)+安全部署。
- 添加速率限制和背压——保护您的系统和用户。
1.过早拆分微服务
- 陷阱:在理解领域边界之前,你就从单一代码库转向了众多小型服务。
- 危害原因:运营开销增大、部署次数增多、调试难度加大、远程调用导致延迟增加。
- 解决方法:从模块化单体架构开始——清晰的模块、明确的接口和完善的测试。只有当团队边界、扩展需求或独立故障域证明有必要时,再进行拆分。
示例(Node.js模块形式,后面方便拆分):
// user-service.js (module inside a monolith)
module.exports = {
createUser(data) {
// validate, write to DB, return user
},
getUser(id) {
// read from DB
}
};
当你之后需要拆分时,提取该模块并通过HTTP/GRPC暴露相同的接口。
2.OFFSET分页
- 陷阱:在大型表的信息流和列表中使用OFFSET。
- 危害原因:随着页码增加,OFFSET扫描会变得越来越慢——这会占用数据库大量的CPU和内存。
- 解决方法:使用游标分页:通过稳定的列(id、created_at)进行分页,并保持查询为
WHERE id > last_seen ORDER BY id LIMIT N
。
游标示例(SQL):
-- good: keyset pagination
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) > (:last_created_at, :last_id)
ORDER BY created_at, id
LIMIT 50;
3.跨服务的同步请求链
- 陷阱:API→服务A→服务B→服务C——所有事情都在一个用户请求中发生。
- 问题所在:单个服务变慢会拖慢整个请求。延迟会叠加,可用性会下降。
- 解决方法:将非必要工作交给后台工作进程处理,并使用队列实现异步工作流。尽可能采用事件驱动设计。
入队作业示例(伪Node + Redis列表):
// Accept request quickly
app.post('/checkout', async (req, res) => {
const job = { userId: req.user.id, cart: req.body.cart };
await redis.lpush('jobs:checkout', JSON.stringify(job)); // fast
return res.status(202).send({ status: 'accepted' });
});
工作线程从jobs:checkout
中提取信息,并执行支付、库存和邮件相关操作。
4.无观测能力(或观测能力出现过晚)
- 陷阱:你认为日志就足够了,或者在发生故障后才添加监控。
- 危害:你无法诊断速度变慢或级联故障的问题。追责变成了猜测。
- 解决方法:为关键路径配备指标(请求延迟、错误率)、结构化日志(包含请求ID)以及用于跨服务流程的分布式追踪。
小型指标代码片段(Express + prom-client 风格):
const client = require('prom-client');
const httpRequestDuration = new client.Histogram({ name: 'http_request_duration_ms', help: '...' });
app.use((req, res, next) => {
const end = httpRequestDuration.startTimer();
res.on('finish', () => end({ route: req.path, code: res.statusCode }));
next();
});
从第一天就开始收集指标——你会感谢自己的。
5.不安全的表结构迁移
- 陷阱:你以破坏旧代码的方式修改表结构(删除列、更改类型),并且在没有协调的情况下部署这些更改。
- 危害:部署失败、服务出错、大型表的迁移耗时过长。
- 解决方法:使用先扩展后收缩模式:
- 添加新列或新表(扩展)——部署同时写入旧数据和新数据的代码。
- 回填数据并将读取切换到新的模式。
- 仅在验证后删除旧列。
迁移示例(SQL):
ALTER TABLE orders ADD COLUMN new_status VARCHAR(32); -- expand
-- deploy: write both status and new_status, read new_status when available
-- backfill script to copy status -> new_status
-- once stable: ALTER TABLE orders DROP COLUMN status; -- contract
6.面向外部的操作不具备幂等性
- 陷阱:Webhook或客户端重试某个端点,而你的系统创建了重复的收费/订单。
- 危害:造成金钱损失、破坏信任,而且你需要花数周时间处理各种边缘情况。
- 解决方法:要求有副作用的端点使用幂等键,并以原子方式存储该键的结果。
幂等性示例(Redis SETNX):
// pseudo code
const key = `idem:${req.headers['Idempotency-Key']}`;
const locked = await redis.set(key, 'in-progress', 'NX', 'EX', 3600); // SET if not exists
if (!locked) {
// return previous response or a "already processed" status
}
try {
const result = await doPayment();
await redis.set(key, JSON.stringify(result), 'XX'); // save result
res.send(result);
} catch (err) {
await redis.del(key);
throw err;
}
如果您的存储支持条件写入,请使用它们来实现原子性。
7.有状态会话服务器
- 陷阱:在应用服务器的内存中存储会话状态(粘性会话)或将用户绑定到单个实例。
- 危害:自动扩展变得困难,部署会中断会话,故障转移也不稳定。
- 解决方法:将会话迁移到共享存储(Redis),或使用带有短生存时间的签名令牌(JWT)实现无状态认证。
JWT验证(Express示例):
const jwt = require('jsonwebtoken');
app.use((req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).end();
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch (e) { res.status(401).end(); }
});
无状态设计简化了扩展和路由。
8.缓存反模式
- 陷阱:过度依赖缓存而不设计失效机制,或者对所有数据类型使用单一缓存副本。
- 危害:缓存失效时会出现读取陈旧数据或“惊群效应”,导致数据库负载过重。
- 解决方法:采用缓存旁路模式,设置合理的生存时间(TTL),并应用请求端限流或锁机制以防止缓存雪崩。
缓存旁路代码片段(伪代码):
const key = `post:${id}`;
let post = await redis.get(key);
if (!post) {
post = await db.query('SELECT * FROM posts WHERE id = ?', [id]);
await redis.set(key, JSON.stringify(post), 'EX', 60); // short TTL
}
return JSON.parse(post);
对于写入密集型数据,请考虑直写式或失效钩子。
9.无功能标志
- 陷阱:直接向所有人推出重大变更。
- 危害原因:一次糟糕的部署可能会影响到100%的用户。回滚操作十分麻烦。
- 解决方法:使用功能标志来控制新行为。先向小比例用户推出,监控指标,然后再扩大范围。
简单的标志检查:
// config-based flag
if (flags.isEnabled('new-checkout') && user.inBeta) {
runNewCheckout();
} else {
runOldCheckout();
}
将标记存储在中央存储库中,将其与指标关联,并确保有快速的关闭开关。
10.无速率限制或背压
- 陷阱:让客户(或有缺陷的代码)无限制地压垮你的服务。- 危害:资源耗尽、级联故障以及糟糕的用户体验。
- 解决方法:实施按用户/按IP的速率限制和优雅降级。增加队列深度限制,并在过载时快速拒绝请求。
简单的令牌桶概览(Node中间件构想):
// pseudo: tokens stored per user in Redis
function allowRequest(userId) {
// decrement token count atomically; if >=0 allow; else reject with 429
}
尽早使用清晰的错误代码拒绝,并显示有意义的Retry-After头部信息。
最后,整体总结一张 UML 供您参考:
原文链接:https://observabilityguy.medium.com/avoid-these-10-architecture-traps-before-you-hit-1m-users-25d34af8c0d4