Feed流系统设计(三):数据模型与存储设计,从表结构到Redis收件箱

写在前面

前面两篇聊了Feed流的概念和架构选型,这一篇开始落地到具体的数据模型设计。

虽然前文讨论了很多问题和策略,但落到存储层面,其实也就是几张表加上Redis缓存的事情。表设计得好不好,直接影响后面业务逻辑的复杂度和系统的性能。所以这一篇我们会把每张表的设计思路讲清楚------为什么这么设计,每个字段的作用是什么,索引怎么建。

1. 消息表

消息表是整个系统最核心的表,存储所有Feed消息的内容。每条用户发布的动态都是这里的一条记录。

sql 复制代码
CREATE TABLE message (
    msg_id      BIGINT PRIMARY KEY COMMENT '消息ID,建议使用雪花算法生成',
    sender_id   BIGINT NOT NULL COMMENT '发布者ID',
    msg_title   VARCHAR(255) COMMENT '消息标题',
    msg_content TEXT COMMENT '消息内容',
    msg_type    TINYINT COMMENT '消息类型:1-文字,2-图片,3-视频',
    msg_status  TINYINT DEFAULT 1 COMMENT '状态:1-正常,0-已删除',
    msg_channel VARCHAR(50) COMMENT '消息所属渠道,用于多系统接入',
    extra_info  JSON COMMENT '扩展信息,存储JSON格式',
    ctime       TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    utime       TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    INDEX idx_sender_time (sender_id, ctime)
);

有几个设计点值得说一下。

msg_id用雪花算法。 不要用自增ID。自增ID在分库分表场景下会有冲突问题,而且自增ID本身会暴露业务量信息。雪花算法生成的ID既是递增的(对索引友好),又能保证全局唯一。

msg_status字段很重要。 上一篇提到过,我们采用软删除策略------删除消息的时候不是真的从数据库里删掉,而是把状态标记为0。这个字段就是干这个用的。为什么要软删除?因为收件箱里存的是消息ID的引用,如果真把消息删了,粉丝收件箱里的引用就变成了悬空引用,处理起来非常麻烦。

extra_info用JSON类型。 这个字段是留给未来的扩展空间用的。比如消息可能需要携带一些额外的元数据------地理位置、@了谁、关联的活动ID等等。这些信息不是每条消息都有,而且格式可能各不相同,用JSON存储最灵活。

索引idx_sender_time。 这是一个联合索引,按发布者ID和创建时间排序。这个索引的使用场景是:查询某个用户发布的消息列表(个人页Timeline),以及读扩散场景下从大V的发件箱拉取消息。这两个场景都需要按sender_id过滤,然后按时间排序。

2. 关注关系表

关注关系表存储用户之间的关注/粉丝关系。这张表的设计有几个容易踩坑的地方。

sql 复制代码
CREATE TABLE follow_relation (
    main_uid      BIGINT COMMENT '被关注者ID(博主)',
    follower_uid  BIGINT COMMENT '关注者ID(粉丝)',
    status        TINYINT DEFAULT 1 COMMENT '状态:1-正常关注,2-特别关注,0-已拉黑',
    hot_follower  TINYINT DEFAULT 0 COMMENT '是否活跃粉丝:1-是,0-否',
    extra_info    JSON COMMENT '扩展信息',
    ctime         TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '关注时间',
    utime         TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    PRIMARY KEY (main_uid, follower_uid),
    INDEX idx_follower (follower_uid)
);

联合主键的设计。 用(main_uid, follower_uid)作为联合主键,天然保证了同一对关注关系不会重复插入。同时这个主键也能高效支持"查询某用户的所有粉丝"这个操作------因为主键索引本身就是按main_uid排序的。

status字段不只是关注/取关。 很多人设计关注关系表的时候只存"关注"和"取关"两种状态,但实际上status字段可以承载更多语义。比如"特别关注"------微博里你可以把某些人设为特别关注,他们的消息会优先展示。"拉黑"------虽然拉黑和取关的效果类似(都看不到对方消息),但在业务层面是两个不同的操作,分开存储更合理。

hot_follower字段是大V场景的关键。 上一篇提到过,对大V用户采用读写结合策略时,需要区分热粉丝和冷粉丝。热粉丝走写扩散,冷粉丝走读扩散。

这里有个设计选择:hot_follower是存在关注关系表里,还是单独维护一张热粉丝表?

如果单独维护一张热粉丝表,那查询大V的热粉丝列表很简单,但要判断某个用户是不是某个大V的热粉丝,就需要做一次join或者两次查询。而把hot_follower直接放在关注关系表里,查询的时候只需要一个条件过滤就行了,不需要额外的表。代价是每次更新粉丝的冷热状态时,要更新这张表的一个字段。

考虑到粉丝冷热状态的变更频率不高(通常是根据登录频率定期批量更新的),放在关注关系表里是更划算的选择。

idx_follower索引。 这个索引的作用是支持"查询某用户关注了哪些人"这个操作。注意这个查询方向和主键是反的------主键是"被关注者 -> 粉丝列表",而这个索引是"粉丝 -> 关注列表"。两个方向的查询在Feed流系统中都很常见,所以都需要索引支持。

3. 收件箱设计

收件箱是Feed流系统中最有特色的设计。它不存储在数据库里,而是用Redis的Sorted Set(ZSet)来实现。

为什么用ZSet?因为ZSet有两个特性完美匹配收件箱的需求:

  • 每个元素有一个score,可以用来排序------我们用消息的发布时间戳作为score
  • 支持按score范围查询------翻页的时候就是按时间范围取数据

收件箱的Key-Value设计如下:

复制代码
Key:   user:{userId}:inbox:{channelId}
Value: {senderId}:{messageId}
Score: 消息发布时间戳(毫秒)

举个例子,用户123的收件箱可能长这样:

复制代码
Key: user:123:inbox:default
Members:
  456:1001  -> score: 1716000000000
  789:1002  -> score: 1716000060000
  456:1003  -> score: 1716000120000
  ...

Value存的是"发送者ID:消息ID"而不是只存消息ID,这是有原因的。后面讲取关逻辑的时候会用到------用户取关了某个人之后,需要从收件箱中过滤掉这个人的所有消息。如果Value里没有senderId,就不知道这条消息是谁发的,过滤就没法做。

channelId是消息渠道标识。如果系统只接入了一个业务,那channelId可以固定为"default"。但如果将来要接入多个业务系统(比如社区动态、活动通知、系统消息等),不同的渠道用不同的收件箱,互不干扰。

用图来表示一下Redis ZSet的结构:

收件箱服务的基本实现如下:

java 复制代码
public class InboxServiceImpl implements InboxService {

    private final RedisTemplate<String, String> redisTemplate;

    @Override
    public void addMessage(long userId, long messageId, long senderId, long timestamp) {
        String key = String.format("user:%d:inbox:default", userId);
        String value = String.format("%d:%d", senderId, messageId);
        redisTemplate.opsForZSet().add(key, value, timestamp);
    }

    @Override
    public List<MessageRef> getMessages(long userId, String lastMessageId, int pageSize) {
        String key = String.format("user:%d:inbox:default", userId);

        double maxScore = Double.POSITIVE_INFINITY;
        if (lastMessageId != null) {
            Double lastScore = redisTemplate.opsForZSet().score(key, lastMessageId);
            if (lastScore != null) {
                maxScore = lastScore - 1;
            }
        }

        Set<ZSetOperations.TypedTuple<String>> results = redisTemplate.opsForZSet()
            .reverseRangeByScoreWithScores(key, 0, maxScore, 0, pageSize);

        List<MessageRef> messageRefs = new ArrayList<>();
        if (results != null) {
            for (ZSetOperations.TypedTuple<String> tuple : results) {
                String value = tuple.getValue();
                if (value != null) {
                    String[] parts = value.split(":");
                    MessageRef ref = new MessageRef();
                    ref.setSenderId(Long.parseLong(parts[0]));
                    ref.setMessageId(Long.parseLong(parts[1]));
                    ref.setTimestamp(tuple.getScore());
                    messageRefs.add(ref);
                }
            }
        }
        return messageRefs;
    }
}

这里有个细节需要注意:收件箱里存的是消息ID的引用,不是完整的消息内容。这意味着用户读取Feed流的时候,拿到的是一堆消息ID,还需要回查数据库才能拿到完整的消息内容。

为什么要多这一步回查?上一篇已经解释过了------这样消息的修改和删除就不需要扩散了。回查的时候拿到的是最新版本的数据,修改自然就同步了;删除的消息通过msg_status字段过滤掉就行。

虽然多了一次数据库查询,但好处是巨大的:修改和删除操作从O(粉丝数)的扩散成本降到了零。

发布配置表(可选)

这张表不是Feed流系统必须的,但如果你的系统将来要扩展成消息推送平台,那这张表就很有用了。它控制消息的发布行为------什么时候发、发到哪个渠道、触发条件是什么。

sql 复制代码
CREATE TABLE publish_config (
    send_id           BIGINT PRIMARY KEY COMMENT '发布配置ID',
    send_type         TINYINT COMMENT '发布类型:1-立即发布,2-定时发布,3-周期发布',
    send_crontab      VARCHAR(100) COMMENT '定时/周期发布的规则,存储crontab表达式',
    send_msg_channel  VARCHAR(50) COMMENT '推送渠道:站内信、邮件、短信等',
    channel           VARCHAR(50) COMMENT '配置所属渠道',
    send_rule         VARCHAR(255) COMMENT '触发规则:如审核通过时触发、活动开始时触发',
    extra_info        JSON COMMENT '扩展信息',
    cuid              BIGINT COMMENT '创建者',
    ctime             TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    utime             TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
);

举个例子,你可以配置一条规则:当某篇内容审核通过的时候,自动给所有关注该作者的用户推送一条站内信。这种场景下,publish_config表就派上用场了。

如果你的系统暂时不需要这么复杂的发布控制,可以先不加这张表,等有需求的时候再扩展也不迟。

面向对象的数据抽象

除了数据库层面的设计,在代码层面也需要对Feed流的核心概念进行抽象。一个好的抽象能让业务代码更清晰,也更容易扩展。

核心的抽象有四个:

消息(Message)。 最基础的数据结构,包含消息标题、内容、附件、类型、渠道等属性。它还需要提供一个"丰富消息内容"的方法------因为收件箱里只存了消息ID,读取的时候需要把消息内容填充完整。

消息发布处理器(MessagePublisher)。 负责消息发布的完整流程。它持有发送用户、发布配置、消息ID等属性,提供获取消息ID、获取接收者、同步消息、保存消息等方法。

用户(User / FeedFetcher)。 在Feed流系统中,用户既是消息的发布者,也是消息的拉取者。作为拉取者,用户需要提供获取关注列表、获取粉丝列表、查询发件箱、查询收件箱等方法。收件箱的查询还包含了过滤逻辑------黑白名单过滤、软删除过滤等。

发布配置(PublishConfig)。 控制消息的发布行为,包括发布渠道和发布方式。

这些抽象不是一成不变的,随着业务的发展可能需要调整。但一开始就把核心概念理清楚,后面扩展的时候会轻松很多。

6. 存储容量规划

最后聊一个实际的问题:存储容量怎么规划。

数据库方面。 消息表是数据量增长最快的表。假设系统有100万日活用户,每人每天平均发布2条消息,那每天新增200万条记录。一年下来就是7亿多条。按每条消息平均1KB计算,大约需要700GB的存储空间。加上索引开销,实际占用可能在1TB左右。这个量级在MySQL里已经需要考虑分库分表了。

Redis方面。 收件箱的容量取决于用户量和关注关系。假设平均每个用户关注50个人,每个被关注者每天发2条消息,那每个用户的收件箱每天新增100条记录。按每条记录50字节计算(senderId:messageId + score),每天新增5KB。100万用户就是每天5GB。Redis的内存成本比较高,所以收件箱数据需要有清理策略------比如只保留最近30天的数据,更早的数据从数据库查询。

这些数字只是估算,实际业务中需要根据用户行为数据来调整。但提前做好容量规划,可以避免上线后手忙脚乱。

7. 小结

这一篇聊了Feed流系统的数据模型和存储设计。核心要点:

  • 消息表用雪花算法生成ID,软删除而不是真删除,extra_info预留扩展空间
  • 关注关系表用联合主键保证唯一性,hot_follower字段支持大V的冷热粉丝分离
  • 收件箱用Redis ZSet实现,Value存"发送者ID:消息ID"而不仅是消息ID,方便后续的取关过滤
  • 收件箱只存消息ID引用,完整内容通过回查获取,这样修改和删除就不需要扩散

这些设计决策不是孤立的,它们相互配合,共同支撑起上一篇文章中讨论的读写结合分发策略。

下一篇会进入最核心的部分------发布和读取Feed流的业务流程实现,包括游标分页、软删除与懒删除的具体代码实现。

相关推荐
JiaHao汤4 小时前
分布式事务方案全景:从理论到 Seata 落地
java·分布式·spring·spring cloud
我是真菜4 小时前
彻底理解js中的深浅拷贝
前端·javascript
色空大师5 小时前
【debug调试详解-idea】
java·ide·intellij-idea·调试·远程调试
程序猿阿越5 小时前
AutoMQ源码(一)读、写、Compaction
java·后端·源码
ywl4708120875 小时前
jwt生产token,简单版helloworld
java·数据库·spring
未若君雅裁5 小时前
生产问题排查与性能瓶颈定位:日志、监控、链路追踪、压测与Arthas
java·web安全
器灵科技5 小时前
AI视频工具实测:Seedance/可灵/HappyHorse谁最能打?
java·运维·数据库·人工智能·github
南部余额5 小时前
RabbitMQ 进阶:延迟队列完全指南
java·分布式·spring·rabbitmq
phltxy5 小时前
Spring AI Agents 智能体模式实战
java·人工智能·spring