10 个架构陷阱

系统从早期产品走向百万用户时,最容易暴露架构上的隐性问题。本文梳理 10 个常见架构陷阱,包括过早微服务、OFFSET 分页、同步调用链、可观测性缺失、表结构迁移、幂等性、会话状态、缓存失效、功能标志和速率限制,并给出可落地的改进方法。

作者 他山之石

系统第一次接近百万用户时,真正被测试的往往不是单个功能,而是早期架构决策的质量。很多团队在业务还小的时候能快速交付,一旦流量、数据量、并发请求、外部回调和部署频率上来,原来那些“暂时能跑”的设计就会变成故障入口。

这篇文章总结 10 个常见架构陷阱。它们不一定会在第一天造成事故,但会在规模扩大后放大延迟、数据不一致、排障困难和发布风险。

核心要点摘要

  • 不要在领域边界还不清楚时过早拆微服务;先把模块边界、接口和测试做好。
  • 数据增长要提前设计,尤其要避免在大表列表里长期依赖 OFFSET 分页。
  • 用户请求链路越长,越容易被单个慢服务拖垮;非核心工作应尽早异步化。
  • 可观测性、数据库迁移、幂等性、缓存失效和速率限制都不是上线后的补丁,而是架构的一部分。
  • 功能标志、背压和快速关闭开关能把一次错误发布的影响范围控制在可承受范围内。

这 10 个架构陷阱解决的是什么问题

架构陷阱 主要风险 推荐做法
过早拆分微服务 运维复杂度、调试成本和远程调用延迟上升 先做模块化单体,边界清楚后再拆
OFFSET 分页 大表深分页变慢,数据库 CPU 和内存压力上升 使用基于稳定字段的游标分页
长同步调用链 延迟叠加,单点变慢拖垮整条链路 用队列和后台任务处理非必要工作
可观测性过晚 故障定位靠猜,无法解释慢请求和级联故障 提前接入指标、结构化日志和追踪
不安全的表结构迁移 部署失败、旧代码不兼容、大表迁移阻塞 采用先扩展后收缩模式
外部操作无幂等性 重试导致重复扣款、重复订单或重复副作用 使用幂等键和原子写入
有状态会话服务器 扩容、发布和故障转移困难 用共享会话存储或短期签名令牌
缓存反模式 脏数据、缓存雪崩、惊群效应 设计 TTL、失效机制和请求合并
无功能标志 一次发布影响全部用户,回滚困难 小流量灰度、监控指标、快速关闭
无速率限制和背压 资源耗尽、级联故障、体验劣化 按用户/IP 限流,过载时快速拒绝

1. 不要过早拆分微服务

很多团队一开始就把系统拆成多个小服务,原因通常是“未来会变大”。问题在于,领域边界还没有经过业务验证时,过早拆分会把代码组织问题变成分布式系统问题。

真正的风险不是微服务本身,而是你还不知道服务边界在哪里,就先承担了独立部署、网络调用、服务发现、链路追踪、接口兼容和跨服务调试的成本。服务数量变多后,定位一个简单问题也可能需要跨多个仓库、日志和监控面板。

更稳妥的做法是从模块化单体开始:模块边界清晰、接口明确、测试完善。当团队边界、扩展需求或独立故障域已经证明“必须拆”时,再把模块提取成独立服务。

示例:先把用户能力封装为单体内的独立模块,保留后续拆分空间。

// 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 分页在小表里很方便,但在数据量增长后会变成性能陷阱。页码越靠后,数据库通常需要跳过越多记录才能返回目标结果,列表页、信息流和管理后台都会逐渐变慢。

更好的方式是游标分页,也叫 keyset pagination。它依赖稳定排序字段,例如 idcreated_at,用“上一次看到的位置”继续向后取数据。

示例:使用 created_atid 作为稳定游标。

-- 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;

这种方式更适合持续增长的数据集。它牺牲了一部分“直接跳到第 N 页”的便利性,但换来了更稳定的查询成本。

3. 避免跨服务的长同步请求链

典型问题是:一个用户请求进入 API 后,依次调用服务 A、服务 B、服务 C。只要其中一个服务变慢,整个请求都会变慢;如果某个服务不可用,请求链路上的其他服务也会被拖住。

同步链路越长,延迟越容易叠加,可用性也越容易下降。支付、库存、邮件、通知、报表这类工作,如果不是必须在用户请求内立刻完成,就应该考虑异步化。

示例:请求只负责接收任务,后续工作交给队列和后台 worker。

// 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' });
});

后台 worker 从 jobs:checkout 中取出任务,再执行支付、库存和邮件相关操作。这样用户请求可以更快返回,系统也更容易通过队列深度、重试和失败补偿来控制风险。

4. 可观测性不要等到故障后再补

只依赖普通日志,通常不足以解释系统为什么变慢、哪条链路出错、哪个依赖拖慢了请求。等到故障发生后再补监控,排障就会变成猜测。

关键路径至少要具备三类信号:

  1. 指标:请求量、错误率、延迟、队列深度、资源使用率。
  2. 结构化日志:包含请求 ID、用户或租户标识、业务对象 ID、错误上下文。
  3. 分布式追踪:用于观察跨服务调用、外部依赖和慢请求路径。

示例:在 Express 服务中采集请求耗时指标。

const client = require('prom-client');
const httpRequestDuration = new client.Histogram({
  name: 'http_request_duration_ms',
  help: 'HTTP request duration in milliseconds'
});

app.use((req, res, next) => {
  const end = httpRequestDuration.startTimer();
  res.on('finish', () => end({ route: req.path, code: res.statusCode }));
  next();
});

可观测性越早进入架构,后续每一次发布、扩容和故障处理都会更可控。

5. 表结构迁移要先扩展后收缩

数据库 schema 变更最怕破坏旧代码。比如直接删除列、修改字段类型、改变字段含义,可能让还没完成发布的旧版本服务立即报错。大表迁移还可能因为锁表、回填时间过长或部署顺序不协调而放大风险。

更安全的模式是 expand and contract,也就是先扩展后收缩:

  1. 添加新列或新表,不破坏旧代码。
  2. 部署同时写入旧字段和新字段的代码。
  3. 回填历史数据。
  4. 将读取逻辑切到新字段。
  5. 验证稳定后,再删除旧字段。

示例:先添加 new_status,再逐步迁移读写逻辑。

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

这个方法的核心不是 SQL 写法,而是让数据库结构、旧代码和新代码在一段时间内可以共存。

6. 面向外部的操作必须具备幂等性

支付、订单、Webhook、回调接口和客户端重试,都可能带来重复请求。如果系统没有幂等设计,一次网络抖动或第三方重试就可能造成重复扣款、重复订单或重复发货。

幂等性的关键是:同一个业务意图重复执行多次,最终结果应该一致。常见做法是要求客户端或调用方传入幂等键,并用原子方式记录该键对应的处理结果。

示例:使用 Redis 的 SET NX 思路控制重复请求。

// pseudo code
const key = `idem:${req.headers['Idempotency-Key']}`;
const locked = await redis.set(key, 'in-progress', 'NX', 'EX', 3600);

if (!locked) {
  // return previous response or an "already processed" status
}

try {
  const result = await doPayment();
  await redis.set(key, JSON.stringify(result), 'XX');
  res.send(result);
} catch (err) {
  await redis.del(key);
  throw err;
}

如果数据库或存储系统支持条件写入,也可以用它来保证原子性。不要把“前端不会重复点”“第三方不会重复回调”当成架构假设。

7. 不要把会话状态绑死在单台应用服务器上

把会话保存在应用服务器内存里,早期实现简单,但会让扩容和故障转移变得困难。用户一旦被绑定到某个实例,部署、重启或实例故障都可能打断会话。

更合理的做法有两种:

  1. 把会话迁移到共享存储,例如 Redis。
  2. 使用短生命周期的签名令牌,例如 JWT,实现无状态认证。

示例:在 Express 中验证 JWT。

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. 缓存必须设计失效和防雪崩机制

缓存可以显著降低数据库压力,但没有失效策略的缓存会制造新的问题:用户读到旧数据,缓存同时过期造成请求打到数据库,或者热点 key 失效后出现惊群效应。

采用缓存旁路模式时,至少要考虑三个问题:

  1. TTL 多长才符合业务新鲜度要求。
  2. 写入后如何失效或更新缓存。
  3. 缓存未命中时,是否需要锁、请求合并或限流来保护数据库。

示例:缓存旁路的基本形态。

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);
}

return JSON.parse(post);

对于写入频繁、数据一致性要求高的场景,可以考虑直写式缓存或明确的失效钩子。缓存不是数据库的替代品,它只是架构里的加速层。

9. 重大变更不要一次性推给所有用户

没有功能标志时,发布就是开关:要么所有用户看到新逻辑,要么全部回滚。只要一次发布存在缺陷,影响面就可能直接扩大到 100% 用户。

功能标志的价值是控制影响范围。新能力可以先给内部用户、Beta 用户或小比例流量使用,观察错误率、延迟和业务指标后再逐步扩大。

示例:用功能标志切换新旧结账流程。

// config-based flag
if (flags.isEnabled('new-checkout') && user.inBeta) {
  runNewCheckout();
} else {
  runOldCheckout();
}

功能标志本身也要治理。标志应该有负责人、关闭开关、过期清理机制,并和监控指标关联,否则它会逐渐变成另一种技术债。

10. 没有速率限制和背压,系统会被无限请求拖垮

如果系统允许客户、爬虫或有缺陷的代码无限制请求,最终风险会落到数据库、队列、线程池和外部依赖上。过载时继续接收请求,通常只会让故障恢复更慢。

速率限制解决“谁可以发多少请求”,背压解决“系统承受不了时如何拒绝或降级”。常见策略包括按用户限流、按 IP 限流、队列深度限制、超时控制和快速返回 429

示例:令牌桶限流的中间件思路。

// pseudo: tokens stored per user in Redis
function allowRequest(userId) {
  // decrement token count atomically; if >=0 allow; else reject with 429
}

当系统过载时,尽早拒绝比排队到超时更好。清晰的错误码和 Retry-After 响应头,也能让客户端知道应该何时重试。

这些问题应该按什么顺序处理

如果团队已经进入快速增长阶段,不必同时改完 10 件事。优先级可以按“事故影响面”和“改造成本”排序:

  1. 先补可观测性,否则你无法判断哪里最危险。
  2. 再处理会造成重复副作用的问题,例如支付、订单和 Webhook 的幂等性。
  3. 然后治理数据库增长问题,包括分页、大表迁移和缓存失效。
  4. 最后完善发布控制、限流和背压,让系统在变更和过载时有退路。

架构优化不是为了追求复杂,而是为了在业务变大时仍然可理解、可发布、可定位、可恢复。

FAQ

Q1:是不是一开始就不能用微服务?

A:不是。问题不在微服务,而在“边界没清楚就拆”。如果团队边界、扩展诉求或故障隔离已经明确,微服务可以成立;如果只是为了未来可能变大,模块化单体通常更稳。

Q2:OFFSET 分页什么时候会成为问题?

A:当表越来越大、页码越来越深、列表查询越来越频繁时,OFFSET 的成本会变得明显。信息流、审计列表、订单列表和后台管理页都应该尽早评估游标分页。

Q3:可观测性最少应该先做什么?

A:先覆盖关键路径的请求量、错误率、延迟和结构化日志,并保证日志里能关联请求 ID 或业务对象 ID。跨服务调用较多时,再补分布式追踪。

Q4:功能标志会不会增加复杂度?

A:会,所以它也需要治理。功能标志适合重大变更、灰度发布和快速回滚,但要有负责人、清理机制和监控关联,不能长期堆积。

Q5:速率限制会不会伤害用户体验?

A:合理的限流是在保护整体体验。过载时快速拒绝一部分请求,通常比让所有请求排队、超时、级联失败更可控。

最后,整体总结一张 UML 供参考:

架构陷阱总结

原文链接:https://observabilityguy.medium.com/avoid-these-10-architecture-traps-before-you-hit-1m-users-25d34af8c0d4

延伸路径

继续看解决方案和产品对比

如果你正在做监控、可观测性或故障定位相关选型,建议从解决方案和产品对比继续往下看。

快猫星云 联系方式 快猫星云 联系方式
快猫星云 联系方式
快猫星云 联系方式
快猫星云 联系方式
快猫星云