从感知到真实速度:API 缓存、乐观更新与数据库索引
从感知到真实速度: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.ts 的 headers() 为全站设置了 X-Frame-Options、CSP、HSTS 等(生产环境 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.ts 的 successResponse 中:若 不传 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-maxage 与 stale-while-revalidate 在边缘的行为(直觉版)
- s-maxage:主要针对 共享缓存(如 CDN、部分代理)。Vercel 等平台上,Route Handler 的响应可被边缘按该值缓存一段时间。浏览器是否用
s-maxage还取决于是否同时存在max-age等,实际以规范与实现为准;本项目在数字模式下未单独写max-age,更侧重 边缘与中间层 的短时缓存。 - stale-while-revalidate:在「缓存已过期但仍在 SWR 窗口内」时,缓存可以 先返回旧内容,同时在后台异步去源站取新内容;下一次请求命中已刷新的副本。对用户而言,列表或搜索往往 少一次冷等待;对源站而言,尖峰流量被摊平。
需要注意:陈旧内容窗口内,投票数、最新一条「痛点」可能短暂滞后。因此缓存时长要与产品容忍度一致。当前仓库中的实践包括:
GET /api/search:cache: 30、revalidate: 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):
- 若已投票或
loading中,直接 return,避免重复提交。 - 点击后 立即
setCount(count + 1)、setVoted(true),并进入loading。 await getFingerprint()后 POST 到/api/problems/vote/:id或/api/comments/:id/vote。- 成功则用
result.data.voteCount覆盖本地 count(与服务器对齐,处理并发或服务端校正)。 - 失败则 回滚
count与voted,并提示用户(当前为alert,可替换为 toast)。
4.2 指纹预加载:消灭「首次点击」里的隐藏异步
getFingerprint() 会读/写 localStorage 并可能生成新指纹,第一次调用有一定开销。组件在 useEffect 中 无参调用一次 getFingerprint().catch(() => {}),在用户浏览详情页时就把指纹准备好,点击投票时多数情况下已是同步读取,减少尾部延迟。
这与第五篇「匿名身份」、第六篇「限流标识」是同一数据链路的用户体验侧优化。
4.3 边界情况与一致性
- 重复点击:乐观更新后
voted为 true,按钮disabled,从交互上禁止连点;若用户极快刷新页面,仍以服务端initialVoted/initialCount为准。 - 429 限流:请求失败会回滚乐观状态;第六篇的
Retry-After可指导前端做退避(当前 VoteButton 以通用错误展示为主,后续可增强)。 - 最终一致性:成功响应里的
voteCount是权威值;若同一资源在别处也被投票,本地短暂偏高会在下次导航或重新拉取详情时被纠正。

五、数据库索引:让第七篇的查询「走得动索引」
第七篇讲了 problems 上 status、category、.or ilike、order、problem_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 双向复合索引
标签筛选使用 !inner 与 in("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 状态与审核闭环。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)