本文合并自 4 篇笔记,点击目录跳转。

目录


可观测性:SkyWalking 链路追踪 / Prometheus + Grafana 指标监控 / 日志体系

一句话速览:可观测性的三大支柱各回答一个问题——Logging 回答”发生了什么”,Metrics 回答”系统整体是否健康”,Tracing 回答”这一次请求卡在哪一环”。三者互补不可替代;同时要记住:可观测性工具本身也是跑在你环境里的软件,有兼容性和资源成本,不是配完就万事大吉

涉及原始记录:07-面试问题/开发问题/01-依赖与配置问题.md P0-02;
07-面试问题/验证问题/03-基础设施与中间件问题.md P2-01

最近修订:2026-08-07

目录


技术点 1:SkyWalking Agent —— 无侵入式链路追踪的原理与代价

难度:⭐⭐ | 掌握要求:Java Agent 字节码增强原理、JDK 强封装与 --add-opens

核心概念

SkyWalking Agent 的”无侵入”是通过 Java Agent 机制(-javaagent 参数)实现的:JVM 启动时先加载 Agent,Agent 用字节码增强(instrumentation)的方式在运行时修改目标类的字节码,在方法调用前后插入埋点逻辑(记录 traceId、耗时等),业务代码完全不需要手写任何埋点代码。这个机制天然要求 Agent 能够用反射去访问、修改目标类的内部结构,包括很多 JDK 内部类。

原理深挖:一条 Trace 是怎么串起来的

链路追踪的数据模型很简单:Trace(一次完整请求链路)由若干 Span(一个环节,如一次 HTTP 调用、一次 DB 查询)组成,Span 之间用 parent-spanId 连成树。难点在跨进程传播:Gateway 收到请求生成 traceId 后,必须在调下游服务时把它带过去——SkyWalking Agent 会自动在 HTTP 请求头里注入 sw8 头(携带 traceId、父 spanId 等),下游服务的 Agent 解析这个头把新 Span 挂到同一棵树上。跨进程上下文传播全靠这个头,这和 Feign 拦截器透传 xid(Seata)、透传用户 Header(鉴权)是完全相同的设计思想——链路上下文必须显式在线程间/进程间搬运,不会自动跟随。

sequenceDiagram
    participant C as 客户端
    participant G as Gateway
    participant L as lease服务
    participant H as house服务
    participant S as SkyWalking OAP

    C->>G: 请求(无 trace 上下文)
    Note over G: Agent 创建 Trace
traceId=T1, span0 G->>L: Feign/HTTP 调用
Header 注入 sw8(T1, span0) Note over L: Agent 创建子 Span
(T1, span1, parent=span0) L->>H: 调用 house
sw8(T1, span1) Note over H: Agent 创建子 Span
(T1, span2, parent=span1) G-->>S: 各 Agent 异步上报 Span 数据 L-->>S: OAP 按 traceId 归并 H-->>S: 还原完整调用链

我们踩过的坑

P0-02:JDK 21 的强封装机制拦住了 Agent 的反射操作

JDK 9 引入的模块系统(Jigsaw)默认对 JDK 内部包做了”强封装”(Strong Encapsulation),未经显式声明,外部代码不能通过反射访问这些内部类的非 public 成员。SkyWalking 9.4.0 版本的部分插件实现是在 JDK 更早期版本的开放策略下写的,没有预料到新版本 JDK 的限制会更严格,遇到 JDK 21 时触发 InaccessibleObjectException,或者导致 Agent 初始化阶段卡顿。

解决方式是在 JVM 启动参数里显式声明”开放”这些内部模块,允许反射访问:

1
2
3
4
JAVA_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED \
--add-opens java.base/java.lang.reflect=ALL-UNNAMED \
--add-opens java.base/java.lang.module=ALL-UNNAMED \
-javaagent:$BASE_DIR/deploy/skywalking/skywalking-agent/skywalking-agent.jar"

--add-opens <module>/<package>=<目标模块> 的含义是”把这个包对指定的目标模块开放,允许深度反射访问”,ALL-UNNAMED 表示对所有未命名模块(也就是传统的 classpath 应用)开放。

必须记住的结论

  • “无侵入式”监控工具的代价是它依赖大量反射/字节码增强能力,JDK 版本升级带来的模块化限制会直接影响它的可用性。引入这类 Agent 工具、同时使用较新的 JDK 版本时,要有意识地检查该工具官方是否明确支持这个 JDK 版本,版本适配问题(尤其是新旧 JDK 的兼容性)几乎是这类工具的常见坑。
  • --add-opens 是绕过 JDK 强封装限制的标准手段,遇到第三方库反射访问 JDK 内部类报 InaccessibleObjectException 时,思路都是类似的:找到具体报错涉及的模块和包名,加对应的 --add-opens 参数,而不是降级 JDK 版本(降级往往有更多连锁影响)。
  • 日志噪音问题(P2-01):Agent 默认日志级别是 INFO,会输出大量插件加载相关的日志,跟业务日志混在一起影响阅读。这是一个小但常见的运维细节:接入任何 Agent/中间件类工具后,第一件事应该顺手检查它的默认日志级别和输出路径,通过配置把它和业务日志分开(比如这里配置 Agent 单独写到 skywalking-api.log),避免污染主日志。
  • 链路追踪跨进程传播的机制(sw8 头)和 Feign 拦截器透传是同一思想:上下文不会自动跟随请求,必须显式注入和解析

一个更宏观的认识:可观测性工具本身也是有”使用成本”的软件

这条记录虽然只对应两个具体问题,但值得提炼一个更普遍的认知:监控/追踪类工具虽然定位是”辅助排障”,但它本身也是运行在同一个 JVM/环境里的软件,会有自己的兼容性问题、资源开销、配置复杂度。接入可观测性工具不是”配完就一定好用”,同样需要像对待业务依赖一样去验证它在当前技术栈组合(这里是 JDK 21)下能不能正常工作。这也是为什么 Sentinel Dashboard 在 JDK 21 下有已知 bug(见 01-微服务治理.md)——中间件生态对最新 JDK 版本的适配,往往会落后于 JDK 本身的发布节奏,引入新 JDK 大版本时要对整条工具链做一次兼容性摸底,而不是只关心业务框架本身。


技术点 2:可观测性三大支柱与 Prometheus 指标监控

难度:⭐⭐ | 掌握要求:三支柱分工、Prometheus 拉模型、四种指标类型

三大支柱的分工

支柱 回答的问题 本项目实现 典型使用时机
Logging(日志) 发生了什么(离散事件的细节) Logback + 文件输出 已知大概位置,要看具体参数/异常栈
Metrics(指标) 系统整体是否健康(聚合数值趋势) Micrometer + Prometheus + Grafana 巡检、告警、容量评估、”是不是变慢了”
Tracing(链路) 这一次请求卡在哪一环(调用链拓扑+耗时) SkyWalking 微服务间互相调用,定位慢/错发生在哪个服务

三者的排查顺序通常是:Metrics 发现异常(QPS 暴跌/错误率上升)→ Tracing 定位到具体服务和环节 → Logging 在该服务里看细节。只依赖日志排障,在微服务架构下等于在十几个服务的日志文件里大海捞针。

Prometheus 的工作模型

Prometheus 与”应用主动上报”的监控方案不同,它是拉(Pull)模型:Prometheus Server 按配置周期(本项目 scrape_interval: 15s)主动抓取每个目标的 /actuator/prometheus 端点。本项目 deploy/docker/prometheus/prometheus.yml 为每个微服务配了一个 job,指标暴露靠的是 Spring Boot Actuator + Micrometer(micrometer-registry-prometheus 依赖),业务代码零侵入。

flowchart LR
    subgraph 微服务
        S1[livin-gateway
/actuator/prometheus] S2[livin-house
/actuator/prometheus] S3[其他服务...] end P[Prometheus Server
每15s拉取] --> S1 & S2 & S3 P --> TS[(内置时序数据库
按指标名+标签存储)] TS --> G[Grafana
PromQL 查询出图] TS --> A[Alertmanager
告警规则触发通知]

四种指标类型是必背概念:

类型 语义 典型例子
Counter 只增不减的计数器 请求总数、异常总数(配 rate() 算 QPS)
Gauge 可增可减的瞬时值 当前线程数、内存占用、连接池活跃连接
Histogram 分桶统计分布 请求耗时分布(算 P95/P99 延迟)
Summary 客户端侧算分位数 类似 Histogram,但分位数在客户端算,无法跨实例聚合

两个实践要点:① “平均值延迟”具有欺骗性——1 个 10 秒的请求和 99 个 10 毫秒的请求平均下来很正常,看 P95/P99 才能发现长尾;② Prometheus 的标签(label,如本项目给每个 job 打的 application 标签)是做维度下钻的关键——“全系统错误率”按 application 一切开,立刻知道是哪个服务的问题。

Grafana 的角色

Grafana 本身不存数据,是可视化 + 告警入口:配置 Prometheus 为数据源,用 PromQL 写查询出图。本项目的 dashboard 配置在 deploy/docker/grafana/dashboards/。学会读三个黄金指标图就能覆盖 80% 的巡检场景:流量(QPS)、错误率、延迟(P95)——这就是 Google SRE 的”四个黄金信号”(再加一个饱和度 Saturation)。


技术点 3:日志体系 —— 结构化输出与 traceId 透传

难度:⭐⭐ | 掌握要求:日志级别语义、MDC 原理、异步日志

核心概念

日志是三大支柱里最”古老”的,但微服务下有两个必须主动解决的问题:

① 日志级别语义要统一ERROR 表示”需要人工介入的异常”、WARN 表示”异常但系统已自愈”(如重试成功)、INFO 是关键业务节点、DEBUG 只在排查时打开。最常见的问题是 ERROR 滥用(业务校验失败也打 ERROR),导致真正的故障淹没在噪音里——ERROR 日志应该是”每条都值得看一眼”的,否则告警无法基于日志搭建。

② 跨线程/跨服务的日志串联:一个请求在 Gateway → lease → house 三个服务各打了若干行日志,没有关联标识就无法拼出完整链路。解法是把 traceId 打进每条日志:

  • MDC(Mapped Diagnostic Context):SLF4J 提供的基于 ThreadLocal 的日志上下文。在请求入口(过滤器/拦截器)把 traceId 塞进 MDC.put("traceId", ...),日志 Pattern 里加 %X{traceId},这个线程后续打的每一行日志都会自动带上它。请求结束必须 MDC.clear()——MDC 底层是 ThreadLocal,线程池复用线程时不清会串数据(ThreadLocal 的坑详见 10-JVM与并发编程.md)。
  • 异步场景要显式传递:MDC 绑在当前线程上,一旦逻辑切到线程池/MQ 消费者/CompletableFuture,traceId 就断了,需要用装饰的线程池(如 TtlExecutors)或在异步入口重新 set。接入了 SkyWalking 的话,其 Agent 的 logback 插件可以自动把 traceId 注入 MDC,免去手写。
  • 异步追加器:高吞吐服务用 Logback 的 AsyncAppender 把”日志 IO”挪到后台线程,避免业务线程阻塞在磁盘写;代价是 JVM 崩溃时队列里未来得及落盘的日志会丢——核心审计类日志不适合走异步。

与三支柱的呼应

日志带上 traceId 之后,三大支柱就真正打通了:Grafana 看板发现错误率突增 → SkyWalking 按时间找到失败 Trace 拿到 traceId → 用 traceId 去日志里精确捞出这次请求在所有服务里的每一行日志。这套”指标 → 链路 → 日志”的下钻路径,是微服务排障的标准动作。


术语表

术语 含义
Java Agent / instrumentation JVM 启动期加载的字节码增强机制,无侵入埋点的实现基础
Trace / Span 一次完整请求链路 / 链路中的一个环节(一次调用、一次查询)
sw8 头 SkyWalking 跨进程传播链路上下文的 HTTP 请求头
三大支柱 Logging(事件细节)/ Metrics(聚合趋势)/ Tracing(调用链)
拉模型(Pull) Prometheus 主动周期抓取目标指标端点的采集方式
Micrometer / Actuator Spring Boot 的指标门面与端点暴露组件,/actuator/prometheus 由此而来
Counter / Gauge / Histogram / Summary Prometheus 四种指标类型:计数/瞬时值/分桶分布/客户端分位数
P95 / P99 95%/99% 分位延迟,发现长尾慢请求的关键指标
四个黄金信号 延迟、流量、错误率、饱和度(Google SRE 提出的监控核心维度)
MDC SLF4J 基于 ThreadLocal 的日志上下文,用于给日志自动附带 traceId
AsyncAppender Logback 异步日志追加器,日志 IO 后台化,崩溃时可能丢尾部日志

容器化部署:Docker Compose 编排

一句话速览:Docker 的心智模型只有一句话——镜像分层 + 容器可写层 + 数据卷,三层各有什么生命周期想清楚了,”插件丢失””数据丢失””重建后配置还原”这类问题全部可以提前预判;编排层面则要记住:depends_on 管顺序不管可用,up -d 成功不代表健康

涉及原始记录:07-面试问题/验证问题/03-基础设施与中间件问题.md P2-02、P1-03、P1-05;
07-面试问题/开发问题/02-框架与中间件集成问题.md P1-05(ES 插件持久化);
07-面试问题/开发问题/01-依赖与配置问题.md P1-06、P1-07、P1-08(多服务脚本管理、Nacos 内存调优、健康检查超时)

最近修订:2026-08-07

目录


技术点 1:数据卷(Volume)—— 决定什么东西能在容器重建后存活

难度:⭐⭐ | 掌握要求:可写层 vs 数据卷生命周期、restart vs recreate 的区别

核心概念

理解 Docker 容器的存储模型,关键是分清”容器的可写层”和”数据卷”两种东西的生命周期:

  • 容器可写层:容器运行时在镜像基础上叠加的一层可写文件系统,容器删除(不是 stop,是 rm)或者被”重建”(--force-recreate、重新 up 一个同名但配置变了的服务)时,这一层连同里面的所有修改会被丢弃,回到镜像原始状态。
  • 数据卷(named volume / bind mount):显式挂载的存储,生命周期独立于容器实例,容器怎么重建,只要 volume 还在,数据就还在。

任何不是通过 volumes 显式挂载出来的目录,写进去的东西都只活在容器可写层里,本质上是”临时的”,即使这个容器可能会正常运行很长时间不重启——它随时可能因为某次部署变更、镜像升级而被重建,届时所有没有落到数据卷里的修改都会消失。

我们踩过的坑

开发问题 P1-05:ES 插件目录没挂载数据卷,容器重建后插件丢失

docker exec livin-es elasticsearch-plugin install ... 这条命令是在容器内部执行的,装好的 IK 插件文件写在了容器的可写层(/usr/share/elasticsearch/plugins 这个路径当时没有被 volumes 挂载出来)。容器只是简单 restart 时插件还在,但只要触发了”重建”(比如改了 compose 配置后 up,或者显式 --force-recreate),插件就消失了,依赖这个插件的自定义分词器索引也会变成不可用状态(red)。

根治方案是把插件目录也纳入数据卷管理:

1
2
3
4
5
6
7
8
9
volumes:
es-data:
es-plugins: # 新增:让插件的生命周期独立于容器实例

services:
elasticsearch:
volumes:
- es-data:/usr/share/elasticsearch/data
- es-plugins:/usr/share/elasticsearch/plugins

迁移已经装好的插件到新卷(避免重装)的操作顺序值得记住,这是一个通用的”给已运行容器补挂载卷”的操作模式:

1
2
3
4
5
6
7
8
9
10
# 1. 从旧容器里把数据先备份到宿主机
docker cp livin-es:/usr/share/elasticsearch/plugins /tmp/es-plugins-backup

# 2. 应用新的 compose 配置重建容器(这一步之后,新的数据卷是空的)
docker compose -f docker-compose-infra.yml up -d --no-deps elasticsearch

# 3. 把备份的数据拷贝进新容器(此时实际是写入新的数据卷),修正文件属主
docker cp /tmp/es-plugins-backup/analysis-ik livin-es:/usr/share/elasticsearch/plugins/analysis-ik
docker exec -u root livin-es chown -R elasticsearch:elasticsearch /usr/share/elasticsearch/plugins/analysis-ik
docker restart livin-es

验证问题 P1-03:容器没起来但 compose 命令没报错

docker compose up -d 是后台执行的,如果某个容器因为内存不足或启动超时失败,命令本身可能不会有明显报错,容易让人误以为所有服务都正常启动了。必须显式检查容器的实际状态,不能只看 docker compose up 命令有没有报错:

1
docker ps -a --filter name=elastic   # 确认容器状态,而不是假设它成功了

如果服务有健康检查配置,更可靠的做法是轮询等待健康检查通过,而不是固定 sleep 一个时间就假设已经就绪:

1
2
3
4
5
for i in (<spanclass="builtin">seq</span>130);<spanclass="keyword">do</span></span><br><spanclass="line">code=(<span class="built_in">seq</span> 1 30); <span class="keyword">do</span></span><br><span class="line">  code=(curl -sf -o /dev/null -w "%{http_code}" http://localhost:9200 2>/dev/null || echo "000")
[ "$code" = "200" ] && break
sleep 2
done

必须记住的结论

  • 任何通过 docker exec 进入容器内部做的修改(装软件、改配置文件、写文件),只要对应路径没有挂载数据卷,这个修改就是”一次性”的,下次容器重建就会消失。养成习惯:手动进容器改了什么东西之后,先问自己”这个目录持久化了吗”,没有的话要么补上 volume 配置,要么这次修改注定要在下次重建时重做一遍。
  • docker compose up -d 命令执行成功,不代表容器真的健康运行,尤其是后台模式下,启动失败可能被悄悄吞掉。判断服务是否就绪,永远以 docker ps 查看容器实际状态、或者主动探测健康检查端点为准,不能只看命令的退出码。

技术点 2:容器编排的依赖顺序(depends_on + healthcheck)

难度:⭐⭐ | 掌握要求:启动顺序 vs 就绪的区别、service_healthy 条件、脚本层等待逻辑

核心概念

多容器应用之间往往有依赖关系(业务服务要等 MySQL 就绪才能连接),但 Docker Compose 默认的 depends_on 只保证启动顺序,不保证”依赖的服务已经可以正常提供服务”——一个 MySQL 容器”启动了”(进程起来了)和”已经初始化完成、可以接受连接了”是两个不同的时间点,中间可能差好几秒到几十秒。

要让 Compose 真正等到依赖服务”可用”再启动下一个,需要给依赖的服务配置 healthcheck,并且在 depends_on 里声明 condition: service_healthy

1
2
3
4
5
6
mysql:
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 10

我们踩过的坑

验证问题 P2-02:中间件未就绪时业务服务提前启动失败

这条记录本身在文档里更多是作为”最佳实践”被提前预防,而不是一次线上事故复盘,但值得强调的一点是:Docker Compose 层面的 depends_on: service_healthy 只能约束 Compose 管理的容器之间的启动顺序,管不到”Compose 外部、由脚本单独启动的 Java 微服务”。本项目的微服务不是用 Compose 管理的,是用 start-all.sh 脚本单独拉起的 Java 进程,所以真正的等待逻辑要写在这个脚本里,而不能指望 Compose 的健康检查机制覆盖到微服务层面。

这一点在实际操作中体现为一个反复出现的踩坑记录(验证问题 P1-05):start-all.sh 本身不会启动 Docker 中间件容器,只负责检测状态和直接拉起 Java 微服务。如果没有先手动确认中间件(尤其是 Nacos)健康,就直接跑这个脚本,Java 微服务会因为连不上 Nacos/MySQL 而集体启动失败。而且 Nacos 的健康检查通过(HTTP 端口响应)和它的 gRPC 端口(微服务注册发现实际用的协议)真正就绪之间,还有大约 50 秒的滞后——这是本项目实践中总结出的一个具体的经验数值,不是 Nacos 官方文档写的标准值,仅供参考。

必须记住的结论

  • depends_on 默认只管启动顺序(谁先谁后),不管”是否真的可用”;要精确控制”等到真正可用再继续”,必须配 healthcheck + condition: service_healthy
  • 这套依赖等待机制只在 Compose 管理的服务范围内有效。如果应用服务是脚本单独管理、不在同一个 compose 文件里,等待逻辑需要在脚本层面自己实现(轮询健康检查端点),不能假设 Compose 会帮忙处理。
  • 中间件的”健康检查通过”和”所有子功能都真正可用”之间可能存在滞后(比如这里 Nacos 的 HTTP 健康检查和 gRPC 注册端口就绪不是同一时刻),涉及这类中间件时,如果启动脚本里只等健康检查通过就立刻启动依赖它的服务,遇到偶发的连接失败要考虑加一点缓冲等待时间,而不是第一反应怀疑代码逻辑有问题。

技术点 3:资源过载是环境问题,不要误判成代码 bug

难度:⭐⭐ | 掌握要求:排查顺序——先看资源再看代码、docker stats 用法

核心概念

本地开发环境用容器同时跑十几个中间件加十几个 Java 微服务,是对宿主机资源的极限压榨。当 CPU/内存严重超载时,会引发一系列表现上像是代码 bug、实际是资源不足导致的间接故障:容器健康检查超时被判定 unhealthy、服务间 RPC 调用超时、消息消费延迟等等。

我们踩过的坑

验证问题 P1-05:资源过载导致的连锁反应,一度被误以为是业务代码问题

阶段7验证合同链路时,同时运行 19+ 容器和 9~10 个 Java 微服务,出现了两个看起来很像代码 bug 的现象:支付回调后租约状态没有正常流转(消费者没有消费日志),以及某个接口偶发返回网关兜底的”系统内部错误”。

排查过程本身是一个值得记住的方法论:没有一头扎进业务代码找 bug,而是先用 docker stats 看了一眼资源占用,发现多个容器 CPU 占用破百,据此判断更可能是资源过载导致 Nacos/RocketMQ 探测超时,进而引发 Feign 调用超时、消息消费延迟。验证方式是完整走一遍 stop-all.sh alldocker compose down,比单独 docker kill 更彻底)重启整个环境,再重新走一遍完整链路——两个问题都没有再复现,从而确认是资源问题而不是代码逻辑问题。

由此也沉淀出一条实际的操作经验:测试非全链路功能时,没必要每次都启动全部中间件,只启动当前验证场景真正需要的核心组件(比如验证合同/租约/支付链路,只需要 MySQL/Redis/Nacos/RocketMQ/MinIO/Seata,完全不需要 ES/Kibana/各种 Dashboard/SkyWalking/Prometheus/Grafana),可以显著降低资源压力。

必须记住的结论

  • 排查”偶发的、没有明确异常信息、看起来毫无规律的故障”时,先看资源使用情况(CPU/内存/磁盘 IO),而不是立刻扎进业务代码逻辑。资源过载引发的连锁故障往往没有清晰的因果链,代码层面怎么看都找不出毛病,因为问题根本不在代码里。
  • 遇到”这个问题上次出现过,这次重启环境后又没了”的情况,第一反应不是”问题玄学消失了”,而是要考虑是不是资源过载/环境不稳定导致的偶发现象,stop-all.sh all 完整重启环境是一个简单但有效的排查/规避手段。
  • 本地开发环境资源有限时,按需启动中间件(而不是无脑全量启动)是一个能减少很多”莫名其妙”问题的实用习惯。

技术点 4:多服务脚本管理——服务清单是唯一事实来源,且要随规模持续演进

难度:⭐⭐ | 掌握要求:清单漂移问题、JVM 参数与规模的关系、僵尸容器与 init

核心概念

本项目的微服务不是用 Docker Compose 编排的,而是用 start-all.sh/stop-all.sh 两个 Shell 脚本,靠一个写死的 SERVICES=(...) 数组 + PID 文件来管理生命周期。这种”轻量脚本 + PID 文件”的管理方式简单直接,但有一个天然的脆弱点:它没有像 Kubernetes/Compose 那样从声明式配置(如 pom.xml 的 module 列表、Gateway 路由表)自动推导出服务清单,SERVICES 数组是一份完全独立维护的”第二份配置”。任何”新增一个微服务模块”的操作,本质上都需要同时改好几个地方保持一致:根 pom.xml<module>、Gateway 的路由配置、以及这里的 SERVICES 数组——它们之间没有任何强制校验,改漏一个不会有任何编译或运行时报错。

此外,这类脚本里常见的两个”魔法数字”——JVM 内存参数(如 JVM_XMX=256m)、健康检查探测超时(如 curl --max-time 3)——都是在项目早期、服务规模较小时按当时的实际情况设定的,它们的合理性是和”当时的服务数量、当时的宿主机负载”绑定的,不会随着服务规模增长自动调整,也很容易被遗忘在角落里,直到规模涨到某个临界点集中爆发问题。

我们踩过的坑

开发问题 P1-06:新增服务模块忘记同步登记到启动脚本,一键启动静默漏起

新增 livin-adminlivin-file 两个模块时,pom.xmlapplication.yml、Gateway 路由都配置齐全,jar 也编译成功,唯独忘了把它们加进 start-all.sh/stop-all.shSERVICES 数组。后果是这两个服务在”一键启动全部”时被完全跳过,而且脚本不会有任何报错或 [FAIL] 提示,因为 for 循环压根不会遍历到它们——这是最隐蔽的一类问题:所有静态检查(编译、配置文件语法)都通过,只有在”实际有没有对应进程在跑”这个运行时事实层面才能发现异常。排查时还顺带发现 livin-filelivin-notify 的端口配置意外撞车(都是 8090),如果两个服务真的同时被启动,会有一个绑定端口失败退出。

开发问题 P1-07:Nacos 容器 JVM 堆内存配置没有随服务数增长而调整,触发 OOM

docker-compose-infra.yml 里 Nacos 的 JVM_XMX=256m 是服务只有 6~7 个时定的,随着微服务数量涨到 13 个,注册表项、长轮询连接、心跳线程都线性增长,256MB 堆内存长时间运行后必然 OOM——容器整体的内存上限(7.8G)完全够用,问题出在 JVM 参数本身太保守,物理资源和 JVM 实际能用的资源是两回事,扩容宿主机内存对这类问题毫无帮助,只能针对性调大 -Xmx。更麻烦的是,OOM 后容器一度进入”僵尸态”,普通 docker stop/kill 都无法结束,需要给容器补充 init: true 让 Docker 注入专职的 PID 1 进程去正确回收异常退出的子进程。

开发问题 P1-08:健康检查探测超时是按早期负载设定的,服务规模涨上来后频繁误判

start-all.shcurl --max-time 3 探测各服务健康检查,在服务数量少、宿主机负载低的阶段完全够用。规模涨到 13 个微服务 + 9 个中间件容器同时运行后,宿主机负载偶尔冲到 Load Average 15+,明明所有服务都在正常返回 HTTP 200,仅仅因为 3 秒内来不及响应,就被脚本整批误判成 [WAITING],看起来像是”全部服务都启动失败了”,实际上只是探测方法本身对高负载场景没有留出容错空间。

必须记住的结论

  • 多服务管理脚本里任何形式的”服务清单”(数组、配置列表)都要当成和 pom.xml 模块声明、网关路由表同等重要的配置,新增服务的检查清单里必须包含”是否同步更新了启动/停止脚本”这一项,最好和其他改动放在同一次提交里,减少遗漏窗口。
  • 判断”服务是否启动成功”要以运行时事实(进程是否存活、健康检查是否通过)为准,不能只看编译是否成功、配置文件是否完整——配置齐全但没人启动进程,是一种和”启动失败”完全不同、但表现上同样让人困惑的状态。
  • JVM 内存参数、超时阈值这类和”当前系统规模”强相关的配置,不是设一次就一劳永逸的,服务数量、并发量、数据量出现数量级变化时,应该主动回头检查一遍这些历史遗留的参数是否还合理,而不是等到 OOM/大面积误判发生了才回头查。
  • 容器 OOM 后可能进入无法被普通信号结束的僵尸态,给长期运行、内存敏感的容器(尤其是 JVM 类应用)默认加上 init: true,是一个能减少”容器卡死必须 docker rm -f 兜底”情况的低成本加固手段。

技术点 5:镜像分层、网络与资源限制 —— 必补的地基

难度:⭐⭐ | 掌握要求:镜像分层与缓存、多阶段构建、bridge 网络、内存限制行为

镜像分层:为什么 Dockerfile 指令顺序很重要

Docker 镜像是只读层的堆叠:Dockerfile 里每条指令(FROM/RUN/COPY…)生成一个新层,层有缓存——某一层变了,它之后的所有层缓存全部失效。由此得到两条 Dockerfile 最佳实践:

  1. 变化频率低的内容放前面:基础依赖安装(apt-get install)放前面,频繁变化的代码拷贝(COPY . /app)放最后,最大化利用构建缓存。
  2. 多阶段构建(multi-stage build)控制镜像体积:第一阶段用 JDK/Maven 完整镜像编译打包,第二阶段只 COPY 产物 jar 到一个精简 JRE 镜像运行——最终镜像不带编译工具链,体积从 GB 级降到几百 MB,攻击面也更小。
1
2
3
4
5
6
7
8
9
10
# 阶段1:构建
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY . .
RUN mvn -pl livin-house -am package -DskipTests

# 阶段2:运行(只带产物,不带 Maven 和源码)
FROM eclipse-temurin:21-jre
COPY --from=build /app/livin-house/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

容器网络

  • bridge(默认):每个容器分配虚拟网卡和私有 IP,同一 bridge 网络内的容器可以用容器名互相解析(Compose 自动把服务名注册成 DNS)——这就是 compose 文件里 mysql:3306nacos:8848 能直接写服务名的原因。
  • 端口映射 -p 8080:8080 只影响”宿主机访问容器”,不影响容器之间互访——容器间通信走内部网络,不需要映射。
  • 容器访问宿主机:用 host.docker.internal(本项目 prometheus.yml 抓微服务指标就是这么配的),不能写 localhost——容器里的 localhost 是容器自己。这是容器化新手的高频坑。

资源限制与 JVM 的特殊性

  • mem_limit/cpus 可以限制容器资源,超限的行为是内存超限直接 OOM Kill(不是变慢,是杀掉)、CPU 超限只是限流变慢。
  • JVM 与容器限制的版本陷阱:老版本 JDK(8u131 之前)读的是宿主机内存而不是容器 limit,容器限 512M 但 JVM 按宿主机 64G 算堆,必然 OOM Kill。JDK 8u191+/10+ 默认支持容器感知(UseContainerSupport),JDK 21 无此问题——但 JVM_XMX 这类显式参数会覆盖容器感知,本项目 Nacos OOM 案例的本质就是”显式参数太小”而不是”容器内存不够”。
  • 配套认知:容器里 JVM 的 OOM 分两层——Java 堆 OOM(OutOfMemoryError)和容器级 OOM Kill(进程直接被 SIGKILL,日志里什么 Java 异常都没有),后者排查要看 docker inspectOOMKilled 字段和 dmesg。OOM 排查完整方法详见 10-JVM与并发编程.md

从 Compose 到 K8s 的演进认知

Compose 解决的是”单机多容器编排”,K8s 解决的是”多机集群调度 + 自愈 + 弹性伸缩”。概念上有一组对应关系,提前建立映射学 K8s 会快很多:Compose service → K8s Deployment/Service、depends_on+healthcheck → 探针(liveness/readiness probe)、数据卷 → PV/PVC、服务名 DNS → Service 发现。本项目 deploy/k8s/ 目录已开始积累相关配置,作为后续演进方向了解即可。


术语表

术语 含义
容器可写层 容器运行时叠加在镜像上的可写文件层,容器删除/重建即丢失
数据卷(Volume) 生命周期独立于容器的持久化存储,named volume 由 Docker 管理
restart vs recreate 重启保留可写层 / 重建从镜像重新创建、可写层重置
healthcheck + service_healthy 让 Compose 等到依赖”真正可用”再启动后续服务的机制
镜像分层 / 构建缓存 每条 Dockerfile 指令生成一个只读层;某层变化则其后缓存全失效
多阶段构建 编译环境与运行环境分离的 Dockerfile 写法,控制产物镜像体积
bridge 网络 默认容器网络模式,同网络内容器名即 DNS 名
host.docker.internal 容器内访问宿主机服务的特殊 DNS 名
OOM Kill 容器内存超限被内核直接杀掉,区别于 JVM 堆 OOM
init: true 给容器注入专职 PID 1 进程,正确回收僵尸子进程
liveness / readiness probe K8s 探针:判断容器要不要重启 / 要不要接流量

认证鉴权与网关架构:JWT / 统一鉴权 / CI-CD 基础概念

一句话速览:本项目的鉴权架构是”网关一处验签、下游全部信任 Header”——这个模式用”业务服务零鉴权代码”换来了”直连业务端口这条路在语义上不成立”的硬约束;JWT 本身用签名换无状态,代价是无法主动失效,踢人/续期都要额外的机制来补。

涉及原始记录:07-面试问题/验证问题/01-业务逻辑问题.md P0-01;
07-面试问题/验证问题/02-数据与环境问题.md P1-02、P1-03;
补充内容:CI/CD 基础概念(阶段12引入,本项目未做 CD)

最近修订:2026-08-07

目录


技术点 1:网关统一鉴权 + Header 透传模式

难度:⭐⭐ | 掌握要求:能画出透传链路、说清直连端口为什么必然拿不到用户

核心概念

本项目的鉴权架构是一种常见的微服务模式:只在网关这一层做 JWT 验签,验签通过后把解析出的用户信息通过请求 Header 透传给下游服务,下游业务服务本身不再重复验签,只信任 Header 里的内容

1
2
3
4
5
6
客户端请求(带 JWT token)
→ Gateway:JwtAuthGlobalFilter 验签 token
→ 验签通过:把 userId/role 写入请求 Header(X-User-Id / X-User-Role)
→ 转发给业务服务
→ 业务服务:UserContextInterceptor 只读 Header,不解析 JWT
→ 填充 UserContextHolder,业务代码通过它拿到当前用户

这个模式的核心假设是:业务服务的入口只能是网关,一旦这个假设被打破(比如测试时直连业务端口),下游的 UserContextHolder 就会拿到空值——因为业务服务的拦截器压根不会去解析请求里的 JWT token,它只认 Header。

我们踩过的坑

这个模式带来的”副作用”在本项目里以两种不同的表现形式反复出现,值得放在一起理解成同一个问题:

验证问题 P0-01:直连业务服务导致 tenant_id 为空,进而影响 Seata 回滚

验证脚本为了图方便,直接调用了 lease 服务的内部接口(绕过 Gateway),UserContextHolder.getUserId() 返回 null,导致创建的租约记录 tenant_id 字段是空的,后续再牵连到 Seata 回滚异常(详见 02-分布式事务与消息队列.md)。表面上看是一个 Seata 问题,根因其实是验证方式违反了架构假设。

数据与环境问题 P1-02:直连业务端口返回”请先登录”

同样是直连业务服务的表现,这次更直白:接口直接返回 10003”请先登录”的业务错误码,即使请求明明带了有效的 token。原因跟上面完全一致——业务服务的拦截器根本不解析 Header 之外的任何东西:

1
2
3
4
5
6
7
8
9
public class UserContextInterceptor implements HandlerInterceptor {
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String userId = request.getHeader("X-User-Id"); // 只认这个 Header
if (userId != null) {
UserContextHolder.setUserId(Long.valueOf(userId));
}
return true; // 即使 userId 是 null,也会放行到 Controller,让业务代码自己判断未登录
}
}

解决方式是建立一条硬性验证规范:所有需要鉴权的验证请求,必须统一通过 Gateway 端口(8080)访问 /api/** 路径,不允许为了”方便调试”直连业务服务端口

必须记住的结论

  • “网关统一鉴权、业务服务信任 Header”这个架构模式的代价是:业务服务自己完全丧失了独立验证请求合法性的能力。这不是缺陷,是这个架构模式本身的设计取舍(换来的是业务服务不用各自实现一遍 JWT 验签逻辑),但意味着这个架构下”直连业务服务”这条路径在语义上是不完整的、不能代表真实的请求处理链路,任何验证、调试都必须走网关。
  • 排查”带了 token 但还是提示未登录/用户信息为空”类问题时,第一步永远是确认请求到底有没有经过网关,而不是去查 JWT 本身的签名、有效期这些。
  • 这类架构模式的选择也解释了为什么 WebSocket(IM 模块)不能照搬同样的方式——WebSocket 是直连独立端口、不经过 Gateway 的,所以它必须自己具备完整的 JWT 验签能力,这是两种架构模式的分界点(详见 05-网络通信Netty.md 的鉴权部分)。
  • 安全视角的提醒(本项目单机部署暂未暴露,但生产必须考虑):”信任 Header”的前提是外部流量无法绕过网关直达业务服务。生产环境必须在网络层(安全组/内网隔离/服务端口不对外暴露)保证这一点,否则任何人都可以自己伪造一个 X-User-Id 头直连业务服务冒充任意用户。

技术点 2:JWT(JSON Web Token)基础

难度:⭐⭐ | 掌握要求:三段结构、HS256 vs RS256、无状态的优缺点

核心概念

JWT 是一种自包含的身份令牌,由三部分组成(header.payload.signature),服务端不需要为每个登录用户保存 session 状态——校验的时候只需要验证签名是否合法(用密钥/公钥验签),再解析出 payload 里携带的用户信息(userId、role、过期时间等)即可,这也是为什么 JWT 特别适合分布式/微服务架构:任何一个持有验签密钥的服务,都能独立完成校验,不需要跟一个中心化的 session 存储通信。

原理深挖:三段结构各是什么

1
2
3
4
5
eyJhbGciOiJSUzI1NiJ9  .  eyJ1c2VySWQiOjEsImV4cCI6MTc1NDU2fQ  .  X9v...签名
↓ ↓ ↓
Header Payload Signature
{"alg":"RS256"} {"userId":1,"exp":...} 用私钥对前两段签名
Base64URL 编码 Base64URL 编码 Base64URL 编码

两个最容易被误解的点:

  • Base64URL 是编码不是加密:payload 部分任何人都能解码出明文(在线工具一秒解),所以 JWT 里绝对不能放密码、身份证号等敏感信息。JWT 保证的是”不可篡改”(改了签名就对不上),不是”不可见”。
  • 签名验签的过程:签发方对 header + "." + payload 用私钥(RS256)或共享密钥(HS256)算出签名;验签方用公钥/同一密钥重新算一遍对比。任何对 payload 的篡改都会导致签名不匹配。

对称 HS256 vs 非对称 RS256

HS256(对称) RS256(非对称,本项目选择)
密钥 同一个密钥既签发又验签 私钥签发、公钥验签
微服务适配性 差:每个要验签的服务都得持有”能伪造 token 的密钥”,一个服务泄漏全盘皆输 好:验签服务只持有公钥,公钥泄漏也无法伪造 token
性能 慢一些(RSA 运算贵),可忽略

本项目用的是非对称签名(RSA):Auth 服务持有私钥,签发 token 时用私钥签名;其他所有需要验签的地方(Gateway、以及独立于网关之外的 IM 服务)只需要持有公钥即可完成验签,不需要也不应该持有私钥。这个设计的好处是即使某个下游服务的公钥配置泄漏,也不可能被用来伪造 token(伪造需要私钥)。

我们踩过的坑

参见 05-网络通信Netty.md 里 IM 服务忘记配置 JWT 公钥导致鉴权必然失败的案例——这个坑本质上属于”JWT 验签所需的密钥材料没有正确分发到需要它的服务”这一类问题。

必须记住的结论

  • 区分清楚”谁需要私钥、谁只需要公钥”:只有签发 token 的服务(本项目是 auth 服务)需要私钥,所有其他做验签的地方只需要公钥。新增一个需要独立验签能力的服务时,只需要同步公钥配置,不要图省事把私钥也一起同步过去(会扩大私钥泄漏的风险面)。
  • JWT 本身不提供”主动失效”能力(一旦签发,在过期之前始终有效),如果业务上需要强制下线/踢人,需要额外的机制(比如本项目 IM 模块的”同用户多端登录踢旧连接”就是应用层维护会话表实现的,不是依赖 JWT 本身)。
  • payload 是明文编码不是加密,敏感信息绝不进 JWT;签名保证的是”不可篡改”,不是”不可见”。

技术点 3:JWT 的续期与主动失效 —— 无状态的两块补丁

难度:⭐⭐ | 掌握要求:refresh token 双 token 机制、黑名单/版本号两种失效方案

JWT 无状态设计带来两个天然的”功能缺口”,是面试必考的延伸问题,本项目虽未实现但方案要能讲清:

缺口一:token 过期了怎么办——双 Token 续期

access token 有效期设短了用户体验差(频繁重新登录),设长了泄漏风险大。标准解法是双 Token

sequenceDiagram
    participant C as 客户端
    participant A as Auth服务
    participant R as 业务链路

    C->>A: 登录
    A-->>C: accessToken(2小时) + refreshToken(7天, 存服务端/Redis)
    C->>R: 用 accessToken 调业务接口
    Note over C,R: 2小时后 accessToken 过期
    C->>A: 用 refreshToken 换新 accessToken
    A-->>C: 新 accessToken(无感续期,不用重新登录)

要点:accessToken 无状态验签(快),refreshToken 有状态存服务端(可控制)——吊销 refreshToken 就能阻止续期,等现有 accessToken 自然过期即完成下线。这是”无状态性能”与”可控制性”的折中。

缺口二:怎么让没过期的 token 立刻失效——两种方案

  • 黑名单方案:把被吊销的 token(或其 jti 唯一标识)存 Redis,TTL 设为 token 剩余有效期;验签时先查黑名单。缺点:每次验签多一次 Redis 查询,部分牺牲了无状态性
  • 用户版本号方案(更常用):用户表维护一个 tokenVersion 字段,签发时写进 payload;踢人/改密时把 tokenVersion +1,验签时对比 token 里的版本号和当前版本号,不一致即拒绝。好处是一次操作吊销该用户所有已签发 token,且版本号可以缓存,开销比黑名单小。

本项目 IM 模块的”多端登录踢旧连接”走的是另一条路——不吊销 JWT,而是在应用层会话表里让新连接顶掉旧 Channel(详见 05-网络通信Netty.md),这说明”登录态控制”和”JWT 失效”是两个可以分开解决的问题。


技术点 4:CI/CD 基础概念(了解即可,本项目未落地 CD)

难度:⭐ | 掌握要求:CI 与 CD 的区别、本项目”只做 CI”的判断逻辑

这部分是纯概念性学习内容,本项目已经搭建了 CI(见 .github/workflows/ci.yml),但结合项目实际情况(本地 2核2G 服务器资源无法承载 11 微服务+8 中间件的完整技术栈)判断不做自动化部署,只需要理解概念、知道行业里通常怎么做即可。

核心概念

  • CI(持续集成,Continuous Integration):代码提交后自动触发一套流水线,做编译、跑测试、代码质量扫描,尽早发现”这次改动把项目弄坏了”,而不是等到人工发现或者部署时才暴露。
  • CD(持续交付/持续部署,Continuous Delivery / Deployment):CI 通过之后,自动把构建产物发布出去。”持续交付”是打包好、但上线需要人工点一下确认;”持续部署”是全自动,测试通过就直接上线,中间没有人工审批环节。这两者的区别就在于”最后一步要不要人工确认”。
  • 常见工具:GitHub Actions、GitLab CI、Jenkins。核心工作模式都是相同的——监听代码仓库事件(push/PR),按照一份 YAML/Groovy 定义的流水线脚本,在一个临时的、全新的 runner 环境里依次执行各个步骤。

本项目的技术选型判断(为什么只做 CI 不做 CD)

这是一次真实的、基于资源约束做的架构判断,值得记住判断过程而不只是结论:

  1. 项目由 11 个自研微服务 + 8+ 个中间件(Nacos/RocketMQ/Seata/ES/Redis/MySQL/MongoDB/MinIO)组成,粗略估算内存需求:光中间件就需要 3~4G,11 个 Java 微服务哪怕每个只给 256M 堆内存也要 2.7G+,合计轻松超过 8G。
  2. 现有可用于部署的服务器只有 2核2G,连单独跑好 ES 一个组件都紧张,完全不可能承载完整技术栈——不是”能不能优化”的问题,是内存总量的硬约束。
  3. GitHub Actions 的 runner 是每次全新创建的临时容器,即使强行想在流水线里做自动部署,也没有一个现实存在的、能承载这套技术栈的目标环境可以部署过去。
  4. 结论:CI(编译/打包门禁)依然有实际价值,能防止把多模块聚合顺序、依赖声明这类低级错误合入主分支;但 CD 环节因为没有可行的部署目标,本阶段不实施,属于”评估后主动不做”,而不是”不会做”。

必须记住的结论

  • CI 和 CD 是两个独立的、可以分开决策是否要做的环节,不是绑定的一个整体。”做了 CI”不代表一定要”做 CD”,两者的价值和成本是分开评估的。
  • 判断要不要上某个工程化设施(这里是 CD),核心问题永远是”这个东西的价值能不能落地”,而不是”这个技术流不流行”。没有可部署的目标环境时,强行搭建自动部署流水线只是空转,没有实际产出。
  • 如果未来有了更充足的服务器资源,把 CD 补上的思路应该是:先从”只部署 1~2 个核心服务做演示”这种小范围场景切入验证流程通不通,而不是一上来就追求”全部 11 个服务自动部署”,这跟本项目其他很多问题的教训是一致的——先保证核心链路正确可信,再谈规模化。

术语表

术语 含义
Header 透传模式 网关验签后把用户信息写入请求头,下游服务只读头不验签的架构模式
Base64URL JWT 三段用的编码方式——是编码不是加密,payload 可被任何人解码
HS256 / RS256 对称签名(共享密钥)/ 非对称签名(私钥签、公钥验)算法
accessToken / refreshToken 双 Token 机制:短效业务令牌 + 长效可吊销的续期令牌
黑名单 / tokenVersion 两种主动失效方案:吊销列表逐个封禁 / 用户版本号一次全废
白名单 网关鉴权中显式放行、不做登录校验的路径清单
CI / CD 持续集成(自动编译测试门禁)/ 持续交付与持续部署(自动发布)
runner CI 工具中执行流水线的临时干净环境

Nginx 基础配置:从静态托管到反向代理

一句话速览:Nginx 配置的心智模型只有三层——http 块管全局、server 块管一个站点、location 块管一个 URL 路径。三者嵌套,从外到内逐层缩小范围。90% 的 Nginx 配置问题都能用三句话定位:哪个 server 块匹配了请求?哪个 location 匹配了路径?匹配到的指令做了什么?

目录


技术点 1:配置文件的层级结构——http → server → location

Nginx 配置文件(通常在 /etc/nginx/nginx.conf)是嵌套的块结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 全局配置——工作进程数、日志路径等
worker_processes auto;

events {
worker_connections 1024; # 每个工作进程的最大连接数
}

http {
# http 块:所有站点共享的配置
sendfile on;
keepalive_timeout 65;
include /etc/nginx/conf.d/*.conf; # 引入其他配置文件(常见做法)

server {
# server 块:一个虚拟主机(一个站点)
listen 80;
server_name example.com;

location / {
# location 块:一个 URL 路径的处理规则
root /var/www/html;
index index.html;
}
}
}

记忆方式:俄罗斯套娃。http 是最外层(全局 HTTP 设置),server 是中间层(一个站点),location 是最内层(一条 URL 路径的规则)。配置项可以在外层定义、内层继承,内层也可以覆盖外层。

1Panel / 宝塔等面板的做法:通常主配置文件 nginx.confinclude /etc/nginx/conf.d/*.conf,每个站点一个 .conf 文件(如 example.com.conf),里面就是一个 server 块。这样加站点只需加文件、不用改主配置。


技术点 2:server 块——虚拟主机,一个 Nginx 托管多个站点

一个 Nginx 可以托管多个站点,靠的是 server_name 区分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 站点 A:博客
server {
listen 80;
server_name blog.example.com;
root /var/www/blog;
}

# 站点 B:API
server {
listen 80;
server_name api.example.com;
proxy_pass http://localhost:3000; # 反向代理到后端
}

# 站点 C:默认(所有未匹配的域名)
server {
listen 80 default_server; # 没有其他 server_name 匹配时走这里
server_name _;
return 444; # 直接断开连接
}

关键概念

  • listen:监听端口,80 是 HTTP、443 是 HTTPS
  • server_name:匹配请求的 Host 头。Nginx 收到请求后,按 server_name 找对应的 server 块
  • default_server:没有匹配时的兜底,一个端口只能有一个 default_server
  • 多个域名指向同一个站点:server_name a.com b.com c.com;

技术点 3:location 匹配规则——Nginx 最容易踩坑的地方

location 决定了”这个 URL 路径由什么规则处理”,匹配规则有优先级:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
location = /exact {
# 精确匹配(优先级最高),只匹配 /exact 本身
}

location ^~ /priority {
# 前缀匹配(不再做正则匹配),匹配以 /priority 开头的路径
}

location ~ \.php</span> &#123;</span><br><span class="line"> <span class="comment"># 正则匹配(区分大小写),匹配 .php 结尾的路径</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="section">location</span> <span class="regexp">~* \.(jpg|png|css|js) {
# 正则匹配(不区分大小写),匹配静态资源
}

location /api {
# 普通前缀匹配(优先级最低),匹配以 /api 开头的路径
}

优先级从高到低

  1. = 精确匹配(最高)
  2. ^~ 前缀匹配(匹配后不再检查正则)
  3. ~ / ~* 正则匹配(按出现顺序,先匹配到的生效)
  4. 普通前缀匹配(最低,最长前缀优先)

实际例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
server {
listen 80;

# 静态资源走正则
location ~* \.(css|js|png|jpg|gif|ico)</span> &#123;</span><br><span class="line"> <span class="attribute">root</span> /var/www/static;</span><br><span class="line"> <span class="attribute">expires</span> <span class="number">30d</span>; <span class="comment"># 浏览器缓存 30 天</span></span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line"> <span class="comment"># /api 反代到后端</span></span><br><span class="line"> <span class="section">location</span> /api &#123;</span><br><span class="line"> <span class="attribute">proxy_pass</span> http://localhost:3000;</span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line"> <span class="comment"># 其他路径找静态文件</span></span><br><span class="line"> <span class="section">location</span> / &#123;</span><br><span class="line"> <span class="attribute">root</span> /var/www/html;</span><br><span class="line"> <span class="attribute">try_files</span> <span class="variable">uri $uri/ /index.html; # SPA 单页应用的标配
}
}

SPA 单页应用的 try_filestry_files $uri uri//index.html的意思是——先找文件uri/ /index.html` 的意思是——先找文件 `uri,再找目录 $uri/,都找不到就返回 index.html(让前端路由处理)。Vue/React 项目部署必配。


技术点 4:反向代理——Nginx 最常见的用途

反向代理就是:用户请求 Nginx → Nginx 转发给后端服务 → 后端返回 → Nginx 转给用户。

1
2
3
4
5
6
7
location /api {
proxy_pass http://localhost:3000; # 转发到本机 3000 端口
proxy_set_header Host host</span>; <span class="comment"># 把原始 Host 传给后端</span></span><br><span class="line"> <span class="attribute">proxy_set_header</span> X-Real-IP <span class="variable">remote_addr; # 传真实客户端 IP
proxy_set_header X-Forwarded-For proxy_add_x_forwarded_for</span>;</span><br><span class="line"> <span class="attribute">proxy_set_header</span> X-Forwarded-Proto <span class="variable">scheme;
}

为什么要传这些 header:后端服务收到的请求是 Nginx 发的,不传的话后端以为请求来自 127.0.0.1,拿不到真实用户 IP 和协议。

带路径前缀的代理

1
2
3
4
5
6
7
8
9
# 访问 https://example.com/app/xxx → 转发到 http://localhost:3000/xxx(去掉 /app 前缀)
location /app/ {
proxy_pass http://localhost:3000/; # 注意末尾的 /,它会把 /app/ 替换掉
}

# 对比:不带末尾 /
location /app/ {
proxy_pass http://localhost:3000; # 不带 /,/app/ 前缀保留,转发为 /app/xxx
}

末尾 / 的区别是最大的坑

  • proxy_pass http://backend/;(带 /)→ /app/foo 变成 /foo
  • proxy_pass http://backend;(不带 /)→ /app/foo 保持 /app/foo

WebSocket 代理(如果后端用了 WS):

1
2
3
4
5
6
7
location /ws {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s; # WS 长连接不超时
}

技术点 5:静态文件托管与 try_files

最简单的用途——把 HTML/CSS/JS 文件托管出去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
server {
listen 80;
server_name example.com;
root /var/www/html; # 文件根目录

# 访问 example.com/ → 找 /var/www/html/index.html
location / {
try_files uri</span><spanclass="variable">uri</span> <span class="variable">uri/ /index.html;
}

# 单独配一个路径
location /study-plan {
# 访问 example.com/study-plan → 找 /var/www/html/study-plan.html
# nginx 自动补 .html 后缀(需要 try_files 配合)
try_files uri</span><spanclass="variable">uri</span> <span class="variable">uri.html $uri/ /index.html;
}
}

root vs alias

  • root /var/www:location 路径拼在 root 后面。location /img + root /var/www → 找 /var/www/img/xxx
  • alias /var/www/images:location 路径被替换。location /img + alias /var/www/images → 找 /var/www/images/xxx

记忆方式:root 是”路径拼接”,alias 是”路径替换”。alias 更直观但要注意末尾 / 要对齐。


技术点 6:负载均衡与 upstream

后端有多个实例时,Nginx 可以分发请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 定义后端服务器组
upstream backend {
server 127.0.0.1:3000 weight=3; # 权重 3
server 127.0.0.1:3001 weight=1; # 权重 1
server 127.0.0.1:3002 backup; # 备用,前面的都挂了才用
}

server {
listen 80;
location /api {
proxy_pass http://backend; # 直接用 upstream 名字
}
}

负载均衡策略

  • 默认:轮询(round-robin),均匀分配
  • weight=N:按权重分配
  • ip_hash:同一 IP 固定走同一后端(解决 session 问题)
  • least_conn:分配给当前连接数最少的后端
1
2
3
4
5
upstream backend {
ip_hash; # 同一用户固定走同一台
server 127.0.0.1:3000;
server 127.0.0.1:3001;
}

技术点 7:HTTPS 配置与证书

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
server {
listen 443 ssl http2; # HTTPS + HTTP/2
server_name example.com;

ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;

location / {
root /var/www/html;
}
}

# HTTP 自动跳转 HTTPS
server {
listen 80;
server_name example.com;
return 301 https://host</span><spanclass="variable">host</span><span class="variable">request_uri; # 301 永久重定向到 HTTPS
}

证书来源

  • Let’s Encrypt(免费,用 certbot 自动申请和续期)
  • 阿里云/腾讯云免费 DV 证书
  • 付费证书(OV/EV)

HTTP 跳 HTTPS 的坑:后端服务如果需要知道用户用的是 HTTPS,必须传 X-Forwarded-Proto $scheme,否则后端以为请求是 HTTP 的,生成 HTTP 的重定向链接造成无限重定向。


技术点 8:常用运维命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 测试配置语法是否正确
nginx -t

# 重新加载配置(不中断服务,平滑重启)
nginx -s reload

# 启动 / 停止
nginx # 启动
nginx -s stop # 快速停止
nginx -s quit # 优雅停止(等当前请求处理完)

# Docker 里跑的 Nginx(如 1Panel/OpenResty)
docker exec <容器名> nginx -t # 测试配置
docker exec <容器名> nginx -s reload # 重载配置

# 查看实时日志
tail -f /var/log/nginx/access.log # 访问日志
tail -f /var/log/nginx/error.log # 错误日志

配置变更流程:改配置 → nginx -t 测试 → nginx -s reload 重载。永远先测试再重载,配置语法错误会导致 reload 失败但旧配置仍在跑(不中断服务)。


术语表

术语 含义
http 块 Nginx 配置最外层,管全局 HTTP 设置
server 块 虚拟主机,一个 server 对应一个站点
location 块 URL 路径匹配规则,一个 location 对应一组 URL
root 文件根目录,location 路径拼在后面
alias 路径别名,location 路径被替换
try_files 按顺序尝试文件/目录,找不到用最后一个兜底
proxy_pass 反向代理,把请求转发给后端
upstream 后端服务器组,配合 proxy_pass 做负载均衡
$uri 当前请求的 URI(不含参数)
$host 请求的 Host 头
$remote_addr 客户端 IP
default_server 没有匹配时的兜底 server
499 客户端主动断开(常见于前端超时取消请求)
502 后端服务没起来或端口不对
504 后端响应超时(Gateway Timeout)