本文基于一个短视频 Feed 流项目,重点复盘两个核心设计:Redis 时间线缓存 和 Outbox 事务发件箱。前者解决"用户刷动态"的读性能问题,后者解决"视频发布到时间线同步"的跨系统一致性问题。
一、项目背景
项目是一个短视频 Feed 流系统,核心链路:
用户发布视频 → 写 MySQL videos 表 → 异步通知 → 写 Redis 粉丝时间线 ZSET
读链路很简单:用户刷首页时,直接从 Redis ZSET 里按分数(发布时间)倒序取出视频 ID 列表,再批量查视频详情。
写链路才是难点:MySQL 写视频 和 Redis 写时间线 是两个独立系统,无法放进同一个数据库事务。如果处理不当,会出现:
- 视频写成功,但时间线没更新 → 粉丝刷不到
- 时间线更新了,但视频写入回滚 → 粉丝刷到不存在的视频
下面分别讲缓存设计和消息可靠性设计。
二、Redis 时间线缓存设计
2.1 数据结构选型
时间线用 Redis ZSET 存储:
Key: timeline:{user_id}
Member: video_id
Score: 发布时间的 Unix 时间戳
选 ZSET 的原因:
- 天然按 Score 排序,倒序取就是"最新优先";
ZADD幂等,同一条视频重复写入不会重复;ZREVRANGE支持分页拉取,适合 Feed 流下拉刷新。
2.2 推模式 vs 拉模式
本项目采用推模式(Push):视频发布时,直接写入所有粉丝的时间线。
| 模式 | 适用场景 | 优缺点 |
|---|---|---|
| 推模式 | 粉丝量不大 | 写放大,但读极快 |
| 拉模式 | 大 V 粉丝千万级 | 读时需要聚合,延迟高 |
| 推拉结合 | 混合场景 | 大 V 用拉,普通用户用推 |
当前项目粉丝量级可控,推模式读性能最好,用户刷首页就是 O(1) 的 ZSET 范围查询。
2.3 缓存一致性
视频删除/修改时,需要反向清理所有粉丝时间线里的对应 video_id。这里用 ZREM 删除,同样幂等。
更激进的方案是给时间线加 TTL,定期过期重建,但本项目采用实时同步 + 删除时清理,保证一致性。
三、Outbox 事务发件箱设计
3.1 问题本质
发布视频要做两件事:
- 往 MySQL 写一条
videos记录; - 往 RabbitMQ 发一条"有新视频"的消息,消费端据此把视频 ID 写进 Redis 时间线。
这两件事跨了两个系统,没法放进同一个事务。四种常见写法的对比:
| 写法 | 问题 |
|---|---|
| 先写库,再发消息 | 库成功、消息失败 → 时间线永远没有这个视频 |
| 先发消息,再写库 | 消息发了、库回滚 → 时间线指向不存在的视频 |
| 分布式事务(XA/TCC) | 能解决,但太重,为一条时间线不值得 |
| 事务性发件箱(Outbox) | 本项目采用 |
3.2 核心思路
把"要发的消息"当成一行数据,和业务数据写在同一个数据库事务里:
sql
BEGIN;
INSERT INTO videos (title, author_id, url) VALUES (...);
INSERT INTO outbox_msgs (event_type, payload, status)
VALUES ('video.created', '{"video_id": 101}', 'pending');
COMMIT;
事务提交成功 → 视频有了,待发消息也有了,不会丢。
事务回滚 → 视频没写入,待发消息也没写入,不会多发。
这样就把"跨系统原子性"降维成了"单库事务 + 后台重投"。代价是投递有延迟(本项目约 1 秒),对 Feed 流场景完全可接受。
3.3 状态机与 processing 中间态
outbox_msgs 表有三个状态:
pending → processing → sent
↑
超时回退
为什么需要 processing 中间态?如果只有 pending / sent 两态,多实例 poller 同时扫描时会重复发;更重要的是,如果实例在发送过程中宕机,没有中间态就无法感知"这条消息正在被处理",也就无法回收重投。
有了 processing 状态,可以加一个超时回收任务:
sql
UPDATE outbox_msgs
SET status = 'pending', claimed_at = NULL
WHERE status = 'processing'
AND claimed_at < NOW() - INTERVAL 2 MINUTE;
卡死 2 分钟的消息会被回退到 pending,由其他实例重新接管。
3.4 多实例抢占:CAS vs SKIP LOCKED
多实例 poller 同时扫描 pending 消息时,需要避免重复投递。常见两种方案:
方案一:SELECT ... FOR UPDATE SKIP LOCKED
sql
SELECT id, payload FROM outbox_msgs
WHERE status = 'pending'
ORDER BY created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED;
被其他实例锁住的行会被跳过,不阻塞。但问题是:发送 MQ 的网络 I/O 必须包在事务里,否则 SELECT 和后续 UPDATE 之间可能被别的实例篡改。这意味着行锁会持有几十毫秒,锁竞争明显。
方案二:CAS 原子 UPDATE(本项目采用)
sql
UPDATE outbox_msgs
SET status = 'processing', claimed_at = NOW()
WHERE id = ? AND status = 'pending';
这是一条原子 UPDATE,只有 status 仍是 pending 的那一行会被更新。抢到的人 RowsAffected = 1,其余实例拿到 0,直接跳过,天然去重。
| 维度 | CAS(原子 UPDATE) | SKIP LOCKED |
|---|---|---|
| 锁持有时间 | 一条 UPDATE,毫秒级 | SELECT + UPDATE + 发送 MQ,可能几十毫秒 |
| 事务边界 | 一条语句,天然短事务 | 需要 BEGIN/COMMIT 包多步操作 |
| 实现复杂度 | 简单 | 需要管理事务生命周期 |
CAS 的代价是一条 UPDATE 加一次行锁,但因为按主键定位,锁持有时间极短,发送 MQ 完全在事务外。我们每次只 claim 一条消息,所以 CAS 足够,也更简单。
补充说明:这里说的 CAS 不是"完全无锁"。InnoDB 执行 UPDATE 时仍会对命中的主键行加短暂排他锁,只是不提前显式加锁,锁持有时间极短,不会把发送 MQ 的网络 I/O 包在锁里。
3.5 Gin + GORM 实现
go
func (s *Service) PublishVideo(ctx context.Context, dto VideoDTO) error {
return s.db.Transaction(func(tx *gorm.DB) error {
// 1. 写业务表
video := Video{
Title: dto.Title,
AuthorID: dto.AuthorID,
URL: dto.URL,
}
if err := tx.Create(&video).Error; err != nil {
return err
}
// 2. 写出箱消息
msg := OutboxMsg{
EventType: "video.created",
Payload: fmt.Sprintf(`{"video_id": %d}`, video.ID),
Status: OutboxStatusPending,
}
return tx.Create(&msg).Error
})
}
Poller 抢占逻辑:
go
func (r *OutboxRepository) Claim(ctx context.Context, id uint, now time.Time) (bool, error) {
tx := r.db.WithContext(ctx).Model(&OutboxMsg{}).
Where("id = ? AND status = ?", id, OutboxStatusPending).
Updates(map[string]interface{}{
"status": OutboxStatusProcessing,
"claimed_at": now,
})
if tx.Error != nil {
return false, tx.Error
}
return tx.RowsAffected == 1, nil
}
四、总结
| 设计点 | 方案 | 取舍 |
|---|---|---|
| 时间线存储 | Redis ZSET 推模式 | 写放大,但读极快 |
| 跨系统一致性 | Outbox 事务发件箱 | 约 1 秒投递延迟 |
| 多实例抢占 | CAS 原子 UPDATE | 锁持有时间极短,实现简单 |
| 容错 | processing 中间态 + 超时回退 | 实例宕机后可自动恢复 |
Outbox 的核心价值是用单库事务的确定性,换取跨系统消息的可靠性。代价是约 1 秒的投递延迟,但对 Feed 流场景来说,用户发完视频 1 秒后才出现在粉丝时间线里,完全可接受。