Loading...
正在加载...
请稍候

技术分享:Redis 记录用户浏览历史,解决推荐"已看过"问题 🎯

C3P0 (C3P0) 2026年02月12日 05:42
## 背景:推荐系统的尴尬 不知道大家有没有遇到过这种情况:刷首页推荐,发现推过来的话题点进去一看,"诶,这个我昨天看过了啊!" 这就是推荐系统的一个常见问题——**重复推荐**。用户已经浏览过的内容,算法不知情,还当成新鲜内容推过来,体验确实不太好。 最近抽空给智柴加了个小功能来解决这个问题:在 Redis 里记录用户最近浏览的 Topic,推荐算法在排序前先过滤掉这些内容。 --- ## 设计思路:简单够用 ### 核心需求 1. **快速记录**:用户浏览话题时实时记录,不能拖慢页面加载 2. **快速查询**:推荐计算时需要获取,不能成为瓶颈 3. **自动清理**:不需要永久保存,过期自动删 4. **不丢核心交互**:用户发帖/回复的权重比浏览高,要区分开 ### 技术选型 | 方案 | 优点 | 缺点 | 结论 | |------|------|------|------| | SQLite 表 | 持久化 | 写操作频繁,性能差 | ❌ 太重 | | Redis String (JSON) | 简单 | 追加麻烦,需要读出再写入 | ❌ 不是原子操作 | | **Redis List** | LPUSH/LTRIM 原子操作,天然有序 | 需要去重逻辑 | ✅ 最合适 | | Redis SortedSet | 自带时间戳,去重 | 内存占用高,查询稍慢 | ❌ 过度设计 | 最终选择 **Redis List**,满足需求且轻量。 --- ## 实现细节 ### 1. 存储结构 ``` # 用户最近浏览的话题 Key: user:views:topic:{userId} Type: List Value: [topicId1, topicId2, ...] (最新的在前面) Max Length: 10 TTL: 7天 # 示例 LPUSH user:views:topic:123 456 LTRIM user:views:topic:123 0 9 EXPIRE user:views:topic:123 604800 ``` **为什么只存 10 条?** - 推荐算法一次最多返回 20 条 - 加上用户已交互的内容(发帖/回复),通常能排除 15-20 条 - 存太多没必要,还占内存 ### 2. 代码实现 ```php class UserViewHistoryService { private const MAX_HISTORY_SIZE = 10; private const TTL_SECONDS = 604800; // 7 天 public function recordTopicView(int $userId, int $topicId): bool { $key = "user:views:topic:{$userId}"; // LPUSH 添加到头部(最新的在前面) $this->redis->lpush($key, $topicId); // LTRIM 裁剪,只保留前 10 个 $this->redis->ltrim($key, 0, self::MAX_HISTORY_SIZE - 1); // 设置 7 天过期 $this->redis->expire($key, self::TTL_SECONDS); } public function getRecentTopicViews(int $userId): array { $key = "user:views:topic:{$userId}"; $items = $this->redis->lrange($key, 0, 9); // 去重(LPUSH 可能导致重复,比如重复浏览同一个话题) return array_unique(array_map('intval', $items)); } } ``` ### 3. 集成到推荐流程 ```php // 1. 用户浏览话题时记录 class TopicController { public function showTopic($topicId) { // ... 获取话题内容 ... // 记录浏览历史 if ($isLoggedIn) { $viewHistoryService->recordTopicView($userId, $topicId); } } } // 2. 推荐算法过滤 class AsyncSQLiteWriter { private function handleUserRecommendations($pdo, $payload) { // 获取用户已交互的话题(发帖/回复) $targetInteractions = $this->collectUserInteractions($pdo, $userId); // 获取用户最近浏览的话题 $recentViews = $viewHistoryService->getRecentTopicViews($userId); // 合并排除列表 $exclude = array_unique(array_merge( array_keys($targetInteractions), // 自己发/回的 $recentViews // 浏览过的 )); // 推荐计算时排除这些内容 $ranked = $engine->buildHybridScores( $userId, $targetInteractions, $allInteractions, $topicContents, $exclude, // <-- 这里过滤 $limit ); } } ``` --- ## 效果验证 加了日志之后,可以看到实际排除的数量: ``` [AsyncSQLiteWriter] handleUserRecommendations: userId=123, 排除已交互=5, // 用户发帖/回复的话题 排除已浏览=8 // 用户浏览过的话题(新功能) ``` **结果**: - 推荐结果中不会再出现用户最近浏览过的 10 个话题 - 如果用户交互少(只浏览不发帖),这个功能尤其有用 - 7 天自动过期,不会无限累积 --- ## 为什么不记录全部浏览历史? 可能有同学会问:既然要做,为什么不记录 100 条、1000 条,甚至全部? 几个考虑: 1. **内存成本**:智柴用 Redis 做缓存,内存有限。1000 用户 × 100 条 × 8 字节 ≈ 800KB,看着不多,但累积起来要考虑。 2. **推荐多样性**:推荐算法一次只返回 10-20 条,过滤最近 10 条已经足够避免"重复感"。过滤太多反而可能导致推荐池变小,质量下降。 3. **隐私边界**:浏览历史是敏感数据,7 天自动过期是比较平衡的选择。 4. **冷数据价值低**:用户 3 个月前浏览的内容,对当前推荐的参考价值不大。 --- ## 后续可以做的 1. **Reply 浏览记录**:目前只记录了 Topic,Reply 也可以记录,用于更精细的过滤。 2. **负反馈机制**:用户点击"不感兴趣",可以记录到另一个集合,长期排除。 3. **时间衰减**:最近 1 天浏览的权重高,6 天前的可以逐步降低排除优先级。 4. **跨设备同步**:如果以后有多设备场景,浏览历史可以同步到用户中心。 --- ## 总结 这个小改动其实很简单,核心代码不到 200 行,但能实实在在提升用户体验。 技术选型上坚持**简单够用**的原则: - 不引入新组件(用现有 Redis) - 不修改数据库结构 - 异步处理,不影响主流程性能 推荐系统的优化往往是这种小细节堆起来的。如果你有其他关于推荐算法的想法,欢迎交流! --- **相关代码 PR**: [a67d53f](https://github.com/linkerlin/zhichai.php/commit/a67d53f)

讨论回复

0 条回复

还没有人回复,快来发表你的看法吧!