# 09 · 部署与运维 > 对应里程碑 **M0-04(工程化)** 与 **M7-10(灰度发布 + 生产环境准备 + 数据迁移预案)**。 > 这份文档回答四个问题:**怎么发**、**密钥怎么办**、**升级与回滚怎么做**、**出事看哪里**。 --- ## §1 四套环境 | 环境 | profile | 配置来源 | 数据库 | 存储 | 用途 | |---|---|---|---|---|---| | 本地 | `dev` | `application-dev.yml`(**含 dev 密钥,已提交**) | `xt-pg` 容器 | `local` | 开发与 E2E | | 集成 | `test` | `application-test.yml`(**无密钥**,全走环境变量) | CI 临时容器 / 联调库 | `local` 或 `qiniu` | CI 与联调 | | 预发 | `prod` + 灰度开关 | `application-prod.yml` | 独立库 | `qiniu` | 客户验收 | | 生产 | `prod` | `application-prod.yml` | 独立库 | `qiniu` | 正式 | **⚠️ dev 与 test/prod 的密钥不能混用。** `dev` 的 JWT / AES 密钥在仓库里能查到, 拿它能伪造任意用户的 token(含平台运营)。这件事由 `ProdEnvCheck` 在启动时拦下 —— 见 §3.4。 --- ## §2 怎么发 ### 2.1 本地 ```bash cd deploy && docker compose up -d # xt-pg(5432) + xt-redis(16379) cd backend mvn -DskipTests package java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar --spring.profiles.active=dev ``` - ⚠️ **工作目录必须是 `backend/`**:两处相对路径依赖它 —— `application-local.yml`(本地私有配置,不提交)在这里; H5 静态目录配的是 `file:../frontend-mp/dist/build/h5/` - 跑的是 **jar 不是热加载**:改后端要重新 package(先停进程,否则 jar 被占用) - H5 没有独立 dev server:`cd frontend-mp && pnpm build:h5`,然后访问 `http://localhost:8080`(由后端托管) ### 2.2 CI(GitLab) `.gitlab-ci.yml` 三个阶段: | 阶段 | 做什么 | 为什么这样切 | |---|---|---| | `check` | 契约对账、实体列对账、文档一致性、**e2e 脚本语法扫描** | 都不需要数据库,几十秒出结果,先在最快的阶段挡掉低级错误 | | `build` | 后端打包、管理端 typecheck/build、**H5 build** | H5 产物是后面 e2e 的输入,不能跳过 | | `e2e` | 起 PG+Redis 容器 → 起后端(`test` profile)→ **依次跑全部 `scripts/e2e-*.py`** | 这个仓库**没有单元测试**,E2E 就是主验证手段;一个只跑编译的流水线在这里等于没跑 | 另外单独一个 job `生产环境自检` 跑 `scripts/e2e-prodguard.py` —— 它验的是"**配错时拒绝启动**",而这恰好是最容易被后来的改动悄悄破坏、 且破坏后**一切看起来都正常**的一件事。 **密钥策略:e2e 阶段每次随机生成密钥,不存任何 CI 变量。** 好处不只是省事 —— 它顺带证明**应用没有偷偷依赖某个 dev 默认值**(依赖了这里就红)。 ### 2.3 生产发布 ```bash # ① 构建(CI 里做,产物是 xt-server.jar) mvn -DskipTests package # ② 先把迁移在一个只读副本上试跑(见 §4.2) java -jar xt-server.jar --spring.profiles.active=prod \ --spring.flyway.enabled=true --spring.main.web-application-type=none # ③ 滚动发布:先起新实例、探活通过、再摘旧实例 curl -sf http://127.0.0.1:8080/actuator/health # 探活用这个,不是 / ``` ⚠️ **滚动发布期间会有两个版本同时在跑**,所以: - **迁移必须是"向后兼容"的**(只加列/加表,不改列类型、不删列)—— 否则旧实例会在新库上出错。改列要走"两次发布":这次加新列并双写,下次删旧列。 - **H5 静态产物要一起发**:它是后端托管的,前后端不同步会出现 "页面调用了还不存在的接口"。 --- ## §3 密钥与凭据 ### 3.1 清单 | 变量 | 用途 | 生成方式 | 轮换影响 | |---|---|---|---| | `XT_JWT_SECRET` | 登录 token 的 HS512 签名 | `openssl rand -base64 64` | **所有人被登出**(要做就安排在低峰) | | `XT_AES_KEY` | **证件号加密** | `openssl rand -base64 32` | ⚠️ **不能直接换** —— 见 3.2 | | `XT_HMAC_KEY` | 开放接口签名 / 去重 hash | `openssl rand -base64 48` | 接入方要改签名密钥;`id_card_hash` 类的去重值会变 | | `XT_RSA_PRIVATE_KEY` | 登录密码解密(PKCS#8 单行 base64) | `openssl genpkey -algorithm RSA ...` | 公钥在前端,**要同时换**(接口 `/auth/public-key` 会下发新公钥) | | `PG_PASSWORD` / `REDIS_PASSWORD` | 基础设施 | 运维 | 重启即生效 | | `QINIU_*` | 对象存储 | 七牛控制台 | 换 key 不影响已存文件(URL 是签名的) | | `WX_*` | 微信小程序 / 公众号 | 微信后台 | 换 secret 后旧 access_token 立刻失效(**服务自己会重新取**) | ### 3.2 ⚠️ AES 密钥不能"直接换" `collect_roster.id_card_no` / `collect_file` 里的证件号是 **AES 密文**。 换密钥之后**旧密文解不开**,而系统不会报错 —— 它只会显示一串乱码, 或者门禁核验永远匹配不上(`id_card_hash` 也变了)。 正确做法(本项目尚未实现,属二期工程): 1. 新密钥加进来(配置支持"当前密钥 + 历史密钥列表"); 2. 写一个离线任务用旧密钥解密、新密钥重加密,并**同时重算 hash 与 mask**; 3. 全部迁完之后,旧密钥才下线。 **在此之前:`XT_AES_KEY` 一旦启用就不要换。** 这条比 token 那条更硬 —— token 换了只是"重新登录一次",这个换了是"全校证件号读不出来"。 ### 3.3 密钥轮换的记录 轮换是**运维动作**,要留痕(谁、什么时候、为什么)。项目里的操作日志表 `sys_operation_log` 可以承接这类记录,但**轮换本身在系统外发生**, 所以还应有独立的运维记录 —— 建议直接用 GitLab 的 issue 或运维台账。 ### 3.4 启动自检(`ProdEnvCheck`) `prod` profile 启动时会先自检,**任何一条不通过就拒绝启动**: | 检查 | 不检查会怎样 | |---|---| | 六个必需环境变量齐全 | 直接起不来(但报错是一堆栈,不是清单 —— 所以这里提前拦) | | `stub-enabled` 不为 `true` | **任何人可以登录任何学生** | | 存储 provider 不是 `local` | 多副本下**必然丢文件**,且丢的时候没有任何报错 | | JWT 密钥不是 dev 那把、且 ≥ 32 字符 | 拿仓库里那把就能**伪造任意用户的 token** | | AES 密钥不是 dev 那把 | 证件号加密形同虚设 | | `springdoc.api-docs.enabled` 不为 `true` | 全部接口与字段暴露给公网 | 实现是 `EnvironmentPostProcessor`(跑在任何 bean 之前)。 ⚠️ **不能写成 `ApplicationRunner`**:那样缺密钥时 `JwtService` 构造先炸, 用户看到的是一段 56 行栈,而不是这份清单(这是实测踩过的)。 --- ## §4 升级、迁移与回滚 ### 4.1 迁移 Flyway 管理,迁移文件在 `backend/xt-server/src/main/resources/db/migration/`。 **已发布的迁移文件不许改**(`validate-on-migrate: true` 会校验)—— 这是保护:它保证"生产跑的迁移"与"仓库里的"逐字节一致。 ⚠️ **生产禁止 `flyway clean`**(`clean-disabled: true`): 它是"清空整个库再重建",在生产上执行等于把所有学校的填报数据删掉。 ### 4.2 升级前必做 ```bash # ① 备份(**这是唯一的后悔药**) pg_dump -Fc -h $PG_HOST -U $PG_USER $PG_DB > backup-$(date +%F-%H%M).dump # ② 在副本上试跑迁移:先恢复备份到临时库,再对临时库跑一次 createdb xt_dryrun && pg_restore -d xt_dryrun backup-*.dump java -jar xt-server.jar --spring.profiles.active=prod \ --spring.datasource.url=jdbc:postgresql://.../xt_dryrun \ --spring.main.web-application-type=none # 只跑迁移,不起 web ``` ②的价值在于:**迁移失败的最好时机是"还没碰生产库"的时候**。 ### 4.3 回滚 | 情形 | 怎么做 | |---|---| | 只换了应用版本、没跑新迁移 | 把上一个 jar 换回来,重启。**最快** | | 跑了新迁移、但迁移是"只加列/加表" | 换回旧 jar 即可(旧版本不认识新列,不影响) | | 跑了新迁移、且迁移**改了列类型或删了列** | ⚠️ 换回旧 jar 也救不了,必须**恢复备份** —— 所以 §2.3 要求迁移必须向后兼容 | | 只是某个开关配错 | 改环境变量重启(`ProdEnvCheck` 会先校验一遍) | **发布前把"回滚要多久"算清楚**,并写在发布单上。恢复一个几十 GB 的库 不是"几分钟"的事。 ### 4.4 灰度 一期建议按**租户**灰度(不是按流量):客户是学校,一个学校要么全在新版本、 要么全在旧版本 —— 按流量切会让同一个学校里一半学生走新代码、一半走旧代码, 而"同一份填报单被两个版本各写一半"是最难排查的一类问题。 ```bash XT_TENANT_DOMAIN_ENABLED=true # 如果要按域名区分单位 ``` --- ## §5 出事看哪里 | 症状 | 先看 | 常见原因 | |---|---|---| | 服务起不来 | 启动日志里的 `生产环境启动自检未通过` | §3.4 的六条 | | 学生上传失败 | `xt-server.log` 里 `MaterialFileService` | 存储密钥错 / 空间名错(开 `XT_STORAGE_SELF_CHECK=true` 能在启动时暴露) | | 页面 404 但接口正常 | `spring.web.resources.static-locations` | H5 构建产物没放对目录(**应用不会报错**) | | 定时任务没生效 | 手工触发一次:`POST /api/v1/schedules/run {"job":"close"}` | 见下 | | 「查不到数据」但库里有 | 租户上下文 | 定时任务/匿名接口的线程里没有登录用户,租户拦截器默认拒绝 | **定时任务的排查办法**:`POST /api/v1/schedules/run` 可以手工触发一轮 (`close` / `urge` / `timeout` / `raw`),运维补跑也用它。 ⚠️ 定时任务最安静的一类故障是"**跑了但什么都没做**": 候选查询的 SQL 别名没加双引号时会被 PostgreSQL 折成小写, `row.get("tenantId")` 得到 `null`,异常被 catch 吞掉,日志只有一行 warn。 所以四个任务都加了候选行自检 —— 缺 `tenantId` 会把"实际的键"打进日志。 --- ## §6 首次接入 CI 的检查单 `.gitlab-ci.yml` 里这几件事**在本地 Windows 上验不到**,第一次上 runner 时要重点看: - [ ] `services: postgres / redis` 的容器别名能否解析(`PG_HOST=postgres`) - [ ] runner 镜像里 `apt-get install python3 python3-pip curl postgresql-client` 可用 - [ ] `pip3 install openpyxl requests` 可用(E2E 脚本要用) - [ ] `openssl rand -base64 64` 生成的随机串里含 `/` `+` `=` 时, 作为**环境变量**传给 Java 没问题(shell 里安全;哪天改用 `.env` 文件则要转义) - [ ] `artifacts` 的路径与保留期 - [ ] `maven:3.9-eclipse-temurin-21` 与 `node:20` 的镜像拉取速度 - [ ] **`scripts/check-test-profile.py` 要先过**:它验的就是"CI 那个 profile 能不能起来"。 它红了就是配置问题,不要去查 E2E 脚本 - [ ] 密钥生成命令的长度要对:`openssl rand -base64 32` → 44 字符(AES-256 要 32 字节)。 长度不对时 `ProdEnvCheck` 会点名报出来("解出来 30 字节,需要 32 字节") - [ ] **shell 片段的语法只能在 Linux 上验**:本次开发机的 `cmd.exe` 解析不了 单引号 pathspec(`'frontend-pc/**/*.vue'`),所以"lint 改动文件"那段 是照 Linux 语义写的,首次跑要留意 --- ## §7 为什么没有 GitHub Actions 仓库里原本有一个模板自带的 `frontend-pc/.github/workflows/deploy.yml` (部署到 GitHub Pages)。它在本项目**不可能工作**,已删除: 1. 远端是 **GitLab**(`gitlab.jxyunge.com`),不是 GitHub; 2. 它用 `npm ci` / `npm run build`,而本项目用 **pnpm**(仓库里是 `pnpm-lock.yaml`); 3. 它把管理端当**静态站**发到 Pages —— 而这个前端**必须连着后端**才有意义 (登录、接口、权限全部依赖 `/api`),发上去只会得到一个打不开的演示页。 留着它的害处不是"多一个文件",而是**它会让人以为"我们有部署流水线"**。 --- ## §8 本地能验到哪一步 `.gitlab-ci.yml` 是配置文件,**"写了没验"的 CI 最糟**(第一次真跑才红,而那时所有人都在等)。 所以本轮把能本地跑的都跑了一遍: | 阶段 | 本地怎么验 | 结果 | |---|---|---| | `check` | 直接跑那三个校验脚本 + 把 CI 里的 heredoc 片段原样执行 | ✅ | | `build` | `mvn package`、`pnpm typecheck`、`pnpm build` | ✅ | | `e2e` | 起 `test` profile 确认能起来(`check-test-profile.py`);全套 E2E 在 `dev` 上跑 | ✅ | | `生产环境自检` | **真的起三次 jar**(三种误配形态) | ✅ 9/9 | **本地验不到的**(要在真实 runner 上跑): `services:` 的容器别名解析、`apt-get`/`pip3` 在 runner 镜像里可用性、 artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows, `cmd.exe` 解析不了单引号 pathspec —— 所以"只 lint 改动文件"那段是照 Linux 语义写的)。 这几条都在 §6 的检查单里。 --- ## §9 压测记录(M7-09) 脚本:`python scripts/load-test.py [--rows N] [--only gate|read|upload|export] [--with-upload]` ⚠️ **本机是"负载生成器 + 后端 + PG + Redis 全在同一台机器(且 PG/Redis 在 Docker 里)"**, 所以绝对值偏悲观;**看比例与形状,不要看绝对值**。 另一条同样重要:**谈响应时间必须带规模**(同一个接口在 60 行与 2000 行上是两件事), 所以报告里每个数字都带规模。 ### 9.1 结果(规模:名册 2000 人) | 场景 | 结果 | |---|---| | **门禁核验**(60 人同一出口 IP 同时扫码) | **60/60 成功**(修复前 **20/60**,见 9.3);并发下 p50 218ms / p95 327ms | | **读接口**(20 并发 × 8 轮 = 640 请求,四类混合) | **0 失败**;单请求中位 **36~47ms**(与页大小 20/50/200 **无关**);20 并发下 p50 156~375ms | | **名册列表的 SQL 条数** | **恒为 3 条**(`selectPage` + `count` + 组织名映射),**与 pageSize 无关 → 没有 N+1** | | **DB 侧耗时** | 分页查一页 **1.2ms**、计数 **0.66ms**、组织映射 **0.06ms** | | **导出** | 前置检查正确拦住(`40035`,见 9.4);大包打包**未测**(需要真实审批过的数据) | | **材料上传** | **默认不跑**(见 9.4) | ### 9.2 结论:后端没有 N+1,DB 不是瓶颈 三条独立证据: 1. **数 SQL 条数**:一次名册列表请求恒 3 条 SQL,pageSize 从 5 到 200 都是 3 条; 2. **单请求延迟与页大小无关**(20/50/200 的中位分别是 47/36/44ms)—— 如果是"按行做重活",这三个数会明显拉开; 3. **DB 侧 2ms 以内**(`EXPLAIN ANALYZE`)。 所以并发下的 p50 156~375ms 是**排队效应**(20 个请求抢同一台机器的 CPU 与网络), 不是单请求成本。**要提吞吐得先加机器/拆进程,而不是改 SQL。** ### 9.3 ⚠️ 门禁限流曾经会挡住整间教室(已修) 压测第一次跑出来的是:60 个**不同**学生同时核验(全部正确), **20 个成功、40 个被限流**(`40071`)。原因是 IP 限流 20 次/分钟, 而它计的是**所有请求**(成功也算)。 真实场景里这有多严重:**一个班的学生坐在同一间教室、连同一个 WiFi**, 出口就是同一个公网 IP。开学第一次收材料,后 40 个学生看到的是 「操作过于频繁,请稍后再试」—— 而他们不知道自己该等多久、也不知道为什么别人能进。 **修法:分开两个计数器,因为目的不同。** | 计数器 | 计什么 | 目的 | 限额 | |---|---|---|---| | 失败(按人) | 只计失败 | 防**猜** | 10 次/小时 | | 失败(按 IP) | 只计失败 | 防**枚举** | 20 次/分钟 | | 成功(按 IP) | 只计成功 | 防"批量取数据" | 200 次/分钟 | 关键洞见:**枚举攻击产生的是失败,而教室产生的是成功。** 把成功也算进防枚举的额度里,等于用防攻击的代价挡住正常用户 —— 而挡住的量级恰好是"一个班"。 ⚠️ 另有一条**刻意不计**的失败:`GATE_ALREADY_BOUND`(两个人抢同一个名册项)。 开学第一天几十人同时扫码必然出现几例,把它算进额度里会让整间教室在几秒内被限流, **而没有一个人做错了什么**。 ### 9.4 两条使用注意 - **上传压测默认关闭**(`--with-upload` 才跑):本机 `application-local.yml` 里 `provider: qiniu`,所以上传落的是**线上那个空间**(bucket=tools-matrix)。 一次压测 40 张图打过去,七牛回了 `status:614`。两件事都不对: 压测不该依赖外部服务(网络抖动会让"性能结论"变成对七牛的评价), 也不该往真实桶里灌测试文件(删库不删桶)。 **要跑就先把存储切成本地**:`XT_STORAGE_PROVIDER=local` 重启后端。 - **导出创建被 `40035` 拦住不是失败**:那是铁律(`canExport=false` 时禁止导出) 在服务端的正确生效。脚本会把这种情形报成「被正确拦住」, 并**断言创建接口也必须拒绝** —— 界面禁用按钮只是第一道,服务端是最后一道。 ### 9.5 还没测的 | 场景 | 为什么没测 | 怎么测 | |---|---|---| | 导出**大包打包**(几百人 / 1GB+) | 需要真实审批过的数据,而造那种数据要跑完整审核链路 | 用 staging 的真实批次,量 `ExportService` 的打包耗时与内存峰值 | | 上传并发(CPU 密集的压缩) | 见 9.4(会打到外部存储) | 切本地存储后 `--with-upload` | | 5000 人名册(PRD 提到的导入上限) | 导入本身是同步的,压测里造 5000 行要 ~20s | `--rows 5000`,重点是**导入**耗时与内存 | | 长时间稳定性(内存泄漏、连接泄漏) | 一次性压测测不出来 | 持续 1~2 小时的低强度压测,看 RSS 与 Hikari 活跃连接数是否单调上升 |