我所知道的关于「好系统设计」的一切(全文翻译)

我看到过很多糟糕的系统设计建议。典型的一类是那种 LinkedIn 上为新手写的:「你知道队列吗?不知道吧?」这种博人眼球的内容。另一类是 Twitter 上的炫技式内容,例如「如果你敢在数据库里存 Boolean,你就是个差劲的工程师」。甚至那些本来是好建议的内容,很多时候也未必真正有用。我很喜欢《Designing Data-Intensive Applications(DDIA)》这本书,但那本书对大部分工程师实际面对的系统设计问题,帮助其实有限。

什么是系统设计?在我看来,如果说软件设计关注的是如何组织代码行,那么系统设计关注的就是如何组织服务。软件设计的原语是变量、函数、类等;系统设计的原语是应用服务器、数据库、缓存、队列、事件总线、代理等。

这篇文章,是我尝试把自己关于系统设计的理解尽可能完整地写下来。具体的判断往往依赖经验,而经验无法完全通过文章传递。但我尽力把能写的都写下来。


如何识别好设计

好的系统设计长什么样?我以前写过一句话:它看起来「毫不起眼」。 实际上,它通常表现为:很长一段时间内,什么事都没发生。

如果你会常常有以下想法,那说明你面对的是好设计:

  • 「咦,这个功能最后居然比预期简单很多。」
  • 「这个系统的这一块我从来不用担心,它一直都很稳。」

某种意义上,好的设计是“隐形的”,而糟糕的设计往往看起来更“令人印象深刻”。我对那些看起来特别酷的系统总是很警惕。如果一个系统里充满分布式一致性、各种事件驱动、CQRS、各种花哨的技巧,我会怀疑:是不是某个根本性的坏决策被复杂度掩盖了?或者干脆就是被「过度设计」了?

很多工程师看到一个复杂的系统,会说「哇,这里面一定用了很多系统设计的技巧」。事实上,复杂系统往往反而意味着缺乏好的设计。我说“往往”,是因为确实存在必须复杂的系统。我也设计过很多确实值得复杂的系统。但能够稳定运行的复杂系统,总是从一个简单且可运行的版本进化而来。 从零开始造一个复杂系统,几乎永远是坏主意。


状态与无状态

软件设计中最难的部分就是状态(state)。 只要你的程序需要保存任何持续性的内容,你都得面对大量棘手的问题:怎么读、怎么写、怎么存。

如果你的系统完全不存储这些信息,那它就是「无状态」的。

例如,GitHub 内部有一个 API:输入 PDF 文件,返回其 HTML 渲染结果。 这个服务就是典型的无状态服务。

但只要你写入数据库,它就成为有状态服务。

你应该尽量减少系统中有状态组件的数量。(当然,任何组件都应该尽量少,但有状态组件尤其危险。)

原因是:状态会变坏,且无法自动恢复。 无状态组件一旦出问题,重启就能恢复;但如果你的数据库里写入了坏数据(例如某条数据导致应用崩溃),你必须手工介入修复。

更重要的是,状态会不断积累、膨胀,最后你不得不扩容或清理。

实践中的准则是:

  • 尽量只有一个服务负责写数据库;其他服务通过 API 功能或事件向它请求写操作。
  • 读操作也尽量集中到这个服务,虽然我对此不那么绝对。 有时候读一下 user_sessions 表比发一个内部 HTTP 请求要快两倍。

数据库

既然管理状态是系统设计的核心,那么最重要的组件当然就是数据库。

我主要使用 SQL 数据库(MySQL、PostgreSQL),以下内容也基于此。


架构(Schema)与索引(Index)

当你要存数据,要做的第一件事就是定义一张表。

表结构应该:

  • 足够灵活:否则未来要修改非常痛苦。
  • 但不能太灵活:例如把所有内容塞到 JSON 列,或用「键值表」来存所有属性。 过度灵活会导致应用逻辑变得极度复杂,也容易产生性能问题。

我喜欢让数据库表是「人类可读」的:只看表结构就能大致理解系统存了什么。

如果表以后会超过几行数据,就应该加索引。

索引设计的要点:

  • 索引字段顺序应该与最常见的查询匹配。
  • 高区分度字段放前面。
  • 不要加太多索引,否则写入会有明显的性能损耗。

性能瓶颈(Bottleneck)

对大流量系统而言,数据库几乎总是性能瓶颈。

即使你使用的是 Ruby on Rails + Unicorn 这种相对慢的计算层,通常数据库依然是更慢的。

因为系统往往要执行很多数据库查询,而且是串行链式查询

  • 先查 A,再根据 A 查 B,再根据 B 查 C…

要避免瓶颈的策略:

1. 让数据库做数据库擅长的事

  • 多表查询应该使用 JOIN,而不是在应用层自己拼接。
  • 警惕 ORM 在循环内部偷偷执行 N 次查询。

2. 分拆查询(偶尔)

有时你会遇到非常复杂、数据库难以优化的 SQL,分拆为两三个查询反而更快。

3. 把尽可能多的读流量打到读副本(read replicas)

主库负责写已经够辛苦了,能不读就不读。

只有当你不能容忍复制延迟(lag)时,才该读主库。

一般情况可以接受:

  • 更新操作之后不立即读取数据库,而是使用内存里的更新值。

4. 警惕写入尖峰(write spike)

写入和事务比读更容易压垮数据库。 例如大批量导入 API,就应提前做节流(throttle)。


快速与缓慢的操作

服务通常有两类操作:

  • 需要快速响应的操作(用户直接体验) 一般需要 100–300ms 内完成。
  • 天然比较慢的操作 如大型 PDF 转 HTML。

通用做法:

把最小可用结果快速返回给用户,剩下的丢到后台任务(background job)里完成。


什么是后台任务?

后台任务系统由两部分组成:

  1. 任务队列(如 Redis)
  2. 任务执行器(worker)

你往队列里塞 {job_name, params},worker 会取出并执行。

也可以调度未来运行的任务(如每日任务)。

这是最成熟的系统设计工具之一。


自己实现队列的场景

例如你要执行一个「一个月后运行的任务」,把它丢进 Redis 并不靠谱:

  • Redis 持久性不够长期可靠
  • 你无法方便地查询这些未来任务

典型替代方案:

  • 建一个数据库表 scheduled_jobs
  • 用每日任务检查 scheduled_at <= today 的项并执行

缓存(Caching)

当某个操作很慢且重复性高,就可以缓存。

例如计费系统每次都要查一次价格,且价格不变,那么可以每 5 分钟查一次,其余时间直接使用缓存。

常见方式:

  • 内存缓存
  • Redis / Memcached

但资深工程师通常会尽量少用缓存。

原因:

  • 缓存是一种「状态」,会过期、不同步、写坏、引发神秘 bug。

如果一次 SQL 查询很慢,第一个动作应该是加索引,而不是用缓存把慢查询藏起来。

高级技巧:

  • 对非常大的结果(例如大型客户的周报),可以在定时任务里生成结果文件存入 S3/Blob Storage,然后直接按需返回文件。

这是一种「缓存思想」,但不使用传统缓存技术。


事件(Events)

大公司通常有事件系统(如 Kafka): 作用是广播「某件事发生了」,而不是触发某个具体任务。

例如用户创建账户后,可产生事件:

  • 发送欢迎邮件
  • 风险检测
  • 初始化账号资源

事件适用场景:

  • 发送方不关心接收方怎么处理
  • 大规模、高吞吐、无强实时性

否则---用 API 调用更直观、日志清晰、同步反馈明确。


推与拉(Push & Pull)

数据从一个地方流到多个地方,有两种方式:

1. Pull(拉取)

客户端主动请求数据 例如用户刷新邮箱页面。

问题: 客户端可能频繁重复拉取相同内容。

2. Push(推送)

客户端注册后,数据变化时服务端主动推送。 例如 Gmail 新邮件自动出现在界面,无需刷新。

对于后台服务:

  • 有几十上百个需要数据的服务时,push 更经济
  • 但 push 系统更复杂

如果是百万客户端(如 Gmail),无论 push 还是 pull,都得扩展系统:

  • push → 需要成千上万的事件处理 worker
  • pull → 需要高性能读缓存层

两者都能成功,差别不是绝对的。


热路径(Hot Path)

系统设计中很多路径很少走,怎么实现都不会出大事。

有些路径是业务核心,又是高频执行的,例如计费系统里:

  • 是否计费的判断逻辑
  • 对所有用户行为的采集与处理

热路径的特点:

  • 可选方案很少
  • 容易出问题
  • 一旦出问题影响巨大

相比之下,一个设置页出 bug 很难把整个服务搞挂。


日志与监控(Logging & Metrics)

如何知道系统是否出了问题? 经验最丰富的工程师的方法是:

1. 异常路径一定要打详细日志

例如:

  • API 返回 422 时应该记录触发条件
  • 计费逻辑中应记录每一次计费判断

这会让代码看起来更丑,但能救命。

2. 基本可观测性(Observability)必须有

  • CPU / 内存
  • 队列长度
  • 每个请求 / 每个任务的平均耗时
  • p95 / p99 耗时(对大客户尤其重要)

看平均数容易掩盖大用户的巨大性能问题。


熔断、重试、优雅降级

我写过一篇单独的文章,这里不重复细节。

重点如下:

重试不是万能的

  • 不要因为重试而造成雪崩效应
  • 高频 API 调用应加「熔断器」(circuit breaker)

写操作必须用幂等键(idempotency key)

否则你可能会重复扣费、重复写入。

失败时该“开”还是“关”

例如限流系统依赖 Redis,如果 Redis 挂了:

  • fail open → 放行所有请求
  • fail closed → 429 全部拦住

限流应当永远 fail open,因为它不是核心功能。 而认证系统必须 fail closed,否则可能泄漏用户数据。

很多情况都没有绝对答案,需要具体判断。


最后的想法

很多常见问题我没有讨论,例如:

  • 是否拆分单体架构(我觉得单体通常没问题)
  • Docker 还是 VM
  • tracing(应该用,但不值得多讨论)
  • 如何设计 API(太复杂写不完)

我想传达的核心观点是:

好系统设计不是炫技,而是把最无聊、最成熟的零件用在正确的位置。

就像管道工一样: 如果你做的事情太“刺激”,可能最终会弄得一身“脏东西”。

尤其是在大公司,基础设施通常现成(事件总线、缓存服务等都有),好的系统设计往往看上去「没什么特别」。

真正值得在大会分享的设计,我十年里只见过一两次。 而“无聊”的好设计,我每天都在见。

原文链接:https://www.seangoedecke.com/good-system-design/

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容