一个电商网站如何从 100 个用户发展到 1 亿用户

W03 加餐阅读。以虚构的“众元商城”为例,理解每一次架构调整的原因。用户规模用于叙述,不是选型门槛,也不代表本文方案经过对应规模的压测。

读图说明:服务器、圆柱和分组边界用于表示部署与职责关系,图标数量不是容量配置建议。前八阶段仍可是一套商城应用,机内业务标签不表示已拆成微服务;请求按需访问组件,返回路径及部分已有组件从略。

你的电商网站刚上线,只有一百个注册用户。大家看看商品,偶尔下一单。一台服务器就能处理,你每天最关心的是有没有人来买。

后来开始做活动,页面越来越慢。你加了应用服务器,快了一阵,数据库又忙不过来了。再加缓存,商品详情快了,历史订单查询却还是慢。

为什么解决了一个问题,又会冒出另一个?

因为一次访问要经过好几个地方。应用负责处理业务,数据库负责保存和查询数据,网络负责把内容送到用户面前。加快其中一处,其他地方的处理能力并不会跟着增加。

下面就顺着这条线,看众元商城怎么调整。先解决“请求太多”,再解决“不同工作挤在一起”,最后解决“这么多组件如何稳定运行”。每一步都从前一步留下的问题出发。

第一段:请求越来越多,压力到底落在哪里?

先跟住三个对象:用户、应用、数据库。前六步都围绕它们展开。

01|单机架构:一台机器先把生意做起来

用户买一支钢笔,点击“下单”。浏览器把请求交给 FastAPI 应用,应用检查商品、计算订单金额,再让 MySQL 保存订单、扣减库存,最后返回处理结果。

此时的路径:用户 → 应用 → MySQL;应用和 MySQL 在同一台服务器里。

单机架构:应用与数据库放在同一台机器上

“应用”和“数据库”虽然住在一起,工作却不同。应用决定“这笔订单应该怎么处理”,数据库负责“把结果可靠地存下来”。你刷新页面以后订单还在,是因为它已经被保存了。

只有一百个用户时,机器大部分时间并不忙。把程序和数据库放在一起,部署简单,花费也少。没必要先买一堆服务器等客户来。

但简单部署也要保证订单正确。创建订单与扣减库存要按事务规则处理;最后一件商品被同时购买时,还需要条件更新或锁等并发控制。不能等人多了,再补上“不超卖”这件事。

生意做起来以后,你开始批量导入新商品。导入一跑,用户下单也跟着慢。检查发现,应用处理文件和数据库写数据,都在争同一台机器的资源。

下一步要解决的,就是这两份工作挤在一起的问题。

02|应用与数据库分离:把争资源的工作分开

先别急着买机器。慢的原因如果只是查询缺了索引,修好查询可能就够了。这里假设已经检查过,瓶颈确实是应用与数据库争抢内存、CPU 或磁盘。

于是你把 MySQL 搬到独立服务器。原来的机器运行应用,新机器处理数据库工作。

改前:一台机器同时运行应用和数据库。
改后:应用服务器 → 数据库服务器,两边独立使用资源。

应用与数据库分离:两类工作独立部署

用户的下单步骤没有改变。变的是部署位置:应用需要更多计算资源,可以单独调整;数据库需要更多内存或更快磁盘,也不用连应用一起换。

这里还没有拆微服务。商城仍然可以是一套代码、一个应用。分开部署,解决的是资源争抢。

新的代价是多了一段网络通信。应用要管理数据库连接,连接失败不能一直等;数据库也不能直接暴露给所有用户。两台机器仍各有单点故障风险,不能因为数量从一变二,就当成已经实现高可用。

这次调整后,导入商品不再明显拖慢前台。可促销一开始,应用服务器自己就忙满了,数据库倒还有余力。两者已经分开,再给数据库加资源,帮不上这一回。

03|应用集群 + 负载均衡:多台机器分担业务请求

一台应用处理不过来,可以换更强的机器,也可以把同一套应用多部署几份。前者叫纵向扩容,后者叫横向扩容。

众元选择横向扩容。三份相同的应用都能查商品、创建订单,组成应用集群。再在前面放一个负载均衡入口,把每个请求分给其中一个健康实例。

改前:所有请求 → 一台应用 → MySQL。
改后:请求 → 负载均衡 → 某个应用实例 → MySQL。

应用集群与负载均衡:每个请求选送一个健康实例

假设原来有 900 个请求同时需要处理,现在可以把这批工作分给多个实例,而不是全挤在一台上。这是分担工作的示意,不意味着三台机器的实际吞吐一定精确变成三倍。

负载均衡可以由 Nginx 或云负载均衡承担。Nginx 代替后端接收、转发请求时,也在充当反向代理;负载均衡则决定请求交给哪个后端。

要让请求能落到不同实例,共享文件、会话等状态就不能只留在某台应用机器上。入口还要检查实例是否健康,避免继续把请求送到坏掉的机器。

多台应用解决了应用处理能力不足,却没有减少数据库收到的工作。 三台应用共同查询同一个 MySQL,反而可能更快地把请求送到它面前。

你很快发现,数据库里重复最多的查询,是那支促销钢笔的商品详情。

04|Redis 缓存:让一部分查询根本不用到数据库

一万人查看同一支钢笔的详情,商品名称和描述却没变。数据库为什么要把相同的结果读一万次?

可以先把查到的详情暂存起来,后面的访问复用它,这就是缓存。

缓存放在应用自己的内存里,叫本地缓存。访问快,但多台应用各有一份,更新时容易不同步。使用 Redis 作为共享缓存,可以让应用实例访问同一份热点缓存;它与 MySQL 的更新一致性仍然需要设计。

改前:每次看商品 → 应用 → MySQL。
改后:先查 Redis;命中就返回,未命中才由应用查 MySQL,再回填 Redis。

Redis 旁路缓存:应用负责查缓存、回源和回填

举个简化算例:一万次商品读取,如果九千次命中缓存,那么只剩约一千次需要回源数据库。缓存的收益是减少数据库实际要处理的查询次数。这种由应用控制查询与回填的方式叫旁路缓存(Cache-Aside)。

Redis 并不会在这里自动替应用查询 MySQL。图上应该看见两条连接:应用连 Redis,应用也连数据库。

商品改价后,要让旧缓存失效,并安排过期规则。页面显示也许可以接受短暂延迟,但下单时不能只相信缓存里的价格和库存,仍需通过权威数据和并发控制确认。

缓存让热点商品轻松了。可每个用户都要查自己的订单,结果不同,有些查询命中不了缓存,有些要求更新及时。这部分读请求还会继续到数据库。

05|读写分离:剩下必须查库的读取,让副本分担

现在数据库读得多、写得相对少。查询和索引已经做过优化,仍有大量读取需要处理。再加缓存也无法把它们全部消掉。

这次,你给 MySQL 增加只读副本。主库负责写入,再把变更复制给副本。应用按规则选择:创建订单找主库,允许一定延迟的查询可以找副本。

改前:读和写,都找同一个主库。
改后:写入找主库;适合的读取找副本;主库向副本复制数据。

读写分离:主库写入,副本承担允许延迟的读取

它与缓存的作用不同:缓存减少查库次数,读写分离分担仍然需要查库的读取。 可以同时使用,但不能互相当作同一种办法。

副本也有一个新问题。假设订单刚写入主库,异步复制尚未追上,用户马上去副本查,可能暂时看不到订单。页面上的“刚刚下单成功”,不会让副本自动变成最新。

因此,刚下单后确认结果等需要及时看到写入的请求,可以安排到主库;历史列表等按业务要求选择副本。增加副本主要扩展读能力,并没有把主库写入分给几台机器。复制也不等于备份,误删除可能被一起复制过去。

读压力缓解以后,新的订单仍不断写入主库。生意做了几年,订单容量和写入负载也逼近了单库的承受范围。

06|分库分表:这次拆开的是数据本身

你已经有了只读副本,为什么还要分库分表?因为副本主要帮助读,新增订单仍要写进主库,数据本身也还在持续增长。

先优化查询、归档冷数据、评估机器容量。如果这些办法仍不能满足需要,再考虑把数据拆开存。

改前:一个主库保存并写入全部订单。
改后:订单按规则进入不同分片,每个分片负责其中一部分。

分库分表:按客户编号把订单分配到不同分片

例如,教学上可以用 customer_id % 4 演示:客户编号除以四,余数决定订单去哪一片。同一个客户的订单放在一起,查询“我的订单”时就能直接找到对应位置。

分表是拆到不同表,分库是拆到不同数据库。需要分散物理负载时,还要安排相应的机器资源。在同一台机器上多建几张表,不会增加机器的总算力。

副本保存同一份数据的复制结果;分片分别保存不同部分的数据。 这是读写分离与分库分表最需要分清的地方。需要时,每个分片还可以再配置自己的副本。

代价也来自“分开了”:全站销售额要汇总多个分片,跨分片事务更难,扩容还涉及数据迁移。取模只是理解路由的例子,不是改个数字就能无痛扩容的生产方案。

到这里,先停一下。商城已经有好几种“加速办法”,但它们各自改变的东西很明确:

改动实际改变了什么?
应用与数据库分离两种工作不再争同一台机器的资源
应用集群相同业务请求由多个应用实例分担
Redis 缓存一部分读取不必再查数据库
读写分离剩余数据库读取交给副本分担
分库分表数据及其负载按规则拆到不同分片

这条线解决了应用与数据库的一批压力。接下来,用户又说页面慢,你查完却发现:这回根本没有卡在它们身上。

第二段:工作不同,就别都挤进同一条处理路径

看图片、搜商品、下订单,表面上都叫“访问网站”,实际需要的能力不同。接下来几步,要把这些工作分清楚。

07|CDN + 对象存储:图片慢,就处理图片的传输

用户打开商品页,标题和价格已经出来,图片却一直空着。你检查接口,响应并不慢,时间主要花在下载大图上,而且远处的用户更明显。

这时继续加数据库没有用。先压缩图片、使用合适尺寸,再根据访问分布引入 CDN。

对象存储保存商品图片等文件,CDN 在分布式边缘节点缓存内容。用户优先从较近的节点取,未命中时再回到源站获取。

改前:用户从源站下载商品图片。
改后:图片优先由 CDN 返回;订单 API 仍由商城应用处理。

CDN 与对象存储:静态图片和动态业务分开处理

这里分出了两条路线:图片走静态资源路径,下单走动态业务路径。CDN 解决的是内容分发距离和源站流量压力,Redis 在前面解决的是应用的热点数据读取。

两者都能缓存,但所在位置、缓存对象和请求入口不同。图片可以公开共享,个人订单和地址却不能随意缓存成所有人都能取到的一份。

现在图片快了,用户能舒服地浏览。可商品多起来以后,新的抱怨变成了:“明明有这件东西,怎么搜不出来?”

08|Elasticsearch:商品能存下来,还要能搜得好

用户搜索“学生练字钢笔”,希望结果合适,还想按品牌和价格筛选。商品少时,简单的名称查询够用;商品和搜索要求增多后,仅仅“包含这几个字”已经很难满足需要。

这不是再复制几份数据库就能解决的。你需要适合检索的索引和排序方式,于是为商品建立 Elasticsearch 搜索索引。

改前:商品检索主要由原来的数据库查询完成。
改后:搜索请求查专门索引;商品业务数据仍有自己的权威存储。

搜索索引:业务数据经同步进入 Elasticsearch,搜索请求查询索引

数据库里的数据按业务方式保存,搜索索引则组织成便于找词、筛选和排序的结构。团队还要配置分词、字段和排序,再用实际搜索词检查结果;装上工具不会自动得到好搜索。

商品修改后,数据要同步到索引,搜索可见性也存在刷新过程,所以搜索结果可能短暂落后。用户看到“有货”,下单时仍要重新校验,搜索索引不负责最后的库存扣减。

你可能也会听到 NoSQL。它是一类非关系型存储的统称,不是每个网站都必须新增的一台服务器。选择键值、文档等模型,仍然要回答:什么数据、什么访问方式,需要它来处理?

商城因此有了不同的处理路线。但如果这些业务代码依然挤在一个应用里,团队发布时仍会互相等待。下一步的瓶颈,开始出现在代码和团队边界上。

09|微服务 + RPC:从复制相同应用,到拆开不同业务

搜索想改排序,订单正在改售后规则。如果它们在同一个部署单元里,就可能必须一起测试、一起上线。推荐业务占用过多资源时,还可能影响交易。

之前的应用集群复制的是同一套商城代码。这次调整不同:先划清模块职责,再把值得独立部署的业务拆成服务,例如商品服务、订单服务和库存服务。

改前:一套应用里包含商品、订单、库存等模块。
改后:业务按边界拆成独立服务,必要时通过网络协作。

服务拆分:订单服务定位并调用独立的库存服务

集群是把同一种能力部署多份;服务拆分是把不同职责分开。 拆出来的订单服务,如果也忙不过来,照样可以再部署多个实例。

跨服务协作时,原来的本地函数调用变成了网络调用。RPC 是远程过程调用,gRPC 是常见实现之一;HTTP API 也是常见通信方式。实例地址会变化,因此还需要通过 DNS、注册中心或平台机制发现服务位置。

独立发布与扩容更方便,失败处理却更难了:库存已经预占,订单服务没收到响应,究竟成功没有?不能简单认为“超时就是没执行”。需要稳定的请求标识、可查询的状态和必要的补偿;拆到不同数据库后,也不能继续指望一个本地事务包住全部操作。

微服务主要处理职责、发布和协作问题。没有这些实际需求时,模块清晰的单体仍是可行选择。

拆完以后,你发现有些服务不必同步等。比如订单已经成立,为什么还要等短信发完才告诉用户?

10|消息队列:把可以稍后完成的工作移出等待链

现在下单流程还会通知用户、更新统计。如果每件事都做完才返回,用户等多久,就会受这些后续工作的影响。

假设必要的订单处理用 100 毫秒,通知要等 800 毫秒,统计再等 100 毫秒。串行处理约要一秒。这里是演示数字,只为了看清等待怎么叠加。

可以让必要的交易步骤完成后,可靠地记录、投递事件,再由消息队列把后续工作交给消费者。用户不必等通知与统计全部执行完。

改前:订单处理 → 通知 → 统计 → 返回用户。
改后:订单必要步骤完成后返回;事件经队列交给后续消费者处理。

消息队列:交易事件可靠投递,通知和分析异步消费

消息队列改变的是什么时候做、谁等谁。它也能暂时缓冲流量波峰,但不增加消费者本身的处理速度。长期进得比出得快,积压仍会增长。

异步还要处理两种情况:订单落库了,消息没发出去;消息发过了,又被重复投递。事务发件箱可以在同一个本地事务里保存订单状态和待发事件,再由后台投递。消费者则需要识别重复,保证幂等,避免一条事件反复造成额外业务结果。

只有允许延后的工作才能这么移走。必须确认的库存或其他交易条件,不会因为“消息入队了”就算完成。

到这里,页面加载、商品搜索、订单处理、后续通知,各有合适的路线。组件更多了,一次局部故障会不会沿着调用关系,把其他地方也拖住?

第三段:组件越来越多,怎么让局部故障停下来?

11|限流、熔断、降级:先保护还能正常工作的部分

推荐服务变慢,调用方一直等。等待占住连接和执行资源,后来的请求也开始排队。如果大家又不断重试,原本只在推荐服务的问题,就可能扩散到商品页面和订单。

“多加几台机器”有时能缓解,但只要仍然无上限地等待和重试,问题就可能再次发生。

改前:请求不断进入,遇到慢依赖就继续等。
改后:入口控制过载;调用设限;非关键功能失败时按规则退让。

服务保护:在入口和依赖调用处限制过载,隔离故障

这三个常放在一起的名词,各管一件事:

机制在控制什么?商城里的例子
限流允许进入多少工作超额请求明确拒绝,或进入有上限的队列
熔断是否继续调用故障依赖推荐持续失败时暂停调用,稍后探测恢复
降级暂时减少哪种业务能力个性化推荐不可用时显示普通商品列表

推荐可以替换成默认结果,订单成功却不能造假。库存无法确认时,要给出符合业务规则的失败或待确认状态。

调用还应有超时预算、有限重试和资源隔离。否则推荐占光共享连接,订单即使本身健康,也可能无资源可用。

怎么找到问题在哪?指标帮助发现哪个环节异常,日志提供具体事件,链路追踪把一次请求跨服务的路径与耗时连起来。这些可观测能力应该从早期逐步建立,服务增多后尤其需要贯通。

系统开始能承受一部分故障了。但下一次发布,某台机器缺依赖,新版本又启动不了。此时需要解决的,是交付方式。

第四段:从程序能运行,到持续交付、扩容和恢复

这一段的工具可以很早使用,不需要等用户很多才引入。放在这里,是为了说明它们与前面解决性能、业务边界的手段有什么不同。

12|Docker:把要运行的应用版本准备成同一份镜像

同样代码,一台机器能运行,另一台报错,常见原因是依赖或环境不一致。每次到机器上临时安装,版本差异就容易越积越多。

你把应用和所需依赖构建成 Docker 镜像,测试与部署使用同一版本,再从镜像启动容器。

改前:在不同机器上分别准备应用环境。
改后:构建明确版本的镜像,再从它启动多个容器实例。

Docker:镜像用于交付同一版本,容器是启动后的实例

镜像是启动模板,容器是运行实例。可以先让少量新版本实例接流量,确认正常再逐步替换,有问题退回旧应用版本。

Docker 主要解决交付一致性,不负责替你优化慢 SQL。镜像也不包含一整台机器,运行仍依赖兼容的底层环境。配置和密钥单独管理,数据库数据则需要持久化与备份。

现在启动一份应用更方便了。实例多起来以后,谁来决定它们放在哪、坏了补几个、更新时替换哪批?

13|Kubernetes:持续管理这些运行实例

只运行几个容器时,简单部署方式可能足够。数量增多、发布频繁后,人工调度和故障补实例容易跟不上。

Kubernetes 让你描述期望状态,例如某应用保持三份副本。控制器持续检查实际状态,并协调发布、补副本等工作。

改前:人逐台启动、检查和替换实例。
改后:声明期望状态,由平台持续协调实际运行情况。

Kubernetes:声明期望状态,协调实例运行与更新

先分清 Docker 与 Kubernetes:前者帮助准备和运行应用容器,后者主要编排管理容器化工作负载。这里说的是能力分工,不代表 Kubernetes 必须使用 Docker Engine 作为运行时。

Pod 是运行容器的基本单位,Deployment 管理一类应用的副本与更新,Service 提供稳定的服务访问抽象。理解它们时,先抓住“运行单位、期望状态、访问入口”,不用在这一篇里学习配置细节。

自动补副本也要有空闲资源。扩应用前,还要检查数据库等下游是否承受得住新增流量和连接。

云平台则提供计算、网络、存储、托管数据库等资源或服务。你可以很早上云,也可以不上 Kubernetes;按需要组合,不是按名词顺序集齐。

平台能补一个应用实例,并不等于能自动处理整个区域失效。最后,还要把故障范围放大来看。

14|容量规划 + 容灾:一亿用户需要哪些运行证据?

众元发展到一亿注册用户,仍然不能据此算出服务器台数。真正需要知道的是:多少人每天使用,峰值每秒多少请求,多少命中缓存,多少是必须完成的交易。

举一个假设:每天一千万人使用,每人产生二十次 API 请求,一天就是两亿次,平均每秒约 2,315 次。活动峰值可能远高于平均值,要结合实际流量分布估计,再通过压测校准。不同网站都叫“一亿用户”,实际压力可能相差很大。

改前:主要处理实例级故障,按已有资源运行。
改后:按真实负载规划容量,并为更大范围的故障准备、验证恢复方案。

容量与容灾:主区域、备用区域和恢复验证需要配套设计

多可用区部署可以减少单个机房故障的影响,但入口、应用、数据层都要配套。跨地域灾备还需明确两个目标:RTO 是希望多久恢复服务;RPO 描述可接受的数据恢复点落后程度,常用时间表达可能丢失的数据范围。

数据库复制可能有延迟,切换还要防止旧主继续写入。用户能重新打开网页,只证明一部分恢复了;订单有没有遗漏、重复,库存是否一致,还需要核对。

面向大规模用户,验收的对象是整条业务路径。 压测要看关键请求耗时、错误率和资源余量;恢复要做演练、实际恢复备份,并检查交易数据。没有这些证据,组件再齐全,也不能直接说能支撑一亿用户。

把整套架构接起来:一个用户并不会依次经过所有组件

前面的图为了突出每次变化,省略了其他部分。把它们接起来以后,要按用户正在做的事来读:

电商架构总览:图片、查询、交易和异步任务各有路径,部署与容灾提供支撑

用户打开商品页时,图片可能由 CDN 返回;搜索商品时,会查询搜索索引;读取热点详情时,应用可能命中 Redis;提交订单时,必须进入负责交易的服务,按权威数据完成必要处理。后续通知与统计,可以经消息队列异步完成。

Docker 和 Kubernetes 帮助交付、运行这些应用,监控与容灾方案帮助发现故障、恢复业务。它们不是用户请求在 Redis 后面还要依次经过的几个站。

全文的演进可以压缩成这几步:

瓶颈变成了什么架构怎么调整
应用和数据库争资源分开部署
一份应用忙不过来应用集群 + 负载均衡
数据库反复回答相同查询加缓存,减少回源
仍需查库的读取太多副本分担读取
单库的数据与写入吃不消按规则分片
图片传输、商品检索有专门需求CDN、对象存储、搜索索引分别处理
不同业务相互牵制按业务边界拆服务
后续任务拖长用户等待消息队列异步处理
慢依赖和过载扩散限流、熔断、降级与资源隔离
交付和实例管理复杂镜像交付、平台编排
故障范围扩大、流量难预估容量规划与容灾演练

从一百个用户到一亿用户,架构并没有一条必须走完的升级路线。你需要判断的是:当前哪一份工作太多、哪几份工作互相影响,以及这次改动具体分担、减少或隔离了什么。

合上文档前,试着回答四个问题

  1. 应用 CPU 很忙,数据库却很空闲,为什么加数据库副本通常帮不上忙?
  2. 缓存、读写分离、分库分表都能缓解数据库压力,它们到底改变了哪件不同的事?
  3. 商品图片很慢,和创建订单很慢,为什么不能直接使用同一个优化方案?
  4. 使用 Docker、Kubernetes 后,为什么仍然不能宣称网站能承受一亿用户?

回答时,尽量说出“谁在做什么、改完少了什么工作或换谁来做”,而不只报技术名词。

参考思路:第一题要扩展真正忙碌的应用处理能力;第二题分别是减少查库、分担读取、拆开数据与负载;第三题要先区分静态传输与交易处理的瓶颈;第四题还缺真实负载下的容量、正确性和恢复证据。

延伸阅读

本篇保留 W03 的 FastAPI + MySQL 背景,用于架构认知加餐,不增加本周项目的实现与验收范围。HTTPS、权限、备份等基础工作应按业务需要从早期建立,不因在本文中篇幅较少就可以忽略。

配套视频《一口气讲清楚互联网高频名词》约 41 MB,超过当前站点单文件限制,暂未上线;本文与配图可正常阅读。

此文件夹下有0条笔记。