系统设计那些事儿

好的系统设计在于使用成熟、简单的组件,而不是追求复杂的技巧。其核心是管理状态,因此数据库至关重要。文章探讨了数据库优化、慢操作处理、缓存、事件、推拉模式、热路径、日志记录以及故障处理等关键环节。最终结论是,优秀的系统设计往往是“无聊”的,因为它意味着系统稳定可靠,无需过多干预。

什么是好的系统设计?

好的系统设计看起来平淡无奇,甚至有些“无聊”。它的标志是系统能长时间稳定运行,让你几乎感觉不到它的存在。如果你觉得“这比预想的要简单”或“我从不用操心这部分系统”,那很可能就是好的设计。

一个能正常工作的复杂系统,总是从一个能正常工作的简单系统演变而来的。从零开始构建一个复杂的系统是个非常糟糕的主意。

相反,那些看起来令人印象深刻、充满复杂机制(如分布式共识、事件驱动、CQRS)的系统,往往是为了弥补某个根本性的错误决策,或者是被过度设计了。

状态与无状态

系统设计中最难的部分是状态管理。任何需要存储信息的地方,都会带来复杂性。

    • 无状态 (Stateless): 服务不存储信息,每次请求都是独立的。例如,一个接收 PDF 文件并返回 HTML 版本的服务。这类服务易于维护,出问题时只需重启即可。
    • 有状态 (Stateful): 服务需要读写数据库,存储信息。例如,任何涉及用户账户和数据的服务。这类服务一旦出现坏数据,就需要人工干预修复。

实践中的最佳做法是,尽量减少有状态组件的数量。理想的结构是:

    • 让一个核心服务负责与数据库交互(管理状态)。
    • 其他服务通过 API 请求或事件与这个核心服务通信,自身保持无状态。
    • 避免让多个服务直接写入同一个数据库表。

数据库是核心

既然状态管理是关键,那么存储状态的数据库就是最重要的组件。

    • 表结构设计 (Schema): 设计应具备一定灵活性,因为后期修改成本高。但也不能过于灵活(如滥用 JSON 字段),这会将复杂性转移到应用程序代码中。好的表结构应该让人能大致看懂应用在存储什么。
    • 索引 (Indexes): 为常用查询字段建立索引,但不要过度索引,因为每个索引都会增加写入开销。
  • 避免瓶颈: 数据库访问常常是性能瓶颈。
      • 善用数据库能力: 尽量让数据库完成数据处理,比如使用 JOIN 而不是在内存中拼接多个查询结果。
      • 读写分离: 将读请求尽可能发送到只读副本,减轻主节点的写入压力。
      • 警惕查询高峰: 对于可能引发大量写入的操作(如批量导入),应考虑进行限流,避免压垮数据库。

处理慢操作与快操作

一个服务需要快速响应用户交互(如 API 或网页),通常应在几百毫秒内完成。但有些操作天生就很慢(如转换一个巨大的 PDF 文件)。

处理慢操作的最佳方式是使用后台任务 (Background Jobs)

    • 工作流程: 将耗时长的部分放入后台任务队列(如 Redis),由专门的工作进程异步执行。例如,先快速渲染 PDF 的第一页给用户,然后将剩余页面的渲染任务放入后台。
    • 长期任务: 对于需要数天或数月后执行的任务,不应使用 Redis。更好的方法是创建一个数据库表来存储待办任务,用一个定时任务每天检查并执行到期的任务。

缓存的使用

当一个慢操作的结果可以被复用时,可以使用缓存。例如,一个计费服务需要查询价格,可以将价格信息缓存五分钟,避免每次都去调用价格服务接口。

初级工程师想缓存一切,而高级工程师则希望尽可能少地使用缓存。

缓存本身也是一种状态,它可能导致数据不同步、脏数据等问题。因此,在使用缓存前,应首先尝试优化操作本身。例如,为一个慢查询添加数据库索引,而不是直接缓存查询结果。

事件驱动

事件系统(如 Kafka)像一个消息队列,但它传递的不是“执行这个任务”,而是“这件事发生了”。例如,当一个新账户创建时,系统可以发出一个“新账户已创建”的事件。

    • 订阅者: 多个服务可以订阅这个事件并各自采取行动(如发送欢迎邮件、进行风险扫描等)。
    • 适用场景: 事件适用于发送方不关心接收方如何处理的场景,或者事件量巨大且对实时性要求不高的场景。大多数时候,直接的 API 调用更简单、更易于追踪。

推送与拉取

当数据需要从一个地方流向多个地方时,有两种模式:

    • 拉取 (Pull): 客户端主动向服务器请求数据。这是最常见的方式,如浏览器刷新网页。缺点是可能产生大量重复请求。
    • 推送 (Push): 服务器在数据变化时,主动将新数据推送给已注册的客户端。这种方式更高效,如 GMail 无需刷新就能收到新邮件。

对于内部服务,如果数据变化不频繁,在数据更新时主动向所有需要它的服务推送一次,通常比让这些服务每秒都来拉取一次要高效得多。

关注热路径

在设计系统时,要优先关注热路径 (Hot Paths),即系统中最重要的、处理数据量最大的部分。

热路径的设计方案选择更少,而且一旦出错,后果也更严重。

例如,在一个按使用量计费的系统中,热路径是那个需要监控所有用户行为以计算费用的部分。你可以有一千种方法来构建一个普通的设置页面,但能稳定处理用户行为数据流的方案可能只有几种。

日志与指标

    • 记录异常路径: 在处理错误或异常逻辑时,要积极记录日志。当用户遇到问题时,这些日志能帮你快速定位原因。例如,记录下用户请求被拒绝的具体条件。
    • 基础监控: 必须监控系统的基本运行指标,如 CPU/内存使用率、队列长度、请求平均耗时等。
    • 关注长尾请求: 除了平均值,还要关注 p95 和 p99 响应时间,因为最慢的请求往往来自你最大、最重要的客户。

优雅地处理故障

    • 重试与熔断: 不要盲目重试失败的请求,这会给下游服务带来更大压力。应使用熔断器 (Circuit Breaker),当某个服务连续出错时,暂停向其发送请求,给它恢复的时间。
    • 幂等性: 对于写入操作,为避免重复执行(如重复扣款),应使用幂等性密钥 (Idempotency Key)。服务在执行操作前检查此密钥,如果已处理过,则直接忽略。
  • 故障开放 vs. 故障关闭: 必须明确部分系统失效时的行为。
      • 故障开放 (Fail Open): 系统失效时,允许操作通过。例如,限流系统不可用时,应放行所有请求,而不是阻止所有用户。
      • 故障关闭 (Fail Closed): 系统失效时,阻止操作。例如,身份验证系统出问题时,必须拒绝所有访问,防止数据泄露。

最终思考

好的系统设计不是关于发明新奇的技巧,而是关于如何正确地使用那些久经考验的、无聊的组件。

在大型科技公司,好的系统设计看起来就像什么都没发生。因为所有基础组件(事件总线、缓存服务等)都是现成的,你只需要像一个好水管工一样,把它们正确地组装起来。如果你做得太花哨,很可能会搞得一团糟。