用 PHP 构建个性化引擎:动态内容交付实战

在 PHP 规模下,"个性化"的实际含义是什么?

不要想着构建万能推荐算法。对大多数 PHP 技术栈来说,最实用的是决策式内容:根据少量信号(用户特征 + 上下文 + 近期行为)为请求选择最合适的横幅、文章、区块或布局变体。

这种决策系统需要满足几个核心约束:

  • 速度要求:每个决策的 CPU 时间控制在 2-10 毫秒内
  • 缓存兼容:片段缓存和边缘缓存必须保持有效性
  • 可审计性:能够追踪为什么展示特定变体
  • 隐私优先:仅使用第一方数据,最小化数据保留
  • 故障安全:服务异常时有确定性的默认行为

在这些约束条件下,我们来构建一个可靠的个性化系统。

系统架构概览

整体架构可以用流水线来理解:

请求 → 上下文构建 → 用户分段 → 内容决策 → 页面渲染 → 响应

各组件的职责:

  • 上下文构建器:收集用户 ID(如果已登录)、地理位置、设备类型、来源渠道、访问时间、行为计数等特征
  • 分段解析器:将用户特征映射到业务分段,如"新用户"、"PHP 读者"、"7 天内活跃"等
  • 决策引擎:基于分段和规则选择内容变体,可结合轻量级机器学习算法
  • 渲染器:将选中的内容组装到页面中,利用基于分段的片段缓存
  • 监控组件:记录决策过程和结果,便于问题排查和效果分析

保持各组件的独立性和无状态设计,使其能够在 MVC 控制器、API 端点或 CLI 脚本中灵活使用。

数据结构设计

在个性化系统中,数据结构的设计应当遵循简单性原则,避免过度复杂的嵌套和关联。

用户画像(Redis 友好设计)

json
{
  "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 写)

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)。设计保持简单以确保安全性。

PHP 实现方案

基于 PSR-7/15 标准实现,确保与主流 PHP 框架(Laravel、Symfony、Slim 等)的兼容性。

1) 上下文和中间件

php
<?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
<?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,如果需要再获取数据库,并在写入时(而不是在请求期间)导出简单计数器。

2) 分段安全规则引擎

php
<?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。

内容和变体表

sql
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 评分器(精简版)

php
<?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); // 粗糙但单调
    }
}

每个插槽 × (主要分段或地理位置桶)使用汤普森采样。保持后验更新异步(队列工作器读取点击/打开事件),这样请求保持快速。

渲染优化策略

个性化实现不应影响页面性能。以下是两种主要的优化方案。

片段化渲染(个性化岛屿)

页面的大部分内容是静态的,只有特定区域需要个性化。可以将这些区域设计为独立的"岛屿":

php
$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 调用),可以采用流式传输策略:

php
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 写入和队头风险。

监控和可观测性

个性化系统需要透明的决策过程,当用户反馈内容推荐存在问题时,能够快速定位原因。

HTTP 头信息

Server-Timing: ctx;desc="segments=returning_7d,id_readers", dec;desc="slot=hero;variant=php_cta_mobile"
X-Request-ID: <correlation>

决策日志

json
{
  "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"
}

为控制存储成本,可对游客请求进行采样记录。

系统保障机制

以下是确保系统稳定运行的关键措施:

  • 冷启动处理:为新内容设置合理的先验值,如初始点击率设为 20%
  • 探索保障:保留 10-15% 的随机性,避免算法陷入局部最优
  • 复杂度控制:每个位置最多使用 6 个用户标签,防止缓存键爆炸
  • 疲劳防护:避免向用户重复展示相同内容
  • 性能限制:决策时间超过 4 毫秒时回退到默认内容
  • 功能开关:提供快速关闭个性化的紧急机制

隐私保护和合规

在当前的隐私保护法规环境下,系统设计需要考虑以下要求:

  • 第一方数据:仅使用第一方 cookie,避免跨站跟踪
  • 数据粒度控制:存储粗粒度信息(如国家而非 GPS,年龄段而非生日)
  • 用户选择权:尊重用户的个性化偏好设置,拒绝时使用默认内容
  • 数据保留策略:为行为数据设置过期时间,定期清理
  • 日志隐私化:记录决策逻辑,但避免存储用户敏感信息

常见问题和解决方案

1) 缓存命中率下降

症状:CDN 和应用缓存命中率急剧下降,基础设施成本上升,网站响应变慢。

原因:使用用户 ID 作为缓存键,导致缓存键过度分散。

解决方案

  • 基于用户分段进行缓存分组,而非用户 ID
  • 每个位置最多使用 6 个标签影响缓存
  • 缓存键构造:位置标识 + 标签组合的哈希值
  • 为其余标签创建通用分组

2) 页面响应时间上升

症状:高流量或规则更新后,页面响应时间显著增加。

原因:决策过程计算开销过大,可能涉及实时数据库查询或外部接口调用。

解决方案

  • 用户行为统计采用异步队列处理,避免实时计算
  • 将用户数据和规则缓存到 Redis,减少数据库访问
  • 设置决策时间限制,超过 5 毫秒时跳过复杂逻辑
  • 当依赖服务响应缓慢时立即使用默认内容

3) 发布后内容异常

症状:规则更新后,用户看到不期望的内容变体。

原因:缓存内容与新规则不匹配,存在版本不一致问题。

解决方案

  • 在缓存键中嵌入版本标识,如 frag:hero:v17:...
  • 规则更新时按标签批量清理相关缓存
  • 采用灰度发布:新规则先测试但仍显示原内容,验证后切换

4) 个性化效果不明显

症状:个性化系统运行后,关键指标相比基准版本无显著提升。

原因:用户分段过于细化导致样本不足,或优化目标与业务指标不匹配。

解决方案

  • 合并小规模分段,确保每个分段有足够的样本量
  • 优化直接可影响的指标,避免目标过于间接
  • 引入内容疲劳控制,确保用户体验的多样性
  • 保持 10-15% 的探索率,避免算法陷入局部最优

5) 实验收敛困难

症状:长期测试后,实验结果仍不稳定,难以确定最优变体。

原因:基础转化率过低、流量分配不均,或同时测试的变体过多。

解决方案

  • 使用带先验的汤普森采样,限制并发测试的变体数量
  • 对于严格的 A/B 测试需求,采用序贯分析提前终止
  • 每个位置并发测试变体不超过 3 个,及时下线表现不佳的变体

生产环境部署

推荐的技术栈配置:

  • 应用层:PHP 8.2+,启用 OPcache,在请求早期加载中间件
  • 存储架构:Redis 存储用户数据和片段缓存;MySQL/PostgreSQL 存储规则和内容;队列系统处理事件
  • 后台处理:独立 worker 进程处理点击数据,更新算法参数
  • CDN 集成:可选择 ESI 支持,使用 Cache-Tag 头实现精确缓存失效
  • 发布流程:采用影子模式验证,确认无误后正式启用

实施案例

以印尼移动端用户的个性化为例,演示完整的实现流程。目标是为首页横幅和侧边栏推荐提供个性化内容。

1. 规则

  • id_readers:最近阅读 ≥3 篇 PHP 文章。
  • returning_7d:7 天内活跃。

2. 变体

  • hero_php_bootcamp(目标:id_readers,排除 cold_guest)
  • hero_general(默认)
  • sidebar_php_series(包含 id_readers|returning_7d)
  • sidebar_generic(默认)

3. 影响英雄的分段:['id_readers', 'returning_7d', 'misc'](预算:3)

4. 运行时

  • 上下文
  • 分段:['id_readers','returning_7d']
  • 决策:hero → hero_php_bootcamp,bandit 样本 0.38;sidebar → sidebar_php_series。
  • 头信息:Server-Timing: ctx;..., dec;desc="hero=bootcamp,sidebar=php_series"

5. 缓存

  • 英雄缓存键:frag:hero:hash(id_readers, mobile, ID) → 在相似用户间高重用。
  • TTL 5 分钟;在变体更新或当 bandit 后验超出阈值时清除(可选)。

强化规则解释器(值得做)

  • 用适当的表达式解析器(Pratt 或调度场)替换快速求值器。
  • 维护可解析变量(country, device, now)的注册表并拒绝其他一切。
  • 用特征矩阵对规则进行单元测试 → 预期分段命中。
  • 在内存中存储编译的 AST(Swoole/RoadRunner)以获得可忽略的每请求开销。

衡量成功(并捕获回归)

每个插槽跟踪:

  • 覆盖率:获得个性化变体的请求百分比。
  • 提升:CTR 或转换增量与同期控制相比。
  • 延迟:决策时间 p50/p95(目标:<2ms / <5ms)。
  • 基数:每小时每个插槽的唯一缓存键数量。
  • 稳定性:特征获取、超时或规则求值的错误率。

自动化防护:如果 p95 决策时间 > 8 毫秒或缓存命中率 < 70%,自动禁用该插槽的学习。

安全考虑

以下安全措施是生产环境的必要保障:

  • 规则语言应视为不可信输入,在写入和读取时都需要验证
  • 使用签名 Cookie 防止用户伪造分段信息
  • 避免向客户端暴露用户分段,除非业务必需
  • 谨慎使用 Vary 头,优先选择显式缓存键策略
  • 为点击事件实现防重放保护(随机数 + 短 TTL)

实施检查清单

  1. 选择 1-2 个位置进行试点
  2. 每个位置限制最多 6 个影响因子
  3. 配置 Redis 用户数据缓存和中间件
  4. 实现规则引擎并编写测试用例
  5. 集成决策引擎(规则 + bandit 算法)
  6. 建立基于分段的缓存机制
  7. 添加监控头信息和决策日志
  8. 运行影子模式一周,分析对比数据
  9. 正式启用,保留紧急开关功能
  10. 验证隐私保护和合规要求

总结

个性化系统的成功不依赖于复杂的技术栈。关键在于明确目标用户群体(可缓存的分段)、设计高效的决策机制(快速且可解释的评分算法)、以及建立完善的容错机制。PHP 在这类应用场景中表现优异,特别是当系统设计简洁、可测试性强且资源消耗可控时。

保持设计的简洁性,控制系统复杂度,完整记录决策过程,避免过度复杂的规则语言。最终目标是为用户提供看似量身定制的内容体验,而非随机或不一致的展示结果。

CatchAdmin
后端开发工程师,前端入门选手,略知相关服务器知识,偏爱❤️ Laravel & Vue
本作品采用《CC 协议》,转载必须注明作者和本文链接