三年前接手一个音乐平台项目时,我面临着一个典型的技术矛盾:业务方要求两周内上线推荐功能,但团队既没有大数据开发经验,也没有推荐算法工程师。最终我们用ThinkPHP+协同过滤的组合,72小时就实现了第一版推荐系统。这个看似"不专业"的方案,后来支撑了日均50万次的推荐请求,今天就来拆解这个实战案例。
这个方案的核心价值在于:用最精简的技术栈(PHP+MySQL)实现工业级推荐效果。不需要Hadoop/Spark等大数据组件,不需要实时计算框架,甚至不需要Python——这对中小型音乐站点、个人开发者或教学演示场景特别友好。系统架构上分为三个关键层:
关键提示:虽然本文以音乐推荐为例,但同样的架构稍作修改即可用于电商、新闻等内容推荐场景
在2019年的技术环境下,我们选择ThinkPHP5.1主要基于以下考量:
具体到版本选择,需要注意:
php复制// 必须关闭调试模式以获得最佳性能
'app_debug' => false,
// 开启路由缓存减少开销
'url_route_must' => true,
'route_check_cache' => true,
我们测试了三种算法变体的效果:
| 算法类型 | 准确率 | 计算耗时 | 冷启动问题 | 实现复杂度 |
|---|---|---|---|---|
| 用户协同过滤 | 68% | 较高 | 严重 | 低 |
| 物品协同过滤 | 72% | 中等 | 较轻 | 中 |
| 混合协同过滤 | 75% | 高 | 中等 | 高 |
最终选择物品协同过滤(ItemCF)的原因:
核心表结构设计(MySQL5.7):
sql复制CREATE TABLE `user_behavior` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL COMMENT '用户ID',
`music_id` int(11) NOT NULL COMMENT '歌曲ID',
`behavior_type` tinyint(4) NOT NULL COMMENT '1播放 2收藏 3分享',
`weight` float DEFAULT '1.0' COMMENT '行为权重',
`create_time` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_music` (`user_id`,`music_id`),
KEY `idx_music` (`music_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `music_similarity` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`music_id1` int(11) NOT NULL,
`music_id2` int(11) NOT NULL,
`similarity` float NOT NULL,
`update_time` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_music_pair` (`music_id1`,`music_id2`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
重要设计:behavior_type权重配置
- 完整播放=1.2
- 收藏=1.5
- 分享=2.0
- 跳过=0.3
物品相似度计算(PHP实现):
php复制public function calculateItemSimilarity()
{
// 获取所有物品共现矩阵
$cooccurrence = [];
$userItems = $this->getUserItemMatrix();
foreach ($userItems as $userId => $items) {
foreach ($items as $item1) {
foreach ($items as $item2) {
if ($item1 == $item2) continue;
$cooccurrence[$item1][$item2] = ($cooccurrence[$item1][$item2] ?? 0) + 1;
}
}
}
// 计算余弦相似度
$similarities = [];
$itemPopularity = $this->getItemPopularity();
foreach ($cooccurrence as $item1 => $relatedItems) {
foreach ($relatedItems as $item2 => $count) {
$similarity = $count / sqrt($itemPopularity[$item1] * $itemPopularity[$item2]);
$similarities[$item1][$item2] = $similarity;
}
}
return $similarities;
}
实时推荐接口核心代码:
php复制public function recommend($userId, $limit = 10)
{
// 获取用户最近交互的20个物品
$userItems = $this->getUserRecentItems($userId, 20);
$recommendations = [];
foreach ($userItems as $item) {
$similarItems = $this->getSimilarItems($item['music_id'], 5);
foreach ($similarItems as $similarItem) {
$recommendations[$similarItem['music_id']] =
($recommendations[$similarItem['music_id']] ?? 0)
+ $similarItem['similarity'] * $item['weight'];
}
}
// 过滤已听过的歌曲并按得分排序
$allHistory = $this->getUserAllItems($userId);
$recommendations = array_diff_key($recommendations, array_flip($allHistory));
arsort($recommendations);
return array_slice(array_keys($recommendations), 0, $limit);
}
将相似度计算拆分为可并行的子任务:
bash复制# 使用Linux crontab分时计算不同类别音乐
0 3 * * * /usr/bin/php /app/calculate.php --type=1 --batch=1/4
30 3 * * * /usr/bin/php /app/calculate.php --type=1 --batch=2/4
# ...其余批次
采用三级缓存架构:
php复制// Redis缓存配置示例
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$cacheKey = "rec:{$userId}";
if ($redis->exists($cacheKey)) {
return json_decode($redis->get($cacheKey), true);
}
sql复制ALTER TABLE user_behavior PARTITION BY RANGE (TO_DAYS(create_time)) (
PARTITION p_2023 VALUES LESS THAN (TO_DAYS('2024-01-01')),
PARTITION p_2024 VALUES LESS THAN (MAXVALUE)
);
sql复制EXPLAIN SELECT music_id FROM user_behavior
WHERE user_id=123 AND create_time > DATE_SUB(NOW(), INTERVAL 30 DAY);
我们采用的混合方案:
php复制public function getHotItems($genre = null)
{
$cacheKey = 'hot_items_' . ($genre ?? 'all');
if (!$result = Cache::get($cacheKey)) {
$query = Music::where('status', 1)
->orderBy('play_count', 'desc');
if ($genre) {
$query->where('genre', $genre);
}
$result = $query->limit(500)
->pluck('id')
->toArray();
// 按播放量加权随机
$weights = array_map(function($id) {
return Music::find($id)->play_count;
}, $result);
Cache::put($cacheKey, [$result, $weights], 3600);
}
return $this->weightedRandom($result[0], $result[1], 10);
}
解决方案:
php复制// 在计算相似度时加入平滑因子
$similarity = ($count + 1) / sqrt(($itemPopularity[$item1] + 5) * ($itemPopularity[$item2] + 5));
我们的优化路径:
php复制// 定义不同行为的实时性要求
$realtimeLevels = [
'share' => 1, // 立即触发更新
'collect' => 2, // 5分钟内更新
'play' => 3 // 次日更新
];
我们构建的评估体系:
| 指标 | 计算公式 | 达标值 |
|---|---|---|
| 推荐准确率 | 点击推荐项/总推荐数 | >65% |
| 覆盖率 | 被推荐歌曲数/总歌曲数 | >40% |
| 新颖度 | 推荐歌曲平均播放量百分位 | <30% |
| 多样性 | 推荐列表风格熵值 | >1.5 |
在ThinkPHP中实现的分桶测试:
php复制public function getRecommendVersion($userId)
{
// 用户分桶算法
$bucket = crc32($userId) % 100;
if ($bucket < 30) {
return 'v1'; // 原始算法
} elseif ($bucket < 60) {
return 'v2'; // 加入时间衰减
} else {
return 'v3'; // 混合内容特征
}
}
关键参数优化记录:
php复制// 时间衰减实现示例
public function applyTimeDecay(&$userItems)
{
$now = time();
foreach ($userItems as &$item) {
$days = ($now - strtotime($item['create_time'])) / 86400;
$item['weight'] *= pow(0.95, $days);
}
}
最低配置要求:
我们的实际部署架构:
code复制 +-----------------+
| 负载均衡(Nginx) |
+--------+--------+
|
+----------------+----------------+
| |
+----------+----------+ +----------+----------+
| Web服务器1 | | Web服务器2 |
| - PHP-FPM | | - PHP-FPM |
| - Redis缓存 | | - Redis缓存 |
+----------+----------+ +----------+----------+
| |
+----------------+----------------+
|
+--------+--------+
| 数据库MySQL |
| - 主从复制 |
+-----------------+
使用JMeter测试结果:
| 并发用户数 | 平均响应时间 | 错误率 | 吞吐量 |
|---|---|---|---|
| 100 | 23ms | 0% | 428/s |
| 500 | 67ms | 0% | 1,240/s |
| 1000 | 142ms | 0.2% | 1,850/s |
| 2000 | 318ms | 1.5% | 2,100/s |
优化技巧:开启OPCache后,吞吐量提升40%
ini复制opcache.enable=1
opcache.memory_consumption=128
opcache.max_accelerated_files=4000
opcache.revalidate_freq=60
当前实现的增强方案:
php复制public function hybridRecommend($userId)
{
$cfItems = $this->cfRecommend($userId, 15);
$cbItems = $this->contentBasedRecommend($userId, 15);
// 合并策略
$merged = [];
foreach ($cfItems as $item) {
$merged[$item] = ($merged[$item] ?? 0) + 1;
}
foreach ($cbItems as $item) {
$merged[$item] = ($merged[$item] ?? 0) + 0.8;
}
arsort($merged);
return array_slice(array_keys($merged), 0, 10);
}
日志分析流水线:
监控体系搭建:
bash复制# 示例监控指标
recommend_api_latency_seconds{endpoint="/recommend"} 0.12
recommend_accuracy{type="item_cf"} 0.68
user_behavior_total{action="play"} 42341
引入时间衰减因子:
php复制$timeWeight = 1 / (1 + log(1 + $daysSinceEvent));
基于矩阵分解的改进:
python复制# 使用Python扩展处理大数据量
from surprise import SVD
algo = SVD(n_factors=20, n_epochs=10)
algo.fit(trainset)
图神经网络探索:
python复制import torch
class GNN(torch.nn.Module):
def __init__(self):
super(GNN, self).__init__()
self.conv1 = GCNConv(dataset.num_features, 16)
这套系统经过三年迭代仍在稳定运行,日均处理推荐请求超过80万次。最大的体会是:合适的架构比先进的技术更重要。对于大多数中小型音乐平台,用ThinkPHP+协同过滤的组合完全能够满足业务需求,关键是要根据实际场景做好工程优化和参数调校