Article
2026-07-02-襷の系譜-索引与服务端缓存优化方案
Tasuki Keifu 性能优化方案:索引与服务端缓存
1. 目标与范围
这份方案用于解决 tasuki-keifu 目前随着数据增长带来的接口和页面查询变慢问题,范围只包含以下两类能力:
- 数据库索引与查询优化
- 服务端缓存与派生结果缓存
本方案暂不包含前端本地缓存、浏览器离线缓存、Service Worker 缓存,也不包含 CDN 方案设计。
当前判断是:项目还没有到必须引入 Redis 的阶段,优先通过查询优化、复合索引、单机内存缓存、预计算缓存即可覆盖当前和下一阶段的数据规模。
2. 当前现状
2.1 线上数据库现状
线上数据库并不是没有索引。
目前已存在的索引主要是 Prisma schema 自动生成或历史 migration 建立的基础索引,包括:
- 各主键和 slug 唯一索引
Membership.personIdMembership.organizationIdMembership.typeMembership.startDateMembership.endDateRaceResult.personIdRaceResult.raceIdRaceResult.organizationIdRace.competitionEditionIdRace.legPlayerRelationCache.personIdPlayerRelationCache.generatedAtTeamCompetitionResult和TeamCompetitionLegSnapshot的部分复合索引
结论不是“完全没建索引”,而是“只有基础索引,缺少按真实查询路径设计的复合索引和搜索型索引”。
2.2 线上数据规模
当前线上大表数量级大致如下:
RaceResult: 约 3,700TeamCompetitionLegSnapshot: 约 3,700Person: 约 2,300Membership: 约 2,300PersonalBest: 约 1,900
现在数据规模还不算大,但已经开始出现慢查询体感,说明性能问题主要来自查询方式和数据装配方式,而不是单纯表太大。
2.3 当前代码中的主要风险点
当前主要问题不是“数据库完全查不动”,而是“页面层做了太多不必要的数据搬运和内存处理”。
重点风险如下:
- 人物列表页先批量查出人物、所属关系、PB,再在 Node 层做搜索过滤和排序
- 人物详情页一次性加载该人物全部
raceResults、memberships、personalBests - 比赛届次页一次性加载整届比赛下所有
races以及每个race的全部raceResults - 选手关系虽然有
PlayerRelationCache,但缓存未命中时仍会重新跑多段大查询 - 当前只有数据库缓存表,没有进程内热点缓存
这类实现会导致三个层面一起浪费:
- 数据库读取量变大
- 服务端到应用层的数据传输变大
- 应用层排序、过滤、聚合时间变大
3. 方案原则
本次优化按照以下原则执行:
- 先优化查询,再补缓存,不先靠缓存掩盖慢查询
- 先做对现有架构侵入小的优化,不优先引入新中间件
- 优先支持现在的 SEO 页面和数据导入工作流,避免引入高风险改造
- 缓存必须有明确失效机制,不能只加 TTL 不设计刷新路径
- 索引调整默认安排在导入低峰或暂停窗口执行
4. 执行方案总览
第一阶段建议按四条线并行推进:
- 梳理高频页面查询路径。
- 给高频条件补复合索引和搜索索引。
- 缩小页面查询数据范围,减少一次性全量 include。
- 为热点页面加服务端缓存和派生缓存。
5. 索引优化方案
5.1 Membership
当前 Membership 常用于:
- 根据人物查所属
- 根据组织查成员
- 按时间判断是否在队
建议补充:
(personId, type, startDate, endDate)(organizationId, type, startDate, endDate)
如果页面经常只查当前有效关系,也可以考虑围绕“当前有效关系”的过滤方式优化查询结构,而不是完全依赖索引硬扛。
5.2 RaceResult
RaceResult 是当前最关键的大表之一,常见查询路径包括:
- 某人物的比赛记录
- 某比赛或某分段的成绩列表
- 某组织在某比赛中的结果
建议补充:
(personId, raceId)(raceId, position)(organizationId, raceId)(personId, recordedAt)或等价时间排序字段
如果没有明确时间字段,也可以根据当前真实排序字段来设计复合索引。
5.3 PersonalBest
常见查询路径是:
- 某人物所有 PB
- 某人物某项目 PB
建议补充:
(personId, discipline)(discipline, mark)
后者是否必要,要看后续是否存在项目榜单或同项目筛选需求。
5.4 Person 搜索
如果当前人物列表页支持关键字搜索,而搜索主要仍是数据库层承担,建议尽快明确搜索策略:
- 如果只是前缀/包含匹配,可先走
nameKana/nameJa/nameEn等字段索引策略 - 如果后续要支持更自然的多字段搜索,再考虑全文检索方案
当前阶段不建议过早引入复杂搜索引擎,先把数据库内搜索路径理顺。
6. 查询优化方案
6.1 人物列表页
当前风险是“先查全,再在应用层过滤排序”。
建议改为:
- 搜索、排序、分页尽量前置到数据库
- 列表页只查卡片级必要字段
- PB、所属等附属信息按展示需要裁剪
不要在列表页一开始就把人物详情页需要的全部数据都查出来。
6.2 人物详情页
建议拆成分层加载思路:
- 首屏只查基础资料、当前所属、核心 PB、最近成绩摘要
- 历史完整成绩可懒加载或分页
- 关系模块优先读缓存
这样可以显著降低详情页首屏查询成本。
6.3 比赛届次页
比赛页不应无差别把一届下所有 race 和所有 raceResults 全量打平读取。
建议:
- 页面首屏只读取必要概要
- 各 race 明细按需加载或按模块拆分
- 排名、队伍结果、分段结果分开组织查询
6.4 关系计算路径
如果 PlayerRelationCache 未命中就同步触发完整重算,页面体验会很差。
建议改为:
- 优先返回可接受的旧缓存或简化结果
- 后台异步刷新
- 或在导入/更新后预计算热点人物
7. 服务端缓存方案
7.1 哪些页面适合缓存
第一阶段适合缓存的主要是:
- 比赛届次页
- 高流量组织页
- 成熟选手页
- 选手关系模块结果
这些页面具备两个特点:
- 数据更新频率不高
- 查询与组装成本相对高
7.2 缓存层级
建议采用两层:
- 进程内热点缓存
- 数据库持久缓存表或已有缓存表
当前阶段不一定要上 Redis。
进程内缓存负责:
- 热门页面秒级或分钟级复用
- 减少同一实例内重复查询
数据库缓存负责:
- 跨请求复用派生结果
- 缓存计算型结果,如关系汇总
7.3 缓存策略
建议区分两类:
- 页面级缓存
- 计算结果缓存
页面级缓存适合:
- 比赛页
- 组织页
- 成熟选手页
计算结果缓存适合:
- 选手关系
- 汇总统计
- 排名衍生数据
8. 缓存失效策略
8.1 不能只靠 TTL
如果只设置 TTL,而没有明确刷新来源,会出现:
- 数据更新后页面长时间不刷新
- 热门页面反复集中失效重建
8.2 建议失效来源
缓存失效应当优先由以下事件驱动:
- 导入比赛结果后
- 修正人物信息后
- 修正组织归属后
- 重算 PB 后
- 重算关系后
TTL 可以作为兜底,但不应是唯一策略。
8.3 stale-while-revalidate 思路
对于读多写少页面,建议采用:
- 命中新鲜缓存直接返回
- 命中过期但可接受缓存时先返回旧值
- 后台再触发重建
这样会比“过期后同步重算”更稳定。
9. 第一阶段落地顺序
建议按以下顺序执行:
第一步:抓真实慢点
先确认最慢的页面和最常见查询路径,而不是抽象想象。
第二步:补索引
先补最确定、风险低、收益高的复合索引。
第三步:裁剪查询
把页面层不必要的 include 和全量装配去掉。
第四步:加热点缓存
先给最慢、最稳定的页面和关系结果加缓存。
第五步:补日志与缓存命中观测
确保能验证缓存有没有真的起作用,而不是“感觉加了缓存”。
10. 不建议现在做的事
当前不建议优先做:
- Redis 集群
- 独立搜索引擎
- 复杂多级缓存平台
- 大规模数据表重构
- 为小数据量场景过早引入过重基础设施
这些方案并不是永远不做,而是现在性价比不高。
11. 最终结论
当前 tasuki-keifu 的性能优化重点,不是“数据库太大”,而是:
- 索引还没按真实查询路径设计
- 页面查询拿了太多不必要的数据
- 派生结果缓存还不够系统
最合适的第一阶段路径是:
- 先补关键复合索引。
- 先收缩页面查询范围。
- 先给比赛页、组织页、成熟选手页和关系结果加缓存。
- 用日志和缓存命中记录验证优化结果。
这样能以较低复杂度先解决当前最真实的性能问题。