什么是系统设计
每周跟踪AI热点新闻动向和震撼发展 想要探索生成式人工智能的前沿进展吗?订阅我们的简报,深入解析最新的技术突破、实际应用案例和未来的趋势。与全球数同行一同,从行业内部的深度分析和实用指南中受益。不要错过这个机会,成为AI领域的领跑者。点击订阅,与未来同行! 订阅:https://rengongzhineng.io/
许多系统设计建议反而适得其反。例如,LinkedIn 风格的“你一定没听说过消息队列”的观点似乎专为新人打造;又比如 Twitter 上流行的“如果你在数据库里存布尔值,你就是个糟糕的工程师”的狡猾建议。即便是公认优秀的系统设计书籍,比如《Designing Data‑Intensive Applications》,也不一定能解决大多数工程师在实际中遇到的问题。
在作者看来,什么是系统设计?如果将软件设计比作如何组合代码行,那么系统设计就是如何组装服务。软件设计的基本原语是变量、函数、类等,而系统设计的基本原语则是应用服务器、数据库、缓存、队列、事件总线、代理(proxy)等构件。
在这篇文章中,概括自己关于良好系统设计所知的关键要点,尽管很多具体判断还需依赖经验,这些写作是将能够传达的尽可能写下来。
识别良好设计
良好的系统设计往往毫不起眼:长期无故障运行才是它的标志。如果你总想“嗯,这比预想得简单”,或者“系统这一部分从来不用操心,它就很好”,那就是好设计。反之,夸张、炫技的系统往往掩盖了设计本身的问题:分布式一致性协议、CQRS、事件驱动通信等复杂结构,可能只是用来补偿底层的糟糕决策,如果不是过度设计,就要认真反思。这种复杂设计若非演进而来,只会制造更多问题。
状态与无状态设计
状态是最难处理的软件设计挑战。一旦存储任何持续信息,就要面对复杂的读写协调;而若不存储状态,系统则是“无状态”的。例如,GitHub 内部实现的 PDF 转 HTML 渲染服务就是一个无状态服务。无状态组件可以轻易恢复,例如通过容器的自动重启;但一旦状态组件(如数据库)出现问题,就需要人工干预,如数据格式异常、存储空间耗尽等。
因此,应极力减少系统中的状态组件,集中状态管理于单一服务。多个服务不应直接写同一表,而应通过 API 调用或事件机制,将写入逻辑集中到一个负责写的服务。如果可能,也应尽量统一读取逻辑,但在某些情况下,直接快速读取某个表会比 RPC 调用更轻量、更实用。
数据库设计
状态多,数据库设计就尤为关键:
表结构与索引:表结构应清晰可读;极端灵活(如所有字段都存在 JSON 或 key-value 存储)会将解析逻辑转移到应用层并带来性能负担。根据查询需求创建复合索引,确保高基数字段优先,以提升查效率。索引虽有益,但过多会增加写操作的成本。
访问瓶颈:高并发时,数据库常是瓶颈。避免在应用中拼接多个查询,而应通过 JOIN 等方式让数据库完成更多逻辑处理。若 ORM 内部循环触发多次查询,就会引发严重性能问题。在少数复杂查询中,将一个查询拆为多个简化查询,有时在数据库执行效率上更优。
读写分离:尽量让读请求落在数据库副本,减少主写节点负担,除非对一致性要求极高(副本有延迟)。在更新记录后若马上使用,可先将数据保存在内存而不再立刻查询,规避副本同步延迟问题。
写入高峰处理:写操作和事务更容易压垮数据库。若服务存在批量导入等需求,应实现节流机制,避免瞬间大量请求导致系统拥塞崩溃。
快速操作 与 慢操作的分离
响应用户请求时,应尽可能快;但是某些操作(如 PDF 转 HTML)本身耗时较长。推荐做法是:先处理用户的关键部分,比如只渲染 PDF 的第一页输出,其他页面通过后台任务异步生成。
后台任务 是系统设计的重要原语。标准架构由队列(如 Redis)与任务执行器组成。通过将 {job_name, params}
入队来提交任务,任务执行器从队列读取并执行;也可定时调度执行(用于清理、报表生成等)。这是处理耗时操作的常用路径。
如果任务计划时间较远(如延迟一个月),不应使用 Redis 队列,这样不可靠且难以查询。此类场景可使用数据库持久表,设置参数列与执行时间戳字段,然后每日调度扫描执行。
缓存机制
当某些操作重复且耗时时(如多用户调用计费 API 获取当前价格),频繁请求会影响性能和成本,此时引入缓存是常见解决方案。缓存可放在应用内存,也可使用共享缓存系统,如 Redis 或 Memcached。
但缓存带来状态性和一致性问题。不要在未优化查询时匆忙缓存,先考虑为数据库查询添加索引或其他优化措施,避免缓存“必需性”。对于体积大或长期存储的结果(如大客户周报),可结合定时任务与文档存储(如 S3)作为持久缓存。
事件系统
除了缓存与后台任务,大多数技术公司还会部署事件总线(如 Kafka)。事件本质上是“某件事情发生了”的消息,而不是执行任务指令。例如“新账户创建”事件可以触发发送欢迎邮件、滥用扫描、账户初始化等不同处理流程。
事件系统适用于生产者无需关心消费者行为、或者事件量大但对时效性要求较低的情况。否则直接使用 API 调用往往更清晰、便于排查,因为所有日志集中可见。
推送 vs 拉取
当数据需要从一个地方传递给多个接收方时,可通过拉取或推送方式:
拉取:客户端主动获取数据。这虽然直观,但可能导致频繁拉取重复数据(例如邮箱刷新行为)。
推送:服务方在数据变更时主动下发更新,例如 GMail 的新邮件即时推送。
后台服务间推送数据往往更高效,尤其是接收方数量有限。若服务规模巨大(如服务百万用户),推送与拉取都需横向扩展:推送模式需消息队列与多个处理节点;拉取模式则需高性能缓存层支持高频读取。
热路径设计
系统中路径很多,但真正关键的是“热路径”——处理量最大、最关键的路径。在按使用量计费系统中,决定是否计费与计算用户行为的模块,就属于热路径。这部分设计方案有限,且极易因错误导致灾难级后果。相比之下,错误设置页面虽糟糕,但影响范围较小。
日志 与 指标监控
如何确定系统是否有问题?作者建议在出错路径上广泛记录日志,例如记录返回 422 的具体判断条件、计费决策的原因等。这虽然增加代码冗余,但在排查客户问题时极有帮助。
还应监控系统运行状态,包括:CPU/内存、队列长度、请求/任务的均时延、p95、p99 等关键性能指标。尤其是 p95/p99 延迟,虽少量请求慢,但往往来自重要客户,影响极大,平均值不足以反映问题严重性。
熔断、重试 与 优雅降级
系统失败时如何处理?重试机制需谨慎,避免盲目重试导致下游系统过载。应使用熔断器(circuit breaker)机制:连续失败时暂缓请求,让服务恢复。同时,写操作重试风险更大,需使用幂等性键(idempotency key)避免重复处理。
还需明确降级策略。例如,当用于限流的 Redis 不可用时,是“开放”(fail open)让请求通过,还是“关闭”(fail closed)拒绝请求,要根据功能区分:限流应开放,而认证功能必须关闭以防止权限泄漏。其他功能则需权衡设计。
总结:匠心设计来自朴素架构
文章最后强调,许多系统设计问题如微服务拆分、容器 vs VM、链路追踪、API 设计均未深入讨论。原因包括这些问题的重要性有限、或过于明显、不便简述。作者强调:优秀的系统设计不是靠花哨技巧,而是将成熟、稳定的组件恰当地组合。大型公司往往已有通用组件(事件总线、缓存服务等)可用,真正优质的系统设计看起来像“什么都没做”,但日久无恙运行。真正需要复杂、自创数据结构的场景极少,而日常运维的永远是朴素、稳健的“无趣”设计。