从瓶颈视角看后端系统设计:从网址短链接到电子商务
一种以需求为导向的 Node.js 后端设计方法:何时需要添加负载均衡器、Redis、副本、队列和速率限制,以及每种方案会带来怎样的成本。
来源: System Design for Backend Engineers: A Practical Guide With Real Examples
许多工程师在设计系统时会先绘制出各种组件构成的图表:负载均衡器、Redis、Kafka、API网关,还有十几种微服务。这样的图表看起来很 impressive,但实际上并未说明任何问题。无论是在面试中还是实际产品开发中,设计的核心在于为特定问题选择合适的组件,并能够为每个组件的选用提供合理解释。本指南将通过两个具体的系统——网址缩短服务和小型电子商务后端——来展示这一思考过程,帮助你逐步构建合理的架构,并明确每一步所带来的权衡。
从需求出发,而非技术
最常见的初期错误就是从技术工具入手。有人描述了一个问题,人们首先想到的却是“用Redis还是Kafka?用微服务吗?”这种思路是反的。在明确系统需要完成什么功能以及规模之前,根本无法做出任何选择。
以网址缩短服务为例。在画出任何一个架构图之前,都需要先回答以下问题:
- 会有多少用户使用它,他们会产生多少网址?
- 在高峰时段它需要处理多高的请求速率?
- 链接的有效期是多久,是否会过期?
- 用户是否需要点击分析功能?
- 可以承受多长的停机时间?
- 重定向的速度要求有多快?
每个问题的答案都会引导设计方向。面对如此高流量的服务:
1,000 requests/day
与需要处理这类流量的服务几乎没有任何共同点:
10 million requests/secon
第一种方案可以轻松部署在单台小型服务器上。而第二种方案从一开始就不得不考虑缓存、复制以及地理位置等相关问题。
从最简单的可行架构开始
对于小型网址缩短服务而言,第一个版本几乎可以做到极其简单:
Client
↓
Node.js API
↓
PostgreSQL
API会生成一个短代码,并返回类似如下的链接:
https://example.com/abc123
随后它会在PostgreSQL中存储一条映射关系:
short_code → original_url
当浏览器请求类似下面的路径时,API会查找原始网址并返回重定向响应:
/abc123
对于规模较小的工作负载来说,这已经是一个完整且正确的系统了。它并不需要那些常见的额外组件:
Kafka
Redis
Kubernetes
Microservices
仅仅因为这些功能可用就加以采用,只会增加运营成本、故障模式,还会让新成员需要学习更多内容。先开发出能正常工作的产品,对其进行测试,然后再针对那些成为瓶颈的部分进行扩展。
当单台服务器不足时进行横向扩展
假设该链接缩短服务变得非常流行,其唯一的API进程不堪重负:
1 API server
你可以继续购买更强大的服务器,但垂直扩展存在上限,而且单台服务器本身就是故障点。更为常见的做法是在负载均衡器后运行多个相同的实例:
Load Balancer
↓
┌───────────┼───────────┐
↓ ↓ ↓
Node API Node API Node API
↓ ↓ ↓
└───────────┼───────────┘
↓
PostgreSQL
现在请求会被分配到多个应用程序实例上。这就是横向扩展:而不是依赖
1 huge server
你应依赖
multiple smaller servers
只有当各个实例可以互相替换时,水平扩展才能生效,而这带来了之前不存在的问题:
- 如果用户的下一个请求可能访问到不同的实例,会话状态应存储在哪里?
- 每个实例真的都是无状态的吗?
- 既然本地磁盘不再共享,上传的文件应存放在何处?
- 每个实例如何访问数据库等共享基础设施?
扩展某个组件往往会在其他地方产生新的需求;在这种情况下,状态必须被存储到共享存储中。
只有在真正存在缓存问题的时候才添加缓存
热门的短链接会被反复请求:
/abc123
/abc123
/abc123
/abc123
/abc123
没有必要让每个请求都去查询 PostgreSQL。在数据库前端设置缓存,可以从内存中提供热键数据,若未找到则回退到数据库,并将结果存储起来以供下次使用:
Client
↓
Load Balancer
↓
Node.js API
↓
Redis
│
├── Cache Hit → Return URL
│
└── Cache Miss
↓
PostgreSQL
↓
Redis
这种缓存辅助机制能够为类似链接重定向这类负载不均衡的场景减轻数据库的读取压力。但它也引出了一个新的问题:如果短链接的目标地址被更改或删除,缓存中的副本该如何知晓?此时就需要一种失效策略,比如在每次写入时都删除对应键值、设置有效时间,或两者结合使用。如需更深入地了解这些选择,可参阅我们的指南 Redis 缓存模式、陷阱与面试相关知识。
本指南中的每一项改进都会带来类似这样的代价。能够明确指出这一代价,正是设计与示意图的区别所在。
在添加更多数据库之前先修复现有数据库
在某些情况下,PostgreSQL 可能会成为性能瓶颈。在新增基础设施之前,先查明其速度慢的原因。常见原因包括:
- 用于查询的列上缺少索引
- 查询或连接操作效率低下
- 应用程序中存在 N+1 查询模式
- 返回的行数或列数远超实际需求
- 分页功能不佳,例如偏移量过大
- 连接池配置错误或已耗尽资源
很多时候,解决方案只需一条语句即可:
CREATE INDEX ...
这比开发新组件更经济、更安全。需牢记的准则是:在围绕该瓶颈构建架构之前,先解决已检测到的性能问题。
面向高读取负载的读取副本
许多应用程序的读取操作远多于写入操作,例如:
90% reads
10% writes
在这种情况下,主数据库可能会被大量的读取请求淹没。一种解决办法是添加读取副本:主数据库负责处理所有写入操作,而副本则承担部分读取任务。
Node.js API
↓
┌────────┴────────┐
↓ ↓
Primary DB Read Replica
↓ ↑
Writes Reads
复制延迟带来的成本
在大多数架构中,副本是异步更新的,因此写入操作会先到达主数据库,之后才会同步到副本:
Write → Primary
↓
Replication
↓
Read Replica
如果某个请求先写入数据,然后立即从副本读取该数据,它可能会在短时间内看到过时的数据。因此,应用程序需要知道哪些读取操作需要最新的数据状态。一种常见的方法是将“读取自己刚写入的数据”这类请求发送到主数据库,比如在保存配置文件后立即从主数据库加载该配置,而将其他请求发送到副本。
将耗时的操作放入后台任务中
许多操作无需在用户收到响应前就完成:
- 发送邮件
- 生成发票
- 发送通知
- 制作报告
- 调用响应缓慢的外部服务
为了避免让用户等待,API会记录需要执行的任务,将其加入队列后立即给予响应:
Client
↓
Node.js API
↓
Create Record
↓
Queue Job
↓
Response
一个独立的工作者进程会接取该任务并执行相应操作:
Queue
↓
Worker
↓
Process Job
实际上,一个队列就可以为处理多种类型任务的工作者提供任务:
API
↓
Redis / Queue
↓
Worker
├── Email
├── Invoice
└── Notification
这样既能降低请求延迟,又能为重试和吞吐量限制提供可控的机制。不过这也带来了一些需要考虑的设计问题:
- 某个任务可能会被多次执行,因此处理程序必须能够应对重复的任务。
可扩展性从来都不是免费的。如果您使用 Node.js 构建系统,我们关于使用 BullMQ 和 Redis 构建可靠后台作业系统的指南通过代码展示了如何解决这些问题。
在故障发生前进行设计
有趣的设计工作始于不再只考虑一切正常时的情况,而是开始思考某个组件出现故障时会怎样。
当依赖项失效时实现优雅降级
假设 Redis 不可用,整个 API 是否就应该停止运行?如果 Redis 仅被用作缓存,那大概不必如此。API 可以跳过缓存直接从 PostgreSQL 读取数据,虽然速度较慢但结果仍是正确的:
Request
↓
Redis
↓
FAIL
↓
PostgreSQL
↓
Response
如果 Redis 还承担着任务队列或速率限制的功能,出现故障的后果就会严重得多,因为这些功能没有简单的备用方案。设计时应明确每个组件的职责,因为正是这些职责决定了组件出现故障时系统的运行方式。
为每个外部调用设置超时时间
想象一下,如果 API 调用外部支付服务时没有设置超时时间:
API
↓
Payment Service
↓
... waiting ...
↓
... waiting ...
↓
... waiting ...
在等待过程中,服务器会持续占用套接字、内存以及请求资源,而这些资源本可用于其他可能永远不会响应的请求。在高负载情况下,这些被卡住的请求可能会耗尽整个进程的资源。一个设计良好的系统会为每个调用设定截止时间:
API
↓
Payment Service
↓
5 second timeout
↓
Failure
一旦截止时间过去,应用程序就必须决定下一步该怎么做,而正确的选择取决于具体操作:
- 重新发起调用
- 向客户端返回错误信息
- 将操作排队到稍后处理
- 使用备用方案
- 将操作标记为待处理,之后再进行协调
只有当操作是幂等的时候,重试才是安全的
当支付调用超时后,重新尝试似乎是显而易见的做法。但超时并不能说明支付是否失败——也许支付已经成功,只是响应丢失了。盲目重试可能会导致向客户收取两次费用。
解决方法是实现幂等性:为操作附加一个稳定的标识符,以便接收方能够识别出重复的请求。
payment_id = order_123
支付服务提供商使用该密钥来判断请求是否已被处理,若是则直接返回原有结果而不再重复扣费:
Request
↓
Idempotency Key
↓
Payment Provider
↓
Already processed?
├── Yes → Return previous result
└── No → Process payment
添加重试功能只需一行代码,但要确保其安全性则需要整个系统协同工作。我们在关于Node.js POST接口中幂等性密钥的文章中介绍了如何自行实现接收端的处理逻辑。
通过限流保护系统
现在假设某个客户端开始发送如此大量的请求:
100,000 requests/minute
如果全部接受这些请求,某个恶意用户就会破坏整个服务的正常运行。限流器会部署在API前端,通过HTTP 429状态码拒绝多余的请求:
Client
↓
Rate Limiter
↓
Allowed?
├── Yes → API
└── No → 429
当存在多个API实例时,计数器需要被共享,这也是Redis成为分布式速率限制常用后端存储的原因:所有实例都会对相同的键进行递增操作。
限制值应反映每个端点的功能特性。登录端口容易成为暴力攻击的目标,因此设置如此严格的限制是合理的:
Login:
10 requests/minute
相比之下,公开的产品列表则可以承受更宽松的限制:
GET /products:
1000 requests/minute
应根据操作的成本及其威胁模型来设定限制,而非使用统一的固定数值。
系统设计本质上是权衡取舍
几乎不存在完美的单一架构。大多数决策都是在各种各有优缺的方案之间进行选择。
SQL与NoSQL
关系型数据库具备强大的关系建模能力、事务处理功能以及灵活的查询语言。而NoSQL存储则具有不同的扩展特性、更灵活的架构,且能在特定工作负载下带来优势。数据的结构及访问模式决定了哪种类型更为合适。
缓存与数据库
缓存速度虽快但属于临时存储,其失效处理会增加复杂性。数据库则更为持久,是数据的真实来源,不过对于频繁读取的数据来说,其响应速度可能会较慢。
单体应用与微服务
单体应用结构更简单,在初期部署和调试都更为容易。微服务则支持独立的部署与扩展,团队之间的职责划分也更明确,但相应的分布式系统复杂性也会大幅增加。
通常没有绝对正确的选择,需根据具体需求来决定。
优先选择模块化单体架构而非早期微服务
这一选择值得单独设节讨论,因为过度设计在此类架构中带来的成本最高。新产品无需从这样一组服务开始:
Auth Service
User Service
Product Service
Order Service
Payment Service
Notification Service
更好的选择是单个可部署的应用程序:
Node.js Application
该应用程序在内部被划分为具有明确边界的模块:
├── Auth
├── Users
├── Products
├── Orders
├── Payments
└── Notifications
这样便拥有一个代码库、一次部署,同时各功能域之间也有清晰的分隔。如果某个模块日后需要独立扩展或部署,清晰的边界使得将其提取出来变得十分简单。
微服务确实能够解决组织架构和扩展性方面的实际问题。但它们也会带来服务间的网络故障、分布式追踪需求、服务发现难题、更复杂的部署流程、跨服务边界的数据一致性挑战,以及更多的监控和基础设施需求。只有在需要其带来的好处时才应承担这些成本。
在可行的最小层面解决每个瓶颈
以一个带有慢速数据库的简单三层系统为例:
Client
↓
API
↓
Database
存在从成本最低到影响最大的多种应对方案。如果某个特定查询速度较慢,就对其进行优化:
Slow query
↓
Optimize query
如果查询需要扫描整个表,就添加索引:
Missing index
↓
Add index
如果重复获取相同数据,就添加缓存:
Repeated reads
↓
Add cache
如果读取操作占主导地位,就增加副本:
Read-heavy workload
↓
Read replicas
只有在真正大规模的情况下才需要考虑结构上的变更:
Massive scale
↓
Partitioning / sharding / architectural changes
顺序至关重要。只有在前一步骤不再足够时才会采取下一步,且每一步都会比前一步增加更多复杂性。
设计面试的标准化流程
同样的思路也适用于系统设计面试。一个合理的流程如下:
- 明确功能性需求与非功能性需求。
- 估算流量和存储需求。
- 提出能够满足这些需求的最简单架构。
- 找出该架构最先出现问题的环节。
- 有针对性地解决这些瓶颈问题。
- 逐一分析故障场景。
- 讨论所做出的权衡取舍。
- 为每个新增的组件提供合理解释。
这种做法远比直接画出二十个框要有说服力。没有合理设计思路的精美图表并非优质的设计。
整合应用:小型电子商务后端
将这些理念应用于在线商店,可以得到如下合理的初始架构:
Client
↓
Load Balancer
↓
┌────────┴────────┐
↓ ↓
Node API Node API
│ │
└────────┬────────┘
↓
┌────────┼────────┐
↓ ↓ ↓
Redis PostgreSQL Queue
│ ↓
│ Worker
│ ↓
│ Email / Invoice
│
↓
External APIs
Payment / Shipp
其优势在于每个组件都能回应某个问题:
- Redis负责缓存、短期数据、限流计数器以及队列基础设施。
- PostgreSQL用于存储持久化的业务数据,并为订单和支付提供关系型事务支持。
- 队列用于处理那些执行速度较慢的异步任务。
- 多个API实例可实现水平扩展和冗余备份。
- 负载均衡器负责将流量分配到这些实例上。
请注意,Redis在这里承担了多种角色。虽然这样效率很高,但同时也意味着Redis出现故障会同时影响缓存、速率限制和任务处理,而这正是上述故障分析应当揭示的依赖关系类型。
确定设计方案前需问的六个问题
- 它能解决什么问题?如果无法清晰表述,说明该架构尚未成熟。
- 需要达到怎样的规模?包括用户数量、请求频率、数据量以及存储和延迟目标。
适度的复杂性优于华而不实的复杂性
学习系统设计时最大的陷阱就是认为更复杂的架构就一定更好。事实并非如此,它只是更加复杂而已。良好的设计应追求适度的复杂性。
如果一个 Node.js 应用程序和一台 PostgreSQL 数据库就能满足需求,那已经是一种非常理想的架构了。当流量增加时,再添加实例;当读取操作成为瓶颈时,对其进行优化;如果缓存能起到帮助作用,就加入 Redis;当某些任务可以稍后处理时,就使用队列;而如果某个模块确实需要独立扩展或部署,那就考虑将其拆分出来。架构会随着产品的发展而不断演变。
关键要点
- 首先明确需求和大致的容量估算,技术选型则基于这些需求来确定。
- 从最简单的可行系统开始,仅对那些经检测存在瓶颈的部分进行扩展。
- 每增加一项功能都会有相应的代价:水平扩展需要共享状态,缓存需要处理失效问题,副本会带来延迟,队列则会产生重复数据。
- 应明确规划故障处理方案,包括备用机制、超时设置、幂等重试以及速率限制等。