<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0"><channel><title>LikeYY</title><link>https://likeyy.love</link><atom:link href="https://likeyy.love/rss.xml" rel="self" type="application/rss+xml"/><description>记录美好生活</description><generator>Halo v2.26.0</generator><language>zh-cn</language><image><url>https://hanserwei-1308845726.cos.ap-chengdu.myqcloud.com/markdown/20250731105905287.svg</url><title>LikeYY</title><link>https://likeyy.love</link></image><lastBuildDate>Mon, 31 Aug 2026 05:00:51 GMT</lastBuildDate><item><title><![CDATA[从 SkyWalking 专用实现迁移到 OpenTelemetry：搭建可复刻的分布式 Trace、日志中心与异步访问日志系统]]></title><link>https://likeyy.love/archives/cong-skywalking-zhuan-yong-shi-xian-qian-yi-dao-opentelemetry-da-jian-ke-fu-ke-de-fen-bu-shi-trace-ri-zhi-zhong-xin-yu-yi-bu-fang-wen-ri-zhi-xi-tong</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=%E4%BB%8E%20SkyWalking%20%E4%B8%93%E7%94%A8%E5%AE%9E%E7%8E%B0%E8%BF%81%E7%A7%BB%E5%88%B0%20OpenTelemetry%EF%BC%9A%E6%90%AD%E5%BB%BA%E5%8F%AF%E5%A4%8D%E5%88%BB%E7%9A%84%E5%88%86%E5%B8%83%E5%BC%8F%20Trace%E3%80%81%E6%97%A5%E5%BF%97%E4%B8%AD%E5%BF%83%E4%B8%8E%E5%BC%82%E6%AD%A5%E8%AE%BF%E9%97%AE%E6%97%A5%E5%BF%97%E7%B3%BB%E7%BB%9F&amp;url=/archives/cong-skywalking-zhuan-yong-shi-xian-qian-yi-dao-opentelemetry-da-jian-ke-fu-ke-de-fen-bu-shi-trace-ri-zhi-zhong-xin-yu-yi-bu-fang-wen-ri-zhi-xi-tong" width="1" height="1" alt="" style="opacity:0;">
<h1 id="从-SkyWalking-专用实现迁移到-OpenTelemetry-搭建可复刻的分布式-Trace-日志中心与异步访问日志系统">从 SkyWalking 专用实现迁移到 OpenTelemetry：搭建可复刻的分布式 Trace、日志中心与异步访问日志系统</h1>
<blockquote>
 <p>本文记录一次真实 Java 微服务项目的可观测性改造：使用 OpenTelemetry Java Agent 自动采集 Trace 和
  <br>
  SLF4J/Logback 日志，经 OpenTelemetry Collector 写入 Elasticsearch 9.x，再用 Kibana 按 Trace ID
  <br>
  串联一次请求涉及的 HTTP、Feign、JDBC、RocketMQ 和业务日志；同时将数据库 API 访问日志从同步
  <br>
  Feign 调用改为 RocketMQ 异步、幂等落库。</p>
 <p>文中的代码、配置和验证结果来自一个 Spring Boot 4.1、JDK 25、Spring Cloud 微服务项目。版本基线
  <br>
  截至 2026-08-23。中间件升级很快，复制到新项目时应再次核对官方发布页。</p>
</blockquote>
<h2 id="本文对应的核心文件">本文对应的核心文件</h2>
<p>如果读者正在查看配套仓库，可以用下面的索引快速定位完整实现：</p>
<table>
 <thead>
  <tr>
   <th>内容</th>
   <th>文件</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>总体部署说明</td>
   <td><a href="https://likeyy.love/./observability.md"><code>docs/observability.md</code></a></td>
  </tr>
  <tr>
   <td>OpenTelemetry 版本管理</td>
   <td><a href="https://likeyy.love/../handao-dependencies/pom.xml"><code>handao-dependencies/pom.xml</code></a></td>
  </tr>
  <tr>
   <td>Trace 自动配置</td>
   <td><a href="https://likeyy.love/../handao-framework/handao-spring-boot-starter-monitor/src/main/java/com/hanserwei/handao/framework/tracer/config/HandaoTracerAutoConfiguration.java"><code>HandaoTracerAutoConfiguration.java</code></a></td>
  </tr>
  <tr>
   <td>Trace ID 工具</td>
   <td><a href="https://likeyy.love/../handao-framework/handao-common/src/main/java/com/hanserwei/handao/framework/common/util/monitor/TracerUtils.java"><code>TracerUtils.java</code></a></td>
  </tr>
  <tr>
   <td>响应 Trace Header</td>
   <td><a href="https://likeyy.love/../handao-framework/handao-spring-boot-starter-monitor/src/main/java/com/hanserwei/handao/framework/tracer/core/filter/TraceFilter.java"><code>TraceFilter.java</code></a></td>
  </tr>
  <tr>
   <td>CORS Header 暴露</td>
   <td><a href="https://likeyy.love/../handao-framework/handao-spring-boot-starter-web/src/main/java/com/hanserwei/handao/framework/web/config/HandaoWebAutoConfiguration.java"><code>HandaoWebAutoConfiguration.java</code></a></td>
  </tr>
  <tr>
   <td>访问日志生产者</td>
   <td><a href="https://likeyy.love/../handao-framework/handao-spring-boot-starter-web/src/main/java/com/hanserwei/handao/framework/apilog/core/producer/ApiAccessLogProducer.java"><code>ApiAccessLogProducer.java</code></a></td>
  </tr>
  <tr>
   <td>访问日志消费者</td>
   <td><a href="https://likeyy.love/../handao-module-infra/handao-module-infra-server/src/main/java/com/hanserwei/handao/module/infra/mq/consumer/ApiAccessLogMessageConsumer.java"><code>ApiAccessLogMessageConsumer.java</code></a></td>
  </tr>
  <tr>
   <td>Collector 配置</td>
   <td><a href="https://likeyy.love/../script/docker/otel-collector-config.yaml"><code>otel-collector-config.yaml</code></a></td>
  </tr>
  <tr>
   <td>Docker Compose</td>
   <td><a href="https://likeyy.love/../script/docker/docker-compose.yml"><code>docker-compose.yml</code></a></td>
  </tr>
  <tr>
   <td>RocketMQ Broker 配置</td>
   <td><a href="https://likeyy.love/../script/docker/rocketmq/broker.conf"><code>broker.conf</code></a></td>
  </tr>
 </tbody>
</table>
<h2 id="一-为什么要重做分布式日志方案">一、为什么要重做分布式日志方案</h2>
<p>微服务系统出现线上问题时，我们通常需要回答下面几个问题：</p>
<ol>
 <li>用户的这次请求经过了哪些服务？</li>
 <li>每一跳分别耗时多久？慢在网关、数据库、远程调用还是消息队列？</li>
 <li>某一条异常日志属于哪一次请求？</li>
 <li>请求返回给前端的 Trace ID，能否直接检索出完整调用链和相关日志？</li>
 <li>API 访问日志落库是否会增加业务接口延迟？</li>
 <li>消息重复投递时，数据库是否会出现重复访问日志？</li>
</ol>
<p>旧实现存在两个典型问题。</p>
<p>第一，业务代码直接依赖 SkyWalking Toolkit，并在网关、异常处理器等位置调用专有 API。这样做会让
 <br>
 业务代码与具体 APM 产品绑定，后续切换后端或采集方案时改动面很大。</p>
<p>第二，API 访问日志通过 Feign 同步调用 infra 服务落库。访问日志本质上属于旁路审计数据，业务线程
 <br>
 不应该等待日志服务和数据库；一旦 infra 服务抖动，日志链路还可能反向拖慢甚至影响业务请求。</p>
<p>本次改造确立了以下目标：</p>
<ul>
 <li>使用厂商中立的 OpenTelemetry 标准；</li>
 <li>尽量通过 Java Agent 零侵入采集，减少手工埋点；</li>
 <li>Trace 与 SLF4J 日志通过同一个 <code>trace_id</code> 关联；</li>
 <li>应用只连接 Collector，不直接连接 Elasticsearch；</li>
 <li>API 访问日志通过 RocketMQ 异步落库；</li>
 <li>考虑 RocketMQ 至少一次投递，实现消费者幂等；</li>
 <li>保留多租户上下文；</li>
 <li>本地、IDE、Docker 和生产环境使用同一套协议和拓扑；</li>
 <li>Elasticsearch 使用 9.x 原生 OTLP Endpoint，不再额外维护自定义日志索引映射。</li>
</ul>
<h2 id="二-先区分三类-日志-">二、先区分三类“日志”</h2>
<p>在开始搭建之前，必须先区分下面三类数据。很多方案的问题，正是因为把它们混为一谈。</p>
<h3 id="2-1-Trace-与-Span">2.1 Trace 与 Span</h3>
<p>Trace 表示一次端到端请求，Trace 内部由多个 Span 组成。例如：</p>
<pre><code class="language-text">Browser
  -&gt; Gateway HTTP Server Span
  -&gt; System HTTP Server Span
  -&gt; Feign Client Span
  -&gt; Infra HTTP Server Span
  -&gt; JDBC Client Span
</code></pre>
<p>整个调用链共享一个 32 位十六进制 Trace ID，每个 Span 有自己的 16 位 Span ID。</p>
<h3 id="2-2-应用日志">2.2 应用日志</h3>
<p>应用日志是代码通过 SLF4J 输出的内容：</p>
<pre><code class="language-java">log.info("[getPermissionInfo][OpenTelemetry 链路验证，userId({})]", userId);
</code></pre>
<p>Java Agent 会捕获 Logback 日志事件，并在事件发生时附加当前 <code>trace_id</code>、<code>span_id</code>。因此同一请求中的
 <br>
 业务日志可以和 Trace 关联。</p>
<h3 id="2-3-API-访问日志">2.3 API 访问日志</h3>
<p>API 访问日志是结构化审计记录，通常包含：</p>
<ul>
 <li>用户与租户；</li>
 <li>应用名；</li>
 <li>URL、HTTP Method、IP、User-Agent；</li>
 <li>脱敏后的请求参数；</li>
 <li>返回码、错误信息；</li>
 <li>开始时间、结束时间、耗时；</li>
 <li>Trace ID。</li>
</ul>
<p>它需要分页查询、合规留存和业务报表，所以仍然落在关系型数据库中。但落库不应该阻塞业务线程，
 <br>
 因此改为 RocketMQ 异步处理。</p>
<p>最终形成两条互相独立、又通过 Trace ID 关联的数据链路：</p>
<div class="language-mermaid">flowchart LR U["浏览器 / 调用方"] --&gt; G["Gateway"] G --&gt; S["System / 其他业务服务"] S --&gt; I["Infra / 下游服务"] G -. "Trace + SLF4J Log" .-&gt; C["OpenTelemetry Collector"] S -. "Trace + SLF4J Log" .-&gt; C I -. "Trace + SLF4J Log" .-&gt; C C --&gt; E["Elasticsearch 9.x /_otlp"] E --&gt; K["Kibana"] S -- "API Access Log Event" --&gt; R["RocketMQ"] I -- "API Access Log Event" --&gt; R R --&gt; AC["Infra Access Log Consumer"] AC --&gt; DB[("MySQL infra_api_access_log")]</div>
<h2 id="三-技术选型">三、技术选型</h2>
<h3 id="3-1-组件与版本">3.1 组件与版本</h3>
<p>本次实现采用以下版本：</p>
<table>
 <thead>
  <tr>
   <th>组件</th>
   <th>版本</th>
   <th>作用</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>JDK</td>
   <td>25</td>
   <td>应用运行时</td>
  </tr>
  <tr>
   <td>Spring Boot</td>
   <td>4.1.x</td>
   <td>应用框架</td>
  </tr>
  <tr>
   <td>OpenTelemetry Java API</td>
   <td>1.64.0</td>
   <td>少量业务手工埋点使用稳定 API</td>
  </tr>
  <tr>
   <td>OpenTelemetry Java Agent</td>
   <td>2.28.1</td>
   <td>HTTP、Feign、JDBC、RocketMQ、日志自动采集与上下文传播</td>
  </tr>
  <tr>
   <td>OpenTelemetry Collector Contrib</td>
   <td>0.159.0</td>
   <td>OTLP 网关、批处理、限流、重试、队列</td>
  </tr>
  <tr>
   <td>Elasticsearch / Kibana</td>
   <td>9.5.2</td>
   <td>Trace 与日志存储、查询和展示</td>
  </tr>
  <tr>
   <td>RocketMQ Server</td>
   <td>5.5.0</td>
   <td>API 访问日志异步投递</td>
  </tr>
  <tr>
   <td>RocketMQ Spring Boot Starter</td>
   <td>2.3.6</td>
   <td>Java 应用生产和消费消息</td>
  </tr>
 </tbody>
</table>
<p>版本资料：</p>
<ul>
 <li><a href="https://opentelemetry.io/docs/zero-code/java/agent/">OpenTelemetry Java Agent 官方文档</a></li>
 <li><a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases">OpenTelemetry Java Agent Releases</a></li>
 <li><a href="https://github.com/open-telemetry/opentelemetry-java/releases">OpenTelemetry Java Releases</a></li>
 <li><a href="https://github.com/open-telemetry/opentelemetry-collector-releases/releases">OpenTelemetry Collector Releases</a></li>
 <li><a href="https://www.elastic.co/docs/manage-data/ingest/otlp-endpoint">Elastic 原生 OTLP Endpoint</a></li>
 <li><a href="https://www.elastic.co/docs/release-notes/elasticsearch">Elasticsearch Release Notes</a></li>
 <li><a href="https://rocketmq.apache.org/release-notes/">RocketMQ Release Notes</a></li>
 <li><a href="https://www.w3.org/TR/trace-context/">W3C Trace Context</a></li>
</ul>
<h3 id="3-2-为什么选择-Java-Agent-而不是在应用里初始化完整-SDK">3.2 为什么选择 Java Agent，而不是在应用里初始化完整 SDK</h3>
<p>Java Agent 的优势是：</p>
<ul>
 <li>在应用 <code>main</code> 方法前加载；</li>
 <li>自动创建 HTTP Server/Client Span；</li>
 <li>自动处理 Feign、JDBC、RocketMQ 等常用组件；</li>
 <li>自动注入和提取 W3C <code>traceparent</code>、<code>baggage</code>；</li>
 <li>自动捕获 Logback 日志；</li>
 <li>不需要每个服务重复编写 SDK、Exporter、Processor 初始化代码；</li>
 <li>采集配置可以通过环境变量统一管理。</li>
</ul>
<p>应用代码只保留 <code>opentelemetry-api</code>，用于读取当前 Trace ID 或创建少量业务 Span：</p>
<pre><code class="language-xml">&lt;properties&gt;
    &lt;opentelemetry.version&gt;1.64.0&lt;/opentelemetry.version&gt;
&lt;/properties&gt;

&lt;dependencyManagement&gt;
    &lt;dependencies&gt;
        &lt;dependency&gt;
            &lt;groupId&gt;io.opentelemetry&lt;/groupId&gt;
            &lt;artifactId&gt;opentelemetry-api&lt;/artifactId&gt;
            &lt;version&gt;${opentelemetry.version}&lt;/version&gt;
        &lt;/dependency&gt;
    &lt;/dependencies&gt;
&lt;/dependencyManagement&gt;
</code></pre>
<p>不要同时在应用中初始化一套 OpenTelemetry SDK，又加载 Java Agent。两套 SDK/Exporter 并存容易导致：</p>
<ul>
 <li>重复 Span；</li>
 <li>重复日志；</li>
 <li><code>GlobalOpenTelemetry</code> 注册冲突；</li>
 <li>版本依赖复杂；</li>
 <li>资源属性和采样策略不一致。</li>
</ul>
<h3 id="3-3-为什么必须保留-Collector">3.3 为什么必须保留 Collector</h3>
<p>应用理论上可以直接把 OTLP 发给 Elasticsearch，但生产环境不建议这么做。Collector 提供了应用与存储
 <br>
 后端之间的隔离层：</p>
<ul>
 <li>批量发送，降低后端请求数；</li>
 <li>内存保护；</li>
 <li>发送队列；</li>
 <li>失败重试；</li>
 <li>统一认证和 TLS；</li>
 <li>数据过滤、脱敏和属性补充；</li>
 <li>后续替换或并行导出到其他后端时，不需要修改所有应用。</li>
</ul>
<p>应用只知道 Collector 的 OTLP 地址，不需要知道 Elasticsearch 的地址和凭证。</p>
<h3 id="3-4-为什么访问日志选-RocketMQ-而不是线程池异步">3.4 为什么访问日志选 RocketMQ，而不是线程池异步</h3>
<p><code>@Async</code> 或本地线程池只能把数据库操作移出请求线程，但无法解决：</p>
<ul>
 <li>应用进程崩溃导致内存任务丢失；</li>
 <li>infra 服务临时不可用；</li>
 <li>消费速度低于生产速度；</li>
 <li>跨服务削峰；</li>
 <li>重试和死信管理。</li>
</ul>
<p>RocketMQ 提供持久化、消费组、重试和死信队列，更适合审计日志事件。但 RocketMQ 是至少一次投递，
 <br>
 所以必须额外设计幂等。</p>
<h2 id="四-总体调用过程">四、总体调用过程</h2>
<p>下面是一条用户请求从前端到 ES 和访问日志数据库的完整时序。</p>
<div class="language-mermaid">sequenceDiagram autonumber participant B as Browser participant G as Gateway participant S as System Service participant DB as MySQL participant MQ as RocketMQ participant I as Infra Consumer participant C as OTel Collector participant ES as Elasticsearch B-&gt;&gt;G: HTTP Request Note over G: Agent 创建 Gateway Server Span G-&gt;&gt;S: HTTP / Feign + traceparent Note over S: Agent 提取上下文并创建 Server Span S-&gt;&gt;DB: JDBC Query Note over DB,S: Agent 创建 JDBC Client Span S--&gt;&gt;S: log.info(...) Note over S: Logback 事件附带 trace_id/span_id S--&gt;&gt;MQ: asyncSend API Access Log Note over MQ: 消息携带 Trace Context 与 tenant-id S--&gt;&gt;G: Response + trace-id G--&gt;&gt;B: Response + trace-id S--&gt;&gt;C: OTLP/HTTP Traces + Logs G--&gt;&gt;C: OTLP/HTTP Traces + Logs C--&gt;&gt;ES: /_otlp/v1/traces + /_otlp/v1/logs MQ-&gt;&gt;I: Consume Access Log I-&gt;&gt;DB: 幂等 INSERT infra_api_access_log</div>
<h2 id="五-应用侧接入-OpenTelemetry">五、应用侧接入 OpenTelemetry</h2>
<h3 id="5-1-下载并校验-Java-Agent">5.1 下载并校验 Java Agent</h3>
<p>不要在构建时下载一个不固定版本的 <code>latest</code> 文件。应固定版本，并校验 SHA-256：</p>
<pre><code class="language-bash">mkdir -p .local/otel

curl -fL \
  https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.28.1/opentelemetry-javaagent.jar \
  -o .local/otel/opentelemetry-javaagent.jar

echo "faa89bdeebf9b1f52be4a4374689176717b02a59df2d8f8b6eb9aa39f9292589  .local/otel/opentelemetry-javaagent.jar" \
  | sha256sum -c -
</code></pre>
<p>校验成功应输出：</p>
<pre><code class="language-text">.local/otel/opentelemetry-javaagent.jar: OK
</code></pre>
<h3 id="5-2-IDE-启动参数">5.2 IDE 启动参数</h3>
<p>System 服务的 VM options：</p>
<pre><code class="language-text">-javaagent:/absolute/path/opentelemetry-javaagent.jar
-Dotel.service.name=system-server
-Dotel.exporter.otlp.endpoint=http://127.0.0.1:4318
-Dotel.exporter.otlp.protocol=http/protobuf
-Dotel.propagators=tracecontext,baggage
-Dotel.traces.exporter=otlp
-Dotel.logs.exporter=otlp
-Dotel.metrics.exporter=none
</code></pre>
<p>Infra 服务只需要修改服务名：</p>
<pre><code class="language-text">-Dotel.service.name=infra-server
</code></pre>
<p>Gateway 服务使用：</p>
<pre><code class="language-text">-Dotel.service.name=gateway-server
</code></pre>
<p>几个关键配置的含义：</p>
<table>
 <thead>
  <tr>
   <th>配置</th>
   <th>含义</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><code>otel.service.name</code></td>
   <td>服务的稳定标识，查询和聚合的重要维度</td>
  </tr>
  <tr>
   <td><code>otel.exporter.otlp.endpoint</code></td>
   <td>Collector 地址，不是 Elasticsearch 地址</td>
  </tr>
  <tr>
   <td><code>otel.exporter.otlp.protocol</code></td>
   <td>Agent 2.x 推荐使用 <code>http/protobuf</code></td>
  </tr>
  <tr>
   <td><code>otel.propagators</code></td>
   <td>使用 W3C Trace Context 与 Baggage</td>
  </tr>
  <tr>
   <td><code>otel.traces.exporter</code></td>
   <td>开启 Trace OTLP 导出</td>
  </tr>
  <tr>
   <td><code>otel.logs.exporter</code></td>
   <td>开启日志 OTLP 导出</td>
  </tr>
  <tr>
   <td><code>otel.metrics.exporter</code></td>
   <td>本方案暂时关闭 OTel Metrics，避免与现有 Micrometer 重复</td>
  </tr>
 </tbody>
</table>
<p>启动日志中应出现类似内容：</p>
<pre><code class="language-text">[otel.javaagent] opentelemetry-javaagent - version: 2.28.1
</code></pre>
<p>如果完全看不到 Agent 启动信息，后续所有 Trace ID 问题都应先检查 <code>-javaagent</code> 路径，而不是检查业务代码。</p>
<h3 id="5-3-Docker-镜像内置-Agent">5.3 Docker 镜像内置 Agent</h3>
<p>容器镜像使用多阶段构建，构建阶段下载并校验 Agent，运行阶段只复制最终 JAR：</p>
<pre><code class="language-dockerfile">FROM alpine:3.22 AS otel-agent
ARG OTEL_JAVA_AGENT_VERSION=2.28.1
ARG OTEL_JAVA_AGENT_SHA256=faa89bdeebf9b1f52be4a4374689176717b02a59df2d8f8b6eb9aa39f9292589

RUN wget -q -O /opentelemetry-javaagent.jar \
    "https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v${OTEL_JAVA_AGENT_VERSION}/opentelemetry-javaagent.jar" \
    &amp;&amp; echo "${OTEL_JAVA_AGENT_SHA256}  /opentelemetry-javaagent.jar" | sha256sum -c -

FROM eclipse-temurin:25-jre
WORKDIR /app
COPY ./target/application.jar app.jar
COPY --from=otel-agent /opentelemetry-javaagent.jar /opt/opentelemetry-javaagent.jar

ENV JAVA_TOOL_OPTIONS="-javaagent:/opt/opentelemetry-javaagent.jar" \
    OTEL_SERVICE_NAME="system-server" \
    OTEL_EXPORTER_OTLP_ENDPOINT="http://otel-collector:4318" \
    OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf" \
    OTEL_PROPAGATORS="tracecontext,baggage" \
    OTEL_TRACES_EXPORTER="otlp" \
    OTEL_LOGS_EXPORTER="otlp" \
    OTEL_METRICS_EXPORTER="none"

CMD java ${JAVA_OPTS} -jar app.jar
</code></pre>
<p>这里使用 <code>JAVA_TOOL_OPTIONS</code>，JVM 会自动读取它，因此不依赖启动脚本是否把 <code>-javaagent</code> 拼进命令行。</p>
<h3 id="5-4-读取当前-Trace-ID">5.4 读取当前 Trace ID</h3>
<p>业务代码不要再调用 SkyWalking Toolkit，统一通过稳定的 OpenTelemetry API 读取当前上下文：</p>
<pre><code class="language-java">public final class TracerUtils {

    public static final String HEADER_TRACE_ID = "trace-id";

    private TracerUtils() {
    }

    public static String getTraceId() {
        SpanContext context = Span.current().getSpanContext();
        return context.isValid() ? context.getTraceId() : "";
    }
}
</code></pre>
<p>必须检查 <code>SpanContext#isValid()</code>。如果 Agent 未加载，<code>Span.current()</code> 仍然可以调用，但得到的是无效上下文；
 <br>
 此时不能把全零 Trace ID 或其他占位值返回给前端。</p>
<h3 id="5-5-把-Trace-ID-返回给前端">5.5 把 Trace ID 返回给前端</h3>
<p>Agent 在 Servlet Filter 之前创建 HTTP Server Span，所以 Filter 中可以读取当前 Trace ID：</p>
<pre><code class="language-java">public class TraceFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws IOException, ServletException {
        String traceId = TracerUtils.getTraceId();
        if (!traceId.isEmpty()) {
            response.setHeader(TracerUtils.HEADER_TRACE_ID, traceId);
        }
        chain.doFilter(request, response);
    }
}
</code></pre>
<p>注册 Filter，并保证它早于访问日志 Filter：</p>
<pre><code class="language-java">@Bean
public FilterRegistrationBean&lt;TraceFilter&gt; traceFilter() {
    FilterRegistrationBean&lt;TraceFilter&gt; bean = new FilterRegistrationBean&lt;&gt;();
    bean.setFilter(new TraceFilter());
    bean.setOrder(WebFilterOrderEnum.TRACE_FILTER);
    return bean;
}
</code></pre>
<p>响应示例：</p>
<pre><code class="language-text">HTTP/1.1 200
trace-id: 8a7c479d422fa7bc0fe1aade0d703a24
Content-Type: application/json;charset=UTF-8
</code></pre>
<h3 id="5-6-不要忘记-CORS-Expose-Headers">5.6 不要忘记 CORS Expose Headers</h3>
<p>这是一个很隐蔽的问题：浏览器开发者工具可能看到 <code>trace-id</code>，但前端 JavaScript 读取不到，因为
 <br>
 <code>trace-id</code> 不是 CORS safelisted response header。</p>
<p>需要显式暴露：</p>
<pre><code class="language-java">CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOriginPattern("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.addExposedHeader(TracerUtils.HEADER_TRACE_ID);
</code></pre>
<p>响应中会增加：</p>
<pre><code class="language-text">Access-Control-Expose-Headers: trace-id
</code></pre>
<p>前端读取方式：</p>
<pre><code class="language-typescript">// Axios
const traceId = response.headers['trace-id']

// Fetch
const traceId = response.headers.get('trace-id')
</code></pre>
<p>HTTP Header 名不区分大小写，但 Axios 通常会把 Header key 规范化成小写，因此读取时使用
 <br>
 <code>response.headers['trace-id']</code>。</p>
<h3 id="5-7-Logback-中打印-Trace-ID-和-Span-ID">5.7 Logback 中打印 Trace ID 和 Span ID</h3>
<p>Java Agent 会在 Logback 事件快照中注入 MDC 字段。日志格式直接读取这些字段：</p>
<pre><code class="language-xml">&lt;property name="CONSOLE_LOG_PATTERN"
          value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [trace_id=%X{trace_id:-} span_id=%X{span_id:-}] %highlight(%-5level) %cyan(%logger{50}:%L) - %msg%n"/&gt;

&lt;property name="FILE_LOG_PATTERN"
          value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [trace_id=%X{trace_id:-} span_id=%X{span_id:-}] %-5level %logger{50}:%L - %msg%n"/&gt;
</code></pre>
<p>业务代码不需要手动向 MDC 写入 Trace ID：</p>
<pre><code class="language-java">log.info("[getPermissionInfo][OpenTelemetry 链路验证，userId({})]", userId);
</code></pre>
<p>输出类似：</p>
<pre><code class="language-text">2026-08-23 12:47:10.123 [http-nio-48081-exec-4]
[trace_id=8a7c479d422fa7bc0fe1aade0d703a24 span_id=12b91feec45048af]
INFO  c.h.h.m.s.c.a.a.AuthController:104 -
[getPermissionInfo][OpenTelemetry 链路验证，userId(1)]
</code></pre>
<p>Logback 仍然保留控制台和滚动文件 Appender，Java Agent 额外捕获日志事件并通过 OTLP 批量发送。
 <br>
 不需要再配置一个自定义 Elasticsearch Appender，也不建议让每个应用直接写 ES。</p>
<h3 id="5-8-少量业务-Span">5.8 少量业务 Span</h3>
<p>自动埋点负责基础设施 Span，业务关键动作可以使用注解和 AOP 创建业务 Span：</p>
<pre><code class="language-java">@Around("@annotation(trace)")
public Object around(ProceedingJoinPoint joinPoint, BizTrace trace) throws Throwable {
    Span span = tracer.spanBuilder(buildOperationName(joinPoint, trace))
            .setAttribute("component", "biz")
            .startSpan();
    try (Scope ignored = span.makeCurrent()) {
        return joinPoint.proceed();
    } catch (Throwable throwable) {
        span.recordException(throwable);
        span.setStatus(StatusCode.ERROR, throwable.getMessage());
        throw throwable;
    } finally {
        span.end();
    }
}
</code></pre>
<p>不要把完整异常堆栈再作为一个字符串 Attribute 写入 Span。<code>recordException</code> 已经使用标准事件表示异常；
 <br>
 额外写大字符串会显著增加 Span 体积和 ES 存储成本。</p>
<h2 id="六-OpenTelemetry-Collector-配置">六、OpenTelemetry Collector 配置</h2>
<h3 id="6-1-完整的开发环境配置">6.1 完整的开发环境配置</h3>
<pre><code class="language-yaml">extensions:
  health_check:
    endpoint: 0.0.0.0:13133

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 384
    spike_limit_mib: 64
  batch:
    timeout: 1s
    send_batch_size: 8192
    send_batch_max_size: 10000

exporters:
  otlphttp/elasticsearch:
    endpoint: http://elasticsearch:9200/_otlp
    compression: gzip
    timeout: 30s
    sending_queue:
      enabled: true
      sizer: bytes
      queue_size: 50000000
      block_on_overflow: true
      batch:
        flush_timeout: 1s
        min_size: 1000000
        max_size: 4000000
    retry_on_failure:
      enabled: true
      initial_interval: 1s
      max_interval: 30s
      max_elapsed_time: 300s

service:
  extensions: [health_check]
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlphttp/elasticsearch]
    logs:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlphttp/elasticsearch]
</code></pre>
<h3 id="6-2-配置解释">6.2 配置解释</h3>
<p><code>otlp</code> Receiver 同时开放：</p>
<ul>
 <li><code>4317</code>：OTLP/gRPC；</li>
 <li><code>4318</code>：OTLP/HTTP。</li>
</ul>
<p>本项目 Java Agent 使用 <code>http/protobuf</code>，因此主要使用 4318。</p>
<p><code>memory_limiter</code> 必须放在处理链前面。当 Collector 内存达到阈值时，它会施加反压，避免进程因 OOM
 <br>
 直接退出。</p>
<p><code>batch</code> 将小批数据合并后再导出，降低网络请求和 ES 写入开销。</p>
<p><code>sending_queue</code> 与 <code>retry_on_failure</code> 用于处理 Elasticsearch 短暂不可用。但开发配置中的队列仍然是
 <br>
 内存队列，Collector 进程退出后数据会丢失；生产环境应使用持久队列，后文会说明。</p>
<p>Elasticsearch OTLP Endpoint 的基础地址是：</p>
<pre><code class="language-text">http://elasticsearch:9200/_otlp
</code></pre>
<p>Collector 的 <code>otlphttp</code> Exporter 会在其后追加标准 signal path：</p>
<pre><code class="language-text">/_otlp/v1/traces
/_otlp/v1/logs
</code></pre>
<h3 id="6-3-用官方二进制验证-Collector-配置">6.3 用官方二进制验证 Collector 配置</h3>
<p>不要只依靠 YAML 解析器。YAML 语法正确，不代表 Collector 组件配置正确。应使用目标版本执行：</p>
<pre><code class="language-bash">otelcol-contrib validate --config script/docker/otel-collector-config.yaml
</code></pre>
<p>命令退出码为 0 且没有错误输出，才表示该版本 Collector 接受配置。</p>
<h2 id="七-Elasticsearch-9-x-原生-OTLP-存储">七、Elasticsearch 9.x 原生 OTLP 存储</h2>
<h3 id="7-1-为什么使用原生-Endpoint">7.1 为什么使用原生 Endpoint</h3>
<p>Elasticsearch 9.x 可以直接接收 OTLP/HTTP。这样不需要：</p>
<ul>
 <li>在 Collector 中把 OTel 字段手工转换成自定义 JSON；</li>
 <li>自己维护 Trace 和 Log index template；</li>
 <li>自己定义 <code>trace_id</code>、<code>span_id</code> 等字段类型；</li>
 <li>使用老旧或非标准的日志 Appender 直写 ES。</li>
</ul>
<p>Elasticsearch 会自动创建 OTel Data Stream 和映射。</p>
<h3 id="7-2-实际生成的数据流">7.2 实际生成的数据流</h3>
<p>本次环境实际生成了：</p>
<pre><code class="language-text">logs-generic.otel-default
traces-generic.otel-default
</code></pre>
<p>查询数据流：</p>
<pre><code class="language-bash">curl 'http://127.0.0.1:9200/_data_stream?pretty'
</code></pre>
<h3 id="7-3-字段名与常见误区">7.3 字段名与常见误区</h3>
<p>在本方案的 Elastic 原生 OTLP Data Stream 中，实际字段是：</p>
<table>
 <thead>
  <tr>
   <th>含义</th>
   <th>ES 字段</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>Trace ID</td>
   <td><code>trace_id</code></td>
  </tr>
  <tr>
   <td>Span ID</td>
   <td><code>span_id</code></td>
  </tr>
  <tr>
   <td>Parent Span ID</td>
   <td><code>parent_span_id</code></td>
  </tr>
  <tr>
   <td>服务名</td>
   <td><code>resource.attributes.service.name</code></td>
  </tr>
  <tr>
   <td>日志正文</td>
   <td><code>body.text</code></td>
  </tr>
  <tr>
   <td>日志级别</td>
   <td><code>severity_text</code></td>
  </tr>
  <tr>
   <td>Logger 名</td>
   <td><code>scope.name</code></td>
  </tr>
  <tr>
   <td>Span 名</td>
   <td><code>name</code></td>
  </tr>
  <tr>
   <td>Span 属性</td>
   <td><code>attributes.*</code></td>
  </tr>
 </tbody>
</table>
<p>特别注意：这里查询的是 <code>trace_id</code>，不是很多 ECS/APM 教程里的 <code>trace.id</code>。不同接入路径使用的模板不同，
 <br>
 不要凭经验猜字段名，应该先查看实际 <code>_source</code> 和 mapping。</p>
<p>查看最新一条日志：</p>
<pre><code class="language-bash">curl -H 'Content-Type: application/json' \
  'http://127.0.0.1:9200/logs-generic.otel-default/_search?pretty' \
  -d '{
    "size": 1,
    "sort": [{"@timestamp": "desc"}],
    "query": {"match_all": {}}
  }'
</code></pre>
<h2 id="八-将-API-访问日志改成-RocketMQ-异步落库">八、将 API 访问日志改成 RocketMQ 异步落库</h2>
<h3 id="8-1-改造前后的区别">8.1 改造前后的区别</h3>
<p>改造前：</p>
<div class="language-mermaid">flowchart LR A["业务请求线程"] --&gt; F["ApiAccessLogFilter"] F --&gt; Feign["Feign 同步调用 Infra"] Feign --&gt; DB[("访问日志表")] DB --&gt; A</div>
<p>只要 Infra、网络或数据库慢，业务响应就会等待。</p>
<p>改造后：</p>
<div class="language-mermaid">flowchart LR A["业务请求线程"] --&gt; F["ApiAccessLogFilter"] F --&gt; P["RocketMQ asyncSend"] P --&gt; A P -. "Broker 持久化" .-&gt; MQ["api-access-log Topic"] MQ --&gt; C["Infra Consumer"] C --&gt; DB[("infra_api_access_log")]</div>
<h3 id="8-2-添加-RocketMQ-依赖">8.2 添加 RocketMQ 依赖</h3>
<p>Web Starter 将 RocketMQ 依赖标记为 optional，未使用 MQ 的应用不会被强制传递：</p>
<pre><code class="language-xml">&lt;dependency&gt;
    &lt;groupId&gt;org.apache.rocketmq&lt;/groupId&gt;
    &lt;artifactId&gt;rocketmq-spring-boot-starter&lt;/artifactId&gt;
    &lt;optional&gt;true&lt;/optional&gt;
&lt;/dependency&gt;
</code></pre>
<p>真正发送或消费访问日志的服务显式引入该 Starter。</p>
<h3 id="8-3-自动配置条件">8.3 自动配置条件</h3>
<p>只有同时满足以下条件才创建 Producer 和访问日志 Filter：</p>
<ul>
 <li>classpath 中存在 <code>RocketMQTemplate</code>；</li>
 <li><code>handao.access-log.enable=true</code>；</li>
 <li>配置了 <code>rocketmq.name-server</code>；</li>
 <li>配置了 <code>rocketmq.producer.group</code>。</li>
</ul>
<pre><code class="language-java">@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(
        prefix = "handao.access-log",
        name = "enable",
        havingValue = "true",
        matchIfMissing = true)
@ConditionalOnProperty(
        prefix = "rocketmq",
        name = {"name-server", "producer.group"})
public ApiAccessLogProducer apiAccessLogProducer(
        RocketMQTemplate rocketMQTemplate,
        @Value("${handao.access-log.rocketmq.topic:api-access-log}") String topic) {
    return new ApiAccessLogProducer(rocketMQTemplate, topic);
}
</code></pre>
<p>这样可以避免未配置 RocketMQ 的轻量应用在启动时失败。</p>
<h3 id="8-4-生产端异步发送">8.4 生产端异步发送</h3>
<pre><code class="language-java">@Slf4j
public class ApiAccessLogProducer {

    private final RocketMQTemplate rocketMQTemplate;
    private final String topic;

    public void send(ApiAccessLogCreateReqDTO dto) {
        String message = null;
        try {
            message = JsonUtils.toJsonString(dto);
            String finalMessage = message;
            rocketMQTemplate.asyncSend(topic, message, new SendCallback() {
                @Override
                public void onSuccess(SendResult result) {
                    // 旁路日志成功时不再打印日志，避免日志放大
                }

                @Override
                public void onException(Throwable throwable) {
                    log.error("[send][访问日志异步发送失败，topic({}) message({})]",
                            topic, StrUtils.maxLength(finalMessage, 500), throwable);
                }
            });
        } catch (Throwable throwable) {
            log.error("[send][访问日志提交发送失败，topic({}) message({})]",
                    topic, StrUtils.maxLength(message, 500), throwable);
        }
    }
}
</code></pre>
<p>这里有两个设计点：</p>
<ol>
 <li>使用 <code>asyncSend</code>，业务线程不等待 Broker 返回；</li>
 <li>访问日志属于旁路数据，发送失败只记录错误，不影响业务响应。</li>
</ol>
<p>第二点是业务取舍。如果访问日志属于强合规数据、要求绝不丢失，就不能只使用 best-effort 异步发送，
 <br>
 应改为本地事务 Outbox、CDC 或其他可靠事件方案。</p>
<h3 id="8-5-在生产端预生成日志-ID">8.5 在生产端预生成日志 ID</h3>
<p>RocketMQ 是至少一次投递。消费者处理成功但 ACK 丢失时，同一消息可能再次到达。如果数据库继续使用
 <br>
 自增 ID，每次消费都会生成新行，无法识别重复消息。</p>
<p>解决方法是在生产端生成稳定 ID，并把它放进消息：</p>
<pre><code class="language-java">// 生产端预生成主键，同一消息无论消费多少次，ID 都不变
accessLog.setId(IdUtil.getSnowflakeNextId());
accessLog.setTraceId(TracerUtils.getTraceId());
</code></pre>
<p>DTO 中把 ID 设为必填：</p>
<pre><code class="language-java">@NotNull(message = "日志编号不能为空")
private Long id;
</code></pre>
<h3 id="8-6-消费者落库与重试">8.6 消费者落库与重试</h3>
<pre><code class="language-java">@Component
@ConditionalOnProperty(
        prefix = "handao.access-log",
        name = "enable",
        havingValue = "true",
        matchIfMissing = true)
@ConditionalOnProperty(prefix = "rocketmq", name = "name-server")
@RocketMQMessageListener(
        topic = "${handao.access-log.rocketmq.topic:api-access-log}",
        consumerGroup = "${handao.access-log.rocketmq.consumer-group:api-access-log-consumer}")
public class ApiAccessLogMessageConsumer implements RocketMQListener&lt;String&gt; {

    @Resource
    private ApiAccessLogService apiAccessLogService;

    @Override
    public void onMessage(String message) {
        try {
            ApiAccessLogCreateReqDTO dto =
                    JsonUtils.parseObject(message, ApiAccessLogCreateReqDTO.class);
            apiAccessLogService.createApiAccessLog(dto);
        } catch (RuntimeException exception) {
            log.error("[onMessage][API 访问日志落库失败，message({})]", message, exception);
            throw exception;
        }
    }
}
</code></pre>
<p>消费者异常必须继续抛出。吞掉异常会让 RocketMQ 误以为消费成功，失去自动重试和死信能力。</p>
<h3 id="8-7-数据库幂等">8.7 数据库幂等</h3>
<p>重复消息第二次插入时会命中相同主键。确认数据库中确实已有该 ID 后，将其视为成功：</p>
<pre><code class="language-java">try {
    apiAccessLogMapper.insert(apiAccessLog);
} catch (DuplicateKeyException exception) {
    ApiAccessLogDO existing = TenantUtils.executeIgnore(
            () -&gt; apiAccessLogMapper.selectById(apiAccessLog.getId()));
    if (existing == null) {
        // 可能是其他唯一约束冲突，不能错误地吞掉
        throw exception;
    }
    log.debug("[createApiAccessLog][访问日志({}) 已存在，忽略重复消息]",
            apiAccessLog.getId());
}
</code></pre>
<p>为什么捕获异常后还要查询一次？因为 <code>DuplicateKeyException</code> 不一定来自主键，也可能来自其他唯一索引。
 <br>
 只有相同访问日志 ID 已经存在时，才能确认这是同一消息的重复投递。</p>
<h3 id="8-8-多租户上下文跨-MQ-传播">8.8 多租户上下文跨 MQ 传播</h3>
<p>HTTP 请求中的租户 ID 通常保存在 ThreadLocal 中。消息进入 RocketMQ 后切换了线程和进程，ThreadLocal
 <br>
 不会自动存在。</p>
<p>生产 Hook 把租户 ID 写入消息属性：</p>
<pre><code class="language-java">public void sendMessageBefore(SendMessageContext context) {
    Long tenantId = TenantContextHolder.getTenantId();
    if (tenantId != null) {
        context.getMessage().putUserProperty("tenant-id", tenantId.toString());
    }
}
</code></pre>
<p>消费 Hook 在执行监听器之前恢复，之后必须清理：</p>
<pre><code class="language-java">public void consumeMessageBefore(ConsumeMessageContext context) {
    String tenantId = context.getMsgList().getFirst().getUserProperty("tenant-id");
    if (StrUtil.isNotEmpty(tenantId)) {
        TenantContextHolder.setTenantId(Long.parseLong(tenantId));
    }
}

public void consumeMessageAfter(ConsumeMessageContext context) {
    TenantContextHolder.clear();
}
</code></pre>
<p>如果不在 <code>after</code> 中清理，线程池复用时可能发生严重的跨租户数据污染。</p>
<h3 id="8-9-配置项">8.9 配置项</h3>
<pre><code class="language-yaml">rocketmq:
  name-server: 192.168.1.117:9876
  producer:
    group: ${spring.application.name}_PRODUCER

handao:
  access-log:
    enable: true
    rocketmq:
      topic: api-access-log
      consumer-group: api-access-log-consumer
</code></pre>
<p>Topic 和 Consumer Group 应保持稳定，不要把随机实例 ID 放入 Consumer Group，否则每个实例都会收到一份
 <br>
 消息，导致重复消费压力。</p>
<h2 id="九-Docker-Compose-部署中间件">九、Docker Compose 部署中间件</h2>
<p>下面是核心编排的精简版本：</p>
<pre><code class="language-yaml">services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:9.5.2
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - ES_JAVA_OPTS=-Xms1g -Xmx1g
    ports:
      - "9200:9200"
    volumes:
      - elasticsearch-data:/usr/share/elasticsearch/data

  kibana:
    image: docker.elastic.co/kibana/kibana:9.5.2
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    ports:
      - "5601:5601"
    depends_on:
      elasticsearch:
        condition: service_healthy

  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.159.0
    command: ["--config=/etc/otelcol-contrib/config.yaml"]
    volumes:
      - ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:ro
    ports:
      - "4317:4317"
      - "4318:4318"
      - "13133:13133"

  rocketmq-namesrv:
    image: apache/rocketmq:5.5.0
    command: sh mqnamesrv
    ports:
      - "9876:9876"

  rocketmq-broker:
    image: apache/rocketmq:5.5.0
    command: sh mqbroker -c /home/rocketmq/conf/broker.conf --enable-proxy
    environment:
      - NAMESRV_ADDR=rocketmq-namesrv:9876
    ports:
      - "8080:8080"
      - "8081:8081"
      - "10909:10909"
      - "10911:10911"
      - "10912:10912"
</code></pre>
<p>本地启动：</p>
<pre><code class="language-bash">cd script/docker
docker compose up -d \
  elasticsearch kibana otel-collector rocketmq-namesrv rocketmq-broker
</code></pre>
<p>使用 Podman 时：</p>
<pre><code class="language-bash">podman compose up -d \
  elasticsearch kibana otel-collector rocketmq-namesrv rocketmq-broker
</code></pre>
<h3 id="9-1-RocketMQ-的-brokerIP1-陷阱">9.1 RocketMQ 的 brokerIP1 陷阱</h3>
<p>Broker 向 NameServer 注册的不只是名字，还包含客户端后续连接的 Broker 地址。</p>
<p>如果所有应用和 Broker 都在同一台机器，可以使用：</p>
<pre><code class="language-properties">brokerIP1=127.0.0.1
</code></pre>
<p>如果应用在另一台机器，而 Broker 部署在 <code>192.168.1.117</code>，必须使用：</p>
<pre><code class="language-properties">brokerIP1=192.168.1.117
</code></pre>
<p>否则会出现非常迷惑的现象：</p>
<ul>
 <li>NameServer 9876 可连接；</li>
 <li>应用能查到 Topic 路由；</li>
 <li>但路由返回 <code>127.0.0.1:10911</code>；</li>
 <li>应用最终连接的是自己的本机 10911，发送失败。</li>
</ul>
<p>因此远程排查 RocketMQ 时，不要只测试 9876，还要测试 10911，并确认 Broker 广播地址。</p>
<h2 id="十-本地应用连接远程中间件">十、本地应用连接远程中间件</h2>
<p>假设中间件统一位于：</p>
<pre><code class="language-text">MIDDLEWARE_HOST=192.168.1.117
</code></pre>
<p>推荐先检查端口：</p>
<pre><code class="language-bash">for port in 3306 6379 8848 9876 10911 4318 13133 9200 5601; do
  nc -z -w 2 192.168.1.117 "$port" \
    &amp;&amp; echo "OPEN $port" \
    || echo "CLOSED $port"
done
</code></pre>
<p>常用端口：</p>
<table>
 <thead>
  <tr>
   <th>端口</th>
   <th>服务</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>3306</td>
   <td>MySQL</td>
  </tr>
  <tr>
   <td>6379</td>
   <td>Redis</td>
  </tr>
  <tr>
   <td>8848</td>
   <td>Nacos</td>
  </tr>
  <tr>
   <td>9876</td>
   <td>RocketMQ NameServer</td>
  </tr>
  <tr>
   <td>10911</td>
   <td>RocketMQ Broker</td>
  </tr>
  <tr>
   <td>4317</td>
   <td>Collector OTLP/gRPC</td>
  </tr>
  <tr>
   <td>4318</td>
   <td>Collector OTLP/HTTP</td>
  </tr>
  <tr>
   <td>13133</td>
   <td>Collector Health Check</td>
  </tr>
  <tr>
   <td>9200</td>
   <td>Elasticsearch</td>
  </tr>
  <tr>
   <td>5601</td>
   <td>Kibana</td>
  </tr>
 </tbody>
</table>
<h3 id="10-1-远端只有-ES-本机启动-Collector">10.1 远端只有 ES，本机启动 Collector</h3>
<p>复制一份临时配置，把 Docker DNS 名 <code>elasticsearch</code> 替换为远端 IP：</p>
<pre><code class="language-bash">sed 's#http://elasticsearch:9200#http://192.168.1.117:9200#' \
  script/docker/otel-collector-config.yaml \
  &gt; /tmp/handao-otel-collector.yaml
</code></pre>
<p>运行 Collector：</p>
<pre><code class="language-bash">podman run --rm \
  --name handao-otel-collector \
  --network host \
  -v /tmp/handao-otel-collector.yaml:/etc/otelcol-contrib/config.yaml:ro,Z \
  otel/opentelemetry-collector-contrib:0.159.0 \
  --config=/etc/otelcol-contrib/config.yaml
</code></pre>
<p>验证：</p>
<pre><code class="language-bash">curl http://127.0.0.1:13133
</code></pre>
<h3 id="10-2-启动顺序">10.2 启动顺序</h3>
<p>微服务模式推荐：</p>
<ol>
 <li>Elasticsearch；</li>
 <li>OpenTelemetry Collector；</li>
 <li>RocketMQ NameServer 和 Broker；</li>
 <li>Nacos、MySQL、Redis；</li>
 <li>Infra；</li>
 <li>System；</li>
 <li>Gateway；</li>
 <li>前端。</li>
</ol>
<p>Infra 先启动，是因为它包含 API 访问日志消费者。System 即使先启动也不会影响业务启动，但早期产生的
 <br>
 访问日志需要等待消费者上线后才能落库。</p>
<h2 id="十一-端到端验证">十一、端到端验证</h2>
<h3 id="11-1-增加一条唯一的-SLF4J-验证日志">11.1 增加一条唯一的 SLF4J 验证日志</h3>
<p>选择登录后前端稳定调用的接口：</p>
<pre><code class="language-text">GET /admin-api/system/auth/get-permission-info
</code></pre>
<p>在 Controller 中添加：</p>
<pre><code class="language-java">public CommonResult&lt;AuthPermissionInfoRespVO&gt; getPermissionInfo() {
    Long userId = getLoginUserId();
    log.info("[getPermissionInfo][OpenTelemetry 链路验证，userId({})]", userId);
    // ...
}
</code></pre>
<p>重启 System，登录或刷新前端页面。</p>
<h3 id="11-2-获取-Trace-ID">11.2 获取 Trace ID</h3>
<p>浏览器 Network 面板或 curl 查看响应头：</p>
<pre><code class="language-bash">curl -i \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  http://127.0.0.1:48081/admin-api/system/auth/get-permission-info
</code></pre>
<p>记录：</p>
<pre><code class="language-text">trace-id: 8a7c479d422fa7bc0fe1aade0d703a24
</code></pre>
<h3 id="11-3-Kibana-查询">11.3 Kibana 查询</h3>
<p>为下面两个 Data Stream 创建 Data View：</p>
<pre><code class="language-text">logs-generic.otel-*
traces-generic.otel-*
</code></pre>
<p>KQL：</p>
<pre><code class="language-text">trace_id : "8a7c479d422fa7bc0fe1aade0d703a24"
</code></pre>
<p>查验证日志：</p>
<pre><code class="language-text">body.text : "*OpenTelemetry 链路验证*"
</code></pre>
<h3 id="11-4-直接使用-Elasticsearch-API-查询">11.4 直接使用 Elasticsearch API 查询</h3>
<p>同时查询日志和 Trace：</p>
<pre><code class="language-bash">curl -H 'Content-Type: application/json' \
  'http://192.168.1.117:9200/logs-generic.otel-default,traces-generic.otel-default/_search?pretty' \
  -d '{
    "size": 100,
    "sort": [{"@timestamp": "asc"}],
    "query": {
      "term": {
        "trace_id": "8a7c479d422fa7bc0fe1aade0d703a24"
      }
    }
  }'
</code></pre>
<p>先按日志内容反查 Trace ID：</p>
<pre><code class="language-bash">curl -H 'Content-Type: application/json' \
  'http://192.168.1.117:9200/logs-generic.otel-default/_search?pretty' \
  -d '{
    "size": 20,
    "sort": [{"@timestamp": "desc"}],
    "query": {
      "match_phrase": {
        "body.text": "OpenTelemetry 链路验证"
      }
    }
  }'
</code></pre>
<p>一个成功的日志文档应同时包含：</p>
<pre><code class="language-json">{
  "trace_id": "8a7c479d422fa7bc0fe1aade0d703a24",
  "span_id": "12b91feec45048af",
  "severity_text": "INFO",
  "resource": {
    "attributes": {
      "service.name": "system-server"
    }
  },
  "scope": {
    "name": "com.example.system.AuthController"
  },
  "body": {
    "text": "[getPermissionInfo][OpenTelemetry 链路验证，userId(1)]"
  }
}
</code></pre>
<h3 id="11-5-验证访问日志异步落库">11.5 验证访问日志异步落库</h3>
<p>接口调用后查询：</p>
<pre><code class="language-sql">SELECT id,
       trace_id,
       application_name,
       request_method,
       request_url,
       duration,
       result_code,
       begin_time
FROM infra_api_access_log
ORDER BY id DESC
LIMIT 20;
</code></pre>
<p>表中的 <code>trace_id</code> 应与响应头和 ES 中的 <code>trace_id</code> 一致。</p>
<h3 id="11-6-如何判断整个链路真正成功">11.6 如何判断整个链路真正成功</h3>
<p>不要只看“ES 有数据”。完整验收应该同时满足：</p>
<ul>
 <li>响应头存在有效 <code>trace-id</code>；</li>
 <li>跨域前端能读取 <code>trace-id</code>；</li>
 <li>Logback 控制台日志含相同 <code>trace_id</code>；</li>
 <li><code>logs-generic.otel-default</code> 能按该 ID 查到业务日志；</li>
 <li><code>traces-generic.otel-default</code> 能查到 HTTP、JDBC、Feign 等 Span；</li>
 <li>多服务 Span 共享同一 Trace ID；</li>
 <li>RocketMQ 消费 Span 能与生产端上下文关联；</li>
 <li><code>infra_api_access_log.trace_id</code> 一致；</li>
 <li>重复消费同一访问日志消息不会产生第二行数据库记录。</li>
</ul>
<h2 id="十二-自动化测试与配置验证">十二、自动化测试与配置验证</h2>
<h3 id="12-1-Trace-ID-工具测试">12.1 Trace ID 工具测试</h3>
<pre><code class="language-java">@Test
void getTraceIdReturnsCurrentOpenTelemetryTraceId() {
    SpanContext context = SpanContext.create(
            "0123456789abcdef0123456789abcdef",
            "0123456789abcdef",
            TraceFlags.getSampled(),
            TraceState.getDefault());

    try (Scope ignored = Span.wrap(context).makeCurrent()) {
        assertEquals(
                "0123456789abcdef0123456789abcdef",
                TracerUtils.getTraceId());
    }
}
</code></pre>
<p>还应验证没有当前 Span 时返回空字符串。</p>
<h3 id="12-2-CORS-测试">12.2 CORS 测试</h3>
<pre><code class="language-java">@Test
void corsFilterExposesTraceIdHeader() throws Exception {
    MockHttpServletRequest request =
            new MockHttpServletRequest("GET", "/admin-api/test");
    request.addHeader(HttpHeaders.ORIGIN, "http://localhost:3000");
    MockHttpServletResponse response = new MockHttpServletResponse();

    CorsFilter filter = new HandaoWebAutoConfiguration()
            .corsFilterBean().getFilter();

    filter.doFilter(request, response,
            (req, resp) -&gt; ((HttpServletResponse) resp)
                    .setHeader("trace-id", "0123456789abcdef0123456789abcdef"));

    assertEquals("trace-id",
            response.getHeader(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS));
}
</code></pre>
<h3 id="12-3-MQ-幂等测试">12.3 MQ 幂等测试</h3>
<p>用同一个 DTO 连续调用两次：</p>
<pre><code class="language-java">apiAccessLogService.createApiAccessLog(createDTO);
apiAccessLogService.createApiAccessLog(createDTO);

assertEquals(1L, apiAccessLogMapper.selectCount());
</code></pre>
<h3 id="12-4-构建验证">12.4 构建验证</h3>
<pre><code class="language-bash">mvn -pl \
  handao-gateway,\
  handao-module-system/handao-module-system-server,\
  handao-module-infra/handao-module-infra-server,\
  handao-server \
  -am -DskipTests compile
</code></pre>
<h3 id="12-5-Compose-与-Collector-验证">12.5 Compose 与 Collector 验证</h3>
<pre><code class="language-bash">docker compose -f script/docker/docker-compose.yml config

otelcol-contrib validate \
  --config script/docker/otel-collector-config.yaml
</code></pre>
<p>两种验证解决不同问题：Compose 验证编排，Collector 验证组件配置。</p>
<h2 id="十三-常见故障排查">十三、常见故障排查</h2>
<h3 id="13-1-响应没有-trace-id">13.1 响应没有 trace-id</h3>
<p>检查顺序：</p>
<ol>
 <li>JVM 命令行是否真的包含 <code>-javaagent:/.../opentelemetry-javaagent.jar</code>；</li>
 <li>启动日志是否打印 Agent 版本；</li>
 <li>Agent 文件路径是否存在；</li>
 <li><code>handao.tracer.enable</code> 是否被关闭；</li>
 <li><code>TraceFilter</code> 自动配置是否加载；</li>
 <li>请求是否经过应用，而不是被前置代理直接返回。</li>
</ol>
<p>快速检查进程：</p>
<pre><code class="language-bash">ps -ef | grep opentelemetry-javaagent
</code></pre>
<h3 id="13-2-浏览器-Network-能看到-trace-id-但-Axios-读不到">13.2 浏览器 Network 能看到 trace-id，但 Axios 读不到</h3>
<p>原因几乎一定是缺少：</p>
<pre><code class="language-text">Access-Control-Expose-Headers: trace-id
</code></pre>
<p>后端 CORS 配置增加 <code>addExposedHeader("trace-id")</code>，重启应用。</p>
<h3 id="13-3-控制台有日志-但-ES-没有日志">13.3 控制台有日志，但 ES 没有日志</h3>
<p>检查：</p>
<ul>
 <li><code>otel.logs.exporter=otlp</code>；</li>
 <li>Agent 是否加载；</li>
 <li>Collector 4318 是否可达；</li>
 <li>Collector 是否启用了 <code>logs</code> pipeline；</li>
 <li><code>otlp</code> Receiver、<code>batch</code> Processor、Exporter 是否都在 logs pipeline 中；</li>
 <li>Collector 日志是否出现 401、403、404、429 或连接超时；</li>
 <li>Elasticsearch <code>/_otlp/v1/logs</code> 是否可用。</li>
</ul>
<h3 id="13-4-Trace-有数据-但日志没有-trace-id">13.4 Trace 有数据，但日志没有 trace_id</h3>
<p>可能原因：</p>
<ul>
 <li>日志发生在 HTTP Span 建立前，例如应用启动日志；</li>
 <li>日志发生在 Span 结束后的异步线程，且上下文未传播；</li>
 <li>手工创建 Span 后没有 <code>makeCurrent()</code>；</li>
 <li>线程池被不兼容的包装器绕过 Agent；</li>
 <li>日志采集配置被关闭。</li>
</ul>
<p>并不是所有日志都应该有 Trace ID。启动日志、定时任务全局日志没有当前 Trace 时，<code>trace_id</code> 为空是正常的。</p>
<h3 id="13-5-Kibana-用-trace-id-查不到">13.5 Kibana 用 trace.id 查不到</h3>
<p>检查实际 <code>_source</code>。本方案使用 Elastic 原生 OTLP Data Stream，字段是：</p>
<pre><code class="language-text">trace_id
</code></pre>
<p>不是：</p>
<pre><code class="language-text">trace.id
</code></pre>
<h3 id="13-6-Collector-启动失败">13.6 Collector 启动失败</h3>
<p>典型原因是拿新版本文档配置旧版本 Collector，或反过来。必须使用和容器完全相同版本的二进制执行：</p>
<pre><code class="language-bash">otelcol-contrib validate --config config.yaml
</code></pre>
<h3 id="13-7-RocketMQ-NameServer-能连接但消息发送失败">13.7 RocketMQ NameServer 能连接但消息发送失败</h3>
<p>检查 Broker 返回的地址。远程 Broker 的 <code>brokerIP1</code> 不能是 <code>127.0.0.1</code>。同时检查防火墙是否开放：</p>
<pre><code class="language-text">9876
10909
10911
10912
</code></pre>
<h3 id="13-8-访问日志出现重复数据">13.8 访问日志出现重复数据</h3>
<p>检查：</p>
<ul>
 <li>ID 是否在生产端生成；</li>
 <li>消息重试时是否复用了相同消息体；</li>
 <li>DTO 到 DO 的转换是否保留 ID；</li>
 <li>数据库是否真的以该 ID 为主键；</li>
 <li>消费者是否错误地重新生成 ID。</li>
</ul>
<h3 id="13-9-访问日志不落库">13.9 访问日志不落库</h3>
<p>检查：</p>
<ul>
 <li><code>handao.access-log.enable=true</code>；</li>
 <li><code>rocketmq.name-server</code> 和 <code>rocketmq.producer.group</code> 是否存在；</li>
 <li>Topic 是否自动创建或已经预创建；</li>
 <li>Infra Consumer Group 是否启动；</li>
 <li>Broker 是否返回正确 IP；</li>
 <li>消息是否进入重试或死信队列；</li>
 <li>租户上下文是否恢复；</li>
 <li>数据库表和主键类型是否匹配 Snowflake Long。</li>
</ul>
<h2 id="十四-生产环境必须补齐的能力">十四、生产环境必须补齐的能力</h2>
<p>开发环境跑通不等于可以直接上线。至少应处理以下事项。</p>
<h3 id="14-1-Elasticsearch-安全">14.1 Elasticsearch 安全</h3>
<p>开发 Compose 中为了快速验证使用：</p>
<pre><code class="language-yaml">xpack.security.enabled=false
</code></pre>
<p>生产环境必须启用 TLS 和认证。Collector 可以集中持有 API Key：</p>
<pre><code class="language-yaml">exporters:
  otlphttp/elasticsearch:
    endpoint: https://elasticsearch.example.com:9200/_otlp
    headers:
      Authorization: "ApiKey ${env:ELASTICSEARCH_API_KEY}"
    tls:
      ca_file: /etc/otelcol/certs/ca.crt
</code></pre>
<p>API Key 不应写进 Git，也不应下发给所有业务应用。</p>
<h3 id="14-2-Collector-持久队列">14.2 Collector 持久队列</h3>
<p>内存队列在 Collector 重启时会丢失。生产环境可以启用 <code>file_storage</code>：</p>
<pre><code class="language-yaml">extensions:
  health_check:
  file_storage:
    directory: /var/lib/otelcol/storage

exporters:
  otlphttp/elasticsearch:
    sending_queue:
      enabled: true
      storage: file_storage

service:
  extensions: [health_check, file_storage]
</code></pre>
<p>对应目录必须挂载持久卷，并监控磁盘容量。</p>
<h3 id="14-3-Collector-高可用">14.3 Collector 高可用</h3>
<p>生产环境应运行多个 Collector Gateway 实例，通过 Service 或负载均衡器提供统一 OTLP 地址。</p>
<p>需要监控：</p>
<ul>
 <li>Receiver 接收速率；</li>
 <li>Exporter 成功和失败数量；</li>
 <li>Queue 容量和使用量；</li>
 <li>Retry 次数；</li>
 <li>Dropped spans/log records；</li>
 <li>Collector 内存、CPU、GC；</li>
 <li>Elasticsearch 429 和 ingest latency。</li>
</ul>
<h3 id="14-4-Trace-采样">14.4 Trace 采样</h3>
<p>开发环境通常 100% 采样：</p>
<pre><code class="language-text">OTEL_TRACES_SAMPLER=always_on
</code></pre>
<p>高流量生产环境可以使用比例采样：</p>
<pre><code class="language-text">OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1
</code></pre>
<p>但头部采样无法提前知道请求是否会失败。若希望保留全部错误和慢请求，可在 Collector 中使用 tail
 <br>
 sampling。Tail sampling 必须看到一条 Trace 的所有 Span，因此通常要求相同 Trace 路由到同一 Collector，
 <br>
 并评估等待窗口带来的内存开销。</p>
<h3 id="14-5-日志保留与-ILM">14.5 日志保留与 ILM</h3>
<p>Trace、应用日志和访问日志的保留周期应分别设计：</p>
<table>
 <thead>
  <tr>
   <th>数据</th>
   <th>常见保留策略示例</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>Trace</td>
   <td>7～30 天，按采样率和调用量调整</td>
  </tr>
  <tr>
   <td>INFO 应用日志</td>
   <td>15～30 天</td>
  </tr>
  <tr>
   <td>ERROR 日志</td>
   <td>30～90 天</td>
  </tr>
  <tr>
   <td>API 访问审计日志</td>
   <td>根据业务与合规要求，可能 180 天以上</td>
  </tr>
 </tbody>
</table>
<p>不要直接修改 Elastic 内置的 OTel 模板。应通过 <code>logs-otel@custom</code>、<code>traces-otel@custom</code> 等官方扩展点
 <br>
 定制生命周期和映射。</p>
<h3 id="14-6-敏感数据与成本控制">14.6 敏感数据与成本控制</h3>
<p>禁止采集或输出：</p>
<ul>
 <li>密码；</li>
 <li>Access Token、Refresh Token；</li>
 <li>Cookie、Authorization Header；</li>
 <li>身份证号、银行卡号等敏感信息；</li>
 <li>完整大请求体和大响应体；</li>
 <li>无限制 SQL 参数；</li>
 <li>完整异常对象的重复字符串副本。</li>
</ul>
<p>API 访问日志应在进入 MQ 前脱敏和截断，否则超大消息不仅增加 Broker 压力，还可能超过 RocketMQ 消息
 <br>
 大小限制。现有脱敏逻辑如果解析失败后选择保留原字符串，属于 fail-open 策略；强合规场景应改成解析失败
 <br>
 直接丢弃请求体或仅记录摘要。</p>
<h3 id="14-7-访问日志可靠性等级">14.7 访问日志可靠性等级</h3>
<p>本方案的语义是：</p>
<ul>
 <li>请求线程不等待 Broker ACK；</li>
 <li>Broker 接受消息后，依赖 RocketMQ 至少一次投递；</li>
 <li>消费端通过预生成主键幂等；</li>
 <li>发送失败只记录错误，不影响业务请求。</li>
</ul>
<p>它适合大多数运营和排障访问日志，但不是严格“零丢失”审计。如果业务要求日志和业务事务原子一致，
 <br>
 推荐：</p>
<div class="language-mermaid">flowchart LR T["业务本地事务"] --&gt; B[("业务表")] T --&gt; O[("Outbox 事件表")] O --&gt; CDC["CDC / 定时发布器"] CDC --&gt; MQ["RocketMQ"] MQ --&gt; A[("审计日志库")]</div>
<h3 id="14-8-时间同步">14.8 时间同步</h3>
<p>跨服务 Trace 高度依赖时间。所有宿主机、容器节点应启用 NTP/chrony。时钟漂移会导致：</p>
<ul>
 <li>Span 时间线顺序混乱；</li>
 <li>负耗时或异常耗时；</li>
 <li>日志与 Trace 在时间窗口中看似不相关；</li>
 <li>Kibana 查询遗漏。</li>
</ul>
<h3 id="14-9-版本升级策略">14.9 版本升级策略</h3>
<p>不要盲目使用浮动标签。推荐：</p>
<ol>
 <li>固定 Agent、Collector、Elasticsearch、Kibana、RocketMQ 版本；</li>
 <li>Agent JAR 固定 SHA-256；</li>
 <li>Elasticsearch 与 Kibana 保持相同版本；</li>
 <li>Collector 升级前运行 <code>validate</code>；</li>
 <li>阅读版本 Breaking Changes；</li>
 <li>在测试环境回放真实 Trace/Log 流量；</li>
 <li>检查 Data Stream、模板、ILM 和字段变化；</li>
 <li>灰度升级 Collector，再升级 Agent；</li>
 <li>保留快速回滚镜像。</li>
</ol>
<h2 id="十五-最终验收清单">十五、最终验收清单</h2>
<p>上线前可以直接按下面的清单逐项确认。</p>
<h3 id="应用">应用</h3>
<ul>
 <li class="vditor-task"><input disabled type="checkbox"> 所有服务加载相同基线的 OpenTelemetry Java Agent；</li>
 <li class="vditor-task"><input disabled type="checkbox"> 每个服务有唯一且稳定的 <code>service.name</code>；</li>
 <li class="vditor-task"><input disabled type="checkbox"> 使用 <code>tracecontext,baggage</code>；</li>
 <li class="vditor-task"><input disabled type="checkbox"> Trace 和 Log 均导出到 Collector；</li>
 <li class="vditor-task"><input disabled type="checkbox"> Logback Pattern 包含 <code>trace_id</code>、<code>span_id</code>；</li>
 <li class="vditor-task"><input disabled type="checkbox"> 响应 Header 返回有效 <code>trace-id</code>；</li>
 <li class="vditor-task"><input disabled type="checkbox"> CORS 暴露 <code>trace-id</code>；</li>
 <li class="vditor-task"><input disabled type="checkbox"> 前端能够读取并展示/上报 Trace ID。</li>
</ul>
<h3 id="Collector-与-Elasticsearch">Collector 与 Elasticsearch</h3>
<ul>
 <li class="vditor-task"><input disabled type="checkbox"> Collector 配置通过目标版本 <code>validate</code>；</li>
 <li class="vditor-task"><input disabled type="checkbox"> 4318 和 13133 健康可达；</li>
 <li class="vditor-task"><input disabled type="checkbox"> <code>logs-generic.otel-default</code> 有数据；</li>
 <li class="vditor-task"><input disabled type="checkbox"> <code>traces-generic.otel-default</code> 有数据；</li>
 <li class="vditor-task"><input disabled type="checkbox"> 同一请求的日志和 Span 使用相同 <code>trace_id</code>；</li>
 <li class="vditor-task"><input disabled type="checkbox"> Collector 配置队列、重试和内存限制；</li>
 <li class="vditor-task"><input disabled type="checkbox"> 生产环境启用 TLS、API Key、持久队列；</li>
 <li class="vditor-task"><input disabled type="checkbox"> 配置 ILM 和容量告警。</li>
</ul>
<h3 id="RocketMQ-访问日志">RocketMQ 访问日志</h3>
<ul>
 <li class="vditor-task"><input disabled type="checkbox"> 生产端使用 <code>asyncSend</code>；</li>
 <li class="vditor-task"><input disabled type="checkbox"> Topic 和 Consumer Group 稳定；</li>
 <li class="vditor-task"><input disabled type="checkbox"> Broker 广播地址对客户端可达；</li>
 <li class="vditor-task"><input disabled type="checkbox"> 生产端生成访问日志 ID；</li>
 <li class="vditor-task"><input disabled type="checkbox"> 消费端主键幂等；</li>
 <li class="vditor-task"><input disabled type="checkbox"> 消费异常继续抛出；</li>
 <li class="vditor-task"><input disabled type="checkbox"> 已验证重试与死信；</li>
 <li class="vditor-task"><input disabled type="checkbox"> 租户 ID 能传播且消费后清理；</li>
 <li class="vditor-task"><input disabled type="checkbox"> 请求参数已经脱敏和截断；</li>
 <li class="vditor-task"><input disabled type="checkbox"> 明确 best-effort 与强审计可靠性边界。</li>
</ul>
<h2 id="十六-总结">十六、总结</h2>
<p>这套方案最重要的不是“把日志写进 Elasticsearch”，而是建立清晰的职责边界：</p>
<ul>
 <li>OpenTelemetry Java Agent 负责自动埋点、上下文传播和 Logback 捕获；</li>
 <li>业务代码只依赖稳定的 OpenTelemetry API，避免绑定具体 APM 产品；</li>
 <li>Collector 负责接收、保护、批处理、重试和统一导出；</li>
 <li>Elasticsearch 9.x 原生 OTLP Endpoint 负责标准化存储；</li>
 <li>Kibana 通过 <code>trace_id</code> 把 Trace 与 SLF4J 日志串起来；</li>
 <li>RocketMQ 负责将 API 访问审计从业务请求线程解耦；</li>
 <li>生产端预生成主键、消费端检查重复主键，实现至少一次投递下的幂等；</li>
 <li>Trace ID 同时出现在响应 Header、应用日志、Trace Data Stream 和访问日志表中，成为排障的统一入口。</li>
</ul>
<p>最终，排查一次请求只需要一个动作：复制前端响应中的 <code>trace-id</code>，在 Kibana 中查询同值的 <code>trace_id</code>。
 <br>
 从用户请求、跨服务调用、SQL、消息队列到业务日志，都可以沿着同一条链路还原。</p>]]></description><guid isPermaLink="false">/archives/cong-skywalking-zhuan-yong-shi-xian-qian-yi-dao-opentelemetry-da-jian-ke-fu-ke-de-fen-bu-shi-trace-ri-zhi-zhong-xin-yu-yi-bu-fang-wen-ri-zhi-xi-tong</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-01a02d0a-984a-72b9-a685-fe67519cdd80-18c8b0e1.png&amp;size=m" type="image/jpeg" length="1914211"/><category>可观测性与日志</category><pubDate>Sun, 23 Aug 2026 05:14:24 GMT</pubDate></item><item><title><![CDATA[AI-Agent-第1章学习笔记]]></title><link>https://likeyy.love/archives/ai-agent-di-1zhang-xue-xi-bi-ji</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=AI-Agent-%E7%AC%AC1%E7%AB%A0%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0&amp;url=/archives/ai-agent-di-1zhang-xue-xi-bi-ji" width="1" height="1" alt="" style="opacity:0;">
<h1 id="深入理解-AI-Agent---第-1-章学习笔记-AI-Agent-入门">深入理解 AI Agent · 第 1 章学习笔记:AI Agent 入门</h1>
<blockquote>
 <p>📖 原书:《深入理解 AI Agent:设计原理与工程实践》(李博杰 著,v2.0)
  <br>
  📌 本章定位:全书的概念地图——快速引入 Agent 的核心公式、运行循环、工程框架和设计模式
  <br>
  🎯 一句话总结:<strong>Agent = 大脑 + 眼睛 + 手脚;模型正在商品化,真正的竞争力在 Harness 工程。</strong></p>
</blockquote>
<hr>
<h2 id="一-核心公式-现代-Agent---LLM---上下文---工具">一、核心公式:现代 Agent = LLM + 上下文 + 工具</h2>
<p>这是全书的基石公式,可以从三个层次理解它:</p>
<table>
 <thead>
  <tr>
   <th>层次</th>
   <th>表述</th>
   <th>含义</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>工程层</td>
   <td><strong>LLM + 上下文 + 工具</strong></td>
   <td>最小可运行实现</td>
  </tr>
  <tr>
   <td>直觉层</td>
   <td><strong>大脑 + 眼睛 + 手脚</strong></td>
   <td>大脑思考决策,眼睛决定看什么,手脚决定做什么</td>
  </tr>
  <tr>
   <td>学术层</td>
   <td><strong>Policy + Observation Space + Action Space</strong></td>
   <td>强化学习的经典形式化语言</td>
  </tr>
 </tbody>
</table>
<p>两个重要补充:</p>
<ol>
 <li><strong>公式只描述 Agent 边界内部</strong>,不包含环境(Environment)。Agent 与环境是闭环交互的两方:环境给观察 → Agent 行动 → 环境状态改变 → 新的观察……</li>
 <li>生产形态下,公式展开为 <code>Agent = Model + Harness</code>,其中 <code>Harness = 上下文管理 + 工具接口 + 约束 + 验证 + 纠正</code>。</li>
</ol>
<h3 id="最关键的一个洞察-扩展接口---换更强的模型">最关键的一个洞察:扩展接口 &gt; 换更强的模型</h3>
<blockquote>
 <p><strong>在底层模型固定时,提升 Agent 任务表现最主要的系统工程手段,往往就是重新定义或扩展观察空间与动作空间。许多看似需要"更聪明模型"的问题,其实只是接口问题。</strong></p>
</blockquote>
<p>书中用两个产品证明这一点:</p>
<ul>
 <li><strong>Manus</strong>:率先把 Deep Research、Coding、Computer Use 三条独立路线放进同一个 Agent——虚拟浏览器扩大观察空间,文件系统/代码执行扩大动作空间。<strong>它没有靠换更强的模型成为通用 Agent,而是取了三个空间并集。</strong></li>
 <li><strong>OpenClaw</strong>:把接口延伸到用户的数字生活——通过 WhatsApp、Telegram、iMessage 等消息渠道接收任务,通过本地 Gateway 连接 Google Drive、Notion 和本地文件系统。分散在各处的数据都进入同一个 Agent 的观察空间。</li>
</ul>
<h2 id="二-三大组件逐一拆解">二、三大组件逐一拆解</h2>
<h3 id="1--工具-Agent-的手脚">1. 工具:Agent 的手脚</h3>
<p>按交互方向分五类:</p>
<table>
 <thead>
  <tr>
   <th>类别</th>
   <th>作用</th>
   <th>例子</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>感知工具</td>
   <td>让 Agent 访问信息</td>
   <td>搜索引擎、文件系统、API</td>
  </tr>
  <tr>
   <td>执行工具</td>
   <td>让 Agent 改变世界</td>
   <td>代码执行、文件操作、外部 API 调用</td>
  </tr>
  <tr>
   <td>协作工具</td>
   <td>与其他 Agent/人分工</td>
   <td>委托子 Agent、请求人类确认</td>
  </tr>
  <tr>
   <td>事件触发工具</td>
   <td>外部输入驱动 Agent(被动激活)</td>
   <td>新邮件、定时任务、Webhook</td>
  </tr>
  <tr>
   <td>用户沟通工具</td>
   <td>主动向用户传递信息</td>
   <td>文字消息、语音通话、邮件</td>
  </tr>
 </tbody>
</table>
<p><strong>工具调用四步流程</strong>:① 在上下文声明工具 → ② 模型自主决定调不调、调哪个、传什么参 → ③ 结果追加回上下文 → ④ 模型决定下一步。这就是 ReAct 的基础。</p>
<p><strong>工具设计的核心原则</strong>:</p>
<ul>
 <li><strong>通用基础能力用于组合与探索</strong>(如代码解释器——任务升级时不用堆专用工具)</li>
 <li><strong>专用工具用于约束高风险和强业务规则操作</strong>(支付、删数据、发邮件——参数明确、权限受限、全程可审计)</li>
 <li>通用性扩大出错面 → 代码必须跑在沙盒里,默认无网络、限路径、限资源</li>
</ul>
<h3 id="2--LLM-Agent-的大脑">2. LLM:Agent 的大脑</h3>
<ul>
 <li>LLM 的能力 = <strong>预训练积累的世界知识 + 后训练固化的决策策略</strong></li>
 <li>独特能力:<strong>内部思考</strong>——行动前先规划推演,不改变环境却显著提升行动质量</li>
 <li><strong>"模型即 Agent"范式</strong>:Kimi K3、GPT-5.6 通过 RL 把工具调用决策内化为原生能力(何时调用、调哪个、传什么参数),编排循环从客户端移到了服务端</li>
 <li>⚠️ 澄清一个常见误解:<strong>RL 内化的是"决策策略",不是"工具执行机制"</strong>——web_search、code_runner 的真实实现仍在模型之外的 Harness/基础设施里</li>
</ul>
<h4 id="值得反复品味的观点-Harness-会被模型-吃掉-吗-">值得反复品味的观点:Harness 会被模型"吃掉"吗?</h4>
<p>书中的立场:<strong>方向认同,节奏务实</strong>。</p>
<blockquote>
 <p>方向:不怀疑模型会持续吃掉 Harness(工具调用、长程规划都曾靠外部编排,如今已是模型原生能力)。但这个过程远比想象中慢——<strong>模型此刻的能力边界,就是 Harness 此刻的价值所在</strong>。模型每内化一层,Harness 就卸下一层,转而兜底新的能力前沿。</p>
</blockquote>
<p>Harness 原指马具(缰绳与挽具)——不是限制马奔跑,而是把力量引导到正确方向。</p>
<h4 id="Agent-行为改变的三条路径">Agent 行为改变的三条路径</h4>
<table>
 <thead>
  <tr>
   <th>路径</th>
   <th>载体</th>
   <th>特点</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>上下文适应</td>
   <td>当前上下文(示例、状态、检索结果)</td>
   <td>即时、低成本;任务结束不保留</td>
  </tr>
  <tr>
   <td>外部产物更新</td>
   <td>知识文档、Prompt/Skill、程序/Harness</td>
   <td>跨任务持久、可审计</td>
  </tr>
  <tr>
   <td>参数更新</td>
   <td>模型权重(SFT、RL)</td>
   <td>高维能力、广泛泛化;成本高</td>
  </tr>
 </tbody>
</table>
<p>三者协同:<strong>临场适应 + 可控积累 + 能力内化</strong>。</p>
<h3 id="3--上下文-Agent-的眼睛">3. 上下文:Agent 的眼睛</h3>
<p>每次调用 LLM 的上下文由五部分组成:</p>
<pre><code>┌─ 静态前缀 ─────────────┐
│ ① 系统提示词(岗位说明书)  │
│ ② 工具定义(可用的手脚清单)│
└────────────────────────┘
┌─ 动态轨迹 ─────────────┐
│ ③ 用户消息(含 RAG 注入知识)│
│ ④ 模型回复(reasoning + content + tool_calls)│
│ ⑤ 工具执行结果           │
└────────────────────────┘
</code></pre>
<p><strong>消融实验(实验 1-1)的结果</strong>——每个组件都不可替代:</p>
<table>
 <thead>
  <tr>
   <th>去掉的组件</th>
   <th>后果</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>工具定义</td>
   <td>❌ 无法调用任何工具</td>
  </tr>
  <tr>
   <td>工具结果</td>
   <td>❌ 盲目执行、陷入无限循环</td>
  </tr>
  <tr>
   <td>思考过程(reasoning)</td>
   <td>△ 决策不连贯、前后矛盾</td>
  </tr>
  <tr>
   <td>历史消息</td>
   <td>△ 重复操作、重复犯错</td>
  </tr>
 </tbody>
</table>
<blockquote>
 <p>核心洞察:<strong>上下文决定了 Agent 能看到什么,而 Agent 只能基于它看到的信息做决策。</strong> 就像人蒙住眼睛就无法做出合理判断。</p>
</blockquote>
<h2 id="三-ReAct-循环-想---做---看">三、ReAct 循环:想 → 做 → 看</h2>
<p>Agent 执行任务的核心模式。名字只含 Reasoning + Acting,实际是三环节:</p>
<pre><code>思考(下一步做什么)→ 行动(调用工具)→ 观察(工具结果)→ 思考 → …直到任务完成
</code></pre>
<p><strong>关键事实:Agent 的上下文 = 静态前缀 + 轨迹(trajectory)</strong></p>
<ul>
 <li>轨迹 = 不断追加的消息历史(用户消息 + 模型回复 + 工具结果)</li>
 <li>每轮 LLM 调用都能看到完整轨迹 → 清楚任务进行到哪一步</li>
 <li>结构化轨迹带来三个好处:易解释调试、可分析行为模式、可沉淀为训练数据(形成从经验中学习的闭环)</li>
</ul>
<p>书中用"多币种收入汇总"的例子展示了完整轨迹:汇率转换 → 代码汇总 → 最终回答,3 次迭代、4 次工具调用。</p>
<h2 id="四-Harness-工程-模型之外的竞争力">四、Harness 工程:模型之外的竞争力</h2>
<blockquote>
 <p><strong>一个能跑的 Demo 和一个可靠的产品之间还有巨大的鸿沟</strong>——模型会幻觉(编造不存在的工具)、选错工具、出错后无法自我恢复。这些脆弱点正是 Harness 工程要解决的。</p>
</blockquote>
<h3 id="Harness-五要素">Harness 五要素</h3>
<pre><code>Agent = Model + Harness
Harness = 上下文管理 + 工具接口 + 约束 + 验证 + 纠正
</code></pre>
<table>
 <thead>
  <tr>
   <th>要素</th>
   <th>职责</th>
   <th>例子</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>Context(上下文)</td>
   <td>提供充分感知信息</td>
   <td>系统提示词、知识库、Agent 状态栏</td>
  </tr>
  <tr>
   <td>Tools(工具接口)</td>
   <td>接口清晰、命名直观、参数有例子</td>
   <td>MCP 工具、代码解释器</td>
  </tr>
  <tr>
   <td>Constrain(约束)</td>
   <td>故障安全默认值,能力默认关闭、显式开放</td>
   <td>Claude Code 每个工具默认需授权</td>
  </tr>
  <tr>
   <td>Verify(验证)</td>
   <td>只看结构化数据,不信模型自由文本</td>
   <td>Linter、类型检查、调用结果校验</td>
  </tr>
  <tr>
   <td>Correct(纠正)</td>
   <td>静默重试、失败回退,不暴露中间态</td>
   <td>熔断器、接续生成、人工兜底</td>
  </tr>
 </tbody>
</table>
<p><strong>"能做事"(上下文+工具)与"不做错事"(约束+验证+纠正)的重要性是不对称的</strong>:Claude Code 的 Harness 中绝大部分代码是约束、验证与纠正——工具本身只是一小部分,围绕工具的保障机制才是核心。</p>
<h3 id="工程范式的演进-层层包含-不是替代-">工程范式的演进(层层包含,不是替代)</h3>
<pre><code>提示工程 ⊂ 上下文工程 ⊂ Harness 工程 ⊂ Loop 工程 ⊂ Graph 工程
</code></pre>
<p>一个有力例证:LangChain 在 Terminal Bench 2.0 上得分从 52.8% → 66.5%(30 名开外 → 前 5),<strong>改变的不是模型,而是 Harness</strong>(自动检查执行结果、检测重复循环、优化思考策略)。</p>
<h3 id="构建-Agent-的三大原则-Anthropic-经验-">构建 Agent 的三大原则(Anthropic 经验)</h3>
<ol>
 <li><strong>保持简单</strong>——直接的 API 调用优于复杂框架;每多一层抽象都是调试时新的盲区</li>
 <li><strong>保持透明</strong>——显示规划步骤、执行日志、决策轨迹;黑箱里的错误无法定位也无法纠正</li>
 <li><strong>设计好工具接口(ACI)</strong>——从 Agent 视角设计接口而非程序员视角;用"防呆"(Poka-yoke)思路让错误无法发生(如 SIM 卡缺角设计)。<strong>模型与工具之间唯一的沟通通道就是接口本身,模糊的接口会被模型放大成系统性错误。</strong></li>
</ol>
<h3 id="编排模式-工作流-vs-自主-Agent">编排模式:工作流 vs 自主 Agent</h3>
<p><strong>决策顺序(从简单到复杂)</strong>:单次 LLM 调用 → 工作流 → 自主 Agent</p>
<table>
 <thead>
  <tr>
   <th></th>
   <th>工作流</th>
   <th>自主 Agent</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>执行路径</td>
   <td>代码写死、确定性</td>
   <td>模型根据环境反馈实时决定</td>
  </tr>
  <tr>
   <td>优势</td>
   <td>流程可控、攻击面限于单节点、业务规则代码强制</td>
   <td>灵活、适合开放式问题</td>
  </tr>
  <tr>
   <td>劣势</td>
   <td>缺乏变通,预设外的情况只能交还人类</td>
   <td>高成本、复合错误风险、可能死循环</td>
  </tr>
  <tr>
   <td>适用</td>
   <td>订票流程、合规要求严格的流程</td>
   <td>Coding、Computer Use、迭代研究</td>
  </tr>
 </tbody>
</table>
<ul>
 <li>自主 Agent 的本质 = <strong>在循环中使用工具的 LLM</strong>(即 ReAct),必须设计明确的停止条件</li>
 <li>实践中常<strong>混合使用</strong>:关键流程用工作流,灵活部分用自主模式(如 n8n)</li>
</ul>
<h3 id="护栏与安全性-三层防线">护栏与安全性:三层防线</h3>
<p>按<strong>被绕过的难度</strong>分层(越往下越不依赖模型自己的判断):</p>
<table>
 <thead>
  <tr>
   <th>层</th>
   <th>管什么</th>
   <th>典型机制</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>上下文层</td>
   <td>模型能看到什么(进入上下文前拦截)</td>
   <td>相关/安全分类器、越狱与提示注入检测、Constitutional Classifiers</td>
  </tr>
  <tr>
   <td>执行层</td>
   <td>模型能做什么(动作生效前验证)</td>
   <td>工具风险评级、独立审查进程、沙盒隔离、人在回路、PII 过滤</td>
  </tr>
  <tr>
   <td>数据层</td>
   <td>世界最终能被改成什么样</td>
   <td>数据库行级安全、约束校验、受控视图</td>
  </tr>
 </tbody>
</table>
<p><strong>关键区分</strong>:越狱 = 用户自己绕过限制;提示注入 = 攻击者通过外部数据(网页、文档)间接操纵模型。</p>
<p>**人工干预(人在回路)**的两种触发:超过失败阈值(重试/操作次数上限)、高风险操作(大额退款、付款)。</p>
<h2 id="五-贯穿全书的五个设计模式">五、贯穿全书的五个设计模式</h2>
<table>
 <thead>
  <tr>
   <th>模式</th>
   <th>核心思想</th>
   <th>应用场景</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><strong>提议者—审核者</strong></td>
   <td>产出与评判由两个不共享上下文的角色承担;自审不可靠</td>
   <td>知识更新、工具调用审批、PPT/视频/日志实验、评估</td>
  </tr>
  <tr>
   <td><strong>渐进式披露</strong></td>
   <td>先给可检索目录,按需加载细节;同时优化上下文预算与选择精度</td>
   <td>Agent Skills、分层检索、主动工具发现</td>
  </tr>
  <tr>
   <td><strong>只增不改</strong></td>
   <td>状态只追加、不回头修改;换来可缓存、可重放、可审计</td>
   <td>KV Cache 前缀稳定、事件式记忆、schema 追加到轨迹末尾</td>
  </tr>
  <tr>
   <td><strong>边界集 + 保留集</strong></td>
   <td>每次修改同时验证"该变的样本"和"不该变的样本"</td>
   <td>回归任务、训练/评估隔离、更新提案验证</td>
  </tr>
  <tr>
   <td><strong>最小 diff + 可回滚</strong></td>
   <td>修改尽量小、带来源、可单独回滚;让归因成为可能</td>
   <td>知识更新、代码补丁、Prompt 更新</td>
  </tr>
 </tbody>
</table>
<h2 id="六-本章小结-核心要点回顾-">六、本章小结(核心要点回顾)</h2>
<ol>
 <li><strong>Agent = 大脑 + 眼睛 + 手脚</strong>,三者缺一不可</li>
 <li><strong>扩展眼睛和手脚是最主要的能力杠杆</strong>——接口问题常被误认为模型问题</li>
 <li><strong>眼睛(上下文)是决定性因素</strong>——静态前缀 + 动态轨迹,消融任何组件都会显著退化</li>
 <li><strong>Harness 是竞争力所在</strong>——模型商品化时代,约束/验证/纠正决定"可靠地做事"</li>
 <li><strong>从简单到复杂</strong>:先提示词 → 再工作流 → 最后自主 Agent</li>
 <li><strong>五个设计模式</strong>贯穿全书:提议者—审核者、渐进式披露、只增不改、边界集+保留集、最小 diff+可回滚</li>
 <li><strong>安全是架构问题</strong>——从第一行代码考虑,三层护栏(上下文/执行/数据层)</li>
</ol>
<h2 id="七-我的思考">七、我的思考</h2>
<ul>
 <li>💡 <strong>"模型此刻的能力边界,就是 Harness 此刻的价值所在"</strong> 这句是我认为全章最有分量的话——它同时回答了两个问题:为什么 Harness 不会消失(模型内化是慢变量),以及 Harness 工程师该做什么(盯住能力前沿,持续"兜底")</li>
 <li>💡 消融实验的方法论值得学:<strong>怀疑某个组件没用,不要猜,做对照实验</strong>。这比任何经验讨论都有说服力</li>
 <li>💡 "产品能力的演进往往就是观察空间和动作空间的演进"——用这个视角重新看任何 Agent 产品的版本更新,都能看懂它到底在扩张什么接口</li>
</ul>
<h2 id="八-遗留问题-书中思考题摘选-">八、遗留问题(书中思考题摘选)</h2>
<ol>
 <li>只能增强一项:更强的模型 / 更丰富的上下文 / 更多的工具,你选哪个?什么条件下会改变?</li>
 <li>"模型即 Agent"与 Harness 重要性上升,这两个趋势如何共存?框架未来的核心价值在哪?</li>
 <li>除了工具结果缺失,还有哪些情况会导致 Agent 无限循环?如何设计检测和终止机制?</li>
 <li>一个低风险工具在特定参数组合下变为高风险(如 <code>delete_file</code> 删系统文件),如何做动态风险评估?</li>
</ol>
<hr>
<p><em>下一章预告:第二章《上下文工程》——全书最关键的一章,深入 KV Cache 原理、提示工程、Agent Skills 与上下文压缩。</em></p>]]></description><guid isPermaLink="false">/archives/ai-agent-di-1zhang-xue-xi-bi-ji</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-01a02993-e495-737b-be9c-5f4a906b2d81-e08b117d.png&amp;size=m" type="image/jpeg" length="1463208"/><category>Agent 与上下文工程</category><pubDate>Sat, 22 Aug 2026 13:05:53 GMT</pubDate></item><item><title><![CDATA[AI Agent 上下文消融实验实录]]></title><link>https://likeyy.love/archives/ai-agent-shang-xia-wen-xiao-rong-shi-yan-shi-lu</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=AI%20Agent%20%E4%B8%8A%E4%B8%8B%E6%96%87%E6%B6%88%E8%9E%8D%E5%AE%9E%E9%AA%8C%E5%AE%9E%E5%BD%95&amp;url=/archives/ai-agent-shang-xia-wen-xiao-rong-shi-yan-shi-lu" width="1" height="1" alt="" style="opacity:0;">
<h1 id="移除哪块-上下文-最致命----一次-AI-Agent-上下文消融实验实录">移除哪块"上下文"最致命？—— 一次 AI Agent 上下文消融实验实录</h1>
<blockquote>
 <p>我们用一个真实的跨国预算分析任务，把 Agent 上下文中的四个组件逐一"摘除"，
  <br>
  观察它何时崩溃、何时变慢、以及最可怕的一种情况——<strong>看起来成功了，答案却是错的</strong>。</p>
</blockquote>
<hr>
<h2 id="一-引言-为什么上下文是-Agent-的生命线">一、引言：为什么上下文是 Agent 的生命线</h2>
<p>和传统的一次性问答不同，AI Agent 是在一个循环里工作的：理解任务 → 调用工具 → 观察结果 → 再决策 → 再调用……直到产出最终答案。驱动这个循环的，不只是模型本身，更是喂给模型的那一长串<strong>上下文</strong>：</p>
<ul>
 <li><strong>历史工具调用</strong>（我做过什么）</li>
 <li><strong>推理过程</strong>（我当时的思考）</li>
 <li><strong>工具调用命令</strong>（我要调用哪个工具、传什么参数）</li>
 <li><strong>工具返回结果</strong>（刚才那一调用回传了什么）</li>
</ul>
<p>这些组件各自值多少钱？哪个是"命根子"、哪个是"可选项"？回答这个问题不能靠猜，要靠<strong>消融实验（Ablation Study）</strong>——把组件一个一个拿掉，看 Agent 的表现如何变化。</p>
<p>本文基于《深入理解 AI Agent》第 1 章"实验 1-1 ★★：上下文的关键作用"的代码与思路，在 DeepSeek 模型上跑了一轮完整消融实验，下面是全部数据与解读。</p>
<h2 id="二-实验设计">二、实验设计</h2>
<h3 id="2-1-任务">2.1 任务</h3>
<p>一个<strong>跨国预算分析</strong>任务（"Multinational Budget"），要求 Agent：</p>
<ol>
 <li>解析一份 PDF 财务报告；</li>
 <li>通过货币换算工具把多币种数据统一为美元；</li>
 <li>计算全球总支出、各区域占比；</li>
 <li>按 8% 的比例做全员预算削减后，重新计算各项数字。</li>
</ol>
<p>这是一个典型的多步 Agent 任务：<strong>外部数据获取（PDF）+ 工具计算 + 顺序依赖的推导</strong>，任何一步断掉都会影响最终结果。</p>
<h3 id="2-2-模型与配置">2.2 模型与配置</h3>
<ul>
 <li>模型：<strong>DeepSeek</strong>（默认模型）</li>
 <li>基线与四个消融臂共 <strong>5 种上下文配置</strong>：</li>
</ul>
<table>
 <thead>
  <tr>
   <th>配置</th>
   <th>拿掉了什么</th>
   <th>一句话含义</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><code>full</code>（基线）</td>
   <td>什么都不拿</td>
   <td>完整上下文</td>
  </tr>
  <tr>
   <td><code>no_history</code></td>
   <td>历史工具调用</td>
   <td>Agent 记不住自己做过什么</td>
  </tr>
  <tr>
   <td><code>no_reasoning</code></td>
   <td>推理过程</td>
   <td>Agent 不再"想一步做一步"</td>
  </tr>
  <tr>
   <td><code>no_tool_calls</code></td>
   <td>工具调用命令</td>
   <td>Agent 无法调用任何外部工具</td>
  </tr>
  <tr>
   <td><code>no_tool_results</code></td>
   <td>工具返回结果</td>
   <td>Agent 发出调用，却看不到返回值（"盲操作"）</td>
  </tr>
 </tbody>
</table>
<h3 id="2-3-观察指标">2.3 观察指标</h3>
<ul>
 <li><strong>执行时间</strong>（execution_time）：跑完（或放弃）花了多少秒</li>
 <li><strong>迭代轮数</strong>（iterations）：Agent 与模型来回了几轮</li>
 <li><strong>工具调用次数</strong>（num_tool_calls）：实际调用了多少次工具</li>
 <li><strong>是否产出终态回答</strong>（has_final_answer / completed）：有没有给出最终答案</li>
</ul>
<blockquote>
 <p>⚠️ 一个重要的方法论提醒：本套件的 <code>success</code> 字段只表示"Agent 给出了一个终态回复"，<strong>并不代表任务做对了</strong>。任务正确性必须用任务级的评分标准（rubric）单独核对。这一点在第四组实验里会变成血淋淋的教训。</p>
</blockquote>
<h2 id="三-结果一览">三、结果一览</h2>
<table>
 <thead>
  <tr>
   <th>配置</th>
   <th>执行时间</th>
   <th>迭代轮数</th>
   <th>工具调用</th>
   <th>终态回答</th>
   <th>最终答案正确性</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><strong>完整上下文（基线）</strong></td>
   <td>23.74s</td>
   <td>3</td>
   <td>6</td>
   <td>✅</td>
   <td>✅ 正确（$11,990,955.43）</td>
  </tr>
  <tr>
   <td><strong>① 无历史工具调用</strong></td>
   <td>41.69s</td>
   <td>10</td>
   <td><strong>50</strong></td>
   <td>❌ 未完成</td>
   <td>—</td>
  </tr>
  <tr>
   <td><strong>② 无推理过程</strong></td>
   <td>23.49s</td>
   <td>4</td>
   <td>6</td>
   <td>✅</td>
   <td>✅ 正确（同上）</td>
  </tr>
  <tr>
   <td><strong>③ 无工具调用命令</strong></td>
   <td>62.67s</td>
   <td>1</td>
   <td>0</td>
   <td>❌ 未完成</td>
   <td>—</td>
  </tr>
  <tr>
   <td><strong>④ 无工具返回结果</strong></td>
   <td>83.03s</td>
   <td>6</td>
   <td>10</td>
   <td>⚠️ "完成"</td>
   <td>❌ <strong>错误</strong>（$11,951,000）</td>
  </tr>
 </tbody>
</table>
<p>（完整原始数据见文末仓库中的 <code>ablation_results.json</code>）</p>
<h2 id="四-逐臂解读">四、逐臂解读</h2>
<h3 id="4-1---无历史记录-失忆症的代价是-重复劳动-">4.1 ① 无历史记录：失忆症的代价是"重复劳动"</h3>
<p>去掉历史工具调用后，Agent 变成了一个<strong>失忆症患者</strong>：它不记得自己刚刚解析过 PDF、也不记得已经查过汇率，于是：</p>
<ul>
 <li>工具调用从基线的 <strong>6 次暴涨到 50 次</strong>（超过 8 倍）；</li>
 <li>迭代了 <strong>10 轮</strong>仍然没有跑完，最终<strong>没有产出任何终态回答</strong>；</li>
 <li>用时从 23.74s 膨胀到 41.69s。</li>
</ul>
<p><strong>解读</strong>：历史记录的价值不在"聪明"，而在"不犯蠢"。没有它，Agent 每一步都从零开始，多步依赖任务（先用 PDF 结果、再算汇率、再汇总）会陷入无休止的重做循环。它是<strong>多步任务的连贯性保障</strong>。</p>
<h3 id="4-2---无推理过程-本任务上几乎零影响">4.2 ② 无推理过程：本任务上几乎零影响</h3>
<p>去掉推理过程后，Agent 表现与基线几乎完全一致：</p>
<ul>
 <li>用时 23.49s（基线 23.74s）；</li>
 <li>工具调用同为 6 次；</li>
 <li><strong>最终答案与基线一字不差</strong>（$11,990,955.43）。</li>
</ul>
<p><strong>解读</strong>：对这个任务来说，模型的"推理文本"是冗余的——不写思考过程，答案照样对，速度还略快。这提示我们：<strong>推理过程不是免费的午餐，也不是万灵药</strong>。当然，这只是一个任务、一个模型上的结论；在数学证明、长链条规划等复杂推理任务上，推理过程的价值可能会完全不同。</p>
<h3 id="4-3---无工具调用命令-直接瘫痪">4.3 ③ 无工具调用命令：直接瘫痪</h3>
<p>拿掉工具调用后，Agent 变成了一个"只能空谈的咨询师"：<strong>0 次工具调用</strong>，只迭代了 <strong>1 轮</strong>就结束了，没有产出任何答案（用时 62.67s 主要耗在了单轮的长回复上）。</p>
<p><strong>解读</strong>：这是最不意外的一组。这类任务的数据（PDF 内容、汇率）完全来自外部工具，砍掉工具等于砍掉了任务的地基。<strong>工具是硬依赖，没有商量的余地。</strong></p>
<h3 id="4-4---无工具返回结果-最危险的-成功----">4.4 ④ 无工具返回结果：最危险的"成功" ⚠️</h3>
<p>这是整场实验里<strong>最值得警惕</strong>的一组。Agent 可以发出工具调用，但看不到任何返回值——像一个蒙着眼睛开车的人：</p>
<ul>
 <li>它花了 <strong>83.03s</strong>（全场最慢），迭代 6 轮、调用 10 次工具；</li>
 <li>协议层面它"完成了"：<code>completed=true</code>、<code>success=true</code>、有终态回答；</li>
 <li><strong>但答案错了</strong>：它给出总支出 <span class="language-math">​11,951,000，而正确答案是 **</span>11,990,955.43**，少了约 <strong>4 万美元</strong>（相对误差约 0.33%）；</li>
 <li>各地区占比也全部跑偏（如美国 20.92% vs 正确的 20.85%，日本 20.99% vs 正确的 21.20%）。</li>
</ul>
<p><strong>解读</strong>：看不见结果，Agent 就会用"猜"来填补空白——它甚至会在盲区里重复调用（10 次 vs 基线的 6 次）来试图"蒙对"。最可怕的地方在于：<strong>这是一个表面健康的失败</strong>。如果你只看 <code>success</code> 标志，你会以为一切正常，然后把这个差 4 万美元的数字写进预算报告。</p>
<p>这正是实验报告里那句警告的含义：</p>
<blockquote>
 <p>"A terminal response is not evidence that the financial task was completed. Task-level claims require a separate evaluator."</p>
</blockquote>
<p><strong>协议完成 ≠ 任务完成。<code>success</code> 会撒谎，rubric 不会。</strong></p>
<h2 id="五-关键启示">五、关键启示</h2>
<h3 id="启示-1-验收-Agent-必须用任务级评分标准">启示 1：验收 Agent，必须用任务级评分标准</h3>
<p><code>success=true</code> 只证明"Agent 说了句结束语"。要判断它到底做没做对，必须有一个<strong>任务特定的 rubric</strong>——例如把最终答案与预期数值比对（本仓库的 <code>run_experiment_1_1.py</code> 就是这么做的：它把模型的回答与预计算的标准答案做数值核对）。第四组实验就是"无 rubric 验收"的完美反面教材。</p>
<h3 id="启示-2-上下文裁剪的经济学--要省钱-先砍谁-">启示 2：上下文裁剪的经济学——要省钱，先砍谁？</h3>
<p>上下文越长越贵，生产环境里大家总想"裁剪"上下文省 token。这组实验给出的优先级建议是：</p>
<ol>
 <li><strong>最不能砍：工具调用 + 工具返回结果</strong>。砍前者直接瘫痪，砍后者产出错误答案——省下的钱不够一次错误决策的损失。</li>
 <li><strong>谨慎保留：历史工具调用</strong>。砍掉它不会马上出错，但会让成本失控（50 次调用 vs 6 次），效率损失本身就是成本。</li>
 <li><strong>优先候选：推理过程</strong>。在本任务上几乎零损耗。若真要做上下文压缩，<strong>考虑对推理文本做摘要/截断，而不是直接删除历史与工具结果</strong>——删除是"截肢"，摘要才是"减肥"。</li>
</ol>
<h3 id="启示-3-正确性比-跑完-更难测量-也更值钱">启示 3：正确性比"跑完"更难测量，也更值钱</h3>
<p>四组消融里，最贵的一组（83s、10 次调用）反而是错误的一组。<strong>"慢但错"比"快但没做完"危害更大</strong>，因为它会混入正常结果、污染下游决策。评估体系必须把"正确性"和"完成度"分开统计。</p>
<h2 id="六-局限与后续工作">六、局限与后续工作</h2>
<p>这份实验必须诚实地标注边界：</p>
<ol>
 <li><strong>单任务、单模型、单次运行</strong>：结论来自一个任务（跨国预算）和一个模型（DeepSeek）的一次运行。LLM 输出有随机性，严谨起见应当<strong>多次重复取均值</strong>；</li>
 <li><strong>推理过程"无用"的结论不能外推</strong>：本任务的推理依赖较浅；在复杂推理任务上，<code>no_reasoning</code> 可能表现出完全不同的结果；</li>
 <li><strong>缺少任务级 rubric 的自动评判</strong>：本套件记录了行为数据，但正确性核对目前依赖人工/独立脚本（<code>run_experiment_1_1.py</code> 提供了数值 rubric 的参考实现）。</li>
</ol>
<p>后续可以做的方向：多模型横向对比、多任务重复实验、推理文本压缩（摘要 vs 截断）的消融、以及把 rubric 自动评分接入主流程。</p>
<h2 id="七-复现指南">七、复现指南</h2>
<p>想亲手跑一遍？三步即可：</p>
<pre><code class="language-bash"># 1. 获取代码
git clone https://github.com/Hanserwei/ai-agent-context-ablation.git
cd ai-agent-context-ablation

# 2. 配置 API Key
pip install -r requirements.txt
cp env.example .env
# 编辑 .env，填入任一 provider 的 API Key（如 DEEPSEEK_API_KEY）

# 3. 运行消融实验
python main.py --mode ablation
</code></pre>
<p>运行后会在当前目录生成：</p>
<ul>
 <li><code>ablation_results.json</code> —— 每组配置的原始数据（时间、迭代、调用次数、终态回答）</li>
 <li><code>ablation_study_report.md</code> —— 自动生成的英文实验报告</li>
 <li><code>ablation_study_results.png</code> —— 可视化对比图</li>
</ul>
<p>用 <code>run_experiment_1_1.py</code> 可以跑带任务级数值 rubric 的严谨版本（它会持久化每一次请求/响应，并核对最终答案是否正确）。</p>
<h2 id="八-结语">八、结语</h2>
<p>一个看似简单的消融实验，暴露了 Agent 工程里最容易被忽视的真相：<strong>上下文组件之间是不平等的</strong>。工具调用与工具结果决定 Agent"能不能做对"，历史记录决定"做得贵不贵"，而推理过程在某些任务上只是锦上添花。</p>
<p>以及那条最值得写进团队检查清单的教训：<strong>永远不要用"它跑完了"替代"它做对了"来验收一个 Agent。</strong></p>
<hr>
<h2 id="代码仓库">代码仓库</h2>
<p>本文所有实验代码与完整结果数据均已开源：</p>
<p>📦 <strong>https://github.com/Hanserwei/ai-agent-context-ablation</strong></p>
<p>（代码来源：https://github.com/bojieli/ai-agent-book/tree/main ，<code>chapter1/context</code> 目录，实验 1-1：上下文的关键作用）</p>]]></description><guid isPermaLink="false">/archives/ai-agent-shang-xia-wen-xiao-rong-shi-yan-shi-lu</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-01a02987-1d80-70c8-acdb-f86e02814b92-f1b00290.png&amp;size=m" type="image/jpeg" length="1765824"/><category>Agent 与上下文工程</category><pubDate>Sat, 22 Aug 2026 12:51:56 GMT</pubDate></item><item><title><![CDATA[React 对照速成文档]]></title><link>https://likeyy.love/archives/react-dui-zhao-su-cheng-wen-dang</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=React%20%E5%AF%B9%E7%85%A7%E9%80%9F%E6%88%90%E6%96%87%E6%A1%A3&amp;url=/archives/react-dui-zhao-su-cheng-wen-dang" width="1" height="1" alt="" style="opacity:0;">
<h1 id="React-对照速成文档">React 对照速成文档</h1>
<blockquote>
 <p>目标：<strong>能看懂已有的 React 项目代码</strong>。本文假设你已掌握上面的《Vue3 学习笔记》，每节都会标注"对照 Vue3"，帮你快速建立映射。</p>
 <p>阅读技巧：React 代码的核心心智模型只有一句话——<strong>UI = f(state)</strong>，组件就是一个"状态 → 界面"的纯函数。</p>
</blockquote>
<hr>
<h1 id="1--React-简介">1. React 简介</h1>
<ul>
 <li>由 Facebook（Meta）于 2013 年开源，用于构建用户界面的 <strong>JavaScript 库</strong>（注意：是"库"，不是"框架"，路由、状态管理等需要第三方配合）。</li>
 <li>当前主流写法是 <strong>函数组件 + Hooks</strong>（2019 年 React 16.8 引入），老项目里可能残留 <strong>Class 组件</strong>，两者要都会"读"。</li>
 <li>与 Vue 最大的理念差异：
  <ul>
   <li>Vue：模板 + 响应式（数据变了，视图自动更新，靠 <code>ref</code>/<code>reactive</code> 代理）。</li>
   <li>React：<strong>没有响应式</strong>，数据变了 React 会<strong>重新执行整个组件函数</strong>，重新算出新的 JSX 与旧的对比（虚拟 DOM diff），只更新变化的部分。</li>
  </ul></li>
</ul>
<blockquote>
 <p>对照 Vue3：Vue 的"数据变化驱动视图"是<strong>细粒度</strong>的（哪个 ref 变了只更新相关的地方）；React 是"<strong>重跑函数 + diff</strong>"的粗粒度模型。读代码时看到组件函数体里大量普通变量、<code>return</code> 的 JSX，就是整个组件会反复执行。</p>
</blockquote>
<hr>
<h1 id="2--创建-React-工程">2. 创建 React 工程</h1>
<h2 id="2-1--基于-Vite-创建-推荐-">2.1. 基于 Vite 创建（推荐）</h2>
<pre><code class="language-powershell">npm create vite@latest
# Project name: react_test
# Framework: React
# Variant: TypeScript (推荐) / JavaScript
cd react_test
npm install
npm run dev
</code></pre>
<h2 id="2-2--项目入口与结构">2.2. 项目入口与结构</h2>
<pre><code>react_test/
├── index.html          # 入口 HTML，里面有 &lt;div id="root"&gt;&lt;/div&gt;
├── src/
│   ├── main.tsx        # 应用入口：把 App 组件渲染到 #root
│   ├── App.tsx         # 根组件
│   └── ...
</code></pre>
<p><code>src/main.tsx</code>（React 版的 <code>main.ts</code>）：</p>
<pre><code class="language-tsx">import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'

// Vue3: createApp(App).mount('#app')
// React: 把 &lt;App /&gt; 挂载到 id 为 root 的 DOM 节点
ReactDOM.createRoot(document.getElementById('root')!).render(
  &lt;React.StrictMode&gt;
    &lt;App /&gt;
  &lt;/React.StrictMode&gt;
)
</code></pre>
<blockquote>
 <p>对照 Vue3：<code>ReactDOM.createRoot(...).render(...)</code> ≈ <code>createApp(App).mount('#app')</code>。
  <br>
  <code>React.StrictMode</code> 是开发模式下的"严格模式"，会让组件在开发时<strong>多执行一遍</strong>用于暴露副作用问题，读代码时忽略即可。</p>
</blockquote>
<hr>
<h1 id="3--React-核心语法">3. React 核心语法</h1>
<h2 id="3-1--函数组件与-JSX">3.1. 函数组件与 JSX</h2>
<p>React 组件就是一个<strong>返回 JSX 的函数</strong>。JSX 是"在 JS 里写 HTML"，本质上会被编译成 <code>React.createElement</code> 调用。</p>
<pre><code class="language-tsx">// App.tsx
export default function App() {
  const name = '张三'

  // 返回的这段"HTML"就是 JSX
  return (
    &lt;div className="person"&gt;
      &lt;h2&gt;姓名：{name}&lt;/h2&gt;
      &lt;button&gt;按钮&lt;/button&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<p><strong>读 JSX 必须记住的规则（与 Vue 模板的差异点）：</strong></p>
<table>
 <thead>
  <tr>
   <th>需求</th>
   <th>Vue 模板</th>
   <th>React JSX</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>插入变量</td>
   <td><code>{{name}}</code></td>
   <td><code>{name}</code></td>
  </tr>
  <tr>
   <td>类名</td>
   <td><code>class="app"</code></td>
   <td><code>className="app"</code></td>
  </tr>
  <tr>
   <td>行内样式</td>
   <td><code>:style="{color:'red'}"</code></td>
   <td><code>style={{ color: 'red' }}</code></td>
  </tr>
  <tr>
   <td>绑定属性</td>
   <td><code>:src="url"</code></td>
   <td><code>src={url}</code></td>
  </tr>
  <tr>
   <td>点击事件</td>
   <td><code>@click="fn"</code></td>
   <td><code>onClick={fn}</code></td>
  </tr>
  <tr>
   <td>条件渲染</td>
   <td><code>v-if</code> / <code>v-show</code></td>
   <td>见 3.2</td>
  </tr>
  <tr>
   <td>列表渲染</td>
   <td><code>v-for</code></td>
   <td>见 3.3</td>
  </tr>
  <tr>
   <td>注释</td>
   <td><code>&lt;!-- --&gt;</code></td>
   <td><code>{/* 注释 */}</code></td>
  </tr>
 </tbody>
</table>
<h2 id="3-2--条件渲染-对照-v-if---v-show-">3.2. 条件渲染（对照 v-if / v-show）</h2>
<p>React 里条件渲染就是<strong>普通的 JS 逻辑</strong>，因为 JSX 就在 JS 里。</p>
<pre><code class="language-tsx">function Demo({ flag, age }: { flag: boolean; age: number }) {
  return (
    &lt;div&gt;
      {/* v-if / v-else-if：用三元表达式 */}
      {age &gt;= 18 ? &lt;p&gt;成年人&lt;/p&gt; : &lt;p&gt;未成年人&lt;/p&gt;}

      {/* 只有 if 没有 else：用 &amp;&amp; 短路 */}
      {flag &amp;&amp; &lt;p&gt;flag 为真才显示&lt;/p&gt;}

      {/* v-show：切换 style.display */}
      &lt;p style={{ display: flag ? 'block' : 'none' }}&gt;相当于 v-show&lt;/p&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<blockquote>
 <p>对照：<code>v-if="x"</code> → <code>{x &amp;&amp; &lt;div/&gt;}</code>；<code>v-else</code> → 三元表达式的后半段；<code>v-show</code> → 自己控制 <code>display</code>。</p>
</blockquote>
<h2 id="3-3--列表渲染-对照-v-for-">3.3. 列表渲染（对照 v-for）</h2>
<p>React 用数组的 <code>.map()</code> 方法，且 <strong><code>key</code> 直接写在 JSX 属性上</strong>，必须传。</p>
<pre><code class="language-tsx">const games = [
  { id: 'a01', name: '英雄联盟' },
  { id: 'a02', name: '王者荣耀' },
  { id: 'a03', name: '原神' },
]

function GameList() {
  return (
    &lt;ul&gt;
      {/* v-for="g in games" :key="g.id" 的 React 写法 */}
      {games.map((g) =&gt; (
        &lt;li key={g.id}&gt;{g.name}&lt;/li&gt;
      ))}
    &lt;/ul&gt;
  )
}
</code></pre>
<blockquote>
 <p>对照：Vue 的 <code>v-for="(item, index) in list"</code> → React 的 <code>list.map((item, index) =&gt; (...))</code>。注意 <code>key</code> 在 React 里是 JSX 属性（<code>key={g.id}</code>），Vue 里是绑定指令（<code>:key="g.id"</code>）。</p>
</blockquote>
<h2 id="3-4--事件处理-对照-v-on-">3.4. 事件处理（对照 v-on）</h2>
<p>React 事件名是<strong>驼峰式</strong>，直接传函数引用（不加括号），事件对象就是原生的，没有 <code>$event</code> 特殊语法。</p>
<pre><code class="language-tsx">function Demo() {
  function handleClick(e: React.MouseEvent) {
    console.log('点击了', e.target)
  }
  function handleChange(e: React.ChangeEvent&lt;HTMLInputElement&gt;) {
    console.log('输入了', e.target.value)
  }

  return (
    &lt;div&gt;
      &lt;button onClick={handleClick}&gt;点我&lt;/button&gt;
      {/* 需要传参时用箭头函数包裹 */}
      &lt;button onClick={() =&gt; handleClick2('参数')}&gt;带参&lt;/button&gt;
      &lt;input onChange={handleChange} /&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<blockquote>
 <p>对照：<code>@click="fn"</code> → <code>onClick={fn}</code>；<code>@click="fn(1)"</code> → <code>onClick={() =&gt; fn(1)}</code>。React 没有事件修饰符（<code>.stop</code>、<code>.prevent</code>），要手动写 <code>e.stopPropagation()</code>、<code>e.preventDefault()</code>。</p>
</blockquote>
<h2 id="3-5--表单与受控组件-对照-v-model-">3.5. 表单与受控组件（对照 v-model）</h2>
<p>React <strong>没有 v-model</strong>。表单输入的值要手动绑定 <code>value</code> + 手动监听 <code>onChange</code> 去更新状态，这种"值由 React state 完全控制"的输入框叫<strong>受控组件</strong>。</p>
<pre><code class="language-tsx">import { useState } from 'react'

function InputDemo() {
  const [text, setText] = useState('')

  return (
    &lt;input
      value={text}                          // 数据 → 页面
      onChange={(e) =&gt; setText(e.target.value)}  // 页面 → 数据
    /&gt;
  )
}
</code></pre>
<blockquote>
 <p>对照：<code>v-model="text"</code> ≈ <code>value={text}</code> + <code>onChange={(e) =&gt; setText(e.target.value)}</code>。
  <br>
  这正是 Vue 里 <code>v-model</code> 拆开后的底层形式（<code>:value</code> + <code>@input</code>）。</p>
</blockquote>
<h2 id="3-6--useState-对照-ref---reactive-">3.6. useState（对照 ref / reactive）</h2>
<p><code>useState</code> 是 React 最核心的 Hook，用于定义"组件的状态"。<strong>状态变了，组件函数重新执行</strong>。</p>
<pre><code class="language-tsx">import { useState } from 'react'

function Counter() {
  // 对照：let count = ref(0)
  // useState 返回 [当前值, 修改函数]，通常用数组解构接收
  const [count, setCount] = useState(0)
  const [user, setUser] = useState({ name: '张三', age: 18 })

  function add() {
    setCount(count + 1)        // 对照 count.value += 1
    // 注意：不能写成 count = count + 1，普通变量改了不会触发重渲染
  }
  function changeAge() {
    // 对象类型必须"整体替换"，不能原地修改（与 reactive 完全不同！）
    setUser({ ...user, age: user.age + 1 })  // 展开旧对象 + 覆盖要改的属性
  }

  return (
    &lt;div&gt;
      &lt;h2&gt;计数：{count}&lt;/h2&gt;
      &lt;h2&gt;姓名：{user.name}，年龄：{user.age}&lt;/h2&gt;
      &lt;button onClick={add}&gt;+1&lt;/button&gt;
      &lt;button onClick={changeAge}&gt;年龄+1&lt;/button&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<p><strong>读 React 代码时最重要的规则：state 不可变（immutable）</strong></p>
<ul>
 <li>基本类型：<code>setCount(count + 1)</code>。</li>
 <li>对象/数组：<strong>永远不要原地改</strong>（如 <code>user.age++</code>、<code>arr.push()</code>），要<strong>新建一份</strong>再 set。因为 React 靠"引用是否变了"来判断要不要重渲染。
  <ul>
   <li>对象：<code>setUser({ ...user, age: 18 })</code></li>
   <li>数组：<code>setList([...list, newItem])</code>、<code>setList(list.filter(...))</code></li>
  </ul></li>
</ul>
<blockquote>
 <p>对照 Vue3：</p>
 <ul>
  <li><code>ref(0)</code> ≈ <code>useState(0)</code>（返回值 <code>.value</code> 变成了解构出的 <code>[值, set函数]</code>）。</li>
  <li><code>reactive({...})</code> <strong>没有直接对应</strong>，React 对象状态也必须整体替换，这跟 Vue <code>reactive</code> 可以 <code>car.price += 1</code> 直接改的体验完全不同。</li>
  <li>没有 <code>.value</code> 这一层，直接读 <code>count</code>，改要调 <code>setCount</code>。</li>
 </ul>
</blockquote>
<h2 id="3-7--useRef-对照-ref-的两种用法-">3.7. useRef（对照 ref 的两种用法）</h2>
<p><code>useRef</code> 同时对应 Vue 里的 <strong>DOM 引用</strong> 和 <strong>"不改触发渲染的变量"</strong>。</p>
<pre><code class="language-tsx">import { useRef } from 'react'

function Demo() {
  // 1. 拿到 DOM 节点（对照 &lt;h1 ref="title"&gt;）
  const titleRef = useRef&lt;HTMLHeadingElement&gt;(null)

  // 2. 存放一个不触发渲染的变量（对照普通 let 变量，但跨渲染保留）
  const timerRef = useRef&lt;number&gt;()

  function show() {
    console.log(titleRef.current)  // 通过 .current 访问 DOM 节点
  }

  return (
    &lt;div&gt;
      &lt;h1 ref={titleRef}&gt;标题&lt;/h1&gt;
      &lt;button onClick={show}&gt;打印&lt;/button&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<blockquote>
 <p>对照 Vue3：</p>
 <ul>
  <li>获取 DOM：Vue 用 <code>let title = ref()</code> + <code>ref="title"</code>，读 <code>title.value</code>；React 用 <code>useRef(null)</code> + <code>ref={titleRef}</code>，读 <code>titleRef.current</code>。</li>
  <li><code>useRef</code> 的 <code>current</code> 改了<strong>不会触发重渲染</strong>，这点和 Vue 的普通变量类似，但普通变量每次渲染会被重置，<code>useRef</code> 则跨渲染保留。</li>
 </ul>
</blockquote>
<h2 id="3-8--useEffect-对照-watch---watchEffect---生命周期-">3.8. useEffect（对照 watch / watchEffect / 生命周期）</h2>
<p><code>useEffect</code> 是 React 里最"重"的 Hook，<strong>同时承担了 Vue 的 watch、watchEffect、onMounted、onUnmounted 职责</strong>。</p>
<p>基本结构：</p>
<pre><code class="language-tsx">useEffect(() =&gt; {
  // 副作用逻辑：发请求、订阅、操作 DOM、设置定时器...

  return () =&gt; {
    // 可选：清理函数，组件卸载 / 依赖变化前执行（对照 onUnmounted）
  }
}, [依赖数组])
</code></pre>
<h3 id="情况一-不传依赖数组---每次渲染都执行-对照-watchEffect-的某种用法-">情况一：不传依赖数组 → 每次渲染都执行（对照 watchEffect 的某种用法）</h3>
<pre><code class="language-tsx">useEffect(() =&gt; {
  console.log('组件每次渲染后都执行')
})
</code></pre>
<h3 id="情况二-传空数组----只在挂载时执行一次-对照-onMounted-">情况二：传空数组 <code>[]</code> → 只在挂载时执行一次（对照 onMounted）</h3>
<pre><code class="language-tsx">import { useEffect, useState } from 'react'

function UserList() {
  const [list, setList] = useState([])

  useEffect(() =&gt; {
    // 对照 onMounted(() =&gt; { getData() })
    fetch('/api/users')
      .then((res) =&gt; res.json())
      .then((data) =&gt; setList(data))
  }, [])  // 空数组 = 只执行一次

  return &lt;ul&gt;{list.map((u) =&gt; &lt;li key={u.id}&gt;{u.name}&lt;/li&gt;)}&lt;/ul&gt;
}
</code></pre>
<h3 id="情况三-依赖数组里写状态---状态变化才执行-对照-watch-">情况三：依赖数组里写状态 → 状态变化才执行（对照 watch）</h3>
<pre><code class="language-tsx">const [count, setCount] = useState(0)

useEffect(() =&gt; {
  // 对照 watch(count, (newV) =&gt; {...})
  console.log('count 变成了', count)
}, [count])  // 只有 count 变化才执行
</code></pre>
<h3 id="情况四-带清理函数-对照-onUnmounted-">情况四：带清理函数（对照 onUnmounted）</h3>
<pre><code class="language-tsx">useEffect(() =&gt; {
  const timer = setInterval(() =&gt; console.log('tick'), 1000)

  return () =&gt; {
    clearInterval(timer)  // 对照 onUnmounted 里清定时器
  }
}, [])
</code></pre>
<blockquote>
 <p>对照 Vue3 总结表：</p>
 <table>
  <thead>
   <tr>
    <th>Vue3</th>
    <th>React</th>
   </tr>
  </thead>
  <tbody>
   <tr>
    <td><code>onMounted(fn)</code></td>
    <td><code>useEffect(fn, [])</code></td>
   </tr>
   <tr>
    <td><code>onUnmounted(fn)</code></td>
    <td><code>useEffect(() =&gt; fn, [])</code> 的清理函数</td>
   </tr>
   <tr>
    <td><code>watch(x, fn)</code></td>
    <td><code>useEffect(fn, [x])</code></td>
   </tr>
   <tr>
    <td><code>watchEffect(fn)</code></td>
    <td>不传依赖数组，或手动罗列依赖</td>
   </tr>
   <tr>
    <td><code>onUpdated</code></td>
    <td>无直接对应，较少用</td>
   </tr>
  </tbody>
 </table>
</blockquote>
<blockquote>
 <p>读代码提示：<code>useEffect</code> 第二个参数（依赖数组）是关键，先看 <code>[]</code> 是"只跑一次"，还是有具体依赖"跟着某个值跑"。</p>
</blockquote>
<h2 id="3-9--useMemo-对照-computed-">3.9. useMemo（对照 computed）</h2>
<p><code>useMemo</code> 用于"根据已有数据计算出新数据并缓存"，等价于 Vue 的 <code>computed</code>。</p>
<pre><code class="language-tsx">import { useMemo, useState } from 'react'

function Demo() {
  const [firstName, setFirstName] = useState('zhang')
  const [lastName, setLastName] = useState('san')

  // 对照 computed(() =&gt; firstName + '-' + lastName)
  const fullName = useMemo(
    () =&gt; `${firstName}-${lastName}`,
    [firstName, lastName]  // 依赖变化才重新计算
  )

  return &lt;p&gt;全名：{fullName}&lt;/p&gt;
}
</code></pre>
<blockquote>
 <p>对照：<code>computed(() =&gt; ...)</code> ≈ <code>useMemo(() =&gt; ..., [依赖])</code>。区别是 Vue 自动追踪依赖，React 要手动在第二个参数里列出依赖。</p>
</blockquote>
<h2 id="3-10--useCallback-缓存函数-">3.10. useCallback（缓存函数）</h2>
<p><code>useCallback</code> 用来"缓存一个函数"，避免子组件因父组件每次渲染都新建函数而多余地重渲染。读代码时先认识它即可。</p>
<pre><code class="language-tsx">import { useCallback, useState } from 'react'

function Parent() {
  const [count, setCount] = useState(0)

  // 这个函数在 count 不变时，引用保持不变
  const handleClick = useCallback(() =&gt; {
    setCount((c) =&gt; c + 1)
  }, [])

  return &lt;Child onClick={handleClick} /&gt;
}
</code></pre>
<blockquote>
 <p>常见于配合 <code>React.memo</code>（见 3.11）使用，用于性能优化。读代码时无需深究原理，知道"它是用来缓存函数的"即可。</p>
</blockquote>
<h2 id="3-11--React-memo-对照组件缓存-">3.11. React.memo（对照组件缓存）</h2>
<p><code>React.memo</code> 包装一个组件，让它在 <strong>props 没变时跳过重渲染</strong>。Vue 中组件默认就有类似优化，React 需要手动加。</p>
<pre><code class="language-tsx">import { memo } from 'react'

function Child({ name }: { name: string }) {
  console.log('Child 渲染了')
  return &lt;p&gt;{name}&lt;/p&gt;
}

// 默认导出时包一层 memo
export default memo(Child)
</code></pre>
<h2 id="3-12--自定义-Hook-对照自定义-hook-">3.12. 自定义 Hook（对照自定义 hook）</h2>
<p><strong>概念与 Vue3 的自定义 hook 完全一致</strong>：把复用逻辑封装成函数，函数名必须以 <code>use</code> 开头。这是两边最像的概念。</p>
<pre><code class="language-tsx">// hooks/useSum.ts
import { useState, useCallback } from 'react'

export function useSum(initial = 0) {
  const [sum, setSum] = useState(initial)

  const increment = useCallback(() =&gt; setSum((s) =&gt; s + 1), [])
  const decrement = useCallback(() =&gt; setSum((s) =&gt; s - 1), [])

  return { sum, increment, decrement }
}
</code></pre>
<pre><code class="language-tsx">// 组件中使用
import { useSum } from './hooks/useSum'

function App() {
  const { sum, increment, decrement } = useSum()
  return (
    &lt;div&gt;
      &lt;h2&gt;求和：{sum}&lt;/h2&gt;
      &lt;button onClick={increment}&gt;+1&lt;/button&gt;
      &lt;button onClick={decrement}&gt;-1&lt;/button&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<blockquote>
 <p>对照：Vue 的自定义 hook 是 <code>export default function() {...}</code>，React 是 <code>export function useXxx() {...}</code>，使用方式（在组件里解构调用）完全一样。</p>
</blockquote>
<h2 id="3-13--组件定义与-props-对照-props---defineProps-">3.13. 组件定义与 props（对照 props / defineProps）</h2>
<p>React 组件通过<strong>函数参数</strong>接收 props，没有 <code>defineProps</code>，TypeScript 用接口约束类型。</p>
<pre><code class="language-tsx">// 定义 props 类型（对照 interface + defineProps）
interface PersonProps {
  name: string
  age?: number        // ? 表示可选
  onGetToy: (value: string) =&gt; void   // 函数类型 props
}

// 子组件：props 通过函数参数传入
function Child({ name, age = 18, onGetToy }: PersonProps) {
  return (
    &lt;div&gt;
      &lt;h2&gt;姓名：{name}，年龄：{age}&lt;/h2&gt;
      &lt;button onClick={() =&gt; onGetToy('奥特曼')}&gt;把玩具给爸爸&lt;/button&gt;
    &lt;/div&gt;
  )
}

// 父组件：像写 HTML 属性一样传 props
function Father() {
  return &lt;Child name="张三" onGetToy={(toy) =&gt; console.log(toy)} /&gt;
}
</code></pre>
<blockquote>
 <p>对照 Vue3：</p>
 <ul>
  <li><code>defineProps(['name','age'])</code> → React 直接在函数参数里解构 <code>{ name, age }</code>。</li>
  <li>默认值：Vue 用 <code>withDefaults</code>，React 直接在解构时写 <code>age = 18</code>。</li>
  <li><strong>子传父</strong>：Vue 用 <code>defineEmits</code> 自定义事件；React 直接传一个<strong>回调函数作为 prop</strong>（<code>onGetToy</code>），子组件调用它即可。React 没有"事件"这一层抽象。</li>
 </ul>
</blockquote>
<h2 id="3-14--生命周期对照总表">3.14. 生命周期对照总表</h2>
<p>React 函数组件没有 onMounted 等命名钩子，全部用 <code>useEffect</code> 表达：</p>
<table>
 <thead>
  <tr>
   <th>Vue3</th>
   <th>React</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><code>setup</code>（创建阶段）</td>
   <td>组件函数体本身</td>
  </tr>
  <tr>
   <td><code>onMounted</code></td>
   <td><code>useEffect(fn, [])</code></td>
  </tr>
  <tr>
   <td><code>onUpdated</code></td>
   <td>无直接对应（一般不写）</td>
  </tr>
  <tr>
   <td><code>onBeforeUnmount</code></td>
   <td><code>useEffect</code> 返回的清理函数</td>
  </tr>
  <tr>
   <td><code>onUnmounted</code></td>
   <td><code>useEffect</code> 返回的清理函数</td>
  </tr>
 </tbody>
</table>
<hr>
<h1 id="4--路由-React-Router-">4. 路由（React Router）</h1>
<p>React 路由最常用的是 <code>react-router-dom</code>，目前是 <strong>v6</strong> 版本。与 Vue Router 概念一一对应。</p>
<h2 id="4-1--安装与配置">4.1. 安装与配置</h2>
<pre><code class="language-powershell">npm install react-router-dom
</code></pre>
<p><code>src/main.tsx</code>（React 版的路由挂载）：</p>
<pre><code class="language-tsx">import { BrowserRouter } from 'react-router-dom'

ReactDOM.createRoot(document.getElementById('root')!).render(
  // BrowserRouter = Vue 的 history 模式（createWebHistory）
  &lt;BrowserRouter&gt;
    &lt;App /&gt;
  &lt;/BrowserRouter&gt;
)
</code></pre>
<h2 id="4-2--定义路由-对照-createRouter-的-routes-">4.2. 定义路由（对照 createRouter 的 routes）</h2>
<pre><code class="language-tsx">// App.tsx
import { Routes, Route, Link, Outlet } from 'react-router-dom'
import Home from './pages/Home'
import About from './pages/About'

function App() {
  return (
    &lt;div&gt;
      {/* 导航区：Link = RouterLink */}
      &lt;nav&gt;
        &lt;Link to="/home"&gt;首页&lt;/Link&gt;
        &lt;Link to="/about"&gt;关于&lt;/Link&gt;
      &lt;/nav&gt;

      {/* 展示区：Routes + Route = RouterView + 路由规则 */}
      &lt;Routes&gt;
        &lt;Route path="/home" element={&lt;Home /&gt;} /&gt;
        &lt;Route path="/about" element={&lt;About /&gt;} /&gt;
      &lt;/Routes&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<blockquote>
 <p>对照 Vue3 路由：</p>
 <table>
  <thead>
   <tr>
    <th>Vue Router</th>
    <th>React Router v6</th>
   </tr>
  </thead>
  <tbody>
   <tr>
    <td><code>&lt;RouterLink to="/home"&gt;</code></td>
    <td><code>&lt;Link to="/home"&gt;</code></td>
   </tr>
   <tr>
    <td><code>&lt;RouterView /&gt;</code></td>
    <td><code>&lt;Routes&gt;...&lt;Route .../&gt;&lt;/Routes&gt;</code> 或 <code>&lt;Outlet /&gt;</code></td>
   </tr>
   <tr>
    <td><code>{ path, component }</code></td>
    <td><code>&lt;Route path element={&lt;组件/&gt;} /&gt;</code></td>
   </tr>
   <tr>
    <td><code>useRouter().push()</code></td>
    <td><code>useNavigate()</code></td>
   </tr>
   <tr>
    <td><code>useRoute().query/params</code></td>
    <td><code>useParams()</code> / <code>useSearchParams()</code></td>
   </tr>
  </tbody>
 </table>
</blockquote>
<h2 id="4-3--嵌套路由-对照-children-">4.3. 嵌套路由（对照 children）</h2>
<pre><code class="language-tsx">function App() {
  return (
    &lt;Routes&gt;
      &lt;Route path="/news" element={&lt;News /&gt;}&gt;
        {/* 子路由：path 不用写全，element 里写子组件 */}
        &lt;Route path="detail" element={&lt;Detail /&gt;} /&gt;
      &lt;/Route&gt;
    &lt;/Routes&gt;
  )
}

// News.tsx 里用 Outlet 占位（对照 &lt;RouterView /&gt;）
function News() {
  return (
    &lt;div&gt;
      &lt;h2&gt;新闻列表&lt;/h2&gt;
      &lt;Outlet /&gt;   {/* 子路由组件渲染到这里 */}
    &lt;/div&gt;
  )
}
</code></pre>
<h2 id="4-4--路由传参">4.4. 路由传参</h2>
<h3 id="params-路径参数-对照-Vue-的-params-">params（路径参数，对照 Vue 的 params）</h3>
<pre><code class="language-tsx">// 定义时用 :id 占位
&lt;Route path="/user/:id" element={&lt;User /&gt;} /&gt;

// 跳转
&lt;Link to="/user/123"&gt;用户 123&lt;/Link&gt;

// 接收：useParams
import { useParams } from 'react-router-dom'
function User() {
  const { id } = useParams()   // 对照 route.params.id
  return &lt;h1&gt;用户 ID：{id}&lt;/h1&gt;
}
</code></pre>
<h3 id="query-搜索参数-对照-Vue-的-query-">query（搜索参数，对照 Vue 的 query）</h3>
<pre><code class="language-tsx">// 跳转
&lt;Link to="/search?keyword=react&amp;page=1"&gt;搜索&lt;/Link&gt;

// 接收：useSearchParams（比 Vue 麻烦一点）
import { useSearchParams } from 'react-router-dom'
function Search() {
  const [searchParams] = useSearchParams()
  const keyword = searchParams.get('keyword')  // 对照 route.query.keyword
  return &lt;h1&gt;关键词：{keyword}&lt;/h1&gt;
}
</code></pre>
<h2 id="4-5--编程式导航-对照-useRouter-">4.5. 编程式导航（对照 useRouter）</h2>
<pre><code class="language-tsx">import { useNavigate } from 'react-router-dom'

function Demo() {
  const navigate = useNavigate()

  function goHome() {
    navigate('/home')                     // 对照 router.push('/home')
  }
  function goBack() {
    navigate(-1)                          // 对照 router.back()
  }
  function replaceTo() {
    navigate('/login', { replace: true }) // 对照 router.replace()
  }
  function goWithParams() {
    navigate('/user/123')                 // params 直接拼在路径里
  }
  function goWithQuery() {
    navigate('/search?keyword=react')     // query 拼在路径后
  }

  return &lt;button onClick={goHome}&gt;去首页&lt;/button&gt;
}
</code></pre>
<h2 id="4-6--两种模式-对照-history---hash-">4.6. 两种模式（对照 history / hash）</h2>
<table>
 <thead>
  <tr>
   <th>模式</th>
   <th>Vue3</th>
   <th>React</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>history（URL 无 #）</td>
   <td><code>createWebHistory()</code></td>
   <td><code>&lt;BrowserRouter&gt;</code></td>
  </tr>
  <tr>
   <td>hash（URL 带 #）</td>
   <td><code>createWebHashHistory()</code></td>
   <td><code>&lt;HashRouter&gt;</code></td>
  </tr>
 </tbody>
</table>
<hr>
<h1 id="5--状态管理-对照-Pinia-">5. 状态管理（对照 Pinia）</h1>
<p>React 状态管理方案很多，<strong>已有项目中最常见的是 Redux Toolkit（RTK）</strong>，其次是 Context + useReducer、Zustand。下面重点讲最可能遇到的 RTK，再简单介绍 Context。</p>
<h2 id="5-1--Redux-Toolkit-对照-Pinia-">5.1. Redux Toolkit（对照 Pinia）</h2>
<h3 id="核心概念对照">核心概念对照</h3>
<table>
 <thead>
  <tr>
   <th>Pinia</th>
   <th>Redux Toolkit</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><code>state</code></td>
   <td><code>state</code>（必须整体替换）</td>
  </tr>
  <tr>
   <td><code>getters</code></td>
   <td><code>createSelector</code> / 组件里用 <code>useMemo</code></td>
  </tr>
  <tr>
   <td><code>actions</code></td>
   <td><code>reducers</code> 里的函数</td>
  </tr>
  <tr>
   <td>修改数据</td>
   <td><code>dispatch(action)</code></td>
  </tr>
  <tr>
   <td><code>storeToRefs</code></td>
   <td><code>useSelector</code> 读数据</td>
  </tr>
 </tbody>
</table>
<h3 id="搭建与使用">搭建与使用</h3>
<pre><code class="language-powershell">npm install @reduxjs/toolkit react-redux
</code></pre>
<p><strong>第一步：创建 store（对照 store/count.ts）</strong></p>
<pre><code class="language-ts">// store/counterSlice.ts —— 一个"切片"，对应一个 Pinia 的 store
import { createSlice } from '@reduxjs/toolkit'

const counterSlice = createSlice({
  name: 'counter',            // 对照 defineStore('count', {...})
  initialState: {             // 对照 state()
    sum: 0,
  },
  reducers: {                 // 对照 actions
    increment(state, action) {
      state.sum += action.payload   // 注意：RTK 内部用 immer，允许"看似原地修改"
    },
    decrement(state, action) {
      state.sum -= action.payload
    },
  },
})

export const { increment, decrement } = counterSlice.actions
export default counterSlice.reducer
</code></pre>
<p><strong>第二步：汇总成 store（对照 createPinia）</strong></p>
<pre><code class="language-ts">// store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './counterSlice'

export const store = configureStore({
  reducer: {
    counter: counterReducer,   // 多个 slice 都挂在这里
  },
})

export type RootState = ReturnType&lt;typeof store.getState&gt;
export type AppDispatch = typeof store.dispatch
</code></pre>
<p><strong>第三步：注入 Provider（对照 app.use(pinia)）</strong></p>
<pre><code class="language-tsx">// main.tsx
import { Provider } from 'react-redux'
import { store } from './store'

ReactDOM.createRoot(document.getElementById('root')!).render(
  &lt;Provider store={store}&gt;
    &lt;App /&gt;
  &lt;/Provider&gt;
)
</code></pre>
<p><strong>第四步：组件中使用（对照 useXxxStore）</strong></p>
<pre><code class="language-tsx">import { useSelector, useDispatch } from 'react-redux'
import { increment, decrement } from './store/counterSlice'
import type { RootState } from './store'

function Counter() {
  // 读数据：useSelector，对照 useSumStore().sum
  const sum = useSelector((state: RootState) =&gt; state.counter.sum)

  // 修改数据：dispatch(action)
  const dispatch = useDispatch()

  return (
    &lt;div&gt;
      &lt;h2&gt;求和：{sum}&lt;/h2&gt;
      &lt;button onClick={() =&gt; dispatch(increment(1))}&gt;+1&lt;/button&gt;
      &lt;button onClick={() =&gt; dispatch(decrement(1))}&gt;-1&lt;/button&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<blockquote>
 <p>对照 Pinia：</p>
 <ul>
  <li><code>useSumStore().sum</code> ≈ <code>useSelector((s) =&gt; s.counter.sum)</code></li>
  <li><code>countStore.increment(n)</code> ≈ <code>dispatch(increment(n))</code></li>
  <li>Pinia 的 <code>action</code> 里可以写业务逻辑，RTK 里业务逻辑通常写在 <code>createAsyncThunk</code> 或组件里。</li>
 </ul>
</blockquote>
<h2 id="5-2--Context-对照-provide---inject-">5.2. Context（对照 provide / inject）</h2>
<p>Context 是 React 内置的跨层级通信方案，<strong>对应 Vue 的 provide / inject</strong>，也常被用作轻量状态管理。</p>
<pre><code class="language-tsx">import { createContext, useContext, useState } from 'react'

// 1. 创建 Context（对照 provide 的 key）
const ThemeContext = createContext('light')

function App() {
  const [theme, setTheme] = useState('light')
  return (
    // 2. Provider 提供数据（对照 provide('theme', value)）
    &lt;ThemeContext.Provider value={theme}&gt;
      &lt;Child /&gt;
    &lt;/ThemeContext.Provider&gt;
  )
}

function Child() {
  return &lt;GrandChild /&gt;
}

function GrandChild() {
  // 3. useContext 消费数据（对照 inject('theme')）
  const theme = useContext(ThemeContext)
  return &lt;p&gt;当前主题：{theme}&lt;/p&gt;
}
</code></pre>
<blockquote>
 <p>对照：<code>provide/inject</code> ≈ <code>Context.Provider</code> + <code>useContext</code>。区别是 React 的 Context 值变化会触发所有消费它的组件重渲染，所以常配合 <code>useMemo</code> 缓存。</p>
</blockquote>
<hr>
<h1 id="6--组件通信对照总表">6. 组件通信对照总表</h1>
<table>
 <thead>
  <tr>
   <th>场景</th>
   <th>Vue3</th>
   <th>React</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>父传子</td>
   <td><code>props</code></td>
   <td>函数参数 <code>props</code></td>
  </tr>
  <tr>
   <td>子传父</td>
   <td><code>defineEmits</code> + <code>emit('事件', 数据)</code></td>
   <td>传回调函数 <code>props.onXxx(数据)</code></td>
  </tr>
  <tr>
   <td>祖孙通信</td>
   <td><code>provide</code> / <code>inject</code></td>
   <td><code>Context.Provider</code> + <code>useContext</code></td>
  </tr>
  <tr>
   <td>任意组件通信</td>
   <td><code>mitt</code> / <code>pinia</code></td>
   <td>状态管理库（Redux/Zustand）或 Context</td>
  </tr>
  <tr>
   <td>插槽</td>
   <td><code>&lt;slot&gt;</code> / 具名插槽 / 作用域插槽</td>
   <td><code>props.children</code> / 具名 props / render props</td>
  </tr>
  <tr>
   <td>DOM/组件引用</td>
   <td><code>ref</code> + <code>defineExpose</code></td>
   <td><code>useRef</code>（React 无 defineExpose，默认拿不到子组件实例）</td>
  </tr>
 </tbody>
</table>
<h2 id="6-1--插槽-对照-slot-">6.1. 插槽（对照 slot）</h2>
<p>React <strong>没有 slot 指令</strong>，默认插槽就是 <code>children</code>，具名插槽就是普通 prop，作用域插槽就是 render props。</p>
<pre><code class="language-tsx">// 默认插槽（对照 &lt;slot&gt;&lt;/slot&gt;）
function Card({ children }: { children: React.ReactNode }) {
  return &lt;div className="card"&gt;{children}&lt;/div&gt;
}

// 使用
&lt;Card&gt;
  &lt;ul&gt;&lt;li&gt;内容&lt;/li&gt;&lt;/ul&gt;
&lt;/Card&gt;
</code></pre>
<pre><code class="language-tsx">// 具名插槽（对照 &lt;slot name="header"&gt;）
function Layout({ header, footer, children }: { header: React.ReactNode; footer: React.ReactNode; children: React.ReactNode }) {
  return (
    &lt;div&gt;
      &lt;header&gt;{header}&lt;/header&gt;
      &lt;main&gt;{children}&lt;/main&gt;
      &lt;footer&gt;{footer}&lt;/footer&gt;
    &lt;/div&gt;
  )
}

// 使用
&lt;Layout
  header={&lt;h1&gt;页头&lt;/h1&gt;}
  footer={&lt;p&gt;页脚&lt;/p&gt;}
&gt;
  &lt;p&gt;主体内容&lt;/p&gt;
&lt;/Layout&gt;
</code></pre>
<pre><code class="language-tsx">// 作用域插槽（对照 v-slot="params"）：用 render props 实现
function GameList({ render }: { render: (games: Game[]) =&gt; React.ReactNode }) {
  const games = [{ id: 'a01', name: '英雄联盟' }]
  return &lt;div&gt;{render(games)}&lt;/div&gt;
}

// 使用：传一个函数，函数参数就是子组件暴露的数据
&lt;GameList render={(games) =&gt; (
  &lt;ul&gt;{games.map((g) =&gt; &lt;li key={g.id}&gt;{g.name}&lt;/li&gt;)}&lt;/ul&gt;
)} /&gt;
</code></pre>
<blockquote>
 <p>对照：<code>&lt;slot&gt;</code> → <code>children</code>；<code>&lt;slot name="x"&gt;</code> → 具名 prop；<code>v-slot="params"</code> → render props（传函数）。</p>
</blockquote>
<hr>
<h1 id="7--其它常见-API">7. 其它常见 API</h1>
<h2 id="7-1--useReducer-对照复杂状态管理-">7.1. useReducer（对照复杂状态管理）</h2>
<p>当状态逻辑复杂（多个子状态相互关联）时，用 <code>useReducer</code>，它类似 Redux 的迷你版。</p>
<pre><code class="language-tsx">import { useReducer } from 'react'

interface State { count: number }
type Action = { type: 'increment' } | { type: 'decrement' } | { type: 'reset' }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 }
    case 'decrement':
      return { count: state.count - 1 }
    case 'reset':
      return { count: 0 }
  }
}

function Demo() {
  const [state, dispatch] = useReducer(reducer, { count: 0 })
  return (
    &lt;div&gt;
      &lt;h2&gt;计数：{state.count}&lt;/h2&gt;
      &lt;button onClick={() =&gt; dispatch({ type: 'increment' })}&gt;+1&lt;/button&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<h2 id="7-2--Fragment-对照模板多根节点-">7.2. Fragment（对照模板多根节点）</h2>
<p>Vue3 模板可以直接写多个根标签，React 的 JSX 必须有一个根元素，用 <code>&lt;&gt;&lt;/&gt;</code>（Fragment）占位即可，不会渲染出真实 DOM。</p>
<pre><code class="language-tsx">function Demo() {
  return (
    &lt;&gt;
      &lt;h1&gt;标题1&lt;/h1&gt;
      &lt;h2&gt;标题2&lt;/h2&gt;
    &lt;/&gt;
  )
}
// 等价于 &lt;React.Fragment&gt;...&lt;/React.Fragment&gt;
</code></pre>
<h2 id="7-3--createPortal-对照-Teleport-">7.3. createPortal（对照 Teleport）</h2>
<pre><code class="language-tsx">import { createPortal } from 'react-dom'

function Modal({ isShow }: { isShow: boolean }) {
  if (!isShow) return null
  // 把弹窗 DOM 渲染到 body 下（对照 &lt;Teleport to="body"&gt;）
  return createPortal(
    &lt;div className="modal"&gt;
      &lt;h2&gt;我是一个弹窗&lt;/h2&gt;
    &lt;/div&gt;,
    document.body
  )
}
</code></pre>
<blockquote>
 <p>对照：<code>&lt;Teleport to="body"&gt;</code> ≈ <code>createPortal(jsx, document.body)</code>。</p>
</blockquote>
<h2 id="7-4--lazy---Suspense-对照-defineAsyncComponent---Suspense-">7.4. lazy + Suspense（对照 defineAsyncComponent + Suspense）</h2>
<pre><code class="language-tsx">import { lazy, Suspense } from 'react'

// 异步引入组件（对照 defineAsyncComponent(() =&gt; import(...))）
const Child = lazy(() =&gt; import('./Child'))

function App() {
  return (
    // Suspense 的 fallback 对照 Vue 的 v-slot:fallback
    &lt;Suspense fallback={&lt;h3&gt;加载中......&lt;/h3&gt;}&gt;
      &lt;Child /&gt;
    &lt;/Suspense&gt;
  )
}
</code></pre>
<blockquote>
 <p>对照：Vue 的 <code>&lt;Suspense&gt;</code> 需要 <code>&lt;template #default&gt;</code> / <code>&lt;template #fallback&gt;</code>；React 直接用 <code>fallback</code> 属性 + 子组件就是 default。</p>
</blockquote>
<h2 id="7-5--Class-组件-老项目遗留代码-">7.5. Class 组件（老项目遗留代码）</h2>
<p>老 React 项目会用 Class 组件，读代码时认识即可，现代写法已全面转向函数组件 + Hooks。</p>
<pre><code class="language-tsx">import { Component } from 'react'

interface Props { name: string }
interface State { count: number }

class Counter extends Component&lt;Props, State&gt; {
  state: State = { count: 0 }          // 对照 data()

  handleClick = () =&gt; {                 // 对照 methods
    this.setState({ count: this.state.count + 1 })  // 对照修改 data
  }

  componentDidMount() {                 // 对照 onMounted
    console.log('挂载了')
  }

  componentWillUnmount() {              // 对照 onUnmounted
    console.log('卸载了')
  }

  render() {                            // 对照 &lt;template&gt;
    return (
      &lt;div&gt;
        &lt;h1&gt;{this.props.name}&lt;/h1&gt;      {/* 对照 this.xxx 访问 props */}
        &lt;h2&gt;计数：{this.state.count}&lt;/h2&gt;
        &lt;button onClick={this.handleClick}&gt;+1&lt;/button&gt;
      &lt;/div&gt;
    )
  }
}
</code></pre>
<blockquote>
 <p>Class 组件生命周期对照：<code>componentDidMount</code> = onMounted；<code>componentDidUpdate</code> = onUpdated；<code>componentWillUnmount</code> = onUnmounted。</p>
</blockquote>
<hr>
<h1 id="8--读-React-代码的速查清单">8. 读 React 代码的速查清单</h1>
<p>读一个陌生 React 文件时，按以下顺序快速理解：</p>
<ol>
 <li><strong>看组件签名</strong>：<code>function Xxx(props)</code> 是函数组件，<code>class Xxx extends Component</code> 是类组件。</li>
 <li><strong>看 import</strong>：<code>useState</code>（状态）、<code>useEffect</code>（副作用/请求）、<code>useRef</code>（DOM/持久变量）、<code>useMemo</code>（计算属性）、<code>useCallback</code>（缓存函数）、<code>useContext</code>（跨层数据）。</li>
 <li><strong>看 useState 返回的解构</strong>：<code>const [xxx, setXxx] = useState(...)</code>，<code>xxx</code> 是数据，<code>setXxx</code> 是唯一修改入口。</li>
 <li><strong>看 useEffect 第二参数</strong>：<code>[]</code> 只跑一次，<code>[a, b]</code> 跟着 a/b 跑，不写则每次渲染都跑。</li>
 <li><strong>看 JSX 里的 <code>{ }</code></strong>：花括号里都是 JS 表达式，<code>{list.map(...)}</code> 是循环，<code>{cond &amp;&amp; ...}</code> 或三元是条件。</li>
 <li><strong>看数据流向</strong>：数据永远是"从上往下"通过 props 传，子改父靠回调函数，跨层靠 Context 或状态库。</li>
 <li><strong>遇到不认识的 <code>useXxx</code></strong>：大概率是自定义 Hook 或第三方 Hook（如 <code>useNavigate</code>、<code>useSelector</code>），按名字猜用途即可。</li>
</ol>
<h2 id="8-1--一张对照速查总表">8.1. 一张对照速查总表</h2>
<table>
 <thead>
  <tr>
   <th>需求</th>
   <th>Vue3</th>
   <th>React</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>定义状态</td>
   <td><code>ref()</code> / <code>reactive()</code></td>
   <td><code>useState()</code></td>
  </tr>
  <tr>
   <td>读状态</td>
   <td><code>xxx.value</code> / <code>xxx</code></td>
   <td><code>xxx</code></td>
  </tr>
  <tr>
   <td>改状态</td>
   <td><code>xxx.value = 1</code> / <code>obj.a = 1</code></td>
   <td><code>setXxx(新值)</code>，对象整体替换</td>
  </tr>
  <tr>
   <td>计算属性</td>
   <td><code>computed()</code></td>
   <td><code>useMemo()</code></td>
  </tr>
  <tr>
   <td>监听</td>
   <td><code>watch</code> / <code>watchEffect</code></td>
   <td><code>useEffect</code></td>
  </tr>
  <tr>
   <td>挂载钩子</td>
   <td><code>onMounted</code></td>
   <td><code>useEffect(fn, [])</code></td>
  </tr>
  <tr>
   <td>卸载钩子</td>
   <td><code>onUnmounted</code></td>
   <td><code>useEffect</code> 清理函数</td>
  </tr>
  <tr>
   <td>DOM 引用</td>
   <td><code>ref</code> + <code>ref="xx"</code></td>
   <td><code>useRef</code> + <code>ref={xx}</code>，读 <code>.current</code></td>
  </tr>
  <tr>
   <td>循环</td>
   <td><code>v-for</code> + <code>:key</code></td>
   <td><code>.map()</code> + <code>key</code></td>
  </tr>
  <tr>
   <td>条件</td>
   <td><code>v-if</code> / <code>v-show</code></td>
   <td><code>&amp;&amp;</code> / 三元 / 控制 display</td>
  </tr>
  <tr>
   <td>双向绑定</td>
   <td><code>v-model</code></td>
   <td><code>value</code> + <code>onChange</code>（受控组件）</td>
  </tr>
  <tr>
   <td>事件</td>
   <td><code>@click</code></td>
   <td><code>onClick</code></td>
  </tr>
  <tr>
   <td>插槽</td>
   <td><code>&lt;slot&gt;</code></td>
   <td><code>children</code> / 具名 prop</td>
  </tr>
  <tr>
   <td>父子通信</td>
   <td>props + emit</td>
   <td>props + 回调函数</td>
  </tr>
  <tr>
   <td>祖孙通信</td>
   <td>provide/inject</td>
   <td>Context</td>
  </tr>
  <tr>
   <td>状态管理</td>
   <td>Pinia</td>
   <td>Redux Toolkit / Zustand</td>
  </tr>
  <tr>
   <td>路由</td>
   <td>vue-router</td>
   <td>react-router-dom v6</td>
  </tr>
  <tr>
   <td>传送 DOM</td>
   <td>Teleport</td>
   <td>createPortal</td>
  </tr>
  <tr>
   <td>异步组件</td>
   <td>defineAsyncComponent</td>
   <td>lazy + Suspense</td>
  </tr>
  <tr>
   <td>多根节点</td>
   <td>原生支持</td>
   <td><code>&lt;&gt;&lt;/&gt;</code> Fragment</td>
  </tr>
  <tr>
   <td>自定义复用逻辑</td>
   <td>自定义 hook</td>
   <td>自定义 Hook（useXxx）</td>
  </tr>
 </tbody>
</table>
<hr>
<blockquote>
 <p>结语：React 相比 Vue3，少了"响应式魔法"（ref/reactive/computed/watch 的自动依赖追踪），换来的是"一切皆函数、一切皆显式"的简单心智模型。读代码时抓住两条主线即可：<strong>① 状态在哪定义、怎么改（useState）；② 状态变化后组件会整体重新执行（JSX 重新计算）</strong>。其余 API 都是围绕这两点的补充。</p>
</blockquote>]]></description><guid isPermaLink="false">/archives/react-dui-zhao-su-cheng-wen-dang</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-01a01045-b8a2-72ad-b988-923de31e0d54-52d6c52c.png&amp;size=m" type="image/jpeg" length="1611227"/><category>前端框架</category><pubDate>Mon, 17 Aug 2026 15:10:58 GMT</pubDate></item><item><title><![CDATA[用 Project Reactor 的 bufferTimeout 代替 BufferTrigger 做高并发计数聚合]]></title><link>https://likeyy.love/archives/yong-project-reactor-de-buffertimeout-dai-ti-buffertrigger-zuo-gao-bing-fa-ji-shu-ju-he</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=%E7%94%A8%20Project%20Reactor%20%E7%9A%84%20bufferTimeout%20%E4%BB%A3%E6%9B%BF%20BufferTrigger%20%E5%81%9A%E9%AB%98%E5%B9%B6%E5%8F%91%E8%AE%A1%E6%95%B0%E8%81%9A%E5%90%88&amp;url=/archives/yong-project-reactor-de-buffertimeout-dai-ti-buffertrigger-zuo-gao-bing-fa-ji-shu-ju-he" width="1" height="1" alt="" style="opacity:0;">
<h1 id="用-Project-Reactor-的-bufferTimeout-代替-BufferTrigger-做高并发计数聚合">用 Project Reactor 的 bufferTimeout 代替 BufferTrigger 做高并发计数聚合</h1>
<blockquote>
 <p>记录一次在计数服务里做「粉丝数聚合写」的实现。原教程用快手的 BufferTrigger，我改用了 Spring Boot 自带的 Project Reactor，零新增依赖。</p>
</blockquote>
<h2 id="背景-为什么要聚合">背景：为什么要聚合</h2>
<p>粉丝数是典型的高并发写场景——某个用户突然爆火，短时间涌入大量关注。如果每来一条 MQ 就 <code>HINCRBY</code> Redis + 写一次库，Redis 和数据库都顶不住。</p>
<p>聚合的本质是：<strong>把一个时间窗口内的大量 +1/-1 先在内存里合并，只对 Redis/DB 做一次净增量操作</strong>。</p>
<p>比如同一秒内，对用户 27 的粉丝数操作是：</p>
<pre><code>+1  +1  -1  +1
</code></pre>
<p>无论顺序如何，聚合到一起就是净 <code>+2</code>，只需操作一次 Redis 与数据库。这就是关注/取关这类计数天然适合聚合的原因——它满足交换律，聚合无需关心顺序，最终一致即可。</p>
<p>聚合需要两个触发条件（谁先到谁触发）：</p>
<ul>
 <li><strong>数量阈值</strong>：攒够 1000 条，聚合一次；</li>
 <li><strong>时间窗口</strong>：即使 1 秒内不足 1000 条，为了数据及时性，到点也触发一次。</li>
</ul>
<h2 id="原方案-快手-BufferTrigger">原方案：快手 BufferTrigger</h2>
<p>原教程用的是快手开源的 <code>com.github.phantomthief:buffer-trigger</code>：</p>
<pre><code class="language-java">private BufferTrigger&lt;String&gt; bufferTrigger = BufferTrigger.&lt;String&gt;batchBlocking()
        .bufferSize(50000)              // 缓存队列最大容量
        .batchSize(1000)                // 一批最多聚合 1000 条
        .linger(Duration.ofSeconds(1))  // 多久聚合一次
        .setConsumerEx(this::consumeMessage)
        .build();

// onMessage 里：
bufferTrigger.enqueue(body);
</code></pre>
<p>它内部就是一个带「数量阈值 + 时间窗口」双触发的缓冲队列。功能没问题，但这个库 <strong>2021 年（0.2.21）后就停更了</strong>，是单维护者项目。给一个全新模块引入一个多年不更新的第三方依赖不太理想，所以我换掉了它。</p>
<h2 id="替代方案-Project-Reactor-的-Sinks---bufferTimeout">替代方案：Project Reactor 的 Sinks + bufferTimeout</h2>
<p>Reactor 随 Spring Boot 传递依赖，<strong>已经在 classpath 上，零新增依赖</strong>，且由 Spring 团队活跃维护，原生支持背压。核心是三件套。</p>
<h3 id="--一个-Sink-当入口缓冲">① 一个 Sink 当入口缓冲</h3>
<pre><code class="language-java">private final Sinks.Many&lt;String&gt; sink = Sinks.many().unicast().onBackpressureBuffer();
</code></pre>
<p><code>Sinks.Many</code> 是 Reactor 里「手动往响应式流里塞元素」的入口。<code>unicast()</code> = 单订阅者，<code>onBackpressureBuffer()</code> = 下游处理不过来时把元素缓存在内部队列里（而不是丢弃或报错）。</p>
<h3 id="--启动时订阅-挂上-bufferTimeout">② 启动时订阅，挂上 bufferTimeout</h3>
<pre><code class="language-java">@PostConstruct
public void init() {
    subscription = sink.asFlux()
            .bufferTimeout(1000, Duration.ofSeconds(1))   // ← BufferTrigger 的双触发等价物
            .subscribe(this::consumeBatch);
}
</code></pre>
<p><code>bufferTimeout(1000, 1s)</code> 就是等价物：<strong>攒够 1000 个元素，或距上批满 1 秒，谁先到就把这一批 <code>List</code> 推给下游</strong>。</p>
<p>发 3200 条就会切成 <code>1000 + 1000 + 1000 + 200</code> 四批——前三批靠「满 1000」触发，最后 200 条靠「1 秒超时」触发。</p>
<h3 id="--RocketMQ-消费回调只负责入队">③ RocketMQ 消费回调只负责入队</h3>
<pre><code class="language-java">@Override
public void onMessage(String body) {
    sink.emitNext(body, Sinks.EmitFailureHandler.busyLooping(Duration.ofSeconds(1)));
}
</code></pre>
<p><code>onMessage</code> 不做任何业务，只把消息塞进 sink 后立刻返回。聚合和落库在 Reactor 的另一条线程上异步进行。</p>
<h2 id="关键坑-并发-emit-不安全">关键坑：并发 emit 不安全</h2>
<p>这是整个替代方案里<strong>最容易踩错</strong>的地方。</p>
<p><strong>RocketMQ 是多线程回调 <code>onMessage</code> 的</strong>（日志里 <code>ConsumeMessageThread_..._3</code> 那个 <code>_3</code> 就是线程编号），但 <strong>Reactor 的 <code>Sinks</code> emit 并不是并发安全的</strong>。如果用 <code>tryEmitNext</code>，多个线程同时 emit 会返回 <code>FAIL_NON_SERIALIZED</code> 失败，消息就丢了。</p>
<p>所以这里不能用 <code>tryEmitNext</code>，要用带失败处理器的 <code>emitNext</code>：</p>
<pre><code class="language-java">sink.emitNext(body, Sinks.EmitFailureHandler.busyLooping(Duration.ofSeconds(1)));
</code></pre>
<p><code>busyLooping</code> 的语义是：遇到并发竞争失败（<code>FAIL_NON_SERIALIZED</code>）时，自旋重试最多 1 秒，直到成功塞进去。这样多线程回调下也不会丢消息。</p>
<blockquote>
 <p>这一条是相比 BufferTrigger 唯一需要额外操心的地方——BufferTrigger 的 <code>enqueue</code> 内部自己处理了并发。</p>
</blockquote>
<h2 id="聚合触发后做什么">聚合触发后做什么</h2>
<p>拿到一批 <code>List&lt;String&gt;</code> 后的处理流程：</p>
<pre><code>1. 逐条 JsonUtils.parseObject → List&lt;CountFollowUnfollowMqDTO&gt;（过滤 null）
2. FansCountAggregator.aggregate(dtoList)
      → 按 targetUserId 分组，FOLLOW +1 / UNFOLLOW -1 净算
      → Map&lt;Long, Integer&gt;，如 {68001: 1, 27: 3200}
3. 遍历 Map：仅当 redisTemplate.hasKey(key) 时才 HINCRBY fansTotal（不初始化缓存）
4. 把整个 countMap 的 JSON 再发一条 MQ 到 CountFans2DBTopic
      → 由落库消费者用 Guava 令牌桶（5000/s）削峰后 upsert 落库
</code></pre>
<p>聚合消费者主体代码：</p>
<pre><code class="language-java">private void doConsumeBatch(List&lt;String&gt; bodyList) {
    log.info("## 聚合粉丝数消息, size: {}", bodyList.size());

    // List&lt;String&gt; → List&lt;CountFollowUnfollowMqDTO&gt;
    List&lt;CountFollowUnfollowMqDTO&gt; dtoList = bodyList.stream()
            .map(body -&gt; JsonUtils.parseObject(body, CountFollowUnfollowMqDTO.class))
            .filter(Objects::nonNull)
            .toList();

    // 按目标用户分组净算增量
    Map&lt;Long, Integer&gt; countMap = FansCountAggregator.aggregate(dtoList);
    if (countMap.isEmpty()) {
        return;
    }

    // 更新 Redis（仅当 Hash key 已存在，不初始化缓存）
    countMap.forEach((targetUserId, delta) -&gt; {
        String redisKey = RedisKeyConstants.buildCountUserKey(targetUserId);
        if (Boolean.TRUE.equals(redisTemplate.hasKey(redisKey))) {
            redisTemplate.opsForHash().increment(redisKey, RedisKeyConstants.FIELD_FANS_TOTAL, delta);
        }
    });

    // 转发落库 MQ，payload 为聚合后的 countMap JSON
    Message&lt;String&gt; message = MessageBuilder.withPayload(JsonUtils.toJsonString(countMap)).build();
    rocketMQTemplate.asyncSend(MQConstants.TOPIC_COUNT_FANS_2_DB, message, /* SendCallback */);
}
</code></pre>
<p><strong>一个可测性改进</strong>：第 2 步「分组净算」的逻辑我特意抽成了独立的纯函数 <code>FansCountAggregator.aggregate()</code>，它不依赖 Reactor / MQ / Spring，输入 <code>List&lt;DTO&gt;</code>、输出 <code>Map&lt;Long,Integer&gt;</code>，可以直接单元测试。原教程是把这段逻辑塞在消费者内部的，不好测。</p>
<pre><code class="language-java">public static Map&lt;Long, Integer&gt; aggregate(List&lt;CountFollowUnfollowMqDTO&gt; dtoList) {
    Map&lt;Long, Integer&gt; countMap = new HashMap&lt;&gt;();
    if (dtoList == null || dtoList.isEmpty()) {
        return countMap;
    }
    for (CountFollowUnfollowMqDTO dto : dtoList) {
        FollowUnfollowTypeEnum typeEnum = FollowUnfollowTypeEnum.valueOf(dto.getType());
        if (Objects.isNull(typeEnum)) {
            continue;   // 非法 type，跳过
        }
        int delta = switch (typeEnum) {
            case FOLLOW -&gt; 1;
            case UNFOLLOW -&gt; -1;
        };
        countMap.merge(dto.getTargetUserId(), delta, Integer::sum);
    }
    return countMap;
}
</code></pre>
<h2 id="两个可靠性设计">两个可靠性设计</h2>
<h3 id="--consumeBatch-整体-try-catch----别让管道断流">① consumeBatch 整体 try/catch —— 别让管道断流</h3>
<pre><code class="language-java">private void consumeBatch(List&lt;String&gt; bodyList) {
    try {
        doConsumeBatch(bodyList);
    } catch (Exception e) {
        log.error("## 聚合粉丝数批处理失败（已吞掉，避免终止订阅）, size: {}", bodyList.size(), e);
    }
}
</code></pre>
<p><code>consumeBatch</code> 是 <code>subscribe</code> 的 onNext 回调。<strong>如果异常外溢，Reactor 会触发 <code>onError</code> 让整个订阅永久终止</strong>——之后所有粉丝数消息都没人处理，只能重启进程恢复。所以这里宁可丢掉出错的这一批（记 error 日志，由计数对账兜底），也绝不能让管道断流。</p>
<blockquote>
 <p>实测踩过一次：预热 Redis 时手误把值写成了 <code>"0;"</code>（带分号），<code>HINCRBY</code> 要求字段是纯整数，于是抛 <code>ERR hash value is not an integer</code>。正是这层 try/catch 兜住了，订阅没死，其他用户的计数照常处理。</p>
</blockquote>
<h3 id="---PreDestroy-优雅关闭----别丢缓冲区">② @PreDestroy 优雅关闭 —— 别丢缓冲区</h3>
<pre><code class="language-java">@PreDestroy
public void shutdown() {
    // 补发完成信号，触发 bufferTimeout 同步 flush 最后一批
    sink.emitComplete(Sinks.EmitFailureHandler.busyLooping(Duration.ofSeconds(1)));
    if (subscription != null) {
        subscription.dispose();
    }
}
</code></pre>
<p>停机时补发完成信号，让 <code>bufferTimeout</code> 把缓冲区里还没满 1000 条 / 未到 1s 的最后一批同步 flush 出去（unicast sink 的完成信号在调用线程上同步传播），再释放订阅。避免正常重启 / 发版时丢掉缓冲中的计数。</p>
<h2 id="方案对比">方案对比</h2>
<table>
 <thead>
  <tr>
   <th>维度</th>
   <th>BufferTrigger</th>
   <th>Reactor bufferTimeout</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>依赖</td>
   <td>新引入，2021 年停更</td>
   <td>Spring Boot 自带，零新增</td>
  </tr>
  <tr>
   <td>双触发（数量/时间）</td>
   <td><code>batchSize</code> + <code>linger</code></td>
   <td><code>bufferTimeout(n, duration)</code></td>
  </tr>
  <tr>
   <td>并发 enqueue</td>
   <td>内部处理好了</td>
   <td>需 <code>emitNext + busyLooping</code> 手动保证</td>
  </tr>
  <tr>
   <td>背压</td>
   <td><code>bufferSize</code> 有上限</td>
   <td><code>onBackpressureBuffer</code>（默认无界，需注意）</td>
  </tr>
  <tr>
   <td>逻辑可测性</td>
   <td>逻辑在消费者内，不好测</td>
   <td>净算抽成纯函数，可独立单测</td>
  </tr>
 </tbody>
</table>
<h2 id="一个共同的固有局限">一个共同的固有局限</h2>
<p>两种方案都是「<strong>先 ack MQ，再异步聚合</strong>」——<code>onMessage</code> 把消息塞进缓冲后就返回，RocketMQ 随即提交 offset。如果进程在缓冲的这批消息落到 Redis/DB 之前<strong>硬崩</strong>，这批消息会永久丢失（无重投），也就是「至多一次」语义。</p>
<p><code>@PreDestroy</code> 只能覆盖<strong>优雅停机</strong>（重启、发版），覆盖不了 kill -9 / OOM / 断电这类硬崩。要彻底兜底，需要一个<strong>计数对账任务</strong>——定期用关系表（<code>t_following</code> / <code>t_fans</code>）重算真实计数，校正计数表。这一点无论用 BufferTrigger 还是 Reactor 都一样，是聚合写这个模式本身的取舍。</p>
<h2 id="一个待优化点">一个待优化点</h2>
<p><code>onBackpressureBuffer()</code> 默认是<strong>无界队列</strong>。极端情况下（下游 Redis/DB 卡死、消息暴涌），可能堆积占内存。BufferTrigger 的 <code>bufferSize(50000)</code> 是有上限的。如果要更贴近有界行为，可以给 sink 配一个容量上限。生产环境值得关注这一点。</p>
<h2 id="小结">小结</h2>
<ul>
 <li>用 <code>Sinks.many().unicast().onBackpressureBuffer()</code> + <code>bufferTimeout(1000, 1s)</code> 就能等价替代 BufferTrigger 的「数量/时间双触发聚合」，零新增依赖。</li>
 <li><strong>务必用 <code>emitNext + busyLooping</code></strong>，不能用 <code>tryEmitNext</code>——RocketMQ 多线程回调下 Sinks emit 非并发安全。</li>
 <li><strong>consumeBatch 要整体 try/catch</strong>，否则一次异常会终止整个订阅、管道断流。</li>
 <li>用 <code>@PreDestroy + emitComplete</code> 优雅关闭，flush 掉缓冲区最后一批。</li>
 <li>聚合的净算逻辑抽成纯函数，方便单测。</li>
 <li>记住聚合写是「至多一次」，硬崩会丢缓冲——生产上要有对账兜底。</li>
</ul>]]></description><guid isPermaLink="false">/archives/yong-project-reactor-de-buffertimeout-dai-ti-buffertrigger-zuo-gao-bing-fa-ji-shu-ju-he</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-019f5197-aa08-74e4-9cd1-59d8a17144ac-8c99edb7.png&amp;size=m" type="image/jpeg" length="1857187"/><category>并发与响应式编程</category><pubDate>Sat, 11 Jul 2026 14:33:07 GMT</pubDate></item><item><title><![CDATA[Spring Boot 4.x 整合 Logback 日志框架（支持异步写入）]]></title><link>https://likeyy.love/archives/spring-boot-4.x-zheng-he-logback-ri-zhi-kuang-jia-zhi-chi-yi-bu-xie-ru</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=Spring%20Boot%204.x%20%E6%95%B4%E5%90%88%20Logback%20%E6%97%A5%E5%BF%97%E6%A1%86%E6%9E%B6%EF%BC%88%E6%94%AF%E6%8C%81%E5%BC%82%E6%AD%A5%E5%86%99%E5%85%A5%EF%BC%89&amp;url=/archives/spring-boot-4.x-zheng-he-logback-ri-zhi-kuang-jia-zhi-chi-yi-bu-xie-ru" width="1" height="1" alt="" style="opacity:0;">
<h1 id="Spring-Boot-4-x-整合-Logback-日志框架-支持异步写入-">Spring Boot 4.x 整合 Logback 日志框架（支持异步写入）</h1>
<p>在构建任何应用程序时，良好的日志管理都是必不可少的。<strong>日志可以帮助我们监控、调试和跟踪代码的运行情况。</strong> 本小节中，我们继续完善 <code>hannote-auth</code> 认证服务的项目骨架，为其整合 <code>Logback</code> 日志框架，并将配置日志的异步写入文件，以提升应用性能。</p>
<h2 id="1--添加日志配置文件">1. 添加日志配置文件</h2>
<p>编辑 <code>hannote-auth</code> 认证服务，在 <code>/resources</code> 资源目录下，创建名为 <code>logback-spring.xml</code> 日志配置文件：</p>
<p>文件内容如下：</p>
<pre><code class="language-xml">&lt;configuration&gt;
    &lt;!-- 引用 Spring Boot 的 logback 基础配置 --&gt;
    &lt;include resource="org/springframework/boot/logging/logback/defaults.xml" /&gt;

    &lt;!-- 应用名称 --&gt;
    &lt;property scope="context" name="appName" value="auth"/&gt;
    &lt;!-- 自定义日志输出路径，以及日志名称前缀 --&gt;
    &lt;property name="LOG_FILE" value="./logs/${appName}.%d{yyyy-MM-dd}"/&gt;
    &lt;!-- 每行日志输出的格式 --&gt;
    &lt;property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"/&gt;

    &lt;!-- 文件输出 --&gt;
    &lt;appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"&gt;
        &lt;rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"&gt;
            &lt;!-- 日志文件的命名格式 --&gt;
            &lt;fileNamePattern&gt;${LOG_FILE}-%i.log&lt;/fileNamePattern&gt;
            &lt;!-- 保留 30 天的日志文件 --&gt;
            &lt;maxHistory&gt;30&lt;/maxHistory&gt;
            &lt;!-- 单个日志文件最大大小 --&gt;
            &lt;maxFileSize&gt;10MB&lt;/maxFileSize&gt;
            &lt;!-- 日志文件的总大小，0 表示不限制 --&gt;
            &lt;totalSizeCap&gt;0&lt;/totalSizeCap&gt;
            &lt;!-- 重启服务时，是否清除历史日志，不推荐清理 --&gt;
            &lt;cleanHistoryOnStart&gt;false&lt;/cleanHistoryOnStart&gt;
        &lt;/rollingPolicy&gt;
        &lt;encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"&gt;
            &lt;pattern&gt;${LOG_PATTERN}&lt;/pattern&gt;
            &lt;charset&gt;UTF-8&lt;/charset&gt;
        &lt;/encoder&gt;
    &lt;/appender&gt;

    &lt;!-- 本地 dev 开发环境 --&gt;
    &lt;springProfile name="dev"&gt;
        &lt;include resource="org/springframework/boot/logging/logback/console-appender.xml" /&gt;
        &lt;root level="INFO"&gt;
            &lt;appender-ref ref="CONSOLE"/&gt; &lt;!-- 输出控制台日志 --&gt;
            &lt;appender-ref ref="FILE"/&gt; &lt;!-- 打印日志到文件中。PS: 本地环境下，如果不想打印日志到文件，可注释掉此行 --&gt;
        &lt;/root&gt;
    &lt;/springProfile&gt;

    &lt;!-- 其它环境 --&gt;
    &lt;springProfile name="prod"&gt;
        &lt;include resource="org/springframework/boot/logging/logback/console-appender.xml" /&gt;
        &lt;root level="INFO"&gt;
            &lt;appender-ref ref="FILE"/&gt; &lt;!-- 生产环境下，仅打印日志到文件中 --&gt;
        &lt;/root&gt;
    &lt;/springProfile&gt;

&lt;/configuration&gt;

</code></pre>
<blockquote>
 <p>说一下日志配置文件中，每项配置都是干啥的：</p>
 <blockquote>
  <ul>
   <li><strong>基础配置和属性定义</strong>：</li>
  </ul>
  <pre><code class="language-xml">&lt;include resource="org/springframework/boot/logging/logback/defaults.xml" /&gt;
</code></pre>
  <p>上述配置用于引用 <code>Spring Boot</code> 的默认 <code>Logback</code> 基础配置。</p>
  <pre><code class="language-xml">&lt;property scope="context" name="appName" value="auth"/&gt;
&lt;property name="LOG_FILE" value="./logs/${appName}.%d{yyyy-MM-dd}"/&gt;
&lt;property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"/&gt;
</code></pre>
  <ul>
   <li>定义了一些全局属性：
    <ul>
     <li><code>appName</code>：应用名称，这里值填写为 <code>auth</code> ，表示认证服务。</li>
     <li><code>LOG_FILE</code>：日志文件的路径和文件名模板， <code>./logs</code> 表示输出到项目的同级目录下的 <code>/logs</code> 文件夹下。</li>
     <li><code>LOG_PATTERN</code>：日志输出格式。</li>
    </ul></li>
   <li><strong>日志文件 Appender 配置</strong>：</li>
  </ul>
  <pre><code class="language-xml">&lt;appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"&gt;
    &lt;rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"&gt;
        &lt;fileNamePattern&gt;${LOG_FILE}-%i.log&lt;/fileNamePattern&gt;
        &lt;maxHistory&gt;30&lt;/maxHistory&gt;
        &lt;maxFileSize&gt;10MB&lt;/maxFileSize&gt;
        &lt;totalSizeCap&gt;0&lt;/totalSizeCap&gt;
        &lt;cleanHistoryOnStart&gt;false&lt;/cleanHistoryOnStart&gt;
    &lt;/rollingPolicy&gt;
    &lt;encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"&gt;
        &lt;pattern&gt;${LOG_PATTERN}&lt;/pattern&gt;
        &lt;charset&gt;UTF-8&lt;/charset&gt;
    &lt;/encoder&gt;
&lt;/appender&gt;
</code></pre>
  <ul>
   <li><code>appender</code>：用于将日志输出到文件，并且使用滚动策略来管理日志文件。</li>
   <li><code>rollingPolicy</code>：定义了日志滚动策略，使用 <code>SizeAndTimeBasedRollingPolicy</code> 以时间和大小为基准进行滚动。</li>
   <li><code>fileNamePattern</code>：定义了日志文件的命名模式。</li>
   <li><code>maxHistory</code>：保留 30 天的日志文件。</li>
   <li><code>maxFileSize</code>：每个日志文件最大 10MB。</li>
   <li><code>totalSizeCap</code>：总日志文件大小没有限制。</li>
   <li><code>cleanHistoryOnStart</code>：项目启动时不清理历史日志文件。</li>
   <li><code>encoder</code>：定义了日志的输出格式，以及文件编码格式。</li>
   <li><strong>Spring Profile 配置</strong>：用于配置各环境的日志行为。这里主要定义了 <code>dev</code> 和 <code>prod</code> 两个环境：</li>
  </ul>
  <pre><code class="language-xml">&lt;springProfile name="dev"&gt;
   &lt;include resource="org/springframework/boot/logging/logback/console-appender.xml" /&gt;
    &lt;root level="INFO"&gt;
       &lt;appender-ref ref="CONSOLE"/&gt;
        &lt;appender-ref ref="FILE"/&gt; 
    &lt;/root&gt;
&lt;/springProfile&gt;
</code></pre>
  <p><code>dev</code> 本地开发环境中，包含控制台输出 <code>CONSOLE</code> 和文件输出 <code>FILE</code>。<code>CONSOLE</code> 配置通过包含 Spring Boot 默认的 <code>console-appender.xml</code> 实现。</p>
  <pre><code class="language-xml">&lt;springProfile name="prod"&gt;
   &lt;include resource="org/springframework/boot/logging/logback/console-appender.xml" /&gt;
    &lt;root level="INFO"&gt;
       &lt;appender-ref ref="FILE"/&gt; 
    &lt;/root&gt;
&lt;/springProfile&gt;
</code></pre>
  <p><code>prod</code> 生产环境中，仅包含文件输出 <code>FILE</code>，不输出到控制台。这是为了生产环境中减少控制台日志输出，避免影响性能。</p>
  <blockquote>
   <p><strong>拓展小知识</strong> : 如果你想同时设置多个环境，假设咱们除了本地开发环境、生产环境外，还有个 <code>test</code> 测试环境， 也仅需要输出日志到文件。则可以配置如下，通过逗号 <code>,</code> 分隔开来就行：</p>
   <pre><code class="language-xml">&lt;springProfile name="test,prod"&gt;
    // 省略...
&lt;/springProfile&gt;
</code></pre>
  </blockquote>
 </blockquote>
</blockquote>
<h2 id="2--测试看看效果">2. 测试看看效果</h2>
<p>因为我们上面通过 <code>springProfile</code> , 配置了 <code>dev</code> 开发环境中，打印日志到文件中。接下来，重启项目，实测一下看看功能是否正常：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260204211921236.png&amp;size=m" alt="image-20260204211919654"></p>
<p>项目启动成功后，如上图所示，进入到项目的 <code>/logs</code> 文件夹下，可以看到日志输出是 ok 的。</p>
<h2 id="3--异步日志">3. 异步日志</h2>
<p>异步打印日志（Asynchronous Logging）是一种日志记录方式，它将日志写入操作放在一个单独的线程中执行，而不是在主线程中进行。这意味着日志写入的过程不会阻塞主线程的执行，主线程可以继续执行其余的业务逻辑，增强了应用的性能和响应速度。</p>
<h3 id="3-1-为什么需要异步打印日志">3.1 为什么需要异步打印日志</h3>
<ul>
 <li><strong>性能提升</strong>：同步日志记录在高并发情况下会显著影响应用性能，因为每一次日志写入操作都可能导致磁盘 I/O 操作，主线程必须等待这些操作完成才能继续执行。异步日志记录将这些操作放在单独的线程中进行，避免了主线程的阻塞，提高了整体性能。</li>
 <li><strong>响应时间</strong>：异步日志记录可以减少应用的响应时间，尤其是在需要记录大量日志信息的时候。用户请求得到快速响应，而日志记录在后台处理。</li>
 <li><strong>资源利用</strong>：通过异步日志记录，应用可以更有效地利用 CPU 资源。同步日志记录可能导致线程频繁等待 I/O 操作完成，而异步记录可以让这些线程去执行其他任务，提高资源利用率。</li>
 <li><strong>系统稳定性</strong>：在极端情况下（例如，日志量非常大时），同步日志记录可能会导致应用出现性能瓶颈甚至崩溃。异步日志记录通过缓冲和队列机制，能够更好地应对突发的大量日志请求，增强系统稳定性。</li>
</ul>
<h3 id="3-2-Logback-配置异步日志">3.2 Logback 配置异步日志</h3>
<p>Logback 提供了 <code>AsyncAppender</code> 来支持异步日志记录。通过 <code>AsyncAppender</code> 可以将日志事件发送到一个队列中，并由一个独立的线程池来处理这些日志事件。编辑 <code>logback-spring.xml</code> 文件，添加配置如下：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260204212047133.png&amp;size=m" alt="image-20260204212045831"></p>
<pre><code class="language-xml">&lt;!-- 异步写入日志，提升性能 --&gt;
&lt;appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender"&gt;
    &lt;!-- 是否丢弃日志, 0 表示不丢弃。默认情况下，如果队列满 80%, 会丢弃 TRACE、DEBUG、INFO 级别的日志 --&gt;
    &lt;discardingThreshold&gt;0&lt;/discardingThreshold&gt;
    &lt;!-- 队列大小。默认值为 256 --&gt;
    &lt;queueSize&gt;256&lt;/queueSize&gt;
    &lt;appender-ref ref="FILE"/&gt;
&lt;/appender&gt;
</code></pre>
<blockquote>
 <p>解释一下修改的地方，主要添加了一个名称为 <code>ASYNC_FILE</code> 异步输出日志的 <code>Appender</code>：</p>
 <p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260204212246935.png&amp;size=m" alt="image-20260204212245135"></p>
 <ul>
  <li><code>AsyncAppender</code> 使用内部队列来异步处理日志事件。</li>
  <li><code>queueSize</code>：队列的大小。</li>
  <li><code>discardingThreshold</code>：是否丢弃日志, 0 表示不丢弃。</li>
 </ul>
 <p>最后，将各个环境中的 <code>FILE</code> 更改为 <code>ASYNC_FILE</code> 异步写入日志。别忘了，再次重启一下项目，自测一波日志功能是否好使~</p>
</blockquote>]]></description><guid isPermaLink="false">/archives/spring-boot-4.x-zheng-he-logback-ri-zhi-kuang-jia-zhi-chi-yi-bu-xie-ru</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-019c28d4-574e-718e-9729-0b8ccfb7d3b5-e2f0a517.png&amp;size=m" type="image/jpeg" length="1459308"/><category>可观测性与日志</category><pubDate>Wed, 4 Feb 2026 13:25:30 GMT</pubDate></item><item><title><![CDATA[自定义 Jackson 配置：支持 LocalDateTime 日期 API]]></title><link>https://likeyy.love/archives/zi-ding-yi-jackson-pei-zhi-zhi-chi-localdatetime-ri-qi-api</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=%E8%87%AA%E5%AE%9A%E4%B9%89%20Jackson%20%E9%85%8D%E7%BD%AE%EF%BC%9A%E6%94%AF%E6%8C%81%20LocalDateTime%20%E6%97%A5%E6%9C%9F%20API&amp;url=/archives/zi-ding-yi-jackson-pei-zhi-zhi-chi-localdatetime-ri-qi-api" width="1" height="1" alt="" style="opacity:0;">
<h1 id="自定义-Jackson-配置-支持-LocalDateTime-日期-API">自定义 Jackson 配置：支持 LocalDateTime 日期 API</h1>
<p>本小节中，我们将为认证服务自定义 Jackson 配置，以支持 Java 8 中新的日期 API 。</p>
<h2 id="1--不支持-LocalDateTime-问题演示">1. 不支持 LocalDateTime 问题演示</h2>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260204203108829.png&amp;size=m" alt="image-20260204203107639"></p>
<p>注意这里返回的时间类型并不是我们在前面设置的时间日期格式，其次如果我们按照我们先前设置的格式作为入参，同样也无法反序列化，这个我们可以做一下实验！</p>
<p>假设我们想让 <code>/test3</code> 接口支持传入参数 <code>User</code> 实体类，代码如下：</p>
<pre><code class="language-java">@PostMapping("/test3")
@ApiOperationLog(description = "测试接口3")
public Response&lt;User&gt; test2(@RequestBody User user) {
    return Response.success(user);
}
</code></pre>
<p>然后发送请求：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260204203426125.png&amp;size=m" alt="image-20260204203424801"></p>
<p>再来看看后端控制台信息，有如下一行警告信息：</p>
<pre><code class="language-java">JSON parse error: Cannot deserialize value of type `java.time.LocalDateTime` from String "2026-02-04 12:00:00": Failed to deserialize `java.time.LocalDateTime` (with format 'ParseCaseSensitive(false)(Value(Year,4,10,EXCEEDS_PAD)'-'Value(MonthOfYear,2)'-'Value(DayOfMonth,2))'T'(Value(HourOfDay,2)':'Value(MinuteOfHour,2)[':'Value(SecondOfMinute,2)[Fraction(NanoOfSecond,0,9,DecimalPoint)]])'): (java.time.format.DateTimeParseException) Text '2026-02-04 12:00:00' could not be parsed at index 10]
</code></pre>
<blockquote>
 <p>提示我们 JSON 解析错误，无法将 <code>2026-02-04 12:00:00</code> 字符串解析为 <code>java.time.LocalDateTime</code> 日期类。</p>
</blockquote>
<h2 id="2--自定义-Jackson-配置">2. 自定义 Jackson 配置</h2>
<p>这个我们直接把这个 <code>Jackson</code>的配置类封装为一个starter，这样方便我们在不同的服务中引入！首先我某地 <code>hanserwei-framework</code>下新建 <code>hanserwei-spring-boot-starter-jackson</code>模块：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260204204735214.png&amp;size=m" alt="image-20260204204733618"></p>
<p>pom文件如下：</p>
<pre><code class="language-xml">&lt;project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"&gt;
    &lt;modelVersion&gt;4.0.0&lt;/modelVersion&gt;
    &lt;!-- 父项目配置 --&gt;
    &lt;parent&gt;
        &lt;!-- 组织ID --&gt;
        &lt;groupId&gt;com.hanserwei&lt;/groupId&gt;
        &lt;!-- 父项目artifactId --&gt;
        &lt;artifactId&gt;hanserwei-framework&lt;/artifactId&gt;
        &lt;!-- 版本号，使用变量引用 --&gt;
        &lt;version&gt;${revision}&lt;/version&gt;
    &lt;/parent&gt;

    &lt;!-- 当前模块的artifactId --&gt;
    &lt;artifactId&gt;hanserwei-spring-boot-starter-jackson&lt;/artifactId&gt;
    &lt;!-- 打包方式为jar包 --&gt;
    &lt;packaging&gt;jar&lt;/packaging&gt;

    &lt;!-- 项目名称，引用artifactId --&gt;
    &lt;name&gt;${project.artifactId}&lt;/name&gt;
    &lt;!-- 项目描述 --&gt;
    &lt;description&gt;自定义JacksonStarter&lt;/description&gt;

    &lt;!-- 项目属性配置 --&gt;
    &lt;properties&gt;
        &lt;!-- 源代码编码格式 --&gt;
        &lt;project.build.sourceEncoding&gt;UTF-8&lt;/project.build.sourceEncoding&gt;
    &lt;/properties&gt;

    &lt;!-- 依赖管理 --&gt;
    &lt;dependencies&gt;
        &lt;!-- hanserwei通用模块依赖 --&gt;
        &lt;dependency&gt;
            &lt;groupId&gt;com.hanserwei&lt;/groupId&gt;
            &lt;artifactId&gt;hanserwei-common&lt;/artifactId&gt;
        &lt;/dependency&gt;
        &lt;!-- Spring Boot自动配置依赖 --&gt;
        &lt;dependency&gt;
            &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
            &lt;artifactId&gt;spring-boot-autoconfigure&lt;/artifactId&gt;
        &lt;/dependency&gt;
    &lt;/dependencies&gt;
&lt;/project&gt;

</code></pre>
<p>我们先在 <code>hanserwei-common</code>的 <code>constant</code>包下定义一下我们常用的几个日期格式：</p>
<pre><code class="language-java">package com.hanserwei.framework.common.constant;

import java.time.format.DateTimeFormatter;

/**
 * @author hanser
 */
public interface DateConstants {

    /**
     * DateTimeFormatter：年-月-日 时：分：秒
     */
    DateTimeFormatter DATE_FORMAT_Y_M_D_H_M_S = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    /**
     * DateTimeFormatter：年-月-日
     */
    DateTimeFormatter DATE_FORMAT_Y_M_D = DateTimeFormatter.ofPattern("yyyy-MM-dd");

    /**
     * DateTimeFormatter：时：分：秒
     */
    DateTimeFormatter DATE_FORMAT_H_M_S = DateTimeFormatter.ofPattern("HH:mm:ss");

    /**
     * DateTimeFormatter：年-月
     */
    DateTimeFormatter DATE_FORMAT_Y_M = DateTimeFormatter.ofPattern("yyyy-MM");
}

</code></pre>
<p>然后回到我们的 <code>CustomJacksonConfiguration </code>类中，<code>Jackson3</code>的配置方式和 <code>Jackson2</code>有些许不同，直接看我的代码，我代码中会有详细的注释：</p>
<pre><code class="language-java">package com.hanserwei.jackson.config;

import com.hanserwei.framework.common.constant.DateConstants;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.context.annotation.Bean;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.ext.javatime.deser.LocalDateDeserializer;
import tools.jackson.databind.ext.javatime.deser.LocalDateTimeDeserializer;
import tools.jackson.databind.ext.javatime.deser.LocalTimeDeserializer;
import tools.jackson.databind.ext.javatime.deser.YearMonthDeserializer;
import tools.jackson.databind.ext.javatime.ser.LocalDateSerializer;
import tools.jackson.databind.ext.javatime.ser.LocalDateTimeSerializer;
import tools.jackson.databind.ext.javatime.ser.LocalTimeSerializer;
import tools.jackson.databind.ext.javatime.ser.YearMonthSerializer;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.module.SimpleModule;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.YearMonth;
import java.util.TimeZone;

/**
 * @author hanser
 */
@AutoConfiguration
public class CustomJacksonConfiguration {

    @Bean
    public JsonMapper jsonMapper() {
        SimpleModule customModule = new SimpleModule();
        // 支持 LocalDateTime、LocalDate、LocalTime
        customModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateConstants.DATE_FORMAT_Y_M_D_H_M_S));
        customModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateConstants.DATE_FORMAT_Y_M_D_H_M_S));
        customModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateConstants.DATE_FORMAT_Y_M_D));
        customModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateConstants.DATE_FORMAT_Y_M_D));
        customModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateConstants.DATE_FORMAT_H_M_S));
        customModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateConstants.DATE_FORMAT_H_M_S));
        // 支持 YearMonth
        customModule.addSerializer(YearMonth.class, new YearMonthSerializer(DateConstants.DATE_FORMAT_Y_M));
        customModule.addDeserializer(YearMonth.class, new YearMonthDeserializer(DateConstants.DATE_FORMAT_Y_M));


        return JsonMapper.builder()
                // 在反序列化时，忽略在 JSON 中存在但 Java 对象中不存在的属性，防止报错
                .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
                // 在序列化时，允许序列化空的 POJO 类（没有属性的类），防止报错
                .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
                // 设置默认时区为东八区（北京时间）
                .defaultTimeZone(TimeZone.getTimeZone("GMT+8"))
                // 添加自定义模块
                .addModule(customModule)
                // 自动查找并添加所有可用的 Jackson 模块
                .findAndAddModules()
                .build();
    }
}

</code></pre>
<p>但是，现在有个问题，在之前 <code>hanserwei-common</code> 公共模块中，我们封装了一个 <code>JsonUtils</code> 工具类，里面的序列化、反序列是有重复定义的，<em>怎么能统一复用上面的呢？</em> 这样就不用适配多处了，代码看起来也优雅很多。</p>
<p>为了解决上面提到的问题，我们可以在 <code>JsonUtils</code> 工具类中，定义一个 <code>init()</code> 初始化方法，代码如下：</p>
<pre><code class="language-java">package com.hanserwei.framework.common.util;

import tools.jackson.databind.json.JsonMapper;

/**
 * JSON 工具类，提供对象与 JSON 字符串之间的转换功能
 *
 * @author hanser
 */
public class JsonUtils {

    /**
     * 初始化 Jackson 的 JsonMapper 实例
     */
    private static JsonMapper JSON_MAPPER = new JsonMapper();
  
    /**
     * 初始化：统一使用 Spring Boot 个性化配置的 ObjectMapper
     *
     * @param jsonMapper 自定义的 JsonMapper 实例
     */
    public static void init(JsonMapper jsonMapper) {
        JSON_MAPPER = jsonMapper;
    }

    /**
     * 将对象序列化为 JSON 字符串
     *
     * @param object 需要转换的对象
     * @return 转换后的 JSON 字符串
     */
    public static String toJsonString(Object object) {
        return JSON_MAPPER.writeValueAsString(object);
    }
}

</code></pre>
<p>然后在 <code>CustomJacksonConfiguration</code>注入bean的时候调用一下 <code>init</code>方法：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260204210842979.png&amp;size=m" alt="image-20260204210841118"></p>
<p>最后一步，在 <code>org.springframework.boot.autoconfigure.AutoConfiguration.imports</code>里面导入自动配置类的全类名：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260204205936340.png&amp;size=m" alt="image-20260204205934785"></p>
<p>为了方便在其他模块导入这个starter，我们在最外层的pom文件中定义这个starter的版本！</p>
<pre><code class="language-xml">&lt;dependency&gt;
    &lt;groupId&gt;com.hanserwei&lt;/groupId&gt;
    &lt;artifactId&gt;hanserwei-spring-boot-starter-jackson&lt;/artifactId&gt;
    &lt;version&gt;${revision}&lt;/version&gt;
&lt;/dependency&gt;
</code></pre>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260204210142587.png&amp;size=m" alt="image-20260204210141271"></p>
<p>然后在 <code>hannote-auth</code>服务中导入我们定义的starter：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260204210335811.png&amp;size=m" alt="image-20260204210332965"></p>
<p>这个时候我们重启一下项目，试验一下！</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260204211002125.png&amp;size=m" alt="image-20260204210928757"></p>
<p>入参也没问题：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260204211039485.png&amp;size=m" alt="image-20260204211037886"></p>
<p>AOP切面日志也没问题：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260204211119744.png&amp;size=m" alt="image-20260204211118357"></p>]]></description><guid isPermaLink="false">/archives/zi-ding-yi-jackson-pei-zhi-zhi-chi-localdatetime-ri-qi-api</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-019c28d4-568b-70d4-8e15-9154da3abde0-74ba992c.png&amp;size=m" type="image/jpeg" length="1353959"/><category>Spring 生态</category><pubDate>Wed, 4 Feb 2026 13:25:30 GMT</pubDate></item><item><title><![CDATA[添加 framework 平台基础设施模块]]></title><link>https://likeyy.love/archives/tian-jia-framework-ping-tai-ji-chu-she-shi-mo-kuai</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=%E6%B7%BB%E5%8A%A0%20framework%20%E5%B9%B3%E5%8F%B0%E5%9F%BA%E7%A1%80%E8%AE%BE%E6%96%BD%E6%A8%A1%E5%9D%97&amp;url=/archives/tian-jia-framework-ping-tai-ji-chu-she-shi-mo-kuai" width="1" height="1" alt="" style="opacity:0;">
<h1 id="添加-framework-平台基础设施模块">添加 framework 平台基础设施模块</h1>
<p>本小节中，我们继续完善后端项目骨架 —— <em>抽取一个 <code>framework</code> 平台基础设施模块</em>。如下图所示：</p>
<h2 id="为什么要抽取一个-framework-基础设施模块-">为什么要抽取一个 framework 基础设施模块？</h2>
<p>在微服务架构中，如果每个服务都像一个独立的“孤岛”，虽然实现了业务解耦，但会导致<strong>大量重复的底层代码</strong>和<strong>架构标准的不统一</strong>。</p>
<p>抽取一个 <strong>Framework（基础设施/底座）模块</strong>，本质上是为了解决“微服务治理的复杂度”问题。</p>
<hr>
<h3 id="1--减少重复造轮子--DRY原则-">1. 减少重复造轮子 (DRY原则)</h3>
<p>在微服务开发中，很多功能是跨业务通用的。如果没有统一的基础设施模块，每个微服务都要手动配置：</p>
<ul>
 <li><strong>异常处理</strong>：统一的全局异常拦截与错误码定义。</li>
 <li><strong>日志记录</strong>：统一的日志格式（如：链路追踪 TraceID、请求 ID）。</li>
 <li><strong>安全校验</strong>：JWT 解析、权限拦截、敏感词过滤。</li>
 <li><strong>工具类</strong>：对象拷贝（BeanUtils）、JSON 处理、日期工具等。</li>
</ul>
<blockquote>
 <p><strong>核心价值</strong>：让开发者专注于业务逻辑（Business Logic），而不是反复配置基础设施。</p>
</blockquote>
<hr>
<h3 id="2--统一技术标准与版本管理">2. 统一技术标准与版本管理</h3>
<p>当项目规模扩大时，最头疼的就是“版本冲突”。</p>
<ul>
 <li>
  <p><strong>依赖控制</strong>：在 Framework 中统一管理 Spring Boot、MyBatis、Swagger 等第三方库的版本，避免 A 服务用 2.x，B 服务用 3.x 导致的兼容性灾难。</p>
 </li>
 <li>
  <p><strong>规范落地</strong>：比如强制要求所有接口返回统一的 JSON 格式：</p>
  <p>JSON</p>
  <pre><code>{ "code": 200, "data": {}, "msg": "success" }
</code></pre>
 </li>
</ul>
<hr>
<h3 id="3--提升运维与监控效率">3. 提升运维与监控效率</h3>
<p>微服务越多，治理难度越大。Framework 模块可以集成以下监控切面：</p>
<ul>
 <li><strong>链路追踪</strong>：自动集成 SkyWalking 或 Zipkin。</li>
 <li><strong>指标采集</strong>：预装 Prometheus 指标，自动暴露健康检查端点。</li>
 <li><strong>限流降级</strong>：预置 Sentinel 或 Resilience4j 的默认配置。</li>
</ul>
<hr>
<h3 id="4--降低维护成本">4. 降低维护成本</h3>
<p>假设公司决定从 Redis 切换到另一个缓存中间件，或者需要对敏感信息进行脱敏处理：</p>
<ul>
 <li><strong>有 Framework</strong>：只需修改一次 Framework 中的 <code>common-redis</code> 启动器，所有微服务升级依赖即可。</li>
 <li><strong>无 Framework</strong>：你需要手动修改几十个微服务的代码，出错概率呈几何倍数增加。</li>
</ul>
<h2 id="新建-framework-模块">新建 framework 模块</h2>
<p>在项目上<em>右键 | New | Module...</em> , 新建一个子模块：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202221151247.png&amp;size=m" alt="image-20260202221149638"></p>
<blockquote>
 <p>解释一下标注的部分：</p>
 <ul>
  <li>①：选择 <code>Maven Archetype</code> 来创建一个 <code>Maven</code> 子模块;</li>
  <li>②：项目名称填写 <code>hanserwei-framework</code>；</li>
  <li>③：项目使用的 <code>JDK</code> 版本，本项目使用的是 <code>JDK 25</code> ；</li>
  <li>④：父模块选择 <code>hannote</code> ；</li>
  <li>⑤：选择 <code>Internal</code> 。</li>
  <li>⑥：选择 <code>maven-archetype-quickstart</code>。</li>
  <li>⑦：填写 Group 组织名称，通常为公司域名倒写，如 <code>com.hanserwei</code>；</li>
  <li>⑧：项目的唯一标识符；</li>
 </ul>
</blockquote>
<p>点击 <em>Create</em> 按钮开始创建 <code>framework</code> 子模块, 成功创建后，查看父模块的 <code>pom.xml</code> 文件，内容如下，可以看到 <code>&lt;modules&gt;</code> 节点中，自动添加上了该模块：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202221401102.png&amp;size=m" alt="image-20260202221359031"></p>
<p>编辑 <code>hanserwei-framework</code> 模块中的 <code>pom.xml</code> , 内容如下：</p>
<pre><code class="language-xml">&lt;project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"&gt;
    &lt;modelVersion&gt;4.0.0&lt;/modelVersion&gt;

    &lt;!-- 父项目配置 --&gt;
    &lt;parent&gt;
        &lt;!-- 组织ID --&gt;
        &lt;groupId&gt;com.hanserwei&lt;/groupId&gt;
        &lt;!-- 父项目工件ID --&gt;
        &lt;artifactId&gt;hannote&lt;/artifactId&gt;
        &lt;!-- 版本号，使用变量引用 --&gt;
        &lt;version&gt;${revision}&lt;/version&gt;
    &lt;/parent&gt;

    &lt;!-- 当前模块工件ID --&gt;
    &lt;artifactId&gt;hanserwei-framework&lt;/artifactId&gt;
    &lt;!-- 打包方式为pom，表示这是一个父模块 --&gt;
    &lt;packaging&gt;pom&lt;/packaging&gt;

    &lt;!-- 项目名称 --&gt;
    &lt;name&gt;${project.artifactId}&lt;/name&gt;
    &lt;!-- 项目描述 --&gt;
    &lt;description&gt;平台基础设施层：封装一些常用功能，供各个业务线拿来即用&lt;/description&gt;

    &lt;!-- 项目属性配置 --&gt;
    &lt;properties&gt;
        &lt;!-- 项目源码编码格式 --&gt;
        &lt;project.build.sourceEncoding&gt;UTF-8&lt;/project.build.sourceEncoding&gt;
    &lt;/properties&gt;

    &lt;!-- 子模块列表 --&gt;
    &lt;modules&gt;

    &lt;/modules&gt;

&lt;/project&gt;

</code></pre>
<blockquote>
 <ul>
  <li>指定了父项目是谁；</li>
  <li>打包方式和父模块一样，都是 <code>pom</code> 形式, 因为后续要在 <code>framework</code> 基础设施层中，添加很多个子模（封装各种业务组件）。</li>
 </ul>
</blockquote>
<p>项目结构如下图所示：</p>
<blockquote>
 <p><strong>注意</strong> : 如果自动生成了 <code>/src</code> 目录，需要删除掉。</p>
</blockquote>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202221728815.png&amp;size=m" alt="image-20260202221656436"></p>
<h2 id="添加-common-通用子模块">添加 common 通用子模块</h2>
<p>在 <code>hanserwei-framewrok</code> 模块上，继续<em>右键 | New | Module...</em> , 为基础设施层添加第一个子模块 —— <code>hanserwei-common</code> , 此模块为平台<strong>通用模块</strong>，主要放置一些通用枚举、工具类等等：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202221920549.png&amp;size=m" alt="image-20260202221918744"></p>
<p>填写相关选项，如下图所示：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202221936439.png&amp;size=m" alt="image-20260202221934878"></p>
<blockquote>
 <p>解释一下标注的部分：</p>
 <ul>
  <li>①：选择 <code>Maven Archetype</code> 来创建一个 <code>Maven</code> 子模块;</li>
  <li>②：项目名称填写 <code>hanserwei-common</code>；</li>
  <li>③：项目使用的 <code>JDK</code> 版本，本项目使用的是 <code>JDK 25</code> ；</li>
  <li>④：父模块选择 <code>hanserwei-framework</code> ；</li>
  <li>⑤：选择 <code>Internal</code> 。</li>
  <li>⑥：选择 <code>maven-archetype-quickstart</code>。</li>
  <li>⑦：组织 ID : <code>com.hanserwei.framework.common</code>；</li>
  <li>⑧：项目的唯一标识符；</li>
 </ul>
</blockquote>
<p>点击 <em>Create</em> 按钮创建子模块。同样的，创建完成以后，查看 <code>hanserwei-framework</code> 模块的 <code>pom.xml</code> 文件，确认一下 <code>&lt;modules&gt;</code> 节点是否有自动添加该通用工具组件：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202222124196.png&amp;size=m" alt="image-20260202222122351"></p>
<p>接着，编辑 <code>hanserwei-common</code> 模块的 <code>pom.xml</code> , 内容如下：</p>
<pre><code class="language-xml">&lt;project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"&gt;
    &lt;modelVersion&gt;4.0.0&lt;/modelVersion&gt;

    &lt;!-- 父项目配置 --&gt;
    &lt;parent&gt;
        &lt;!-- 组织ID --&gt;
        &lt;groupId&gt;com.hanserwei&lt;/groupId&gt;
        &lt;!-- 父项目名称 --&gt;
        &lt;artifactId&gt;hanserwei-framework&lt;/artifactId&gt;
        &lt;!-- 版本号，使用变量引用 --&gt;
        &lt;version&gt;${revision}&lt;/version&gt;
    &lt;/parent&gt;

    &lt;!-- 当前模块的组织ID --&gt;
    &lt;groupId&gt;com.hanserwei.framework.common&lt;/groupId&gt;
    &lt;!-- 当前模块的项目ID --&gt;
    &lt;artifactId&gt;hanserwei-common&lt;/artifactId&gt;
    &lt;!-- 打包方式：jar包 --&gt;
    &lt;packaging&gt;jar&lt;/packaging&gt;

    &lt;!-- 项目名称，引用项目ID --&gt;
    &lt;name&gt;${project.artifactId}&lt;/name&gt;
    &lt;!-- 项目描述 --&gt;
    &lt;description&gt;平台通用模块，如一些通用枚举、工具类等等&lt;/description&gt;

    &lt;!-- 项目属性配置 --&gt;
    &lt;properties&gt;
        &lt;!-- 项目源码编码格式 --&gt;
        &lt;project.build.sourceEncoding&gt;UTF-8&lt;/project.build.sourceEncoding&gt;
    &lt;/properties&gt;

    &lt;!-- 项目依赖配置 --&gt;
    &lt;dependencies&gt;
        &lt;!-- Lombok依赖：用于简化Java代码，自动生成getter/setter等方法 --&gt;
        &lt;dependency&gt;
            &lt;groupId&gt;org.projectlombok&lt;/groupId&gt;
            &lt;artifactId&gt;lombok&lt;/artifactId&gt;
        &lt;/dependency&gt;
    &lt;/dependencies&gt;
&lt;/project&gt;

</code></pre>
<blockquote>
 <ul>
  <li>注意，这里指定了父项目为 <code>hanserwei-framework</code>， 打包方式为 <code>Jar</code> 包，添加一些模块描述性文字；</li>
  <li>然后添加了 <code>Lombok</code> 依赖。</li>
 </ul>
</blockquote>
<p>删掉图中所示的部分：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202222401147.png&amp;size=m" alt="image-20260202222359544"></p>
<h2 id="添加-Response-工具类">添加 Response 工具类</h2>
<p>在 <code>com.hanserwei.framework.common</code> 包下，添加一个封装好的 <code>Response</code> 响参工具类，以及相关异常类：</p>
<pre><code class="language-java">package com.hanserwei.framework.common.exception;

/**
 * @author hanser
 */
public interface BaseExceptionInterface {


    /**
     * 获取异常码
     *
     * @return 异常码
     */
    String getErrorCode();

    /**
     * 获取异常信息
     *
     * @return 异常信息
     */
    String getErrorMessage();
}
</code></pre>
<p>定义业务异常类：</p>
<blockquote>
 <p><strong>TIP</strong> : <em>Biz</em> 是 <em>Business</em> 英文的缩写，代表<em>业务</em>的意思。</p>
</blockquote>
<pre><code class="language-java">package com.hanserwei.framework.common.exception;

import lombok.Getter;
import lombok.Setter;

/**
 * @author hanser
 */
@Getter
@Setter
public class BizException extends RuntimeException {

    /**
     * 异常码
     */
    private String errorCode;
    /**
     * 错误信息
     */
    private String errorMessage;

    public BizException(BaseExceptionInterface baseExceptionInterface) {
        this.errorCode = baseExceptionInterface.getErrorCode();
        this.errorMessage = baseExceptionInterface.getErrorMessage();
    }
}
</code></pre>
<p>封装响参工具类：</p>
<pre><code class="language-java">package com.hanserwei.framework.common.response;

import com.hanserwei.framework.common.exception.BaseExceptionInterface;
import com.hanserwei.framework.common.exception.BizException;
import lombok.Data;

import java.io.Serializable;

/**
 * 统一响应结果封装类
 *
 * @param &lt;T&gt; 响应数据类型
 * @author hanser
 */
@Data
public class Response&lt;T&gt; implements Serializable {

    /**
     * 是否成功，默认为 true
     */
    private boolean success = true;

    /**
     * 响应消息
     */
    private String message;

    /**
     * 异常码
     */
    private String errorCode;

    /**
     * 响应数据
     */
    private T data;

    /**
     * 成功响应（无数据）
     *
     * @param &lt;T&gt; 响应数据类型
     * @return 成功响应对象
     */
    public static &lt;T&gt; Response&lt;T&gt; success() {
        return new Response&lt;&gt;();
    }

    /**
     * 成功响应（带数据）
     *
     * @param data 响应数据
     * @param &lt;T&gt;  响应数据类型
     * @return 成功响应对象
     */
    public static &lt;T&gt; Response&lt;T&gt; success(T data) {
        Response&lt;T&gt; response = new Response&lt;&gt;();
        response.setData(data);
        return response;
    }

    /**
     * 失败响应（无错误信息）
     *
     * @param &lt;T&gt; 响应数据类型
     * @return 失败响应对象
     */
    public static &lt;T&gt; Response&lt;T&gt; fail() {
        Response&lt;T&gt; response = new Response&lt;&gt;();
        response.setSuccess(false);
        return response;
    }

    /**
     * 失败响应（带错误消息）
     *
     * @param errorMessage 错误消息
     * @param &lt;T&gt;          响应数据类型
     * @return 失败响应对象
     */
    public static &lt;T&gt; Response&lt;T&gt; fail(String errorMessage) {
        Response&lt;T&gt; response = new Response&lt;&gt;();
        response.setSuccess(false);
        response.setMessage(errorMessage);
        return response;
    }

    /**
     * 失败响应（带错误码和错误消息）
     *
     * @param errorCode    错误码
     * @param errorMessage 错误消息
     * @param &lt;T&gt;          响应数据类型
     * @return 失败响应对象
     */
    public static &lt;T&gt; Response&lt;T&gt; fail(String errorCode, String errorMessage) {
        Response&lt;T&gt; response = new Response&lt;&gt;();
        response.setSuccess(false);
        response.setErrorCode(errorCode);
        response.setMessage(errorMessage);
        return response;
    }

    /**
     * 失败响应（基于业务异常）
     *
     * @param bizException 业务异常对象
     * @param &lt;T&gt;          响应数据类型
     * @return 失败响应对象
     */
    public static &lt;T&gt; Response&lt;T&gt; fail(BizException bizException) {
        Response&lt;T&gt; response = new Response&lt;&gt;();
        response.setSuccess(false);
        response.setErrorCode(bizException.getErrorCode());
        response.setMessage(bizException.getErrorMessage());
        return response;
    }

    /**
     * 失败响应（基于异常接口）
     *
     * @param baseExceptionInterface 异常接口对象
     * @param &lt;T&gt;                    响应数据类型
     * @return 失败响应对象
     */
    public static &lt;T&gt; Response&lt;T&gt; fail(BaseExceptionInterface baseExceptionInterface) {
        Response&lt;T&gt; response = new Response&lt;&gt;();
        response.setSuccess(false);
        response.setErrorCode(baseExceptionInterface.getErrorCode());
        response.setMessage(baseExceptionInterface.getErrorMessage());
        return response;
    }

}

</code></pre>
<p>至此，我们已经在 <code>hanserwei-common</code> 通用模块中，成功添加了响参工具类。这个功能是后续各个服务中，都需要用到的。</p>
<h2 id="父模块中管理-common-模块版本号">父模块中管理 common 模块版本号</h2>
<p>接下来，我们将在 <code>hannote-auth</code> 认证服务中，引入 <code>hanserwei-common</code> 模块，并测试一下是否能够正常使用 <code>Response</code> 响参工具类。首先，需要在最外层的 <code>pom</code> 文件中，声明一下 <code>hanserwei-common</code> 依赖以及其版本号，如下：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202223158836.png&amp;size=m" alt="image-20260202223157126"></p>
<p>然后，编辑 <code>hannote-auth</code> 认证服务的 <code>pom.xml</code> 文件，引入该依赖：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202223259205.png&amp;size=m" alt="image-20260202223257905"></p>
<h2 id="添加一个测试接口">添加一个测试接口</h2>
<p>将认证服务中添加一个 <code>controller</code> 包，并创建一个 <code>TestController</code> 用于测试：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202223451066.png&amp;size=m" alt="image-20260202223426096"></p>
<p>添加一个 <code>/test</code> 接口，并使用 <code>Response</code> 响参工具类进行成功响应，代码如下：</p>
<pre><code class="language-java">package com.hanserwei.hannote.auth.controller;

import com.hanserwei.framework.common.response.Response;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author hanser
 */
@RestController
public class TestController {

    @GetMapping("/test")
    public Response&lt;String&gt; test() {
        return Response.success("Hello, Hanserwei!");
    }
}
</code></pre>
<p>重启认证服务，打开浏览器，访问 <code>localhost:8080/test</code> 接口，可以看到响应参数都是 OK 的：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202223530356.png&amp;size=m" alt="image-20260202223528871"></p>
<h2 id="结语">结语</h2>
<p>本小节中，我们继续完善了小憨书的工程骨架，添加了 <code>framework</code> 基础设施模块，接着，在该模块中添加了 <code>hanserwei-common</code>通用模块，后续如一些业务上通用的枚举、工具类等等，都可以放置此模块中。最后，我们在认证服务的 <code>pom.xml</code> 中，引入了 <code>common</code> 模块，并创建了一个测试接口，通过 <code>Response</code> 工具类，对前端成功返回了响应参数。</p>]]></description><guid isPermaLink="false">/archives/tian-jia-framework-ping-tai-ji-chu-she-shi-mo-kuai</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-019c28d4-54b9-711a-a894-7f3ccc73e1fa-73d04633.png&amp;size=m" type="image/jpeg" length="1393608"/><category>分布式系统与微服务</category><pubDate>Wed, 4 Feb 2026 13:25:30 GMT</pubDate></item><item><title><![CDATA[自定义 Spring Boot Starter 封装 API 请求日志切面业务组件]]></title><link>https://likeyy.love/archives/zi-ding-yi-spring-boot-starter-feng-zhuang-api-qing-qiu-ri-zhi-qie-mian-ye-wu-zu-jian</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=%E8%87%AA%E5%AE%9A%E4%B9%89%20Spring%20Boot%20Starter%20%E5%B0%81%E8%A3%85%20API%20%E8%AF%B7%E6%B1%82%E6%97%A5%E5%BF%97%E5%88%87%E9%9D%A2%E4%B8%9A%E5%8A%A1%E7%BB%84%E4%BB%B6&amp;url=/archives/zi-ding-yi-spring-boot-starter-feng-zhuang-api-qing-qiu-ri-zhi-qie-mian-ye-wu-zu-jian" width="1" height="1" alt="" style="opacity:0;">
<h1 id="自定义-Spring-Boot-Starter--封装-API-请求日志切面业务组件">自定义 Spring Boot Starter: 封装 API 请求日志切面业务组件</h1>
<p>本小节中，我们将自定义一个 <code>Spring Boot Starter</code> 组件，将 API 请求日志切面功能封装进去，后续新建新的服务时，只需添加这个 <code>starter</code> , 即可拿来即用。</p>
<h2 id="1--什么是-Spring-Boot-Starter--">1. 什么是 Spring Boot Starter ?</h2>
<p>Spring Boot Starter 就像是一个“工具包”，里面已经包含了你所需要的东西。它们把一些常用的功能和技术打包好了，比如处理数据库、处理 Web 请求等等。你只需要在你的项目中引入这些 Starter，它们就会自动帮你配置好所需的依赖项和参数。这样，你就可以省去很多繁琐的配置工作。</p>
<p>举个栗子，当你想要使用 Spring Boot 开发一个 Web 应用时，只需要引入 <code>spring-boot-starter-web</code> Starter。这个 Starter 包含了一系列依赖项和配置，使得开发 Web 应用变得更加简单。</p>
<p>具体来说，引入 <code>spring-boot-starter-web</code> Starter 后，你可以享受到以下好处：</p>
<ul>
 <li><strong>内嵌的 Web 服务器支持</strong>：Spring Boot 内置了多种 Web 服务器支持，比如 Tomcat、Jetty、Undertow。<code>spring-boot-starter-web</code> 会自动配置一个默认的 Web 服务器，你无需手动配置即可启动你的应用。</li>
 <li><strong>Spring MVC 框架支持</strong>：Spring Boot 基于 Spring MVC 构建了强大的 Web 开发框架，包括了控制器、视图解析器等。引入 <code>spring-boot-starter-web</code> 后，你可以直接使用 Spring MVC 来处理 Web 请求。</li>
 <li><strong>静态资源支持</strong>：<code>spring-boot-starter-web</code> Starter 自动配置了对静态资源（如 HTML、CSS、JavaScript 文件）的处理，你可以直接在项目中放置这些文件，Spring Boot 就能够正确地访问它们。</li>
 <li><strong>自动配置</strong>：Spring Boot 会根据你的 classpath 自动配置应用程序。比如，如果你引入了 <code>spring-boot-starter-web</code>，Spring Boot 就会自动配置 DispatcherServlet、ViewResolver 等关键组件，从而让你的 Web 应用能够顺利地工作起来。</li>
</ul>
<p>简而言之，Spring Boot Starter 是一种方便的方式，让你能够更快、更轻松地开始使用 Spring Boot 框架，并集成各种常用功能和技术。</p>
<h2 id="2--自定义-Spring-Boot-Starter">2. 自定义 Spring Boot Starter</h2>
<p>在 <code>hanserwei-framewrok</code> 模块上，继续<em>右键 | New | Module...</em> , 为基础设施层添加新的子模块 —— <strong>业务日志切面组件</strong>：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202223949659.png&amp;size=m" alt="image-20260202223947755"></p>
<p>填写相关选项，如下图所示：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202224027811.png&amp;size=m" alt="image-20260202224026442"></p>
<p>点击 <em>Create</em> 按钮，开始创建子模块，创建完成后，查看 <code>hanserwei-framework</code> 模块的 <code>pom.xml</code> 文件，可以看到 <code>&lt;modules&gt;</code> 节点下自动添加好了该子模块：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202224128109.png&amp;size=m" alt="image-20260202224126682"></p>
<p>编辑切面日志组件的 <code>pom.xml</code> 文件，内容如下：</p>
<pre><code class="language-xml">&lt;project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"&gt;
    &lt;modelVersion&gt;4.0.0&lt;/modelVersion&gt;

    &lt;!-- 父项目配置 --&gt;
    &lt;parent&gt;
        &lt;groupId&gt;com.hanserwei&lt;/groupId&gt;
        &lt;artifactId&gt;hanserwei-framework&lt;/artifactId&gt;
        &lt;version&gt;${revision}&lt;/version&gt;
    &lt;/parent&gt;

    &lt;!-- 当前模块的artifactId --&gt;
    &lt;artifactId&gt;hanserwei-spring-boot-starter-biz-operationlog&lt;/artifactId&gt;
    &lt;!-- 打包方式：jar包 --&gt;
    &lt;packaging&gt;jar&lt;/packaging&gt;

    &lt;!-- 项目名称 --&gt;
    &lt;name&gt;hanserwei-spring-boot-starter-biz-operationlog&lt;/name&gt;
    &lt;!-- 项目描述：接口日志组件 --&gt;
    &lt;description&gt;接口日志组件&lt;/description&gt;

    &lt;!-- 项目属性配置 --&gt;
    &lt;properties&gt;
        &lt;!-- 项目构建时的源码编码格式 --&gt;
        &lt;project.build.sourceEncoding&gt;UTF-8&lt;/project.build.sourceEncoding&gt;
    &lt;/properties&gt;

    &lt;!-- 项目依赖 --&gt;
    &lt;dependencies&gt;
        &lt;!-- 公共工具依赖 --&gt;
        &lt;dependency&gt;
            &lt;groupId&gt;com.hanserwei&lt;/groupId&gt;
            &lt;artifactId&gt;hanserwei-common&lt;/artifactId&gt;
        &lt;/dependency&gt;

        &lt;!-- Spring Boot AOP切面支持，用于实现操作日志的切面拦截 --&gt;
        &lt;dependency&gt;
            &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
            &lt;artifactId&gt;spring-boot-starter-aspectj&lt;/artifactId&gt;
        &lt;/dependency&gt;
    &lt;/dependencies&gt;
&lt;/project&gt;

</code></pre>
<blockquote>
 <p>注意：在Spring Boot 4中AOP的Starter名称变了！</p>
 <p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202224454107.png&amp;size=m" alt="image-20260202224427483"></p>
</blockquote>
<p>接着，将一些不需要的类删除掉：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202224531510.png&amp;size=m" alt="image-20260202224530116"></p>
<h2 id="3--添加-JSON-工具类">3. 添加 JSON 工具类</h2>
<p>由于在日志切面 <code>starter</code> 组件中，需要以 <code>json</code> 的格式打印出参，所以，我们需要先封装一个 <code>Json</code> 工具类。由于我们在最外层已经导入了pom文件，里面已经定义了Jackson的版本信息。所有我们不用再显示指定！</p>
<p>编辑 <code>hanserwei-common</code> 公共模块的 <code>pom.xml</code>, 添加 <code>Jackson</code> 相关依赖：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202224854083.png&amp;size=m" alt="image-20260202224852666"></p>
<blockquote>
 <p>注意：Jackson3的包名变了而且有关Java8的新日期API也已经合并到Jackson3中了，不用再额外引入了！</p>
</blockquote>
<p>在 <code>hanserwei-common</code> 模块中，新建一个 <code>util</code> 工具包，用于统一放置相关工具类，并创建 <code>JsonUtils</code> , 如下图所示:</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202225413546.png&amp;size=m" alt="image-20260202225411225"></p>
<p>代码如下：</p>
<pre><code class="language-java">package com.hanserwei.framework.common.util;

import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;

/**
 * JSON 工具类，提供对象与 JSON 字符串之间的转换功能
 *
 * @author hanser
 */
public class JsonUtils {

    /**
     * 初始化 Jackson 的 JsonMapper 实例
     */
    private static final JsonMapper JSON_MAPPER = JsonMapper.builder()
            // 在反序列化时，忽略在 JSON 中存在但 Java 对象中不存在的属性，防止报错
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
            // 在序列化时，允许序列化空的 POJO 类（没有属性的类），防止报错
            .configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false)
            // 自动查找并注册所有的 Jackson 模块（如 Java 8 时间模块等）
            .findAndAddModules()
            .build();

    /**
     * 将对象序列化为 JSON 字符串
     *
     * @param object 需要转换的对象
     * @return 转换后的 JSON 字符串
     */
    public static String toJsonString(Object object) {
        return JSON_MAPPER.writeValueAsString(object);
    }
}

</code></pre>
<h2 id="4--添加日志切面">4. 添加日志切面</h2>
<p><code>Json</code> 工具类编写完成后，回到日志切面 <code>starter</code> 组件中，创建 <code>/aspect</code> 包，用于放置切面相关的类，在里面添加自定义注解以及切面，这块的代码就是很经典的AOP的代码，不做赘述，直接拿过用就行:</p>
<pre><code class="language-java">package com.hanserwei.framework.biz.operationlog.aspect;

import java.lang.annotation.*;

/**
 * @author hanser
 */
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface ApiOperationLog {
    /**
     * API 功能描述
     *
     */
    String description() default "";

}
</code></pre>
<pre><code class="language-java">
package com.hanserwei.framework.biz.operationlog.aspect;

import com.hanserwei.framework.common.util.JsonUtils;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;

import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
 * API操作日志切面
 *
 * @author hanser
 */
@Aspect
@Slf4j
public class ApiOperationLogAspect {

    /**
     * 定义切点：以自定义 @ApiOperationLog 注解为切点，凡是添加 @ApiOperationLog 的方法，都会执行环绕中的代码
     */
    @Pointcut("@annotation(com.hanserwei.framework.biz.operationlog.aspect.ApiOperationLog)")
    public void apiOperationLog() {}

    /**
     * 环绕通知：记录API操作日志
     *
     * @param joinPoint 连接点
     * @return 方法执行结果
     * @throws Throwable 方法执行异常
     */
    @Around("apiOperationLog()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
        // 请求开始时间
        long startTime = System.currentTimeMillis();

        // 获取被请求的类和方法
        String className = joinPoint.getTarget().getClass().getSimpleName();
        String methodName = joinPoint.getSignature().getName();

        // 请求入参
        Object[] args = joinPoint.getArgs();
        // 入参转 JSON 字符串
        String argsJsonStr = Arrays.stream(args).map(toJsonStr()).collect(Collectors.joining(", "));

        // 功能描述信息
        String description = getApiOperationLogDescription(joinPoint);

        // 打印请求相关参数
        log.info("====== 请求开始: [{}], 入参: {}, 请求类: {}, 请求方法: {} =================================== ",
                description, argsJsonStr, className, methodName);

        // 执行切点方法
        Object result = joinPoint.proceed();

        // 执行耗时
        long executionTime = System.currentTimeMillis() - startTime;

        // 打印出参等相关信息
        log.info("====== 请求结束: [{}], 耗时: {}ms, 出参: {} =================================== ",
                description, executionTime, JsonUtils.toJsonString(result));

        return result;
    }

    /**
     * 获取注解的描述信息
     *
     * @param joinPoint 连接点
     * @return 注解描述信息
     */
    private String getApiOperationLogDescription(ProceedingJoinPoint joinPoint) {
        // 1. 从 ProceedingJoinPoint 获取 MethodSignature
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();

        // 2. 使用 MethodSignature 获取当前被注解的 Method
        Method method = signature.getMethod();

        // 3. 从 Method 中提取 ApiOperationLog 注解
        ApiOperationLog apiOperationLog = method.getAnnotation(ApiOperationLog.class);

        // 4. 从 ApiOperationLog 注解中获取 description 属性
        return apiOperationLog.description();
    }

    /**
     * 转换为 JSON 字符串
     *
     * @return 转换函数
     */
    private Function&lt;Object, String&gt; toJsonStr() {
        return JsonUtils::toJsonString;
    }

}

</code></pre>
<h2 id="5--starter-自动配置">5. starter 自动配置</h2>
<p>接下来，就是自定义 <code>starter</code> 的重头戏 —— <strong>自动配置</strong>。新建一个 <code>/config</code> 包，并创建日志切面自动配置类，如下图所示：</p>
<pre><code class="language-java">package com.hanserwei.framework.biz.operationlog.config;

import com.hanserwei.framework.biz.operationlog.aspect.ApiOperationLogAspect;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.context.annotation.Bean;

/**
 * @author hanser
 */
@AutoConfiguration
public class ApiOperationLogAutoConfiguration {

    @Bean
    public ApiOperationLogAspect apiOperationLogAspect() {
        return new ApiOperationLogAspect();
    }
}
</code></pre>
<blockquote>
 <p>这是一个<strong>自动配置类</strong>，用于配置 API 操作日志记录功能，并且通过 <code>@Bean</code> 注解的 <code>apiOperationLogAspect()</code> 方法来创建一个 <code>ApiOperationLogAspect</code> 实例，以实现注入到 Spring 容器中。</p>
</blockquote>
<p>接着，在 <code>/main</code> 文件夹下，创建 <code>/resources</code> 包，再创建 <code>/META-INF</code> 文件夹，再在里面创建 <code>/spring</code> 文件夹 , 以及 <code>org.springframework.boot.autoconfigure.AutoConfiguration.imports</code> 文件，<strong>注意，这是自定义 <code>starter</code> 固定步骤，需要严格按照此格式来书写</strong>，如下图所示：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202230020841.png&amp;size=m" alt="image-20260202230019147"></p>
<blockquote>
 <p>注意在创建多级目录的时候要使用 <code>/</code>而不是 <code>.</code></p>
</blockquote>
<p><code>imports</code> 文件中内容如下，填写 <code>ApiOperationLogAutoConfiguration</code> 配置类的完整包路径：</p>
<pre><code class="language-java">com.hanserwei.framework.biz.operationlog.config.ApiOperationLogAutoConfiguration
</code></pre>
<p>至此，自定义 <code>starter</code> 步骤就完成了。</p>
<h2 id="6--统一版本控制">6. 统一版本控制</h2>
<p>接下来，我们想在 <code>hannote-auth</code> 认证服务中使用刚刚封装好的 <code>starter</code> 组件。回到最外层的 <code>pom.xml</code> , 声明该组件依赖以及版本号：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202230252598.png&amp;size=m" alt="image-20260202230243325"></p>
<h2 id="7--使用-starter">7. 使用 starter</h2>
<p>接着，编辑 <code>hanserwei-auth</code> 认证服务的 <code>pom.xml</code> , 添加日志切面 <code>starter</code> 的依赖, 代码如下：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202230442403.png&amp;size=m" alt="image-20260202230357993"></p>
<p>若出现爆红问题，点击右侧 <em>Reload</em> 按钮，重新刷新一下 Maven 依赖。最后，为 <code>/test</code> 接口添加 <code>@ApiOperationLog</code> 日志切面注解：</p>
<pre><code class="language-java">package com.hanserwei.hannote.auth.controller;

import com.hanserwei.framework.biz.operationlog.aspect.ApiOperationLog;
import com.hanserwei.framework.common.response.Response;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author hanser
 */
@RestController
public class TestController {

    @GetMapping("/test")
    @ApiOperationLog
    public Response&lt;String&gt; test() {
        return Response.success("Hello, Hanserwei!");
    }
}
</code></pre>
<p>重启项目， 浏览器访问 <code>localhost:8080/test</code> 接口，自测一波，看看日志切面是否能够正常工作：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202230611500.png&amp;size=m" alt="image-20260202230610095"></p>
<p>OK , 没有任何问题，自定义的日志切面 <code>starter</code> 工作良好！</p>
<h2 id="8--测试一下-切面打印出参中包含-Java-8-新日期-API">8. 测试一下，切面打印出参中包含 Java 8 新日期 API</h2>
<p>上面我们讲到，Jackson3已经支持 Java 8 新日志 API 问题。这块也需要单独测试一下：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202230730765.png&amp;size=m" alt="image-20260202230729357"></p>
<p>创建一个 <code>User</code> 用户实体类，并添加两个字段，包含一个 <code>LocalDateTime</code> 日期字段，代码如下：</p>
<pre><code class="language-java">package com.hanserwei.hannote.auth.controller;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

/**
 * @author hanser
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class User {
    /**
     * 昵称
     */
    private String nickName;

    /**
     * 创建时间
     */
    private LocalDateTime createTime;
}


</code></pre>
<p>编辑 <code>TestController</code> 控制器，新增一个 <code>/test2</code> 接口，代码如下：</p>
<pre><code class="language-java">@GetMapping("/test2")
@ApiOperationLog(description = "测试接口2")
public Response&lt;User&gt; test2() {
    return Response.success(User.builder()
            .nickName("Hanserwei")
            .createTime(LocalDateTime.now())
            .build());
}
</code></pre>
<p>重启项目，浏览器访问: http://localhost:8080/test2 , 观察控制台日志，如下：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202230925061.png&amp;size=m" alt="image-20260202230923575"></p>
<p>可以看到，即使出参对象中包含 Java 8 新日期 API 字段, 也是正常的，没有出现报异常情况。就是日期序列化格式不太友好，如上图所示。</p>
<h2 id="9--适配日期序列化格式">9. 适配日期序列化格式</h2>
<p>为了解决上述问题，我们需要手动指定 <code>Jackson</code> 日期的序列化和反序列化规则。在 <code>hanserwei-common</code> 通用模块中，新建包 <code>/constant</code> 全局常量包, 并创建 <code>DateConstants</code> 日期常量接口，代码如下：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260203203658868.png&amp;size=m" alt="image-20260203203650272"></p>
<p>代码如下：</p>
<pre><code class="language-java">package com.hanserwei.framework.common.constant;

/**
 * @author hanser
 */
public interface DateConstants {
    /**
     * 年-月-日 时：分：秒
     */
    String Y_M_D_H_M_S_FORMAT = "yyyy-MM-dd HH:mm:ss";
}

</code></pre>
<p>接着，编辑 <code>JsonUtils</code> 工具类，手动配置 <code>LocalDateTime</code> 日期格式化规则，注意，<code>Jackson3</code>的配置方法和 <code>Jackson2</code>的配置方式有点区别：</p>
<pre><code class="language-java">package com.hanserwei.framework.common.util;

import com.hanserwei.framework.common.constant.DateConstants;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.ext.javatime.deser.LocalDateTimeDeserializer;
import tools.jackson.databind.ext.javatime.ser.LocalDateTimeSerializer;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.module.SimpleModule;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.TimeZone;

/**
 * JSON 工具类，提供对象与 JSON 字符串之间的转换功能
 *
 * @author hanser
 */
public class JsonUtils {

    /**
     * 初始化 Jackson 的 JsonMapper 实例
     */
    private static final JsonMapper JSON_MAPPER;


    static {
        // 创建日期时间格式化器，使用 yyyy-MM-dd HH:mm:ss 格式
        DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(DateConstants.Y_M_D_H_M_S_FORMAT);

        // 创建自定义模块，用于注册 LocalDateTime 的序列化和反序列化器
        SimpleModule customizeModule = new SimpleModule();
        // 注册 LocalDateTime 反序列化器，将 JSON 字符串转换为 LocalDateTime 对象
        customizeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(dateTimeFormatter));
        // 注册 LocalDateTime 序列化器，将 LocalDateTime 对象转换为 JSON 字符串
        customizeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(dateTimeFormatter));

        // 构建 JsonMapper 实例
        JSON_MAPPER = JsonMapper.builder()
                // 在反序列化时，忽略在 JSON 中存在但 Java 对象中不存在的属性，防止报错
                .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
                // 在序列化时，允许序列化空的 POJO 类（没有属性的类），防止报错
                .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
                // 设置默认时区为东八区（北京时间）
                .defaultTimeZone(TimeZone.getTimeZone("GMT+8"))
                // 添加自定义模块
                .addModule(customizeModule)
                // 自动查找并添加所有可用的 Jackson 模块
                .findAndAddModules()
                .build();
    }

    /**
     * 将对象序列化为 JSON 字符串
     *
     * @param object 需要转换的对象
     * @return 转换后的 JSON 字符串
     */
    public static String toJsonString(Object object) {
        return JSON_MAPPER.writeValueAsString(object);
    }
}

</code></pre>
<p>再次重启项目，测试 <code>/test2</code> 接口，观察控制台日志，现在看到日期打印格式就友好多了~</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260203205107779.png&amp;size=m" alt="image-20260203205106212"></p>
<h2 id="10--结语">10. 结语</h2>
<p>本小节中，我们了解了什么是 <code>Spring Boot Starter</code> , 并自己动手实现了一个日志切面 <code>starter</code> 组件，后续创建新的服务时，只需添加该 <code>starter</code> 组件, 然后，为想要打印接口出入参的接口，添加 <code>@ApiOperationLog</code> 注解，即可快速使用上日志切面功能了，非常方便，有木有！</p>]]></description><guid isPermaLink="false">/archives/zi-ding-yi-spring-boot-starter-feng-zhuang-api-qing-qiu-ri-zhi-qie-mian-ye-wu-zu-jian</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-019c28d4-54d4-74c7-a445-7d424ad2155d-b9750141.png&amp;size=m" type="image/jpeg" length="1580300"/><category>Spring 生态</category><pubDate>Wed, 4 Feb 2026 13:25:30 GMT</pubDate></item><item><title><![CDATA[Spring Boot 4.x 整合 MyBatis-Plus]]></title><link>https://likeyy.love/archives/spring-boot-4.x-zheng-he-mybatis-plus</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=Spring%20Boot%204.x%20%E6%95%B4%E5%90%88%20MyBatis-Plus&amp;url=/archives/spring-boot-4.x-zheng-he-mybatis-plus" width="1" height="1" alt="" style="opacity:0;">
<h1 id="Spring-Boot-4-x-整合-MyBatis-Plus">Spring Boot 4.x 整合 MyBatis-Plus</h1>
<p>本小节中，我们来为后端服务整合数据库持久层框架 —— <strong>MyBatis-Plus</strong> ，实现对数据库的增删改查。</p>
<h2 id="1--什么是-MyBatis--">1. 什么是 MyBatis ？</h2>
<p>MyBatis 是一个用 Java 编写的持久层框架，它简化了数据库交互的过程，将 SQL 语句与 Java 方法相映射，从而使得开发者能够更轻松地操作数据库。</p>
<p>MyBatis 的优点包括：</p>
<ul>
 <li><strong>简化的 SQL 语句：</strong> MyBatis 允许开发者使用简单的 XML 文件或者注解来编写 SQL 语句，而不需要手动拼接 SQL 语句，大大简化了数据库操作的流程。</li>
 <li><strong>灵活性：</strong> MyBatis 提供了丰富的配置选项和灵活的映射方式，开发者可以根据需求自定义映射关系，适应各种复杂的业务场景。</li>
 <li><strong>与 SQL 的紧密结合：</strong> MyBatis 将 SQL 语句与 Java 代码紧密结合，开发者可以直观地理解代码的执行逻辑，同时也方便了 SQL 优化和调试。</li>
 <li><strong>良好的性能：</strong> MyBatis 通过预编译 SQL 语句和数据库连接池等机制，提升了数据库操作的性能，使得系统能够更高效地处理大量数据。</li>
 <li><strong>与现有项目的兼容性：</strong> MyBatis 不会对现有的项目结构和代码产生太大的影响，易于集成到已有的项目中，并且可以与其他框架（如 Spring）无缝整合，提高了开发效率。</li>
</ul>
<p>总的来说，MyBatis 是一个功能强大且易于使用的持久层框架，适用于各种规模的项目，能够帮助开发者简化数据库操作，提高开发效率。</p>
<h2 id="2--什么是-MyBatis-Plus--">2. 什么是 MyBatis-Plus ？</h2>
<p>简单来说，<strong>MyBatis-Plus (MP)</strong> 是 MyBatis 的一个<strong>增强工具</strong>。它在 MyBatis 的基础上只做增强不做改变，目的是<strong>简化开发、提高效率</strong>。</p>
<p>如果把 MyBatis 比作一把需要手动打磨的宝剑，MyBatis-Plus 就是给这把剑装上了“自动化刀架”和“倍镜”。</p>
<ol>
 <li>无侵入与损耗小</li>
</ol>
<p>MP 完全兼容 MyBatis，引入它不会影响你现有的 MyBatis 代码。它在启动时进行预处理，基本不影响运行性能。</p>
<ol start="2">
 <li>强大的 CRUD 操作</li>
</ol>
<p>这是 MP 最受欢迎的功能。它内置了通用 Mapper 和 Service，仅通过少量配置即可实现单表的增、删、改、查。</p>
<ul>
 <li><strong>以前（MyBatis）：</strong> 你需要手写 SQL 或使用逆向工程生成 XML 文件。</li>
 <li><strong>现在（MP）：</strong> 你的 Mapper 接口只需继承 <code>BaseMapper&lt;T&gt;</code>，一行代码不写就能拥有数十种基础查询方法。</li>
</ul>
<ol start="3">
 <li>条件构造器 (Wrapper)</li>
</ol>
<p>MP 提供了强大的条件构造器，允许你通过链式调用（Java 代码）来编写复杂的查询逻辑，而不需要在 XML 里拼接繁琐的 <code>&lt;if&gt;</code> 标签。</p>
<pre><code class="language-java">// 示例：查询年龄大于18且名字包含"张"的用户
userMapper.selectList(new QueryWrapper&lt;User&gt;()
    .gt("age", 18)
    .like("name", "张"));
</code></pre>
<ol start="4">
 <li>自动分页</li>
</ol>
<p>内置分页插件，你只需要简单的配置，查询时传入一个 <code>Page</code> 对象，MP 就会自动帮你处理不同数据库（MySQL, Oracle, PostgreSQL 等）的分页语法差异。</p>
<ol start="5">
 <li>
  <p>其他进阶功能</p>
  <ul>
   <li><strong>逻辑删除：</strong> 只需加个注解，<code>delete</code> 语句会自动变成 <code>update</code> 状态字段。</li>
   <li><strong>自动填充：</strong> 比如 <code>create_time</code> 和 <code>update_time</code>，可以设置在插入或更新时自动赋值，不需要手动 <code>set</code>。</li>
   <li><strong>代码生成器：</strong> 快速生成 Entity、Mapper、Service、Controller 代码。</li>
  </ul>
 </li>
</ol>
<p>MyBatis-Plus 并不是要取代 MyBatis，而是为了让你<strong>从繁琐的单表重复劳动中解脱出来</strong>。</p>
<h2 id="3--整合-MyBatis-Plus">3. 整合 MyBatis-Plus</h2>
<p>一切准备就绪之后，我们先在最外层的 <code>pom</code>文件里导入一下 <code>MyBatis-plus</code>有关的版本声明！</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260203210046860.png&amp;size=m" alt="image-20260203210045174"></p>
<pre><code class="language-xml">        &lt;dependency&gt;
            &lt;groupId&gt;com.baomidou&lt;/groupId&gt;
            &lt;artifactId&gt;mybatis-plus-bom&lt;/artifactId&gt;
            &lt;version&gt;3.5.16&lt;/version&gt;
            &lt;type&gt;pom&lt;/type&gt;
            &lt;scope&gt;import&lt;/scope&gt;
        &lt;/dependency&gt;
</code></pre>
<p>接着，编辑 <code>hanserwei-auth</code> 服务的 <code>pom.xml</code> ， 引入相关的依赖：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260203210407569.png&amp;size=m" alt="image-20260203210406091"></p>
<pre><code class="language-xml">        &lt;!-- MySQL数据库驱动：用于连接MySQL数据库 --&gt;
        &lt;dependency&gt;
            &lt;groupId&gt;com.mysql&lt;/groupId&gt;
            &lt;artifactId&gt;mysql-connector-j&lt;/artifactId&gt;
            &lt;scope&gt;runtime&lt;/scope&gt;
        &lt;/dependency&gt;

        &lt;!-- MyBatis Plus启动器：用于简化MyBatis的配置和使用 --&gt;
        &lt;dependency&gt;
            &lt;groupId&gt;com.baomidou&lt;/groupId&gt;
            &lt;artifactId&gt;mybatis-plus-spring-boot4-starter&lt;/artifactId&gt;
        &lt;/dependency&gt;
</code></pre>
<h2 id="4--项目多环境配置">4. 项目多环境配置</h2>
<p>为了区分不同的环境，Spring Boot项目可以有多个配置文件，如下图所示：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260203210723033.png&amp;size=m" alt="image-20260203210720351"></p>
<blockquote>
 <p><strong>注意</strong>：也可以在这里在 <code>/resources</code> 目录下，新建一个 <code>/config</code> 配置文件夹，统一放置 <code>applicaiton</code> 多环境配置文件。</p>
</blockquote>
<p>编辑 <code>application.yml</code> 父配置，内容如下:</p>
<pre><code class="language-yml">spring:
  application:
    name: han-note-auth
  banner:
    location: banner.txt
  threads:
    virtual:
      enabled: true
  # 默认激活 dev 本地开发环境
  profiles:
    active: dev
server:
  port: 8080
</code></pre>
<p>编辑 <code>application-dev.yml</code> 本地开发环境配置文件，配置本地的数据库链接相关信息，如下：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260203211556926.png&amp;size=m" alt="image-20260203211555239"></p>
<blockquote>
 <p><strong>TIP</strong> : 数据库连接池暂时不做配置，直接用默认的，后续单独开一小节讲这一块，届时我会把分库分表也一并引入进来！</p>
</blockquote>
<h2 id="5--新建相关包">5. 新建相关包</h2>
<p>编辑 <code>hannote-auth</code> 服务，创建以下文件夹：</p>
<ul>
 <li><code>/domain/dataobject</code> : 用于统一放置 <code>DO</code> 类，对应数据库表；</li>
 <li><code>/domain/mapper</code> : 用于放置 <code>Mapper</code> 接口；</li>
 <li><code>/resources/mapper</code> : 用于放置 MyBatis <code>XML</code> 文件；</li>
</ul>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260203211828373.png&amp;size=m" alt="image-20260203211826927"></p>
<h2 id="6--配置-MyBatis-Plus">6. 配置 MyBatis-Plus</h2>
<p>接着，在 <code>Application</code> 启动类的头部，添加 <code>@MapperScan</code> 注解，值填写 <code>mapper</code> 接口所处的包路径，如下图所示：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260203211946869.png&amp;size=m" alt="image-20260203211945189"></p>
<pre><code class="language-java">package com.hanserwei.hannote.auth;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * @author hanser
 */
@SpringBootApplication
@MapperScan("com.hanserwei.hannote.auth.domain.mapper")
public class HannoteAuthApplication {
    static void main() {
        SpringApplication.run(HannoteAuthApplication.class);
    }
}

</code></pre>
<p>另外，编辑 <code>application.yml</code> , 为 MyBatis-plus 配置相关配置：</p>
<pre><code class="language-yml">mybatis-plus:
  # MyBatis xml 配置文件路径
  mapper-locations: classpath:/mapper/**/*.xml
  # 实体类包路径
  type-aliases-package: com.hannote.auth.domain.entity
  # MyBatis 原生配置
  configuration:
    # 是否开启自动驼峰命名规则映射
    map-underscore-to-camel-case: true
    # 开启 MyBatis 二级缓存，默认为 true
    cache-enabled: false
    # 日志实现
    log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
</code></pre>
<blockquote>
 <p>解释：<code>classpath</code>: 表示 <code>/resources</code> 目录; <code>/mapper/**/*.xml</code> 表示在 mapper 目录及其所有子目录下查找以 <code>.xml</code> 结尾的文件。这种表达式允许你指定一个包含通配符的路径模式，以方便地匹配多个文件。</p>
</blockquote>
<h2 id="7--配置-SQL-日志打印">7. 配置 SQL 日志打印</h2>
<p>开发中为了方便调试，想要打印实际执行的 SQL 语句, 可编辑 <code>application-dev.yml</code> 配置文件，配置 <code>mapper</code> 接口所在包的日志级别为 <code>debug</code> , 即可实现此功能：</p>
<pre><code class="language-yml">logging:
  level:
    com.hanserwei.hannote.auth.domain.mapper: debug
</code></pre>]]></description><guid isPermaLink="false">/archives/spring-boot-4.x-zheng-he-mybatis-plus</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-019c28d4-54bb-750e-8031-c35a23b2f06b-4a3295e3.png&amp;size=m" type="image/jpeg" length="1611948"/><category>数据持久化</category><pubDate>Wed, 4 Feb 2026 13:25:30 GMT</pubDate></item><item><title><![CDATA[搭建微服务项目骨架：通过 Maven 多模块方式]]></title><link>https://likeyy.love/archives/da-jian-wei-fu-wu-xiang-mu-gu-jia-tong-guo-maven-duo-mo-kuai-fang-shi</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=%E6%90%AD%E5%BB%BA%E5%BE%AE%E6%9C%8D%E5%8A%A1%E9%A1%B9%E7%9B%AE%E9%AA%A8%E6%9E%B6%EF%BC%9A%E9%80%9A%E8%BF%87%20Maven%20%E5%A4%9A%E6%A8%A1%E5%9D%97%E6%96%B9%E5%BC%8F&amp;url=/archives/da-jian-wei-fu-wu-xiang-mu-gu-jia-tong-guo-maven-duo-mo-kuai-fang-shi" width="1" height="1" alt="" style="opacity:0;">
<h1 id="搭建微服务项目骨架-通过-Maven-多模块方式">搭建微服务项目骨架：通过 Maven 多模块方式</h1>
<h2 id="相关概念">相关概念</h2>
<blockquote>
 <p>在搭建项目之前，先了解一下相关概念。</p>
</blockquote>
<h3 id="什么是单体架构">什么是单体架构</h3>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202200455673.png&amp;size=m" alt="image-20260202200454590"></p>
<p>单体架构是一种传统的软件架构模式，<strong>通常将整个应用作为一个单一的、独立部署的单元来开发、构建和部署</strong>。在单体架构中，所有的功能模块和业务逻辑都打包在同一个应用程序中，比如 <code>weblog</code> 项目，最终是打包成一个 <code>Jar</code> 包来部署。这包括业务逻辑、数据访问和数据存储等。整个应用程序通常部署在一个应用服务器上，与外部系统通过接口进行通信。</p>
<p><strong>单体架构开发简单，部署方便，适合流量不大的应用。但随着应用规模的增长，往往会遇到可扩展性差、维护困难、部署风险高等问题。</strong></p>
<p><strong>微服务架构是一种将应用程序拆分为多个小型、自治的服务的架构模式</strong>。可以通过上图本项目的架构图来理解。每个服务都专注于实现一个特定的业务功能，并通过轻量级的通信机制（如 HTTP 或 RPC）与其他服务进行通信。微服务架构将整个应用程序拆分成多个松耦合的服务单元，每个服务单元都可以独立开发、测试、部署和扩展。<strong>微服务架构有助于提高灵活性、可扩展性和可维护性，适合大流量型应用。但也增加了部署和运维的复杂性，需要考虑服务间通信、服务注册与发现、服务监控等方面的问题。</strong></p>
<h3 id="Spring-Cloud">Spring Cloud</h3>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202200548051.jpeg&amp;size=m" alt="img"></p>
<p>Spring Cloud 是基于 Spring Boot 的微服务架构开发工具包，提供了一系列开发工具和库，用于快速构建、部署和管理分布式系统中的微服务应用。Spring Cloud提供了诸如服务发现、服务注册、负载均衡、断路器、配置管理、消息总线等功能，帮助开发人员解决了微服务架构中的常见问题，简化了微服务应用的开发和部署流程。</p>
<h3 id="Spring-Cloud-Alibaba">Spring Cloud Alibaba</h3>
<p>Spring Cloud Alibaba 是 Spring Cloud 生态的一部分，提供了一系列基于阿里巴巴开源产品的分布式解决方案和工具，用于构建和管理基于 Spring Cloud 的微服务应用。Spring Cloud Alibaba 集成了阿里巴巴开源的服务，如 Nacos（服务注册与发现）、Sentinel（流量控制和熔断降级）、Dubbo（远程服务调用）、RocketMQ（消息队列）等，为开发人员提供了一站式的微服务解决方案。Spring Cloud Alibaba 与 Spring Cloud 兼容，并且提供了额外的功能和工具，帮助开发人员更轻松地构建、部署和管理微服务应用。</p>
<h3 id="多模块项目">多模块项目</h3>
<h4 id="什么是多模块项目-">什么是多模块项目？</h4>
<p>多模块项目是项目构建中的概念。拿 Maven 来说，多模块项目（Multi-Module Project）是其一个重要特性，<strong>它允许我们在一个项目中管理多个子模块。</strong></p>
<p>在一个 Maven 多模块项目中，每个模块都是一个独立的项目，拥有自己的 POM 文件（Project Object Model，项目对象模型）。这些模块可以互相依赖，也可以被其他项目依赖。但是，所有的模块都会被统一管理，它们共享同一套构建系统和依赖管理。</p>
<p>Maven 多模块项目的结构大概是下面这样的：</p>
<pre><code class="language-xml">my-app/  (父项目)
  |- pom.xml
  |- my-module1/  (子模块1)
  |    |- pom.xml
  |- my-module2/  (子模块2)
       |- pom.xml
  | ... (实际企业级项目中，会分非常多的模块)   

</code></pre>
<p>在这个例子中，<code>my-app</code> 是父项目，<code>my-module1</code> 和 <code>my-module2</code> 是它的子模块。每个模块都有自己的 <code>pom.xml</code> 文件。</p>
<h4 id="为什么需要多模块项目-">为什么需要多模块项目？</h4>
<p>主要有以下几个原因：</p>
<ul>
 <li><strong>代码组织</strong>：在大型项目中，我们经常需要把代码分成多个模块，以便更好地组织代码。每个模块可以聚焦于一个特定的功能或领域，这样可以提高代码的可读性和可维护性。</li>
 <li><strong>依赖管理</strong>：Maven 多模块项目可以帮助我们更好地管理项目的依赖。在父项目的 POM 文件中，我们可以定义所有模块共享的依赖，这样可以避免重复的依赖定义，也方便我们管理和升级依赖。</li>
 <li><strong>构建和部署</strong>：Maven 多模块项目的另一个优点是它可以统一管理项目的构建和部署。我们只需要在父项目中执行 Maven 命令，就可以对所有模块进行构建和部署。这大大简化了开发者的工作。</li>
</ul>
<h2 id="IDEA-搭建微服务多模块工程骨架">IDEA 搭建微服务多模块工程骨架</h2>
<h3 id="创建父模块">创建父模块</h3>
<p>打开 IDEA, 这里我使用的是 2025 版本，即最新的IDEA不同版本的IDEA操作类似。依次点击左上角菜单 <em>File | New | Project</em> , 开始创建项目：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202201138049.png&amp;size=m" alt="image-20260202201136339"></p>
<blockquote>
 <p>解释一下标注的地方:</p>
 <ul>
  <li>①：选择 <code>Maven Archetype</code> 来创建一个 <code>Maven</code> 项目;</li>
  <li>②：项目名称，hannote；</li>
  <li>③：项目创建的位置；</li>
  <li>④：项目使用的 <code>JDK</code> 版本，本项目使用的是 <code>JDK 25</code> ,因为有虚拟线程这一大杀器；</li>
  <li>⑤：IDEA 需要知道 Maven Archetype Catalog 的位置，以便从中获取可用的 Archetype 列表。这个 Catalog 文件通常包含了 Maven 官方仓库或其他远程仓库中可用的 Archetype 信息。选择 <code>Internal</code> 即可。</li>
  <li>⑥：通过使用 Archetype，你可以基于已有的项目模板创建一个新项目。这里选择 <code>maven-archetype-quickstart</code>。</li>
  <li>⑦：填写 Group 组织名称，通常为公司域名倒写，如 <code>com.hanserwei</code>；</li>
  <li>⑧：项目的唯一标识符；</li>
  <li>⑨：项目版本号，默认就行；</li>
 </ul>
</blockquote>
<p>创建完毕之后，第一步删除掉src目录：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202201418262.png&amp;size=m" alt="image-20260202201416674"></p>
<blockquote>
 <p>父模块仅需要保留一个 <code>pom.xml</code> 文件，用于统一管理依赖、插件等。如下图所示：</p>
</blockquote>
<blockquote>
 <p>截至目前：2026年2月2日，Spring Cloud Alibaba 2025.1.0的正式版还没发布，我目前先使用快照版本，正式版据issue说，一个星期内发布！</p>
 <pre><code class="language-xml">  &lt;servers&gt;
    &lt;server&gt;
      &lt;id&gt;github&lt;/id&gt;
      &lt;username&gt;${env.GITHUB_ACTOR}&lt;/username&gt;
      &lt;password&gt;${env.GITHUB_TOKEN}&lt;/password&gt;
    &lt;/server&gt;
  &lt;/servers&gt;
</code></pre>
 <p>创建 GitHub Token 地址：https://github.com/settings/tokens。然后替换掉上面的两个为你自己的token和名字。</p>
 <pre><code class="language-xml">
&lt;repositories&gt;
    &lt;repository&gt;
        &lt;id&gt;github&lt;/id&gt;
        &lt;url&gt;https://maven.pkg.github.com/alibaba/spring-cloud-alibaba&lt;/url&gt;
        &lt;releases&gt;
            &lt;enabled&gt;false&lt;/enabled&gt;
        &lt;/releases&gt;
        &lt;snapshots&gt;
            &lt;enabled&gt;true&lt;/enabled&gt;
        &lt;/snapshots&gt;
    &lt;/repository&gt;
&lt;/repositories&gt;
</code></pre>
 <p>以下是我的settings.xml文件：</p>
 <pre><code class="language-xml">&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd"&gt;

  &lt;servers&gt;
    &lt;server&gt;
      &lt;id&gt;github&lt;/id&gt;
      &lt;username&gt;替换为你自己的&lt;/username&gt;
      &lt;password&gt;替换为你自己的&lt;/password&gt;
    &lt;/server&gt;
  &lt;/servers&gt;

  &lt;mirrors&gt;
    &lt;mirror&gt;
      &lt;id&gt;maven-default-http-blocker&lt;/id&gt;
      &lt;mirrorOf&gt;external:http:*&lt;/mirrorOf&gt;
      &lt;name&gt;Pseudo repository to mirror external repositories initially using HTTP.&lt;/name&gt;
      &lt;url&gt;http://0.0.0.0/&lt;/url&gt;
      &lt;blocked&gt;true&lt;/blocked&gt;
    &lt;/mirror&gt;
  &lt;/mirrors&gt;

  &lt;profiles&gt;
    &lt;profile&gt;
      &lt;id&gt;github-repo&lt;/id&gt;
      &lt;repositories&gt;
        &lt;repository&gt;
          &lt;id&gt;github&lt;/id&gt;
          &lt;url&gt;https://maven.pkg.github.com/alibaba/spring-cloud-alibaba&lt;/url&gt;
          &lt;releases&gt;
            &lt;enabled&gt;false&lt;/enabled&gt;
          &lt;/releases&gt;
          &lt;snapshots&gt;
            &lt;enabled&gt;true&lt;/enabled&gt;
          &lt;/snapshots&gt;
        &lt;/repository&gt;
      &lt;/repositories&gt;
    &lt;/profile&gt;
  &lt;/profiles&gt;

  &lt;activeProfiles&gt;
    &lt;activeProfile&gt;github-repo&lt;/activeProfile&gt;
  &lt;/activeProfiles&gt;
&lt;/settings&gt;
</code></pre>
</blockquote>
<p>然后修改顶层的pom文件如下所示：</p>
<pre><code class="language-xml">&lt;project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"&gt;

    &lt;!-- Maven 项目对象模型版本 --&gt;
    &lt;modelVersion&gt;4.0.0&lt;/modelVersion&gt;

    &lt;!-- 项目坐标信息 --&gt;
    &lt;groupId&gt;com.hanserwei&lt;/groupId&gt;
    &lt;artifactId&gt;hannote&lt;/artifactId&gt;
    &lt;version&gt;${revision}&lt;/version&gt;
    &lt;name&gt;${project.artifactId}&lt;/name&gt;
    &lt;description&gt;小憨书（仿小红书），基于 Spring Cloud Alibaba 2025.1.0 微服务架构&lt;/description&gt;

    &lt;!-- 打包方式：pom 表示这是一个父项目 --&gt;
    &lt;packaging&gt;pom&lt;/packaging&gt;

    &lt;!-- 子模块列表 --&gt;
    &lt;modules&gt;
    &lt;/modules&gt;

    &lt;!-- 项目属性配置 --&gt;
    &lt;properties&gt;
        &lt;!-- 项目版本号 --&gt;
        &lt;revision&gt;0.0.1-SNAPSHOT&lt;/revision&gt;

        &lt;!-- Java 版本配置 --&gt;
        &lt;java.version&gt;25&lt;/java.version&gt;
        &lt;maven.compiler.source&gt;${java.version}&lt;/maven.compiler.source&gt;
        &lt;maven.compiler.target&gt;${java.version}&lt;/maven.compiler.target&gt;

        &lt;!-- 项目编码配置 --&gt;
        &lt;project.build.sourceEncoding&gt;UTF-8&lt;/project.build.sourceEncoding&gt;

        &lt;!-- Spring Boot 版本 --&gt;
        &lt;spring-boot.version&gt;4.0.2&lt;/spring-boot.version&gt;

        &lt;!-- Spring Cloud 版本 --&gt;
        &lt;spring-cloud.version&gt;2025.1.1&lt;/spring-cloud.version&gt;

        &lt;!-- Spring Cloud Alibaba 版本 --&gt;
        &lt;spring-cloud-alibaba.version&gt;2025.1.0.0-SNAPSHOT&lt;/spring-cloud-alibaba.version&gt;

        &lt;!-- Maven 编译插件版本 --&gt;
        &lt;maven-compiler-plugin.version&gt;3.11.0&lt;/maven-compiler-plugin.version&gt;

        &lt;!-- Lombok 版本 --&gt;
        &lt;lombok.version&gt;1.18.38&lt;/lombok.version&gt;
    &lt;/properties&gt;

    &lt;!-- 依赖管理：统一管理项目依赖版本 --&gt;
    &lt;dependencyManagement&gt;
        &lt;dependencies&gt;
            &lt;!-- Spring Boot 依赖 --&gt;
            &lt;dependency&gt;
                &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
                &lt;artifactId&gt;spring-boot-dependencies&lt;/artifactId&gt;
                &lt;version&gt;${spring-boot.version}&lt;/version&gt;
                &lt;type&gt;pom&lt;/type&gt;
                &lt;scope&gt;import&lt;/scope&gt;
            &lt;/dependency&gt;

            &lt;!-- Spring Cloud 依赖 --&gt;
            &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;

            &lt;!-- Spring Cloud Alibaba 依赖 --&gt;
            &lt;dependency&gt;
                &lt;groupId&gt;com.alibaba.cloud&lt;/groupId&gt;
                &lt;artifactId&gt;spring-cloud-alibaba-dependencies&lt;/artifactId&gt;
                &lt;version&gt;${spring-cloud-alibaba.version}&lt;/version&gt;
                &lt;type&gt;pom&lt;/type&gt;
                &lt;scope&gt;import&lt;/scope&gt;
            &lt;/dependency&gt;

            &lt;!-- Lombok 依赖：用于简化 Java 代码 --&gt;
            &lt;dependency&gt;
                &lt;groupId&gt;org.projectlombok&lt;/groupId&gt;
                &lt;artifactId&gt;lombok&lt;/artifactId&gt;
                &lt;version&gt;${lombok.version}&lt;/version&gt;
            &lt;/dependency&gt;
        &lt;/dependencies&gt;
    &lt;/dependencyManagement&gt;

    &lt;!-- 构建配置 --&gt;
    &lt;build&gt;
        &lt;!-- 插件管理：统一管理构建插件 --&gt;
        &lt;pluginManagement&gt;
            &lt;plugins&gt;
                &lt;!-- Spring Boot Maven 插件：用于打包 Spring Boot 应用 --&gt;
                &lt;plugin&gt;
                    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
                    &lt;artifactId&gt;spring-boot-maven-plugin&lt;/artifactId&gt;
                    &lt;version&gt;${spring-boot.version}&lt;/version&gt;
                    &lt;executions&gt;
                        &lt;execution&gt;
                            &lt;id&gt;repackage&lt;/id&gt;
                            &lt;goals&gt;
                                &lt;!-- 重新打包为可执行的 jar 文件 --&gt;
                                &lt;goal&gt;repackage&lt;/goal&gt;
                            &lt;/goals&gt;
                        &lt;/execution&gt;
                    &lt;/executions&gt;
                &lt;/plugin&gt;

                &lt;!-- Maven 编译插件：配置 Java 编译参数 --&gt;
                &lt;plugin&gt;
                    &lt;groupId&gt;org.apache.maven.plugins&lt;/groupId&gt;
                    &lt;artifactId&gt;maven-compiler-plugin&lt;/artifactId&gt;
                    &lt;version&gt;${maven-compiler-plugin.version}&lt;/version&gt;
                    &lt;configuration&gt;
                        &lt;!-- 源码版本 --&gt;
                        &lt;source&gt;${java.version}&lt;/source&gt;
                        &lt;!-- 目标版本 --&gt;
                        &lt;target&gt;${java.version}&lt;/target&gt;
                        &lt;!-- 编码格式 --&gt;
                        &lt;encoding&gt;${project.build.sourceEncoding}&lt;/encoding&gt;
                        &lt;!-- 注解处理器路径：配置 Lombok 注解处理器 --&gt;
                        &lt;annotationProcessorPaths&gt;
                            &lt;path&gt;
                                &lt;groupId&gt;org.projectlombok&lt;/groupId&gt;
                                &lt;artifactId&gt;lombok&lt;/artifactId&gt;
                                &lt;version&gt;${lombok.version}&lt;/version&gt;
                            &lt;/path&gt;
                        &lt;/annotationProcessorPaths&gt;
                    &lt;/configuration&gt;
                &lt;/plugin&gt;
            &lt;/plugins&gt;
        &lt;/pluginManagement&gt;
    &lt;/build&gt;
&lt;/project&gt;
</code></pre>
<p>非常完美，到此为止，我们已经把多模块的maven项目的骨架搭建起来了，现在我们来引入第一个模块，用户认证微服务。</p>
<p>在父项目<em>右键 | New | Module...</em> ， 来创建一个子模块：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202204444046.png&amp;size=m" alt="image-20260202204441953"></p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202204605347.png&amp;size=m" alt="image-20260202204603732"></p>
<p>我这里使用maven骨架来创建Spring Boot项目，这样我可以按需导入锁需要的依赖，这样整个工程看起来要更加清爽一些！如果你使用Spring Boot来创建，当然也是可以的。</p>
<p>auth服务的pom文件如下：</p>
<pre><code class="language-xml">&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"&gt;
    &lt;modelVersion&gt;4.0.0&lt;/modelVersion&gt;

    &lt;!-- 父项目配置 --&gt;
    &lt;parent&gt;
        &lt;!-- 父项目的组织标识 --&gt;
        &lt;groupId&gt;com.hanserwei&lt;/groupId&gt;
        &lt;!-- 父项目的唯一标识 --&gt;
        &lt;artifactId&gt;hannote&lt;/artifactId&gt;
        &lt;!-- 父项目版本号，使用变量${revision}便于统一管理 --&gt;
        &lt;version&gt;${revision}&lt;/version&gt;
    &lt;/parent&gt;

    &lt;!-- 当前模块的唯一标识 --&gt;
    &lt;artifactId&gt;hannote-auth&lt;/artifactId&gt;
    &lt;!-- 打包方式：jar包 --&gt;
    &lt;packaging&gt;jar&lt;/packaging&gt;

    &lt;!-- 项目名称，使用Maven内置变量 --&gt;
    &lt;name&gt;${project.artifactId}&lt;/name&gt;
    &lt;!-- 项目描述信息 --&gt;
    &lt;description&gt;小憨书：认证服务（负责处理用户登录、注册、账号注销等）&lt;/description&gt;

    &lt;!-- 项目属性配置 --&gt;
    &lt;properties&gt;
        &lt;!-- 项目源代码和编译输出使用UTF-8编码 --&gt;
        &lt;project.build.sourceEncoding&gt;UTF-8&lt;/project.build.sourceEncoding&gt;
    &lt;/properties&gt;

    &lt;!-- 项目依赖配置 --&gt;
    &lt;dependencies&gt;
        &lt;!-- Spring Boot Web启动器：用于构建Web应用，包含RESTful、Spring MVC等功能 --&gt;
        &lt;dependency&gt;
            &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
            &lt;artifactId&gt;spring-boot-starter-web&lt;/artifactId&gt;
        &lt;/dependency&gt;

        &lt;!-- Spring Boot测试启动器：用于单元测试和集成测试 --&gt;
        &lt;dependency&gt;
            &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
            &lt;artifactId&gt;spring-boot-starter-test&lt;/artifactId&gt;
            &lt;!-- 作用域：仅在测试阶段使用，不会打包到最终的jar中 --&gt;
            &lt;scope&gt;test&lt;/scope&gt;
        &lt;/dependency&gt;
    &lt;/dependencies&gt;

    &lt;!-- 项目构建配置 --&gt;
    &lt;build&gt;
        &lt;!-- 插件配置 --&gt;
        &lt;plugins&gt;
            &lt;!-- Spring Boot Maven插件：用于将应用打包成可执行的jar包 --&gt;
            &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;
        &lt;/plugins&gt;
    &lt;/build&gt;
&lt;/project&gt;

</code></pre>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202205215137.png&amp;size=m" alt="image-20260202205213539"></p>
<p>完善一下启动类：</p>
<pre><code class="language-java">package com.hanserwei.hannote.auth;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * @author hanser
 */
@SpringBootApplication
public class HannoteAuthApplication {
    static void main() {
        SpringApplication.run(HannoteAuthApplication.class);
    }
}

</code></pre>
<p>添加一下配置文件：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202205527578.png&amp;size=m" alt="image-20260202205526133"></p>
<h3 id="测试打包是否正常">测试打包是否正常</h3>
<p>接下来，我们测试一下多模块项目打包是否正常。对右侧栏中的<em>父项目</em>进行 <code>clean package</code> , 观察控制台日志，确认一下项目打包是否正常：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202205815241.png&amp;size=m" alt="image-20260202205813333"></p>
<h3 id="启动认证服务">启动认证服务</h3>
<p>打包没问题后，再来测试一下认证服务是否能够正常运行。点击 <code>XiaohashuAuthApplication</code> 启动类的 <code>main</code> 方法左侧的运行按钮，来启动服务：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202210239470.png&amp;size=m" alt="image-20260202210237841"></p>
<p>若看到控制台输出 <code>Tomcat started on port(s): 8080</code> 信息，则表示服务成功运行在了 <code>8080</code> 端口上。打开浏览器，访问地址 <code>localhost:8080</code> ，即可看到 <code>Spring Boot</code> 项目的页面提示信息了，如下图所示：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202210312684.png&amp;size=m" alt="image-20260202210310774"></p>
<p>看到这个就说明我们已经成功了。</p>
<h2 id="结语">结语</h2>
<p>本小结中，我们了解了什么是单体架构、微服务架构，以及 Spring Cloud 、Spring Cloud Alibaba 之间的关系。然后，上手搭建了一个 Maven 多模块项目，并引入了 Spring Cloud Alibaba 2025.1.0 依赖， 还创建了一个子模块 —— 认证服务，并成功运行了起来。但是，目前还只是简单的多模块的项目，还没实际使用到微服务，后续小节中，将一点一点引入 Spring Cloud Alibaba 相关组件，将整个微服务体系渐进式的搭建起来。</p>
<h2 id="扩展">扩展</h2>
<p>众所周知，Java21开始引入了一个叫做虚拟虚拟线程的东西，这个玩意可以说是Java跨时代的东西，那么我们Spring Boot4对它支持如何呢，答案是支持非常的完美！现在我们来看看如何在Spring Boot4中开启虚拟线程的功能！</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202210616219.png&amp;size=m" alt="image-20260202210613893"></p>
<p>写一个测试接口来看一下是否生效：</p>
<pre><code class="language-java">package com.hanserwei.hannote.auth.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author hanser
 */
@RestController
public class ThreadCheckController {

    @GetMapping("/check")
    public String check() {
        Thread thread = Thread.currentThread();
        return "Current Thread: " + thread.toString();
    }
}
</code></pre>
<p>访问这个接口：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202211133093.png&amp;size=m" alt="image-20260202211131172"></p>
<p>完美，可以看到tomcat已经由原来的平台线程池变成了虚拟线程。完美的蜕变！如果上述方式不起作用，可以尝试配置类的方式：</p>
<pre><code class="language-java">package com.hanserwei.hannote.auth.config;

import org.springframework.boot.tomcat.TomcatProtocolHandlerCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.Executors;

/**
 * @author hanser
 */
@Configuration
public class TomcatConfiguration {


    /**
     * 配置Tomcat协议处理器使用虚拟线程执行器
     * 虚拟线程是Java 21引入的轻量级线程，可以大幅提升并发处理能力
     * 
     * @return Tomcat协议处理器自定义器
     */
    @Bean
    public TomcatProtocolHandlerCustomizer&lt;?&gt; protocolHandlerVirtualThreadExecutorCustomizer() {
        return protocolHandler -&gt; {
            // 为Tomcat的协议处理器设置虚拟线程执行器，每个任务创建一个新的虚拟线程
            protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        };
    }
}

</code></pre>
<p>这样也可以达到同样的效果！</p>
<p>当然，虚拟线程的作用远不止于此，在后续的章节我会给大家带来更多的它的用法。</p>]]></description><guid isPermaLink="false">/archives/da-jian-wei-fu-wu-xiang-mu-gu-jia-tong-guo-maven-duo-mo-kuai-fang-shi</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-019c1e84-0b70-7618-8594-6e6938bfac08-77f128fe.png&amp;size=m" type="image/jpeg" length="1562469"/><category>分布式系统与微服务</category><pubDate>Mon, 2 Feb 2026 13:21:36 GMT</pubDate></item><item><title><![CDATA[Spring Cloud Alibaba 小憨书（仿小红书）微服务项目实战：专栏介绍]]></title><link>https://likeyy.love/archives/spring-cloud-alibaba-xiao-han-shu-fang-xiao-hong-shu-wei-fu-wu-xiang-mu-shi-zhan-zhuan-lan-jie-shao</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=Spring%20Cloud%20Alibaba%20%E5%B0%8F%E6%86%A8%E4%B9%A6%EF%BC%88%E4%BB%BF%E5%B0%8F%E7%BA%A2%E4%B9%A6%EF%BC%89%E5%BE%AE%E6%9C%8D%E5%8A%A1%E9%A1%B9%E7%9B%AE%E5%AE%9E%E6%88%98%EF%BC%9A%E4%B8%93%E6%A0%8F%E4%BB%8B%E7%BB%8D&amp;url=/archives/spring-cloud-alibaba-xiao-han-shu-fang-xiao-hong-shu-wei-fu-wu-xiang-mu-shi-zhan-zhuan-lan-jie-shao" width="1" height="1" alt="" style="opacity:0;">
<h1 id="Spring-Cloud-Alibaba-小憨书-仿小红书-微服务项目实战-专栏介绍">Spring Cloud Alibaba 小憨书（仿小红书）微服务项目实战：专栏介绍</h1>
<h2 id="项目介绍">项目介绍</h2>
<p>先来看一下小红书官网的对自己的介绍：</p>
<blockquote>
 <p>小红书是一个年轻生活方式分享平台，由毛文超和瞿芳创立于2013年。</p>
 <p>截止2019年1月，小红书用户数超过2亿，其中90后和95后是最活跃的用户群体。</p>
 <p>在小红书，用户通过短视频、图文等形式记录生活的点滴。</p>
 <p>社区每天产生数十亿次的笔记曝光，内容覆盖时尚、护肤、彩妆、美食、旅行、影视、读书、健身等各个生活方式领域。</p>
</blockquote>
<h2 id="架构图">架构图</h2>
<p>本项目不是单体架构，而是采用企业主流的 Spring Cloud Alibaba 技术栈，微服务分布式项目搞起，并且技术我一律使用最新的技术，目的就是为了让大家感受一下目前最先进的Java生态是如何的！整体架构图如下：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20260202195958720.png&amp;size=m" alt="diagram-1770033589790"></p>
<blockquote>
 <p>PS : 本专栏主要讲解后端部分。前端工程后续看情况，我大概率也是Vibe Coding，需要等后端相关接口开发完成后。（<code>uni-app</code> 暂不做实现，等后面再说）</p>
</blockquote>
<blockquote>
 <p>特别注意：该项目部分代码借鉴了知识星期博主<a href="https://www.quanxiaoha.com/column">犬小哈</a>。该博主教学十分细致，建议有条件的伙伴支持一波。</p>
</blockquote>]]></description><guid isPermaLink="false">/archives/spring-cloud-alibaba-xiao-han-shu-fang-xiao-hong-shu-wei-fu-wu-xiang-mu-shi-zhan-zhuan-lan-jie-shao</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-019c1e84-0b72-70f8-aa97-7c530e07653a-a635df20.png&amp;size=m" type="image/jpeg" length="1783363"/><category>分布式系统与微服务</category><pubDate>Mon, 2 Feb 2026 13:21:36 GMT</pubDate></item><item><title><![CDATA[Java21的虚拟线程真的完美吗？]]></title><link>https://likeyy.love/archives/java21de-xu-ni-xian-cheng-zhen-de-wan-mei-ma</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=Java21%E7%9A%84%E8%99%9A%E6%8B%9F%E7%BA%BF%E7%A8%8B%E7%9C%9F%E7%9A%84%E5%AE%8C%E7%BE%8E%E5%90%97%EF%BC%9F&amp;url=/archives/java21de-xu-ni-xian-cheng-zhen-de-wan-mei-ma" width="1" height="1" alt="" style="opacity:0;">
<h1 id="Java21的虚拟线程真的完美吗-">Java21的虚拟线程真的完美吗？</h1>
<blockquote>
 <p>锁不是不在了，只是没人能去拿。你看着它，等着它，却永远不能碰到它。这就是虚拟线程的浪漫。</p>
</blockquote>
<p>我最近的项目一律使用的 <strong>Java 21</strong>，其中一个重要原因是看中了 <strong>虚拟线程（Virtual Threads）</strong> 这一新特性。Java 21 将虚拟线程从预览阶段带入正式版，提供了一种更轻量的线程实现。据官方描述，虚拟线程是“<em>极轻量的线程，实现高吞吐并发应用所需的编写、维护和监控成本都大幅降低”</em>，其关键在于当发生阻塞时线程可以自动挂起并让出底层操作系统线程去执行其它任务。利用虚拟线程，我们希望既简化并发编程模型（避免繁琐的回调地狱或 Reactor 流水线），又提升系统在高并发场景下的吞吐性能。</p>
<p>我在重构我的一个Task Managment System基于的是最新的Spring Boot3，（写这个文章的时候，Spring Boot 4.0.1已经在两周前发布了）。该服务使用的是内嵌的Tomcat提供REST风格的接口。在Java21环境下，我手动在配置文件中开启了虚拟线程的支持，随后我直接对系统进行了一波高并发压力的测试。本以为可以看到一条完美的直线，可惜理想和现实汪往往差距很远！在有一次测试中，我发现<strong>系统吞吐量突然归零</strong>的诡异问题。</p>
<h2 id="问题复现">问题复现</h2>
<p>当时压测跑着跑着，系统突然就“停跳”了。最诡异的是，应用既没报错也没宕机，但吞吐量直接掉到了零，整个 Tomcat 就像被点穴了一样，旧请求卡死不动，新请求死活进不来。</p>
<p>翻看后台，连一条异常日志都刷不出来，安静得让人发毛。但一查监控，我发现 <strong>CLOSE_WAIT 的连接数正疯狂往上涨</strong>。这意味着客户端早就等不及断开了，可服务器这边还死死拽着连接不撒手，根本腾不出手来去处理新任务。简单说，系统还没彻底“断气”，但已经成了无法对外服务的“植物人”状态。</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownv2-e11f4599ee9ae72b0e4208012687c774_r.jpg&amp;size=m" alt="img"></p>
<p>看着监控面板上这两根走势极其离谱的曲线，我当时心里就凉了大半截。</p>
<p>大约到晚上 <strong>23:30</strong> 那会儿，本来跑得稳稳的吞吐量（就是那根蓝线）跟蹦极一样，<strong>直接来了个 90 度大跳水，瞬间躺平变 0 了。</strong> 与此同时，旁边的 <strong>CLOSE_WAIT 连接数（那根棕线）却像坐了火箭一样疯涨</strong>，把右侧纵轴都快撑爆了。</p>
<p>这画面太诡异了：系统还没挂，但已经完全“石化”了，所有请求都被死死卡在喉咙里出不来。看着这满屏的 CLOSE_WAIT，我知道系统肯定是卡在了某个环节，导致连接断不开也挪不动。<strong>没辙，只能硬着头皮赶紧上线开搞，看看这黑盒子里到底藏了什么鬼。</strong></p>
<h2 id="排查与诊断">排查与诊断</h2>
<p>既然服务没挂但又不理人，我的第一反应就是：<strong>坏了，肯定是有线程被锁死了，或者是哪里在大面积阻塞。</strong></p>
<p>我二话没说，反手就打了一个 <code>jstack</code> 想看看线程栈。可等我把 Dump 文件打开一看，整个人都懵了——<strong>“一片风平浪静”</strong>。所有的工作线程都老老实实地待在空闲状态，既没有死锁报错，也没有哪里在疯狂竞争资源。看起来 JVM 就像是在优雅地“空转”，好像完全没活干一样。</p>
<p>那种感觉特别无力，就像你明明看到病人已经快不行了，但心电图居然显示一切正常。我当时就在那儿犯嘀咕：难不成这系统还学会“隐身”了？</p>
<p>冷静下来反复推敲，我才猛地拍了一下大腿：<strong>坏了，这项目可是开了虚拟线程（Virtual Threads）的！</strong></p>
<p>这时候我才反应过来，<strong>传统的 <code>jstack</code> 工具根本抓不到虚拟线程的踪迹</strong>。在它眼里，那些真正在跑业务、被卡住的虚拟线程全都是“透明”的，我看到的那些空闲线程不过是承载它们的“搬运工”而已。</p>
<p>发现了这个盲区，我赶紧换了招式，改用 <code>jcmd Thread.dump_to_file</code>。这命令才是专门对付虚拟线程的“照妖镜”，能把那些藏在暗处的调用栈全部揪出来。顺带手，我也把堆内存（Heap Dump）给导了出来，双管齐下。</p>
<p><strong>手里攥着这份“完整版”的数据，我深吸了一口气：行了，这回我看你往哪儿躲。</strong></p>
<h2 id="深入分析">深入分析</h2>
<p>首先，新的线程Dump里揭示了一个耐人寻味的现象：<strong>存在成千上百个所谓“空白”的虚拟线程</strong>，它们被创建出来后从未真正执行过任何任务，因此没有任何堆栈信息。这些虚拟线程对象大量堆积，其数量和系统中 <code>CLOSE_WAIT</code> 连接的数量惊人地接近。也就是说，每一个未曾运行的虚拟线程，很可能对应着一个悬而未决的 socket 连接。这暗示问题出在<strong>虚拟线程被创建后没有得到调度执行</strong>，从而请求卡住、连接未关闭。</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251228215546960.png&amp;size=m" alt="image-20251228215546960"></p>
<h3 id="为什么虚拟线程全-集体罢工-了-">为什么虚拟线程全“集体罢工”了？</h3>
<p>要搞清楚这背后的鬼天气，得先聊聊虚拟线程的“潜规则”。</p>
<p>大家都知道，虚拟线程不是真的操作系统线程，它更像是一堆“小零件”，由 JVM 的调度器（Fork-Join Pool）负责把它们安装到几个真正的 <strong>载体线程（Carrier Thread）</strong> 上去跑。在我的 Spring Boot 应用里，开启虚拟线程后，Tomcat 确实变猛了——来一个请求就造一个虚拟线程。按理说，虚拟线程随勾随用，遇到阻塞就该自己“闪开”，把底下的载体线程让给别人。</p>
<p><strong>可理想很丰满，现实直接给我开了个大玩笑。</strong></p>
<p>当我翻开那份完整的 <code>jcmd</code> Dump 文件时，真相简直让我大跌眼镜：<strong>我的载体线程被“绑架”了！</strong></p>
<p>虽然我用的是虚拟线程，但在代码的某个角落，有人用了 <code>synchronized</code> 同步块。JDK 有个著名的坑：<strong>如果虚拟线程在 <code>synchronized</code> 块里卡住了，它就不会“礼让”出底下的载体线程，而是会死死地“钉”在上面。</strong> 这种现象官方叫“线程固定（Pinning）”。</p>
<p>更倒霉的是，我的服务器是 4 核的，默认也就 4 个载体线程。Dump 显示，正好有 4 个虚拟线程在竞争同一把锁时被 Pin 住了。<strong>这就好比一共就 4 个工位，被 4 个人占着位置干等，剩下的成千上万个虚拟线程只能在外面排队。</strong> Tomcat 还在不断地接新客、造新线程，但这些线程连个落脚的工位都没有，只能在队列里“活活饿死”，直到客户端等不及把连接断了，留下满地的 CLOSE_WAIT。</p>
<h3 id="到底谁拿着那把锁-">到底谁拿着那把锁？</h3>
<p>最让我头大的是，Java 21 的 <code>jcmd</code> Dump 有个硬伤：<strong>它不标注锁到底在谁手里。</strong> 我只看到一堆线程在排队：</p>
<ul>
 <li><strong>4 个被 Pin 住的虚拟线程</strong>：霸占了所有 CPU 资源（载体线程）。</li>
 <li><strong>编号为 #42 的虚拟线程</strong>：虽然也在排队，但它还没抢到工位，正处于挂起状态。</li>
 <li><strong>一个普通平台线程（#7）</strong>：这货是负责后台日志异步刷新的。</li>
</ul>
<p>这个 #7 线程的行为最诡异。它之前拿着锁去执行了一个 <code>awaitNanos()</code>（等待队列刷新），按理说等完了应该重新拿回锁继续干活。可当时的情况是：<strong>它想回也回不来了。</strong></p>
<p>为了破案，我不得不祭出 <strong>Eclipse MAT</strong> 去翻堆内存 Dump，直接查锁对象的底层状态。</p>
<p>这一查，更有意思的事情发生了：<strong>锁其实是空的！</strong> 内存显示锁的 <code>state</code> 是 0，<code>exclusiveOwnerThread</code> 也是 null。也就是说，压根没人占着锁！而排在队列最前面的正是那个挂起的虚拟线程 <strong>#42</strong>。</p>
<p>按逻辑，#42 已经被唤醒准备去拿锁了，但问题就在这里：<strong>#42 要想去拿锁，它得先被调度到一个载体线程上运行。可现在所有的载体线程都被那 4 个 Pin 住的倒霉蛋给占满了！</strong></p>
<p><strong>破案了：这成了一个死循环。</strong> #42 等着载体线程去拿锁，而载体线程被另外几个等锁的家伙死死占着不放（因为他们被 synchronized 固定住了）。大家谁也别想动，系统就这么在沉默中彻底“石化”了。</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251228220101878.png&amp;size=m" alt="image-20251228220101878"></p>
<p>这一通分析下来，我总算抓住了这个“幕后黑手”。</p>
<p>说实话，理论上锁从释放到被下一个线程接手，也就那么一瞬间的事。可偏偏在那个节骨眼上，事情卡在了一个极度尴尬的闭环里：<strong>线程 #42 虽然收到了“锁已释放”的信号，但因为它是个虚拟线程，它得先找个“工位”（载体线程）坐下来才能去伸手拿锁。</strong></p>
<p>可这时候，全公司仅有的 4 个工位，全被那 4 个卡在 <code>synchronized</code> 里的兄弟给占死了。这 4 位仁兄也在等锁释放，好赶紧干完活儿走人腾位置。</p>
<p><strong>这就是最诡异的地方：锁是空的，没人拿；想拿锁的人（#42）没位子坐，动不了；占着位子的人（那 4 个被 Pin 住的线程）又在等锁。</strong></p>
<p>大家大眼瞪小眼，谁也动弹不得。这哪是普通的死锁啊？这简直是**“一把空锁 + 四个坑位”**诱发的另类死锁。平时我们防着两个锁互相等，这次倒好，锁在等线程，线程在等锁，活生生把系统憋成了“植物人”。</p>
<h2 id="解决措施-给虚拟线程-松绑-">解决措施：给虚拟线程“松绑”</h2>
<p>既然找到了症结，剩下的就是动刀子了。既然 <code>synchronized</code> 会把虚拟线程钉死在载体线程上，那我就得让它“解绑”。</p>
<p>我的方案其实很简单，但很管用：</p>
<ol>
 <li><strong>全面“拆迁” synchronized</strong>：我把代码里所有涉及长时间阻塞、I/O 操作或者等待条件的 <code>synchronized</code> 块全部铲掉。</li>
 <li><strong>换上 ReentrantLock</strong>：改用 <code>java.util.concurrent.Lock</code> 系列的显式锁。
  <ul>
   <li><strong>为什么这么做？</strong> 因为 <code>ReentrantLock</code> 底层用的是 <code>LockSupport</code> 挂起机制，它对虚拟线程非常友好。当虚拟线程在等待 <code>ReentrantLock</code> 时，它会很自觉地把底下的载体线程让出来，绝不“占着茅坑不拉屎”。</li>
  </ul></li>
 <li><strong>优化临界区</strong>：确保所有可能磨叽的调用（比如网络请求、休眠等）都尽量挪到锁范围之外。</li>
</ol>
<p>重构完代码重新部署后，我心里其实也有点打鼓。但随后的压测数据说明了一切：<strong>那根代表吞吐量的蓝线再也没有“蹦极”过，CLOSE_WAIT 也不再乱窜。</strong> 这次经历也算给我提了个醒：虚拟线程虽然是个好东西，但要是带着旧时代的 <code>synchronized</code> 思维去玩，它真的会分分钟教你做人。</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownv2-3d6918e80cf7f86e3729146a9d514ce3_r.jpg&amp;size=m" alt="img"></p>
<h2 id="反思与展望">反思与展望</h2>
<p>这次压测翻车也算是给我上了一课：Java 21 的虚拟线程虽然香，但它和老牌的 <code>synchronized</code> 锁之间确实存在“先天性八字不合”。</p>
<p>虚拟线程的设计初衷是让我们能用写同步代码的方式，跑出异步的高性能，但这玩意儿就像一把双刃剑——如果你还没搞清楚什么是 <strong>Pinning（线程固定）</strong> 就盲目上线，它能让系统陷入比以前更隐蔽、更难排查的死锁泥潭。</p>
<p>不过，大家也别因为这一个坑就对虚拟线程失去信心。作为一项新技术，它的生态确实还在“打补丁”的过程中。我们在享受它带来的高并发红利时，确实得对现阶段的局限性保持警惕，老老实实按照官方建议把那些老旧的锁机制改一改。</p>
<p>往长远了看，这个锅终究得 JVM 自己来背。好消息是，社区已经意识到了这个问题，并开始在底层动手术了。</p>
<p>据说即将到来的 <strong>Java 24</strong>（通过 <strong>JEP 491</strong>）将彻底重构虚拟线程对同步锁的处理机制。到那时候，就算虚拟线程进了 <code>synchronized</code> 块被卡住，它也能像处理其他阻塞一样，大大方方地把底下的载体线程释放出来。这意味着，像我们这次遇到的这种“另类死锁”将从底层被彻底杜绝。</p>
<p><strong>虚拟线程的完全体依然值得期待。但在那一天到来之前，答应我，先把代码里的 <code>synchronized</code> 搜一搜，能换的就先换了吧！</strong></p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251228220417342.png&amp;size=m" alt="image-20251228220417342"></p>
<h2 id="附录-">附录：</h2>
<p>示例代码：</p>
<pre><code class="language-java">import java.time.Duration;
import java.util.concurrent.Executors;

public class VirtualThreadPinningDemo {
    // 一个共享资源，我们将使用 synchronized 来保护它
    private static final Object sharedLock = new Object();

    public static void main(String[] args) {
        // 使用虚拟线程执行器创建大量虚拟线程
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i &lt; 100; i++) {
                int taskId = i;
                executor.submit(() -&gt; performTask(taskId));
            }
        }
        System.out.println("All tasks completed.");
    }

    private static void performTask(int id) {
        System.out.printf("Task %d started on thread: %s%n", id, Thread.currentThread());

        // 情况1：在 synchronized 块内进行阻塞操作 -&gt; 会导致 PINNED
        synchronized (sharedLock) {
            System.out.printf("Task %d acquired lock. Thread: %s%n", id, Thread.currentThread());
            try {
                // 模拟一个阻塞操作，比如睡眠或I/O
                // 在 synchronized 块内睡眠，虚拟线程无法被卸载，平台线程被占用！
                Thread.sleep(Duration.ofSeconds(2));
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            System.out.printf("Task %d released lock.%n", id);
        }
        System.out.printf("Task %d finished.%n", id);
    }
}
</code></pre>
<pre><code class="language-java">Task 0 started on thread: VirtualThread[#30]/runnable@ForkJoinPool-1-worker-1
Task 0 acquired lock. Thread: VirtualThread[#30]/runnable@ForkJoinPool-1-worker-1
Task 1 started on thread: VirtualThread[#32]/runnable@ForkJoinPool-1-worker-10
Task 4 started on thread: VirtualThread[#35]/runnable@ForkJoinPool-1-worker-9
Task 5 started on thread: VirtualThread[#36]/runnable@ForkJoinPool-1-worker-5
Task 6 started on thread: VirtualThread[#37]/runnable@ForkJoinPool-1-worker-6
Task 7 started on thread: VirtualThread[#38]/runnable@ForkJoinPool-1-worker-8
Task 2 started on thread: VirtualThread[#33]/runnable@ForkJoinPool-1-worker-3
Task 8 started on thread: VirtualThread[#39]/runnable@ForkJoinPool-1-worker-7
Task 9 started on thread: VirtualThread[#40]/runnable@ForkJoinPool-1-worker-2
Task 10 started on thread: VirtualThread[#41]/runnable@ForkJoinPool-1-worker-4
Task 11 started on thread: VirtualThread[#42]/runnable@ForkJoinPool-1-worker-11
Task 12 started on thread: VirtualThread[#43]/runnable@ForkJoinPool-1-worker-13
Task 14 started on thread: VirtualThread[#45]/runnable@ForkJoinPool-1-worker-14
Task 13 started on thread: VirtualThread[#44]/runnable@ForkJoinPool-1-worker-12
Task 15 started on thread: VirtualThread[#46]/runnable@ForkJoinPool-1-worker-15
Task 16 started on thread: VirtualThread[#47]/runnable@ForkJoinPool-1-worker-16
Task 0 released lock.
Task 0 finished.
Task 17 started on thread: VirtualThread[#48]/runnable@ForkJoinPool-1-worker-1
这里就卡住不向下走了
</code></pre>
<p>但是同样的代码，在JDK 25下，运行正常：</p>
<pre><code class="language-java">Task 2 started on thread: VirtualThread[#42]/runnable@ForkJoinPool-1-worker-3
Task 2 acquired lock. Thread: VirtualThread[#42]/runnable@ForkJoinPool-1-worker-3
Task 0 started on thread: VirtualThread[#37]/runnable@ForkJoinPool-1-worker-6
Task 3 started on thread: VirtualThread[#45]/runnable@ForkJoinPool-1-worker-6
Task 1 started on thread: VirtualThread[#39]/runnable@ForkJoinPool-1-worker-6
Task 4 started on thread: VirtualThread[#47]/runnable@ForkJoinPool-1-worker-6
Task 5 started on thread: VirtualThread[#48]/runnable@ForkJoinPool-1-worker-6
Task 7 started on thread: VirtualThread[#50]/runnable@ForkJoinPool-1-worker-6
Task 8 started on thread: VirtualThread[#53]/runnable@ForkJoinPool-1-worker-6
Task 9 started on thread: VirtualThread[#54]/runnable@ForkJoinPool-1-worker-6
Task 10 started on thread: VirtualThread[#55]/runnable@ForkJoinPool-1-worker-6
Task 11 started on thread: VirtualThread[#56]/runnable@ForkJoinPool-1-worker-6
Task 12 started on thread: VirtualThread[#57]/runnable@ForkJoinPool-1-worker-6
Task 13 started on thread: VirtualThread[#58]/runnable@ForkJoinPool-1-worker-6
Task 15 started on thread: VirtualThread[#60]/runnable@ForkJoinPool-1-worker-6
Task 16 started on thread: VirtualThread[#61]/runnable@ForkJoinPool-1-worker-6
Task 14 started on thread: VirtualThread[#59]/runnable@ForkJoinPool-1-worker-6
Task 17 started on thread: VirtualThread[#62]/runnable@ForkJoinPool-1-worker-6
Task 19 started on thread: VirtualThread[#64]/runnable@ForkJoinPool-1-worker-6
Task 18 started on thread: VirtualThread[#63]/runnable@ForkJoinPool-1-worker-6
Task 6 started on thread: VirtualThread[#49]/runnable@ForkJoinPool-1-worker-6
Task 21 started on thread: VirtualThread[#67]/runnable@ForkJoinPool-1-worker-6
Task 20 started on thread: VirtualThread[#66]/runnable@ForkJoinPool-1-worker-6
Task 22 started on thread: VirtualThread[#68]/runnable@ForkJoinPool-1-worker-6
Task 23 started on thread: VirtualThread[#69]/runnable@ForkJoinPool-1-worker-6
Task 24 started on thread: VirtualThread[#70]/runnable@ForkJoinPool-1-worker-6
Task 26 started on thread: VirtualThread[#72]/runnable@ForkJoinPool-1-worker-6
Task 25 started on thread: VirtualThread[#71]/runnable@ForkJoinPool-1-worker-6
Task 27 started on thread: VirtualThread[#73]/runnable@ForkJoinPool-1-worker-6
Task 28 started on thread: VirtualThread[#74]/runnable@ForkJoinPool-1-worker-3
Task 31 started on thread: VirtualThread[#77]/runnable@ForkJoinPool-1-worker-7
Task 30 started on thread: VirtualThread[#76]/runnable@ForkJoinPool-1-worker-7
Task 32 started on thread: VirtualThread[#78]/runnable@ForkJoinPool-1-worker-7
Task 29 started on thread: VirtualThread[#75]/runnable@ForkJoinPool-1-worker-7
Task 33 started on thread: VirtualThread[#79]/runnable@ForkJoinPool-1-worker-7
Task 34 started on thread: VirtualThread[#80]/runnable@ForkJoinPool-1-worker-7
Task 35 started on thread: VirtualThread[#81]/runnable@ForkJoinPool-1-worker-7
Task 36 started on thread: VirtualThread[#82]/runnable@ForkJoinPool-1-worker-7
Task 37 started on thread: VirtualThread[#83]/runnable@ForkJoinPool-1-worker-7
Task 38 started on thread: VirtualThread[#84]/runnable@ForkJoinPool-1-worker-7
Task 39 started on thread: VirtualThread[#85]/runnable@ForkJoinPool-1-worker-7
Task 41 started on thread: VirtualThread[#87]/runnable@ForkJoinPool-1-worker-7
Task 42 started on thread: VirtualThread[#88]/runnable@ForkJoinPool-1-worker-7
Task 40 started on thread: VirtualThread[#86]/runnable@ForkJoinPool-1-worker-7
Task 43 started on thread: VirtualThread[#89]/runnable@ForkJoinPool-1-worker-7
Task 44 started on thread: VirtualThread[#90]/runnable@ForkJoinPool-1-worker-7
Task 45 started on thread: VirtualThread[#91]/runnable@ForkJoinPool-1-worker-7
Task 46 started on thread: VirtualThread[#92]/runnable@ForkJoinPool-1-worker-7
Task 47 started on thread: VirtualThread[#93]/runnable@ForkJoinPool-1-worker-7
Task 48 started on thread: VirtualThread[#94]/runnable@ForkJoinPool-1-worker-7
Task 49 started on thread: VirtualThread[#95]/runnable@ForkJoinPool-1-worker-7
Task 50 started on thread: VirtualThread[#96]/runnable@ForkJoinPool-1-worker-7
Task 51 started on thread: VirtualThread[#97]/runnable@ForkJoinPool-1-worker-7
Task 52 started on thread: VirtualThread[#98]/runnable@ForkJoinPool-1-worker-7
Task 53 started on thread: VirtualThread[#99]/runnable@ForkJoinPool-1-worker-7
Task 54 started on thread: VirtualThread[#100]/runnable@ForkJoinPool-1-worker-7
Task 55 started on thread: VirtualThread[#101]/runnable@ForkJoinPool-1-worker-7
Task 56 started on thread: VirtualThread[#102]/runnable@ForkJoinPool-1-worker-7
Task 58 started on thread: VirtualThread[#104]/runnable@ForkJoinPool-1-worker-7
Task 59 started on thread: VirtualThread[#105]/runnable@ForkJoinPool-1-worker-7
Task 57 started on thread: VirtualThread[#103]/runnable@ForkJoinPool-1-worker-7
Task 60 started on thread: VirtualThread[#106]/runnable@ForkJoinPool-1-worker-7
Task 62 started on thread: VirtualThread[#108]/runnable@ForkJoinPool-1-worker-7
Task 63 started on thread: VirtualThread[#109]/runnable@ForkJoinPool-1-worker-7
Task 61 started on thread: VirtualThread[#107]/runnable@ForkJoinPool-1-worker-7
Task 64 started on thread: VirtualThread[#110]/runnable@ForkJoinPool-1-worker-7
Task 66 started on thread: VirtualThread[#112]/runnable@ForkJoinPool-1-worker-7
Task 65 started on thread: VirtualThread[#111]/runnable@ForkJoinPool-1-worker-7
Task 67 started on thread: VirtualThread[#113]/runnable@ForkJoinPool-1-worker-7
Task 68 started on thread: VirtualThread[#114]/runnable@ForkJoinPool-1-worker-7
Task 69 started on thread: VirtualThread[#115]/runnable@ForkJoinPool-1-worker-7
Task 70 started on thread: VirtualThread[#116]/runnable@ForkJoinPool-1-worker-7
Task 71 started on thread: VirtualThread[#117]/runnable@ForkJoinPool-1-worker-7
Task 72 started on thread: VirtualThread[#118]/runnable@ForkJoinPool-1-worker-7
Task 73 started on thread: VirtualThread[#119]/runnable@ForkJoinPool-1-worker-7
Task 74 started on thread: VirtualThread[#120]/runnable@ForkJoinPool-1-worker-7
Task 75 started on thread: VirtualThread[#121]/runnable@ForkJoinPool-1-worker-7
Task 76 started on thread: VirtualThread[#122]/runnable@ForkJoinPool-1-worker-7
Task 77 started on thread: VirtualThread[#123]/runnable@ForkJoinPool-1-worker-7
Task 78 started on thread: VirtualThread[#124]/runnable@ForkJoinPool-1-worker-7
Task 79 started on thread: VirtualThread[#125]/runnable@ForkJoinPool-1-worker-7
Task 80 started on thread: VirtualThread[#126]/runnable@ForkJoinPool-1-worker-7
Task 81 started on thread: VirtualThread[#127]/runnable@ForkJoinPool-1-worker-7
Task 82 started on thread: VirtualThread[#128]/runnable@ForkJoinPool-1-worker-7
Task 83 started on thread: VirtualThread[#129]/runnable@ForkJoinPool-1-worker-7
Task 84 started on thread: VirtualThread[#130]/runnable@ForkJoinPool-1-worker-7
Task 85 started on thread: VirtualThread[#131]/runnable@ForkJoinPool-1-worker-7
Task 86 started on thread: VirtualThread[#132]/runnable@ForkJoinPool-1-worker-7
Task 88 started on thread: VirtualThread[#134]/runnable@ForkJoinPool-1-worker-7
Task 87 started on thread: VirtualThread[#133]/runnable@ForkJoinPool-1-worker-7
Task 89 started on thread: VirtualThread[#135]/runnable@ForkJoinPool-1-worker-7
Task 90 started on thread: VirtualThread[#136]/runnable@ForkJoinPool-1-worker-7
Task 91 started on thread: VirtualThread[#137]/runnable@ForkJoinPool-1-worker-7
Task 92 started on thread: VirtualThread[#138]/runnable@ForkJoinPool-1-worker-7
Task 93 started on thread: VirtualThread[#139]/runnable@ForkJoinPool-1-worker-7
Task 94 started on thread: VirtualThread[#140]/runnable@ForkJoinPool-1-worker-7
Task 95 started on thread: VirtualThread[#141]/runnable@ForkJoinPool-1-worker-8
Task 97 started on thread: VirtualThread[#143]/runnable@ForkJoinPool-1-worker-8
Task 96 started on thread: VirtualThread[#142]/runnable@ForkJoinPool-1-worker-8
Task 98 started on thread: VirtualThread[#144]/runnable@ForkJoinPool-1-worker-3
Task 99 started on thread: VirtualThread[#145]/runnable@ForkJoinPool-1-worker-3
Task 2 released lock.
Task 2 finished.
Task 0 acquired lock. Thread: VirtualThread[#37]/runnable@ForkJoinPool-1-worker-7
Task 0 released lock.
Task 0 finished.
Task 3 acquired lock. Thread: VirtualThread[#45]/runnable@ForkJoinPool-1-worker-5
Task 3 released lock.
Task 3 finished.
Task 1 acquired lock. Thread: VirtualThread[#39]/runnable@ForkJoinPool-1-worker-7
Task 1 released lock.
Task 1 finished.
Task 4 acquired lock. Thread: VirtualThread[#47]/runnable@ForkJoinPool-1-worker-5
Task 4 released lock.
Task 4 finished.
Task 5 acquired lock. Thread: VirtualThread[#48]/runnable@ForkJoinPool-1-worker-7
Task 5 released lock.
Task 5 finished.
Task 7 acquired lock. Thread: VirtualThread[#50]/runnable@ForkJoinPool-1-worker-8
Task 7 released lock.
Task 7 finished.
Task 8 acquired lock. Thread: VirtualThread[#53]/runnable@ForkJoinPool-1-worker-8
Task 8 released lock.
Task 8 finished.
Task 9 acquired lock. Thread: VirtualThread[#54]/runnable@ForkJoinPool-1-worker-6
Task 9 released lock.
Task 9 finished.
Task 10 acquired lock. Thread: VirtualThread[#55]/runnable@ForkJoinPool-1-worker-8
Task 10 released lock.
Task 10 finished.
Task 11 acquired lock. Thread: VirtualThread[#56]/runnable@ForkJoinPool-1-worker-2
Task 11 released lock.
Task 11 finished.
Task 12 acquired lock. Thread: VirtualThread[#57]/runnable@ForkJoinPool-1-worker-8
Task 12 released lock.
Task 12 finished.
Task 13 acquired lock. Thread: VirtualThread[#58]/runnable@ForkJoinPool-1-worker-7
Task 13 released lock.
Task 13 finished.
Task 15 acquired lock. Thread: VirtualThread[#60]/runnable@ForkJoinPool-1-worker-7
Task 15 released lock.
Task 15 finished.
Task 16 acquired lock. Thread: VirtualThread[#61]/runnable@ForkJoinPool-1-worker-5
Task 16 released lock.
Task 16 finished.
Task 14 acquired lock. Thread: VirtualThread[#59]/runnable@ForkJoinPool-1-worker-2
Task 14 released lock.
Task 14 finished.
Task 17 acquired lock. Thread: VirtualThread[#62]/runnable@ForkJoinPool-1-worker-8
Task 17 released lock.
Task 17 finished.
Task 19 acquired lock. Thread: VirtualThread[#64]/runnable@ForkJoinPool-1-worker-8
Task 19 released lock.
Task 19 finished.
Task 18 acquired lock. Thread: VirtualThread[#63]/runnable@ForkJoinPool-1-worker-2
Task 18 released lock.
Task 18 finished.
... 会一直正常执行，直到运行结束
</code></pre>]]></description><guid isPermaLink="false">/archives/java21de-xu-ni-xian-cheng-zhen-de-wan-mei-ma</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-cc8e9797-ab30-4862-b00e-4d6214878647-ade426d6.png&amp;size=m" type="image/jpeg" length="1773151"/><category>并发与响应式编程</category><pubDate>Sun, 28 Dec 2025 14:12:29 GMT</pubDate></item><item><title><![CDATA[Kafka常见面试题]]></title><link>https://likeyy.love/archives/kafkachang-jian-mian-shi-ti</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=Kafka%E5%B8%B8%E8%A7%81%E9%9D%A2%E8%AF%95%E9%A2%98&amp;url=/archives/kafkachang-jian-mian-shi-ti" width="1" height="1" alt="" style="opacity:0;">
<h1 id="Kafka常见面试题">Kafka常见面试题</h1>
<h2 id="如何保证Kafka消息不丢失">如何保证Kafka消息不丢失</h2>
<p>和RabbitMQ相似，在Kafka里面如果有防止消息不丢失，还是从以下3个方面入手解决。</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251226140908810.png&amp;size=m" alt="image-20251226140904517"></p>
<ul>
 <li>
  <p>防止生产者发送消息到Broker导致的消息丢失</p>
  <ol>
   <li>
    <p>设置异步发送</p>
    <pre><code class="language-java">// 同步发送
RecordMetadata recordMetadata = kafkaProducer.send(record).get();
// 异步发送
kafkaProducer.send(record, new Callback() {
    @Override
    public void onCompletion(RecordMetadata recordMetadata, Exception e) {
        if (e != null) {
            System.out.println("消息发送失败 | 记录日志");
        }
        long offset = recordMetadata.offset();
        int partition = recordMetadata.partition();
        String topic = recordMetadata.topic();
    }
});
</code></pre>
    <p>如果消息成功发送，那么回调里面就不会有异常，如果出现了异常，那么就可以在回调函数里面进行兜底操作。</p>
   </li>
   <li>
    <p>如果是由于网络波动，也可以利用kafka自己的重试机制</p>
    <pre><code class="language-java">// 设置重试次数
prop.put(ProducerConfig.RETRIES_CONFIG, 10);
</code></pre>
   </li>
  </ol>
 </li>
 <li>
  <p>在Broker中解决消息不丢失</p>
  <p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251226141806195.png&amp;size=m" alt="image-20251226141804684"></p>
  <ul>
   <li>发送确认机制ACK
    <table>
     <thead>
      <tr>
       <th><strong>确认机制</strong></th>
       <th><strong>说明</strong></th>
      </tr>
     </thead>
     <tbody>
      <tr>
       <td><strong>acks=0</strong></td>
       <td>生产者在成功写入消息之前不会等待任何来自服务器的响应，消息有丢失的风险，但是速度最快</td>
      </tr>
      <tr>
       <td><strong>acks=1（默认值）</strong></td>
       <td>只要集群首领节点收到消息，生产者就会收到一个来自服务器的成功响应</td>
      </tr>
      <tr>
       <td><strong>acks=all</strong></td>
       <td>只有当所有参与赋值的节点全部收到消息时，生产者才会收到一个来自服务器的成功响应</td>
      </tr>
     </tbody>
    </table></li>
  </ul>
 </li>
 <li>
  <p>消费者冲Broker接受消息防止消息丢失</p>
  <p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251226142640948.png&amp;size=m" alt="image-20251226142639493"></p>
  <ul>
   <li>Kafka中的分区机制是将每个主题划分为多个分区（Partition）</li>
   <li>topic分区中的消息只能由消费者组中的唯一一个消费者处理，不同的分区分配给不同的消费者（同一个消费组）</li>
   <li>不同的分区是按偏移量来存储消息的</li>
  </ul>
  <p>如图：</p>
  <p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251226143752315.png&amp;size=m" alt="image-20251226143750811"></p>
  <p>在 Kafka 消费过程中，由于<strong>定期自动提交</strong>（<code>enable.auto.commit=true</code>）是基于固定时间间隔执行的，它与实际消息处理的进度并不同步，这正是导致消息丢失或重复消费的根本原因。</p>
  <ul>
   <li>
    <p>为什么会导致消息丢失？</p>
    <p>消息丢失通常发生在**“先提交，后处理失败”**的情况下。</p>
    <p><strong>原理：</strong> 假设你设置每 5 秒自动提交一次。在第 3 秒时，消费者拉取了一批消息（如偏移量 5-10）。</p>
    <p><strong>触发丢失：</strong> 刚好在第 5 秒，Kafka 客户端自动将偏移量 10 提交到了服务端。然而，此时你的业务代码可能刚处理完偏移量 7，在处理偏移量 8 时程序崩溃（Crash）或重启。</p>
    <p><strong>后果：</strong> 当消费者恢复后，它会从上次提交的偏移量 10 开始拉取。这意味着 <strong>8、9、10 这几条消息虽然从未被成功处理，但在 Kafka 看来已经“消费完成”了</strong>，从而导致消息丢失。</p>
   </li>
   <li>
    <p>为什么会导致重复消费？</p>
    <p>重复消费通常发生在**“已处理，未提交”**的情况下。</p>
    <p><strong>原理：</strong> 同样假设 5 秒提交一次。消费者拉取了偏移量 5-10 的消息。</p>
    <p><strong>触发重复：</strong> 在第 4 秒时，你的业务代码已经<strong>成功处理了所有消息</strong>（5-10）。但在第 5 秒自动提交动作发生之前，消费者突然断开连接或触发了再均衡（Rebalance）。</p>
    <p><strong>后果：</strong> 由于偏移量 10 还没来得及提交，Kafka 服务端记录的最新偏移量仍是 5。当新的消费者接管该分区（如图片中的 P1、P2 或 P3）后，它会再次从偏移量 5 开始拉取，导致 <strong>5-10 的消息被业务系统重新处理一遍</strong>。</p>
   </li>
   <li>
    <p>解决办法：</p>
    <ol>
     <li>禁用自动提交偏移量，改为手动，采用异步和同步提交的组合。</li>
    </ol>
    <pre><code class="language-java">try {
    while (true) {
        ConsumerRecords&lt;String, String&gt; records = consumer.poll(Duration.ofMillis(1000));
        for (ConsumerRecord&lt;String, String&gt; record : records) {
            System.out.println(record.value());
            System.out.println(record.key());
        }
        // 异步提交：在循环中为了效率使用异步提交
        consumer.commitAsync();
    }
} catch (Exception e) {
    e.printStackTrace();
    System.out.println("记录错误信息: " + e);
} finally {
    try {
        // 同步提交：在消费者关闭前，确保最后一次偏移量提交成功
        consumer.commitSync();
    } finally {
        consumer.close();
    }
}
</code></pre>
   </li>
  </ul>
 </li>
</ul>
<h2 id="Kafka保证消息顺序性">Kafka保证消息顺序性</h2>
<p>应用场景：</p>
<ul>
 <li>即时单对单聊天和群聊，保证发送方消息发送顺序和接受方的顺序是一致的</li>
 <li>充值转账两个渠道在同一个时间进行余额变更，短信通知必须有顺序</li>
</ul>
<p>topic分区中消息只能由消费者组中的唯一一个处理，所以消息肯定是按照先后顺序进行处理的。但是它仅仅是保证Topic的一个分区顺序处理，不能保证跨分区的消息先后处理顺序。所以，如果需要顺序处理Topic的所有消息，那就只能提供一个分区。</p>
<pre><code class="language-java">// 指定分区发送
kafkaTemplate.send("springboot-kafka-topic", 0, "key-001", "value-0001");

// 使用相同的业务key发送
kafkaTemplate.send("springboot-kafka-topic", "key-001", "value-0001");
</code></pre>
<p>在 Kafka 中，如果你没有在发送时手动指定分区号（即上面代码的第一种方式），Kafka 会使用 <strong>分区策略（Partitioner）</strong> 来决定消息去往哪个分区。</p>
<p>当消息包含 Key 时，Kafka 默认的分区器会执行以下操作：</p>
<ol>
 <li><strong>计算哈希值</strong>：对 Key（如 <code>"key-001"</code>）进行哈希运算（通常是 <code>murmur2</code> 算法）。</li>
 <li><strong>取模运算</strong>：用计算出的哈希值对该 Topic 的<strong>分区总数</strong>进行取模。
  <ul>
   <li><strong>公式：</strong> <span class="language-math">​partition = hash(key) \pmod{numPartitions}</span></li>
  </ul></li>
</ol>
<h2 id="Kafka高可用机制">Kafka高可用机制</h2>
<ul>
 <li>集群模式</li>
</ul>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251226150719813.png&amp;size=m" alt="image-20251226150718343"></p>
<ul>
 <li>Kafka的服务器端由被称为Broker的服务进程构成，即一个Kafka集群由多个Broker组成</li>
 <li>这样如果集群中某一台机器岩机，其他机器上的Broker也依然能够对外提供服务。这其实就是Kafka提供高可用的手段之一</li>
</ul>
<h3 id="分区备份机制">分区备份机制</h3>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251226151921961.png&amp;size=m" alt="image-20251226151919825"></p>
<p>某一个topic中有三个分区P0、P1、P2</p>
<ul>
 <li>一个topic有多个分区，每个分区有多个副本，其中有一个leader，其余的是follower，副本存储在不同的broker中</li>
 <li>所有的分区副本的内容是都是相同的，如果leader发生故障时，会自动将其中一个follower提升为leader</li>
</ul>
<p>当producer发送消息的时候，Leader分区会往ISR分区和普通分区发送消息，如图所示：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251226152822915.png&amp;size=m" alt="image-20251226152821401"></p>
<p><strong>ISR (in-sync replica) 需要同步复制保存的follower</strong></p>
<p>如果leader失效后，需要选出新的leader，选举的原则如下：</p>
<ul>
 <li><strong>第一：</strong> 选举时优先从ISR中选定，因为这个列表中follower的数据是与leader同步的</li>
 <li><strong>第二：</strong> 如果ISR列表中的follower都不行了，就只能从其他follower中选取</li>
</ul>
<pre><code class="language-java">//一个topic默认分区的replication个数，不能大于集群中broker的个数。默认为1
default.replication.factor=3

//最小的ISR副本个数
min.insync.replicas=2
</code></pre>
<h2 id="Kafka数据清理机制">Kafka数据清理机制</h2>
<h3 id="Kafka文件存储机制">Kafka文件存储机制</h3>
<p>存储结构：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251226153902257.png&amp;size=m" alt="image-20251226153900846"></p>
<p>Kafka 的数据存储遵循“由大到小”的逻辑拆分，确保了系统的高伸缩性和读写效率：</p>
<ul>
 <li><strong>Topic（主题层）：</strong> 如图中顶层的 <code>hanserwei</code> 。这是一个逻辑上的分类。</li>
 <li><strong>Partition（分区层）：</strong> 一个 Topic 可以分为多个 Partition（如 <code>hanserwei-0</code>, <code>hanserwei-1</code>, <code>hanserwei-2</code>）。在物理磁盘上，每个 Partition 对应一个单独的<strong>文件夹</strong>。</li>
 <li><strong>Segment（日志分段层）：</strong> 每个 Partition 文件夹下又被切分为多个 <strong>Segment</strong>（如 <code>Segment 0</code>, <code>Segment 1</code>）。</li>
</ul>
<p>为什么要引入 Segment（分段机制）？</p>
<p>根据图中的描述，将 Partition 进一步切分为 Segment 主要有两个核心目的：</p>
<ul>
 <li><strong>删除无用文件方便，提高磁盘利用率：</strong> Kafka 的数据是有过期时间的。通过分段，可以更轻松地删除旧的 Segment 文件，而不会影响正在写入的活跃文件。</li>
 <li><strong>查找数据便捷：</strong> 较小的文件配合索引，可以大幅提高定位特定偏移量（Offset）数据的速度。</li>
</ul>
<p>Segment 的物理组成</p>
<p>每个 Segment 并不是单一的文件，而是由一组具有相同文件名的不同后缀文件组成：</p>
<table>
 <thead>
  <tr>
   <th><strong>文件后缀</strong></th>
   <th><strong>名称</strong></th>
   <th><strong>作用</strong></th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><strong>.log</strong></td>
   <td><strong>数据文件</strong></td>
   <td>实际存储消息内容的地方。</td>
  </tr>
  <tr>
   <td><strong>.index</strong></td>
   <td><strong>索引文件</strong></td>
   <td>偏移量索引，帮助快速根据 Offset 找到对应的消息在 <code>.log</code> 中的物理位置。</td>
  </tr>
  <tr>
   <td><strong>.timeindex</strong></td>
   <td><strong>时间索引文件</strong></td>
   <td>根据时间戳查找消息位置的索引文件。</td>
  </tr>
 </tbody>
</table>
<h3 id="数据清理机制">数据清理机制</h3>
<p>日志的清理策略有两个</p>
<p><strong>1. 根据消息的保留时间</strong></p>
<p>当消息在 Kafka 中保存的时间超过了指定的时间，就会触发清理过程。</p>
<pre><code class="language-bash"># The minimum age of a log file to be eligible for deletion due to age
# 默认保留时间（示例为 168 小时，即 7 天）
log.retention.hours=168
</code></pre>
<p><strong>2.根据 Topic 存储的数据大小</strong></p>
<p>当 Topic 所占的日志文件大小大于一定的阈值，则开始删除最久的消息。需手动开启。</p>
<pre><code class="language-bash"># A size-based retention policy for logs. Segments are pruned from the log 
# unless the remaining segments drop below log.retention.bytes. 
# Functions independently of log.retention.hours.
# 示例阈值为 1073741824 字节（即 1GB）
#log.retention.bytes=1073741824
</code></pre>
<h2 id="Kafka为什么快">Kafka为什么快</h2>
<p><strong>消息分区（Partitioning）</strong></p>
<ul>
 <li><strong>提取：</strong> 不受单台服务器的限制，可以不受限的处理更多的数据。</li>
 <li><strong>补充：</strong> 分区是 Kafka <strong>横向扩展（Scalability）</strong> 的基础。通过将一个 Topic 分成多个 Partition 分布在不同的 Broker（服务器）上，实现了<strong>并发读写</strong>。这也是负载均衡的关键。</li>
</ul>
<p><strong>顺序读写（Sequential I/O）</strong></p>
<ul>
 <li><strong>提取：</strong> 磁盘顺序读写，提升读写效率。</li>
 <li><strong>补充：</strong> 磁盘随机读写速度很慢，但顺序读写的性能接近内存。Kafka 采用 <strong>Append-only（只追加）</strong> 模式写入日志文件，避免了磁盘机械臂的频繁寻道，极大地提升了 I/O 吞吐。</li>
</ul>
<p><strong>页缓存（Page Cache）</strong></p>
<ul>
 <li><strong>提取：</strong> 把磁盘中的数据缓存到内存中，把对磁盘的访问变为对内存的访问。</li>
 <li><strong>补充：</strong> Kafka 并不在 JVM 堆内存中缓存数据，而是利用 <strong>操作系统层面的 Page Cache</strong>。这样做避免了 JVM GC（垃圾回收）带来的性能开销，即使服务重启，缓存依然存在。</li>
</ul>
<p><strong>零拷贝（Zero Copy）</strong></p>
<ul>
 <li><strong>提取：</strong> 减少上下文切换及数据拷贝。</li>
 <li><strong>补充：</strong> 主要通过 Linux 的 <code>sendfile</code> 系统调用实现。数据直接从“页缓存”传输到“网卡接口”，无需经过“用户态”内存，减少了 CPU 在内核态与用户态之间的切换次数。</li>
</ul>
<p><strong>消息压缩（Message Compression）</strong></p>
<ul>
 <li><strong>提取：</strong> 减少磁盘 IO 和网络 IO。</li>
 <li><strong>补充：</strong> 支持 Snappy、Gzip、LZ4 和 Zstd 等算法。压缩通常在生产者端完成，在消费者端解压，能显著降低网络带宽占用并节省磁盘存储空间。</li>
</ul>
<p><strong>分批发送（Batching）</strong></p>
<ul>
 <li><strong>提取：</strong> 将消息打包批量发送，减少网络开销。</li>
 <li><strong>补充：</strong> 通过攒够一定数量的消息或达到设定时间后再统一发送，大幅减少了网络请求的次数（RTT）和 TCP 报文头的比例，提高了整体的有效载荷。</li>
</ul>
<h3 id="零拷贝">零拷贝</h3>
<p>如果不使用零拷贝技术，那么Kafka发送一条消息的过程如下：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251226161512073.png&amp;size=m" alt="image-20251226161510636"></p>
<p>拷贝次数：磁盘文件--&gt;页缓存--&gt;Kafka--&gt;Socket缓存区--&gt;网卡，一共四次拷贝</p>
<p>如果使用零拷贝，那么流程如下：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251226161809580.png&amp;size=m" alt="image-20251226161807556"></p>
<p>拷贝次数：磁盘文件--&gt;页缓存--&gt;网卡，就只需要两次拷贝即可。</p>]]></description><guid isPermaLink="false">/archives/kafkachang-jian-mian-shi-ti</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-422e2067-315a-4366-b41d-2e3e2736e538-9a071c6c.png&amp;size=m" type="image/jpeg" length="1501957"/><category>消息队列</category><pubDate>Sat, 27 Dec 2025 01:26:45 GMT</pubDate></item><item><title><![CDATA[RabbitMQ常见面试题]]></title><link>https://likeyy.love/archives/rabbitmqchang-jian-mian-shi-ti</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=RabbitMQ%E5%B8%B8%E8%A7%81%E9%9D%A2%E8%AF%95%E9%A2%98&amp;url=/archives/rabbitmqchang-jian-mian-shi-ti" width="1" height="1" alt="" style="opacity:0;">
<h1 id="RabbitMQ常见面试题">RabbitMQ常见面试题</h1>
<h2 id="RabbitMQ如何保证消息不丢失">RabbitMQ如何保证消息不丢失</h2>
<h3 id="RabbitMQ消息丢失的三种情况">RabbitMQ消息丢失的三种情况</h3>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251225114231623.png&amp;size=m" alt="image-20251225114230266"></p>
<p>消息丢失的三种情况：</p>
<ol>
 <li>消息未到达交换机</li>
 <li>消息到达交换机但是未到达队列，或到达队列但是未莱得及消费</li>
 <li>消费者未接收到消息</li>
</ol>
<p>解决以上问题，可以从以下三个方面解决</p>
<h4 id="生产者确认机制">生产者确认机制</h4>
<p>RabbitMQ提供了publisher confirm机制来避免消息发送到MQ过程中丢失。消息发送到MQ以后，会返回一个结果给发送者，表示消息是否处理成功。</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251225114950987.png&amp;size=m" alt="image-20251225114948932"></p>
<p>消息失败之后如何处理？</p>
<ul>
 <li>回调方法即时重发</li>
 <li>记录日志</li>
 <li>保留数据到数据库，定时重发，成功后删除表中数据</li>
</ul>
<h4 id="消息持久化">消息持久化</h4>
<p>MQ默认是内存存储的，开启持久化功能可以确保缓存到MQ中的消息不丢失。</p>
<ol>
 <li>
  <p>交换机持久化 (Exchange Persistence)</p>
  <pre><code class="language-java">@Bean
public DirectExchange simpleExchange() {
    // 三个参数：交换机名称、是否持久化、当没有 queue 与其绑定时是否自动删除
    return new DirectExchange("simple.direct", true, false);
}
</code></pre>
 </li>
 <li>
  <p>队列持久化 (Queue Persistence)</p>
  <pre><code class="language-java">@Bean
public Queue simpleQueue() {
    // 使用 QueueBuilder 构建队列，durable 就是持久化的
    return QueueBuilder.durable("simple.queue").build();
}
</code></pre>
 </li>
 <li>
  <p>消息持久化 (Message Persistence)</p>
  <p>SpringAMQP 中的消息默认是持久化的，可以通过 <code>MessageProperties</code> 中的 <code>DeliveryMode</code> 来指定。</p>
  <pre><code class="language-java">Message msg = MessageBuilder
    .withBody(message.getBytes(StandardCharsets.UTF_8)) // 消息体
    .setDeliveryMode(MessageDeliveryMode.PERSISTENT) // 持久化
    .build();
</code></pre>
 </li>
</ol>
<p><strong>说明：</strong></p>
<ul>
 <li><strong>交换机持久化</strong>：确保 MQ 重启后交换机依然存在。</li>
 <li><strong>队列持久化</strong>：确保 MQ 重启后队列依然存在。</li>
 <li><strong>消息持久化</strong>：确保 MQ 重启后，队列中的消息依然存在（前提是交换机和队列也必须持久化）。</li>
</ul>
<h4 id="消费者确认">消费者确认</h4>
<p>RabbitMQ支持消费者确认机制，即：消费者处理消息之后可以想MQ发送ACK回执。MQ收到ACK回执之后才删除该消息。而SpingAMQP则允许配置三种确认方式：</p>
<ul>
 <li><strong>manual</strong>: 手动ack，需要在业务代码结束后，调用api发送ack。</li>
 <li><strong>auto</strong>: 自动ack，由spring监测listener代码是否出现异常，没有异常则返回ack；抛出异常则返回nack</li>
 <li><strong>none</strong>: 关闭ack，MQ假定消费者获取消息后会成功处理，因此消息投递后立即被删除</li>
</ul>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251225120041873.png&amp;size=m" alt="image-20251225120039828"></p>
<p>我们可以使用Spring的retry机制，在消费者出现异常的时候利用本地重试，设置重试次数，当次数达到上限之后，如果消息依然没有成功被消费，那么就把消息投递到异常交换机，然后交由人工处理。</p>
<h2 id="RabbitMQ如何防止消息重复消费">RabbitMQ如何防止消息重复消费</h2>
<h3 id="1--利用数据库唯一索引-Unique-Index-">1. 利用数据库唯一索引（Unique Index）</h3>
<p>这是最简单直接的方法。如果你的业务是将数据写入数据库，可以利用数据库的强一致性。</p>
<ul>
 <li><strong>做法</strong>：在表中设置一个业务相关的<strong>唯一键</strong>（如 <code>order_id</code>）。</li>
 <li><strong>效果</strong>：当重复的消息到达时，执行 <code>INSERT</code> 操作会触发唯一索引冲突，数据库报错。你只需要捕获异常并忽略即可。</li>
</ul>
<h3 id="2--利用-Redis-的原子性操作-去重表机制-">2. 利用 Redis 的原子性操作（去重表机制）</h3>
<p>如果你的业务不是简单的数据库写入（比如调用第三方 API 或发送邮件），可以使用 Redis 来做拦截。</p>
<ul>
 <li><strong>流程</strong>：
  <ol>
   <li>每条消息在生产者发送时，附带一个<strong>全局唯一 ID</strong>（如 UUID 或雪花 ID）。</li>
   <li>消费者接收到消息后，先去 Redis 查一下这个 ID 是否存在：
    <ul>
     <li><strong>不存在</strong>：说明没消费过。将 ID 存入 Redis（可设置过期时间），然后执行业务逻辑。</li>
     <li><strong>存在</strong>：说明是重复消息，直接丢弃。</li>
    </ul></li>
  </ol></li>
 <li><strong>注意</strong>：为了防止“逻辑执行了一半挂掉”导致后续无法重试，建议结合 <strong>Lua 脚本</strong> 或 <strong>分布式锁</strong> 来保证查重与写入的原子性。</li>
</ul>
<h3 id="3--状态机幂等-Status-Machine-">3. 状态机幂等（Status Machine）</h3>
<p>适用于有明显状态流转的业务（如订单系统）。</p>
<ul>
 <li>
  <p><strong>做法</strong>：在更新数据时，带上状态条件。</p>
  <blockquote>
   <p><code>UPDATE orders SET status = 'PAID' WHERE id = 123 AND status = 'UNPAID';</code></p>
  </blockquote>
 </li>
 <li>
  <p><strong>效果</strong>：如果消息重复触发，第二次执行时 <code>status</code> 已经是 <code>PAID</code> 了，SQL 不会更新任何行，从而保证了业务正确性。</p>
 </li>
</ul>
<hr>
<h3 id="4--RabbitMQ-的--标志位">4. RabbitMQ 的 <code>redelivered</code> 标志位</h3>
<p>RabbitMQ 协议层面提供了一个辅助工具。</p>
<ul>
 <li><strong>原理</strong>：当消息是第二次及以上投递给消费者时，RabbitMQ 会在消息头中将 <code>redelivered</code> 字段设为 <code>true</code>。</li>
 <li><strong>策略</strong>：
  <ul>
   <li>如果 <code>redelivered</code> 为 <code>false</code>：直接消费。</li>
   <li>如果 <code>redelivered</code> 为 <code>true</code>：进入“去重检查”逻辑（如查 Redis），这样可以减少对外部缓存的压力。</li>
  </ul></li>
</ul>
<blockquote>
 <p>实际业务当中，一般都是采用的数据库唯一索引来保证的，无论咋操作，你的数据最后都是落库。</p>
</blockquote>
<h2 id="RabbitMQ中死信交换机-延时队列-">RabbitMQ中死信交换机（延时队列）</h2>
<h3 id="死信交换机">死信交换机</h3>
<p>当一个队列中的消息满足下列情况之一时，可以成为死信：</p>
<ul>
 <li><strong>消息被拒绝</strong>：消费者使用了 <code>basic.reject</code> 或 <code>basic.nack</code>，且设置 <code>requeue=false</code>。</li>
 <li><strong>消息过期</strong>：消息达到了存活时间（TTL）。</li>
 <li><strong>队列达到最大长度</strong>：最早的消息会被丢弃或变成死信。</li>
</ul>
<p>如果该队列配置了dead-letter-exchange属性，指定了一个交换机，那么队列中的死信就会投递到这个交换机中，而这个交换机就被称为死信交换机（Dead Letter Exchange，简称DLX）。</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251225142509822.png&amp;size=m" alt="image-20251225142508820"></p>
<p>代码示例：</p>
<pre><code class="language-java">@Bean
public Queue ttlQueue(){
    return QueueBuilder.durable("simple.queue") // 指定队列名称，并持久化
            .ttl(10000) // 设置队列的超时时间，10秒
            .deadLetterExchange("dl.direct") // 指定死信交换机
            .build();
}
</code></pre>
<h3 id="TTL">TTL</h3>
<p><strong>TTL，也就是 Time-To-Live。如果一个队列中的消息 TTL 结束仍未消费，则会变为死信，TTL 超时分为两种情况：</strong></p>
<ul>
 <li><strong>消息所在的队列设置了存活时间</strong></li>
 <li><strong>消息本身设置了存活时间</strong></li>
</ul>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251225142915104.png&amp;size=m" alt="image-20251225142914115"></p>
<p>发信的时候可以携带一个过期时间，队列本身也可以设置一个过期时间，两者以较小的为准。到时间就自动成为死信。</p>
<pre><code class="language-java">// 创建消息
Message message = MessageBuilder
        .withBody("hello, ttl message".getBytes(StandardCharsets.UTF_8))
        .setExpiration("5000")
        .build();

// 消息ID，需要封装到CorrelationData中
CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());

// 发送消息
rabbitTemplate.convertAndSend("ttl.direct", "ttl", message, correlationData);
</code></pre>
<h2 id="延迟队列插件">延迟队列插件</h2>
<p><strong>DelayExchange插件，需要安装在 RabbitMQ 中</strong></p>
<p>RabbitMQ 有一个官方的插件社区，地址为：https://www.rabbitmq.com/community-plugins.html</p>
<p><strong>rabbitmq_delayed_message_exchange</strong></p>
<blockquote>
 <p>A plugin that adds <strong>delayed-messaging</strong> (or <strong>scheduled-messaging</strong>) to RabbitMQ.</p>
 <ul>
  <li>Releases</li>
  <li>Author: <strong>Alvaro Videla</strong></li>
  <li>GitHub: <a href="https://github.com/rabbitmq/rabbitmq-delayed-message-exchange">rabbitmq/rabbitmq-delayed-message-exchange</a></li>
 </ul>
</blockquote>
<p>DelayExchange 的本质还是官方的三种交换机，只是添加了延迟功能。 因此使用时只需要声明一个交换机，交换机的类型可以是任意类型，然后设定 <strong>delayed</strong> 属性为 <strong>true</strong> 即可。</p>
<pre><code class="language-java">@RabbitListener(bindings = @QueueBinding(
    value = @Queue(name = "delay.queue", durable = "true"),
    exchange = @Exchange(name = "delay.direct", delayed = "true"),
    key = "delay"
))
public void listenDelayedQueue(String msg){
    log.info("接收到 delay.queue 的延迟消息：{}", msg);
}
</code></pre>
<h2 id="RabbitMQ如果有100万消息积压如何解决">RabbitMQ如果有100万消息积压如何解决</h2>
<p>当生产者发送消息的速度超过了消费者处理消息的速度，就会导致队列中消息积压，直到队列存储的消息达到上限。之后发送的消息就会成为死信，可能被丢弃，这就是消息堆积的问题。</p>
<p>解决消息堆积一般有三种思路:</p>
<ol>
 <li>增加更多消费者，提高消费速度</li>
 <li>在消费者内开启线程池来消费加快处理速度</li>
 <li>扩大队列容量，提高堆积上限</li>
</ol>
<h3 id="惰性队列">惰性队列</h3>
<p><strong>文字内容：</strong> <strong>惰性队列的特征如下：</strong></p>
<ul>
 <li>接收到消息后直接存入磁盘而非内存</li>
 <li>消费者要消费消息时才会从磁盘中读取并加载到内存</li>
 <li>支持数百万条的消息存储</li>
</ul>
<ol>
 <li>
  <p>通过 Bean 声明惰性队列：</p>
  <pre><code class="language-java">@Bean
public Queue lazyQueue(){
    return QueueBuilder
            .durable("lazy.queue")
            .lazy() // 开启 x-queue-mode 为 lazy
            .build();
}
</code></pre>
 </li>
 <li>
  <p>通过注解声明并监听惰性队列：</p>
  <pre><code class="language-java">@RabbitListener(queuesToDeclare = @Queue(
    name = "lazy.queue",
    durable = "true",
    arguments = @Argument(name = "x-queue-mode", value = "lazy")
))
public void listenLazyQueue(String msg){
    log.info("接收到 lazy.queue 的消息：{}", msg);
}
</code></pre>
 </li>
</ol>
<h2 id="RabbitMQ的高可用机制">RabbitMQ的高可用机制</h2>
<ul>
 <li>在生产环境下，使用集群来保证高可用性</li>
 <li>普通集群、镜像集群、仲裁集群</li>
</ul>
<h3 id="普通集群">普通集群</h3>
<p>普通集群，或者叫标准集群（classic cluster），具备下列特征：</p>
<ul>
 <li>会在集群的各个节点间共享部分数据，包括：交换机、队列元信息。不包含队列中的消息。</li>
 <li>当访问集群某节点时，如果队列不在该节点，会从数据所在节点传递到当前节点并返回</li>
 <li>队列所在节点宕机，队列中的消息就会丢失</li>
</ul>
<blockquote>
 <p>一般不使用这个集群方式</p>
</blockquote>
<h3 id="镜像集群">镜像集群</h3>
<p><strong>镜像集群：本质是主从模式，具备下面的特征：</strong></p>
<ul>
 <li><strong>交换机、队列、队列中的消息</strong>会在各个 mq 的镜像节点之间同步备份。</li>
 <li>创建队列的节点被称为该队列的<strong>主节点</strong>，备份到的其它节点叫做该队列的<strong>镜像节点</strong>。</li>
 <li>一个队列的主节点可能是另一个队列的镜像节点。</li>
 <li><strong>所有操作都是主节点完成</strong>，然后同步给镜像节点。</li>
 <li><strong>主宕机后</strong>，镜像节点会替代成新的主。</li>
</ul>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251225151456950.png&amp;size=m" alt="image-20251225151455449"></p>
<h3 id="仲裁队列">仲裁队列</h3>
<p><strong>仲裁队列</strong>：仲裁队列是 3.8 版本以后才有新功能，用来替代镜像队列，具备下列特征：</p>
<ul>
 <li>与镜像队列一样，都是主从模式，支持主从数据同步</li>
 <li>使用非常简单，没有复杂的配置</li>
 <li>主从同步基于 Raft 协议，强一致</li>
</ul>
<p>Java (Spring AMQP) 中声明仲裁队列的代码示例：</p>
<pre><code class="language-java">@Bean
public Queue quorumQueue() {
    return QueueBuilder
            .durable("quorum.queue") // 持久化
            .quorum() // 仲裁队列
            .build();
}
</code></pre>]]></description><guid isPermaLink="false">/archives/rabbitmqchang-jian-mian-shi-ti</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-625e6c5c-0273-43c7-935f-a78340654378-d979b0cd.png&amp;size=m" type="image/jpeg" length="1925748"/><category>消息队列</category><pubDate>Sat, 27 Dec 2025 01:26:40 GMT</pubDate></item><item><title><![CDATA[Spring框架常见面试题]]></title><link>https://likeyy.love/archives/springkuang-jia-chang-jian-mian-shi-ti</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=Spring%E6%A1%86%E6%9E%B6%E5%B8%B8%E8%A7%81%E9%9D%A2%E8%AF%95%E9%A2%98&amp;url=/archives/springkuang-jia-chang-jian-mian-shi-ti" width="1" height="1" alt="" style="opacity:0;">
<h1 id="spring框架常见面试题">Spring框架常见面试题</h1>
<h2 id="spring框架中的单例bean是线程安全的吗">Spring框架中的单例bean是线程安全的吗？</h2>
<p>答案显然是<strong>否</strong>，但是为啥我们平时使用的是并没刻意去注意线程安全问题呢？其实最主要的原因是在于我们平时使用的时候大多注入的是无状态的Bean对象，无状态的Bean对象天然就是线程安全的。</p>
<h3 id="无状态和有状态">无状态和有状态</h3>
<p>简单来说：<strong>无状态 Bean 像自动售货机（谁来都一样），有状态 Bean 像私人理发师（记得你的具体偏好）。</strong></p>
<ol>
 <li>核心区别对比表</li>
</ol>
<table>
 <thead>
  <tr>
   <th><strong>特性</strong></th>
   <th><strong>无状态 Bean (Stateless)</strong></th>
   <th><strong>有状态 Bean (Stateful)</strong></th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><strong>定义</strong></td>
   <td>不保存特定客户端的数据，每次调用都是独立的。</td>
   <td>保存了特定客户端或会话的数据，方法调用之间有关联。</td>
  </tr>
  <tr>
   <td><strong>实例变量</strong></td>
   <td>通常没有成员变量，或者只有不可变的配置/依赖（如 Service, DAO）。</td>
   <td>有成员变量用于存储数据（如用户购物车、计数器）。</td>
  </tr>
  <tr>
   <td><strong>线程安全</strong></td>
   <td><strong>天然线程安全</strong>。多个线程可以共享同一个实例。</td>
   <td><strong>线程不安全</strong>。通常不能在多线程间共享同一个实例。</td>
  </tr>
  <tr>
   <td><strong>作用域 (Scope)</strong></td>
   <td><strong>Singleton</strong> (单例) - 全局只需要一个实例。</td>
   <td><strong>Prototype</strong> (原型), <strong>Session</strong>, <strong>Request</strong> - 每个用户/请求需要新实例。</td>
  </tr>
  <tr>
   <td><strong>性能/开销</strong></td>
   <td>性能高，内存占用小（实例少）。</td>
   <td>开销较大，需要创建和销毁大量实例。</td>
  </tr>
  <tr>
   <td><strong>典型场景</strong></td>
   <td>Service 层, DAO 层, Controller (大多情况), 工具类。</td>
   <td>购物车, 向导式表单, 用户会话信息 (Session)。</td>
  </tr>
 </tbody>
</table>
<ol start="2">
 <li>无状态 Bean (Stateless Bean)</li>
</ol>
<p><strong>定义：</strong> 一个 Bean 如果不包含任何<strong>可变的</strong>成员变量（Instance Variable），或者虽然有成员变量但它们是不可变的（如注入的 Service 或 DAO），那么它就是无状态的。</p>
<p><strong>特点：</strong></p>
<ul>
 <li><strong>一次性逻辑</strong>：它的方法执行只依赖于传入的参数，不依赖于上一次调用的结果。</li>
 <li><strong>高并发友好</strong>：因为没有“私有数据”，所以在 Spring 中，默认的 Bean（Singleton）都是无状态的。Spring 容器只需要创建一个实例，就可以服务成千上万个并发请求。</li>
</ul>
<pre><code class="language-java">// 这是一个典型的无状态 Bean
@Service
public class UserService {

    // 这是一个依赖，通常是单例且不可变的，不属于"用户状态"
    @Autowired
    private UserRepository userRepository;

    public User findUser(Long id) {
        // 方法内的局部变量是线程安全的（栈内存），不会影响 Bean 的状态
        return userRepository.findById(id);
    }
}
</code></pre>
<ol start="3">
 <li>
  <p>有状态 Bean (Stateful Bean)</p>
  <p><strong>定义：</strong> 一个 Bean 如果包含<strong>成员变量</strong>，并且这些变量用于存储特定客户端在多次调用之间产生的数据，那么它就是有状态的。</p>
  <p><strong>特点：</strong></p>
  <ul>
   <li><strong>记忆能力</strong>：它“记得”你上一次调用做了什么。</li>
   <li><strong>独占性</strong>：张三的 Bean 不能给李四用，否则李四会看到张三的数据。因此，通常需要为每个用户创建一个新的 Bean 实例（<code>@Scope("prototype")</code>）。</li>
  </ul>
  <pre><code class="language-java">// 这是一个有状态的 Bean
// 注意：在 Spring 单例模式下直接这样写会导致严重的线程安全问题！
// 除非将其 Scope 设置为 prototype 或 session
@Component
@Scope("prototype") 
public class ShoppingCart {

    // 这是一个状态！每个用户的购物车内容不同
    private List&lt;String&gt; items = new ArrayList&lt;&gt;();

    public void addItem(String item) {
        this.items.add(item);
    }

    public List&lt;String&gt; getItems() {
        return this.items;
    }
}
</code></pre>
 </li>
</ol>
<blockquote>
 <p>注意一下：在日常开发过程中，一般都是直接使用无状态的Bean即可，有状态的Bean很少使用，至少我还没用过。</p>
</blockquote>
<h2 id="aop">AOP</h2>
<p>概念：AOP称为面向切面编程，用于将那些与业务无关，但却对多个对象产生影响的公共行为和逻辑，抽取并封装为一个可重用的模块，这个模块被命名为“切面”（Aspect），减少系统中的重复代码，降低了模块间的耦合度，同时提高了系统的可维护性。</p>
<ul>
 <li>常见的AOP使用场景：
  <ul>
   <li>记录操作日志</li>
   <li>缓存处理</li>
   <li>Spring中内置的事务处理</li>
  </ul></li>
</ul>
<p>示例：</p>
<h3 id="第一步引入依赖">第一步：引入依赖</h3>
<p>如果你使用的是 <strong>Spring Boot</strong>，只需要引入 AOP 的 Starter，它会自动配置好 AspectJ 相关的库。</p>
<p>maven：</p>
<pre><code class="language-xml">&lt;dependency&gt;
    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
    &lt;artifactId&gt;spring-boot-starter-aop&lt;/artifactId&gt;
&lt;/dependency&gt;
</code></pre>
<p>gradle：</p>
<pre><code class="language-groovy">implementation 'org.springframework.boot:spring-boot-starter-aop'
</code></pre>
<h3 id="第二步定义切面类-aspect">第二步：定义切面类 (Aspect)</h3>
<p>创建一个普通的 Java 类，加上 <code>@Aspect</code> 和 <code>@Component</code> 注解。</p>
<ul>
 <li><code>@Aspect</code>：告诉 Spring 这是一个切面类。</li>
 <li><code>@Component</code>：将其注册为 Spring 容器中的一个 Bean。</li>
</ul>
<pre><code class="language-java">import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Aspect
@Component
public class LogAspect {

    private final Logger logger = LoggerFactory.getLogger(LogAspect.class);

    /**
     * 1. 定义切点 (Pointcut)
     * 表达式含义：execution(修饰符 返回值 包名.类名.方法名(参数))
     * 这里表示：拦截 com.example.service 包下所有类的所有方法
     */
    @Pointcut("execution(* com.example.service..*.*(..))")
    public void serviceLayer() {}

    /**
     * 2. 前置通知 (Before Advice)
     * 在目标方法执行之前执行
     */
    @Before("serviceLayer()")
    public void doBefore() {
        logger.info("========== 开始执行 Service 方法 ==========");
    }

    /**
     * 3. 环绕通知 (Around Advice) - 最常用，功能最强
     * 它可以控制目标方法是否执行，还能修改返回值，计算时间等
     */
    @Around("serviceLayer()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        
        // 获取方法签名
        String methodName = joinPoint.getSignature().getName();
        // 获取参数
        Object[] args = joinPoint.getArgs();

        try {
            // *** 核心：执行目标方法 ***
            Object result = joinPoint.proceed(); 
            
            long end = System.currentTimeMillis();
            logger.info("方法 [{}] 执行耗时: {} ms", methodName, (end - start));
            
            return result; // 返回目标方法的执行结果
        } catch (Throwable e) {
            logger.error("方法 [{}] 执行出错: {}", methodName, e.getMessage());
            throw e; // 抛出异常，否则调用方以为执行成功了
        }
    }
}
</code></pre>
<h3 id="第三步理解核心概念-关键词">第三步：理解核心概念 (关键词)</h3>
<p>为了能灵活使用，你需要理解上面代码中的几个关键术语：</p>
<ol>
 <li><strong>切面 (Aspect)</strong>：也就是上面的 <code>LogAspect</code> 类，是包含切点和通知的模块。</li>
 <li><strong>切点 (Pointcut)</strong>：定义“在哪里”执行切面逻辑。
  <ul>
   <li>常用表达式：<code>execution(* com.service.*.*(..))</code></li>
   <li>另一种常用方式：<code>@annotation(com.example.MyLog)</code> (只拦截加了特定注解的方法)。</li>
  </ul></li>
 <li><strong>通知 (Advice)</strong>：定义“做什么”以及“什么时候做”。
  <ul>
   <li><code>@Before</code>：方法前。</li>
   <li><code>@AfterReturning</code>：方法成功返回后。</li>
   <li><code>@AfterThrowing</code>：抛出异常后。</li>
   <li><code>@After</code>：无论成功失败，最终都会执行（类似 finally）。</li>
   <li><code>@Around</code>：<strong>最强大</strong>，包围整个方法，能决定是否执行目标方法。</li>
  </ul></li>
 <li><strong>连接点 (JoinPoint)</strong>：程序执行的某个具体位置。在 Spring AOP 中，总是指方法的执行。</li>
</ol>
<blockquote>
 <p>实际开发中我一般还是会配合注解来是先AOP。步骤也很简单：</p>
 <p>第一步定义注解：</p>
 <pre><code class="language-java">@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoLog {
    String value() default "";
}
</code></pre>
 <p>第二步修改切点表达式：</p>
 <pre><code class="language-java">// 只要方法上加了 @AutoLog 注解，就会被拦截
@Pointcut("@annotation(com.example.annotation.AutoLog)")
public void annotationPointcut() {}
</code></pre>
 <p>第三步使用：</p>
 <pre><code class="language-java">@Service
public class UserService {
    
    @AutoLog("查询用户详情") // 只有加了这个注解的方法才会被 AOP 处理
    public User getUser(Long id) {
        return userRepository.findById(id);
    }
}
</code></pre>
</blockquote>
<blockquote>
 <p>注意事项 (非常重要！)</p>
 <ol>
  <li><strong>AOP 失效问题（自调用）</strong>： Spring AOP 是基于<strong>代理 (Proxy)</strong> 实现的。如果在同一个类中，方法 A 调用了方法 B，而方法 B 上配了 AOP，<strong>AOP 是不会生效的</strong>。
   <ul>
    <li><em>原因</em>：<code>this.methodB()</code> 是直接调用对象内部方法，绕过了 Spring 生成的代理对象。</li>
   </ul></li>
  <li><strong>访问权限</strong>： AOP 通常只能拦截 <code>public</code> 方法。虽然通过配置可以拦截 protected/private，但不推荐。</li>
 </ol>
</blockquote>
<h2 id="spring事务管理以及事务失效场景">Spring事务管理以及事务失效场景</h2>
<p>详情见：<a href="https://likeyy.love/archives/liao-liao-transactionalzhu-jie-he-shi-wu-de-shi-yong">聊聊@Transactional注解和事务的使用</a></p>
<h2 id="spring中bean的生命周期">Spring中Bean的生命周期</h2>
<p>这个问题是牢中牢，太夸张了。我在这里梳理一下这个问题的答案。</p>
<p>我们先看一个Bean的生命周期主要包括哪些部分：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251222174829381.png&amp;size=m" alt="image-20251222174827768"></p>
<p>但是你觉得spring的bean就这么简单几步？其实主要的就这么几步，还有一些细节的操作。完整的流程图如图所示：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251222182323888.png&amp;size=m" alt="image-20251222182322053"></p>
<p>注意一下，两幅图有对应关系的哦。</p>
<h3 id="spring-bean-生命周期详细步骤">Spring Bean 生命周期详细步骤</h3>
<h4 id="1-实例化阶段">1. 实例化阶段：</h4>
<ul>
 <li>Bean 的实例化是通过反射机制创建的。Spring 根据 <code>@Component</code>、<code>@Bean</code> 或者 XML 中的 <code>&lt;bean&gt;</code> 元素配置，来确定要创建的 Bean。</li>
</ul>
<h4 id="2-属性赋值阶段">2. 属性赋值阶段：</h4>
<ul>
 <li>在实例化完成后，Spring 会进行依赖注入。这包括将属性值注入到 Bean 的字段中，可能是通过 setter 方法注入，或者直接字段注入。</li>
</ul>
<h4 id="3-初始化前的扩展机制">3. 初始化前的扩展机制：</h4>
<ul>
 <li>Bean 可以实现 <code>BeanNameAware</code>、<code>BeanFactoryAware</code> 等 <code>Aware</code> 接口，从而在初始化之前获取 Bean 的名称、BeanFactory、ApplicationContext 等容器资源。例如，<code>ApplicationContextAware</code> 接口允许 Bean 获取 <code>ApplicationContext</code>，以便进一步与 Spring 容器交互。</li>
</ul>
<h4 id="4-beanpostprocessor-的作用">4. BeanPostProcessor 的作用：</h4>
<ul>
 <li><code>BeanPostProcessor</code> 接口允许开发者在 Bean 初始化前后添加自定义逻辑。例如，可以在 <code>postProcessBeforeInitialization</code> 方法中执行某些前置操作，如代理包装、AOP 切面等。在 <code>postProcessAfterInitialization</code> 中，可以进一步修改或替换 Bean 实例。</li>
</ul>
<h4 id="5-初始化的细节">5. 初始化的细节：</h4>
<ul>
 <li><code>InitializingBean</code> 接口提供了一个 <code>afterPropertiesSet</code> 方法，用于在 Bean 的所有属性设置完成后执行一些自定义初始化逻辑。开发者也可以通过 <code>@PostConstruct</code> 注解或者 XML/Java 配置中的 <code>init-method</code> 属性，来指定初始化方法。</li>
</ul>
<h4 id="6-bean-的就绪状态">6. Bean 的就绪状态：</h4>
<ul>
 <li>Bean 完成初始化后，即进入就绪状态，可以供应用程序使用。在此状态下，Bean 已经完成了所有的属性设置和初始化步骤，处于可用状态。</li>
</ul>
<h4 id="7-销毁阶段的清理">7. 销毁阶段的清理：</h4>
<ul>
 <li>Bean 的销毁通常在容器关闭时进行。<code>DisposableBean</code> 接口提供了 <code>destroy</code> 方法，用于清理资源。开发者也可以通过 <code>@PreDestroy</code> 注解或配置中的 <code>destroy-method</code> 属性，指定清理逻辑。</li>
</ul>
<h3 id="bean-的生命周期扩展点汇总">Bean 的生命周期扩展点汇总</h3>
<p>Spring 提供了多个扩展点，让开发者可以自定义和控制 Bean 的生命周期：</p>
<h4 id="1-beanpostprocessor"><strong>1. BeanPostProcessor</strong>：</h4>
<ul>
 <li>通过实现 <code>BeanPostProcessor</code> 接口，开发者可以在 Bean 初始化前后添加自定义逻辑，如动态代理、AOP 增强等。</li>
</ul>
<h4 id="2beanfactorypostprocessor"><strong>2.BeanFactoryPostProcessor</strong>：</h4>
<ul>
 <li><code>BeanFactoryPostProcessor</code> 允许开发者在 Bean 实例化之前，修改 Bean 的定义信息（如属性值），它在所有 Bean 实例化之前执行。</li>
</ul>
<h4 id="3aware-接口"><strong>3.Aware 接口</strong>：</h4>
<ul>
 <li>Spring 提供了多个 <code>Aware</code> 接口，如 <code>BeanNameAware</code>、<code>BeanFactoryAware</code>、<code>ApplicationContextAware</code> 等，允许 Bean 获取 Spring 容器的相关信息，进一步定制生命周期。</li>
</ul>
<h4 id="4postconstruct-和-predestroy"><strong>4.@PostConstruct 和 @PreDestroy</strong>：</h4>
<ul>
 <li>这些注解提供了一种声明式的方法来定义初始化和销毁逻辑，通常用于替代 XML 或 Java 配置中的 <code>init-method</code> 和 <code>destroy-method</code>。</li>
</ul>
<h2 id="spring三级缓存解决循环依赖">Spring三级缓存解决循环依赖</h2>
<p>一级缓存</p>
<ul>
 <li>singletonObjects，单例池，缓存已经经历了完整的生命周期，已经初始化完成的bean对象</li>
 <li>作用：限制bean在beanFactory中只存一份，即实现singleton scope，解决不了循环依赖</li>
</ul>
<p>二级缓存</p>
<ul>
 <li>earlySingletonObject，缓存早期的bean对象（生命周期还没走完）</li>
 <li>作用：配合一级缓存实现普通对象的循环依赖，代理对象的循环依赖还不能解决</li>
</ul>
<p>三级缓存</p>
<ul>
 <li>singletonFactories，缓存的是ObjectFactory，表示对象工厂，用来创建某个对象的</li>
 <li>作用：实例化对象，如果对象是代理对象，则生成代理对象，如果不是则生成普通对象</li>
</ul>
<pre><code class="language-java">//单实例对象注册器 
public class DefaultSingletonBeanRegistry extends SimpleAliasRegistry implements SingletonBeanRegistry {     
    private static final int SUPPRESSED_EXCEPTIONS_LIMIT = 100;     
    private final Map&lt;String, Object&gt; singletonObjects = new ConcurrentHashMap(256);     
    private final Map&lt;String, ObjectFactory&lt;?&gt;&gt; singletonFactories = new HashMap(16);     
    private final Map&lt;String, Object&gt; earlySingletonObjects = new ConcurrentHashMap(16);
 }
</code></pre>
<p>二级缓存辅助解决普通对象循环依赖图示：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251222185454427.png&amp;size=m" alt="image-20251222185452816"></p>
<p>三级缓存解决代理对象循环依赖</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251222190455390.png&amp;size=m" alt="image-20251222190453156"></p>
<ul>
 <li>
  <p>构造方法注入出现循环依赖</p>
  <ul>
   <li>
    <p>解决：加@Lazy注解</p>
    <pre><code class="language-java">@Component public class A {      
// B成员变量   
    private B b;      
    public A(@Lazy B b){         
        System.out.println("A的构造方法执行了...");         
        this.b = b ;
     }
 }
 
@Component public class B {  
    // A成员变量   
    private A a;      
    public B(A a){
         System.out.println("B的构造方法执行了...");
         this.a = a ;     
    }
 }
</code></pre>
   </li>
  </ul>
 </li>
</ul>
<h2 id="springmvc的执行流程">SpringMVC的执行流程</h2>
<p>这里有两种，一种是前后端不分离的，我不写，如果你面试的还是前后端不分离的，我建议你换一家，没有意义。纯傻逼。</p>
<ul>
 <li>用户发送出请求到前端控制器DispatcherServlet</li>
 <li>DispatcherServlet收到请求调用HandlerMapping（处理器映射器）</li>
 <li>HandlerMapping找到具体的处理器，生成处理器对象及处理器拦截器(如果有)，再一起返回给DispatcherServlet。</li>
 <li>DispatcherServlet调用HandlerAdapter（处理器适配器）</li>
 <li>HandlerAdapter经过适配调用具体的处理器（Handler/Controller）</li>
 <li>方法上添加了@ResponseBody</li>
 <li>通过HttpMessageConverter来返回结果转换为JSON并响应</li>
</ul>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fhanserwei-1308845726.cos.ap-chengdu.myqcloud.com%2Fmarkdown%2F20251222191639614.png&amp;size=m" alt="image-20251222191638078"></p>
<h2 id="spring-boot自动装配的原理">Spring Boot自动装配的原理</h2>
<p>我这里只说Spring Boot3的，毕竟现在Spring Boot3是主流的开发技术。</p>
<p>Spring Boot 3 的自动装配（Auto-configuration）是其“约定大于配置”核心理念的基石。简单来说，它能根据你项目中引入的依赖（Jar包），自动将需要的 Bean 配置到 Spring 容器中，省去了大量手动编写配置文件的麻烦。</p>
<p>其核心原理可以概括为：<strong>通过 SPI 机制，加载 META-INF/spring 下的配置文件，并根据条件注解按需注入。</strong></p>
<h3 id="1-核心注解springbootapplication">1. 核心注解：@SpringBootApplication</h3>
<p>自动装配的入口在于启动类上的 <code>@SpringBootApplication</code> 注解，它实际上是一个复合注解，其中最关键的是 <strong><code>@EnableAutoConfiguration</code></strong>。</p>
<ul>
 <li><strong><code>@SpringBootConfiguration</code></strong>: 标记这是一个配置类。</li>
 <li><strong><code>@ComponentScan</code></strong>: 扫描当前包及其子包下的组件（<strong>@Component</strong>, <strong>@Service</strong>等）。</li>
 <li><strong><code>@EnableAutoConfiguration</code></strong>: 真正开启自动装配的开关。</li>
</ul>
<h3 id="2-执行流程从注解到-bean">2. 执行流程：从注解到 Bean</h3>
<p>Spring Boot 启动时，自动装配遵循以下关键步骤：</p>
<h4 id="第一步引入选择器">第一步：引入选择器</h4>
<p><code>@EnableAutoConfiguration</code> 注解通过 <code>@Import</code> 引入了 <code>AutoConfigurationImportSelector</code> 类。这个类的作用是帮助 Spring 寻找并筛选出需要自动配置的类。</p>
<h4 id="第二步读取配置文件-spi-机制">第二步：读取配置文件 (SPI 机制)</h4>
<p>在 Spring Boot 3 中，传统的 <code>spring.factories</code> 文件已被弃用（虽然目前版本仍做兼容，但已不是主流）。</p>
<ul>
 <li><strong>新位置</strong>：Spring Boot 3 优先读取资源目录下 <code>META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports</code> 文件。</li>
 <li><strong>内容</strong>：该文件中列出了所有可选的自动配置全限定类名（例如 <code>JdbcTemplateAutoConfiguration</code>）。</li>
</ul>
<h4 id="第三步按需过滤-condition-机制">第三步：按需过滤 (Condition 机制)</h4>
<p>并不是配置文件里所有的类都会被加载。Spring Boot 会利用 <strong>Conditional 注解</strong>（条件装配）进行过滤：</p>
<ul>
 <li><code>@ConditionalOnClass</code>：classpath下有指定的类才装配。</li>
 <li><code>@ConditionalOnMissingBean</code>：容器中没有这个 Bean 时才装配（方便用户自定义覆盖）。</li>
 <li><code>@ConditionalOnProperty</code>：配置文件中指定的属性为特定值时才装配。</li>
</ul>]]></description><guid isPermaLink="false">/archives/springkuang-jia-chang-jian-mian-shi-ti</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-94026667-a2d3-4ea2-bbdf-ad3bbcc50cb5-190c7bf3.png&amp;size=m" type="image/jpeg" length="1583732"/><category>Spring 生态</category><pubDate>Wed, 24 Dec 2025 01:04:24 GMT</pubDate></item><item><title><![CDATA[MySQL常见面试题]]></title><link>https://likeyy.love/archives/mysqlchang-jian-mian-shi-ti</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=MySQL%E5%B8%B8%E8%A7%81%E9%9D%A2%E8%AF%95%E9%A2%98&amp;url=/archives/mysqlchang-jian-mian-shi-ti" width="1" height="1" alt="" style="opacity:0;">
<h1 id="mysql常见面试题">MySQL常见面试题</h1>
<p>:泡泡_滑稽::泡泡_啊:</p>
<h2 id="如何定位慢查询">如何定位慢查询</h2>
<p>解决方案</p>
<p>使用成熟的开源工具：</p>
<ol>
 <li>调试工具：Arthas</li>
 <li>运维工具：Prometheus 、Skywalking</li>
</ol>
<p>MySQL自带的慢日志（调试阶段开启，生产阶段关闭）</p>
<ol>
 <li>慢查询日志记录了所有执行时间超过指定参数（long_query_time，单位：秒，默认10秒）的所有SQL语句的日志</li>
 <li>如果要开启慢查询日志，需要在MySQL的配置文件（/etc/my.cnf）中配置</li>
</ol>
<pre><code class="language-shell"># 开启MySQL的慢日志查询开关
slow_query_log=1
# 设置慢日志的时间为2秒，SQL语句的执行时间超过2秒，就会视作为慢查询，记录慢查询日志
long_query_time=2
</code></pre>
<p>分析SQL</p>
<ul>
 <li>方法：可以采用EXPLAIN 或者 DESC命令获取 MySQL 如何执行 SELECT 语句的信息</li>
 <li>信息:
  <ul>
   <li>possible_key 当前sql可能会使用到的索引</li>
   <li>key 当前sql实际命中的索引</li>
   <li>key_len 索引占用的大小</li>
   <li>Extra 额外的优化建议</li>
   <li>type 这条sql的连接的类型，性能由好到差为NULL、system、const、eq_ref、ref、range、 index、all
    <ul>
     <li>system：查询系统中的表</li>
     <li>const：根据主键查询</li>
     <li>eq_ref：主键索引查询或唯一索引查询</li>
     <li>ref：索引查询</li>
     <li>range：范围查询</li>
     <li>index：索引树扫描</li>
     <li>all：全盘扫描</li>
    </ul></li>
  </ul></li>
</ul>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251219203623791.png&amp;size=m" alt="image-20251219203623791"></p>
<h2 id="索引">索引</h2>
<p>概念：索引（index）是帮助MySQL高效获取数据的数据结构(有序)。在数据之外，数据库系统还维护着满足特定查找算法的数据结构（B+树），这些数据结构以某种方式引用（指向）数据， 这样就可以在这些数据结构上实现高级查找算法，这种数据结构就是索引。</p>
<h3 id="数据结构比较">数据结构比较：</h3>
<p>在数据库（尤其是 MySQL 的 InnoDB 引擎）的设计中，核心目标是<strong>在海量数据中实现极速的查找，并尽量减少磁盘 I/O 操作</strong>。注意：磁盘IO次数是首要条件！！</p>
<h4 id="为什么不选二叉树bst--avl--红黑树">为什么不选二叉树（BST / AVL / 红黑树）？</h4>
<p>无论是普通的二叉搜索树（BST），还是自平衡的 AVL 树或红黑树，它们在数据库索引场景中都有一个致命伤：<strong>“树太高了”</strong>。</p>
<ul>
 <li><strong>磁盘 I/O 瓶颈：</strong> 磁盘寻道是非常耗时的操作。在数据库索引中，每个节点通常对应一个磁盘页（Page，InnoDB 默认为 16KB）。</li>
 <li><strong>高度失控：</strong> 二叉树的每个节点只能有两个子节点（阶数为 2）。如果存储 1000 万行数据，二叉树的高度大约为 <span class="math math-inline">log_2(10^7) \approx 24</span>。这意味着查找一条数据可能需要进行 <strong>24 次磁盘 I/O</strong>。</li>
 <li><strong>逻辑：</strong> 这种“瘦高”的结构在内存中运行很快，但在涉及磁盘读写时，过多的 I/O 会导致性能崩塌。</li>
</ul>
<h4 id="为什么不选-b-树b-tree">为什么不选 B 树（B-Tree）？</h4>
<p>B 树是对二叉树的改良，它变成了“多叉树”，大大降低了树的高度。但相比 B+ 树，它有三个主要劣势：</p>
<p>一、内部节点存储数据导致“扇出”降低</p>
<ul>
 <li><strong>B 树：</strong> 每个节点（无论是根节点还是叶子节点）都存储完整的 <code>Data</code>。</li>
 <li><strong>B+ 树：</strong> 非叶子节点（索引节点）只存储 <code>Key</code> 和 <code>Pointer</code>，不存储具体的 <code>Data</code>。</li>
 <li><strong>结论：</strong> 同样是 16KB 的磁盘页，B+ 树的非叶子节点能存储更多的索引项，这意味着它的**扇出（Fan-out）**更大，树的高度更低（通常 3 到 4 层即可支撑千万级数据）。</li>
</ul>
<p>二、范围查询（Range Query）性能差</p>
<ul>
 <li><strong>B 树：</strong> 如果要查 ID 在 10 到 100 之间的数据，B 树需要进行中序遍历，不断在不同层级的父子节点间“反复横跳”。</li>
 <li><strong>B+ 树：</strong> 所有数据都存在叶子节点，并且叶子节点之间用<strong>双向链表</strong>连接。查找范围时，只需先找到起始点，然后在叶子节点层进行顺序扫描即可。</li>
</ul>
<p>三、查询稳定性</p>
<ul>
 <li><strong>B 树：</strong> 查找性能不稳定，运气好在根节点就找到了，运气差要到叶子节点。</li>
 <li><strong>B+ 树：</strong> 任何查找都要最终落到叶子节点，查询路径长度固定，性能非常稳定。</li>
</ul>
<h4 id="为啥选择了b树">为啥选择了B+树？</h4>
<p>B+ 树之所以成为 InnoDB 的首选，主要得益于以下设计：</p>
<table>
 <thead>
  <tr>
   <th><strong>特性</strong></th>
   <th><strong>B+ 树的表现</strong></th>
   <th><strong>给数据库带来的好处</strong></th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><strong>矮胖结构</strong></td>
   <td>阶数通常几百上千，高度极低</td>
   <td>即使亿级数据，磁盘 I/O 也在 3-4 次以内</td>
  </tr>
  <tr>
   <td><strong>非叶子节点去 Data 化</strong></td>
   <td>索引页能装更多索引</td>
   <td>内存缓存效率更高，命中率高</td>
  </tr>
  <tr>
   <td><strong>叶子节点双向链表</strong></td>
   <td>物理存储有序，逻辑链表连接</td>
   <td>完美支持 <code>ORDER BY</code>、<code>GROUP BY</code> 和范围查询</td>
  </tr>
  <tr>
   <td><strong>预读性</strong></td>
   <td>节点大小与操作系统的页对齐</td>
   <td>充分利用局部性原理，一次读取整页数据</td>
  </tr>
 </tbody>
</table>
<h3 id="索引类型">索引类型</h3>
<h4 id="聚集索引聚簇索引">聚集索引（聚簇索引）</h4>
<p>将数据存储与索引放到了一块，索引结构的叶子节点保存了行数据。必须有，且只有一个。</p>
<p>聚簇索引并不是一种单独的索引类型，而是一种<strong>数据存储方式</strong>。</p>
<ul>
 <li><strong>核心逻辑：</strong> 将数据行与索引 B+ 树的叶子节点存放在一起。也就是说，<strong>索引即表，表即索引</strong>。</li>
 <li><strong>物理存储：</strong> 数据的物理存储顺序与索引的逻辑顺序完全一致。</li>
 <li><strong>唯一性：</strong> 因为数据行只能按照一种物理顺序存储，所以一个表<strong>只能有一个</strong>聚簇索引。</li>
</ul>
<h4 id="二级索引非聚集索引">二级索引（非聚集索引）</h4>
<p>将数据与索引分开存储，索引结构的叶子节点关联的是对应的主键。可以存在多个。</p>
<p>非聚簇索引也叫辅助索引（Secondary Index）或者二级索引。</p>
<ul>
 <li><strong>核心逻辑：</strong> 索引结构与数据行分开存储。B+ 树的叶子节点不存储真实的整行数据。</li>
 <li><strong>叶子节点存什么：</strong>
  <ul>
   <li>在 <strong>InnoDB</strong> 中：叶子节点存储的是该索引列的值 + 对应的<strong>主键值</strong>。</li>
   <li>在 <strong>MyISAM</strong> 中：叶子节点存储的是该索引列的值 + 数据的<strong>磁盘地址（物理指针）</strong>。</li>
  </ul></li>
 <li><strong>数量：</strong> 一个表可以拥有多个非聚簇索引（如对 <code>name</code>、<code>age</code> 分别建索引）。</li>
</ul>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251219215501385.png&amp;size=m" alt="image-20251219215501385"></p>
<h3 id="回表查询">回表查询</h3>
<p>通过二级索引找到对应的主键值，到聚集索引中查找整行数据，这个过程就是回表。再实际开发中尽可能要避免回表查询。</p>
<p>比如我上面的表结构，聚簇索引是主键ID，非聚簇索引是name，那么看如下SQL：</p>
<pre><code class="language-sql">select *
from t_user
where name = 'Kit';
</code></pre>
<p>该SQL会先走非聚簇索引，拿到主键ID，然后由于我是select的所有字段，所以，还会拿着这个主键ID去走一遍聚簇索引，拿到剩余字段。</p>
<table>
 <thead>
  <tr>
   <th align="left">id</th>
   <th align="left">select_type</th>
   <th align="left">table</th>
   <th align="left">partitions</th>
   <th align="left">type</th>
   <th align="left">possible_keys</th>
   <th align="left">key</th>
   <th align="left">key_len</th>
   <th align="left">ref</th>
   <th align="left">rows</th>
   <th align="left">filtered</th>
   <th align="left">Extra</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td align="left">1</td>
   <td align="left">SIMPLE</td>
   <td align="left">t_user</td>
   <td align="left">null</td>
   <td align="left">ref</td>
   <td align="left">t_user_name_index</td>
   <td align="left">t_user_name_index</td>
   <td align="left">82</td>
   <td align="left">const</td>
   <td align="left">1</td>
   <td align="left">100</td>
   <td align="left">null</td>
  </tr>
 </tbody>
</table>
<p>注意这里的Extra是null，所以肯定是回表了的。</p>
<h3 id="覆盖索引">覆盖索引</h3>
<p>是指查询使用了索引，并且需要返回的列，在该索引中已经全部能够找到 。</p>
<pre><code class="language-sql">-- 第一句
select * from tb_user where id = 1; 覆盖索引

-- 第二句
select id, name from tb_user where name = 'Arm'; 覆盖索引

-- 第三句
select id, name, gender from tb_user where name = 'Arm'; 非覆盖索引需要回表
</code></pre>
<p>覆盖索引处理MySQL超大分页问题</p>
<ul>
 <li>问题：当在进行分页查询时，如果执行 limit 9000000,10 ，此时需要MySQL排序前9000010 记录，仅仅返回 9000000 - 9000010 的记录，其他记录丢弃，查询排序的代价非常大 。</li>
 <li>优化思路: 一般分页查询时，通过创建 覆盖索引 能够比较好地提高性能，可以通过覆盖索引加子查询形式进行优化</li>
</ul>
<pre><code class="language-sql">select *
from tb_sku t,
    (select id from tb_sku order by id limit 9000000,10) a
where t.id = a.id;
</code></pre>
<p>这段代码展示了 <strong>MySQL 大分页查询优化</strong>的一种常见技巧（延迟关联）：</p>
<ul>
 <li><strong>子查询部分</strong>：<code>(select id from tb_sku order by id limit 9000000,10)</code>。这步只查询 <code>id</code> 字段，可以利用<strong>覆盖索引</strong>快速定位到第 9,000,000 行开始的 10 个 ID，避免了全表扫描和大量的回表操作。</li>
 <li><strong>主查询部分</strong>：通过 <code>where t.id = a.id</code> 将这 10 个 ID 与原表关联，从而获取这 10 条记录的所有字段信息。</li>
</ul>
<p>相比于直接执行 <code>select * from tb_sku limit 9000000, 10</code>，这种方式能显著提升超大偏移量分页的查询性能。</p>
<h3 id="索引创建原则">索引创建原则</h3>
<ul>
 <li>针对于数据量较大，且查询比较频繁的表建立索引。</li>
 <li>针对于常作为查询条件（where）、排序（order by）、分组（group by）操作的字段建立索引。</li>
 <li>尽量选择区分度高的列作为索引，尽量建立唯一索引，区分度越高，使用索引的效率越高。</li>
 <li>如果是字符串类型的字段，字段的长度较长，可以针对于字段的特点，建立前缀索引。</li>
 <li>尽量使用联合索引，减少单列索引，查询时，联合索引很多时候可以覆盖索引，节省存储空间，避免回表，提高查询效率。</li>
 <li>要控制索引的数量，索引并不是多多益善，索引越多，维护索引结构的代价也就越大，会影响增删改的效率。</li>
 <li>如果索引列不能存储NULL值，请在创建表时使用NOT NULL约束它。当优化器知道每列是否包含NULL值时，它可以更好地确定哪个索引最有效地用于查询。</li>
</ul>
<h3 id="索引失效">索引失效</h3>
<p>假设我们有一张用户表 <code>tb_user</code>，并针对 <code>profession</code> (职业)、<code>age</code> (年龄)、<code>status</code> (状态) 三个字段建立了<strong>联合索引</strong>： <code>CREATE INDEX idx_user_pro_age_sta ON tb_user(profession, age, status);</code></p>
<p>索引失效的情况：</p>
<hr>
<h4 id="1-违反最左前缀法则">1. 违反最左前缀法则</h4>
<p><strong>规则</strong>：查询必须从索引的最左列开始，且不能跳过中间列。</p>
<ul>
 <li>
  <p><strong>完全失效</strong>：跳过了最左列 <code>profession</code>。</p>
  <pre><code class="language-sql">-- 失效：直接从 age 开始，没有 profession
select * from tb_user where age = 31 and status = '0';
</code></pre>
 </li>
 <li>
  <p><strong>部分失效</strong>：跳过了中间列 <code>age</code>。</p>
  <pre><code class="language-sql">-- 只有 profession 走索引，status 不走索引（中间断开了）
select * from tb_user where profession = 'Software Engineering' and status = '0';
</code></pre>
 </li>
</ul>
<h4 id="2-范围查询右边的列失效">2. 范围查询右边的列失效</h4>
<p><strong>规则</strong>：联合索引中，出现范围查询（<code>&gt;</code>、<code>&lt;</code>）后的列索引失效。</p>
<ul>
 <li>
  <p><strong>例子</strong>：</p>
  <pre><code class="language-sql">-- profession 和 age 走索引，但 status 索引失效
-- 因为 age 使用了范围查询 '&gt;'
select * from tb_user where profession = 'Software Engineering' and age &gt; 30 and status = '0';
</code></pre>
  <blockquote>
   <p><strong>提示</strong>：在业务允许的情况下，使用 <code>&gt;=</code> 或 <code>&lt;=</code> 通常可以规避部分失效问题。</p>
  </blockquote>
 </li>
</ul>
<h4 id="3-在索引列上进行运算操作">3. 在索引列上进行运算操作</h4>
<p><strong>规则</strong>：对索引字段使用函数或数学计算，MySQL 将无法利用索引。</p>
<ul>
 <li>
  <p><strong>例子</strong>：</p>
  <pre><code class="language-sql">-- 失效：对 phone 字段进行了截取操作
select * from tb_user where substring(phone, 10, 2) = '15';

-- 失效：对 age 进行了数学运算
select * from tb_user where age + 1 = 30;
</code></pre>
 </li>
</ul>
<h3 id="4-字符串不加单引号隐式类型转换">4. 字符串不加单引号（隐式类型转换）</h3>
<p><strong>规则</strong>：如果字段类型是字符串，但查询时未加引号，MySQL 会进行隐式转换，导致索引失效。</p>
<ul>
 <li>
  <p><strong>例子</strong>：</p>
  <pre><code class="language-sql">-- 假设 phone 是 varchar 类型
-- 失效：未加单引号，发生了类型转换
select * from tb_user where phone = 17612345678;
</code></pre>
 </li>
</ul>
<h4 id="5-like-模糊查询以--开头">5. Like 模糊查询以 % 开头</h4>
<p><strong>规则</strong>：头部模糊匹配会导致全表扫描。</p>
<ul>
 <li>
  <p><strong>失效例子（头部模糊）</strong>：</p>
  <pre><code class="language-sql">-- 失效：百分号在前面
select * from tb_user where profession like '%Engineering';
</code></pre>
 </li>
 <li>
  <p><strong>有效例子（尾部模糊）</strong>：</p>
  <pre><code class="language-sql">-- 走索引：百分号只在后面
select * from tb_user where profession like 'Software%';
</code></pre>
 </li>
</ul>
<h3 id="sql优化">sql优化</h3>
<ul>
 <li>
  <p>表的设计优化</p>
  <p>比如设置合适的数值（tinyint int bigint），要根据实际情况选择</p>
  <p>比如设置合适的字符串类型（char和varchar）char定长效率高，varchar可变长度，效率稍低</p>
 </li>
 <li>
  <p>索引优化（参考优化创建原则和索引失效）</p>
 </li>
 <li>
  <p>SQL语句优化</p>
  <ol>
   <li>SELECT语句务必指明字段名称（避免直接使用select * ）</li>
   <li>SQL语句要避免造成索引失效的写法</li>
   <li>尽量用union all代替union，union all会展示重复的数据，而union会多一次过滤，效率低</li>
   <li>避免在where子句中对字段进行表达式操作（可能会导致索引失效）</li>
   <li>Join优化 能用innerjoin 就不用left join right join，如必须使用 一定要<strong>以小表为驱动</strong>，内连接会对两个表进行优化，优先把小表放到外边，把大表放到里边。left join 或 right join，不会重新调整顺序</li>
  </ol>
 </li>
 <li>
  <p>主从复制、读写分离</p>
  <p>如果数据库的使用场景读的操作比较多的时候，为了避免写的操作所造成的性能影响 可以采用读写分离的架构。读写分离解决的是，数据库的写入，影响了查询的效率。</p>
 </li>
 <li>
  <p>分库分表</p>
 </li>
</ul>
<h2 id="事务隔离级别">事务隔离级别</h2>
<p>在数据库的并发控制中，<strong>事务隔离级别（Transaction Isolation Levels）</strong> 是为了平衡<strong>数据一致性</strong>与<strong>系统并发性能</strong>而设计的规范。</p>
<p>MySQL 的 InnoDB 存储引擎完全支持 SQL:1992 标准定义的四个隔离级别。</p>
<h3 id="一-并发事务带来的三大问题">一、 并发事务带来的三大问题</h3>
<p>在聊隔离级别之前，我们需要先了解如果不进行隔离，并发事务同时操作同一行数据会发生什么“惨案”：</p>
<ol>
 <li><strong>脏读 (Dirty Read)：</strong> 事务 A 读取了事务 B <strong>还没提交</strong>的数据。如果事务 B 随后回滚，事务 A 读到的就是非法数据。</li>
 <li><strong>不可重复读 (Non-repeatable Read)：</strong> 事务 A 在同一个事务内多次读取同一条数据，结果不一致。这是因为在两次读取之间，事务 B <strong>修改并提交</strong>了该数据。</li>
 <li><strong>幻读 (Phantom Read)：</strong> 事务 A 按某个范围查询数据，发现多了或少了几行。这是因为在查询期间，事务 B <strong>插入或删除</strong>了符合条件的行并提交了。</li>
</ol>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdowndrawio.png&amp;size=m" alt="img"></p>
<h3 id="二-mysql-的四大隔离级别">二、 MySQL 的四大隔离级别</h3>
<p>为了解决上述问题，隔离级别从低到高分为四层，级别越高，数据越安全，但性能开销也越大。</p>
<h4 id="1-读未提交-read-uncommitted">1. 读未提交 (Read Uncommitted)</h4>
<ul>
 <li><strong>特性：</strong> 一个事务可以读取到另一个未提交事务修改的数据。</li>
 <li><strong>存在问题：</strong> 脏读、不可重复读、幻读。</li>
 <li><strong>评价：</strong> 性能最高，但几乎没人用，安全性太低。</li>
</ul>
<h4 id="2-读已提交-read-committed-rc">2. 读已提交 (Read Committed, RC)</h4>
<ul>
 <li><strong>特性：</strong> 只能读取到已经提交的数据。</li>
 <li><strong>解决问题：</strong> 解决了<strong>脏读</strong>。</li>
 <li><strong>存在问题：</strong> 不可重复读、幻读。</li>
 <li><strong>应用场景：</strong> 许多主流数据库（如 Oracle, SQL Server）的默认隔离级别。</li>
</ul>
<h4 id="3-可重复读-repeatable-read-rr--mysql-默认级别">3. 可重复读 (Repeatable Read, RR) — <strong>MySQL 默认级别</strong></h4>
<ul>
 <li><strong>特性：</strong> 在同一个事务中，多次读取同一记录的结果是一致的。</li>
 <li><strong>解决问题：</strong> 解决了<strong>脏读</strong>、<strong>不可重复读</strong>。</li>
 <li><strong>特殊点：</strong> <strong>MySQL 的 InnoDB 通过 MVCC（多版本并发控制）和 Next-Key Locks 很大程度上解决了幻读问题。</strong> 这是它相比于标准 SQL 规范的强大之处。</li>
</ul>
<h4 id="4-串行化-serializable">4. 串行化 (Serializable)</h4>
<ul>
 <li><strong>特性：</strong> 所有的事务都按顺序执行。它会对读取的每一行数据都加锁（读写冲突）。</li>
 <li><strong>解决问题：</strong> 解决所有并发问题。</li>
 <li><strong>评价：</strong> 性能最差，通常只在对数据绝对敏感且并发量极低的场景下使用。</li>
</ul>
<table>
 <thead>
  <tr>
   <th><strong>隔离级别</strong></th>
   <th><strong>脏读</strong></th>
   <th><strong>不可重复读</strong></th>
   <th><strong>幻读</strong></th>
   <th><strong>备注</strong></th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><strong>Read Uncommitted</strong></td>
   <td>❌ (存在)</td>
   <td>❌ (存在)</td>
   <td>❌ (存在)</td>
   <td>极少使用</td>
  </tr>
  <tr>
   <td><strong>Read Committed</strong></td>
   <td>✅ (解决)</td>
   <td>❌ (存在)</td>
   <td>❌ (存在)</td>
   <td>Oracle/PostgreSQL 默认</td>
  </tr>
  <tr>
   <td><strong>Repeatable Read</strong></td>
   <td>✅ (解决)</td>
   <td>✅ (解决)</td>
   <td>⚠️ (InnoDB 优化)</td>
   <td><strong>MySQL 默认</strong></td>
  </tr>
  <tr>
   <td><strong>Serializable</strong></td>
   <td>✅ (解决)</td>
   <td>✅ (解决)</td>
   <td>✅ (解决)</td>
   <td>性能极低</td>
  </tr>
 </tbody>
</table>
<p>虽然 MySQL InnoDB 在 <strong>可重复读 (RR)</strong> 级别下通过 <strong>MVCC (多版本并发控制)</strong> 和 <strong>Next-Key Locks (临键锁)</strong> 已经解决了绝大多数场景下的幻读，但在一些<strong>特殊的并发操作序列</strong>下，幻读依然会“现身”。</p>
<p>我们可以把 InnoDB 解决幻读的方案分成两套“拳法”：</p>
<ol>
 <li><strong>快照读 (Snapshot Read)：</strong> 普通的 <code>SELECT</code> 语句。靠 <strong>MVCC</strong> 解决，它让你看到的是事务开始时的快照，别人新插的数据你看不到。</li>
 <li><strong>当前读 (Current Read)：</strong> <code>SELECT ... FOR UPDATE</code>、<code>UPDATE</code>、<code>DELETE</code> 等。靠 <strong>Gap Lock (间隙锁)</strong> 解决，它会在范围内加锁，不准别人插新数据。</li>
</ol>
<p>但当这两套拳法“混用”或者顺序不对时，幻读就钻了空子。</p>
<p>幻读依然发生的两个典型场景：</p>
<p>一、“后发制人”的更新操作 (最经典)</p>
<p>假设表 <code>user</code> 中原本没有 <code>id=5</code> 的记录。</p>
<ul>
 <li><strong>事务 A：</strong> 先执行 <code>SELECT * FROM user WHERE id=5</code>。由于是快照读，结果是<strong>空</strong>。</li>
 <li><strong>事务 B：</strong> 插入一条 <code>id=5</code> 的数据并 <strong>提交</strong>。</li>
 <li><strong>事务 A：</strong> 此时执行 <code>UPDATE user SET name='new' WHERE id=5</code>。
  <ul>
   <li><strong>关键点：</strong> <code>UPDATE</code> 是<strong>当前读</strong>。它会去磁盘读最新的数据，结果它竟然发现并成功更新了事务 B 刚插进去的那行！</li>
  </ul></li>
 <li><strong>事务 A：</strong> 再次执行 <code>SELECT * FROM user WHERE id=5</code>。
  <ul>
   <li><strong>炸裂点：</strong> 根据 MVCC 的规则，<strong>一个事务能看到自己修改过的数据</strong>。因为事务 A 刚才更新了这行，它现在的版本号属于事务 A 了。于是，事务 A 的第二次查询竟然查出了这行数据！</li>
  </ul></li>
</ul>
<p><strong>结论：</strong> 在事务 A 看来，就像凭空变出来的一样，这就是幻读。</p>
<p>二、快照读切换到当前读</p>
<ul>
 <li><strong>事务 A：</strong> 执行普通 <code>SELECT</code> 查询某个范围（快照读），没发现新行。</li>
 <li><strong>事务 B：</strong> 插入新行并 <strong>提交</strong>。</li>
 <li><strong>事务 A：</strong> 突然想给这些行加锁，执行了 <code>SELECT ... FOR UPDATE</code>（当前读）。
  <ul>
   <li><strong>结果：</strong> 由于当前读会读取数据库中最新的记录，事务 A 这一次查询会立刻看到事务 B 插入的数据。</li>
  </ul></li>
</ul>
<p><strong>结论：</strong> 同一个事务内，查询结果集变了。</p>
<h3 id="什么是快照读和当前读">什么是快照读和当前读？</h3>
<p>简单来说，快照读（Snapshot Read）让你看到的是“过去”，而当前读（Current Read）让你看到的是“现在”。</p>
<p>在 MySQL InnoDB 引擎中，为了同时兼顾<strong>高并发性能</strong>和<strong>数据一致性</strong>，设计了这两套不同的读取机制。</p>
<h4 id="一-快照读-snapshot-read">一、 快照读 (Snapshot Read)</h4>
<p>快照读就是普通的 <code>SELECT</code> 查询。它不需要加锁，而是通过 <strong>MVCC（多版本并发控制）</strong> 来实现。</p>
<ul>
 <li>
  <p><strong>核心逻辑：</strong> 它读取的是数据的“快照版本”。即便当前有其他事务在修改这些数据，快照读也会去 <strong>Undo Log（回滚日志）</strong> 里找之前的老版本，从而实现非阻塞的并发读。</p>
 </li>
 <li>
  <p><strong>适用场景：</strong> 普通的、对实时性要求不是极高的查询。</p>
 </li>
 <li>
  <p><strong>代表语句：</strong></p>
  <pre><code class="language-sql">SELECT * FROM table WHERE id = 1;
</code></pre>
 </li>
 <li>
  <p><strong>特点：</strong></p>
  <ul>
   <li><strong>不加锁：</strong> 性能极高，不会被其他事务的写操作阻塞。</li>
   <li><strong>可见性受隔离级别影响：</strong>
    <ul>
     <li><strong>RC（读已提交）：</strong> 每次执行 <code>SELECT</code> 都会生成一个新的快照，所以能读到别人刚提交的修改。</li>
     <li><strong>RR（可重复读）：</strong> 整个事务只在第一次 <code>SELECT</code> 时生成快照，后面再查都用同一个，所以能保证“可重复读”。</li>
    </ul></li>
  </ul>
 </li>
</ul>
<h4 id="二-当前读-current-read">二、 当前读 (Current Read)</h4>
<p>当前读是指读取数据库中<strong>最新、最真实</strong>的数据，并且为了防止在读取过程中数据被别人改掉，它会对读取的行进行<strong>加锁</strong>。</p>
<ul>
 <li><strong>核心逻辑：</strong> 必须读取到磁盘上已经提交的最新的那一行记录，不能读回滚日志里的老版本。</li>
 <li><strong>适用场景：</strong> 涉及修改操作（更新、删除、插入）或者需要保证绝对数据实时的查询。</li>
 <li><strong>代表语句：</strong>
  <ol>
   <li><code>SELECT ... FOR UPDATE</code> (加排他锁/X锁)</li>
   <li><code>SELECT ... LOCK IN SHARE MODE</code> (加共享锁/S锁)</li>
   <li><code>INSERT</code></li>
   <li><code>UPDATE</code></li>
   <li><code>DELETE</code></li>
  </ol></li>
 <li><strong>特点：</strong>
  <ul>
   <li><strong>加锁：</strong> 会阻塞其他事务对这些行的修改（或获取锁）。</li>
   <li><strong>保证一致性：</strong> 强制读最新，不会因为版本快照而产生数据延迟。</li>
  </ul></li>
</ul>
<h2 id="三大log文件">三大log文件</h2>
<p>在 MySQL（尤其是使用 InnoDB 存储引擎时）中，<strong>redolog</strong>、<strong>undolog</strong> 和 <strong>binlog</strong> 是保证数据库事务 ACID 特性、崩溃恢复和主备复制的核心机制。</p>
<h4 id="三大日志概览">三大日志概览</h4>
<table>
 <thead>
  <tr>
   <th><strong>特性</strong></th>
   <th><strong>Redo Log (重做日志)</strong></th>
   <th><strong>Undo Log (回滚日志)</strong></th>
   <th><strong>Binlog (归档日志)</strong></th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><strong>所属层级</strong></td>
   <td>InnoDB 存储引擎层</td>
   <td>InnoDB 存储引擎层</td>
   <td>MySQL Server 层</td>
  </tr>
  <tr>
   <td><strong>文件格式</strong></td>
   <td>物理日志（记录数据页的变化）</td>
   <td>逻辑日志（记录相反的操作）</td>
   <td>逻辑日志（记录 SQL 或数据行）</td>
  </tr>
  <tr>
   <td><strong>记录内容</strong></td>
   <td>“在某个数据页上做了什么修改”</td>
   <td>“这条记录修改前的值是多少”</td>
   <td>“更新了哪一行” 或 “执行了某条 SQL”</td>
  </tr>
  <tr>
   <td><strong>主要作用</strong></td>
   <td><strong>崩溃恢复</strong>（保证持久性）</td>
   <td><strong>事务回滚 &amp; MVCC</strong>（原子性、隔离性）</td>
   <td><strong>主从复制 &amp; 数据恢复</strong></td>
  </tr>
  <tr>
   <td><strong>写入方式</strong></td>
   <td>循环写（空间固定，会覆盖）</td>
   <td>逻辑写入（存储在回滚段中）</td>
   <td>追加写（文件满了切下一个）</td>
  </tr>
 </tbody>
</table>
<h4 id="redo-log保证不丢数据的硬汉">Redo Log：保证“不丢数据”的硬汉</h4>
<p><strong>作用：</strong> Redo Log 是为了实现事务的<strong>持久性（Durability）</strong>。当数据库宕机时，内存中尚未刷到磁盘的数据（脏页）会丢失，重启后通过 Redo Log 重新执行操作，确保数据不丢失。这就是所谓的 <strong>WAL（Write-Ahead Logging，预写日志）</strong> 技术。</p>
<ul>
 <li><strong>产生时机：</strong> 只要有数据修改，就会先写到 <code>redo log buffer</code>，然后根据策略（如 <code>innodb_flush_log_at_trx_commit</code>）刷到磁盘文件。</li>
 <li><strong>清除时机：</strong> Redo Log 的大小是固定的（通常是几个文件组成的环形结构）。当日志对应的“脏页”已经被真正刷新到磁盘数据文件（<code>.ibd</code>）后，这部分日志就失效了，空间可以被<strong>覆盖重用</strong>。</li>
</ul>
<h4 id="undo-log后悔药与多版本管理">Undo Log：后悔药与多版本管理</h4>
<p><strong>作用：</strong> Undo Log 主要负责原子性（Atomicity）和 <strong>MVCC（多版本并发控制）</strong>。</p>
<ol>
 <li><strong>回滚：</strong> 如果事务失败或执行了 <code>ROLLBACK</code>，MySQL 利用 Undo Log 将数据改回原来的样子（记录相反的操作：比如你执行了 <code>INSERT</code>，它记录一个 <code>DELETE</code>）。</li>
 <li><strong>MVCC：</strong> 当一个事务在读取数据时，如果有另一个事务正在修改，它可以通过 Undo Log 读取到之前的版本。</li>
</ol>
<ul>
 <li><strong>产生时机：</strong> 在事务开始修改数据<strong>之前</strong>，会先记录该行的原始状态到 Undo Log 中。</li>
 <li><strong>清除时机：</strong> 事务提交后，Undo Log 不会立刻删除。它需要等待 <strong>purge 线程</strong> 确认没有其他事务再需要这个版本（用于 MVCC）时，才会异步地进行清理。</li>
</ul>
<h4 id="binlog集群复制与数据溯源">Binlog：集群复制与数据溯源</h4>
<p><strong>作用：</strong> Binlog 记录了所有修改数据库结构的 DDL 和 DML 语句，属于 MySQL Server 层，与引擎无关。</p>
<ol>
 <li><strong>主从复制：</strong> Master 将 Binlog 发送给 Slave，Slave 重放实现数据同步。</li>
 <li><strong>数据恢复：</strong> 如果数据库被误删，可以结合全备和 Binlog 进行“增量恢复”到指定时间点。</li>
</ol>
<ul>
 <li><strong>产生时机：</strong> 事务提交时，一次性将整个事务的日志写入 Binlog 文件。</li>
 <li><strong>清除时机：</strong> 通过配置项 <code>expire_logs_days</code>（或新版本的 <code>binlog_expire_logs_seconds</code>）设置保存天数。超过时间后，系统会自动<strong>删除</strong>旧文件。</li>
</ul>
<h2 id="mvcc">MVCC</h2>
<p><strong>MVCC</strong>（Multi-Version Concurrency Control，<strong>多版本并发控制</strong>）是 MySQL InnoDB 存储引擎实现隔离级别（主要是 <strong>RC 读已提交</strong> 和 <strong>RR 可重复读</strong>）的核心机制。</p>
<blockquote>
 <p>串行化和读未提交这两个事务级别没有用到MVCC</p>
</blockquote>
<p>简单来说，它的目的是：<strong>让数据库在处理读写冲突时，不用加锁也能保证数据的安全性，从而大幅提升并发性能。</strong></p>
<h4 id="mvcc-的核心实现原理">MVCC 的核心实现原理</h4>
<p>MVCC 的实现依赖于三个关键组件：<strong>隐藏列</strong>、<strong>Undo Log（回滚日志）</strong> 和 <strong>ReadView（一致性视图）</strong>。</p>
<h5 id="隐藏列">隐藏列</h5>
<p>InnoDB 在每行数据后面都会默默添加几个隐藏字段：</p>
<ul>
 <li><strong><code>DB_TRX_ID</code></strong>：最近修改这条数据的<strong>事务 ID</strong>。</li>
 <li><strong><code>DB_ROLL_PTR</code></strong>：<strong>回滚指针</strong>，指向这条记录的上一个版本（存放在 Undo Log 中）。</li>
 <li><strong><code>DB_ROW_ID</code></strong>：如果表没有主键，InnoDB 会自动生成的隐藏主键。</li>
</ul>
<h5 id="undo-log版本链">Undo Log（版本链）</h5>
<p>当一个事务修改数据时，InnoDB 不会直接覆盖旧数据，而是先把旧数据写入 Undo Log。 通过隐藏列中的 <code>DB_ROLL_PTR</code> 指针，将这些旧版本的数据串联起来，形成一个<strong>版本链</strong>。</p>
<h5 id="readview一致性视图">ReadView（一致性视图）</h5>
<p>ReadView 是事务在进行“快照读”时产生的一个结构，它定义了<strong>哪些数据版本对当前事务是可见的</strong>。它包含四个核心字段：</p>
<ol>
 <li><strong><code>m_ids</code></strong>：生成 ReadView 时，系统中当前正在活跃（未提交）的事务 ID 列表。</li>
 <li><strong><code>min_trx_id</code></strong>：活跃事务中最小的 ID。</li>
 <li><strong><code>max_trx_id</code></strong>：系统即将分配给下一个事务的 ID 值。</li>
 <li><strong><code>creator_trx_id</code></strong>：创建这个 ReadView 的事务 ID。</li>
</ol>
<h4 id="可见性判断规则">可见性判断规则</h4>
<p>当事务想读某行数据时，会拿着该行当前的 <code>trx_id</code> 与 ReadView 进行比对：</p>
<ol>
 <li><strong><code>trx_id</code> == <code>creator_trx_id</code></strong>：这个版本是我自己改的，<strong>可见</strong>。</li>
 <li><strong><code>trx_id</code> &lt; <code>min_trx_id</code></strong>：说明这个事务在 ReadView 创建前已经提交了，<strong>可见</strong>。</li>
 <li><strong><code>trx_id</code> &gt;= <code>max_trx_id</code></strong>：说明这个事务在 ReadView 创建后才启动，<strong>不可见</strong>。</li>
 <li><strong><code>min_trx_id</code> &lt;= <code>trx_id</code> &lt; <code>max_trx_id</code></strong>：
  <ul>
   <li>如果 <code>trx_id</code> 在 <code>m_ids</code> 列表中：说明事务还没提交，<strong>不可见</strong>。</li>
   <li>如果不在：说明事务已提交，<strong>可见</strong>。</li>
  </ul></li>
</ol>
<p><strong>如果当前版本不可见，就顺着版本链（Undo Log）往回找，直到找到可见的版本。</strong></p>
<h4 id="图示">图示：</h4>
<p>下面我结合图片来解释这一个过程，图片主要参考的是<strong>黑马程序员的MySQL相关课程</strong>：</p>
<p>假设当前我有五个事务，他们同时开始，但是事务提交的时间不一致，如下图表格所示：</p>
<table>
 <thead>
  <tr>
   <th><strong>事务2</strong></th>
   <th><strong>事务3</strong></th>
   <th><strong>事务4</strong></th>
   <th><strong>事务5</strong></th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>开始事务</td>
   <td>开始事务</td>
   <td>开始事务</td>
   <td>开始事务</td>
  </tr>
  <tr>
   <td>修改id为30记录，age改为3</td>
   <td></td>
   <td>查询id为30的记录</td>
   <td></td>
  </tr>
  <tr>
   <td><strong>提交事务</strong></td>
   <td></td>
   <td></td>
   <td></td>
  </tr>
  <tr>
   <td></td>
   <td>修改id为30记录，name改为A3</td>
   <td></td>
   <td></td>
  </tr>
  <tr>
   <td></td>
   <td></td>
   <td></td>
   <td>查询id为30的记录</td>
  </tr>
  <tr>
   <td></td>
   <td><strong>提交事务</strong></td>
   <td></td>
   <td></td>
  </tr>
  <tr>
   <td></td>
   <td></td>
   <td>修改id为30记录，age改为10</td>
   <td></td>
  </tr>
  <tr>
   <td></td>
   <td></td>
   <td>查询id为30的记录</td>
   <td></td>
  </tr>
  <tr>
   <td></td>
   <td></td>
   <td></td>
   <td>查询id为30的记录</td>
  </tr>
  <tr>
   <td></td>
   <td></td>
   <td><strong>提交事务</strong></td>
   <td></td>
  </tr>
 </tbody>
</table>
<p>我们先来看一下Undo Log版本链的产生过程：</p>
<p>最开始我们的记录如图所示：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251220145553663.png&amp;size=m" alt="image-20251220145553663"></p>
<p>然后事务二开始了，它需要把id为30的数据的age改为3：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251220150025647.png&amp;size=m" alt="image-20251220150025647"></p>
<p>此时，InnoDB 会将旧版本数据写入 <strong>Undo Log</strong>，并让当前记录的 <code>DB_ROLL_PTR</code> 指向该快照，形成<strong>版本链</strong>。</p>
<p>接着事务三来了，它把id为30的数据的name改为A3,同样需要在UndoLog里产生一个快照记录。并调整指针的指向，最终如下图所示：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251220150631638.png&amp;size=m" alt="image-20251220150631638"></p>
<p>同样的道理，当事务四也修改了记录，所以也会有一个“快照记录”，我这里就直接给出最后的图：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251220151400080.png&amp;size=m" alt="image-20251220151400080"></p>
<blockquote>
 <p>不同的是事务或相同事务对同一条数据进行刘修改，会导致该记录的undolog生成一条记录版本的链表，链表的头部是最新的旧记录，链表的尾部是最早的旧记录。</p>
</blockquote>
<p>到这里我觉得你肯定已经明白这个Undo Log版本链是如何产生的了，那么接下来我们来看看事务五的两个查询究竟会返回哪条记录！</p>
<p>回顾一下前面说的一致性视图（ReadView）有的地方也叫读视图，它是快照读SQL执行的时，MVCC提取数据的依据，记录并维护当前系统活跃的事务（未提交）的id。这个视图和隔离级别有一定的关系，比如在RC（Read Committed）级别下：每次select都会生成一个快照读。而在RR（Repeatable Read）开启事务后的第一个select语句才是产生快照读的地方。</p>
<p>ReadView维护了四个字段：</p>
<table>
 <thead>
  <tr>
   <th><strong>字段</strong></th>
   <th><strong>含义</strong></th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><strong>m_ids</strong></td>
   <td>当前活跃的事务ID集合，只有未提交（Active）的事务才会进入 m_ids</td>
  </tr>
  <tr>
   <td><strong>min_trx_id</strong></td>
   <td>最小活跃事务ID</td>
  </tr>
  <tr>
   <td><strong>max_trx_id</strong></td>
   <td>ReadView生成时，系统即将分配给下一个事务的 ID</td>
  </tr>
  <tr>
   <td><strong>creator_trx_id</strong></td>
   <td>ReadView创建者的事务ID</td>
  </tr>
 </tbody>
</table>
<p>这里我来再说一下这个活跃的事务ID是啥意思，以我开始的那个表格为例：当我的事务五的第一个查询语句时刻，那个时刻，活跃的事务ID一共有3，4，5。因为事务一已经commit了。</p>
<p>我现在先以RC隔离级别来说一下这个ReadView是如何生成的：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownmarkdownmarkdowndrawio.png&amp;size=m" alt="drawio"></p>
<p>在RC模式下，每次select都会有一个属于自己的ReadView。</p>
<p>然后获取记录，需要按照如下的规则，并结合开始版本链来完成，先看规则，规则如下：</p>
<table>
 <thead>
  <tr>
   <th><strong>序号</strong></th>
   <th><strong>判断条件</strong></th>
   <th><strong>结论</strong></th>
   <th><strong>详细说明</strong></th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><strong>①</strong></td>
   <td><code>trx_id == creator_trx_id</code></td>
   <td><strong>可以访问</strong>该版本</td>
   <td>说明数据是当前这个事务自己更改的。</td>
  </tr>
  <tr>
   <td><strong>②</strong></td>
   <td><code>trx_id &lt; min_trx_id</code></td>
   <td><strong>可以访问</strong>该版本</td>
   <td>说明该事务在 ReadView 生成前已经提交了。</td>
  </tr>
  <tr>
   <td><strong>③</strong></td>
   <td><code>trx_id &gt; max_trx_id</code></td>
   <td><strong>不可以访问</strong>该版本</td>
   <td>说明该版本的事务在当前 ReadView<strong>生成后才启动</strong>，因此不可见。</td>
  </tr>
  <tr>
   <td><strong>④</strong></td>
   <td><code>min_trx_id &lt;= trx_id &lt;= max_trx_id</code></td>
   <td><strong>视情况而定</strong></td>
   <td>如果 <code>trx_id</code> <strong>不在</strong> <code>m_ids</code> 中，<strong>可以访问</strong>。说明该事务在 ReadView 生成前已提交。</td>
  </tr>
 </tbody>
</table>
<p>现在我针对第一个读视图来，看一下它获取的是哪一条数据：</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251220151400080.png&amp;size=m" alt="image-20251220151400080"></p>
<p>目前版本链中有 4 条记录（1 条当前记录，3 条在 Undo Log 中）。</p>
<p>ReadView 状态： <span class="math math-inline">m\_ids: \{3, 4, 5\}</span>，<span class="math math-inline">min\_trx\_id: 3</span>，<span class="math math-inline">max\_trx\_id: 6</span>，<span class="math math-inline">creator\_trx\_id: 5</span>。</p>
<p>我们从最新版本（当前记录）开始按顺序往后找：</p>
<ol>
 <li>分析当前记录（<span class="math math-inline">trx\_id = 4</span>）</li>
</ol>
<ul>
 <li><strong>规则①（是否是自己）：</strong> <span class="math math-inline">trx\_id(4) \neq creator\_trx\_id(5)</span>，不是当前事务修改的。</li>
 <li><strong>规则②（是否已提交）：</strong> <span class="math math-inline">trx\_id(4) \nless min\_trx\_id(3)</span>，说明该版本在 ReadView 生成时可能还未提交。</li>
 <li><strong>规则③（是否太新）：</strong> <span class="math math-inline">trx\_id(4) &lt; max\_trx\_id(6)</span>，说明该事务不是在 ReadView 生成后才开启的。</li>
 <li><strong>规则④（活跃事务判断）：</strong> 虽然 <span class="math math-inline">trx\_id</span> 在 3 和 6 之间，但 <span class="math math-inline">4 \in m\_ids</span>。<strong>结论：</strong> 说明 ReadView 生成时，事务 4 还处于“活跃未提交”状态，<strong>不可见</strong>。</li>
</ul>
<ol start="2">
 <li>分析 Undo Log 版本链</li>
</ol>
<ul>
 <li><strong><span class="math math-inline">trx\_id = 3</span> 的记录：</strong> 同理，虽然 <span class="math math-inline">3 &lt; 6</span>，但 <span class="math math-inline">3 \in m\_ids</span>，说明事务 3 当时也未提交，<strong>不可见</strong>。</li>
 <li><strong><span class="math math-inline">trx\_id = 2</span> 的记录：</strong> 此时 <span class="math math-inline">trx\_id(2) &lt; min\_trx\_id(3)</span>。
  <ul>
   <li><strong>结论：</strong> 根据规则 ②，该版本在 ReadView 生成前就已经提交，<strong>可见</strong>。</li>
  </ul></li>
</ul>
<p>最终结果：第一个 ReadView 最终获取的是 事务 2 提交的数据。</p>
<blockquote>
 <p>其实这里我觉得去套这个公式有点蠢了，你自己看你的事务隔离级别，你事务隔离级别不是读已提交吗，那么ReadView读的肯定就是最新提交的数据呀。一眼就能判断出读取的是哪个数据。RC就是找事务比自己先开启的，并且已经提交的，就可以读。</p>
 <p>同理：如果是在RR级别下，由于会复用第一次select产生的ReadView，所以无论你读多少次，都以第一次select到的数据为准。</p>
 <p><strong>RC 级别：</strong> 本质上就是“<strong>取最新已提交的版本</strong>”。每次 SELECT 都重新生成 ReadView，所以它能看到别人刚提交的修改。</p>
 <p><strong>RR 级别：</strong> 本质上是“<strong>静态快照</strong>”。只在第一次 SELECT 时生成 ReadView，后续无论别人怎么改，它都只认第一次看到的世界。</p>
</blockquote>
<h2 id="数据库主从同步">数据库主从同步</h2>
<p>MySQL 的主从同步（Replication）是实现高可用、负载均衡和读写分离的核心机制。其核心原理可以概括为：<strong>主库（Master）记录变更日志，从库（Slave）读取并重放这些日志。</strong></p>
<p><strong>日志文件：</strong></p>
<ul>
 <li><strong>Binary Log (Binlog)</strong>：存在于主库，记录了所有修改数据库结构的 DDL 和修改数据的 DML 操作。</li>
 <li><strong>Relay Log (中继日志)</strong>：存在于从库，它是从主库拿过来的 Binlog 的临时“中转站”。</li>
</ul>
<p><strong>线程：</strong></p>
<ul>
 <li><strong>Binlog Dump Thread (主库)</strong>：当从库连接时，主库会创建该线程，负责发送 Binlog 内容给从库。</li>
 <li><strong>I/O Thread (从库)</strong>：负责连接主库，请求 Binlog 内容，并将其写入本地的 Relay Log。</li>
 <li><strong>SQL Thread (从库)</strong>：负责读取 Relay Log 中的内容，并在从库本地执行，从而保持数据一致。</li>
</ul>
<h3 id="三种同步方式">三种同步方式</h3>
<table>
 <thead>
  <tr>
   <th><strong>模式</strong></th>
   <th><strong>描述</strong></th>
   <th><strong>优点</strong></th>
   <th><strong>缺点</strong></th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><strong>异步复制 (Asynchronous)</strong></td>
   <td>主库写完 Binlog 后立即返回客户端，不关心从库是否收到。</td>
   <td>性能最高，延迟最低。</td>
   <td>如果主库宕机且日志未传到从库，可能丢失数据。</td>
  </tr>
  <tr>
   <td><strong>半同步复制 (Semi-Synchronous)</strong></td>
   <td>主库至少等待一个从库确认收到 Relay Log 后才返回。</td>
   <td>数据安全性较高。</td>
   <td>会增加主库写操作的响应时间（RT）。</td>
  </tr>
  <tr>
   <td><strong>全同步复制 (Fully Synchronous)</strong></td>
   <td>必须等所有从库都执行完并反馈后才返回。</td>
   <td>数据最安全。</td>
   <td>性能损耗巨大，通常由集群方案（如 MGR）实现。</td>
  </tr>
 </tbody>
</table>
<h2 id="分库分表">分库分表</h2>
<p>当单表数据量达到千万级，或者单库并发请求过高时，MySQL 的性能会显著下降。这时候就需要考虑<strong>分库分表</strong>。</p>
<p>分库分表的核心思想是：<strong>化整为零，分而治之</strong>。</p>
<hr>
<h3 id="1-拆分的两个维度">1. 拆分的两个维度</h3>
<p>分库分表通常分为“垂直拆分”和“水平拆分”两种方式：</p>
<h4 id="垂直拆分vertical-splitting"><strong>垂直拆分（Vertical Splitting）</strong></h4>
<ul>
 <li><strong>垂直分库</strong>：按业务模块将表拆分到不同的数据库中。例如：将用户表、订单表、商品表分别放在 <code>user_db</code>、<code>order_db</code>、<code>product_db</code>。</li>
 <li><strong>垂直分表</strong>：将一张表中的字段拆分到多张表中。通常将 <strong>高频访问列</strong> 和 <strong>低频/大字段列</strong>（如 <code>text</code> 类型的备注、详情）分开，以减少单行数据的体积。</li>
</ul>
<h4 id="水平拆分horizontal-splitting"><strong>水平拆分（Horizontal Splitting）</strong></h4>
<ul>
 <li><strong>水平分表</strong>：表的结构不变，按某种规则将数据行存入不同的表中（如 <code>order_0</code>, <code>order_1</code>）。</li>
 <li><strong>水平分库</strong>：将不同的数据行存入不同的物理数据库实例中，主要解决单机高并发和磁盘 IO 的瓶颈。</li>
</ul>
<hr>
<h3 id="2-数据的分片策略sharding-strategy">2. 数据的分片策略（Sharding Strategy）</h3>
<p>如何决定一条数据该去哪个表？常见的策略有：</p>
<ol>
 <li><strong>取模（Hash）</strong>：
  <ul>
   <li><strong>原理</strong>：<code>user_id % 4</code>，将数据均匀分布在 4 个分片。</li>
   <li><strong>优点</strong>：数据分布均匀，不易产生热点。</li>
   <li><strong>缺点</strong>：扩容极其麻烦（需要全量数据迁移）。</li>
  </ul></li>
 <li><strong>范围（Range）</strong>：
  <ul>
   <li><strong>原理</strong>：按时间（如每月一张表）或按自增 ID 范围。</li>
   <li><strong>优点</strong>：扩容简单，无需迁移历史数据。</li>
   <li><strong>缺点</strong>：容易产生<strong>热点问题</strong>（比如最近一个月的数据会被疯狂读写，而老的表却很闲）。</li>
  </ul></li>
 <li><strong>查找表（Map）</strong>：
  <ul>
   <li><strong>原理</strong>：在元数据库中记录每条数据对应的分片位置。虽然灵活，但维护成本高且有性能损耗。</li>
  </ul></li>
</ol>
<hr>
<h3 id="3-分库分表带来的技术挑战">3. 分库分表带来的技术挑战</h3>
<p>这是面试和实际架构中最难的部分，因为拆分后会破坏原生数据库的特性：</p>
<ul>
 <li><strong>分布式事务问题</strong>：原本一个事务就能解决的写操作，现在跨了库。通常需要引入 <strong>Seata</strong> 这种分布式事务中间件，或者使用 <strong>TCC、可靠消息最终一致性</strong> 等方案。</li>
 <li><strong>跨节点 Join 问题</strong>：无法直接在 SQL 里 Join 不同库的表。
  <ul>
   <li><em>对策</em>：字段冗余（空间换时间）、全局表（每个库都存一份小表）、在应用层（Java 代码）多次查询后组装。</li>
  </ul></li>
 <li><strong>分页与排序（Max/Count/Order By）</strong>：
  <ul>
   <li><em>对策</em>：需要在每个分片执行后，在中间件层面进行<strong>归并排序</strong>。如果偏移量（Offset）很大，性能会急剧下降。</li>
  </ul></li>
 <li><strong>全局唯一 ID</strong>：单表自增 ID 失效。
  <ul>
   <li><em>对策</em>：使用 <strong>雪花算法（Snowflake）</strong> 或分布式 ID 生成器。</li>
  </ul></li>
</ul>
<hr>
<h3 id="4-常见的中间件解决方案">4. 常见的中间件解决方案</h3>
<p>目前主流的方案分为两类：</p>
<table>
 <thead>
  <tr>
   <th><strong>类型</strong></th>
   <th><strong>代表产品</strong></th>
   <th><strong>优点</strong></th>
   <th><strong>缺点</strong></th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><strong>客户端代理 (SDK)</strong></td>
   <td><strong>ShardingSphere-JDBC</strong></td>
   <td>性能高，无需独立部署，支持 Spring Boot。</td>
   <td>对代码有侵入性，升级需重启应用。</td>
  </tr>
  <tr>
   <td><strong>服务端代理 (Proxy)</strong></td>
   <td><strong>MyCat</strong>, ShardingSphere-Proxy</td>
   <td>对代码透明（像连 MySQL 一样），跨语言。</td>
   <td>增加了一层网络消耗，需要运维中间件。</td>
  </tr>
 </tbody>
</table>
<hr>
<h3 id="5-什么时候该分库分表">5. 什么时候该分库分表？</h3>
<p>不要为了“高大上”而分。阿里巴巴开发手册建议：<strong>单表行数超过 500 万行或者单表容量超过 2GB</strong> 时才考虑。</p>
<p>在动手之前，应优先考虑以下顺序：</p>
<ol>
 <li><strong>SQL 优化与索引优化</strong>。</li>
 <li><strong>读写分离</strong>（利用主从架构缓解读压力）。</li>
 <li><strong>缓存（Redis）</strong>。</li>
 <li><strong>冷热数据分离</strong>（将历史旧数据归档）。</li>
 <li><strong>最后才是分库分表</strong></li>
</ol>]]></description><guid isPermaLink="false">/archives/mysqlchang-jian-mian-shi-ti</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-97e2a23c-ce25-4477-9e67-54924c1f24a9-9ca97bba.png&amp;size=m" type="image/jpeg" length="1789936"/><category>关系型数据库</category><pubDate>Sat, 20 Dec 2025 08:44:35 GMT</pubDate></item><item><title><![CDATA[Redis面试常见题]]></title><link>https://likeyy.love/archives/redismian-shi-chang-jian-ti</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=Redis%E9%9D%A2%E8%AF%95%E5%B8%B8%E8%A7%81%E9%A2%98&amp;url=/archives/redismian-shi-chang-jian-ti" width="1" height="1" alt="" style="opacity:0;">
<h1 id="Redis面试常见题">Redis面试常见题</h1>
<h2 id="缓存穿透">缓存穿透</h2>
<p>定义：查询一个不存在的数据，DB查询不到数据也不会直接写入缓存，就会导致每次请求都查数据库（如果有人恶意攻击，会击垮数据库）。</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251218215248730.png&amp;size=m" alt="image-20251218215248730"></p>
<p>解决办法：</p>
<ol>
 <li>
  <p>缓存空数据，查询返回的数据为空，仍然把这个空结果进行缓存</p>
  <p>优点：实现简单。</p>
  <p>缺点：消耗内存，可能发生数据不一致的问题</p>
 </li>
 <li>
  <p>使用布隆过滤器</p>
  <p>优点：内存占用较少，没有多余key</p>
  <p>缺点：实现复杂，存在一定误判</p>
 </li>
</ol>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251218220222405.png&amp;size=m" alt="image-20251218220222405"></p>
<h3 id="布隆过滤器">布隆过滤器</h3>
<p><strong>布隆过滤器（Bloom Filter）</strong> 是一种非常节省空间的概率型数据结构，主要用于判断 <strong>“一个元素是否在一个集合中”</strong>。它的核心特点是：<strong>高效、省空间，但存在一定的误报率。</strong></p>
<ol>
 <li>核心原理</li>
</ol>
<p>布隆过滤器的本质是一个 <strong>位数组（Bit Array）</strong> 和一系列 <strong>无偏哈希函数（Hash Functions）</strong>。</p>
<ul>
 <li>
  <p><strong>初始化</strong>：开始时，位数组的所有位都设为 <code>0</code>。</p>
 </li>
 <li>
  <p><strong>添加元素</strong>：当一个元素被加入集合时，通过多个哈希函数计算出多个索引值，并将位数组中对应这些索引的位置全部设为 <code>1</code>。</p>
 </li>
 <li>
  <p><strong>查询元素</strong>：查询时，同样用这些哈希函数计算索引。</p>
  <ul>
   <li>如果这些位置中<strong>有任何一个为</strong> <code>0</code>，那么该元素<strong>一定不在</strong>集合中。</li>
   <li>如果这些位置<strong>全部为</strong> <code>1</code>，那么该元素<strong>可能在</strong>集合中（也可能是因为其他多个元素凑巧把这些位置都染红了，这就是“误报”）。</li>
  </ul>
 </li>
</ul>
<p>为啥会误判？</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251218221344994.png&amp;size=m" alt="image-20251218221344994"></p>
<h2 id="缓存击穿">缓存击穿</h2>
<p>定义：给某一个key设置了过期时间，当key过期的时候，恰好这时间点对这个key有大量的并发请求过来，这些并发的请求可能会瞬间把DB压垮</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251218221629918.png&amp;size=m" alt="image-20251218221629918"></p>
<p>解决方案：</p>
<ol>
 <li>
  <p>互斥锁：互斥地读取redis缓存，使得每次只能有一条请求，它没有找到数据后跨过缓存去数据库找数据，进行缓存重建。</p>
  <div class="language-mermaid">sequenceDiagram participant C as Client (客户端) participant R as Redis (缓存) participant L as Lock (分布式锁) participant DB as Database (数据库) C-&gt;&gt;R: 1. 查询缓存 (Key) R--&gt;&gt;C: 返回空 (缓存失效/击穿) C-&gt;&gt;L: 2. 尝试获取分布式锁 alt 获取锁成功 C-&gt;&gt;R: 3. 二次检查 (Double Check) Note over C,R: 防止在拿锁期间其他线程已更新缓存 alt 缓存依然为空 C-&gt;&gt;DB: 4. 查询数据库 DB--&gt;&gt;C: 返回数据 C-&gt;&gt;R: 5. 回写缓存 (带过期时间) else 缓存已有数据 Note right of R: 直接使用新产生的缓存数据 end C-&gt;&gt;L: 6. 释放分布式锁 C--&gt;&gt;C: 7. 返回结果给业务层 else 获取锁失败 C-&gt;&gt;C: 8. 休眠等待 (如 50ms) C-&gt;&gt;R: 9. 重试：重新查询缓存 R--&gt;&gt;C: 返回数据 (此时前序线程通常已填好) C--&gt;&gt;C: 10. 返回结果 end</div>
 </li>
 <li>
  <p>逻辑过期：会有一条请求拿了锁之后去开一个新线程尝试重建内存，请求不会等待，直接返回逻辑过期的数据</p>
 </li>
</ol>
<div class="language-mermaid">sequenceDiagram participant C as Client (客户端) participant R as Redis (缓存) participant L as Lock (互斥锁) participant T as New Thread (后台线程) participant DB as Database (数据库) C-&gt;&gt;R: 1. 查询缓存 (Key) R--&gt;&gt;C: 返回数据 (包含逻辑过期时间) rect rgb(240, 240, 240) Note over C, R: 判定逻辑：当前时间 &gt; 数据中的过期时间 C-&gt;&gt;L: 2. 尝试获取分布式锁 (非阻塞) alt 获取锁成功 C--&gt;&gt;T: 3. 开启后台异步线程 T-&gt;&gt;DB: 4. 查询数据库 (重建数据) DB--&gt;&gt;T: 返回最新数据 T-&gt;&gt;R: 5. 写入缓存 (重置逻辑过期时间) T-&gt;&gt;L: 6. 释放分布式锁 C--&gt;&gt;C: 7. 获取锁的请求：立即返回旧数据 else 获取锁失败 C--&gt;&gt;C: 8. 未拿到锁的请求：立即返回旧数据 end end</div>
<h3 id="总结">总结</h3>
<table>
 <thead>
  <tr>
   <th></th>
   <th></th>
   <th></th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><strong>特性</strong></td>
   <td><strong>互斥锁方案 (Mutex)</strong></td>
   <td><strong>逻辑过期方案 (Logical)</strong></td>
  </tr>
  <tr>
   <td><strong>核心思路</strong></td>
   <td>没数据就锁住，让一个线程去查，其他人<strong>排队等待</strong>。</td>
   <td>缓存永不过期，发现快过期了就锁住，派个<strong>后台线程</strong>去更新。</td>
  </tr>
  <tr>
   <td><strong>一致性</strong></td>
   <td><strong>强一致性</strong>。用户拿到的永远是数据库中最新的数据。</td>
   <td><strong>弱一致性</strong>。更新期间，用户会拿到逻辑上已过期的旧数据。</td>
  </tr>
  <tr>
   <td><strong>可用性/性能</strong></td>
   <td><strong>较好</strong>。但由于存在阻塞和重试，高并发下响应时间会有抖动。</td>
   <td><strong>极好</strong>。完全非阻塞，用户请求立即返回，响应速度最快。</td>
  </tr>
  <tr>
   <td><strong>优点</strong></td>
   <td>1. 保证数据最新。 2. 不需要额外的内存字段。 3. 实现逻辑相对直观。</td>
   <td>1. 性能极高，无等待时间。 2. 用户体验极佳，不会因为重建缓存而卡顿。</td>
  </tr>
  <tr>
   <td><strong>缺点</strong></td>
   <td>1. 有死锁风险（虽概率低）。 2. 线程阻塞会消耗 CPU 资源，性能略低。</td>
   <td>1. 存在数据不一致窗口。 2. 实现复杂（需代码维护逻辑时间、开启异步线程）。</td>
  </tr>
 </tbody>
</table>
<p><strong>优先选择“互斥锁”的场景：</strong></p>
<ul>
 <li><strong>对数据一致性要求极高</strong>：例如金融账户余额、库存精准扣减、活动起止时间等。</li>
 <li><strong>并发量大但可控</strong>：虽然有阻塞，但在配置好连接池和超时时间的情况下，系统可以承受。</li>
</ul>
<p><strong>优先选择“逻辑过期”的场景：</strong></p>
<ul>
 <li><strong>用户体验高于实时性</strong>：例如热点新闻浏览、社交平台大 V 的主页、商品详情页的非核心信息。这些场景下，用户多看几秒钟“半分钟前”的数据通常没关系，但如果页面转圈圈（阻塞等待）则无法接受。</li>
 <li><strong>极高并发请求</strong>：当瞬间流量大到排队重试会导致系统线程耗尽（雪崩隐患）时，逻辑过期这种“立即返回”的策略更为安全。</li>
</ul>
<h2 id="缓存雪崩">缓存雪崩</h2>
<p>定义：<strong>缓存雪崩</strong>是指在同一时段大量的缓存key同时失效或者Redis服务宕机，导致大量请求到达数据库，带来巨大压力。</p>
<p>解决方案：</p>
<ol>
 <li>给不同的Key的TTL添加随机值（就不会突然大量key同时失效了）</li>
 <li>利用<a href="https://zhida.zhihu.com/search?content_id=237392462&amp;content_type=Article&amp;match_order=1&amp;q=Redis%E9%9B%86%E7%BE%A4&amp;zhida_source=entity">Redis集群</a>提高服务的可用性（哨兵模式、集群模式）</li>
 <li>给缓存业务添加降级限流策略（ngxin或spring cloud gateway，这个可以保底，三个都能用这个尝试解决</li>
 <li>给业务添加多级缓存（Guava或Caffeine）</li>
</ol>
<h2 id="缓存一致性">缓存一致性</h2>
<p><a href="https://likeyy.love/archives/mysqlhe-redisshuang-xie-bu-yi-zhi-wen-ti-zen-me-jie-jue">缓存一致性解决方案</a></p>
<h2 id="Redis持久化">Redis持久化</h2>
<ul>
 <li>RDB全称Redis Database Backup file（Redis数据备份文件），也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后，从磁盘读取快照文件，恢复数据</li>
 <li>AOF全称为Append Only File（追加文件）。Redis处理的每一个写命令都会记录在AOF文件，可以看做是命令日志文件。</li>
</ul>
<h3 id="RDB的执行原理">RDB的执行原理</h3>
<ul>
 <li>
  <p>bgsave开始时会fork主进程得到子进程，子进程共享主进程的内存数据（复制页表）。完成fork后读取内存数据并写入 RDB 文件。</p>
 </li>
 <li>
  <p>fork采用的是copy-on-write技术：</p>
  <ul>
   <li>当主进程执行读操作时，访问共享内存；</li>
   <li>当主进程执行写操作时，则会拷贝一份数据，执行写操作。</li>
  </ul>
 </li>
</ul>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251218224226319.png&amp;size=m" alt="image-20251218224226319"></p>
<h3 id="AOF">AOF</h3>
<ul>
 <li>AOF默认是关闭的，需要修改redis.conf配置文件来开启AOF</li>
 <li>AOF的命令记录的频率也可以通过redis.conf文件来配</li>
 <li>因为是记录命令，AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作，但只有最后一次写操作才有意义。通过执行bgrewriteaof命令，可以让AOF文件执行重写功能，用最少的命令达到相同效果。</li>
</ul>
<p>Redis AOF 配置项</p>
<pre><code class="language-shell"># 是否开启AOF功能，默认是no
appendonly yes

# AOF文件的名称
appendfilename "appendonly.aof"

# 表示每执行一次写命令，立即记录到AOF文件
# appendfsync always

# 写命令执行完先放入AOF缓冲区，然后表示每隔1秒将缓冲区数据写到AOF文件，是默认方案
appendfsync everysec

# 写命令执行完先放入AOF缓冲区，由操作系统决定何时将缓冲区内容写回磁盘
# appendfsync no

</code></pre>
<p>AOF 刷盘策略对比表</p>
<table>
 <thead>
  <tr>
   <th></th>
   <th></th>
   <th></th>
   <th></th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><strong>配置项</strong></td>
   <td><strong>刷盘时机</strong></td>
   <td><strong>优点</strong></td>
   <td><strong>缺点</strong></td>
  </tr>
  <tr>
   <td><strong>Always</strong></td>
   <td><strong>同步刷盘</strong>：每执行一次写命令立即调用 <code>fsync</code> 写入磁盘。</td>
   <td>可靠性最高，几乎不丢失数据。</td>
   <td>性能影响大，受磁盘 I/O 限制明显。</td>
  </tr>
  <tr>
   <td><strong>everysec</strong></td>
   <td><strong>每秒刷盘</strong>：每隔一秒由异步线程执行一次 <code>fsync</code>。</td>
   <td>性能适中，兼顾了安全与效率。</td>
   <td>在宕机情况下，最多丢失 1 秒的数据。</td>
  </tr>
  <tr>
   <td><strong>no</strong></td>
   <td><strong>操作系统控制</strong>：由操作系统（OS）决定何时将缓冲区数据同步到磁盘。</td>
   <td>性能最好，对业务响应无额外延迟。</td>
   <td>可靠性最差，掉电时可能丢失大量数据。</td>
  </tr>
 </tbody>
</table>
<p>bgrewriteaof命令触发设置</p>
<pre><code class="language-shell"># AOF文件比上次文件 增长超过多少百分比则触发重写
auto-aof-rewrite-percentage 100

# AOF文件体积最小多大以上才触发重写
auto-aof-rewrite-min-size 64mb

</code></pre>
<p><strong>默认选择</strong>：在生产环境下，Redis 官方默认推荐使用 <code>everysec</code>，因为它在性能和可靠性之间取得了极佳的平衡。</p>
<p><strong>配合 RDB 使用</strong>：通常建议将 AOF 与 RDB 快照配合使用，以获得更快的故障恢复速度和更高的数据安全性。</p>
<h3 id="总结-">总结</h3>
<p>RDB 与 AOF 持久化方式对比</p>
<table>
 <thead>
  <tr>
   <th></th>
   <th></th>
   <th></th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><strong>对比维度</strong></td>
   <td><strong>RDB (Redis Database)</strong></td>
   <td><strong>AOF (Append Only File)</strong></td>
  </tr>
  <tr>
   <td><strong>持久化方式</strong></td>
   <td>定时对整个内存做快照。</td>
   <td>记录每一次执行的命令。</td>
  </tr>
  <tr>
   <td><strong>数据完整性</strong></td>
   <td>不完整，两次备份之间会丢失数据。</td>
   <td>相对完整，取决于刷盘策略。</td>
  </tr>
  <tr>
   <td><strong>文件大小</strong></td>
   <td>会有压缩，文件体积小。</td>
   <td>记录命令，文件体积很大。</td>
  </tr>
  <tr>
   <td><strong>宕机恢复速度</strong></td>
   <td>很快。</td>
   <td>慢。</td>
  </tr>
  <tr>
   <td><strong>数据恢复优先级</strong></td>
   <td>低，因为数据完整性不如 AOF。</td>
   <td>高，因为数据完整性更高。</td>
  </tr>
  <tr>
   <td><strong>系统资源占用</strong></td>
   <td>高，大量 CPU 和内存消耗（Fork 进程）。</td>
   <td>低（主要是磁盘 IO），但重写时占用 CPU/内存。</td>
  </tr>
  <tr>
   <td><strong>使用场景</strong></td>
   <td>容忍数分钟数据丢失，追求快启动。</td>
   <td>对数据安全性要求较高。</td>
  </tr>
 </tbody>
</table>
<h2 id="Redis过期策略">Redis过期策略</h2>
<h3 id="惰性删除">惰性删除</h3>
<ul>
 <li>**定义：**设置该key过期时间后，我们不去管它，当需要该key时，我们在检查其是否过期，如果过期，我们就删掉它，反之返回该key</li>
 <li><strong>优点</strong> ：对CPU友好，只会在使用该key时才会进行过期检查，对于很多用不到的key不用浪费时间进行过期检查</li>
 <li><strong>缺点</strong> ：对内存不友好，如果一个key已经过期，但是一直没有使用，那么该key就会一直存在内存中，内存永远不会释放</li>
</ul>
<h3 id="定期删除">定期删除</h3>
<ul>
 <li>**定义：**每隔一段时间，我们就对一些key进行检查，删除里面过期的key(从一定数量的数据库中取出一定数量的随机key进行检查，并删除其中的过期key)。</li>
 <li>SLOW模式是定时任务，执行频率默认为10hz，每次不超过25ms，以通过修改配置文件redis.conf 的hz 选项来调整这个次数</li>
 <li>FAST模式执行频率不固定，但两次间隔不低于2ms，每次耗时不超过1ms</li>
 <li><strong>优点</strong>：可以通过限制删除操作执行的时长和频率来减少删除操作对 CPU 的影响。另外定期删除，也能有效释放过期键占用的内存。</li>
 <li><strong>缺点</strong>：难以确定删除操作执行的时长和频率。</li>
</ul>
<h3 id="数据的淘汰策略">数据的淘汰策略</h3>
<p>当Redis中的内存不够用时，此时在向Redis中添加新的key，那么Redis就会按照某一种规则将内存中的数据删除掉，这种数据的删除规则被称之为内存的淘汰策略。</p>
<ul>
 <li>noeviction： 不淘汰任何key，但是内存满时不允许写入新数据，默认就是这种策略。</li>
 <li>volatile-ttl： 对设置了TTL的key，比较key的剩余TTL值，TTL越小越先被淘汰</li>
 <li>allkeys-random：对全体key ，随机进行淘汰。</li>
 <li>volatile-random：对设置了TTL的key ，随机进行淘汰。</li>
 <li>allkeys-lru： 对全体key，基于LRU算法进行淘汰</li>
 <li>volatile-lru： 对设置了TTL的key，基于LRU算法进行淘汰</li>
 <li>allkeys-lfu： 对全体key，基于LFU算法进行淘汰</li>
 <li>volatile-lfu： 对设置了TTL的key，基于LFU算法进行淘汰</li>
</ul>
<p>数据的淘汰策略使用建议</p>
<ul>
 <li>优先使用 allkeys-lru 策略。充分利用 LRU 算法的优势，把最近最常访问的数据留在缓存中。如果业务有明显的冷热数据区分，建议使用。</li>
 <li>如果业务中数据访问频率差别不大，没有明显冷热数据区分，建议使用 allkeys-random，随机选择淘汰。</li>
 <li>如果业务中有置顶的需求，可以使用 volatile-lru 策略，同时置顶数据不设置过期时间，这些数据就一直不被删除，会淘汰其他设置过期时间的数据。</li>
 <li>如果业务中有短时高频访问的数据，可以使用 allkeys-lfu 或 volatile-lfu 策略。</li>
</ul>
<h2 id="Redisson">Redisson</h2>
<ul>
 <li>底层是setnx和lua脚本（保证原子性）</li>
 <li>获取锁：SET lock value NX EX 10 ，注意：NX是互斥、EX是设置超时时间</li>
 <li>*释放锁：*DEL key</li>
 <li>超时事件可以自己设置控制；也可以靠看门狗帮忙做续期</li>
 <li>redisson实现的分布式锁-可重入（同一线程可以获取同一把锁，会记录重入次数，重入次数到0才会删除这个锁）</li>
</ul>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251218225925952.png&amp;size=m" alt="image-20251218225925952"></p>
<h3 id="redisson实现的分布式锁">redisson实现的分布式锁</h3>
<p>注意：主从一致性问题，Redisson无法解决主从一致性问题。</p>
<p>为啥会出现主从一致性问题？</p>
<p>Redis 的主从复制是<strong>异步</strong>的。这就导致了一个潜在的“锁丢失”风险：</p>
<ol>
 <li><strong>客户端 A</strong> 在 Redis 主节点（Master）上成功获取了锁。</li>
 <li>主节点在将这个锁的数据同步给从节点（Slave）之前，突然<strong>宕机</strong>了。</li>
 <li>从节点被哨兵提升为新的主节点。</li>
 <li><strong>客户端 B</strong> 请求获取同一个锁，由于新主节点上没有该锁的数据，<strong>客户端 B 也能获取成功</strong>。</li>
</ol>
<p>此时，系统中同时有两个客户端持有同一把锁，分布式锁的<strong>互斥性</strong>宣告失效。</p>
<p>如何解决？</p>
<p>Redisson 提供了两种主要的思路来应对这个问题：</p>
<ol>
 <li>
  <p>Wait 机制（不常用）</p>
  <p>Redisson 允许在发送命令后使用 <code>WAIT</code> 命令，要求主节点必须将数据同步到指定数量的从节点后才返回成功。虽然提高了可靠性，但牺牲了性能，且在极端网络分区下仍有风险。</p>
 </li>
 <li>
  <p>红锁算法（Redlock）—— 不推荐</p>
  <p>红锁不再依赖“主从架构”，而是要求部署 <strong>N 个完全独立的 Redis 节点</strong>（通常为 5 个，这些节点之间没有任何主从关系）。</p>
  <ol>
   <li><strong>获取时间戳</strong>：客户端获取当前系统时间。</li>
   <li><strong>轮询加锁</strong>：客户端尝试在 N 个节点上依次申请加锁。加锁时使用很短的超时时间（比如几十毫秒），防止在某个宕机的节点上耗时太久。</li>
   <li><strong>过半成功原则</strong>：只有当客户端在<strong>大多数</strong>（<span class="language-math">​N/2 + 1</span>）节点上加锁成功，且总耗时小于锁的有效期时，才认为最终获取锁成功。</li>
   <li><strong>计算有效期</strong>：实际锁的有效时间 = 初始有效时间 - 加锁消耗的时间。</li>
   <li><strong>失败释放</strong>：如果获取锁失败，客户端必须向<strong>所有</strong>节点发起解锁请求（即使有些节点加锁没成功）。</li>
  </ol>
 </li>
</ol>
<p>但是红锁有如下缺点，所有目前官方不推荐使用红锁来解决：</p>
<ol>
 <li><strong>运维成本</strong>：维护 5 个独立的 Redis 实例非常麻烦。</li>
 <li><strong>性能损耗</strong>：多次网络交互会导致延迟增加。</li>
 <li><strong>实际场景</strong>：大多数业务场景下，主从切换导致的瞬时锁丢失可以通过业务幂等性或数据库唯一索引来兜底。</li>
</ol>
<p>若要强一致性，可以使用zookeeper这种CP模式的分布式锁：</p>
<p>Redis 分布式锁的核心设计目标是 <strong>AP（可用性）</strong>，而 ZooKeeper 的核心设计目标是 <strong>CP（一致性）</strong>。在 ZK 中，只要写入成功，就意味着超过半数节点已同步，且 Leader 宕机后，只有数据最新的节点才能当选新 Leader，因此不会出现类似 Redis 主从异步复制导致的锁丢失问题。</p>
<h3 id="ZooKeeper-实现分布式锁的原理">ZooKeeper 实现分布式锁的原理</h3>
<p>ZK 实现分布式锁主要依赖其 <strong>临时顺序节点（Ephemeral Sequential Nodes）</strong> 和 <strong>监听机制（Watcher）</strong>。</p>
<p>核心步骤：</p>
<ol>
 <li>
  <p><strong>创建父节点</strong>：在 ZK 中创建一个持久节点 <code>/MyLock</code>。</p>
 </li>
 <li>
  <p><strong>创建临时顺序节点</strong>：每个尝试加锁的客户端，都会在 <code>/MyLock</code> 下创建一个临时顺序节点。</p>
  <ul>
   <li>客户端 A 创建节点 <code>/MyLock/lock-0000001</code></li>
   <li>客户端 B 创建节点 <code>/MyLock/lock-0000002</code></li>
  </ul>
 </li>
 <li>
  <p><strong>判断最小节点</strong>：客户端获取 <code>/MyLock</code> 下的所有子节点，判断自己创建的节点序号是否是<strong>最小</strong>的。</p>
  <ul>
   <li>如果是最小的，则<strong>获得锁</strong>。</li>
   <li>如果不是最小的，则<strong>等待锁</strong>。</li>
  </ul>
 </li>
 <li>
  <p><strong>监听前一个节点</strong>：<strong>关键点在于：</strong> 没拿到锁的客户端不会轮询，而是向它<strong>序号前一个</strong>的节点注册一个 <code>Watcher</code> 监听。</p>
 </li>
 <li>
  <p><strong>释放锁</strong>：当持有锁的客户端操作完成或意外断开连接（Session 超时）时，该临时节点会自动删除。</p>
 </li>
 <li>
  <p><strong>触发回调</strong>：ZK 通知监听了该节点的后续客户端。后续客户端再次检查自己是否为最小节点，如果是，则获取锁。</p>
 </li>
</ol>
<p>为什么 ZK 能保证强一致性？</p>
<ul>
 <li><strong>顺序性</strong>：ZK 保证所有的更新操作都是全局有序的。</li>
 <li><strong>非异步丢失</strong>：ZK 使用 ZAB 协议，只有过半节点写入成功才会返回确认。</li>
 <li><strong>自动释放</strong>：由于使用的是“临时节点”，如果客户端所在的服务器挂了，ZK 感知到 Session 断开会立刻删除节点，从而自动释放锁，避免死锁。</li>
 <li><strong>无羊群效应</strong>：每个节点只监听前一个节点，当锁释放时，只会唤醒一个客户端，不会造成全集群的惊群效应（Thundering Herd）。</li>
</ul>
<h2 id="Redis主从复制">Redis主从复制</h2>
<p>单节点Redis的并发能力是有上限的，要进一步提高Redis的并发能力，就需要搭建主从集群，实现读写分离。</p>
<h3 id="全量同步">全量同步</h3>
<div class="language-mermaid">sequenceDiagram participant M as Master participant S as Slave S-&gt;&gt;S: 1. 执行 replicaof 命令，建立连接 S-&gt;&gt;M: 2. 请求数据同步 (发送 replid, offset) rect rgb(240, 240, 240) Note right of M: 3. 判断是否是第一次同步 (检查 replid 是否一致) end M-&gt;&gt;S: 4. 如果是第一次，返回 Master 的数据版本信息 (replid, offset) S-&gt;&gt;S: 5. 保存版本信息 M-&gt;&gt;M: 6. 执行 bgsave，生成 RDB 文件 par 并行处理 M-&gt;&gt;M: 9. 记录 RDB 期间的所有命令到 repl_baklog and 发送文件 M-&gt;&gt;S: 7. 发送 RDB 文件 end S-&gt;&gt;S: 8. 清空本地数据，加载 RDB 文件 M-&gt;&gt;S: 10. 发送 repl_baklog 中的命令 S-&gt;&gt;S: 11. 执行接收到的命令</div>
<p>关键步骤解析：</p>
<ul>
 <li><strong>Replication Id (replid):</strong> 数据集的标记。如果 Slave 的 <code>replid</code> 与 Master 不一致，说明是第一次同步或需要全量同步。</li>
 <li><strong>Offset (偏移量):</strong> 随着 <code>repl_baklog</code> 中数据的增多而增大。如果 Slave 的 <code>offset</code> 小于 Master，说明数据落后，需要更新。</li>
 <li><strong>RDB 生成与传输:</strong> Master 在后台生成快照（bgsave），并将其发送给 Slave。这是最耗时的阶段。</li>
 <li><strong>repl_baklog:</strong> 在生成和传输 RDB 文件的过程中，新产生的写命令会被缓存在这个日志中，确保 Slave 在加载完 RDB 后能补全这段时间的增量数据。</li>
</ul>
<h3 id="增量同步">增量同步</h3>
<div class="language-mermaid">sequenceDiagram participant M as Master participant S as Slave S-&gt;&gt;S: 1. 重启 (或其他原因触发重连) S-&gt;&gt;M: 2. 发送 psync replid offset rect rgb(240, 240, 240) Note right of M: 3. 判断请求 replid 是否一致 end M-&gt;&gt;S: 4. 不是第一次同步，返回 CONTINUE S-&gt;&gt;S: 5. 保存/更新版本信息 M-&gt;&gt;M: 6. 去 repl_baklog 中获取 offset 后的数据 M-&gt;&gt;S: 7. 发送 offset 后的命令 S-&gt;&gt;S: 8. 执行命令</div>
<p>流程要点分析：</p>
<ul>
 <li><strong>触发条件</strong>：主要用于 Slave 节点重启或网络闪断后恢复连接的情况。</li>
 <li><strong>psync 命令</strong>：Slave 会主动告知 Master 自己目前的 <code>replid</code> 和已经同步到的 <code>offset</code>（偏移量）。</li>
 <li><strong>判断逻辑</strong>：Master 检查 <code>replid</code> 是否匹配。如果一致，且请求的 <code>offset</code> 仍在 Master 的 <code>repl_baklog</code>（积压缓冲区）范围内，则执行增量同步。</li>
 <li><strong>效率优势</strong>：相比全量同步（需要生成和传输巨大的 RDB 文件），增量同步只传输断开期间缺失的少量写命令，极大地降低了带宽消耗和系统压力。</li>
</ul>
<h2 id="Redis哨兵模式-其实不常用-">Redis哨兵模式（其实不常用）</h2>
<p>Redis提供了哨兵（Sentinel）机制来实现主从集群的自动故障恢复</p>
<ul>
 <li><strong>监控</strong>：Sentinel 会不断检查您的master和slave是否按预期工作</li>
 <li><strong>自动故障恢复</strong>：如果master故障，Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主</li>
 <li><strong>通知</strong>：Sentinel充当Redis客户端的服务发现来源，当集群发生故障转移时，会将最新信息推送给Redis的客户端</li>
 <li>Sentinel基于心跳机制监测服务状态，每隔1秒向集群的每个实例发送ping命令</li>
 <li>主观下线：如果某sentinel节点发现某实例未在规定时间响应，则认为该实例<strong>主观下线</strong>。</li>
 <li>客观下线：若超过指定数量（quorum）的sentinel都认为该实例主观下线，则该实例<strong>客观下线</strong>。quorum值最好超过Sentinel实例数量的一半。</li>
</ul>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251219095218481.png&amp;size=m" alt="image-20251219095218481"></p>
<h3 id="哨兵选主规则"><strong>哨兵选主规则</strong></h3>
<ul>
 <li>首先判断主与从节点断开时间长短，如超过指定值就排该从节点</li>
 <li>然后判断从节点的slave-priority值，越小优先级越高</li>
 <li>如果slave-prority一样，则判断slave节点的offset值，越大优先级越高</li>
 <li>最后是判断slave节点的运行id大小，越小优先级越高。</li>
</ul>
<h3 id="redis集群-哨兵模式-脑裂">redis集群（哨兵模式）脑裂</h3>
<ul>
 <li><strong>集群脑裂</strong>是由于主节点和从节点和sentinel处于不同的网络分区，使得sentinel没有能够心跳感知到主节点，所以通过选举的方式提升了一个从节点为主，这样就存在了两个master，就像大脑分裂了一样，这样会导致客户端还在老的主节点那里写入数据，新节点无法同步数据，当网络恢复后，sentinel会将老的主节点降为从节点，这时再从新master同步数据，就会导致数据丢失</li>
 <li>**解决：**我们可以修改redis的配置，可以设置最少的从节点数量以及缩短主从数据同步的延迟时间，达不到要求就拒绝请求，就可以避免大量的数据丢失</li>
</ul>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251219095737658.png&amp;size=m" alt="image-20251219095737658"></p>
<h2 id="Redis分片集群结构">Redis分片集群结构</h2>
<p>主从和哨兵可以解决高可用、高并发读的问题。但是依然有两个问题没有解决</p>
<ul>
 <li>海量数据存储问题</li>
 <li>高并发写的问题</li>
</ul>
<p>使用分片集群可以解决上述问题，分片集群特征</p>
<ul>
 <li>集群中有多个master，每个master保存不同数据</li>
 <li>每个master都可以有多个slave节点</li>
 <li>master之间通过ping监测彼此健康状态</li>
 <li>客户端请求可以访问集群任意节点，最终都会被转发到正确节点（自动路由）</li>
</ul>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251219100602764.png&amp;size=m" alt="image-20251219100602764"></p>
<p>分片集群结构-数据读写</p>
<ul>
 <li>Redis 分片集群引入了哈希槽的概念，Redis 集群有 16384 个哈希槽，每个 key通过 CRC16 校验后对 16384 取模来决定放置哪个槽，集群的每个节点负责一部分 hash 槽。</li>
</ul>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251219101209368.png&amp;size=m" alt="image-20251219101209368"></p>
<h2 id="Redis是单线程的-为什么还那么快">Redis是单线程的，为什么还那么快</h2>
<ul>
 <li>redis是纯内存操作，执行速度非常快</li>
 <li>采用单线程，避免不必要的上下文切换可竞争条件，多线程还要考虑线程安全问题</li>
 <li>使用I/O多路复用模型，非阻塞IO</li>
 <li>Redis是纯内存操作，执行速度非常快，它的性能瓶颈是网络延迟而不是执行速度， I/O多路复用模型主要就是实现了高效的网络请求</li>
</ul>
<p>I/O模型</p>
<ul>
 <li>阻塞IO：阻塞IO模型中，用户进程在两个阶段都是阻塞状态。</li>
 <li>非阻塞IO：非阻塞IO模型中，用户进程在第一个阶段是非阻塞，第二个阶段是阻塞状态。虽然是非阻塞，但性能并没有得到提高。而且忙等机制会导致CPU空转，CPU使用率暴增。</li>
 <li>IO多路复用</li>
</ul>
<p>阻塞IO</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251219102712418.png&amp;size=m" alt="image-20251219102712418"></p>
<p>非阻塞IO</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251219103247300.png&amp;size=m" alt="image-20251219103247300"></p>
<p>IO多路复用</p>
<ul>
 <li>
  <p>概念：是利用单个线程来同时监听多个Socket ，并在某个Socket可读、可写时得到通知，从而避免无效的等待，充分利用CPU资源。</p>
 </li>
 <li>
  <p>实现：不过监听Socket的方式、通知的方式有多种实现</p>
  <ul>
   <li>select</li>
   <li>poll</li>
   <li>epoll</li>
  </ul>
 </li>
 <li>
  <p>差异</p>
  <ul>
   <li>select和poll只会通知用户进程有Socket就绪，但不确定具体是哪个Socket ，需要用户进程逐个遍历Socket来确认</li>
  </ul>
 </li>
</ul>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251219104107834.png&amp;size=m" alt="image-20251219104107834"></p>
<p>Redis网络模型：就是使用I/O多路复用结合事件的处理器来应对多个Socket请求</p>
<ul>
 <li>连接应答处理器</li>
 <li>命令回复处理器，在Redis6.0之后，为了提升更好的性能，使用了多线程来处理回复事件</li>
 <li>命令请求处理器，在Redis6.0之后，将命令的转换使用了多线程，增加命令转换速度，在命令执行的时候，依然是单线程</li>
</ul>
<p>Redis网络模型</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251219105115582.png&amp;size=m" alt="image-20251219105115582"></p>]]></description><guid isPermaLink="false">/archives/redismian-shi-chang-jian-ti</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-0bfb80c0-af86-402b-9258-497c7bcd21ef-9f83b3fa.png&amp;size=m" type="image/jpeg" length="1383715"/><category>Redis 与缓存</category><pubDate>Fri, 19 Dec 2025 02:55:02 GMT</pubDate></item><item><title><![CDATA[Java之JUC学习--part5]]></title><link>https://likeyy.love/archives/javazhi-jucxue-xi--part5</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=Java%E4%B9%8BJUC%E5%AD%A6%E4%B9%A0--part5&amp;url=/archives/javazhi-jucxue-xi--part5" width="1" height="1" alt="" style="opacity:0;">
<h1 id="java之juc学习--part5">Java之JUC学习--part5</h1>
<h2 id="无锁">无锁</h2>
<h3 id="cas">CAS</h3>
<h4 id="原理">原理</h4>
<p>无锁编程：Lock Free</p>
<p>CAS 的全称是 Compare-And-Swap，是 <strong>CPU 并发原语</strong></p>
<ul>
 <li>CAS 并发原语体现在 Java 语言中就是 sun.misc.Unsafe 类的各个方法，调用 UnSafe 类中的 CAS 方法，JVM 会实现出 CAS 汇编指令，这是一种完全依赖于硬件的功能，实现了原子操作</li>
 <li>CAS 是一种系统原语，原语属于操作系统范畴，是由若干条指令组成 ，用于完成某个功能的一个过程，并且原语的执行必须是连续的，执行过程中不允许被中断，所以 CAS 是一条 CPU 的原子指令，不会造成数据不一致的问题，是线程安全的</li>
</ul>
<p>底层原理：CAS 的底层是 <code>lock cmpxchg</code> 指令（X86 架构），在单核和多核 CPU 下都能够保证比较交换的原子性</p>
<ul>
 <li>
  <p>程序是在单核处理器上运行，会省略 lock 前缀，单处理器自身会维护处理器内的顺序一致性，不需要 lock 前缀的内存屏障效果</p>
 </li>
 <li>
  <p>程序是在多核处理器上运行，会为 cmpxchg 指令加上 lock 前缀。当某个核执行到带 lock 的指令时，CPU 会执行<strong>总线锁定或缓存锁定</strong>，将修改的变量写入到主存，这个过程不会被线程的调度机制所打断，保证了多个线程对内存操作的原子性</p>
 </li>
</ul>
<p>作用：比较当前工作内存中的值和主物理内存中的值，如果相同则执行规定操作，否则继续比较直到主内存和工作内存的值一致为止</p>
<p>CAS 特点：</p>
<ul>
 <li>CAS 体现的是<strong>无锁并发、无阻塞并发</strong>，线程不会陷入阻塞，线程不需要频繁切换状态（上下文切换，系统调用）</li>
 <li>CAS 是基于乐观锁的思想</li>
</ul>
<p>CAS 缺点：</p>
<ul>
 <li>执行的是循环操作，如果比较不成功一直在循环，最差的情况某个线程一直取到的值和预期值都不一样，就会无限循环导致饥饿，<strong>使用 CAS 线程数不要超过 CPU 的核心数</strong>，采用分段 CAS 和自动迁移机制</li>
 <li>只能保证一个共享变量的原子操作
  <ul>
   <li>对于一个共享变量执行操作时，可以通过循环 CAS 的方式来保证原子操作</li>
   <li>对于多个共享变量操作时，循环 CAS 就无法保证操作的原子性，这个时候<strong>只能用锁来保证原子性</strong></li>
  </ul></li>
 <li>引出来 ABA 问题</li>
</ul>
<h4 id="乐观锁">乐观锁</h4>
<p>CAS 与 synchronized 总结：</p>
<ul>
 <li>synchronized 是从悲观的角度出发：总是假设最坏的情况，每次去拿数据的时候都认为别人会修改，所以每次在拿数据的时候都会上锁，这样别人想拿这个数据就会阻塞（共享资源每次只给一个线程使用，其它线程阻塞，用完后再把资源转让给其它线程），因此 synchronized 也称之为悲观锁，ReentrantLock 也是一种悲观锁，性能较差</li>
 <li>CAS 是从乐观的角度出发：总是假设最好的情况，每次去拿数据的时候都认为别人不会修改，所以不会上锁，但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。<strong>如果别人修改过，则获取现在最新的值，如果别人没修改过，直接修改共享数据的值</strong>，CAS 这种机制也称之为乐观锁，综合性能较好</li>
</ul>
<h3 id="atomic">Atomic</h3>
<h4 id="常用api">常用API</h4>
<p>常见原子类：AtomicInteger、AtomicBoolean、AtomicLong</p>
<p>构造方法：</p>
<ul>
 <li><code>public AtomicInteger()</code>：初始化一个默认值为 0 的原子型 Integer</li>
 <li><code>public AtomicInteger(int initialValue)</code>：初始化一个指定值的原子型 Integer</li>
</ul>
<p>常用API：</p>
<table>
 <thead>
  <tr>
   <th>方法</th>
   <th>作用</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>public final int get()</td>
   <td>获取 AtomicInteger 的值</td>
  </tr>
  <tr>
   <td>public final int getAndIncrement()</td>
   <td>以原子方式将当前值加 1，返回的是自增前的值</td>
  </tr>
  <tr>
   <td>public final int incrementAndGet()</td>
   <td>以原子方式将当前值加 1，返回的是自增后的值</td>
  </tr>
  <tr>
   <td>public final int getAndSet(int value)</td>
   <td>以原子方式设置为 newValue 的值，返回旧值</td>
  </tr>
  <tr>
   <td>public final int addAndGet(int data)</td>
   <td>以原子方式将输入的数值与实例中的值相加并返回 实例：AtomicInteger 里的 value</td>
  </tr>
 </tbody>
</table>
<h4 id="原理分析">原理分析</h4>
<p><strong>AtomicInteger 原理</strong>：自旋锁 + CAS 算法</p>
<p>CAS 算法：有 3 个操作数（内存值 V， 旧的预期值 A，要修改的值 B）</p>
<ul>
 <li>当旧的预期值 A == 内存值 V 此时可以修改，将 V 改为 B</li>
 <li>当旧的预期值 A != 内存值 V 此时不能修改，并重新获取现在的最新值，重新获取的动作就是自旋</li>
</ul>
<p>分析 getAndSet 方法：</p>
<ul>
 <li>
  <p>AtomicInteger：</p>
  <pre><code class="language-java">public final int getAndSet(int newValue) {
    /**
    * this: 		当前对象
    * valueOffset:	内存偏移量，内存地址
    */
    return unsafe.getAndSetInt(this, valueOffset, newValue);
}
</code></pre>
  <p>valueOffset：偏移量表示该变量值相对于当前对象地址的偏移，Unsafe 就是根据内存偏移地址获取数据</p>
  <pre><code class="language-java">valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
//调用本地方法   --&gt;
public native long objectFieldOffset(Field var1);
</code></pre>
 </li>
 <li>
  <p>unsafe 类：</p>
  <pre><code class="language-java">// val1: AtomicInteger对象本身，var2: 该对象值得引用地址，var4: 需要变动的数
public final int getAndSetInt(Object var1, long var2, int var4) {
    int var5;
    do {
        // var5: 用 var1 和 var2 找到的内存中的真实值
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var4));

    return var5;
}
</code></pre>
  <p>var5：从主内存中拷贝到工作内存中的值（每次都要从主内存拿到最新的值到本地内存），然后执行 <code>compareAndSwapInt()</code> 再和主内存的值进行比较，假设方法返回 false，那么就一直执行 while 方法，直到期望的值和真实值一样，修改数据</p>
 </li>
 <li>
  <p>变量 value 用 volatile 修饰，保证了多线程之间的内存可见性，避免线程从工作缓存中获取失效的变量</p>
  <pre><code class="language-java">private volatile int value
</code></pre>
  <p><strong>CAS 必须借助 volatile 才能读取到共享变量的最新值来实现比较并交换的效果</strong></p>
 </li>
</ul>
<p>分析 getAndUpdate 方法：</p>
<ul>
 <li>
  <p>getAndUpdate：</p>
  <pre><code class="language-java">public final int getAndUpdate(IntUnaryOperator updateFunction) {
    int prev, next;
    do {
        prev = get();	//当前值，cas的期望值
        next = updateFunction.applyAsInt(prev);//期望值更新到该值
    } while (!compareAndSet(prev, next));//自旋
    return prev;
}
</code></pre>
 </li>
 <li>
  <p>compareAndSet：</p>
  <pre><code class="language-java">public final boolean compareAndSet(int expect, int update) {
    /**
    * this: 		当前对象
    * valueOffset:	内存偏移量，内存地址
    * expect:		期望的值
    * update: 		更新的值
    */
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
</code></pre>
 </li>
</ul>
<h4 id="原子引用">原子引用</h4>
<p>原子引用：对 Object 进行原子操作，提供一种读和写都是原子性的对象引用变量</p>
<p>原子引用类：AtomicReference、AtomicStampedReference、AtomicMarkableReference</p>
<p>AtomicReference 类：</p>
<ul>
 <li>
  <p>构造方法：<code>AtomicReference&lt;T&gt; atomicReference = new AtomicReference&lt;T&gt;()</code></p>
 </li>
 <li>
  <p>常用 API：</p>
  <ul>
   <li><code>public final boolean compareAndSet(V expectedValue, V newValue)</code>：CAS 操作</li>
   <li><code>public final void set(V newValue)</code>：将值设置为 newValue</li>
   <li><code>public final V get()</code>：返回当前值</li>
  </ul>
 </li>
</ul>
<pre><code class="language-java">public class AtomicReferenceDemo {
    public static void main(String[] args) {
        Student s1 = new Student(33, "z3");
        
        // 创建原子引用包装类
        AtomicReference&lt;Student&gt; atomicReference = new AtomicReference&lt;&gt;();
        // 设置主内存共享变量为s1
        atomicReference.set(s1);

        // 比较并交换，如果现在主物理内存的值为 z3，那么交换成 l4
        while (true) {
            Student s2 = new Student(44, "l4");
            if (atomicReference.compareAndSet(s1, s2)) {
                break;
            }
        }
        System.out.println(atomicReference.get());
    }
}

class Student {
    private int id;
    private String name;
    //。。。。
}
</code></pre>
<h4 id="原子数组">原子数组</h4>
<p>原子数组类：AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray</p>
<p>AtomicIntegerArray 类方法：</p>
<pre><code class="language-java">/**
*   i		the index
* expect 	the expected value
* update 	the new value
*/
public final boolean compareAndSet(int i, int expect, int update) {
    return compareAndSetRaw(checkedByteOffset(i), expect, update);
}
</code></pre>
<h4 id="原子更新器">原子更新器</h4>
<p>原子更新器类：AtomicReferenceFieldUpdater、AtomicIntegerFieldUpdater、AtomicLongFieldUpdater</p>
<p>利用字段更新器，可以针对对象的某个域（Field）进行原子操作，只能配合 volatile 修饰的字段使用，否则会出现异常 <code>IllegalArgumentException: Must be volatile type</code></p>
<p>常用 API：</p>
<ul>
 <li><code>static &lt;U&gt; AtomicIntegerFieldUpdater&lt;U&gt; newUpdater(Class&lt;U&gt; c, String fieldName)</code>：构造方法</li>
 <li><code>abstract boolean compareAndSet(T obj, int expect, int update)</code>：CAS</li>
</ul>
<pre><code class="language-java">public class UpdateDemo {
    private volatile int field;
    
    public static void main(String[] args) {
        AtomicIntegerFieldUpdater fieldUpdater = AtomicIntegerFieldUpdater
            		.newUpdater(UpdateDemo.class, "field");
        UpdateDemo updateDemo = new UpdateDemo();
        fieldUpdater.compareAndSet(updateDemo, 0, 10);
        System.out.println(updateDemo.field);//10
    }
}
</code></pre>
<h4 id="原子累加器">原子累加器</h4>
<p>原子累加器类：LongAdder、DoubleAdder、LongAccumulator、DoubleAccumulator</p>
<p>LongAdder 和 LongAccumulator 区别：</p>
<p>相同点：</p>
<ul>
 <li>LongAddr 与 LongAccumulator 类都是使用非阻塞算法 CAS 实现的</li>
 <li>LongAddr 类是 LongAccumulator 类的一个特例，只是 LongAccumulator 提供了更强大的功能，可以自定义累加规则，当accumulatorFunction 为 null 时就等价于 LongAddr</li>
</ul>
<p>不同点：</p>
<ul>
 <li>
  <p>调用 casBase 时，LongAccumulator 使用 function.applyAsLong(b = base, x) 来计算，LongAddr 使用 casBase(b = base, b + x)</p>
 </li>
 <li>
  <p>LongAccumulator 类功能更加强大，构造方法参数中</p>
  <ul>
   <li>accumulatorFunction 是一个双目运算器接口，可以指定累加规则，比如累加或者相乘，其根据输入的两个参数返回一个计算值，LongAdder 内置累加规则</li>
   <li>identity 则是 LongAccumulator 累加器的初始值，LongAccumulator 可以为累加器提供非0的初始值，而 LongAdder 只能提供默认的 0</li>
  </ul>
 </li>
</ul>
<h3 id="adder">Adder</h3>
<h4 id="优化机制">优化机制</h4>
<p>LongAdder 是 Java8 提供的类，跟 AtomicLong 有相同的效果，但对 CAS 机制进行了优化，尝试使用分段 CAS 以及自动分段迁移的方式来大幅度提升多线程高并发执行 CAS 操作的性能</p>
<p>CAS 底层实现是在一个循环中不断地尝试修改目标值，直到修改成功。如果竞争不激烈修改成功率很高，否则失败率很高，失败后这些重复的原子性操作会耗费性能（导致大量线程<strong>空循环，自旋转</strong>）</p>
<p>优化核心思想：数据分离，将 AtomicLong 的<strong>单点的更新压力分担到各个节点，空间换时间</strong>，在低并发的时候直接更新，可以保障和 AtomicLong 的性能基本一致，而在高并发的时候通过分散减少竞争，提高了性能</p>
<p><strong>分段 CAS 机制</strong>：</p>
<ul>
 <li>在发生竞争时，创建 Cell 数组用于将不同线程的操作离散（通过 hash 等算法映射）到不同的节点上</li>
 <li>设置多个累加单元（会根据需要扩容，最大为 CPU 核数），Therad-0 累加 Cell[0]，而 Thread-1 累加 Cell[1] 等，最后将结果汇总</li>
 <li>在累加时操作的不同的 Cell 变量，因此减少了 CAS 重试失败，从而提高性能</li>
</ul>
<p><strong>自动分段迁移机制</strong>：某个 Cell 的 value 执行 CAS 失败，就会自动寻找另一个 Cell 分段内的 value 值进行 CAS 操作</p>
<h4 id="伪共享">伪共享</h4>
<p>Cell 为累加单元：数组访问索引是通过 Thread 里的 threadLocalRandomProbe 域取模实现的，这个域是 ThreadLocalRandom 更新的</p>
<pre><code class="language-java">// Striped64.Cell
@sun.misc.Contended static final class Cell {
    volatile long value;
    Cell(long x) { value = x; }
    // 用 cas 方式进行累加, prev 表示旧值, next 表示新值
    final boolean cas(long prev, long next) {
    	return UNSAFE.compareAndSwapLong(this, valueOffset, prev, next);
    }
    // 省略不重要代码
}
</code></pre>
<p>Cell 是数组形式，<strong>在内存中是连续存储的</strong>，64 位系统中，一个 Cell 为 24 字节（16 字节的对象头和 8 字节的 value），每一个 cache line 为 64 字节，因此缓存行可以存下 2 个的 Cell 对象，当 Core-0 要修改 Cell[0]、Core-1 要修改 Cell[1]，无论谁修改成功都会导致当前缓存行失效，从而导致对方的数据失效，需要重新去主存获取，影响效率</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fseazean.oss-cn-beijing.aliyuncs.com%2Fimg%2FJava%2FJUC-%25E4%25BC%25AA%25E5%2585%25B1%25E4%25BA%25AB1.png&amp;size=m" alt=""></p>
<p>@sun.misc.Contended：防止缓存行伪共享，在使用此注解的对象或字段的前后各增加 128 字节大小的 padding，使用 2 倍于大多数硬件缓存行让 CPU 将对象预读至缓存时<strong>占用不同的缓存行</strong>，这样就不会造成对方缓存行的失效</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fseazean.oss-cn-beijing.aliyuncs.com%2Fimg%2FJava%2FJUC-%25E4%25BC%25AA%25E5%2585%25B1%25E4%25BA%25AB2.png&amp;size=m" alt=""></p>
<h4 id="源码解析">源码解析</h4>
<p>Striped64 类成员属性：</p>
<pre><code class="language-java">// 表示当前计算机CPU数量
static final int NCPU = Runtime.getRuntime().availableProcessors()
// 累加单元数组, 懒惰初始化
transient volatile Cell[] cells;
// 基础值, 如果没有竞争, 则用 cas 累加这个域，当 cells 扩容时，也会将数据写到 base 中
transient volatile long base;
// 在 cells 初始化或扩容时只能有一个线程执行, 通过 CAS 更新 cellsBusy 置为 1 来实现一个锁
transient volatile int cellsBusy;
</code></pre>
<p>工作流程：</p>
<ul>
 <li>
  <p>cells 占用内存是相对比较大的，是惰性加载的，在无竞争或者其他线程正在初始化 cells 数组的情况下，直接更新 base 域</p>
 </li>
 <li>
  <p>在第一次发生竞争时（casBase 失败）会创建一个大小为 2 的 cells 数组，将当前累加的值包装为 Cell 对象，放入映射的槽位上</p>
 </li>
 <li>
  <p>分段累加的过程中，如果当前线程对应的 cells 槽位为空，就会新建 Cell 填充，如果出现竞争，就会重新计算线程对应的槽位，继续自旋尝试修改</p>
 </li>
 <li>
  <p>分段迁移后还出现竞争就会扩容 cells 数组长度为原来的两倍，然后 rehash，<strong>数组长度总是 2 的 n 次幂</strong>，默认最大为 CPU 核数，但是可以超过，如果核数是 6 核，数组最长是 8</p>
 </li>
</ul>
<p>方法分析：</p>
<ul>
 <li>
  <p>LongAdder#add：累加方法</p>
  <pre><code class="language-java">public void add(long x) {
    // as 为累加单元数组的引用，b 为基础值，v 表示期望值
    // m 表示 cells 数组的长度 - 1，a 表示当前线程命中的 cell 单元格
    Cell[] as; long b, v; int m; Cell a;
    
    // cells 不为空说明 cells 已经被初始化，线程发生了竞争，去更新对应的 cell 槽位
    // 进入 || 后的逻辑去更新 base 域，更新失败表示发生竞争进入条件
    if ((as = cells) != null || !casBase(b = base, b + x)) {
        // uncontended 为 true 表示 cell 没有竞争
        boolean uncontended = true;
        
        // 条件一: true 说明 cells 未初始化，多线程写 base 发生竞争需要进行初始化 cells 数组
        //		  fasle 说明 cells 已经初始化，进行下一个条件寻找自己的 cell 去累加
        // 条件二: getProbe() 获取 hash 值，&amp; m 的逻辑和 HashMap 的逻辑相同，保证散列的均匀性
        // 		  true 说明当前线程对应下标的 cell 为空，需要创建 cell
        //        false 说明当前线程对应的 cell 不为空，进行下一个条件【将 x 值累加到对应的 cell 中】
        // 条件三: 有取反符号，false 说明 cas 成功，直接返回，true 说明失败，当前线程对应的 cell 有竞争
        if (as == null || (m = as.length - 1) &lt; 0 ||
            (a = as[getProbe() &amp; m]) == null ||
            !(uncontended = a.cas(v = a.value, v + x)))
            longAccumulate(x, null, uncontended);
        	// 【uncontended 在对应的 cell 上累加失败的时候才为 false，其余情况均为 true】
    }
}
</code></pre>
 </li>
 <li>
  <p>Striped64#longAccumulate：cell 数组创建</p>
  <pre><code class="language-java">							// x  			null 			false | true
final void longAccumulate(long x, LongBinaryOperator fn, boolean wasUncontended) {
    int h;
    // 当前线程还没有对应的 cell, 需要随机生成一个 hash 值用来将当前线程绑定到 cell
    if ((h = getProbe()) == 0) {
        // 初始化 probe，获取 hash 值
        ThreadLocalRandom.current(); 
        h = getProbe();	
        // 默认情况下 当前线程肯定是写入到了 cells[0] 位置，不把它当做一次真正的竞争
        wasUncontended = true;
    }
    // 表示【扩容意向】，false 一定不会扩容，true 可能会扩容
    boolean collide = false; 
    //自旋
    for (;;) {
        // as 表示cells引用，a 表示当前线程命中的 cell，n 表示 cells 数组长度，v 表示 期望值
        Cell[] as; Cell a; int n; long v;
        // 【CASE1】: 表示 cells 已经初始化了，当前线程应该将数据写入到对应的 cell 中
        if ((as = cells) != null &amp;&amp; (n = as.length) &gt; 0) {
            // CASE1.1: true 表示当前线程对应的索引下标的 Cell 为 null，需要创建 new Cell
            if ((a = as[(n - 1) &amp; h]) == null) {
                // 判断 cellsBusy 是否被锁
                if (cellsBusy == 0) {   
                    // 创建 cell, 初始累加值为 x
                    Cell r = new Cell(x);  
                    // 加锁
                    if (cellsBusy == 0 &amp;&amp; casCellsBusy()) {
                        // 创建成功标记，进入【创建 cell 逻辑】
                        boolean created = false;	
                        try {
                            Cell[] rs; int m, j;
                            // 把当前 cells 数组赋值给 rs，并且不为 null
                            if ((rs = cells) != null &amp;&amp;
                                (m = rs.length) &gt; 0 &amp;&amp;
                                // 再次判断防止其它线程初始化过该位置，当前线程再次初始化该位置会造成数据丢失
                                // 因为这里是线程安全的判断，进行的逻辑不会被其他线程影响
                                rs[j = (m - 1) &amp; h] == null) {
                                // 把新创建的 cell 填充至当前位置
                                rs[j] = r;
                                created = true;	// 表示创建完成
                            }
                        } finally {
                            cellsBusy = 0;		// 解锁
                        }
                        if (created)			// true 表示创建完成，可以推出循环了
                            break;
                        continue;
                    }
                }
                collide = false;
            }
            // CASE1.2: 条件成立说明线程对应的 cell 有竞争, 改变线程对应的 cell 来重试 cas
            else if (!wasUncontended)
                wasUncontended = true;
            // CASE 1.3: 当前线程 rehash 过，如果新命中的 cell 不为空，就尝试累加，false 说明新命中也有竞争
            else if (a.cas(v = a.value, ((fn == null) ? v + x : fn.applyAsLong(v, x))))
                break;
            // CASE 1.4: cells 长度已经超过了最大长度 CPU 内核的数量或者已经扩容
            else if (n &gt;= NCPU || cells != as)
                collide = false; 		// 扩容意向改为false，【表示不能扩容了】
            // CASE 1.5: 更改扩容意向，如果 n &gt;= NCPU，这里就永远不会执行到，case1.4 永远先于 1.5 执行
            else if (!collide)
                collide = true;
            // CASE 1.6: 【扩容逻辑】，进行加锁
            else if (cellsBusy == 0 &amp;&amp; casCellsBusy()) {
                try {
                    // 线程安全的检查，防止期间被其他线程扩容了
                    if (cells == as) {     
                        // 扩容为以前的 2 倍
                        Cell[] rs = new Cell[n &lt;&lt; 1];
                        // 遍历移动值
                        for (int i = 0; i &lt; n; ++i)
                            rs[i] = as[i];
                        // 把扩容后的引用给 cells
                        cells = rs;
                    }
                } finally {
                    cellsBusy = 0;	// 解锁
                }
                collide = false;	// 扩容意向改为 false，表示不扩容了
                continue;
            }
            // 重置当前线程 Hash 值，这就是【分段迁移机制】
            h = advanceProbe(h);
        }

        // 【CASE2】: 运行到这说明 cells 还未初始化，as 为null
        // 判断是否没有加锁，没有加锁就用 CAS 加锁
        // 条件二判断是否其它线程在当前线程给 as 赋值之后修改了 cells，这里不是线程安全的判断
        else if (cellsBusy == 0 &amp;&amp; cells == as &amp;&amp; casCellsBusy()) {
            // 初始化标志，开始 【初始化 cells 数组】
            boolean init = false;
            try { 
               	// 再次判断 cells == as 防止其它线程已经提前初始化了，当前线程再次初始化导致丢失数据
                // 因为这里是【线程安全的，重新检查，经典 DCL】
                if (cells == as) {
                    Cell[] rs = new Cell[2];	// 初始化数组大小为2
                    rs[h &amp; 1] = new Cell(x);	// 填充线程对应的cell
                    cells = rs;
                    init = true;				// 初始化成功，标记置为 true
                }
            } finally {
                cellsBusy = 0;					// 解锁啊
            }
            if (init)
                break;							// 初始化成功直接跳出自旋
        }
        // 【CASE3】: 运行到这说明其他线程在初始化 cells，当前线程将值累加到 base，累加成功直接结束自旋
        else if (casBase(v = base, ((fn == null) ? v + x :
                                    fn.applyAsLong(v, x))))
            break; 
    }
}
</code></pre>
 </li>
 <li>
  <p>sum：获取最终结果通过 sum 整合，<strong>保证最终一致性，不保证强一致性</strong></p>
  <pre><code class="language-java">public long sum() {
    Cell[] as = cells; Cell a;
    long sum = base;
    if (as != null) {
        // 遍历 累加
        for (int i = 0; i &lt; as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}
</code></pre>
 </li>
</ul>
<h3 id="aba">ABA</h3>
<p>ABA 问题：当进行获取主内存值时，该内存值在写入主内存时已经被修改了 N 次，但是最终又改成原来的值</p>
<p>其他线程先把 A 改成 B 又改回 A，主线程<strong>仅能判断出共享变量的值与最初值 A 是否相同</strong>，不能感知到这种从 A 改为 B 又 改回 A 的情况，这时 CAS 虽然成功，但是过程存在问题</p>
<ul>
 <li>
  <p>构造方法：</p>
  <ul>
   <li><code>public AtomicStampedReference(V initialRef, int initialStamp)</code>：初始值和初始版本号</li>
  </ul>
 </li>
 <li>
  <p>常用API：</p>
  <ul>
   <li><code> public boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp)</code>：<strong>期望引用和期望版本号都一致</strong>才进行 CAS 修改数据</li>
   <li><code>public void set(V newReference, int newStamp)</code>：设置值和版本号</li>
   <li><code>public V getReference()</code>：返回引用的值</li>
   <li><code>public int getStamp()</code>：返回当前版本号</li>
  </ul>
 </li>
</ul>
<pre><code class="language-java">public static void main(String[] args) {
    AtomicStampedReference&lt;Integer&gt; atomicReference = new AtomicStampedReference&lt;&gt;(100,1);
    int startStamp = atomicReference.getStamp();
    new Thread(() -&gt;{
        int stamp = atomicReference.getStamp();
        atomicReference.compareAndSet(100, 101, stamp, stamp + 1);
        stamp = atomicReference.getStamp();
        atomicReference.compareAndSet(101, 100, stamp, stamp + 1);
    },"t1").start();

    new Thread(() -&gt;{
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        if (!atomicReference.compareAndSet(100, 200, startStamp, startStamp + 1)) {
            System.out.println(atomicReference.getReference());//100
            System.out.println(Thread.currentThread().getName() + "线程修改失败");
        }
    },"t2").start();
}
</code></pre>
<h3 id="unsafe">Unsafe</h3>
<p>Unsafe 是 CAS 的核心类，由于 Java 无法直接访问底层系统，需要通过本地（Native）方法来访问</p>
<p>Unsafe 类存在 sun.misc 包，其中所有方法都是 native 修饰的，都是直接调用<strong>操作系统底层资源</strong>执行相应的任务，基于该类可以直接操作特定的内存数据，其内部方法操作类似 C 的指针</p>
<p>模拟实现原子整数：</p>
<pre><code class="language-java">public static void main(String[] args) {
    MyAtomicInteger atomicInteger = new MyAtomicInteger(10);
    if (atomicInteger.compareAndSwap(20)) {
        System.out.println(atomicInteger.getValue());
    }
}

class MyAtomicInteger {
    private static final Unsafe UNSAFE;
    private static final long VALUE_OFFSET;
    private volatile int value;

    static {
        try {
            //Unsafe unsafe = Unsafe.getUnsafe()这样会报错，需要反射获取
            Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
            theUnsafe.setAccessible(true);
            UNSAFE = (Unsafe) theUnsafe.get(null);
            // 获取 value 属性的内存地址，value 属性指向该地址，直接设置该地址的值可以修改 value 的值
            VALUE_OFFSET = UNSAFE.objectFieldOffset(
                		   MyAtomicInteger.class.getDeclaredField("value"));
        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
            throw new RuntimeException();
        }
    }

    public MyAtomicInteger(int value) {
        this.value = value;
    }
    public int getValue() {
        return value;
    }

    public boolean compareAndSwap(int update) {
        while (true) {
            int prev = this.value;
            int next = update;
            //							当前对象  内存偏移量    期望值 更新值
            if (UNSAFE.compareAndSwapInt(this, VALUE_OFFSET, prev, update)) {
                System.out.println("CAS成功");
                return true;
            }
        }
    }
}
</code></pre>
<h3 id="final">final</h3>
<h4 id="原理-1">原理</h4>
<pre><code class="language-java">public class TestFinal {
	final int a = 20;
}
</code></pre>
<p>字节码：</p>
<pre><code class="language-java">0: aload_0
1: invokespecial #1 // Method java/lang/Object."&lt;init&gt;":()V
4: aload_0
5: bipush 20		// 将值直接放入栈中
7: putfield #2 		// Field a:I
&lt;-- 写屏障
10: return
</code></pre>
<p>final 变量的赋值通过 putfield 指令来完成，在这条指令之后也会加入写屏障，保证在其它线程读到它的值时不会出现为 0 的情况</p>
<p>其他线程访问 final 修饰的变量</p>
<ul>
 <li><strong>复制一份放入栈中</strong>直接访问，效率高</li>
 <li>大于 short 最大值会将其复制到类的常量池，访问时从常量池获取</li>
</ul>
<h4 id="不可变">不可变</h4>
<p>不可变：如果一个对象不能够修改其内部状态（属性），那么就是不可变对象</p>
<p>不可变对象线程安全的，不存在并发修改和可见性问题，是另一种避免竞争的方式</p>
<p>String 类也是不可变的，该类和类中所有属性都是 final 的</p>
<ul>
 <li>
  <p>类用 final 修饰保证了该类中的方法不能被覆盖，防止子类无意间破坏不可变性</p>
 </li>
 <li>
  <p>无写入方法（set）确保外部不能对内部属性进行修改</p>
 </li>
 <li>
  <p>属性用 final 修饰保证了该属性是只读的，不能修改</p>
  <pre><code class="language-java">public final class String
    implements java.io.Serializable, Comparable&lt;String&gt;, CharSequence {
    /** The value is used for character storage. */
    private final char value[];
    //....
}
</code></pre>
 </li>
 <li>
  <p>更改 String 类数据时，会构造新字符串对象，生成新的 char[] value，通过<strong>创建副本对象来避免共享的方式称之为保护性拷贝</strong></p>
 </li>
</ul>
<h3 id="state">State</h3>
<p>无状态：成员变量保存的数据也可以称为状态信息，无状态就是没有成员变量</p>
<p>Servlet 为了保证其线程安全，一般不为 Servlet 设置成员变量，这种没有任何成员变量的类是线程安全的</p>]]></description><guid isPermaLink="false">/archives/javazhi-jucxue-xi--part5</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-61ea7c5c-ee72-46a7-821b-44af2236a3a9-61d948e6.png&amp;size=m" type="image/jpeg" length="1839365"/><category>并发与响应式编程</category><pubDate>Wed, 10 Dec 2025 00:54:04 GMT</pubDate></item><item><title><![CDATA[Java之JUC学习--part4]]></title><link>https://likeyy.love/archives/javazhi-jucxue-xi--part4</link><description><![CDATA[<img src="https://likeyy.love/plugins/feed/assets/telemetry.gif?title=Java%E4%B9%8BJUC%E5%AD%A6%E4%B9%A0--part4&amp;url=/archives/javazhi-jucxue-xi--part4" width="1" height="1" alt="" style="opacity:0;">
<h1 id="Java之JUC学习--part4">Java之JUC学习--part4</h1>
<h2 id="JMM-Java内存模型-">JMM（Java内存模型）</h2>
<h3 id="内存模型">内存模型</h3>
<p>Java 内存模型是 Java Memory Model（JMM），本身是一种<strong>抽象的概念</strong>，实际上并不存在，描述的是一组规则或规范，通过这组规范定义了程序中各个变量（包括实例字段，静态字段和构成数组对象的元素）的访问方式</p>
<p>JMM 作用：</p>
<ul>
 <li>屏蔽各种硬件和操作系统的内存访问差异，实现让 Java 程序在各种平台下都能达到一致的内存访问效果</li>
 <li>规定了线程和内存之间的一些关系</li>
</ul>
<p>根据 JMM 的设计，系统存在一个主内存（Main Memory），Java 中所有变量都存储在主存中，对于所有线程都是共享的；每条线程都有自己的工作内存（Working Memory），工作内存中保存的是主存中某些<strong>变量的拷贝</strong>，线程对所有变量的操作都是先对变量进行拷贝，然后在工作内存中进行，不能直接操作主内存中的变量；线程之间无法相互直接访问，线程间的通信（传递）必须通过主内存来完成</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251207201533079.png&amp;size=m" alt="image-20251207201533079"></p>
<p>主内存和工作内存：</p>
<ul>
 <li>主内存：计算机的内存，也就是经常提到的 8G 内存，16G 内存，存储所有共享变量的值</li>
 <li>工作内存：存储该线程使用到的共享变量在主内存的的值的副本拷贝</li>
</ul>
<p><strong>JVM 和 JMM 之间的关系</strong>：JMM 中的主内存、工作内存与 JVM 中的 Java 堆、栈、方法区等并不是同一个层次的内存划分，这两者基本上是没有关系的，如果两者一定要勉强对应起来：</p>
<ul>
 <li>主内存主要对应于 Java 堆中的对象实例数据部分，而工作内存则对应于虚拟机栈中的部分区域</li>
 <li>从更低层次上说，主内存直接对应于物理硬件的内存，工作内存对应寄存器和高速缓存</li>
</ul>
<h3 id="内存交互">内存交互</h3>
<p>Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作，每个操作都是<strong>原子</strong>的</p>
<p>非原子协定：没有被 volatile 修饰的 long、double 外，默认按照两次 32 位的操作</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251207202038128.png&amp;size=m" alt="image-20251207202038128"></p>
<ul>
 <li>lock：作用于主内存，将一个变量标识为被一个线程独占状态（对应 monitorenter）</li>
 <li>unclock：作用于主内存，将一个变量从独占状态释放出来，释放后的变量才可以被其他线程锁定（对应 monitorexit）</li>
 <li>read：作用于主内存，把一个变量的值从主内存传输到工作内存中</li>
 <li>load：作用于工作内存，在 read 之后执行，把 read 得到的值放入工作内存的变量副本中</li>
 <li>use：作用于工作内存，把工作内存中一个变量的值传递给<strong>执行引擎</strong>，每当遇到一个使用到变量的操作时都要使用该指令</li>
 <li>assign：作用于工作内存，把从执行引擎接收到的一个值赋给工作内存的变量</li>
 <li>store：作用于工作内存，把工作内存的一个变量的值传送到主内存中</li>
 <li>write：作用于主内存，在 store 之后执行，把 store <strong>得到的值放入主内存的变量中</strong></li>
</ul>
<blockquote>
 <p>参考文章：https://github.com/CyC2018/CS-Notes/blob/master/notes/Java%20%E5%B9%B6%E5%8F%91.md</p>
</blockquote>
<h2 id="三大特性">三大特性</h2>
<h3 id="可见性">可见性</h3>
<p>可见性：是指当多个线程访问同一个变量时，一个线程修改了这个变量的值，其他线程能够立即看得到修改的值</p>
<p>存在不可见问题的根本原因是由于缓存的存在，线程持有的是共享变量的副本，无法感知其他线程对于共享变量的更改，导致读取的值不是最新的。但是 final 修饰的变量是<strong>不可变</strong>的，就算有缓存，也不会存在不可见的问题</p>
<p>main 线程对 run 变量的修改对于 t 线程不可见，导致了 t 线程无法停止：</p>
<pre><code class="language-java">static boolean run = true;	//添加volatile
public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(()-&gt;{
        while(run){
        // ....
        }
	});
    t.start();
    sleep(1);
    run = false; // 线程t不会如预想的停下来
}
</code></pre>
<p>原因：</p>
<ul>
 <li>初始状态， t 线程刚开始从主内存读取了 run 的值到工作内存</li>
 <li>因为 t 线程要频繁从主内存中读取 run 的值，JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中，减少对主存中 run 的访问，提高效率</li>
 <li>1 秒之后，main 线程修改了 run 的值，并同步至主存，而 t 是从自己工作内存中的高速缓存中读取这个变量的值，结果永远是旧值</li>
</ul>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251207202807408.png&amp;size=m" alt="image-20251207202807408"></p>
<h3 id="原子性">原子性</h3>
<p>原子性：不可分割，完整性，也就是说某个线程正在做某个具体业务时，中间不可以被分割，需要具体完成，要么同时成功，要么同时失败，保证指令不会受到线程上下文切换的影响</p>
<p>定义原子操作的使用规则：</p>
<ol>
 <li>不允许 read 和 load、store 和 write 操作之一单独出现，必须顺序执行，但是不要求连续</li>
 <li>不允许一个线程丢弃 assign 操作，必须同步回主存</li>
 <li>不允许一个线程无原因地（没有发生过任何 assign 操作）把数据从工作内存同步会主内存中</li>
 <li>一个新的变量只能在主内存中诞生，不允许在工作内存中直接使用一个未被初始化（assign 或者 load）的变量，即对一个变量实施 use 和 store 操作之前，必须先自行 assign 和 load 操作</li>
 <li>一个变量在同一时刻只允许一条线程对其进行 lock 操作，但 lock 操作可以被同一线程重复执行多次，多次执行 lock 后，只有<strong>执行相同次数的 unlock</strong> 操作，变量才会被解锁，<strong>lock 和 unlock 必须成对出现</strong></li>
 <li>如果对一个变量执行 lock 操作，将会<strong>清空工作内存中此变量的值</strong>，在执行引擎使用这个变量之前需要重新从主存加载</li>
 <li>如果一个变量事先没有被 lock 操作锁定，则不允许执行 unlock 操作，也不允许去 unlock 一个被其他线程锁定的变量</li>
 <li>对一个变量执行 unlock 操作之前，必须<strong>先把此变量同步到主内存</strong>中（执行 store 和 write 操作）</li>
</ol>
<h3 id="有序性">有序性</h3>
<p>有序性：在本线程内观察，所有操作都是有序的；在一个线程观察另一个线程，所有操作都是无序的，无序是因为发生了指令重排序</p>
<p>CPU 的基本工作是执行存储的指令序列，即程序，程序的执行过程实际上是不断地取出指令、分析指令、执行指令的过程，为了提高性能，编译器和处理器会对指令重排，一般分为以下三种：</p>
<pre><code class="language-java">源代码 -&gt; 编译器优化的重排 -&gt; 指令并行的重排 -&gt; 内存系统的重排 -&gt; 最终执行指令
</code></pre>
<p>现代 CPU 支持多级指令流水线，几乎所有的冯•诺伊曼型计算机的 CPU，其工作都可以分为 5 个阶段：取指令、指令译码、执行指令、访存取数和结果写回，可以称之为<strong>五级指令流水线</strong>。CPU 可以在一个时钟周期内，同时运行五条指令的<strong>不同阶段</strong>（每个线程不同的阶段），本质上流水线技术并不能缩短单条指令的执行时间，但变相地提高了指令地吞吐率</p>
<p>处理器在进行重排序时，必须要考虑<strong>指令之间的数据依赖性</strong></p>
<ul>
 <li>单线程环境也存在指令重排，由于存在依赖性，最终执行结果和代码顺序的结果一致</li>
 <li>多线程环境中线程交替执行，由于编译器优化重排，会获取其他线程处在不同阶段的指令同时执行</li>
</ul>
<p>补充知识：</p>
<ul>
 <li>指令周期是取出一条指令并执行这条指令的时间，一般由若干个机器周期组成</li>
 <li>机器周期也称为 CPU 周期，一条指令的执行过程划分为若干个阶段（如取指、译码、执行等），每一阶段完成一个基本操作，完成一个基本操作所需要的时间称为机器周期</li>
 <li>振荡周期指周期性信号作周期性重复变化的时间间隔</li>
</ul>
<h2 id="Cache">Cache</h2>
<h4 id="缓存机制">缓存机制</h4>
<h5 id="缓存结构">缓存结构</h5>
<p>在计算机系统中，CPU 高速缓存（CPU Cache，简称缓存）是用于减少处理器访问内存所需平均时间的部件；在存储体系中位于自顶向下的第二层，仅次于 CPU 寄存器；其容量远小于内存，但速度却可以接近处理器的频率</p>
<p>CPU 处理器速度远远大于在主内存中的，为了解决速度差异，在它们之间架设了多级缓存，如 L1、L2、L3 级别的缓存，这些缓存离 CPU 越近就越快，将频繁操作的数据缓存到这里，加快访问速度</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251207203625974.png&amp;size=m" alt="image-20251207203625974"></p>
<table>
 <thead>
  <tr>
   <th>从 CPU 到</th>
   <th>大约需要的时钟周期</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>寄存器</td>
   <td>1 cycle (4GHz 的 CPU 约为 0.25ns)</td>
  </tr>
  <tr>
   <td>L1</td>
   <td>3~4 cycle</td>
  </tr>
  <tr>
   <td>L2</td>
   <td>10~20 cycle</td>
  </tr>
  <tr>
   <td>L3</td>
   <td>40~45 cycle</td>
  </tr>
  <tr>
   <td>内存</td>
   <td>120~240 cycle</td>
  </tr>
 </tbody>
</table>
<h5 id="缓存使用">缓存使用</h5>
<p>当处理器发出内存访问请求时，会先查看缓存内是否有请求数据，如果存在（命中），则不用访问内存直接返回该数据；如果不存在（失效），则要先把内存中的相应数据载入缓存，再将其返回处理器</p>
<p>缓存之所以有效，主要因为程序运行时对内存的访问呈现局部性（Locality）特征。既包括空间局部性（Spatial Locality），也包括时间局部性（Temporal Locality），有效利用这种局部性，缓存可以达到极高的命中率。</p>
<h4 id="伪共享">伪共享</h4>
<p><strong>缓存以缓存行 cache line 为单位</strong>，每个缓存行对应着一块内存，一般是 64 byte（8 个 long），在 CPU 从主存获取数据时，以 cache line 为单位加载，于是相邻的数据会一并加载到缓存中</p>
<p>缓存会造成数据副本的产生，即同一份数据会缓存在不同核心的缓存行中，CPU 要保证数据的一致性，需要做到某个 CPU 核心更改了数据，其它 CPU 核心对应的<strong>整个缓存行必须失效</strong>，这就是伪共享。</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251207204820858.png&amp;size=m" alt="image-20251207204820858"></p>
<p>解决方法：</p>
<ul>
 <li>padding：通过填充，让数据落在不同的 cache line 中</li>
 <li>@Contended：原理参考 无锁 → Adder → 优化机制 → 伪共享</li>
</ul>
<p>Linux 查看 CPU 缓存行：</p>
<ul>
 <li>命令：<code>cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size64</code></li>
 <li>内存地址格式：[高位组标记] [低位索引] [偏移量]</li>
</ul>
<h4 id="缓存一致">缓存一致</h4>
<p>缓存一致性：当多个处理器运算任务都涉及到同一块主内存区域的时候，将可能导致各自的缓存数据不一样</p>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251207205158307.png&amp;size=m" alt="image-20251207205158307"></p>
<p>MESI（Modified Exclusive Shared Or Invalid）是一种广泛使用的<strong>支持写回策略的缓存一致性协议</strong>，CPU 中每个缓存行（caceh line）使用 4 种状态进行标记（使用额外的两位 bit 表示)：</p>
<ul>
 <li>
  <p>M：被修改（Modified）</p>
  <p>该缓存行只被缓存在该 CPU 的缓存中，并且是被修改过的，与主存中的数据不一致 (dirty)，该缓存行中的内存需要写回 (write back) 主存。该状态的数据再次被修改不会发送广播，因为其他核心的数据已经在第一次修改时失效一次</p>
  <p>当被写回主存之后，该缓存行的状态会变成独享 (exclusive) 状态</p>
 </li>
 <li>
  <p>E：独享的（Exclusive）</p>
  <p>该缓存行只被缓存在该 CPU 的缓存中，是未被修改过的 (clear)，与主存中数据一致，修改数据不需要通知其他 CPU 核心，该状态可以在任何时刻有其它 CPU 读取该内存时变成共享状态 (shared)</p>
  <p>当 CPU 修改该缓存行中内容时，该状态可以变成 Modified 状态</p>
 </li>
 <li>
  <p>S：共享的（Shared）</p>
  <p>该状态意味着该缓存行可能被多个 CPU 缓存，并且各个缓存中的数据与主存数据一致，当 CPU 修改该缓存行中，会向其它 CPU 核心广播一个请求，使该缓存行变成无效状态 (Invalid)，然后再更新当前 Cache 里的数据</p>
 </li>
 <li>
  <p>I：无效的（Invalid）</p>
  <p>该缓存是无效的，可能有其它 CPU 修改了该缓存行</p>
 </li>
</ul>
<p>解决方法：各个处理器访问缓存时都遵循一些协议，在读写时要根据协议进行操作，协议主要有 MSI、MESI 等</p>
<h4 id="处理机制">处理机制</h4>
<p>单核 CPU 处理器会自动保证基本内存操作的原子性</p>
<p>多核 CPU 处理器，每个 CPU 处理器内维护了一块内存，每个内核内部维护着一块缓存，当多线程并发读写时，就会出现缓存数据不一致的情况。处理器提供：</p>
<ul>
 <li>总线锁定：当处理器要操作共享变量时，在 BUS 总线上发出一个 LOCK 信号，其他处理器就无法操作这个共享变量，该操作会导致大量阻塞，从而增加系统的性能开销（<strong>平台级别的加锁</strong>）</li>
 <li>缓存锁定：当处理器对缓存中的共享变量进行了操作，其他处理器有嗅探机制，将各自缓存中的该共享变量的失效，读取时会重新从主内存中读取最新的数据，基于 MESI 缓存一致性协议来实现</li>
</ul>
<p>有如下两种情况处理器不会使用缓存锁定：</p>
<ul>
 <li>当操作的数据跨多个缓存行，或没被缓存在处理器内部，则处理器会使用总线锁定</li>
 <li>有些处理器不支持缓存锁定，比如：Intel 486 和 Pentium 处理器也会调用总线锁定</li>
</ul>
<p>总线机制：</p>
<ul>
 <li>总线嗅探：每个处理器通过嗅探在总线上传播的数据来检查自己缓存值是否过期了，当处理器发现自己的缓存对应的内存地址的数据被修改，就<strong>将当前处理器的缓存行设置为无效状态</strong>，当处理器对这个数据进行操作时，会重新从内存中把数据读取到处理器缓存中</li>
 <li>总线风暴：当某个 CPU 核心更新了 Cache 中的数据，要把该事件广播通知到其他核心（<strong>写传播</strong>），CPU 需要每时每刻监听总线上的一切活动，但是不管别的核心的 Cache 是否缓存相同的数据，都需要发出一个广播事件，不断的从主内存嗅探和 CAS 循环，无效的交互会导致总线带宽达到峰值；因此不要大量使用 volatile 关键字，使用 volatile、syschonized 都需要根据实际场景</li>
</ul>
<h3 id="volatile">volatile</h3>
<h4 id="同步机制">同步机制</h4>
<p>volatile 是 Java 虚拟机提供的<strong>轻量级</strong>的同步机制（三大特性）</p>
<ul>
 <li>保证可见性</li>
 <li>不保证原子性</li>
 <li>保证有序性（禁止指令重排）</li>
</ul>
<p>性能：volatile 修饰的变量进行读操作与普通变量几乎没什么差别，但是写操作相对慢一些，因为需要在本地代码中插入很多内存屏障来保证指令不会发生乱序执行，但是开销比锁要小</p>
<p>synchronized 无法禁止指令重排和处理器优化，为什么可以保证有序性可见性</p>
<ul>
 <li>加了锁之后，只能有一个线程获得到了锁，获得不到锁的线程就要阻塞，所以同一时间只有一个线程执行，相当于单线程，由于数据依赖性的存在，单线程的指令重排是没有问题的</li>
 <li>线程加锁前，将<strong>清空工作内存</strong>中共享变量的值，使用共享变量时需要从主内存中重新读取最新的值；线程解锁前，必须把共享变量的最新值<strong>刷新到主内存</strong>中（JMM 内存交互章节有讲）</li>
</ul>
<h4 id="指令重排">指令重排</h4>
<p>volatile 修饰的变量，可以禁用指令重排</p>
<p>指令重排实例：</p>
<ul>
 <li>
  <p>example 1：</p>
  <pre><code class="language-java">public void mySort() {
	int x = 11;	//语句1
	int y = 12;	//语句2  谁先执行效果一样
	x = x + 5;	//语句3
	y = x * x;	//语句4
}
</code></pre>
  <p>执行顺序是：1 2 3 4、2 1 3 4、1 3 2 4</p>
  <p>指令重排也有限制不会出现：4321，语句 4 需要依赖于 y 以及 x 的申明，因为存在数据依赖，无法首先执行</p>
 </li>
 <li>
  <p>example 2：</p>
  <pre><code class="language-java">int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
    if(ready) {
    	r.r1 = num + num;
    } else {
    	r.r1 = 1;
    }
}
// 线程2 执行此方法
public void actor2(I_Result r) {
	num = 2;
	ready = true;
}
</code></pre>
  <p>情况一：线程 1 先执行，ready = false，结果为 r.r1 = 1</p>
  <p>情况二：线程 2 先执行 num = 2，但还没执行 ready = true，线程 1 执行，结果为 r.r1 = 1</p>
  <p>情况三：线程 2 先执行 ready = true，线程 1 执行，进入 if 分支结果为 r.r1 = 4</p>
  <p>情况四：线程 2 执行 ready = true，切换到线程 1，进入 if 分支为 r.r1 = 0，再切回线程 2 执行 num = 2，发生指令重排</p>
 </li>
</ul>
<h4 id="底层原理">底层原理</h4>
<h5 id="缓存一致-">缓存一致</h5>
<p>使用 volatile 修饰的共享变量，底层通过汇编 lock 前缀指令进行缓存锁定，在线程修改完共享变量后写回主存，其他的 CPU 核心上运行的线程通过 CPU 总线嗅探机制会修改其共享变量为失效状态，读取时会重新从主内存中读取最新的数据</p>
<p>lock 前缀指令就相当于内存屏障，Memory Barrier（Memory Fence）</p>
<ul>
 <li>对 volatile 变量的写指令后会加入写屏障</li>
 <li>对 volatile 变量的读指令前会加入读屏障</li>
</ul>
<p>内存屏障有三个作用：</p>
<ul>
 <li>确保对内存的读-改-写操作原子执行</li>
 <li>阻止屏障两侧的指令重排序</li>
 <li>强制把缓存中的脏数据写回主内存，让缓存行中相应的数据失效</li>
</ul>
<h5 id="内存屏障">内存屏障</h5>
<p>保证<strong>可见性</strong>：</p>
<ul>
 <li>
  <p>写屏障（sfence，Store Barrier）保证在该屏障之前的，对共享变量的改动，都同步到主存当中</p>
  <pre><code class="language-java">public void actor2(I_Result r) {
    num = 2;
    ready = true; // ready 是 volatile 赋值带写屏障
    // 写屏障
}
</code></pre>
 </li>
 <li>
  <p>读屏障（lfence，Load Barrier）保证在该屏障之后的，对共享变量的读取，从主存刷新变量值，加载的是主存中最新数据</p>
  <pre><code class="language-java">public void actor1(I_Result r) {
    // 读屏障
    // ready 是 volatile 读取值带读屏障
    if(ready) {
    	r.r1 = num + num;
    } else {
    	r.r1 = 1;
    }
}
</code></pre>
  <p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251207211604113.png&amp;size=m" alt="image-20251207211604113"></p>
 </li>
 <li>
  <p>全能屏障：mfence（modify/mix Barrier），兼具 sfence 和 lfence 的功能</p>
 </li>
</ul>
<p>保证<strong>有序性</strong>：</p>
<ul>
 <li>写屏障会确保指令重排序时，不会将写屏障之前的代码排在写屏障之后</li>
 <li>读屏障会确保指令重排序时，不会将读屏障之后的代码排在读屏障之前</li>
</ul>
<p>不能解决指令交错：</p>
<ul>
 <li>
  <p>写屏障仅仅是保证之后的读能够读到最新的结果，但不能保证其他线程的读跑到写屏障之前</p>
 </li>
 <li>
  <p>有序性的保证也只是保证了本线程内相关代码不被重排序</p>
  <pre><code class="language-java">volatile i = 0;
new Thread(() -&gt; {i++});
new Thread(() -&gt; {i--});
</code></pre>
  <p>i++ 反编译后的指令：</p>
  <pre><code class="language-java">0: iconst_1			// 当int取值 -1~5 时，JVM采用iconst指令将常量压入栈中
1: istore_1			// 将操作数栈顶数据弹出，存入局部变量表的 slot 1
2: iinc		1, 1
</code></pre>
  <p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251207211850722.png&amp;size=m" alt="image-20251207211850722"></p>
 </li>
</ul>
<h5 id="交互规则">交互规则</h5>
<p>对于 volatile 修饰的变量：</p>
<ul>
 <li>线程对变量的 use 与 load、read 操作是相关联的，所以变量使用前必须先从主存加载</li>
 <li>线程对变量的 assign 与 store、write 操作是相关联的，所以变量使用后必须同步至主存</li>
 <li>线程 1 和线程 2 谁先对变量执行 read 操作，就会先进行 write 操作，防止指令重排</li>
</ul>
<h3 id="双端检锁">双端检锁</h3>
<h4 id="检锁机制">检锁机制</h4>
<p>Double-Checked Locking：双端检锁机制</p>
<p>DCL（双端检锁）机制不一定是线程安全的，原因是有指令重排的存在，加入 volatile 可以禁止指令重排</p>
<pre><code class="language-java">public final class Singleton {
    private Singleton() { }
    private static Singleton INSTANCE = null;
  
    public static Singleton getInstance() {
        if(INSTANCE == null) { // t2，这里的判断不是线程安全的
            // 首次访问会同步，而之后的使用没有 synchronized
            synchronized(Singleton.class) {
                // 这里是线程安全的判断，防止其他线程在当前线程等待锁的期间完成了初始化
                if (INSTANCE == null) { 
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}
</code></pre>
<p>不锁 INSTANCE 的原因：</p>
<ul>
 <li>INSTANCE 要重新赋值</li>
 <li>INSTANCE 是 null，线程加锁之前需要获取对象的引用，设置对象头，null 没有引用</li>
</ul>
<p>实现特点：</p>
<ul>
 <li>懒惰初始化</li>
 <li>首次使用 getInstance() 才使用 synchronized 加锁，后续使用时无需加锁</li>
 <li>第一个 if 使用了 INSTANCE 变量，是在同步块之外，但在多线程环境下会产生问题</li>
</ul>
<h4 id="DCL问题">DCL问题</h4>
<p>getInstance 方法对应的字节码为：</p>
<pre><code class="language-java">0: 	getstatic 		#2 		// Field INSTANCE:Ltest/Singleton;
3: 	ifnonnull 		37
6: 	ldc 			#3 		// class test/Singleton
8: 	dup
9: 	astore_0
10: monitorenter
11: getstatic 		#2 		// Field INSTANCE:Ltest/Singleton;
14: ifnonnull 27
17: new 			#3 		// class test/Singleton
20: dup
21: invokespecial 	#4 		// Method "&lt;init&gt;":()V
24: putstatic 		#2 		// Field INSTANCE:Ltest/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic 		#2 		// Field INSTANCE:Ltest/Singleton;
40: areturn
</code></pre>
<ul>
 <li>17 表示创建对象，将对象引用入栈</li>
 <li>20 表示复制一份对象引用，引用地址</li>
 <li>21 表示利用一个对象引用，调用构造方法初始化对象</li>
 <li>24 表示利用一个对象引用，赋值给 static INSTANCE</li>
</ul>
<p><strong>步骤 21 和 24 之间不存在数据依赖关系</strong>，而且无论重排前后，程序的执行结果在单线程中并没有改变，因此这种重排优化是允许的</p>
<ul>
 <li>关键在于 0:getstatic 这行代码在 monitor 控制之外，可以越过 monitor 读取 INSTANCE 变量的值</li>
 <li>当其他线程访问 INSTANCE 不为 null 时，由于 INSTANCE 实例未必已初始化，那么 t2 拿到的是将是一个未初始化完毕的单例返回，这就造成了线程安全的问题</li>
</ul>
<p><img src="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fcdncos.likeyy.love%2F%2Fmarkdownimage-20251207213119833.png&amp;size=m" alt="image-20251207213119833"></p>
<h5 id="解决方法">解决方法</h5>
<p>指令重排只会保证串行语义的执行一致性（单线程），但并不会关系多线程间的语义一致性</p>
<p>引入 volatile，来保证出现指令重排的问题，从而保证单例模式的线程安全性：</p>
<pre><code class="language-java">private static volatile SingletonDemo INSTANCE = null;
</code></pre>
<h3 id="ha-be">ha-be</h3>
<p>happens-before 先行发生</p>
<p>Java 内存模型具备一些先天的“有序性”，即不需要通过任何同步手段（volatile、synchronized 等）就能够得到保证的安全，这个通常也称为 happens-before 原则，它是可见性与有序性的一套规则总结</p>
<p>不符合 happens-before 规则，JMM 并不能保证一个线程的可见性和有序性</p>
<ol>
 <li>
  <p>程序次序规则 (Program Order Rule)：一个线程内，逻辑上书写在前面的操作先行发生于书写在后面的操作 ，因为多个操作之间有先后依赖关系，则不允许对这些操作进行重排序</p>
 </li>
 <li>
  <p>锁定规则 (Monitor Lock Rule)：一个 unlock 操作先行发生于后面（时间的先后）对同一个锁的 lock 操作，所以线程解锁 m 之前对变量的写（解锁前会刷新到主内存中），对于接下来对 m 加锁的其它线程对该变量的读可见</p>
 </li>
 <li>
  <p><strong>volatile 变量规则</strong> (Volatile Variable Rule)：对 volatile 变量的写操作先行发生于后面对这个变量的读</p>
 </li>
 <li>
  <p>传递规则 (Transitivity)：具有传递性，如果操作 A 先行发生于操作 B，而操作 B 又先行发生于操作 C，则可以得出操作 A 先行发生于操作 C</p>
 </li>
 <li>
  <p>线程启动规则 (Thread Start Rule)：Thread 对象的 start()方 法先行发生于此线程中的每一个操作</p>
  <pre><code class="language-java">static int x = 10;//线程 start 前对变量的写，对该线程开始后对该变量的读可见
new Thread(()-&gt;{	System.out.println(x);	},"t1").start();
</code></pre>
 </li>
 <li>
  <p>线程中断规则 (Thread Interruption Rule)：对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生</p>
 </li>
 <li>
  <p>线程终止规则 (Thread Termination Rule)：线程中所有的操作都先行发生于线程的终止检测，可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行</p>
 </li>
 <li>
  <p>对象终结规则（Finaizer Rule）：一个对象的初始化完成（构造函数执行结束）先行发生于它的 finalize() 方法的开始</p>
 </li>
</ol>
<h3 id="设计模式">设计模式</h3>
<h4 id="终止模式">终止模式</h4>
<p>终止模式之两阶段终止模式：停止标记用 volatile 是为了保证该变量在多个线程之间的可见性</p>
<pre><code class="language-java">class TwoPhaseTermination {
    // 监控线程
    private Thread monitor;
    // 停止标记
    private volatile boolean stop = false;;

    // 启动监控线程
    public void start() {
        monitor = new Thread(() -&gt; {
            while (true) {
                Thread thread = Thread.currentThread();
                if (stop) {
                    System.out.println("后置处理");
                    break;
                }
                try {
                    Thread.sleep(1000);// 睡眠
                    System.out.println(thread.getName() + "执行监控记录");
                } catch (InterruptedException e) {
                   	System.out.println("被打断，退出睡眠");
                }
            }
        });
        monitor.start();
    }

    // 停止监控线程
    public void stop() {
        stop = true;
        monitor.interrupt();// 让线程尽快退出Timed Waiting
    }
}
// 测试
public static void main(String[] args) throws InterruptedException {
    TwoPhaseTermination tpt = new TwoPhaseTermination();
    tpt.start();
    Thread.sleep(3500);
    System.out.println("停止监控");
    tpt.stop();
}
</code></pre>
<h4 id="Balking">Balking</h4>
<p>Balking （犹豫）模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事，那么本线程就无需再做了，直接结束返回</p>
<pre><code class="language-java">public class MonitorService {
    // 用来表示是否已经有线程已经在执行启动了
    private volatile boolean starting = false;
    public void start() {
        System.out.println("尝试启动监控线程...");
        synchronized (this) {
            if (starting) {
            	return;
            }
            starting = true;
        }
        // 真正启动监控线程...
    }
}
</code></pre>
<p>对比保护性暂停模式：保护性暂停模式用在一个线程等待另一个线程的执行结果，当条件不满足时线程等待</p>
<p>例子：希望 doInit() 方法仅被调用一次，下面的实现出现的问题：</p>
<ul>
 <li>当 t1 线程进入 init() 准备 doInit()，t2 线程进来，initialized 还为f alse，则 t2 就又初始化一次</li>
 <li>volatile 适合一个线程写，其他线程读的情况，这个代码需要加锁</li>
</ul>
<pre><code class="language-java">public class TestVolatile {
    volatile boolean initialized = false;
  
    void init() {
        if (initialized) {
            return;
        }
    	doInit();
    	initialized = true;
    }
    private void doInit() {
    }
}
</code></pre>]]></description><guid isPermaLink="false">/archives/javazhi-jucxue-xi--part4</guid><dc:creator>五未</dc:creator><enclosure url="https://likeyy.love/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fcover-c6725625-214d-428a-a849-300658f97a59-f184acd2.png&amp;size=m" type="image/jpeg" length="1323808"/><category>并发与响应式编程</category><pubDate>Sun, 7 Dec 2025 12:56:57 GMT</pubDate></item></channel></rss>