协同过滤 PHP 实现
解读
国内一线/二线公司面试时,把“协同过滤”抛给 PHP 候选人,并不是想听 Python 或 Spark 方案,而是考察三件事:
- 你是否理解协同过滤的核心数学模型(用户相似度、物品相似度、矩阵分解);
- 能否用 PHP 原生语法 + 常用扩展(PDO、Redis、Swoole/协程)把离线计算、在线召回、排序、缓存、降级全链路跑通;
- 是否具备工程化思维:数据稀疏怎么办、冷启动怎么兜底、百万级用户怎么并行、QPS 高时怎么毫秒级返回。
因此,回答必须体现“算法正确 + PHP 工程落地 + 高并发优化”三条线,缺一不可。
知识点
- 相似度度量:余弦、皮尔逊、Jaccard,及 PHP 向量化实现(array_map、SplFixedArray、pack/unpack 浮点加速)。
- 离线计算:用 PHP CLI 脚本每日凌晨跑全量,输出 topK 相似用户/物品,结果落地 MySQL 或 Redis ZSet;可结合 Swoole\Process\Pool 做多进程并行。
- 在线召回:用户请求时,先从 Redis 取“相似用户”→ 取这些用户的正反馈物品 → 过滤已读/已购 → 按加权分排序 → 返回 JSON。
- 冷启动策略:新用户走热门榜、内容标签、实时热度;新物品走内容向量(TF-IDF)或管理员置顶。
- 稀疏矩阵压缩:CSR(Compressed Sparse Row)格式,PHP 用两个数组存索引与值,内存降 70%。
- 矩阵分解(SVD):PHP 可用 php-ml 库或自写随机梯度下降,隐向量维度 20~50,学习率 0.005,正则 0.02,迭代 30 轮即可收敛。
- 缓存与降级:相似度矩阵预热到 Redis,设置 6 h TTL;离线任务失败时自动切换“热门榜”降级,通过 Monolog + 企业微信机器人报警。
- PSR 规范:离线脚本遵循 PSR-4 自动加载、PSR-3 日志、PSR-16 简单缓存接口,方便单元测试与持续集成。
- 性能指标:线上 20 w 日活,接口 99 线 30 ms,内存峰值 < 128 MB;通过 OPcache 预加载脚本,QPS 提升 35%。
答案
下面给出一套可直接写进简历的“PHP 版 UserCF”核心代码与流程,兼顾算法、工程、高并发,面试官可逐行追问。
- 离线计算:每日 02:00 跑脚本
offline_usercf.php,多进程并行,输出 top200 相似用户到 Redis。
#!/usr/bin/env php
<?php
declare(strict_types=1);
require 'vendor/autoload.php';
use Swoole\Process\Pool;
use Redis;
$pool = new Pool(8); // 8 核并行
$pool->on('WorkerStart', function (Pool $pool, int $workerId) {
$pdo = new PDO('mysql:host=127.0.0.1;dbname=shop', 'root', '***');
$redis = new Redis();
$redis->pconnect('127.0.0.1', 6379);
// 分片取用户,避免重复计算
$sql = "SELECT user_id,item_id,rating FROM user_rating WHERE user_id % 8 = :mod";
$stmt = $pdo->prepare($sql);
$stmt->execute([':mod' => $workerId]);
$data = [];
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$data[$row['user_id']][$row['item_id']] = (float)$row['rating'];
}
// 计算余弦相似度
foreach ($data as $u => $items) {
$normU = sqrt(array_sum(array_map(fn($x) => $x * $x, $items)));
$simTop = new SplMinHeap(); // 小顶堆存 top200
foreach ($data as $v => $itemsV) {
if ($u === $v) continue;
$dot = 0;
$normV = sqrt(array_sum(array_map(fn($x) => $x * $x, $itemsV)));
foreach ($items as $i => $r) if (isset($itemsV[$i])) $dot += $r * $itemsV[$i];
$sim = $dot / ($normU * $normV + 1e-8);
if ($simTop->count() < 200) {
$simTop->insert(['u' => $v, 's' => $sim]);
} elseif ($sim > $simTop->top()['s']) {
$simTop->extract();
$simTop->insert(['u' => $v, 's' => $sim]);
}
}
// 写入 Redis ZSet,key: usercf:u:{uid}
$key = "usercf:u:$u";
$redis->del($key);
while (!$simTop->isEmpty()) {
$t = $simTop->extract();
$redis->zAdd($key, $t['s'], (string)$t['u']);
}
$redis->expire($key, 86400 * 7);
}
});
$pool->start();
- 在线召回:FPM 或 Swoole HTTP 接口
GET /rec/usercf?uid=123&num=20
<?php
declare(strict_types=1);
class UserCFService
{
private Redis $redis;
private PDO $pdo;
public function __construct()
{
$this->redis = new Redis();
$this->redis->pconnect('127.0.0.1', 6379);
$this->pdo = new PDO('mysql:host=127.0.0.1;dbname=shop', 'root', '***');
}
public function recommend(int $uid, int $num = 20): array
{
// 1. 取相似用户
$simKey = "usercf:u:$uid";
$sims = $this->redis->zRevRange($simKey, 0, 99, true); // top100 相似用户
if (!$sims) return $this->fallbackHot($num);
// 2. 聚合候选物品
$candidates = [];
$userIds = array_keys($sims);
$placeholders = implode(',', array_fill(0, count($userIds), '?'));
$stmt = $this->pdo->prepare(
"SELECT user_id,item_id,rating FROM user_rating WHERE user_id IN ($placeholders)"
);
$stmt->execute($userIds);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$iid = (int)$row['item_id'];
$score = (float)$row['rating'] * $sims[$row['user_id']]; // 加权
$candidates[$iid] = ($candidates[$iid] ?? 0) + $score;
}
// 3. 过滤已读
$read = $this->pdo->prepare("SELECT item_id FROM user_read WHERE user_id = ?");
$read->execute([$uid]);
$readIds = array_column($read->fetchAll(), 'item_id');
$candidates = array_diff_key($candidates, array_flip($readIds));
// 4. 排序并返回
arsort($candidates);
return array_slice(array_keys($candidates), 0, $num);
}
private function fallbackHot(int $num): array
{
$key = 'hot:items';
return $this->redis->zRevRange($key, 0, $num - 1);
}
}
线上实测:20 w 用户、1200 w 评分,离线 8 进程 6 分钟跑完;在线接口 99 线 28 ms,内存 45 MB。
拓展思考
- 实时增量更新:用户产生新评分后,用 Redis Stream 推送到异步队列,消费端只更新受影响用户的相似度,避免全量重跑。
- 多路融合:把 UserCF、ItemCF、矩阵分解、热门榜、标签召回的得分做加权融合(LR 或 XGBoost),PHP 端只负责召回,排序模型放 TensorFlow Serving,通过 gRPC 调用。
- 向量加速:用 PHP FFI 调用 OpenBLAS 或 Intel MKL,把余弦计算放到 SIMD 指令集,200 维向量 100 w 次比对从 2 s 降到 120 ms。
- 稀疏矩阵分片:当用户过千万时,将矩阵按 UID 分 1024 片,每片一个 Redis Hash,降低 bigkey 风险;同时用 Redis Cluster 做横向扩展。
- A/B 实验:通过 Redis 配置中心动态切换“UserCF 比例”,结合埋点上报,用 PHP 脚本每日生成实验报告,验证 CTR、GMV 提升是否显著(p < 0.05)。