缓存结构
本文记录项目当前使用的 Redis / Django cache 结构,以及缓存与数据库、API 之间的读写流向。数据库字段结构以各 app 的 models.py 为准,这里只记录缓存中实际保存的数据结构。
总览
缓存数据结构
| 所属 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。 |
videomanager | freeze_queue | hash | 同 newest_queue,用于冻结录像队列。 |
videomanager | review_queue | hash | 同 newest_queue,用于待审核录像队列。 |
msuser / videomanager | news_queue | zset | member = news JSON;score = time.timestamp();最多保留 200 条。JSON 包含 time、player_id、video_id、index、mode、level、value、old_value。 |
msuser | player_{stat}_{mode}_{user_id} | hash | 三关个人纪录详情。字段为 b、i、e、b_id、i_id、e_id、sum。 |
msuser | player_{stat}_{mode}_ids | zset | member = user_id;score = 三关 sum。player_rank 使用它作为排序入口,并通过 Redis SORT GET 读取详情 hash。 |
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 / score_total / gsc_total / gsc_best / weekly_total / weekly_classic_total / weekly_classic_best | zset | member = user_id;score = TournamentUser 对应字段值。score_current、各 total 字段为 0 时不写入;gsc_best、weekly_classic_best 为 MAX_TOURNAMENT_BEST 时不写入。 |
common | api:common/videosummary | Django cache | video_summary 返回体,TTL 300 秒。 |
common | api:common/tasksummary | Django cache | task_summary 返回体,TTL 300 秒。 |
common | api:common/diskusage | Django cache | disk_usage 返回体,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 任务会先调用 delete_participants_without_videos 删除无录像站内 participant。排名积分发放和 best 刷新是独立的非关键后台任务,会各自从本场剩余站内 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。
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 可按状态批量恢复普通队列。 |
| 纪录新闻 | msuser.signals.push_news_queue_on_record_save | videomanager.management.commands.refresh_stnb 会清理 news_queue。 |
| 经典三关排行 | UserMS.update_3_level_cache_record | UserMS.del_user_record_redis 删除单个用户所有排行缓存;个人纪录重建后会重新写入。 |
| 自定义 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 | manage.py rebuild_tournament_user_cache 从 TournamentUser 全量重建 7 个 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。player_rank的请求参数直接指定 Redis 排行 key 和详情 key;调用方必须保证 key 与UserMS.update_3_level_cache_record写入规则一致。
