预计阅读 31 分钟
08 | 经典系统设计(上)—— 短链服务、Feed 流、即时通讯、抢红包
真正考验系统设计能力的,不是画出一幅漂亮的架构图,而是当线上告警响起时,你能在 5 分钟内定位到瓶颈在哪。
开篇自测
- 短链服务当 QPS 从 100 涨到 10 万时,系统会在哪些环节出现瓶颈?你能画出完整的优化路径吗?
- Feed 流的”推模式”和”拉模式”各自的性能瓶颈是什么?生产系统通常怎么做?
- 抢红包场景下,如何在保证不超发的前提下让 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 + 副本 + 异步持久化 |
| 跨机房部署? | 就近写入 + 异步同步 + 冲突解决 |
思考题
-
如果短链服务需要支持”统计点击量、地域分布、设备分布”,你会怎么设计数据采集和存储方案?
-
微信红包春节峰值达每秒 76 万个红包,如果让你设计,你会如何做容量规划和降级策略?
结尾自测
-
短链服务为什么推荐 Redis + MySQL 双层存储,而不是只用 Redis?
- 答:Redis 存储成本高、持久化不够可靠。MySQL 负责数据不丢,Redis 拦截 99% 的读请求保证性能。
-
Feed 流的推模式和拉模式各自的瓶颈是什么?
- 答:推模式瓶颈是大 V 发帖的写放大;拉模式瓶颈是刷 Feed 时的读放大。生产环境采用混合模式:普通用户推、大 V 拉。
-
IM 系统为什么不能用客户端时间戳做消息排序?
- 答:不同客户端时钟有偏差。正确做法是服务端为每个会话维护递增序号,用 Redis INCR 保证原子递增。
-
抢红包为什么用”预拆分 + Redis LPOP”而不是”数据库加锁”?
- 答:数据库行锁在高并发下锁等待严重,P99 飙升到秒级。Redis LPOP 微秒级完成,天然防超发,P99 可控制在 5ms 以内。
-
高并发系统性能优化的”四板斧”是什么?
- 答:缓存前置、异步削峰、原子操作、预计算。
下一章预告:秒杀系统如何做到”百万人抢一件商品”而不崩?推荐系统的”猜你喜欢”背后是怎样的架构?下一章我们继续用四个经典案例深入实战。
购买课程解锁全部内容
面试晋升必学:11 章掌握系统设计
¥29.90