#Feed 流项目复盘:Redis 时间线缓存与 Outbox 消息可靠性设计

本文基于一个短视频 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 问题本质

发布视频要做两件事:

  1. 往 MySQL 写一条 videos 记录;
  2. 往 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 秒后才出现在粉丝时间线里,完全可接受。

相关推荐
小嘉丶学长5 小时前
Day14 · 字典与集合
java·开发语言·网络·python·学习·1024程序员节
北冥you鱼9 小时前
Go 语言 CRUD 实战:从接口设计到真实业务场景
开发语言·后端·golang
大江东去浪淘尽千古风流人物11 小时前
【FLORA】机器人视频标注引擎解析:三模型如何产出动作指令与四类奖励信号
matlab·机器人·跨模态·遥感·图像匹配·1024程序员节·特征描述符
【 STM32开发 】12 小时前
【CubeMX+STM32】SD卡 文件系统读写 FatFs+SDIO+DMA (F103版本)
stm32·单片机·嵌入式硬件·1024程序员节
利威尔xu13 小时前
Go IM 源码实战(11):token 只是个 HASH,多端互踢的顺序错了就踢错人
后端·微服务·面试·架构·golang
microrain13 小时前
SagooIoT 把协议解析和消息通知都放进了独立进程
物联网·golang·插件·sagooiot
北冥you鱼13 小时前
Go 语言 GORM 使用 Preload:从关联查询到嵌套预加载的完整实践
开发语言·jvm·golang
microrain13 小时前
把规则链存成文件之后,上线一条规则只需改一个字段
物联网·golang·规则引擎·sagooiot
牢姐与蒯14 小时前
WSL2 迁移教程:把Ubuntu+WSL2从C盘挪到别的盘
linux·运维·服务器·网络·ubuntu·1024程序员节