3.2 信息流:时间线与排序策略
场景:用户说首页永远只有那几条
运营把这个问题报上来的时候,我们查了半天接口没毛病。首页按最新排序,但半数用户停留在前三屏就走了,热门帖子被埋在第二十页。数据说明一切:纯时间线分发对资讯社区是灾难,老帖没人看,新帖没机会。产品要求首页改成按版块加权加互动热度排序,同时保留最新入口给习惯刷时间线的用户。
难点在于排序参数每天都在变。运营要对不同版块设权重,还要对帖子热度衰减做调整,这些规则如果写进代码,每改一次都要发版。我们的做法是把排序公式提到配置,Redis 里维护热榜 zset,聚合任务每十分钟重算一次,运营改权重只改数据库配置不发版。
信息流改版还顺带改变了前端的交互形态。纯时间线年代,用户刷到底的心理预期是看完了就走;上了排序流之后,底部加载不再有明确的尽头,我们顺势把刷到底的文案改成了推荐引导,配合新版块入口,用户人均浏览帖子数从十一条涨到十九条。这个联动改动虽然不在后端职责内,却是后端排序能力外溢到产品体验的一个好例子:信息流的形态由后端排序决定,前后端必须一起设计这段体验,而不是各做各的。
排序方案上线后的第一周,我们盯着看板验证效果:人均停留时长从两分四十秒涨到四分十秒,前三屏互动率翻倍。但第二周数据回落,查了一圈发现是衰减系数没调好,新内容曝光不足老帖沉不下去,公式又迭代了两版才稳定。排序不是上线就完事的功能,它需要持续盯数据微调,我们把排序参数的调整权限下放给运营之后,迭代频率从一周一次变成一天一次。
还有一个观察值得记一笔:信息流的效果与帖子的生命周期强相关。热点帖子发布后两小时内的互动贡献了百分之八十的热度,所以聚合任务的十分钟窗口正好覆盖了内容最关键的生命期。基于这个观察,我们把热门版块的聚合频率加密到五分钟一次,冷门版块放宽到半小时,资源花在刀刃上。
方案:加权时间衰减加版块权重
排序公式综合三股力量:版块权重、互动热度、时间衰减,最终落到一个 score,写入 Redis zset,key 按版块分开,首页和各版块列表各自走各自的 zset。公式里每个因子都可配置,运营在后台拖滑块就能看效果:
| 因子 | 来源 | 权重示例 | 调整频率 |
|---|---|---|---|
| 版块权重 | t_section.weight | 一到十 | 每周运营调 |
| 点赞数 | 计数服务 | 每赞一分 | 实时 |
| 评论数 | 计数服务 | 每评论三分 | 实时 |
| 时间衰减 | 聚合任务 | 每小时九五折 | 固定 |
| 浏览量 | 不计入 | 零 | 防刷 |
| 新帖保底 | 聚合任务 | 两小时内加成 | 固定 |
刻意把浏览量排除在热度公式之外,是踩坑后的决定,原因在踩坑一节展开。新帖保底则是另一个妥协:严格按公式排序时,刚发的帖子互动为零永远沉底,作者发完帖看不到反馈就流失了,所以给最新两小时内的帖子一个保底分,让新内容至少有一次曝光机会。
排序公式落地之前还要先解决一个工程问题:公式参数放哪里。最先想到的是配置文件,运营改一次就要开发发版,方案直接否掉。最终参数存在排序配置表里,运营在管理后台拖动滑块即改即生效,改完留痕可回滚。配置表是信息流模块里唯一允许运营直接写数据的表:
CREATE TABLE t_sort_config (
cfg_id INT PRIMARY KEY AUTO_INCREMENT,
section_id INT NOT NULL,
like_weight DECIMAL(4,2) DEFAULT 1.00,
comment_weight DECIMAL(4,2) DEFAULT 3.00,
decay_base DECIMAL(4,2) DEFAULT 0.95,
newbie_boost TINYINT DEFAULT 1,
effect_at DATETIME NOT NULL,
KEY idx_section (section_id, effect_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
生效逻辑也值得注意:配置表用 effect_at 字段表达生效时间,聚合任务每次启动时读取当前时刻最新的一行,避免运营误填一个未来时间导致立刻生效。参数与代码之间始终隔着一层校验,这层校验在后来阻止过至少三次离谱配置。
配置与代码
聚合任务每十分钟跑一次,从 t_post 取近七天内可读帖子计算 score,写进 zset 并只保留前一千条。这个动作看似频繁,实际每天只写数万次 Redis,对负载毫无影响:
const HALF_LIFE = 3600 * 1000; // 一小时衰减周期
async function aggregateFeed(section_id) {
const rows = await postModel.recentBySection(section_id, 7 * 86400);
const zset = 'feed:section:' + section_id;
const members = rows.map(p => {
const decay = Math.pow(0.95, (Date.now() - p.ts) / HALF_LIFE);
const score = (p.like_cnt + p.comment_cnt * 3) * decay * p.section_weight;
return { score, member: String(p.post_id) };
});
await redis.zadd(zset, members);
await redis.zremrangebyrank(zset, 0, -1001);
await redis.expire(zset, 900);
}
列表读取直接用 zrange 翻页,再用批量拉取接口拿帖子详情,速度快且稳定。翻页游标用上一页最后一条的 score 而不是 page,深翻时效率更高:
router.get('/api/feed/:sid', async ctx => {
const { offset = 0, limit = 20 } = ctx.query;
const ids = await redis.zrevrange('feed:section:' + ctx.params.sid,
+offset, +offset + +limit - 1);
const posts = await postModel.mget(ids);
ctx.state.data = { list: posts, has_more: ids.length === +limit };
});
详情批量拉取走的是一个专门的 mget 接口,它内部先过详情缓存、再合并落库的轻量字段,避免列表页把富文本正文也一并拉回。首页一次二十个帖子 id 在这里被转成一次批量缓存查询,而不是二十次单条详情请求,这是信息流接口耗速能稳在五十毫秒以内的直接原因。
踩坑
第一个坑是把浏览量算进热度。有人发现看自己帖子能涨分,立刻出现刷浏览脚本把自家帖子顶上首页,我们在热度公式里彻底剔除浏览量,改用点赞和评论这种有成本的行为计分。第二个坑是时间衰减系数,最早按自然天衰减,半夜十二点所有帖子同时掉一档分,首页每天准点洗牌,用户体感非常怪,改成连续衰减后再也没有跃变。第三个坑是 zset 的体积,最初不限制长度,七天的帖子全堆进去,内存涨得飞快,后来每个 zset 只保留前一千条,内存占用降了六成而首屏体验毫无差别。第四个坑是新帖冷启动,最新两小时保底分上线后,又有人卡着保底窗口刷量,保底分加了上限封顶才封住口子。第五个坑是聚合任务的单点故障,某次任务卡住二十分钟没人发现,首页内容静止用户直接流失,加了任务执行间隔监控,超过十五分钟无更新即告警。第六个坑是多版本排序的 ab 实验,同一版块同时跑两套公式,缓存键用实验号隔离,实验结束后及时清理旧键,否则 Redis 会留下成堆的实验垃圾。第七个坑是运营误操作,某次把评论权重从三调到三十,首页瞬间被吵架帖霸占,吓得我们给所有参数加了单次变更幅度上限,超过百分之两百需要双人复核。第八个坑是热门帖的详情回源压力,热帖从信息流被点进详情后缓存命中率本来就高,但聚合任务刷新 zset 时会顺带清掉详情缓存,给自己造了一次小规模击穿,解法是热榜键与详情键彻底分离、互不影响。
小结
信息流排序没有银弹,核心是可配置:因子可调、权重可调、衰减可调,全部不写死在代码里。把浏览量从公式里踢出去、给新内容保底曝光,这两条是实践中最有价值的判断。监控人均停留和互动率比监控接口耗时更能说明排序效果,业务指标与技术指标要一起看。最后给一个上线的节奏建议:排序改动一律灰度一个版块观察两天再全推,急性子直接全量上线,回滚成本远高于观察成本。排序服务的稳定性依赖缓存层,更完整的缓存兜底实践可以看 高并发秒杀实战,下一节则展开评论与点赞的树形设计。
延伸阅读
排序体系的下一步演化我们已经能预见:把人工配公式换成机器学习模型排序,数据准备工作其实都已经在这次重构里就位了。
