简介:这是一份基于Java实现的社交场景抽象库,面向需要快速搭建朋友圈、微博、信息流与IM通讯功能的后端开发者。它围绕Timeline时间线模式,封装了数据流间的分发、消息推送、Feed流聚合及基础IM通信能力,可显著降低复杂社交系统的开发门槛。压缩包共71个文件,以51个Java源码为主,配合XML配置、Markdown说明、YAML及数据库文件,整体仅1.59MB,便于下载后直接对照阅读。工程按core、store、example、starter等模块组织,包含内存与Redis两种存储实现,并提供示例项目,适合有Java基础且希望理解社交系统架构的开发者扩展复用。已有84人学习下载。通过源码与说明,读者可以理清消息分发链路的抽象设计、时间线数据存储与离线消息处理思路,并基于示例改造出适合自身业务的后端骨架,是一份实用的工程参考。
1. Timeline 抽象库到底解决什么问题:把朋友圈、IM、推送装进同一种时间线
朋友圈刷到的是好友动态,微博刷到的是关注的人,IM 收到的是别人发来的消息,消息推送是系统塞进来的通知。这四件事业务形态完全不一样,但落到数据层,本质是同一个动作:往某个用户的时间线里追加一条带时间戳的记录,再按时间倒序消费。我今年接手的项目里,就有同事把这四套逻辑各自写了一遍,连分页都做了三种。标题里这个基于 Java 的抽象库,就是用一套 Timeline 模型统一 feed 流、消息推送、IM 通讯,核心能力是数据流之间的分发:一条数据产生后,由它决定进哪些接收方的时间线、按什么顺序落地、怎么推给在线客户端。适合准备做社区或社交功能的 Java 团队,后端人手有限、不想再从零写推拉分页和离线消息那套轮子的时候,这个方向值得认真试一试。
2. Timeline 数据模型:怎么让同一份存储同时扛住 feed 流、推送和 IM 会话
2.1 从“按人查动态”到“按人读时间线”:建模思路的切换
先拿最常见的朋友圈场景对比。业务初期,一张动态表加一张关注关系表,查 feed 时把两步 JOIN 出来再排序,逻辑很直观:
-- 普通做法:查询时才临时组装时间线 SELECT m.* FROM moments m JOIN follows f ON m.user_id = f.followee_id WHERE f.fan_id = ? ORDER BY m.created_at DESC LIMIT 20; -- Timeline 写扩散做法:写入时已经落到每个人的列表 SELECT * FROM timeline WHERE owner_id = ? AND ts < ? ORDER BY ts DESC LIMIT 20;第一段 SQL 的问题是:关注人数涨上去以后,JOIN 和排序的开销线性增加,尤其当用户关注了上千人、其中还有高频发动态的博主时,MySQL 的执行计划经常在索引选择上翻车,这个查询很难靠加索引根治。Timeline 模型反过来:写入时就把数据复制到所有接收者的时间线里,读取时只查一个 owner_id。这个“写入时组装、读取时只取”的思路,就是 feed 流常说的写扩散/推模式。它牺牲了写入的放大系数,换取了读取路径的绝对简单——用户的每一次下拉刷新,都是一次单表范围查询。
那 IM 消息为什么也能塞进这个模型?IM 的会话列表本质上也是按人维护的:A 给 B 发消息,B 的时间线里出现一条新记录;A 在群里说一句话,群里每个人的时间线各多一条。和朋友圈唯一的区别是,朋友圈的 payload 是一条富文本动态,IM 的 payload 是一段序列化的聊天消息,未读状态要单独标一下。对存储层来说,它们都只是“一条带归属者和时间戳的字节数组”。所以抽象库把 0 定义成 feed 流、1 定义成 IM 消息、2 定义成推送通知,类型不同只是 payload 里存的协议不同,读写路径完全共用。我接这种库的前提就是先确认它允许我用一个 int 区分业务类型,而不是为每个业务建一张表。
2.2 Java 侧的对象模型:Entry、Store 与游标分页
接这种库,我第一件事是看它暴露的 Java 核心接口够不够通用。多数 Timeline 类抽象包会收敛成三个概念:一条数据(Entry)、一块存储(Store)、一个游标(Cursor)。我用一个最小接口还原它的骨架:
// 核心接口:一条可以被分发的数据 public interface TimelineEntry { String getOwnerId(); // 这条数据落在谁的 Timeline 上 String getActorId(); // 谁产生了这条数据 long getTs(); // 全局排序时间戳 int getType(); // 0=feed 流 1=IM 2=推送通知 byte[] getPayload(); // 序列化后的具体内容 } // 核心存储:追加一条 + 按游标范围查询 public interface TimelineStore { boolean append(TimelineEntry entry); List<TimelineEntry> range(String ownerId, long startTs, long endTs, int limit, long cursor); }参数说明:getOwnerId()是时间线的归属者,getActorId()是产生者,两者可以不同;getTs()单机时可以用系统时间,多机部署必须换成 HybridLogicalClock 或序号发号器,这个坑在第 5 章展开;getType()是区分 feed、IM、推送的关键字段,抽象库本身不解析 payload,它只做分发和排序,具体业务自己反序列化。range()里的cursor是分页游标,一般取上一页最后一条的ts,配合endTs和limit拉下一页,不要用 MySQL 的 offset,原因同样在第 5 章。
我接过一个简化版实现,它把每条 entry 序列化后写入 RocksDB,key 是 ownerId 的哈希前缀 + 8 字节反转 ts + 4 字节随机后缀。反转 ts 是为了让 RocksDB 默认的字典序直接变成时间倒序,这样 range 查询就是一次顺序扫表,不用再排序。这个细节平时常被当成 java 面试八股文里的考点,但真正的坑在线上:如果拿到的时间线库没对 key 做这种前缀设计,range 查询会退化成全表扫描,数据量到千万级就扛不住了,表现为接口响应从 5ms 一路涨到 800ms。
还有一点值得提前确认,就是 append 失败时库怎么表现。有的实现返回 false 就完事,有的会内部重试三次再抛异常。我把这个行为摸清楚之后,才发现自己需要在分发器外层加一个重试队列,否则任何一次存储抖动都会直接丢数据。如果你项目是 Spring Boot + MyBatis 栈,接入时记得把 TimelineEngine 声明成一个单例 Bean,放在配置类里初始化一次,不要在每个 Service 里 new;这个库内部维护线程池和连接池,重复初始化等于每次请求都重建线程,线上一次小高峰就能触发 Tomcat 线程阻塞。这个单例化的教训来源于我同事的一次线上告警:他用 @Autowired 注入了一个每次 new 的 factory,结果连接数打满,最后靠线程 dump 才定位到。
3. Java 接入第一步:初始化参数、写入和读取一条 feed 流
3.1 组装引擎:四个必调参数和一个默认值陷阱
这类抽象库一般不会让你直接 new 一个实现类,而是像 Spring Boot 的 starter 一样给你一个 Builder。我接入时的习惯是先把存储、分发器、时钟、批量阈值四个参数显式配好,不用库里的默认值——默认值往往是给单机演示用的,线程池小、不落盘、时钟直接用本机时间,放大流量就露馅:
TimelineEngine engine = TimelineEngine.builder() // 1. 存储介质:本地验证用 RocksDB 落盘 .store(new RocksDBTimelineStore("/data/timeline")) // 2. 分发器:异步扇出,线程数按核心数 * 2 起 .dispatcher(new FanoutDispatcher( Executors.newFixedThreadPool( Math.max(8, Runtime.getRuntime().availableProcessors() * 2)), new RedisTopicNotifier("timeline:channel") )) // 3. 时钟源:多机部署一定换混合逻辑时钟 .clock(new HybridLogicalClock("node-" + instanceId)) // 4. 批量落盘阈值:攒够 256 条或 50ms 再写一次 .batchSize(256).batchTimeoutMs(50) .build();逐个说参数。store决定数据最终落在哪,本地单机验证用 RocksDB 最省事;如果要接 MySQL 或 TiKV,看这个库有没有对应存储适配器,没有就要自己实现TimelineStore接口。我接过一个只有 Redis 适配器的版本,上线前自己补了个 TiKV 实现,花了两个晚上,接口设计得通用的话这活不亏。dispatcher是数据流分发的心脏,它决定一条 entry 产生后怎么扩散到所有接收方;异步线程池的大小直接决定写入吞吐,太小会把分发变成瓶颈,太大又会把下游连接数打满,我的经验是从max(8, CPU 核数 * 2)起步,压测再调。clock最容易被忽略,单机用System.currentTimeMillis()没问题,多实例部署后同一毫秒内两台机器产生的事件会乱序,所以要么让库支持 HybridLogicalClock,要么在业务层给 ts 加自增序号段。batchSize和batchTimeoutMs是吞吐和延迟的权衡:攒批越大吞吐越高但消息越晚可见,256 条 + 50ms 通常是个不坏的起点。
3.2 写入一条动态并让它进入好友的时间线
引擎初始化完成后,业务侧只需要做三件事:构造 entry、调distribute、读取时调range。下面是一个发朋友圈的最小闭环,业务代码不关心好友列表怎么查、扇出到多少粉丝、Redis 推送给谁,这些都在分发器内部:
// 用户 A 发一条动态 TimelineEntry entry = TimelineEntryFactory.builder() .ownerId(aId) // 自己的时间线先落一条 .actorId(aId) .type(0) // 0 表示 feed 流 .ts(engine.now()) .payload(gson.toJson(moment).getBytes(StandardCharsets.UTF_8)) .build(); engine.distribute(entry); // 粉丝 B 拉取自己的时间线,第一页 20 条 List<TimelineEntry> page = engine.range(bId, engine.now(), // 最新时间 0L, // endTs 不设上限 20, 0L); // limit + 初始游标distribute内部做的事情,按重要程度拆开看:第一,先把 entry 写进 A 自己的时间线,这步必须同步完成,保证发布者立刻能看到;第二,分发器根据 type 走路由,type=0 从 Redis 或 DB 里取 A 的粉丝列表,逐批提交线程池,把 entry 的 ownerId 改成粉丝 id 后写入对应时间线;第三,通过 Redis 的 pub/sub 通知在线粉丝“你的时间线有新数据”,客户端收到信号后再调range拉增量。整个链路只有第一步是同步的,扇出和推送都是异步,所以distribute返回很快,但粉丝不一定立刻看到。这正好匹配朋友圈的场景:发布偶发、读取高频,异步扇出反而削平了瞬时热点。
读取侧有一个细节值得点名:range的startTs参数我不是传调用方的System.currentTimeMillis(),而是传engine.now()。因为任何一台时钟偏慢的机器都会漏掉刚写入、时间戳比本机时间大的新数据。这个“时钟偏差导致读漏”的问题,是时间线类系统最常见的隐性 bug,很多团队查了半天,最后发现是测试机的 NTP 没同步。
提示:在 Spring Boot 项目里,TimelineEngine 务必声明成单例 Bean,依赖注入一次;如果放在 @Scope("prototype") 或每次 new,线程池会被重复创建,线上表现为连接数缓慢上涨,最后连不上 Redis。
4. 数据流分发:扇形推送、聚合拉取与 IM 消息的实时路径
4.1 推拉模型的边界:不是所有场景都适合写扩散
第3章的示例是典型的写扩散(推模式):一条动态复制到所有粉丝的时间线。这种模式读得快,但写放大明显。抽象库让分发器可插拔,就是让你在推、拉、推拉结合之间切换。我习惯用一个表把三种分发策略的边界摆清楚:
| 分发策略 | 一句话描述 | 适合场景 | 主要代价 |
|---|---|---|---|
| 推模式(写扩散) | 写入时复制给所有接收者 | 粉丝量小、读取频繁的强社交关系 | 粉丝多时写入放大严重 |
| 拉模式(读聚合) | 读取时实时合并生产者列表 | 大 V 冷启动、低频读取 | 读取时 JOIN 和排序开销大 |
| 混合模式 | 活跃粉丝推,不活跃粉丝拉 | 粉丝量大但活跃度不均匀 | 逻辑复杂度上升 |
这个抽象的价值在于,数据落到谁的时间线,被拆成业务代码之外的一个可替换 Router。我实现过一个按活跃度路由的分发器,核心逻辑很短:
public class HybridRouter implements Router { @Override public List<String> route(TimelineEntry entry) { // 0 类型是 feed,走活跃粉丝过滤 if (entry.getType() == 0) { return fanStore.latestActiveFans(entry.getActorId(), 5000); } // 1 类型是 IM,只发给接收者本人 if (entry.getType() == 1) { return List.of(entry.getOwnerId()); } return defaultTargets(entry); } }route 方法返回接收者 id 列表,分发器只负责把这条数据写进这些人的时间线,不关心你是从 Redis 查的活跃用户还是从 DB 全量查的粉丝。latestActiveFans里的 latest 提醒一个关键点:扇出前先过滤不活跃用户,否则千万粉丝账号发一条动态,瞬间产生千万次异步写,再大的线程池也会被打垮。这就是第 5 章第一个避坑点的根因。实现 Router 时还要注意route方法的耗时不能太长,它会被分发线程循环调用,一次 DB 慢查询可能拖住整个扇出链路,所以常见的做法是在路由里只查 Redis 缓存,粉丝列表的 DB 全量同步放到后台任务去做。
4.2 消息推送与 IM 通讯:离线消息是时间线的天然场景
消息推送和 IM 通讯接入这个模型,有一半代码是共用的。以 IM 为例:A 给 B 发消息,B 在线就走 Redis 推送实时通道;B 离线,消息必须存下来等上线拉取。用时间线模型表达,离线消息就是给 B 的 owner 时间线追加一条带未读标记的 entry:
// A 给 B 发一条 IM 消息 TimelineEntry imEntry = TimelineEntryFactory.builder() .ownerId(bId) // 落在 B 的时间线 .actorId(aId) .type(1) // 1 表示 IM 消息 .ts(senderClock.now()) .payload(protoMessage.toByteArray()) .flag(TimelineFlags.UNREAD) // 标记未读 .build(); engine.distribute(imEntry); // B 上线后拉取未读会话 List<TimelineEntry> unread = engine.range(bId, now, 0L, 50, 0L) .stream() .filter(e -> e.hasFlag(TimelineFlags.UNREAD)) .collect(Collectors.toList());这条链路的参数说明:flag字段是对“未读”的标准处理方式,不单独建未读表,直接在 entry 上打标记;distribute对 type=1 的处理与 feed 不同——B 在线时,分发器先走notifier实时推送,同时写时间线;B 不在线时,只写时间线。为什么不省掉在线时的写入?消息除了实时展示,还要进历史记录,只推不写的话 B 上滑聊天记录会丢得干干净净。这个“在线推 + 全量存”的双写,是 IM 区别于纯 feed 流分发的地方,抽象库把这个差异收敛在 type=1 的分发逻辑里,业务方完全感知不到。已读回执也可以用它表达:B 读完后把对应 entry 的 UNREAD flag 清掉,后台异步批量更新,不需要单独维护一张已读表。
推送通知则是更简单的一种类型:ownerId 是目标用户,payload 是一段通知文案,分发器只需要“写入 + 在线推送”两步,不需要未读标记,系统通知天生允许覆盖。这也解释了为什么标题把 feed 流、IM、消息推送放在同一个库:它们存储同构、读取同构,差异只在分发目标列表和未读策略。你甚至可以拿这份抽象直接写群聊功能,群成员列表就是一个小型扇出列表,分发器拿到群成员 ids 逐条写入,和朋友圈的唯一区别只是从“粉丝关系表”换成了“群成员表”。群聊场景还有一个细节,群成员是即时拉取的,如果群里有退群用户,扇出前要先过滤一遍,不然消息会写进已经退群的人的时间线里,这种数据既占存储又可能引发隐私投诉。
5. 避坑记录:时间线场景里最容易翻车的 5 个环节
这类抽象库在 demo 里几乎不出问题,问题都出在数据量、多实例和极端流量下。下面这几条是我在类似架构里攒下来的血泪经验,每一条都按现象、根因、解决来写。
5.1 粉丝多了谁扛不住:扇出写入风暴
现象是:大 V 账号发一条动态,整个集群的写入 QPS 瞬间飙升,Redis 连接被打满,普通用户的写入跟着超时。根因是分发器做了全量扇出,线程池又配了无界队列,五百万粉丝等于五百万次 store.append,任务全被线程池接住之后,内存先爆。解决分两步:第一步,扇出前过滤不活跃用户,只把动态写进 48~72 小时内活跃过或正在在线的粉丝时间线;第二步,线程池改用有界队列加饱和策略,分发器放不下的任务直接降级为“不推送但保留原始 data”,由读取侧兜底。过滤逻辑一般长这样:
.dispatcher(new FanoutDispatcher(executor, notifier) .fanoutFilter(user -> user.isOnline() || user.getLastActiveAt() > now - 72 * 3600_000L))这段配置的意思是:只在线的、或三天内活跃过的粉丝才收到实时扇出,长期不活跃的粉丝打开 App 时再去各大 V 的独立时间线拉取合并。这步做完,千万粉丝账号的瞬时扇出量可以从千万级降到十万级。配合有界队列后,极端情况下丢的是“实时性”而不是“数据本身”,用户下次刷新还能看到,体验损失可控。
5.2 多实例时钟漂移:新消息排到了旧消息后面
现象是:跨机房部署后,A 在甲机房发消息,B 在乙机房回复,A 看到自己的消息出现在回复消息的后面。根因是两台机器用System.currentTimeMillis()各取时间戳,乙机的时钟快了 500ms,回复消息的 ts 就比原消息更大。解决:时间线库必须支持混合逻辑时钟,或者由入库序号充当 ts。我当时的做法是做一个自增号段服务,每条 entry 的 ts 由“物理秒 + 序号段”拼成,从源头保证单调递增。如果你暂时没有号段服务,至少先实现一个进程内的 HLC 简化版:
private final AtomicLong lastTs = new AtomicLong(); public long nextTs() { long physical = System.currentTimeMillis(); long last = lastTs.get(); long next = Math.max(physical, last + 1); return lastTs.compareAndSet(last, next) ? next : nextTs(); }这段代码保证同一个 JVM 内生成的 ts 严格递增;跨 JVM 时再加机房前缀或机器编号,避免两个进程各自维护计数器互相覆盖。接库之前先确认它有没有把时钟这一层抽象出来,如果没有,那 ts 就得业务方自己生成,改造代价会大不少。
5.3 分页翻车:offset 分页导致重复和漏数据
现象是:用户连续下拉,第三页出现了第一页已经看过的动态,或者直接漏掉中间几条。根因是 feed 流持续写入,用LIMIT 20 OFFSET 40分页时,第一页和第二页之间一旦有新数据插入,offset 之后的所有行都跟着位移,第三页自然和第一页重叠。解决:统一改游标分页,游标取上一页最后一条的 ts:
WHERE owner_id = ? AND ts < ? ORDER BY ts DESC LIMIT 20这个库的range接口如果原生支持 cursor,业务层就不需要自己拼 SQL;如果不支持,那就要在存储适配器里补这个语义。游标分页的另一个好处是天然免疫新写入带来的位移,代价是翻页时不能任意跳到指定页号,这对 feed 流产品来说完全够用。业务端拿到游标后,前端传回来的是上次最后一条的 ts 而不是页码,接口参数设计上要提前定好这个约定。
5.4 进程被杀丢消息:内存缓冲区的代价
现象是:JVM 被 OOM Killer 杀掉,重启后最近几秒的消息消失,用户投诉“消息被吞了”。根因是分发器为了吞吐,先把 entry 放进内存队列,攒批后异步落盘,进程被杀时队列里还有几百条没写进存储。解决:存储层加预写日志(WAL),entry 先追加进日志文件再进内存队列,重启后从 WAL 恢复。分发入口一般是这步:
public void distribute(TimelineEntry e) { wal.append(e); // 先落预写日志 queue.offer(e); // 再进内存队列,异步批量刷存储 }如果不想引入 WAL 组件,退一步的做法是把batchSize调小到 32、batchTimeoutMs调到 10ms,牺牲一点吞吐换可靠性。我见过不少团队两种都不愿意做,直到线上出事故才回来加班补,属于典型的“早知道”问题。另外注意 WAL 文件要定期滚动清理,不然磁盘会被历史日志占满。
5.5 循环分发:消息风暴把系统拖死
现象是:两个服务互相调用,比如 A 发 IM 给 B,B 的机器人自动回复,A 收到回复后再触发自己这边的事件,消息被反复加入对方时间线,数量指数级上涨。根因是分发器没做幂等和去重,也没限制一条数据的传播深度。解决:每条 entry 带全局唯一的 messageId,分发器维护最近处理过的消息 ID 集合,重复 ID 直接丢弃;分发路径再加一个剩余跳数,超过 3 跳的事件不再触发新的分发:
if (!seenMessageIds.add(entry.getMessageId())) return; if (entry.getRemainingHops() <= 0) return;这个集合可以是 Caffeine 的 5 分钟过期缓存,数量控制在百万级,内存占用可控。循环分发这个问题在早期排查时看着流量曲线十倍增长,心里是凉的;一旦加上去重和跳数限制,曲线立刻平缓。测试阶段最好写一个故障注入用例,模拟双向自动回复场景,验证这条防线真的有效。
6. 进阶用法:自定义分发策略、压测与一致性校验
6.1 自定义分发策略:把“谁先看到”变成业务规则
抽象库的扩展点通常在分发器。我做过一次替换,把 FanoutDispatcher 换成 PriorityDispatcher,给 VIP 用户更高的推送优先级:
TimelineEngine engine = TimelineEngine.builder() .dispatcher(new PriorityDispatcher( vipCache::isVip, // 判断优先级的函数 Executors.newFixedThreadPool(16) )) .store(...) .build();这样做的价值是:优先队列让 VIP 用户的时间线在突发流量下依然低延迟,普通用户略慢但不至于拖垮整条写入链路。类似的策略还有“按在线状态分流”:在线的走实时推送通道,离线的只落存储不发信号,能省掉大量无效的 Redis pub/sub 调用。
6.2 压测与一致性校验:怎么确认这套时间线能上生产
实践中最有效的验证是制造差异:起三个节点,关掉其中一台的存储,用一致性脚本对比另外两台的时间线内容,确认数据不丢不乱;然后压测写入链路,观察线程池活跃线程数、队列积压量、Redis 通道消费速率。常见的压测起点是:单条 payload 1KB,500 个并发客户端,每客户端每秒发 20 条动态,观察 P99 写入延迟是否小于 200ms。如果 P99 超过 500ms,优先调整 batchSize 和线程池大小,再看存储介质的刷盘策略。
我最早做类似系统时,第一版就死在扇出风暴上,后来养成的习惯是:接任何时间线库,第一件事不是看文档,而是把分发路径上每一环的确认机制摸清楚——谁负责写、谁负责推、哪一步是异步、崩溃之后数据能不能找回。带着这个框架去读代码,比对着 README 抄示例可靠得多。希望帮到你。
本文还有配套的精品资源,点击获取