经典系统设计案例
短链、秒杀、Feed、IM 四个端到端案例:每个案例按"需求与指标 → 架构图 → 核心难点与解法 → 可扩展点"展开,附分布式 ID 与限流算法选型附表。
一、案例阅读方法
1.1 四个案例的通用分析框架
系统设计面试与实战分析都可套用同一流程(详见《高并发系统设计》的 8 步法):
- 明确需求与指标(功能边界 + 量化目标);
- 画架构图(分层 + 数据流);
- 识别核心难点(每个案例只有 1~3 个真难点);
- 给解法(优先用缓存/异步/分片三板斧);
- 指出扩展点与演进方向。
1.2 本笔记与其它笔记的关系
- 秒杀中的限流、削峰细节可回看《高并发系统设计》第六、八章;
- Feed/订单中的领域建模可回看《DDD领域驱动设计》;
- 案例中涉及的分布式事务、注册中心选型见《微服务架构与拆分》;
- 本文只做端到端串联,不再重复展开原理细节。
二、案例一:短链系统
2.1 需求与指标
| 项 | 内容 |
|---|---|
| 功能 | 长链转短链、短链跳转长链、过期清理、自定义短链、统计 |
| 指标 | 短链生成 QPS 数百~数千;跳转 QPS 数万~数十万(读远大于写) |
| 约束 | 短链尽量短(6~8 位)、全球唯一、跳转 P99 < 100ms、不可枚举遍历 |
读多写少的典型场景,设计重心在"生成算法"与"跳转链路"。
2.2 架构图
2.3 短链生成:哈希 vs 发号器
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 哈希截断 | 长链 MD5/SHA 后取前 N 位(或用 62 进制压缩) | 无状态、天然幂等(同长链同短链) | 冲突需处理(加盐/长度扩展/冲突重试);无法保证最短 |
| 发号器自增 | 全局发号器(DB 自增/Redis incr/号段)拿到数字,转 62 进制短码 | 短链最短、无冲突、有序便于分库 | 需要发号组件;数字可枚举(可用随机 salt 混淆) |
| 预生成短码池 | 预先批量生成随机短码入池,用时取 | 生成与使用解耦 | 池维护成本 |
推荐组合:写侧用"发号器(号段模式)取号 + 随机偏移混淆",跳转读侧用缓存。哈希方案适合不想维护发号器的小系统,冲突概率与查库处理必须设计清楚。
62 进制示例:ID=1000000 → 短码 计算如下。
private static final char[] CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();
public String encode(long id) {
StringBuilder sb = new StringBuilder();
while (id > 0) {
sb.append(CHARS[(int) (id % 62)]);
id /= 62;
}
return sb.reverse().toString(); // 6 位可表示 62^6 ≈ 568 亿
}private static final char[] CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();
public String encode(long id) {
StringBuilder sb = new StringBuilder();
while (id > 0) {
sb.append(CHARS[(int) (id % 62)]);
id /= 62;
}
return sb.reverse().toString(); // 6 位可表示 62^6 ≈ 568 亿
}2.4 跳转流程与缓存
- 用户访问
/s/{code},网关路由到短链服务; - 先查本地缓存 → Redis(
short:{code} → longUrl,命中率 >99%); - 未命中查 DB(存储用
code作分片键/主键),回填缓存; - 返回 302 跳转到长链,同时异步记访问日志(MQ 削峰)。
为什么 302 而不是 301:301 会被浏览器缓存,服务端将收不到后续点击,无法做统计与风控;302 每次都经过服务端,可统计、可拦截。跳转链路只做映射,不做业务,保证 P99 低。
2.5 过期与清理
| 策略 | 做法 | 适用 |
|---|---|---|
| TTL 惰性删除 | Redis key 带 TTL,过期即不可访问 | 兜底,必选 |
| 定时批量清理 | 扫过期记录批删 + 短码回收入池 | 短码资源紧张时 |
| 访问触发检查 | 访问时发现过期标记删除 | 与 TTL 配合 |
不可回收场景:短链一旦被分享到外部(印刷品、外链),回收会破坏历史链接,生产上普遍只删新链接、长期保留已访问短链。
2.6 防滥用与安全
- 生成接口限流 + 登录/白名单校验,防批量刷接口;
- 跳转目标校验:禁止跳向恶意域名(跳转前做 URL 安全检测),防钓鱼;
- 短码防枚举:发号加随机偏移或用随机短码池,防遍历抓取全部链接;
- 敏感长链不落明文日志。
2.7 可扩展点
- 分库分表:按
code前缀哈希分 64 表,写入顺序基本无热点(发号器取号本就均匀); - 统计升级:点击日志入数仓(Hive/Doris),产出天/周/月报表与来源分析;
- 自定义短链与域名(企业品牌短链)。
三、案例二:秒杀系统
3.1 需求与指标
| 项 | 内容 |
|---|---|
| 功能 | 定时秒杀、限量抢购、一人一单、下单后支付 |
| 指标 | 瞬时流量可到日常 50~100 倍(如 1000 万人在线抢 10 万库存);写放大极猛 |
| 硬约束 | 库存不能超卖、不能少卖;用户体验"抢到就是抢到,未抢到快速失败" |
秒杀的本质矛盾:洪峰流量 vs 有限库存——99.9% 的流量注定失败,系统设计目标不是"让所有人成功",而是"把注定失败的流量在尽量靠前的层挡住,只让少量有效请求进入交易链路"。
3.2 架构图(本地标记 + MQ 削峰 + 库存预扣)
3.3 核心难点拆解
| 难点 | 本质 | 解法 |
|---|---|---|
| 超卖 | 并发扣减库存超过总量 | Redis DECR 原子预扣 + DB 兜底校验 |
| 一单多买/一人多单 | 同一用户重复抢 | 用户维度标记(Redis SETNX 用户已购)+ 订单表唯一索引 |
| 瞬时洪峰 | 流量远超处理能力 | 分层拦截:CDN 静态化 → 网关限流 → 本地标记 → MQ 削峰 |
| 少卖 | 有库存却无人成功 | 预扣失败/超时未支付需回补库存,保证不超卖也不积压错杀 |
3.4 库存预扣的原子操作
Redis 预扣是防超卖的第一道闸,必须是原子命令:
// 秒杀资格校验 + 库存预扣(Lua 保证原子)
// KEYS[1]=stock:1001 KEYS[2]=user:1001:bought ARGV[1]=userId
String lua = """
if redis.call('exists', KEYS[2]) == 1 then
return -1 -- 已抢过,一人一单
end
local stock = redis.call('get', KEYS[1])
if not stock or tonumber(stock) <= 0 then
return 0 -- 无库存
end
redis.call('decr', KEYS[1])
redis.call('set', KEYS[2], 1, 'EX', 86400)
return 1 -- 预扣成功
""";// 秒杀资格校验 + 库存预扣(Lua 保证原子)
// KEYS[1]=stock:1001 KEYS[2]=user:1001:bought ARGV[1]=userId
String lua = """
if redis.call('exists', KEYS[2]) == 1 then
return -1 -- 已抢过,一人一单
end
local stock = redis.call('get', KEYS[1])
if not stock or tonumber(stock) <= 0 then
return 0 -- 无库存
end
redis.call('decr', KEYS[1])
redis.call('set', KEYS[2], 1, 'EX', 86400)
return 1 -- 预扣成功
""";预扣成功后发 MQ 异步落单;落单失败(如风控拦截)需回补库存(INCR + 删用户标记),配合定时对账任务保证最终一致。
3.5 本地标记(防止打爆 Redis 的第一道闸)
每个秒杀应用节点启动/开抢前加载"场次剩余标记"到本地内存:
- Redis 预扣返回 0(无库存)后,本节点本地标记为"已售罄",后续请求本地直接返回"已抢光",不再打 Redis;
- 标记带 TTL(如 5s),Redis 回补库存后标记自然过期恢复,避免"明明有货本地却一直挡着"。
本地标记把对 Redis 的流量降低几个数量级:100 万请求打过来,只有首个探针请求真正触达 Redis。
3.6 秒杀的下单链路最终一致性
用户看到"抢购成功"只代表预扣成功,真正订单在 MQ 后异步落库——这就是秒杀"秒级响应 + 最终一致"的关键。库存预扣与订单落库之间的账,由定时对账任务兜底(预扣成功但 15 分钟无订单则回补)。
3.7 可扩展点
- 多级库存:Redis 预扣 → DB 库存校对 → 供应商/仓库库存,支持超卖风险分级;
- 风控升级:设备指纹、IP 频控、人机校验前移到网关;
- 结果查询接口独立(轮询"是否抢到"与抢购主流程分离,避免拖慢主链路)。
四、案例三:Feed 流系统
4.1 需求与指标
| 项 | 内容 |
|---|---|
| 功能 | 关注的人发动态,我刷出按时间倒序的聚合流;含评论/点赞 |
| 指标 | 读多写极少:大 V 有百万粉丝,写 1 条要能被百万粉丝看到;个人刷新 P99 < 300ms |
| 约束 | 最终一致(允许秒级延迟);关注关系动态变化 |
核心矛盾是扇出(Fanout):一个生产者的一条内容要投递给 N 个消费者,N 可以大到百万级。
4.2 推模式 vs 拉模式 vs 推拉结合
| 模式 | 流程 | 优点 | 缺点 |
|---|---|---|---|
| 纯推 | 发动态时写入所有粉丝的收件箱 | 读极快(读自己时间线即可) | 大 V 发一条要写百万份,写放大爆炸 |
| 纯拉 | 发动态只写自己的发件箱,粉丝刷新时拉取关注列表的内容再合并 | 写便宜 | 读放大(拉取 N 个关注对象再归并排序),大 V 粉丝读时压力大 |
| 推拉结合 | 普通用户推,大 V 粉丝刷时拉(大 V 的粉丝不推,读时实时拉取合并) | 均衡 | 实现最复杂,需处理"大 V 判定 + 合并缓存" |
4.3 架构图(推拉结合)
4.4 缓存结构设计
| 数据 | 存储 | 结构 | 说明 |
|---|---|---|---|
| 收件箱(我的时间线) | Redis | ZSet:inbox:{userId},score=发布时间戳 | 只保留最近 500~1000 条,防无限膨胀 |
| 发件箱 | Redis | ZSet:outbox:{userId} | 生产者自己的内容索引 |
| 内容详情 | Redis | String:feed:{feedId} | 过期+DB 兜底(Cache Aside) |
| 关注列表 | Redis/DB | 大 V 列表与全量关注分存 | 推拉判断依据 |
分页用游标不用 offset:时间线是动态数据,用"上次最后一条的时间戳"做游标(maxTime 游标拉取),避免 offset 深分页的数据跳动与性能问题。
4.5 核心难点与解法
| 难点 | 解法 |
|---|---|
| 大 V 扇出放大 | 阈值判定(如粉丝 > 10 万)走拉模式,粉丝刷新时实时拉取合并 |
| 收件箱膨胀 | 只保留最近 N 条,历史内容通过"关注对象内容回溯"兜底 |
| 重复/乱序 | ZSet 以内容 ID + 发布时间为序,去重天然成立 |
| 读放大 | 内容详情缓存 + 批量获取(pipeline/mget)一次拉 20 条 |
| 延迟 | 推模式投递用 MQ 异步批量写收件箱 |
4.6 可扩展点
- 内容推荐:时间线后接推荐流(热榜/兴趣),数据源复用内容缓存与画像;
- 关注关系存储:粉丝量级大时用图存储或分片关注表;
- 拉取合并的分层缓存:大 V 内容加本地缓存,扛住粉丝集中刷新。
五、案例四:IM 消息系统
5.1 需求与指标
| 项 | 内容 |
|---|---|
| 功能 | 单聊/群聊、在线状态、已读回执、历史消息 |
| 指标 | 在线连接数百万级(长连接);单机连接 10 万级;消息端到端延迟 P99 < 200ms |
| 硬约束 | 不丢、不重、不乱序;离线消息可靠送达 |
5.2 架构图
5.3 长连接网关设计
- 连接层:网关(Netty/自研)维护与客户端的 WebSocket 长连接,一台网关支撑 10 万级连接;客户端断线自动重连(指数退避 + 随机抖动,防重连风暴);
- 路由表:Redis 维护
uid → 网关节点,消息先到网关、查路由表转发到目标所在网关(或经 MQ 路由到目标网关); - 有状态服务:连接是有状态的,网关扩缩容需平滑迁移(先踢到重连、再摘除);网关宕机 = 连接断开,靠客户端重连 + 路由表更新恢复;
- 心跳:30s 应用层心跳 + 超时判定,配合 TCP keepalive,及时清理死连接。
5.4 消息可靠性:不丢、不重、不乱序
"不丢不重不乱序"是 IM 的核心难点,业界标准解法是客户端序列号 + 服务端确认 + 会话维度的单线程有序处理:
| 目标 | 手段 |
|---|---|
| 不丢 | 消息先落库/落 MQ 再应答;客户端本地有"待确认"队列,未 ack 重发(消息带客户端 msgId 去重) |
| 不重 | 服务端按 会话+客户端msgId 唯一约束去重,重复消息对客户端不可见(返回原 seq) |
| 不乱序 | 每条消息在会话内分配单调递增 seq;同一会话的消息由同一队列分区顺序消费,客户端按 seq 排序,缺口触发重拉 |
发送流程:
客户端(msgId=本地唯一) → 网关 → 消息服务(落库,分配 seq) → MQ(会话分区键=conversationId)
→ 消费端按会话顺序处理 → 路由到目标网关 → 目标客户端 ack
离线/多端:按会话拉取 > 本地 seq 的消息做多端同步发送流程:
客户端(msgId=本地唯一) → 网关 → 消息服务(落库,分配 seq) → MQ(会话分区键=conversationId)
→ 消费端按会话顺序处理 → 路由到目标网关 → 目标客户端 ack
离线/多端:按会话拉取 > 本地 seq 的消息做多端同步5.5 群消息的扇出
群聊的扇出在消息层而非连接层:
- 发送方把消息投递一次到群会话,消费端按群成员路由到各自网关(成员量大时离线成员走推送/拉取);
- 与 Feed 流同理:超大群(万人在线)不做实时全量推送,采用"在线成员实时推 + 离线/新进群成员拉取最近 N 条"的推拉结合。
5.6 已读回执与在线状态
| 功能 | 设计 |
|---|---|
| 在线状态 | 网关心跳上报 Redis(含最后在线时间);好友状态订阅用 Redis Pub/Sub 或状态服务推送 |
| 单聊已读回执 | 客户端读到最新 seq 后上报 已读seq,服务端更新会话已读游标,通知对方 |
| 群聊已读回执 | 只统计不逐人通知:维护"已读人数/群成员已读游标",高频场景异步批量更新 |
| 多端同步 | 每端独立游标,端与端不互相影响已读状态 |
5.7 可扩展点
- 历史消息检索:消息存储归档到数仓/ES,支持关键词搜索;
- 消息存储分级:热数据(近 30 天)在在线库,冷数据归档 OSS/冷表;
- 音视频通话信令复用消息通道(信令走长连接、媒体走 RTC);
- 端到端加密(E2EE):服务端只存密文,安全诉求高时引入。
六、通用组件选型附表
6.1 分布式 ID 生成方案对比表
| 方案 | 实现 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| UUID | 本机生成 36 位字符串 | 零依赖、全球唯一 | 无序、过长(难做索引)、无业务含义 | 本地标识、非数据库主键 |
| DB 自增 | MySQL AUTO_INCREMENT | 简单、有序 | 单点、依赖 DB、并发受限 | 低并发单体 |
| 号段模式 | 每次取一段(如 1000 个),内存发号 | 高效、顺序、少 DB 交互 | 号段丢失会跳号 | 发号器/短链推荐 |
| Redis INCR | INCR key | 高吞吐、简单 | 依赖 Redis、持久化有重号风险 | 低要求计数类 |
| 雪花算法(Snowflake) | 时间戳41位+机器10位+序列12位 | 趋势递增、无中心、高吞吐 | 时钟回拨会重号(需等待/备用位)、依赖机器 ID | 大厂默认主流方案 |
| 美团 Leaf / 百度 UidGenerator | 号段 / 雪花变种 | 解决各自痛点 | 引入中间件组件 | 规模化生产 |
选型结论:
- 追求简单:
号段模式(DB 批量取号)性价比最高; - 追求高性能无中心:
雪花算法,注意时钟回拨三件套(校验等待 / 备用机器位 / 前后缀兜底); - 业务需要严格趋势有序且可读(订单号):号段或雪花;
- 不要把 UUID 当海量表主键(页分裂 + 索引膨胀)。
6.2 限流算法对比表
| 算法 | 原理 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| 固定窗口 | 单位时间窗口内计数超限拒绝 | 实现最简单 | 窗口临界突刺(两倍流量) | 简单限流 |
| 滑动窗口 | 按时间片滑动计数 | 消除固定窗口突刺 | 内存开销略高(多时间片) | 常规接口限流,推荐 |
| 漏桶 | 匀速出水,超容丢弃 | 输出绝对平滑,天然削峰 | 无法应对突发(匀速是硬性) | 保护下游 DB/第三方 |
| 令牌桶 | 按速率放令牌,桶存突发容量 | 允许合理突发 + 平滑整体 | 突发超过桶容量仍会拒绝 | 网关/接口限流主流(Sentinel/Guava) |
选型结论:
- 只想简单挡流量:滑动窗口;
- 需要"允许秒杀式突发又保护下游":令牌桶(生产网关默认);
- 下游完全不能扛尖峰(对账导出、第三方接口):漏桶;
- 组合用法:网关令牌桶限总流量 + 应用层滑动窗口限用户维度。
6.3 附表使用指引
- 分布式 ID 用在案例中的场景:短链取号(号段)、秒杀订单号(雪花/号段)、Feed 与 IM 消息 ID(雪花,保证时间有序便于游标分页);
- 限流算法用在案例中的场景:秒杀网关(令牌桶挡洪峰 + 本地标记挡重复)、短链生成接口(用户维度限流)、IM 登录与重连(防重连风暴限流)。
6.4 四案例方法论提炼
| 案例 | 第一性矛盾 | 三板斧应用 |
|---|---|---|
| 短链 | 读放大 | 发号器 + 缓存跳转 + 异步统计 |
| 秒杀 | 洪峰 vs 有限库存 | 分层拦截 + Redis 原子预扣 + MQ 削峰 |
| Feed | 生产者扇出 | 推拉结合 + 收件箱缓存 + 游标分页 |
| IM | 不丢不重不乱序 | 长连接网关 + seq + 会话分区顺序消费 |
面试表达要点:每个案例先说清"最难的是什么",再给方案——能说清矛盾的人比能背架构图的人更接近设计本质。
Cao Note