Article

2026-07-02-襷の系譜-索引与服务端缓存优化方案

个人项目

Tasuki Keifu 性能优化方案:索引与服务端缓存

1. 目标与范围

这份方案用于解决 tasuki-keifu 目前随着数据增长带来的接口和页面查询变慢问题,范围只包含以下两类能力:

  • 数据库索引与查询优化
  • 服务端缓存与派生结果缓存

本方案暂不包含前端本地缓存、浏览器离线缓存、Service Worker 缓存,也不包含 CDN 方案设计。

当前判断是:项目还没有到必须引入 Redis 的阶段,优先通过查询优化、复合索引、单机内存缓存、预计算缓存即可覆盖当前和下一阶段的数据规模。

2. 当前现状

2.1 线上数据库现状

线上数据库并不是没有索引。

目前已存在的索引主要是 Prisma schema 自动生成或历史 migration 建立的基础索引,包括:

  • 各主键和 slug 唯一索引
  • Membership.personId
  • Membership.organizationId
  • Membership.type
  • Membership.startDate
  • Membership.endDate
  • RaceResult.personId
  • RaceResult.raceId
  • RaceResult.organizationId
  • Race.competitionEditionId
  • Race.leg
  • PlayerRelationCache.personId
  • PlayerRelationCache.generatedAt
  • TeamCompetitionResultTeamCompetitionLegSnapshot 的部分复合索引

结论不是“完全没建索引”,而是“只有基础索引,缺少按真实查询路径设计的复合索引和搜索型索引”。

2.2 线上数据规模

当前线上大表数量级大致如下:

  • RaceResult: 约 3,700
  • TeamCompetitionLegSnapshot: 约 3,700
  • Person: 约 2,300
  • Membership: 约 2,300
  • PersonalBest: 约 1,900

现在数据规模还不算大,但已经开始出现慢查询体感,说明性能问题主要来自查询方式和数据装配方式,而不是单纯表太大。

2.3 当前代码中的主要风险点

当前主要问题不是“数据库完全查不动”,而是“页面层做了太多不必要的数据搬运和内存处理”。

重点风险如下:

  • 人物列表页先批量查出人物、所属关系、PB,再在 Node 层做搜索过滤和排序
  • 人物详情页一次性加载该人物全部 raceResultsmembershipspersonalBests
  • 比赛届次页一次性加载整届比赛下所有 races 以及每个 race 的全部 raceResults
  • 选手关系虽然有 PlayerRelationCache,但缓存未命中时仍会重新跑多段大查询
  • 当前只有数据库缓存表,没有进程内热点缓存

这类实现会导致三个层面一起浪费:

  • 数据库读取量变大
  • 服务端到应用层的数据传输变大
  • 应用层排序、过滤、聚合时间变大

3. 方案原则

本次优化按照以下原则执行:

  • 先优化查询,再补缓存,不先靠缓存掩盖慢查询
  • 先做对现有架构侵入小的优化,不优先引入新中间件
  • 优先支持现在的 SEO 页面和数据导入工作流,避免引入高风险改造
  • 缓存必须有明确失效机制,不能只加 TTL 不设计刷新路径
  • 索引调整默认安排在导入低峰或暂停窗口执行

4. 执行方案总览

第一阶段建议按四条线并行推进:

  1. 梳理高频页面查询路径。
  2. 给高频条件补复合索引和搜索索引。
  3. 缩小页面查询数据范围,减少一次性全量 include。
  4. 为热点页面加服务端缓存和派生缓存。

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 的性能优化重点,不是“数据库太大”,而是:

  • 索引还没按真实查询路径设计
  • 页面查询拿了太多不必要的数据
  • 派生结果缓存还不够系统

最合适的第一阶段路径是:

  1. 先补关键复合索引。
  2. 先收缩页面查询范围。
  3. 先给比赛页、组织页、成熟选手页和关系结果加缓存。
  4. 用日志和缓存命中记录验证优化结果。

这样能以较低复杂度先解决当前最真实的性能问题。