缓存结构
本文记录项目当前使用的 Redis / Django cache 结构,以及缓存与数据库、API 之间的读写流向。数据库字段结构以各 app 的 models.py 为准,这里只记录缓存中实际保存的数据结构。
旧竞速排行榜及新闻的读写逻辑已移除,可运行python manage.py delete_legacy_speedranking_cache清理旧个人纪录hash、排行zset及news_queue。支持--dry-run预览,具体范围见管理命令文档。
旧 World 监控页面及其 CPU/网络 IO 采集已移除,不再读写历史采样。monitor APP 现在承载服务器页面的磁盘、任务、录像和内存统计 Ninja API,路径为 /api/monitor/diskusage、/api/monitor/tasksummary、/api/monitor/videosummary、/api/monitor/memoryusage;旧 /monitor/io_cpus/ 和 /monitor/capacity/ 不恢复。部署并停止旧采集进程后,可按固定名称清理 saolei_website Redis DB 中的五个遗留 key,无需遍历其他 key:
redis-cli -n 0 DEL io_s_old io_r_old io_s_spds io_r_spds cpus服务器内存统计
GET /api/monitor/memoryusage 同时返回进程内存 memory 和 Redis 分配内存 redis。monitor/cache.py 通过 Django default Redis 缓存保存完整返回体,逻辑 key 为 api:monitor/memoryusage,TTL 300 秒。命中不重新采集、不读取用户 ID、不续期;到期后的首次请求重新计算。不新增 APScheduler 任务。
每块包含 status、UTC sampled_at、固定错误码 reason、非负整数 total_bytes 和固定分类 groups。进程按 uWSGI(含 master/workers)、本项目 APScheduler、Redis、MySQL、db_worker、其他进程聚合。Linux 统一采集 PSS,其他平台统一 RSS;RSS 可能重复计算共享页。合计仅覆盖进程驻留内存,主机总内存/可用内存单独放在 memory.host,不包含在图中合计内。本机 Redis 服务进程按进程名或可执行文件名汇总,Redis/MySQL 连接配置为远程或已知容器隔离时,进程块返回范围不足。
Redis 分类只读取业务代码直接确定的 DB 0 key,不执行 SCAN、scan_iter、KEYS 或排行榜成员枚举:
| 分类 | key 来源 |
|---|---|
| PB 榜 | PBRankingCache.counts_key;固定 hash 的 HKEYS 经校验后构造小榜 key;从 UserProfile 主键顺序每批最多 1,000 个 ID 构造用户 hash key。元数据缺失不发现 key、不触发重建。 |
| 其他排行榜 | SpeedRankingCache(name, stats=RANK_STATS).keys、TopRankingCache().keys、四种自定义 pluck 配置的 rank/detail key、比赛用户榜 key 和积分时间 key;当前共 54 个 key。 |
| 最新录像 | newest_cache.key(newest_queue)。 |
| 其他 | INFO memory.used_memory 减去前三类;包含其他业务、会话、同实例 DB 1 通用缓存、孤立/历史/临时 key 和 Redis 开销,不枚举 DB 1。 |
所有已知 key 去重后以 pipeline(transaction=False) 每批最多 100 个执行 MEMORY USAGE ... SAMPLES 5,不存在的 key 为 0。该结果为估算,redis.estimated=true、redis.samples=5;总量为实例 used_memory,不同于进程 RSS/PSS。分类合计大于实例总量时不发布分布。两块共享 5 秒协作式采集预算,另限制最多 5,000 个进程和 50,000 个 key;阻塞中的 socket/查询仍受各自超时控制,因此 5 秒不是硬中断时限。
进程采集中退出的 PID 跳过;存活进程权限不足时整块不可用,并通过 memory.unreadable_processes 记录数量,不能当作零。不可用块的时间、总量和分类为 null,另一块可独立成功;完整结果仍缓存 300 秒,避免重复失败采集。缓存后端本身出错则请求失败,不绕过缓存继续采集。日志仅记录安全原因、耗时和数量,不记录 PID、命令行、key 名或业务值。上线需以实际 API 用户验证 Linux /proc 权限,并抽查冷缓存耗时、Redis 计量结果及缓存 TTL。前端可保留当前页面上次成功的图,明确标注旧数据及原采集时间。
总览
独立的本地初始化脚本 dangerzone/init_local_test.py 会清空本地 saolei_website Redis 数据库(包含会话),然后根据导入的公开录像重建竞速榜(扫雷网普通/NF、PB 普通/NF、top 十八项及锚点)、效率 Thrp 榜、pluck 纪录缓存和录像状态队列。在 back_end/saolei 目录使用 python -m dangerzone.init_local_test 启动。只允许本地测试配置,并拒绝存在 .production 标记的环境;不注册 import_public_data 管理命令。详情及数据缺失限制见本地公开数据快照。
新竞速榜 speedranking
扫雷网规则使用 speedranking:{ranking_name}:records hash 保存个人纪录,使用 speedranking:{ranking_name}:{stat} zset 保存以玩家 id 为 member 的排序索引,由 speedranking.saolei.cache.SpeedRankingCache 管理。PB 是一般约定的例外:每个 (普通/NF, level, bv) 一个 zset,每个用户一个包含用时、录像 id 和 rank 的 hash,由 speedranking.pb.cache.PBRankingCache 管理。
共享缓存层提供连接及 pipeline,扫雷网、PB 和 top 的业务分别位于对应子目录。一次更新或每个批处理段共同读取用户纪录,独立判断后共同写入;PB 查询旧 score、刷新变化小榜的 rank,以及 top 更新锚点是依赖前序结果的后续批量阶段。必须补位的单项按各榜规则查询。
top 由 speedranking.top.cache.TopRankingCache 管理,包含用户前五明细、十八个原始索引、九个软实力/LSF 索引及锚点。缓存结构、后台发布与单机准入、前端启动快照约定统一见 back_end/saolei/speedranking/README.md。部署升级需要执行 top 重建命令,不能混用旧 score 编码。
排序编码、精度与数值范围、纪录更新及缓存升级约定统一维护在仓库的 back_end/saolei/speedranking/README.md,此处不再重复。重建命令用法见管理命令。
PB 只读接口不自动回源,初始化和修复用法见 PB 重建命令。前端 PB 排行页面已实现,分页上传时间直接由 zset score 解码,不增加查询或 hash 字段。
PB 另使用 speedranking:pb:counts hash 汇总各小榜人数,可通过 GET /api/speedranking/pb/counts 一次读取,随 rank 刷新同步更新;前端用于筛选网格的人数显示。已有缓存通过 PB 重建命令补齐。具体 field 及空榜约定见 app README。
管理员页面的“排行纪录重建” tab 可按大榜、小榜和用户 ID 修复单项缓存,对应接口为 POST /api/speedranking/admin/rebuild_record;具体更新范围和并发约定见上述 README。
效率榜 effranking
Thrp 的一个个人纪录 hash 和四个排序 zset 见下方数据结构表。编码、选优和事务边界统一见 back_end/saolei/effranking/README.md;首次部署及缓存丢失后执行 rebuild_eff_ranks,查询不自动回源。
缓存数据结构
| 所属 app | key | Redis 类型 | 数据结构 |
|---|---|---|---|
videomanager | newest_queue | hash | field = video_id;value = VideoQueue JSON,包含 state、tournament、software、time、player_id、identifier、level、mode、timems、bv、cl、ce、right_ce。 |
videomanager | freeze_queue | hash | 同 newest_queue,用于冻结录像队列。 |
videomanager | review_queue | hash | 同 newest_queue,用于待审核录像队列。 |
effranking | effranking:thrp:records | hash | field = player_id;完整 b/i/e 原始纪录 JSON,每项为 null 或包含 value、video_id、upload_time 的对象。sum 不重复存储。 |
effranking | effranking:thrp:{stat} | zset | stat = b/i/e/sum;member = player_id,score 为六位小数成绩和分钟级达成时间的排序编码,升序读取,不用于恢复成绩。 |
customranking | customranking:pluck:{level}:rank | zset | member = player_id;score = pluck,当 pluck == 0 时使用 timems - MAX_TIMEMS 降低 0 碰撞风险。 |
customranking | customranking:pluck:{level}:detail | hash | field = player_id;value = detail JSON,包含 video_id、mode、timems、bv、upload_time。 |
tournament | tournament:normal | hash | field = tournament_id;value = CachedTournament JSON,包含 id、state、subclass、host_id、start_time、end_time、data。data 保存子类独占字段:GSC 为 order、token;周赛为 year、week、tournament_format。 |
tournament | tournament:normal:participants | hash | field = user_id;value = list[CachedNormalParticipant] JSON,每项包含 id、token、arbiter_identifier、tournament、start_time、end_time。 |
tournament | tournament:user:score_current | zset | member = user_id;score = score_current × tournament_score_decay_factor(last_updated, 缓存时间基准);所有成员使用同一时间基准。数据库 score_current = 0 时不写入。 |
tournament | tournament:user:score_current:last_updated | string | 带时区的 ISO datetime;当前积分 zset 的年度统一锚点,正常为当年 1 月 1 日 00:00 UTC。由年度 APScheduler 任务更新;普通积分发放不推进。调度任务延迟时继续统一使用旧锚点。 |
tournament | tournament:user:score_total / gsc_total / gsc_best / weekly_total / weekly_classic_total / weekly_classic_best | zset | member = user_id;score = TournamentUser 对应字段值。各 total 字段为 0 时不写入;gsc_best、weekly_classic_best 为 MAX_TOURNAMENT_BEST 时不写入。 |
monitor | api:monitor/videosummary | Django cache | video_summary 返回体,TTL 300 秒。 |
monitor | api:monitor/tasksummary | Django cache | task_summary 返回体,TTL 300 秒;管理员删除或重启任务后失效。 |
monitor | api:monitor/diskusage | Django cache | disk_usage 返回体,TTL 300 秒。 |
monitor | api:monitor/memoryusage | Django cache | 进程和 Redis 两块内存统计的完整返回体,TTL 300 秒;独立不可用结果也缓存,命中不续期。 |
article | articles | list | 文章目录项字符串,来自 assets/article 或静态目录下的文章文件。 |
TournamentUser 是比赛积分的持久化汇总表;Redis 中另用 7 个 sorted set 保存排行榜入口。站内用户创建 TournamentParticipant / GSCParticipant / WeeklyParticipant 时,保存信号会在当前数据库事务内立即创建缺失的 TournamentUser;删除 participant 不会跟随删除 TournamentUser。无站内用户的 participant 在数据库中保留 user_id = NULL,但 API 序列化时统一输出 user_id = 0,前端只按 0 识别非站内用户。GSC / 周赛 finish 任务先调用 ensure_tournament_users,为本场所有站内参赛用户补建缺失的默认 TournamentUser,然后才调用 delete_participants_without_videos 删除无录像站内 participant。排名积分发放和 best 刷新是独立的非关键后台任务,入口同样先执行 ensure_tournament_users,支持独立执行或重跑;随后各自从本场剩余站内 participant 出发,通过 participant.user.tournamentuser 取得对应的 TournamentUser,并使用 select_related('user__tournamentuser') 避免 N+1。积分发放任务读取并衰减既有 TournamentUser.score_current,再根据本场 participant 的 rank_score 增量写回 TournamentUser 和 TournamentParticipant.rank_score,随后同步刷新 score_current、score_total 和对应分类 total 的 zset。历史最好成绩在 participant 保存后只读取当前 participant 和对应比赛子表,并与 TournamentUser.gsc_best / weekly_classic_best 直接比较;只有当前成绩更好时才改变内存值。结算流程使用 bulk_update 写入 rank_score,不会触发保存信号,因此 best 刷新通过 refresh_gsc_best_scores / refresh_weekly_best_scores 作为独立后台任务执行,并在写库后刷新对应 best zset;best 与排名积分发放没有顺序依赖。为了简化结算代码,积分发放和 best 刷新都不筛选字段是否发生变化,会统一 bulk_update 候选 participant 和对应 TournamentUser。结算链路通过 tournament logger 写入 logs/tournament.log,记录后台任务、删除无录像 participant、读取 TournamentUser、成绩/排名刷新、积分发放、best 刷新、状态切换和录像公开等阶段的处理数量。没有有效历史成绩时,best 字段使用 MAX_TOURNAMENT_BEST 作为哨兵值,并且不写入 Redis。participant 删除时,GSC / 周赛各自的信号会在当前事务内按需调用 calculate_gsc_best_score / calculate_weekly_classic_best 重算该类型历史最好成绩,并同步更新对应 best zset。由于 TournamentUser 是数据库汇总数据,participant 信号触发的创建和 best 更新必须在当前事务内立即执行,不使用 transaction.on_commit。如果需要修复历史汇总数据,尤其是把旧的 0 best 值迁移到新的最大值哨兵,可以运行 manage.py refresh_tournament_user_stats。该命令会先调用 tournament.services.refresh_tournament_user_total_fields 重建 score_total、gsc_total、weekly_total 和 weekly_classic_total,再执行命令内的 best 重建步骤写回 gsc_best 和 weekly_classic_best;score_current 依赖实时衰减,不由该命令刷新。如果 Redis 排行缓存丢失或需要按当前数据库状态重建,可以运行 manage.py rebuild_tournament_user_cache。
当前积分 zset 使用固定年度锚点。update_tournament_users 读取缓存锚点,仅换算本批用户,不再根据用户 last_updated 推进基准或整体衰减其他成员。空缓存首次写入时初始化为当年 1 月 1 日 00:00 UTC。折算使用 tournament_score_decay_factor(last_updated, 锚点),负时间差通过负指数自动兼容;因此年内新积分折算到年初时会放大,但所有成员共用锚点,排序等价于实时积分。锚点读取和本批写入使用 redis-py transaction(WATCH / MULTI / EXEC),年度更新并发发生时重试。数据库仍保留用户各自的 score_current / last_updated,API 回表输出这些原始值,前端按 globalNow 衰减显示;统一锚点只影响 Redis 排序。更新其他 total / best 字段不会改变当前积分基准。
common.apscheduler.refresh_tournament_score_reference 在每年 1 月 1 日 00:00 UTC 调用 TournamentCache.refresh_tournament_score_reference,使用 ZUNIONSTORE 权重将当前积分 zset 整体折算到新锚点,并在同一 Redis 事务中设置锚点;其他排行和数据库不变。同一年重复执行无副作用。调度器启动时也执行一次,补齐跨年停机;尚未补齐时,结算仍沿用旧锚点,不会混用年份。读取旧锚点、整体折算及设置新锚点采用 WATCH / MULTI / EXEC,与结算写入并发时保持一致。
rebuild_tournament_current_score_cache 从数据库分批读取当前积分,按当年年初锚点重算并原子替换当前积分 zset 和锚点,不改其他 6 个排行或数据库;并发缓存写入会触发重试,读请求在提交前仍能读取旧排行。为避免数据库尚未提交但缓存已写入的跨系统窗口,重算期间仍应暂停积分结算和历史积分修正任务。部署年度锚点方案后应先运行此命令,再重启 APScheduler。rebuild_tournament_user_cache 则清理并分批重建全部 7 个 zset 和年初锚点,重建期间仍需暂停其他排行写入。清理全部排行时也删除锚点 key;非空当前积分 zset 缺少锚点时会报错,要求显式重建,不进行数据库 fallback。
每批排行缓存更新按字段汇总:每个 zset 最多执行一次多成员 ZADD 和一次多成员 ZREM,分别写入非默认分数与移除默认分数成员,不再为每个用户单独排入命令。缓存重建按 batch_size 分批读取。
ensure_tournament_users 按本场 participant 的非空 user_id 去重并执行 get_or_create,不覆盖已有记录;默认积分为 0,best 为 MAX_TOURNAMENT_BEST,因此补建本身不写入排行 zset。此步骤兼容早期只有 participant、尚无 TournamentUser 且未发放积分的历史数据,不负责恢复已丢失的历史积分。新 participant 仍由创建信号保证关联完整;删除信号不容忍缺失关联,也不补建记录。每次任务会记录补建数量,无需额外管理命令。
GSCParticipant.t37 是数据库持久化 generated field,并建立 gsc_t37_idx 索引,服务于 GSC 排名和历史最好成绩查询。保存信号使用当前 participant 实例上的临时 t37 值进行 best 增量比较,因此不会为了读取 generated field 再查询一次 participant。
写入与重建入口
| 缓存 | 主要写入入口 | 重建 / 清理入口 |
|---|---|---|
| 录像状态队列 | videomanager.signals.refresh_state_queue_on_video_save | videomanager.cache.add_videos_to_state_queues_bulk 可按状态批量恢复普通队列。 |
| 效率 Thrp 排行 | effranking.signals 与批量 services,提交后更新 | rebuild_eff_ranks 全量重建;管理员 /api/effranking/thrp/admin/rebuild_record 修复单项,sum 仅从个人 hash 重算。 |
| 自定义 pluck 排行 | customranking.services.update_custom_pluck_top_cache | manage.py rebuild_custom_pluck_cache 从 CustomPluckRecord 全量重建。 |
| NORMAL 比赛 | TournamentCache.update_tournament | manage.py rebuild_tournament_cache 显式查询 NORMAL GSC 与周赛并重建。 |
| NORMAL 参赛关系 | TournamentCache.update_participant / remove_participant | manage.py rebuild_tournament_cache 按 user_id 分组重建。 |
| 比赛积分排行 | TournamentCache.update_tournament_user / update_tournament_users;APScheduler 年度锚点更新 | manage.py rebuild_tournament_user_cache 重建全部 7 个 zset;manage.py rebuild_tournament_current_score_cache 原子重算当前积分 zset 和年度锚点。 |
| common 摘要 | 对应 API 内部 cache.set | TTL 到期自动失效。 |
| 文章目录 | article.views.update_list | 管理员手动调用 update_list 全量刷新。 |
读取约定
- NORMAL 比赛列表只读
tournament:normal,不在缓存未命中时回落数据库;缓存与数据库不同步视为写路径 bug。 - 录像队列缓存不保存比赛录像,
VideoQueueCache.add/add_bulk会跳过ongoing_tournament=True的录像。 - 参赛 checkin 先读
tournament:normal:participants,命中后再按 tournament id 查询数据库对象,用于写入录像的多对多关系。 customranking:pluck:{level}:rank只保存排序所需数据,展示字段来自同级detailhash。
