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/api,API 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 的StringRedisTemplate:
@Service 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(); Map<String, Double> cursorMap = CursorCodec.decode(req.getCursor()); List<String> videoIds = new ArrayList<>(); Map<String, Double> nextCursor = new HashMap<>(); for (Map.Entry<String, Double> entry : cursorMap.entrySet()) { String bucket = entry.getKey(); double lastScore = entry.getValue(); Set<ZSetOperations.TypedTuple<String>> 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.TypedTuple<String> t : tuples) { videoIds.add(t.getValue()); minScore = Math.min(minScore, t.getScore()); } nextCursor.put(bucket, minScore); } List<String> filtered = filterWatched(req.getUserId(), videoIds); List<VideoDTO> videos = videoService.batchGet(filtered); return new FeedResp( videos, CursorCodec.encode(nextCursor), !nextCursor.isEmpty() ); } private List<String> filterWatched(Long userId, List<String> ids) { if (ids.isEmpty()) return ids; Set<String> 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 Map<String, Double> decode(String cursor) { if (cursor == null || cursor.isEmpty()) { return defaultCursor(); } try { byte[] raw = Base64.getUrlDecoder().decode(cursor); return MAPPER.readValue(raw, new TypeReference<Map<String, Double>>() {}); } catch (Exception e) { return defaultCursor(); } } public static String encode(Map<String, Double> map) { try { byte[] raw = MAPPER.writeValueAsBytes(map); return Base64.getUrlEncoder().withoutPadding().encodeToString(raw); } catch (Exception e) { return ""; } } private static Map<String, Double> defaultCursor() { Map<String, 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 为空,服务端用默认 cursor(score 为Double.MAX_VALUE):
curl -X GET "http://localhost:8080/api/feed?userId=10001&size=3" \ -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?userId=10001&size=3&cursor=eyJ0ZWNoIjo3Ni4xfQ" \ -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?userId=10001&size=3&cursor=eyJ0ZWNoIjo0My4xfQ" \ -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 混合。再灌一个 bucket:
redis-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_source=taotoken_aicg_blog_end&utm_content=feed_zset_cursor&utm_campaign=rewrite,生成后填进 settings.json 的apiKey字段。接入文档在https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=feed_zset_cursor&utm_campaign=rewrite,里面有 chat completions 的完整参数说明。想先验证模型通不通,直接去模型对话页https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=feed_zset_cursor&utm_campaign=rewrite发一条消息,比写代码快。
如果你打算长期做 Feed 相关的编码和 Agent 任务,Coding Plan 在https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=feed_zset_cursor&utm_campaign=rewrite,适合需要持续调用模型的场景。控制台在https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=feed_zset_cursor&utm_campaign=rewrite,用来查看用量和调整配置。
最后给一个实用技巧:Feed 服务里调模型做 score 微调时,不要把模型调用放在请求链路上。用异步任务提前算好,写进 ZSET,请求链路只读 Redis。这样即使模型通道偶尔抖动,Feed 接口的 P99 也不会受影响。模型调用超时设 3 秒,失败就降级用离线 score,不要阻塞用户上滑。这套降级逻辑,比任何复杂的推荐算法都更能保证系统不崩。