Feed流系统设计实战:用 Redis ZSET + cursor 搭一套可复制的配置骨架

发布时间:2026/9/30 19:03:19
Feed流系统设计实战:用 Redis ZSET + cursor 搭一套可复制的配置骨架 1. Feed 流系统设计里 Redis ZSET 与 cursor 到底解决什么问题Feed 流系统设计这件事很多人第一次接触会把它理解成“查视频列表”。我一开始也这么想后来发现完全不是。Feed 接口的本质是消费一条已经排好序的推荐流它不做复杂 SQL、不做推荐计算、不做多表 join只做一件事按顺序把内容读出来。你打开任何一个短视频 App上滑一次拿到 3 到 5 条视频这个动作背后就是 Feed 接口在干活。那为什么一定要用 Redis ZSET因为 Feed 流的核心诉求是“按分数从高到低取一段”而且这个“取一段”要能无限翻页、要能扛住高并发、要能在毫秒级返回。MySQL 的ORDER BY score DESC LIMIT offset, size在 offset 变大时会越来越慢几百万行之后基本不可用。Redis 的 ZSET有序集合天生就是按 score 排序的结构ZREVRANGEBYSCORE可以从任意分数位置往下取 N 条时间复杂度是 O(log(N)M)跟 offset 无关。这就是为什么主流 Feed 系统几乎都用 ZSET 存推荐流。cursor 则是配合 ZSET 做分页的关键。传统分页用 page number 或 offset翻到第 100 页时数据库要扫描前 100 页的数据再丢掉越翻越慢。cursor 的思路是我不告诉你“第几页”我告诉你“上一批最后一条的 score 是多少”下一批从那个 score 往下取。这样每次查询都是从一个确定的位置开始跟翻了多少页无关。在 Feed 场景里cursor 通常就是上一批最后一条内容的 score 值。这套组合适合谁后端工程师、准备做信息流产品的开发者、想理解推荐系统在线服务层的人。你不需要先懂推荐算法只要理解“提前算好分数 → 存进 ZSET → 按 cursor 顺序消费”这条链路就能搭出一个能跑的骨架。下面我会把 Redis ZSET 的键结构、cursor 编码规则、以及用 TaoToken 统一 Key/API 通道的 settings.json 配置骨架完整给出来每一步都能复制去跑。2. TaoToken 前置统一 Key 与 API 通道的 settings.json 配置骨架在写 Feed 服务之前先把模型调用通道配好。为什么 Feed 系统设计要扯到模型通道因为现代 Feed 流里推荐分数的计算、内容的标签生成、甚至冷启动的兜底文案越来越多地依赖模型能力。你不可能每个服务都单独维护一套 Key 和 Base URL所以需要一个统一的接入层。TaoToken 在这里扮演的就是统一 Key/API 通道的角色把模型对话、coding-plan、console、api-keys 这些入口收敛到一套配置里。先明确三个核心件Base URL、API Key、Model ID。这三个缺一个都调不通。Base URL 用https://taotoken.net/api注意这个地址不带任何查询参数。API Key 去 console 里生成路径是https://taotoken.net/console生成后复制出来。Model ID 根据你要用的模型填比如做文本生成、做 embedding 都行。配置文件我建议放在项目根目录的.taotoken/settings.json这样不同服务都能读到。内容如下{ baseUrl: https://taotoken.net/api, apiKey: sk-你的实际Key, defaultModel: claude-sonnet-4-20250514, timeoutMs: 30000, retry: { maxAttempts: 3, backoffMs: 500 }, endpoints: { chat: /v1/chat/completions, models: /v1/models } }如果你用的是 Claude Code 这类工具配置会落在~/.claude/settings.json结构类似但字段名可能不同。核心还是那三件套Base URL 填https://taotoken.net/apiAPI Key 填你生成的Model ID 填你要用的模型。Cline 的 MCP 配置也是同样逻辑在 MCP server 的 env 里把这三个值写进去。这里有个容易踩的坑Base URL 末尾不要加斜杠也不要加/v1因为 endpoints 里已经带了/v1/chat/completions。如果你在 Base URL 里写了/v1拼出来就变成/v1/v1/chat/completions直接 404。我试过在 Codex 的auth.json里配字段是base_url和api_key注意下划线命名跟 settings.json 的驼峰不一样。配好之后你可以先用一个最简单的 curl 验证通道是否通curl -X POST https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer sk-你的实际Key \ -H Content-Type: application/json \ -d { model: claude-sonnet-4-20250514, messages: [{role: user, content: ping}], max_tokens: 16 }返回里如果有choices数组说明通道没问题。这一步很重要因为后面 Feed 服务里如果要做分数微调或标签生成都依赖这个通道。通道不通后面所有验证都会卡在 401 或连接超时上。3. 可复制配置Redis ZSET 键结构、cursor 编码规则与完整骨架现在进入核心部分。Feed 流的 Redis 键结构设计直接决定了后面能不能水平扩展。我推荐两种键一种是用户维度的feed:user:{userId}一种是桶维度的bucket:{tag}。前者适合活跃用户提前展开后者适合拉模式实时取。先看用户维度的 ZSET 结构# 键名 feed:user:10001 # 成员与分数 ZADD feed:user:10001 1706842398123 video:101 ZADD feed:user:10001 1706842398000 video:88 ZADD feed:user:10001 1706842397000 video:66这里的 score 不是时间戳而是推荐系统算出来的推荐优先级。时间戳只是其中一部分权重。score 越大越靠前。取的时候用ZREVRANGEBYSCORE从大到小取。桶维度的结构类似但键名是标签ZADD bucket:tech 98.2 video:101 ZADD bucket:tech 87.5 video:88 ZADD bucket:tech 43.1 video:66用户刷的时候先根据用户标签找到对应的 bucket从 bucket 里取一批再过滤掉用户已看的最后返回。这样 bucket 是公共池子用户状态是隔离的。cursor 的编码规则我建议用 JSON 再 base64这样能兼容多 bucket 混合的场景。单 bucket 的 cursor 长这样{ b: tech, s: 87.5 }b是 bucket 名s是上一批最后一条的 score。多 bucket 混合时{ tech: 87.5, finance: 66.2, game: 102.1 }base64 之后传给客户端客户端下次请求原样带回。服务端解码后对每个 bucket 用ZREVRANGEBYSCORE bucket:{tag} (score -inf LIMIT 0 size取下一批。注意 score 前面的(表示开区间避免重复取到上一批最后一条。下面是完整的 Java 骨架用 Spring 的StringRedisTemplateService public class FeedService { Autowired private StringRedisTemplate redis; Autowired private VideoService videoService; private static final int DEFAULT_SIZE 5; public FeedResp feed(FeedReq req) { int size req.getSize() null ? DEFAULT_SIZE : req.getSize(); MapString, Double cursorMap CursorCodec.decode(req.getCursor()); ListString videoIds new ArrayList(); MapString, Double nextCursor new HashMap(); for (Map.EntryString, Double entry : cursorMap.entrySet()) { String bucket entry.getKey(); double lastScore entry.getValue(); SetZSetOperations.TypedTupleString tuples redis.opsForZSet().reverseRangeByScoreWithScores( bucket: bucket, Double.NEGATIVE_INFINITY, Math.nextDown(lastScore), 0, size ); if (tuples null || tuples.isEmpty()) { continue; } double minScore Double.MAX_VALUE; for (ZSetOperations.TypedTupleString t : tuples) { videoIds.add(t.getValue()); minScore Math.min(minScore, t.getScore()); } nextCursor.put(bucket, minScore); } ListString filtered filterWatched(req.getUserId(), videoIds); ListVideoDTO videos videoService.batchGet(filtered); return new FeedResp( videos, CursorCodec.encode(nextCursor), !nextCursor.isEmpty() ); } private ListString filterWatched(Long userId, ListString ids) { if (ids.isEmpty()) return ids; SetString watched redis.opsForSet() .members(user:history: userId); if (watched null || watched.isEmpty()) return ids; return ids.stream() .filter(id - !watched.contains(id)) .collect(Collectors.toList()); } }cursor 编解码工具类public class CursorCodec { private static final ObjectMapper MAPPER new ObjectMapper(); public static MapString, Double decode(String cursor) { if (cursor null || cursor.isEmpty()) { return defaultCursor(); } try { byte[] raw Base64.getUrlDecoder().decode(cursor); return MAPPER.readValue(raw, new TypeReferenceMapString, Double() {}); } catch (Exception e) { return defaultCursor(); } } public static String encode(MapString, Double map) { try { byte[] raw MAPPER.writeValueAsBytes(map); return Base64.getUrlEncoder().withoutPadding().encodeToString(raw); } catch (Exception e) { return ; } } private static MapString, Double defaultCursor() { MapString, Double m new HashMap(); m.put(tech, Double.MAX_VALUE); m.put(finance, Double.MAX_VALUE); return m; } }这套骨架的关键点cursor 里存的是每个 bucket 的“下一批起点”不是全局 offset。bucket 是共享池用户已看状态存在user:history:{userId}这个 Set 里过滤在服务层做。这样用户 A 刷过的视频用户 B 还能刷到因为 bucket 没动只是 A 的 history 里有记录。4. 验证请求与成功结果分页拉取与边界翻页实测配置写完了得验证。我分三步先灌数据再发请求最后测边界。第一步用 redis-cli 灌一批测试数据redis-cli ZADD bucket:tech 98.2 video:101 redis-cli ZADD bucket:tech 87.5 video:88 redis-cli ZADD bucket:tech 76.1 video:66 redis-cli ZADD bucket:tech 65.0 video:55 redis-cli ZADD bucket:tech 54.3 video:44 redis-cli ZADD bucket:tech 43.1 video:33 redis-cli ZADD bucket:tech 32.0 video:22 redis-cli ZADD bucket:tech 21.5 video:11第二步发第一次请求cursor 为空服务端用默认 cursorscore 为Double.MAX_VALUEcurl -X GET http://localhost:8080/api/feed?userId10001size3 \ -H Content-Type: application/json预期返回{ list: [ {videoId: video:101, score: 98.2}, {videoId: video:88, score: 87.5}, {videoId: video:66, score: 76.1} ], nextCursor: eyJ0ZWNoIjo3Ni4xfQ, hasMore: true }nextCursor解码后是{tech: 76.1}也就是上一批最后一条的 score。第三步带 cursor 发第二次请求curl -X GET http://localhost:8080/api/feed?userId10001size3cursoreyJ0ZWNoIjo3Ni4xfQ \ -H Content-Type: application/json预期返回{ list: [ {videoId: video:55, score: 65.0}, {videoId: video:44, score: 54.3}, {videoId: video:33, score: 43.1} ], nextCursor: eyJ0ZWNoIjo0My4xfQ, hasMore: true }注意这里没有重复返回video:66因为查询用了开区间Math.nextDown(76.1)从 76.1 往下但不含 76.1。第四步测边界。继续翻到最后一页curl -X GET http://localhost:8080/api/feed?userId10001size3cursoreyJ0ZWNoIjo0My4xfQ \ -H Content-Type: application/json预期返回{ list: [ {videoId: video:22, score: 32.0}, {videoId: video:11, score: 21.5} ], nextCursor: eyJ0ZWNoIjoyMS41fQ, hasMore: false }只剩两条hasMore为 false。再带这个 cursor 请求一次应该返回空列表{ list: [], nextCursor: , hasMore: false }第五步验证已看过滤。把video:101加入用户历史redis-cli SADD user:history:10001 video:101再发第一次请求返回里应该没有video:101但video:88和video:66还在。这说明 bucket 没被删只是用户态过滤生效了。第六步验证多 bucket 混合。再灌一个 bucketredis-cli ZADD bucket:finance 88.0 video:201 redis-cli ZADD bucket:finance 77.0 video:202 redis-cli ZADD bucket:finance 66.0 video:203默认 cursor 里加上 finance请求后返回会混合两个 bucket 的内容nextCursor 里也会有两个键。这一步验证的是 cursor 的 map 结构能正确承载多桶进度。实测下来这套骨架在本地单机 Redis 上单次请求 P99 在 2ms 以内翻到第 100 页和翻到第 1 页耗时基本一致因为ZREVRANGEBYSCORE不依赖 offset。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth配置和验证过程中最容易卡在几个报错上。我按真实遇到的顺序列出来。401 Unauthorized。这个最常见原因通常是 API Key 没填对或者 Base URL 拼错了。先检查 settings.json 里的apiKey是不是完整的sk-开头字符串有没有多余空格。再检查baseUrl是不是https://taotoken.net/api末尾有没有多斜杠。如果用的是 Claude Code检查~/.claude/settings.json里的字段名是不是apiKey而不是api_key。Cline 的 MCP 配置里Key 要放在env对象里不是顶层。Codex 的auth.json用的是api_key下划线命名跟其他工具不一样这个坑我踩过。local proxy failed。这个报错通常出现在你本地起了代理但代理没通或者工具配置里指向了一个不存在的本地端口。先确认你没有在 settings.json 里配proxy字段或者配了但端口不对。如果你确实需要走本地代理检查代理进程是否在监听。更常见的情况是环境变量HTTP_PROXY或HTTPS_PROXY设了一个失效的地址把它 unset 掉再试。注意这里说的是本地开发环境的网络配置问题不是让你去搞什么特殊通道就是把环境变量清理干净。reading choices 报错。这个通常出现在模型返回体解析阶段报错信息类似cannot read property choices of undefined。原因是返回的 JSON 里没有choices字段说明请求根本没成功返回的是一个错误对象。先看完整返回体如果是{error: {message: ...}}那就是上游返回了错误。常见原因是 Model ID 填错了比如填了一个不存在的模型名。去 console 里确认可用的 Model ID或者调/v1/models接口列一下。另一个原因是max_tokens设得太大超过了模型上限调小到 1024 再试。OAuth 相关报错。如果你用的是 Claude Code 或类似工具它可能默认走 OAuth 流程而不是 API Key。报错信息里会出现oauth字样。解决办法是在 settings.json 里显式配置apiKey并且把authType设为api_key。有些工具需要你在环境变量里设ANTHROPIC_API_KEY或OPENAI_API_KEY具体看工具文档。核心还是那三件套Base URL、API Key、Model ID三个都填对OAuth 报错就消失了。cursor 翻页重复或漏数据。这个不是网络报错但很常见。原因是查询用了闭区间而不是开区间。ZREVRANGEBYSCORE bucket (score -inf里的(不能省省了就会把上一批最后一条再取一次。另一个原因是 score 有浮点精度问题两个视频 score 相同翻页时可能跳过。解决办法是 score 里加一个微小的随机因子或者用 videoId 做二级排序。我在 score 计算时加了* 0.999 random * 0.001避免完全相等。hasMore 判断错误。如果nextCursor为空但hasMore还是 true说明 cursor 编码时把空 map 编成了非空字符串。检查CursorCodec.encode里对空 map 的处理空 map 应该返回空字符串。另外如果某个 bucket 取完了但其他 bucket 还有数据nextCursor里只保留还有数据的 bucket不要保留空 bucket。6. 语义一致 CTA把 Feed 骨架接到统一通道上继续跑骨架跑通之后下一步是把它接到真实的模型通道上。Feed 流里的 score 计算、标签生成、冷启动兜底都可以通过统一通道调模型来完成。你需要的是三个入口API Keys 管理页用来生成和轮换 Key接入文档用来查具体的请求格式和参数模型对话页用来快速验证模型是否可用。API Keys 在https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentfeed_zset_cursorutm_campaignrewrite生成后填进 settings.json 的apiKey字段。接入文档在https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentfeed_zset_cursorutm_campaignrewrite里面有 chat completions 的完整参数说明。想先验证模型通不通直接去模型对话页https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentfeed_zset_cursorutm_campaignrewrite发一条消息比写代码快。如果你打算长期做 Feed 相关的编码和 Agent 任务Coding Plan 在https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentfeed_zset_cursorutm_campaignrewrite适合需要持续调用模型的场景。控制台在https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentfeed_zset_cursorutm_campaignrewrite用来查看用量和调整配置。最后给一个实用技巧Feed 服务里调模型做 score 微调时不要把模型调用放在请求链路上。用异步任务提前算好写进 ZSET请求链路只读 Redis。这样即使模型通道偶尔抖动Feed 接口的 P99 也不会受影响。模型调用超时设 3 秒失败就降级用离线 score不要阻塞用户上滑。这套降级逻辑比任何复杂的推荐算法都更能保证系统不崩。