Files
inkreach-official-website/plans/refactor/public-capacity-10k-refactor.md
T

157 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 整改计划:public 端口 1w 瞬时并发承压(public-capacity-10k-refactor
前置审查:[docs/references/performance-review-public-port.md](../../docs/references/performance-review-public-port.md)
## 目标
- 目标口径:**1w 瞬时并发请求**(同一时刻 1 万请求到达边缘,万 QPS 级)。
- 验收:p95 < 300ms、p99 < 1s、错误率 < 0.1%、无 nginx 连接拒绝/重置、PG 连接水位 < 80%、无池排队超时(详见报告 §5)。
- 原则:本次改造只动「配置与架构层」,不改对外 API 契约(响应结构不变),数据语义零变化。
## 全局约束(执行本计划所有任务强制遵守)
1. **数据库变更先行备份存档**:凡是涉及数据库更改的行动(schema 迁移、数据回填、族重算、同步行为变更、PG 参数调整与重启),执行前必须先完成风险回滚存档:
- 受影响库 `pg_dump -Fc` 全量 dump + 相关配置快照,落 `deploy/backups/<时间戳>/` 并写 `RESTORE.md`(沿用 AGENTS.md「生产栈收敛三件套」);
- 变更完成并验证通过前,备份不得清理;验收通过后按 RESTORE.md 归档流程归档。
- 本计划内的触发点:P0-3(PG 参数 + 重启)、P1-1(物化列迁移)、P1-2(重算/同步行为变更)、P2-1(索引/扩展)。
2. **写路径必失效缓存**:凡引入缓存(P0-1),所有数据写路径(admin 侧 CRUD、sync 同步、族重算、上传替换)在写事务提交成功后必须同步失效对应缓存域;「等待 TTL 自然过期」不作为可接受的兜底;每条写路径都必须有「写后缓存已失效」的测试覆盖。
## 执行顺序与依赖
```
P0-3(池参数,防排队超时)→ P0-1(缓存,DB 负载降 95%+)→ P0-2nginx,边缘承压)
→ P1-3(压测基线,验证 P0 是否达标)
→ P1-1(SQL 分页,缓存兜底后放宽量级)→ P1-2(同步隔离)→ P2(收尾项)
```
P0 三项互相独立可并行;P1-3 必须在 P0 全部上线后进行,否则测的是改造前基线(可先测一次作对照)。
---
## P0-1 进程内存缓存层(DB 热路径负载降 95%+,杠杆最高)
**原则**:public 读路径数据变更频率 = 小时级同步 → 分钟级 TTL 缓存几乎无损新鲜度。缓存键必须包含影响结果的全部入参;失效锚点为同步任务(见 P0-1.3)。
### P0-1.1 低熵全量数据缓存(categories / countries / tags / tag-groups / 树序元数据 / 族最低价)
- 涉及:`apps/api/src/public/public.service.ts``apps/api/src/app.module.ts`(若用 `@nestjs/cache-manager`
- 做法:引入进程内存缓存(`@nestjs/cache-manager` 或自研 Map+TTL 包装,单进程部署下内存缓存即全局缓存;不引入 Redis,避免新增运维依赖)。
- `loadTreeOrderMeta()``loadFamilyMinPrices()` 两个私有聚合结果整体缓存(TTL 10min)——它们被列表/首页每次请求复用,是最大的固定成本。
- `getCategoriesTree` / `getCountries` / `getTags` / `getTagGroups``countryId`(若有)作键缓存结果(TTL 10min)。
- 测试:TDD 先写——缓存命中返回等价结构、TTL 过期后重新查询、countryId 不同键不串数据。
- 回归风险:同步窗口内缓存有最多 10min 滞后 → P0-1.3 主动失效可缩短到秒级。
### P0-1.2 商品列表缓存:全量物化 + 内存分页(各 page/筛选组合共享一份缓存)
- 涉及:`apps/api/src/public/public.service.ts``getGoods`
- 做法:**不要**缓存「page 组合」——对筛选结果(countryId/keyword/category/tags/价格区间 + 排序)物化出 `{ rows: PublicGoodDto[](已排序已分组), total }` 整体缓存(TTL 10min,键 = 筛选参数序列化,不含 page/pageSize),`items.slice(start, start+pageSize)` 在缓存命中后执行。默认排序下全部 240 行物化成本一次,所有页共享。
- 注意:价格筛选/价格排序依赖族最低价 → 与 P0-1.1 的族最低价缓存同源;族最低价变化(族重算/同步)后必须连带失效列表缓存。
- 测试:TDD——同筛选不同 page 只触发一次底层查询(spy findMany 计数);筛选参数不同键不同;失效后重建。
- 回归风险:内存占用 = 缓存条目 × 240 DTO(每条 ~1KB)→ 筛选组合多时需设条目上限(LRU,如 64 条)与单条目 TTL。
### P0-1.3 详情缓存
- 涉及:`public.service.ts``getGoodByFamilyId`
- 做法:`goods/:id` 详情按 familyId 缓存(TTL 10min;响应含 11KB price_matrix,缓存收益尤其大)。
- 测试:命中返回等价结构、TTL 过期重建、失效后重建。
- 回归风险:多进程部署时内存缓存不相通 → 当前单进程无此问题;扩容多副本时需换 Redis 或接受短滞后(文档注明)。
### P0-1.4 缓存失效统一机制(全局约束 §2 的落地,必须先于/同步于 P0-1.1~1.3 交付)
- 涉及:新增 `apps/api/src/public/public-cache.service.ts`(或等价),`apps/api/src/sync/sync.service.ts``family-recompute.service.ts`、admin 侧全部写服务
- 做法:
1. **版本域设计**:每个缓存键带版本前缀,分域管理——`v:categories`(分类树/国家/标签组/树序元数据)、`v:goods`(列表/首页/详情)、`v:matrix`(族最低价);读路径取版本号拼键,写路径 `bump(域)` 使整个域失效。域粒度避免「任意写导致全量缓存清空」,实现简单且不漏键(不做逐个 `del` 键级清理,易漏易错)。
2. **统一入口**`PublicCacheService` 只暴露 `get/bump/set` 三个方法;所有写路径收口到 `bump`,禁止在业务代码里散落 cache 键。
3. **写路径挂钩清单**(缓存上线时必须全部挂上,缺一不可):
- `sync.service.ts``syncCategories` 提交后 → `v:categories``syncProducts` 提交后 → `v:goods`;详情同步 `persistProductDetail` 提交后 → `v:goods`
- `family-recompute.service.ts`:族重算 `persist` 提交后 → `v:matrix` + `v:goods`
- admin 侧写服务:goods CRUD/排序/标签挂接、categories、tags/tag-groups、positions、product-familiesoverride)→ 按影响域 `bump`;注意单纯「上传文件」若不改库行则无需 bump,挂钩点是**引用该图的写操作**(改 goods/detail/media 行 → `v:goods`)。
4. **写后即失效**write-through 语义):`bump` 必须在写事务提交**成功之后**执行(事务回滚不得 bump);`bump` 本身用版本号递增(防止「失效瞬间的并发读又把旧值写回缓存」的竞态——读路径读版本号后写入,版本号已变则丢弃写入或重查)。
- 测试(每个写路径至少一条):
- 「写后缓存已失效」:写入数据 → 断言对应缓存域读不到旧值;
- 「TTL 未到期但写已发生 → 前台立即可见新数据」的端到端用例(同步/族重算/admin 修改各一);
- 「事务回滚不 bump」:构造回滚路径断言版本号未变;
- 并发竞态:并发读+写下不出现陈旧值(版本号检查路径)。
- 回归风险:挂接漏写路径 = 前台最长 10min 陈旧(比报错更隐蔽)→ 挂钩清单作为自查 checklist 纳入 PR 模板与代码评审。
---
## P0-2 边缘 nginx 调优(连接数硬顶 4096 → 16384+,压缩与限流补齐)
- 涉及:`deploy/nginx/admin.conf`bind mount,改后 `docker compose up -d --force-recreate admin`,文件型挂载 inode 不随 restart 生效)、**新增** `deploy/nginx/nginx.conf` + `deploy/admin.Dockerfile`(主配置 COPY 进镜像,需 `docker compose build` 重建——只 force-recreate 不生效)、`deploy/nginx/admin.v2.conf`/v2/h5/ /v2/admin/ 静态缓存头落点)、`deploy/docker-compose.yml`admin 服务 ulimits
- 做法(`/etc/nginx/nginx.conf` 主配置无法覆盖时,在 conf.d 里用 `worker_rlimit_nofile` 不行——主配置的 workers 属 http 级不可段内覆盖——需在镜像层覆盖主配置或确认官方镜像默认即可):
1. **连接数**(已核实:`deploy/admin.Dockerfile` 最终镜像只 COPY 了 `conf.d/default.conf`,主 nginx.conf 为官方原版 `worker_connections 1024``events{}` 只存在于主配置,conf.d 无法覆盖):新增 `deploy/nginx/nginx.conf` 并在 Dockerfile 最终阶段加 `COPY deploy/nginx/nginx.conf /etc/nginx/nginx.conf`——内容 `worker_processes auto; worker_rlimit_nofile 65536; events { worker_connections 16384; multi_accept on; }`;同时在 compose `admin` 服务加 `ulimits: { nofile: { soft: 65536, hard: 65536 } }`4 worker × 16384 连接 + 上游 keepalive 连接需落在容器 fd 预算内,Docker 默认值需实测确认,低于预算必须显式设置)。
2. **upstream keepalive**`/v2-api/``/public/``/uploads/``/assets/` 四个 proxy location 统一加 `upstream v2_api { server v2-api:3001; keepalive 64; }` + `proxy_http_version 1.1`(已有)+ `proxy_set_header Connection ""`keepalive 必需)。
3. **gzip on**`gzip on; gzip_types application/json application/javascript text/css image/svg+xml; gzip_min_length 1k;`JSON API + SPA 静态资源,带宽降 5~10x)。
4. **静态资源缓存头**(注意配置落点):`/uploads/``/assets/` 在边缘 admin.conf 加 `expires 30d; add_header Cache-Control "public, immutable";`;但 `/v2/h5/``/v2/admin/` 的静态文件实际由 **v2-admin 容器的 `deploy/nginx/admin.v2.conf`** 提供(边缘只做代理),哈希文件名的缓存头要加在那边,`index.html` 保持 `no-cache`——边缘不要对代理响应统一加 expires,避免覆盖源头的 no-cache 语义。
5. **limit_req 兜底**(定位:只挡瞬时洪峰/扫描,不做业务限流——app 层 120 req/min/IP(≈2 r/s 持续)比它严得多):`limit_req_zone $binary_remote_addr zone=public:10m rate=50r/s;` + `limit_req zone=public burst=200 nodelay;` + 显式 `limit_req_status 429;`(默认 503 会让前端误判服务故障),仅挂 `/public/``/v2-api/` 两个 location。
6. **proxy 超时显式化**`proxy_connect_timeout 5s; proxy_read_timeout 30s; proxy_send_timeout 30s;`(默认 60s 过长,同步长事务窗口内挂死连接)。
7. **client_header_buffer 大请求头**(1 万连接下避免默认 1k 头部缓冲爆),并按 AGENTS.md 先做配置快照(`deploy/backups/<时间戳>/`)。
- 测试/验证:改后 `docker compose config` 校验、`nginx -t`(容器内)、`curl -I` 断言 gzip 头/Cache-Control;压测脚本断言无 connection refused。
- 回归风险:`/uploads/` 长缓存头会让「替换图片同名不同图」被浏览器缓存 → 上传路径需确认图片 URL 含版本参数或降级 `max-age=1d`(此点实施时与业务确认)。
## P0-3 连接池与超时参数(防排队超时与慢查询占池)
- 涉及:`deploy/docker-compose.yml`(生产 `DATABASE_URL`
- 做法:
1. `DATABASE_URL` 追加 `?connection_limit=50&pool_timeout=3`。注意 `pool_timeout` 单位是**秒**(Prisma 文档默认 10s),不要写成毫秒;不要加 `statement_cache_size=0`(那是 pgbouncer 事务模式的做法,直连场景禁用 prepared statement 反而拖慢)。50 < PG 上限,为同步与运维连接留余量。
2. PG 容器 `command: ["postgres", "-c", "statement_timeout=10000", "-c", "shared_buffers=512MB", "-c", "effective_cache_size=1536MB", "-c", "max_connections=200"]`14GB 内存机,shared_buffers 512MB 够工作集;statement_timeout 10s 兜住慢查询不占池)。
3. 同步任务自身查库量大(长事务串行循环)—— statement_timeout 10s 需确认不误伤同步批量 upsert(单条语句都不应超 10s;若误伤,同步侧改批量化见 P1-2)。
4. 变更 PG 参数需重启容器(秒级中断):按全局约束 §1 先做配置快照 + `pg_dump -Fc` 存档,低峰窗口执行,`docker compose up -d --force-recreate v2-postgres` 后立即验证健康与连接水位。
- 测试:入参级别——用一次性 docker postgresAGENTS.md 同款 54329 套路)跑 `prisma migrate deploy` + 全量 jest 确认无超时回归;生产以 `SHOW statement_timeout`/`pg_stat_activity` 验证。
- 回归风险:`pool_timeout=3000` 让洪峰期排队 >3s 快速失败(429/500)而非挂 10s——配合 P0-1 缓存后池压力极小;这是有意的快速失败语义,需在压测时观察错误率符合验收口径。
---
## P1-1 `/public/goods` SQL 分页(量级放宽后的正解)
- 涉及:`apps/api/src/public/public.service.ts``getGoods`
- 背景:P0-1.2 缓存已接住当前百级量级;SQL 分页是为「商品量级上万」预埋(代码注释 L231-233 已留方向)。
- 做法:族表物化视图方向——`product_families` 加「族代表行」物化列(代表 Good 的 goodName/主图/分类/countryId/排序键)与**族最低价物化列**P0-1.1 的 LATERAL 聚合结果届时改为由族重算路径写入该列,内存缓存退化为直读列),列表查询改为 `findMany({ take, skip, include })` + 独立 `count`,去掉内存分组与全量拉取。物化列的写入统一挂在族重算 `recomputeFamily` 与 admin 写路径上(写后 bump 对应缓存域,遵守全局约束 §2)。
- 测试:TDD——分页正确性(边界页、total 与 items 长度)、排序与现内存版全等(用 AGENTS.md「同一排序函数生成期望」防中文/键序手写错误)、打乱输入断言输出全等。
- 风险:改动面大(DTO/排序语义),**必须**与现实现做差分对照(同一数据集新老实现结果全等),放 P1 不与 P0 抢时间窗口;涉及 schema 变更(族表物化列)时按全局约束 §1 先备份存档并交付回滚迁移。
## P1-2 同步任务与 public 隔离
- 涉及:`apps/api/src/sync/sync.service.ts``apps/api/src/product-families/family-recompute.service.ts`
- 做法(按成本升序,实施时选一):
1. **错峰**:整点分类同步窗口(~分钟级长事务)移到低峰(如 04:00 后紧邻详情同步);至少避开业务高峰。
2. **批量化**syncCategories 单事务串行 → 分批事务(每 50 分类一提交);syncProducts 逐商品 upsert → `createMany`/并行化(注意 SDS 依赖逐条校验)。
3. **独立 worker**`SYNC_WORKER=true` 环境开关,第二个 v2-api 容器只跑 schedule`app.listen` 前 return / NestFactory disable listen),与 public 进程池物理隔离——唯一彻底方案,代价是 +1 容器与连接预算(已 P0-3 预留 50/池 ×2)。
- 测试:同步结果幂等与现行为全等(对比 sync_logs 行数与库行数);并发压测与同步同时进行的窗口场景;行为变更(批量化/重算)执行前按全局约束 §1 备份存档。
- 风险:族重算 fire-and-forget 队列在独立 worker 下需确认 enqueue 侧(public 进程也会 trigger?——现状 enqueue 在同步与族变更路径,若拆分需统一入口)。
## P1-3 压测基线(验收依据,必须分布式源)
- 涉及:k6(或 wrk)脚本放 `scripts/loadtest/`;目标环境与压力机要求见步骤 0
- 步骤:
0. **目标环境(硬性)**:1 万并发口径压测**不得直打生产栈**——用最近 `pg_dump` 在一次性镜像栈恢复(AGENTS.md 同款 `docker run … postgres:16-alpine` 起库 + `prisma migrate deploy` + 单独 compose 起 api/nginx 副本,参数与生产一致),`THROTTLE_LIMIT` 只在镜像栈抬高;若最终必须在生产验证,只允许业务书面确认的低峰窗口 + 只读场景 + 限流不动的保守梯度。**压力机必须是独立机器**(与被测机同机跑 10k VU 会互相抢 CPU,测出来的是压测机瓶颈)。
1. SQL 单测基线:dump 恢复后逐条跑 `loadFamilyMinPrices` / `loadTreeOrderMeta` / 列表全量 / 详情 / ILIKE keyword,记录真实耗时(填空报告 §2 的估算值)。
2. 功能压测:`THROTTLE_LIMIT` 临时抬到 1e6(或分布式 ≥ 数十 IP)避免 429 干扰;场景 = 混合读(home-goods / goods / 详情 / 分类树按 3:4:2:1)。
3. 口径压测:逐步加并发至 1 万瞬时请求(如 k6 10000 VU,分布式源),记录 p95/p99/错误率/nginx 连接拒绝/PG 水位,并记录 Node 进程 CPU 与事件循环延迟(`--max-old-space-size` 与 GC 停顿)。
4. **达标失败的扩展阶梯**(按成本升序,逐级尝试后再进下一级):
a. **响应字节级缓存**:P0-1 缓存的是 DTO 对象,命中后每请求仍要 `JSON.stringify`——1 万瞬时 burst 下单进程事件循环的序列化 CPU 可能成为新瓶颈(每响应 ~10-20KB × 1 万次)。若压测显示 stringify 是热点,把缓存值改为**已序列化的最终 JSON 字符串**(含 TransformInterceptor 包裹结构),命中路径直接回写字节,绕过逐请求序列化;
b. **nginx micro-cache**`proxy_cache``/public/` GET 设 10~30s 短 TTL,洪峰完全由边缘吸收。⚠️ 与全局约束 §2 冲突:proxy_cache 无法主动 purge,只能靠短 TTL——需业务确认接受该陈旧窗口,不接受则跳过此项;
c. **Redis + 多副本**:内存缓存换共享存储(版本域失效天然跨进程),加 v2-api 副本(nginx upstream 已 keepalive,扩容仅加容器 + PG max_connections 预算)。
- 交付:压测报告(数字+结论)追加到性能审查报告 §5 验收结果。
---
## P2 收尾项(非阻塞,可后置)
1. **静态资源边缘直出**`/uploads/` 数据卷直挂 edge nginx`deploy/docker-compose.yml``v2-uploads` 卷 + try_files),省一跳代理;注意 upload 与 nginx 读同卷的一致性(nginx 缓存/直读无冲突)。
2. **pg_trgm 索引**`CREATE EXTENSION pg_trgm; CREATE INDEX ... ON goods USING GIN (good_name gin_trgm_ops);`admin 侧 origin_goods.good_name 同)——goods 上万后 keyword 才需要;迁移文件 + 回滚脚本,执行前按全局约束 §1 备份存档(CREATE EXTENSION 为 DDL,回滚与重放需按迁移流程管理)。
3. **监控告警**sync_logs 失败数、`pg_stat_activity` 连接水位、Prisma 池排队(日志关键字 `Timed out fetching a new connection`)→ 告警;nginx access log 结构化落盘(当前无 access 日志,1w 并发下调试无从下手)。
4. **README/docs 更新**`docs/references/structs.md`(部署拓扑段补充性能配置说明)按 AGENTS.md 第 6/7 节执行。
## 全局回归策略
- 每项任务独立分支提交(Conventional Commits`perf(...)`),合并回 `develop`
- 全量 jest 前停 dev serverAGENTS.md 经验);连共享库前 `prisma migrate status`;集成测试夹具自包含(一次性 pg 跑套件)。
- 缓存相关改动重点回归:列表/详情/首页三端点输出与改造前全等(差分测试)、同步后新鲜度、TTL 失效路径。
## 验收(全部完成后)
- 分布式压测 1 万并发瞬时请求:p95 < 300ms、p99 < 1s、错误率 < 0.1%、无连接拒绝、PG 水位 < 80%、无池排队超时。
- 全局约束核查:涉及 DB 变更的任务全部留有 `deploy/backups/<时间戳>/` 存档与 RESTORE.md(可 dry-run 恢复验证);全部写路径(sync/族重算/admin CRUD/上传)的「写后缓存即失效」用例通过(含事务回滚不 bump、并发读不写回旧值)。
- 结果数字回填报告 §5 并关闭本计划。