系统设计:案例实战
系统设计:案例实战
一、短链服务
前面做了这么长时间的铺垫,我们学习了架构和系统设计的各种“脚手架”——理论基础、基础组件、数据存储、服务治理……现在,这些知识终于要派上用场了。从这一节课开始,我们将进入系统设计的经典案例环节,真刀真枪地解决实际问题。
其实,系统设计和算法刷题很像。刷题需要掌握数据结构与算法,然后灵活运用来解决问题;系统设计同样需要一套基本的“脚手架”,也就是我们前面讲的那些内容。
在接下来的每个案例讲解中,我不会直接抛出一个最终的架构图或设计文档。我们交付的不是一份冷冰冰的设计方案,而是一个完整的思考过程。从需求分析、性能评估、其他非功能性要求开始,到初步设计、给出技术解决方案,再到设计优化,优化性能、可用性、可靠性等等,最后,讨论演进方向,如何应对x10倍流量增长。你可以把每一节课,都当成一次系统设计的模拟面试或技术讨论。
通过这些案例,你学到的绝不仅仅是某个特定系统的设计方案,毕竟系统设计的问题千千万,我不可能把所有问题都讲一遍,你也不可能靠记忆去应对所有的面试。我希望你能真正掌握的,是系统设计的思考方法,以及如何合理运用那些“脚手架”去解决未知问题的能力。
这节课,我们先从一个简单的系统设计问题出发,展示以上系统设计的思考过程。 这个系统设计问题是:请设计一个类似 TinyURL短链服务。
1. 系统分析
拿到问题之后,我们一定不要立刻就去做设计,而是先要搞清楚需求,包括功能性需求和非功能性需求。需求从何而来?一般是看这个系统的应用场景,可以是来自自己的调研,或者跟技术leader、面试官的讨论等。
(1)功能性需求分析
功能性需求怎么分析整理?如果你没法很清晰的把需求整理出来,我的一个方法是通过编写user case(用户用例)的方法来初步罗列功能需求,然后基于此再整理,比如设计一个停车场管理系统。
当然,对于短链服务这个系统设计,需求是比较简单的,我们直接罗列如下:
- 用户提交一个长的URL,得到一个短链;
用户输入 https://www.example.com/articles/123?utm_source=newsletter
服务返回 https://short.com/abc123- 用户访问短链,跳转到原始URL;
- 用户可以自定义短链,方便记忆;
- 用户可以设定短链的过期时间:永久或者7天有效等;
对于系统设计面试来说,我们需要在有限的时间里,设计相对完整的系统,如果所有的功能需求都实现,很有可能时间不够,因此,我们往往需要跟面试官沟通,划定要解决的功能需求的边界,也就是在接下来的系统设计中,要做什么,暂时不做什么。这一点非常重要!
对于短链服务这个系统设计问题,因为功能并不多,可以都实现,当然,我们也可以只实现核心基本功能,也就是前面罗列的前两个功能需求。
(2)非功能性需求分析
非功能性需求最主要的一点就是性能分析,毕竟架构的复杂度往往都是因为性能压力导致的。我们需要合理假设日活,并正确估算 QPS、存储量等。当然,除了性能分析,不同的系统设计问题,可能有特有的其他需求,比如延迟、可用性、安全性、唯一性(短链就有这个要求)等等,需要你自己去挖掘。
对于短链服务,我们进行性能估算。
假设每天要生成100万个短链,平均每个短链被访问 100 次(有的热门链可能被点几万次,有的只有几次)。基于这个假设,我们得到如下性能估算。
- 读 QPS = 100万 × 100 / 86400 ≈ 1157,峰值按 3 倍算 ≈ 3500 QPS。
- 写 QPS = 100万 / 86400 ≈ 11.6,峰值 ≈ 60 QPS。
对于存储方面,每条记录包含短链码(6~8 字符)、长链接(平均 200 字节)、创建时间、过期时间等,按 300 字节估算。一年数据量 = 100万 × 365 × 300B ≈ 110GB。五年大约 550GB。
2. 初步设计
有了以上需求分析之后,我们先梳理基本的处理流程,并设计初步的架构方案。初步架构方案只实现功能,可以不考虑性能等非功能性的需求,这样设计的难度就简单了很多,解决了面对系统设计题目时,我们总感觉无从下手的问题。
有了初步的架构设计方案之后,以此作为基础,再进行优化,考虑性能、可用性、可靠性等一系列非功能性的需求,得到最终的版本。这就有点类似算法刷题,如果一时半会想不到最优算法,可以先想一个时间空间复杂度稍高的算法,在此基础之上,再去优化。
(1)核心功能实现
好了,我们再回到短链服务这个问题上。针对功能性需求,我们先梳理一下前后端交换和后端的处理流程。
- 获取短链的流程:用户在网页上输入长链接之后,前端将长链接通过POST HTTP 接口发送到后端系统,后端系统接收到长链接之后,生成短链码(比如abc123),然后,将短链码、长链接等信息存储到MySQL数据库,并返回短链接(短链服务域名 + / + 短链码)。

- 访问短链的流程:用户访问短链接时,后端系统根据解析得到短链码,然后查询数据库得到对应的长链接,并返回HTTP 302响应,引导浏览器重定向到长链接对应的网页。

数据库表的设计非常简单,如下所示。我们在short_code上建立了索引,方便根据短链码查询长链接。
CREATE TABLE `short_url_map` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`short_code` varchar(16) NOT NULL COMMENT '短链码',
`long_url` text NOT NULL COMMENT '原始长链接',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`expires_at` datetime DEFAULT NULL COMMENT '过期时间,为空表示永不过期',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_short_code` (`short_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短链映射表';(2)关键技术讨论
从上述处理流程,我们发现,其中有一个非常关键的设计,那就是:短链码的格式和生成算法。接下来,我们重点解决这个问题。也就是系统设计中的其中一个步骤:关键技术、核心算法、技术难题的讨论和解决。
对于短链码,我们有两个要求,一个是尽量短,一个是唯一。那么,如何生成唯一的尽量短的短链码。如果你对我们前面的课程掌握的比较牢,你应该能联想到前面讲到的分布式ID生成算法这个知识点。在那节课里,我们讲到了很多算法,比如基于数据库自增ID、UUID、雪花算法、Redis INCR、号段模式等等。
- 对于自增ID,数据库性能将会成为瓶颈,分库分表来解决,显然比较麻烦;
- 对于UUID,长度是128bits,32个十六进制字符串,好像有点长;
- 对于雪花算法,长度可以接受,无状态,不需要存储,64bits,性能超高!
- 对于Redis INCR,虽然Redis性能极强,10万QPS的量级,但是自增模式,泄露业务数据量;
- 对于号段模式,它是基于Redis INCR性能的增强版,也解决不了泄露业务信息的问题。
对比来看,雪花算法最合适。你看,前面学到的知识不就用上了吗?面试官不管怎么追问短链码的生成细节,你都能对答如流!这就是我们前面的课程存在的意义!
64bits的雪花算法生成的ID,如果转化成16进制表示(也即是用09,AE来表示),长度是16个字符(4个bits是一个16进制数),似乎还是很长啊!我们是否可以用更高进制表示呢?
如果短链码中允许0 ~ 9,A ~ Z,a ~ z,这62个字符,那么,我们就可以使用62进制来表示雪花算法生成的ID。将64bits的数字,转化成62进制表示,最大长度是6,也就是基于62进制表示短链码最多只有6个字符长度,已经足够短了,满足题目的要求!
3. 优化方案
以上给出的设计方案,单从功能上来考虑,已经能用,但没有考虑到非功能性需求,特别是性能这个重要的方面。接下来,我们基于以上方面,做架构优化(或者说是演进),以满足非功能性要求!
(1)性能优化
通过合理的假设和估算,我们在前面得到,峰值读QPS大约3500,峰值写QPS大约60,典型的读多写少。存储压力为一年存储量为110GB,五年大约550GB。
这样的访问量,对于后端服务器的压力是不大的,而且,雪花算法是无状态的分布式ID生成算法,支持多机部署,因此,即便后端服务器在生成ID上有性能压力,我们也可以很容易的做水平扩展,多部署几台后端服务器,前端使用Nginx等负载均衡器进行流量路由。
也就是说,压力完全在MySQL数据库。前面在讲解MySQL的时候,讲到了它的性能表现。对于简单的查询,具有索引,并且索引能加载到内存中,查询效率是很高的,甚至可以接近Redis的水平。在我们这个场景中,前两个条件都符合,但是,每年110GB的数据,对应的索引,也就是B+树,层级可能会比较高,且不一定能全部加载到内存中,因此,查询效率可能会急剧降低。
如果今后还要应对x10倍的流量增加,可能就必须动用分表。预先创建256张表,一次管够,避免扩容导致的数据迁移。基于短链码进行分表,如 hash(shortcode) % 256,然后得到对应的表号。这样每张表数据量可控(五年 550GB / 256 ≈ 2.1GB/表)。如果单个数据库存储这256张表访问有压力,也可以再分库,也就是把这些表分别存储到不同的数据库实例中。
换个思路,我们真的需要MySQL吗?
在这个场景中,我们没有复杂的查询,不需要事务,NoSQL数据库显然更加高效,而且天然支持分布式部署。因此,我们可以将MySQL替换成HBase、Cassandra这类列式存储系统。当然,这也要看你的团队的运维能力,对哪个存储系统更加熟悉,如果对MySQL更有经验,也可以继续使用MySQL。
数据库配缓存,这基本上是高性能读写标配的解决方案。这里也不例外!特别是,在这个场景中,明显读远多于写,而且数据存储到数据库之后基本不会去修改,特别适合用缓存。缓存的命中率要在90%以上,因此,数据库的访问压力就很小了。
前面我们用专门的一节课讲了缓存,这里就不赘述了,基本上可以完全搬移过来使用,而且,这个场景中,也有热key问题,有些链接访问可以比频繁,解决方案我们也提到了,你自己去那节课看下吧,应对面试官的细节拷问,完全不用怯场!
当然,对于这个场景,提高性能的方式还有一个特别的方法。那就是,当访问短链时,短链服务返回HTTP302响应时,可以设置更长时间的TTL(比如1个小时),在这一个小时内,浏览器会缓存跳转关系,再次访问短链时,浏览器直接跳转,不需要访问短链服务。如果短链和长长链接的对应关系长久不变,短链服务可以返回HTTP301响应,浏览器会永久缓存跳转关系,进一步降低了短链服务的压力!
(2)其他优化
除了性能方面,对于其他非功能性需求,我们也可以展开讲讲,比如系统的可用性、数据的可靠性等。
MySQL我们使用主从架构,Master负责读写,Slave负责备份,这样设计的目的是,防止数据丢失,并且,因为缓存已经挡住了大部分的读压力,Slave就不用去分担读压力了,只负责备份就可以了,更加简单。当然,更牛逼点的,可以再搞个自动故障转移,但一般也不会搞这么麻烦!
对于前面估算的性能压力,读QPS3500,对于Redis来说,算是毛毛雨了。110GB的数据,如果有20%是频繁访问的,那么就是20GB。如果服务器内存比较大,单个Redis实例就够了,否则,我们可以使用Redis Cluster,既兼顾了性能,又保证了数据可靠,还自带故障转移,提高了可用性。
除此之外,为了防止恶意攻击,比如跑脚本去生成短链,我们可以在API-Gateway这一层开发限流服务,限制IP每秒生成次数,比如10次/秒/IP等。这个我们前面讲到服务治理的时候,详细讲过了,这里也不赘述了。
4. 最后总结
这节课主要借助一个简单的系统设计问题(短链服务),像你展示了系统设计的思考过程,先做系统分析(功能性需求和非功能性需求),划定问题的边界、合理假设和估算性能压力等,然后给出初步的设计方案,这一步可以先不考虑性能要求等非功能性需求,着重实现基本功能和关键技术、核心算法、技术难题的讨论。最后,基于初步设计方案,进行优化,着重解决性能问题、可用性、可靠性等等非功能性要求!
二、实时排行
这一节课我们来讨论实时排行榜这样一个系统设计问题。无论是游戏中的战力榜、直播间的礼物榜,还是电商平台的热销榜,背后都离不开高效的排名系统。为了方便讨论,我们聚焦在游戏战力排行榜或者积分排行榜(以下都统一为积分排行榜)这一场景,在每场对局结束之后,玩家的积分都会更新。游戏中有一个页面展示积分TOP100的玩家,要求排行榜尽量实时更新,面对千万级玩家,如何实现这样一个排行榜?
1. 系统分析
有了上一节课的学习,我们应该已经掌握的基本的系统设计流程。拿到问题,先要进行功能性需求分析和非功能性需求分析,确定业务边界和非功能性要求。这个题目也不例外!
(1)功能性需求分析
其实,游戏积分排行榜的功能需求也非常简单:
- 更新分数:玩家完成对局后,根据表现计算获得的积分,更新到系统中。
- 查询榜单:展示当前积分前 100 名的玩家列表,包括他们的分数和排名。
- 查询个人排名:给定玩家 ID,返回他当前的排名和分数。
(2)非功能性需求分析
题目中提到这款游戏的玩家数量是千万级别的,我们假设有5000万注册用户,日活用户大约是500万,我们基于此估算排行榜的读QPS、写QPS、用户和积分的存储规模。
- 写 QPS 估算
每场对局结束后会触发一次积分更新。假设每个日活用户平均每天进行 10 场对局,则每日总对局数 = 500 万 × 10 = 5000 万场。通常系统峰值流量是平均值的 2~3 倍,且集中在每天 4 小时的晚高峰时段。那么,峰值写 QPS ≈ 5000 万 / (4 × 3600) × 3 ≈ 10416,也就是可以达到上万的写QPS。
- 读 QPS 估算
相对来说,查看TOP100排行榜的频率,并没有更新排行榜的频率高,但是,他有可能每局游戏之后,都会看一下自己的积分和排名情况,因此,在这个场景中,读QPS跟写QPS相当,也是1万左右。
- 存储规模估算
注册用户 5000 万,每个用户需要存储玩家 ID(假设为 8 字节长整型)和当前积分(4 字节整型),除此之外,还有一些额外的开销,比如,如果使用 Redis SortedSet 存储,每个成员除了存储玩家ID和积分外,还有跳表节点指针、哈希表条目等额外开销(SortedSet基于跳表和哈希表实现)。实际每个成员约占用 50~80 字节。基于此,我们预估存储规模为:5000 万 × 70 字节 ≈ 3.5 GB,这个存储需求并不大,不管是放到内存还是磁盘,都能满足。
- 其他非功能性要求
每局游戏结束之后,用户可能会立马查看自己更新之后的积分和排名,这个对实时性要求相对要高!不过,TOP100排行榜的实时性要求就没那么高了,几秒甚至几分钟的延迟都能接受,看具体的需求,我们这里假设可以接受1秒的延迟。
2. 初步设计
需求确定之后,我们先做一个初步的设计方案,其中就包括上述的功能实现,以及关键技术、核心算法、技术难题的重点讨论。
(1)核心功能实现
实现功能,最简单的做法是,将用户ID和积分等信息存储到一个数据库表中,当一局游戏结束之后,根据用户ID去更新数据库表中的积分。当需要查询TOP100排行榜时,我只需要使用以下SQL语句来查询:
SELECT ... ORDER BY score LIMIT 100不过,查询某个用户的排名,该怎么来实现呢?我们可以先算出有多少人的积分大于当前用户,然后加 1。
SELECT COUNT(*) + 1 FROM players WHERE score > (SELECT score FROM players WHERE player_id = ?)这个语句在千万级数据下,即使 score 上有索引,也需要扫描所有比当前用户分数高的人。如果用户排名靠后,扫描量极大,而且每次查询都要执行一次全索引范围扫描,性能很差。所以,用关系型数据库直接做个人排名查询,单个数据库是无法实现1万QPS的读写的(更新score和查询个人排名)。
既然单库不行,那么,分库分表可以吗?也不行,你看以上的查询都是复杂查询或者分页查询,分库分表之后,查询SQL要改造,一次查询操作要查询多个数据库,然后再将结果合并,非常麻烦!
放弃关系数据库之后,还有什么其他的数据库可用吗?
我们在前面将KV数据库的时候,提到过,Redis SortedSet基于跳表和哈希表实现,天然支持排行榜的功能。Sorted Set 会为每个玩家(player)关联一个对应的积分(score),并始终保持按积分自动排序。
- 当需要设置或更新玩家分数时,可以使用 ZADD 命令。如果玩家不存在,ZADD 会直接添加;如果玩家已存在,则会用新分数覆盖旧分数。除此之外,ZINCRBY 命令可以直接增减玩家的score,具体用哪个看你的设计。
ZADD game_rank 1500 player_123- Sorted Set 的成员默认是按分数从小到大排序的。要获取分数最高的前 N 名,就需要按分数从高到低来查询。ZREVRANGE 命令就是为此设计的。REV 代表 Reverse(反转)。比如想获取积分前100名的玩家,使用 ZREVRANGE 就能一次性拿到,速度极快。
ZREVRANGE game_rank 0 99 WITHSCORES- Sorted Set 也提供了直接查询某个玩家当前排名的命令ZREVRANK 。
ZREVRANK game_rank player_123(2)关键技术讨论
Redis是基于内存的,一旦宕机或者重启,数据就会丢失。该怎么办呢?
方案一:Redis 持久化
Redis 提供了两种持久化机制,可以让我们在重启后恢复数据:
- RDB(快照):定期将内存中的数据全量写入磁盘。缺点是两次快照之间的数据可能丢失。如果排行榜允许丢失几分钟的数据,RDB 就够了。但每局游戏都更新积分,玩家很在意,丢失几秒的数据也可能引发问题。
- AOF(追加日志):每一条写命令(如 ZADD)都会追加到日志文件中。恢复时重放所有命令。可以配置 appendfsync always/everysec/no。everysec 是推荐配置:每秒同步一次,最多丢失 1 秒的数据。这对排行榜来说是可以接受的(1 秒内的积分变化丢失,玩家可能感觉不到)。
对于排行榜场景,玩家积分丢失 1 秒是可接受的,我们选择 AOF everysec。同时开启 RDB 作为恢复加速。但即便如此,如果 Redis 所在的机器彻底损坏,数据仍然可能丢失。所以我们还需要主从复制。
方案二:主从复制 + 哨兵
配置一个主 Redis 实例负责读写操作,一个或多个从实例实时同步主的数据。当主宕机时,可以手动或自动将从提升为新的主。如果我们希望实现自动故障转移,我们可以使用Redis Sentinel,自动监控主从状态,实现故障自动切换。客户端连接哨兵,哨兵会告知当前主节点的地址。
这样一来,即使主节点宕机,系统能在几秒内完成切换,且从节点拥有几乎完整的数据(异步复制可能有微小延迟)。对于排行榜场景,这种方案足够应对绝大多数故障。
方案三:数据库备份
即使做了持久化和主从复制,极端情况下仍可能丢失少量数据。我们可以使用数据库做备份。为了避免1万QPS打垮数据库,我们把每场对局的积分变化先记录到消息中间件中,然后再拉取消息中间件中的流水,更新到数据库中。当 Redis 数据丢失时,可以使用数据库中的记录来恢复。
对于千万级玩家的排行榜,我们选择 AOF(everysec)+ 主从 + 哨兵 作为标准方案。如果业务要求零丢失,再增加数据库记录做双重保证,基本上不那么背的情况下,中间件、数据库、Redis硬件都坏了,否则,不会丢失数据。
3. 方案优化
以上是初步方案,我们再来看非功能性需求是否都满足,有哪些可以优化扩展的地方。
我们的数据量(3.5GB)和 QPS(写 1 万,读 1 万)单机Redis完全能扛住,如果后续用户x10倍增长,读写QPS达到10万+,数据量到达35GB,这个时候,我们可以使用Redis Cluster做数据分片。
但问题来了:一个 Sorted Set 在 Cluster 中是如何存储的? 默认情况下,Redis Cluster 根据 key 的哈希值分配 slot,整个 Sorted Set 作为一个 key,只会落在一个节点上,并不会自动分片。也就是说,即便有100个节点,rank:global 这个 Sorted Set 仍然只存在于某一个节点。数据量35GB、QPS 10万+,单节点依然扛不住。
我们需要的不是把一个巨大的 Sorted Set 放到一个节点,而是将排行榜分片存储——比如按玩家 ID 哈希分成多个 Sorted Set,每个节点负责一部分玩家。但这样,查询全局 Top 100 就变成了一个分布式归并问题。
- 创建多个 Sorted Set,例如 rank:0, rank:1, ..., rank:N-1(N 为分片数,比如 64)。
- 写入时,根据玩家 ID 哈希决定写入哪个分片:ZADD rank:{hash(player_id) % N} score player_id。
- 查询全局 Top 100 时,需要从所有分片分别获取局部 Top 100(ZREVRANGE rank:i 0 99 WITHSCORES),然后在应用层归并排序,取出总分最高的 100 个玩家。
这个方案的问题:每次查询都要访问所有 N 个分片。如果 N=64,就要发 64 次网络请求。对于 10 万 QPS,64 × 10万 = 640 万次内部请求,Redis Cluster 压力很大,且归并排序本身也有开销。
我们可以在排行榜服务中加入本地缓存(Caffeine/Guava Cache),缓存整个 Top 100 榜单,设置过期时间为 1 秒(TOP100排行榜要求实时性 1 秒)。当客户端请求榜单时,优先返回本地缓存的数据。这样1秒才执行一次统计TOP100的操作,其他大部分时间都是访问已经统计好的本地缓存,Redis的压力降低了很多!
4. 最后总结
本节课讲解了如何实现实时排行榜,我们统计的用户积分是累加积分,适合游戏这个场景,但对于多场景需要的是时间窗口内的排行榜,比如“本周活跃榜”、“上月消费榜”、“近7日热销榜”,持续累加积分不适合了,需要统计最近时间窗口内的积分,本节的系统设计方案就不适用了,需要改造,如何做呢?我们留在下一个案例中讲解。
三、微博热榜
上一节课,我们讲到实时排行榜,不过,这个排行榜默认是长期累积的总榜,所有历史对局的积分都累加在一起。但是,很多场景需要的是时间窗口内的排行榜,比如“本周活跃榜”、“上月消费榜”、“近7日热销榜”。如果直接复用上一节课的设计方案,会出现两个问题:
- 分数永不衰减:老玩家的历史优势会长期霸榜,新玩家永远赶不上。
- 无法自动过期:上周的数据还在影响本周的排名,不符合业务语义。
那么,如何改造才能支持类似“最近一周”这样的滑动窗口内的排行榜呢?本节课,我们就以“微博热榜”这个系统设计问题为背景,带你了解这一类问题的设计思路。
1. 系统分析
我们把问题描述一下:假设我们要设计一个类似微博热榜的功能,显示最近一小时的 TOP50 热门微博,并且每隔几分钟刷新一次榜单。
同样,基于这样一个较模糊的需求,我们需要通过沟通、假设、估算等手段,合理的进行需求分析,包括功能性需求分析和非功能性需求分析。
(1)功能性需求
- 查看榜单:用户查看榜单获取TOP100 热度微博;
- 刷新榜单:每隔5分钟刷新一次榜单;
- 热度计算:每条微博有一个热度值,按照转发、评论、点赞、查看这几种行为加权计算得到;
(2)非功能性需求
我们假设微博的日活用户是1个亿,然后对峰值写QPS、读QPS,以及存储规模做一个预估。
- 写 QPS 估算
用户对微博的每次转发、评论、点赞、查看行为都会影响热度。假设每个日活用户每天平均产生 20 次互动行为(包括查看),则每日总事件数 = 1 亿 × 20 = 20 亿次。流量通常集中在晚高峰 4 小时,且峰值可能是平均值的 3 倍。峰值写 QPS ≈ 20 亿 / (4 × 3600) × 3 ≈ 4.17 × 10^6,即约 400 万。
- 读 QPS 估算
假设每个日活用户每天查看 2 次热榜,则每日总查询 = 1 亿 × 2 = 2 亿次。同样按 4 小时高峰、3 倍峰值因子计算:峰值读 QPS ≈ 2 亿 / (4 × 3600) × 3 ≈ 41.7 万,即约50万。
- 存储规模估算
热榜只关心最近 1 小时内的微博热度。我们假设1 小时内产生互动的活跃微博数量,大约在百万级别(假设 500 万条)。每条微博需要存储其热度值(整数,4 字节)以及微博 ID(8 字节),加上 Redis SortedSet 的额外开销(约 70 字节/成员),总内存 ≈ 500 万 × 70 ≈ 350 MB,没有什么压力!当然,这只是初步的预估,后面根据设计方案,需要存储的数据量可能会调整。
- 其他非功能性要求
热榜每隔5分钟才刷新一次,对实时性的要求并不高,我们这里可以想到,可以每5分钟计算出TOP100后,缓存起来,之后的所有查询都不需要重复计算。
2. 初步设计
我们先抛开性能等非功能性要求,给出一个基本功能的实现方案。
(1)核心功能实现
微博热榜这个设计问题,跟上一节课讲到的游戏实时排行榜,有相同的地方,也有不同的地方。相同的地方是,微博热榜计算得到的每个微博的最近一小时的热度,也可以存储到Redis SortedSet中,方便获取TOP100数据。不同的地方是,最近一小时是一个滑动窗口,随着时间的往前推移,老的事件(点赞、评论、转发、查看等)对热度值的影响会消失,新的事件对热度值的影响会累加。
如果我们每隔五分钟,就对最近一小时的事件全量做一个统计,累加得到每条微博的热度值,这个计算量是非常大的(峰值事件QPS400万 x 3600秒 = 144亿事件/小时)。怎么快速得到某条微博在滑动时间窗口内的热度值呢?这其实是一个简单的数据结构和算法问题,如果是学习过我们数据结构和算法课的同学,应该会知道怎么来快速计算。

我们记录每个事件到来的时间,以及当前滑动窗口的某个微博的热度值H,当滑动窗口往前滑动N秒之后,如果需要重新计算这个微博的热度值,我只需要基于之前窗口的热度值H,减去前面的N秒时间的事件热度贡献值,再加上后面N秒时间的事件热度值,就是新的这个时间窗口的热度值。

你看,只需要统计2*N秒时间的事件热度贡献值,不用扫描1小时的全量数据来计算,显然,计算量降低了很多。不过,问题并没有完全解决,每个小时有144亿的事件数据需要存储,存储量也是很大的,怎么解决这个问题呢?
其实,微博的热度计算并需要特别的精确。我们可以把滑动窗口分成12个时间槽,每个时间槽是5分钟。每个微博都维护这样一个滑动窗口。我们统计每个时间槽内事件的热度贡献值,当需要计算最近一小时的热度值时,我们只需要把这12个时间槽的热度值加起来就可以。时间槽可以做成循环队列,某个时间槽过期之后(不属于最近1小时了),就会清空(热度贡献值设置为0),重新被使用。

基于这种方案,我们还需要给一小时内活跃的微博(500万),每条都记录12个值即可,内存的使用量也不高,单机都可以搞定。但是,每秒钟会有400万的事件到来啊,写QPS还是很高的,单机是无法承担的,我们可以采用多机处理,每台计算机计算和存储一部分微博的滑动窗口。
为了做到微博应用服务和排行榜服务的解耦、削峰填谷,我们可以基于RocketMQ等消息中间件来传递事件数据。具体的架构如下所示。

如上图所示,每隔五分钟,排行榜服务会统计每个活跃微博最近一小时的热度值,更新到Redis SortedSet中,并获取TOP100数据放入到Redis中(相当于缓存结果,避免频繁计算TOP100),当然,我们也可以将TOP100数据缓存到排行榜服务本地,性能更高。除此之外,排行榜服务还会提供接口给前端获取TOP100数据库。
(2)关键技术问题
刚才讲到,我们可以通过 RocketMQ 将事件分发到不同的排行榜服务器,每个服务器负责一部分微博的滑动窗口计算。但这里有一个关键问题:如何保证同一个微博的所有事件都发送到同一台排行榜服务器? 如果同一个微博的转发、评论等事件被分散到不同的服务器,那么每台服务器上的窗口数据就不完整,计算出的热度值就会出错。
RocketMQ 提供了非常灵活的消息队列选择器(MessageQueueSelector),让我们可以自定义路由规则。
假设我们有 10 台排行榜服务器,那么 Topic 的队列数至少应该设置为 10(或者更多,比如 20 或 30,方便以后扩展)。每个队列会被分配给某台消费者机器消费。
在生产者发送消息时,我们需要实现一个 MessageQueueSelector 接口,它的核心逻辑是:根据微博 ID 的哈希值对队列总数取模,决定这条消息去哪个队列。
// 自定义选择器,确保同一微博的消息进入同一队列
MessageQueueSelector topicSelector = new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
// arg 就是微博 ID
Long weiboId = (Long) arg;
// 取哈希绝对值,避免负数
int hash = Math.abs(weiboId.hashCode());
// 对队列总数取模,得到队列索引
int index = hash % mqs.size();
return mqs.get(index);
}
};发送消息时指定选择器和微博 ID。
// 发送消息时使用上述选择器,并传入微博 ID 作为参数
producer.send(message, topicSelector, weiboId);这样,无论有多少台排行榜服务器在消费,同一个微博 ID 的所有事件都会进入同一个 RocketMQ 队列。而我们在部署排行榜服务时,可以让每台服务器消费固定的一批队列(例如服务器 A 消费队列 0、1、2,服务器 B 消费队列 3、4、5……),从而保证同一微博的事件永远被同一台服务器处理,窗口状态自然也就完整了。
3. 方案优化
关于以上架构方案,我们从以下几点讨论有无优化的地方。
(1)性能优化
其实,对于“本周活跃榜”、“上月消费榜”、“近7日热销榜”这样的排行榜需求,对热度的精确度要求并不高,使用滑动窗口来计算热度值稍稍有点复杂,需要消耗较多的内存来维护滑动窗口数据,更加简单常用的一种做法是:指数衰减。
所谓指数衰减,就是不给热度设定固定的时间窗口,而是让热度值随着时间的推移自然“冷却”。具体做法是:每过一段时间(比如 1 秒或 1 分钟),将所有微博的热度值乘以一个衰减因子(例如 0.999),然后再加上新发生的事件带来的热度增量。这样,老的热度会指数级下降,越旧的事件影响力越小,最终趋近于零。
这个方案实现很简单:我们不需要维护时间槽或滑动窗口。每次事件到来,排行榜服务对Redis SortedSet 执行 ZINCRBY 加上增量,当然,为了避免对Redis的压力,可以使用Pipeline或者累积10秒钟的热度值之后再更新到Redis;同时由一个后台定时任务,每隔固定周期(比如 10 秒)对全量热度值乘以衰减因子(可以用 Lua 脚本遍历所有成员,或者更高效地,在读取时动态计算衰减)。
(2)可扩展性
从以上架构,我们可以发现,Redis 是没有什么压力的(350MB 数据,几万 QPS 的读写),压力都落在了 RocketMQ 和排行榜服务上,它们要面对峰值写 QPS 400 万,单机都是扛不住的。因此,RocketMQ 需要多机多队列部署,排行榜服务也需要多机部署。好在它们水平扩展起来都比较容易。RocketMQ 通过增加 Broker 和队列数,排行榜服务通过增加节点并调整消费关系,就能线性提升处理能力。这个架构应对更高的峰值写 QPS(比如 1000 万)也是没问题的,只要机器资源足够。
(3)可用性
系统的高可用需要从多个环节来保障:
- RocketMQ 的高可用:采用主从(Master-Slave)或 Dledger 集群模式,确保主 Broker 宕机后,从节点可以接管,消息不丢失。
- 排行榜服务的高可用:一个服务实例宕机,RocketMQ的broker会感知并启动Rebalance机制,将下线的消费者负责(排行榜服务实例)的队列分配给其他消费者。不过,这个服务实例上的滑动窗口数据会丢失,但是,对于热度这种非精确计算问题,丢失部分数据是完全可以接受的。
- Redis 的高可用:采用主从 + 哨兵,支持数据备份和故障自动转移。同时,排行榜服务可以在本地缓存一份 TOP 100 榜单,这样即使 Redis 短暂不可用,用户仍然能看到旧榜单。
4. 最后总结
这节课我们讲解了微博热榜这个系统设计问题的解决思路,其实,对于“本周活跃榜”、“上月消费榜”、“近7日热销榜”这一类问题,我们都可以基于这个方案来实现。
四、签到系统
这节课我们来看一个稍微简单点的系统设计问题:如何为日活百万的APP设计一个签到系统?支持每日签到获得积分、连续7天签到获得额外积分等功能。
1. 系统分析
照例,我们还是先进行需求分析,确定业务边界和非功能性需求。
(1)功能性需求分析
在前面的课程中,我们提到,如果需求没法一下子梳理清楚,我们可以先思考user case,也就是用户用例,用户是怎么用这个功能的,然后归纳总结,得出最终的条理性需求。
OK,我们先列一下user case:
- 用户可以在一天内任意时间签到(且一天只能签一次)
- 签到后获得固定积分,连续签到多天后积分给额外奖励
- 用户可以查看自己今天的签到状态(已签 / 未签)
- 用户可以查看最近一段时间的签到记录
- 用户可以查看当前连续签到天数
这里需要继续细化讨论:如果用户签到7天给予100积分奖励,那么当用户连续签到14天时,是否继续奖励100积分呢?或者额外更多积分呢?比如200积分?
这其实是一个业务或者产品的设计问题,在面试中,我们需要跟面试官主动沟通清楚。而且,能够想到这个问题,也能体现出你思考问题的全面性和逻辑的严谨性,这也是面试官考察的一方面。
在这里,我们假设产品是这么设计的:除了每日签到获得的10积分之外,每连续7天都奖励100积分。也就是,在第7天、第14天、第21天...以此类推,都会获得额外100积分。
(2)非功能性需求分析
对于非功能性需求,我们首先要分析性能压力:峰值写QPS、峰值读QPS、存储规模预估。
日过百万用户,假设这 100 万用户每天都会签到(实际不可能 100%,但按峰值设计)。如果签到集中在2个小时内,并且,在这2个小时内的平均写QPS 约为 1000000 / (3600 x 2) ≈ 139。峰值写QPS是平均写QPS的5倍,那么,峰值写QPS约为700。
我们再来看读请求,用户签到时,查看签到记录,包括今日签到状态、历史签到状态、连续签到天数等,因此,一次签到行为(写操作),可能涉及10倍的读操作,读QPS是写QPS的10倍,因此,预估峰值读QPS为7000。
再来看存储压力。每天要存储100万条签到记录,一年就是3.65亿条,每条记录包括用户ID(8字节)、签到时间(8字节)等信息,我们估算一条记录占用50字节(往高了估一点),那么,一年就是18GB。数据量不大,但是行数太多,如果使用MySQL存储,单库单表肯定扛不住的。
2. 初步设计
需求清楚了,我们现来做初步的设计,在不考虑性能的情况下,实现基本功能,并对关键技术、核心算法、技术难点做讨论。
(1)基本功能实现
我们先看是否可以用MySQL来存储数据。根据功能需求,我们需要设计以下几个表:
CREATE TABLE user_sign_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
sign_date DATE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY idx_user_date (user_id, sign_date)
);签到就在这张表中插入一条数据,因为user_id和sign_date合在一起是索引键,一方面不允许重复签到,另一方面,支持快速查看用户在某天是否签到。用户查看历史签到记录,只需要根据user_id,以及按照时间范围(sign_date)查询即可。
-- 签到
INSERT INTO user_sign_log (user_id, sign_date) VALUES (123, CURDATE());
-- 今日是否签到
SELECT COUNT(*) FROM user_sign_log WHERE user_id = 123 AND sign_date = CURDATE();
-- 一周内的签到记录
SELECT sign_date FROM user_sign_log
WHERE user_id = 123 AND sign_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
ORDER BY sign_date;以上需求都不难实现,最难的是累积连续签到天数的统计和查询。没法一个SQL搞定,要一天一天的回溯查询,直到遇到没签到的日期,在百万日活下,这种操作既慢又浪费资源。
因此,我们可以用另一个表,专门记录每个用户当前的连续签到天数。这样每次签到后,只需根据昨天是否签到(根据以下表中的last_sign_date),决定将连续签到天数 +1 或重置为 1,然后更新这个表即可。查询时直接读这个字段,O(1) 复杂度。
CREATE TABLE user_continuous_log (
user_id BIGINT NOT NULL PRIMARY KEY,
continuous_days INT NOT NULL DEFAULT 0,
last_sign_date DATE NOT NULL, -- 最后一次签到日期,用于辅助判断
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);(2)关键技术讨论
前面讲到,签到记录的数据量是非常大的,一年有3.65亿,因此,必须分库分表。我们可以使用user_id作为分库分表的键。这样一个用户的所有签到记录都会分配到同一个表中,用户签到记录和连续签到天数记录会分配到同一个库中。查询签到记录等操作不需要跨表,比较简单。
因为签到记录和连续签到天数放到了两张表中记录,用户在签到时,需要保证插入签到记录和更新连续签到天数的一致性,也就是要在一个事务内完成。因为一个用户的两张表在一个数据库中,所以,可以使用数据库事务来保证。
我们再来看积分奖励的发放。
积分发放的基本流程是,当用户签到完成之后(插入签到记录、更新连续签到天数),我们读取用户的连续签到天数,并与7取模,值为0,则进行积分额外奖励100 + 每日积分奖励10;如果值非0, 则只进行每日积分奖励。
用户积分表有可能跟签到记录和连续签到天数记录表不在一个数据库中,因此,要保证签到之后,奖励积分一定能到账,我们需要分布式事务。前面讲解了很多分布式事务的解决方案,这里可以选择本地事务表或者消息事务。
3. 方案优化
以上基于MySQL的方案,因为采用了分库分表来分担压力,完全可以满足功能和性能需求(峰值写QPS不到1千,峰值读QPS 不到1万,每年亿级存储需求)。
不过,为了小小的签到这个非核心业务,搞这么复杂,实在有点没有必要!实际上,我们可以使用Redis的Bitmap来更加优雅的实现。我们来看看具体该怎么做。
Bitmap顾名思义就是位图,这个我们在数据结构和算法中已经详细讲过了。针对每个用户,我们维护一个Bitmap( key是sign:{year}:{user_id}),长度是366bits,记录一年的签到状态。偏移量 offset 就是当年的第几天,第1天的签到状态记录在第一个bit里,第2天的签到状态记录在第2个bit里,以此类推。那么,一年的签到状态只需要46个字节(366/8)。100万用户记录一年的签到状态也只需要46MB内存。如果要记录10年,也不过460MB。具体记录多久,看你的业务需求。
Redis提供了一些原子操作来快速实现签到、查询签到等功能。
-- 第100天签到
SETBIT sign:2026:123 100 1
-- 查询第100天签到情况
GETBIT sign:2026:123 100如果想知道查询 N 天的签到记录,可以用 BITFIELD 一次性取出多个 bit。如下示例所示。BITFIELD命令返回一个整数(64位),其二进制表示的第 0 位对应第 70 天,第 30 位对应第 100 天。应用层再解析这个整数即可得到每天的签到情况。如果查询的天数N超过64天,我们要在应用层分段查询。
-- BITFIELD key GET u<位数> <偏移量>
BITFIELD sign:2026:123 GET u31 70同样,连续签到天数怎么统计呢?Redis 没有现成的命令直接计算连续天数。因此,我们需要再给每个用户维护一个计数器,key 为 cont:{year}:{user_id},value 可以是一个整数,表示当前连续签到天数。
同样,我们要保证签到和更新连续签到天数两个操作的原子性,好在Redis可以使用Lua脚本,实现起来非常简单。具体如下所示。
-- KEYS[1] = sign_key (例如 sign:2026:123)
-- KEYS[2] = cont_key (例如 cont:2026:123)
-- ARGV[1] = today_offset (当年第几天,1~366)
-- 1. 检查今天是否已签到
local signed = redis.call('GETBIT', KEYS[1], ARGV[1])
if signed == 1 then
return {0, "already"} -- 已签到,返回错误码
end
-- 2. 设置今天签到
redis.call('SETBIT', KEYS[1], ARGV[1], 1)
-- 3. 获取昨天签到状态
local yesterday_signed = redis.call('GETBIT', KEYS[1], ARGV[1]-1)
-- 4. 获取当前连续天数(如果不存在则视为0)
local cur_cont = redis.call('GET', KEYS[2])
if not cur_cont then
cur_cont = 0
else
cur_cont = tonumber(cur_cont)
end
-- 5. 计算新连续天数
local new_cont
if yesterday_signed == 1 then
new_cont = cur_cont + 1
else
new_cont = 1
end
-- 6. 更新连续天数
redis.call('SET', KEYS[2], new_cont)
-- 7. 返回成功和新连续天数
return {1, new_cont}如何保证签到成功,一定会发放积分成功呢?
我们同样可以基于本地事务来实现,在Redis中,记录每个用户的积分发放情况(key是reward:user:{UID}:sign_date,value是发放状态值)。当前签到的同时,将积分发放任务记录下来,在同一个Lua脚本中,保证操作原子性。
之后将积分发放任务发送到RocketMQ消息队列,消费者拉取消息进行积分的发放。
- 保证消息一定发送成功:后台启动一个线程JOB,定时查询积分发放任务,再次发送到RocketMQ,然后删除这个积分发放任务。
- 保证消息一定被消费:RocketMQ默认开启重试消费机制,多次重试失败之后,消息会被转入死信队列,人工介入处理。
- 保证消息仅消费一次:为了避免消费端重复消费消息,导致的重复发放奖励积分,我们可以使用业务幂等号,将积分的发放做成幂等操作。
这个稍微有点复杂,我们解释一下。
我们需要一个业务幂等号,可以是:user_id + sign_date,当消费者接收到消息时,可以把发放积分这个明细记录数据库中,发放明细的记录和用户积分的更新在同一个事务中,保证积分更新之后,发放记录一定存在。
当重复消费消息时,先基于user_id+sign_date查询是否已经发放,如果已经存在这条明细记录,则说明已经发放,不再重复发放。除此之外,我们在user_id+sign_date上建立唯一索引,完全杜绝了两个消费者同时检查发现没有发放记录,同时发放导致重复发放讲解!也就是说,实现了发放奖励这个操作的幂等特性。
好了,至此优化方案已大体搞定。Redis读写可以达到10万QPS,因此,对于峰值写不到1千、峰值读不到1万、内存只需要46MB(100万用户记录一年的签到情况),单机就可以搞定了,比基于MySQL分库分表要简单多了。
不过,Redis 虽然快,但毕竟是内存数据库。为了保证数据不丢失,我们可以开启主从复制+哨兵,并且,开启 AOF 和 RDB 保证宕机后最多丢失一秒的数据,并可以基于RDB快速恢复。同时,每天凌晨将签到数据批量写入 MySQL 做冷备份。冷备份可以这样实现:遍历所有用户,用 BITFIELD 一次性获取某月的签到情况(一个64位整数),然后批量插入 user_sign_log 表,user_sign_log中的一行数据记录用户一个月的签到情况,这样就避免了表非常大!
4. 最后总结
本节我们讲了签到系统的设计方案,其中设计数据库分库分表、分布式事务、Redis Bitmap、消息中间件如何保证消息的可靠发送和消费、如何实现接口的幂等性等,涉及的架构知识还是非常多的,而这些知识都是我们前面详细讲到过的,有了之前的知识铺垫,你是不是觉得这个问题也可以轻松拿捏了呢?
五、订单支付
想象这样一个场景:你在某个电商网站上点击“立即购买”,页面瞬间跳转到支付,输完密码后订单状态变成“已支付”。整个过程不过两三秒。但在这短短几秒里,系统到底经历了什么?这节课,我们就来聊聊下单和支付这一在很多网站和应用中存在的系统设计问题。
1. 系统分析
照例,我们先进行需求分析,包括功能性需求分析和非功能性需求分析。
(1)功能性需求分析
对于下单和支付,拆解下来,我们需要支持以下功能:
- 创建订单:用户点击购买,系统生成对应订单,此时订单处于“待支付”状态,并扣减库存。
- 发起支付:用户选择支付方式,系统调用第三方支付网关(支付宝、微信等),返回支付链接或二维码。
- 支付结果通知:第三方支付异步回调我们的系统,告知支付成功或失败。我们根据回调更新订单状态。
- 取消订单:用户主动取消未支付的订单,或者系统自动取消超时未支付的订单,并释放库存。
- 查询订单:用户查看自己的订单列表和详情,运营后台需要查询和统计等。
你可能会说,还有发货流程、退货退款流程(退货退款、仅退款、部分退款等等),这些都会独立在物流系统和售后系统中去做,为了保证我们这节课内容不至于过多,我们只关注下单和支付这一流程。
(2)非功能性需求分析
接下来,我们分析非功能性需求。这部分往往比功能需求更决定架构设计,因为你要知道系统扛多大压力、存多少数据,才能决定要不要分库分表、要不要上缓存、要不要消息队列。
我们先做一些合理的业务假设。假设这是一个中等规模的电商平台,日活跃用户(DAU)在 500 万左右。通常这类平台的订单转化率(每天下单的用户比例)可能在 5% 左右,那么每天产生约 25 万笔订单。如果搞一次大促,比如双十一,订单量可能是平时的 10 到 20 倍,也就是单日峰值 500 万笔订单。再考虑到秒杀场景,下单的瞬时并发可能更高。不过,这里我们暂时不考虑秒杀,下节课我们来讨论秒杀系统的设计。
- 写负载评估
假设 80% 订单发生在 4 小时黄金时段,那么,在此其期间平均TPS(订单数) 25 万 × 0.8 / (4×3600) ≈ 14 TPS,峰值是这个值的5倍,也就是70 TPS。
一个完整的下单和支付流程,涉及到多次数据库的写操作,比如,创建订单、库存扣减、更新订单状态、创建支付流水等等。一次下单和支付流程,我们按平均 10 次数据库写入来估算,那么,数据库的峰值写入压力是 700 QPS。
大促时 500 万订单集中在 1 小时完成,峰值订单数 TPS 为 500 万 x 5 / 3600 ≈ 7000 TPS。对应数据库的峰值写入压力是 7万 QPS。单台数据库显然撑不住,需要分库分表!
对比淘宝双十一的峰值订单数(TPS),2020年峰值是58.3万笔订单/秒。7千 vs 58.3万,我们这个中型电商系统的压力相对来说还是不大的。应对淘宝双十一这种峰值几十万订单的方案,留在下节秒杀系统中讲解。
- 读负载评估
用户会频繁查询订单列表和详情,读请求通常是写的 10 倍以上。日常读 QPS 可能在 7000 左右,大促时可能达到 70 万以上。而且订单查询往往带分页、排序、过滤条件,对数据库压力更大。所以除了分库分表,还需要引入缓存来减轻读压力。
- 存储规模预估
一笔订单记录加上支付流水等信息,假设平均占用 1KB 存储。按每天 25 万订单,一年就是 25 万 × 365 ≈ 9125 万,接近一亿订单。每笔 1KB,一年约 91GB。三年就是 272GB。加上索引、日志、备份,单个 MySQL 实例撑不住,而且随着数据量增长,查询性能会急剧下降。
除了以上性能要求之外,因为下单和支付涉及到金钱,对数据的可靠性、一致性有极高的要求,订单、支付、库存三者数据必须一致,库存减少了,订单必须创建成功,用户支付了,订单必须更新状态。因为任何一个系统处理的异常,而导致的数据不一致,都会导致用户的信任度降低。
2. 初步设计
假设我们先不考虑性能压力、微服务拆分(订单、支付、库存),把所有的表都放在一个数据库中(单库,没有分库分表的技术挑战),看如何实现下单和支付这样一个基本功能。
(1)基本功能实现
整个下单和支付流程涉及到至少三张核心的表:订单、支付流水、库存,如下所示,仅显示核心字段。
-- 订单表
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号,全局唯一',
user_id BIGINT NOT NULL COMMENT '用户ID',
total_amount DECIMAL(10,2) NOT NULL COMMENT '订单总金额,单位元,保留两位小数',
status TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态:0待支付,1已支付,2已关闭,3.已完成',
INDEX idx_user_id (user_id),
INDEX idx_order_no (order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
-- 支付流水表
CREATE TABLE payments (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
payment_no VARCHAR(32) NOT NULL UNIQUE COMMENT '支付流水号,每次发起支付生成一个',
order_no VARCHAR(32) NOT NULL COMMENT '关联的订单号',
gateway_trade_no VARCHAR(64) COMMENT '第三方网关交易号,支付成功后返回,用于对账',
pay_channel VARCHAR(20) NOT NULL COMMENT '支付渠道:wechat, alipay, unionpay',
amount DECIMAL(10,2) NOT NULL COMMENT '本次支付金额',
status TINYINT NOT NULL DEFAULT 0 COMMENT '支付状态:0支付中,1成功,2失败',
INDEX idx_order_no (order_no),
INDEX idx_payment_no (payment_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付流水表';
-- 库存表
CREATE TABLE inventory (
sku_id BIGINT PRIMARY KEY COMMENT '商品SKU ID',
available_quantity INT NOT NULL DEFAULT 0 COMMENT '可售库存数量',
locked_quantity INT NOT NULL DEFAULT 0 COMMENT '已锁定库存(预扣但未支付)',
sold_quantity INT NOT NULL DEFAULT 0 COMMENT '已售库存(支付成功)'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存表';有了这三张表,我们就可以实现基本的下单和支付流程,虽然没法应对高并发,但起码可以梳理清楚详细的业务处理流程,这就是我们初步设计的想要达到的效果。
1.1 创建订单
用户提交订单请求,会做调用库存服务预扣库存(防止超卖),并生成全局唯一订单号(后面会讨论如何生成),创建订单记录,状态设置为“待支付”。
1.2 发起支付
订单创建成功后,系统会创建一个支付单(关联订单号、金额、支付方式),并调用第三方支付网关,获得支付凭证,返回给客户端让用户去支付。
1.3 支付回调处理
用户完成支付后,第三方支付网关会异步回调我们预先配置的回调地址。如果回调不能发挥成功响应(比如HTTP200或者预定义好的success消息),第三方支付网关会重复发送回调,直到成功或者重试次数超过阈值为止,因此,我们需要保证回调接口幂等。
回调接口为了保证幂等,处理流程如下:
根据支付网关返回的附加数据:交易流水号(对应payments表中的payment_no,生成支付凭证的时候传递给三方支付网关的,支付成功回调时再返回回来),检查支付流水是否已经处理过(根据status状态,保证幂等性)。如果未处理,则更新payments表中的支付流水状态(status=已支付或失败等),以及三方支付网关返回的三方交易流水(gateway_payment_no,用于对账)。
对应的SQL如下所示。如果影响行数为0,说明支付流水已经不是待支付状态,记录日志并直接忽略。
UPDATE payments SET status='PAID', gateway_trade_no='xxx'
WHERE payment_no='xxx' AND status='PENDING_PAY'完成支付流水的更新之后,对应的订单记录也需要更新。以上支付流水的更新过程支持幂等,订单状态的更新也需要支持幂等(根据订单号和订单状态),如下所示。
UPDATE orders SET status='PAID' WHERE order_no='xxx' AND status='PENDING_PAY'1.4 取消订单
用户可以主动取消订单,当然,订单也可能因为超时(比如30分钟)未支付而被动取消,释放库存。用户主动取消订单非常简单,我们就不讲了。我们重点看如何实现超时取消订单?
最直观的方案是定时任务扫描数据库里所有“待支付”且创建时间超过阈值的订单,批量取消。但这种方法在大表上效率低,且扫描时间间隔难以确定(时间间隔短则压力大,时间间隔长则取消不及时)。更好的做法是使用延迟消息:创建订单时向消息队列发送一条30分钟后触发的延迟消息(RocketMQ 和 RabbitMQ均支持),订单服务消费该消息时检查订单状态:
- 如果为“待支付”,则执行取消并释放库存。
- 如果为“已关闭”(用户正巧也主动去取消),啥都不用干了,直接返回。
- 如果为“已支付”(用户卡着点去支付了),肯定不能给他取消订单了,直接返回。
- 如果为其他状态(如已完成),啥都不用干了,直接返回。
我们再来看取消订单导致的潜在并发问题:
用户在接近订单超时时间的时候去支付,支付成功后三方还没有发起回调前,订单超时被动关闭,然后,三方发起支付成功的回调,这个时候,订单已经关闭,该怎么处理?
支付回调发现订单已关闭时,不能更新订单为已支付,而应该立即发起自动退款,将钱退还给用户,并记录异常日志。退款流程走售后系统,我们不继续往下讨论。
(2)关键技术讨论
以上是基本功能的实现,这里我们再重点讨论其中一些关键技术问题。
- 数据一致性保证
因为所有的表(订单、支付流水、库存)都在一个数据库中,因此,数据的一致性很容易保证,只需要把多个操作(比如,创建订单的同时扣减库存),放到同一个数据库事务中执行即可。
- 订单号生成算法
订单号需要满足:唯一性、局部有序性(利于数据库索引)、安全性(不泄露业务,如订单量)、高性能(不要让生成订单号成为系统瓶颈)。前面我们详细讲解过分布式ID生成算法,这里可以选用雪花算法,当然,为了方便分库分表后的查询,在雪花算法之上可以拼接分片ID作为最终订单号(基因法,在分库分表章节中有详细讲解)。
- 如何进行对账
尽管支付网关有回调重试,回调接口也支持幂等,但理论上仍有可能出现订单状态与支付网关实际交易不一致的情况,比如订单系统异常,回调重试次数超过阈值后停止重试,因此每日对账是必须的。每天凌晨,从支付网关拉取前一天的交易流水,与本地支付流水、订单进行比对,找出支付成功但支付流水和订单未更新的,自动或人工修复。
- 订单状态机设计
对于订单,前面我们定义了四种状态:待支付、已支付、已关闭、已完成,虽然说物流和售后的细节状态(如已揽件、运输中、派送中、已签收等),会放到其他系统其他表中记录,但是,关键业务状态会同步到订单中,比如已发货。因此,我们重新梳理一下订单的状态:
待支付:用户已经下单,等待支付。
已支付:买家已完成支付,等待卖家发货。
已发货:卖家录入物流单号,订单进入物流系统,等待买家确认收货。
交易成功:买家主动点击“确认收货”或系统超时自动确认(如发货后10天),订单完结。
交易关闭:可能原因有很多,比如用户主动关闭、超时关闭、售后(如退货退款)结束之后关闭,可以给订单增加字段,记录交易关闭的原因。
在下单和支付流程中,有大量订单状态的判断逻辑和状态转换,为了避免非法的状态变更,比如不允许从“已关闭”变为“已支付”,我们可以使用状态机来保证订单状态的合法流转。

订单状态机如何实现呢?我们可以使用Spring State Machine,或者自己实现。自己实现的方法,我们在《设计模式之美》状态设计模式中讲过,既可以使用状态表,也可以使用状态设计模式,具体我们就不展开讲了。
3. 方案优化
以上已经是基本可用的方案了,但是,还有很多值得优化的地方,比如服务拆分、分库分表等,这里我们继续深入的讨论。
(1)微服务拆分
为了解耦、方便水平扩展等,我们一般会做服务拆分。
- 订单服务:负责订单的 CRUD、状态机管理、超时取消等。
- 支付服务:负责与第三方支付网关交互,创建支付流水,生成支付凭证,处理异步回调,对账等。
- 库存服务:负责商品库存的预扣、实扣、释放以及库存变更流水记录。
- 售后服务:负责退款、退货等售后申请的审核、退货物流跟踪、退款执行等流程。
- 物流服务:负责发货单管理、物流单号追踪、物流状态更新等。
下图展示了拆分后的服务架构中,一次完整下单支付流程涉及的数据流转。实线是本节重点讨论的核心流程,虚线表示非本节重点但存在的关联流程。

从上面的处理流程,我们发现,回调接口收到支付成功通知之后,会通过消息队列将支付成功消息发送给订单服务,订单服务再更新订单状态。这是不是有点麻烦?支付服务直接通过RPC调用订单服务更新订单状态,岂不是更加简单高效吗?
当然,如果是小的应用,直接RPC调用也没有什么问题。但是,对于中大型应用来说,消息队列充当着解耦、异步、削峰填谷的作用,能有效地降低订单服务的压力(慢慢处理呗),起码在更新订单状态这件事情上,不会因为压力过大,而导致RPC调用超时或者失败。
(2)分布式事务
在未进行服务拆分时,订单、支付、库存之间的数据一致性很容易保证,直接使用数据库事务即可。但是,进行服务拆分之后,一个事务中可能涉及多个服务的写操作,就需要解决分布式事务问题,来保证数据的一致性。我们对上图进行分析,发现以下几个步骤会出现一个事务中跨服务调用。
- 情况一:步骤2(预扣库存)与步骤3(创建订单) 订单服务先调用库存服务预扣库存,再在本地创建订单。如果库存预扣成功,但订单创建失败(比如数据库宕机),就会出现“库存扣了但订单不存在”的不一致情况。
- 情况二:步骤16(更新订单状态)与步骤17(实扣库存) 订单服务更新订单状态为“已支付”成功后,调用库存服务实扣库存。如果订单更新成功,但实扣库存失败(比如库存服务暂时不可用),则订单已支付但库存未实际扣减。
- 情况三:步骤12(更新支付单)与步骤13(发送支付成功消息) 回调接口更新支付单(也就是支付流水)成功后,发送“支付成功”消息到MQ。如果消息发送失败,支付单已更新为成功,但订单服务永远不会收到通知,订单状态无法更新。
情况一和情况二是标准的分布式事务问题,解决方案在前面的文章中已经详细讲过了。情况一因为要先扣减库存再创建订单,为了杜绝超卖,核心逻辑在后,因此,不方便使用最终一致性方案,那么,我们可以选择使用TCC方案(可以直接引入Seata框架来实现)。对于情况二,典型的核心逻辑在前,因此,可以采用本地消息表或者消息事务,来实现最终一致性。
情况三,我们也可以使用消息事务,保证“支付单更新”和“消息发送”要么都成功,要么都失败。如果本该更新支付单,但因为消息发送失败,而没有更新,每日对账JOB会进行三方跟支付、订单的对账,发现问题,自动或者人工介入处理。
(3)分库分表
其实,多数系统的性能瓶颈都是在存储系统。纯无状态的计算系统,容易水平扩展,基本无压力。存储系统因为扩展起来并没有计算系统那么容易,因此,应对性能压力,设计的重点就落在了存储系统上。前面讲到的存储系统主要有MySQL、Redis、以及消息中间件(也可以看做是一种存储系统)等。
在前面的性能分析中,我们预估大促时峰值订单数 TPS 为 500 万 x 5 / 3600 ≈ 7000 TPS。大促时 500 万订单集中在 1 小时完成,峰值订单数 TPS 为 500 万 x 5 / 3600 ≈ 7000 TPS。对应数据库的峰值写入压力是 7万 QPS。单台数据库显然撑不住,需要分库分表!消息队列需要多Broker多队列部署。
这里我们重点看MySQL数据库的分库分表。
其实,在「数据存储-分库分表」那节课中,我们提到过类似场景的分表策略:基因法,即使用UserID作为分片键,并且在订单号中融入分片信息。这样既能实现基于用户ID快速查询订单,又能实现基于订单号快速查询订单。当然,支付流水表同样可以使用基因法进行分表。基因法的具体实现思路,这里就不赘述了,忘记了的再回过头去看下。
当然,为了提高查询效率,我们还可以做一些工作:数据库读写分离、Redis分布式缓存。前面都详细讲过,这里就不赘述了。
4. 最后总结
本节课从下单支付的完整流程出发,分析了中型电商的性能需求,并给出了单库实现、服务拆分、分布式事务、分库分表等逐层演进方案。核心设计思想是:利用状态机保证订单合法流转,通过回调接口幂等解决支付回调重试问题,使用延迟消息实现超时取消订单,借助TCC、本地消息表、消息事务解决一致性难题,依靠消息队列实现异步、解耦和削峰填谷。理解了下单和支付的业务来处理流程,下一节我们将进入秒杀系统设计,讨论如何在瞬时流量洪峰下保证系统不崩、库存不超卖。
六、秒杀系统
想象一下双十一零点,某款热门手机限量 1000 台,结果 100 万人同时涌进来。你点击“立即秒杀”,页面转圈了两秒,然后告诉你“很遗憾,没抢到”。这背后,秒杀系统到底经历了什么?它和普通下单有什么本质区别?我们该怎么设计它?
1. 系统分析
我们先明确一下场景。假设我们要为某电商平台设计一个秒杀活动:一款商品限量 5000 件,秒杀价 9.9 元,每个用户限购 1 件。秒杀开始后,先到先得,卖完即止。
(1)功能性需求分析
秒杀系统的核心功能其实不复杂:
- 商品管理:运营后台配置秒杀活动(商品、库存、开始/结束时间、限购数量等)。
- 用户秒杀:用户在活动页点击“抢购”,系统判断是否有资格、是否有库存,如果成功则扣减库存、生成订单。
- 支付流程:抢到后需要在限定时间内(比如 15 分钟)完成支付,否则释放库存给其他人。
秒杀系统只负责“抢”库存或者说“抢”名额这个环节,至于抢到名额之后的下单和支付,从服务划分的角度来讲,这部分工作属于订单服务和支付服务,也就是我们上一节讲的内容。
(2)非功能性需求分析
秒杀系统的非功能需求,和普通下单系统完全不是一个量级。假设预热期有 200 万用户预约,秒杀开始瞬间,可能有 100 万并发请求涌入。100 万人在同一秒内发起请求,API网关看到的峰值 QPS 可达 100 万。即便经过限流,后端核心秒杀服务仍然可能面对几十万的瞬时 QPS。
在这么大的性能压力下,系统还要保证:
- 快速响应:用户点击后,必须在 2 秒内得到明确结果(成功/失败/排队)。
- 超卖少买:不能超卖,多一件都不行,也不能少卖,库存还有却告诉用户卖完了。
- 高可用:在这么高的瞬时QPS的情况下,我们要考虑系统不能被压垮。
- 安全性:防脚本、防黄牛、防机器人刷单。
2. 初步设计
你可能会说,秒杀也不过是扣库存、创建订单、支付这一流程,跟上节课讲的普通购买没什么区别啊,上一节课也提到了扣库存啊。而且,上一节课的架构设计,在分库分表的情况下,应对几千、几万订单量/秒也没什么问题,只要水平扩展,加机器,几十万订单/秒也不是不可能。秒杀系统直接复用上一节课讲到的架构不就行了吗?
实际上,这两个场景有一个本质区别:普通购买(即便双十一大促)针对的是所有商品,每个商品的抢购人数并不多。不同商品的库存记录可以分散到不同的数据库分片,读写压力和行锁冲突都被天然打散了。但秒杀完全不同,它只针对单个商品,库存记录只有一行,没法分片。上百万的抢购请求同时涌向这一行记录,都要执行扣减操作。数据库的行锁机制会让所有请求串行化:同一时刻只有一个请求能拿到锁并更新库存,其余 999,999 个请求全部排队等锁,超时是必然的。
所以,秒杀系统的最大压力全在库存扣减这一环。真正走到下单和支付的,不过几千个成功用户,那部分其实毫无压力。订单和支付的实现,上一节课已经详细讲过,这节课我们把重点放在前置环节:如何扛住库存扣减的瞬间洪峰。
你可能会想:不就是扣库存吗?我用数据库行锁(update底层会对更新的行加锁)不就完了?如果更新影响行数大于 0,就说明抢到了名额,进入下单和支付流程即可。否则,就直接返回没有抢到。
UPDATE inventory
SET available_quantity = available_quantity - 1
WHERE sku_id = 123 AND available_quantity > 0;这个逻辑在普通下单场景下完全没问题。但在秒杀时,100 万个请求同时执行这条 SQL,会发生什么?
数据库连接池瞬间被占满,大量请求等待连接,响应时间飙升至几十秒。InnoDB 的行锁机制导致所有请求串行化执行。实际上同一时刻只能有一个请求成功更新库存,其余 999,999 个请求都在排队等锁,超时在所难免。
所以,直接用数据库扣库存是死路一条。秒杀系统的本质是在大流量下,快速筛选出极少数的成功者。我们可以设计一个漏斗状的架构:流量从用户端进入,经过前端、网关、缓存、消息队列,最后落到数据库。每一层都做一次筛选,只有极少数请求能穿透到最底层。
下面我们逐步展开每一层的设计,并解释为什么这么做。
(1)独立部署+CDN
首先,秒杀系统和主站必须隔离。不能因为秒杀把订单、商品、支付这些核心服务拖垮。所以我们会独立部署一套秒杀服务集群,单独分配机器资源。
同时,秒杀活动页是静态内容为主的页面。我们可以把 HTML、CSS、JS、图片等资源全部缓存在 CDN 上,用户直接访问 CDN。只有点击“秒杀”按钮时,才向后端发起一次真正的动态请求。
为什么要这么做? 因为秒杀开始前,会有大量用户疯狂刷新页面等待倒计时归零。如果每次都请求后端,服务器根本扛不住。CDN 能消化掉 99% 的静态请求,只有最后那一下会到秒杀系统。
(2)流量入口限流
即使有了 CDN,秒杀开始的瞬间,动态请求依然可能达到百万级。我们的服务器处理不了这么多,那就在入口处直接拒绝一部分。常见的手段有:
- 前端限流:秒杀按钮被点击后立即置灰,防止用户疯狂点击。
- 网关层限流:在 Nginx 或 API Gateway 上使用令牌桶或漏桶算法,限制每秒进入后端服务的请求总量。比如,每秒只放行 10 万 QPS 到秒杀服务,多余的直接返回“繁忙”。
- 用户/IP限流:同一个 用户或IP 每秒最多允许 5 次请求,防止脚本攻击。
这里你可能会问:限流阈值设多少合适?这取决于你的秒杀服务能处理多少。我们通常会做压测,比如单机秒杀服务能扛 1 万 QPS,那 10 台机器就设 10 万。多余的流量宁可失败,也不能让系统过载。限流之后,进入秒杀服务的流量从 100 万降到了 10 万,压力依然很大,但至少可以handle的了了。
(3)Redis原子扣减库存
现在我们还有 10 万请求同时打到秒杀服务。如果每个请求都去查数据库剩余库存,数据库照样扛不住。你说分库分表不行吗?分库分表根本没用,因为秒杀的产品都是单一一个产品,在数据库中的库存只占一行。10万请求并发访问这一行数据,竞争行锁,任何数据库都撑不住!我们需要一个更快的存储:Redis。
在秒杀活动开始前,运营后台配置好商品库存后,我们把库存数量从数据库同步到 Redis 中:
SET seckill:stock:123 5000当用户请求到达秒杀服务时,服务首先做校验(比如用户是否已经抢到过、活动是否已结束等),然后执行一个 Redis Lua 脚本,原子性地扣减库存:
-- KEYS[1] = seckill:stock:123
-- ARGV[1] = 1 要扣减的数量
local stock = redis.call('GET', KEYS[1])
if not stock then
return 0 -- 库存不存在
end
if tonumber(stock) >= tonumber(ARGV[1]) then
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1 -- 扣减成功
else
return 0 -- 库存不足
end为什么用 Lua 脚本? 因为 Redis 单线程执行脚本,执行脚本的过程是原子操作,不会出现并发下的超卖问题。而且 Redis 纯内存操作,单机 QPS 可以到 10 万以上,完全能扛住我们的入口流量。当脚本返回 1,说明用户成功抢到了一个“名额”。返回 0,则直接告诉用户“已抢完”。
这时,我们其实已经完成了最关键的一步:在内存中确定了哪些请求是成功的。对于抢到的用户,我们并没有立刻创建订单,而是把他放进消息队列,异步去创建订单、扣减数据库库存、调用支付接口。
(4)消息队列异步下单
你可能会说,也就5000个订单会成功,对数据库来说并没有太多压力(上一节课我们的下单支付流程可以抗起码7000TPS),为啥要通过消息队列异步去创建订单、扣减数据库库存、发起支付呢?
如果将这些数据库操作和抢购同步执行,会显著增加请求的响应时间,而且万一订单服务抖动,会连累秒杀服务。更重要的是,如果订单服务处理慢,后续的请求会被阻塞,导致秒杀服务线程池耗尽。
所以,我们把“抢成功”和“真正下单”拆成两步:
- 秒杀服务 Redis 扣减成功后,立即生成一条“秒杀成功消息”发送到消息队列,然后直接返回用户“抢购成功,请稍后查看订单”。用户看到这个提示后,可以去订单列表页刷新。
- 订单服务从消息队列中拉取消息,再执行真正的数据库写入:创建订单、扣减数据库的库存、发起支付。
这样一来,秒杀服务的响应时间极短(只需要一次 Redis 操作 + 一次 MQ 发送),能处理的 QPS 就更高了。而订单服务根据自己的处理能力慢慢消费消息,数据库压力进一步降低。
(5)热点商品库存分片
Redis 单 key 能支撑 10 万 QPS 已经很高了,但如果你的秒杀并发超过这个数(比如几十万),单个 Redis 实例就无法满足了。这个时候该怎么办呢?
经典的解决方案是库存分片:把 5000 件库存拆成 10 份,每份 500 件,存在不同的 Redis key 上:seckill:stock:123:1 ......seckill:stock:123:1:10。用户请求到来时,随机选择一个分片(比如根据用户 ID 哈希),尝试扣减该分片的库存。如果该分片库存不足,直接返回“商品售罄”。这样就把压力分散到了多个 key 上,每个 key 的 QPS 降低了 10 倍。
不过,理论上,这种方案并不完美。如果某个分片被分配的用户特别多,该分片先卖完,但其他分片可能还有库存,这些库存却因为用户哈希不到而永远卖不掉。这会造成实际库存未卖完,但系统提前告诉用户无货,也就是,出现少买的情况。
不过,理论归理论。实际上,当用户数量远大于库存,且用户ID随机分布的场景,统计学上各分片负载基本均衡,这样基本上可以肯定会卖完,库存浪费的可能性非常非常低!
(6)用户资格校验与防刷
现在还剩最后一个问题:如何保证一个用户只能抢购一件商品?我们不能依赖前端禁用按钮,因为脚本可以绕过。需要在后端做校验。我们在 Redis 中维护一个 Set,记录已经抢购成功的用户:
SADD seckill:users:123 10086 # 商品123,用户10086已抢到每次请求进入时,先用 SISMEMBER 检查用户是否已经存在,如果存在直接拒绝,否则,进入库存试扣减环节。当库存扣减成功之后,说明用户已经分配了名额,则将用户添加到Set中。需要注意的是,检查用户是否已经抢购、库存扣减、添加用户到Set,要在一个Lua脚本中,保证操作的原子性,避免一个用户点击两次抢购,两个请求同时校验用户具有抢购资格,导致扣减两次库存。
如果我们要限制一个用户只能抢购3件商品,又该如何来做呢?我们可以把Set替换掉,使用seckill:user_limit:{skuId}:{userId} 来记录每个用户已购买的数量,其他流程不变。
对于防刷,我们可以增加:验证码,秒杀开始前要求用户输入图形验证码或滑动验证,增加脚本成本。同时,在API网关层对IP和用户的限流。
3. 方案优化
其实,以上方案已经非常完善了,但是有些小的细节,我们再继续澄清一下。
- 库存扣减成功但 MQ 发送失败
上面提到过,在 Redis 库存扣减成功后,向消息队列发送一条“用户秒杀成功”的消息,订单服务消费消息,异步进行创建订单等数据库操作,进一步减少数库的压力,也提高秒杀响应的速度。
如果库存扣减成功了,但是因为网络抖动等原因,导致消息发送失败,导致库存被占用却无人下单,那岂不是就浪费掉了一个库存,这该怎么办?其实,这有点类似分布式事务问题。我们可以基于MQ的消息事务来实现,也可以使用本地消息表,只不过,这里的本地消息表不是记录在数据库中,而是记录在Redis中。
- 用户抢到名额后,长时间不支付
上一讲我们介绍了延迟消息处理订单超时。秒杀场景同样适用:创建订单时发送一条 15 分钟后的延迟消息,如果订单仍未支付,则关闭订单并回滚 Redis 库存,并从抢购记录Redis Set中移除用户,否则,已占用的库存就浪费了且用户永久使用了再次抢购的资格。
- 秒杀服务、Redis崩溃了怎么办
秒杀服务是无状态的,可以多实例部署,前面用负载均衡分发流量。单个实例挂掉不影响整体。但是,如果Redis宕机,会导致内存中记录的库存余额数据丢失。因此,Redis必须开启AOF和RDB持久化,并且,使用主从+哨兵部署架构。除此之外,数据库中也会记录库存,订单服务在创建订单时,会扣减数据库中的库存,作为库存余额的最终可靠数据。其实,在某种意义上来讲,Redis中记录的库存可以看作是数据库中的库存记录的缓存。
4. 最后总结
秒杀系统的本质,是在瞬时海量请求中快速筛选出极少数胜利者,核心压力全在单行库存的原子扣减上。直接使用数据库行锁会导致请求串行排队、系统崩溃。核心处理思想概括为:尽早拒绝无效请求,用内存代替数据库,消息队列异步解耦后续流程,最终在极限压力下实现不超卖、不崩溃、快响应。
七、微信红包
每年节假日,微信红包的收发数量都会暴涨,尤以除夕为最。比如,微信官方公布的2017年除夕,微信抢红包用户数高达3.42亿,发红包37.77亿个,收发峰值76万/秒。如此大规模、高峰值的业务需要,背后需要怎样的技术支撑?百亿级别的红包规模,如何保证并发性能与资金安全?这节课我们就来看微信红包的架构和系统设计方案。
1. 系统分析
照例,我们还是先进行功能性需求分析和非功能性需求分析。
(1)功能性需求分析
微信红包我想绝大多数人都发过也抢过,核心的功能基本上有以下几点。
- 发红包:用户在群里选择红包类型(普通红包还是拼手气红包),塞入总金额,设置红包个数,然后支付成功之后,系统生成一个红包记录,等待群里的人来抢。
- 抢红包:群里的用户看到红包消息,点击红包。系统需要判断这个红包还有没有剩余名额,如果有,就让他“抢中”一个资格,弹出一个“拆红包”的界面。如果没有,就弹出一个“红包已抢光”的界面。注意,这个阶段还没有分配具体金额,只是占了个位置。
- 拆红包:抢到资格的用户点击“拆”,系统从剩余金额中随机分配一笔钱给他(拼手气红包),或者直接给固定金额(普通红包)。拆完之后,钱真正进入用户的零钱账户。
- 查看红包详情:用户可以查看这个红包谁抢了多少、谁是手气最佳、红包有没有被抢完。
- 红包过期退款:如果一个红包发出去后,超过24小时还没被抢完,剩余的钱需要原路退回到发红包者的账户。
(2)非功能性需求分析
非功能性需求决定了架构方案。我们重点分析性能压力,因为这是红包系统最大的挑战。
2.1 性能压力
我们以2017年除夕数据为参考,微信官方公布的数据是:抢红包用户数3.42亿,红包收发总量37.77亿个,峰值76万/秒。
我们先来看写入压力。峰值76万/秒,这个数字是“收发”总和。我们假设其中70万是“抢”(因为抢远多于发),那么每秒要写入的抢红包明细记录就是70万条。同时,每次抢成功后还要更新红包表的剩余名额和剩余金额,这也是70万次更新。再加上发红包的写入、支付流水的写入,每秒的数据库写入操作轻松超过150万次。
我们在看读请求压力。用户抢红包之前,可能需要先查看红包有没有被抢完;抢完之后,要查看红包详情。读请求通常是写请求的几倍甚至十几倍。因此,每秒数据库的读请求在300万次以上!如果每次读都去查数据库,数据库肯定扛不住。所以我们需要用缓存来挡住绝大部分读请求。
我们再来预估下存储规模。2017年除夕,红包收发总量是37.77亿个。每个红包被发出来,在数据库里就是一条记录,我们叫它“红包表”。每个红包被抢一次,就会产生一条“抢红包明细记录”。我们保守估计平均每个红包被抢10次(因为发100个红包的人很少,大部分人发几个到几十个)。那么,抢红包明细记录的数量就是:37.77亿 × 10 ≈ 377.7亿条。再加上发红包时的支付流水、用户零钱变动记录、退款记录等,一天产生的数据量轻松超过400亿条。假设每条记录占100字节(实际加上索引会更大),那就是400亿 × 100B ≈ 40TB。
2.2 资金安全
微信红包的金额可能小到0.01元,大到200元(普通红包上限)或520元(特殊节日)。金额虽小,但笔数巨大,累积起来就是天文数字。每一笔都不能错。
红包涉及到真金白银,资金安全是第一位的。不能出现超发(同一个红包发出的总金额超过发红包者支付的金额),也不能出现少发(用户抢到了但钱没到账),更不能出现重复发放(一个红包被同一个人抢两次)。
2.3 高可用
在节假日,比如除夕夜,如果系统宕机几分钟,影响的是几亿人的体验,造成的信任损失无法估量。所以系统必须高可用,不能有单点故障。任何一个组件挂了,都要能自动切换,做到用户基本无感知。
同时,系统还要能容忍部分组件降级。比如,红包详情页的“手气最佳”徽章如果算不出来,可以暂时不显示,但抢红包和拆红包的核心链路不能断。
2.4 数据一致性
红包涉及多个系统:红包系统、零钱系统、支付网关、消息推送等,因此,需要保证数据在多个系统之间的一致性。比如,用户拆红包后,零钱系统要增加余额;如果零钱系统暂时不可用,怎么办?我们不能让用户拆了红包但钱没到账。
我们需要用分布式事务来保证跨系统的数据一致。但红包场景对一致性的要求很高,用户拆完红包希望立刻看到零钱变化,所以“最终一致性”中的“最终”不能太久,几秒钟内必须达成一致。
2. 对比分析
你有没有发现,抢红包的过程(先抢再拆),上一节课讲到的秒杀过程非常相似,都是先“抢”一个名额,然后再进行下一步的复杂工作。下一步的复杂工作是什么呢?对于秒杀系统就是创建订单、扣减数据库库存、支付等,对于微信红包就是计算随机红包金额、写入抢红包明细记录、划钱到对应零钱账户等。
我们其实就可以把抢红包看成N个小型秒杀活动,因此,可以借鉴秒杀系统的设计来解决抢红包问题,核心就是将库存放到Redis中,使用Redis内存操作做到高效扣减库存。
不过,它们也是有区别的:秒杀是一个商品被几百万个人抢,热点非常集中;微信抢红包是几十万个红包同时被抢,每个红包都是一个独立的热点。热点数量多,但每个热点的并发量相对较小(几百到几千,在普通微信群里,一个红包最多被500人抢。如果是在认证公众号关联的群,最多被1000人抢)。
这样看来,抢红包过程是不是更像是普通下单过程,碰上了双十一大促?是不是可以直接使用上上一节课讲到的下单和支付的架构设计方案来解决呢?核心就是利用数据库分库分表,来分担读写、存储压力。
不过,它们也是有区别的:普通下单的架构设计,并不是特别关注库存超卖少卖的问题,毕竟从实际情况来看,一个商品多卖点、少卖点,都不是什么大事,业务层面是允许的,并且也不限制只能购买一件。但是,抢红包这个场景恰恰相反,它不允许超卖和少卖(少卖的意思是明明有库存但用户没法购买),并且每个用户只能抢一次。
也就是说,抢红包这个场景跟秒杀和普通下单确实有很多相似之处,其架构设计方案,可以去借鉴秒杀和普通下单的架构设计方案,比如Redis库存扣减、数据库分库分表、消息队列异步处理、数据一致性保证等等,但是,也要根据抢红包一些特定的需求做适当调整。
3. 初步设计
有了以上需求分析和讨论,我们对架构设计的方向已经逐渐清晰了,先借鉴普通下单的架构设计来做初步设计,然后借鉴秒杀系统设计方案,对库存扣减、资格校验(仅能抢一次)做优化。
(1)基本功能实现
按照惯例,先从最简单的方案开始:纯数据库实现。这个方案虽然扛不住除夕夜的洪峰,但能帮我们理清核心业务流程,为后面的优化打好基础。
其中,核心的表主要有两个:
-- 红包主表(red_envelope):记录每个红包的基本信息。
CREATE TABLE red_envelope (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
envelope_id VARCHAR(64) NOT NULL UNIQUE COMMENT '红包唯一ID,业务主键',
sender_id BIGINT NOT NULL COMMENT '发红包用户ID',
total_amount DECIMAL(10,2) NOT NULL COMMENT '总金额(元)',
total_count INT NOT NULL COMMENT '总名额',
remain_amount DECIMAL(10,2) NOT NULL COMMENT '剩余金额',
remain_count INT NOT NULL COMMENT '剩余名额',
opened_count INT NOT NULL COMMENT '已拆红包个数,用于计算随机红包金额'
blessing VARCHAR(255) DEFAULT '' COMMENT '祝福语',
type TINYINT NOT NULL DEFAULT 1 COMMENT '类型:1普通红包,2拼手气红包',
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0进行中,1已抢完,2已过期',
expire_time DATETIME NOT NULL COMMENT '过期时间(发红包后24小时)',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_sender (sender_id)
);
-- 抢红包明细表(red_envelope_detail):记录每个人抢到的金额。
CREATE TABLE red_envelope_detail (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
envelope_id VARCHAR(64) NOT NULL COMMENT '红包ID',
user_id BIGINT NOT NULL COMMENT '抢红包用户ID',
amount DECIMAL(10,2) NOT NULL COMMENT '抢到的金额',
is_king BOOLEAN DEFAULT FALSE COMMENT '是否是手气最佳',
grab_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_envelope (envelope_id),
INDEX idx_user (user_id),
UNIQUE KEY uk_envelope_user (envelope_id, user_id) -- 保证一人只能抢一次
);有了这几张表,我们来实现一下完整的发红包、抢红包、拆红包流程。
发红包流程:
用户A在群里选择发一个100元10个的拼手气红包,支付成功后,插入一条红包主表记录。然后,系统把这个红包的消息推送到群里。这一步由消息推送系统完成,我们不展开讨论。
INSERT INTO red_envelope (
envelope_id, sender_id, total_amount, total_count,
remain_amount, remain_count, opened_count, blessing, type, status, expire_time
) VALUES (
'RE20250210001', 10086, 100.00, 10,
100.00, 10, 0, '恭喜发财', 2, 0, DATE_ADD(NOW(), INTERVAL 24 HOUR)
);抢红包流程:
用户B看到红包消息,点击红包。这时候系统需要判断他有没有资格抢,以及还有没有剩余名额,然后扣减名额,预创建抢红包明细记录。
-- 开启事务
START TRANSACTION;
-- 1. 查询红包剩余名额,并加上行锁(FOR UPDATE)
SELECT remain_count, status
FROM red_envelope
WHERE envelope_id = 'RE20250210001'
FOR UPDATE;
-- 2. 判断:如果 remain_count > 0 且 status = 0,继续;否则并返回红包抢完了
-- 3. 插入抢红包明细记录(此时金额先填0,因为还没拆)
INSERT INTO red_envelope_detail (
envelope_id, user_id, amount, is_king, grab_time
) VALUES (
'RE20250210001', 10086, 0.00, FALSE, NOW()
);
-- 4. 判断:因为envelope_id,user_id是唯一键,
-- 如果Insert成功(返回1),侧面反映没有抢过,否则,说明已经抢过,不能再抢了
-- 5. 更新红包表的剩余名额(减1)
UPDATE red_envelope
SET remain_count = remain_count - 1
WHERE envelope_id = 'RE20250210001';
COMMIT;这里有几个细节值得注意:
- 为什么用 SELECT ... FOR UPDATE?因为多个并发抢请求可能同时读到 remain_count=1,然后都认为有名额,导致超卖。加上 FOR UPDATE 后,第一个请求会锁住这行,其他抢请求必须等待。
- 插入明细记录和更新红包表必须放在同一个事务里。如果插入成功但更新失败,就会造成抢红包明细表里多了一条记录,但红包名额没减少,出现超卖的情况。
- 此时明细表的 amount 填 0,因为用户还没拆,不知道具体金额。等到拆的时候再更新。为了什么这里就要预先记录明细表了呢?因为不记录的话,下一步拆无从知道这个用户是否已经抢到了红包。
总结一下,上述抢红包执行过程,通过事务保证数据一致性,通过数据库悲观锁实现了分布式锁,保证并发竞争导致的超卖问题。明眼人都看得出来,上述过程相当于串行操作,效率高不了!500人的群并发抢红包,排队耗时要好几秒,也就是说,从点红包到出现拆红包界面或者红包抢完了界面,要等待好几秒,用户体验显然不咋好。
拆红包流程:
用户B点击“拆”,系统需要分配一个随机金额给他。拆红包的事务比抢红包更长,因为它还要调用零钱系统:
START TRANSACTION;
-- 1. 先检查用户是否已经抢到了这个红包(即明细表中是否存在记录)
-- 同时锁住这条明细记录,防止重复拆
SELECT amount, envelope_id
FROM red_envelope_detail
WHERE envelope_id = 'RE20250210001' AND user_id = 10086
FOR UPDATE;
-- 如果查不到记录,说明用户根本没有抢到这个红包,直接返回错误
-- 如果查到记录且 amount > 0,说明已经拆过了,幂等返回成功(或提示已拆过)
-- 2.查询剩余金额和已拆人数(剩余未拆人数 = total_count - opened_count)
SELECT remain_amount, opened_count, total_count
FROM red_envelope
WHERE envelope_id = 'RE20250210001'
FOR UPDATE;
-- 3.根据剩余未拆红包个数(total_count-opened_count)和剩余金额,计算随机红包金额reward
-- 4.更新红包表
UPDATE red_envelope
SET remain_amount = remain_amount - @reward,
opened_count = opened_count + 1
WHERE envelope_id = 'RE20250210001';
-- 5.更新明细表的金额
UPDATE red_envelope_detail
SET amount = @reward
WHERE envelope_id = 'RE20250210001' AND user_id = 10086;
COMMIT;这里有几个细节值得注意:
- 第一个SELECT ... FOR UPDATE,是因为避免一个用户并发拆两次红包。
- 第二个SELECT ... FOR UPDATE,是因为要使用未拆红包个数和剩余金额来计算随机红包金额,如果不加锁,多个并发拆红包请求,会得到相同的未拆红包个数和剩余金额,导致随机算法出现问题,最后金额可能出现多分、或者有剩余的情况。加上 FOR UPDATE 后,第一个请求会锁住这行,其他请求必须等待。
- 细心的你应该发现,抢红包和拆红包都对red_envelope同一行加了悲观锁(FOR UPDATE),不仅抢红包之间不能并发执行,拆红包和抢红包之间也不能并发执行。用户点击抢,要等好几秒才出结果,用户点击拆,也要等好几秒才出结果,用户体验极差!
- 拆完红包之后,要将钱从微信公共账户(发红包的人提前转进来的),转到抢红包人的零钱账户,这个转账的操作,是在另一个系统(支付系统)中完成的,要么用RPC同步调用,要么使用消息队列来解耦异步。显然,为了性能,我们要选择后者,并且可以使用消息事务或者本地消息表,来保证转账消息一定发送到消息队列,这个上一节课有讲到。
(2)关键技术讨论
这部分我们重点看下,红包金额随机算法。
拼手气红包最迷人的地方在于它的不确定性,有人抢到0.01元,有人抢到几十块。这种随机性是怎么实现的?而且,在每秒几十万次拆红包的峰值下,随机算法的性能也至关重要。
我们先来看一个最直观的随机方案:每次从剩余金额中随机抽取一个值,范围是 [0.01, 剩余金额]。这种方案的问题很明显:如果第一个人运气好,抽走了大部分钱,后面的人就只能分一点残羹剩饭,体验极差。更糟糕的是,如果第一个人抽走了几乎全部金额,后面的人可能连0.01元都分不到,导致红包无法继续分配。
所以,随机算法必须满足两个约束:
- 公平性:每个人抢到金额的期望值应该相等(即总金额/总名额)。
- 可行性:任何时候,剩余金额至少要能保证剩余名额每人分到0.01元。
微信红包采用的算法叫做二倍均值法。它的核心思想是:每次抢的金额在当前剩余金额和剩余名额的约束下随机,但上限被控制为剩余均值的两倍,最后一个红包直接取剩余金额,确保不少分不多分。具体公式如下:
当前用户抢到的金额 = 随机区间 [0.01, 剩余金额 / 剩余名额 × 2] 内的一个值举个例子:一个100元10个的红包,剩余金额100元,剩余名额10个,那么均值是10元,上限是20元。第一个人抢到的金额在0.01到20元之间随机。假设他抢到了12元,那么剩余金额88元,剩余名额9个,均值变为9.78元,第二个人抢到的上限就是19.56元。依此类推。
4. 方案优化
初步设计中,抢红包和拆红包都严重依赖数据库行锁,导致所有操作串行化。一个500人的红包,即使只有10个名额,抢的过程也要串行处理500次,耗时数秒。
优化的思路其实在秒杀系统那节课里已经讲过了:用Redis代替数据库扛住高并发。但红包场景有自己的特点,不能完全照搬。我们一步步来看。
(1)Redis做库存扣减
在Redis中,我们需要存这样几个东西:
- Hash:存储红包的元信息(总名额、剩余名额、剩余金额、已拆人数)
- Set:grabbed_users,记录已抢到名额的用户 ID(用于防重复抢)
- Set:opened_users,记录已拆的用户 ID(用于防重复拆,后面拆红包时用)
有了Redis,还需要数据库吗?当然需要。数据库是最终的数据权威来源,承担以下职责:
- 持久化存储所有红包记录和抢红包明细,用于长期查询、对账等。
- 在 Redis 数据丢失(如宕机)时,可以从数据库恢复 Redis。
- 支持复杂的离线分析(如统计某用户今年抢了多少红包)。
Redis 在这里扮演的是热数据缓存 + 高性能计数器的角色,而不是替代数据库。
OK,搞清楚了Redis和数据库各自扮演的角色之后,我们再来重新设计发红包、抢红包、拆红包的执行流程。
发红包
先写数据库red_envelope,然后再同步到Redis。
# 红包库存信息
HSET red_envelope:RE20250210001 total_count 10 remain_amount 100.00 remain_count 10 opened_count 0
# 设置 24 小时过期
EXPIRE red_envelope:RE20250210001 86400抢红包
用户抢红包时,执行以下Lua脚本。
-- KEYS[1] = red_envelope:RE20250210001
-- ARGV[1] = 用户ID
local key = KEYS[1]
local user_id = ARGV[1]
-- 1. 检查用户是否已经抢过(grabbed_users 集合)
if redis.call('SISMEMBER', key .. ':grabbed_users', user_id) == 1 then
return {0, "already_grabbed"} -- 已抢过
end
-- 2. 检查剩余可抢名额
local remain_count = redis.call('HGET', key, 'remain_count')
if not remain_count or tonumber(remain_count) <= 0 then
return {0, "sold_out"} -- 名额已用完
end
-- 3. 扣减剩余名额,并记录已抢用户
redis.call('HINCRBY', key, 'remain_count', -1)
redis.call('SADD', key .. ':grabbed_users', user_id)
-- 4. 返回成功
return {1, "grabbed success"}这个脚本做了四件事:防重复、查库存、扣库存、记录用户,原子操作,没有行锁,没有事务开销。单机 Redis 每秒可以执行数万次这样的脚本。比初步设计中,使用数据库锁串行执行抢红包逻辑,不知道要高效多少。
这一步,我们并不同步数据到数据库中,因此性能非常高。抢红包是压力最大的,因为一个红包可能上百、上千人抢,但真正抢到的只有几个、几十个,因此,拆红包压力就小多了。
拆红包
用户点击“拆”时,我们需要在Redis Lua脚本中执行:
STEP1: 验证用户是否抢到了资格(即是否在 grabbed_users 中)
STEP2: 验证用户是否已经拆过(是否在 opened_users 中)
STEP3: 计算随机金额,更新剩余金额和已拆人数
STEP4: 记录已拆用户(opened_users)
-- KEYS[1] = red_envelope:RE20250210001
-- ARGV[1] = 用户ID
local key = KEYS[1]
local user_id = ARGV[1]
-- 1. 检查是否抢到资格(必须在 grabbed_users 中)
if redis.call('SISMEMBER', key .. ':grabbed_users', user_id) == 0 then
return {0, "not_grabbed", nil} -- 没抢到,不能拆
end
-- 2. 检查是否已经拆过
if redis.call('SISMEMBER', key .. ':opened_users', user_id) == 1 then
-- 已拆过,直接返回
return {0, "already_opened", nil}
end
-- 3. 获取当前剩余金额、总名额、已拆人数、未拆人数,计算随机金额
local remain_amount = tonumber(redis.call('HGET', key, 'remain_amount'))
local total_count = tonumber(redis.call('HGET', key, 'total_count'))
local opened_count = tonumber(redis.call('HGET', key, 'opened_count'))
local unopened = total_count - opened_count
if not remain_amount or unopened <= 0 then
return {0, "error", nil}
end
-- 计算随机金额(二倍均值法)
local reward
if unopened == 1 then
reward = remain_amount
else
local max = remain_amount / unopened * 2
-- 随机到分(整数运算,避免浮点误差)
local max_fen = math.floor(max * 100)
local reward_fen = math.random(1, max_fen)
reward = reward_fen / 100
if reward < 0.01 then reward = 0.01 end
end
-- 4. 更新剩余金额,增加已拆人数
redis.call('HSET', key, 'remain_amount', string.format('%.2f', remain_amount - reward))
redis.call('HSET', key, 'opened_amount', opened_count+1)
redis.call('SADD', key .. ':opened_users', user_id)
-- 5. 返回拆到的金额
return {1, "ok", reward}执行以上Lua脚本之后,如果返回结果为“{1, "ok", reward}”,我们需要将“用户抢到红包xxx”的消息(envelope_id, user_id, reward, grab_time 等),发送到消息队列,然后,异步更新数据库(更新剩余金额、剩余名额、记录拆红包明细),以及赚钱到用户的零钱包(可以通过消息队列再解耦异步)。
(2)数据库分库分表
即使我们把抢和拆的热路径都放到了 Redis,数据库依然是最终的权威数据源。前面我们算过,除夕夜一天产生的红包主表和明细记录超过 400 亿条,40TB 的数据。单个 MySQL 实例无论如何都扛不住。分库分表是必须的。
分库分表首先要解决的是分片键的选择。我们有两类核心查询:
- 按红包 ID 查询:查看某个红包的详情、谁抢了多少、手气最佳。这是读请求的主力。
- 按用户 ID 查询:查看“我发过的红包”和“我抢过的红包”。这是用户维度的查询。
如果只按红包 ID 分片,那么“按用户查询”就需要扫描所有分片,性能极差。反之,如果只按用户 ID 分片,查询红包详情时又不知道用户 ID(只知道红包 ID),同样需要广播SQL。
解决方案前面分库分表章节中已经讲过了,我们可以同时维护两张表,一张按红包 ID 分片,一张按用户 ID 分片。或者更常见的做法是:红包主表按红包 ID 分片,抢红包明细表按红包 ID 分片,再额外维护一张“用户红包索引表”按用户 ID 分片,只存储用户 ID、红包 ID、金额、时间等必要字段。这样两种查询都能快速定位。
(3)冷热数据分离
分库分表解决了数据的水平扩展,但 每天400 亿条记录,即使每张表1000万行记录,每天也需要4000张表,一年的表数量就要上百万了,显然不可行。
其实,用户发红包和抢红包时效性非常强,超过3天,就不会有用户去关注了。因此,非常适合做冷热数据分离。
- 热数据:最近 7 天的红包主表和明细记录,放在 SSD 高性能存储的在线数据库中。这部分数据量占不到 10% (因为大部分红包在发出后几小时内就被抢完了,新产生的数据不断写入,旧数据被移走)。
- 冷数据:超过 7 天的记录,定期迁移到廉价存储,比如 HBase、TDW(腾讯数据仓库)中。冷数据仍然支持查询,但延迟会高一些(比如几百毫秒到几秒),用户查询历史红包时可以接受。
如何实现迁移数据呢?可以每天凌晨运行一个定时任务,扫描红包主表,将 create_time 超过 7 天的记录导出到冷存储,然后从热表中删除。明细表同理。查询时,先查热表,如果没找到,再异步去冷存储中查。
(4)Redis分布式缓存
我们已经在抢红包和拆红包中用 Redis 作为主存储,但那是为了高性能写入。对于读请求,尤其是红包详情页的查询,我们也需要 Redis 来提高性能。
一个红包被抢完后,它的详情(谁抢了多少钱、手气最佳、总金额等)就不再变化。我们可以把详情页的完整数据序列化成 JSON,存入 Redis,设置 24 小时过期。当用户刷新详情页时,直接从 Redis 读取,根本不需要查询数据库。
对于未抢完的红包,详情页数据会频繁变化(每拆一个就变一次),不适合长期缓存。但未抢完的红包数量很少(因为大部分红包在发出来几分钟内就被抢光了),即使每次都查数据库,压力也不大。更何况我们可以做短时缓存,比如缓存 1 秒,允许少量不一致。
5. 最后总结
至此,我们已经讲完了普通下单、秒杀、抢红包三个系统设计问题,它们有相似之处,也有不同之处。
普通下单不在意库存超卖少卖、也不限制用户只购买一次,也没有热点库存,整体来说,解决起来比较简单,单纯分库分表、缓存、消息队列异步,就能搞定。
对于秒杀,限制不能超卖少卖,但限制没那么严格,限制用户只购买一次,并且,只有一个商品,热点库存并发访问压力非常大,解决的重点也是库存扣减问题,真正走到下单流程的不多,数据库存储压力不大,主要解决方案是使用Redis做库存扣减。
对于抢红包,严格限制超卖少卖,限制用户只抢购一次,相当于N多个秒杀活动,但是,热点库存并发访问压力没那么大,解决的重点也是使用Redis做库存扣减,并且,还要处理海量订单的存储问题,因此,也要设计分库分表等方案的设计,并且要做冷热数据分离。
八、消息瀑布
消息瀑布,也叫做news feed,或者Feed流,这个功能在很多社交软件中都有,并且是核心功能,比如微信朋友圈、微博、Facebook等,每天有数亿用户发布内容,数十亿次刷新浏览,每条动态要出现在成百上千个好友的时间线上。如果让你来设计社交软件的Feed流,你会如何来做?
1. 系统分析
照例,我们先进行需求分析。我们以微信朋友圈为背景展开讨论。
(1)功能性需求分析
朋友圈的功能可以归纳为以下几点:
- 发布动态:支持文字、图片、视频;支持可见范围设置;支持删除。
- 刷朋友圈:按时间倒序展示好友动态;支持分页加载;支持点赞、评论、转发。
- 我的朋友圈:查看自己发布过的所有动态,按时间倒序。
本节课我们聚焦以上部分核心功能的设计与实现,对于“可见范围设置”、“点赞、评论、转发”,不做讨论。
(2)非功能性需求分析
有了功能,我们再来估算系统的性能压力。做架构设计不能拍脑袋,需要基于合理的假设。我以微信朋友圈的真实规模为参考(公开数据结合推算),给出以下量级分析。
用户规模:微信月活约13亿,假设其中70%会使用朋友圈,即约9亿活跃用户。日活用户(每天打开朋友圈的)按5亿估算。
发布频率:假设平均每个日活用户每天发布0.5条朋友圈(有些人一天发好几条,很多人几天发一条,取0.5是合理的)。那么日发布总量 = 5亿 × 0.5 = 2.5亿条。峰值发布量按全天均匀分布的3倍计算,峰值每秒发布:2.5亿 / 86400秒 × 3 ≈ 8700 QPS。考虑节假日(春节、国庆)可能再翻倍,峰值按 2万 QPS 估算。
浏览频率:用户刷朋友圈的频率远高于发布。假设每个日活用户每天刷10次,每次拉取20条动态。那么日浏览请求 = 5亿 × 10 = 50亿次。峰值QPS:50亿 / 86400 × 3 ≈ 17万 QPS,节假日翻倍,峰值按照30万QPS 估算。这里要注意,一次浏览请求返回20条动态,但后端需要从收件箱取出20个feed_id,再批量查20条详情。所以实际的内部数据库的读QPS要远大于30万。
存储规模:每条动态平均大小约1KB(包括用户ID、时间戳、可见范围、点赞评论计数、图片URL、视频URL等),图片和视频存储在其他存储系统中。那么,一年产生的动态数量 = 2.5亿 × 365 ≈ 912亿条。存储容量 = 912亿 × 1KB ≈ 91TB。这只是原始数据,加上索引、备份,三年内轻松达到PB级。所以分库分表是必须的。
关系链规模:微信限制每个用户最多5000个好友,实际平均好友数约200~300。那么总的好友关系记录数 = 9亿用户 × 平均200 = 1800亿条。关系链的读写频率也很高(发动态时要查询好友列表),需要专门的关系链服务。关系链服务的架构设计不在我们的讨论范围内。
带宽压力:图片和视频的流量极大。假设每条动态有3张图片,每张500KB,每天2.5亿条动态,图片总大小 = 2.5亿 × 3 × 500KB ≈ 3.75PB/天,这还不包括视频。这个量不可能走业务服务器,必须全部由CDN承载。客户端上传图片到专门的海量文件存储系统(比如腾讯的TFS),然后同步到CDN。下载时,Feed流返回的图片URL指向CDN,用户从就近CDN节点拉取。这样业务服务器只传输元数据,流量减少了很多。
一致性要求:最终一致性即可。你发了一条动态,朋友晚几秒钟看到,完全可接受。但动态不能丢,发布成功后必须持久化。
2. 初步设计
照例,我们还是先基于数据库存储做初步设计,梳理清楚业务流程。
(1)基本功能实现
要实现发布和刷朋友圈,最核心的就是一张存储动态内容的表。我们暂且叫它 feed_detail。这张表要记录一条动态的所有元数据:谁发的、发了什么、什么时候发的、有多少人点赞评论,以及图片视频的地址。
CREATE TABLE feed_detail (
feed_id BIGINT PRIMARY KEY, --全局唯一,通常用雪花算法(Snowflake)生成
user_id BIGINT NOT NULL,
content TEXT,
image_urls TEXT, -- 存储JSON数组,如["url1","url2"]
video_url VARCHAR(512),
like_count INT DEFAULT 0,
comment_count INT DEFAULT 0,
INDEX idx_user_time (user_id, created_at DESC) --用来支持“我发表的朋友圈”的快速查询
);有了这张表,发布动态就很简单:插入一条记录。刷朋友圈呢?就会是这样:
SELECT * FROM feed_detail
WHERE user_id IN (好友ID列表)
ORDER BY created_at DESC
LIMIT 20;这里 IN 列表的长度就是用户的好友数,平均200个。这条SQL会怎么执行?数据库要对每个 user_id 利用 idx_user_time 索引分别扫描,然后做归并排序。虽然索引能加速,但200个用户的索引扫描和归并开销极大,而且好友越多越慢。
这种实现方式,叫做拉模式,也叫做读扩散。用户发布动态时,只需要写一条记录。但是,好友刷朋友圈时,系统会实时查询好友列表,然后根据好友列表,从动态表中,拉取动态,合并、排序、截取,再返回。拉模式的好处是写操作轻量:只存一份。缺点是读操作慢:每次刷朋友圈都需要实时合并排序,如果关注的人很多(比如1000个好友),每次要查询1000个人的发布的动态,性能压力巨大。
对应拉模式,就是推模式,也叫做写扩散。当用户发一条朋友圈时,系统立刻找出这个用户的所有好友,把这条动态的ID(feed_id)“推”到每个好友的收件箱(Inbox)里。好友刷朋友圈时,只需要读自己的收件箱,获取feed_id列表,然后再去批量查询feed_detail表即可。
收件箱的表结构如下:
CREATE TABLE user_inbox (
user_id BIGINT NOT NULL, -- 收件箱的主人
feed_id BIGINT NOT NULL, -- 动态ID
created_at DATETIME NOT NULL, -- 动态发布的时间(用于排序)
PRIMARY KEY (user_id, feed_id),
INDEX idx_time (user_id, created_at DESC) -- 按时间倒序拉取
);有了这张表,刷朋友圈的查询就变为:
-- 第一步:从收件箱取最新的20个feed_id
SELECT feed_id FROM user_inbox
WHERE user_id = 当前用户ID
ORDER BY created_at DESC
LIMIT 20;
-- 第二步:根据feed_id批量查详情(用 IN 或分批 MGET)
SELECT * FROM feed_detail
WHERE feed_id IN (id1, id2, ..., id20);推模式的好处是读操作(浏览朋友圈)很快。缺点是写操作变慢:发布一条动态,要写n条收件箱记录(n等于好友个数,上线5000个,平均200个)。为了避免发布一条动态,耗时太长,我们可以借助消息队列,在写成功feed_detail之后,将这条需要写扩散的feed_id+发布者user_id信息,发布到消息队列,然后异步读取写扩散信息,查询好友,并批量写入所有好友的收件箱。虽然异步操作会加大延迟,用户发布动态之后,好友需要等待几秒才能看到,但这在业务上完全是允许的。
(2)关键技术讨论
以上feed流架构的讨论,基于的是微信朋友圈这个场景,每个用户的好友个数不会很多,上线也就是5000人,写扩散有压力,但也还可以接受,并且,待会还会讲到基于Redis存储用户收件箱的优化策略。但是,对于微博这种场景,follow关系是可以单向的,一个明星可能有1000万个粉丝,发一条动态就要写入1000万个收件箱,给消息队列和数据库带来灾难:存储成本飙升(每条动态复制1000万份),扩散任务可能几个小时都跑不完,粉丝刷微博时甚至看不到最新动态(因为收件箱还没写完)。
那微博是怎么做的?答案是推拉结合,或者叫混合模式。核心思想是:对于关注列表中的用户,普通用户(粉丝数少)采用推模式,大V(粉丝数超过阈值,比如100万)采用拉模式。读者刷微博时,需要合并两个来源。
假设你关注了200个人,其中180个是普通好友,20个是百万粉丝的大V。当你刷新微博首页时,系统会:
- 从你的收件箱中取出普通好友的最新动态ID。这部分已经通过推模式预先写好了,直接读即可,速度极快。
- 从feed_detail中实时拉取大V最近发布的动态。因为大V数量少(比如你只关注了20个),所以实时查询20个人的发布到最新动态并合并排序,开销完全可控。
- 将两路结果合并,按时间排序,取前20条返回。
推拉结合还有一种变体:基于活跃度的分层。不是按发布者的粉丝数,而是按读者的活跃度来决定推还是拉。Facebook 的早期实现就采用了类似策略。这个策略能大幅减少写扩散的总量,因为大部分用户其实不活跃。
- 活跃用户(每天登录多次):采用推模式。因为他们的收件箱会被频繁读取,值得提前写好。
- 不活跃用户(几天登录一次):采用拉模式。因为他们很少读,提前写收件箱浪费存储和计算资源。
3. 方案优化
以上我们梳理清楚了Feed流基本业务的实现方案。但如果要支撑微信这样体量的系统(日活5亿,峰值30万QPS的浏览请求,每天2.5亿条新动态),我们还需要做一系列优化。
(1)Redis收件箱
对于推模式来说,一个动态的发布,要写N(平均200,上线5000)个收件箱,写的压力很大,当然,读也非常频繁(每次刷朋友圈要读取自己的收件箱)。我们可以使用Redis替代数据库来实现收件箱。收件箱的数据结构非常固定,每个用户有一个有序的 feed_id 列表,按时间倒序。这种“用户ID → 有序列表”的映射,天然适合用 Redis 的 Sorted Set 来实现。
每个用户的收件箱对应一个 Redis Key,格式为:inbox:{user_id}。我们使用 Redis 的 Sorted Set(有序集合)来存储 feed_id 列表。member 是 feed_id,score 是动态发布的时间戳。
ZADD inbox:12345 <timestamp> <feed_id>当用户刷朋友圈时,用 ZREVRANGE 按 score 倒序取出前 20 个 feed_id:
ZREVRANGE inbox:12345 0 19 WITHSCORESRedis 虽然快,但毕竟是内存存储,万一宕机可能丢失数据。对于收件箱,丢失几条最近写入的 feed_id 是可以接受的,但不能全部丢失。因此需要开启 Redis 的 AOF/RDB 持久化。同时,部署 Redis 主从+哨兵架构,主节点故障时自动切换到从节点。
当然,最保险的做法是:将收件箱数据定期异步备份到 MySQL 或 HBase。例如,每小时对每个用户的收件箱做一次快照,写入一张MySQL冷备表(只做备份用,不负责查询)。如果 Redis 集群发生灾难性故障,可以从备份中恢复一大部分。其实,即便Redis的数据全部丢失,收件箱的重建成本也是不高的,可以通过好友列表和feed_detail离线计算得到的。
(2)分库分表
用户的收件箱用Redis来实现,因为读写要快,而且丢一点、甚至全丢也没关系,都能恢复。但是,feed_detail是用户发布的动态,100%不能丢,因此,这部分数据仍然需要依赖数据库存储。而feed_detail 表一年就接近千亿条记录,单库单表显然不可行。我们必须对它进行分库分表。
分片键的选择直接决定了查询是否能路由到单个分片。回顾一下我们对 feed_detail 的查询模式:
- 主查询:根据 feed_id 查详情(刷朋友圈时批量查)。这是频率最高的操作。
- 次查询:SELECT * FROM feed_detail WHERE user_id = ? ORDER BY created_at DESC(“我的朋友圈”)。频率低,但也是真实需求。
如果按 feed_id 哈希分片,主查询可以精确命中单个分片,性能极好。但“我的朋友圈”查询会变成跨所有分片的聚合扫描,每个分片都要按 user_id 索引去查,然后汇总归并,效率极低。
如果按 user_id 哈希分片,“我的朋友圈”查询完美命中单个分片,性能极好。但 feed_id 查询就麻烦了,我们只有 feed_id,不知道对应的 user_id,无法路由到正确分片,只能广播到所有分片。
怎么办?两种方案各有优劣,我们需要根据业务权重做选择。朋友圈的访问模式中,刷朋友圈(按 feed_id 查详情)的频次远高于“我的朋友圈”。前者是用户每天几十次的操作,后者可能一天不到一次。从这个角度看,优化主查询更合理。
如果选择 feed_id 分片,那 user_id 查询就需要跨分片。为了不每次都扫全分片,我们可以建一张索引表user_feed_index,存储 (user_id, feed_id, created_at)对应关系。这张表非常小(每行只有三个字段),专门用来支持“我的朋友圈”查询。用户发动态时,双写 feed_detail 和 user_feed_index;查询时先查索引表拿到 feed_id 列表,再回 feed_detail 查详情。
但还有一个事实:feed_id 本身可以携带分片信息(基因法)。这样,给定一个 feed_id,我们可以直接解析出它应该落在哪个分片,从而精确路由。因此,我们还可以选择基于user_id分片,feed_id携带分片信息的基因法来实现。
索引法和基因法在「数据存储-分库分表」中都有详细讲解,不懂的可以回过头去看下。
(3)冷热数据分离
前面提到,每年产生上千亿条动态,即便分库分表,一个表1000万条记录,也需要1万张表,这还只是一年的量,显然,光分库分表还是不够的。其实,上一节讲微信红包的时候,也有类似的问题,解决思路都是类似的,即冷热数据分离。
随着时间推移,用户几乎只会刷最近几天的动态,三个月前的朋友圈,谁还会去翻呢?热数据占比很小,大部分历史数据占据着宝贵的 SSD 空间,还拖慢了索引扫描。
我们可以做 冷热分离:将超过一定时间(比如 3 个月)的动态从主表中定期迁移到廉价存储,比如 HBase、TDW(腾讯数据仓库)中。冷数据仍然支持查询,但延迟会高一些(比如几百毫秒到几秒),用户查询历史朋友圈时是可以接受。
同时,收件箱也要配合做裁剪。收件箱只保留最近 2000 条动态。按照每天 20 条动态的发布频率(一个用户的好友总共每天产生 20 条),2000 条大约是 100 天的量,刚好覆盖 3 个月。也就是说,收件箱里天然存的就是最近 3 个月的热数据。只有“我的朋友圈”这种主动翻历史的行为才需要访问冷数据,频率极低。
(4)分布式缓存
虽然我们已经对 feed_detail 做了分库分表,但每次查询都要走 MySQL 仍然扛不住 30 万 QPS。Redis分布式缓存必须上场。
缓存 Key 为:feed:{feed_id},value 是动态详情的序列化数据(JSON 或 Protobuf)。先查询Redis,未命中的再查询 MySQL,查询结果写入Redis。
新发布的动态,用户会立刻刷到。在发布时,我们可以主动将动态详情写入缓存(而不是等第一次读)。这样可以避免第一个刷到的人要到数据库去查询。
动态详情一旦发布,基本不会修改。所以,过期时间可以设置得很长,比如 7 天。为什么不永久保留?因为热点会转移,旧动态没人看,没必要占着内存。而且 7 天的缓存时间已经覆盖了绝大多数刷朋友圈的场景。
4. 最后总结
Feed流设计的核心在于“推”与“拉”的权衡:推模式用写扩散换取极致的读性能,适合好友数量有限且读多写少的场景;拉模式写成本低但读合并开销大,适用于大V或低频访问。
微信朋友圈基于好友上限和最终一致性,选择异步推模式,并用Redis收件箱、分库分表、冷热分离和分布式缓存层层优化,最终支撑起海量请求。
