Commit 8b74784e101f65588c792ebaf1261df74d715740

Authored by tangwang
1 parent 6f7840cf

cache manage

docs/DEVELOPER_GUIDE.md
... ... @@ -423,6 +423,7 @@ services:
423 423 | 向量模块与 clip-as-service | [embeddings/README.md](../embeddings/README.md) |
424 424 | TEI 服务专项(安装/部署/GPU-CPU 模式) | [TEI_SERVICE说明文档.md](./TEI_SERVICE说明文档.md) |
425 425 | CN-CLIP 服务专项(环境/运维/GPU) | [CNCLIP_SERVICE说明文档.md](./CNCLIP_SERVICE说明文档.md) |
  426 +| 缓存 / Redis 使用与 key 设计 | [缓存与Redis使用说明.md](./缓存与Redis使用说明.md) |
426 427  
427 428 ### 10.2 仓库内入口
428 429  
... ...
docs/缓存与Redis使用说明.md 0 → 100644
... ... @@ -0,0 +1,347 @@
  1 +## 缓存与 Redis 使用说明
  2 +
  3 +本仓库中 Redis 主要用于**性能型缓存**,当前落地的业务缓存包括:
  4 +
  5 +- **文本向量缓存**(embedding 缓存)
  6 +- **翻译结果缓存**(Qwen-MT 等机器翻译)
  7 +- **商品内容理解缓存**(锚文本 / 语义属性 / 标签)
  8 +
  9 +底层连接配置统一来自 `config/env_config.py` 的 `REDIS_CONFIG`:
  10 +
  11 +- **Host/Port**:`REDIS_HOST` / `REDIS_PORT`(默认 `localhost:6479`)
  12 +- **Password**:`REDIS_PASSWORD`
  13 +- **Socket & 超时**:`REDIS_SOCKET_TIMEOUT` / `REDIS_SOCKET_CONNECT_TIMEOUT` / `REDIS_RETRY_ON_TIMEOUT`
  14 +- **通用缓存 TTL**:`REDIS_CACHE_EXPIRE_DAYS`(默认 `360*2` 天,代码注释为 “6 months”)
  15 +- **翻译缓存 TTL & 前缀**:`REDIS_TRANSLATION_CACHE_EXPIRE_DAYS`、`REDIS_TRANSLATION_CACHE_PREFIX`
  16 +
  17 +---
  18 +
  19 +## 1. 缓存总览表
  20 +
  21 +| 模块 / 场景 | Key 模板 | Value 内容示例 | 过期策略 | 备注 |
  22 +|------------|----------|----------------|----------|------|
  23 +| 文本向量缓存(embedding) | `embedding:{language}:{norm_flag}:{query}` | `pickle.dumps(np.ndarray)`,如 1024 维 BGE 向量 | TTL=`REDIS_CONFIG["cache_expire_days"]` 天;访问时滑动过期 | 见 `embeddings/text_encoder.py` |
  24 +| 翻译结果缓存(Qwen-MT 翻译) | `{cache_prefix}:{model}:{src}:{tgt}:{sha256(payload)}` | 机翻后的单条字符串 | TTL=`services.translation.cache.ttl_seconds` 秒;可配置滑动过期 | 见 `query/qwen_mt_translate.py` + `config/config.yaml` |
  25 +| 商品内容理解缓存(anchors / 语义属性 / tags) | `{ANCHOR_CACHE_PREFIX}:{tenant_or_global}:{target_lang}:{md5(title)}` | `json.dumps(dict)`,包含 id/title/category/tags/anchor_text 等 | TTL=`ANCHOR_CACHE_EXPIRE_DAYS` 天 | 见 `indexer/product_enrich.py` |
  26 +
  27 +下面按模块详细说明。
  28 +
  29 +---
  30 +
  31 +## 2. 文本向量缓存(embeddings/text_encoder.py)
  32 +
  33 +- **代码位置**:`embeddings/text_encoder.py` 中 `TextEmbeddingEncoder`
  34 +- **用途**:缓存调用向量服务(6005)的文本向量结果,避免重复计算。
  35 +
  36 +### 2.1 Key 设计
  37 +
  38 +- 函数:`_get_cache_key(query: str, language: str, normalize_embeddings: bool) -> str`
  39 +- 模板:
  40 +
  41 +```text
  42 +embedding:{language}:{norm_flag}:{query}
  43 +```
  44 +
  45 +- 字段说明:
  46 + - `language`:当前实现中统一传入 `"generic"`;
  47 + - `norm_flag`:`"norm1"` 表示归一化向量,`"norm0"` 表示未归一化;
  48 + - `query`:原始文本(未做哈希),注意长度特别长的 query 会直接出现在 key 中。
  49 +
  50 +### 2.2 Value 与类型
  51 +
  52 +- 类型:`pickle.dumps(np.ndarray)`,在读取时通过 `pickle.loads` 还原为 `np.ndarray`。
  53 +- 典型示例:BGE-M3 1024 维 `float32` 向量。
  54 +
  55 +### 2.3 过期策略
  56 +
  57 +- 初始化:
  58 + - `self.expire_time = timedelta(days=REDIS_CONFIG.get("cache_expire_days", 180))`
  59 + - `.env` 中可通过 `REDIS_CACHE_EXPIRE_DAYS` 配置,默认 `360*2`(代码注释标注为 6 个月)。
  60 +- 写入:
  61 + - `redis.setex(cache_key, self.expire_time, serialized_data)`
  62 +- 访问(滑动过期):
  63 + - 命中缓存后,会调用 `redis.expire(cache_key, self.expire_time)` 延长 TTL。
  64 +
  65 +### 2.4 特殊处理
  66 +
  67 +- 若缓存中的向量 **为空 / shape 异常 / 含 NaN/Inf**,会:
  68 + - 直接丢弃该缓存(并尝试 `delete` key);
  69 + - 回退为重新调用向量服务。
  70 +
  71 +---
  72 +
  73 +## 3. 翻译结果缓存(query/qwen_mt_translate.py)
  74 +
  75 +- **代码位置**:`query/qwen_mt_translate.py` 中 `Translator` 类
  76 +- **用途**:缓存 Qwen-MT 翻译(及 translator service 复用的翻译)结果,减少云端请求,遵守限速。
  77 +- **配置入口**:`config/config.yaml -> services.translation.cache`,统一由 `config/services_config.get_translation_cache_config()` 解析。
  78 +
  79 +### 3.1 Key 设计
  80 +
  81 +- 内部构造函数:`_build_cache_key(...)`
  82 +- 模板:
  83 +
  84 +```text
  85 +{cache_prefix}:{model}:{src}:{tgt}:{sha256(payload)}
  86 +```
  87 +
  88 +其中:
  89 +
  90 +- `cache_prefix`:来自 `services.translation.cache.key_prefix`,默认 `trans:v2`;
  91 +- `model`:如 `"qwen-mt"`;
  92 +- `src`:源语言(如 `zh` / `en` / `auto`),是否包含在 key 中由 `key_include_source_lang` 控制;
  93 +- `tgt`:目标语言,如 `en` / `zh`;
  94 +- `sha256(payload)`:对以下内容整体做 SHA-256:
  95 + - `model`
  96 + - `src` / `tgt`
  97 + - `context`(受 `key_include_context` 控制)
  98 + - `prompt`(受 `key_include_prompt` 控制)
  99 + - 原始 `text`
  100 +
  101 +> 注意:所有 key 设计集中在 `_build_cache_key`,**不要在其他位置手动拼翻译缓存 key**。
  102 +
  103 +### 3.2 Value 与类型
  104 +
  105 +- 类型:**UTF-8 字符串**,即翻译后的文本结果。
  106 +- 存取逻辑:
  107 + - 读取:`redis.get(key)` 返回 `str` 或 `None`;
  108 + - 写入:`redis.setex(key, expire_seconds, translation)`。
  109 +
  110 +### 3.3 过期策略
  111 +
  112 +- 配置来源:`config/config.yaml -> services.translation.cache`,经 `get_translation_cache_config()` 解析:
  113 +
  114 +```yaml
  115 +services:
  116 + translation:
  117 + cache:
  118 + enabled: true
  119 + key_prefix: "trans:v2"
  120 + ttl_seconds: 62208000 # 默认约 720 天
  121 + sliding_expiration: true
  122 + key_include_context: true
  123 + key_include_prompt: true
  124 + key_include_source_lang: true
  125 +```
  126 +
  127 +- 运行时行为:
  128 + - 创建 `Translator` 时,从 `cache_cfg` 读取:
  129 + - `self.cache_prefix`
  130 + - `self.expire_seconds`
  131 + - `self.cache_sliding_expiration`
  132 + - `self.cache_include_*` 一系列布尔开关;
  133 + - **读缓存**:
  134 + - 命中后,若 `sliding_expiration=True`,会调用 `redis.expire(key, expire_seconds)`;
  135 + - **写缓存**:
  136 + - 使用 `redis.setex(key, expire_seconds, translation)`。
  137 +
  138 +### 3.4 关联模块
  139 +
  140 +- `api/translator_app.py` 会通过 `query.qwen_mt_translate.Translator` 复用同一套缓存逻辑;
  141 +- 文档说明:`docs/翻译模块说明.md` 中提到“推荐通过 Redis 翻译缓存复用结果”。
  142 +
  143 +---
  144 +
  145 +## 4. 商品内容理解缓存(indexer/product_enrich.py)
  146 +
  147 +- **代码位置**:`indexer/product_enrich.py`
  148 +- **用途**:在生成商品锚文本(qanchors)、语义属性、标签等内容理解结果时复用缓存,避免对同一标题重复调用大模型。
  149 +
  150 +### 4.1 Key 设计
  151 +
  152 +- 配置项:
  153 + - `ANCHOR_CACHE_PREFIX = REDIS_CONFIG.get("anchor_cache_prefix", "product_anchors")`
  154 + - `ANCHOR_CACHE_EXPIRE_DAYS = int(REDIS_CONFIG.get("anchor_cache_expire_days", 30))`
  155 +- Key 构造函数:`_make_anchor_cache_key(title, target_lang, tenant_id)`
  156 +- 模板:
  157 +
  158 +```text
  159 +{ANCHOR_CACHE_PREFIX}:{tenant_or_global}:{target_lang}:{md5(title)}
  160 +```
  161 +
  162 +- 字段说明:
  163 + - `ANCHOR_CACHE_PREFIX`:默认 `"product_anchors"`,可通过 `.env` 中的 `REDIS_ANCHOR_CACHE_PREFIX`(若存在)间接配置到 `REDIS_CONFIG`;
  164 + - `tenant_or_global`:`tenant_id` 去空白后的字符串,若为空则使用 `"global"`;
  165 + - `target_lang`:内容理解输出语言,例如 `zh`;
  166 + - `md5(title)`:对原始商品标题(UTF-8)做 MD5。
  167 +
  168 +### 4.2 Value 与类型
  169 +
  170 +- 类型:`json.dumps(dict, ensure_ascii=False)`。
  171 +- 典型结构(简化):
  172 +
  173 +```json
  174 +{
  175 + "id": "123",
  176 + "lang": "zh",
  177 + "title_input": "原始标题",
  178 + "title": "归一化后的商品标题",
  179 + "category_path": "...",
  180 + "tags": "...",
  181 + "target_audience": "...",
  182 + "usage_scene": "...",
  183 + "anchor_text": "..., ..."
  184 +}
  185 +```
  186 +
  187 +- 读取时通过 `json.loads(raw)` 还原为 `Dict[str, Any]`。
  188 +
  189 +### 4.3 过期策略
  190 +
  191 +- TTL:`ttl = ANCHOR_CACHE_EXPIRE_DAYS * 24 * 3600` 秒(默认 30 天);
  192 +- 写入:`redis.setex(key, ttl, json.dumps(result, ensure_ascii=False))`;
  193 +- 读取:仅做 `redis.get(key)`,**不做滑动过期**。
  194 +
  195 +### 4.4 调用流程中的位置
  196 +
  197 +- 单条调用(索引阶段常见)时,`analyze_products()` 会先尝试命中缓存:
  198 + - 若命中,直接返回缓存结果;
  199 + - 若 miss,调用 LLM,解析结果后再写入缓存。
  200 +
  201 +---
  202 +
  203 +## 5. Redis 运维脚本工具
  204 +
  205 +`scripts/redis/` 下提供三个脚本,用于查看缓存数量、内存占用与健康状态。连接配置均来自 `config/env_config.py` 的 `REDIS_CONFIG`,运行前需在项目根目录执行(或保证 `PYTHONPATH` 含项目根),以便加载配置。
  206 +
  207 +### 5.1 redis_cache_health_check.py(缓存健康巡检)
  208 +
  209 +**功能**:按**业务缓存类型**(embedding / translation / anchors)做健康巡检,不扫全库。
  210 +
  211 +- 对每类缓存:SCAN 匹配对应 key 前缀,统计**匹配 key 数量**(受 `--max-scan` 上限约束);
  212 +- **TTL 分布**:对采样 key 统计 `no-expire-or-expired` / `0-1h` / `1h-1d` / `1d-30d` / `>30d`;
  213 +- **近期活跃 key**:从采样中选出 `OBJECT IDLETIME <= 600s` 的 key,用于判断是否有新写入;
  214 +- **样本 key 与 value 预览**:对 embedding 显示 ndarray 信息,对 translation 显示译文片段,对 anchors 显示 JSON 摘要。
  215 +
  216 +**适用场景**:日常查看三类缓存是否在增长、TTL 是否合理、是否有近期写入;与「缓存总览表」中的 key 设计一一对应。
  217 +
  218 +**用法示例**:
  219 +
  220 +```bash
  221 +# 默认:检查 embedding / translation / anchors 三类
  222 +python scripts/redis/redis_cache_health_check.py
  223 +
  224 +# 只检查某一类或两类
  225 +python scripts/redis/redis_cache_health_check.py --type embedding
  226 +python scripts/redis/redis_cache_health_check.py --type translation anchors
  227 +
  228 +# 按自定义 pattern 检查(不按业务类型)
  229 +python scripts/redis/redis_cache_health_check.py --pattern "mycache:*"
  230 +
  231 +# 调整采样与扫描规模
  232 +python scripts/redis/redis_cache_health_check.py --sample-size 100 --max-scan 50000 --db 0
  233 +```
  234 +
  235 +**常用参数**:
  236 +
  237 +| 参数 | 说明 | 默认 |
  238 +|------|------|------|
  239 +| `--type` | 缓存类型:`embedding` / `translation` / `anchors`,可多选 | 三类都检查 |
  240 +| `--pattern` | 自定义 key pattern(如 `mycache:*`),指定后忽略 `--type` | - |
  241 +| `--db` | Redis 数据库编号 | 0 |
  242 +| `--sample-size` | 每类采样的 key 数量 | 50 |
  243 +| `--max-scan` | 每类最多 SCAN 的 key 数量上限 | 20000 |
  244 +
  245 +---
  246 +
  247 +### 5.2 redis_cache_prefix_stats.py(按前缀统计条数与内存)
  248 +
  249 +**功能**:**全局视角**,扫描当前 DB 下所有 key,按 key 的**前缀**(第一个冒号前)分类,统计每类 key 的**条数**与**内存占用量**(含占比),并输出每类示例 key 与 Redis 总内存信息。
  250 +
  251 +- 使用 `SCAN` 扫全库,按前缀聚合;
  252 +- 内存优先用 Redis `MEMORY USAGE`,不可用时用 key+value 长度估算;key 过多时按 `--sample-size` 采样后按均值推算总内存;
  253 +- 输出:前缀、条数、内存及计算方式、占比、简要说明(如「翻译缓存」「向量化缓存」);末尾附 Redis 总内存与 maxmemory(若配置)。
  254 +
  255 +**适用场景**:了解「哪一类前缀占了多少条、多少内存」,做容量规划或清理决策;支持多 DB(`--all-db`)或指定前缀(`--prefix`)缩小范围。
  256 +
  257 +**用法示例**:
  258 +
  259 +```bash
  260 +# 默认 DB 0,全库按前缀统计
  261 +python scripts/redis/redis_cache_prefix_stats.py
  262 +
  263 +# 统计所有有数据的 DB
  264 +python scripts/redis/redis_cache_prefix_stats.py --all-db
  265 +
  266 +# 指定 DB
  267 +python scripts/redis/redis_cache_prefix_stats.py --db 1
  268 +
  269 +# 只统计指定前缀(可多个)
  270 +python scripts/redis/redis_cache_prefix_stats.py --prefix trans embedding product_anchors
  271 +
  272 +# 全 DB + 指定前缀
  273 +python scripts/redis/redis_cache_prefix_stats.py --all-db --prefix trans embedding
  274 +```
  275 +
  276 +**常用参数**:
  277 +
  278 +| 参数 | 说明 | 默认 |
  279 +|------|------|------|
  280 +| `--prefix` | 只统计这些前缀下的 key(如 `trans` `embedding`) | 全库按前缀统计 |
  281 +| `--db` | 数据库编号(0–15) | 0 |
  282 +| `--all-db` | 对所有有数据的 DB 分别执行 | 否 |
  283 +| `--sample-size` | 单前缀 key 过多时,用于内存采样的数量 | 100 |
  284 +| `--real` | 对单前缀 key 数 ≤10000 时计算全部 key 真实内存(较慢) | 否 |
  285 +
  286 +---
  287 +
  288 +### 5.3 redis_memory_heavy_keys.py(大 key / 内存占用排查)
  289 +
  290 +**功能**:找出当前 DB 中**占用内存最多的 key**,并分析「按 key 估算的总内存」与「Redis 实际使用内存」的差异原因。
  291 +
  292 +- 全库 `SCAN` 获取 key 列表,对 key 采样(默认最多 1000)调用 `MEMORY USAGE`(或估算)得到单 key 内存;
  293 +- 按内存排序,输出**占用最高的 N 个 key**(`--top`,默认 50);
  294 +- 输出:前缀分布、采样统计、估算总内存 vs 实际内存、差异说明(碎片、内部结构等);并检测超大 value(>1MB)、key 类型分布。
  295 +
  296 +**适用场景**:内存异常升高时定位大 key;理解为何「按 key 加总」与 `used_memory` 不一致。
  297 +
  298 +**用法示例**:
  299 +
  300 +```bash
  301 +# 显示占用内存最多的 50 个 key(默认)
  302 +python scripts/redis/redis_memory_heavy_keys.py
  303 +
  304 +# 显示前 100 个
  305 +python scripts/redis/redis_memory_heavy_keys.py --top 100
  306 +```
  307 +
  308 +**常用参数**:
  309 +
  310 +| 参数 | 说明 | 默认 |
  311 +|------|------|------|
  312 +| `--top` | 显示内存占用最高的 N 个 key | 50 |
  313 +
  314 +---
  315 +
  316 +### 5.4 三个脚本的选用建议
  317 +
  318 +| 需求 | 推荐脚本 |
  319 +|------|----------|
  320 +| 看三类业务缓存(embedding/translation/anchors)的数量、TTL、近期写入、样本 value | `redis_cache_health_check.py` |
  321 +| 看全库或某前缀的 key 条数与内存占比 | `redis_cache_prefix_stats.py` |
  322 +| 找占用内存最多的大 key、分析内存差异 | `redis_memory_heavy_keys.py` |
  323 +
  324 +---
  325 +
  326 +## 6. 其他 Redis 相关代码(测试与替身)
  327 +
  328 +除上述运维脚本外,以下代码使用 Redis 或其替身,但不定义新的业务缓存 key 协议:
  329 +
  330 +- **单元测试**:`tests/test_embedding_pipeline.py` 中的 `_FakeRedis` 用于 mock embedding 缓存;`tests/test_translator_failure_semantics.py` 中的 `_RecordingRedis` 用于验证翻译失败时不写缓存。
  331 +
  332 +---
  333 +
  334 +## 7. 添加新缓存时的建议
  335 +
  336 +新增 Redis 缓存时,建议遵循以下约定:
  337 +
  338 +- **配置集中**:
  339 + - key 前缀、TTL 优先放在 `config/env_config.py`(通用)或 `config/config.yaml -> services.<capability>.cache`;
  340 + - 避免在业务代码中硬编码 TTL。
  341 +- **命名规范**:
  342 + - 统一使用 `prefix:维度1:维度2:...` 的扁平 key 结构;
  343 + - 对长文本/value 使用 `md5`/`sha256` 做哈希,避免过长 key。
  344 +- **文档同步**:
  345 + - 新增缓存后,应在本文件中补充一行总览表 + 详细小节;
  346 + - 若缓存与外部系统/历史实现兼容(如 Java 侧翻译缓存),需在说明中显式标注。
  347 +
... ...
scripts/redis/redis_cache_health_check.py 0 → 100644
... ... @@ -0,0 +1,401 @@
  1 +#!/usr/bin/env python3
  2 +"""
  3 +缓存状态巡检脚本
  4 +
  5 +按「缓存类型」维度(embedding / translation / anchors)查看:
  6 +- 估算 key 数量
  7 +- TTL 分布(采样)
  8 +- 近期活跃 key(按 IDLETIME 近似)
  9 +- 若干样本 key 及 value 概览
  10 +
  11 +使用示例:
  12 +
  13 + # 默认:检查已知三类缓存,使用 env_config 中的 Redis 配置
  14 + python scripts/redis/redis_cache_health_check.py
  15 +
  16 + # 只看某一类缓存
  17 + python scripts/redis/redis_cache_health_check.py --type embedding
  18 + python scripts/redis/redis_cache_health_check.py --type translation anchors
  19 +
  20 + # 自定义前缀(pattern),不限定缓存类型
  21 + python scripts/redis/redis_cache_health_check.py --pattern "mycache:*"
  22 +
  23 + # 调整采样规模
  24 + python scripts/redis/redis_cache_health_check.py --sample-size 100 --max-scan 50000
  25 +"""
  26 +
  27 +from __future__ import annotations
  28 +
  29 +import argparse
  30 +import json
  31 +import pickle
  32 +import sys
  33 +from collections import defaultdict
  34 +from dataclasses import dataclass
  35 +from datetime import datetime
  36 +from pathlib import Path
  37 +from typing import Dict, Iterable, List, Optional, Tuple
  38 +
  39 +import redis
  40 +import numpy as np
  41 +
  42 +# 让脚本可以直接使用 config/env_config 与 services_config
  43 +PROJECT_ROOT = Path(__file__).parent.parent.parent
  44 +sys.path.insert(0, str(PROJECT_ROOT))
  45 +
  46 +from config.env_config import REDIS_CONFIG # type: ignore
  47 +from config.services_config import get_translation_cache_config # type: ignore
  48 +
  49 +
  50 +@dataclass
  51 +class CacheTypeConfig:
  52 + name: str
  53 + pattern: str
  54 + description: str
  55 +
  56 +
  57 +def _load_known_cache_types() -> Dict[str, CacheTypeConfig]:
  58 + """根据当前配置装配三种已知缓存类型及其前缀 pattern。"""
  59 + cache_types: Dict[str, CacheTypeConfig] = {}
  60 +
  61 + # embedding 缓存:固定 embedding:* 前缀
  62 + cache_types["embedding"] = CacheTypeConfig(
  63 + name="embedding",
  64 + pattern="embedding:*",
  65 + description="文本向量缓存(embeddings/text_encoder.py)",
  66 + )
  67 +
  68 + # translation 缓存:prefix 来自 services.translation.cache.key_prefix
  69 + cache_cfg = get_translation_cache_config()
  70 + trans_prefix = cache_cfg.get("key_prefix", "trans:v2")
  71 + cache_types["translation"] = CacheTypeConfig(
  72 + name="translation",
  73 + pattern=f"{trans_prefix}:*",
  74 + description="翻译结果缓存(query/qwen_mt_translate.Translator)",
  75 + )
  76 +
  77 + # anchors 缓存:prefix 来自 REDIS_CONFIG['anchor_cache_prefix'](若存在),否则 product_anchors
  78 + anchor_prefix = REDIS_CONFIG.get("anchor_cache_prefix", "product_anchors")
  79 + cache_types["anchors"] = CacheTypeConfig(
  80 + name="anchors",
  81 + pattern=f"{anchor_prefix}:*",
  82 + description="商品内容理解缓存(indexer/product_enrich.py,anchors/语义属性/tags)",
  83 + )
  84 +
  85 + return cache_types
  86 +
  87 +
  88 +def get_redis_client(db: int = 0) -> redis.Redis:
  89 + return redis.Redis(
  90 + host=REDIS_CONFIG.get("host", "localhost"),
  91 + port=REDIS_CONFIG.get("port", 6479),
  92 + password=REDIS_CONFIG.get("password"),
  93 + db=db,
  94 + decode_responses=False, # 原始 bytes,方便区分 pickle / str
  95 + socket_timeout=10,
  96 + socket_connect_timeout=10,
  97 + )
  98 +
  99 +
  100 +def scan_keys(
  101 + client: redis.Redis, pattern: str, max_scan: int, scan_count: int = 1000
  102 +) -> List[bytes]:
  103 + """使用 SCAN 扫描匹配 pattern 的 key,最多扫描 max_scan 个结果。"""
  104 + keys: List[bytes] = []
  105 + cursor: int = 0
  106 + scanned = 0
  107 + while True:
  108 + cursor, batch = client.scan(cursor=cursor, match=pattern, count=scan_count)
  109 + for k in batch:
  110 + keys.append(k)
  111 + scanned += 1
  112 + if scanned >= max_scan:
  113 + return keys
  114 + if cursor == 0:
  115 + break
  116 + return keys
  117 +
  118 +
  119 +def ttl_bucket(ttl: int) -> str:
  120 + """将 TTL(秒)归类到简短区间标签中。"""
  121 + if ttl < 0:
  122 + # -1: 永不过期;-2: 不存在
  123 + return "no-expire-or-expired"
  124 + if ttl <= 3600:
  125 + return "0-1h"
  126 + if ttl <= 86400:
  127 + return "1h-1d"
  128 + if ttl <= 30 * 86400:
  129 + return "1d-30d"
  130 + return ">30d"
  131 +
  132 +
  133 +def format_seconds(sec: int) -> str:
  134 + if sec < 0:
  135 + return str(sec)
  136 + if sec < 60:
  137 + return f"{sec}s"
  138 + if sec < 3600:
  139 + return f"{sec // 60}m{sec % 60}s"
  140 + if sec < 86400:
  141 + h = sec // 3600
  142 + m = (sec % 3600) // 60
  143 + return f"{h}h{m}m"
  144 + d = sec // 86400
  145 + h = (sec % 86400) // 3600
  146 + return f"{d}d{h}h"
  147 +
  148 +
  149 +def decode_value_preview(
  150 + cache_type: str, key: bytes, raw_value: Optional[bytes]
  151 +) -> str:
  152 + """根据缓存类型生成简短的 value 概览字符串。"""
  153 + if raw_value is None:
  154 + return "<nil>"
  155 +
  156 + # embedding: pickle 序列化的 numpy.ndarray
  157 + if cache_type == "embedding":
  158 + try:
  159 + arr = pickle.loads(raw_value)
  160 + if isinstance(arr, np.ndarray):
  161 + return f"ndarray shape={arr.shape} dtype={arr.dtype}"
  162 + return f"pickle object type={type(arr).__name__}"
  163 + except Exception:
  164 + return f"<binary {len(raw_value)} bytes>"
  165 +
  166 + # anchors: JSON dict
  167 + if cache_type == "anchors":
  168 + try:
  169 + text = raw_value.decode("utf-8", errors="replace")
  170 + obj = json.loads(text)
  171 + if isinstance(obj, dict):
  172 + brief = {
  173 + k: obj.get(k)
  174 + for k in ["id", "lang", "title_input", "title", "category_path", "anchor_text"]
  175 + if k in obj
  176 + }
  177 + return "json " + json.dumps(brief, ensure_ascii=False)[:200]
  178 + # 其他情况简单截断
  179 + return "json " + text[:200]
  180 + except Exception:
  181 + return raw_value.decode("utf-8", errors="replace")[:200]
  182 +
  183 + # translation: 纯字符串
  184 + if cache_type == "translation":
  185 + try:
  186 + text = raw_value.decode("utf-8", errors="replace")
  187 + return text[:200]
  188 + except Exception:
  189 + return f"<binary {len(raw_value)} bytes>"
  190 +
  191 + # 兜底:尝试解码为 UTF-8
  192 + try:
  193 + text = raw_value.decode("utf-8", errors="replace")
  194 + return text[:200]
  195 + except Exception:
  196 + return f"<binary {len(raw_value)} bytes>"
  197 +
  198 +
  199 +def analyze_cache_type(
  200 + client: redis.Redis,
  201 + cache_type: str,
  202 + cfg: CacheTypeConfig,
  203 + sample_size: int,
  204 + max_scan: int,
  205 +) -> None:
  206 + """对单个缓存类型做统计与样本展示。"""
  207 + print("=" * 80)
  208 + print(f"Cache type: {cache_type} pattern={cfg.pattern}")
  209 + print(f"Description: {cfg.description}")
  210 + print("=" * 80)
  211 +
  212 + keys = scan_keys(client, cfg.pattern, max_scan=max_scan)
  213 + total_scanned = len(keys)
  214 + print(f"Scanned up to {max_scan} keys, matched {total_scanned} keys.")
  215 + if total_scanned == 0:
  216 + print("No keys found for this cache type.\n")
  217 + return
  218 +
  219 + # 采样
  220 + if total_scanned <= sample_size:
  221 + sampled_keys = keys
  222 + else:
  223 + # 简单取前 sample_size 个,scan 本身已是渐进遍历,足够近似随机
  224 + sampled_keys = keys[:sample_size]
  225 +
  226 + ttl_hist: Dict[str, int] = defaultdict(int)
  227 + recent_keys: List[Tuple[bytes, int, int]] = [] # (key, ttl, idletime)
  228 + samples: List[Tuple[bytes, int, int, str]] = [] # (key, ttl, idletime, preview)
  229 +
  230 + for k in sampled_keys:
  231 + try:
  232 + ttl = client.ttl(k)
  233 + except Exception:
  234 + ttl = -3 # 表示 TTL 查询失败
  235 +
  236 + # TTL 分布
  237 + ttl_hist[ttl_bucket(ttl)] += 1
  238 +
  239 + # 近期活跃判断(idletime 越小越“新”)
  240 + idletime = -1
  241 + try:
  242 + # OBJECT IDLETIME 返回秒数(整数)
  243 + idletime = client.object("idletime", k) # type: ignore[arg-type]
  244 + except Exception:
  245 + pass
  246 +
  247 + # 记录近期活跃样本
  248 + if idletime >= 0 and idletime <= 600:
  249 + recent_keys.append((k, ttl, idletime))
  250 +
  251 + # 收集样本 value 预览(控制数量)
  252 + if len(samples) < 5:
  253 + raw_val = None
  254 + try:
  255 + raw_val = client.get(k)
  256 + except Exception:
  257 + pass
  258 + preview = decode_value_preview(cache_type, k, raw_val)
  259 + samples.append((k, ttl, idletime, preview))
  260 +
  261 + # TTL 分布输出
  262 + print("\nTTL distribution (sampled):")
  263 + total_sampled = len(sampled_keys)
  264 + for bucket in ["no-expire-or-expired", "0-1h", "1h-1d", "1d-30d", ">30d"]:
  265 + cnt = ttl_hist.get(bucket, 0)
  266 + pct = (cnt / total_sampled * 100.0) if total_sampled else 0.0
  267 + print(f" {bucket:<18}: {cnt:>4} ({pct:>5.1f}%)")
  268 +
  269 + # 近期活跃 key
  270 + recent_keys = sorted(recent_keys, key=lambda x: x[2])[:5]
  271 + print("\nRecent active keys (idletime <= 600s, from sampled set):")
  272 + if not recent_keys:
  273 + print(" (none in sampled set)")
  274 + else:
  275 + for k, ttl, idle in recent_keys:
  276 + try:
  277 + k_str = k.decode("utf-8", errors="replace")
  278 + except Exception:
  279 + k_str = repr(k)
  280 + if len(k_str) > 80:
  281 + k_str = k_str[:77] + "..."
  282 + print(
  283 + f" key={k_str} ttl={ttl} ({format_seconds(ttl)}) "
  284 + f"idletime={idle} ({format_seconds(idle)})"
  285 + )
  286 +
  287 + # 样本 value 概览
  288 + print("\nSample keys & value preview:")
  289 + if not samples:
  290 + print(" (no samples)")
  291 + else:
  292 + for k, ttl, idle, preview in samples:
  293 + try:
  294 + k_str = k.decode("utf-8", errors="replace")
  295 + except Exception:
  296 + k_str = repr(k)
  297 + if len(k_str) > 80:
  298 + k_str = k_str[:77] + "..."
  299 + print(f" key={k_str}")
  300 + print(f" ttl={ttl} ({format_seconds(ttl)}) idletime={idle} ({format_seconds(idle)})")
  301 + print(f" value: {preview}")
  302 +
  303 + print() # 结尾空行
  304 +
  305 +
  306 +def main() -> None:
  307 + parser = argparse.ArgumentParser(description="Redis 缓存状态巡检(按缓存类型)")
  308 + parser.add_argument(
  309 + "--type",
  310 + dest="types",
  311 + nargs="+",
  312 + choices=["embedding", "translation", "anchors"],
  313 + help="指定要检查的缓存类型(默认:三种全部)",
  314 + )
  315 + parser.add_argument(
  316 + "--pattern",
  317 + type=str,
  318 + help="自定义 key pattern(如 'mycache:*')。设置后将忽略 --type,仅按 pattern 进行一次检查。",
  319 + )
  320 + parser.add_argument(
  321 + "--db",
  322 + type=int,
  323 + default=0,
  324 + help="Redis 数据库编号(默认 0)",
  325 + )
  326 + parser.add_argument(
  327 + "--sample-size",
  328 + type=int,
  329 + default=50,
  330 + help="每种缓存类型采样的 key 数量(默认 50)",
  331 + )
  332 + parser.add_argument(
  333 + "--max-scan",
  334 + type=int,
  335 + default=20000,
  336 + help="每种缓存类型最多 SCAN 的 key 数量上限(默认 20000)",
  337 + )
  338 +
  339 + args = parser.parse_args()
  340 +
  341 + print("Redis 缓存状态巡检")
  342 + print("=" * 80)
  343 + print(f"Checked at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
  344 + print(f"Redis host={REDIS_CONFIG.get('host', 'localhost')} port={REDIS_CONFIG.get('port', 6479)} db={args.db}")
  345 + print()
  346 +
  347 + try:
  348 + client = get_redis_client(db=args.db)
  349 + client.ping()
  350 + print("✅ Redis 连接成功\n")
  351 + except Exception as exc:
  352 + print(f"❌ Redis 连接失败: {exc}")
  353 + print(f" Host: {REDIS_CONFIG.get('host', 'localhost')}")
  354 + print(f" Port: {REDIS_CONFIG.get('port', 6479)}")
  355 + print(f" Password: {'已配置' if REDIS_CONFIG.get('password') else '未配置'}")
  356 + return
  357 +
  358 + # 如果指定了自定义 pattern,则只做一次“匿名类型”的巡检
  359 + if args.pattern:
  360 + anon_cfg = CacheTypeConfig(
  361 + name=f"pattern:{args.pattern}",
  362 + pattern=args.pattern,
  363 + description="自定义 pattern 检查",
  364 + )
  365 + analyze_cache_type(
  366 + client=client,
  367 + cache_type="custom",
  368 + cfg=anon_cfg,
  369 + sample_size=args.sample_size,
  370 + max_scan=args.max_scan,
  371 + )
  372 + print("巡检完成。")
  373 + return
  374 +
  375 + # 否则根据已知缓存类型巡检
  376 + known_types = _load_known_cache_types()
  377 + types_to_check: Iterable[str]
  378 + if args.types:
  379 + types_to_check = args.types
  380 + else:
  381 + types_to_check = known_types.keys()
  382 +
  383 + for t in types_to_check:
  384 + cfg = known_types.get(t)
  385 + if not cfg:
  386 + print(f"⚠️ 未知缓存类型: {t},跳过")
  387 + continue
  388 + analyze_cache_type(
  389 + client=client,
  390 + cache_type=t,
  391 + cfg=cfg,
  392 + sample_size=args.sample_size,
  393 + max_scan=args.max_scan,
  394 + )
  395 +
  396 + print("巡检完成。")
  397 +
  398 +
  399 +if __name__ == "__main__":
  400 + main()
  401 +
... ...
scripts/redis/check_cache_stats.py renamed to scripts/redis/redis_cache_prefix_stats.py
... ... @@ -7,19 +7,19 @@
7 7 使用方法:
8 8  
9 9 直接使用(默认数据库 0):
10   -python scripts/redis/check_cache_stats.py
  10 +python scripts/redis/redis_cache_prefix_stats.py
11 11  
12 12 统计所有数据库:
13   -python scripts/redis/check_cache_stats.py --all-db
  13 +python scripts/redis/redis_cache_prefix_stats.py --all-db
14 14  
15 15 统计指定数据库:
16   -python scripts/redis/check_cache_stats.py --db 1
  16 +python scripts/redis/redis_cache_prefix_stats.py --db 1
17 17  
18 18 只统计以下三种前缀:
19   -python scripts/redis/check_cache_stats.py --prefix trans embedding product
  19 +python scripts/redis/redis_cache_prefix_stats.py --prefix trans embedding product
20 20  
21 21 统计所有数据库的指定前缀:
22   -python scripts/redis/check_cache_stats.py --all-db --prefix trans embedding
  22 +python scripts/redis/redis_cache_prefix_stats.py --all-db --prefix trans embedding
23 23  
24 24  
25 25  
... ... @@ -487,7 +487,7 @@ def analyze_all_databases(args):
487 487 print(f"总 key 数量: {total_keys_all_db:,}")
488 488 print(f"\n提示: 要查看详细的内存统计,请分别运行每个数据库:")
489 489 for db_num in databases:
490   - print(f" python scripts/redis/check_cache_stats.py --db {db_num}")
  490 + print(f" python scripts/redis/redis_cache_prefix_stats.py --db {db_num}")
491 491  
492 492 def main():
493 493 """主函数"""
... ...
scripts/redis/find_memory_usage.py renamed to scripts/redis/redis_memory_heavy_keys.py