🔗 原文链接:https://www.seangoedecke.com/good-system-design/
在系统设计领域,存在不少欠佳的建议:一类是面向行业新人、主打 “你肯定没听过队列” 的 LinkedIn 风格内容,另一类是标榜 “用数据库存储布尔值即不合格工程师” 的 Twitter 式 “小聪明”。即便部分优质资料(如《设计数据密集型应用》),对工程师日常面临的多数系统设计问题也未必具备实用性。
什么是系统设计?从行业共识来看,软件设计是 “组装代码行”,核心元素包括变量、函数、类等;而系统设计是 “组装服务”,核心元素涵盖应用服务器、数据库、缓存、队列、事件总线、代理等基础设施。本文将梳理 “好的系统设计” 的核心认知 —— 尽管具体决策需依赖实践经验,但仍可提炼出具有普适性的核心原则
一、好的系统设计的识别标准
好的系统设计往往呈现 “不起眼” 的特征,甚至与直觉相悖:
- 外观平淡,运行稳定:具体表现为 “长时间无故障”“操作复杂度低于预期”“无需特意关注某部分运行状态”。例如,当接入某一服务时,若出现 “流程比预想中顺畅” 的感受,大概率是优质设计的作用。
- 复杂不等于优秀:劣质设计常更具 “视觉冲击力”—— 若一个系统堆砌了分布式一致性机制、多模式事件驱动、CQRS 等复杂技术,反而可能是在弥补底层决策失误,或存在过度设计问题。当然,并非所有复杂系统均不合理(部分场景确实需要一定复杂度),但能稳定运行的复杂系统,必然源于能稳定运行的简单系统,从零构建复杂系统属于高风险行为。
二、核心矛盾:状态与无状态
软件设计的核心难点,本质在于 “状态管理”,二者的定义与特征如下:
- 有状态(Stateful):需长期存储信息(如与数据库交互的服务),这类组件易出现故障且无法自动修复 —— 例如数据库中存储了触发应用崩溃的异常数据时,需手动清理;磁盘空间不足时,需手动扩容或删除冗余数据。
- 无状态(Stateless):不持久化存储信息(仅在请求生命周期内暂存数据),典型案例为 GitHub 的内部 API:接收 PDF 文件后返回 HTML 渲染结果,重启服务无需恢复任何前置数据。
设计原则:最小化有状态组件
-
实践方案:设置单一服务专门管理状态(仅该服务与数据库交互),其他服务仅承担无状态处理任务。例如,避免 5 个服务同时写入同一张数据库表,而是让其中 4 个服务通过 API 或事件调用 “状态管理服务”,由后者统一执行写入逻辑。
-
读逻辑灵活性:读操作可适当简化 —— 若直接读取user_sessions表的速度比调用内部会话服务快 2 倍,无需强求通过统一服务调用。
三、数据库:状态管理的核心载体
由于状态管理是系统设计的关键,存储状态的数据库自然成为核心组件。以下以 SQL 数据库(MySQL/PostgreSQL)为例,梳理核心设计要点:
1. Schema 与索引:平衡灵活性与可读性
-
Schema 设计:需具备一定灵活性(避免数据量达百万级后修改 Schema 的繁琐操作),但不可过度灵活 —— 若将所有数据存入 “value” JSON 列,或通过 “键值表” 存储任意数据,会将复杂度转移至应用层,还可能引发性能隐患。核心原则是人类可读:通过 Schema 即可大致理解 “存储内容及存储目的”。
-
索引设计:
- 必要性:仅当表数据量极少(如仅几行)时可省略索引,否则必须添加 —— 索引是提升查询效率的核心手段。
- 匹配查询场景:若常用email与type组合查询,需建立包含这两个字段的联合索引。
- 字段顺序技巧:索引结构类似 “嵌套字典”,需将高基数字段(如email,值唯一度高)置于前方,避免出现 “查询email时先扫描所有同type数据” 的低效情况。
- 避免过度索引:每新增一个索引,都会增加写操作的开销(写入数据时需同步更新索引)。
2. 突破数据库瓶颈:优化读写逻辑
在高流量应用中,数据库常成为性能瓶颈(即便应用层代码效率一般,如 Ruby on Rails 服务),原因是复杂请求可能需顺序执行数百次数据库调用。解决思路包括:
- 最大化数据库处理能力:多表数据查询优先使用JOIN语句,而非多次查询后在内存中拼接;需警惕 ORM 框架的 “内循环查询”—— 例如将 “查询所有记录的 id 和 name” 拆分为 “先查询所有 id,再循环查询每个 id 对应的 name”,会导致查询次数从 1 次增至 101 次。
- 复杂查询拆分:极少数情况下,拆分极复杂查询比优化索引更高效(尽管理论上可通过索引优化解决,但实际操作中拆分更易落地)。
- 读写分离:采用 “1 主库 + 多从库” 架构,读请求优先分配至从库(主库已承担全部写操作)。仅当无法容忍 “毫秒级同步延迟” 时读取主库 —— 例如数据更新后,可直接在内存中使用更新后的值,避免立即查询主库。
- 应对查询峰值:写请求与事务最易导致数据库过载(每笔操作需消耗大量计算资源)。若服务可能引发查询峰值(如批量导入 API),需实施 “节流” 策略控制请求频率,防止数据库因过载陷入 “越慢越拥堵” 的恶性循环。
代码示例:数据库索引与查询优化
1 | -- 1. 合理的用户表Schema设计(人类可读,避免过度JSON化) |
四、快慢操作:任务拆分与后台处理
系统需同时处理 “低延迟响应” 与 “长耗时操作”,核心策略是拆分任务,通过后台异步处理长耗时操作:
- 快操作标准:用户交互场景(如 API 调用、网页加载)需在数百毫秒内返回响应(游戏领域虽要求 10 毫秒内,但实际成功产品中,用户可接受稍慢响应,前提是功能具备实用价值)。
- 慢操作处理方式:优先完成 “对用户有直接价值的最小任务集”,剩余任务移交后台处理。例如转换大型 PDF 文件时,先返回第一页的 HTML 结果,其余页面通过后台任务异步处理。
后台任务的两种实现方式
- 常规任务(短周期):采用 “队列 + 任务执行器” 架构 —— 队列(如 Redis)存储任务(格式示例:{job_name: “pdf_render”, params: {file_id: 123}}),任务执行器从队列中提取任务并运行,支持定时执行(如每日日志清理)。
- 长期任务(长周期):Redis 不适用于存储 “一个月后执行” 的任务(持久化可靠性不足,且查询难度高),此时需改用数据库表:创建包含任务参数与scheduled_at(计划执行时间)字段的表,通过每日定时任务筛选 “scheduled_at ≤ 当日” 的记录,执行完成后标记状态或删除。
代码示例:后台任务实现(常规任务与长期任务)
1 | # 1. 常规短周期任务(基于Redis队列,使用Celery框架) |
五、缓存:缓解高成本操作的双刃剑
缓存用于解决 “重复执行高成本操作” 的问题,但需谨慎使用:
-
适用场景:例如计费服务需调用外部 API 获取实时价格,若采用按次计费模式(如 OpenAI 按 token 收费),会导致 “响应延迟高 + 外部服务流量过载”,此时每 5 分钟查询一次价格并缓存,可显著优化性能。
-
常见实现方案:内存缓存(实现简单但无法跨服务共享)、Redis/Memcached(支持跨服务共享,且读写速度快)。
-
核心原则:先优化,再缓存:初级工程师易倾向 “缓存所有内容”,但资深工程师会尽量减少缓存使用 —— 因为缓存同样属于 “状态载体”,可能出现数据不一致、过期数据(stale data)等问题。例如,对于慢 SQL 查询,应优先添加索引,而非直接缓存查询结果。
-
高级技巧:大结果缓存:若需缓存超大体积结果(如大客户的周度使用报表),Redis 存储能力不足时,可将结果按时间戳存入 S3 或 Azure Blob Storage,直接从存储服务返回文件。
1 | import redis |
六、事件:解耦多服务通信的工具
多数企业会部署事件中心(如 Kafka),但该工具并非适用于所有场景:
- 本质定义:事件中心是一种 “事件队列”,与后台任务队列的区别在于,其存储的是 “某事件已发生” 的信息,而非 “执行某任务” 的指令。例如 “新账户创建” 事件,可被 “发送欢迎邮件”“滥用行为扫描”“账户基础设施初始化” 等多个服务消费。
- 使用边界:
- 优先选择 API 调用:API 调用的日志集中、逻辑可追溯性强、能实时获取响应结果,多数场景下优于事件通信。
- 适合使用事件的场景:发送方无需关注消费方行为(如 “新账户创建” 事件无需确认邮件是否发送成功),或事件流量大且对实时性要求低(如社交平台中每篇新内容的滥用扫描)。
代码示例:Kafka 事件通信实现
1 | from kafka import KafkaProducer, KafkaConsumer |
七、数据流动:推模式与拉模式的选择
数据从 “源头” 传递至 “多个接收方” 时,存在两种核心模式,需根据场景选择:
-
两种模式的核心差异
- 拉模式(Pull):接收方主动请求数据,例如普通网站 —— 用户刷新邮箱时,浏览器向服务器请求最新邮件数据。缺点是易出现 “重复拉取相同数据” 的情况(如刷新页面时重新加载全部内容)。
- 推模式(Push):接收方完成注册后,数据发生变化时由源头主动推送,例如 Gmail—— 新邮件到达后无需刷新页面,会自动显示。
-
不同场景下的选择策略
- 服务间通信:若数据更新频率低(如配置信息),推模式更优 —— 数据更新时向 100 个服务各发送 1 次请求,比 100 个服务每秒各拉取 1 次(累计 1000 次 / 秒)更高效。
- 百万级客户端场景(如 Gmail):
- 推模式:将推送任务加入事件队列,通过大量事件处理器从队列提取任务,分别向客户端推送数据。
- 拉模式:部署数百台 “快速读从库缓存”(无需频繁与主库交互,可存储内存数据或磁盘静态数据),集中处理所有读请求。
代码示例:推模式与拉模式实现(服务间通信)
1 | # 1. 拉模式(服务A主动拉取服务B的配置数据) |
八、聚焦关键:热路径设计
系统设计无需追求 “全面覆盖”,应重点关注热路径(Hot Path):
- 定义:系统中 “业务价值最高” 且 “数据流量最大” 的部分。例如在计量计费系统中,“判断是否向客户收费” 与 “接入所有用户行为以计算费用” 均属于热路径。
- 重要性原因:
- 解决方案局限性高:非热路径(如计费设置页面)的实现方式多样,而热路径(如处理用户行为数据流)的合理解决方案可能仅少数几种。
- 故障影响范围广:非热路径故障(如设置页面异常)难以导致整体产品不可用,但触发所有用户行为的代码若存在漏洞,会直接引发全局故障。
代码示例:热路径设计实现(用户行为计费)
1 | import redis |
九、可观测性:日志与监控体系
“提前发现系统问题” 比 “问题发生后修复” 更重要,核心依赖日志与监控:
- 日志:重点记录 “异常路径”
- 设计原则:在 “流程异常” 场景中增加日志输出。例如返回 422 响应时,记录 “触发 422 的具体条件”;计费逻辑中,记录 “因某原因不执行收费的决策依据”。
核心价值:排查用户问题时不可或缺 —— 若重要客户遇到 422 错误,即便问题源于客户操作,也需明确 “具体操作行为”,不可为追求代码简洁省略关键日志。
- 设计原则:在 “流程异常” 场景中增加日志输出。例如返回 422 响应时,记录 “触发 422 的具体条件”;计费逻辑中,记录 “因某原因不执行收费的决策依据”。
- 监控:关注 “核心指标 + 长尾延迟”
- 运维指标:需监控主机 / 容器的 CPU 使用率、内存占用、队列长度、请求 / 任务的平均耗时。
- 用户体验指标:必须关注p95/p99 延迟(即 95%/99% 的请求耗时低于该数值),而非仅依赖平均值。例如 100 个请求中,98 个耗时 100 毫秒、2 个耗时 10 秒,平均值仅 298 毫秒,但这 2 个慢请求可能来自核心客户,平均值会掩盖真实的用户体验问题。
代码示例:日志与监控实现(Python+Prometheus)
1 | import logging |
十、容错设计:熔断、重试与优雅失败
系统故障不可避免,关键在于 “故障发生时减少影响范围”:
-
重试的局限性与熔断的必要性
- 避免盲目重试:若服务已返回 5xx 错误,盲目重试会增加服务负载,加剧故障严重程度。
- 熔断机制的作用:对高流量 API,若连续收到大量 5xx 错误,应暂时停止发送请求(如暂停 30 秒),为服务预留恢复时间。
- 写请求重试的注意事项:需使用 “幂等键(Idempotency Key)”—— 在请求中携带唯一 UUID,服务执行后存储该键;若收到相同键的请求,直接忽略,避免重复执行(如 “给用户计费” 请求返回 5xx,无法确定是否已计费,幂等键可防止重复扣费)。
-
优雅失败:开放与关闭的权衡
- 当部分系统故障时,需决策 “是否允许请求通过”:
- 失败开放(Fail Open):故障时放请求通过。例如限流系统依赖的 Redis 不可用,暂时允许所有请求 —— 避免限流故障引发用户可见问题。
- 失败关闭(Fail Closed):故障时拒绝请求。例如认证服务不可用,直接返回 401—— 宁可不允许合法用户访问,也不能泄露他人数据。
- 多数场景无标准答案,需结合业务风险权衡。
- 当部分系统故障时,需决策 “是否允许请求通过”:
代码示例:熔断与幂等性设计(Python+tenacity)
1 | from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type |
十一、总结:好设计的本质是 “平淡”
本文未覆盖微服务拆分、容器 / VM 选择、链路追踪等话题 —— 要么是因为这些决策对 “系统好坏” 影响不大(如单体应用多数时候足够好),要么是太基础无需强调(如链路追踪是必备工具),要么是复杂度过高(如 API 设计需单独展开)。
核心观点始终是:好的系统设计,不是用花哨技巧,而是在正确的地方用成熟、经过验证的组件。就像优质的管道工程不会 “惊艳”—— 若操作太复杂,反而容易出现问题。
尤其在大型企业中,现成的基础设施(事件总线、缓存服务等)已足够完善,好的设计往往 “看不见”。实践中,“自定义数据结构实现关键功能” 的惊艳设计极少出现,而 “平淡的、可靠的设计”,才是支撑系统稳定运行的主流。
