GoWind ShortPlay 拆解:不用模型的推荐闭环——埋点回流、三路召回与已看去重
GoWind ShortPlay 拆解不用模型的推荐闭环——埋点回流、三路召回与已看去重本文回答一个问题没有算法团队、没有特征平台、没有模型服务一个短视频 Feed 的推荐要怎么做才不是自欺欺人GoWind ShortPlay 的 Feed 有一套完整的推荐链路Flutter 播放器实时上报观看行为 → core 防刷入库 → 三路召回 热度加权排序 → 已看去重 → 召回理由随内容回传。全程一个模型都没有但闭环的每一环都是真的——埋的是真数据、算的是真信号、去重是真去重。写这篇文章前先立个牌子它不是 AI 推荐系统。没有用户画像、没有协同过滤、没有 CTR 预估热门榜对每个登录用户长一个样。这是刻意的而且我认为在开源 MVP 语境下这是唯一诚实的选择——下面拆完每一环你会看到简单本身是怎么被设计出来的。一、先泼冷水为什么不用模型在数据没攒够之前上模型不是先进是负资产没有正负样本。推荐模型的燃料是曝光-行为对开源版上线第一天数据量是零。规则排序零数据可用模型排序零数据报废。不可解释。运营问为什么这个视频在首页view×1 like×3 collect×5 complete×4能当场算给他看模型的 feature importance 不能。不可调试。规则错了改权重立刻生效模型错了要查特征、重训、灰度——这些基建本身就是三万人月。闭环比聪明重要。短视频产品的冷启动阶段用户滑走的不再推、完播的被加权这两条规则贡献的体验提升远大于一个没有数据喂的模型。所以 GoWind ShortPlay 的选择是先花最小代价把反馈环转起来把升级路径留在契约里。全链路长这样Flutter 播放器 core 服务 ┌──────────────────┐ HTTPS ┌─────────────────────────┐ │ onPlayStart │ │ ReportEvents │ │ onHeartbeat(10s) ├──批量上报──▶│ 单批≤100 防刷 │ │ onComplete │ │ 逐条落 sp_uba_events │ │ onSwipeAway(95%)│ └───────────┬─────────────┘ └──────────────────┘ │ ▼ ┌──────────────────┐ ┌─────────────────────────┐ │ GetFeed │◀──gRPC────│ 三路召回 │ │ 上下滑沉浸流 │ │ 关注 → 热门 → 最新兜底 │ │ (reason 回传) │ │ 热度分 SQL 加权排序 │ └──────────────────┘ │ 已看去重 Redis SET │ └─────────────────────────┘四类事件、一张表、一条 SQL、一个 SET。下面逐环拆。二、信号从哪来播放器就是埋点器推荐系统的第一课是信号质量取决于离行为现场有多近。GoWind ShortPlay 把埋点直接做进了播放器组件——video_player_item.dart封装 media_kit向上抛四个回调宿主页面负责上报信号触发时机语义onPlayStart进入当前页开始播放一次真实曝光进入过播放态不只是划过卡片onHeartbeat10 秒定时器仅播放中触发持续观看证明带当前进度onComplete播放器 completed 流触发percent100看完了onSwipeAway离开当前页且进度 95%没看完就走了这里最值得抄的是95% 这条线的互补设计if(!oldWidget.isActivewidget.isActive){/* 进入 */}if(oldWidget.isActive!widget.isActive){unawaited(_player.pause());finalprogress_snapshot();if(progress.percent95){widget.onSwipeAway?.call(progress);// 只有没看完就走才算划走}}用户在第 96% 处滑走、播放器恰好没来得及触发 completed这条行为不算划走——它接近完播让位给COMPLETE事件。两个信号不重叠计费热门分不会因为结尾长的短视频被重复加权。埋点的去重做在客户端定义层比服务端事后清洗便宜得多。心跳的节流也做在客户端Timer.periodic(Duration(seconds: 10))且仅在_player.state.playing时触发——暂停不计心跳快进/拖动由_snapshot()取实时位置服务端拿到的就是干净的每 10 秒一次、只在真看的脉冲。上报是 fire-and-forget这是采集段的第二原则——埋点永远不配阻塞播放voidreportAll(ListUbaEventevents){unawaited(_dio.post(/uba/events,...).then((_){},onError:(Objecte){debug(uba report failed (ignored):$e);}));}unawaited 失败静默。序列化层还处理了一个 protojson 的实际坑durationMs是 int64必须序列化成字符串durationMs: $durationMswatchPercent是 uint32 传数字并 clamp 到 0-100——前后端契约的精度细节在客户端就锁死了。三、信号去哪了一张埋点表两个消费方服务端接收接口POST /app/v1/uba/events在游客白名单里——游客也能上报否则冷启动期的行为数据永远归因不到人。配套的UbaIdentityInjector中间件做了一件精细的事白名单短路了鉴权但请求带有效 token 时尽力解析出 uid 写回请求头解析失败静默放行按游客处理。身份是尽力而为的增强项不是上报的前置条件。入库三道闸core 的uba_service.go单批上限 100ubaMaxBatchSize——防刷的第一道墙正常客户端单次上报撑死几条一次 POST 几千条的必是脚本逐条落库、单条失败不中断整批——CreateBatch是尽力而为语义脏数据丢弃不影响好数据表设计直接为消费方服务index.Fields(event_type,occurred_at).StorageKey(idx_sp_uba_event_type_occurred_at)index.Fields(video_id,event_type).StorageKey(idx_sp_uba_video_event)(video_id, event_type)这个复合索引就是给热门聚合用的——后面那条热门 SQL 的WHERE event_typeVIDEO_COMPLETE GROUP BY video_id走的就是它。埋点表的索引是照着查询画出来的不是拍脑袋加的。而这张表最一鱼两吃的地方观看历史就是埋点数据本身。ListWatchHistoryPage对该用户的VIDEO_PLAY事件做GROUP BY video_id MAX(COALESCE(occurred_at, created_at))聚合出最近观看时间排序——没有独立的 history 表看过的视频天然按最后观看时间倒序。“行为日志即业务数据”少一张表就少一条同步链路。四、分怎么算一条 SQL 的推荐排序Feed 的召回-排序-合并在feed_service.go的GetFeed先看全景再拆热度recallN:int(pageSize)*4// 超采样 4 倍// 1. 关注召回最近关注 100 人的已发布视频发布时间倒序authorIDs,_:s.interRepo.FollowingAuthorIDs(ctx,uid,100)followVideos:s.videoRepo.ListPublishedByAuthors(ctx,authorIDs,recallN)// 2. 热门加权hotVideos:s.videoRepo.ListHot(ctx,recallN)// 3. 最新兜底带 tag 走标签聚合latestVideos:s.videoRepo.ListLatest(ctx,recallN)三路各自超采样 4 倍每路都取 4×pageSize 条合并是一个先到先得的闭包add:func(vs[]*ent.Video,reason feedV1.FeedReason){for_,v:rangevs{if_,dup:mergedIDs[v.ID];dup{continue// 已被更高优先级召回想,跳过}mergedIDs[v.ID]struct{}{}mergedappend(merged,v)reasonsOf[v.ID]reason// 先到先得:关注 热门 最新}}合并时用 map 去重、理由先到先得关注 热门 最新每条 item 的reason字段随内容一起下发——前端可以拿它做角标“你关注的 TA”、做分路占比的灰度观测。召回理由进契约而不是藏在服务端这是后面升级路径的地基。热度分公式ListHot是全项目少数原生 SQL 聚合点值得整段读// UBA 完播聚合每视频至多一行不产生行增殖完播是内容质量的// 强信号计数列view/like/collect只反映互动量不反映看完率。agg:sql.Select(video_id,COUNT(*) AS complete_cnt).From(sql.Table(sp_uba_events)).Where(sql.EQ(event_type,VIDEO_COMPLETE)).GroupBy(video_id).As(uba_agg)s.LeftJoin(agg).On(s.C(video.FieldID),video_id)s.OrderBy(sql.Desc(view_count * 1 like_count * 3 collect_count * 5 COALESCE(complete_cnt, 0) * 4),sql.Desc(video.FieldPublishedAt),)权重不是拍脑袋的每个数字对应一种行为成本信号权重行为成本说明view_count×1零成本详情接口 1容忍丢失不走事务——最廉价的信号只配最低权重like_count×3一次点击主动互动但要双击/点按钮collect_count×5一次点击 未来回报预期收藏是我还要再看意向最强的轻量信号complete_cnt×4完整时长投入质量信号互动可以刷看完刷不了两个 SQL 细节子查询聚合 LEFT JOINGROUP BY video_id保证每视频至多一行注释专门点名不产生行增殖——埋点表是几十万行JOIN 出来的还是视频行数排序在视频表规模上完成COALESCE(complete_cnt, 0)兜住没有完播记录的视频同分回退published_at DESC——没人互动的新内容按新鲜度兜底冷启动视频永远有机会进池。关于这条 SQL 的量级账也值得算清楚聚合子查询WHERE event_typeVIDEO_COMPLETE GROUP BY video_id走(video_id, event_type)复合索引代价与完播事件总量线性而非视频总量线性——埋点表涨到百万行时这个子查询会是热门召回的主要成本。到那一步的正确动作不是给 MySQL 加机器而是把聚合结果周期物化视频表加一列complete_count冗余或挪进 OLAP——但 MVP 阶段刻意不做冗余列的增量维护会把埋点写入从尽力而为的单条 insert变成带计数事务的写采集端的失败语义会跟着变。读时聚合是惰性的正确物化是规模到了之后的优化两者切换点清晰。这就是够用的推荐的形态一条索引友好的 SQL把质量信号完播从行为信号互动里拆出来加权。它回答不了这个人爱看什么但能保证垃圾内容沉底、好内容上浮——而后者决定了 Feed 的及格线。五、怎么不重复已看集合与诚实的尽力而为沉浸式 Feed 最大的体验底线是不重复推。实现是一个 Redis SETfeed_service.goconst(feedSeenKeyPrefixgowind-shortplay:feed:seen:feedSeenTTL7*24*time.Hour feedSeenCap2000)写本页下发即记已看——SAddExpire(7 天)走 Pipeline每次下发滑动续期pipe:s.redis.Pipeline()pipe.SAdd(ctx,key,members...)pipe.Expire(ctx,key,feedSeenTTL)// 每次写入滑动续期 7 天if_,err:pipe.Exec(ctx);err!nil{return}ifn,err:s.redis.SCard(ctx,key).Result();errnilnfeedSeenCap{s.redis.SPopN(ctx,key,n-feedSeenCap)// 超上限,随机弹出多余成员}注意语义已看的定义是下发过而非播放过Feed 场景下推了没看≈不想看同样不该再推。读SMembers全量拉回内存 set三路召回各自过一遍seenFiltered。敢全量拉是因为容量有界——上限 2000约 20KB/用户微秒级过滤。防膨胀SCard超 2000 时SPopN(key, n-2000)随机弹出多余成员。注释大方承认是尽力而为——不是 LRU最早看过的和最近看过的有同等概率被弹掉。为什么不用 ZSet 做真 LRU、不用 bitmap算一笔账单用户 2000 个 ID 的 SET 约 20KBSMembers一次内存过滤微秒级换成 ZSet 每次写多一次 score 更新读侧改成 ZRANGEBYSCORE 分页——复杂度上去了收益是第 2001 个之前看过的视频理论上不重复而页码截页的分页方式本来就无法保证严格不重复。这是两个刻意声明的取舍随机淘汰超容量的老记忆可能被弹掉换 SET 的简单页码截页非游标往回翻可能因已看过滤出现空页换实现直接。代码不装。注释里写的就是 trade-off 本身等真到商业化量级替换的是这一层GetFeed的接口签名不动。六、个性化在哪、不在哪升级路径是设计出来的把边界画清楚这一节回答那它到底个性化了个啥有的个性化关注流——唯一的人维度信号最近关注 100 人的新作优先先到先得占住理由位已看去重——千人千面最朴素的形式你看过的别人不用管你自己不再看到。没有的个性化热门榜全局一份没有相似用户也看了划走信号采集了但排序还没消费——SWIPE_AWAY在sp_uba_events里躺着热门分只用了COMPLETE。信号先行入库、消费后行跟进这是有意的节奏。冷启动的真实路径也值得走一遍新用户第一屏关注召回为空FollowingAuthorIDs返回空集直接短路热门召回对它是全局榜——新用户和老用户看到的头部内容一样唯一的差异会随关注关系与已看集合慢慢积累游客uid0更彻底已看集合整个跳过刷两页同内容会重复——游客本来就不承载个性化预期。这两个体验弱点都是刻意不修的修复它们的每一项画像、协同过滤成本都远超 MVP 阶段的收益而埋点已经把需要的数据存下来了。那下一步怎么走代码里已经留好了三段路径消费已有信号把SWIPE_AWAY变成负权重- swipe_cnt × k、给心跳聚合出平均观看进度都是现表现索引上的 SQL 改动加画像维度tag 偏好用户对哪类标签完播率高可以纯 SQL 聚合出来插进召回层做一路兴趣召回——GetFeed里加一路add()契约不变换真推荐服务ListHot的代码注释明示了这条升级路径是预留过的——「替换本召回时接口形态不变ROADMAP 预留」。因为FeedReason枚举进了 proto 契约、召回收敛在 videoRepo 的三个方法里未来无论后面是向量召回还是模型排序GetFeed的调用方Flutter App / BFF一行不用改。这就是开篇说的把升级路径留在契约里的完整含义推荐系统的可替换性不取决于今天用没用模型取决于召回/排序有没有被隔离在契约边界内。七、快速验证# 起服务后见仓库 README游客直接刷 Feedcurlhttp://127.0.0.1:7700/app/v1/feed?pageNum1pageSize5# 连刷两页第二页不含第一页的 videoId游客无已看集合登录后生效# 登录用户登录态刷两页观察 reason 字段的分路占比# FEED_REASON_FOLLOW / FEED_REASON_HOT / FEED_REASON_LATEST# 手工制造完播信号看热门分变化curl-XPOST http://127.0.0.1:7700/app/v1/uba/events\-HContent-Type: application/json\-HAuthorization: Bearer token\-d{events:[{eventType:EVENT_TYPE_VIDEO_COMPLETE,videoId:1,durationMs:60000,watchPercent:100}]}Flutter 端跑起来更直观flutter run --dart-defineAPI_BASEhttp://127.0.0.1:7700上下滑几个视频看管理后台数据看板的趋势变化——那就是你刚喂进去的埋点。八、结语这篇拆的是 GoWind ShortPlay 里最不像功能的功能它没有独立的服务、没有模型、甚至没有一张新表但它把推荐系统的骨架——信号采集、防刷入库、召回排序、去重、理由回传——全部立住了。回头看这个闭环的设计原则其实就三条信号定义做在离行为最近的地方播放器回调层95% 线互补去重埋点永远尽力而为fire-and-forget、逐条落库、失败静默——数据管道不能反过来拖垮主业务可解释先行、可替换留门SQL 权重当场能算给运营看FeedReason进契约未来换推荐服务调用方不动。这套SQL Redis 够用推荐的适用边界由几个常量共同划出热门聚合走(video_id, event_type)索引、已看集合封顶 2000、单批上报上限 100——每个常量都对应一个明确的量级假设。假设被打破时埋点表千万行、单用户消费深度远超 2000先被打破的是聚合查询延迟与去重精度届时该动的只是消费层完播聚合周期物化、已看集合换 ZSet 分页淘汰——埋点表结构不用动采集端一条代码都不用改。仓库GitHubgithub.com/tx7do/go-wind-shortplayGiteegitee.com/tx7do/go-wind-shortplay