码桶

发现社区成员的开源项目

geekssl /

wstats

公开
main
wstats/app/Support/RedisGuard.php
RedisGuard.php11.7 KB
<?php
/**
 * Redis 键类型守卫:WRONGTYPE 的诊断、审计与坏键隔离。
 *
 * 背景:本项目所有 Redis 键的类型是固定的(见 EXPECT 表)。一旦某个键被写成了
 * 别的类型(同库其它程序键名冲突、历史版本残留、人工误操作),Redis 会返回
 *   WRONGTYPE Operation against a key holding the wrong kind of value
 * 这不属于“网络抖动”,重试永远不会自愈,必须当成配置/数据故障来处理:
 *   - 采集端:不能让上报 500,应降级直写 MySQL(CollectController)
 *   - worker:不能 3 秒一轮无限刷日志,应打印可执行的诊断后退出(worker.php)
 *   - 运维:doctor.php 预检 + 本类 audit() 给出坏键清单与修复命令
 */
declare(strict_types=1);

namespace Wstat\Support;

class RedisGuard
{
    /**
     * 键模式 => [期望类型, 用途]。
     * 顺序即匹配优先级('queue' 需精确匹配,其余为前缀)。
     */
    public const EXPECT = [
        'queue'   => ['list',   '采集事件队列(LPUSH / BRPOP)'],
        'today:'  => ['hash',   '今日 PV 计数(HINCRBY field=Ymd:pv)'],
        'rt:min:' => ['hash',   '分钟级 PV(HINCRBY field=YmdHi)'],
        'uv:'     => ['string', 'UV 基数 HyperLogLog(历史键:写入已停止,保留声明以兼容存量键)'],
        'ip:'     => ['string', 'IP 基数 HyperLogLog(历史键:写入已停止,保留声明以兼容存量键)'],
        'on:'     => ['zset',   '在线访客(ZADD score=ts)'],
        'ssn:'    => ['hash',   '进行中会话(HMSET / HGETALL)'],
        'live:'   => ['set',    '进行中会话集合(SADD / SMEMBERS)'],
        'v:'      => ['string', '访客首见标记(SET NX EX)'],
    ];

    /** 是否为 Redis 键类型错误(重试无效,属永久故障) */
    public static function isWrongType(\Throwable $e): bool
    {
        return stripos($e->getMessage(), 'WRONGTYPE') !== false;
    }

    /** 逻辑键名 -> 命中的模式(无命中返回 null) */
    public static function patternOf(string $key): ?string
    {
        if (isset(self::EXPECT[$key])) {
            return $key;                        // 精确名(queue)
        }
        foreach (self::EXPECT as $pat => $_) {
            if (str_ends_with($pat, ':') && str_starts_with($key, $pat)) {
                return $pat;
            }
        }
        return null;
    }

    /** 期望类型;未知键返回 null */
    public static function expectedType(string $key): ?string
    {
        $p = self::patternOf($key);
        return $p === null ? null : self::EXPECT[$p][0];
    }

    /**
     * 诊断一个键:类型、元素数、TTL、内容预览,并判断是否与期望类型一致。
     * @return array{key:string,logical:string,type:string,expected:string,size:int,ttl:int,sample:string,ok:bool,known:bool}
     */
    public static function diagnose(RedisClient $r, string $key): array
    {
        $full = $r->fullKey($key);
        $type = 'unknown';
        $size = 0;
        $ttl = 0;
        $sample = '';
        try {
            $type = $r->type($key);
            $ttl = $r->ttl($key);
            if ($type !== 'none') {
                [$size, $sample] = self::probe($r, $key, $type);
            }
        } catch (\Throwable $e) {
            $sample = '读取失败: ' . $e->getMessage();
        }
        $expected = self::expectedType($key);
        return [
            'key' => $full,
            'logical' => $key,
            'type' => $type,
            'expected' => $expected ?? '(未知键)',
            'size' => $size,
            'ttl' => $ttl,
            'sample' => $sample,
            'ok' => $expected === null ? true : ($expected === $type || $type === 'none'),
            'known' => $expected !== null,
        ];
    }

    /** 按实际类型取元素数与内容预览(截断,避免刷屏) */
    private static function probe(RedisClient $r, string $key, string $type): array
    {
        switch ($type) {
            case 'list':
                return [$r->llen($key), self::cut(implode(' | ', $r->lrange($key, 0, 2)), 300)];
            case 'hash': {
                $all = $r->hgetall($key);
                $pairs = [];
                foreach (array_slice($all, 0, 5, true) as $f => $v) {
                    $pairs[] = $f . '=' . self::cut((string) $v, 60);
                }
                return [count($all), self::cut(implode(', ', $pairs), 300)];
            }
            case 'set': {
                $m = $r->smembers($key);
                return [count($m), self::cut(implode(', ', array_slice($m, 0, 5)), 300)];
            }
            case 'zset': {
                $m = $r->zrangebyscore($key, '-inf', '+inf', true, 5);
                $pairs = [];
                foreach ($m as $member => $score) {
                    $pairs[] = $member . '=>' . $score;
                }
                return [$r->zcard($key), self::cut(implode(', ', $pairs), 300)];
            }
            default: {
                $v = (string) $r->get($key);
                return [$r->strlen($key), self::cut($v, 300)];
            }
        }
    }

    /**
     * 生成可直接贴给用户的诊断文本(含修复命令)。
     * 只读,不改动任何键。
     */
    public static function describe(array $d, string $title = 'Redis 键类型错误(WRONGTYPE)'): string
    {
        $db = (int) (wstat_config('redis.db') ?? 0);
        $L = [];
        $L[] = '';
        $L[] = '==================================================================';
        $L[] = "[FATAL] $title";
        $L[] = '------------------------------------------------------------------';
        $L[] = '  key      = ' . $d['key'];
        $L[] = '  实际类型 = ' . $d['type'] . '   (期望 ' . $d['expected'] . ')';
        $L[] = '  元素数   = ' . $d['size'];
        $L[] = '  TTL      = ' . ($d['ttl'] < 0 ? '无过期' : $d['ttl'] . 's')
            . ($d['ttl'] === -2 ? '(键已不存在)' : '');
        if ($d['sample'] !== '') {
            $L[] = '  内容预览 = ' . $d['sample'];
        }
        $L[] = '';
        $L[] = '  含义:该键已存在但不是本项目期望的数据结构,Redis 拒绝了本次操作。';
        $L[] = '        这不是网络抖动,重试不会自愈;worker 会一直刷这条错误且队列永不消费。';
        $L[] = '  常见原因:';
        $L[] = '    1) 同一个 Redis 库被别的程序/站点共用,且键名相同(尤其 redis.prefix 为空时);';
        $L[] = '    2) 之前跑过键结构不同的旧版本(如哈希队列),升级后残留;';
        $L[] = '    3) 人工用 redis-cli / 面板工具往同名键写过值。';
        $L[] = '';
        $L[] = '  排查(不改动数据):';
        $L[] = "    redis-cli -n $db TYPE {$d['key']}";
        $L[] = "    redis-cli -n $db OBJECT ENCODING {$d['key']}";
        $L[] = '';
        $L[] = '  修复(确认内容与其它程序有关就先备份,不要直接删):';
        $L[] = "    redis-cli -n $db RENAME {$d['key']} {$d['key']}:bad_" . date('YmdHis');
        $L[] = "    # 或确认无用后直接删除: redis-cli -n $db DEL {$d['key']}";
        $L[] = '    然后重启 worker。也可直接执行(自动备份坏键后继续消费):';
        $L[] = '      php ' . wstat_rel('scripts/worker.php') . ' --quarantine';
        $L[] = '  如需彻底避免再次冲突,把 config.php 的 redis.prefix 改成站点专属前缀(如 wstat_xxx:)。';
        $L[] = '==================================================================';
        return implode("\n", $L) . "\n";
    }

    /**
     * 隔离坏键:RENAME 到 {key}:bad_{时间戳}(保留原数据,便于事后确认归属)。
     * 成功返回备份键名,失败返回 null。
     */
    public static function quarantine(RedisClient $r, string $key): ?string
    {
        $bak = $key . ':bad_' . date('YmdHis');
        try {
            if (!$r->rename($key, $bak)) {
                return null;
            }
        } catch (\Throwable $e) {
            return null;
        }
        return $r->fullKey($bak);
    }

    /**
     * 键类型审计:按 EXPECT 表逐模式抽样检查,返回坏键清单。
     * @param int $cap 每个模式最多抽样多少个键
     * @return array{checked:int,bad:array<string,array{type:string,expected:string,logical:string}>}
     */
    public static function audit(RedisClient $r, int $cap = 200): array
    {
        $checked = 0;
        $bad = [];
        foreach (self::EXPECT as $pat => [$want, $_]) {
            $isPrefix = str_ends_with($pat, ':');
            $cmd = $isPrefix ? $pat . '*' : $pat;
            $cursor = 0;
            $seen = 0;
            do {
                $keys = $r->scanKeys($cmd, $cursor, 200);
                foreach ($keys as $k) {
                    if ($seen++ >= $cap) {
                        break 2;
                    }
                    // 前缀模式下 'v:xxx' 不能被 'v:' 之外的规则误判
                    if ($isPrefix && !str_starts_with($k, $pat)) {
                        continue;
                    }
                    $checked++;
                    $t = $r->type($k);
                    if ($t !== $want && $t !== 'none') {
                        $bad[$r->fullKey($k)] = [
                            'logical' => $k,
                            'type' => $t,
                            'expected' => $want,
                        ];
                    }
                }
            } while ($cursor !== 0);
        }
        return ['checked' => $checked, 'bad' => $bad];
    }

    /** 生成坏键修复命令(审计输出用) */
    public static function fixHint(string $fullKey): string
    {
        $db = (int) (wstat_config('redis.db') ?? 0);
        return "redis-cli -n $db RENAME $fullKey $fullKey:bad_" . date('Ymd');
    }

    /**
     * 审计结果的可读输出(含逐键修复命令)。
     * 用于不可定位到单个键的 WRONGTYPE(如 reap/sessionize 内部命令)。
     */
    public static function describeAudit(array $a, string $title = 'Redis 键类型审计'): string
    {
        $L = [];
        $L[] = '';
        $L[] = '==================================================================';
        $L[] = "[FATAL] $title";
        $L[] = '------------------------------------------------------------------';
        if (empty($a['bad'])) {
            $L[] = "  未发现类型异常键(已检查 {$a['checked']} 个)。";
            $L[] = '  若错误出现在 ssn:/v: 等按会话生成的键上,可用 redis-cli KEYS "wstat:ssn:*" 抽查。';
            $L[] = '==================================================================';
            return implode("\n", $L) . "\n";
        }
        $L[] = '  发现 ' . count($a['bad']) . " 个键类型与预期不符(已检查 {$a['checked']} 个):";
        foreach ($a['bad'] as $k => $info) {
            $L[] = sprintf('    %-52s 实际=%-7s 期望=%s', $k, $info['type'], $info['expected']);
        }
        $L[] = '';
        $L[] = '  修复(逐个确认后执行,RENAME 会把原数据保留在新键名下):';
        foreach (array_keys($a['bad']) as $k) {
            $L[] = '    ' . self::fixHint($k);
        }
        $L[] = '  或在 worker 启动时加 --quarantine 自动隔离队列键后继续。';
        $L[] = '  根因多为:同库共用且键名前缀冲突(redis.prefix)/旧版本残留/人工误写。';
        $L[] = '==================================================================';
        return implode("\n", $L) . "\n";
    }

    private static function cut(string $s, int $len): string
    {
        $s = str_replace(["\r", "\n"], ' ', $s);
        return mb_strlen($s) > $len ? mb_substr($s, 0, $len) . '…' : $s;
    }
}