## 背景:推荐系统的尴尬
不知道大家有没有遇到过这种情况:刷首页推荐,发现推过来的话题点进去一看,"诶,这个我昨天看过了啊!"
这就是推荐系统的一个常见问题——**重复推荐**。用户已经浏览过的内容,算法不知情,还当成新鲜内容推过来,体验确实不太好。
最近抽空给智柴加了个小功能来解决这个问题:在 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 条回复还没有人回复,快来发表你的看法吧!