Skip to content

经典系统设计案例 ​

短链、秒杀、Feed、IM 四个端到端案例:每个案例按"需求与指标 → 架构图 → 核心难点与解法 → 可扩展点"展开,附分布式 ID 与限流算法选型附表。


一、案例阅读方法 ​

1.1 四个案例的通用分析框架 ​

系统设计面试与实战分析都可套用同一流程(详见《高并发系统设计》的 8 步法):

  1. 明确需求与指标(功能边界 + 量化目标);
  2. 画架构图(分层 + 数据流);
  3. 识别核心难点(每个案例只有 1~3 个真难点);
  4. 给解法(优先用缓存/异步/分片三板斧);
  5. 指出扩展点与演进方向。

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 → 短码 计算如下。

java
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 跳转流程与缓存 ​

  1. 用户访问 /s/{code},网关路由到短链服务;
  2. 先查本地缓存 → Redis(short:{code} → longUrl,命中率 >99%);
  3. 未命中查 DB(存储用 code 作分片键/主键),回填缓存;
  4. 返回 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 预扣是防超卖的第一道闸,必须是原子命令:

java
// 秒杀资格校验 + 库存预扣(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 缓存结构设计 ​

数据存储结构说明
收件箱(我的时间线)RedisZSet:inbox:{userId},score=发布时间戳只保留最近 500~1000 条,防无限膨胀
发件箱RedisZSet:outbox:{userId}生产者自己的内容索引
内容详情RedisString: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 排序,缺口触发重拉
text
发送流程:
客户端(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 INCRINCR 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 + 会话分区顺序消费

面试表达要点:每个案例先说清"最难的是什么",再给方案——能说清矛盾的人比能背架构图的人更接近设计本质。