各位来宾,大家好!
欢迎来到今天的讲座现场。我是你们的老朋友,一个虽然头发还在,但发际线正在思考人生高度的资深PHP程序员。
今天我们要聊一个话题,这话题听起来就像是某种来自未来的科幻电影——“高性能用户推荐算法与实时兴趣画像系统”。听起来很高大上,对吧?仿佛只要一敲代码,你的网站就能变成一个穿着紧身衣、在纽约街头飞来飞去的超级英雄,自动识别哪个用户想买鞋,哪个用户想喝奶茶。
但别急,今天我们不谈虚的。我们要用PHP,用这门曾经被认为只能写“面包店网站”的语言,来构建一套真正的、高并发、低延迟的推荐系统。
为什么选PHP?因为PHP最容易上手,就像炒菜用的油,哪里需要哪里放。但如果我们把PHP的底子掀开,你会发现它其实是个潜力股,尤其是在用了Swoole、ReactPHP这些“黑科技”之后。
准备好了吗?让我们把空调温度调低一点,开始这场关于“如何让你的用户画像比他还了解他自己”的技术盛宴。
第一章:打破刻板印象,PHP也能飞上天
在开始之前,我得先给在座的各位“PHP老将”和“Java/C++新贵”们松个绑。
长久以来,PHP背负着一个沉重的骂名:“PHP慢,PHP吃资源,PHP只能做中小型网站。” 哎哟,这帽子扣得真大。其实,PHP慢是因为它默认是“同步阻塞”的。就像你一个人去快餐店点餐、取餐、收拾桌子,后面排着长队,你想不慢都难。
但在现代高性能PHP的世界里,我们有了协程和非阻塞IO。这就好比我们雇了一群“超级服务员”。
想象一下,你是一个大厨(PHP程序),你的厨房(服务器)里有一个专门的传送带(网络连接)。以前,你端一盘菜出来,要盯着它,直到顾客吃完(请求结束),这期间你什么都干不了。现在,我们用了Swoole,你把菜放在传送带上,告诉它“你去端给第1桌”,然后你转身去切萝卜(处理下一个请求)。等第1桌反馈说“好吃,再来一份”,你再回头处理。
这就是异步非阻塞。
我们要构建的推荐系统,就是基于这种高性能的PHP环境。如果还在用传统的mysql_query加sleep(1)去算推荐,那你的系统还没上线,服务器就先因为高并发“烧”了。
第二章:什么是“实时兴趣画像”?(用户画像的“灵魂画手”)
在写代码之前,我们先得明白,什么是“画像”。
很多人以为画像就是把用户的ID存进数据库。不,那叫“存档”,不叫“画像”。
画像,是对用户特征的数字化描述,就像给用户画一幅“像素画”。我们需要捕捉用户的每一个微小动作:点击了哪个按钮、停留了多久、搜索了什么关键词、甚至鼠标在屏幕上划过的轨迹。
为了高性能,我们不能把这些数据存进MySQL。为什么?因为MySQL在处理海量并发写入时,就像是一辆满载货物的卡车在爬坡,费劲得很。我们要用Redis,利用它的内存特性,把它当成一个超高速的缓存库。
2.1 画像的结构设计
在我们的PHP高性能架构中,用户画像通常由几个核心部分组成:
- 基础属性:性别、年龄、地区(静态数据,存一次,读多次)。
- 行为数据:最近点击的商品ID、浏览过的标签。
- 兴趣向量:这是推荐算法的核心。我们需要把用户的兴趣转换成一个数组,比如
[ '篮球' => 0.9, '编程' => 0.8, '猫' => 0.5 ]。这个数值代表了兴趣的强度。
2.2 代码示例:画像的实时更新
假设用户小王刚刚在手机上点击了一款“赛博朋克2077”的游戏。我们需要在PHP中捕获这个动作,并实时更新他的画像。
这里我们使用PHP的SwooleRuntime来开启协程模式,配合Redis扩展。
<?php
use SwooleCoroutine as Co;
// 模拟开启协程环境
Co::set(['hook_flags' => SWOOLE_HOOK_ALL]);
class UserProfileService
{
private $redis;
public function __construct()
{
// 连接Redis,这里使用协程Redis,性能极高
$this->redis = new Redis();
$this->redis->connect('127.0.0.1', 6379, 0.5); // 第三个参数是超时时间
}
/**
* 处理用户行为并更新画像
* @param int $userId 用户ID
* @param string $actionType 行为类型,如 'click', 'buy'
* @param string $itemId 物品ID
*/
public function handleUserAction(int $userId, string $actionType, string $itemId)
{
echo "用户 {$userId} 发生了 {$actionType} 行为,物品是 {$itemId}n";
// 1. 获取用户当前的画像Hash
// Redis Hash: 用户的兴趣向量
$profileKey = "user:profile:{$userId}";
// 2. 增加行为权重
// 我们可以用一个简单的策略:点击+1分,购买+10分
$scoreIncrement = $actionType === 'buy' ? 10 : 1;
// 在Redis中执行原子操作,既获取又更新
$this->redis->hIncrBy($profileKey, $itemId, $scoreIncrement);
// 3. 记录最近行为(用于做时效性过滤,比如只推荐5分钟内的点击)
$historyKey = "user:history:{$userId}";
$this->redis->lPush($historyKey, json_encode([
'item' => $itemId,
'time' => microtime(true)
]));
// 只保留最近100条行为
$this->redis->lTrim($historyKey, 0, 99);
// 4. 增加全局热度(可选,用于热门推荐)
$hotKey = "global:hot";
$this->redis->zIncrBy($hotKey, $scoreIncrement, $itemId);
echo "画像更新完成,用户 {$userId} 现在的物品 {$itemId} 权重提升了 {$scoreIncrement}。n";
}
}
// 模拟高并发场景:1万个用户同时点击商品
Corun(function () {
$service = new UserProfileService();
// 并发1万次请求
for ($i = 1; $i <= 10000; $i++) {
go(function () use ($service, $i) {
// 随机生成用户和商品
$userId = rand(1, 10000);
$itemId = "item_" . rand(1, 100);
// 异步处理
$service->handleUserAction($userId, 'click', $itemId);
});
}
});
专家点评:
看,上面的代码是不是很顺滑?这就是PHP协程的威力。在传统同步PHP中,你需要写循环和sleep来模拟并发,耗时至少几秒钟。而这段代码,1万次并发只需要几百毫秒,而且你的Redis数据库压力非常小。这就是高性能画像系统的基石。
第三章:推荐算法——从“猜你喜欢”到“算命大师”
画像建好了,接下来就是最刺激的部分:推荐算法。
如果你是初学者,可能会去写几十个if-else,比如:
如果用户喜欢A,且性别为男,且年龄大于18,且……
那么推荐B。
这叫规则推荐,容易维护但太死板。我们今天要玩的是协同过滤。
3.1 基于物品的协同过滤(Item-based CF)
这是目前电商领域最成熟的算法。逻辑很简单:“物以类聚,人以群分”。
如果你的画像显示你最近喜欢了“iPhone 15”,而系统发现有1000个用户在喜欢你这个商品的同时,也喜欢了“AirPods Pro”,那么算法就会告诉你:“嘿,这哥们也买了耳机,你买不买?”
3.2 向量相似度计算(余弦相似度)
数学上,我们要计算两个物品向量之间的夹角。余弦相似度公式是:
$$Similarity(A, B) = frac{A cdot B}{||A|| times ||B||}$$
在PHP里写这个公式并不难,难的是怎么让它在高并发下跑得飞快。
3.3 优化策略:Lua脚本与Redis ZSet
直接在PHP里写双重循环去算相似度?别闹了,那就像是用算盘去造火箭,CPU能干到你怀疑人生。
我们的策略是“离线计算 + 实时检索”。
- 每天晚上12点,有一批Python工人(或者后台任务),算好所有商品之间的相似度矩阵,存到Redis里。
- 用户在App上点击商品A时,我们直接去Redis查“与A相似的商品B”,然后按权重排序。
这里,我们需要用到Redis的有序集合,它能以O(log N)的时间复杂度进行排序,简直是高性能推荐的神器。
3.4 代码示例:基于Redis ZSet的推荐引擎
假设我们已经有一个预先计算好的相似度表 item:similarity_matrix,现在我们要实时为用户返回Top 10推荐。
<?php
use SwooleCoroutine as Co;
class RecommendEngine
{
private $redis;
public function __construct()
{
$this->redis = new Redis();
$this->redis->connect('127.0.0.1', 6379, 0.5);
}
/**
* 获取用户的实时推荐列表
* @param int $userId
* @param int $limit 返回数量
*/
public function getRecommendations(int $userId, int $limit = 10)
{
// 1. 获取用户当前最感兴趣的商品(画像里的最高分商品)
$profileKey = "user:profile:{$userId}";
$items = $this->redis->hGetAll($profileKey);
if (empty($items)) {
return []; // 没有画像数据,全站热门推荐
}
// 2. 提取物品ID,准备查询相似度
// 假设我们要找用户最感兴趣的物品
$maxItem = array_keys($items, max($items))[0];
$simKey = "item:similarity:{$maxItem}";
// 3. 使用ZREVRANGE获取相似度最高的物品(Redis会自动按分数排序)
// range 1-10 表示取第1到第10名(排除自己)
$candidates = $this->redis->zRevRange($simKey, 1, $limit);
if (empty($candidates)) {
return [];
}
// 4. 构建推荐结果
$result = [];
foreach ($candidates as $candidateId) {
// 获取候选物品的相似度分数
$score = $this->redis->zScore($simKey, $candidateId);
// 增加一点用户画像的动态权重(可选,让推荐更个性化)
// 比如,如果用户已经买过这个候选物品,就不推荐了
if ($this->redis->hExists($profileKey, $candidateId)) {
$score = $score * 0.5; // 打个折,避免重复推荐
}
$result[] = [
'item_id' => $candidateId,
'similarity_score' => round($score, 4)
];
}
return $result;
}
}
// 测试一下
Corun(function () {
$engine = new RecommendEngine();
// 假设用户1001喜欢了iPhone,我们查推荐
$recommendations = $engine->getRecommendations(1001, 5);
echo "为用户生成的推荐列表:n";
print_r($recommendations);
});
专家点评:
看到了吗?这一步计算几乎是瞬间完成的。我们的算法核心其实只是把数据从Redis的Hash里拿出来,再扔进一个ZSet里取个前几名。
但是!各位同学,痛点来了。
如果用户画像中喜欢的商品有1000个怎么办?我们每次都只取一个最感兴趣的物品去查相似度?这不够精准。
进阶优化:加权聚合
我们需要一个更聪明的逻辑。不要只看一个商品,我们要看用户画像里的所有商品。
我们可以利用Redis的 Pipeline(管道) 技术或者 Lua 脚本,把用户的画像Hash里的所有Item ID取出来,然后一次性查询每个Item对应的Top N相似度,最后在PHP端做一个简单的聚合(比如取分数最高的那些)。
这就像是你去菜市场买菜,不要只问一个摊贩(单一商品推荐),而是把所有摊贩的清单拿过来(用户所有兴趣),然后挑出你最喜欢的菜(聚合推荐)。
下面是一个使用Lua脚本实现原子化查询的例子。Lua脚本在Redis服务端执行,避免了网络往返,这是极致性能的体现。
3.5 极致性能版:Lua脚本实现用户画像聚合推荐
这个脚本接收用户的画像Key,获取所有物品ID,查询相似度,并返回结果。
-- Lua脚本:UserBasedAggregatedRecommendation.lua
-- KEYS[1]: 用户画像Key (user:profile:1001)
-- ARGV[1]: 相似度矩阵前缀 (item:similarity:)
-- ARGV[2]: 返回的Top数量
local profileKey = KEYS[1]
local simPrefix = ARGV[1]
local topN = tonumber(ARGV[2])
local profile = redis.call('hgetall', profileKey)
local results = {}
-- 遍历用户的画像Hash
for i = 1, #profile, 2 do
local itemId = profile[i]
-- 获取该物品的相似度集合
local simKey = simPrefix .. itemId
local similarItems = redis.call('zrevrange', simKey, 0, topN, 'WITHSCORES')
-- 处理返回的数据 (similarItems是 interleaved 的 id, score, id, score...)
for j = 1, #similarItems, 2 do
local candidateId = similarItems[j]
local score = tonumber(similarItems[j+1])
if results[candidateId] then
-- 如果已经存在,累加分数
results[candidateId] = results[candidateId] + score
else
results[candidateId] = score
end
end
end
-- 排序并取Top N
local sorted = {}
for k, v in pairs(results) do
table.insert(sorted, {k, v})
end
table.sort(sorted, function(a, b) return a[2] > b[2] end)
local finalResult = {}
for i = 1, math.min(topN, #sorted) do
table.insert(finalResult, {sorted[i][1], sorted[i][2]})
end
return finalResult
然后在PHP中调用:
// PHP代码调用Lua脚本
$luaScript = file_get_contents('UserBasedAggregatedRecommendation.lua');
$profileKey = "user:profile:1001";
$simPrefix = "item:similarity:";
$topN = 10;
// 传入参数,注意key只有1个
$result = $this->redis->eval($luaScript, 1, $profileKey, $simPrefix, $topN);
echo "高性能Lua脚本推荐结果:n";
print_r($result);
专家点评:
怎么样?是不是很优雅?用户画像里的几千个商品,通过这一行eval,在Redis服务端就完成了复杂的数学运算。整个过程只有一次网络IO。这就是架构之美。
第四章:构建高可用架构——从单机到分布式
现在,我们有了高性能的画像更新(Swoole + Redis)和高性能的推荐查询(Lua + Redis)。但是,作为一个资深的架构师,我们不能只盯着代码,还得看系统。
4.1 负载均衡与数据分片
如果用户量到了100万,Redis单机扛不住怎么办?我们需要集群。
对于画像数据,我们可以使用一致性哈希或者按用户ID分片。
比如,取用户ID的哈希值的最后一位,是1-5的用户存Redis实例A,6-0的用户存Redis实例B。
对于相似度矩阵,因为数据量巨大(几千万商品),通常不会存Redis。我们会把相似度矩阵存到专门的分布式KV数据库(如Tair、Cassandra)或者用离线计算的结果做大规模缓存。
4.2 消息队列削峰填谷
用户行为数据(点击、浏览)是海量的。
如果在页面点击的瞬间,每一笔操作都直接连数据库,那数据库会死机。我们的做法是:
- Web服务器 (Nginx) 接收请求。
- Swoole 协程服务 接收请求,将用户行为封装成JSON。
- 写入消息队列:将JSON推送到 RabbitMQ 或 Kafka。
- 后台消费者:有专门的PHP进程在后台慢慢从队列取数据,更新Redis画像。
专家点评:
这就像高速公路上的收费站。如果全是车,收费站(数据库)会堵死。但是,我们架了一个收费站入口(队列),让车排好队慢慢进,收费站门口有一堆收费员(PHP协程)在后面慢慢处理。
4.3 服务拆分与缓存
最后,我们的推荐系统最好是一个独立的服务,对外提供RESTful API。
- 前端:用户点击商品 -> 调用推荐服务API -> 返回JSON。
- 推荐服务:缓存用户的画像(TTL设为5分钟,平衡实时性和内存占用) -> 查询算法 -> 返回结果。
第五章:实战演练——一个完整的Swoole服务
最后,让我们把上面所有的东西串起来,写一个“胖尾推荐服务器”。
这个服务器有两个功能:
- 暴露一个WebSocket接口,监听用户的实时行为。
- 定时任务,每隔5分钟,重新计算一次用户的推荐结果(模拟离线更新)。
<?php
use SwooleServer;
use SwooleCoroutine as Co;
use SwooleTimer;
require_once 'vendor/autoload.php';
// 引入之前写的逻辑类
require_once 'UserProfileService.php';
require_once 'RecommendEngine.php';
class RecommendationServer
{
private $server;
private $profileService;
private $engine;
public function __construct($host, $port)
{
// 初始化Swoole服务器(支持WebSocket)
$this->server = new Server($host, $port, SWOOLE_PROCESS, SWOOLE_SOCK_TCP);
// 设置运行参数
$this->server->set([
'worker_num' => 4, // 进程数
'task_worker_num' => 4, // 异步任务进程数
'log_file' => '/tmp/swoole.log',
'max_request' => 5000, // 防止内存泄漏,每5000个请求重启worker
]);
// 注册事件
$this->server->on('connect', [$this, 'onConnect']);
$this->server->on('receive', [$this, 'onReceive']);
$this->server->on('task', [$this, 'onTask']);
$this->server->on('finish', [$this, 'onFinish']);
$this->server->on('close', [$this, 'onClose']);
// 初始化服务
$this->profileService = new UserProfileService();
$this->engine = new RecommendEngine();
// 启动定时器,每300秒(5分钟)做一次全局画像重算(模拟离线任务)
Timer::after(3000, [$this, 'recomputeGlobalProfiles']);
}
public function start()
{
echo "推荐服务器正在启动,监听 {$this->server->host}:{$this->server->port}...n";
$this->server->start();
}
public function onConnect($server, $fd)
{
echo "Client #{$fd} 已连接。n";
}
public function onReceive($server, $fd, $reactorId, $data)
{
// 1. 解析客户端发来的JSON数据
$json = json_decode($data, true);
if (!$json) {
$server->send($fd, "Invalid JSON");
return;
}
// 2. 处理用户行为
// 假设客户端发送的是 { "user_id": 1001, "item_id": "phone", "action": "click" }
$userId = $json['user_id'];
$itemId = $json['item_id'];
$action = $json['action'] ?? 'click';
// 异步处理画像更新,不阻塞客户端响应
$server->task([
'type' => 'update_profile',
'user_id' => $userId,
'item_id' => $itemId,
'action' => $action
]);
// 3. 实时返回一个推荐结果(稍微延迟一点,模拟计算)
go(function () use ($server, $fd, $userId) {
Co::sleep(0.1); // 模拟一点计算延迟
$recommendations = $this->engine->getRecommendations($userId, 5);
$response = [
'user_id' => $userId,
'timestamp' => time(),
'recommendations' => $recommendations
];
$server->send($fd, json_encode($response));
});
echo "已接收用户 {$userId} 的行为,任务已入队。n";
}
public function onTask($server, $taskId, $reactorId, $data)
{
if ($data['type'] === 'update_profile') {
// 在Task进程中执行画像更新逻辑
$this->profileService->handleUserAction(
$data['user_id'],
$data['action'],
$data['item_id']
);
}
return "Task Finished";
}
public function onFinish($server, $taskId, $data)
{
echo "Task #{$taskId} 完成。n";
}
public function onClose($server, $fd)
{
echo "Client #{$fd} 已断开连接。n";
}
// 模拟离线任务:重新计算全局热门
public function recomputeGlobalProfiles()
{
echo "开始执行离线画像重算任务...n";
// 这里可以写逻辑,遍历Redis中的热门商品,重新计算向量
// 为了演示,我们只打印一下
$this->profileService->redis->zRevRange("global:hot", 0, 10, true);
echo "离线画像重算任务完成。n";
// 递归调用,形成循环任务
Timer::after(3000, [$this, 'recomputeGlobalProfiles']);
}
}
// 启动服务
$server = new RecommendationServer('0.0.0.0', 9501);
$server->start();
结语
各位,今天的讲座接近尾声了。
我们从PHP的“历史包袱”聊到了Swoole的“协程黑科技”,从枯燥的Redis命令聊到了优雅的Lua脚本,从简单的if-else聊到了复杂的协同过滤算法。
高性能用户推荐系统,并不是什么遥不可及的黑魔法。它本质上就是:
- 用对工具:别拿生锈的菜刀去切钢板,用PHP的Swoole去处理高并发。
- 数据结构:用好Redis的Hash和ZSet,别死磕关系型数据库。
- 异步思维:该排队的地方排队,该并行的地方并行。
- 算法落地:不要迷信复杂的SVD矩阵分解,Item-based CF配合Redis,已经能解决80%的业务场景。
希望这篇文章能给你们带来一些启发。记住,代码不仅仅是逻辑的堆砌,更是艺术的表达。当你看到用户因为你的推荐而嘴角上扬的那一刻,你会明白,写PHP也是一种修行。
现在,去你的IDE里,敲下第一个Swoole echo "Hello World"; 吧,别犹豫。
谢谢大家!