Book Cover

目录


第 1 章:凌晨 3 点的电话

第一部分:崩溃

那不是普通的铃声,而像是在尖叫。

那是一种极其特殊的震动方式,像是手机厂商专门为了制造恐慌而设计出来的。便宜木质床头柜上传来暴躁、凶狠的嗡鸣。它不是把你叫醒,而是直接把你从睡梦里拽进高度警戒状态。时钟上那刺眼的红光显示着:凌晨 3:14

我还没完全睁开眼,心脏就已经开始猛跳。这个时间点来电话,通常只可能有两种情况:家里出了急事,或者公司出了大事。来电显示很快排除了前者。屏幕上跳出了一个我再熟悉不过的名字:“Suumit”

Suumit Shah,我的联合创始人,我的战友,我负责搭系统,他负责业务。凌晨 3 点他只会为一件事打电话:公司出事了。

我划开接听,嗓子又干又哑地挤出一句:“喂?”

“Subhash! Uth! Sab bandh ho gaya hai!” Suumit 的声音像一发肾上腺素爆弹,从听筒里直接轰了出来。快起来!全都挂了!

他甚至不需要再多说。我已经从床上翻身起来,冰冷的地面让我瞬间清醒。我跌跌撞撞走向笔记本电脑,黑暗房间里 Apple 标志那道熟悉的白光,像是一座灯塔。我脑子里疯狂翻滚着各种数字灾难的可能性。

我们被黑了吗?是 DDoS 攻击?还是哪个国外的脚本小子闲得无聊,把我们当成靶子?

是不是某个开发者推了一段有问题的代码?一个放错位置的分号,就把整个系统打趴下了?

还是我们的云厂商宕机了?这次是不是已经完全不在我们掌控范围内?

“网站打不开了,App 也在报错,什么都不行,整个系统一片黑。”Suumit 继续说,声音绷得很紧。我能听出他在电话那头来回踱步。

“好,好,我来看。Shanti。”我嘴上尽量保持镇定。先冷静。 灭火的第一原则,就是别让自己也变成火的一部分。

我还带着睡意的手指飞快地在键盘上敲动。我打开终端,那块黑底绿字的屏幕,就是我观察整个系统的窗口。那是我的作战指挥台。

ssh root@dukaan.app

我按下回车。

光标在闪。

一直在闪。

还在闪。

正常情况下,登录提示应该瞬间出现。这个延迟……不是好兆头。是非常糟糕的兆头。这意味着服务器不只是“生病了”,而是已经躺在病床上,连开门都很吃力。过了像一个世纪那么久,提示符终于出现了。服务器还活着,但只剩一口气。

我脑子转得飞快。如果服务器慢成这样,那就不是普通代码 Bug 了。问题更深,更底层。感觉像是这台机器本身都快喘不过气了。

我敲下第一个诊断命令,一个用来查看服务器生命体征的简单工具。

htop

屏幕上出现的结果让我浑身发冷。满屏都是红色。

每一个进程都像在拼命呼救。CPU 使用率条全部顶到 100%。内存条,也就是服务器“思考空间”的那部分,已经被塞满了。Swap,也就是服务器的紧急备用内存,也被占满了。

服务器不只是快窒息了。它其实已经死了,我们看到的只是神经系统最后几次抽搐。

然后我看见了原因。那个简单到几乎有点难堪的原因,就摆在系统监控顶部:服务器总内存是 512MB

512 兆。

我现在用的智能手机都有 8 GB RAM,是这台承载整家公司机器的 16 倍。成千上万依赖我们的商家,目录里几百万个商品,Dukaan 整个公司的希望与野心,居然都压在一台算力还不如我口袋里手机的机器上。

这不是什么高深黑客攻击,也不是什么复杂 Bug。我们只是把空间用完了。我们试图在电话亭里办一场摇滚演唱会,而电话亭终于塌了。

我盯着屏幕,手机还贴在耳边,忽然生出一种令人发寒的清醒感。一个没有科班计算机学历、没有系统扩容正规训练的人,怎么会走到今天,负责这样一套东西?

要理解这个问题,你得先理解我们当时试图驯服的怪兽。你得先理解,此刻正在起火的那个东西,到底是什么:我们的服务器。

第二部分:服务器解剖课,或者说,只有一个厨师的厨房

先从凌晨 3 点的恐慌里抽离一下。要解决问题,先得理解问题。到底什么是“服务器”?

先把那些技术术语放一边,也别去想冰冷机房里闪个不停的灯。从这本书开始往后读,我希望你把服务器想象成一件更简单的东西:一家餐厅后厨,里面只有一个忙得不可开交的厨师。

这个比喻,是你理解基础设施最重要的一把钥匙。后面所有内容都建立在它之上。

CPU:厨师的速度

CPU(Central Processing Unit) 就是你的厨师。它是整个厨房的大脑。厨师拿到原材料(数据),按照菜谱(代码)处理,最后做出成品(网页、搜索结果、订单等)。

  • CPU 越快(通常以 GHz 衡量),就像厨师动作越快。切菜、翻锅、装盘都更利索。
  • CPU 的 核心数越多,就像厨师长出了更多手臂。4 核 CPU,好比一个能同时切菜、翻炒、油炸、调味的厨师,可以并行处理多项任务。

我们那台小小的 512MB 服务器只有单核 CPU。相当于一个独臂厨师,却要给一万人做满汉全席。

RAM:操作台空间

RAM(Random Access Memory) 就是厨房的操作台。这部分非常关键,必须真正理解。RAM 是厨师的工作台面,是他放置当前正在做的菜所需食材、锅碗瓢盆和工具的地方。

从操作台上拿东西极快。厨师不用思考,伸手就能拿到。RAM 越大,操作台越大。操作台越大,厨师就能同时处理更多订单,因为所有材料都已经摊在眼前。

如果操作台满了,问题就严重了。厨师必须停下手头工作,跑去后面的慢速储藏间找材料,再搬回来;与此同时,还得把台面上的别的东西挤下去,腾位置。这会让一切速度暴跌。

这正是我们服务器当时发生的事。512MB 的 RAM,就像一块小小切菜板。我们的应用、数据库以及操作系统本身,都在争这点可怜的台面空间。一旦放满,服务器就开始使用 “swap” 这块区域,也就是慢速储藏间里专门划出来的“紧急操作台”。它的效率糟透了。厨师花在来回跑储藏间上的时间,已经比真正做菜还多。

磁盘 / 存储:储藏间

Disk(或 Storage),无论是 HDD 还是 SSD,都相当于厨房里的储藏间和冰箱。菜谱(代码)、食材(数据)、厨房设备(操作系统)都长期放在那里。

它比操作台(RAM)大得多,但访问速度也慢得多。你当然不希望厨师每次需要一撮盐,都跑去储藏间拿;盐应该已经放在操作台上了。

资源争用:厨房里的混乱

现在再来想象我们当时的系统。一个小得可怜的厨房,一个独臂厨师,一块切菜板大小的操作台,而我们却要求他什么都做:

  • 跑应用: 他要读菜谱(我们的 Python 代码)并完成烹饪。
  • 管理数据库: 他还得兼任储藏管理员,不停整理、取出、存放各种食材(用户数据)。
  • 处理 Web 流量: 更糟的是,他还得兼职服务员,跑去餐厅前台,同时接收成千上万顾客的新订单。

这就是 Resource Contention(资源争用)。所有人都在同时抢厨师的注意力。应用要 CPU,数据库要写磁盘,用户请求要 RAM。它们都在争同一套有限资源,结果就是整个系统彻底堵死。

如果想看清这种混乱,你需要在厨房里装一台监控摄像头。在服务器世界里,这台“摄像头”就是一个简单命令:htop。它是 top 的增强版,能实时告诉你厨师到底在忙什么。

它看起来有点复杂,但你只需要关注几个地方:

  • 顶部 CPU 条: 如果全部拉满到 100%(而且是红色),说明厨师已经过载了。
  • Mem(Memory / RAM)条: 如果这里满了,说明你的操作台已经溢出了。
  • Swp(Swap)条: 如果它开始上涨,说明情况已经很危险。厨师正在把慢速储藏间当工作区用。
  • 进程列表: 这里会显示每一项任务,以及谁正在疯狂吞资源。

学会读懂这块屏幕,是成为 CTO 的第一步,不管你是不是“意外成为”的。它会让你停止靠猜,开始真正诊断。对那晚的我来说,屏幕只是在反复喊一句无法否认的真相:我们的厨房从根子上说,就小得无法承载我们的野心。

第三部分:辉煌而危险的单体应用

服务器是厨房,那厨师手上的菜谱又是什么?在软件世界里,我们把这叫作 架构(architecture)。而我们的架构,是几乎所有创业公司起步时都会采用的经典形态。

我们用的是 Monolith(单体架构)

这个名字听起来很吓人,像什么古老的巨型石碑。但它本质上是个很简单的概念。单体应用,就是所有代码都放在一个统一的大块头里。用户注册、商品目录、订单管理、商家后台、支付处理,所有这些代码,全都塞进了同一个 Django 项目里。

你可以把它想象成一本又厚又全的超级菜谱。前菜、主菜、甜点、饮料,所有配方都装订在同一册里。

为什么我们一开始会选择单体(而且这是对的!)

我想特别明确一点:从单体开始,不是错误。对于创业公司来说,它往往是 最好的选择。在最初阶段,你唯一的目标就是尽快把产品做出来、发出去。你得先验证,这个世界上到底有没有人真的想要你做的东西。

单体天生就是为了速度而生:

  • 开发简单: 所有东西都在一个地方。不需要操心不同服务之间复杂的通信。写个函数,直接调用就行。
  • 测试简单: 你可以在自己的笔记本上把整个应用跑起来,把所有功能一起验证。
  • 部署简单: 整块代码打包,放到服务器上。结束。

我们的单体架构,让我和 Suumit 两个人,在 48 小时内就搭出并上线了一个可用的电商平台。如果一开始就选择更复杂的“微服务”架构(后面会讲),我们大概到现在还在争设计方案。

单体是我们的超能力。它让我们快得像闪电。但就像所有超能力一样,它也有隐蔽而危险的副作用。


单体的隐患

随着这本“菜谱”越写越厚,问题开始浮现:

  • 它变得又重又笨。 查一个具体配方越来越慢,甜点区的改动会不会影响前菜区,也越来越难判断。在软件世界里,这叫 紧耦合(tight coupling)
  • 一个小错误就可能毁掉整本书。 某个不重要功能里一个小小的拼写错误,理论上就可能让整套系统崩掉。一个很次要的 Bug,也可能把整个网站拖下水。
  • 你很难请来专业厨师。 就算你想招一个专做甜点的人,他也必须先搞懂这本巨大无比的总菜谱。新开发者上手会变得非常痛苦。

最关键的是,也正是这一点在那个夜晚干掉了我们:你无法只扩展单体里的某一部分,而不把整个系统一起扩。

我们的店铺首页迎来了成千上万访客(“主菜”异常火爆),但商家后台(“前菜”)的使用频率其实没那么高。可它们都塞在同一个单体里,所以服务器资源必须同时服务于所有模块。主菜区域的巨大流量,把厨房里其他部分的资源全部挤占掉了,最终导致整个系统一起垮掉。

运行在那台小小单机上的单体应用,构成了一场完美风暴:软件层面只有一个单点,硬件层面也只有一个单点。它本就是一颗定时炸弹,而在某个星期二凌晨 3:14,它终于炸了。

第 1 章:关键结论

  • 你的第一台服务器一定会出问题。 不是会不会的问题,而是什么时候的问题。目标不是永不失败,而是失败后能快速恢复,并从中学到东西。
  • 先掌握基本功。 在学习复杂架构之前,先真正理解服务器是什么。用 CPU(厨师速度)RAM(操作台空间)Disk(储藏间) 这套模型思考。
  • 学会最基本的诊断工具。 看不见,就修不好。学会使用 sshhtop(或 top)。它们是系统管理员的听诊器。
  • 从单体起步是特性,不是缺陷。 在最初阶段,开发速度压倒一切。不要过早为初始产品做过度架构设计。
  • 要知道早期选择是有保质期的。 能把你带到第一个 1 万用户的架构,未必能把你带到 10 万用户。做好持续演进的准备。

第 2 章:WhatsApp PDF 问题(起点)

第一部分:这个点子,以及那块“滑板”

每个创业故事都始于一个问题。我们的故事,始于一份模糊不清的 PDF,以及一场席卷全国的封锁。

那是 2020 年。整个世界像是被按下了暂停键。在印度,曾经充满喇叭声、叫卖声和人群噪音的街道突然沉寂。日常生活原有的节奏被打断。对数以百万计的小商家来说,无论是街角 kirana 杂货店老板、社区蔬菜摊贩,还是卖手工纱丽的阿姨,这都是一场灾难。卷帘门拉下,顾客关在家里,生计一点点蒸发。

唯一的生命线变成了互联网,更准确地说,是 WhatsApp。它成了新的集市、新的门店、新的讨价还价柜台。但它同时也是一个混乱、低效、令人无比沮丧的集市。

这时,我的联合创始人 Suumit 出场了。他当时并不是在创办一家公司,他只是想帮邻居家的一家杂货店撑过去。他一边着迷,一边又无比焦虑地看着他们把整套生意塞进一连串混乱的 WhatsApp 聊天里。

整个流程,几乎就是低效的教科书。

第一步:商品目录。 店主会给每个潜在顾客发一份多页 PDF。排版非常糟糕,分辨率很低,多半是用 Microsoft Word 做完又保存错了格式。商品名对不齐,价格看不清,也完全不能搜索。你想确认有没有自己常买的饼干品牌,只能手动在几页模糊图片里来回翻。

第二步:下单。 顾客眯着眼放大缩小看半天后,还得自己把整份订单敲成一长串容易出错的文字消息:“Ek packet Maggi, do kilo atta, aadha kilo cheeni, woh blue wala Lays ka packet…” (一包 Maggi、两公斤面粉、半公斤糖、那个蓝色包装的 Lays……)

第三步:确认。 店主同时得处理几十个这样的聊天窗口,于是还要一项项手工确认:“不好意思 madam,蓝色 Lays 没货了,只有绿色的。”于是双方又开始新一轮来回沟通。

第四步:付款。 最后,订单确认后,店主会发 UPI ID 或二维码。顾客用 GPay 或 PhonePe 付款,然后做一件最关键的事:把支付成功截图发过去当凭证。店主的手机相册里堆满了几千张付款截图,却几乎没有办法把“谁付了什么钱”对起来。

这简直像噩梦。是绝望之下拼出来的一套数字化“土办法(jugaad)”。

一天晚上,我的手机响了,是 Suumit。我隔着听筒都能感觉到他整个人都在发电。他不是普通地说话,而像是被一个念头击中后正在震动。

“Subhash,yaar,这太离谱了。”他连招呼都没打,直接冲进主题,“我看着这位老板硬是在 WhatsApp 上跑完整家店,简直一团糟。订单丢了、付款对不上……整个流程就是坏的。我们得做点什么。”

他把我刚才描述的整幅画面复述给我:PDF、截图、聊天、混乱。

“我们得给他们做个简单的东西,”他说,声音越来越急,“一个 App,让他们能把商品列出来,顾客直接下单。就这么简单。不要 PDF,不要长聊天。只要一个链接。他们就能有自己的线上 dukaan(店)。”

那个词停在空气里:Dukaan。店。

他说得没错。问题不是没有技术,而是没有简单。那些卖家并不需要 Amazon 或 Shopify 那样复杂的平台。他们没时间,也没技术能力去搞那一套。他们需要的是像 WhatsApp 一样容易上手、但专门为交易设计的工具。

那通电话,就是火花。但想法终究只是想法。要把它变成现实,就得动手。而在创业世界里,必须 。我们没有奢侈到花 6 个月做一个完美产品。我们需要在几天内知道,这个想法值不值得继续。

这就引出了每个创业者和技术人都必须理解的一个重要概念:MVP

技术深入:最小可行产品(MVP)

MVP,也就是 Minimum Viable Product,在技术圈经常被提起。很多人以为它是“最终产品的简化版、粗糙版、Bug 多一点的版本”。这是错的。

MVP 不是产品。它是一个实验。

它的首要目标不是赚钱,也不是吸引几百万用户。它的首要目标是 学习。它是一个科学实验工具,用尽可能小的成本,去验证你最关键的假设。我们的假设是:“如果给小商家一个极其简单的线上开店工具,他们会用吗?”

为了验证这个问题,我们不需要一个完美产品。我们只需要一个足以回答这个问题的最简单东西。这就是 MVP 思维。

你可以这样理解:如果你的目标是解决“交通出行”问题,你不会一开始就造汽车。汽车太复杂了,有发动机、轮子、座椅、底盘、电路。造一辆车太久了。等你终于造完,很可能才发现用户其实更想要的是摩托车。

MVP 的思路,是先造一块 滑板(skateboard)。它很基础,很简单,但它解决了核心问题:能把人从 A 点带到 B 点。它让你验证核心假设:人们到底想不想用轮子移动?

一旦确认这一点,你就能根据反馈继续迭代:滑板车、自行车、摩托车,最后才是汽车。每一步都既在学习,也在创造价值。

所以,对 Dukaan 来说,我们要先定义自己的“滑板”。为了验证想法,我们到底必须做到什么最小集合?我们把所有花哨功能统统扔掉。不做支付网关集成、不做配送追踪、不做客户账号、不做主题皮肤、不做分析报表。我们把产品剥到最本质的那一层。

我们把它定义为 Dukaan 的核心闭环(Core Loop)

  • 创建店铺: 一个页面,让用户输入手机号,收到 OTP(一次性验证码)完成验证,再给店铺起个名字。就这样。店开好了。不需要邮箱,不需要密码,不需要复杂表单。
  • 添加商品: 一个尽可能简单的商品表单。商品名,一个价格字段,再加一个按钮,从手机相册上传一张图片。不做分类,不做规格(尺寸、颜色等),不做库存数量。只保留最基本的建目录能力。
  • 分享链接: 加了几个商品之后,系统生成一个唯一可分享链接,比如 mydukaan.io/mystore。商家可以直接复制,贴到自己的 WhatsApp 聊天里。

这就够了。这就是我们的滑板。

它不是“平台”,也不是“电商套件”。它只是一个用干净、适合手机浏览的网页,替换丑陋 PDF 的简单工具。它是解决核心问题所需的绝对最小集合。

计划敲定后,我们给自己下了一个挑战:不要长周期开发,不要冗长规划。这个 MVP,要在 一个周末内 做完并上线。

48 小时黑客松,正式开始。 倒计时已经启动。接下来,我们得做第一个关键技术决策:用什么工具来造这块滑板?

第二部分:选择工具

48 小时倒计时已经开始。我们明确了做什么,也就是那个 MVP,那块数字世界里的滑板。接下来要回答的是“怎么做”。我们该选什么工具?

在软件世界里,你的一整套工具组合,叫作 tech stack(技术栈)。你可以把它想象成盖房子时要先定材料。用砖、木头还是钢筋?打什么地基?靠哪些工具把一切拼起来?这些选择会决定你建得有多快、房子有多稳,以及以后扩一间新房有多容易。

对一个周末黑客松来说,选型只遵循一条最高原则:。我们不需要最可扩展、最前沿、最“时髦”的技术栈。我们需要的是,能让我们用最短时间从 0 走到可用产品的那一套。

这意味着:选熟悉的、可靠的、还能替我们干大量脏活累活的工具。

技术深入:语言与框架

我们第一个,也是最重要的决定,是编程语言和框架。

为什么是 Python?

编程语言就是你跟计算机沟通的词汇体系。我们选了 Python。为什么?因为 Python 以语法简单、干净著称,读起来几乎像自然语言。你在和时间赛跑时,最不想做的事,就是跟自己的工具打架,为了一个复杂规则或者漏掉的分号折腾半天。Python 会尽量给你让路,让你把注意力放在真正要解决的问题上。它还有庞大的社区,以及几乎你能想到的任何场景都有现成库可用。

为什么是 Django:自带“电池”的框架

语言只是词汇,框架才像一整本使用说明书。它提供结构、骨架和一组现成组件,让你不必从零开始。

想象你要盖房子。你可以自己去砍树、锯木、打钉子;也可以直接买一套预制房屋套件,墙、窗、门都已经做完,你只需要拼装并加上一些自己的细节。

这就是 Django。它就是 Web 应用世界里的预制房屋套件。它著名的理念叫 “batteries-included”,也就是“自带电池”。意思是:你一开箱,几乎常用东西都有。对我们这个 48 小时 MVP 来说,其中两块“电池”堪称神助攻:

  • Django Admin 后台: 这是 Django 的杀手级功能。只需少量代码,Django 就能自动生成一个完整、安全、且看起来相当专业的管理后台。这是一个只有我们创始人能登录的私有网站,我们可以在上面看应用里的所有数据。新用户创建店铺,我们能立刻在后台看到;也能查看商品、按需编辑、排查问题。如果从零做这样一个后台,至少要花一天时间。而 Django 只用了大概 15 分钟就免费送给了我们。它成了我们上线时的任务控制中心。
  • ORM(Object-Relational Mapper,对象关系映射): 这个名字听起来很复杂,概念其实很简单。正常情况下,从数据库取数据要写一种专门语言,叫 SQL(Structured Query Language)。它会长这样:SELECT * FROM products WHERE store_id = 123; 很强大,但容易打错,也和你正在写的 Python 风格完全不同。Django 的 ORM 就像一个翻译器。它让我们用简单的 Python 代码跟数据库交互。上面的命令,在 Django 里可能写成 Product.objects.filter(store_id=123)。这不光更容易写、读起来也更顺,而且还能帮你规避一大类叫 SQL 注入的安全问题。它让我们的代码更干净,也让开发速度快了很多。

技术深入:我们考虑过的替代方案

当然,Django 不是唯一选项。技术世界里,任何事情通常都有十几种做法。关键在于:此时此刻,哪个工具最适合眼前这个问题。

  • Node.js + Express: 这是很流行的替代方案。Node.js 允许你用 JavaScript 写服务端代码,也就是浏览器里本来就在跑的那门语言。对很多团队来说,这有巨大优势。Express 这个框架极简又灵活。但对我们来说,这恰恰是它的短板。Express 更像一盒高质量乐高,而不是预制房。它给你基础积木,但很多东西都得自己搭。对 48 小时交付来说,我们不想要那么多自由,我们要的是 Django 这种自带结构和现成组件的体系。
  • Ruby on Rails: 这是更接近 Django 的对手。Rails 的理念和 Django 非常像,强调“约定优于配置”,也就是帮你先做好很多默认决策,从而加快开发。说实话,Rails 也是个很棒的选择。最终决定更多是个人熟悉度问题。我更熟 Python 和 Django,而在这种拼速度的局里,你永远押注自己最顺手的武器。

技术深入:数据库

框架选完了,我们还要决定把所有信息永久存在哪里:店铺名、商品详情、价格等等。我们需要一个 数据库。如果说框架是房屋套件,那数据库就是地基。它必须稳、可靠,而且有组织。

为什么选关系型数据库?

我们选择了关系型数据库。思路很简单:数据按表存储,就像功能极其强大的 Excel。你有一张 stores 表放店铺信息;一张 products 表放商品信息;更关键的是,你可以在它们之间定义关系。比如,每件商品必须属于某个店铺。

这种结构对电商来说太合适了。你的数据有明确关系和规则。你不会希望商品脱离店铺独自漂着,也不希望出现没有客户的订单。关系型数据库会强制这些结构存在,让数据保持整洁和一致。

为什么是 PostgreSQL?

关系型数据库有很多,比如 MySQL、Microsoft SQL Server 等。我们选了 PostgreSQL(通常简称 Postgres)。

为什么?对 MVP 来说,Postgres 和它的老对手 MySQL 都很优秀。但我们更偏向 Postgres,有几个原因。它在开发者社区里一直有“结实、可靠、标准兼容性好”的口碑,是货真价实的重型工具。更重要的是,我知道它有一些高级能力,未来可能会派上大用场。其中一个叫 LISTEN/NOTIFY 的特性,后来成了我们在第 7 章里构建实时缓存失效系统的秘密武器。MVP 阶段并不需要它,但从第一天开始就选择一个足够强的地基,即便暂时还没用上所有能力,未来也可能带来巨大回报。

于是,蓝图就定了。我们的技术栈明确如下:

  • 语言: Python
  • 框架: Django
  • 数据库: PostgreSQL

材料都备齐了。接下来,就是把地基打下去,把第一面墙立起来。是时候搭服务器了。

第三部分:打地基

技术栈确定之后,纸上谈兵阶段结束。要做真东西了。我们得找个地方,让代码能活在互联网上。我们需要服务器。

如果代码是蓝图,技术栈是建材,那么服务器就是那块真正的地。它是现实中(或者说,在我们的场景里,是虚拟世界里)让你浇筑地基、把建筑立起来并展示给全世界看的空间。

创业初期选服务器供应商,本质上是在成本、简单程度和能力之间找平衡。我们不需要 AWS 或 Google Cloud 那种几乎无限的能力。对当时来说,那像是为了盖一栋房子,先去买下一整片工业园区。太复杂,也太贵。我们要的是一块简单、便宜的地。

于是,我们选择了 DigitalOcean

技术深入:搭建你的第一台服务器

DigitalOcean 之所以深受开发者欢迎,原因只有一个:它让你拿到一台服务器变得异常简单。他们给服务器起了一个很友好的名字,叫 “Droplets(水滴)”,一下子就把传统服务器托管那种吓人的气氛削弱了。

DigitalOcean Droplet 实操指南

我们的第一台服务器,也就是后来那台臭名昭著、引发无数 drama 的 512MB 机器,从开始到拿到手,不到 5 分钟。流程简单到惊人:

  • 注册账号: 标准的注册流程。
  • 创建 Droplet: 真正的关键步骤就在这里。你会看到一个干净、简洁的控制台。
  • 选择 Image(镜像): “镜像”就是提前打包好的操作系统和软件模板。我们选了标准 Ubuntu 镜像。Ubuntu 是 Linux 的一个发行版,而 Linux 是 Web 服务器领域事实上的主流操作系统。它免费、安全,而且社区极其庞大。我们还选了最新的 LTS(Long-Term Support)版本,能保证未来好几年的安全更新。
  • 选择套餐: 这一步决定服务器大小,也就是你这块“地”有多大。我们一路拉到最便宜的选项:512MB RAM、1 个 vCPU、20GB SSD。每月只要 5 美元。对于一个零用户、零收入的项目来说,这看上去是最负责任的决定。地虽然小,但至少是我们的。
  • 选择数据中心区域: 也就是服务器在世界上的物理位置。我们选了 班加罗尔。为什么?因为我们知道第一批用户会在印度。让服务器尽量靠近他们,就能通过减少延迟让应用感觉更快。
  • 点击 “Create Droplet”。

就这样。大概等了 60 秒,DigitalOcean 就把服务器准备好了。我们正式拥有了一个公网 IP 地址,也就是这台服务器在全球互联网中的唯一地址。我们的第一块小地盘,就这么到手了。

SSH:通往服务器机房的钥匙

地有了,接下来要进去施工。你不会用浏览器控制服务器,你需要一个专门工具,叫 SSH(Secure Shell)

把服务器想象成一栋远方的、没有窗户、戒备森严的大楼。SSH 就是你那把神奇的加密钥匙。你会在终端里执行诸如 ssh root@123.45.67.89 这样的命令,打开门,走进去。进门之后,你得到的是一个命令行界面:纯文本方式直接给服务器下命令,安装软件、创建文件、运行应用。

这就是我们的工作台。一块黑屏,一个闪烁的光标,安全地连接着班加罗尔的数据中心里的那台新服务器。现在,该装基础设施最后几块关键拼图了:那些真正把网站展示给外部世界的软件。

Nginx + Gunicorn:服务员与后厨班组长

你不能指望只运行 Django 应用本身,就能把网站正常对外服务。Django 是用来 构建 业务逻辑的框架,它像是菜谱加主厨。但它不适合直接面对来自互联网的成千上万条原始请求。那就像让一大群饿着肚子的顾客直接冲进后厨,对着厨师大喊自己要点什么,现场只会变成灾难。

你需要一套流量管理系统。对 Python Web 应用来说,这通常是一个两段式组合:Web Server(Web 服务器)Application Server(应用服务器)

  • Web 服务器(Nginx):服务员。 我们的 Web 服务器是 Nginx(发音“Engine-X”)。它就是站在餐厅前台、动作麻利的服务员。它是每位访客的第一接触点,非常擅长同时处理成千上万条连接,并执行简单、快速的任务。它主要做两件事:
    • 提供静态文件: 如果用户请求的是图片、CSS 文件或 JavaScript 文件,Nginx 会直接从储藏间(磁盘)里把它们拿出来给用户,不需要麻烦厨师。这是巨大的性能收益。
    • 充当反向代理: 对那些真正需要“做菜”的请求(比如打开某个店铺的商品页),Nginx 自己不处理,而是把订单整理好,送到后厨门口,交给应用服务器。
  • 应用服务器(Gunicorn):后厨经理。 我们的应用服务器是 Gunicorn。它像后厨经理,接到 Nginx 转过来的请求后,把它翻译成 Django 能理解的格式。它还会管理多个“帮厨”(worker 进程),从而同时处理多笔订单。它是外部世界(Nginx 管)和我们应用代码(Django 写的)之间的关键连接件。

整个链路简单却强大:用户请求从互联网进入,首先打到 Nginx。Nginx 要么直接把静态文件发出去,要么把动态请求交给 Gunicorn。Gunicorn 再运行 Django 代码,处理请求、生成 HTML,然后把响应交还给 Nginx,最后由 Nginx 返回给用户。

搭起这套组合,需要在服务器上安装 Nginx 和 Gunicorn,再写几份简单的配置文件告诉它们如何协同工作。最后这一块拼上之后,我们的地基就打好了,主体结构也立起来了。我们把域名 mydukaan.io 指向了服务器的 IP。

我手指有点发抖,在浏览器里输入地址,按下回车。

成功了。一个来自这台 5 美元小服务器的简单 “Hello, World” 页面出现在眼前。

我们的滑板造出来了。48 小时所剩无几。接下来,要看有没有人愿意骑上它。

第 2 章:关键结论

  • MVP 是用来验证核心假设的实验,不是最终产品的缩小版。 它的目标是最大化学习,而不是堆功能。写一行代码前,先定义你的“滑板”是什么。
  • 最初的技术栈,优先考虑速度和熟悉度。 在早期,“上市速度”是最重要的指标。优先选 Django、Rails 这类自带大量能力的框架。
  • 选一个扎实的数据库,是长期主义下注。 从第一天开始使用 PostgreSQL 这样强大、特性丰富的数据库,未来能少踩很多坑,也更容易解锁高级能力,即使你一开始暂时用不到。
  • 基础设施从小、从简单开始。 每月 5 美元的服务器,足够撑起最开始的几千个用户。在还没有被验证的需求之前,不要在云服务上过度复杂化或过度花钱。
  • 要理解 Web 服务器(Nginx)和应用服务器(Gunicorn)的分工。 “服务员 + 后厨经理” 这种基本模式,是现代 Web 应用对外服务的核心骨架。

第 3 章:伟大的分家:拆分应用与数据库

第一部分:风暴之后的早晨

上线之后,一切像一阵旋风。48 小时黑客松结束后,我们把刚做好的 MVP 链接发进了几个小商家 WhatsApp 群。我们并不知道会发生什么。也许就是零星几个注册,一些礼貌性的反馈,然后慢慢消失在无人问津里。

结果迎来的却是一场爆炸。

事实证明,我们的假设是对的。“WhatsApp PDF” 这个问题的痛点强烈到商家们急切地想要解决方案。我们的工具朴素、没有花哨功能,却正好切中他们的需求。链接一个群接一个群地被转发。我们从十几个用户,很快涨到几百,再到几千,全都发生在几天之内。每个新店主都会上传商品,再把自己的 mydukaan.io 链接分享给顾客,而这些顾客里又有很多本身也是小商家。增长开始自传播。

那是世界上最让人兴奋的感觉。每刷新一次 Django Admin,都能看到来自全国各地的新店铺不断冒出来。我们在实时看着自己的想法、自己的“滑板”变成活生生的现实。但在兴奋之下,一种安静而持续的恐惧也开始慢慢滋生。

应用变慢了。

原本秒开的页面开始要等几秒钟,管理后台有时会卡住。用户也陆续发来第一批消息:“网站打不开”“服务器是不是挂了?” 我们像一群慌乱的消防员一样,每隔几小时就重启一次服务器。这是个原始粗暴的修法,而且越来越不管用。那间小厨房,正在被我们的成功活活压垮。

然后,事情终于发生了。那通凌晨 3 点的电话。那次崩溃。也就是本书开头的故事。

那个夜晚,是我们最初那段天真增长期的高潮。那台 5 美元 DigitalOcean Droplet,那个曾经撑起我们整个世界的单机服务器,终于彻底扛不住了。它就是我们第一版架构不可避免、痛苦又真实的死亡时刻。

第二天早上,辗转睡了几个小时之后,我和 Suumit 通了电话。眼前的火情算是暂时压住了,我们又一次把服务器重启了起来。但我们都知道,这只是暂缓。它还会再挂,而且大概率就是几个小时之后。

“我们不能老这么重启下去,Subhash。”Suumit 的声音很疲惫,“我们得真正修这个问题。根因到底是什么?”

过去几个小时,我一直在翻服务器日志,盯着 htop 看到眼睛发疼。答案开始变得越来越清楚。

“数据库,”我说,“数据库正在把其他一切活活拖死。”

识别瓶颈:厨房里的内战

我们回到“只有一个厨师的厨房”这个比喻。服务器是一间很小的屋子,里面挤着厨师(CPU)、操作台(RAM)和储藏间(磁盘)。

崩溃之后的分析,揭示了一个关键细节。厨师花掉的大部分时间,并不是在真正做菜(执行 Python 代码),而是在来回跑储藏间,疯狂找东西、塞东西(读写数据库)。

数据库操作太重了,重到它把应用其余部分需要的资源都抢走了。服务员(Nginx)站在门口接新订单,但厨师忙着处理一团混乱的储藏间,根本顾不上看它们。这就是为什么网站会越来越慢,最后彻底失去响应。

要解决这个问题,我们必须理解系统设计里的一个根本概念:应用里的不同部分,做的是完全不同类型的工作。

技术深入:应用负载 vs. 数据库负载

不是所有工作都一样。一个 Web 应用主要会做两种完全不同的任务:

  • 应用工作(厨房): 这是“思考型”工作。由 Django 代码完成,由 CPU 执行。这类工作是 CPU-bound(CPU 密集型)。它负责跑逻辑:决定展示哪些商品、计算订单总价、检查用户是否登录。像厨师按食谱操作,切、拌、调味、试味。这类工作需要更快的厨师(更好的 CPU)以及足够大的操作台(RAM)。
  • 数据库工作(储藏间与图书馆): 这是“取放型”工作。数据库的主要任务,是把数据从磁盘读出来、再写回去。这类工作是 I/O-bound(输入输出密集型)。重点不在思考,而在于搬运信息。像图书管理员跑去书架上找一本书,或者储藏管理员在货架间整理东西。这类工作依赖更快的存储(更快的 SSD)和更好的组织方式。

我们的真正问题在于:我们逼着这个原本就很忙的厨师,还要兼职当全职图书管理员。他得在吵闹的图书馆里做精致料理。不断来回跑书架(磁盘 I/O),导致他根本做不好自己的本职:真正地“做菜”(执行代码)。结果是两边都做不好。

概念上的解决方案很简单,但在实际操作中极其吓人。

“我们得把它们拆开。”我对 Suumit 说,“数据库得有自己的房间。一个真正的图书馆,一个专职管理员。厨师也得有自己的厨房。”

这意味着从一台服务器,变成两台。这是我们第一次真正意义上的架构升级。

  • 服务器 1:应用服务器。 这台服务器专门针对 CPU 密集型任务优化,运行 Nginx、Gunicorn 和 Django 代码。它的唯一工作就是“思考”。
  • 服务器 2:数据库服务器。 这台服务器专门针对 I/O 密集型任务优化,只运行 PostgreSQL。它的唯一工作就是“记住”。

计划是有了。伟大的分家。听起来完全合理,也确实正确。但这也意味着,我们得对一个正在运行的活体系统做“开胸手术”。我们必须把整个数据库——每个用户、每件商品、公司所有的数据——从一台机器,小心翼翼地搬到另一台机器上。

一旦失手,我们可能会把数据搞坏。可能丢订单。可能摧毁几千个刚开始信任我们的商家的信心。赌注高得不能再高。

第二部分:迁移作战手册

决定已经下了。我们要执行这场伟大的分家。那感觉像站在悬崖边,明知必须跳下去,唯一的问题是:怎么在下坠过程中把降落伞缝出来。

我们花了几个小时,把每一步都画了出来,像飞行前检查清单一样写下。做这种高风险操作时,绝不能即兴发挥。你只能照计划来。一个失误就可能致命。

蓝图:改造前与改造后

我们的目标,是把基础设施从一台超负荷的一体机,改造成两台分工明确的专用机器。

  • 改造前: 一台服务器(例如 104.248.62.77),同时跑 Nginx、Gunicorn、Django 和 PostgreSQL。
  • 改造后:
    • 应用服务器(IP:104.248.62.77):运行 Nginx、Gunicorn、Django。
    • 数据库服务器(IP:142.93.218.155):只运行 PostgreSQL。

应用服务器不再连接本机的 localhost 数据库,而是必须通过网络,去访问新的专用数据库服务器。

下面就是我们当时照着执行的完整手册。如果你未来也遇到这种情况,这就是迁移在线数据库时那些让人胆战心惊、但又无法回避的步骤。

步骤 1:准备新家

没把新房子盖好,就不能搬家。第一步,是先给数据库建一台新的专属服务器。

我们回到 DigitalOcean 控制台,新开了一台 Droplet。这次,套餐和之前略有不同。我们不再选通用型,而是选了一种更偏 Storage-Optimized(存储优化) 的配置。它用更快的 NVMe SSD,并且相对于 CPU,给了更多 RAM。这种配置天生适合数据库这种 I/O 密集型工作。它就是我们新的、专门为数据打造的现代图书馆。

服务器启动后,我通过 SSH 登录进去。除了 PostgreSQL,我什么都没装。没有 Nginx,没有 Python,没有应用代码。它的使命只有一个:成为我们数据最好的家。我还把防火墙设置成只允许来自应用服务器那个特定 IP 的连接。互联网上其他任何人,都不该有机会和数据库说上一句话。就像只给应用服务器配了一把图书馆私钥。

步骤 2:备份(pg_dump

这是整个过程最关键的一步。你怎么复制一个“活着”的数据库?你当然不能直接拷文件,因为数据库可能正在写入,那样得到的副本很容易损坏。

你需要先创建一个完美快照。对 PostgreSQL 来说,完成这个动作的神兵利器就是 pg_dump

pg_dump 是一个命令行工具。它会读取整个数据库——每一张表、所有数据、每个关系——然后输出成一个巨大的 .sql 文本文件。这个文件里包含了从零完整重建数据库所需的全部 SQL 命令。

你可以把它想象成:pg_dump 是一个神奇的书记员,走进图书馆,把每本书都读了一遍,然后写出一本总手册,名字叫《如何重建这整座图书馆》。

于是,我仍然在那台旧的一体机服务器上,执行了这条命令:

pg_dump -U postgres dukaan_prod > dukaan_backup.sql

我看着服务器 CPU 飙升。它正在努力生成这份快照。几分钟后,完成了。现在我们手上有了一个文件:dukaan_backup.sql。这里面装着公司整个灵魂。

步骤 3:传输与恢复

蓝图有了,但它还在旧服务器上。我们得把这个备份文件安全地从旧服务器传到新的空白数据库服务器上。我们用的是另一个命令行工具:scp(Secure Copy)。

scp dukaan_backup.sql root@142.93.218.155:/root/

这条命令会通过网络安全地复制备份文件。现在,新图书馆里已经有了那本“重建说明书”。

接下来就是恢复。我 SSH 进新数据库服务器,先创建一个空数据库壳 dukaan_prod,然后执行恢复命令:

psql -U postgres -d dukaan_prod < dukaan_backup.sql

这条命令会做 pg_dump 的反向过程。它逐行读取那份大说明书,创建表、插入数据、重建关系。我盯着屏幕,祈祷别蹦出任何错误。几分钟后,它跑完了。

现在,我们有了一份完美的数据库克隆,运行在一台全新的、更强大且彻底隔离的服务器上。旧数据库还在线,还在服务用户;但它的替身已经准备就绪。

步骤 4:切换

这一刻,才是真正的生死关头。整个操作里最危险的部分。我们必须让线上应用停止使用旧数据库,转而改用新数据库。这意味着需要几分钟停机时间。

  • 打开维护模式: 第一步,必须阻止新的写入。我们快速在网站上挂起维护页。访问 mydukaan.io 的用户会看到一条简单提示:“Dukaan 正在进行一次快速升级,5 分钟后恢复!”
  • 最终同步: 我们知道,在第一次备份和现在之间,线上又产生了少量新数据。新开了几家店,改了几个商品。所以我们又重复了一次步骤 2 和 3。但这次因为网站处于维护模式,速度快得多。这能确保克隆副本和线上 100% 一致。
  • 修改连接字符串: 这一步才是真正的“开胸手术”。在 Django 的配置文件深处,有一行告诉应用去哪里找数据库。原来是 HOST: 'localhost'。我们把它改成了新数据库服务器的 IP:HOST: '142.93.218.155'
  • 重启,然后祈祷: 保存新配置后,我执行应用服务器重启命令:sudo systemctl restart gunicorn。那几秒钟里,我的心几乎提到了嗓子眼。应用正在启动,而它将第一次尝试通过网络去连接数据库。
  • 疯狂测试: 服务器一恢复,Suumit 和我立刻开始四处点。能登录吗?能。店铺能打开吗?能。能新增商品吗?能!成功了。连接打通了。
  • 关闭维护模式: 我们长出一口气,把网站从维护模式放了出来。

整个停机时间,大概只有 3 分钟。

伟大的分家,完成了。应用在自己的厨房里运行,数据安全地待在自己的图书馆里。用户立刻开始反馈,说网站明显“更利索了”。我们撑过了第一次真正的架构升级。厨房更干净了,图书馆更有秩序了,而且两者终于能互不打架地各司其职。

第三部分:新的瓶颈

几周以来,我们第一次终于能喘口气了。

这次分家非常成功。网站稳定了、快了,也能够承受稳定增长的新用户流量,不会每隔几个小时就崩一次。厨房和图书馆终于各自有了独立空间,整个系统运转得比以往都顺畅。Suumit 和我还庆祝了一下。我们第一次真正直面扩容危机,并且硬生生挺了过来。我们为自己争取到了时间。

可在创业世界里,你买到的永远只是时间。你解决的每一个瓶颈,都会暴露出下一个正在前方等你的瓶颈。扩展一家技术公司,有点像打地鼠:你刚按下一个,另一个又冒头了。

这次的新问题很隐蔽。不是服务器起火,也不是灾难式崩溃,而是一种安静、缓慢、逐步侵蚀的“变慢”。即使我们现在已经有了两台强大、专职化的服务器,某些页面看起来还是有一点……迟钝。我们解决了资源争用,却同时给自己造出了一个全新的、更加高级的问题:网络延迟(network latency)

技术深入:一次网络调用的成本

当应用和数据库还在同一台机器上(localhost)时,它们之间的通信几乎是瞬时的。就像厨师在厨房里转个身,伸手就能拿到背后架子上的食材一样。“往返时间”几乎可以忽略。

但现在,厨房(应用服务器)和图书馆(数据库服务器)已经搬进了两栋不同的楼。虽然它们都在班加罗尔同一个数据中心,像是两栋挨着的楼,中间用一条超快的私有光纤相连,但不管这条链路有多快,厨师依然必须:

  • 停下手头工作。
  • 走出厨房门。
  • 穿过那条小小的“街”。
  • 找到图书管理员并提出请求。
  • 等管理员把书(数据)找出来。
  • 再走回厨房。

这个完整过程,就是一次 network call(网络调用)。花掉的时间,就叫 latency(延迟)

单次请求的延迟可能很小,也许只有 1 到 2 ms,你几乎感觉不到。但真正的陷阱在于:一个普通网页通常不是只查一次数据库。比如要组装一个店铺页面,我们的代码可能要:

  • 取店铺详情。(去图书馆 1 次)
  • 取店铺所有分类。(1 次)
  • 取第一个分类下的所有商品。(1 次)
  • 取第二个分类下的所有商品。(1 次)
  • ……

一个页面请求,轻轻松松就会产生 10 次、20 次,甚至 50 次数据库往返。在分家之前,这 50 次调用几乎不花钱;现在,它们每一次都是真实成本。

50 次往返 * 每次 2ms 延迟 = 100ms

也就是说,即便应用服务器和数据库服务器本身都各自执行得很快,仅仅因为网络往返,我们也平白增加了 0.1 秒页面加载时间。这就是新瓶颈。它不是靠换更贵的硬件就能消掉的。我们必须更聪明。我们得让代码少跟数据库“唠嗑”。

对抗延迟:减少往返,让每次更值

既然每趟去图书馆都很贵,那最合理的办法就是少去。与其为每样东西来回跑一次,不如一次带着完整购物清单去拿齐。在 Django 世界里,这意味着:我们必须激进地优化数据库查询。

  • N+1 查询问题: 我们发现自己踩中了 Web 开发里最常见的性能杀手:N+1 Query。比如你想拿到 10 个店铺和每个店铺的第一个商品。最天真的写法是:
    • 先查 1 次,拿到这 10 个店铺。
    • 再循环这 10 个店铺,对每个店铺额外再查一次商品。也就是再发起 N(这里是 10) 次查询。
      这样总共就要跑 11 趟图书馆。非常低效。
  • 解决方案(select_relatedprefetch_related): Django 自带解决工具。借助 prefetch_related,我们可以告诉 Django:“你去拿这 10 个店铺的时候,顺手把它们的商品也一起拿回来。” 于是 Django 会聪明地把原本 11 次查询压缩成 2 次:一次拿到 10 个店铺,一次拿到这些店铺对应的商品,然后在应用层帮我们拼好。这就是我们的“购物清单”。我们把这类优化逐步打进代码库后,网络调用数量大幅下降,应用体感速度明显更快。
  • 连接池(PgBouncer): 我们还意识到,若每个请求都重新建立数据库连接,本身也很慢。就像厨师每次去图书馆都得先找钥匙、开门、进门、取书、锁门、再回来。光手续就很重。于是我们引入了 PgBouncer。它是一个连接池工具。你可以把它想象成站在厨房和图书馆中间的保安,手里预先拿着一串已经开好的钥匙。应用要连接数据库时,不用自己新建连接,只要向 PgBouncer 要一个现成连接即可,几乎瞬间可用。这样就省掉了每次重新建连的开销,进一步降低了实际延迟。

第四部分:路口抉择,为什么我们坚持用 SQL

伟大的分家已经完成。我们的 PostgreSQL 现在住进了自己的高性能服务器,彻底远离应用逻辑的混战。这是关系型数据库扩展时非常经典的一步。

但到了这里,现代技术世界里一个很自然的问题也会冒出来:为什么还要坚持传统 SQL 数据库? 为什么不用 MongoDB、Cassandra 这类横向扩展能力很强的 NoSQL 数据库?

这是我们深思熟虑后的选择。要理解原因,你必须先理解数据库世界里两种根本不同的哲学。

技术深入:数据库世界的两大星系

把数据库世界想象成两个平行星系:SQL 和 NoSQL。两个星系都很庞大、都很强,但遵循的是不同物理法则。

1. SQL 星系(关系型数据库)

这个星系里住着一些非常著名的星球:PostgreSQL、MySQL、Microsoft SQL Server

  • 类比: SQL 数据库像一本组织极其严谨的 Excel 工作簿,里面有多张相互关联的表格。
  • 核心思想:结构与一致性。 数据存储在表里,列名和数据类型都是预先定义好的,而且约束很严格(比如价格必须是数字,created_at 必须是时间戳)。表和表之间的关系也会被强制约束。你不可能创建一个属于不存在店铺的商品。
  • 超能力:ACID 保证。 这是一组可靠性保证(Atomicity、Consistency、Isolation、Durability),让事务执行极其稳健。放在电商语境里,它意味着:如果顾客下单买了 5 件商品,那么事务要么把 5 件商品全部成功写入、同时更新好库存;要么全部失败,什么都不写。数据 绝不会 停留在半完成、损坏的状态里。
  • 最适合: 任何对数据完整性和一致性零容忍的系统,包括电商、银行、金融、预订系统等。

2. NoSQL 星系(非关系型数据库)

这个星系更加多样,有 MongoDB(文档)、Cassandra(宽列)、Redis(键值)、DynamoDB 等不同类型。

  • 类比: NoSQL 数据库像一个装满灵活 Word 文档或 JSON 文件的文件夹。每个文档都可以有自己的结构。
  • 核心思想:灵活与规模。 它没有严格的预定义模式。一个商品文档可以有 color 字段,另一个可以没有。这让应用结构变化时,不需要频繁迁移数据库模式。很多 NoSQL 系统也从一开始就为水平扩展设计,方便把负载摊到大量便宜服务器上。
  • 超能力:BASE 与横向扩展。 相比严格 ACID,很多 NoSQL 系统提供的是 BASE(Basically Available、Soft state、Eventual consistency)。也就是优先保证可用性,而不是强一致性。它们特别擅长处理超大数据量、极高写入吞吐场景。
  • 最适合: 大数据、社交媒体流、IoT 传感器数据、实时分析,以及数据结构经常变化的业务。

快速对比

特性SQL(PostgreSQL)NoSQL(如 MongoDB)
数据模型结构化(表与行)灵活(文档、键值)
---------
Schema预定义、严格动态、灵活
---------
扩展方式主要靠纵向扩展与读副本主要靠横向扩展
---------
一致性强一致性(ACID)可调,通常偏最终一致性(BASE)
---------
适用场景电商、金融、记录型系统社交、大数据、IoT、分析
---------

为什么我们选择 SQL 这条路

看完这张表,我们在 Dukaan 的选择几乎是不言自明的。

  • 我们的数据高度结构化: 一个订单有客户、一组商品和总金额;一个商品有名称、价格和库存。业务逻辑建立在这些严格关系之上。NoSQL 的灵活性不是我们需要的特性,反而 SQL 的“严格”正是我们想要的。
  • 数据完整性就是一切: 对电商平台来说,订单必须正确处理、库存必须准确更新、支付结果必须无误反映,这些都是用户信任的基石。PostgreSQL 的 ACID 保证,对我们来说是不可谈判的前提。
  • 我们的瓶颈是读,不是写: 正如我们在第 5 章会看到的,我们最大的问题不是“每秒有几百万新商品写入”这种 NoSQL 擅长的写密集型场景,而是“几百万顾客同时查看已有商品”这种读密集型场景。对于这个问题,PostgreSQL 有一个成熟、稳定、被无数实践证明的解法:读副本。

我们没有“大数据问题”。我们面对的是一个标准、典型、严肃的电商交易问题。为了追时髦去选 NoSQL,就像用大锤砸核桃。PostgreSQL 才是那个精确、可靠、强大的工具,完美契合我们当下和未来的需求。它是一个足以承载十亿美元级公司地基的选择。


第 3 章:关键结论

  • 应用服务器与数据库服务器分离,是系统扩展的第一关键步。 这样每个组件都能发挥自己最擅长的能力,而不是互相争资源。
  • 每个解决方案,都会制造下一个问题。 进入分布式系统后,网络延迟会成为新的主要性能瓶颈。
  • 网络调用很贵,能少就少。 对抗延迟最有效的方式,就是写出更聪明的代码,让数据库查询更少、更高效。学会使用 select_relatedprefetch_related
  • 优化连接本身。 使用 PgBouncer 这样的连接池工具,减少频繁建连的开销,让系统在高负载下更稳、更快。

.

第 4 章:交通警察:负载均衡入门

第一部分:起火的厨房

创业公司在解决完第一个重大危机后,往往会进入一个很危险的阶段:短暂的平静。

数据库分家之后,我们的系统运行得非常顺。应用快了,服务器稳了,第一次让我们觉得问题终于被我们甩在身后。我们成功给基础设施做了一场开胸手术,而且病人不但活了下来,还恢复得相当好。

我们的日常,从无休止救火,变成了带着乐观情绪的监控。我们看着实时用户数不断上涨,检查服务器负载图(如今漂亮得很,低且稳定),甚至有点得意。我们做出了真正有用的东西,能跑、能扩、能顶住增长。那一小段辉煌时光里,我们甚至有点觉得自己无敌了。

然后,下一场火就烧起来了。

这次不是缓慢恶化,而是爆炸式增长。苏拉特一位很受欢迎的卖家,主营手工纺织品,把自己的 Dukaan 店铺链接丢进了一个超大的 Facebook 群。与此同时,一家技术博客也写了一篇小文章介绍我们。这两件事叠在一起,形成了一场完美风暴——一波我们从未见过的流量海啸。

我的手机开始不断振动,消息不是来自 Suumit,而是来自监控系统。“App Server CPU usage 高”,一条警报。1 分钟后,又来一条:“CRITICAL: CPU 连续 5 分钟维持 100%。”

我几乎还没来得及打开电脑,Suumit 的短信就弹出来了。很短,很熟悉,带着一种疲惫的无奈:

“Ab kya hua?”(现在又怎么了?)

我 SSH 登录服务器,手指条件反射一样地打开了最信任的诊断工具:htop。我先看数据库服务器。一切正常。CPU 很低,内存稳定。新的图书馆井井有条,取书工作完全扛得住。

然后我看应用服务器。那简直是一片屠宰场。CPU 被钉死在 100%。进程列表里,Gunicorn worker 像疯了一样滚动,拼命尝试处理冲进来的海量请求,但已经明显顶不住。服务器还活着,却没有响应。对外界来说,Dukaan 又一次“挂了”。

识别瓶颈:一个厨师,一千个顾客

我们的厨房着火了。

继续沿用前面的类比:我们已经在厨房旁边建好了一个现代化图书馆,厨师不用再管理储藏间。可现在,成千上万饥饿顾客同时冲进餐厅,对着他大喊订单。

我们的单个厨师(也就是唯一一台应用服务器),即使效率已经比过去高很多,也根本不可能做得过来。一个人同一时间能做多少道菜,是有物理极限的。我们撞上了这个极限。订单小票堆得像山一样漫出来,整个餐厅彻底堵住。

显然,我们需要更多“做菜能力”。但怎么加?这就把我们推向了每一家需要扩展的公司都会面对的根本选择:向上扩,还是向外扩。

技术深入:纵向扩展 vs. 横向扩展

当服务器扛不住负载时,你有两个选择。

1. Vertical Scaling(纵向扩展 / Scaling Up)

这是最直观的思路。如果厨房太慢,那就把你这个还不错的厨师换成世界级超级名厨,做菜速度直接翻倍。

在服务器世界里,这叫 scale up。你点一下 DigitalOcean 的按钮,把当前服务器关掉,然后换成一个大得多、强得多的配置,比如从 2 CPU + 4GB RAM 升到 8 CPU + 16GB RAM。再开机。好了,你的应用现在跑在一头怪兽级机器上。就像把家用轿车换成巨型怪物卡车。

  • 优点: 简单粗暴。你不需要改代码,也不用改架构。直接砸钱,问题暂时就小了。
  • 缺点: 这个策略有三个致命问题。
    • 贵得飞快。 服务器能力翻倍,价格通常不止翻倍,往往是 4 倍甚至 8 倍地涨。
    • 它有天花板。 你不可能无限往上升。总有一天你会用到云厂商最贵、最大的那台机器。再往上怎么办?没有更大的怪兽卡车了。
    • 它仍然是单点故障。 这是最关键的问题。你现在拥有的是一台非常强、也非常贵的单机。一旦它硬件故障,或者因为安全补丁需要重启,整个业务立刻离线。你的整家餐厅,仍然只靠那一个超级厨师。厨师病了,餐厅就关门。

2. Horizontal Scaling(横向扩展 / Scaling Out)

这条路不那么直观,但威力大得多。不是请一个超级厨师,而是保留你原来的好厨师,再请 3 个一模一样的。扩大厨房,让他们并行工作。

在服务器世界里,这叫 scale out。你不再用一台大机器,而是搭一整支由小而相同的服务器组成的舰队。不是一辆怪兽卡车,而是 4 辆普通车。

  • 优点:
    • 成本更可控。 你使用的是便宜、标准化的机器。每多加一台,成本都是小步递增。
    • 理论上几乎没上限。 不够就再加一台。4 台、40 台、400 台,系统就是这么设计的。
    • 容错能力强。 这是横向扩展真正的超能力。如果 4 个厨师中的 1 个请病假回家了(某台服务器挂了),另外 3 个仍在工作。餐厅可能会暂时慢一点,但 不会关门。你消灭了单点故障。
  • 缺点: 它会引入新复杂度。如果你有 4 个一样的厨师,当服务员拿着新订单进来时,到底该把单子分给谁?怎么决定?

对我们来说,答案非常明确。纵向扩展只是临时止痛贴,不是真正长期策略。我们想做的是一家服务数百万人的公司,这就意味着架构必须能跟着业务一起长大。所以我们必须学会向外扩。

我们做了决定。要组建一支应用服务器舰队。但随之而来的,是新架构天然制造出来的问题:要有一个系统,能聪明地把所有进来的请求分给新的“厨师团队”。

我们需要一个交通警察。需要一个负载均衡器。

第二部分:交通警察

决定走横向扩展,是我们的一个重要分水岭。我们开始从“单机思维”进入“舰队思维”。但舰队没有指挥官就毫无意义。我们的“厨师舰队”已经就位,接下来需要一个总服务员,能聪明地把新订单分发出去。

这个总服务员,这个交通警察,这块关键拼图,就是 load balancer(负载均衡器)

技术深入:什么是负载均衡器?

负载均衡器,名字就已经说明了一切。它是一个专门放在应用服务器前面的服务或服务器,它唯一的职责,就是把进来的流量均匀分发到后面的多台应用服务器上。

对于互联网用户来说,负载均衡器本身就是你的网站。他们访问的是这一个统一地址(例如 dukaan.app)。他们并不知道,在这个地址后面,站着的是 2 台、10 台、100 台一模一样、随时准备工作的服务器。负载均衡器就是统一前门,把后厨的复杂性完全挡在门后。

理解负载均衡器最好的比喻,是 繁忙超市里的收银经理

想象一长队顾客(Web 流量)在等着结账。如果只有一个收银员(单台应用服务器),队伍很快就会变得极长。顾客会不耐烦,收银员也会崩溃。

现在,超市经理又开了 4 个新收银台(我们的应用服务器舰队)。但经理不是让顾客自己乱选队伍,而是站在最前面,把每个新顾客引导到当前最合适的柜台。

  • “先生,请去 3 号柜台。”
  • “女士,1 号柜台现在空着。”

这个经理,就是负载均衡器。它的工作,就是避免某一个收银台被压垮,而别的却闲着。它会抹平流量高峰和低谷,让整个系统运转得更高效。负载均衡器还会做健康检查。如果某个收银员突然晕倒了(服务器挂了),经理会立刻停止给这个柜台分配顾客,把流量导去其他还正常工作的柜台。整个超市照常营业。

技术深入:负载均衡算法

超市经理得有一套规则,才能知道下一个顾客该去哪。负载均衡领域里,这些规则就叫 算法(algorithm)。算法很多,但对我们当时来说,只需要理解最常见的两种。

1. Round Robin(轮询):简单,但有点笨

这是最基础的负载均衡算法。顾名思义,请求会按照固定顺序循环分发到服务器上。

  • 第 1 个请求去 Server A。
  • 第 2 个请求去 Server B。
  • 第 3 个请求去 Server C。
  • 第 4 个请求又回到 Server A。
  • 依次循环。

它就像发扑克牌,每个人轮流拿一张。

  • 优点: 配置简单,负载均衡器几乎不用动脑。
  • 缺点(也就是“笨”的地方): 它默认所有请求都一样重,所有服务器能力都一样。但现实不是这样。假设发给 Server B 的第 2 个请求特别复杂,要跑 10 秒;而发给其他服务器的请求只要 1 秒。Round Robin 不管这些。它会按既定顺序,继续把第 5 个请求又发给 Server B,哪怕它还在苦苦处理上一个慢任务,而 Server A 早就闲下来了。这就会导致工作量分配不均。

2. Least Connections(最少连接):更聪明的默认选项

这是更聪明、也更动态的一种算法。负载均衡器会实时跟踪每台应用服务器当前有多少个活动连接。每次新请求到来时,它都把请求发给 当前活动连接数最少 的那台机器。

这就像超市里真正聪明的经理,不是照顺序瞎分,而是扫一眼所有队伍,把你带去 此刻最短 的那一条。

  • 优点: 它天然考虑到了“有些请求比另一些更慢”这件事。正在处理慢请求的服务器会积累更多打开连接,于是负载均衡器会暂时让它喘口气,把新请求发给更空闲的服务器。整体负载会更均匀。
  • 缺点: 相比轮询,它需要负载均衡器做更多工作,得持续跟踪连接数。

对 Dukaan 来说,答案也很清楚。Least Connections 是更稳妥、更智能的默认选择,能更好应对用户流量的不可预测性。

理论我们已经理解了。交通警察该怎么指挥,也有了策略。下一步,就是实际落地。我们要选一个具体工具,并把它配置起来,去管理我们逐渐壮大的服务器舰队。

第三部分:我们的第一位交通警察

理论没问题。我们知道该怎么组建服务器舰队,也知道该怎么给它配一个指挥系统。现在,是时候撸起袖子真正把它搭出来了。

首先要回答的是:负载均衡器到底用什么软件?可选项很多,从价格昂贵的专用硬件设备,到 AWS 的 Elastic Load Balancer 这种云服务都有。但我们当时依然是一家预算敏感的创业公司。我们需要的是:强大、可靠,而且最好免费。

答案其实早就躺在我们的服务器上了。

技术深入:把 Nginx 当成负载均衡器

我们本来就已经在用 Nginx 做 Web 服务器,也就是前面说的那个高效“服务员”,负责提供静态文件并把动态请求转给应用。事实证明,Nginx 同时也是一个世界级负载均衡器。只要在配置文件里多加几行,我们就能把原本的服务员,训练成一个聪明的超市经理。

这真是巨大的红利。我们不需要再学习或安装一套全新的复杂技术。直接用已经熟悉、也已经信任的工具,就能完成这件事。

实现过程 surprisingly 简单。我先在 DigitalOcean 上又拉起了一台完全一样的应用服务器。现在,我们有了两个“厨师”。接着,我 SSH 到第一台服务器,也就是域名 dukaan.app 所指向的那台。这台机器现在要额外承担负载均衡器角色。

我打开 Nginx 配置文件(/etc/nginx/nginx.conf),加上了两小段配置。

我们的 Nginx 负载均衡配置

# Define the group of servers that will handle the application work.
# We'll call this group "app_servers".
 
upstream app_servers {
  # This is the magic rule. It tells Nginx to use the "Least Connections"
  # algorithm we talked about. Send traffic to the server with the fewest connections.
  least_conn;
 
  # List the IP addresses of all the servers in our fleet.
  # These are the private network IPs for speed and security.
  server 10.132.2.31; # Our first application server
  server 10.132.4.55; # Our second application server
 
  # To scale, we just add more lines here!
}
 
server {
  listen 80;
  server_name dukaan.app;
 
  location / {
    # This is the line that does all the work.
    # It tells Nginx to pass every incoming request to the
    # "app_servers" group we defined above.
    proxy_pass http://app_servers;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
  }
}

就这么多。upstream 这段定义了我们的服务器舰队;least_conn; 指明了智能分流策略;proxy_pass 则告诉 Nginx 开始把流量往后面转发。保存文件、重启 Nginx 后,负载均衡器就正式上线了。


新的蓝图

我们的架构再次升级。流量路径变得更复杂,但也更健壮。

  • 用户访问 dukaan.app,请求先打到 Nginx 负载均衡器。
  • 负载均衡器查看两台应用服务器,比较它们谁的活动连接更少。
  • 请求被转发到更空闲的那一台(比如 App Server 2)。
  • App Server 2 运行 Django 代码处理请求,而这需要数据。
  • App Server 2 再去连接那台单独的共享数据库服务器,获取所需信息。
  • 响应再沿原路返回给用户。

如果 App Server 1 崩了,Nginx 负载均衡器的健康检查会检测到,并自动停止向它转发流量。所有请求都会走 App Server 2。网站仍然在线。到这一步,我们终于做出了一个具备容错能力的系统。流量高峰能扛,某台服务器挂掉也能活。我们再次觉得自己“无敌”了。

新问题:图书馆开始拥挤了

这套架构一开始运行得非常漂亮。流量增加时,我们不再惊慌。只需要再开一台应用服务器,把 IP 填进 Nginx 的 upstream,重新加载配置即可。我们能在几分钟内给厨房增加更多“厨师”。

但问题也随之而来:当你同时有 10 个厨师在疯狂做菜,会发生什么?

他们全都要食材。

他们全都在冲向同一座图书馆,对着同一个管理员大喊需求。

我们的瓶颈只是挪了地方而已。它不再是单台应用服务器的 CPU。这个问题我们已经通过横向扩展解决了。新的瓶颈,正在逐步变成上一章拯救过我们的那个东西:我们那台单独的、仍然还是“一个大块头”的数据库。

一整支应用服务器舰队都在同时向数据库猛烈发请求,它开始冒汗了。数据库服务器的 CPU 使用率在持续上升,查询开始慢下来。

我们成功扩展了“厨房”,但“图书馆”仍然只是一个房间,仍然只有一个管理员。而它,也快被压垮了。

第 4 章:关键结论

  • 横向扩展,才是高可用和大规模增长的长期道路。 它比纵向扩展复杂,但更省钱、更灵活,也能消除单点故障。
  • 负载均衡器,是横向扩展得以成立的核心交通警察。 它负责在服务器舰队之间分发请求,并在故障出现时自动绕行。
  • 可以从简单方案起步。 像 Nginx 这样强大的工具,可以同时扮演 Web 服务器和负载均衡器,减少早期基础设施复杂度。
  • Least Connections 是很聪明的默认算法。 相比更简单的 Round Robin,它通常能更均匀地分配真实工作负载。
  • 瓶颈永远在迁移。 解决一个性能问题后,负载只会转移到链路中的下一个薄弱环节。应用服务器不再是问题后,数据库很快就会变成新的火场。

第 5 章:数据库夜店门口的保安:读副本

当一家创业公司从几千用户增长到十万用户时,会发生一种根本性的变化。最初你面对的是生存问题,是四处灭火。问题都很响、很直接:服务器挂了,应用崩了。解决方案也往往很粗暴:重启、加内存、换更大的机器。

但当你跨过 10 万用户门槛后,会冒出一种全新的问题。火焰消失了,取而代之的是一种缓慢升温的闷热。系统不再频繁崩溃,它只是开始……变重,变迟钝。问题不再是“能不能活”,而是“能不能快”。解决方案也不再靠蛮力,而需要像外科手术一样精细。你不能只想着把灯点着,而得开始重新思考整栋楼本身的结构。

我们的负载均衡应用集群,已经解决了“厨房起火”问题。但现在,图书馆挤得几乎寸步难行。

第一部分:图书馆里的交通堵塞

有了负载均衡器之后,应用层变得非常顺。流量高峰来了,我们看着 App Server 的 CPU 上升,只要点几下,就能再加一台新服务器进舰队,然后看到负载自动摊平。我们第一次感觉,自己真的掌握了扩展能力。

我们的用户数一路冲过 5 万卖家、8 万卖家,眼看就要逼近 10 万卖家这个惊人的里程碑。而每个卖家背后还有自己的顾客,这意味着浏览 Dukaan 店铺的人已经是以百万计。我们的流量,远超曾经的想象。

但那种熟悉的恐惧感,又慢慢爬回来了。不同的是,这次大家抱怨的不是“网站打不开”,而是“网站很慢”。

  • “我的店铺顾客打开要 5 到 6 秒。”
  • “有时候我新增商品,保存要转很久才结束。”

这种迟钝在印度白天商业高峰时段最明显,大概是上午 11 点到下午 5 点。Suumit 和我盯着监控图表看。应用服务器其实没问题,CPU 分布均匀,很少超过 50%。负载均衡器工作得相当漂亮。

但那台单独而强大的数据库服务器,图表讲的是另一个故事。CPU 长时间维持在 80% 到 90%。磁盘 I/O 指标,也就是存储设备忙碌程度,已经顶满。数据库,这台曾经救了我们的强大隔离服务器,如今也开始喘不过气。图书馆挤爆了,而那个英勇的单一管理员终于快扛不住了。

识别瓶颈:太多人只是来看一眼

“数据库很慢”就像医生说“病人不舒服”。这不是诊断,只是观察。想要下药,我们得知道病根。我们必须钻进数据库内部,看清它到底在被什么样的工作拖住。

这促使我们开始分析数据库查询,也就是应用发给数据库的那些最底层命令。

技术深入:分析查询类型(读 vs. 写)

从根上看,数据库做的是两类完全不同的工作,理解这一区别至关重要。

  • 写查询(Write Queries): 所有会 修改 数据的操作。核心命令是 INSERT(新增)、UPDATE(更新)和 DELETE(删除)。
    • 类比: 像作者或管理员在真正修改图书馆藏书。INSERT 是一本新书入馆;UPDATE 是管理员修正卡片目录上的错字;DELETE 是把一本破损旧书从书架上撤下。
    • 这些动作都很关键,必须小心处理,才能维持整个馆藏的完整性。它们通常还需要“锁”,避免两个人同时改同一份东西。整体上,它们更慢,也更耗资源。对 Dukaan 来说,这类操作就是卖家新增商品、改价格,或者顾客下单。
  • 读查询(Read Queries): 所有只负责 读取 数据的操作。核心命令是 SELECT
    • 类比: 就像普通读者走进图书馆找书来看。他们不会改任何内容,只是找到、阅读、再放回去。他们只是消费信息。
    • 这类操作通常比写入快很多、轻很多。对 Dukaan 来说,这就是大量顾客浏览店铺和商品目录产生的洪流。

我们安装了数据库流量分析工具,结果一下子点醒了我们。这种模式在 Web 应用里常见到有一个固定名字。

95/5 法则(读写分离比例)

分析显示出了惊人的不平衡。数据库每收到 100 条查询:

  • 95 条是 SELECT(读)
  • 5 条是 INSERTUPDATEDELETE(写)

这非常合理。一个卖家一天也许只改几次商品(少量写),但他的店可能会被几千个顾客访问(海量读)。我们的系统,本质上是一个绝对被读流量主导的系统。

读,是怎么拖慢写的

真正的问题在这里:我们的单数据库服务器,把这两种工作放进了同一个队列里,按同样优先级处理。

再回到图书馆。图书馆只有一个入口、一个排队队伍。队伍里有 95 个人只是想问一句:“这本书在哪?”(快速读查询)但同一条队伍里,还有 5 位作者要为新书做完整登记,这需要填表、改主目录(慢写查询)。

结果就是:这些作者必须在一大群普通读者后面排队。海量简单读请求,制造了交通堵塞,拖慢了那些更关键、更敏感的写请求。这也就解释了,为什么卖家保存一个新商品时,会感受到明显延迟:他的“写”请求,被塞在一大堆匿名顾客的“读”请求后面排队。

答案一下就清楚了。我们不能再让所有人挤同一扇门。我们必须给“作者们”开一扇私有 VIP 门,而把普通读者导向另一扇更宽的大门。我们得把读和写分开。

第二部分:门口保安与 VIP 通道

问题已经很明确了。图书馆只有一个入口,而大量随便看看的读者,挡住了真正要办正事的作者。那解决方案就很自然:另开一扇门。作者走专属 VIP 通道,普通读者走另一个面向大众的大门。

在数据库架构里,这个策略叫 replication(复制)

技术深入:解决方案,数据库复制

复制,就是创建并维护多个相同数据库副本的过程。不是让一台数据库包打天下,而是让一组数据库分工合作。最常见的一种复制形式,也是我们实际采用的,是 Master-Slave Replication(主从复制)

这次我们先暂时放下图书馆,换个更形象的比喻:一家热门夜店。

主数据库(Master):VIP 区

Master 就像夜店里那个最核心、最封闭的 VIP 区。它是唯一的真相源头。

  • 它处理所有写操作(INSERTUPDATEDELETE)。 凡是会改变夜店状态的动作——新 VIP 到来、某位客人点了一杯酒、某位客人离场——都必须在这里登记。门口站着一个铁面无私的保安,确保每一笔变更都合法、准确、被记录下来。
  • 这里是卖家的入口。 卖家改商品价格、加商品、删商品,全部直接发往 Master。因为这些操作很关键,所以必须在这个不那么拥挤、优先级更高的环境里完成。

读副本(Read Replica / Slave):主舞池

读副本 就像夜店的主舞池。它是 VIP 区最新状态的一份近实时镜像,但它向公众开放观看。

  • 它只处理读操作(SELECT)。 成千上万的人(顾客)可以同时站在舞池里看、逛、欣赏,但他们不会改变任何东西。他们能看到 VIP 区发生了什么,但不能自己动手修改。
  • 它的职责,是吸收海量读流量。 只要把所有“我只是看看”的请求卸给 Read Replica,Master 数据库就能专心处理那些真正重要的写入。必要时,我们还可以增加多个 Read Replica,也就是再开几个舞池。

这种关注点分离,正是我们需要的架构跃迁。它让读和写第一次可以分别扩展。

技术深入:实现方式

理论很好,但实际怎么落地?主舞池怎么会知道 VIP 区里刚发生了什么?

PostgreSQL 的流复制(Streaming Replication)

PostgreSQL 内建了一套非常漂亮的能力:streaming replication

  • WAL(Write-Ahead Log,预写日志): 主库,也就是我们的 VIP 区,有一个极度勤勉的保安,会把每一件发生的事情都记进一本特殊日志里。新客人来了,记一笔;价格改了,记一笔。这个日志本叫 WAL。它按顺序、实时记录数据库里发生的每一个变化。
  • Stream(流): 我们新建一台服务器作为读副本,并配置它去连接主库。它的第一条指令就是:“订阅 WAL。” 然后主库会通过私有、安全的网络连接,把日志本里的新记录一条条实时“流式”推送给副本。
  • 副本应用: Read Replica 接收到这些变更流后,会按照完全相同的顺序,把这些变化应用到自己的数据副本上。

最终结果就是:Replica 始终是一份几乎实时同步的主库镜像。就像 VIP 区里发生的一切,都通过大屏幕直播到主舞池,让大家看见。

让应用具备“复制感知能力”

把数据库服务器搭起来,只完成了一半。我们的 Django 应用本身还是“傻”的,它只知道如何连一个数据库。我们必须教它变聪明,教它扮演那个门口保安:谁该进 VIP,谁该去主舞池。

这意味着我们必须对代码库做一次重要改造。

  • 配置多个数据库连接: 首先,在 Django settings 里,我们不再只配置一个数据库连接,而是两个:default 指向 Master 数据库的 IP,read_replica 指向新的 Replica IP。
  • 编写数据库路由器: 接着,我们实现了一个自定义“数据库路由器”。它是 Django 里的一段特殊代码,会拦截每一次数据库查询,并决定应该把它发去哪台数据库。逻辑很简单,但至关重要:
# A simplified version of our router logic
class PrimaryReplicaRouter:
 
  def db_for_read(self, model, **hints):
    # 所有读操作都去副本。
    return 'read_replica'
 
  def db_for_write(self, model, **hints):
    # 所有写操作都去主库。
    return 'default'

这个路由器一上线,应用立刻就“聪明”了。顾客打开店铺页面时,会触发大量 SELECT,这些流量全部被导向 Read Replica。而卖家点击“保存”新增商品时,触发的 INSERTUPDATE 会被导向受到保护、也更轻松的 Master。

我们把这套改动部署上线。效果又快又明显。店铺页面几乎是秒开,卖家也再次感觉“保存动作很利索”。主库上的高 CPU 和高 I/O 压力迅速降了下去。我们成功了,数据库扩展这件事,终于迈过去了。


逃不掉的三角:CAP 定理

刚把主从复制上线时,效果几乎像魔法一样。主库负责写,副本负责读,整家夜店忽然就顺了起来。卖家更新商品不再被大量顾客拖垮,顾客浏览也不需要排长队。看上去,我们似乎找到了完美系统。

可分布式系统从来不会白送你完美。计算机科学里有一个古老原则,过去我只是略微看过一眼,但现在它每天都在我面前上演:CAP 定理。

CAP 指的是 Consistency(一致性)、Availability(可用性)和 Partition tolerance(分区容错性)。它的意思是:任何分布式数据系统里,这三件事你最多只能同时严格保证其中两项,不可能三项全拿。

  • 一致性(Consistency) 指的是:夜店里每个房间看到的内容,应该在同一时刻完全一致。VIP 区换了歌单,舞池应该立刻听到同一首歌。
  • 可用性(Availability) 指的是:门永远开着。无论发生什么,请求来了总能得到一个响应。
  • 分区容错性(Partition tolerance) 指的是:即使房间之间的通道出了问题,系统也得继续运转。比如 VIP 区和舞池之间的音响线路卡住了,整场派对也不能就此停摆。

真正的关键在于:现实世界里,网络分区是一定会发生的。链路会断、丢包会有、光缆会出问题。所以任何真实系统,一旦发生分区,最终都必须在“一致性”和“可用性”之间做取舍。

当我们引入读副本时,其实已经在做选择了,只是当时可能还没意识到。我们选择的是:优先可用性,而不是绝对强一致性。主舞池(副本)始终对顾客开放,即便它还没完全追上 VIP 区(主库)的最新状态。结果就是:有时主舞池展示的,会是稍微旧一点的数据。

一个具体例子

假设某个卖家把一条裙子的价格从 ₹1000 改成 ₹800。主库会立刻记录下来。

  • 如果下一次请求直达主库,用户看到的是 ₹800。

  • 如果请求打到副本,但更新还没同步过去,用户看到的还是 ₹1000。

这两个答案在各自那个房间里都“成立”。但对卖家来说,这看起来就像系统坏了。明明刚改完价,为什么店铺页还是老价格?


为什么 CAP 如此重要

CAP 不是教科书里落灰的抽象概念。只要你开始加副本、开始跨区域同步数据、开始做分布式架构,它就会变成那个始终跟着你的隐形三角。我们决定采用复制的那一刻,就已经进入了一个“读可能落后于写”的世界。这不是 Bug。这只是 CAP 再次提醒你:分布式系统永远在逼你做选择。

一致性的不同层次

一旦接受了 CAP,下一个问题就是:既然不可能什么都要,那我们到底要哪种一致性?现实里并不是只有一种答案。分布式系统活在一个连续光谱上,不同产品会根据自己最在乎的东西,选不同的一端。

最常见的有三种:

  • 强一致性(Strong Consistency)
    这是大多数人直觉里认为“应该如此”的世界。卖家把商品价格改成 ₹800 后,之后任何一次读取,不管命中哪台服务器,都必须得到 ₹800。
    用夜店比喻就是:VIP 房里的 DJ 一旦切歌,舞池必须瞬间听到同一首,不能有例外。
    强一致性很干净,但代价往往是可用性下降。只要 VIP 区和舞池之间的通道哪怕阻塞一瞬间,系统宁可停住,也不允许有人听见“错误”的歌。

  • 最终一致性(Eventual Consistency)
    这正是副本常见的状态。VIP 区的更新会尽快流到舞池,但不是瞬时。如果你运气差,可能还会多听几拍旧歌,直到新歌同步过来。
    对用户来说,这会很困惑:明明刚保存的新数据,前台还显示旧值。只要给足够时间,所有房间终归会一致,但“足够时间”到底是一秒还是五秒,你没法精确承诺。

  • 因果一致性(Causal Consistency)
    这是一个中间方案,核心目标是保住因果关系。如果 Priya 刚把项链价格调低,然后她立刻查看自己的店铺,那么因果一致性会保证 她自己 先看到新价格,即便世界上其他用户还没同步到。
    夜店里就是:只要 DJ 本人知道已经切歌,那么当时在 VIP 房里见证切歌的人,一定会马上听到新歌,哪怕舞池上的人还在慢半拍。
    它不保证全球同步,但它保护了“我改了东西,所以我应该看见改动”的逻辑。

选择适合自己的模型

不同系统会做不同选择。银行系统必须要强一致性,你绝不会接受一家支行显示余额 ₹10,000,而另一家显示 ₹5,000。社交网络则更偏向最终一致性,如果点赞数慢几秒更新,没人会因此恐慌。因果一致性越来越适合面向用户的产品,因为它在规模和用户的即时预期之间,取得了更现实的平衡。

在 Dukaan,当我们引入副本时,实际上就已经做成了一个最终一致系统。这也是为什么 Priya 有时会看到“旧数据的幽灵”。那不是代码写错了,而是最终一致性的标准教科书案例。

当然,就像每次升级都会引入新副作用一样,这次的新架构也带来了一个微妙且可能危险的问题。


新问题:复制延迟(Replication Lag)

主库到副本的数据流非常快,但绝不是 即时。它总会有一个小延迟,通常以毫秒计。在压力大时,这个延迟甚至会飙到 1 到 2 秒。这就叫 replication lag(复制延迟)

也就是说,主舞池看到的 VIP 区,永远比现实慢半拍。

这会制造一整套很令人困惑的场景。比如卖家把某个商品价格从 ₹100 改成 ₹90(写入主库),接着立刻刷新自己的店铺页(读请求命中副本),却仍然看到 ₹100,因为更新还没同步过来。

这就是 eventual consistency(最终一致性) 那个既让人困惑、又可能危险的世界。

第三部分:旧数据的幽灵

从系统工程角度看,我们的新架构是巨大成功。系统更快、更稳,也能撑起 10 万用户。从工程师视角看,我们赢了。但从用户视角看,我们刚刚引入了一个非常奇怪、几乎带点魔幻色彩、又极其令人困惑的新问题。

想象一个卖家,叫 Priya。她经营一家定制首饰小店。她登录 Dukaan 后台,看到自己最热卖的一条项链定价是 ₹1000。她决定搞一个闪购活动,于是把价格改成 ₹800,点了“保存”。系统立刻反馈:“商品更新成功!”

为了确认没有问题,Priya 马上点开“查看店铺”,从顾客视角再看一遍。她盯着那条项链看。价格还是 ₹1000。

她心里一沉。没保存成功?她回后台,后台里显示的是 ₹800。她再回店铺页,又是 ₹1000。她开始慌了。我的店是不是坏了?顾客会不会还在按旧价付款?她一遍又一遍疯狂刷新页面。₹1000。₹1000。然后突然,过了 5 秒,它变成了 ₹800。

Priya 看到的,就是“旧数据的幽灵”。她撞上了复制延迟。她的“保存”是写请求,直接落在 Master;她的“查看店铺”是读请求,被路由到了 Read Replica,而那一刻副本恰好还比主库慢了半拍。

这不是代码 Bug,而是我们刚刚构建的这套高性能架构的自然属性。我们用瞬时一致性交换了大规模扩展能力。我们正式进入了 最终一致性 的世界。

技术深入:最终一致性

理解这个概念,先要把它和大家直觉里的“正常情况”对比起来。

  • 强一致性(Strong Consistency): 这是我们日常最自然的预期。比如银行转账后,你会认为余额应该立刻在所有地方同步。一次写入之后,后续任何一次读取都 保证 能看到最新数据。单服务器单数据库,天然就能提供这种强一致性,因为真相只有一个。
  • 最终一致性(Eventual Consistency): 这是分布式系统的现实,也是我们现在生活的世界。系统 保证:只要停止继续写入,所有副本最终都会变得一致。但它不承诺这个“最终”到底要多久。它承诺“会一致”,但不承诺“马上一致”。

这就是取舍。为了能扛住海量读取,我们放弃了“立即一致”的保证。对 99.9% 的用户——也就是普通浏览商品的顾客——来说,价格更新晚一秒,根本察觉不到。但对那 0.1% 的人,也就是 触发了这次修改 的卖家而言,这一秒却会变成非常刺眼、完全不可接受的糟糕体验。

我们没办法消灭复制延迟,它是物理现实。但我们必须想办法,让卖家不直接感受到它。

技术深入:处理旧数据的策略

这种问题该怎么解?你无法让系统再快一点,那就只能让应用更聪明一点。

策略 1:什么都不做(以及什么时候可以这样做)

对很多功能来说,一点点延迟完全可以接受。比如一个管理后台里展示“店铺总数”,哪怕数据晚 30 秒都没关系。你必须有意识地区分:系统里哪些地方必须强一致,哪些地方可以容忍最终一致。

策略 2:“写后读一致”方案(VIP 通行证)

这就是我们为了解决 Priya 问题真正落地的方案。思路很简单:某个用户刚刚完成写操作之后,短时间内,我们主动打破自己的默认读路由规则,把他的读请求也发去 Master。

这就是 Priya 的 VIP 通行证。

  • Priya 保存了项链的新价格。这是一次写操作,落到 Master
  • 应用检测到写入成功,于是在 Priya 的会话里写下一个临时标记(可以理解成浏览器里的 cookie):这个用户在接下来的 60 秒里,处于 VIP 窗口。
  • Priya 立刻刷新店铺页。这是一次读请求。
  • 数据库路由器识别到这是 Priya 的请求,并检查到她会话中的 VIP 标记。
  • 于是,系统不再像对普通用户那样把这个读请求发给 Read Replica,而是直接发往 Master
  • 因为 Master 永远持有绝对最新数据,所以返回的是正确的 ₹800。Priya 立刻就能看到自己的修改,产品信任感不会受损。
  • 一分钟后,这个 VIP 标记自动过期。之后她的读请求就重新回到正常副本路径。而那个时候,数据早已同步完成。

这个方案给了我们两头兼顾的结果:对大众用户保留海量可扩展能力,对真正发起变更的那个人,则提供“仿佛强一致”的体验。

第 5 章:关键结论

  • 用读副本扩展数据库,会带来巨大的性能收益,但一定要付出代价。 你是在用“强一致性的简单”交换“最终一致性的复杂”。
  • 复制延迟是物理现实,不是 Bug。 主库和副本之间必然存在延迟。你消不掉它,只能设计系统去适应它。
  • 最终一致性会制造非常困惑的用户体验。 用户刚改完数据就看到旧值,会迅速削弱他们对产品的信任。
  • 实现“Read Your Own Writes(读到自己刚写的数据)”策略。 对刚完成写操作的用户,临时把读请求导向主库。这样在最关键的地方保留“即时一致”的体验,同时又不丢掉读副本的扩展能力。

第 6 章:“兄弟,别在生产上测!“:预发布环境

前面的旅程,基本都在和外部敌人作战。我们对抗服务器极限、流量洪峰和物理定律。那是属于规模增长的光荣战役,也是成功之后自然会迎来的麻烦。

这一章要讲的是另一种完全不同的战斗。我们要对抗的最大敌人,是我们自己。

一家公司做大后,你不再是两个人的小部队,而会慢慢变成一个小排。你开始招第一批工程师,开发速度变快了,但复杂度和风险也同步上升。最早期那种“快速行动、先做出来再说”的文化,最初让你起飞,后来却可能把你亲手烧掉。

这就是我们第一次“自伤事故”的故事,也是我们学到的关键一课:在学会飞之前,先给自己织一张安全网。

第一部分:那个把一切搞崩的 Bug

新架构搭稳之后,我们开始招聘。团队从只有我和 Suumit,逐渐扩展到几位很有能力、也很有热情的工程师。整个团队充满能量。功能上线速度比以往都快:新支付方式、商品筛选、更好的订单管理……那种节奏令人兴奋。

现在回头看,我们当时的上线流程简直危险得可怕。一个工程师在自己电脑上写完代码,觉得“跑得通”,就推到 GitHub 的中央仓库里。接着,有个简单脚本会自动把这份新代码直接部署到线上生产服务器——也就是那台真正服务着 10 万卖家和数百万顾客的机器。

从开发者脑子里的一个想法,到真实用户屏幕上出现,快到 5 分钟都用不到。我们当时竟然觉得,这就是“敏捷”的巅峰。实际上,这更像是不带安全绳在玩高空飞人。

一个星期二下午,一位刚入职不久的年轻开发者——我们叫他 Rohan——接到一个很简单的需求:加一个按价格排序的按钮。他挺聪明,两个小时左右就把功能做完了。在他自己的笔记本上,用那个大概只有 15 个商品的测试店铺一跑,完全正常。排序几乎是瞬间完成。于是他信心满满,把代码推了上去。

5 分钟后,我的手机炸了。

这次不是服务器监控报警,而是 Suumit。并且他的语气一点也不平静。

“Subhash!我们的大卖家全挂了!gavranmisal.com、Jain Shikanji,那些大客户的店都打不开了!他们电话打爆我了,钱都在流失!到底怎么回事?”

我立刻打开监控面板。服务器没挂。CPU 正常,内存也正常。这不是扩容问题,而是代码问题。一个 Bug。

我们一阵慌乱,迅速查看最近的代码提交,马上就锁定到了 Rohan 那次改动。逻辑本身看起来似乎没错,但我盯着其中一条数据库查询时,眼睛停住了。就是一条简单的 ORDER BY price。问题在于,它的写法在商品量大时会极其低效。在 Rohan 那个只有 15 个商品的测试店铺上,它几乎瞬间完成;但在 gavranmisal.com 这种有 2000 个商品的大店里,这条查询会直接超时,把整页渲染流程拖死。也就是说,他不小心上线了一个只会炸“头部大客户”的 Bug。

接下来的 10 分钟,全是肾上腺素爆棚的混乱。我们必须紧急执行 revert(回滚),也就是把代码恢复到上一个版本;而与此同时,线上系统已经在冒烟,客服渠道里全是卖家的愤怒消息。

最后我们确实修回来了。店铺重新上线。但伤害已经造成了。我们让用户失望了。更糟的是,这次不是流量洪峰,不是硬件故障,而是 100% 我们自己的责任

那天晚上,Suumit 和我打了一通非常紧张的电话。

“这种事不能再发生了。”他语气很硬,“我们看起来像外行。代码从开发者电脑直接进线上,太离谱了。这就像厨师第一次试新菜,就直接端给总理吃。我们需要安全网。需要一个地方,在真正上线之前把东西测清楚。”

他说得完全对。我们之前一直在高空飞,却没有降落伞。现在,必须成熟起来了。


解决方案:搭一张安全网

我们的根本问题在于:代码只活在两个地方。一个是开发者的笔记本,一个是真实用户面前的线上环境。中间没有任何缓冲。一个专业的软件团队,必须有一条完整的装配线,在每个阶段都设置质量检查点。这就是 Software Development Lifecycle(软件开发生命周期)

技术深入:不同环境(Environments)

想象一家高端餐厅。他们不会是“做了就上”,而是有一套严格流程,对应 3 个不同环境。

  1. Development Environment(开发环境):测试厨房

也就是开发者自己的笔记本电脑。

  • 类比: 这是厨师的私人测试厨房。这里可以乱、可以试错、可以尝试奇怪食材组合。顾客永远不会直接吃到从这个厨房里端出来的菜。
  • 我们的错误: Rohan 只在自己的测试厨房里验证了那道新菜,而那个厨房只放得下很小一顿饭(15 个商品的店铺)。他并没有准备足够材料去模拟“大型宴会”(2000 个商品)会发生什么。
  1. Production Environment(生产环境):正式餐厅

也就是现实中真实用户在访问的线上服务器。

  • 类比: 这是坐满付费顾客的主餐厅。这里端上桌的每一道菜都必须完美。任何失误,都是公开的、难堪的,而且会直接伤害餐厅名誉。
  • 我们的错误: 我们当时居然把测试厨房里的菜,直接端进主餐厅。我们在拿真实顾客试验自己的想法。
  1. Staging Environment(预发布环境):彩排舞台

这正是我们之前缺失的关键一环。Staging 是一个完整、平行且 尽可能镜像 Production 的环境。

  • 类比: 这是后台一间设备齐全、和正式厨房一模一样的彩排厨房。它有同样的炉子、同样的人员、同样质量的食材。一道新菜在正式写入菜单之前,必须先在这间“彩排厨房”里做完整演练。厨师做出来后,会先给店长、员工这些内部测试者品尝,他们会像真实顾客那样走完整体验:点单、出餐、上桌、反馈。只有通过这轮完整彩排,菜才有资格进正式餐厅。

这就是我们的安全网。一个地方,Rohan 可以先把代码部署进去,再拿一个 2000 商品规模的店铺去测。届时 Bug 会在 Staging 立刻暴露,页面会先在彩排环境里炸掉,而不会伤害任何一个真实客户。

我们知道自己必须做什么了。我们要搭建一个和正式舞台几乎完全一致的镜像世界,只用来彩排。

第二部分:搭建那面镜子

决定建设预发布环境,对我们来说是一个标志性时刻。那意味着:我们不再是随便排练的车库乐队,而准备成为一支真正的职业交响乐团。职业乐团需要专门排练厅,我们也一样。

但要造一个 有用的 Staging,比听上去难得多。它绝不是“再开一台服务器,把代码放进去”这么简单。一个做得很差的 Staging,甚至比没有还糟,因为它会给你制造虚假的安全感。若想真正成为安全网,这个彩排舞台就必须是一个尽可能毫米级还原正式环境的镜像。

技术深入:为什么必须和生产环境一致

这是 Staging 的黄金准则:你的预发布环境,必须尽可能和生产环境保持一致。

为什么?因为 Bug 最喜欢躲在那些“微妙差异”里。

  • 如果 Staging 服务器比 Production 内存更大,你就很难提前发现内存泄漏类问题。
  • 如果 Staging 跑的是比 Production 更新的 Python 版本,那么代码可能在 Staging 一切正常,但上线到生产就因为库兼容性崩掉。
  • 如果 Staging 的网络规则和正式环境不同,某个功能可能只在 Staging 可用,而线上却被防火墙挡住。

你不可能在一个中学礼堂大小的纸板舞台上给百老汇大戏做彩排,还指望发现所有真实问题。你必须有同尺寸舞台、同样灯光、同样声场。

对我们来说,这意味着一笔并不轻松的新投入。我们得把整套生产架构再复制一份:

  • 一致的“硬件”: 我们为 Staging 新建了一批 DigitalOcean Droplets,CPU、RAM、SSD 规格和生产完全一致。
  • 一致的软件: 我们通过配置脚本,确保 Staging 上的 Ubuntu、Python、Django、PostgreSQL、Nginx、Gunicorn,以及所有依赖库的版本都和生产一致。
  • 一致的架构: 生产现在已经是“负载均衡器 + 两台应用服务器 + 读副本数据库”。那 Staging 也必须一样:一个 Staging LB、两台 Staging App Server,以及一套 Staging 主从数据库。

这基本等于让我们的服务器成本翻了一倍。对一家 Bootstrapped 创业公司来说,这绝对是肉疼的。但我们换了个角度思考:这不是成本,而是一份 保险费。我们是在用一笔可预测的月费,去给自己购买一张“避免线上事故伤害声誉”的保险单。

技术深入:数据灌入与脱敏的难题

现在,舞台搭好了,但舞台上空无一人。没有演员、没有道具,彩排也没意义。对 Staging 来说也是一样:如果里面没有真实规模、真实复杂度的数据,它就只是个空壳。而这恰恰是维护一个有用的 Staging 最难的部分。

Rohan 那次事故之所以发生,就是因为他只在 15 个商品的测试数据上验证,而真正的问题只会在 1000+ 商品规模上暴露。所以,Staging 里的数据,必须能映射出生产环境的 规模复杂度

最显而易见、也是最危险的错误做法,就是直接克隆生产数据库,整份塞进 Staging。这件事绝对不能做。

生产数据库里有用户最敏感的信息:姓名、手机号、邮箱、私密订单历史。把这些数据拷贝进一个权限更宽、更多开发者可见的 Staging 环境,不只是糟糕实践,甚至可能违法。

于是,我们陷入了一个两难:我们需要生产数据的规模,但不能真的使用生产数据本身。

解决方法,是构建一条 Seeding and Sanitization Pipeline(数据灌入与脱敏流水线)。这是一套每天晚上自动执行的脚本流程,分两步走:

步骤 1:Seeding(灌入)

脚本会先用 pg_dump 对生产数据库做一次完整备份。这样我们就得到了一份在结构上完全准确、反映当下规模的数据快照。

步骤 2:Sanitization(脱敏)

这才是关键。在把备份导入 Staging 之前,脚本会先把它送进一个“清洗器”,把所有敏感信息洗掉:

  • 匿名化用户数据: 扫描 users 表,把真实姓名替换成类似 “Test User 1234” 的假名;邮箱改成 <testuser1234@example.com>;手机号替换成随机生成的假号码。
  • 模糊化财务数据: 把真实商品价格和订单总额改成看起来合理、但经过随机扰动的值。
  • 保留规模与结构: 最重要的是,脚本 不会删除数据。如果生产环境里某家店有 2000 个商品,那么脱敏后的 Staging 副本里依然有 2000 个商品,只是名字和价格被打乱了。如果某个用户原本有 500 个订单,Staging 里那个匿名用户也仍然保留 500 个订单。

这套东西很难做,也需要持续维护。但它带来了预发布环境的“圣杯”:一个在规模和复杂度上都足够接近生产、却不包含任何真实敏感数据的数据库。

如此一来,Rohan 本来就应该把那个排序功能先部署到 Staging,然后对照 gavranmisal.com 这种有 2000 商品规模的脱敏店铺做测试。届时页面会先在 Staging 崩掉,他修好之后再上线,真实顾客根本不会知道这段插曲的存在。

现在,我们已经有了测试厨房(开发者笔记本)、主营业厅(Production),也有了一座专业彩排舞台(Staging)。最后还缺的一块拼图,是建立一条正式、安全、可重复执行的代码流转流程。我们需要一条装配线。我们需要部署流水线。

第三部分:装配线

环境搭出来了。测试厨房(Dev)、彩排舞台(Staging)、主营业厅(Production)都齐了。这已经是一次质的飞跃。但只有房间还不够,你还需要一种安全、高效的方法,把“菜”在这些房间之间流转。

我们旧的做法,是开发者手工跑个脚本,直接把代码推上生产。那就像厨师端着一口着火的锅,从测试厨房一路狂奔冲进主餐厅。它确实快,也很刺激,但最终一定会出事故。

我们必须把这种混乱冲刺,替换成一种平稳、有序、可预测的过程。我们要给代码建一条装配线。在技术世界里,它叫 Deployment Pipeline(部署流水线)

技术深入:部署流水线

部署流水线,是一套自动化流程:它接收开发者笔记本上的代码,并在最终交付给用户之前,先通过一系列质量检查。

把它想象成现代汽车工厂的装配线。开发者的代码是原始钢材。流水线则是传送带和机械臂,把钢材自动从一个工位送到下一个。每个工位上都有测试、都有检查。只有通过每一关的车,才有资格滚出工厂,进入展厅。

流水线的目标,是让部署这件事变得 无聊(boring)。部署不应该是一次高风险的祈祷仪式,而应该是一次例行、可预测、可复现的操作。无聊是好事。无聊意味着网站没在着火。

于是,我们设计出了第一版简单但完整的部署流水线,混合了人工检查和自动步骤。

步骤 1:GitHub 上的 Pull Request

整个流程从开发者写完代码开始。不同于过去直接把代码塞进主分支(master),现在他们必须先在 GitHub 上发起一个 Pull Request(PR)。

PR 的含义很正式:“我这部分功能已经完成了。这是代码,请你们 review,并决定是否允许合入主项目。” 这就是我们装配线的入口。像原钢材进入工厂大门。

步骤 2:人工质检(Code Review)

这对我们来说,是一次非常大的文化转变。任何一行新代码想继续往下走,都必须先经过至少一位其他工程师的 review 和批准。

这第二双眼睛,是一种极其强大的质量关。Review 的人会重点看:

  • 明显 Bug 或逻辑错误。
  • 低效数据库查询(就像上次搞垮我们的那种)。
  • 难读、难理解的代码。
  • 安全漏洞。

这个简单却以人为核心的步骤,迫使团队协作,也建立了对代码的共同责任感。它是提前挡住 Bug 的非常有效手段。

步骤 3:自动化测试与部署到 Staging

当人完成批准后,机器接管。我们用 GitHub Actions 搭了一套自动化系统,会在这时自动触发:

  • 跑自动化测试: 系统先执行完整的 unit testsintegration tests,验证新代码行为符合预期,也确认没有不小心把旧功能搞坏(也就是所谓 regression)。
  • 部署到 Staging: 如果所有自动化测试都通过,系统就会自动把代码合入 staging 分支,并部署到 Staging 环境。

这意味着,功能已经上线到我们那块完整镜像的彩排舞台里,背后还带着一份脱敏后的、生产规模级别的数据。

步骤 4:最后一次总彩排(Staging 上的人工 QA)

这是最后、也是最关键的检查点。开发者本人,或者专门的 QA,需要在 Staging 上手工测试功能。

他们会照着清单,以真实用户身份去体验:按价格排序按钮能用吗?在只有 5 个商品的店里能用吗?在一份脱敏后的 5000 商品大店里还能用吗?移动端浏览器上正常吗?有没有把页面其他部分搞坏?

只有通过这一轮严格、接近真实世界的人工验证,功能才会被允许进入最后一步。也正是这一步,本该挡住 Rohan 的 Bug,救我们于一次线上事故。

步骤 5:推送到 Production

现在,上线到生产已经变成了一次平稳、刻意且从容的动作。它不再是慌乱、临时拼出来的操作。一个功能只要在 Staging 被确认通过,我们就会把代码合入 master 分支。这个合并动作,会触发流水线最后一段,把代码部署到真实生产服务器。

这套流程,彻底改变了我们团队。它用秩序替代了混乱,用信心替代了焦虑。我们不再只是几个会写代码的人,而开始成长为一支真正专业的工程组织。


第 6 章:关键结论

  • 预发布环境,是避免自伤型事故的刚需保险。 相比一次线上事故造成的损失,搭建和维护它的成本几乎可以忽略。
  • 你的 Staging 必须是 Production 的镜像。 硬件、软件、架构越一致,越有可能提前拦住真实世界才会出现的 Bug。
  • 绝对不要把原始生产数据直接用于 Staging。 建立自动化灌入和脱敏流程,既保留真实规模,又保护用户隐私。
  • 部署流水线,是用可靠自动化替代混乱手工步骤。 它强制引入代码评审、自动化测试和人工 QA 等多层质量关。
  • 好流程的目标,是让部署变得无聊。 无聊意味着可预测,可预测意味着可靠。对一家增长中的公司来说,可靠就是一切。

第 7 章:速度即一切:用 Redis 做缓存

我们已经打完了稳定性战争。架构足够稳,部署流程足够专业,系统也能扛住宕机和流量高峰。我们像一支从车库乐队进化成训练有素的交响乐团。卖家数量也跨过了 100 万大关,而几个月前,这都还像梦一样。

但新的挑战正在出现。它不像服务器宕机那样刺眼,却同样危险。我们的问题已经不再是 availability(可用性),而是 performance(性能)。店铺不只是要在线,还必须要快。在电商世界里,速度不是锦上添花的功能,而是一条底线。页面加载每慢 1 秒,转化率都可能明显下滑。

我们即将理解:一个“好产品”和一个“伟大产品”之间的差距,很多时候只以毫秒计算。


第一部分:来自 gavranmisal.com 的投诉

这次电话,来自我们的一位明星卖家。gavranmisal.com 的店主,是普纳一家超火的餐饮商家,也是我们最早一批用户之一。他们菜单很大、分类很多、流量也非常高,是 Dukaan 成功案例里的典型代表。

而现在,他们不满意了。

Suumit 接了电话。对方抱怨的不是“网站打不开”,某种意义上说,这更糟。“我的顾客一直在抱怨,店铺要 5 到 6 秒才能打开,”他声音里满是挫败,“大家不是没耐心,但也不可能等这么久!很多人甚至还没看到菜单就直接走了。这是在直接损失我的生意。”

这是一种全新的火。不是明火,是闷火。不是技术宕机,而是业务问题。我们本来想赋能卖家,结果平台本身的迟钝,反而在伤害他们。

我的第一反应,是去看监控面板。我满以为会看到某台服务器已经快被压垮。但结果却是……一切都挺正常。负载均衡器分流漂亮,应用服务器 CPU 连 30% 都很少超过,读副本数据库也处理得很稳,没有任何吃力迹象。按图表看,整个系统非常健康,远没到极限。

可用户的真实体验,却是整整 6 秒加载时间。系统“能扛住”和用户“觉得快”,之间存在巨大的断层。我们只能继续往下挖。

识别瓶颈:重复的数据库查询

我们启用了 Django Debug Toolbar。这个工具可以把一次页面加载过程中发生的所有事情都摊开给你看。我们带着 Toolbar 打开 gavranmisal.com 的店铺页,原因几乎像砖头一样砸在了我们脸上。

为了渲染这一个页面,应用竟然对读副本数据库发起了 114 次独立的 SELECT 查询

我们先查店铺详情,再查主题配置,再查所有分类,再查第一个分类下的商品,再查第二个分类下的商品……以此类推。而且每来一个用户,我们都 从头完整做一遍

虽然读副本很强、这 114 次查询单看都很快(也许每次只要 5 到 10 ms),但累计起来就很恐怖。

114 次查询 * 每次 10ms = 1140ms

光数据库时间就超过了 1 秒。这就是典型的 “death by a thousand cuts(千刀慢死)”。再加上每次查询的网络延迟,以及应用本身组装页面的时间,5 到 6 秒加载时长一下就完全说得通了。

真正的洞察是:gavranmisal.com 的菜单,并不是每秒都在变。实际上,可能一天只更新一两次。但我们的系统却像个勤劳过头的傻子,每来一个用户,都要重新从数据库里一片一片拼出整份菜单。

我们在不停重复执行同一项昂贵计算,而结果几乎总是一模一样。这就是低效的定义。

技术深入:缓存的原理

解决重复劳动的办法,是高性能系统里最基础的一条原则。从 CPU 到全球互联网,这个思想无处不在。它叫 caching(缓存)

先用一个非常简单的比喻来理解。

假设数学老师问你:“135 乘以 782 等于多少?”

第一次,你大概率会拿出手机计算器,小心输入,得到答案:105,570。这是一项“昂贵”的操作。它花了你几秒钟时间和一点脑力。对应到我们的系统,就是应用去查询数据库。

现在,假设老师 5 秒后又问了你一模一样的问题。你会怎么做?你不会再掏一次计算器。你会直接记住答案,张口就来。你把结果暂时存在了大脑短期记忆里。你可以瞬间回答。你已经 cache 住了这个结果。对这个问题来说,你的大脑此刻比计算器快得多。

这就是缓存的核心原则:

  • 先识别一个执行起来 很昂贵 的操作。
  • 这个操作还得是 被频繁请求 的。
  • 并且它每次都会产出 相同结果
  • 那就先把这个操作 只执行一次
  • 把结果存进一个 更快的临时位置(缓存)。
  • 以后每次再来请求时,直接从缓存返回,而不是重新执行那项昂贵操作。

我们的店铺页,就是缓存的完美候选。它昂贵(114 次数据库查询 + 页面组装),被请求频繁(每小时成千上万次),而且 99.9% 的情况下结果都一样。

我们得给应用造一个“短期记忆”。一个地方,能把最终可以直接返回的店铺页面数据暂存起来,不必每次都重新从数据库拼装。是时候请出我们整套技术栈里最重要的工具之一了:Redis

第二部分:白板

我们已经知道自己需要缓存,给应用加一个“短期记忆”,让它把那些重复问题的答案先记住。下一步是选工具。我们需要一个极快、易用、可靠的东西。这个选择几乎没有悬念,因为在内存缓存世界里有一个无可争议的王者:Redis

技术深入:什么是 Redis?

Redis(全称 REmote DIctionary Server)是一个开源的内存数据存储系统。想理解它,得把这句话拆开看。

基于内存 vs. 基于磁盘

这是最关键的概念。

  • PostgreSQL 这样的传统数据库,主要是 disk-based(基于磁盘) 的。它把数据存到 SSD 或 HDD 上。你可以把它理解成一座 图书馆。它容量大、永久、组织有序。但每次你要拿一条信息,数据库引擎都像图书管理员一样,要跑到书架、找到书、再拿下来。对人类来说这已经很快了,但对计算机来说,这仍然是可测量的时间开销。
  • Redis 则是 in-memory(基于内存) 的数据库。它把数据直接放进服务器的 RAM 里。你可以把它想成放在办公桌旁边的一块超大 白板。你想看某条信息时,只需要抬眼瞄一下白板。读取几乎是瞬时完成的。

从 RAM 读数据,比从最好的 SSD 读数据也要快几个数量级。这就是 Redis 为什么快得吓人。当然,它的代价也很明确:RAM 是易失性的(服务器一重启,白板上的内容就擦掉了),而且比磁盘空间贵得多。但作为缓存,这个权衡反而完美,因为缓存里的数据本来就是临时的,随时都可以从“图书馆”(PostgreSQL)重新算出来。

什么是 Key-Value Store

Redis 快的另一个原因,是它足够简单。它是一个 key-value store(键值存储),这大概是最简单的数据模型了。

它就像字典。你有一个唯一的 key(键),像你要查的词;再有一个 value(值),像它对应的释义。

  • Key: 一个唯一字符串,比如 store_catalog:gavranmisal.com
  • Value: 跟这个 key 关联的数据。它可以是字符串、数字,或者像我们这里一样,是一大段包含完整商品信息的文本。

想取数据时,你只需要跟 Redis 说:给我这个 key 对应的 value。没有 SQL 那种复杂查询语言。直接 GET key 即可。这种极致简单,让它和我们的应用对接起来又快又直接。

技术深入:我们的 Read-Through 缓存策略

工具选好了,接下来要定策略。我们的做法是:提前把整份店铺页数据组装好,作为一个完整块存进 Redis。

  • key 采用一个简单、可预测的字符串,例如 store_catalog:<store_name>
  • value 则是一整个大型 JSON object。JSON 是文本形式的结构化数据。我们会把原来需要 114 次查询拼出来的数据,打包成一个现成可返回的 JSON 文件。

于是,读取店铺目录的代码逻辑,改成了典型的 “read-through cache(穿透式读取缓存)”。大概长这样:

简化版 Python 示例

 
import redis
import json
 
# Connect to our Redis server
redis_client = redis.Redis(host='localhost', port=6379, db=0)
 
def get_store_catalog(store_slug):
  # 1. Define the key we will use for this store.
  cache_key = f"store_catalog:{store_slug}"
  # 2. First, try to get the data from the cache (the whiteboard).
  cached_data = redis_client.get(cache_key)
  if cached_data:
    # 3a. CACHE HIT! The data was on the whiteboard.
    print("CACHE HIT!")
    # Convert the JSON string back into a Python dictionary and return it.
    return json.loads(cached_data)
  else:
    # 3b. CACHE MISS! The data was not on the whiteboard.
    print("CACHE MISS!")
    # 4. Do the expensive operation: query the database (the library).
    # (This is a placeholder for our 114 database queries)
    store_data_from_db = build_catalog_from_database(store_slug)
    # 5. Convert the freshly fetched data into a JSON string.
    json_data = json.dumps(store_data_from_db)
    # 6. Save it to the cache for next time!
    # Set an expiration time (ex) of 1 hour (3600 seconds).
    redis_client.set(cache_key, json_data, ex=3600)
    # 7. Return the data to the user.
    return store_data_from_db

这段逻辑几乎改变了一切。我们把它部署上去之后,第一个访问 gavranmisal.com 的用户一定会触发 CACHE MISS。他的请求还是会慢,因为系统必须真的去数据库查、组装 JSON。但在这个过程中,结果会顺手被写进 Redis。

从那一刻起,接下来一小时里每一个访问者,都会触发 CACHE HIT。他们的请求甚至不会再碰 PostgreSQL。Redis 会直接从内存里把预先构建好的 JSON 以几毫秒速度吐出来。原本 6 秒的页面加载时间,立刻掉到 200 ms 以内。效果极其惊人。

新的蓝图

现在,Redis 作为一个高速缓冲层,坐在了应用和数据库之间。

新的流程变成了:用户请求 -> 应用 -> 先查 Redis ->(如果 Miss)-> PostgreSQL。

新问题:脏缓存(Stale Cache)

我们解决了速度问题。但与此同时,又造出了另一个更阴险的问题。

如果 gavranmisal.com 的店主把某个商品价格从 ₹150 改到 ₹120,会发生什么?这笔改动当然会正确写进我们的 PostgreSQL 主库(图书馆)。但 Redis 缓存(白板)上,还保留着旧的目录版本,价格仍然是 ₹150。

我们在 set 命令里给了它 1 小时 TTL。这意味着接下来的整整 1 小时,所有访问这家店的顾客,都会非常快地看到一个 错误的旧数据版本

我们造出了一个极快、却可能对用户“撒谎”的系统。那该怎么让白板在主图书馆里的原件一变动时,就立刻把自己擦掉?

这就是 cache invalidation(缓存失效) 问题。它之所以著名,是因为它常被戏称为“计算机科学里最难的两个问题之一”。

第三部分:遗忘的艺术

我们给系统造出了一种闪电般的记忆,却忽略了一个关键事实:好的记忆,必须搭配同样优秀的“遗忘能力”。缓存牢牢抓着旧数据不放,于是这个原本 brilliant 的性能方案,开始演变成真相错位的问题。Priya 的“幽灵价格”,就是架构仍然太天真的明确信号。

第一个错误想法:缩短狗绳

我们第一个也是最直观的想法,是把缓存的过期时间,也就是 TTL(Time-To-Live),直接调小。之前我们拍脑袋设成了 1 小时(ex=3600)。

“那如果改成 1 分钟呢?”团队里有人提议。

这听起来像是一个诱人的快速修复。1 分钟 TTL 意味着,旧数据最多只会错误显示 60 秒。比 1 小时当然强,但这是个很糟糕的权衡。

缓存好不好,核心指标之一是 命中率(hit rate)——也就是请求有多少次能直接从缓存拿结果,而不是回数据库重算。1 小时 TTL 的情况下,一个热门店铺通常只会有 1 次慢请求(一次 miss),随后跟着几千次极快请求(大量 hit),命中率能超过 99%。

如果 TTL 改成 1 分钟,等于你强迫每家热门店铺 每分钟都要 miss 一次。也就是应用每分钟都必须重新回数据库,重新跑那 100 多次查询,再重新构造一次 JSON。原本一小时做一次的重活,变成一小时要做 60 次。这会显著增加读副本压力,把我们刚靠缓存赢回来的优势又吃回去一大半。

这就像试图修一个漏水龙头,办法却是每分钟把总水闸开一下、关一下。太笨,也太低效。我们需要的是手术刀,而不是大铁锤。我们不该等缓存“自然过期”,而应该能在数据一变的时候,主动把它精准杀掉。

真正的解法:事件驱动失效

正确做法,是让系统变得主动。只要“真相源头”(主数据库)里的数据发生变化,就要立刻向缓存发出一条明确信号:“你手里的信息错了,马上忘掉。”

这就是 event-driven cache invalidation(事件驱动的缓存失效)。要做到它,我们需要两样东西:

  • 一种机制,用来检测“数据变化”这个事件发生了。
  • 一种机制,用来把这个事件广播给监听者。

幸运的是,我们用的 PostgreSQL,刚好内建了这两种能力。

技术深入:Postgres Triggers 与 LISTEN/NOTIFY

我们搭了一套系统,让数据库第一次“开口说话”,能在自己被改动时对外宣布。

  1. 数据库触发器(Trigger):保安感应器

Trigger 是数据库里一种特殊函数,你可以配置它在某张特定表发生特定操作时自动执行。

我们在 products 表上挂了一个 Trigger。可以把它想成装在图书馆保险库门上的动作感应器。只要 products 表里有任意一行被修改(INSERTUPDATEDELETE),它就立刻触发。

  1. NOTIFY:广播器

感应器触发后,总得拉警报。于是我们让 Trigger 去执行一条 NOTIFY 命令。这是 PostgreSQL 的一个特性,可以在某个公共频道上发出一条消息,像电台广播一样。我们把频道命名为 product_changes。更关键的是,消息里还会带上一点关键信息——刚刚被改动商品对应的 store_id

  1. LISTEN:收音机

最后一块,是一个独立的小服务。我们叫它 “Cache Invalidator(缓存失效器)”。它在这个世界上只有一个职责:连接数据库,并持续 LISTEN product_changes 频道。它就像一个专职无线电接收员,永远守着同一个频道。

完整的数据流,漂亮得让人上瘾:

  • Priya 在自己的店铺(Store ID: 456)里,把一条项链价格改掉。这个动作是对 Master PostgreSQL 的一次 UPDATE
  • products 表上的 Trigger 立刻被触发。
  • Trigger 向 product_changes 频道广播一条 NOTIFY 消息,payload 是 "456"
  • 我们那个持续监听的 Cache Invalidator 服务,马上收到这条消息。
  • 它瞬间知道自己该做什么:“Store 456 的数据已经脏了,我必须把缓存干掉。”
  • 然后它去连接 Redis,并执行一条极快的命令:DEL store_catalog:store-456。白板上的旧 JSON 立刻被擦掉。

这样一来,Priya 再刷新店铺页时,应用先查 Redis,会发现没有命中(cache miss)。于是系统重新去数据库拿最新价格 ₹800,重建 JSON,并把新版本重新写回缓存。旧数据的幽灵,被彻底赶走了。


第 7 章:关键结论

  • 速度就是功能。 一个慢网站,本质上就是坏网站。缓存,是提升应用性能最强有力的工具之一。
  • Redis 是缓存场景下极其优秀的工具。 它基于内存、键值简单,对临时数据存储来说,比传统磁盘型数据库快几个数量级。
  • 缓存一定会引入数据一致性问题。 仅靠时间型 TTL 来处理脏数据,通常既粗糙又低效。
  • 事件驱动的缓存失效,是更高级也更正确的做法。 只要底层数据变化,就主动删除对应缓存,效率和可靠性都更好。
  • 别浪费数据库自带的高级能力。 PostgreSQL 的 Trigger 以及 LISTEN/NOTIFY,就是构建实时缓存失效系统非常强的内建基础设施。

第 8 章:拆掉单体:我们的第一个微服务

我们已经征服了基础设施扩展中的大怪兽。服务器能扛流量,数据库能扛读取,部署流程也足够安全。我们有了一支专业工程团队、一套稳健架构,以及一个被数百万人使用的产品。第一次,我们感觉自己像是真的掌控了局面。

但规模增长的本质,就是瓶颈永远会迁移。我们优化了机器,却忘了优化组织。随着团队变大,那个曾经帮助我们快速起步的单体代码库,开始从最大资产变成最大负担。

下一场火,不是流量洪峰点起来的,而是我们自己团队点起来的。那是一道典型“自伤”,起因是太多人同时挤在同一间小厨房里做事。

第一部分:内战

工程团队已经扩到大约 10 个人。我们把他们拆成两个小 squad,分别盯不同业务方向。“Growth” 小队管卖家体验,比如 onboarding、后台和商品管理;“Operations” 小队管更底层的事情,比如支付、物流和履约。

这个拆法听起来很合理。问题在于:两个小队依然在同一个单体 Django 应用里工作,代码彻底纠缠在一起。

某天下午,Operations 小队在接一个新的物流合作方。为此,他们得在数据库里的 Order 模型上新增几个字段,比如 tracking_idshipping_provider。与此同时,Growth 小队在做卖家优惠功能,也需要改 Order 模型,增加 discount_code 字段。

两个团队并行推进。两个功能都在 Staging 测过,看起来都没问题。物流功能先通过审批,上了生产。一个小时后,折扣功能也通过了,也上了生产。

然后,混乱爆发了。

我们的支付确认 webhook,也就是那个负责确认顾客付款成功的关键接口,开始对每一笔订单都失败。钱已经从顾客那里扣掉了,但订单在我们系统里却没有被标记成 “Paid”。从卖家视角看,就是顾客付款后,订单凭空消失。

Suumit 的电话立刻打来。这已经不是“网站慢一点”了,而是“用户的钱出问题了”。这是最高等级的火警。

我们一头扎进日志。报错很晦涩,出错点在支付确认逻辑很深的地方。半小时手忙脚乱调试之后,我们终于找到根因:Growth 小队在加 discount_code 字段时,顺手改了 Order 保存方式的一些细节;而物流小队之前的变更,恰好依赖于旧的保存行为。两个功能单独看都没问题,独立测试也都通过,但一旦一起跑在线上,就发生了没人预料到的冲突。

这是我们代码库内部的一场内战。两个都在为同一个目标努力的团队,居然在不知不觉间把对方绊倒了。

识别瓶颈:代码库本身

我们回滚变更、控制住局面后,认真做了一次复盘。问题已经很清楚:瓶颈不在服务器,不在数据库,也不在部署流水线。流水线完全按要求执行了,它没有错。

真正的瓶颈,是 单体本身

我们的应用已经变成一张巨大的依赖网。支付代码紧耦合订单,订单再紧耦合物流、卖家、商品。开发者在任意一处做改动,都很难不去理解它会对整个系统产生什么连锁反应。

我们的开发速度,也就是能够 安全地 交付新功能的速度,开始急速下降。每个新功能都需要更多会议、更多协调、更多担心“会不会把别的东西搞坏”。我们花在“管理单体复杂度”上的时间,已经快超过真正“为用户造功能”的时间。厨房变得太拥挤了。

技术深入:单体 vs. 微服务的正面对决

这次危机逼着我们正视一个重大架构抉择:是不是该把单体拆成 microservices(微服务)

单体:一家巨大的餐厅。正如前面讲过的,单体就是一个应用把所有事都包了。

  • 类比: 它像一家超级大餐厅。只有一个后厨,但里面既做意面、也做 tacos、还做中式面条。一个总厨盯着所有工位。
  • 优点(早期): 起步简单。所有人都在同一间厨房里,沟通成本低,可以最快速度把第一批客人服务起来。
  • 缺点(我们现在的现实): 一旦业务红了,厨房就开始混乱。意面厨师得等 taco 厨师用完炉子;改个面条配方,可能把做意面酱汁要用的盐都耗光。想招一个新专家厨师也很难,因为他必须先理解整本庞大的菜单。这正是我们正在经历的事。

微服务:一个由专门档口组成的美食广场。微服务架构,会把一个大应用拆成一组更小、更独立的服务。

  • 类比: 它像一个 food court。不是一间巨型餐厅,而是按菜系拆成多个档口。披萨档、taco 档、面条档,每个都有自己的小厨房、自己的专门厨师、自己的食材。
  • 优点:
    • 团队自治: 披萨团队可以改菜单、试新烤箱,完全不需要担心把 taco 档搞挂。他们甚至可以一天发多次版,而不影响其他团队。
    • 技术专用化: 面条档可以用最适合炒面的高功率炒锅(不同语言、不同数据库),而披萨档可以用传统砖炉。
    • 故障隔离: taco 档着火了,它自己停摆;但披萨和面条照常营业。整个 food court 不会跟着停。
  • 缺点: 建起来和维护起来都更复杂。一个既想吃 taco 又想吃披萨的顾客怎么办?档口之间如何协作?你开始要面对网络通信、服务发现、分布式数据这些在单体里根本不存在的问题。

对我们来说,答案已经很明确:单体带来的痛,已经超过拆分所带来的痛。我们必须开始动刀。必须规划第一次切口。

第二部分:规划第一刀

决定要拆单体,既吓人又必要。那种感觉,就像站在一台自己亲手造出来的复杂机器前,明知道必须一边运行、一边把它拆开。

问题是:该从哪里下手?

外科医生不会闭着眼乱切。他会先研究 X 光、先规划切口。而第一刀往往最关键,它会决定整场手术后面的走向。我们需要计划。需要先决定,第一个微服务应该是谁,也就是从单体上先剥离哪一块。

第一次选错,代价会非常大。如果你挑了一块太复杂、依赖太深的部分,整场手术很可能失败,最后你得到的不是一个更健康的系统,而是比原来单体更糟的一团乱麻。

技术深入:第一个微服务该怎么选

经过很多讨论和研究后,我们列出了一套标准。这套 playbook 对任何团队都适用。

标准 1:业务关键度要低。 你的第一次微服务实践,一定是学习过程,一定会犯错。最好把这些错误,发生在“就算挂一小时也不会毁掉公司”的那块业务上。

  • 类比: 你学做菜,不会一上来就给国宴做主菜。先从沙拉开始。沙拉做坏了很糟,但不至于毁掉整顿饭。
  • 对 Dukaan 来说: 这意味着像 Payments、Orders 这种高度关键、耦合复杂的域,一开始就得排除。支付服务出 Bug,可能是用户的钱直接受损,这是生死级风险,不适合当试验田。

标准 2:依赖少而清晰。 所谓“依赖”,就是这个服务和系统其他部分之间的连接与交互。你要挑的那块,最好尽可能自成一体。

  • 类比: 把单体想成一团缠得死死的毛线球。你不要一开始就试图从正中心扯出一根线,那只会越扯越乱。你要找一根已经露在边缘、相对松的线头,从那里开始拆。
  • 对 Dukaan 来说: 我们做了代码分析。Orders 简直像噩梦,依赖 Users、Products、Payments、Logistics,牵一发动全身。而有些部分则孤立得多。

标准 3:领域边界要清楚。 你选中的服务,必须有一个单一而明确的职责。你应该能用一句话说清它到底做什么。这在软件架构里,叫 bounded context(有界上下文)

  • 类比: 在 food court 里,“披萨档”就是很清晰的 bounded context。你一听就知道它干什么。“杂项食物档”则是糟糕透顶的边界定义。

我们的决定:Storefront 服务

把整个单体按这些标准过了一遍后,一个候选人非常清晰地浮现出来:Storefront(店铺前台)

Storefront 就是卖家店铺里,顾客真正访问的那一层公共页面。我们来看它为什么完美匹配规则:

  • 业务关键度相对较低: 这听上去也许有点反直觉,但是真的。Storefront 挂掉时,顾客没法 店铺,这当然不好,但并不致命。卖家依然能登录后台、管理商品、查看已有订单。更关键的是,不会因此直接丢钱或丢数据。它是沙拉,不是主菜。
  • 依赖很少: Storefront 几乎是一个纯 read-only 服务。它的核心职责,是从数据库(尤其是我们在第 5 章里做好的读副本)取商品和店铺信息,然后把页面优雅展示出来。它和订单处理、支付确认这些复杂“写逻辑”的耦合极少。它就是毛线球边缘那根线头。
  • 领域边界非常清晰: 它做的事一句话就能说清:“展示卖家的公开只读商品目录。” 完美的 bounded context。

而且它还有一个巨大附加优势:Storefront 的 扩展曲线(scaling profile) 和其他业务完全不同。卖家后台一天可能只有几千访问,但一个热门店铺页可能一天上百万访问。把它单独剥离后,我们就可以独立扩展。比如给 Storefront 单独配 20 台高性能服务器,而卖家后台仍然维持 3 台更轻的服务器。这会直接帮我们省下大量成本。

方案定了。我们要动手术了。我们会把所有与公开前台有关的代码、模板和逻辑,仔细从主应用里抽出来,重建成一套全新、完全独立的应用:storefront-service

新问题:服务之间如何通信

就在我们做出这个决定的一刻,一个以前从未真正面对的新问题也出现了。

在单体里,通信非常简单。订单逻辑如果想拿商品价格,只要直接调用一个 getProductPrice() 函数就行。这是同一套应用内的内部对话。

但现在,我们即将拥有两套独立应用:旧单体(我们后来叫它 core-api)和新的 storefront-service。它们是两栋不同的楼。问题来了:如果卖家在 core-api 里修改了商品价格,完全独立的 storefront-service 要怎么知道这件事?

它们不能再直接函数调用了。它们必须通过网络交谈。我们需要定义一个契约,也就是正式的 API(Application Programming Interface),让服务之间能够通信。

我们的美食广场,需要一套共同语言。披萨档要有一个正式方式去找中央仓库拿食材,仓库也得有办法把价格变化告诉档口。我们已经正式踏进了分布式系统的世界。

第三部分:绞杀榕模式(Strangler Fig Pattern)

路线已经明确。我们知道要先剥离 Storefront。但怎么在不杀死活体的前提下做这场手术?“大爆炸式重写”完全不现实。停掉现有系统几个月,重新打造一版,对创业公司来说几乎等同于自杀。

我们需要一种分步替换策略:一点点替换旧系统,同时让其余部分继续运行。我们最后借助了一种非常漂亮的架构模式,它以自然界中的一种植物命名:Strangler Fig Pattern(绞杀榕模式)

技术深入:绞杀榕模式

在雨林里,绞杀榕会从一棵已经存在的大树身上开始生长。它一点点沿着树干长出根系,逐渐形成一层新的外部结构,把老树慢慢包裹起来。再过许多年,里面那棵旧树哪怕枯死腐烂,外层由绞杀榕构成的结构依然坚固完整。

这正是替换遗留系统的完美比喻。

  • 不要一口气重写整个单体。
  • 先选出一块功能(老树的一根分支)。
  • 给它新建一个独立微服务(种下一株新藤蔓)。
  • 在旧单体前面放一个“路由器”或“代理”。这才是关键。
  • 一开始,路由器仍然把所有流量导向旧单体。
  • 接着,只针对你已经重建的那块功能,逐步拦截流量并转发给新微服务。你甚至可以先只开放给内部员工,再到 1% 真实用户、10% 真实用户……
  • 当某块功能 100% 流量都已经由新服务承接时,单体里那段旧代码就等于“死了”,因为再也没有任何请求会走到它。
  • 这时你就可以安全删除那段旧代码,让单体再缩小一点。
  • 然后对下一块功能重复这个过程。

几个月甚至几年下来,新的微服务们(榕树根系)会越来越强、接手越来越多职责,而旧单体最终要么缩小成一个可管理的小核,要么彻底消失。

我们的实现:让 Nginx 来扮演“绞杀者”

我们老朋友 Nginx 再次派上了大用场。它本来就已经是我们的负载均衡器,只要再加一点配置逻辑,它就能继续兼职做那个智能路由器。

我们先把独立的 storefront-service 造出来。它是一个轻、快、职责单一的应用,只负责渲染店铺页。随后,我们在负载均衡器上的 Nginx 配置里加入了新的路由逻辑:

 
# A simplified version of our "Strangler" config
 
upstream storefront_service {
  server 10.132.8.12; # IP of the new service
}
 
upstream monolith_service {
  server 10.132.2.31;
  server 10.132.4.55;
}
 
server {
  listen 80;
  server_name dukaan.app;
 
  location / {
    # Default: send all traffic to the old monolith.
    set $target_service $monolith_service;
 
    # But if this is a store page request and a special test cookie exists...
    if ($uri ~* "^/store/" and $cookie_use_new_storefront = "true") {
      # ...then route to the new service instead.
      set $target_service $storefront_service;
    }
 
    proxy_pass http://$target_service;
    # ... other proxy settings
  }
}

这套配置给了我们极强的控制力。我们可以先给内部团队打上 use_new_storefront=true 这个 cookie,只让自己人先走新服务。这样,即便是真实生产流量,我们也能先在不暴露给真正用户的情况下做验证。等信心足够强,再把逻辑改成 10% 匿名流量走新服务、50% 再走、最终 100%。

第一次手术,很成功。storefront-service 正式上线,速度快、扩展也漂亮。更重要的是,我们验证出了一套安全、渐进、可复用的“绞杀单体”方法论。

新问题:打开了一个潘多拉魔盒

Storefront 只是一个简单、只读的服务,所以这次拆分相对温和。但它同时也把我们从单体世界里保护得太好的那层外壳彻底掀开了。

Storefront 的通信问题还算简单。卖家在单体里改商品时,我们可以顺手更新 Storefront 的数据库,或者发一个消息给它的缓存。大体上,这还是一条单向通路。

可一旦以后拆更复杂的服务,比如 Orders,会发生什么?

  • 新的 order-service 怎么去找 user-service 验证顾客信息?
  • 它怎么去和 payment-service 确认交易状态?
  • 如果支付服务已经成功扣款,但 order-service 在创建订单前崩了,该怎么处理?

这就是 distributed transaction(分布式事务),它是软件工程里最难的问题之一。多个独立服务之间,怎么保持数据一致?

我们原本那套简单的点对点通信方式,显然已经不够了。我们打开了潘多拉魔盒。从这一步开始,我们不再只是“写应用的人”,而被迫成长为真正的分布式系统工程师。

第 8 章:关键结论

  • 永远不要从零重写复杂系统。 风险太高。分阶段、逐块替换,才是更安全也更高效的道路。
  • 绞杀榕模式,是渐进替换单体的黄金标准。 它允许你在真实流量中验证新服务,把风险降到最低。
  • 现有反向代理(如 Nginx)本身就是实现 Strangler 模式的强工具。 你可以用它在新旧系统之间做精细流量路由。
  • 第一刀要选简单、低风险、边界清晰的部分。 像店铺前台、搜索这种读多写少的服务,就是理想的第一拆分对象。
  • 微服务能解决团队规模问题,但会带来更复杂的技术挑战。 服务发现、网络延迟、分布式一致性,都会成为新的必修课。

第 9 章:不可违背的承诺:用 Kafka 保证数据一致性

我们做到了。第一次成功给单体做手术。新的独立 storefront-service 已经在线,速度快、扩展也漂亮。Strangler Fig Pattern 给了我们一套未来可重复使用的拆分方法。那一刻,我们仿佛终于掌握了应对复杂度增长的钥匙。

但在分布式系统世界里,解决一个问题,常常只是掀开了底下更本质的问题。现在,服务已经各自独立,住在不同楼里。之前那套靠“缓存失效器”勉强维持的数据一致性通信方式,本质上只是拉了一根聪明但脆弱的电话线。它马上就会在真正的压力下断掉,并逼着我们承认:对微服务架构来说,通信不是附加功能,而是地基本身。

第一部分:那个不稳定的监听器

有了第一个微服务之后,日子一度很不错。Storefront 团队终于可以安心发版,不怕把支付系统带崩;core-api 团队也很开心,因为海量前台流量被分出去单独扛。分离,确实起作用了。

而把数据一致性串起来的关键,就是我们在第 7 章做的那个小 Python 服务:Cache Invalidator。它连接着主数据库,一直 LISTEN NOTIFY 消息。商品价格变了,它听到广播后,就去删掉 Redis 里对应的缓存。

最开始几周,它工作得无比顺滑。可随后,小故障开始出现。

某天下午,一位卖家打给客服,很沮丧:“我这一个小时一直在做闪购!主商品价格改来改去,但顾客看到的一直都是旧价格。我辛辛苦苦拉起来的热度全没了!”

我们立刻去看服务器日志,再看那个 Cache Invalidator 服务。它已经挂了。数据库和这段脚本之间一次短暂网络抖动,导致连接断开,而脚本本身又没有自动恢复。结果就是:它已经无声无息地死了一个多小时。那段时间里,卖家所有商品更新都没有触发缓存失效,于是整个站点到处都是脏缓存。

我们把脚本重启后,问题马上消失了。但伤害已经暴露出来:我们居然又有了一个 单点故障

识别问题:一套建立在祈祷之上的系统

这次事故把我们那套事件驱动方案的核心弱点彻底揭开了。LISTEN/NOTIFY 很聪明,是 Postgres 自带的一个小妙招,但它并不是一个真正为生产级消息传递设计的系统。

  • 它太脆弱。 正如这次事故所展示的,一旦监听服务宕机或者断连,这段时间里数据库广播出去的消息就 永久丢失 了。没有持久化。数据库只是朝空气里喊一句,没人听见,就没了。
  • 它扩不起来。 它天生更适合一对一通信。如果以后不止缓存需要感知商品变更怎么办?比如新做一个 search-service,商品信息变了,它也需要知道,以便更新搜索索引。难道我们要再写一个单独的监听脚本?那就只会继续增加复杂度和故障点。
  • 它不可观测。 我们没有办法确认一条消息到底有没有被成功处理。监听器收到了吗?Redis 的删除动作成功了吗?我们几乎是闭着眼飞。

说白了,我们的系统建立在一个美好希望上:一个脆弱的单脚本服务最好永远别挂。那不是工程,那叫祈祷。

我们意识到,是时候把这根简陋电话线,升级成真正的工业级邮政系统了。我们需要一套能保证消息送达、支持多个订阅者、并且保留完整消息历史的机制。我们需要一条真正的 message bus(消息总线)

第二部分:邮政系统 vs. 报纸

我们已经清楚自己需要一条消息总线,一种能把信息可靠地从一个服务送到另一个服务的机制。但真正做调研时,我们发现消息系统的底层哲学,其实分成两派:传统 Message Queue(消息队列) 和更现代的 Distributed Log(分布式日志)

选哪一边,将会影响未来整个架构的演进方式。

技术深入:消息队列 vs. 分布式日志

这是现代系统设计里一个非常关键的概念,我们拆开看。

1. 消息队列(如 RabbitMQ):邮政系统

传统消息队列,像邮局,或者说共享待办清单。

  • 类比: 生产者(比如主应用)写一封信:“请让店铺 456 的缓存失效。” 然后把信投进一个特定邮箱(队列)。某个专门负责这个邮箱的工人(消费者,比如 Cache Invalidator)会把信拿走、执行任务,然后 把信丢掉。任务做完,消息就消失了。

这种模式非常适合分发任务。它保证某项工作会被某个消费者完成。但它的核心特征是:消息是 短暂的(ephemeral)。一旦被消费,就没有了。

2. 分布式日志(如 Apache Kafka):报纸

分布式日志的工作方式完全不同。它不是邮局,而更像是报社。

  • 类比: 生产者(我们的主应用)像一名记者。某个商品价格变了,这名记者写了一篇短新闻:“店铺 456 的价格已变更。” 他不是把这条消息寄给某个指定的人,而是把它刊登在报纸的某个栏目里,比如 product_updates(在 Kafka 里,这就是 Topic)。
  • 现在,多个彼此独立的订阅读者,都可以看这份报纸。缓存失效服务可以订阅 product_updates 栏目;新的搜索索引服务也能订阅同一栏目去更新搜索结果;Analytics 服务同样能订阅它,分析价格变化趋势。
  • 更关键的是,当缓存失效服务读了这篇文章之后,文章 不会从报纸上消失。它仍然留在那里,供其他订阅者继续阅读。整份报纸本身,就是一份持久、完整的历史记录。

这就是当时我们的顿悟时刻。我们的需求,其实早就不只是“告诉一个服务去删缓存”这么简单。我们已经能看到未来:同一个业务事件,会有很多系统都需要响应。比如一笔订单创建后,我们可能需要:

  • 通知 shipping 服务准备履约。
  • 通知 notification 服务给顾客发邮件。
  • 通知 analytics 服务更新当天销售数据。

这种“一条消息,多方响应”的模式,跟“报纸”模型完全契合。更重要的是,它的 持久性 正好补上我们最渴望的那块可靠性。如果缓存失效服务挂了,product_updates 里的新闻不会丢,只会在报纸上安全积压。等服务恢复后,它可以从上次停下的地方继续往后读,不会漏掉任何一条更新。

答案终于确定了。我们决定把 Dukaan 的“中央神经系统”建在 Apache Kafka 上。

技术深入:Kafka 作为 Dukaan 的中央神经系统

我们决定拔掉脆弱的 LISTEN/NOTIFY,把通信机制切到 Kafka。为此,必须先理解 Kafka 的几个核心角色。

  • Producers(生产者): 所有把数据写进 Kafka Topic 的应用,都是生产者。对我们来说,最初的主要生产者是单体应用本身。卖家一旦更新商品,单体除了正常写数据库,还会额外向 Kafka 发出一条结构化小消息。
  • Topics(主题): Topic 是消息的分类或频道。我们最开始只有一个 product_updates,但很快就意识到:可以为系统里几乎每个重要事件建一个 Topic,例如 orders_placednew_users_registeredshipments_updated 等。它们逐渐变成了整个业务的官方事件史。
  • Consumers(消费者): 所有订阅 Topic 并处理消息的应用,都是消费者。新版 Cache Invalidator 就是第一个消费者。它订阅 product_updates。Kafka 还有一个关键概念,叫 consumer group(消费者组),允许你同时跑多个相同服务实例,共同消费同一个 Topic,用来做扩容和故障容忍。
  • Brokers(代理节点): 一个 Broker,就是一台 Kafka 服务器。多个 Broker 组成一个 Kafka 集群。这也是 Kafka 能够高可靠的原因。如果一台 Broker 挂了,其他节点依然能继续“出报纸”。

新地基选好了。下一步,就是把旧的“摇摇欲坠监听器”彻底退休,让单体正式变成一个全职记者,把每个重要事件发布到这份不会消失的中央报纸上。

第三部分:落地,以及新的现实

蓝图已经有了:用 Kafka 驱动一个真正可靠的中央神经系统,取代原来那根脆弱电话线。接下来就是“开刀”:把旧监听器拔掉,让整个应用接进新的消息总线。

技术深入:具体实现

步骤 1:搭 Kafka 集群

Kafka 本身是个复杂的分布式系统。长期来看,我们终归会学会自己管理它。但在最开始,我们的首要目标是速度与可靠性,而不是立刻变成 Kafka 专家。于是,我们做了一个很实用的决定:先上 托管 Kafka 服务(managed Kafka)

像 Confluent Cloud、Aiven 这样的服务,可以让你点几下就拉起一个生产级、具备容错能力的 Kafka 集群。备份、监控、故障恢复这些脏活都由平台帮你做,代价是一笔月费。这个阶段我们学到一个非常重要的经验:在早期采用复杂技术时,优先借助托管服务。 把团队注意力留给“怎么用它解决业务问题”,而不是“怎么先把它养活”。不到一小时,我们的 Kafka “印报机”就上线了。

步骤 2:借助 Debezium 做 CDC(Change Data Capture)

然后问题来了:让单体“往报纸上投稿”,到底要怎么做?

最天真的方案,是在 Django 代码库里到处塞 kafka_producer.send()。每个 product.save() 后面都跟一行发消息代码。这种方式几乎一定会演变成噩梦:代码被污染,逻辑散落在四处,而且以后新功能开发时,工程师很容易忘记补上这一步,最终造成数据不一致。

所以我们用了一个很神奇的工具:Debezium

Debezium 是一个开源的 CDC(Change Data Capture)平台。继续用图书馆类比来理解它:

  • 问题: 怎么知道主手稿什么时候被改了?你当然可以要求作者每写一笔都大喊一声“我改了!”,但他总会忘。
  • Debezium 的办法: 它更像一台架在主手稿上方的高精度扫描仪。它不盯作者本人,而是盯“手稿的变化”本身。它会读取数据库事务日志(WAL)里刚落下的“墨水”。哪怕只是一个字被改动,它都能立刻看见、捕获、格式化成一条标准消息,并把它发到正确的 Kafka Topic 里。

这对我们来说,几乎是革命性的。因为这意味着:我们的单体应用 甚至不需要知道 Kafka 的存在。它继续像以前一样,只负责把数据老老实实写进 PostgreSQL。Debezium 则在后台独立地盯着 WAL,可靠地把所有数据变化发布到 Kafka。应用层和消息系统彻底解耦。更干净,也更可靠,而且不存在“开发者忘了补发消息”的问题。

步骤 3:重写消费者

最后一步,是升级 Cache Invalidator 服务。这部分反而相对简单。我们删掉了之前那套连接 Postgres、长期 LISTEN 频道的代码,换成标准、成熟的 Kafka consumer 库。

新服务的工作就非常清楚:订阅 product_updates 这个由 Debezium 持续填充的 Topic。每来一条消息,解析其中的 store_id,然后向 Redis 发出 redis_client.del()

更棒的是,借助 Kafka 的消费者组,我们现在可以同时跑两到三个同样的 Invalidator 实例。如果其中一个挂了,另一个会无缝接上继续消费。我们之前那个单点故障,终于被消掉了。

新的蓝图

我们的数据一致性架构,终于变得既优雅又可扩展,而且足够抗打。

新的链路如下:

Monolith App -> Master DB(WAL)-> Debezium -> Kafka Topic -> Consumer Service(s) -> Redis

应用只负责写数据库,而系统的其他部分围绕这个“事件”自然响应。Kafka 和 Debezium 则扮演了不可摧毁的传输层。

新问题:运维复杂度

我们确实解决了数据一致性问题,系统也比过去可靠得多。但新的架构图里,也多出来了一个又大又复杂的方框。我们把一个有状态、分布式的复杂系统放进了自己架构的核心位置。

Kafka 很强,但它不是魔法。它是一套需要认真维护和监控的基础设施。于是我们又有了一串全新的问题要关心:

  • Kafka Broker 都健康吗?
  • 消息是否在被正确生产、正确消费?
  • 消费者是否出现了积压(lag)?
  • Topic 是否正在把 Broker 的磁盘一点点打满?

我们把旧系统的“脆弱”,换成了新系统的 运维复杂度。像是从简陋小房子,搬进了一座自带发电厂的大庄园。发电厂当然可靠得多,但它需要真正懂这套系统的工程师来长期维护。这就是进入微服务世界的入场费。

第 9 章:关键结论

  • 简陋的消息机制,很容易变成单点故障。 如果你想要真正可靠的服务间通信,需要像 Kafka 这样可持久化的事件日志。
  • CDC(Change Data Capture)是一种非常强的模式。 借助 Debezium 直接从数据库日志流出事件,比在应用代码里到处手工发消息更可靠,也更解耦。
  • 面对复杂基础设施,早期优先借助托管服务。 把精力集中在“用技术解决业务”,而不是一开始就陷入“如何把技术本身维护好”。
  • 每往技术栈里加一个强工具,都会增加运维复杂度。 你必须同步投入监控、知识和流程,才能把它真正驾驭好。
  • 有了中央神经系统,你才算有了真正的地基。 稳定可靠的事件流,是后续继续拆单体、做成真正微服务架构的关键前提。

第 10 章:集装箱革命:Docker 入门

我们的架构越来越强了。自适应扩展的 Web 服务器舰队、具备容错能力的数据库、再加上用 Kafka 连接各服务的数据神经系统。按图纸看,这已经像一家现代、可扩展技术公司的标准形态。

但落到日常实践里,一切仍然很乱。新的摩擦开始拖慢我们,问题不来自系统崩溃,而来自那些极小却令人抓狂的不一致。与此同时,我们的 AWS 月账单也在一路爬升,高到让 Suumit 开始坐不住。我们造出了一台强大的引擎,却依旧效率不高,还非常容易出人为差错。

这一章,要讲的是我们如何用一项改变了整个技术行业的技术,解决这些问题:容器(containers)

第一部分:双头恶龙

我们当时面对的是一条双头龙。一头叫“不一致”,专门折磨开发者;另一头叫“低效率”,安静却持续地吃掉我们的钱。

第一颗龙头:“可我这边明明能跑啊!”

开发团队不断变大。后端、前端、数据工程师都开始同时推进不同功能。我们有位非常优秀的新工程师,我们叫她 Anjali。她负责做一个给卖家生成 PDF 发票的功能。

她找到一个很不错的 Python 库 reportlab-ng,用它生成发票非常方便。她在自己的 MacBook 上装好依赖,写完代码、反复测试,生成出来的 PDF 漂亮又规整。“完全没问题!” 她说。代码 review 通过后,功能被部署到了 Staging。

一个小时后,Bug 报告来了。Staging 上 PDF 一塌糊涂,文字错位、排版全乱。

Anjali 完全懵了。她拉下和 Staging 一模一样的代码,在自己电脑上重跑,PDF 又是完美的。她再推回去,Staging 还是坏的。于是诞生了开发者最崩溃的一句话:

“我也不知道为什么……它在我机器上能跑。”

几个小时的痛苦排查后,我们终于找到原因:Ubuntu 服务器上安装的系统字体库,和 Anjali MacBook 上的字体库略有差异。而 PDF 库恰好依赖这些系统字体。这个微小差异,就足以让最终格式完全跑偏。

这种 environment drift(环境漂移) 几乎在不停发生。有人本地用 Python 3.9.6,服务器是 3.9.2;有人本地依赖库版本更新了一点点。每一个差异,都像埋在路上的地雷。它不断制造“开发”和“运维”之间的摩擦,浪费大量时间在毫无生产力的排查上。

第二颗龙头:AWS 账单失控

与此同时,在一次每周预算会议上,Suumit 找我聊账单,语气明显不安。

“Subhash,我看 AWS 账单,太夸张了。上面写着我们跑了 20 台应用服务器。但流量图显示,大部分时间好像只有一半机器在忙。那另外 10 台在干嘛?”

他是对的。我们的横向扩展确实生效了,但方式太低效。每台服务器都是一整个完整虚拟机(EC2 实例),有自己单独的操作系统,仅仅“活着”就要吃掉一部分 CPU 和 RAM。应用进程再跑在上面。为了安全起见,我们通常只在一台机器上放一两个服务,怕它们互相干扰。

结果就是:我们手里有一整队长期利用率很低的服务器。Server A 也许 CPU 只用到 20%,Server B 也才 30%,但我们没法轻易把负载合并。我们像是为整辆车付了钱,却只坐了一个座位。每个月,这种低效都在悄悄烧掉几千美元。

识别问题:不一致与低效率,本质同源

这条双头龙的两个脑袋,其实都喂养于同一个根本问题:我们打包和运行软件的方式。

  • 不一致: 代码在开发者笔记本上和在服务器上的行为不一样,因为底层环境不一样。
  • 低效率: 我们的服务器像一栋栋大房子,明明很多房间空着,但我们还是只敢让一个租户住一间,因为担心不同住户之间互相打架。

我们需要一种方式,把代码以及它依赖的一切都装进一个独立、稳定、可复制的盒子里。还需要一种方式,让很多这种“小盒子”能在同一台大机器上并排运行,而且彼此不冲突。我们需要软件世界里的标准化集装箱。我们需要 Docker

第二部分:魔法盒子

我们当时正在同时面对两个问题:环境不一致,以及服务器利用率太低。但我们隐约感觉,这两个问题都能被一件武器同时解决。于是我们开始疯狂读博客、看演讲,而一个名字一遍又一遍地出现:Docker

一开始我们没完全懂。听上去很抽象,也很复杂。直到我们遇到一个比喻,它几乎一下子把一切点亮了。理解容器革命,关键就在这个比喻里。

技术深入:集装箱比喻

在 20 世纪 60 年代之前,海运几乎是一场混乱噩梦。货舱里装满各种尺寸的桶、麻袋、木箱和板条箱。装卸全靠人工,既慢、又贵,还经常丢货、损坏或被盗。

后来,标准化集装箱被发明出来。这个看似普通的金属盒子,彻底改变了世界。无论你运的是香蕉、电子产品还是汽车,都统一装进相同尺寸的箱子。吊机、火车和卡车都不再关心箱子里装了什么,它们只需要知道:这是个标准箱,可以搬。

Docker,就是软件世界里的标准化集装箱。

在 Docker 出现之前,我们的应用像极了那个杂乱无章的货舱。为了让它在一台服务器上跑起来,我们得手动装对的 Python 版本、对的系统库、对的应用代码,以及一堆其他依赖。每台服务器都略有不同,就像每个木箱、麻袋、桶的形状都不一样。

Docker 让我们能够把整个应用——包括代码、指定版本的 Python、所需依赖库,甚至部分必要的操作系统环境——一起打包进一个整齐标准的盒子里,这个盒子就叫 container(容器)

于是我们可以把这个容器拿起来,扔到任何安装了 Docker 的机器上跑:Anjali 的 MacBook、Staging 服务器、Production 服务器……结果都一样。“它在我机器上能跑” 这个问题,从根上就被消灭了。容器本身,就是那台机器。

Docker 三要素:蓝图、盒子与建筑

要理解 Docker,其实只需要掌握 3 个核心概念:

  • Dockerfile(蓝图): Dockerfile 是一个纯文本文件,里面写着如何构建容器镜像的逐步说明。它就像 IKEA 说明书。
    • 第 1 步:从一个基础操作系统开始(比如 Ubuntu 20.04)。
    • 第 2 步:安装 Python 3.9.6。
    • 第 3 步:把应用代码拷进去。
    • 第 4 步:执行 pip install -r requirements.txt 安装依赖。
    • 第 5 步:定义容器启动时该跑什么命令。
  • Image(镜像):扁平包装盒): 你用 Dockerfile 跑 docker build 后,Docker 会按说明书执行,并产出一个 Image。它就是一个已经打包好、可运输,但尚未运行的成品盒子。像还没组装的 IKEA 家具包装箱,里面零件齐全,随时可被展开。
  • Container(容器):组装好的书架): 当你对镜像执行 docker run 时,得到的就是 Container。它是一个真正运行中的镜像实例。也就是那套已经装起来、现在在你客厅里摆着书的书架。神奇之处在于:同一个镜像,可以拉出无数个完全一致、彼此隔离的容器。

技术深入:虚拟机 vs. 容器

这时你也许会想:“这听起来不就像 Virtual Machine(虚拟机)吗?” 很多人第一次都会混淆,但两者的差别,正是容器能让我们把成本打下来的关键。

虚拟机(VM)像独栋房子。 VM 会模拟一整台完整电脑,连硬件都一并虚拟。每一个 VM 里,都要跑一整套自己的 Guest OS。

  • 类比: 如果物理服务器是一块地皮,那么一台 VM 就是一栋完整独立的房子。有自己的地基、墙、水管和电路(Guest OS),应用只是摆在里面的家具。它隔离性很强,但为了放一套家具,先盖一整栋房子,代价实在太重。

容器像公寓。 容器不模拟硬件,它虚拟的是操作系统层。所有容器共享宿主机的 OS kernel。

  • 类比: 如果服务器是一块地,Docker 就是一栋 公寓楼。所有公寓共享同一套地基和管道(宿主 OS kernel),所以效率高得多。但每套公寓也有自己的门、自己的墙、自己的家具和摆设(应用及依赖),彼此依然是隔离的。

因为容器比 VM 轻得多,所以它启动速度是秒级,不是分钟级。更重要的是,对于我们那张越来越夸张的 AWS 账单来说,你能在一栋楼里塞进远比“几栋独立房子”更多的住户。这就是所谓 density(密度)

这意味着,一台原来只能放一两个应用进程的大服务器,现在可以同时跑 10、20、甚至 50 个彼此隔离的容器。服务器上的 CPU 和 RAM 能被吃得更满,所需服务器数量大幅减少,成本也会跟着大跌。

理论我们懂了。集装箱,是未来。接下来就是亲手装出第一个盒子。

第三部分:装出我们的第一个盒子

Docker 的理论一旦想通,简直像顿悟。它同时承诺解决两个长期困扰我们的问题:开发环境不一致,以及服务器严重浪费。现在,是时候从理论进入实践了。我们要写出第一个 Dockerfile,把第一个应用真正装进标准化容器里。

技术深入:写第一个 Dockerfile

我们决定先容器化最关键的应用:那个 Python / Django 单体。Dockerfile 就是一个名叫 Dockerfile 的普通文本文件,放在代码仓库里,像配方一样描述如何构建镜像。我们的文件大概长这样:

  # Step 1: Start from an official, trusted base image.
  # We're using a specific version of Python on a lean version of Debian.
  FROM python:3.9-slim
 
  # Set an environment variable so Python runs in an optimized mode.
  ENV PYTHONUNBUFFERED 1
 
  # Set the working directory inside the container.
  WORKDIR /app
 
  # Copy dependency list first.
  COPY requirements.txt .
 
  # Step 2: Install dependencies.
  RUN pip install --no-cache-dir -r requirements.txt
 
  # Step 3: Copy application code.
  COPY . .
 
  # Step 4: Start Gunicorn when container boots.
  CMD ["gunicorn", "--bind", "0.0.0.0:8000", "dukaan.wsgi:application"]

这个小小的文本文件,就是我们的蓝图。它对应用运行环境给出了精确、可重复、可版本化的定义。没有模糊空间。Python 的具体版本、依赖列表,全都被写死进了构建过程里。

新的工作流:交付镜像,而不是交付代码

Docker 不只是一个运行方式,它实际上改写了我们的开发与部署流程。

  • 旧模式: 开发者在本机写代码,本机有自己独特环境;代码推到 GitHub;服务器再去拉代码,并试图在 它自己 那套独特环境里把它跑起来。Bug,就躲在两种环境之间的缝里。
  • 新模式: 一切围绕“镜像”来运转:
    • 开发者写代码,也维护 Dockerfile。
    • 在自己笔记本上执行 docker build .,构建出一个完整打包的盒子。
    • 再在本机执行 docker run <image_name>,跑的就是将来线上几乎一模一样的东西。如果这里能工作,我们对“它到处都能工作”就有了极高把握。
    • 开发者不只是推代码,还要把构建好的 image 推到中央 Container Registry(比如 Docker Hub 或 Amazon ECR)。Registry 就像镜像仓库。
    • 此后,Staging 和 Production 不再直接从 GitHub 拉代码,而是从 Registry 拉那份预先构建好的、已经验证过的镜像来运行。

这里最根本的变化是:我们不再交付代码,而开始交付镜像。 环境和应用被捆成了一个整体。“它在我机器上能跑” 这个问题,从工作流层面被完全判了死刑。只要它能在开发者本地容器里跑,线上跑的也是同一个容器。

影响:大幅削减成本

第二个同样剧烈的变化,体现在服务器账单上。我们开始把服务器视作通用的“容器宿主机”。一台更大的 EC2 实例,不再只跑一两个应用,而是可以同时承载多个彼此完全隔离的服务容器。例如,同一台机器上同时运行:

  • 5 个我们的主 Django 单体容器。
  • 10 个高流量 storefront-service 容器。
  • 2 个缓存失效器容器。
  • 3 个图片缩放服务容器。

这些容器都跑在同一台宿主机上,共享内核、彼此隔离。服务器利用率一下子飞了起来。我们不再为 20 栋半空的房子付钱,而是改成只为 5 栋住满人的公寓楼付钱。最终,我们把应用服务器舰队缩减了 60% 以上,成本下降既直接又巨大。

新问题:没有指挥家的乐团

环境不一致的问题解决了,资源浪费的问题也解决了。但照例,每解决一个问题,新的麻烦就会浮现。

Docker 是一个很棒的工具,它非常擅长构建、发布、运行 单个 容器。但我们的生产环境现在已经是:几百个容器,分布在多台服务器上共同组成的复杂系统。新的问题很自然地冒出来:

  • 某台服务器挂了,它上面那 50 个容器怎么办?如何把它们自动搬到健康服务器上?
  • 某个容器自己崩了,谁负责自动重启它?
  • 我们发布新版本时,如何把 100 个旧容器平滑替换成 100 个新容器,而且 不产生停机
  • storefront-service 的某个容器,怎么找到 core-api 的某个容器?它们可能此刻还在不同宿主机上跑来跑去。

我们现在拥有的是一支庞大的容器乐团,但管理方式仍然是人工逐个告诉每位乐手什么时候上台、什么时候下台。这会变成一种全新层面的混乱。

我们需要一个指挥家。需要一套系统,能够在规模化场景下自动管理容器的完整生命周期。我们需要 container orchestrator(容器编排器)。也就是下一步要学的:Kubernetes

第 10 章:关键结论

  • Docker 一次性解决了两头龙:环境不一致(“我机器上能跑”)与服务器低利用率。
  • Dockerfile 是运行环境的蓝图。 它把依赖和运行条件写死,保证应用“到哪都按同一种方式跑”。
  • 把思维从“交付代码”切换为“交付镜像”。 镜像是应用和其运行环境的一体化、不可变打包产物。
  • 容器可以显著提高服务器密度。 在单台宿主机上运行多个隔离容器,能大幅降低基础设施成本。
  • 管理几个容器很轻松,管理几百个会变成噩梦。 一旦全面进入容器世界,你迟早会需要一个容器编排器。

第 11 章:聪明的店员:打造世界级搜索

我们的平台每天都在变得更强。稳定性、扩展能力、复杂度管理,我们都在逐步拿下。整个系统看起来已经很现代、很有章法。但再强的引擎,如果方向盘坏了,也毫无意义。对用户来说,搜索框——电商里最关键的入口之一——其实一直是坏的。

这不是服务器起火的故事,而是用户挫败感与销量流失的故事。也是我们如何把一个“笨搜索框”升级成智能发现引擎,并最终意识到:伟大的平台不仅在于你能建出什么,还在于你能不能帮用户找到它。

第一部分:坏搜索带来的挫败感

这个问题不是先出现在监控报警里,而是先出现在一封带着不满情绪的邮件里。邮件来自我们增长最快的卖家之一。他在 Dukaan 上经营一家大型线上鞋店,正在为 Diwali 节日大促跑一轮重要投放。广告流量很高,可销售却低得离谱。

“我的顾客一直在抱怨,”他写道,“他们搜 ‘running shoes’,结果显示 0 个。我明明有 200 多种跑鞋!搜 ‘sneakers’ 也找不到。我的爆款商品叫 Nike Air Zoom Pegasus 38 Men's Running Shoe,但如果用户输入 Nike Pegasus running shoes,它也不会出来。这简直是灾难。你们让我在丢钱。”

Suumit 立刻就看到了业务冲击。我们赶紧去翻分析数据。结果很残酷:使用过搜索框的用户,转化率远低于手动浏览商品的用户。搜索框不仅没帮用户找到商品,反而在不断把他们带向死胡同。

识别问题:一个“很笨”的店员

问题根源,在于我们当初为了快速做 MVP,采用了一种极其朴素、也极其天真的搜索实现方式。用户输入关键词时,我们只是对 PostgreSQL 跑一条非常基础的 SQL:

SELECT * FROM products WHERE name ILIKE '%running shoes%';

Postgres 里的 ILIKE 本质上是大小写不敏感的子串匹配。它问数据库的是:“产品名里有没有按这个顺序,逐字出现 running shoes 这串字符?”

对一个用于记录交易的数据库来说,这样的查询完全合理;但对一个想找商品的顾客来说,这种搜索方式愚蠢得近乎残忍。

  • 类比: 我们的 PostgreSQL 搜索,就像图书馆里一个过于死板、也有点笨的管理员。你问他找一本关于 “American Cars” 的书,他只会沿着书架去看每本书名,只有书名里 完整包含 这个短语的才会拿给你。一本叫 The Automobiles of the USA 的书,或者 A History of Ford and General Motors,他都会直接忽略。因为他没有任何上下文理解能力。

技术深入:为什么简单数据库搜索不适合电商

这个“笨店员”在 4 个关键点上辜负了用户:

  • 它要求你输入得非常完美。 只有精确子串、顺序也必须一致。一个名叫 Nike Running Shoe for Men 的商品,搜索 Nike shoes for running 时就找不到。词明明都在,但顺序不对。用户搜复数 shoes,也可能找不到标题里用单数 Shoe 的商品。
  • 它没有上下文理解。 它对人类语言一无所知。它不知道 “sneakers” 和 “trainers” 都可以表示跑鞋,不知道 “run” / “runs” / “running” 是同一个词根的不同形式(这叫 stemming)。
  • 它完全不容错。 用户如果在手机上不小心输入成 runing shoesILIKE 就会直接返回空。对任何面向真实用户的系统来说,这种零容错都是致命缺陷。
  • 它不会按相关性排序。 就算匹配出来了,它也只是按数据库默认顺序返回(通常是录入时间)。一个描述里提到 “inspired by vintage running shoes” 的 T 恤,可能和真正热销跑鞋一样被摆到前面。最相关的结果,反而可能沉到底部。

更糟的是,这种 LIKE '%...%' 查询对数据库来说也非常慢。它很难高效利用标准索引,往往会触发 full table scan(全表扫描),也就是为了找一个词,数据库得把每件商品的名字都读一遍。随着卖家商品数从几百涨到几千、几万,这样的搜索只会越来越慢,还会额外拖累我们的读副本。

于是我们得出一个很清晰的结论:这个搜索功能,不是“再打磨一下”就能变好,它从方向上就错了。我们不是要把这个笨店员训练得稍微聪明点,而是要直接换掉他。我们需要聘请一个真正的天才。

第二部分:天才店员

原来的搜索系统已经成了负资产。靠几条更花哨的 SQL 技巧修修补补没有意义,因为地基本身就错了。我们要做的不是继续训练那个笨店员,而是辞退他,再请来一个世界级研究助理。深入研究搜索技术之后,答案非常清楚:我们需要 Elasticsearch

Elasticsearch 是一个开源、分布式的搜索与分析引擎。它不是 PostgreSQL 的替代品,而是一种高度专用的工具,像一辆 Formula 1 赛车,从设计之初就只有一个目标:在海量文本里,以毫秒级速度进行极其聪明的搜索。

技术深入:Elasticsearch 的超能力

想理解 Elasticsearch 为什么如此适合电商搜索,必须先理解它的几个核心超能力。

  • 类比: 如果 PostgreSQL 是我们那位谨慎、严谨、负责看守每一本原始藏书的图书管理员,那么 Elasticsearch 就像一位天才、多语种、把每一本书都读透了的研究助理。他还为每个概念、关键词、人物都建立了一套巨大的交叉索引。你问管理员一本书的确切书名,他能帮你找;而你问研究助理“有没有关于未来世界里悲伤机器人的书”,他会立刻给你一份按相关度排序好的结果清单。

它的魔法主要体现在这几个地方:

超能力 1:倒排索引(Inverted Index),速度的秘密

传统数据库搜索一个词时,通常要把所有商品描述一条条扫过去。Elasticsearch 的思路刚好反过来。它先构建一个 倒排索引

在你真正发起搜索之前,Elasticsearch 就会先把所有商品数据读一遍,像教材索引一样建立一张映射表:每个词,对应哪些商品包含它。

"nike" -> [Product 1, Product 5, Product 88]
"running" -> [Product 1, Product 7, Product 23, Product 88]
"shoes" -> [Product 1, Product 23, Product 55, Product 88]

当用户搜索 nike running shoes 时,它根本不需要再去扫商品全文。它只是查索引,取出 nikerunningshoes 这 3 份列表,然后快速求交集。于是几毫秒内就知道 Product 1 和 Product 88 是最匹配结果。这也是它能在数百万文档里依然飞快返回结果的根本原因。

超能力 2:高级文本分析,真正理解语言

Elasticsearch 不只是“存文本”,它会 分析文本。比如当我们把商品名 Nike's Best Running Shoes 送进去建立索引时,它会先做一串处理:

  • Tokenization(分词): 拆成独立词元:Nike'sBestRunningShoes
  • Normalization(标准化): 全部转小写:nike'sbestrunningshoes
  • Stemming / Lemmatization(词干提取 / 词形还原): Running 会变成 runShoes 变成 shoe。这意味着用户搜 run shoe,也能命中 Running Shoes。光这一点,就解决了我们大量搜索失败问题。
  • 同义词支持(Synonym Support): 我们甚至可以给 Elasticsearch 提供一份自定义词典,告诉它 sneakerstrainerskicks 都可以当作 shoes 的同义词。于是用户搜 Nike sneakers 时,也能精准命中我们那双 Nike Running Shoes

超能力 3:相关性评分(Relevance Scoring)

这是“好搜索”和“伟大搜索”的真正分水岭。Elasticsearch 不只是找到结果,它会 按相关性排序。它会用一套复杂算法(例如 BM25),像一个懂销售的高手一样判断:命中 product_title 远比命中 product_description 重要;稀有关键词比常见词更值钱。最终,用户最可能购买的商品,总是会排在最前面。

超能力 4:模糊匹配(Fuzzy Matching)

最后,Elasticsearch 是为真实人类设计的。它能容忍错别字。用户即便输入 runing sheos,系统也可以通过词编辑距离判断,大概率他其实想找的是 running shoes,然后把正确结果给出来。对用手机飞快输入的用户来说,这一点改变巨大。

选择已经没有悬念。Elasticsearch 就是我们要请来的那个天才店员。接下来,我们决定专门为搜索再建一个独立微服务。

剩下唯一的问题,和前面已经很熟悉了:怎样让这个新的、专门化的引擎,和主数据库始终同步?这次的答案,仍然来自我们已经打造出的中央神经系统:Kafka

.

第三部分:实现方式(由 Kafka 驱动)

Elasticsearch 这个“天才店员”已经找到了。最后一步,是给它搭办公室,并且确保它能实时收到主图书馆里的每一次更新。我们需要让搜索索引和主数据库始终保持同步。

如果是前几章,这件事会是一条脆弱、定制、极其容易出问题的数据同步链路。但现在,因为我们已经有了 Kafka 中央神经系统,这件事突然变得优雅又简单。

架构:一个新的监听者

我们的 Kafka + Debezium 体系,本来就已经是整个 Dukaan 平台上所有数据变化的唯一事实流。我们不需要新造一条数据管道,只需要让搜索服务订阅现成的数据源就够了。

  • 我们创建了一个新的独立微服务,叫 search-service。它唯一的职责,就是管理 Elasticsearch 集群,并对外暴露搜索 API 给 Storefront 使用。
  • 然后把它注册成 Kafka 已有 Topic 的另一个 consumer(消费者)

整个设计就这么简单。像是在已经建好的电网里,再插一个新电器。

数据流:一个事件,触发多方行动

商品更新后的完整链路,成了“解耦事件驱动架构”有多强的最佳示范:

  • 卖家新增或修改一个商品。改动首先落在主 PostgreSQL 数据库(位于 Mumbai)。
  • Debezium 这位 CDC 代理,一直盯着数据库事务日志。它会在第一时间看到这次改动。
  • Debezium 把包含完整商品信息的一条结构化消息,写进 Kafkaproduct_updates Topic。
  • 现在,两个互不耦合、却都订阅了同一个 Topic 的服务,会同时行动:
    • 消费者 1:缓存失效器。 它读取消息,拿到 store_id,然后向 Redis 发出删除命令,把这家店旧的缓存直接抹掉。
    • 消费者 2:新的搜索服务。 它读取 同一条消息,解析出完整商品数据,把它转成 JSON 文档,送进 Elasticsearch 集群建立 / 更新索引。

这套系统漂亮得令人着迷。处理卖家操作的主单体应用,对“缓存”和“搜索引擎”的存在 一无所知。它只需要把数据存进数据库。至于下游要怎么响应,不管是缓存、搜索,还是未来的分析系统、推荐系统,都可以独立订阅这条事件流,自行做出反应。我们甚至可以在不改主应用代码的前提下,基于同一条事件流不断长出新能力。

最终,Dukaan 的搜索体验实现了质变:它开始容错拼写、理解上下文、按相关性高低返回结果,而且都是毫秒级。卖家兴奋极了,因为他们的商品终于真正“能被找到”,而来自搜索框的销售额也肉眼可见地增长。我们把原本的致命短板,变成了一项极强的竞争力。

第 11 章:关键结论

  • 仅靠简单数据库查询(比如 SQL LIKE)并不能替代真正的搜索引擎。对电商体验来说,你需要的是专门工具。
  • Elasticsearch 提供了电商搜索最关键的能力:相关性排序、拼写容错、语言分析。这些能力直接决定转化率和用户体验。
  • 事件驱动架构 加上 Kafka,是同步数据库、缓存、搜索索引等多系统状态的一种极其优雅且强大的方案。
  • “一条事件、多方消费(fan-out)” 是构建可扩展、低耦合微服务架构的核心模式之一。

第 12 章:配送小哥:静态资源的 CDN

随着平台越做越大,我们的野心也在变化。我们已经不再只是印度本土平台,越来越多海外卖家开始自然发现我们。东南亚、欧洲、南美,越来越多店铺在不同国家冒出来。我们开始真正面对“全球用户”这件事。

而全球化,也暴露出架构里的另一个薄弱点。这个问题跟 CPU、内存、代码都没太大关系,它直接来自一个朴素而无可逃避的物理事实:光速。我们的服务器在 Mumbai,但用户遍布世界。对这些远方用户来说,网站实在太慢。这一章讲的,就是我们如何通过建设自己的全球配送网络,解决这个性能问题。

第一部分:漫长的旅程

最先发来抱怨的是一位来自巴西的卖家。她经营一家手工艺品小店,很喜欢 Dukaan 的简单。但她遇到了问题。“你们的平台很好,”她发给客服,“可我的顾客在 São Paulo 打开商品图时,图片加载特别慢。他们以为是我网站坏了。”

几乎同一时间,Suumit 也把当月 AWS 账单发给我,在其中一行上标了红色:Data Transfer Out from EC2。这部分成本涨得比服务器本身还快。

我们再次遇到了一条双头龙。一边是海外用户体验差,一边是一行神秘却越来越夸张的 AWS 支出项。后来我们才发现,这两个问题其实根本就是一个问题的两个面。

识别问题:那座 Mumbai 图书馆

我们的全部基础设施——应用服务器、负载均衡器、数据库——都集中在一个地方:AWS 的 Mumbai 区域。这对印度用户很友好,但对其他地区极不友好。

想象一张商品图片的一次旅程:

  • São Paulo 的卖家上传一张 2MB 高清商品图。它要先沿着海底光缆,跨越 14,000 多公里,传到 Mumbai 服务器存储。
  • São Paulo 的顾客访问她的店铺,浏览器请求这张图片。
  • 请求从 São Paulo 走 14,000 公里抵达 Mumbai。
  • 服务器找到这张 2MB 图片,再把它原路发回浏览器。

这个往返旅程受制于光速本身,光这段物理路程,就会给每一张图片的加载增加数秒。也正因此,巴西卖家的店铺对巴西顾客来说会这么慢。

这也顺便解释了那条 AWS 账单。所谓 “Data Transfer Out”,本质上就是 AWS 按流量向公网“寄数据”收的邮费。每次我们给用户发一张图片,都得支付一点费用。而当你面对的是全球用户、而且每天在发送几百万张大图时,这笔数字邮资会迅速变成巨额成本。

我们的服务器,本质上像一座唯一的中央图书馆,设在 Mumbai,保存着全世界的相册。巴西有人要看一张照片,我们就真的把照片从 Mumbai 邮寄过去。慢,而且贵。

技术深入:什么是 CDN?

这个问题的标准答案,就是 CDN(Content Delivery Network,内容分发网络)

CDN 是一个全球服务器网络,专门负责尽可能快地把静态内容(图片、视频、CSS、JavaScript 文件等)送到用户手上。

  • 类比: 别再只有一座 Mumbai 总图书馆。想象一下,现在你有一个全球连锁图书馆系统:Mumbai 是总馆,São Paulo、London、Singapore、Tokyo 都有分馆。当新的相册发布到 Mumbai 总馆(也就是我们的“源站”)后,它会自动复制到世界各地的分馆里。

这样一来,当 São Paulo 的顾客想看照片时:

  • 请求不再去 Mumbai,而是自动路由到最近的“分馆”,也就是 CDN 在 São Paulo 的 edge location(边缘节点)
  • 这个节点已经有一份图片副本,因此它可以直接把图片秒级返回给用户。

这叫 caching at the edge(边缘缓存)。某个地区第一次有人请求某文件时,边缘节点会回源到 Mumbai,拿一份副本并存下来。之后该地区其他用户再请求,就直接从本地副本返回。

CDN 一次性解决我们两个问题:

  • 速度: 静态文件从离用户更近的节点返回,加载时延可以从数秒直接降到毫秒级。巴西用户的体验可以接近印度用户。
  • 成本: 数据改由 CDN 网络分发,而不是由我们的应用服务器直接出网。CDN 服务商本身以更低成本采购带宽,因此单位流量价格远低于我们自己从 EC2 往外发。于是 “Data Transfer Out” 会大幅下降。

理论已经理解了。接下来,就是要接入一家全球图书馆连锁,并让应用学会使用它。

第二部分:全球配送网络

CDN 的理论非常优雅,它同时切中了我们的全球速度问题和数据传输成本问题。接下来要做的,就是实际接入。

考虑到我们当时整套基础设施都跑在 AWS 上,最自然的选择当然是它自家的 CDN:Amazon CloudFront。这样,我们可以在同一个控制台里管理服务器、数据库和 CDN,整体复杂度也最低。

技术深入:搭 AWS CloudFront

接入 CDN,一般分成 3 步:先准备静态文件的统一存储源,再配置 CDN 从那里取文件,最后让应用把资源地址改成 CDN 链接。

步骤 1:源站(Origin)- Amazon S3

第一步,我们需要给所有静态文件准备一个“总馆”。继续让应用服务器自己直接保存图片,效率太低了。所以我们改用了 Amazon S3(Simple Storage Service)

S3 本质上就是一块超大、极度耐久、价格便宜的云硬盘,而且它专门为文件存储设计。于是我们把应用逻辑改掉:卖家上传商品图时,不再落到本地磁盘,而是由 Django 直接把文件上传到一个专用 S3 bucket,例如 dukaan-product-images。这个 bucket 位于 Mumbai,成为所有用户上传图片的唯一真相源。

步骤 2:创建 CloudFront Distribution

有了源站之后,我们在 AWS CloudFront 控制台里创建一个新的 “Distribution”。这其实就是一组 CDN 规则。整个配置过程就是一个简单向导:

  • Origin Domain: 告诉 CloudFront 原始文件存在哪。我们直接指向刚创建的 S3 bucket。
  • Cache Behavior: 设置边缘节点的缓存策略。我们把 TTL 设成 24 小时,也就是一旦某个边缘节点缓存了某张图片,接下来 24 小时内都会直接用本地副本,之后才会再回到 Mumbai 检查有没有更新。
  • Deployment: 点击 “Create Distribution”。接下来 AWS 会花大概 15 分钟,把配置同步到它遍布全球的数百个边缘节点。

部署完成后,AWS 会给你一个唯一域名,类似 d123xyzabcdef.cloudfront.net。这就成了我们全球配送网络的公开地址。

步骤 3:修改应用链接

最后一步,是让网站真正开始使用这个 CDN。因为我们原本页面里生成的图片链接还是旧的,比如:https://dukaan.app/media/seller123/product.jpg

我们把代码改成生成新的链接。并且为了更专业,还单独配置了一个自定义域名 cdn.dukaan.app 指向 CloudFront 提供的长域名。于是新的图片地址就变成了:https://cdn.dukaan.app/seller123/product.jpg

改动部署上线。手术完成。

结果:更快的网站,更便宜的账单

效果非常直接,也非常夸张。

我们回头联系巴西那位卖家,请她再打开一次店铺。第一次加载时还是稍微有点慢,因为这是 cache miss,São Paulo 边缘节点第一次看到这些图片,还得先去 Mumbai 的 S3 回源。

但第二次开始——无论是她自己,还是其他任何南美用户——页面体验都完全不同了。图片直接从 São Paulo 节点返回,几乎是瞬间加载。页面打开时间从 6 秒以上,直接掉到 2 秒以内。她非常兴奋。

月末时,Suumit 和我再次打开 AWS 账单。原本那条 “Data Transfer Out from EC2” 几乎被砍掉了,只剩下一条小得多的 “CloudFront” 费用。我们的带宽成本下降了 70% 以上。

这条双头龙,终于被一刀解决。一次相对简单的架构升级,就同时让全球用户体验大幅提升,也替公司省下了一大笔钱。


第 12 章:关键结论

  • 只要用户地理分布足够广,CDN 就不是可选项,而是必需品。 它通常是最容易落地、也最有性能回报的优化之一。
  • CDN 一次性解决速度和成本两个问题。 它让用户从更近的节点取内容,降低延迟;同时也减少你的源站出网流量成本。
  • 静态资源要与应用服务器分离。 把图片、CSS、JavaScript 这类文件放进专门的对象存储(如 Amazon S3),再由 CDN 对外分发,是更可扩展也更省钱的架构。
  • 落地路径很直接: 建一个存储 bucket 做源站,创建 CDN Distribution 指向它,再把页面里的静态资源链接切到新的 CDN URL。

第 13 章:指挥家:用 Kubernetes 编排一切

Docker 改变了我们的世界。“我机器上能跑” 这个问题几乎消失了,部署变得一致,服务器也被装得前所未有地满。我们有种像发现超能力的感觉。应用的任意部分,都能被装进一个漂亮、可移动的盒子,拿到哪里都能跑。

但很快,我们也看到了这种力量的另一面。我们把过去那几十台乱糟糟的虚拟机舰队,换成了一支由上百个整整齐齐容器组成的大军。而军队,必须有将军;舰队,必须有提督。我们的容器化世界,是一种全新的混乱,而我们还在用人工方式硬控它。

这一章,要讲的就是我们如何走进复杂却强大的容器编排世界,并最终找到那位真正的指挥家:Kubernetes

第一部分:没有指挥家的乐团

刚引入 Docker 的几个月,效率像被拉爆了一样。但随着我们不断拆微服务,容器数量从十几个迅速涨到上百个之后,日常运维开始变成噩梦。我一度成了公司里薪水最高、压力最大的“手动码头工人”。

真正的崩溃点,发生在一个星期六凌晨 2 点。

告警把我从床上震起来:Host Unreachable: app-server-07。一台 EC2 实例发生硬件故障,整机下线。放在以前,这种事负载均衡器就能解决。但这次不一样:那台机器上同时跑着 30 个不同容器,涵盖 API、缓存失效器、图片缩放服务等多个角色。

我的“修复”过程,几乎是一场手忙脚乱的人工灾备演练:

  • 先拉起一台新的空白 EC2。
  • SSH 上去,把 Docker 装好。
  • 努力回想,那台挂掉的机器上到底跑了哪 30 个容器。
  • 然后在凌晨 2 点,对着终端一条条手敲 30 个 docker run ...,祈祷自己别在长长的镜像名和端口映射里打错一个字。

我花了将近 1 小时,靠高压、低效、极易出错的人工操作,才把服务慢慢恢复。系统勉强回来了,但这个过程明显不是“方案”,只是裸奔状态下的临时自救。

发布新版本也一样可怕。一次 storefront-service 升级,就意味着要在服务器舰队里替换掉 50 个正在运行的容器。我们只能手工跳舞:停掉一个旧容器,拉起一个新容器,等健康,再换下一个。整个过程里,始终要祈祷自己别把用户搞宕线。

Suumit 也看得出来我快扛不住了。有次通话里他说:“你看起来像一个星期没睡了。我们交付速度是快了,但你成了新的瓶颈。公司稳定性不能建立在你半夜手工敲命令这件事上。”

他说得没错。我白天本该做更有价值的产品建设,结果晚上全在给运维填坑。于是我开始研究:其他公司到底怎么在大规模环境下管理容器?然后,一个名字开始反复出现。复杂、 intimidating、看一眼文档就让人头大:Kubernetes

它的学习曲线,简直像一堵直立的墙。文档里全是陌生词:Pods、Deployments、Services、Ingress、ReplicaSets、YAML……感觉像是在学一门外星语言。但它承诺的东西实在太诱人:自动化、自愈、系统级编排。

我跟 Suumit 说:“我需要消失一周。我得把自己锁起来,把 Kubernetes 这玩意儿啃明白。我觉得这是我们活到下一阶段的唯一办法。”

识别问题:我们缺的不是工具,而是容器管理系统

那一周扎进 Kubernetes 兔子洞后,我确认了自己的判断。我们缺的不是某个单点工具,而是整整一个软件类别。人工方式之所以失败,是因为它在 4 个关键方面都无能为力:

  • 没有自愈能力: 容器崩了,只能等人手工拉起;服务器死了,容器就永久消失,直到有人去别处重建。
  • 没有智能调度: 容器跑哪台机器,全靠人拍脑袋决定,利用率极差。
  • 没有自动化发布: 上新版本只能靠手工停旧启新,风险高得离谱。
  • 没有服务发现: 假如 order-service 要调用 user-service,而后者有 50 个容器,随时可能重启、迁移、换 IP,那它到底要怎么稳定找到对方?

我们缺的不是“能跑容器”的能力,而是“能自动管理整个容器生命周期”的系统。换句话说,我们需要 容器编排器(container orchestrator)


技术深入:什么叫容器编排?

理解容器编排最好的比喻,就是交响乐团。

  • 我们的单个容器,就是 乐手。每个都是专业分工的个体。storefront-service 容器像小提琴手,数据库连接器像大提琴手,图片缩放服务像打击乐手。
  • 如果你只是把这 100 个技术高超的乐手扔进一个房间,让他们“自己演奏”,得到的只会是一团灾难性的噪音。那就是我们当时的真实状态。

要把噪音变成音乐,你需要一位 指挥家(Conductor)。指挥家并不演奏乐器,他的职责是统筹整个乐团。

  • 指挥家手里拿着总谱(desired state,目标状态),并确保大家按谱演奏。
  • 他告诉小提琴组什么时候进、什么时候停。
  • 他掌控节奏、动态和配合。
  • 如果某位乐手的琴弦断了(容器崩了),他会马上示意另一位顶上。

Kubernetes,就是我们容器乐团的指挥家。

它不替代 Docker。Docker 仍然是每位乐手手里的乐器。而 Kubernetes 是统筹整支乐团的大脑,它跨越整支服务器舰队,告诉所有 Docker 实例该如何协同,确保最终跑出来的系统,和总谱(配置文件)描述的一模一样。

我们终于找到了指挥家。接下来,要学会读他的乐谱:理解 Pods、Services、Deployments 这些 Kubernetes 的基本语言。

第二部分:学习乐谱

那一周沉进 Kubernetes 的学习,对我来说既震撼又谦卑。我很清楚它就是答案,但它的概念太抽象、术语也太陌生。想要指挥自己的容器乐团,我必须先学会和指挥家说同一种语言。

慢慢地,我发现 Kubernetes 本质上建立在一组非常简单、非常有力的概念之上。一旦这些积木理解了,整个系统就开始顺了。

技术深入:Kubernetes 的核心组件

我们把 Kubernetes 的词汇表拆开理解。

1. Node:乐手坐的椅子

一个 Node,就是你舰队中的一台服务器——物理机也好,虚拟机也好——它已经被配置进 Kubernetes 集群,成为一名工作节点。

  • 类比: Node 就是乐团舞台上的 椅子。它是乐手真正坐下并演奏的物理位置。我们把一批 EC2 实例装上 Kubernetes 所需组件后,它们就成了集群里的 Node,也就是一排排等待被安排使用的椅子。

2. Pod:真正的乐手

这是 Kubernetes 里最核心的概念。在 Docker 世界里,最小单位是容器;而在 Kubernetes 世界里,最小可调度、可部署的单位是 Pod

Pod 是对一个或多个容器的包装。绝大多数时候,你完全可以把 Pod 理解成“一个正在运行的容器”

  • 类比: Pod 就是一个真正 坐在椅子上的乐手。Pod 提供了容器所处的即时环境:网络、存储、存在的空间。我们不会直接对 Kubernetes 说“帮我跑个容器”,而是说“帮我创建一个 Pod”,并在 Pod 的配置里说明要跑哪个镜像。正是这层抽象,让 Kubernetes 获得了巨大的编排能力。

3. Service:乐团分区的牌子

Pod 是会死的。它可以崩掉,可以被替换。新 Pod 替换旧 Pod 后,通常会拿到一个全新内部 IP。问题来了:如果 storefront-service 的 Pod 要调用 api-service 的 Pod,可对方 IP 老在变,它怎么知道该去找谁?

答案就是 Service。Service 为一组 Pod 提供一个稳定、恒定的网络入口(固定 IP 和 DNS 名称)。

  • 类比: 假设乐团里有 20 个小提琴手(Pods)。你不想让指挥家记住每一个人的名字,于是你在他们前面立了一个很大的牌子,上面写着:Violins(小提琴组)。这个牌子就是 Service。Service 会知道当前哪些小提琴手还健康、还在场。于是 storefront-service 要找 API 时,不需要知道具体某个 Pod 地址,只需要把请求打给 api-service 这个稳定入口。Service 会自己在后面挑一个健康 Pod 转发,相当于一个内部负载均衡器。某个 Pod 崩掉并被替换后,Service 会自动更新自己的后端列表,前面的调用方完全无感知。

4. Deployment:总谱

所有东西真正合在一起,就发生在 Deployment 里。Deployment 是我们人类声明“希望系统最终长成什么样”的地方。它是那份交给指挥家的总谱。

  • 类比: Deployment 就是那份 乐谱。里面写着所有关键指令:
    • “我需要 50 个 storefront-service 的 Pod 副本。”(这个乐器组需要多少位乐手)
    • “它们必须使用 dukaan/storefront:v2.1 这个镜像。”(乐器具体是哪一版)
    • “当我给你新谱子时,要用 rolling update 的方式一位位替换,确保音乐不中断。”(如何无停机更新)

Kubernetes 真正神奇的地方,在于它会持续拿这个总谱(desired state)去对比舞台上实际发生的事情(current state)。

如果总谱要求 50 个 Storefront Pod,但当前因为崩了一个,只剩 49 个,Kubernetes 不会慌。它只会看见:“现实状态和目标状态不一致。” 然后立刻在健康的 Node 上再创建一个新 Pod,把数量补回 50。这就是我们苦苦追求的 自愈能力

到这一步,我们终于有了一整套描述自己需求的语言:我们要一个 Deployment,来管理一组运行在 Nodes 上的 Pods,并通过 Service 让它们能互相通信。

下一步,就是要学会把这份“总谱”真正写出来。

第三部分:写乐谱(YAML)

概念已经学得差不多了。我们知道 Kubernetes 是容器乐团的指挥家,靠一份关于目标状态的“总谱”来管理一切。接下来,就是学会写这份乐谱。而这也意味着,我们得接受一种全新的基础设施思维方式:用 YAML 来描述系统。

技术深入:声明式 vs. 命令式

这是从传统运维进入 Kubernetes 世界时,最重要的一次哲学转变。

  • 旧方式(命令式 / Imperative): 我们之前一直在这样干:一步步告诉系统该怎么做。比如“SSH 到 Server A,执行这个 docker run;再 SSH 到 Server B,执行另一个命令。”
    • 类比: 这像一个事无巨细的经理,告诉木工学徒“椅子要怎么搭”:先拿 4 条腿,再找坐板,再把左前腿用 3 颗螺丝拧上去……繁琐、容易错,而且一旦中途出事,你还得继续人工兜底。
  • Kubernetes 方式(声明式 / Declarative): 完全相反。你不再告诉 Kubernetes 怎么 做,而只描述你想要的 最终结果
    • 类比: 你把一份详细椅子蓝图交给一位经验丰富的木匠,说:“我要一把长这样、四条腿、蓝色、20 英寸高的椅子。” 至于先装哪条腿,不需要你指挥。Kubernetes 这个“老木匠”会自己按蓝图把它造出来,并在之后持续维护,使现实始终贴着蓝图。

这就是它的魔法。你声明目标状态,而 Kubernetes 会在后台不断做“对账”和“校准”。这些声明通常写在一种很适合人读的格式里:YAML。它靠缩进和键值对来表达配置。

技术深入:我们的第一个 Deployment YAML

是时候真正为 storefront-service 写总谱了。我们创建了一个叫 storefront-deployment.yaml 的文件。第一次看它,会觉得有点 intimidating,但本质上,它只是把“我们到底想要什么”写清楚。

下面,就是这个第一版 Deployment 蓝图:

YAML

# 1. The API version and Kind tell Kubernetes what type of object this is.
# We are creating a "Deployment".
apiVersion: apps/v1
kind: Deployment

# 2. Metadata is data about the object itself, like its name.
metadata:
  name: storefront-deployment

# 3. The Spec (Specification) is the most important part.
# This is our desired state—the blueprint for the chair.
spec:
  # 4. We want 50 identical copies of our application running.
  replicas: 50

  # 5. This tells the Deployment how to find the Pods it's supposed to manage.
  selector:
    matchLabels:
      app: storefront

  # 6. This is the template for the Pods themselves.
  template:
    metadata:
      # 7. We give the Pods a label so the Deployment can find them.
      labels:
        app: storefront

    spec:
      # 8. This section defines the containers to run inside the Pod.
      containers:
        - name: storefront-container
          # 9. This is the most critical line: the specific Docker image to run.
          image: dukaan/storefront:v2.1

          # 10. Tell Kubernetes which port our application is listening on.
          ports:
            - containerPort: 8000

kubectl apply 的魔法

这个 YAML 只是个文本文件,光放在那里当然不会自己生效。我们需要一种方式,把总谱真正交到指挥家手里。这个动作是通过 kubectl 完成的(发音接近 “koob-control”),它是与 Kubernetes 集群交互的主要工具。

我们只执行了一条命令:

kubectl apply -f storefront-deployment.yaml

然后,魔法开始了。

Kubernetes 读入文件后,看见目标状态是:“运行 50 个 dukaan/storefront:v2.1 的 Pod。” 接着,它对比当前集群状态,发现:“匹配的 Pod 数量是 0。” 于是 reconciliation loop 被触发。调度器会在不同 Node 上智能找空间,然后在接下来的几秒内,启动整整 50 份一模一样的 Storefront Pod。

而真正让人震撼的,是发布新版本时。我们做了 v2.2 新镜像,只需要把 YAML 里一行从 dukaan/storefront:v2.1 改成 dukaan/storefront:v2.2

然后再次执行——注意,是 同一条命令

kubectl apply -f storefront-deployment.yaml

我们不需要告诉 Kubernetes 如何“升级”。它会读取新的蓝图,对比运行中的系统,自行推导出差异。然后自动开始一场 rolling update:先杀掉一个旧的 v2.1 Pod,再拉起一个新的 v2.2 Pod,等新 Pod 变健康,再继续替换下一个。50 个副本会在不停机的前提下,一个接一个平滑完成切换。这就是我们一直梦寐以求的:自动、安全、无聊的部署流程。

到这里,我们已经把总谱交给了指挥家,乐团也开始演奏。但它们还只是待在一间完全隔音的排练室里。下一步,我们还得把门打开,让真正的观众——也就是用户——能听到这场演出。

第四部分:把音乐送到观众耳边

我们已经成功把总谱交给指挥家。Kubernetes 集群活起来了,storefront-deployment 也在老老实实维持着 50 个运行中的 Pod,哪怕崩了也会自动补上。乐团演奏得很好。

唯一的问题是:它还在一间完全隔音的屋子里演奏。

Pod 们只活在集群内部的私有网络里。外部互联网根本碰不到它们。我们拥有了一支乐团,却没有观众。最后这一步,就是给用户建一扇安全、稳定、可管理的入口。这,就是 Kubernetes Service 的职责。

前面我们已经讲过,Service 可以为 Pod 提供稳定的内部访问地址,让服务与服务之间能通信。但 Service 还可以进一步把这些 Pod 暴露给外部世界。而这时,你得先选一种 Service Type(服务类型)

技术深入:Kubernetes Service 类型

Kubernetes 提供了多种“开门方式”,分别适用于不同使用场景。选对它,直接决定你的系统是否既安全又省钱。

1. ClusterIP:内部对讲机

这是默认的 Service 类型。它提供一个固定 IP,但 只能在集群内部访问

  • 类比: 这像一个建筑内部的 对讲机系统。指挥家可以通过它联系小提琴组,小提琴组也可以用它联系打击乐组。但站在外面马路上的人,永远摸不到这套对讲系统。
  • 使用场景: 这是服务间通信的主力军。order-serviceuser-service,走的就是它的 ClusterIP。它既常见,又安全。

2. NodePort:消防通道

NodePort 会在 ClusterIP 基础上,再把服务暴露到 每一台 Node 的一个高位端口 上。

  • 类比: 就像在每个乐手椅子背后,都开了一扇 消防门。只要你知道建筑地址和门牌号(例如 Node_IP:30080),就能直接从外部进去。虽然可用,但不优雅,也不安全。
  • 使用场景: 它更适合 调试或临时访问。开发者可能为了快速验证一个新服务,懒得配正式负载均衡,就先开个 NodePort。实际生产很少会直接这么用。

3. LoadBalancer:正门大厅

这是在 AWS、Google Cloud 等云平台上,最常见的对外暴露方式。你创建一个 type: LoadBalancer 的 Service 时,Kubernetes 会自动为你向云平台申请一个真正的外部负载均衡器。

  • 类比: 这就是一座正式建筑的 大门和票务大厅。云厂商帮你建好一个拥有公网 IP 的正门。所有观众(互联网流量)先来到这里,然后由这个大门自动把人导向后面某个 Node,再转进真正的 Pod。
  • 使用场景: 适合对外公开一个单独服务。比如你只有一个主 API,那么给它配一个 LoadBalancer,再把域名指过去,就能完成曝光。

4. Ingress:聪明的前台接待

LoadBalancer 很方便,但有个问题:每创建一个,对云平台来说通常就是多开一个外部负载均衡器,成本很快会上来。如果你有 10 个服务都要对外暴露,总不能买 10 个大门。

这就是 Ingress 的意义。严格说,Ingress 不属于 Service Type,但它是放在多个 Service 前面的一层“更聪明的入口管理”。

  • 类比: Ingress 就像一座综合大楼门口的 前台接待。所有观众先从同一个大门进来(由一个 LoadBalancer 接住)。他们把票交给接待。接待看票后说:“你是来 /rock 的,请去 A 厅;你是来 /classical 的,请去 B 厅。”
  • 使用场景: 对微服务架构来说,这是最强也最省钱的外部入口管理方式。你通常只需要一个外部 LoadBalancer,再由 Ingress Controller 根据 URL 路径(如 /api/storefront)或主机名(如 api.dukaan.appstore.dukaan.app)把流量转给不同内部 Service。

我们的实现

storefront-service,我们最终选择的是 Ingress。它非常适合我们的架构。整个集群只有一个主入口,而 Ingress Controller 充当前台接待。我们写了一个规则:凡是访问 dukaan.app/store/* 的请求,都转给 storefront-service 对应的内部 ClusterIP。

我们又写了一份 YAML,把这条 Ingress 规则交给 Kubernetes,执行 kubectl apply。那一刻,排练厅的门终于打开了。真实互联网用户可以访问到这个运行在 Kubernetes 集群里的 storefront-service,而它后面,是 50 个会自愈、可自动滚动更新的 Pod。

到这里,我们终于控制住了容器的混乱。乐团,有指挥了。

第 13 章:关键结论

  • Kubernetes 是容器乐团的指挥家。 它用声明式管理,取代高风险的人肉操作。
  • 拥抱声明式配置。 别再告诉系统“怎么做”,而是写 YAML 明确声明“我要什么结果”,让 Kubernetes 自己对齐现实。
  • 理解核心组件: Node 是服务器,Pod 是运行实例,Deployment 是总谱,Service 负责通信。
  • Kubernetes 天生提供强大的自愈能力。 Pod 挂了会自动补,Node 故障了也会被重新调度。
  • 给外部流量选对入口工具。 内部通信用 ClusterIP,面向用户的统一外部入口则优先用 Ingress

第四部分:追求离谱般的速度(打造世界级边缘网络)

到这里,我们已经把公司的技术引擎从头到脚重造了一遍。我们从一台哮喘般单机,进化成一支由 Kubernetes 管理的、可自愈、可扩展、稳健的容器乐团。稳定性、一致性、工作流的问题,都有了答案。我们有了一座堡垒,并且自信它扛得住任何风暴。

而这一部分的故事,要从一场意料之外的飓风开始。那场风暴撞上堡垒时,我们原本以为自己靠的是更高的墙才没倒下;后来才发现,真正保护我们的,是一条更聪明的海岸线。


第 14 章:鲨鱼缸效应:一场烈火试炼

在印度创业圈,没有什么比 “Shark Tank Effect(鲨鱼缸效应)” 更可怕、也更令人渴望。一个小品牌一旦在热门节目 Shark Tank India 上亮相,网站流量不会是正常上涨,而是会在几分钟内被几十万、甚至更多好奇观众瞬间冲爆。对任何电商平台来说,这都是终极烈火试炼。

我们原以为新的 Kubernetes 集群已经能扛住这件事。事实证明,任何中心化系统都不可能单靠自己扛住。

第一部分:不可能的流量尖峰

那是一个平静的周四晚上。当值工程师还在处理几个无伤大雅的小报警。晚上 9:45,公司 Slack 突然弹出一句消息:“GUYS! JAIN SHIKANJI IS ON SHARK TANK RIGHT NOW!”

所有人立刻冲去打开 Grafana 监控面板。我们盯着同一张图:“Concurrent Users(并发用户数)”。原本只有几百的曲线,突然像一面垂直的墙一样拔起。1 万、3 万、5 万,最终峰值停在了惊人的 8 万并发用户

Slack 里瞬间安静下来。所有人脑子里都在算账。我们的 storefront-service 当时只跑了 10 个 Pod。哪怕优化得再极致,这 10 个 Pod 也绝不可能直接扛住 8 万同时在线。于是,所有人都盯着 “Pod Count” 图,等着 Horizontal Pod Autoscaler(HPA)英勇出场开始扩容。

但奇怪的事情发生了。用户量明明高得离谱,可 Mumbai 那 10 个 Pod 的 CPU 只是轻微升高。HPA 看到 CPU 没有打到 70% 的扩容门槛,几乎没怎么加 Pod。网站依旧非常快。没有报警,没有崩溃,一切都稳得可疑。

一位资深工程师在 Slack 里打破沉默:“这算不过来。10 个 Pod 怎么可能撑住 8 万并发?而且 HPA 根本还没来得及反应。到底发生了什么?”

他说得没错。我们都感觉到了:堡垒确实扛住了,但不是靠我们以为的那种方式。我们之所以没被冲垮,是因为某种我们自己都还没完全意识到的隐藏超能力,在替我们扛伤害。

技术深入:为什么“反应式扩容”扛不住海啸

要理解那一晚到底发生了什么,得先理解:为什么我们原本指望的 HPA,其实根本救不了场。

Kubernetes HPA 这类自动扩容,本质上是 reactive(反应式) 的。它不会预判流量,只会在指标被打高之后,才开始反应。这个过程包含几个步骤,而每一步都要时间:

  • 指标采集: 一个叫 Metrics Server 的组件,会周期性抓取 Pod 的 CPU 等数据。注意,这不是实时流,而是每 15 到 30 秒采一次。
  • 检测与决策: HPA 控制器根据这些数据判断是否超阈值,再计算应该加多少 Pod 才能把平均 CPU 拉回目标范围。这个动作虽然快,但前提是指标已经被采回来。
  • Pod 启动: 这是最慢的部分。Kubernetes 要先调度 Pod 到某台可用 Node,然后 Node 还得拉 Docker 镜像(这本身就要时间),再启动容器,最后还得等应用彻底 ready,能真正接流量。

从流量尖峰真正开始,到新 Pod 真正能承接用户,中间完全可能要 2 到 5 分钟

为什么 HPA 在“闪电洪峰”面前会失灵

“Shark Tank 效应” 不是慢慢涨潮,而是海啸。流量会在 60 秒内,从接近零直接冲到最高点。

  • 类比: 反应式扩容,就像你发现开始下暴雨了,于是决定给房子临时加建一个雨棚。可等你地基刚打好,山洪早就已经冲过整座镇子了。

等 HPA 真能把集群从 10 个 Pod 扩到假设需要的 150 个时,最初那 8 万并发的第一波冲击,早已经把原先那 10 个 Pod 来回打死十几次了。

那如果 HPA 根本来不及当英雄,究竟是谁救了我们?

答案是:那晚真正保护我们的,不是 Mumbai 那个中心化 Kubernetes 集群在及时扩容,而是 绝大多数 8 万并发流量,压根就没有打到中心集群上。

它们被一个高度可扩展、强分布式的 edge network(边缘网络) 吸收并分散了。而这个网络,最初并不是为了抗流量峰值建的,而是为了另一个问题:我们对“极致速度”的执念。这套网络像一个全球减震器,把海啸的力量先分散到世界各地很多小节点上,再把剩余压力送进主城。它的来龙去脉,就是接下来一章要讲的内容。

Jain Shikanji 事件,对我们来说是一次分水岭。它让我们偶然见识到了平台隐藏的真正力量。但这股力量本身并不偶然。它来自我们几个月前有意识做出的一个巨大选择:向 Shopify 宣战,把“速度”变成我们最重要的产品特性。

第 15 章:向 Shopify 宣战:把性能做成特性

那时,Dukaan 在印度市场已经有了不小的知名度。我们增长很快。但如果想吸引更大的卖家,并真正走向国际,我们始终绕不开一个名字:Shopify

Suumit 对这件事几乎到了执念的程度。他白天黑夜都在注册 Shopify 试用、拆解他们的店铺、和同时使用两个平台的商家聊天。他一直在找一个弱点,一个真正能撬开的裂缝。

有一天晚上,他给我打电话,声音里是那种“终于找到敌方命门”的兴奋。

“Subhash,我找到了,”他说,“我知道我们怎么赢。他们在这里的店,真的很慢。一个典型 Shopify 店铺,对印度用户来说,要 3 秒,有时候 4 秒才能打开。”

他说得没错。Shopify 的核心基础设施主要在北美。对他们的主市场来说,这没有问题;但对 Delhi、Bangalore 这样的用户来说,每次请求都必须绕半个地球跑一个来回。

“这就是我们的机会,”Suumit 继续说,“现在拼功能我们比不过,他们领先了 10 年。但我们可以更快。不是快一点,而是如果我们能做到全球都几乎瞬时呢?这种能力,谁都无法忽视。”

这场对话改变了很多东西。我们的目标,不再只是造一个简单好用的电商平台,而是去打造 全世界最快的电商体验。而要做到这一点,我得先真正理解一个新敌人:延迟(latency)

技术深入:互联网的物理学

为什么一个部署在美国的 Shopify 店,在印度会慢?答案其实更多来自物理学,而不是服务器算力。

当你衡量一个网站有多快时,实际是在看两个部分:

  • 处理时间: 服务器思考、查库、生成页面所花的时间。
  • 网络时间: 请求从用户到服务器,再把响应送回来,需要多久。

前面的章节里,我们一直在优化处理时间。但网络时间受一个绝对上限限制:光速

互联网数据在光纤里的传播速度,大约是真空光速的三分之二。Mumbai 到美国 Virginia 数据中心大约 13,000 公里,一次来回就是 26,000 公里。

算一下:

  • 光在光纤中的速度:约 200,000 km/s
  • 往返距离:26,000 km
  • 理论最短时间:26,000 / 200,000 = 0.13 秒,也就是 130ms

这就是 RTT(Round Trip Time)。而问题在于,打开一个网页不是一次往返,而是一串必须顺序发生的往返:

  • DNS Lookup: “这个域名对应哪个 IP?”(1 次)
  • TCP Handshake: “你好服务器,我要建立连接。”(1 次)
  • TLS Handshake: “我们建立一条加密连接。”(2 次)
  • HTTP Request: “好了,请把网页发给我。”(1 次)

仅仅拿到 HTML 的第一个字节,印度用户理论上就要等至少 5 个 RTT。

5 次往返 * 130ms = 650ms

也就是说,如果核心逻辑在美国,印度用户 光让页面开始出现,理论上就已经先背上了至少 650ms 的物理延迟税。再叠加链路拥堵、图片、CSS、JavaScript 下载,3 到 4 秒加载时间几乎是必然的。

我们没法让光更快,也没法让用户搬去服务器旁边。唯一能做的,是把服务器搬到用户身边。这,就是我们进入 Edge Computing(边缘计算) 的起点。

第二部分:证据与铁证

理论判断已经有了,但如果要让整家公司围绕这个方向投入巨量工程资源,理论远远不够。我们需要公开、直接、可验证的数据证据。

于是我们开始公开挑战。在 X(原 Twitter)上一系列帖子里,我非常直接地抛出了观点:“Shopify 存在严重性能问题。”

我们没有空口下结论,而是把几家印度最知名的 D2C 品牌——boAt、Bummer、Urbanmonkey、Hammer——的 Shopify 店拿来做实测。

从印度测试位置跑它们首页后,结果非常一致:慢、评分差、体验拖沓。

这些公开测试很快引起了关注,甚至引来了 Shopify 自家性能工程负责人 Colin Bendell 的回应。他把问题归因到一个很常见的借口:第三方内容、第三方脚本太多。

也就是说,他认为慢不在 Shopify 平台本身,而在商家自己加了太多 App、统计脚本和营销标签。这个解释听上去合理,但我们知道它不对,而且必须拿出更硬的证据。

于是我们做了一个非常简单、也非常致命的实验:同一家店,只改测试位置。

  • 测试 A:India 测。
  • 测试 B:Canada 测。

结果几乎像白天和黑夜。Bummer 在 Canada 突然就不 “bummer” 了;boAt 在 Canada 也快得多。第三方脚本一模一样,页面一模一样,唯一变化的只是 用户所在地

这就是铁证。问题不是第三方脚本,而是 Shopify 核心基础设施的位置。它在 Canada 快,在 India 慢。

技术深入:TTFB 才是最关键的铁证

暴露真相的关键指标,是 TTFB(Time to First Byte)

TTFB 统计的是:浏览器发出请求,到收到 HTML 第一个字节 之间的时间。这个指标发生在浏览器下载大图、执行沉重 JavaScript 之前,所以它非常“纯”,几乎就是在测服务器响应速度和网络延迟本身。

我们的测试显示:

  • boAt 在 India 的 TTFB,是 Canada 的 7 倍
  • Bummer 在 India 的 TTFB,是 Canada 的 3 倍

我甚至公开演示过:在 India 打开 boAt 网站,看浏览器 Network 面板,主文档请求的 TTFB 超过 1.06 秒。整整一秒,只为了让服务器发回一个 98KB HTML 文本文件的第一个小字节。

这就是“光速税”的可视化证据。它证明 Shopify 的核心架构仍然高度中心化在北美。对全球用户来说,这不是小优化问题,而是底层方向问题。

概念:什么是边缘计算?

问题是距离,答案也只能是消灭距离。

你赢不了光速,但你可以缩短赛道。

边缘计算的核心思想就是:不再把所有计算与数据都集中在一个中心,而是尽可能把它们搬到离用户最近的地方。

不是一个国家里只有一个巨大中央大脑,而是在全球多个城市里部署很多同步的小脑(edge nodes)。当印度用户访问时,他不再和美国总脑沟通,而是和 Mumbai 或 Delhi 的本地脑直接对话。

Shopify 的路线是中心化的。我们的路线,将是分布式的。他们让用户去找服务器;我们要把服务器送到用户面前。

第 16 章:巨鲸:迎接第一家企业级巨头

我们公开和 Shopify 对打性能,再加上 Shark Tank 效应的故事,很快在印度 D2C 圈发酵开来。大家开始形成一个清晰印象:Dukaan 比 Shopify 快,而且这是用数据证明出来的。

这样的主张像磁铁一样,吸引来的不再只是小卖家,而是更大的鱼。

有一天,Suumit 接到一个电话,整个团队都安静了下来。对方是 Wow Skin Science 的管理层——印度最大的 D2C 品牌之一,千亿卢比级别的巨头。他们看到了我们的主张,很感兴趣,但也非常怀疑。他们只提了一个要求:“证明给我们看。”

这通电话,拉开了一个全新阶段。我们不再只是服务海量中小卖家,而是必须开始思考企业级隔离、企业级架构与企业级承诺。

第一部分:挑战,以及那句“完了”

Wow Skin Science 的团队非常专业。他们给了我们一周试用,不是整站迁移,而是先把一个营销落地页放到 Dukaan 上,跑一波真实广告流量,验证我们的边缘网络到底有没有吹的那么神。

那一周,全团队都高度戒备。我们盯着监控,看着他们的流量不断进来。结果平台表现得非常漂亮:页面打开极快,体验流畅,我们的性能承诺也被他们自己的数据验证了。

一周后,他们又打来电话。

“我们信了,”他们的技术负责人说,“性能非常夸张。我们想把整个业务——全量商品、数百万顾客、整个千亿规模的电商业务——迁到 Dukaan。”

Slack 里的第一反应是狂喜。这几乎是对我们过去所有努力的最高背书。

然后,喜悦很快变成了团队里那种安静而一致的心声:“哦,糟了……”

Wow Skin Science 的规模,和我们之前服务过的任何对象都不是一个量级。他们的订单量预计会达到 我们平台其余 500 万店铺当前总订单量的 20 倍。他们一次新品发布带来的冲击,甚至可能超过 Jain Shikanji 上 Shark Tank 时的瞬时峰值。

我们眼前不是一条鱼,而是一头巨鲸。而问题是,怎么把鲸鱼拖上船,又不把整支舰队一起压沉。

识别问题:Noisy Neighbor(吵闹邻居)

我们的全球边缘网络非常擅长处理海量 读取。但那些真正会改变系统状态的 写入——订单、新用户注册、库存变更——最终仍然要回到 Mumbai 的主 PostgreSQL 数据库。

之前这么做没有问题。数百万中小卖家形成的是分散、相对平稳的写负载。但 Wow Skin Science 完全不同。他们是单一主体,却能在极短时间里爆发出近乎末日级别的写压力。

这就是典型的 Noisy Neighbor 问题。

  • 类比: 我们原本搭的是一张大型高速公路网。现在,一支由上千辆巨型卡车组成的车队(Wow)也要上路。如果他们在一场闪购里同时冲进来,普通轿车(数百万普通卖家)可能会被堵得动弹不得。

反过来也一样。某个普通卖家的流量小高潮,理论上也可能对 Wow 这种体量的企业带来不可接受的波动。对他们来说,不能接受和“公共流量池”共享命运。

我们不能把鲸鱼和小鱼都养在同一个鱼缸里。我们必须给它单独造一片海。

这件事把我们第一次真正推向了 database sharding(数据库分片)

第二部分:企业堡垒

架构答案非常清楚:必须彻底隔离。不是给他们更大的机器,而是从数据库开始,给他们一整套独享基础设施。

技术深入:什么是数据库分片?

前面我们一直用读副本解决 读扩展。而分片,是处理数据库 写扩展 的经典方案。

  • 类比: 当前数据库像一座服务全国的中央图书馆。现在它既要服务普通大众,又要承受一家超大型企业极端密集的特殊需求。于是我们决定:单独给企业客户再建一座新的国家级图书馆,而原来的图书馆继续服务普通公众。两者各自有自己的馆员、资源和秩序。

技术上,分片是一种水平拆分。你不再只有一个主数据库,而是拥有多个主数据库,再通过应用里的路由层决定某次请求该去哪个主库。

我们的方案:企业一客一分片

我们设计出了一套非常直接、但极其有力的策略:

  • Public Shard(公共分片): 继续使用现有主数据库集群,服务数百万中小卖家。
  • Enterprise Shard(企业分片): 另外新建一套完全独立的主数据库集群,跑在高性能裸金属硬件上,唯一职责就是服务 Wow Skin Science。

为了让它工作,我们在应用层增加了一个 shard router(分片路由器)。任何数据库查询在真正执行前,应用都先判断:

  • 这次请求的 store_id 是谁?
  • 这个 store_id 是否属于企业客户名单?
  • 如果 ,连接 Enterprise Shard DB
  • 如果 不是,连接 Public Shard DB

这套逻辑被打进整个代码库,从请求进入应用的第一刻起,企业客户与普通商家的数据、流量、资源就开始走两条完全隔离的路。

这座堡垒带来的价值

这种架构几乎完美解决了 Noisy Neighbor 问题:

  • 性能隔离: Wow Skin Science 的大促只会打满他们自己的企业分片,不会影响数百万普通卖家。反过来,普通卖家的偶发爆红,也不会给 Wow 增加哪怕 1ms 抖动。
  • 更高的安全与控制: 对企业客户来说,物理隔离本身就是卖点。他们的客户数据和订单数据不是仅仅“逻辑分开”,而是确实运行在不同机器上,更容易满足合规与内控要求。
  • 可复制的未来模式: 下一头企业级“巨鲸”再来时,我们不用再问“公共数据库扛不扛得住”,而是直接按 playbook 再开一片专属 Shard。这也成了后来 Dukaan Enterprise 的基础模板。

第 15 章:我们的全球大脑:设计 Dukaan 边缘网络

我们向延迟宣战了。我们的目标,是把电商平台做到不仅“快”,而是对全球任何一个用户都 几乎瞬时打开。理论上我们已经知道方向:必须把服务器搬到离用户更近的地方。

但这次,已经不再是“给图片接个 CDN”那么简单了。我们想搬到边缘去的,不只是静态资源,而是整个 storefront 应用:计算、业务逻辑、以及数据库本身。这是当时我们做过最激进、最复杂、最雄心勃勃的工程项目。也是我们的 moonshot。

第一部分:白板,以及那个神奇的 IP 地址

项目一开始,是我和两位资深工程师站在一块巨大的白板前,回答一个看似简单的问题:“如果要为全球电商平台设计一套理论上最快的架构,它应该长什么样?”

我们画出来的东西,非常激进。我们决定在全球多个 AWS Region 中,部署一整套独立的 storefront 应用副本和数据库副本:Mumbai 覆盖南亚,Frankfurt 覆盖欧洲,Singapore 覆盖东南亚,Ohio 覆盖北美……

当我把这个方案拿给 Suumit 看时,他立刻意识到了其中的业务价值,但也马上戳中了两个最核心的技术风险。

“这个想法太猛了,”他说,“但等等。数据库也要在每个区域复制一份?这不会是同步地狱吗?而且全世界这么多区都跑服务器,成本不会高到离谱吗?”

他说到了问题本质:数据一致性成本。这是一个回报巨大、但技术风险也巨大的赌注。我们押的是:速度收益会大到足以压过一切复杂度。我当时坚信会。

不过,在真正讨论数据同步之前,还有一个必须先解决的根本问题:我们怎么把用户自动送到“正确”的服务器?

技术深入:速度架构的第一层

如果我们在 Mumbai 有一组服务器,在 Frankfurt 也有一组,那德国用户的浏览器怎么知道该连哪边?我们不可能要求卖家告诉不同国家用户访问不同网址。我们需要的是一个 单一的全球入口地址,并由它自动把用户送到最近的节点。让这一切成立的魔法,叫 Anycast IP

概念:Anycast IP,像一条全球披萨热线

平常互联网里,IP 地址通常是 Unicast。也就是:一个 IP,对应一台机器、一个地点。

Anycast 不一样。它允许我们在全球多个数据中心 同时宣告同一个 IP 地址

  • 类比: 想象一个全球披萨连锁品牌,比如 Domino’s。他们有一个全球统一电话:1-800-DOMINOS
    • 如果你在 Mumbai 拨这个号码,电信网络会聪明地把你接到 Mumbai 最近的接线中心,而不是某个美国总机。
    • 如果 Frankfurt 的用户拨的是 同一个号码,网络则自动把他导向 Frankfurt 的接线中心。
    • 用户不需要知道背后有几个呼叫中心,也不需要自己选择地区。只需记住一个号码,剩下交给网络。

Anycast IP 对我们的应用来说,就是这条全球统一热线。我们专门获取了自己拥有的一段 IP 地址空间,然后在选定的各个 AWS Region 同时广播同一组 IP。

这一下,整个设计被彻底点亮:

  • 卖家只需要把自己的域名(比如 mycoolstore.com)一次性指向这个 Anycast IP。
  • 德国顾客访问这个域名时,互联网路由协议(BGP)会自动把流量送到 Frankfurt 数据中心。
  • 印度顾客访问同一个域名时,流量则自动到达 Mumbai。

一切无感、自动,而且理论上可无限扩展。Anycast 成了我们的全球前门。现在,前门后面每个区域都必须有一整套能独立提供极速服务的“区域大脑”。

第二部分:区域大脑

Anycast 帮我们把用户优雅地送到最近的数据中心门口。但如果门后是一栋空楼,再快的路由也没有意义。我们必须确保,在 Frankfurt、Singapore、Ohio 这些地方,门一打开,后面就立着一套完整、自治、速度极快的 storefront 平台。

这就意味着,每个区域都必须具备两类能力:计算数据

技术深入:区域架构设计

我们选了全球 9 个战略性的 AWS Region,作为 Dukaan “regional brains(区域大脑)” 的落点。每个区域里,我们都搭出了一套标准化、结构一致、彼此独立的小系统。

实现方式:独立 Kubernetes 集群

在每个 Region,我们都部署了一个小型、自包含的 Kubernetes 集群。

  • 类比: 如果整个全球网络是一家连锁,那每个区域就是一家完整独立的门店。Frankfurt 门店有自己的厨房、自己的厨师、自己的值班经理。它并不需要 Mumbai 门店替它做菜。
  • 价值: 这种独立性带来了更高层级的容灾能力。如果整个 AWS Mumbai Region 故障,对欧洲用户 零影响,因为他们走的是 Frankfurt。某个针对美国区域的 DDoS,也只会冲击 Ohio,而不会波及亚洲和欧洲。这意味着故障被局部化,而不再是全局性崩盘。

实现方式:区域只读数据库副本

仅有区域应用集群还不够。如果 Frankfurt 的应用容器每次还得花 150ms 去 Mumbai 查数据库,那边缘化等于没做。必须让数据也靠近计算。

所以,在每个 Region 里,除了 Kubernetes 集群,我们还部署了一份 主 PostgreSQL 数据库的只读副本

  • 类比: 每家区域门店都拿到一份本地的、和总部同步的只读菜谱。Frankfurt 的厨师不需要再给 Mumbai 总部打长途电话确认做法,直接本地翻即可。
  • 价值: 这就是我们把页面目标压到亚 100ms 的关键。当用户请求打到 Frankfurt 的某个 Pod 时,这个 Pod 查询的是同机房、几米外的 PostgreSQL 副本。整个查询是 子毫秒级 的,不再有跨洲传输延迟。

到这里,我们已经成功设计出了“区域大脑”:每个脑都能独立服务附近用户,而且速度极快。

但问题也随之变得可怕起来。

新问题:全球数据同步

我们现在拥有 9 份散布全球的只读数据库副本。可所有“真相源头”,也就是卖家真正修改数据的主数据库,依然只有一个,放在 Mumbai。

那么问题就是:当印度卖家改了某个商品价格,怎么把这条更新,可靠、按顺序、近实时地传播到 Frankfurt、Ohio、Singapore、Brazil、Sydney 等所有其他区域?

我们之前用过的那种“简单流复制”,根本不是为这种全球范围的一对多同步场景设计的。一次跨洲网络抖动,就可能让某个区域落后很久,开始对外展示大量旧数据。

所以,我们虽然造出了分布式“大脑”,却还缺少一套全球级“神经系统”去连接它们。下一步,就是搭建这个系统,让所有区域同时“想”在一起。

.

第三部分:全球神经系统

9 个区域大脑已经有了。但如果它们彼此孤立,那只是 9 个聪明却互不知晓的脑袋。我们必须让 Mumbai 的变化,能第一时间在 Frankfurt、Ohio、Singapore 等所有地方被感知并同步。我们需要一条全球中央神经系统。

这次负责承担重任的工具,正是我们已经相当熟悉、但即将把它用到全球级别的那一个:Apache Kafka

技术深入:用 Kafka 做全球数据同步

我们用 Kafka 配合 Debezium,搭出了一条实时、可靠、可持久化的全球同步链路。

整体结构:一条中央新闻专线

  • 中心端的生产者: 在 Mumbai 的主数据中心里,我们让 Debezium 直接盯住主 PostgreSQL 数据库。正如第 9 章讲过的那样,它持续读取事务日志。每次新增商品、改价格、创建店铺,都会被自动转换成一条结构化事件。接着,这些事件被写入位于 Mumbai 的高可用 Kafka 集群。这个 Kafka 集群,自此成了 全平台所有数据变化的全球真相源头
  • 边缘端的消费者: 在 9 个区域中的每一个地方,从 Frankfurt 到 São Paulo,我们都部署了一个轻量级的同步消费者服务。它唯一的职责,就是订阅 Mumbai 这套中心 Kafka 集群,等待新事件。

数据流:推模型(Push Model)

整个系统的行为方式,特别像一家全球新闻通讯社(比如 Reuters)。

  • 类比: Mumbai 的 Debezium + Kafka,就是总部新闻编辑台。只要有新闻发生(例如某个卖家改价格),编辑台立刻发稿。我们 9 个区域门店,都是这条通讯社专线的订阅方,每个后台都有一台电传机。只要总社发稿,不到一会儿,各地电传机就会同步吐出这条消息。然后本地值班经理(消费者服务)根据稿件内容,把本地那份菜谱副本(区域数据库)更新掉。

完整流程很优雅:

  • 卖家修改商品价格,数据先写入 Mumbai 的主 Postgres。
  • Debezium 看到 WAL 中这条变化。
  • Debezium 把消息写进中心 Kafka 的 product_updates_global Topic。
  • 全球 9 个区域的消费者服务都订阅着这个 Topic,因此会在毫秒到秒级内先后拿到这条消息。
  • 各区域消费者根据消息内容,在自己本地的 PostgreSQL 只读副本上执行对应 UPDATE

几乎只要一两秒,印度卖家做出的修改,就能传播到整个全球边缘网络。

为什么 Kafka 比传统复制更合适

核心原因在于:Kafka 的日志是 durable(持久的)。假设 Frankfurt 与 Mumbai 之间的链路掉了 5 分钟,Frankfurt 消费者并不会丢数据。它只是“落后”了而已。那 5 分钟的更新会安安静静堆积在 Mumbai 的 Kafka 日志中。等网络恢复,它重新连上后,就从上次停下的位置继续读,把漏掉的更新一次补齐。

这意味着:每个区域都能 最终一致地追上来,而消息不会在传输中消失。这正是边缘网络能真正成立的底层保障。

Shark Tank 之谜:终于解释通了

也正是这套全球分布式架构,在 “Shark Tank 效应” 那次惊险场景里救了我们。那 8 万并发,并没有全部砸向 Mumbai 一处。由于 Anycast 的存在,北美用户自动打到 Ohio,欧洲用户打到 Frankfurt,亚洲用户进 Mumbai / Singapore……

那波极端流量,被 9 套彼此独立的小系统自然分担了。没有任何单一区域被真正压死。这是一种把集中冲击,转化为分布式吸收的防御方式。我们原本只是为了“全球快”,结果却意外造出了一套拥有强大弹性和韧性的系统。

从某种意义上说,我们为了速度而打出的这套组合拳,顺手也把可用性与弹性一起抬上了一个新台阶。

Maggi 配送网络问题

想理解我们这套边缘网络究竟比普通 CDN 强在哪里,得先明白传统 CDN 的边界。CDN 很好用,它可以把图片、CSS、JavaScript 这些静态文件缓存到离用户近的地方。它是第一步,而且非常必要。

但传统 CDN,更像是一个 Maggi 调料包配送网络

想象你的主服务器在 Mumbai,它就是 Maggi 工厂。它负责生产复杂、动态的核心产品:HTML 页面本身,也就是由应用逻辑和数据库共同生成的内容。而图片、JS、CSS 这些静态资源,就是一袋袋不会变化的 调料包

CDN 像一群快递员。他们可以把成箱调料包预先放进 Delhi、Bangalore、London 等地的本地仓库。于是 Delhi 的用户要拿一袋调料,快递员可以秒送。

但问题是:快递员那里 没有面条本体。用户真正想吃的那碗 Maggi(HTML 页面),依然得从 Mumbai 工厂出发,现煮现发。这就解释了为什么 Shopify 用了 CDN 之后,图片加载可以还不错,可页面首字节时间(TTFB)在印度依然很高:静态资源近了,但 HTML 还得跨洲回源。

我们意识到:如果真要做到“瞬时”,光把调料包搬到边缘不够,我们必须 在每个主要城市都建一座完整的 Maggi 工厂。不仅有配送,还有烹饪本身。也就是说,应用计算和数据库,都得一起下沉到边缘。这才是真正的 Edge Computing。


边缘网络的三大支柱

我们的 Global Edge Network 建在 3 根支柱上。它们不只是功能点,而是平台的战略优势本身。

支柱 1:性能,100ms 承诺

我们和 Shopify 的战争,本质就是和延迟的战争,而边缘网络就是我们的终极武器。整个架构是围绕着“砍掉 TTFB、让页面瞬间出现”来设计的。

  • 计算与数据同地部署: 最关键的决定,是在 9 个 AWS Region 中,每个都同时部署 Kubernetes 集群(计算)PostgreSQL 读副本(数据)。这就是我们和普通 CDN 的根本差异。用户请求打到 Frankfurt 节点时,处理请求的容器访问的是同机房里的数据库副本。数据往返从原来跨大西洋的 150ms+,变成微秒到毫秒级。
  • Anycast IP 实现极速路由: Anycast IP 让整套网络像魔法一样缝合起来。通过在 9 个 Region 上同时广播同一个 IP,我们把“用户去哪里”的决策,交给了互联网路由层。BGP 会自动选择最短路径,把用户送到最快地点。浏览器不用思考、应用也不用思考。更进一步,我们甚至选择了 自己持有 IP 地址空间。这一步非常前瞻。因为这意味着 Anycast IP 属于 Dukaan,而不是 AWS。将来我们想把某个边缘节点从 AWS 迁到 Google Cloud,或者迁到裸金属机房,卖家域名完全不用变。用户仍然连的是同一个 IP,只是这个 IP 在新的地点继续被广播。我们不给单一云厂商锁死。
  • Kafka 推模型同步: 性能上的最后一块关键拼图,是我们的 Kafka 全球同步链路。这套机制不是等边缘缓存过期后再去拉,而是主动 push 更新。卖家在 Mumbai 一保存,中央 Kafka 就会立刻把变化向全局 9 个边缘节点推送。这意味着大部分时候,边缘数据在用户请求来之前就已经是热的、最新的。

这几项能力合在一起,让我们真正实现了 moonshot:从用户点开链接,到浏览器收到 HTML 首字节,TTFB 长期稳定低于 50ms;整页连图片一起完成渲染,也常常在 100ms 以内。而当很多竞品还在用秒来计算页面打开速度时,我们已经把体验压到了“眨眼之间”。

支柱 2:安全与稳定,全局免疫系统

中心化系统的问题在于,它天然是一个巨大的单点目标。无论是技术故障还是恶意攻击,只要打中核心,就可能全站一起掉线。分布式边缘网络则天然不同,它像一套全球免疫系统。

  • DDoS 攻击缓冲: DDoS 的本质,是用超大规模流量把目标压垮。传统架构里,攻击 dukaan.app,流量最终会全部灌进一处中心机房,很容易压穿。
    但在边缘网络里,情况完全不同。恶意流量也会被 Anycast 自动分散:来自欧洲的攻击会打到 Frankfurt,来自北美的会打到 Ohio。攻击面被全球节点均匀稀释,恶意流量在边缘就能被封堵。亚洲、南美等其他区域的正常用户,几乎不会感知到性能变化。
  • 故障隔离与高可用: 这个原理不仅对攻击成立,对云厂商故障也一样成立。大家都知道,公有云并不是永不出事。中心化系统一旦碰上某个 Region 故障,就会全站挂。
    而我们的架构像一个个水密舱。假如整个 Mumbai 区域失效,Anycast 网络会自动撤掉该地路由,把印度用户切到下一个最近、仍然健康的节点,比如 Singapore。用户体验也许会从 20ms 变成 60ms,但网站 仍然在线。这就是分布式结构带来的真实 uptime。

支柱 3:扩展,弹性的海岸线

Shark Tank 事件,正是边缘网络扩展能力的终极证明。真正厉害的地方,不只是负载被分散,而是系统拥有多层弹性结构,层层缓冲、层层兜底。

假设再来一次类似海啸,整个系统会这样运作:

  • 第一层:全球分布(海岸线)
    第一波 8 万用户打到 Anycast IP 上,互联网本身先帮我们把这波浪分散到 9 个不同海岸。Ohio 接住北美流量,Frankfurt 接住欧洲流量,其他区域各接各的。超大峰值先被拆成很多个更小、更局部的波峰。
  • 第二层:智能重路由(溢流渠道)
    假设美国流量还是太猛,Ohio 集群开始接近容量上限。我们的监控会先感知到,甚至在真正过载前,就能通过路由策略把一部分新流量自动导去下一个最近的健康区域,比如 Virginia 或 Toronto。这会让少数用户略微增加几十毫秒延迟,但能换来整站 100% 在线。系统会先弯一下,而不是断掉。
  • 第三层:区域内自动扩容(会长大的沙滩)
    与此同时,在 Ohio 内部,Kubernetes 的 HPA 也开始工作。它看到本地 Pod CPU 上升,就逐步扩 Pod。这个过程比较慢,往往要几分钟,但现在它不需要“立刻”完成,因为全球重路由已经帮它买到了时间。等本地集群通过扩容稳住后,网络层再逐步把原先分流出去的流量拉回来。

这种“全球主动分流 + 本地被动扩容”的多层弹性系统,就是我们边缘网络最漂亮的地方。它让我们面对最极端、最不可预测的流量冲击时,不只是“没挂”,而且往往连值班工程师都来不及收到一条真正的告警。

第 15 章:关键结论

  • 想要全球低延迟,必须把计算和数据一起搬到边缘。 只有区域应用服务器,没有区域数据副本,性能优势就会被跨洲数据库访问吞掉。
  • Anycast IP 是全球路由得以丝滑成立的魔法。 一个 IP,对全球多个节点同时广播,自动把用户送到最近入口。
  • 分布式架构天然带来极强的容错能力。 多区域独立集群意味着某一区域挂掉,不会拖垮全球用户体验。
  • Kafka 这种持久事件日志,是全球数据同步的骨干。 它让十几个分布式副本能可靠、可恢复地保持近实时一致。
  • 有时,解决一个问题会顺手优雅地解决另一个。 我们原本只是为了性能而建边缘网络,结果它也把可扩展性和抗冲击能力抬到了新的高度。

第 16 章:聚光灯下:从意外 CTO 到技术领导者

很多年里,我的世界一直是一块黑底绿字的屏幕。我的战斗几乎总是安静发生:崩掉的服务器、卡死的数据库、凌晨的异常流量。我们的胜利,通常只体现在 uptime 图和不断下降的 latency 数字里。我们造出了一台很厉害的机器,但几乎一直是在黑暗里造它。

但随着平台越来越大,外界开始注意到我们。关于 Dukaan 惊人性能和全球边缘网络的讨论越来越多。终于有一天,聚光灯照到了我脸上。这一章讲的是另一种火场:它和服务器无关,却和我这个自学工程师如何面对内心深处的恐惧有关。

第一部分:邀请,以及那个“冒牌货”声音

那封邮件是在一个周二下午落进我邮箱的。标题很简单:Invitation: Scaler Podcast with Arnav Gupta

我的心跳立刻快了起来。

在印度创业和技术圈,Arnav Gupta 和他的 Scaler Podcast 几乎是传奇。Arnav 是那种会从第一性原理出发、对技术潮流保持极强判断力的人。他的播客不是轻松闲聊,而是三小时起步的深度技术对谈。

更让我紧张的,是之前坐在那个位置上的人:

  • Amod Malviya: Flipkart 传奇 CTO,Udaan 联合创始人。
  • Jiten: CARS24 CTO,曾是 Hotstar 的 VP。
  • Jacob Singh: 前 Grofers(现 Blinkit)CTO,也曾在 Sequoia Capital 担任 CTO。

这些人不是普通 CTO,而是印度技术体系里的地标人物。很多人都有极强的学院派背景和深厚的理论基础。

而现在,第四位嘉宾,居然轮到了我。

说实话,我第一反应不是兴奋,而是彻底的恐惧。我怕得不行。

一路伴随着我的 impostor syndrome,一下子在耳边吵了起来:你凭什么坐在那里?你不过是 Bihar 的一个商科生,是那个靠盗版 PDF 自学 PHP、靠一次次凌晨 3 点事故逼着自己学系统设计的人。

我没有学院里那套扎实理论。如果 Arnav 现场让我用教科书定义解释 CAP Theorem,或者在白板上写复杂度推导,我很可能会卡住。我会不会当场暴露成一个“冒牌 CTO”?

更重要的是,这已经不只是我自己的事了。我代表 Dukaan,代表整个工程团队。一旦我说了蠢话、表现得很差,不只是我丢脸,而是会伤害团队和公司声誉。

我第一反应,是想礼貌拒绝,继续缩回自己熟悉的阴影里。

但紧接着,我想起我们的旅程:边缘网络、裸金属迁移、复杂观测系统……我们确实有一个值得讲的故事。不是 PPT 上的空话,而是从一线火场里打出来的实战故事。如果我不敢讲,那谁来讲?

这时,它已经不再只是“我敢不敢露面”的问题,而是一种责任。

于是,我回了邮件:I'd be honored to.

第二部分:三个小时的极客长谈

录播前几天,我一直在焦虑。但我没有去死磕教科书,因为我知道临时补理论只会让我更慌。相反,我选择抓住自己真正拥有的东西:真实的故事。我不准备把自己伪装成学院派天才,而是准备以 builder 的身份出现。

我整理了架构图、Shark Tank 时的 Grafana 图表、裸金属迁移后的成本对比图。我不打算背理论,而是准备把一线证据带上桌。

走进录音棚时,我的心跳很快。灯光很亮,现场很专业。Arnav 和我握手,他和我想象中一样:锋利、专注、技术味很浓。但真正聊起来后,我的紧张感反而慢慢消失了。

他不是来考试的。他也是个 builder,他想聊的不是抽象定义,而是真问题。我们从最初讲起:那台曾经撑起整家公司的 512MB DigitalOcean Droplet,以及它凌晨 3 点崩溃时,我第一次真正理解“扩展”到底意味着什么。

接下来的 3 小时,与其说是采访,不如说是两个技术人之间非常投入的深聊。我慢慢忘了摄像机,也忘了观众,只是在跟另一位工程师复盘我们一路踩过的坑。

有趣的是,我过去最自卑的地方——缺乏学院派背景——反而变成了优势。因为我没法依赖术语和套话,只能用第一性原理,把复杂问题讲成普通人也能跟上的故事。

  • 当 Arnav 问我们怎么处理边缘函数冷启动时,我没有扔一堆抽象术语,而是拿 Maggi 煮面 打比方:“第一个顾客得等水烧开,后面的人就能立刻吃上。”
  • 我们深入聊了向 裸金属 迁移、以及和 E2E Networks 这类厂商合作后极其夸张的成本下降。
  • 我们讲了对卖家自定义域名,如何在 第一次请求发生时即时下发 SSL 证书,这是团队非常自豪的一段边缘工程实现。
  • 聊着聊着,话题甚至自然转向了团队建设,以及那个在印度技术圈永远讨论不完的问题:面试到底要不要执着于 DSA(数据结构与算法)。我很坦诚地讲了自己的观点:排序算法可以 Google,但凌晨 3 点把线上数据库从火场里拖出来的韧性,Google 不出来。

三个小时结束时,我整个人非常疲惫,但也如释重负。我没有装,我只是把我们的故事讲完整了。接下来,就交给外界去判断。

后续:机场那一刻

播客上线后一周,反响来得非常快,而且出乎意料地好。印度技术社区在 Twitter 和 LinkedIn 上的反应,不是在纠结我有没有学位,而是在讨论我们解决的那些真实工程问题。大家对这种从实战里长出来的系统设计非常有共鸣。

最 surreal 的瞬间,发生在几周后的机场。我正准备登机,一个年轻人有点迟疑地走到我面前。

“Excuse me, sir,您是 Subhash Choudhary 吗?”

我点点头。

“Sir,我看了你的 Scaler 播客,” 他说,眼睛亮了起来,“真的太厉害了。尤其是你讲边缘网络那段。谢谢你。”

那一刻我整个人都愣住了。第一次,我在公开场合被认出来,不是因为“创业者身份”,而是因为“工程师身份”。那个曾经在第一份工作里觉得自己像 outsider 的 Bihar 男孩,居然会因为技术分享被人在机场认出来。这种感受非常复杂,也非常有治愈力。

而更大的后续,还在公司等着我们。那期播客,像是给全印度最好的工程师打亮了一束灯。

第三部分:灯塔与回声

机场那一刻,对我个人是非常深的肯定。但那期三小时长谈真正长期的影响,其实并不在我身上,而在公司身上,并且回响得远超预期。

在高度竞争的技术创业环境里,有一种资源比融资更稀缺:优秀工程人才。在那期播客之前,我们只是众多创业公司里的一个;播客之后,我们点亮了一座灯塔。

人才洪流

我们的招聘页面,原本只是稳定地接到一些简历;之后却像被洪水冲开。我的 LinkedIn 收件箱里开始塞满来自印度顶级产品公司的资深工程师消息。真正发生变化的,不只是数量,而是申请者的 质量意图

那期播客,实际上成了一则三小时的“工程广告”。真正厉害的工程师,不只是找一份工作,他们是在找值得投入的难题。很多人直接说,听完这期内容,他们第一次觉得:Dukaan 从技术视角看,是一家非常值得加入的公司。

播客甚至变成了我们的筛选器和 magnet。有人在评论里写:“Who wouldn’t want to work with such a CTO.” 这几乎是我能收到的最高评价之一。

“Street Smart CTO” 的确认感

对我个人来说,最重要的反馈,是对那条“非典型路径”的确认。我读评论时,能明显感觉到自己多年背着的 impostor syndrome 在一点点松掉。 人们并不在乎我是不是 CS 科班。他们在乎的是,我们到底解决了什么真实问题。有人把我叫作 “raw, knowledgeable, street smart CTO”,有人说我是 “engineer in true sense”。还有一条评论对我冲击很大:“Impressed by this guy!! How he switched from non-tech background to tech background with all the nitty gritty!!”

另一条评论则把这件事放大到了我自己都没想到的层面:“English is the problem for me and so many people I think… but he achieved this much gives me confidence.” 这提醒了我:我的故事并不只属于我自己。它也许正在给那些同样觉得自己像 outsider 的人一点勇气。

这本书的种子

那期播客很长,足足 3 小时。一开始我甚至担心没人会看完。但很多评论都在说:本来以为这么长根本不会看,结果一看就停不下来。

然后,有一条评论,真正种下了这本书的种子。

一位叫 Haridarshan Choudhary 的观众写道:“one can create a system design book out of the topics covered throughout the interview.”

我读到那句时,脑子里像突然亮了一下。他说得对。大家不只是在“看一场播客”,他们在跟着一个真实故事学习。他们想看的,不只是抽象原理,而是一个普通人怎样在一次次真实火场中学会系统设计。

从某种意义上说,那期播客就是这本书的第一份草稿。它让我确认了一件事:如果你把自己的故事讲得足够真实,它不只会帮助公司,也会帮助你的社区,甚至最终也会帮助你自己。

第 16 章:关键结论

  • 公司的工程故事,本身就是最强的招聘资产之一。 顶尖工程师真正被吸引的,是高难度问题,而不只是薪资。
  • 把你的工作公开讲出来。 写博客、做分享、上播客,都能把公司塑造成技术品牌,吸引最优秀的人才。
  • 真实,比头衔和学历更有穿透力。 能用简单、故事化的方式把复杂问题讲清楚,比背诵学院派术语更能打动人。
  • 走出舒适区。 对你个人和公司来说,真正的跃迁,往往都在恐惧的另一侧。

第 17 章:逃离金笼:从 AWS 迁往裸金属

我们确实在 AWS 上建起了自己的杰作。公有云让我们能几乎点几下鼠标就把服务器铺到全球各地,做出全球数据库网络,把平台扩到非常高的规模。那是一段被云能力托起的黄金时代。

但后来我们慢慢意识到:这个漂亮的家,其实是一只 金笼子。它提供了便利、速度和弹性,但代价高得惊人,而且把我们牢牢锁进了一套自己并不真正掌控的系统里。这一章,就是讲我们如何决定越狱。

第一部分:那通 8 万美元的电话

月底时,Suumit 打来一个电话。他没有愤怒,也没有慌张,语气非常平——一种被数字震麻之后的平。

“Subhash,” 他说,“AWS 账单出来了。8 万美元。”

我停下手里的事情:“8 万?一个月?”

“对,一个月,8 万美金。”

我打开 AWS Cost Explorer。他没说错。我们的支出曲线不再是平缓上升,而像火箭发射一样,和用户增长几乎完全同步。我们的成功,正在用最直接的方式燃烧掉利润。

我们一项项拆账单,发现“罪魁祸首”几乎无处不在:

  • 9 个全球区域里,几十台 EC2 机器在承担 Kubernetes 节点。
  • AWS 的托管 Kubernetes(EKS)本身要收一笔高级服务费。
  • 主数据库和各区域副本组成的 RDS 集群,费用很夸张。
  • Kafka 全球同步跨区域传输,也在不断吃掉成本。

从栈的每一层看,我们都在为“便利”付税。早期这笔税完全值得,因为它让我们跑得极快。但现在,这份便利已经开始反噬盈利能力。我们被困在金笼子里了。

技术深入:基础设施的经济学

那张 8 万美元账单,逼着我们不得不正面讨论一个根问题:基础设施的经济模型。互联网公司常见的两条路,其实就是:跑在 公有云 上,或者跑在 裸金属 上。

1. Public Cloud(AWS、Google Cloud、Azure)

  • 类比: 像在城市最核心地段租一套高档、精装修、带完整物业服务的公寓。
  • 优点:
    • 快与方便: 今天就能入住。水、电、安全、维护都有人管。要再加一间房?打一通电话(一次 API 调用)就行。我们能在几周里铺开全球网络,正是因为这个便利。
    • 弹性: 需要时就能扩大,不需要时也能缩回去,按月付费。
  • 缺点:
    • 规模上来之后贵得离谱: 你为所有便利付的不是小费,而是巨额溢价。租金里有平台利润、运营成本、品牌税,一切都叠在你头上。
    • 控制权不足: 你无法改水管,无法打墙,也只能选房东提供的那些“家电型号”(实例规格)。说到底,你永远是在租,不是在拥有。

2. Bare Metal(自托管裸金属)

  • 类比: 像自己买地,自建房子。你可以去找 Hetzner、OVH,或者区域性机房服务商。
  • 优点:
    • 成本会大幅下降: 这是最核心的吸引力。你只为服务器、电力和网络本身付钱,没有中间商溢价。很多情况下,和公有云同等资源相比,价格能低 10 倍到 20 倍
    • 控制权与性能都更强: 房子是你的,怎么造、用什么硬件,全由你决定。很多时候,你能买到比云厂商更强、更便宜的专用机器。
  • 缺点:
    • 运维负担巨大: 这才是真正的代价。你不再只是租客,而得同时扮演建筑师、装修工、水电工和保安。凌晨 3 点硬盘坏了,没有 AWS 支持帮你秒修,你得靠自己和团队扛。
    • 缺乏弹性: 你买的是房,不是酒店房间。扩容意味着买新机器、上架、配置,可能要几天甚至几周;缩容更麻烦。

对早期我们来说,租 AWS 这套“高级公寓”是绝对正确的决定。它帮助我们飞速找到产品市场匹配,也帮我们把全球边缘网络搭了起来。但现在,架构已经成熟,负载也越来越可预测,而房租真的太高了。

于是,我们做了一个大胆又吓人的决定:搬出去。离开金笼,去买自己的地、自己盖房。我们决定把 Dukaan 迁往裸金属。

第二部分:大迁徙

决定做了,接下来的问题就很残酷:你怎么把一整套全球在线生产系统,从一个提供商搬到另一个提供商,而且 1 秒停机都没有

你当然不能简单地把旧系统关掉,再把新系统打开。那是计划性停机,而对我们的业务来说不可接受。我们要做的,是一边让演出继续,一边悄悄把整套舞台重建在另一个地基上,再无缝把观众引过去。

我们的秘密武器:Anycast IP

这次零停机迁移之所以可能,靠的是几个月前我们做过的一个关键决策:我们拥有自己的 IP 地址空间。

因为 Anycast IP 属于 Dukaan,而不属于 AWS,所以它在互联网上的“广播位置”完全由我们掌控。我们不是绑在 AWS 网络里。这个 IP,是我们的可携带门牌号。房子可以搬,地址不变。这就是零停机迁移真正的钥匙。

策略:把绞杀榕模式用在基础设施上

我们决定把之前用于拆单体的 Strangler Fig Pattern,应用到基础设施迁移上。

  • 类比: 不是某天一拍脑袋,就从昂贵公寓里拎包走人。而是先在隔壁地块把新房建好。接下来一段时间里,你会同时付旧公寓租金和新房成本。然后,你一点点把家具从旧房搬到新房,先搬一个房间,再搬一个房间,每一步都测试到确定安全。直到旧房里真的一件东西都没了,才正式退租。

这种渐进式、可回滚的迁移,是唯一安全的方式。

技术深入:迁移作战手册

步骤 1:先把新地基搭起来

我们先选好了裸金属供应商:Hetzner。它以强性能和极高性价比出名。然后,我们在与原 AWS Region 地理位置尽量接近的地方租用物理服务器,比如用 Helsinki 机房承接原本 Frankfurt 的那一部分负载。

这时,“运维开销”从抽象概念一下子变得非常真实。你没有一个舒服的 AWS 控制台,只会得到一台裸 Linux 机器的 root 密码。接下来,所有事都得你自己来:

  • 装操作系统。
  • 配网络。
  • 搭建自己的 Kubernetes 集群。

我们借助了像 k3s 这样较轻量但依然足够强的 Kubernetes 发行版,让过程变得稍微可控些。即便如此,它依然是一场大工程。我们不仅要懂自己的应用,还得懂承载应用的底层基础设施。经过几周时间,我们把 9 个新数据中心里的自托管 Kubernetes 集群一点点搭了出来。

步骤 2:逐步迁移流量

这是真正最 delicate 的部分。以欧洲流量为例:

  • 初始状态: 100% 的欧洲用户都由 AWS Frankfurt 集群承接。新建的 Hetzner Helsinki 集群已经 ready,但还没有流量。
  • 双重广播: 我们开始同时从 AWS Frankfurt 和 Hetzner Helsinki 两侧广播同一个 Anycast IP。
  • 先切 1%: 利用 BGP 路由偏好控制,我们只把 1% 的欧洲流量导向新裸金属集群,其余 99% 继续走 AWS。
  • 仔细盯盘: 我们盯着监控看错误率、页面速度、CPU、网络,一项项确认。新集群稳吗?表现和旧环境一致吗?
  • 逐级放量: 稳定后,逐步放到 10%、25%、50%。每一步都带着严格监控。一旦发现问题,我们可以瞬间把所有流量拨回 AWS。
  • 最终切换: 几天观察后,把 100% 欧洲流量切到 Helsinki。

这时,Frankfurt 上的 AWS 集群就彻底空了,像一套已经没有人住的昂贵公寓。

步骤 3:下线旧环境,重复整个过程

流量完全迁走后,我们才敢去 AWS 控制台,把 Frankfurt 区域里的旧服务器、旧数据库、旧 LB 一台台关掉。至此,金笼的一间房,算是拆掉了。

然后我们把同样的过程,在接下来的两个月里一个 Region 一个 Region 地重复,直到整个全球生产系统都运行在自己的裸金属基础设施上。

第 16 章:自动驾驶:面向全球网络的 CI/CD

到了这一步,我们的基础设施几乎到达了一种运维意义上的“宁静”。全球裸金属 Kubernetes 集群很快、很稳、还便宜。观测系统让我们对每个角落都看得很清楚。就像一辆 Formula 1 赛车,性能和遥测都很顶。

但还有一块流程,依然异常原始:怎么把开发者笔记本上的新版本,送上全球 9 个 Kubernetes 集群?

系统越分布式,更新它就越痛苦。这一章讲的,就是我们如何给这整套全球网络加上一套真正的 自动驾驶系统

第一部分:九次部署的痛苦

有一次,一个新版 storefront-service 准备好了。里面有一个 Bug 修复和一点性能提升。放在过去,部署是一个命令就能完成的事。可现在,它变成了我亲自主持的一场、长达一小时的高风险仪式。

我得打开终端,按区域逐个 rollout:

  • 先切到 Frankfurt 集群:kubectl config use-context frankfurt
  • 执行部署命令:kubectl apply -f storefront-v2.3.yaml
  • 然后盯着 Frankfurt 的 Grafana 仪表板看上 5 分钟,观察错误率、CPU 和延迟。
  • 之后换到 Ohio,重复同样动作。
  • 然后是下一个区域。

这一套操作,整整要重复 9 次

问题很明显:

  • 太慢: 一个很小的版本发布,就要吃掉一位资深工程师一小时专注力。
  • 不一致风险大: 我万一部署到第 5 个区域时被打断了,剩下 4 个怎么办?全球不同区域跑着不同版本,是运维灾难。
  • 很容易出错: 一次 routine deployment 时,我甚至把本来给 storefront 的配置错误地 apply 到了 Singapore 的 api-service 上,直接让东南亚用户短暂宕了一次。

Suumit 看着销售图上的那个小凹坑,问我:“Singapore 怎么了?” 我解释了人为错误。他只说了一句:“这个过程必须 foolproof。必须自动化。”

识别问题:一个完全不成规模的手工流程

我们已经有了最先进的、自愈型生产系统,但改变这套系统的方式,仍然停留在人肉操作时代。它不可靠,也不具备规模化能力,而且还对值班工程师产生了强依赖。

我们需要一套系统,把 9 个集群视为 一个逻辑整体。它应该接收开发者已经通过审批的代码,自动、一致、安全地把它发布到全球,而不需要任何人 SSH 上任何机器。我们需要一套真正的 CI/CD。

第二部分:装配线与送货车(CI/CD)

要解决部署问题,我们必须真正给代码搭一条完整装配线。行业里,这个概念就叫 CI/CD

技术深入:什么是 CI/CD?

CI/CD 代表 Continuous Integration 和 Continuous Deployment(或 Delivery)。听起来复杂,本质上就是两件配合的事。

CI:Continuous Integration(持续集成)

持续集成,指的是开发者频繁把代码合入中央仓库,而每次合入后,自动流程立即开始构建、测试系统。

  • 类比: 它像汽车工厂入口处的 质量检测站。每当一批新钢材(开发者代码)送到,机器会自动抽样做压力测试(单测、lint、集成测试)。只要材料有问题,就当场打回去,不能进入主生产线。
  • 目标: 尽可能早、尽可能自动地发现 Bug、回归和集成问题,确保主代码仓随时保持健康。

CD:Continuous Deployment(持续部署)

持续部署,则是在 CI 成功后,把结果自动部署到生产环境。

  • 类比: 像工厂尾端那支自动送货车队。一辆车只要通过最终质检,就立刻装上卡车,自动送去全球各地展厅,不需要人工司机介入。
  • 目标: 让发布从“紧张仪式”变成“稳定日常”。小步快跑地高频发布,比低频、大体量发布,风险要低得多。

我们的 CI:GitHub Actions

持续集成这一段,我们用的是 GitHub Actions。因为代码本来就托管在 GitHub 上,这几乎是最自然的选择。我们把自动化工作流写成 YAML 文件,直接和代码放在一起。

每当开发者提 Pull Request,GitHub Actions 就会自动触发:

  • 拉起一台干净的临时环境。
  • 检出代码。
  • 跑全量自动化测试。
  • 跑 linter,检查代码风格和常见错误。
  • 如果都通过,就用 Dockerfile 构建一个新的 Docker image。
  • 最后,把这个 image(例如 dukaan/storefront:v2.3.1)推到中央镜像仓库(Amazon ECR)。

于是,CI 流程的产物非常明确:一个经过验证、可直接上线、不可变的 Docker 镜像。

第三部分:自动驾驶(GitOps + Argo CD)

CI 解决了“怎么产出可上线工件”,但没有解决“怎么把它平稳送到 9 个集群”。如果继续写脚本去 9 个地方依次 kubectl set image,那依然是 push 模式的人肉命令,本质没变。

我们需要一种更优雅的部署哲学。最后找到的答案,是 GitOps

技术深入:什么是 GitOps?

GitOps 是云原生时代非常重要的一种持续部署思路。它的核心概念很简单,但影响非常深:Git 仓库是整套生产环境目标状态的唯一事实源。

也就是说,改变基础设施的方式,不再是 SSH 上服务器,也不是在本机直接跑 kubectl,而是去改一个 Git 仓库里的配置文件,提 PR、review、merge,然后由自动化代理把线上环境拉到 Git 描述的那个状态。

  • 类比: 还拿乐团打比方。
    • 旧模式(命令式): 我们走到指挥家(Kubernetes)旁边,当面告诉他:“请把小提琴谱换成 2.3.1 版本。” 这是一次手工、即时、不可追踪的口头命令。
    • GitOps 模式(声明式): 有一个 总谱图书馆(Git 仓库),它是整场演出的唯一官方版本。我们要改什么,不是去找指挥家,而是去图书馆里把旧的小提琴谱换成新的版本,走正式审批流程。然后,一个常驻的 图书管理员(GitOps Agent)会一直盯着这个图书馆。一旦发现新谱子被正式批准,它就会自动把谱送到指挥家手上,指挥家再让乐团开始演奏新版本。

这种方式是 pull-based、可审计、风险可控的。

我们的工具:Argo CD

我们选的“图书管理员”,是开源 GitOps 工具 Argo CD

Argo CD 运行在 Kubernetes 集群内部。它的工作非常单纯:持续盯着一个 Git 仓库,把仓库里定义的目标状态和集群当前状态进行对比。一旦发现差异,就自动执行同步,让线上集群回到 Git 所描述的状态。

我们的落地方式如下:

  • 单独建立配置仓库: 新建一个独立仓库 dukaan-infra-configs。这里面不放应用代码,只放 Kubernetes YAML,也就是我们的“总谱”。我们按 9 个区域分别组织目录。
  • 每个区域安装 Argo CD Agent: 在每个 Kubernetes 集群内都装上 Argo CD,并分别让它盯自己对应目录。例如 Frankfurt Agent 只盯 frankfurt/,Ohio Agent 只盯 ohio/

新的部署流程:一次合并 PR

于是,新的全球发布流程突然变得非常优雅:

  • 开发者写完代码。
  • GitHub Actions 跑测试、构建镜像、推送 dukaan/storefront:v2.3.1 到镜像仓库。
  • 然后,CI 最后一步会自动去 dukaan-infra-configs 仓库提一个 Pull Request。这个 PR 的内容非常简单:把 9 个区域 storefront-deployment.yaml 里的镜像版本,从 v2.3.0 改成 v2.3.1
  • 资深工程师或 Release Manager review 这个 PR,确认变更正确,然后点击 Merge
  • 这时,真正的“魔法”发生了:9 个区域里的 Argo CD 同时检测到目标状态变化。
  • 它们把 Git 里的新版本和集群当前运行状态做比对,发现集群还在跑 v2.3.0,于是将状态标记为 OutOfSync
  • 接着,Argo CD 直接在各自集群内部执行同步,相当于自动替我们触发了 Kubernetes 的 rolling update。

这件事会在全球 9 个集群并行发生。过去那种耗时 1 小时、充满手工风险的发布,现在收敛成一次稳定、可审计的 Git merge。我们终于做出了真正的“自动驾驶”。

第 17 章:关键结论

  • CI/CD 是现代软件交付的基础。 CI 保证主代码仓始终健康,CD 保证你可以稳定、快速地把变更送到用户手上。
  • CI 的产物,最好是一个不可变工件。 比如 Docker image,而不是松散代码。
  • GitOps 是持续部署更高级的形态。 让 Git 仓库成为基础设施的唯一事实源,使变更更安全、更透明、也更容易审计。
  • Argo CD 这类工具,就是 GitOps 的自动执行者。 它会持续把线上状态拉回你在 Git 里声明的目标状态。
  • 至此,我们终于学会了“如何改变一台复杂机器”。 发布不再是焦虑源,而成了安全、例行、甚至有点无聊的日常动作。

第 18 章:终章大秀:一次在线故障切换

到了这时,Dukaan 的故事已经不再是行业秘密。Scaler 播客之后,大家知道了我们的工程文化和我的个人经历;而接下来,当我们公开“迁往裸金属并节省 95% 成本”的消息之后,整个印度技术圈几乎炸了。

反应很复杂:惊叹、怀疑、不可思议混在一起。有人说“节省 95% 不可能”,也有人说“那你们稳定性肯定不行”。大家都在问:你们真的做出了一台更快、更便宜、还更稳的机器吗?

既然如此,我们决定不只是“打开引擎盖”,而是要在所有人面前,拿锤子真砸一下它,看它能不能自己恢复。

第一部分:挑战,“Asli Engineering”

就在舆论最热的时候,我们收到了一封来自 Arpit Bhayani 的邮件。Arpit 是前 Google 工程师,也是技术频道 Asli Engineering 的创作者。和许多泛创业内容不同,Arpit 的内容风格就是深挖底层、强调真工程。

来自 Arpit 的邀请,不是友好访谈,更像是一场公开技术审计。他想聊我们的裸金属迁移,而我知道,他会把“你们到底怎么做到的”问得非常细。

但这一次,我的状态和 Scaler 那次已经很不一样了。那种长期缠着我的 impostor syndrome,在那次播客之后已经明显弱了很多。我开始真正相信一件事:社区在乎的,不是我有没有好学历,而是我们到底解决了哪些真实问题。

这一次,我不是害怕,而是有种很强的自豪感。我们确实做出了一套逆着“云优先信条”走的系统。可我也知道,光说不够。讲一小时“我们系统很稳”,无法说服那些怀疑的人。

我得 展示 给他们看。

于是,在和 Arpit 的准备电话里,我提出了一个一半天才、一半疯狂的主意。

“Arpit,” 我说,“裸金属和成本节省当然值得讲,但任何人都能秀一张账单对比图。我想真正证明的是这套系统的韧性。”

“你想怎么证明?” 他问。

“我们做一次 live failover。”我说,“录节目时,现场,直播,我直接打开终端,SSH 到一台欧洲生产服务器上,然后把它关机。”

电话那头停了几秒。我几乎能听到他的脑子在转:工程师的本能在算风险,做内容的人在感受戏剧张力。

“你认真的?” 他终于问。

“百分之百认真。” 我说,“我要让所有人亲眼看到:Anycast 网络如何自动切流,系统如何自愈,网站如何始终在线。没有剪辑,没有魔术,直接来。”

Arpit 的反应也非常 “Asli Engineering”。他没有退缩,反而笑了。“好,那就这么干。Fatega to dekha jayega.”(炸了再说。)

于是舞台搭好了。这不再只是一期节目,而是一场没有安全网的高空走钢丝。我们要在所有人面前,拿整套全球生产架构和工程团队声誉做一次现场豪赌。

第二部分:现场故障切换,直接关生产

和 Arpit 的前半段访谈,都是深度技术内容。我们讲了 Asli Engineering 的理念、CI/CD 配置、裸金属与云的权衡,以及令人震惊的成本与性能数据。

但讲得再多,也不如现场打一次脸来得有力。

“这一切听起来都很厉害,” Arpit 对着镜头说,语气里带着一点刻意的怀疑,“但系统一复杂,大家都会说自己有高可用。你怎么证明真有你说的那么稳?”

我笑了。这就是我等的时刻。

“这是个很好的问题,” 我说,“那我们别讲了。现在,直接测。”

我共享了屏幕。观众看到的不再是幻灯片,而是我的桌面。

第 1 步和第 2 步:先建立基线

“先看一个真实站点。”我打开了 buywow.in,也就是 Wow Skin Science 的官网。页面几乎瞬间打开。

接着,我打开浏览器开发者工具的 Network 面板,点开主文档响应头,指着一个自定义 header 说:“看这里,x-edge-route: bom1。这是我们的内部路由标识,说明我从 India 发起的请求,是由 Mumbai 的裸金属集群在提供服务。”

“然后我们看看全球表现。” 我又打开了 KeyCDN 的全球延迟测试工具,输入网址,点击测试。一张世界地图很快开始刷出结果:

  • Frankfurt:41ms
  • London:18ms
  • Bangalore:22ms
  • New York:34ms

这些数字本身,就是我们 100ms 承诺的物理证明。

第 3 步和第 4 步:证明边缘网络真的在工作

“只有把站点真的放到边缘,这些数字才有可能成立。”我解释说,“如果这些请求都统一回 India,London 和 New York 的响应时间一定会高出很多。我们刚刚讲的‘光速税’,就是这么来的,而我们是靠边缘网络把它砍掉的。”

为了让观众更直观看见,我点开 Frankfurt 的测试结果,展开响应头。“看这里。对于 Germany 这次测试,x-edge-route 显示的是 ams1。也就是说,这个请求被自动送到了 Amsterdam 节点。这不是 PPT,这是现场真流量。”

第 5 步和第 6 步:走到悬崖边

我深吸一口气:“现在来点真正好玩的。我们来亲手弄坏它。”

我切回终端。那块熟悉的黑底绿字出现在直播画面上。

“我现在要 SSH 进我们在 New York 的一台真实生产 Kubernetes 节点。” 我一边解说,一边输入:ssh root@ny-prod-01。登录成功,屏幕上很快出现了 htop,里面跑着的是正在服务真实用户的容器进程。

我对着镜头说:“这台机器现在是 live 的。它正在承接一部分美国东海岸用户流量。”

然后我把光标挪到新的一行。气氛一下子绷紧了。Arpit 整个人都往前探,带着那种工程师看到高风险动作时特有的兴奋。

“好,” 我说,“现在,我们把它关掉。”

我一个字一个字敲下命令:

sudo shutdown -h now

我故意把手指停在 Enter 上一秒,让大家感受到这一刻有多重。然后,我按了下去。

SSH 会话立刻中断。New York 服务器离线了。

第 7、8、9 步:真正的魔法

我立刻切回 KeyCDN 测试工具:“好,现在 New York 节点已经死了。我们重新跑一次测试。”

结果一项项刷出来。London,快。Frankfurt,快。Singapore,快。然后轮到 New York。HTTP 状态依旧是绿色的 200 OK。站点没掉。

但 TTFB 变了。它不再是之前那种 34ms 的轻快,而变成了 231ms

我点开这次 New York 的响应头,对着屏幕说:“真正的魔法在这里。看 x-edge-route。它已经不是 New York 了,现在显示的是 la。我们的 Anycast 网络在几秒内检测到 New York 节点不可用,并自动把美国东海岸流量切到了最近的健康节点:Los Angeles。”

然后我转向镜头:“延迟会更高,因为物理距离变远了,这是事实。但站点根本没掉。没有一个包丢失。一次真正即时、自动的 failover。

Arpit 当时的反应非常经典。他愣了好几秒,然后大笑着摇头:“这太离谱了。你真的就这么干了。你现场关掉一台生产服务器,然后系统就……自己活下来了。这才叫 Asli Engineering。”

第三部分:回声

这次现场演示,是一次彻底的“mic drop”。在那一刻,我们从“讲故事”变成了“拿证据说话”。高空走钢丝结束了,而我们稳稳落地。

Arpit 当场的震撼,只是第一层回响。接下来的几天、几周,真正的冲击来自整个技术社区。YouTube 评论区像被点燃了一样,大家讨论的不只是技术本身,更是这种“敢把真实生产系统摆上桌”的透明度。

社区反应:这也太疯了

最被反复提起的片段,就是我现场把生产服务器关机的那一段。很多评论都在反复提它:

  • “he literally shutdown the actual server for the demo. I learnt, do not over design the architecture, just see the data and build.”
  • “this is so good that I have come number of times to see this.. especially the portion where he just shuts down the server”

大家震撼的,不只是系统本身,还有我们愿意在公开场合承担这种风险、开放这种透明度的态度。有人留言说:“Man he is knowledgeable and confident enough to show the inside workings of his company. Really respect him.”

这句话抓到了本质。我们没有躲在营销话术和美化图表后面,而是把真正运行中的系统、真正的配置、真正的行为摊开给大家看。还有人说:“How many CTO / Founder share so much of internal working code on YouTube…” 这种分享本身,也建立了极深的信任。

“Asli Engineering” 的哲学

更重要的是,我们一直强调的那套工程观——简单、第一性原理、敢质疑云优先 dogma——也第一次如此大范围地找到共鸣。评论区不断出现类似的话:

  • “This is truly ‘Asli Engineering’.”
  • “Subhash bhai is Asli Engineer, forget about system design jargons.. he is living system design.”
  • “Such a great example of thinking from first principles!”

那期节目后来已经不只是一期访谈,它更像是一种对不同工程价值观的公开召唤。很多人本来只是想随便点开几分钟,最后却被一路看完。有人甚至专门注册 YouTube 账号,就是为了留下评论表达激动。

最终验证

回头看,这其实就是我们整条旅程的一个浓缩:我们从一台让我在凌晨 3 点惊醒的 512MB 单机,一路走到了一个全球自愈网络。而在这个网络面前,我甚至可以在公开直播中,心平气和地亲手关掉一台生产服务器,因为我相信它自己会恢复。

这种自信不是傲慢,而是建立在几层真实东西之上的:

  • 架构 的信任。
  • 自动化 的信任。
  • 可观测性 的信任。
  • 以及最重要的,对 团队 的信任。

我们不怕服务器坏掉,因为整个系统从设计开始就默认:故障一定会发生。 真正的工程,不是祈祷故障别来,而是让系统在故障来了时优雅地活下去。

第 18 章:关键结论

  • 验证高可用最好的方式,不是相信它,而是主动去测试它。
  • 彻底透明,会建立非常强的信任。 真架构、真数字、真演示,比一切品牌话术都更有说服力。
  • “Asli Engineering” 的吸引力,在于它强调简单、第一性原理与成本意识。
  • Show, don’t tell。 一次真实成功的 failover,比一千页高可用 PPT 都更有力量。

第 19 章:意外成为 CTO

你刚刚读完的,是一套系统的故事。关于服务器、代码与全球网络。是一台引擎如何被一点点、痛苦地搭起来的故事。

但每台引擎背后,都有一个“驾车的人”,也有属于他的那段故事。这一章,就是那个故事。

它不是一个 IIT 天才少年一路注定成功的精英叙事,而是一个来自 Bihar 小村庄、学商科、没学历、没资源的人,如何一次次被生活推入火里,最后居然成了一家全球技术公司的 CTO。

这就是那个“意外”。

第一部分:梦想之城的岔路口

一个商科生,掉进了程序员世界

我的故事,开始于 Bihar 的一个小村庄,离印度那些闪闪发光的技术中心非常遥远。塑造我人生的两件事,是我 3 岁时失去父亲,以及我母亲的坚韧。她是一名裁缝,一针一线地把我们的生活缝起来。她一直有一个简单而坚定的梦想:希望我能走出去,成为一个真正的大学毕业生。

但梦想需要钱。10+2 之后,我去了 Mumbai,本来想读 Chartered Accountant。Mumbai 是一座承诺一切的城市,但同时也是一座生存成本高得可怕的城市。最终,CA 这个梦想在现实压力下被迫搁置。我母亲的愿望只能往后放,因为我得先活下来。我必须找工作。

学了 6 个月计算机硬件之后,我进了一家叫 Zenith 的公司。第一天走进办公室时,我心里全是紧张。办公室里到处都是工程师。他们用一种我完全不懂的、带着 Java、C++、数据库 schema 的语言说话。他们每个人都像是理所当然属于这里,而我只是一个会修打印机的商科生。那种感觉非常强烈:我是 outsider,我像个 impostor。

那天,我第一次真正意识到一件事:在这个世界里,你要么 不断学习,要么就会被快速淘汰。

Virar Local 上的誓言

真正的决定,不是在某个安静下午做出的,而是在 Mumbai 本地火车上被一点点磨出来的。我的通勤,是一场每天 4 小时的生存战:从 Virar 一路挤到 Andheri 再回来。车厢里全是和我一样拼命的人。火车不在乎你有没有学位,它只在乎你有没有力气继续挤下去。

就在这种日复一日的拉扯里,我给自己立了一个誓:我不仅要生存下来,我还要比所有人都更拼。我会学会工程师的语言,直到它变成我的母语。

我的生活被压缩成一种几乎残酷的节奏:

  • 9 小时 白天上班。
  • 4 小时 通勤挤火车。
  • 10 小时 自学,从傍晚到凌晨。

连续 7 年,这几乎就是我的生活。没有周末,没有假期,没有社交。别人出去玩的时候,我在家里对着电脑啃教程,从 WordPress 开始,再拆到 PHP,再一路深入到 MySQL。

幸运的是,我遇到了一个看见我“饥饿感”的经理:Imran Syed。他没有把我当成一个商科生,而是把我当成一个愿意学的人。有一天他对我说:“Subhash,去把公司新的 intranet 搭出来。” 我把这看成一次试炼,拼尽全力把它做完。后来他又说:“再去做 partner portal。” 我又做出来了。他一次次给我真正的项目,让我在真实世界里验证能力。他是我职业生涯里,第一个真正相信我的人。

慢慢地,我不再只是那个 outsider。靠极端努力,我终于给自己挣到了一张桌子。

第二部分:催化剂,与逃离

斧头落下的那一刻

7 年高强度努力,确实给了我回报。那时的我,已经带着一支 40 人的工程团队,有稳定的收入,也有同行的认可。

然后,裁员来了。

那是一个来自遥远董事会的冰冷决定。我被叫进会议室,领到一个几乎无法承受的任务:我得亲手通知自己的整个团队,他们的岗位没了。

我永远忘不了他们眼睛里的表情:困惑、被背叛、恐惧。这些人跟着公司干了很多年,把时间和精力都投了进去,却在一瞬间被一张表格抹掉。看着他们收拾工位时,我脑子里冒出了一个极其深刻的念头:原来一个人再努力,如果命运最终只是握在别人 Excel 表格里,那到底图什么?

那一刻,创业者的种子在我心里真正生根了。我走出办公室时,脑子里只有一句叛逆又清醒的话:“Kya point hi sala job ka?”(这工作到底有个什么意义?)“Khud ka kuch karenge.”(我要做自己的事。)

那个赚到 100 万卢比的博客

我并没有什么宏大商业计划。我只是回到自己唯一真正懂的东西上:技能和互联网。我写了一个很简单的博客,主题非常窄,讲的是如何创建免费 FTP 账户。然后靠自己掌握的 SEO,把它推到了 Google 首页。

几周后,我收到一封陌生公司的邮件。他们说喜欢我的博客,想在文章里挂他们的 affiliate link。老实说,当时我甚至不太懂 affiliate 是什么。但他们说,每来一个有效注册,就给我 50 美元。我觉得像天上掉钱,但也没什么损失,于是答应了。

一个月后,我登录他们后台看数据,整个人都停住了:20 个 referral。 我立刻心算,1000 美元

那一瞬间,我脑子里像被打开了一个开关。1000 美元,比我那份工作一个月工资还多。而且它是在我睡觉的时候发生的。

我看到的不是一笔偶然收入,而是一台机器。一台我可以自己搭、自己控制、自己放大的机器。于是我彻底沉进去,开始系统性研究 affiliate marketing。不再只有一个博客,而是几十个,分别打不同关键词、不同产品。

这台机器越滚越大。到 2013 年,我的小型博客矩阵已经能稳定做到 每月 1 万美元以上。我从公司里逃了出来。但那时我依然只是“一人军队”。真正大规模的东西,还在后面。

第三部分:Suumit Shah

我已经获得了自己小时候完全不敢想的财务自由。但我仍然只是一个一人军队,而任何单人系统都有天花板。接下来这段旅程,不再只是更拼命工作,而是学会把自己放大,学会让系统替我工作。

我开始写软件来自动化自己。先是一些脚本,用来自动化创建和管理博客。慢慢地,我已经不再是在管十几个站,而是在带一支由 1500 个博客 组成的自动化军团。后来,这演化成了一个真正的平台,叫 Rankz。我和朋友 Kaustub、Anurag Meena 一起,把它做成了一款真实产品。

我对“搭系统、做规模”的兴奋越来越强,也开始在 Facebook 上分享这些实验:流量图、收益截图、一些有效方法。我不是在炫耀,只是单纯分享自己的热情。没想到,其中一条动态,会彻底改写我的人生。

那条“Hello”消息

2014 年,我在 Facebook 上收到一条私信。发信人叫 Suumit Shah。我不认识他,但他的消息和别人很不一样。不是普通的“求带一下”,而是很锋利、很有策略感,字里行间全是那种极度专注的能量。

聊了几次之后,我知道必须见一面。我们约在 Mumbai 的一家咖啡馆,见面的那一刻就知道:对的人出现了。他说话带着一种成熟创业者的笃定,而我这边拥有的是深挖到底的技术执行力。 我们把牌摊在桌上,协同效应几乎是肉眼可见的:

  • 他有遍布印度高速增长创业圈的人脉、对 business development 有天赋。他是 hustler。
  • 我有已被证明过的技术能力,以及把复杂东西真正做出来的资源。我是 hacker。

这个组合太顺了。于是我们决定,不再分别单干,而是合伙做一家公司。最开始,它是一家数字营销与增长 agency。


从 Agency 到“打手天团”

合作几乎是一开场就爆了。Suumit 带来的第一个大客户,就是一笔超过 2 crore 的合同。我们从第一天起,就不是在苟活,而是在赚钱。

我们把收入继续投入团队建设。但我们不叫他们“员工”,我们叫他们 ninjas。大概 40 个人,一群又狠又快、极度能打的增长专家和工程师。

接下来的几年,我们服务了一串梦一样的客户名单:大量高增长创业公司、上市公司,甚至 3 家 Fortune 500 巨头。我们不只是替他们投广告,而像他们的秘密武器、外部增长引擎,帮他们获得互联网规模。直到今天,我们当时搭出来的一些系统依然每月能产生上亿自然流量。

那段时期,其实就是我们真正的 MBA。是我们从内部观察互联网生意如何成、又如何败的实验室。我们见识了成百上千种互联网模式的模式与反模式。我们学会了什么真的有效,什么根本没用。

我们那时已经非常成功:团队强、业务赚钱、口碑也好。舞台已经准备好了,接下来只差一个会改变世界的触发器。而这个触发器,最终是全球疫情。

第四部分:Dukaan 革命

我们已经有了成功的 agency、优秀的团队,以及一整套内部技术能力。我们很舒服。但世界突然变了,我们的使命也跟着变了。

时间来到 2020 年。

COVID-19 疫情席卷全球,让印度几乎停摆。对 kirana 店老板、街边小商户、以及那数以百万计构成印度商业血脉的小卖家来说,这是末日。店铺关门、顾客居家、日收入一夜消失。对很多人来说,能不能把饭端上桌,都成了日常焦虑。

与此同时,一部分 India 已经在快速数字化,从杂货到电子产品都可以在线购买。但这些小商户,被整个浪潮甩在了后面。Shopify、Amazon 这类既有平台,对他们来说太复杂、太贵,或者根本不是为他们设计的。

Suumit 本身就出身商户家庭,所以他看到的不是新闻,而是自己熟悉的人群正在被时代抛下。他看见的,是一种近在眼前的痛苦和一个巨大的空白。

于是,我们做出了那场改变一切的决定:把我们关于增长、规模、技术和互联网的一切理解,压到这个问题上。 我们决定做 Dukaan

这个产品的想法极其简单,也和最初的 MVP 思想一脉相承:做一个移动应用,让任何一个小店主,不管技术水平如何,都能在 30 秒内搭好自己的线上店铺。

今天一个典型的 Dukaandar,会装上 Dukaan App,用手机相机拍商品图,上传商品,拿到独立店铺链接,再把链接发给顾客,通过 WhatsApp 接在线订单。

我们上线时几乎没有做什么营销,最初只是想帮人。结果,反应强得超出我们此前所有经验。

仅仅头两个月,就有超过 270 万 印度小商家开始用 Dukaan 在线卖货。

接下来涌来的故事,既 humble,又极度鼓舞人心:三线城市的小杂货店开始覆盖整个社区;家庭主妇靠卖手工作品第一次赚到独立收入;小城里的餐馆靠在线下单活了下来。光是那最初两个月,我们就帮助这些 Dukaandar 获得了 60 多万笔订单,创造了超过 100 crore 卢比 销售额。

这种惊人的自然增长,也迅速吸引了世界级投资人的注意。从 Matrix Partners、Lightspeed,到 CRED 的 Kunal Shah、Product Hunt 的 Ryan Hoover,再到 Razorpay、Freecharge 的创始人,他们都看见了我们所看见的东西:一场真正的商业与技术革命正在发生。

而也正是在这里,本书回到了起点。

你刚读完的整段技术旅程——从那通关于 512MB 服务器崩溃的凌晨 3 点电话,到最后建成足以扛住 Shark Tank 冲击的全球边缘网络——其实就是我们为了追上这种爆炸式增长,不断试错、不断修、不断硬扛出来的过程。

我一开始只是一个没有学历、没有经验、也充满自我怀疑的商科生。现实把我直接扔进深水区。我不是从课本学会造全球产品的,而是在真实火场里,一天一天学会的。某种意义上说,我就是那个 意外成为 CTO 的人

如果这本书有最后一条想传达的东西,那就是:你不需要完美简历,也不需要名校背景,才能做出重要的东西。你真正需要的,是一个让你在夜里睡不着的问题,以及一股愿意为解决它不断学习的执念。

我们的使命,是帮助印度未来 7000 万商家真正完成数字化。而支撑我们走向那里的,不只是投资人,更是那支每天都在创造这一切的 ninja 团队。

而回头想想,一切都只是始于一条再普通不过的 “hello” 消息。

献词

献给 Suumit Shah

人生中,你会遇到很多会成为朋友的人。如果足够幸运,你还会遇到少数能成为合伙人的人。但一生里,也许只会有那么一次,星辰真的对齐了,你会遇到那个像是你故事另一半的人。

创业是一条非常 brutal 的路。它向一个人索取的,远远超出常人的想象——身体、精神、情绪,都会被不断抽空。这条路又常常很孤独,伴随着焦虑和压力,而这些东西,往往就连最亲近的家人也未必能完全理解。尤其是在最早那些日子里,我们真的是把血、汗、泪一起砸进去,才勉强让这件事浮在水面上。一路走来,是你的激情,是你那种近乎疯狂、完全不讲条件的执行意志,撑高了我们成功的概率,也把我们一路带到了今天。

世界上最难找的,不只是一个会和你一起干活的人,而是那个能站在一块空白画布前,脑子里看到 同一幅画 的人。那个不仅能看见同样愿景,还能在最后的设计里注意到 0.5 像素差异的人。而我真的、真的非常幸运,一路都能让你站在我身边。

说真的,这 13 年里,我可能跟你待在一起的时间,比跟我自己家人都多。我们从两个带着疯狂念头的年轻人,一路走到一起造出今天这个不可思议的东西。我们吵过、庆祝过、差点失败过、也一起赢过。始终作为一个整体。

这本书写了很多关于服务器和数据库扩展的故事,但真正重要的那条故事线,其实是从你那条 “hello” 消息开始的。那是一个建立在绝对信任上的合伙故事。一直以来,你都是我代码之外的火花,是我架构之外的视野。

比这一切更重要的是,你教会了我一种活法。

这本书和你一样多,也和我一样多。

献给我的联合创始人,我的兄弟。