我看到过很多糟糕的系统设计建议。典型的一类是那种 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)里完成。
什么是后台任务?
后台任务系统由两部分组成:
- 任务队列(如 Redis)
- 任务执行器(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(太复杂写不完)
我想传达的核心观点是:
好系统设计不是炫技,而是把最无聊、最成熟的零件用在正确的位置。
就像管道工一样: 如果你做的事情太“刺激”,可能最终会弄得一身“脏东西”。
尤其是在大公司,基础设施通常现成(事件总线、缓存服务等都有),好的系统设计往往看上去「没什么特别」。
真正值得在大会分享的设计,我十年里只见过一两次。 而“无聊”的好设计,我每天都在见。