# 整改计划: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-2(nginx,边缘承压) → 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-families(override)→ 按影响域 `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 postgres(AGENTS.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 server(AGENTS.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 并关闭本计划。