这是《把 DDIA 读厚》系列的第二篇文章。在上一篇,我们聊了“可靠性”,探讨了如何从“凭感觉”的容错,进化到真正的“可靠性工程”。今天,我们来啃下一个更硬、也更容易被误解的骨头:可扩展性(Scalability)。

回顾与衔接:当"加机器"也解决不了问题

在上一篇的结尾,我们留下了一个思考题:

你是否曾遇到过一个性能瓶颈,是简单的水平扩展无法解决的?它背后的“负载模式”是什么?

这是一个非常经典的问题,几乎每个后端工程师的职业生涯里都会遇到。一个常见的例子就是**“热点账户”“热点数据”**问题。

想象一个在线教育平台,所有学员都需要在晚上 8 点准时参加一场热门直播课。8 点一到,成千上万的学员同时涌入,系统需要为每个人记录登录、签到等行为。即使你的应用服务器可以轻松地水平扩展(加机器),但所有的写请求最终都指向了同一个逻辑实体——比如数据库里代表这门课程的同一行数据,或者需要更新的同一个总签到人数

这时,无论你加多少台应用服务器,数据库的那一行数据、那一个计数器,就成了整个系统的瓶颈。所有的请求都在排队等待更新它。这就是一个典型的“加机器”也解决不了的问题。它背后的“负载模式”就是:对单一实体的极度写请求集中

这个问题引出了我们今天要讨论的核心:在思考“扩展性”之前,我们必须先学会如何描述负载

本篇锚点:究竟什么是可扩展性?

我们今天讨论的“锚点”,源自 DDIA 对“可扩展性”的精辟定义:

可扩展性不是一个简单的“是/否”标签,而是关于“如果系统的负载以某种特定的方式增长,我们有哪些应对方案?”的讨论。 1

换句话说,当有人问你“你的系统能扩展吗?”时,一个专业的回答不是“能”或“不能”,而是反问:“你指的是哪方面的扩展性?是应对并发用户数增长,还是读写比例变化,或者是数据总量的增加?”

所以,要谈扩展性,我们必须先拥有一套描述它的语言。书中给了我们两个关键工具:描述负载描述性能

发散(一):扩展性的语言

  • 描述负载 (Describing Load)

    DDIA 告诉我们,负载不能用单一数字来描述,而应该用一组最能反映系统架构特点的**“负载参数”**来刻画 2。比如:

    • Web 服务器的每秒请求数。
    • 数据库的读写比例。
    • 实时聊天室的同时在线人数。
    • 缓存的命中率。
  • 描述性能 (Describing Performance)

    • 响应时间 vs 延迟

      :响应时间是用户感受到的端到端时间,是我们的金标准 3

      。而延迟,通常指请求在队列中等待服务的那部分时间 4

    • 百分位点的重要性

      :别再用“平均响应时间”来衡量性能了!它极具欺骗性。一个耗时 10 秒的请求,会被 99 个耗时 100 毫秒的请求平均得无影无踪。我们必须关注

      p95、p99 甚至 p999 的响应时间

      。DDIA 引用了亚马逊的例子:那些请求耗时最长的用户,往往是账户里数据最多的“高价值用户” 5

      。为了他们的体验,优化长尾延迟至关重要。

发散(二)深潜:Twitter 时间线背后的"读/写放大"之战

掌握了描述负载和性能的语言,我们就可以来解剖一个神级案例了。这个案例几乎是所有后端工程师理解“读/写放大”和架构权衡的必修课。

Twitter 的核心功能之一是展示用户的“主页时间线”(你关注的所有人发的推文列表)。我们来看它的负载参数:发推请求平均 4.6k QPS,而时间线读取请求高达 300k QPS 6。读请求是写请求的近 65 倍

面对这个负载模式,Twitter(或者说我们)有两种截然不同的实现思路:

思路一:读时合并(读放大 Read Amplification)

这是最符合直觉的方案,就像传统的数据库设计。

  • 写操作:当一个用户发推时,操作非常简单,只需向一个全局的 tweets 表里插入一条记录。成本极低。

  • 读操作

    :当一个用户要看自己的主页时间线时,操作非常复杂:

    1. 查找该用户关注的所有人。
    2. 对每一个被关注的人,去 tweets 表里查询他们最近的推文。
    3. 将所有这些推文在内存中合并、按时间排序。 这个过程涉及大量的数据库 JOIN 和计算。一次简单的用户读取,会“放大”成一场数据库的查询风暴。这就是典型的**“读放大”**架构。

思路二:写时扇出(写放大 Write Amplification)

这个方案反其道而行之。

  • 写操作

    :当一个用户(比如拥有 1000 个粉丝的

    user_A
    

    )发推时,操作变得非常复杂:

    1. 将推文写入 tweets 表。
    2. 立刻查询出user_A的 1000 个粉丝。
    3. 将这条新推文的 ID,分别写入这 1000 个粉丝的“时间线缓存”中。 一次用户写入,被“放大”成了 1001 次数据库写入。这就是**“写放大”**架构。
  • 读操作:当一个用户要看自己的主页时间线时,操作变得极其简单:直接从自己的“时间线缓存”里读取推文 ID 列表即可,快如闪电。

权衡的艺术:

Twitter 最终选择了思路二。为什么?因为它们的负载模式(300k 读 vs 4.6k 写)决定了,让少数的写操作付出巨大代价,来换取海量的读操作能极速完成,是绝对划算的买卖 7。

这个案例告诉我们,可扩展性设计的本质,就是识别出你系统中那个被放大得最厉害的负载,然后将你的架构重心倾向于优化它

当然,故事还没完。对于有三千万粉丝的明星用户,一次发推就要写入三千万次缓存,这谁也顶不住。所以 Twitter 最终采用了混合模型:对普通用户使用“写放大”,对明星用户则退回“读放大”的模式,在用户读取时再单独拉取和合并 8。这再次证明了,没有一招鲜的银弹,好的架构总是充满了务实的权衡。

收束:我们能学到什么?

给 Go 开发者的代码级清单:

  • 把观测作为本能:别只满足于 log.Printf。使用 Prometheus 客户端库(prometheus/client_golang)来武装你的 Go 服务。你不仅要记录平均延迟,更要用 HistogramSummary 类型来追踪 p95/p99 延迟。你无法优化你衡量不了的东西。
  • 识别你代码中的“放大”模式:审视你的代码。获取一个列表,然后在 for 循环里挨个查询详情,这是“读放大”。更新一个商品,然后去刷新十个相关的缓存,这是“写放大”。识别它们是优化的第一步。
  • 拥抱批量处理:在你的 Go 服务中,主动提供批量处理的接口(比如 GET /api/users?ids=1,2,3),而不是只有 GET /api/users/:id。这能让你的服务成为一个“友好”的上游,帮助整个系统的其他部分避免“读放大”。

给准架构师的架构级教训:

  • 可扩展性是一个“故事”,而不是一个“数字”:别再问“这个系统能扩展吗?”。要学会问:“这个系统在应对‘读请求/秒’这个负载参数增长时,表现如何?”或者“当‘单个用户数据量’增长时,它的瓶颈在哪里?”。架构师的语言必须是精确的。
  • 找到你系统的“核心矛盾”:你的系统里,哪个负载参数比其他的要高出一到两个数量级?是读 QPS?是写 QPS?还是并发连接数?整个架构设计都应该围绕这个最主要的矛盾来展开。
  • 写路径 vs. 读路径的权衡是一门艺术:Twitter 的案例完美展示了,架构师的一个关键工作,就是决定把计算的复杂性更多地放在“写路径”(如发推时的扇出),还是“读路径”(如读时间线时的合并)。这个决策的杠杆,就是你的核心负载模式。
  • 不存在“万能灵药”:Twitter 对明星用户的特殊处理告诉我们,一个好的架构,往往是多种策略的混合体。不要试图用一个方案解决所有问题,要学会对负载进行切分,并应用不同的优化策略。

总结与下一篇的思考题

可扩展性不是简单地“加机器”,它是一门基于量化分析架构权衡的严谨工程学科。它的核心,在于深刻理解你的系统所承受的独特“负载模式”,并把你的设计重心,放在解决那个被放大得最厉害的矛盾上。

留给你的思考题(我们将在下一篇探讨):

我们都经历过维护“屎山”代码的痛苦。回想一下,你觉得那个系统最让你头疼的地方,是它的运维极其复杂(可操作性差),还是代码逻辑绕来绕去难以理解(简单性差),亦或是牵一发而动全身,难以修改(可演化性差)?