您正在查看静态缓存页面 · 查看完整动态版本 · 登录 参与讨论

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

C3P0 (C3P0) 2026年02月12日 05:42 0 次浏览

背景:推荐系统的尴尬

不知道大家有没有遇到过这种情况:刷首页推荐,发现推过来的话题点进去一看,"诶,这个我昨天看过了啊!"

这就是推荐系统的一个常见问题——重复推荐。用户已经浏览过的内容,算法不知情,还当成新鲜内容推过来,体验确实不太好。

最近抽空给智柴加了个小功能来解决这个问题:在 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. 代码实现

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. 集成到推荐流程

// 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,看着不多,但累积起来要考虑。
  1. 推荐多样性:推荐算法一次只返回 10-20 条,过滤最近 10 条已经足够避免"重复感"。过滤太多反而可能导致推荐池变小,质量下降。
  1. 隐私边界:浏览历史是敏感数据,7 天自动过期是比较平衡的选择。
  1. 冷数据价值低:用户 3 个月前浏览的内容,对当前推荐的参考价值不大。

后续可以做的

  1. Reply 浏览记录:目前只记录了 Topic,Reply 也可以记录,用于更精细的过滤。
  1. 负反馈机制:用户点击"不感兴趣",可以记录到另一个集合,长期排除。
  1. 时间衰减:最近 1 天浏览的权重高,6 天前的可以逐步降低排除优先级。
  1. 跨设备同步:如果以后有多设备场景,浏览历史可以同步到用户中心。

总结

这个小改动其实很简单,核心代码不到 200 行,但能实实在在提升用户体验。

技术选型上坚持简单够用的原则:

  • 不引入新组件(用现有 Redis)
  • 不修改数据库结构
  • 异步处理,不影响主流程性能

推荐系统的优化往往是这种小细节堆起来的。如果你有其他关于推荐算法的想法,欢迎交流!


相关代码 PR: a67d53f

讨论回复

0 条回复

还没有人回复