在 PHP 规模下,"个性化"的实际含义是什么?
不要想着构建万能推荐算法。对大多数 PHP 技术栈来说,最实用的是决策式内容:根据少量信号(用户特征 + 上下文 + 近期行为)为请求选择最合适的横幅、文章、区块或布局变体。
这种决策系统需要满足几个核心约束:
在这些约束条件下,我们来构建一个可靠的个性化系统。
整体架构可以用流水线来理解:
请求 → 上下文构建 → 用户分段 → 内容决策 → 页面渲染 → 响应
各组件的职责:
保持各组件的独立性和无状态设计,使其能够在 MVC 控制器、API 端点或 CLI 脚本中灵活使用。
在个性化系统中,数据结构的设计应当遵循简单性原则,避免过度复杂的嵌套和关联。
{
"user_id": "u_12345", // 游客就是 null
"traits": {
"country": "ID",
"device": "mobile",
"lang": "en",
"tz": "Asia/Jakarta",
"last_active_at": "2025-08-24T13:51:00Z",
"categories_read": { "php": 4, "security": 1 },
"utm_campaign": "august_push"
}
}
数据大小应控制在 1KB 以内,确保在 Redis 中的存储效率和检索速度。避免存储原始敏感信息,应进行哈希处理或分桶处理。
event_id, user_id, ts, type, props_json
这是一个事件流水表,记录用户的所有行为事件。在实际决策过程中,系统会从这些原始事件中计算出汇总指标(如计数器、最近活跃时间等),并将结果写回到用户画像中。
在实际应用中,业务人员经常需要调整分段规则,但直接执行代码存在安全风险。因此需要设计一个安全的规则描述语言。
标签规则(用 YAML 写)
- id: returning_7d
when: 'now - last_active_at < 7d'
- id: id_readers
when: "country == 'ID' && categories_read['php'] >= 3"
- id: cold_guest
when: '!user_id && now - last_active_at > 30d'
支持的操作符包括:==
、!=
、<
、<=
、>
、>=
、&&
、||
、!
,以及时间计算(如 7d
)。设计保持简单以确保安全性。
基于 PSR-7/15 标准实现,确保与主流 PHP 框架(Laravel、Symfony、Slim 等)的兼容性。
<?php
// src/Personalization/Context.php
declare(strict_types=1);
namespace App\Personalization;
final class Context
{
public function __construct(
public readonly ?string $userId,
/** @var array<string, mixed> */
public array $traits,
/** @var list<string> */
public array $segments = [],
/** 用于追踪的关联 ID */
public readonly string $correlationId = ''
) {}
}
<?php
// src/Personalization/ContextMiddleware.php
declare(strict_types=1);
namespace App\Personalization;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class ContextMiddleware implements MiddlewareInterface
{
public function __construct(
private ProfileStore $profiles,
private SegmentMatcher $matcher,
) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$correlation = bin2hex(random_bytes(8));
$userId = $request->getAttribute('userId') ?? $request->getCookieParams()['uid'] ?? null;
$traits = $this->profiles->loadTraits($userId, $request);
$segments = $this->matcher->match($traits);
$ctx = new Context($userId, $traits, $segments, $correlation);
$request = $request->withAttribute(Context::class, $ctx);
$response = $handler->handle($request);
// 可观测性:为什么我们选择了我们选择的?
$serverTiming = sprintf('ctx;desc="segments=%s"', implode(',', $segments));
return $response
->withHeader('X-Request-ID', $correlation)
->withAddedHeader('Server-Timing', $serverTiming);
}
}
ProfileStore::loadTraits(...)
应该先获取 Redis,如果需要再获取数据库,并在写入时(而不是在请求期间)导出简单计数器。
<?php
// src/Personalization/SegmentMatcher.php
declare(strict_types=1);
namespace App\Personalization;
final class SegmentMatcher
{
/** @var array<string, string> segmentId => expression */
public function __construct(private array $rules) {}
/**
* @param array<string, mixed> $traits
* @return list<string>
*/
public function match(array $traits): array
{
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$out = [];
foreach ($this->rules as $id => $expr) {
if ($this->evalExpr($expr, $traits, $now)) {
$out[] = $id;
}
}
return $out;
}
private function evalExpr(string $expr, array $t, \DateTimeImmutable $now): bool
{
// 极小的解释器:仅替换已知令牌
$replacements = [
'now' => (string)$now->getTimestamp(),
];
// 时间算术:"now - last_active_at < 7d"
$expr = preg_replace_callback('/(\d+)\s*d\b/', fn($m) => (string)((int)$m[1] * 86400), $expr);
// categories_read['php'] 的简单映射查找
$expr = preg_replace_callback("/categories_read\\['([^']+)'\\]/", function ($m) use ($t) {
$val = $t['categories_read'][$m[1]] ?? 0;
return (string)intval($val);
}, $expr);
// 替换 'last_active_at' -> timestamp
if (isset($t['last_active_at'])) {
$ts = (new \DateTimeImmutable($t['last_active_at']))->getTimestamp();
$expr = str_replace('last_active_at', (string)$ts, $expr);
}
// 布尔值 / 可空值
$expr = str_replace('!user_id', ($t['user_id'] ?? null) ? 'false' : 'true', $expr);
// 用安全字面量替换基本类型
$replacements["country"] = "'" . addslashes((string)($t['country'] ?? '')) . "'";
foreach ($replacements as $k => $v) {
$expr = preg_replace('/\b' . preg_quote($k, '/') . '\b/', $v, $expr);
}
// 允许的操作符:==, !=, <, <=, >, >=, &&, ||, !
// 通过白名单检查拒绝其他任何东西
if (preg_match('/[^0-9\\s\\-+*\\/()<>!=&|\'",._a-z]/i', $expr)) {
return false; // 拒绝未知令牌
}
// 使用微型布尔求值器安全求值
// 这里我们在清理后仅依赖 PHP eval;在真实项目中首选适当的解析器
try {
/** @phpstan-ignore-next-line */
return (bool) eval('return ('. $expr . ');');
} catch (\Throwable) {
return false;
}
}
}
这种设计有意限制功能范围,避免执行危险代码。如需更高安全性,可以实现专门的 Pratt 解析器,但这个简化版本已能满足大多数应用场景。
个性化系统的主要挑战是缓存效率。常见错误是为每个用户生成独立的缓存键,导致缓存碎片化和命中率下降。
正确的策略是基于用户分段进行缓存分组,而非使用用户 ID。
CREATE TABLE content_slot (
id BIGINT PRIMARY KEY,
slug VARCHAR(64) UNIQUE, -- 例如,'homepage.hero'
default_variant_id BIGINT NOT NULL
);
CREATE TABLE content_variant (
id BIGINT PRIMARY KEY,
slot_id BIGINT NOT NULL,
name VARCHAR(64), -- 例如,'php_cta_mobile'
body_json JSON NOT NULL, -- 模板变量或结构化载荷
weight INT NOT NULL DEFAULT 1, -- 用于 bandit 先验
UNIQUE(slot_id, name)
);
CREATE TABLE variant_targeting (
id BIGINT PRIMARY KEY,
variant_id BIGINT NOT NULL,
include_segments JSON NOT NULL, -- ["id_readers","returning_7d"]
exclude_segments JSON NOT NULL, -- ["cold_guest"]
start_at TIMESTAMP NULL,
end_at TIMESTAMP NULL
);
现在片段缓存键可以是:
fragment:{slot_slug}:{hash(sorted(segments ∩ slot_segments) ∪ [geo_bucket, device])}
这里 slot_segments
是每个位置允许的标签列表(最多 6 个),这样能控制缓存键的数量。
这是个性化系统的核心组件。基本原理是对候选内容进行评分,选择得分最高的变体展示给用户。
评分算法设计如下:
总分 = 规则匹配分
+ 新鲜度加分 // 新内容优先
- 疲劳扣分 // 避免重复展示
+ 机器学习加分 // 基于历史点击率
<?php
// src/Personalization/DecisionEngine.php
declare(strict_types=1);
namespace App\Personalization;
final class DecisionEngine
{
public function __construct(
private VariantStore $variants,
private MetricsStore $metrics // 读取每个变体和分段的 CTR 先验/后验
) {}
public function choose(string $slotSlug, Context $ctx): ChosenVariant
{
$candidates = $this->variants->eligible($slotSlug, $ctx->segments, $ctx->traits);
if (!$candidates) {
return $this->variants->default($slotSlug);
}
$best = null; $bestScore = PHP_FLOAT_MIN;
foreach ($candidates as $v) {
$s = $this->ruleFit($v, $ctx);
$s += $this->recencyBonus($v);
$s -= $this->fatiguePenalty($v, $ctx);
$s += $this->banditUplift($v, $ctx);
if ($s > $bestScore) {
$bestScore = $s;
$best = $v;
}
}
return $best ?? $this->variants->default($slotSlug);
}
private function ruleFit(Variant $v, Context $ctx): float
{
// 示例:如果任何包含分段命中则 +1;如果排除则 -1
if (array_intersect($v->includeSegments, $ctx->segments)) return 1.0;
if (array_intersect($v->excludeSegments, $ctx->segments)) return -INF;
return 0.0;
}
private function recencyBonus(Variant $v): float
{
$ageHours = max(1, (time() - $v->createdAtTs) / 3600);
return 0.3 / log(3 + $ageHours); // 温和衰减
}
private function fatiguePenalty(Variant $v, Context $ctx): float
{
$seen = (int)($ctx->traits['seen_variant_'.$v->id] ?? 0);
return min(0.8, $seen * 0.2);
}
private function banditUplift(Variant $v, Context $ctx): float
{
// 汤普森采样:每个决策抽取一次
[$alpha, $beta] = $this->metrics->betaAB($v->id, $ctx->segments);
$sample = $this->betaSample($alpha, $beta);
return $sample * 0.8; // 限制其影响
}
private function betaSample(float $a, float $b): float
{
// 快速粗糙的伽马采样器;对 UI 决策足够了
$g1 = $this->gammaSample($a, 1.0);
$g2 = $this->gammaSample($b, 1.0);
return $g1 / ($g1 + $g2);
}
private function gammaSample(float $shape, float $scale): float
{
// 对于 k>1 的 Marsaglia-Tsang,小形状的提升;为简洁这里省略
$u = mt_rand() / mt_getrandmax();
return -log($u) * $scale * max(0.1, $shape); // 粗糙但单调
}
}
每个插槽 × (主要分段或地理位置桶)使用汤普森采样。保持后验更新异步(队列工作器读取点击/打开事件),这样请求保持快速。
个性化实现不应影响页面性能。以下是两种主要的优化方案。
页面的大部分内容是静态的,只有特定区域需要个性化。可以将这些区域设计为独立的"岛屿":
$ctx = $request->getAttribute(\App\Personalization\Context::class);
/** @var DecisionEngine $engine */
$hero = $engine->choose('homepage.hero', $ctx);
$cacheKey = sprintf('frag:hero:%s', $hero->cacheKeyFor($ctx));
if ($html = $cache->get($cacheKey)) {
echo $html;
} else {
$html = $twig->render('slots/hero.twig', ['payload' => $hero->payload]);
$cache->set($cacheKey, $html, ttl: 300);
echo $html;
}
每个岛屿应保持独立性,便于后续集成 CDN 的 ESI 功能。
对于计算密集的个性化内容(如需要外部 API 调用),可以采用流式传输策略:
ob_implicit_flush(true);
// 外壳
echo $twig->render('page_shell.twig');
// 个性化块
echo "<script>document.getElementById('hero').innerHTML = `";
echo addslashes($twig->render('slots/hero.twig', ['payload' => $hero->payload]));
echo "`;</script>";
避免流式传输数十个块;每次刷新都是 TLS 写入和队头风险。
个性化系统需要透明的决策过程,当用户反馈内容推荐存在问题时,能够快速定位原因。
Server-Timing: ctx;desc="segments=returning_7d,id_readers", dec;desc="slot=hero;variant=php_cta_mobile"
X-Request-ID: <correlation>
{
"ts": "2025-08-26T06:21:03Z",
"req_id": "ae91f3c4a1b2c3d4",
"slot": "homepage.hero",
"variant": "php_cta_mobile",
"segments": ["id_readers", "returning_7d"],
"scores": { "rule": 1.0, "recency": 0.15, "fatigue": 0.0, "bandit": 0.42 },
"user_id": "u_12345"
}
为控制存储成本,可对游客请求进行采样记录。
以下是确保系统稳定运行的关键措施:
在当前的隐私保护法规环境下,系统设计需要考虑以下要求:
症状:CDN 和应用缓存命中率急剧下降,基础设施成本上升,网站响应变慢。
原因:使用用户 ID 作为缓存键,导致缓存键过度分散。
解决方案:
症状:高流量或规则更新后,页面响应时间显著增加。
原因:决策过程计算开销过大,可能涉及实时数据库查询或外部接口调用。
解决方案:
症状:规则更新后,用户看到不期望的内容变体。
原因:缓存内容与新规则不匹配,存在版本不一致问题。
解决方案:
症状:个性化系统运行后,关键指标相比基准版本无显著提升。
原因:用户分段过于细化导致样本不足,或优化目标与业务指标不匹配。
解决方案:
症状:长期测试后,实验结果仍不稳定,难以确定最优变体。
原因:基础转化率过低、流量分配不均,或同时测试的变体过多。
解决方案:
推荐的技术栈配置:
以印尼移动端用户的个性化为例,演示完整的实现流程。目标是为首页横幅和侧边栏推荐提供个性化内容。
id_readers
:最近阅读 ≥3 篇 PHP 文章。returning_7d
:7 天内活跃。hero_php_bootcamp
(目标:id_readers,排除 cold_guest)hero_general
(默认)sidebar_php_series
(包含 id_readers|returning_7d)sidebar_generic
(默认)每个插槽跟踪:
自动化防护:如果 p95 决策时间 > 8 毫秒或缓存命中率 < 70%,自动禁用该插槽的学习。
以下安全措施是生产环境的必要保障:
个性化系统的成功不依赖于复杂的技术栈。关键在于明确目标用户群体(可缓存的分段)、设计高效的决策机制(快速且可解释的评分算法)、以及建立完善的容错机制。PHP 在这类应用场景中表现优异,特别是当系统设计简洁、可测试性强且资源消耗可控时。
保持设计的简洁性,控制系统复杂度,完整记录决策过程,避免过度复杂的规则语言。最终目标是为用户提供看似量身定制的内容体验,而非随机或不一致的展示结果。