<?xml version="1.0" encoding="utf-8"?>
<ul>
  <li>项目里的分布式锁支持多种实现，但默认实现是 Redis：zjc:  distributed-lock:    provider: redis    mysql:      table-name: t_distributed_lock      lease-time: 30s      retry-interval: 100ms  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/config/application-lock.yaml这次提交删掉了仓库里的 MySQL 租约表初始化 SQL。删除前它只包含一张可选实现的表：CREATE TABLE IF NOT EXISTS t_distributed_lock (    lock_key VARCHAR(191) NOT NULL COMMENT '锁 key',    lock_owner VARCHAR(120) NOT NULL COMMENT '持有者标识',    lock_count INT NOT NULL COMMENT '重入次数',    expire_at DATETIME(3) NOT NULL COMMENT '租约到期时间',    PRIMARY KEY (lock_key)) ENGINE = InnoDB  DEFAULT CHARSET = utf8mb4 COMMENT = 'MySQL 分布式锁租约表';  https://github.com/springvortex/spring-cloud-alibaba/commit/3889e425f12a75f36d94f2dc97b697f359d579d3这不是说 MySQL 锁实现被删了。配置里仍保留 mysql.table-name，说明它仍是可切换后端；删除的是“默认初始化清单里的可选后端表”。当 provider 是 redis 时，新环境不需要这张表，把它放在通用 SQL 里反而会让部署者误解 MySQL 业务库的必需结构。如果以后真的切换：zjc:  distributed-lock:    provider: mysql就必须重新为当前环境准备这张租约表，并把它纳入自己的迁移和备份范围。它是该后端的运行依赖，不再是默认部署的公共依赖。经验总结初始化 SQL 应该跟随当前默认运行方式，而不是罗列所有可选实现的存储结构。可选后端的 schema 应该放在后端专属的启用文档或迁移里，切谁准备谁的表。</li>
  <li>微服务部署脚本最怕“清单漂移”：Compose 里新增了服务，脚本里却还维护着四个硬编码模块名。这次 Windows 侧 deploy.ps1 的做法是把 docker-compose.yml 当作唯一服务清单。脚本先解析 Compose 中的镜像定义：$modules = @(    Get-Content -LiteralPath "deploy\docker-compose.yml" |        ForEach-Object {            if ($_ -match 'image:\s*zjc/service-(?&lt;module&gt;[a-z0-9-]+):') {                $Matches["module"]            }        })  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/windows/deploy.ps1后面所有循环都基于 $modules：foreach ($module in $modules) {    $requiredFiles += "deploy\config\$module\application.yaml.template"    $requiredFiles += "service-$module\target\jib-image.tar"}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/windows/deploy.ps1也就是说，新增一个 service-ai 时，只要 Compose 里有 zjc/service-ai:tag，脚本就会自动检查它的配置模板和镜像 tar，并按同样规则传输，不需要再改部署脚本的模块数组。脚本把动作拆成三个开关：[switch]$Build,[switch]$Load,[switch]$StartBuild 调 Maven 构建 linux/amd64 镜像 tar；Load 在远程执行 docker load；Start 加载镜像后执行 docker compose up -d。Start 包含加载动作，避免“只想启动却拿到未加载的旧镜像”。  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/windows/deploy.ps1传输前还有完整性检查：Compose、.env.example、部署文档、维护脚本、每个模块的模板和 tar 缺一不可。这个检查发生在任何 ssh/scp 动作之前，避免远程目录传到一半才发现本地产物缺失。完整脚本较长，正文不贴全文，最新源码直接看 GitHub：deploy.ps1。Windows 脚本怎么用在仓库根目录执行。默认目标是 zjc@192.168.100.128，部署目录是 /home/zjc/app，默认 tag 是 1.0.0：# 只传输已经存在的镜像 tar 和部署文件，不重新构建.\deploy\scripts\windows\deploy.ps1# 构建、传输.\deploy\scripts\windows\deploy.ps1 -Build# 构建、传输，并在远程执行 docker load.\deploy\scripts\windows\deploy.ps1 -Build -Load# 构建、传输、远程加载镜像，并执行 docker compose up -d.\deploy\scripts\windows\deploy.ps1 -Build -Start  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/README.md自定义目标和版本：.\deploy\scripts\windows\deploy.ps1 `  -Remote "zjc@192.168.100.128" `  -AppDir "/home/zjc/app" `  -Tag "1.0.1" `  -Build -Start  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/README.md如果 PowerShell 执行策略拦截脚本：powershell -NoProfile -ExecutionPolicy Bypass -File `  .\deploy\scripts\windows\deploy.ps1 -Build  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/README.mdMaven 不在 PATH，或者安装路径里有空格时，用 -MavenCommand 指定完整命令：.\deploy\scripts\windows\deploy.ps1 `  -Build `  -MavenCommand "D:\tools\maven\bin\mvn.cmd"  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/README.md脚本传输后，Ubuntu 上的关键文件是：/home/zjc/app/docker-compose.yml/home/zjc/app/.env.example/home/zjc/app/config/&lt;module&gt;/application.yaml.template/home/zjc/app/scripts/unix/manage.sh/home/zjc/app/images/service-&lt;module&gt;.tar  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/README.md首次使用 -Start 前，先把 .env 和每个模块的 config/&lt;module&gt;/application.yaml 准备好；脚本只会传模板，不会生成运行配置。经验总结让部署脚本的模块清单来自 Compose，而不是重复维护一份。脚本负责流程编排，服务拓扑仍由 Compose 声明，两边就不会各自为政。</li>
  <li>Ubuntu 侧的 manage.sh 不是把 Docker 命令随便包一层，而是把几个容易误用的操作语义拆开了。最关键的是 restart 和 apply。脚本完整源码较长，正文不贴全文，最新版本直接看 GitHub：manage.sh。重启保留容器：restart_services() {  compose restart}应用 Compose 定义则可能重建容器：apply_services() {  compose up -d}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/unix/manage.sh这两个命令的适用场景不同。修改 config/provider/application.yaml 后，应用重新读取配置，restart service-provider 就够了；修改 .env 里的 APP_TAG、Jasypt 主密钥或 Compose 文件后，容器创建时的配置变了，必须执行 apply all 或 docker compose up -d 让 Compose 按需重建。脚本在交互入口也反复提示这条边界：warn "外置 YAML 只需要 restart；修改 .env 或 docker-compose.yml 时请使用 apply。"  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/unix/manage.sh服务清单同样来自 Compose：service_text=$(compose config --services 2&gt;/dev/null | sort)  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/unix/manage.sh这比在 Bash 里手写 SERVICES=(provider consumer gateway mail) 稳。Compose 中有哪些服务，脚本就展示哪些服务；start、stop、restart、logs、scale 都基于同一份解析结果。.env 的处理也克制：不存在时可以从模板复制并 chmod 600，编辑时不把内容打印到终端；如果修改了 APP_TAG、SPRING_PROFILE 或 Jasypt 主密钥，再提醒执行 apply。  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/unix/manage.sh日常命令进入交互菜单：cd /home/zjc/app./scripts/unix/manage.sh  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/README.md非交互模式更适合固定操作和远程 SSH：./scripts/unix/manage.sh status./scripts/unix/manage.sh start all./scripts/unix/manage.sh stop service-provider./scripts/unix/manage.sh restart service-provider./scripts/unix/manage.sh config-restart service-provider./scripts/unix/manage.sh apply all./scripts/unix/manage.sh logs service-provider./scripts/unix/manage.sh recent service-gateway 300./scripts/unix/manage.sh scale service-provider 2  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/README.md发布新版本时，Windows 先执行：.\deploy\scripts\windows\deploy.ps1 -Build -Load -Tag "1.0.1"Ubuntu 再切换版本并应用：cd /home/zjc/appvi .env# APP_TAG=1.0.1./scripts/unix/manage.sh apply all./scripts/unix/manage.sh status  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/README.md验证通过后，再查看镜像和日志：./scripts/unix/manage.sh images./scripts/unix/manage.sh logs service-providercurl http://192.168.100.128/api/v1/provider/user/1  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/README.md经验总结维护脚本的价值在于把操作语义讲清楚：restart 重读应用配置，apply 应用容器定义。版本发布、环境变量变化走 apply，外置 YAML 变化走 restart，回滚和扩容才不容易踩错档。</li>
  <li>商品购买接口确实要加分布式锁，但 Swagger 文档不应该告诉调用方“我们用的是 Redisson”。这次把描述里的实现名拿掉了：@Operation(    summary = "购买商品",    description = "按商品维度使用分布式锁扣减库存并创建待支付订单")  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/controller/GoodsController.java调用方需要知道的契约是行为：购买会按商品维度串行化关键操作，避免并发扣错库存，并返回订单和剩余库存。至于锁来自 Redis、MySQL 还是 ZooKeeper，不影响请求参数、响应结构，也不改变失败语义。服务层也已经依赖抽象：GoodsPurchaseResponseDTO result = distributedLockFactory.getTemplate().execute(        PURCHASE_LOCK_KEY_PREFIX + goodsId, LOCK_WAIT, "当前购买人数过多，请稍后再试",        () -&gt; {            GoodsPurchaseResponseDTO callbackResult;            try {                callbackResult = transactionTemplate.execute(status -&gt; purchaseInTransaction(goodsId, request));            } catch (BusinessException e) {                throw e;            } catch (RuntimeException e) {                log.error("购买事务执行失败：goodsId={}, userId={}, quantity={}",                        goodsId, request.getUserId(), request.getQuantity(), e);                throw e;            }            evictGoodsCache(goodsId);            return callbackResult;        });  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/service/impl/GoodsPurchaseServiceImpl.java默认实现由配置决定：zjc:  distributed-lock:    provider: redis  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/config/application-lock.yaml如果哪天把 provider 换成 MySQL 或 ZooKeeper，购买接口的 HTTP 契约不应该变。文档若写死 Redisson，就会在实现切换时制造一次无意义的文档变更，甚至误导调用方把运维细节当成接口稳定性的一部分。接口文档可以说明并发语义和可能的失败错误，例如“当前购买人数过多”，但不应该暴露供应商名、内部类名、表名或缓存 key 结构。那些属于实现和运维文档。经验总结API 文档写稳定契约，不写可替换实现。代码依赖 DistributedLockTemplate，文档描述“分布式锁”，两层边界保持一致。</li>
  <li>部署配置里有一类文件适合进 Git，另一类绝对不适合。这次部署目录把两者拆开：application.yaml.template 是可公开的初始模板，服务器上的 application.yaml 是运行配置，只保存在部署机。忽略规则很直接：.envlogs/config/**/application.yaml  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/.gitignore模板只写环境差异，不复制全量配置。Provider 的模板覆盖 Nacos、MySQL、Redis 和 Zipkin 地址：spring:  cloud:    nacos:      discovery:        server-addr: 192.168.100.128:8848  datasource:    url: jdbc:mysql://192.168.100.128:3306/spring_cloud_alibaba?useUnicode=true&amp;characterEncoding=utf-8&amp;serverTimezone=Asia/Shanghai&amp;sslMode=REQUIRED  data:    redis:      host: 192.168.100.128      port: 6379  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/config/provider/application.yaml.template它为什么能覆盖镜像里的配置？因为 Jib 生成的每个镜像都带了这个 JVM 参数：-Dspring.config.additional-location=optional:file:/app/config/  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/pom.xmlCompose 再把部署机目录挂载到 /app/config：volumes:  - ./config/provider:/app/config:ro  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/docker-compose.yml于是 /app/config/application.yaml 参与配置加载，模板里的地址覆盖镜像内置 dev 基线；没有写在模板里的路由、线程池、日志等配置继续使用镜像内置值。.template 后缀不会被 Spring Boot 当成配置文件，它只是给人复制的起点。这次命名也从早期的 application-vm.yaml 收敛为 application.yaml。这不是简单改名：前者需要 vm Profile 配合，后者是通用外置配置。因此 .env 里只需要：SPRING_PROFILE=dev  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/.env.example注意，外置 application.yaml 不是 dev 专属文件。切换到 prod 时也要检查里面是否还有开发 IP、MailHog 或测试端口，避免“Profile 换了，地址却还被外置文件覆盖”的错觉。经验总结模板进 Git，运行配置留部署机；模板只写差异，不搬运全量。这样基础设施迁移只需要改服务器上的 application.yaml 并重启服务，业务默认值变化才进镜像。</li>
  <li>部署脚本如果只负责“把文件传上去并启动”，很容易掩盖一个危险场景：服务器缺少外置 application.yaml，服务带着镜像内置配置启动，看起来成功了，实际连的是错误环境。这次脚本只上传模板：&amp; scp "deploy\config\$module\application.yaml.template" `    "$($Remote):$AppDir/config/$module/application.yaml.template"  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/windows/deploy.ps1然后逐个检查远程运行配置是否存在：foreach ($module in $modules) {    &amp; ssh $Remote "test -f '$AppDir/config/$module/application.yaml'" *&gt; $null    if ($LASTEXITCODE -ne 0) {        $missingRuntimeConfigs += $module    }}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/windows/deploy.ps1如果用户带上了 -Start，脚本不会“顺手”替服务器生成配置，而是直接取消启动：if ($Start -and $missingRuntimeConfigs.Count -gt 0) {    Write-Host "以下服务缺少运行配置：$($missingRuntimeConfigs -join ', ')"    throw "远程运行配置未生成，已取消启动。"}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/windows/deploy.ps1这不方便，但是正确。运行配置可能被运维在服务器上改过，部署脚本无法判断模板里的地址、账号和密文是否适合当前环境。自动覆盖会丢配置，自动放任启动会错环境，两者都比“停下来让人处理”更危险。.env 也保持同样边界：脚本会传输 .env.example，但不会创建或修改远程 .env。自定义镜像 tag 时只输出提醒：if ($Tag -ne "1.0.0") {    Write-Host "提醒：当前构建 tag 是 $Tag，请确保远程 .env 中 APP_TAG=$Tag。"}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/scripts/windows/deploy.ps1经验总结自动化不等于替人做所有决定。传输产物可以自动，环境配置和密钥要留在部署机；启动前做存在性校验，缺失就停，是部署脚本该有的刹车。</li>
  <li>容器重启不是只有“进程消失”一种结果。如果应用正在处理请求，直接杀掉会把用户请求、邮件发送和链路记录一起切断。这次四个服务统一启用了 Spring Boot 优雅停机。Provider、Consumer、Gateway 的配置是：server:  shutdown: gracefulspring:  lifecycle:    timeout-per-shutdown-phase: 30s  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/config/application-shutdown.yamlMail 的等待时间更长：server:  shutdown: gracefulspring:  lifecycle:    timeout-per-shutdown-phase: 60s  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-mail/src/main/resources/config/application-shutdown.yamlserver.shutdown: graceful 让 Web 服务器停止接收新请求，并给正在处理的请求留出完成时间；timeout-per-shutdown-phase 约束 Spring 生命周期各阶段的停止等待。Mail 依赖外部 SMTP，慢请求的概率更高，所以单独放宽到 60 秒。配置不是散落在四个 application.yaml 里，而是作为公共 Profile 引入：spring:  profiles:    include:      - shutdown  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/application.yaml并且有配置结构测试兜底：assertThat(path(application, "spring.profiles.include")).asList().contains("shutdown");assertThat(path(shutdown, "server.shutdown")).isEqualTo("graceful");assertThat(path(shutdown, "spring.lifecycle.timeout-per-shutdown-phase"))        .isEqualTo("30s");  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/test/java/com/zjc/provider/config/ProfileConfigurationTest.javaDocker 侧也要配合留时间。普通服务的 stop_grace_period 是 40 秒，Mail 是 70 秒，都比应用等待多 10 秒：stop_grace_period: 40s  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/docker-compose.yml如果 Docker 的宽限期小于应用等待时间，应用还没处理完请求就被强制杀掉；多留 10 秒是给信号传播、收尾日志和 Compose 调度过程留缓冲。经验总结优雅停机要同时看应用和容器两层：Spring 负责拒绝新请求并等待旧请求，Docker 负责给 SIGTERM 后的进程足够时间。公共 Profile 统一配置，测试固定约定，Compose 宽限期略高于应用等待。</li>
  <li>日志先要能被找到，然后才谈得上分析。这次把四个服务的 Logback 滚动文件名统一成“应用名 + 级别 + 日期”的格式。应用名来自 Spring 属性：&lt;springProperty name="application"                scope="context"                source="spring.application.name"                defaultValue="app"/&gt;&lt;property name="LOG_HOME" value="logs/${application}"/&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/logback-spring.xml各级别文件都使用同一命名规则：&lt;fileNamePattern&gt;    ${LOG_HOME}/info/${application}-info-%d{yyyy-MM-dd}.%i.log&lt;/fileNamePattern&gt;&lt;maxFileSize&gt;1MB&lt;/maxFileSize&gt;&lt;maxHistory&gt;180&lt;/maxHistory&gt;&lt;totalSizeCap&gt;2GB&lt;/totalSizeCap&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/logback-spring.xml以前的问题是格式不统一：DEBUG 文件里有构建时生成的 logPrefix 和小时分钟，INFO、WARN、ERROR 又叫 log-info、log-warn、log-error。排查跨服务问题时，文件名本身无法说明来源。调整后是：service-provider-info-2026-08-25.0.logservice-gateway-error-2026-08-25.0.logservice-mail-warn-2026-08-25.0.log日期只保留到天，同一天超过 1MB 时由 %i 递增序号。相比把小时分钟放进文件名，这种结构更适合 SizeAndTimeBasedRollingPolicy，也让按天检索保持稳定。在 Docker 里，Compose 已把 /app/logs/&lt;application&gt; 挂到宿主机的 logs/provider、logs/gateway 等目录：- ./logs/provider:/app/logs/service-provider  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/docker-compose.yml所以生产排查既可以用宿主机文件，也可以用 docker compose logs 看 stdout/stderr。要注意多实例场景：当前文件名按服务名区分，不按容器实例区分，长期横向扩容前应把容器 HOSTNAME 加入日志路径或文件名，避免多个 JVM 写同一组文件。完整配置四个可运行服务的 logback-spring.xml 结构一致，下面贴 Provider 的完整配置；Gateway、Consumer、Mail 会通过各自的 spring.application.name 得到自己的日志目录和文件名前缀。&lt;?xml version="1.0" encoding="UTF-8"?&gt;&lt;configuration scan="true" scanPeriod="10 seconds" debug="false"&gt;    &lt;springProperty name="application" scope="context" source="spring.application.name" defaultValue="app"/&gt;    &lt;springProperty name="port" scope="context" source="server.port" defaultValue="0"/&gt;    &lt;contextName&gt;logback&lt;/contextName&gt;    &lt;property name="LOG_HOME" value="logs/${application}"/&gt;    &lt;property name="CONSOLE_LOG_PATTERN"              value="[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [traceId=%X{traceId:-},spanId=%X{spanId:-}] [%highlight(%-5level)] [${application}:${port}] [%cyan(%logger{50})] [%msg]%n"/&gt;    &lt;property name="FILE_LOG_PATTERN"              value="[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [traceId=%X{traceId:-},spanId=%X{spanId:-}] [%-5level] [${application}:${port}] [%logger{50}] [%msg]%n"/&gt;    &lt;!-- DEBUG 级别文件输出 --&gt;    &lt;appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"&gt;        &lt;encoder&gt;            &lt;Pattern&gt;${FILE_LOG_PATTERN}&lt;/Pattern&gt;            &lt;charset&gt;UTF-8&lt;/charset&gt;        &lt;/encoder&gt;        &lt;rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"&gt;            &lt;fileNamePattern&gt;${LOG_HOME}/debug/${application}-debug-%d{yyyy-MM-dd}.%i.log&lt;/fileNamePattern&gt;            &lt;maxFileSize&gt;1MB&lt;/maxFileSize&gt;            &lt;maxHistory&gt;180&lt;/maxHistory&gt;            &lt;totalSizeCap&gt;2GB&lt;/totalSizeCap&gt;        &lt;/rollingPolicy&gt;        &lt;filter class="ch.qos.logback.classic.filter.LevelFilter"&gt;            &lt;level&gt;DEBUG&lt;/level&gt;            &lt;onMatch&gt;ACCEPT&lt;/onMatch&gt;            &lt;onMismatch&gt;DENY&lt;/onMismatch&gt;        &lt;/filter&gt;    &lt;/appender&gt;    &lt;!-- INFO 级别文件输出（仅 INFO） --&gt;    &lt;appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"&gt;        &lt;encoder&gt;            &lt;Pattern&gt;${FILE_LOG_PATTERN}&lt;/Pattern&gt;            &lt;charset&gt;UTF-8&lt;/charset&gt;        &lt;/encoder&gt;        &lt;rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"&gt;            &lt;fileNamePattern&gt;${LOG_HOME}/info/${application}-info-%d{yyyy-MM-dd}.%i.log&lt;/fileNamePattern&gt;            &lt;maxFileSize&gt;1MB&lt;/maxFileSize&gt;            &lt;maxHistory&gt;180&lt;/maxHistory&gt;            &lt;totalSizeCap&gt;2GB&lt;/totalSizeCap&gt;        &lt;/rollingPolicy&gt;        &lt;filter class="ch.qos.logback.classic.filter.LevelFilter"&gt;            &lt;level&gt;INFO&lt;/level&gt;            &lt;onMatch&gt;ACCEPT&lt;/onMatch&gt;            &lt;onMismatch&gt;DENY&lt;/onMismatch&gt;        &lt;/filter&gt;    &lt;/appender&gt;    &lt;!-- WARN 级别文件输出 --&gt;    &lt;appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"&gt;        &lt;encoder&gt;            &lt;Pattern&gt;${FILE_LOG_PATTERN}&lt;/Pattern&gt;            &lt;charset&gt;UTF-8&lt;/charset&gt;        &lt;/encoder&gt;        &lt;rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"&gt;            &lt;fileNamePattern&gt;${LOG_HOME}/warn/${application}-warn-%d{yyyy-MM-dd}.%i.log&lt;/fileNamePattern&gt;            &lt;maxFileSize&gt;1MB&lt;/maxFileSize&gt;            &lt;maxHistory&gt;180&lt;/maxHistory&gt;            &lt;totalSizeCap&gt;2GB&lt;/totalSizeCap&gt;        &lt;/rollingPolicy&gt;        &lt;filter class="ch.qos.logback.classic.filter.LevelFilter"&gt;            &lt;level&gt;WARN&lt;/level&gt;            &lt;onMatch&gt;ACCEPT&lt;/onMatch&gt;            &lt;onMismatch&gt;DENY&lt;/onMismatch&gt;        &lt;/filter&gt;    &lt;/appender&gt;    &lt;!-- ERROR 级别文件输出 --&gt;    &lt;appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"&gt;        &lt;encoder&gt;            &lt;Pattern&gt;${FILE_LOG_PATTERN}&lt;/Pattern&gt;            &lt;charset&gt;UTF-8&lt;/charset&gt;        &lt;/encoder&gt;        &lt;rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"&gt;            &lt;fileNamePattern&gt;${LOG_HOME}/error/${application}-error-%d{yyyy-MM-dd}.%i.log&lt;/fileNamePattern&gt;            &lt;maxFileSize&gt;1MB&lt;/maxFileSize&gt;            &lt;maxHistory&gt;180&lt;/maxHistory&gt;            &lt;totalSizeCap&gt;2GB&lt;/totalSizeCap&gt;        &lt;/rollingPolicy&gt;        &lt;filter class="ch.qos.logback.classic.filter.LevelFilter"&gt;            &lt;level&gt;ERROR&lt;/level&gt;            &lt;onMatch&gt;ACCEPT&lt;/onMatch&gt;            &lt;onMismatch&gt;DENY&lt;/onMismatch&gt;        &lt;/filter&gt;    &lt;/appender&gt;    &lt;!-- Logback AsyncAppender 只允许挂载一个下游 Appender，因此每个级别单独异步 --&gt;    &lt;appender name="ASYNC_DEBUG_FILE" class="ch.qos.logback.classic.AsyncAppender"&gt;        &lt;queueSize&gt;8192&lt;/queueSize&gt;        &lt;discardingThreshold&gt;0&lt;/discardingThreshold&gt;        &lt;neverBlock&gt;false&lt;/neverBlock&gt;        &lt;includeCallerData&gt;false&lt;/includeCallerData&gt;        &lt;maxFlushTime&gt;3000&lt;/maxFlushTime&gt;        &lt;appender-ref ref="DEBUG_FILE"/&gt;    &lt;/appender&gt;    &lt;appender name="ASYNC_INFO_FILE" class="ch.qos.logback.classic.AsyncAppender"&gt;        &lt;queueSize&gt;8192&lt;/queueSize&gt;        &lt;discardingThreshold&gt;0&lt;/discardingThreshold&gt;        &lt;neverBlock&gt;false&lt;/neverBlock&gt;        &lt;includeCallerData&gt;false&lt;/includeCallerData&gt;        &lt;maxFlushTime&gt;3000&lt;/maxFlushTime&gt;        &lt;appender-ref ref="INFO_FILE"/&gt;    &lt;/appender&gt;    &lt;appender name="ASYNC_WARN_FILE" class="ch.qos.logback.classic.AsyncAppender"&gt;        &lt;queueSize&gt;8192&lt;/queueSize&gt;        &lt;discardingThreshold&gt;0&lt;/discardingThreshold&gt;        &lt;neverBlock&gt;false&lt;/neverBlock&gt;        &lt;includeCallerData&gt;false&lt;/includeCallerData&gt;        &lt;maxFlushTime&gt;3000&lt;/maxFlushTime&gt;        &lt;appender-ref ref="WARN_FILE"/&gt;    &lt;/appender&gt;    &lt;appender name="ASYNC_ERROR_FILE" class="ch.qos.logback.classic.AsyncAppender"&gt;        &lt;queueSize&gt;8192&lt;/queueSize&gt;        &lt;discardingThreshold&gt;0&lt;/discardingThreshold&gt;        &lt;neverBlock&gt;false&lt;/neverBlock&gt;        &lt;includeCallerData&gt;false&lt;/includeCallerData&gt;        &lt;maxFlushTime&gt;3000&lt;/maxFlushTime&gt;        &lt;appender-ref ref="ERROR_FILE"/&gt;    &lt;/appender&gt;    &lt;!-- 开发/测试环境：控制台同步输出，文件异步输出 --&gt;    &lt;springProfile name="dev,test"&gt;        &lt;appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"&gt;            &lt;filter class="ch.qos.logback.classic.filter.ThresholdFilter"&gt;                &lt;level&gt;DEBUG&lt;/level&gt;            &lt;/filter&gt;            &lt;encoder&gt;                &lt;Pattern&gt;${CONSOLE_LOG_PATTERN}&lt;/Pattern&gt;                &lt;charset&gt;UTF-8&lt;/charset&gt;            &lt;/encoder&gt;        &lt;/appender&gt;        &lt;!-- 业务代码输出 DEBUG，第三方依赖保持 INFO，避免噪声过大 --&gt;        &lt;logger name="com.zjc" level="DEBUG"/&gt;        &lt;root level="INFO"&gt;            &lt;appender-ref ref="STDOUT"/&gt;            &lt;appender-ref ref="ASYNC_DEBUG_FILE"/&gt;            &lt;appender-ref ref="ASYNC_INFO_FILE"/&gt;            &lt;appender-ref ref="ASYNC_WARN_FILE"/&gt;            &lt;appender-ref ref="ASYNC_ERROR_FILE"/&gt;        &lt;/root&gt;    &lt;/springProfile&gt;    &lt;!-- 生产环境：仅保留异步文件输出 --&gt;    &lt;springProfile name="prod"&gt;        &lt;root level="INFO"&gt;            &lt;appender-ref ref="ASYNC_DEBUG_FILE"/&gt;            &lt;appender-ref ref="ASYNC_INFO_FILE"/&gt;            &lt;appender-ref ref="ASYNC_WARN_FILE"/&gt;            &lt;appender-ref ref="ASYNC_ERROR_FILE"/&gt;        &lt;/root&gt;    &lt;/springProfile&gt;&lt;/configuration&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/logback-spring.xml经验总结滚动文件名带上应用名，是最便宜的日志元数据。服务、级别、日期一眼可辨，再配合大小滚动和保留上限，日志才不会变成部署机上的黑洞。</li>
  <li>这次容器化没有走“本机 docker build”的路线，而是用 Jib 的 buildTar 直接生成 OCI 镜像 tar。它的好处很实际：Windows 开发机不需要安装 Docker Desktop，也不需要启动 Docker daemon；产物可以直接 scp 到 Ubuntu，再由服务器执行 docker load。父 POM 先把公共决策收敛起来：&lt;jib-maven-plugin.version&gt;3.5.2&lt;/jib-maven-plugin.version&gt;&lt;docker.image-prefix&gt;zjc&lt;/docker.image-prefix&gt;&lt;docker.tag&gt;${project.version}&lt;/docker.tag&gt;&lt;docker.base-image&gt;dockerproxy.net/library/eclipse-temurin:21-jre&lt;/docker.base-image&gt;&lt;jib.skip&gt;true&lt;/jib.skip&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/pom.xmljib.skip 默认是 true，所以日常 mvn clean package 不会偷偷生成四个镜像 tar。只有显式激活 docker-tar Profile 的模块才把开关关掉。Jib 的公共配置也放在父 POM：&lt;execution&gt;    &lt;id&gt;build-docker-tar&lt;/id&gt;    &lt;phase&gt;package&lt;/phase&gt;    &lt;goals&gt;        &lt;goal&gt;buildTar&lt;/goal&gt;    &lt;/goals&gt;&lt;/execution&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/pom.xml镜像基础信息、平台、工作目录和 JVM 参数由父 POM 统一声明：&lt;image&gt;${docker.image-prefix}/${project.artifactId}:${docker.tag}&lt;/image&gt;&lt;platforms&gt;    &lt;platform&gt;        &lt;os&gt;linux&lt;/os&gt;        &lt;architecture&gt;amd64&lt;/architecture&gt;    &lt;/platform&gt;&lt;/platforms&gt;&lt;workingDirectory&gt;/app&lt;/workingDirectory&gt;&lt;jvmFlags&gt;    &lt;jvmFlag&gt;-Dspring.config.additional-location=optional:file:/app/config/&lt;/jvmFlag&gt;    &lt;jvmFlag&gt;-XX:MaxRAMPercentage=75.0&lt;/jvmFlag&gt;    &lt;jvmFlag&gt;-XX:+ExitOnOutOfMemoryError&lt;/jvmFlag&gt;    &lt;jvmFlag&gt;-XX:+HeapDumpOnOutOfMemoryError&lt;/jvmFlag&gt;&lt;/jvmFlags&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/pom.xml这段公共配置承担了几件事：镜像名统一为 zjc/&lt;module&gt;:tag，平台固定为部署机常见的 linux/amd64，工作目录固定为 /app，容器统一支持外置配置，并按容器内存限制计算堆。OOM 时进程退出，交给 Docker 的重启策略处理，同时保留堆转储现场。可运行服务只补齐自己真正不同的三个值。以 Provider 为例：&lt;profile&gt;    &lt;id&gt;docker-tar&lt;/id&gt;    &lt;properties&gt;        &lt;docker.main-class&gt;com.zjc.provider.ProviderApplication&lt;/docker.main-class&gt;        &lt;docker.container-port&gt;9001&lt;/docker.container-port&gt;        &lt;jib.skip&gt;false&lt;/jib.skip&gt;    &lt;/properties&gt;&lt;/profile&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/pom.xml这样模块 POM 不需要复制 Jib 全量配置，新增可运行服务时也只需要说明启动类、容器端口和开关。构建命令是：mvn -Pdocker-tar "-Ddocker.tag=1.0.0" -DskipTests clean package  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/README.md最终每个服务都会得到 target/jib-image.tar。Jib 不需要 Docker daemon，但构建基础镜像时仍要能读取基础镜像 manifest；如果代理地址不可用，可以用 -Ddocker.base-image=... 换成可访问的 eclipse-temurin:21-jre 地址。从 Maven 到 Docker 的完整过程先看纯命令链路。Windows 开发机只需要 JDK 21、Maven、ssh/scp，不需要安装 Docker；Docker Engine 和 Compose 插件安装在 Ubuntu 部署机上。第一步，在仓库根目录构建全部镜像：mvn -Pdocker-tar "-Ddocker.tag=1.0.0" -DskipTests clean package  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/README.md构建完成后会得到四个 tar：service-provider\target\jib-image.tarservice-consumer\target\jib-image.tarservice-gateway\target\jib-image.tarservice-mail\target\jib-image.tar  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/README.md临时只验证 Provider 时，可以只构建 Provider 及其依赖模块：mvn -pl service-provider -am -Pdocker-tar "-Ddocker.tag=1.0.0" -DskipTests clean package  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/README.md第二步，把 tar 传输到 Ubuntu。日常推荐直接用部署脚本：.\deploy\scripts\windows\deploy.ps1 -Build -Load -Tag "1.0.0"它会执行 Maven 构建、同步 Compose 和配置模板、传输镜像 tar，并远程执行 docker load。如果想完全手动，则用 scp 把每个 jib-image.tar 传到部署机的 images/ 目录。第三步，首次部署要在 Ubuntu 上生成两个不会被脚本覆盖的东西：cd /home/zjc/appcp .env.example .envchmod 600 .envvi .envfor module in provider consumer gateway mail; do  [ -f "config/$module/application.yaml" ] ||    cp "config/$module/application.yaml.template" \       "config/$module/application.yaml"done  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/README.md.env 中至少确认三个值：APP_TAG=1.0.0SPRING_PROFILE=devJASYPT_ENCRYPTOR_PASSWORD=真实主密钥APP_TAG 必须和 Maven 构建时的 -Ddocker.tag 一致，否则 Compose 找不到刚加载的镜像。  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/README.md第四步，如果没有让脚本远程加载镜像，在 Ubuntu 执行：cd /home/zjc/appfor module in provider consumer gateway mail; do  docker load -i "images/service-$module.tar"done  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/README.md第五步，启动并验证：docker compose up -ddocker compose psdocker compose imagesdocker compose logs --tail=200 service-providercurl http://192.168.100.128/api/v1/provider/user/1curl http://192.168.100.128/api/v1/consumer/user/1  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/README.md发布 1.0.1 时流程固定为：mvn -Pdocker-tar "-Ddocker.tag=1.0.1" -DskipTests clean package.\deploy\scripts\windows\deploy.ps1 -Tag "1.0.1" -Load然后在 Ubuntu 修改 .env 的 APP_TAG=1.0.1，再执行 docker compose up -d。镜像 tag 变化后必须让 Compose 重建容器，只执行 restart 不会切换镜像。  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/README.md经验总结buildTar 适合没有镜像仓库的单机部署链路：公共镜像策略放父 POM，服务差异放模块 Profile，产物离线传输。只要 tag 不复用，这条链路比每台机器都装一套构建环境更可控。</li>
  <li>容器化后最容易混淆的一组概念是 expose 和 ports。这次 Compose 文件把它们用得很清楚：Provider、Consumer、Mail 只在 Docker 网络内声明端口，Gateway 才发布宿主机端口。四个服务先进入同一个 bridge 网络：networks:  zjc-net:    driver: bridge  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/docker-compose.yml业务服务使用 expose：service-provider:  image: zjc/service-provider:${APP_TAG}  expose:    - "9001"  networks:    - zjc-netexpose 只是声明容器网络内可访问，不占用宿主机端口。外部流量统一从网关进入：service-gateway:  image: zjc/service-gateway:${APP_TAG}  ports:    - "80:80"  networks:    - zjc-net  depends_on:    - service-provider    - service-consumer    - service-mail  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/docker-compose.yml这个网络对 Nacos 服务发现尤其重要。Nacos 注册的是容器 IP，Gateway 拿到 Provider、Consumer、Mail 的实例列表后，要能直接访问这些 IP。让四个服务共用 zjc-net，比把所有业务端口发布到宿主机更符合实际调用路径，也少了很多误暴露面。日志则按服务落到宿主机：volumes:  - ./config/provider:/app/config:ro  - ./logs/provider:/app/logs/service-provider  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/deploy/docker-compose.yml配置目录只读，日志目录可写。容器重建后，应用文件日志仍保留在部署机，方便按 logs/provider、logs/gateway 这类目录排查。还有一个容易误读的配置：depends_on:  - service-provider  - service-consumer  - service-mail它只控制容器启动顺序，不代表下游服务已经完成 Nacos 注册或业务就绪。真正的调用可用性仍由 Gateway 的服务发现、路由错误和健康观测体现。经验总结Compose 的端口边界应该和系统架构一致：内网服务用 expose，唯一入口用 ports。depends_on 解决顺序问题，不解决就绪问题； readiness 还是要交给注册中心、健康检查和请求观测。</li>
  <li>邮件发送是有副作用的写操作。service-mail 不可用时，Consumer 不能在本地造一条“发送成功”的响应，否则调用方会以为邮件已经发出，问题会延迟到用户收不到邮件时才暴露。这次提交给 MailFeignApi 显式接入降级工厂：@FeignClient(        value = "service-mail",        contextId = "mailFeignApi",        fallbackFactory = MailFeignFallbackFactory.class)  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/api/mail/MailFeignApi.java降级实现只做一件事：return dto -&gt; ApiResponse.failure(        ApiResponseEnum.SERVICE_UNAVAILABLE.code(), "业务繁忙，请稍后再试");  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/api/mail/factory/MailFeignFallbackFactory.java失败原因在 create(Throwable cause) 里记录 ERROR，用于后续排查 SMTP、服务发现、超时或下游状态；对外响应保持统一的 503 业务码和稳定提示。这个实现仍然不是完整的可靠投递方案。调用方收到失败后可以重试，但如果要求“邮件最终一定发送”，还需要发送状态落库、重试队列、幂等键和对账机制。当前项目的边界是先把失败如实暴露出来。MailFeignFallbackFactory 同时加入 AutoConfiguration.imports，并新增清单测试确认用户、邮件两个降级工厂都会被公共模块自动注册。共享 Feign 契约的模块不需要再手动 @Import。经验总结读操作可以考虑空数据兜底，写操作的第一原则是诚实。邮件发送失败必须返回失败，后续的补偿和重试才有机会建立在一个真实状态上。</li>
  <li>用户 Feign 接口之前的降级策略是：单个查询返回 data=null 的成功响应，列表返回空列表。这个写法对演示很友好，但有一个语义问题：查询成功但数据不存在 -&gt; success=true, data=null下游服务不可用     -&gt; success=true, data=null调用方无法区分“确实没有这条用户”和“Provider 当前不可用”。这次提交把降级响应统一改成：private static &lt;T&gt; ApiResponse&lt;T&gt; serviceUnavailable() {    return ApiResponse.failure(ApiResponseEnum.SERVICE_UNAVAILABLE.code(), "业务繁忙，请稍后再试");}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/api/user/factory/UserFeignFallbackFactory.java调整后，getUser 和 list 触发降级时都返回：{  "success": false,  "code": 503,  "message": "业务繁忙，请稍后再试",  "data": null}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/test/java/com/zjc/common/api/user/factory/UserFeignFallbackFactoryTest.java失败原因仍然由 FallbackFactory 拿到并写入日志，包括连接拒绝、超时、下游 503 或反序列化失败；但对外只暴露稳定提示，不透出下游地址、连接类型等基础设施细节。单元测试同步验证了两件事：单个查询和列表都返回 SERVICE_UNAVAILABLE，并且 data 为空。这样上层不能再把降级误判成普通空结果。经验总结降级响应要保留业务语义。查询不到数据和下游不可用是两种完全不同的结果；前者可以是成功响应，后者应该明确失败，让调用方决定重试、提示还是停止后续流程。</li>
  <li>这次提交把 Gateway 的 CORS 配置从 application-dev.yaml 移到独立的 config/application-cors.yaml，再由基础配置引入：spring:  profiles:    include:      - cors  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/resources/application.yaml公共配置如下：spring:  cloud:    gateway:      server:        webflux:          globalcors:            cors-configurations:              '[/**]':                allowed-origin-patterns:                  - '*'                allowed-headers:                  - '*'                allowed-methods:                  - '*'                allow-credentials: false                max-age: 3600  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/resources/config/application-cors.yaml调整后 dev 和 prod 共用同一份 CORS 策略，环境文件只保留真正有环境差异的路由、地址、文档开关和采样率。这样避免了一个常见漂移：开发环境允许跨域，生产环境却悄悄没有配置，前端上线时才暴露问题。这里最关键的约束是：allowed-origin-patterns = *allow-credentials = false  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/resources/config/application-cors.yaml通配来源和携带凭证不能同时放开。当前系统不通过 Cookie 做跨域凭证，所以可以使用通配来源；如果以后要携带 Cookie，必须把来源改成明确的真实域名。配置测试同时断言了三件事：基础配置包含 cors profile，dev/prod 没有自己的 globalcors 覆盖，公共文件中的来源、方法、请求头和不允许凭证的基线正确。CORS 是安全边界的一部分，靠“浏览器试一下”不够。经验总结CORS 不只是前端报错时的补丁，而是网关的入口策略。把它提成公共 Profile，环境差异只留下地址和运行开关，策略行为才能在 dev/prod 之间保持一致。</li>
  <li>商品购买是这套项目里第一个真正需要跨实例互斥的业务。Provider 多实例部署后，同一个商品的扣库存请求可能被 LoadBalancer 分到不同 JVM，本地锁失效，库存就可能超卖。这次实现的链路是：Consumer -&gt; GoodsFeignApi -&gt; Provider        -&gt; 按商品 ID 获取 Redisson 锁        -&gt; 事务内校验商品        -&gt; 数据库条件更新扣库存        -&gt; 保存订单主表和明细        -&gt; 提交后清理商品详情缓存  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/service/impl/GoodsPurchaseServiceImpl.java锁按商品维度拆分：redissonClient.getLock("zjc:provider:goods:purchase:lock:" + goodsId);  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/service/impl/GoodsPurchaseServiceImpl.java不同商品不互相等待，同一商品在集群内串行执行。这个版本的锁等待为 10 秒，超时返回 503 和“当前购买人数过多”；后续压测提交又把等待调整到 20 秒。只靠锁还不够。数据库层使用条件更新：UPDATE t_goods   SET stock = stock - #{quantity} WHERE goods_id = #{goodsId}   AND status = 1   AND is_deleted = 0   AND stock &gt;= #{quantity}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/mapper/GoodsMapper.java影响行数不是 1 就说明商品不可购买或库存不足，事务直接失败回滚。这样即使 Redis 锁异常失效、看门狗续期异常或锁被误释放，数据库条件也能保证库存不会被扣成负数。订单主表和明细使用已有的 saveWithDetails 同事务保存，明细保留商品名称、单价和数量快照。事务提交后再清理 provider:goods:id 详情缓存；缓存清理失败不影响购买结果，但会记录 ERROR，提示旧库存可能保留到 TTL 到期。购买请求本身也有边界：用户 ID 必须有效，购买数量限制在 1 到 100。Consumer 侧只是 Feign 入口，不本地兜底下单，Provider 不可用时统一返回业务繁忙。经验总结分布式锁负责减少并发冲突，数据库条件更新负责守住最后一致性边界。两层能力解决的是不同问题，购买链路里都需要存在。</li>
  <li>商品购买最初直接注入 RedissonClient，能跑通，但业务代码被迫知道锁的获取、中断、续期、解锁异常等细节。这次提交把这些细节下沉到公共模板：public interface DistributedLockTemplate {    &lt;T&gt; T execute(String lockKey, Duration waitTime, Supplier&lt;T&gt; action);    &lt;T&gt; T execute(String lockKey, Duration waitTime,                  String timeoutMessage, Supplier&lt;T&gt; action);}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/lock/DistributedLockTemplate.java业务侧变成：distributedLockFactory.getTemplate().execute(        PURCHASE_LOCK_KEY_PREFIX + goodsId,        LOCK_WAIT,        "当前购买人数过多，请稍后再试",        () -&gt; purchaseInLock(goodsId, request));  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/service/impl/GoodsPurchaseServiceImpl.java默认实现由配置选择：zjc:  distributed-lock:    provider: redis    mysql:      table-name: t_distributed_lock      lease-time: 30s      retry-interval: 100ms  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/config/application-lock.yaml当前支持三种 Provider：            Provider      状态      说明                  redis      可用      Redisson 可重入锁，不传租期时由看门狗续期              mysql      可用      租约表、唯一键、行事务、重入计数和后台续期              zookeeper      占位      显式抛出未实现，保留后续接入 Curator 的位置      MySQL 实现使用 lock_key 唯一键和 SELECT ... FOR UPDATE 完成抢锁，每个线程有独立 owner，支持重入计数；持有期间按租期的三分之一续期，进程崩溃后等待租约到期释放。表名通过白名单正则校验后才拼接 SQL，避免配置注入。公共模块中的 Redisson、JDBC 和事务依赖都是 optional。自动配置根据已有 Bean 装配可用实现：有 RedissonClient 就提供 Redis 模板，有 JdbcTemplate 和事务管理器才提供 MySQL 模板。不需要锁或没有对应基础设施的服务，不会被强制引入依赖。这个抽象的边界也清楚：它解决“用统一方式拿锁和释放锁”，不把业务事务包进锁模板。商品购买仍在回调里使用 TransactionTemplate，数据库条件更新依旧是最后兜底。经验总结当锁的语义开始包含等待、超时、中断和释放异常时，业务代码不应该直接面对具体客户端 API。统一模板保留业务语义，把存储差异和释放细节留在基础设施层。</li>
  <li>网关限流不能只写一条兜底规则。全站默认流量和邮件发送这种容易触发外部依赖的接口，承载能力完全不同。这次提交把原来的单接口规则改成两层：interfaces:  - name: non-mail-interfaces    pattern: /api/(?![^/]+/mail/send$).*    total-qps: 100    per-ip-qps: 10  - name: mail-send    pattern: /api/[^/]+/mail/send    total-qps: 20    per-ip-qps: 2  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/resources/config/application-sentinel.yaml默认规则使用负向前瞻，匹配除邮件发送外的全部网关入口：/api/v1/consumer/user/1  -&gt; non-mail-interfaces/api/v1/provider/user/1  -&gt; non-mail-interfaces/api/v1/mail/send        -&gt; mail-send  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/test/java/com/zjc/gateway/config/ProfileConfigurationTest.java这样邮件接口只进入专用规则，不会被默认规则重复统计。阈值也拆开了：普通入口是 100/10 QPS，邮件发送是 20/2 QPS，更贴近两类请求的风险。还有一个容易误解的点：Sentinel 只统计进入 Gateway 的这一次请求。Gateway -&gt; Consumer -&gt; Feign -&gt; ProviderConsumer 内部通过 Feign 调 Provider 时没有再次经过 Gateway，所以不会被 Gateway 的接口限流重复计数。反过来，直连 Provider 或 Consumer 端口也不会触发这些入口规则。配置测试直接调用 Pattern.matches 验证三个路径的归属，同时断言两条规则的名称和阈值。规则越接近业务入口，越需要这种可执行的说明。经验总结默认规则适合兜住普通入口，专用规则承载风险更高的接口。用负向前瞻保证规则互斥，再用测试固定匹配结果，限流语义才不会随着正则改动悄悄变化。</li>
  <li>这次提交没有新增独立的 service-cache 服务，而是把 Redis 缓存基础设施放在 service-common，把具体缓存放回数据拥有方 service-provider 的 Service 层。公共配置：spring:  cache:    type: rediszjc:  cache:    redis:      enabled: true      key-prefix: "zjc:"      default-ttl: 30m      cache-ttls:        provider:user:id: 30m        provider:goods:id: 30m  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/config/application-redis.yamlservice-common 提供 JSON 值序列化、字符串 key 序列化、统一 key 前缀、默认 TTL、按 cacheName 覆盖 TTL 和故障降级。Redis 和 Spring Cache 依赖在公共模块中是 optional，只有业务服务自己引入 starter 后，自动配置才生效，Gateway 不会被强制带入 Redis 运行时。Provider 的商品详情读取：@Cacheable(cacheNames = "provider:goods:id", key = "#goodsId")public GoodsDTO getGoods(Long goodsId) {    return goodsConverter.entityToDto(getById(goodsId));}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/service/impl/GoodsServiceImpl.java更新和删除后按 ID 驱逐：@CacheEvict(cacheNames = "provider:goods:id", key = "#goods.goodsId")public boolean updateGoods(Goods goods) {    return updateById(goods);}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/service/impl/GoodsServiceImpl.java最终 key 形如：zjc:provider:goods:id:1  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/test/java/com/zjc/common/cache/RedisCacheAutoConfigurationTest.java放在 Service 层而不是 Controller 层，是因为缓存的是业务对象和数据库访问结果；放在 Provider 而不是 Consumer，是因为 Provider 是用户、商品数据的拥有方，能在写入后准确失效，避免双层缓存和跨服务失效不同步。缓存故障策略也有边界：读和写失败记录 WARN，并继续执行数据库方法；清理失败记录 ERROR，因为旧数据可能保留到 TTL 到期。缓存不可用时业务可用，但一致性风险必须被看见。经验总结缓存不是独立服务边界，而是数据拥有方的一种访问策略。公共模块统一技术底座，业务模块决定哪些数据值得缓存、何时失效，这样职责更稳。</li>
  <li>Redis 缓存里的 JSON 会携带类型信息。如果反序列化允许任意 @type，一旦 Redis 数据被篡改，应用就可能实例化不该实例化的类型。项目的原则是：Redis 不是可信边界。基础白名单包含：com.zjc.* 项目内部 DTOSpring Cache NullValuejava.math.BigDecimal  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/cache/RedisCacheAutoConfiguration.java这次提交把 JDK 类型部分做成配置：zjc:  cache:    redis:      allowed-sub-types:        - java.math.BigDecimal  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/config/application-redis.yaml装配时会把这些完整类名追加进 BasicPolymorphicTypeValidator：properties.getAllowedSubTypes()        .forEach(validatorBuilder::allowIfSubType);  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/cache/RedisCacheAutoConfiguration.java默认加入 BigDecimal 是为了让商品金额等数值字段稳定参与多态序列化。后续如果确实需要 BigInteger 之类类型，也通过完整类名显式追加。这里不建议为了省事放宽到 java.lang.Object 或整个 JDK 包前缀。白名单越宽，安全边界越模糊；测试里专门构造了：{"@type":"java.lang.Thread"}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/test/java/com/zjc/common/cache/RedisCacheAutoConfigurationTest.java并断言反序列化失败，防止未来调整序列化器时把任意类型又放开。同一次提交还做了几处配套修正：Spring Cloud 版本升到 2025.1.3；业务异常在 Web 日志里降为 WARN，未预期异常仍为 ERROR；商品 Feign 购买契约显式声明 consumes=application/json；购买锁等待调整到 20 秒，库存不足日志降为 INFO。经验总结多态缓存方便，但必须显式圈定可反序列化类型。把少量 JDK 类型做成配置化白名单，既保留业务扩展空间，也不把 Redis 当成可信输入。</li>
  <li>引入 Redisson 后，最容易出现的配置漂移是：Spring Cache 连接一套 Redis，Redisson 又维护一套地址、密码和超时。环境一多，两边不同步的风险就会变成线上问题。这次提交使用 Redisson Core，并在 Provider 中手动装配客户端：singleServer.setAddress("redis://" + redisProperties.getHost() + ":" + redisProperties.getPort())        .setDatabase(redisProperties.getDatabase())        .setConnectTimeout((int) redisProperties.getConnectTimeout().toMillis())        .setTimeout((int) redisProperties.getTimeout().toMillis());  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/config/RedissonConfiguration.java连接字段来自：spring:  data:    redis:      host: ...      port: 6379      password: ...      timeout: 2s      connect-timeout: 2s  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/config/application-redis.yaml这样 dev/prod 只维护一份 Redis 地址。用户名和密码为空时不传给 Redisson，避免把空字符串当成有效凭据。客户端还有两个细节：config.setLazyInitialization(true);config.setLockWatchdogTimeout(30_000);  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/config/RedissonConfiguration.java懒初始化让应用启动时不因为 Redis 暂不可用而提前失败，第一条命令执行时才建立连接；看门狗基础值设为 30 秒，后续使用不带 leaseTime 的 lock() 或 tryLock(...) 时会自动续期。显式传入 leaseTime 则不同，锁到期后自动释放，不再由看门狗续期。Redisson 对象名不会经过 Spring Cache 的 key 前缀处理，所以业务代码必须自己约定命名：zjc:provider:seckill:lock:1001zjc:provider:seckill:delay-queuezjc:provider:ratelimit:user:10001  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/README.md经验总结Redisson 提供的是分布式协调能力，连接来源仍应该服从应用配置。复用 spring.data.redis，再显式约束对象名和看门狗语义，可以减少环境配置和运行行为的不确定性。</li>
  <li>浏览器打开任何 HTML 页面时，都会自动请求一次 /favicon.ico。如果网关没有提供这个文件，请求会走到ErrorWebExceptionHandler，并产生一条 404 WARN 日志。这个 404 不是接口问题，也不是攻击信号，但每次打开页面都会重复出现。这次提交没有把 favicon 伪装成成功响应，而是在异常处理器里加了一个很窄的判断：if (status.is5xxServerError()) {    log.error("网关请求处理失败：{} {}", exchange.getRequest().getMethod(), path, ex);} else if (!isBrowserFaviconNotFound(path, status)) {    log.warn("网关请求处理失败：{} {}，状态码：{}，原因：{}",            exchange.getRequest().getMethod(), path, status.value(), ex.getMessage());}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/java/com/zjc/gateway/exception/GatewayErrorWebExceptionHandler.java判断条件同时要求两件事：status.value() == 404 &amp;&amp; FAVICON_PATH.equals(path)  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/java/com/zjc/gateway/exception/GatewayErrorWebExceptionHandler.java也就是说，只有精确的 /favicon.ico 且状态码为 404 时才跳过日志。其他 404 仍然照常记录，避免把真实路由问题、扫描请求和配置错误一起吞掉。测试也没有只看响应码，而是用 Logback 的 ListAppender 捕获这个 Handler 的日志事件，断言 favicon 404 处理后日志列表为空。这个测试点很重要：日志降级和响应降级是两件事，响应仍保持 404，只是不再制造无意义告警。经验总结网关日志的价值取决于信噪比。favicon 404 属于浏览器行为的已知噪音，可以在异常处理器里精确过滤；但不要顺手扩大成“所有 4xx 都不记”，否则真正需要排查的入口错误也会消失。</li>
  <li>这篇笔记记录在一台全新的 Windows 机器上搭建本博客本地运行环境的过程。目标很明确：进入项目目录后，能够用 Bundler 启动Jekyll，并通过 http://127.0.0.1:4000/ 访问站点。最终环境如下：            组件      版本      位置                  Ruby      3.3.12      C:\Ruby33-x64              Bundler      2.5.22      Ruby 自带              MSYS2      2025-12-13      C:\msys64              项目依赖      37 个 gems      vendor/bundle      一、为什么不能只装一个 Ruby仓库的 Gemfile 有两个关键约束：ruby '&gt;= 3.3.0', '&lt; 3.4'gem 'jekyll', '4.4.1'gem 'fastimage', '2.3.1'也就是说，Ruby 必须选 3.3.x，不能顺手装成 3.4。这个博客的 Gemfile.lock 也已经锁定了依赖版本，本地安装时应该让Bundler 按锁文件还原，而不是各自解析一套新版本。Windows 上还有一个额外问题：bigdecimal、eventmachine、http_parser.rb 这类依赖包含原生扩展。只安装 Ruby运行时，缺少编译工具链时，bundle install 会在安装原生扩展阶段失败，并提示：MSYS2 could not be found. Please run 'ridk install'所以完整环境需要三部分：  Ruby 3.3  MSYS2/MinGW 编译工具链  Bundler 和项目本地依赖二、安装 Ruby 3.3用 winget 安装 RubyInstaller 提供的 Ruby 3.3：winget install --id RubyInstallerTeam.Ruby.3.3 --exact --silent安装完成后，重新打开一个 PowerShell，确认版本：ruby -vgem -vbundle -v正常输出应类似：ruby 3.3.12 ...3.5.22Bundler version 2.5.22如果是在安装前已经打开的旧终端里执行，进程还没有拿到新的系统 PATH，会找不到 ruby 或 bundle。重新打开终端最省事；临时处理也可以执行：$env:Path = "C:\Ruby33-x64\bin;$env:Path"三、安装 MSYS2 编译工具链执行：ridk install出现组件选择提示时，直接回车接受默认的 [1,3]：1 - MSYS2 base installation3 - MSYS2 and MINGW development toolchain安装完成后检查：ridk versionridk exec gcc --versionridk exec make --version能看到 Ruby 路径、MSYS2 路径、gcc 和 make 版本，就说明编译环境可用。我这次遇到的问题是 ridk install 默认从 repo.msys2.org 下载 MSYS2，速度非常慢。可以把下载源换成一个可用的MSYS2 镜像，例如清华镜像的安装包目录：https://mirrors.tuna.tsinghua.edu.cn/msys2/distrib/下载对应架构的安装包后，也可以静默安装到默认目录：$installer = "$env:TEMP\msys2-x86_64-20251213.exe"&amp; $installer install --root C:\msys64 --accept-messages --accept-licenses --confirm-command再补齐 Ruby 3.3 对应的 UCRT64 编译包：C:\msys64\usr\bin\pacman.exe -Sy --needed --noconfirm make mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkgconf最后执行 ridk version，只要它能识别 C:\msys64，RubyInstaller 和 MSYS2 就已经接上了。四、把依赖装进项目本地进入博客仓库：cd D:\springvortex.github.io先把 Bundler 的安装路径配置成项目本地：bundle config set --local path vendor/bundle这会生成 .bundle/config。项目 .gitignore 已经忽略 .bundle/ 和 vendor/，不会把几十 MB 的依赖提交进仓库。普通依赖可以直接安装：bundle install如果这次安装涉及原生扩展，或者后面新增了需要编译的 gem，建议在 Windows 上固定使用：ridk exec bundle installridk exec 的作用是在 MSYS2 环境里执行命令，保证 RubyGems 能找到编译器和 make。安装完成后做一次确认：bundle checkbundle exec jekyll -v输出包含下面两句话就说明依赖已经完整：The Gemfile's dependencies are satisfiedjekyll 4.4.1五、构建与启动先跑一次生产构建：bundle exec jekyll buildJekyll 输出 done in ... 且没有报错，说明 Liquid、插件、文章和页面都能正常生成。日常开发启动：bundle exec jekyll serve --watch默认地址是：http://127.0.0.1:4000/需要让局域网其他设备访问，或者想固定端口：bundle exec jekyll serve --watch --host=0.0.0.0 --port=8080停止服务时，在运行 Jekyll 的终端里按 Ctrl + C。仓库里的 blog.sh 也封装了两个常用命令，适合在 Git Bash 里执行：./blog.sh run      # 4000 端口启动./blog.sh run 8080 # 指定 8080 端口./blog.sh build    # 构建到 dist 并压缩 JS/CSS六、启动后怎么验证不要只看终端里出现 Server running，再请求几个关键页面更稳：Invoke-WebRequest http://127.0.0.1:4000/Invoke-WebRequest http://127.0.0.1:4000/pages/categories.htmlInvoke-WebRequest http://127.0.0.1:4000/pages/links.html三个请求都返回 StatusCode: 200，说明首页、分类页和书签页都能被本地服务正常渲染。也可以检查生成产物：Get-Item _site\index.htmlGet-Item _site\pages\categories.htmlGet-Item _site\pages\links.html七、常见问题1. bundle 不是内部或外部命令多半是终端是在安装 Ruby 前打开的。重新打开 PowerShell；如果仍不行，确认下面路径存在：C:\Ruby33-x64\bin再检查用户 PATH 中是否包含它。2. MSYS2 could not be found说明只装了 Ruby 运行时，没有装 MSYS2，或 MSYS2 不在 C:\msys64 这类默认位置。执行：ridk install完成后再用 ridk version 检查。3. 原生扩展编译失败先确认编译器：ridk exec gcc --versionridk exec make --version如果这两个命令不正常，重新执行 ridk install，确认选择了 MSYS2 和 MINGW development toolchain。4. 端口被占用4000 端口被占用时，Jekyll 启动会报地址已被监听。直接换端口：bundle exec jekyll serve --watch --port=4001或者先查占用进程：Get-NetTCPConnection -LocalPort 40005. 依赖被装到全局如果之前忘记配置 path，gems 会进入 Ruby 全局目录。项目内执行下面命令可以改回本地安装：bundle config set --local path vendor/bundlebundle install八、日常只需要记住两条依赖没有变化时，日常启动只需要：cd D:\springvortex.github.iobundle exec jekyll serve --watch依赖变化时，先执行：ridk exec bundle install环境搭好之后，Windows 上跑 Jekyll 并不复杂；真正容易浪费时间的是 Ruby 版本、MSYS2 编译链和镜像源这三件事。</li>
  <li>service-provider 是这套微服务的业务核心，负责用户、商品、订单和连通性测试接口。它注册到 Nacos，对外提供/api/v1/provider/** API，既可以直接被 Gateway 转发，也可以被 Consumer 通过 Feign 调用。这个模块的价值不只是 CRUD，而是把几个工程约定跑通了：Entity 与 DTO 分离、MyBatis-Plus通用数据访问、订单主表明细同事务、逻辑删除、审计时间自动填充、分页参数校验和统一响应。基本信息            项      值                  服务名      service-provider              端口      9001              API 前缀      /api/v1/provider              服务发现      Nacos Discovery              数据访问      MyBatis-Plus + MySQL              接口文档      /swagger-ui.html，仅 dev 开启      启动类很薄，只声明应用入口、服务发现和 Mapper 扫描：@SpringBootApplication@EnableDiscoveryClient@MapperScan("com.zjc.provider.mapper")public class ProviderApplication {    public static void main(String[] args) {        SpringApplication.run(ProviderApplication.class, args);    }}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/ProviderApplication.java包结构com.zjc.provider├── ProviderApplication├── config│   ├── AuditMetaObjectHandler│   ├── MybatisPlusConfig│   └── OpenApiConfig├── controller│   ├── GoodsController│   ├── OrderController│   ├── TestController│   └── UserController├── converter├── entity├── mapper└── service    └── impl分层职责很传统，但边界清楚：  Controller 只处理 HTTP 契约和参数校验。  Service 处理业务规则和事务。  Mapper 继承 MyBatis-Plus 通用能力。  Converter 负责 Entity 与 DTO 转换。  Entity 只代表数据库表，不对外暴露。API 面Controller 中编写的资源路径如下，运行时会自动追加 /api/v1/provider：            模块      路径                  用户      /user、/user/{id}、/user/list、/user/page              商品      /goods、/goods/{id}、/goods/list、/goods/page              订单      /order、/order/{id}、/order/list、/order/page              连通性      /port      分页接口约定：current 从 1 开始size 范围 1-100更新和删除接口根据实际影响行数判断结果。记录不存在时返回业务失败 code=102，HTTP 状态仍保持统一响应封装的 200。Entity 不出模块以用户接口为例，Controller 返回的是 UserDTO，不是 User 实体：@GetMapping("/user/{id}")public ApiResponse&lt;UserDTO&gt; getUser(@PathVariable("id") Long id) {    return ApiResponse.success(            userConverter.entityToDto(userService.getById(id)));}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/controller/UserController.java这样做的理由：  数据库结构变化不等于接口契约变化。  逻辑删除、审计字段等内部字段不被迫暴露。  DTO 可以加 Swagger 描述和校验注解。  Feign 调用方只依赖 common 里的 DTO，不依赖 Provider 的实体层。MyBatis-Plus 分页Provider 配置了 MySQL 分页拦截器：@Beanpublic MybatisPlusInterceptor mybatisPlusInterceptor() {    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();    interceptor.addInnerInterceptor(            new PaginationInnerInterceptor(DbType.MYSQL));    return interceptor;}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/config/MybatisPlusConfig.java没有这个拦截器，userService.page(...) 只是普通查询，不会自动追加 LIMIT。项目同时引入 mybatis-plus-jsqlparser，因为新版分页拦截器需要 JSqlParser 改写 SQL。审计时间自动填充实体字段使用 @TableField(fill = FieldFill.INSERT) 或 INSERT_UPDATE，然后由 AuditMetaObjectHandler 统一填充：public void insertFill(MetaObject metaObject) {    this.strictInsertFill(metaObject, "createTime",            LocalDateTime.class, LocalDateTime.now(ZONE));    this.strictInsertFill(metaObject, "updateTime",            LocalDateTime.class, LocalDateTime.now(ZONE));}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/config/AuditMetaObjectHandler.java新增时填充 createTime 和 updateTime，更新时只刷新 updateTime。业务代码不手动 set 时间，避免漏填和时区不一致。逻辑删除数据访问配置里统一声明：mybatis-plus:  global-config:    db-config:      id-type: assign_id      logic-delete-field: isDeleted  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/application-prod.yaml这样普通查询会过滤已删除数据，removeById 会把删除标记改为已删除。注意：逻辑删除不是审计日志，重要业务还需要保留操作人和操作原因，这两个字段当前模型没有覆盖。订单主表与明细同事务订单是 Provider 里最有业务含量的部分。新增订单时先保存主表，再批量保存明细：@Transactional(rollbackFor = Exception.class)public void saveWithDetails(Order order, List&lt;OrderDetail&gt; details) {    if (!save(order)) {        throw new BusinessException(ApiResponseEnum.INTERNAL_ERROR);    }    details.forEach(detail -&gt; {        detail.setId(null);        detail.setOrderId(order.getOrderId());        detail.setOrderNo(order.getOrderNo());    });    if (!details.isEmpty() &amp;&amp; !orderDetailService.saveBatch(details)) {        throw new BusinessException(ApiResponseEnum.INTERNAL_ERROR);    }}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/service/impl/OrderServiceImpl.java删除订单也使用同一个事务方法，先删主表，再按 orderId 删除明细。这样订单不会出现“主表还在、明细没了”或“主表没了、明细悬空”的中间状态。订单详情聚合查询单个订单时，Controller 先查主表，再按订单 ID 查明细，最后组装 DTO：OrderDTO dto = orderConverter.entityToDto(order);List&lt;OrderDetail&gt; details = orderDetailService.list(        new LambdaQueryWrapper&lt;OrderDetail&gt;()                .eq(OrderDetail::getOrderId, id));dto.setOrderDetails(detailDTOs);  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/controller/OrderController.java这个实现保持简单清晰，适合当前规模。如果订单明细很多或列表也需要聚合，可以考虑一次 JOIN 查询、批量查询或专门的查询对象，避免N+1 查询。连通性测试/port 返回当前 Provider 实例端口。它不是为了业务，而是为了验证链路：Gateway -&gt; ProviderConsumer -&gt; Feign -&gt; Provider启动多个 Provider 实例后连续调用这个接口，如果返回端口来回变化，说明 LoadBalancer 在多个实例间分流。配置组织基础配置放在 application.yaml：spring:  application:    name: service-provider  profiles:    active: dev    include:      - api      - jasypt      - zipkin      - nacos  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/application.yaml环境差异放在：            文件      职责                  application-dev.yaml      共享开发环境 MySQL、Nacos、Zipkin、Swagger              application-prod.yaml      本机 MySQL、Nacos、Zipkin，关闭文档              config/application-api.yaml      API 前缀和版本              config/application-jasypt.yaml      加密算法参数              config/application-nacos.yaml      Nacos 公共认证              config/application-zipkin.yaml      链路追踪公共配置      生产环境只连 127.0.0.1 的 MySQL、Nacos、Zipkin，端口不对外暴露。链路追踪Provider 引入 Actuator 和 Zipkin，日志里有 traceId、spanId。通过 W3C traceparent 传播，同一次请求可以在 Zipkin 中串起来：Gateway -&gt; Consumer -&gt; ProviderGateway -&gt; ProviderGateway -&gt; Mail生产配置采样率是 0.1，开发配置是 1.0。排查问题时先用 traceId 聚合日志，再去 Zipkin 看调用树和耗时分布。运行前提启动 Provider 前确认：  JDK 21。  MySQL 可访问，spring_cloud_alibaba 库和表已初始化。  Nacos 可访问。  Zipkin 可访问。  Jasypt 密钥已通过启动参数或环境变量注入。生产部署时 Provider 的 9001 端口只允许内网访问，外部请求统一走 Gateway。模块小结service-provider 是整套约定最完整的落地点：公共响应、全局异常、接口日志、DTO 隔离、MyBatis-Plus、事务、逻辑删除、审计填充、Nacos注册、Zipkin 链路都在这里真实运行。读懂这个模块，再去看 Consumer 和 Mail，基本就是看不同职责如何复用同一套底座。</li>
  <li>service-mail 是独立邮件微服务，负责发送纯文本和 HTML 邮件，并把每次发送过程写入 t_mail_log。其他服务不需要自己配置SMTP，也不需要知道发件账号；只要调用 MailFeignApi 或通过 Gateway 请求 /api/v1/mail/send，邮件服务统一处理。这个模块最有价值的设计不是“能发邮件”，而是发送过程可追踪：先写入待发送记录，再执行 SMTP，最后更新成功或失败状态。SMTP抖动时，调用方能拿到一条状态明确的记录，而不是只收到一个无法排查的异常。基本信息            项      值                  服务名      service-mail              端口      9004              API 前缀      /api/v1/mail              数据表      t_mail_log              SMTP      dev 使用 MailHog，prod 使用真实 SMTP              服务发现      Nacos Discovery      包结构com.zjc.mail├── MailApplication├── config│   ├── AuditMetaObjectHandler│   ├── MybatisPlusConfig│   └── OpenApiConfig├── controller│   └── MailController├── converter│   └── MailLogConverter├── entity│   └── MailLog├── mapper│   └── MailLogMapper└── service    ├── MailLogService    ├── MailSendService    └── impl分层和 Provider 一致：Controller 处理 HTTP，Service 处理发送流程，Mapper 负责记录，MapStruct 负责 Entity 和 DTO 转换。对外接口Controller 只有一个接口：@PostMapping("/send")public ApiResponse&lt;MailLogDTO&gt; send(@Valid @RequestBody MailSendDTO dto) {    return ApiResponse.success(mailSendService.send(dto));}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-mail/src/main/java/com/zjc/mail/controller/MailController.java请求示例：{  "toEmails": "a@example.com,b@example.com",  "ccEmails": "c@example.com",  "bccEmails": "d@example.com",  "subject": "测试邮件",  "content": "&lt;p&gt;Hello Service Mail&lt;/p&gt;",  "isHtml": true}toEmails、subject、content 必填；抄送和密送可选。发件人来自环境配置里的 spring.mail.username，调用方不能指定，避免接口被拿来伪装任意发件人。状态模型邮件记录有三个状态：            状态      含义                  0      待发送              1      发送成功              2      发送失败      这个状态模型让接口语义变得稳定：HTTP 调用成功只表示邮件服务接到了请求并完成流程处理，实际 SMTP 结果要看返回的data.status。发送流程MailSendServiceImpl 的主流程：String[] toArray = parseAndValidate(dto.getToEmails(), "收件人");String[] ccArray = parseAndValidate(dto.getCcEmails(), "抄送人");String[] bccArray = parseAndValidate(dto.getBccEmails(), "密送人");MailLog mailLog = buildMailLog(dto);mailLog.setStatus(STATUS_PENDING);mailLogService.save(mailLog);try {    if (Boolean.TRUE.equals(dto.getIsHtml())) {        sendHtmlMail(dto, toArray, ccArray, bccArray);    } else {        sendTextMail(dto, toArray, ccArray, bccArray);    }    mailLog.setStatus(STATUS_SUCCESS);} catch (Exception e) {    mailLog.setStatus(STATUS_FAILURE);    mailLog.setErrorMsg(e.getMessage());}mailLogService.updateById(mailLog);return mailLogConverter.entityToDto(mailLog);  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-mail/src/main/java/com/zjc/mail/service/impl/MailSendServiceImpl.java流程拆开看：  校验收件人、抄送人、密送人格式。  写入一条 status=0 的待发送记录。  根据 isHtml 选择发送方式。  成功更新为 status=1。  失败更新为 status=2，并保存 errorMsg。  把实体转换为 DTO 返回。邮箱校验发送前先解析逗号分隔的邮箱字符串：private static final Pattern EMAIL_PATTERN =        Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$");这不是完整 RFC 邮箱校验，但足够拦截常见错误：空格、缺少 @、缺少域名点号等。非法地址在发送前失败，不会写入待发送记录，也不会浪费SMTP 调用。如果业务要求更严，可以在 DTO 上继续叠加长度限制、域名白名单或邮箱可达性策略。纯文本与 HTML纯文本使用 SimpleMailMessage：SimpleMailMessage message = new SimpleMailMessage();message.setFrom(fromEmail);message.setTo(to);message.setSubject(dto.getSubject());message.setText(dto.getContent());HTML 使用 MimeMessageHelper：MimeMessage mimeMessage = mailSender.createMimeMessage();MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true);helper.setFrom(fromEmail);helper.setTo(to);helper.setSubject(dto.getSubject());helper.setText(dto.getContent(), true);两条路径分开，避免把用户输入当 HTML 渲染。调用方必须显式传 isHtml=true，服务端才按 MIME HTML 处理。邮件记录实体MailLog 映射 t_mail_log，核心字段：            字段      说明                  mailId      雪花 ID              fromEmail      发件人              toEmails      收件人，多个逗号分隔              ccEmails / bccEmails      抄送、密送              subject / content      主题和正文              isHtml      是否 HTML              status      0 待发送，1 成功，2 失败              errorMsg      失败原因              isDeleted      逻辑删除              createTime / updateTime      审计时间        https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-mail/src/main/java/com/zjc/mail/entity/MailLog.java实体只在 Mail 模块内部使用，接口返回 MailLogDTO，避免把逻辑删除等数据库细节暴露给调用方。配置边界基础配置：spring:  application:    name: service-mail  profiles:    active: dev    include:      - api      - jasypt      - zipkin      - nacos  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-mail/src/main/resources/application.yaml开发环境 SMTP 可以指向 MailHog：spring:  mail:    host: 127.0.0.1    port: 1025    username: dev@localhost    password: ""    default-encoding: UTF-8生产环境使用真实 SMTP，密码通过 Jasypt 密文保存：spring:  mail:    host: your-smtp-host    port: 465    username: your-from-email    password: ENC(your-encrypted-password)    properties:      mail:        smtp:          auth: true          ssl:            enable: true  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-mail/src/main/resources/application-prod.yaml启动时注入 JASYPT_ENCRYPTOR_PASSWORD，密钥不写进 Git。MailHog 的价值开发环境用 MailHog 假装 SMTP 服务器，好处很明显：  不会把测试邮件发给真实用户。  可以在 Web 页面查看邮件最终内容。  不需要真实邮箱账号即可调试邮件链路。  便于验证 HTML 渲染、主题编码和收发关系。MailHog 只适合开发或内网测试。它没有生产级安全边界，不能暴露公网，更不能当生产邮件服务。Feign 调用方式其他服务可以通过 common 里的共享契约调用：@FeignClient(value = "service-mail", contextId = "mailFeignApi")public interface MailFeignApi {    @PostMapping("/send")    ApiResponse&lt;MailLogDTO&gt; send(@Valid @RequestBody MailSendDTO dto);}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/api/mail/MailFeignApi.java契约仍然只写资源路径 /send，运行时会自动补 /api/v1/mail 前缀。链路追踪邮件服务引入 Actuator 和 Zipkin。一次邮件请求可以形成：Client -&gt; Gateway -&gt; service-mailConsumer -&gt; MailFeignApi -&gt; service-mail如果 SMTP 慢，Zipkin 能看到 Mail 服务内部耗时；如果 SMTP 失败，日志和 t_mail_log.error_msg 都能提供线索。设计取舍当前实现把 SMTP 异常吞掉并记录到数据库，调用方拿到的是携带状态的记录。这降低了调用方复杂度，适合通知类邮件。如果希望“请求提交就一定要感知失败”，可以继续演进为队列模式：  接口只受理请求并写入待发送记录。  后台任务或消息消费者执行发送。  失败进入重试队列。  提供按 mailId 查询状态的接口。  增加告警和死信处理。当前单服务同步发送适合学习项目和小规模通知场景，结构简单，问题也容易定位。常见问题返回成功但邮件没收到先看 data.status。status=2 表示 SMTP 发送失败，errorMsg 是第一排查线索。开发环境收不到邮件确认 MailHog 是否启动、端口是否是 1025，并到 http://127.0.0.1:8025 查看收件箱。生产 SMTP 认证失败检查 host、port、username、密码密文和 Jasypt 密钥。465 通常走 SSL，587 通常走 STARTTLS，不要把两种配置混着写。HTML 显示异常确认请求里 isHtml=true，内容使用合法 HTML，并保留 default-encoding: UTF-8。模块小结service-mail 把外部资源访问收敛到一个服务里：SMTP 配置、发件人、发送记录、失败原因和链路追踪都由邮件模块自己负责。对调用方来说，邮件能力变成一个清晰的内部API；对运维来说，所有发送行为都有一张表可以审计。</li>
  <li>service-gateway 是整套微服务的流量入口，基于 Spring Cloud Gateway 和 WebFlux。它不实现业务接口，也不访问数据库；它的职责是把外部请求路由到Provider、Consumer、Mail，并在入口处做限流、熔断、日志、追踪和统一错误响应。这个模块最重要的工程判断有两个：第一，Gateway 不依赖 service-common，避免 WebFlux 应用被带入 Spring MVC 体系；第二，网关只转发完整API 前缀，不做 RewritePath。基本信息            项      值                  服务名      service-gateway              默认端口      80              编程模型      WebFlux              服务发现      Nacos Discovery              负载均衡      LoadBalancer + Caffeine              防护      Sentinel 接口限流、单 IP 限流、路由熔断      默认端口是 80，但 Linux 上普通用户不能绑定 1024 以下端口。生产部署建议改成 9000，或由 Nginx、云负载均衡统一收口。路由模型生产路由配置：spring:  cloud:    gateway:      server:        webflux:          routes:            - id: provider-route              uri: lb://service-provider              predicates:                - Path=/api/*/provider/**              order: 1            - id: consumer-route              uri: lb://service-consumer              predicates:                - Path=/api/*/consumer/**              order: 2            - id: mail-route              uri: lb://service-mail              predicates:                - Path=/api/*/mail/**              order: 3  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/resources/application-prod.yaml路径里的 * 表示版本号：/api/v1/provider/user/1 -&gt; service-provider/api/v1/consumer/user/1 -&gt; service-consumer/api/v1/mail/send       -&gt; service-mail为什么不做 RewritePath下游服务已经通过 service-common 接收完整前缀：Provider: /api/v1/provider/**Consumer: /api/v1/consumer/**Mail:     /api/v1/mail/**所以 Gateway 按模块匹配后原样转发即可。如果在网关剥离前缀，下游再自动补前缀，路径语义会绕一圈，排查时也看不出原始路径和目标路径的对应关系。保留完整前缀还有一个好处：后续 /api/v2/provider/** 只需要在下游启用 v2，Gateway 路由模式不用跟着改。包结构com.zjc.gateway├── GatewayApplication├── config│   ├── ObservabilityConfiguration│   ├── SentinelGatewayConfiguration│   ├── SentinelGatewayProperties│   └── SentinelGatewayCircuitBreakerFilterFactory├── controller│   └── GatewayFallbackController├── exception│   ├── GatewayErrorWebExceptionHandler│   └── SentinelGatewayBlockRequestHandler└── filter    └── ServiceGlobalFilter每一层职责都很明确：  启动类只负责 Gateway 应用和 Nacos 发现。  filter 记录请求开始、结束和耗时。  config/Sentinel* 装配限流和熔断。  exception 输出 WebFlux 版统一 JSON 错误。  controller 提供熔断后的内部兜底响应。全局请求日志过滤器ServiceGlobalFilter 实现 GlobalFilter 和 Ordered：log.info("开始请求{} {}", method, uri);return chain.filter(exchange)        .doFinally(signalType -&gt; {            long endTime = System.currentTimeMillis();            log.info("结束请求 {} {}，耗时：{}ms，信号：{}",                    method, uri, endTime - startTime, signalType);        });  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/java/com/zjc/gateway/filter/ServiceGlobalFilter.java这里用 doFinally，不用 doOnSuccess。WebFlux 请求可能正常结束、异常结束，也可能被取消；doFinally三种情况都会记录，网关日志不会在异常场景缺一条结束记录。过滤器 order 是 0，后续如果加鉴权、签名、灰度或租户过滤器，可以用 order 控制执行顺序。Reactor 上下文传播Gateway 是异步链路，回调线程可能和发起线程不同。项目专门加了这个配置：@PostConstructpublic void enableReactorContextPropagation() {    Hooks.enableAutomaticContextPropagation();}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/java/com/zjc/gateway/config/ObservabilityConfiguration.java开启后，Micrometer Tracing 才能把 Reactor Context 中的 trace 上下文恢复到回调线程的 MDC。没有它，开始日志和结束日志可能拿不到稳定的traceId、spanId。Sentinel 接口限流Sentinel 公共配置定义了两类接口规则：            规则      路径模式      全局 QPS      单 IP QPS                  provider-user-detail      /api/[^/]+/provider/user/\d+      100      10              mail-send      /api/[^/]+/mail/send      20      2      zjc:  gateway:    sentinel:      interfaces:        - name: provider-user-detail          pattern: /api/[^/]+/provider/user/\d+          total-qps: 100          per-ip-qps: 10          interval-sec: 1  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/resources/config/application-sentinel.yamlSentinelGatewayConfiguration 会把每条规则转换成两条 Gateway Flow Rule：一条接口全局 QPS，一条按 CLIENT_IP 参数限流。  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/java/com/zjc/gateway/config/SentinelGatewayConfiguration.java被限流时由 SentinelGatewayBlockRequestHandler 返回 HTTP 429 和统一 JSON 结构。可信代理与真实 IP单 IP 限流最怕伪造 X-Forwarded-For。这个项目的策略是：  先拿 TCP 直连地址。  只有直连地址在 trusted-proxies 里，才采信 X-Forwarded-For 的第一个 IP。  否则使用直连地址。默认可信代理只有：127.0.0.1::10:0:0:0:0:0:0:1如果生产环境前面有 Nginx、云负载均衡或 WAF，必须把它们的内网直连地址加入 trusted-proxies。否则看到的一直是代理IP；反过来，也不能把不可信客户端放进白名单。路由熔断Gateway 通过默认过滤器挂载 CircuitBreaker：default-filters:  - name: CircuitBreaker    args:      statusCodes:        - INTERNAL_SERVER_ERROR        - BAD_GATEWAY        - SERVICE_UNAVAILABLE        - GATEWAY_TIMEOUT熔断阈值：            配置      含义                  min-request-amount      最小统计请求数              exception-ratio      异常比例阈值              slow-request-rt-threshold-ms      慢请求 RT 阈值              slow-request-ratio      慢请求比例阈值              stat-interval-ms      统计窗口              recovery-seconds      半开放恢复等待      触发熔断后请求进入 /gateway/fallback/{route}，返回 HTTP 503：{  "success": false,  "code": 503,  "message": "下游服务暂不可用，请稍后再试",  "data": null,  "timestamp": 1787191865837}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/java/com/zjc/gateway/controller/GatewayFallbackController.java统一错误响应Gateway 不能复用 service-common 里的 @RestControllerAdvice，因为那是 Spring MVC 的处理机制。这里实现了 WebFlux 的ErrorWebExceptionHandler：public class GatewayErrorWebExceptionHandler        implements ErrorWebExceptionHandler, Ordered {}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/java/com/zjc/gateway/exception/GatewayErrorWebExceptionHandler.java状态映射：            HTTP      业务码      提示                  400      101      请求体格式错误              401      401      未认证              403      403      无权限              404      102      请求路径不存在              500      500      服务内部错误              503      503      服务不可用      这样前端在网关错误和业务错误上看到的是同一种响应结构。注意它不会拦截已经成功转发的业务响应；下游返回什么，Gateway 原样透传。CORS 边界CORS 只在 dev Profile 开启，生产默认不配置。开发环境示例：globalcors:  cors-configurations:    '[/**]':      allowed-origin-patterns:        - '*'      allowed-methods:        - '*'      allowed-headers:        - '*'      allow-credentials: false      max-age: 3600开发环境可以宽松；生产如果前端独立域名，应配置明确来源。allow-credentials: true 时不能继续使用 *。链路追踪生产 Zipkin endpoint：http://127.0.0.1:9411/api/v2/spans采样率 0.1。Gateway 传播 W3C traceparent，下游继续同一条链路：Client -&gt; Gateway -&gt; ProviderClient -&gt; Gateway -&gt; Consumer -&gt; ProviderClient -&gt; Gateway -&gt; Mail排查入口请求时，先拿 Gateway 日志里的 traceId，再去 Zipkin 查整棵调用树。端口暴露策略生产只暴露 Gateway：            服务      端口      公网                  Gateway      80 / 443 / 9000      允许              Provider      9001      禁止              Consumer      9002      禁止              Mail      9004      禁止              Nacos      8848、9848、9849、7848      禁止              MySQL      3306      禁止              Zipkin      9411      禁止      业务服务的 Swagger UI 也只适合内网调试，不要绕过 Gateway 直接暴露。模块小结service-gateway 是整个系统的安全边界和流量边界。它把“入口该做的事情”集中处理：路由、负载均衡、限流、熔断、日志、追踪、统一错误和端口收敛。同时它刻意保持轻量，不写业务逻辑，也不引入公共MVC 模块，这让 WebFlux 网关和 MVC 业务服务的边界保持干净。</li>
  <li>service-consumer 是这套微服务里的调用方示例。它自己没有数据库，也不承载核心业务数据，而是通过 OpenFeign 调用service-provider，再对外暴露 /api/v1/consumer/** 接口。这个模块把三件事讲清楚：声明式调用怎么写、共享契约怎么复用、下游失败时怎么降级。基本信息            项      值                  服务名      service-consumer              端口      9002              API 前缀      /api/v1/consumer              数据库      无              远程调用      OpenFeign + LoadBalancer              服务发现      Nacos Discovery      Consumer 的代码量很小，这是它最有代表性的地方：当契约被抽到 service-common 后，消费者不需要重复定义一堆 Feign 接口和 DTO。启动类设计@SpringBootApplication@EnableDiscoveryClient@EnableFeignClients(basePackages = {"com.zjc.common.api"})public class ConsumerApplication {    public static void main(String[] args) {        SpringApplication.run(ConsumerApplication.class, args);    }}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-consumer/src/main/java/com/zjc/consumer/ConsumerApplication.java三个注解分别解决三件事：  @SpringBootApplication：启动 Spring Boot 应用。  @EnableDiscoveryClient：注册到 Nacos，并具备服务发现能力。  @EnableFeignClients(basePackages = {"com.zjc.common.api"})：扫描公共模块里的 Feign 契约。这里的重点是指定扫描包。如果只写 @EnableFeignClients，默认扫描 Consumer 自己的包，公共模块里的 UserFeignApi、TestApi、MailFeignApi 不会被创建成 Bean。包结构com.zjc.consumer├── ConsumerApplication├── config│   └── OpenApiConfig├── controller│   ├── UserConsumerController│   └── TessFeignController└── service    ├── FeignService    └── impl        └── FeignServiceImplController 不直接封装 HTTP 请求，也不手写 RestTemplate；它注入 Feign 接口，让 OpenFeign 根据 Nacos 服务列表和注解声明生成代理调用。用户消费接口用户接口直接复用 common 中的 UserFeignApi：@GetMapping("/user/{id}")public ApiResponse&lt;UserDTO&gt; getUser(@PathVariable("id") Long id) {    return userFeignApi.getUser(id);}@GetMapping("/user/list")public ApiResponse&lt;List&lt;UserDTO&gt;&gt; list() {    return userFeignApi.list();}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-consumer/src/main/java/com/zjc/consumer/controller/UserConsumerController.javaFeign 契约里只写资源路径 /user/{id}，真正发送请求前，common 的 Feign 拦截器会补上目标服务前缀，最终请求：GET http://service-provider/api/v1/provider/user/1服务名到 IP 的解析交给 Nacos，实例选择交给 LoadBalancer。连通性测试接口/feign/port 是一个很实用的冒烟测试接口：@GetMapping("/feign/port")public ApiResponse&lt;String&gt; getServerPort() {    return feignService.getServerPort();}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-consumer/src/main/java/com/zjc/consumer/controller/TessFeignController.java实现层委托给 TestApi：@Overridepublic ApiResponse&lt;String&gt; getServerPort() {    return testApi.getServerPort();}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-consumer/src/main/java/com/zjc/consumer/service/impl/FeignServiceImpl.java如果 Provider 启动了多个实例，连续调用这个接口看到端口变化，就说明 Nacos 发现和 LoadBalancer 分流都正常。降级策略UserFeignApi 的降级在 common 里实现：            接口      降级结果                  查询单个用户      success=true，data=null              查询用户列表      success=true，data=[]      这层设计让上层 Controller 不需要写 try-catch。Provider 宕机、网络超时或调用异常时，UserFeignFallbackFactory会记录失败原因，然后返回兜底数据。需要注意的是，这种策略适合演示和可用性优先场景。对强一致性业务，比如扣库存、支付、下单，不能一律“成功返回空数据”；该失败的调用要明确失败，否则上游可能基于不完整数据继续决策。Feign 与 SentinelConsumer 引入 Sentinel，并在生产配置中开启：feign:  sentinel:    enabled: true  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-consumer/src/main/resources/application-prod.yaml这让 Feign 方法成为可统计资源，并让 fallback factory 生效。当前网关也有自己的 Sentinel 限流和熔断规则；Consumer 侧的 Feign熔断保护的是服务间调用，两层职责不同。超时配置生产环境 OpenFeign 超时配置：spring:  cloud:    openfeign:      client:        config:          default:            connect-timeout: 3000            read-timeout: 5000开发环境基线是连接 1000ms、读取 2000ms；生产更宽松一些，避免网络抖动造成过多降级。如果某个接口明显更慢，可以按 contextId 单独配置，例如 userFeignApi，避免为了一个慢接口放大所有 Feign 调用的超时。负载均衡与缓存Consumer 依赖：            依赖      作用                  spring-cloud-starter-loadbalancer      从服务实例列表选择实例              caffeine      缓存服务列表，减少 Nacos 压力      Feign 的 lb://service-provider 逻辑名会先由 LoadBalancer 解析成具体实例地址。Caffeine 缓存能避免每次请求都重新拉取实例列表。配置组织基础配置：spring:  application:    name: service-consumer  profiles:    active: dev    include:      - api      - jasypt      - zipkin      - nacos  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-consumer/src/main/resources/application.yamldev 和 prod 分别维护：  Nacos 地址。  Zipkin 地址。  Feign 超时。  Swagger 开关。  链路采样率。Nacos 只做服务发现，不用 Nacos Config。链路追踪一次用户查询会形成：Client  -&gt; service-consumer    -&gt; UserFeignApi      -&gt; service-providerConsumer 引入 Micrometer Tracing + Zipkin 后，Feign 请求会继续传播 W3C traceparent。日志里用 traceId 聚合，Zipkin 里能看到Consumer 发起调用和 Provider 处理请求的 span。常见问题注入 UserFeignApi 报 Bean 不存在检查启动类是否指定扫描 com.zjc.common.api。@EnableFeignClients(basePackages = {"com.zjc.common.api"}) 是这个模块的关键配置。调用返回 404Feign 契约写的是资源路径，实际需要自动补 /api/v1/provider 前缀。如果 common 的路径自动装配没有生效，检查业务服务是否引入service-common，以及 config/application-api.yaml 是否被 include。一直降级优先验证 Provider 可用，再检查 Nacos 服务列表、Feign 超时和网络安全组。UserFeignFallbackFactory 会记录 cause，日志是最直接的排查入口。列表为空但不知道是正常还是降级当前降级策略会把列表调用降级为空列表。排查时要看 Consumer 是否输出了降级日志，也可以通过 Zipkin 确认 Provider span 是否存在。模块小结service-consumer的代码少，但它演示了微服务间调用的关键工程约定：契约集中、声明式调用、路径自动补齐、负载均衡、超时控制、失败降级和链路传播。它是一个很好的“消费者模板”，新增业务消费者时可以按这个结构复制思路，而不是复制代码。</li>
  <li>service-common 是这套微服务的公共库，不是可独立运行的服务。它的目标很明确：把业务服务反复需要的响应结构、异常处理、接口日志、API路径约定、共享 DTO、共享 Feign 契约和配置加密能力集中到一个标准 JAR 里，让 Provider、Consumer、Mail 引入后自动获得这些能力。这个模块最容易写坏的地方不是“放什么”，而是“不该放什么”。它不能变成所有依赖的大杂烩，否则一个公共库升级会拖着重启所有服务。这个项目的处理方式是：公共能力进common，运行时技术栈仍由具体服务自己决定。模块定位            项      说明                  Maven artifact      service-common              打包形态      标准 JAR，不生成可执行 Fat JAR              引用方      Provider、Consumer、Mail              刻意不引用      Gateway              主要职责      契约、响应、异常、日志、路径、Feign、Jasypt      Gateway 不依赖它是刻意设计。Gateway 是 WebFlux 应用，而 common 里的 GlobalExceptionHandler、WebLogAspect 都是面向Spring MVC 的；把 common 引进 Gateway 会同时带来依赖模型和编程模型冲突。  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/pom.xml包结构com.zjc.common├── aop│   └── WebLogAspect├── api│   ├── mail│   ├── test│   └── user├── constant│   ├── ApiResponseEnum│   └── ErrorCode├── dto├── exception│   ├── BusinessException│   └── GlobalExceptionHandler└── web    ├── annotation    ├── ApiPathAutoConfiguration    ├── ApiPathProperties    ├── ApiPathResolver    └── ApiResponse这个包结构对应三类东西：  web、exception、aop：横切能力。  dto、api：跨服务契约。  constant：统一错误码和响应码。自动装配机制common 不是靠启动类扫描生效，而是通过 Spring Boot 自动装配文件注册：com.zjc.common.exception.GlobalExceptionHandlercom.zjc.common.web.ApiPathAutoConfigurationcom.zjc.common.aop.WebLogAspectcom.zjc.common.api.user.factory.UserFeignFallbackFactory  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports所以业务服务只要引入依赖，不需要再写 @Import，也不需要扩展 @ComponentScan。这让公共能力的接入成本接近零，但也要提醒自己：自动装配越方便，越要控制依赖边界。统一响应结构所有业务接口默认返回 ApiResponse&lt;T&gt;：{  "success": true,  "code": 0,  "message": "操作成功",  "data": {},  "timestamp": 1787191865837}success 给前端做分支，code 给细粒度错误定位，message 给用户展示，data 承载业务数据。timestamp在对象创建时固定，避免中途被业务代码修改。ApiResponse 提供两类构建方式：ApiResponse.success(user);ApiResponse.failureMessage("参数非法");ApiResponse.&lt;UserDTO&gt;builder()        .ok()        .data(user)        .build();  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/web/ApiResponse.javasuccessMessage 和 failureMessage 这两个名字故意写得长一点，避免当泛型是 String 时和 success(data)、failure(data)产生重载歧义。全局异常处理GlobalExceptionHandler 是 Spring MVC 的 @RestControllerAdvice。它统一拦截业务异常、参数校验异常、请求解析异常和兜底异常，再包装成ApiResponse。常见处理规则：            异常      业务码      结果                  BusinessException      透传异常 code      业务异常              MethodArgumentNotValidException      100      字段级校验错误              ConstraintViolationException      100      单参数校验错误              HttpMessageNotReadableException      101      请求体解析失败              NoResourceFoundException      102      路径不存在              Exception      500      未预期异常兜底        https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/exception/GlobalExceptionHandler.java兜底处理会记录完整堆栈，但响应里只放稳定提示或异常摘要，避免把内部堆栈直接暴露给前端。业务异常和错误码业务层不返回“失败响应对象”，而是直接抛 BusinessException：throw new BusinessException(ApiResponseEnum.USER_NOT_FOUND);throw new BusinessException("自定义提示");throw new BusinessException(10001, "用户不存在");  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/exception/BusinessException.java推荐用实现 ErrorCode 的枚举管理错误，这样 code 和 message 绑定，不会散落在 Controller 里。不同业务域可以有自己的错误码枚举，只要实现同一个接口。接口日志切面WebLogAspect 拦截所有标注 @RestController 的类，记录：  请求前：HTTP 方法、URI、类名、方法名、入参 JSON。  正常返回：耗时、返回值 JSON，超过 2000 字符截断。  异常抛出：耗时、异常消息，然后继续把异常交给全局异常处理器。log.info("==&gt; {} {} | {} | args={}", httpMethod, uri, target,        formatArgs(joinPoint.getArgs()));Object result = joinPoint.proceed();log.info("&lt;== {} {} | {} | cost={}ms | result={}", httpMethod, uri, target,        costTime, formatResult(result));  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/aop/WebLogAspect.java它会跳过 HttpServletRequest、HttpServletResponse、MultipartFile 这类不适合 JSON 序列化的对象，避免日志切面自己把请求打挂。统一 API 路径约定common 通过 spring.application.name 推导模块名，再生成统一前缀：service-provider -&gt; /api/v1/providerservice-consumer -&gt; /api/v1/consumerservice-mail     -&gt; /api/v1/mail配置来自各业务服务的公共 Profile：zjc:  api:    prefix: /api    versions:      - v1ApiPathAutoConfiguration 做了三件事：  给 Spring MVC Controller 追加版本前缀。  给 SpringDoc 自动创建版本分组。  给 Feign 请求自动补目标服务的标准前缀。  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/web/ApiPathAutoConfiguration.java这个设计让 Controller 只写资源路径，例如 /user/{id}；让 Feign 契约也只写资源路径；版本前缀统一在运行时推导。将来出现v2，只需要增加版本配置并给部分 Controller 标注 @ApiVersion("v2")。共享 Feign 契约跨服务调用最容易失控的是“每个服务自己抄一份接口定义”。这个项目把 Feign 接口放进 common：            Feign 接口      目标服务      资源路径                  UserFeignApi      service-provider      /user/{id}、/user/list              MailFeignApi      service-mail      /send              TestApi      service-provider      /port      例如用户契约：@FeignClient(        name = "service-provider",        contextId = "userFeignApi",        fallbackFactory = UserFeignFallbackFactory.class)public interface UserFeignApi {    @GetMapping("/user/{id}")    ApiResponse&lt;UserDTO&gt; getUser(@PathVariable("id") Long userId);}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/api/user/UserFeignApi.javacontextId 用来隔离 OpenFeign 配置。即使两个客户端指向同一个服务，也能分别配置超时或拦截器。降级工厂用户查询使用 FallbackFactory，不是简单 fallback：@Overridepublic UserFeignApi create(Throwable cause) {    log.error("调用 service-provider 用户接口失败，触发降级", cause);    return new UserFeignApi() {        @Override        public ApiResponse&lt;UserDTO&gt; getUser(Long userId) {            return ApiResponse.success();        }        @Override        public ApiResponse&lt;List&lt;UserDTO&gt;&gt; list() {            return ApiResponse.success(Collections.emptyList());        }    };}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/api/user/factory/UserFeignFallbackFactory.javaFallbackFactory 的价值是能拿到失败原因。单个对象降级为 data=null，列表降级为空集合，上层不需要try-catch。这个策略适合演示和可用性优先的场景；如果业务要求强一致，有些调用反而应该失败，而不是假装成功。配置加密common 引入 jasypt-spring-boot-starter，业务服务可以用 ENC(...) 替代明文密码。Jasypt 参数放在各业务模块的config/application-jasypt.yaml 中：jasypt:  encryptor:    algorithm: PBEWithHMACSHA512AndAES_256    key-obtention-iterations: 100000    pool-size: 1    provider-name: SunJCE    salt-generator-classname: org.jasypt.salt.RandomSaltGenerator    iv-generator-classname: org.jasypt.iv.RandomIvGenerator    string-output-type: base64  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/config/application-jasypt.yaml密钥不进 Git，启动时通过：java -Djasypt.encryptor.password=your-secret-key -jar service.jar或环境变量：JASYPT_ENCRYPTOR_PASSWORD=your-secret-key依赖边界common 的 POM 只声明源码直接用到的 API，例如 Spring Web、AutoConfigure、AspectJ、Feign Core、SpringDoc common、Jasypt、Swagger注解和 Hutool。Servlet API 是 provided，由目标服务的 Web 运行时提供。这些运行能力不在 common 里决定：            能力      决定位置                  Tomcat / Spring MVC      Provider、Consumer、Mail              Swagger UI      各业务服务              OpenFeign starter      Consumer              Sentinel Feign      Consumer              Nacos / MySQL / Mail      对应业务模块        https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/pom.xml这个边界让公共库保持“契约和横切能力”，而不是把所有服务都锁死在同一套运行时依赖上。阅读入口想快速读懂这个模块，建议按这个顺序看：  ApiResponse  ErrorCode、ApiResponseEnum  BusinessException  GlobalExceptionHandler  ApiPathProperties、ApiPathResolver  ApiPathAutoConfiguration  UserFeignApi 和 UserFeignFallbackFactory  WebLogAspect先看响应和异常，能理解所有 Controller 的返回方式；再看路径自动装配，能理解 /api/v1/{module} 是怎么来的；最后看 Feign契约，就能串起跨服务调用。</li>
  <li>引入 Nacos 后很容易把“注册中心”和“配置中心”混在一起讨论。这个项目明确当前只使用 Nacos Discovery，不使用 Nacos Config管业务配置。一、Nacos 承担什么服务启动时向 Nacos 注册实例，并通过服务发现找到下游服务。lb://service-provider 这类调用依赖这份实例列表。二、Nacos 不承担什么项目显式关闭：spring:  cloud:    nacos:      config:        enabled: false  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/config/application-nacos.yaml业务配置仍在本地 Profile 中，修改后需要重新打包重启。三、为什么这个边界有价值只做服务发现时，配置链路更简单：不用处理配置导入顺序、命名空间、灰度配置和动态刷新。排查启动问题时，也不会同时怀疑远程配置和本地配置。四、公共认证和服务地址分开Nacos 认证放在公共 Profile，服务地址按 dev、prod 环境维护。这样账号规则一致，环境拓扑仍可不同。五、经验总结Nacos Config 当然可以做，但要等确实需要动态配置、多环境集中管理时再引入。架构组件不是越多越好，职责边界清楚更重要。</li>
  <li>表配置了逻辑删除后，删除并不是真正的 DELETE，而是把标记字段更新为已删除。主表和明细如果分开处理，很容易留下一边可见、一边不可见的数据。一、服务层统一删除@Transactional(rollbackFor = Exception.class)public boolean removeWithDetails(Long orderId) {    if (!removeById(orderId)) {        return false;    }    orderDetailService.remove(new LambdaQueryWrapper&lt;OrderDetail&gt;()            .eq(OrderDetail::getOrderId, orderId));    return true;}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/service/impl/OrderServiceImpl.javaController 只传订单 ID，不关心主明细结构。二、返回 false 和抛异常是两种语义订单不存在时返回 false，由接口转换成“资源不存在”；数据库操作失败时抛异常，由事务回滚并进入全局错误处理。二者不要混成同一种处理方式。三、逻辑删除同样要事务主表标记删除成功后，如果明细更新失败，事务回滚，两边恢复一致。没有事务时，接口可能返回成功，但数据状态已经裂开。四、查询也要遵守同一假设订单列表查询不带明细，单个订单查询聚合明细。后续如果做统计报表，必须记得逻辑删除字段，否则会把历史删除数据重新算进去。五、经验总结逻辑删除解决的是审计和恢复需求，不是让删除变得不用考虑一致性。主从结构一旦出现，删除永远是一个业务事务。</li>
  <li>订单是最典型的主表加明细结构：只保存主表成功、明细失败，会出现一个没有商品的空订单；反过来则会出现孤儿明细。项目把这笔业务动作封装到事务方法里。一、事务方法@Transactional(rollbackFor = Exception.class)public void saveWithDetails(Order order, List&lt;OrderDetail&gt; details) {    save(order);    details.forEach(detail -&gt; {        detail.setId(null);        detail.setOrderId(order.getOrderId());        detail.setOrderNo(order.getOrderNo());    });    orderDetailService.saveBatch(details);}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/service/impl/OrderServiceImpl.javaController 负责 DTO 转换和调用，不自己拆分两次数据库写入。二、rollbackFor 不能省Spring 默认只对运行时异常回滚。加上 rollbackFor = Exception.class 后，受检异常同样触发回滚。业务代码将来引入受检异常时，事务语义不会被悄悄改变。三、先补齐关联字段明细的主键 ID 会被清空，避免调用方传入旧 ID 造成覆盖或冲突；订单 ID 和订单号统一由服务端补齐，保证明细一定挂在这张新订单上。四、失败时向上抛保存失败抛出 BusinessException，由全局异常处理转换成统一响应。事务边界内不要吞异常，否则方法看起来失败，数据库却已经提交了一半。五、经验总结事务应该围绕业务用例，而不是围绕 Repository 方法。订单保存这个动作天然要求主表和明细一致，所以边界就放在 saveWithDetails。</li>
  <li>分页接口最常见的滥用是 size=100000。一次请求就能把数据库和内存打出压力。项目对分页参数加了明确边界，并统一了新增、修改、删除的结果反馈。一、参数边界@Min(value = 1, message = "当前页码必须从1开始")@RequestParam(defaultValue = "1") long current,@Min(value = 1, message = "每页条数不能小于1")@Max(value = 100, message = "每页条数不能超过100")@RequestParam(defaultValue = "10") long size  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/controller/UserController.javaController 标注 @Validated 后，这些单参数约束才会生效。二、非法值在入口拦截current=0、size=9999 会进入参数校验异常，由全局异常处理器返回错误信息，不会触达数据库。三、Entity 分页转换为 DTO 分页项目先查询 Page&lt;Entity&gt;，再重建 Page&lt;DTO&gt;，保留 current、size、total，并转换 records。对外接口不暴露 Entity，也不把分页结构手工改散。四、更新删除要反馈结果更新成功：success=true更新失败：code=102，资源不存在删除成功：success=true删除失败：code=102，资源不存在不要所有情况都返回成功，让前端猜数据有没有变化。五、经验总结分页默认值提供便利，上限保护系统；CRUD 返回值表达真实结果。这三个小细节都直接影响接口可用性。</li>
  <li>Sentinel 在这套系统里主要守在 Gateway：按接口做 QPS 限流，按客户端 IP 做参数流控，并为路由配置异常比例和慢请求熔断。一、规则配置化zjc:  gateway:    sentinel:      enabled: true      interfaces:        - name: provider-user-detail          pattern: /api/[^/]+/provider/user/\d+          total-qps: 100          per-ip-qps: 10          interval-sec: 1  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/resources/config/application-sentinel.yaml同一条接口规则同时生成全局 QPS 和单 IP QPS 限制。阈值写在配置里，代码负责装配和校验规则。二、两种资源不能混Sentinel Gateway Filter 按“路由 + API 分组”建立资源，用于接口限流；路由熔断由 Reactive CircuitBreaker单独建立资源。两条链路语义不同，项目里刻意保持分离。三、拒绝响应要统一被限流或熔断拒绝时，Gateway 返回统一 JSON 结构，而不是把框架内部信息直接抛给调用方。这样调用方能按稳定错误码处理。四、避坑点  单 IP 限流必须确认客户端 IP 来源，不能盲信 X-Forwarded-For。  熔断阈值要有最小请求数，避免低流量时误判。  限流是保护手段，不能替代容量规划。  规则越具体越容易失控，先守关键入口，再逐步细化。五、经验总结Sentinel 把“流量异常”变成可配置的规则。项目把它放在网关层，先保护系统总入口，再考虑服务内部更细粒度的治理。</li>
  <li>业务 WebMVC 服务可以用 @RestControllerAdvice 统一异常，但 Gateway 是 WebFlux 应用，异常处理链完全不同。项目为 Gateway单独实现 ErrorWebExceptionHandler。一、为什么要单独写Gateway 处理的是 Mono&lt;Void&gt; 和 ServerWebExchange，不存在传统的 Servlet 请求线程模型。直接复用 WebMVC的异常处理器，最大概率是根本拦不到。二、响应结构保持一致网关错误同样返回：{  "success": false,  "code": 102,  "message": "请求路径不存在，请检查接口地址或网关路由",  "data": null,  "timestamp": 0}前端不需要区分错误来自网关还是业务服务。三、状态码映射业务码常见映射关系：400 -&gt; 101401 -&gt; 401403 -&gt; 403404 -&gt; 102500 -&gt; 500503 -&gt; 503未知状态回退到通用失败码，保持响应结构稳定。四、响应已提交时不能硬写如果响应已经 committed，处理器只能把异常继续抛出。此时再改状态码和响应体，会破坏 HTTP 输出。五、经验总结统一响应是外部契约，统一异常处理是内部实现。WebMVC 和 WebFlux 可以共用响应格式，但不能共用同一套处理机制。</li>
  <li>网关是所有请求的入口，也是观察请求生命周期最好的位置。项目实现了 ServiceGlobalFilter，在每个经过网关的请求前后输出日志。一、GlobalFilter 的位置@Componentpublic class ServiceGlobalFilter implements GlobalFilter, Ordered {    @Override    public Mono&lt;Void&gt; filter(ServerWebExchange exchange, GatewayFilterChain chain) {        return chain.filter(exchange);    }}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/java/com/zjc/gateway/filter/ServiceGlobalFilter.java它对所有路由生效，不用逐条路由配置。二、doFinally 覆盖三种结束方式Reactor 请求可能正常完成，也可能异常终止或被取消。使用 doFinally 可以统一输出结束日志和信号类型：return chain.filter(exchange)        .doFinally(signalType -&gt; log.info("结束请求，信号：{}", signalType));  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/java/com/zjc/gateway/filter/ServiceGlobalFilter.java这比只写成功回调可靠。三、计时值要可信开始时间在进入过滤器时记录，结束时间在 doFinally 中计算。耗时覆盖下游调用和后续过滤器链，接近网关处理这次请求的总时间。四、日志内容保持克制这里记录方法和 URI，不打印请求体。网关层面打印所有请求体，很容易把密码、上传文件和超长 JSON 写进日志，还会带来内存和 IO 压力。五、经验总结网关日志的目标是回答“请求有没有到、走了多久、以什么方式结束”。具体入参出参交给业务服务的 Web 日志切面，两层职责分开。</li>
  <li>路径规范如果只靠约定文档，迟早会跑偏。项目把 /api/{版本}/{模块} 做成自动配置，由服务名和配置推导前缀，Controller 只保留资源路径。一、路径结构/api/v1/provider/user/1 │   │  │ │   │  └── 模块名：来自 service-provider │   └────── 版本：来自 zjc.api.versions └────────── 根路径：默认 /apiservice-provider 会截取最后一个短横线后的 provider，生成 /api/v1/provider。二、版本必须显式配置zjc:  api:    prefix: /api    versions:      - v1  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/config/application-api.yaml至少要配置一个版本；默认版本必须在版本列表内。启动时校验配置，避免服务带着含糊的路径规则上线。三、Controller 不写全局前缀Controller 里只写：@GetMapping("/user/{id}")  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/controller/UserController.java实际暴露路径由公共模块追加。这样模块名、版本规则调整时，业务代码不需要大面积修改。四、网关规则更简单所有业务服务都遵守同一规范后，网关可以使用：/api/*/provider/**/api/*/consumer/**/api/*/mail/**不用为每个资源单独建路由。五、经验总结路径规范一旦可执行，就不再依赖自觉。服务名、版本、模块名和 OpenAPI 分组可以保持同一套推导逻辑。</li>
  <li>Gateway 开了链路追踪后，可能会遇到一个诡异现象：Zipkin 有 trace，日志里却没有 traceId。原因通常是 Reactor异步切换线程后，ThreadLocal 里的 MDC 上下文没有跟过去。一、问题根源传统日志 MDC 依赖 ThreadLocal，而 WebFlux 的执行链可能在多个线程之间切换。上下文不传播，日志就找不到当前 trace。二、开启自动传播项目在 Gateway 配置里执行：Hooks.enableAutomaticContextPropagation();  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/java/com/zjc/gateway/config/ObservabilityConfiguration.java它会把 Reactor Context 和 ThreadLocal 上下文关联起来，让 Micrometer Tracing 在回调线程上恢复 MDC。三、日志格式要预留字段Logback pattern 中输出 traceId 和 spanId。上下文恢复后，网关开始和结束日志就能和 Zipkin 对上。四、异步代码仍要谨慎如果自己创建线程池或包装 Subscriber，可能仍然绕开自动传播。尽量使用框架管理的异步边界；自定义并发逻辑要显式携带上下文。五、经验总结这是 WebMVC 和 WebFlux 的思维差异：后者要把上下文当成数据流的一部分，而不是默认挂在线程上。</li>
  <li>Micrometer Tracing 负责 traceId、spanId 的创建和跨进程传播。它是日志与调用链能串起来的前提，不负责存储和展示调用链。一、Reactor 场景要开启上下文传播@PostConstructpublic void enableReactorContextPropagation() {    Hooks.enableAutomaticContextPropagation();}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/java/com/zjc/gateway/config/ObservabilityConfiguration.javaGateway 是异步链路，线程会切换。开启自动上下文传播后，traceId 才能恢复到回调线程的 MDC，被日志输出。二、跨服务靠标准请求头服务之间传播 W3C traceparent 请求头。一个请求从 Gateway 进入，再调用 Provider 或 Mail，仍然保持同一个 traceId。三、采样是成本开关开发环境使用全采样，方便排查；生产环境使用低采样率，控制链路数据量。采样策略要跟环境目标匹配。四、避坑点  异步线程、线程池、Reactor 回调都可能丢上下文。  日志格式要包含 traceId/spanId 才能真正使用。  有 traceId 不代表每个服务都导出了 span。  采样率越高，观测越细，成本也越高。五、经验总结Micrometer Tracing 是可观测性的上下文基础。它让一次请求在不同服务、不同线程之间仍然可以被识别为同一件事。</li>
  <li>一次请求经过 Gateway、Provider、Consumer 后，如果每个服务只有自己的日志，拼事故时间线会很痛苦。项目为四个可运行服务引入Micrometer Tracing、Brave 和 Zipkin，用同一个 traceId 串联调用链。一、依赖每个服务都引入：spring-boot-starter-actuatorspring-boot-starter-zipkinActuator 提供观测基础，Zipkin starter 负责导出链路数据。二、采样策略开发环境使用：management:  tracing:    sampling:      probability: 1.0  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/application-prod.yaml生产环境降低到 0.1。全采样便于排查，低采样控制成本，这是常见的环境差异。三、请求头传播服务之间会传播 W3C traceparent 请求头。Gateway 发起调用，下游服务继续同一个 trace，并在合适位置创建 span。四、排查路径  从网关日志拿 traceId。  在 Zipkin 查询该 trace。  查看各服务 span 的耗时和异常。  用同一个 traceId 聚合日志。五、经验总结链路追踪的价值在跨进程边界。只在一个服务里埋点，看不到网关、Feign、数据库和外部服务之间的完整关系。</li>
  <li>很多 Gateway 示例会在转发前把 /api/provider/user/1 重写成 /user/1。这个项目反而对业务路由不做重写，因为下游服务已经统一接受/api/{版本}/{模块} 前缀。一、业务路由/api/*/provider/** -&gt; lb://service-provider/api/*/consumer/** -&gt; lb://service-consumer/api/*/mail/**     -&gt; lb://service-mail路径中的版本使用通配符匹配，模块名决定路由目标。二、不重写带来的好处下游应用日志、OpenAPI 文档、异常处理看到的路径和网关入口一致。排查问题时不需要在脑中维护两套 URL 映射。同时，下游服务即使被内网直连，也仍遵守同一套 API 契约，不会出现“网关一套路径、内网一套路径”的分裂。三、OpenAPI 是例外开发环境的 OpenAPI 转发路由会重写：/api/*/provider/v3/api-docs/**转发到下游的 /v3/api-docs/**。这是因为 SpringDoc 原生文档地址不归业务 API 路径规范管理。四、路由顺序要明确OpenAPI 转发路由更具体，优先级更高；业务路由随后匹配。模糊规则在前，具体规则就永远轮不到。五、经验总结是否重写路径不是风格问题，而是路径所有权的决策。这个项目选择让标准路径贯穿网关和服务，换来的是更简单的排障链路。</li>
  <li>这套 Spring Cloud Alibaba 项目有两种主要运行方式：本地开发默认使用 dev Profile，直接连接共享开发环境；生产运行显式切换到prod Profile，Nacos、MySQL、Zipkin 都通过本机 127.0.0.1 访问。本文按“先跑起来，再部署到生产”的顺序整理完整步骤。真正需要部署的服务只有 4 个：            服务      端口      说明                  service-gateway      80      统一入口，生产建议改 9000              service-provider      9001      用户、商品、订单业务核心              service-consumer      9002      Feign 调用示例              service-mail      9004      邮件发送与记录      service-common 是公共库，已经打进各业务服务的 Fat JAR；MP-Generator 是代码生成工具，都不需要作为服务部署。本地源码运行本地开发机准备：  JDK 21+  Maven 3.9+  IDEA 或其它 IDE  可访问共享 Nacos、MySQL、Zipkin、MailHog确认版本：java -versionmvn -version在项目根目录构建：mvn clean package -DskipTests生成的可执行 JAR：service-provider/target/service-provider-1.0.0.jarservice-consumer/target/service-consumer-1.0.0.jarservice-gateway/target/service-gateway-1.0.0.jarservice-mail/target/service-mail-1.0.0.jar  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/README.md数据库密码和 SMTP 密码使用 Jasypt 密文，启动时必须注入密钥。IDEA 建议在 Run Configuration 的 VM Options 写：-Djasypt.encryptor.password=your-secret-key命令行方式：java -Djasypt.encryptor.password=your-secret-key \  -jar service-provider-1.0.0.jar推荐启动顺序：  service-provider  service-mail  service-consumer  service-gateway第一次验证最小链路时，只启动 Provider 和 Gateway 就够了。默认不需要传 --spring.profiles.active，包内默认激活 dev。本地验证：curl http://127.0.0.1/api/v1/provider/portcurl http://127.0.0.1/api/v1/provider/user/1curl http://127.0.0.1/api/v1/consumer/feign/port开发环境聚合 Swagger：http://127.0.0.1/swagger-ui.html完全本地隔离运行默认 dev 连接共享环境。如果想在本地搭一套完全隔离环境，需要安装 MySQL 8、Nacos 3、Zipkin、MailHog 和 JDK 21。临时切换可以用命令行覆盖，以 Provider 为例：JDBC_URL="jdbc:mysql://127.0.0.1:3306/spring_cloud_alibaba"JDBC_PARAMS="?useUnicode=true&amp;characterEncoding=utf-8&amp;serverTimezone=Asia/Shanghai&amp;sslMode=REQUIRED"java -Djasypt.encryptor.password=your-secret-key \  -jar service-provider-1.0.0.jar \  --spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848 \  --spring.datasource.url="$JDBC_URL$JDBC_PARAMS" \  --spring.datasource.username=spring_cloud_alibaba \  --spring.datasource.password=your-db-password \  --management.tracing.export.zipkin.endpoint=http://127.0.0.1:9411/api/v2/spansGateway 和 Consumer 需要覆盖 Nacos 与 Zipkin 地址；Mail 还需要覆盖 SMTP 配置。也可以直接修改各服务的application-dev.yaml 为本机地址后重新打包，但要注意不要把本地地址和敏感密码误提交。prod 运行前提生产服务器需要：  JDK 21+  MySQL 8 已运行，库表已初始化  Nacos 3 已运行  Zipkin 已运行  生产 SMTP 账号已配置  Jasypt 密钥已准备prod Profile 访问的基础设施：            组件      地址                  Nacos      127.0.0.1:8848              MySQL      127.0.0.1:3306              Zipkin      127.0.0.1:9411              SMTP      生产 Profile 中的邮件服务商        https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/application-prod.yaml生产网络边界：            服务 / 组件      端口      公网                  Gateway      80 / 9000      允许              Provider      9001      禁止              Consumer      9002      禁止              Mail      9004      禁止              Nacos      8848、9848、9849、7848      禁止              MySQL      3306      禁止              Zipkin      9411      禁止      Linux 普通用户不能绑定 80 端口，Gateway 建议改用 9000：--server.port=9000也可以由 Nginx 或云负载均衡监听 80/443，再转发到 Gateway 的 9000。获取部署包本地打包：mvn clean package -DskipTests只需要拷贝 4 个可执行 JAR：service-gateway-1.0.0.jarservice-provider-1.0.0.jarservice-consumer-1.0.0.jarservice-mail-1.0.0.jar建议服务器目录：/opt/spring-cloud-alibaba/├── gateway/├── provider/├── consumer/└── mail/仓库的 Java CI 也会在构建后收集可执行 JAR，并上传为 application-jars Artifact。  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/.github/workflows/maven.yml当前工作流监听 main 分支；部署其它分支时，建议在本地构建或另外配置工作流。Linux prod 启动第一次部署可以先前台启动 Provider 观察日志：cd /opt/spring-cloud-alibaba/providerjava -Xms256m -Xmx512m \  -Djasypt.encryptor.password=your-secret-key \  -jar service-provider-1.0.0.jar \  --spring.profiles.active=prod确认成功后再后台启动：nohup java -Xms256m -Xmx512m \  -Djasypt.encryptor.password=your-secret-key \  -jar service-provider-1.0.0.jar \  --spring.profiles.active=prod \  &gt; provider.out 2&gt; provider.err &amp;echo $! &gt; provider.pidGateway：nohup java -Xms256m -Xmx512m \  -Djasypt.encryptor.password=your-secret-key \  -jar service-gateway-1.0.0.jar \  --spring.profiles.active=prod \  --server.port=9000 \  &gt; gateway.out 2&gt; gateway.err &amp;echo $! &gt; gateway.pidConsumer 与 Mail 同理，替换 JAR 名、输出文件和 PID 文件名即可。更推荐 systemd 管理。先创建权限收紧的密钥文件：sudo mkdir -p /etc/spring-cloud-alibabasudo vim /etc/spring-cloud-alibaba/provider.env写入：JASYPT_ENCRYPTOR_PASSWORD=your-secret-key设置权限：sudo chown root:root /etc/spring-cloud-alibaba/provider.envsudo chmod 600 /etc/spring-cloud-alibaba/provider.env创建 spring-provider.service：[Unit]Description=Spring Cloud Alibaba ProviderAfter=network.target mysql.service nacos.service[Service]User=springGroup=springWorkingDirectory=/opt/spring-cloud-alibaba/providerEnvironmentFile=/etc/spring-cloud-alibaba/provider.envExecStart=/usr/bin/java -Xms256m -Xmx512m -jar service-provider-1.0.0.jar --spring.profiles.active=prodRestart=on-failureRestartSec=5[Install]WantedBy=multi-user.target启动并设置开机自启：sudo systemctl daemon-reloadsudo systemctl enable --now spring-providersudo journalctl -u spring-provider -f其它服务复制 unit 后替换目录、JAR 和描述即可。Windows prod 启动设置密钥：$env:JASYPT_ENCRYPTOR_PASSWORD = "your-secret-key"后台启动 Provider：Start-Process -FilePath "java" `  -ArgumentList `    "-Xms256m", "-Xmx512m", `    "-Djasypt.encryptor.password=your-secret-key", `    "-jar", "service-provider-1.0.0.jar", `    "--spring.profiles.active=prod" `  -RedirectStandardOutput "provider.out" `  -RedirectStandardError "provider.err"Windows 同样只暴露 Gateway，其它端口用防火墙封住。生产验证假设 Gateway 使用 9000：curl http://127.0.0.1:9000/api/v1/provider/portcurl http://127.0.0.1:9000/api/v1/consumer/feign/port验证邮件：curl -X POST "http://127.0.0.1:9000/api/v1/mail/send" \  -H "Content-Type: application/json" \  -d '{"toEmails":"test@example.com","subject":"Prod Test","content":"Hello","isHtml":false}'邮件返回看 data.status：1 成功，2 失败并查看 errorMsg。再确认 Nacos 服务列表：service-providerservice-consumerservice-mailservice-gateway最后到 Zipkin 查询 Gateway 日志里的 traceId，确认整条调用链完整。常见问题Jasypt 解密失败确认密钥已通过 VM 参数或环境变量注入。IDEA 修改系统环境变量后要彻底退出再重新打开；VM Options 里写-Djasypt.encryptor.password=... 最可靠。服务注册不上 Nacos在服务器上确认 Nacos 可访问，并检查 8848、9848、9849、7848 内网通信端口。只开放 8848 不一定满足客户端通信。Gateway 返回 503优先检查下游服务是否启动、是否注册到 Nacos、路由服务名是否正确，以及是否触发 Sentinel 路由熔断。Gateway 返回 404确认访问的是完整前缀：/api/v1/provider/**/api/v1/consumer/**/api/v1/mail/**数据库连接失败先用命令行验证：mysql -h 127.0.0.1 -P 3306 -u spring_cloud_alibaba -p再检查账号 Host、密码密文、Jasypt 密钥、SSL 配置和库表初始化状态。上线检查清单  Java 版本为 21+。  4 个服务都显式使用 prod Profile。  Jasypt 密钥没有写入 Git 和日志。  MySQL、Nacos、Zipkin 均不暴露公网。  公网只暴露 Gateway。  Linux 上 Gateway 不用普通用户绑定 80。  Nacos 实例完整。  Gateway 冒烟请求成功。  Zipkin 链路完整。  日志有采集和轮转方案。这套项目的部署核心是：本地用 dev 降低准备成本，生产用 prod 收敛配置和网络边界；业务服务留在内网，所有外部流量统一从Gateway 进入。</li>
  <li>Lombok 用来减少 Java 的样板代码。项目里常见的用法是 @Slf4j、@Getter、@Setter。它让代码短一些，但仍然只是编译期工具，不应改变类的职责设计。一、日志声明@Slf4j@Componentpublic class ServiceGlobalFilter implements GlobalFilter, Ordered {  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/java/com/zjc/gateway/filter/ServiceGlobalFilter.java不用每个类手写 LoggerFactory.getLogger，日志变量名统一为 log，阅读和检索都更稳定。二、模型代码Entity、DTO、配置属性常用 @Getter、@Setter。这类代码没有复杂逻辑，交给 Lombok 可以减少噪音。三、工具链要配好项目同时使用 Lombok 和 MapStruct，父 POM 的编译插件配置了 annotation processor path 与 binding。这个细节很重要，否则生成代码和预期可能不一致。四、避坑点  @Data 不适合无脑用在所有类上，相等性语义容易被改变。  Lombok 减少代码量，不等于减少设计责任。  打包时 Lombok 被排除，运行期不依赖它。  团队使用注解要收敛，避免每个人都有一套习惯。五、经验总结Lombok 的定位很朴素：删掉重复样板，保留清晰模型。它适合做小工具，不适合用来掩盖混乱的类设计。</li>
  <li>多模块 Maven 项目的 CI 除了“构建成功”，还要回答一个很现实的问题：构建产物在哪里。这个项目用 GitHub Actions 构建，并只收集真正可执行的JAR 作为 Artifact。一、触发和权限工作流在 main 的 push、pull request 和手动触发时运行，默认只给 contents: read。权限从最小开始，是 CI 的基本素养。二、构建命令mvn -B -ntp package -DskipTests --file pom.xml  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/.github/workflows/maven.yml当前打包阶段跳过测试，构建速度更快。测试要真正进入发布门禁，可以增加独立测试步骤或单独工作流。三、识别可执行 JAR脚本遍历模块 target 下的 JAR，读取 META-INF/MANIFEST.MF：存在 Main-Class -&gt; 可执行应用 JAR不存在 Main-Class -&gt; 普通库 JAR这样 service-common 这类公共库不会混进部署包。四、Artifact 校验如果一个可执行 JAR 都没找到，脚本直接失败；上传 Artifact 也配置为没有文件时报错，避免“CI 绿了但包没交出来”。五、经验总结CI 的产物规则要和部署方式一致。这个项目部署的是各服务 Fat JAR，公共库已经在依赖里，所以只收集带 Main-Class 的 JAR。</li>
  <li>配置管理的关键不是文件数量，而是每类信息只有一个固定位置。项目最终采用本地 Profile 打包方案：服务包内保留环境配置，启动时按dev 或 prod 切换。一、基础配置保持稳定application.yaml 只保留：  应用名。  服务端口。  默认激活环境。  需要引入的公共 Profile。例如业务服务会引入 api、jasypt、zipkin、nacos；Gateway 则引入 nacos、zipkin、sentinel。二、公共能力单独成 Profileconfig/application-api.yamlconfig/application-jasypt.yamlconfig/application-nacos.yamlconfig/application-zipkin.yamlAPI 前缀、加密算法、注册中心认证、链路导出这些公共规则不再复制到每个服务。三、环境差异集中在 env 文件application-dev.yaml 和 application-prod.yaml 维护环境差异，例如外部组件地址、采样率、文档开关和日志细节。生产切换：java -jar app.jar --spring.profiles.active=prod  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/README.md四、修改配置要重新打包当前业务配置随 JAR 分发，没有放在远程配置中心。修改环境配置后需要重新构建并重启对应服务。这换来的是配置来源简单、启动依赖少。五、经验总结当配置规模还没大到需要动态下发时，本地 Profile 加清晰分组是更朴素的方案。先把边界整理干净，以后迁移配置中心也不会痛苦。</li>
  <li>Zipkin 用来接收微服务上报的 trace 数据，并展示 Gateway、Consumer、Provider、Mail 之间的调用链。Windows 和 Ubuntu 直接运行官方zipkin.jar，macOS 使用 Homebrew 安装，前提是已经安装 JDK 21。Windows 部署 Zipkin  创建目录：New-Item -ItemType Directory -Force -Path D:\apps\zipkin  下载官方可执行 JAR：$zipkinVersion = "3.6.1"Invoke-WebRequest `  -Uri "https://repo1.maven.org/maven2/io/zipkin/zipkin-server/$zipkinVersion/zipkin-server-$zipkinVersion-exec.jar" `  -OutFile "D:\apps\zipkin\zipkin.jar"  启动：java -jar D:\apps\zipkin\zipkin.jar  新开 PowerShell 验证：Invoke-RestMethod -Uri "http://127.0.0.1:9411/health"返回内容包含 UP 字样即可。  打开页面：http://127.0.0.1:9411/zipkin/  停止服务时，在启动窗口按 Ctrl+C。Ubuntu 部署 Zipkin  下载并安装到 /opt/zipkin：ZIPKIN_VERSION=3.6.1sudo mkdir -p /opt/zipkinsudo curl -L \  "https://repo1.maven.org/maven2/io/zipkin/zipkin-server/${ZIPKIN_VERSION}/zipkin-server-${ZIPKIN_VERSION}-exec.jar" \  -o /opt/zipkin/zipkin.jar  创建运行用户：sudo useradd --system --home /opt/zipkin --shell /usr/sbin/nologin zipkinsudo chown -R zipkin:zipkin /opt/zipkin  创建 systemd 服务：sudo vim /etc/systemd/system/zipkin.service写入：[Unit]Description=Zipkin ServerAfter=network.target[Service]User=zipkinGroup=zipkinExecStart=/usr/bin/java -jar /opt/zipkin/zipkin.jarRestart=on-failureRestartSec=5[Install]WantedBy=multi-user.target  启动并设置开机自启：sudo systemctl daemon-reloadsudo systemctl enable --now zipkin  查看状态：sudo systemctl status zipkin --no-pagersudo journalctl -u zipkin -f  验证：curl http://127.0.0.1:9411/healthmacOS 部署 ZipkinmacOS 有 Homebrew 包，可以直接安装。  安装 Zipkin：brew install zipkin  前台启动，适合观察日志：zipkin  如果要后台运行并随用户登录启动：brew services start zipkin  验证：curl http://127.0.0.1:9411/health  打开页面：http://127.0.0.1:9411/zipkin/接入微服务生产环境 Zipkin 和应用部署在同一台服务器时，项目配置如下：management:  tracing:    sampling:      probability: 0.1    export:      zipkin:        endpoint: http://127.0.0.1:9411/api/v2/spans  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/application-prod.yaml验证调用链的方法：  启动 Nacos、Provider、Consumer、Gateway 和 Zipkin。  通过 Gateway 发起一次请求。  在 Zipkin 页面点击搜索。  找到对应 trace，查看每一跳的耗时。数据保存方式默认情况下，Zipkin 把数据保存在内存里。进程重启后调用链会丢失，适合本机开发和临时排查。生产环境要配置外部存储，常用选择是 MySQL 或 Elasticsearch。以 MySQL 为例，需要：  创建 zipkin 数据库和专用账号。  初始化 Zipkin 官方 MySQL 表结构。  通过环境变量指定 STORAGE_TYPE=mysql 和数据库连接信息。  重启 Zipkin。官方 MySQL 表结构入口：  https://github.com/openzipkin/zipkin/blob/master/zipkin-storage/mysql-v1/src/main/resources/mysql.sql安全边界  Zipkin 9411 只给应用和运维人员访问，不要暴露公网。  远程查看时使用 VPN、堡垒机或内网反向代理。  Zipkin 里有接口名、耗时、异常等信息，不能当成公开页面。常见问题启动提示找不到 java先执行 java -version。Ubuntu systemd 环境里使用 /usr/bin/java，如果 Java 装在其他位置，要修改 zipkin.service 里的ExecStart。页面能打开但没有数据检查业务服务的 Zipkin endpoint 是否正确，确认端口是 9411，路径是 /api/v2/spans。然后确认当前请求的采样率不是 0。调用链只有单个服务说明 trace 上下文没有跨服务传播。检查 Gateway、Consumer、Provider、Mail 是否都引入了链路追踪相关依赖，并确认配置生效。重启后数据消失这是默认内存存储的正常行为。需要保留历史数据时，配置 MySQL 或 Elasticsearch 存储。</li>
  <li>快捷键： Win+i 直接打开设置，按下面顺序操作，拒绝 C 盘爆红、广告弹窗、开机卡顿。一、系统更新、激活、卸载预装垃圾  Windows 更新 → 检查更新，把系统、驱动全部装好，反复重启直到没有更新；不要彻底关闭 Windows 更新，可临时暂停更新。  确认 Windows 激活；有预装 Office，登录微软账号完成 Office 激活。  设置→ 应用→已安装应用，卸载品牌预装试用杀毒（迈克菲等）、推广管家、多余试用软件。&gt; Windows 自带 Defender足够安全，不建议装第三方杀毒管家。二、显示设置设置 → 系统 → 显示  分辨率：调到显示器 推荐分辨率。  高级显示器 → 选择显示器，把 刷新率调到最大值（很多新机默认锁 60Hz）。  缩放：笔记本按推荐缩放（125%/150%），字体才清晰。三、存储优化设置 → 系统 → 存储  打开 存储感知，配置：自动删除临时文件、回收站超过 30 天文件，设置每天执行，自动清理垃圾。  如果整机只有 C 盘：Win+X → 磁盘管理，压缩卷分出 D 盘，不懂问我，操作错误系统可能不可用四、电源与睡眠设置 → 系统 → 电源和电池  游戏本 / 台式插电：电源模式选 最佳性能；笔记本电池用均衡 / 最佳能效。  屏幕、睡眠：按需设置；挂机下载可以改成 “从不”。  笔记本：接通电源尽量用高性能，电池模式兼顾续航。五、关闭广告、隐私追踪设置 → 隐私和安全性  常规：4 个开关全部关闭（广告 ID、启动跟踪、应用启动追踪等）。  诊断与反馈：关闭可选诊断数据，关闭反馈提醒。  权限：相机、麦克风、位置，只给真正需要的软件，其余关闭。  设置→系统→通知：可以关闭总通知开关，或只保留少数软件通知，大幅减少弹窗广告。个性化清理界面设置 → 个性化  任务栏：关闭小组件、不需要的搜索；任务栏行为可以改成任务栏图标 不合并，更像 Win10 习惯。  开始：关闭 “显示建议内容”，关掉推荐广告。  锁屏界面：背景改为图片，关闭 Windows 聚焦趣闻、广告推送。六、开机速度优化两种方式任选其一：  设置 → 应用 → 启动，把不需要开机启动全部关掉（聊天软件、网盘、厂商管家等）。  Ctrl+Shift+Esc打开任务管理器 → 启动应用，启动影响高的非必要程序禁用。  不要全部禁用，声卡、输入法、指纹识别驱动保留启用。七、网络更新优化设置 → Windows 更新 → 高级选项 → 传递优化，关闭 “允许从其他电脑下载”，防止偷偷占上传网速。八、桌面图标显示设置 → 个性化 → 主题 → 桌面图标设置，勾选 此电脑、回收站，桌面就显示计算机图标。九、避坑提醒❌不要随便网上下载 “Win11 一键优化工具”，很多捆绑垃圾软件。❌不要直接禁用 Windows 更新服务，会缺失安全补丁。十、常用软件及设置  卸载软件工具:双击对应的软件卸载，下载地址 https://geekuninstaller.com/  卸载系统没用软件软件，特别是很多没用的东西，双击卸载，等待卸载完成并清理相应的残留  WinRAR 解压软件，下载地址：https://www.win-rar.com/start.html?&amp;L=7安装时候注意设置右键的菜单，不要勾选其余设置，参考：只保留常用的菜单  系统优化软件 dism ++，下载地址：点击下载下载具体设置路径如下：设置项参考：其他设置      Win11 恢复 Win10 经典完整右键菜单    1.开始菜单搜索 终端，右键 → 以管理员身份运行    2.复制下面整段粘贴进去，回车执行：      reg add "HKCU\Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32" /ve /t REG_SZ /d "" /f        3.再执行这条重启资源管理器，立刻生效（桌面会闪一下）：      taskkill /f /im explorer.exe &amp; start explorer.exe</li>
  <li>Sentinel Dashboard 是规则的可视化控制台，可以查看接口 QPS、拒绝数、响应耗时，并动态下发限流规则。你当前项目里的 Sentinel规则已经在配置文件中声明，Dashboard 接入后要分清“配置里的启动规则”和“控制台临时修改规则”。端口和用途            端口      用途                  8080      Dashboard Web 页面              8719      Sentinel 客户端通信端口      8080 只给运维或内网访问，8719 由客户端使用，都不要暴露公网。Windows 安装部署 Sentinel Dashboard  确认 JDK：java -version  下载官方 Dashboard JAR：New-Item -ItemType Directory -Force -Path D:\apps\sentinel-dashboardInvoke-WebRequest `  -Uri "https://github.com/alibaba/Sentinel/releases/download/1.8.8/sentinel-dashboard-1.8.8.jar" `  -OutFile "D:\apps\sentinel-dashboard\sentinel-dashboard-1.8.8.jar"  启动，并覆盖默认账号密码：java `  -Dserver.port=8080 `  -Dsentinel.dashboard.auth.username=zjc `  -Dsentinel.dashboard.auth.password=your-dashboard-password `  -jar D:\apps\sentinel-dashboard\sentinel-dashboard-1.8.8.jar  访问：http://127.0.0.1:8080/使用刚才设置的用户名和密码登录。Ubuntu 安装部署 Sentinel Dashboard  创建目录并下载：sudo mkdir -p /opt/sentinel-dashboardsudo curl -fL \  -o /opt/sentinel-dashboard/sentinel-dashboard-1.8.8.jar \  https://github.com/alibaba/Sentinel/releases/download/1.8.8/sentinel-dashboard-1.8.8.jar  启动：nohup java \  -Dserver.port=8080 \  -Dsentinel.dashboard.auth.username=zjc \  -Dsentinel.dashboard.auth.password=your-dashboard-password \  -jar /opt/sentinel-dashboard/sentinel-dashboard-1.8.8.jar \  &gt; /opt/sentinel-dashboard/dashboard.log 2&gt;&amp;1 &amp;  验证：curl -I http://127.0.0.1:8080/ss -lntp | grep 8080macOS 安装部署 Sentinel Dashboard  创建目录并下载：mkdir -p ~/apps/sentinel-dashboardcurl -fL \  -o ~/apps/sentinel-dashboard/sentinel-dashboard-1.8.8.jar \  https://github.com/alibaba/Sentinel/releases/download/1.8.8/sentinel-dashboard-1.8.8.jar  启动：nohup java \  -Dserver.port=8080 \  -Dsentinel.dashboard.auth.username=zjc \  -Dsentinel.dashboard.auth.password=your-dashboard-password \  -jar ~/apps/sentinel-dashboard/sentinel-dashboard-1.8.8.jar \  &gt; ~/apps/sentinel-dashboard/dashboard.log 2&gt;&amp;1 &amp;  浏览器访问：http://127.0.0.1:8080/客户端接入要点应用需要配置 Dashboard 地址和客户端端口：spring:  cloud:    sentinel:      transport:        dashboard: 127.0.0.1:8080        port: 8719  https://sentinelguard.io/zh-cn/docs/dashboard.htmlDashboard 首页开始可能是空的，通常要等应用启动并发起流量后才会出现。生产部署建议  必须覆盖默认账号密码。  Dashboard 不要暴露公网，至少要加反向代理认证和访问控制。  控制台修改默认存在内存里，重启会丢；生产规则要接配置中心或数据库持久化。  当前项目已有配置化规则，接入 Dashboard 后要明确哪边是生产规则的最终来源。  规则变更要有记录，方便回滚。常见问题Dashboard 看不到服务，依次检查客户端依赖、Dashboard 地址、8719 端口、防火墙和是否已有请求流量。规则重启后消失，是未做持久化的典型表现。页面登录成功但机器列表为空，不一定是账号问题，通常还是客户端通信链路没通。</li>
  <li>Seata 用于解决跨服务操作的一致性问题。后续如果订单、库存、邮件这些动作要拆到多个服务里，并要求“要么都成功，要么整体回滚”，再评估AT、TCC、Saga 或 XA 模式，不要为了引入分布式事务而强行拆业务。端口和用途            端口      用途                  8091      Seata Server 和控制台访问端口      Seata Server 本身也要注册到 Nacos，客户端才能通过服务发现找到它。Windows 安装部署 Seata  确认 JDK：java -version  下载 seata-server-2.0.0.zip：New-Item -ItemType Directory -Force -Path D:\apps\seataInvoke-WebRequest `  -Uri "https://github.com/apache/incubator-seata/releases/download/v2.0.0/seata-server-2.0.0.zip" `  -OutFile "D:\apps\seata\seata-server-2.0.0.zip"Expand-Archive `  -Path "D:\apps\seata\seata-server-2.0.0.zip" `  -DestinationPath "D:\apps\seata" `  -Force  进入解压后的目录，先用 file 模式启动：Set-Location D:\apps\seata\seata-server-2.0.0.\bin\seata-server.bat -p 8091 -m file  验证端口：Test-NetConnection 127.0.0.1 -Port 8091浏览器访问：http://127.0.0.1:8091/file 模式只适合本机验证，不要用于生产。Ubuntu 安装部署 Seata  创建目录并下载：sudo mkdir -p /opt/seatacd /opt/seatasudo curl -fLO https://github.com/apache/incubator-seata/releases/download/v2.0.0/seata-server-2.0.0.zipsudo apt updatesudo apt install -y unzipsudo unzip seata-server-2.0.0.zipsudo chown -R "$USER":"$USER" /opt/seata  前台启动验证：cd /opt/seata/seata-server-2.0.0sh bin/seata-server.sh -p 8091 -m file  另开终端验证：curl -I http://127.0.0.1:8091/ss -lntp | grep 8091  测试通过后后台运行：cd /opt/seata/seata-server-2.0.0nohup sh bin/seata-server.sh -p 8091 -m file &gt; seata-server.log 2&gt;&amp;1 &amp;macOS 安装部署 Seata      下载并解压官方包，例如放到 ~/apps/seata。        启动：  cd ~/apps/seata/seata-server-2.0.0sh bin/seata-server.sh -p 8091 -m file  验证：curl -I http://127.0.0.1:8091/接入 Nacos 和数据库生产环境不要长期使用 file 模式。修改 conf/application.yml 时重点看三块：  seata.registry：注册中心改成 Nacos。  seata.store：存储模式改成 db。  数据库连接：使用独立账号，不和业务库共用高权限账号。Seata 的 global_table、branch_table、lock_table 要提前建好，具体建表语句以当前版本官方文档为准。生产部署建议  Seata Server 独立部署，不和应用服务抢资源。  事务日志表要纳入数据库备份和监控。  先梳理业务边界，能本地事务解决的不要跨服务。  AT 模式要确认每张业务表都有主键。  全局锁竞争高的场景，考虑 TCC 或业务侧最终一致。  接入前要设计事务超时、重试和人工兜底流程。常见问题客户端注册不上先检查 Nacos 命名空间、分组、集群名是否完全一致。回滚失败要查 branch_table、lock_table、业务表结构和事务日志，不要只看应用异常。could not get global lock 说明并发竞争全局锁。先降低同一行热点，或者调整业务拆分方式。</li>
  <li>RocketMQ 更适合后续承接订单事件、延时消息、事务消息和顺序消息。它和 Spring Cloud Alibaba 体系同源，接入时可以优先评估rocketmq-spring-boot-starter。端口和用途            端口      用途                  9876      NameServer 地址              10911      Broker 主端口              10909      VIP Channel 端口              10912      Broker HA 端口      学习和单机部署可以先只暴露内网，生产不要把这些端口直接开放到公网。Windows 安装部署 RocketMQ  准备 JDK 21，确认：java -version  打开 RocketMQ Download，下载二进制发行包并解压，例如：D:\apps\rocketmq  设置环境变量：[Environment]::SetEnvironmentVariable("ROCKETMQ_HOME", "D:\apps\rocketmq", "User")[Environment]::SetEnvironmentVariable("NAMESRV_ADDR", "127.0.0.1:9876", "User")重新打开 PowerShell 后生效。  启动 NameServer：Set-Location D:\apps\rocketmq.\bin\mqnamesrv.cmd保持窗口开启。  新开 PowerShell，启动 Broker：Set-Location D:\apps\rocketmq.\bin\mqbroker.cmd -n 127.0.0.1:9876  查看集群：.\bin\mqadmin.cmd clusterList -n 127.0.0.1:9876Windows 更适合本机调试。长期部署建议使用 Ubuntu，并用 systemd 管理进程。Ubuntu 安装部署 RocketMQ  安装 JDK 21 后创建目录：sudo mkdir -p /opt/rocketmqsudo chown -R "$USER":"$USER" /opt/rocketmq  下载并解压二进制包。版本号以官方下载页为准：cd /tmpcurl -fLO https://archive.apache.org/dist/rocketmq/5.3.0/rocketmq-all-5.3.0-bin-release.zipunzip rocketmq-all-5.3.0-bin-release.zipmv rocketmq-all-5.3.0-bin-release/* /opt/rocketmq/  小内存机器先调整 JVM。编辑 bin/runserver.sh 和 bin/runbroker.sh，把默认大堆内存调整为适合测试环境的值，例如：-Xms512m -Xmx512m -Xmn256m  启动 NameServer：cd /opt/rocketmqnohup sh bin/mqnamesrv &gt; /opt/rocketmq/namesrv.log 2&gt;&amp;1 &amp;  启动 Broker：cd /opt/rocketmqnohup sh bin/mqbroker -n 127.0.0.1:9876 &gt; /opt/rocketmq/broker.log 2&gt;&amp;1 &amp;  验证：sh bin/mqadmin clusterList -n 127.0.0.1:9876ss -lntp | grep 9876macOS 安装部署 RocketMQ      安装 JDK 21 并下载二进制包，解压到例如 ~/apps/rocketmq。        调整 bin/runserver.sh 和 bin/runbroker.sh 的 JVM 内存。        启动 NameServer：  cd ~/apps/rocketmqnohup sh bin/mqnamesrv &gt; namesrv.log 2&gt;&amp;1 &amp;  启动 Broker：nohup sh bin/mqbroker -n 127.0.0.1:9876 &gt; broker.log 2&gt;&amp;1 &amp;  验证：sh bin/mqadmin clusterList -n 127.0.0.1:9876生产部署建议  NameServer 和 Broker 分开规划，不要和应用混抢内存。  生产必须配置 brokerIP1 为服务器内网或可达 IP。  刷盘和副本策略要按业务可靠性要求选择，先写清 RPO 和 RTO。  消息轨迹和死信队列要提前开启或设计。  事务消息只解决最终一致，不能替代本地事务。  队列不是越多越好，顺序消息还要评估单队列热点。常见问题connect to broker failed 通常和 brokerIP1、端口、防火墙有关。客户端拿到的是 Broker 返回的地址，不只是 NameServer 地址。启动立即退出先看日志里的 JVM 大小。默认脚本常见 Xms4g 配置，小机器会直接起不来。消息发送超时要同时检查磁盘、网络、Broker 压力和客户端线程池，不要只重试发送。</li>
  <li>Redis 后续可以承担验证码、登录态、接口幂等锁、热点数据缓存这类短生命周期数据。它不适合替代 MySQL 保存业务主数据，接入时要先想清楚key 的命名、过期时间和淘汰策略。端口和边界            端口      用途                  6379      Redis 服务端口      本地学习可以监听 127.0.0.1。生产环境必须设置密码，不要把 6379 暴露到公网。Windows 安装说明Redis 官方没有提供原生 Windows 安装包，这一节按“跳过原生安装”处理。如果只是想在 Windows 本机调试，推荐两种方式：  使用 WSL2 Ubuntu，按下面 Ubuntu 章节安装。  使用 Docker Desktop 运行官方 Redis 镜像。不建议使用来源不明的 Windows 修改版，后续版本、补丁和持久化行为都不可控。Ubuntu 安装部署 Redis  更新索引并安装：sudo apt updatesudo apt install -y redis-server  确认版本和服务状态：redis-server --versionsudo systemctl status redis-server --no-pager  本机连通性测试：redis-cli ping看到 PONG 表示部署成功。  设置访问密码：sudo sed -i 's/^# requirepass foobared/requirepass your-redis-password/' /etc/redis/redis.confsudo systemctl restart redis-server  使用密码验证：redis-cli -h 127.0.0.1 -p 6379 -a 'your-redis-password' ping  只监听本机时确认配置：grep '^bind ' /etc/redis/redis.conf期望看到 bind 127.0.0.1 ::1。macOS 安装部署 Redis  安装：brew install redis  作为本机服务启动：brew services start redis  验证：redis-cli pingredis-server --version  停止或重启：brew services stop redisbrew services restart redisHomebrew 默认只监听本机，适合开发环境。生产部署建议  requirepass 必须设置，密码不要写进代码仓库。  bind 127.0.0.1 ::1 或只绑定内网地址。  防火墙默认拒绝 6379，只允许应用服务器访问。  缓存 key 统一加业务前缀，例如 springvortex:mail:verify-code:*。  所有缓存 key 都要有过期时间，避免内存被慢慢吃满。  打开 AOF 或 RDB 持久化前，先明确这块数据丢了能不能接受。常见问题NOAUTH Authentication required 表示服务已要求密码，连接时加 -a 或在应用配置里提供密码。Connection refused 先检查服务是否启动、端口是否一致、防火墙是否拦截。内存持续上涨通常是 key 没有过期时间或缓存穿透。先用 redis-cli --bigkeys 找大 key，再回到业务代码检查生命周期。</li>
  <li>RabbitMQ 适合后续做邮件异步发送、低延迟任务分发、失败重试这类业务消息。它强在路由能力和确认机制，不适合当超高吞吐日志管道使用。端口和用途            端口      用途                  5672      AMQP，应用连接使用              15672      Web 管理页面      生产环境只开放 5672 给应用服务器，15672 建议走内网或 SSH 隧道访问。Windows 安装部署 RabbitMQ  先安装 Erlang/OTP：打开 Erlang Downloads，下载 Windows x64 安装包，以管理员身份安装。  再安装 RabbitMQ：打开 RabbitMQ Windows 安装文档，下载社区版安装包并安装。  确认服务：Get-Service RabbitMQ  进入安装目录启用管理页面，路径中的版本号按实际安装版本调整：Set-Location "C:\Program Files\RabbitMQ Server\rabbitmq_server-版本号\sbin".\rabbitmq-plugins.bat enable rabbitmq_management  重启服务并访问：Restart-Service RabbitMQ浏览器打开：http://127.0.0.1:15672/默认账号密码都是 guest。这个账号只允许本机访问，远程管理要新建账号。Ubuntu 安装部署 RabbitMQ  安装服务和管理插件：sudo apt updatesudo apt install -y rabbitmq-serversudo rabbitmq-plugins enable rabbitmq_management  启动并设为开机自启：sudo systemctl enable --now rabbitmq-serversudo systemctl status rabbitmq-server --no-pager  创建管理员账号：sudo rabbitmqctl add_user zjc-admin 'your-strong-password'sudo rabbitmqctl set_user_tags zjc-admin administratorsudo rabbitmqctl set_permissions -p / zjc-admin ".*" ".*" ".*"  创建应用账号，只给基础权限：sudo rabbitmqctl add_user springvortex-app 'your-app-password'sudo rabbitmqctl set_permissions -p / springvortex-app ".*" ".*" ".*"  验证端口：ss -lntp | grep -E '5672|15672'macOS 安装部署 RabbitMQ  安装：brew install rabbitmq  启动：brew services start rabbitmq  启用管理页面：rabbitmq-plugins enable rabbitmq_management  验证：rabbitmq-diagnostics pingrabbitmq-diagnostics status浏览器访问：http://127.0.0.1:15672/生产部署建议  guest 只能本机使用，生产必须创建独立账号。  应用账号不要直接给 administrator 标签。  队列和交换机的声明要固定，不要每次发布随手改变定义。  发送方开启 publisher confirm，消费方开启 manual ack。  死信队列提前设计好，失败消息要能追踪和重放。  消息体里不要放大对象，更不要放数据库密码、密钥这类敏感信息。常见问题登录管理页提示 User can only log in via localhost，说明正在用 guest 远程登录。换管理员账号，或通过 SSH 隧道访问。应用连接失败时，依次确认账号密码、虚拟主机、5672 端口和防火墙。消息堆积时先看管理页的消费者状态，不要盲目重启。多数情况是消费者挂了、处理阻塞，或者 ack 策略写错。</li>
  <li>Nacos 在这套微服务里只做服务注册与发现，不用它做配置中心。本文部署 standalone 模式，适合本机学习和单机部署；页面访问、端口检查、开机自启都会覆盖到。  先安装 JDK 21。Nacos 启动脚本本质上是启动一个 Java 进程，java -version 能看到 21 再继续。安装包下载打开 Nacos 3.0.0 Release：  Windows 下载 nacos-server-3.0.0.zip。  Ubuntu 和 macOS 下载 nacos-server-3.0.0.tar.gz。后面三个系统使用的是同一个 Nacos 发行包，区别只在解压命令、启动脚本和后台运行方式。Windows 部署 Nacos 3  创建目录并解压：New-Item -ItemType Directory -Force -Path D:\appsExpand-Archive .\nacos-server-3.0.0.zip -DestinationPath D:\apps解压后目录一般是：D:\apps\nacos  进入启动脚本目录：cd D:\apps\nacos\bin  以 standalone 模式启动：.\startup.cmd -m standalone  新开一个 PowerShell，检查端口：Test-NetConnection 127.0.0.1 -Port 8848TcpTestSucceeded 为 True 即可。  打开控制台：http://127.0.0.1:8848/默认账号密码通常为：nacosnacos  停止 Nacos：cd D:\apps\nacos\bin.\shutdown.cmd  Windows 本机学习可以直接保留启动窗口。窗口关闭或进程结束后，Nacos 也会停止。Ubuntu 部署 Nacos 3  进入存放安装包的目录，解压到 /opt/nacos：sudo mkdir -p /opt/nacossudo tar -xzf nacos-server-3.0.0.tar.gz -C /opt/nacos --strip-components=1  创建专用运行用户：sudo useradd --system --home /opt/nacos --shell /usr/sbin/nologin nacossudo chown -R nacos:nacos /opt/nacos  前台手动试启动一次：sudo -u nacos /opt/nacos/bin/startup.sh -m standalone启动脚本会输出日志文件路径，打开对应日志确认没有异常后停止：sudo -u nacos /opt/nacos/bin/shutdown.sh  创建 systemd 服务：sudo vim /etc/systemd/system/nacos.service写入：[Unit]Description=Nacos ServerAfter=network.target[Service]Type=forkingUser=nacosGroup=nacosEnvironment=JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64ExecStart=/opt/nacos/bin/startup.sh -m standaloneExecStop=/opt/nacos/bin/shutdown.shRestart=on-failureTimeoutSec=60[Install]WantedBy=multi-user.target  启动并设置开机自启：sudo systemctl daemon-reloadsudo systemctl enable --now nacos  查看状态和日志：sudo systemctl status nacos --no-pagersudo journalctl -u nacos -f  验证端口：ss -lntp | grep -E '8848|9848|9849|7848'能看到 Nacos 相关进程监听这些端口即为正常。再用浏览器访问：http://服务器IP:8848/macOS 部署 Nacos 3  解压安装包：mkdir -p ~/appstar -xzf nacos-server-3.0.0.tar.gz -C ~/apps  启动：cd ~/apps/nacos/bin./startup.sh -m standalone  检查进程：jps -l能看到 Nacos 相关 Java 进程。  访问控制台：http://127.0.0.1:8848/  停止：./shutdown.sh  macOS 更适合本机开发调试。长期服务器部署建议使用 Ubuntu systemd 管理进程。接入微服务这套项目只需要 Nacos Discovery，不需要 Nacos Config。公共账号配置如下：spring:  cloud:    nacos:      discovery:        username: nacos        password: nacos  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/config/application-nacos.yaml生产环境地址写入各服务的 application-prod.yaml：spring:  cloud:    nacos:      discovery:        server-addr: 127.0.0.1:8848启动业务服务后，在 Nacos 控制台的服务列表里应能看到：service-providerservice-consumerservice-mailservice-gatewaystandalone 模式的数据在哪里开发环境的 standalone 模式默认使用内嵌 Derby 存储。数据通常保存在 Nacos 目录下的 data 相关目录中，所以不要随手删除整个Nacos 目录。如果要重建本地环境：  停止 Nacos。  备份 data 目录。  删除或迁移旧数据。  重新启动。生产环境注意事项  不要把 8848、9848、9849、7848 暴露到公网。  登录后修改默认密码。  根据实际安全要求开启鉴权，并保存好密钥配置。  生产建议使用外置 MySQL 存储，并提前备份 Nacos 数据。  单机 standalone 适合学习和轻量部署；真正生产高可用需要集群规划。常见问题启动后马上退出优先看 Nacos 日志。常见原因有三个：JDK 不存在、JDK 版本不对、服务器内存不足。先执行 java -version，再看 logs 目录下最新日志。页面打不开但进程还在检查 8848 端口是否监听，确认访问地址没有多写 nacos 路径。Nacos 3 的控制台入口以实际发行包文档和页面提示为准。服务注册不上在业务服务所在机器上确认：curl http://Nacos服务器IP:8848/如果机器之间网络不通，检查安全组和防火墙。Nacos 客户端通信还需要 9848 等端口，不要只开放 8848。账号密码正确但注册失败确认 Nacos 服务端鉴权配置和客户端 config/application-nacos.yaml 是否匹配。密码修改后，需要同步更新应用配置并重启业务服务。</li>
  <li>MySQL 8 用来保存业务数据和邮件发送记录。这套项目需要数据库 spring_cloud_alibaba，应用通过 spring_cloud_alibaba 用户连接，并且JDBC URL 使用 sslMode=REQUIRED。本文从零开始安装，并创建这套项目可用的数据库和账号。  本文按“单机部署”写，适合同一台服务器上运行 MySQL 和微服务的场景。不要把 3306 端口直接暴露到公网。Windows 安装 MySQL 8  打开 MySQL Community Server 下载页。  下载 mysql-installer-community，建议选择体积更大的完整版，包含配置向导。  打开安装器，选择 Server only，点击 Execute。  类型选择 Standalone MySQL Server。  网络配置保持默认端口 3306。  认证方式选择默认的 Use Strong Password Encryption。  给 root 设置一个强密码并记住。这个密码只给 MySQL 运维使用，不建议直接写进微服务配置。  Windows Service 保持默认即可，服务名一般是 MySQL80。  完成安装后，打开 PowerShell，验证服务状态：Get-Service MySQL80看到 Running 即为正常。  连接 MySQL。如果 mysql 不在 PATH 里，常见目录是：C:\Program Files\MySQL\MySQL Server 8.0\bin可以在该目录下执行：.\mysql.exe -u root -p也可以把 bin 目录加入系统 PATH，之后直接执行：mysql -u root -pUbuntu 安装 MySQL 8  更新软件源：sudo apt update  安装 MySQL Server：sudo apt install -y mysql-server  启动并设置开机自启：sudo systemctl enable --now mysql  查看状态：sudo systemctl status mysql --no-pager看到 active (running) 即为正常。  Ubuntu 安装后 root 默认使用系统用户认证，先进入 MySQL：sudo mysql  给 root 设置密码，之后可以用密码登录：ALTER USER 'root'@'localhost' IDENTIFIED WITH caching_sha2_password BY '换成你的root强密码';FLUSH PRIVILEGES;EXIT;  使用密码验证：mysql -u root -pmacOS 安装 MySQL 8  安装 Homebrew 后执行：brew install mysql@8.4brew link --force mysql@8.4  启动 MySQL：brew services start mysql@8.4  查看状态：brew services list  确认客户端版本：mysql -V  首次连接。Homebrew 安装的 MySQL 初始 root 密码通常为空：mysql -u root -p提示密码时直接回车。  设置 root 密码：ALTER USER 'root'@'localhost' IDENTIFIED WITH caching_sha2_password BY '换成你的root强密码';FLUSH PRIVILEGES;EXIT;创建项目数据库和账号三个系统进入 MySQL 后，执行同样的 SQL。  创建数据库：CREATE DATABASE spring_cloud_alibaba  DEFAULT CHARACTER SET utf8mb4  COLLATE utf8mb4_0900_ai_ci;  创建应用账号。把 换成强密码 替换成自己的密码：CREATE USER 'spring_cloud_alibaba'@'localhost'  IDENTIFIED WITH caching_sha2_password BY '换成强密码';  授权。单机部署时只授权 spring_cloud_alibaba 这一个库：GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES  ON spring_cloud_alibaba.*  TO 'spring_cloud_alibaba'@'localhost';FLUSH PRIVILEGES;  如果初始化表结构由外部 SQL 脚本执行，可以临时把 CREATE、ALTER、DROP 授权给执行脚本的用户；日常运行建议只保留SELECT、INSERT、UPDATE、DELETE。  使用新账号验证：mysql -u spring_cloud_alibaba -p  登录后确认当前数据库可访问：SHOW DATABASES;USE spring_cloud_alibaba;SHOW TABLES;确认 SSL 可用这套项目的 JDBC URL 使用 sslMode=REQUIRED，所以 MySQL 必须启用 SSL。MySQL 8 默认会在首次启动时自动生成证书，一般不需要手动创建。登录 MySQL 后执行：SHOW VARIABLES LIKE 'have_ssl';SHOW VARIABLES LIKE 'ssl_ca';期望结果：have_ssl    YES也可以执行：STATUS;输出里的连接信息包含 SSL: Cipher in use is ...，说明当前客户端连接已经走了 SSL。接入微服务配置单机部署时，项目生产环境的连接地址保持为 127.0.0.1:3306：spring:  datasource:    driver-class-name: com.mysql.cj.jdbc.Driver    url: jdbc:mysql://127.0.0.1:3306/spring_cloud_alibaba?useUnicode=true&amp;characterEncoding=utf-8&amp;serverTimezone=Asia/Shanghai&amp;sslMode=REQUIRED    username: spring_cloud_alibaba    password: ENC(你的Jasypt密文)  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/application-prod.yaml密码建议使用 Jasypt 加密，不要把明文密码提交到 Git。数据库和应用不在同一台机器优先做法是创建一个限定来源 IP 的账号，不要使用 % 通配所有主机：CREATE USER 'spring_cloud_alibaba'@'应用服务器IP'  IDENTIFIED WITH caching_sha2_password BY '换成强密码';GRANT SELECT, INSERT, UPDATE, DELETE  ON spring_cloud_alibaba.*  TO 'spring_cloud_alibaba'@'应用服务器IP';FLUSH PRIVILEGES;然后修改 MySQL 监听地址和防火墙，只允许应用服务器访问 3306。Ubuntu 查看监听地址：ss -lntp | grep 3306如果需要远程访问，编辑 /etc/mysql/mysql.conf.d/mysqld.cnf，将：bind-address = 127.0.0.1改为：bind-address = 0.0.0.0mysqlx-bind-address = 127.0.0.1重启 MySQL：sudo systemctl restart mysql  改成 0.0.0.0 只是表示 MySQL 监听所有网卡，不代表可以裸奔公网。必须配合安全组、防火墙和限定来源 IP 的账号使用。备份与恢复备份：mysqldump -u root -p \  --databases spring_cloud_alibaba \  --single-transaction \  --routines --triggers \  &gt; spring_cloud_alibaba.sql恢复：mysql -u root -p &lt; spring_cloud_alibaba.sql建议把备份文件存到另一台机器或对象存储，不要只留在数据库服务器本机。常见问题Access denied for user确认三件事：用户名和密码正确；用户 Host 匹配；账号有目标库权限。单机部署用 localhost，远程部署要创建对应应用服务器 IP 的用户。Public Key Retrieval is not allowed常见于 MySQL 8 的 caching_sha2_password 认证。既然项目要求 SSL，保持 sslMode=REQUIRED 即可，不要关闭 SSL 后用allowPublicKeyRetrieval=true 绕过。SSL connection error执行 SHOW VARIABLES LIKE 'have_ssl'; 确认为 YES。如果证书缺失，备份 MySQL 数据后重新生成 SSL 证书，或参考 MySQL官方文档修复证书配置。应用连不上 127.0.0.1先在数据库服务器执行：mysql -h 127.0.0.1 -P 3306 -u spring_cloud_alibaba -p如果这个命令能连上，说明 MySQL 正常，问题在应用配置、Jasypt 密钥或应用进程环境。部署完成检查SELECT VERSION();SHOW VARIABLES LIKE 'have_ssl';SHOW DATABASES LIKE 'spring_cloud_alibaba';SHOW GRANTS FOR 'spring_cloud_alibaba'@'localhost';四条都符合预期后，这台机器的 MySQL 部分就完成了。</li>
  <li>MailHog 是本地开发环境的测试邮箱。它会假装成一个 SMTP 服务器，接收 service-mail发出来的邮件，并在网页里查看内容。这样调试邮件功能时不会把测试邮件发到真实用户邮箱。端口和用途            端口      用途                  1025      SMTP 服务，应用把邮件发到这里              8025      Web 页面和 API，用浏览器查看邮件      MailHog 默认没有认证，只适合放在本机或内网测试环境，绝不能暴露公网。Windows 安装 MailHog  创建目录：New-Item -ItemType Directory -Force -Path D:\apps\mailhog  下载 Windows x64 版本：Invoke-WebRequest `  -Uri "https://github.com/mailhog/MailHog/releases/download/v1.0.1/MailHog_windows_amd64.exe" `  -OutFile "D:\apps\mailhog\MailHog.exe"  启动：cd D:\apps\mailhog.\MailHog.exe  看到 MailHog 输出 HTTP 和 SMTP 监听信息后，打开页面：http://127.0.0.1:8025/  使用 Windows PowerShell 发送一封测试邮件：Send-MailMessage `  -SmtpServer "127.0.0.1" `  -Port 1025 `  -From "dev@localhost" `  -To "test@example.com" `  -Subject "MailHog Test" `  -Body "Hello MailHog"      回到浏览器刷新，能看到这封邮件即为部署成功。        停止服务时，在启动窗口按 Ctrl+C。  Ubuntu 安装 MailHog  下载 Linux x64 版本：curl -L \  https://github.com/mailhog/MailHog/releases/download/v1.0.1/MailHog_linux_amd64 \  -o MailHog_linux_amd64  安装到 /usr/local/bin：sudo install -m 0755 MailHog_linux_amd64 /usr/local/bin/mailhog  先手动启动验证：mailhog  另开一个终端，确认端口：ss -lntp | grep -E '1025|8025'      回到第一个终端按 Ctrl+C 停止。        创建 systemd 服务：  sudo useradd --system --home /var/lib/mailhog --shell /usr/sbin/nologin mailhogsudo mkdir -p /var/lib/mailhogsudo chown -R mailhog:mailhog /var/lib/mailhog创建 /etc/systemd/system/mailhog.service：sudo vim /etc/systemd/system/mailhog.service写入：[Unit]Description=MailHog Test Mail ServerAfter=network.target[Service]User=mailhogGroup=mailhogWorkingDirectory=/var/lib/mailhogExecStart=/usr/local/bin/mailhogRestart=on-failureRestartSec=5[Install]WantedBy=multi-user.target  启动并设置开机自启：sudo systemctl daemon-reloadsudo systemctl enable --now mailhog  查看状态：sudo systemctl status mailhog --no-pagersudo journalctl -u mailhog -f  访问页面：http://服务器IP:8025/Ubuntu 发送测试邮件在 Ubuntu 上执行：python3 - &lt;&lt;'PY'import smtplibfrom email.message import EmailMessagemsg = EmailMessage()msg["From"] = "dev@localhost"msg["To"] = "test@example.com"msg["Subject"] = "MailHog Test"msg.set_content("Hello MailHog")with smtplib.SMTP("127.0.0.1", 1025, timeout=5) as smtp:    smtp.send_message(msg)print("sent")PY执行成功后输出：sent然后刷新 MailHog 页面，或查询 API：curl http://127.0.0.1:8025/api/v2/messagesmacOS 安装 MailHog先看 CPU 架构：uname -mIntel Mac 输出 x86_64，Apple Silicon 输出 arm64。Intel Mac  下载并安装：mkdir -p ~/apps/mailhogcurl -L \  https://github.com/mailhog/MailHog/releases/download/v1.0.1/MailHog_darwin_amd64 \  -o ~/apps/mailhog/MailHogchmod +x ~/apps/mailhog/MailHog  启动：~/apps/mailhog/MailHog  访问：http://127.0.0.1:8025/Apple SiliconMailHog v1.0.1 官方 release 只有 darwin_amd64，没有 ARM64 包。可以在 Apple Silicon 上先安装 Rosetta，再运行这个 x86_64二进制：softwareupdate --install-rosetta --agree-to-license然后按 Intel Mac 的方式下载 MailHog_darwin_amd64 并启动。  如果不想安装 Rosetta，macOS 这一项可以先跳过；Ubuntu 和 Windows 都有官方对应架构的二进制包。macOS 发送测试邮件python3 - &lt;&lt;'PY'import smtplibfrom email.message import EmailMessagemsg = EmailMessage()msg["From"] = "dev@localhost"msg["To"] = "test@example.com"msg["Subject"] = "MailHog Test"msg.set_content("Hello MailHog")with smtplib.SMTP("127.0.0.1", 1025, timeout=5) as smtp:    smtp.send_message(msg)print("sent")PY刷新 http://127.0.0.1:8025/ 看到邮件即可。接入微服务开发环境的 service-mail SMTP 配置如下：spring:  mail:    host: 127.0.0.1    port: 1025    username: dev@localhost    password: ""    default-encoding: UTF-8  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-mail/src/main/resources/application-dev.yaml如果 MailHog 部署在另一台内网服务器上，把 host 改成那台服务器的内网 IP，端口仍然是 1025。业务服务所在机器必须能访问这个端口。安全提醒  MailHog 不做真实认证，不要暴露公网。  防火墙只允许可信开发网段访问 1025 和 8025。  测试邮件里不要放真实用户手机号、密码、密钥等敏感信息。  生产邮件应使用真实 SMTP 服务，不要使用 MailHog。常见问题页面打不开确认 MailHog 进程还在，检查 8025 端口。远程服务器还要检查安全组、防火墙和是否只绑定了本机地址。应用发送失败先在应用所在机器测试 1025 端口是否连通：nc -vz MailHog服务器IP 1025Windows 可以用：Test-NetConnection MailHog服务器IP -Port 1025页面能看到乱码检查邮件编码。service-mail 已配置 default-encoding: UTF-8，发送 HTML 时也应在内容里声明正确的编码。重启后邮件丢失MailHog 默认把测试邮件保存在内存里，重启后丢失。测试环境一般不需要持久化；如果确实要保留，参考 MailHog 官方文档配置文件存储。</li>
  <li>MP-Generator 是这个项目里的 MyBatis-Plus 代码生成工具模块。它连接 MySQL，根据表结构生成Entity、Mapper、Service、ServiceImpl 和 Mapper XML。它不是一个运行时微服务，不注册 Nacos，不对外提供接口，也没有被父 POM 聚合构建。这个模块的定位很干净：生成代码的独立工具，用完把生成结果迁移到业务模块，再由业务模块继续演进。模块定位            项      说明                  目录      MP-Generator              类型      独立 Maven 工程              父工程聚合      否              运行方式      本地运行 main              输入      MySQL 连接、表名、表前缀、父包名              输出      Entity、Mapper、Service、ServiceImpl、Mapper XML      父工程 pom.xml 的 modules 只包含：service-commonservice-providerservice-consumerservice-gatewayservice-mail所以在项目根目录执行 mvn package 不会构建 MP-Generator。这样生成工具不会影响业务构建速度，也不会把生成器依赖带进服务包。目录结构MP-Generator/├── pom.xml├── README.md└── src/main/    ├── java/com/zjc/CodeGenerator.java    └── resources/        ├── generator.properties.template        └── generator.properties仓库里只提交模板：generator.properties.template本地实际配置是：generator.properties模板负责说明有哪些配置项；本地文件负责保存真实数据库地址和密码，并且不进 Git。  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/MP-Generator/README.md配置模板首次使用先复制模板。macOS / Linux：cp src/main/resources/generator.properties.template \   src/main/resources/generator.propertiesWindows PowerShell：Copy-Item src/main/resources/generator.properties.template `  src/main/resources/generator.properties模板核心配置：db.url=jdbc:mysql://127.0.0.1:3306/spring_cloud_alibaba?useUnicode=true&amp;characterEncoding=utf-8&amp;zeroDateTimeBehavior=convertToNull&amp;allowMultiQueries=truedb.username=usernamedb.password=passwordpackage.parent=com.zjc.providergenerator.tables=t_user,t_order,t_order_detail,t_goodsgenerator.tablePrefix=t_,sys_  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/MP-Generator/src/main/resources/generator.properties.template            配置      作用                  db.url      数据库 JDBC 地址              db.username      数据库用户              db.password      数据库密码              package.parent      生成代码父包              generator.tables      要生成的表              generator.tablePrefix      生成类名时去掉的表前缀      换库、换表、换包名都改配置文件，不改 Java 代码。读取配置时为什么用 UTF-8Java Properties.load(InputStream) 默认按 ISO-8859-1 处理，中文容易乱码。这个项目显式包装了 Reader：try (InputStream is = CodeGenerator.class.getResourceAsStream("/generator.properties")) {    props.load(new InputStreamReader(            Objects.requireNonNull(is),            StandardCharsets.UTF_8));}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/MP-Generator/src/main/java/com/zjc/CodeGenerator.java前提是 generator.properties 本身也要以 UTF-8 保存。输出目录怎么确定工具不是直接取当前工作目录，而是从 CodeGenerator.class 的位置反推：File moduleRoot = new File(CodeGenerator.class.getProtectionDomain()        .getCodeSource().getLocation().toURI())        .getParentFile()        .getParentFile();String outputDir = moduleRoot.toPath()        .resolve("src/main/java")        .toString();编译产物通常在：MP-Generator/target/classes回退两级就是模块根目录，再拼 src/main/java。这样无论从 IDEA、模块目录还是父工程目录运行，输出目录都稳定。生成策略全局配置：.globalConfig(builder -&gt; builder        .author("jiancai.zhong")        .commentDate("yyyy-MM-dd")        .enableSpringdoc()        .outputDir(outputDir)        .disableOpenDir())包配置：.packageConfig(builder -&gt; builder        .parent(parentPackage)        .entity("entity")        .mapper("mapper")        .service("service")        .serviceImpl("service.impl")        .xml("mapper"))实体策略：.entityBuilder()        .enableLombok(new ClassAnnotationAttributes("@Data", "lombok.Data"))        .enableTableFieldAnnotation()        .enableSerialAnnotation()这里选择给字段显式加 @TableField。虽然数据库下划线字段和 Java 驼峰属性可以自动映射，但显式注解能让实体和表结构的对应关系更直观，也方便后期处理关键字段。Controller 策略：.controllerBuilder()        .disable()也就是说，生成器只生成数据访问和 Service 骨架，不生成 REST Controller。Controller 与业务契约相关，需要开发者根据DTO、参数校验、权限和响应结构手写。生成内容每张表会生成：entity/User.javamapper/UserMapper.javaservice/UserService.javaservice/impl/UserServiceImpl.javaresources/mapper/UserMapper.xml例如表 t_user，配置前缀 t_ 后生成类名 User；表 t_order_detail 生成 OrderDetail。Mapper XML 包含 BaseResultMap 和 BaseColumnList。当前业务大量使用 MyBatis-Plus 通用方法，XML 主要作为生成的标准骨架保留；以后复杂SQL 可以直接写在对应 XML 中。运行方式IDEA：  打开 CodeGenerator.java。  确认模块依赖已导入。  右键运行 main。命令行：cd MP-Generatormvn compilemvn org.codehaus.mojo:exec-maven-plugin:3.1.0:java \  -Dexec.mainClass="com.zjc.CodeGenerator"运行结束输出：代码生成完毕！生成后怎么迁移生成结果不是留在工具模块里长期编译。推荐流程：  在 generator.properties 里配置目标父包，例如 com.zjc.provider。  运行生成器。  检查 Entity 字段、表名、主键和逻辑删除字段。  把生成的包复制到对应业务模块。  检查业务模块的 Mapper 扫描路径和 mapper-locations。  编译业务模块。  手写或调整 Controller、DTO 和 Converter。生成器模块只包含生成器运行所需依赖，不包含 Spring Boot Web、MyBatis-Plus Spring Boot Starter、数据库连接池等业务运行依赖。所以生成出来的Service 代码在 MP-Generator 里编译报错是正常现象，迁到业务模块后才是正确位置。依赖设计            依赖      用途                  mybatis-plus-generator      代码生成核心              mysql-connector-j      读取 MySQL 表结构              freemarker      模板引擎              slf4j-api / slf4j-simple      生成过程日志              lombok      生成实体注解所需              swagger-annotations-jakarta      SpringDoc 注释支持        https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/MP-Generator/pom.xml这些依赖只服务“生成代码”，不参与线上服务运行。常见问题找不到 generator.properties确认模板已经复制为 src/main/resources/generator.properties，并且执行过 mvn compile，让资源进入 target/classes。生成到错误目录检查 CodeGenerator.class 的运行位置。这个设计已经尽量从 class 位置反推模块根目录，如果构建产物被移动过，需要重新用 Maven编译后再运行。类名前缀没去掉检查 generator.tablePrefix。它是一个逗号分隔列表，例如：generator.tablePrefix=t_,sys_生成的 Service 编译失败生成器模块不是业务模块，缺少 IService、ServiceImpl 相关运行依赖。把代码迁到 Provider 或 Mail 等业务模块后编译。覆盖已有代码怎么办生成前先确认 Git 工作区干净，或者先输出到新目录检查。生成结果迁移后再手工合并，不要盲目覆盖已经改过的业务代码。模块小结MP-Generator的价值是把重复的表到代码映射交给工具，把真正需要设计的部分留给人：接口契约、DTO、权限、事务和业务规则。它保持独立、不进父工程、不携带真实配置，这些边界让代码生成变成一个安全的工程辅助步骤，而不是后续维护的隐患。</li>
  <li>Kafka 更适合后续承接高吞吐事件流、行为日志、审计事件和数据管道。它强在吞吐、分区并行和日志保留，如果只是少量业务任务分发，RabbitMQ或 RocketMQ 可能更合适。端口和用途            端口      用途                  9092      Broker 对客户端提供服务              2181      旧 ZooKeeper 模式端口      新部署建议直接使用 KRaft 模式，不再依赖 ZooKeeper。Windows 安装部署 Kafka  准备 JDK 17 或 JDK 21：java -version      打开 Apache Kafka Downloads，下载 Scala 2.13 对应的二进制压缩包。        解压到固定目录，例如：  D:\apps\kafka  生成集群 ID：Set-Location D:\apps\kafka.\bin\windows\kafka-storage.bat random-uuid记下输出的 UUID。  初始化 KRaft 存储：.\bin\windows\kafka-storage.bat format `  --standalone `  -t 替换为上一步的UUID `  -c .\config\server.properties  启动 Kafka：.\bin\windows\kafka-server-start.bat .\config\server.properties  创建测试 Topic：.\bin\windows\kafka-topics.bat `  --bootstrap-server 127.0.0.1:9092 `  --create `  --topic springvortex-test `  --partitions 1 `  --replication-factor 1  查看详情：.\bin\windows\kafka-topics.bat `  --bootstrap-server 127.0.0.1:9092 `  --describe `  --topic springvortex-testWindows 只建议本机学习使用。Kafka 的文件句柄、路径和后台运行方式更适合放在 Linux 服务器。Ubuntu 安装部署 Kafka  准备 JDK 17 或 JDK 21，创建目录：sudo mkdir -p /opt/kafkasudo chown -R "$USER":"$USER" /opt/kafka  下载并解压。版本号以官方下载页为准：cd /tmpcurl -fLO https://archive.apache.org/dist/kafka/4.0.0/kafka_2.13-4.0.0.tgztar -xzf kafka_2.13-4.0.0.tgzmv kafka_2.13-4.0.0/* /opt/kafka/  生成并格式化存储：cd /opt/kafkaKAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)"bin/kafka-storage.sh format --standalone -t "$KAFKA_CLUSTER_ID" -c config/server.properties  前台启动，先确认能运行：bin/kafka-server-start.sh config/server.properties  另开终端测试 Topic：/opt/kafka/bin/kafka-topics.sh \  --bootstrap-server 127.0.0.1:9092 \  --create \  --topic springvortex-test \  --partitions 1 \  --replication-factor 1  验证后可以用 nohup 启动：cd /opt/kafkanohup bin/kafka-server-start.sh config/server.properties &gt; kafka.log 2&gt;&amp;1 &amp;macOS 安装部署 Kafka      安装 JDK 后下载二进制包，解压到 ~/apps/kafka。        初始化存储：  cd ~/apps/kafkaKAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)"bin/kafka-storage.sh format --standalone -t "$KAFKA_CLUSTER_ID" -c config/server.properties  启动：bin/kafka-server-start.sh config/server.properties  创建 Topic：bin/kafka-topics.sh \  --bootstrap-server 127.0.0.1:9092 \  --create \  --topic springvortex-test \  --partitions 1 \  --replication-factor 1生产部署建议  Topic 命名一开始就规划前缀和环境，例如 prod.order.event.created。  分区数决定并行上限，不要上线后再随意缩小。  生产至少三节点，replication-factor 最小为 3。  min.insync.replicas、acks 和重试策略要一起设计。  消费组要监控 lag，堆积比单条消息错误更能说明问题。  磁盘要有保留周期和容量告警，Kafka 磁盘满会造成严重事故。常见问题Connection to node could not be established 要检查 Broker 监听地址。容器、云服务器和混合网络里经常是advertised.listeners 配错。格式化存储报已存在，说明数据目录已经初始化过。学习环境可以清空目录后重来，生产环境不能随手删。消费 lag 持续增加先看消费者是否在线、线程是否阻塞、再评估分区数和下游处理能力。</li>
  <li>JDK 21 是这套 Spring Cloud Alibaba 微服务的运行基线。Gateway、Provider、Consumer、Mail 都是 Java 服务，Nacos 和 Zipkin也建议直接跑在 JDK 21 上。本文按三个系统分别安装，最后确认 java 命令可用。  先确认系统架构：普通 Intel/AMD 电脑选 x64，Apple Silicon 的 Mac 选 ARM64。不要按“最新版本”盲装，先认准大版本 21。安装目标安装完成后，在终端执行下面命令，能看到 21 开头的版本号即可：java -version期望输出类似：openjdk version "21.0.x" 2026-xx-xxOpenJDK Runtime Environment Temurin-21.0.xOpenJDK 64-Bit Server VM Temurin-21.0.xWindows 安装 JDK 21  打开 Eclipse Temurin 21 下载页。  在 Operating System 选择 Windows，Machine Architecture 选择自己电脑的架构，一般是 x64 / JDK。  下载 .msi 安装包，例如 OpenJDK21U-jdk_x64_windows_hotspot_21.0.x_x.msi。  双击安装包，一路下一步。到 Custom Setup 页面时，建议勾选：Add to PATHAssociate .jar files with JavaSet JAVA_HOME variable  点击 Install，等待安装完成。  重新打开一个 PowerShell。一定要重新打开，旧窗口不会自动拿到新的环境变量。  验证安装：java -versionwhere.exe java第一条能看到 21 开头的版本号，第二条能看到 java.exe 的完整路径，就说明安装成功。Windows 手动配置 JAVA_HOME如果安装时没有勾选 Set JAVA_HOME variable，可以手动补：  按 Win 键，搜索“编辑系统环境变量”，打开后点击 环境变量。  在“系统变量”里点击 新建。  变量名填：JAVA_HOME  变量值填 JDK 安装目录，例如：C:\Program Files\Eclipse Adoptium\jdk-21.0.x  找到 Path，确认里面有 %JAVA_HOME%\bin，或者直接有 C:\Program Files\Eclipse Adoptium\jdk-21.0.x\bin。  连续点击确定，重新打开 PowerShell 后验证。  目录里的 21.0.x 要换成你实际安装的版本号。配置环境变量时选择到包含 bin 目录的上一层，不要把路径配到 bin 里面。Ubuntu 安装 JDK 21Ubuntu 22.04、24.04 可以直接使用 apt 安装 OpenJDK 21。  更新软件源：sudo apt update  安装 JDK 21：sudo apt install -y openjdk-21-jdk  验证：java -version  查看安装路径：sudo update-alternatives --list java常见路径是：/usr/lib/jvm/java-21-openjdk-amd64/bin/java因此 JAVA_HOME 通常配置为：/usr/lib/jvm/java-21-openjdk-amd64  如果某些脚本必须依赖 JAVA_HOME，可以编辑 /etc/environment：sudo vim /etc/environment追加一行：JAVA_HOME="/usr/lib/jvm/java-21-openjdk-amd64"保存后重新登录 SSH，或者重启服务器。Ubuntu 多版本切换如果机器上同时安装了 JDK 17、21 等多个版本，可以切换默认 java：sudo update-alternatives --config java输入 JDK 21 对应的编号，回车后再执行 java -version 确认。macOS 安装 JDK 21macOS 推荐用 Homebrew 安装 Temurin 21。  如果没有 Homebrew，先安装：/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"  安装 JDK 21：brew install --cask temurin@21  验证：java -version  查看 JDK 21 的安装目录：/usr/libexec/java_home -v 21  如果需要设置 JAVA_HOME，编辑 ~/.zshrc：vim ~/.zshrc追加：export JAVA_HOME=$(/usr/libexec/java_home -v 21)export PATH="$JAVA_HOME/bin:$PATH"  让配置立即生效：source ~/.zshrc  验证环境变量：echo "$JAVA_HOME"java -version常见问题提示 java 不是内部或外部命令通常是 PATH 没生效。Windows 重新打开 PowerShell；Ubuntu 和 macOS 重新登录终端。如果还是没有，检查 JAVA_HOME 是否配置到了bin 里面。版本显示的不是 21说明机器上有多个 JDK，PATH 优先级不对。Windows 在“环境变量”里把 JDK 21 的 bin 上移；Ubuntu 用 update-alternatives；macOS用 JAVA_HOME 明确指定版本。Nacos 或 Zipkin 启动失败先在同一个终端执行 java -version。如果终端显示的是 21，但服务脚本找不到 Java，通常是服务运行用户的环境变量没配置。Ubuntu可以在服务配置里显式写入 JAVA_HOME，或者确认 /usr/bin/java 指向 21。部署清单安装完成后，记下这台机器的 Java 环境：java -versionecho "$JAVA_HOME"这套微服务的生产部署顺序一般是：先装 JDK 21，再装 MySQL、Nacos、Zipkin，最后启动业务服务。JDK 是所有 Java 进程的地基，建议最先装好。</li>
  <li>Elasticsearch 后续可以做商品搜索、文章搜索、日志检索和复杂聚合分析。它不是普通数据库的替代品，索引结构、分词、字段类型和刷新策略都会影响效果。端口和用途            端口      用途                  9200      HTTP API 和客户端访问      9200 不要直接暴露公网。生产环境必须启用安全认证和 TLS。Windows 安装部署 Elasticsearch      打开 Elasticsearch Download，下载 Windows ZIP 包。        解压到固定目录，例如：  D:\apps\elasticsearch  仅本机学习时，可以先关闭安全认证。编辑 config\elasticsearch.yml，追加：xpack.security.enabled: falsexpack.security.enrollment.enabled: false  启动：Set-Location D:\apps\elasticsearch.\bin\elasticsearch.bat  验证：Invoke-RestMethod http://127.0.0.1:9200看到 cluster_name 和版本信息即可。  如果保留安全认证，首次启动时终端会输出 elastic 的初始密码，必须保存。忘记密码可以重置：.\bin\elasticsearch-reset-password.bat -u elasticUbuntu 安装部署 Elasticsearch  添加官方 APT 源：sudo apt updatesudo apt install -y apt-transport-httpses_keyring=/usr/share/keyrings/elasticsearch-keyring.gpgcurl -fsSL https://artifacts.elastic.co/GPG-KEY-elasticsearch \  | sudo gpg --dearmor -o "$es_keyring"echo "deb [signed-by=$es_keyring] https://artifacts.elastic.co/packages/8.x/apt stable main" \  | sudo tee /etc/apt/sources.list.d/elastic-8.x.list  安装：sudo apt updatesudo apt install -y elasticsearch  小内存测试环境限制堆内存。编辑 /etc/elasticsearch/jvm.options.d/heap.options：-Xms1g-Xmx1g  启动并设为开机自启：sudo systemctl daemon-reloadsudo systemctl enable --now elasticsearchsudo systemctl status elasticsearch --no-pager  APT 安装默认启用安全认证。查看或重置密码：sudo /usr/share/elasticsearch/bin/elasticsearch-reset-password -u elastic  验证：curl -k -u elastic:'your-elastic-password' https://127.0.0.1:9200macOS 安装部署 Elasticsearch  使用官方 Homebrew 包：brew install elastic/tap/elasticsearch-full  启动：brew services start elastic/tap/elasticsearch-full  验证：curl -k https://127.0.0.1:9200如果启用安全认证，需要加 -u elastic 并输入密码。生产部署建议  至少三节点，避免脑裂和数据不可用。  堆内存一般不超过机器内存一半，且不要超过 31GB。  必须启用 TLS、认证、角色和索引权限。  分片数和副本数按数据量规划，不是越多越好。  明确映射，避免字段类型被第一条数据带偏。  写入频繁的场景调低 refresh_interval 带来的实时性预期。常见问题启动失败提示 vm.max_map_count，Linux 上需要按官方文档调大内核参数。9200 能连但返回 401，说明安全认证已启用，账号密码或 TLS 配置不对。写入延迟高不要先加节点，先确认 refresh interval、分片大小、mapping 和查询是否合理。</li>
  <li>微服务拆分后，接口文档如果分散在三个端口，前端要来回切换。开发环境的 Gateway 使用 SpringDoc 聚合 Swagger UI，一个页面切换Provider、Consumer 和 Mail。一、聚合入口http://localhost/swagger-ui.html页面里的文档组类似：Provider v1Consumer v1Mail v1二、OpenAPI JSON 走网关Provider 的文档地址形如：/api/v1/provider/v3/api-docs/v1-provider网关根据模块名转发到对应服务，再把标准前缀重写为 SpringDoc 原生路径。三、只在开发环境开放生产 Profile 会关闭：springdoc.api-docs.enabledspringdoc.swagger-ui.enabled同时网关不注册 OpenAPI 转发路由。文档能暴露接口结构和参数模型，默认不应该出现在生产入口。四、验证不能只看状态码WebMVC 全局异常处理可能把不存在路径包装成 HTTP 200，响应体是 code=102。生产验证文档关闭时，要检查响应体，而不是只看 404。五、经验总结聚合文档提升的是协作效率，但必须和 Profile 绑定。开发默认开，生产默认关，这是比较稳的默认值。</li>
  <li>按客户端 IP 限流有一个前提：你要确定这个 IP 是真的。项目的 Sentinel 配置只允许可信代理的 X-Forwarded-For 生效，否则直接使用TCP 远端地址。一、伪造风险X-Forwarded-For 是普通请求头，客户端可以随便填。如果无脑取第一个值，攻击者可以在每次请求里换一个假 IP，轻松绕过单 IP 限流。二、判断直连地址逻辑分两步：  读取 TCP direct address。  只有 direct address 在 trusted-proxies 里，才解析转发头。本机回环地址默认可信，适配本机反向代理调试。三、取第一个非 unknown 值X-Forwarded-For 可能包含多跳代理：client, proxy1, proxy2项目取第一项作为候选客户端，并排除空白和 unknown。更复杂的多层代理需要明确每跳的追加规则。四、可信代理列表要精确不要为了方便填写整个网段。代理列表越宽，伪造面就越大；如果架构变化，也要同步维护这个列表。五、经验总结安全相关的请求头不能默认可信。来源识别的正确性，直接决定 IP 限流是否真的有意义。</li>
  <li>微服务依赖链很长，某个传递依赖出安全问题时，如果每个模块各管各的版本，修复会非常慢。项目把关键版本集中在父 POM，并用 BOM管理框架族。一、三层版本管理Spring Boot ParentSpring Cloud BOMSpring Cloud Alibaba BOM业务模块基本不写版本号，避免和框架兼容矩阵脱节。二、关键依赖显式覆盖父 POM 集中维护：Jackson 2 / Jackson 3NettyHttpCore5 / HttpClient5Log4j2 APIBouncy Castle这些库常见且传递面广，出问题时必须能一处升级、全工程生效。三、版本不是越新越好升级要区分安全修复和功能版本。安全修复优先，功能升级要看兼容性测试。特别是 Jackson、Netty 这类底层库，序列化格式和网络行为都可能影响链路。四、自动 PR 只是入口Dependabot 能提示版本，但合并前仍要确认：  是否包含安全修复。  是否兼容 Java 21 和当前 Boot 版本。  测试是否完整通过。  是否需要同步覆盖多个相关依赖。五、经验总结版本治理的目标是可控升级。集中声明、少量覆盖、定期更新，比临时到处改版本稳得多。</li>
  <li>公共模块最容易变成垃圾桶：为了几个 DTO，把 Web、数据库、Swagger、Sentinel 全部传递给下游。项目后来重构了 service-common的依赖边界，让它只做公共库，不替业务服务决定运行时栈。一、公共模块只声明直接用到的 API例如源码直接使用 Spring MVC、AutoConfigure、AspectJ、OpenFeign Core、SpringDoc Common，就在 service-common/pom.xml显式声明。没有直接使用的完整 starter 不往里塞。二、运行能力下沉到服务Web 容器        provider / consumer / mailOpenFeign      consumerSentinel Feign consumer数据库          provider / mailSwagger UI     对外提供文档的服务这样公共包不会把 Tomcat、驱动、UI 依赖传递给不需要的模块。三、Gateway 刻意不引入Gateway 是 WebFlux 技术栈，不依赖 service-common，避免公共模块里的 WebMVC 假设和自动装配混进来。网关自己的错误处理、日志和响应结构单独实现。四、公共库不 repackageservice-common 保持普通 JAR：spring-boot.repackage.skip=true它只作为依赖进入业务服务的 Fat JAR，自己不独立启动。源码 JAR 一并打包，方便 IDE 查看实现。五、经验总结公共模块的依赖越克制，升级 Spring Boot 和 Spring Cloud 时影响面越清楚。公共代码新增 import 时，要同步补直接依赖，而不是蹭下游的传递依赖。</li>
  <li>限流保护的是入口流量，熔断保护的是故障扩散。Gateway 为业务路由统一挂上 CircuitBreaker，下游异常比例或慢请求比例过高时，后续请求会快速失败。一、两类熔断条件项目同时配置：异常比例：异常请求数 / 总请求数慢请求比例：超过 RT 阈值的请求比例二者都可以设置最小请求数、统计窗口和恢复时间。最小请求数能避免服务刚启动时因少量样本误熔断。二、默认规则挂到路由过滤器的资源名使用：{routeId}-circuit不同路由拥有独立熔断器，Provider 故障不应该导致 Mail 请求也一起失败。三、统一 fallback 接口熔断后转发到：/gateway/fallback/{route}该接口返回 HTTP 503 和统一 JSON，提示下游服务暂不可用。四、快速失败也是一种保护没有熔断时，一个下游变慢会占住网关和调用方的并发资源，进而拖垮其他健康服务。熔断把等待变成可预期的快速失败，给下游恢复留时间。五、经验总结熔断阈值不能拍脑袋定得太敏感。它应该是容量测试和真实错误率观测之后的结果。</li>
  <li>OpenFeign 本来已经很声明式，但如果没有共享契约，消费者仍要在自己模块里重抄一份 provider接口。路径改了、响应类型变了，两个项目不会同时提醒你。这个项目把跨服务 Feign 接口放进 service-common，让调用方直接注入。一、契约放在公共 API 包@FeignClient(    name = "service-provider",    contextId = "userFeignApi",    fallbackFactory = UserFeignFallbackFactory.class)public interface UserFeignApi {    @GetMapping("/user/{id}")    ApiResponse&lt;UserDTO&gt; getUser(@PathVariable("id") Long userId);}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/api/user/UserFeignApi.java接口描述的是跨服务契约，和 DTO 一样属于公共层，而不是某一个消费者的私有代码。二、contextId 隔离同服务多客户端两个 Feign 客户端可能指向同一个服务，但超时、拦截器、日志级别不同。contextId 让 OpenFeign 配置能精确到某个客户端，避免同名服务下的配置互相影响。三、契约资源路径保持简短接口里写 /user/{id}，运行时会由统一 API 路径拦截器补成 /api/v1/provider/user/{id}。这样契约描述资源本身，版本和模块前缀由公共规则推导。四、公共契约也要克制只共享确实跨服务使用的 API，不要把 provider 的所有 Controller 都搬进 common。否则公共模块会膨胀，下游也会误用本该内部使用的接口。五、经验总结共享 Feign 契约的本质是“接口即文档、接口即代码”。一次修改，编译链路立刻暴露所有调用方，比人工同步注释可靠得多。</li>
  <li>Linux 设置静态 IP，就是让机器重启后地址不变。方便 SSH 远程连接，不用每次查IP。搭建网站、数据库、集群服务时，其他设备能稳定访问。防火墙规则、端口映射、内网同步任务也不会因 IP 变动失效。缺点是要规划地址，容易出现IP 冲突。第一步：设置 VMware 的虚拟网络打开 VMware，进入 编辑 -&gt; 虚拟网络编辑器：按照下图进行配置，其中子网 IP 192.168.100.0 决定了以后的网段：配置完上述设置后，进行 NAT 设置，主要配置网关地址：完整的配置参考：第二步：修改 Ubuntu 网卡信息主要是查看网卡的名称，修改对应的 Ubuntu 的网卡配置信息，最后重启网卡。查看网卡 ip addr运行该命令后会输出网卡的基本信息，找到你的网卡名称，如我的输出如下： zjc@ubuntu-24-dev:~$ ip addr 1: lo: &lt;LOOPBACK,UP,LOWER_UP&gt; mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000     link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00     inet 127.0.0.1/8 scope host lo        valid_lft forever preferred_lft forever     inet6 ::1/128 scope host noprefixroute        valid_lft forever preferred_lft forever 2: ens32: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1500 qdisc fq_codel state UP group default qlen 1000     link/ether 00:0c:29:a2:18:d1 brd ff:ff:ff:ff:ff:ff     altname enp2s0     inet 192.168.100.128/24 brd 192.168.100.255 scope global ens32        valid_lft forever preferred_lft forever     inet6 fe80::20c:29ff:fea2:18d1/64 scope link        valid_lft forever preferred_lft forever那么我的网卡名称就是 ens32，接下来修改配置时候要看准这个网卡，不要修改错误。查看并修改网卡配置文件首先进入网卡的配置目录 /etc/netplan/，然后拷贝一份保存起来，防止修改出错后以后上不了网，再进行编辑： zjc@ubuntu-24-dev:/etc/netplan$ cd /etc/netplan/ zjc@ubuntu-24-dev:/etc/netplan$ pwd /etc/netplan zjc@ubuntu-24-dev:/etc/netplan$ ls 50-cloud-init.yaml zjc@ubuntu-24-dev:/etc/netplan$ cp 50-cloud-init.yaml 50-cloud-init.yaml.bak zjc@ubuntu-24-dev:/etc/netplan$ ls 50-cloud-init.yaml  50-cloud-init.yaml.bak备份完成后，使用 vim 编辑配置并保存，修改后的配置示例如下： network:   version: 2   ethernets:     ens32:       dhcp4: false       dhcp6: false       addresses: [ 192.168.100.128/24 ]       optional: true       routes:         - to: default           via: 192.168.100.2       nameservers:         addresses: [ 114.114.114.114, 8.8.8.8 ]  注意：Ubuntu 进来默认不是 root 用户，如果执行命令遇到没有权限 Permission denied 则需要使用 sudo 进行授权。配置项说明：  ens32：你的网卡名  192.168.100.128/24：自选静态 IP（和 VMnet8 同网段）  via: 192.168.100.2：VMware NAT 网关重启网卡一般是先测试网卡，没有问题再正式执行重启命令生效： # 测试配置，出错 30 秒自动回滚 sudo netplan try网络正常就回车确认永久生效。测试没问题也可以直接执行： sudo netplan apply验证网络 ip addr                # 查看 IP 是否生效 ping 192.168.100.2    # 测试通网关 ping www.baidu.com    # 测试外网ip addr 能正常显示网卡的 IP 为 192.168.100.128 则正常，ping 网关和外网返回如下则正常： zjc@ubuntu-24-dev:~$ ping 192.168.100.2 PING 192.168.100.2 (192.168.100.2) 56(84) bytes of data. 64 bytes from 192.168.100.2: icmp_seq=1 ttl=128 time=0.132 ms 64 bytes from 192.168.100.2: icmp_seq=2 ttl=128 time=0.185 ms 64 bytes from 192.168.100.2: icmp_seq=3 ttl=128 time=0.205 ms 64 bytes from 192.168.100.2: icmp_seq=4 ttl=128 time=0.225 ms 64 bytes from 192.168.100.2: icmp_seq=5 ttl=128 time=0.237 ms 64 bytes from 192.168.100.2: icmp_seq=6 ttl=128 time=0.208 ms ^C --- 192.168.100.2 ping statistics --- 6 packets transmitted, 6 received, 0% packet loss, time 5157ms rtt min/avg/max/mdev = 0.132/0.198/0.237/0.033 ms zjc@ubuntu-24-dev:~$ ping www.baidu.com PING www.a.shifen.com (183.2.172.177) 56(84) bytes of data. 64 bytes from 183.2.172.177: icmp_seq=1 ttl=128 time=11.7 ms 64 bytes from 183.2.172.177: icmp_seq=2 ttl=128 time=7.23 ms 64 bytes from 183.2.172.177: icmp_seq=3 ttl=128 time=10.5 ms 64 bytes from 183.2.172.177: icmp_seq=4 ttl=128 time=8.14 ms 64 bytes from 183.2.172.177: icmp_seq=5 ttl=128 time=8.70 ms ^C --- www.a.shifen.com ping statistics --- 5 packets transmitted, 5 received, 0% packet loss, time 4007ms rtt min/avg/max/mdev = 7.228/9.260/11.710/1.631 ms踩坑点  yaml 缩进必须空格，禁止 Tab，格式错直接断网  IP、网关必须和 VMnet8 网段一致，否则不通  静态 IP 不要落在 VMware DHCP 地址池范围内，防止 IP 冲突  想宿主机 SSH 连接虚拟机：确保 VMnet8 网卡启用</li>
  <li>MapStruct 负责 Entity 和 DTO 之间的转换。它在编译期生成实现类，不走反射，字段映射错误尽量在构建阶段暴露，而不是等到运行时才发现。一、映射规则写在接口上@Mapper(componentModel = "spring")public interface UserConverter {    UserDTO entityToDto(User user);    @Mapping(target = "isDeleted", ignore = true)    @Mapping(target = "updateTime", ignore = true)    @Mapping(target = "createTime", ignore = true)    User dtoToEntity(UserDTO dto);}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/converter/UserConverter.javacomponentModel = "spring" 让生成类直接作为 Spring Bean 注入。反向映射时显式忽略数据库管理字段，避免请求 DTO 意外覆盖审计字段。二、它保护的是接口契约Entity 面向数据库，DTO 面向接口。MapStruct 让这两个模型可以不同：数据库加内部字段不自动泄漏到 API，接口字段调整也不迫使表结构跟着变。三、编译期是关键MapStruct 与 Lombok 同时使用时，项目在编译插件里配置了 annotation processor 顺序和 binding。工具链没有配好时，最常见现象是生成了空实现或字段丢失。四、避坑点  同名字段不代表语义相同，必要时显式 @Mapping。  集合映射要确认元素映射方法存在。  不要把 MapStruct 当成任意对象复制的万能工具。  生成代码可以打开看，遇到疑问直接看实现最直观。五、经验总结MapStruct 用编译期成本换来映射确定性。它比手写拷贝少样板代码，比反射拷贝更容易排查。</li>
  <li>BeanUtils.copyProperties 写起来最省事，代价藏得很深：字段名悄悄拼错时没有编译错误，类型不一致要运行时才发现，性能 also依赖反射。项目改用 MapStruct，把 Entity 和 DTO 的转换提前到编译期。一、转换器长什么样@Mapper(componentModel = "spring")public interface UserConverter {    UserDTO entityToDto(User user);    @Mapping(target = "isDeleted", ignore = true)    @Mapping(target = "createTime", ignore = true)    @Mapping(target = "updateTime", ignore = true)    User dtoToEntity(UserDTO dto);}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/converter/UserConverter.javacomponentModel = "spring" 让生成实现类注册成 Bean，Controller 直接注入使用。二、显式忽略数据库托管字段DTO 转 Entity 时，isDeleted、createTime、updateTime 被忽略。它们属于数据库和公共填充逻辑管理的字段，不应该由调用方随便传入。这个忽略动作同时是文档：调用方能设置哪些字段，转换器一眼可见。三、编译期发现字段问题MapStruct 在编译时生成实现类。字段名不匹配、类型不能转换、目标字段没有来源，都会在构建阶段暴露。相比运行时反射拷贝，这是把风险往前挪了一大步。四、列表转换不用手写循环List&lt;UserDTO&gt; entityListToDtoList(List&lt;User&gt; users);  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/converter/UserConverter.javaMapStruct 会基于单对象转换方法生成集合转换，减少模板代码。五、经验总结DTO 和 Entity 分离后，转换代码会变多，这是契约清晰的成本。MapStruct 把这部分成本控制在编译期，属于划算的交换。</li>
  <li>远程调用一定会有失败。OpenFeign 支持在失败时走 fallback，但项目选择的是 FallbackFactory，因为它能拿到失败原因。排查线上问题时，这一件事非常关键。  2026-08-24 更新：用户接口的降级策略已从“返回空数据”调整为统一返回 code=503 的业务繁忙失败响应。本文的重点仍是FallbackFactory 能拿到失败原因；具体策略以《用户 Feign 降级要承认服务不可用》为准。一、工厂能拿到异常@Slf4j@Componentpublic class UserFeignFallbackFactory implements FallbackFactory&lt;UserFeignApi&gt; {    @Override    public UserFeignApi create(Throwable cause) {        log.error("调用 service-provider 用户接口失败，触发降级", cause);        return new UserFeignApi() {            // 返回兜底数据        };    }}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/api/user/factory/UserFeignFallbackFactory.java连接拒绝、读超时、下游 503、反序列化失败，都会出现在 cause 里。普通 fallback 只知道“进了兜底”，不知道为什么。二、降级策略要跟业务走项目早期曾采用可用性优先：  单个查询：返回 data=null 的成功响应。  列表查询：返回空列表，避免上层遍历时空指针。这适合演示和部分读取场景，但会把“数据不存在”和“下游不可用”混在一起。当前用户接口已改为统一返回业务繁忙失败响应；下单、扣库存、支付这类操作更应该暴露失败，让用户重试，而不是返回一个看似成功的空结果。三、成功包装不代表没有降级降级响应使用统一 ApiResponse，上层必须检查 data。如果调用方只看 success，就会把“服务不可用但优雅返回”误判成“查询结果为空”。四、降级日志要能聚合记录失败原因、目标服务、接口方法和关键参数，可以快速回答三个问题：  是哪个下游不可用。  是超时还是连接失败。  影响了多少请求。五、经验总结降级不是把异常藏起来，而是把失败转换成系统设计好的响应。想清楚一致性优先还是可用性优先，比写一个空返回重要得多。</li>
  <li>微服务部署时，很多端口都需要监听，但不都应该对外暴露。这个项目的网络原则很简单：公网只访问 Gateway，业务服务和基础设施全部留在内网。一、端口角色service-gateway   80     对外入口service-provider  9001   仅内网service-consumer  9002   仅内网service-mail      9004   仅内网Nacos             8848 等 仅内网MySQL             3306   仅内网Zipkin            9411   仅内网二、推荐拓扑公网客户端   |Gateway   |业务服务内网   |Nacos / MySQL / Zipkin外部请求统一经过网关，网关通过服务发现转发，业务服务之间也在内网通信。三、安全组不要放大网段只放行 Gateway 需要的 80 或 443；如果 Gateway 改用 9000，就只放行 9000。不要为了调试临时开放 9001、9002、9004，尤其不要把Nacos 和 MySQL 直接暴露公网。四、应用层校验只是兜底可以在请求里增加来源标识、签名或 mTLS，防止业务端口被误访问。但这不能替代防火墙和安全组。网络边界是第一道，也是最可靠的一道。五、经验总结生产环境直接访问 http://host:9001/swagger-ui.html 这类地址，只适合本机或内网调试。绕过网关的访问一旦出现在公网，统一鉴权、限流和审计都会失去意义。</li>
  <li>Zipkin 负责接收、查询和展示服务调用链。Micrometer Tracing 生成 trace/span，Zipkin 把这些数据变成可以查看的调用树。一、导出地址management:  tracing:    export:      zipkin:        endpoint: http://127.0.0.1:9411/api/v2/spans  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/application-prod.yaml每个服务把自己的 span 导出到 Zipkin。开发环境和生产环境只差异在地址与采样率，链路模型保持一致。二、排查一次请求典型流程：  通过 Gateway 发起请求。  在网关日志里找到 traceId。  到 Zipkin 查询该 traceId。  查看各服务 span 的耗时、状态和调用关系。  再回到对应服务日志看业务细节。三、它回答的是路径问题Zipkin 擅长回答：  请求经过了哪些服务。  哪一段最耗时。  异常发生在哪一层。  调用是否出现了跨服务传播断点。它不直接回答数据库慢 SQL 的完整执行计划，也不替代日志和指标。四、避坑点  Zipkin 内存存储重启会丢历史，生产要配置外部存储。  全采样会快速放大数据量。  Zipkin 不应暴露到公网。  服务时钟偏差会影响耗时判断。五、经验总结Zipkin 把分散的 span 变成完整调用树。它和日志互为印证：日志给细节，链路给路径。</li>
  <li>网关限流不能只按路由粗略控制，因为同一条路由里可能有高频只读接口，也有低频写接口。项目使用 Sentinel Gateway 的 API分组，把规则挂到更细的接口模式上。一、API 分组用正则匹配例如：/api/[^/]+/provider/user/\d+/api/[^/]+/mail/send版本位置使用 [^/]+，模块和资源保持明确，比整条业务路由全量限流更准确。二、每条规则生成两层限制同一个接口会同时配置：total-qps：接口全局上限per-ip-qps：单个客户端 IP 上限全局阈值保护下游，单 IP 阈值防止单个来源吃光配额。三、被限流时返回统一 JSONSentinel 的 BlockRequestHandler 返回 HTTP 429 和统一响应结构：请求过于频繁，请稍后再试前端不需要识别 Sentinel 默认文本。四、配置先校验接口名必须唯一，正则必须可编译，QPS 和统计窗口必须为正，并且单 IP 阈值不能超过全局阈值。非法配置直接启动失败，好过规则上线后不生效。五、经验总结限流规则要能表达业务风险。普通查询和邮件发送的承载能力完全不同，阈值也应该分开设置。</li>
  <li>Jasypt 用来处理配置文件里的敏感值。数据库密码、SMTP 授权码这类信息不以明文提交，而是保存为 ENC(...) 密文，运行时通过外部注入的密钥解密。一、算法显式固定jasypt:  encryptor:    algorithm: PBEWithHMACSHA512AndAES_256    key-obtention-iterations: 100000    salt-generator-classname: org.jasypt.salt.RandomSaltGenerator    iv-generator-classname: org.jasypt.iv.RandomIvGenerator    string-output-type: base64  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/config/application-jasypt.yaml算法、迭代次数、盐和 IV 都显式声明。加密工具和运行时使用同一组参数，才不会出现“本地能解，服务起不来”的问题。二、密文在配置，密钥不在配置配置文件可以保存密文，但密钥必须通过环境变量、JVM 参数或部署系统注入。密钥一旦跟着密文一起提交，加密就只剩下混淆意义。三、项目使用边界Jasypt 只解决配置读取时的静态敏感信息问题。它不负责数据库权限、网络隔离、账号轮换或审计。这些仍要由 MySQL、SMTP 服务和部署环境承担。四、避坑点  同一明文每次加密结果不同是随机盐的正常表现。  排查失败先确认密钥，再比较算法参数。  开发、生产可以使用不同密钥，泄露范围更小。  密钥泄露后要重新加密敏感配置，而不是只改代码。五、经验总结Jasypt 的价值是降低仓库泄露后的直接影响。真正的安全边界仍然取决于密钥管理和最小权限。</li>
  <li>数据库密码、SMTP 授权码这类配置，明文放在仓库里迟早会变成事故。项目使用 Jasypt 把敏感值加密成 ENC(xxx)，启动时再通过环境变量或JVM 参数注入密钥。一、加密配置公共配置使用同一组参数：jasypt:  encryptor:    algorithm: PBEWithHMACSHA512AndAES_256    key-obtention-iterations: 100000    salt-generator-classname: org.jasypt.salt.RandomSaltGenerator    iv-generator-classname: org.jasypt.iv.RandomIvGenerator    string-output-type: base64  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/config/application-jasypt.yaml显式写出算法、迭代次数、随机盐和随机 IV，可以避免依赖隐式默认值，也方便加密工具和运行时保持一致。二、加密工具不引入新系统service-common 里的 JasyptTest 提供交互式工具。运行前先传入密钥：java -Djasypt.encryptor.password=your-secret-key ...  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/test/java/com/zjc/common/JasyptTest.java也可以使用环境变量：JASYPT_ENCRYPTOR_PASSWORD=your-secret-key  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/test/java/com/zjc/common/JasyptTest.java工具会输出密文并回读解密验证，确认一致后再复制到配置里。三、密钥才是真正的秘密配置文件里可以有密文，但不能有密钥。密钥一旦提交，ENC(xxx) 只是把明文换了一种形状。更稳妥的做法是：  密钥只存在于部署环境或密钥管理系统。  不同环境使用不同密钥。  密钥泄露时重新加密所有敏感配置。四、随机盐带来的现象同一个明文每次加密出的密文都不同，这是正常的，也是安全的。排查启动失败时不要对比密文，应该确认密钥是否正确、算法配置是否一致。五、经验总结Jasypt 能降低仓库泄露带来的直接影响，但它不是访问控制。数据库仍要限制来源 IP，SMTP 授权码仍要支持独立撤销和更换。</li>
  <li>排查接口问题时，最有价值的三条信息通常是：请求带来了什么、返回了什么、耗时多久。项目把这些信息统一放在 WebLogAspect，所有@RestController 自动被拦截，业务代码不用手写日志。一、切点只看 RestController@Pointcut("@within(org.springframework.web.bind.annotation.RestController)")public void controllerPointcut() {}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/aop/WebLogAspect.java这个范围足够克制：只记录 HTTP 入口，不拦截 Service、Mapper 等内部调用，避免一次请求产生成倍的重复日志。二、环绕通知统一记录开始和结束方法执行前输出 HTTP 方法、URI、目标方法、参数；执行后输出耗时和结果；异常分支输出异常消息后继续抛出，交给GlobalExceptionHandler 处理。long startTime = System.currentTimeMillis();Object result = joinPoint.proceed();log.info("&lt;== cost={}ms", System.currentTimeMillis() - startTime);return result;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/aop/WebLogAspect.java计时必须包住 proceed()，如果用普通前置和后置通知，异常路径很容易漏掉失败耗时。三、不要把请求对象序列化进日志HttpServletRequest、HttpServletResponse、MultipartFile 被排除，只记录类型名。它们要么不适合 JSON 序列化，要么可能携带大文件和流对象。返回值超过 2000 字符会截断，避免一个大列表把日志系统打出超高成本。四、日志内容要有取舍入参和出参日志在开发、测试环境很好用，生产环境要特别注意：  密码、token、邮箱、身份证号等字段需要脱敏。  大响应只保留长度或摘要即可。  高并发接口可以采样，而不是每条全量输出。五、经验总结AOP 解决的是“通用观测能力”，不是业务日志。关键业务动作仍应在 Service 层记录，例如订单创建成功、邮件发送失败原因。</li>
  <li>接口写多了以后，最怕的不是异常本身，而是异常的形状不一致：有的返回 Tomcat 默认错误页，有的把完整堆栈给前端，有的只在日志里留一句模糊提示。这个项目把WebMVC 服务的异常处理收敛到 service-common 的 GlobalExceptionHandler，业务模块引入公共包后自动生效。一、处理边界处理器使用 @RestControllerAdvice，优先级可以理解为：BusinessException参数校验异常请求格式 / 路径 / 方法异常未预期异常兜底这个顺序很重要。业务异常最了解自己，应该原样透传 code 和 message；框架异常则应该翻译成统一的 ApiResponse，避免前端理解Spring 内部异常。二、把校验错误拼成可读信息@RequestBody 校验失败、表单绑定失败、@RequestParam 约束失败，最终都被转换成类似下面的提示：username: 用户名不能为空; age: 年龄必须在 1 到 120 之间核心做法是从 FieldError 或 ConstraintViolation 中取出字段和默认消息，再用分号拼接。这样前端可以直接展示，不需要解析异常类名。三、框架异常的分类常见异常都有明确归属：  HttpMessageNotReadableException：请求体 JSON 不合法。  MissingServletRequestParameterException：缺少必填参数。  NoResourceFoundException：路径不存在。  HttpRequestMethodNotSupportedException：请求方法不匹配。其中 favicon.ico 的 404 会被静默处理。浏览器会自动请求它，如果每次都打 warn，日志很快就会被无价值信息填满。四、兜底异常不要泄露堆栈未预期异常统一记录完整堆栈，但响应里只返回稳定结构和提示：@ExceptionHandler(Exception.class)public ApiResponse&lt;Void&gt; handleException(Exception e) {    log.error("未预期异常", e);    String message = e.getMessage() != null            ? e.getMessage()            : ApiResponseEnum.INTERNAL_ERROR.message();    return ApiResponse.failure(ApiResponseEnum.INTERNAL_ERROR.code(), message);}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/exception/GlobalExceptionHandler.java完整堆栈给开发者，稳定 JSON 给调用方，这是全局异常处理的基本分工。五、经验总结全局异常处理适合放在公共模块，但不代表业务代码可以到处 try-catch。业务失败优先抛 BusinessException；只有需要转换错误、补充上下文或清理资源时，才在局部捕获。</li>
  <li>统一前缀不只是 URL 好看，还直接影响接口文档分组。项目的 ApiPathAutoConfiguration 同时做两件事：给 MVC 追加路径前缀，并为每个版本生成SpringDoc 分组。一、按版本追加 MVC 前缀自动配置注册 WebMvcConfigurer，对每个版本调用 addPathPrefix。默认 Controller 使用默认版本，标注 @ApiVersion("v2") 的Controller 使用 v2 前缀。这样资源路径不变，版本通过外层前缀区分，符合常见的 API 版本演进方式。二、包范围避免误伤公共模块优先使用 Spring Boot 自动推断的应用包，也可以通过 base-packages 明确限制。只有位于范围内的 Controller才追加前缀，避免把第三方 Controller 也改了路径。三、OpenAPI 分组自动生成版本 v1、模块 provider 会生成：分组名：v1-provider匹配路径：/api/v1/provider/**多个版本共存时，Swagger UI 可以直接切换，不需要手工维护每组路径。四、版本注解只是选择器@ApiVersion 不负责解析路径，只声明“这个 Controller 属于哪个版本”。如果声明了未配置的版本，启动会失败，避免文档和真实路径不一致。五、经验总结路由规则、接口文档和版本演进放在同一个自动配置里，是保持一致性的关键。否则文档分组很容易落后于真实接口。</li>
  <li>Hutool 是项目里的通用工具库，目前最典型的使用点是 Web 日志切面中的 JSON 序列化。它减少手写工具方法，但也要控制边界，避免业务代码逐渐依赖一个大杂烩。一、用于日志对象格式化import cn.hutool.json.JSONUtil;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/aop/WebLogAspect.java日志切面需要把入参和返回值转成可读 JSON。Hutool 提供轻量直接的 API，同时项目会跳过 Request、Response、文件上传这类不适合完整序列化的对象。二、工具库的价值在“少写代码”这类代码不属于业务核心竞争力：日期格式、字符串判断、JSON 转换、简单校验。交给稳定工具库，可以让业务代码更关注规则本身。三、边界同样重要项目没有把 Hutool 当成所有问题的默认答案。核心架构能力仍交给专门组件：  JSON API 契约使用 Jackson。  数据访问使用 MyBatis-Plus。  对象映射使用 MapStruct。  邮件使用 Spring Boot Mail。四、避坑点  日志序列化要防止对象过大和敏感信息泄漏。  工具库 API 稳定性不等同于语义完全一致，升级要看变更说明。  同一件事不要在项目里保留多套工具写法。  工具库依赖应集中在需要的模块，不盲目全量传递。五、经验总结Hutool 适合处理小而确定的通用问题。它让项目少写重复工具代码，但架构能力仍然要交给更专业的组件。</li>
  <li>统一错误码最容易被做坏的地方，是把所有枚举都塞进一个公共类。项目里用 ErrorCode 接口切开这个耦合：公共模块只定义标准码段，业务模块自己扩展自己的错误码。一、错误码抽象public interface ErrorCode {    int code();    String message();}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/constant/ErrorCode.javaApiResponseEnum 实现它，负责 0、100、101、102、401、403、500、503 这类通用语义。业务模块如果需要 20001起的用户错误码，只需要再定义一个枚举，不需要修改公共代码。二、业务异常携带上下文BusinessException 是运行时异常，内部保存错误码：throw new BusinessException(ApiResponseEnum.NOT_FOUND);throw new BusinessException(20001, "用户不存在");  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/exception/BusinessException.java推荐第一种和自定义枚举写法。数字散落在业务代码里，后期很难知道它属于哪个模块。三、公共枚举与业务枚举的边界可以这样划分：  公共枚举：表达 HTTP / REST 通用语义。  业务枚举：表达具体业务规则，比如用户被禁用、库存不足、订单状态不允许取消。  临时字符串：只用于明确的一次性提示，不承载稳定契约。一旦前端要根据错误码做跳转、重试或表单提示，这个错误码就应该进入枚举管理。四、异常不是日志的替代品BusinessException 通常代表可预期的业务失败，处理器里用 warn 记录；未预期异常才用 error 记录完整堆栈。如果到处都按最高级别打日志，真正的事故反而会被淹没。五、经验总结错误码的价值在于稳定契约，而不是数字越多越好。每个码都要有明确处理方式；否则保留通用失败码反而更干净。</li>
  <li>邮件接口被滥用时，最常见的问题不是发送失败，而是把非法地址、超长内容和无法追踪的请求送进SMTP。项目在邮件服务里做了两道很实用的防线：先校验收件人，再持久化发送过程。一、地址格式先拦住收件人、抄送、密送都支持逗号分隔，解析时会逐个 trim 并匹配基础格式：local@domain.top非法地址在入库和发送前直接抛出异常。这个校验不证明邮箱真实存在，但能挡住明显错误和大部分脏输入。二、可选字段允许为空抄送和密送可以为空；为空时不设置对应 header。收件人则必须提供，避免构建出一封没有目标地址的邮件。三、先写待发送发送前保存 status=0 的记录。这样即使进程在发送过程中中断，也能留下一条待处理痕迹，而不是完全丢失。四、发送后更新结果成功更新为 status=1；失败更新为 status=2 并保存错误信息。这个状态机虽然简单，但已经能回答：  哪些邮件还在待发送状态。  哪些失败以及失败原因。  某次业务操作对应的邮件记录是哪条。五、经验总结外部系统调用前先落一条初始状态，是提高可观测性的经典做法。后续要扩展重试和告警，也可以基于这张状态表继续演进。</li>
  <li>邮件看起来是一个小功能，一旦多个业务模块都要发信，它就会变成公共能力。项目把发送邮件独立成 service-mail，统一管理 SMTP配置、格式处理和发送记录。一、模块边界调用方只提交业务字段：收件人 / 抄送 / 密送主题正文是否 HTMLSMTP 服务器、发件人和授权码由邮件模块维护，业务服务不需要知道邮件基础设施细节。二、支持文本和 HTML普通文本使用 SimpleMailMessage，HTML 使用 MimeMessageHelper 并设置 setText(content, true)。这比在业务代码里拼接不同邮件实现干净得多。三、发送结果入库每次发送先创建待发送记录，再执行发送，最后更新状态：0 待发送1 发送成功2 发送失败失败时保存错误信息。这样即使调用方没有保存日志，也能追溯某封邮件的最终状态。四、异常边界SMTP 异常不会直接炸给调用方，而是被记录到邮件日志状态中。调用方拿到 MailLogDTO，可以看到发送记录和状态，再决定是否提示用户或触发重试。五、经验总结独立邮件服务的价值是收口：安全凭据、发送策略、审计记录都只维护一份，业务模块只表达“我要发什么”。</li>
  <li>微服务项目的测试最容易变重：一跑测试就要 Nacos、MySQL、网络和一堆环境变量。这个项目的原则是绝大多数测试不启动完整应用，也不依赖外部组件，让mvn test 可以在本机随手执行。一、测试栈项目使用：JUnit 5MockitoAssertJSpring Test普通业务逻辑用 Mockito 隔离依赖；和自动装配相关的公共组件用轻量级 ContextRunner 验证，不起完整服务。二、命名让失败自己会说话测试类按 被测类名 + Test 命名，方法使用 test + 行为描述：UserControllerTest#testGetUserReturnsDtoOrderServiceImplTest#testSaveWithDetailsRollsBackWhenDetailSaveFails看到失败名字，就应该大概知道哪个契约被打破。三、测行为，不测实现细节比如订单事务测试关注的不是 MyBatis-Plus 内部怎么执行，而是：  主表保存成功、明细保存失败时抛出异常。  明细写入前会补齐订单 ID 和订单号。  删除订单时会同时删除明细。这种测试在重构实现时仍然有效。四、Mock 要有边界适合 Mock 的是外部协作对象：Mapper、远程 Feign 客户端、邮件发送器。不要把被测对象自己 Mock 掉，也不要为了凑覆盖率测试 getter和 setter。五、经验总结单元测试的价值来自快、稳定、指向清楚。需要真实数据库和网络的验证放到集成或环境测试里，不要混进每次提交都跑的基础测试。</li>
  <li>写测试不能只靠感觉，JaCoCo 能把测试执行后的字节码覆盖情况生成报告。项目把 JaCoCo 挂在父 POM，所有 Maven模块统一继承，不需要每个子模块重复配置。一、两个执行阶段&lt;execution&gt;  &lt;id&gt;prepare-agent&lt;/id&gt;  &lt;goals&gt;&lt;goal&gt;prepare-agent&lt;/goal&gt;&lt;/goals&gt;&lt;/execution&gt;&lt;execution&gt;  &lt;id&gt;report&lt;/id&gt;  &lt;phase&gt;test&lt;/phase&gt;  &lt;goals&gt;&lt;goal&gt;report&lt;/goal&gt;&lt;/goals&gt;&lt;/execution&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/pom.xmlprepare-agent 负责注入探针，report 在测试阶段生成 HTML、XML 等报告。二、报告位置执行测试后查看：target/site/jacoco/index.htmlXML 报告也适合接入 CI 平台展示趋势。三、覆盖率是信号，不是 KPI覆盖率高不代表测试好，只代表这些代码被跑过。真正有价值的是：  分支失败路径有没有覆盖。  事务回滚有没有验证。  配置非法时是否启动失败。  异常响应结构是否稳定。四、和 Surefire 配合项目使用 @{argLine} 晚绑定，保留 JaCoCo 注入的 agent 参数，同时追加 JDK 21 需要的动态 Agent 参数，避免 Mockito 相关警告影响执行。五、经验总结JaCoCo 适合用来发现“完全没人碰过的代码”，再反推缺哪些测试。为百分比写无意义测试，是本末倒置。</li>
  <li>共享 Feign 契约里写 /user/{id}，下游真实路径却是 /api/v1/provider/user/{id}。如果靠每个调用方手写完整前缀，服务名和模块名一变就要改契约。项目用Feign RequestInterceptor 在发送前自动补前缀。一、识别目标服务拦截器从 request.feignTarget() 读取目标服务名，例如：service-provider然后用同一套规则推导目标服务前缀：/api/v1/provider二、根据方法版本选择前缀Feign 方法可以标注 @ApiVersion。没有标注时使用默认版本。这样调用 v2 接口时，会补 /api/v2/...，而不是继续沿用默认版本。三、避免重复拼接如果请求路径已经是标准前缀，拦截器不会再次追加。这个判断看似简单，却能避免接口升级过程中新旧写法混用时出现双重前缀。四、契约和路由使用同源规则公共模块里的 ApiPathResolver 同时服务 MVC 前缀、SpringDoc 分组和 Feign 拦截器。规则只有一份，出错的概率自然低。五、经验总结这个设计让 Feign 契约保持资源级简洁，同时让实际请求符合全局路由规范。调用方不需要理解网关路径拼接细节。</li>
  <li>SpringDoc OpenAPI 负责把代码里的接口注解转换成 OpenAPI 文档，再通过 Swagger UI 查看。这个项目的原则是：接口定义变化时，文档跟着代码一起变化。一、服务级元信息@Beanpublic OpenAPI openAPI() {    return new OpenAPI()            .servers(List.of(server))            .info(new Info()                    .title("Service Provider API")                    .version("v1.0.0"));}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/config/OpenApiConfig.java每个服务维护自己的标题、描述和版本。接口分组则由公共 API 路径模块根据统一前缀自动生成。二、文档入口分两层单个服务提供：  /swagger-ui.html  /v3/api-docs  分组 JSON，例如 /v3/api-docs/v1-providerGateway 再把 Provider、Consumer、Mail 的文档地址聚合成一个入口。这样本地调试可以看单服务，联调时可以从网关看全貌。三、环境开关开发环境开启 api-docs 和 swagger-ui；生产环境默认关闭。文档是开发和联调工具，不应该默认变成生产接口清单。四、避坑点  DTO 注解和 Controller 注解要描述同一份契约。  文档路径经过 Gateway 时要单独设计转发规则。  生产关闭文档不等于接口鉴权可以省略。  版本分组要和统一 API 前缀一致，避免文档路径和真实路径分叉。五、经验总结SpringDoc 让文档回到代码源头。它解决的不是“有没有文档”，而是文档能不能随接口演进而同步。</li>
  <li>Gateway 是外部请求进入这套微服务系统的唯一入口。浏览器、脚本或第三方调用方只需要知道 Gateway 地址和统一路径规则，不需要知道Provider、Consumer、Mail 分别部署在哪个端口和哪台机器上。一、路由按服务名转发- id: provider-route  uri: lb://service-provider  predicates:    - Path=/api/*/provider/**  order: 1  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/resources/application-dev.yamllb://service-provider 表示先通过服务发现拿到实例，再由 LoadBalancer 选择一个实例转发。路由规则保留/api/{version}/provider 这类统一前缀，下游服务继续使用自己的资源路径。二、WebFlux 是它的基本模型Gateway 基于 Reactor，过滤器返回 Mono&lt;Void&gt;。全局过滤器使用 doFinally 记录结束日志：return chain.filter(exchange)        .doFinally(signalType -&gt; {            long endTime = System.currentTimeMillis();            log.info("结束请求 {} {}，耗时：{}ms，信号：{}", method, uri, endTime - startTime, signalType);        });  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-gateway/src/main/java/com/zjc/gateway/filter/ServiceGlobalFilter.java这不是为了炫技，而是 Gateway 的执行模型决定的。三、职责边界Gateway 负责：  统一路径和服务路由。  全局请求日志。  Sentinel 限流与路由熔断入口。  OpenAPI 文档聚合入口。  统一 JSON 错误响应。它不承载业务规则，也不直接访问业务数据库。这个边界让入口层可以专心处理流量问题。四、避坑点  Gateway 是 WebFlux，不能复用 MVC 的 RestControllerAdvice。  路由重写要少用，路径前缀尽量由公共 API 模块统一。  生产环境不应把所有服务端口一起暴露，只暴露 Gateway。  CORS、可信代理、客户端 IP 解析要在入口层统一想清楚。五、经验总结Gateway 的价值是把“外部访问”和“内部服务拓扑”拆开。内部服务可以换端口、加实例、换机器，外部契约仍然稳定。</li>
  <li>接口参数校验不应该散落在 Service 里写 if。这个项目使用 Jakarta Validation，把规则贴在 DTO 和 Controller 参数上。一、请求体校验@PostMapping("/user")public ApiResponse&lt;UserDTO&gt; add(@Valid @RequestBody UserDTO dto) {    ...}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/controller/UserController.javaDTO 字段上声明规则：@NotBlank(message = "登录账号不能为空")@Size(min = 3, max = 20, message = "账号长度需在3-20个字符之间")private String username;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/dto/UserDTO.java二、Query 参数校验分页参数在 Controller 方法上直接标注：@Min(value = 1, message = "当前页码必须从1开始")@RequestParam long current  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/controller/UserController.java同时类上要开启：@Validated  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/controller/UserController.java三、常用注解            注解      用途                  @NotNull      不允许 null              @NotBlank      字符串非空白              @Email      邮箱格式              @Min / @Max      数值范围              @Size      字符串或集合长度      四、校验失败如何返回校验失败会抛出不同异常：  MethodArgumentNotValidException  BindException  ConstraintViolationException公共全局异常处理器统一拦截，把错误字段和消息拼成稳定 ApiResponse。五、踩坑点  @NotNull 不等于字符串非空。  @Valid 只校验当前对象，嵌套对象需要继续标 @Valid。  Query 参数校验需要类上的 @Validated。  校验消息也应该稳定，方便前端展示。</li>
  <li>JaCoCo 用来生成单元测试覆盖率报告。它不能证明系统没有缺陷，但能回答另一个问题：这些测试到底执行了哪些代码。一、父 POM 统一接入&lt;plugin&gt;    &lt;groupId&gt;org.jacoco&lt;/groupId&gt;    &lt;artifactId&gt;jacoco-maven-plugin&lt;/artifactId&gt;    &lt;executions&gt;        &lt;execution&gt;            &lt;id&gt;prepare-agent&lt;/id&gt;            &lt;goals&gt;&lt;goal&gt;prepare-agent&lt;/goal&gt;&lt;/goals&gt;        &lt;/execution&gt;        &lt;execution&gt;            &lt;id&gt;report&lt;/id&gt;            &lt;phase&gt;test&lt;/phase&gt;            &lt;goals&gt;&lt;goal&gt;report&lt;/goal&gt;&lt;/goals&gt;        &lt;/execution&gt;    &lt;/executions&gt;&lt;/plugin&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/pom.xml测试前注入 agent，测试阶段生成报告，所有子模块共享同一套规则。二、看报告要看路径覆盖率报告比一个总分更有价值：  核心业务分支是否被测试触达。  异常路径是否被覆盖。  大段未覆盖代码是风险还是配置代码。  高覆盖测试是否只是执行了代码而没有断言。三、与 Surefire 配合项目在 Surefire 中使用 @{argLine} 晚绑定，保留 JaCoCo 注入的 agent 参数，同时加入 JDK 21 动态 agent相关参数。插件配置顺序不当，覆盖率可能悄悄变成 0。四、避坑点  不要为了覆盖率写无意义测试。  覆盖率 100% 不等于所有行为正确。  集成测试和单元测试的覆盖语义不同。  报告要和构建产物一起归档，否则查历史时会缺证据。五、经验总结JaCoCo 是测试证据生成器。它帮助找到盲区，但测试质量仍然取决于断言和场景设计。</li>
  <li>OpenFeign 的 lb://service-provider 最终要经过 Spring Cloud LoadBalancer。它每次都去拉服务实例列表，会让调用链路多出一层不确定性。引入Caffeine 后，实例列表可以在本地缓存一小段时间。一、为什么是 CaffeineCaffeine 是 JVM 生态里成熟的高性能本地缓存，Spring Cloud LoadBalancer 对它有官方支持。相比自己写 Map 和过期逻辑，直接使用适配实现更稳。二、收益缓存服务实例列表可以带来三个变化：  降低注册中心客户端压力。  降低服务间调用前的元数据获取开销。  注册中心短暂抖动时，仍有机会使用最近一次实例列表。三、缓存时间不能太长实例列表缓存的 TTL 需要和发布频率平衡。太长会导致实例下线后仍被调用；太短则缓存收益不明显。滚动发布频繁的集群，更要确认客户端服务发现的心跳和缓存刷新节奏。四、缓存不能替代健康检查本地缓存只知道“上次看到的实例”，不知道实例当前是否健康。调用失败后的重试、负载均衡策略和下游健康状态，仍然要由完整链路共同保障。五、经验总结这是微服务里很典型的空间换时间：用一点本地内存，减少每次远程调用前的重复发现成本。小优化不炫技，但链路长了以后效果很实在。</li>
  <li>微服务项目拆模块时，最容易走两个极端：一是所有东西都塞进公共模块，二是每个服务重复复制一份代码。这个项目最终采用的边界是：公共模块只放稳定契约和横切能力，不替业务服务决定运行时技术栈。一、模块划分service-common      公共契约和横切能力service-provider    业务数据和核心接口service-consumer    Feign 消费方service-gateway     唯一流量入口service-mail        邮件领域服务MP-Generator        独立代码生成工具MP-Generator 没放进父 POM 聚合，因为它是开发工具，不是线上服务。业务构建不应该因为一个代码生成器而增加数据库驱动等额外依赖。二、service-common 里放什么当前公共模块包含：  ApiResponse 统一响应  全局异常处理  Web 接口日志切面  DTO  共享 Feign 契约  API 路径自动配置这些都属于多个服务共同遵守的契约，或者和具体业务无关的横切能力。三、公共模块不能背太多运行时依赖一个典型错误是把 Web、Swagger、数据库、Sentinel 全塞进 common。这样所有依赖它的服务都会被迫继承这些依赖。这个项目后来重构成：            能力      声明位置                  Web 容器      provider、consumer、mail              OpenFeign starter      consumer              Swagger UI      各文档服务或 gateway              数据库和邮件      对应业务模块              WebFlux      gateway      公共模块只保留源码直接使用的编译 API，例如 Spring Web 注解、Feign Core、Validation、SpringDoc common。四、Gateway 不依赖 commonservice-gateway 是 WebFlux 应用，service-common 里的 @RestControllerAdvice 和 WebMVC 切面不能直接复用。如果为了一个统一响应结构把WebMVC 依赖带进 Gateway，很容易出现 Web 栈冲突。所以 Gateway 独立实现：  WebFlux 错误处理器  全局请求日志过滤器  Sentinel Gateway 适配  自己的统一 JSON 响应 record五、自动装配边界公共组件通过 Spring Boot 自动装配文件注册：META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports业务服务引入 service-common 后，不需要手动 @Import。但自动装配也应该克制，只注册真正全局生效的组件。</li>
  <li>MySQL 是这套系统的业务数据后端，保存用户、商品、订单、订单明细和邮件记录。微服务拆分后，数据库仍然承担“事实来源”的角色，接口返回只是数据的某种投影。一、数据源配置spring:  datasource:    driver-class-name: com.mysql.cj.jdbc.Driver    url: jdbc:mysql://127.0.0.1:3306/spring_cloud_alibaba?serverTimezone=Asia/Shanghai&amp;sslMode=REQUIRED  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/application-prod.yaml开发环境和生产环境只有地址、端口和认证配置不同，数据库命名和表结构保持同一套约定。二、表结构跟随业务模块主要表包括：  t_user  t_goods  t_order  t_order_detail  t_mail_log订单主表和明细保存在同一事务里，邮件发送则先落库待发送状态，再更新成功或失败。这说明数据库不只是 CRUD 存储，也参与业务一致性。三、访问原则  MySQL 只被 Provider 和 Mail 直接访问。  Gateway 和 Consumer 不直连业务库。  跨服务数据通过接口契约获取，不跨库 join。  内部字段不直接暴露为 API 模型。四、避坑点  连接地址、账号和密码属于环境差异，不应复制到公共配置。  敏感密码使用 Jasypt 密文，密钥另从环境注入。  逻辑删除会影响唯一索引和查询条件。  当前仓库没有数据库初始化 SQL，换环境前要先准备表结构。五、经验总结MySQL 是系统事实的落点。让数据库访问收敛在具体业务服务内，服务边界才不会被一条跨模块 SQL 悄悄打穿。</li>
  <li>create_time 和 update_time 不应该依赖每个业务方法手动赋值。人工赋值一定会漏，而且不同开发写法可能不同。一、实体标记填充时机@TableField(value = "create_time", fill = FieldFill.INSERT)private LocalDateTime createTime;@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)private LocalDateTime updateTime;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/entity/User.java含义：  新增时填充创建时间和更新时间  更新时刷新更新时间二、实现 MetaObjectHandler@Componentpublic class AuditMetaObjectHandler implements MetaObjectHandler {    @Override    public void insertFill(MetaObject metaObject) {        this.strictInsertFill(metaObject, "createTime",                LocalDateTime.class, LocalDateTime.now());        this.strictInsertFill(metaObject, "updateTime",                LocalDateTime.class, LocalDateTime.now());    }    @Override    public void updateFill(MetaObject metaObject) {        this.strictUpdateFill(metaObject, "updateTime",                LocalDateTime.class, LocalDateTime.now());    }}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/config/AuditMetaObjectHandler.javastrictInsertFill 可以保留已有值，不会无脑覆盖业务已经设置的时间。三、时区项目统一使用 Asia/Shanghai：LocalDateTime.now(ZoneId.of("Asia/Shanghai"))  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/config/AuditMetaObjectHandler.java只改数据库连接参数不够，Java 代码里生成的时间也要有明确时区语义。四、验证新增一条记录：create_time = 当前时间update_time = 当前时间再更新同一条记录：create_time 不变update_time 刷新五、注意自动填充只对 MyBatis-Plus 认识的实体写入生效。手写 SQL、批量导入脚本、数据库默认值都要另行核对。</li>
  <li>单表 CRUD 代码很适合生成器处理。这个项目保留了一个独立的 MP-Generator 模块，用 MyBatis-Plus 的 FastAutoGenerator根据数据库表生成 Entity、Mapper、Service、ServiceImpl 和 XML。一、为什么独立成模块代码生成器只服务开发阶段，不应该进入线上服务依赖。独立模块有几个好处：  不污染业务服务依赖。  可以单独运行 main 方法。  数据库连接配置可以放本地忽略文件。  生成策略修改不影响业务构建。二、配置外置生成器不把数据库连接写死在 Java 代码里，而是读取：src/main/resources/generator.properties仓库里只保留模板：generator.properties.template本地真实配置加入 .gitignore。这样换数据库、换表、换输出包名时，只改配置文件。常用配置：db.url=jdbc:mysql://host:3306/db?...db.username=your-userdb.password=your-passwordpackage.parent=com.zjc.providergenerator.tables=t_user,t_goods,t_ordergenerator.tablePrefix=t_  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/MP-Generator/src/main/resources/generator.properties.template三、输出目录要稳定生成器通过 CodeGenerator.class 的位置反推模块根目录，再拼出：MP-Generator/src/main/java这样无论从父目录还是模块目录运行，输出都不会跑到错误位置。四、生成策略当前策略是：  实体使用 Lombok @Data  字段显式加 @TableField  保留 @Serial serialVersionUID  Mapper 文件名以 Mapper 结尾  生成 XML 的 BaseResultMap 和 BaseColumnList  不生成 Controller不生成 Controller 是刻意选择。Controller 承担 API 契约、参数校验和文档注解，通常需要人工设计；Entity、Mapper、基础 Service更适合生成。五、生成后要迁移生成的 Service 依赖 MyBatis-Plus 的 extension 和 Spring，而这些依赖属于业务模块，不属于生成器模块。所以生成后要把代码迁移到实际业务模块，再按当前分层补DTO、转换器和测试。</li>
  <li>这套微服务没有把 Java 版本当成随手可选的参数，而是明确固定在 Java 21。它决定了编译产物、Spring Boot 4 的运行要求、GC行为、虚拟线程能力，以及测试工具链要支持的字节码版本。一、版本只写在一处&lt;properties&gt;    &lt;java.version&gt;21&lt;/java.version&gt;    &lt;maven.compiler.source&gt;21&lt;/maven.compiler.source&gt;    &lt;maven.compiler.target&gt;21&lt;/maven.compiler.target&gt;&lt;/properties&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/pom.xml父 POM 统一约束版本，业务模块不再各自声明。这对多模块项目很重要：一旦某个模块悄悄用不同字节码版本编译，部署时经常要到运行期才暴露。二、它影响的不只是语法Java 21 在这个项目里至少承担四件事：  支撑 Spring Boot 4.1 和当前依赖链的最低运行要求。  让 JaCoCo、Mockito、ByteBuddy 这类字节码工具明确适配目标。  保留使用虚拟线程、record、模式匹配等语言能力的空间。  让所有服务和 MP-Generator 共享同一套 JDK 认知，减少本机环境差异。三、项目里的取舍项目没有为了“新”而全面使用 Java 21 语法，而是先把它作为稳定基线。业务代码里更多还是清晰的面向对象写法；record主要出现在网关这类边界模型上。这个顺序比较稳：先统一运行环境，再逐步吸收语言特性。四、避坑点  本机 java -version 和 Maven 使用的 JDK 可能不是同一个。  IDEA 的 Project SDK、Module Language Level、Maven Runner JDK 要一致。  升级依赖时要确认其是否支持 Java 21 字节码。  打包后的机器也要使用 JDK 21+，不要只保证编译机正确。五、经验总结Java 21 是整套工程的地基。它带来的价值不是某个单点语法，而是让框架、构建、测试和部署都围绕同一个运行时版本形成闭环。</li>
  <li>当 lb://service-provider 或 Feign 通过服务名发起调用时，总要有人回答一个问题：这个服务当前有多个实例，这次选哪一个？在这套项目里，答案由Spring Cloud LoadBalancer 给出。一、作为调用链的一环引入&lt;dependency&gt;    &lt;groupId&gt;org.springframework.cloud&lt;/groupId&gt;    &lt;artifactId&gt;spring-cloud-starter-loadbalancer&lt;/artifactId&gt;&lt;/dependency&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-consumer/pom.xmlConsumer 和 Gateway 都引入 LoadBalancer。Gateway 根据路由转发，Feign 根据服务名调用，两者最终都需要客户端侧选择实例。二、客户端负载均衡的含义它不是一台独立的中转机器，而是嵌在调用方进程里的选择逻辑。调用方从 Nacos 拿到实例列表，再按负载均衡策略选一个实例发起连接。这带来两个特点：  少一跳集中式转发组件。  调用方需要维护实例缓存和选择策略。三、实例列表需要缓存服务实例列表来自注册中心，但每次调用都实时拉取会把 Nacos 打成热点。项目为 LoadBalancer 引入 Caffeine缓存，在“实例感知及时性”和“调用性能”之间取平衡。四、避坑点  服务下线后，客户端可能短时间内仍拿到旧实例，要结合重试和超时处理。  实例数很少时，负载均衡效果天然有限。  本地调试多实例时，先确认端口和注册元数据。  Gateway 里的 lb:// 和 Feign 里的服务名都依赖服务发现健康数据。五、经验总结LoadBalancer 是服务发现和真实请求之间的桥。它不显眼，但决定了服务名调用能否稳定落到一个具体实例上。</li>
  <li>Spring Boot Mail 提供的是 SMTP 发送抽象。项目把它封装在独立的 service-mail 中，让邮件成为微服务能力，而不是散落在各业务服务里的工具方法。一、服务封装@Resourceprivate JavaMailSender mailSender;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-mail/src/main/java/com/zjc/mail/service/impl/MailSendServiceImpl.java业务代码不直接处理 SMTP 协议，而是使用 JavaMailSender 发送纯文本或 HTML 邮件。SMTP 地址、端口、认证和编码由环境 Profile管理。二、发送不只是调用 send邮件服务的流程是：  校验收件人、抄送、密送格式。  写入一条待发送记录。  发送纯文本或 HTML 邮件。  根据结果更新成功或失败状态。这样失败可追踪，也能统计发送结果。三、环境隔离开发环境使用 MailHog，不向真实邮箱发信；生产环境连接真实 SMTP 服务，认证信息通过加密配置和环境注入管理。这避免了测试邮件骚扰真实用户。四、避坑点  SMTP 服务商通常有频率限制。  邮件发送失败不应让整个业务事务失控，要明确边界。  HTML 邮件要注意样式兼容和内容安全。  发件人、回复地址、认证账号可能不是同一个概念。五、经验总结Spring Boot Mail 解决协议细节，独立 Mail 服务解决系统边界。两者叠加后，邮件才从“发出去就行”变成可观测、可审计的基础能力。</li>
  <li>Actuator 是四个可运行服务共同引入的观测基础。它提供运行端点，也为链路追踪相关自动装配提供入口。一、统一依赖&lt;dependency&gt;    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;    &lt;artifactId&gt;spring-boot-starter-actuator&lt;/artifactId&gt;&lt;/dependency&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-consumer/pom.xmlProvider、Consumer、Gateway、Mail 都使用它。观测能力不是某个服务的专属功能，所有参与请求链路的服务都应该暴露一致的运行信息。二、它关注应用，不关注业务Actuator 更适合回答：  进程是否活着。  依赖是否异常。  请求链路是否开启导出。  运行时指标是否可采集。业务层面的用户数、订单量，仍应由业务接口和数据库统计提供。三、与链路追踪配合项目中的 spring-boot-starter-zipkin 基于 Micrometer Tracing 和 Brave 导出链路。Actuator 管理模型让这类观测能力可以按Starter 统一装配，不需要每个服务手写。四、避坑点  生产环境要控制暴露端点和权限。  健康检查和业务可用性不是完全等价。  指标采集会增加少量开销，采样和暴露范围要规划。  内部管理端点不应随 Gateway 公开暴露。五、经验总结Actuator 是服务的体检入口。它让“服务注册上去了”和“服务真的能正常工作”之间有了更多判断依据。</li>
  <li>OpenFeign 把 HTTP 服务间调用变成接口声明。Consumer 想调用 Provider，不需要手写 HTTP 客户端、拼接 URL、处理响应反序列化，而是注入一个带注解的接口。一、共享契约@FeignClient(        name = "service-provider",        contextId = "userFeignApi",        fallbackFactory = UserFeignFallbackFactory.class)public interface UserFeignApi {    @GetMapping("/user/{id}")    ApiResponse&lt;UserDTO&gt; getUser(@PathVariable("id") Long userId);}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/api/user/UserFeignApi.java这个接口放在 service-common，调用方复用同一份契约。name 对应 Nacos 服务名，contextId 用于隔离不同 Feign 客户端配置。二、启动扫描@EnableFeignClients(basePackages = {"com.zjc.common.api"})  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-consumer/src/main/java/com/zjc/consumer/ConsumerApplication.javaConsumer 只扫描公共 API 包，不在本地重复定义 Feign 接口。接口路径和 DTO 改动时，依赖方跟随公共模块一起演进。三、Feign 不只是 HTTP 工具在这个项目里，它同时连接：  Nacos 服务名。  LoadBalancer 实例选择。  统一 API 前缀拦截器。  FallbackFactory 降级。  Micrometer Tracing 上下文传播。四、避坑点  超时参数要区分连接超时和读超时。  降级返回不能掩盖真实异常，fallback factory 要记录原因。  服务名冲突或 contextId 缺失会导致配置串线。  Feign 契约属于跨服务 API，不能随手改。五、经验总结OpenFeign 让服务间调用看起来像本地接口调用，但底层仍是远程请求。把契约、超时、降级和链路一起管好，它才真的省心。</li>
  <li>微服务之间不能把下游服务 IP 写死。服务发现负责找到实例，OpenFeign 负责用接口声明的方式发起 HTTP 调用。一、启用 Feign启动类上开启扫描：@EnableFeignClients(basePackages = {"com.zjc.common.api"})  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-consumer/src/main/java/com/zjc/consumer/ConsumerApplication.java这里扫描的是公共模块里的共享 Feign 契约，Consumer 本地不需要重复定义客户端。二、声明客户端@FeignClient(        name = "service-provider",        contextId = "userFeignApi")public interface UserFeignApi {    @GetMapping("/user/{id}")    ApiResponse&lt;UserDTO&gt; getUser(@PathVariable("id") Long userId);}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/api/user/UserFeignApi.javaname 是注册中心里的服务名，Feign 会结合 LoadBalancer 选实例。contextId 用来隔离指向同一个服务的不同客户端配置。三、Controller 直接注入接口@Resourceprivate UserFeignApi userFeignApi;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-consumer/src/main/java/com/zjc/consumer/controller/UserConsumerController.java调用时看起来像本地方法：return userFeignApi.getUser(id);  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-consumer/src/main/java/com/zjc/consumer/controller/UserConsumerController.java实际发生的是：Consumer  -&gt; Nacos 发现 service-provider  -&gt; LoadBalancer 选择实例  -&gt; Feign 发 HTTP  -&gt; Provider 返回 ApiResponse四、返回类型要一致Feign 接口和服务端 Controller 使用同一个 DTO 和 ApiResponse。两端共享契约放在 service-common，避免手写 Map 后字段名不一致。五、调试入口Provider 提供了 /port 接口，Consumer 暴露：/feign/port多次调用如果返回不同端口，可以验证多实例负载均衡是否生效。</li>
  <li>用户、商品这类单表资源很适合抽象成统一 CRUD 形状。这个项目的用户接口是：GET    /user/{id}GET    /user/listGET    /user/pagePOST   /userPUT    /userDELETE /user/{id}由于公共模块会自动追加 /api/v1/provider 前缀，Controller 里只写资源路径。一、查询单个@GetMapping("/user/{id}")public ApiResponse&lt;UserDTO&gt; getUser(@PathVariable Long id) {    return ApiResponse.success(userConverter.entityToDto(userService.getById(id)));}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/controller/UserController.java查询不到时返回 success=true、data=null。这种设计表示“请求处理成功，但没有这条资源”。二、新增@PostMapping("/user")public ApiResponse&lt;UserDTO&gt; add(@Valid @RequestBody UserDTO dto) {    User user = userConverter.dtoToEntity(dto);    userService.save(user);    return ApiResponse.success(userConverter.entityToDto(user));}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/controller/UserController.javasave 后实体会带上生成的主键，再转换成 DTO 返回。三、更新和删除要看影响行数boolean updated = userService.updateById(userConverter.dtoToEntity(dto));return updated        ? ApiResponse.success()        : ApiResponse.failure(ApiResponseEnum.NOT_FOUND);  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/controller/UserController.java更新和删除不能只看 HTTP 请求是否到达。updateById 和 removeById 返回 false 时，说明没有命中有效记录，应该返回业务失败。四、逻辑删除removeById 实际执行的是逻辑删除。MyBatis-Plus 会根据配置把 is_deleted 置为已删除，后续查询自动过滤。五、统一约定  入参 DTO 校验  出参 DTO  返回 ApiResponse  更新和删除返回结果反馈  数据访问细节留在 Service 和 Mapper</li>
  <li>MyBatis-Plus 是 Provider 和 Mail 的数据访问组件。它没有替代数据库设计，而是把单表CRUD、分页、逻辑删除、审计填充这类通用模式标准化，让业务代码集中处理业务规则。一、分页拦截器@Beanpublic MybatisPlusInterceptor mybatisPlusInterceptor() {    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();    interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));    return interceptor;}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/config/MybatisPlusConfig.java分页不是传一个 Page 对象就自动生效，必须注册拦截器。这里同时指定 MySQL 方言，让 SQL 改写有明确目标。二、通用规则放在配置里项目统一配置：  Mapper XML 扫描路径。  实体别名包。  下划线转驼峰。  雪花 ID。  isDeleted 逻辑删除字段。这些规则是数据访问层契约，写一次比每个业务模块手写一遍更可靠。三、分层使用方式Entity 只留在服务内部，接口层使用 DTO。Mapper 继承 BaseMapper，Service 继承 IService，通用能力直接复用；订单主表明细这类复杂写入，再由业务Service 用事务组织。四、避坑点  逻辑删除配置里写的是实体属性名，不是数据库列名。  分页返回 DTO 时不能只拷贝 records，还要保留总数和页码。  复杂查询和批量规则要警惕性能，通用方便不等于无限安全。  代码生成器生成的是起点，不是最终业务模型。五、经验总结MyBatis-Plus 的定位是减少重复数据访问代码。这个项目把它约束在 Provider 和 Mail 内部，公共模块不携带 ORM，边界也比较干净。</li>
  <li>业务数据通常不允许物理删除。订单、用户、邮件记录一旦删除，审计和问题排查就没有依据。这个项目统一使用逻辑删除。一、全局配置mybatis-plus:  global-config:    db-config:      logic-delete-field: isDeleted  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/application-dev.yaml这里的 isDeleted 是 Java 实体属性名，不是数据库列名。实体中再通过 @TableField("is_deleted") 映射数据库列。二、删除变成更新调用：userService.removeById(id);  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/controller/UserController.java实际效果是把对应记录的删除标记置为已删除，而不是执行 DELETE。三、查询自动过滤配置生效后，list、page、getById 等通用查询会自动追加未删除条件。业务代码不需要每个 Wrapper 都手写：.eq(User::getIsDeleted, 0)四、注意字段命名这个项目曾把配置误写成数据库列名 is_deleted，后来修正为属性名 isDeleted。这类错误不一定马上报错，但逻辑删除可能不生效。五、设计取舍优点：  保留历史数据  便于审计  删除操作可恢复代价：  查询条件更复杂  唯一索引需要考虑已删除数据  需要定期归档超大表</li>
  <li>这个项目的数据层采用很朴素的三层：Entity      对应数据库表Mapper      继承 BaseMapperService     继承 IServiceServiceImpl 继承 ServiceImpl这不是为了套层次，而是把职责说清楚：Entity 不出模块，Mapper 负责数据库访问，Service 承载业务语义。一、Entity实体类用 @TableName 映射表，用 @TableId 指定主键策略：@TableName("t_user")public class User {    @TableId(value = "user_id", type = IdType.ASSIGN_ID)    private Long userId;}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/entity/User.java分布式部署下，自增 ID 容易受单库限制，所以这里使用雪花 ID。二、Mapperpublic interface UserMapper extends BaseMapper&lt;User&gt; {}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/mapper/UserMapper.java单表 CRUD 直接继承 BaseMapper，不手写重复 SQL。复杂查询再通过 Wrapper 或 XML 扩展。三、Servicepublic interface UserService extends IService&lt;User&gt; {}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/service/UserService.javaService 层不是简单转发 getById。它更适合承载：  多表一致性  业务校验  事务边界  领域语义如果只是单表查询，Controller 可以直接调用通用 Service；一旦出现订单和明细这种多表关系，就必须收进 Service 事务方法。四、逻辑删除字段实体中保留 isDeleted 字段，并交给 MyBatis-Plus 统一处理。这个字段属于数据内部状态，不会暴露到 DTO。五、注意  Entity 不作为 API 契约返回。  Mapper 不写业务规则。  Service 不依赖 Controller 上下文。  分页插件必须配置，否则 Page 只是普通参数，不会真正改写 SQL。</li>
  <li>很多刚用 MyBatis-Plus 的人会以为传入 Page 就自动分页，其实还差一步：注册分页拦截器。一、配置拦截器@Beanpublic MybatisPlusInterceptor mybatisPlusInterceptor() {    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();    interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));    return interceptor;}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/config/MybatisPlusConfig.java没有这个 Bean，分页对象不会触发 SQL 改写，查询仍然可能把全表数据查出来。二、Service 调用Page&lt;User&gt; page = userService.page(new Page&lt;&gt;(current, size));  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/controller/UserController.java分页插件会在执行时补充 LIMIT，并执行 count 查询。三、把 Entity 分页转换成 DTO 分页不能只转换 records，还要保留分页元信息：Page&lt;UserDTO&gt; result = new Page&lt;&gt;(        page.getCurrent(),        page.getSize(),        page.getTotal());result.setRecords(userConverter.entityListToDtoList(page.getRecords()));  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/controller/UserController.java这样返回结构里既有当前页数据，也有总数、页码和每页数量。四、参数必须校验分页参数不是随便传：current &gt;= 1size 1 到 100current=0 会导致奇怪偏移，size=100000 会把分页变成拖库。五、踩坑点  忘记分页插件：接口看起来能跑，但数据量一大性能崩。  只返回 records：前端无法计算总页数。  不限制 size：调用方可以一次请求海量数据。  明确数据库类型：不同数据库的分页方言不同。</li>
  <li>接口直接返回 Entity 很省事，但代价是数据库结构被泄漏成 API 契约。以后加一个内部字段，前端可能收到不该看到的数据；改一个列名，接口也可能跟着变。一、分离原则Entity：数据库映射，只在服务模块内部使用DTO：对外契约，放在 common，供服务和调用方共享用户实体可能有：is_deletedupdate_time内部状态字段DTO 只保留调用方需要的内容，例如用户名、昵称、邮箱、状态、创建时间。二、DTO 放哪里这个项目的 DTO 放在 service-common。原因是 Consumer 通过 Feign 调用 Provider，两边需要同一份契约。这带来一个边界要求：DTO 应该稳定、简单、可序列化，不要把各种模块内部实现都放进去。三、转换发生在哪里Controller 层负责接收请求并返回 DTO，Service 内部可以继续使用 Entity。转换逻辑由 MapStruct Converter 处理：UserDTO toDto = userConverter.entityToDto(user);  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/converter/UserConverter.java新增数据时再反向转换：User user = userConverter.dtoToEntity(dto);  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/java/com/zjc/provider/converter/UserConverter.java四、DTO 上的校验DTO 同时承担请求体校验：@NotBlank(message = "登录账号不能为空")@Size(min = 3, max = 20)private String username;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/dto/UserDTO.java这样契约、校验和文档都围绕同一个对象，避免接口定义和校验规则分叉。五、收益  数据库结构变化不直接破坏接口。  内部字段不会默认序列化出去。  Feign 两端共享稳定契约。  OpenAPI 文档展示的是真实对外模型。</li>
  <li>后端接口如果没有统一响应结构，前端就要面对各种形状的数据：有的直接返回对象，有的返回字符串，出错时可能返回默认错误页。这个项目的做法是所有REST 接口都返回 ApiResponse&lt;T&gt;。一、响应结构{  "success": true,  "code": 0,  "message": "操作成功",  "data": {},  "timestamp": 1723017600000}字段含义很直接：  success：快速判断业务是否成功。  code：细粒度错误码。  message：给调用方看的提示。  data：业务数据。  timestamp：响应生成时间。二、错误码分段0      成功-1     通用失败100    参数校验失败101    请求体格式错误102    资源不存在401    未认证403    无权限500    服务内部错误503    服务不可用这个分段刻意和 HTTP 状态语义接近，但不是简单等同。业务服务内部失败时 HTTP 可以是 200，响应体里的 code 再表达具体业务结果。三、用接口承接自定义错误码标准错误码放在 ApiResponseEnum 中。业务模块要扩展自己的错误码时，不需要改公共枚举，只要实现 ErrorCode：public interface ErrorCode {    int code();    String message();}  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/constant/ErrorCode.java然后业务异常可以直接接收这个接口：throw new BusinessException(ApiResponseEnum.USER_NOT_FOUND);  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/exception/BusinessException.java这样公共模块只依赖抽象，不需要知道每个业务模块有哪些错误码。四、静态工厂让调用侧更清楚ApiResponse 提供了这些入口：ApiResponse.success(data);ApiResponse.successMessage("操作成功");ApiResponse.failureMessage("参数非法");ApiResponse.failure(ApiResponseEnum.NOT_FOUND);  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/src/main/java/com/zjc/common/web/ApiResponse.java这里有个小设计点：successMessage 和 failureMessage 显式带 Message，避免当 T=String 时和 success(Object)、failure(Object) 产生重载歧义。五、避坑点  不要把数据库实体字段直接塞进错误提示。  未预期异常不要把完整堆栈返回给前端。  success 字段判断要防 null，所以内部使用 Boolean.TRUE.equals(success)。  前端不能只判断 HTTP 200，还要读取 success 和 code。</li>
  <li>Spring Cloud 本身不是一个功能点，而是一组微服务协作规范。这个项目用它统一管理Gateway、OpenFeign、LoadBalancer、CircuitBreaker 等依赖版本，让这些组件在同一套版本语义下工作。一、BOM 固定协作版本&lt;dependency&gt;    &lt;groupId&gt;org.springframework.cloud&lt;/groupId&gt;    &lt;artifactId&gt;spring-cloud-dependencies&lt;/artifactId&gt;    &lt;version&gt;2025.1.2&lt;/version&gt;    &lt;type&gt;pom&lt;/type&gt;    &lt;scope&gt;import&lt;/scope&gt;&lt;/dependency&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/pom.xml父 POM 导入 BOM 后，业务模块只声明 spring-cloud-starter-*，不手写具体版本。Spring Cloud 的组件之间有版本兼容关系，靠子模块各自挑版本很容易挖坑。二、项目实际使用的协作能力  服务注册发现：Nacos Discovery。  声明式调用：OpenFeign。  客户端负载均衡：Spring Cloud LoadBalancer。  统一入口：Spring Cloud Gateway。  熔断抽象：Spring Cloud CircuitBreaker 接 Sentinel 实现。  文档聚合：Gateway 组合各服务的 SpringDoc OpenAPI 地址。这些能力分别落在不同模块里，但版本和语义由 Spring Cloud 统一起来。三、Alibaba 生态同样受版本约束项目同时导入 Spring Cloud Alibaba BOM。Nacos、Sentinel 以及 Sentinel CircuitBreaker 相关依赖都从这里获得版本。SpringBoot、Spring Cloud、Spring Cloud Alibaba 三者的版本关系是这个项目依赖治理的核心。四、避坑点  不要只升级一个 Spring Cloud 组件，先看整体版本矩阵。  Gateway 是 WebFlux 模型，不能照搬 MVC 的异常处理方式。  OpenFeign 背后还需要 LoadBalancer 负责选实例。  熔断、限流、降级是三件相关但不同的事，规则不要混在一起。五、经验总结Spring Cloud 提供的是“协作契约”。这个项目把契约固定在父 POM，再把 Gateway、Feign、Nacos、Sentinel放到合适的模块里，既保持组合能力，也避免每个服务重复配置。</li>
  <li>如果把 Nacos、Gateway、OpenFeign 看成微服务的协作组件，Spring Boot 就是每个服务自己的底座。它负责自动装配、配置体系、Web 服务器、Actuator、测试支持和打包方式。一、父级版本统一管理&lt;parent&gt;    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;    &lt;artifactId&gt;spring-boot-starter-parent&lt;/artifactId&gt;    &lt;version&gt;4.1.0&lt;/version&gt;&lt;/parent&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/pom.xml四个可运行服务和公共模块都挂在这个父工程下。版本、插件默认行为、编码和资源过滤由父级统一决定，避免每个模块自己拼一套。二、Starter 表达能力边界项目通过 Starter 描述每个服务的能力：  spring-boot-starter-web：Provider、Consumer、Mail 提供 REST 接口。  spring-boot-starter-actuator：观测端点和链路自动装配。  spring-boot-starter-mail：邮件服务使用 SMTP 抽象。  spring-boot-starter-zipkin：链路导出。这种写法让模块职责直接体现在依赖里。看到一个服务的 POM，基本能猜到它对外提供什么能力。三、Profile 是配置组织核心Spring Boot 的 Profile 被用来拆分 dev、prod 和公共能力配置。应用名、端口、默认环境和 include 留在主配置，环境差异下沉到Profile，避免同一份 YAML 越写越难维护。四、打包方式&lt;plugin&gt;    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;    &lt;artifactId&gt;spring-boot-maven-plugin&lt;/artifactId&gt;&lt;/plugin&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/pom.xml项目采用 Fat JAR：一个可运行服务一个完整部署包。公共库已经进包内，部署时不需要再维护外部 lib 目录和类加载路径。五、经验总结Spring Boot 在这里的价值是标准化。每个服务虽然职责不同，但配置结构、启动方式、测试依赖和打包方式保持一致，微服务的复杂度才会留在架构层，而不是散落在运维细节里。</li>
  <li>Nacos 在这套系统里承担“服务台账”的角色：服务启动时注册自己的地址，调用方通过服务名拿到可用实例。它不参与业务请求转发，真正的转发由 Gateway、OpenFeign 和 LoadBalancer 完成。一、公共认证配置spring:  cloud:    nacos:      discovery:        username: nacos        password: nacos      config:        enabled: false  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/src/main/resources/config/application-nacos.yaml这份公共配置把边界说得很清楚：Discovery 开启，Config 显式关闭。也就是说，当前项目不用 Nacos 下发业务配置，配置仍随服务包内的Profile 分发。二、地址按环境拆分application-dev.yaml 和 application-prod.yaml 只维护 Nacos 地址差异；账号、密码和“关闭 Config”的规则放在公共Profile。这样环境差异不会把公共规则复制出多份。三、服务名是协作契约Provider、Consumer、Gateway、Mail 都通过 spring.application.name 注册。Gateway 的 lb://service-provider、Feign 的name = "service-provider" 依赖的都是这个名字。因此服务名一旦确定，就不能随意改。它不是包名，也不是工程显示名，而是运行期服务发现契约。四、避坑点  服务注册成功不代表业务健康，健康状态要结合 Actuator。  Nacos 地址、网络分区、认证失败表现不同，排查时要分层看。  只调用服务不需要感知实例 IP，调试时再查 Nacos 控制台。  使用 Nacos Config 前要先想清楚配置刷新边界，当前项目刻意没有使用。五、经验总结Nacos 让多实例部署从“配置 IP 清单”变成“按服务名发现”。这个项目保持它只做注册与发现，配置仍由本地 Profile 管理，边界简单，问题也更容易定位。</li>
  <li>微服务项目最怕的不是依赖少，而是每个模块各自声明一套版本。时间一长，provider 用一个版本的库，consumer用另一个版本，本地能跑，部署后行为却不一样。这个项目一开始就把版本治理放进父 POM，子模块只声明“我要用什么”，不关心“它是什么版本”。一、父工程负责两件事&lt;packaging&gt;pom&lt;/packaging&gt;&lt;modules&gt;    &lt;module&gt;service-common&lt;/module&gt;    &lt;module&gt;service-provider&lt;/module&gt;    &lt;module&gt;service-consumer&lt;/module&gt;    &lt;module&gt;service-gateway&lt;/module&gt;    &lt;module&gt;service-mail&lt;/module&gt;&lt;/modules&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/pom.xmlpackaging=pom 表示父工程不产出业务 JAR，只负责聚合和治理。执行 Maven 命令时，它会按依赖关系先构建 service-common，再构建依赖它的业务服务。第二件事是统一版本属性：&lt;properties&gt;    &lt;java.version&gt;21&lt;/java.version&gt;    &lt;spring-boot.version&gt;4.1.0&lt;/spring-boot.version&gt;    &lt;spring-cloud.version&gt;2025.1.2&lt;/spring-cloud.version&gt;    &lt;spring-cloud-alibaba.version&gt;2025.1.0.0&lt;/spring-cloud-alibaba.version&gt;    &lt;mybatis.plus.version&gt;3.5.17&lt;/mybatis.plus.version&gt;&lt;/properties&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/pom.xml版本升级时只需要改父 POM，所有子模块同步生效。二、BOM 只管版本，不引入依赖父 POM 通过 dependencyManagement 导入 BOM：&lt;dependency&gt;    &lt;groupId&gt;org.springframework.cloud&lt;/groupId&gt;    &lt;artifactId&gt;spring-cloud-dependencies&lt;/artifactId&gt;    &lt;version&gt;${spring-cloud.version}&lt;/version&gt;    &lt;type&gt;pom&lt;/type&gt;    &lt;scope&gt;import&lt;/scope&gt;&lt;/dependency&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/pom.xmlSpring Cloud Alibaba 也同样导入自己的 dependencies BOM。这样子模块里的依赖可以省掉版本号：&lt;dependency&gt;    &lt;groupId&gt;com.alibaba.cloud&lt;/groupId&gt;    &lt;artifactId&gt;spring-cloud-starter-alibaba-nacos-discovery&lt;/artifactId&gt;&lt;/dependency&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-provider/pom.xmldependencyManagement 不会自动把依赖加进 classpath。它的意义是：当子模块真正声明这个依赖时，版本由父工程统一决定。三、三套版本要保持兼容Spring Boot、Spring Cloud、Spring Cloud Alibaba 不是三个独立演进的版本，它们有对应的兼容矩阵。混搭时常见问题是：启动时找不到某个自动配置类、Feign行为异常、Gateway 配置绑定不上。排查这类问题先看三件事：  Boot 和 Cloud 版本是否匹配。  Cloud 和 Alibaba 版本是否匹配。  子模块是否绕过父 POM 写死版本。四、插件也统一管理编译器、Spring Boot 打包、JaCoCo、Surefire 都放在父 POM。好处是新建业务模块时，不需要复制一整套构建配置。&lt;plugin&gt;    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;    &lt;artifactId&gt;spring-boot-maven-plugin&lt;/artifactId&gt;&lt;/plugin&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/pom.xmlservice-common 是普通库模块，不适合打成可执行 JAR，所以它单独设置：&lt;spring-boot.repackage.skip&gt;true&lt;/spring-boot.repackage.skip&gt;  https://github.com/springvortex/spring-cloud-alibaba/blob/release/v1.0.0/service-common/pom.xml五、经验总结  父 POM 管版本和插件，子模块管业务依赖。  BOM 解决版本一致性，不等于自动引入依赖。  公共库和可启动服务要区分打包方式。  多模块项目先构建公共模块，再构建业务模块， Maven Reactor 会自动处理这个顺序。</li>
  <li>这篇博客已经内置 GitHub Pages 自动部署工作流：只要代码推送到 main 分支，Actions 会自动完成 Jekyll 构建、JS/CSS压缩和站点发布。下面按实际部署顺序走一遍，最后是我踩过或者最容易踩的坑。一、准备仓库先把博客源码推送到 GitHub 仓库：git initgit add .git commit -m "init blog"git branch -M maingit remote add origin git@github.com:&lt;用户名&gt;/&lt;仓库名&gt;.gitgit push -u origin main注意：如果想按本文的根路径方式部署，新建仓库名必须使用：&lt;username&gt;.github.io例如 GitHub 用户名是 zjc，仓库名就应该是 zjc.github.io。原因是 GitHub Pages 会把这种命名的用户仓库识别为个人主站点，最终访问地址是：https://&lt;username&gt;.github.io/如果仓库名叫 blog，它会变成项目站点，访问地址是：https://&lt;username&gt;.github.io/blog/这时静态资源都会多出 /blog/ 这个前缀，博客的 baseurl 也必须配置成 /blog，否则样式、搜索和 Service Worker 都可能 404。如果仓库是从别人那里 Fork 来的，先确认：  默认分支是不是 main。  仓库的 Actions 页面有没有被禁用，需要手动点击 Enable workflows。  .github/workflows/pages.yml、Gemfile、package.json、package-lock.json 是否都存在。  本博客不是纯默认 Jekyll 站点，它包含 _plugins/md5_permalink.rb 自定义插件和 Node 压缩脚本，所以部署必须走仓库里的GitHub Actions 工作流。二、开启 GitHub Pages打开 GitHub 仓库：Settings -&gt; Pages -&gt; Build and deployment -&gt; Source选择：GitHub Actions不要选择 Deploy from a branch。这个博客需要在构建阶段执行自定义 Ruby 插件和 esbuild 压缩，直接从分支部署静态文件会绕过这些步骤。设置完成后，pages.yml 工作流才会被 GitHub Pages 正确接收产物。三、理解自动部署流程核心工作流在：.github/workflows/pages.yml关键步骤如下：steps:- name: Setup Ruby  uses: ruby/setup-ruby@v1  with:    ruby-version: "2.7"- name: Setup Node  uses: actions/setup-node@v4  with:    node-version: "22"    cache: npm- name: Build Jekyll site  run: |    bundle install    JEKYLL_ENV=production bundle exec jekyll build --destination _site    npm ci    npm run minify -- _site它的执行顺序是：  检出 main 分支代码。  安装 Ruby 2.7 和 Bundler 依赖。  安装 Node 22 和 npm 依赖。  使用 Jekyll 3.8.5 构建站点到 _site。  使用 esbuild 压缩自定义 JS、CSS 和 service-worker.js。  上传 _site 作为 Pages Artifact。  调用 actions/deploy-pages@v4 发布站点。之后每次推送 main，都会自动触发部署；也可以在：Actions -&gt; Build and deploy Pages -&gt; Run workflow手动触发一次。四、配置自定义域名默认地址是：https://&lt;用户名&gt;.github.io或者：https://&lt;用户名&gt;.github.io/&lt;仓库名&gt;/如果要使用自己的域名，例如 uhaiin.com，在仓库里配置：Settings -&gt; Pages -&gt; Custom domain填写域名后，GitHub 会提示需要添加的 DNS 记录。以根域名绑定 Pages 为例，通常需要给域名服务商配置 GitHub Pages 的 A 记录；如果绑定www 或其他子域名，则配置 CNAME 记录到：&lt;用户名&gt;.github.ioDNS 生效后，回到 Settings -&gt; Pages：  确认 Custom domain 显示绿色可用状态。  勾选 Enforce HTTPS。  等证书签发完成，通常需要几分钟到几十分钟。  具体 DNS 记录值以 GitHub Pages 设置页显示的内容为准，GitHub 官方记录有可能调整，不要盲目抄旧文章里的 IP。五、修改站点域名配置这个博客有两个和地址有关的配置：domainUrl: ''baseurl: ''如果绑定根域名，例如 uhaiin.com，建议这样配置：domainUrl: 'https://uhaiin.com'baseurl: ''domainUrl 主要用于 RSS 和 sitemap 生成完整链接；baseurl 会影响页面资源、菜单、Service Worker 和文章资源路径。如果部署到项目路径，例如：https://&lt;用户名&gt;.github.io/blog/则必须配置：baseurl: '/blog'否则 CSS、JS、搜索索引和 Service Worker 都可能按根路径请求，出现样式丢失或功能异常。六、部署完成后检查打开站点后，建议按下面顺序检查：  首页能正常打开，样式没有丢。  点一篇文章，确认文章 URL 是 32 位 MD5 路径。  文章里的图片能显示，资源路径应该类似 /posts/年/月/日/文件名。  搜索页输入关键词，确认搜索索引可以请求。  代码块语言高亮和复制按钮正常。  文章二维码可以生成，扫码打开的是当前部署地址。  明暗主题切换后刷新页面，偏好还能保留。  浏览器控制台没有 CSS 404、JS 404 或 Service Worker 注册失败。如果某些页面还是旧内容，先强制刷新；仍不更新时，再检查 Service Worker 缓存。七、踩坑点1. Pages Source 选错症状：工作流执行成功，但 Pages 一直是旧页面，或者提示没有部署分支。原因是 Source 选择了 Deploy from a branch。这个博客必须选择：GitHub Actions2. 推送分支不是 main工作流触发条件写的是：on:  push:    branches:      - main如果代码在 master 或其他分支，Actions 不会自动部署。要么把默认分支改成工作流里的分支，要么修改 branches 配置。3. 工作流权限被收紧工作流需要这些权限：permissions:  contents: read  pages: write  id-token: write如果组织或仓库策略把 pages: write、id-token: write 禁掉，部署会在上传或发布阶段失败。需要在仓库或组织层面允许 Pages 写入和OIDC 认证。4. 使用默认 Jekyll 构建，MD5 链接失效这个博客的文章 URL 由 _plugins/md5_permalink.rb 生成。如果绕过仓库里的工作流，让 GitHub 使用默认 Jekyll构建流程，自定义插件不会执行，文章链接就会退回日期路径，首页、RSS、sitemap 和二维码对应关系都会乱掉。5. 文章链接依赖稳定元数据文章链接由 _plugins/md5_permalink.rb 计算，MD5 输入只有三项：发布日期、作者和标题。发布日期会按 UTC+8时区格式化；文章没有单独声明作者时，使用站点作者 zjc。但要注意：不要手工修改 _site 产物，也不要把 _site 提交回仓库。部署产物每次都会重新生成，改源文件才是正确入口。6. 改一个错别字，文章链接会变吗不会。正文、分类、标签、注释、Front Matter 其他字段和空白字符都不参与 MD5 计算，所以修改文章内容不会让链接变化。MD5 输入只有发布日期、作者和标题。这三项是永久链接的身份字段；发布后修改其中任意一项，会生成新链接，旧路径不会被自动重定向。因此发布前只需要确认这三项信息正确。发布后修正错别字、补充示例代码或调整文章结构，都不会影响文章链接。7. 项目路径部署忘记 baseurl如果站点最终运行在：https://&lt;用户名&gt;.github.io/&lt;仓库名&gt;/而 baseurl 还是空字符串，页面会去根路径找静态资源，典型表现是：  页面没有样式。  控制台大量 CSS 404。  搜索接口 404。  Service Worker 注册失败。项目路径部署时，baseurl 必须等于仓库路径。8. 自定义域名 DNS 未生效DNS 配置后可能因为缓存没有立即生效。排查顺序：  用 nslookup 或在线 DNS 查询工具确认记录已经扩散。  回到 GitHub Pages 设置页看 Custom domain 状态。  等待 HTTPS 证书签发。  清理浏览器缓存或换无痕窗口访问。不要在 DNS 未生效时反复删除又绑定域名，反而可能让证书状态更混乱。9. Service Worker 只适合 HTTPSService Worker 要求站点运行在 HTTPS 或 localhost 下。GitHub Pages 默认支持 HTTPS，绑定自定义域名后也要开启 Enforce HTTPS，否则二维码、离线缓存等依赖页面环境的功能可能不稳定。10. 忘记提交 package-lock.jsonActions 里使用的是：npm ci这个命令依赖 package-lock.json。如果锁文件没有提交，CI 会直接失败。压缩依赖版本固定在 package.json 和锁文件里，所以两个文件要一起提交。八、出问题先看 Actions 日志打开：Actions -&gt; Build and deploy Pages点击失败的那次运行，再看具体失败步骤：  Build Jekyll site 失败：多半是 Ruby 依赖、Markdown、Liquid 或插件问题。  npm ci 失败：检查 package.json 和 package-lock.json 是否同步提交。  Upload site 或 Deploy Pages 失败：优先检查 Pages Source、分支和权限。  部署成功但页面异常：打开浏览器开发者工具，看 Network 和 Console 里的 404。只要 Actions 日志里 Build and deploy Pages 全绿，GitHub Pages 侧基本已经完成发布；剩下的问题通常在域名、路径配置或浏览器缓存。</li>
</ul>
