跳到主要内容
预计阅读 31 分钟

08 | 经典系统设计(上)—— 短链服务、Feed 流、即时通讯、抢红包

真正考验系统设计能力的,不是画出一幅漂亮的架构图,而是当线上告警响起时,你能在 5 分钟内定位到瓶颈在哪。


开篇自测

  1. 短链服务当 QPS 从 100 涨到 10 万时,系统会在哪些环节出现瓶颈?你能画出完整的优化路径吗?
  2. Feed 流的”推模式”和”拉模式”各自的性能瓶颈是什么?生产系统通常怎么做?
  3. 抢红包场景下,如何在保证不超发的前提下让 99.9% 的请求在 50ms 内返回?

一、短链服务设计

1.1 从一个线上问题说起

某天运营反馈:短链跳转偶尔出现 5 秒延迟。排查发现是数据库连接池耗尽——短链查询没走缓存,一条热门短链被分享到微信群后瞬间涌入大量请求。这告诉我们:短链服务的核心挑战不是”如何生成短链”,而是”如何扛住突发读流量”。

1.2 系统架构

+----------------------------------------------------------------+
|                     短链服务架构                                 |
+----------------------------------------------------------------+
|  用户点击 https://s.co/Xk9mP2                                  |
|       |                                                        |
|  +----------+     +---------+     +---------+                  |
|  |  Nginx   |---->| 短链服务 |---->|  Redis  |  <- 热链缓存      |
|  | (负载均衡)|     | (无状态) |     | Cluster |                  |
|  +----------+     +----+----+     +---------+                  |
|                        |               |                       |
|                   Cache Miss      Cache Hit (95%+)             |
|                        v               v                       |
|                   +---------+     302 Redirect                 |
|                   |  MySQL  |     Location: https://原始URL     |
|                   | (主从)  |                                  |
|                   +---------+                                  |
|                                                                |
|  写入链路: 长链 -> ID 生成器 -> Base62 编码 -> MySQL + Redis     |
+----------------------------------------------------------------+

1.3 ID 生成与编码

import time, threading

class SnowflakeIdWorker:
    """雪花算法 ID 生成器"""
    EPOCH = 1710720000000  # 自定义纪元
    SEQ_BITS, WORKER_BITS = 12, 10
    MAX_SEQ = (1 << SEQ_BITS) - 1

    def __init__(self, worker_id: int):
        self.worker_id = worker_id
        self.sequence = 0
        self.last_ts = -1
        self._lock = threading.Lock()

    def next_id(self) -> int:
        with self._lock:
            ts = int(time.time() * 1000)
            if ts == self.last_ts:
                self.sequence = (self.sequence + 1) & self.MAX_SEQ
                if self.sequence == 0:
                    while ts <= self.last_ts:
                        ts = int(time.time() * 1000)
            else:
                self.sequence = 0
            self.last_ts = ts
            return ((ts - self.EPOCH) << (self.WORKER_BITS + self.SEQ_BITS)
                    | (self.worker_id << self.SEQ_BITS) | self.sequence)

CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

def to_base62(num: int) -> str:
    if num == 0: return CHARS[0]
    parts = []
    while num > 0:
        parts.append(CHARS[num % 62])
        num //= 62
    return "".join(reversed(parts))

# 7 位 Base62 可表示 62^7 ≈ 3.5 万亿个短链

1.4 缓存穿透防护

三种缓存异常及应对:

1. 缓存穿透 (查不存在的短链):
   -> 布隆过滤器前置拦截 + 空值缓存 (TTL=60s)

2. 缓存击穿 (热 Key 过期):
   -> 热 Key 永不过期 + 后台异步续期

3. 缓存雪崩 (大量 Key 同时过期):
   -> TTL 加随机偏移 (base_ttl + random(0, 300))

二、Feed 流系统设计

2.1 推模式 vs 拉模式

推模式 (Fan-out on Write):
  用户 A 发帖 -> 查 A 的粉丝列表 -> 异步写入每个粉丝的收件箱
  ✅ 读取极快 (直接查收件箱)
  ❌ 大 V 发帖写入百万份 (写放大)

拉模式 (Fan-out on Read):
  用户 B 刷 Feed -> 查 B 关注的人 -> 从各人发件箱拉最新帖子 -> 合并排序
  ✅ 写入简单 (只写一份)
  ❌ 读取时聚合多人数据 (读放大)

2.2 混合模式架构

+----------------------------------------------------------------+
|               Feed 流混合架构                                    |
+----------------------------------------------------------------+
|  普通用户 (粉丝 < 1000):                                        |
|    发帖 -> 推模式,写入所有粉丝收件箱                             |
|                                                                |
|  大 V 用户 (粉丝 > 10万):                                       |
|    发帖 -> 只写发件箱,粉丝刷 Feed 时拉取                        |
|                                                                |
|  粉丝刷 Feed:                                                   |
|  +----------+     +------------------+     +---------+         |
|  | 收件箱   | +   | 大 V 发件箱拉取  | --> | 合并排序 | --> 返回 |
|  | (已推送) |     | (实时拉取)       |     | (TopN)  |         |
|  +----------+     +------------------+     +---------+         |
+----------------------------------------------------------------+

2.3 Feed 流存储设计

// Feed 收件箱 —— Redis Sorted Set
// Score = 帖子时间戳,Member = 帖子 ID

func pushToInbox(rdb *redis.Client, followerID, postID, publishTime int64) error {
    key := fmt.Sprintf("inbox:%d", followerID)
    return rdb.ZAdd(ctx, key, &redis.Z{
        Score: float64(publishTime), Member: postID,
    }).Err()
}

func fetchFeed(rdb *redis.Client, userID, cursor, pageSize int64) ([]int64, error) {
    key := fmt.Sprintf("inbox:%d", userID)
    maxScore := "+inf"
    if cursor > 0 {
        maxScore = fmt.Sprintf("%d", cursor-1)
    }
    // go-redis v9+ 写法:ZRangeArgs 配合 Rev: true 替代已废弃的 ZRevRangeByScore
    results, err := rdb.ZRangeArgsWithScores(ctx, redis.ZRangeArgs{
        Key: key, Start: "-inf", Stop: maxScore,
        ByScore: true, Rev: true, Count: pageSize,
    }).Result()
    if err != nil { return nil, err }
    return parsePostIDs(results), nil // parsePostIDs: 从 []Z 中提取 Member 转为 []int64,实现略
}

2.4 线上问题:Feed 流空洞

现象: 用户反馈"某些关注人的帖子看不到"
根因: 推送服务偶尔超时,部分粉丝收件箱漏写

解决:
  1. 推送失败自动重试 (最多 3 次,指数退避)
  2. 补偿任务: 每 5 分钟扫描发件箱和收件箱差异
  3. 拉模式兜底: 读时也会拉取关注人的发件箱

三、即时通讯系统设计

3.1 长连接架构

+----------------------------------------------------------------+
|                  即时通讯系统架构                                 |
+----------------------------------------------------------------+
|  客户端 A <=== WebSocket ===> +--------+                       |
|  客户端 B <=== WebSocket ===> | 接入层 |  (Gateway)             |
|  客户端 C <=== WebSocket ===> +---+----+                       |
|                                   |                            |
|                           +-------v--------+                   |
|                           |   路由服务      |                   |
|                           | (用户->网关映射)|                   |
|                           +-------+--------+                   |
|                                   |                            |
|              +--------------------+--------------------+       |
|        +-----v------+    +-------v------+    +--------v---+   |
|        |  消息服务   |    |  群组服务     |    |  在线状态   |   |
|        | (单聊/群聊) |    | (成员管理)   |    |  服务      |   |
|        +-----+------+    +--------------+    +------------+   |
|              |                                                 |
|        +-----v------+                                          |
|        |  消息存储   |   MySQL + Redis + HBase                  |
|        +------------+                                          |
+----------------------------------------------------------------+

3.2 消息投递的可靠性

三步 ACK 机制:

  发送方 A          服务端            接收方 B
     |--- 发送消息 --->|                  |
     |                |--- 推送消息 ----->|
     |                |<--- 收到 ACK ----|  (B 确认收到)
     |<--- 已送达 ----|                  |  (通知 A)
     |                |<--- 已读 ACK ----|  (B 确认已读)
     |<--- 已读通知 ---|                  |

  异常处理:
    B 不在线 -> 存入离线队列 -> B 上线后拉取
    推送超时 -> 重试 3 次 -> 仍失败等 B 上线拉取
    防重复  -> msg_id 唯一,客户端去重

3.3 消息序号与排序

为什么不能用客户端时间戳排序?不同客户端时钟有偏差。正确做法是服务端递增序号

func nextSeqID(rdb *redis.Client, conversationID string) (int64, error) {
    return rdb.Incr(ctx, fmt.Sprintf("msg_seq:%s", conversationID)).Result()
}

type ChatMessage struct {
    MsgID          string `json:"msg_id"`          // 全局唯一 (防重复)
    ConversationID string `json:"conversation_id"` // 会话 ID
    SeqID          int64  `json:"seq_id"`          // 会话内递增序号
    SenderID       int64  `json:"sender_id"`
    Content        string `json:"content"`
    SendTime       int64  `json:"send_time"`       // 服务端时间戳
}

3.4 万人群消息优化

问题: 万人群发消息,写入每个成员收件箱 = 1万次写入

优化: 读扩散 + 群消息序号
  群消息只存一份在群时间线上:
  | group:10086 | seq=1:"大家好" | seq=2:"你好" | seq=3:"几点开会" |

  每个成员记录 last_read_seq:
  | 用户 A -> last_read_seq = 3 | 用户 B -> 2 | 用户 C -> 1 |

  用户 B 打开群 -> 拉取 seq > 2 的消息
  未读数 = 群最新 seq - last_read_seq = 3 - 2 = 1

四、抢红包系统设计

4.1 性能挑战

场景: 500 人群,10 个红包,500 人同时点"拆"
核心矛盾: 10 个名额 500 人抢,490 人要快速拒绝

  | 方案 | P99 延迟 | 防超发 | 复杂度 |
  |------|---------|--------|--------|
  | 数据库行锁 | 800ms | 能 | 低 |
  | Redis 原子操作 | 5ms | 能 | 中 |
  | 预拆分+Redis | 3ms | 能 | 中 |

4.2 预拆分方案

发红包: 扣余额(MySQL事务) -> 预拆分N个子红包 -> LPUSH到Redis List
抢红包: LPOP原子弹出 -> 弹到=抢到 -> nil=抢完 -> 异步写MySQL入账
import random

def split_red_packet(total_fen: int, count: int) -> list[int]:
    """二倍均值法拆分红包 (金额单位: 分)
    每次从剩余金额中随机取 [1, 剩余/剩余人数*2],期望值相等"""
    if total_fen < count:
        raise ValueError("总金额不够每人分 1 分")
    packets = []
    remaining = total_fen
    for i in range(count - 1):
        people_left = count - i
        upper = max(1, (remaining // people_left) * 2)
        ceiling = max(1, remaining - (people_left - 1))  # 防御性检查,确保 > 0
        amount = random.randint(1, min(upper, ceiling))
        packets.append(amount)
        remaining -= amount
    packets.append(remaining)
    return packets

# 88.88 元分 6 个红包
amounts = split_red_packet(8888, 6)
print([f"{a/100:.2f}元" for a in amounts])

4.3 Redis Lua 保证不超发

-- KEYS[1] = 红包子包列表, KEYS[2] = 已抢记录 set
-- ARGV[1] = 用户 ID

if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
    return '-1'  -- 已抢过
end
local packet = redis.call('LPOP', KEYS[1])
if not packet then
    return '0'   -- 已抢完
end
redis.call('SADD', KEYS[2], ARGV[1])
return packet    -- 返回金额(分)

4.4 异步入账与对账

同步入账: 抢红包 -> Redis -> MySQL 钱包 -> 返回 (35ms) ❌ 太慢
异步入账: 抢红包 -> Redis -> 返回 (5ms) ✅
          后台消费 MQ -> 写 MySQL 钱包

对账 (每 5 分钟):
  1. Redis 已拆 N 个 vs MySQL 已入账 K 条
  2. N != K -> 补偿重发 MQ
  3. 超时 24 小时未领完 -> 退回发送者

五、四个系统的共性模式

5.1 高并发优化四板斧

+----------------------------------------------------------------+
|              高并发系统性能优化四板斧                              |
+----------------------------------------------------------------+
|  1. 缓存前置: 90%+ 读请求挡在缓存层                              |
|     短链/Feed/IM/红包 -> 全部依赖 Redis 前置                     |
|                                                                |
|  2. 异步削峰: 写操作从主链路摘出去                                |
|     短链访问日志/Feed推送/IM持久化/红包入账 -> 全走 MQ            |
|                                                                |
|  3. 原子操作: Redis Lua 替代数据库锁                              |
|     红包 LPOP 防超发 / IM 序号 INCR 防乱序                      |
|                                                                |
|  4. 预计算: 提前算好,运行时直接取                                |
|     红包预拆分 / Feed推模式预写 / 短链预生成ID池                  |
+----------------------------------------------------------------+

六、容量规划

6.1 短链服务容量估算

假设: 日活 500 万,日新增短链 50 万,日访问 5000 万 (读写比 100:1)

存储: 1.8 亿条/年 × 200B ≈ 36 GB (MySQL 单机可承载)
QPS:  平均读 580, 峰值 ≈ 2900 (Redis 单节点 10万+ QPS,绰绰有余)
带宽: 峰值 2900 × 500B ≈ 12 Mbps

6.2 压测三原则

1. 影子环境压测,不在生产环境
2. 阶梯式加压: 100 -> 500 -> 1000 -> 2000,每阶段观察 5 分钟
3. 关注长尾: P99 比 P50 更能反映真实体验
   P99 > 200ms 就要告警,即使 P50 只有 10ms

七、面试答题框架

5 步法 (每步约 5-8 分钟):

Step 1: 需求澄清  "日活多少?读写比?"     -> 不盲目设计
Step 2: 容量估算  "峰值 QPS 约 3000..."    -> 有工程量感
Step 3: 高层架构   画出核心组件和数据流       -> 有全局视角
Step 4: 深入设计   挑 1-2 个核心难点深入     -> 有深度思考
Step 5: 扩展讨论  "QPS 涨 10 倍我会..."    -> 能应对变化
追问回答要点
QPS 涨 10 倍?缓存扩容 + 服务水平扩展
数据不丢?WAL + 副本 + 异步持久化
跨机房部署?就近写入 + 异步同步 + 冲突解决

思考题

  1. 如果短链服务需要支持”统计点击量、地域分布、设备分布”,你会怎么设计数据采集和存储方案?

  2. 微信红包春节峰值达每秒 76 万个红包,如果让你设计,你会如何做容量规划和降级策略?


结尾自测

  1. 短链服务为什么推荐 Redis + MySQL 双层存储,而不是只用 Redis?

    • :Redis 存储成本高、持久化不够可靠。MySQL 负责数据不丢,Redis 拦截 99% 的读请求保证性能。
  2. Feed 流的推模式和拉模式各自的瓶颈是什么?

    • :推模式瓶颈是大 V 发帖的写放大;拉模式瓶颈是刷 Feed 时的读放大。生产环境采用混合模式:普通用户推、大 V 拉。
  3. IM 系统为什么不能用客户端时间戳做消息排序?

    • :不同客户端时钟有偏差。正确做法是服务端为每个会话维护递增序号,用 Redis INCR 保证原子递增。
  4. 抢红包为什么用”预拆分 + Redis LPOP”而不是”数据库加锁”?

    • :数据库行锁在高并发下锁等待严重,P99 飙升到秒级。Redis LPOP 微秒级完成,天然防超发,P99 可控制在 5ms 以内。
  5. 高并发系统性能优化的”四板斧”是什么?

    • :缓存前置、异步削峰、原子操作、预计算。

下一章预告:秒杀系统如何做到”百万人抢一件商品”而不崩?推荐系统的”猜你喜欢”背后是怎样的架构?下一章我们继续用四个经典案例深入实战。

购买课程解锁全部内容

面试晋升必学:11 章掌握系统设计

¥29.90