从感知到真实速度:API 缓存、乐观更新与数据库索引

本文是「不爽有解」技术博客连载的第八篇,围绕「用户觉得快」与「数据库真的快」两条线,拆解 Next.js 与响应头、GET 接口的 Cache-Control 与 stale-while-revalidate、投票按钮的乐观更新与指纹预加载,以及 PostgreSQL 索引与 EXPLAIN 验证思路。
不爽有解https://bushuangyoujie.cn — 基于真实痛点的独立开发者工具发现与推荐平台。


一、性能要拆成两层:感知延迟与后端成本

独立开发者做内容型产品时,性能优化常被笼统说成「加缓存」或「上 CDN」。更稳妥的做法是先把目标拆开:

  • 感知性能(Perceived Performance):首屏是否尽快有内容、交互是否「跟手」、弱网下是否仍可用。典型手段包括骨架屏、预取、乐观更新、减少主线程长任务等。
  • 真实性能(Actual Performance):数据库单次查询耗时、API P99、边缘节点回源次数、包体积导致的解析与执行时间等。典型手段包括 HTTP 缓存索引、减少 N+1、按需拆分 Client Component 等。

「不爽有解」里,列表与搜索走 Server Component 或客户端 fetch 的 GET API,投票走 POST 且带限流(见第六篇)。本篇把 缓存头、乐观投票、索引 三件事串起来:它们分别解决「重复读」「写路径上的等待感」「读路径上的磁盘与排序成本」,互为补充而不是互相替代。

在这里插入图片描述


二、Next.js 层:图片优化、包导入裁剪与安全响应头

2.1 images:remotePatterns、格式与长期缓存 TTL

工具 Logo、用户头像等常托管在 Supabase Storage,域名落在 **.supabase.co。Next 的 next/image 默认只允许同源图片,必须在 next.config.ts 里声明 remotePatterns,否则运行时会拒绝优化请求。

当前仓库中的配置要点包括:

  • remotePatterns:放行 https + **.supabase.co,与第三篇的存储域名一致。
  • formats["image/avif", "image/webp"],由 Image Optimization 按 Accept 协商输出,减少体积。
  • deviceSizes / imageSizes:与常见断点、图标尺寸对齐,避免生成过大变体。
  • minimumCacheTTL:例如 30 天,表示优化后图片在服务端缓存中的最短保留时间(具体行为以 Next 文档为准),减轻重复优化同一张远程图的开销。

这些配置直接影响 LCP 相关资源 的体积与命中,属于「不改业务逻辑也能收益」的一层。

2.2 experimental.optimizePackageImports

experimental.optimizePackageImports 中列出 lucide-react@radix-ui/react-dropdown-menu 等包后,构建阶段会把「按子路径导入的图标 / 组件」做成更细的按需拆分,避免整包进入客户端 bundle。配合 尽量把纯展示留在 Server Component、只在需要事件与状态时挂 "use client",可以把首屏 JS 控制在合理范围。

2.3 全局 headers:安全与静态资源 immutable

next.config.tsheaders() 为全站设置了 X-Frame-OptionsCSPHSTS 等(生产环境 TLS 前提下 HSTS 才有意义)。其中对 静态图片扩展名(jpg、png、webp、avif 等)单独设置了 Cache-Control: public, max-age=31536000, immutable,与带 hash 的文件名策略配合时,浏览器可长期缓存,减少重复请求。

/api/* 则默认写了 private, no-store, no-cache, max-age=0,避免匿名可读的 GET 被公共 CDN 误缓存成「千人一面」或泄露带会话的响应。因此:凡是希望被 CDN/浏览器短时缓存的 GET,必须在路由 handler 内显式覆盖 Cache-Control(见下一节),而不是依赖「全局默认」。


三、API 缓存:successResponse 与 stale-while-revalidate

3.1 实现位置与默认行为

统一封装在 lib/api/response.tssuccessResponse 中:若 不传 options.cache,则设置

Cache-Control: private, no-store, no-cache, max-age=0

next.config.ts 里对 /api/* 的兜底一致,保证「未思考过缓存的接口」默认安全。

当传入 数字 cache: N 时,会设置:

Cache-Control: public, s-maxage=N, stale-while-revalidate=R

其中 R 来自 options.revalidate,若未传则使用 N * 2(与第六篇文档描述一致)。

3.2 s-maxagestale-while-revalidate 在边缘的行为(直觉版)

  • s-maxage:主要针对 共享缓存(如 CDN、部分代理)。Vercel 等平台上,Route Handler 的响应可被边缘按该值缓存一段时间。浏览器是否用 s-maxage 还取决于是否同时存在 max-age 等,实际以规范与实现为准;本项目在数字模式下未单独写 max-age,更侧重 边缘与中间层 的短时缓存。
  • stale-while-revalidate:在「缓存已过期但仍在 SWR 窗口内」时,缓存可以 先返回旧内容,同时在后台异步去源站取新内容;下一次请求命中已刷新的副本。对用户而言,列表或搜索往往 少一次冷等待;对源站而言,尖峰流量被摊平。

需要注意:陈旧内容窗口内,投票数、最新一条「痛点」可能短暂滞后。因此缓存时长要与产品容忍度一致。当前仓库中的实践包括:

  • GET /api/searchcache: 30revalidate: 60(约 30s s-maxage、60s SWR),注释中写明搜索变化相对频繁,故偏短。
  • GET /api/problems(列表):根据是否带关键词或多种筛选字段,在 30 秒与 60 秒 间切换 cacheTime,并令 revalidate: cacheTime * 2,筛选越多越保守。

错误路径一律走 errorResponse,其中强制 no-store,避免把 4xx/5xx 缓存出去。

3.3 与页面 force-dynamic 的关系(第七篇延续)

痛点列表页使用 export const dynamic = "force-dynamic" 时,HTML 文档 仍可按请求动态生成;若页内客户端组件或通过 SWR/React Query 去请求上述 带短时 s-maxage 的 GET API,则 JSON 仍可在边缘命中缓存。另一种常见模式是 纯 Server Component 拉 Supabase,此时 HTML 动态、不经过 REST 缓存,本篇的 API 缓存主要惠及 客户端搜索、独立调用的列表接口 等路径。架构上没有唯一正确答案,关键是 同一资源不要重复设两套矛盾的长缓存

在这里插入图片描述


四、投票:乐观更新、指纹预加载与最终一致性

4.1 为何投票特别适合乐观 UI

投票是 高延迟敏感 的操作:用户点击后期望立刻看到数字 +1 和按钮状态变化。若等网络往返完成再改 UI,在移动网络或冷启动 TLS 下很容易产生「点了没反应」的错觉。

components/common/VoteButton.tsx 中的做法是标准 乐观更新(Optimistic UI)

  1. 若已投票或 loading 中,直接 return,避免重复提交。
  2. 点击后 立即 setCount(count + 1)setVoted(true),并进入 loading
  3. await getFingerprint() 后 POST 到 /api/problems/vote/:id/api/comments/:id/vote
  4. 成功则用 result.data.voteCount 覆盖本地 count(与服务器对齐,处理并发或服务端校正)。
  5. 失败则 回滚 countvoted,并提示用户(当前为 alert,可替换为 toast)。

4.2 指纹预加载:消灭「首次点击」里的隐藏异步

getFingerprint() 会读/写 localStorage 并可能生成新指纹,第一次调用有一定开销。组件在 useEffect无参调用一次 getFingerprint().catch(() => {}),在用户浏览详情页时就把指纹准备好,点击投票时多数情况下已是同步读取,减少尾部延迟。

这与第五篇「匿名身份」、第六篇「限流标识」是同一数据链路的用户体验侧优化。

4.3 边界情况与一致性

  • 重复点击:乐观更新后 voted 为 true,按钮 disabled,从交互上禁止连点;若用户极快刷新页面,仍以服务端 initialVoted / initialCount 为准。
  • 429 限流:请求失败会回滚乐观状态;第六篇的 Retry-After 可指导前端做退避(当前 VoteButton 以通用错误展示为主,后续可增强)。
  • 最终一致性:成功响应里的 voteCount 是权威值;若同一资源在别处也被投票,本地短暂偏高会在下次导航或重新拉取详情时被纠正。

在这里插入图片描述


五、数据库索引:让第七篇的查询「走得动索引」

第七篇讲了 problemsstatuscategory.or ilike、orderproblem_tags!inner 等组合。若没有索引,PostgreSQL 很容易走 Seq Scan + 大排序,数据量上万时列表与搜索会明显变慢。

项目中的 docs/database-indexes-optimization.md 给出了与业务查询对齐的建议,核心思路可归纳为:

5.1 部分索引(Partial Index)与固定前缀条件

列表几乎总带 status = 'published'。对该条件建 部分索引WHERE status = 'published'),索引体积更小,且与查询过滤一致时更易被规划器选中。例如:

  • (status, vote_count DESC) 的热门排序;
  • (category, status, vote_count) 的分类 + 热门;
  • (status, created_at DESC) 的最新排序;
  • has_solution 相关的「未解决」筛选等。

5.2 关联表:problem_tags 双向复合索引

标签筛选使用 !innerin("problem_tags.tags.slug", ...) 时,优化器需要在 problem_tags 上高效找到 按 tag 反查 problem按 problem 聚合 tag 的路径。文档建议 (problem_id, tag_id)(tag_id, problem_id) 两类复合索引,分别覆盖「从痛点找标签」与「从标签筛痛点」两种方向,避免大表嵌套循环。

5.3 投票表与防刷查询

votes 上按 (target_type, target_id, user_id)(target_type, target_id, fingerprint, created_at) 建索引,可加速「是否已投」「近期指纹投票次数」等校验;部分索引可限制在 fingerprint IS NOT NULL 或时间窗口内,控制写入与体积。

5.4 全文检索:GIN 与 to_tsvector

若关键词从纯 ilike '%...%' 演进为 to_tsvector + to_tsquery(或使用 pg_trgm 的相似索引),文档中的 GIN 索引示例(对 title || scenario 建 tsvector)才有用武之地。当前第六篇仍以 转义后的 ilike 为主,索引上可先用 B-tree 等值/范围 兜底;上全文前需评估分词语言(中文可能需额外扩展或外部搜索引擎),那是系列后续可写的独立话题。

5.5 如何用 EXPLAIN 验证(操作建议)

在 Supabase SQL Editor 或本地 psql 中对第七篇等价 SQL 执行:

EXPLAIN (ANALYZE, BUFFERS) SELECT ...

关注:

  • 是否 Index Scan / Bitmap Index Scan,Seq Scan 比例是否可接受;
  • Buffers: read= 是否过高(冷缓存);
  • Sort 是否 external merge(内存不足落盘);
  • Planning Time vs Execution Time 在预编译后是否仍偏高。

索引不是越多越好:每张索引都会拖慢 INSERT/UPDATE,并与 RLS 策略下的写入路径叠加,需在 读多写少 的列表、搜索场景优先。

「不爽有解」在列表加载、搜索与投票上的流畅体验,来自 边缘短时缓存、交互层乐观更新、数据库索引与查询形态 的配合,而不是单一「大招」。下一篇(第九篇)将回到 表单与提交流程:从 Zod 校验到 pending 状态与审核闭环。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐