跳至正文
Starone

架构

为百万级请求设计系统

高请求量很少是真正的难点。难的是状态、写路径,以及需求超过容量时系统的表现。

· 阅读约 5 分钟 · Starone 工程团队

承载百万级请求,与其说关乎原始吞吐量,不如说关乎系统在极限处的行为。负载均衡器后面的无状态服务,靠增加实例就能扩展。真正需要花设计功夫的,是它背后的状态、最慢那批请求的延迟,以及当到达的工作量超过处理能力时系统的降级方式。

我们的立场是,过载行为应当在开始优化之前就设计好。一个在容量上限处能够丢弃多余负载并保持响应的系统,比一个上限更高、但一触顶就崩溃的系统更有价值。

先把账算清楚

每天一百万个请求,平均下来大约每秒十二个,一台配置得当的服务器就能轻松应付。真正驱动设计的数字是峰值,而不是日总量,此外还有读写比例和每个请求的成本。一个每秒接收一千个缓存读请求的系统,要比一个每秒接收五十个事务性写请求、且每个写请求都涉及好几张表的系统简单。

把这些数字写下来,哪怕只是估算,也能避免两种常见错误:为一个单库就能扛住的负载搭建分布式架构,以及到了很晚才发现写路径存在一个没人测量过的硬上限。

服务无状态,状态有意安排

应用实例不应持有任何其他实例会用到的状态。会话放在共享存储或签名令牌里,上传文件进入对象存储,定时任务通过锁或队列来协调,而不是谁碰巧先启动就在哪个节点上跑。这样一来,任何实例都能处理任何请求,实例也可以在部署过程中被增加、移除或替换,而用户毫无察觉。

无状态只是把问题挪了个地方,并没有消除它。状态现在位于数据库、缓存和队列里,而决定系统能扩展到多大的,正是这些组件。

缓存分层及其失效方式

缓存是分层工作的,每一层都为它后面的那一层卸掉负载:CDN 负责静态资源和可缓存的响应,进程内缓存负责热点参考数据,Redis 这类分布式缓存负责共享的计算结果。响应在离用户越近的地方返回,消耗的基础设施就越少。

每一层都会带来一个一致性问题和一种失效模式。真正导致宕机的是缓存击穿:一个热门的 key 过期,数百个请求同时未命中,全都去对数据库执行同一条昂贵的查询。

  • 给过期时间加上抖动,让同时写入的 key 不会同时过期
  • 只让一个请求重新计算缺失的 key,其余请求等待或拿到旧值
  • 按数据类型决定是在写入时失效,还是接受一个有界的陈旧窗口
  • 确认数据库扛得住冷缓存,或者备有一套受控预热的流程

数据库扩展:先副本,后分区

对于读多写少的负载,只读副本是第一步。但副本是异步的,所以用户写入之后立即从副本读取,可能看不到自己刚做的修改。常见的补救办法是在写入之后的一小段时间内把读请求路由到主库,或者跟踪会话所要求的复制位点。

副本对写入量毫无帮助。当单个主库再也吸收不了写入或装不下数据时,可选的方案是在一个数据库内分区,或者跨多个数据库分片。分区键是那个很难回头的决定。好的分区键能让负载均匀分布,并把重要的查询留在同一个分区内。糟糕的分区键会制造热点分区,即某个大客户或某个繁忙的时间段承接了大部分流量,同时还会带来必须扇出再合并的跨分区查询。

分片应当排在很靠后的位置。索引、查询调优、连接池、冷数据归档以及换更大的实例,都更便宜,而且可以回退。我们会在垂直扩展的余量即将耗尽时才转向分片,而不是一开始就把它当作既定方向。

背压、队列与负载丢弃

每个系统都有一个最大处理速率。设计上要回答的问题是,超出这个速率的请求会怎样。如果不设任何限制,它们会堆积在线程池、连接池和无界队列里,所有人的延迟都会上升,客户端超时并重试,而重试又给一个已经在失败的系统增加负载。一次短暂的流量尖峰就是通过这个反馈回路演变成长时间宕机的。

背压通过把限制显式化并向上游传递来打破这个回路。队列有界。每个出站调用的超时都短于调用方自身的截止时间。重试采用带抖动的指数退避,并设有重试预算,使其无法成倍放大流量。熔断器会停止对明显已经失败的依赖发起调用。

队列能削平突发流量,却不会增加容量。如果到达速率超过处理速率的时间足够长,队列只是推迟了失败,并且在恢复时处理的还是已经过时的工作。达到上限时,系统应当尽早、低成本地拒绝请求,返回 429 或 503 并附带 Retry-After 响应头,同时按优先级丢弃:支付确认要保留,推荐面板可以舍弃。

规模上来之后,尾延迟起主导作用

请求一旦扇出,平均值就会产生误导。如果一个页面并行调用一百个后端,每个后端有百分之一的概率变慢,那么大约 63% 的页面加载会包含至少一次慢调用。用户感受到的是最慢的那个依赖,于是每个服务的 p99 就成了整体的典型体验。

  • 按依赖逐个设定 p99 延迟目标,并对照端到端目标来分配预算
  • 通过批量处理和去掉首次响应不需要的调用来减少扇出
  • 对幂等的读操作使用对冲请求,在短暂延迟后发出第二次尝试
  • 当可选依赖超出截止时间时,返回部分结果

赶在用户之前找到极限

容量是一个测量出来的量。我们使用贴近真实的操作组合和数据量做压测,并采用开放式负载模型,即无论响应时间如何,请求都以固定速率到达。封闭式模型下,每个虚拟用户要等到响应之后才发下一个请求,系统吃力时它会自行降速,恰恰掩盖了本该暴露出来的延迟。

测试应当越过曲线的拐点一直压到某处出问题为止,因为目的就是弄清楚哪个组件最先失效,以及负载回落后系统能否自行恢复。一个过载之后需要人工重启的系统,存在一个值得在上线前修复的缺陷。

正在做类似的事情

我们很乐意交流心得。欢迎与撰写本文的工程师聊聊。