10 个架构陷阱

他山之石 2025-10-13 15:44:38

达到首批百万用户是最好的压力测试——它会迫使那些微小的设计选择暴露出大问题。我见过一些团队快速推出功能,然后在流量、数据或边缘情况激增时碰壁。本文列出了我发现团队在早期最容易陷入的10个架构陷阱,每个陷阱在规模扩大后为何会变得有害,以及你现在就可以实施的明确、实用的解决方案。我会让内容简洁、坦诚且具有可操作性,并附上简短的代码片段和简洁的UML图,让这些想法更加具体。

  1. 不要过早拆分为微服务。
  2. 为数据增长做好规划(避免使用OFFSET分页/对单体数据库的假设)。
  3. 避免长的同步调用链——使用队列和后台工作程序。
  4. 提早构建可观测性(指标+日志+追踪)。
  5. 确保表结构安全迁移(先扩展后收缩)。
  6. 为面向外部的操作(支付、Webhook 等)设计幂等性。
  7. 保持身份验证的无状态性(或集中管理会话存储)。
  8. 使用缓存旁路时要小心进行失效处理,小心惊群效应。
  9. 使用功能标志(feature flags)+安全部署。
  10. 添加速率限制和背压——保护您的系统和用户。

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.不安全的表结构迁移

  • 陷阱:你以破坏旧代码的方式修改表结构(删除列、更改类型),并且在没有协调的情况下部署这些更改。
  • 危害:部署失败、服务出错、大型表的迁移耗时过长。
  • 解决方法:使用先扩展后收缩模式:
  1. 添加新列或新表(扩展)——部署同时写入旧数据和新数据的代码。
  2. 回填数据并将读取切换到新的模式。
  3. 仅在验证后删除旧列。

迁移示例(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

快猫星云 联系方式 快猫星云 联系方式
快猫星云 联系方式
快猫星云 联系方式
快猫星云 联系方式
快猫星云
OpenSource
开源版
Flashcat
Flashcat