<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>huangnvBlog</title><description>No description</description><link>https://blog.huangnv.online/</link><language>zh_CN</language><item><title>从零构建电脑活动 Agent（四）：用 PTC 处理大规模查询</title><link>https://blog.huangnv.online/posts/2697/4/4/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2697/4/4/</guid><description>当按天活动数据超出普通工具和模型上下文的承载范围时，用可控的临时程序组合工具、聚合结果并减少不必要的数据搬运，同时保留安全与监控边界。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;从零构建电脑活动 Agent（四）：用 PTC 处理大规模查询&lt;/h1&gt;
&lt;p&gt;当查询从一个时间点扩大到一整天，系统既要判断 Agent 能否选对工具，也要处理一天内可能出现的几万帧数据。把它们全部放进模型上下文既昂贵又缓慢，让模型直接完成确定性统计也不稳定。继续为每一种问法增加专用工具，又会让工具数量和描述成本不断膨胀。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/hero.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;图：PTC 在程序内部处理大量记录并组合工具，只向 Agent 返回小型结构化结果。&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;一、我在这里所说的 PTC 是什么&lt;/h2&gt;
&lt;p&gt;PTC 的全称是 Programmatic Tool Calling，即“程序化工具调用”。在 &lt;a href=&quot;https://platform.claude.com/docs/en/agents-and-tools/tool-use/programmatic-tool-calling&quot;&gt;Anthropic 的官方定义&lt;/a&gt;中，模型生成的代码运行在代码执行容器内，并可从代码中调用已注册工具；中间结果可以先在程序中筛选和处理，最后再把较小的结果送回模型上下文。本文借用这一机制来说明电脑活动查询中的实现思路。&lt;/p&gt;
&lt;p&gt;在这套设计里，PTC 位于基础工具和 Agent 之间，承担程序化的数据处理与工具编排。数据库访问仍由受控工具负责，宿主机权限也要由执行环境单独限制。&lt;/p&gt;
&lt;h2&gt;二、为什么不只让 Agent 自己计算&lt;/h2&gt;
&lt;p&gt;按天返回的原始活动记录可能达到七八万 token。让 Agent 直接阅读并统计，我观察到思考时间很长，算术和口径也难以稳定复现。语言模型适合理解问题和组织回答，重复搬运大量记录、排序和求和则更适合交给确定性程序。&lt;/p&gt;
&lt;p&gt;另一种方案是为“按天统计应用时长”“找出出现最多的窗口”等每种问法各做一个工具。这样短期内很直观，但用户的查询方式很多，专用接口很快会变成难以维护的工具集合。PTC 提供了一个中间层：保留有限的基础工具，把变化的组合逻辑放进一次受控执行中。&lt;/p&gt;
&lt;h2&gt;三、PTC 能做的两类事情&lt;/h2&gt;
&lt;p&gt;第一类是聚合。程序可以分批读取活动片段，按应用或窗口分组，计算明确口径下的时长，再把 Top-N 等小结果交给模型。这个计算过程不要求模型把每一帧都放进上下文。&lt;/p&gt;
&lt;p&gt;第二类是工具间数据传递。前一个工具返回的 ID 或筛选结果，可以在程序中直接作为下一个工具的输入，避免先把中间结果完整交给模型，再由模型发起下一次调用。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart LR
    A[用户问题] --&amp;gt; B[PTC 临时程序]
    B --&amp;gt; C[查询活动片段]
    C --&amp;gt; D[筛选和聚合]
    D --&amp;gt; E[把 ID 传给详情工具]
    E --&amp;gt; F[返回小型结构化结果]
    F --&amp;gt; G[Agent 组织回答]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;图：PTC 在一次程序执行中完成查询、聚合和工具间数据传递。&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;例如，程序可以先查询某时间范围内的活动片段，只保留浏览器窗口对应的标识，再将这些标识传给详情工具，最后统计满足条件的记录。这里的代码和数据都是教学示例，实际字段必须以工具契约为准。&lt;/p&gt;
&lt;h2&gt;四、第一轮调整：让工具返回结构适合程序消费&lt;/h2&gt;
&lt;p&gt;我一开始有些工具通过不同参数返回不同粒度的数据。对模型直接阅读，这种变化偶尔还能工作；放进 PTC 后，临时程序必须知道字段位置、类型和缺失状态，返回形状频繁变化就会迫使程序增加分支。&lt;/p&gt;
&lt;p&gt;因此，我逐步固定工具的输入输出结构：分页字段保持稳定，成功和缺失用明确状态表达，列表结果使用一致的容器，数据量过大时返回可继续查询的游标，避免截断成一段无法判断是否完整的文本。&lt;/p&gt;
&lt;p&gt;面向模型的摘要文本和面向程序的结构化数据也要区分。程序可以逐页聚合完整数据；一份被截断的自然语言结果不能被当成完整输入继续统计。&lt;/p&gt;
&lt;h2&gt;五、真实迭代：普通工具上限过低会诱发 PTC 升级&lt;/h2&gt;
&lt;p&gt;这是我引入 PTC 后遇到的具体问题。早期为了控制 token，我把普通工具的返回上限设得很低。没有 PTC 时，一个简单查询通常再补查一两次就能拿全；加入 PTC 后，第一次调用只返回轻量结果，Agent 可能把“结果太大”判断成应该升级到 PTC。&lt;/p&gt;
&lt;p&gt;调用路径会变成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;简单查询
  → 普通工具返回过少
  → Agent 误判为大任务
  → 生成并执行 PTC 程序
  → 发生修复或重试
  → token 与延迟反而上升
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以，引入 PTC 后，普通工具仍然需要合理的返回预算。我的调整是适当提高非 PTC 工具的返回预算，让中小查询在普通路径里直接完成；同时把“数据规模、是否需要聚合、是否需要跨工具传递”写成使用 PTC 的判断条件。调整后要同时回归简单查询和复杂查询，避免只优化大任务却让小任务退化。&lt;/p&gt;
&lt;h2&gt;六、PTC 仍然有固定成本&lt;/h2&gt;
&lt;p&gt;PTC 需要描述可调用的函数、参数和结果访问方式，这些说明会占用上下文。生成程序、启动执行环境、处理异常和重试也有额外开销。因此，“用了 PTC”不能直接等同于“省 token”。&lt;/p&gt;
&lt;p&gt;我会在 Trace 中继续记录内部调用了哪些工具、读取了多少页、是否重复读取、程序运行多久，以及最终返回给 Agent 的结果大小。评测时同时比较正确率、token 和延迟，并区分普通工具路径与 PTC 路径。&lt;/p&gt;
&lt;h2&gt;七、把程序执行限制在业务边界内&lt;/h2&gt;
&lt;p&gt;PTC 只应访问明确注册的只读工具和必要数据。执行环境还需要限制运行时间、内存、输出规模，以及不需要的文件和网络访问；生成的程序本身也可能出错，不能因为它由模型生成就默认安全。&lt;/p&gt;
&lt;p&gt;这层能力也不能绕过前面已经建立的数据库边界。查询范围、参数校验、分页和权限仍由工具程序落实。PTC 负责组合和计算，原始数据访问权与执行资源边界仍然属于宿主系统的职责。&lt;/p&gt;
&lt;h2&gt;八、我的判断标准&lt;/h2&gt;
&lt;p&gt;对少量结果、单次定位和简单概况，我优先使用普通工具；对大量记录、确定性聚合以及需要在工具间传递 ID 的任务，再考虑 PTC。最终选择由分层评测和监控结果决定，概念本身不构成采用理由。&lt;/p&gt;
&lt;p&gt;PTC 对这个项目的价值，是把大数据处理和工具编排放到可限制、可观测的程序层，减少模型搬运中间结果的负担。它适用于达到一定数据规模或编排复杂度的查询，并依赖稳定的工具契约。只有当普通路径的边界、返回结构和监控都清楚时，这个中间层才真正有助于扩大查询范围。&lt;/p&gt;
</content:encoded></item><item><title>从零构建电脑活动 Agent（一）：从可验证查询开始</title><link>https://blog.huangnv.online/posts/2697/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2697/1/1/</guid><description>从已有的电脑活动采集数据出发，建立可观察事实、查询工具和 Pi Agent 之间的边界，说明为什么第一步应该验证一条事实，再逐步生成日记。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;从零构建电脑活动 Agent（一）：从可验证查询开始&lt;/h1&gt;
&lt;p&gt;我正在做一个电脑活动 Agent。它的目标是记录电脑上的应用、窗口和界面信息，让我可以询问“昨晚我在做什么”“下午使用过哪些应用”，也可以继续追问某个时间点的界面细节。&lt;/p&gt;
&lt;p&gt;这个目标很容易被描述成“把电脑记录交给大模型，让它生成日记”。真正开始拆解后，我发现最先要解决的是三件更基础的事：数据能否查对，工具能否用对，结果能否被验证。文字自然度要建立在这些基础之上。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/hero.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;图：电脑活动从采集记录进入受控查询，再由 Agent 基于证据组织回答。&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;这篇先建立项目边界，并记录我为什么从最小的可验证查询开始。&lt;/p&gt;
&lt;h2&gt;1. 先把采集器当成数据源&lt;/h2&gt;
&lt;p&gt;我的采集层来自已有的 Rust 后端。它在相关交互发生时采集当前应用、窗口和界面信息，并将活动记录保存到项目使用的 SQLite 数据库中。这里的 Rust 是采集程序的实现语言，SQLite 是现有数据源的一部分；当前工作的重点是适配这套数据源，采集器重写不在这一阶段的范围内。&lt;/p&gt;
&lt;p&gt;采集过程还涉及 macOS 的辅助功能接口。辅助功能树能够以应用、窗口和界面的层级关系表示应用暴露出来的元素，并可能包含角色、标题和值等属性。它描述的是可访问性结构，不能直接等同于完整屏幕图像，也不能替代 OCR。&lt;/p&gt;
&lt;p&gt;因此，我先把系统边界固定为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;电脑交互与界面变化
        ↓
已有的 Rust 采集器
        ↓
SQLite 活动记录
        ↓
我封装的查询工具
        ↓
Pi Agent
        ↓
基于证据组织回答
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Agent 不需要知道采集器内部每个模块如何实现。它需要知道有哪些工具、工具能观察到什么，以及返回字段的含义和限制。&lt;/p&gt;
&lt;h2&gt;2. 为什么选择 Pi Agent&lt;/h2&gt;
&lt;p&gt;我选用的是 Pi Agent 作为上层 Agent 框架。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/badlogic/pi-mono/tree/main/packages/agent&quot;&gt;Pi Agent&lt;/a&gt; 的核心运行时提供工具调用和状态管理，项目也包含 Web UI 等配套包，并支持通过扩展增加能力。对我当前的第一阶段来说，核心循环足够简单：理解问题，选择工具，取得证据，再组织答案。单 Agent 查询已经能覆盖主要场景，暂时没有引入多个角色协作的必要。&lt;/p&gt;
&lt;p&gt;我看重的还有外围生态：Web UI、监控和会话管理等已有能力可以复用，让我把精力放到查询契约和评测上。这里需要区分框架核心、插件和宿主程序的职责；多个聊天会话也不自动等于多个操作系统线程。我的选型结论只适用于当前业务边界：Pi Agent 的扩展方式和可复用组件与这个阶段比较匹配。&lt;/p&gt;
&lt;h2&gt;3. 先查对一条事实，再生成摘要&lt;/h2&gt;
&lt;p&gt;“昨晚我在学习数据库”看起来只是一句话，实际上混合了时间范围、活动记录、页面内容和对行为目的的解释。回答出错时，如果没有中间证据，我很难判断问题来自时间计算、数据缺失、工具调用，还是模型的语义推断。&lt;/p&gt;
&lt;p&gt;所以我把能力按可验证程度分层：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;查询层次&lt;/th&gt;
&lt;th&gt;示例问题&lt;/th&gt;
&lt;th&gt;首先验证的内容&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;时间点&lt;/td&gt;
&lt;td&gt;14:12:30 使用了什么应用？&lt;/td&gt;
&lt;td&gt;时间匹配、实际记录和来源 ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;时间段&lt;/td&gt;
&lt;td&gt;14:00—14:30 用过哪些应用？&lt;/td&gt;
&lt;td&gt;活动是否查全、片段是否组合正确&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;一整天&lt;/td&gt;
&lt;td&gt;今天各应用大概用了多久？&lt;/td&gt;
&lt;td&gt;统计口径、边界和计算过程&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;摘要检索&lt;/td&gt;
&lt;td&gt;过去两天出现了哪些活动？&lt;/td&gt;
&lt;td&gt;摘要是否忠实、能否回到原始证据&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;em&gt;表：电脑活动查询从时间点到摘要检索的能力分层。&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;这个顺序建立了依赖关系：时间点查询稳定后，时间段分析才有基础；时间段和统计口径稳定后，整天汇总才有意义；事实可靠后，摘要才不会只是压缩错误。&lt;/p&gt;
&lt;h2&gt;4. “可验证”要落实为契约&lt;/h2&gt;
&lt;p&gt;以时间点查询为例，我希望工具至少返回应用名称、实际记录时间和来源 ID。用户询问 14:12:30 时，如果最近记录是 14:12:28，工具必须预先规定采用允许范围内的最近匹配，还是只接受精确匹配；回答也应说明证据的实际时间。&lt;/p&gt;
&lt;p&gt;同样，浏览器窗口处于前台，只能证明采集记录中的前台状态，不能证明用户阅读了窗口内全部文字。工具先描述可观察信息，意图和结论留在证据允许的范围内。&lt;/p&gt;
&lt;p&gt;这部分是实现原则；下面是用于说明契约的示例返回值，字段和时间均为教学示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;observed_at&quot;: &quot;2026-09-01T14:12:28+08:00&quot;,
  &quot;app&quot;: &quot;Chrome&quot;,
  &quot;window&quot;: &quot;Documentation&quot;,
  &quot;source_id&quot;: &quot;frame_101&quot;,
  &quot;match&quot;: &quot;nearest_within_tolerance&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;复现时，我会先选择数据覆盖明确的短窗口，准备能人工核对的问题；再分别验证工具返回、Agent 调用和最终自然语言回答。静态阅读提示词只能说明设计意图，不能证明运行时已经满足这些条件。&lt;/p&gt;
&lt;h2&gt;5. 监控也属于第一阶段&lt;/h2&gt;
&lt;p&gt;我在早期迭代中先接入了基础工具调用、监控和一轮提示词调整，目的是尽快获得一个可观察、可反复测试的最小版本。这不代表整个系统已经完成。&lt;/p&gt;
&lt;p&gt;如果只保留最终答案，我看不到模型是否读取了大量无关数据、是否重复调用工具，也无法解释 token 为什么增加。对我来说，开发循环应该包含：定义一个小问题，给它清楚的工具，记录实际调用过程，再根据失败结果调整接口或提示词。&lt;/p&gt;
&lt;p&gt;监控至少要能回答：调用了哪个工具、参数是什么、返回了多少数据、是否发生分页或重试，以及最终答案引用了哪些来源。它让“答案看起来合理”变成可以追踪的执行过程。&lt;/p&gt;
&lt;h2&gt;6. 第一篇的结论&lt;/h2&gt;
&lt;p&gt;目前已经确定的基础边界是：采集器提供活动记录，查询工具提供受控证据，Pi Agent 负责选择工具并组织回答。活动合并、复杂统计和摘要索引属于后续设计，本篇不把它们写成已完成能力。&lt;/p&gt;
&lt;p&gt;这个项目的第一步，是让 Agent 对一件小事做对、说清，并且能够说明自己为什么这样回答。下一篇继续讨论查询工具的设计，以及我为什么没有一开始就让 Agent 自由生成 SQL。&lt;/p&gt;
</content:encoded></item><item><title>从零构建电脑活动 Agent（二）：为什么不让 Agent 自由生成 SQL</title><link>https://blog.huangnv.online/posts/2697/2/2/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2697/2/2/</guid><description>以电脑活动查询为例，说明如何用受控业务工具封装 SQL、合并高频记录并分离概况与详情，同时明确工具契约和数据完整性的边界。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;从零构建电脑活动 Agent（二）：为什么不让 Agent 自由生成 SQL&lt;/h1&gt;
&lt;p&gt;有了活动数据库，最直接的方案似乎是把表结构告诉模型，让它自己写 SQL，查询后再总结。我确实考虑过这种方式，但当前项目还需要同时解决模型能否理解记录的业务含义，以及一次查询应该拿回多少信息。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/hero.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;图：Agent 通过职责明确的查询工具和访问边界读取本地活动数据库。&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;在当前业务边界下，我选择先由程序封装 SQL，再把有限、明确的查询能力提供给 Agent。这一篇记录这个取舍，并把已实现的思路、复现建议和教学示例分开说明。&lt;/p&gt;
&lt;h2&gt;1. 表结构不等于业务语义&lt;/h2&gt;
&lt;p&gt;电脑活动数据通常高频而细碎。一次点击、键盘交互或界面变化，都可能产生新的记录；同一窗口在短时间内也可能对应许多内容相近的采集帧。&lt;/p&gt;
&lt;p&gt;模型即使知道 &lt;code&gt;timestamp&lt;/code&gt;、&lt;code&gt;app_name&lt;/code&gt; 和 &lt;code&gt;window_title&lt;/code&gt;，也不天然知道：记录数量不能直接代表使用时长，相邻记录可能属于同一段连续活动，辅助功能树等详细内容可能很大，而“下午用了哪些应用”通常并不需要读取全部界面文本。&lt;/p&gt;
&lt;p&gt;例如，用户只问应用分布，模型却生成一条 SQL 返回这段时间内每一帧的完整界面内容。SQL 在语法上正确，查询也成功了，但系统仍然浪费了资源，且把无关细节交给了后续上下文。&lt;/p&gt;
&lt;p&gt;因此，我把这件事作为业务接口设计问题处理。模型生成 SQL 的能力只是其中一个环节。&lt;/p&gt;
&lt;h2&gt;2. 封装的是访问边界&lt;/h2&gt;
&lt;p&gt;我的做法是让模型选择工具和参数，工具内部执行预先定义的只读查询。时间范围、字段选择、分页方式、返回预算和允许的操作，都可以在同一个程序边界内落实。&lt;/p&gt;
&lt;p&gt;对于复现实现，我会把参数校验、参数化 SQL、只读连接和资源上限作为基础约束。未来如果确实需要写操作，也应提供带有明确业务含义、单独授权的写入工具；不能因为查询需要灵活性，就顺便给模型任意修改数据库的权限。&lt;/p&gt;
&lt;p&gt;封装工具并不代表天然安全。权限、事务、超时和资源限制仍需要程序执行。&lt;a href=&quot;https://www.sqlite.org/wal.html&quot;&gt;SQLite 的 WAL 模式&lt;/a&gt;允许读取和写入并发进行，但同一时刻仍然只有一个写入者；并发行为要结合实际连接和事务配置分析，不能只用“模型自由写 SQL 会删库”这种泛化说法替代验证。&lt;/p&gt;
&lt;p&gt;对这个活动查询项目，受控业务工具更容易测试，也更容易解释失败原因。这是当前边界下的工程选择，不是对所有数据库 Agent 的普遍结论。&lt;/p&gt;
&lt;h2&gt;3. 先把高频帧整理成活动片段&lt;/h2&gt;
&lt;p&gt;时间点查询可以直接定位记录；时间段查询则需要中间粒度。一个窗口连续使用十分钟，期间可能产生数百条记录，逐条交给模型既重复，也不利于理解。&lt;/p&gt;
&lt;p&gt;因此，我在查询处理中引入“活动片段”：把同一应用、同一窗口中的连续活动整理为一个 &lt;code&gt;task&lt;/code&gt;。这里的 &lt;code&gt;task&lt;/code&gt; 只是窗口级连续活动片段，不表示用户完成了一项语义任务。&lt;/p&gt;
&lt;p&gt;例如，下面是三个片段：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;10:00—10:05  浏览器，窗口 A
10:05—10:07  编辑器，窗口 B
10:07—10:10  浏览器，窗口 A
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两个浏览器片段不能仅凭应用名称合并成“10:00—10:10 一直使用浏览器”，不同窗口也不能仅凭应用相同就合并。合并记录与计算使用时长同样是两件事：没有连续状态证据时，不能只凭两帧时间差断言中间一直在使用该应用。空闲、采集缺失和查询边界都要有明确口径。&lt;/p&gt;
&lt;p&gt;在我处理的部分时间窗口中，几百条记录可以整理成十几个或二十个片段。这是具体数据下的观察，不是固定压缩比例，也不构成所有数据集的运行证明。&lt;/p&gt;
&lt;h2&gt;4. 工具围绕查询动作设计&lt;/h2&gt;
&lt;p&gt;我倾向于提供少量职责清楚的工具，避免为每一种自然语言问法单独制造一个接口：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;工具&lt;/th&gt;
&lt;th&gt;职责&lt;/th&gt;
&lt;th&gt;默认返回&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_activity_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;定位具体时间点&lt;/td&gt;
&lt;td&gt;实际时间、应用、窗口、来源 ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list_activity_tasks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;查询时间段的活动片段&lt;/td&gt;
&lt;td&gt;起止时间、应用、窗口、片段 ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;search_activity_frames&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;在已知范围内找候选帧&lt;/td&gt;
&lt;td&gt;匹配帧的元信息和 ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;read_frame_content&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;读取已定位帧的详细内容&lt;/td&gt;
&lt;td&gt;界面文本、来源类型和缺失状态&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;em&gt;表：基础查询工具及其默认返回范围。&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;这种划分把“查概况”和“读详情”分开。只问应用或时长时，不读取完整辅助功能树；只有问题涉及列表、按钮或页面文字时，才按来源 ID 读取详细内容。&lt;/p&gt;
&lt;p&gt;下面是一个活动片段的契约示例，时间和数据均为虚构：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;items&quot;: [
    {
      &quot;task_id&quot;: &quot;task_01&quot;,
      &quot;app&quot;: &quot;Chrome&quot;,
      &quot;window_id&quot;: &quot;window_A&quot;,
      &quot;start&quot;: &quot;2026-09-01T10:00:00+08:00&quot;,
      &quot;end&quot;: &quot;2026-09-01T10:05:00+08:00&quot;,
      &quot;duration_seconds&quot;: 300,
      &quot;frame_count&quot;: 86
    }
  ],
  &quot;next_cursor&quot;: null,
  &quot;coverage_complete&quot;: true
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;duration_seconds&lt;/code&gt; 必须有定义，例如在已建立的活动区间上与查询窗口取交集后的时长，不能根据帧数随意估算。&lt;code&gt;next_cursor&lt;/code&gt; 表示是否还有分页，&lt;code&gt;coverage_complete&lt;/code&gt; 表示源数据是否完整覆盖请求时间；取完所有分页并不等于这段时间没有采集缺失。&lt;/p&gt;
&lt;h2&gt;5. 局部说明与全局策略分开&lt;/h2&gt;
&lt;p&gt;单个工具的说明应包含用途、输入输出、适用场景和限制。例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;工具：read_frame_content
用途：读取已知 frame_id 对应的详细界面内容。
输入：frame_ids，必须来自已有查询结果。
输出：实际记录时间、内容来源类型、文本及缺失状态。
适合：回答界面文字、列表项和按钮内容。
不适合：只查询应用名称、窗口分布或时长。
限制：返回内容可能较大；缺失内容不代表元素不存在。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;多个工具如何组合，则由系统提示词统一描述：先定位活动范围，范围仍大时再筛选候选帧，最后读取详细内容；已有精确来源 ID 时直接读取；只问应用或时长时不读取无关文本。&lt;/p&gt;
&lt;p&gt;工具描述负责局部能力，系统提示词负责跨工具策略。硬边界仍要由程序落实，提示词不能代替只读权限、返回上限和参数校验。&lt;/p&gt;
&lt;h2&gt;6. 结构化返回让工具可测试&lt;/h2&gt;
&lt;p&gt;我在设计中还特别关注返回结构的稳定性。同一个工具如果有时返回数组、有时返回带 &lt;code&gt;items&lt;/code&gt; 的对象，有时因为数据过多改成一段提示文本，模型也许还能勉强阅读，程序却很难可靠消费。&lt;/p&gt;
&lt;p&gt;因此，字段位置、类型、分页方式和完整性状态应当可预测。轻量文本和面向程序的结构化数据，也不应共用含义模糊的截断规则：程序可以处理分页，却不能把被截断的文本当成完整统计结果。&lt;/p&gt;
&lt;h2&gt;7. 第二篇的结论&lt;/h2&gt;
&lt;p&gt;自由 SQL 可能把业务语义、数据范围和安全边界同时交给模型。对我的电脑活动查询，程序封装 SQL、合并高频记录、分离概况和详情，能够让工具更容易控制和验证。&lt;/p&gt;
&lt;p&gt;目前这些工具契约和活动片段是复现设计；具体字段、分页和时长口径仍应以实际数据源和运行测试为准。下一步需要通过调用轨迹、答案正确性、返回规模和来源完整性，验证这些设计是否真的帮助 Agent 工作。&lt;/p&gt;
</content:encoded></item><item><title>从零构建电脑活动 Agent（三）：怎样评测工具调用过程</title><link>https://blog.huangnv.online/posts/2697/3/3/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2697/3/3/</guid><description>从具体时间点、时间段到按天汇总，记录正确率、Trace、token 与延迟，建立能够驱动提示词和工具迭代的分层评测与监控。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;从零构建电脑活动 Agent（三）：怎样评测工具调用过程&lt;/h1&gt;
&lt;p&gt;电脑活动 Agent 最终可能要回答“我今天做了什么”，但我没有从这类开放问题开始评测。活动记录包含时间、应用、窗口和界面证据，任何一层查询出错，最后的自然语言总结都可能听起来合理却无法核对。因此，我先把目标拆成可验证的最小能力，再逐层增加数据规模和工具编排。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/hero.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;图：从单工具查询、多工具编排到大规模聚合的分层评测与监控。&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;一、先把最终目标拆成三层测试&lt;/h2&gt;
&lt;p&gt;我目前使用的测试层次大致如下：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;层次&lt;/th&gt;
&lt;th&gt;示例问题&lt;/th&gt;
&lt;th&gt;主要观察点&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;具体时间点&lt;/td&gt;
&lt;td&gt;14:12:30 我用了什么应用？&lt;/td&gt;
&lt;td&gt;是否命中正确记录，是否一次调用完成&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;时间段&lt;/td&gt;
&lt;td&gt;14:00—14:30 用了哪些应用，侧边栏有什么内容？&lt;/td&gt;
&lt;td&gt;是否先缩小范围，工具编排是否合理&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;按天汇总&lt;/td&gt;
&lt;td&gt;今天各应用大概用了多久？&lt;/td&gt;
&lt;td&gt;数据量、聚合口径、总耗时和失败恢复&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;em&gt;表：三个查询粒度及其主要评测目标。&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;这个顺序表达的是依赖关系。时间点查询先验证“单条事实能否取对”，时间段查询再验证多个工具怎样组合，按天查询最后检验系统面对大量记录时是否仍能稳定工作。它是当前项目的测试设计，不代表所有活动 Agent 都必须采用相同粒度。&lt;/p&gt;
&lt;h2&gt;二、具体时间点：验证工具能否直接完成任务&lt;/h2&gt;
&lt;p&gt;第一组测试给每个问题准备一个能够人工核对的时间点。例如，我会指定某天的 14:12:30，期望工具返回实际记录时间、应用、窗口和来源 ID。&lt;/p&gt;
&lt;p&gt;我主要检查三件事：答案是否正确，是否调用了预期工具，以及是否出现了额外调用。理论上，已经知道精确时间的问题可以由一个定位工具完成。如果 Agent 绕去调用其他工具，或者连续调用同一个工具，通常说明工具描述、参数契约或系统提示词还不够清楚。&lt;/p&gt;
&lt;p&gt;这里的“正确”不只看最终中文句子。假设请求时间没有完全匹配，工具可能返回允许范围内最近的一条记录；评测就要同时核对实际证据时间，避免把近似命中写成精确命中。&lt;/p&gt;
&lt;h2&gt;三、时间段：检查范围缩小和工具轨迹&lt;/h2&gt;
&lt;p&gt;时间段问题更接近真实使用。比如我询问某个半小时内用了哪些应用，或者询问浏览器侧边栏中的具体项目。这个范围内可能有数百帧，完整返回每一帧的 OCR 或辅助功能内容，会迅速增加上下文成本。&lt;/p&gt;
&lt;p&gt;我为这类问题准备的期望轨迹是：先查询已经合并的活动片段（项目中称为 &lt;code&gt;task&lt;/code&gt;），据此缩小到相关应用、窗口和帧，再读取少量详情。测试不只看回答，也看 Trace 中是否遵循了这个路径。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[时间段问题] --&amp;gt; B[查询活动片段]
    B --&amp;gt; C[缩小应用和窗口范围]
    C --&amp;gt; D[定位候选帧]
    D --&amp;gt; E[按需读取详情]
    E --&amp;gt; F[回答并保留来源]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;图：时间段详情问题的预期工具调用路径。&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;一个典型的失败是：Agent 看到“侧边栏有什么”，直接读取十几或二十帧的完整文本，没有使用已有的范围查询器。最终答案可能碰巧正确，但这条 Trace 没有满足设计目标，token 也会明显增加。于是我会调整工具说明和系统提示词，再用同一批问题做回归。&lt;/p&gt;
&lt;p&gt;单个工具的用途和输入输出属于工具描述；多个工具的先后顺序、何时从概况进入详情，属于系统级编排规则。把两者混在一起，会让局部描述越来越长，也更难判断到底是哪一层造成了错误。&lt;/p&gt;
&lt;h2&gt;四、按天汇总：把大数据量当成独立能力评测&lt;/h2&gt;
&lt;p&gt;按天查询的难度会明显上升。活动采集频率较高时，一天可能有数万帧；如果全部返回给 Agent，输入上下文可能达到七八万 token。此时，即使问题只是“各应用用了多久”，也不应该默认把原始记录交给模型自行计算。&lt;/p&gt;
&lt;p&gt;我会单独记录这类测试的聚合口径、数据覆盖、工具调用次数和失败重试。时长统计最好由程序在明确的活动区间上计算，Agent 负责选择查询方式、解释结果和指出覆盖限制。否则，模型的长思考时间和算术错误会掩盖真正的查询问题。&lt;/p&gt;
&lt;h2&gt;五、监控：让每次试错都有证据&lt;/h2&gt;
&lt;p&gt;我在最初一两天先接入了基础工具、监控和一轮提示词调整。监控的价值在于把“感觉这次变好了”拆成可以比较的信号：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;信号&lt;/th&gt;
&lt;th&gt;用来回答的问题&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;正确率&lt;/td&gt;
&lt;td&gt;最终事实是否与人工核对结果一致？&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trace&lt;/td&gt;
&lt;td&gt;是否调用了预期工具，顺序和次数是否合理？&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;token&lt;/td&gt;
&lt;td&gt;是否读取了过多详情，是否发生不必要重试？&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;延迟&lt;/td&gt;
&lt;td&gt;用户等待时间来自模型、工具还是数据规模？&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;em&gt;表：监控信号与对应的诊断问题。&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Trace 至少应能看到请求、工具名、参数摘要、返回状态、来源数量、重试和总耗时。对于长文本，不必把所有内容复制到监控面板，但要保留足以定位问题的大小、分页和来源信息。静态检查能确认字段存在，不能替代真实运行观测。&lt;/p&gt;
&lt;p&gt;每次修改提示词、工具上限或合并逻辑后，我会在固定测试集上做回归；如果条件允许，也做 A/B 对照。小范围的 A/B 结果只说明这一批数据和配置下的差异，不能直接外推成系统整体能力。&lt;/p&gt;
&lt;h2&gt;六、从监控结果反推改动&lt;/h2&gt;
&lt;p&gt;评测是迭代入口，不只在流程末尾产出一个分数。例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;正确率下降且 Trace 偏离预期，优先检查工具契约和编排提示。&lt;/li&gt;
&lt;li&gt;正确率不变但 token 上升，检查是否读取了不必要的 OCR、是否重复分页。&lt;/li&gt;
&lt;li&gt;token 可接受但延迟变长，检查工具查询、模型等待和重试分别占了多少时间。&lt;/li&gt;
&lt;li&gt;按天汇总失败而时间段测试稳定，说明需要增加确定性聚合或引入适合大数据的执行路径。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我希望先让 Agent 对一件小事做对、说清，并且能够说明证据来自哪里。等事实层稳定后，再叠加更难验证的语义总结。这样，评测结果才能真正指导下一轮工具和提示词设计，并避免停留在对文字流畅度的评价上。&lt;/p&gt;
</content:encoded></item><item><title>从零构建电脑活动 Agent（五）：让摘要成为可追溯的查询入口</title><link>https://blog.huangnv.online/posts/2697/5/5/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2697/5/5/</guid><description>设计一层面向查询的活动摘要：用十分钟事实摘要和六小时汇总降低重复扫描，同时通过 source、task、frame 证据链支持按时间与按事件检索，并为后续评测保留可验证边界。</description><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;从零构建电脑活动 Agent（五）：让摘要成为可追溯的查询入口&lt;/h1&gt;
&lt;p&gt;前面几篇解决的是“现在提出一个问题，Agent 如何查询活动记录”。当问题扩大到“过去两天我做了什么”，或者变成“最近在哪些时间看过某个网站”，每次都从原始帧重新扫描，既重复，也会把大量低层数据交给模型。&lt;/p&gt;
&lt;p&gt;因此，我开始设计摘要层。它先用更少的信息定位可验证的事实：小粒度记录事实，大粒度组织索引，必要时仍能回到原始证据。有感染力的日记属于事实层稳定后的目标。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/hero.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;图：多粒度摘要提供按时间和按事件两条检索路径，并保留到原始证据的连接。&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;本文讨论当前设计中的两级摘要、两种检索入口和评测边界。这里描述的是正在建立的摘要与检索方案，不把尚未验证的能力写成已经完成的系统。&lt;/p&gt;
&lt;h2&gt;1. 摘要首先服务于查询&lt;/h2&gt;
&lt;p&gt;摘要是对活动记录的压缩，但不能只是脱离来源的文本。一个可用的摘要至少要同时回答两个问题：这段时间观察到了什么，以及这些描述可以回到哪条记录。&lt;/p&gt;
&lt;p&gt;例如，用户询问过去两天的活动时，Agent 可以先读取六小时汇总，确定需要展开的时间段，再读取对应的十分钟摘要；只有问题涉及具体页面内容时，才继续定位原始帧。高层摘要负责缩小范围，低层记录负责提供证据。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    Q[用户问题] --&amp;gt; T[六小时汇总]
    T --&amp;gt; M[十分钟事实摘要]
    M --&amp;gt; F[原始活动帧]
    F --&amp;gt; A[可核对的回答]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;图：六小时汇总、十分钟事实摘要和原始活动帧之间的回查链路。&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;这使摘要成为能够持续回查的查询入口。摘要是否有文采可以放在后续讨论；当前优先保证定位和追溯。&lt;/p&gt;
&lt;h2&gt;2. 十分钟摘要：先记录事实，再讨论含义&lt;/h2&gt;
&lt;p&gt;我把较小的摘要窗口设为十分钟。这是当前设计选择，实际粒度仍需结合采集频率、数据密度和查询效果评测，不能视作适用于所有活动数据的固定最优值。&lt;/p&gt;
&lt;p&gt;这一层主要描述应用、窗口、可观察到的页面信息和时间范围。例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;记录显示，10:00—10:06 的 Chrome 前台窗口标题包含“Redis Persistence”。
来源：task_01；代表性记录：frame_101、frame_118。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这句话没有进一步断言用户持续阅读、完成了学习任务，或准备优化项目。窗口标题属于观察事实，用户意图属于更高层的解释，二者需要分开。&lt;/p&gt;
&lt;p&gt;一个用于说明契约的摘要可以写成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 活动摘要：2026-09-01 10:00—10:10（UTC+08:00）

## 活动记录

- 10:00—10:06：Chrome 前台窗口标题包含“Redis Persistence”。
  来源：task_01；frame_101、frame_118。
- 10:06—10:10：VS Code 前台窗口标题包含“agent_tools.ts”。
  来源：task_02；frame_126、frame_139。

## 记录限制

内容仅描述成功采集到的活动，不据此推断持续阅读、任务完成或知识掌握。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;source&lt;/code&gt; 表示证据来源，&lt;code&gt;task&lt;/code&gt; 表示查询层整理出的连续活动片段，&lt;code&gt;frame&lt;/code&gt; 表示更底层的具体采集记录。三者共同构成回查路径：摘要描述一个事实，事实指向片段，片段再指向原始帧。&lt;/p&gt;
&lt;h2&gt;3. 六小时汇总：组织摘要，不重新编故事&lt;/h2&gt;
&lt;p&gt;六小时窗口可以覆盖三十六个十分钟窗口。汇总层不必重复每一条底层记录，而应组织主要活动、标出重复出现的对象，并引用相关的十分钟摘要。同时要保留窗口已生成、数据缺失或覆盖不完整等状态。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;六小时汇总
  ├─ 10:00—10:10：浏览器与编辑器活动
  │      └─ 十分钟摘要 → task → frame
  ├─ 10:10—10:20：相关页面再次出现
  │      └─ 十分钟摘要 → task → frame
  └─ 其他时间窗口及覆盖状态
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;高层汇总不能提升低层证据的确定性。十分钟摘要只写“标题中出现某个关键词”，六小时汇总就不能改写成“用户进行了六小时的系统学习”。如果底层窗口缺失，汇总也必须保留这个限制。&lt;/p&gt;
&lt;p&gt;因此，这是一种逐层压缩、逐层引用的结构：高层提供概况，中层帮助定位，底层保留证据，引用链不能因为文字变短而断开。&lt;/p&gt;
&lt;h2&gt;4. 检索入口一：按时间逐层展开&lt;/h2&gt;
&lt;p&gt;当用户问“过去两天我主要在干什么”，查询天然从时间范围开始。Agent 可以先定位重叠的六小时汇总，再展开相关十分钟摘要，最后根据问题需要读取原始帧：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;时间范围
  ↓
相关六小时汇总
  ↓
相关十分钟摘要
  ↓
必要的原始帧
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这更准确地称为“按时间组织的分层检索”。它利用不同粒度的摘要减少无关读取，但不意味着候选范围每次严格减半，也不能据此承诺二分查找的复杂度。高层信息已经足够回答概况时，查询可以在那里结束。&lt;/p&gt;
&lt;h2&gt;5. 检索入口二：按事件定位时间&lt;/h2&gt;
&lt;p&gt;另一些问题先给出事件对象，例如：“最近在哪些时间看过某个网站？”这时从所有时间摘要逐个翻阅并不理想。六小时汇总可以额外组织从证据中识别出的对象及其出现时间：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;事件对象&lt;/th&gt;
&lt;th&gt;出现时段&lt;/th&gt;
&lt;th&gt;对应摘要&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;网站 A&lt;/td&gt;
&lt;td&gt;2026-09-01 10:00—10:20&lt;/td&gt;
&lt;td&gt;十分钟摘要 01、02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;网站 A&lt;/td&gt;
&lt;td&gt;2026-09-01 15:30—15:40&lt;/td&gt;
&lt;td&gt;十分钟摘要 18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;网站 B&lt;/td&gt;
&lt;td&gt;2026-09-01 11:10—11:20&lt;/td&gt;
&lt;td&gt;十分钟摘要 08&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;em&gt;表：按事件对象关联出现时段和底层摘要的索引示例。&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;检索路径就变成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;网站 / 页面 / 文档对象
  ↓
相关摘要与时间段
  ↓
十分钟事实摘要
  ↓
必要的原始帧
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;按事件组织不代表另建一套互不一致的记忆。时间索引和事件索引应当指向同一批事实；证据无法确认网站地址时，也不能仅凭模糊标题强行归类。&lt;/p&gt;
&lt;p&gt;“更频繁”还必须先定义指标。采集帧数量、连续访问片段数量、前台停留时长和出现日期都可能得到不同结论。对纯计数或时长统计，应优先使用结构化记录和确定性程序；摘要负责定位和解释口径，不替代统计计算。&lt;/p&gt;
&lt;h2&gt;6. 事实层先于语义层&lt;/h2&gt;
&lt;p&gt;未来的语义层可能需要把分散活动串联起来，形成更有上下文的描述。但它必须建立在稳定的事实层之上。&lt;/p&gt;
&lt;p&gt;事实层更容易检查：时间是否正确，应用和窗口是否得到记录支持，来源 ID 是否有效，是否把“窗口出现”写成了“任务完成”。如果源头就不准确，语义层增加的连贯性只会让错误更难发现。&lt;/p&gt;
&lt;p&gt;因此，当前摘要先保持事实导向，不展开长期记忆或带有温度的叙事能力。等事实数据、证据链和评测稳定后，再评估是否需要增加语义层，以及如何让每个语义判断回到支持它的事实。&lt;/p&gt;
&lt;h2&gt;7. 摘要需要专门评测&lt;/h2&gt;
&lt;p&gt;摘要的评测至少包含两组问题。&lt;/p&gt;
&lt;p&gt;第一组检查“对不对”：描述是否被原始帧支持，时间是否落在正确窗口，&lt;code&gt;source&lt;/code&gt;、&lt;code&gt;task&lt;/code&gt;、&lt;code&gt;frame&lt;/code&gt; 是否能回查，缺失状态是否被保留，以及有没有加入证据之外的意图和成果。&lt;/p&gt;
&lt;p&gt;第二组检查“有没有用”：Agent 读取摘要后能否回答原来的问题，是否能定位到正确来源，进入上下文的信息是否减少，关键对象是否因压缩而遗漏。&lt;/p&gt;
&lt;p&gt;可以在同一份数据上比较两条路径：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;路径 A：直接查询活动片段与原始记录
路径 B：先查询摘要，需要时再回查原始记录
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比较指标不能只有 token 和耗时，还应包括答案正确性、来源定位率、遗漏率和不确定信息是否被正确表达。摘要是有损压缩；某个对象没有出现在摘要中，不等于原始数据中没有它。查询未命中时，需要根据问题和覆盖范围回查更低层记录。&lt;/p&gt;
&lt;h2&gt;结语：把摘要做成可回查的结构&lt;/h2&gt;
&lt;p&gt;在这个项目里，摘要的第一职责是帮助查询：十分钟摘要描述可核对的事实，六小时汇总组织更大的范围，按时间与按事件提供两种入口，&lt;code&gt;source&lt;/code&gt;、&lt;code&gt;task&lt;/code&gt;、&lt;code&gt;frame&lt;/code&gt; 让每层都能回到证据。&lt;/p&gt;
&lt;p&gt;这条路径把事实层和语义层拆开，也把“文字更短”与“系统更可靠”区分开。只有在事实、检索和评测都稳定后，摘要才有资格成为更高层理解的基础。&lt;/p&gt;
</content:encoded></item><item><title>RocketMQ 路由机制</title><link>https://blog.huangnv.online/posts/2689/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2689/1/1/</guid><pubDate>Sun, 09 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;RocketMQ 路由机制&lt;/h1&gt;
&lt;p&gt;RocketMQ 的路由机制可以分成三个层次理解：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;生产路由
Producer → Topic → MessageQueue → Broker

订阅关系
Topic → ConsumerGroup

消费负载均衡
ConsumerGroup → Consumer实例
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中，生产路由负责决定&lt;strong&gt;消息存在哪里&lt;/strong&gt;；ConsumerGroup 负责表示&lt;strong&gt;哪些业务需要消费消息&lt;/strong&gt;；负载均衡负责决定&lt;strong&gt;同一个消费组中的哪个 Consumer 实例处理消息&lt;/strong&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;1. Topic 与 MessageQueue&lt;/h2&gt;
&lt;p&gt;Topic 是 RocketMQ 对消息进行业务分类的基本单位。&lt;/p&gt;
&lt;p&gt;例如文字付费平台可以定义：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Topic：payment_event
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;表示支付领域相关消息。&lt;/p&gt;
&lt;p&gt;一个 Topic 内部可以包含多个 MessageQueue：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;               Topic：payment_event
                       │
          ┌────────────┼────────────┬────────────┐
          ↓            ↓            ↓            ↓
         Q0           Q1           Q2           Q3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Q0、Q1、Q2、Q3 是&lt;strong&gt;并列关系&lt;/strong&gt;，不存在 Q0 → Q1 → Q2 这样的上下游关系。&lt;/p&gt;
&lt;p&gt;可以把它理解成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Topic
= 消息的大分类

MessageQueue
= Topic 内部的分区
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一个 MessageQueue 只属于一个 Topic。例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;payment_event
├── Q0
├── Q1
├── Q2
└── Q3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Q0 不会同时属于另外一个 &lt;code&gt;order_event&lt;/code&gt; Topic。&lt;/p&gt;
&lt;p&gt;每个 MessageQueue 内部的消息都有自己的 Offset：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Q0

offset 0 → 消息A
offset 1 → 消息B
offset 2 → 消息C
offset 3 → 消息D
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此可以把 Offset 理解成：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;消息在某一个 MessageQueue 中的位置。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h1&gt;2. Producer 的消息路由&lt;/h1&gt;
&lt;p&gt;Producer 准备发送：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Topic = payment_event
Tag   = PAYMENT_SUCCESS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;此时 Producer 首先需要知道：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;payment_event
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个 Topic 分布在哪些 Broker 上。&lt;/p&gt;
&lt;p&gt;RocketMQ 使用 NameServer 保存这些路由信息。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;                    NameServer
                        ↑
                        │ 注册路由信息
              ┌─────────┴─────────┐
              ↓                   ↓
          Broker A            Broker B

          Q0、Q1              Q2、Q3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;NameServer 中可以保存类似这样的信息：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Topic：payment_event

Broker A
    Q0
    Q1

Broker B
    Q2
    Q3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Producer 从 NameServer 获取这些信息以后，就知道自己可以把消息发送到：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Q0
Q1
Q2
Q3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;中的某一个 MessageQueue。&lt;/p&gt;
&lt;p&gt;需要注意，NameServer &lt;strong&gt;不负责真正转发消息&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;不是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Producer
    ↓
NameServer
    ↓
Broker
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Producer ──查询路由──→ NameServer

Producer ──发送消息──→ Broker
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以 NameServer 更接近一个：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Topic 路由注册表
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;导航系统
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;MessageQueue 的选择&lt;/h2&gt;
&lt;p&gt;Producer 获取到：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Q0
Q1
Q2
Q3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;之后，会从可用的 MessageQueue 中选择一个进行发送。&lt;/p&gt;
&lt;p&gt;普通消息可以粗略理解为尽量让消息均匀分布，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;消息A → Q0
消息B → Q1
消息C → Q2
消息D → Q3
消息E → Q0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最终一条消息正常只会进入其中一个 MessageQueue。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PAYMENT_SUCCESS
       ↓
      Q2
       ↓
   Broker B
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PAYMENT_SUCCESS

同时写入：
Q0
Q1
Q2
Q3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MessageQueue 的意义主要在于把一个 Topic 拆成多个可以并行存储和消费的分区。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;3. ConsumerGroup 的消费模型&lt;/h1&gt;
&lt;p&gt;消息写入 MessageQueue 后，就进入消费阶段。&lt;/p&gt;
&lt;p&gt;假设：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Topic = payment_event
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;支付成功以后有三个业务需要处理：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;订单服务
权限服务
通知服务
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以分别创建：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;order-group

entitlement-group

notify-group
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;于是整体关系是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;                    payment_event
                  Q0 Q1 Q2 Q3
                       │
          ┌────────────┼────────────┐
          ↓            ↓            ↓
     order-group  entitlement-group notify-group
          ↓            ↓            ↓
       更新订单       开阅读权限       发通知
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;ConsumerGroup 表示的是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;一种独立的消费业务。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;order-group
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;表示订单系统对支付消息的消费逻辑。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;entitlement-group
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;表示阅读权限系统对支付消息的消费逻辑。&lt;/p&gt;
&lt;p&gt;它们虽然消费同一个 Topic，但业务职责完全不同，因此应该属于不同的 ConsumerGroup。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;一个 MessageQueue 可以被多个 ConsumerGroup 独立消费&lt;/h2&gt;
&lt;p&gt;假设：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Q0

offset 0 → 支付1001成功
offset 1 → 支付1002成功
offset 2 → 支付1003成功
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;下面三个消费组都可以消费 Q0：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;                  Q0
                   │
        ┌──────────┼──────────┐
        ↓          ↓          ↓
   order-group entitlement-group notify-group
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;order-group
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;消费过 &lt;code&gt;支付1001成功&lt;/code&gt; 以后，不代表这条消息就被：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;entitlement-group
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;notify-group
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;拿不到了。&lt;/p&gt;
&lt;p&gt;它们三个拥有各自独立的消费进度。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;4. ConsumerGroup 的独立 Offset&lt;/h1&gt;
&lt;p&gt;RocketMQ 为不同 ConsumerGroup 分别维护消费位置。&lt;/p&gt;
&lt;p&gt;例如 Q0 中：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;offset 0 → 消息A
offset 1 → 消息B
offset 2 → 消息C
offset 3 → 消息D
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三个 ConsumerGroup 当前可能是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;order-group
Q0 offset = 4

entitlement-group
Q0 offset = 3

notify-group
Q0 offset = 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;表示：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;order-group
A、B、C、D 都已经消费

entitlement-group
A、B、C 已经消费
D 还没有处理

notify-group
只消费了 A
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以形象地理解成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;MessageQueue
= 一本书

ConsumerGroup
= 不同的读者

Offset
= 每个读者自己的书签
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;                     Q0
             A   B   C   D
                         ↑
                       order-group

                     ↑
               entitlement-group

             ↑
         notify-group
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一个 ConsumerGroup 的 Offset 推进不会影响其他 ConsumerGroup。&lt;/p&gt;
&lt;p&gt;这也是 RocketMQ 能实现：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;同一条业务事件被多个不同业务系统分别消费&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;的基础。&lt;/p&gt;
&lt;p&gt;这一点和 Kafka 的消费模型比较类似：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Kafka：

Topic
  ↓
Partition
  ↓
Offset
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RocketMQ：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Topic
  ↓
MessageQueue
  ↓
Offset
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;5. ConsumerGroup 与 Consumer 实例&lt;/h1&gt;
&lt;p&gt;ConsumerGroup 是逻辑上的消费业务，真正运行消费代码的是 Consumer 实例。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;order-group
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一开始订单服务只部署一台：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;order-group
     │
     ↓
Consumer A
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;随着业务量增加，可以部署成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;                order-group
                    │
          ┌─────────┼─────────┐
          ↓         ↓         ↓
     Consumer A Consumer B Consumer C
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A、B、C 并不是三个不同的业务消费者。&lt;/p&gt;
&lt;p&gt;它们应该：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;订阅相同的 Topic

使用相同的过滤规则

执行相同的消费逻辑
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;例如全部负责：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;消费 PAYMENT_SUCCESS
        ↓
更新订单状态
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此可以把两者理解成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ConsumerGroup
= 一种消费业务

Consumer实例
= 这种消费业务部署出来的运行实例
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;部署多个 Consumer 的目的主要是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;提高消费能力
+
水平扩容
+
故障转移
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;6. ConsumerGroup 内的 MessageQueue 分配&lt;/h1&gt;
&lt;p&gt;按照经典 RocketMQ Queue 级消费模型，假设：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;payment_event

Q0
Q1
Q2
Q3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;order-group&lt;/code&gt; 有两个 Consumer：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Consumer A
Consumer B
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RocketMQ 可以通过负载均衡进行分配：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Consumer A
├── Q0
└── Q1

Consumer B
├── Q2
└── Q3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;整体关系：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;                  payment_event

             Q0   Q1   Q2   Q3
              │    │    │    │
              └─┬──┘    └─┬──┘
                ↓           ↓
           Consumer A   Consumer B
                \           /
                 \         /
                 order-group
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里有两个重要关系：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;同一个 ConsumerGroup 内

一个 MessageQueue
同一时刻由一个 Consumer 实例负责
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是反过来：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;一个 Consumer
可以负责多个 MessageQueue
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以并不是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1 Queue = 1 Consumer
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;严格一一对应。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Queue 数量与消费实例扩容&lt;/h2&gt;
&lt;p&gt;假设：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Topic只有：

Q0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;order-group

Consumer A
Consumer B
Consumer C
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;那么经典 Queue 粒度模型下可能：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Q0 → Consumer A

Consumer B
没有Queue

Consumer C
没有Queue
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此增加 Consumer 实例并不能无限提高消费能力。&lt;/p&gt;
&lt;p&gt;如果 Topic 有：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Q0
Q1
Q2
Q3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;则可以：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Consumer A → Q0 Q1

Consumer B → Q2

Consumer C → Q3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以 MessageQueue 还有一个重要作用：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;提供 Topic 的并行消费分区。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h1&gt;7. Consumer 实例与消费线程&lt;/h1&gt;
&lt;p&gt;MessageQueue 被分配给某个 Consumer，不代表这个 Queue 只能由一个线程处理。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Q0
 ↓
Consumer A
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Consumer A 内部可以拥有消费线程池：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;                 Consumer A
                     │
          ┌──────────┼──────────┐
          ↓          ↓          ↓
       Thread1    Thread2     Thread3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;普通并发消费时，Consumer 拉取到一批消息以后，可以交给内部线程池处理：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;msg1 → Thread1

msg2 → Thread2

msg3 → Thread3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以需要把下面几个概念彻底分开：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;MessageQueue
= Topic 的消息分区

ConsumerGroup
= 一种消费业务

Consumer
= 这种业务的运行实例

Thread
= Consumer 内真正执行消费代码的线程
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不存在：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1 Queue
=
1 Consumer
=
1 Thread
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种严格关系。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;8. Tag 消息过滤&lt;/h1&gt;
&lt;p&gt;除了 Topic，RocketMQ 还可以通过 Tag 对同一个 Topic 内的消息进一步分类。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Topic = order_event
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;里面存在：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;订单创建
订单支付
订单取消
订单退款
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Producer 可以设置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;订单创建

Tag = CREATED
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;订单支付

Tag = PAID
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;订单取消

Tag = CANCELLED
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;订单退款

Tag = REFUNDED
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不同 ConsumerGroup 可以拥有不同的订阅规则。&lt;/p&gt;
&lt;p&gt;例如通知服务：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;notify-group

Topic：
order_event

Tag：
PAID
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;表示只消费：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;订单支付成功
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;审计服务：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;audit-group

Topic：
order_event

Tag：
*
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;表示所有订单事件都需要。&lt;/p&gt;
&lt;p&gt;也可以订阅多个 Tag：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PAID || REFUNDED
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;表示：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;支付成功
或者
退款成功
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;都需要消费。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;9. SQL 属性过滤&lt;/h1&gt;
&lt;p&gt;Tag 比较适合表达：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;这是什么类型的事件
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PAID
REFUNDED
CANCELLED
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果需要根据更多业务属性过滤，可以给消息添加 Properties。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Topic = order_event

Tag = PAID

Properties：

region = shanghai
amount = 1999
userType = vip
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;某个消费者只关心：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;上海
+
金额大于1000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;就可以定义类似：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;region = &apos;shanghai&apos;
AND amount &amp;gt; 1000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;的过滤条件。&lt;/p&gt;
&lt;p&gt;RocketMQ 可以在 Broker 侧根据 Tag 或 SQL 属性过滤消息。&lt;/p&gt;
&lt;p&gt;因此并不是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Consumer把整个Topic全部拉下来
        ↓
自己写大量if过滤
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而可以是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Consumer提交过滤规则
        ↓
Broker进行消息匹配
        ↓
返回符合条件的消息
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;10. 同一个 ConsumerGroup 的订阅规则&lt;/h1&gt;
&lt;p&gt;同一个 ConsumerGroup 中的 Consumer 应该保持相同的消费行为。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;order-group
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;里面：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Consumer A
Consumer B
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不应该设计成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Consumer A

Topic = payment_event
Tag = PAID

Consumer B

Topic = payment_event
Tag = REFUNDED
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为 A、B 本质上应该代表：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;同一种消费业务的不同实例。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;正确设计应该是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Consumer A

payment_event
PAID || REFUNDED

Consumer B

payment_event
PAID || REFUNDED
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A
B
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只负责分担处理压力。&lt;/p&gt;
&lt;p&gt;如果确实存在两个不同的业务职责，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;支付成功处理

退款成功处理
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;更加合理的是拆成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;payment-group

refund-group
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而不是把两个完全不同的订阅规则塞进同一个 ConsumerGroup。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;11. ConsumerGroup 与 Topic 的关系&lt;/h1&gt;
&lt;p&gt;ConsumerGroup 和 Topic 也不是严格的一一关系。&lt;/p&gt;
&lt;p&gt;最常见的情况是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;order-group
     ↓
payment_event
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以看起来像：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;一个Group订阅一个Topic
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但 ConsumerGroup 也可以订阅多个 Topic，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;order-group

├── payment_event
├── refund_event
└── order_event
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真正应该保持的是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;同一个 ConsumerGroup 内的 Consumer 实例拥有一致的订阅关系和业务行为。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;因此整体关系更准确地说是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;一个 Topic
可以被多个 ConsumerGroup 订阅

一个 ConsumerGroup
也可以订阅多个 Topic
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;12. RocketMQ 与 RabbitMQ 路由方式&lt;/h1&gt;
&lt;p&gt;RabbitMQ 和 RocketMQ 都可以实现：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;一条业务事件被多个不同业务系统处理。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;但两者的设计思想不同。&lt;/p&gt;
&lt;p&gt;RabbitMQ 主要通过：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Producer
   ↓
Exchange
   ↓
RoutingKey + Binding
   ↓
决定进入哪些 Queue
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;例如发送：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;order.created.shanghai
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RabbitMQ 的 Topic Exchange 可以匹配：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;order.*.shanghai

order.created.*

order.#
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;                     Exchange
                        │
          ┌─────────────┼─────────────┐
          ↓             ↓             ↓
       华东Queue       通知Queue      审计Queue
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说 RabbitMQ 更像：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;消息发布时先根据路由规则进行分流，再进入不同 Queue。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;RocketMQ 则主要是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Producer
   ↓
Topic
   ↓
选择一个 MessageQueue
   ↓
消息持久化
   ↓
不同 ConsumerGroup 独立订阅
   ↓
Tag / SQL过滤
   ↓
Consumer实例
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Topic = payment_event
Tag = PAYMENT_SUCCESS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;消息进入：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Q2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;order-group
entitlement-group
notify-group
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;分别拥有自己的消费进度，各自消费这条消息。&lt;/p&gt;
&lt;p&gt;因此可以把两者最核心的区别理解成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;RabbitMQ

重点：
消息发布时应该路由到哪些 Queue
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;RocketMQ

重点：
消息先按照 Topic / MessageQueue 分区存储，
再由不同 ConsumerGroup 独立订阅和过滤消费
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;RocketMQ 路由模型总结&lt;/h1&gt;
&lt;p&gt;整个 RocketMQ 普通消息模型可以压缩成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Producer
   ↓
通过 NameServer 获取 Topic 路由
   ↓
从 Topic 的 MessageQueue 中选择一个
   ↓
直接发送给对应 Broker
   ↓
Broker 持久化消息
   ↓
多个 ConsumerGroup 可以独立消费
   ↓
每个 Group 拥有自己的 Offset
   ↓
同一个 Group 内多个 Consumer 做负载均衡
   ↓
Consumer 内部通过消费线程执行具体业务
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中几个核心概念的职责是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Topic
→ 消息的大分类

MessageQueue
→ Topic 内部的消息分区

NameServer
→ 保存 Topic、Broker、Queue 路由关系

ConsumerGroup
→ 一种独立的消费业务

Consumer
→ 消费业务的运行实例

Tag / SQL
→ 从 Topic 中筛选需要消费的消息

Offset
→ 某个 ConsumerGroup 在某个 Queue 中消费到的位置
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此 RocketMQ 的路由不能只理解成“Producer 怎么找到 Queue”，而应该理解成：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Producer 完成存储路由，ConsumerGroup 完成业务订阅，Tag/SQL 完成消息过滤，Group 内负载均衡完成最终 Consumer 实例的分配。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>RocketMQ 高频面试题</title><link>https://blog.huangnv.online/posts/2688/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2688/1/1/</guid><pubDate>Sat, 08 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;消息队列八股 RocketMQ&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;范围：高频消息队列与 RocketMQ 面试题，重点覆盖支付场景中的事务消息、延迟消息和消息可靠性。&lt;/p&gt;
&lt;p&gt;版本提示：涉及 RocketMQ 延迟消息、负载均衡等问题时，先说明项目使用的是 4.x 还是 5.x，避免混用不同版本的机制。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;通用消息队列&lt;/h2&gt;
&lt;h3&gt;1. 为什么使用消息队列？有什么缺点？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;使用 MQ 的核心价值是异步、解耦和削峰填谷。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;异步：订单创建后，短信、积分、通知等非核心操作交由消费者处理，主链路不用同步等待，降低接口 RT。&lt;/li&gt;
&lt;li&gt;解耦：生产者只发布业务事件，不直接依赖所有下游系统；后续增加积分、搜索、数据分析消费者时，订单服务通常不用改动。&lt;/li&gt;
&lt;li&gt;削峰填谷：流量高峰时请求先进入 MQ，消费者按数据库和下游可承受的速度逐步处理。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;代价是系统复杂度提高，要面对消息丢失、重复、乱序、积压、最终一致性和 MQ 故障等问题。因此要配套消息轨迹、消费位点、重试、死信、限流和监控。&lt;/p&gt;
&lt;h3&gt;2. RocketMQ、Kafka、RabbitMQ 怎么选？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;异步、削峰、解耦、局部顺序、消费重试、消费组和消息回放等能力在三种 MQ 中都有不同实现，区分度有限。选型时应说明各自原生模型中最有区分度、使用其他中间件需要额外开发才能达到的能力。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;中间件&lt;/th&gt;
&lt;th&gt;最有区分度的能力&lt;/th&gt;
&lt;th&gt;最适合的场景&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RocketMQ&lt;/td&gt;
&lt;td&gt;半消息、本地事务和 Broker 事务回查&lt;/td&gt;
&lt;td&gt;订单、支付、库存等需要保证本地事务与消息最终一致的业务&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kafka&lt;/td&gt;
&lt;td&gt;Log Compaction、Kafka Connect、Kafka Streams 组成的事件流平台&lt;/td&gt;
&lt;td&gt;CDC 数据同步、埋点分析、实时计算、状态流和数据管道&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RabbitMQ&lt;/td&gt;
&lt;td&gt;Exchange、Binding、Routing Key 组成的 Broker 路由拓扑&lt;/td&gt;
&lt;td&gt;消息需要按照多个业务维度动态分发到不同队列&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4&gt;RocketMQ：事务消息回查&lt;/h4&gt;
&lt;p&gt;例如支付服务需要同时修改订单状态并发送支付成功消息：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. Producer 先发送半消息，消费者暂时不可见
2. Broker 保存成功后，支付服务执行本地数据库事务
3. 本地事务成功则 Commit，失败则 Rollback
4. Broker 没有收到最终结果时，主动回查 Producer
5. Producer 查询订单或支付表，返回 Commit、Rollback 或 Unknown
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它解决的典型异常是订单已经修改为已支付，但服务在发送普通消息前宕机，导致库存、积分和通知服务收不到支付事件。最有区分度的能力是 Broker 能主动回查生产者的本地事务状态。&lt;/p&gt;
&lt;p&gt;Kafka 的事务主要协调 Kafka 内部的消息、Partition 和消费位点；RabbitMQ 的 Publisher Confirm 主要确认消息是否到达 Broker。若要协调 MySQL 本地事务，通常还要使用 Outbox 等额外方案。&lt;/p&gt;
&lt;h4&gt;Kafka：完整的事件流平台&lt;/h4&gt;
&lt;p&gt;例如把 MySQL 用户表的变更持续同步出去：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;MySQL CDC
    -&amp;gt; Kafka 用户变更 Topic
        -&amp;gt; Elasticsearch
        -&amp;gt; 数据仓库
        -&amp;gt; 风控系统
        -&amp;gt; 推荐系统
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Kafka 可以使用以 &lt;code&gt;userId&lt;/code&gt; 为 Key 的 Compacted Topic。假设同一个用户连续修改三次邮箱：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1001 -&amp;gt; email=a@example.com
1001 -&amp;gt; email=b@example.com
1001 -&amp;gt; email=c@example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Log Compaction 保证日志中至少保留 Key &lt;code&gt;1001&lt;/code&gt; 的最新状态。新启动的下游服务可以读取 Topic，重建用户状态或缓存。Kafka Connect 提供数据源和目标端之间的标准化导入导出框架；Kafka Streams 可以完成窗口统计、聚合、Join、状态存储和事件时间计算。&lt;/p&gt;
&lt;p&gt;因此 Kafka 更适合数据库变更订阅、用户行为埋点、Flink 或 Kafka Streams 实时计算、数据湖和数据仓库管道。RocketMQ 也能保存和回放消息，Kafka 的差异主要体现在围绕状态日志、数据集成和流计算形成了更完整的原生生态。&lt;/p&gt;
&lt;h4&gt;RabbitMQ：Broker 路由拓扑&lt;/h4&gt;
&lt;p&gt;例如订单消息的 Routing Key 是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;order.paid.shanghai.vip
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RabbitMQ 可以配置多个 Binding：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;order.paid.#          -&amp;gt; 履约队列
order.*.shanghai.*    -&amp;gt; 上海业务队列
order.*.*.vip         -&amp;gt; VIP 服务队列
order.#               -&amp;gt; 审计队列
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;生产者只向 Exchange 发送一次消息，RabbitMQ 根据 Binding 规则决定消息进入哪些队列。新增华东数据分析队列时，通常只需要增加 Binding，无需修改生产者代码。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Direct Exchange：根据 Routing Key 精确匹配。&lt;/li&gt;
&lt;li&gt;Topic Exchange：通过通配符匹配多个业务维度。&lt;/li&gt;
&lt;li&gt;Fanout Exchange：将消息广播到所有绑定目标。&lt;/li&gt;
&lt;li&gt;Headers Exchange：根据消息头属性匹配。&lt;/li&gt;
&lt;li&gt;Exchange-to-Exchange Binding：组合多层路由拓扑。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;面试总结：RocketMQ 最有区分度的是半消息和事务回查，适合本地事务与消息需要最终一致的业务；Kafka 最有区分度的是 Log Compaction、Kafka Connect 和 Kafka Streams 组成的事件流平台，适合 CDC、埋点和实时计算；RabbitMQ 最有区分度的是 Exchange 与 Binding 路由拓扑，适合消息按多个业务维度动态分发。&lt;/p&gt;
&lt;h3&gt;3. 怎么保证消息尽量不丢失？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;要按生产者、Broker、消费者和业务兜底的完整链路回答。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;生产者：使用同步发送并检查结果；发送超时做有限重试；结果不确定时查询或补偿；数据库修改和发消息有原子性要求时，使用事务消息或 Outbox 本地消息表。&lt;/li&gt;
&lt;li&gt;Broker：消息持久化写入 CommitLog；核心消息根据要求选择同步刷盘和更强副本确认；监控磁盘、刷盘延迟和副本同步。&lt;/li&gt;
&lt;li&gt;消费者：本地业务事务成功后才返回消费成功；失败则返回失败让 Broker 重试；超过重试上限进入死信队列。&lt;/li&gt;
&lt;li&gt;业务兜底：使用订单号、支付单号、事件 ID 做幂等；对账、补偿任务和人工异常处理覆盖极端故障。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;结论：消息可靠性由生产、存储、消费和补偿共同保证，单靠“生产者重试”无法覆盖全链路。&lt;/p&gt;
&lt;h3&gt;4. 为什么会重复消费？如何保证幂等？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;常见重复原因包括：Broker 已收到消息但生产者未收到响应后重发；消费者本地业务成功但 ACK 前宕机；消费超时、网络异常、重平衡；人工重置位点或消息回放。&lt;/p&gt;
&lt;p&gt;MQ 通常按至少一次投递设计，因此应让消费者做到业务幂等：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用订单号、支付流水号、业务事件 ID 作为唯一业务键；&lt;/li&gt;
&lt;li&gt;数据库建立唯一索引；&lt;/li&gt;
&lt;li&gt;通过状态机和条件更新限制状态迁移；&lt;/li&gt;
&lt;li&gt;将幂等记录和业务修改放进同一个本地事务；&lt;/li&gt;
&lt;li&gt;Redis 去重可作辅助，不应替代数据库最终约束。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;UPDATE payment_order
SET status = &apos;SUCCESS&apos;
WHERE payment_no = ?
  AND status = &apos;WAIT_PAY&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;影响行数为 1 才表示本次状态迁移成功；为 0 时按当前状态做幂等返回或异常补偿。&lt;/p&gt;
&lt;h3&gt;5. 怎么保证消息顺序？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;先区分全局顺序和局部顺序。全局顺序要求所有消息进入同一队列并由单线程消费，吞吐量较低；业务常见需求是按订单、用户等业务 Key 保证局部顺序。&lt;/p&gt;
&lt;p&gt;实现方式：相同业务 Key 始终路由到同一个 MessageQueue 或 MessageGroup；生产端按业务顺序发送；消费端使用顺序消费，同一队列不要再异步分发给无序线程池；前一条失败时不能让后一条越过执行。&lt;/p&gt;
&lt;p&gt;RocketMQ 4.x 通常用 ShardingKey 将同一业务 Key 路由到同一个队列；5.x 使用 MessageGroup 表达顺序关系。不同业务 Key 可以分散到不同队列并行处理。&lt;/p&gt;
&lt;h3&gt;6. 消息积压怎么排查和处理？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;排查时先确定 Lag 是持续增长还是短暂尖峰，判断是生产速度提升还是消费速度下降；再定位到具体 Topic、ConsumerGroup 和 MessageQueue，确认是否存在热点队列。&lt;/p&gt;
&lt;p&gt;重点检查：消费者是否阻塞在数据库、RPC、锁、文件 IO 或第三方接口；消费线程池、批量大小和消费超时时间是否合理。&lt;/p&gt;
&lt;h4&gt;检查异常消息和重试风暴&lt;/h4&gt;
&lt;p&gt;消费者处理消息失败后，会把失败结果返回给 Broker，Broker 间隔一段时间重新投递。异常消息分为两类：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;临时异常&lt;/strong&gt;：数据库超时、下游服务限流、网络抖动等，过一段时间可能恢复，适合延迟重试。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;永久异常&lt;/strong&gt;：消息格式错误、字段缺失、数据不合法、代码反序列化失败等，重复执行仍然失败，这类消息也叫毒消息。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果数据库已经不可用，大量消息消费失败后不断重试，消费者就会同时处理新消息和多轮重试消息，访问下游的请求量被不断放大：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;数据库异常
-&amp;gt; 消息消费失败
-&amp;gt; 大量消息重新投递
-&amp;gt; 消费者再次访问数据库
-&amp;gt; 数据库压力进一步升高
-&amp;gt; 更多消息失败
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这就是重试风暴。顺序消费时，一条毒消息还可能阻塞同一队列后面的所有消息。&lt;/p&gt;
&lt;p&gt;排查时重点观察消费失败率、重试次数、死信消息数量，以及同一个消息 ID 或业务 ID 是否反复出现。同时检查数据库、RPC 和消费者日志，判断错误能否通过重试恢复。&lt;/p&gt;
&lt;p&gt;临时异常应采用延迟重试和退避策略，例如分别在 5 秒、30 秒、1 分钟后重试，避免立即连续请求下游。永久异常应尽快进入死信队列，告警后由人工或补偿任务处理。下游整体故障时，还需要降低消费并发、限流或暂停部分消费，恢复后逐步提高速度。消费者必须通过唯一索引、状态机或条件更新保证幂等，避免重试造成重复扣款、扣库存或发券。&lt;/p&gt;
&lt;p&gt;处理顺序是：先修复消费者和下游瓶颈，再扩容消费者实例。扩容时要注意经典队列粒度负载均衡的并行能力受 MessageQueue 数量限制；必要时增加队列并调整业务分片。还可以批量消费、批量写库、拆分耗时操作、隔离毒消息到死信队列。严重积压时可使用临时 Topic 和临时消费者转储、分流。&lt;/p&gt;
&lt;h3&gt;7. 数据库已提交，但 MQ 消息还没发出去怎么办？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;普通顺序写法存在窗口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;数据库事务提交
-&amp;gt; 准备发送 MQ
-&amp;gt; 服务宕机
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;此时数据库成功但下游永远收不到事件。常用方案有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;RocketMQ 事务消息：实时性较好，需要实现本地事务和事务回查；&lt;/li&gt;
&lt;li&gt;Outbox 本地消息表：业务数据和待发消息在同一数据库事务提交，由后台任务或 CDC 可靠投递；&lt;/li&gt;
&lt;li&gt;Binlog / CDC：业务侵入较低，需要已有数据同步基础设施；&lt;/li&gt;
&lt;li&gt;补偿扫描：作为异常兜底，不能替代前面的可靠投递方案。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;8. 重试队列和死信队列是什么？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;消费者处理失败后，Broker 按策略重新投递消息；超过最大重试次数后，消息进入死信队列。重试适合偶发故障，不应用于正常业务分支或限流。永久性错误应尽快识别，否则会形成重试风暴。&lt;/p&gt;
&lt;p&gt;死信消息需要有监控、告警、查询、人工处理和补偿流程，不能直接丢弃。&lt;/p&gt;
&lt;h3&gt;9. At-most-once、At-least-once、Exactly-once 是什么？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;At-most-once：最多一次，可能丢失，一般不重复。&lt;/li&gt;
&lt;li&gt;At-least-once：至少一次，尽量不丢失，但可能重复。&lt;/li&gt;
&lt;li&gt;Exactly-once：业务效果恰好一次。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;RocketMQ 面试中应回答为：以至少一次投递为主，通过唯一键、状态机、条件更新和本地事务实现业务效果上的恰好一次。不要声称 MQ 天然保证所有业务 Exactly-once。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;RocketMQ 核心原理&lt;/h2&gt;
&lt;h3&gt;10. RocketMQ 有哪些核心组件？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;NameServer：保存 Topic 路由信息，节点之间基本无状态、不做路由同步。&lt;/li&gt;
&lt;li&gt;Broker：接收、存储、查询和投递消息。&lt;/li&gt;
&lt;li&gt;Producer：从 NameServer 获取路由，选择 MessageQueue，向 Broker 发送消息。&lt;/li&gt;
&lt;li&gt;Consumer：订阅 Topic、参与队列分配并从 Broker 消费消息。&lt;/li&gt;
&lt;li&gt;Proxy：RocketMQ 5.x 可使用的无状态接入层，可与 Broker 同进程或独立部署。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Broker 会向所有 NameServer 注册路由；Producer 和 Consumer 获取路由后直接与 Broker 通信。&lt;/p&gt;
&lt;h3&gt;11. RocketMQ 消息发送流程是什么？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Producer 启动
-&amp;gt; 从 NameServer 获取 Topic 路由
-&amp;gt; 选择 MessageQueue
-&amp;gt; 向对应 Broker 发送消息
-&amp;gt; Broker 写入 CommitLog
-&amp;gt; 按同步或异步刷盘策略持久化
-&amp;gt; 异步构建 ConsumeQueue 和 IndexFile
-&amp;gt; 返回发送结果
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同步发送通常等待 Broker 返回结果；异步发送由回调处理结果；单向发送不等待结果，适合对可靠性要求较低的日志等场景。&lt;/p&gt;
&lt;h3&gt;12. CommitLog、ConsumeQueue、IndexFile 分别是什么？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CommitLog：真正保存消息主体和元数据。一个 Broker 上多个 Topic、多个 MessageQueue 的消息统一追加写入 CommitLog。&lt;/li&gt;
&lt;li&gt;ConsumeQueue：逻辑消费队列，是 CommitLog 的轻量索引，通常保存 CommitLog 物理偏移、消息长度、Tag 哈希值等。消费者先读取 ConsumeQueue，再按偏移读取 CommitLog 正文。&lt;/li&gt;
&lt;li&gt;IndexFile：按消息 Key 和时间范围查询消息的索引，可用于按订单号、支付单号排查消息。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;总结：CommitLog 存数据，ConsumeQueue 提供消费索引，IndexFile 提供按 Key 的查询索引。&lt;/p&gt;
&lt;h3&gt;13. RocketMQ 为什么性能高？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;主要原因包括：CommitLog 统一顺序追加写，避免大量随机写；利用操作系统 PageCache；通过 Mmap 内存映射提高文件访问效率；ConsumeQueue 是体积较小的轻量索引，消费者不需要扫描完整 CommitLog；网络通信基于 Netty 和 Reactor 模型。&lt;/p&gt;
&lt;p&gt;异步刷盘时，消息写入 PageCache 后即可返回，也会提高吞吐量，但会扩大极端故障下尚未落盘消息的风险窗口。&lt;/p&gt;
&lt;h3&gt;14. 同步刷盘和异步刷盘有什么区别？同步刷盘和同步复制是一回事吗？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;同步刷盘：Broker 等待消息落到本机磁盘后再返回发送成功，可靠性更高，发送延迟和吞吐代价更大。&lt;/p&gt;
&lt;p&gt;异步刷盘：写入 PageCache 后即可返回，后台批量刷盘，吞吐更高；机器断电或操作系统崩溃时，尚未落盘消息可能丢失。&lt;/p&gt;
&lt;p&gt;同步刷盘和同步复制是两个维度：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;同步刷盘：主 Broker 是否等待本机磁盘落盘
同步复制：主 Broker 是否等待副本节点完成同步
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;支付等核心消息可根据可靠性和延迟指标选择更严格的刷盘与副本确认策略，不能仅凭 &lt;code&gt;SEND_OK&lt;/code&gt; 判断消息在所有故障场景下都绝对可靠。&lt;/p&gt;
&lt;h3&gt;15. RocketMQ 如何保证高可用？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;经典 Master-Slave 架构中，Master 负责写入，Slave 同步数据；异步复制性能更高，极端主节点故障下可能丢少量未同步数据；同步双写需要主从均确认，可靠性更高。&lt;/p&gt;
&lt;p&gt;自动故障切换可使用 DLedger，通过 Raft 完成 Leader 选举和日志复制。RocketMQ 5.x 也提供 Controller 模式，用于主节点选举和主备切换。具体架构取决于部署版本和高可用方案。&lt;/p&gt;
&lt;h3&gt;16. RocketMQ 的 Push 是真正的服务端推送吗？Consumer 如何负载均衡？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;PushConsumer 对业务代码表现为监听器回调，本质仍是客户端 SDK 的长轮询拉取：SDK 拉取消息，放入本地缓存，再提交给消费线程执行。&lt;/p&gt;
&lt;p&gt;经典 RocketMQ 4.x 主要按 MessageQueue 分配负载：一个 Topic 有 8 个队列、一个 ConsumerGroup 有 4 个消费者时，通常每个消费者分配 2 个队列。消费者上线、下线和订阅关系变化会触发 Rebalance。消费者数量超过队列数量时，多余消费者可能无法分到队列。&lt;/p&gt;
&lt;h3&gt;17. 集群消费、广播消费、Topic、MessageQueue、Tag 分别是什么？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;集群消费：同一个 ConsumerGroup 共同分担消息，一条消息只被组内一个消费者处理。&lt;/li&gt;
&lt;li&gt;广播消费：组内每个消费者都处理全部消息。&lt;/li&gt;
&lt;li&gt;Topic：一级业务分类，例如订单消息。&lt;/li&gt;
&lt;li&gt;MessageQueue：Topic 内部并行存储和消费单元，类似分区。&lt;/li&gt;
&lt;li&gt;Tag：Topic 内二级分类，例如订单创建、支付、取消。&lt;/li&gt;
&lt;li&gt;Key：业务查询和定位消息时使用，例如订单号。&lt;/li&gt;
&lt;li&gt;ConsumerGroup：具有相同消费逻辑的一组消费者。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;经典 RocketMQ 中，消费失败后的自动重试主要面向集群消费；广播消费的失败处理需要按版本和客户端模型单独设计。&lt;/p&gt;
&lt;h3&gt;18. NameServer 为什么不使用 ZooKeeper？&lt;/h3&gt;
&lt;hr /&gt;
&lt;h2&gt;支付场景：事务消息&lt;/h2&gt;
&lt;h3&gt;19. 为什么支付链路需要事务消息？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;支付回调成功后，支付服务通常要在本地事务中更新支付单状态、写支付流水，并向下游发布支付成功事件。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;第三方回调
-&amp;gt; 验签、校验金额、接口幂等
-&amp;gt; 更新支付单和支付流水
-&amp;gt; 发布支付成功事件
-&amp;gt; 订单、积分、履约、短信等下游处理
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;普通消息会产生双写窗口：先提交数据库再发消息，服务宕机时下游收不到事件；先发消息再提交数据库，消息可能已被消费而本地事务回滚。RocketMQ 事务消息解决的是&lt;strong&gt;生产者本地事务与消息发送的最终一致性&lt;/strong&gt;，它不覆盖消费者事务和第三方支付渠道。&lt;/p&gt;
&lt;h3&gt;20. RocketMQ 事务消息完整流程是什么？为什么要先发送半事务消息？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Producer 发送半事务消息
-&amp;gt; Broker 持久化，消息对 Consumer 不可见
-&amp;gt; Producer 执行本地数据库事务
-&amp;gt; 成功：Commit
-&amp;gt; 失败：Rollback
-&amp;gt; 状态未知或二次确认丢失：Broker 发起事务回查
-&amp;gt; Producer 根据本地持久化状态返回 Commit / Rollback / Unknown
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先发半事务消息的原因是先确保 Broker 已经持有一条可回查的消息记录。若先提交数据库，随后发送消息前服务宕机，双写不一致窗口仍然存在。半事务消息发送失败时不执行本地事务；本地事务成功但 Commit 通知丢失时，Broker 可通过回查恢复最终状态。&lt;/p&gt;
&lt;h3&gt;21. 事务回查查什么？事务消息能保证只消费一次吗？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;回查应按业务唯一标识查持久化数据，例如支付单号、第三方支付流水号或业务事务号：支付单状态为 &lt;code&gt;SUCCESS&lt;/code&gt; 则 Commit；不存在或明确失败则 Rollback；数据库暂时不可用、状态无法确定时返回 Unknown，等待后续回查。&lt;/p&gt;
&lt;p&gt;事务消息不能保证消费者只收到一次。它只解决生产端本地事务与消息发送的一致性；发送重试、消费成功后 ACK 丢失、重平衡仍可产生重复。因此消费者仍需通过唯一索引、状态机、条件更新和本地事务保证幂等。&lt;/p&gt;
&lt;h3&gt;22. 事务消息和本地消息表怎么选择？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;th&gt;优点&lt;/th&gt;
&lt;th&gt;代价&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RocketMQ 事务消息&lt;/td&gt;
&lt;td&gt;实时性较好，无需自己扫描消息表&lt;/td&gt;
&lt;td&gt;需要实现本地事务执行器和事务回查，与 RocketMQ 耦合更深&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outbox 本地消息表&lt;/td&gt;
&lt;td&gt;业务数据和消息记录在同一数据库事务提交，可审计、可补偿&lt;/td&gt;
&lt;td&gt;需要消息表、扫描或 CDC、状态管理&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;两种方案都能用于支付核心链路。面试重点是说明一致性窗口、失败补偿和消费幂等，不能只回答“使用事务消息”。&lt;/p&gt;
&lt;h3&gt;23. 事务消息能解决第三方支付成功但回调未到达吗？支付回调重复怎么办？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;事务消息只覆盖本地数据库事务和 RocketMQ 消息发送，第三方支付渠道不在这个原子范围内。回调丢失仍需主动查单、定时对账、差错处理和补偿。&lt;/p&gt;
&lt;p&gt;支付回调本身也必须幂等：验签；校验订单号、金额和商户身份；以第三方支付流水号建立唯一约束；已成功则直接返回成功；首次成功时才进入本地事务和事务消息流程。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;支付场景：延迟消息&lt;/h2&gt;
&lt;h3&gt;24. 订单超时自动关闭怎么实现？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;订单创建成功后发送“订单超时检查”延迟消息，消息携带订单号和到期时间。到期后消费者查询订单状态，只有订单仍为 &lt;code&gt;WAIT_PAY&lt;/code&gt; 才通过条件更新关闭订单并释放预占资源；已支付则幂等返回。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UPDATE orders
SET status = &apos;CANCELLED&apos;
WHERE order_no = ?
  AND status = &apos;WAIT_PAY&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;延迟消息只负责到期触发检查，不能收到消息后直接无条件关闭订单。&lt;/p&gt;
&lt;h3&gt;25. 延迟消息应该何时发送？发送失败怎么兜底？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;订单创建成功后需要可靠地创建超时任务。若订单数据库提交后才准备发送延迟消息，服务在发送前宕机，订单可能永远不会自动关闭。&lt;/p&gt;
&lt;p&gt;可选方案：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;订单事务同时写入订单记录和待发送延迟任务的 Outbox 记录，由后台任务可靠发送。&lt;/li&gt;
&lt;li&gt;使用两段式：订单服务发送 &lt;code&gt;ORDER_CREATED&lt;/code&gt; 事务消息；调度消费者收到后再发送独立的 &lt;code&gt;ORDER_TIMEOUT_CHECK&lt;/code&gt; 延迟消息，延迟消息成功发送后才确认前一条消息。&lt;/li&gt;
&lt;li&gt;低频数据库扫描 &lt;code&gt;WAIT_PAY&lt;/code&gt; 且已过期订单，作为极端故障的最终兜底，不能作为主流程高频全表扫描。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;26. 同一条消息可以既是事务消息又是延迟消息吗？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;RocketMQ 5.x 的事务消息和定时/延迟消息属于不同消息类型，一个 Topic 只支持一种消息类型。工程上应拆为两段：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ORDER_CREATED_TRANSACTION_TOPIC
-&amp;gt; 调度服务
-&amp;gt; ORDER_TIMEOUT_DELAY_TOPIC
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;项目中不要笼统说“发送了一条延迟事务消息”，先说明使用版本和两段处理方式。&lt;/p&gt;
&lt;h3&gt;27. 支付回调和超时关单同时发生怎么办？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;支付回调和延迟消费者都对订单状态做条件更新：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UPDATE orders SET status = &apos;PAID&apos;
WHERE order_no = ? AND status = &apos;WAIT_PAY&apos;;

UPDATE orders SET status = &apos;CANCELLED&apos;
WHERE order_no = ? AND status = &apos;WAIT_PAY&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只有一个更新能成功。支付成功时关单消费者发现已支付后退出；关单成功后支付回调进入异常支付处理，如查单、退款或人工差错流程。核心是状态机、数据库条件更新和对账补偿。&lt;/p&gt;
&lt;h3&gt;28. RocketMQ 4.x 和 5.x 延迟消息有什么区别？4.x 底层如何实现？&lt;/h3&gt;
&lt;h3&gt;29. 延迟消息能精确到某一毫秒吗？延迟消息丢失怎么办？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;延迟消息适合订单超时等允许秒级误差的业务，不属于硬实时定时器。实际消费时间还受 Broker 调度负载、大量消息同刻到期、消费者积压、网络和消费耗时影响。&lt;/p&gt;
&lt;p&gt;可靠性需要生产端可靠创建、Broker 持久化和副本保护、消费失败重试与死信、关单接口幂等，以及低频数据库扫描兜底共同保障。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;支付项目口述示例&lt;/h2&gt;
&lt;p&gt;我的支付链路主要使用 RocketMQ 的事务消息和延迟消息。支付回调后，支付服务先发送半事务消息，再在本地事务中更新支付单状态和支付流水；本地事务成功则 Commit，二次确认丢失时由 Broker 回查支付单的持久化状态。事务消息保证本地事务和支付成功事件最终一致，消费者仍通过支付单号、第三方流水号唯一索引和状态机保证幂等。&lt;/p&gt;
&lt;p&gt;订单创建后会可靠生成一条独立的超时检查延迟消息。消息到期后先查订单，只有仍处于待支付状态时才执行条件更新关单；支付与关单并发时由状态机和条件更新决定唯一成功迁移，异常情况通过查单、对账和补偿处理。&lt;/p&gt;
&lt;p&gt;RocketMQ 在 Broker 侧将消息顺序写入 CommitLog，通过 ConsumeQueue 提供消费索引。核心消息根据可靠性目标选择刷盘和副本策略；消费端在业务成功后确认，结合重试、死信、幂等和对账，形成完整可靠性保障。&lt;/p&gt;
</content:encoded></item><item><title>美团后端面试复盘（三）</title><link>https://blog.huangnv.online/posts/2688/2/2/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2688/2/2/</guid><pubDate>Sat, 08 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;美团 3&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;类型：Java 后端面试问题整理&lt;/p&gt;
&lt;p&gt;已排除项目介绍、MySQL 索引、秒杀系统三题。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;面试问题&lt;/h2&gt;
&lt;h3&gt;Java 并发&lt;/h3&gt;
&lt;h2&gt;2. 说下你对 Java 锁的理解？&lt;code&gt;synchronized&lt;/code&gt; 和 &lt;code&gt;ReentrantLock&lt;/code&gt; 的区别是什么？AQS 的原理是什么？&lt;/h2&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;锁用于保证多线程访问共享资源时的互斥性和可见性。常见分类包括悲观锁和乐观锁、公平锁和非公平锁、可重入锁、独占锁和共享锁。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;synchronized&lt;/code&gt; 是 JVM 提供的关键字，底层通过对象监视器 Monitor 实现。修饰实例方法时锁当前对象，修饰静态方法时锁 Class 对象，修饰代码块时锁指定对象。它是可重入锁，退出同步块时由 JVM 自动释放。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ReentrantLock&lt;/code&gt; 是 JUC 提供的显式锁，底层基于 AQS。它同样可重入，额外支持：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;tryLock()&lt;/code&gt; 尝试获取锁；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tryLock(timeout)&lt;/code&gt; 超时等待；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lockInterruptibly()&lt;/code&gt; 等锁时响应中断；&lt;/li&gt;
&lt;li&gt;公平锁和非公平锁；&lt;/li&gt;
&lt;li&gt;多个 &lt;code&gt;Condition&lt;/code&gt; 条件队列。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    // 临界区
} finally {
    lock.unlock();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;日常简单同步优先使用 &lt;code&gt;synchronized&lt;/code&gt;，代码短且不易遗漏释放锁；需要超时、中断、公平性或多个条件队列时使用 &lt;code&gt;ReentrantLock&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;AQS 原理&lt;/strong&gt;：AQS 是 JUC 中锁和同步器的基础框架。它维护一个 &lt;code&gt;volatile int state&lt;/code&gt; 和一个 FIFO 等待队列。线程先通过 CAS 尝试修改 &lt;code&gt;state&lt;/code&gt; 获取同步状态；失败后封装为节点进入等待队列，并使用 &lt;code&gt;LockSupport.park()&lt;/code&gt; 挂起。持锁线程释放时更新 &lt;code&gt;state&lt;/code&gt;，再 &lt;code&gt;unpark()&lt;/code&gt; 唤醒后继节点继续竞争。&lt;/p&gt;
&lt;p&gt;以 &lt;code&gt;ReentrantLock&lt;/code&gt; 为例，&lt;code&gt;state = 0&lt;/code&gt; 表示未加锁；首次获得锁后为 &lt;code&gt;1&lt;/code&gt;；同一线程重入时递增。释放时递减到 &lt;code&gt;0&lt;/code&gt; 才真正释放，这就是可重入的基础。&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;JVM 与垃圾回收&lt;/h3&gt;
&lt;h2&gt;3. 说下你对 JVM 的理解？JDK 7 到 JDK 8 有哪些重要变化？为什么？&lt;/h2&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;JVM 是 Java 字节码的运行环境，主要包括类加载子系统、运行时数据区、执行引擎、垃圾收集器和本地方法接口。Java 代码编译为 &lt;code&gt;.class&lt;/code&gt; 后，由类加载器加载，再由解释器和 JIT 编译器执行。&lt;/p&gt;
&lt;p&gt;运行时数据区主要包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;堆：保存对象实例，是 GC 的主要区域；&lt;/li&gt;
&lt;li&gt;虚拟机栈：每个线程独有，保存栈帧、局部变量表和操作数栈；&lt;/li&gt;
&lt;li&gt;本地方法栈：为 Native 方法服务；&lt;/li&gt;
&lt;li&gt;程序计数器：记录当前线程执行的字节码位置；&lt;/li&gt;
&lt;li&gt;方法区：存放类元数据、常量池、方法信息等概念性数据。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;JDK 7 到 JDK 8 的内存变化，面试中重点回答永久代到元空间的演进。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;JDK 8：永久代被元空间替代&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;JDK 7                         JDK 8

Heap                          Heap
├── Young                     ├── Young
├── Old                       └── Old
└── PermGen

                              Native Memory
                              └── Metaspace
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;元空间主要保存类元数据，使用本地内存，默认可以按类加载情况增长。生产环境仍可通过 &lt;code&gt;-XX:MaxMetaspaceSize&lt;/code&gt; 设置上限，防止无边界占用本地内存。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;为什么要删除永久代&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;永久代容量难以预估。动态代理、反射、CGLIB、热部署或频繁类加载场景中，永久代容易出现 &lt;code&gt;OutOfMemoryError: PermGen space&lt;/code&gt;。元空间把类元数据迁到本地内存，减少了固定永久代大小带来的配置困难。&lt;/p&gt;
&lt;p&gt;元空间也需要监控。应用发生 ClassLoader 泄漏时，已加载类无法卸载，最终仍可能出现 &lt;code&gt;OutOfMemoryError: Metaspace&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;补充：G1 在 JDK 7 已出现，JDK 8 的默认收集器仍取决于具体发行版和配置；JDK 9 才将 G1 设为 HotSpot 默认收集器。&lt;/p&gt;
&lt;h2&gt;4. 垃圾回收器有哪些？各有什么特点？G1 的工作原理是什么？怎么调整老年代阈值？&lt;/h2&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;常见收集器可以这样回答：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Serial&lt;/strong&gt;：单线程收集，停顿时会 Stop The World，适合小堆、客户端或资源受限场景。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parallel Scavenge / Parallel Old&lt;/strong&gt;：强调吞吐量，适合批处理、离线计算。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CMS&lt;/strong&gt;：老年代并发标记清除，目标是缩短停顿；会产生内存碎片和浮动垃圾，已经在较新 JDK 中移除。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;G1&lt;/strong&gt;：面向服务端大堆和可预测停顿，按 Region 管理堆，优先回收垃圾收益高的 Region。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;G1 将堆划分为一批大小相等的 Region。Region 的角色可以动态为 Eden、Survivor、Old 或 Humongous，不要求年轻代和老年代在物理上连续。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[E][E][S][O][O][E][H][O][E][S]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;G1 的并发标记周期大致是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Initial Mark(STW)
  -&amp;gt; Root Region Scan
  -&amp;gt; Concurrent Mark
  -&amp;gt; Remark(STW)
  -&amp;gt; Cleanup
  -&amp;gt; Mixed GC
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mixed GC 会回收年轻代 Region，并按预估回收收益选择一部分老年代 Region。&lt;code&gt;-XX:MaxGCPauseMillis&lt;/code&gt; 是停顿时间目标，G1 会据此控制每次选择回收的 Region 数量，它是目标值而非硬性保证。&lt;/p&gt;
&lt;p&gt;“老年代阈值”需要先确认具体含义：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果指对象经过多少次 Young GC 晋升，关注 &lt;code&gt;-XX:MaxTenuringThreshold&lt;/code&gt;；Survivor 空间不足时也可能提前晋升。&lt;/li&gt;
&lt;li&gt;如果指什么时候启动 G1 并发标记周期，关注 &lt;code&gt;-XX:InitiatingHeapOccupancyPercent&lt;/code&gt;，它按堆占用情况触发并发标记。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h3&gt;Java 集合&lt;/h3&gt;
&lt;h2&gt;5. 说下你对 Java 集合的理解？HashMap、HashSet、LinkedHashMap 的底层实现？ConcurrentHashMap 从 JDK 7 到 JDK 8 有哪些改进？&lt;/h2&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Java 集合可分为 &lt;code&gt;Collection&lt;/code&gt; 和 &lt;code&gt;Map&lt;/code&gt; 两大体系。&lt;code&gt;Collection&lt;/code&gt; 下有 List、Set、Queue；&lt;code&gt;Map&lt;/code&gt; 保存 Key-Value。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;HashMap&lt;/strong&gt; 在 JDK 8 中是“数组 + 链表 + 红黑树”。通过 Key 的哈希值计算桶下标：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(n - 1) &amp;amp; hash
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;哈希冲突的元素位于同一个桶。链表长度达到 8 且数组容量至少为 64 时，链表会树化为红黑树，降低极端冲突场景的查询复杂度。默认负载因子是 &lt;code&gt;0.75&lt;/code&gt;，元素数量超过 &lt;code&gt;capacity * loadFactor&lt;/code&gt; 时扩容，容量通常翻倍。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;HashSet&lt;/strong&gt; 底层就是 &lt;code&gt;HashMap&lt;/code&gt;。集合元素作为 Key，Value 使用固定占位对象，因此它利用 Key 的唯一性实现元素去重。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;LinkedHashMap&lt;/strong&gt; 在 HashMap 的基础上维护双向链表，可保持插入顺序；设置 &lt;code&gt;accessOrder = true&lt;/code&gt; 时，会维护访问顺序，可用于实现简单的 LRU 缓存。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ConcurrentHashMap&lt;/strong&gt; 的演进：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;JDK 7：&lt;code&gt;Segment[] + HashEntry[]&lt;/code&gt;，锁粒度是 Segment&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;JDK 7 的 &lt;code&gt;ConcurrentHashMap&lt;/code&gt; 由多个 &lt;code&gt;Segment&lt;/code&gt; 组成。每个 &lt;code&gt;Segment&lt;/code&gt; 内部维护一张自己的 &lt;code&gt;HashEntry[]&lt;/code&gt; 哈希表，并且 &lt;code&gt;Segment&lt;/code&gt; 继承 &lt;code&gt;ReentrantLock&lt;/code&gt;。&lt;code&gt;HashEntry&lt;/code&gt; 才是真正保存一条 Key-Value 的节点；同一个桶发生哈希冲突时，多个 &lt;code&gt;HashEntry&lt;/code&gt; 会形成链表。&lt;/p&gt;
&lt;p&gt;一次 &lt;code&gt;put&lt;/code&gt; 可以理解为两次定位：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Key
  -&amp;gt; hash 定位 Segment
  -&amp;gt; 在该 Segment 的 HashEntry[] 中定位 bucket
  -&amp;gt; 将 HashEntry 写入 bucket 的链表
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;写入前需要先锁住整个 Segment。假设两个 Key 都进入 &lt;code&gt;Segment[3]&lt;/code&gt;，但分别在 &lt;code&gt;bucket[2]&lt;/code&gt; 和 &lt;code&gt;bucket[8]&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user:1001    -&amp;gt; Segment[3] -&amp;gt; bucket[2]
product:9    -&amp;gt; Segment[3] -&amp;gt; bucket[8]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两个线程写入时仍需竞争 &lt;code&gt;Segment[3]&lt;/code&gt; 这一把锁，只能串行执行。两个 Key 位于不同 Segment 时，才可以并发写入。&lt;code&gt;Segment&lt;/code&gt; 数量通常远小于实际数据节点数，例如 16 个 Segment 内可以保存数百万个 &lt;code&gt;HashEntry&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;JDK 8：&lt;code&gt;Node[] + 链表/红黑树&lt;/code&gt;，锁粒度缩小到桶&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;JDK 8 删除了 &lt;code&gt;Segment&lt;/code&gt;，直接使用全局 &lt;code&gt;Node[]&lt;/code&gt; 桶数组。写入时根据 hash 直接定位一个 bucket：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Key -&amp;gt; hash -&amp;gt; table[index]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;空桶插入通过 CAS 完成，无需加锁；桶中已有节点时，才对该桶的头节点加 &lt;code&gt;synchronized&lt;/code&gt;，在链表或红黑树中完成插入、更新。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user:1001    -&amp;gt; table[52]
product:9    -&amp;gt; table[93]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两个线程写不同桶时通常可并发进行。只有两个 Key 落入同一个桶、同时修改该桶时，才会竞争同一把桶锁；这既可能来自哈希冲突，也可能是同一 Key 的并发更新。链表过长时还会树化为红黑树，降低冲突严重时的操作复杂度。&lt;/p&gt;
&lt;p&gt;JDK 8 在扩容时还允许多个线程协作迁移不同桶的数据，减少单线程扩容带来的停顿。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：JDK 7 将锁划分到 Segment；JDK 8 将日常写竞争进一步缩小到单个 bucket，因此不同桶之间的并发写入能力更强。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 不允许 &lt;code&gt;null&lt;/code&gt; Key 和 &lt;code&gt;null&lt;/code&gt; Value，因为并发场景中 &lt;code&gt;null&lt;/code&gt; 难以区分“键不存在”和“值为 null”。&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;MySQL&lt;/h3&gt;
&lt;h2&gt;6. MySQL 有哪些日志？各有什么用途？undo log 如何保证事务原子性？&lt;/h2&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;面试中最重要的三类日志是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;undo log  -&amp;gt; 原子性 + MVCC
redo log  -&amp;gt; 持久性 + 崩溃恢复
binlog    -&amp;gt; 主从复制 + 数据恢复
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;redo log&lt;/code&gt; 属于 InnoDB 存储引擎层，采用 WAL。修改数据页时，先记录 redo，再由后台慢慢刷脏页。数据库故障恢复时，可根据 redo 重做已经提交但尚未落盘的数据修改，保证持久性。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;undo log&lt;/code&gt; 保存修改前的逻辑版本，用于事务回滚和 MVCC。例如余额从 &lt;code&gt;1000&lt;/code&gt; 更新为 &lt;code&gt;500&lt;/code&gt;，对应 undo 信息保存旧值或反向操作。事务失败时，InnoDB 按 undo log 逆序撤销已完成的修改，使事务整体回到执行前状态，从而保证原子性。undo 中的历史版本也会组成 MVCC 的版本链。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;binlog&lt;/code&gt; 属于 MySQL Server 层，记录逻辑变更，用于主从复制和基于时间点的数据恢复。InnoDB 会通过两阶段提交让 redo log 和 binlog 保持一致，避免主从复制与崩溃恢复产生不一致。&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;Redis&lt;/h3&gt;
&lt;h2&gt;8. Redis 有哪些数据类型？底层实现原理是什么？为什么 ZSet 用跳表？&lt;/h2&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Redis 的五种基础数据类型是 String、List、Hash、Set、ZSet，另外常用扩展结构还有 Bitmap、HyperLogLog、Geo 和 Stream。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;String&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;核心字符串结构是 SDS。SDS 记录字符串长度和可用空间，获取长度为 &lt;code&gt;O(1)&lt;/code&gt;，支持二进制安全，也会通过预分配空间减少频繁扩容。值会按内容和大小选择 &lt;code&gt;int&lt;/code&gt;、&lt;code&gt;embstr&lt;/code&gt;、&lt;code&gt;raw&lt;/code&gt; 等编码。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;List&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;现代 Redis 主要使用 quicklist，可理解为由多个紧凑 listpack 组成的双向链表，兼顾内存利用率与两端插入、删除效率。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Hash&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;小 Hash 使用紧凑的 listpack 节省内存；字段或值较大、数量较多后转为哈希表。适合对象属性的局部读写。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Set&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;元素全为整数且数量较少时使用 &lt;code&gt;intset&lt;/code&gt;；其他场景使用哈希表。适合去重和交、并、差集运算。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;ZSet&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;ZSet 保存 &lt;code&gt;member + score&lt;/code&gt;。小数据量使用 listpack；规模增大后使用“哈希表 + 跳表”。哈希表支持按 member 快速查 score，跳表按 score 排序并支持范围查询、排名查询。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;普通有序链表查找是 &lt;code&gt;O(n)&lt;/code&gt;，跳表通过多级索引让平均查询、插入和删除达到 &lt;code&gt;O(log n)&lt;/code&gt;。它还天然保持有序链表结构，范围查询和顺序遍历实现直接，因此适合 ZSet。&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;Spring&lt;/h3&gt;
&lt;h2&gt;9. Spring Bean 的生命周期是怎样的？Spring 怎么解决循环依赖？&lt;code&gt;@Lazy&lt;/code&gt; 能解决循环依赖吗？&lt;/h2&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Bean 生命周期可概括为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;实例化
  -&amp;gt; 属性注入
  -&amp;gt; Aware 回调
  -&amp;gt; BeanPostProcessor Before
  -&amp;gt; 初始化回调
  -&amp;gt; BeanPostProcessor After
  -&amp;gt; 对外可用
  -&amp;gt; 销毁回调
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Spring Bean 的生命周期整体可以分成实例化、属性赋值、初始化和销毁四个阶段。首先 Spring 根据 BeanDefinition 创建 Bean 实例，然后进行属性填充和依赖注入。接下来会执行 &lt;code&gt;BeanNameAware&lt;/code&gt;、&lt;code&gt;BeanFactoryAware&lt;/code&gt;、&lt;code&gt;ApplicationContextAware&lt;/code&gt; 等 Aware 回调，再执行 BeanPostProcessor 的初始化前置处理，然后执行 &lt;code&gt;@PostConstruct&lt;/code&gt;、&lt;code&gt;InitializingBean.afterPropertiesSet()&lt;/code&gt; 以及自定义 init-method 等初始化逻辑，之后执行 BeanPostProcessor 的后置处理，AOP 代理通常也是在这个阶段生成。至此 Bean 可以正常使用。对于默认的 singleton Bean，它通常会一直存在到 Spring 容器关闭，关闭时再执行 &lt;code&gt;@PreDestroy&lt;/code&gt;、&lt;code&gt;DisposableBean.destroy()&lt;/code&gt; 或自定义 destroy-method。&lt;/p&gt;
&lt;p&gt;Spring 解决循环依赖的核心思想是 &lt;strong&gt;提前暴露 Bean 的引用&lt;/strong&gt;。它处理的是&lt;strong&gt;单例 Bean 的属性注入循环依赖&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;假设有两个 Bean：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A -&amp;gt; B
^    |
|____|
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Bean 的创建不是一步完成，而是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;实例化
  -&amp;gt; 属性注入
  -&amp;gt; 初始化
  -&amp;gt; 完整 Bean
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Spring 可以利用“对象已经实例化，但属性尚未注入”的中间阶段提前暴露 A 的引用。三级缓存是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;singletonObjects       完整 Bean
earlySingletonObjects  提前暴露的 Bean
singletonFactories     生成早期引用的 ObjectFactory
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;完整过程如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Spring 先创建 A，完成实例化。此时 A 对象已存在，但其中的 B 属性还没有注入，所以 A 还不能作为完整 Bean 放入一级缓存。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Spring 将“获取 A 早期引用”的 &lt;code&gt;ObjectFactory&lt;/code&gt; 放入三级缓存。它表达的是：后续有 Bean 提前需要 A 时，可以通过这个工厂取得 A 的早期引用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Spring 开始为 A 注入属性，发现 A 依赖 B，于是暂停 A 的创建，转而创建 B。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;B 完成实例化后也开始属性注入，发现 B 又依赖 A。此时 Spring 不会重新创建 A，否则会形成 A 创建 B、B 创建 A 的无限递归。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Spring 从三级缓存取得 A 的 early reference，并将这个已经确定的引用放入二级缓存，同时移除三级缓存中的工厂。若 A 没有 AOP，这个 early A 通常就是先前实例化出的原始对象；若 A 需要 AOP，这一步可以提前返回 A 的代理引用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Spring 将 early A 注入 B，B 的依赖满足，B 完成初始化后进入一级缓存：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;singletonObjects
B -&amp;gt; 完整 B

earlySingletonObjects
A -&amp;gt; early A
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Spring 回到 A 的创建流程，将已经完成的 B 注入 A。A 继续初始化，最终也进入一级缓存；A 的二级缓存早期引用随之清理。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;singletonObjects
A -&amp;gt; 完整 A
B -&amp;gt; 完整 B
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;三级缓存的关键作用是延迟生成早期引用。它保证同一个 A 在创建完成前，对其他 Bean 暴露的是同一份 early reference；同时给 AOP 留出机会，使依赖方拿到的可以是代理引用而不是后续再替换的原始对象。&lt;/p&gt;
&lt;p&gt;构造器循环依赖无法按这个机制解决，因为对象尚未实例化完成，无法提前暴露引用。原型 Bean 循环依赖也无法使用单例三级缓存解决。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@Lazy&lt;/code&gt; 可以在一侧注入延迟代理，从创建时依赖变为首次使用时再解析，因此可打断部分构造器循环依赖：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public A(@Lazy B b) {
    this.b = b;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但循环依赖通常反映职责耦合，优先通过拆分职责或引入中间协调服务解决，&lt;code&gt;@Lazy&lt;/code&gt; 更适合作为有限场景的补救手段。&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;计算机网络&lt;/h3&gt;
&lt;h2&gt;10. OSI 七层模型是什么？每一层的作用是什么？&lt;/h2&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;OSI 七层从上到下是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;应用层
表示层
会话层
传输层
网络层
数据链路层
物理层
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;层级&lt;/th&gt;
&lt;th&gt;主要作用&lt;/th&gt;
&lt;th&gt;常见协议或概念&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;应用层&lt;/td&gt;
&lt;td&gt;直接向应用程序提供网络服务&lt;/td&gt;
&lt;td&gt;HTTP、HTTPS、DNS、FTP、SMTP、WebSocket、SSH&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;表示层&lt;/td&gt;
&lt;td&gt;数据格式转换、编码、加密解密、压缩&lt;/td&gt;
&lt;td&gt;TLS/SSL、JSON、XML、UTF-8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;会话层&lt;/td&gt;
&lt;td&gt;建立、管理和终止会话&lt;/td&gt;
&lt;td&gt;RPC 会话、NetBIOS Session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;传输层&lt;/td&gt;
&lt;td&gt;端到端通信，使用端口区分应用&lt;/td&gt;
&lt;td&gt;TCP、UDP、QUIC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;网络层&lt;/td&gt;
&lt;td&gt;跨网络寻址和路由&lt;/td&gt;
&lt;td&gt;IP、ICMP；设备是路由器&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;数据链路层&lt;/td&gt;
&lt;td&gt;同一链路或局域网内按帧传输&lt;/td&gt;
&lt;td&gt;Ethernet、Wi-Fi、MAC 地址；设备是交换机&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;物理层&lt;/td&gt;
&lt;td&gt;传输比特流&lt;/td&gt;
&lt;td&gt;网线、光纤、无线电信号&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;面试中最常问的协议对应关系是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HTTP / HTTPS -&amp;gt; 应用层
TLS / SSL     -&amp;gt; 常按表示层理解
TCP / UDP     -&amp;gt; 传输层
IP            -&amp;gt; 网络层
MAC 地址      -&amp;gt; 数据链路层
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;例如浏览器访问 HTTPS 网站时，请求大致会逐层封装：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HTTP 请求
  -&amp;gt; TLS 加密
  -&amp;gt; TCP 分段，加入源端口和目标端口
  -&amp;gt; IP 加入源 IP 和目标 IP，供路由器转发
  -&amp;gt; Ethernet / Wi-Fi 加入源 MAC 和目标 MAC
  -&amp;gt; 通过网线、光纤或无线信号传输
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;接收方按相反方向逐层拆包，最终将 HTTP 请求交给 Web 服务器。TCP 三次握手属于传输层；HTTPS 可以理解为 HTTP 加 TLS，HTTP 位于应用层，TLS 通常按表示层理解。&lt;/p&gt;
&lt;p&gt;实际工程更多使用 TCP/IP 四层模型：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;OSI 应用层 + 表示层 + 会话层 -&amp;gt; TCP/IP 应用层
OSI 传输层                   -&amp;gt; TCP/IP 传输层
OSI 网络层                   -&amp;gt; TCP/IP 网络层
OSI 数据链路层 + 物理层       -&amp;gt; TCP/IP 网络接口层
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h3&gt;线上排障&lt;/h3&gt;
&lt;h2&gt;11. 线上接口出现超时，你会如何排查？&lt;/h2&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;先确认影响范围：是全部请求还是部分请求，是单个实例还是整个集群，是持续发生还是突发发生，是否与刚发布的版本、流量峰值或下游故障相关。&lt;/p&gt;
&lt;p&gt;然后根据 TraceId 沿调用链定位耗时位置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Client
  -&amp;gt; Nginx / Gateway
  -&amp;gt; Service A
  -&amp;gt; Service B
  -&amp;gt; Redis / MySQL / MQ
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;排查顺序如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;监控指标&lt;/strong&gt;：查看 QPS、错误率、RT/P99、CPU、Load、内存、GC、线程数、网络指标，先判断是资源饱和、突发流量还是依赖异常。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;本服务线程&lt;/strong&gt;：CPU 高时排查热点方法、死循环和频繁 GC；CPU 不高但请求阻塞时，查看 Web 线程池、业务线程池、队列和连接池。可用 &lt;code&gt;jstack &amp;lt;pid&amp;gt;&lt;/code&gt;、Arthas &lt;code&gt;thread&lt;/code&gt; 查看线程阻塞位置。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据库&lt;/strong&gt;：查看 SQL 耗时、执行计划、慢 SQL、锁等待、连接数和连接池等待。大量线程卡在 &lt;code&gt;HikariPool.getConnection()&lt;/code&gt; 时，重点检查连接池耗尽和慢事务。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Redis、MQ 与下游服务&lt;/strong&gt;：检查 Redis RT、慢命令、大 Key、Hot Key、连接池；检查 MQ 堆积和消费延迟；当前服务耗时正常而下游调用慢时，继续沿下游链路排查。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;网络与发布&lt;/strong&gt;：排查 DNS、连接建立、丢包、跨机房网络；对比发布前后指标，确认是否只发生在新版本实例，必要时快速回滚或摘流。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：&lt;strong&gt;先确定范围，再看监控和调用链，接着定位到具体服务，最后按 CPU/GC/线程池、MySQL/Redis/MQ、下游依赖、网络和近期发布逐层收敛。&lt;/strong&gt;&lt;/p&gt;
</content:encoded></item><item><title>JVM 高频面试题：内存、GC 与类加载</title><link>https://blog.huangnv.online/posts/2682/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2682/1/1/</guid><pubDate>Sun, 02 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;本文按照&lt;strong&gt;中国互联网大厂 Java 后端面试标准&lt;/strong&gt;整理 JVM 高频八股。&lt;/p&gt;
&lt;p&gt;筛选标准：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;✅ 阿里、字节、美团、腾讯、京东等 Java 后端面试常问&lt;/li&gt;
&lt;li&gt;✅ 牛客大厂面经中出现频率高&lt;/li&gt;
&lt;li&gt;✅ 不是冷门 JVM 源码题&lt;/li&gt;
&lt;li&gt;✅ 给出标准回答思路&lt;/li&gt;
&lt;li&gt;✅ 给出国内资料与面经的复习方向&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;JVM 面试重点通常集中在：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;JVM 内存结构&lt;/li&gt;
&lt;li&gt;对象创建与内存分配&lt;/li&gt;
&lt;li&gt;垃圾回收&lt;/li&gt;
&lt;li&gt;类加载机制&lt;/li&gt;
&lt;li&gt;JVM 调优与线上排查&lt;/li&gt;
&lt;li&gt;JIT/逃逸分析&lt;/li&gt;
&lt;li&gt;常见 GC 收集器&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;参考资料：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;JavaGuide JVM 面试题整理（国内 Java 面试使用非常广）&lt;/li&gt;
&lt;li&gt;阿里 Java 面经（包含 JVM 部分）&lt;/li&gt;
&lt;li&gt;美团 JVM 技术文章（GC/G1/JIT 实战）&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h1&gt;一、JVM 内存结构（必问）&lt;/h1&gt;
&lt;h2&gt;1. JVM 的内存区域有哪些？&lt;/h2&gt;
&lt;h3&gt;高频公司&lt;/h3&gt;
&lt;p&gt;阿里 / 美团 / 字节 一面高频&lt;/p&gt;
&lt;h3&gt;答案&lt;/h3&gt;
&lt;p&gt;JVM 运行时数据区域分为：&lt;/p&gt;
&lt;h3&gt;线程私有&lt;/h3&gt;
&lt;h4&gt;1. 程序计数器&lt;/h4&gt;
&lt;p&gt;作用：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当前线程执行字节码的位置&lt;/li&gt;
&lt;li&gt;多线程切换后恢复执行位置&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;特点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;唯一不会 OOM 的区域&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h4&gt;2. Java 虚拟机栈&lt;/h4&gt;
&lt;p&gt;每个方法执行都会创建一个栈帧：&lt;/p&gt;
&lt;p&gt;包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;局部变量表&lt;/li&gt;
&lt;li&gt;操作数栈&lt;/li&gt;
&lt;li&gt;动态链接&lt;/li&gt;
&lt;li&gt;方法出口&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public void test(){
    int a=10;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;a 会存在栈帧里面。&lt;/p&gt;
&lt;p&gt;可能异常：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;StackOverflowError
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;原因：&lt;/p&gt;
&lt;p&gt;递归过深。&lt;/p&gt;
&lt;hr /&gt;
&lt;h4&gt;3. 本地方法栈&lt;/h4&gt;
&lt;p&gt;作用：&lt;/p&gt;
&lt;p&gt;支持 native 方法。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Thread.start()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;底层调用 C++。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;线程共享&lt;/h1&gt;
&lt;h2&gt;4. 堆&lt;/h2&gt;
&lt;p&gt;存放：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;对象实例&lt;/li&gt;
&lt;li&gt;数组&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也是 GC 主要区域。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;User user=new User();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对象在堆。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;5. 方法区 / 元空间&lt;/h2&gt;
&lt;p&gt;Java8以后：&lt;/p&gt;
&lt;p&gt;永久代 -&amp;gt; 元空间&lt;/p&gt;
&lt;p&gt;存储：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;类信息&lt;/li&gt;
&lt;li&gt;常量池&lt;/li&gt;
&lt;li&gt;静态变量&lt;/li&gt;
&lt;li&gt;方法信息&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;面试回答总结&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;JVM 内存主要分为线程私有和线程共享两部分，线程私有包括程序计数器、虚拟机栈、本地方法栈，线程共享包括堆和方法区，其中堆是 GC 管理的主要区域。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h1&gt;二、对象创建过程（超级高频）&lt;/h1&gt;
&lt;h2&gt;2. 一个对象创建过程？&lt;/h2&gt;
&lt;h3&gt;字节/阿里面经常问&lt;/h3&gt;
&lt;p&gt;流程：&lt;/p&gt;
&lt;h2&gt;第一步：类加载检查&lt;/h2&gt;
&lt;p&gt;JVM 判断：&lt;/p&gt;
&lt;p&gt;类是否已经加载。&lt;/p&gt;
&lt;p&gt;如果没有：&lt;/p&gt;
&lt;p&gt;执行：&lt;/p&gt;
&lt;p&gt;加载
↓&lt;/p&gt;
&lt;p&gt;验证
↓&lt;/p&gt;
&lt;p&gt;准备
↓&lt;/p&gt;
&lt;p&gt;解析
↓&lt;/p&gt;
&lt;p&gt;初始化&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;第二步：分配内存&lt;/h2&gt;
&lt;p&gt;两种方式：&lt;/p&gt;
&lt;h3&gt;指针碰撞&lt;/h3&gt;
&lt;p&gt;适合：&lt;/p&gt;
&lt;p&gt;内存规整&lt;/p&gt;
&lt;h3&gt;空闲列表&lt;/h3&gt;
&lt;p&gt;适合：&lt;/p&gt;
&lt;p&gt;内存碎片多&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;第三步：初始化零值&lt;/h2&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;int age;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;默认：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;第四步：设置对象头&lt;/h2&gt;
&lt;p&gt;包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Mark Word&lt;/li&gt;
&lt;li&gt;Class Pointer&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;第五步：执行构造方法&lt;/h2&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;new User();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最后调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;User.init&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;三、对象一定在堆中吗？&lt;/h1&gt;
&lt;h2&gt;高频追问&lt;/h2&gt;
&lt;p&gt;答案：&lt;/p&gt;
&lt;p&gt;不一定。&lt;/p&gt;
&lt;p&gt;正常：&lt;/p&gt;
&lt;p&gt;对象在堆。&lt;/p&gt;
&lt;p&gt;但是 JVM 有逃逸分析。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public void test(){

    User u=new User();

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果 JVM 判断：&lt;/p&gt;
&lt;p&gt;对象不会逃出方法。&lt;/p&gt;
&lt;p&gt;可能：&lt;/p&gt;
&lt;p&gt;栈上分配。&lt;/p&gt;
&lt;p&gt;优化：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;标量替换&lt;/li&gt;
&lt;li&gt;栈上分配&lt;/li&gt;
&lt;li&gt;锁消除&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h1&gt;四、垃圾回收机制（核心）&lt;/h1&gt;
&lt;h1&gt;4. 如何判断对象是否死亡？&lt;/h1&gt;
&lt;p&gt;两种算法：&lt;/p&gt;
&lt;h2&gt;1. 引用计数&lt;/h2&gt;
&lt;p&gt;对象有引用数量。&lt;/p&gt;
&lt;p&gt;问题：&lt;/p&gt;
&lt;p&gt;循环引用无法解决。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;p&gt;A引用B&lt;/p&gt;
&lt;p&gt;B引用A&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;2. 可达性分析（HotSpot使用）&lt;/h2&gt;
&lt;p&gt;从 GC Roots 开始搜索。&lt;/p&gt;
&lt;p&gt;GC Roots：&lt;/p&gt;
&lt;p&gt;包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;栈中的引用&lt;/li&gt;
&lt;li&gt;静态变量&lt;/li&gt;
&lt;li&gt;常量引用&lt;/li&gt;
&lt;li&gt;JNI引用&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不可达对象：&lt;/p&gt;
&lt;p&gt;可以回收。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;五、Minor GC、Major GC、Full GC 区别&lt;/h1&gt;
&lt;h2&gt;Minor GC&lt;/h2&gt;
&lt;p&gt;回收：&lt;/p&gt;
&lt;p&gt;新生代&lt;/p&gt;
&lt;p&gt;触发：&lt;/p&gt;
&lt;p&gt;Eden 满。&lt;/p&gt;
&lt;p&gt;特点：&lt;/p&gt;
&lt;p&gt;频繁&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Major GC&lt;/h2&gt;
&lt;p&gt;回收：&lt;/p&gt;
&lt;p&gt;老年代。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Full GC&lt;/h2&gt;
&lt;p&gt;整个堆：&lt;/p&gt;
&lt;p&gt;年轻代 + 老年代 + 方法区&lt;/p&gt;
&lt;p&gt;特点：&lt;/p&gt;
&lt;p&gt;慢。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;六、垃圾收集器有哪些？&lt;/h1&gt;
&lt;h2&gt;常问：&lt;/h2&gt;
&lt;p&gt;CMS 和 G1 区别？&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;CMS&lt;/h2&gt;
&lt;p&gt;流程：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;初始标记&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;STW&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;并发标记&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;重新标记&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;STW&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;并发清除&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;优点：&lt;/p&gt;
&lt;p&gt;低停顿&lt;/p&gt;
&lt;p&gt;缺点：&lt;/p&gt;
&lt;p&gt;产生内存碎片。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;G1&lt;/h2&gt;
&lt;p&gt;JDK9默认。&lt;/p&gt;
&lt;p&gt;核心：&lt;/p&gt;
&lt;p&gt;Region。&lt;/p&gt;
&lt;p&gt;把堆划分多个 Region。&lt;/p&gt;
&lt;p&gt;每次选择垃圾最多的 Region 回收。&lt;/p&gt;
&lt;p&gt;优势：&lt;/p&gt;
&lt;p&gt;可预测停顿。&lt;/p&gt;
&lt;p&gt;美团技术团队发布过多篇 JVM/G1 实践文章，可作为延伸阅读方向。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;七、为什么 G1 可以替代 CMS？&lt;/h1&gt;
&lt;p&gt;面试回答：&lt;/p&gt;
&lt;p&gt;CMS：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;标记清除&lt;/li&gt;
&lt;li&gt;内存碎片&lt;/li&gt;
&lt;li&gt;Full GC风险&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;G1：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;标记整理&lt;/li&gt;
&lt;li&gt;Region设计&lt;/li&gt;
&lt;li&gt;可预测停顿&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以：&lt;/p&gt;
&lt;p&gt;G1 更适合大堆低延迟服务。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;八、JVM 调优怎么做？（大厂必问）&lt;/h1&gt;
&lt;h2&gt;线上 CPU 飙高怎么办？&lt;/h2&gt;
&lt;p&gt;步骤：&lt;/p&gt;
&lt;h3&gt;1. 找进程&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;top
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;找到 PID&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;2. 找线程&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;top -Hp pid
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h3&gt;3. 转换16进制&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;printf &quot;%x&quot; tid
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h3&gt;4. jstack&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;jstack pid
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;定位线程。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;九、线上 OOM 怎么排查？&lt;/h1&gt;
&lt;p&gt;流程：&lt;/p&gt;
&lt;h2&gt;1. 查看日志&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;java.lang.OutOfMemoryError
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;2. dump堆&lt;/h2&gt;
&lt;p&gt;参数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-XX:+HeapDumpOnOutOfMemoryError
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;3. MAT分析&lt;/h2&gt;
&lt;p&gt;查看：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;最大对象&lt;/li&gt;
&lt;li&gt;GC Roots引用链&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h1&gt;十、JVM 参数有哪些？&lt;/h1&gt;
&lt;p&gt;高频：&lt;/p&gt;
&lt;h2&gt;堆大小&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;-Xms
-Xmx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-Xms4g
-Xmx4g
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;避免动态扩容。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;新生代&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;-Xmn
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;GC&lt;/h2&gt;
&lt;p&gt;G1:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-XX:+UseG1GC
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;GC日志&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;-Xlog:gc
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;十一、JIT 编译是什么？&lt;/h1&gt;
&lt;p&gt;热点代码：&lt;/p&gt;
&lt;p&gt;解释执行&lt;/p&gt;
&lt;p&gt;↓&lt;/p&gt;
&lt;p&gt;JIT编译&lt;/p&gt;
&lt;p&gt;↓&lt;/p&gt;
&lt;p&gt;机器码&lt;/p&gt;
&lt;p&gt;目的：&lt;/p&gt;
&lt;p&gt;提高运行速度。&lt;/p&gt;
&lt;p&gt;HotSpot:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;C1 编译器&lt;/li&gt;
&lt;li&gt;C2 编译器&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;美团技术团队发布过 JVM JIT 实践分析，可作为延伸阅读方向。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;十二、什么是逃逸分析？&lt;/h1&gt;
&lt;p&gt;判断：&lt;/p&gt;
&lt;p&gt;对象是否逃离当前方法。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public void test(){

 User u=new User();

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果没有返回：&lt;/p&gt;
&lt;p&gt;没有逃逸。&lt;/p&gt;
&lt;p&gt;可以：&lt;/p&gt;
&lt;p&gt;栈上分配。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;十三、类加载过程（必问）&lt;/h1&gt;
&lt;p&gt;五个阶段：&lt;/p&gt;
&lt;h2&gt;1. 加载&lt;/h2&gt;
&lt;p&gt;读取 class 文件。&lt;/p&gt;
&lt;p&gt;↓&lt;/p&gt;
&lt;h2&gt;2. 验证&lt;/h2&gt;
&lt;p&gt;检查字节码。&lt;/p&gt;
&lt;p&gt;↓&lt;/p&gt;
&lt;h2&gt;3. 准备&lt;/h2&gt;
&lt;p&gt;给静态变量分配内存。&lt;/p&gt;
&lt;p&gt;↓&lt;/p&gt;
&lt;h2&gt;4. 解析&lt;/h2&gt;
&lt;p&gt;符号引用转换。&lt;/p&gt;
&lt;p&gt;↓&lt;/p&gt;
&lt;h2&gt;5. 初始化&lt;/h2&gt;
&lt;p&gt;执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;static{}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;十四、双亲委派模型&lt;/h1&gt;
&lt;h2&gt;为什么需要？&lt;/h2&gt;
&lt;p&gt;避免：&lt;/p&gt;
&lt;p&gt;核心类被替换。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;p&gt;自己写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;java.lang.String
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不会生效。&lt;/p&gt;
&lt;p&gt;原因：&lt;/p&gt;
&lt;p&gt;Bootstrap ClassLoader优先加载。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;十五、类什么时候初始化？&lt;/h1&gt;
&lt;p&gt;触发：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;new对象&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;调用静态变量&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;调用静态方法&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;反射&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;不会触发：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;子类引用父类静态变量
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;大厂 JVM 高频排序（建议背）&lt;/h1&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;排名&lt;/th&gt;
&lt;th&gt;问题&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;⭐⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;JVM内存结构&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⭐⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;对象创建过程&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⭐⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;GC算法&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⭐⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;CMS/G1区别&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⭐⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;类加载过程&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;双亲委派&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;OOM排查&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;JVM调优参数&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;Minor GC Full GC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⭐⭐⭐&lt;/td&gt;
&lt;td&gt;逃逸分析&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⭐⭐⭐&lt;/td&gt;
&lt;td&gt;JIT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⭐⭐⭐&lt;/td&gt;
&lt;td&gt;对象布局&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;p&gt;建议重点复习：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;JavaGuide JVM 面试题（八股主线）&lt;/li&gt;
&lt;li&gt;牛客阿里 Java 面经（看真实问法）&lt;/li&gt;
&lt;li&gt;美团 JVM/G1/JIT 技术文章（准备深挖）&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这一套基本覆盖 &lt;strong&gt;Java 后端实习 → 校招 → 1~3年社招 JVM 面试 80%以上的问题&lt;/strong&gt;。&lt;/p&gt;
</content:encoded></item><item><title>Java/JVM 并发编程大厂高频八股题</title><link>https://blog.huangnv.online/posts/2681/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2681/1/1/</guid><pubDate>Sat, 01 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Java/JVM 并发编程大厂高频八股题&lt;/h1&gt;
&lt;p&gt;严格来说，大厂面试里的“JVM 并发”通常不只包括 JVM，还会覆盖：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Java 内存模型 JMM、&lt;code&gt;volatile&lt;/code&gt;、&lt;code&gt;synchronized&lt;/code&gt;、CAS、AQS、线程池、ThreadLocal、ConcurrentHashMap、CompletableFuture、各种并发工具类以及线上并发问题排查。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;我主要筛选了字节跳动、腾讯、阿里、拼多多、美团、京东、快手、百度、滴滴等公司的公开面经。公开面经属于候选人回忆，并不是公司官方题库，所以这里的“高频”指的是：&lt;strong&gt;同一问题在多家公司、多篇面经中反复出现&lt;/strong&gt;。答案则依据 JLS、JDK API 和 OpenJDK 源码校正，没有直接照抄候选人的现场回答。例如一篇腾讯 QQ 面经中候选人仍用老版本的 &lt;code&gt;Segment&lt;/code&gt; 回答 &lt;code&gt;ConcurrentHashMap&lt;/code&gt;，这在 JDK 8 及之后已经不准确。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/14787ab5e61549ba8dafa24debae04c2&quot;&gt;牛客网&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;近期面经还出现了明显变化：除了传统的原理题，腾讯云等岗位开始重点考察“不同场景如何选择锁”“线程池核心参数如何设计”“系统变慢如何定位”“CompletableFuture 如何编排任务”等场景题。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/b16c3e91342244ae8e183728d524eccd&quot;&gt;牛客网&lt;/a&gt;)&lt;/p&gt;
&lt;h2&gt;一、建议优先级&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;优先级&lt;/th&gt;
&lt;th&gt;需要掌握的内容&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;P0，必须会&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JMM、happens-before、volatile、synchronized、ReentrantLock、CAS、AQS、线程池、ThreadLocal、ConcurrentHashMap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;P1，高频追问&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;锁升级、ABA、线程池队列与拒绝策略、CompletableFuture、CountDownLatch、线程状态、死锁&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;P2，近期趋势&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;跨线程上下文传递、线程池动态调参、锁的场景选型、ForkJoinPool、虚拟线程、线上线程排查&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h1&gt;二、重点大厂面经链接&lt;/h1&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;公司与面经&lt;/th&gt;
&lt;th&gt;其中的并发问题&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/b16c3e91342244ae8e183728d524eccd&quot;&gt;腾讯云技术方向一面&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;ThreadLocal、线程池参数、CompletableFuture、不同场景选择锁、系统变慢排查。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/b16c3e91342244ae8e183728d524eccd&quot;&gt;牛客网&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/40139aa48ae34e5b94e0a1cf71327c49&quot;&gt;字节跳动近期日常实习一面&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;线程池执行流程、核心线程回收、拒绝策略、ThreadLocal、跨线程传递。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/40139aa48ae34e5b94e0a1cf71327c49&quot;&gt;牛客网&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/006dc9b462dd495588abb764418dc71d&quot;&gt;美团近期后端一面&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;线程池参数与队列、拒绝策略、Future、CompletableFuture、主线程等待子任务。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/006dc9b462dd495588abb764418dc71d&quot;&gt;牛客网&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/36fd5d6290724b25916fc105929bc085&quot;&gt;拼多多 Java 后端一面&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;锁升级、volatile 数组、ThreadLocal、ForkJoinPool、AQS、动态线程池、CHM size、CAS 与 ABA。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/36fd5d6290724b25916fc105929bc085&quot;&gt;牛客网&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/742257c2ead94976a08d8a7256c841de&quot;&gt;字节跳动后端面经&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;synchronized 与 ReentrantLock、ThreadLocal 原理和内存泄漏。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/742257c2ead94976a08d8a7256c841de&quot;&gt;牛客网&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/0abf15b01dd04accb6176e7e8e1c2bb2&quot;&gt;阿里菜鸟 Java 实习二面&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;AQS、原子性、可见性、有序性、volatile、指令重排、CAS ABA。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/0abf15b01dd04accb6176e7e8e1c2bb2&quot;&gt;牛客网&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/c852db0682e344448b1eb9590216f77a&quot;&gt;京东 Young Java 实习面经&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;线程池流程、死锁必要条件、乐观锁、悲观锁、AQS。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/c852db0682e344448b1eb9590216f77a?toCommentId=18963969&quot;&gt;牛客网&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/46c0aba953de44628a7ebba0a8876577&quot;&gt;快手等公司 Java 社招记录&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;CompletableFuture、线程池、阻塞队列、synchronized、ReentrantLock、ThreadLocal。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/46c0aba953de44628a7ebba0a8876577&quot;&gt;牛客网&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/1fab317e43ba4b518ba7c2c022ac10f0&quot;&gt;滴滴 Java 一面&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;ThreadLocal、线程池、高并发递增 ID。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/1fab317e43ba4b518ba7c2c022ac10f0?sourceSSR=post&quot;&gt;牛客网&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/c7c5f594a9af435081511a9f8259952a&quot;&gt;百度客户端开发面经&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;线程池参数、任务执行流程、线程状态、锁升级。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/c7c5f594a9af435081511a9f8259952a&quot;&gt;牛客网&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/14787ab5e61549ba8dafa24debae04c2&quot;&gt;腾讯 QQ 后台开发一面&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;HashMap 线程安全、ConcurrentHashMap 如何保证线程安全。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/14787ab5e61549ba8dafa24debae04c2&quot;&gt;牛客网&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/f256ed05fc4945e4a932a86bc0d01a5d&quot;&gt;TikTok 后端实习面经&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;ThreadLocal、父子线程传递、三个线程交替打印。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/f256ed05fc4945e4a932a86bc0d01a5d&quot;&gt;牛客网&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h1&gt;三、P0 级别：必须掌握的高频题目与答案&lt;/h1&gt;
&lt;h2&gt;1. 什么是 Java 内存模型 JMM？和 JVM 内存区域有什么区别？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;JMM 是 Java 为多线程程序定义的一套&lt;strong&gt;内存访问与同步规则&lt;/strong&gt;，主要解决三个问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;原子性；&lt;/li&gt;
&lt;li&gt;可见性；&lt;/li&gt;
&lt;li&gt;有序性。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;JMM 规定一个线程对共享变量的写入，什么时候能够被另一个线程看到，以及编译器、处理器可以进行哪些指令重排序。&lt;/p&gt;
&lt;p&gt;JVM 内存区域则是堆、虚拟机栈、方法区、程序计数器、本地方法栈等运行时数据区域。&lt;/p&gt;
&lt;p&gt;所以：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;JVM 内存区域回答“数据放在哪里”，JMM 回答“多个线程怎样正确访问这些数据”。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;JLS 将普通读写、&lt;code&gt;volatile&lt;/code&gt; 读写、加锁、解锁、线程启动和线程结束等定义为线程间动作或同步动作。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-17.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/0abf15b01dd04accb6176e7e8e1c2bb2&quot;&gt;阿里菜鸟二面&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/36fd5d6290724b25916fc105929bc085&quot;&gt;拼多多 Java 一面&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;2. 什么是 happens-before？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;happens-before 表示：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;如果操作 A happens-before 操作 B，那么 A 的执行结果必须对 B 可见，并且 A 在逻辑顺序上先于 B。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;面试最常问的规则有：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;程序顺序规则&lt;/strong&gt;：同一个线程中，前面的操作 happens-before 后面的操作。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;监视器锁规则&lt;/strong&gt;：对一个锁的解锁 happens-before 后续对同一个锁的加锁。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;volatile 规则&lt;/strong&gt;：对某个 volatile 变量的写 happens-before 后续对该变量的读。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;线程启动规则&lt;/strong&gt;：调用 &lt;code&gt;Thread.start()&lt;/code&gt; happens-before 新线程中的操作。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;线程终止规则&lt;/strong&gt;：线程中的操作 happens-before 其他线程从 &lt;code&gt;join()&lt;/code&gt; 成功返回。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;传递性&lt;/strong&gt;：A happens-before B，B happens-before C，则 A happens-before C。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;它不是简单的“时间上先执行”，而是 JMM 对&lt;strong&gt;可见性和顺序性&lt;/strong&gt;的保证。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-17.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/0abf15b01dd04accb6176e7e8e1c2bb2&quot;&gt;阿里菜鸟二面&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;3. volatile 有什么作用？能保证原子性吗？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;volatile&lt;/code&gt; 主要有两个作用：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;保证可见性&lt;/strong&gt;：一个线程修改 volatile 变量后，其他线程能够看到最新值。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;限制特定指令重排序&lt;/strong&gt;：volatile 写之前的普通写不能被重排到它后面，volatile 读之后的普通读不能被重排到它前面。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;但是 &lt;code&gt;volatile&lt;/code&gt; &lt;strong&gt;不能保证复合操作的原子性&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;volatile int count = 0;
count++;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;count++&lt;/code&gt; 实际包含：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;读取 count
count 加 1
写回 count
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;多个线程仍然可能丢失更新。需要使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;AtomicInteger
synchronized
ReentrantLock
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见使用场景：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;volatile boolean stopped;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;以及双重检查单例中的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;private static volatile Singleton instance;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JLS 明确规定，同一变量的 volatile 写与后续 volatile 读之间存在同步关系。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-17.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/0abf15b01dd04accb6176e7e8e1c2bb2&quot;&gt;阿里菜鸟二面&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/36fd5d6290724b25916fc105929bc085&quot;&gt;拼多多 Java 一面&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;4. &lt;code&gt;volatile int[] array&lt;/code&gt; 能保证数组元素的可见性吗？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;不能完整保证。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;volatile int[] array = new int[10];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里被 &lt;code&gt;volatile&lt;/code&gt; 修饰的是 &lt;strong&gt;array 这个引用&lt;/strong&gt;，不是 &lt;code&gt;array[0]&lt;/code&gt;、&lt;code&gt;array[1]&lt;/code&gt; 等数组元素。&lt;/p&gt;
&lt;p&gt;下面这种重新赋值受 volatile 规则保护：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;array = new int[20];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是下面这种元素更新，不等于元素本身具有 volatile 语义：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;array[0]++;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要保证数组元素的原子更新时，可以使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;AtomicIntegerArray
AtomicLongArray
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者加锁。JLS 将数组元素视为独立的变量，而 &lt;code&gt;AtomicIntegerArray&lt;/code&gt; 明确支持对单个数组元素进行原子更新。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-17.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/36fd5d6290724b25916fc105929bc085&quot;&gt;拼多多 Java 一面&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;5. synchronized 的原理是什么？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;synchronized&lt;/code&gt; 是 Java 提供的内置互斥同步机制，锁对象对应一个 Monitor。&lt;/p&gt;
&lt;p&gt;它有三个重要特性：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;互斥性&lt;/strong&gt;：同一时刻只有一个线程能持有相同 Monitor。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;可重入性&lt;/strong&gt;：同一个线程可以重复获取自己已经持有的锁。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;内存可见性&lt;/strong&gt;：解锁 happens-before 后续线程对同一锁的加锁。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;不同写法锁的对象不同：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public synchronized void test() {
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;锁的是当前对象 &lt;code&gt;this&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public static synchronized void test() {
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;锁的是当前类的 &lt;code&gt;Class&lt;/code&gt; 对象。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;synchronized (lock) {
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;锁的是括号中的 &lt;code&gt;lock&lt;/code&gt; 对象。&lt;/p&gt;
&lt;p&gt;字节码层面：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;同步代码块通常使用 &lt;code&gt;monitorenter&lt;/code&gt; 和 &lt;code&gt;monitorexit&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;同步方法由 JVM 根据方法的同步标志隐式获取和释放 Monitor。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;JVM 规范说明，&lt;code&gt;monitorenter&lt;/code&gt;/&lt;code&gt;monitorexit&lt;/code&gt; 可用于实现同步代码块，而同步方法的监视器进入由方法调用指令隐式处理。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-17.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/742257c2ead94976a08d8a7256c841de&quot;&gt;字节跳动后端面经&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/36fd5d6290724b25916fc105929bc085&quot;&gt;拼多多 Java 一面&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;6. synchronized 的锁升级过程是什么？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这是一个非常容易因为 JDK 版本而答错的问题。&lt;/p&gt;
&lt;p&gt;按照传统 &lt;strong&gt;JDK 8 HotSpot 面试口径&lt;/strong&gt;，常见回答是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;无锁
→ 偏向锁
→ 轻量级锁
→ 重量级锁
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;大致过程：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;第一次进入同步代码时，可能使用偏向锁；&lt;/li&gt;
&lt;li&gt;出现其他线程竞争后，撤销偏向，尝试轻量级锁；&lt;/li&gt;
&lt;li&gt;轻量级锁通过 CAS 和一定程度的自旋竞争；&lt;/li&gt;
&lt;li&gt;竞争严重或自旋失败时，膨胀为重量级 Monitor，线程可能阻塞。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但是必须补充版本说明：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;偏向锁从 JDK 15 开始默认关闭，因此不能把这条升级路径当作所有现代 JDK 都固定存在的规则。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;OpenJDK 的 JEP 374 明确说明 JDK 15 默认禁用偏向锁。(&lt;a href=&quot;https://openjdk.org/jeps/374&quot;&gt;OpenJDK&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/36fd5d6290724b25916fc105929bc085&quot;&gt;拼多多 Java 一面&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/c7c5f594a9af435081511a9f8259952a&quot;&gt;百度面经&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;7. synchronized 和 ReentrantLock 有什么区别？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;相同点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;都能实现互斥；&lt;/li&gt;
&lt;li&gt;都是可重入锁；&lt;/li&gt;
&lt;li&gt;都能够提供正确的内存可见性。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;主要区别：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;synchronized&lt;/th&gt;
&lt;th&gt;ReentrantLock&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JVM 内置关键字&lt;/td&gt;
&lt;td&gt;JUC 提供的类&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;自动释放锁&lt;/td&gt;
&lt;td&gt;必须手动 &lt;code&gt;unlock()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;不直接支持超时获取&lt;/td&gt;
&lt;td&gt;支持 &lt;code&gt;tryLock(timeout)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;不直接支持可中断等待&lt;/td&gt;
&lt;td&gt;支持 &lt;code&gt;lockInterruptibly()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;一个锁对应一个等待集合&lt;/td&gt;
&lt;td&gt;可创建多个 &lt;code&gt;Condition&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;没有公平参数&lt;/td&gt;
&lt;td&gt;可选择公平锁或非公平锁&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;代码简单、不容易忘记释放&lt;/td&gt;
&lt;td&gt;功能更丰富，但必须规范使用&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;正确写法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;lock.lock();
try {
    // 临界区
} finally {
    lock.unlock();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要回答“ReentrantLock 一定比 synchronized 快”。现代 JVM 对 &lt;code&gt;synchronized&lt;/code&gt; 做了大量优化，应该根据是否需要超时、可中断、公平性、多个条件队列等功能来选择。Oracle 文档说明 ReentrantLock 与内置 Monitor 具有相同的基本行为，但提供了扩展能力。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/locks/ReentrantLock.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/742257c2ead94976a08d8a7256c841de&quot;&gt;字节跳动后端面经&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/0abf15b01dd04accb6176e7e8e1c2bb2&quot;&gt;阿里菜鸟二面&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/46c0aba953de44628a7ebba0a8876577&quot;&gt;快手社招记录&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;8. 什么是 CAS？有什么问题？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;CAS 是 Compare-And-Set，也就是比较并交换。&lt;/p&gt;
&lt;p&gt;它通常包含三个值：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;当前内存值 V
期望值 A
新值 B
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;逻辑是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;如果 V == A，就把 V 更新成 B；
否则更新失败。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;CAS 是很多原子类和并发组件的基础，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;AtomicInteger
AtomicLong
AQS
ConcurrentHashMap
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;高竞争时自旋消耗 CPU&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只能直接操作一个变量或一个复合状态&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ABA 问题&lt;/strong&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;ABA 指：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;线程1看到值是 A
线程2把 A 改成 B
线程2又把 B 改回 A
线程1执行 CAS 时仍认为它没变
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;解决方式是增加版本号，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;AtomicStampedReference
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它把引用和版本戳作为一个整体进行原子比较和更新。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/atomic/AtomicStampedReference.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/36fd5d6290724b25916fc105929bc085&quot;&gt;拼多多 Java 一面&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/0abf15b01dd04accb6176e7e8e1c2bb2&quot;&gt;阿里菜鸟二面&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;9. AQS 是什么？底层原理是什么？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;AQS 全称：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;AbstractQueuedSynchronizer
抽象队列同步器
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它不是一把具体的锁，而是用于实现锁和同步器的基础框架。&lt;/p&gt;
&lt;p&gt;核心结构：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;一个原子的 int state
+
一个 FIFO 等待队列
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;state&lt;/code&gt; 表示同步状态；&lt;/li&gt;
&lt;li&gt;获取资源失败的线程进入同步队列；&lt;/li&gt;
&lt;li&gt;前面的线程释放资源后，唤醒后继线程；&lt;/li&gt;
&lt;li&gt;子类通过实现 &lt;code&gt;tryAcquire()&lt;/code&gt;、&lt;code&gt;tryRelease()&lt;/code&gt; 等方法定义获取和释放规则。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;支持两种模式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;独占模式&lt;/strong&gt;：如 &lt;code&gt;ReentrantLock&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;共享模式&lt;/strong&gt;：如 &lt;code&gt;Semaphore&lt;/code&gt;、&lt;code&gt;CountDownLatch&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;常见基于 AQS 的组件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ReentrantLock
ReentrantReadWriteLock
Semaphore
CountDownLatch
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;面试里常说 AQS 使用“CLH 队列”，更严谨的说法是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;AQS 使用基于 FIFO 的同步等待队列，工程资料中通常将其描述为 CLH 队列的变体。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Oracle 官方文档将其定义为依赖 FIFO 等待队列和单个原子 &lt;code&gt;int state&lt;/code&gt; 的同步器框架。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/locks/AbstractQueuedSynchronizer.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/36fd5d6290724b25916fc105929bc085&quot;&gt;拼多多 Java 一面&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/0abf15b01dd04accb6176e7e8e1c2bb2&quot;&gt;阿里菜鸟二面&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/c852db0682e344448b1eb9590216f77a&quot;&gt;京东实习面经&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;10. 公平锁和非公平锁有什么区别？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;公平锁：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;尽量按照等待时间顺序获取锁；&lt;/li&gt;
&lt;li&gt;可以降低线程长期饥饿的概率；&lt;/li&gt;
&lt;li&gt;需要检查等待队列，吞吐量通常较低。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;非公平锁：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;新到线程可以直接尝试抢锁；&lt;/li&gt;
&lt;li&gt;可能插队；&lt;/li&gt;
&lt;li&gt;通常吞吐量更高；&lt;/li&gt;
&lt;li&gt;某些线程可能等待更久。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;new ReentrantLock();       // 默认非公平
new ReentrantLock(true);   // 公平锁
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;补充：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tryLock()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;即便锁被创建为公平锁，无参 &lt;code&gt;tryLock()&lt;/code&gt; 也允许直接尝试获取，不严格遵守公平顺序。Oracle 文档同时说明公平锁通常吞吐量更低。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/locks/ReentrantLock.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;四、线程池高频题&lt;/h1&gt;
&lt;h2&gt;11. ThreadPoolExecutor 的七个核心参数是什么？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;new ThreadPoolExecutor(
    corePoolSize,
    maximumPoolSize,
    keepAliveTime,
    unit,
    workQueue,
    threadFactory,
    handler
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;分别表示：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;corePoolSize&lt;/code&gt;：核心线程数；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;maximumPoolSize&lt;/code&gt;：最大线程数；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;keepAliveTime&lt;/code&gt;：非核心空闲线程存活时间；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;unit&lt;/code&gt;：时间单位；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;workQueue&lt;/code&gt;：任务队列；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;threadFactory&lt;/code&gt;：线程工厂；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;handler&lt;/code&gt;：拒绝策略。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;核心线程默认不会因为空闲而回收，但调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;allowCoreThreadTimeOut(true);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;后也可以回收核心线程。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/ThreadPoolExecutor.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/b16c3e91342244ae8e183728d524eccd&quot;&gt;腾讯云一面&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/36fd5d6290724b25916fc105929bc085&quot;&gt;拼多多 Java 一面&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/c7c5f594a9af435081511a9f8259952a&quot;&gt;百度面经&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;12. 线程池提交任务后的执行流程是什么？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;提交一个任务后：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. 当前线程数 &amp;lt; corePoolSize
   创建核心线程执行任务

2. 当前线程数 &amp;gt;= corePoolSize
   尝试把任务放进 workQueue

3. 队列放不进去
   当前线程数 &amp;lt; maximumPoolSize
   创建非核心线程执行任务

4. 队列放不进去，并且线程数已经达到 maximumPoolSize
   执行拒绝策略
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;线程池不是先创建到最大线程数再放队列，而是先到核心线程数，然后优先入队，队列满了才继续扩线程。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;JDK 官方文档明确说明：小于核心线程数时优先创建线程；达到核心线程数后优先入队；入队失败才扩展到最大线程数，之后拒绝。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/ThreadPoolExecutor.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/c852db0682e344448b1eb9590216f77a&quot;&gt;京东实习面经&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/c7c5f594a9af435081511a9f8259952a&quot;&gt;百度面经&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;13. 常见线程池队列有什么区别？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;ArrayBlockingQueue&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;有界数组队列；&lt;/li&gt;
&lt;li&gt;创建时必须指定容量；&lt;/li&gt;
&lt;li&gt;可以限制内存和任务堆积；&lt;/li&gt;
&lt;li&gt;生产环境常用。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;LinkedBlockingQueue&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;链表队列；&lt;/li&gt;
&lt;li&gt;可以指定容量；&lt;/li&gt;
&lt;li&gt;不指定时容量非常大，可能导致大量任务堆积。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果使用接近无界的队列：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;达到核心线程数以后，任务通常一直进入队列，&lt;code&gt;maximumPoolSize&lt;/code&gt; 很难发挥作用。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;SynchronousQueue&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;不真正存储任务；&lt;/li&gt;
&lt;li&gt;每次提交必须直接交给一个工作线程；&lt;/li&gt;
&lt;li&gt;没有线程接手时，就需要创建新线程或者拒绝。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;PriorityBlockingQueue&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;按优先级执行；&lt;/li&gt;
&lt;li&gt;通常是无界队列；&lt;/li&gt;
&lt;li&gt;使用时要注意低优先级任务饥饿和任务堆积。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;线程池官方文档说明，队列类型直接影响线程池扩容策略和资源控制。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/ThreadPoolExecutor.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/46c0aba953de44628a7ebba0a8876577&quot;&gt;快手社招记录&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/006dc9b462dd495588abb764418dc71d&quot;&gt;美团后端面经&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;14. 线程池有哪些拒绝策略？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;JDK 内置四种策略：&lt;/p&gt;
&lt;h3&gt;AbortPolicy&lt;/h3&gt;
&lt;p&gt;直接抛出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;RejectedExecutionException
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;默认策略。&lt;/p&gt;
&lt;h3&gt;CallerRunsPolicy&lt;/h3&gt;
&lt;p&gt;由提交任务的调用线程自己执行任务。&lt;/p&gt;
&lt;p&gt;优点是能形成一定程度的反压，让提交任务的一方慢下来。&lt;/p&gt;
&lt;h3&gt;DiscardPolicy&lt;/h3&gt;
&lt;p&gt;直接丢弃任务，不抛异常。&lt;/p&gt;
&lt;h3&gt;DiscardOldestPolicy&lt;/h3&gt;
&lt;p&gt;丢弃队列中最老的任务，再尝试提交当前任务。&lt;/p&gt;
&lt;p&gt;生产环境中通常会自定义拒绝策略，至少应该包含：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;日志
监控指标
告警
降级或持久化补偿
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不能只是静默丢弃。JDK 文档给出了四种内置策略的准确行为。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/ThreadPoolExecutor.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/40139aa48ae34e5b94e0a1cf71327c49&quot;&gt;字节近期实习面经&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/006dc9b462dd495588abb764418dc71d&quot;&gt;美团近期面经&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;15. 线程池核心线程数应该怎么设置？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;不存在一个适用于所有项目的固定公式。&lt;/p&gt;
&lt;p&gt;可以用以下方式给出初始值：&lt;/p&gt;
&lt;h3&gt;CPU 密集型&lt;/h3&gt;
&lt;p&gt;任务主要做计算：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;线程数接近 CPU 核心数
或者 CPU 核心数 + 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;线程太多只会增加上下文切换。&lt;/p&gt;
&lt;h3&gt;I/O 密集型&lt;/h3&gt;
&lt;p&gt;任务经常等待数据库、网络、磁盘：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;线程数可以大于 CPU 核心数
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见估算：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;线程数 ≈ CPU 核心数 × (1 + 等待时间 / 计算时间)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但这只是初始估算。最终必须结合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CPU 利用率；&lt;/li&gt;
&lt;li&gt;任务执行耗时；&lt;/li&gt;
&lt;li&gt;队列长度；&lt;/li&gt;
&lt;li&gt;活跃线程数；&lt;/li&gt;
&lt;li&gt;拒绝次数；&lt;/li&gt;
&lt;li&gt;接口 P95、P99 延迟；&lt;/li&gt;
&lt;li&gt;数据库连接池；&lt;/li&gt;
&lt;li&gt;Redis、RPC 等下游容量；&lt;/li&gt;
&lt;li&gt;容器 CPU 限额。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;例如数据库连接池只有 20 个连接，即使线程池设置 200，也不代表能同时执行 200 个数据库任务。&lt;/p&gt;
&lt;p&gt;面试中最好回答：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;先通过任务性质和公式估算初始值，再通过压测、监控和下游容量逐步调整，不应该只背一个固定数字。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;拼多多和腾讯云面经已经从“背参数”进一步问到核心参数设计和动态调整。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/36fd5d6290724b25916fc105929bc085&quot;&gt;牛客网&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;五、ThreadLocal 高频题&lt;/h1&gt;
&lt;h2&gt;16. ThreadLocal 的底层原理是什么？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;每个线程内部维护自己的 &lt;code&gt;ThreadLocalMap&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;大致关系是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Thread
  └── ThreadLocalMap
          └── Entry[]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;threadLocal.set(value);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不是把值放进 ThreadLocal 对象自身，而是放进当前线程的 &lt;code&gt;ThreadLocalMap&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;key   = ThreadLocal 对象
value = 线程自己的数据
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此不同线程访问同一个 ThreadLocal 时，实际拿到的是各自线程中的独立副本。&lt;/p&gt;
&lt;p&gt;官方 API 将 ThreadLocal 描述为：每个访问它的线程都有独立初始化的变量副本。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/ThreadLocal.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/b16c3e91342244ae8e183728d524eccd&quot;&gt;腾讯云一面&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/742257c2ead94976a08d8a7256c841de&quot;&gt;字节跳动面经&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/1fab317e43ba4b518ba7c2c022ac10f0&quot;&gt;滴滴一面&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;17. ThreadLocal 为什么可能发生内存泄漏？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ThreadLocalMap.Entry&lt;/code&gt; 的结构是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;key：ThreadLocal 的弱引用
value：普通强引用
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当外部不再引用 ThreadLocal 后，GC 可以回收 key，于是 Entry 可能变成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;key = null
value = 某个业务对象
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是只要线程还活着：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Thread
→ ThreadLocalMap
→ Entry
→ value
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;value 仍然可能被强引用。&lt;/p&gt;
&lt;p&gt;在线程池中，工作线程通常长期存在，因此 value 可能长时间无法释放。&lt;/p&gt;
&lt;p&gt;正确做法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;try {
    threadLocal.set(value);
    // 使用数据
} finally {
    threadLocal.remove();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;仅仅因为 key 是弱引用，并不意味着一定不会泄漏。OpenJDK 源码明确说明，Entry 的 key 是弱引用，但没有使用引用队列，陈旧 Entry 只在部分访问、清理或空间不足时得到清除；value 则是普通强引用。(&lt;a href=&quot;https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/lang/ThreadLocal.java&quot;&gt;GitHub&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/b16c3e91342244ae8e183728d524eccd&quot;&gt;腾讯云一面&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/36fd5d6290724b25916fc105929bc085&quot;&gt;拼多多 Java 一面&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/742257c2ead94976a08d8a7256c841de&quot;&gt;字节跳动面经&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;18. ThreadLocal 如何跨线程传递？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;普通 &lt;code&gt;ThreadLocal&lt;/code&gt; 不会自动传递给其他线程。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;InheritableThreadLocal&lt;/code&gt; 可以在线程创建时，将父线程中的初始值传给子线程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;InheritableThreadLocal&amp;lt;String&amp;gt; context =
        new InheritableThreadLocal&amp;lt;&amp;gt;();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是它在线程池中通常不可靠，因为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;线程池工作线程可能早已创建；&lt;/li&gt;
&lt;li&gt;提交任务时并没有重新创建子线程；&lt;/li&gt;
&lt;li&gt;旧任务上下文可能残留在复用线程中。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此线程池中更常见的方案是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;提交任务前捕获上下文
→ 包装 Runnable/Callable
→ 工作线程执行前设置上下文
→ finally 中清理
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者使用框架提供的上下文传播机制。&lt;/p&gt;
&lt;p&gt;Oracle 文档明确说明，&lt;code&gt;InheritableThreadLocal&lt;/code&gt; 的值是在创建子线程时传递的，而不是每次提交线程池任务时传递。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/InheritableThreadLocal.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/40139aa48ae34e5b94e0a1cf71327c49&quot;&gt;字节近期实习面经&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/f256ed05fc4945e4a932a86bc0d01a5d&quot;&gt;TikTok 后端面经&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;六、并发容器高频题&lt;/h1&gt;
&lt;h2&gt;19. ConcurrentHashMap 如何保证线程安全？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;需要先区分 JDK 版本。&lt;/p&gt;
&lt;h3&gt;JDK 7&lt;/h3&gt;
&lt;p&gt;使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Segment 分段锁
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;JDK 8 及之后&lt;/h3&gt;
&lt;p&gt;不再以 Segment 作为普通并发控制结构，核心思路包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;普通查询尽量不加锁；&lt;/li&gt;
&lt;li&gt;对空桶初始化、插入等场景使用 CAS；&lt;/li&gt;
&lt;li&gt;桶发生竞争时，对桶头节点使用 &lt;code&gt;synchronized&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;链表过长时可能转成红黑树；&lt;/li&gt;
&lt;li&gt;扩容时多个线程可以协助迁移数据。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此面试不能只回答：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;ConcurrentHashMap 使用分段锁。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这只是旧版本口径。现代 JDK 官方文档说明其读取操作通常不需要加锁，也不存在阻塞整个表全部访问的全局表锁。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/ConcurrentHashMap.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;另外，ConcurrentHashMap 不允许：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;null key
null value
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为在并发环境中无法可靠区分：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;这个 key 不存在
还是这个 key 对应的 value 就是 null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/ConcurrentHashMap.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/14787ab5e61549ba8dafa24debae04c2&quot;&gt;腾讯 QQ 后台一面&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/36fd5d6290724b25916fc105929bc085&quot;&gt;拼多多 Java 一面&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;20. ConcurrentHashMap 的 size() 为什么可能不精确？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;在计算 &lt;code&gt;size()&lt;/code&gt; 的过程中，其他线程可能仍在插入或删除数据。&lt;/p&gt;
&lt;p&gt;因此在并发修改期间：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;size()
isEmpty()
containsValue()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;得到的结果更适合用于监控、估算或状态观察，不适合直接作为并发正确性判断条件。&lt;/p&gt;
&lt;p&gt;例如不要写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (map.size() &amp;lt; 100) {
    map.put(key, value);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为 &lt;code&gt;size()&lt;/code&gt; 和 &lt;code&gt;put()&lt;/code&gt; 不是一个原子操作。&lt;/p&gt;
&lt;p&gt;较大规模时可以使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mappingCount()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它返回 &lt;code&gt;long&lt;/code&gt;，但并发修改期间同样是估算值，不能把它当作锁或原子容量控制。&lt;/p&gt;
&lt;p&gt;需要高并发计数时，可以使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ConcurrentHashMap&amp;lt;K, LongAdder&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;官方文档也推荐使用 &lt;code&gt;LongAdder&lt;/code&gt; 构建高并发频率统计表。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/ConcurrentHashMap.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/36fd5d6290724b25916fc105929bc085&quot;&gt;拼多多 Java 一面&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;21. AtomicLong 和 LongAdder 有什么区别？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AtomicLong&lt;/code&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;所有线程竞争同一个值；&lt;/li&gt;
&lt;li&gt;使用 CAS 更新；&lt;/li&gt;
&lt;li&gt;可以获得单一变量的原子更新语义；&lt;/li&gt;
&lt;li&gt;低竞争场景简单直接。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;LongAdder&lt;/code&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;高竞争时把计数分散到多个 Cell；&lt;/li&gt;
&lt;li&gt;不同线程可以更新不同 Cell；&lt;/li&gt;
&lt;li&gt;读取总数时汇总 Base 和各个 Cell；&lt;/li&gt;
&lt;li&gt;高竞争统计场景下吞吐量通常更高；&lt;/li&gt;
&lt;li&gt;需要更多内存。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;适用场景：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;订单编号、状态控制、必须精确进行 CAS
→ AtomicLong

接口调用次数、监控指标、访问频率统计
→ LongAdder
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要用 LongAdder 参与“是否恰好等于某值”的并发流程控制。Oracle 文档说明 LongAdder 会在高竞争下动态维护多个变量，以空间换取吞吐量。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/atomic/LongAdder.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;七、异步任务与并发工具类&lt;/h1&gt;
&lt;h2&gt;22. Future 和 CompletableFuture 有什么区别？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;传统 &lt;code&gt;Future&lt;/code&gt; 主要支持：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;get()
cancel()
isDone()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;要获得结果通常需要调用 &lt;code&gt;get()&lt;/code&gt;，容易阻塞当前线程。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;CompletableFuture&lt;/code&gt; 支持：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;异步任务；&lt;/li&gt;
&lt;li&gt;回调；&lt;/li&gt;
&lt;li&gt;任务编排；&lt;/li&gt;
&lt;li&gt;多任务组合；&lt;/li&gt;
&lt;li&gt;异常处理。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;常见方法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;thenApply()       // 对结果进行转换
thenAccept()      // 消费结果
thenCompose()     // 串联存在依赖的异步任务
thenCombine()     // 合并两个相互独立任务的结果
allOf()           // 等待多个任务全部完成
anyOf()           // 任意任务完成
exceptionally()   // 异常恢复
handle()          // 同时处理成功和异常
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要区分：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;thenApply(x -&amp;gt; CompletableFuture&amp;lt;Y&amp;gt;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;会形成嵌套结构：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CompletableFuture&amp;lt;CompletableFuture&amp;lt;Y&amp;gt;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;存在异步依赖时一般使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;thenCompose(...)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;没有显式指定 Executor 的异步方法通常会使用 &lt;code&gt;ForkJoinPool.commonPool()&lt;/code&gt;，业务中的阻塞 I/O 一般应显式指定受控线程池，避免所有任务挤占公共池。CompletableFuture 官方定义就是一个同时实现 &lt;code&gt;Future&lt;/code&gt; 和 &lt;code&gt;CompletionStage&lt;/code&gt;、支持依赖动作的异步结果对象。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/CompletableFuture.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/b16c3e91342244ae8e183728d524eccd&quot;&gt;腾讯云一面&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/36fd5d6290724b25916fc105929bc085&quot;&gt;拼多多 Java 一面&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/46c0aba953de44628a7ebba0a8876577&quot;&gt;快手社招记录&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;23. CountDownLatch、CyclicBarrier 和 Semaphore 有什么区别？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;CountDownLatch&lt;/h3&gt;
&lt;p&gt;用于等待若干事件完成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;主线程等待 N 个子任务完成
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;子线程调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;countDown();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;主线程调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;await();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;计数到 0 后放行，而且不能重新设置，属于一次性工具。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/CountDownLatch.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;h3&gt;CyclicBarrier&lt;/h3&gt;
&lt;p&gt;让固定数量的线程互相等待，所有线程都到达屏障点后一起继续：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;线程1到达，等待
线程2到达，等待
线程3到达，全部放行
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它可以重复使用。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/CyclicBarrier.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;h3&gt;Semaphore&lt;/h3&gt;
&lt;p&gt;维护一定数量的许可证，用来限制并发量：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Semaphore semaphore = new Semaphore(10);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;表示最多允许 10 个线程同时访问某个资源。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/Semaphore.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;一句话记忆：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CountDownLatch：一个线程等多个事件
CyclicBarrier：多个线程互相等
Semaphore：控制同时进入的线程数量
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/006dc9b462dd495588abb764418dc71d&quot;&gt;美团近期后端面经&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/77c131736e184b4880c0d666d98045c6&quot;&gt;腾讯 S3 后端面经&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;24. sleep、wait 和 park 有什么区别？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方法&lt;/th&gt;
&lt;th&gt;是否必须持有锁&lt;/th&gt;
&lt;th&gt;是否释放锁&lt;/th&gt;
&lt;th&gt;唤醒方式&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Thread.sleep()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;否&lt;/td&gt;
&lt;td&gt;不释放已持有的锁&lt;/td&gt;
&lt;td&gt;时间到或中断&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Object.wait()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;是，必须持有该对象 Monitor&lt;/td&gt;
&lt;td&gt;释放调用对象对应的 Monitor&lt;/td&gt;
&lt;td&gt;&lt;code&gt;notify/notifyAll&lt;/code&gt;、中断或虚假唤醒&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LockSupport.park()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;否&lt;/td&gt;
&lt;td&gt;不涉及自动释放 Monitor&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unpark&lt;/code&gt;、中断或虚假唤醒&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;wait()&lt;/code&gt; 必须放在循环中检查条件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;synchronized (lock) {
    while (!condition) {
        lock.wait();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为线程可能发生虚假唤醒，而且被唤醒并不等于业务条件已经满足。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;park/unpark&lt;/code&gt; 使用许可证模型：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每个线程最多保存一个许可证；&lt;/li&gt;
&lt;li&gt;可以先 &lt;code&gt;unpark(thread)&lt;/code&gt;，再让该线程执行 &lt;code&gt;park()&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;如果已经存在许可证，&lt;code&gt;park()&lt;/code&gt; 会立即消费它并返回。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;JLS 明确说明 &lt;code&gt;sleep()&lt;/code&gt; 不释放 Monitor，而 &lt;code&gt;wait()&lt;/code&gt; 会释放当前调用对象对应的 Monitor；LockSupport 文档则说明 &lt;code&gt;park()&lt;/code&gt; 在没有许可证时暂停线程调度。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-17.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;25. Java 线程有哪些状态？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Java 定义六种线程状态：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NEW
RUNNABLE
BLOCKED
WAITING
TIMED_WAITING
TERMINATED
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重点区别：&lt;/p&gt;
&lt;h3&gt;RUNNABLE&lt;/h3&gt;
&lt;p&gt;Java 层面把“正在 CPU 上运行”和“等待操作系统分配 CPU”都归入 RUNNABLE。&lt;/p&gt;
&lt;h3&gt;BLOCKED&lt;/h3&gt;
&lt;p&gt;只表示：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;线程正在等待进入 synchronized 对应的 Monitor。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;等待 ReentrantLock 不一定显示为 BLOCKED，通常可能显示为 WAITING。&lt;/p&gt;
&lt;h3&gt;WAITING&lt;/h3&gt;
&lt;p&gt;无限期等待，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Object.wait()
Thread.join()
LockSupport.park()
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;TIMED_WAITING&lt;/h3&gt;
&lt;p&gt;带超时时间等待，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Thread.sleep()
Object.wait(timeout)
Thread.join(timeout)
LockSupport.parkNanos()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Oracle 的 &lt;code&gt;Thread.State&lt;/code&gt; API 对六种状态及 BLOCKED 的含义有明确说明。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Thread.State.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/c7c5f594a9af435081511a9f8259952a&quot;&gt;百度面经&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;26. interrupt 是不是强制终止线程？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;不是。&lt;/p&gt;
&lt;p&gt;Java 的中断是一种&lt;strong&gt;协作式通知机制&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;thread.interrupt();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;通常只是设置线程的中断标志，线程需要自己检查并决定是否退出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;while (!Thread.currentThread().isInterrupted()) {
    // 执行任务
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果线程阻塞在：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sleep()
wait()
join()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可能抛出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;InterruptedException
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而且抛出异常后，中断标志通常会被清除。&lt;/p&gt;
&lt;p&gt;如果当前方法不能继续向上抛出异常，常见做法是恢复中断状态：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    return;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要直接空着：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;catch (InterruptedException e) {
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;否则相当于吞掉了取消信号。JLS 对中断、等待和中断状态清除行为进行了规定。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-17.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;八、场景题和排障题&lt;/h1&gt;
&lt;h2&gt;27. 死锁产生的必要条件是什么？如何避免？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;死锁通常需要同时满足四个条件：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;互斥条件；&lt;/li&gt;
&lt;li&gt;请求并保持；&lt;/li&gt;
&lt;li&gt;不可剥夺；&lt;/li&gt;
&lt;li&gt;循环等待。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;例如转账：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;线程1：先锁账户A，再锁账户B
线程2：先锁账户B，再锁账户A
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可能形成循环等待。&lt;/p&gt;
&lt;p&gt;常见解决方式：&lt;/p&gt;
&lt;h3&gt;固定加锁顺序&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;先锁 ID 较小的账户
再锁 ID 较大的账户
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;使用 tryLock 和超时&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;if (lock.tryLock(1, TimeUnit.SECONDS)) {
    try {
        // ...
    } finally {
        lock.unlock();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;减小锁范围&lt;/h3&gt;
&lt;p&gt;不要在持锁期间执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;RPC
数据库慢查询
网络请求
长时间文件操作
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;线上排查&lt;/h3&gt;
&lt;p&gt;可以执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;jcmd &amp;lt;pid&amp;gt; Thread.print -l
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者导出线程信息：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;jcmd &amp;lt;pid&amp;gt; Thread.dump_to_file thread-dump.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重点查看：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;BLOCKED 线程
等待的锁
持有该锁的线程
是否形成循环依赖
大量线程是否停在相同调用栈
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JDK 25 的 &lt;code&gt;jcmd&lt;/code&gt; 官方文档支持直接打印所有线程栈，也支持将线程栈导出为文本或 JSON。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/specs/man/jcmd.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/c852db0682e344448b1eb9590216f77a&quot;&gt;京东实习面经&lt;/a&gt;、&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/b16c3e91342244ae8e183728d524eccd&quot;&gt;腾讯云一面&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;28. 系统突然变慢，如何从并发角度排查？&lt;/h2&gt;
&lt;p&gt;这是腾讯云近期面经出现的典型场景题。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/b16c3e91342244ae8e183728d524eccd&quot;&gt;牛客网&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;标准回答框架：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;第一步，先确定范围：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;所有接口都慢
还是某一个接口慢
单个实例慢
还是所有实例都慢
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第二步，看基础指标：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CPU
内存
GC
负载
线程数
连接数
接口 P95/P99
错误率
线程池活跃线程数
队列长度
拒绝次数
数据库连接池使用率
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第三步，分析线程栈：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;jcmd &amp;lt;pid&amp;gt; Thread.print -l
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见现象：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;大量 RUNNABLE 且 CPU 很高：可能死循环、热点计算、自旋严重；&lt;/li&gt;
&lt;li&gt;大量 BLOCKED：可能 synchronized 锁竞争；&lt;/li&gt;
&lt;li&gt;大量 WAITING：可能线程池、连接池、AQS 等待；&lt;/li&gt;
&lt;li&gt;大量线程卡在数据库驱动：数据库慢或者连接池耗尽；&lt;/li&gt;
&lt;li&gt;大量线程卡在 RPC：下游服务慢；&lt;/li&gt;
&lt;li&gt;大量线程停在同一锁：锁粒度过大；&lt;/li&gt;
&lt;li&gt;线程池队列持续增加：消费能力不足；&lt;/li&gt;
&lt;li&gt;大量任务被拒绝：线程数和队列达到上限。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;第四步，结合链路追踪和慢调用日志定位具体接口、SQL、RPC 或锁竞争位置。&lt;/p&gt;
&lt;p&gt;面试回答不要只说“看日志”，而要给出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;指标定位范围
→ 线程栈定位等待点
→ 链路追踪定位接口
→ 下游指标验证根因
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;29. 不同并发场景应该选择什么锁？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;简单临界区&lt;/h3&gt;
&lt;p&gt;优先考虑：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;synchronized
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;代码简单、自动释放，不容易漏解锁。&lt;/p&gt;
&lt;h3&gt;需要超时、可中断、公平性、多个等待条件&lt;/h3&gt;
&lt;p&gt;使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ReentrantLock
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;读多写少&lt;/h3&gt;
&lt;p&gt;考虑：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ReentrantReadWriteLock
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但前提是读操作足够长、竞争足够明显；非常短的临界区未必比普通互斥锁快。&lt;/p&gt;
&lt;h3&gt;简单计数&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;AtomicLong
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;高竞争统计：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;LongAdder
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;限制同时访问下游的请求数&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Semaphore
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;多个线程协作等待&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;CountDownLatch
CyclicBarrier
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;不同 JVM 实例之间互斥&lt;/h3&gt;
&lt;p&gt;本地锁不够，需要考虑：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;数据库唯一约束
Redis 分布式锁
ZooKeeper
业务幂等设计
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;答题重点不是罗列所有锁，而是先说清楚：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;是否跨进程
读写比例
竞争程度
临界区时长
是否需要超时
是否允许中断
是否要求公平
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;腾讯云近期面经已经直接考察“各种场景下如何选择合适的锁”。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/b16c3e91342244ae8e183728d524eccd&quot;&gt;牛客网&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;九、进阶热点&lt;/h1&gt;
&lt;h2&gt;30. ForkJoinPool 的工作窃取是什么？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;ForkJoinPool 适合能够递归拆分的计算任务。&lt;/p&gt;
&lt;p&gt;每个工作线程维护自己的双端队列：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;工作线程优先从自己的队列取任务
自己的任务执行完后
去其他工作线程队列窃取任务
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样可以减少线程之间争抢同一个全局队列，提高 CPU 密集型分治任务的利用率。&lt;/p&gt;
&lt;p&gt;典型场景：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;大数组并行计算
递归分治
并行 Stream
RecursiveTask
RecursiveAction
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不适合未经控制地执行大量长时间阻塞 I/O，因为工作线程阻塞后可能影响公共池中其他任务。&lt;/p&gt;
&lt;p&gt;Oracle 官方并发包文档将 ForkJoinPool 定义为使用 work-stealing 调度器、主要面向计算密集型并行处理的执行器。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/package-summary.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/36fd5d6290724b25916fc105929bc085&quot;&gt;拼多多 Java 一面&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;31. 虚拟线程是什么？能替代线程池吗？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;标准回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;虚拟线程仍然是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;java.lang.Thread
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但不会长期绑定一个操作系统线程。虚拟线程执行阻塞 I/O 时，Java 运行时可以挂起虚拟线程，让底层操作系统线程继续执行其他虚拟线程。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/core/virtual-threads.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;虚拟线程适合：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;高并发
阻塞式 I/O
一个请求一个线程
大量网络或数据库等待
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不代表：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CPU 计算会变快
单个请求延迟一定降低
可以忽略数据库连接池限制
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;正确使用方式通常是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;try (var executor =
         Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(task);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;虚拟线程应当“一任务一线程”，不应该像平台线程一样维护固定数量的虚拟线程池。Oracle 官方文档直接建议不要池化虚拟线程。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/core/virtual-threads.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;需要限制并发访问下游时，应使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;数据库连接池
Semaphore
限流器
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而不是通过“只创建 20 个虚拟线程”来控制。&lt;/p&gt;
&lt;p&gt;另外，不建议给大量虚拟线程使用 ThreadLocal 缓存昂贵对象，因为每个虚拟线程都可能拥有自己的副本，造成较高内存消耗。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/core/virtual-threads.html&quot;&gt;Oracle Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对应面经：&lt;/strong&gt;
&lt;a href=&quot;https://www.nowcoder.com/discuss/630411236341002240&quot;&gt;网易相关虚拟线程面试题&lt;/a&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;十、手撕并发题：三个线程交替打印 1、2、3&lt;/h1&gt;
&lt;p&gt;TikTok 面经出现了“三个线程交替打印”的题目。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/f256ed05fc4945e4a932a86bc0d01a5d&quot;&gt;牛客网&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;一种常见思路是使用三个 Semaphore：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import java.util.concurrent.Semaphore;

public class AlternatePrint {

    private static final Semaphore S1 = new Semaphore(1);
    private static final Semaphore S2 = new Semaphore(0);
    private static final Semaphore S3 = new Semaphore(0);

    public static void main(String[] args) {
        Thread t1 = new Thread(() -&amp;gt; print(1, S1, S2));
        Thread t2 = new Thread(() -&amp;gt; print(2, S2, S3));
        Thread t3 = new Thread(() -&amp;gt; print(3, S3, S1));

        t1.start();
        t2.start();
        t3.start();
    }

    private static void print(
            int number,
            Semaphore current,
            Semaphore next) {

        for (int i = 0; i &amp;lt; 10; i++) {
            try {
                current.acquire();
                System.out.print(number);
                next.release();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;初始状态：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;S1 有 1 个许可证
S2 有 0 个许可证
S3 有 0 个许可证
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;执行顺序：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;线程1打印1，唤醒线程2
线程2打印2，唤醒线程3
线程3打印3，唤醒线程1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最终输出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;123123123123...
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;十一、最容易答错的五个点&lt;/h1&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;错误回答&lt;/th&gt;
&lt;th&gt;正确说法&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JMM 就是堆、栈、方法区&lt;/td&gt;
&lt;td&gt;JMM 是多线程内存访问规则；堆、栈属于 JVM 运行时内存区域&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;volatile 能保证 &lt;code&gt;count++&lt;/code&gt; 线程安全&lt;/td&gt;
&lt;td&gt;volatile 不保证复合操作原子性&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;volatile int[]&lt;/code&gt; 能保证所有元素线程安全&lt;/td&gt;
&lt;td&gt;volatile 修饰的是数组引用，不等于每个元素都具有 volatile 或原子语义&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ConcurrentHashMap 一直使用 Segment 分段锁&lt;/td&gt;
&lt;td&gt;Segment 是 JDK 7 典型实现；JDK 8 以后主要使用 CAS、桶级 synchronized 等机制&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ThreadLocal 的 key 是弱引用，所以不会泄漏&lt;/td&gt;
&lt;td&gt;key 可以被回收，但 value 仍可能被长生命周期线程强引用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;synchronized 永远按偏向锁、轻量级锁、重量级锁升级&lt;/td&gt;
&lt;td&gt;这是传统 HotSpot/JDK 8 口径；JDK 15 起偏向锁默认关闭&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;线程池越大吞吐量越高&lt;/td&gt;
&lt;td&gt;还受 CPU、连接池、下游容量、队列和上下文切换影响&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;虚拟线程就是创建一个超大的固定线程池&lt;/td&gt;
&lt;td&gt;应当一任务一个虚拟线程，不要池化虚拟线程&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;最值得先背熟的 15 道&lt;/h2&gt;
&lt;p&gt;准备国内互联网大厂 Java 实习或校招时，建议先将下面这些题练到能在 &lt;strong&gt;60～90 秒内完整回答&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. JMM 和 JVM 内存区域的区别
2. happens-before
3. volatile 的作用与局限
4. synchronized 原理
5. synchronized 和 ReentrantLock 的区别
6. 锁升级及 JDK 版本差异
7. CAS 与 ABA
8. AQS 原理
9. 线程池七个参数
10. 线程池任务执行流程
11. 阻塞队列和拒绝策略
12. ThreadLocal 原理与内存泄漏
13. ConcurrentHashMap JDK 8 原理
14. CompletableFuture 任务编排
15. 死锁和线上线程排查
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这 15 道覆盖了目前字节、腾讯、美团、阿里、拼多多、京东、快手、百度和滴滴公开面经中重复度最高的并发主题。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/b16c3e91342244ae8e183728d524eccd&quot;&gt;牛客网&lt;/a&gt;)&lt;/p&gt;
</content:encoded></item><item><title>Java 集合框架高频面试题</title><link>https://blog.huangnv.online/posts/2681/3/3/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2681/3/3/</guid><pubDate>Sat, 01 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Java 基础面试分类：集合框架&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;筛选口径：优先保留近年国内大厂候选人面经中反复出现的问题，再用 JDK API 和 OpenJDK 源码校正答案。候选人面经属于回忆材料，用于判断频率，不作为 Java 规则的最终依据。&lt;a href=&quot;https://www.nowcoder.com/discuss/601813614772662272&quot;&gt;面经证据 1&lt;/a&gt; &lt;a href=&quot;https://www.nowcoder.com/discuss/603257053682941952&quot;&gt;面经证据 2&lt;/a&gt; &lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/536f42609577444baf4b9bee8e9807f6&quot;&gt;面经证据 3&lt;/a&gt; &lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/1b3714d9fe754609bf2488cbb9610b76&quot;&gt;面经证据 4&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;集合框架的高频主线是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ArrayList 与 LinkedList
→ HashMap 的结构、put、扩容、树化
→ HashMap 的线程安全问题
→ ConcurrentHashMap
→ HashSet、LinkedHashMap、TreeMap
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;优先级说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;★★★：多家公司反复出现，需要能连续口述并应对追问。&lt;/li&gt;
&lt;li&gt;★★：常见关联题，需要掌握结论和使用场景。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;1. 集合框架整体&lt;/h2&gt;
&lt;h3&gt;★★★ 1. List、Set、Map 有什么区别？&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Collection
├── List
├── Set
└── Queue

Map
└── key-value
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;List&lt;/code&gt;：元素有顺序、允许重复，可以通过下标访问。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Set&lt;/code&gt;：不允许重复元素。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Map&lt;/code&gt;：保存键值对，key 不能重复。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Map&lt;/code&gt; 与 &lt;code&gt;Collection&lt;/code&gt; 是两个独立的顶层体系。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h1&gt;List&lt;/h1&gt;
&lt;h2&gt;2. ArrayList&lt;/h2&gt;
&lt;h3&gt;★★★ 1. ArrayList 的底层结构和扩容机制是什么？&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;ArrayList&lt;/code&gt; 底层是动态扩容的 &lt;code&gt;Object[]&lt;/code&gt; 数组。&lt;a href=&quot;https://docs.oracle.com/javase/8/docs/api/java/util/ArrayList.html&quot;&gt;JDK API&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;按 JDK 8 常见源码口径：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;new ArrayList&amp;lt;&amp;gt;()&lt;/code&gt; 先使用共享空数组。&lt;/li&gt;
&lt;li&gt;第一次添加元素时，默认容量扩到 &lt;code&gt;10&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;后续容量不足时，新容量通常是旧容量的 &lt;code&gt;1.5&lt;/code&gt; 倍。&lt;/li&gt;
&lt;li&gt;创建新数组并复制原数组中的元素。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;核心计算类似：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;newCapacity = oldCapacity + (oldCapacity &amp;gt;&amp;gt; 1);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见复杂度：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;get(index)&lt;/code&gt;、&lt;code&gt;set(index)&lt;/code&gt;：&lt;code&gt;O(1)&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;尾部添加：摊销 &lt;code&gt;O(1)&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;中间插入或删除：通常为 &lt;code&gt;O(n)&lt;/code&gt;，因为需要移动后续元素。&lt;/li&gt;
&lt;li&gt;按值查找：&lt;code&gt;O(n)&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;已知元素数量较大时，可以在构造时指定容量，减少扩容和数组复制：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;List&amp;lt;Integer&amp;gt; list = new ArrayList&amp;lt;&amp;gt;(10000);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;★★★ 2. ArrayList 和 LinkedList 有什么区别？&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;对比项&lt;/th&gt;
&lt;th&gt;&lt;code&gt;ArrayList&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;LinkedList&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;底层结构&lt;/td&gt;
&lt;td&gt;动态数组&lt;/td&gt;
&lt;td&gt;双向链表&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;随机访问&lt;/td&gt;
&lt;td&gt;&lt;code&gt;O(1)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;O(n)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;尾部添加&lt;/td&gt;
&lt;td&gt;摊销 &lt;code&gt;O(1)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;O(1)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;中间插入、删除&lt;/td&gt;
&lt;td&gt;定位快，通常需要移动元素&lt;/td&gt;
&lt;td&gt;定位通常为 &lt;code&gt;O(n)&lt;/code&gt;，定位后修改指针&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;内存开销&lt;/td&gt;
&lt;td&gt;相对较小&lt;/td&gt;
&lt;td&gt;每个节点还保存前驱、后继引用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;缓存友好性&lt;/td&gt;
&lt;td&gt;较好&lt;/td&gt;
&lt;td&gt;较弱&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;额外接口&lt;/td&gt;
&lt;td&gt;&lt;code&gt;List&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;List&lt;/code&gt;、&lt;code&gt;Deque&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;LinkedList.get(index)&lt;/code&gt; 会从距离目标更近的一端开始遍历，整体复杂度仍是 &lt;code&gt;O(n)&lt;/code&gt;。&lt;a href=&quot;https://docs.oracle.com/javase/8/docs/api/java/util/LinkedList.html&quot;&gt;JDK API&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;选择口径：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;查询、遍历和尾部追加较多：通常优先考虑 &lt;code&gt;ArrayList&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;频繁操作队头、队尾：可以考虑 &lt;code&gt;LinkedList&lt;/code&gt;，实际作为队列时也要比较 &lt;code&gt;ArrayDeque&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;链表只有在已经定位到目标节点后，修改指针才是 &lt;code&gt;O(1)&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;★★ 3. ArrayList 为什么线程不安全？线程安全场景怎么选？&lt;/h3&gt;
&lt;p&gt;多个线程同时修改 &lt;code&gt;ArrayList&lt;/code&gt; 时，可能发生元素覆盖、更新丢失、&lt;code&gt;size&lt;/code&gt; 不准确，以及扩容期间状态不一致。&lt;/p&gt;
&lt;p&gt;常见选择：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Collections.synchronizedList(...)&lt;/code&gt;：通过同步包装原列表。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CopyOnWriteArrayList&lt;/code&gt;：写入时复制数组，适合读多写少、允许读取弱一致快照的场景。&lt;/li&gt;
&lt;li&gt;使用外部锁：需要把多个集合操作组合成一个原子过程时使用。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;Vector&lt;/code&gt; 的许多方法带同步，属于早期集合类。它只能保证单次方法调用的同步，多个方法组合仍需额外的并发控制，因此面试通常把它合并到本题比较，不必单独背扩容细节。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;Set&lt;/h1&gt;
&lt;h2&gt;3. HashSet&lt;/h2&gt;
&lt;h3&gt;★★★ 1. HashSet 的底层是什么？如何保证元素不重复？&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;HashSet&lt;/code&gt; 底层使用 &lt;code&gt;HashMap&lt;/code&gt;：&lt;a href=&quot;https://docs.oracle.com/javase/8/docs/api/java/util/HashSet.html&quot;&gt;JDK API&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HashSet 中的元素 → HashMap 的 key
统一占位对象       → HashMap 的 value
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;添加元素时，先根据 &lt;code&gt;hashCode()&lt;/code&gt; 定位桶，再通过引用比较或 &lt;code&gt;equals()&lt;/code&gt; 判断桶中是否已经存在相同元素。因此自定义元素要按照约定正确实现 &lt;code&gt;equals()&lt;/code&gt; 和 &lt;code&gt;hashCode()&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;其他常见结论：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不保证迭代顺序。&lt;/li&gt;
&lt;li&gt;可以保存一个 &lt;code&gt;null&lt;/code&gt; 元素，因为底层 &lt;code&gt;HashMap&lt;/code&gt; 允许一个 &lt;code&gt;null&lt;/code&gt; key。&lt;/li&gt;
&lt;li&gt;哈希分布良好时，添加、删除和查询平均接近 &lt;code&gt;O(1)&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;★★ 2. HashSet、LinkedHashSet、TreeSet 怎么选择？&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;实现&lt;/th&gt;
&lt;th&gt;顺序&lt;/th&gt;
&lt;th&gt;底层与特点&lt;/th&gt;
&lt;th&gt;常见复杂度&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HashSet&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;不保证顺序&lt;/td&gt;
&lt;td&gt;基于 &lt;code&gt;HashMap&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;平均 &lt;code&gt;O(1)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LinkedHashSet&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;维护插入顺序&lt;/td&gt;
&lt;td&gt;哈希结构加双向链表&lt;/td&gt;
&lt;td&gt;平均 &lt;code&gt;O(1)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TreeSet&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;按自然顺序或比较器排序&lt;/td&gt;
&lt;td&gt;基于 &lt;code&gt;TreeMap&lt;/code&gt; 红黑树&lt;/td&gt;
&lt;td&gt;&lt;code&gt;O(log n)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;TreeSet&lt;/code&gt; 使用 &lt;code&gt;Comparable&lt;/code&gt; 或 &lt;code&gt;Comparator&lt;/code&gt; 比较元素；比较结果为 &lt;code&gt;0&lt;/code&gt; 时，会把两个元素视为重复元素。&lt;a href=&quot;https://docs.oracle.com/javase/8/docs/api/java/util/TreeSet.html&quot;&gt;JDK API&lt;/a&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;Map&lt;/h1&gt;
&lt;h2&gt;4. HashMap&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 是集合部分最重要的八股。近期美团、腾讯、阿里等候选人面经都出现了底层结构、扩容、负载因子、树化和线程安全追问。&lt;a href=&quot;https://www.nowcoder.com/discuss/601813614772662272&quot;&gt;面经证据 1&lt;/a&gt; &lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/536f42609577444baf4b9bee8e9807f6&quot;&gt;面经证据 3&lt;/a&gt; &lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/1b3714d9fe754609bf2488cbb9610b76&quot;&gt;面经证据 4&lt;/a&gt; &lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/af7578c21bd24530bd8db37694fdc2b0&quot;&gt;面经证据 5&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;★★★ 1. HashMap 的底层结构是什么？如何解决哈希冲突？&lt;/h3&gt;
&lt;p&gt;JDK 8 的典型结构是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;数组 + 链表 + 红黑树
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;数组中的每个位置称为桶。不同 key 落入同一个桶时，通过链地址法存放在链表或红黑树中。&lt;/p&gt;
&lt;p&gt;树化的常见源码条件：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;桶内节点数达到 &lt;code&gt;8&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;数组容量至少为 &lt;code&gt;64&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;两个条件同时满足时才会树化。桶内节点较多但数组容量小于 &lt;code&gt;64&lt;/code&gt; 时，会优先扩容。节点减少到一定程度时，红黑树可能退化为链表；JDK 8 的退化阈值常量是 &lt;code&gt;6&lt;/code&gt;。&lt;a href=&quot;https://github.com/openjdk/jdk8u/blob/master/jdk/src/share/classes/java/util/HashMap.java&quot;&gt;OpenJDK 源码&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;★★★ 2. HashMap 的 put 过程是什么？&lt;/h3&gt;
&lt;p&gt;口述顺序：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;计算 key 的扰动后 hash。&lt;/li&gt;
&lt;li&gt;根据数组长度计算桶下标。&lt;/li&gt;
&lt;li&gt;桶为空时直接插入。&lt;/li&gt;
&lt;li&gt;桶不为空时，先判断桶首节点的 hash 和 key。&lt;/li&gt;
&lt;li&gt;key 相同时覆盖旧 value。&lt;/li&gt;
&lt;li&gt;key 不同时，在链表或红黑树中继续查找并插入。&lt;/li&gt;
&lt;li&gt;链表过长时判断树化条件。&lt;/li&gt;
&lt;li&gt;插入后元素数量超过阈值时扩容。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;面试时需要明确：相同 key 的 &lt;code&gt;put&lt;/code&gt; 会更新 value，通常不会增加 &lt;code&gt;size&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;★★★ 3. HashMap 的 get 过程是什么？&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;计算 key 的扰动后 hash。&lt;/li&gt;
&lt;li&gt;根据数组长度找到桶下标。&lt;/li&gt;
&lt;li&gt;先检查桶首节点。&lt;/li&gt;
&lt;li&gt;桶是链表时顺序比较，桶是红黑树时按树结构查找。&lt;/li&gt;
&lt;li&gt;通过 hash 加引用比较或 &lt;code&gt;equals()&lt;/code&gt; 确认具体 key。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;平均查询复杂度接近 &lt;code&gt;O(1)&lt;/code&gt;；哈希冲突严重且没有树化时可能退化到 &lt;code&gt;O(n)&lt;/code&gt;；树化后的最坏查找复杂度约为 &lt;code&gt;O(log n)&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;★★★ 4. HashMap 的默认容量、负载因子和扩容机制是什么？&lt;/h3&gt;
&lt;p&gt;常见 JDK 8 口径：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;默认初始容量：16
默认负载因子：0.75
扩容阈值：capacity × loadFactor
每次扩容：通常扩大为原容量的 2 倍
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;默认构造方法采用延迟初始化，真正的桶数组通常在第一次 &lt;code&gt;put&lt;/code&gt; 时创建。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;0.75&lt;/code&gt; 是空间利用率和哈希冲突概率之间的经验折中：负载因子越大，空间利用率越高，冲突概率也会增加；负载因子越小，冲突减少，扩容更早、空间占用更多。&lt;a href=&quot;https://docs.oracle.com/javase/8/docs/api/java/util/HashMap.html&quot;&gt;JDK API&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;★★★ 5. HashMap 为什么把容量设计为 2 的幂？hash 为什么要高位异或低位？&lt;/h3&gt;
&lt;p&gt;JDK 8 常见计算方式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hash = h ^ (h &amp;gt;&amp;gt;&amp;gt; 16);
index = (n - 1) &amp;amp; hash;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;容量为 2 的幂时，&lt;code&gt;(n - 1) &amp;amp; hash&lt;/code&gt; 可以高效计算桶下标。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;n - 1&lt;/code&gt; 的低位全部是 &lt;code&gt;1&lt;/code&gt;，能够较均匀地利用 hash 的低位。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;h ^ (h &amp;gt;&amp;gt;&amp;gt; 16)&lt;/code&gt; 把高位信息混入低位，减少只使用低位造成的碰撞。&lt;/li&gt;
&lt;li&gt;扩容为两倍后，节点只需留在原下标或移动到“原下标 + 旧容量”，迁移判断更高效。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;★★★ 6. HashMap 怎样判断两个 key 相同？为什么 key 最好保持不可变？&lt;/h3&gt;
&lt;p&gt;查找 key 时通常先比较 hash，再判断引用是否相同或 &lt;code&gt;equals()&lt;/code&gt; 是否为 &lt;code&gt;true&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;自定义 key 应满足 &lt;code&gt;equals()&lt;/code&gt; 与 &lt;code&gt;hashCode()&lt;/code&gt; 的约定：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;equals()&lt;/code&gt; 相等的对象必须具有相同的 &lt;code&gt;hashCode()&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;hashCode()&lt;/code&gt; 相同的对象，&lt;code&gt;equals()&lt;/code&gt; 可以不同。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对象放入 &lt;code&gt;HashMap&lt;/code&gt; 后，如果参与 &lt;code&gt;hashCode()&lt;/code&gt; 或 &lt;code&gt;equals()&lt;/code&gt; 的字段被修改，后续查询可能计算出不同的桶位置，导致原有映射无法正常找到。因此 key 最好使用不可变对象，或至少保证参与相等性判断的字段不再变化。&lt;/p&gt;
&lt;h3&gt;★★ 7. HashMap 可以保存 null 吗？&lt;/h3&gt;
&lt;p&gt;可以保存：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一个 &lt;code&gt;null&lt;/code&gt; key。&lt;/li&gt;
&lt;li&gt;多个 &lt;code&gt;null&lt;/code&gt; value。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;get(key) == null&lt;/code&gt; 同时可能表示“key 不存在”或“key 对应的 value 是 null”，需要时可以结合 &lt;code&gt;containsKey(key)&lt;/code&gt; 判断。&lt;a href=&quot;https://docs.oracle.com/javase/8/docs/api/java/util/HashMap.html&quot;&gt;JDK API&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;★★★ 8. HashMap 为什么线程不安全？JDK 7 和 JDK 8 有什么主要区别？&lt;/h3&gt;
&lt;p&gt;并发修改普通 &lt;code&gt;HashMap&lt;/code&gt; 时，可能出现更新覆盖、数据丢失、&lt;code&gt;size&lt;/code&gt; 不准确，以及扩容或结构调整期间的状态不一致。它没有提供并发读写的安全保证。&lt;/p&gt;
&lt;p&gt;版本差异：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;版本&lt;/th&gt;
&lt;th&gt;主要结构&lt;/th&gt;
&lt;th&gt;重要变化&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JDK 7&lt;/td&gt;
&lt;td&gt;数组 + 链表&lt;/td&gt;
&lt;td&gt;冲突节点使用链表&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JDK 8&lt;/td&gt;
&lt;td&gt;数组 + 链表 + 红黑树&lt;/td&gt;
&lt;td&gt;冲突严重时支持树化，并调整扩容迁移方式&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;并发场景通常使用 &lt;code&gt;ConcurrentHashMap&lt;/code&gt;，需要复合原子操作时优先使用它提供的 &lt;code&gt;putIfAbsent()&lt;/code&gt;、&lt;code&gt;compute()&lt;/code&gt;、&lt;code&gt;merge()&lt;/code&gt; 等方法。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;5. 常见 Map 实现对比&lt;/h2&gt;
&lt;h3&gt;★★★ 1. HashMap、Hashtable、ConcurrentHashMap 有什么区别？&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;对比项&lt;/th&gt;
&lt;th&gt;&lt;code&gt;HashMap&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;Hashtable&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;ConcurrentHashMap&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;线程安全&lt;/td&gt;
&lt;td&gt;不保证&lt;/td&gt;
&lt;td&gt;多数公开方法使用同步&lt;/td&gt;
&lt;td&gt;支持高并发访问&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt; key/value&lt;/td&gt;
&lt;td&gt;允许&lt;/td&gt;
&lt;td&gt;都不允许&lt;/td&gt;
&lt;td&gt;都不允许&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;并发控制&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;同步粒度较大&lt;/td&gt;
&lt;td&gt;JDK 8 主要使用 CAS、桶级 &lt;code&gt;synchronized&lt;/code&gt; 等机制&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;使用场景&lt;/td&gt;
&lt;td&gt;普通单线程或外部同步场景&lt;/td&gt;
&lt;td&gt;旧式兼容代码&lt;/td&gt;
&lt;td&gt;并发读写&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;JDK 7 的 &lt;code&gt;ConcurrentHashMap&lt;/code&gt; 使用 &lt;code&gt;Segment&lt;/code&gt; 分段锁；JDK 8 的主要存储结构改为 Node 数组、链表和红黑树，并结合 CAS、桶级同步和协作扩容。这一版本差异和扩容机制在近期美团面经中仍有直接追问。&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/565d06d34df14235929f0710a3e2085a&quot;&gt;面经证据 6&lt;/a&gt; &lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/a3cb8da1dd574942903b579b5b821973&quot;&gt;面经证据 7&lt;/a&gt; &lt;a href=&quot;https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ConcurrentHashMap.html&quot;&gt;JDK API&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;它禁止 &lt;code&gt;null&lt;/code&gt; 的核心考虑是并发语义：当 &lt;code&gt;get(key)&lt;/code&gt; 返回 &lt;code&gt;null&lt;/code&gt; 时，可以明确表示当前没有对应映射，避免在两次检查之间发生并发变化所带来的歧义。&lt;/p&gt;
&lt;p&gt;完整实现原理和 &lt;code&gt;put&lt;/code&gt;、&lt;code&gt;get&lt;/code&gt;、扩容追问见：[[JVM并发编程面试题#19. ConcurrentHashMap 如何保证线程安全？]]&lt;/p&gt;
&lt;h3&gt;★★ 2. LinkedHashMap 如何维护顺序并实现 LRU？&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;LinkedHashMap&lt;/code&gt; 可以理解为 &lt;code&gt;HashMap&lt;/code&gt; 加双向链表：&lt;a href=&quot;https://docs.oracle.com/javase/8/docs/api/java/util/LinkedHashMap.html&quot;&gt;JDK API&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;默认按插入顺序迭代。&lt;/li&gt;
&lt;li&gt;构造时设置 &lt;code&gt;accessOrder = true&lt;/code&gt; 后，按访问顺序维护链表。&lt;/li&gt;
&lt;li&gt;重写 &lt;code&gt;removeEldestEntry()&lt;/code&gt; 可以在新增元素后淘汰最老节点，构造简单的 LRU 缓存。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;new LinkedHashMap&amp;lt;&amp;gt;(16, 0.75f, true);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这类简单 LRU 实现仍需自行处理线程安全和容量策略。&lt;/p&gt;
&lt;h3&gt;★★ 3. HashMap 和 TreeMap 怎么选择？&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;对比项&lt;/th&gt;
&lt;th&gt;&lt;code&gt;HashMap&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;TreeMap&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;底层&lt;/td&gt;
&lt;td&gt;哈希表&lt;/td&gt;
&lt;td&gt;红黑树&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;顺序&lt;/td&gt;
&lt;td&gt;不保证&lt;/td&gt;
&lt;td&gt;key 按自然顺序或 &lt;code&gt;Comparator&lt;/code&gt; 排序&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;核心操作&lt;/td&gt;
&lt;td&gt;平均 &lt;code&gt;O(1)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;O(log n)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;适合场景&lt;/td&gt;
&lt;td&gt;普通键值查询&lt;/td&gt;
&lt;td&gt;排序、范围查询、查找相邻 key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;TreeMap&lt;/code&gt; 根据 &lt;code&gt;compareTo()&lt;/code&gt; 或 &lt;code&gt;Comparator.compare()&lt;/code&gt; 的结果判断 key 的顺序；比较结果为 &lt;code&gt;0&lt;/code&gt; 时，会把两个 key 视为同一个 key。&lt;a href=&quot;https://docs.oracle.com/javase/8/docs/api/java/util/TreeMap.html&quot;&gt;JDK API&lt;/a&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;建议优先背诵顺序&lt;/h2&gt;
&lt;p&gt;第一优先级：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;HashMap&lt;/code&gt; 的底层结构、哈希冲突和树化条件。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;HashMap&lt;/code&gt; 的 &lt;code&gt;put&lt;/code&gt;、&lt;code&gt;get&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;默认容量、负载因子和扩容机制。&lt;/li&gt;
&lt;li&gt;容量为什么是 2 的幂，hash 如何计算。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;equals()&lt;/code&gt;、&lt;code&gt;hashCode()&lt;/code&gt; 与 key 不可变性。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;HashMap&lt;/code&gt; 为什么线程不安全。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 的 JDK 7、JDK 8 区别。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ArrayList&lt;/code&gt; 扩容及其与 &lt;code&gt;LinkedList&lt;/code&gt; 的区别。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;第二优先级：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;HashSet&lt;/code&gt; 如何去重。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LinkedHashMap&lt;/code&gt; 与 LRU。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;HashMap&lt;/code&gt;、&lt;code&gt;Hashtable&lt;/code&gt;、&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 对比。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TreeMap&lt;/code&gt;、&lt;code&gt;TreeSet&lt;/code&gt; 的排序依据。&lt;/li&gt;
&lt;li&gt;线程安全 List 的选择。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;筛选结果说明&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;保留并强化：&lt;code&gt;ArrayList&lt;/code&gt;、&lt;code&gt;LinkedList&lt;/code&gt;、&lt;code&gt;HashMap&lt;/code&gt;、&lt;code&gt;ConcurrentHashMap&lt;/code&gt;、&lt;code&gt;HashSet&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;合并：&lt;code&gt;Vector&lt;/code&gt; 合并到线程安全 List；&lt;code&gt;LinkedHashSet&lt;/code&gt;、&lt;code&gt;TreeSet&lt;/code&gt; 合并为 Set 选型题；&lt;code&gt;Hashtable&lt;/code&gt; 合并为 Map 并发对比题。&lt;/li&gt;
&lt;li&gt;跨主题去重：&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 的实现细节保留在并发笔记，本文件保留集合视角的核心对比和入口。&lt;/li&gt;
&lt;li&gt;删除独立低频细节：&lt;code&gt;Vector&lt;/code&gt; 扩容倍数、&lt;code&gt;LinkedHashSet&lt;/code&gt; 重复插入顺序、&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 迭代器与 &lt;code&gt;size()&lt;/code&gt; 等细碎问题。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;资料来源&lt;/h2&gt;
&lt;h3&gt;国内公司候选人面经：用于判断频率&lt;/h3&gt;
&lt;h3&gt;官方资料：用于校正答案&lt;/h3&gt;
</content:encoded></item><item><title>计算机网络高频面试题</title><link>https://blog.huangnv.online/posts/2681/2/2/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2681/2/2/</guid><pubDate>Sat, 01 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;国内互联网大厂计算机网络高频八股题&lt;/h1&gt;
&lt;p&gt;我主要筛选了字节跳动、腾讯、美团、阿里云、蚂蚁、京东、拼多多等公司的后端面经。这里的“热点题目”不是根据某一篇题库判断，而是根据不同公司、不同年份的面经中反复出现的题目归纳。&lt;/p&gt;
&lt;p&gt;从筛选结果来看，优先级最高的是：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TCP 三次握手与四次挥手、TCP 可靠性、TCP/UDP 区别、TIME_WAIT/CLOSE_WAIT、HTTP/HTTPS、TLS、DNS、输入 URL 后发生什么、HTTP 版本区别、HTTP 状态码。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;近几年明显增加的题目是：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;HTTP/2、HTTP/3、QUIC、WebSocket、CORS、TCP 粘包拆包、select/poll/epoll。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;例如，字节面经直接出现了粘包、HTTPS 改造、DNS/TCP/HTTP 请求全过程；腾讯面经出现了 HTTP/1.1、HTTP/2、HTTP/3、QUIC、TLS 1.3、拥塞控制；近期多家公司汇总中仍然反复出现 URL 全过程、epoll、TCP 可靠性、TIME_WAIT、CLOSE_WAIT 和 SYN Flood。(&lt;a href=&quot;https://www.nowcoder.com/discuss/746424726964154368&quot;&gt;牛客网&lt;/a&gt;)&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;面经属于候选人的个人记录，并不是公司官方题库，因此不能保证每场面试都会问；但用来判断重复出现的高频知识点非常合适。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h1&gt;一、网络模型与基础流程&lt;/h1&gt;
&lt;h2&gt;1. OSI 七层模型和 TCP/IP 模型分别是什么？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;面试回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;OSI 七层模型从上到下是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;应用层&lt;/li&gt;
&lt;li&gt;表示层&lt;/li&gt;
&lt;li&gt;会话层&lt;/li&gt;
&lt;li&gt;传输层&lt;/li&gt;
&lt;li&gt;网络层&lt;/li&gt;
&lt;li&gt;数据链路层&lt;/li&gt;
&lt;li&gt;物理层&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;TCP/IP 四层模型是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;应用层：HTTP、DNS、SMTP&lt;/li&gt;
&lt;li&gt;传输层：TCP、UDP&lt;/li&gt;
&lt;li&gt;网际层：IP、ICMP&lt;/li&gt;
&lt;li&gt;网络接口层：以太网、Wi-Fi 等&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;面试中也经常使用“五层模型”，即把网络接口层拆成数据链路层和物理层。&lt;/p&gt;
&lt;p&gt;数据发送时会不断封装：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;应用数据
  ↓
TCP 段 / UDP 数据报
  ↓
IP 数据包
  ↓
以太网帧
  ↓
比特流
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;接收端按照相反顺序解封装。TCP/IP 标准体系通常划分为应用层、传输层、网际层和链路层。(&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc1122.html&quot;&gt;RFC 编辑器&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;2. TCP 和 UDP 有什么区别？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;面试回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;TCP 是面向连接、可靠、有序、全双工的字节流协议；UDP 是无连接、尽力而为、保留报文边界的数据报协议。&lt;/p&gt;
&lt;p&gt;主要区别如下：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;对比项&lt;/th&gt;
&lt;th&gt;TCP&lt;/th&gt;
&lt;th&gt;UDP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;是否连接&lt;/td&gt;
&lt;td&gt;需要建立连接&lt;/td&gt;
&lt;td&gt;不需要建立连接&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;可靠性&lt;/td&gt;
&lt;td&gt;有确认、重传、排序&lt;/td&gt;
&lt;td&gt;协议本身不保证&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;数据形式&lt;/td&gt;
&lt;td&gt;字节流，没有消息边界&lt;/td&gt;
&lt;td&gt;数据报，有消息边界&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;顺序&lt;/td&gt;
&lt;td&gt;保证有序&lt;/td&gt;
&lt;td&gt;不保证有序&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;流量控制&lt;/td&gt;
&lt;td&gt;有&lt;/td&gt;
&lt;td&gt;没有内置&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;拥塞控制&lt;/td&gt;
&lt;td&gt;有&lt;/td&gt;
&lt;td&gt;没有内置&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;开销&lt;/td&gt;
&lt;td&gt;较大&lt;/td&gt;
&lt;td&gt;较小&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;常见场景&lt;/td&gt;
&lt;td&gt;HTTP/1.1、HTTP/2、MySQL、Redis&lt;/td&gt;
&lt;td&gt;实时音视频、游戏、DNS、QUIC&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;一个常见错误是说“UDP 一定不可靠”。更准确的说法是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;UDP 协议本身不提供可靠性，但应用层可以基于 UDP 实现可靠传输，例如 QUIC 就在 UDP 之上实现了确认、重传、流量控制和拥塞控制。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;TCP 提供可靠、按序、面向连接的字节流服务；UDP 提供最小化的、非保证交付的数据报服务。(&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc1122.html&quot;&gt;RFC 编辑器&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;二、TCP 核心高频题&lt;/h1&gt;
&lt;h2&gt;3. TCP 是怎么保证可靠传输的？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;面试回答：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;TCP 主要通过以下机制保证可靠性：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;连接管理&lt;/strong&gt;：三次握手建立连接。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;序列号&lt;/strong&gt;：每个字节都有序列号，用来排序和去重。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;确认机制 ACK&lt;/strong&gt;：接收方告诉发送方已经收到哪些数据。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;校验和&lt;/strong&gt;：发现数据在传输过程中是否损坏。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;超时重传&lt;/strong&gt;：长时间没有收到 ACK，就重新发送。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;快速重传&lt;/strong&gt;：收到多个重复 ACK 时，不等待超时就重传。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SACK&lt;/strong&gt;：告诉发送方哪些不连续的数据块已经收到。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;滑动窗口&lt;/strong&gt;：允许批量发送，提高吞吐量。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;流量控制&lt;/strong&gt;：避免发送方把接收方缓冲区打满。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;拥塞控制&lt;/strong&gt;：避免发送速度超过网络承载能力。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;有序重组和重复数据丢弃&lt;/strong&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;需要注意：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;TCP 的“可靠”是指数据尽量完整、有序、不重复地到达，不代表数据经过了加密，也不代表绝对不会断开连接。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;TCP 标准定义了可靠、有序的字节流传输；RTO 根据 RTT 和波动动态计算，SACK 可以报告已经收到的不连续数据块。(&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc1122.html&quot;&gt;RFC 编辑器&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;4. TCP 三次握手的过程是什么？&lt;/h2&gt;
&lt;p&gt;假设客户端初始序列号是 &lt;code&gt;x&lt;/code&gt;，服务端初始序列号是 &lt;code&gt;y&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;客户端                              服务端

SYN=1, seq=x
   ------------------------------&amp;gt;

                     SYN=1, ACK=1
                     seq=y, ack=x+1
   &amp;lt;------------------------------

ACK=1, seq=x+1, ack=y+1
   ------------------------------&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;具体过程：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;客户端发送 &lt;code&gt;SYN&lt;/code&gt;，进入 &lt;code&gt;SYN_SENT&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;服务端收到后发送 &lt;code&gt;SYN + ACK&lt;/code&gt;，进入 &lt;code&gt;SYN_RECEIVED&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;客户端发送 &lt;code&gt;ACK&lt;/code&gt;，双方进入 &lt;code&gt;ESTABLISHED&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;三次握手不仅是建立连接，还会同步双方的初始序列号。(&lt;a href=&quot;https://www.rfc-editor.org/info/rfc9293/?utm_source=chatgpt.com&quot;&gt;RFC 编辑器&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;5. 为什么 TCP 建立连接需要三次握手，不能两次？&lt;/h2&gt;
&lt;p&gt;核心原因是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;双方都需要确认自己的发送能力和接收能力正常，并同步双方的初始序列号。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;两次握手之后：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;客户端知道：自己能发、自己能收、服务端能收、服务端能发。&lt;/li&gt;
&lt;li&gt;服务端只知道：自己收到了客户端的 SYN，并且自己发出了 SYN+ACK。&lt;/li&gt;
&lt;li&gt;服务端不知道：客户端是否成功收到自己的 SYN+ACK。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;第三次 ACK 能让服务端确认客户端确实收到了服务端的初始序列号。&lt;/p&gt;
&lt;p&gt;三次握手还可以降低历史失效 SYN 导致错误建连的风险。假如网络中延迟很久的旧 SYN 到达服务端，服务端返回 SYN+ACK，但当前客户端不会为这条旧连接发送正确 ACK，因此连接无法建立。&lt;/p&gt;
&lt;p&gt;至于为什么不需要四次，是因为第三次 ACK 已经完成了双方确认；如果 ACK 也必须被再次确认，就会产生无限确认问题。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;6. 第三次握手的 ACK 丢失会怎么样？&lt;/h2&gt;
&lt;p&gt;客户端发送第三次 ACK 后，客户端通常已经认为连接建立成功，但服务端仍处于 &lt;code&gt;SYN_RECEIVED&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;服务端没有收到 ACK，会重新发送 &lt;code&gt;SYN+ACK&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;客户端收到重传的 SYN+ACK
        ↓
再次发送 ACK
        ↓
服务端进入 ESTABLISHED
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果客户端直接发送了携带有效 ACK 的业务数据，服务端也可能根据该 ACK 完成连接建立。&lt;/p&gt;
&lt;p&gt;如果服务端始终收不到有效 ACK，经过多次重传和超时后，就会清理这条半连接。TCP 三次握手及 SYN/ACK 重传行为由 TCP 状态机和重传机制共同完成。(&lt;a href=&quot;https://www.rfc-editor.org/info/rfc9293/?utm_source=chatgpt.com&quot;&gt;RFC 编辑器&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;7. TCP 为什么是四次挥手？&lt;/h2&gt;
&lt;p&gt;TCP 是全双工通信，两个方向必须分别关闭。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;客户端                              服务端

FIN
   ------------------------------&amp;gt;

ACK
   &amp;lt;------------------------------

                         服务端处理完剩余数据
                         调用 close()

FIN
   &amp;lt;------------------------------

ACK
   ------------------------------&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;过程是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;主动关闭方发送 FIN，表示自己不再发送数据。&lt;/li&gt;
&lt;li&gt;被动关闭方返回 ACK，但它可能还有数据没有发送完。&lt;/li&gt;
&lt;li&gt;被动关闭方处理完数据后，再发送 FIN。&lt;/li&gt;
&lt;li&gt;主动关闭方返回 ACK。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;为什么通常比握手多一次？&lt;/p&gt;
&lt;p&gt;因为服务端收到 FIN 后，只能立即确认“我知道你不发了”，但服务端自己可能还没有发送完数据，所以 ACK 和 FIN 通常分开发送。&lt;/p&gt;
&lt;p&gt;如果服务端收到 FIN 后也立即关闭，ACK 和 FIN 可以合并，因此抓包时有可能看到三次报文完成关闭，并不是所有连接都严格表现为四个独立报文。(&lt;a href=&quot;https://www.rfc-editor.org/info/rfc1122/?utm_source=chatgpt.com&quot;&gt;RFC 编辑器&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;10. TCP 流量控制和拥塞控制有什么区别？&lt;/h2&gt;
&lt;h3&gt;流量控制&lt;/h3&gt;
&lt;p&gt;解决的是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;接收方处理不过来怎么办？&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;接收方通过 TCP 头中的接收窗口 &lt;code&gt;rwnd&lt;/code&gt; 告诉发送方自己还能接收多少数据，防止接收缓冲区被打满。&lt;/p&gt;
&lt;h3&gt;拥塞控制&lt;/h3&gt;
&lt;p&gt;解决的是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;网络中间链路承载不过来怎么办？&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;发送方维护拥塞窗口 &lt;code&gt;cwnd&lt;/code&gt;，根据丢包、ACK、RTT 等信息调整发送速度。&lt;/p&gt;
&lt;p&gt;发送方实际能发送的数据量受二者共同限制：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;实际发送窗口 = min(rwnd, cwnd)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;记忆：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rwnd：保护接收方
cwnd：保护整个网络
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;TCP 的拥塞窗口和接收窗口共同限制发送量。(&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc5681.html&quot;&gt;RFC 编辑器&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;14. HTTP 长连接、TCP Keepalive 和应用心跳有什么区别？&lt;/h2&gt;
&lt;h3&gt;HTTP 长连接&lt;/h3&gt;
&lt;p&gt;表示多个 HTTP 请求复用同一条 TCP 或 QUIC 连接，减少重复建连开销。&lt;/p&gt;
&lt;h3&gt;TCP Keepalive&lt;/h3&gt;
&lt;p&gt;是操作系统 TCP 层的探测机制。连接长时间空闲后发送探测包，判断对端是否已经消失。&lt;/p&gt;
&lt;p&gt;它通常：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;需要显式开启。&lt;/li&gt;
&lt;li&gt;默认探测时间可能很长。&lt;/li&gt;
&lt;li&gt;只能判断 TCP 对端是否仍有响应。&lt;/li&gt;
&lt;li&gt;不能证明业务线程一定正常。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;应用层心跳&lt;/h3&gt;
&lt;p&gt;应用主动定期发送业务心跳，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PING
PONG
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它可以更快发现：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;网络中断。&lt;/li&gt;
&lt;li&gt;服务进程卡死。&lt;/li&gt;
&lt;li&gt;业务线程不响应。&lt;/li&gt;
&lt;li&gt;网关或 NAT 清理空闲连接。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以实际长连接服务经常同时使用 TCP Keepalive 和应用层心跳。Linux 的 TCP Keepalive 通过空闲时间、探测间隔和探测次数等参数控制。(&lt;a href=&quot;https://man7.org/linux/man-pages/man7/tcp.7.html&quot;&gt;man7.org&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;15. 什么是 SYN Flood？&lt;/h2&gt;
&lt;p&gt;攻击者向服务端发送大量 SYN，但不完成第三次握手：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;攻击者：大量 SYN
服务端：创建半连接并返回 SYN+ACK
攻击者：不返回 ACK
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;大量伪造半连接会占满半连接队列，使正常用户无法建立连接。&lt;/p&gt;
&lt;p&gt;常见防御：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SYN Cookie。&lt;/li&gt;
&lt;li&gt;SYN Cache。&lt;/li&gt;
&lt;li&gt;限制单 IP 建连速率。&lt;/li&gt;
&lt;li&gt;防火墙、负载均衡器清洗流量。&lt;/li&gt;
&lt;li&gt;合理增加半连接队列。&lt;/li&gt;
&lt;li&gt;减少 SYN+ACK 重试时间和次数。&lt;/li&gt;
&lt;li&gt;源地址过滤。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;SYN Flood 的核心就是利用大量伪造半连接占满 backlog。(&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc4987.html&quot;&gt;RFC 编辑器&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;16. TCP 和 UDP 可以使用相同端口吗？&lt;/h2&gt;
&lt;p&gt;可以。&lt;/p&gt;
&lt;p&gt;例如，一个程序可以同时监听：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;TCP 53
UDP 53
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为操作系统区分连接时不仅看端口，还会看传输层协议：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;协议 + 本地 IP + 本地端口 + 远端 IP + 远端端口
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;TCP 和 UDP 有各自独立的端口空间。&lt;/p&gt;
&lt;p&gt;但是在&lt;strong&gt;同一种协议&lt;/strong&gt;下，两个 Socket 通常不能随意绑定到完全相同的本地 IP 和端口，除非使用特定的地址复用选项并满足操作系统规则。&lt;/p&gt;
&lt;p&gt;这个问题曾直接出现在阿里云后端面经中。(&lt;a href=&quot;https://www.nowcoder.com/discuss/590576768491216896&quot;&gt;牛客网&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;三、DNS、ARP 与访问网站全过程&lt;/h1&gt;
&lt;h2&gt;18. DNS 域名解析过程是什么？&lt;/h2&gt;
&lt;p&gt;假设访问：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;www.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见流程是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;浏览器检查自己的缓存。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;操作系统检查 DNS 缓存和 hosts 文件。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;客户端的 Stub Resolver 向递归 DNS 服务器查询。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;递归 DNS 如果有缓存，直接返回。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;没有缓存时，递归 DNS 依次查询：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;根 DNS。&lt;/li&gt;
&lt;li&gt;顶级域 DNS，例如 &lt;code&gt;.com&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;权威 DNS。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;权威 DNS 返回 A、AAAA 或 CNAME 等记录。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;递归 DNS 根据 TTL 缓存结果并返回客户端。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;需要注意：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;DNS 并没有固定的“必须经过五级缓存”。浏览器、操作系统、递归解析器、企业网关等是否缓存，取决于具体实现。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;DNS 经典体系由递归解析器、根服务器、顶级域服务器、权威服务器和缓存共同完成。(&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc1034&quot;&gt;RFC 编辑器&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;19. ARP 是什么？访问不同网段的服务器时 ARP 谁？&lt;/h2&gt;
&lt;p&gt;ARP 用来根据 IPv4 地址查询同一链路上的 MAC 地址。&lt;/p&gt;
&lt;h3&gt;目标和自己在同一个网段&lt;/h3&gt;
&lt;p&gt;客户端 ARP 查询目标服务器的 MAC：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;目标 IP → 目标 MAC
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;目标和自己不在同一个网段&lt;/h3&gt;
&lt;p&gt;客户端不会 ARP 查询远程服务器的 MAC，而是查询默认网关的 MAC：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;默认网关 IP → 默认网关 MAC
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后把数据帧发送给网关。&lt;/p&gt;
&lt;p&gt;跨路由器传输时：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;目标 IP 通常仍然是最终服务器 IP，忽略 NAT 情况。&lt;/li&gt;
&lt;li&gt;源 MAC 和目标 MAC 会在每一跳重新封装和变化。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;记忆：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;IP 地址负责端到端定位
MAC 地址负责当前这一跳的链路传输
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;ARP 标准描述了协议地址到以太网地址的映射过程。(&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc826.html&quot;&gt;RFC 编辑器&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;20. 浏览器输入 URL 后发生了什么？&lt;/h2&gt;
&lt;p&gt;这是最重要的综合题之一。&lt;/p&gt;
&lt;p&gt;以 HTTPS 网站为例，可以按下面的顺序回答。&lt;/p&gt;
&lt;h3&gt;第一步：解析 URL&lt;/h3&gt;
&lt;p&gt;浏览器解析：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;协议、域名、端口、路径、查询参数
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;并检查：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;浏览器缓存。&lt;/li&gt;
&lt;li&gt;Service Worker。&lt;/li&gt;
&lt;li&gt;HSTS。&lt;/li&gt;
&lt;li&gt;已有连接是否可以复用。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;第二步：DNS 解析&lt;/h3&gt;
&lt;p&gt;将域名解析成服务器或 CDN 节点的 IP 地址。&lt;/p&gt;
&lt;h3&gt;第三步：确定下一跳&lt;/h3&gt;
&lt;p&gt;操作系统根据路由表判断数据发给：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;同网段目标。&lt;/li&gt;
&lt;li&gt;默认网关。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;然后通过 ARP 获取下一跳 MAC 地址。&lt;/p&gt;
&lt;h3&gt;第四步：建立传输连接&lt;/h3&gt;
&lt;p&gt;根据协议不同：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HTTP/1.1、HTTP/2：
TCP 三次握手 → TLS 握手

HTTP/3：
QUIC + TLS 1.3 握手
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;第五步：发送 HTTP 请求&lt;/h3&gt;
&lt;p&gt;请求中包含：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;请求方法
路径
请求头
Cookie/Token
可选请求体
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;第六步：服务端处理&lt;/h3&gt;
&lt;p&gt;请求可能经过：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CDN
→ 负载均衡
→ Nginx / 网关
→ 后端服务
→ Redis / MySQL / RPC
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;第七步：返回 HTTP 响应&lt;/h3&gt;
&lt;p&gt;包含状态码、响应头和响应体。&lt;/p&gt;
&lt;h3&gt;第八步：浏览器渲染&lt;/h3&gt;
&lt;p&gt;大致过程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;解析 HTML 构建 DOM
解析 CSS 构建 CSSOM
生成渲染树
布局
绘制
合成
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同时还会下载 JavaScript、CSS、图片等子资源。&lt;/p&gt;
&lt;p&gt;回答时最好补充：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;实际过程中，DNS、TCP、TLS 都可能因为缓存和连接复用而被跳过；使用 HTTP/3 时也不存在传统 TCP 三次握手。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;URL 全过程、DNS、TCP、HTTP、HTTPS 是字节、京东、美团和近期后端面经中反复出现的组合题。(&lt;a href=&quot;https://www.nowcoder.com/discuss/746424726964154368&quot;&gt;牛客网&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;四、HTTP 与 HTTPS 高频题&lt;/h1&gt;
&lt;h2&gt;21. HTTP 和 HTTPS 有什么区别？&lt;/h2&gt;
&lt;p&gt;HTTPS 本质上是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HTTP + TLS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;HTTP 负责请求方法、状态码、请求头、响应体等语义；TLS 负责：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;机密性：防止内容被窃听。&lt;/li&gt;
&lt;li&gt;完整性：防止内容被篡改。&lt;/li&gt;
&lt;li&gt;身份认证：通常验证服务端身份。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;常见默认端口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HTTP：80
HTTPS：443
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但端口只是默认约定，并不是协议本质。&lt;/p&gt;
&lt;p&gt;HTTPS 不能解决所有安全问题，例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用户访问的本身就是钓鱼网站。&lt;/li&gt;
&lt;li&gt;服务端已经被入侵。&lt;/li&gt;
&lt;li&gt;客户端主动忽略证书错误。&lt;/li&gt;
&lt;li&gt;Token 或密码在应用层被泄露。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;TLS 1.3 的设计目标包括防窃听、防篡改和防消息伪造。(&lt;a href=&quot;https://www.rfc-editor.org/info/rfc8446/?utm_source=chatgpt.com&quot;&gt;RFC 编辑器&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;22. HTTPS 的 TLS 1.3 握手过程是什么？&lt;/h2&gt;
&lt;p&gt;可以使用下面这个简化版本回答。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;客户端                              服务端

ClientHello
版本、加密套件、随机数、
key_share、SNI、ALPN
   ------------------------------&amp;gt;

                     ServerHello
                     选择参数和 key_share
   &amp;lt;------------------------------

双方计算共享密钥，后续握手开始加密

                     EncryptedExtensions
                     Certificate
                     CertificateVerify
                     Finished
   &amp;lt;------------------------------

Finished
   ------------------------------&amp;gt;

开始传输加密的 HTTP 数据
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;主要步骤：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;客户端发送 &lt;code&gt;ClientHello&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;服务端发送 &lt;code&gt;ServerHello&lt;/code&gt;，双方根据临时密钥交换计算共享密钥。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;服务端发送证书和证书签名证明。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;客户端验证：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;证书链。&lt;/li&gt;
&lt;li&gt;CA 签名。&lt;/li&gt;
&lt;li&gt;域名。&lt;/li&gt;
&lt;li&gt;有效期。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;双方发送 Finished，确认握手没有被篡改。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用对称 AEAD 算法加密业务数据。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;需要纠正一个过时的八股答案：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;现代 TLS 1.3 通常不是“服务端给 RSA 公钥，客户端生成对称密钥并用 RSA 加密”。TLS 1.3 通常通过临时的 ECDHE 等方式协商共享密钥，证书主要用于身份认证和签名证明，业务数据再使用高效的对称加密算法保护。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;TLS 1.3 标准定义了 ClientHello、ServerHello、Certificate、CertificateVerify 和 Finished 等握手消息，并将大部分握手内容加密。(&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc8446.html&quot;&gt;RFC 编辑器&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;23. 如何把 HTTP 服务升级为 HTTPS？&lt;/h2&gt;
&lt;p&gt;面试可以回答下面几步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;申请可信 CA 签发的证书。&lt;/li&gt;
&lt;li&gt;安全保存私钥和完整证书链。&lt;/li&gt;
&lt;li&gt;在 Nginx、负载均衡器或应用服务器配置 TLS。&lt;/li&gt;
&lt;li&gt;监听 HTTPS 端口。&lt;/li&gt;
&lt;li&gt;将 HTTP 请求重定向到 HTTPS。&lt;/li&gt;
&lt;li&gt;清理页面中的 HTTP 混合内容。&lt;/li&gt;
&lt;li&gt;配置证书自动续期。&lt;/li&gt;
&lt;li&gt;服务稳定后再考虑 HSTS。&lt;/li&gt;
&lt;li&gt;禁用过时 TLS 版本和弱密码套件。&lt;/li&gt;
&lt;li&gt;保证内部回源、网关和服务间调用的安全策略一致。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;字节后台面经曾直接询问“如何把 HTTP 服务器升级成 HTTPS”。(&lt;a href=&quot;https://www.nowcoder.com/discuss/746424726964154368&quot;&gt;牛客网&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;24. HTTP/1.0、HTTP/1.1、HTTP/2、HTTP/3 有什么区别？&lt;/h2&gt;
&lt;h3&gt;HTTP/1.0&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;通常一个连接处理一个请求。&lt;/li&gt;
&lt;li&gt;大量请求需要频繁建立连接。&lt;/li&gt;
&lt;li&gt;长连接主要依赖扩展方式实现。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;HTTP/1.1&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;默认支持持久连接。&lt;/li&gt;
&lt;li&gt;增加 &lt;code&gt;Host&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;支持分块传输。&lt;/li&gt;
&lt;li&gt;支持管线化，但响应顺序会造成应用层队头阻塞。&lt;/li&gt;
&lt;li&gt;浏览器通常通过建立多个 TCP 连接提升并发。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;HTTP/2&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;二进制分帧。&lt;/li&gt;
&lt;li&gt;一个 TCP 连接内建立多个 Stream。&lt;/li&gt;
&lt;li&gt;多路复用。&lt;/li&gt;
&lt;li&gt;HPACK 头部压缩。&lt;/li&gt;
&lt;li&gt;解决 HTTP/1.1 应用层的队头阻塞。&lt;/li&gt;
&lt;li&gt;仍然存在 TCP 层队头阻塞。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;HTTP/3&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;基于 QUIC。&lt;/li&gt;
&lt;li&gt;QUIC 基于 UDP 承载。&lt;/li&gt;
&lt;li&gt;集成 TLS 1.3。&lt;/li&gt;
&lt;li&gt;使用 QPACK。&lt;/li&gt;
&lt;li&gt;不同流之间相对独立。&lt;/li&gt;
&lt;li&gt;支持连接迁移。&lt;/li&gt;
&lt;li&gt;降低建连延迟和跨流队头阻塞。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;腾讯后台面试曾直接要求从 HTTP/1.1 一直讲到 HTTP/3，并继续追问 HPACK、QPACK、TLS 1.3 和 QUIC。(&lt;a href=&quot;https://www.nowcoder.com/discuss/353159162152558592&quot;&gt;牛客网&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;25. HTTP/2 是否彻底解决了队头阻塞？&lt;/h2&gt;
&lt;p&gt;没有。&lt;/p&gt;
&lt;h3&gt;HTTP/1.1 的队头阻塞&lt;/h3&gt;
&lt;p&gt;同一条管线中，前面的响应没有完成，后面的响应不能越过它返回，这是应用层队头阻塞。&lt;/p&gt;
&lt;h3&gt;HTTP/2 做了什么？&lt;/h3&gt;
&lt;p&gt;HTTP/2 将请求拆成帧，不同 Stream 的帧可以交错发送，因此解决了 HTTP 层面的请求响应顺序阻塞。&lt;/p&gt;
&lt;h3&gt;为什么还存在 TCP 队头阻塞？&lt;/h3&gt;
&lt;p&gt;HTTP/2 的多个流共用一条 TCP 连接。&lt;/p&gt;
&lt;p&gt;如果某个 TCP 报文段丢失，由于 TCP 必须按序向上层交付，后面已经到达的数据也要等待这个缺失报文重传。因此一个 TCP 丢包可能暂时阻塞多个 HTTP/2 Stream。&lt;/p&gt;
&lt;p&gt;HTTP/3 使用 QUIC 的独立流，使一个流的数据丢失通常不会阻塞其他无关流；但同一个流内部仍然需要保证有序交付。HTTP/2 标准明确说明，它解决了应用层队头阻塞，但没有解决 TCP 层队头阻塞。(&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9113.html&quot;&gt;RFC 编辑器&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;27. GET 和 POST 有什么区别？&lt;/h2&gt;
&lt;p&gt;不要只回答“GET 参数在 URL、POST 参数在请求体”，这只是常见使用方式，不是最本质区别。&lt;/p&gt;
&lt;h3&gt;语义区别&lt;/h3&gt;
&lt;p&gt;GET：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;获取目标资源的表示，语义上是只读的。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;POST：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;让目标资源处理所携带的内容，例如创建订单、提交表单、执行某项操作。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;安全性和幂等性&lt;/h3&gt;
&lt;p&gt;GET 是安全方法，也是幂等方法。&lt;/p&gt;
&lt;p&gt;安全方法表示客户端没有请求服务端改变资源状态；幂等表示相同请求执行一次和执行多次，预期效果相同。&lt;/p&gt;
&lt;p&gt;POST 默认既不是安全方法，也不保证幂等。&lt;/p&gt;
&lt;h3&gt;参数位置&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;GET 参数通常放在 URL 查询字符串。&lt;/li&gt;
&lt;li&gt;POST 数据通常放在请求体。&lt;/li&gt;
&lt;li&gt;HTTP 规范并没有用这一点来定义 GET 和 POST 的本质区别。&lt;/li&gt;
&lt;li&gt;GET 携带请求体没有通用明确语义，很多服务器和中间件也不会支持，因此一般不要这样设计。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;安全性误区&lt;/h3&gt;
&lt;p&gt;POST 并不天然比 GET 安全。&lt;/p&gt;
&lt;p&gt;在 HTTP 明文传输下，两者都可能被看到；在 HTTPS 下，请求路径、请求头和请求体通常都受到 TLS 保护，但域名、IP、流量特征等元数据未必全部隐藏。&lt;/p&gt;
&lt;p&gt;RFC 9110 将 GET 等方法定义为安全方法，将安全方法以及 PUT、DELETE 定义为幂等方法。(&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9110.html&quot;&gt;RFC 编辑器&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;28. 常见 HTTP 状态码有哪些？&lt;/h2&gt;
&lt;h3&gt;2xx：请求成功&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;200 OK&lt;/code&gt;：请求成功。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;201 Created&lt;/code&gt;：资源创建成功。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;204 No Content&lt;/code&gt;：成功，但没有响应体。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3xx：重定向和缓存&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;301 Moved Permanently&lt;/code&gt;：永久重定向。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;302 Found&lt;/code&gt;：临时重定向。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;304 Not Modified&lt;/code&gt;：缓存内容没有变化。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;307 Temporary Redirect&lt;/code&gt;：临时重定向，并保持原请求方法和请求体。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;308 Permanent Redirect&lt;/code&gt;：永久重定向，并保持原请求方法和请求体。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;301、302 在历史客户端中可能把 POST 改为 GET；307、308 明确要求保持请求方法和请求体。&lt;/p&gt;
&lt;h3&gt;4xx：客户端相关问题&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;400 Bad Request&lt;/code&gt;：请求格式或参数错误。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;401 Unauthorized&lt;/code&gt;：没有完成身份认证。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;403 Forbidden&lt;/code&gt;：服务端理解请求，但拒绝授权访问。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;404 Not Found&lt;/code&gt;：资源不存在。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;405 Method Not Allowed&lt;/code&gt;：请求方法不允许。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;409 Conflict&lt;/code&gt;：资源状态冲突。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;429 Too Many Requests&lt;/code&gt;：请求频率过高。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;5xx：服务端或上游问题&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;500 Internal Server Error&lt;/code&gt;：服务端内部错误。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;502 Bad Gateway&lt;/code&gt;：网关从上游收到无效响应。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;503 Service Unavailable&lt;/code&gt;：服务暂时不可用或过载。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;504 Gateway Timeout&lt;/code&gt;：网关等待上游超时。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;腾讯微信面经直接从 301、302 一路追问到三次握手、四次挥手和 TIME_WAIT。(&lt;a href=&quot;https://www.nowcoder.com/discuss/600598995727048704&quot;&gt;牛客网&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;29. HTTP 请求和响应报文由什么组成？&lt;/h2&gt;
&lt;p&gt;以 HTTP/1.1 为例。&lt;/p&gt;
&lt;h3&gt;请求报文&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;POST /orders HTTP/1.1
Host: example.com
Content-Type: application/json
Content-Length: 18

{&quot;productId&quot;:100}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;包含：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;请求行。&lt;/li&gt;
&lt;li&gt;请求头。&lt;/li&gt;
&lt;li&gt;空行。&lt;/li&gt;
&lt;li&gt;可选请求体。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;响应报文&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 15

{&quot;status&quot;:&quot;ok&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;包含：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;状态行。&lt;/li&gt;
&lt;li&gt;响应头。&lt;/li&gt;
&lt;li&gt;空行。&lt;/li&gt;
&lt;li&gt;可选响应体。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;HTTP/2 和 HTTP/3 使用二进制帧传输，不再按照 HTTP/1.1 的纯文本格式直接在网络上传输，但请求方法、状态码和字段等 HTTP 语义仍然保留。(&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9110.html&quot;&gt;RFC 编辑器&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;30. HTTP 为什么是无状态协议？Cookie、Session、Token 有什么关系？&lt;/h2&gt;
&lt;p&gt;HTTP 无状态表示：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;协议本身不要求服务端必须记住前一次请求的信息，每个请求都可以被独立理解。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;Cookie&lt;/h3&gt;
&lt;p&gt;Cookie 是浏览器保存并自动携带小段状态数据的机制：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;服务端：Set-Cookie
浏览器：保存
后续请求：Cookie
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Cookie 可以保存 Session ID，也可以保存其他信息。&lt;/p&gt;
&lt;h3&gt;Session&lt;/h3&gt;
&lt;p&gt;Session 通常表示服务端保存用户状态：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Session ID → 用户登录信息
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;客户端通常只保存 Session ID，服务端根据 ID 查询 Session 数据。&lt;/p&gt;
&lt;h3&gt;Token&lt;/h3&gt;
&lt;p&gt;Token 是客户端携带的访问凭证。服务端可以通过查询数据库、Redis，或者验证签名来判断 Token 是否有效。&lt;/p&gt;
&lt;p&gt;JWT 是一种 Token 格式，但需要注意：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;JWT 不等于绝对无状态。&lt;/li&gt;
&lt;li&gt;JWT 泄露后仍可能被冒用。&lt;/li&gt;
&lt;li&gt;JWT 仍要处理过期、续期、注销和撤销。&lt;/li&gt;
&lt;li&gt;如果服务端维护黑名单，系统仍然保存了状态。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一句话区分：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Cookie：浏览器保存和发送数据的机制
Session：服务端保存的会话状态
Token：用于证明身份或权限的凭证
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;HTTP Cookie 标准定义了通过 &lt;code&gt;Set-Cookie&lt;/code&gt; 和 &lt;code&gt;Cookie&lt;/code&gt; 实现状态管理的机制。(&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc6265.html&quot;&gt;RFC 编辑器&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;五、近几年明显升温的题目&lt;/h1&gt;
&lt;h2&gt;32. WebSocket 和 HTTP 长连接有什么区别？&lt;/h2&gt;
&lt;p&gt;HTTP 长连接只是复用连接：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;客户端请求 → 服务端响应
客户端请求 → 服务端响应
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它仍然主要遵循请求—响应模型。&lt;/p&gt;
&lt;p&gt;WebSocket 在握手阶段通常先发送 HTTP Upgrade 请求，升级成功后变成独立的双向帧协议：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;客户端 ⇄ 服务端
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;特点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;全双工。&lt;/li&gt;
&lt;li&gt;服务端可以主动推送。&lt;/li&gt;
&lt;li&gt;长时间保持连接。&lt;/li&gt;
&lt;li&gt;使用帧区分消息。&lt;/li&gt;
&lt;li&gt;支持 Ping/Pong。&lt;/li&gt;
&lt;li&gt;适合聊天、实时通知、协同编辑、游戏等。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;长轮询则是服务端暂时不返回 HTTP 响应，等有数据或超时后再响应，然后客户端重新发起下一次请求。它依旧是 HTTP 请求响应模式，不等同于 WebSocket。&lt;/p&gt;
&lt;p&gt;腾讯和美团面经中都出现了 WebSocket 与 HTTP、HTTP 长连接的区别。(&lt;a href=&quot;https://www.nowcoder.com/discuss/353159162152558592&quot;&gt;牛客网&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;33. 什么是跨域？什么是 CORS 预检请求？&lt;/h2&gt;
&lt;p&gt;浏览器同源通常要求以下三项全部相同：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;协议 + 主机 + 端口
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://a.example.com
https://b.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;主机不同，因此是跨域。&lt;/p&gt;
&lt;p&gt;CORS 是服务端通过响应头告诉浏览器，允许哪些来源读取响应：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Access-Control-Allow-Origin: https://a.example.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Authorization
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对于非 CORS Safelist 范围内的方法、请求头或内容类型，浏览器通常先发送 OPTIONS 预检：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;OPTIONS 预检
    ↓
服务端允许
    ↓
发送真实请求
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要注意：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;CORS 主要是浏览器安全机制，后端服务之间调用、curl 通常不受浏览器同源策略约束。&lt;/li&gt;
&lt;li&gt;CORS 不是身份认证机制。&lt;/li&gt;
&lt;li&gt;CORS 也不能代替 CSRF 防护。&lt;/li&gt;
&lt;li&gt;对于不触发预检的跨域请求，请求可能已经到达服务端，只是浏览器不允许 JavaScript 读取响应。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Fetch 标准定义了预检请求、允许方法、允许请求头和预检缓存等行为。(&lt;a href=&quot;https://fetch.spec.whatwg.org/&quot;&gt;Fetch Specification&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;35. CDN 的基本原理是什么？&lt;/h2&gt;
&lt;p&gt;CDN 的核心是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;把内容缓存到离用户更近的边缘节点，减少访问延迟和源站压力。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;常见流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;用户访问域名
     ↓
DNS / CDN 调度系统选择合适边缘节点
     ↓
用户请求边缘节点
     ↓
缓存命中：直接返回
缓存未命中：向源站回源
     ↓
边缘节点缓存并返回结果
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;CDN 的收益：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;降低网络延迟。&lt;/li&gt;
&lt;li&gt;减少源站带宽。&lt;/li&gt;
&lt;li&gt;降低源站 QPS。&lt;/li&gt;
&lt;li&gt;缓解突发流量。&lt;/li&gt;
&lt;li&gt;提高静态资源可用性。&lt;/li&gt;
&lt;li&gt;可以承担 DDoS 清洗、TLS 终止等功能。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;适合缓存：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;图片。&lt;/li&gt;
&lt;li&gt;CSS。&lt;/li&gt;
&lt;li&gt;JavaScript。&lt;/li&gt;
&lt;li&gt;视频分片。&lt;/li&gt;
&lt;li&gt;安装包。&lt;/li&gt;
&lt;li&gt;不经常变化的接口结果。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不适合直接公共缓存：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用户个人订单。&lt;/li&gt;
&lt;li&gt;强实时库存。&lt;/li&gt;
&lt;li&gt;带隐私数据的响应。&lt;/li&gt;
&lt;li&gt;每个用户结果都不同的动态接口。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;DNS、CDN、QUIC 和完整访问链路在字节及拼多多相关面经中都有出现；CDN 缓存行为仍要遵守具体的 HTTP 缓存和鉴权策略。(&lt;a href=&quot;https://www.nowcoder.com/discuss/681654036927369216?utm_source=chatgpt.com&quot;&gt;牛客网&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;六、对应的大厂面经链接&lt;/h1&gt;
&lt;p&gt;下面优先列真实候选人记录，同时保留少量汇总帖用于判断重复频率。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;公司与面经&lt;/th&gt;
&lt;th&gt;其中出现的网络问题&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;个人面经&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/discuss/746424726964154368&quot;&gt;字节跳动后台 2025 暑期实习面经&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;select/poll/epoll、粘包、HTTPS 改造、DNS/TCP/HTTP 全流程、中间人攻击&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;个人面经&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/discuss/681654036927369216&quot;&gt;字节跳动后端三面面经&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;DNS、CDN、ARP、默认路由、QUIC、poll/epoll&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;深挖面经&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/discuss/353159162152558592&quot;&gt;腾讯 IEG 后台开发面试&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;WebSocket、HTTP/1.1/2/3、HPACK、QPACK、QUIC、TLS 1.3、拥塞控制、epoll&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;个人面经&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/discuss/600598995727048704&quot;&gt;腾讯微信实习后端面经&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;301/302、HTTP 状态码、三次握手、四次挥手、TIME_WAIT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;个人面经&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/discuss/792497325234016256&quot;&gt;蚂蚁数字支付一面&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;五层网络模型、TCP/UDP、HTTP/HTTPS、UDP 应用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;个人面经&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/discuss/792130801822367744&quot;&gt;美团秋招一面&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;WebSocket 使用场景、HTTP 长连接和 WebSocket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;个人面经&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/discuss/590576768491216896&quot;&gt;阿里云实习面经&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;TCP 和 UDP 能否使用同一个端口&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;汇总帖&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/discuss/754298760536002560&quot;&gt;京东 Java 后端 2025 面经合集&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;输入 URL 全过程、DNS 缓存、301/302、状态码&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;汇总帖&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/discuss/798969703874973696&quot;&gt;拼多多面试真题&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;QUIC 可靠传输、TCP 握手、CORS、访问页面全过程、CDN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;近期汇总&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/discuss/889150185626955776&quot;&gt;27 届后端高频面试题汇总&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;URL 全过程、epoll、TCP 可靠性、少一次握手、TIME_WAIT、CLOSE_WAIT、SYN Flood&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;复习路线&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nowcoder.com/discuss/353159281434370048&quot;&gt;阿里云后端校招经验与复习方向&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;HTTP 请求响应、HTTP 版本、HTTPS、TCP/UDP、握手挥手、丢包重传&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h1&gt;七、建议背诵顺序&lt;/h1&gt;
&lt;p&gt;第一轮先把下面这些背到能够完整回答：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;TCP/UDP
→ TCP 可靠性
→ 三次握手
→ 四次挥手
→ TIME_WAIT/CLOSE_WAIT
→ 流量控制/拥塞控制
→ HTTP/HTTPS
→ TLS
→ DNS
→ 输入 URL 全过程
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第二轮学习：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HTTP 状态码
→ GET/POST
→ HTTP 缓存
→ HTTP/1.1、2、3
→ HTTP/2 队头阻塞
→ QUIC
→ WebSocket
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第三轮补充：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;TCP 粘包拆包
→ select/poll/epoll
→ SYN Flood
→ ARP
→ CORS
→ CDN
→ 中间人攻击
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;面试回答统一使用这个结构最稳：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;先说定义
→ 再讲工作流程
→ 再解释为什么这样设计
→ 最后补充异常情况和常见误区
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>Java 面向对象高频面试题</title><link>https://blog.huangnv.online/posts/2681/4/4/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2681/4/4/</guid><pubDate>Sat, 01 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Java 基础面试分类：面向对象&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;筛选口径：近期国内大厂面经中，面向对象三大特性、运行时多态、重载与重写、抽象类与接口最稳定。构造方法、转型、初始化顺序和访问权限更多作为追问出现，因此合并为少量综合题。候选人面经用于判断频率，Java 规则以 JLS 和 Oracle 文档为准。&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/78726b5bff9c4bbbbb82ced71aa2ef87&quot;&gt;面经证据 1&lt;/a&gt; &lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/5ca19405b1144724bd5308c76175bf68&quot;&gt;面经证据 2&lt;/a&gt; &lt;a href=&quot;https://www.nowcoder.com/discuss/593104748941684736&quot;&gt;面经证据 3&lt;/a&gt; &lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/4b410af825414b43a2fefb12fe5ce3b8&quot;&gt;面经证据 4&lt;/a&gt; &lt;a href=&quot;https://www.nowcoder.com/discuss/396704240440287232&quot;&gt;面经证据 5&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;优先级说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;★★★：高频主问题，需要能直接口述并应对追问。&lt;/li&gt;
&lt;li&gt;★★：常见基础追问，需要掌握结论和典型例子。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;1. 封装、继承、多态&lt;/h2&gt;
&lt;h3&gt;★★★ 1. Java 面向对象的三大特性是什么？&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;封装&lt;/strong&gt;：隐藏对象内部状态和实现细节，通过稳定的公开行为控制访问。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;继承&lt;/strong&gt;：子类复用父类可继承的状态和行为，形成 &lt;code&gt;is-a&lt;/code&gt; 关系。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;多态&lt;/strong&gt;：父类或接口引用可以指向不同的实现对象，同一方法调用在运行时表现出不同的行为。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;封装不等同于机械地添加 getter、setter。真正的封装会维护对象约束：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Account {
    private int balance;

    public void withdraw(int amount) {
        if (amount &amp;lt;= 0 || amount &amp;gt; balance) {
            throw new IllegalArgumentException();
        }
        balance -= amount;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里通过行为方法保护余额的合法状态，外部代码无法随意写入负数。&lt;/p&gt;
&lt;h3&gt;★★★ 2. Java 如何实现运行时多态？编译时多态和它有什么区别？&lt;/h3&gt;
&lt;p&gt;运行时多态通常需要：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;存在继承或接口实现关系。&lt;/li&gt;
&lt;li&gt;子类重写实例方法。&lt;/li&gt;
&lt;li&gt;父类或接口引用指向子类对象。&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;Animal animal = new Dog();
animal.sound();
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;编译阶段根据引用的声明类型 &lt;code&gt;Animal&lt;/code&gt; 检查能否调用 &lt;code&gt;sound()&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;运行阶段根据实际对象类型 &lt;code&gt;Dog&lt;/code&gt; 执行重写后的方法。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;常见追问：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;编译时多态&lt;/strong&gt;主要对应方法重载，由编译器根据参数列表决定调用目标。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;实例方法&lt;/strong&gt;支持运行时动态分派。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;成员变量&lt;/strong&gt;根据引用的声明类型访问。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;static 方法&lt;/strong&gt;属于类，子类声明同签名静态方法时称为隐藏，调用目标根据引用或类名的静态类型确定。&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se8/html/jls-15.html#jls-15.12.4.4&quot;&gt;JLS&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;★★ 3. Java 支持多继承吗？&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;一个类只能直接继承一个父类。&lt;/li&gt;
&lt;li&gt;一个类可以实现多个接口。&lt;/li&gt;
&lt;li&gt;一个接口可以继承多个接口。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;class Dog extends Animal implements Runnable, Serializable {
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;类的单继承可以避免重复继承实例状态带来的复杂性；接口多实现用于组合多种能力或契约。&lt;a href=&quot;https://docs.oracle.com/javase/tutorial/java/IandI/subclasses.html&quot;&gt;Oracle 教程&lt;/a&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;2. 重载与重写&lt;/h2&gt;
&lt;h3&gt;★★★ 1. 重载和重写有什么区别？&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;对比项&lt;/th&gt;
&lt;th&gt;重载 &lt;code&gt;Overload&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;重写 &lt;code&gt;Override&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;发生位置&lt;/td&gt;
&lt;td&gt;同一个类或继承体系中&lt;/td&gt;
&lt;td&gt;父子类之间&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;方法名&lt;/td&gt;
&lt;td&gt;相同&lt;/td&gt;
&lt;td&gt;相同&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;参数列表&lt;/td&gt;
&lt;td&gt;必须不同&lt;/td&gt;
&lt;td&gt;方法签名需要匹配&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;返回值&lt;/td&gt;
&lt;td&gt;可以不同，但不能只靠返回值区分&lt;/td&gt;
&lt;td&gt;相同或使用协变返回类型&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;访问权限&lt;/td&gt;
&lt;td&gt;各重载方法独立决定&lt;/td&gt;
&lt;td&gt;子类方法不能降低可见性&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;checked 异常&lt;/td&gt;
&lt;td&gt;各重载方法独立决定&lt;/td&gt;
&lt;td&gt;子类不能声明更宽的 checked 异常&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;绑定阶段&lt;/td&gt;
&lt;td&gt;编译期&lt;/td&gt;
&lt;td&gt;实例方法在运行期动态分派&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;高频追问：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;只修改返回值无法构成重载，因为调用表达式缺少足够信息来区分目标方法。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;private&lt;/code&gt; 方法对子类不可见，不参与重写。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;final&lt;/code&gt; 方法禁止重写。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;static&lt;/code&gt; 方法可以被隐藏。&lt;/li&gt;
&lt;li&gt;构造方法可以重载，不能被继承或重写。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;class Parent {
    protected Number value() throws IOException {
        return 1;
    }
}

class Child extends Parent {
    @Override
    public Integer value() {
        return 1;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个重写扩大了访问权限，使用了协变返回类型，并减少了 checked 异常，符合规则。&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se8/html/jls-8.html#jls-8.4.8.3&quot;&gt;JLS&lt;/a&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;3. 抽象类与接口&lt;/h2&gt;
&lt;h3&gt;★★★ 1. 抽象类和接口有什么区别？怎么选择？&lt;/h3&gt;
&lt;p&gt;以下按 Java 8 常见面试口径回答：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;对比项&lt;/th&gt;
&lt;th&gt;抽象类&lt;/th&gt;
&lt;th&gt;接口&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;声明&lt;/td&gt;
&lt;td&gt;&lt;code&gt;abstract class&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;interface&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;继承数量&lt;/td&gt;
&lt;td&gt;类只能直接继承一个父类&lt;/td&gt;
&lt;td&gt;类可以实现多个接口&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;实例状态&lt;/td&gt;
&lt;td&gt;可以有普通实例字段&lt;/td&gt;
&lt;td&gt;字段隐式为 &lt;code&gt;public static final&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;构造方法&lt;/td&gt;
&lt;td&gt;可以有&lt;/td&gt;
&lt;td&gt;没有构造方法&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;方法实现&lt;/td&gt;
&lt;td&gt;可以同时包含抽象方法和具体方法&lt;/td&gt;
&lt;td&gt;可以有抽象方法、&lt;code&gt;default&lt;/code&gt; 方法、&lt;code&gt;static&lt;/code&gt; 方法&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;主要用途&lt;/td&gt;
&lt;td&gt;共享同一类事物的状态和通用实现&lt;/td&gt;
&lt;td&gt;定义能力、角色和跨类型契约&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;选择口径：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;多个类需要共享实例状态、受保护方法或通用模板实现：考虑抽象类。&lt;/li&gt;
&lt;li&gt;多个类型需要遵守同一能力契约，并需要多实现：考虑接口。&lt;/li&gt;
&lt;li&gt;两者可以组合使用，抽象类实现接口并提供部分公共实现。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;★★ 2. 抽象类和接口有哪些常见边界问题？&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;抽象类可以没有抽象方法，这样仍能限制外部直接创建实例。&lt;/li&gt;
&lt;li&gt;抽象类可以有构造方法；创建具体子类对象时会先执行父类构造过程。&lt;/li&gt;
&lt;li&gt;接口中的字段隐式为 &lt;code&gt;public static final&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;Java 8 接口支持 &lt;code&gt;default&lt;/code&gt; 和 &lt;code&gt;static&lt;/code&gt; 方法。&lt;/li&gt;
&lt;li&gt;接口的普通抽象方法隐式为 &lt;code&gt;public abstract&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;类实现接口时，具体实现方法必须是 &lt;code&gt;public&lt;/code&gt;，因为不能降低接口方法的可见性。&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se8/html/jls-9.html&quot;&gt;JLS&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;interface Flyable {
    void fly();

    default void stop() {
        System.out.println(&quot;stop&quot;);
    }

    static void info() {
        System.out.println(&quot;flyable&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;4. 构造方法、this 和 super&lt;/h2&gt;
&lt;h3&gt;★★ 1. 构造方法有哪些特点？什么是默认构造方法？&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;名称与类名相同。&lt;/li&gt;
&lt;li&gt;没有返回值类型，连 &lt;code&gt;void&lt;/code&gt; 也不写。&lt;/li&gt;
&lt;li&gt;创建对象时用于初始化对象。&lt;/li&gt;
&lt;li&gt;可以重载，不能被继承或重写。&lt;/li&gt;
&lt;li&gt;可以使用 &lt;code&gt;private&lt;/code&gt;，常见于单例、静态工厂和禁止实例化的工具类。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一个类没有显式声明任何构造方法时，编译器会提供默认构造方法。只要声明了任意构造方法，编译器就不再自动补充无参构造方法。&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se8/html/jls-8.html#jls-8.8.9&quot;&gt;JLS&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class User {
    User(String name) {
    }
}

// 此时 new User() 无法通过编译
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;创建子类对象时，需要先初始化父类部分，所以子类构造过程会先调用父类构造方法。&lt;/p&gt;
&lt;h3&gt;★★ 2. this、super、this()、super() 分别表示什么？&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;this&lt;/code&gt;：当前对象的引用，用于访问当前对象成员或消除同名歧义。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;super&lt;/code&gt;：用于访问直接父类中可访问的成员。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;this(...)&lt;/code&gt;：调用本类的另一个构造方法。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;super(...)&lt;/code&gt;：调用直接父类构造方法。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;按 Java 8 规则，显式构造器调用必须是构造方法体中的第一条语句，所以同一个构造方法不能同时显式写 &lt;code&gt;this(...)&lt;/code&gt; 和 &lt;code&gt;super(...)&lt;/code&gt;。&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se8/html/jls-8.html#jls-8.8.7.1&quot;&gt;JLS&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;静态上下文没有当前对象，因此不能使用 &lt;code&gt;this&lt;/code&gt; 或 &lt;code&gt;super&lt;/code&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;5. 向上转型和向下转型&lt;/h2&gt;
&lt;h3&gt;★★ 1. 什么是向上转型和向下转型？&lt;/h3&gt;
&lt;p&gt;向上转型：子类对象赋给父类或接口引用，由编译器自动完成。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Animal animal = new Dog();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;向下转型：把父类引用显式转换为子类引用。只有实际对象确实属于目标类型时才能成功，否则会抛出 &lt;code&gt;ClassCastException&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (animal instanceof Dog) {
    Dog dog = (Dog) animal;
    dog.guard();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;父类引用能直接调用哪些成员，由引用的声明类型在编译阶段决定；重写后的实例方法具体执行哪个版本，由实际对象类型在运行阶段决定。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;6. 对象初始化顺序&lt;/h2&gt;
&lt;h3&gt;★★ 1. 父类和子类的初始化顺序是什么？&lt;/h3&gt;
&lt;p&gt;首次主动使用并初始化子类时，类初始化顺序是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;父类静态字段初始化、静态代码块
→ 子类静态字段初始化、静态代码块
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;创建子类对象时，实例初始化顺序是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;父类实例字段初始化、普通代码块
→ 父类构造方法
→ 子类实例字段初始化、普通代码块
→ 子类构造方法
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同一个类中的字段初始化语句和代码块按照源码顺序执行。实例字段会先获得默认值，再执行显式初始化。静态初始化通常在一个类加载器对应的类初始化过程中执行一次。&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se8/html/jls-12.html#jls-12.4&quot;&gt;JLS&lt;/a&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;7. 访问权限&lt;/h2&gt;
&lt;h3&gt;★★ 1. private、默认、protected、public 的访问范围是什么？&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;修饰符&lt;/th&gt;
&lt;th&gt;当前类&lt;/th&gt;
&lt;th&gt;同包类&lt;/th&gt;
&lt;th&gt;不同包子类&lt;/th&gt;
&lt;th&gt;其他包普通类&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;private&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;可以&lt;/td&gt;
&lt;td&gt;不可以&lt;/td&gt;
&lt;td&gt;不可以&lt;/td&gt;
&lt;td&gt;不可以&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;默认（package-private）&lt;/td&gt;
&lt;td&gt;可以&lt;/td&gt;
&lt;td&gt;可以&lt;/td&gt;
&lt;td&gt;不可以&lt;/td&gt;
&lt;td&gt;不可以&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;protected&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;可以&lt;/td&gt;
&lt;td&gt;可以&lt;/td&gt;
&lt;td&gt;可以，但受继承访问规则限制&lt;/td&gt;
&lt;td&gt;不可以&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;public&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;可以&lt;/td&gt;
&lt;td&gt;可以&lt;/td&gt;
&lt;td&gt;可以&lt;/td&gt;
&lt;td&gt;可以&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;protected&lt;/code&gt; 的高频细节：不同包子类可以通过继承关系访问父类的 &lt;code&gt;protected&lt;/code&gt; 成员；通过对象表达式访问时，表达式的类型还必须满足 JLS 对子类访问的限制。&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se8/html/jls-6.html#jls-6.6&quot;&gt;JLS&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;其他结论：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;顶层类只能使用 &lt;code&gt;public&lt;/code&gt; 或包级访问。&lt;/li&gt;
&lt;li&gt;成员内部类可以使用四种访问修饰符。&lt;/li&gt;
&lt;li&gt;重写方法不能降低父类方法的可见性。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;8. 内部类&lt;/h2&gt;
&lt;h3&gt;★★ 1. Java 内部类分为哪几种？成员内部类和静态嵌套类有什么区别？&lt;/h3&gt;
&lt;p&gt;常见类型：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;成员内部类。&lt;/li&gt;
&lt;li&gt;静态嵌套类。&lt;/li&gt;
&lt;li&gt;局部类。&lt;/li&gt;
&lt;li&gt;匿名内部类。&lt;/li&gt;
&lt;/ol&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;对比项&lt;/th&gt;
&lt;th&gt;成员内部类&lt;/th&gt;
&lt;th&gt;静态嵌套类&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;与外部实例的关系&lt;/td&gt;
&lt;td&gt;每个实例关联一个外部类实例&lt;/td&gt;
&lt;td&gt;不关联外部类实例&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;访问外部类实例成员&lt;/td&gt;
&lt;td&gt;可以直接访问&lt;/td&gt;
&lt;td&gt;需要先获得外部类对象&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;创建方式&lt;/td&gt;
&lt;td&gt;&lt;code&gt;outer.new Inner()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;new Outer.Nested()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;匿名内部类在创建对象的同时定义一次性实现，没有显式类名，也不能显式声明构造方法。Java 8 以后，只有函数式接口的简单一次性实现通常可以进一步使用 Lambda 表达式。&lt;a href=&quot;https://docs.oracle.com/javase/tutorial/java/javaOO/nested.html&quot;&gt;Oracle 教程&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;该题在近期普通 Java 后端面经中的频率低于三大特性、重载重写和抽象类接口，按关联题掌握即可。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;建议优先背诵顺序&lt;/h2&gt;
&lt;p&gt;第一优先级：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;面向对象三大特性。&lt;/li&gt;
&lt;li&gt;运行时多态及其成立条件。&lt;/li&gt;
&lt;li&gt;重载与重写及边界规则。&lt;/li&gt;
&lt;li&gt;抽象类与接口的区别和选择。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;第二优先级：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;构造方法、&lt;code&gt;this&lt;/code&gt;、&lt;code&gt;super&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;向上转型和向下转型。&lt;/li&gt;
&lt;li&gt;父子类初始化顺序。&lt;/li&gt;
&lt;li&gt;四种访问权限。&lt;/li&gt;
&lt;li&gt;内部类分类与静态嵌套类。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;筛选结果说明&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;保留并强化：三大特性、运行时多态、重载与重写、抽象类与接口。&lt;/li&gt;
&lt;li&gt;合并：构造方法与 &lt;code&gt;this&lt;/code&gt;、&lt;code&gt;super&lt;/code&gt;；四个转型小题合并为一题；四个初始化小题合并为一题；访问权限细节合并为一题。&lt;/li&gt;
&lt;li&gt;降级为关联题：多继承、构造方法、转型、初始化顺序、访问权限、内部类。&lt;/li&gt;
&lt;li&gt;删除独立低频拆题：&lt;code&gt;private&lt;/code&gt; 构造方法、静态代码块次数、成员变量默认值、匿名内部类特点等内容已并入综合答案，不再单独占题号。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;资料来源&lt;/h2&gt;
&lt;h3&gt;国内公司候选人面经：用于判断频率&lt;/h3&gt;
&lt;h3&gt;官方资料：用于校正答案&lt;/h3&gt;
</content:encoded></item><item><title>Java 语法与基础类型高频面试题</title><link>https://blog.huangnv.online/posts/26730/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26730/1/1/</guid><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;1. Java 语法与基础类型&lt;/h1&gt;
&lt;p&gt;我重新对照了字节跳动、京东、美团、快手、腾讯等公开 Java 后端候选人面经。&lt;code&gt;String&lt;/code&gt;、&lt;code&gt;int/Integer&lt;/code&gt;、自动装箱、&lt;code&gt;==/equals&lt;/code&gt;、值传递、&lt;code&gt;final/finally/finalize&lt;/code&gt;、&lt;code&gt;static&lt;/code&gt; 和初始化顺序在多份面经中重复出现。下面保留适合国内大厂 Java 后端面试的高频主问题与常见追问，合并相邻重复题，并使用 Java 官方规范校准容易背错的说法。(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/068e351d710247498ff5912a43991dd2&quot;&gt;字节跳动面经&lt;/a&gt;)(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/3997dfbe9a1c427bbf0494e76280c4c7&quot;&gt;京东面经&lt;/a&gt;)(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/4c811020c93743c58083e50493e7073f&quot;&gt;美团面经&lt;/a&gt;)(&lt;a href=&quot;https://www.nowcoder.com/feed/main/detail/87b2a9eb59a34ef890152a04346f9d93&quot;&gt;快手面经&lt;/a&gt;)(&lt;a href=&quot;https://www.nowcoder.com/discuss/353155236690862080&quot;&gt;腾讯面经&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;标记说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;★★★：高频，必须能够直接口述&lt;/li&gt;
&lt;li&gt;★★：常见追问&lt;/li&gt;
&lt;li&gt;不展开特别偏僻的底层实现细节&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;1.1 八种基本数据类型&lt;/h2&gt;
&lt;h3&gt;★★★ 1. Java 有哪八种基本数据类型？成员变量和局部变量都有默认值吗？&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;常见占用位数&lt;/th&gt;
&lt;th&gt;取值范围或含义&lt;/th&gt;
&lt;th&gt;成员变量默认值&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;byte&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8 位&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-2^7&lt;/code&gt; ～ &lt;code&gt;2^7-1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;short&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;16 位&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-2^15&lt;/code&gt; ～ &lt;code&gt;2^15-1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;int&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;32 位&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-2^31&lt;/code&gt; ～ &lt;code&gt;2^31-1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;long&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;64 位&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-2^63&lt;/code&gt; ～ &lt;code&gt;2^63-1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0L&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;float&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;32 位&lt;/td&gt;
&lt;td&gt;单精度浮点数&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.0f&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;double&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;64 位&lt;/td&gt;
&lt;td&gt;双精度浮点数&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.0d&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;char&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;16 位&lt;/td&gt;
&lt;td&gt;&lt;code&gt;\u0000&lt;/code&gt; ～ &lt;code&gt;\uffff&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;\u0000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;boolean&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;规范未规定固定存储字节数&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt; 或 &lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;可以按照四类记忆：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;整数类型：byte、short、int、long
浮点类型：float、double
字符类型：char
布尔类型：boolean
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Java 语言规范明确规定了整数类型和浮点类型的位数，但没有把 &lt;code&gt;boolean&lt;/code&gt; 固定定义成“1 个字节”。所以面试时不要直接背成“boolean 一定占一个字节”。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-4.html&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h4&gt;成员变量和局部变量的默认值&lt;/h4&gt;
&lt;p&gt;不是。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;类变量、实例变量和数组元素会被自动赋予默认值。&lt;/li&gt;
&lt;li&gt;方法中的局部变量不会自动获得可以直接使用的默认值，使用前必须显式初始化。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;class User {
    int age;        // 默认值 0
    boolean valid;  // 默认值 false
    String name;    // 默认值 null

    public void test() {
        int number;
        // System.out.println(number); // 编译错误，未初始化
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-4.html?utm_source=chatgpt.com&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;h2&gt;1.2 int 和 Integer 的区别&lt;/h2&gt;
&lt;h3&gt;★★★ 1. int 和 Integer 有什么区别？parseInt() 和 valueOf() 有什么区别？&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;对比项&lt;/th&gt;
&lt;th&gt;&lt;code&gt;int&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;Integer&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;类型&lt;/td&gt;
&lt;td&gt;基本数据类型&lt;/td&gt;
&lt;td&gt;包装类、引用类型&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;默认值&lt;/td&gt;
&lt;td&gt;成员变量默认 &lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;成员变量默认 &lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;是否可以为 null&lt;/td&gt;
&lt;td&gt;不可以&lt;/td&gt;
&lt;td&gt;可以&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;是否能用于泛型&lt;/td&gt;
&lt;td&gt;不可以&lt;/td&gt;
&lt;td&gt;可以&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;是否包含方法&lt;/td&gt;
&lt;td&gt;不包含对象方法&lt;/td&gt;
&lt;td&gt;包含解析、转换、比较等方法&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;存储内容&lt;/td&gt;
&lt;td&gt;整数值&lt;/td&gt;
&lt;td&gt;对 Integer 对象的引用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;比较方式&lt;/td&gt;
&lt;td&gt;&lt;code&gt;==&lt;/code&gt; 比较数值&lt;/td&gt;
&lt;td&gt;&lt;code&gt;==&lt;/code&gt; 通常比较引用，&lt;code&gt;equals()&lt;/code&gt; 比较数值&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;例如集合泛型不能使用基本类型：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;List&amp;lt;int&amp;gt; list;       // 编译错误
List&amp;lt;Integer&amp;gt; list;   // 正确
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Java 泛型的类型参数必须是引用类型，所以需要使用 &lt;code&gt;Integer&lt;/code&gt;。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-4.html&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;h4&gt;parseInt() 和 valueOf() 的区别&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;int number1 = Integer.parseInt(&quot;123&quot;);
Integer number2 = Integer.valueOf(&quot;123&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;区别是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;parseInt()&lt;/code&gt; 返回基本类型 &lt;code&gt;int&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;valueOf()&lt;/code&gt; 返回包装类型 &lt;code&gt;Integer&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;valueOf()&lt;/code&gt; 可能利用 Integer 缓存&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Integer.html&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;★★★ 2. Integer 的缓存机制是什么？包装类可以直接使用 == 比较吗？&lt;/h3&gt;
&lt;p&gt;看经典代码：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Integer a = 127;
Integer b = 127;

Integer c = 128;
Integer d = 128;

System.out.println(a == b);
System.out.println(c == d);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见结果：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;true
false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;原因是自动装箱通常会调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Integer.valueOf(...)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Integer.valueOf()&lt;/code&gt; 至少会缓存：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-128 ～ 127
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个边界来自 Java 语言规范对装箱同一性的最低要求：布尔值、&lt;code&gt;\u0000 ～ \u007f&lt;/code&gt; 的字符，以及 &lt;code&gt;-128 ～ 127&lt;/code&gt; 范围内的整数常量在装箱后必须复用相同引用。该整数区间也正好覆盖整个 &lt;code&gt;byte&lt;/code&gt; 取值范围，能够以有限缓存覆盖常见的小整数。&lt;/p&gt;
&lt;p&gt;因此 &lt;code&gt;a&lt;/code&gt; 和 &lt;code&gt;b&lt;/code&gt; 通常引用同一个缓存对象。&lt;code&gt;127&lt;/code&gt; 是规范要求的默认上界，并不代表 JVM 只能缓存到这里。&lt;/p&gt;
&lt;p&gt;对于 &lt;code&gt;128&lt;/code&gt; 这样的值，规范不要求必须使用相同对象；某些 JVM 也可能扩展缓存范围。因此不能写依赖下面结果的业务代码：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;c == d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比较包装类数值时，应使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;c.equals(d)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者先拆箱成基本类型。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-5.html&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h4&gt;Integer 使用 == 比较的情况&lt;/h4&gt;
&lt;p&gt;要分情况。&lt;/p&gt;
&lt;h4&gt;两边都是 Integer&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;Integer a = 1000;
Integer b = 1000;

System.out.println(a == b);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里比较的是两个引用是否指向同一个对象，不能用来稳定判断数值是否相等。&lt;/p&gt;
&lt;p&gt;应该写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;System.out.println(a.equals(b));
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;一边是 Integer，一边是 int&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;Integer a = 1000;
int b = 1000;

System.out.println(a == b); // true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这时 &lt;code&gt;Integer&lt;/code&gt; 会自动拆箱成 &lt;code&gt;int&lt;/code&gt;，最终比较的是两个整数值。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;1.3 自动装箱与自动拆箱&lt;/h2&gt;
&lt;h3&gt;★★★ 1. 什么是自动装箱和自动拆箱？有哪些空指针和性能问题？&lt;/h3&gt;
&lt;p&gt;自动装箱：基本类型自动转换为包装类型。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Integer number = 10;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以理解为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Integer number = Integer.valueOf(10);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;自动拆箱：包装类型自动转换为基本类型。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Integer number = 10;
int value = number;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以理解为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;int value = number.intValue();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-5.html&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h4&gt;自动拆箱导致的空指针异常&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;Integer number = null;
int value = number;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;拆箱时相当于调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;number.intValue();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;由于 &lt;code&gt;number&lt;/code&gt; 是 &lt;code&gt;null&lt;/code&gt;，因此抛出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NullPointerException
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;下面代码也会触发自动拆箱：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Integer count = null;

if (count &amp;gt; 0) {
    // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以从数据库、Map 或接口参数中取得 &lt;code&gt;Integer&lt;/code&gt; 后，要注意它可能是 &lt;code&gt;null&lt;/code&gt;。Java 规范明确规定，对 &lt;code&gt;null&lt;/code&gt; 包装对象进行拆箱会抛出 &lt;code&gt;NullPointerException&lt;/code&gt;。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-5.html&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h4&gt;频繁装箱和拆箱的性能问题&lt;/h4&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Long sum = 0L;

for (long i = 0; i &amp;lt; 100000; i++) {
    sum += i;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每次执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sum += i;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;都可能涉及：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;Long&lt;/code&gt; 拆箱为 &lt;code&gt;long&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;执行加法&lt;/li&gt;
&lt;li&gt;结果重新装箱为 &lt;code&gt;Long&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这种写法会增加不必要的对象创建和转换。&lt;/p&gt;
&lt;p&gt;更适合写成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;long sum = 0L;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此，大量数值运算应优先使用基本类型。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;1.4 基本类型和引用类型&lt;/h2&gt;
&lt;h3&gt;★★★ 1. 基本类型和引用类型有什么区别？String 是基本类型吗？&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;对比项&lt;/th&gt;
&lt;th&gt;基本类型&lt;/th&gt;
&lt;th&gt;引用类型&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;保存内容&lt;/td&gt;
&lt;td&gt;具体的值&lt;/td&gt;
&lt;td&gt;对对象的引用值&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;是否可以为 null&lt;/td&gt;
&lt;td&gt;不可以&lt;/td&gt;
&lt;td&gt;可以&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;赋值效果&lt;/td&gt;
&lt;td&gt;复制具体数值&lt;/td&gt;
&lt;td&gt;复制引用值&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;泛型参数&lt;/td&gt;
&lt;td&gt;不可以直接使用&lt;/td&gt;
&lt;td&gt;可以使用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;典型类型&lt;/td&gt;
&lt;td&gt;&lt;code&gt;int&lt;/code&gt;、&lt;code&gt;double&lt;/code&gt;、&lt;code&gt;boolean&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;类、接口、数组、String&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;int a = 10;
int b = a;

b = 20;
System.out.println(a); // 10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;基本类型复制的是数值。&lt;/p&gt;
&lt;p&gt;而引用类型：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;User user1 = new User();
User user2 = user1;

user2.name = &quot;Tom&quot;;
System.out.println(user1.name); // Tom
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;user1&lt;/code&gt; 和 &lt;code&gt;user2&lt;/code&gt; 保存了相同的引用值，所以指向同一个对象。&lt;/p&gt;
&lt;p&gt;Java 语言中的类型分为基本类型和引用类型；类、接口和数组等属于引用类型。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-4.html&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h4&gt;String 的类型&lt;/h4&gt;
&lt;p&gt;不是。&lt;/p&gt;
&lt;p&gt;虽然 String 使用非常频繁，并且字符串字面量可以直接写成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String text = &quot;hello&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但 &lt;code&gt;String&lt;/code&gt; 本质上仍然是一个类，属于引用类型。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/String.html?utm_source=chatgpt.com&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;1.5 == 和 equals() 的区别&lt;/h2&gt;
&lt;h3&gt;★★★ 1. == 和 equals() 有什么区别？String 应该怎样比较？&lt;/h3&gt;
&lt;h4&gt;基本数据类型使用 ==&lt;/h4&gt;
&lt;p&gt;比较的是值：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;int a = 10;
int b = 10;

System.out.println(a == b); // true
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;引用类型使用 ==&lt;/h4&gt;
&lt;p&gt;比较两个引用是否指向同一个对象：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;User user1 = new User();
User user2 = new User();

System.out.println(user1 == user2); // false
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;equals()&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;equals()&lt;/code&gt; 是 &lt;code&gt;Object&lt;/code&gt; 类中的方法。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Object&lt;/code&gt; 默认实现仍然是比较对象身份，效果类似：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;this == obj
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是子类可以重写 &lt;code&gt;equals()&lt;/code&gt;，定义自己的逻辑相等规则。例如 String 重写了 &lt;code&gt;equals()&lt;/code&gt;，用于比较字符串内容。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-15.html?utm_source=chatgpt.com&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h4&gt;String 的比较方式&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;String a = new String(&quot;hello&quot;);
String b = new String(&quot;hello&quot;);

System.out.println(a == b);      // false
System.out.println(a.equals(b)); // true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;原因是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;a == b&lt;/code&gt;：比较是否为同一个 String 对象&lt;/li&gt;
&lt;li&gt;&lt;code&gt;a.equals(b)&lt;/code&gt;：比较字符内容是否相同&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此字符串内容比较应该使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;a.equals(b)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要避免空指针时，可以写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;hello&quot;.equals(a)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Objects.equals(a, b)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;String 的 &lt;code&gt;==&lt;/code&gt; 比较引用身份，而 &lt;code&gt;equals()&lt;/code&gt; 比较字符串内容。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-15.html?utm_source=chatgpt.com&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;★★★ 2. 重写 equals() 为什么通常还要重写 hashCode()？equals() 有哪些约定？&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;equals()&lt;/code&gt; 的通用约定要求逻辑相等的对象具有一致的相等结果。哈希集合还要求：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;如果 a.equals(b) 为 true，
那么 a.hashCode() 和 b.hashCode() 必须相同。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;否则可能出现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Set&amp;lt;User&amp;gt; set = new HashSet&amp;lt;&amp;gt;();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两个逻辑相等的 User 对象却被分配到不同哈希位置，导致去重或查找出现问题。&lt;/p&gt;
&lt;p&gt;注意反过来不成立：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hashCode 相同，不代表 equals 一定为 true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;哈希冲突是允许存在的。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Object.html&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h4&gt;equals() 的基本约定&lt;/h4&gt;
&lt;p&gt;通常包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;自反性：&lt;code&gt;x.equals(x)&lt;/code&gt; 必须为 &lt;code&gt;true&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;对称性：&lt;code&gt;x.equals(y)&lt;/code&gt; 与 &lt;code&gt;y.equals(x)&lt;/code&gt; 结果一致&lt;/li&gt;
&lt;li&gt;传递性：如果 &lt;code&gt;x=y&lt;/code&gt;、&lt;code&gt;y=z&lt;/code&gt;，那么 &lt;code&gt;x=z&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;一致性：对象未改变时，多次调用结果一致&lt;/li&gt;
&lt;li&gt;非空性：&lt;code&gt;x.equals(null)&lt;/code&gt; 必须为 &lt;code&gt;false&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Object.html&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;1.6 Java 的值传递&lt;/h2&gt;
&lt;h3&gt;★★★ 1. Java 是值传递还是引用传递？为什么方法能修改对象属性，却不能替换调用者的对象？&lt;/h3&gt;
&lt;p&gt;Java &lt;strong&gt;只有值传递&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;分成两种情况理解：&lt;/p&gt;
&lt;h4&gt;传递基本类型&lt;/h4&gt;
&lt;p&gt;复制的是具体数值。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;static void change(int number) {
    number = 100;
}

public static void main(String[] args) {
    int number = 10;
    change(number);

    System.out.println(number); // 10
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;方法中的 &lt;code&gt;number&lt;/code&gt; 是一份副本，不会修改外面的变量。&lt;/p&gt;
&lt;h4&gt;传递引用类型&lt;/h4&gt;
&lt;p&gt;复制的是引用值。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;static void change(User user) {
    user.name = &quot;Tom&quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;形参和实参保存的引用值相同，因此都能找到同一个 User 对象。&lt;/p&gt;
&lt;p&gt;但这仍然是值传递，因为复制的是“引用这个值”，不是把外部变量本身交给方法。Oracle 官方教程也明确说明，基本类型参数和引用类型参数都是按值传递。(&lt;a href=&quot;https://docs.oracle.com/javase/tutorial/java/javaOO/arguments.html?utm_source=chatgpt.com&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h4&gt;修改对象属性与重新给形参赋值的区别&lt;/h4&gt;
&lt;p&gt;看这个例子：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;static void change(User user) {
    user.name = &quot;B&quot;;
    user = new User();
    user.name = &quot;C&quot;;
}

public static void main(String[] args) {
    User user = new User();
    user.name = &quot;A&quot;;

    change(user);

    System.out.println(user.name); // B
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;过程如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;调用前：
外部 user ──→ 原来的 User 对象

进入方法后：
外部 user ──→ 原来的 User 对象
形参 user ──→ 原来的 User 对象
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user.name = &quot;B&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;修改的是双方共同指向的对象，所以外部可以看到。&lt;/p&gt;
&lt;p&gt;执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user = new User();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只是让方法中的形参副本指向了新对象，不会改变外部变量保存的引用。&lt;/p&gt;
&lt;h2&gt;1.7 String、StringBuilder、StringBuffer&lt;/h2&gt;
&lt;h3&gt;★★★ 1. 三者有什么区别？&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;对比项&lt;/th&gt;
&lt;th&gt;String&lt;/th&gt;
&lt;th&gt;StringBuilder&lt;/th&gt;
&lt;th&gt;StringBuffer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;是否可变&lt;/td&gt;
&lt;td&gt;不可变&lt;/td&gt;
&lt;td&gt;可变&lt;/td&gt;
&lt;td&gt;可变&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;线程安全&lt;/td&gt;
&lt;td&gt;不可变对象，可安全共享&lt;/td&gt;
&lt;td&gt;不保证线程安全&lt;/td&gt;
&lt;td&gt;方法通常带同步机制&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;字符串修改&lt;/td&gt;
&lt;td&gt;通常产生新的结果对象&lt;/td&gt;
&lt;td&gt;修改自身缓冲区&lt;/td&gt;
&lt;td&gt;修改自身缓冲区&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;典型场景&lt;/td&gt;
&lt;td&gt;少量字符串、不可变文本&lt;/td&gt;
&lt;td&gt;单线程大量字符串拼接&lt;/td&gt;
&lt;td&gt;多线程共享同一个可变缓冲区&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;单线程中进行大量字符串拼接，通常优先使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;StringBuilder
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;StringBuffer&lt;/code&gt; 为并发访问提供同步，但同步也会带来额外开销。官方 API 将 StringBuilder 定义为非同步的可变字符序列，并建议单线程场景优先于 StringBuffer；StringBuffer 则提供同步操作。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/StringBuilder.html&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;★★★ 2. String 为什么不可变？&lt;/h3&gt;
&lt;p&gt;String 对象创建之后，其表示的字符内容不会被修改。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String text = &quot;hello&quot;;
text = text + &quot; world&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里不是修改原来的 &lt;code&gt;&quot;hello&quot;&lt;/code&gt; 对象，而是让 &lt;code&gt;text&lt;/code&gt; 指向一个新的字符串结果。&lt;/p&gt;
&lt;p&gt;常见面试回答可以包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;方便字符串常量池共享对象&lt;/li&gt;
&lt;li&gt;字符串作为 HashMap key 时，哈希值不会因为内容变化而失效&lt;/li&gt;
&lt;li&gt;多线程共享字符串时，不需要担心内容被其他线程修改&lt;/li&gt;
&lt;li&gt;有利于安全地表示类名、文件路径、网络地址等信息&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;另外，&lt;code&gt;String&lt;/code&gt; 类被声明为 &lt;code&gt;final&lt;/code&gt;，不能被子类通过继承破坏不可变约束。官方 String API 明确规定，String 是不可变的，并且字符串对象可以安全共享。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/String.html?utm_source=chatgpt.com&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;注意不要把答案死背成：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;因为 String 底层是 final char[]。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这是依赖具体 JDK 实现的说法。不同 JDK 版本的内部存储结构可能变化，String 不可变是整个类的设计约束，不只是某一个字段使用了 &lt;code&gt;final&lt;/code&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;★★★ 3. 字符串常量池是什么？&lt;/h3&gt;
&lt;p&gt;字符串字面量会被放入字符串常量池并进行复用。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String a = &quot;hello&quot;;
String b = &quot;hello&quot;;

System.out.println(a == b); // true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;a&lt;/code&gt; 和 &lt;code&gt;b&lt;/code&gt; 通常指向常量池中的同一个 &lt;code&gt;&quot;hello&quot;&lt;/code&gt; 对象。&lt;/p&gt;
&lt;p&gt;而：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String c = new String(&quot;hello&quot;);

System.out.println(a == c);      // false
System.out.println(a.equals(c)); // true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;new String(...)&lt;/code&gt; 会创建新的 String 对象，因此引用不同，但内容相同。Java 规范规定，字符串字面量以及常量表达式形成的字符串会被 intern。(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/String.html?utm_source=chatgpt.com&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;★★★ 4. new String(&quot;abc&quot;) 创建了几个对象？&lt;/h3&gt;
&lt;p&gt;不能脱离上下文直接回答“永远两个”。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String text = new String(&quot;abc&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;涉及：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;字符串常量池中的 &lt;code&gt;&quot;abc&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;new String(...)&lt;/code&gt; 创建的新对象&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果 &lt;code&gt;&quot;abc&quot;&lt;/code&gt; 此前还没有进入常量池，那么整体可能涉及这两个对象。&lt;/p&gt;
&lt;p&gt;如果常量池中已经存在 &lt;code&gt;&quot;abc&quot;&lt;/code&gt;，执行到这一行时通常只额外创建 &lt;code&gt;new String(...)&lt;/code&gt; 对象。&lt;/p&gt;
&lt;p&gt;所以标准回答是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;最多涉及一个常量池对象和一个 new 出来的 String 对象；具体新创建几个要看常量池中是否已经存在该字面量。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h3&gt;★★ 5. intern() 方法有什么作用？&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;intern()&lt;/code&gt; 返回字符串常量池中与当前字符串内容相同的规范引用。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String a = new String(&quot;hello&quot;);
String b = a.intern();
String c = &quot;hello&quot;;

System.out.println(b == c); // true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;面试时可以回答：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;intern()&lt;/code&gt; 会尝试复用字符串常量池中的对象，并返回池中的规范引用。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;(&lt;a href=&quot;https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/String.html?utm_source=chatgpt.com&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;★★★ 6. String 使用 + 拼接一定很慢吗？&lt;/h3&gt;
&lt;p&gt;不能一概而论。&lt;/p&gt;
&lt;p&gt;对于常量表达式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String text = &quot;a&quot; + &quot;b&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;编译器可以直接计算为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String text = &quot;ab&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对于单次运行时拼接：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String text = name + age;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;编译器也可能使用可变字符串缓冲结构进行优化。&lt;/p&gt;
&lt;p&gt;真正需要避免的是循环中不断拼接：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String result = &quot;&quot;;

for (int i = 0; i &amp;lt; 10000; i++) {
    result = result + i;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;由于 String 不可变，这种写法可能不断产生中间字符串。&lt;/p&gt;
&lt;p&gt;更合适的是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;StringBuilder builder = new StringBuilder();

for (int i = 0; i &amp;lt; 10000; i++) {
    builder.append(i);
}

String result = builder.toString();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Java 规范允许编译器在字符串连接中使用 StringBuffer 或类似技术进行优化，但循环中的反复拼接仍应优先显式使用 StringBuilder。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-15.html&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;1.8 final、finally、finalize&lt;/h2&gt;
&lt;h3&gt;★★★ 1. final、finally、finalize 有什么区别？&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;名称&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;final&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Java 关键字，用于限制变量、方法和类&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;finally&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;异常处理结构中的代码块&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;finalize()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Object 中与垃圾回收相关的旧方法，已经废弃&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;finalize()&lt;/code&gt; 不保证执行时间，也不保证一定执行，还会带来可靠性、安全性和性能问题。它从 Java 9 开始被标记为废弃，并已进入未来移除流程。新代码释放资源应使用 &lt;code&gt;try-with-resources&lt;/code&gt;、&lt;code&gt;AutoCloseable&lt;/code&gt; 或显式 &lt;code&gt;close()&lt;/code&gt;；特殊场景才考虑 &lt;code&gt;Cleaner&lt;/code&gt;、&lt;code&gt;PhantomReference&lt;/code&gt;。(&lt;a href=&quot;https://openjdk.org/jeps/421&quot;&gt;OpenJDK JEP 421&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;★★★ 2. final 可以修饰什么？&lt;/h3&gt;
&lt;h4&gt;修饰变量&lt;/h4&gt;
&lt;p&gt;变量只能完成一次赋值。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;final int number = 10;
// number = 20; // 编译错误
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;修饰引用变量&lt;/h4&gt;
&lt;p&gt;引用不能重新指向其他对象，但对象本身仍然可以修改。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;final List&amp;lt;Integer&amp;gt; list = new ArrayList&amp;lt;&amp;gt;();

list.add(1);                    // 可以
// list = new ArrayList&amp;lt;&amp;gt;();    // 不可以
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;修饰方法&lt;/h4&gt;
&lt;p&gt;方法不能被子类重写。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public final void test() {
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;修饰类&lt;/h4&gt;
&lt;p&gt;类不能被继承。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public final class User {
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Java 规范规定，final 类不能被继承，final 实例方法不能被重写，final 变量只能完成一次确定赋值。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-8.html?utm_source=chatgpt.com&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;★★★ 3. finally 一定会执行吗？&lt;/h3&gt;
&lt;p&gt;正常 Java 控制流程中，无论 &lt;code&gt;try&lt;/code&gt; 是正常结束、抛出异常还是执行 &lt;code&gt;return&lt;/code&gt;，都会先执行 &lt;code&gt;finally&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;static int test() {
    try {
        return 1;
    } finally {
        System.out.println(&quot;finally&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;会先打印：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;finally
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后返回 &lt;code&gt;1&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;但不能说它在任何情况下都绝对执行。例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;JVM 或进程被直接终止&lt;/li&gt;
&lt;li&gt;执行 &lt;code&gt;System.exit()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;机器断电或 JVM 崩溃&lt;/li&gt;
&lt;li&gt;线程永远阻塞在 try 中，未能离开&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此标准回答是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;只要程序正常离开 try/catch 控制流程，finally 通常都会执行；但进程或 JVM 被终止等特殊情况下无法保证。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;JLS 规定，正常或异常离开 try/catch 时会处理 finally；而 return 的控制转移也需要等待 finally 执行。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-14.html&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;★★★ 4. finally 中写 return 会怎么样？&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;static int test() {
    try {
        return 1;
    } finally {
        return 2;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为 &lt;code&gt;finally&lt;/code&gt; 中的 &lt;code&gt;return&lt;/code&gt; 会覆盖 &lt;code&gt;try&lt;/code&gt; 原本的返回结果。&lt;/p&gt;
&lt;p&gt;同样，&lt;code&gt;finally&lt;/code&gt; 中抛出的异常也可能覆盖原来的异常。因此通常不要在 &lt;code&gt;finally&lt;/code&gt; 中写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;return
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也不要无必要地抛出新异常。Java 规范规定，如果 finally 自己以异常方式完成，其原因会替代此前 try/catch 的完成原因。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-14.html&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;h2&gt;1.9 static 关键字&lt;/h2&gt;
&lt;h3&gt;★★★ 1. static 可以修饰什么？静态变量和实例变量有什么区别？&lt;/h3&gt;
&lt;p&gt;常见可以修饰：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;成员变量&lt;/li&gt;
&lt;li&gt;成员方法&lt;/li&gt;
&lt;li&gt;静态代码块&lt;/li&gt;
&lt;li&gt;静态内部类&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h4&gt;静态变量和实例变量的区别&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;class User {
    static int total;
    int age;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;total&lt;/code&gt; 属于类，在同一个类加载器加载的类中通常只有一份&lt;/li&gt;
&lt;li&gt;&lt;code&gt;age&lt;/code&gt; 属于具体对象，每个 User 对象都有自己的 age&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;User user1 = new User();
User user2 = new User();

user1.age = 18;
user2.age = 20;

User.total = 2;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;访问静态成员时，推荐使用类名：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;User.total
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Java 规范将 static 字段定义为类变量，每个类只有对应的一份类变量，而实例字段属于每个对象。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-8.html?utm_source=chatgpt.com&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;★★★ 2. static 方法为什么不能直接访问实例变量？&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class User {
    int age;

    static void test() {
        // System.out.println(age); // 编译错误
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为静态方法属于类，调用静态方法时可能根本没有创建任何对象。&lt;/p&gt;
&lt;p&gt;因此静态方法中没有当前对象，也就不能直接使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;this
super
实例成员变量
实例成员方法
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是可以通过明确的对象引用访问实例成员：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;static void test(User user) {
    System.out.println(user.age);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-8.html?utm_source=chatgpt.com&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;★★★ 3. static 方法可以被重写吗？&lt;/h3&gt;
&lt;p&gt;不能被真正重写，只能被隐藏。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Parent {
    static void test() {
        System.out.println(&quot;Parent&quot;);
    }
}

class Child extends Parent {
    static void test() {
        System.out.println(&quot;Child&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;静态方法的调用主要根据编译时类型或类名确定，不具有实例方法那样的运行时多态。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Parent parent = new Child();
parent.test(); // Parent
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;实例方法可以发生动态绑定，静态方法属于类，只能发生隐藏。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-8.html?utm_source=chatgpt.com&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;h2&gt;1.10 代码块和静态代码块&lt;/h2&gt;
&lt;h3&gt;★★★ 1. Java 中有哪些代码块？它们和构造方法的执行顺序是什么？&lt;/h3&gt;
&lt;h4&gt;局部代码块&lt;/h4&gt;
&lt;p&gt;写在方法中，用于限制变量作用域：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public void test() {
    {
        int number = 10;
    }

    // number 在这里不可用
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;实例初始化代码块&lt;/h4&gt;
&lt;p&gt;直接写在类中，没有 &lt;code&gt;static&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class User {
    {
        System.out.println(&quot;实例代码块&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每次创建对象都会执行。&lt;/p&gt;
&lt;h4&gt;静态代码块&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;class User {
    static {
        System.out.println(&quot;静态代码块&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;类初始化时执行，通常在同一个类加载器中只执行一次。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-8.html?utm_source=chatgpt.com&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;hr /&gt;
&lt;h4&gt;静态代码块、实例代码块和构造方法的顺序&lt;/h4&gt;
&lt;p&gt;存在父子类时，第一次创建子类对象的典型顺序是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;父类静态变量、静态代码块
→ 子类静态变量、静态代码块
→ 父类实例变量、实例代码块
→ 父类构造方法
→ 子类实例变量、实例代码块
→ 子类构造方法
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同一个类中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;静态变量初始化语句与静态代码块，按照源码书写顺序执行&lt;/li&gt;
&lt;li&gt;实例变量初始化语句与实例代码块，也按照源码书写顺序执行&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;父类初始化先于子类初始化。(&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se25/html/jls-12.html?utm_source=chatgpt.com&quot;&gt;Oracle 文档&lt;/a&gt;)&lt;/p&gt;
&lt;h1&gt;这一部分优先背诵顺序&lt;/h1&gt;
&lt;h2&gt;第一优先级：必须熟练口述&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;int&lt;/code&gt; 和 &lt;code&gt;Integer&lt;/code&gt; 的区别、Integer 缓存与包装类比较&lt;/li&gt;
&lt;li&gt;自动装箱、自动拆箱及空指针问题&lt;/li&gt;
&lt;li&gt;&lt;code&gt;==&lt;/code&gt;、&lt;code&gt;equals()&lt;/code&gt; 与 &lt;code&gt;hashCode()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Java 的值传递，以及对象属性修改与形参重新赋值&lt;/li&gt;
&lt;li&gt;String、StringBuilder、StringBuffer 的区别&lt;/li&gt;
&lt;li&gt;String 不可变、字符串常量池与 &lt;code&gt;new String()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;final&lt;/code&gt;、&lt;code&gt;finally&lt;/code&gt;、&lt;code&gt;finalize&lt;/code&gt; 的区别&lt;/li&gt;
&lt;li&gt;&lt;code&gt;finally&lt;/code&gt; 的执行规则和 &lt;code&gt;return&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;static&lt;/code&gt; 变量、实例变量和静态方法&lt;/li&gt;
&lt;li&gt;静态代码块、实例代码块和构造方法的执行顺序&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;第二优先级：常见追问&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;八种基本数据类型，成员变量与局部变量的默认值&lt;/li&gt;
&lt;li&gt;&lt;code&gt;parseInt()&lt;/code&gt; 与 &lt;code&gt;valueOf()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;基本类型与引用类型，String 属于哪一类&lt;/li&gt;
&lt;li&gt;&lt;code&gt;equals()&lt;/code&gt; 的约定&lt;/li&gt;
&lt;li&gt;&lt;code&gt;intern()&lt;/code&gt; 与字符串 &lt;code&gt;+&lt;/code&gt; 拼接&lt;/li&gt;
&lt;li&gt;&lt;code&gt;final&lt;/code&gt; 修饰变量、引用、方法和类的含义&lt;/li&gt;
&lt;li&gt;static 方法隐藏与实例方法重写的区别&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;最常见的结果题&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;Integer a = 127;
Integer b = 127;
System.out.println(a == b); // true
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;Integer a = null;
int b = a; // NullPointerException
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;String a = &quot;abc&quot;;
String b = &quot;abc&quot;;
String c = new String(&quot;abc&quot;);

System.out.println(a == b);      // true
System.out.println(a == c);      // false
System.out.println(a.equals(c)); // true
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;static int test() {
    try {
        return 1;
    } finally {
        return 2;
    }
}
// 返回 2
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;static void change(User user) {
    user.name = &quot;B&quot;;
    user = new User();
    user.name = &quot;C&quot;;
}
// 外部原对象的 name 最终是 B
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>美团后端面试复盘（二）</title><link>https://blog.huangnv.online/posts/26715/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26715/1/1/</guid><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;美团 2&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;类型：Java 后端面试问题整理&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;面试问题&lt;/h2&gt;
&lt;h3&gt;Redis&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Redis 的数据结构和特点有哪些？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Redis 的数据结构需要分两层理解：对外暴露的数据类型，以及 Redis 为了节省内存和提高性能采用的底层编码。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;String&lt;/p&gt;
&lt;p&gt;String 是最基础的类型，底层核心是 SDS，也就是简单动态字符串。它会记录当前长度和剩余空间，支持二进制安全，获取长度是 &lt;code&gt;O(1)&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;Redis 会根据值的大小和内容选择 &lt;code&gt;int&lt;/code&gt;、&lt;code&gt;embstr&lt;/code&gt;、&lt;code&gt;raw&lt;/code&gt; 等编码：纯整数可以直接按整数保存；短字符串通常将对象和 SDS 一次分配；较长字符串使用独立 SDS。&lt;/p&gt;
&lt;p&gt;它适合保存字符串、数字和序列化后的对象，也支持原子自增、自减和位操作。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Hash&lt;/p&gt;
&lt;p&gt;Hash 用于保存 field-value 结构。字段较少、值较小时通常使用紧凑的 &lt;code&gt;listpack&lt;/code&gt;；数据变大后转为哈希表。&lt;/p&gt;
&lt;p&gt;它的特点是可以只读写某一个字段，不必每次读写整个对象；适合表示对象的多个属性。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;List&lt;/p&gt;
&lt;p&gt;List 是有序且允许重复的双端列表，底层使用 &lt;code&gt;quicklist&lt;/code&gt;，可以理解为多个 &lt;code&gt;listpack&lt;/code&gt; 组成的双向链表。&lt;/p&gt;
&lt;p&gt;它支持从两端高效插入和弹出，适合按顺序消费或维护最近数据。按下标访问中间元素需要遍历，复杂度随距离增长。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Set&lt;/p&gt;
&lt;p&gt;Set 是无序且元素唯一的集合。元素全是整数且数量较少时使用 &lt;code&gt;intset&lt;/code&gt;；其他情况使用哈希表。&lt;/p&gt;
&lt;p&gt;它支持成员判断、去重、交集、并集和差集，成员查询平均为 &lt;code&gt;O(1)&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;ZSet&lt;/p&gt;
&lt;p&gt;ZSet 是带 score 的有序去重集合。小数据量时使用 &lt;code&gt;listpack&lt;/code&gt;；数据变大后使用跳表和哈希表的组合。&lt;/p&gt;
&lt;p&gt;哈希表用于按成员快速查 score，跳表用于按 score 排序和范围查询。因此它既能快速判断成员是否存在，也能高效获取排名或指定分数区间的数据。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;特殊结构&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Bitmap：基于 String 的位操作，适合大量布尔状态统计。&lt;/li&gt;
&lt;li&gt;HyperLogLog：用较小固定内存做基数估算，结果存在可接受误差。&lt;/li&gt;
&lt;li&gt;Geo：底层基于 ZSet，利用 Geohash 编码完成附近位置查询。&lt;/li&gt;
&lt;li&gt;Stream：支持消费者组、消息确认和 pending list，适合需要消费进度管理的消息流。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：面试中先区分五种基础类型 String、Hash、List、Set、ZSet，再补充其底层编码。Redis 会随着数据规模和数据特征在紧凑结构与通用结构之间切换，在节省内存的同时保证常用操作的效率。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Redis 分布式锁的原理是什么？使用过程中遇到过什么问题？还了解哪些其他的分布式锁？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Redis 分布式锁的核心是利用 Redis 的原子命令，保证同一时刻只有一个客户端获得某个资源的锁。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;加锁&lt;/p&gt;
&lt;p&gt;一般使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SET lock:order:1001 &amp;lt;requestId&amp;gt; NX PX 30000
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;NX&lt;/code&gt;：Key 不存在才创建，保证互斥。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PX 30000&lt;/code&gt;：设置过期时间，防止持锁服务宕机后锁永久存在。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;requestId&lt;/code&gt;：每次加锁生成唯一值，如 UUID，用来标识锁的持有者。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;返回 &lt;code&gt;OK&lt;/code&gt; 说明加锁成功；返回空说明锁已被其他请求占用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;解锁&lt;/p&gt;
&lt;p&gt;解锁必须校验锁的值，确认当前请求就是持锁者，再删除 Key。校验和删除通过 Lua 脚本原子执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if redis.call(&apos;get&apos;, KEYS[1]) == ARGV[1] then
    return redis.call(&apos;del&apos;, KEYS[1])
end
return 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;直接删除锁 Key 会带来误删风险：锁过期后可能已被其他请求重新获取，旧请求仍在执行时会删除新请求的锁。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;锁超时问题&lt;/p&gt;
&lt;p&gt;固定过期时间存在风险：业务执行时间超过锁的有效期时，锁提前过期，其他请求可能进入临界区。&lt;/p&gt;
&lt;p&gt;实际常用 Redisson。它会在持锁期间通过看门狗定时续期，业务结束后主动释放锁。业务仍要设置合理的最大执行时间，并做好幂等和状态校验。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;常见问题&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;锁 Key 粒度太大，吞吐量下降。通常按资源维度加锁，如 &lt;code&gt;lock:order:{orderId}&lt;/code&gt;、&lt;code&gt;lock:stock:{skuId}&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;未校验持有者就释放锁，可能误删其他线程的锁。&lt;/li&gt;
&lt;li&gt;业务时间过长，锁过期后发生并发执行。&lt;/li&gt;
&lt;li&gt;Redis 主从切换时，主节点刚写入锁还未完成复制就故障，切换后可能出现两个客户端都认为自己持锁。&lt;/li&gt;
&lt;li&gt;锁只保证互斥，关键业务仍要依靠数据库事务、唯一约束、幂等和状态机校验兜底。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;其他分布式锁方案&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ZooKeeper：通过临时顺序节点实现锁。会话断开后临时节点自动删除，一致性和锁语义更强，适合协调类场景。&lt;/li&gt;
&lt;li&gt;数据库：通过唯一索引、条件更新或悲观锁实现。实现简单，高并发竞争时数据库压力较大。&lt;/li&gt;
&lt;li&gt;etcd：通过 Lease、事务和 Watch 实现，适合云原生和基础设施协调场景。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：Redis 锁一般使用 &lt;code&gt;SET NX PX&lt;/code&gt; 加锁、Lua 脚本校验删除解锁，生产中常用 Redisson 处理自动续期。它适合高并发、短临界区；涉及扣款、库存最终扣减等关键数据时，还需要数据库事务、唯一约束和幂等机制兜底。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Redis 的事务。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Redis 事务通过 &lt;code&gt;MULTI&lt;/code&gt;、&lt;code&gt;EXEC&lt;/code&gt;、&lt;code&gt;DISCARD&lt;/code&gt; 和 &lt;code&gt;WATCH&lt;/code&gt; 实现，核心能力是将多条命令放入队列后按顺序执行。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;基本流程&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;MULTI
SET account:A 900
DECRBY stock:1001 1
EXEC
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;MULTI&lt;/code&gt; 开启事务；后续命令进入队列；&lt;code&gt;EXEC&lt;/code&gt; 按顺序执行队列中的所有命令；&lt;code&gt;DISCARD&lt;/code&gt; 放弃队列中的命令。&lt;code&gt;EXEC&lt;/code&gt; 执行期间，其他客户端命令不会插入其中。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Redis 事务没有自动回滚&lt;/p&gt;
&lt;p&gt;某条命令在执行阶段报错时，已经成功执行的命令会保留，后续命令也会继续尝试执行。因此，涉及关键业务状态时，需要配合 Lua 脚本、幂等控制、数据库事务或状态机保证完整性。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;WATCH&lt;/code&gt; 实现乐观锁&lt;/p&gt;
&lt;p&gt;“版本变化”是便于理解的说法。Redis 不会给每个 Key 暴露一个版本号；执行 &lt;code&gt;WATCH stock:1001&lt;/code&gt; 后，Redis 记录当前客户端正在关注这个 Key。&lt;code&gt;EXEC&lt;/code&gt; 前只要该 Key 被任意客户端修改或删除，Redis 就将当前客户端标记为数据已变化。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;客户端 A                    客户端 B

WATCH stock:1001
GET stock:1001 -&amp;gt; 1

                            DECR stock:1001
                            库存变为 0

MULTI
DECR stock:1001
EXEC -&amp;gt; (nil)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A 查询时库存是 &lt;code&gt;1&lt;/code&gt;，B 在 A 提交前已经修改库存，A 的判断依据已过期。此时 &lt;code&gt;EXEC&lt;/code&gt; 返回空结果，事务队列不执行；业务端需要重新读取数据、重新判断并重试。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;与 Lua 脚本的关系&lt;/p&gt;
&lt;p&gt;事务适合固定命令组合。Lua 脚本能够在 Redis 内部完成“读取、判断、更新”，适合库存预扣、限流、抢购资格判断等场景。两者都没有自动回滚；Lua 脚本应保持短小，避免影响其他 Redis 请求。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;集群限制&lt;/p&gt;
&lt;p&gt;Redis Cluster 中，事务或 Lua 脚本涉及多个 Key 时，这些 Key 需要位于同一个 Hash Slot，通常使用 Hash Tag 保证，例如 &lt;code&gt;stock:{1001}&lt;/code&gt; 和 &lt;code&gt;order:{1001}&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Redis 的缓存一致性如何解决？&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;分库分表&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;分库分表如何设计？如何选择分库分表组件？为什么使用该方案？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;分库分表前先判断瓶颈来源。索引优化、归档历史数据、读写分离、缓存和批处理已经无法满足单库容量、单表数据量或写入 TPS 时，再进行水平拆分。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;拆分方式&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;垂直分库：按业务域拆分，例如用户库、订单库、商品库和支付库。&lt;/li&gt;
&lt;li&gt;垂直分表：将大字段、低频字段拆到扩展表，例如文章正文和商品详情。&lt;/li&gt;
&lt;li&gt;水平分库分表：同一逻辑表的数据按规则拆到多个库表，解决数据量和写入压力问题。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;分表可减小单表和索引规模；数据仍在同一个 MySQL 实例时，CPU、内存、Buffer Pool、磁盘 IO、连接数和日志写入能力仍由该实例承担。需要解决单机资源瓶颈时，分片应分布到多个 MySQL 实例。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;分片键选择&lt;/p&gt;
&lt;p&gt;分片键应当基数高、数据分布均匀、值稳定，并且出现在大多数查询条件中，保证 SQL 能精确路由。订单通常按 &lt;code&gt;user_id&lt;/code&gt; 或可推导出用户分片的订单号拆分；流水表可以按时间或用户维度拆分。&lt;/p&gt;
&lt;p&gt;例如，订单表采用 8 个数据库、每库 16 张表：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;库序号 = hash(user_id) % 8
表序号 = hash(user_id) % 16
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用户查询自己的订单时携带 &lt;code&gt;user_id&lt;/code&gt;，请求即可路由到一个库的一张表。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;绑定表&lt;/p&gt;
&lt;p&gt;订单表和订单明细表使用相同的分片键、相同的路由规则：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user_id = 1001
-&amp;gt; order_db_1.orders_01
-&amp;gt; order_db_1.order_item_01
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两张表会位于同一个 MySQL 实例，可以执行本地 Join。这类表称为绑定表。订单明细表需要保存 &lt;code&gt;user_id&lt;/code&gt;，或让 &lt;code&gt;order_id&lt;/code&gt; 能推导出所属分片；否则系统无法确定明细所在的库表。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;配套设计&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;使用雪花算法等全局唯一 ID，避免不同分片主键冲突。&lt;/li&gt;
&lt;li&gt;小型低频配置表可作为广播表，写入会同步到所有分片。&lt;/li&gt;
&lt;li&gt;避免跨库事务、跨库 Join、跨库排序和深分页。&lt;/li&gt;
&lt;li&gt;提前规划扩容。简单取模扩容会导致大量数据迁移，可预分更多逻辑表、按范围分片或使用一致性哈希降低迁移量。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;组件选择&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ShardingSphere-JDBC&lt;/code&gt;：以 Jar 包嵌入 Java 应用，应用内完成 SQL 路由、改写和结果归并。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ShardingSphere-Proxy&lt;/code&gt;：独立代理层，通过 MySQL 协议提供分片能力，适合多语言接入和集中治理。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MyCat&lt;/code&gt;：代理型方案，适合已有 MyCat 体系或需要多语言统一接入的团队。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Java 微服务项目通常优先选 &lt;code&gt;ShardingSphere-JDBC&lt;/code&gt;：链路更短，和 Spring Boot、JDBC、MyBatis 集成方便，分片规则随应用代码和配置管理，SQL 路由排查更直接。多语言服务较多、需要统一以 MySQL 协议接入时，可选择 &lt;code&gt;ShardingSphere-Proxy&lt;/code&gt;，同时建设代理层的高可用、监控和运维能力。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;分库分表场景下，跨库查询如何解决？&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;核心思路是减少跨库查询，将不同查询类型交给合适的方案处理。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;关联查询：使用绑定表&lt;/p&gt;
&lt;p&gt;订单表和订单明细表使用同一个分片键和路由规则，让相关数据位于同一个 MySQL 实例，在本地完成 Join。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;按主键查询：建立路由能力&lt;/p&gt;
&lt;p&gt;如果订单按 &lt;code&gt;user_id&lt;/code&gt; 分片，而请求只传入 &lt;code&gt;order_id&lt;/code&gt;，系统无法直接确定库表位置。常见方案包括：接口同时携带 &lt;code&gt;user_id&lt;/code&gt;；维护 &lt;code&gt;order_id -&amp;gt; db_index、table_index&lt;/code&gt; 的路由表并缓存到 Redis；或在订单 ID 中编码分片信息。目标是将全库全表扫描变为一次精准路由。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;小表关联：使用广播表&lt;/p&gt;
&lt;p&gt;字典表、地区表、配置表等数据量小、更新频率低的表可以复制到每个分片。每个库都拥有这类表，关联查询仍由本地 MySQL 完成。广播表写入需要同步到所有分片。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;列表展示：应用层批量查询后组装&lt;/p&gt;
&lt;p&gt;例如订单列表需要展示用户昵称时，先查订单得到一页 &lt;code&gt;user_id&lt;/code&gt;，再批量查询用户服务或用户库，最后在 Java 内存中组装结果。查询必须批量执行，避免 N+1 问题。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;全局搜索和复杂统计：同步到专用查询系统&lt;/p&gt;
&lt;p&gt;分片 MySQL 不适合处理全站搜索、全文检索、大范围筛选和复杂聚合。可以将 MySQL 的变更通过 Binlog、Canal 或 Debezium 捕获，经由 Kafka 等消息队列同步到专用查询系统：Elasticsearch 用于关键词搜索和多条件筛选；ClickHouse 用于报表和大规模聚合；Redis 用于实时计数、排行榜和热点汇总。&lt;/p&gt;
&lt;p&gt;查询系统中的数据存在短暂同步延迟，适合搜索和统计。支付结果、库存、订单状态等关键查询仍以 MySQL 主库为准。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;中间件广播查询：限制使用范围&lt;/p&gt;
&lt;p&gt;ShardingSphere 可以将一条 SQL 广播到多个分片，分别执行后归并结果。该方式适合小数据量管理后台或临时查询；高频接口和大数据量查询会产生大量 SQL、网络传输和内存归并，应避免使用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;跨库分页：使用游标分页&lt;/p&gt;
&lt;p&gt;查询全局最新数据时，服务会并行查询各分片，再归并排序返回前 N 条。全局 &lt;code&gt;LIMIT 100000, 20&lt;/code&gt; 会导致每个分片读取大量数据，深分页成本很高。&lt;/p&gt;
&lt;p&gt;常用游标分页：以 &lt;code&gt;create_time + order_id&lt;/code&gt; 作为稳定联合游标。下一页让每个分片查询游标之后的 N 条数据，服务层归并后返回全局前 N 条，并以本页最后一条生成下一页游标。分片查询可并行执行；全局搜索和深分页需求很重时，优先使用 Elasticsearch。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：核心交易查询通过分片键和绑定表实现本地查询；展示类查询通过批量查询后在应用层组装；全局搜索、报表和复杂聚合交给 Elasticsearch、ClickHouse 或数仓。&lt;/p&gt;
&lt;h3&gt;消息队列&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;介绍一下对消息队列的了解。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;消息队列是生产者和消费者之间的异步通信组件。生产者将消息发送给 Broker，Broker 负责存储和分发，消费者再按自己的处理能力消费消息。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;生产者
-&amp;gt; 消息队列 Broker
-&amp;gt; 消费者
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;核心价值&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;异步解耦：订单创建后通过消息触发积分、通知、搜索索引等下游处理，订单服务无需同步等待所有下游完成。&lt;/li&gt;
&lt;li&gt;削峰填谷：高并发请求先进入 MQ，消费者按下游数据库可承受的速度处理，保护数据库。&lt;/li&gt;
&lt;li&gt;异步处理：适合发送短信、生成报表、文件转码、日志处理和数据同步等耗时任务。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;核心能力&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;持久化和副本机制：消息可恢复，节点故障后仍能继续消费。&lt;/li&gt;
&lt;li&gt;消费确认：消费者业务处理成功后再 ACK 或提交 Offset，避免消息丢失。&lt;/li&gt;
&lt;li&gt;重试和死信队列：失败消息重试，多次失败后进入死信队列，由人工或补偿任务处理。&lt;/li&gt;
&lt;li&gt;顺序消息：同一业务 Key 路由到同一个队列或分区，由单线程顺序消费。&lt;/li&gt;
&lt;li&gt;消费幂等：消息可能重复投递，消费者通过唯一业务键、状态机或唯一索引保证重复处理没有副作用。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;可靠消息流程&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;本地事务提交成功
-&amp;gt; 发送消息或写入本地消息表
-&amp;gt; Broker 持久化成功
-&amp;gt; 消费者处理业务
-&amp;gt; 消费者业务事务提交成功
-&amp;gt; ACK / 提交 Offset
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;消费者在 ACK 前失败时，消息会再次投递，因此消费端需要保证幂等。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：消息队列主要用于异步、解耦和削峰。使用时重点关注可靠投递、消费幂等、顺序性、重复消费、消息积压监控和失败补偿。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;消息队列如何保证消费有序性？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;消息顺序分为全局有序和局部有序。业务中通常要求局部有序，即同一个订单、用户或商品的消息严格有序；不同业务 Key 可以并行处理。全局有序需要所有消息进入同一个队列并由一个消费者处理，吞吐量较低。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;生产端：相同业务 Key 路由到同一分区&lt;/p&gt;
&lt;p&gt;例如按 &lt;code&gt;orderId&lt;/code&gt; 路由：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hash(orderId) % 分区数
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同一个 &lt;code&gt;orderId&lt;/code&gt; 的创建、支付、发货消息始终进入同一个 Queue 或 Partition。Kafka 可用 &lt;code&gt;key=orderId&lt;/code&gt;；RocketMQ 可用 &lt;code&gt;shardingKey=orderId&lt;/code&gt; 选择同一 MessageQueue。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Broker：单个分区内部保持顺序&lt;/p&gt;
&lt;p&gt;Kafka 的同一个 Partition 按 Offset 递增保存消息；RocketMQ 的同一个 MessageQueue 按写入顺序保存消息。不同分区或队列之间可以并行，不保证全局顺序。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;消费端：同一分区顺序处理&lt;/p&gt;
&lt;p&gt;一个消费者组内，一个 Partition 同一时刻只分配给一个消费者。同一分区内不能再提交给线程池并发执行，否则会破坏顺序；多个分区可以由多个消费者并行处理。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;消费失败处理&lt;/p&gt;
&lt;p&gt;同一订单的支付消息失败时，后续发货消息不能直接越过它执行。通常原地重试，成功后继续消费后续消息；持续失败时告警并进入人工或自动补偿流程。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;业务层状态校验&lt;/p&gt;
&lt;p&gt;消息重试和异常恢复仍可能带来重复消息。订单状态更新通过状态机或版本号校验，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UPDATE orders
SET status = &apos;PAID&apos;
WHERE order_id = ?
  AND status = &apos;CREATED&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：按业务 Key 路由到同一分区，同一分区由一个消费者顺序处理，可以保证局部有序；通过多个分区实现不同业务 Key 的并行消费。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Kafka、RabbitMQ、RocketMQ 之间的区别。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;三者都能做异步、解耦和削峰，核心差异在定位：Kafka 偏大数据事件流和日志平台；RabbitMQ 偏复杂路由和任务分发；RocketMQ 偏高并发业务消息。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;架构与存储模型&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Kafka：Topic 被拆成多个 Partition，每个 Partition 是按 Offset 递增的追加日志。消息按保留策略保存，消费者组独立维护 Offset，可以回放历史消息。&lt;/li&gt;
&lt;li&gt;RabbitMQ：消息经 Exchange 按 Binding Rule 路由到一个或多个 Queue，再由消费者消费。支持 Direct、Topic、Fanout、Headers 等灵活路由。&lt;/li&gt;
&lt;li&gt;RocketMQ：Topic 下有多个 MessageQueue，消费者组按队列消费，提供面向业务消息的顺序、延迟、事务、重试和死信等能力。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;消费与消息保留&lt;/p&gt;
&lt;p&gt;Kafka 消费者提交 Offset 后，消息仍保留到过期时间；多个 Consumer Group 可以独立消费和回放。&lt;/p&gt;
&lt;p&gt;RabbitMQ 的消息在消费者 ACK 后通常从对应 Queue 删除。多个下游订阅同一事件时，通常让 Exchange 绑定多个独立 Queue。&lt;/p&gt;
&lt;p&gt;RocketMQ 和 Kafka 都支持消费者组和消费进度管理；RocketMQ 内置业务消费常用的重试和死信队列能力。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;主要特性&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Kafka：高吞吐、大消息积压、消息回放、流处理生态、单 Partition 有序，以及 Kafka 内多 Topic/Partition 写入和 Offset 提交的事务能力。&lt;/li&gt;
&lt;li&gt;RabbitMQ：复杂路由、Producer Confirm、Consumer ACK、TTL、死信队列和优先级队列。&lt;/li&gt;
&lt;li&gt;RocketMQ：按业务 Key 的局部顺序、延迟消息、事务消息、内置重试、死信队列以及 Tag/属性过滤。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;选型&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;日志采集、埋点、Binlog 同步、实时计算、需要回放
-&amp;gt; Kafka

通知、邮件、短信、异步任务、复杂消息路由
-&amp;gt; RabbitMQ

订单、支付、库存、延迟关单、事务消息、顺序消息
-&amp;gt; RocketMQ
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;性能受消息大小、副本数量、刷盘策略、批量参数、硬件和网络影响，不能只用绝对吞吐量排名做选型。&lt;/p&gt;
&lt;p&gt;总结：Kafka 适合事件流和大数据管道，优势是高吞吐、消息保留和回放；RabbitMQ 适合复杂路由和可靠任务分发；RocketMQ 面向高并发业务消息，顺序、延迟、事务和重试能力更完整。实际选型还要结合团队已有基础设施、运维能力、消息规模和业务一致性要求。&lt;/p&gt;
&lt;h3&gt;JVM&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;JVM 内存结构。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;类加载机制。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;类加载机制指 JVM 将 &lt;code&gt;.class&lt;/code&gt; 字节码加载进内存，并转化为可执行 &lt;code&gt;Class&lt;/code&gt; 对象的过程。&lt;/p&gt;
&lt;p&gt;类生命周期：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;加载
-&amp;gt; 连接（验证、准备、解析）
-&amp;gt; 初始化
-&amp;gt; 使用
-&amp;gt; 卸载
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;双亲委派属于加载阶段中的类加载器策略。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;加载&lt;/p&gt;
&lt;p&gt;加载阶段根据类的全限定名获取字节码二进制流，将字节码转为 JVM 的运行时数据结构，并创建对应的 &lt;code&gt;Class&lt;/code&gt; 对象。字节码可以来自 &lt;code&gt;.class&lt;/code&gt; 文件、Jar 包、网络或动态代理生成的字节码。&lt;/p&gt;
&lt;p&gt;双亲委派发生在加载阶段：当前 ClassLoader 收到请求后，先检查是否已加载，再委托父加载器；父加载器无法加载时，当前加载器才自行 &lt;code&gt;findClass&lt;/code&gt; 和 &lt;code&gt;defineClass&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Bootstrap ClassLoader
-&amp;gt; Extension ClassLoader（JDK 8）
-&amp;gt; Application ClassLoader

JDK 9 以后：
Bootstrap ClassLoader
-&amp;gt; Platform ClassLoader
-&amp;gt; Application ClassLoader
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;双亲委派可以防止核心类被篡改、避免同一个类被重复加载，并保证核心类的统一性。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;连接&lt;/p&gt;
&lt;p&gt;连接包括验证、准备和解析。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;验证：检查字节码格式、语义和类型安全，避免非法字节码危害 JVM。&lt;/li&gt;
&lt;li&gt;准备：为 &lt;code&gt;static&lt;/code&gt; 变量分配内存并设置默认值。例如 &lt;code&gt;static int count = 10&lt;/code&gt; 在准备阶段通常为 &lt;code&gt;0&lt;/code&gt;，初始化阶段才赋值为 &lt;code&gt;10&lt;/code&gt;；编译期常量 &lt;code&gt;static final int COUNT = 10&lt;/code&gt; 在准备阶段可以直接赋值为 &lt;code&gt;10&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;解析：将常量池中的符号引用替换为直接引用。解析中发现某个类尚未加载时，会发起该类的加载请求；新类加载时同样使用双亲委派。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;初始化&lt;/p&gt;
&lt;p&gt;初始化阶段执行类初始化方法 &lt;code&gt;&amp;lt;clinit&amp;gt;&lt;/code&gt;，即静态变量的显式赋值和静态代码块。静态变量和静态代码块按源码顺序执行；初始化子类前会先初始化父类；JVM 保证同一个类的初始化过程线程安全。&lt;/p&gt;
&lt;p&gt;常见主动初始化时机：&lt;code&gt;new&lt;/code&gt; 创建对象、读取或修改非编译期常量的静态字段、调用静态方法、反射调用类、启动 &lt;code&gt;main&lt;/code&gt; 方法所在类，以及初始化子类前初始化父类。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ClassLoader.loadClass&lt;/code&gt;、&lt;code&gt;Class.forName(&quot;Demo&quot;, false, loader)&lt;/code&gt;、访问编译期常量和创建类数组通常不会触发类初始化。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用与卸载&lt;/p&gt;
&lt;p&gt;初始化完成后，类进入使用阶段。类卸载通常要求自定义类加载器、该加载器加载的类以及相关实例都不再被引用。Tomcat 热部署中，旧 Web 应用的 ClassLoader 被回收后，相关类才有机会卸载。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;调整默认委派方式的场景&lt;/p&gt;
&lt;p&gt;Tomcat 为支持多个 Web 应用使用不同版本的同名 Jar，会由 WebAppClassLoader 优先加载当前应用的类，实现类隔离。JDBC SPI 使用线程上下文类加载器，使由 Bootstrap ClassLoader 加载的 &lt;code&gt;DriverManager&lt;/code&gt; 能加载应用 ClassPath 中的数据库驱动实现。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：类生命周期是加载、连接、初始化、使用和卸载。连接包括验证、准备和解析；双亲委派属于加载阶段，用于决定由哪个类加载器查找和定义类。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;JVM 常见垃圾回收器的特点和应用场景。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;常见回答可以围绕 Serial、CMS、G1、ZGC 展开。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Serial 收集器&lt;/p&gt;
&lt;p&gt;Serial 使用单个 GC 线程回收，回收时 Stop The World。它实现简单、额外线程开销低，适合小堆、单核或 CPU 资源有限、对停顿不敏感的简单应用；堆变大后停顿会明显增长。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;CMS 收集器&lt;/p&gt;
&lt;p&gt;CMS 主要回收老年代，目标是降低停顿时间。流程为：初始标记（STW）-&amp;gt; 并发标记 -&amp;gt; 重新标记（STW）-&amp;gt; 并发清除。&lt;/p&gt;
&lt;p&gt;优点是大部分工作与用户线程并发，老年代回收停顿较短；缺点是标记清除会产生内存碎片，并发 GC 会占用 CPU，也会产生浮动垃圾。老年代空间不足时可能发生 Concurrent Mode Failure 并退化为 Full GC。&lt;/p&gt;
&lt;p&gt;CMS 已在 JDK 14 移除，面试考察它主要用于理解并发标记和 G1 的设计演进，新项目不再选择 CMS。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;G1 收集器&lt;/p&gt;
&lt;p&gt;G1 将堆划分为多个 Region，每个 Region 可以动态作为 Eden、Survivor 或 Old。它优先回收垃圾比例高、收益大的 Region，并可通过 &lt;code&gt;-XX:MaxGCPauseMillis&lt;/code&gt; 设置期望最大停顿时间。&lt;/p&gt;
&lt;p&gt;G1 适合中大堆、希望停顿较稳定的服务端应用，能够在吞吐和停顿之间平衡；维护 Region 和 Remembered Set 存在额外开销，小堆场景优势不明显。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;ZGC 收集器&lt;/p&gt;
&lt;p&gt;ZGC 通过染色指针、读屏障、并发标记、并发转移和并发重映射，将大部分 GC 工作与用户线程并发执行。它适合大堆、低延迟在线服务，例如交易、推荐、网关等。&lt;/p&gt;
&lt;p&gt;优点是停顿很短且受堆大小影响较小；缺点是并发回收会消耗更多 CPU，需要结合 JDK 版本和压测结果评估吞吐代价。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：Serial 简单且适合小堆；CMS 通过并发标记清除降低停顿但已移除；G1 通过 Region 和可预测停顿适合大多数服务端应用；ZGC 通过读屏障和并发整理适合大堆、低延迟场景。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;遇到过 Full GC 或内存泄漏吗？如何排查和解决？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Full GC 是一次 GC 事件，内存泄漏是无用对象仍被引用、GC 无法回收的根因之一。Full GC 频繁也可能由堆配置不足、流量突增、大对象或对象晋升过快导致，需要通过趋势和现场证据判断。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;监控发现&lt;/p&gt;
&lt;p&gt;重点看老年代使用率、Full GC 次数和停顿时间、接口 P99、CPU 与错误率。&lt;/p&gt;
&lt;p&gt;正常情况下，堆内存使用曲线呈锯齿形：上涨后经过 GC 会明显回落。内存泄漏常表现为 Full GC 后老年代基线持续抬高：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;第一次 Full GC 后：老年代 40%
第二次 Full GC 后：老年代 55%
第三次 Full GC 后：老年代 70%
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;业务流量没有明显增长而基线持续抬高时，需要怀疑长期引用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;GC 日志确认&lt;/p&gt;
&lt;p&gt;开启 &lt;code&gt;-Xlog:gc*&lt;/code&gt;，关注 Full GC 前后的老年代大小、Full GC 频率、停顿时间和对象晋升速度。&lt;/p&gt;
&lt;p&gt;例如 Full GC 前老年代 &lt;code&gt;7.8G&lt;/code&gt;、之后仍为 &lt;code&gt;7.2G&lt;/code&gt;，且连续多次回收量都很小，说明大量对象仍存活。还要检查 Metaspace、Direct Memory 是否持续增长。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;对象直方图定位增长对象&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;jcmd &amp;lt;pid&amp;gt; GC.class_histogram
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对比间隔一段时间采集的两到三份直方图，观察某类对象的实例数和占用是否持续增长。单次直方图只能说明对象占用大，持续增长才是泄漏的重要证据。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Heap Dump 确认引用链&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;jcmd &amp;lt;pid&amp;gt; GC.heap_dump filename=/data/heap.hprof
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用 MAT、VisualVM 或 JProfiler 分析 Dominator Tree、Histogram 和 Path to GC Roots。&lt;code&gt;Path to GC Roots&lt;/code&gt; 用于定位对象为何仍然存活，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;OrderDTO
-&amp;gt; HashMap$Node
-&amp;gt; static ConcurrentHashMap CACHE
-&amp;gt; 单例 Bean
-&amp;gt; GC Root
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这说明静态缓存持有对象，可能缺少容量上限或过期清理。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;常见根因与修复&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;无上限本地缓存或 Map：设置最大容量、TTL 和淘汰策略。&lt;/li&gt;
&lt;li&gt;ThreadLocal 在线程池中未清理：在 &lt;code&gt;finally&lt;/code&gt; 中调用 &lt;code&gt;remove()&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;无界消息、任务或请求队列：设置队列容量、拒绝策略和消费速率。&lt;/li&gt;
&lt;li&gt;大对象、超大 JSON、一次性查大量数据：使用分页、流式处理和对象瘦身。&lt;/li&gt;
&lt;li&gt;监听器、回调或连接对象未释放：修复注册与注销、关闭资源的生命周期。&lt;/li&gt;
&lt;li&gt;类加载器泄漏或堆外内存增长：分别检查 Metaspace、ClassLoader 和 Direct Memory。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;线上处理与验证&lt;/p&gt;
&lt;p&gt;先限流、降级、暂停大批任务或扩容止血，同时保留 GC 日志、对象直方图和 Heap Dump 现场。修复后观察 Full GC 频率、Full GC 后老年代基线、接口 P99 和对象数量趋势是否恢复稳定。&lt;/p&gt;
&lt;p&gt;启动时可增加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：通过“老年代基线趋势 -&amp;gt; GC 日志 -&amp;gt; 多次对象直方图对比 -&amp;gt; Heap Dump 的 Path to GC Roots”确认内存泄漏，再按引用链修复缓存、ThreadLocal、队列或资源生命周期问题。&lt;/p&gt;
&lt;h3&gt;并发编程&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;线程池的核心参数。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;线程池一般通过 &lt;code&gt;ThreadPoolExecutor&lt;/code&gt; 手动创建，核心有 7 个参数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;new ThreadPoolExecutor(
    corePoolSize,
    maximumPoolSize,
    keepAliveTime,
    unit,
    workQueue,
    threadFactory,
    handler
);
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;corePoolSize&lt;/code&gt;：核心线程数。当前线程数小于核心线程数时，任务到来会创建核心线程执行。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;maximumPoolSize&lt;/code&gt;：最大线程数。核心线程都忙且任务队列满时，才会继续创建非核心线程，直到达到该上限。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;keepAliveTime&lt;/code&gt;：非核心线程的空闲存活时间，超过后会被回收。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;unit&lt;/code&gt;：&lt;code&gt;keepAliveTime&lt;/code&gt; 的时间单位。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;workQueue&lt;/code&gt;：任务队列。核心线程都忙时，新任务先进入队列等待。常见有 &lt;code&gt;ArrayBlockingQueue&lt;/code&gt;、&lt;code&gt;LinkedBlockingQueue&lt;/code&gt;、&lt;code&gt;SynchronousQueue&lt;/code&gt; 和 &lt;code&gt;PriorityBlockingQueue&lt;/code&gt;；生产中通常使用有界队列，避免任务无限堆积。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;threadFactory&lt;/code&gt;：线程工厂，用于设置业务线程名、是否守护线程和未捕获异常处理器，方便排查问题。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;handler&lt;/code&gt;：拒绝策略。线程数达到最大值且队列已满时触发。&lt;code&gt;AbortPolicy&lt;/code&gt; 抛异常，&lt;code&gt;CallerRunsPolicy&lt;/code&gt; 由提交任务的线程执行，&lt;code&gt;DiscardPolicy&lt;/code&gt; 丢弃任务，&lt;code&gt;DiscardOldestPolicy&lt;/code&gt; 丢弃最早任务后再尝试提交。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;任务处理顺序：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;提交任务
-&amp;gt; 当前线程数 &amp;lt; corePoolSize：创建核心线程
-&amp;gt; 核心线程已满：任务进入 workQueue
-&amp;gt; workQueue 已满且线程数 &amp;lt; maximumPoolSize：创建非核心线程
-&amp;gt; 队列和最大线程数都满：触发拒绝策略
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;非核心线程空闲超过 &lt;code&gt;keepAliveTime&lt;/code&gt; 会被回收；默认核心线程不回收，调用 &lt;code&gt;allowCoreThreadTimeOut(true)&lt;/code&gt; 后核心线程也可以超时回收。&lt;/p&gt;
&lt;p&gt;总结：核心是核心线程数、最大线程数、任务队列和拒绝策略。扩容顺序是先核心线程，再队列，队列满后才创建非核心线程，最后拒绝任务。&lt;/p&gt;
&lt;h3&gt;MySQL&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;MySQL 索引的类型有哪些？哪种使用最多？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;MySQL 索引可以从业务约束和 InnoDB 存储结构两个维度回答。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;按业务约束分类&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;主键索引 &lt;code&gt;PRIMARY KEY&lt;/code&gt;：唯一且不能为空。&lt;/li&gt;
&lt;li&gt;唯一索引 &lt;code&gt;UNIQUE&lt;/code&gt;：保证索引列值不重复。&lt;/li&gt;
&lt;li&gt;普通索引 &lt;code&gt;INDEX / KEY&lt;/code&gt;：仅用于加速查询。&lt;/li&gt;
&lt;li&gt;联合索引 &lt;code&gt;INDEX(a, b, c)&lt;/code&gt;：多个列组成，遵循最左前缀原则。&lt;/li&gt;
&lt;li&gt;全文索引 &lt;code&gt;FULLTEXT&lt;/code&gt;：用于文本检索。&lt;/li&gt;
&lt;li&gt;空间索引 &lt;code&gt;SPATIAL&lt;/code&gt;：用于地理空间数据，使用较少。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;按 InnoDB 存储结构分类&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;聚簇索引：通常是主键索引，叶子节点保存整行数据，一张 InnoDB 表只有一个。&lt;/li&gt;
&lt;li&gt;二级索引：普通、唯一、联合索引通常属于二级索引，叶子节点保存索引列和主键值；查询整行数据时可能需要回表。查询字段都在二级索引中时可以走覆盖索引。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;按底层数据结构分类&lt;/p&gt;
&lt;p&gt;InnoDB 的常规索引主要是 B+ 树，支持等值、范围、排序和最左前缀匹配。Hash 索引主要由 Memory 引擎支持；InnoDB 的自适应 Hash 索引由内部自动维护。全文索引使用倒排结构，空间索引使用 R 树。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;实际开发使用最多的是主键索引、普通二级索引和联合二级索引，底层绝大多数是 B+ 树。唯一索引用于业务唯一性约束，全文和空间索引用于特定场景。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;MySQL 的事务及其特性。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;事务是一组数据库操作的最小执行单元。事务中的操作要么全部成功提交，要么全部失败回滚。MySQL 中主要由 InnoDB 支持事务，MyISAM 不支持事务。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;START TRANSACTION;

UPDATE account
SET balance = balance - 100
WHERE user_id = 1;

UPDATE account
SET balance = balance + 100
WHERE user_id = 2;

COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;任意一步失败时执行 &lt;code&gt;ROLLBACK&lt;/code&gt;，已执行的修改会撤销。&lt;/p&gt;
&lt;p&gt;事务的四个特性是 ACID：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;原子性 Atomicity&lt;/p&gt;
&lt;p&gt;事务中的多个操作作为整体执行。例如 A 扣款成功、B 加款失败时，整个事务回滚，A 的扣款也撤销。InnoDB 主要通过 Undo Log 支持回滚。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;一致性 Consistency&lt;/p&gt;
&lt;p&gt;事务执行前后，数据都要满足业务规则和数据约束，例如总金额保持不变、账户余额不能小于零、订单状态按规定流转。一致性依赖事务机制、业务代码、数据库约束、唯一索引和外键等共同保证。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;隔离性 Isolation&lt;/p&gt;
&lt;p&gt;多个事务并发执行时，一个事务的中间状态不能被其他事务随意看到或影响。InnoDB 通过锁和 MVCC 实现隔离性。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;持久性 Durability&lt;/p&gt;
&lt;p&gt;事务提交成功后，即使 MySQL 宕机，已提交的数据也应能恢复。InnoDB 主要依靠 Redo Log 保证持久性；Redo Log 和 Binlog 的两阶段提交保证崩溃恢复结果与主从复制日志一致。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：事务保证一组操作整体执行。原子性依靠 Undo Log 回滚，一致性依靠事务和业务约束共同保证，隔离性依靠锁和 MVCC，持久性依靠 Redo Log。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;介绍脏读、不可重复读、幻读。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;三者都是并发事务下的读一致性问题。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;脏读&lt;/p&gt;
&lt;p&gt;一个事务读到了另一个事务尚未提交的数据。例如事务 A 修改余额但未提交，事务 B 读取到修改后的余额，随后事务 A 回滚，事务 B 读到的数据就成了无效数据。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;不可重复读&lt;/p&gt;
&lt;p&gt;同一个事务中，多次查询同一行数据，读到的结果不一致。例如事务 A 第一次读余额为 &lt;code&gt;100&lt;/code&gt;，事务 B 修改为 &lt;code&gt;200&lt;/code&gt; 并提交，事务 A 第二次读取到 &lt;code&gt;200&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;幻读&lt;/p&gt;
&lt;p&gt;同一个事务中，多次按相同范围条件查询，第二次查询出现了第一次不存在的新行，或少了原有的行。例如事务 A 查询某用户订单得到 10 条，事务 B 插入一条该用户订单并提交，事务 A 再查得到 11 条。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;区别：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;脏读：读到未提交数据
不可重复读：同一行数据被修改
幻读：范围查询的结果集合发生变化
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;隔离级别与问题的关系：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;隔离级别&lt;/th&gt;
&lt;th&gt;脏读&lt;/th&gt;
&lt;th&gt;不可重复读&lt;/th&gt;
&lt;th&gt;幻读&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;READ UNCOMMITTED&lt;/td&gt;
&lt;td&gt;可能&lt;/td&gt;
&lt;td&gt;可能&lt;/td&gt;
&lt;td&gt;可能&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;READ COMMITTED&lt;/td&gt;
&lt;td&gt;避免&lt;/td&gt;
&lt;td&gt;可能&lt;/td&gt;
&lt;td&gt;可能&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;REPEATABLE READ&lt;/td&gt;
&lt;td&gt;避免&lt;/td&gt;
&lt;td&gt;避免&lt;/td&gt;
&lt;td&gt;SQL 标准下仍可能&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SERIALIZABLE&lt;/td&gt;
&lt;td&gt;避免&lt;/td&gt;
&lt;td&gt;避免&lt;/td&gt;
&lt;td&gt;避免&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;InnoDB 默认隔离级别是 &lt;code&gt;REPEATABLE READ&lt;/code&gt;。普通 &lt;code&gt;SELECT&lt;/code&gt; 使用 MVCC 的一致性读，在同一事务中读取同一个 Read View；范围当前读，例如 &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;、&lt;code&gt;UPDATE&lt;/code&gt;、&lt;code&gt;DELETE&lt;/code&gt;，通过 Gap Lock 和 Next-Key Lock 阻止其他事务在范围内插入，从而处理幻读问题。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;千万级数据表 CRUD 效率低，如何优化？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;千万级数据量本身不一定需要分库分表。优化要先定位瓶颈，再按 SQL、索引、写入、表结构和架构分层处理。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;定位问题&lt;/p&gt;
&lt;p&gt;先通过慢 SQL、APM 链路、&lt;code&gt;EXPLAIN&lt;/code&gt;、锁等待、长事务、Buffer Pool、IO、CPU 和连接池指标确认瓶颈。重点看执行计划的 &lt;code&gt;type&lt;/code&gt;、&lt;code&gt;key&lt;/code&gt;、&lt;code&gt;rows&lt;/code&gt;、&lt;code&gt;Extra&lt;/code&gt;，以及是否出现 &lt;code&gt;Using filesort&lt;/code&gt;、&lt;code&gt;Using temporary&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;索引优化&lt;/p&gt;
&lt;p&gt;按真实查询条件设计联合索引，例如按 &lt;code&gt;user_id&lt;/code&gt;、&lt;code&gt;status&lt;/code&gt; 查询并按 &lt;code&gt;create_time&lt;/code&gt; 排序时可考虑 &lt;code&gt;INDEX(user_id, status, create_time)&lt;/code&gt;。遵循最左前缀，优先高区分度和过滤性强的列，尽量使用覆盖索引，避免索引列函数计算和隐式类型转换，避免冗余索引。索引会增加写入维护成本，不能无限增加。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;SQL 优化&lt;/p&gt;
&lt;p&gt;避免 &lt;code&gt;SELECT *&lt;/code&gt;、大范围 Join、N+1 查询和索引列上的函数。深分页避免 &lt;code&gt;LIMIT 1000000, 20&lt;/code&gt;，使用基于主键或时间的游标分页，例如 &lt;code&gt;WHERE id &amp;gt; ? ORDER BY id LIMIT 20&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;写入优化&lt;/p&gt;
&lt;p&gt;使用批量 &lt;code&gt;INSERT&lt;/code&gt; 并控制批量大小，避免超大事务，减少不必要的二级索引，主键尽量递增或趋势递增，使用主键或唯一索引精确更新，缩短事务时间。库存、计数器等热点行可通过 Redis 原子预扣、分段计数和 MQ 削峰降低锁竞争，数据库负责最终一致性。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;表结构和数据生命周期&lt;/p&gt;
&lt;p&gt;大字段拆到扩展表，历史数据归档，按时间分区或分表，冷热数据分离，避免在主表存储超大 JSON、TEXT、BLOB。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;读扩展&lt;/p&gt;
&lt;p&gt;读多写少时使用 Redis 缓存热点数据、读写分离和连接池。写后立即读取、订单状态、支付结果等一致性要求高的场景读主库或绕过缓存，避免主从延迟导致旧数据。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;分库分表&lt;/p&gt;
&lt;p&gt;单表、单库的数据量、写入 TPS、磁盘 IO、CPU 或连接数接近单机上限时，再考虑垂直拆分、水平分表和分库到多个 MySQL 实例。分库分表会带来跨库查询、全局 ID、分布式事务和扩容迁移等复杂度。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：先从慢 SQL 和执行计划入手，通过合适索引、减少扫描行数、游标分页、控制事务和锁竞争优化；再做缓存、读写分离、归档和冷热分离；单机资源确实达到瓶颈时再分库分表。&lt;/p&gt;
&lt;h3&gt;设计模式&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;了解哪些设计模式？平时开发中使用过哪些设计模式？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Web 开发中可以重点讲单例模式、代理模式、策略模式和责任链模式。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;单例模式&lt;/p&gt;
&lt;p&gt;Spring 容器中的 Bean 默认是单例，常见于 Service、Controller、Mapper、配置类和无状态工具类。单例 Bean 中避免保存请求级别的可变成员变量，防止并发线程安全问题。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;代理模式&lt;/p&gt;
&lt;p&gt;Spring AOP、事务、缓存和异步方法都使用代理模式。例如 &lt;code&gt;@Transactional&lt;/code&gt; 会由代理对象在方法前开启事务，正常结束后提交，异常时回滚。日志、权限校验、接口耗时统计也是常见场景。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;策略模式&lt;/p&gt;
&lt;p&gt;策略模式适合一个业务有多种可替换处理规则，用于消除大量 &lt;code&gt;if else&lt;/code&gt;。例如满减券、折扣券、立减券实现同一个 &lt;code&gt;CouponStrategy&lt;/code&gt; 接口，再按优惠券类型从 &lt;code&gt;Map&amp;lt;String, CouponStrategy&amp;gt;&lt;/code&gt; 中选择对应实现。支付方式、运费计算、消息渠道和风控规则也常使用策略模式。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;责任链模式&lt;/p&gt;
&lt;p&gt;责任链模式让多个处理器按顺序处理请求。典型链路为：参数校验 -&amp;gt; 登录校验 -&amp;gt; 权限校验 -&amp;gt; 限流校验 -&amp;gt; 风控校验。Spring MVC 的 Filter 链和 Interceptor 链是典型应用。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：单例用于复用无状态对象；代理用于事务、日志、缓存等横切能力；策略用于多种规则选择；责任链用于多步骤校验和处理。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;哪些中间件使用了单例模式？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;这里的单例指一个应用进程中只创建一份共享对象，由多个业务线程复用。它不表示中间件服务端只有一个实例，也不表示只有一个线程或连接。&lt;/p&gt;
&lt;p&gt;适合单例复用的通常是客户端、连接池和工厂对象，因为这些对象内部管理网络连接、IO 线程、连接池、元数据或配置，频繁创建成本较高。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Redis&lt;/p&gt;
&lt;p&gt;&lt;code&gt;RedissonClient&lt;/code&gt; 通常作为单例 Bean 使用，内部维护 Redis 连接池和 Netty 线程，多个业务线程共享调用。&lt;code&gt;JedisPool&lt;/code&gt; 适合单例；具体 &lt;code&gt;Jedis&lt;/code&gt; 连接从池中借取，用完归还，不能全局共享给多个线程。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Kafka 和 RocketMQ&lt;/p&gt;
&lt;p&gt;&lt;code&gt;KafkaProducer&lt;/code&gt; 通常作为应用级单例复用，它线程安全，内部维护发送缓冲区、网络连接、后台 IO 线程和 Broker 元数据。RocketMQ Producer 也通常按 Producer Group 创建并由 Spring 统一管理。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;KafkaConsumer&lt;/code&gt; 不支持多线程并发访问，通常一个消费线程对应一个 Consumer 实例；它保存分区分配、Offset 和 &lt;code&gt;poll&lt;/code&gt; 状态，不应作为多个线程共用的全局单例。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;数据库与 MyBatis&lt;/p&gt;
&lt;p&gt;&lt;code&gt;DataSource&lt;/code&gt;、HikariCP、Druid 等连接池通常是单例，内部维护多个数据库连接；每个请求或事务从池中借一个 &lt;code&gt;Connection&lt;/code&gt;，用完归还。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;SqlSessionFactory&lt;/code&gt; 和 MyBatis-Spring 的 &lt;code&gt;SqlSessionTemplate&lt;/code&gt; 通常是单例；&lt;code&gt;SqlSession&lt;/code&gt; 持有连接、事务和一级缓存等状态，按请求或事务使用，不共享。MyBatis 本身不通常称为线程池；处理 SQL 的线程一般是 Web 容器线程池中的请求线程。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：客户端、连接池和工厂对象适合单例；连接、会话、事务和 Consumer 这类带请求或线程状态的对象按自身生命周期创建和释放。&lt;/p&gt;
</content:encoded></item><item><title>腾讯后端面试题整理</title><link>https://blog.huangnv.online/posts/26714/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26714/1/1/</guid><pubDate>Tue, 14 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;腾讯面试&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;来源：网上面经&lt;br /&gt;
已去除项目架构、短链接生成、短链接唯一性、短链接跳转和项目进程等项目相关问题。&lt;br /&gt;
已有答案同步自《八股文总结》；其余题目保留待补充。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Redis 与缓存&lt;/h2&gt;
&lt;h3&gt;1. 了解 Redis 的容灾策略吗？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Redis 容灾我会从节点高可用、数据可靠性和故障降级三层回答。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;节点高可用&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Redis 通常采用主从复制，主节点负责写入，从节点复制主节点数据并可承担读请求。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;非分片场景通常使用 Redis Sentinel。一般部署 3 个 Sentinel 监控主从节点；主节点故障后，由 Sentinel 投票并完成故障转移，将合适的从节点提升为新主节点。&lt;/li&gt;
&lt;li&gt;大容量、高并发场景使用 Redis Cluster。数据按 16,384 个 Slot 分片，每个主节点配置从节点；主节点故障后，对应从节点会提升为新主节点。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;主从副本和 Sentinel 应部署在不同机器，条件允许时跨可用区，避免单机或单机房故障。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;数据可靠性&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;持久化通常使用 AOF + RDB。RDB 用于定期快照和快速恢复；AOF 记录写命令，常用 &lt;code&gt;appendfsync everysec&lt;/code&gt;，在性能和数据安全之间平衡。&lt;/li&gt;
&lt;li&gt;Redis 主从默认异步复制，故障切换时存在短暂丢数据窗口。关键写入可以使用 &lt;code&gt;WAIT&lt;/code&gt; 等待指定副本确认，也可以配置 &lt;code&gt;min-replicas-to-write&lt;/code&gt; 和 &lt;code&gt;min-replicas-max-lag&lt;/code&gt;，副本数量不足或复制延迟过大时拒绝写入。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些机制用于缩小丢数据窗口。核心订单、账务等数据仍应以 MySQL 等持久化存储为最终依据。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Redis 故障时保护数据库&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Redis 宕机、集群切换或大面积失效时，需要防止请求全部穿透到 MySQL：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在网关或应用层限流，优先保护数据库连接池和核心接口。&lt;/li&gt;
&lt;li&gt;对非核心请求熔断降级，返回默认值、旧数据或系统繁忙。&lt;/li&gt;
&lt;li&gt;使用 Caffeine 等本地缓存兜住热点数据。&lt;/li&gt;
&lt;li&gt;对热点 Key 使用逻辑过期或互斥重建；Redis 恢复后限速预热缓存，避免大量请求同时回源。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;总结：主从加 Sentinel 或 Cluster 处理节点故障，AOF、RDB 和副本确认控制数据丢失，限流、降级和本地缓存保护后端数据库。&lt;/p&gt;
&lt;h3&gt;2. Redis 中 String 的底层数据结构是什么？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Redis 的 String 对外是字符串类型，底层由 &lt;code&gt;redisObject&lt;/code&gt; 表示。&lt;code&gt;redisObject&lt;/code&gt; 记录对象类型、编码方式和实际数据指针。String 常见有三种编码：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;int&lt;/code&gt; 编码&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;当 String 保存的是整数，例如 &lt;code&gt;SET stock 100&lt;/code&gt;，Redis 会直接用整数保存数值，不额外创建 SDS 字符串。它适合计数器、库存等场景，&lt;code&gt;INCR&lt;/code&gt;、&lt;code&gt;DECR&lt;/code&gt; 操作效率高，也更节省内存。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;embstr&lt;/code&gt; 编码&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;短字符串通常使用 &lt;code&gt;embstr&lt;/code&gt; 编码。&lt;code&gt;redisObject&lt;/code&gt; 和 SDS 字符串数据一次性连续分配，减少一次内存分配，CPU 缓存局部性也更好。验证码、短链接、短 Token 等短 Value 常见这种情况。&lt;code&gt;embstr&lt;/code&gt; 修改后通常会转换为 &lt;code&gt;raw&lt;/code&gt; 编码，以便扩容。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;raw&lt;/code&gt; 编码&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;长字符串使用 &lt;code&gt;raw&lt;/code&gt; 编码，&lt;code&gt;redisObject&lt;/code&gt; 和 SDS 分开分配。它适合较长 Value，支持追加和扩容，例如文章内容、序列化后的对象、较长 JSON。&lt;/p&gt;
&lt;p&gt;真正保存字符串内容的是 SDS，即 Simple Dynamic String。可以理解为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct sdshdr {
    len;    // 已使用长度
    alloc;  // 已分配容量
    flags;  // SDS 头类型
    buf[];  // 实际字符内容，末尾保留 \0
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SDS 的特点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;len&lt;/code&gt; 直接记录长度，&lt;code&gt;STRLEN&lt;/code&gt; 获取长度是 &lt;code&gt;O(1)&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;buf&lt;/code&gt; 末尾保留 &lt;code&gt;\0&lt;/code&gt;，兼容 C 字符串函数。&lt;/li&gt;
&lt;li&gt;SDS 是二进制安全的，内容可以包含 &lt;code&gt;\0&lt;/code&gt;，因此可保存文本、JSON、序列化对象和二进制数据。&lt;/li&gt;
&lt;li&gt;追加字符串时会预留部分空间，减少频繁扩容；小字符串扩容通常按倍数增长，大字符串按固定增量预留，因此 &lt;code&gt;APPEND&lt;/code&gt; 的均摊复杂度较低。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;补充：Redis 的 Key 本身通常也是 SDS，Key 和 Value 通过 Redis 字典结构关联。&lt;/p&gt;
&lt;p&gt;总结：Redis String 由 &lt;code&gt;redisObject&lt;/code&gt; 管理，底层根据内容选择 &lt;code&gt;int&lt;/code&gt;、&lt;code&gt;embstr&lt;/code&gt; 和 &lt;code&gt;raw&lt;/code&gt; 编码。整数用 &lt;code&gt;int&lt;/code&gt; 节省内存；短字符串用 &lt;code&gt;embstr&lt;/code&gt; 减少内存分配；长字符串用 &lt;code&gt;raw&lt;/code&gt; 配合 SDS。SDS 记录长度和容量，支持二进制安全与高效追加。&lt;/p&gt;
&lt;h3&gt;3. 如何防止缓存击穿和缓存穿透？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;缓存击穿指某个热点 Key 过期后，大量并发请求同时发现 Redis 没有数据，于是一起回源查询 MySQL，导致数据库瞬间压力升高。&lt;/p&gt;
&lt;p&gt;常见处理方式：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;热点 Key 提前预热。&lt;/li&gt;
&lt;li&gt;缓存未命中时加互斥锁，只允许一个线程查询数据库并重建缓存。&lt;/li&gt;
&lt;li&gt;使用逻辑过期。物理 Key 不立即删除，过期后先返回旧数据，再由后台线程异步重建缓存。&lt;/li&gt;
&lt;li&gt;对回源数据库的路径做限流，避免缓存失效时所有请求都去查 MySQL。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;缓存穿透指请求的数据在 Redis 和数据库中都不存在，例如持续查询不存在的商品 ID 或短链接。请求每次都会穿过缓存访问数据库。&lt;/p&gt;
&lt;p&gt;常见处理方式：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;参数校验。非法 ID、异常范围和不符合业务状态的请求直接拒绝。&lt;/li&gt;
&lt;li&gt;空值缓存。数据库查不到数据时，将空结果写入 Redis 并设置较短 TTL。&lt;/li&gt;
&lt;li&gt;布隆过滤器。将存在的 ID 写入布隆过滤器；判断一定不存在时直接拦截。判断可能存在时，仍需查询缓存和数据库。&lt;/li&gt;
&lt;li&gt;限流、黑名单和验证码等手段，防御恶意高频请求。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;消息队列&lt;/h2&gt;
&lt;h3&gt;4. 如何保证消息消费的幂等性？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;消息队列通常按至少一次投递处理。消费者处理成功后、提交消费结果前宕机，Broker 会重新投递消息；消费者扩缩容和重平衡也可能带来重复消费。所以幂等要由业务侧保证。&lt;/p&gt;
&lt;p&gt;核心思路：给每条业务事件一个稳定的唯一标识，在数据库中用唯一约束或状态机保证同一事件只生效一次。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;使用业务唯一键&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;不建议只用 MQ 的 &lt;code&gt;msgId&lt;/code&gt;。重试或重新投递场景下可能出现不同消息 ID。常用业务幂等键包括订单创建的 &lt;code&gt;orderId&lt;/code&gt;、支付回调的 &lt;code&gt;paymentId&lt;/code&gt; 或第三方流水号、发券的 &lt;code&gt;userId + couponId + activityId&lt;/code&gt;、库存扣减的 &lt;code&gt;orderId + skuId&lt;/code&gt;，以及业务主键加事件类型和版本号。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;数据库唯一索引兜底&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;可以建立消费记录表或业务流水表：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE TABLE consumed_event (
    event_key VARCHAR(128) PRIMARY KEY,
    created_at DATETIME NOT NULL
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;消费时，在同一个数据库事务中先插入 &lt;code&gt;event_key&lt;/code&gt;，再执行业务更新并提交事务。第一次消费插入成功；重复消费时唯一索引冲突，说明业务已经处理过，直接返回成功。这样即使数据库已提交但 ACK 前消费者宕机，消息重投后也不会重复扣库存、重复发券或重复创建订单。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;状态机或条件更新&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;对状态流转类消息，可以使用条件更新：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UPDATE orders
SET status = &apos;PAID&apos;
WHERE order_id = ? AND status = &apos;UNPAID&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;受影响行数为 1 表示本次成功处理；为 0 表示订单已处理或状态不符合。对有顺序要求的事件，再配合版本号，只接受符合预期版本的更新，避免旧消息覆盖新状态。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;业务成功后再确认消息&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;先完成数据库事务，再返回消费成功或提交 offset。处理失败时返回失败，让 MQ 重试；超过最大重试次数进入死信队列，由人工或补偿任务处理。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Redis 去重只能做辅助&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;可以用 &lt;code&gt;SET key value NX EX&lt;/code&gt; 做短期快速去重，但 Redis 过期、故障或主从切换后仍可能重复消费。扣款、订单、库存等核心链路，应由数据库唯一索引或条件更新做最终兜底。&lt;/p&gt;
&lt;p&gt;总结：MQ 无法完全避免重复投递，所以消费者必须幂等。实践中使用业务唯一键，在同一数据库事务内写入消费记录并执行业务操作，依赖唯一索引防重；状态更新通过条件更新或版本号控制；业务成功后再 ACK，重复消息直接返回成功。&lt;/p&gt;
&lt;h3&gt;5. 使用哪一种消息队列？为什么使用 RocketMQ？&lt;/h3&gt;
&lt;p&gt;待补充。&lt;/p&gt;
&lt;h2&gt;算法&lt;/h2&gt;
&lt;h3&gt;6. 算法：反转链表的一个区间&lt;/h3&gt;
&lt;p&gt;待补充。&lt;/p&gt;
&lt;h3&gt;7. 算法：编辑距离&lt;/h3&gt;
&lt;p&gt;待补充。&lt;/p&gt;
&lt;h2&gt;操作系统&lt;/h2&gt;
&lt;h3&gt;8. 进程间的通信方式有哪些？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;进程之间地址空间隔离，不能直接访问对方内存。常见 IPC 方式有管道、消息队列、共享内存、信号量、信号、Socket 和内存映射文件。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;匿名管道 Pipe&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;管道是内核维护的字节流缓冲区，通常用于有亲缘关系的进程，例如父子进程。它通常单向通信，双向通信需要两条管道；Shell 的 &lt;code&gt;A | B&lt;/code&gt; 就是典型使用场景。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;命名管道 FIFO&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;命名管道有文件路径，两个没有亲缘关系的本机进程也可以通信。本质仍是管道字节流，适合同机简单通信。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;消息队列&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;内核维护消息队列，进程按消息发送和接收，保留消息边界，适合多个本机进程通信。数据需要经过内核拷贝，吞吐通常低于共享内存。操作系统消息队列与 RocketMQ、Kafka 这类分布式消息队列属于不同层面的概念。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;共享内存&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;多个进程将同一块物理内存映射到各自虚拟地址空间，直接读写共享区域。它速度最快，适合本机高吞吐、大量数据交换；多个进程并发读写时需要配合互斥锁、读写锁或信号量保证同步。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;信号量&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;信号量主要用于进程同步和互斥，通常配合共享内存使用。例如生产者写入共享内存后通知消费者，消费者读取时避免读到未写完的数据。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;信号 Signal&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;信号用于向目标进程发送通知，例如 &lt;code&gt;SIGTERM&lt;/code&gt; 请求退出、&lt;code&gt;SIGKILL&lt;/code&gt; 强制结束、&lt;code&gt;SIGHUP&lt;/code&gt; 通知重新加载配置。信号携带的信息有限，适合事件通知，不适合传递大量业务数据。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Socket&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Socket 可以支持同机和跨机器通信。本机常用 Unix Domain Socket，跨机器通常使用 TCP 或 UDP Socket。微服务之间的 HTTP、RPC、gRPC 调用，底层都依赖 Socket。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;内存映射文件 mmap&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;多个进程将同一个文件映射到内存，访问内存即可读写文件内容；映射同一文件时可以共享数据。它适合大文件读写和进程间共享大量数据，也需要考虑并发同步。&lt;/p&gt;
&lt;p&gt;总结：少量简单数据可用管道或消息队列；本机高吞吐数据交换用共享内存加信号量；事件通知用信号；跨机器或服务间通信用 Socket；大文件共享可使用 mmap。&lt;/p&gt;
</content:encoded></item><item><title>京东后端面试复盘（二）</title><link>https://blog.huangnv.online/posts/26713/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26713/1/1/</guid><pubDate>Mon, 13 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;京东面试二&lt;/h1&gt;
&lt;h2&gt;认证与安全&lt;/h2&gt;
&lt;h3&gt;1. JWT 为什么是无状态的？相比 Session 有哪些优势？如果 JWT 被窃取，攻击者冒充登录怎么办？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;JWT 无状态，指服务端不保存每个用户的登录会话。&lt;/p&gt;
&lt;p&gt;用户登录成功后，认证服务把用户 ID、角色、过期时间等信息放进 JWT，并用密钥签名。客户端后续携带 JWT 请求，网关或业务服务直接验签和校验过期时间即可完成鉴权，不需要再去 Redis 或数据库查询 Session。&lt;/p&gt;
&lt;p&gt;Session 的流程是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;客户端携带 Session ID
-&amp;gt; 服务端从 Redis 或内存查询 Session
-&amp;gt; 获取用户登录信息
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JWT 的流程是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;客户端携带 JWT
-&amp;gt; 服务端验签
-&amp;gt; 解析用户 ID、角色、过期时间
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JWT 的优势主要有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;分布式部署方便。多个服务只要具备验签能力，就能独立完成鉴权，不依赖共享 Session 或粘性会话。&lt;/li&gt;
&lt;li&gt;减少一次查询 Session 的网络开销。&lt;/li&gt;
&lt;li&gt;适合网关统一鉴权和微服务间传递用户身份。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;实际项目通常采用 Access Token + Refresh Token 的策略：&lt;/p&gt;
&lt;p&gt;两者通常分开设计：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Access Token：短期 JWT，客户端携带它访问业务接口。
Refresh Token：高随机度字符串，服务端在 Redis 或数据库保存它的有效状态。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Access Token 过期后，服务端只需拒绝请求；Refresh Token 由服务端保存和管理，便于按用户或设备主动删除、轮换和失效控制。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;用户登录成功后，服务端下发两个 Token。Access Token 用于访问业务接口，时间设置较短，例如 15～30 分钟；Refresh Token 用于续期，时间可以设置为 7～30 天。&lt;/li&gt;
&lt;li&gt;客户端请求业务接口时只携带 Access Token。网关或业务服务验签并校验过期时间，校验通过后放行。&lt;/li&gt;
&lt;li&gt;Access Token 过期后，客户端携带 Refresh Token 调用刷新接口。服务端校验 Refresh Token 是否有效，成功后签发新的 Access Token。&lt;/li&gt;
&lt;li&gt;Refresh Token 通常在 Redis 或数据库中保存登录设备、过期时间和状态。退出登录、修改密码、风险登录时删除对应记录，使该设备无法继续刷新 Token。&lt;/li&gt;
&lt;li&gt;刷新时可以轮换 Refresh Token：签发新的 Refresh Token，同时让旧 Token 失效，降低 Refresh Token 被长期重放的风险。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这样，短期 Access Token 保证接口鉴权的高性能，Refresh Token 提供续期和主动失效能力。&lt;/p&gt;
&lt;p&gt;JWT 被盗后，攻击者可以直接带着 Token 请求接口，服务端验签仍会通过。因此 JWT 被盗的本质是 Bearer Token 被重放。&lt;/p&gt;
&lt;p&gt;常见处理方式：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;全站 HTTPS，避免 Token 在传输过程中被窃取。&lt;/li&gt;
&lt;li&gt;Access Token 设置较短过期时间，例如 15 分钟；过期后使用 Refresh Token 换取新的 Access Token。&lt;/li&gt;
&lt;li&gt;Refresh Token 存在 Redis 或数据库中，退出登录、修改密码、发现风险登录时删除 Refresh Token 或加入黑名单，强制用户重新登录。&lt;/li&gt;
&lt;li&gt;浏览器场景将 Token 放在带 &lt;code&gt;HttpOnly&lt;/code&gt;、&lt;code&gt;Secure&lt;/code&gt;、&lt;code&gt;SameSite&lt;/code&gt; 属性的 Cookie 中，并做好 XSS 和 CSRF 防护。&lt;/li&gt;
&lt;li&gt;JWT Payload 可以被解码查看，里面只放用户 ID、角色等必要信息，不放密码和敏感隐私数据。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：JWT 的核心价值是把登录信息放到 Token 中，通过验签完成无状态鉴权，适合分布式和微服务场景。JWT 的缺点是主动失效能力较弱，所以实际项目通常使用短期 Access Token 加 Refresh Token，并在 Redis 中维护 Refresh Token 或黑名单。&lt;/p&gt;
&lt;h2&gt;Redis 与高并发&lt;/h2&gt;
&lt;h3&gt;2. 项目中如何解决超卖？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;超卖的本质是高并发下，多个请求同时判断库存充足，随后都完成扣减，导致库存变成负数或卖出的数量超过实际库存。&lt;/p&gt;
&lt;h4&gt;普通并发：数据库条件更新兜底&lt;/h4&gt;
&lt;p&gt;库存扣减使用带条件的一条 SQL：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UPDATE sku
SET stock = stock - #{count}
WHERE sku_id = #{skuId}
  AND stock &amp;gt;= #{count};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;通过受影响行数判断结果：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;返回 &lt;code&gt;1&lt;/code&gt;：扣减成功，可以创建订单。&lt;/li&gt;
&lt;li&gt;返回 &lt;code&gt;0&lt;/code&gt;：库存不足，直接返回售罄。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这条 SQL 是原子操作，数据库库存作为最终兜底。创建订单、扣库存、记录扣减流水放在同一个本地事务中，并给订单或扣减流水加唯一约束，保证重复请求不会重复扣减。&lt;/p&gt;
&lt;h4&gt;秒杀等高并发：Redis 预扣库存 + MQ 异步下单&lt;/h4&gt;
&lt;p&gt;秒杀场景下，全部请求直接打 MySQL 会形成热点行竞争，因此在 Redis 做库存预扣：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;请求到达
-&amp;gt; Redis Lua 校验库存、校验是否重复购买、预扣库存
-&amp;gt; 预扣成功，发送消息到 MQ
-&amp;gt; 消费者异步创建订单，并通过数据库条件更新扣减最终库存
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Lua 脚本中完成“判断库存是否充足 + 扣减库存 + 记录用户已抢购”的逻辑。Redis 单线程执行脚本，整个过程具备原子性。&lt;/p&gt;
&lt;h4&gt;异常与一致性处理&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;消费者处理消息时做幂等：订单号、业务流水号设置唯一索引，消息重复投递也只会成功一次。&lt;/li&gt;
&lt;li&gt;数据库扣减失败或订单创建失败时，需要补偿 Redis 库存。&lt;/li&gt;
&lt;li&gt;Redis 宕机或数据异常时，以数据库库存为准，通过定时任务或对账任务修正 Redis 库存。&lt;/li&gt;
&lt;li&gt;用户请求使用幂等号，避免重复点击导致多次下单。&lt;/li&gt;
&lt;li&gt;限购场景在 Lua 中记录用户购买标记，防止同一用户重复抢购。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：普通库存通过 &lt;code&gt;stock &amp;gt;= count&lt;/code&gt; 的条件更新保证不超卖；秒杀场景用 Redis Lua 预扣库存削峰，再通过 MQ 异步落库，数据库条件更新作为最终一致性兜底。&lt;/p&gt;
&lt;h3&gt;3. 为什么 Lua 脚本能够保证原子性？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Redis 中 Lua 脚本的原子性，来自 Redis 将整段脚本作为一个命令执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;客户端执行 EVAL / EVALSHA
-&amp;gt; Redis 执行整段 Lua 脚本
-&amp;gt; 脚本执行完成
-&amp;gt; Redis 再处理其他客户端命令
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Redis 执行 Lua 脚本期间，不会切换去执行其他客户端的命令。因此脚本里的多条 Redis 操作不会被其他请求插入，最终效果相当于一个原子操作。&lt;/p&gt;
&lt;p&gt;例如秒杀扣库存：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;local stock = tonumber(redis.call(&apos;GET&apos;, KEYS[1]))

if stock == nil or stock &amp;lt;= 0 then
    return 0
end

redis.call(&apos;DECR&apos;, KEYS[1])
redis.call(&apos;SADD&apos;, KEYS[2], ARGV[1])
return 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个脚本一次完成库存校验、扣减库存和记录用户已购买。多个请求并发执行时，Redis 串行执行脚本；后一个请求会读到前一个请求扣减后的最新库存。&lt;/p&gt;
&lt;h4&gt;使用边界&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;Lua 原子性不等于数据库事务回滚。脚本运行到一半发生错误时，前面已经成功执行的 Redis 命令会保留。因此应先完成参数和库存校验，再执行写操作。&lt;/li&gt;
&lt;li&gt;脚本必须短小。Lua 执行期间会阻塞 Redis 对其他命令的处理，脚本中不应写大循环、复杂计算或耗时逻辑。&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;Redis Cluster 场景&lt;/h4&gt;
&lt;p&gt;Lua 脚本访问多个 Key 时，这些 Key 必须位于同一个 Hash Slot。Key 分散在不同 Slot 时，Redis Cluster 直接拒绝执行，报错：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CROSSSLOT Keys in request don&apos;t hash to the same slot
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;脚本不会开始执行，也不会出现部分命令已执行的情况。&lt;/p&gt;
&lt;p&gt;常用 Hash Tag 让相关 Key 路由到同一分片：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;stock:{sku1001}
buyers:{sku1001}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Redis Cluster 只使用花括号中的内容计算 Slot，因此这两个 Key 会落到同一节点，Lua 才能原子访问。跨多个 SKU、跨分片的场景无法依赖单个 Lua 脚本保证原子性，通常按 SKU 拆分处理，再通过消息队列、订单状态和补偿机制保证最终一致性。&lt;/p&gt;
&lt;p&gt;总结：Lua 脚本通过 Redis 串行执行，保证“校验 + 扣减 + 标记”等多步 Redis 操作不会被其他请求插入。&lt;/p&gt;
&lt;h3&gt;4. 并发量特别大时如何应对？请说明 Redis Cluster 的方案。&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;并发量特别大时，我会先判断瓶颈在网关、应用、Redis 还是数据库。Redis Cluster 主要解决 Redis 单实例内存和单节点 QPS 的上限问题，通过数据分片把请求分散到多个节点。&lt;/p&gt;
&lt;h4&gt;Redis Cluster 的核心原理&lt;/h4&gt;
&lt;p&gt;Redis Cluster 将 Key 空间划分为 &lt;code&gt;16384&lt;/code&gt; 个 Hash Slot：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;slot = CRC16(key) % 16384
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每个主节点负责一部分 Slot：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Node A -&amp;gt; Slot 0 ~ 5460
Node B -&amp;gt; Slot 5461 ~ 10922
Node C -&amp;gt; Slot 10923 ~ 16383
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;客户端根据 Key 计算 Slot，直接请求负责该 Slot 的主节点。因此不同 Key 的读写请求可以分散到多个 Redis 主节点处理。生产上每个主节点会配置从节点，主节点故障后，Cluster 会从对应从节点中选举新主节点继续提供服务。&lt;/p&gt;
&lt;h4&gt;按业务 Key 分片&lt;/h4&gt;
&lt;p&gt;例如用户数据、商品数据按用户 ID 或商品 ID 设计 Key：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user:1001
user:1002
product:2001
product:2002
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不同 Key 会分布到不同 Slot 和不同节点，QPS 可以随节点数增加而横向扩展。&lt;/p&gt;
&lt;h4&gt;避免热点 Key&lt;/h4&gt;
&lt;p&gt;一个热点 Key 始终只属于一个 Slot，也只会压在一个主节点上。即使 Cluster 有多个分片，也无法自动分散单个 Key 的压力：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;product:1001 -&amp;gt; Slot 8000 -&amp;gt; Node B

100 万 QPS 都访问 product:1001
-&amp;gt; 请求都落到 Node B
-&amp;gt; Node B 成为瓶颈
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;读多写少的热点数据，例如商品详情、热点配置，使用多级缓存：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;请求
-&amp;gt; Caffeine 本地缓存命中，直接返回
-&amp;gt; 本地未命中，查询 Redis
-&amp;gt; Redis 未命中，查询 MySQL 并回填
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本地缓存将读请求分摊到各应用机器。热点数据更新时，可以更新 MySQL 后删除 Redis，并通过消息通知各应用机器清理本地缓存；也可设置较短 TTL，接受短暂旧数据。热点失效时通过请求合并，只允许一个线程回源，其他线程等待结果。&lt;/p&gt;
&lt;p&gt;计数器这类写热点可以做分片。原来所有写请求更新一个 Key：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;INCR like:article:1001
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;现在将总数拆成 16 个部分：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;like:article:1001:0
like:article:1001:1
...
like:article:1001:15
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;写入时根据用户 ID 哈希或随机值选择一个分片执行 &lt;code&gt;INCR&lt;/code&gt;。每个分片记录的是总点赞数的一部分，查询时由应用并行读取各分片后求和。这样写压力会分散到多个 Key、多个 Slot 和节点，适合点赞数、浏览量等允许短暂延迟汇总的指标。&lt;/p&gt;
&lt;p&gt;库存不能将同一份库存直接复制到多个 Key。例如真实库存为 100，下面的设计会导致系统认为库存有 200：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;stock:sku1001:0 = 100
stock:sku1001:1 = 100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;极端热点下可以使用库存分桶，但每个桶只能保存真实库存的一部分：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;真实库存：1000
stock:sku1001:0 = 250
stock:sku1001:1 = 250
stock:sku1001:2 = 250
stock:sku1001:3 = 250
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;请求选择一个桶后，用 Lua 原子校验和扣减该桶库存。库存分桶需要额外处理空桶重试、回补、幂等和对账，常规秒杀先使用单库存 Key 的 Lua 预扣、限流和 MQ 削峰；单 Key 已成为明确瓶颈时，再考虑库存分桶。&lt;/p&gt;
&lt;h4&gt;避免大 Key&lt;/h4&gt;
&lt;p&gt;大 Key 常见形式包括：单个 String 存储数 MB 的 JSON，或一个 Hash、List、Set、ZSet 保存数万甚至更多元素。大 Key 会带来网络传输、序列化、主从复制、持久化和 Slot 迁移开销；对大集合执行 &lt;code&gt;HGETALL&lt;/code&gt;、&lt;code&gt;SMEMBERS&lt;/code&gt;、大范围 &lt;code&gt;ZRANGE&lt;/code&gt; 或直接 &lt;code&gt;DEL&lt;/code&gt;，还可能影响同节点其他请求。&lt;/p&gt;
&lt;p&gt;常见处理方式：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;文章正文、文件等大对象放数据库或对象存储，Redis 只缓存摘要、ID、版本号、访问地址等小数据。&lt;/li&gt;
&lt;li&gt;大集合按日期、分页、用户范围或分片编号拆分，读取时分页查询指定分片。&lt;/li&gt;
&lt;li&gt;删除大 Key 使用 &lt;code&gt;UNLINK&lt;/code&gt;，由后台线程异步释放内存，减少 Redis 主线程阻塞。&lt;/li&gt;
&lt;li&gt;结合 &lt;code&gt;redis-cli --bigkeys&lt;/code&gt;、&lt;code&gt;MEMORY USAGE&lt;/code&gt;、慢查询日志和业务监控定位大 Key；线上避免执行 &lt;code&gt;KEYS *&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;缓存和数据库保护&lt;/h4&gt;
&lt;p&gt;Redis Cluster 仍需要配合缓存治理：热点数据提前预热；缓存穿透使用布隆过滤器和空值缓存；缓存击穿使用逻辑过期、互斥重建或请求合并；缓存雪崩通过过期时间打散、限流、熔断和降级保护数据库。&lt;/p&gt;
&lt;h4&gt;扩容和故障转移&lt;/h4&gt;
&lt;p&gt;扩容时新增节点并迁移部分 Slot。迁移期间客户端可能收到 &lt;code&gt;MOVED&lt;/code&gt; 或 &lt;code&gt;ASK&lt;/code&gt; 重定向，更新 Slot 与节点的映射后重试请求。每个主节点配置从节点，主节点故障后由从节点提升为主节点。&lt;/p&gt;
&lt;p&gt;总结：Redis Cluster 通过 &lt;code&gt;16384&lt;/code&gt; 个 Hash Slot 将数据和请求分散到多个主节点，解决单实例容量和 QPS 的瓶颈。实际高并发场景还要处理热点 Key、大 Key、缓存保护和数据库兜底。&lt;/p&gt;
&lt;h3&gt;5. Redis Cluster 如何实现主从复制？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Redis Cluster 中，每个 Slot 由一个主节点负责，主节点可以配置一个或多个从节点：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Master A：负责部分 Slot
  ├─ Replica A1
  └─ Replica A2

Master B：负责另一部分 Slot
  └─ Replica B1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所有写请求先写入对应的主节点，主节点再将写命令异步同步给从节点。&lt;/p&gt;
&lt;h4&gt;首次同步：全量复制&lt;/h4&gt;
&lt;p&gt;从节点第一次连接主节点，或复制积压缓冲区无法覆盖断线期间的缺失数据时，会触发全量复制：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;从节点连接主节点，发送 PSYNC
-&amp;gt; 主节点 fork 子进程执行 BGSAVE，生成 RDB 快照
-&amp;gt; 主进程继续处理客户端读写
-&amp;gt; 主节点将生成 RDB 期间的新写命令写入复制缓冲区
-&amp;gt; 主节点发送 RDB 给从节点
-&amp;gt; 从节点加载 RDB
-&amp;gt; 主节点按顺序发送复制缓冲区中的增量命令
-&amp;gt; 从节点追上主节点进度，进入实时复制
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;全量复制期间主节点不会为同步而锁住用户写请求。&lt;code&gt;BGSAVE&lt;/code&gt; 的子进程基于 fork 时刻的内存视图生成 RDB，主进程继续处理请求。用户在快照期间修改的数据会进入复制缓冲区，在从节点加载 RDB 后按顺序补发。&lt;/p&gt;
&lt;p&gt;例如 RDB 快照中的 &lt;code&gt;key = 10&lt;/code&gt;，生成 RDB 期间用户执行 &lt;code&gt;SET key 11&lt;/code&gt;。从节点先从 RDB 得到 &lt;code&gt;key = 10&lt;/code&gt;，再执行缓冲区中的 &lt;code&gt;SET key 11&lt;/code&gt;，最终数据与主节点一致。&lt;/p&gt;
&lt;p&gt;fork 依赖操作系统的 Copy-On-Write。高写入期间会产生额外内存页复制，因此全量同步时需要预留足够内存，避免频繁触发。&lt;/p&gt;
&lt;p&gt;从节点加载新 RDB 的短暂阶段，需要清空旧数据并加载新数据，可能暂时无法响应客户端请求。同步开始前，从节点可以按配置继续使用旧数据提供读服务，因此副本读取需要接受最终一致性。&lt;/p&gt;
&lt;h4&gt;网络短暂断开：增量复制&lt;/h4&gt;
&lt;p&gt;从节点会记录主节点的复制 ID 和复制偏移量：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;replid + offset
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;网络恢复后：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;从节点携带 replid + offset 重新发送 PSYNC
-&amp;gt; 主节点检查 replication backlog
-&amp;gt; 缓冲区保留了缺失命令：发送缺失的增量命令
-&amp;gt; 缓冲区已无法覆盖：退化为全量复制
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;增量复制不生成 RDB，主节点继续处理客户端请求，恢复速度比全量复制快。&lt;/p&gt;
&lt;h4&gt;异步复制、WAIT 与写保护&lt;/h4&gt;
&lt;p&gt;Redis 主从复制默认异步：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;客户端写入 Master
-&amp;gt; Master 执行成功并返回客户端
-&amp;gt; Master 异步将命令发送给 Replica
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;主节点刚返回成功就宕机，而该数据尚未同步到从节点时，故障转移后可能丢失这次写入。&lt;/p&gt;
&lt;p&gt;关键写入可以使用 &lt;code&gt;WAIT&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;写入 Master
-&amp;gt; 当前客户端等待指定数量 Replica 的确认或超时
-&amp;gt; 收到确认后继续业务流程
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;WAIT&lt;/code&gt; 只等待当前客户端请求，不会阻塞 Redis 处理其他客户端命令。它能降低故障转移时的数据丢失概率，业务仍按最终一致性处理重试和补偿。&lt;/p&gt;
&lt;p&gt;还可以配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;min-replicas-to-write
min-replicas-max-lag
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;满足副本数量和延迟要求时主节点才接受写入；条件不满足时直接拒绝新的写请求。该机制限制数据丢失窗口。&lt;/p&gt;
&lt;h4&gt;故障转移&lt;/h4&gt;
&lt;p&gt;主节点故障后，Cluster 会从其从节点中选举一个提升为新主节点，并接管原主节点负责的 Slot。旧主节点恢复后，通常作为新主节点的从节点重新同步数据。&lt;/p&gt;
&lt;p&gt;总结：Redis Cluster 通过主节点处理写入、从节点异步复制实现高可用；首次同步使用“RDB 快照 + 缓冲区增量命令”，短暂断线通过 &lt;code&gt;PSYNC&lt;/code&gt;、&lt;code&gt;replid&lt;/code&gt; 和 &lt;code&gt;offset&lt;/code&gt; 做增量复制。全量同步期间主节点继续处理用户请求，关键写入可结合 &lt;code&gt;WAIT&lt;/code&gt; 和副本延迟保护降低数据丢失风险。&lt;/p&gt;
&lt;h3&gt;6. 请说明 Redis Sentinel（哨兵）的工作机制。&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Redis Sentinel 用于非 Cluster 模式下的 Redis 高可用，负责监控主从节点、故障通知、自动故障转移，以及向客户端提供当前主节点地址。&lt;/p&gt;
&lt;p&gt;生产上通常部署至少 &lt;code&gt;3&lt;/code&gt; 个 Sentinel，并放在不同机器或可用区：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Sentinel 1
Sentinel 2
Sentinel 3

      监控
        ↓
Master
  ├─ Replica 1
  └─ Replica 2
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;故障检测&lt;/h4&gt;
&lt;p&gt;Sentinel 会定期向主节点、从节点和其他 Sentinel 发送 &lt;code&gt;PING&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;当某个 Sentinel 在 &lt;code&gt;down-after-milliseconds&lt;/code&gt; 时间内无法得到正常响应时，会标记主节点为主观下线（SDOWN）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Sentinel 1 认为 Master 不可用
-&amp;gt; SDOWN
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;多个 Sentinel 之间会互相确认。当达到配置的 &lt;code&gt;quorum&lt;/code&gt; 数量，都认为主节点不可用时，标记为客观下线（ODOWN）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Sentinel 1：Master 不可用
Sentinel 2：Master 不可用
quorum = 2
-&amp;gt; ODOWN
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;选举 Leader Sentinel&lt;/h4&gt;
&lt;p&gt;发生 ODOWN 后，多个 Sentinel 发起投票，选出一个 Leader Sentinel 执行故障转移：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ODOWN
-&amp;gt; Sentinel 发起选举
-&amp;gt; 多数 Sentinel 投票
-&amp;gt; 选出 Leader Sentinel
-&amp;gt; Leader 执行 Failover
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;选举需要多数 Sentinel 同意。网络分区时，少数分区无法提升新主节点，降低双主风险。&lt;/p&gt;
&lt;p&gt;Sentinel 使用自己的纪元和投票流程，不运行 Raft 协议。&lt;/p&gt;
&lt;h4&gt;故障转移流程&lt;/h4&gt;
&lt;p&gt;Leader Sentinel 选出一个从节点提升为新主节点，通常优先考虑：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;replica-priority&lt;/code&gt; 更高的副本。&lt;/li&gt;
&lt;li&gt;复制偏移量更大的副本。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;runid&lt;/code&gt; 更小的副本作为最终排序条件。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;具体流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;原 Master 故障
-&amp;gt; Leader Sentinel 选择最佳 Replica
-&amp;gt; 向该 Replica 发送 REPLICAOF NO ONE
-&amp;gt; Replica 晋升为新 Master
-&amp;gt; 其他 Replica 改为复制新 Master
-&amp;gt; Sentinel 通知客户端新的 Master 地址
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;应用客户端需要支持 Sentinel。连接时先向 Sentinel 查询当前主节点地址，主从切换后重新获取地址并重连新主节点。&lt;/p&gt;
&lt;h4&gt;旧主节点恢复&lt;/h4&gt;
&lt;p&gt;旧主节点恢复后，Sentinel 会将它配置为新主节点的从节点：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;旧 Master 恢复
-&amp;gt; REPLICAOF 新 Master
-&amp;gt; 同步新 Master 数据
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;风险和保护&lt;/h4&gt;
&lt;p&gt;Redis 复制默认异步，主节点已经返回成功、数据尚未同步到副本时发生故障转移，这部分写入可能丢失。&lt;/p&gt;
&lt;p&gt;网络分区时，旧主节点一侧的客户端可能继续写入旧主节点；网络恢复后，旧主节点会变为新主节点的副本，旧主上的这些写入会被覆盖。可以配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;min-replicas-to-write
min-replicas-max-lag
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当主节点无法与足够数量的副本保持同步时，拒绝新的写请求，缩小旧主继续写入的时间窗口。&lt;/p&gt;
&lt;p&gt;总结：Sentinel 通过多哨兵监控实现 SDOWN、ODOWN、Leader 选举和自动主从切换，适合单主多从 Redis 的高可用；容量和 QPS 横向扩展使用 Redis Cluster。&lt;/p&gt;
&lt;h3&gt;7. 请说明 Raft 协议。&lt;/h3&gt;
&lt;p&gt;待补充。&lt;/p&gt;
&lt;h2&gt;线上排障与 JVM&lt;/h2&gt;
&lt;h3&gt;8. 场景题：服务器发生抖动时如何排查和处理？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;服务器抖动通常表现为接口 RT 突增、P99 波动、超时或错误率上升。我会按“先确认现象，再定位资源，最后定位代码和依赖”的顺序排查。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先关联监控时间线：对齐应用 QPS、P99/P999、错误率、线程池队列长度，以及 CPU、Load Average、上下文切换、容器 CPU Throttling、JVM 堆使用率、GC 停顿、MySQL/Redis/MQ RT 等指标，确认抖动落在哪一层。&lt;/li&gt;
&lt;li&gt;CPU 高时，定位 Java 进程和具体线程的 CPU 占用，再结合线程栈判断是否存在死循环、频繁序列化、复杂计算、锁竞争或线程数过多导致的上下文切换。&lt;/li&gt;
&lt;li&gt;GC 与 RT 尖刺重合时，检查 GC 日志中的 Young GC、Old GC、Full GC、老年代使用率、对象分配速率和 &lt;code&gt;concurrent mode failure&lt;/code&gt;。进一步通过堆转储定位大对象、无界缓存或内存泄漏。&lt;/li&gt;
&lt;li&gt;下游 RT 升高时，检查数据库慢 SQL、Redis 延迟、MQ 积压、连接池耗尽和网络 IO，配合超时、限流、熔断和降级保护调用链。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;常见处理包括：减少临时对象和大对象、治理无界缓存、合理设置堆和老年代余量、控制线程池规模、修复慢 SQL 和下游瓶颈。CMS 碎片或 Full GC 频繁时，评估切换到 G1。&lt;/p&gt;
&lt;p&gt;补充追问：CPU 调度、垃圾回收、CMS 的完整回收流程，以及各阶段的 STW。&lt;/p&gt;
&lt;p&gt;CMS 全称是 Concurrent Mark Sweep，主要用于老年代垃圾回收。它的目标是降低 GC 停顿时间，所以会让一部分 GC 工作和用户线程并发执行。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;初始标记&lt;/p&gt;
&lt;p&gt;初始标记会标记 GC Roots 能直接关联到的对象。&lt;/p&gt;
&lt;p&gt;这个阶段会发生 Stop The World，但它只标记第一层直接可达对象，所以速度比较快，停顿时间通常比较短。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;并发标记&lt;/p&gt;
&lt;p&gt;并发标记会从初始标记得到的对象继续向下遍历，找出所有可达对象。&lt;/p&gt;
&lt;p&gt;这个阶段 GC 线程和用户线程可以同时运行，所以不会长时间暂停业务线程。&lt;/p&gt;
&lt;p&gt;但因为用户线程还在运行，对象引用关系可能会继续变化，所以后面还需要重新标记。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;重新标记&lt;/p&gt;
&lt;p&gt;重新标记主要是修正并发标记期间，因为用户线程继续运行导致的对象引用变化。&lt;/p&gt;
&lt;p&gt;这个阶段也会 Stop The World。&lt;/p&gt;
&lt;p&gt;相比并发标记，重新标记时间通常短一些，但会比初始标记稍微复杂，因为它要处理并发阶段产生的变化。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;并发清除&lt;/p&gt;
&lt;p&gt;并发清除会清理没有被标记到的不可达对象。&lt;/p&gt;
&lt;p&gt;这个阶段 GC 线程和用户线程也可以并发执行，所以整体停顿时间比较低。&lt;/p&gt;
&lt;p&gt;CMS 使用的是标记清除算法，只清理垃圾对象，不做整体压缩整理。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;优点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;并发标记和并发清除阶段可以和用户线程一起执行，STW 主要集中在初始标记和重新标记。&lt;/li&gt;
&lt;li&gt;适合响应时间敏感的在线服务。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;缺点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;标记清除会产生内存碎片，大对象分配可能触发 Full GC。&lt;/li&gt;
&lt;li&gt;并发阶段会消耗 CPU，影响业务线程执行效率。&lt;/li&gt;
&lt;li&gt;会产生浮动垃圾，只能留到下一轮回收。&lt;/li&gt;
&lt;li&gt;回收速度跟不上对象分配速度时，可能触发 Concurrent Mode Failure，退化为 Serial Old Full GC，停顿明显变长。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;9. JVM 如何调整堆外内存大小？&lt;/h3&gt;
&lt;p&gt;待补充。&lt;/p&gt;
&lt;h3&gt;10. 请说明 JVM 内存模型，以及方法区、永久代和元空间。&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;这道题通常问的是 JVM 的运行时数据区，不是并发编程里的 Java Memory Model（JMM）。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;线程共享：
Heap
Method Area

线程私有：
Program Counter Register
Java Virtual Machine Stack
Native Method Stack

补充：
Direct Memory
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;堆&lt;/h4&gt;
&lt;p&gt;堆保存对象实例和数组，是 GC 的主要回收区域。堆通常分为新生代和老年代：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Heap
├─ Young Generation
│  ├─ Eden
│  ├─ Survivor 0
│  └─ Survivor 1
└─ Old Generation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;java.lang.OutOfMemoryError: Java heap space&lt;/code&gt; 表示堆内存不足。&lt;/p&gt;
&lt;h4&gt;Java 虚拟机栈、程序计数器和本地方法栈&lt;/h4&gt;
&lt;p&gt;每个线程都有独立的 Java 虚拟机栈。每调用一个 Java 方法，就创建一个栈帧，包含局部变量表、操作数栈、动态链接和方法返回地址。递归过深常见 &lt;code&gt;StackOverflowError&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;程序计数器记录当前线程执行到哪一条字节码指令，线程切换后 JVM 据此恢复执行位置。&lt;/p&gt;
&lt;p&gt;本地方法栈服务于 JNI 等 Native 方法调用。&lt;/p&gt;
&lt;h4&gt;方法区&lt;/h4&gt;
&lt;p&gt;方法区是 JVM 规范定义的线程共享逻辑概念，保存类级别信息：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;类的结构信息
运行时常量池
字段和方法信息
方法字节码
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JVM 规范没有强制规定方法区的物理位置和具体实现。&lt;/p&gt;
&lt;h4&gt;永久代和元空间&lt;/h4&gt;
&lt;p&gt;永久代和元空间是 HotSpot 对方法区相关数据的不同实现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;JDK 7 及以前：PermGen（永久代）
JDK 8 及以后：Metaspace（元空间）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;永久代位于 JVM 堆内，大小受 &lt;code&gt;-XX:PermSize&lt;/code&gt; 和 &lt;code&gt;-XX:MaxPermSize&lt;/code&gt; 限制。类很多、动态生成类较多或类加载器泄漏时，可能出现 &lt;code&gt;OutOfMemoryError: PermGen space&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;JDK 8 使用元空间替代永久代。元空间使用本地内存，不占 Java 堆 &lt;code&gt;-Xmx&lt;/code&gt;。常用参数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;MetaspaceSize&lt;/code&gt; 是触发与类卸载相关 GC 的阈值，&lt;code&gt;MaxMetaspaceSize&lt;/code&gt; 用于限制元空间最大使用量。超过上限会出现 &lt;code&gt;OutOfMemoryError: Metaspace&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;永久代大小固定且难以预估。Spring、Tomcat、动态代理、CGLIB 等场景会加载大量类，永久代容易 OOM。元空间可以按需使用本地内存，降低固定永久代容量带来的问题；生产环境仍应设置合理上限并监控类加载数量。&lt;/p&gt;
&lt;h4&gt;Direct Memory&lt;/h4&gt;
&lt;p&gt;Direct Memory 属于堆外内存，NIO 和 Netty 常用。它不属于 JVM 规范中的运行时数据区，但会占用进程总内存：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-XX:MaxDirectMemorySize=1g
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;总结：堆保存对象，栈保存方法调用过程，程序计数器记录执行位置，本地方法栈服务 Native 调用；方法区保存类元数据和运行时常量池。JDK 8 使用元空间替代永久代。&lt;/p&gt;
&lt;h2&gt;Spring&lt;/h2&gt;
&lt;h3&gt;11. 请说明 Spring 中 Bean 的生命周期。&lt;/h3&gt;
&lt;p&gt;Spring 中一个类被容器管理后，会经历 Bean 的生命周期。大致流程是：创建对象、依赖注入、执行初始化回调、放入容器，后续业务使用时从容器中获取这个 Bean。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;创建 Bean 对象&lt;/p&gt;
&lt;p&gt;Spring 启动时会扫描配置类、&lt;code&gt;@Component&lt;/code&gt;、&lt;code&gt;@Service&lt;/code&gt;、&lt;code&gt;@Controller&lt;/code&gt;、&lt;code&gt;@Repository&lt;/code&gt; 等注解。扫描到 Bean 定义后，通过构造方法创建对象。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;依赖注入&lt;/p&gt;
&lt;p&gt;对象创建出来后，Spring 会处理 &lt;code&gt;@Autowired&lt;/code&gt;、构造器注入等依赖。字段注入场景下，构造方法先执行，之后才进行字段注入。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;执行 &lt;code&gt;Aware&lt;/code&gt; 回调&lt;/p&gt;
&lt;p&gt;如果 Bean 实现了 &lt;code&gt;BeanNameAware&lt;/code&gt;、&lt;code&gt;BeanFactoryAware&lt;/code&gt;、&lt;code&gt;ApplicationContextAware&lt;/code&gt; 等接口，Spring 会注入相应的容器对象。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;执行 &lt;code&gt;BeanPostProcessor&lt;/code&gt; 前置处理&lt;/p&gt;
&lt;p&gt;调用 &lt;code&gt;BeanPostProcessor#postProcessBeforeInitialization&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;执行初始化方法&lt;/p&gt;
&lt;p&gt;常见方式包括 &lt;code&gt;@PostConstruct&lt;/code&gt;、&lt;code&gt;InitializingBean#afterPropertiesSet()&lt;/code&gt; 和 &lt;code&gt;@Bean(initMethod = &quot;init&quot;)&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;执行 &lt;code&gt;BeanPostProcessor&lt;/code&gt; 后置处理&lt;/p&gt;
&lt;p&gt;调用 &lt;code&gt;BeanPostProcessor#postProcessAfterInitialization&lt;/code&gt;。AOP 代理对象通常在这个阶段创建或返回，因此最终拿到的 Bean 可能是代理对象。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Bean 投入使用与销毁&lt;/p&gt;
&lt;p&gt;初始化完成后，Bean 才能被业务使用。容器关闭时会执行 &lt;code&gt;@PreDestroy&lt;/code&gt;、&lt;code&gt;DisposableBean&lt;/code&gt; 或 &lt;code&gt;destroyMethod&lt;/code&gt; 等销毁回调。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;12. 如何保证只有完全初始化好的 Bean 才会投入使用？（&lt;code&gt;ApplicationContext.getBean()&lt;/code&gt;）&lt;/h3&gt;
&lt;p&gt;待补充。&lt;/p&gt;
&lt;h3&gt;13. 请说明 Spring 的事务传播机制。&lt;/h3&gt;
&lt;p&gt;待补充。&lt;/p&gt;
&lt;p&gt;补充记录：面试中涉及 &lt;code&gt;REQUIRED&lt;/code&gt;、&lt;code&gt;NESTED&lt;/code&gt;、&lt;code&gt;SUPPORTS&lt;/code&gt;、&lt;code&gt;NOT_SUPPORTED&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;并发与线程池&lt;/h2&gt;
&lt;h3&gt;14. 请说明线程池的调度全过程。为什么需要线程池？为什么线程切换有开销？&lt;/h3&gt;
&lt;p&gt;线程池处理任务的流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;提交任务
-&amp;gt; 当前线程数 &amp;lt; corePoolSize，创建核心线程执行
-&amp;gt; 当前线程数 &amp;gt;= corePoolSize，任务进入队列
-&amp;gt; 队列满了，并且当前线程数 &amp;lt; maximumPoolSize，创建非核心线程执行
-&amp;gt; 当前线程数 &amp;gt;= maximumPoolSize，队列也满了，触发拒绝策略
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;线程池不是核心线程满了就立刻扩容到最大线程数，而是先把任务放入队列，队列满了才继续创建非核心线程。&lt;/p&gt;
&lt;p&gt;高峰结束后，非核心线程空闲超过 &lt;code&gt;keepAliveTime&lt;/code&gt; 会被回收。核心线程默认按需创建并长期保留；可以通过 &lt;code&gt;prestartCoreThread()&lt;/code&gt;、&lt;code&gt;prestartAllCoreThreads()&lt;/code&gt; 提前创建，也可以通过 &lt;code&gt;allowCoreThreadTimeOut(true)&lt;/code&gt; 允许核心线程超时回收。&lt;/p&gt;
&lt;p&gt;线程池一般推荐使用 &lt;code&gt;ThreadPoolExecutor&lt;/code&gt; 手动创建，并使用有界队列、具备业务含义的线程名和合适的拒绝策略。&lt;/p&gt;
&lt;h4&gt;为什么需要线程池&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;复用线程，减少频繁创建和销毁线程带来的栈内存、内核线程和调度资源开销。&lt;/li&gt;
&lt;li&gt;控制并发量，避免大量请求同时访问 MySQL、Redis 或第三方接口，保护下游。&lt;/li&gt;
&lt;li&gt;通过有界队列缓冲短时突发流量，通过拒绝策略在系统饱和时快速失败或降级。&lt;/li&gt;
&lt;li&gt;便于监控活跃线程数、队列长度、任务耗时和拒绝次数，进行容量治理。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;无界队列会让任务在核心线程繁忙后持续排队，&lt;code&gt;maximumPoolSize&lt;/code&gt; 基本失去作用，并可能导致内存压力。生产环境通常使用有界队列。&lt;/p&gt;
&lt;h4&gt;为什么线程切换有开销&lt;/h4&gt;
&lt;p&gt;线程切换时，操作系统调度器需要保存当前线程的寄存器、程序计数器和栈指针等 CPU 上下文，选择下一个可运行线程，再恢复目标线程的上下文。&lt;/p&gt;
&lt;p&gt;切换后，前一个线程使用的 CPU Cache 数据和指令可能对新线程无效，导致 Cache 命中率下降、重新加载数据。线程过多会带来更多上下文切换，CPU 实际用于业务计算的时间减少，最终表现为 RT 上升、吞吐下降。&lt;/p&gt;
&lt;p&gt;总结：线程池通过复用线程、限制并发、缓冲突发和拒绝过载任务提升稳定性；线程数需要控制，避免调度和 Cache 开销抵消并发收益。&lt;/p&gt;
&lt;h3&gt;15. 如何确定线程池的线程数？&lt;/h3&gt;
&lt;p&gt;线程数不能只靠一个固定公式，先看任务是 CPU 密集、IO 密集还是混合型，再结合下游容量和压测结果确定。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;CPU 密集型任务，例如加密、压缩、图片处理、复杂计算，线程数通常接近 CPU 核数或 CPU 核数加 1。线程过多会增加上下文切换和 CPU Cache 失效。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;IO 密集型任务，例如调用 MySQL、Redis、HTTP 接口、文件 IO，线程数可以更多。常用估算是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;线程数 ≈ CPU 核数 × (1 + 等待时间 / 计算时间)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;例如 &lt;code&gt;8&lt;/code&gt; 核机器，任务平均计算 &lt;code&gt;10 ms&lt;/code&gt;、等待 IO &lt;code&gt;40 ms&lt;/code&gt;，估算起点约为 &lt;code&gt;8 × (1 + 40 / 10) = 40&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;下游容量是上限约束。任务需要访问数据库时，线程数还要受数据库连接池大小限制。数据库连接池最大为 &lt;code&gt;20&lt;/code&gt; 时，线程池设置为 &lt;code&gt;100&lt;/code&gt; 会导致大量线程等待连接，增加 RT 和上下文切换。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;混合型业务应隔离线程池，例如订单核心线程池、外部 HTTP 调用线程池、MQ 消费线程池、定时任务线程池，避免慢任务耗尽核心线程池。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;最终以压测为准，重点观察 CPU、P99 RT、活跃线程数、队列积压、拒绝次数、上下文切换，以及 MySQL、Redis、HTTP 连接池使用率和下游 RT。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：CPU 密集型线程数接近 CPU 核数；IO 密集型可根据等待时间适当增加；最终必须受下游容量、队列长度和压测结果约束。&lt;/p&gt;
&lt;h2&gt;MySQL&lt;/h2&gt;
&lt;h3&gt;16. MySQL 的慢 SQL 如何调优？&lt;/h3&gt;
&lt;p&gt;分析慢 SQL，一般分成线上定位、执行计划分析、索引分析、执行过程分析和业务数据量分析。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;先定位慢 SQL&lt;/p&gt;
&lt;p&gt;线上可以通过监控、链路追踪、数据库平台统计和合理阈值的慢查询日志定位。&lt;/p&gt;
&lt;p&gt;重点看接口 RT、数据库 CPU、IO、连接数、Top SQL、扫描行数和执行次数。慢查询日志适合设置合理阈值或在排查阶段临时开启。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;用 &lt;code&gt;EXPLAIN&lt;/code&gt; 看执行计划&lt;/p&gt;
&lt;p&gt;重点关注：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type&lt;/code&gt;：尽量达到 &lt;code&gt;ref&lt;/code&gt;、&lt;code&gt;range&lt;/code&gt;、&lt;code&gt;const&lt;/code&gt;，避免 &lt;code&gt;ALL&lt;/code&gt; 全表扫描。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;possible_keys&lt;/code&gt;：理论可使用的索引。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;key&lt;/code&gt;：实际使用的索引。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rows&lt;/code&gt;、&lt;code&gt;filtered&lt;/code&gt;：预估扫描行数和过滤比例。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Extra&lt;/code&gt;：关注 &lt;code&gt;Using filesort&lt;/code&gt;、&lt;code&gt;Using temporary&lt;/code&gt;、&lt;code&gt;Using index&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;分析索引和回表&lt;/p&gt;
&lt;p&gt;检查 &lt;code&gt;WHERE&lt;/code&gt; 条件、联合索引最左前缀、函数或隐式类型转换、排序分组字段和索引区分度。&lt;/p&gt;
&lt;p&gt;只查询必要字段，避免 &lt;code&gt;SELECT *&lt;/code&gt;；可通过覆盖索引减少回表。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;分析排序、分页和锁等待&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Using filesort&lt;/code&gt;、&lt;code&gt;Using temporary&lt;/code&gt; 往往意味着额外排序或临时表。深分页如 &lt;code&gt;LIMIT 100000, 20&lt;/code&gt; 可改为基于游标或主键的分页。&lt;/p&gt;
&lt;p&gt;SQL 慢也可能由锁等待、长事务、死锁或大事务导致，可结合 &lt;code&gt;SHOW PROCESSLIST&lt;/code&gt;、&lt;code&gt;performance_schema&lt;/code&gt; 和 InnoDB 锁等待信息排查。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用 &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;MySQL 8 可以通过 &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; 查看真实耗时和实际扫描行数，判断优化器预估是否偏差。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：先定位慢 SQL，再用 &lt;code&gt;EXPLAIN&lt;/code&gt; 或 &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; 分析执行计划，最后从索引、回表、排序、分页、数据量和锁等待等方向优化。&lt;/p&gt;
&lt;h3&gt;17. 哪些情况会导致索引失效？&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;对索引列使用函数或计算。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT * FROM user WHERE DATE(create_time) = &apos;2026-07-06&apos;;
SELECT * FROM user WHERE age + 1 = 19;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;隐式类型转换。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;phone&lt;/code&gt; 是 &lt;code&gt;VARCHAR&lt;/code&gt; 时，传入数字可能促使 MySQL 转换字段：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT * FROM user WHERE phone = 13800000000;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;应按字符串传参：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT * FROM user WHERE phone = &apos;13800000000&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;联合索引不满足最左前缀原则，或范围查询后的列无法继续用于精确定位。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;LIKE&lt;/code&gt; 左模糊查询。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT * FROM user WHERE name LIKE &apos;%张&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;OR&lt;/code&gt; 条件中有列没有索引。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;!=&lt;/code&gt;、&lt;code&gt;NOT IN&lt;/code&gt;、&lt;code&gt;NOT LIKE&lt;/code&gt; 等条件返回范围很大，优化器可能放弃索引。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;索引区分度低或返回数据量过大，回表成本高，优化器可能选择全表扫描。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：函数或计算、隐式类型转换、最左前缀不满足、左模糊、部分 &lt;code&gt;OR&lt;/code&gt;、不等于类条件和低选择性，都会导致索引无法使用或优化器主动不使用。&lt;/p&gt;
&lt;h3&gt;18. 有索引的情况下，一条 &lt;code&gt;UPDATE&lt;/code&gt; 语句执行很慢，可能是什么原因？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;有索引只说明 MySQL 可以更快定位数据。&lt;code&gt;UPDATE&lt;/code&gt; 的耗时还包括锁等待、扫描范围、索引维护、日志写入和事务提交。&lt;/p&gt;
&lt;h4&gt;当前读和锁等待&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;UPDATE&lt;/code&gt; 走当前读：根据索引定位记录后，尝试获取排他锁，读取当前最新版本，再写入新版本并生成 Undo Log、Redo Log。&lt;/p&gt;
&lt;p&gt;热点行竞争是最常见原因：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UPDATE account
SET balance = balance - 100
WHERE id = 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;大量请求同时更新 &lt;code&gt;id = 1&lt;/code&gt; 时，只能串行获得该行的锁。排查时重点看 &lt;code&gt;performance_schema.data_locks&lt;/code&gt;、&lt;code&gt;performance_schema.data_lock_waits&lt;/code&gt; 和长事务。&lt;/p&gt;
&lt;h4&gt;索引命中，但扫描范围很大&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;UPDATE orders
SET status = 2
WHERE status = 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;status&lt;/code&gt; 即使有索引，区分度低时仍可能命中几十万行。需要通过 &lt;code&gt;EXPLAIN&lt;/code&gt; 检查 &lt;code&gt;rows&lt;/code&gt;、&lt;code&gt;type&lt;/code&gt; 和 &lt;code&gt;key&lt;/code&gt;，比较扫描行数和实际更新行数。&lt;/p&gt;
&lt;p&gt;范围条件和非唯一索引条件还可能产生 Next-Key Lock，扩大锁范围：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UPDATE orders
SET status = 2
WHERE create_time &amp;gt;= &apos;2026-07-14 00:00:00&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;高并发场景尽量通过主键或唯一索引做等值更新：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UPDATE orders
SET status = 2
WHERE id = ?;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;缺少合适索引时的锁范围&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;UPDATE&lt;/code&gt; 没有合适索引时会扫描聚簇索引中的大量记录，并在扫描过程中加锁，再判断 &lt;code&gt;WHERE&lt;/code&gt; 条件。默认 &lt;code&gt;REPEATABLE READ&lt;/code&gt; 下，锁范围可能接近全表，表现为其他 &lt;code&gt;UPDATE&lt;/code&gt;、&lt;code&gt;DELETE&lt;/code&gt;、&lt;code&gt;INSERT&lt;/code&gt; 大量等待。&lt;/p&gt;
&lt;p&gt;底层主要是扫描到的索引记录锁和间隙锁，不是 &lt;code&gt;LOCK TABLES&lt;/code&gt; 形式的表锁。MySQL 官方说明：没有合适索引导致全表扫描时，表中每行都可能被锁住。&lt;/p&gt;
&lt;h4&gt;更新索引列的写放大&lt;/h4&gt;
&lt;p&gt;更新字段出现在多个二级索引中时，除了更新聚簇索引记录，还要维护这些二级索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;更新记录
-&amp;gt; 写 Undo Log、Redo Log
-&amp;gt; 修改聚簇索引
-&amp;gt; 维护相关二级索引
-&amp;gt; 写 Binlog
-&amp;gt; 提交时可能等待刷盘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;索引越多、更新行越多，写放大越明显。&lt;/p&gt;
&lt;h4&gt;大事务和其他额外工作&lt;/h4&gt;
&lt;p&gt;一次更新大量数据会产生大量 Undo Log、Redo Log、Binlog、脏页和锁记录，也会增加回滚成本、主从复制延迟和刷盘压力。通常按主键范围分批更新，每批控制在合理数量并尽快提交。&lt;/p&gt;
&lt;p&gt;还要检查 Trigger、外键检查、级联更新、Buffer Pool 命中率、磁盘 IO、数据库 CPU 和连接数。&lt;/p&gt;
&lt;p&gt;排查顺序：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;确认执行时间还是锁等待
-&amp;gt; EXPLAIN 看扫描范围
-&amp;gt; 查锁等待和长事务
-&amp;gt; 检查更新字段涉及的索引数量
-&amp;gt; 检查更新行数和事务大小
-&amp;gt; 检查 Trigger、外键、Redo/Binlog、IO 和 CPU
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;总结：有索引的 &lt;code&gt;UPDATE&lt;/code&gt; 仍然可能慢，最常见是锁等待、热点行竞争、低区分度索引扫描大量记录、范围锁、更新索引列带来的索引维护，以及大事务产生的日志和刷盘压力。&lt;/p&gt;
</content:encoded></item><item><title>Agent 评测：从测试集到 Tool、Skill 与整体能力</title><link>https://blog.huangnv.online/posts/26711/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26711/1/1/</guid><description>从 Agent 为什么需要评测出发，介绍测试集、Trace、Grader、LLM Judge 和分层指标，并给出一套可以进入日常开发循环的评测方法。</description><pubDate>Sat, 11 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Agent 评测：从测试集到 Tool、Skill 与整体能力&lt;/h1&gt;
&lt;p&gt;Agent 能够调用工具、修改环境，并根据中间结果持续调整执行路径。这种能力让它可以完成比单轮问答更复杂的任务，也让质量问题变得更难观察。一次 Prompt、Skill 或 Tool 描述的调整，可能提升某类任务，同时让另一类原本稳定的任务发生退化。&lt;/p&gt;
&lt;p&gt;因此，Agent 评测需要回答的不只是“这次回答看起来好不好”，还要回答一组工程问题：任务最终是否完成，执行过程是否合理，成本是否可接受，结果能否稳定复现，以及一次修改是否破坏了已有能力。只有把这些问题转化为可重复运行的测试集和评分标准，Agent 开发才能形成稳定的迭代循环。&lt;/p&gt;
&lt;p&gt;本文从评测的必要性出发，依次介绍测试集、Trace、Grader、LLM Judge，以及 Agent、Skill、Tool 三个层级的评测方法，最后给出一套可以接入实际开发流程的最小实践。&lt;/p&gt;
&lt;h2&gt;一、为什么 Agent 开发离不开评测&lt;/h2&gt;
&lt;p&gt;传统软件的核心行为通常由显式代码和确定性规则定义。在输入、状态和运行环境一致的情况下，系统行为具有较强的可复现性；出现问题时，团队可以沿着日志、调用链、状态变化和代码逻辑逐步定位原因。&lt;/p&gt;
&lt;p&gt;Agent 系统则把概率性模型引入了执行链路，并将系统 Prompt、上下文、记忆、Skill、Tool、工作流编排和外部环境组合在一起。任何一个环节发生变化，都可能改变模型对任务的理解、工具选择和执行路径。即使输入完全相同，多次运行也可能得到不同结果。&lt;/p&gt;
&lt;p&gt;这种不确定性会在项目规模扩大后被进一步放大。一个 Agent 安装了更多 Skill，接入了更多 Tool，也开始执行更长的任务链路，各组件之间的行为影响会不断叠加。如果缺少稳定的基础测试集，团队只能通过少量人工试用或线上反馈感知质量变化，却无法确定一次修改带来了能力提升，还是引入了新的回归。&lt;/p&gt;
&lt;p&gt;评测在这里承担了三项基础职责：第一，明确什么结果可以被视为成功；第二，为不同版本提供可比较的质量基线；第三，把模糊的“Agent 变差了”转化为可以定位的工程信号。对于 Agent 项目，评测体系实际上构成了各个模型、Prompt、Skill 和 Tool 之间的行为契约。&lt;/p&gt;
&lt;h2&gt;二、与传统软件开发的差异&lt;/h2&gt;
&lt;p&gt;测试在传统软件开发和 Agent 开发中都很重要，但两类系统的测试重点并不完全相同。传统软件通常先通过需求、接口和代码定义行为，再用单元测试、集成测试和端到端测试验证实现是否满足这些定义。只要模块保持接口契约，内部实现的变化通常可以被限制在明确边界内。&lt;/p&gt;
&lt;p&gt;Agent 的正确行为经常无法完全写成一组确定规则。回答是否完整、工具选择是否合理、修改范围是否克制、计划是否过度复杂，都带有一定的语义判断。开发团队需要在实现能力的同时，通过测试样本、评分维度和人工标注逐步定义“什么才算完成得好”。评测因而会更早地进入需求和开发阶段。&lt;/p&gt;
&lt;p&gt;Agent 的行为耦合也让并行开发更加困难。一个团队修改系统 Prompt，可能改变工具调用意愿；另一个团队增加 Skill，可能改变任务分类和上下文长度；还有一个团队调整 Tool 描述，可能影响多个工作流的调用顺序。代码层面没有产生 Git 冲突，组合后的系统仍然可能出现行为冲突。&lt;/p&gt;
&lt;p&gt;因此，功能型 Agent 适合从最小可用系统开始，先建立一组代表性任务和成功标准，再逐步增加模型能力、Skill 和 Tool。每轮迭代尽量控制主要变量，并重新运行相关任务和回归测试。如果等到系统已经庞大后再补充评测，多个模块之间的影响已经混合在一起，团队即使发现性能下降，也很难完成可靠归因。&lt;/p&gt;
&lt;h2&gt;三、一个没有评测基线的退化案例&lt;/h2&gt;
&lt;p&gt;假设一个代码 Agent 已经运行了一段时间，团队一直通过人工试用判断效果。它表面上可以完成常见的 Bug 修复任务，但团队没有保存可重复的测试任务，也没有记录任务成功率、Tool 调用次数、Token 消耗和完成时间。&lt;/p&gt;
&lt;p&gt;随后，团队增加了一个“架构优化 Skill”，希望 Agent 在修复问题时顺便改善代码结构。上线一段时间后，用户开始反馈响应变慢、读取文件增多、修改范围扩大，部分原本稳定的 Bug 修复任务也出现失败。&lt;/p&gt;
&lt;p&gt;此时可能存在多种原因：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;新 Skill 与“缺陷修复 Skill”的触发条件重叠。&lt;/li&gt;
&lt;li&gt;新增说明让上下文变长，模型对原始任务的关注下降。&lt;/li&gt;
&lt;li&gt;Skill 改变了 Tool 选择，使 Agent 读取和修改了更多文件。&lt;/li&gt;
&lt;li&gt;系统 Prompt、模型版本或运行环境在同一时期发生了变化。&lt;/li&gt;
&lt;li&gt;模型随机性让少量人工测试无法稳定复现问题。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;缺少评测基线时，团队能够感受到 Agent “变差了”，却不知道它从哪里开始变差，只能反复修改并等待下一次用户反馈。如果存在一组固定的 Bug 修复任务，团队就可以在相同环境下对比 Skill 加载前后的结果，并结合 Trace 检查触发、文件读取、Tool 调用和修改范围，从而判断退化发生在哪个环节。&lt;/p&gt;
&lt;p&gt;这个案例说明，评测的价值不仅在于产生一个分数。稳定的任务、环境和行为记录能够把复杂系统中的耦合影响拆解出来，使团队拥有分析变化的共同参照。&lt;/p&gt;
&lt;h2&gt;四、Agent 评测体系由什么组成&lt;/h2&gt;
&lt;p&gt;在开始编写测试用例前，需要先区分评测体系中的几个基本对象。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;对象&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;th&gt;例子&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Task&lt;/td&gt;
&lt;td&gt;一条具有明确输入和成功标准的测试任务&lt;/td&gt;
&lt;td&gt;修复指定仓库中的一个 Bug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trial&lt;/td&gt;
&lt;td&gt;Agent 对某条 Task 的一次独立尝试&lt;/td&gt;
&lt;td&gt;使用同一版本 Agent 运行第 3 次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trace&lt;/td&gt;
&lt;td&gt;一次 Trial 中系统能够记录的执行轨迹&lt;/td&gt;
&lt;td&gt;消息、Tool 调用、参数、返回结果和中间状态&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outcome&lt;/td&gt;
&lt;td&gt;Trial 结束后真实环境中的最终状态&lt;/td&gt;
&lt;td&gt;测试是否通过、文件是否正确修改&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grader&lt;/td&gt;
&lt;td&gt;对 Outcome 或 Trace 进行评分的逻辑&lt;/td&gt;
&lt;td&gt;单元测试、规则检查、LLM Judge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eval Harness&lt;/td&gt;
&lt;td&gt;负责运行任务、隔离环境、记录 Trace、评分和汇总的基础设施&lt;/td&gt;
&lt;td&gt;批量启动沙箱并生成评测报告&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;表：Agent 评测体系中的基本对象。&lt;/p&gt;
&lt;p&gt;这里最重要的区分是 Trace 和 Outcome。Agent 可以在最终回答中声称“任务已经完成”，但真正的 Outcome 可能是代码没有通过测试、数据库状态没有更新，或者文件根本没有生成。因此，只检查最终文本通常不够，结果评测应尽量读取真实环境状态。&lt;/p&gt;
&lt;p&gt;Trace 则用于解释结果为什么出现。它可以告诉团队 Agent 选择了哪些 Tool、传入了什么参数、进行了多少轮尝试、在哪一步偏离目标，以及失败后是否正确恢复。这里的 Trace 指系统可观察的交互和状态，不包括无法访问的模型内部推理。对于权限检查、审批顺序等关键约束，Trace 也可以直接参与评分。&lt;/p&gt;
&lt;p&gt;不过，Agent 可能通过多条合理路径完成任务。除非某个步骤本身构成安全或业务要求，Grader 不宜强制规定唯一 Tool 顺序。结果评分负责判断任务是否完成，Trace 评分负责诊断过程、检查必要约束和衡量执行效率。&lt;/p&gt;
&lt;h2&gt;五、如何构建基础测试集&lt;/h2&gt;
&lt;p&gt;测试集决定了团队最终在优化什么。一个高质量测试集需要贴近真实任务，同时能够稳定复现和明确评分。&lt;/p&gt;
&lt;h3&gt;1. 从真实任务和失败案例开始&lt;/h3&gt;
&lt;p&gt;最有价值的样本通常来自团队已经在人工测试的任务、线上 Trace、用户反馈、Bug 记录和人工接管案例。每一次真实失败都可以被整理为新的回归用例。公开 Benchmark 可以作为能力参照，但它很难覆盖产品自己的 Tool、权限规则、业务数据和交互方式。&lt;/p&gt;
&lt;p&gt;测试集初期无需追求数量。先选择几十个高价值、能够代表核心使用场景的任务，通常比收集大量含义模糊的样本更有效。随着产品运行，再持续把新的失败模式补充进来。&lt;/p&gt;
&lt;h3&gt;2. 同时覆盖正向、负向和边界场景&lt;/h3&gt;
&lt;p&gt;只测试“应该发生”的行为容易产生单向优化。例如，Skill 测试集全部是应该触发的任务，Agent 可能逐渐形成过度触发；Tool 测试集全部要求搜索，Agent 也可能在无需实时信息时继续调用搜索工具。&lt;/p&gt;
&lt;p&gt;一套平衡的测试集至少应包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;正向任务：预期能力、Skill 或 Tool 应该被使用。&lt;/li&gt;
&lt;li&gt;负向任务：Agent 可以直接完成，不应加载相关能力。&lt;/li&gt;
&lt;li&gt;边界任务：多个 Skill 或 Tool 都可能相关，用于检查选择和组合。&lt;/li&gt;
&lt;li&gt;异常任务：Tool 超时、返回空结果、权限不足或中间状态损坏。&lt;/li&gt;
&lt;li&gt;回归任务：过去已经能够稳定完成，升级后必须继续通过。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3. 为每条任务定义清晰的成功标准&lt;/h3&gt;
&lt;p&gt;如果两名领域专家无法独立得出接近的通过或失败结论，这条任务就需要继续澄清。每条用例应描述输入、初始环境、允许的 Tool、最终结果、必要约束和评分方式。可以为复杂任务提供一份参考解法，用来证明任务可完成，并验证 Grader 没有配置错误。&lt;/p&gt;
&lt;p&gt;对于开放任务，成功标准可以拆成多个维度。例如，代码 Agent 的一次修改可以分别评估功能正确性、修改范围、可维护性和约束遵循，再按照业务重要性设置权重。部分完成的任务也应该获得部分分数，以免所有失败都被压缩成同一个信号。&lt;/p&gt;
&lt;h3&gt;4. 区分能力评测和回归评测&lt;/h3&gt;
&lt;p&gt;能力评测关注 Agent 能否解决更难、更广的新任务，题目可以随着能力提高而升级；回归评测关注已有能力能否保持稳定，任务和评分标准应尽量固定。两套测试集混在一起，会让团队难以判断分数变化来自 Agent 能力，还是来自题目本身发生变化。&lt;/p&gt;
&lt;p&gt;调优过程中还需要保留一部分未参与 Prompt、Skill 或 Tool 修改的保留集。开发集帮助定位问题和迭代实现，保留集用于判断优化是否具备泛化能力，防止团队只把系统调整到适配已知题目。&lt;/p&gt;
&lt;h3&gt;5. 固定 Trial 与统计口径&lt;/h3&gt;
&lt;p&gt;同一 Task 应在相同模型、配置和隔离环境下运行多个独立 Trial。Agent 在一次 Trial 内部重试 Tool，仍然属于同一次尝试；重新初始化环境并再次运行，才构成新的 Trial。比较两个版本时，优先使用相同 Task 的配对结果，同时报告样本量、绝对差值和不确定性区间。测试集较小时，几个百分点的变化可能只是随机波动，不宜直接解释为能力提升或回归。&lt;/p&gt;
&lt;h2&gt;六、如何组合 Grader&lt;/h2&gt;
&lt;p&gt;Agent 任务通常同时包含确定结果、开放质量和复杂过程，单一 Grader 很难覆盖全部问题。工程实践中常见的做法是组合确定性检查、LLM Judge 和人工评测。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Grader 类型&lt;/th&gt;
&lt;th&gt;适合评什么&lt;/th&gt;
&lt;th&gt;优点&lt;/th&gt;
&lt;th&gt;局限&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;确定性检查&lt;/td&gt;
&lt;td&gt;测试结果、结构化状态、Tool 和参数、权限边界&lt;/td&gt;
&lt;td&gt;快、便宜、可复现、易定位&lt;/td&gt;
&lt;td&gt;难以处理开放质量和合理变体&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM Judge&lt;/td&gt;
&lt;td&gt;完整性、相关性、风格、行为合理性、指令遵循&lt;/td&gt;
&lt;td&gt;可扩展，能处理语义和开放输出&lt;/td&gt;
&lt;td&gt;存在随机性，需要人工校准&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;人工评测&lt;/td&gt;
&lt;td&gt;专业质量、复杂边界、新失败模式&lt;/td&gt;
&lt;td&gt;最接近真实专家判断&lt;/td&gt;
&lt;td&gt;成本高、速度慢、一致性需要管理&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;表：三类 Grader 的适用范围与局限。&lt;/p&gt;
&lt;h3&gt;1. 优先使用确定性结果&lt;/h3&gt;
&lt;p&gt;能够通过代码判断的结果，应尽量使用代码判断。例如，代码是否通过测试、文件是否存在、数据库记录是否创建、Tool 参数是否符合 Schema、Agent 是否访问了禁止路径。这些检查稳定且便于回归，适合在每次修改后快速运行。&lt;/p&gt;
&lt;h3&gt;2. 用 LLM Judge 处理语义质量&lt;/h3&gt;
&lt;p&gt;结果的完整性、相关性、表达质量和行为合理性通常需要语义判断。此时可以让 LLM Judge 根据 Rubric 分维度评分。Rubric 应明确每个分数对应的证据，并允许 Judge 在信息不足时返回“无法判断”，减少没有依据的强行评分。&lt;/p&gt;
&lt;p&gt;Judge 上线前需要使用人工标注样本进行校准。可以先让两名领域专家独立标注一批样本，对分歧进行复核，形成 Gold 标签；随后将样本划分为校准集和保留验证集。分类任务可以观察混淆矩阵、精确率、召回率和 F1，分数型任务可以观察绝对误差及各分段的一致性，不能只看相关系数。&lt;/p&gt;
&lt;p&gt;成对比较还需要交换候选答案顺序，检查 Judge 是否存在位置偏差；同时抽查它是否偏爱更长、更像自身风格的答案。Judge、Rubric 或模型版本发生变化时，应让新旧 Judge 在同一批桥接样本上共同评分，区分评分漂移与 Agent 变化。&lt;a href=&quot;https://arxiv.org/abs/2406.07791&quot;&gt;相关研究&lt;/a&gt;已经证明 LLM Judge 可能存在系统性的位置偏差，因此人工校准和顺序稳定性检查不能省略。&lt;/p&gt;
&lt;h3&gt;3. 用人工评测发现未知问题&lt;/h3&gt;
&lt;p&gt;自动化评测擅长稳定检查已经定义的问题，人工审阅更容易发现测试集没有覆盖的新失败模式。团队可以周期性抽查真实 Trace，把高价值问题转化为新的用例、Rubric 或确定性规则。随着样本和规则逐渐成熟，人工评测的结果可以继续用于校准 Judge。&lt;/p&gt;
&lt;h2&gt;七、分层评测：Agent、Skill 与 Tool&lt;/h2&gt;
&lt;p&gt;Agent 整体、Skill 和 Tool 关注的对象不同，需要共用一套测试基础，同时保留各自的行为指标。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;层级&lt;/th&gt;
&lt;th&gt;核心问题&lt;/th&gt;
&lt;th&gt;主要指标&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Agent 整体&lt;/td&gt;
&lt;td&gt;能否稳定、经济、安全地完成真实任务&lt;/td&gt;
&lt;td&gt;任务成功率、单位成功任务成本、连续成功率&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skill&lt;/td&gt;
&lt;td&gt;是否在正确场景加载，并提升目标能力&lt;/td&gt;
&lt;td&gt;触发准确率、目标任务提升、跨能力回归&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool&lt;/td&gt;
&lt;td&gt;是否被正确选择、正确调用并有效返回&lt;/td&gt;
&lt;td&gt;选择正确率、参数有效率、调用错误率和延迟&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;表：Agent、Skill 与 Tool 三个层级的评测重点。&lt;/p&gt;
&lt;h3&gt;1. Agent 整体评测&lt;/h3&gt;
&lt;p&gt;Agent 整体评测应以真实任务的最终 Outcome 为核心。任务成功率表示 Agent 最终完成目标任务的比例；开放任务可以增加结果质量评分，分别观察正确性、完整性、相关性和约束遵循。总体平均分之外，还需要按任务类型、难度和业务风险拆分结果，避免某类大量简单任务掩盖关键场景的低成功率。&lt;/p&gt;
&lt;p&gt;效率指标应围绕成功结果计算。若目标是衡量生产中的经济成本，可以使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;单位成功任务成本 = 所有业务请求产生的货币成本 / 最终成功的业务请求数量
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;分子应统一为货币成本，并采用与生产一致的最大尝试次数和重试策略；Token、延迟和人工介入属于不同量纲，适合另外报告。分母也需要固定为“成功 Trial”或“允许重试后成功的业务任务”，不能在版本间切换。零成功时该指标没有定义，应直接报告零成功和总投入。为了看清失败带来的代价，可以同时展示成功运行的条件成本，以及包含全部失败投入的单位成功成本，并按任务类型和难度分层比较。&lt;/p&gt;
&lt;p&gt;稳定性可以同时观察 Pass@1 和 Pass^k。Pass@1 表示一次独立 Trial 成功的概率；Pass^k 表示同一 Task 在相同配置下进行 k 个独立 Trial，且全部成功的概率，更适合衡量生产环境需要的持续可靠性。统计时应先按 Task 估计，再对任务做宏平均，并同时报告 Trial 数量和置信区间。二者都要与单次 Trial 内部的 Tool 重试区分开。&lt;a href=&quot;https://arxiv.org/abs/2107.03374&quot;&gt;Pass@k&lt;/a&gt; 表示 k 次采样中至少一次成功，回答的是另一个问题，不应与 Pass^k 混用。&lt;/p&gt;
&lt;h3&gt;2. Skill 评测&lt;/h3&gt;
&lt;p&gt;本文讨论的 Skill 主要指以 &lt;code&gt;SKILL.md&lt;/code&gt;、脚本和参考资料组成，并由 Agent 动态发现和加载的文件型能力包。其他框架也可以把相同方法映射到自己的路由、插件或能力模块。Skill 评测主要回答三个问题：Agent 能否在合适的任务中加载 Skill，Skill 是否提升目标任务，以及它是否影响其他能力。&lt;/p&gt;
&lt;p&gt;首先建立三类触发测试：应该触发、不应该触发，以及多个 Skill 都可能相关的边界任务。只使用“请调用某 Skill”这样的显式指令无法衡量自动发现能力，测试输入应尽量接近真实用户表达。触发结果可以计算召回率、精确率和误触发率，并进一步分析 Skill 的 &lt;code&gt;name&lt;/code&gt; 和 &lt;code&gt;description&lt;/code&gt; 是否过宽或过窄。&lt;/p&gt;
&lt;p&gt;随后进行开启和关闭 Skill 的受控对照实验。在模型、系统 Prompt、Tool、环境和任务保持一致的条件下，比较任务成功率、结果质量、执行时间、Tool 调用次数和 Token 消耗。每条任务应运行多个 Trial，避免一次随机结果左右结论。&lt;/p&gt;
&lt;p&gt;Skill Trace 需要记录是否读取 &lt;code&gt;SKILL.md&lt;/code&gt;、是否按需加载引用文件、是否执行脚本、是否遵循规定流程，以及中途是否需要用户纠正。读取文件只能证明 Skill 被使用，最终 Outcome 和对照实验才能证明它有效。意外读取大量资料、重复加载上下文或过度依赖某个参考文件，都可能带来成本和行为问题。&lt;/p&gt;
&lt;p&gt;最后运行非目标任务回归集，检查新增 Skill 是否降低其他任务成功率、干扰其他 Skill 的触发，或让整体上下文持续增长。一个 Skill 的价值应同时考虑目标任务收益和跨能力副作用。&lt;/p&gt;
&lt;h3&gt;3. Tool 评测&lt;/h3&gt;
&lt;p&gt;Tool 评测需要同时观察任务结果和调用轨迹。测试集应包含必须调用 Tool、无需调用 Tool 和多个 Tool 容易混淆的任务，并补充超时、空结果、参数错误和权限不足等异常场景。每条用例可以记录预期使用的 Tool，但应允许 Agent 采用其他同样正确的路径。&lt;/p&gt;
&lt;p&gt;运行时需要保存完整 Trace，包括 Tool 选择、调用参数、返回结果、错误、重试、总轮数和最终 Outcome。对于存在多条正确路径的任务，可以预先定义“可接受 Tool 集合”，或分别标注调用的必要性、适切性和实际贡献，避免把合理替代方案判错。&lt;/p&gt;
&lt;p&gt;行为指标还应继续拆分：参数 Schema 合法率检查格式，参数语义正确率检查内容；Tool 执行成功率观察接口是否正常返回，结果有用率观察返回内容是否真正推动任务完成；重复调用则需要排除必要的分页和恢复操作。效率层可以统计平均时间、调用次数和 Tool 输入输出带来的 Token 消耗。只有样本量足够时再报告 P95，否则应展示原始分布或具体长尾样本。&lt;/p&gt;
&lt;p&gt;Trace 还能反向帮助团队优化 Tool 设计。大量无效参数通常说明 Tool 描述、Schema 或示例不够清晰；频繁重复调用可能说明返回结果、分页机制或结束条件存在歧义；Tool 选择正确但任务仍然失败，则可能与结果格式、数据质量或上下文组织方式有关。&lt;/p&gt;
&lt;p&gt;Tool 改动完成后，需要同时运行目标任务和完整回归集。Tool 描述属于模型决策上下文的一部分，即使底层接口没有变化，描述文字的调整也可能改变多个任务的工具选择。&lt;/p&gt;
&lt;h2&gt;八、如何让评测进入 Agent 开发循环&lt;/h2&gt;
&lt;p&gt;评测体系会与 Agent 能力共同演进。一个可持续的开发循环需要同时管理任务、Judge、版本基线和真实失败案例。&lt;/p&gt;
&lt;h3&gt;1. 建立可版本化的评测基线&lt;/h3&gt;
&lt;p&gt;初始版本 Agent A0 在代表性任务和真实场景中生成 Trace，团队通过人工分析整理常见失败类型，并据此定义指标、Rubric 和标注规范。随后，人工标注一批高质量样本形成 Gold Dataset V1，用于构建和校准 Judge J1。&lt;/p&gt;
&lt;p&gt;当 Judge J1 与人工判断达到可接受的一致性后，团队将 Judge、测试集和评分标准共同冻结为版本基线。Agent A1 的每次修改都运行确定性检查、Judge 评分和少量人工抽查。新发现的错误会重新进入测试集，原有 Judge 无法识别的新模式则推动 Gold Dataset V2 和 Judge J2 的建立。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/agent-judge-iteration.png&quot; alt=&quot;Agent 与 Judge 协同迭代流程&quot; /&gt;&lt;/p&gt;
&lt;p&gt;图：Agent 产生真实行为，评测体系沉淀失败模式，并持续推动测试集和 Judge 升级。&lt;/p&gt;
&lt;p&gt;这个循环的关键是版本化。模型、系统 Prompt、Skill、Tool、测试集、Judge 和运行环境都需要留下版本信息。每轮实验尽量只改变一个主要变量，才能解释指标变化的来源。如果多个变量必须同时修改，也要记录完整配置，并通过局部消融实验判断各项变化的贡献。&lt;/p&gt;
&lt;h3&gt;2. 保存最小任务描述&lt;/h3&gt;
&lt;p&gt;评测体系无需一开始就建设成庞大平台。一个可以运行的最小闭环通常包含任务文件、隔离环境、Trace 记录、Grader 和结果报告。&lt;/p&gt;
&lt;p&gt;每条任务可以保存以下信息：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;id&quot;: &quot;coding-fix-001&quot;,
  &quot;input&quot;: &quot;修复指定仓库中的分页错误&quot;,
  &quot;initial_state&quot;: &quot;fixtures/coding-fix-001&quot;,
  &quot;success_criteria&quot;: [&quot;新增测试通过&quot;, &quot;原有测试通过&quot;],
  &quot;behavior_constraints&quot;: [&quot;不得修改无关模块&quot;],
  &quot;tags&quot;: [&quot;coding&quot;, &quot;regression&quot;, &quot;tool-use&quot;],
  &quot;graders&quot;: [&quot;unit_test&quot;, &quot;scope_check&quot;, &quot;quality_judge&quot;]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真实 Trace 往往包含用户数据、Tool 返回值、密钥或业务状态。进入日志、标注平台或 LLM Judge 前，需要执行脱敏和权限控制，限制保存期限，并把不可信 Tool 内容与 Judge 指令隔离，降低敏感信息泄露和提示注入风险。&lt;/p&gt;
&lt;h3&gt;3. 把评测放入每轮变更&lt;/h3&gt;
&lt;p&gt;开发阶段可以按照以下节奏运行：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;修改 Prompt、Skill 或 Tool 前，先在相关测试集上记录基线。&lt;/li&gt;
&lt;li&gt;每轮尽量只改变一个主要变量，同时保存模型、配置和环境版本。&lt;/li&gt;
&lt;li&gt;在本地或 CI 中运行小规模高价值回归集，快速发现明显退化。&lt;/li&gt;
&lt;li&gt;在合并、模型升级或正式发布前运行完整评测集，并比较分项指标。&lt;/li&gt;
&lt;li&gt;定期抽查真实 Trace，把线上失败和人工接管沉淀为新测试用例。&lt;/li&gt;
&lt;li&gt;测试集或 Judge 升级时保留桥接样本，维持不同评测版本之间的可比性。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;4. 让评测结果回流到下一版本&lt;/h3&gt;
&lt;p&gt;结果报告也不应只展示一个总分。至少需要同时观察任务结果、行为、效率、稳定性和安全，并按任务类型展开。某次修改可能让成功率略有提升，却显著增加 Token 或越权风险；只有分项指标才能支持可靠的工程决策。&lt;/p&gt;
&lt;p&gt;工程实践中还需要注意几类常见问题：评测环境之间残留共享状态会污染结果；只观察平均数会隐藏长尾延迟和少数严重失败；反复根据同一批测试题调 Prompt 会造成过拟合；未经人工校准的 Judge 可能形成稳定但错误的评分偏差。这些问题都需要通过环境隔离、多个 Trial、保留集和定期人工抽查进行控制。&lt;/p&gt;
&lt;h2&gt;九、总结&lt;/h2&gt;
&lt;p&gt;Agent 评测的核心，是把概率性系统的质量变化转化为可重复、可比较和可定位的工程信号。测试集定义需要关注的真实任务，Outcome 告诉团队任务是否完成，Trace 解释执行过程，Grader 将结果和行为转换为指标，版本化基线则让每次修改拥有可靠参照。&lt;/p&gt;
&lt;p&gt;一套有效的评测体系通常从少量高价值任务开始：先定义成功标准，组合确定性检查、LLM Judge 和人工抽查，再逐步积累失败案例和回归样本。随着 Agent 能力扩大，评测也会从整体任务延伸到 Skill 触发、Tool 调用、成本、稳定性和安全。&lt;/p&gt;
&lt;p&gt;当评测真正进入开发循环后，团队可以在上线前发现退化，解释性能变化，并用相同标准比较模型、Prompt、Skill 和 Tool 的不同版本。Agent 的复杂性不会因此消失，但它会逐渐变成可以管理和持续优化的工程对象。&lt;/p&gt;
</content:encoded></item><item><title>小红书后端一面复盘</title><link>https://blog.huangnv.online/posts/26710/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26710/1/1/</guid><pubDate>Fri, 10 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;小红书一面&lt;/h1&gt;
&lt;h2&gt;MySQL 与 Redis&lt;/h2&gt;
&lt;h3&gt;1. MySQL 和 Redis 的区别是什么？&lt;/h3&gt;
&lt;h3&gt;2. 现在有上亿条数据，每条数据包含标题、内容和标签：标题约十几个字符，内容约几十个字符，&lt;code&gt;tag&lt;/code&gt; 为 &lt;code&gt;int&lt;/code&gt; 类型。请估算一条数据的大小，以及总数据量。&lt;/h3&gt;
&lt;p&gt;可以先明确估算口径：按 1 亿条数据、UTF-8 中文字符、标题 15 个字符、内容 50 个字符计算。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;标题：&lt;code&gt;15 × 3B ≈ 45B&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;内容：&lt;code&gt;50 × 3B ≈ 150B&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tag&lt;/code&gt;：&lt;code&gt;int&lt;/code&gt;，约 &lt;code&gt;4B&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;主键、变长字段长度、行记录元数据等：粗略预留 &lt;code&gt;10B～50B&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，一条记录的原始数据量可以按 &lt;code&gt;210B～250B&lt;/code&gt; 估算，取中间值约 &lt;code&gt;220B&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1 亿 × 220B = 22,000,000,000B
≈ 22GB（十进制）
≈ 20.5GiB（二进制）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;面试里可以回答：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;按 UTF-8 中文场景估算，15 个字的标题约 45B，50 个字的内容约 150B，tag 是 int 占 4B；再算上主键和记录元数据，一条数据可以按 200～250B 估算。按 1 亿条计算，原始数据量大约 20～25GB。实际存入 MySQL 时还会有索引、页和行格式的开销；存入 Redis 时还会有对象、字典和内存分配开销，实际占用会比原始数据量大。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;3. 这些数据分别存储在 Redis 和 MySQL 中，大约需要多大空间？&lt;/h3&gt;
&lt;h4&gt;Redis 的额外内存开销：从底层到上层&lt;/h4&gt;
&lt;p&gt;上一题按业务字段估算，一条记录约 &lt;code&gt;210B～250B&lt;/code&gt;。Redis 的实际内存还要加上存储结构的开销。以下以每篇文章使用一个 Key、Value 存储文章内容为例，从底层向上说明。&lt;/p&gt;
&lt;h4&gt;估算前提与结果&lt;/h4&gt;
&lt;p&gt;下面的数字用于面试中的量级估算，采用以下前提：64 位 Redis、1 亿个 Key、Key 为 &lt;code&gt;article:10000001&lt;/code&gt;（约 &lt;code&gt;16B&lt;/code&gt;）、每条文章序列化为一个 String Value（业务内容按 &lt;code&gt;220B&lt;/code&gt; 计算）、不设置过期时间、不计算副本。不同 Redis 版本、内存分配器、Value 编码和实际字段长度会导致结果变化。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;部分&lt;/th&gt;
&lt;th&gt;单条估算&lt;/th&gt;
&lt;th&gt;1 亿条估算&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Key 的 SDS&lt;/td&gt;
&lt;td&gt;&lt;code&gt;24B～32B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2.4GB～3.2GB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;16B&lt;/code&gt; Key 内容，加 SDS 元数据、结尾 &lt;code&gt;\0&lt;/code&gt; 和内存对齐&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Value 的 SDS 缓冲区&lt;/td&gt;
&lt;td&gt;&lt;code&gt;224B～256B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;22.4GB～25.6GB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;已包含约 &lt;code&gt;220B&lt;/code&gt; 的业务字段；额外包含 SDS 元数据和内存对齐&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Value 的 Redis 对象&lt;/td&gt;
&lt;td&gt;约 &lt;code&gt;16B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;约 &lt;code&gt;1.6GB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;保存 Value 类型、内部编码、访问时间或 LFU 信息等&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;字典 Entry&lt;/td&gt;
&lt;td&gt;&lt;code&gt;24B～32B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2.4GB～3.2GB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Key 指针、Value 指针、冲突链指针及可能的对齐&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;哈希桶数组分摊&lt;/td&gt;
&lt;td&gt;&lt;code&gt;8B～16B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.8GB～1.6GB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;每个桶位通常为 &lt;code&gt;8B&lt;/code&gt; 指针；扩容后桶位利用率会下降&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;常驻内存小计&lt;/td&gt;
&lt;td&gt;&lt;code&gt;296B～352B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;29.6GB～35.2GB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;不含过期字典、复制和持久化峰值&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;如果每个 Key 都设置 TTL，还会多一份过期字典：每条通常再增加约 &lt;code&gt;32B～48B&lt;/code&gt;，1 亿条约多 &lt;code&gt;3.2GB～4.8GB&lt;/code&gt;。渐进式 Rehash 期间，新旧两张桶数组会短暂共存，额外增加约 &lt;code&gt;0.8GB～1.6GB&lt;/code&gt;；有 1 个全量副本时，数据集本身还需要再准备约一份同等规模的内存。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;内存分配器的规格与对齐&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Redis 底层通过内存分配器申请内存。分配器会按固定的大小规格分配，例如 Key 的 SDS 原始需求约为 &lt;code&gt;16B&lt;/code&gt; 字符内容加少量元数据，申请约 &lt;code&gt;20B&lt;/code&gt;，实际可能得到 &lt;code&gt;24B&lt;/code&gt; 或 &lt;code&gt;32B&lt;/code&gt; 的内存块。实际分配大小超过申请大小的部分就是对齐开销。这个例子在 1 亿条数据下约为 &lt;code&gt;0.4GB～1.2GB&lt;/code&gt;，已分摊在上表的 Key SDS、Value SDS 和 Entry 中，不能再次重复相加。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;SDS 字符串结构&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Redis 的字符串使用 SDS（Simple Dynamic String）保存。除了字符内容，还会记录当前长度 &lt;code&gt;len&lt;/code&gt;、已分配容量 &lt;code&gt;alloc&lt;/code&gt;、编码标记 &lt;code&gt;flags&lt;/code&gt;，并在结尾保留 &lt;code&gt;\0&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Key：article:10000001
字符内容：约 16B
额外信息：len、alloc、flags、\0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;刚创建字符串时，&lt;code&gt;alloc&lt;/code&gt; 通常接近 &lt;code&gt;len&lt;/code&gt;，空闲容量很少。上表中 Key 的 SDS 按 &lt;code&gt;24B～32B&lt;/code&gt; 估算，Value 的 SDS 缓冲区按 &lt;code&gt;224B～256B&lt;/code&gt; 估算。字符串通过 &lt;code&gt;APPEND&lt;/code&gt; 等方式增长时，SDS 会预留更大的 &lt;code&gt;alloc&lt;/code&gt;，减少后续反复申请内存。例如长度从 &lt;code&gt;16B&lt;/code&gt; 扩展到 &lt;code&gt;18B&lt;/code&gt;，容量可能预留到约 &lt;code&gt;36B&lt;/code&gt;，其中 &lt;code&gt;alloc - len&lt;/code&gt; 就是扩容后的空闲空间。文章 Key 一般创建后保持不变，因此 Key 本身很少出现这部分预留；频繁追加的字符串 Value 更容易出现。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Value 的 Redis 对象与编码&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Redis 需要记录 Value 的数据类型、内部编码、访问时间或 LFU 信息、引用计数等。单个 String Value 的 Redis 对象在 64 位环境中可粗略按约 &lt;code&gt;16B&lt;/code&gt; 估算，1 亿条约 &lt;code&gt;1.6GB&lt;/code&gt;。Value 会根据数据类型和大小使用不同编码，例如 String、Hash、List、Set、ZSet 各自的内部结构不同。这里会产生对象元数据和对应结构的额外内存。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;字典 Entry&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Redis 数据库本质上通过字典保存 Key 和 Value 的关联。每个 Key 通常需要一个字典 Entry，其中保存 Key 指针、Value 指针和冲突链相关指针等信息。64 位环境中 3 个指针约为 &lt;code&gt;24B&lt;/code&gt;，算上对齐可按 &lt;code&gt;24B～32B / 条&lt;/code&gt; 估算，1 亿条约 &lt;code&gt;2.4GB～3.2GB&lt;/code&gt;。Key 数量达到 1 亿时，这类固定的指针开销会累计为数 GB。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;哈希桶数组&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;字典还需要一张哈希桶数组定位 Entry。哈希表扩容后会保留一定数量的空桶；渐进式 Rehash 期间，新旧两张桶数组会短时间同时存在。桶数组的开销按 Key 数量增长，在 64 位机器上每个桶位通常至少需要一个 &lt;code&gt;8B&lt;/code&gt; 指针。按每条分摊 &lt;code&gt;8B～16B&lt;/code&gt; 估算，1 亿条约 &lt;code&gt;0.8GB～1.6GB&lt;/code&gt;。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;可选的附加结构&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果 Key 设置了过期时间，Redis 还需要在过期字典中记录该 Key，1 亿条全量 TTL 数据可额外按 &lt;code&gt;3.2GB～4.8GB&lt;/code&gt; 粗略预留。开启 AOF、主从复制、执行 RDB 快照或进行 Rehash 时，也可能出现缓冲区、复制积压区或写时复制带来的额外内存峰值。&lt;/p&gt;
&lt;p&gt;可以把 Redis 的单条实际占用概括为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;实际内存
= 业务字段
+ 内存分配对齐
+ SDS 元数据与可能的预留容量
+ Value 对象及其内部编码
+ 字典 Entry
+ 哈希桶分摊
+ 过期、复制、持久化等可选开销
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此，短 Key 的字符串内容本身不大，例如 &lt;code&gt;16B&lt;/code&gt; 的 Key 只占约 &lt;code&gt;1.6GB / 1 亿条&lt;/code&gt;；更显著的部分来自每个 Key 都要持有的字典、对象和指针元数据。具体容量要以实际 Redis 版本、Value 编码和数据样本为准，可通过 &lt;code&gt;MEMORY USAGE key&lt;/code&gt; 抽样后再乘以数据量估算。&lt;/p&gt;
&lt;h4&gt;MySQL（InnoDB）的存储估算&lt;/h4&gt;
&lt;p&gt;继续使用相同的业务数据规模，假设表结构为 &lt;code&gt;id BIGINT PRIMARY KEY&lt;/code&gt;、&lt;code&gt;title VARCHAR(...)&lt;/code&gt;、&lt;code&gt;content VARCHAR(...)&lt;/code&gt;、&lt;code&gt;tag INT&lt;/code&gt;，字符集为 UTF-8，标题平均 15 个中文字符，内容平均 50 个中文字符。内容较短，可直接存放在 InnoDB 行记录中。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;部分&lt;/th&gt;
&lt;th&gt;单条估算&lt;/th&gt;
&lt;th&gt;1 亿条估算&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;8B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.8GB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BIGINT&lt;/code&gt; 主键&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;标题实际内容&lt;/td&gt;
&lt;td&gt;约 &lt;code&gt;45B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;约 &lt;code&gt;4.5GB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;15 × 3B&lt;/code&gt;，按平均中文字符计算&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;正文实际内容&lt;/td&gt;
&lt;td&gt;约 &lt;code&gt;150B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;约 &lt;code&gt;15GB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;50 × 3B&lt;/code&gt;，按平均中文字符计算&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;4B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.4GB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INT&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;业务字段小计&lt;/td&gt;
&lt;td&gt;约 &lt;code&gt;207B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;约 &lt;code&gt;20.7GB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;四个业务字段的实际数据&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VARCHAR&lt;/code&gt; 长度信息&lt;/td&gt;
&lt;td&gt;约 &lt;code&gt;2B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;约 &lt;code&gt;0.2GB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;标题和正文各约 &lt;code&gt;1B&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;InnoDB 行记录元数据&lt;/td&gt;
&lt;td&gt;约 &lt;code&gt;18B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;约 &lt;code&gt;1.8GB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;约 &lt;code&gt;5B&lt;/code&gt; 记录头、&lt;code&gt;6B&lt;/code&gt; 事务 ID、&lt;code&gt;7B&lt;/code&gt; 回滚指针&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;行记录小计&lt;/td&gt;
&lt;td&gt;约 &lt;code&gt;227B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;约 &lt;code&gt;22.7GB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;尚未计算数据页利用率&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;数据页管理与页内空闲空间分摊&lt;/td&gt;
&lt;td&gt;约 &lt;code&gt;35B～45B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;约 &lt;code&gt;3.5GB～4.5GB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;默认 &lt;code&gt;16KB&lt;/code&gt; 数据页的页头、页目录、页分裂预留和平均利用率&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;聚簇索引叶子页总计&lt;/td&gt;
&lt;td&gt;约 &lt;code&gt;262B～272B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;约 &lt;code&gt;26.2GB～27.2GB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;数据行存放在主键聚簇索引的叶子页中，数据与主键叶子页只计算一次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;主键 B+ 树非叶子页&lt;/td&gt;
&lt;td&gt;小于 &lt;code&gt;1B / 条&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;通常小于 &lt;code&gt;0.1GB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;仅保存主键和子页指针，量级很小，可在面试估算中忽略&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;因此，只有主键索引时，1 亿条数据在 MySQL InnoDB 中可按约 &lt;code&gt;26GB～28GB&lt;/code&gt; 的数据文件规模估算。&lt;/p&gt;
&lt;p&gt;如果给 &lt;code&gt;tag&lt;/code&gt; 额外建立二级索引，二级索引叶子记录通常需要保存 &lt;code&gt;tag&lt;/code&gt;、主键值和索引记录元数据。可粗略按 &lt;code&gt;18B～25B / 条&lt;/code&gt; 估算，1 亿条大约增加 &lt;code&gt;1.8GB～2.5GB&lt;/code&gt;。标题索引、全文索引、联合索引都会继续增加磁盘占用，需要按具体索引单独计算。&lt;/p&gt;
&lt;p&gt;上面的 MySQL 数字只计算表数据和索引文件。&lt;code&gt;redo log&lt;/code&gt;、&lt;code&gt;undo&lt;/code&gt;、&lt;code&gt;binlog&lt;/code&gt;、备份文件、从库副本和文件系统预留空间属于独立的容量项，部署规划时需要另外预留。&lt;/p&gt;
&lt;h3&gt;4. 去掉成本因素，你认为这些数据放在 Redis 还是 MySQL 更合适？&lt;/h3&gt;
&lt;p&gt;olap oltp&lt;/p&gt;
&lt;p&gt;大数量上的搜索 查询 mysql不够看 join在大数据量上面性能差 大数量上要分片 es根据业务需求搜索出建值 再去olap大数据量上的离线数据库 宽表 是有很多业务字段 通过id拿到对应的字段然后返回
newsql&lt;/p&gt;
&lt;p&gt;先明确前提：不考虑内存、机器和副本成本，只比较性能。若文章的核心读路径固定为“按 ID 查详情”和“按 tag 查最新文章”，Redis 的性能优势更强；MySQL 更适合承担最终数据和持续变化的通用查询需求。&lt;/p&gt;
&lt;h4&gt;示例数据与查询需求&lt;/h4&gt;
&lt;p&gt;假设有一篇文章：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;id = 1001
title = &quot;Redis 性能优化&quot;
content = &quot;……&quot;
tag = 7
create_time = 2026-07-10 10:00:00
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见请求：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. 按文章 ID 查询详情。
2. 查询 tag = 7 下最新的 20 篇文章。
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Redis 的实现与优势&lt;/h4&gt;
&lt;p&gt;可以提前维护文章详情和 tag 时间索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;article:1001
  -&amp;gt; {&quot;title&quot;:&quot;Redis 性能优化&quot;,&quot;content&quot;:&quot;……&quot;,&quot;tag&quot;:7}

tag:7:by_time
  -&amp;gt; ZSet
  -&amp;gt; score = create_time
  -&amp;gt; member = 1001
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;按 ID 查询详情：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GET article:1001
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查询 &lt;code&gt;tag = 7&lt;/code&gt; 下最新的 20 篇文章：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ZREVRANGE tag:7:by_time 0 19
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;拿到文章 ID 后，再批量读取对应的文章详情。单机 Redis 中，可以用 Lua 脚本一次完成“取文章 ID、取详情、组装返回结果”，减少网络往返。&lt;/p&gt;
&lt;p&gt;文章从 &lt;code&gt;tag = 7&lt;/code&gt; 修改为 &lt;code&gt;tag = 8&lt;/code&gt; 时，Lua 可以原子完成以下操作：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. 更新 article:1001 的内容。
2. 从 tag:7:by_time 删除 1001。
3. 将 1001 加入 tag:8:by_time。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Redis 的优势在于：查询模式已经确定后，可以提前把对应索引设计好，数据全部在内存中，按 ID 查询和按 tag 取 Top N 的路径都很短。Lua 适合把多个 Key 的更新压缩成一次原子执行。&lt;/p&gt;
&lt;p&gt;Lua 脚本需要保持很短。大范围扫描或对大集合做复杂计算会阻塞该 Redis 实例上的其他命令。Redis Cluster 中，Lua 脚本涉及的 Key 还需要位于同一个 Hash Slot；跨分片的查询和更新需要额外设计。&lt;/p&gt;
&lt;h4&gt;Redis 事务与一致性边界&lt;/h4&gt;
&lt;p&gt;Redis 支持 &lt;code&gt;MULTI / EXEC&lt;/code&gt; 事务。&lt;code&gt;MULTI&lt;/code&gt; 之后的命令会进入队列，执行 &lt;code&gt;EXEC&lt;/code&gt; 后会连续执行，中间不会插入其他客户端命令。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;MULTI
SET article:1001 ...
ZREM tag:7:by_time 1001
ZADD tag:8:by_time 1720000000 1001
EXEC
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Redis 事务具备原子执行和隔离执行的能力，但不提供传统数据库的事务回滚：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;EXEC&lt;/code&gt; 前执行 &lt;code&gt;DISCARD&lt;/code&gt;，可以丢弃整组待执行命令。&lt;/li&gt;
&lt;li&gt;使用 &lt;code&gt;WATCH&lt;/code&gt; 监视 Key 后，只要任一被监视 Key 在 &lt;code&gt;EXEC&lt;/code&gt; 前被修改，&lt;code&gt;EXEC&lt;/code&gt; 会失败，整组命令不执行；业务端需要重新读取数据后重试。这是乐观锁机制。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;EXEC&lt;/code&gt; 后，某条命令发生运行时错误时，已经执行成功的命令会保留，后续命令也会继续执行，Redis 不会回滚前面的命令。&lt;/li&gt;
&lt;li&gt;Redis Cluster 中，多 Key 事务涉及的 Key 也需要处于同一个 Hash Slot。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，文章与索引的简单同步更新可以用 &lt;code&gt;MULTI / EXEC&lt;/code&gt; 或 Lua。需要“先读取、判断业务条件、再修改多个 Key”时，Lua 更合适；需要完整回滚、复杂隔离级别或跨分片强一致事务时，需要由 MySQL 等关系型数据库承担最终一致性。&lt;/p&gt;
&lt;h4&gt;MySQL 的实现与优势&lt;/h4&gt;
&lt;p&gt;MySQL 可以建立如下表和联合索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE TABLE article (
    id BIGINT PRIMARY KEY,
    title VARCHAR(100) NOT NULL,
    content VARCHAR(500) NOT NULL,
    tag INT NOT NULL,
    create_time DATETIME NOT NULL,
    INDEX idx_tag_time (tag, create_time)
) ENGINE = InnoDB;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查询 &lt;code&gt;tag = 7&lt;/code&gt; 下最新的 20 篇文章：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT id, title, content, tag, create_time
FROM article
WHERE tag = 7
ORDER BY create_time DESC
LIMIT 20;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MySQL 可以利用 &lt;code&gt;(tag, create_time)&lt;/code&gt; 联合索引查询。文章更新 tag 或创建时间时，InnoDB 自动维护行数据和索引，业务代码不需要自行更新多套集合。&lt;/p&gt;
&lt;p&gt;MySQL 的优势会在需求变化时体现。例如新增“查询 tag = 7、标题包含‘性能’、最近 7 天发布、排除已下架文章、按热度排序的前 20 篇”这一需求，MySQL 可以通过增加字段、索引或全文索引，并调整 SQL 来实现。&lt;/p&gt;
&lt;p&gt;Redis 也可以实现，但需要额外维护多份索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tag:7                  -&amp;gt; Set
word:性能               -&amp;gt; Set
status:online          -&amp;gt; Set
time:last_7_days       -&amp;gt; ZSet
hot_score              -&amp;gt; ZSet
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;随后在 Lua 中做集合交集、过滤和排序。读取性能可以很高，但每增加一个查询维度，文章写入和更新时就需要同步维护一份新的索引。&lt;/p&gt;
&lt;h4&gt;对比与结论&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;场景&lt;/th&gt;
&lt;th&gt;Redis&lt;/th&gt;
&lt;th&gt;MySQL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;按文章 ID 查询详情&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET&lt;/code&gt; 直接读取，延迟低、QPS 高&lt;/td&gt;
&lt;td&gt;主键索引查询，性能稳定&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;按 tag 查询最新文章&lt;/td&gt;
&lt;td&gt;ZSet 直接取 Top N，适合固定读路径&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(tag, create_time)&lt;/code&gt; 联合索引直接查询&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;多 Key 更新&lt;/td&gt;
&lt;td&gt;Lua 原子更新文章与预计算索引&lt;/td&gt;
&lt;td&gt;事务内更新行和索引&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;查询条件固定&lt;/td&gt;
&lt;td&gt;提前建好索引后性能很强&lt;/td&gt;
&lt;td&gt;可以支持，但性能优势较弱&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;查询条件常变化&lt;/td&gt;
&lt;td&gt;每个新维度需要新增索引结构和维护代码&lt;/td&gt;
&lt;td&gt;增加索引、字段或调整 SQL 即可&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;最终数据管理&lt;/td&gt;
&lt;td&gt;需要自行处理多份索引一致性和恢复策略&lt;/td&gt;
&lt;td&gt;事务、MVCC、备份、数据修复更完整&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;面试总结：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;如果不考虑成本，且查询模式固定、核心目标是高并发低延迟，我会优先使用 Redis。文章详情用 String 存储，tag 的最新文章用 ZSet 建索引，多 Key 更新通过 Lua 保证原子性。MySQL 适合保存最终数据，并处理查询条件变化、后台筛选、统计、事务和数据修复。实际系统中可以由 MySQL 保存全量数据，Redis 作为高性能查询层。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;5. Redis 能不能替代 MySQL？&lt;/h3&gt;
&lt;p&gt;redis 事务&lt;/p&gt;
&lt;h3&gt;6. Redis 的数据结构是什么？&lt;/h3&gt;
&lt;h3&gt;7. 使用 HashMap 作为存储结构会有哪些空间浪费？&lt;/h3&gt;
&lt;h3&gt;8. 你的 MQ 用在了哪里？作用是什么？&lt;/h3&gt;
&lt;h3&gt;9. 消费者消费消息时，如何保证可靠性？（不考虑网络环境）&lt;/h3&gt;
&lt;h2&gt;SQL 分析&lt;/h2&gt;
&lt;h3&gt;10. 给出一段 SQL，分析其中有哪些不合理的地方。&lt;/h3&gt;
&lt;h2&gt;算法&lt;/h2&gt;
&lt;h3&gt;11. LeetCode 300：最长递增子序列（LIS）&lt;/h3&gt;
</content:encoded></item><item><title>Java 后端八股文总结</title><link>https://blog.huangnv.online/posts/2677/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2677/1/1/</guid><pubDate>Tue, 07 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;八股文总结&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;日期：2026-07-07&lt;br /&gt;
范围：汇总 &lt;code&gt;面试题目&lt;/code&gt; 目录下的字节、携程、京东、美团、美团 2、美团 3 面试题。&lt;br /&gt;
说明：重复题按语义合并，题目旁标记重复次数；已有答案保留更详细的一版；没有答案的只搬运题目。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;目录&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;认证与安全&lt;/li&gt;
&lt;li&gt;Redis 与缓存&lt;/li&gt;
&lt;li&gt;MySQL&lt;/li&gt;
&lt;li&gt;JVM&lt;/li&gt;
&lt;li&gt;JUC 与并发&lt;/li&gt;
&lt;li&gt;Java 基础与集合&lt;/li&gt;
&lt;li&gt;Spring&lt;/li&gt;
&lt;li&gt;网络、IO 与操作系统&lt;/li&gt;
&lt;li&gt;系统设计与架构&lt;/li&gt;
&lt;li&gt;算法&lt;/li&gt;
&lt;li&gt;AI 与 RAG&lt;/li&gt;
&lt;li&gt;项目与开放题&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;认证与安全&lt;/h2&gt;
&lt;h3&gt;1. JWT 为什么是无状态的？相比 Session 有哪些优势？如果 JWT 被窃取，攻击者冒充登录怎么办？&lt;/h3&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;JWT 无状态，指服务端不保存每个用户的登录会话。&lt;/p&gt;
&lt;p&gt;用户登录成功后，认证服务把用户 ID、角色、过期时间等信息放进 JWT，并用密钥签名。客户端后续携带 JWT 请求，网关或业务服务直接验签和校验过期时间即可完成鉴权，不需要再去 Redis 或数据库查询 Session。&lt;/p&gt;
&lt;p&gt;Session 的流程是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;客户端携带 Session ID
-&amp;gt; 服务端从 Redis 或内存查询 Session
-&amp;gt; 获取用户登录信息
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JWT 的流程是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;客户端携带 JWT
-&amp;gt; 服务端验签
-&amp;gt; 解析用户 ID、角色、过期时间
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JWT 的优势主要有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;分布式部署方便。多个服务只要具备验签能力，就能独立完成鉴权，不依赖共享 Session 或粘性会话。&lt;/li&gt;
&lt;li&gt;减少一次查询 Session 的网络开销。&lt;/li&gt;
&lt;li&gt;适合网关统一鉴权和微服务间传递用户身份。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;实际项目通常采用 Access Token + Refresh Token 的策略：&lt;/p&gt;
&lt;p&gt;两者通常分开设计：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Access Token：短期 JWT，客户端携带它访问业务接口。
Refresh Token：高随机度字符串，服务端在 Redis 或数据库保存它的有效状态。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Access Token 过期后，服务端只需拒绝请求；Refresh Token 由服务端保存和管理，便于按用户或设备主动删除、轮换和失效控制。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;用户登录成功后，服务端下发两个 Token。Access Token 用于访问业务接口，时间设置较短，例如 15～30 分钟；Refresh Token 用于续期，时间可以设置为 7～30 天。&lt;/li&gt;
&lt;li&gt;客户端请求业务接口时只携带 Access Token。网关或业务服务验签并校验过期时间，校验通过后放行。&lt;/li&gt;
&lt;li&gt;Access Token 过期后，客户端携带 Refresh Token 调用刷新接口。服务端校验 Refresh Token 是否有效，成功后签发新的 Access Token。&lt;/li&gt;
&lt;li&gt;Refresh Token 通常在 Redis 或数据库中保存登录设备、过期时间和状态。退出登录、修改密码、风险登录时删除对应记录，使该设备无法继续刷新 Token。&lt;/li&gt;
&lt;li&gt;刷新时可以轮换 Refresh Token：签发新的 Refresh Token，同时让旧 Token 失效，降低 Refresh Token 被长期重放的风险。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这样，短期 Access Token 保证接口鉴权的高性能，Refresh Token 提供续期和主动失效能力。&lt;/p&gt;
&lt;p&gt;JWT 被盗后，攻击者可以直接带着 Token 请求接口，服务端验签仍会通过。因此 JWT 被盗的本质是 Bearer Token 被重放。&lt;/p&gt;
&lt;p&gt;常见处理方式：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;全站 HTTPS，避免 Token 在传输过程中被窃取。&lt;/li&gt;
&lt;li&gt;Access Token 设置较短过期时间，例如 15 分钟；过期后使用 Refresh Token 换取新的 Access Token。&lt;/li&gt;
&lt;li&gt;Refresh Token 存在 Redis 或数据库中，退出登录、修改密码、发现风险登录时删除 Refresh Token 或加入黑名单，强制用户重新登录。&lt;/li&gt;
&lt;li&gt;浏览器场景将 Token 放在带 &lt;code&gt;HttpOnly&lt;/code&gt;、&lt;code&gt;Secure&lt;/code&gt;、&lt;code&gt;SameSite&lt;/code&gt; 属性的 Cookie 中，并做好 XSS 和 CSRF 防护。&lt;/li&gt;
&lt;li&gt;JWT Payload 可以被解码查看，里面只放用户 ID、角色等必要信息，不放密码和敏感隐私数据。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：JWT 的核心价值是把登录信息放到 Token 中，通过验签完成无状态鉴权，适合分布式和微服务场景。JWT 的缺点是主动失效能力较弱，所以实际项目通常使用短期 Access Token 加 Refresh Token，并在 Redis 中维护 Refresh Token 或黑名单。&lt;/p&gt;
&lt;h2&gt;Redis 与缓存&lt;/h2&gt;
&lt;h3&gt;Redis 与 MySQL 数据一致性（重复 3 次）&lt;/h3&gt;
&lt;p&gt;来源：字节后端一面:81（&lt;code&gt;2026-6-27/字节后端一面.md&lt;/code&gt;）；携程二面:171（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）；美团2:160（&lt;code&gt;美团2/美团2.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;题目变体：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Redis 与 MySQL 数据一致性&lt;/li&gt;
&lt;li&gt;Redis 和数据库之间的同步该怎么做？若其他服务直接操作数据库导致数据不一致，该如何处理？&lt;/li&gt;
&lt;li&gt;Redis 的缓存一致性如何解决？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Redis 一般作为缓存，MySQL 作为最终数据源。系统通常保证最终一致性，常见方案是 Cache Aside。&lt;/p&gt;
&lt;h4&gt;Cache Aside 的基本流程&lt;/h4&gt;
&lt;p&gt;读请求：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;先读 Redis
-&amp;gt; Redis 命中，直接返回
-&amp;gt; Redis 未命中，读 MySQL
-&amp;gt; 把 MySQL 查询结果写回 Redis
-&amp;gt; 返回结果
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;写请求：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;先更新 MySQL
-&amp;gt; 再删除 Redis 缓存
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;写请求里通常选择删除缓存，让后续读请求重新从 MySQL 加载最新数据。这样可以避免复杂缓存内容在并发更新时被错误覆盖。&lt;/p&gt;
&lt;h4&gt;主从延迟带来的旧数据问题&lt;/h4&gt;
&lt;p&gt;如果 MySQL 做了读写分离，写请求更新的是主库，读请求可能走从库。主库同步到从库存在延迟，所以缓存删除之后，如果有读请求回源到从库，就可能读到旧数据，并把旧数据重新写回 Redis。&lt;/p&gt;
&lt;p&gt;这个问题可以根据一致性要求分层处理：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;普通最终一致场景&lt;br /&gt;
可以使用延迟双删。更新主库后先删除缓存，等待一段时间后再删除一次缓存，清掉可能被旧数据回填的缓存。延迟时间需要参考主从同步延迟，一般取大于主从延迟 P99 的时间。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;删除动作可靠性&lt;br /&gt;
可以结合消息队列、binlog 监听或重试任务，保证缓存删除动作最终执行成功。比如监听 MySQL binlog，发现数据变更后异步删除对应 Redis Key。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;强一致场景&lt;br /&gt;
对订单状态、支付状态、库存扣减结果这类强一致读，可以在写后一段时间内强制读主库，或者直接绕过缓存和从库，以 MySQL 主库结果为准。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;秒杀热点场景的补充&lt;/h4&gt;
&lt;p&gt;秒杀库存属于热点高并发写场景，如果每次购买都同步更新 MySQL 再删除 Redis，后续大量读请求可能同时回源 MySQL，导致数据库压力过高。&lt;/p&gt;
&lt;p&gt;这类场景通常把库存提前预热到 Redis：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;活动开始前：
MySQL 库存 -&amp;gt; 预热到 Redis

用户抢购时：
Redis 原子扣减库存
-&amp;gt; 扣减成功后发送 MQ
-&amp;gt; 消费者异步创建订单、落 MySQL、扣数据库库存
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Redis 承接高并发读写，MySQL 作为最终数据源。后续通过 MQ 消费、库存流水、订单状态和对账任务保证最终一致性。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;普通读多写少业务可以用 Cache Aside，写 MySQL 后删除 Redis，保证最终一致性。如果存在主从延迟，缓存删除后可能从从库读到旧数据并回填 Redis，普通场景可以用延迟双删或 binlog 监听做二次删除；强一致场景可以在写后一段时间内强制读主库，或者直接绕过缓存和从库。秒杀库存这种热点场景会用 Redis 预扣库存加 MQ 异步落库，再通过流水和对账保证最终一致。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;缓存过期与热点 Key（重复 2 次）&lt;/h3&gt;
&lt;p&gt;来源：字节后端一面:143（&lt;code&gt;2026-6-27/字节后端一面.md&lt;/code&gt;）；美团:13（&lt;code&gt;美团/美团.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;题目变体：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;缓存过期与热点 Key&lt;/li&gt;
&lt;li&gt;一个热点数据刚好失效，被几万请求同时打到数据库，这时候会锁住那个 key 吗？锁的粒度是多大？Redis 锁还是本地锁？锁超时了怎么办？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;如果缓存过期时流量很高，需要先区分两类问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;大量 Key 同时过期&lt;br /&gt;
这类问题通常叫缓存雪崩。大量请求同时发现 Redis 没有数据，然后一起回源 MySQL，数据库会承受瞬时高压。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;单个热点 Key 过期&lt;br /&gt;
这类问题通常叫缓存击穿。比如秒杀商品库存、热门商品详情、热门活动页配置这类 Key 访问量特别大，一旦过期，短时间内会有大量请求同时打到 MySQL。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;缓存雪崩：大量 Key 同时过期&lt;/h4&gt;
&lt;p&gt;常见处理方式包括过期时间随机化、分批预热、限流降级。&lt;/p&gt;
&lt;h5&gt;过期时间随机化&lt;/h5&gt;
&lt;p&gt;缓存写入 Redis 时，不给所有 Key 设置完全相同的过期时间，而是在基础过期时间上增加一个随机偏移。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;基础过期时间：30 分钟
随机偏移：0-5 分钟
实际过期时间：30-35 分钟
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样可以让 Key 分散过期，降低同一时刻大量回源 MySQL 的概率。&lt;/p&gt;
&lt;h5&gt;分批预热&lt;/h5&gt;
&lt;p&gt;分批预热是指在高峰流量到来之前，提前把热点数据从 MySQL 加载到 Redis，并且控制加载节奏。&lt;/p&gt;
&lt;p&gt;比如秒杀活动开始前，可以先筛选活动商品、库存、活动配置、商品详情等热点数据，然后按批次写入 Redis：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;第 1 批：活动配置
第 2 批：热门商品基础信息
第 3 批：秒杀库存
第 4 批：商品详情和展示数据
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;分批预热要注意几点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;控制预热速率&lt;br /&gt;
预热本身也会查 MySQL，所以不能一次性把所有数据打到数据库上。可以分页读取、分批写入 Redis，并控制每批之间的间隔。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;优先预热热点数据&lt;br /&gt;
先预热访问量最高、最影响核心链路的数据，比如秒杀商品库存、活动配置、热门商品详情。低频数据可以等首次访问时再加载。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;错开过期时间&lt;br /&gt;
预热写入 Redis 后，仍然要给不同 Key 设置不同过期时间，避免下一轮集中失效。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;做预热校验&lt;br /&gt;
预热完成后，可以校验 Redis 中的 Key 数量、库存值、活动状态，避免活动开始后才发现缓存缺失。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;
&lt;p&gt;对活动类热点数据，我会在活动开始前做缓存预热，把商品信息、活动配置、库存等数据提前加载到 Redis。预热时不会一次性全量加载，而是分页查 MySQL、分批写 Redis，并控制预热速率，避免预热过程本身把数据库打满。预热完成后还会校验关键 Key 是否存在，并给不同 Key 设置随机过期时间，减少后续集中失效。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h5&gt;限流降级&lt;/h5&gt;
&lt;p&gt;限流降级是在缓存异常、回源 MySQL 增多或数据库压力升高时，主动减少进入核心链路的请求量。&lt;/p&gt;
&lt;p&gt;常见做法包括：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;入口限流&lt;br /&gt;
在网关或接口层限制整体 QPS，也可以按用户、IP、接口、商品、活动等维度限流。比如某个秒杀商品的请求量超过阈值后，后续请求直接返回“系统繁忙，请稍后重试”。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;回源限流&lt;br /&gt;
对“查 MySQL 重建缓存”这条路径单独限流。即使 Redis 失效，也不能让所有请求都去查 MySQL，只允许少量请求回源，其余请求等待、重试或返回兜底结果。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;降级返回&lt;br /&gt;
对商品详情、活动展示这类读请求，可以短时间返回旧缓存、默认值或简化信息；对下单、扣库存这类核心写请求，可以返回排队中、系统繁忙，或者通过活动开关暂停入口。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;熔断保护&lt;br /&gt;
如果发现 MySQL 响应时间升高、错误率升高或连接池耗尽，可以暂时熔断回源请求，让系统进入降级状态，优先保护数据库和核心服务。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;
&lt;p&gt;如果缓存失效后回源流量明显升高，我会在入口和回源路径都做限流。入口层限制用户、IP、商品和接口维度的请求量；回源层只允许少量线程查 MySQL 重建缓存，其他请求等待、重试或返回兜底结果。如果 MySQL 压力已经很高，可以触发熔断和降级，比如返回系统繁忙、返回旧缓存，或者临时关闭活动入口，优先保护数据库。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;缓存击穿：单个热点 Key 过期&lt;/h4&gt;
&lt;p&gt;热点 Key 过期时，重点是避免所有请求同时回源 MySQL。常见方案是互斥锁和逻辑过期。&lt;/p&gt;
&lt;h5&gt;互斥锁&lt;/h5&gt;
&lt;p&gt;缓存未命中后，只有一个线程能拿到锁去查 MySQL 并重建缓存。其他线程等待、重试，或者直接返回旧值。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;请求发现 Redis 未命中
-&amp;gt; 尝试获取分布式锁
-&amp;gt; 获取成功：查 MySQL，重建缓存，释放锁
-&amp;gt; 获取失败：等待后重试，或返回兜底结果
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;互斥锁可以减少数据库回源次数，但要注意锁超时时间、死锁、重试间隔和线程堆积问题。&lt;/p&gt;
&lt;h5&gt;逻辑过期&lt;/h5&gt;
&lt;p&gt;逻辑过期是缓存 Key 本身不设置物理过期时间，而是在 value 里保存一个过期时间字段。请求发现逻辑时间过期后，先返回旧数据，同时只让一个后台线程异步重建缓存。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Redis value = 数据内容 + 逻辑过期时间

请求读取 Redis
-&amp;gt; 数据没过期：直接返回
-&amp;gt; 数据逻辑过期：先返回旧数据
-&amp;gt; 后台线程异步查 MySQL 并刷新缓存
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;逻辑过期可以提高可用性，适合商品详情、活动配置这类允许短时间旧数据的场景。缺点是短时间内可能返回旧值，所以强一致业务要谨慎使用。&lt;/p&gt;
&lt;h4&gt;结合秒杀项目的回答&lt;/h4&gt;
&lt;p&gt;秒杀库存这类热点数据一般会提前预热到 Redis，抢购时通过 Redis 原子扣减库存，扣减成功后写入 MQ，由消费者异步落 MySQL。这样高并发入口主要打 Redis，MySQL 通过 MQ 被削峰。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;如果缓存过期时流量很高，我会先区分缓存雪崩和缓存击穿。大量 Key 同时过期可以通过随机过期时间、分批预热、限流降级来缓解；分批预热会提前把热点数据分页加载到 Redis，并控制速率，避免预热过程压垮 MySQL。单个热点 Key 过期可以用互斥锁或逻辑过期，只让一个线程重建缓存，其他请求等待、重试或返回旧数据。秒杀库存这种热点场景会提前预热到 Redis，用 Redis 原子扣减加 MQ 异步落库，避免请求集中回源 MySQL。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;你说一下缓存击穿、缓存穿透的问题（重复 4 次）&lt;/h3&gt;
&lt;p&gt;来源：携程二面:175（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）；美团:11（&lt;code&gt;美团/美团.md&lt;/code&gt;）；美团:12（&lt;code&gt;美团/美团.md&lt;/code&gt;）；腾讯面试（&lt;code&gt;腾讯/腾讯.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;题目变体：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;你说一下缓存击穿、缓存穿透的问题。&lt;/li&gt;
&lt;li&gt;假设系统使用 Redis 做缓存，现在突然出现大量短链访问不存在的 key，数据库压力暴增，应该怎么办？&lt;/li&gt;
&lt;li&gt;这个其实就是缓存穿透，对吧？打算怎么防？布隆过滤器放在哪一层？布隆过滤器误判了怎么办？误判后是不是要兜底查库？那数据库会不会又被打爆？&lt;/li&gt;
&lt;li&gt;如何防止缓存击穿和缓存穿透？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;缓存击穿指的是某个热点 Key 过期后，大量并发请求同时发现 Redis 没有数据，于是一起回源查询 MySQL，导致数据库瞬间压力升高。比如秒杀商品详情、热门活动配置、库存信息这类热点数据，一旦缓存失效，就容易出现击穿。&lt;/p&gt;
&lt;p&gt;解决缓存击穿可以从几个方面处理：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;热点 Key 提前预热，活动开始前把商品信息、活动配置、库存等数据加载到 Redis。&lt;/li&gt;
&lt;li&gt;缓存未命中时加互斥锁，只允许一个线程查询数据库并重建缓存，其他线程等待、重试或返回旧值。&lt;/li&gt;
&lt;li&gt;使用逻辑过期。缓存 Key 本身不设置物理过期时间，而是在 value 里保存一个逻辑过期时间。请求读取缓存时，如果数据没有逻辑过期，就直接返回；如果已经逻辑过期，也先返回旧数据，同时只让一个后台线程异步查询 MySQL 并刷新缓存。这样可以避免热点 Key 过期瞬间所有请求都打到数据库，但缺点是短时间内可能返回旧数据，适合商品详情、活动配置这类允许短暂旧值的场景。&lt;/li&gt;
&lt;li&gt;对回源数据库的路径做限流，避免缓存失效时所有请求都去查 MySQL。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;缓存穿透指的是请求的数据在 Redis 和数据库里都不存在，比如一直查询不存在的商品 ID 或订单 ID。因为缓存查不到，数据库也查不到，每次请求都会穿过缓存打到数据库。如果这类请求量很大，会持续消耗数据库资源。&lt;/p&gt;
&lt;p&gt;解决缓存穿透常见有几种方式：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;空值缓存。数据库查不到数据时，也把空结果写入 Redis，并设置较短 TTL，后续相同请求直接命中空缓存。&lt;/li&gt;
&lt;li&gt;布隆过滤器。把合法存在的 ID 提前放进布隆过滤器，请求进来先判断是否可能存在。如果布隆过滤器判断不存在，就直接拦截，减少无效请求访问数据库。&lt;/li&gt;
&lt;li&gt;对异常请求做限流、黑名单或风控，避免恶意请求持续穿透到数据库。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;有了解过分布式锁吗？你们是怎么样实现一个分布式锁的？&lt;/h3&gt;
&lt;p&gt;来源：携程二面:194（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;分布式锁主要是解决多服务实例并发操作同一份共享资源的问题。比如多个服务实例同时扣库存、更新订单状态、处理同一个定时任务，如果只用 Java 本地锁，只能锁住当前 JVM，锁不住其他机器上的实例，所以需要分布式锁。&lt;/p&gt;
&lt;p&gt;项目里可以说用 Redisson 实现分布式锁。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;加锁方式&lt;/p&gt;
&lt;p&gt;Redisson 底层基于 Redis 实现分布式锁，核心是利用 Redis 的原子操作保证同一时刻只有一个客户端能加锁成功。&lt;/p&gt;
&lt;p&gt;本质上类似：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SET lockKey uniqueValue NX EX expireTime
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;NX&lt;/code&gt; 保证只有锁不存在时才能加锁成功，&lt;code&gt;EX&lt;/code&gt; 设置过期时间，避免服务宕机后锁一直不释放。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;锁的唯一标识&lt;/p&gt;
&lt;p&gt;加锁时会给锁设置一个唯一标识，用来区分当前锁属于哪个线程或客户端。&lt;/p&gt;
&lt;p&gt;释放锁时不能直接删除 Key，需要先判断锁是不是自己持有的，再删除。Redisson 底层会用 Lua 脚本保证「判断锁持有者 + 删除锁」是原子操作，避免误删其他线程的锁。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;自动续期&lt;/p&gt;
&lt;p&gt;Redisson 有看门狗机制。如果加锁时没有手动指定固定过期时间，业务还在执行，Redisson 会自动给锁续期，避免业务还没执行完锁就过期，导致其他线程进入临界区。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;可重入&lt;/p&gt;
&lt;p&gt;Redisson 的 &lt;code&gt;RLock&lt;/code&gt; 支持可重入。同一个线程重复获取同一把锁时，不会被自己阻塞，底层会维护加锁次数。释放时也要释放相同次数，计数归零后才真正删除锁。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;获取锁失败处理&lt;/p&gt;
&lt;p&gt;获取锁失败时，可以根据业务场景选择快速失败、等待一段时间重试，或者返回“系统繁忙”。比如秒杀场景里，一般不会让请求长时间阻塞，拿不到锁可以快速返回，避免线程堆积。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;业务兜底&lt;/p&gt;
&lt;p&gt;分布式锁只能降低并发冲突，业务层还要做幂等和数据库兜底。比如创建订单可以用唯一索引防止重复下单，扣库存时可以用库存流水或数据库条件更新保证最终一致性。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;有听过 Redlock 红锁吗？&lt;/h3&gt;
&lt;p&gt;来源：携程二面:234（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Redlock 是 Redis 作者提出的一种分布式锁算法，主要是为了提高 Redis 分布式锁在高可用场景下的可靠性。&lt;/p&gt;
&lt;p&gt;普通 Redis 分布式锁如果只依赖单个 Redis 节点，一旦这个节点宕机，锁就不可用了。如果使用主从复制，主节点加锁成功后还没同步到从节点就宕机，从节点被提升为主节点，其他客户端可能再次加锁成功，导致同一把锁被多个客户端持有。Redlock 就是为了解决这类问题提出的。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;基本思路&lt;/p&gt;
&lt;p&gt;Redlock 会准备多个相互独立的 Redis 节点，比如 5 个 Redis 实例。&lt;/p&gt;
&lt;p&gt;客户端加锁时，不只向一个 Redis 节点加锁，而是依次向多个节点尝试加锁。&lt;/p&gt;
&lt;p&gt;只要在锁有效期内，超过半数节点加锁成功，比如 5 个节点里至少 3 个成功，就认为加锁成功。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;加锁条件&lt;/p&gt;
&lt;p&gt;Redlock 判断加锁成功一般有两个条件：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;超过半数 Redis 节点加锁成功。&lt;/li&gt;
&lt;li&gt;整个加锁过程消耗的时间小于锁的过期时间。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;第二个条件是为了避免加锁过程太慢。比如锁过期时间是 10 秒，但加锁过程已经花了 9 秒多，这时候即使多数节点成功，锁实际剩余时间也很短，继续执行业务会有风险。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;释放锁&lt;/p&gt;
&lt;p&gt;释放锁时，客户端会向所有 Redis 节点发送释放锁请求。&lt;/p&gt;
&lt;p&gt;释放时仍然要校验锁的唯一标识，只能释放自己持有的锁，避免误删其他客户端的锁。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;优点&lt;/p&gt;
&lt;p&gt;Redlock 相比单节点 Redis 锁，容错能力更强。&lt;/p&gt;
&lt;p&gt;只要多数 Redis 节点可用，就可以继续完成加锁，避免单个 Redis 节点故障导致锁完全不可用。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;请说明 Redis 常用的数据类型（重复 4 次）&lt;/h3&gt;
&lt;p&gt;来源：携程二面:848（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）；美团:10（&lt;code&gt;美团/美团.md&lt;/code&gt;）；美团2:9（&lt;code&gt;美团2/美团2.md&lt;/code&gt;）；美团3:218（&lt;code&gt;美团3/美团3.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;题目变体：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;请说明 Redis 常用的数据类型。&lt;/li&gt;
&lt;li&gt;Redis 的基本数据类型有哪些？&lt;/li&gt;
&lt;li&gt;Redis 的数据结构和特点有哪些？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Redis 常用数据类型主要有 String、Hash、List、Set、ZSet，另外还有一些特殊结构，比如 Bitmap、HyperLogLog、Geo、Stream。可以先讲五种基础类型，再补充几个常见特殊类型。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;String&lt;/p&gt;
&lt;p&gt;String 是 Redis 最基础的数据类型，可以存字符串、数字、JSON、序列化对象等。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;缓存对象或页面结果。&lt;/li&gt;
&lt;li&gt;计数器，比如浏览量、点赞数。&lt;/li&gt;
&lt;li&gt;分布式锁，比如 &lt;code&gt;SET key value NX EX&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;保存验证码、Token、Session 等。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;常用命令有 &lt;code&gt;GET&lt;/code&gt;、&lt;code&gt;SET&lt;/code&gt;、&lt;code&gt;INCR&lt;/code&gt;、&lt;code&gt;DECR&lt;/code&gt;、&lt;code&gt;MGET&lt;/code&gt;、&lt;code&gt;SETEX&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Hash&lt;/p&gt;
&lt;p&gt;Hash 类似 Java 里的 &lt;code&gt;Map&amp;lt;String, Map&amp;lt;String, String&amp;gt;&amp;gt;&lt;/code&gt;，一个 key 下面可以有多个 field。&lt;/p&gt;
&lt;p&gt;适合存对象的多个字段，比如用户信息、商品信息。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user:1
name = 张三
age = 20
city = 上海
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;好处是可以单独修改某个字段，不需要每次把整个对象序列化后重新写入。&lt;/p&gt;
&lt;p&gt;常用命令有 &lt;code&gt;HGET&lt;/code&gt;、&lt;code&gt;HSET&lt;/code&gt;、&lt;code&gt;HMGET&lt;/code&gt;、&lt;code&gt;HINCRBY&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;List&lt;/p&gt;
&lt;p&gt;List 是有序列表，底层可以支持从两端插入和弹出。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;简单消息队列。&lt;/li&gt;
&lt;li&gt;最新消息列表。&lt;/li&gt;
&lt;li&gt;时间线列表。&lt;/li&gt;
&lt;li&gt;任务队列。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;常用命令有 &lt;code&gt;LPUSH&lt;/code&gt;、&lt;code&gt;RPUSH&lt;/code&gt;、&lt;code&gt;LPOP&lt;/code&gt;、&lt;code&gt;RPOP&lt;/code&gt;、&lt;code&gt;LRANGE&lt;/code&gt;、&lt;code&gt;BRPOP&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;比如用 &lt;code&gt;LPUSH + BRPOP&lt;/code&gt; 可以实现简单阻塞队列。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Set&lt;/p&gt;
&lt;p&gt;Set 是无序不重复集合。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;去重，比如用户签到、访问用户集合。&lt;/li&gt;
&lt;li&gt;共同好友。&lt;/li&gt;
&lt;li&gt;标签系统。&lt;/li&gt;
&lt;li&gt;抽奖、随机取用户。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Set 支持集合运算，比如交集、并集、差集。&lt;/p&gt;
&lt;p&gt;常用命令有 &lt;code&gt;SADD&lt;/code&gt;、&lt;code&gt;SREM&lt;/code&gt;、&lt;code&gt;SISMEMBER&lt;/code&gt;、&lt;code&gt;SINTER&lt;/code&gt;、&lt;code&gt;SUNION&lt;/code&gt;、&lt;code&gt;SDIFF&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;ZSet&lt;/p&gt;
&lt;p&gt;ZSet 也叫 Sorted Set，有序集合。&lt;/p&gt;
&lt;p&gt;它和 Set 一样元素不重复，但每个元素会有一个 score，Redis 会根据 score 排序。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;排行榜。&lt;/li&gt;
&lt;li&gt;热门文章排序。&lt;/li&gt;
&lt;li&gt;延迟队列。&lt;/li&gt;
&lt;li&gt;按权重排序的任务。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;常用命令有 &lt;code&gt;ZADD&lt;/code&gt;、&lt;code&gt;ZRANGE&lt;/code&gt;、&lt;code&gt;ZREVRANGE&lt;/code&gt;、&lt;code&gt;ZRANK&lt;/code&gt;、&lt;code&gt;ZREM&lt;/code&gt;、&lt;code&gt;ZSCORE&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Bitmap&lt;/p&gt;
&lt;p&gt;Bitmap 本质上是基于 String 的位操作。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用户签到。&lt;/li&gt;
&lt;li&gt;活跃用户统计。&lt;/li&gt;
&lt;li&gt;布尔状态标记。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比如一个用户一年 365 天签到，可以用 365 个 bit 表示，空间占用很小。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;HyperLogLog&lt;/p&gt;
&lt;p&gt;HyperLogLog 用于基数统计，也就是统计不重复元素数量。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;UV 统计。&lt;/li&gt;
&lt;li&gt;去重计数。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它的优点是占用内存很小，缺点是结果有一定误差。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Geo&lt;/p&gt;
&lt;p&gt;Geo 用于地理位置相关场景。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;附近的人。&lt;/li&gt;
&lt;li&gt;附近门店。&lt;/li&gt;
&lt;li&gt;计算两个位置之间距离。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Stream&lt;/p&gt;
&lt;p&gt;Stream 是 Redis 5.0 引入的消息流结构。&lt;/p&gt;
&lt;p&gt;它支持消息持久化、消费组、消息 ID，适合做轻量级消息队列。&lt;/p&gt;
&lt;p&gt;相比 List，Stream 更适合多消费者和消息确认场景。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Redis 常用基础类型有 String、Hash、List、Set、ZSet。String 适合缓存、计数器、分布式锁；Hash 适合存对象字段；List 适合队列和列表；Set 适合去重和集合运算；ZSet 适合排行榜和延迟队列。除此之外，Bitmap 适合签到和状态统计，HyperLogLog 适合 UV 这类基数统计，Geo 适合地理位置，Stream 适合轻量级消息队列。&lt;/p&gt;
&lt;h4&gt;底层实现追问：为什么 ZSet 用跳表？&lt;/h4&gt;
&lt;p&gt;Redis 的底层编码会随数据量和数据特征变化：String 使用 SDS；List 在现代 Redis 中主要使用 quicklist；小 Hash 和小 ZSet 使用紧凑的 listpack；Set 在元素全为整数且数量较少时使用 &lt;code&gt;intset&lt;/code&gt;，否则使用哈希表。&lt;/p&gt;
&lt;p&gt;ZSet 保存 &lt;code&gt;member + score&lt;/code&gt;。数据量增大后，通常使用哈希表和跳表组合：哈希表根据 member 快速查询 score，跳表按 score 维护顺序，支持排名与范围查询。普通有序链表查找为 &lt;code&gt;O(n)&lt;/code&gt;；跳表通过多级索引，使查找、插入、删除的平均复杂度为 &lt;code&gt;O(log n)&lt;/code&gt;，且顺序遍历和范围查询实现直接，因此适合 ZSet。&lt;/p&gt;
&lt;h3&gt;Redis 的容灾策略是什么？&lt;/h3&gt;
&lt;p&gt;来源：腾讯面试（&lt;code&gt;腾讯/腾讯.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Redis 容灾分为节点高可用、数据可靠性和故障降级三层。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;节点高可用：Redis 采用主从复制。非分片场景通常使用 3 个 Sentinel 监控和故障转移；大容量、高并发场景使用 Redis Cluster，将数据按 16,384 个 Slot 分片，每个主节点配置从节点。主从和 Sentinel 应分布在不同机器或可用区。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;数据可靠性：通常配置 AOF + RDB。AOF 常用 &lt;code&gt;appendfsync everysec&lt;/code&gt;，RDB 提供定期快照和恢复能力。主从默认异步复制，关键写入可通过 &lt;code&gt;WAIT&lt;/code&gt; 等待副本确认，并用 &lt;code&gt;min-replicas-to-write&lt;/code&gt;、&lt;code&gt;min-replicas-max-lag&lt;/code&gt; 在副本不足或延迟过大时拒绝写入。核心订单和账务仍以 MySQL 等持久化存储为最终依据。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;故障降级：Redis 宕机或切换期间，通过网关限流、接口熔断降级、本地缓存、热点 Key 逻辑过期或互斥重建保护数据库；恢复后限速预热缓存，避免请求集中回源。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：主从加 Sentinel 或 Cluster 处理节点故障，AOF、RDB 和副本确认控制数据丢失，限流、降级和本地缓存保护后端数据库。&lt;/p&gt;
&lt;h3&gt;Redis 中 String 的底层数据结构是什么？&lt;/h3&gt;
&lt;p&gt;来源：腾讯面试（&lt;code&gt;腾讯/腾讯.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Redis String 对外是字符串类型，底层由 &lt;code&gt;redisObject&lt;/code&gt; 管理，常见编码有 &lt;code&gt;int&lt;/code&gt;、&lt;code&gt;embstr&lt;/code&gt; 和 &lt;code&gt;raw&lt;/code&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;int&lt;/code&gt;：Value 是整数时，直接保存整数值，不创建 SDS；适合计数器、库存。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;embstr&lt;/code&gt;：短字符串。&lt;code&gt;redisObject&lt;/code&gt; 和 SDS 连续分配，内存分配次数少、缓存局部性较好；修改后通常转为 &lt;code&gt;raw&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;raw&lt;/code&gt;：较长字符串。&lt;code&gt;redisObject&lt;/code&gt; 和 SDS 分开分配，适合长文本、JSON、序列化对象等可扩容数据。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;实际字符串由 SDS（Simple Dynamic String）保存，可理解为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct sdshdr {
    len;    // 已使用长度
    alloc;  // 已分配容量
    flags;  // SDS 头类型
    buf[];  // 实际内容，末尾保留 \0
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SDS 直接保存长度，&lt;code&gt;STRLEN&lt;/code&gt; 是 &lt;code&gt;O(1)&lt;/code&gt;；它是二进制安全的，可保存 &lt;code&gt;\0&lt;/code&gt; 和任意字节；追加时预留空间，减少频繁扩容，&lt;code&gt;APPEND&lt;/code&gt; 的均摊复杂度较低。Redis 的 Key 本身通常也使用 SDS，Key 和 Value 由 Redis 字典关联。&lt;/p&gt;
&lt;p&gt;总结：String 的对象编码负责内存与访问优化，SDS 负责保存实际字节数据。&lt;/p&gt;
&lt;h3&gt;除了 Redis 可以实现分布式锁，还有其他什么方式吗？&lt;/h3&gt;
&lt;p&gt;来源：携程二面:971（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;除了 Redis，分布式锁还可以用 ZooKeeper 和数据库来实现。Redis 更偏性能，ZooKeeper 更偏分布式协调和一致性，数据库方案实现简单，但高并发能力相对一般。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;ZooKeeper 实现分布式锁&lt;/p&gt;
&lt;p&gt;ZooKeeper 通常通过临时顺序节点实现分布式锁。&lt;/p&gt;
&lt;p&gt;大致流程是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;多个客户端都在同一个锁目录下创建临时顺序节点。&lt;/li&gt;
&lt;li&gt;创建成功后，每个客户端查看自己是不是序号最小的节点。&lt;/li&gt;
&lt;li&gt;如果自己是最小节点，说明获取锁成功。&lt;/li&gt;
&lt;li&gt;如果自己不是最小节点，就监听自己前一个节点。&lt;/li&gt;
&lt;li&gt;当前一个节点被删除时，再判断自己是否变成最小节点。&lt;/li&gt;
&lt;li&gt;释放锁时，客户端删除自己的临时节点。&lt;/li&gt;
&lt;li&gt;如果客户端宕机或会话断开，临时节点会自动删除，避免死锁。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;ZooKeeper 的优点是：一致性强，天然适合做分布式协调，锁释放也比较可靠。&lt;/p&gt;
&lt;p&gt;缺点是：性能通常不如 Redis，部署维护成本更高。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;数据库实现分布式锁&lt;/p&gt;
&lt;p&gt;数据库可以通过唯一索引或悲观锁实现分布式锁。&lt;/p&gt;
&lt;p&gt;第一种是唯一索引方案。&lt;/p&gt;
&lt;p&gt;可以建一张锁表，&lt;code&gt;lock_name&lt;/code&gt; 设置唯一索引。加锁时往表里插入一条锁记录，插入成功表示拿到锁；如果唯一索引冲突，说明锁已经被其他客户端持有。释放锁时删除这条记录。&lt;/p&gt;
&lt;p&gt;第二种是悲观锁方案。&lt;/p&gt;
&lt;p&gt;可以通过：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT ... FOR UPDATE;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对某一行加排他锁。事务没有提交前，其他事务无法拿到这行锁。&lt;/p&gt;
&lt;p&gt;数据库方案的优点是：实现简单，不需要额外引入 Redis 或 ZooKeeper。&lt;/p&gt;
&lt;p&gt;缺点是：性能和并发能力一般，高并发下会给数据库带来压力；还需要处理锁超时、事务提交、死锁等问题。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;除了 Redis，分布式锁还可以用 ZooKeeper 和数据库实现。ZooKeeper 一般通过临时顺序节点实现，客户端只监听前一个节点，拿到最小序号就获得锁，适合一致性要求更高的分布式协调场景。数据库可以通过唯一索引或 &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; 实现，方案简单，但并发能力和性能一般，更适合低并发或对中间件依赖较少的场景。&lt;/p&gt;
&lt;h3&gt;通过 ZooKeeper 实现的分布式锁和 Redis 实现的分布式锁有什么不一样？两种方式各有什么优缺点？&lt;/h3&gt;
&lt;p&gt;来源：携程二面:1016（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;h3&gt;假如 Redis 故障恢复时间有 1 分钟，1 分钟内所有请求都穿透到数据库，应该怎么处理？&lt;/h3&gt;
&lt;p&gt;来源：美团:14（&lt;code&gt;美团/美团.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;这个场景更接近 Redis 整体不可用导致的缓存雪崩。核心思路是：Redis 挂掉期间，不能让所有流量直接打到数据库，要做限流、降级、熔断和本地缓存兜底。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;先做限流，保护数据库&lt;/p&gt;
&lt;p&gt;Redis 故障时，数据库会突然承接大量请求。第一步要在网关层或应用层限流，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;网关限流
接口限流
用户维度限流
热点 Key 维度限流
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比如某个接口平时 1 万 QPS，数据库只能扛 1000 QPS，那 Redis 故障时就只能放一部分请求进来，剩下的快速失败或返回降级结果。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;做熔断，避免请求持续打库&lt;/p&gt;
&lt;p&gt;如果发现 Redis 连续不可用，或者数据库压力已经很高，就触发熔断。&lt;/p&gt;
&lt;p&gt;熔断后，部分非核心接口可以直接返回：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;系统繁忙，请稍后重试
默认值
兜底数据
空结果
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样可以避免所有请求都阻塞在数据库上，导致数据库也被拖垮。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用本地缓存兜底&lt;/p&gt;
&lt;p&gt;对一些热点数据、配置数据、首页数据，可以在应用内保留一份本地缓存，比如 Caffeine。&lt;/p&gt;
&lt;p&gt;Redis 故障时，先从本地缓存读：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;请求 -&amp;gt; Redis 失败 -&amp;gt; 读本地缓存 -&amp;gt; 返回旧数据
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本地缓存的数据可能不是最新的，但可以在短时间内保证系统可用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;非核心业务降级&lt;/p&gt;
&lt;p&gt;Redis 故障期间，可以临时关闭一些非核心功能，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;推荐列表
排行榜
浏览量统计
非关键状态查询
活动装饰信息
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;核心链路优先保证，比如下单、支付、登录、查询订单等。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;数据库查询也要做保护&lt;/p&gt;
&lt;p&gt;对必须查数据库的请求，也不能无限制查。可以加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SQL 超时时间
连接池限制
慢查询监控
只读库分担压力
热点请求合并
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果多个请求同时查同一个热点数据，可以用本地锁或请求合并，让一个线程查库，其他线程等待结果，避免同一个 Key 打出大量重复 SQL。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Redis 恢复后重建缓存&lt;/p&gt;
&lt;p&gt;Redis 恢复后，不能让所有请求同时回源重建缓存。可以采用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;分批预热缓存
热点 Key 优先加载
缓存过期时间加随机值
异步重建缓存
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;防止 Redis 刚恢复，数据库又被大量缓存重建请求打满。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：Redis 故障 1 分钟时，重点是保护数据库。处理方式包括网关或应用限流、熔断降级、本地缓存兜底、非核心功能降级、数据库连接池和 SQL 超时保护。对于热点数据，可以做请求合并，避免大量请求同时查库。Redis 恢复后，要分批预热缓存，避免缓存重建再次冲垮数据库。&lt;/p&gt;
&lt;h3&gt;项目中如何解决超卖？&lt;/h3&gt;
&lt;p&gt;来源：京东面试二&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;超卖的本质是高并发下，多个请求同时判断库存充足，随后都完成扣减，导致库存变成负数或卖出的数量超过实际库存。&lt;/p&gt;
&lt;h4&gt;普通并发：数据库条件更新兜底&lt;/h4&gt;
&lt;p&gt;库存扣减使用带条件的一条 SQL：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UPDATE sku
SET stock = stock - #{count}
WHERE sku_id = #{skuId}
  AND stock &amp;gt;= #{count};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;通过受影响行数判断结果：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;返回 &lt;code&gt;1&lt;/code&gt;：扣减成功，可以创建订单。&lt;/li&gt;
&lt;li&gt;返回 &lt;code&gt;0&lt;/code&gt;：库存不足，直接返回售罄。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这条 SQL 是原子操作，数据库库存作为最终兜底。创建订单、扣库存、记录扣减流水放在同一个本地事务中，并给订单或扣减流水加唯一约束，保证重复请求不会重复扣减。&lt;/p&gt;
&lt;h4&gt;秒杀等高并发：Redis 预扣库存 + MQ 异步下单&lt;/h4&gt;
&lt;p&gt;秒杀场景下，全部请求直接打 MySQL 会形成热点行竞争，因此在 Redis 做库存预扣：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;请求到达
-&amp;gt; Redis Lua 校验库存、校验是否重复购买、预扣库存
-&amp;gt; 预扣成功，发送消息到 MQ
-&amp;gt; 消费者异步创建订单，并通过数据库条件更新扣减最终库存
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Lua 脚本中完成“判断库存是否充足 + 扣减库存 + 记录用户已抢购”的逻辑。Redis 单线程执行脚本，整个过程具备原子性。&lt;/p&gt;
&lt;h4&gt;异常与一致性处理&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;消费者处理消息时做幂等：订单号、业务流水号设置唯一索引，消息重复投递也只会成功一次。&lt;/li&gt;
&lt;li&gt;数据库扣减失败或订单创建失败时，需要补偿 Redis 库存。&lt;/li&gt;
&lt;li&gt;Redis 宕机或数据异常时，以数据库库存为准，通过定时任务或对账任务修正 Redis 库存。&lt;/li&gt;
&lt;li&gt;用户请求使用幂等号，避免重复点击导致多次下单。&lt;/li&gt;
&lt;li&gt;限购场景在 Lua 中记录用户购买标记，防止同一用户重复抢购。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：普通库存通过 &lt;code&gt;stock &amp;gt;= count&lt;/code&gt; 的条件更新保证不超卖；秒杀场景用 Redis Lua 预扣库存削峰，再通过 MQ 异步落库，数据库条件更新作为最终一致性兜底。&lt;/p&gt;
&lt;h3&gt;为什么 Lua 脚本能够保证原子性？&lt;/h3&gt;
&lt;p&gt;来源：京东面试二&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Redis 中 Lua 脚本的原子性，来自 Redis 将整段脚本作为一个命令执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;客户端执行 EVAL / EVALSHA
-&amp;gt; Redis 执行整段 Lua 脚本
-&amp;gt; 脚本执行完成
-&amp;gt; Redis 再处理其他客户端命令
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Redis 执行 Lua 脚本期间，不会切换去执行其他客户端的命令。因此脚本里的多条 Redis 操作不会被其他请求插入，最终效果相当于一个原子操作。&lt;/p&gt;
&lt;p&gt;例如秒杀扣库存：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;local stock = tonumber(redis.call(&apos;GET&apos;, KEYS[1]))

if stock == nil or stock &amp;lt;= 0 then
    return 0
end

redis.call(&apos;DECR&apos;, KEYS[1])
redis.call(&apos;SADD&apos;, KEYS[2], ARGV[1])
return 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个脚本一次完成库存校验、扣减库存和记录用户已购买。多个请求并发执行时，Redis 串行执行脚本；后一个请求会读到前一个请求扣减后的最新库存。&lt;/p&gt;
&lt;h4&gt;使用边界&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;Lua 原子性不等于数据库事务回滚。脚本运行到一半发生错误时，前面已经成功执行的 Redis 命令会保留。因此应先完成参数和库存校验，再执行写操作。&lt;/li&gt;
&lt;li&gt;脚本必须短小。Lua 执行期间会阻塞 Redis 对其他命令的处理，脚本中不应写大循环、复杂计算或耗时逻辑。&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;Redis Cluster 场景&lt;/h4&gt;
&lt;p&gt;Lua 脚本访问多个 Key 时，这些 Key 必须位于同一个 Hash Slot。Key 分散在不同 Slot 时，Redis Cluster 直接拒绝执行，报错：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CROSSSLOT Keys in request don&apos;t hash to the same slot
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;脚本不会开始执行，也不会出现部分命令已执行的情况。&lt;/p&gt;
&lt;p&gt;常用 Hash Tag 让相关 Key 路由到同一分片：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;stock:{sku1001}
buyers:{sku1001}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Redis Cluster 只使用花括号中的内容计算 Slot，因此这两个 Key 会落到同一节点，Lua 才能原子访问。跨多个 SKU、跨分片的场景无法依赖单个 Lua 脚本保证原子性，通常按 SKU 拆分处理，再通过消息队列、订单状态和补偿机制保证最终一致性。&lt;/p&gt;
&lt;p&gt;总结：Lua 脚本通过 Redis 串行执行，保证“校验 + 扣减 + 标记”等多步 Redis 操作不会被其他请求插入。&lt;/p&gt;
&lt;h3&gt;并发量特别大时如何应对？请说明 Redis Cluster 的方案。&lt;/h3&gt;
&lt;p&gt;来源：京东面试二&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;并发量特别大时，我会先判断瓶颈在网关、应用、Redis 还是数据库。Redis Cluster 主要解决 Redis 单实例内存和单节点 QPS 的上限问题，通过数据分片把请求分散到多个节点。&lt;/p&gt;
&lt;h4&gt;Redis Cluster 的核心原理&lt;/h4&gt;
&lt;p&gt;Redis Cluster 将 Key 空间划分为 &lt;code&gt;16384&lt;/code&gt; 个 Hash Slot：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;slot = CRC16(key) % 16384
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每个主节点负责一部分 Slot：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Node A -&amp;gt; Slot 0 ~ 5460
Node B -&amp;gt; Slot 5461 ~ 10922
Node C -&amp;gt; Slot 10923 ~ 16383
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;客户端根据 Key 计算 Slot，直接请求负责该 Slot 的主节点。因此不同 Key 的读写请求可以分散到多个 Redis 主节点处理。生产上每个主节点会配置从节点，主节点故障后，Cluster 会从对应从节点中选举新主节点继续提供服务。&lt;/p&gt;
&lt;h4&gt;按业务 Key 分片&lt;/h4&gt;
&lt;p&gt;例如用户数据、商品数据按用户 ID 或商品 ID 设计 Key：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user:1001
user:1002
product:2001
product:2002
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不同 Key 会分布到不同 Slot 和不同节点，QPS 可以随节点数增加而横向扩展。&lt;/p&gt;
&lt;h4&gt;避免热点 Key&lt;/h4&gt;
&lt;p&gt;一个热点 Key 始终只属于一个 Slot，也只会压在一个主节点上。即使 Cluster 有多个分片，也无法自动分散单个 Key 的压力：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;product:1001 -&amp;gt; Slot 8000 -&amp;gt; Node B

100 万 QPS 都访问 product:1001
-&amp;gt; 请求都落到 Node B
-&amp;gt; Node B 成为瓶颈
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;读多写少的热点数据，例如商品详情、热点配置，使用多级缓存：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;请求
-&amp;gt; Caffeine 本地缓存命中，直接返回
-&amp;gt; 本地未命中，查询 Redis
-&amp;gt; Redis 未命中，查询 MySQL 并回填
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本地缓存将读请求分摊到各应用机器。热点数据更新时，可以更新 MySQL 后删除 Redis，并通过消息通知各应用机器清理本地缓存；也可设置较短 TTL，接受短暂旧数据。热点失效时通过请求合并，只允许一个线程回源，其他线程等待结果。&lt;/p&gt;
&lt;p&gt;计数器这类写热点可以做分片。原来所有写请求更新一个 Key：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;INCR like:article:1001
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;现在将总数拆成 16 个部分：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;like:article:1001:0
like:article:1001:1
...
like:article:1001:15
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;写入时根据用户 ID 哈希或随机值选择一个分片执行 &lt;code&gt;INCR&lt;/code&gt;。每个分片记录的是总点赞数的一部分，查询时由应用并行读取各分片后求和。这样写压力会分散到多个 Key、多个 Slot 和节点，适合点赞数、浏览量等允许短暂延迟汇总的指标。&lt;/p&gt;
&lt;p&gt;库存不能将同一份库存直接复制到多个 Key。例如真实库存为 100，下面的设计会导致系统认为库存有 200：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;stock:sku1001:0 = 100
stock:sku1001:1 = 100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;极端热点下可以使用库存分桶，但每个桶只能保存真实库存的一部分：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;真实库存：1000
stock:sku1001:0 = 250
stock:sku1001:1 = 250
stock:sku1001:2 = 250
stock:sku1001:3 = 250
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;请求选择一个桶后，用 Lua 原子校验和扣减该桶库存。库存分桶需要额外处理空桶重试、回补、幂等和对账，常规秒杀先使用单库存 Key 的 Lua 预扣、限流和 MQ 削峰；单 Key 已成为明确瓶颈时，再考虑库存分桶。&lt;/p&gt;
&lt;h4&gt;避免大 Key&lt;/h4&gt;
&lt;p&gt;大 Key 常见形式包括：单个 String 存储数 MB 的 JSON，或一个 Hash、List、Set、ZSet 保存数万甚至更多元素。大 Key 会带来网络传输、序列化、主从复制、持久化和 Slot 迁移开销；对大集合执行 &lt;code&gt;HGETALL&lt;/code&gt;、&lt;code&gt;SMEMBERS&lt;/code&gt;、大范围 &lt;code&gt;ZRANGE&lt;/code&gt; 或直接 &lt;code&gt;DEL&lt;/code&gt;，还可能影响同节点其他请求。&lt;/p&gt;
&lt;p&gt;常见处理方式：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;文章正文、文件等大对象放数据库或对象存储，Redis 只缓存摘要、ID、版本号、访问地址等小数据。&lt;/li&gt;
&lt;li&gt;大集合按日期、分页、用户范围或分片编号拆分，读取时分页查询指定分片。&lt;/li&gt;
&lt;li&gt;删除大 Key 使用 &lt;code&gt;UNLINK&lt;/code&gt;，由后台线程异步释放内存，减少 Redis 主线程阻塞。&lt;/li&gt;
&lt;li&gt;结合 &lt;code&gt;redis-cli --bigkeys&lt;/code&gt;、&lt;code&gt;MEMORY USAGE&lt;/code&gt;、慢查询日志和业务监控定位大 Key；线上避免执行 &lt;code&gt;KEYS *&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;缓存和数据库保护&lt;/h4&gt;
&lt;p&gt;Redis Cluster 仍需要配合缓存治理：热点数据提前预热；缓存穿透使用布隆过滤器和空值缓存；缓存击穿使用逻辑过期、互斥重建或请求合并；缓存雪崩通过过期时间打散、限流、熔断和降级保护数据库。&lt;/p&gt;
&lt;h4&gt;扩容和故障转移&lt;/h4&gt;
&lt;p&gt;扩容时新增节点并迁移部分 Slot。迁移期间客户端可能收到 &lt;code&gt;MOVED&lt;/code&gt; 或 &lt;code&gt;ASK&lt;/code&gt; 重定向，更新 Slot 与节点的映射后重试请求。每个主节点配置从节点，主节点故障后由从节点提升为主节点。&lt;/p&gt;
&lt;p&gt;总结：Redis Cluster 通过 &lt;code&gt;16384&lt;/code&gt; 个 Hash Slot 将数据和请求分散到多个主节点，解决单实例容量和 QPS 的瓶颈。实际高并发场景还要处理热点 Key、大 Key、缓存保护和数据库兜底。&lt;/p&gt;
&lt;h3&gt;Redis Cluster 如何实现主从复制？&lt;/h3&gt;
&lt;p&gt;来源：京东面试二&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Redis Cluster 中，每个 Slot 由一个主节点负责，主节点可以配置一个或多个从节点：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Master A：负责部分 Slot
  ├─ Replica A1
  └─ Replica A2

Master B：负责另一部分 Slot
  └─ Replica B1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所有写请求先写入对应的主节点，主节点再将写命令异步同步给从节点。&lt;/p&gt;
&lt;h4&gt;首次同步：全量复制&lt;/h4&gt;
&lt;p&gt;从节点第一次连接主节点，或复制积压缓冲区无法覆盖断线期间的缺失数据时，会触发全量复制：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;从节点连接主节点，发送 PSYNC
-&amp;gt; 主节点 fork 子进程执行 BGSAVE，生成 RDB 快照
-&amp;gt; 主进程继续处理客户端读写
-&amp;gt; 主节点将生成 RDB 期间的新写命令写入复制缓冲区
-&amp;gt; 主节点发送 RDB 给从节点
-&amp;gt; 从节点加载 RDB
-&amp;gt; 主节点按顺序发送复制缓冲区中的增量命令
-&amp;gt; 从节点追上主节点进度，进入实时复制
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;全量复制期间主节点不会为同步而锁住用户写请求。&lt;code&gt;BGSAVE&lt;/code&gt; 的子进程基于 fork 时刻的内存视图生成 RDB，主进程继续处理请求。用户在快照期间修改的数据会进入复制缓冲区，在从节点加载 RDB 后按顺序补发。&lt;/p&gt;
&lt;p&gt;例如 RDB 快照中的 &lt;code&gt;key = 10&lt;/code&gt;，生成 RDB 期间用户执行 &lt;code&gt;SET key 11&lt;/code&gt;。从节点先从 RDB 得到 &lt;code&gt;key = 10&lt;/code&gt;，再执行缓冲区中的 &lt;code&gt;SET key 11&lt;/code&gt;，最终数据与主节点一致。&lt;/p&gt;
&lt;p&gt;fork 依赖操作系统的 Copy-On-Write。高写入期间会产生额外内存页复制，因此全量同步时需要预留足够内存，避免频繁触发。&lt;/p&gt;
&lt;p&gt;从节点加载新 RDB 的短暂阶段，需要清空旧数据并加载新数据，可能暂时无法响应客户端请求。同步开始前，从节点可以按配置继续使用旧数据提供读服务，因此副本读取需要接受最终一致性。&lt;/p&gt;
&lt;h4&gt;网络短暂断开：增量复制&lt;/h4&gt;
&lt;p&gt;从节点会记录主节点的复制 ID 和复制偏移量：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;replid + offset
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;网络恢复后：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;从节点携带 replid + offset 重新发送 PSYNC
-&amp;gt; 主节点检查 replication backlog
-&amp;gt; 缓冲区保留了缺失命令：发送缺失的增量命令
-&amp;gt; 缓冲区已无法覆盖：退化为全量复制
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;增量复制不生成 RDB，主节点继续处理客户端请求，恢复速度比全量复制快。&lt;/p&gt;
&lt;h4&gt;异步复制、WAIT 与写保护&lt;/h4&gt;
&lt;p&gt;Redis 主从复制默认异步：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;客户端写入 Master
-&amp;gt; Master 执行成功并返回客户端
-&amp;gt; Master 异步将命令发送给 Replica
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;主节点刚返回成功就宕机，而该数据尚未同步到从节点时，故障转移后可能丢失这次写入。&lt;/p&gt;
&lt;p&gt;关键写入可以使用 &lt;code&gt;WAIT&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;写入 Master
-&amp;gt; 当前客户端等待指定数量 Replica 的确认或超时
-&amp;gt; 收到确认后继续业务流程
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;WAIT&lt;/code&gt; 只等待当前客户端请求，不会阻塞 Redis 处理其他客户端命令。它能降低故障转移时的数据丢失概率，业务仍按最终一致性处理重试和补偿。&lt;/p&gt;
&lt;p&gt;还可以配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;min-replicas-to-write
min-replicas-max-lag
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;满足副本数量和延迟要求时主节点才接受写入；条件不满足时直接拒绝新的写请求。该机制限制数据丢失窗口。&lt;/p&gt;
&lt;h4&gt;故障转移&lt;/h4&gt;
&lt;p&gt;主节点故障后，Cluster 会从其从节点中选举一个提升为新主节点，并接管原主节点负责的 Slot。旧主节点恢复后，通常作为新主节点的从节点重新同步数据。&lt;/p&gt;
&lt;p&gt;总结：Redis Cluster 通过主节点处理写入、从节点异步复制实现高可用；首次同步使用“RDB 快照 + 缓冲区增量命令”，短暂断线通过 &lt;code&gt;PSYNC&lt;/code&gt;、&lt;code&gt;replid&lt;/code&gt; 和 &lt;code&gt;offset&lt;/code&gt; 做增量复制。全量同步期间主节点继续处理用户请求，关键写入可结合 &lt;code&gt;WAIT&lt;/code&gt; 和副本延迟保护降低数据丢失风险。&lt;/p&gt;
&lt;h3&gt;请说明 Redis Sentinel（哨兵）的工作机制。&lt;/h3&gt;
&lt;p&gt;来源：京东面试二&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Redis Sentinel 用于非 Cluster 模式下的 Redis 高可用，负责监控主从节点、故障通知、自动故障转移，以及向客户端提供当前主节点地址。&lt;/p&gt;
&lt;p&gt;生产上通常部署至少 &lt;code&gt;3&lt;/code&gt; 个 Sentinel，并放在不同机器或可用区：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Sentinel 1
Sentinel 2
Sentinel 3

      监控
        ↓
Master
  ├─ Replica 1
  └─ Replica 2
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;故障检测&lt;/h4&gt;
&lt;p&gt;Sentinel 会定期向主节点、从节点和其他 Sentinel 发送 &lt;code&gt;PING&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;当某个 Sentinel 在 &lt;code&gt;down-after-milliseconds&lt;/code&gt; 时间内无法得到正常响应时，会标记主节点为主观下线（SDOWN）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Sentinel 1 认为 Master 不可用
-&amp;gt; SDOWN
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;多个 Sentinel 之间会互相确认。当达到配置的 &lt;code&gt;quorum&lt;/code&gt; 数量，都认为主节点不可用时，标记为客观下线（ODOWN）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Sentinel 1：Master 不可用
Sentinel 2：Master 不可用
quorum = 2
-&amp;gt; ODOWN
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;选举 Leader Sentinel&lt;/h4&gt;
&lt;p&gt;发生 ODOWN 后，多个 Sentinel 发起投票，选出一个 Leader Sentinel 执行故障转移：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ODOWN
-&amp;gt; Sentinel 发起选举
-&amp;gt; 多数 Sentinel 投票
-&amp;gt; 选出 Leader Sentinel
-&amp;gt; Leader 执行 Failover
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;选举需要多数 Sentinel 同意。网络分区时，少数分区无法提升新主节点，降低双主风险。&lt;/p&gt;
&lt;p&gt;Sentinel 使用自己的纪元和投票流程，不运行 Raft 协议。&lt;/p&gt;
&lt;h4&gt;故障转移流程&lt;/h4&gt;
&lt;p&gt;Leader Sentinel 选出一个从节点提升为新主节点，通常优先考虑：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;replica-priority&lt;/code&gt; 更高的副本。&lt;/li&gt;
&lt;li&gt;复制偏移量更大的副本。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;runid&lt;/code&gt; 更小的副本作为最终排序条件。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;具体流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;原 Master 故障
-&amp;gt; Leader Sentinel 选择最佳 Replica
-&amp;gt; 向该 Replica 发送 REPLICAOF NO ONE
-&amp;gt; Replica 晋升为新 Master
-&amp;gt; 其他 Replica 改为复制新 Master
-&amp;gt; Sentinel 通知客户端新的 Master 地址
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;应用客户端需要支持 Sentinel。连接时先向 Sentinel 查询当前主节点地址，主从切换后重新获取地址并重连新主节点。&lt;/p&gt;
&lt;h4&gt;旧主节点恢复&lt;/h4&gt;
&lt;p&gt;旧主节点恢复后，Sentinel 会将它配置为新主节点的从节点：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;旧 Master 恢复
-&amp;gt; REPLICAOF 新 Master
-&amp;gt; 同步新 Master 数据
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;风险和保护&lt;/h4&gt;
&lt;p&gt;Redis 复制默认异步，主节点已经返回成功、数据尚未同步到副本时发生故障转移，这部分写入可能丢失。&lt;/p&gt;
&lt;p&gt;网络分区时，旧主节点一侧的客户端可能继续写入旧主节点；网络恢复后，旧主节点会变为新主节点的副本，旧主上的这些写入会被覆盖。可以配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;min-replicas-to-write
min-replicas-max-lag
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当主节点无法与足够数量的副本保持同步时，拒绝新的写请求，缩小旧主继续写入的时间窗口。&lt;/p&gt;
&lt;p&gt;总结：Sentinel 通过多哨兵监控实现 SDOWN、ODOWN、Leader 选举和自动主从切换，适合单主多从 Redis 的高可用；容量和 QPS 横向扩展使用 Redis Cluster。&lt;/p&gt;
&lt;h3&gt;请说明 Raft 协议。&lt;/h3&gt;
&lt;p&gt;来源：京东面试二（待补充）&lt;/p&gt;
&lt;h2&gt;MySQL&lt;/h2&gt;
&lt;h3&gt;如何理解事务？ACID 特性是什么？（重复 2 次）&lt;/h3&gt;
&lt;p&gt;来源：京东面试:713（&lt;code&gt;京东面试/京东面试.md&lt;/code&gt;）；美团2:628（&lt;code&gt;美团2/美团2.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;什么是事务&lt;/p&gt;
&lt;p&gt;事务可以理解成一组数据库操作的执行单元。&lt;/p&gt;
&lt;p&gt;这一组操作要么全部成功，要么全部失败。&lt;/p&gt;
&lt;p&gt;比如转账场景：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A 账户扣 100 元
B 账户加 100 元
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这两个操作必须放在同一个事务里。如果 A 扣款成功，但 B 加款失败，数据就不一致了。所以事务要保证这两个操作一起成功，或者一起回滚。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;ACID 是什么&lt;/p&gt;
&lt;p&gt;事务有 4 个核心特性，也就是 ACID：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A：Atomicity，原子性
C：Consistency，一致性
I：Isolation，隔离性
D：Durability，持久性
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;原子性&lt;/p&gt;
&lt;p&gt;原子性指一个事务中的多个操作，要么全部成功，要么全部失败。&lt;/p&gt;
&lt;p&gt;如果中间任何一步失败，已经执行的操作要回滚。&lt;/p&gt;
&lt;p&gt;在 MySQL 中，原子性主要依赖 &lt;code&gt;undo log&lt;/code&gt; 实现。事务回滚时，可以通过 &lt;code&gt;undo log&lt;/code&gt; 把数据恢复到修改前的状态。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;一致性&lt;/p&gt;
&lt;p&gt;一致性指事务执行前后，数据库都要处于正确的业务状态。&lt;/p&gt;
&lt;p&gt;比如转账前 A 和 B 总金额是 &lt;code&gt;1000&lt;/code&gt; 元，转账后总金额仍然应该是 &lt;code&gt;1000&lt;/code&gt; 元，不能凭空多钱或少钱。&lt;/p&gt;
&lt;p&gt;一致性是事务最终要达到的目标，它依赖原子性、隔离性、持久性，以及业务约束共同保证。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;隔离性&lt;/p&gt;
&lt;p&gt;隔离性指多个事务并发执行时，事务之间不能互相干扰到不该看到的数据。&lt;/p&gt;
&lt;p&gt;比如一个事务还没有提交的数据，其他事务一般不应该随便读到。&lt;/p&gt;
&lt;p&gt;MySQL 通过锁和 MVCC 来保证隔离性。&lt;/p&gt;
&lt;p&gt;不同隔离级别对并发问题的控制不同，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;读未提交。&lt;/li&gt;
&lt;li&gt;读已提交。&lt;/li&gt;
&lt;li&gt;可重复读。&lt;/li&gt;
&lt;li&gt;串行化。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;持久性&lt;/p&gt;
&lt;p&gt;持久性指事务一旦提交，修改结果就要永久保存下来。&lt;/p&gt;
&lt;p&gt;即使数据库宕机，重启后也应该能恢复已经提交的数据。&lt;/p&gt;
&lt;p&gt;在 MySQL 中，持久性主要依赖 &lt;code&gt;redo log&lt;/code&gt; 实现。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;总结&lt;/p&gt;
&lt;p&gt;事务就是保证一组数据库操作作为一个整体执行。ACID 中，原子性保证全部成功或全部失败，一致性保证数据符合业务规则，隔离性保证并发事务之间互不干扰，持久性保证事务提交后数据不会丢失。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;建立索引需要考虑什么？&lt;/h3&gt;
&lt;p&gt;来源：京东面试:786（&lt;code&gt;京东面试/京东面试.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;查询条件&lt;/p&gt;
&lt;p&gt;建索引首先要看 SQL 的查询条件。&lt;/p&gt;
&lt;p&gt;经常出现在 &lt;code&gt;WHERE&lt;/code&gt;、&lt;code&gt;JOIN ON&lt;/code&gt;、&lt;code&gt;ORDER BY&lt;/code&gt;、&lt;code&gt;GROUP BY&lt;/code&gt; 里的字段，可以考虑建立索引。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where phone = &apos;13800000000&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果 &lt;code&gt;phone&lt;/code&gt; 经常作为查询条件，就可以考虑给 &lt;code&gt;phone&lt;/code&gt; 建索引。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;字段区分度&lt;/p&gt;
&lt;p&gt;索引字段要有一定区分度。&lt;/p&gt;
&lt;p&gt;区分度越高，索引过滤效果越好。&lt;/p&gt;
&lt;p&gt;比如手机号、订单号、用户 ID 区分度比较高，适合建索引。&lt;/p&gt;
&lt;p&gt;性别、状态这种字段取值很少，区分度低，单独建索引效果通常不好。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;联合索引顺序&lt;/p&gt;
&lt;p&gt;如果建立联合索引，要考虑字段顺序。&lt;/p&gt;
&lt;p&gt;一般把区分度高、使用频率高、等值查询多的字段放在前面。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;where user_id = ? and status = ? and create_time &amp;gt; ?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以考虑：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(user_id, status, create_time)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同时要遵守最左前缀原则。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;是否能覆盖查询&lt;/p&gt;
&lt;p&gt;如果查询的字段都在索引里，就可以走覆盖索引，减少回表。&lt;/p&gt;
&lt;p&gt;比如有索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;idx_user_status(user_id, status)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SQL 是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select status from order where user_id = ?;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这时候可以直接从索引里拿到 &lt;code&gt;status&lt;/code&gt;，不需要回表。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;写入成本&lt;/p&gt;
&lt;p&gt;索引不是越多越好。&lt;/p&gt;
&lt;p&gt;每次 &lt;code&gt;insert&lt;/code&gt;、&lt;code&gt;update&lt;/code&gt;、&lt;code&gt;delete&lt;/code&gt; 数据时，数据库不仅要改表数据，还要维护索引。&lt;/p&gt;
&lt;p&gt;索引越多，写入成本越高，占用磁盘空间也越多。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;字段长度&lt;/p&gt;
&lt;p&gt;如果字段很长，比如 &lt;code&gt;varchar(500)&lt;/code&gt;，直接建索引可能占用空间较大。&lt;/p&gt;
&lt;p&gt;可以考虑前缀索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;create index idx_name on user(name(20));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是前缀索引不能完全支持排序和覆盖索引，需要结合场景判断。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;避免冗余索引&lt;/p&gt;
&lt;p&gt;如果已经有联合索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(a, b, c)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;通常不需要再单独建：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(a)
(a, b)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为联合索引可以按最左前缀匹配。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;总结&lt;/p&gt;
&lt;p&gt;建立索引要看查询条件、字段区分度、联合索引顺序、是否能覆盖查询，同时也要考虑写入成本、字段长度和冗余索引。索引的目的不是越多越好，而是让高频 SQL 用更小的代价定位到数据。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;索引失效的原因有哪些？&lt;/h3&gt;
&lt;p&gt;来源：京东面试:893（&lt;code&gt;京东面试/京东面试.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;对索引列使用函数&lt;/p&gt;
&lt;p&gt;如果对索引字段使用函数，可能导致索引失效。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where date(create_time) = &apos;2026-07-06&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为索引里保存的是原始字段值，数据库需要对每一行执行函数计算，可能无法直接利用索引。&lt;/p&gt;
&lt;p&gt;可以改成范围查询：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user
where create_time &amp;gt;= &apos;2026-07-06 00:00:00&apos;
  and create_time &amp;lt; &apos;2026-07-07 00:00:00&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;对索引列进行计算&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where age + 1 = 19;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以改成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where age = 18;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;索引列参与计算后，优化器可能无法直接使用索引定位数据。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;隐式类型转换&lt;/p&gt;
&lt;p&gt;如果字段类型和传入参数类型不一致，可能发生隐式类型转换。&lt;/p&gt;
&lt;p&gt;比如 &lt;code&gt;phone&lt;/code&gt; 是 &lt;code&gt;varchar&lt;/code&gt; 类型：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where phone = 13800000000;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MySQL 可能会把字段转换成数字再比较，导致索引失效。&lt;/p&gt;
&lt;p&gt;应该写成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where phone = &apos;13800000000&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;联合索引不满足最左前缀原则&lt;/p&gt;
&lt;p&gt;比如有联合索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;idx_name_age_city(name, age, city)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;下面这个可以用到索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;where name = ? and age = ?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是直接跳过最左边的 &lt;code&gt;name&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;where age = ? and city = ?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;就可能无法充分使用这个联合索引。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;范围查询后面的列可能用不上索引&lt;/p&gt;
&lt;p&gt;比如联合索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;idx_user_status_time(user_id, create_time, status)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SQL：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;where user_id = ?
  and create_time &amp;gt; ?
  and status = ?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;user_id&lt;/code&gt; 和 &lt;code&gt;create_time&lt;/code&gt; 可能用到索引，但 &lt;code&gt;status&lt;/code&gt; 在范围查询后面，可能无法继续利用索引进行精确定位。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;like&lt;/code&gt; 左模糊查询&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where name like &apos;%张&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;左边是 &lt;code&gt;%&lt;/code&gt;，B+ 树无法从左到右匹配，所以索引通常用不上。&lt;/p&gt;
&lt;p&gt;下面这种右模糊一般可以用索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where name like &apos;张%&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用 &lt;code&gt;or&lt;/code&gt; 时条件中有列没有索引&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where phone = ? or address = ?;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果 &lt;code&gt;phone&lt;/code&gt; 有索引，&lt;code&gt;address&lt;/code&gt; 没有索引，优化器可能选择全表扫描。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;不等于、&lt;code&gt;not in&lt;/code&gt;、&lt;code&gt;not like&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;这类条件可能导致索引效果变差，甚至优化器选择不走索引。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;where status != 1
where id not in (1, 2, 3)
where name not like &apos;张%&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为排除的数据范围可能很大，走索引不一定比全表扫描更划算。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;查询返回数据量太大&lt;/p&gt;
&lt;p&gt;即使 SQL 条件里有索引，如果优化器判断返回的数据量太大，回表成本很高，也可能选择全表扫描。&lt;/p&gt;
&lt;p&gt;比如一个状态字段 &lt;code&gt;status&lt;/code&gt;，大部分数据都是 &lt;code&gt;status = 1&lt;/code&gt;，单独给它建索引效果也不好。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;总结&lt;/p&gt;
&lt;p&gt;索引失效常见原因有：对索引列使用函数或计算、隐式类型转换、联合索引不满足最左前缀、范围查询后面的列用不上、&lt;code&gt;like&lt;/code&gt; 左模糊、&lt;code&gt;or&lt;/code&gt; 条件里有字段没索引、不等于类条件、返回数据量太大导致优化器放弃索引。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;&lt;code&gt;WHERE&lt;/code&gt; 语句中字段类型本来是 &lt;code&gt;BIGINT&lt;/code&gt;，与 &lt;code&gt;String&lt;/code&gt; 类型用 &lt;code&gt;=&lt;/code&gt; 号相比会导致索引失效吗？&lt;/h3&gt;
&lt;p&gt;来源：京东面试:1031（&lt;code&gt;京东面试/京东面试.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;一般情况下，&lt;code&gt;BIGINT&lt;/code&gt; 字段和字符串数字比较，不一定会导致索引失效。&lt;/p&gt;
&lt;p&gt;比如字段 &lt;code&gt;id&lt;/code&gt; 是 &lt;code&gt;BIGINT&lt;/code&gt;，并且有索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where id = &apos;1001&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MySQL 会把字符串 &lt;code&gt;&apos;1001&apos;&lt;/code&gt; 转成数字 &lt;code&gt;1001&lt;/code&gt;，再和 &lt;code&gt;id&lt;/code&gt; 比较。&lt;/p&gt;
&lt;p&gt;这个过程叫隐式类型转换。隐式类型转换就是 SQL 两边的数据类型不一致时，MySQL 为了能比较它们，会自动把其中一边转换成另一种类型。&lt;/p&gt;
&lt;p&gt;这种情况下，因为转换发生在右边的常量这一侧，不需要对索引列 &lt;code&gt;id&lt;/code&gt; 做函数或类型转换，所以通常还能使用索引。&lt;/p&gt;
&lt;p&gt;可以理解成优化后类似：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where id = 1001;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是反过来就容易出问题。&lt;/p&gt;
&lt;p&gt;如果字段是 &lt;code&gt;VARCHAR&lt;/code&gt;，传入的是数字：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where phone = 13800000000;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;假设 &lt;code&gt;phone&lt;/code&gt; 是 &lt;code&gt;VARCHAR&lt;/code&gt;，右边是数字。&lt;/p&gt;
&lt;p&gt;MySQL 中字符串和数字比较时，通常会按照数字比较，也就是倾向于把字符串转换成数字。&lt;/p&gt;
&lt;p&gt;所以 MySQL 可能会把 &lt;code&gt;phone&lt;/code&gt; 字段里的每一行值都转成数字再比较。&lt;/p&gt;
&lt;p&gt;可以理解成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where cast(phone as signed) = 13800000000;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这就麻烦了，因为转换发生在字段这一侧。&lt;/p&gt;
&lt;p&gt;索引里存的是原始字符串，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&apos;13800000000&apos;
&apos;13800000001&apos;
&apos;abc123&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;现在 MySQL 要先把字段值转成数字后再判断，可能没法直接按原始字符串索引查找，就容易导致索引失效。&lt;/p&gt;
&lt;p&gt;也可以通过例子理解 MySQL 的比较规则：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select &apos;123&apos; = 123;     -- true
select &apos;123abc&apos; = 123;  -- true
select &apos;abc&apos; = 0;       -- true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这些结果说明，字符串和数字比较时，MySQL 会把字符串按数字规则转换后再比较。&lt;/p&gt;
&lt;p&gt;所以结论是：&lt;/p&gt;
&lt;p&gt;如果字段是 &lt;code&gt;BIGINT&lt;/code&gt;，参数是字符串数字，比如 &lt;code&gt;id = &apos;1001&apos;&lt;/code&gt;，通常还能走索引，因为 MySQL 会把常量转成数字。&lt;/p&gt;
&lt;p&gt;如果字段是 &lt;code&gt;VARCHAR&lt;/code&gt;，参数是数字，比如 &lt;code&gt;phone = 13800000000&lt;/code&gt;，更容易导致索引失效，因为可能会对字段做隐式类型转换。&lt;/p&gt;
&lt;p&gt;实际开发中，最好让参数类型和字段类型保持一致。&lt;code&gt;BIGINT&lt;/code&gt; 字段就传数字类型，&lt;code&gt;VARCHAR&lt;/code&gt; 字段就传字符串类型，这样最稳。&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;CHAR&lt;/code&gt; 和 &lt;code&gt;VARCHAR&lt;/code&gt; 的区别是什么？如何使用？&lt;/h3&gt;
&lt;p&gt;来源：京东面试:1105（&lt;code&gt;京东面试/京东面试.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;存储方式不同&lt;/p&gt;
&lt;p&gt;&lt;code&gt;CHAR&lt;/code&gt; 是定长字符串。&lt;/p&gt;
&lt;p&gt;比如定义：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;name char(10)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不管实际存的是 &lt;code&gt;&quot;abc&quot;&lt;/code&gt; 还是 &lt;code&gt;&quot;abcdef&quot;&lt;/code&gt;，都会按照固定长度 &lt;code&gt;10&lt;/code&gt; 来处理，不足的部分会用空格补齐。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;VARCHAR&lt;/code&gt; 是变长字符串。&lt;/p&gt;
&lt;p&gt;比如定义：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;name varchar(10)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果实际存的是 &lt;code&gt;&quot;abc&quot;&lt;/code&gt;，它只会按照实际长度存储，再额外用 1 到 2 个字节记录长度。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;空间占用不同&lt;/p&gt;
&lt;p&gt;&lt;code&gt;CHAR&lt;/code&gt; 长度固定，可能会浪费空间。&lt;/p&gt;
&lt;p&gt;比如手机号、身份证号、性别编码这种长度比较固定的字段，可以考虑用 &lt;code&gt;CHAR&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;VARCHAR&lt;/code&gt; 按实际长度存储，更节省空间。&lt;/p&gt;
&lt;p&gt;比如用户名、邮箱、地址、备注这类长度不固定的字段，更适合用 &lt;code&gt;VARCHAR&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;查询和更新效率&lt;/p&gt;
&lt;p&gt;&lt;code&gt;CHAR&lt;/code&gt; 因为长度固定，存取时处理相对简单。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;VARCHAR&lt;/code&gt; 长度可变，更新时如果长度变化较大，可能会带来额外的存储调整成本。&lt;/p&gt;
&lt;p&gt;不过在实际业务中，更多还是根据字段长度是否固定来选择。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用场景&lt;/p&gt;
&lt;p&gt;如果字段长度固定，可以用 &lt;code&gt;CHAR&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;gender char(1)
country_code char(2)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果字段长度不固定，优先用 &lt;code&gt;VARCHAR&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;username varchar(64)
email varchar(128)
address varchar(255)
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;总结&lt;/p&gt;
&lt;p&gt;&lt;code&gt;CHAR&lt;/code&gt; 是定长字符串，适合长度固定的字段；&lt;code&gt;VARCHAR&lt;/code&gt; 是变长字符串，适合长度变化较大的字段。实际开发中，用户昵称、邮箱、地址这类字段通常用 &lt;code&gt;VARCHAR&lt;/code&gt;，固定编码类字段可以考虑用 &lt;code&gt;CHAR&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;请说明 MySQL 索引的数据结构（重复 2 次）&lt;/h3&gt;
&lt;p&gt;来源：携程二面:452（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）；美团:19（&lt;code&gt;美团/美团.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;题目变体：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;请说明 MySQL 索引的数据结构。&lt;/li&gt;
&lt;li&gt;为什么 &lt;code&gt;InnoDB&lt;/code&gt; 选择 B+ 树作为索引？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;MySQL 里不同存储引擎索引结构不完全一样。一般默认说的是 InnoDB。InnoDB 的主流索引结构是 B+ 树。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;为什么用 B+ 树&lt;/p&gt;
&lt;p&gt;数据库索引通常存储在磁盘上，查询时要尽量减少磁盘 IO。&lt;/p&gt;
&lt;p&gt;B+ 树是一种多路平衡树，每个节点可以存很多 key，所以树的高度比较低。高度低意味着从根节点查到叶子节点需要的磁盘 IO 次数少。&lt;/p&gt;
&lt;p&gt;比如一棵 B+ 树通常 3 到 4 层就可以存很多数据，查询时只需要几次 IO 就能定位到目标数据。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;B+ 树结构&lt;/p&gt;
&lt;p&gt;B+ 树有几个特点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;非叶子节点只存索引 key 和子节点指针，不存完整数据。&lt;/li&gt;
&lt;li&gt;叶子节点存完整索引项。&lt;/li&gt;
&lt;li&gt;所有数据都在叶子节点。&lt;/li&gt;
&lt;li&gt;叶子节点之间通过双向链表连接。&lt;/li&gt;
&lt;li&gt;树整体保持有序和平衡。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样做的好处是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;单个非叶子节点能放更多 key，树更矮，IO 更少。&lt;/li&gt;
&lt;li&gt;叶子节点有序，适合范围查询。&lt;/li&gt;
&lt;li&gt;查询性能稳定，从根到叶子的路径长度基本一致。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;聚簇索引&lt;/p&gt;
&lt;p&gt;InnoDB 的主键索引是聚簇索引。&lt;/p&gt;
&lt;p&gt;聚簇索引的叶子节点存的是整行数据。&lt;/p&gt;
&lt;p&gt;所以通过主键查询时，可以直接从主键 B+ 树的叶子节点拿到完整数据。&lt;/p&gt;
&lt;p&gt;如果表有主键，InnoDB 会用主键作为聚簇索引；如果没有主键，会选择一个唯一非空索引；如果还没有，就生成隐藏的 row_id 作为聚簇索引。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;二级索引&lt;/p&gt;
&lt;p&gt;除了主键索引以外，其他普通索引、唯一索引都属于二级索引，也叫辅助索引。&lt;/p&gt;
&lt;p&gt;二级索引的叶子节点存的不是整行数据，而是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;索引列值 + 主键值
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以通过二级索引查询时，一般会先在二级索引 B+ 树上找到对应主键，再拿主键去聚簇索引里查整行数据，这个过程叫回表。&lt;/p&gt;
&lt;p&gt;如果查询字段都在二级索引里，不需要回表，这种情况叫覆盖索引。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;InnoDB 的索引主要基于 B+ 树。B+ 树是多路平衡树，非叶子节点只存 key 和指针，叶子节点存完整索引项，并且叶子节点之间用双向链表连接，所以既能降低树高、减少磁盘 IO，也适合范围查询。InnoDB 的主键索引是聚簇索引，叶子节点存整行数据；二级索引叶子节点存索引列和主键值，通过二级索引查整行时通常需要回表。如果查询字段都在索引里，就可以走覆盖索引避免回表。&lt;/p&gt;
&lt;h3&gt;MySQL 索引的类型有哪些？哪种使用最多？&lt;/h3&gt;
&lt;p&gt;来源：美团2:602（&lt;code&gt;美团2/美团2.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;MySQL 索引可从业务约束和 InnoDB 存储结构两个维度回答。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;业务约束类型：主键索引 &lt;code&gt;PRIMARY KEY&lt;/code&gt;、唯一索引 &lt;code&gt;UNIQUE&lt;/code&gt;、普通索引 &lt;code&gt;INDEX / KEY&lt;/code&gt;、联合索引 &lt;code&gt;INDEX(a, b, c)&lt;/code&gt;、全文索引 &lt;code&gt;FULLTEXT&lt;/code&gt;、空间索引 &lt;code&gt;SPATIAL&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;InnoDB 存储结构：主键通常是聚簇索引，叶子节点保存整行数据，一张表只有一个；普通、唯一、联合索引通常是二级索引，叶子节点保存索引列和主键值，查整行数据时可能回表，索引覆盖查询可避免回表。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;底层结构：InnoDB 的常规索引主要是 B+ 树，支持等值、范围、排序和最左前缀匹配。Hash 索引主要由 Memory 引擎支持，InnoDB 自适应 Hash 索引由内部自动维护；全文索引使用倒排结构，空间索引使用 R 树。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;实际开发最常用的是主键索引、普通二级索引和联合二级索引，底层绝大多数是 B+ 树。唯一索引用于业务唯一性约束，全文和空间索引用于特定场景。&lt;/p&gt;
&lt;h3&gt;介绍脏读、不可重复读、幻读&lt;/h3&gt;
&lt;p&gt;来源：美团2:670（&lt;code&gt;美团2/美团2.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;脏读、不可重复读和幻读都是并发事务下的读一致性问题。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;脏读：一个事务读到另一个事务未提交的数据；后者回滚后，前者读到的数据无效。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;不可重复读：同一事务多次查询同一行，其他事务修改并提交后，前后结果不同。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;幻读：同一事务多次按相同范围条件查询，其他事务插入或删除符合条件的行并提交后，结果集合发生变化。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;脏读：读到未提交数据
不可重复读：同一行数据被修改
幻读：范围查询的结果集合发生变化
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;隔离级别&lt;/th&gt;
&lt;th&gt;脏读&lt;/th&gt;
&lt;th&gt;不可重复读&lt;/th&gt;
&lt;th&gt;幻读&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;READ UNCOMMITTED&lt;/td&gt;
&lt;td&gt;可能&lt;/td&gt;
&lt;td&gt;可能&lt;/td&gt;
&lt;td&gt;可能&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;READ COMMITTED&lt;/td&gt;
&lt;td&gt;避免&lt;/td&gt;
&lt;td&gt;可能&lt;/td&gt;
&lt;td&gt;可能&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;REPEATABLE READ&lt;/td&gt;
&lt;td&gt;避免&lt;/td&gt;
&lt;td&gt;避免&lt;/td&gt;
&lt;td&gt;SQL 标准下仍可能&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SERIALIZABLE&lt;/td&gt;
&lt;td&gt;避免&lt;/td&gt;
&lt;td&gt;避免&lt;/td&gt;
&lt;td&gt;避免&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;InnoDB 默认 &lt;code&gt;REPEATABLE READ&lt;/code&gt;。普通 &lt;code&gt;SELECT&lt;/code&gt; 使用 MVCC 一致性读，同一事务读取同一个 Read View；范围当前读通过 Gap Lock 和 Next-Key Lock 阻止范围内插入，从而处理幻读。&lt;/p&gt;
&lt;h3&gt;千万级数据表 CRUD 效率低，如何优化？&lt;/h3&gt;
&lt;p&gt;来源：美团2:707（&lt;code&gt;美团2/美团2.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;千万级数据量不必然需要分库分表。优化应先定位瓶颈，再按 SQL、索引、写入、表结构和架构分层处理。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;使用慢 SQL、APM、&lt;code&gt;EXPLAIN&lt;/code&gt;、锁等待、长事务、Buffer Pool、IO、CPU 和连接池指标定位问题；重点关注 &lt;code&gt;type&lt;/code&gt;、&lt;code&gt;key&lt;/code&gt;、&lt;code&gt;rows&lt;/code&gt;、&lt;code&gt;Extra&lt;/code&gt;，以及 &lt;code&gt;Using filesort&lt;/code&gt;、&lt;code&gt;Using temporary&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;根据真实查询条件设计联合索引，遵循最左前缀，优先高区分度和过滤性强的列，使用覆盖索引，避免索引列函数计算、隐式类型转换和冗余索引。索引会增加写入成本。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;避免 &lt;code&gt;SELECT *&lt;/code&gt;、大范围 Join、N+1 查询和索引列函数。深分页使用主键或时间游标，例如 &lt;code&gt;WHERE id &amp;gt; ? ORDER BY id LIMIT 20&lt;/code&gt;，避免大 Offset。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;写入使用批量 &lt;code&gt;INSERT&lt;/code&gt;、控制批量和事务大小、减少无用二级索引、使用趋势递增主键、通过主键或唯一索引精确更新。热点库存和计数器可用 Redis 预扣、分段计数和 MQ 削峰降低锁竞争。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;大字段拆分、历史归档、按时间分区或分表、冷热分离；读多写少时使用 Redis、读写分离和连接池。强一致读主库或绕过缓存，避免主从延迟。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;单机数据量、写入 TPS、IO、CPU 或连接数达到上限时，再进行垂直拆分、水平分表和分库到多个 MySQL 实例。分库分表会引入跨库查询、全局 ID、分布式事务和扩容迁移复杂度。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：先从执行计划和 SQL 入手，完成索引、分页、事务和锁竞争优化，再做缓存、读写分离、归档和冷热分离，最后才考虑分库分表。&lt;/p&gt;
&lt;h3&gt;请说明 InnoDB 里面有哪些锁，以及每个锁的作用&lt;/h3&gt;
&lt;p&gt;来源：携程二面:505（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;InnoDB 里的锁可以从几个维度理解：锁粒度、锁模式和加锁算法。常见的有表锁、行锁、共享锁、排他锁、意向锁、记录锁、间隙锁和临键锁。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;表锁&lt;/p&gt;
&lt;p&gt;表锁是对整张表加锁。&lt;/p&gt;
&lt;p&gt;它的粒度比较大，并发能力较差，但加锁和释放锁的开销比较小。&lt;/p&gt;
&lt;p&gt;在 InnoDB 中，普通业务操作更多使用行锁，表锁一般出现在 DDL、显式锁表或者某些特殊场景里。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;行锁&lt;/p&gt;
&lt;p&gt;行锁是对某一行记录加锁，是 InnoDB 支持高并发的重要基础。&lt;/p&gt;
&lt;p&gt;InnoDB 的行锁是基于索引实现的。也就是说，锁通常加在索引记录上。如果查询条件没有命中索引，可能会扫描更多记录，导致加锁范围变大。&lt;/p&gt;
&lt;p&gt;行锁适合高并发更新场景，因为不同事务操作不同行时可以并发执行。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;共享锁（S 锁）&lt;/p&gt;
&lt;p&gt;共享锁也叫读锁。&lt;/p&gt;
&lt;p&gt;一个事务对某行加共享锁后，其他事务也可以继续对这行加共享锁，也就是多个事务可以同时读。&lt;/p&gt;
&lt;p&gt;但如果某行已经有共享锁，其他事务不能对这行加排他锁进行修改。&lt;/p&gt;
&lt;p&gt;常见用法是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT ... LOCK IN SHARE MODE;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;排他锁（X 锁）&lt;/p&gt;
&lt;p&gt;排他锁也叫写锁。&lt;/p&gt;
&lt;p&gt;一个事务对某行加排他锁后，其他事务不能再对这行加共享锁或排他锁。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;UPDATE&lt;/code&gt;、&lt;code&gt;DELETE&lt;/code&gt;、&lt;code&gt;INSERT&lt;/code&gt; 通常会涉及排他锁。&lt;/p&gt;
&lt;p&gt;常见显式加锁方式是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT ... FOR UPDATE;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;意向锁&lt;/p&gt;
&lt;p&gt;意向锁是表级锁，用来表示某个事务接下来想在表中的某些行上加什么类型的行锁。&lt;/p&gt;
&lt;p&gt;常见有两种：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;意向共享锁（IS）：表示事务准备对某些行加共享锁。&lt;/li&gt;
&lt;li&gt;意向排他锁（IX）：表示事务准备对某些行加排他锁。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;意向锁的作用是提高表锁和行锁之间的冲突判断效率。&lt;/p&gt;
&lt;p&gt;比如一个事务要给整张表加表级排他锁时，不需要逐行检查有没有行锁，只要看表上是否存在意向锁即可。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;记录锁（Record Lock）&lt;/p&gt;
&lt;p&gt;记录锁是锁住某一条具体的索引记录。&lt;/p&gt;
&lt;p&gt;比如通过唯一索引等值查询命中一条记录，并对它执行 &lt;code&gt;FOR UPDATE&lt;/code&gt;，通常会对这条索引记录加记录锁。&lt;/p&gt;
&lt;p&gt;记录锁主要防止其他事务修改或删除这条记录。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;间隙锁（Gap Lock）&lt;/p&gt;
&lt;p&gt;间隙锁锁住的是两个索引记录之间的间隙，不锁具体记录。&lt;/p&gt;
&lt;p&gt;它的作用是防止其他事务在这个范围内插入新记录，从而避免幻读。&lt;/p&gt;
&lt;p&gt;比如表里已有索引值 10 和 20，事务锁住 &lt;code&gt;(10, 20)&lt;/code&gt; 这个间隙后，其他事务就不能往这个范围内插入 15。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;临键锁（Next-Key Lock）&lt;/p&gt;
&lt;p&gt;临键锁可以理解为记录锁加间隙锁。&lt;/p&gt;
&lt;p&gt;它锁住的是一个左开右闭的范围，比如 &lt;code&gt;(10, 20]&lt;/code&gt;，既锁住 20 这条记录，也锁住 10 到 20 之间的间隙。&lt;/p&gt;
&lt;p&gt;在可重复读隔离级别下，InnoDB 默认通过临键锁来解决当前读场景下的幻读问题。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;整体来说，InnoDB 通过行锁提高并发能力，通过共享锁和排他锁控制读写互斥，通过意向锁协调表锁和行锁，通过记录锁、间隙锁、临键锁控制具体记录和范围，保证事务隔离性。&lt;/p&gt;
&lt;h3&gt;请说明 MVCC&lt;/h3&gt;
&lt;p&gt;来源：携程二面:591（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;MVCC 全称是 Multi-Version Concurrency Control，多版本并发控制。它的核心思想是：同一行数据可以存在多个历史版本，读操作通过读取合适的历史版本来实现一致性读，从而减少读写之间的阻塞。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;MVCC 解决什么问题&lt;/p&gt;
&lt;p&gt;在并发事务里，如果读和写都完全依赖锁，读写之间会互相阻塞，并发性能会比较差。&lt;/p&gt;
&lt;p&gt;MVCC 通过版本链和 ReadView，让普通 &lt;code&gt;SELECT&lt;/code&gt; 可以不加锁读取快照数据。&lt;/p&gt;
&lt;p&gt;这样读操作不会阻塞写操作，写操作也不会阻塞普通快照读，提高了并发能力。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;MVCC 的核心组成&lt;/p&gt;
&lt;p&gt;InnoDB 的 MVCC 主要依赖三个东西：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;隐藏字段&lt;/li&gt;
&lt;li&gt;undo log&lt;/li&gt;
&lt;li&gt;ReadView&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;隐藏字段&lt;/p&gt;
&lt;p&gt;InnoDB 每行记录里会有一些隐藏字段，和 MVCC 相关的主要有两个：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;trx_id&lt;/code&gt;：记录最后一次修改这行数据的事务 ID。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;roll_pointer&lt;/code&gt;：回滚指针，指向这行记录的上一个历史版本，也就是对应的 undo log。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;每次事务修改一行数据时，都会生成新的版本，并通过 &lt;code&gt;roll_pointer&lt;/code&gt; 把历史版本串起来，形成版本链。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;undo log&lt;/p&gt;
&lt;p&gt;undo log 保存的是数据被修改前的旧版本。&lt;/p&gt;
&lt;p&gt;比如一行数据从 A 改成 B，当前记录里是 B，undo log 里会保存 A。&lt;/p&gt;
&lt;p&gt;如果又从 B 改成 C，当前记录是 C，undo log 里会通过回滚指针继续保存 B、A 这些历史版本。&lt;/p&gt;
&lt;p&gt;所以 undo log 不只是用来事务回滚，也支撑了 MVCC 的历史版本读取。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;ReadView&lt;/p&gt;
&lt;p&gt;ReadView 可以理解为事务做快照读时生成的一个“可见性视图”。&lt;/p&gt;
&lt;p&gt;它里面会记录当前系统中活跃事务的情况，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当前活跃事务 ID 列表。&lt;/li&gt;
&lt;li&gt;最小活跃事务 ID。&lt;/li&gt;
&lt;li&gt;下一个将要分配的事务 ID。&lt;/li&gt;
&lt;li&gt;当前事务自己的 ID。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;普通 &lt;code&gt;SELECT&lt;/code&gt; 做快照读时，会根据 ReadView 判断某个版本对当前事务是否可见。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;可见性判断&lt;/p&gt;
&lt;p&gt;简单来说，InnoDB 会从当前记录开始，沿着版本链往旧版本找，直到找到一个对当前 ReadView 可见的版本。&lt;/p&gt;
&lt;p&gt;判断规则可以简化理解为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果某个版本的 &lt;code&gt;trx_id&lt;/code&gt; 是当前事务自己生成的，可以看到。&lt;/li&gt;
&lt;li&gt;如果某个版本的 &lt;code&gt;trx_id&lt;/code&gt; 小于最小活跃事务 ID，说明这个版本在快照创建前已经提交，可以看到。&lt;/li&gt;
&lt;li&gt;如果某个版本的 &lt;code&gt;trx_id&lt;/code&gt; 大于等于下一个将要分配的事务 ID，说明这个版本在快照创建后才产生，看不到。&lt;/li&gt;
&lt;li&gt;如果某个版本的 &lt;code&gt;trx_id&lt;/code&gt; 在活跃事务列表里，说明创建这个版本的事务当时还没提交，看不到。&lt;/li&gt;
&lt;li&gt;其他情况一般说明事务已经提交，可以看到。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;不同隔离级别下的区别&lt;/p&gt;
&lt;p&gt;在读已提交（RC）隔离级别下，每次普通 &lt;code&gt;SELECT&lt;/code&gt; 都会生成新的 ReadView。&lt;/p&gt;
&lt;p&gt;所以同一个事务里，两次查询可能看到其他事务刚提交的数据。&lt;/p&gt;
&lt;p&gt;在可重复读（RR）隔离级别下，事务第一次普通 &lt;code&gt;SELECT&lt;/code&gt; 时生成 ReadView，后续快照读复用这个 ReadView。&lt;/p&gt;
&lt;p&gt;所以同一个事务里，多次查询看到的结果是一致的。&lt;/p&gt;
&lt;p&gt;这也是 MySQL InnoDB 可重复读能实现“可重复读”的重要原因。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;快照读和当前读&lt;/p&gt;
&lt;p&gt;MVCC 主要作用在快照读上，也就是普通 &lt;code&gt;SELECT&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;当前读会读取最新数据，并且通常会加锁，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT ... FOR UPDATE;
SELECT ... LOCK IN SHARE MODE;
UPDATE;
DELETE;
INSERT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当前读要保证读到的是最新版本，所以不会像普通快照读那样直接读历史版本。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;MVCC 是 InnoDB 实现高并发一致性读的重要机制。它通过隐藏字段 &lt;code&gt;trx_id&lt;/code&gt; 和 &lt;code&gt;roll_pointer&lt;/code&gt; 维护版本链，通过 undo log 保存历史版本，通过 ReadView 判断版本是否可见。普通 &lt;code&gt;SELECT&lt;/code&gt; 可以基于 ReadView 读取历史快照，从而做到读写不互相阻塞。在 RC 下每次查询生成新的 ReadView，在 RR 下第一次快照读生成 ReadView 后复用，所以 RR 下同一个事务多次查询结果保持一致。&lt;/p&gt;
&lt;h3&gt;MySQL 有哪些日志？undo log 如何保证事务原子性？（重复 2 次）&lt;/h3&gt;
&lt;p&gt;来源：携程二面:684（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）；美团3:196（&lt;code&gt;美团3/美团3.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;MySQL 面试中最重要的三类日志是：&lt;code&gt;undo log&lt;/code&gt;、&lt;code&gt;redo log&lt;/code&gt; 和 &lt;code&gt;binlog&lt;/code&gt;。undo log 用于事务回滚和 MVCC；redo log 用于崩溃恢复，保证事务持久性；binlog 用于主从复制和数据恢复。redo log 和 binlog 在事务提交时还会通过两阶段提交保持一致。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;undo log&lt;/p&gt;
&lt;p&gt;undo log 保存数据修改前的历史版本或反向操作。例如余额从 &lt;code&gt;1000&lt;/code&gt; 更新为 &lt;code&gt;500&lt;/code&gt; 后事务失败，InnoDB 可依据 undo log 将已执行的修改按相反顺序撤销，使数据回到事务开始前状态，从而保证原子性。&lt;/p&gt;
&lt;p&gt;undo log 同时为 MVCC 提供历史版本，配合记录中的 &lt;code&gt;roll_pointer&lt;/code&gt; 形成版本链。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;redo log&lt;/p&gt;
&lt;p&gt;redo log 是 InnoDB 存储引擎层的日志。&lt;/p&gt;
&lt;p&gt;它主要解决的问题是：事务提交后，数据还没来得及真正刷到磁盘时，如果 MySQL 宕机，重启后怎么恢复已提交的数据。&lt;/p&gt;
&lt;p&gt;InnoDB 更新数据时，会先修改 Buffer Pool 里的数据页，同时写 redo log。数据页可以延迟刷盘，但 redo log 会按照事务提交要求刷盘。&lt;/p&gt;
&lt;p&gt;如果 MySQL 崩溃，重启时就可以根据 redo log，把已经提交但还没写入数据文件的修改恢复出来。&lt;/p&gt;
&lt;p&gt;所以 redo log 主要保证事务的持久性，也就是 ACID 里的 Durability。&lt;/p&gt;
&lt;p&gt;redo log 记录的是物理层面的修改，比如某个数据页的某个位置做了什么变更。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;binlog&lt;/p&gt;
&lt;p&gt;binlog 是 MySQL Server 层的日志，和具体存储引擎无关。&lt;/p&gt;
&lt;p&gt;它主要用于主从复制和数据恢复。&lt;/p&gt;
&lt;p&gt;主从复制时，主库会把自己的 binlog 发送给从库，从库重放这些日志来同步数据。&lt;/p&gt;
&lt;p&gt;做数据恢复时，也可以先恢复一份全量备份，再通过 binlog 回放某个时间点之后的增量变更。&lt;/p&gt;
&lt;p&gt;binlog 记录的是逻辑层面的变更，比如执行了什么 SQL，或者哪些行发生了变化。&lt;/p&gt;
&lt;p&gt;binlog 常见有三种格式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Statement：记录原始 SQL。&lt;/li&gt;
&lt;li&gt;Row：记录每一行数据的变更。&lt;/li&gt;
&lt;li&gt;Mixed：前两种混合。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;redo log 和 binlog 的区别&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;层级不同：redo log 是 InnoDB 引擎层日志，binlog 是 MySQL Server 层日志。&lt;/li&gt;
&lt;li&gt;作用不同：redo log 用于崩溃恢复，binlog 用于主从复制和数据恢复。&lt;/li&gt;
&lt;li&gt;内容不同：redo log 是物理日志，记录数据页的修改；binlog 是逻辑日志，记录 SQL 或行变更。&lt;/li&gt;
&lt;li&gt;写入方式不同：redo log 是循环写，空间固定；binlog 是追加写，会不断生成新的日志文件。&lt;/li&gt;
&lt;li&gt;覆盖范围不同：redo log 只属于 InnoDB，binlog 对所有存储引擎都可以生效。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;为什么需要两阶段提交&lt;/p&gt;
&lt;p&gt;一次事务提交时，redo log 和 binlog 都要写。&lt;/p&gt;
&lt;p&gt;如果只写了 redo log，没有写 binlog，那么主库崩溃恢复后数据存在，但从库或基于 binlog 的恢复里没有这次变更。&lt;/p&gt;
&lt;p&gt;如果只写了 binlog，没有提交 redo log，那么从库可能同步了这次变更，但主库崩溃恢复后没有这次数据。&lt;/p&gt;
&lt;p&gt;所以 MySQL 通过两阶段提交保证两者一致：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;redo log 写入 prepare 状态
-&amp;gt; 写 binlog
-&amp;gt; redo log 写入 commit 状态
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;崩溃恢复时，MySQL 会根据 redo log 和 binlog 的状态判断事务是提交还是回滚。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;redo log 是 InnoDB 层的物理日志，用于崩溃恢复，保证事务提交后的数据不会丢；binlog 是 Server 层的逻辑日志，用于主从复制和数据恢复。redo log 是循环写，binlog 是追加写。事务提交时两者都要写，所以 MySQL 使用两阶段提交，先写 redo log prepare，再写 binlog，最后提交 redo log，保证主库崩溃恢复和 binlog 复制的数据一致。&lt;/p&gt;
&lt;h3&gt;我们怎么样去分析一条 SQL 的性能？&lt;/h3&gt;
&lt;p&gt;来源：携程二面:747（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;分析一条 SQL 的性能，一般可以分成线上定位、执行计划分析、索引分析、执行过程分析、业务数据量分析几步。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;先定位是哪条 SQL 慢&lt;/p&gt;
&lt;p&gt;线上不一定会长期全量开启慢查询日志，因为如果阈值设置太低、SQL 量很大，会带来日志 IO 和存储压力。&lt;/p&gt;
&lt;p&gt;所以一般会先从监控和链路追踪定位，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;接口 RT 是否升高。&lt;/li&gt;
&lt;li&gt;数据库 CPU、IO、连接数是否异常。&lt;/li&gt;
&lt;li&gt;APM 或链路追踪里哪条 SQL 耗时高。&lt;/li&gt;
&lt;li&gt;数据库平台统计的慢 SQL、Top SQL、扫描行数、执行次数。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;慢查询日志可以作为辅助手段，比如设置合理的 &lt;code&gt;long_query_time&lt;/code&gt;，或者在排查阶段临时开启，排查完后关闭或调高阈值。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;用 EXPLAIN 看执行计划&lt;/p&gt;
&lt;p&gt;定位到 SQL 后，用 &lt;code&gt;EXPLAIN&lt;/code&gt; 看执行计划。&lt;/p&gt;
&lt;p&gt;重点看几个字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type&lt;/code&gt;：访问类型，最好能达到 &lt;code&gt;ref&lt;/code&gt;、&lt;code&gt;range&lt;/code&gt;、&lt;code&gt;const&lt;/code&gt; 这类，尽量避免 &lt;code&gt;ALL&lt;/code&gt; 全表扫描。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;possible_keys&lt;/code&gt;：理论上可能使用的索引。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;key&lt;/code&gt;：实际使用的索引。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rows&lt;/code&gt;：优化器预估要扫描的行数。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;filtered&lt;/code&gt;：过滤比例。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Extra&lt;/code&gt;：是否出现 &lt;code&gt;Using filesort&lt;/code&gt;、&lt;code&gt;Using temporary&lt;/code&gt;、&lt;code&gt;Using index&lt;/code&gt; 等。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果 &lt;code&gt;key&lt;/code&gt; 为空，或者 &lt;code&gt;type=ALL&lt;/code&gt;，通常要重点看索引是否合理。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;分析索引是否合理&lt;/p&gt;
&lt;p&gt;主要看几个方向：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;WHERE&lt;/code&gt; 条件字段是否有索引。&lt;/li&gt;
&lt;li&gt;联合索引是否符合最左前缀原则。&lt;/li&gt;
&lt;li&gt;索引列上是否做了函数、表达式计算或隐式类型转换。&lt;/li&gt;
&lt;li&gt;排序、分组字段是否能利用索引。&lt;/li&gt;
&lt;li&gt;是否存在索引区分度太低，导致优化器不用索引。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;分析是否回表、排序、临时表&lt;/p&gt;
&lt;p&gt;如果 SQL 走二级索引，但查询字段很多，可能会产生大量回表。&lt;/p&gt;
&lt;p&gt;可以考虑只查必要字段，避免 &lt;code&gt;SELECT *&lt;/code&gt;，或者用覆盖索引减少回表。&lt;/p&gt;
&lt;p&gt;如果 &lt;code&gt;Extra&lt;/code&gt; 里出现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Using filesort
Using temporary
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;说明可能发生额外排序或临时表。常见于 &lt;code&gt;ORDER BY&lt;/code&gt;、&lt;code&gt;GROUP BY&lt;/code&gt;、&lt;code&gt;DISTINCT&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这时可以考虑调整联合索引，让过滤字段、排序字段更好地匹配索引顺序。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;分析数据量和分页方式&lt;/p&gt;
&lt;p&gt;如果是深分页，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;LIMIT 100000, 20
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MySQL 需要扫描并丢弃前面大量数据，性能会比较差。&lt;/p&gt;
&lt;p&gt;可以改成基于游标或主键的分页：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;WHERE id &amp;gt; last_id
ORDER BY id
LIMIT 20
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者先用索引查出主键，再回表查需要的数据。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;分析锁等待和事务问题&lt;/p&gt;
&lt;p&gt;有些 SQL 慢，不一定是执行计划差，也可能是被锁阻塞。&lt;/p&gt;
&lt;p&gt;可以看：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;是否有长事务。&lt;/li&gt;
&lt;li&gt;是否有锁等待。&lt;/li&gt;
&lt;li&gt;是否有死锁。&lt;/li&gt;
&lt;li&gt;是否有大事务长时间占用资源。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;可以通过 &lt;code&gt;SHOW PROCESSLIST&lt;/code&gt;、&lt;code&gt;performance_schema&lt;/code&gt;、InnoDB 锁等待信息或数据库平台排查。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;用 EXPLAIN ANALYZE 看真实执行情况&lt;/p&gt;
&lt;p&gt;如果是 MySQL 8，可以用 &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;普通 &lt;code&gt;EXPLAIN&lt;/code&gt; 是优化器预估，&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; 会真实执行 SQL，能看到实际耗时、实际扫描行数和每个执行步骤的开销。&lt;/p&gt;
&lt;p&gt;这样可以判断优化器预估和真实执行是否有偏差。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;分析 SQL 性能时，我会先通过监控、链路追踪、数据库平台或合理阈值的慢查询日志定位慢 SQL。定位后用 &lt;code&gt;EXPLAIN&lt;/code&gt; 看执行计划，重点关注 &lt;code&gt;type&lt;/code&gt;、&lt;code&gt;key&lt;/code&gt;、&lt;code&gt;rows&lt;/code&gt;、&lt;code&gt;filtered&lt;/code&gt; 和 &lt;code&gt;Extra&lt;/code&gt;。然后分析索引是否合理，是否有回表过多、&lt;code&gt;Using filesort&lt;/code&gt;、&lt;code&gt;Using temporary&lt;/code&gt;、深分页、锁等待或长事务问题。如果是 MySQL 8，还可以用 &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; 看真实执行耗时。优化方向一般是补充或调整索引、减少回表、改写 SQL、优化分页和排查锁等待。&lt;/p&gt;
&lt;h3&gt;在 MySQL 可重复读隔离级别下，分析指定并发 SQL 的查询结果、更新阻塞情况及最终查询金额&lt;/h3&gt;
&lt;p&gt;来源：携程二面:1645（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;h3&gt;请说明 MySQL 中当前读和快照读的区别，以及什么情况下会触发当前读？&lt;/h3&gt;
&lt;p&gt;来源：携程二面:1646（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;MySQL InnoDB 里读数据可以分成快照读和当前读。核心区别是：快照读读的是历史版本，不加锁；当前读读的是最新版本，并且通常会加锁。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;快照读是什么&lt;/p&gt;
&lt;p&gt;快照读就是普通 &lt;code&gt;SELECT&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT * FROM user WHERE id = 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 InnoDB 里，普通 &lt;code&gt;SELECT&lt;/code&gt; 会通过 MVCC 读取某个历史版本。&lt;/p&gt;
&lt;p&gt;它不会直接读取最新加锁版本，也不会对记录加锁。&lt;/p&gt;
&lt;p&gt;在可重复读（RR）隔离级别下，事务第一次快照读会生成 ReadView，后续普通 &lt;code&gt;SELECT&lt;/code&gt; 会复用这个 ReadView，所以同一个事务里多次查询结果保持一致。&lt;/p&gt;
&lt;p&gt;在读已提交（RC）隔离级别下，每次普通 &lt;code&gt;SELECT&lt;/code&gt; 都会生成新的 ReadView，所以可以读到其他事务刚提交的数据。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;当前读是什么&lt;/p&gt;
&lt;p&gt;当前读读取的是记录的最新版本。&lt;/p&gt;
&lt;p&gt;当前读为了保证读到的数据后续可以被安全修改，通常会加锁。&lt;/p&gt;
&lt;p&gt;常见当前读包括：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT ... FOR UPDATE;
SELECT ... LOCK IN SHARE MODE;
UPDATE ...
DELETE ...
INSERT ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; 会加排他锁。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;SELECT ... LOCK IN SHARE MODE&lt;/code&gt; 会加共享锁。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;UPDATE&lt;/code&gt;、&lt;code&gt;DELETE&lt;/code&gt; 在执行时也需要先读到最新数据，再加锁修改。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;两者核心区别&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;快照读：普通 &lt;code&gt;SELECT&lt;/code&gt;，基于 MVCC 读历史版本，不加锁。&lt;/li&gt;
&lt;li&gt;当前读：读最新版本，并加锁，保证当前事务后续可以安全修改或判断数据状态。&lt;/li&gt;
&lt;li&gt;快照读提高并发性能，避免读写互相阻塞。&lt;/li&gt;
&lt;li&gt;当前读保证数据一致性，适合更新、删除、加锁查询等场景。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;什么情况下会触发当前读&lt;/p&gt;
&lt;p&gt;下面这些语句会触发当前读：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT ... FOR UPDATE;
SELECT ... LOCK IN SHARE MODE;
UPDATE ...
DELETE ...
INSERT ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;另外，如果执行的是加锁读，或者要修改数据，就需要当前读。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;举例说明&lt;/p&gt;
&lt;p&gt;比如账户余额初始是 100。&lt;/p&gt;
&lt;p&gt;事务 A：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT balance FROM account WHERE id = 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是快照读，读的是当前事务 ReadView 可见的版本。&lt;/p&gt;
&lt;p&gt;事务 B 提交修改，把余额改成 80。&lt;/p&gt;
&lt;p&gt;事务 A 再执行普通 &lt;code&gt;SELECT&lt;/code&gt;，在 RR 下可能还是看到 100。&lt;/p&gt;
&lt;p&gt;但如果事务 A 执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UPDATE account SET balance = balance - 10 WHERE id = 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个 &lt;code&gt;UPDATE&lt;/code&gt; 是当前读，会基于最新已提交的 80 继续更新。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;快照读就是普通 &lt;code&gt;SELECT&lt;/code&gt;，基于 MVCC 和 ReadView 读取历史版本，不加锁；当前读读取最新版本，并且通常会加锁。&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;、&lt;code&gt;SELECT ... LOCK IN SHARE MODE&lt;/code&gt;、&lt;code&gt;UPDATE&lt;/code&gt;、&lt;code&gt;DELETE&lt;/code&gt;、&lt;code&gt;INSERT&lt;/code&gt; 都属于当前读。RR 下普通 &lt;code&gt;SELECT&lt;/code&gt; 会复用 ReadView，所以多次查询可能看到相同快照；但 &lt;code&gt;UPDATE&lt;/code&gt; 这类当前读会读取最新已提交数据并加锁。&lt;/p&gt;
&lt;h3&gt;MySQL 中有哪些存储引擎？&lt;code&gt;InnoDB&lt;/code&gt; 和 &lt;code&gt;MyISAM&lt;/code&gt; 的区别是什么？&lt;/h3&gt;
&lt;p&gt;来源：美团:328（&lt;code&gt;美团/美团.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;MySQL 常见的存储引擎有 &lt;code&gt;InnoDB&lt;/code&gt;、&lt;code&gt;MyISAM&lt;/code&gt;、&lt;code&gt;Memory&lt;/code&gt;、&lt;code&gt;Archive&lt;/code&gt; 等。现在实际开发里最常用的是 &lt;code&gt;InnoDB&lt;/code&gt;，也是 MySQL 5.5 之后的默认存储引擎。&lt;/p&gt;
&lt;p&gt;重点比较 &lt;code&gt;InnoDB&lt;/code&gt; 和 &lt;code&gt;MyISAM&lt;/code&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;事务支持&lt;/p&gt;
&lt;p&gt;&lt;code&gt;InnoDB&lt;/code&gt; 支持事务，支持提交和回滚，满足 ACID。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MyISAM&lt;/code&gt; 不支持事务，一旦写入就不能通过事务回滚。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;锁粒度&lt;/p&gt;
&lt;p&gt;&lt;code&gt;InnoDB&lt;/code&gt; 支持行级锁，也支持表级锁。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MyISAM&lt;/code&gt; 主要是表级锁。&lt;/p&gt;
&lt;p&gt;所以并发写入场景下，&lt;code&gt;InnoDB&lt;/code&gt; 的并发能力更好。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;外键支持&lt;/p&gt;
&lt;p&gt;&lt;code&gt;InnoDB&lt;/code&gt; 支持外键。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MyISAM&lt;/code&gt; 不支持外键。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;崩溃恢复&lt;/p&gt;
&lt;p&gt;&lt;code&gt;InnoDB&lt;/code&gt; 有 redo log、undo log，支持崩溃恢复。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MyISAM&lt;/code&gt; 崩溃后恢复能力较弱，表损坏后需要修复。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;索引结构&lt;/p&gt;
&lt;p&gt;&lt;code&gt;InnoDB&lt;/code&gt; 使用聚簇索引，数据和主键索引放在一起。&lt;/p&gt;
&lt;p&gt;主键索引的叶子节点存整行数据，二级索引的叶子节点存主键值。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MyISAM&lt;/code&gt; 使用非聚簇索引，索引文件和数据文件分开，索引叶子节点存的是数据文件地址。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;适用场景&lt;/p&gt;
&lt;p&gt;&lt;code&gt;InnoDB&lt;/code&gt; 适合大多数业务系统，特别是需要事务、并发写入、数据一致性和崩溃恢复的场景。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MyISAM&lt;/code&gt; 适合读多写少、对事务没有要求的老系统或特定场景，现在新项目一般很少主动选择。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：MySQL 常见存储引擎有 &lt;code&gt;InnoDB&lt;/code&gt;、&lt;code&gt;MyISAM&lt;/code&gt;、&lt;code&gt;Memory&lt;/code&gt;、&lt;code&gt;Archive&lt;/code&gt; 等。实际开发中主要使用 &lt;code&gt;InnoDB&lt;/code&gt;。&lt;code&gt;InnoDB&lt;/code&gt; 支持事务、行级锁、外键和崩溃恢复，使用聚簇索引，适合高并发和数据一致性要求高的业务。&lt;code&gt;MyISAM&lt;/code&gt; 不支持事务和外键，主要使用表级锁，索引和数据分开存储，崩溃恢复能力较弱，适合读多写少且对事务要求不高的场景。&lt;/p&gt;
&lt;h3&gt;数据库的第三范式是什么？数据库设计为什么要遵循三范式？&lt;/h3&gt;
&lt;p&gt;来源：美团:438（&lt;code&gt;美团/美团.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;数据库三范式主要是为了减少数据冗余，避免插入、更新、删除时出现数据不一致。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;第一范式：字段原子性&lt;/p&gt;
&lt;p&gt;表里的每个字段都应该是不可再拆分的原子值。&lt;/p&gt;
&lt;p&gt;比如用户表里不要把多个手机号放在同一个字段里：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;phone = &quot;138xxx,139xxx&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;更合理的做法是拆成用户表和用户手机号表。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;第二范式：非主键字段完全依赖主键&lt;/p&gt;
&lt;p&gt;在满足第一范式的基础上，非主键字段要完全依赖整个主键，不能只依赖联合主键的一部分。&lt;/p&gt;
&lt;p&gt;比如订单商品表：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;order_id
product_id
product_name
order_time
quantity
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果主键是 &lt;code&gt;(order_id, product_id)&lt;/code&gt;，那么 &lt;code&gt;quantity&lt;/code&gt; 依赖这个联合主键。&lt;/p&gt;
&lt;p&gt;但 &lt;code&gt;product_name&lt;/code&gt; 只依赖 &lt;code&gt;product_id&lt;/code&gt;，&lt;code&gt;order_time&lt;/code&gt; 只依赖 &lt;code&gt;order_id&lt;/code&gt;，就不适合放在这张明细表里。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;第三范式：非主键字段不能传递依赖主键&lt;/p&gt;
&lt;p&gt;在满足第二范式的基础上，非主键字段之间不能存在依赖关系。&lt;/p&gt;
&lt;p&gt;比如用户表：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user_id
dept_id
dept_name
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;dept_name&lt;/code&gt; 依赖 &lt;code&gt;dept_id&lt;/code&gt;，&lt;code&gt;dept_id&lt;/code&gt; 又依赖 &lt;code&gt;user_id&lt;/code&gt;，这就产生了传递依赖。&lt;/p&gt;
&lt;p&gt;更合理的设计是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user 表：user_id, dept_id
dept 表：dept_id, dept_name
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;为什么要遵循三范式&lt;/p&gt;
&lt;p&gt;遵循三范式的好处是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;减少重复数据。&lt;/li&gt;
&lt;li&gt;降低更新异常，比如部门名只需要改一处。&lt;/li&gt;
&lt;li&gt;降低插入异常，比如没有用户时也可以单独维护部门。&lt;/li&gt;
&lt;li&gt;降低删除异常，比如删除用户不会把部门信息一起删掉。&lt;/li&gt;
&lt;li&gt;保证数据结构更清晰，更容易维护。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;实际开发中的取舍&lt;/p&gt;
&lt;p&gt;实际业务里不会机械地完全遵守三范式。&lt;/p&gt;
&lt;p&gt;在读多写少、查询性能要求高的场景下，可能会适当反范式设计，比如订单表里冗余商品名称、商品快照、用户昵称。&lt;/p&gt;
&lt;p&gt;这样可以减少多表 join，提高查询性能，也能保留当时下单时的历史信息。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：三范式主要是为了减少数据冗余和数据异常。第一范式要求字段不可再拆分；第二范式要求非主键字段完全依赖主键，不能只依赖联合主键的一部分；第三范式要求非主键字段不能传递依赖主键。遵循三范式可以减少冗余，避免更新、插入、删除异常。实际开发中会根据性能和业务快照需求适当反范式，比如订单表冗余商品名称和用户信息。&lt;/p&gt;
&lt;h3&gt;有索引的情况下，一条 &lt;code&gt;UPDATE&lt;/code&gt; 语句执行很慢，可能是什么原因？&lt;/h3&gt;
&lt;p&gt;来源：京东面试二&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;有索引只说明 MySQL 可以更快定位数据。&lt;code&gt;UPDATE&lt;/code&gt; 的耗时还包括锁等待、扫描范围、索引维护、日志写入和事务提交。&lt;/p&gt;
&lt;h4&gt;当前读和锁等待&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;UPDATE&lt;/code&gt; 走当前读：根据索引定位记录后，尝试获取排他锁，读取当前最新版本，再写入新版本并生成 Undo Log、Redo Log。&lt;/p&gt;
&lt;p&gt;热点行竞争是最常见原因：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UPDATE account
SET balance = balance - 100
WHERE id = 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;大量请求同时更新 &lt;code&gt;id = 1&lt;/code&gt; 时，只能串行获得该行的锁。排查时重点看 &lt;code&gt;performance_schema.data_locks&lt;/code&gt;、&lt;code&gt;performance_schema.data_lock_waits&lt;/code&gt; 和长事务。&lt;/p&gt;
&lt;h4&gt;索引命中，但扫描范围很大&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;UPDATE orders
SET status = 2
WHERE status = 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;status&lt;/code&gt; 即使有索引，区分度低时仍可能命中几十万行。需要通过 &lt;code&gt;EXPLAIN&lt;/code&gt; 检查 &lt;code&gt;rows&lt;/code&gt;、&lt;code&gt;type&lt;/code&gt; 和 &lt;code&gt;key&lt;/code&gt;，比较扫描行数和实际更新行数。&lt;/p&gt;
&lt;p&gt;范围条件和非唯一索引条件还可能产生 Next-Key Lock，扩大锁范围：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UPDATE orders
SET status = 2
WHERE create_time &amp;gt;= &apos;2026-07-14 00:00:00&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;高并发场景尽量通过主键或唯一索引做等值更新：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UPDATE orders
SET status = 2
WHERE id = ?;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;缺少合适索引时的锁范围&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;UPDATE&lt;/code&gt; 没有合适索引时会扫描聚簇索引中的大量记录，并在扫描过程中加锁，再判断 &lt;code&gt;WHERE&lt;/code&gt; 条件。默认 &lt;code&gt;REPEATABLE READ&lt;/code&gt; 下，锁范围可能接近全表，表现为其他 &lt;code&gt;UPDATE&lt;/code&gt;、&lt;code&gt;DELETE&lt;/code&gt;、&lt;code&gt;INSERT&lt;/code&gt; 大量等待。&lt;/p&gt;
&lt;p&gt;底层主要是扫描到的索引记录锁和间隙锁，不是 &lt;code&gt;LOCK TABLES&lt;/code&gt; 形式的表锁。MySQL 官方说明：没有合适索引导致全表扫描时，表中每行都可能被锁住。&lt;/p&gt;
&lt;h4&gt;更新索引列的写放大&lt;/h4&gt;
&lt;p&gt;更新字段出现在多个二级索引中时，除了更新聚簇索引记录，还要维护这些二级索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;更新记录
-&amp;gt; 写 Undo Log、Redo Log
-&amp;gt; 修改聚簇索引
-&amp;gt; 维护相关二级索引
-&amp;gt; 写 Binlog
-&amp;gt; 提交时可能等待刷盘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;索引越多、更新行越多，写放大越明显。&lt;/p&gt;
&lt;h4&gt;大事务和其他额外工作&lt;/h4&gt;
&lt;p&gt;一次更新大量数据会产生大量 Undo Log、Redo Log、Binlog、脏页和锁记录，也会增加回滚成本、主从复制延迟和刷盘压力。通常按主键范围分批更新，每批控制在合理数量并尽快提交。&lt;/p&gt;
&lt;p&gt;还要检查 Trigger、外键检查、级联更新、Buffer Pool 命中率、磁盘 IO、数据库 CPU 和连接数。&lt;/p&gt;
&lt;p&gt;排查顺序：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;确认执行时间还是锁等待
-&amp;gt; EXPLAIN 看扫描范围
-&amp;gt; 查锁等待和长事务
-&amp;gt; 检查更新字段涉及的索引数量
-&amp;gt; 检查更新行数和事务大小
-&amp;gt; 检查 Trigger、外键、Redo/Binlog、IO 和 CPU
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;总结：有索引的 &lt;code&gt;UPDATE&lt;/code&gt; 仍然可能慢，最常见是锁等待、热点行竞争、低区分度索引扫描大量记录、范围锁、更新索引列带来的索引维护，以及大事务产生的日志和刷盘压力。&lt;/p&gt;
&lt;h2&gt;JVM&lt;/h2&gt;
&lt;h3&gt;有了解过哪些垃圾回收器？（重复 3 次）&lt;/h3&gt;
&lt;p&gt;来源：携程二面:269（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）；美团2:452（&lt;code&gt;美团2/美团2.md&lt;/code&gt;）；美团3:88（&lt;code&gt;美团3/美团3.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;我主要了解 Serial、CMS、G1 和 ZGC 这几类垃圾回收器。它们代表了不同阶段的 GC 设计思路：Serial 比较简单，适合小堆和单线程场景；CMS 主要追求降低老年代回收停顿；G1 更适合服务端大堆，并且可以设置期望停顿时间；ZGC 属于低延迟收集器，目标是在大堆场景下也把停顿控制得很短。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Serial 收集器&lt;/p&gt;
&lt;p&gt;Serial 是比较早期的垃圾回收器，特点是单线程执行 GC。&lt;/p&gt;
&lt;p&gt;它在垃圾回收时会暂停所有用户线程，也就是 Stop The World，然后由一个 GC 线程完成垃圾回收。&lt;/p&gt;
&lt;p&gt;Serial 的优点是实现简单，额外线程开销小，在小堆内存、客户端程序、单核环境下反而比较高效。&lt;/p&gt;
&lt;p&gt;缺点也很明显：因为只有一个 GC 线程，并且回收期间会暂停用户线程，所以堆大或者对象多时，停顿时间会比较明显。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;CMS 收集器&lt;/p&gt;
&lt;p&gt;CMS 全称是 Concurrent Mark Sweep，主要用于老年代回收，目标是降低 GC 停顿时间。&lt;/p&gt;
&lt;p&gt;它的核心思路是让部分 GC 工作和用户线程并发执行，减少 Stop The World 的时间。&lt;/p&gt;
&lt;p&gt;CMS 的主要流程有 4 个阶段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;初始标记：标记 GC Roots 直接关联的对象，这个阶段会 Stop The World，但时间通常比较短。&lt;/li&gt;
&lt;li&gt;并发标记：从 GC Roots 关联对象继续向下遍历，标记可达对象，这个阶段 GC 线程和用户线程并发执行。&lt;/li&gt;
&lt;li&gt;重新标记：修正并发标记期间用户线程继续运行导致的标记变化，这个阶段也会 Stop The World。&lt;/li&gt;
&lt;li&gt;并发清除：清理不可达对象，这个阶段和用户线程并发执行。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;CMS 的优点是停顿时间较短，适合对响应时间比较敏感的服务端应用。&lt;/p&gt;
&lt;p&gt;缺点主要有几个：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;使用标记清除算法，会产生内存碎片。&lt;/li&gt;
&lt;li&gt;并发阶段会和用户线程抢 CPU。&lt;/li&gt;
&lt;li&gt;用户线程和 GC 线程并发运行时，还会产生浮动垃圾，本轮 GC 可能清理不掉，只能留到下一轮。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;G1 收集器&lt;/p&gt;
&lt;p&gt;G1 全称是 Garbage First，是面向服务端应用的垃圾回收器。&lt;/p&gt;
&lt;p&gt;它和传统分代收集器不同，G1 会把整个 Java 堆划分成多个大小相等的 Region。每个 Region 可以作为 Eden、Survivor 或 Old 使用。&lt;/p&gt;
&lt;p&gt;G1 的核心思想是优先回收收益最高的 Region，也就是垃圾最多、回收价值最大的区域，所以叫 Garbage First。&lt;/p&gt;
&lt;p&gt;G1 的特点有几个：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;支持新生代和老年代统一管理。&lt;/li&gt;
&lt;li&gt;使用 Region 管理堆，减少大对象和碎片问题。&lt;/li&gt;
&lt;li&gt;可以设置期望最大停顿时间，比如通过 &lt;code&gt;-XX:MaxGCPauseMillis&lt;/code&gt; 指定目标停顿。&lt;/li&gt;
&lt;li&gt;回收时会根据停顿目标，选择一部分 Region 进行回收，而不是每次都全堆回收。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;G1 的大致流程包括初始标记、并发标记、最终标记、筛选回收等阶段。&lt;/p&gt;
&lt;p&gt;如果追问“老年代阈值”，需要先确认语义：对象经历多少次 Young GC 后晋升，关注 &lt;code&gt;-XX:MaxTenuringThreshold&lt;/code&gt;，Survivor 空间不足时也可能提前晋升；堆占用达到什么水平后启动 G1 并发标记周期，关注 &lt;code&gt;-XX:InitiatingHeapOccupancyPercent&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;它的优点是更适合大堆服务端应用，可以在吞吐量和停顿时间之间做平衡。&lt;/p&gt;
&lt;p&gt;缺点是实现复杂，维护 Region、Remembered Set 等结构会带来额外开销。在小堆场景下，优势不一定明显。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;ZGC 收集器&lt;/p&gt;
&lt;p&gt;ZGC 是一种低延迟垃圾回收器，目标是让 GC 停顿时间非常短，并且停顿时间基本不随着堆大小明显增长。&lt;/p&gt;
&lt;p&gt;它适合大堆内存、低延迟场景，比如在线交易、实时推荐、网关服务、对响应时间敏感的系统。&lt;/p&gt;
&lt;p&gt;ZGC 的核心机制包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;染色指针：在对象引用指针里记录对象状态信息，比如对象是否被标记、是否被移动。&lt;/li&gt;
&lt;li&gt;读屏障：应用线程读取对象引用时，会通过读屏障判断对象状态，如果对象已经移动，可以修正引用。&lt;/li&gt;
&lt;li&gt;并发标记：标记阶段大部分工作和用户线程并发执行。&lt;/li&gt;
&lt;li&gt;并发转移：对象整理和移动也尽量和用户线程并发执行，减少 Stop The World 时间。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;ZGC 的优点是停顿时间很短，适合大堆和低延迟系统。&lt;/p&gt;
&lt;p&gt;缺点是实现复杂，对运行环境和 JDK 版本有要求，并且并发 GC 也会消耗一定 CPU 资源。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;有对哪个垃圾回收器比较了解吗？请说明 CMS 的回收流程、优点和缺点&lt;/h3&gt;
&lt;p&gt;来源：携程二面:342（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;CMS 全称是 Concurrent Mark Sweep，主要用于老年代垃圾回收。它的目标是降低 GC 停顿时间，所以会让一部分 GC 工作和用户线程并发执行。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;初始标记&lt;/p&gt;
&lt;p&gt;初始标记会标记 GC Roots 能直接关联到的对象。&lt;/p&gt;
&lt;p&gt;这个阶段会发生 Stop The World，但它只标记第一层直接可达对象，所以速度比较快，停顿时间通常比较短。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;并发标记&lt;/p&gt;
&lt;p&gt;并发标记会从初始标记得到的对象继续向下遍历，找出所有可达对象。&lt;/p&gt;
&lt;p&gt;这个阶段 GC 线程和用户线程可以同时运行，所以不会长时间暂停业务线程。&lt;/p&gt;
&lt;p&gt;但因为用户线程还在运行，对象引用关系可能会继续变化，所以后面还需要重新标记。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;重新标记&lt;/p&gt;
&lt;p&gt;重新标记主要是修正并发标记期间，因为用户线程继续运行导致的对象引用变化。&lt;/p&gt;
&lt;p&gt;这个阶段也会 Stop The World。&lt;/p&gt;
&lt;p&gt;相比并发标记，重新标记时间通常短一些，但会比初始标记稍微复杂，因为它要处理并发阶段产生的变化。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;并发清除&lt;/p&gt;
&lt;p&gt;并发清除会清理没有被标记到的不可达对象。&lt;/p&gt;
&lt;p&gt;这个阶段 GC 线程和用户线程也可以并发执行，所以整体停顿时间比较低。&lt;/p&gt;
&lt;p&gt;CMS 使用的是标记清除算法，只清理垃圾对象，不做整体压缩整理。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;优点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;停顿时间短&lt;/p&gt;
&lt;p&gt;CMS 的并发标记和并发清除阶段可以和用户线程一起执行，所以 Stop The World 的时间主要集中在初始标记和重新标记两个阶段。&lt;/p&gt;
&lt;p&gt;相比传统老年代收集器，它更适合对响应时间敏感的服务端应用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;适合低延迟场景&lt;/p&gt;
&lt;p&gt;比如 Web 服务、接口服务、订单服务这类在线业务，通常更关注请求响应时间，CMS 可以减少老年代 GC 带来的长时间停顿。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;缺点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;会产生内存碎片&lt;/p&gt;
&lt;p&gt;CMS 使用标记清除算法，清理对象后不会整理内存空间，所以老年代里可能出现很多不连续的小空闲块。&lt;/p&gt;
&lt;p&gt;如果后续要分配大对象，即使总剩余空间足够，也可能因为没有连续空间而触发 Full GC。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;并发阶段会消耗 CPU&lt;/p&gt;
&lt;p&gt;CMS 的并发标记和并发清除阶段会和用户线程一起运行，所以 GC 线程会占用一部分 CPU。&lt;/p&gt;
&lt;p&gt;在 CPU 资源紧张的情况下，可能会影响业务线程执行效率。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;会产生浮动垃圾&lt;/p&gt;
&lt;p&gt;并发清除阶段用户线程还在运行，用户线程可能继续产生新的垃圾对象。&lt;/p&gt;
&lt;p&gt;这些新产生的垃圾在本轮 CMS 中可能清理不到，只能等下一轮 GC 再处理，这部分就叫浮动垃圾。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;可能出现 Concurrent Mode Failure&lt;/p&gt;
&lt;p&gt;如果 CMS 回收速度赶不上对象分配速度，老年代空间提前不足，就可能触发 Concurrent Mode Failure。&lt;/p&gt;
&lt;p&gt;这时 JVM 可能退化成 Serial Old 进行 Full GC，停顿时间会明显变长。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;有了解过 ZGC 吗？染色指针和读屏障能说出来吗？&lt;/h3&gt;
&lt;p&gt;来源：携程二面:413（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;ZGC 是一种低延迟垃圾回收器，目标是在大堆内存下也尽量把 GC 停顿控制得很短。它能做到低停顿，核心原因是把大部分 GC 工作都设计成和用户线程并发执行，比如并发标记、并发转移和并发重映射。染色指针和读屏障就是支撑这个并发过程的关键机制。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;染色指针&lt;/p&gt;
&lt;p&gt;染色指针指的是 ZGC 会在对象引用指针中拿出几位 bit，用来记录引用的状态信息。&lt;/p&gt;
&lt;p&gt;这些状态信息可以表示当前引用是否被标记过、是否处于重映射阶段、是否是当前阶段可直接使用的引用。&lt;/p&gt;
&lt;p&gt;简单理解就是：引用本身携带 GC 状态，ZGC 通过引用上的颜色判断这个引用在当前 GC 阶段是否可用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;读屏障&lt;/p&gt;
&lt;p&gt;读屏障是在用户线程读取对象引用时触发的一段检查逻辑。&lt;/p&gt;
&lt;p&gt;用户线程读到一个引用时，读屏障会先检查引用上的染色标记。如果这个引用的颜色符合当前 GC 阶段要求，就直接使用；如果颜色不符合要求，就进入慢路径。&lt;/p&gt;
&lt;p&gt;慢路径里会进一步判断对象是否已经被重定位。如果对象已经移动，就根据转发表找到新地址，返回新的引用，并把引用修正成当前阶段可用的颜色。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;两者怎么配合&lt;/p&gt;
&lt;p&gt;染色指针负责记录引用状态。&lt;/p&gt;
&lt;p&gt;读屏障负责在读取引用时检查状态。&lt;/p&gt;
&lt;p&gt;如果引用状态正常，就直接访问；如果状态不符合当前阶段要求，读屏障就按需修正引用。&lt;/p&gt;
&lt;p&gt;所以 ZGC 不是提前扫描所有引用统一修正，而是在对象引用真正被读取时才检查和修正。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;为什么能低停顿&lt;/p&gt;
&lt;p&gt;传统 GC 如果移动对象，往往需要暂停用户线程，把所有旧引用统一修正成新引用。堆越大、引用越多，停顿时间就越难控制。&lt;/p&gt;
&lt;p&gt;ZGC 通过染色指针和读屏障，把引用修正这件事拆散到用户线程后续读取引用的过程中完成。GC 线程可以并发移动对象，用户线程继续运行，读到旧引用时再由读屏障按需修正。&lt;/p&gt;
&lt;p&gt;这样 ZGC 不需要长时间 Stop The World 去统一更新所有引用，所以停顿时间可以控制得很短。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;整体来说，ZGC 通过染色指针在引用中记录 GC 状态，通过读屏障在用户线程读取引用时检查这个状态。如果引用颜色符合当前阶段要求，就直接访问；如果不符合，就进入慢路径，判断对象是否已经重定位，并根据转发表修正为新引用。这样对象移动和引用修正可以尽量和用户线程并发执行，ZGC 就不需要长时间 STW 去统一更新所有引用，所以能实现低停顿。&lt;/p&gt;
&lt;h3&gt;场景题：服务器发生抖动时如何排查和处理？&lt;/h3&gt;
&lt;p&gt;来源：京东面试二&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;服务器抖动通常表现为接口 RT 突增、P99 波动、超时或错误率上升。我会按“先确认现象，再定位资源，最后定位代码和依赖”的顺序排查。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;对齐应用 QPS、P99/P999、错误率、线程池队列长度，以及 CPU、Load Average、上下文切换、容器 CPU Throttling、JVM 堆使用率、GC 停顿、MySQL/Redis/MQ RT 等指标，确认抖动落在哪一层。&lt;/li&gt;
&lt;li&gt;CPU 高时，定位 Java 进程和具体线程的 CPU 占用，再结合线程栈判断是否存在死循环、频繁序列化、复杂计算、锁竞争或线程数过多导致的上下文切换。&lt;/li&gt;
&lt;li&gt;GC 与 RT 尖刺重合时，检查 GC 日志中的 Young GC、Old GC、Full GC、老年代使用率、对象分配速率和 &lt;code&gt;concurrent mode failure&lt;/code&gt;。进一步通过堆转储定位大对象、无界缓存或内存泄漏。&lt;/li&gt;
&lt;li&gt;下游 RT 升高时，检查数据库慢 SQL、Redis 延迟、MQ 积压、连接池耗尽和网络 IO，配合超时、限流、熔断和降级保护调用链。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;常见处理包括：减少临时对象和大对象、治理无界缓存、合理设置堆和老年代余量、控制线程池规模、修复慢 SQL 和下游瓶颈。CMS 碎片或 Full GC 频繁时，评估切换到 G1。&lt;/p&gt;
&lt;p&gt;补充追问：CPU 调度、垃圾回收、CMS 的完整回收流程，以及各阶段的 STW。&lt;/p&gt;
&lt;p&gt;CMS 主要回收老年代，通过将大部分工作与业务线程并发执行来降低停顿。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;初始标记（STW）
-&amp;gt; 并发标记
-&amp;gt; 并发预清理
-&amp;gt; 重新标记（STW）
-&amp;gt; 并发清除
-&amp;gt; 并发重置
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;初始标记：暂停业务线程，标记 GC Roots 直接可达对象，停顿通常较短。&lt;/li&gt;
&lt;li&gt;并发标记：GC 线程遍历对象图，业务线程继续运行。&lt;/li&gt;
&lt;li&gt;并发预清理：提前处理部分引用变化，减少重新标记工作量。&lt;/li&gt;
&lt;li&gt;重新标记：暂停业务线程，处理并发标记期间发生引用变化而遗漏的对象。该阶段通常比初始标记更长。&lt;/li&gt;
&lt;li&gt;并发清除：并发回收不可达对象。CMS 不做压缩整理，可能产生内存碎片。&lt;/li&gt;
&lt;li&gt;并发重置：清理本轮 CMS 的状态，为下一轮回收准备。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;CMS 回收来不及完成、老年代已满或连续空间不足时，可能发生 &lt;code&gt;concurrent mode failure&lt;/code&gt;，JVM 会退化为 Full GC，造成长时间 STW。处理重点是降低对象分配速率、预留老年代空间、提前触发回收，并治理大对象和内存泄漏。&lt;/p&gt;
&lt;h3&gt;线上接口出现超时，你会如何排查？&lt;/h3&gt;
&lt;p&gt;来源：美团3:406（&lt;code&gt;美团3/美团3.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;先确认影响范围：全部或部分请求、单实例或全集群、持续或突发，以及是否与发布、流量峰值或下游故障相关。然后根据 TraceId 沿调用链定位耗时位置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Client -&amp;gt; Nginx / Gateway -&amp;gt; Service A -&amp;gt; Service B -&amp;gt; Redis / MySQL / MQ
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;看 QPS、错误率、RT/P99、CPU、Load、内存、GC、线程数和网络指标，先判断资源饱和、突发流量还是依赖异常。&lt;/li&gt;
&lt;li&gt;CPU 高时排查热点方法、死循环和频繁 GC；CPU 不高但请求阻塞时，使用 &lt;code&gt;jstack &amp;lt;pid&amp;gt;&lt;/code&gt; 或 Arthas &lt;code&gt;thread&lt;/code&gt; 检查 Web 线程池、业务线程池、队列和连接池等待。&lt;/li&gt;
&lt;li&gt;数据库侧看 SQL 耗时、执行计划、慢查询、锁等待、连接数和连接池。线程大量阻塞在 &lt;code&gt;HikariPool.getConnection()&lt;/code&gt; 时，重点排查连接池耗尽和慢事务。&lt;/li&gt;
&lt;li&gt;Redis、MQ 与下游侧看 Redis RT、慢命令、大 Key、Hot Key、消息积压和消费延迟；当前服务耗时正常而下游调用慢时，继续沿下游链路排查。&lt;/li&gt;
&lt;li&gt;最后检查 DNS、连接建立、丢包、跨机房网络，并对比发布前后指标；新版本实例异常时可摘流或回滚止血。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：先确定范围，再看监控和调用链，接着按 CPU/GC/线程池、MySQL/Redis/MQ、下游依赖、网络和近期发布逐层收敛。&lt;/p&gt;
&lt;h3&gt;JVM 如何调整堆外内存大小？&lt;/h3&gt;
&lt;p&gt;来源：京东面试二（待补充）&lt;/p&gt;
&lt;h3&gt;请说明 JVM 内存结构，以及方法区、永久代和元空间。（重复 2 次）&lt;/h3&gt;
&lt;p&gt;来源：京东面试二；美团3:48（&lt;code&gt;美团3/美团3.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;这道题通常问的是 JVM 的运行时数据区，不是并发编程里的 Java Memory Model（JMM）。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;线程共享：
Heap
Method Area

线程私有：
Program Counter Register
Java Virtual Machine Stack
Native Method Stack

补充：
Direct Memory
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;堆&lt;/h4&gt;
&lt;p&gt;堆保存对象实例和数组，是 GC 的主要回收区域。堆通常分为新生代和老年代：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Heap
├─ Young Generation
│  ├─ Eden
│  ├─ Survivor 0
│  └─ Survivor 1
└─ Old Generation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;java.lang.OutOfMemoryError: Java heap space&lt;/code&gt; 表示堆内存不足。&lt;/p&gt;
&lt;h4&gt;Java 虚拟机栈、程序计数器和本地方法栈&lt;/h4&gt;
&lt;p&gt;每个线程都有独立的 Java 虚拟机栈。每调用一个 Java 方法，就创建一个栈帧，包含局部变量表、操作数栈、动态链接和方法返回地址。递归过深常见 &lt;code&gt;StackOverflowError&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;程序计数器记录当前线程执行到哪一条字节码指令，线程切换后 JVM 据此恢复执行位置。&lt;/p&gt;
&lt;p&gt;本地方法栈服务于 JNI 等 Native 方法调用。&lt;/p&gt;
&lt;h4&gt;方法区&lt;/h4&gt;
&lt;p&gt;方法区是 JVM 规范定义的线程共享逻辑概念，保存类级别信息：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;类的结构信息
运行时常量池
字段和方法信息
方法字节码
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JVM 规范没有强制规定方法区的物理位置和具体实现。&lt;/p&gt;
&lt;h4&gt;永久代和元空间&lt;/h4&gt;
&lt;p&gt;永久代和元空间是 HotSpot 对方法区相关数据的不同实现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;JDK 7 及以前：PermGen（永久代）
JDK 8 及以后：Metaspace（元空间）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;永久代位于 JVM 堆内，大小受 &lt;code&gt;-XX:PermSize&lt;/code&gt; 和 &lt;code&gt;-XX:MaxPermSize&lt;/code&gt; 限制。类很多、动态生成类较多或类加载器泄漏时，可能出现 &lt;code&gt;OutOfMemoryError: PermGen space&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;JDK 8 使用元空间替代永久代。元空间使用本地内存，不占 Java 堆 &lt;code&gt;-Xmx&lt;/code&gt;。常用参数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;MetaspaceSize&lt;/code&gt; 是触发与类卸载相关 GC 的阈值，&lt;code&gt;MaxMetaspaceSize&lt;/code&gt; 用于限制元空间最大使用量。超过上限会出现 &lt;code&gt;OutOfMemoryError: Metaspace&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;永久代大小固定且难以预估。Spring、Tomcat、动态代理、CGLIB 等场景会加载大量类，永久代容易 OOM。元空间可以按需使用本地内存，降低固定永久代容量带来的问题；生产环境仍应设置合理上限并监控类加载数量。&lt;/p&gt;
&lt;h4&gt;Direct Memory&lt;/h4&gt;
&lt;p&gt;Direct Memory 属于堆外内存，NIO 和 Netty 常用。它不属于 JVM 规范中的运行时数据区，但会占用进程总内存：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-XX:MaxDirectMemorySize=1g
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;总结：堆保存对象，栈保存方法调用过程，程序计数器记录执行位置，本地方法栈服务 Native 调用；方法区保存类元数据和运行时常量池。JDK 8 使用元空间替代永久代。&lt;/p&gt;
&lt;h3&gt;类加载机制&lt;/h3&gt;
&lt;p&gt;来源：美团2:389（&lt;code&gt;美团2/美团2.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;类生命周期为加载、连接、初始化、使用和卸载；连接包括验证、准备和解析。双亲委派属于加载阶段的类加载器策略。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;加载：类加载器根据全限定名获取字节码，转为 JVM 运行时数据结构，并创建 &lt;code&gt;Class&lt;/code&gt; 对象。默认流程是先检查是否已加载，再委托父加载器；父加载器无法加载时，当前加载器才自行 &lt;code&gt;findClass&lt;/code&gt; 和 &lt;code&gt;defineClass&lt;/code&gt;。双亲委派可以防止核心类被篡改、避免重复加载并保证核心类统一性。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;连接：验证检查字节码格式、语义和类型安全；准备为 &lt;code&gt;static&lt;/code&gt; 变量分配内存并赋默认值，&lt;code&gt;static int count = 10&lt;/code&gt; 在此时通常为 &lt;code&gt;0&lt;/code&gt;，编译期常量 &lt;code&gt;static final int COUNT = 10&lt;/code&gt; 可以直接赋值为 &lt;code&gt;10&lt;/code&gt;；解析将符号引用替换为直接引用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;初始化：执行 &lt;code&gt;&amp;lt;clinit&amp;gt;&lt;/code&gt;，即静态变量显式赋值和静态代码块。初始化子类前先初始化父类，且同一个类的初始化过程线程安全。&lt;code&gt;new&lt;/code&gt;、读写非编译期常量静态字段、调用静态方法、反射和启动 &lt;code&gt;main&lt;/code&gt; 类会触发初始化；&lt;code&gt;loadClass&lt;/code&gt;、&lt;code&gt;Class.forName&lt;/code&gt; 的 &lt;code&gt;initialize=false&lt;/code&gt;、访问编译期常量和创建类数组通常不触发初始化。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;类加载器层次：JDK 8 常见为 Bootstrap、Extension、Application；JDK 9 后为 Bootstrap、Platform、Application。Tomcat 为实现不同 Web 应用的类隔离会调整默认委派顺序；JDBC SPI 通过线程上下文类加载器加载应用 ClassPath 中的驱动实现。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：双亲委派位于加载阶段，连接和初始化负责校验、准备、解析与执行静态初始化逻辑。&lt;/p&gt;
&lt;h3&gt;遇到过 Full GC 或内存泄漏吗？如何排查和解决？&lt;/h3&gt;
&lt;p&gt;来源：美团2:484（&lt;code&gt;美团2/美团2.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Full GC 是一次 GC 事件；内存泄漏是无用对象仍被引用、GC 无法回收的根因之一。Full GC 频繁还可能由堆配置不足、流量突增、大对象或对象晋升过快导致，因此需要通过趋势和现场证据判断。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;监控：关注老年代使用率、Full GC 次数和停顿时间、接口 P99、CPU 和错误率。正常堆曲线在 GC 后明显回落；若业务流量稳定而 Full GC 后老年代基线持续抬高，例如 &lt;code&gt;40% -&amp;gt; 55% -&amp;gt; 70%&lt;/code&gt;，需要怀疑长期引用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;GC 日志：开启 &lt;code&gt;-Xlog:gc*&lt;/code&gt;，检查 Full GC 前后的老年代大小、回收量、频率、停顿和对象晋升速度。连续多次 Full GC 回收量很小，说明大量对象仍然存活；同时检查 Metaspace 与 Direct Memory。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;对象直方图：使用 &lt;code&gt;jcmd &amp;lt;pid&amp;gt; GC.class_histogram&lt;/code&gt;，对比两到三次采样，确认某类对象的数量和占用持续增长。单次占用大只能说明热点对象，持续增长才是泄漏证据。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Heap Dump：使用 &lt;code&gt;jcmd &amp;lt;pid&amp;gt; GC.heap_dump filename=/data/heap.hprof&lt;/code&gt;，再用 MAT、VisualVM 或 JProfiler 查看 Dominator Tree、Histogram 和 Path to GC Roots。通过引用链定位对象为什么仍然存活，例如 &lt;code&gt;OrderDTO -&amp;gt; HashMap$Node -&amp;gt; static ConcurrentHashMap CACHE -&amp;gt; 单例 Bean -&amp;gt; GC Root&lt;/code&gt;，说明静态缓存缺少容量或过期控制。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;常见根因：无上限缓存或 Map、线程池中的 ThreadLocal 未 &lt;code&gt;remove&lt;/code&gt;、无界消息或任务队列、大对象和一次性大查询、未注销监听器或未关闭资源、类加载器泄漏和 Direct Memory 增长。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;处理：先限流、降级、暂停大批任务或扩容止血，并保留 GC 日志、直方图和 Heap Dump；再修复缓存容量与 TTL、ThreadLocal 清理、队列容量与拒绝策略、分页或流式处理、资源生命周期。修复后持续观察 Full GC 后老年代基线、GC 停顿、接口 P99 和对象数量趋势。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;建议开启 &lt;code&gt;-XX:+HeapDumpOnOutOfMemoryError&lt;/code&gt; 和 &lt;code&gt;-XX:HeapDumpPath=/data/dump&lt;/code&gt; 保留 OOM 现场。&lt;/p&gt;
&lt;p&gt;总结：按照“老年代基线趋势 -&amp;gt; GC 日志 -&amp;gt; 多次对象直方图对比 -&amp;gt; Heap Dump 的 Path to GC Roots”确认内存泄漏，再根据引用链修复问题。&lt;/p&gt;
&lt;h2&gt;JUC 与并发&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;synchronized&lt;/code&gt; 锁和 &lt;code&gt;ReentrantLock&lt;/code&gt; 的区别（重复 2 次）&lt;/h3&gt;
&lt;p&gt;来源：京东面试:368（&lt;code&gt;京东面试/京东面试.md&lt;/code&gt;）；美团3:11（&lt;code&gt;美团3/美团3.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;基本区别&lt;/p&gt;
&lt;p&gt;&lt;code&gt;synchronized&lt;/code&gt; 是 Java 关键字，由 JVM 层面支持，使用起来比较简单，进入同步代码块自动加锁，退出时自动释放锁。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ReentrantLock&lt;/code&gt; 是 JUC 包下的锁类，底层主要基于 AQS 实现，需要手动调用 &lt;code&gt;lock()&lt;/code&gt; 和 &lt;code&gt;unlock()&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    // 临界区代码
} finally {
    lock.unlock();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;是否可重入&lt;/p&gt;
&lt;p&gt;两者都是可重入锁。&lt;/p&gt;
&lt;p&gt;同一个线程已经拿到锁后，可以再次进入同一把锁保护的代码。&lt;code&gt;synchronized&lt;/code&gt; 由 JVM 维护重入次数，&lt;code&gt;ReentrantLock&lt;/code&gt; 通过 AQS 的 &lt;code&gt;state&lt;/code&gt; 记录重入次数。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;功能区别&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ReentrantLock&lt;/code&gt; 比 &lt;code&gt;synchronized&lt;/code&gt; 更灵活，主要体现在：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;支持 &lt;code&gt;tryLock()&lt;/code&gt;，可以尝试获取锁，拿不到可以直接返回。&lt;/li&gt;
&lt;li&gt;支持 &lt;code&gt;tryLock(timeout)&lt;/code&gt;，可以在指定时间内等待锁。&lt;/li&gt;
&lt;li&gt;支持 &lt;code&gt;lockInterruptibly()&lt;/code&gt;，线程等待锁时可以响应中断。&lt;/li&gt;
&lt;li&gt;支持公平锁和非公平锁。&lt;/li&gt;
&lt;li&gt;支持多个 &lt;code&gt;Condition&lt;/code&gt; 条件队列，可以做更精细的等待和唤醒。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;synchronized&lt;/code&gt; 主要配合 &lt;code&gt;wait()&lt;/code&gt;、&lt;code&gt;notify()&lt;/code&gt;、&lt;code&gt;notifyAll()&lt;/code&gt; 使用，功能相对简单。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;性能区别&lt;/p&gt;
&lt;p&gt;早期 &lt;code&gt;synchronized&lt;/code&gt; 性能较差，但后面 JVM 做了很多优化，比如锁升级、轻量级锁等，所以普通场景下 &lt;code&gt;synchronized&lt;/code&gt; 和 &lt;code&gt;ReentrantLock&lt;/code&gt; 性能差距不大。&lt;/p&gt;
&lt;p&gt;现在选择哪个，更多看功能需求。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用场景&lt;/p&gt;
&lt;p&gt;普通同步代码，优先用 &lt;code&gt;synchronized&lt;/code&gt;，代码简单，也不容易忘记释放锁。&lt;/p&gt;
&lt;p&gt;如果需要可中断、超时等待、公平锁，或者多个条件队列，就用 &lt;code&gt;ReentrantLock&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;线程创建的方法有哪些？&lt;code&gt;Runnable&lt;/code&gt; 和 &lt;code&gt;Callable&lt;/code&gt; 相比有什么区别？（重复 2 次）&lt;/h3&gt;
&lt;p&gt;来源：京东面试:419（&lt;code&gt;京东面试/京东面试.md&lt;/code&gt;）；美团:39（&lt;code&gt;美团/美团.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;题目变体：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;线程创建的方法有哪些？&lt;code&gt;Runnable&lt;/code&gt; 和 &lt;code&gt;Callable&lt;/code&gt; 相比有什么区别？&lt;/li&gt;
&lt;li&gt;实现 &lt;code&gt;Runnable&lt;/code&gt; 接口创建线程和实现 &lt;code&gt;Callable&lt;/code&gt; 接口创建线程有什么区别？哪一种接口可以拿到执行结果？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;继承 &lt;code&gt;Thread&lt;/code&gt; 类&lt;/p&gt;
&lt;p&gt;可以自定义类继承 &lt;code&gt;Thread&lt;/code&gt;，重写 &lt;code&gt;run()&lt;/code&gt; 方法，然后调用 &lt;code&gt;start()&lt;/code&gt; 启动线程。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println(&quot;线程执行&quot;);
    }
}

new MyThread().start();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种方式简单，但 Java 是单继承，继承了 &lt;code&gt;Thread&lt;/code&gt; 后就不能再继承其他类。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;实现 &lt;code&gt;Runnable&lt;/code&gt; 接口&lt;/p&gt;
&lt;p&gt;可以实现 &lt;code&gt;Runnable&lt;/code&gt; 接口，把任务逻辑写在 &lt;code&gt;run()&lt;/code&gt; 方法里，再交给 &lt;code&gt;Thread&lt;/code&gt; 执行。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println(&quot;线程执行&quot;);
    }
}

new Thread(new MyRunnable()).start();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种方式更常用，因为任务和线程本身分离，也不会占用类的继承位置。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;实现 &lt;code&gt;Callable&lt;/code&gt; 接口&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Callable&lt;/code&gt; 和 &lt;code&gt;Runnable&lt;/code&gt; 类似，也是定义线程任务。&lt;/p&gt;
&lt;p&gt;区别是 &lt;code&gt;Callable&lt;/code&gt; 有返回值，也可以抛异常。通常配合 &lt;code&gt;FutureTask&lt;/code&gt; 或线程池使用。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Callable&amp;lt;Integer&amp;gt; callable = () -&amp;gt; {
    return 1;
};

FutureTask&amp;lt;Integer&amp;gt; task = new FutureTask&amp;lt;&amp;gt;(callable);
new Thread(task).start();

Integer result = task.get();
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用线程池&lt;/p&gt;
&lt;p&gt;实际开发中更推荐使用线程池，比如 &lt;code&gt;ThreadPoolExecutor&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;线程池可以复用线程，避免频繁创建和销毁线程，也方便控制并发数量。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ExecutorService executor = Executors.newFixedThreadPool(5);

executor.submit(() -&amp;gt; {
    System.out.println(&quot;线程池执行任务&quot;);
});

executor.shutdown();
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Runnable&lt;/code&gt; 和 &lt;code&gt;Callable&lt;/code&gt; 的区别&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Runnable&lt;/code&gt; 的方法是 &lt;code&gt;run()&lt;/code&gt;，没有返回值，不能直接抛出受检异常。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;void run();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Callable&lt;/code&gt; 的方法是 &lt;code&gt;call()&lt;/code&gt;，有返回值，可以抛出异常。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;V call() throws Exception;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以如果只是执行一个任务，不关心结果，用 &lt;code&gt;Runnable&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;如果需要拿到执行结果，或者需要把异常抛给调用方处理，用 &lt;code&gt;Callable&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;&lt;code&gt;ThreadLocal&lt;/code&gt; 是什么？有什么问题？如何避免内存泄露？&lt;/h3&gt;
&lt;p&gt;来源：京东面试:508（&lt;code&gt;京东面试/京东面试.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;ThreadLocal&lt;/code&gt; 是什么&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ThreadLocal&lt;/code&gt; 可以理解成线程本地变量。&lt;/p&gt;
&lt;p&gt;它让每个线程都保存一份自己的变量副本，线程之间互不影响。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;private static final ThreadLocal&amp;lt;String&amp;gt; USER_ID = new ThreadLocal&amp;lt;&amp;gt;();

USER_ID.set(&quot;1001&quot;);
String userId = USER_ID.get();
USER_ID.remove();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比如在 Web 项目中，可以把当前登录用户 ID、请求上下文、链路追踪 ID 放到 &lt;code&gt;ThreadLocal&lt;/code&gt; 中，这样同一个线程里的不同方法都可以直接获取。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;底层原理&lt;/p&gt;
&lt;p&gt;每个 &lt;code&gt;Thread&lt;/code&gt; 对象里面都有一个 &lt;code&gt;ThreadLocalMap&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ThreadLocal&lt;/code&gt; 存数据时，实际是存到当前线程自己的 &lt;code&gt;ThreadLocalMap&lt;/code&gt; 里。&lt;/p&gt;
&lt;p&gt;可以简单理解成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Thread
  ThreadLocalMap
    key: ThreadLocal 对象
    value: 线程自己的变量值
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以不同线程使用同一个 &lt;code&gt;ThreadLocal&lt;/code&gt;，取到的值也不一样，因为它们各自访问的是自己线程里的 &lt;code&gt;ThreadLocalMap&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;有什么问题&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ThreadLocal&lt;/code&gt; 最大的问题是可能导致内存泄露。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ThreadLocalMap&lt;/code&gt; 里的 key 是弱引用，key 指向 &lt;code&gt;ThreadLocal&lt;/code&gt; 对象。&lt;/p&gt;
&lt;p&gt;但是 value 是强引用，指向真正保存的数据。&lt;/p&gt;
&lt;p&gt;如果 &lt;code&gt;ThreadLocal&lt;/code&gt; 对象已经没有外部强引用了，key 可能会被 GC 回收，变成 &lt;code&gt;null&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;但是 value 还挂在线程的 &lt;code&gt;ThreadLocalMap&lt;/code&gt; 里。如果这个线程一直不销毁，比如线程池里的线程，就可能导致 value 长时间无法释放。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;线程池里的问题更明显&lt;/p&gt;
&lt;p&gt;在线程池中，线程会被复用。&lt;/p&gt;
&lt;p&gt;如果一次请求设置了 &lt;code&gt;ThreadLocal&lt;/code&gt;，请求结束后没有清理，那么这个值可能会残留在线程里。&lt;/p&gt;
&lt;p&gt;下一个请求复用同一个线程时，可能读到上一个请求的数据，造成数据污染。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;如何避免&lt;/p&gt;
&lt;p&gt;使用完 &lt;code&gt;ThreadLocal&lt;/code&gt; 后，一定要在 &lt;code&gt;finally&lt;/code&gt; 中调用 &lt;code&gt;remove()&lt;/code&gt; 清理。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;try {
    USER_ID.set(&quot;1001&quot;);
    // 业务逻辑
} finally {
    USER_ID.remove();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果是在 Web 项目中，可以在过滤器、拦截器或者 AOP 里统一清理。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用场景&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ThreadLocal&lt;/code&gt; 适合保存线程级别的上下文，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当前登录用户信息。&lt;/li&gt;
&lt;li&gt;请求 ID。&lt;/li&gt;
&lt;li&gt;链路追踪 traceId。&lt;/li&gt;
&lt;li&gt;数据源上下文。&lt;/li&gt;
&lt;li&gt;事务上下文。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但是不要把大对象、连接对象或者生命周期很长的数据随便放进去，用完必须清理。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;请说明 Java 中 synchronized 的锁升级过程&lt;/h3&gt;
&lt;p&gt;来源：携程二面:1017（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;synchronized&lt;/code&gt; 是 Java 里的内置锁。JVM 为了减少加锁开销，对 &lt;code&gt;synchronized&lt;/code&gt; 做了锁优化，它的锁状态会随着竞争程度逐步升级。&lt;/p&gt;
&lt;p&gt;锁升级的大致过程是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;无锁 -&amp;gt; 偏向锁 -&amp;gt; 轻量级锁 -&amp;gt; 重量级锁
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;无锁状态&lt;/p&gt;
&lt;p&gt;对象刚创建时，锁标记处于无锁状态。&lt;/p&gt;
&lt;p&gt;对象头里的 Mark Word 会保存对象的 hashCode、分代年龄、锁标志位等信息。&lt;/p&gt;
&lt;p&gt;如果没有线程进入同步代码块，就不需要加锁。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;偏向锁&lt;/p&gt;
&lt;p&gt;偏向锁的目标是优化“只有一个线程反复进入同步代码块”的场景。&lt;/p&gt;
&lt;p&gt;第一个线程进入同步代码块时，JVM 会把这个线程 ID 记录到对象头 Mark Word 里。&lt;/p&gt;
&lt;p&gt;后续如果还是同一个线程进入，就只需要判断对象头里的线程 ID 是不是自己，不需要 CAS 竞争，开销很低。&lt;/p&gt;
&lt;p&gt;如果有其他线程来竞争这把锁，偏向锁就会被撤销，升级为轻量级锁。&lt;/p&gt;
&lt;p&gt;注意：JDK 15 之后偏向锁被废弃，JDK 18 已经移除偏向锁相关逻辑，但锁升级问题通常还是会问这个过程。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;轻量级锁&lt;/p&gt;
&lt;p&gt;轻量级锁适合“多个线程交替进入同步代码块，但竞争不激烈”的场景。&lt;/p&gt;
&lt;p&gt;线程进入同步代码块时，会在自己的栈帧里创建 Lock Record，把对象头 Mark Word 复制进去。&lt;/p&gt;
&lt;p&gt;然后通过 CAS 尝试把对象头 Mark Word 替换成指向自己 Lock Record 的指针。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CAS 成功：线程获得轻量级锁。&lt;/li&gt;
&lt;li&gt;CAS 失败：说明有其他线程竞争。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果竞争线程短时间内能拿到锁，会通过自旋等待，避免直接阻塞线程。&lt;/p&gt;
&lt;p&gt;如果自旋多次仍然失败，说明竞争比较激烈，轻量级锁会膨胀为重量级锁。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;重量级锁&lt;/p&gt;
&lt;p&gt;重量级锁适合竞争激烈的场景。&lt;/p&gt;
&lt;p&gt;当锁升级为重量级锁后，对象头 Mark Word 会指向一个 Monitor 对象。&lt;/p&gt;
&lt;p&gt;Monitor 里面会维护锁的持有者、等待队列、阻塞队列等信息。&lt;/p&gt;
&lt;p&gt;没有抢到锁的线程会被阻塞，进入操作系统层面的线程挂起和唤醒。&lt;/p&gt;
&lt;p&gt;这种方式避免了线程一直自旋浪费 CPU，但线程阻塞和唤醒需要从用户态切换到内核态，开销比较大。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;锁升级特点&lt;/p&gt;
&lt;p&gt;&lt;code&gt;synchronized&lt;/code&gt; 的锁升级通常是单向的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;偏向锁 -&amp;gt; 轻量级锁 -&amp;gt; 重量级锁
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一般不会从重量级锁再降回轻量级锁。&lt;/p&gt;
&lt;p&gt;JVM 这样设计是为了在不同竞争强度下选择合适的加锁方式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;没有竞争：偏向锁，减少 CAS。&lt;/li&gt;
&lt;li&gt;轻微竞争：轻量级锁，通过 CAS 和自旋避免阻塞。&lt;/li&gt;
&lt;li&gt;激烈竞争：重量级锁，阻塞线程，避免空转浪费 CPU。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;synchronized&lt;/code&gt; 的锁升级过程大致是无锁、偏向锁、轻量级锁、重量级锁。偏向锁适合单线程反复进入同步块，会把线程 ID 记录在对象头里；出现其他线程竞争后撤销偏向锁，升级为轻量级锁；轻量级锁通过栈帧里的 Lock Record 和 CAS 获取锁，竞争不激烈时通过自旋等待；如果竞争激烈，自旋失败，就膨胀为重量级锁，底层通过 Monitor 管理等待线程。这样 JVM 可以根据竞争程度在加锁开销和线程阻塞之间做平衡。&lt;/p&gt;
&lt;h3&gt;synchronized 升级成重量级锁后，底层的数据结构是怎么样实现的？&lt;/h3&gt;
&lt;p&gt;来源：携程二面:1091（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;h3&gt;请说明 AQS（重复 3 次）&lt;/h3&gt;
&lt;p&gt;来源：携程二面:1092（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）；携程二面:1439（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）；美团3:11（&lt;code&gt;美团3/美团3.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;题目变体：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;请说明 AQS。&lt;/li&gt;
&lt;li&gt;请介绍 AQS 的原理。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;AQS 全称是 AbstractQueuedSynchronizer，是 Java 并发包里很多锁和同步器的基础框架，比如 &lt;code&gt;ReentrantLock&lt;/code&gt;、&lt;code&gt;Semaphore&lt;/code&gt;、&lt;code&gt;CountDownLatch&lt;/code&gt;、&lt;code&gt;ReentrantReadWriteLock&lt;/code&gt; 底层都用到了 AQS。&lt;/p&gt;
&lt;p&gt;AQS 的核心思想是：用一个 &lt;code&gt;state&lt;/code&gt; 表示同步状态，用一个 FIFO 队列管理获取锁失败后需要等待的线程。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;state 同步状态&lt;/p&gt;
&lt;p&gt;AQS 里有一个 &lt;code&gt;volatile int state&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这个 &lt;code&gt;state&lt;/code&gt; 的含义由具体同步器决定。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ReentrantLock&lt;/code&gt; 里，&lt;code&gt;state=0&lt;/code&gt; 表示锁空闲，&lt;code&gt;state&amp;gt;0&lt;/code&gt; 表示锁被持有，数值表示重入次数。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Semaphore&lt;/code&gt; 里，&lt;code&gt;state&lt;/code&gt; 表示剩余许可证数量。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CountDownLatch&lt;/code&gt; 里，&lt;code&gt;state&lt;/code&gt; 表示计数器还剩多少。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;AQS 通过 CAS 修改 &lt;code&gt;state&lt;/code&gt;，保证并发修改的原子性。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;CLH 队列&lt;/p&gt;
&lt;p&gt;如果线程尝试获取锁失败，就会被封装成一个 Node 节点，加入 AQS 的同步队列。&lt;/p&gt;
&lt;p&gt;这个队列是一个变体的 CLH FIFO 双向队列。&lt;/p&gt;
&lt;p&gt;队列里的节点大致保存：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当前线程。&lt;/li&gt;
&lt;li&gt;前驱节点。&lt;/li&gt;
&lt;li&gt;后继节点。&lt;/li&gt;
&lt;li&gt;等待状态。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;排队的线程会被 &lt;code&gt;LockSupport.park()&lt;/code&gt; 挂起，等前驱节点释放锁后，再被唤醒继续竞争。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;获取锁流程&lt;/p&gt;
&lt;p&gt;以独占锁为例，比如 &lt;code&gt;ReentrantLock&lt;/code&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;线程先尝试通过 CAS 修改 &lt;code&gt;state&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;如果 &lt;code&gt;state=0&lt;/code&gt;，CAS 成功，说明拿到锁。&lt;/li&gt;
&lt;li&gt;如果锁已经被其他线程持有，获取失败。&lt;/li&gt;
&lt;li&gt;获取失败后，当前线程进入 AQS 队列排队。&lt;/li&gt;
&lt;li&gt;排队后，线程会被挂起，等待前驱节点释放锁后唤醒。&lt;/li&gt;
&lt;li&gt;被唤醒后再次尝试获取锁。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;释放锁流程&lt;/p&gt;
&lt;p&gt;持有锁的线程释放锁时，会修改 &lt;code&gt;state&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;如果释放后 &lt;code&gt;state=0&lt;/code&gt;，说明锁真正空闲。&lt;/p&gt;
&lt;p&gt;然后 AQS 会唤醒队列里的后继节点，让它继续尝试获取锁。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;AQS 的作用&lt;/p&gt;
&lt;p&gt;AQS 把通用的排队、阻塞、唤醒、CAS 修改状态这些逻辑封装好了。&lt;/p&gt;
&lt;p&gt;具体同步器只需要实现几个模板方法，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;tryAcquire&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tryRelease&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tryAcquireShared&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tryReleaseShared&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样不同锁可以复用同一套同步队列和线程调度机制。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;AQS 是 Java 并发包的基础同步框架，核心是 &lt;code&gt;state&lt;/code&gt; 加 CLH 队列。&lt;code&gt;state&lt;/code&gt; 表示同步状态，通过 CAS 修改；获取失败的线程会被封装成 Node 放入 FIFO 队列，并通过 &lt;code&gt;LockSupport.park()&lt;/code&gt; 挂起；释放锁时再通过 &lt;code&gt;unpark()&lt;/code&gt; 唤醒后继节点。像 &lt;code&gt;ReentrantLock&lt;/code&gt;、&lt;code&gt;Semaphore&lt;/code&gt;、&lt;code&gt;CountDownLatch&lt;/code&gt; 都是基于它实现的。&lt;/p&gt;
&lt;h3&gt;ReentrantLock 是如何实现可重入的？&lt;/h3&gt;
&lt;p&gt;来源：携程二面:1440（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ReentrantLock&lt;/code&gt; 的可重入是基于 AQS 的 &lt;code&gt;state&lt;/code&gt; 和当前持锁线程来实现的。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;state 表示重入次数&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ReentrantLock&lt;/code&gt; 底层基于 AQS。&lt;/p&gt;
&lt;p&gt;AQS 里有一个 &lt;code&gt;volatile int state&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;在 &lt;code&gt;ReentrantLock&lt;/code&gt; 里，&lt;code&gt;state&lt;/code&gt; 表示锁被当前线程重入的次数：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;state = 0&lt;/code&gt;：锁没有被占用。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;state = 1&lt;/code&gt;：锁被某个线程持有了一次。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;state &amp;gt; 1&lt;/code&gt;：同一个线程重复获取了这把锁。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;第一次加锁&lt;/p&gt;
&lt;p&gt;当线程第一次调用 &lt;code&gt;lock()&lt;/code&gt; 时，如果锁空闲，也就是 &lt;code&gt;state = 0&lt;/code&gt;，线程会通过 CAS 把 &lt;code&gt;state&lt;/code&gt; 从 0 改成 1。&lt;/p&gt;
&lt;p&gt;CAS 成功后，AQS 会把当前线程设置为锁的持有者，也就是 owner。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;同一线程再次加锁&lt;/p&gt;
&lt;p&gt;如果当前线程再次调用 &lt;code&gt;lock()&lt;/code&gt;，AQS 会发现锁已经被占用。&lt;/p&gt;
&lt;p&gt;这时它会判断当前线程是不是 owner。&lt;/p&gt;
&lt;p&gt;如果当前线程就是 owner，说明是重入，不需要阻塞，直接把 &lt;code&gt;state + 1&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;释放锁&lt;/p&gt;
&lt;p&gt;每次调用 &lt;code&gt;unlock()&lt;/code&gt;，都会让 &lt;code&gt;state - 1&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;如果 &lt;code&gt;state&lt;/code&gt; 还大于 0，说明当前线程只是释放了一层重入，锁仍然由它持有。&lt;/p&gt;
&lt;p&gt;只有当 &lt;code&gt;state&lt;/code&gt; 减到 0，才表示锁完全释放。&lt;/p&gt;
&lt;p&gt;这时 AQS 会清空 owner，并唤醒等待队列里的后继线程。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;为什么必须 unlock 对应次数&lt;/p&gt;
&lt;p&gt;因为每次重入都会让 &lt;code&gt;state + 1&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;所以加锁几次，就必须释放几次。&lt;/p&gt;
&lt;p&gt;如果少释放一次，&lt;code&gt;state&lt;/code&gt; 不能归零，锁就不会真正释放，其他线程会一直拿不到锁。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;ReentrantLock&lt;/code&gt; 的可重入是通过 AQS 的 &lt;code&gt;state&lt;/code&gt; 实现的。第一次加锁时，线程通过 CAS 把 &lt;code&gt;state&lt;/code&gt; 从 0 改成 1，并设置 owner 为当前线程；同一个线程再次加锁时，发现 owner 是自己，就直接让 &lt;code&gt;state + 1&lt;/code&gt;，不会阻塞；释放锁时每次 &lt;code&gt;state - 1&lt;/code&gt;，只有减到 0 才真正释放锁并唤醒等待线程。所以加锁几次就要解锁几次。&lt;/p&gt;
&lt;h3&gt;为什么要实现锁的可重入特性？&lt;/h3&gt;
&lt;p&gt;来源：携程二面:1489（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;锁的可重入主要是为了避免同一个线程重复获取同一把锁时把自己阻塞住，同时也让同步方法之间的相互调用更加自然。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;避免自己锁死自己&lt;/p&gt;
&lt;p&gt;如果锁不可重入，同一个线程已经拿到锁后，再次进入需要同一把锁的代码，就会被阻塞。&lt;/p&gt;
&lt;p&gt;但阻塞的线程正是当前持锁线程，它自己不继续执行就无法释放锁，于是会造成死锁。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public synchronized void methodA() {
    methodB();
}

public synchronized void methodB() {
    // do something
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;methodA()&lt;/code&gt; 和 &lt;code&gt;methodB()&lt;/code&gt; 都需要同一个对象锁。&lt;/p&gt;
&lt;p&gt;如果 &lt;code&gt;synchronized&lt;/code&gt; 不可重入，线程进入 &lt;code&gt;methodA()&lt;/code&gt; 后已经持有对象锁，再调用 &lt;code&gt;methodB()&lt;/code&gt; 时会再次申请同一把锁，然后把自己阻塞住。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;支持同步方法之间的嵌套调用&lt;/p&gt;
&lt;p&gt;实际业务代码里，一个同步方法调用另一个同步方法很常见。&lt;/p&gt;
&lt;p&gt;可重入锁允许同一个线程多次进入同一把锁保护的代码块，只要记录重入次数即可。&lt;/p&gt;
&lt;p&gt;这样代码可以按正常业务逻辑拆分方法，不需要为了避免重复加锁把逻辑写成一大块。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;支持递归调用&lt;/p&gt;
&lt;p&gt;如果一个同步方法里存在递归调用，也需要可重入。&lt;/p&gt;
&lt;p&gt;同一个线程每递归一层，都会再次进入同一个同步代码块。&lt;/p&gt;
&lt;p&gt;如果锁不可重入，递归第二次进入时就会阻塞自己。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;让锁的语义更符合线程所有权&lt;/p&gt;
&lt;p&gt;可重入锁的语义是：如果当前线程已经拥有这把锁，那么它可以再次进入这把锁保护的区域。&lt;/p&gt;
&lt;p&gt;锁内部只需要维护一个重入计数。&lt;/p&gt;
&lt;p&gt;每次进入时计数加 1，每次退出时计数减 1，只有计数归零时才真正释放锁。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;实现锁的可重入，主要是为了避免同一个线程重复申请同一把锁时发生自我死锁。它支持同步方法之间的嵌套调用和递归调用，让代码结构更自然。底层一般通过“持锁线程 + 重入计数”实现：同一个线程再次加锁时只增加计数，不阻塞；释放时计数递减，直到计数为 0 才真正释放锁。&lt;/p&gt;
&lt;h3&gt;线程的创建方式有哪些？&lt;/h3&gt;
&lt;p&gt;来源：美团:38（&lt;code&gt;美团/美团.md&lt;/code&gt;）&lt;/p&gt;
&lt;h3&gt;一般线程池通过什么方式来创建？线程池有哪些核心参数？（重复 2 次）&lt;/h3&gt;
&lt;p&gt;来源：美团:1346（&lt;code&gt;美团/美团.md&lt;/code&gt;）；美团2:560（&lt;code&gt;美团2/美团2.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;线程池一般推荐通过 &lt;code&gt;ThreadPoolExecutor&lt;/code&gt; 手动创建，不建议直接使用 &lt;code&gt;Executors&lt;/code&gt; 的快捷方法。因为 &lt;code&gt;Executors&lt;/code&gt; 有些默认参数不太安全，比如无界队列、最大线程数过大，任务量大时可能导致内存占用过高或者线程数失控。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;线程池创建方式&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ThreadPoolExecutor executor = new ThreadPoolExecutor(
    10,
    20,
    60L,
    TimeUnit.SECONDS,
    new ArrayBlockingQueue&amp;lt;&amp;gt;(1000),
    new ThreadFactory() {
        @Override
        public Thread newThread(Runnable r) {
            return new Thread(r, &quot;biz-thread-&quot; + r.hashCode());
        }
    },
    new ThreadPoolExecutor.CallerRunsPolicy()
);
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;核心参数&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ThreadPoolExecutor&lt;/code&gt; 主要有 7 个核心参数。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;corePoolSize&lt;/code&gt;：核心线程数。任务来了以后，如果当前线程数小于核心线程数，会优先创建核心线程执行任务。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;maximumPoolSize&lt;/code&gt;：最大线程数。当核心线程都忙，任务队列也满了，线程池才会继续创建非核心线程，直到达到最大线程数。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;keepAliveTime&lt;/code&gt;：空闲线程存活时间。非核心线程空闲超过这个时间后会被回收。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;unit&lt;/code&gt;：时间单位，配合 &lt;code&gt;keepAliveTime&lt;/code&gt; 使用，比如秒、毫秒、分钟。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;workQueue&lt;/code&gt;：任务队列。当核心线程都在忙时，新任务会进入任务队列等待。&lt;/p&gt;
&lt;p&gt;常见队列有：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ArrayBlockingQueue：有界队列
LinkedBlockingQueue：链表队列，可以有界也可以无界
SynchronousQueue：不存任务，直接交给线程执行
PriorityBlockingQueue：优先级队列
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;实际开发一般建议使用有界队列，避免任务无限堆积。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;threadFactory&lt;/code&gt;：线程工厂。用来创建线程，可以设置线程名称、是否守护线程、异常处理等。设置线程名称很重要，方便排查问题。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;handler&lt;/code&gt;：拒绝策略。当线程数达到最大线程数，并且队列也满了，就会触发拒绝策略。&lt;/p&gt;
&lt;p&gt;常见拒绝策略：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;AbortPolicy：直接抛异常，默认策略
CallerRunsPolicy：由提交任务的线程自己执行
DiscardPolicy：直接丢弃任务，不抛异常
DiscardOldestPolicy：丢弃队列里最旧的任务
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;线程池扩容策略&lt;/p&gt;
&lt;p&gt;线程池不是一开始就直接创建到最大线程数，而是按下面这个顺序处理任务：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;提交任务
-&amp;gt; 当前线程数 &amp;lt; corePoolSize，创建核心线程执行
-&amp;gt; 当前线程数 &amp;gt;= corePoolSize，任务进入队列
-&amp;gt; 队列满了，并且当前线程数 &amp;lt; maximumPoolSize，创建非核心线程执行
-&amp;gt; 当前线程数 &amp;gt;= maximumPoolSize，队列也满了，触发拒绝策略
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说，线程池的扩容顺序是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;先创建核心线程
-&amp;gt; 核心线程满了先排队
-&amp;gt; 队列满了再创建非核心线程
-&amp;gt; 最大线程数也满了再拒绝
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个点很容易被问到：线程池不是核心线程满了就马上扩容到最大线程数，而是先把任务放进队列，队列满了才会继续创建非核心线程。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;线程回收&lt;/p&gt;
&lt;p&gt;当任务高峰过去后，非核心线程如果空闲时间超过 &lt;code&gt;keepAliveTime&lt;/code&gt;，就会被回收。&lt;/p&gt;
&lt;p&gt;默认情况下，核心线程不会被回收。&lt;/p&gt;
&lt;p&gt;但如果调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;allowCoreThreadTimeOut(true);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;核心线程空闲超过 &lt;code&gt;keepAliveTime&lt;/code&gt; 后也可以被回收。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;实际开发怎么设置&lt;/p&gt;
&lt;p&gt;一般会根据业务类型设置线程池：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CPU 密集型任务：线程数接近 CPU 核心数。&lt;/li&gt;
&lt;li&gt;IO 密集型任务：线程数可以适当大一些，因为很多时间在等待 IO。&lt;/li&gt;
&lt;li&gt;队列建议用有界队列。&lt;/li&gt;
&lt;li&gt;线程名要有业务含义，比如 &lt;code&gt;order-thread-1&lt;/code&gt;、&lt;code&gt;pay-thread-1&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;拒绝策略要结合业务选择，不能随便丢任务。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：线程池一般推荐通过 &lt;code&gt;ThreadPoolExecutor&lt;/code&gt; 手动创建。核心参数有 &lt;code&gt;corePoolSize&lt;/code&gt;、&lt;code&gt;maximumPoolSize&lt;/code&gt;、&lt;code&gt;keepAliveTime&lt;/code&gt;、&lt;code&gt;unit&lt;/code&gt;、&lt;code&gt;workQueue&lt;/code&gt;、&lt;code&gt;threadFactory&lt;/code&gt;、&lt;code&gt;handler&lt;/code&gt;。线程池处理任务时，先创建核心线程；核心线程满了，任务先进入队列；队列满了，才创建非核心线程；线程数达到 &lt;code&gt;maximumPoolSize&lt;/code&gt; 且队列也满了，就触发拒绝策略。高峰过后，非核心线程空闲超过 &lt;code&gt;keepAliveTime&lt;/code&gt; 会被回收。&lt;/p&gt;
&lt;h3&gt;谈谈线程池工作的流程&lt;/h3&gt;
&lt;p&gt;来源：美团:41（&lt;code&gt;美团/美团.md&lt;/code&gt;）&lt;/p&gt;
&lt;h3&gt;核心线程是一开始就创建，还是任务来了才创建？&lt;/h3&gt;
&lt;p&gt;来源：美团:1459（&lt;code&gt;美团/美团.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;默认情况下，核心线程不是线程池创建时就全部创建好的，而是任务来了之后才逐步创建。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;corePoolSize&lt;/code&gt; 只是表示线程池最多保留多少个核心线程，不代表线程池一创建就马上创建这么多线程。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;corePoolSize = 10
maximumPoolSize = 20
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;线程池刚创建出来时，线程数通常是 0。&lt;/p&gt;
&lt;p&gt;第 1 个任务来了，创建第 1 个核心线程。&lt;/p&gt;
&lt;p&gt;第 2 个任务来了，如果当前核心线程还没达到 &lt;code&gt;corePoolSize&lt;/code&gt;，继续创建核心线程。&lt;/p&gt;
&lt;p&gt;一直到核心线程数达到 &lt;code&gt;corePoolSize&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;达到 &lt;code&gt;corePoolSize&lt;/code&gt; 之后，如果再来任务，就优先进入队列。&lt;/p&gt;
&lt;p&gt;核心线程的特点是：创建之后默认不会因为空闲被回收。&lt;/p&gt;
&lt;p&gt;所以完整理解是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;核心线程默认按需创建
创建后默认长期保留
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果想线程池一启动就创建核心线程，可以手动调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;prestartCoreThread()
prestartAllCoreThreads()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果想让核心线程空闲时也能回收，可以调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;allowCoreThreadTimeOut(true)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;总结：核心线程默认不是线程池创建时就创建好的，而是任务提交后按需创建，直到达到 &lt;code&gt;corePoolSize&lt;/code&gt;。可以通过 &lt;code&gt;prestartCoreThread&lt;/code&gt; 或 &lt;code&gt;prestartAllCoreThreads&lt;/code&gt; 提前创建核心线程。核心线程默认不会因为空闲被回收，但如果设置 &lt;code&gt;allowCoreThreadTimeOut(true)&lt;/code&gt;，核心线程空闲超过 &lt;code&gt;keepAliveTime&lt;/code&gt; 后也可以被回收。&lt;/p&gt;
&lt;h3&gt;为什么需要线程池？为什么线程切换有开销？&lt;/h3&gt;
&lt;p&gt;来源：京东面试二&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;线程池的作用包括：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;复用线程，减少频繁创建和销毁线程带来的栈内存、内核线程和调度资源开销。&lt;/li&gt;
&lt;li&gt;控制并发量，避免大量请求同时访问 MySQL、Redis 或第三方接口，保护下游。&lt;/li&gt;
&lt;li&gt;通过有界队列缓冲短时突发流量，通过拒绝策略在系统饱和时快速失败或降级。&lt;/li&gt;
&lt;li&gt;便于监控活跃线程数、队列长度、任务耗时和拒绝次数，进行容量治理。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;线程切换时，操作系统调度器需要保存当前线程的寄存器、程序计数器和栈指针等 CPU 上下文，选择下一个可运行线程，再恢复目标线程的上下文。&lt;/p&gt;
&lt;p&gt;切换后，前一个线程使用的 CPU Cache 数据和指令可能对新线程无效，导致 Cache 命中率下降、重新加载数据。线程过多会带来更多上下文切换，CPU 实际用于业务计算的时间减少，最终表现为 RT 上升、吞吐下降。&lt;/p&gt;
&lt;p&gt;生产环境通常使用 &lt;code&gt;ThreadPoolExecutor&lt;/code&gt; 和有界队列。无界队列会让任务在核心线程繁忙后持续排队，&lt;code&gt;maximumPoolSize&lt;/code&gt; 基本失去作用，并可能导致内存压力。&lt;/p&gt;
&lt;p&gt;总结：线程池通过复用线程、限制并发、缓冲突发和拒绝过载任务提升稳定性；线程数需要控制，避免调度和 Cache 开销抵消并发收益。&lt;/p&gt;
&lt;h3&gt;如何确定线程池的线程数？&lt;/h3&gt;
&lt;p&gt;来源：京东面试二&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;线程数不能只靠一个固定公式，先看任务是 CPU 密集、IO 密集还是混合型，再结合下游容量和压测结果确定。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;CPU 密集型任务，例如加密、压缩、图片处理、复杂计算，线程数通常接近 CPU 核数或 CPU 核数加 1。线程过多会增加上下文切换和 CPU Cache 失效。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;IO 密集型任务，例如调用 MySQL、Redis、HTTP 接口、文件 IO，线程数可以更多。常用估算是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;线程数 ≈ CPU 核数 × (1 + 等待时间 / 计算时间)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;例如 &lt;code&gt;8&lt;/code&gt; 核机器，任务平均计算 &lt;code&gt;10 ms&lt;/code&gt;、等待 IO &lt;code&gt;40 ms&lt;/code&gt;，估算起点约为 &lt;code&gt;8 × (1 + 40 / 10) = 40&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;下游容量是上限约束。任务需要访问数据库时，线程数还要受数据库连接池大小限制。数据库连接池最大为 &lt;code&gt;20&lt;/code&gt; 时，线程池设置为 &lt;code&gt;100&lt;/code&gt; 会导致大量线程等待连接，增加 RT 和上下文切换。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;混合型业务应隔离线程池，例如订单核心线程池、外部 HTTP 调用线程池、MQ 消费线程池、定时任务线程池，避免慢任务耗尽核心线程池。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;最终以压测为准，重点观察 CPU、P99 RT、活跃线程数、队列积压、拒绝次数、上下文切换，以及 MySQL、Redis、HTTP 连接池使用率和下游 RT。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：CPU 密集型线程数接近 CPU 核数；IO 密集型可根据等待时间适当增加；最终必须受下游容量、队列长度和压测结果约束。&lt;/p&gt;
&lt;h2&gt;Java 基础与集合&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;==&lt;/code&gt; 和 &lt;code&gt;equals&lt;/code&gt; 的区别&lt;/h3&gt;
&lt;p&gt;来源：京东面试:10（&lt;code&gt;京东面试/京东面试.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;==&lt;/code&gt; 和 &lt;code&gt;equals&lt;/code&gt; 的区别，主要看比较的是基本数据类型还是引用类型。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;==&lt;/code&gt; 的作用&lt;/p&gt;
&lt;p&gt;如果比较的是基本数据类型，&lt;code&gt;==&lt;/code&gt; 比较的是具体的值。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;int a = 10;
int b = 10;
System.out.println(a == b); // true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果比较的是引用类型，&lt;code&gt;==&lt;/code&gt; 比较的是两个引用是否指向同一个对象，也就是对象地址是否相同。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;User u1 = new User();
User u2 = new User();
System.out.println(u1 == u2); // false
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;equals&lt;/code&gt; 的作用&lt;/p&gt;
&lt;p&gt;&lt;code&gt;equals&lt;/code&gt; 是 &lt;code&gt;Object&lt;/code&gt; 类提供的方法，默认实现和 &lt;code&gt;==&lt;/code&gt; 类似，也是比较对象地址。&lt;/p&gt;
&lt;p&gt;但是很多类会重写 &lt;code&gt;equals&lt;/code&gt; 方法，用来比较对象内容。比如 &lt;code&gt;String&lt;/code&gt;、&lt;code&gt;Integer&lt;/code&gt; 这类常见类型，&lt;code&gt;equals&lt;/code&gt; 比较的是内容值。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String a = new String(&quot;abc&quot;);
String b = new String(&quot;abc&quot;);

System.out.println(a == b);      // false
System.out.println(a.equals(b)); // true
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;String&lt;/code&gt; 的特殊情况&lt;/p&gt;
&lt;p&gt;字符串字面量会进入字符串常量池。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String s1 = &quot;abc&quot;;
String s2 = &quot;abc&quot;;
System.out.println(s1 == s2); // true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;new String(&quot;abc&quot;)&lt;/code&gt; 中的 &lt;code&gt;&quot;abc&quot;&lt;/code&gt; 会放在字符串常量池中，但 &lt;code&gt;new&lt;/code&gt; 会额外创建堆对象，变量最终指向堆对象。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String s1 = &quot;abc&quot;;
String s2 = new String(&quot;abc&quot;);

System.out.println(s1 == s2);      // false
System.out.println(s1.equals(s2)); // true
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;&lt;code&gt;String&lt;/code&gt;、&lt;code&gt;StringBuilder&lt;/code&gt;、&lt;code&gt;StringBuffer&lt;/code&gt; 的区别&lt;/h3&gt;
&lt;p&gt;来源：京东面试:68（&lt;code&gt;京东面试/京东面试.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;String&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;String&lt;/code&gt; 是不可变对象，一旦创建，内容就不能被修改。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String s = &quot;abc&quot;;
s = s + &quot;def&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里看起来是修改了 &lt;code&gt;s&lt;/code&gt;，实际是创建了一个新的字符串对象，然后让 &lt;code&gt;s&lt;/code&gt; 指向新对象。原来的 &lt;code&gt;&quot;abc&quot;&lt;/code&gt; 没有被修改。&lt;/p&gt;
&lt;p&gt;所以如果在循环里频繁拼接字符串，用 &lt;code&gt;String&lt;/code&gt; 会产生很多临时对象，性能较差。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;StringBuilder&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;StringBuilder&lt;/code&gt; 是可变的字符串对象，底层维护的是一个可以扩容的字符数组。&lt;/p&gt;
&lt;p&gt;它适合在单线程场景下频繁拼接字符串。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;StringBuilder sb = new StringBuilder();
sb.append(&quot;abc&quot;);
sb.append(&quot;def&quot;);
System.out.println(sb.toString());
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;StringBuilder&lt;/code&gt; 没有加锁，线程不安全，但性能比较高。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;StringBuffer&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;StringBuffer&lt;/code&gt; 也是可变的字符串对象，用法和 &lt;code&gt;StringBuilder&lt;/code&gt; 类似。&lt;/p&gt;
&lt;p&gt;区别是 &lt;code&gt;StringBuffer&lt;/code&gt; 的很多方法加了 &lt;code&gt;synchronized&lt;/code&gt;，所以它是线程安全的。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;StringBuffer sb = new StringBuffer();
sb.append(&quot;abc&quot;);
sb.append(&quot;def&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为加锁会有额外开销，所以单线程场景下通常不用 &lt;code&gt;StringBuffer&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用场景&lt;/p&gt;
&lt;p&gt;&lt;code&gt;String&lt;/code&gt; 适合字符串内容固定、修改少的场景，比如常量、配置值、普通字段。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;StringBuilder&lt;/code&gt; 适合单线程下频繁拼接字符串，是实际开发中最常用的拼接工具。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;StringBuffer&lt;/code&gt; 适合多线程共享同一个字符串对象并且需要保证线程安全的场景，不过现在这种场景相对少见。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;&lt;code&gt;List&lt;/code&gt; 和 &lt;code&gt;Set&lt;/code&gt; 的区别&lt;/h3&gt;
&lt;p&gt;来源：京东面试:122（&lt;code&gt;京东面试/京东面试.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;List&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;List&lt;/code&gt; 是有序集合，元素可以重复。&lt;/p&gt;
&lt;p&gt;有序指的是元素按照插入顺序保存，可以通过下标访问。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;List&amp;lt;String&amp;gt; list = new ArrayList&amp;lt;&amp;gt;();
list.add(&quot;A&quot;);
list.add(&quot;A&quot;);
list.add(&quot;B&quot;);

System.out.println(list.get(0)); // A
System.out.println(list);        // [A, A, B]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见实现类有 &lt;code&gt;ArrayList&lt;/code&gt;、&lt;code&gt;LinkedList&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Set&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Set&lt;/code&gt; 是不允许重复元素的集合。&lt;/p&gt;
&lt;p&gt;它一般不能通过下标访问元素，主要用于去重。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Set&amp;lt;String&amp;gt; set = new HashSet&amp;lt;&amp;gt;();
set.add(&quot;A&quot;);
set.add(&quot;A&quot;);
set.add(&quot;B&quot;);

System.out.println(set); // [A, B]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见实现类有 &lt;code&gt;HashSet&lt;/code&gt;、&lt;code&gt;LinkedHashSet&lt;/code&gt;、&lt;code&gt;TreeSet&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;是否有序&lt;/p&gt;
&lt;p&gt;&lt;code&gt;List&lt;/code&gt; 是有序的，按照插入顺序保存。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Set&lt;/code&gt; 要看具体实现：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;HashSet&lt;/code&gt; 不保证顺序。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LinkedHashSet&lt;/code&gt; 可以保持插入顺序。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TreeSet&lt;/code&gt; 会按照排序规则保存。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用场景&lt;/p&gt;
&lt;p&gt;如果需要保留重复数据，或者需要通过下标访问元素，一般用 &lt;code&gt;List&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;如果主要目的是去重，或者只关心元素是否存在，一般用 &lt;code&gt;Set&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;&lt;code&gt;ArrayList&lt;/code&gt; 和 &lt;code&gt;LinkedList&lt;/code&gt; 的区别&lt;/h3&gt;
&lt;p&gt;来源：京东面试:177（&lt;code&gt;京东面试/京东面试.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;底层数据结构不同&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ArrayList&lt;/code&gt; 底层是动态数组。&lt;/p&gt;
&lt;p&gt;它在内存中是一段连续空间，所以可以通过下标快速访问元素。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;LinkedList&lt;/code&gt; 底层是双向链表。&lt;/p&gt;
&lt;p&gt;每个节点保存当前元素，同时保存前一个节点和后一个节点的引用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;查询效率不同&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ArrayList&lt;/code&gt; 支持下标随机访问，查询效率高，时间复杂度是 &lt;code&gt;O(1)&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;list.get(10);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;LinkedList&lt;/code&gt; 通过下标查询时，需要从头或者从尾遍历链表，时间复杂度是 &lt;code&gt;O(n)&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;所以按照下标查询元素，&lt;code&gt;ArrayList&lt;/code&gt; 更合适。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;插入和删除效率不同&lt;/p&gt;
&lt;p&gt;如果是在末尾追加元素，&lt;code&gt;ArrayList&lt;/code&gt; 效率也很高。&lt;/p&gt;
&lt;p&gt;如果是在中间插入或删除元素，&lt;code&gt;ArrayList&lt;/code&gt; 需要移动后面的元素，开销比较大。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;LinkedList&lt;/code&gt; 如果已经定位到节点，插入和删除只需要修改前后节点的引用，效率比较高。&lt;/p&gt;
&lt;p&gt;但是如果是通过下标先去找位置，&lt;code&gt;LinkedList&lt;/code&gt; 也要先遍历，所以整体不一定比 &lt;code&gt;ArrayList&lt;/code&gt; 快。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;内存占用不同&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ArrayList&lt;/code&gt; 主要存储元素本身，额外开销较小。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;LinkedList&lt;/code&gt; 每个节点都要保存前后引用，所以内存占用更高。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用场景&lt;/p&gt;
&lt;p&gt;如果查询多、遍历多、按下标访问多，一般用 &lt;code&gt;ArrayList&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;如果频繁在头部或中间插入删除，并且能直接定位到节点，可以考虑 &lt;code&gt;LinkedList&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;实际开发中，&lt;code&gt;ArrayList&lt;/code&gt; 使用更多，因为它查询快、内存占用低、CPU 缓存友好。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;&lt;code&gt;HashMap&lt;/code&gt; 和 &lt;code&gt;Hashtable&lt;/code&gt; 的区别，扩容机制是什么，为什么它们的起始容量不一样？&lt;/h3&gt;
&lt;p&gt;来源：京东面试:227（&lt;code&gt;京东面试/京东面试.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;线程安全不同&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 是线程不安全的，多线程同时修改时可能出现数据不一致问题。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Hashtable&lt;/code&gt; 的方法基本都加了 &lt;code&gt;synchronized&lt;/code&gt;，所以它是线程安全的。&lt;/p&gt;
&lt;p&gt;但是 &lt;code&gt;Hashtable&lt;/code&gt; 是整张表加锁，并发性能比较差。现在多线程场景一般使用 &lt;code&gt;ConcurrentHashMap&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;是否允许 &lt;code&gt;null&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 允许一个 &lt;code&gt;null&lt;/code&gt; key，也允许多个 &lt;code&gt;null&lt;/code&gt; value。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Map&amp;lt;String, String&amp;gt; map = new HashMap&amp;lt;&amp;gt;();
map.put(null, &quot;A&quot;);
map.put(&quot;B&quot;, null);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Hashtable&lt;/code&gt; 不允许 &lt;code&gt;null&lt;/code&gt; key 和 &lt;code&gt;null&lt;/code&gt; value，否则会抛 &lt;code&gt;NullPointerException&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;继承结构不同&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 继承自 &lt;code&gt;AbstractMap&lt;/code&gt;，是比较新的集合框架实现。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Hashtable&lt;/code&gt; 继承自 &lt;code&gt;Dictionary&lt;/code&gt;，属于比较早期的集合类，现在实际开发中用得比较少。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;扩容机制不同&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 默认初始容量是 &lt;code&gt;16&lt;/code&gt;，负载因子是 &lt;code&gt;0.75&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;当元素数量超过：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;容量 * 负载因子
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;就会触发扩容。&lt;/p&gt;
&lt;p&gt;比如默认容量是 &lt;code&gt;16&lt;/code&gt;，阈值就是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;16 * 0.75 = 12
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当元素数量超过 &lt;code&gt;12&lt;/code&gt; 后，会扩容为原来的 &lt;code&gt;2&lt;/code&gt; 倍，也就是从 &lt;code&gt;16&lt;/code&gt; 扩到 &lt;code&gt;32&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Hashtable&lt;/code&gt; 默认初始容量是 &lt;code&gt;11&lt;/code&gt;，负载因子也是 &lt;code&gt;0.75&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;扩容时容量变为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;原容量 * 2 + 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比如从 &lt;code&gt;11&lt;/code&gt; 扩容到：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;11 * 2 + 1 = 23
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;为什么起始容量不一样&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 的容量设计成 &lt;code&gt;2&lt;/code&gt; 的幂，比如 &lt;code&gt;16&lt;/code&gt;、&lt;code&gt;32&lt;/code&gt;、&lt;code&gt;64&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这样计算数组下标时，可以用位运算：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;index = (n - 1) &amp;amp; hash
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;位运算效率高，而且配合扰动函数可以让元素分布更均匀。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Hashtable&lt;/code&gt; 是早期设计，默认容量是 &lt;code&gt;11&lt;/code&gt;，扩容规则是 &lt;code&gt;2n + 1&lt;/code&gt;，更偏向使用奇数容量来减少哈希冲突。&lt;/p&gt;
&lt;p&gt;现在实际开发中，基本优先使用 &lt;code&gt;HashMap&lt;/code&gt;；如果需要并发安全，一般使用 &lt;code&gt;ConcurrentHashMap&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 如何实现并发安全？（重复 2 次）&lt;/h3&gt;
&lt;p&gt;来源：京东面试:307（&lt;code&gt;京东面试/京东面试.md&lt;/code&gt;）；美团3:127（&lt;code&gt;美团3/美团3.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;JDK 7 的实现方式&lt;/p&gt;
&lt;p&gt;JDK 7 里的 &lt;code&gt;ConcurrentHashMap&lt;/code&gt; 使用分段锁，也就是 &lt;code&gt;Segment[] + HashEntry[]&lt;/code&gt;。每个 &lt;code&gt;Segment&lt;/code&gt; 内部维护一张自己的 &lt;code&gt;HashEntry[]&lt;/code&gt; 哈希表，并且 &lt;code&gt;Segment&lt;/code&gt; 继承 &lt;code&gt;ReentrantLock&lt;/code&gt;；&lt;code&gt;HashEntry&lt;/code&gt; 是实际保存 Key-Value 的节点。&lt;/p&gt;
&lt;p&gt;一次写入要经过两次定位：先用 hash 定位 &lt;code&gt;Segment&lt;/code&gt;，再在该 Segment 内部的 &lt;code&gt;HashEntry[]&lt;/code&gt; 定位 bucket，最后将节点写入桶中的链表。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ConcurrentHashMap
  -&amp;gt; Segment[3]（一把锁）
       -&amp;gt; bucket[2] -&amp;gt; HashEntry
       -&amp;gt; bucket[8] -&amp;gt; HashEntry
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;多个线程操作不同 Segment 时可以并发；同一 Segment 内即使位于不同 bucket，写入时仍要竞争该 Segment 的同一把锁。Segment 数量通常远小于实际数据节点数量。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;JDK 8 的实现方式&lt;/p&gt;
&lt;p&gt;JDK 8 取消了 &lt;code&gt;Segment&lt;/code&gt;，直接使用全局 &lt;code&gt;Node[]&lt;/code&gt;，底层结构为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;数组 + 链表 + 红黑树
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;空桶插入使用 CAS，无需加锁；桶中已有节点时，对该桶头节点加 &lt;code&gt;synchronized&lt;/code&gt;，在链表或红黑树中插入或更新。不同 bucket 的写操作通常可以并发；两个 Key 落在同一个 bucket 并同时修改时才竞争同一把桶锁。扩容时多个线程可以协作迁移不同 bucket。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;查询为什么不加锁&lt;/p&gt;
&lt;p&gt;&lt;code&gt;get&lt;/code&gt; 操作一般不加锁，主要依赖 &lt;code&gt;volatile&lt;/code&gt; 保证可见性。&lt;/p&gt;
&lt;p&gt;比如数组节点、节点里的 value 等关键字段会通过 &lt;code&gt;volatile&lt;/code&gt; 保证一个线程修改后，其他线程能看到最新结果。&lt;/p&gt;
&lt;p&gt;所以读操作效率比较高。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;写入如何保证线程安全&lt;/p&gt;
&lt;p&gt;写入时分几种情况：&lt;/p&gt;
&lt;p&gt;如果当前位置为空，会使用 &lt;code&gt;CAS&lt;/code&gt; 尝试直接放入新节点。&lt;/p&gt;
&lt;p&gt;如果当前位置不为空，会对当前桶的头节点加 &lt;code&gt;synchronized&lt;/code&gt; 锁，然后在这个桶里面进行链表插入、红黑树插入或者覆盖旧值。&lt;/p&gt;
&lt;p&gt;这样锁的粒度是桶级别的，不会锁整张表。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;扩容如何处理&lt;/p&gt;
&lt;p&gt;扩容时，&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 支持多个线程一起迁移数据。&lt;/p&gt;
&lt;p&gt;当一个线程发现正在扩容时，可以帮忙迁移一部分桶的数据，这样可以提高扩容效率，减少单个线程扩容压力。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;总结&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 的并发安全主要依赖更细粒度的锁。JDK 7 使用 &lt;code&gt;Segment&lt;/code&gt; 分段锁；JDK 8 使用 &lt;code&gt;CAS + synchronized + volatile&lt;/code&gt;，读操作大多无锁，写操作只锁当前桶，所以并发性能比 &lt;code&gt;Hashtable&lt;/code&gt; 更好。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;JDK 8 的新特性有哪些？请说明 Lambda 表达式和 Stream 流的使用&lt;/h3&gt;
&lt;p&gt;来源：京东面试:594（&lt;code&gt;京东面试/京东面试.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;JDK 8 常见新特性&lt;/p&gt;
&lt;p&gt;JDK 8 里比较常见的新特性有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Lambda 表达式。&lt;/li&gt;
&lt;li&gt;Stream 流。&lt;/li&gt;
&lt;li&gt;函数式接口。&lt;/li&gt;
&lt;li&gt;接口默认方法和静态方法。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Optional&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;新的日期时间 API，比如 &lt;code&gt;LocalDateTime&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;方法引用。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;重点掌握 Lambda 和 Stream。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Lambda 表达式&lt;/p&gt;
&lt;p&gt;Lambda 表达式可以理解成一种更简洁的匿名函数写法。&lt;/p&gt;
&lt;p&gt;以前写线程可能这样写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println(&quot;hello&quot;);
    }
}).start();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JDK 8 之后可以简化成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;new Thread(() -&amp;gt; {
    System.out.println(&quot;hello&quot;);
}).start();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它主要用在函数式接口上，也就是只有一个抽象方法的接口，比如 &lt;code&gt;Runnable&lt;/code&gt;、&lt;code&gt;Callable&lt;/code&gt;、&lt;code&gt;Comparator&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;比如排序：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;List&amp;lt;Integer&amp;gt; list = Arrays.asList(3, 1, 2);

list.sort((a, b) -&amp;gt; a - b);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Lambda 的好处是代码更简洁，适合表达一段行为逻辑。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Stream 流&lt;/p&gt;
&lt;p&gt;Stream 是对集合数据进行链式处理的一套 API。&lt;/p&gt;
&lt;p&gt;它可以让我们用更声明式的方式完成过滤、转换、排序、分组、统计等操作。&lt;/p&gt;
&lt;p&gt;比如筛选出大于 &lt;code&gt;10&lt;/code&gt; 的数字：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;List&amp;lt;Integer&amp;gt; list = Arrays.asList(1, 12, 5, 20);

List&amp;lt;Integer&amp;gt; result = list.stream()
        .filter(x -&amp;gt; x &amp;gt; 10)
        .collect(Collectors.toList());
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比如把用户列表中的用户名取出来：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;List&amp;lt;String&amp;gt; names = userList.stream()
        .map(User::getName)
        .collect(Collectors.toList());
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Stream 常用操作&lt;/p&gt;
&lt;p&gt;&lt;code&gt;filter&lt;/code&gt;：过滤数据。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.filter(user -&amp;gt; user.getAge() &amp;gt; 18)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;map&lt;/code&gt;：转换数据。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.map(User::getName)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;sorted&lt;/code&gt;：排序。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.sorted(Comparator.comparing(User::getAge))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;collect&lt;/code&gt;：收集结果。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.collect(Collectors.toList())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;groupingBy&lt;/code&gt;：分组。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.collect(Collectors.groupingBy(User::getCity))
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用时要注意&lt;/p&gt;
&lt;p&gt;Stream 适合处理集合数据，让代码更清晰。&lt;/p&gt;
&lt;p&gt;但是不要在 Stream 里写太复杂的业务逻辑，否则可读性会变差。&lt;/p&gt;
&lt;p&gt;如果涉及大量数据，还要注意性能和内存占用。并行流 &lt;code&gt;parallelStream()&lt;/code&gt; 也不能随便用，因为它会使用公共线程池，可能影响其他任务。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;请说明 HashMap 中 put 一个元素的过程&lt;/h3&gt;
&lt;p&gt;来源：携程二面:1278（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 的 &lt;code&gt;put&lt;/code&gt; 过程主要包括：计算 hash、定位数组下标、判断桶是否为空、处理 key 冲突、链表或红黑树插入、必要时扩容。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;计算 hash&lt;/p&gt;
&lt;p&gt;调用 &lt;code&gt;put(key, value)&lt;/code&gt; 时，首先会根据 key 计算 hash 值。&lt;/p&gt;
&lt;p&gt;如果 key 是 &lt;code&gt;null&lt;/code&gt;，通常放在数组下标 0 的位置。&lt;/p&gt;
&lt;p&gt;如果 key 不为 &lt;code&gt;null&lt;/code&gt;，会调用 key 的 &lt;code&gt;hashCode()&lt;/code&gt;，然后做一次扰动计算：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hash = h ^ (h &amp;gt;&amp;gt;&amp;gt; 16)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样做是为了让 hash 的高位也参与下标计算，减少哈希冲突。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;初始化数组&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 底层是数组加链表加红黑树。&lt;/p&gt;
&lt;p&gt;如果当前 table 还没有初始化，第一次 put 时会先进行初始化。&lt;/p&gt;
&lt;p&gt;默认初始容量是 16，默认负载因子是 0.75。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;定位桶下标&lt;/p&gt;
&lt;p&gt;根据 hash 计算数组下标：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;index = (table.length - 1) &amp;amp; hash
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为 table 长度通常是 2 的幂，所以可以用位运算代替取模，提高效率。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;桶为空，直接插入&lt;/p&gt;
&lt;p&gt;如果对应下标位置没有元素，就直接创建新节点放进去。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;桶不为空，处理冲突&lt;/p&gt;
&lt;p&gt;如果桶里已经有元素，说明发生哈希冲突。&lt;/p&gt;
&lt;p&gt;这时会先判断桶里的第一个节点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果第一个节点的 hash 相同，并且 key 相等，就覆盖旧 value。&lt;/li&gt;
&lt;li&gt;如果第一个节点是红黑树节点，就按红黑树方式插入。&lt;/li&gt;
&lt;li&gt;否则就遍历链表。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;遍历链表&lt;/p&gt;
&lt;p&gt;遍历链表时，会逐个比较节点的 hash 和 key。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果找到相同 key，就覆盖旧 value。&lt;/li&gt;
&lt;li&gt;如果没有找到，就把新节点插入到链表尾部。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;插入后，如果链表长度达到树化阈值，默认是 8，并且数组容量达到 64，就会把链表转换成红黑树。&lt;/p&gt;
&lt;p&gt;如果数组容量还没到 64，通常会优先扩容，而不是马上树化。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;扩容判断&lt;/p&gt;
&lt;p&gt;插入新节点后，&lt;code&gt;HashMap&lt;/code&gt; 的 size 会加 1。&lt;/p&gt;
&lt;p&gt;如果 size 超过阈值：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;threshold = capacity * loadFactor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;就会触发扩容。&lt;/p&gt;
&lt;p&gt;扩容一般是容量变成原来的 2 倍，然后重新分布元素。&lt;/p&gt;
&lt;p&gt;JDK 8 中扩容时不需要重新计算完整 hash，只需要看原 hash 和旧容量做与运算：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hash &amp;amp; oldCap
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果结果是 0，节点还在原位置；如果不是 0，节点移动到 &lt;code&gt;原位置 + oldCap&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;返回旧值&lt;/p&gt;
&lt;p&gt;如果 put 的 key 已经存在，会覆盖旧 value，并返回旧 value。&lt;/p&gt;
&lt;p&gt;如果是新增 key，则返回 &lt;code&gt;null&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; put 时，先根据 key 计算 hash，并通过 &lt;code&gt;(n - 1) &amp;amp; hash&lt;/code&gt; 定位数组下标。如果桶为空就直接插入；如果桶不为空，就判断是否 key 相同，相同则覆盖 value；如果是链表就遍历链表插入或覆盖；如果是红黑树就按树节点插入。链表长度达到 8 且数组容量达到 64 时会树化，否则优先扩容。插入后如果 size 超过 &lt;code&gt;capacity * loadFactor&lt;/code&gt;，会触发扩容，容量变为原来的 2 倍。&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;HashSet&lt;/code&gt; 和 &lt;code&gt;LinkedHashMap&lt;/code&gt; 的底层实现&lt;/h3&gt;
&lt;p&gt;来源：美团3:127（&lt;code&gt;美团3/美团3.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashSet&lt;/code&gt; 底层使用 &lt;code&gt;HashMap&lt;/code&gt;，集合元素作为 Map 的 Key，Value 使用固定占位对象，因此依赖 Key 的唯一性实现去重。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;LinkedHashMap&lt;/code&gt; 在 HashMap 的基础上为节点维护双向链表。默认按插入顺序遍历；设置 &lt;code&gt;accessOrder = true&lt;/code&gt; 后会按访问顺序维护，最近访问节点移动到链表尾部，因此可用于实现简单的 LRU 缓存。&lt;/p&gt;
&lt;h3&gt;为什么 HashMap 使用红黑树，而非平衡二叉树（如 AVL 树）？&lt;/h3&gt;
&lt;p&gt;来源：携程二面:1368（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;h3&gt;为什么 JDK 提供非线程安全的 HashMap，而非统一鼓励使用 ConcurrentHashMap？&lt;/h3&gt;
&lt;p&gt;来源：携程二面:1369（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;JDK 同时提供 &lt;code&gt;HashMap&lt;/code&gt; 和 &lt;code&gt;ConcurrentHashMap&lt;/code&gt;，是因为它们面向的使用场景不一样。&lt;code&gt;HashMap&lt;/code&gt; 适合单线程或外部已经保证线程安全的场景，&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 适合多线程并发读写场景。并不是所有地方都需要并发控制，统一使用 &lt;code&gt;ConcurrentHashMap&lt;/code&gt; 会带来不必要的成本。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;HashMap 性能更轻量&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 没有并发控制逻辑。&lt;/p&gt;
&lt;p&gt;它不需要 CAS、volatile、锁、分段控制、同步状态维护这些机制，所以在单线程场景下性能更好，内存结构也更简单。&lt;/p&gt;
&lt;p&gt;很多业务代码里，Map 只是方法内部的临时变量，或者对象私有数据，不会被多个线程同时访问，这种情况用 &lt;code&gt;HashMap&lt;/code&gt; 就足够了。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;ConcurrentHashMap 有并发成本&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 为了保证线程安全，需要额外机制。&lt;/p&gt;
&lt;p&gt;JDK 8 里它主要通过：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CAS 插入节点&lt;/li&gt;
&lt;li&gt;volatile 保证可见性&lt;/li&gt;
&lt;li&gt;synchronized 锁桶头节点&lt;/li&gt;
&lt;li&gt;扩容协作&lt;/li&gt;
&lt;li&gt;更复杂的计数逻辑&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些都会带来额外 CPU 和内存开销。&lt;/p&gt;
&lt;p&gt;如果没有并发访问，使用 &lt;code&gt;ConcurrentHashMap&lt;/code&gt; 属于过度设计。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;语义不一样&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 允许 &lt;code&gt;null key&lt;/code&gt; 和 &lt;code&gt;null value&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 不允许 &lt;code&gt;null key&lt;/code&gt; 和 &lt;code&gt;null value&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;因为在并发场景下，如果 &lt;code&gt;get(key)&lt;/code&gt; 返回 &lt;code&gt;null&lt;/code&gt;，无法区分：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;key 不存在
key 存在，但 value 是 null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这会影响并发语义。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;线程安全不等于业务安全&lt;/p&gt;
&lt;p&gt;即使用了 &lt;code&gt;ConcurrentHashMap&lt;/code&gt;，也只能保证单次操作是线程安全的。&lt;/p&gt;
&lt;p&gt;但复合操作仍然可能有并发问题，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (!map.containsKey(key)) {
    map.put(key, value);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这两个操作组合起来不是原子的。&lt;/p&gt;
&lt;p&gt;如果业务需要原子语义，还是要用 &lt;code&gt;putIfAbsent&lt;/code&gt;、&lt;code&gt;computeIfAbsent&lt;/code&gt;，或者额外加锁。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;JDK 提供的是不同工具&lt;/p&gt;
&lt;p&gt;JDK 的设计是提供不同层次的工具，让开发者根据场景选择。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;单线程或局部变量：用 &lt;code&gt;HashMap&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;只读共享：可以初始化完成后安全发布，继续用普通 Map。&lt;/li&gt;
&lt;li&gt;多线程并发读写：用 &lt;code&gt;ConcurrentHashMap&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;需要有序：用 &lt;code&gt;LinkedHashMap&lt;/code&gt; 或 &lt;code&gt;TreeMap&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;需要外部同步：可以用 &lt;code&gt;Collections.synchronizedMap&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;JDK 保留非线程安全的 &lt;code&gt;HashMap&lt;/code&gt;，是因为很多场景并不需要并发控制。&lt;code&gt;HashMap&lt;/code&gt; 结构简单、性能更好、支持 null，适合单线程、局部变量或外部已经保证同步的场景。&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 适合多线程并发读写，但它有 CAS、volatile、锁和扩容协作等额外成本，而且只能保证单次操作线程安全，不能自动保证复合业务逻辑原子。所以 JDK 不会统一鼓励所有场景都用 &lt;code&gt;ConcurrentHashMap&lt;/code&gt;，而是让开发者根据是否存在并发访问来选择合适的 Map。&lt;/p&gt;
&lt;h3&gt;静态代码块和构造方法，哪个先执行？&lt;/h3&gt;
&lt;p&gt;来源：美团:822（&lt;code&gt;美团/美团.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;静态代码块先执行，构造方法后执行。&lt;/p&gt;
&lt;p&gt;因为静态代码块属于类级别，类加载的时候执行；构造方法属于对象级别，创建对象的时候执行。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;静态代码块什么时候执行&lt;/p&gt;
&lt;p&gt;静态代码块在类加载初始化阶段执行，而且只执行一次。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public class User {
    static {
        System.out.println(&quot;静态代码块&quot;);
    }

    public User() {
        System.out.println(&quot;构造方法&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第一次使用 &lt;code&gt;User&lt;/code&gt; 类时，比如 &lt;code&gt;new User()&lt;/code&gt;，会先触发类加载，然后执行静态代码块。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;构造方法什么时候执行&lt;/p&gt;
&lt;p&gt;构造方法在创建对象时执行。&lt;/p&gt;
&lt;p&gt;每 new 一次对象，就会执行一次构造方法。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;new User();
new User();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出大概是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;静态代码块
构造方法
构造方法
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;静态代码块只执行一次，构造方法执行两次。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;如果还有普通代码块&lt;/p&gt;
&lt;p&gt;如果类里还有普通代码块，顺序是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;静态代码块
-&amp;gt; 普通代码块
-&amp;gt; 构造方法
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;普通代码块和构造方法都是对象级别，每次创建对象都会执行。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;如果有父类和子类&lt;/p&gt;
&lt;p&gt;如果涉及继承，执行顺序一般是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;父类静态代码块
-&amp;gt; 子类静态代码块
-&amp;gt; 父类普通代码块
-&amp;gt; 父类构造方法
-&amp;gt; 子类普通代码块
-&amp;gt; 子类构造方法
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：静态代码块先于构造方法执行。静态代码块在类加载初始化时执行，只执行一次；构造方法在创建对象时执行，每 new 一次都会执行一次。如果有继承关系，先执行父类静态代码块，再执行子类静态代码块，然后执行父类普通代码块和父类构造方法，最后执行子类普通代码块和子类构造方法。&lt;/p&gt;
&lt;h3&gt;Java 中 &lt;code&gt;HashMap&lt;/code&gt; 和 &lt;code&gt;ConcurrentHashMap&lt;/code&gt; 有什么区别？&lt;/h3&gt;
&lt;p&gt;来源：美团:32（&lt;code&gt;美团/美团.md&lt;/code&gt;）&lt;/p&gt;
&lt;h2&gt;Spring&lt;/h2&gt;
&lt;h3&gt;Spring 中处理一个请求，会经过 Spring 的哪些模块？&lt;/h3&gt;
&lt;p&gt;来源：美团:517（&lt;code&gt;美团/美团.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;一个 HTTP 请求进入 Spring Web 项目后，核心会经过 Servlet 容器、Filter、DispatcherServlet、HandlerMapping、HandlerAdapter、Controller、Service、DAO/Mapper，最后再返回响应。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Servlet 容器接收请求&lt;/p&gt;
&lt;p&gt;请求最先到达 Servlet 容器，比如 Tomcat、Jetty、Undertow。&lt;/p&gt;
&lt;p&gt;以 Tomcat 为例，它启动后会监听指定端口，比如 &lt;code&gt;8080&lt;/code&gt;。客户端发起请求时，会先通过 TCP 连接到 Tomcat 监听的端口。&lt;/p&gt;
&lt;p&gt;Tomcat 底层会接收客户端建立的 TCP 连接，读取 HTTP 请求报文。请求报文一般包括：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;请求行：GET /user/1 HTTP/1.1
请求头：Host、Content-Type、Cookie、User-Agent
请求体：POST 请求里的 JSON 或表单数据
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tomcat 会把原始 HTTP 报文解析成 Java Web 里的对象：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HttpServletRequest
HttpServletResponse
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;HttpServletRequest&lt;/code&gt; 里包含请求路径、请求方法、请求头、请求参数、Cookie、请求体等信息。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HttpServletResponse&lt;/code&gt; 用来写回响应状态码、响应头和响应体。&lt;/p&gt;
&lt;p&gt;一个 Tomcat 里可以部署多个 Web 应用。Tomcat 会根据域名、端口、Context Path 找到对应的 Web 应用，再根据 Servlet 映射规则把请求交给对应的 Servlet。&lt;/p&gt;
&lt;p&gt;在 Spring MVC 项目里，核心 Servlet 通常是 &lt;code&gt;DispatcherServlet&lt;/code&gt;。符合映射规则的请求会进入 Spring MVC 的统一入口。&lt;/p&gt;
&lt;p&gt;同时，Tomcat 会从线程池里取一个工作线程处理请求。这个线程会负责后续的 Filter、DispatcherServlet、Controller、Service、Mapper 调用，直到响应返回。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Filter 过滤器&lt;/p&gt;
&lt;p&gt;请求进入 Spring MVC 之前，会先经过 Filter。&lt;/p&gt;
&lt;p&gt;Filter 常用于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;登录校验。&lt;/li&gt;
&lt;li&gt;编码处理。&lt;/li&gt;
&lt;li&gt;跨域处理。&lt;/li&gt;
&lt;li&gt;日志记录。&lt;/li&gt;
&lt;li&gt;链路追踪。&lt;/li&gt;
&lt;li&gt;安全认证。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比如 Spring Security 里很多认证逻辑就是通过 Filter 链处理的。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;DispatcherServlet 前端控制器&lt;/p&gt;
&lt;p&gt;经过 Filter 后，请求会进入 Spring MVC 的核心入口：&lt;code&gt;DispatcherServlet&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;它负责接收请求、分发请求、调用 Controller、处理返回结果。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;HandlerMapping 查找处理器&lt;/p&gt;
&lt;p&gt;&lt;code&gt;DispatcherServlet&lt;/code&gt; 会通过 &lt;code&gt;HandlerMapping&lt;/code&gt; 根据请求路径和请求方法找到对应的 Controller 方法。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@GetMapping(&quot;/user/{id}&quot;)
public UserVO getUser(@PathVariable Long id) {
    return userService.getUser(id);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;HandlerMapping&lt;/code&gt; 会把 &lt;code&gt;/user/1&lt;/code&gt; 映射到这个方法上。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;HandlerAdapter 调用 Controller&lt;/p&gt;
&lt;p&gt;找到 Controller 方法后，&lt;code&gt;DispatcherServlet&lt;/code&gt; 通常通过 &lt;code&gt;HandlerAdapter&lt;/code&gt; 适配调用。&lt;/p&gt;
&lt;p&gt;这一步会处理：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;参数绑定。&lt;/li&gt;
&lt;li&gt;类型转换。&lt;/li&gt;
&lt;li&gt;参数校验。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@RequestBody&lt;/code&gt; JSON 反序列化。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@PathVariable&lt;/code&gt;、&lt;code&gt;@RequestParam&lt;/code&gt; 参数解析。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Interceptor 拦截器&lt;/p&gt;
&lt;p&gt;调用 Controller 前后，还可能经过 Spring MVC 的 Interceptor。&lt;/p&gt;
&lt;p&gt;Interceptor 常用于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;权限校验。&lt;/li&gt;
&lt;li&gt;登录态检查。&lt;/li&gt;
&lt;li&gt;接口耗时统计。&lt;/li&gt;
&lt;li&gt;业务日志。&lt;/li&gt;
&lt;li&gt;防重复提交。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Filter 属于 Servlet 规范，Interceptor 属于 Spring MVC。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Controller 调用业务层&lt;/p&gt;
&lt;p&gt;Controller 负责接收参数、做简单校验，然后调用 Service。&lt;/p&gt;
&lt;p&gt;一般不建议把复杂业务逻辑写在 Controller 里。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Service 处理业务逻辑&lt;/p&gt;
&lt;p&gt;Service 层负责真正的业务处理。&lt;/p&gt;
&lt;p&gt;比如事务控制、业务校验、状态流转、调用其他服务、组装数据等。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;DAO / Mapper 访问数据库&lt;/p&gt;
&lt;p&gt;Service 如果需要查数据库，会调用 DAO 或 Mapper。&lt;/p&gt;
&lt;p&gt;比如 MyBatis 的 Mapper 最终会执行 SQL，访问 MySQL。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;返回响应&lt;/p&gt;
&lt;p&gt;Controller 返回对象后，Spring MVC 会通过 &lt;code&gt;HttpMessageConverter&lt;/code&gt; 把对象转换成 JSON。&lt;/p&gt;
&lt;p&gt;最后再经过 Interceptor、Filter，返回给客户端。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：一个请求进入 Spring 项目后，先到 Tomcat 这类 Servlet 容器。Tomcat 监听端口，接收 TCP 连接，解析 HTTP 报文，并封装成 &lt;code&gt;HttpServletRequest&lt;/code&gt; 和 &lt;code&gt;HttpServletResponse&lt;/code&gt;。然后请求经过 Filter 链，进入 &lt;code&gt;DispatcherServlet&lt;/code&gt;。&lt;code&gt;DispatcherServlet&lt;/code&gt; 通过 &lt;code&gt;HandlerMapping&lt;/code&gt; 找到对应 Controller 方法，再通过 &lt;code&gt;HandlerAdapter&lt;/code&gt; 完成参数解析和方法调用。调用前后可能经过 Interceptor。Controller 调用 Service 处理业务，Service 调用 DAO 或 Mapper 访问数据库。最后返回结果经过 &lt;code&gt;HttpMessageConverter&lt;/code&gt; 转成 JSON，再通过 &lt;code&gt;DispatcherServlet&lt;/code&gt;、Filter 返回给客户端。&lt;/p&gt;
&lt;h3&gt;Spring Bean 的生命周期是怎样的？Spring 怎么解决循环依赖？&lt;code&gt;@Lazy&lt;/code&gt; 能解决循环依赖吗？（重复 2 次）&lt;/h3&gt;
&lt;p&gt;来源：美团:642（&lt;code&gt;美团/美团.md&lt;/code&gt;）；美团3:250（&lt;code&gt;美团3/美团3.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;题目变体：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Spring 中的类在启动之后，会执行哪些方法或者用到哪些注解？&lt;/li&gt;
&lt;li&gt;Spring Bean 的生命周期是怎样的？Spring 怎么解决循环依赖？&lt;code&gt;@Lazy&lt;/code&gt; 能解决循环依赖吗？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Spring 中一个类被容器管理后，它会经历 Bean 的生命周期。大致流程是：创建对象、依赖注入、执行初始化回调、放入容器，后续业务使用时从容器中获取这个 Bean。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;创建 Bean 对象&lt;/p&gt;
&lt;p&gt;Spring 启动时会扫描配置类、&lt;code&gt;@Component&lt;/code&gt;、&lt;code&gt;@Service&lt;/code&gt;、&lt;code&gt;@Controller&lt;/code&gt;、&lt;code&gt;@Repository&lt;/code&gt; 等注解。&lt;/p&gt;
&lt;p&gt;扫描到 Bean 定义后，会通过构造方法创建对象。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Service
public class UserService {
    public UserService() {
        System.out.println(&quot;构造方法&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里会先执行构造方法。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;依赖注入&lt;/p&gt;
&lt;p&gt;对象创建出来后，Spring 会给属性注入依赖。&lt;/p&gt;
&lt;p&gt;常见方式有：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Autowired
private UserMapper userMapper;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者构造器注入：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public UserService(UserMapper userMapper) {
    this.userMapper = userMapper;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果是字段注入，执行顺序一般是先构造对象，再给字段注入依赖。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;执行 &lt;code&gt;Aware&lt;/code&gt; 回调&lt;/p&gt;
&lt;p&gt;如果 Bean 实现了一些 &lt;code&gt;Aware&lt;/code&gt; 接口，Spring 会把容器相关对象传进来。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;BeanNameAware&lt;/code&gt;：拿到 Bean 名称。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BeanFactoryAware&lt;/code&gt;：拿到 BeanFactory。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ApplicationContextAware&lt;/code&gt;：拿到 ApplicationContext。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些一般用于框架扩展，业务代码里不常用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;执行 BeanPostProcessor 前置处理&lt;/p&gt;
&lt;p&gt;Spring 会执行 &lt;code&gt;BeanPostProcessor#postProcessBeforeInitialization&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;很多 Spring 扩展能力都依赖它，比如一些代理、注解处理、初始化前增强。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;执行初始化方法&lt;/p&gt;
&lt;p&gt;初始化阶段常见有几种方式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;使用 &lt;code&gt;@PostConstruct&lt;/code&gt; 注解。&lt;/li&gt;
&lt;li&gt;实现 &lt;code&gt;InitializingBean&lt;/code&gt; 接口，重写 &lt;code&gt;afterPropertiesSet()&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;在 &lt;code&gt;@Bean(initMethod = &quot;init&quot;)&lt;/code&gt; 中指定初始化方法。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@PostConstruct
public void init() {
    System.out.println(&quot;初始化方法&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一步适合做一些初始化逻辑，比如加载配置、初始化本地缓存、注册资源等。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;执行 BeanPostProcessor 后置处理&lt;/p&gt;
&lt;p&gt;初始化完成后，会执行 &lt;code&gt;BeanPostProcessor#postProcessAfterInitialization&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;AOP 代理对象通常就是在这个阶段创建或返回的。&lt;/p&gt;
&lt;p&gt;所以我们拿到的 Bean 有时候已经不是原始对象，而是代理对象。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Bean 可以被业务使用&lt;/p&gt;
&lt;p&gt;前面步骤完成后，Bean 才算初始化完成，会放到 Spring 容器里。&lt;/p&gt;
&lt;p&gt;后续 Controller、Service 之间注入的就是这个完成初始化后的 Bean。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;容器关闭时执行销毁方法&lt;/p&gt;
&lt;p&gt;如果应用关闭，Spring 会执行销毁逻辑。&lt;/p&gt;
&lt;p&gt;常见方式有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;@PreDestroy&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;实现 &lt;code&gt;DisposableBean&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@Bean(destroyMethod = &quot;destroy&quot;)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：Spring 中一个类被容器管理后，会先根据 BeanDefinition 创建对象，也就是执行构造方法；然后进行依赖注入，比如处理 &lt;code&gt;@Autowired&lt;/code&gt;；接着执行 &lt;code&gt;Aware&lt;/code&gt; 回调；然后执行 &lt;code&gt;BeanPostProcessor&lt;/code&gt; 的前置处理；再执行初始化方法，比如 &lt;code&gt;@PostConstruct&lt;/code&gt;、&lt;code&gt;InitializingBean&lt;/code&gt; 的 &lt;code&gt;afterPropertiesSet&lt;/code&gt;、&lt;code&gt;@Bean&lt;/code&gt; 的 &lt;code&gt;initMethod&lt;/code&gt;；初始化后会执行 &lt;code&gt;BeanPostProcessor&lt;/code&gt; 的后置处理，AOP 代理通常也在这个阶段完成；最后 Bean 放入容器供业务使用。应用关闭时还会执行 &lt;code&gt;@PreDestroy&lt;/code&gt;、&lt;code&gt;DisposableBean&lt;/code&gt; 或 &lt;code&gt;destroyMethod&lt;/code&gt;。&lt;/p&gt;
&lt;h4&gt;单例 Bean 的属性注入循环依赖&lt;/h4&gt;
&lt;p&gt;Spring 处理循环依赖的核心是提前暴露已经实例化、但尚未完成属性注入和初始化的 Bean 引用。它适用于单例 Bean 的属性注入场景，例如 &lt;code&gt;A -&amp;gt; B -&amp;gt; A&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;三级缓存分别是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;singletonObjects       完整 Bean
earlySingletonObjects  已确定的早期引用
singletonFactories     生成早期引用的 ObjectFactory
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;创建 A 时，Spring 先实例化 A，并把获取 A 早期引用的 &lt;code&gt;ObjectFactory&lt;/code&gt; 放入三级缓存；注入 A 的属性时发现依赖 B，转而创建 B。B 注入属性时又需要 A，Spring 通过三级缓存取得 A 的 early reference，将它放入二级缓存并清除对应工厂，再把 early A 注入 B。B 完成初始化后进入一级缓存；随后 A 注入完整 B，完成初始化并进入一级缓存，A 的二级缓存早期引用随之清理。&lt;/p&gt;
&lt;p&gt;early reference 使 AOP 能参与循环依赖：没有 AOP 时通常是原始 A；需要 AOP 时可以提前返回代理引用，保证依赖方与容器最终使用同一份引用。&lt;/p&gt;
&lt;p&gt;构造器循环依赖和原型 Bean 循环依赖无法通过三级缓存解决。&lt;code&gt;@Lazy&lt;/code&gt; 可以在一侧注入延迟代理，将依赖解析推迟到首次使用，因此能打断部分构造器循环依赖；业务上仍优先通过拆分职责消除循环依赖。&lt;/p&gt;
&lt;h3&gt;构造方法和 &lt;code&gt;@Autowired&lt;/code&gt; 哪个先执行？&lt;/h3&gt;
&lt;p&gt;来源：美团:751（&lt;code&gt;美团/美团.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;一般情况下，构造方法先执行，&lt;code&gt;@Autowired&lt;/code&gt; 字段注入后执行。&lt;/p&gt;
&lt;p&gt;因为 Spring 创建 Bean 时，必须先把对象 new 出来，也就是先调用构造方法。对象创建完成后，Spring 才能给这个对象的属性赋值，处理 &lt;code&gt;@Autowired&lt;/code&gt; 依赖注入。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;字段注入场景&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    public UserService() {
        System.out.println(userMapper);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里构造方法执行时，&lt;code&gt;userMapper&lt;/code&gt; 还没有完成字段注入，所以打印出来可能是 &lt;code&gt;null&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;执行顺序是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;调用构造方法创建对象
-&amp;gt; 处理 @Autowired 字段注入
-&amp;gt; 执行 @PostConstruct 等初始化方法
-&amp;gt; Bean 可以使用
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;构造器注入场景&lt;/p&gt;
&lt;p&gt;如果使用构造器注入：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Service
public class UserService {

    private final UserMapper userMapper;

    public UserService(UserMapper userMapper) {
        this.userMapper = userMapper;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Spring 会先找到 &lt;code&gt;UserMapper&lt;/code&gt; 这个依赖，然后把它作为参数传给构造方法。&lt;/p&gt;
&lt;p&gt;这种情况下，依赖在构造方法里就可以使用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;为什么推荐构造器注入&lt;/p&gt;
&lt;p&gt;构造器注入的好处是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;依赖关系更明确。&lt;/li&gt;
&lt;li&gt;可以使用 &lt;code&gt;final&lt;/code&gt; 修饰字段。&lt;/li&gt;
&lt;li&gt;对象创建完成后就是完整状态。&lt;/li&gt;
&lt;li&gt;更方便单元测试。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：如果是字段注入，构造方法先执行，&lt;code&gt;@Autowired&lt;/code&gt; 后执行，所以在构造方法里使用 &lt;code&gt;@Autowired&lt;/code&gt; 字段可能拿到 &lt;code&gt;null&lt;/code&gt;。如果是构造器注入，Spring 会先解析构造方法参数依赖，再调用构造方法，所以构造方法里可以直接使用这个依赖。实际开发中更推荐构造器注入，因为依赖更明确，也能保证对象创建完成后就是可用状态。&lt;/p&gt;
&lt;h3&gt;了解过 &lt;code&gt;@PostConstruct&lt;/code&gt; 注解吗？这个注解和实现 &lt;code&gt;InitializingBean&lt;/code&gt; 接口重写 &lt;code&gt;afterPropertiesSet&lt;/code&gt; 方法，哪个先执行？&lt;/h3&gt;
&lt;p&gt;来源：美团:27（&lt;code&gt;美团/美团.md&lt;/code&gt;）&lt;/p&gt;
&lt;h3&gt;如何保证只有完全初始化好的 Bean 才会投入使用？（&lt;code&gt;ApplicationContext.getBean()&lt;/code&gt;）&lt;/h3&gt;
&lt;p&gt;来源：京东面试二（待补充）&lt;/p&gt;
&lt;h3&gt;请说明 Spring 的事务传播机制。&lt;/h3&gt;
&lt;p&gt;来源：京东面试二（待补充）&lt;/p&gt;
&lt;p&gt;补充记录：面试中涉及 &lt;code&gt;REQUIRED&lt;/code&gt;、&lt;code&gt;NESTED&lt;/code&gt;、&lt;code&gt;SUPPORTS&lt;/code&gt;、&lt;code&gt;NOT_SUPPORTED&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;网络、IO 与操作系统&lt;/h2&gt;
&lt;h3&gt;OSI 七层模型是什么？每一层的作用是什么？&lt;/h3&gt;
&lt;p&gt;来源：美团3:344（&lt;code&gt;美团3/美团3.md&lt;/code&gt;）&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;应用层
表示层
会话层
传输层
网络层
数据链路层
物理层
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;层级&lt;/th&gt;
&lt;th&gt;主要作用&lt;/th&gt;
&lt;th&gt;常见协议或概念&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;应用层&lt;/td&gt;
&lt;td&gt;向应用程序提供网络服务&lt;/td&gt;
&lt;td&gt;HTTP、HTTPS、DNS、FTP、SMTP、WebSocket、SSH&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;表示层&lt;/td&gt;
&lt;td&gt;数据格式转换、编码、加密解密、压缩&lt;/td&gt;
&lt;td&gt;TLS/SSL、JSON、XML、UTF-8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;会话层&lt;/td&gt;
&lt;td&gt;建立、管理和终止会话&lt;/td&gt;
&lt;td&gt;RPC 会话、NetBIOS Session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;传输层&lt;/td&gt;
&lt;td&gt;端到端通信，使用端口区分应用&lt;/td&gt;
&lt;td&gt;TCP、UDP、QUIC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;网络层&lt;/td&gt;
&lt;td&gt;跨网络寻址和路由&lt;/td&gt;
&lt;td&gt;IP、ICMP；路由器&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;数据链路层&lt;/td&gt;
&lt;td&gt;同一链路或局域网内按帧传输&lt;/td&gt;
&lt;td&gt;Ethernet、Wi-Fi、MAC 地址；交换机&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;物理层&lt;/td&gt;
&lt;td&gt;传输比特流&lt;/td&gt;
&lt;td&gt;网线、光纤、无线电信号&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;常见追问：HTTP/HTTPS 属于应用层，TLS/SSL 通常按表示层理解，TCP/UDP 属于传输层，IP 属于网络层，MAC 地址属于数据链路层。浏览器访问 HTTPS 时可理解为 &lt;code&gt;HTTP -&amp;gt; TLS -&amp;gt; TCP -&amp;gt; IP -&amp;gt; Ethernet/Wi-Fi -&amp;gt; 物理信号&lt;/code&gt;，接收端再按相反方向拆包。&lt;/p&gt;
&lt;p&gt;实际工程更常用 TCP/IP 四层模型：OSI 的应用层、表示层、会话层合并为应用层；数据链路层和物理层合并为网络接口层。&lt;/p&gt;
&lt;h3&gt;进程间的通信方式有哪些？&lt;/h3&gt;
&lt;p&gt;来源：腾讯面试（&lt;code&gt;腾讯/腾讯.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;进程之间地址空间隔离，常见 IPC 方式有管道、消息队列、共享内存、信号量、信号、Socket 和内存映射文件。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;匿名管道 Pipe：内核维护的字节流缓冲区，通常用于父子进程；单向通信，Shell 的 &lt;code&gt;A | B&lt;/code&gt; 是典型场景。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;命名管道 FIFO：有文件路径，支持无亲缘关系的本机进程通信。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;消息队列：内核按消息发送和接收，保留消息边界，适合多个本机进程通信；数据经过内核拷贝，吞吐通常低于共享内存。它与 RocketMQ、Kafka 等分布式消息队列属于不同层面。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;共享内存：多个进程映射同一块物理内存，直接读写，速度最快；适合本机高吞吐、大量数据交换，需要配合互斥锁、读写锁或信号量同步。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;信号量：主要负责同步和互斥，常与共享内存组合，避免读写并发冲突。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;信号 Signal：用于事件通知，例如 &lt;code&gt;SIGTERM&lt;/code&gt;、&lt;code&gt;SIGKILL&lt;/code&gt;、&lt;code&gt;SIGHUP&lt;/code&gt;；携带数据有限。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Socket：支持同机和跨机器通信；本机常用 Unix Domain Socket，跨机器常用 TCP/UDP。HTTP、RPC、gRPC 底层都基于 Socket。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;内存映射文件 mmap：多个进程映射同一文件，适合大文件读写和共享大量数据，也需同步控制。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：少量简单数据使用管道或消息队列；本机高吞吐数据交换使用共享内存加信号量；事件通知使用信号；跨机器服务通信使用 Socket；大文件共享使用 mmap。&lt;/p&gt;
&lt;h3&gt;请说明阻塞式 IO（BIO）和非阻塞式 IO 的区别，以及非阻塞式 IO 的实现原理&lt;/h3&gt;
&lt;p&gt;来源：携程二面:1160（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;BIO 和非阻塞 IO 的核心区别在于：线程发起 IO 操作时，如果数据没有准备好，线程是否会被阻塞。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;BIO 是什么&lt;/p&gt;
&lt;p&gt;BIO 是 Blocking IO，也就是阻塞式 IO。&lt;/p&gt;
&lt;p&gt;在 BIO 模型下，线程调用 &lt;code&gt;read()&lt;/code&gt; 读取数据时，如果数据还没有准备好，线程会一直阻塞在那里，直到数据到达或者连接关闭。&lt;/p&gt;
&lt;p&gt;典型模型是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;一个连接 -&amp;gt; 一个线程
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每来一个客户端连接，服务端就分配一个线程处理这个连接的读写。&lt;/p&gt;
&lt;p&gt;这种方式编程简单，但并发连接数很高时，线程数量会很多，线程上下文切换、内存占用都会变大。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;非阻塞 IO 是什么&lt;/p&gt;
&lt;p&gt;非阻塞 IO 指的是把 socket 设置成 non-blocking。&lt;/p&gt;
&lt;p&gt;当线程调用 &lt;code&gt;read()&lt;/code&gt; 时，如果数据还没准备好，系统不会阻塞线程，而是立即返回一个错误码，比如 &lt;code&gt;EAGAIN&lt;/code&gt; 或 &lt;code&gt;EWOULDBLOCK&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;应用线程可以继续做其他事情，稍后再来尝试读取。&lt;/p&gt;
&lt;p&gt;这样一个线程可以管理多个连接，但如果只是简单轮询所有连接，会有大量无效检查，效率也不高。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;IO 多路复用&lt;/p&gt;
&lt;p&gt;实际工程里，非阻塞 IO 通常会配合 IO 多路复用使用，比如 &lt;code&gt;select&lt;/code&gt;、&lt;code&gt;poll&lt;/code&gt;、&lt;code&gt;epoll&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;它的思路是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;把多个 socket 注册到一个多路复用器上。&lt;/li&gt;
&lt;li&gt;线程阻塞在 &lt;code&gt;select/poll/epoll_wait&lt;/code&gt; 上。&lt;/li&gt;
&lt;li&gt;哪些连接有事件，比如可读、可写，内核就通知应用。&lt;/li&gt;
&lt;li&gt;应用线程只处理已经就绪的连接。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样一个线程就可以管理大量连接，避免一个连接一个线程。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;非阻塞 IO 的实现原理&lt;/p&gt;
&lt;p&gt;非阻塞 IO 的关键有两点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;socket 设置为非阻塞。&lt;/li&gt;
&lt;li&gt;使用事件通知机制监听多个 fd 的就绪状态。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;大致流程是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;socket 设置 non-blocking
-&amp;gt; 注册 fd 到 epoll
-&amp;gt; 线程调用 epoll_wait 等待事件
-&amp;gt; 内核发现某个 fd 可读或可写
-&amp;gt; epoll_wait 返回就绪 fd
-&amp;gt; 应用线程对这些 fd 执行 read/write
-&amp;gt; read/write 不会长期阻塞，没有数据就立即返回
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;BIO 和非阻塞 IO 的区别&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;BIO：线程调用 IO 时会阻塞，一个连接通常对应一个线程。&lt;/li&gt;
&lt;li&gt;非阻塞 IO：线程调用 IO 时不会因为数据未准备好长期阻塞，可以配合事件机制管理多个连接。&lt;/li&gt;
&lt;li&gt;BIO 编程简单，但高并发连接下线程成本高。&lt;/li&gt;
&lt;li&gt;非阻塞 IO 编程复杂，但适合高并发网络服务，比如 Netty、Redis、Nginx。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;BIO 是阻塞式 IO，线程调用 &lt;code&gt;read/write&lt;/code&gt; 时，如果数据没准备好会一直阻塞，常见模型是一个连接一个线程。非阻塞 IO 会把 socket 设置为 non-blocking，数据没准备好时立即返回，不会阻塞线程。实际使用时通常配合 IO 多路复用，比如 epoll，把多个 fd 注册到内核，线程通过 &lt;code&gt;epoll_wait&lt;/code&gt; 等待就绪事件，只处理已经可读或可写的连接。这样一个线程可以管理大量连接，更适合高并发场景。&lt;/p&gt;
&lt;h3&gt;请介绍对 epoll 系统调用的了解。（OS）&lt;/h3&gt;
&lt;p&gt;来源：携程二面:1230（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;epoll&lt;/code&gt; 是 Linux 提供的 IO 多路复用机制，用来让一个线程高效监听大量文件描述符，比如 socket 连接。它常用于高并发网络服务，比如 Redis、Nginx、Netty 的底层模型。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;epoll 解决什么问题&lt;/p&gt;
&lt;p&gt;在高并发网络场景下，如果一个连接一个线程，线程数量会很多，切换和内存开销很大。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;epoll&lt;/code&gt; 的作用是：把大量 socket 注册到内核里，由内核帮我们监听哪些 socket 已经就绪。应用线程只需要处理已经就绪的连接。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;epoll 的三个核心调用&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;epoll_create&lt;/code&gt;：创建一个 epoll 实例。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;epoll_ctl&lt;/code&gt;：把 fd 注册、修改或删除到 epoll 实例中。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;epoll_wait&lt;/code&gt;：等待就绪事件返回。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;大致流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;epoll_create 创建 epoll 实例
-&amp;gt; epoll_ctl 注册 socket fd
-&amp;gt; epoll_wait 等待事件
-&amp;gt; 有 fd 可读或可写时返回
-&amp;gt; 应用程序处理这些就绪 fd
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;相比 select / poll 的优势&lt;/p&gt;
&lt;p&gt;&lt;code&gt;select&lt;/code&gt; 和 &lt;code&gt;poll&lt;/code&gt; 每次调用都需要把 fd 集合传给内核，并且内核要遍历所有 fd 判断是否就绪。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;epoll&lt;/code&gt; 会把 fd 维护在内核里，应用只需要注册一次。事件就绪后，内核把就绪 fd 放到就绪队列，&lt;code&gt;epoll_wait&lt;/code&gt; 直接返回就绪事件。&lt;/p&gt;
&lt;p&gt;所以在大量连接、少量活跃的场景下，&lt;code&gt;epoll&lt;/code&gt; 效率更高。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;LT 和 ET 模式&lt;/p&gt;
&lt;p&gt;&lt;code&gt;epoll&lt;/code&gt; 有两种触发模式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LT，水平触发：只要 fd 上还有数据没读完，&lt;code&gt;epoll_wait&lt;/code&gt; 就会一直通知。&lt;/li&gt;
&lt;li&gt;ET，边缘触发：只有状态发生变化时通知一次，比如从不可读变成可读。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;LT 使用更简单，不容易漏事件。ET 性能更高，但要求一次性把数据读到 &lt;code&gt;EAGAIN&lt;/code&gt;，通常要配合非阻塞 IO 使用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用场景&lt;/p&gt;
&lt;p&gt;&lt;code&gt;epoll&lt;/code&gt; 适合高并发连接场景，特别是连接数很多但同一时间活跃连接有限的服务，比如网关、IM、Redis、Nginx 这类网络服务。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;epoll&lt;/code&gt; 是 Linux 的 IO 多路复用机制，通过 &lt;code&gt;epoll_create&lt;/code&gt; 创建实例，&lt;code&gt;epoll_ctl&lt;/code&gt; 注册 fd，&lt;code&gt;epoll_wait&lt;/code&gt; 等待就绪事件。它把 fd 集合维护在内核中，就绪后只返回活跃 fd，避免每次遍历所有连接，所以适合高并发网络服务。&lt;code&gt;epoll&lt;/code&gt; 支持 LT 和 ET 两种模式，ET 性能更高但要求配合非阻塞 IO，把数据读到 &lt;code&gt;EAGAIN&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;请介绍 HTTPS 中 TLS 的密钥交换流程&lt;/h3&gt;
&lt;p&gt;来源：携程二面:1540（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;HTTPS 本质上是 HTTP 加 TLS。TLS 握手的核心目标是：客户端先确认服务端身份可信，然后和服务端协商出一个只有双方知道的对称会话密钥。后续真正传输 HTTP 数据时，用这个会话密钥加密。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;ClientHello&lt;/p&gt;
&lt;p&gt;客户端先发送 &lt;code&gt;ClientHello&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;里面主要包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;客户端支持的 TLS 版本。&lt;/li&gt;
&lt;li&gt;支持的加密套件列表。&lt;/li&gt;
&lt;li&gt;客户端随机数 &lt;code&gt;Client Random&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;支持的扩展，比如 SNI。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一步不传公钥，也不传私钥。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;ServerHello&lt;/p&gt;
&lt;p&gt;服务端收到后返回 &lt;code&gt;ServerHello&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;里面主要包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;服务端选择的 TLS 版本。&lt;/li&gt;
&lt;li&gt;服务端选择的加密套件。&lt;/li&gt;
&lt;li&gt;服务端随机数 &lt;code&gt;Server Random&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一步本身也不传私钥。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;服务端发送证书&lt;/p&gt;
&lt;p&gt;服务端会把自己的数字证书发给客户端。&lt;/p&gt;
&lt;p&gt;证书里包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;服务端公钥。&lt;/li&gt;
&lt;li&gt;域名。&lt;/li&gt;
&lt;li&gt;证书颁发机构。&lt;/li&gt;
&lt;li&gt;有效期。&lt;/li&gt;
&lt;li&gt;CA 签名。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这里传输的是服务端公钥。&lt;/p&gt;
&lt;p&gt;注意：服务端私钥不会传输，私钥永远只保存在服务端。&lt;/p&gt;
&lt;p&gt;客户端收到证书后，会验证证书是否合法，比如是否过期、域名是否匹配、证书链是否可信、CA 签名是否正确。&lt;/p&gt;
&lt;p&gt;验证通过后，客户端就认为：证书里的公钥确实属于这个服务端。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;密钥交换&lt;/p&gt;
&lt;p&gt;如果是传统 RSA 密钥交换：&lt;/p&gt;
&lt;p&gt;客户端生成一个 &lt;code&gt;Pre-Master Secret&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;然后客户端使用服务端证书里的公钥加密这个 &lt;code&gt;Pre-Master Secret&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;再把加密后的内容发送给服务端。&lt;/p&gt;
&lt;p&gt;这里用到的是服务端公钥加密。&lt;/p&gt;
&lt;p&gt;服务端收到后，使用自己本地保存的服务端私钥解密，得到 &lt;code&gt;Pre-Master Secret&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这里用到的是服务端私钥解密。&lt;/p&gt;
&lt;p&gt;所以：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;公钥：在证书里发给客户端。&lt;/li&gt;
&lt;li&gt;私钥：不传输，只在服务端本地用来解密。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Pre-Master Secret&lt;/code&gt;：客户端生成，用服务端公钥加密后传输。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;然后客户端和服务端都根据：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Client Random + Server Random + Pre-Master Secret
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;生成最终的会话密钥。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;切换到对称加密&lt;/p&gt;
&lt;p&gt;会话密钥生成后，客户端和服务端会发送 &lt;code&gt;ChangeCipherSpec&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;它表示：后续通信开始使用协商出的对称会话密钥加密。&lt;/p&gt;
&lt;p&gt;然后双方发送 &lt;code&gt;Finished&lt;/code&gt; 消息，用来校验前面的握手过程是否被篡改。&lt;/p&gt;
&lt;p&gt;这一步开始后，主要使用的是对称密钥。&lt;/p&gt;
&lt;p&gt;公钥和私钥不再用于加密每一条 HTTP 数据。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;后续 HTTP 数据加密传输&lt;/p&gt;
&lt;p&gt;TLS 握手完成后，后续 HTTP 请求和响应都会使用会话密钥进行对称加密。&lt;/p&gt;
&lt;p&gt;原因是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;非对称加密安全，但性能较低。&lt;/li&gt;
&lt;li&gt;对称加密性能高，适合大量业务数据传输。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;服务端公钥通过证书发送给客户端，服务端私钥永远不传输。客户端验证证书后，用服务端公钥加密 &lt;code&gt;Pre-Master Secret&lt;/code&gt; 发给服务端，服务端用自己的私钥解密。双方再结合 &lt;code&gt;Client Random&lt;/code&gt;、&lt;code&gt;Server Random&lt;/code&gt; 和 &lt;code&gt;Pre-Master Secret&lt;/code&gt; 生成对称会话密钥。后续 HTTP 数据都用这个会话密钥加密传输。&lt;/p&gt;
&lt;h3&gt;客户端如何验证 CA 证书的有效性？&lt;/h3&gt;
&lt;p&gt;来源：携程二面:1641（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;h2&gt;系统设计与架构&lt;/h2&gt;
&lt;h3&gt;MQ 削峰与故障处理&lt;/h3&gt;
&lt;p&gt;来源：字节后端一面:16（&lt;code&gt;2026-6-27/字节后端一面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;有没有想过 MQ 如果挂了怎么办？&lt;/p&gt;
&lt;p&gt;如果 MQ 挂了，我会先看它在系统里承担的角色。秒杀场景里 MQ 主要是用来削峰和异步处理，所以 MQ 不可用时，不能直接绕过 MQ 去写数据库，否则高峰流量会把库存、订单库打崩。&lt;/p&gt;
&lt;p&gt;我的处理会分几层：&lt;/p&gt;
&lt;p&gt;第一层是 MQ 集群自身的高可用，比如 Broker 集群部署、主从/副本机制、多可用区部署、故障转移，避免单个 Broker 宕机导致整个消息链路不可用。&lt;/p&gt;
&lt;p&gt;第二层是入口降级。如果生产者发现 MQ 写入失败，接口可以快速失败或返回“系统繁忙，请稍后重试”，同时配合限流、熔断、活动开关，保护后端核心服务。&lt;/p&gt;
&lt;p&gt;第三层是重要消息兜底。如果业务必须保证请求不丢，可以把请求先写入本地可靠存储或数据库 outbox 表，再由后台任务在 MQ 恢复后补偿投递。但秒杀入口流量很大，这种方案要控制写入量，否则会把压力转移到数据库。&lt;/p&gt;
&lt;p&gt;第四层是恢复后的补偿。MQ 恢复后，要根据订单表、库存流水、消息状态做对账，重新投递未处理消息，并且消费者侧要保证幂等，防止重复消费导致重复扣库存或重复下单。&lt;/p&gt;
&lt;h3&gt;如何保证消息消费的幂等性？&lt;/h3&gt;
&lt;p&gt;来源：腾讯面试（&lt;code&gt;腾讯/腾讯.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;消息队列通常按至少一次投递处理，消费者处理成功后、确认消息前宕机，或发生重试、重平衡时，都可能收到重复消息。因此需要业务侧保证幂等。&lt;/p&gt;
&lt;p&gt;核心是使用稳定的业务唯一键，例如 &lt;code&gt;orderId&lt;/code&gt;、支付流水号、&lt;code&gt;orderId + skuId&lt;/code&gt; 或业务主键加事件类型和版本号。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;数据库唯一索引兜底&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;建立消费记录表或业务流水表，以 &lt;code&gt;event_key&lt;/code&gt; 为主键或唯一索引。消费时在同一个数据库事务中先写入 &lt;code&gt;event_key&lt;/code&gt;，再执行业务更新并提交。第一次插入成功才执行业务；重复插入发生唯一索引冲突时，直接返回成功。这样即使数据库已提交但 ACK 前消费者宕机，消息重投也不会重复生效。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;状态机或条件更新&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;对状态流转类消息使用条件更新，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UPDATE orders
SET status = &apos;PAID&apos;
WHERE order_id = ? AND status = &apos;UNPAID&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;受影响行数为 1 表示本次成功处理；为 0 表示已处理或状态不符合。有顺序要求时配合版本号，避免旧消息覆盖新状态。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;业务成功后再确认消息&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;先完成数据库事务，再 ACK 或提交 offset。失败时让 MQ 重试；超过最大次数进入死信队列，再由补偿任务或人工处理。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Redis 只能辅助去重&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;SET key value NX EX&lt;/code&gt; 可以做短期快速去重，但 Redis 过期、故障或主从切换后仍可能重复。订单、库存、扣款等核心场景仍以数据库唯一索引或条件更新为最终兜底。&lt;/p&gt;
&lt;p&gt;总结：使用业务唯一键，在同一数据库事务内写入消费记录并执行业务操作，依赖唯一索引、状态机和条件更新防止重复副作用；业务成功后再确认消息。&lt;/p&gt;
&lt;h3&gt;使用哪一种消息队列？为什么使用 RocketMQ？&lt;/h3&gt;
&lt;p&gt;来源：腾讯面试（&lt;code&gt;腾讯/腾讯.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;待补充。&lt;/p&gt;
&lt;h3&gt;接口限流&lt;/h3&gt;
&lt;p&gt;来源：字节后端一面:33（&lt;code&gt;2026-6-27/字节后端一面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;一般情况下，除了用户和 IP 这两个维度之外，还可以增加哪些维度？&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;接口维度
例如 /seckill/order、/coupon/receive。
用来保护高成本或核心接口。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;资源维度
例如商品 ID、优惠券 ID、活动 ID、店铺 ID。
用来防止热点资源被打爆。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;设备维度
例如 deviceId、客户端指纹。
用来补充 IP 和用户维度，防止多账号或换 IP 刷。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;限流策略&lt;/h3&gt;
&lt;p&gt;来源：字节后端一面:49（&lt;code&gt;2026-6-27/字节后端一面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;常见限流策略主要有固定窗口、滑动窗口、漏桶和令牌桶。&lt;/p&gt;
&lt;h4&gt;固定窗口&lt;/h4&gt;
&lt;p&gt;固定窗口是把时间切成固定长度的窗口，比如每 1 秒一个窗口。每个窗口内维护一个计数器，请求进来时计数加 1，只要计数没有超过阈值就放行；超过阈值就拒绝。窗口结束后，计数器清零，进入下一个窗口。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;优点：实现简单，计数成本低，适合对精度要求不高的场景。&lt;/li&gt;
&lt;li&gt;缺点：存在临界突刺问题。比如限制每秒 100 次请求，用户可以在上一秒最后 100 毫秒打满 100 次，又在下一秒最开始 100 毫秒再打 100 次，短时间内实际通过 200 次请求。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;滑动窗口&lt;/h4&gt;
&lt;p&gt;滑动窗口是在固定窗口基础上做细粒度拆分。比如把 1 秒拆成 10 个 100 毫秒的小窗口，每次统计最近 1 秒内多个小窗口的请求总数。随着时间推进，窗口会不断向前滑动。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;优点：比固定窗口更平滑，可以缓解临界突刺问题，限流结果更接近真实的最近一段时间请求量。&lt;/li&gt;
&lt;li&gt;缺点：实现和存储成本更高，需要维护多个小窗口的计数。窗口拆得越细，精度越高，维护成本也越高。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;漏桶&lt;/h4&gt;
&lt;p&gt;漏桶可以理解为一个固定容量的桶，请求先进入桶里排队，系统按照固定速率从桶里取出请求并处理。如果桶满了，后续请求会被拒绝或丢弃。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;优点：输出速率稳定，可以把突发流量整形成平稳流量，适合保护下游服务。&lt;/li&gt;
&lt;li&gt;缺点：对突发流量不够友好。即使系统短时间内还有处理能力，漏桶也会按照固定速率放行，请求可能在桶里等待或被拒绝。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;令牌桶&lt;/h4&gt;
&lt;p&gt;令牌桶是系统按照固定速率往桶里放令牌，桶有最大容量。请求到来时需要先拿到令牌，拿到令牌才能通过；没有令牌就被拒绝或等待。桶里如果积累了一些令牌，短时间内可以允许一批请求同时通过。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;优点：既能限制平均速率，也能允许一定程度的突发流量，适合秒杀、网关、接口限流等场景。&lt;/li&gt;
&lt;li&gt;缺点：参数需要结合业务容量设置，比如令牌生成速率和桶容量。桶容量设置过大时，瞬时突发流量仍然可能对下游造成压力。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;设计一个支持超高 QPS 的转账服务（参数：用户 A、用户 B、转账金额），需考虑哪些方面？&lt;/h3&gt;
&lt;p&gt;来源：携程二面:15（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;这类转账服务不能只看 QPS，核心首先是资金安全。用户 A 给用户 B 转账，本质上要保证：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A 扣钱成功
B 加钱成功
转账流水成功
三者必须在同一个事务里完成
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;参数和业务校验&lt;/p&gt;
&lt;p&gt;接口入参有：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fromUserId
toUserId
amount
requestId
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要校验：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用户 A、用户 B 是否存在。&lt;/li&gt;
&lt;li&gt;转账金额是否大于 0。&lt;/li&gt;
&lt;li&gt;A 和 B 是否相同。&lt;/li&gt;
&lt;li&gt;账户状态是否正常，比如冻结、注销、风控限制。&lt;/li&gt;
&lt;li&gt;A 的余额是否足够。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;requestId&lt;/code&gt; 是否重复，用来做幂等。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;幂等设计&lt;/p&gt;
&lt;p&gt;转账接口必须支持幂等。&lt;/p&gt;
&lt;p&gt;客户端可能因为超时重试，如果没有幂等，可能重复扣款。&lt;/p&gt;
&lt;p&gt;可以要求每次请求带一个全局唯一的 &lt;code&gt;requestId&lt;/code&gt;，然后在转账流水表里加唯一索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;request_id unique
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果重复请求进来，先查流水状态：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;已成功：直接返回成功结果。&lt;/li&gt;
&lt;li&gt;处理中：返回处理中或稍后查询。&lt;/li&gt;
&lt;li&gt;已失败：根据业务规则返回失败或允许重试。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;数据库事务保证原子性&lt;/p&gt;
&lt;p&gt;转账时要放在一个数据库事务里：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;begin;

-- 扣 A 账户
update account
set balance = balance - amount
where user_id = A and balance &amp;gt;= amount;

-- 给 B 账户加钱
update account
set balance = balance + amount
where user_id = B;

-- 插入转账流水
insert into transfer_order(...);

commit;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;扣款 SQL 里要带上：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;balance &amp;gt;= amount
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样可以避免余额不足时扣成负数。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;并发控制和死锁处理&lt;/p&gt;
&lt;p&gt;如果多个转账同时操作同一批账户，需要控制并发。&lt;/p&gt;
&lt;p&gt;可以按账户 ID 固定顺序加锁，比如每次都先锁 ID 小的账户，再锁 ID 大的账户，减少死锁概率。&lt;/p&gt;
&lt;p&gt;比如 A 给 B 转账、B 给 A 转账，如果两个事务加锁顺序相反，就容易死锁。&lt;/p&gt;
&lt;p&gt;所以可以统一规则：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;先锁 min(A, B)
再锁 max(A, B)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也可以使用数据库行锁或乐观锁版本号控制并发。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;高 QPS 扩展&lt;/p&gt;
&lt;p&gt;高 QPS 下不能所有请求都打到单库单表。&lt;/p&gt;
&lt;p&gt;可以考虑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;账户表按 &lt;code&gt;userId&lt;/code&gt; 分库分表。&lt;/li&gt;
&lt;li&gt;转账流水表按时间或用户 ID 分表。&lt;/li&gt;
&lt;li&gt;热点账户做限流，比如大 V、商户账户、平台账户。&lt;/li&gt;
&lt;li&gt;读请求走缓存或只读库，但余额判断和扣款必须以主库事务为准。&lt;/li&gt;
&lt;li&gt;写请求通过水平扩展账户分片承载。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果 A 和 B 在不同分片，跨库事务会变复杂。可以通过事务消息、TCC、Saga 或账务中间层处理，但资金场景优先保证一致性，不能随意异步扣加。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;MQ 的使用边界&lt;/p&gt;
&lt;p&gt;转账核心链路不建议只靠 MQ 异步完成扣款和加款。&lt;/p&gt;
&lt;p&gt;MQ 可以用于非核心动作，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;发送通知。&lt;/li&gt;
&lt;li&gt;更新账单展示缓存。&lt;/li&gt;
&lt;li&gt;风控异步分析。&lt;/li&gt;
&lt;li&gt;对账任务。&lt;/li&gt;
&lt;li&gt;积分、活动奖励。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;核心资金变更应该由数据库事务、流水和账户余额保证。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;流水和对账&lt;/p&gt;
&lt;p&gt;资金系统一定要有流水表。&lt;/p&gt;
&lt;p&gt;流水里记录：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;转账单号&lt;/li&gt;
&lt;li&gt;fromUserId&lt;/li&gt;
&lt;li&gt;toUserId&lt;/li&gt;
&lt;li&gt;amount&lt;/li&gt;
&lt;li&gt;状态&lt;/li&gt;
&lt;li&gt;请求 ID&lt;/li&gt;
&lt;li&gt;创建时间&lt;/li&gt;
&lt;li&gt;完成时间&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后续可以通过账户余额、转账流水、资金明细做对账，发现不一致时进入补偿流程。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;缓存使用&lt;/p&gt;
&lt;p&gt;余额这类强一致数据不能完全依赖 Redis 判断。&lt;/p&gt;
&lt;p&gt;Redis 可以用于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;展示余额缓存。&lt;/li&gt;
&lt;li&gt;限流。&lt;/li&gt;
&lt;li&gt;风控计数。&lt;/li&gt;
&lt;li&gt;幂等请求短期缓存。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;真正扣款时必须以数据库账户余额为准。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;设计高 QPS 转账服务时，先保证资金正确性。接口需要做参数校验、账户状态校验和幂等控制；核心转账逻辑放在数据库事务里完成，保证 A 扣款、B 加款、流水记录一起成功或一起失败。并发下通过行锁、固定账户加锁顺序、余额条件更新避免超扣和死锁。高 QPS 方面可以做账户分库分表、流水分表、热点账户限流，读多场景可以用缓存，但扣款必须以主库事务为准。MQ 主要用于通知、风控、对账等异步任务，核心资金链路要靠事务、流水和对账兜底。&lt;/p&gt;
&lt;h3&gt;什么是一致性哈希？和普通哈希有什么区别？&lt;/h3&gt;
&lt;p&gt;来源：美团:971（&lt;code&gt;美团/美团.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;一致性哈希主要是为了解决分布式场景下节点扩容、缩容时，数据迁移量过大的问题。常见场景有 Redis 分片、缓存节点选择、分布式存储路由等。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;普通哈希怎么做&lt;/p&gt;
&lt;p&gt;普通哈希一般是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hash(key) % 节点数量
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比如有 3 台缓存服务器：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hash(userId) % 3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果是 0，就放到节点 0；结果是 1，就放到节点 1。&lt;/p&gt;
&lt;p&gt;这种方式简单，但问题是节点数量一变，取模结果会大面积变化。&lt;/p&gt;
&lt;p&gt;比如从 3 台机器扩容到 4 台：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hash(key) % 3
变成
hash(key) % 4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;大量 key 会被重新映射到不同节点，缓存会大面积失效，数据迁移成本也很高。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;一致性哈希怎么做&lt;/p&gt;
&lt;p&gt;一致性哈希会把哈希值组织成一个环，通常叫哈希环。&lt;/p&gt;
&lt;p&gt;可以理解成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0 ~ 2^32 - 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先把服务器节点 hash 到这个环上，再把数据 key 也 hash 到这个环上。&lt;/p&gt;
&lt;p&gt;一个 key 落到环上的某个位置后，顺时针找到的第一个服务器节点，就是它要存放的节点。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;扩容时影响更小&lt;/p&gt;
&lt;p&gt;如果新增一个节点，只会影响新节点附近的一小段数据。&lt;/p&gt;
&lt;p&gt;原来这段数据属于下一个节点，现在迁移到新节点。&lt;/p&gt;
&lt;p&gt;其他大部分 key 的归属不变。&lt;/p&gt;
&lt;p&gt;所以一致性哈希扩容时，数据迁移量更小，缓存失效范围也更小。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;缩容时影响也更小&lt;/p&gt;
&lt;p&gt;如果某个节点下线，只需要把这个节点负责的数据迁移给它顺时针方向的下一个节点。&lt;/p&gt;
&lt;p&gt;其他节点的数据基本不受影响。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;虚拟节点&lt;/p&gt;
&lt;p&gt;一致性哈希还有一个问题：如果真实节点太少，数据可能分布不均匀。&lt;/p&gt;
&lt;p&gt;所以通常会引入虚拟节点。&lt;/p&gt;
&lt;p&gt;比如一台真实机器映射成多个虚拟节点：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nodeA#1
nodeA#2
nodeA#3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样可以让节点在哈希环上分布更均匀，减少数据倾斜。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：普通哈希通常用 &lt;code&gt;hash(key) % 节点数&lt;/code&gt; 来决定数据落在哪个节点，节点数量变化时，大量 key 会重新映射。一致性哈希把节点和 key 都映射到哈希环上，key 顺时针找到的第一个节点就是目标节点。扩容或缩容时，只影响相邻区间的数据，迁移量更小。为了避免数据倾斜，通常还会引入虚拟节点。&lt;/p&gt;
&lt;h3&gt;用过哪些设计模式？（重复 2 次）&lt;/h3&gt;
&lt;p&gt;来源：美团:1053（&lt;code&gt;美团/美团.md&lt;/code&gt;）；美团2:745（&lt;code&gt;美团2/美团2.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Web 开发里最常用、也最好结合项目讲的设计模式，可以重点说 4 个：单例模式、代理模式、策略模式和责任链模式。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;单例模式&lt;/p&gt;
&lt;p&gt;Spring 容器里的 Bean 默认就是单例。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Service
public class UserService {
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;默认情况下，整个 Spring 容器里只有一个 &lt;code&gt;UserService&lt;/code&gt; 对象。&lt;/p&gt;
&lt;p&gt;常见场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Service&lt;/li&gt;
&lt;li&gt;Mapper&lt;/li&gt;
&lt;li&gt;Controller&lt;/li&gt;
&lt;li&gt;工具类 Bean&lt;/li&gt;
&lt;li&gt;配置类 Bean&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;需要注意：单例 Bean 适合无状态对象，不要在里面随便放可变成员变量，否则多个请求同时访问时可能有线程安全问题。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;代理模式&lt;/p&gt;
&lt;p&gt;Spring AOP、事务、缓存、异步都大量用了代理模式。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Transactional
public void createOrder() {
    // 创建订单
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;@Transactional&lt;/code&gt; 的核心思路是：业务方法外面包一层代理对象，在方法执行前开启事务，执行成功后提交事务，异常时回滚事务。&lt;/p&gt;
&lt;p&gt;常见场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;事务控制&lt;/li&gt;
&lt;li&gt;日志记录&lt;/li&gt;
&lt;li&gt;权限校验&lt;/li&gt;
&lt;li&gt;接口耗时统计&lt;/li&gt;
&lt;li&gt;缓存增强&lt;/li&gt;
&lt;li&gt;异步方法&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;策略模式&lt;/p&gt;
&lt;p&gt;策略模式常用于消除大量 &lt;code&gt;if else&lt;/code&gt;，适合“同一个业务有多种处理方式”的场景。&lt;/p&gt;
&lt;p&gt;比如订单结算时，不同优惠券有不同计算规则：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;满减券：满 100 减 20
折扣券：打 8 折
立减券：直接减 10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以定义统一接口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public interface CouponStrategy {
    BigDecimal calculate(BigDecimal amount);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;满减策略：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Component(&quot;FULL_REDUCE&quot;)
public class FullReduceStrategy implements CouponStrategy {
    @Override
    public BigDecimal calculate(BigDecimal amount) {
        if (amount.compareTo(new BigDecimal(&quot;100&quot;)) &amp;gt;= 0) {
            return amount.subtract(new BigDecimal(&quot;20&quot;));
        }
        return amount;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;折扣策略：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Component(&quot;DISCOUNT&quot;)
public class DiscountStrategy implements CouponStrategy {
    @Override
    public BigDecimal calculate(BigDecimal amount) {
        return amount.multiply(new BigDecimal(&quot;0.8&quot;));
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用时可以让 Spring 注入所有策略实现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Service
public class CouponService {

    private final Map&amp;lt;String, CouponStrategy&amp;gt; strategyMap;

    public CouponService(Map&amp;lt;String, CouponStrategy&amp;gt; strategyMap) {
        this.strategyMap = strategyMap;
    }

    public BigDecimal calculate(String type, BigDecimal amount) {
        CouponStrategy strategy = strategyMap.get(type);
        return strategy.calculate(amount);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样新增一种优惠券时，只需要新增一个策略类，不用改原来的大段判断逻辑。&lt;/p&gt;
&lt;p&gt;常见场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;优惠券计算&lt;/li&gt;
&lt;li&gt;支付方式选择&lt;/li&gt;
&lt;li&gt;运费计算&lt;/li&gt;
&lt;li&gt;消息发送渠道&lt;/li&gt;
&lt;li&gt;风控规则&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;责任链模式&lt;/p&gt;
&lt;p&gt;责任链模式适合多个处理器按顺序处理请求。&lt;/p&gt;
&lt;p&gt;Web 里最常见的是 Filter 链和 Interceptor 链。&lt;/p&gt;
&lt;p&gt;比如一个接口请求进来，需要经过多层校验：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;参数校验
-&amp;gt; 登录校验
-&amp;gt; 权限校验
-&amp;gt; 限流校验
-&amp;gt; 风控校验
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以抽象一个处理器接口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public interface RequestHandler {
    void handle(RequestContext context);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;参数校验处理器：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Component
public class ParamCheckHandler implements RequestHandler {
    @Override
    public void handle(RequestContext context) {
        if (context.getUserId() == null) {
            throw new RuntimeException(&quot;用户 ID 不能为空&quot;);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;登录校验处理器：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Component
public class LoginCheckHandler implements RequestHandler {
    @Override
    public void handle(RequestContext context) {
        if (!context.isLogin()) {
            throw new RuntimeException(&quot;用户未登录&quot;);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;统一执行责任链：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Service
public class RequestCheckChain {

    private final List&amp;lt;RequestHandler&amp;gt; handlers;

    public RequestCheckChain(List&amp;lt;RequestHandler&amp;gt; handlers) {
        this.handlers = handlers;
    }

    public void check(RequestContext context) {
        for (RequestHandler handler : handlers) {
            handler.handle(context);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样以后要新增限流校验，只需要新增一个 &lt;code&gt;RateLimitHandler&lt;/code&gt;，放到责任链里，不需要改原来每个校验逻辑。&lt;/p&gt;
&lt;p&gt;常见场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;登录校验&lt;/li&gt;
&lt;li&gt;权限校验&lt;/li&gt;
&lt;li&gt;限流&lt;/li&gt;
&lt;li&gt;参数校验&lt;/li&gt;
&lt;li&gt;风控校验&lt;/li&gt;
&lt;li&gt;审批流&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：Web 开发里常用的设计模式可以重点说单例、代理、策略和责任链。单例体现在 Spring Bean 默认单例；代理体现在 Spring AOP、事务、缓存；策略适合支付方式、优惠券、运费计算这类多种业务规则选择；责任链常见于 Filter、Interceptor、登录校验、权限校验和限流。&lt;/p&gt;
&lt;h3&gt;哪些中间件使用了单例模式？&lt;/h3&gt;
&lt;p&gt;来源：美团2:769（&lt;code&gt;美团2/美团2.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;中间件单例通常指一个应用进程中复用一份客户端、连接池或工厂对象，它们内部管理网络连接、IO 线程、连接池、元数据或配置，频繁创建成本较高。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Redis：&lt;code&gt;RedissonClient&lt;/code&gt; 和 &lt;code&gt;JedisPool&lt;/code&gt; 通常作为单例；具体 &lt;code&gt;Jedis&lt;/code&gt; 连接从池中借取并归还，不能多线程全局共享。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;MQ：&lt;code&gt;KafkaProducer&lt;/code&gt; 通常是应用级单例，内部管理发送缓冲、连接、IO 线程和 Broker 元数据；RocketMQ Producer 也按 Producer Group 复用。&lt;code&gt;KafkaConsumer&lt;/code&gt; 保存分区、Offset 和 &lt;code&gt;poll&lt;/code&gt; 状态，一个消费线程通常使用一个实例，不应多线程共用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;数据库和 MyBatis：&lt;code&gt;DataSource&lt;/code&gt;、HikariCP、Druid、&lt;code&gt;SqlSessionFactory&lt;/code&gt;、&lt;code&gt;SqlSessionTemplate&lt;/code&gt; 通常是单例。&lt;code&gt;Connection&lt;/code&gt; 和 &lt;code&gt;SqlSession&lt;/code&gt; 持有连接、事务或一级缓存状态，按请求或事务使用，不共享。MyBatis 不通常称为线程池；执行 SQL 的线程一般来自 Web 容器线程池。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：客户端、连接池和工厂对象适合单例；连接、会话、事务和 Consumer 等带请求或线程状态的对象按自身生命周期创建和释放。&lt;/p&gt;
&lt;h3&gt;假设分别部署了 A 和 B 两个服务，A 服务需要调用 B 服务，B 服务的执行时间比较长。B 服务执行完毕后，需要把结果返回给 A 服务。请设计解决方法，如何让 A 和 B 进行交互？请给出三种方案&lt;/h3&gt;
&lt;p&gt;来源：美团:1510（&lt;code&gt;美团/美团.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;这个场景核心是：B 服务耗时比较长，A 服务不适合一直同步阻塞等待。可以根据实时性要求设计 3 种方案。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;方案一：同步调用，设置超时&lt;/p&gt;
&lt;p&gt;如果 B 服务执行时间虽然比较长，但还在可接受范围内，比如 1 到 3 秒，可以用同步 RPC 或 HTTP 调用。&lt;/p&gt;
&lt;p&gt;流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A 调用 B
-&amp;gt; B 执行业务
-&amp;gt; B 返回结果
-&amp;gt; A 返回给前端
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要注意：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A 调用 B 要设置超时时间。&lt;/li&gt;
&lt;li&gt;B 要做好幂等，避免 A 超时重试导致重复执行。&lt;/li&gt;
&lt;li&gt;A 要有降级逻辑，比如超时返回“处理中”或“稍后查询”。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;适合场景：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;耗时可控
调用链简单
前端需要马上拿到结果
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;方案二：异步消息队列 + 回调通知&lt;/p&gt;
&lt;p&gt;如果 B 服务执行时间比较长，不适合 A 一直等，可以用 MQ 异步解耦。&lt;/p&gt;
&lt;p&gt;流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A 接收请求
-&amp;gt; A 生成任务 ID
-&amp;gt; A 把任务消息发送到 MQ
-&amp;gt; A 先返回“任务已提交”
-&amp;gt; B 消费 MQ 执行任务
-&amp;gt; B 执行完成后回调 A
-&amp;gt; A 更新任务状态和结果
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A 可以提供一个回调接口，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /task/callback
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;B 完成后调用这个接口，把任务结果返回给 A。&lt;/p&gt;
&lt;p&gt;需要注意：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;消息要有唯一任务 ID。&lt;/li&gt;
&lt;li&gt;B 消费消息要做幂等。&lt;/li&gt;
&lt;li&gt;回调 A 失败要重试。&lt;/li&gt;
&lt;li&gt;A 要保存任务状态，比如 &lt;code&gt;处理中&lt;/code&gt;、&lt;code&gt;成功&lt;/code&gt;、&lt;code&gt;失败&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;可以加死信队列处理失败任务。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;适合场景：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;B 执行耗时长
不要求前端立即拿到最终结果
需要削峰解耦
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;方案三：异步任务 + A 轮询查询结果&lt;/p&gt;
&lt;p&gt;也可以不让 B 主动回调 A，而是 A 或前端通过任务 ID 查询结果。&lt;/p&gt;
&lt;p&gt;流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A 接收请求
-&amp;gt; A 创建任务记录，状态为处理中
-&amp;gt; A 通知 B 执行任务
-&amp;gt; A 返回 taskId 给前端
-&amp;gt; B 执行完成后把结果写入数据库或 Redis
-&amp;gt; 前端拿 taskId 轮询 A 查询任务状态
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查询接口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GET /task/{taskId}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;返回：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;处理中
成功 + 结果
失败 + 原因
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要注意：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;任务状态要持久化。&lt;/li&gt;
&lt;li&gt;查询接口可以加缓存，避免频繁查库。&lt;/li&gt;
&lt;li&gt;前端轮询要控制频率。&lt;/li&gt;
&lt;li&gt;任务超时要有补偿机制。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;适合场景：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;任务耗时较长
结果不需要实时推送
前端可以接受查询进度
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：如果 B 执行时间可控，可以用同步调用，但要设置超时、重试、降级和幂等。如果 B 执行时间较长，更推荐异步方案：一种是 A 发 MQ，B 消费完成后回调 A；另一种是 A 返回 &lt;code&gt;taskId&lt;/code&gt;，B 执行完成后落库，前端或 A 通过 &lt;code&gt;taskId&lt;/code&gt; 查询结果。实际项目里通常会用 MQ + 任务状态表 + 幂等 + 重试补偿来保证可靠性。&lt;/p&gt;
&lt;h3&gt;假如有两个很大的集合，每个集合本身的数据不重复，但是两个集合之间的数据存在重复。集合很大，加载到内存中会出现问题，请从数据结构和算法角度考虑，怎么找到两个大集合的重复元素？&lt;/h3&gt;
&lt;p&gt;来源：美团:47（&lt;code&gt;美团/美团.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;这个问题不能直接把两个集合都加载到内存里做 &lt;code&gt;HashSet&lt;/code&gt; 交集，因为数据太大，内存放不下。常见思路是分片 + 哈希 + 小文件内存比对。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;先用 Hash 分片&lt;/p&gt;
&lt;p&gt;假设有两个大文件或两个大集合：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A 集合
B 集合
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对每个元素做 hash，然后按照 hash 值取模分到不同的小文件里：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hash(value) % N
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比如分成 1000 份：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A0, A1, A2 ... A999
B0, B1, B2 ... B999
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同一个元素经过同样的 hash 规则，一定会落到同一个编号的分片里。&lt;/p&gt;
&lt;p&gt;也就是说：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A 里的 x 如果落到 A10
B 里的 x 一定也会落到 B10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样只需要比较相同编号的小文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A0 和 B0
A1 和 B1
...
A999 和 B999
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不需要全量两两比较。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;每个小分片内做 HashSet 比对&lt;/p&gt;
&lt;p&gt;如果每个小文件已经可以放进内存，就可以逐个处理。&lt;/p&gt;
&lt;p&gt;比如处理 &lt;code&gt;A10&lt;/code&gt; 和 &lt;code&gt;B10&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;先把 A10 加载到 HashSet
再遍历 B10
如果 B10 里的元素在 HashSet 中存在，就说明它是重复元素
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;伪代码：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Set&amp;lt;String&amp;gt; set = loadToHashSet(&quot;A10&quot;);

for (String value : read(&quot;B10&quot;)) {
    if (set.contains(value)) {
        // value 是重复元素
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;处理完一个分片就释放内存，再处理下一个分片。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：两个超大集合找重复元素，不能直接全部加载到内存。常用方案是 hash 分片：对两个集合里的元素使用同一个 hash 函数取模，分别拆成多个小分片。相同元素一定会落到相同编号的分片里，然后逐个把 A 的某个分片加载成 HashSet，再遍历 B 的对应分片找交集。&lt;/p&gt;
&lt;h2&gt;算法&lt;/h2&gt;
&lt;h3&gt;反转链表的一个区间&lt;/h3&gt;
&lt;p&gt;来源：腾讯面试（&lt;code&gt;腾讯/腾讯.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;待补充。&lt;/p&gt;
&lt;h3&gt;编辑距离&lt;/h3&gt;
&lt;p&gt;来源：腾讯面试（&lt;code&gt;腾讯/腾讯.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;待补充。&lt;/p&gt;
&lt;h2&gt;AI 与 RAG&lt;/h2&gt;
&lt;h3&gt;RAG 检索准确性&lt;/h3&gt;
&lt;p&gt;来源：字节后端一面:262（&lt;code&gt;2026-6-27/字节后端一面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;RAG 检索准确性重点不是只讲“用户问题 -&amp;gt; 检索 -&amp;gt; 拼 Prompt -&amp;gt; 生成答案”的流程，而是要说明怎么让检索结果更相关、更可靠。&lt;/p&gt;
&lt;p&gt;可以从数据质量、文档切分、元数据过滤、查询改写、混合检索、重排、阈值控制和离线评估几个方面回答。&lt;/p&gt;
&lt;h4&gt;数据质量&lt;/h4&gt;
&lt;p&gt;知识库入库前要先做清洗、去重和去噪。比如去掉重复文档、无效 HTML、导航栏、广告、乱码内容，保留真正有业务价值的信息。&lt;/p&gt;
&lt;p&gt;如果知识库内容本身质量很差，后面的向量检索和大模型生成都很难稳定。&lt;/p&gt;
&lt;h4&gt;文档切分&lt;/h4&gt;
&lt;p&gt;文档切分要尽量按标题、段落、语义边界切分，避免把一个完整语义拆散。&lt;/p&gt;
&lt;p&gt;Chunk 太大时，检索结果可能包含很多无关内容；Chunk 太小时，又容易丢失上下文。实际项目里一般会结合 chunk size、overlap 和文档结构来调整。&lt;/p&gt;
&lt;p&gt;比如商品知识库可以按商品、规格、售后规则、活动规则切分；技术文档可以按标题、章节、段落切分。&lt;/p&gt;
&lt;h4&gt;Metadata 过滤&lt;/h4&gt;
&lt;p&gt;入库时要保留标题、来源、业务类型、商品 ID、时间、权限、租户等 metadata。检索前可以先根据 metadata 做过滤，再做向量召回。&lt;/p&gt;
&lt;p&gt;比如用户问某个商品的售后政策，可以先过滤到对应商品、售后文档、有效时间范围内的内容，再进行相似度检索。&lt;/p&gt;
&lt;p&gt;这样可以减少无关文档进入候选集。&lt;/p&gt;
&lt;h4&gt;Query 改写&lt;/h4&gt;
&lt;p&gt;用户问题可能比较口语化，也可能缺少关键词。可以对 Query 做关键词提取、同义词扩展、多路查询或结合历史上下文改写。&lt;/p&gt;
&lt;p&gt;比如用户问“这个能不能退”，可以结合上下文改写成“某商品是否支持退货、退款、售后政策”。&lt;/p&gt;
&lt;p&gt;Query 改写的目标是提高召回率，让检索系统更容易找到相关资料。&lt;/p&gt;
&lt;h4&gt;混合检索&lt;/h4&gt;
&lt;p&gt;只用向量检索时，语义相似效果比较好，但可能漏掉精确关键词、编号、商品名、错误码、专业术语。只用关键词检索时，精确匹配强，但语义泛化能力弱。&lt;/p&gt;
&lt;p&gt;所以可以使用混合检索：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;向量检索：负责语义相似召回
关键词检索：负责精确关键词召回
混合排序：合并两路候选结果
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见组合是向量检索加 BM25。这样既能召回语义相近的内容，也能保留精确匹配能力。&lt;/p&gt;
&lt;h4&gt;Rerank 重排&lt;/h4&gt;
&lt;p&gt;第一阶段检索通常会召回 TopK 候选片段，比如 Top20 或 Top50。召回后可以用 rerank 模型对候选片段重新排序，把和问题最相关的内容排到前面。&lt;/p&gt;
&lt;p&gt;Rerank 的作用是提高前几条结果的相关性，因为最终塞进 Prompt 的内容数量有限，前几条质量会直接影响回答质量。&lt;/p&gt;
&lt;h4&gt;阈值控制&lt;/h4&gt;
&lt;p&gt;检索结果需要设置相似度阈值或相关性阈值。如果最高分结果都很低，说明知识库里可能没有可靠答案。&lt;/p&gt;
&lt;p&gt;这种情况下不应该强行把低相关内容塞给模型，否则模型容易根据弱相关材料编答案。更合理的做法是返回“知识库中没有找到明确依据”，或者让模型基于无资料状态拒答。&lt;/p&gt;
&lt;h4&gt;离线评估&lt;/h4&gt;
&lt;p&gt;RAG 不能只靠感觉调参，需要准备一批测试问题和标准答案，评估检索效果。&lt;/p&gt;
&lt;p&gt;常见指标包括：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Recall@K：正确资料有没有出现在前 K 个召回结果里
MRR：正确资料排在越前面分数越高
Hit Rate：问题是否命中了有效资料
人工评估：判断检索片段是否真的能支撑答案
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;通过这些指标可以持续调整 chunk 大小、overlap、TopK、阈值、metadata 过滤规则、混合检索权重和 rerank 策略。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;我会从知识库质量、文档切分、召回策略、重排和评估几个方面保证 RAG 检索准确性。数据入库前先清洗、去重，并保留标题、来源、业务标签等 metadata；切分时按语义边界切 chunk，避免上下文丢失；检索时可以用向量检索结合关键词检索，提高语义召回和精确匹配能力；召回后再用 rerank 模型重排，把最相关的片段放到前面；最后设置相似度阈值，低于阈值就认为没有可靠资料，并通过离线问题集持续评估 Recall@K、MRR 等指标。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;RAG 无答案时的幻觉控制&lt;/h3&gt;
&lt;p&gt;来源：字节后端一面:343（&lt;code&gt;2026-6-27/字节后端一面.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;如果知识库里面没有准确答案，核心目标是让模型识别“无可靠依据”，并且明确拒答或说明资料不足，避免根据常识和猜测补全答案。&lt;/p&gt;
&lt;h4&gt;检索阈值&lt;/h4&gt;
&lt;p&gt;检索阶段要设置相似度阈值或 rerank 分数阈值。如果召回结果低于阈值，说明知识库里没有足够相关的资料，就不把这些低相关片段强行塞给模型。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;召回结果分数 &amp;gt;= 阈值：进入生成阶段
召回结果分数 &amp;lt; 阈值：判断为无可靠资料
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样可以减少模型基于弱相关内容进行发挥。&lt;/p&gt;
&lt;h4&gt;TopK 结果校验&lt;/h4&gt;
&lt;p&gt;不能只看有没有召回结果，还要判断 TopK 结果是否真的覆盖用户问题。&lt;/p&gt;
&lt;p&gt;比如用户问“某商品是否支持 7 天无理由退货”，检索结果只命中了商品介绍，没有命中售后规则，这种情况下也应该认为资料不足。&lt;/p&gt;
&lt;p&gt;可以让系统在生成前先判断：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;检索结果是否与问题相关
检索结果是否包含回答所需的关键信息
检索结果之间是否互相矛盾
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果结果不相关、信息不完整或互相冲突，就不要输出确定性结论。&lt;/p&gt;
&lt;h4&gt;Prompt 约束&lt;/h4&gt;
&lt;p&gt;生成阶段要在 Prompt 里明确约束模型只能基于检索资料回答。&lt;/p&gt;
&lt;p&gt;可以加入类似规则：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;只能使用给定资料回答问题。
如果资料中没有答案，回复“知识库中没有找到相关信息”。
不要根据常识、经验或猜测补充资料中没有出现的内容。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这类约束可以降低模型自由发挥的概率。&lt;/p&gt;
&lt;h4&gt;引用约束&lt;/h4&gt;
&lt;p&gt;答案最好绑定引用来源，比如文档标题、文档 ID、段落编号或来源链接。模型输出结论时，需要能对应到具体检索片段。&lt;/p&gt;
&lt;p&gt;如果一个结论没有来源支撑，就不能作为确定答案输出。&lt;/p&gt;
&lt;p&gt;这种方式可以让回答从“模型自己觉得对”变成“能在知识库里找到依据”。&lt;/p&gt;
&lt;h4&gt;结构化输出&lt;/h4&gt;
&lt;p&gt;可以让模型先输出一个结构化判断，再决定是否回答。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;has_answer: true / false
evidence: 命中的资料片段
answer: 最终回答
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果 &lt;code&gt;has_answer=false&lt;/code&gt;，就直接返回资料不足的说明，不进入正常回答。&lt;/p&gt;
&lt;p&gt;这种方式可以把“是否有依据”和“生成答案”拆开，减少无依据生成。&lt;/p&gt;
&lt;h4&gt;评估监控&lt;/h4&gt;
&lt;p&gt;线上需要记录无答案问题、低置信度问题、用户负反馈和人工标注结果。后续可以把这些问题用来补充知识库、优化切分策略、调整阈值和改进 Query 改写。&lt;/p&gt;
&lt;p&gt;RAG 的幻觉控制不是只靠 Prompt，一般需要检索阈值、证据约束和评估反馈一起做。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;如果知识库里没有准确答案，我不会让模型强行回答。检索阶段会设置相似度阈值和 rerank 阈值，低于阈值就判断为无可靠资料；生成前会检查 TopK 结果是否真的覆盖问题；Prompt 中会约束模型只能基于检索资料回答，没有依据就说明知识库中没有找到相关信息；答案需要带引用来源，没有证据支撑的结论不能输出。还可以让模型先输出 has_answer 判断，再决定是否生成答案，并记录无答案问题用于后续补充知识库和优化检索。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;消息队列&lt;/h2&gt;
&lt;h3&gt;介绍一下对消息队列的了解&lt;/h3&gt;
&lt;p&gt;来源：美团2:260（&lt;code&gt;美团2/美团2.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;消息队列是生产者和消费者之间的异步通信组件。生产者发送消息到 Broker，Broker 负责存储和分发，消费者按自己的处理能力消费消息。&lt;/p&gt;
&lt;p&gt;消息队列主要用于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;异步解耦：订单创建后通过消息触发积分、通知、搜索索引等下游处理。&lt;/li&gt;
&lt;li&gt;削峰填谷：高并发请求先进入 MQ，消费者按下游数据库可承受的速度处理。&lt;/li&gt;
&lt;li&gt;异步处理：发送短信、生成报表、文件转码、日志处理和数据同步等耗时任务。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;核心能力包括持久化和副本机制、消费确认、失败重试和死信队列、顺序消息以及消费幂等。&lt;/p&gt;
&lt;p&gt;可靠消费流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;本地事务提交成功
-&amp;gt; 发送消息或写入本地消息表
-&amp;gt; Broker 持久化成功
-&amp;gt; 消费者处理业务
-&amp;gt; 消费者业务事务提交成功
-&amp;gt; ACK / 提交 Offset
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;消费者在 ACK 前失败时，消息会再次投递，因此消费端需要通过唯一业务键、状态机或唯一索引保证幂等。&lt;/p&gt;
&lt;p&gt;总结：消息队列主要用于异步、解耦和削峰，使用时重点关注可靠投递、消费幂等、顺序性、重复消费、消息积压监控和失败补偿。&lt;/p&gt;
&lt;h3&gt;消息队列如何保证消费有序性？&lt;/h3&gt;
&lt;p&gt;来源：美团2:301（&lt;code&gt;美团2/美团2.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;消息顺序分为全局有序和局部有序。业务通常要求局部有序，即同一个订单、用户或商品的消息严格有序；不同业务 Key 可以并行处理。全局有序需要单队列和单消费者，吞吐量较低。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;生产端按业务 Key 路由，例如 &lt;code&gt;hash(orderId) % 分区数&lt;/code&gt;。同一个 &lt;code&gt;orderId&lt;/code&gt; 的所有消息进入同一个 Queue 或 Partition。Kafka 使用同一个 &lt;code&gt;key=orderId&lt;/code&gt;；RocketMQ 使用同一个 &lt;code&gt;shardingKey=orderId&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Broker 在单个分区内保存顺序。Kafka 同一 Partition 按 Offset 递增，RocketMQ 同一 MessageQueue 按写入顺序保存；不同分区之间并行，不保证全局顺序。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;同一消费者组中，一个 Partition 同一时刻只分配给一个消费者。同一分区内不应交给线程池并发处理，多个分区可以并行。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;前序消息失败时，应原地重试；后续同业务 Key 的消息不能直接越过失败消息执行。持续失败时进行告警和补偿。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;消息重试和异常恢复仍可能带来重复消息，业务侧通过状态机或版本号校验保证幂等。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：按业务 Key 路由到同一分区，同一分区由一个消费者顺序处理，保证局部有序；多分区提供不同业务 Key 的并行能力。&lt;/p&gt;
&lt;h3&gt;Kafka、RabbitMQ、RocketMQ 之间的区别&lt;/h3&gt;
&lt;p&gt;来源：美团2:342（&lt;code&gt;美团2/美团2.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;三者都能做异步、解耦和削峰，定位不同：Kafka 偏大数据事件流和日志平台；RabbitMQ 偏复杂路由和任务分发；RocketMQ 偏高并发业务消息。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;对比项&lt;/th&gt;
&lt;th&gt;Kafka&lt;/th&gt;
&lt;th&gt;RabbitMQ&lt;/th&gt;
&lt;th&gt;RocketMQ&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;核心模型&lt;/td&gt;
&lt;td&gt;Partition 追加日志&lt;/td&gt;
&lt;td&gt;Exchange 路由到 Queue&lt;/td&gt;
&lt;td&gt;Topic 下多个 MessageQueue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;消费与保留&lt;/td&gt;
&lt;td&gt;Consumer Group 提交 Offset，消息按保留策略保存，可回放&lt;/td&gt;
&lt;td&gt;ACK 后通常从对应 Queue 删除&lt;/td&gt;
&lt;td&gt;Consumer Group 管理消费进度，内置重试和死信能力&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;强项&lt;/td&gt;
&lt;td&gt;高吞吐、事件流、日志、CDC、回放&lt;/td&gt;
&lt;td&gt;复杂路由、任务分发、可靠投递&lt;/td&gt;
&lt;td&gt;顺序、延迟、事务消息、业务重试&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Kafka 的 Topic 由多个 Partition 组成，每个 Partition 是按 Offset 递增的追加日志；多个 Consumer Group 可以独立消费和回放。&lt;/p&gt;
&lt;p&gt;RabbitMQ 的 Exchange 支持 Direct、Topic、Fanout、Headers 等路由方式，适合一个消息按规则投递到不同队列。&lt;/p&gt;
&lt;p&gt;RocketMQ 提供按业务 Key 的局部顺序、延迟消息、事务消息、重试、死信队列和 Tag/属性过滤等能力。&lt;/p&gt;
&lt;p&gt;选型：日志采集、埋点、Binlog 同步、实时计算和回放需求选 Kafka；通知、邮件、短信、异步任务和复杂路由选 RabbitMQ；订单、支付、库存、延迟关单、事务消息和顺序消息选 RocketMQ。&lt;/p&gt;
&lt;p&gt;性能受消息大小、副本数量、刷盘策略、批量参数、硬件和网络影响，不能只靠绝对吞吐量排名选型。实际还要考虑团队已有基础设施、运维能力、消息规模和业务一致性要求。&lt;/p&gt;
&lt;h2&gt;分库分表&lt;/h2&gt;
&lt;h3&gt;分库分表如何设计？如何选择分库分表组件？为什么使用该方案？&lt;/h3&gt;
&lt;p&gt;来源：美团2:164（&lt;code&gt;美团2/美团2.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;分库分表用于解决单库容量、单表数据量或写入 TPS 的瓶颈。实施前应先完成索引优化、历史归档、读写分离、缓存和批处理等常规优化。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;垂直分库按业务域拆分，例如用户库、订单库、商品库和支付库；垂直分表将大字段、低频字段拆到扩展表；水平分库分表按规则将同一逻辑表的数据拆到多个库表。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;分表可减小单表和索引规模。解决 CPU、内存、Buffer Pool、磁盘 IO、连接数和日志写入等单机资源瓶颈时，分片应分布到多个 MySQL 实例。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;分片键要基数高、分布均匀、值稳定，并且应出现在主要查询条件中。以订单表为例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;库序号 = hash(user_id) % 8
表序号 = hash(user_id) % 16
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用户查询携带 &lt;code&gt;user_id&lt;/code&gt; 时，可以精确路由到一个库的一张表。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;订单表和订单明细表应使用相同的分片键和路由规则，称为绑定表。相关数据落在同一 MySQL 实例，可以完成本地 Join。明细表应保存 &lt;code&gt;user_id&lt;/code&gt;，或让 &lt;code&gt;order_id&lt;/code&gt; 能推导出所属分片。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用雪花算法等全局唯一 ID；小型低频配置表可作为广播表；避免跨库事务、跨库 Join、跨库排序和深分页。提前规划扩容，简单取模扩容会导致大量数据迁移，可预分更多逻辑表、按范围分片或使用一致性哈希降低迁移量。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Java 微服务常选择 &lt;code&gt;ShardingSphere-JDBC&lt;/code&gt;，它以 Jar 包嵌入应用，链路短，和 Spring Boot、JDBC、MyBatis 集成方便，SQL 路由排查直接。多语言系统需要统一 MySQL 协议接入时，可使用 &lt;code&gt;ShardingSphere-Proxy&lt;/code&gt;，并建设代理层的高可用、监控和运维能力。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;分库分表场景下，跨库查询如何解决？&lt;/h3&gt;
&lt;p&gt;来源：美团2:218（&lt;code&gt;美团2/美团2.md&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;跨库查询的核心是减少跨库操作，并按查询类型选择方案。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;关联查询使用绑定表：关联表采用相同分片键和路由规则，相关数据位于同一个 MySQL 实例，从而完成本地 Join。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;按主键查询要建立路由能力：当订单按 &lt;code&gt;user_id&lt;/code&gt; 分片而请求只传 &lt;code&gt;order_id&lt;/code&gt; 时，可由接口携带 &lt;code&gt;user_id&lt;/code&gt;、查询 &lt;code&gt;order_id -&amp;gt; 库表位置&lt;/code&gt; 的路由表，或从订单 ID 中解析分片信息，实现精准路由。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;小型低频表可作为广播表，复制到每个分片，保证字典、地区、配置等表可以本地关联。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;列表展示可采用应用层组装：先查询业务数据，再批量查询关联数据，在 Java 内存中组合结果，避免 N+1 查询。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;全局搜索、全文检索、复杂统计通过 Binlog、Canal 或 Debezium 捕获 MySQL 变更，经 MQ 同步到专用查询系统。Elasticsearch 用于搜索和筛选，ClickHouse 用于报表和聚合，Redis 用于实时计数和热点汇总。查询副本存在短暂同步延迟，关键交易状态以 MySQL 主库为准。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;ShardingSphere 广播查询适合小数据量后台或临时查询；高频接口和大数据量查询会产生大量 SQL、网络传输和内存归并，应避免使用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;跨库分页使用游标分页。以 &lt;code&gt;create_time + order_id&lt;/code&gt; 作为稳定联合游标，每个分片只查询游标之后的 N 条，再由服务层归并全局前 N 条。全局深分页和重度检索优先交给 Elasticsearch。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;项目与开放题&lt;/h2&gt;
&lt;h3&gt;自我介绍&lt;/h3&gt;
&lt;p&gt;来源：字节后端一面:10（&lt;code&gt;2026-6-27/字节后端一面.md&lt;/code&gt;）&lt;/p&gt;
&lt;h3&gt;介绍秒杀平台项目&lt;/h3&gt;
&lt;p&gt;来源：字节后端一面:14（&lt;code&gt;2026-6-27/字节后端一面.md&lt;/code&gt;）&lt;/p&gt;
&lt;h3&gt;项目相关问题&lt;/h3&gt;
&lt;p&gt;来源：携程二面:11（&lt;code&gt;携程二面/携程二面.md&lt;/code&gt;）&lt;/p&gt;
</content:encoded></item><item><title>美团后端面试复盘（一）</title><link>https://blog.huangnv.online/posts/2677/2/2/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2677/2/2/</guid><pubDate>Tue, 07 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;美团&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;日期：2026-07-07&lt;br /&gt;
类型：Java 后端面试问题整理&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;面试问题整理&lt;/h2&gt;
&lt;h3&gt;一、Redis&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;问题：Redis 的基本数据类型有哪些？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Redis 常用数据类型主要有 String、Hash、List、Set、ZSet，另外还有一些特殊结构，比如 Bitmap、HyperLogLog、Geo、Stream。可以先讲 5 种基础类型，再补充几个常见特殊类型。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;String&lt;/p&gt;
&lt;p&gt;String 是 Redis 最基础的数据类型，可以存字符串、数字、JSON、序列化对象等。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;缓存对象或页面结果。&lt;/li&gt;
&lt;li&gt;计数器，比如浏览量、点赞数。&lt;/li&gt;
&lt;li&gt;分布式锁，比如 &lt;code&gt;SET key value NX EX&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;保存验证码、Token、Session 等。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;常用命令有 &lt;code&gt;GET&lt;/code&gt;、&lt;code&gt;SET&lt;/code&gt;、&lt;code&gt;INCR&lt;/code&gt;、&lt;code&gt;DECR&lt;/code&gt;、&lt;code&gt;MGET&lt;/code&gt;、&lt;code&gt;SETEX&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Hash&lt;/p&gt;
&lt;p&gt;Hash 类似 Java 里的 &lt;code&gt;Map&amp;lt;String, Map&amp;lt;String, String&amp;gt;&amp;gt;&lt;/code&gt;，一个 key 下面可以有多个 field。&lt;/p&gt;
&lt;p&gt;适合存对象的多个字段，比如用户信息、商品信息。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user:1
name = 张三
age = 20
city = 上海
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;好处是可以单独修改某个字段，不需要每次把整个对象序列化后重新写入。&lt;/p&gt;
&lt;p&gt;常用命令有 &lt;code&gt;HGET&lt;/code&gt;、&lt;code&gt;HSET&lt;/code&gt;、&lt;code&gt;HMGET&lt;/code&gt;、&lt;code&gt;HINCRBY&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;List&lt;/p&gt;
&lt;p&gt;List 是有序列表，底层可以支持从两端插入和弹出。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;简单消息队列。&lt;/li&gt;
&lt;li&gt;最新消息列表。&lt;/li&gt;
&lt;li&gt;时间线列表。&lt;/li&gt;
&lt;li&gt;任务队列。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;常用命令有 &lt;code&gt;LPUSH&lt;/code&gt;、&lt;code&gt;RPUSH&lt;/code&gt;、&lt;code&gt;LPOP&lt;/code&gt;、&lt;code&gt;RPOP&lt;/code&gt;、&lt;code&gt;LRANGE&lt;/code&gt;、&lt;code&gt;BRPOP&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;比如用 &lt;code&gt;LPUSH + BRPOP&lt;/code&gt; 可以实现简单阻塞队列。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Set&lt;/p&gt;
&lt;p&gt;Set 是无序不重复集合。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;去重，比如用户签到、访问用户集合。&lt;/li&gt;
&lt;li&gt;共同好友。&lt;/li&gt;
&lt;li&gt;标签系统。&lt;/li&gt;
&lt;li&gt;抽奖、随机取用户。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Set 支持集合运算，比如交集、并集、差集。&lt;/p&gt;
&lt;p&gt;常用命令有 &lt;code&gt;SADD&lt;/code&gt;、&lt;code&gt;SREM&lt;/code&gt;、&lt;code&gt;SISMEMBER&lt;/code&gt;、&lt;code&gt;SINTER&lt;/code&gt;、&lt;code&gt;SUNION&lt;/code&gt;、&lt;code&gt;SDIFF&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;ZSet&lt;/p&gt;
&lt;p&gt;ZSet 也叫 Sorted Set，有序集合。&lt;/p&gt;
&lt;p&gt;它和 Set 一样元素不重复，但每个元素会有一个 score，Redis 会根据 score 排序。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;排行榜。&lt;/li&gt;
&lt;li&gt;热门文章排序。&lt;/li&gt;
&lt;li&gt;延迟队列。&lt;/li&gt;
&lt;li&gt;按权重排序的任务。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;常用命令有 &lt;code&gt;ZADD&lt;/code&gt;、&lt;code&gt;ZRANGE&lt;/code&gt;、&lt;code&gt;ZREVRANGE&lt;/code&gt;、&lt;code&gt;ZRANK&lt;/code&gt;、&lt;code&gt;ZREM&lt;/code&gt;、&lt;code&gt;ZSCORE&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Bitmap&lt;/p&gt;
&lt;p&gt;Bitmap 本质上是基于 String 的位操作。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用户签到。&lt;/li&gt;
&lt;li&gt;活跃用户统计。&lt;/li&gt;
&lt;li&gt;布尔状态标记。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比如一个用户一年 365 天签到，可以用 365 个 bit 表示，空间占用很小。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;HyperLogLog&lt;/p&gt;
&lt;p&gt;HyperLogLog 用来做基数统计，也就是统计不重复元素数量。&lt;/p&gt;
&lt;p&gt;常见用途是 UV 统计，比如统计某个页面有多少独立访问用户。&lt;/p&gt;
&lt;p&gt;它的优点是占用内存很小，缺点是结果有一定误差。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Geo&lt;/p&gt;
&lt;p&gt;Geo 用来存储地理位置，经纬度信息。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;附近的人。&lt;/li&gt;
&lt;li&gt;附近门店。&lt;/li&gt;
&lt;li&gt;距离计算。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Stream&lt;/p&gt;
&lt;p&gt;Stream 是 Redis 5.0 后提供的消息流结构。&lt;/p&gt;
&lt;p&gt;它可以支持消息持久化、消费者组、消息确认等能力，比 List 更适合做消息队列场景。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：Redis 最常见的 5 种基础数据类型是 String、Hash、List、Set、ZSet。String 适合缓存和计数，Hash 适合对象，List 适合队列和列表，Set 适合去重和集合运算，ZSet 适合排行榜。特殊类型里，Bitmap 适合签到和状态统计，HyperLogLog 适合 UV 统计，Geo 适合地理位置，Stream 适合消息流。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：假设系统使用 Redis 做缓存，现在突然出现大量短链访问不存在的 key，数据库压力暴增，应该怎么办？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;这个场景本质上是缓存穿透。请求访问的短链 key 在 Redis 里不存在，数据库里也不存在，导致每次请求都会绕过缓存打到数据库。如果这种不存在的短链访问量很大，数据库压力会突然升高。&lt;/p&gt;
&lt;p&gt;可以从几个方面处理：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;参数校验&lt;/p&gt;
&lt;p&gt;先在入口处校验短链格式，比如长度、字符集、前缀、业务状态等。&lt;/p&gt;
&lt;p&gt;明显不合法的短链直接拒绝，连 Redis 和数据库都不查。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;空值缓存&lt;/p&gt;
&lt;p&gt;如果 Redis 没命中，查数据库也不存在，可以把空结果写入 Redis。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;short:abc123 -&amp;gt; null
TTL = 1 ~ 5 分钟
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;后续相同短链再访问时，直接命中空缓存，不会继续打到数据库。&lt;/p&gt;
&lt;p&gt;空值缓存的过期时间要短一点，避免后续真实数据创建后长时间查不到。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;布隆过滤器&lt;/p&gt;
&lt;p&gt;把已经存在的短链 key 提前放到布隆过滤器里。&lt;/p&gt;
&lt;p&gt;请求进来后，先判断短链 key 是否可能存在：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;布隆过滤器判断不存在，直接返回不存在。&lt;/li&gt;
&lt;li&gt;布隆过滤器判断可能存在，再继续查 Redis 和数据库。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样可以挡掉大量明显不存在的短链请求。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;限流和风控&lt;/p&gt;
&lt;p&gt;如果发现某些 IP、设备、用户或者来源大量访问不存在的短链，可以做限流、黑名单、验证码、人机校验等。&lt;/p&gt;
&lt;p&gt;这类措施主要是为了防止恶意扫描短链 key。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;数据库保护&lt;/p&gt;
&lt;p&gt;对回源数据库的路径做限流和熔断。&lt;/p&gt;
&lt;p&gt;当数据库压力过高时，可以快速失败或返回统一降级结果，避免数据库被打满。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;整体流程可以这样说：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;请求进来
  -&amp;gt; 校验短链格式
  -&amp;gt; 查布隆过滤器
  -&amp;gt; 查 Redis
  -&amp;gt; Redis 未命中再查数据库
  -&amp;gt; 数据库不存在则写空值缓存
  -&amp;gt; 对异常访问做限流和风控
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：这个其实就是缓存穿透，对吧？打算怎么防？布隆过滤器放在哪一层？布隆过滤器误判了怎么办？误判后是不是要兜底查库？那数据库会不会又被打爆？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;对，这个就是缓存穿透。&lt;/p&gt;
&lt;p&gt;布隆过滤器一般放在业务服务的缓存访问层，也就是请求进入业务服务后，在查 Redis 和数据库之前先判断一次。&lt;/p&gt;
&lt;p&gt;请求链路可以理解成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Controller
  -&amp;gt; 参数校验
  -&amp;gt; 布隆过滤器
  -&amp;gt; Redis
  -&amp;gt; MySQL
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果短链数据量不大，可以每个服务实例本地维护一份布隆过滤器，定时刷新。如果短链数据量很大，或者多实例之间要保持一致，可以用 RedisBloom 这类 Redis 模块统一维护。&lt;/p&gt;
&lt;p&gt;布隆过滤器会有误判。它可能把一个不存在的 key 判断成“可能存在”，然后这个请求会继续查 Redis 和数据库。&lt;/p&gt;
&lt;p&gt;但是误判率可以通过合理设置 bitmap 大小和 hash 函数数量控制在比较低的范围，比如 0.1% 或 0.01%。所以绝大多数不存在的 key 会被挡住，少量误判请求再交给后面的空值缓存和限流兜底。&lt;/p&gt;
&lt;p&gt;误判后的处理方式：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;继续查 Redis。&lt;/li&gt;
&lt;li&gt;Redis 没有，再查数据库。&lt;/li&gt;
&lt;li&gt;数据库也没有，就写入空值缓存。&lt;/li&gt;
&lt;li&gt;对同一类不存在 key 或异常来源做限流。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;所以不会只依赖布隆过滤器。完整方案通常是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;参数校验 + 布隆过滤器 + 空值缓存 + 限流风控 + 数据库熔断降级
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样即使布隆过滤器有少量误判，也不会让所有不存在的请求都直接打到数据库。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：一个热点数据刚好失效，被几万请求同时打到数据库，这时候会锁住那个 key 吗？锁的粒度是多大？Redis 锁还是本地锁？锁超时了怎么办？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;这个场景是典型的缓存击穿。热点 key 过期的一瞬间，大量请求同时发现 Redis 没有数据，然后一起查数据库，数据库压力会突然升高。&lt;/p&gt;
&lt;p&gt;处理时一般会对这个热点 key 做互斥控制，但锁的粒度要尽量小，通常只锁当前失效的这个 key，不会锁整个缓存或整个业务。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;锁住哪个 key&lt;/p&gt;
&lt;p&gt;锁的是当前失效的热点 key 对应的重建逻辑。&lt;/p&gt;
&lt;p&gt;比如商品详情缓存失效：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cache:product:1001
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以对应一把锁：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;lock:product:1001
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样只有访问 &lt;code&gt;product:1001&lt;/code&gt; 的请求会竞争这把锁，其他商品不受影响。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;拿到锁的线程做什么&lt;/p&gt;
&lt;p&gt;只有一个线程拿到锁后去查数据库，然后把结果写回 Redis。&lt;/p&gt;
&lt;p&gt;流程是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;查 Redis 失败
  -&amp;gt; 尝试获取 lock:product:1001
  -&amp;gt; 获取成功，查数据库
  -&amp;gt; 写回 Redis
  -&amp;gt; 释放锁
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;没拿到锁的线程怎么办&lt;/p&gt;
&lt;p&gt;没拿到锁的请求不要都去查数据库，可以有几种处理方式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;等待一小段时间后重试查 Redis。&lt;/li&gt;
&lt;li&gt;直接返回旧缓存数据。&lt;/li&gt;
&lt;li&gt;返回系统繁忙，让客户端稍后重试。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果是商品详情、配置类数据，通常可以返回旧值；如果是强一致数据，就短暂等待或快速失败。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Redis 锁还是本地锁&lt;/p&gt;
&lt;p&gt;如果服务只有单机，可以用本地锁，比如 &lt;code&gt;synchronized&lt;/code&gt;、&lt;code&gt;ReentrantLock&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;但线上一般是多实例部署，本地锁只能锁住当前 JVM，锁不住其他机器，所以更常用 Redis 分布式锁。&lt;/p&gt;
&lt;p&gt;分布式锁可以用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SET lockKey requestId NX EX 10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;NX&lt;/code&gt; 保证只有一个请求加锁成功，&lt;code&gt;EX&lt;/code&gt; 设置锁过期时间，避免服务异常导致死锁。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;锁超时了怎么办&lt;/p&gt;
&lt;p&gt;锁超时时间要设置得比数据库查询和缓存重建时间稍微长一点。&lt;/p&gt;
&lt;p&gt;如果业务执行时间不确定，可以用 Redisson 的看门狗机制自动续期，避免业务没执行完锁就过期。&lt;/p&gt;
&lt;p&gt;释放锁时要校验锁的唯一标识，只能释放自己加的锁，避免误删别人的锁。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;更好的方案：逻辑过期&lt;/p&gt;
&lt;p&gt;对特别热点的数据，可以用逻辑过期降低阻塞。&lt;/p&gt;
&lt;p&gt;Redis 里的 key 不设置物理过期时间，而是在 value 里存一个逻辑过期时间。&lt;/p&gt;
&lt;p&gt;请求读取时：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;没过期 -&amp;gt; 直接返回缓存
已过期 -&amp;gt; 先返回旧数据
       -&amp;gt; 只让一个线程异步刷新缓存
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样几万请求来了也能先返回旧数据，只有一个线程去重建缓存，数据库不会被打爆。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：这个属于缓存击穿。一般会针对当前热点 key 加互斥锁，锁粒度是单个业务 key，比如 &lt;code&gt;lock:product:1001&lt;/code&gt;。拿到锁的线程查数据库并重建缓存，没拿到锁的线程等待重试、返回旧值或快速失败。多实例部署下用 Redis 分布式锁，本地锁只能解决单 JVM 问题。锁要设置过期时间，释放时校验唯一标识，业务时间不确定时可以用 Redisson 看门狗续期。对特别热点的数据，更推荐逻辑过期加异步刷新。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：假如 Redis 故障恢复时间有 1 分钟，1 分钟内所有请求都穿透到数据库，应该怎么处理？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;这个场景更接近 Redis 整体不可用导致的缓存雪崩。核心思路是：Redis 挂掉期间，不能让所有流量直接打到数据库，要做限流、降级、熔断和本地缓存兜底。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;先做限流，保护数据库&lt;/p&gt;
&lt;p&gt;Redis 故障时，数据库会突然承接大量请求。第一步要在网关层或应用层限流，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;网关限流
接口限流
用户维度限流
热点 Key 维度限流
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比如某个接口平时 1 万 QPS，数据库只能扛 1000 QPS，那 Redis 故障时就只能放一部分请求进来，剩下的快速失败或返回降级结果。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;做熔断，避免请求持续打库&lt;/p&gt;
&lt;p&gt;如果发现 Redis 连续不可用，或者数据库压力已经很高，就触发熔断。&lt;/p&gt;
&lt;p&gt;熔断后，部分非核心接口可以直接返回：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;系统繁忙，请稍后重试
默认值
兜底数据
空结果
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样可以避免所有请求都阻塞在数据库上，导致数据库也被拖垮。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用本地缓存兜底&lt;/p&gt;
&lt;p&gt;对一些热点数据、配置数据、首页数据，可以在应用内保留一份本地缓存，比如 Caffeine。&lt;/p&gt;
&lt;p&gt;Redis 故障时，先从本地缓存读：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;请求 -&amp;gt; Redis 失败 -&amp;gt; 读本地缓存 -&amp;gt; 返回旧数据
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本地缓存的数据可能不是最新的，但可以在短时间内保证系统可用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;非核心业务降级&lt;/p&gt;
&lt;p&gt;Redis 故障期间，可以临时关闭一些非核心功能，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;推荐列表
排行榜
浏览量统计
非关键状态查询
活动装饰信息
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;核心链路优先保证，比如下单、支付、登录、查询订单等。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;数据库查询也要做保护&lt;/p&gt;
&lt;p&gt;对必须查数据库的请求，也不能无限制查。可以加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SQL 超时时间
连接池限制
慢查询监控
只读库分担压力
热点请求合并
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果多个请求同时查同一个热点数据，可以用本地锁或请求合并，让一个线程查库，其他线程等待结果，避免同一个 Key 打出大量重复 SQL。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Redis 恢复后重建缓存&lt;/p&gt;
&lt;p&gt;Redis 恢复后，不能让所有请求同时回源重建缓存。可以采用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;分批预热缓存
热点 Key 优先加载
缓存过期时间加随机值
异步重建缓存
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;防止 Redis 刚恢复，数据库又被大量缓存重建请求打满。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：Redis 故障 1 分钟时，重点是保护数据库。处理方式包括网关或应用限流、熔断降级、本地缓存兜底、非核心功能降级、数据库连接池和 SQL 超时保护。对于热点数据，可以做请求合并，避免大量请求同时查库。Redis 恢复后，要分批预热缓存，避免缓存重建再次冲垮数据库。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;二、MySQL&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;问题：MySQL 中有哪些存储引擎？&lt;code&gt;InnoDB&lt;/code&gt; 和 &lt;code&gt;MyISAM&lt;/code&gt; 的区别是什么？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;MySQL 常见的存储引擎有 &lt;code&gt;InnoDB&lt;/code&gt;、&lt;code&gt;MyISAM&lt;/code&gt;、&lt;code&gt;Memory&lt;/code&gt;、&lt;code&gt;Archive&lt;/code&gt; 等。现在实际开发里最常用的是 &lt;code&gt;InnoDB&lt;/code&gt;，也是 MySQL 5.5 之后的默认存储引擎。&lt;/p&gt;
&lt;p&gt;重点比较 &lt;code&gt;InnoDB&lt;/code&gt; 和 &lt;code&gt;MyISAM&lt;/code&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;事务支持&lt;/p&gt;
&lt;p&gt;&lt;code&gt;InnoDB&lt;/code&gt; 支持事务，支持提交和回滚，满足 ACID。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MyISAM&lt;/code&gt; 不支持事务，一旦写入就不能通过事务回滚。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;锁粒度&lt;/p&gt;
&lt;p&gt;&lt;code&gt;InnoDB&lt;/code&gt; 支持行级锁，也支持表级锁。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MyISAM&lt;/code&gt; 主要是表级锁。&lt;/p&gt;
&lt;p&gt;所以并发写入场景下，&lt;code&gt;InnoDB&lt;/code&gt; 的并发能力更好。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;外键支持&lt;/p&gt;
&lt;p&gt;&lt;code&gt;InnoDB&lt;/code&gt; 支持外键。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MyISAM&lt;/code&gt; 不支持外键。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;崩溃恢复&lt;/p&gt;
&lt;p&gt;&lt;code&gt;InnoDB&lt;/code&gt; 有 redo log、undo log，支持崩溃恢复。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MyISAM&lt;/code&gt; 崩溃后恢复能力较弱，表损坏后需要修复。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;索引结构&lt;/p&gt;
&lt;p&gt;&lt;code&gt;InnoDB&lt;/code&gt; 使用聚簇索引，数据和主键索引放在一起。&lt;/p&gt;
&lt;p&gt;主键索引的叶子节点存整行数据，二级索引的叶子节点存主键值。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MyISAM&lt;/code&gt; 使用非聚簇索引，索引文件和数据文件分开，索引叶子节点存的是数据文件地址。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;适用场景&lt;/p&gt;
&lt;p&gt;&lt;code&gt;InnoDB&lt;/code&gt; 适合大多数业务系统，特别是需要事务、并发写入、数据一致性和崩溃恢复的场景。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MyISAM&lt;/code&gt; 适合读多写少、对事务没有要求的老系统或特定场景，现在新项目一般很少主动选择。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：MySQL 常见存储引擎有 &lt;code&gt;InnoDB&lt;/code&gt;、&lt;code&gt;MyISAM&lt;/code&gt;、&lt;code&gt;Memory&lt;/code&gt;、&lt;code&gt;Archive&lt;/code&gt; 等。实际开发中主要使用 &lt;code&gt;InnoDB&lt;/code&gt;。&lt;code&gt;InnoDB&lt;/code&gt; 支持事务、行级锁、外键和崩溃恢复，使用聚簇索引，适合高并发和数据一致性要求高的业务。&lt;code&gt;MyISAM&lt;/code&gt; 不支持事务和外键，主要使用表级锁，索引和数据分开存储，崩溃恢复能力较弱，适合读多写少且对事务要求不高的场景。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：为什么 &lt;code&gt;InnoDB&lt;/code&gt; 选择 B+ 树作为索引？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;InnoDB&lt;/code&gt; 选择 B+ 树作为索引，核心原因是它适合磁盘存储，能减少磁盘 IO，并且同时支持等值查询、范围查询和排序。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;B+ 树树高低，磁盘 IO 少&lt;/p&gt;
&lt;p&gt;数据库索引通常存储在磁盘页里，一次 IO 读取一个页。&lt;/p&gt;
&lt;p&gt;B+ 树是多路平衡树，一个节点可以存很多 key 和指针，所以分叉很多，树高很低。&lt;/p&gt;
&lt;p&gt;比如几千万数据，B+ 树通常也就 3 到 4 层。查询时从根节点到叶子节点，只需要少量磁盘 IO。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;非叶子节点只存 key 和指针&lt;/p&gt;
&lt;p&gt;B+ 树的非叶子节点不存完整数据，只存 key 和子节点指针。&lt;/p&gt;
&lt;p&gt;这样一个磁盘页能放更多 key，分叉更多，树更矮。&lt;/p&gt;
&lt;p&gt;树越矮，查询需要访问的页越少，IO 成本越低。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;叶子节点存数据，并且有序&lt;/p&gt;
&lt;p&gt;在 &lt;code&gt;InnoDB&lt;/code&gt; 的主键索引里，B+ 树叶子节点存的是整行数据。&lt;/p&gt;
&lt;p&gt;二级索引的叶子节点存的是索引列和主键值。&lt;/p&gt;
&lt;p&gt;所有数据都在叶子节点，并且按照索引顺序排列。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;叶子节点之间有链表，适合范围查询&lt;/p&gt;
&lt;p&gt;B+ 树的叶子节点之间通过双向链表连接。&lt;/p&gt;
&lt;p&gt;范围查询时，先定位到起始位置，然后沿着叶子节点链表向后扫描即可。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where age between 20 and 30;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种范围查询对 B+ 树很友好。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;比二叉树、红黑树更适合数据库&lt;/p&gt;
&lt;p&gt;二叉树和红黑树每个节点分支少，数据量大时树会很高。&lt;/p&gt;
&lt;p&gt;树高越高，查找路径越长，需要的磁盘 IO 越多。&lt;/p&gt;
&lt;p&gt;它们更适合内存结构，不适合作为数据库磁盘索引。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;比 B 树更适合范围查询&lt;/p&gt;
&lt;p&gt;B 树的非叶子节点也可能存数据，范围查询时可能要在多个层级之间跳转。&lt;/p&gt;
&lt;p&gt;B+ 树的数据都集中在叶子节点，叶子节点之间有链表，所以范围扫描更简单、更稳定。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：&lt;code&gt;InnoDB&lt;/code&gt; 选择 B+ 树，主要是因为 B+ 树是多路平衡树，树高低，能减少磁盘 IO；非叶子节点只存 key 和指针，一个页能放更多索引项；叶子节点存数据并按顺序通过链表连接，适合范围查询和排序。相比二叉树、红黑树，B+ 树更适合磁盘页；相比 B 树，B+ 树范围查询更高效。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：数据库的第三范式是什么？数据库设计为什么要遵循三范式？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;数据库三范式主要是为了减少数据冗余，避免插入、更新、删除时出现数据不一致。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;第一范式：字段原子性&lt;/p&gt;
&lt;p&gt;表里的每个字段都应该是不可再拆分的原子值。&lt;/p&gt;
&lt;p&gt;比如用户表里不要把多个手机号放在同一个字段里：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;phone = &quot;138xxx,139xxx&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;更合理的做法是拆成用户表和用户手机号表。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;第二范式：非主键字段完全依赖主键&lt;/p&gt;
&lt;p&gt;在满足第一范式的基础上，非主键字段要完全依赖整个主键，不能只依赖联合主键的一部分。&lt;/p&gt;
&lt;p&gt;比如订单商品表：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;order_id
product_id
product_name
order_time
quantity
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果主键是 &lt;code&gt;(order_id, product_id)&lt;/code&gt;，那么 &lt;code&gt;quantity&lt;/code&gt; 依赖这个联合主键。&lt;/p&gt;
&lt;p&gt;但 &lt;code&gt;product_name&lt;/code&gt; 只依赖 &lt;code&gt;product_id&lt;/code&gt;，&lt;code&gt;order_time&lt;/code&gt; 只依赖 &lt;code&gt;order_id&lt;/code&gt;，就不适合放在这张明细表里。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;第三范式：非主键字段不能传递依赖主键&lt;/p&gt;
&lt;p&gt;在满足第二范式的基础上，非主键字段之间不能存在依赖关系。&lt;/p&gt;
&lt;p&gt;比如用户表：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user_id
dept_id
dept_name
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;dept_name&lt;/code&gt; 依赖 &lt;code&gt;dept_id&lt;/code&gt;，&lt;code&gt;dept_id&lt;/code&gt; 又依赖 &lt;code&gt;user_id&lt;/code&gt;，这就产生了传递依赖。&lt;/p&gt;
&lt;p&gt;更合理的设计是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user 表：user_id, dept_id
dept 表：dept_id, dept_name
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;为什么要遵循三范式&lt;/p&gt;
&lt;p&gt;遵循三范式的好处是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;减少重复数据。&lt;/li&gt;
&lt;li&gt;降低更新异常，比如部门名只需要改一处。&lt;/li&gt;
&lt;li&gt;降低插入异常，比如没有用户时也可以单独维护部门。&lt;/li&gt;
&lt;li&gt;降低删除异常，比如删除用户不会把部门信息一起删掉。&lt;/li&gt;
&lt;li&gt;保证数据结构更清晰，更容易维护。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;实际开发中的取舍&lt;/p&gt;
&lt;p&gt;实际业务里不会机械地完全遵守三范式。&lt;/p&gt;
&lt;p&gt;在读多写少、查询性能要求高的场景下，可能会适当反范式设计，比如订单表里冗余商品名称、商品快照、用户昵称。&lt;/p&gt;
&lt;p&gt;这样可以减少多表 join，提高查询性能，也能保留当时下单时的历史信息。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：三范式主要是为了减少数据冗余和数据异常。第一范式要求字段不可再拆分；第二范式要求非主键字段完全依赖主键，不能只依赖联合主键的一部分；第三范式要求非主键字段不能传递依赖主键。遵循三范式可以减少冗余，避免更新、插入、删除异常。实际开发中会根据性能和业务快照需求适当反范式，比如订单表冗余商品名称和用户信息。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;三、Spring&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;问题：Spring 中处理一个请求，会经过 Spring 的哪些模块？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;一个 HTTP 请求进入 Spring Web 项目后，核心会经过 Servlet 容器、Filter、DispatcherServlet、HandlerMapping、HandlerAdapter、Controller、Service、DAO/Mapper，最后再返回响应。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Servlet 容器接收请求&lt;/p&gt;
&lt;p&gt;请求最先到达 Servlet 容器，比如 Tomcat、Jetty、Undertow。&lt;/p&gt;
&lt;p&gt;以 Tomcat 为例，它启动后会监听指定端口，比如 &lt;code&gt;8080&lt;/code&gt;。客户端发起请求时，会先通过 TCP 连接到 Tomcat 监听的端口。&lt;/p&gt;
&lt;p&gt;Tomcat 底层会接收客户端建立的 TCP 连接，读取 HTTP 请求报文。请求报文一般包括：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;请求行：GET /user/1 HTTP/1.1
请求头：Host、Content-Type、Cookie、User-Agent
请求体：POST 请求里的 JSON 或表单数据
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tomcat 会把原始 HTTP 报文解析成 Java Web 里的对象：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HttpServletRequest
HttpServletResponse
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;HttpServletRequest&lt;/code&gt; 里包含请求路径、请求方法、请求头、请求参数、Cookie、请求体等信息。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HttpServletResponse&lt;/code&gt; 用来写回响应状态码、响应头和响应体。&lt;/p&gt;
&lt;p&gt;一个 Tomcat 里可以部署多个 Web 应用。Tomcat 会根据域名、端口、Context Path 找到对应的 Web 应用，再根据 Servlet 映射规则把请求交给对应的 Servlet。&lt;/p&gt;
&lt;p&gt;在 Spring MVC 项目里，核心 Servlet 通常是 &lt;code&gt;DispatcherServlet&lt;/code&gt;。符合映射规则的请求会进入 Spring MVC 的统一入口。&lt;/p&gt;
&lt;p&gt;同时，Tomcat 会从线程池里取一个工作线程处理请求。这个线程会负责后续的 Filter、DispatcherServlet、Controller、Service、Mapper 调用，直到响应返回。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Filter 过滤器&lt;/p&gt;
&lt;p&gt;请求进入 Spring MVC 之前，会先经过 Filter。&lt;/p&gt;
&lt;p&gt;Filter 常用于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;登录校验。&lt;/li&gt;
&lt;li&gt;编码处理。&lt;/li&gt;
&lt;li&gt;跨域处理。&lt;/li&gt;
&lt;li&gt;日志记录。&lt;/li&gt;
&lt;li&gt;链路追踪。&lt;/li&gt;
&lt;li&gt;安全认证。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比如 Spring Security 里很多认证逻辑就是通过 Filter 链处理的。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;DispatcherServlet 前端控制器&lt;/p&gt;
&lt;p&gt;经过 Filter 后，请求会进入 Spring MVC 的核心入口：&lt;code&gt;DispatcherServlet&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;它负责接收请求、分发请求、调用 Controller、处理返回结果。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;HandlerMapping 查找处理器&lt;/p&gt;
&lt;p&gt;&lt;code&gt;DispatcherServlet&lt;/code&gt; 会通过 &lt;code&gt;HandlerMapping&lt;/code&gt; 根据请求路径和请求方法找到对应的 Controller 方法。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@GetMapping(&quot;/user/{id}&quot;)
public UserVO getUser(@PathVariable Long id) {
    return userService.getUser(id);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;HandlerMapping&lt;/code&gt; 会把 &lt;code&gt;/user/1&lt;/code&gt; 映射到这个方法上。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;HandlerAdapter 调用 Controller&lt;/p&gt;
&lt;p&gt;找到 Controller 方法后，&lt;code&gt;DispatcherServlet&lt;/code&gt; 通常通过 &lt;code&gt;HandlerAdapter&lt;/code&gt; 适配调用。&lt;/p&gt;
&lt;p&gt;这一步会处理：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;参数绑定。&lt;/li&gt;
&lt;li&gt;类型转换。&lt;/li&gt;
&lt;li&gt;参数校验。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@RequestBody&lt;/code&gt; JSON 反序列化。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@PathVariable&lt;/code&gt;、&lt;code&gt;@RequestParam&lt;/code&gt; 参数解析。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Interceptor 拦截器&lt;/p&gt;
&lt;p&gt;调用 Controller 前后，还可能经过 Spring MVC 的 Interceptor。&lt;/p&gt;
&lt;p&gt;Interceptor 常用于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;权限校验。&lt;/li&gt;
&lt;li&gt;登录态检查。&lt;/li&gt;
&lt;li&gt;接口耗时统计。&lt;/li&gt;
&lt;li&gt;业务日志。&lt;/li&gt;
&lt;li&gt;防重复提交。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Filter 属于 Servlet 规范，Interceptor 属于 Spring MVC。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Controller 调用业务层&lt;/p&gt;
&lt;p&gt;Controller 负责接收参数、做简单校验，然后调用 Service。&lt;/p&gt;
&lt;p&gt;一般不建议把复杂业务逻辑写在 Controller 里。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Service 处理业务逻辑&lt;/p&gt;
&lt;p&gt;Service 层负责真正的业务处理。&lt;/p&gt;
&lt;p&gt;比如事务控制、业务校验、状态流转、调用其他服务、组装数据等。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;DAO / Mapper 访问数据库&lt;/p&gt;
&lt;p&gt;Service 如果需要查数据库，会调用 DAO 或 Mapper。&lt;/p&gt;
&lt;p&gt;比如 MyBatis 的 Mapper 最终会执行 SQL，访问 MySQL。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;返回响应&lt;/p&gt;
&lt;p&gt;Controller 返回对象后，Spring MVC 会通过 &lt;code&gt;HttpMessageConverter&lt;/code&gt; 把对象转换成 JSON。&lt;/p&gt;
&lt;p&gt;最后再经过 Interceptor、Filter，返回给客户端。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：一个请求进入 Spring 项目后，先到 Tomcat 这类 Servlet 容器。Tomcat 监听端口，接收 TCP 连接，解析 HTTP 报文，并封装成 &lt;code&gt;HttpServletRequest&lt;/code&gt; 和 &lt;code&gt;HttpServletResponse&lt;/code&gt;。然后请求经过 Filter 链，进入 &lt;code&gt;DispatcherServlet&lt;/code&gt;。&lt;code&gt;DispatcherServlet&lt;/code&gt; 通过 &lt;code&gt;HandlerMapping&lt;/code&gt; 找到对应 Controller 方法，再通过 &lt;code&gt;HandlerAdapter&lt;/code&gt; 完成参数解析和方法调用。调用前后可能经过 Interceptor。Controller 调用 Service 处理业务，Service 调用 DAO 或 Mapper 访问数据库。最后返回结果经过 &lt;code&gt;HttpMessageConverter&lt;/code&gt; 转成 JSON，再通过 &lt;code&gt;DispatcherServlet&lt;/code&gt;、Filter 返回给客户端。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：Spring 中的类在启动之后，会执行哪些方法或者用到哪些注解？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Spring 中一个类被容器管理后，它会经历 Bean 的生命周期。大致流程是：创建对象、依赖注入、执行初始化回调、放入容器，后续业务使用时从容器中获取这个 Bean。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;创建 Bean 对象&lt;/p&gt;
&lt;p&gt;Spring 启动时会扫描配置类、&lt;code&gt;@Component&lt;/code&gt;、&lt;code&gt;@Service&lt;/code&gt;、&lt;code&gt;@Controller&lt;/code&gt;、&lt;code&gt;@Repository&lt;/code&gt; 等注解。&lt;/p&gt;
&lt;p&gt;扫描到 Bean 定义后，会通过构造方法创建对象。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Service
public class UserService {
    public UserService() {
        System.out.println(&quot;构造方法&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里会先执行构造方法。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;依赖注入&lt;/p&gt;
&lt;p&gt;对象创建出来后，Spring 会给属性注入依赖。&lt;/p&gt;
&lt;p&gt;常见方式有：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Autowired
private UserMapper userMapper;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者构造器注入：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public UserService(UserMapper userMapper) {
    this.userMapper = userMapper;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果是字段注入，执行顺序一般是先构造对象，再给字段注入依赖。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;执行 &lt;code&gt;Aware&lt;/code&gt; 回调&lt;/p&gt;
&lt;p&gt;如果 Bean 实现了一些 &lt;code&gt;Aware&lt;/code&gt; 接口，Spring 会把容器相关对象传进来。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;BeanNameAware&lt;/code&gt;：拿到 Bean 名称。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BeanFactoryAware&lt;/code&gt;：拿到 BeanFactory。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ApplicationContextAware&lt;/code&gt;：拿到 ApplicationContext。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些一般用于框架扩展，业务代码里不常用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;执行 BeanPostProcessor 前置处理&lt;/p&gt;
&lt;p&gt;Spring 会执行 &lt;code&gt;BeanPostProcessor#postProcessBeforeInitialization&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;很多 Spring 扩展能力都依赖它，比如一些代理、注解处理、初始化前增强。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;执行初始化方法&lt;/p&gt;
&lt;p&gt;初始化阶段常见有几种方式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;使用 &lt;code&gt;@PostConstruct&lt;/code&gt; 注解。&lt;/li&gt;
&lt;li&gt;实现 &lt;code&gt;InitializingBean&lt;/code&gt; 接口，重写 &lt;code&gt;afterPropertiesSet()&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;在 &lt;code&gt;@Bean(initMethod = &quot;init&quot;)&lt;/code&gt; 中指定初始化方法。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@PostConstruct
public void init() {
    System.out.println(&quot;初始化方法&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一步适合做一些初始化逻辑，比如加载配置、初始化本地缓存、注册资源等。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;执行 BeanPostProcessor 后置处理&lt;/p&gt;
&lt;p&gt;初始化完成后，会执行 &lt;code&gt;BeanPostProcessor#postProcessAfterInitialization&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;AOP 代理对象通常就是在这个阶段创建或返回的。&lt;/p&gt;
&lt;p&gt;所以我们拿到的 Bean 有时候已经不是原始对象，而是代理对象。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Bean 可以被业务使用&lt;/p&gt;
&lt;p&gt;前面步骤完成后，Bean 才算初始化完成，会放到 Spring 容器里。&lt;/p&gt;
&lt;p&gt;后续 Controller、Service 之间注入的就是这个完成初始化后的 Bean。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;容器关闭时执行销毁方法&lt;/p&gt;
&lt;p&gt;如果应用关闭，Spring 会执行销毁逻辑。&lt;/p&gt;
&lt;p&gt;常见方式有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;@PreDestroy&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;实现 &lt;code&gt;DisposableBean&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@Bean(destroyMethod = &quot;destroy&quot;)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：Spring 中一个类被容器管理后，会先根据 BeanDefinition 创建对象，也就是执行构造方法；然后进行依赖注入，比如处理 &lt;code&gt;@Autowired&lt;/code&gt;；接着执行 &lt;code&gt;Aware&lt;/code&gt; 回调；然后执行 &lt;code&gt;BeanPostProcessor&lt;/code&gt; 的前置处理；再执行初始化方法，比如 &lt;code&gt;@PostConstruct&lt;/code&gt;、&lt;code&gt;InitializingBean&lt;/code&gt; 的 &lt;code&gt;afterPropertiesSet&lt;/code&gt;、&lt;code&gt;@Bean&lt;/code&gt; 的 &lt;code&gt;initMethod&lt;/code&gt;；初始化后会执行 &lt;code&gt;BeanPostProcessor&lt;/code&gt; 的后置处理，AOP 代理通常也在这个阶段完成；最后 Bean 放入容器供业务使用。应用关闭时还会执行 &lt;code&gt;@PreDestroy&lt;/code&gt;、&lt;code&gt;DisposableBean&lt;/code&gt; 或 &lt;code&gt;destroyMethod&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：构造方法和 &lt;code&gt;@Autowired&lt;/code&gt; 哪个先执行？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;一般情况下，构造方法先执行，&lt;code&gt;@Autowired&lt;/code&gt; 字段注入后执行。&lt;/p&gt;
&lt;p&gt;因为 Spring 创建 Bean 时，必须先把对象 new 出来，也就是先调用构造方法。对象创建完成后，Spring 才能给这个对象的属性赋值，处理 &lt;code&gt;@Autowired&lt;/code&gt; 依赖注入。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;字段注入场景&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    public UserService() {
        System.out.println(userMapper);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里构造方法执行时，&lt;code&gt;userMapper&lt;/code&gt; 还没有完成字段注入，所以打印出来可能是 &lt;code&gt;null&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;执行顺序是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;调用构造方法创建对象
-&amp;gt; 处理 @Autowired 字段注入
-&amp;gt; 执行 @PostConstruct 等初始化方法
-&amp;gt; Bean 可以使用
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;构造器注入场景&lt;/p&gt;
&lt;p&gt;如果使用构造器注入：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Service
public class UserService {

    private final UserMapper userMapper;

    public UserService(UserMapper userMapper) {
        this.userMapper = userMapper;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Spring 会先找到 &lt;code&gt;UserMapper&lt;/code&gt; 这个依赖，然后把它作为参数传给构造方法。&lt;/p&gt;
&lt;p&gt;这种情况下，依赖在构造方法里就可以使用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;为什么推荐构造器注入&lt;/p&gt;
&lt;p&gt;构造器注入的好处是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;依赖关系更明确。&lt;/li&gt;
&lt;li&gt;可以使用 &lt;code&gt;final&lt;/code&gt; 修饰字段。&lt;/li&gt;
&lt;li&gt;对象创建完成后就是完整状态。&lt;/li&gt;
&lt;li&gt;更方便单元测试。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：如果是字段注入，构造方法先执行，&lt;code&gt;@Autowired&lt;/code&gt; 后执行，所以在构造方法里使用 &lt;code&gt;@Autowired&lt;/code&gt; 字段可能拿到 &lt;code&gt;null&lt;/code&gt;。如果是构造器注入，Spring 会先解析构造方法参数依赖，再调用构造方法，所以构造方法里可以直接使用这个依赖。实际开发中更推荐构造器注入，因为依赖更明确，也能保证对象创建完成后就是可用状态。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：了解过 &lt;code&gt;@PostConstruct&lt;/code&gt; 注解吗？这个注解和实现 &lt;code&gt;InitializingBean&lt;/code&gt; 接口重写 &lt;code&gt;afterPropertiesSet&lt;/code&gt; 方法，哪个先执行？&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;四、Java 基础与集合&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;问题：静态代码块和构造方法，哪个先执行？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;静态代码块先执行，构造方法后执行。&lt;/p&gt;
&lt;p&gt;因为静态代码块属于类级别，类加载的时候执行；构造方法属于对象级别，创建对象的时候执行。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;静态代码块什么时候执行&lt;/p&gt;
&lt;p&gt;静态代码块在类加载初始化阶段执行，而且只执行一次。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public class User {
    static {
        System.out.println(&quot;静态代码块&quot;);
    }

    public User() {
        System.out.println(&quot;构造方法&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第一次使用 &lt;code&gt;User&lt;/code&gt; 类时，比如 &lt;code&gt;new User()&lt;/code&gt;，会先触发类加载，然后执行静态代码块。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;构造方法什么时候执行&lt;/p&gt;
&lt;p&gt;构造方法在创建对象时执行。&lt;/p&gt;
&lt;p&gt;每 new 一次对象，就会执行一次构造方法。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;new User();
new User();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出大概是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;静态代码块
构造方法
构造方法
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;静态代码块只执行一次，构造方法执行两次。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;如果还有普通代码块&lt;/p&gt;
&lt;p&gt;如果类里还有普通代码块，顺序是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;静态代码块
-&amp;gt; 普通代码块
-&amp;gt; 构造方法
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;普通代码块和构造方法都是对象级别，每次创建对象都会执行。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;如果有父类和子类&lt;/p&gt;
&lt;p&gt;如果涉及继承，执行顺序一般是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;父类静态代码块
-&amp;gt; 子类静态代码块
-&amp;gt; 父类普通代码块
-&amp;gt; 父类构造方法
-&amp;gt; 子类普通代码块
-&amp;gt; 子类构造方法
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：静态代码块先于构造方法执行。静态代码块在类加载初始化时执行，只执行一次；构造方法在创建对象时执行，每 new 一次都会执行一次。如果有继承关系，先执行父类静态代码块，再执行子类静态代码块，然后执行父类普通代码块和父类构造方法，最后执行子类普通代码块和子类构造方法。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：Java 中 &lt;code&gt;HashMap&lt;/code&gt; 和 &lt;code&gt;ConcurrentHashMap&lt;/code&gt; 有什么区别？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 和 &lt;code&gt;ConcurrentHashMap&lt;/code&gt; 最大区别是线程安全。&lt;code&gt;HashMap&lt;/code&gt; 适合单线程场景，&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 适合多线程并发读写场景。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;线程安全不同&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 线程不安全。多个线程同时 put、remove、扩容时，可能出现数据覆盖、数据不一致等问题。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 线程安全，内部通过 &lt;code&gt;CAS&lt;/code&gt;、&lt;code&gt;volatile&lt;/code&gt;、&lt;code&gt;synchronized&lt;/code&gt; 等机制保证并发安全。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;底层结构&lt;/p&gt;
&lt;p&gt;JDK 8 中，&lt;code&gt;HashMap&lt;/code&gt; 底层结构是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;数组 + 链表 + 红黑树
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 底层也是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;数组 + 链表 + 红黑树
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但它在并发控制上做了更多处理，比如节点上的 &lt;code&gt;volatile&lt;/code&gt;、桶级别加锁、CAS 插入等。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;加锁粒度&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 本身不加锁。&lt;/p&gt;
&lt;p&gt;JDK 8 的 &lt;code&gt;ConcurrentHashMap&lt;/code&gt; 主要是锁住单个桶的头节点，锁粒度比较小。&lt;/p&gt;
&lt;p&gt;多个线程操作不同桶时，可以并发执行，性能比整张表加锁更好。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;是否允许 &lt;code&gt;null&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 允许一个 &lt;code&gt;null&lt;/code&gt; key，也允许多个 &lt;code&gt;null&lt;/code&gt; value。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 不允许 &lt;code&gt;null&lt;/code&gt; key 和 &lt;code&gt;null&lt;/code&gt; value。&lt;/p&gt;
&lt;p&gt;原因是并发场景下，如果 &lt;code&gt;get(key)&lt;/code&gt; 返回 &lt;code&gt;null&lt;/code&gt;，很难区分是 key 不存在，还是 value 本身就是 &lt;code&gt;null&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;复合操作问题&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 只能保证单次操作线程安全，比如单次 &lt;code&gt;put&lt;/code&gt;、&lt;code&gt;get&lt;/code&gt;、&lt;code&gt;remove&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;但多个操作组合起来不一定是原子的，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (!map.containsKey(key)) {
    map.put(key, value);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种写法在并发场景下仍然可能有问题。&lt;/p&gt;
&lt;p&gt;如果要保证原子性，可以使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;map.putIfAbsent(key, value);
map.computeIfAbsent(key, k -&amp;gt; value);
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用场景&lt;/p&gt;
&lt;p&gt;单线程、局部变量、方法内部临时 Map，可以用 &lt;code&gt;HashMap&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;多线程共享 Map，比如缓存、统计计数、并发任务结果收集，更适合用 &lt;code&gt;ConcurrentHashMap&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：&lt;code&gt;HashMap&lt;/code&gt; 线程不安全，适合单线程场景；&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 线程安全，适合多线程共享场景。JDK 8 中两者底层都是数组、链表、红黑树，但 &lt;code&gt;ConcurrentHashMap&lt;/code&gt; 通过 CAS、&lt;code&gt;volatile&lt;/code&gt;、&lt;code&gt;synchronized&lt;/code&gt; 等机制保证并发安全，并且锁粒度控制在桶级别。&lt;code&gt;HashMap&lt;/code&gt; 允许 &lt;code&gt;null&lt;/code&gt; key 和 &lt;code&gt;null&lt;/code&gt; value，&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 不允许。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：什么是一致性哈希？和普通哈希有什么区别？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;一致性哈希主要是为了解决分布式场景下节点扩容、缩容时，数据迁移量过大的问题。常见场景有 Redis 分片、缓存节点选择、分布式存储路由等。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;普通哈希怎么做&lt;/p&gt;
&lt;p&gt;普通哈希一般是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hash(key) % 节点数量
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比如有 3 台缓存服务器：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hash(userId) % 3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果是 0，就放到节点 0；结果是 1，就放到节点 1。&lt;/p&gt;
&lt;p&gt;这种方式简单，但问题是节点数量一变，取模结果会大面积变化。&lt;/p&gt;
&lt;p&gt;比如从 3 台机器扩容到 4 台：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hash(key) % 3
变成
hash(key) % 4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;大量 key 会被重新映射到不同节点，缓存会大面积失效，数据迁移成本也很高。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;一致性哈希怎么做&lt;/p&gt;
&lt;p&gt;一致性哈希会把哈希值组织成一个环，通常叫哈希环。&lt;/p&gt;
&lt;p&gt;可以理解成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0 ~ 2^32 - 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先把服务器节点 hash 到这个环上，再把数据 key 也 hash 到这个环上。&lt;/p&gt;
&lt;p&gt;一个 key 落到环上的某个位置后，顺时针找到的第一个服务器节点，就是它要存放的节点。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;扩容时影响更小&lt;/p&gt;
&lt;p&gt;如果新增一个节点，只会影响新节点附近的一小段数据。&lt;/p&gt;
&lt;p&gt;原来这段数据属于下一个节点，现在迁移到新节点。&lt;/p&gt;
&lt;p&gt;其他大部分 key 的归属不变。&lt;/p&gt;
&lt;p&gt;所以一致性哈希扩容时，数据迁移量更小，缓存失效范围也更小。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;缩容时影响也更小&lt;/p&gt;
&lt;p&gt;如果某个节点下线，只需要把这个节点负责的数据迁移给它顺时针方向的下一个节点。&lt;/p&gt;
&lt;p&gt;其他节点的数据基本不受影响。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;虚拟节点&lt;/p&gt;
&lt;p&gt;一致性哈希还有一个问题：如果真实节点太少，数据可能分布不均匀。&lt;/p&gt;
&lt;p&gt;所以通常会引入虚拟节点。&lt;/p&gt;
&lt;p&gt;比如一台真实机器映射成多个虚拟节点：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nodeA#1
nodeA#2
nodeA#3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样可以让节点在哈希环上分布更均匀，减少数据倾斜。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：普通哈希通常用 &lt;code&gt;hash(key) % 节点数&lt;/code&gt; 来决定数据落在哪个节点，节点数量变化时，大量 key 会重新映射。一致性哈希把节点和 key 都映射到哈希环上，key 顺时针找到的第一个节点就是目标节点。扩容或缩容时，只影响相邻区间的数据，迁移量更小。为了避免数据倾斜，通常还会引入虚拟节点。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：用过哪些设计模式？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;Web 开发里最常用、也最好结合项目讲的设计模式，可以重点说 4 个：单例模式、代理模式、策略模式和责任链模式。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;单例模式&lt;/p&gt;
&lt;p&gt;Spring 容器里的 Bean 默认就是单例。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Service
public class UserService {
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;默认情况下，整个 Spring 容器里只有一个 &lt;code&gt;UserService&lt;/code&gt; 对象。&lt;/p&gt;
&lt;p&gt;常见场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Service&lt;/li&gt;
&lt;li&gt;Mapper&lt;/li&gt;
&lt;li&gt;Controller&lt;/li&gt;
&lt;li&gt;工具类 Bean&lt;/li&gt;
&lt;li&gt;配置类 Bean&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;需要注意：单例 Bean 适合无状态对象，不要在里面随便放可变成员变量，否则多个请求同时访问时可能有线程安全问题。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;代理模式&lt;/p&gt;
&lt;p&gt;Spring AOP、事务、缓存、异步都大量用了代理模式。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Transactional
public void createOrder() {
    // 创建订单
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;@Transactional&lt;/code&gt; 的核心思路是：业务方法外面包一层代理对象，在方法执行前开启事务，执行成功后提交事务，异常时回滚事务。&lt;/p&gt;
&lt;p&gt;常见场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;事务控制&lt;/li&gt;
&lt;li&gt;日志记录&lt;/li&gt;
&lt;li&gt;权限校验&lt;/li&gt;
&lt;li&gt;接口耗时统计&lt;/li&gt;
&lt;li&gt;缓存增强&lt;/li&gt;
&lt;li&gt;异步方法&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;策略模式&lt;/p&gt;
&lt;p&gt;策略模式常用于消除大量 &lt;code&gt;if else&lt;/code&gt;，适合“同一个业务有多种处理方式”的场景。&lt;/p&gt;
&lt;p&gt;比如订单结算时，不同优惠券有不同计算规则：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;满减券：满 100 减 20
折扣券：打 8 折
立减券：直接减 10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以定义统一接口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public interface CouponStrategy {
    BigDecimal calculate(BigDecimal amount);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;满减策略：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Component(&quot;FULL_REDUCE&quot;)
public class FullReduceStrategy implements CouponStrategy {
    @Override
    public BigDecimal calculate(BigDecimal amount) {
        if (amount.compareTo(new BigDecimal(&quot;100&quot;)) &amp;gt;= 0) {
            return amount.subtract(new BigDecimal(&quot;20&quot;));
        }
        return amount;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;折扣策略：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Component(&quot;DISCOUNT&quot;)
public class DiscountStrategy implements CouponStrategy {
    @Override
    public BigDecimal calculate(BigDecimal amount) {
        return amount.multiply(new BigDecimal(&quot;0.8&quot;));
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;使用时可以让 Spring 注入所有策略实现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Service
public class CouponService {

    private final Map&amp;lt;String, CouponStrategy&amp;gt; strategyMap;

    public CouponService(Map&amp;lt;String, CouponStrategy&amp;gt; strategyMap) {
        this.strategyMap = strategyMap;
    }

    public BigDecimal calculate(String type, BigDecimal amount) {
        CouponStrategy strategy = strategyMap.get(type);
        return strategy.calculate(amount);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样新增一种优惠券时，只需要新增一个策略类，不用改原来的大段判断逻辑。&lt;/p&gt;
&lt;p&gt;常见场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;优惠券计算&lt;/li&gt;
&lt;li&gt;支付方式选择&lt;/li&gt;
&lt;li&gt;运费计算&lt;/li&gt;
&lt;li&gt;消息发送渠道&lt;/li&gt;
&lt;li&gt;风控规则&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;责任链模式&lt;/p&gt;
&lt;p&gt;责任链模式适合多个处理器按顺序处理请求。&lt;/p&gt;
&lt;p&gt;Web 里最常见的是 Filter 链和 Interceptor 链。&lt;/p&gt;
&lt;p&gt;比如一个接口请求进来，需要经过多层校验：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;参数校验
-&amp;gt; 登录校验
-&amp;gt; 权限校验
-&amp;gt; 限流校验
-&amp;gt; 风控校验
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以抽象一个处理器接口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public interface RequestHandler {
    void handle(RequestContext context);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;参数校验处理器：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Component
public class ParamCheckHandler implements RequestHandler {
    @Override
    public void handle(RequestContext context) {
        if (context.getUserId() == null) {
            throw new RuntimeException(&quot;用户 ID 不能为空&quot;);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;登录校验处理器：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Component
public class LoginCheckHandler implements RequestHandler {
    @Override
    public void handle(RequestContext context) {
        if (!context.isLogin()) {
            throw new RuntimeException(&quot;用户未登录&quot;);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;统一执行责任链：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Service
public class RequestCheckChain {

    private final List&amp;lt;RequestHandler&amp;gt; handlers;

    public RequestCheckChain(List&amp;lt;RequestHandler&amp;gt; handlers) {
        this.handlers = handlers;
    }

    public void check(RequestContext context) {
        for (RequestHandler handler : handlers) {
            handler.handle(context);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样以后要新增限流校验，只需要新增一个 &lt;code&gt;RateLimitHandler&lt;/code&gt;，放到责任链里，不需要改原来每个校验逻辑。&lt;/p&gt;
&lt;p&gt;常见场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;登录校验&lt;/li&gt;
&lt;li&gt;权限校验&lt;/li&gt;
&lt;li&gt;限流&lt;/li&gt;
&lt;li&gt;参数校验&lt;/li&gt;
&lt;li&gt;风控校验&lt;/li&gt;
&lt;li&gt;审批流&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：Web 开发里常用的设计模式可以重点说单例、代理、策略和责任链。单例体现在 Spring Bean 默认单例；代理体现在 Spring AOP、事务、缓存；策略适合支付方式、优惠券、运费计算这类多种业务规则选择；责任链常见于 Filter、Interceptor、登录校验、权限校验和限流。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;五、并发编程&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;问题：线程的创建方式有哪些？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;线程创建方式常见有 4 种。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;继承 &lt;code&gt;Thread&lt;/code&gt; 类&lt;/p&gt;
&lt;p&gt;可以自定义类继承 &lt;code&gt;Thread&lt;/code&gt;，重写 &lt;code&gt;run()&lt;/code&gt; 方法，然后调用 &lt;code&gt;start()&lt;/code&gt; 启动线程。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println(&quot;线程执行&quot;);
    }
}

new MyThread().start();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种方式简单，但 Java 是单继承，继承了 &lt;code&gt;Thread&lt;/code&gt; 后就不能再继承其他类。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;实现 &lt;code&gt;Runnable&lt;/code&gt; 接口&lt;/p&gt;
&lt;p&gt;可以实现 &lt;code&gt;Runnable&lt;/code&gt; 接口，把任务逻辑写在 &lt;code&gt;run()&lt;/code&gt; 方法里，再交给 &lt;code&gt;Thread&lt;/code&gt; 执行。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println(&quot;线程执行&quot;);
    }
}

new Thread(new MyRunnable()).start();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种方式更常用，因为任务和线程本身分离，也不会占用类的继承位置。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;实现 &lt;code&gt;Callable&lt;/code&gt; 接口&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Callable&lt;/code&gt; 和 &lt;code&gt;Runnable&lt;/code&gt; 类似，也是定义线程任务。&lt;/p&gt;
&lt;p&gt;区别是 &lt;code&gt;Callable&lt;/code&gt; 有返回值，也可以抛异常。通常配合 &lt;code&gt;FutureTask&lt;/code&gt; 或线程池使用。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Callable&amp;lt;Integer&amp;gt; callable = () -&amp;gt; {
    return 1;
};

FutureTask&amp;lt;Integer&amp;gt; task = new FutureTask&amp;lt;&amp;gt;(callable);
new Thread(task).start();

Integer result = task.get();
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用线程池&lt;/p&gt;
&lt;p&gt;实际开发中更推荐使用线程池，比如 &lt;code&gt;ThreadPoolExecutor&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;线程池可以复用线程，避免频繁创建和销毁线程，也方便控制并发数量。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ExecutorService executor = Executors.newFixedThreadPool(5);

executor.submit(() -&amp;gt; {
    System.out.println(&quot;线程池执行任务&quot;);
});

executor.shutdown();
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：线程创建方式主要有继承 &lt;code&gt;Thread&lt;/code&gt;、实现 &lt;code&gt;Runnable&lt;/code&gt;、实现 &lt;code&gt;Callable&lt;/code&gt; 配合 &lt;code&gt;FutureTask&lt;/code&gt;、使用线程池。实际开发中更推荐使用线程池，因为可以复用线程、控制并发数量，也更方便统一管理任务。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：实现 &lt;code&gt;Runnable&lt;/code&gt; 接口创建线程和实现 &lt;code&gt;Callable&lt;/code&gt; 接口创建线程有什么区别？哪一种接口可以拿到执行结果？&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：一般线程池通过什么方式来创建？线程池有哪些核心参数？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;线程池一般推荐通过 &lt;code&gt;ThreadPoolExecutor&lt;/code&gt; 手动创建，不建议直接使用 &lt;code&gt;Executors&lt;/code&gt; 的快捷方法。因为 &lt;code&gt;Executors&lt;/code&gt; 有些默认参数不太安全，比如无界队列、最大线程数过大，任务量大时可能导致内存占用过高或者线程数失控。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;线程池创建方式&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ThreadPoolExecutor executor = new ThreadPoolExecutor(
    10,
    20,
    60L,
    TimeUnit.SECONDS,
    new ArrayBlockingQueue&amp;lt;&amp;gt;(1000),
    new ThreadFactory() {
        @Override
        public Thread newThread(Runnable r) {
            return new Thread(r, &quot;biz-thread-&quot; + r.hashCode());
        }
    },
    new ThreadPoolExecutor.CallerRunsPolicy()
);
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;核心参数&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ThreadPoolExecutor&lt;/code&gt; 主要有 7 个核心参数。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;corePoolSize&lt;/code&gt;：核心线程数。任务来了以后，如果当前线程数小于核心线程数，会优先创建核心线程执行任务。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;maximumPoolSize&lt;/code&gt;：最大线程数。当核心线程都忙，任务队列也满了，线程池才会继续创建非核心线程，直到达到最大线程数。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;keepAliveTime&lt;/code&gt;：空闲线程存活时间。非核心线程空闲超过这个时间后会被回收。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;unit&lt;/code&gt;：时间单位，配合 &lt;code&gt;keepAliveTime&lt;/code&gt; 使用，比如秒、毫秒、分钟。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;workQueue&lt;/code&gt;：任务队列。当核心线程都在忙时，新任务会进入任务队列等待。&lt;/p&gt;
&lt;p&gt;常见队列有：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ArrayBlockingQueue：有界队列
LinkedBlockingQueue：链表队列，可以有界也可以无界
SynchronousQueue：不存任务，直接交给线程执行
PriorityBlockingQueue：优先级队列
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;实际开发一般建议使用有界队列，避免任务无限堆积。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;threadFactory&lt;/code&gt;：线程工厂。用来创建线程，可以设置线程名称、是否守护线程、异常处理等。设置线程名称很重要，方便排查问题。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;handler&lt;/code&gt;：拒绝策略。当线程数达到最大线程数，并且队列也满了，就会触发拒绝策略。&lt;/p&gt;
&lt;p&gt;常见拒绝策略：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;AbortPolicy：直接抛异常，默认策略
CallerRunsPolicy：由提交任务的线程自己执行
DiscardPolicy：直接丢弃任务，不抛异常
DiscardOldestPolicy：丢弃队列里最旧的任务
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;线程池扩容策略&lt;/p&gt;
&lt;p&gt;线程池不是一开始就直接创建到最大线程数，而是按下面这个顺序处理任务：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;提交任务
-&amp;gt; 当前线程数 &amp;lt; corePoolSize，创建核心线程执行
-&amp;gt; 当前线程数 &amp;gt;= corePoolSize，任务进入队列
-&amp;gt; 队列满了，并且当前线程数 &amp;lt; maximumPoolSize，创建非核心线程执行
-&amp;gt; 当前线程数 &amp;gt;= maximumPoolSize，队列也满了，触发拒绝策略
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说，线程池的扩容顺序是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;先创建核心线程
-&amp;gt; 核心线程满了先排队
-&amp;gt; 队列满了再创建非核心线程
-&amp;gt; 最大线程数也满了再拒绝
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个点很容易被问到：线程池不是核心线程满了就马上扩容到最大线程数，而是先把任务放进队列，队列满了才会继续创建非核心线程。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;线程回收&lt;/p&gt;
&lt;p&gt;当任务高峰过去后，非核心线程如果空闲时间超过 &lt;code&gt;keepAliveTime&lt;/code&gt;，就会被回收。&lt;/p&gt;
&lt;p&gt;默认情况下，核心线程不会被回收。&lt;/p&gt;
&lt;p&gt;但如果调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;allowCoreThreadTimeOut(true);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;核心线程空闲超过 &lt;code&gt;keepAliveTime&lt;/code&gt; 后也可以被回收。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;实际开发怎么设置&lt;/p&gt;
&lt;p&gt;一般会根据业务类型设置线程池：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CPU 密集型任务：线程数接近 CPU 核心数。&lt;/li&gt;
&lt;li&gt;IO 密集型任务：线程数可以适当大一些，因为很多时间在等待 IO。&lt;/li&gt;
&lt;li&gt;队列建议用有界队列。&lt;/li&gt;
&lt;li&gt;线程名要有业务含义，比如 &lt;code&gt;order-thread-1&lt;/code&gt;、&lt;code&gt;pay-thread-1&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;拒绝策略要结合业务选择，不能随便丢任务。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：线程池一般推荐通过 &lt;code&gt;ThreadPoolExecutor&lt;/code&gt; 手动创建。核心参数有 &lt;code&gt;corePoolSize&lt;/code&gt;、&lt;code&gt;maximumPoolSize&lt;/code&gt;、&lt;code&gt;keepAliveTime&lt;/code&gt;、&lt;code&gt;unit&lt;/code&gt;、&lt;code&gt;workQueue&lt;/code&gt;、&lt;code&gt;threadFactory&lt;/code&gt;、&lt;code&gt;handler&lt;/code&gt;。线程池处理任务时，先创建核心线程；核心线程满了，任务先进入队列；队列满了，才创建非核心线程；线程数达到 &lt;code&gt;maximumPoolSize&lt;/code&gt; 且队列也满了，就触发拒绝策略。高峰过后，非核心线程空闲超过 &lt;code&gt;keepAliveTime&lt;/code&gt; 会被回收。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：谈谈线程池工作的流程。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：核心线程是一开始就创建，还是任务来了才创建？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;默认情况下，核心线程不是线程池创建时就全部创建好的，而是任务来了之后才逐步创建。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;corePoolSize&lt;/code&gt; 只是表示线程池最多保留多少个核心线程，不代表线程池一创建就马上创建这么多线程。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;corePoolSize = 10
maximumPoolSize = 20
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;线程池刚创建出来时，线程数通常是 0。&lt;/p&gt;
&lt;p&gt;第 1 个任务来了，创建第 1 个核心线程。&lt;/p&gt;
&lt;p&gt;第 2 个任务来了，如果当前核心线程还没达到 &lt;code&gt;corePoolSize&lt;/code&gt;，继续创建核心线程。&lt;/p&gt;
&lt;p&gt;一直到核心线程数达到 &lt;code&gt;corePoolSize&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;达到 &lt;code&gt;corePoolSize&lt;/code&gt; 之后，如果再来任务，就优先进入队列。&lt;/p&gt;
&lt;p&gt;核心线程的特点是：创建之后默认不会因为空闲被回收。&lt;/p&gt;
&lt;p&gt;所以完整理解是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;核心线程默认按需创建
创建后默认长期保留
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果想线程池一启动就创建核心线程，可以手动调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;prestartCoreThread()
prestartAllCoreThreads()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果想让核心线程空闲时也能回收，可以调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;allowCoreThreadTimeOut(true)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;总结：核心线程默认不是线程池创建时就创建好的，而是任务提交后按需创建，直到达到 &lt;code&gt;corePoolSize&lt;/code&gt;。可以通过 &lt;code&gt;prestartCoreThread&lt;/code&gt; 或 &lt;code&gt;prestartAllCoreThreads&lt;/code&gt; 提前创建核心线程。核心线程默认不会因为空闲被回收，但如果设置 &lt;code&gt;allowCoreThreadTimeOut(true)&lt;/code&gt;，核心线程空闲超过 &lt;code&gt;keepAliveTime&lt;/code&gt; 后也可以被回收。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;六、系统设计与算法&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;问题：假设分别部署了 A 和 B 两个服务，A 服务需要调用 B 服务，B 服务的执行时间比较长。B 服务执行完毕后，需要把结果返回给 A 服务。请设计解决方法，如何让 A 和 B 进行交互？请给出三种方案。&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;这个场景核心是：B 服务耗时比较长，A 服务不适合一直同步阻塞等待。可以根据实时性要求设计 3 种方案。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;方案一：同步调用，设置超时&lt;/p&gt;
&lt;p&gt;如果 B 服务执行时间虽然比较长，但还在可接受范围内，比如 1 到 3 秒，可以用同步 RPC 或 HTTP 调用。&lt;/p&gt;
&lt;p&gt;流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A 调用 B
-&amp;gt; B 执行业务
-&amp;gt; B 返回结果
-&amp;gt; A 返回给前端
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要注意：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A 调用 B 要设置超时时间。&lt;/li&gt;
&lt;li&gt;B 要做好幂等，避免 A 超时重试导致重复执行。&lt;/li&gt;
&lt;li&gt;A 要有降级逻辑，比如超时返回“处理中”或“稍后查询”。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;适合场景：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;耗时可控
调用链简单
前端需要马上拿到结果
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;方案二：异步消息队列 + 回调通知&lt;/p&gt;
&lt;p&gt;如果 B 服务执行时间比较长，不适合 A 一直等，可以用 MQ 异步解耦。&lt;/p&gt;
&lt;p&gt;流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A 接收请求
-&amp;gt; A 生成任务 ID
-&amp;gt; A 把任务消息发送到 MQ
-&amp;gt; A 先返回“任务已提交”
-&amp;gt; B 消费 MQ 执行任务
-&amp;gt; B 执行完成后回调 A
-&amp;gt; A 更新任务状态和结果
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A 可以提供一个回调接口，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /task/callback
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;B 完成后调用这个接口，把任务结果返回给 A。&lt;/p&gt;
&lt;p&gt;需要注意：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;消息要有唯一任务 ID。&lt;/li&gt;
&lt;li&gt;B 消费消息要做幂等。&lt;/li&gt;
&lt;li&gt;回调 A 失败要重试。&lt;/li&gt;
&lt;li&gt;A 要保存任务状态，比如 &lt;code&gt;处理中&lt;/code&gt;、&lt;code&gt;成功&lt;/code&gt;、&lt;code&gt;失败&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;可以加死信队列处理失败任务。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;适合场景：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;B 执行耗时长
不要求前端立即拿到最终结果
需要削峰解耦
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;方案三：异步任务 + A 轮询查询结果&lt;/p&gt;
&lt;p&gt;也可以不让 B 主动回调 A，而是 A 或前端通过任务 ID 查询结果。&lt;/p&gt;
&lt;p&gt;流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A 接收请求
-&amp;gt; A 创建任务记录，状态为处理中
-&amp;gt; A 通知 B 执行任务
-&amp;gt; A 返回 taskId 给前端
-&amp;gt; B 执行完成后把结果写入数据库或 Redis
-&amp;gt; 前端拿 taskId 轮询 A 查询任务状态
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查询接口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GET /task/{taskId}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;返回：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;处理中
成功 + 结果
失败 + 原因
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要注意：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;任务状态要持久化。&lt;/li&gt;
&lt;li&gt;查询接口可以加缓存，避免频繁查库。&lt;/li&gt;
&lt;li&gt;前端轮询要控制频率。&lt;/li&gt;
&lt;li&gt;任务超时要有补偿机制。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;适合场景：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;任务耗时较长
结果不需要实时推送
前端可以接受查询进度
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：如果 B 执行时间可控，可以用同步调用，但要设置超时、重试、降级和幂等。如果 B 执行时间较长，更推荐异步方案：一种是 A 发 MQ，B 消费完成后回调 A；另一种是 A 返回 &lt;code&gt;taskId&lt;/code&gt;，B 执行完成后落库，前端或 A 通过 &lt;code&gt;taskId&lt;/code&gt; 查询结果。实际项目里通常会用 MQ + 任务状态表 + 幂等 + 重试补偿来保证可靠性。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：假如有两个很大的集合，每个集合本身的数据不重复，但是两个集合之间的数据存在重复。集合很大，加载到内存中会出现问题，请从数据结构和算法角度考虑，怎么找到两个大集合的重复元素？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;这个问题不能直接把两个集合都加载到内存里做 &lt;code&gt;HashSet&lt;/code&gt; 交集，因为数据太大，内存放不下。常见思路是分片 + 哈希 + 小文件内存比对。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;先用 Hash 分片&lt;/p&gt;
&lt;p&gt;假设有两个大文件或两个大集合：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A 集合
B 集合
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对每个元素做 hash，然后按照 hash 值取模分到不同的小文件里：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hash(value) % N
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比如分成 1000 份：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A0, A1, A2 ... A999
B0, B1, B2 ... B999
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同一个元素经过同样的 hash 规则，一定会落到同一个编号的分片里。&lt;/p&gt;
&lt;p&gt;也就是说：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A 里的 x 如果落到 A10
B 里的 x 一定也会落到 B10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样只需要比较相同编号的小文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A0 和 B0
A1 和 B1
...
A999 和 B999
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不需要全量两两比较。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;每个小分片内做 HashSet 比对&lt;/p&gt;
&lt;p&gt;如果每个小文件已经可以放进内存，就可以逐个处理。&lt;/p&gt;
&lt;p&gt;比如处理 &lt;code&gt;A10&lt;/code&gt; 和 &lt;code&gt;B10&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;先把 A10 加载到 HashSet
再遍历 B10
如果 B10 里的元素在 HashSet 中存在，就说明它是重复元素
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;伪代码：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Set&amp;lt;String&amp;gt; set = loadToHashSet(&quot;A10&quot;);

for (String value : read(&quot;B10&quot;)) {
    if (set.contains(value)) {
        // value 是重复元素
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;处理完一个分片就释放内存，再处理下一个分片。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;总结：两个超大集合找重复元素，不能直接全部加载到内存。常用方案是 hash 分片：对两个集合里的元素使用同一个 hash 函数取模，分别拆成多个小分片。相同元素一定会落到相同编号的分片里，然后逐个把 A 的某个分片加载成 HashSet，再遍历 B 的对应分片找交集。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>京东后端面试复盘（一）</title><link>https://blog.huangnv.online/posts/2676/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2676/1/1/</guid><pubDate>Mon, 06 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;京东面试&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;日期：2026-07-06&lt;br /&gt;
类型：Java 后端面试问题整理&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;面试问题整理&lt;/h2&gt;
&lt;h3&gt;一、Java 基础&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;问题：&lt;code&gt;==&lt;/code&gt; 和 &lt;code&gt;equals&lt;/code&gt; 的区别。&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;==&lt;/code&gt; 和 &lt;code&gt;equals&lt;/code&gt; 的区别，主要看比较的是基本数据类型还是引用类型。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;==&lt;/code&gt; 的作用&lt;/p&gt;
&lt;p&gt;如果比较的是基本数据类型，&lt;code&gt;==&lt;/code&gt; 比较的是具体的值。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;int a = 10;
int b = 10;
System.out.println(a == b); // true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果比较的是引用类型，&lt;code&gt;==&lt;/code&gt; 比较的是两个引用是否指向同一个对象，也就是对象地址是否相同。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;User u1 = new User();
User u2 = new User();
System.out.println(u1 == u2); // false
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;equals&lt;/code&gt; 的作用&lt;/p&gt;
&lt;p&gt;&lt;code&gt;equals&lt;/code&gt; 是 &lt;code&gt;Object&lt;/code&gt; 类提供的方法，默认实现和 &lt;code&gt;==&lt;/code&gt; 类似，也是比较对象地址。&lt;/p&gt;
&lt;p&gt;但是很多类会重写 &lt;code&gt;equals&lt;/code&gt; 方法，用来比较对象内容。比如 &lt;code&gt;String&lt;/code&gt;、&lt;code&gt;Integer&lt;/code&gt; 这类常见类型，&lt;code&gt;equals&lt;/code&gt; 比较的是内容值。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String a = new String(&quot;abc&quot;);
String b = new String(&quot;abc&quot;);

System.out.println(a == b);      // false
System.out.println(a.equals(b)); // true
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;String&lt;/code&gt; 的特殊情况&lt;/p&gt;
&lt;p&gt;字符串字面量会进入字符串常量池。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String s1 = &quot;abc&quot;;
String s2 = &quot;abc&quot;;
System.out.println(s1 == s2); // true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;new String(&quot;abc&quot;)&lt;/code&gt; 中的 &lt;code&gt;&quot;abc&quot;&lt;/code&gt; 会放在字符串常量池中，但 &lt;code&gt;new&lt;/code&gt; 会额外创建堆对象，变量最终指向堆对象。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String s1 = &quot;abc&quot;;
String s2 = new String(&quot;abc&quot;);

System.out.println(s1 == s2);      // false
System.out.println(s1.equals(s2)); // true
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：&lt;code&gt;String&lt;/code&gt;、&lt;code&gt;StringBuilder&lt;/code&gt;、&lt;code&gt;StringBuffer&lt;/code&gt; 的区别。&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;String&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;String&lt;/code&gt; 是不可变对象，一旦创建，内容就不能被修改。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String s = &quot;abc&quot;;
s = s + &quot;def&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里看起来是修改了 &lt;code&gt;s&lt;/code&gt;，实际是创建了一个新的字符串对象，然后让 &lt;code&gt;s&lt;/code&gt; 指向新对象。原来的 &lt;code&gt;&quot;abc&quot;&lt;/code&gt; 没有被修改。&lt;/p&gt;
&lt;p&gt;所以如果在循环里频繁拼接字符串，用 &lt;code&gt;String&lt;/code&gt; 会产生很多临时对象，性能较差。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;StringBuilder&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;StringBuilder&lt;/code&gt; 是可变的字符串对象，底层维护的是一个可以扩容的字符数组。&lt;/p&gt;
&lt;p&gt;它适合在单线程场景下频繁拼接字符串。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;StringBuilder sb = new StringBuilder();
sb.append(&quot;abc&quot;);
sb.append(&quot;def&quot;);
System.out.println(sb.toString());
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;StringBuilder&lt;/code&gt; 没有加锁，线程不安全，但性能比较高。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;StringBuffer&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;StringBuffer&lt;/code&gt; 也是可变的字符串对象，用法和 &lt;code&gt;StringBuilder&lt;/code&gt; 类似。&lt;/p&gt;
&lt;p&gt;区别是 &lt;code&gt;StringBuffer&lt;/code&gt; 的很多方法加了 &lt;code&gt;synchronized&lt;/code&gt;，所以它是线程安全的。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;StringBuffer sb = new StringBuffer();
sb.append(&quot;abc&quot;);
sb.append(&quot;def&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为加锁会有额外开销，所以单线程场景下通常不用 &lt;code&gt;StringBuffer&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用场景&lt;/p&gt;
&lt;p&gt;&lt;code&gt;String&lt;/code&gt; 适合字符串内容固定、修改少的场景，比如常量、配置值、普通字段。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;StringBuilder&lt;/code&gt; 适合单线程下频繁拼接字符串，是实际开发中最常用的拼接工具。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;StringBuffer&lt;/code&gt; 适合多线程共享同一个字符串对象并且需要保证线程安全的场景，不过现在这种场景相对少见。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：&lt;code&gt;List&lt;/code&gt; 和 &lt;code&gt;Set&lt;/code&gt; 的区别。&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;List&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;List&lt;/code&gt; 是有序集合，元素可以重复。&lt;/p&gt;
&lt;p&gt;有序指的是元素按照插入顺序保存，可以通过下标访问。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;List&amp;lt;String&amp;gt; list = new ArrayList&amp;lt;&amp;gt;();
list.add(&quot;A&quot;);
list.add(&quot;A&quot;);
list.add(&quot;B&quot;);

System.out.println(list.get(0)); // A
System.out.println(list);        // [A, A, B]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见实现类有 &lt;code&gt;ArrayList&lt;/code&gt;、&lt;code&gt;LinkedList&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Set&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Set&lt;/code&gt; 是不允许重复元素的集合。&lt;/p&gt;
&lt;p&gt;它一般不能通过下标访问元素，主要用于去重。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Set&amp;lt;String&amp;gt; set = new HashSet&amp;lt;&amp;gt;();
set.add(&quot;A&quot;);
set.add(&quot;A&quot;);
set.add(&quot;B&quot;);

System.out.println(set); // [A, B]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见实现类有 &lt;code&gt;HashSet&lt;/code&gt;、&lt;code&gt;LinkedHashSet&lt;/code&gt;、&lt;code&gt;TreeSet&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;是否有序&lt;/p&gt;
&lt;p&gt;&lt;code&gt;List&lt;/code&gt; 是有序的，按照插入顺序保存。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Set&lt;/code&gt; 要看具体实现：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;HashSet&lt;/code&gt; 不保证顺序。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LinkedHashSet&lt;/code&gt; 可以保持插入顺序。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TreeSet&lt;/code&gt; 会按照排序规则保存。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用场景&lt;/p&gt;
&lt;p&gt;如果需要保留重复数据，或者需要通过下标访问元素，一般用 &lt;code&gt;List&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;如果主要目的是去重，或者只关心元素是否存在，一般用 &lt;code&gt;Set&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：&lt;code&gt;ArrayList&lt;/code&gt; 和 &lt;code&gt;LinkedList&lt;/code&gt; 的区别。&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;底层数据结构不同&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ArrayList&lt;/code&gt; 底层是动态数组。&lt;/p&gt;
&lt;p&gt;它在内存中是一段连续空间，所以可以通过下标快速访问元素。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;LinkedList&lt;/code&gt; 底层是双向链表。&lt;/p&gt;
&lt;p&gt;每个节点保存当前元素，同时保存前一个节点和后一个节点的引用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;查询效率不同&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ArrayList&lt;/code&gt; 支持下标随机访问，查询效率高，时间复杂度是 &lt;code&gt;O(1)&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;list.get(10);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;LinkedList&lt;/code&gt; 通过下标查询时，需要从头或者从尾遍历链表，时间复杂度是 &lt;code&gt;O(n)&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;所以按照下标查询元素，&lt;code&gt;ArrayList&lt;/code&gt; 更合适。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;插入和删除效率不同&lt;/p&gt;
&lt;p&gt;如果是在末尾追加元素，&lt;code&gt;ArrayList&lt;/code&gt; 效率也很高。&lt;/p&gt;
&lt;p&gt;如果是在中间插入或删除元素，&lt;code&gt;ArrayList&lt;/code&gt; 需要移动后面的元素，开销比较大。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;LinkedList&lt;/code&gt; 如果已经定位到节点，插入和删除只需要修改前后节点的引用，效率比较高。&lt;/p&gt;
&lt;p&gt;但是如果是通过下标先去找位置，&lt;code&gt;LinkedList&lt;/code&gt; 也要先遍历，所以整体不一定比 &lt;code&gt;ArrayList&lt;/code&gt; 快。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;内存占用不同&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ArrayList&lt;/code&gt; 主要存储元素本身，额外开销较小。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;LinkedList&lt;/code&gt; 每个节点都要保存前后引用，所以内存占用更高。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用场景&lt;/p&gt;
&lt;p&gt;如果查询多、遍历多、按下标访问多，一般用 &lt;code&gt;ArrayList&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;如果频繁在头部或中间插入删除，并且能直接定位到节点，可以考虑 &lt;code&gt;LinkedList&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;实际开发中，&lt;code&gt;ArrayList&lt;/code&gt; 使用更多，因为它查询快、内存占用低、CPU 缓存友好。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：&lt;code&gt;HashMap&lt;/code&gt; 和 &lt;code&gt;Hashtable&lt;/code&gt; 的区别，扩容机制是什么，为什么它们的起始容量不一样？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;线程安全不同&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 是线程不安全的，多线程同时修改时可能出现数据不一致问题。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Hashtable&lt;/code&gt; 的方法基本都加了 &lt;code&gt;synchronized&lt;/code&gt;，所以它是线程安全的。&lt;/p&gt;
&lt;p&gt;但是 &lt;code&gt;Hashtable&lt;/code&gt; 是整张表加锁，并发性能比较差。现在多线程场景一般使用 &lt;code&gt;ConcurrentHashMap&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;是否允许 &lt;code&gt;null&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 允许一个 &lt;code&gt;null&lt;/code&gt; key，也允许多个 &lt;code&gt;null&lt;/code&gt; value。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Map&amp;lt;String, String&amp;gt; map = new HashMap&amp;lt;&amp;gt;();
map.put(null, &quot;A&quot;);
map.put(&quot;B&quot;, null);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Hashtable&lt;/code&gt; 不允许 &lt;code&gt;null&lt;/code&gt; key 和 &lt;code&gt;null&lt;/code&gt; value，否则会抛 &lt;code&gt;NullPointerException&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;继承结构不同&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 继承自 &lt;code&gt;AbstractMap&lt;/code&gt;，是比较新的集合框架实现。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Hashtable&lt;/code&gt; 继承自 &lt;code&gt;Dictionary&lt;/code&gt;，属于比较早期的集合类，现在实际开发中用得比较少。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;扩容机制不同&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 默认初始容量是 &lt;code&gt;16&lt;/code&gt;，负载因子是 &lt;code&gt;0.75&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;当元素数量超过：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;容量 * 负载因子
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;就会触发扩容。&lt;/p&gt;
&lt;p&gt;比如默认容量是 &lt;code&gt;16&lt;/code&gt;，阈值就是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;16 * 0.75 = 12
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当元素数量超过 &lt;code&gt;12&lt;/code&gt; 后，会扩容为原来的 &lt;code&gt;2&lt;/code&gt; 倍，也就是从 &lt;code&gt;16&lt;/code&gt; 扩到 &lt;code&gt;32&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Hashtable&lt;/code&gt; 默认初始容量是 &lt;code&gt;11&lt;/code&gt;，负载因子也是 &lt;code&gt;0.75&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;扩容时容量变为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;原容量 * 2 + 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比如从 &lt;code&gt;11&lt;/code&gt; 扩容到：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;11 * 2 + 1 = 23
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;为什么起始容量不一样&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 的容量设计成 &lt;code&gt;2&lt;/code&gt; 的幂，比如 &lt;code&gt;16&lt;/code&gt;、&lt;code&gt;32&lt;/code&gt;、&lt;code&gt;64&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这样计算数组下标时，可以用位运算：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;index = (n - 1) &amp;amp; hash
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;位运算效率高，而且配合扰动函数可以让元素分布更均匀。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Hashtable&lt;/code&gt; 是早期设计，默认容量是 &lt;code&gt;11&lt;/code&gt;，扩容规则是 &lt;code&gt;2n + 1&lt;/code&gt;，更偏向使用奇数容量来减少哈希冲突。&lt;/p&gt;
&lt;p&gt;现在实际开发中，基本优先使用 &lt;code&gt;HashMap&lt;/code&gt;；如果需要并发安全，一般使用 &lt;code&gt;ConcurrentHashMap&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 如何实现并发安全？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;JDK 7 的实现方式&lt;/p&gt;
&lt;p&gt;JDK 7 里的 &lt;code&gt;ConcurrentHashMap&lt;/code&gt; 使用的是分段锁，也就是 &lt;code&gt;Segment&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;整个 Map 会被拆成多个 &lt;code&gt;Segment&lt;/code&gt;，每个 &lt;code&gt;Segment&lt;/code&gt; 类似一个小的 &lt;code&gt;HashMap&lt;/code&gt;，并且每个 &lt;code&gt;Segment&lt;/code&gt; 自己加锁。&lt;/p&gt;
&lt;p&gt;多个线程操作不同的 &lt;code&gt;Segment&lt;/code&gt; 时，可以并发执行，减少锁竞争。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ConcurrentHashMap
  Segment 1
  Segment 2
  Segment 3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种方式比 &lt;code&gt;Hashtable&lt;/code&gt; 整张表加锁性能更好。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;JDK 8 的实现方式&lt;/p&gt;
&lt;p&gt;JDK 8 取消了 &lt;code&gt;Segment&lt;/code&gt;，底层结构和 &lt;code&gt;HashMap&lt;/code&gt; 类似，主要是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;数组 + 链表 + 红黑树
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JDK 8 主要通过 &lt;code&gt;CAS + synchronized&lt;/code&gt; 保证并发安全。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;查询为什么不加锁&lt;/p&gt;
&lt;p&gt;&lt;code&gt;get&lt;/code&gt; 操作一般不加锁，主要依赖 &lt;code&gt;volatile&lt;/code&gt; 保证可见性。&lt;/p&gt;
&lt;p&gt;比如数组节点、节点里的 value 等关键字段会通过 &lt;code&gt;volatile&lt;/code&gt; 保证一个线程修改后，其他线程能看到最新结果。&lt;/p&gt;
&lt;p&gt;所以读操作效率比较高。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;写入如何保证线程安全&lt;/p&gt;
&lt;p&gt;写入时分几种情况：&lt;/p&gt;
&lt;p&gt;如果当前位置为空，会使用 &lt;code&gt;CAS&lt;/code&gt; 尝试直接放入新节点。&lt;/p&gt;
&lt;p&gt;如果当前位置不为空，会对当前桶的头节点加 &lt;code&gt;synchronized&lt;/code&gt; 锁，然后在这个桶里面进行链表插入、红黑树插入或者覆盖旧值。&lt;/p&gt;
&lt;p&gt;这样锁的粒度是桶级别的，不会锁整张表。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;扩容如何处理&lt;/p&gt;
&lt;p&gt;扩容时，&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 支持多个线程一起迁移数据。&lt;/p&gt;
&lt;p&gt;当一个线程发现正在扩容时，可以帮忙迁移一部分桶的数据，这样可以提高扩容效率，减少单个线程扩容压力。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;总结&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 的并发安全主要依赖更细粒度的锁。JDK 7 使用 &lt;code&gt;Segment&lt;/code&gt; 分段锁；JDK 8 使用 &lt;code&gt;CAS + synchronized + volatile&lt;/code&gt;，读操作大多无锁，写操作只锁当前桶，所以并发性能比 &lt;code&gt;Hashtable&lt;/code&gt; 更好。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;二、并发编程&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;问题：&lt;code&gt;synchronized&lt;/code&gt; 锁和 &lt;code&gt;ReentrantLock&lt;/code&gt; 的区别。&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;基本区别&lt;/p&gt;
&lt;p&gt;&lt;code&gt;synchronized&lt;/code&gt; 是 Java 关键字，由 JVM 层面支持，使用起来比较简单，进入同步代码块自动加锁，退出时自动释放锁。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ReentrantLock&lt;/code&gt; 是 JUC 包下的锁类，底层主要基于 AQS 实现，需要手动调用 &lt;code&gt;lock()&lt;/code&gt; 和 &lt;code&gt;unlock()&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    // 临界区代码
} finally {
    lock.unlock();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;是否可重入&lt;/p&gt;
&lt;p&gt;两者都是可重入锁。&lt;/p&gt;
&lt;p&gt;同一个线程已经拿到锁后，可以再次进入同一把锁保护的代码。&lt;code&gt;synchronized&lt;/code&gt; 由 JVM 维护重入次数，&lt;code&gt;ReentrantLock&lt;/code&gt; 通过 AQS 的 &lt;code&gt;state&lt;/code&gt; 记录重入次数。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;功能区别&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ReentrantLock&lt;/code&gt; 比 &lt;code&gt;synchronized&lt;/code&gt; 更灵活，主要体现在：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;支持 &lt;code&gt;tryLock()&lt;/code&gt;，可以尝试获取锁，拿不到可以直接返回。&lt;/li&gt;
&lt;li&gt;支持 &lt;code&gt;tryLock(timeout)&lt;/code&gt;，可以在指定时间内等待锁。&lt;/li&gt;
&lt;li&gt;支持 &lt;code&gt;lockInterruptibly()&lt;/code&gt;，线程等待锁时可以响应中断。&lt;/li&gt;
&lt;li&gt;支持公平锁和非公平锁。&lt;/li&gt;
&lt;li&gt;支持多个 &lt;code&gt;Condition&lt;/code&gt; 条件队列，可以做更精细的等待和唤醒。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;synchronized&lt;/code&gt; 主要配合 &lt;code&gt;wait()&lt;/code&gt;、&lt;code&gt;notify()&lt;/code&gt;、&lt;code&gt;notifyAll()&lt;/code&gt; 使用，功能相对简单。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;性能区别&lt;/p&gt;
&lt;p&gt;早期 &lt;code&gt;synchronized&lt;/code&gt; 性能较差，但后面 JVM 做了很多优化，比如锁升级、轻量级锁等，所以普通场景下 &lt;code&gt;synchronized&lt;/code&gt; 和 &lt;code&gt;ReentrantLock&lt;/code&gt; 性能差距不大。&lt;/p&gt;
&lt;p&gt;现在选择哪个，更多看功能需求。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用场景&lt;/p&gt;
&lt;p&gt;普通同步代码，优先用 &lt;code&gt;synchronized&lt;/code&gt;，代码简单，也不容易忘记释放锁。&lt;/p&gt;
&lt;p&gt;如果需要可中断、超时等待、公平锁，或者多个条件队列，就用 &lt;code&gt;ReentrantLock&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：线程创建的方法有哪些？&lt;code&gt;Runnable&lt;/code&gt; 和 &lt;code&gt;Callable&lt;/code&gt; 相比有什么区别？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;继承 &lt;code&gt;Thread&lt;/code&gt; 类&lt;/p&gt;
&lt;p&gt;可以自定义类继承 &lt;code&gt;Thread&lt;/code&gt;，重写 &lt;code&gt;run()&lt;/code&gt; 方法，然后调用 &lt;code&gt;start()&lt;/code&gt; 启动线程。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println(&quot;线程执行&quot;);
    }
}

new MyThread().start();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种方式简单，但 Java 是单继承，继承了 &lt;code&gt;Thread&lt;/code&gt; 后就不能再继承其他类。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;实现 &lt;code&gt;Runnable&lt;/code&gt; 接口&lt;/p&gt;
&lt;p&gt;可以实现 &lt;code&gt;Runnable&lt;/code&gt; 接口，把任务逻辑写在 &lt;code&gt;run()&lt;/code&gt; 方法里，再交给 &lt;code&gt;Thread&lt;/code&gt; 执行。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println(&quot;线程执行&quot;);
    }
}

new Thread(new MyRunnable()).start();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种方式更常用，因为任务和线程本身分离，也不会占用类的继承位置。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;实现 &lt;code&gt;Callable&lt;/code&gt; 接口&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Callable&lt;/code&gt; 和 &lt;code&gt;Runnable&lt;/code&gt; 类似，也是定义线程任务。&lt;/p&gt;
&lt;p&gt;区别是 &lt;code&gt;Callable&lt;/code&gt; 有返回值，也可以抛异常。通常配合 &lt;code&gt;FutureTask&lt;/code&gt; 或线程池使用。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Callable&amp;lt;Integer&amp;gt; callable = () -&amp;gt; {
    return 1;
};

FutureTask&amp;lt;Integer&amp;gt; task = new FutureTask&amp;lt;&amp;gt;(callable);
new Thread(task).start();

Integer result = task.get();
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用线程池&lt;/p&gt;
&lt;p&gt;实际开发中更推荐使用线程池，比如 &lt;code&gt;ThreadPoolExecutor&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;线程池可以复用线程，避免频繁创建和销毁线程，也方便控制并发数量。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ExecutorService executor = Executors.newFixedThreadPool(5);

executor.submit(() -&amp;gt; {
    System.out.println(&quot;线程池执行任务&quot;);
});

executor.shutdown();
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Runnable&lt;/code&gt; 和 &lt;code&gt;Callable&lt;/code&gt; 的区别&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Runnable&lt;/code&gt; 的方法是 &lt;code&gt;run()&lt;/code&gt;，没有返回值，不能直接抛出受检异常。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;void run();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Callable&lt;/code&gt; 的方法是 &lt;code&gt;call()&lt;/code&gt;，有返回值，可以抛出异常。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;V call() throws Exception;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以如果只是执行一个任务，不关心结果，用 &lt;code&gt;Runnable&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;如果需要拿到执行结果，或者需要把异常抛给调用方处理，用 &lt;code&gt;Callable&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：&lt;code&gt;ThreadLocal&lt;/code&gt; 是什么？有什么问题？如何避免内存泄露？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;ThreadLocal&lt;/code&gt; 是什么&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ThreadLocal&lt;/code&gt; 可以理解成线程本地变量。&lt;/p&gt;
&lt;p&gt;它让每个线程都保存一份自己的变量副本，线程之间互不影响。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;private static final ThreadLocal&amp;lt;String&amp;gt; USER_ID = new ThreadLocal&amp;lt;&amp;gt;();

USER_ID.set(&quot;1001&quot;);
String userId = USER_ID.get();
USER_ID.remove();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比如在 Web 项目中，可以把当前登录用户 ID、请求上下文、链路追踪 ID 放到 &lt;code&gt;ThreadLocal&lt;/code&gt; 中，这样同一个线程里的不同方法都可以直接获取。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;底层原理&lt;/p&gt;
&lt;p&gt;每个 &lt;code&gt;Thread&lt;/code&gt; 对象里面都有一个 &lt;code&gt;ThreadLocalMap&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ThreadLocal&lt;/code&gt; 存数据时，实际是存到当前线程自己的 &lt;code&gt;ThreadLocalMap&lt;/code&gt; 里。&lt;/p&gt;
&lt;p&gt;可以简单理解成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Thread
  ThreadLocalMap
    key: ThreadLocal 对象
    value: 线程自己的变量值
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以不同线程使用同一个 &lt;code&gt;ThreadLocal&lt;/code&gt;，取到的值也不一样，因为它们各自访问的是自己线程里的 &lt;code&gt;ThreadLocalMap&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;有什么问题&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ThreadLocal&lt;/code&gt; 最大的问题是可能导致内存泄露。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ThreadLocalMap&lt;/code&gt; 里的 key 是弱引用，key 指向 &lt;code&gt;ThreadLocal&lt;/code&gt; 对象。&lt;/p&gt;
&lt;p&gt;但是 value 是强引用，指向真正保存的数据。&lt;/p&gt;
&lt;p&gt;如果 &lt;code&gt;ThreadLocal&lt;/code&gt; 对象已经没有外部强引用了，key 可能会被 GC 回收，变成 &lt;code&gt;null&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;但是 value 还挂在线程的 &lt;code&gt;ThreadLocalMap&lt;/code&gt; 里。如果这个线程一直不销毁，比如线程池里的线程，就可能导致 value 长时间无法释放。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;线程池里的问题更明显&lt;/p&gt;
&lt;p&gt;在线程池中，线程会被复用。&lt;/p&gt;
&lt;p&gt;如果一次请求设置了 &lt;code&gt;ThreadLocal&lt;/code&gt;，请求结束后没有清理，那么这个值可能会残留在线程里。&lt;/p&gt;
&lt;p&gt;下一个请求复用同一个线程时，可能读到上一个请求的数据，造成数据污染。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;如何避免&lt;/p&gt;
&lt;p&gt;使用完 &lt;code&gt;ThreadLocal&lt;/code&gt; 后，一定要在 &lt;code&gt;finally&lt;/code&gt; 中调用 &lt;code&gt;remove()&lt;/code&gt; 清理。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;try {
    USER_ID.set(&quot;1001&quot;);
    // 业务逻辑
} finally {
    USER_ID.remove();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果是在 Web 项目中，可以在过滤器、拦截器或者 AOP 里统一清理。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用场景&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ThreadLocal&lt;/code&gt; 适合保存线程级别的上下文，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当前登录用户信息。&lt;/li&gt;
&lt;li&gt;请求 ID。&lt;/li&gt;
&lt;li&gt;链路追踪 traceId。&lt;/li&gt;
&lt;li&gt;数据源上下文。&lt;/li&gt;
&lt;li&gt;事务上下文。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但是不要把大对象、连接对象或者生命周期很长的数据随便放进去，用完必须清理。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;三、JDK 8&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;问题：JDK 8 的新特性有哪些？请说明 Lambda 表达式和 Stream 流的使用。&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;JDK 8 常见新特性&lt;/p&gt;
&lt;p&gt;JDK 8 里比较常见的新特性有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Lambda 表达式。&lt;/li&gt;
&lt;li&gt;Stream 流。&lt;/li&gt;
&lt;li&gt;函数式接口。&lt;/li&gt;
&lt;li&gt;接口默认方法和静态方法。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Optional&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;新的日期时间 API，比如 &lt;code&gt;LocalDateTime&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;方法引用。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;面试里重点说 Lambda 和 Stream 就可以。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Lambda 表达式&lt;/p&gt;
&lt;p&gt;Lambda 表达式可以理解成一种更简洁的匿名函数写法。&lt;/p&gt;
&lt;p&gt;以前写线程可能这样写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println(&quot;hello&quot;);
    }
}).start();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JDK 8 之后可以简化成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;new Thread(() -&amp;gt; {
    System.out.println(&quot;hello&quot;);
}).start();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它主要用在函数式接口上，也就是只有一个抽象方法的接口，比如 &lt;code&gt;Runnable&lt;/code&gt;、&lt;code&gt;Callable&lt;/code&gt;、&lt;code&gt;Comparator&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;比如排序：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;List&amp;lt;Integer&amp;gt; list = Arrays.asList(3, 1, 2);

list.sort((a, b) -&amp;gt; a - b);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Lambda 的好处是代码更简洁，适合表达一段行为逻辑。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Stream 流&lt;/p&gt;
&lt;p&gt;Stream 是对集合数据进行链式处理的一套 API。&lt;/p&gt;
&lt;p&gt;它可以让我们用更声明式的方式完成过滤、转换、排序、分组、统计等操作。&lt;/p&gt;
&lt;p&gt;比如筛选出大于 &lt;code&gt;10&lt;/code&gt; 的数字：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;List&amp;lt;Integer&amp;gt; list = Arrays.asList(1, 12, 5, 20);

List&amp;lt;Integer&amp;gt; result = list.stream()
        .filter(x -&amp;gt; x &amp;gt; 10)
        .collect(Collectors.toList());
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比如把用户列表中的用户名取出来：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;List&amp;lt;String&amp;gt; names = userList.stream()
        .map(User::getName)
        .collect(Collectors.toList());
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Stream 常用操作&lt;/p&gt;
&lt;p&gt;&lt;code&gt;filter&lt;/code&gt;：过滤数据。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.filter(user -&amp;gt; user.getAge() &amp;gt; 18)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;map&lt;/code&gt;：转换数据。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.map(User::getName)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;sorted&lt;/code&gt;：排序。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.sorted(Comparator.comparing(User::getAge))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;collect&lt;/code&gt;：收集结果。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.collect(Collectors.toList())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;groupingBy&lt;/code&gt;：分组。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.collect(Collectors.groupingBy(User::getCity))
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用时要注意&lt;/p&gt;
&lt;p&gt;Stream 适合处理集合数据，让代码更清晰。&lt;/p&gt;
&lt;p&gt;但是不要在 Stream 里写太复杂的业务逻辑，否则可读性会变差。&lt;/p&gt;
&lt;p&gt;如果涉及大量数据，还要注意性能和内存占用。并行流 &lt;code&gt;parallelStream()&lt;/code&gt; 也不能随便用，因为它会使用公共线程池，可能影响其他任务。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;四、MySQL&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;问题：如何理解事务？ACID 特性是什么？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;什么是事务&lt;/p&gt;
&lt;p&gt;事务可以理解成一组数据库操作的执行单元。&lt;/p&gt;
&lt;p&gt;这一组操作要么全部成功，要么全部失败。&lt;/p&gt;
&lt;p&gt;比如转账场景：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A 账户扣 100 元
B 账户加 100 元
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这两个操作必须放在同一个事务里。如果 A 扣款成功，但 B 加款失败，数据就不一致了。所以事务要保证这两个操作一起成功，或者一起回滚。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;ACID 是什么&lt;/p&gt;
&lt;p&gt;事务有 4 个核心特性，也就是 ACID：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A：Atomicity，原子性
C：Consistency，一致性
I：Isolation，隔离性
D：Durability，持久性
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;原子性&lt;/p&gt;
&lt;p&gt;原子性指一个事务中的多个操作，要么全部成功，要么全部失败。&lt;/p&gt;
&lt;p&gt;如果中间任何一步失败，已经执行的操作要回滚。&lt;/p&gt;
&lt;p&gt;在 MySQL 中，原子性主要依赖 &lt;code&gt;undo log&lt;/code&gt; 实现。事务回滚时，可以通过 &lt;code&gt;undo log&lt;/code&gt; 把数据恢复到修改前的状态。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;一致性&lt;/p&gt;
&lt;p&gt;一致性指事务执行前后，数据库都要处于正确的业务状态。&lt;/p&gt;
&lt;p&gt;比如转账前 A 和 B 总金额是 &lt;code&gt;1000&lt;/code&gt; 元，转账后总金额仍然应该是 &lt;code&gt;1000&lt;/code&gt; 元，不能凭空多钱或少钱。&lt;/p&gt;
&lt;p&gt;一致性是事务最终要达到的目标，它依赖原子性、隔离性、持久性，以及业务约束共同保证。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;隔离性&lt;/p&gt;
&lt;p&gt;隔离性指多个事务并发执行时，事务之间不能互相干扰到不该看到的数据。&lt;/p&gt;
&lt;p&gt;比如一个事务还没有提交的数据，其他事务一般不应该随便读到。&lt;/p&gt;
&lt;p&gt;MySQL 通过锁和 MVCC 来保证隔离性。&lt;/p&gt;
&lt;p&gt;不同隔离级别对并发问题的控制不同，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;读未提交。&lt;/li&gt;
&lt;li&gt;读已提交。&lt;/li&gt;
&lt;li&gt;可重复读。&lt;/li&gt;
&lt;li&gt;串行化。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;持久性&lt;/p&gt;
&lt;p&gt;持久性指事务一旦提交，修改结果就要永久保存下来。&lt;/p&gt;
&lt;p&gt;即使数据库宕机，重启后也应该能恢复已经提交的数据。&lt;/p&gt;
&lt;p&gt;在 MySQL 中，持久性主要依赖 &lt;code&gt;redo log&lt;/code&gt; 实现。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;总结&lt;/p&gt;
&lt;p&gt;事务就是保证一组数据库操作作为一个整体执行。ACID 中，原子性保证全部成功或全部失败，一致性保证数据符合业务规则，隔离性保证并发事务之间互不干扰，持久性保证事务提交后数据不会丢失。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：建立索引需要考虑什么？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;查询条件&lt;/p&gt;
&lt;p&gt;建索引首先要看 SQL 的查询条件。&lt;/p&gt;
&lt;p&gt;经常出现在 &lt;code&gt;WHERE&lt;/code&gt;、&lt;code&gt;JOIN ON&lt;/code&gt;、&lt;code&gt;ORDER BY&lt;/code&gt;、&lt;code&gt;GROUP BY&lt;/code&gt; 里的字段，可以考虑建立索引。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where phone = &apos;13800000000&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果 &lt;code&gt;phone&lt;/code&gt; 经常作为查询条件，就可以考虑给 &lt;code&gt;phone&lt;/code&gt; 建索引。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;字段区分度&lt;/p&gt;
&lt;p&gt;索引字段要有一定区分度。&lt;/p&gt;
&lt;p&gt;区分度越高，索引过滤效果越好。&lt;/p&gt;
&lt;p&gt;比如手机号、订单号、用户 ID 区分度比较高，适合建索引。&lt;/p&gt;
&lt;p&gt;性别、状态这种字段取值很少，区分度低，单独建索引效果通常不好。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;联合索引顺序&lt;/p&gt;
&lt;p&gt;如果建立联合索引，要考虑字段顺序。&lt;/p&gt;
&lt;p&gt;一般把区分度高、使用频率高、等值查询多的字段放在前面。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;where user_id = ? and status = ? and create_time &amp;gt; ?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以考虑：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(user_id, status, create_time)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同时要遵守最左前缀原则。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;是否能覆盖查询&lt;/p&gt;
&lt;p&gt;如果查询的字段都在索引里，就可以走覆盖索引，减少回表。&lt;/p&gt;
&lt;p&gt;比如有索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;idx_user_status(user_id, status)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SQL 是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select status from order where user_id = ?;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这时候可以直接从索引里拿到 &lt;code&gt;status&lt;/code&gt;，不需要回表。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;写入成本&lt;/p&gt;
&lt;p&gt;索引不是越多越好。&lt;/p&gt;
&lt;p&gt;每次 &lt;code&gt;insert&lt;/code&gt;、&lt;code&gt;update&lt;/code&gt;、&lt;code&gt;delete&lt;/code&gt; 数据时，数据库不仅要改表数据，还要维护索引。&lt;/p&gt;
&lt;p&gt;索引越多，写入成本越高，占用磁盘空间也越多。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;字段长度&lt;/p&gt;
&lt;p&gt;如果字段很长，比如 &lt;code&gt;varchar(500)&lt;/code&gt;，直接建索引可能占用空间较大。&lt;/p&gt;
&lt;p&gt;可以考虑前缀索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;create index idx_name on user(name(20));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是前缀索引不能完全支持排序和覆盖索引，需要结合场景判断。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;避免冗余索引&lt;/p&gt;
&lt;p&gt;如果已经有联合索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(a, b, c)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;通常不需要再单独建：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(a)
(a, b)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为联合索引可以按最左前缀匹配。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;总结&lt;/p&gt;
&lt;p&gt;建立索引要看查询条件、字段区分度、联合索引顺序、是否能覆盖查询，同时也要考虑写入成本、字段长度和冗余索引。索引的目的不是越多越好，而是让高频 SQL 用更小的代价定位到数据。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：索引失效的原因有哪些？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;对索引列使用函数&lt;/p&gt;
&lt;p&gt;如果对索引字段使用函数，可能导致索引失效。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where date(create_time) = &apos;2026-07-06&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为索引里保存的是原始字段值，数据库需要对每一行执行函数计算，可能无法直接利用索引。&lt;/p&gt;
&lt;p&gt;可以改成范围查询：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user
where create_time &amp;gt;= &apos;2026-07-06 00:00:00&apos;
  and create_time &amp;lt; &apos;2026-07-07 00:00:00&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;对索引列进行计算&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where age + 1 = 19;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以改成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where age = 18;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;索引列参与计算后，优化器可能无法直接使用索引定位数据。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;隐式类型转换&lt;/p&gt;
&lt;p&gt;如果字段类型和传入参数类型不一致，可能发生隐式类型转换。&lt;/p&gt;
&lt;p&gt;比如 &lt;code&gt;phone&lt;/code&gt; 是 &lt;code&gt;varchar&lt;/code&gt; 类型：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where phone = 13800000000;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MySQL 可能会把字段转换成数字再比较，导致索引失效。&lt;/p&gt;
&lt;p&gt;应该写成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where phone = &apos;13800000000&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;联合索引不满足最左前缀原则&lt;/p&gt;
&lt;p&gt;比如有联合索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;idx_name_age_city(name, age, city)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;下面这个可以用到索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;where name = ? and age = ?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是直接跳过最左边的 &lt;code&gt;name&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;where age = ? and city = ?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;就可能无法充分使用这个联合索引。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;范围查询后面的列可能用不上索引&lt;/p&gt;
&lt;p&gt;比如联合索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;idx_user_status_time(user_id, create_time, status)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SQL：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;where user_id = ?
  and create_time &amp;gt; ?
  and status = ?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;user_id&lt;/code&gt; 和 &lt;code&gt;create_time&lt;/code&gt; 可能用到索引，但 &lt;code&gt;status&lt;/code&gt; 在范围查询后面，可能无法继续利用索引进行精确定位。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;like&lt;/code&gt; 左模糊查询&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where name like &apos;%张&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;左边是 &lt;code&gt;%&lt;/code&gt;，B+ 树无法从左到右匹配，所以索引通常用不上。&lt;/p&gt;
&lt;p&gt;下面这种右模糊一般可以用索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where name like &apos;张%&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用 &lt;code&gt;or&lt;/code&gt; 时条件中有列没有索引&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where phone = ? or address = ?;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果 &lt;code&gt;phone&lt;/code&gt; 有索引，&lt;code&gt;address&lt;/code&gt; 没有索引，优化器可能选择全表扫描。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;不等于、&lt;code&gt;not in&lt;/code&gt;、&lt;code&gt;not like&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;这类条件可能导致索引效果变差，甚至优化器选择不走索引。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;where status != 1
where id not in (1, 2, 3)
where name not like &apos;张%&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为排除的数据范围可能很大，走索引不一定比全表扫描更划算。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;查询返回数据量太大&lt;/p&gt;
&lt;p&gt;即使 SQL 条件里有索引，如果优化器判断返回的数据量太大，回表成本很高，也可能选择全表扫描。&lt;/p&gt;
&lt;p&gt;比如一个状态字段 &lt;code&gt;status&lt;/code&gt;，大部分数据都是 &lt;code&gt;status = 1&lt;/code&gt;，单独给它建索引效果也不好。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;总结&lt;/p&gt;
&lt;p&gt;索引失效常见原因有：对索引列使用函数或计算、隐式类型转换、联合索引不满足最左前缀、范围查询后面的列用不上、&lt;code&gt;like&lt;/code&gt; 左模糊、&lt;code&gt;or&lt;/code&gt; 条件里有字段没索引、不等于类条件、返回数据量太大导致优化器放弃索引。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：&lt;code&gt;WHERE&lt;/code&gt; 语句中字段类型本来是 &lt;code&gt;BIGINT&lt;/code&gt;，与 &lt;code&gt;String&lt;/code&gt; 类型用 &lt;code&gt;=&lt;/code&gt; 号相比会导致索引失效吗？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;p&gt;一般情况下，&lt;code&gt;BIGINT&lt;/code&gt; 字段和字符串数字比较，不一定会导致索引失效。&lt;/p&gt;
&lt;p&gt;比如字段 &lt;code&gt;id&lt;/code&gt; 是 &lt;code&gt;BIGINT&lt;/code&gt;，并且有索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where id = &apos;1001&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MySQL 会把字符串 &lt;code&gt;&apos;1001&apos;&lt;/code&gt; 转成数字 &lt;code&gt;1001&lt;/code&gt;，再和 &lt;code&gt;id&lt;/code&gt; 比较。&lt;/p&gt;
&lt;p&gt;这个过程叫隐式类型转换。隐式类型转换就是 SQL 两边的数据类型不一致时，MySQL 为了能比较它们，会自动把其中一边转换成另一种类型。&lt;/p&gt;
&lt;p&gt;这种情况下，因为转换发生在右边的常量这一侧，不需要对索引列 &lt;code&gt;id&lt;/code&gt; 做函数或类型转换，所以通常还能使用索引。&lt;/p&gt;
&lt;p&gt;可以理解成优化后类似：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where id = 1001;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但是反过来就容易出问题。&lt;/p&gt;
&lt;p&gt;如果字段是 &lt;code&gt;VARCHAR&lt;/code&gt;，传入的是数字：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where phone = 13800000000;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;假设 &lt;code&gt;phone&lt;/code&gt; 是 &lt;code&gt;VARCHAR&lt;/code&gt;，右边是数字。&lt;/p&gt;
&lt;p&gt;MySQL 中字符串和数字比较时，通常会按照数字比较，也就是倾向于把字符串转换成数字。&lt;/p&gt;
&lt;p&gt;所以 MySQL 可能会把 &lt;code&gt;phone&lt;/code&gt; 字段里的每一行值都转成数字再比较。&lt;/p&gt;
&lt;p&gt;可以理解成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from user where cast(phone as signed) = 13800000000;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这就麻烦了，因为转换发生在字段这一侧。&lt;/p&gt;
&lt;p&gt;索引里存的是原始字符串，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&apos;13800000000&apos;
&apos;13800000001&apos;
&apos;abc123&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;现在 MySQL 要先把字段值转成数字后再判断，可能没法直接按原始字符串索引查找，就容易导致索引失效。&lt;/p&gt;
&lt;p&gt;也可以通过例子理解 MySQL 的比较规则：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select &apos;123&apos; = 123;     -- true
select &apos;123abc&apos; = 123;  -- true
select &apos;abc&apos; = 0;       -- true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这些结果说明，字符串和数字比较时，MySQL 会把字符串按数字规则转换后再比较。&lt;/p&gt;
&lt;p&gt;所以结论是：&lt;/p&gt;
&lt;p&gt;如果字段是 &lt;code&gt;BIGINT&lt;/code&gt;，参数是字符串数字，比如 &lt;code&gt;id = &apos;1001&apos;&lt;/code&gt;，通常还能走索引，因为 MySQL 会把常量转成数字。&lt;/p&gt;
&lt;p&gt;如果字段是 &lt;code&gt;VARCHAR&lt;/code&gt;，参数是数字，比如 &lt;code&gt;phone = 13800000000&lt;/code&gt;，更容易导致索引失效，因为可能会对字段做隐式类型转换。&lt;/p&gt;
&lt;p&gt;实际开发中，最好让参数类型和字段类型保持一致。&lt;code&gt;BIGINT&lt;/code&gt; 字段就传数字类型，&lt;code&gt;VARCHAR&lt;/code&gt; 字段就传字符串类型，这样最稳。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：&lt;code&gt;CHAR&lt;/code&gt; 和 &lt;code&gt;VARCHAR&lt;/code&gt; 的区别是什么？如何使用？&lt;/p&gt;
&lt;p&gt;答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;存储方式不同&lt;/p&gt;
&lt;p&gt;&lt;code&gt;CHAR&lt;/code&gt; 是定长字符串。&lt;/p&gt;
&lt;p&gt;比如定义：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;name char(10)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不管实际存的是 &lt;code&gt;&quot;abc&quot;&lt;/code&gt; 还是 &lt;code&gt;&quot;abcdef&quot;&lt;/code&gt;，都会按照固定长度 &lt;code&gt;10&lt;/code&gt; 来处理，不足的部分会用空格补齐。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;VARCHAR&lt;/code&gt; 是变长字符串。&lt;/p&gt;
&lt;p&gt;比如定义：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;name varchar(10)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果实际存的是 &lt;code&gt;&quot;abc&quot;&lt;/code&gt;，它只会按照实际长度存储，再额外用 1 到 2 个字节记录长度。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;空间占用不同&lt;/p&gt;
&lt;p&gt;&lt;code&gt;CHAR&lt;/code&gt; 长度固定，可能会浪费空间。&lt;/p&gt;
&lt;p&gt;比如手机号、身份证号、性别编码这种长度比较固定的字段，可以考虑用 &lt;code&gt;CHAR&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;VARCHAR&lt;/code&gt; 按实际长度存储，更节省空间。&lt;/p&gt;
&lt;p&gt;比如用户名、邮箱、地址、备注这类长度不固定的字段，更适合用 &lt;code&gt;VARCHAR&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;查询和更新效率&lt;/p&gt;
&lt;p&gt;&lt;code&gt;CHAR&lt;/code&gt; 因为长度固定，存取时处理相对简单。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;VARCHAR&lt;/code&gt; 长度可变，更新时如果长度变化较大，可能会带来额外的存储调整成本。&lt;/p&gt;
&lt;p&gt;不过在实际业务中，更多还是根据字段长度是否固定来选择。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用场景&lt;/p&gt;
&lt;p&gt;如果字段长度固定，可以用 &lt;code&gt;CHAR&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;gender char(1)
country_code char(2)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果字段长度不固定，优先用 &lt;code&gt;VARCHAR&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;username varchar(64)
email varchar(128)
address varchar(255)
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;总结&lt;/p&gt;
&lt;p&gt;&lt;code&gt;CHAR&lt;/code&gt; 是定长字符串，适合长度固定的字段；&lt;code&gt;VARCHAR&lt;/code&gt; 是变长字符串，适合长度变化较大的字段。实际开发中，用户昵称、邮箱、地址这类字段通常用 &lt;code&gt;VARCHAR&lt;/code&gt;，固定编码类字段可以考虑用 &lt;code&gt;CHAR&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>携程后端二面复盘</title><link>https://blog.huangnv.online/posts/2675/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2675/1/1/</guid><pubDate>Sun, 05 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;携程二面&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;日期：2026-07-05&lt;br /&gt;
类型：后端二面问题提取&lt;br /&gt;
来源：用户提供截图&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;面试问题整理&lt;/h2&gt;
&lt;h3&gt;一、项目&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;问题：项目相关问题。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;二、情景题&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;问题：设计一个支持超高 QPS 的转账服务（参数：用户 A、用户 B、转账金额），需考虑哪些方面？&lt;/p&gt;
&lt;p&gt;这类转账服务不能只看 QPS，核心首先是资金安全。用户 A 给用户 B 转账，本质上要保证：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A 扣钱成功
B 加钱成功
转账流水成功
三者必须在同一个事务里完成
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;参数和业务校验&lt;/p&gt;
&lt;p&gt;接口入参有：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fromUserId
toUserId
amount
requestId
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要校验：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用户 A、用户 B 是否存在。&lt;/li&gt;
&lt;li&gt;转账金额是否大于 0。&lt;/li&gt;
&lt;li&gt;A 和 B 是否相同。&lt;/li&gt;
&lt;li&gt;账户状态是否正常，比如冻结、注销、风控限制。&lt;/li&gt;
&lt;li&gt;A 的余额是否足够。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;requestId&lt;/code&gt; 是否重复，用来做幂等。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;幂等设计&lt;/p&gt;
&lt;p&gt;转账接口必须支持幂等。&lt;/p&gt;
&lt;p&gt;客户端可能因为超时重试，如果没有幂等，可能重复扣款。&lt;/p&gt;
&lt;p&gt;可以要求每次请求带一个全局唯一的 &lt;code&gt;requestId&lt;/code&gt;，然后在转账流水表里加唯一索引：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;request_id unique
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果重复请求进来，先查流水状态：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;已成功：直接返回成功结果。&lt;/li&gt;
&lt;li&gt;处理中：返回处理中或稍后查询。&lt;/li&gt;
&lt;li&gt;已失败：根据业务规则返回失败或允许重试。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;数据库事务保证原子性&lt;/p&gt;
&lt;p&gt;转账时要放在一个数据库事务里：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;begin;

-- 扣 A 账户
update account
set balance = balance - amount
where user_id = A and balance &amp;gt;= amount;

-- 给 B 账户加钱
update account
set balance = balance + amount
where user_id = B;

-- 插入转账流水
insert into transfer_order(...);

commit;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;扣款 SQL 里要带上：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;balance &amp;gt;= amount
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样可以避免余额不足时扣成负数。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;并发控制和死锁处理&lt;/p&gt;
&lt;p&gt;如果多个转账同时操作同一批账户，需要控制并发。&lt;/p&gt;
&lt;p&gt;可以按账户 ID 固定顺序加锁，比如每次都先锁 ID 小的账户，再锁 ID 大的账户，减少死锁概率。&lt;/p&gt;
&lt;p&gt;比如 A 给 B 转账、B 给 A 转账，如果两个事务加锁顺序相反，就容易死锁。&lt;/p&gt;
&lt;p&gt;所以可以统一规则：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;先锁 min(A, B)
再锁 max(A, B)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也可以使用数据库行锁或乐观锁版本号控制并发。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;高 QPS 扩展&lt;/p&gt;
&lt;p&gt;高 QPS 下不能所有请求都打到单库单表。&lt;/p&gt;
&lt;p&gt;可以考虑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;账户表按 &lt;code&gt;userId&lt;/code&gt; 分库分表。&lt;/li&gt;
&lt;li&gt;转账流水表按时间或用户 ID 分表。&lt;/li&gt;
&lt;li&gt;热点账户做限流，比如大 V、商户账户、平台账户。&lt;/li&gt;
&lt;li&gt;读请求走缓存或只读库，但余额判断和扣款必须以主库事务为准。&lt;/li&gt;
&lt;li&gt;写请求通过水平扩展账户分片承载。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果 A 和 B 在不同分片，跨库事务会变复杂。可以通过事务消息、TCC、Saga 或账务中间层处理，但资金场景优先保证一致性，不能随意异步扣加。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;MQ 的使用边界&lt;/p&gt;
&lt;p&gt;转账核心链路不建议只靠 MQ 异步完成扣款和加款。&lt;/p&gt;
&lt;p&gt;MQ 可以用于非核心动作，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;发送通知。&lt;/li&gt;
&lt;li&gt;更新账单展示缓存。&lt;/li&gt;
&lt;li&gt;风控异步分析。&lt;/li&gt;
&lt;li&gt;对账任务。&lt;/li&gt;
&lt;li&gt;积分、活动奖励。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;核心资金变更应该由数据库事务、流水和账户余额保证。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;流水和对账&lt;/p&gt;
&lt;p&gt;资金系统一定要有流水表。&lt;/p&gt;
&lt;p&gt;流水里记录：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;转账单号&lt;/li&gt;
&lt;li&gt;fromUserId&lt;/li&gt;
&lt;li&gt;toUserId&lt;/li&gt;
&lt;li&gt;amount&lt;/li&gt;
&lt;li&gt;状态&lt;/li&gt;
&lt;li&gt;请求 ID&lt;/li&gt;
&lt;li&gt;创建时间&lt;/li&gt;
&lt;li&gt;完成时间&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后续可以通过账户余额、转账流水、资金明细做对账，发现不一致时进入补偿流程。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;缓存使用&lt;/p&gt;
&lt;p&gt;余额这类强一致数据不能完全依赖 Redis 判断。&lt;/p&gt;
&lt;p&gt;Redis 可以用于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;展示余额缓存。&lt;/li&gt;
&lt;li&gt;限流。&lt;/li&gt;
&lt;li&gt;风控计数。&lt;/li&gt;
&lt;li&gt;幂等请求短期缓存。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;真正扣款时必须以数据库账户余额为准。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;设计高 QPS 转账服务时，先保证资金正确性。接口需要做参数校验、账户状态校验和幂等控制；核心转账逻辑放在数据库事务里完成，保证 A 扣款、B 加款、流水记录一起成功或一起失败。并发下通过行锁、固定账户加锁顺序、余额条件更新避免超扣和死锁。高 QPS 方面可以做账户分库分表、流水分表、热点账户限流，读多场景可以用缓存，但扣款必须以主库事务为准。MQ 主要用于通知、风控、对账等异步任务，核心资金链路要靠事务、流水和对账兜底。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：Redis 和数据库之间的同步该怎么做？若其他服务直接操作数据库导致数据不一致，该如何处理？&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;三、八股&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;问题：你说一下缓存击穿、缓存穿透的问题。&lt;/p&gt;
&lt;p&gt;缓存击穿指的是某个热点 Key 过期后，大量并发请求同时发现 Redis 没有数据，于是一起回源查询 MySQL，导致数据库瞬间压力升高。比如秒杀商品详情、热门活动配置、库存信息这类热点数据，一旦缓存失效，就容易出现击穿。&lt;/p&gt;
&lt;p&gt;解决缓存击穿可以从几个方面处理：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;热点 Key 提前预热，活动开始前把商品信息、活动配置、库存等数据加载到 Redis。&lt;/li&gt;
&lt;li&gt;缓存未命中时加互斥锁，只允许一个线程查询数据库并重建缓存，其他线程等待、重试或返回旧值。&lt;/li&gt;
&lt;li&gt;使用逻辑过期。缓存 Key 本身不设置物理过期时间，而是在 value 里保存一个逻辑过期时间。请求读取缓存时，如果数据没有逻辑过期，就直接返回；如果已经逻辑过期，也先返回旧数据，同时只让一个后台线程异步查询 MySQL 并刷新缓存。这样可以避免热点 Key 过期瞬间所有请求都打到数据库，但缺点是短时间内可能返回旧数据，适合商品详情、活动配置这类允许短暂旧值的场景。&lt;/li&gt;
&lt;li&gt;对回源数据库的路径做限流，避免缓存失效时所有请求都去查 MySQL。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;缓存穿透指的是请求的数据在 Redis 和数据库里都不存在，比如一直查询不存在的商品 ID 或订单 ID。因为缓存查不到，数据库也查不到，每次请求都会穿过缓存打到数据库。如果这类请求量很大，会持续消耗数据库资源。&lt;/p&gt;
&lt;p&gt;解决缓存穿透常见有几种方式：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;空值缓存。数据库查不到数据时，也把空结果写入 Redis，并设置较短 TTL，后续相同请求直接命中空缓存。&lt;/li&gt;
&lt;li&gt;布隆过滤器。把合法存在的 ID 提前放进布隆过滤器，请求进来先判断是否可能存在。如果布隆过滤器判断不存在，就直接拦截，减少无效请求访问数据库。&lt;/li&gt;
&lt;li&gt;对异常请求做限流、黑名单或风控，避免恶意请求持续穿透到数据库。&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：有了解过分布式锁吗？你们是怎么样实现一个分布式锁的？&lt;/p&gt;
&lt;p&gt;分布式锁主要是解决多服务实例并发操作同一份共享资源的问题。比如多个服务实例同时扣库存、更新订单状态、处理同一个定时任务，如果只用 Java 本地锁，只能锁住当前 JVM，锁不住其他机器上的实例，所以需要分布式锁。&lt;/p&gt;
&lt;p&gt;项目里可以说用 Redisson 实现分布式锁。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;加锁方式&lt;/p&gt;
&lt;p&gt;Redisson 底层基于 Redis 实现分布式锁，核心是利用 Redis 的原子操作保证同一时刻只有一个客户端能加锁成功。&lt;/p&gt;
&lt;p&gt;本质上类似：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SET lockKey uniqueValue NX EX expireTime
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;NX&lt;/code&gt; 保证只有锁不存在时才能加锁成功，&lt;code&gt;EX&lt;/code&gt; 设置过期时间，避免服务宕机后锁一直不释放。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;锁的唯一标识&lt;/p&gt;
&lt;p&gt;加锁时会给锁设置一个唯一标识，用来区分当前锁属于哪个线程或客户端。&lt;/p&gt;
&lt;p&gt;释放锁时不能直接删除 Key，需要先判断锁是不是自己持有的，再删除。Redisson 底层会用 Lua 脚本保证「判断锁持有者 + 删除锁」是原子操作，避免误删其他线程的锁。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;自动续期&lt;/p&gt;
&lt;p&gt;Redisson 有看门狗机制。如果加锁时没有手动指定固定过期时间，业务还在执行，Redisson 会自动给锁续期，避免业务还没执行完锁就过期，导致其他线程进入临界区。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;可重入&lt;/p&gt;
&lt;p&gt;Redisson 的 &lt;code&gt;RLock&lt;/code&gt; 支持可重入。同一个线程重复获取同一把锁时，不会被自己阻塞，底层会维护加锁次数。释放时也要释放相同次数，计数归零后才真正删除锁。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;获取锁失败处理&lt;/p&gt;
&lt;p&gt;获取锁失败时，可以根据业务场景选择快速失败、等待一段时间重试，或者返回“系统繁忙”。比如秒杀场景里，一般不会让请求长时间阻塞，拿不到锁可以快速返回，避免线程堆积。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;业务兜底&lt;/p&gt;
&lt;p&gt;分布式锁只能降低并发冲突，业务层还要做幂等和数据库兜底。比如创建订单可以用唯一索引防止重复下单，扣库存时可以用库存流水或数据库条件更新保证最终一致性。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：有听过 Redlock 红锁吗？&lt;/p&gt;
&lt;p&gt;Redlock 是 Redis 作者提出的一种分布式锁算法，主要是为了提高 Redis 分布式锁在高可用场景下的可靠性。&lt;/p&gt;
&lt;p&gt;普通 Redis 分布式锁如果只依赖单个 Redis 节点，一旦这个节点宕机，锁就不可用了。如果使用主从复制，主节点加锁成功后还没同步到从节点就宕机，从节点被提升为主节点，其他客户端可能再次加锁成功，导致同一把锁被多个客户端持有。Redlock 就是为了解决这类问题提出的。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;基本思路&lt;/p&gt;
&lt;p&gt;Redlock 会准备多个相互独立的 Redis 节点，比如 5 个 Redis 实例。&lt;/p&gt;
&lt;p&gt;客户端加锁时，不只向一个 Redis 节点加锁，而是依次向多个节点尝试加锁。&lt;/p&gt;
&lt;p&gt;只要在锁有效期内，超过半数节点加锁成功，比如 5 个节点里至少 3 个成功，就认为加锁成功。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;加锁条件&lt;/p&gt;
&lt;p&gt;Redlock 判断加锁成功一般有两个条件：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;超过半数 Redis 节点加锁成功。&lt;/li&gt;
&lt;li&gt;整个加锁过程消耗的时间小于锁的过期时间。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;第二个条件是为了避免加锁过程太慢。比如锁过期时间是 10 秒，但加锁过程已经花了 9 秒多，这时候即使多数节点成功，锁实际剩余时间也很短，继续执行业务会有风险。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;释放锁&lt;/p&gt;
&lt;p&gt;释放锁时，客户端会向所有 Redis 节点发送释放锁请求。&lt;/p&gt;
&lt;p&gt;释放时仍然要校验锁的唯一标识，只能释放自己持有的锁，避免误删其他客户端的锁。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;优点&lt;/p&gt;
&lt;p&gt;Redlock 相比单节点 Redis 锁，容错能力更强。&lt;/p&gt;
&lt;p&gt;只要多数 Redis 节点可用，就可以继续完成加锁，避免单个 Redis 节点故障导致锁完全不可用。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：有了解过哪些垃圾回收器？&lt;/p&gt;
&lt;p&gt;我主要了解 Serial、CMS、G1 和 ZGC 这几类垃圾回收器。它们代表了不同阶段的 GC 设计思路：Serial 比较简单，适合小堆和单线程场景；CMS 主要追求降低老年代回收停顿；G1 更适合服务端大堆，并且可以设置期望停顿时间；ZGC 属于低延迟收集器，目标是在大堆场景下也把停顿控制得很短。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Serial 收集器&lt;/p&gt;
&lt;p&gt;Serial 是比较早期的垃圾回收器，特点是单线程执行 GC。&lt;/p&gt;
&lt;p&gt;它在垃圾回收时会暂停所有用户线程，也就是 Stop The World，然后由一个 GC 线程完成垃圾回收。&lt;/p&gt;
&lt;p&gt;Serial 的优点是实现简单，额外线程开销小，在小堆内存、客户端程序、单核环境下反而比较高效。&lt;/p&gt;
&lt;p&gt;缺点也很明显：因为只有一个 GC 线程，并且回收期间会暂停用户线程，所以堆大或者对象多时，停顿时间会比较明显。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;CMS 收集器&lt;/p&gt;
&lt;p&gt;CMS 全称是 Concurrent Mark Sweep，主要用于老年代回收，目标是降低 GC 停顿时间。&lt;/p&gt;
&lt;p&gt;它的核心思路是让部分 GC 工作和用户线程并发执行，减少 Stop The World 的时间。&lt;/p&gt;
&lt;p&gt;CMS 的主要流程有 4 个阶段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;初始标记：标记 GC Roots 直接关联的对象，这个阶段会 Stop The World，但时间通常比较短。&lt;/li&gt;
&lt;li&gt;并发标记：从 GC Roots 关联对象继续向下遍历，标记可达对象，这个阶段 GC 线程和用户线程并发执行。&lt;/li&gt;
&lt;li&gt;重新标记：修正并发标记期间用户线程继续运行导致的标记变化，这个阶段也会 Stop The World。&lt;/li&gt;
&lt;li&gt;并发清除：清理不可达对象，这个阶段和用户线程并发执行。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;CMS 的优点是停顿时间较短，适合对响应时间比较敏感的服务端应用。&lt;/p&gt;
&lt;p&gt;缺点主要有几个：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;使用标记清除算法，会产生内存碎片。&lt;/li&gt;
&lt;li&gt;并发阶段会和用户线程抢 CPU。&lt;/li&gt;
&lt;li&gt;用户线程和 GC 线程并发运行时，还会产生浮动垃圾，本轮 GC 可能清理不掉，只能留到下一轮。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;G1 收集器&lt;/p&gt;
&lt;p&gt;G1 全称是 Garbage First，是面向服务端应用的垃圾回收器。&lt;/p&gt;
&lt;p&gt;它和传统分代收集器不同，G1 会把整个 Java 堆划分成多个大小相等的 Region。每个 Region 可以作为 Eden、Survivor 或 Old 使用。&lt;/p&gt;
&lt;p&gt;G1 的核心思想是优先回收收益最高的 Region，也就是垃圾最多、回收价值最大的区域，所以叫 Garbage First。&lt;/p&gt;
&lt;p&gt;G1 的特点有几个：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;支持新生代和老年代统一管理。&lt;/li&gt;
&lt;li&gt;使用 Region 管理堆，减少大对象和碎片问题。&lt;/li&gt;
&lt;li&gt;可以设置期望最大停顿时间，比如通过 &lt;code&gt;-XX:MaxGCPauseMillis&lt;/code&gt; 指定目标停顿。&lt;/li&gt;
&lt;li&gt;回收时会根据停顿目标，选择一部分 Region 进行回收，而不是每次都全堆回收。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;G1 的大致流程包括初始标记、并发标记、最终标记、筛选回收等阶段。&lt;/p&gt;
&lt;p&gt;它的优点是更适合大堆服务端应用，可以在吞吐量和停顿时间之间做平衡。&lt;/p&gt;
&lt;p&gt;缺点是实现复杂，维护 Region、Remembered Set 等结构会带来额外开销。在小堆场景下，优势不一定明显。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;ZGC 收集器&lt;/p&gt;
&lt;p&gt;ZGC 是一种低延迟垃圾回收器，目标是让 GC 停顿时间非常短，并且停顿时间基本不随着堆大小明显增长。&lt;/p&gt;
&lt;p&gt;它适合大堆内存、低延迟场景，比如在线交易、实时推荐、网关服务、对响应时间敏感的系统。&lt;/p&gt;
&lt;p&gt;ZGC 的核心机制包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;染色指针：在对象引用指针里记录对象状态信息，比如对象是否被标记、是否被移动。&lt;/li&gt;
&lt;li&gt;读屏障：应用线程读取对象引用时，会通过读屏障判断对象状态，如果对象已经移动，可以修正引用。&lt;/li&gt;
&lt;li&gt;并发标记：标记阶段大部分工作和用户线程并发执行。&lt;/li&gt;
&lt;li&gt;并发转移：对象整理和移动也尽量和用户线程并发执行，减少 Stop The World 时间。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;ZGC 的优点是停顿时间很短，适合大堆和低延迟系统。&lt;/p&gt;
&lt;p&gt;缺点是实现复杂，对运行环境和 JDK 版本有要求，并且并发 GC 也会消耗一定 CPU 资源。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：有对哪个垃圾回收器比较了解吗？请说明 CMS 的回收流程、优点和缺点。&lt;/p&gt;
&lt;p&gt;CMS 全称是 Concurrent Mark Sweep，主要用于老年代垃圾回收。它的目标是降低 GC 停顿时间，所以会让一部分 GC 工作和用户线程并发执行。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;初始标记&lt;/p&gt;
&lt;p&gt;初始标记会标记 GC Roots 能直接关联到的对象。&lt;/p&gt;
&lt;p&gt;这个阶段会发生 Stop The World，但它只标记第一层直接可达对象，所以速度比较快，停顿时间通常比较短。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;并发标记&lt;/p&gt;
&lt;p&gt;并发标记会从初始标记得到的对象继续向下遍历，找出所有可达对象。&lt;/p&gt;
&lt;p&gt;这个阶段 GC 线程和用户线程可以同时运行，所以不会长时间暂停业务线程。&lt;/p&gt;
&lt;p&gt;但因为用户线程还在运行，对象引用关系可能会继续变化，所以后面还需要重新标记。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;重新标记&lt;/p&gt;
&lt;p&gt;重新标记主要是修正并发标记期间，因为用户线程继续运行导致的对象引用变化。&lt;/p&gt;
&lt;p&gt;这个阶段也会 Stop The World。&lt;/p&gt;
&lt;p&gt;相比并发标记，重新标记时间通常短一些，但会比初始标记稍微复杂，因为它要处理并发阶段产生的变化。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;并发清除&lt;/p&gt;
&lt;p&gt;并发清除会清理没有被标记到的不可达对象。&lt;/p&gt;
&lt;p&gt;这个阶段 GC 线程和用户线程也可以并发执行，所以整体停顿时间比较低。&lt;/p&gt;
&lt;p&gt;CMS 使用的是标记清除算法，只清理垃圾对象，不做整体压缩整理。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;优点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;停顿时间短&lt;/p&gt;
&lt;p&gt;CMS 的并发标记和并发清除阶段可以和用户线程一起执行，所以 Stop The World 的时间主要集中在初始标记和重新标记两个阶段。&lt;/p&gt;
&lt;p&gt;相比传统老年代收集器，它更适合对响应时间敏感的服务端应用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;适合低延迟场景&lt;/p&gt;
&lt;p&gt;比如 Web 服务、接口服务、订单服务这类在线业务，通常更关注请求响应时间，CMS 可以减少老年代 GC 带来的长时间停顿。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;缺点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;会产生内存碎片&lt;/p&gt;
&lt;p&gt;CMS 使用标记清除算法，清理对象后不会整理内存空间，所以老年代里可能出现很多不连续的小空闲块。&lt;/p&gt;
&lt;p&gt;如果后续要分配大对象，即使总剩余空间足够，也可能因为没有连续空间而触发 Full GC。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;并发阶段会消耗 CPU&lt;/p&gt;
&lt;p&gt;CMS 的并发标记和并发清除阶段会和用户线程一起运行，所以 GC 线程会占用一部分 CPU。&lt;/p&gt;
&lt;p&gt;在 CPU 资源紧张的情况下，可能会影响业务线程执行效率。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;会产生浮动垃圾&lt;/p&gt;
&lt;p&gt;并发清除阶段用户线程还在运行，用户线程可能继续产生新的垃圾对象。&lt;/p&gt;
&lt;p&gt;这些新产生的垃圾在本轮 CMS 中可能清理不到，只能等下一轮 GC 再处理，这部分就叫浮动垃圾。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;可能出现 Concurrent Mode Failure&lt;/p&gt;
&lt;p&gt;如果 CMS 回收速度赶不上对象分配速度，老年代空间提前不足，就可能触发 Concurrent Mode Failure。&lt;/p&gt;
&lt;p&gt;这时 JVM 可能退化成 Serial Old 进行 Full GC，停顿时间会明显变长。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：有了解过 ZGC 吗？染色指针和读屏障能说出来吗？&lt;/p&gt;
&lt;p&gt;ZGC 是一种低延迟垃圾回收器，目标是在大堆内存下也尽量把 GC 停顿控制得很短。它能做到低停顿，核心原因是把大部分 GC 工作都设计成和用户线程并发执行，比如并发标记、并发转移和并发重映射。染色指针和读屏障就是支撑这个并发过程的关键机制。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;染色指针&lt;/p&gt;
&lt;p&gt;染色指针指的是 ZGC 会在对象引用指针中拿出几位 bit，用来记录引用的状态信息。&lt;/p&gt;
&lt;p&gt;这些状态信息可以表示当前引用是否被标记过、是否处于重映射阶段、是否是当前阶段可直接使用的引用。&lt;/p&gt;
&lt;p&gt;简单理解就是：引用本身携带 GC 状态，ZGC 通过引用上的颜色判断这个引用在当前 GC 阶段是否可用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;读屏障&lt;/p&gt;
&lt;p&gt;读屏障是在用户线程读取对象引用时触发的一段检查逻辑。&lt;/p&gt;
&lt;p&gt;用户线程读到一个引用时，读屏障会先检查引用上的染色标记。如果这个引用的颜色符合当前 GC 阶段要求，就直接使用；如果颜色不符合要求，就进入慢路径。&lt;/p&gt;
&lt;p&gt;慢路径里会进一步判断对象是否已经被重定位。如果对象已经移动，就根据转发表找到新地址，返回新的引用，并把引用修正成当前阶段可用的颜色。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;两者怎么配合&lt;/p&gt;
&lt;p&gt;染色指针负责记录引用状态。&lt;/p&gt;
&lt;p&gt;读屏障负责在读取引用时检查状态。&lt;/p&gt;
&lt;p&gt;如果引用状态正常，就直接访问；如果状态不符合当前阶段要求，读屏障就按需修正引用。&lt;/p&gt;
&lt;p&gt;所以 ZGC 不是提前扫描所有引用统一修正，而是在对象引用真正被读取时才检查和修正。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;为什么能低停顿&lt;/p&gt;
&lt;p&gt;传统 GC 如果移动对象，往往需要暂停用户线程，把所有旧引用统一修正成新引用。堆越大、引用越多，停顿时间就越难控制。&lt;/p&gt;
&lt;p&gt;ZGC 通过染色指针和读屏障，把引用修正这件事拆散到用户线程后续读取引用的过程中完成。GC 线程可以并发移动对象，用户线程继续运行，读到旧引用时再由读屏障按需修正。&lt;/p&gt;
&lt;p&gt;这样 ZGC 不需要长时间 Stop The World 去统一更新所有引用，所以停顿时间可以控制得很短。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;整体来说，ZGC 通过染色指针在引用中记录 GC 状态，通过读屏障在用户线程读取引用时检查这个状态。如果引用颜色符合当前阶段要求，就直接访问；如果不符合，就进入慢路径，判断对象是否已经重定位，并根据转发表修正为新引用。这样对象移动和引用修正可以尽量和用户线程并发执行，ZGC 就不需要长时间 STW 去统一更新所有引用，所以能实现低停顿。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：请说明 MySQL 索引的数据结构。&lt;/p&gt;
&lt;p&gt;MySQL 里不同存储引擎索引结构不完全一样。面试里一般默认说的是 InnoDB。InnoDB 的主流索引结构是 B+ 树。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;为什么用 B+ 树&lt;/p&gt;
&lt;p&gt;数据库索引通常存储在磁盘上，查询时要尽量减少磁盘 IO。&lt;/p&gt;
&lt;p&gt;B+ 树是一种多路平衡树，每个节点可以存很多 key，所以树的高度比较低。高度低意味着从根节点查到叶子节点需要的磁盘 IO 次数少。&lt;/p&gt;
&lt;p&gt;比如一棵 B+ 树通常 3 到 4 层就可以存很多数据，查询时只需要几次 IO 就能定位到目标数据。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;B+ 树结构&lt;/p&gt;
&lt;p&gt;B+ 树有几个特点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;非叶子节点只存索引 key 和子节点指针，不存完整数据。&lt;/li&gt;
&lt;li&gt;叶子节点存完整索引项。&lt;/li&gt;
&lt;li&gt;所有数据都在叶子节点。&lt;/li&gt;
&lt;li&gt;叶子节点之间通过双向链表连接。&lt;/li&gt;
&lt;li&gt;树整体保持有序和平衡。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样做的好处是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;单个非叶子节点能放更多 key，树更矮，IO 更少。&lt;/li&gt;
&lt;li&gt;叶子节点有序，适合范围查询。&lt;/li&gt;
&lt;li&gt;查询性能稳定，从根到叶子的路径长度基本一致。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;聚簇索引&lt;/p&gt;
&lt;p&gt;InnoDB 的主键索引是聚簇索引。&lt;/p&gt;
&lt;p&gt;聚簇索引的叶子节点存的是整行数据。&lt;/p&gt;
&lt;p&gt;所以通过主键查询时，可以直接从主键 B+ 树的叶子节点拿到完整数据。&lt;/p&gt;
&lt;p&gt;如果表有主键，InnoDB 会用主键作为聚簇索引；如果没有主键，会选择一个唯一非空索引；如果还没有，就生成隐藏的 row_id 作为聚簇索引。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;二级索引&lt;/p&gt;
&lt;p&gt;除了主键索引以外，其他普通索引、唯一索引都属于二级索引，也叫辅助索引。&lt;/p&gt;
&lt;p&gt;二级索引的叶子节点存的不是整行数据，而是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;索引列值 + 主键值
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以通过二级索引查询时，一般会先在二级索引 B+ 树上找到对应主键，再拿主键去聚簇索引里查整行数据，这个过程叫回表。&lt;/p&gt;
&lt;p&gt;如果查询字段都在二级索引里，不需要回表，这种情况叫覆盖索引。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;InnoDB 的索引主要基于 B+ 树。B+ 树是多路平衡树，非叶子节点只存 key 和指针，叶子节点存完整索引项，并且叶子节点之间用双向链表连接，所以既能降低树高、减少磁盘 IO，也适合范围查询。InnoDB 的主键索引是聚簇索引，叶子节点存整行数据；二级索引叶子节点存索引列和主键值，通过二级索引查整行时通常需要回表。如果查询字段都在索引里，就可以走覆盖索引避免回表。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：请说明 InnoDB 里面有哪些锁，以及每个锁的作用。&lt;/p&gt;
&lt;p&gt;InnoDB 里的锁可以从几个维度理解：锁粒度、锁模式和加锁算法。常见的有表锁、行锁、共享锁、排他锁、意向锁、记录锁、间隙锁和临键锁。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;表锁&lt;/p&gt;
&lt;p&gt;表锁是对整张表加锁。&lt;/p&gt;
&lt;p&gt;它的粒度比较大，并发能力较差，但加锁和释放锁的开销比较小。&lt;/p&gt;
&lt;p&gt;在 InnoDB 中，普通业务操作更多使用行锁，表锁一般出现在 DDL、显式锁表或者某些特殊场景里。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;行锁&lt;/p&gt;
&lt;p&gt;行锁是对某一行记录加锁，是 InnoDB 支持高并发的重要基础。&lt;/p&gt;
&lt;p&gt;InnoDB 的行锁是基于索引实现的。也就是说，锁通常加在索引记录上。如果查询条件没有命中索引，可能会扫描更多记录，导致加锁范围变大。&lt;/p&gt;
&lt;p&gt;行锁适合高并发更新场景，因为不同事务操作不同行时可以并发执行。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;共享锁（S 锁）&lt;/p&gt;
&lt;p&gt;共享锁也叫读锁。&lt;/p&gt;
&lt;p&gt;一个事务对某行加共享锁后，其他事务也可以继续对这行加共享锁，也就是多个事务可以同时读。&lt;/p&gt;
&lt;p&gt;但如果某行已经有共享锁，其他事务不能对这行加排他锁进行修改。&lt;/p&gt;
&lt;p&gt;常见用法是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT ... LOCK IN SHARE MODE;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;排他锁（X 锁）&lt;/p&gt;
&lt;p&gt;排他锁也叫写锁。&lt;/p&gt;
&lt;p&gt;一个事务对某行加排他锁后，其他事务不能再对这行加共享锁或排他锁。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;UPDATE&lt;/code&gt;、&lt;code&gt;DELETE&lt;/code&gt;、&lt;code&gt;INSERT&lt;/code&gt; 通常会涉及排他锁。&lt;/p&gt;
&lt;p&gt;常见显式加锁方式是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT ... FOR UPDATE;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;意向锁&lt;/p&gt;
&lt;p&gt;意向锁是表级锁，用来表示某个事务接下来想在表中的某些行上加什么类型的行锁。&lt;/p&gt;
&lt;p&gt;常见有两种：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;意向共享锁（IS）：表示事务准备对某些行加共享锁。&lt;/li&gt;
&lt;li&gt;意向排他锁（IX）：表示事务准备对某些行加排他锁。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;意向锁的作用是提高表锁和行锁之间的冲突判断效率。&lt;/p&gt;
&lt;p&gt;比如一个事务要给整张表加表级排他锁时，不需要逐行检查有没有行锁，只要看表上是否存在意向锁即可。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;记录锁（Record Lock）&lt;/p&gt;
&lt;p&gt;记录锁是锁住某一条具体的索引记录。&lt;/p&gt;
&lt;p&gt;比如通过唯一索引等值查询命中一条记录，并对它执行 &lt;code&gt;FOR UPDATE&lt;/code&gt;，通常会对这条索引记录加记录锁。&lt;/p&gt;
&lt;p&gt;记录锁主要防止其他事务修改或删除这条记录。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;间隙锁（Gap Lock）&lt;/p&gt;
&lt;p&gt;间隙锁锁住的是两个索引记录之间的间隙，不锁具体记录。&lt;/p&gt;
&lt;p&gt;它的作用是防止其他事务在这个范围内插入新记录，从而避免幻读。&lt;/p&gt;
&lt;p&gt;比如表里已有索引值 10 和 20，事务锁住 &lt;code&gt;(10, 20)&lt;/code&gt; 这个间隙后，其他事务就不能往这个范围内插入 15。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;临键锁（Next-Key Lock）&lt;/p&gt;
&lt;p&gt;临键锁可以理解为记录锁加间隙锁。&lt;/p&gt;
&lt;p&gt;它锁住的是一个左开右闭的范围，比如 &lt;code&gt;(10, 20]&lt;/code&gt;，既锁住 20 这条记录，也锁住 10 到 20 之间的间隙。&lt;/p&gt;
&lt;p&gt;在可重复读隔离级别下，InnoDB 默认通过临键锁来解决当前读场景下的幻读问题。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;整体来说，InnoDB 通过行锁提高并发能力，通过共享锁和排他锁控制读写互斥，通过意向锁协调表锁和行锁，通过记录锁、间隙锁、临键锁控制具体记录和范围，保证事务隔离性。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：请说明 MVCC。&lt;/p&gt;
&lt;p&gt;MVCC 全称是 Multi-Version Concurrency Control，多版本并发控制。它的核心思想是：同一行数据可以存在多个历史版本，读操作通过读取合适的历史版本来实现一致性读，从而减少读写之间的阻塞。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;MVCC 解决什么问题&lt;/p&gt;
&lt;p&gt;在并发事务里，如果读和写都完全依赖锁，读写之间会互相阻塞，并发性能会比较差。&lt;/p&gt;
&lt;p&gt;MVCC 通过版本链和 ReadView，让普通 &lt;code&gt;SELECT&lt;/code&gt; 可以不加锁读取快照数据。&lt;/p&gt;
&lt;p&gt;这样读操作不会阻塞写操作，写操作也不会阻塞普通快照读，提高了并发能力。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;MVCC 的核心组成&lt;/p&gt;
&lt;p&gt;InnoDB 的 MVCC 主要依赖三个东西：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;隐藏字段&lt;/li&gt;
&lt;li&gt;undo log&lt;/li&gt;
&lt;li&gt;ReadView&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;隐藏字段&lt;/p&gt;
&lt;p&gt;InnoDB 每行记录里会有一些隐藏字段，和 MVCC 相关的主要有两个：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;trx_id&lt;/code&gt;：记录最后一次修改这行数据的事务 ID。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;roll_pointer&lt;/code&gt;：回滚指针，指向这行记录的上一个历史版本，也就是对应的 undo log。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;每次事务修改一行数据时，都会生成新的版本，并通过 &lt;code&gt;roll_pointer&lt;/code&gt; 把历史版本串起来，形成版本链。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;undo log&lt;/p&gt;
&lt;p&gt;undo log 保存的是数据被修改前的旧版本。&lt;/p&gt;
&lt;p&gt;比如一行数据从 A 改成 B，当前记录里是 B，undo log 里会保存 A。&lt;/p&gt;
&lt;p&gt;如果又从 B 改成 C，当前记录是 C，undo log 里会通过回滚指针继续保存 B、A 这些历史版本。&lt;/p&gt;
&lt;p&gt;所以 undo log 不只是用来事务回滚，也支撑了 MVCC 的历史版本读取。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;ReadView&lt;/p&gt;
&lt;p&gt;ReadView 可以理解为事务做快照读时生成的一个“可见性视图”。&lt;/p&gt;
&lt;p&gt;它里面会记录当前系统中活跃事务的情况，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当前活跃事务 ID 列表。&lt;/li&gt;
&lt;li&gt;最小活跃事务 ID。&lt;/li&gt;
&lt;li&gt;下一个将要分配的事务 ID。&lt;/li&gt;
&lt;li&gt;当前事务自己的 ID。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;普通 &lt;code&gt;SELECT&lt;/code&gt; 做快照读时，会根据 ReadView 判断某个版本对当前事务是否可见。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;可见性判断&lt;/p&gt;
&lt;p&gt;简单来说，InnoDB 会从当前记录开始，沿着版本链往旧版本找，直到找到一个对当前 ReadView 可见的版本。&lt;/p&gt;
&lt;p&gt;判断规则可以简化理解为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果某个版本的 &lt;code&gt;trx_id&lt;/code&gt; 是当前事务自己生成的，可以看到。&lt;/li&gt;
&lt;li&gt;如果某个版本的 &lt;code&gt;trx_id&lt;/code&gt; 小于最小活跃事务 ID，说明这个版本在快照创建前已经提交，可以看到。&lt;/li&gt;
&lt;li&gt;如果某个版本的 &lt;code&gt;trx_id&lt;/code&gt; 大于等于下一个将要分配的事务 ID，说明这个版本在快照创建后才产生，看不到。&lt;/li&gt;
&lt;li&gt;如果某个版本的 &lt;code&gt;trx_id&lt;/code&gt; 在活跃事务列表里，说明创建这个版本的事务当时还没提交，看不到。&lt;/li&gt;
&lt;li&gt;其他情况一般说明事务已经提交，可以看到。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;不同隔离级别下的区别&lt;/p&gt;
&lt;p&gt;在读已提交（RC）隔离级别下，每次普通 &lt;code&gt;SELECT&lt;/code&gt; 都会生成新的 ReadView。&lt;/p&gt;
&lt;p&gt;所以同一个事务里，两次查询可能看到其他事务刚提交的数据。&lt;/p&gt;
&lt;p&gt;在可重复读（RR）隔离级别下，事务第一次普通 &lt;code&gt;SELECT&lt;/code&gt; 时生成 ReadView，后续快照读复用这个 ReadView。&lt;/p&gt;
&lt;p&gt;所以同一个事务里，多次查询看到的结果是一致的。&lt;/p&gt;
&lt;p&gt;这也是 MySQL InnoDB 可重复读能实现“可重复读”的重要原因。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;快照读和当前读&lt;/p&gt;
&lt;p&gt;MVCC 主要作用在快照读上，也就是普通 &lt;code&gt;SELECT&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;当前读会读取最新数据，并且通常会加锁，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT ... FOR UPDATE;
SELECT ... LOCK IN SHARE MODE;
UPDATE;
DELETE;
INSERT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当前读要保证读到的是最新版本，所以不会像普通快照读那样直接读历史版本。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;MVCC 是 InnoDB 实现高并发一致性读的重要机制。它通过隐藏字段 &lt;code&gt;trx_id&lt;/code&gt; 和 &lt;code&gt;roll_pointer&lt;/code&gt; 维护版本链，通过 undo log 保存历史版本，通过 ReadView 判断版本是否可见。普通 &lt;code&gt;SELECT&lt;/code&gt; 可以基于 ReadView 读取历史快照，从而做到读写不互相阻塞。在 RC 下每次查询生成新的 ReadView，在 RR 下第一次快照读生成 ReadView 后复用，所以 RR 下同一个事务多次查询结果保持一致。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：MySQL 里面除了 undo log 还有其他什么 log？&lt;/p&gt;
&lt;p&gt;MySQL 里除了 undo log，比较核心的还有 redo log 和 binlog。redo log 主要用于崩溃恢复，保证事务持久性；binlog 主要用于主从复制和数据恢复。两者经常一起配合，所以事务提交时还会涉及两阶段提交。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;redo log&lt;/p&gt;
&lt;p&gt;redo log 是 InnoDB 存储引擎层的日志。&lt;/p&gt;
&lt;p&gt;它主要解决的问题是：事务提交后，数据还没来得及真正刷到磁盘时，如果 MySQL 宕机，重启后怎么恢复已提交的数据。&lt;/p&gt;
&lt;p&gt;InnoDB 更新数据时，会先修改 Buffer Pool 里的数据页，同时写 redo log。数据页可以延迟刷盘，但 redo log 会按照事务提交要求刷盘。&lt;/p&gt;
&lt;p&gt;如果 MySQL 崩溃，重启时就可以根据 redo log，把已经提交但还没写入数据文件的修改恢复出来。&lt;/p&gt;
&lt;p&gt;所以 redo log 主要保证事务的持久性，也就是 ACID 里的 Durability。&lt;/p&gt;
&lt;p&gt;redo log 记录的是物理层面的修改，比如某个数据页的某个位置做了什么变更。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;binlog&lt;/p&gt;
&lt;p&gt;binlog 是 MySQL Server 层的日志，和具体存储引擎无关。&lt;/p&gt;
&lt;p&gt;它主要用于主从复制和数据恢复。&lt;/p&gt;
&lt;p&gt;主从复制时，主库会把自己的 binlog 发送给从库，从库重放这些日志来同步数据。&lt;/p&gt;
&lt;p&gt;做数据恢复时，也可以先恢复一份全量备份，再通过 binlog 回放某个时间点之后的增量变更。&lt;/p&gt;
&lt;p&gt;binlog 记录的是逻辑层面的变更，比如执行了什么 SQL，或者哪些行发生了变化。&lt;/p&gt;
&lt;p&gt;binlog 常见有三种格式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Statement：记录原始 SQL。&lt;/li&gt;
&lt;li&gt;Row：记录每一行数据的变更。&lt;/li&gt;
&lt;li&gt;Mixed：前两种混合。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;redo log 和 binlog 的区别&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;层级不同：redo log 是 InnoDB 引擎层日志，binlog 是 MySQL Server 层日志。&lt;/li&gt;
&lt;li&gt;作用不同：redo log 用于崩溃恢复，binlog 用于主从复制和数据恢复。&lt;/li&gt;
&lt;li&gt;内容不同：redo log 是物理日志，记录数据页的修改；binlog 是逻辑日志，记录 SQL 或行变更。&lt;/li&gt;
&lt;li&gt;写入方式不同：redo log 是循环写，空间固定；binlog 是追加写，会不断生成新的日志文件。&lt;/li&gt;
&lt;li&gt;覆盖范围不同：redo log 只属于 InnoDB，binlog 对所有存储引擎都可以生效。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;为什么需要两阶段提交&lt;/p&gt;
&lt;p&gt;一次事务提交时，redo log 和 binlog 都要写。&lt;/p&gt;
&lt;p&gt;如果只写了 redo log，没有写 binlog，那么主库崩溃恢复后数据存在，但从库或基于 binlog 的恢复里没有这次变更。&lt;/p&gt;
&lt;p&gt;如果只写了 binlog，没有提交 redo log，那么从库可能同步了这次变更，但主库崩溃恢复后没有这次数据。&lt;/p&gt;
&lt;p&gt;所以 MySQL 通过两阶段提交保证两者一致：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;redo log 写入 prepare 状态
-&amp;gt; 写 binlog
-&amp;gt; redo log 写入 commit 状态
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;崩溃恢复时，MySQL 会根据 redo log 和 binlog 的状态判断事务是提交还是回滚。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;redo log 是 InnoDB 层的物理日志，用于崩溃恢复，保证事务提交后的数据不会丢；binlog 是 Server 层的逻辑日志，用于主从复制和数据恢复。redo log 是循环写，binlog 是追加写。事务提交时两者都要写，所以 MySQL 使用两阶段提交，先写 redo log prepare，再写 binlog，最后提交 redo log，保证主库崩溃恢复和 binlog 复制的数据一致。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：我们怎么样去分析一条 SQL 的性能？&lt;/p&gt;
&lt;p&gt;分析一条 SQL 的性能，一般可以分成线上定位、执行计划分析、索引分析、执行过程分析、业务数据量分析几步。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;先定位是哪条 SQL 慢&lt;/p&gt;
&lt;p&gt;线上不一定会长期全量开启慢查询日志，因为如果阈值设置太低、SQL 量很大，会带来日志 IO 和存储压力。&lt;/p&gt;
&lt;p&gt;所以一般会先从监控和链路追踪定位，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;接口 RT 是否升高。&lt;/li&gt;
&lt;li&gt;数据库 CPU、IO、连接数是否异常。&lt;/li&gt;
&lt;li&gt;APM 或链路追踪里哪条 SQL 耗时高。&lt;/li&gt;
&lt;li&gt;数据库平台统计的慢 SQL、Top SQL、扫描行数、执行次数。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;慢查询日志可以作为辅助手段，比如设置合理的 &lt;code&gt;long_query_time&lt;/code&gt;，或者在排查阶段临时开启，排查完后关闭或调高阈值。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;用 EXPLAIN 看执行计划&lt;/p&gt;
&lt;p&gt;定位到 SQL 后，用 &lt;code&gt;EXPLAIN&lt;/code&gt; 看执行计划。&lt;/p&gt;
&lt;p&gt;重点看几个字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type&lt;/code&gt;：访问类型，最好能达到 &lt;code&gt;ref&lt;/code&gt;、&lt;code&gt;range&lt;/code&gt;、&lt;code&gt;const&lt;/code&gt; 这类，尽量避免 &lt;code&gt;ALL&lt;/code&gt; 全表扫描。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;possible_keys&lt;/code&gt;：理论上可能使用的索引。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;key&lt;/code&gt;：实际使用的索引。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rows&lt;/code&gt;：优化器预估要扫描的行数。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;filtered&lt;/code&gt;：过滤比例。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Extra&lt;/code&gt;：是否出现 &lt;code&gt;Using filesort&lt;/code&gt;、&lt;code&gt;Using temporary&lt;/code&gt;、&lt;code&gt;Using index&lt;/code&gt; 等。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果 &lt;code&gt;key&lt;/code&gt; 为空，或者 &lt;code&gt;type=ALL&lt;/code&gt;，通常要重点看索引是否合理。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;分析索引是否合理&lt;/p&gt;
&lt;p&gt;主要看几个方向：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;WHERE&lt;/code&gt; 条件字段是否有索引。&lt;/li&gt;
&lt;li&gt;联合索引是否符合最左前缀原则。&lt;/li&gt;
&lt;li&gt;索引列上是否做了函数、表达式计算或隐式类型转换。&lt;/li&gt;
&lt;li&gt;排序、分组字段是否能利用索引。&lt;/li&gt;
&lt;li&gt;是否存在索引区分度太低，导致优化器不用索引。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;分析是否回表、排序、临时表&lt;/p&gt;
&lt;p&gt;如果 SQL 走二级索引，但查询字段很多，可能会产生大量回表。&lt;/p&gt;
&lt;p&gt;可以考虑只查必要字段，避免 &lt;code&gt;SELECT *&lt;/code&gt;，或者用覆盖索引减少回表。&lt;/p&gt;
&lt;p&gt;如果 &lt;code&gt;Extra&lt;/code&gt; 里出现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Using filesort
Using temporary
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;说明可能发生额外排序或临时表。常见于 &lt;code&gt;ORDER BY&lt;/code&gt;、&lt;code&gt;GROUP BY&lt;/code&gt;、&lt;code&gt;DISTINCT&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这时可以考虑调整联合索引，让过滤字段、排序字段更好地匹配索引顺序。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;分析数据量和分页方式&lt;/p&gt;
&lt;p&gt;如果是深分页，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;LIMIT 100000, 20
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MySQL 需要扫描并丢弃前面大量数据，性能会比较差。&lt;/p&gt;
&lt;p&gt;可以改成基于游标或主键的分页：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;WHERE id &amp;gt; last_id
ORDER BY id
LIMIT 20
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者先用索引查出主键，再回表查需要的数据。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;分析锁等待和事务问题&lt;/p&gt;
&lt;p&gt;有些 SQL 慢，不一定是执行计划差，也可能是被锁阻塞。&lt;/p&gt;
&lt;p&gt;可以看：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;是否有长事务。&lt;/li&gt;
&lt;li&gt;是否有锁等待。&lt;/li&gt;
&lt;li&gt;是否有死锁。&lt;/li&gt;
&lt;li&gt;是否有大事务长时间占用资源。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;可以通过 &lt;code&gt;SHOW PROCESSLIST&lt;/code&gt;、&lt;code&gt;performance_schema&lt;/code&gt;、InnoDB 锁等待信息或数据库平台排查。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;用 EXPLAIN ANALYZE 看真实执行情况&lt;/p&gt;
&lt;p&gt;如果是 MySQL 8，可以用 &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;普通 &lt;code&gt;EXPLAIN&lt;/code&gt; 是优化器预估，&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; 会真实执行 SQL，能看到实际耗时、实际扫描行数和每个执行步骤的开销。&lt;/p&gt;
&lt;p&gt;这样可以判断优化器预估和真实执行是否有偏差。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;分析 SQL 性能时，我会先通过监控、链路追踪、数据库平台或合理阈值的慢查询日志定位慢 SQL。定位后用 &lt;code&gt;EXPLAIN&lt;/code&gt; 看执行计划，重点关注 &lt;code&gt;type&lt;/code&gt;、&lt;code&gt;key&lt;/code&gt;、&lt;code&gt;rows&lt;/code&gt;、&lt;code&gt;filtered&lt;/code&gt; 和 &lt;code&gt;Extra&lt;/code&gt;。然后分析索引是否合理，是否有回表过多、&lt;code&gt;Using filesort&lt;/code&gt;、&lt;code&gt;Using temporary&lt;/code&gt;、深分页、锁等待或长事务问题。如果是 MySQL 8，还可以用 &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; 看真实执行耗时。优化方向一般是补充或调整索引、减少回表、改写 SQL、优化分页和排查锁等待。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：请说明 Redis 常用的数据类型。&lt;/p&gt;
&lt;p&gt;Redis 常用数据类型主要有 String、Hash、List、Set、ZSet，另外还有一些特殊结构，比如 Bitmap、HyperLogLog、Geo、Stream。可以先讲五种基础类型，再补充几个常见特殊类型。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;String&lt;/p&gt;
&lt;p&gt;String 是 Redis 最基础的数据类型，可以存字符串、数字、JSON、序列化对象等。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;缓存对象或页面结果。&lt;/li&gt;
&lt;li&gt;计数器，比如浏览量、点赞数。&lt;/li&gt;
&lt;li&gt;分布式锁，比如 &lt;code&gt;SET key value NX EX&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;保存验证码、Token、Session 等。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;常用命令有 &lt;code&gt;GET&lt;/code&gt;、&lt;code&gt;SET&lt;/code&gt;、&lt;code&gt;INCR&lt;/code&gt;、&lt;code&gt;DECR&lt;/code&gt;、&lt;code&gt;MGET&lt;/code&gt;、&lt;code&gt;SETEX&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Hash&lt;/p&gt;
&lt;p&gt;Hash 类似 Java 里的 &lt;code&gt;Map&amp;lt;String, Map&amp;lt;String, String&amp;gt;&amp;gt;&lt;/code&gt;，一个 key 下面可以有多个 field。&lt;/p&gt;
&lt;p&gt;适合存对象的多个字段，比如用户信息、商品信息。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user:1
name = 张三
age = 20
city = 上海
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;好处是可以单独修改某个字段，不需要每次把整个对象序列化后重新写入。&lt;/p&gt;
&lt;p&gt;常用命令有 &lt;code&gt;HGET&lt;/code&gt;、&lt;code&gt;HSET&lt;/code&gt;、&lt;code&gt;HMGET&lt;/code&gt;、&lt;code&gt;HINCRBY&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;List&lt;/p&gt;
&lt;p&gt;List 是有序列表，底层可以支持从两端插入和弹出。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;简单消息队列。&lt;/li&gt;
&lt;li&gt;最新消息列表。&lt;/li&gt;
&lt;li&gt;时间线列表。&lt;/li&gt;
&lt;li&gt;任务队列。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;常用命令有 &lt;code&gt;LPUSH&lt;/code&gt;、&lt;code&gt;RPUSH&lt;/code&gt;、&lt;code&gt;LPOP&lt;/code&gt;、&lt;code&gt;RPOP&lt;/code&gt;、&lt;code&gt;LRANGE&lt;/code&gt;、&lt;code&gt;BRPOP&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;比如用 &lt;code&gt;LPUSH + BRPOP&lt;/code&gt; 可以实现简单阻塞队列。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Set&lt;/p&gt;
&lt;p&gt;Set 是无序不重复集合。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;去重，比如用户签到、访问用户集合。&lt;/li&gt;
&lt;li&gt;共同好友。&lt;/li&gt;
&lt;li&gt;标签系统。&lt;/li&gt;
&lt;li&gt;抽奖、随机取用户。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Set 支持集合运算，比如交集、并集、差集。&lt;/p&gt;
&lt;p&gt;常用命令有 &lt;code&gt;SADD&lt;/code&gt;、&lt;code&gt;SREM&lt;/code&gt;、&lt;code&gt;SISMEMBER&lt;/code&gt;、&lt;code&gt;SINTER&lt;/code&gt;、&lt;code&gt;SUNION&lt;/code&gt;、&lt;code&gt;SDIFF&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;ZSet&lt;/p&gt;
&lt;p&gt;ZSet 也叫 Sorted Set，有序集合。&lt;/p&gt;
&lt;p&gt;它和 Set 一样元素不重复，但每个元素会有一个 score，Redis 会根据 score 排序。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;排行榜。&lt;/li&gt;
&lt;li&gt;热门文章排序。&lt;/li&gt;
&lt;li&gt;延迟队列。&lt;/li&gt;
&lt;li&gt;按权重排序的任务。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;常用命令有 &lt;code&gt;ZADD&lt;/code&gt;、&lt;code&gt;ZRANGE&lt;/code&gt;、&lt;code&gt;ZREVRANGE&lt;/code&gt;、&lt;code&gt;ZRANK&lt;/code&gt;、&lt;code&gt;ZREM&lt;/code&gt;、&lt;code&gt;ZSCORE&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Bitmap&lt;/p&gt;
&lt;p&gt;Bitmap 本质上是基于 String 的位操作。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用户签到。&lt;/li&gt;
&lt;li&gt;活跃用户统计。&lt;/li&gt;
&lt;li&gt;布尔状态标记。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比如一个用户一年 365 天签到，可以用 365 个 bit 表示，空间占用很小。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;HyperLogLog&lt;/p&gt;
&lt;p&gt;HyperLogLog 用于基数统计，也就是统计不重复元素数量。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;UV 统计。&lt;/li&gt;
&lt;li&gt;去重计数。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它的优点是占用内存很小，缺点是结果有一定误差。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Geo&lt;/p&gt;
&lt;p&gt;Geo 用于地理位置相关场景。&lt;/p&gt;
&lt;p&gt;常见用途：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;附近的人。&lt;/li&gt;
&lt;li&gt;附近门店。&lt;/li&gt;
&lt;li&gt;计算两个位置之间距离。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Stream&lt;/p&gt;
&lt;p&gt;Stream 是 Redis 5.0 引入的消息流结构。&lt;/p&gt;
&lt;p&gt;它支持消息持久化、消费组、消息 ID，适合做轻量级消息队列。&lt;/p&gt;
&lt;p&gt;相比 List，Stream 更适合多消费者和消息确认场景。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Redis 常用基础类型有 String、Hash、List、Set、ZSet。String 适合缓存、计数器、分布式锁；Hash 适合存对象字段；List 适合队列和列表；Set 适合去重和集合运算；ZSet 适合排行榜和延迟队列。除此之外，Bitmap 适合签到和状态统计，HyperLogLog 适合 UV 这类基数统计，Geo 适合地理位置，Stream 适合轻量级消息队列。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：除了 Redis 可以实现分布式锁，还有其他什么方式吗？&lt;/p&gt;
&lt;p&gt;除了 Redis，分布式锁还可以用 ZooKeeper 和数据库来实现。Redis 更偏性能，ZooKeeper 更偏分布式协调和一致性，数据库方案实现简单，但高并发能力相对一般。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;ZooKeeper 实现分布式锁&lt;/p&gt;
&lt;p&gt;ZooKeeper 通常通过临时顺序节点实现分布式锁。&lt;/p&gt;
&lt;p&gt;大致流程是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;多个客户端都在同一个锁目录下创建临时顺序节点。&lt;/li&gt;
&lt;li&gt;创建成功后，每个客户端查看自己是不是序号最小的节点。&lt;/li&gt;
&lt;li&gt;如果自己是最小节点，说明获取锁成功。&lt;/li&gt;
&lt;li&gt;如果自己不是最小节点，就监听自己前一个节点。&lt;/li&gt;
&lt;li&gt;当前一个节点被删除时，再判断自己是否变成最小节点。&lt;/li&gt;
&lt;li&gt;释放锁时，客户端删除自己的临时节点。&lt;/li&gt;
&lt;li&gt;如果客户端宕机或会话断开，临时节点会自动删除，避免死锁。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;ZooKeeper 的优点是：一致性强，天然适合做分布式协调，锁释放也比较可靠。&lt;/p&gt;
&lt;p&gt;缺点是：性能通常不如 Redis，部署维护成本更高。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;数据库实现分布式锁&lt;/p&gt;
&lt;p&gt;数据库可以通过唯一索引或悲观锁实现分布式锁。&lt;/p&gt;
&lt;p&gt;第一种是唯一索引方案。&lt;/p&gt;
&lt;p&gt;可以建一张锁表，&lt;code&gt;lock_name&lt;/code&gt; 设置唯一索引。加锁时往表里插入一条锁记录，插入成功表示拿到锁；如果唯一索引冲突，说明锁已经被其他客户端持有。释放锁时删除这条记录。&lt;/p&gt;
&lt;p&gt;第二种是悲观锁方案。&lt;/p&gt;
&lt;p&gt;可以通过：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT ... FOR UPDATE;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对某一行加排他锁。事务没有提交前，其他事务无法拿到这行锁。&lt;/p&gt;
&lt;p&gt;数据库方案的优点是：实现简单，不需要额外引入 Redis 或 ZooKeeper。&lt;/p&gt;
&lt;p&gt;缺点是：性能和并发能力一般，高并发下会给数据库带来压力；还需要处理锁超时、事务提交、死锁等问题。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;除了 Redis，分布式锁还可以用 ZooKeeper 和数据库实现。ZooKeeper 一般通过临时顺序节点实现，客户端只监听前一个节点，拿到最小序号就获得锁，适合一致性要求更高的分布式协调场景。数据库可以通过唯一索引或 &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; 实现，方案简单，但并发能力和性能一般，更适合低并发或对中间件依赖较少的场景。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：通过 ZooKeeper 实现的分布式锁和 Redis 实现的分布式锁有什么不一样？两种方式各有什么优缺点？&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：请说明 Java 中 synchronized 的锁升级过程。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;synchronized&lt;/code&gt; 是 Java 里的内置锁。JVM 为了减少加锁开销，对 &lt;code&gt;synchronized&lt;/code&gt; 做了锁优化，它的锁状态会随着竞争程度逐步升级。&lt;/p&gt;
&lt;p&gt;锁升级的大致过程是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;无锁 -&amp;gt; 偏向锁 -&amp;gt; 轻量级锁 -&amp;gt; 重量级锁
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;无锁状态&lt;/p&gt;
&lt;p&gt;对象刚创建时，锁标记处于无锁状态。&lt;/p&gt;
&lt;p&gt;对象头里的 Mark Word 会保存对象的 hashCode、分代年龄、锁标志位等信息。&lt;/p&gt;
&lt;p&gt;如果没有线程进入同步代码块，就不需要加锁。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;偏向锁&lt;/p&gt;
&lt;p&gt;偏向锁的目标是优化“只有一个线程反复进入同步代码块”的场景。&lt;/p&gt;
&lt;p&gt;第一个线程进入同步代码块时，JVM 会把这个线程 ID 记录到对象头 Mark Word 里。&lt;/p&gt;
&lt;p&gt;后续如果还是同一个线程进入，就只需要判断对象头里的线程 ID 是不是自己，不需要 CAS 竞争，开销很低。&lt;/p&gt;
&lt;p&gt;如果有其他线程来竞争这把锁，偏向锁就会被撤销，升级为轻量级锁。&lt;/p&gt;
&lt;p&gt;注意：JDK 15 之后偏向锁被废弃，JDK 18 已经移除偏向锁相关逻辑，但面试里讲锁升级时通常还是会问这个过程。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;轻量级锁&lt;/p&gt;
&lt;p&gt;轻量级锁适合“多个线程交替进入同步代码块，但竞争不激烈”的场景。&lt;/p&gt;
&lt;p&gt;线程进入同步代码块时，会在自己的栈帧里创建 Lock Record，把对象头 Mark Word 复制进去。&lt;/p&gt;
&lt;p&gt;然后通过 CAS 尝试把对象头 Mark Word 替换成指向自己 Lock Record 的指针。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CAS 成功：线程获得轻量级锁。&lt;/li&gt;
&lt;li&gt;CAS 失败：说明有其他线程竞争。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果竞争线程短时间内能拿到锁，会通过自旋等待，避免直接阻塞线程。&lt;/p&gt;
&lt;p&gt;如果自旋多次仍然失败，说明竞争比较激烈，轻量级锁会膨胀为重量级锁。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;重量级锁&lt;/p&gt;
&lt;p&gt;重量级锁适合竞争激烈的场景。&lt;/p&gt;
&lt;p&gt;当锁升级为重量级锁后，对象头 Mark Word 会指向一个 Monitor 对象。&lt;/p&gt;
&lt;p&gt;Monitor 里面会维护锁的持有者、等待队列、阻塞队列等信息。&lt;/p&gt;
&lt;p&gt;没有抢到锁的线程会被阻塞，进入操作系统层面的线程挂起和唤醒。&lt;/p&gt;
&lt;p&gt;这种方式避免了线程一直自旋浪费 CPU，但线程阻塞和唤醒需要从用户态切换到内核态，开销比较大。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;锁升级特点&lt;/p&gt;
&lt;p&gt;&lt;code&gt;synchronized&lt;/code&gt; 的锁升级通常是单向的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;偏向锁 -&amp;gt; 轻量级锁 -&amp;gt; 重量级锁
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一般不会从重量级锁再降回轻量级锁。&lt;/p&gt;
&lt;p&gt;JVM 这样设计是为了在不同竞争强度下选择合适的加锁方式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;没有竞争：偏向锁，减少 CAS。&lt;/li&gt;
&lt;li&gt;轻微竞争：轻量级锁，通过 CAS 和自旋避免阻塞。&lt;/li&gt;
&lt;li&gt;激烈竞争：重量级锁，阻塞线程，避免空转浪费 CPU。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;synchronized&lt;/code&gt; 的锁升级过程大致是无锁、偏向锁、轻量级锁、重量级锁。偏向锁适合单线程反复进入同步块，会把线程 ID 记录在对象头里；出现其他线程竞争后撤销偏向锁，升级为轻量级锁；轻量级锁通过栈帧里的 Lock Record 和 CAS 获取锁，竞争不激烈时通过自旋等待；如果竞争激烈，自旋失败，就膨胀为重量级锁，底层通过 Monitor 管理等待线程。这样 JVM 可以根据竞争程度在加锁开销和线程阻塞之间做平衡。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：synchronized 升级成重量级锁后，底层的数据结构是怎么样实现的？&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：请说明 AQS。&lt;/p&gt;
&lt;p&gt;AQS 全称是 AbstractQueuedSynchronizer，是 Java 并发包里很多锁和同步器的基础框架，比如 &lt;code&gt;ReentrantLock&lt;/code&gt;、&lt;code&gt;Semaphore&lt;/code&gt;、&lt;code&gt;CountDownLatch&lt;/code&gt;、&lt;code&gt;ReentrantReadWriteLock&lt;/code&gt; 底层都用到了 AQS。&lt;/p&gt;
&lt;p&gt;AQS 的核心思想是：用一个 &lt;code&gt;state&lt;/code&gt; 表示同步状态，用一个 FIFO 队列管理获取锁失败后需要等待的线程。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;state 同步状态&lt;/p&gt;
&lt;p&gt;AQS 里有一个 &lt;code&gt;volatile int state&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这个 &lt;code&gt;state&lt;/code&gt; 的含义由具体同步器决定。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ReentrantLock&lt;/code&gt; 里，&lt;code&gt;state=0&lt;/code&gt; 表示锁空闲，&lt;code&gt;state&amp;gt;0&lt;/code&gt; 表示锁被持有，数值表示重入次数。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Semaphore&lt;/code&gt; 里，&lt;code&gt;state&lt;/code&gt; 表示剩余许可证数量。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CountDownLatch&lt;/code&gt; 里，&lt;code&gt;state&lt;/code&gt; 表示计数器还剩多少。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;AQS 通过 CAS 修改 &lt;code&gt;state&lt;/code&gt;，保证并发修改的原子性。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;CLH 队列&lt;/p&gt;
&lt;p&gt;如果线程尝试获取锁失败，就会被封装成一个 Node 节点，加入 AQS 的同步队列。&lt;/p&gt;
&lt;p&gt;这个队列是一个变体的 CLH FIFO 双向队列。&lt;/p&gt;
&lt;p&gt;队列里的节点大致保存：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当前线程。&lt;/li&gt;
&lt;li&gt;前驱节点。&lt;/li&gt;
&lt;li&gt;后继节点。&lt;/li&gt;
&lt;li&gt;等待状态。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;排队的线程会被 &lt;code&gt;LockSupport.park()&lt;/code&gt; 挂起，等前驱节点释放锁后，再被唤醒继续竞争。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;获取锁流程&lt;/p&gt;
&lt;p&gt;以独占锁为例，比如 &lt;code&gt;ReentrantLock&lt;/code&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;线程先尝试通过 CAS 修改 &lt;code&gt;state&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;如果 &lt;code&gt;state=0&lt;/code&gt;，CAS 成功，说明拿到锁。&lt;/li&gt;
&lt;li&gt;如果锁已经被其他线程持有，获取失败。&lt;/li&gt;
&lt;li&gt;获取失败后，当前线程进入 AQS 队列排队。&lt;/li&gt;
&lt;li&gt;排队后，线程会被挂起，等待前驱节点释放锁后唤醒。&lt;/li&gt;
&lt;li&gt;被唤醒后再次尝试获取锁。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;释放锁流程&lt;/p&gt;
&lt;p&gt;持有锁的线程释放锁时，会修改 &lt;code&gt;state&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;如果释放后 &lt;code&gt;state=0&lt;/code&gt;，说明锁真正空闲。&lt;/p&gt;
&lt;p&gt;然后 AQS 会唤醒队列里的后继节点，让它继续尝试获取锁。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;AQS 的作用&lt;/p&gt;
&lt;p&gt;AQS 把通用的排队、阻塞、唤醒、CAS 修改状态这些逻辑封装好了。&lt;/p&gt;
&lt;p&gt;具体同步器只需要实现几个模板方法，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;tryAcquire&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tryRelease&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tryAcquireShared&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tryReleaseShared&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样不同锁可以复用同一套同步队列和线程调度机制。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;AQS 是 Java 并发包的基础同步框架，核心是 &lt;code&gt;state&lt;/code&gt; 加 CLH 队列。&lt;code&gt;state&lt;/code&gt; 表示同步状态，通过 CAS 修改；获取失败的线程会被封装成 Node 放入 FIFO 队列，并通过 &lt;code&gt;LockSupport.park()&lt;/code&gt; 挂起；释放锁时再通过 &lt;code&gt;unpark()&lt;/code&gt; 唤醒后继节点。像 &lt;code&gt;ReentrantLock&lt;/code&gt;、&lt;code&gt;Semaphore&lt;/code&gt;、&lt;code&gt;CountDownLatch&lt;/code&gt; 都是基于它实现的。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：请说明阻塞式 IO（BIO）和非阻塞式 IO 的区别，以及非阻塞式 IO 的实现原理。&lt;/p&gt;
&lt;p&gt;BIO 和非阻塞 IO 的核心区别在于：线程发起 IO 操作时，如果数据没有准备好，线程是否会被阻塞。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;BIO 是什么&lt;/p&gt;
&lt;p&gt;BIO 是 Blocking IO，也就是阻塞式 IO。&lt;/p&gt;
&lt;p&gt;在 BIO 模型下，线程调用 &lt;code&gt;read()&lt;/code&gt; 读取数据时，如果数据还没有准备好，线程会一直阻塞在那里，直到数据到达或者连接关闭。&lt;/p&gt;
&lt;p&gt;典型模型是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;一个连接 -&amp;gt; 一个线程
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每来一个客户端连接，服务端就分配一个线程处理这个连接的读写。&lt;/p&gt;
&lt;p&gt;这种方式编程简单，但并发连接数很高时，线程数量会很多，线程上下文切换、内存占用都会变大。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;非阻塞 IO 是什么&lt;/p&gt;
&lt;p&gt;非阻塞 IO 指的是把 socket 设置成 non-blocking。&lt;/p&gt;
&lt;p&gt;当线程调用 &lt;code&gt;read()&lt;/code&gt; 时，如果数据还没准备好，系统不会阻塞线程，而是立即返回一个错误码，比如 &lt;code&gt;EAGAIN&lt;/code&gt; 或 &lt;code&gt;EWOULDBLOCK&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;应用线程可以继续做其他事情，稍后再来尝试读取。&lt;/p&gt;
&lt;p&gt;这样一个线程可以管理多个连接，但如果只是简单轮询所有连接，会有大量无效检查，效率也不高。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;IO 多路复用&lt;/p&gt;
&lt;p&gt;实际工程里，非阻塞 IO 通常会配合 IO 多路复用使用，比如 &lt;code&gt;select&lt;/code&gt;、&lt;code&gt;poll&lt;/code&gt;、&lt;code&gt;epoll&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;它的思路是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;把多个 socket 注册到一个多路复用器上。&lt;/li&gt;
&lt;li&gt;线程阻塞在 &lt;code&gt;select/poll/epoll_wait&lt;/code&gt; 上。&lt;/li&gt;
&lt;li&gt;哪些连接有事件，比如可读、可写，内核就通知应用。&lt;/li&gt;
&lt;li&gt;应用线程只处理已经就绪的连接。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样一个线程就可以管理大量连接，避免一个连接一个线程。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;非阻塞 IO 的实现原理&lt;/p&gt;
&lt;p&gt;非阻塞 IO 的关键有两点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;socket 设置为非阻塞。&lt;/li&gt;
&lt;li&gt;使用事件通知机制监听多个 fd 的就绪状态。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;大致流程是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;socket 设置 non-blocking
-&amp;gt; 注册 fd 到 epoll
-&amp;gt; 线程调用 epoll_wait 等待事件
-&amp;gt; 内核发现某个 fd 可读或可写
-&amp;gt; epoll_wait 返回就绪 fd
-&amp;gt; 应用线程对这些 fd 执行 read/write
-&amp;gt; read/write 不会长期阻塞，没有数据就立即返回
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;BIO 和非阻塞 IO 的区别&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;BIO：线程调用 IO 时会阻塞，一个连接通常对应一个线程。&lt;/li&gt;
&lt;li&gt;非阻塞 IO：线程调用 IO 时不会因为数据未准备好长期阻塞，可以配合事件机制管理多个连接。&lt;/li&gt;
&lt;li&gt;BIO 编程简单，但高并发连接下线程成本高。&lt;/li&gt;
&lt;li&gt;非阻塞 IO 编程复杂，但适合高并发网络服务，比如 Netty、Redis、Nginx。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;BIO 是阻塞式 IO，线程调用 &lt;code&gt;read/write&lt;/code&gt; 时，如果数据没准备好会一直阻塞，常见模型是一个连接一个线程。非阻塞 IO 会把 socket 设置为 non-blocking，数据没准备好时立即返回，不会阻塞线程。实际使用时通常配合 IO 多路复用，比如 epoll，把多个 fd 注册到内核，线程通过 &lt;code&gt;epoll_wait&lt;/code&gt; 等待就绪事件，只处理已经可读或可写的连接。这样一个线程可以管理大量连接，更适合高并发场景。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：请介绍对 epoll 系统调用的了解。（OS）&lt;/p&gt;
&lt;p&gt;&lt;code&gt;epoll&lt;/code&gt; 是 Linux 提供的 IO 多路复用机制，用来让一个线程高效监听大量文件描述符，比如 socket 连接。它常用于高并发网络服务，比如 Redis、Nginx、Netty 的底层模型。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;epoll 解决什么问题&lt;/p&gt;
&lt;p&gt;在高并发网络场景下，如果一个连接一个线程，线程数量会很多，切换和内存开销很大。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;epoll&lt;/code&gt; 的作用是：把大量 socket 注册到内核里，由内核帮我们监听哪些 socket 已经就绪。应用线程只需要处理已经就绪的连接。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;epoll 的三个核心调用&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;epoll_create&lt;/code&gt;：创建一个 epoll 实例。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;epoll_ctl&lt;/code&gt;：把 fd 注册、修改或删除到 epoll 实例中。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;epoll_wait&lt;/code&gt;：等待就绪事件返回。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;大致流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;epoll_create 创建 epoll 实例
-&amp;gt; epoll_ctl 注册 socket fd
-&amp;gt; epoll_wait 等待事件
-&amp;gt; 有 fd 可读或可写时返回
-&amp;gt; 应用程序处理这些就绪 fd
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;相比 select / poll 的优势&lt;/p&gt;
&lt;p&gt;&lt;code&gt;select&lt;/code&gt; 和 &lt;code&gt;poll&lt;/code&gt; 每次调用都需要把 fd 集合传给内核，并且内核要遍历所有 fd 判断是否就绪。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;epoll&lt;/code&gt; 会把 fd 维护在内核里，应用只需要注册一次。事件就绪后，内核把就绪 fd 放到就绪队列，&lt;code&gt;epoll_wait&lt;/code&gt; 直接返回就绪事件。&lt;/p&gt;
&lt;p&gt;所以在大量连接、少量活跃的场景下，&lt;code&gt;epoll&lt;/code&gt; 效率更高。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;LT 和 ET 模式&lt;/p&gt;
&lt;p&gt;&lt;code&gt;epoll&lt;/code&gt; 有两种触发模式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LT，水平触发：只要 fd 上还有数据没读完，&lt;code&gt;epoll_wait&lt;/code&gt; 就会一直通知。&lt;/li&gt;
&lt;li&gt;ET，边缘触发：只有状态发生变化时通知一次，比如从不可读变成可读。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;LT 使用更简单，不容易漏事件。ET 性能更高，但要求一次性把数据读到 &lt;code&gt;EAGAIN&lt;/code&gt;，通常要配合非阻塞 IO 使用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用场景&lt;/p&gt;
&lt;p&gt;&lt;code&gt;epoll&lt;/code&gt; 适合高并发连接场景，特别是连接数很多但同一时间活跃连接有限的服务，比如网关、IM、Redis、Nginx 这类网络服务。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;epoll&lt;/code&gt; 是 Linux 的 IO 多路复用机制，通过 &lt;code&gt;epoll_create&lt;/code&gt; 创建实例，&lt;code&gt;epoll_ctl&lt;/code&gt; 注册 fd，&lt;code&gt;epoll_wait&lt;/code&gt; 等待就绪事件。它把 fd 集合维护在内核中，就绪后只返回活跃 fd，避免每次遍历所有连接，所以适合高并发网络服务。&lt;code&gt;epoll&lt;/code&gt; 支持 LT 和 ET 两种模式，ET 性能更高但要求配合非阻塞 IO，把数据读到 &lt;code&gt;EAGAIN&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：请说明 HashMap 中 put 一个元素的过程。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 的 &lt;code&gt;put&lt;/code&gt; 过程主要包括：计算 hash、定位数组下标、判断桶是否为空、处理 key 冲突、链表或红黑树插入、必要时扩容。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;计算 hash&lt;/p&gt;
&lt;p&gt;调用 &lt;code&gt;put(key, value)&lt;/code&gt; 时，首先会根据 key 计算 hash 值。&lt;/p&gt;
&lt;p&gt;如果 key 是 &lt;code&gt;null&lt;/code&gt;，通常放在数组下标 0 的位置。&lt;/p&gt;
&lt;p&gt;如果 key 不为 &lt;code&gt;null&lt;/code&gt;，会调用 key 的 &lt;code&gt;hashCode()&lt;/code&gt;，然后做一次扰动计算：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hash = h ^ (h &amp;gt;&amp;gt;&amp;gt; 16)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样做是为了让 hash 的高位也参与下标计算，减少哈希冲突。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;初始化数组&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 底层是数组加链表加红黑树。&lt;/p&gt;
&lt;p&gt;如果当前 table 还没有初始化，第一次 put 时会先进行初始化。&lt;/p&gt;
&lt;p&gt;默认初始容量是 16，默认负载因子是 0.75。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;定位桶下标&lt;/p&gt;
&lt;p&gt;根据 hash 计算数组下标：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;index = (table.length - 1) &amp;amp; hash
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为 table 长度通常是 2 的幂，所以可以用位运算代替取模，提高效率。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;桶为空，直接插入&lt;/p&gt;
&lt;p&gt;如果对应下标位置没有元素，就直接创建新节点放进去。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;桶不为空，处理冲突&lt;/p&gt;
&lt;p&gt;如果桶里已经有元素，说明发生哈希冲突。&lt;/p&gt;
&lt;p&gt;这时会先判断桶里的第一个节点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果第一个节点的 hash 相同，并且 key 相等，就覆盖旧 value。&lt;/li&gt;
&lt;li&gt;如果第一个节点是红黑树节点，就按红黑树方式插入。&lt;/li&gt;
&lt;li&gt;否则就遍历链表。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;遍历链表&lt;/p&gt;
&lt;p&gt;遍历链表时，会逐个比较节点的 hash 和 key。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果找到相同 key，就覆盖旧 value。&lt;/li&gt;
&lt;li&gt;如果没有找到，就把新节点插入到链表尾部。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;插入后，如果链表长度达到树化阈值，默认是 8，并且数组容量达到 64，就会把链表转换成红黑树。&lt;/p&gt;
&lt;p&gt;如果数组容量还没到 64，通常会优先扩容，而不是马上树化。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;扩容判断&lt;/p&gt;
&lt;p&gt;插入新节点后，&lt;code&gt;HashMap&lt;/code&gt; 的 size 会加 1。&lt;/p&gt;
&lt;p&gt;如果 size 超过阈值：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;threshold = capacity * loadFactor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;就会触发扩容。&lt;/p&gt;
&lt;p&gt;扩容一般是容量变成原来的 2 倍，然后重新分布元素。&lt;/p&gt;
&lt;p&gt;JDK 8 中扩容时不需要重新计算完整 hash，只需要看原 hash 和旧容量做与运算：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hash &amp;amp; oldCap
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果结果是 0，节点还在原位置；如果不是 0，节点移动到 &lt;code&gt;原位置 + oldCap&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;返回旧值&lt;/p&gt;
&lt;p&gt;如果 put 的 key 已经存在，会覆盖旧 value，并返回旧 value。&lt;/p&gt;
&lt;p&gt;如果是新增 key，则返回 &lt;code&gt;null&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; put 时，先根据 key 计算 hash，并通过 &lt;code&gt;(n - 1) &amp;amp; hash&lt;/code&gt; 定位数组下标。如果桶为空就直接插入；如果桶不为空，就判断是否 key 相同，相同则覆盖 value；如果是链表就遍历链表插入或覆盖；如果是红黑树就按树节点插入。链表长度达到 8 且数组容量达到 64 时会树化，否则优先扩容。插入后如果 size 超过 &lt;code&gt;capacity * loadFactor&lt;/code&gt;，会触发扩容，容量变为原来的 2 倍。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：为什么 HashMap 使用红黑树，而非平衡二叉树（如 AVL 树）？&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：为什么 JDK 提供非线程安全的 HashMap，而非统一鼓励使用 ConcurrentHashMap？&lt;/p&gt;
&lt;p&gt;JDK 同时提供 &lt;code&gt;HashMap&lt;/code&gt; 和 &lt;code&gt;ConcurrentHashMap&lt;/code&gt;，是因为它们面向的使用场景不一样。&lt;code&gt;HashMap&lt;/code&gt; 适合单线程或外部已经保证线程安全的场景，&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 适合多线程并发读写场景。并不是所有地方都需要并发控制，统一使用 &lt;code&gt;ConcurrentHashMap&lt;/code&gt; 会带来不必要的成本。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;HashMap 性能更轻量&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 没有并发控制逻辑。&lt;/p&gt;
&lt;p&gt;它不需要 CAS、volatile、锁、分段控制、同步状态维护这些机制，所以在单线程场景下性能更好，内存结构也更简单。&lt;/p&gt;
&lt;p&gt;很多业务代码里，Map 只是方法内部的临时变量，或者对象私有数据，不会被多个线程同时访问，这种情况用 &lt;code&gt;HashMap&lt;/code&gt; 就足够了。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;ConcurrentHashMap 有并发成本&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 为了保证线程安全，需要额外机制。&lt;/p&gt;
&lt;p&gt;JDK 8 里它主要通过：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CAS 插入节点&lt;/li&gt;
&lt;li&gt;volatile 保证可见性&lt;/li&gt;
&lt;li&gt;synchronized 锁桶头节点&lt;/li&gt;
&lt;li&gt;扩容协作&lt;/li&gt;
&lt;li&gt;更复杂的计数逻辑&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些都会带来额外 CPU 和内存开销。&lt;/p&gt;
&lt;p&gt;如果没有并发访问，使用 &lt;code&gt;ConcurrentHashMap&lt;/code&gt; 属于过度设计。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;语义不一样&lt;/p&gt;
&lt;p&gt;&lt;code&gt;HashMap&lt;/code&gt; 允许 &lt;code&gt;null key&lt;/code&gt; 和 &lt;code&gt;null value&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 不允许 &lt;code&gt;null key&lt;/code&gt; 和 &lt;code&gt;null value&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;因为在并发场景下，如果 &lt;code&gt;get(key)&lt;/code&gt; 返回 &lt;code&gt;null&lt;/code&gt;，无法区分：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;key 不存在
key 存在，但 value 是 null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这会影响并发语义。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;线程安全不等于业务安全&lt;/p&gt;
&lt;p&gt;即使用了 &lt;code&gt;ConcurrentHashMap&lt;/code&gt;，也只能保证单次操作是线程安全的。&lt;/p&gt;
&lt;p&gt;但复合操作仍然可能有并发问题，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (!map.containsKey(key)) {
    map.put(key, value);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这两个操作组合起来不是原子的。&lt;/p&gt;
&lt;p&gt;如果业务需要原子语义，还是要用 &lt;code&gt;putIfAbsent&lt;/code&gt;、&lt;code&gt;computeIfAbsent&lt;/code&gt;，或者额外加锁。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;JDK 提供的是不同工具&lt;/p&gt;
&lt;p&gt;JDK 的设计是提供不同层次的工具，让开发者根据场景选择。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;单线程或局部变量：用 &lt;code&gt;HashMap&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;只读共享：可以初始化完成后安全发布，继续用普通 Map。&lt;/li&gt;
&lt;li&gt;多线程并发读写：用 &lt;code&gt;ConcurrentHashMap&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;需要有序：用 &lt;code&gt;LinkedHashMap&lt;/code&gt; 或 &lt;code&gt;TreeMap&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;需要外部同步：可以用 &lt;code&gt;Collections.synchronizedMap&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;JDK 保留非线程安全的 &lt;code&gt;HashMap&lt;/code&gt;，是因为很多场景并不需要并发控制。&lt;code&gt;HashMap&lt;/code&gt; 结构简单、性能更好、支持 null，适合单线程、局部变量或外部已经保证同步的场景。&lt;code&gt;ConcurrentHashMap&lt;/code&gt; 适合多线程并发读写，但它有 CAS、volatile、锁和扩容协作等额外成本，而且只能保证单次操作线程安全，不能自动保证复合业务逻辑原子。所以 JDK 不会统一鼓励所有场景都用 &lt;code&gt;ConcurrentHashMap&lt;/code&gt;，而是让开发者根据是否存在并发访问来选择合适的 Map。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：请介绍 AQS 的原理。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：ReentrantLock 是如何实现可重入的？&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ReentrantLock&lt;/code&gt; 的可重入是基于 AQS 的 &lt;code&gt;state&lt;/code&gt; 和当前持锁线程来实现的。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;state 表示重入次数&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ReentrantLock&lt;/code&gt; 底层基于 AQS。&lt;/p&gt;
&lt;p&gt;AQS 里有一个 &lt;code&gt;volatile int state&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;在 &lt;code&gt;ReentrantLock&lt;/code&gt; 里，&lt;code&gt;state&lt;/code&gt; 表示锁被当前线程重入的次数：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;state = 0&lt;/code&gt;：锁没有被占用。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;state = 1&lt;/code&gt;：锁被某个线程持有了一次。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;state &amp;gt; 1&lt;/code&gt;：同一个线程重复获取了这把锁。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;第一次加锁&lt;/p&gt;
&lt;p&gt;当线程第一次调用 &lt;code&gt;lock()&lt;/code&gt; 时，如果锁空闲，也就是 &lt;code&gt;state = 0&lt;/code&gt;，线程会通过 CAS 把 &lt;code&gt;state&lt;/code&gt; 从 0 改成 1。&lt;/p&gt;
&lt;p&gt;CAS 成功后，AQS 会把当前线程设置为锁的持有者，也就是 owner。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;同一线程再次加锁&lt;/p&gt;
&lt;p&gt;如果当前线程再次调用 &lt;code&gt;lock()&lt;/code&gt;，AQS 会发现锁已经被占用。&lt;/p&gt;
&lt;p&gt;这时它会判断当前线程是不是 owner。&lt;/p&gt;
&lt;p&gt;如果当前线程就是 owner，说明是重入，不需要阻塞，直接把 &lt;code&gt;state + 1&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;释放锁&lt;/p&gt;
&lt;p&gt;每次调用 &lt;code&gt;unlock()&lt;/code&gt;，都会让 &lt;code&gt;state - 1&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;如果 &lt;code&gt;state&lt;/code&gt; 还大于 0，说明当前线程只是释放了一层重入，锁仍然由它持有。&lt;/p&gt;
&lt;p&gt;只有当 &lt;code&gt;state&lt;/code&gt; 减到 0，才表示锁完全释放。&lt;/p&gt;
&lt;p&gt;这时 AQS 会清空 owner，并唤醒等待队列里的后继线程。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;为什么必须 unlock 对应次数&lt;/p&gt;
&lt;p&gt;因为每次重入都会让 &lt;code&gt;state + 1&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;所以加锁几次，就必须释放几次。&lt;/p&gt;
&lt;p&gt;如果少释放一次，&lt;code&gt;state&lt;/code&gt; 不能归零，锁就不会真正释放，其他线程会一直拿不到锁。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;ReentrantLock&lt;/code&gt; 的可重入是通过 AQS 的 &lt;code&gt;state&lt;/code&gt; 实现的。第一次加锁时，线程通过 CAS 把 &lt;code&gt;state&lt;/code&gt; 从 0 改成 1，并设置 owner 为当前线程；同一个线程再次加锁时，发现 owner 是自己，就直接让 &lt;code&gt;state + 1&lt;/code&gt;，不会阻塞；释放锁时每次 &lt;code&gt;state - 1&lt;/code&gt;，只有减到 0 才真正释放锁并唤醒等待线程。所以加锁几次就要解锁几次。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：为什么要实现锁的可重入特性？&lt;/p&gt;
&lt;p&gt;锁的可重入主要是为了避免同一个线程重复获取同一把锁时把自己阻塞住，同时也让同步方法之间的相互调用更加自然。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;避免自己锁死自己&lt;/p&gt;
&lt;p&gt;如果锁不可重入，同一个线程已经拿到锁后，再次进入需要同一把锁的代码，就会被阻塞。&lt;/p&gt;
&lt;p&gt;但阻塞的线程正是当前持锁线程，它自己不继续执行就无法释放锁，于是会造成死锁。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public synchronized void methodA() {
    methodB();
}

public synchronized void methodB() {
    // do something
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;methodA()&lt;/code&gt; 和 &lt;code&gt;methodB()&lt;/code&gt; 都需要同一个对象锁。&lt;/p&gt;
&lt;p&gt;如果 &lt;code&gt;synchronized&lt;/code&gt; 不可重入，线程进入 &lt;code&gt;methodA()&lt;/code&gt; 后已经持有对象锁，再调用 &lt;code&gt;methodB()&lt;/code&gt; 时会再次申请同一把锁，然后把自己阻塞住。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;支持同步方法之间的嵌套调用&lt;/p&gt;
&lt;p&gt;实际业务代码里，一个同步方法调用另一个同步方法很常见。&lt;/p&gt;
&lt;p&gt;可重入锁允许同一个线程多次进入同一把锁保护的代码块，只要记录重入次数即可。&lt;/p&gt;
&lt;p&gt;这样代码可以按正常业务逻辑拆分方法，不需要为了避免重复加锁把逻辑写成一大块。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;支持递归调用&lt;/p&gt;
&lt;p&gt;如果一个同步方法里存在递归调用，也需要可重入。&lt;/p&gt;
&lt;p&gt;同一个线程每递归一层，都会再次进入同一个同步代码块。&lt;/p&gt;
&lt;p&gt;如果锁不可重入，递归第二次进入时就会阻塞自己。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;让锁的语义更符合线程所有权&lt;/p&gt;
&lt;p&gt;可重入锁的语义是：如果当前线程已经拥有这把锁，那么它可以再次进入这把锁保护的区域。&lt;/p&gt;
&lt;p&gt;锁内部只需要维护一个重入计数。&lt;/p&gt;
&lt;p&gt;每次进入时计数加 1，每次退出时计数减 1，只有计数归零时才真正释放锁。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;实现锁的可重入，主要是为了避免同一个线程重复申请同一把锁时发生自我死锁。它支持同步方法之间的嵌套调用和递归调用，让代码结构更自然。底层一般通过“持锁线程 + 重入计数”实现：同一个线程再次加锁时只增加计数，不阻塞；释放时计数递减，直到计数为 0 才真正释放锁。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：请介绍 HTTPS 中 TLS 的密钥交换流程。&lt;/p&gt;
&lt;p&gt;HTTPS 本质上是 HTTP 加 TLS。TLS 握手的核心目标是：客户端先确认服务端身份可信，然后和服务端协商出一个只有双方知道的对称会话密钥。后续真正传输 HTTP 数据时，用这个会话密钥加密。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;ClientHello&lt;/p&gt;
&lt;p&gt;客户端先发送 &lt;code&gt;ClientHello&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;里面主要包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;客户端支持的 TLS 版本。&lt;/li&gt;
&lt;li&gt;支持的加密套件列表。&lt;/li&gt;
&lt;li&gt;客户端随机数 &lt;code&gt;Client Random&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;支持的扩展，比如 SNI。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一步不传公钥，也不传私钥。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;ServerHello&lt;/p&gt;
&lt;p&gt;服务端收到后返回 &lt;code&gt;ServerHello&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;里面主要包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;服务端选择的 TLS 版本。&lt;/li&gt;
&lt;li&gt;服务端选择的加密套件。&lt;/li&gt;
&lt;li&gt;服务端随机数 &lt;code&gt;Server Random&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一步本身也不传私钥。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;服务端发送证书&lt;/p&gt;
&lt;p&gt;服务端会把自己的数字证书发给客户端。&lt;/p&gt;
&lt;p&gt;证书里包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;服务端公钥。&lt;/li&gt;
&lt;li&gt;域名。&lt;/li&gt;
&lt;li&gt;证书颁发机构。&lt;/li&gt;
&lt;li&gt;有效期。&lt;/li&gt;
&lt;li&gt;CA 签名。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这里传输的是服务端公钥。&lt;/p&gt;
&lt;p&gt;注意：服务端私钥不会传输，私钥永远只保存在服务端。&lt;/p&gt;
&lt;p&gt;客户端收到证书后，会验证证书是否合法，比如是否过期、域名是否匹配、证书链是否可信、CA 签名是否正确。&lt;/p&gt;
&lt;p&gt;验证通过后，客户端就认为：证书里的公钥确实属于这个服务端。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;密钥交换&lt;/p&gt;
&lt;p&gt;如果是传统 RSA 密钥交换：&lt;/p&gt;
&lt;p&gt;客户端生成一个 &lt;code&gt;Pre-Master Secret&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;然后客户端使用服务端证书里的公钥加密这个 &lt;code&gt;Pre-Master Secret&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;再把加密后的内容发送给服务端。&lt;/p&gt;
&lt;p&gt;这里用到的是服务端公钥加密。&lt;/p&gt;
&lt;p&gt;服务端收到后，使用自己本地保存的服务端私钥解密，得到 &lt;code&gt;Pre-Master Secret&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这里用到的是服务端私钥解密。&lt;/p&gt;
&lt;p&gt;所以：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;公钥：在证书里发给客户端。&lt;/li&gt;
&lt;li&gt;私钥：不传输，只在服务端本地用来解密。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Pre-Master Secret&lt;/code&gt;：客户端生成，用服务端公钥加密后传输。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;然后客户端和服务端都根据：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Client Random + Server Random + Pre-Master Secret
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;生成最终的会话密钥。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;切换到对称加密&lt;/p&gt;
&lt;p&gt;会话密钥生成后，客户端和服务端会发送 &lt;code&gt;ChangeCipherSpec&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;它表示：后续通信开始使用协商出的对称会话密钥加密。&lt;/p&gt;
&lt;p&gt;然后双方发送 &lt;code&gt;Finished&lt;/code&gt; 消息，用来校验前面的握手过程是否被篡改。&lt;/p&gt;
&lt;p&gt;这一步开始后，主要使用的是对称密钥。&lt;/p&gt;
&lt;p&gt;公钥和私钥不再用于加密每一条 HTTP 数据。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;后续 HTTP 数据加密传输&lt;/p&gt;
&lt;p&gt;TLS 握手完成后，后续 HTTP 请求和响应都会使用会话密钥进行对称加密。&lt;/p&gt;
&lt;p&gt;原因是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;非对称加密安全，但性能较低。&lt;/li&gt;
&lt;li&gt;对称加密性能高，适合大量业务数据传输。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;服务端公钥通过证书发送给客户端，服务端私钥永远不传输。客户端验证证书后，用服务端公钥加密 &lt;code&gt;Pre-Master Secret&lt;/code&gt; 发给服务端，服务端用自己的私钥解密。双方再结合 &lt;code&gt;Client Random&lt;/code&gt;、&lt;code&gt;Server Random&lt;/code&gt; 和 &lt;code&gt;Pre-Master Secret&lt;/code&gt; 生成对称会话密钥。后续 HTTP 数据都用这个会话密钥加密传输。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：客户端如何验证 CA 证书的有效性？&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;四、分析并发 SQL&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;问题：在 MySQL 可重复读隔离级别下，分析指定并发 SQL 的查询结果、更新阻塞情况及最终查询金额。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;问题：请说明 MySQL 中当前读和快照读的区别，以及什么情况下会触发当前读？&lt;/p&gt;
&lt;p&gt;MySQL InnoDB 里读数据可以分成快照读和当前读。核心区别是：快照读读的是历史版本，不加锁；当前读读的是最新版本，并且通常会加锁。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;快照读是什么&lt;/p&gt;
&lt;p&gt;快照读就是普通 &lt;code&gt;SELECT&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT * FROM user WHERE id = 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 InnoDB 里，普通 &lt;code&gt;SELECT&lt;/code&gt; 会通过 MVCC 读取某个历史版本。&lt;/p&gt;
&lt;p&gt;它不会直接读取最新加锁版本，也不会对记录加锁。&lt;/p&gt;
&lt;p&gt;在可重复读（RR）隔离级别下，事务第一次快照读会生成 ReadView，后续普通 &lt;code&gt;SELECT&lt;/code&gt; 会复用这个 ReadView，所以同一个事务里多次查询结果保持一致。&lt;/p&gt;
&lt;p&gt;在读已提交（RC）隔离级别下，每次普通 &lt;code&gt;SELECT&lt;/code&gt; 都会生成新的 ReadView，所以可以读到其他事务刚提交的数据。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;当前读是什么&lt;/p&gt;
&lt;p&gt;当前读读取的是记录的最新版本。&lt;/p&gt;
&lt;p&gt;当前读为了保证读到的数据后续可以被安全修改，通常会加锁。&lt;/p&gt;
&lt;p&gt;常见当前读包括：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT ... FOR UPDATE;
SELECT ... LOCK IN SHARE MODE;
UPDATE ...
DELETE ...
INSERT ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; 会加排他锁。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;SELECT ... LOCK IN SHARE MODE&lt;/code&gt; 会加共享锁。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;UPDATE&lt;/code&gt;、&lt;code&gt;DELETE&lt;/code&gt; 在执行时也需要先读到最新数据，再加锁修改。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;两者核心区别&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;快照读：普通 &lt;code&gt;SELECT&lt;/code&gt;，基于 MVCC 读历史版本，不加锁。&lt;/li&gt;
&lt;li&gt;当前读：读最新版本，并加锁，保证当前事务后续可以安全修改或判断数据状态。&lt;/li&gt;
&lt;li&gt;快照读提高并发性能，避免读写互相阻塞。&lt;/li&gt;
&lt;li&gt;当前读保证数据一致性，适合更新、删除、加锁查询等场景。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;什么情况下会触发当前读&lt;/p&gt;
&lt;p&gt;下面这些语句会触发当前读：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT ... FOR UPDATE;
SELECT ... LOCK IN SHARE MODE;
UPDATE ...
DELETE ...
INSERT ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;另外，如果执行的是加锁读，或者要修改数据，就需要当前读。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;举例说明&lt;/p&gt;
&lt;p&gt;比如账户余额初始是 100。&lt;/p&gt;
&lt;p&gt;事务 A：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT balance FROM account WHERE id = 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是快照读，读的是当前事务 ReadView 可见的版本。&lt;/p&gt;
&lt;p&gt;事务 B 提交修改，把余额改成 80。&lt;/p&gt;
&lt;p&gt;事务 A 再执行普通 &lt;code&gt;SELECT&lt;/code&gt;，在 RR 下可能还是看到 100。&lt;/p&gt;
&lt;p&gt;但如果事务 A 执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UPDATE account SET balance = balance - 10 WHERE id = 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个 &lt;code&gt;UPDATE&lt;/code&gt; 是当前读，会基于最新已提交的 80 继续更新。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;快照读就是普通 &lt;code&gt;SELECT&lt;/code&gt;，基于 MVCC 和 ReadView 读取历史版本，不加锁；当前读读取最新版本，并且通常会加锁。&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;、&lt;code&gt;SELECT ... LOCK IN SHARE MODE&lt;/code&gt;、&lt;code&gt;UPDATE&lt;/code&gt;、&lt;code&gt;DELETE&lt;/code&gt;、&lt;code&gt;INSERT&lt;/code&gt; 都属于当前读。RR 下普通 &lt;code&gt;SELECT&lt;/code&gt; 会复用 ReadView，所以多次查询可能看到相同快照；但 &lt;code&gt;UPDATE&lt;/code&gt; 这类当前读会读取最新已提交数据并加锁。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>字节跳动后端一面复盘</title><link>https://blog.huangnv.online/posts/26627/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26627/1/1/</guid><pubDate>Sat, 27 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;字节后端一面&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;日期：2026-06-27&lt;br /&gt;
类型：后端一面复盘&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;面试问题整理&lt;/h2&gt;
&lt;h3&gt;1. 自我介绍&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;问题：自我介绍。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2. 秒杀平台项目介绍&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;问题：介绍秒杀平台项目。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3. MQ 削峰与故障处理&lt;/h3&gt;
&lt;p&gt;有没有想过 MQ 如果挂了怎么办？&lt;/p&gt;
&lt;p&gt;如果 MQ 挂了，我会先看它在系统里承担的角色。秒杀场景里 MQ 主要是用来削峰和异步处理，所以 MQ 不可用时，不能直接绕过 MQ 去写数据库，否则高峰流量会把库存、订单库打崩。&lt;/p&gt;
&lt;p&gt;我的处理会分几层：&lt;/p&gt;
&lt;p&gt;第一层是 MQ 集群自身的高可用，比如 Broker 集群部署、主从/副本机制、多可用区部署、故障转移，避免单个 Broker 宕机导致整个消息链路不可用。&lt;/p&gt;
&lt;p&gt;第二层是入口降级。如果生产者发现 MQ 写入失败，接口可以快速失败或返回“系统繁忙，请稍后重试”，同时配合限流、熔断、活动开关，保护后端核心服务。&lt;/p&gt;
&lt;p&gt;第三层是重要消息兜底。如果业务必须保证请求不丢，可以把请求先写入本地可靠存储或数据库 outbox 表，再由后台任务在 MQ 恢复后补偿投递。但秒杀入口流量很大，这种方案要控制写入量，否则会把压力转移到数据库。&lt;/p&gt;
&lt;p&gt;第四层是恢复后的补偿。MQ 恢复后，要根据订单表、库存流水、消息状态做对账，重新投递未处理消息，并且消费者侧要保证幂等，防止重复消费导致重复扣库存或重复下单。&lt;/p&gt;
&lt;h3&gt;4. 接口限流&lt;/h3&gt;
&lt;p&gt;一般情况下，除了用户和 IP 这两个维度之外，还可以增加哪些维度？&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;接口维度
例如 /seckill/order、/coupon/receive。
用来保护高成本或核心接口。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;资源维度
例如商品 ID、优惠券 ID、活动 ID、店铺 ID。
用来防止热点资源被打爆。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;设备维度
例如 deviceId、客户端指纹。
用来补充 IP 和用户维度，防止多账号或换 IP 刷。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;5. 限流策略&lt;/h3&gt;
&lt;p&gt;常见限流策略主要有固定窗口、滑动窗口、漏桶和令牌桶。&lt;/p&gt;
&lt;h4&gt;固定窗口&lt;/h4&gt;
&lt;p&gt;固定窗口是把时间切成固定长度的窗口，比如每 1 秒一个窗口。每个窗口内维护一个计数器，请求进来时计数加 1，只要计数没有超过阈值就放行；超过阈值就拒绝。窗口结束后，计数器清零，进入下一个窗口。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;优点：实现简单，计数成本低，适合对精度要求不高的场景。&lt;/li&gt;
&lt;li&gt;缺点：存在临界突刺问题。比如限制每秒 100 次请求，用户可以在上一秒最后 100 毫秒打满 100 次，又在下一秒最开始 100 毫秒再打 100 次，短时间内实际通过 200 次请求。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;滑动窗口&lt;/h4&gt;
&lt;p&gt;滑动窗口是在固定窗口基础上做细粒度拆分。比如把 1 秒拆成 10 个 100 毫秒的小窗口，每次统计最近 1 秒内多个小窗口的请求总数。随着时间推进，窗口会不断向前滑动。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;优点：比固定窗口更平滑，可以缓解临界突刺问题，限流结果更接近真实的最近一段时间请求量。&lt;/li&gt;
&lt;li&gt;缺点：实现和存储成本更高，需要维护多个小窗口的计数。窗口拆得越细，精度越高，维护成本也越高。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;漏桶&lt;/h4&gt;
&lt;p&gt;漏桶可以理解为一个固定容量的桶，请求先进入桶里排队，系统按照固定速率从桶里取出请求并处理。如果桶满了，后续请求会被拒绝或丢弃。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;优点：输出速率稳定，可以把突发流量整形成平稳流量，适合保护下游服务。&lt;/li&gt;
&lt;li&gt;缺点：对突发流量不够友好。即使系统短时间内还有处理能力，漏桶也会按照固定速率放行，请求可能在桶里等待或被拒绝。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;令牌桶&lt;/h4&gt;
&lt;p&gt;令牌桶是系统按照固定速率往桶里放令牌，桶有最大容量。请求到来时需要先拿到令牌，拿到令牌才能通过；没有令牌就被拒绝或等待。桶里如果积累了一些令牌，短时间内可以允许一批请求同时通过。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;优点：既能限制平均速率，也能允许一定程度的突发流量，适合秒杀、网关、接口限流等场景。&lt;/li&gt;
&lt;li&gt;缺点：参数需要结合业务容量设置，比如令牌生成速率和桶容量。桶容量设置过大时，瞬时突发流量仍然可能对下游造成压力。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;6. Redis 与 MySQL 数据一致性&lt;/h3&gt;
&lt;p&gt;Redis 一般作为缓存，MySQL 作为最终数据源。系统通常保证最终一致性，常见方案是 Cache Aside。&lt;/p&gt;
&lt;h4&gt;Cache Aside 的基本流程&lt;/h4&gt;
&lt;p&gt;读请求：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;先读 Redis
-&amp;gt; Redis 命中，直接返回
-&amp;gt; Redis 未命中，读 MySQL
-&amp;gt; 把 MySQL 查询结果写回 Redis
-&amp;gt; 返回结果
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;写请求：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;先更新 MySQL
-&amp;gt; 再删除 Redis 缓存
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;写请求里通常选择删除缓存，让后续读请求重新从 MySQL 加载最新数据。这样可以避免复杂缓存内容在并发更新时被错误覆盖。&lt;/p&gt;
&lt;h4&gt;主从延迟带来的旧数据问题&lt;/h4&gt;
&lt;p&gt;如果 MySQL 做了读写分离，写请求更新的是主库，读请求可能走从库。主库同步到从库存在延迟，所以缓存删除之后，如果有读请求回源到从库，就可能读到旧数据，并把旧数据重新写回 Redis。&lt;/p&gt;
&lt;p&gt;这个问题可以根据一致性要求分层处理：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;普通最终一致场景&lt;br /&gt;
可以使用延迟双删。更新主库后先删除缓存，等待一段时间后再删除一次缓存，清掉可能被旧数据回填的缓存。延迟时间需要参考主从同步延迟，一般取大于主从延迟 P99 的时间。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;删除动作可靠性&lt;br /&gt;
可以结合消息队列、binlog 监听或重试任务，保证缓存删除动作最终执行成功。比如监听 MySQL binlog，发现数据变更后异步删除对应 Redis Key。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;强一致场景&lt;br /&gt;
对订单状态、支付状态、库存扣减结果这类强一致读，可以在写后一段时间内强制读主库，或者直接绕过缓存和从库，以 MySQL 主库结果为准。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;秒杀热点场景的补充&lt;/h4&gt;
&lt;p&gt;秒杀库存属于热点高并发写场景，如果每次购买都同步更新 MySQL 再删除 Redis，后续大量读请求可能同时回源 MySQL，导致数据库压力过高。&lt;/p&gt;
&lt;p&gt;这类场景通常把库存提前预热到 Redis：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;活动开始前：
MySQL 库存 -&amp;gt; 预热到 Redis

用户抢购时：
Redis 原子扣减库存
-&amp;gt; 扣减成功后发送 MQ
-&amp;gt; 消费者异步创建订单、落 MySQL、扣数据库库存
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Redis 承接高并发读写，MySQL 作为最终数据源。后续通过 MQ 消费、库存流水、订单状态和对账任务保证最终一致性。&lt;/p&gt;
&lt;p&gt;面试时可以总结为：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;普通读多写少业务可以用 Cache Aside，写 MySQL 后删除 Redis，保证最终一致性。如果存在主从延迟，缓存删除后可能从从库读到旧数据并回填 Redis，普通场景可以用延迟双删或 binlog 监听做二次删除；强一致场景可以在写后一段时间内强制读主库，或者直接绕过缓存和从库。秒杀库存这种热点场景会用 Redis 预扣库存加 MQ 异步落库，再通过流水和对账保证最终一致。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;7. 缓存过期与热点 Key&lt;/h3&gt;
&lt;p&gt;如果缓存过期时流量很高，需要先区分两类问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;大量 Key 同时过期&lt;br /&gt;
这类问题通常叫缓存雪崩。大量请求同时发现 Redis 没有数据，然后一起回源 MySQL，数据库会承受瞬时高压。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;单个热点 Key 过期&lt;br /&gt;
这类问题通常叫缓存击穿。比如秒杀商品库存、热门商品详情、热门活动页配置这类 Key 访问量特别大，一旦过期，短时间内会有大量请求同时打到 MySQL。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;缓存雪崩：大量 Key 同时过期&lt;/h4&gt;
&lt;p&gt;常见处理方式包括过期时间随机化、分批预热、限流降级。&lt;/p&gt;
&lt;h5&gt;过期时间随机化&lt;/h5&gt;
&lt;p&gt;缓存写入 Redis 时，不给所有 Key 设置完全相同的过期时间，而是在基础过期时间上增加一个随机偏移。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;基础过期时间：30 分钟
随机偏移：0-5 分钟
实际过期时间：30-35 分钟
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样可以让 Key 分散过期，降低同一时刻大量回源 MySQL 的概率。&lt;/p&gt;
&lt;h5&gt;分批预热&lt;/h5&gt;
&lt;p&gt;分批预热是指在高峰流量到来之前，提前把热点数据从 MySQL 加载到 Redis，并且控制加载节奏。&lt;/p&gt;
&lt;p&gt;比如秒杀活动开始前，可以先筛选活动商品、库存、活动配置、商品详情等热点数据，然后按批次写入 Redis：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;第 1 批：活动配置
第 2 批：热门商品基础信息
第 3 批：秒杀库存
第 4 批：商品详情和展示数据
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;分批预热要注意几点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;控制预热速率&lt;br /&gt;
预热本身也会查 MySQL，所以不能一次性把所有数据打到数据库上。可以分页读取、分批写入 Redis，并控制每批之间的间隔。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;优先预热热点数据&lt;br /&gt;
先预热访问量最高、最影响核心链路的数据，比如秒杀商品库存、活动配置、热门商品详情。低频数据可以等首次访问时再加载。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;错开过期时间&lt;br /&gt;
预热写入 Redis 后，仍然要给不同 Key 设置不同过期时间，避免下一轮集中失效。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;做预热校验&lt;br /&gt;
预热完成后，可以校验 Redis 中的 Key 数量、库存值、活动状态，避免活动开始后才发现缓存缺失。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;面试里可以这样表达：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;对活动类热点数据，我会在活动开始前做缓存预热，把商品信息、活动配置、库存等数据提前加载到 Redis。预热时不会一次性全量加载，而是分页查 MySQL、分批写 Redis，并控制预热速率，避免预热过程本身把数据库打满。预热完成后还会校验关键 Key 是否存在，并给不同 Key 设置随机过期时间，减少后续集中失效。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h5&gt;限流降级&lt;/h5&gt;
&lt;p&gt;限流降级是在缓存异常、回源 MySQL 增多或数据库压力升高时，主动减少进入核心链路的请求量。&lt;/p&gt;
&lt;p&gt;常见做法包括：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;入口限流&lt;br /&gt;
在网关或接口层限制整体 QPS，也可以按用户、IP、接口、商品、活动等维度限流。比如某个秒杀商品的请求量超过阈值后，后续请求直接返回“系统繁忙，请稍后重试”。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;回源限流&lt;br /&gt;
对“查 MySQL 重建缓存”这条路径单独限流。即使 Redis 失效，也不能让所有请求都去查 MySQL，只允许少量请求回源，其余请求等待、重试或返回兜底结果。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;降级返回&lt;br /&gt;
对商品详情、活动展示这类读请求，可以短时间返回旧缓存、默认值或简化信息；对下单、扣库存这类核心写请求，可以返回排队中、系统繁忙，或者通过活动开关暂停入口。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;熔断保护&lt;br /&gt;
如果发现 MySQL 响应时间升高、错误率升高或连接池耗尽，可以暂时熔断回源请求，让系统进入降级状态，优先保护数据库和核心服务。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;面试里可以这样表达：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;如果缓存失效后回源流量明显升高，我会在入口和回源路径都做限流。入口层限制用户、IP、商品和接口维度的请求量；回源层只允许少量线程查 MySQL 重建缓存，其他请求等待、重试或返回兜底结果。如果 MySQL 压力已经很高，可以触发熔断和降级，比如返回系统繁忙、返回旧缓存，或者临时关闭活动入口，优先保护数据库。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;缓存击穿：单个热点 Key 过期&lt;/h4&gt;
&lt;p&gt;热点 Key 过期时，重点是避免所有请求同时回源 MySQL。常见方案是互斥锁和逻辑过期。&lt;/p&gt;
&lt;h5&gt;互斥锁&lt;/h5&gt;
&lt;p&gt;缓存未命中后，只有一个线程能拿到锁去查 MySQL 并重建缓存。其他线程等待、重试，或者直接返回旧值。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;请求发现 Redis 未命中
-&amp;gt; 尝试获取分布式锁
-&amp;gt; 获取成功：查 MySQL，重建缓存，释放锁
-&amp;gt; 获取失败：等待后重试，或返回兜底结果
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;互斥锁可以减少数据库回源次数，但要注意锁超时时间、死锁、重试间隔和线程堆积问题。&lt;/p&gt;
&lt;h5&gt;逻辑过期&lt;/h5&gt;
&lt;p&gt;逻辑过期是缓存 Key 本身不设置物理过期时间，而是在 value 里保存一个过期时间字段。请求发现逻辑时间过期后，先返回旧数据，同时只让一个后台线程异步重建缓存。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Redis value = 数据内容 + 逻辑过期时间

请求读取 Redis
-&amp;gt; 数据没过期：直接返回
-&amp;gt; 数据逻辑过期：先返回旧数据
-&amp;gt; 后台线程异步查 MySQL 并刷新缓存
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;逻辑过期可以提高可用性，适合商品详情、活动配置这类允许短时间旧数据的场景。缺点是短时间内可能返回旧值，所以强一致业务要谨慎使用。&lt;/p&gt;
&lt;h4&gt;结合秒杀项目的回答&lt;/h4&gt;
&lt;p&gt;秒杀库存这类热点数据一般会提前预热到 Redis，抢购时通过 Redis 原子扣减库存，扣减成功后写入 MQ，由消费者异步落 MySQL。这样高并发入口主要打 Redis，MySQL 通过 MQ 被削峰。&lt;/p&gt;
&lt;p&gt;面试时可以总结为：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;如果缓存过期时流量很高，我会先区分缓存雪崩和缓存击穿。大量 Key 同时过期可以通过随机过期时间、分批预热、限流降级来缓解；分批预热会提前把热点数据分页加载到 Redis，并控制速率，避免预热过程压垮 MySQL。单个热点 Key 过期可以用互斥锁或逻辑过期，只让一个线程重建缓存，其他请求等待、重试或返回旧数据。秒杀库存这种热点场景会提前预热到 Redis，用 Redis 原子扣减加 MQ 异步落库，避免请求集中回源 MySQL。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;8. RAG 检索准确性&lt;/h3&gt;
&lt;p&gt;RAG 检索准确性重点不是只讲“用户问题 -&amp;gt; 检索 -&amp;gt; 拼 Prompt -&amp;gt; 生成答案”的流程，而是要说明怎么让检索结果更相关、更可靠。&lt;/p&gt;
&lt;p&gt;可以从数据质量、文档切分、元数据过滤、查询改写、混合检索、重排、阈值控制和离线评估几个方面回答。&lt;/p&gt;
&lt;h4&gt;数据质量&lt;/h4&gt;
&lt;p&gt;知识库入库前要先做清洗、去重和去噪。比如去掉重复文档、无效 HTML、导航栏、广告、乱码内容，保留真正有业务价值的信息。&lt;/p&gt;
&lt;p&gt;如果知识库内容本身质量很差，后面的向量检索和大模型生成都很难稳定。&lt;/p&gt;
&lt;h4&gt;文档切分&lt;/h4&gt;
&lt;p&gt;文档切分要尽量按标题、段落、语义边界切分，避免把一个完整语义拆散。&lt;/p&gt;
&lt;p&gt;Chunk 太大时，检索结果可能包含很多无关内容；Chunk 太小时，又容易丢失上下文。实际项目里一般会结合 chunk size、overlap 和文档结构来调整。&lt;/p&gt;
&lt;p&gt;比如商品知识库可以按商品、规格、售后规则、活动规则切分；技术文档可以按标题、章节、段落切分。&lt;/p&gt;
&lt;h4&gt;Metadata 过滤&lt;/h4&gt;
&lt;p&gt;入库时要保留标题、来源、业务类型、商品 ID、时间、权限、租户等 metadata。检索前可以先根据 metadata 做过滤，再做向量召回。&lt;/p&gt;
&lt;p&gt;比如用户问某个商品的售后政策，可以先过滤到对应商品、售后文档、有效时间范围内的内容，再进行相似度检索。&lt;/p&gt;
&lt;p&gt;这样可以减少无关文档进入候选集。&lt;/p&gt;
&lt;h4&gt;Query 改写&lt;/h4&gt;
&lt;p&gt;用户问题可能比较口语化，也可能缺少关键词。可以对 Query 做关键词提取、同义词扩展、多路查询或结合历史上下文改写。&lt;/p&gt;
&lt;p&gt;比如用户问“这个能不能退”，可以结合上下文改写成“某商品是否支持退货、退款、售后政策”。&lt;/p&gt;
&lt;p&gt;Query 改写的目标是提高召回率，让检索系统更容易找到相关资料。&lt;/p&gt;
&lt;h4&gt;混合检索&lt;/h4&gt;
&lt;p&gt;只用向量检索时，语义相似效果比较好，但可能漏掉精确关键词、编号、商品名、错误码、专业术语。只用关键词检索时，精确匹配强，但语义泛化能力弱。&lt;/p&gt;
&lt;p&gt;所以可以使用混合检索：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;向量检索：负责语义相似召回
关键词检索：负责精确关键词召回
混合排序：合并两路候选结果
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;常见组合是向量检索加 BM25。这样既能召回语义相近的内容，也能保留精确匹配能力。&lt;/p&gt;
&lt;h4&gt;Rerank 重排&lt;/h4&gt;
&lt;p&gt;第一阶段检索通常会召回 TopK 候选片段，比如 Top20 或 Top50。召回后可以用 rerank 模型对候选片段重新排序，把和问题最相关的内容排到前面。&lt;/p&gt;
&lt;p&gt;Rerank 的作用是提高前几条结果的相关性，因为最终塞进 Prompt 的内容数量有限，前几条质量会直接影响回答质量。&lt;/p&gt;
&lt;h4&gt;阈值控制&lt;/h4&gt;
&lt;p&gt;检索结果需要设置相似度阈值或相关性阈值。如果最高分结果都很低，说明知识库里可能没有可靠答案。&lt;/p&gt;
&lt;p&gt;这种情况下不应该强行把低相关内容塞给模型，否则模型容易根据弱相关材料编答案。更合理的做法是返回“知识库中没有找到明确依据”，或者让模型基于无资料状态拒答。&lt;/p&gt;
&lt;h4&gt;离线评估&lt;/h4&gt;
&lt;p&gt;RAG 不能只靠感觉调参，需要准备一批测试问题和标准答案，评估检索效果。&lt;/p&gt;
&lt;p&gt;常见指标包括：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Recall@K：正确资料有没有出现在前 K 个召回结果里
MRR：正确资料排在越前面分数越高
Hit Rate：问题是否命中了有效资料
人工评估：判断检索片段是否真的能支撑答案
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;通过这些指标可以持续调整 chunk 大小、overlap、TopK、阈值、metadata 过滤规则、混合检索权重和 rerank 策略。&lt;/p&gt;
&lt;p&gt;面试时可以总结为：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;我会从知识库质量、文档切分、召回策略、重排和评估几个方面保证 RAG 检索准确性。数据入库前先清洗、去重，并保留标题、来源、业务标签等 metadata；切分时按语义边界切 chunk，避免上下文丢失；检索时可以用向量检索结合关键词检索，提高语义召回和精确匹配能力；召回后再用 rerank 模型重排，把最相关的片段放到前面；最后设置相似度阈值，低于阈值就认为没有可靠资料，并通过离线问题集持续评估 Recall@K、MRR 等指标。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;9. RAG 无答案时的幻觉控制&lt;/h3&gt;
&lt;p&gt;如果知识库里面没有准确答案，核心目标是让模型识别“无可靠依据”，并且明确拒答或说明资料不足，避免根据常识和猜测补全答案。&lt;/p&gt;
&lt;h4&gt;检索阈值&lt;/h4&gt;
&lt;p&gt;检索阶段要设置相似度阈值或 rerank 分数阈值。如果召回结果低于阈值，说明知识库里没有足够相关的资料，就不把这些低相关片段强行塞给模型。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;召回结果分数 &amp;gt;= 阈值：进入生成阶段
召回结果分数 &amp;lt; 阈值：判断为无可靠资料
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样可以减少模型基于弱相关内容进行发挥。&lt;/p&gt;
&lt;h4&gt;TopK 结果校验&lt;/h4&gt;
&lt;p&gt;不能只看有没有召回结果，还要判断 TopK 结果是否真的覆盖用户问题。&lt;/p&gt;
&lt;p&gt;比如用户问“某商品是否支持 7 天无理由退货”，检索结果只命中了商品介绍，没有命中售后规则，这种情况下也应该认为资料不足。&lt;/p&gt;
&lt;p&gt;可以让系统在生成前先判断：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;检索结果是否与问题相关
检索结果是否包含回答所需的关键信息
检索结果之间是否互相矛盾
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果结果不相关、信息不完整或互相冲突，就不要输出确定性结论。&lt;/p&gt;
&lt;h4&gt;Prompt 约束&lt;/h4&gt;
&lt;p&gt;生成阶段要在 Prompt 里明确约束模型只能基于检索资料回答。&lt;/p&gt;
&lt;p&gt;可以加入类似规则：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;只能使用给定资料回答问题。
如果资料中没有答案，回复“知识库中没有找到相关信息”。
不要根据常识、经验或猜测补充资料中没有出现的内容。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这类约束可以降低模型自由发挥的概率。&lt;/p&gt;
&lt;h4&gt;引用约束&lt;/h4&gt;
&lt;p&gt;答案最好绑定引用来源，比如文档标题、文档 ID、段落编号或来源链接。模型输出结论时，需要能对应到具体检索片段。&lt;/p&gt;
&lt;p&gt;如果一个结论没有来源支撑，就不能作为确定答案输出。&lt;/p&gt;
&lt;p&gt;这种方式可以让回答从“模型自己觉得对”变成“能在知识库里找到依据”。&lt;/p&gt;
&lt;h4&gt;结构化输出&lt;/h4&gt;
&lt;p&gt;可以让模型先输出一个结构化判断，再决定是否回答。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;has_answer: true / false
evidence: 命中的资料片段
answer: 最终回答
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果 &lt;code&gt;has_answer=false&lt;/code&gt;，就直接返回资料不足的说明，不进入正常回答。&lt;/p&gt;
&lt;p&gt;这种方式可以把“是否有依据”和“生成答案”拆开，减少无依据生成。&lt;/p&gt;
&lt;h4&gt;评估监控&lt;/h4&gt;
&lt;p&gt;线上需要记录无答案问题、低置信度问题、用户负反馈和人工标注结果。后续可以把这些问题用来补充知识库、优化切分策略、调整阈值和改进 Query 改写。&lt;/p&gt;
&lt;p&gt;RAG 的幻觉控制不是只靠 Prompt，一般需要检索阈值、证据约束和评估反馈一起做。&lt;/p&gt;
&lt;p&gt;面试时可以总结为：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;如果知识库里没有准确答案，我不会让模型强行回答。检索阶段会设置相似度阈值和 rerank 阈值，低于阈值就判断为无可靠资料；生成前会检查 TopK 结果是否真的覆盖问题；Prompt 中会约束模型只能基于检索资料回答，没有依据就说明知识库中没有找到相关信息；答案需要带引用来源，没有证据支撑的结论不能输出。还可以让模型先输出 has_answer 判断，再决定是否生成答案，并记录无答案问题用于后续补充知识库和优化检索。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>消息队列：使用场景、流程与常见问题</title><link>https://blog.huangnv.online/posts/26626/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26626/1/1/</guid><pubDate>Fri, 26 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;消息队列：使用场景、流程与常见问题&lt;/h1&gt;
&lt;p&gt;消息队列（Message Queue，MQ）通常用于系统之间的异步通信。它把生产者和消费者隔开，让生产者只负责发送业务事件，消费者按自己的节奏处理后续逻辑。理解消息队列时，可以先从使用场景入手，再看消息从生产、存储到消费的完整链路，最后分析重复消费、事务消息和顺序性这些常见问题。&lt;/p&gt;
&lt;h2&gt;消息队列的使用场景&lt;/h2&gt;
&lt;h3&gt;解耦&lt;/h3&gt;
&lt;p&gt;在复杂业务系统中，一个操作往往会牵动多个下游模块。比如用户下单后，系统可能需要扣库存、生成订单、发优惠券、通知物流、发送短信。如果下单服务直接调用所有下游接口，任何一个服务响应慢或故障，都会影响主流程。&lt;/p&gt;
&lt;p&gt;引入消息队列后，下单服务只需要把「订单创建成功」这类事件发送到 MQ，下游系统各自订阅并处理。这样可以降低系统之间的直接依赖。后续新增业务逻辑时，也可以通过新增消费者完成扩展，核心下单代码不需要频繁修改。&lt;/p&gt;
&lt;h3&gt;异步&lt;/h3&gt;
&lt;p&gt;有些业务操作不需要在用户请求中立即完成，比如发送短信、发送邮件、生成报表、同步搜索索引等。如果这些操作都放在主请求链路中执行，用户需要等待所有任务完成后才能得到响应，接口耗时会明显变长。&lt;/p&gt;
&lt;p&gt;使用消息队列后，主流程只负责完成核心业务，然后把后续任务投递到 MQ 中，由消费者异步处理。这样可以缩短用户等待时间，提高接口响应速度，也能把耗时任务从核心链路中拆出来，便于独立扩容和监控。&lt;/p&gt;
&lt;h3&gt;削峰&lt;/h3&gt;
&lt;p&gt;在秒杀、抢票、促销活动等高并发场景中，流量可能在短时间内突然暴涨。如果所有请求都直接打到数据库或核心服务，系统很容易因为瞬时压力过大而崩溃。&lt;/p&gt;
&lt;p&gt;消息队列可以作为缓冲层，把突发请求先写入队列，再由消费者按照系统可承受的速度逐步处理。这样可以把瞬时高峰流量摊平，保护后端数据库和服务。削峰的核心价值在于用队列承接流量洪峰，让系统处理节奏更加稳定。&lt;/p&gt;
&lt;h3&gt;可靠性&lt;/h3&gt;
&lt;p&gt;在分布式系统中，服务调用失败、网络抖动、消费者宕机都很常见。如果业务只依赖一次同步调用，失败后数据可能丢失，流程也难以恢复。&lt;/p&gt;
&lt;p&gt;消息队列通常提供持久化、确认机制、重试机制和死信队列等能力，可以提高消息处理的可靠性。生产者发送消息后，MQ 负责保存消息；消费者处理成功后再返回确认。处理失败时，消息可以重试或进入死信队列等待排查，适合订单、支付、库存等对数据一致性要求较高的场景。&lt;/p&gt;
&lt;h2&gt;消息队列的基本流程&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;./assets/%E6%B6%88%E8%B4%B9%E9%98%9F%E5%88%97/file-20260625201007963.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;第一阶段：消息生产&lt;/h3&gt;
&lt;p&gt;消息生产阶段就是生产者把业务消息发布给 MQ。比如订单服务创建订单成功后，会把「订单已创建」这条消息发送给 MQ；MQ 收到消息并保存成功后，会给生产者返回 &lt;code&gt;ACK&lt;/code&gt;。生产者收到 &lt;code&gt;ACK&lt;/code&gt; 后，就知道这条消息已经投递成功，可以继续执行后续逻辑。&lt;/p&gt;
&lt;p&gt;如果生产者没有收到 &lt;code&gt;ACK&lt;/code&gt;，通常会认为这次发送可能失败，于是会再次发送同一条消息。这个阶段有一个典型边界：消息可能已经到达 MQ，只是 MQ 返回 &lt;code&gt;ACK&lt;/code&gt; 时发生了网络抖动，导致生产者没有收到确认。生产者重试后，MQ 或消费者就可能看到重复消息。因此生产端要设计合理的重试策略，消费端也要具备处理重复消息的能力。&lt;/p&gt;
&lt;h3&gt;第二阶段：消息存储&lt;/h3&gt;
&lt;p&gt;消息存储阶段就是 MQ 收到生产者发来的消息后，把消息保存到自己的存储结构里，等待消费者后续消费。不同 MQ 的实现细节不同，但常见思路是先把消息写入内存中的队列、缓存或日志缓冲区，再根据刷盘策略写入磁盘。&lt;/p&gt;
&lt;p&gt;这个过程可以类比 Redis 的持久化：写入通常先进入内存，然后再通过 RDB、AOF 这类机制落到磁盘。MQ 也会在吞吐量和可靠性之间做权衡。有些配置会采用异步刷盘，写入性能更好，但如果 MQ 返回 &lt;code&gt;ACK&lt;/code&gt; 后还没来得及把消息刷到磁盘，此时服务突然宕机，内存中未持久化的消息就可能丢失。因此存储阶段需要关注持久化配置、刷盘策略和主从复制能力。&lt;/p&gt;
&lt;h3&gt;第三阶段：消息消费&lt;/h3&gt;
&lt;p&gt;消息消费阶段就是消费者从 MQ 中拉取消息并执行业务逻辑。比如库存服务从 MQ 中拿到「订单已创建」消息后，开始扣减库存；业务处理成功后，消费者会向 MQ 返回 &lt;code&gt;ACK&lt;/code&gt;。MQ 收到确认后，才会认为这条消息已经被成功消费。&lt;/p&gt;
&lt;p&gt;如果消费者处理失败，或者处理过程中宕机，没有给 MQ 返回 &lt;code&gt;ACK&lt;/code&gt;，MQ 通常会重新投递这条消息。还有一种常见情况是消费者业务已经处理成功，但返回 &lt;code&gt;ACK&lt;/code&gt; 时网络抖动，MQ 没收到确认，就会再次投递同一条消息。因此消费阶段必须考虑幂等性，比如用订单号做唯一判断，避免同一条消息重复扣库存、重复发券或重复发送通知。&lt;/p&gt;
&lt;h2&gt;消息队列的常见问题&lt;/h2&gt;
&lt;h3&gt;重复生产或重复消费&lt;/h3&gt;
&lt;p&gt;网络抖动、服务宕机、&lt;code&gt;ACK&lt;/code&gt; 丢失等情况都可能导致重复生产或重复消费。比如生产者已经把消息发送到 MQ，但没有收到 MQ 返回的 &lt;code&gt;ACK&lt;/code&gt;，生产者就可能再次发送同一条消息；消费者已经执行业务逻辑，但返回 &lt;code&gt;ACK&lt;/code&gt; 时失败，MQ 也可能再次投递同一条消息。&lt;/p&gt;
&lt;p&gt;在消息队列场景中，需要通过幂等性设计保证同一条消息被处理多次时，业务结果仍然只生效一次。&lt;/p&gt;
&lt;h4&gt;生产者端&lt;/h4&gt;
&lt;p&gt;有些 MQ 支持生产者生成唯一消息 ID，MQ 可以根据这个 ID 做内部去重，减少生产者重复发送带来的影响。不过这种能力主要覆盖「生产者到 MQ」这一层，只能帮助 MQ 判断是否接收过同一条消息。&lt;/p&gt;
&lt;p&gt;消息进入 MQ 之后，消费者仍然可能因为消费失败、&lt;code&gt;ACK&lt;/code&gt; 丢失或重试机制收到重复消息，所以实际业务中通常还要在消费者端做幂等控制。&lt;/p&gt;
&lt;h4&gt;消费者端&lt;/h4&gt;
&lt;p&gt;消费者端是否需要做幂等，先看处理逻辑是否会产生业务副作用。查询、校验、打印日志、读取数据这类操作通常不会改变业务状态，重复执行影响较小。写数据库、扣库存、发优惠券、发通知这类操作会改变业务状态，需要重点考虑重复消费后的结果。&lt;/p&gt;
&lt;p&gt;对于插入类操作，可以让生产者在消息中携带业务唯一 ID，例如订单 ID、支付流水号、消息 ID 等。消费者落库时基于这个字段创建唯一索引，第一次插入成功，后续重复消息再次插入时会触发唯一约束。这样可以利用数据库约束拦住重复数据，也可以在捕获唯一键冲突后直接认为消息已经处理过。&lt;/p&gt;
&lt;p&gt;对于更新类操作，可以使用版本号或状态条件控制更新。比如消息中携带当前版本号，消费者更新时带上条件：只有数据库中的版本号等于消息中的版本号，才执行更新并把版本号加 1。重复消息再次执行时，版本号已经变化，更新影响行数为 0，就说明这条消息已经处理过。类似地，订单状态也可以作为条件，例如只允许从「待支付」更新为「已支付」，避免重复消息反复修改同一条记录。&lt;/p&gt;
&lt;h3&gt;原子性&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;./assets/%E6%B6%88%E8%B4%B9%E9%98%9F%E5%88%97/file-20260625212445114.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;普通 MQ 消息的流程一般是：生产者生成消息并发送给 MQ，MQ 收到后把消息持久化到存储系统，然后返回 &lt;code&gt;ACK&lt;/code&gt; 给生产者。之后 MQ 把消息推送给消费者，消费者处理成功后再返回 &lt;code&gt;ACK&lt;/code&gt;，MQ 收到消费确认后删除消息。&lt;/p&gt;
&lt;p&gt;这个流程可以保证消息从生产到消费有完整的确认链路，但它主要解决的是消息投递过程的可靠性问题，没有覆盖「本地业务操作」和「发送消息」之间的原子性。以下单场景为例，订单系统先创建订单，再发送消息通知库存、优惠券、物流等下游系统。如果订单创建成功，但发送 MQ 消息失败，下游系统就感知不到这次下单，最终会出现订单数据已经存在、下游业务没有执行的数据不一致问题。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/%E6%B6%88%E8%B4%B9%E9%98%9F%E5%88%97/file-20260625212458015.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;事务消息用于处理「本地事务」和「消息发送」之间的一致性问题。生产者先向 MQ 发送一条半事务消息，也可以理解为待确认消息；MQ 收到后先持久化这条消息，但暂时不会把它投递给消费者。MQ 返回 &lt;code&gt;ACK&lt;/code&gt; 后，生产者开始执行本地事务，比如创建订单。&lt;/p&gt;
&lt;p&gt;订单创建成功后，生产者向 MQ 发送 &lt;code&gt;commit&lt;/code&gt;，MQ 将这条消息标记为可投递状态，再推送给消费者。订单创建失败时，生产者向 MQ 发送 &lt;code&gt;rollback&lt;/code&gt;，MQ 删除这条消息，消费者不会看到它。如果生产者因为宕机或网络问题没有及时返回 &lt;code&gt;commit&lt;/code&gt; 或 &lt;code&gt;rollback&lt;/code&gt;，MQ 会反查生产者，询问本地事务最终状态，再决定提交消息或删除消息。&lt;/p&gt;
&lt;p&gt;事务消息把本地事务结果和消息可见性绑定起来，可以减少「本地业务成功但下游收不到消息」的问题。它主要保证生产者本地事务到消息投递之间的一致性。MQ 到消费者这一段链路仍然依赖 &lt;code&gt;ACK&lt;/code&gt;、重试和消费者幂等来保证最终结果。如果消费者多次重试后仍然失败，消息通常会进入死信队列，再通过告警和人工处理兜底。&lt;/p&gt;
&lt;h3&gt;顺序性&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;./assets/%E6%B6%88%E8%B4%B9%E9%98%9F%E5%88%97/file-20260625221024050.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;在订单业务中，同一个订单会产生多条相关消息，例如创建订单、扣减库存、增加积分。这些操作之间存在明确的业务依赖关系，消费者需要按照业务顺序处理。如果先加积分，再扣库存，甚至订单还没有创建就开始处理后续逻辑，就会导致业务状态异常。&lt;/p&gt;
&lt;p&gt;要保证同一个订单下的消息顺序，生产者发送消息时需要按照同一个业务键做固定路由。例如使用 &lt;code&gt;orderId&lt;/code&gt; 作为路由键，让同一个订单的所有消息都进入同一个队列。单个队列内部通常具备 FIFO 特性，再配合单线程消费或有序消费机制，就能让同一个订单下的消息按照发送顺序依次处理。&lt;/p&gt;
&lt;p&gt;顺序性通常有范围限制。它更常见的目标是保证「同一个订单」或「同一个用户」维度下有序。全局有序会明显降低吞吐量，也会削弱 MQ 本身的并发处理能力。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;消息队列的核心价值是把生产者和消费者解耦，用异步处理提升响应速度，用队列缓冲削平流量高峰，并通过确认、重试、持久化和死信队列提高可靠性。&lt;/p&gt;
&lt;p&gt;真正落地时，需要重点关注三个问题：重复消息要靠幂等性兜住，生产者本地事务和消息发送之间要靠事务消息或类似机制保证一致性，同一业务对象内有顺序要求的消息要通过固定路由和有序消费来处理。理解这些边界后，才能把 MQ 从「会用」推进到「用得可靠」。&lt;/p&gt;
</content:encoded></item><item><title>MySQL InnoDB 行记录结构详解</title><link>https://blog.huangnv.online/posts/26625/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26625/1/1/</guid><pubDate>Thu, 25 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;在看一条行记录的内部结构之前，先把 InnoDB 的逻辑存储层级放在一起看。一个表的数据最终会落到表空间中，表空间下面还会继续划分为段、区、页和行。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql%E5%85%AB%E8%82%A1------%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/file-20260625153233624.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这篇文章的重点是行记录格式，所以这里只展开和后文直接相关的两个层级：&lt;strong&gt;行（Row）&lt;strong&gt;和&lt;/strong&gt;页（Page）&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;行（Row）&lt;/h3&gt;
&lt;p&gt;数据库表中的记录都是按行存储的，每一行记录会按照当前表使用的行格式组织起来。不同的行格式会影响一条记录内部如何保存字段值、NULL 标记、隐藏字段等信息。后面要重点看的 &lt;code&gt;COMPACT&lt;/code&gt; 行格式，就是在解释一条行记录内部到底长什么样。&lt;/p&gt;
&lt;h3&gt;页（Page）&lt;/h3&gt;
&lt;p&gt;虽然表里的数据是一行一行保存的，但 InnoDB 读写数据时并不以单行作为磁盘 I/O 单位。InnoDB 默认以页为单位管理数据，常见的数据页大小是 16KB。&lt;/p&gt;
&lt;p&gt;读取某一条记录时，InnoDB 会先把这条记录所在的数据页读入内存；写入或修改数据时，也会围绕页来管理内存中的数据和后续刷盘过程。也就是说，行记录实际存放在数据页里面，页是理解行记录存储位置的上一级结构。&lt;/p&gt;
&lt;p&gt;在 InnoDB 的 B+ 树索引结构中，一般也可以把一个节点理解成一个页：叶子节点页里存放行记录，非叶子节点页里存放索引键和页指针。&lt;/p&gt;
&lt;h1&gt;行&lt;/h1&gt;
&lt;p&gt;先假设有这样一张 &lt;code&gt;t_user&lt;/code&gt; 表：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE TABLE `t_user` (
  `id` INT(11) NOT NULL,
  `name` VARCHAR(20) DEFAULT NULL,
  `phone` VARCHAR(20) DEFAULT NULL,
  `age` INT(11) DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=ascii ROW_FORMAT=COMPACT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;现在 &lt;code&gt;t_user&lt;/code&gt; 表中有三条记录：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql%E5%85%AB%E8%82%A1------%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/file-20260625143833198.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;一条 InnoDB 行记录可以先拆成三个部分来看：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;记录头信息&lt;/li&gt;
&lt;li&gt;MVCC 字段&lt;/li&gt;
&lt;li&gt;用户真实数据字段&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;flowchart LR
    subgraph Header[&quot;记录头信息&quot;]
        V[&quot;变长字段长度列表&quot;]
        N[&quot;NULL 值列表&quot;]
        H[&quot;记录头信息&amp;lt;br/&amp;gt;5 字节&quot;]
    end

    subgraph MVCC[&quot;MVCC 字段&quot;]
        R[&quot;row_id&amp;lt;br/&amp;gt;6 字节&quot;]
        T[&quot;trx_id&amp;lt;br/&amp;gt;6 字节&quot;]
        P[&quot;roll_ptr&amp;lt;br/&amp;gt;7 字节&quot;]
    end

    subgraph Data[&quot;用户真实数据字段&quot;]
        C1[&quot;第 1 列值&quot;]
        C2[&quot;第 2 列值&quot;]
        CN[&quot;第 N 列值&quot;]
    end

    V --&amp;gt; N --&amp;gt; H --&amp;gt; R --&amp;gt; T --&amp;gt; P --&amp;gt; C1 --&amp;gt; C2 --&amp;gt; CN

    style V fill:#2f6bd8,color:#fff,stroke:#2456aa
    style N fill:#7a3fc7,color:#fff,stroke:#6533a6
    style H fill:#e87932,color:#fff,stroke:#c86424
    style R fill:#d9d9d9,color:#222,stroke:#999
    style T fill:#d9d9d9,color:#222,stroke:#999
    style P fill:#d9d9d9,color:#222,stroke:#999
    style C1 fill:#63a03b,color:#fff,stroke:#4f842f
    style C2 fill:#63a03b,color:#fff,stroke:#4f842f
    style CN fill:#63a03b,color:#fff,stroke:#4f842f
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;记录头信息&lt;/h2&gt;
&lt;p&gt;记录头信息里先看三个部分：&lt;strong&gt;变长字段长度列表&lt;/strong&gt;、&lt;strong&gt;NULL 值列表&lt;/strong&gt;和&lt;strong&gt;固定 5 字节的记录头信息&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;变长字段长度列表&lt;/strong&gt;：记录变长字段实际占用的字节数。当前表里，&lt;code&gt;name&lt;/code&gt; 和 &lt;code&gt;phone&lt;/code&gt; 都是 &lt;code&gt;VARCHAR&lt;/code&gt; 类型，它们每一行占用的字节数可能不同，所以需要在行记录里额外保存长度信息。&lt;code&gt;COMPACT&lt;/code&gt; 行格式中，变长字段长度列表会按照变长字段的逆序存放，也就是先记录 &lt;code&gt;phone&lt;/code&gt;，再记录 &lt;code&gt;name&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NULL 值列表&lt;/strong&gt;：标记哪些字段为 &lt;code&gt;NULL&lt;/code&gt;。当前表里，&lt;code&gt;name&lt;/code&gt;、&lt;code&gt;phone&lt;/code&gt;、&lt;code&gt;age&lt;/code&gt; 都允许为 &lt;code&gt;NULL&lt;/code&gt;，所以至少需要 3 个 bit 来表示这三个字段的空值情况。&lt;code&gt;COMPACT&lt;/code&gt; 行格式中，NULL 值列表也会按照可空字段的逆序存放，也就是 &lt;code&gt;age -&amp;gt; phone -&amp;gt; name&lt;/code&gt;。某个字段为 &lt;code&gt;NULL&lt;/code&gt; 时，对应 bit 标记为 1；字段不为 &lt;code&gt;NULL&lt;/code&gt; 时，对应 bit 标记为 0。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;固定 5 字节的记录头信息&lt;/strong&gt;：保存当前行是否已删除、当前记录在页内的连接关系等信息。这里先知道它负责描述记录本身的状态即可，等讲到数据页结构时再展开。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对应到上面的三条记录，可以得到下面这张表：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;记录&lt;/th&gt;
&lt;th&gt;变长字段长度列表（逆序：&lt;code&gt;phone -&amp;gt; name&lt;/code&gt;）&lt;/th&gt;
&lt;th&gt;NULL 值列表（逆序：&lt;code&gt;age -&amp;gt; phone -&amp;gt; name&lt;/code&gt;）&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id = 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;phone = 3&lt;/code&gt;，&lt;code&gt;name = 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;age = 0&lt;/code&gt;，&lt;code&gt;phone = 0&lt;/code&gt;，&lt;code&gt;name = 0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id = 2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;phone = 4&lt;/code&gt;，&lt;code&gt;name = 2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;age = 1&lt;/code&gt;，&lt;code&gt;phone = 0&lt;/code&gt;，&lt;code&gt;name = 0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id = 3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;phone&lt;/code&gt; 为 &lt;code&gt;NULL&lt;/code&gt;，不记录长度；&lt;code&gt;name = 3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;age = 1&lt;/code&gt;，&lt;code&gt;phone = 1&lt;/code&gt;，&lt;code&gt;name = 0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这两个列表采用逆序存储，是为了配合行记录的解析过程。&lt;code&gt;COMPACT&lt;/code&gt; 行格式中，用户真实数据字段按列定义顺序存放，但一行记录本身是变长的。要定位后面的字段，就需要先知道前面字段是否为 &lt;code&gt;NULL&lt;/code&gt;，以及前面的变长字段实际占用了多少字节。&lt;/p&gt;
&lt;p&gt;以 &lt;code&gt;age&lt;/code&gt; 字段为例，真实数据字段的顺序是 &lt;code&gt;id -&amp;gt; name -&amp;gt; phone -&amp;gt; age&lt;/code&gt;。&lt;code&gt;id&lt;/code&gt; 是固定长度字段，&lt;code&gt;name&lt;/code&gt; 和 &lt;code&gt;phone&lt;/code&gt; 是变长字段，&lt;code&gt;age&lt;/code&gt; 前面实际占用了多少空间，需要结合 NULL 值列表和变长字段长度列表才能推出来。逆序存储可以让这些额外信息集中放在记录头附近，解析行记录时先读取这些长度和空值标记，再计算目标字段在真实数据区域中的位置。&lt;/p&gt;
&lt;h2&gt;MVCC 字段&lt;/h2&gt;
&lt;p&gt;MVCC 字段里先看三个字段：&lt;strong&gt;&lt;code&gt;row_id&lt;/code&gt;&lt;/strong&gt;、&lt;strong&gt;&lt;code&gt;trx_id&lt;/code&gt;&lt;/strong&gt; 和 &lt;strong&gt;&lt;code&gt;roll_ptr&lt;/code&gt;&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;row_id&lt;/code&gt;&lt;/strong&gt;：隐藏的行 ID，占用 6 字节。如果建表时没有指定主键，也没有不允许为 &lt;code&gt;NULL&lt;/code&gt; 的唯一索引，InnoDB 会自动为每条记录生成 &lt;code&gt;row_id&lt;/code&gt;。当前这张 &lt;code&gt;t_user&lt;/code&gt; 表已经指定了主键 &lt;code&gt;id&lt;/code&gt;，所以实际不会额外生成 &lt;code&gt;row_id&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;trx_id&lt;/code&gt;&lt;/strong&gt;：事务 ID，占用 6 字节，用来记录当前这条记录是由哪个事务生成的。这个字段是 MVCC 必需的字段。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;roll_ptr&lt;/code&gt;&lt;/strong&gt;：回滚指针，占用 7 字节，用来指向 undo log 中的上一个版本。这个字段也是 MVCC 必需的字段。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;简单来说，&lt;code&gt;trx_id&lt;/code&gt; 用来标记当前版本属于哪个事务，&lt;code&gt;roll_ptr&lt;/code&gt; 用来把当前版本和历史版本串起来。通过这两个字段，InnoDB 才能在一致性读时找到一条记录的历史版本。&lt;/p&gt;
&lt;h2&gt;用户真实数据字段&lt;/h2&gt;
&lt;p&gt;用户真实数据字段保存的是表中定义的列值。对于这张 &lt;code&gt;t_user&lt;/code&gt; 表来说，就是 &lt;code&gt;id&lt;/code&gt;、&lt;code&gt;name&lt;/code&gt;、&lt;code&gt;phone&lt;/code&gt;、&lt;code&gt;age&lt;/code&gt; 这几列真正写入的数据。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;按列顺序存放&lt;/strong&gt;：真实数据字段按照建表时的列定义顺序存放，也就是 &lt;code&gt;id -&amp;gt; name -&amp;gt; phone -&amp;gt; age&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;固定长度字段直接存值&lt;/strong&gt;：&lt;code&gt;id&lt;/code&gt; 和 &lt;code&gt;age&lt;/code&gt; 是 &lt;code&gt;INT&lt;/code&gt; 类型，每个值占用 4 字节。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;变长字段只存实际内容&lt;/strong&gt;：&lt;code&gt;name&lt;/code&gt; 和 &lt;code&gt;phone&lt;/code&gt; 是 &lt;code&gt;VARCHAR&lt;/code&gt; 类型，真实数据区域只保存具体字符串内容，长度由前面的变长字段长度列表提供。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NULL 字段不存实际值&lt;/strong&gt;：如果某个字段为 &lt;code&gt;NULL&lt;/code&gt;，真实数据区域不会为它保存具体内容，只通过前面的 NULL 值列表标记出来。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对应到当前三条记录，真实数据字段可以这样看：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;记录&lt;/th&gt;
&lt;th&gt;真实数据字段的存放内容&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id = 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;id = 1&lt;/code&gt;，&lt;code&gt;name = &apos;a&apos;&lt;/code&gt;，&lt;code&gt;phone = &apos;123&apos;&lt;/code&gt;，&lt;code&gt;age = 18&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id = 2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;id = 2&lt;/code&gt;，&lt;code&gt;name = &apos;bb&apos;&lt;/code&gt;，&lt;code&gt;phone = &apos;1234&apos;&lt;/code&gt;；&lt;code&gt;age&lt;/code&gt; 为 &lt;code&gt;NULL&lt;/code&gt;，真实数据区域不存具体值&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id = 3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;id = 3&lt;/code&gt;，&lt;code&gt;name = &apos;ccc&apos;&lt;/code&gt;；&lt;code&gt;phone&lt;/code&gt; 和 &lt;code&gt;age&lt;/code&gt; 都为 &lt;code&gt;NULL&lt;/code&gt;，真实数据区域不存具体值&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;所以，真实数据字段只负责保存用户真正写入的列值。至于每个字段从哪里开始、占多少字节，需要结合前面的变长字段长度列表和 NULL 值列表一起解析。&lt;/p&gt;
&lt;h1&gt;页&lt;/h1&gt;
&lt;p&gt;一个数据页默认大小是 16KB，但这 16KB 里并不只存用户行记录。为了管理页本身的状态、记录的位置以及页之间的连接关系，数据页内部还会划分出多个区域：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql%E5%85%AB%E8%82%A1------%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/file-20260625154241660.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这 7 个部分的作用可以先按下表记：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql%E5%85%AB%E8%82%A1------%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/file-20260625154248802.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这里先重点关注两个区域：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;User Records&lt;/strong&gt;：真正存放用户行记录的地方，也就是前面讲的行记录最终落到页里的位置。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Page Directory&lt;/strong&gt;：保存用户记录的相对位置，对页内记录起到索引作用，避免每次都从头遍历整页记录。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;另外，&lt;code&gt;File Header&lt;/code&gt; 中会保存上一个数据页和下一个数据页的指针。多个数据页可以通过这些指针形成逻辑上的双向链表：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql%E5%85%AB%E8%82%A1------%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/file-20260625154257742.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;因此，数据页之间不要求在磁盘上物理连续，但可以通过链表在逻辑上连接起来。范围查询或顺序扫描时，InnoDB 就可以沿着这些页之间的逻辑关系继续向后读取。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql%E5%85%AB%E8%82%A1------%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/file-20260625155646620.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;从这张图可以看到，页里的 &lt;code&gt;User Records&lt;/code&gt; 区域由一条条行记录组成。前面讲行格式时提到的“记录头信息”，在页内组织记录时就会发挥作用。&lt;/p&gt;
&lt;p&gt;可以分成几个点来看：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;行记录之间通过 &lt;code&gt;next_record&lt;/code&gt; 串起来&lt;/strong&gt;：每条记录的记录头信息中都有 &lt;code&gt;next_record&lt;/code&gt;，用来指向页内下一条记录。这样，页内记录就可以按照索引顺序形成一条单向链表。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;最小记录和最大记录是虚拟记录&lt;/strong&gt;：页里会固定存在两条虚拟记录，分别是 &lt;code&gt;Infimum&lt;/code&gt; 和 &lt;code&gt;Supremum&lt;/code&gt;。&lt;code&gt;Infimum&lt;/code&gt; 表示页内记录链表的起点，&lt;code&gt;Supremum&lt;/code&gt; 表示页内记录链表的终点。它们不存用户数据，主要用来稳定页内链表的边界，避免插入、删除记录时频繁处理“头节点为空”或“尾节点变化”这类特殊情况。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;页内记录会被划分成多个组&lt;/strong&gt;：InnoDB 不会只靠单向链表从头遍历整页，而是会把页内记录分成若干个组。普通分组一般保持在 4 到 8 条记录之间，超过范围时会重新调整分组。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;每组的最大记录会保存 &lt;code&gt;n_owned&lt;/code&gt;&lt;/strong&gt;：一个组里索引值最大的那条记录，会在记录头信息中记录当前组拥有多少条记录，也就是 &lt;code&gt;n_owned&lt;/code&gt;。其他普通记录的 &lt;code&gt;n_owned&lt;/code&gt; 通常为 0。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;页目录记录每组最大记录的位置&lt;/strong&gt;：&lt;code&gt;Page Directory&lt;/code&gt; 中的每个槽（slot）会保存一个组内最大记录的相对地址。这样查找记录时，可以先在页目录里定位到目标可能属于哪个组，再进入组内沿着记录链表继续查找。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以，页目录并不是直接记录每一行的位置，而是记录每个分组里最大记录的位置。查找时先通过页目录缩小范围，再在组内遍历少量记录，这样就避免了每次都从页内第一条记录开始扫完整个数据页。&lt;/p&gt;
&lt;p&gt;页内查找记录时，会先利用页目录做二分定位。比如要修改索引值为 &lt;code&gt;9&lt;/code&gt; 的记录，可以先按下面的步骤找到它：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;页目录本身是有序槽数组&lt;/strong&gt;：页目录里的槽按照记录大小顺序排列，每个槽都保存一个分组中最大记录的地址。查找时不需要先遍历所有槽，而是可以直接在槽数组上做二分。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;先取中间槽位比较&lt;/strong&gt;：图中有 5 个槽，可以先取中间的槽 2。槽 2 里保存的是一个记录地址，通过这个地址可以找到它指向的最大记录，最大值是 &lt;code&gt;8&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;根据比较结果缩小范围&lt;/strong&gt;：目标值是 &lt;code&gt;9&lt;/code&gt;，因为 &lt;code&gt;9 &amp;gt; 8&lt;/code&gt;，所以目标不在槽 2 及其左边对应的范围里。此时二分查找会把左边界移动到槽 2 右侧，只在右半部分继续查找。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;继续在右半部分二分&lt;/strong&gt;：右半部分还剩槽 3 和槽 4，可以继续取中间槽位比较。这里比较到槽 3 指向的最大记录，最大值是 &lt;code&gt;12&lt;/code&gt;。因为 &lt;code&gt;9 &amp;lt;= 12&lt;/code&gt;，所以目标记录应该落在最大值为 &lt;code&gt;12&lt;/code&gt; 的这个分组里。这个分组的范围可以理解为 &lt;code&gt;(8, 12]&lt;/code&gt;，里面包含 &lt;code&gt;9&lt;/code&gt;、&lt;code&gt;10&lt;/code&gt;、&lt;code&gt;11&lt;/code&gt;、&lt;code&gt;12&lt;/code&gt; 这些记录。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;进入组内顺着链表查找&lt;/strong&gt;：页目录只能定位到分组，不能直接定位到组内每一条记录。确定分组后，需要从上一组最大记录 &lt;code&gt;8&lt;/code&gt; 的下一条记录开始，沿着 &lt;code&gt;next_record&lt;/code&gt; 往后找。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;找到索引值为 &lt;code&gt;9&lt;/code&gt; 的记录后再修改&lt;/strong&gt;：当链表遍历到索引值为 &lt;code&gt;9&lt;/code&gt; 的记录时，就可以对这条记录执行修改操作。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;所以，页目录的作用是先用二分法把查找范围缩小到某个组，组内再通过 &lt;code&gt;next_record&lt;/code&gt; 遍历少量记录。这样比从页内第一条用户记录开始一路遍历到目标记录更高效。&lt;/p&gt;
</content:encoded></item><item><title>即时通信系统架构：从长连接到消息投递</title><link>https://blog.huangnv.online/posts/2667/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2667/1/1/</guid><pubDate>Sun, 07 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;在线 IM 聊天系统中的网络传输协议选择&lt;/h2&gt;
&lt;p&gt;网络传输协议决定 IM 消息如何在客户端和服务端之间传递。它直接影响消息实时性、可靠性、连接稳定性、服务端连接压力、弱网表现和跨端兼容性。&lt;/p&gt;
&lt;p&gt;一套合适的网络传输方案，至少要解决下面几个问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;实时性&lt;/strong&gt;：消息、回执、在线状态能尽快到达对方；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;可靠性&lt;/strong&gt;：文本消息、系统通知、离线同步等内容不能随意丢失；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;顺序性&lt;/strong&gt;：同一个会话里的消息需要尽量按业务顺序展示；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;连接稳定性&lt;/strong&gt;：移动网络抖动、前后台切换、网络切换后能恢复通信；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;连接成本&lt;/strong&gt;：服务端需要承受大量在线用户的长连接；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;传输成本&lt;/strong&gt;：图片、文件、短视频等大对象不能挤占实时消息通道；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;兼容性&lt;/strong&gt;：浏览器、移动端、网关、负载均衡和防火墙都要能稳定支持。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果协议选择不合适，后续很容易出现这些问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;高频轮询导致服务端压力过大；&lt;/li&gt;
&lt;li&gt;长连接频繁断开，用户在线状态不准确；&lt;/li&gt;
&lt;li&gt;大文件占用实时通道，普通文本消息被阻塞；&lt;/li&gt;
&lt;li&gt;弱网环境下消息延迟明显；&lt;/li&gt;
&lt;li&gt;客户端重连逻辑复杂，消息补偿成本升高；&lt;/li&gt;
&lt;li&gt;网关和负载均衡难以维护大量在线连接。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，IM 系统选择网络传输协议时，不能只看某个协议“快不快”，还要看它是否适合消息类型、用户规模、客户端环境和运维能力。&lt;/p&gt;
&lt;h3&gt;TCP：适合可靠消息传输&lt;/h3&gt;
&lt;p&gt;TCP 是 IM 系统中最常见的基础传输协议。它适合承载文本消息、系统通知、离线消息同步、消息 ACK 等对可靠性要求较高的内容。&lt;/p&gt;
&lt;p&gt;TCP 的主要优势：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;可靠传输&lt;/strong&gt;：丢包后可以重传；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;按序到达&lt;/strong&gt;：应用层更容易处理消息顺序；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;拥塞控制&lt;/strong&gt;：可以根据网络状况调整发送速度；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;生态成熟&lt;/strong&gt;：服务端框架、网关、监控、排障工具都比较完善。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;TCP 的主要成本：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;服务端需要维护大量长连接；&lt;/li&gt;
&lt;li&gt;移动网络抖动时连接容易断开；&lt;/li&gt;
&lt;li&gt;Wi-Fi 和蜂窝网络切换时可能需要重新建连；&lt;/li&gt;
&lt;li&gt;存在队头阻塞，一个包丢失可能影响后续数据的读取。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对于普通聊天消息，TCP 的可靠性和成熟生态很有价值。它的主要挑战在于长连接维护、弱网恢复和大规模连接治理。&lt;/p&gt;
&lt;h3&gt;WebSocket：适合浏览器和通用实时消息&lt;/h3&gt;
&lt;p&gt;Web 端 IM 系统通常使用 &lt;strong&gt;WebSocket&lt;/strong&gt;。它先通过 HTTP 完成握手，再升级为全双工长连接，客户端和服务端都可以主动发送数据。&lt;/p&gt;
&lt;p&gt;WebSocket 适合的场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在线聊天消息；&lt;/li&gt;
&lt;li&gt;消息推送；&lt;/li&gt;
&lt;li&gt;已读回执；&lt;/li&gt;
&lt;li&gt;输入中状态；&lt;/li&gt;
&lt;li&gt;在线状态变更；&lt;/li&gt;
&lt;li&gt;消息撤回、编辑等实时事件。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;相比 HTTP 轮询，WebSocket 的优势更明显：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不需要客户端频繁请求服务端；&lt;/li&gt;
&lt;li&gt;实时性更好；&lt;/li&gt;
&lt;li&gt;空轮询带来的网络开销更低；&lt;/li&gt;
&lt;li&gt;服务端可以主动推送消息。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;WebSocket 的工程成本也要提前考虑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;需要心跳检测，及时发现断开的连接；&lt;/li&gt;
&lt;li&gt;需要断线重连，保证用户从弱网恢复后能继续收消息；&lt;/li&gt;
&lt;li&gt;需要连接管理，记录用户当前连接在哪台机器上；&lt;/li&gt;
&lt;li&gt;多实例部署时，需要维护用户连接和服务节点的映射关系；&lt;/li&gt;
&lt;li&gt;消息路由通常要结合网关、Redis、MQ 或专门的连接层来设计。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;WebSocket 适合承载小而频繁的实时消息和状态变化。它的重点成本不在协议本身，而在服务端如何管理海量长连接。&lt;/p&gt;
&lt;h3&gt;HTTP/HTTPS：适合请求响应和大对象传输&lt;/h3&gt;
&lt;p&gt;HTTP/HTTPS 更适合请求-响应式场景，例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用户登录；&lt;/li&gt;
&lt;li&gt;用户鉴权；&lt;/li&gt;
&lt;li&gt;拉取历史消息；&lt;/li&gt;
&lt;li&gt;获取用户资料；&lt;/li&gt;
&lt;li&gt;上传图片；&lt;/li&gt;
&lt;li&gt;上传文件；&lt;/li&gt;
&lt;li&gt;上传短视频；&lt;/li&gt;
&lt;li&gt;下载附件。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;图片、文件、短视频这类大对象适合通过 HTTP/HTTPS 上传和下载。更常见的流程是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;客户端通过 HTTP/HTTPS 上传文件到后端或对象存储；&lt;/li&gt;
&lt;li&gt;服务端返回文件 URL、文件大小、封面图、消息 ID 等元数据；&lt;/li&gt;
&lt;li&gt;客户端或服务端再通过 WebSocket 推送一条“文件消息”；&lt;/li&gt;
&lt;li&gt;接收方根据元数据展示消息，并在需要时通过 HTTP/HTTPS 下载文件。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这样设计的好处是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;大文件不会挤占 IM 实时消息通道；&lt;/li&gt;
&lt;li&gt;文件下载可以接入 CDN、对象存储和缓存；&lt;/li&gt;
&lt;li&gt;失败重试、断点续传、权限校验更容易处理；&lt;/li&gt;
&lt;li&gt;WebSocket 只负责传递轻量级消息事件。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;HTTP/HTTPS 的优势是成熟、稳定、兼容性强。它的主要限制是请求-响应模型更适合客户端主动发起请求，实时推送能力需要依赖其他机制配合。&lt;/p&gt;
&lt;h3&gt;UDP：适合低延迟和可容忍丢包的场景&lt;/h3&gt;
&lt;p&gt;UDP 的特点是开销小、延迟低、没有连接建立过程。它更适合对实时性要求极高、并且允许少量丢包的业务，例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;实时语音；&lt;/li&gt;
&lt;li&gt;视频通话；&lt;/li&gt;
&lt;li&gt;游戏状态同步；&lt;/li&gt;
&lt;li&gt;直播互动中的部分实时信令或媒体流。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;普通聊天消息、图片、文件发送对可靠性要求更高。UDP 自身不保证：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;可靠送达；&lt;/li&gt;
&lt;li&gt;按序到达；&lt;/li&gt;
&lt;li&gt;自动重传；&lt;/li&gt;
&lt;li&gt;去重；&lt;/li&gt;
&lt;li&gt;拥塞控制。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果把 UDP 用在普通 IM 消息上，应用层需要自己实现消息编号、ACK、重传、去重、乱序重排和拥塞控制。对于以文字、图片、文件、短视频为主的 IM 系统，直接使用 UDP 通常会显著增加工程复杂度。&lt;/p&gt;
&lt;p&gt;大型实时通信系统使用 UDP 时，通常会在 UDP 上再封装可靠传输层，或者采用 QUIC、KCP、WebRTC 这类成熟思路。它们追求的是弱网恢复、低延迟、连接迁移和移动端体验，同时仍然会保留必要的确认和重传机制。&lt;/p&gt;
&lt;h3&gt;QUIC：适合弱网和移动网络切换&lt;/h3&gt;
&lt;p&gt;QUIC 是基于 UDP 实现的新一代传输协议，常用于 HTTP/3。它在 UDP 之上实现了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;可靠传输；&lt;/li&gt;
&lt;li&gt;拥塞控制；&lt;/li&gt;
&lt;li&gt;默认加密；&lt;/li&gt;
&lt;li&gt;多路复用；&lt;/li&gt;
&lt;li&gt;连接迁移。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;QUIC 在移动端和弱网场景中有明显价值。例如手机从 Wi-Fi 切换到 4G/5G 时，QUIC 可以通过连接 ID 维持会话连续性，减少重新建连带来的延迟。&lt;/p&gt;
&lt;p&gt;它的优势主要体现在：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;握手延迟更低；&lt;/li&gt;
&lt;li&gt;弱网恢复体验更好；&lt;/li&gt;
&lt;li&gt;移动网络切换更友好；&lt;/li&gt;
&lt;li&gt;可以缓解 TCP 层面的队头阻塞问题。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它的落地成本也更高：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;服务端和网关支持更复杂；&lt;/li&gt;
&lt;li&gt;负载均衡需要适配连接迁移；&lt;/li&gt;
&lt;li&gt;监控和排障体系要能理解 QUIC/HTTP3；&lt;/li&gt;
&lt;li&gt;部分网络环境对 UDP 支持不稳定。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;QUIC 更适合移动端、跨地域、弱网优化等场景。它能改善连接恢复和握手延迟，但对服务端基础设施、网关、监控和排障能力要求更高。&lt;/p&gt;
&lt;h3&gt;常见场景选型&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;场景&lt;/th&gt;
&lt;th&gt;推荐协议&lt;/th&gt;
&lt;th&gt;原因&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;在线文字消息&lt;/td&gt;
&lt;td&gt;WebSocket/WSS、原生 TCP 长连接或 QUIC&lt;/td&gt;
&lt;td&gt;需要实时、可靠、双向通信&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;消息 ACK&lt;/td&gt;
&lt;td&gt;实时长连接&lt;/td&gt;
&lt;td&gt;属于实时状态确认&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;已读回执&lt;/td&gt;
&lt;td&gt;实时长连接&lt;/td&gt;
&lt;td&gt;状态变化需要及时推送&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;在线状态&lt;/td&gt;
&lt;td&gt;实时长连接&lt;/td&gt;
&lt;td&gt;服务端需要主动通知状态变化&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;登录和鉴权&lt;/td&gt;
&lt;td&gt;HTTP/HTTPS&lt;/td&gt;
&lt;td&gt;标准请求-响应流程，易于接入安全体系&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;拉取历史消息&lt;/td&gt;
&lt;td&gt;HTTP/HTTPS&lt;/td&gt;
&lt;td&gt;更适合分页、缓存和权限控制&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;图片上传&lt;/td&gt;
&lt;td&gt;HTTP/HTTPS&lt;/td&gt;
&lt;td&gt;文件体积较大，适合对象存储和 CDN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;文件下载&lt;/td&gt;
&lt;td&gt;HTTP/HTTPS&lt;/td&gt;
&lt;td&gt;支持缓存、断点续传和访问控制&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;短视频发送&lt;/td&gt;
&lt;td&gt;HTTP/HTTPS + WebSocket 元数据通知&lt;/td&gt;
&lt;td&gt;文件走 HTTP，消息事件走 WebSocket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;实时语音/视频&lt;/td&gt;
&lt;td&gt;UDP、QUIC 或 WebRTC&lt;/td&gt;
&lt;td&gt;更关注低延迟和实时性&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;移动弱网优化&lt;/td&gt;
&lt;td&gt;QUIC&lt;/td&gt;
&lt;td&gt;更适合连接迁移和快速恢复&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;在线 IM 聊天系统中的数据通信格式选择&lt;/h2&gt;
&lt;p&gt;数据通信格式决定一条消息在客户端和服务端之间如何表达、如何解析、如何扩展。它直接影响 IM 系统的开发效率、性能上限、跨端兼容、排障体验和后续演进成本。&lt;/p&gt;
&lt;p&gt;一套设计良好的通信格式，至少要解决下面几个问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;消息可识别&lt;/strong&gt;：接收方能根据字段判断这是文本、图片、文件、回执、心跳还是系统通知；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;字段可解析&lt;/strong&gt;：不同端对字段名、字段类型、默认值和必填项有一致理解；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;体积可控制&lt;/strong&gt;：高频消息不能携带过多冗余字段；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;性能可接受&lt;/strong&gt;：编码、解码不能成为客户端和服务端的明显开销；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;版本可演进&lt;/strong&gt;：新增字段、废弃字段、增加消息类型时，旧客户端仍然能稳定运行；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;问题可排查&lt;/strong&gt;：开发和线上排障时，能较快定位消息内容、字段缺失和解析失败原因。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果通信格式设计得过于随意，后续很容易出现这些问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不同客户端对同一字段理解不一致；&lt;/li&gt;
&lt;li&gt;新版本字段导致旧版本客户端解析异常；&lt;/li&gt;
&lt;li&gt;消息体积持续变大，移动端流量和耗电增加；&lt;/li&gt;
&lt;li&gt;缺少统一 &lt;code&gt;messageId&lt;/code&gt;、&lt;code&gt;seq&lt;/code&gt;、&lt;code&gt;timestamp&lt;/code&gt; 等字段，消息去重、排序和补偿同步变困难；&lt;/li&gt;
&lt;li&gt;调试时只能看到零散字段，无法快速判断一条消息的业务语义。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，IM 系统选择通信格式时，不能只看某个格式“快不快”，还要看它是否适合当前团队规模、客户端数量、消息量级和协议演进方式。&lt;/p&gt;
&lt;h3&gt;JSON：适合快速开发和调试&lt;/h3&gt;
&lt;p&gt;JSON 是最常见、最容易落地的数据通信格式。它采用键值对结构，语义直观，可读性强，前端、后端、移动端都有成熟支持。&lt;/p&gt;
&lt;p&gt;一条文本消息可以设计成这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;version&quot;: 1,
  &quot;messageType&quot;: &quot;text&quot;,
  &quot;messageId&quot;: &quot;msg_90001&quot;,
  &quot;conversationId&quot;: &quot;c2c_1001_1002&quot;,
  &quot;fromUserId&quot;: &quot;1001&quot;,
  &quot;toUserId&quot;: &quot;1002&quot;,
  &quot;timestamp&quot;: 1710000000000,
  &quot;body&quot;: {
    &quot;text&quot;: &quot;hello&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JSON 的优势：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;开发效率高，前后端联调成本低；&lt;/li&gt;
&lt;li&gt;可读性强，日志和抓包内容容易理解；&lt;/li&gt;
&lt;li&gt;跨语言支持成熟；&lt;/li&gt;
&lt;li&gt;字段结构灵活，适合早期快速迭代；&lt;/li&gt;
&lt;li&gt;很适合课程项目、Web 项目和中小规模 IM 系统。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;JSON 的成本：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;字段名会重复传输；&lt;/li&gt;
&lt;li&gt;消息体积比二进制协议更大；&lt;/li&gt;
&lt;li&gt;解析性能弱于常见二进制格式；&lt;/li&gt;
&lt;li&gt;字段类型约束较弱，协议一致性依赖代码、文档和测试；&lt;/li&gt;
&lt;li&gt;项目变大后，容易出现字段命名不统一、可选字段过多的问题。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果项目处于早期阶段，JSON 是很稳妥的选择。它能让团队先把登录、心跳、文本消息、图片消息、已读回执、离线同步等核心流程跑通。&lt;/p&gt;
&lt;h3&gt;Protobuf：适合高并发和多端协议治理&lt;/h3&gt;
&lt;p&gt;Protobuf 是常见的二进制序列化协议。它需要先定义 &lt;code&gt;.proto&lt;/code&gt; 文件，再生成不同语言的代码。&lt;/p&gt;
&lt;p&gt;Protobuf 适合的场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;消息量较大的 IM 系统；&lt;/li&gt;
&lt;li&gt;移动端流量敏感场景；&lt;/li&gt;
&lt;li&gt;客户端覆盖 iOS、Android、Web、PC 等多端；&lt;/li&gt;
&lt;li&gt;协议字段需要强约束的系统。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;大型 IM 系统可以用 Protobuf 定义：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;登录包；&lt;/li&gt;
&lt;li&gt;心跳包；&lt;/li&gt;
&lt;li&gt;文本消息包；&lt;/li&gt;
&lt;li&gt;图片消息包；&lt;/li&gt;
&lt;li&gt;ACK 确认包；&lt;/li&gt;
&lt;li&gt;群聊消息包；&lt;/li&gt;
&lt;li&gt;已读回执；&lt;/li&gt;
&lt;li&gt;离线消息同步请求。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Protobuf 的优势：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;编码体积小；&lt;/li&gt;
&lt;li&gt;解析速度快；&lt;/li&gt;
&lt;li&gt;字段结构更严格；&lt;/li&gt;
&lt;li&gt;跨语言代码生成成熟；&lt;/li&gt;
&lt;li&gt;适合长期维护稳定协议；&lt;/li&gt;
&lt;li&gt;字段编号和默认值机制有利于版本演进。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Protobuf 的成本：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;抓包后不能直接阅读；&lt;/li&gt;
&lt;li&gt;需要维护 &lt;code&gt;.proto&lt;/code&gt; 文件；&lt;/li&gt;
&lt;li&gt;前后端联调需要配套工具；&lt;/li&gt;
&lt;li&gt;字段变更要遵守兼容规则，字段编号不能随意复用。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对于工业级 IM，Protobuf 是很常见的选择。它更适合消息量大、端类型多、协议需要长期稳定演进的项目。&lt;/p&gt;
&lt;h3&gt;MessagePack：适合轻量二进制优化&lt;/h3&gt;
&lt;p&gt;MessagePack 可以理解为一种更紧凑的类 JSON 二进制格式。它保留了类似 JSON 的数据模型，编码后体积更小，解析效率也更高。&lt;/p&gt;
&lt;p&gt;它的优势：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;比 JSON 更省空间；&lt;/li&gt;
&lt;li&gt;数据模型接近 JSON；&lt;/li&gt;
&lt;li&gt;使用灵活，schema 约束较轻；&lt;/li&gt;
&lt;li&gt;适合想降低体积但又不想立即维护 &lt;code&gt;.proto&lt;/code&gt; 的场景。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它的成本：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;普及度弱于 JSON 和 Protobuf；&lt;/li&gt;
&lt;li&gt;调试体验弱于 JSON；&lt;/li&gt;
&lt;li&gt;团队协作中需要统一工具链；&lt;/li&gt;
&lt;li&gt;大型长期协议治理能力通常弱于 Protobuf。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;MessagePack 适合想降低消息体积，同时又希望保持较高灵活性的项目。主流 IM 工程里，JSON 和 Protobuf 的可见度更高。&lt;/p&gt;
&lt;h3&gt;XML：主要用于历史兼容&lt;/h3&gt;
&lt;p&gt;XML 也是结构化数据格式，早期系统和部分标准协议中比较常见。它表达能力强，结构规范，适合复杂文档型数据。&lt;/p&gt;
&lt;p&gt;在高频 IM 消息里，XML 的问题比较明显：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;标签冗余多；&lt;/li&gt;
&lt;li&gt;消息体积大；&lt;/li&gt;
&lt;li&gt;解析成本高；&lt;/li&gt;
&lt;li&gt;移动端流量和性能开销更明显。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;现代 IM 系统通常只在兼容历史系统、对接存量协议或特定标准时使用 XML。&lt;/p&gt;
&lt;h3&gt;自定义二进制协议：适合大型团队深度优化&lt;/h3&gt;
&lt;p&gt;部分大型 IM 系统会设计自定义二进制协议。它通常会把消息拆成固定消息头和消息体。&lt;/p&gt;
&lt;p&gt;一个常见的二进制消息头可能包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;协议版本；&lt;/li&gt;
&lt;li&gt;消息类型；&lt;/li&gt;
&lt;li&gt;请求 ID；&lt;/li&gt;
&lt;li&gt;序列号；&lt;/li&gt;
&lt;li&gt;消息体长度；&lt;/li&gt;
&lt;li&gt;压缩标志；&lt;/li&gt;
&lt;li&gt;加密标志；&lt;/li&gt;
&lt;li&gt;校验字段。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;消息体再承载具体业务内容，可以是 Protobuf，也可以是自定义二进制结构。&lt;/p&gt;
&lt;p&gt;自定义二进制协议的优势：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;体积可控；&lt;/li&gt;
&lt;li&gt;性能上限高；&lt;/li&gt;
&lt;li&gt;可以精确适配业务场景；&lt;/li&gt;
&lt;li&gt;方便加入压缩、加密、分片、批量 ACK 等机制。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它的工程成本也很高：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;协议设计复杂；&lt;/li&gt;
&lt;li&gt;调试工具需要自建；&lt;/li&gt;
&lt;li&gt;多端 SDK 维护成本高；&lt;/li&gt;
&lt;li&gt;版本兼容要求严格；&lt;/li&gt;
&lt;li&gt;新人理解成本更高。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以，自定义二进制协议适合有稳定客户端团队、服务端团队、协议治理流程和配套工具的大型 IM 系统。&lt;/p&gt;
&lt;h3&gt;统一消息结构怎么设计&lt;/h3&gt;
&lt;p&gt;IM 消息建议统一成“消息头 + 消息体”的结构。&lt;/p&gt;
&lt;p&gt;通用字段负责表达消息身份、消息类型和排序信息：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;version&quot;: 1,
  &quot;messageType&quot;: &quot;image&quot;,
  &quot;messageId&quot;: &quot;msg_90002&quot;,
  &quot;clientMsgId&quot;: &quot;client_abc_001&quot;,
  &quot;conversationId&quot;: &quot;group_20001&quot;,
  &quot;fromUserId&quot;: &quot;1001&quot;,
  &quot;seq&quot;: 1024,
  &quot;timestamp&quot;: 1710000000000,
  &quot;body&quot;: {}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;version&lt;/code&gt; 表示协议版本；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;messageType&lt;/code&gt; 表示消息类型；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;messageId&lt;/code&gt; 表示服务端生成的消息 ID；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;clientMsgId&lt;/code&gt; 用于客户端本地去重和发送状态跟踪；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;conversationId&lt;/code&gt; 表示会话；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fromUserId&lt;/code&gt; 表示发送方；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;seq&lt;/code&gt; 用于会话内排序和补拉；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;timestamp&lt;/code&gt; 表示消息时间；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;body&lt;/code&gt; 保存不同消息类型的具体内容。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;图片消息可以这样表达：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;body&quot;: {
    &quot;imageId&quot;: &quot;img_70001&quot;,
    &quot;originUrl&quot;: &quot;https://cdn.example.com/img/origin.jpg&quot;,
    &quot;thumbUrl&quot;: &quot;https://cdn.example.com/img/thumb.jpg&quot;,
    &quot;width&quot;: 1080,
    &quot;height&quot;: 720,
    &quot;size&quot;: 245760,
    &quot;format&quot;: &quot;jpg&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;文件消息可以这样表达：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;body&quot;: {
    &quot;fileId&quot;: &quot;file_80001&quot;,
    &quot;fileName&quot;: &quot;report.pdf&quot;,
    &quot;fileUrl&quot;: &quot;https://cdn.example.com/file/report.pdf&quot;,
    &quot;size&quot;: 1048576,
    &quot;mimeType&quot;: &quot;application/pdf&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种设计有两个好处：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;服务端可以先根据 &lt;code&gt;messageType&lt;/code&gt; 找到处理逻辑；&lt;/li&gt;
&lt;li&gt;客户端可以按消息类型解析不同的 &lt;code&gt;body&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;后续新增消息类型时，可以尽量复用通用字段；&lt;/li&gt;
&lt;li&gt;排序、去重、撤回、已读、补拉等能力有稳定字段可依赖。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;常见格式选型&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;场景&lt;/th&gt;
&lt;th&gt;推荐格式&lt;/th&gt;
&lt;th&gt;原因&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;早期 Web IM&lt;/td&gt;
&lt;td&gt;JSON&lt;/td&gt;
&lt;td&gt;开发快，调试方便&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;中小规模 IM&lt;/td&gt;
&lt;td&gt;JSON&lt;/td&gt;
&lt;td&gt;可维护性和可读性较好&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;多端工业级 IM&lt;/td&gt;
&lt;td&gt;Protobuf&lt;/td&gt;
&lt;td&gt;体积小，解析快，字段约束强&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;移动端流量敏感场景&lt;/td&gt;
&lt;td&gt;Protobuf 或 MessagePack&lt;/td&gt;
&lt;td&gt;更省流量，编码更紧凑&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;历史协议兼容&lt;/td&gt;
&lt;td&gt;XML&lt;/td&gt;
&lt;td&gt;适合对接存量系统&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;大型自研 IM&lt;/td&gt;
&lt;td&gt;自定义二进制协议&lt;/td&gt;
&lt;td&gt;性能和扩展性更可控&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;最终可以归纳为一句话：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;数据通信格式的核心，是先设计稳定的消息协议，再根据性能、调试、跨端和演进成本选择 JSON、Protobuf 或自定义二进制格式。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>以 OpenCode 为目标优化 CodingAgent 成本</title><link>https://blog.huangnv.online/posts/2665/1/agent------%E4%BB%A5-opencode-%E4%B8%BA%E7%9B%AE%E6%A0%87%E4%BC%98%E5%8C%96-codingagent-%E6%88%90%E6%9C%AC/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2665/1/agent------%E4%BB%A5-opencode-%E4%B8%BA%E7%9B%AE%E6%A0%87%E4%BC%98%E5%8C%96-codingagent-%E6%88%90%E6%9C%AC/</guid><description>记录一次围绕 MBPP-53 测评的 CodingAgent 优化过程：从减少自然语言输出、推动工具并行，到替换计划工具和增强文件编辑工具。</description><pubDate>Fri, 05 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;以 OpenCode 为目标优化 CodingAgent 成本&lt;/h1&gt;
&lt;p&gt;这次优化的目标是让自研 CodingAgent 的运行方式逐步向 OpenCode 靠近：更少的闲聊输出，更清晰的工具协议，以及更少由工具失败带来的重复 model call。MBPP-53 的分数只是观察优化效果的一个结果，真正关注的是运行成本和执行路径。&lt;/p&gt;
&lt;p&gt;MBPP-53 是一个很适合暴露 Agent 运行成本问题的测试集。它有 53 个相似但不完全相同的 Python 编程任务，每个任务都需要读题、写 &lt;code&gt;solution.py&lt;/code&gt;、运行公开测试，并在失败后继续修复。这个过程天然会放大几类成本：重复读文件、重复解释、重复测试、过细计划导致的串行执行、长上下文反复进入模型请求，以及文件编辑失败后的再尝试。&lt;/p&gt;
&lt;p&gt;因此，这次优化更像一次围绕真实任务的 Agent 工程调优。OpenCode 在这里是一个参照目标：它更强调短输出、工具驱动、权限规则、任务清单和长会话管理。我的优化过程就是不断把本地 CodingAgent 从“自由探索式运行”收敛到“受约束工具调用式运行”。&lt;/p&gt;
&lt;h2&gt;一、先看成本问题在哪里&lt;/h2&gt;
&lt;p&gt;早期运行中，Agent 虽然能完成不少任务，但过程非常昂贵。以一次旧 trace 为例：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;指标&lt;/th&gt;
&lt;th&gt;旧运行 &lt;code&gt;20260603-2237&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;model requests&lt;/td&gt;
&lt;td&gt;342&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;total tokens&lt;/td&gt;
&lt;td&gt;16,448,556&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prompt tokens&lt;/td&gt;
&lt;td&gt;16,181,874&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;completion tokens&lt;/td&gt;
&lt;td&gt;266,682&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;自动压缩次数&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这里最明显的问题是请求次数太多。大量请求来自重复探索：模型不断输出解释、尝试额外试探、读取和编辑文件、再运行测试。即使 prompt cache 命中率很高，累计 total tokens 仍会迅速膨胀。&lt;/p&gt;
&lt;p&gt;这类问题需要回到 Agent 行为本身解决：让它少说、少绕、少串行、少失败、少携带无效上下文。&lt;/p&gt;
&lt;h2&gt;二、优化一：收紧提示词，减少自然语言 content&lt;/h2&gt;
&lt;p&gt;早期系统提示词对“如何协作”“如何解释”“如何总结”保留了较多空间。模型在执行任务时容易把思考过程、推导过程和阶段总结写进普通 assistant content。对于命令行或 Web Chat 里的 coding agent 来说，这些内容大多不会直接推动任务完成，却会进入后续上下文。&lt;/p&gt;
&lt;p&gt;对应优化是收紧系统提示词，把回复风格改成更接近 CLI agent 的短输出策略：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;保持回复简短。&lt;/li&gt;
&lt;li&gt;可以用 1 到 3 句话回答时就不展开。&lt;/li&gt;
&lt;li&gt;工具调用之外的文本只保留用户真正需要看的内容。&lt;/li&gt;
&lt;li&gt;完成文件处理后直接停止，避免额外解释。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;相关提交是 &lt;code&gt;74552a1 chore(prompt): 收紧 agent 输出与失败处理规则&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这一步降低的是 completion token 和后续上下文污染。少输出一段解释，既能省掉当前这段输出，也能减少后续每一次请求都会携带的历史内容。&lt;/p&gt;
&lt;h2&gt;三、优化二：提示词和工具描述都强调并行化&lt;/h2&gt;
&lt;p&gt;MBPP-53 里大量任务彼此独立。早期 Agent 会倾向于“读一个任务、写一个任务、测一个任务、再进入下一个任务”。这种串行策略稳定但昂贵，因为每个小动作都可能触发一次 model call。&lt;/p&gt;
&lt;p&gt;优化方向是在系统提示词和工具描述里同时强化并行化：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;多个独立信息请求可以一次返回多个工具调用。&lt;/li&gt;
&lt;li&gt;多个可并行读取的文件应该批量读取。&lt;/li&gt;
&lt;li&gt;多个独立任务的公开测试可以在一次 assistant turn 中返回多个测试工具调用。&lt;/li&gt;
&lt;li&gt;工具调度层把安全工具分块并行执行，把独占工具单独执行。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这类优化的关键是让模型在一次返回中提出更多独立 tool call。模型调用次数减少，工具执行仍然由运行时调度层控制安全边界。&lt;/p&gt;
&lt;h2&gt;四、优化三：提高单次最大输出，减少长文本续写&lt;/h2&gt;
&lt;p&gt;在长任务里，模型可能需要一次性生成较长的代码、压缩摘要或修复说明。如果 API 的单次输出上限过低，就会出现 &lt;code&gt;finish_reason=length&lt;/code&gt;，随后系统需要追加续写请求。续写请求不只增加一次 model call，也会增加截断恢复逻辑的复杂度。&lt;/p&gt;
&lt;p&gt;因此我把模型输出上限统一提高到 &lt;code&gt;32000&lt;/code&gt;，并让普通请求、自动压缩摘要请求和手动 compact 请求共享这个配置。相关提交是 &lt;code&gt;8607494 将模型输出上限设为 32000&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这项优化解决的是长输出被迫拆成多轮的问题。对于 MBPP 这种需要大量写代码的任务，减少一次续写就意味着减少一次完整上下文请求。&lt;/p&gt;
&lt;h2&gt;五、优化四：从 plan 迁移到 todowrite，减少过细计划带来的串行&lt;/h2&gt;
&lt;p&gt;早期 &lt;code&gt;plan&lt;/code&gt; 工具更像一个完整的任务状态机，包含 &lt;code&gt;goal&lt;/code&gt;、&lt;code&gt;current_item_id&lt;/code&gt;、&lt;code&gt;note&lt;/code&gt;、失败状态、当前项更新等字段。这个设计对复杂任务很清晰，但在 MBPP-53 这种重复型任务里容易产生副作用：模型会把每个小任务、每次测试、每次失败都写成计划项，然后围绕计划项逐个推进。&lt;/p&gt;
&lt;p&gt;结果是原本可以并行的动作被计划状态串起来。模型需要反复更新计划、查看当前项、标记失败或通过，model call 数量随之增加。&lt;/p&gt;
&lt;p&gt;对应优化是迁移到 OpenCode / Claude Code 风格更接近的 &lt;code&gt;todowrite&lt;/code&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;todo 输入只有整张 &lt;code&gt;todos&lt;/code&gt; 表。&lt;/li&gt;
&lt;li&gt;状态更少，只保留 &lt;code&gt;pending&lt;/code&gt;、&lt;code&gt;in_progress&lt;/code&gt;、&lt;code&gt;completed&lt;/code&gt;、&lt;code&gt;cancelled&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;工具描述强调只在 3 个以上概念步骤或非平凡任务中使用。&lt;/li&gt;
&lt;li&gt;不为每个文件、每个测试、每次重复尝试创建 todo。&lt;/li&gt;
&lt;li&gt;后续又把 todo 更新改成阶段级主动更新，减少细粒度状态抖动。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;相关提交是 &lt;code&gt;3fe5b65 feat: replace plan tool with todowrite&lt;/code&gt; 和 &lt;code&gt;46c33fa fix(todowrite): 改为阶段级主动更新&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这一步的核心收益是减少计划管理本身消耗的 model call。任务清单应该帮助 Agent 保持方向，避免变成阻止并行执行的锁。&lt;/p&gt;
&lt;h2&gt;六、优化五：增强文件编辑工具，减少编辑失败重试&lt;/h2&gt;
&lt;p&gt;MBPP 任务看起来只是写 &lt;code&gt;solution.py&lt;/code&gt;，但文件编辑仍然会带来大量重复尝试。早期编辑工具如果要求精确匹配，模型生成的 &lt;code&gt;old_text&lt;/code&gt; 只要有缩进、空行、引号或换行差异，就可能匹配失败。失败后模型通常会重新读取文件、重新生成修改片段、再次调用工具。这会增加 model call，也会让上下文里堆积大量错误信息。&lt;/p&gt;
&lt;p&gt;另一个问题是读写顺序不清。覆盖已有文件前，如果没有明确的“先读后写”约束，模型可能直接覆盖文件，或者在不知道当前内容的情况下生成错误替换。&lt;/p&gt;
&lt;p&gt;对应优化分成三步：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;write_file&lt;/code&gt; 增加 guarded 行为：新文件可以直接创建，覆盖已有文件前必须先成功 &lt;code&gt;read_file&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;edit_file&lt;/code&gt; 增加读前置校验，编辑已有文件前也需要先读过同一路径。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;edit_file&lt;/code&gt; 增强匹配能力，支持换行归一、tab/space 归一、缩进整体偏移、行首尾空白、外层空行、转义文本和中英文引号差异等场景。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;相关提交是 &lt;code&gt;9f88157 feat(tools): add guarded write_file&lt;/code&gt;、&lt;code&gt;54b4207 feat(edit): 添加读前置校验&lt;/code&gt; 和 &lt;code&gt;519c950 feat(edit): 增强编辑匹配能力&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这个优化对 coding agent 很重要。文件编辑工具越可靠，模型越少因为工具失败进入“读文件、解释错误、再编辑”的循环。&lt;/p&gt;
&lt;h2&gt;七、优化效果：从自由探索到受约束执行&lt;/h2&gt;
&lt;p&gt;经过这些优化后，新运行的行为已经明显收敛。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;指标&lt;/th&gt;
&lt;th&gt;旧运行 &lt;code&gt;20260603-2237&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;新运行 &lt;code&gt;20260605-2039&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;model requests&lt;/td&gt;
&lt;td&gt;342&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;total tokens&lt;/td&gt;
&lt;td&gt;16,448,556&lt;/td&gt;
&lt;td&gt;1,759,698&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prompt tokens&lt;/td&gt;
&lt;td&gt;16,181,874&lt;/td&gt;
&lt;td&gt;1,693,902&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;completion tokens&lt;/td&gt;
&lt;td&gt;266,682&lt;/td&gt;
&lt;td&gt;65,796&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;自动压缩次数&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;从调用次数看，优化后的运行把模型请求压到了原来的约一成。工具调用总量也下降了，但下降幅度小于模型请求，说明优化重点在于让 Agent 用更少的模型轮次批量提出更多有效工具调用。&lt;/p&gt;
&lt;p&gt;下面四张图来自 Monitor 的 Step Analytics 页面，同一套图表分别切换到旧运行 &lt;code&gt;20260603-2237&lt;/code&gt; 和新运行 &lt;code&gt;20260605-2039&lt;/code&gt; 后截图。&lt;/p&gt;
&lt;h3&gt;工具调用曲线对比&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;./assets/monitor-20260603-2237-tool-call-curve.png&quot; alt=&quot;20260603-2237 工具调用曲线&quot; /&gt;&lt;/p&gt;
&lt;p&gt;图：旧运行 &lt;code&gt;20260603-2237&lt;/code&gt; 的工具调用曲线。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/monitor-20260605-2039-tool-call-curve.png&quot; alt=&quot;20260605-2039 工具调用曲线&quot; /&gt;&lt;/p&gt;
&lt;p&gt;图：新运行 &lt;code&gt;20260605-2039&lt;/code&gt; 的工具调用曲线。&lt;/p&gt;
&lt;p&gt;从 token 结构看，主要下降来自 prompt tokens。completion tokens 也明显减少，但总成本的大头仍然是每轮请求反复携带的历史内容。因此，减少 model call 是这次优化里最直接的成本收益来源。&lt;/p&gt;
&lt;h3&gt;Token 曲线对比&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;./assets/monitor-20260603-2237-token-curve.png&quot; alt=&quot;20260603-2237 token 曲线&quot; /&gt;&lt;/p&gt;
&lt;p&gt;图：旧运行 &lt;code&gt;20260603-2237&lt;/code&gt; 的 token 曲线。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/monitor-20260605-2039-token-curve.png&quot; alt=&quot;20260605-2039 token 曲线&quot; /&gt;&lt;/p&gt;
&lt;p&gt;图：新运行 &lt;code&gt;20260605-2039&lt;/code&gt; 的 token 曲线。&lt;/p&gt;
&lt;p&gt;这组数据属于工程现场对比，代码和工具协议本身在变化，任务现场也有差异。但它足以说明成本来源已经发生变化：旧运行的主要问题是自由探索和重复 model call，新运行的主要行为已经变成批量读写、规范测试和少量失败修复。&lt;/p&gt;
&lt;p&gt;从工程角度看，这比单纯追求一次高分更重要。Agent 的成本优化来自系统性收敛：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;成本过高&quot;] --&amp;gt; B[&quot;减少自然语言输出&quot;]
    A --&amp;gt; C[&quot;推动独立工具并行&quot;]
    A --&amp;gt; D[&quot;提高单次输出上限&quot;]
    A --&amp;gt; E[&quot;替换过细计划工具&quot;]
    A --&amp;gt; F[&quot;增强文件编辑工具&quot;]

    B --&amp;gt; J[&quot;更少 completion token&quot;]
    C --&amp;gt; K[&quot;更少 model call&quot;]
    D --&amp;gt; K
    E --&amp;gt; K
    F --&amp;gt; L[&quot;更少编辑失败重试&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;八、总结&lt;/h2&gt;
&lt;p&gt;这次优化可以概括为一句话：把 Agent 从自由探索式运行，逐步收敛为受约束、可并行、少重试的工具调用系统。&lt;/p&gt;
&lt;p&gt;提示词收紧减少了无效 content；并行描述减少了串行 model call；更高输出上限减少了长文本续写；&lt;code&gt;todowrite&lt;/code&gt; 降低了计划管理成本；文件编辑工具增强减少了编辑失败重试。&lt;/p&gt;
&lt;p&gt;这也是我理解中 OpenCode 风格最值得借鉴的地方：优秀的 coding agent 需要模型能力、工具协议和任务执行策略一起工作。成本下降来自整条运行链路的收敛。&lt;/p&gt;
</content:encoded></item><item><title>LLM Agent 技术演化史：从 Prompt 到 Harness Engineering</title><link>https://blog.huangnv.online/posts/26526/1/llm-agent%E6%8A%80%E6%9C%AF%E6%BC%94%E5%8C%96%E5%8F%B2_%E8%AF%A6%E7%BB%86%E6%8A%A5%E5%91%8A/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26526/1/llm-agent%E6%8A%80%E6%9C%AF%E6%BC%94%E5%8C%96%E5%8F%B2_%E8%AF%A6%E7%BB%86%E6%8A%A5%E5%91%8A/</guid><description>一篇面向技术史写作的详细报告：按主导工程问题重新梳理 LLM Agent 从对话模型、外部知识、工具调用、自主循环、工程编排、协议连接、GUI 行动、Coding Agent、Skills、Memory、Heartbeat 到 Harness Engineering 的演化。</description><pubDate>Tue, 26 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;LLM Agent 技术演化史：从 Prompt 到 Harness Engineering&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;本文聚焦 &lt;strong&gt;LLM Agent&lt;/strong&gt; 的工程演化史。Agent 概念早于 ChatGPT 很多年，符号规划、专家系统、BDI Agent、多智能体系统、强化学习、机器人控制、游戏 AI、RPA 都可以纳入更宽的 Agent 传统。2022 年之后真正爆发的，是以大语言模型为推理核心、以工具、上下文和运行时系统为外部身体的新一代 LLM Agent。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;摘要&lt;/h2&gt;
&lt;p&gt;过去几年里，“AI Agent”这个词经历了明显的含义迁移。&lt;/p&gt;
&lt;p&gt;2022 年底，ChatGPT 让大语言模型第一次以大众产品形态进入日常使用。那时的 AI 更像一个强大的文本生成器：它能写、能解释、能总结、能聊天，但多数时候只能给出建议，不能真正执行任务。&lt;/p&gt;
&lt;p&gt;随后，行业很快意识到：只会说话不够。模型需要知道最新信息，需要访问私有数据，需要调用工具，需要运行代码，需要操作浏览器，需要记住用户偏好，还需要在复杂任务中自我纠错。于是，Agent 的工程中心从“怎么写 Prompt”，转向“怎么组织 Context”，再转向“怎么搭建 Harness”。&lt;/p&gt;
&lt;p&gt;这条历史可以概括为：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Prompt Engineering 解决“让模型听懂任务”；Context Engineering 解决“让模型拿到正确信息”；Harness Engineering 解决“让模型在真实系统里持续做对”。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;真正的 Agent 是一个系统。模型只是推理核心，外面还需要工具、记忆、状态机、权限、评估、日志、回滚、人工确认和长期运行机制。&lt;/p&gt;
&lt;p&gt;本文按“主导工程问题”划分阶段。各阶段会相互叠加：RAG、Tool Use、Workflow、MCP、Computer Use、Skills、Memory、Heartbeat 都会在今天的 Agent 系统中同时出现。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;目录&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#%E4%B8%80%E6%80%BB%E8%A7%88agent-%E7%9A%84%E4%B8%BB%E7%BA%BF%E6%98%AF%E5%8F%AF%E6%8E%A7%E5%9C%B0%E5%81%9A%E4%BA%8B&quot;&gt;一、总览：Agent 的主线是可控地做事&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E4%BA%8C%E5%89%8D%E5%8F%B2chatgpt-%E4%B9%8B%E5%89%8D%E7%9A%84-agent-%E4%BC%A0%E7%BB%9F&quot;&gt;二、前史：ChatGPT 之前的 Agent 传统&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E4%B8%89%E7%AC%AC%E4%B8%80%E9%98%B6%E6%AE%B5%E5%AF%B9%E8%AF%9D%E4%B8%8E-prompt%E6%A8%A1%E5%9E%8B%E5%BC%80%E5%A7%8B%E5%90%AC%E6%87%82%E4%BB%BB%E5%8A%A1&quot;&gt;三、第一阶段：对话与 Prompt，模型开始听懂任务&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E5%9B%9B%E7%AC%AC%E4%BA%8C%E9%98%B6%E6%AE%B5%E5%A4%96%E9%83%A8%E7%9F%A5%E8%AF%86%E4%B8%8E%E5%B7%A5%E5%85%B7%E6%A8%A1%E5%9E%8B%E5%BC%80%E5%A7%8B%E6%8E%A5%E8%A7%A6%E4%B8%96%E7%95%8C&quot;&gt;四、第二阶段：外部知识与工具，模型开始接触世界&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E4%BA%94%E7%AC%AC%E4%B8%89%E9%98%B6%E6%AE%B5%E8%87%AA%E4%B8%BB%E5%BE%AA%E7%8E%AF%E5%88%B0%E5%B7%A5%E7%A8%8B%E7%BC%96%E6%8E%92agent-%E4%BB%8E%E6%BC%94%E7%A4%BA%E8%B5%B0%E5%90%91%E5%B7%A5%E4%BD%9C%E6%B5%81&quot;&gt;五、第三阶段：自主循环到工程编排，Agent 从演示走向工作流&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E5%85%AD%E7%AC%AC%E5%9B%9B%E9%98%B6%E6%AE%B5%E5%8D%8F%E8%AE%AE%E7%95%8C%E9%9D%A2%E4%B8%8E%E8%BD%AF%E4%BB%B6%E5%B7%A5%E7%A8%8Bagent-%E8%BF%9B%E5%85%A5%E7%9C%9F%E5%AE%9E%E5%B7%A5%E4%BD%9C%E7%8E%AF%E5%A2%83&quot;&gt;六、第四阶段：协议、界面与软件工程，Agent 进入真实工作环境&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E4%B8%83%E7%AC%AC%E4%BA%94%E9%98%B6%E6%AE%B5%E9%95%BF%E6%9C%9F%E8%BF%90%E8%A1%8C%E4%B8%8E-harnessagent-%E8%B5%B0%E5%90%91%E5%8F%AF%E6%B2%BB%E7%90%86%E7%B3%BB%E7%BB%9F&quot;&gt;七、第五阶段：长期运行与 Harness，Agent 走向可治理系统&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E5%85%AB%E6%80%BB%E8%A1%A8%E4%BA%94%E4%B8%AA%E9%98%B6%E6%AE%B5%E7%9A%84%E5%B0%9D%E8%AF%95%E9%97%AE%E9%A2%98%E5%92%8C%E4%BC%98%E5%8C%96&quot;&gt;八、总表：五个阶段的尝试、问题和优化&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#%E4%B9%9D%E7%BB%93%E8%AE%BAagent-%E6%88%90%E7%86%9F%E7%9A%84%E6%A0%87%E5%BF%97%E6%98%AF%E7%A8%B3%E5%AE%9A&quot;&gt;九、结论：Agent 成熟的标志是稳定&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;一、总览：Agent 的主线是可控地做事&lt;/h2&gt;
&lt;p&gt;AI Agent 的演化，表面上看是模型越来越像一个“数字员工”：会聊天、会查资料、会调用工具、会操作电脑、会记住偏好、会后台跟进。但从工程角度看，它更准确的主线是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;人类逐步扩大对模型的信任边界，同时被迫补上越来越重的治理系统。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;早期模型只能输出文本。它说错了，风险主要是误导用户。后来模型可以调用 API，错误就可能变成真实系统里的错误查询、错误写入、错误删除。再后来模型可以操作浏览器、文件系统和终端，风险半径进一步扩大。等到 Agent 可以后台常驻、跨会话保存记忆、定期唤醒并主动执行动作，它已经成为组织流程中的一个参与者。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart LR
    A[&quot;文本生成&quot;] --&amp;gt; B[&quot;外部知识&quot;]
    B --&amp;gt; C[&quot;工具调用&quot;]
    C --&amp;gt; D[&quot;工作流编排&quot;]
    D --&amp;gt; E[&quot;标准化连接&quot;]
    E --&amp;gt; F[&quot;GUI 与代码环境&quot;]
    F --&amp;gt; G[&quot;长期运行&quot;]
    G --&amp;gt; H[&quot;Harness 治理&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此，Agent 的成熟标志要看以下能力是否稳定：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;能否拿到正确上下文；&lt;/li&gt;
&lt;li&gt;能否选择正确工具；&lt;/li&gt;
&lt;li&gt;能否处理工具失败；&lt;/li&gt;
&lt;li&gt;能否验证结果；&lt;/li&gt;
&lt;li&gt;能否在高风险动作前暂停；&lt;/li&gt;
&lt;li&gt;能否记录过程；&lt;/li&gt;
&lt;li&gt;能否恢复、回滚或交给人类。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;从这个角度看，Agent 发展史是一部系统工程史。&lt;/p&gt;
&lt;h2&gt;二、前史：ChatGPT 之前的 Agent 传统&lt;/h2&gt;
&lt;p&gt;如果从 AI 学术史看，Agent 不是新概念。早期 AI 里已经有“智能体”思想：一个系统感知环境、维护内部状态、选择动作、影响环境。&lt;/p&gt;
&lt;p&gt;在 LLM 之前，至少有几条重要传统：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;传统&lt;/th&gt;
&lt;th&gt;典型形式&lt;/th&gt;
&lt;th&gt;能力&lt;/th&gt;
&lt;th&gt;限制&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;符号规划&lt;/td&gt;
&lt;td&gt;STRIPS、PDDL、任务规划器&lt;/td&gt;
&lt;td&gt;在明确状态空间中生成行动序列&lt;/td&gt;
&lt;td&gt;依赖结构化世界模型，难处理开放语言任务&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;专家系统&lt;/td&gt;
&lt;td&gt;规则引擎、知识库&lt;/td&gt;
&lt;td&gt;在窄领域内执行推理&lt;/td&gt;
&lt;td&gt;规则维护成本高，泛化能力弱&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BDI Agent&lt;/td&gt;
&lt;td&gt;Belief-Desire-Intention 架构&lt;/td&gt;
&lt;td&gt;把信念、目标、意图分层&lt;/td&gt;
&lt;td&gt;语义理解和现实环境连接有限&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;多智能体系统&lt;/td&gt;
&lt;td&gt;协商、博弈、群体决策&lt;/td&gt;
&lt;td&gt;多个 Agent 协作或竞争&lt;/td&gt;
&lt;td&gt;需要明确协议和环境假设&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;强化学习 Agent&lt;/td&gt;
&lt;td&gt;游戏、机器人、控制&lt;/td&gt;
&lt;td&gt;通过奖励学习策略&lt;/td&gt;
&lt;td&gt;需要环境、奖励和大量训练回合&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RPA&lt;/td&gt;
&lt;td&gt;表单、后台系统、流程自动化&lt;/td&gt;
&lt;td&gt;能点击、填表、搬运数据&lt;/td&gt;
&lt;td&gt;不懂开放语言，界面变化后脆弱&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这些系统能做事，但多数不懂开放自然语言；LLM 懂语言，但一开始不能稳定做事。LLM Agent 的工程史，本质上就是把“懂语言的大脑”和“能行动的身体”重新接起来。&lt;/p&gt;
&lt;p&gt;LLM Agent 的特殊性在于，大语言模型第一次提供了一个较通用的自然语言推理层。它可以读用户请求、读文档、读代码、生成计划、解释工具结果、写结构化参数。过去分散在搜索、脚本、RPA、数据库、浏览器自动化和规划器里的能力，开始被统一到自然语言接口之后。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;广义 Agent 传统&quot;] --&amp;gt; A1[&quot;规划&quot;]
    A --&amp;gt; A2[&quot;规则推理&quot;]
    A --&amp;gt; A3[&quot;控制与行动&quot;]
    A --&amp;gt; A4[&quot;RPA 自动化&quot;]

    B[&quot;LLM&quot;] --&amp;gt; B1[&quot;语言理解&quot;]
    B --&amp;gt; B2[&quot;文本生成&quot;]
    B --&amp;gt; B3[&quot;代码与结构化输出&quot;]

    A1 --&amp;gt; C[&quot;LLM Agent&quot;]
    A2 --&amp;gt; C
    A3 --&amp;gt; C
    A4 --&amp;gt; C
    B1 --&amp;gt; C
    B2 --&amp;gt; C
    B3 --&amp;gt; C
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;三、第一阶段：对话与 Prompt，模型开始听懂任务&lt;/h2&gt;
&lt;h3&gt;1. 阶段背景&lt;/h3&gt;
&lt;p&gt;2022 年末到 2023 年初，行业最关注的是如何让模型输出更好。ChatGPT 发布后，用户第一次感受到一个通用语言模型可以完成写作、总结、解释代码、翻译、头脑风暴、问答等大量任务。&lt;/p&gt;
&lt;p&gt;但这一代系统主要运行在聊天窗口里。它的输入是用户文本，输出也是文本。它能告诉用户“应该怎么做”，但不能替用户完成真实动作。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;用户任务&lt;/th&gt;
&lt;th&gt;这一阶段模型能做什么&lt;/th&gt;
&lt;th&gt;做不了什么&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;写代码&lt;/td&gt;
&lt;td&gt;生成代码片段&lt;/td&gt;
&lt;td&gt;不能进入项目、跑测试、修 Bug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;查资料&lt;/td&gt;
&lt;td&gt;根据训练知识回答&lt;/td&gt;
&lt;td&gt;不能稳定访问最新网页或私有文档&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;安排旅行&lt;/td&gt;
&lt;td&gt;给出行程建议&lt;/td&gt;
&lt;td&gt;不能打开网站订票&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;数据分析&lt;/td&gt;
&lt;td&gt;写 Python 思路&lt;/td&gt;
&lt;td&gt;不能自动读取文件并运行分析&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;写报告&lt;/td&gt;
&lt;td&gt;生成文本草稿&lt;/td&gt;
&lt;td&gt;不能核验来源、更新数据、产出可交付文件&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这一阶段的核心矛盾是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;模型能生成语言，但缺少外部世界。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;它没有实时知识，没有企业内部数据，没有工具执行能力，也没有跨任务状态。&lt;/p&gt;
&lt;h3&gt;2. Prompt Engineering 的技术尝试&lt;/h3&gt;
&lt;p&gt;Prompt Engineering 的本质，是用自然语言约束模型的输出空间。它并不会把模型参数中没有的事实直接变出来，但可以让模型更稳定地进入某种任务、角色、格式和推理路径。&lt;/p&gt;
&lt;h4&gt;Zero-shot Prompt&lt;/h4&gt;
&lt;p&gt;最简单的方式是直接给任务：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;请总结这篇文章。
请把这段话翻译成英文。
请写一个 Python 函数。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种方式适合简单任务。问题是，复杂任务的任务边界、输出格式、评判标准都不清楚，模型容易跑偏。&lt;/p&gt;
&lt;h4&gt;Few-shot Prompt&lt;/h4&gt;
&lt;p&gt;后来大家发现，给几个示例可以显著提高稳定性。比如先给三组“输入—输出”，再让模型模仿格式完成第四组。&lt;/p&gt;
&lt;p&gt;Few-shot 解决的是“格式不稳定”和“任务定义不清”的问题。它本质上是在上下文里临时定义一个小型任务分布。&lt;/p&gt;
&lt;h4&gt;Role Prompt&lt;/h4&gt;
&lt;p&gt;让模型扮演某种角色：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;你是一名资深产品经理。
你是一名法律顾问。
你是一名代码审查专家。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Role Prompt 的价值在于压缩任务背景，让模型进入某种语域。但它也带来幻觉风险：模型会说得像专家，不代表它真的掌握专业事实。&lt;/p&gt;
&lt;h4&gt;Chain-of-Thought 与 Self-Consistency&lt;/h4&gt;
&lt;p&gt;Chain-of-Thought 让模型显式展开中间推理步骤，Self-Consistency 则让模型采样多条推理路径，再选择更一致的答案。它们都说明一件事：Prompt 不只是“让模型回答”，还可以“塑造模型的思考过程”。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;用户任务&quot;] --&amp;gt; B[&quot;Prompt 约束&quot;]
    B --&amp;gt; C[&quot;角色&quot;]
    B --&amp;gt; D[&quot;示例&quot;]
    B --&amp;gt; E[&quot;格式&quot;]
    B --&amp;gt; F[&quot;推理步骤&quot;]
    C --&amp;gt; G[&quot;模型输出&quot;]
    D --&amp;gt; G
    E --&amp;gt; G
    F --&amp;gt; G
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. 暴露的问题&lt;/h3&gt;
&lt;p&gt;第一，Prompt 不能补事实。如果模型不知道最新信息，Prompt 写得再好也没有稳定依据。&lt;/p&gt;
&lt;p&gt;第二，Prompt 不能执行动作。它可以让模型写代码，但不能保证代码运行；可以让模型写 SQL，但不能保证数据库查询正确；可以让模型生成计划，但不能真正执行计划。&lt;/p&gt;
&lt;p&gt;第三，Prompt 很脆弱。同一个任务，换一种说法，结果可能不同。提示词模板看起来有效，但在不同模型、不同上下文、不同用户输入下容易失效。&lt;/p&gt;
&lt;p&gt;第四，Prompt 工程出现泡沫。早期行业把很多模板技巧包装成长期壁垒。后来模型指令遵循能力增强，系统提示、产品交互、结构化输出和工具调用能力成熟，单纯“卖提示词模板”的价值迅速下降。&lt;/p&gt;
&lt;h3&gt;4. 这一阶段的历史意义&lt;/h3&gt;
&lt;p&gt;Prompt 时代的意义不在于提示词模板本身，而在于它定义了 LLM Agent 的第一个工程问题：如何把人类意图稳定地传递给模型。&lt;/p&gt;
&lt;p&gt;这一阶段留下的经验后来并未消失。系统提示、开发者消息、角色约束、输出格式、few-shot 示例、任务模板，今天仍然是 Agent 系统的重要组成部分。只是它们从“全部能力来源”，退回到了更合理的位置：语言控制层。&lt;/p&gt;
&lt;h2&gt;四、第二阶段：外部知识与工具，模型开始接触世界&lt;/h2&gt;
&lt;h3&gt;1. 阶段背景&lt;/h3&gt;
&lt;p&gt;当模型具备较强语言能力后，行业很快遇到两个问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;模型不知道最新或私有信息；&lt;/li&gt;
&lt;li&gt;模型不能执行真实动作。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;于是第二阶段的主导工程问题变成：如何让模型接触外部世界。&lt;/p&gt;
&lt;p&gt;这个阶段包含两条并行路线：RAG 让模型获得外部知识，Tool Use 让模型执行外部动作。&lt;/p&gt;
&lt;h3&gt;2. RAG：把外部知识接入上下文&lt;/h3&gt;
&lt;p&gt;RAG 的基本思路是：先把文档切块、向量化、存入检索系统；用户提问时，系统检索相关片段，把片段放入上下文，再让模型回答。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;外部文档&quot;] --&amp;gt; B[&quot;切块&quot;]
    B --&amp;gt; C[&quot;向量化&quot;]
    C --&amp;gt; D[&quot;索引 / 向量库&quot;]
    Q[&quot;用户问题&quot;] --&amp;gt; E[&quot;检索&quot;]
    D --&amp;gt; E
    E --&amp;gt; F[&quot;相关片段&quot;]
    F --&amp;gt; G[&quot;注入上下文&quot;]
    G --&amp;gt; H[&quot;模型回答&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RAG 解决了第一代对话模型的知识断裂问题。企业规章、产品文档、代码库说明、研究论文、客服知识库，都可以通过检索进入模型上下文。&lt;/p&gt;
&lt;p&gt;但 RAG 也暴露出一批问题：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;问题&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;切块粗糙&lt;/td&gt;
&lt;td&gt;文档被机械切碎，语义边界被破坏&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;检索不准&lt;/td&gt;
&lt;td&gt;向量相似不等于任务相关&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;召回片段孤立&lt;/td&gt;
&lt;td&gt;单个片段缺少前后文&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;仍会幻觉&lt;/td&gt;
&lt;td&gt;模型可能忽略证据或过度推断&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;权限复杂&lt;/td&gt;
&lt;td&gt;企业知识库需要严格控制可见范围&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;后续优化包括 hybrid search、rerank、结构化元数据、引用来源、source pointer、contextual retrieval、按权限过滤等。RAG 的本质没有过时，但它从“一个向量库方案”变成了更完整的数据与上下文工程。&lt;/p&gt;
&lt;h3&gt;3. Tool Use：从文本建议到真实动作&lt;/h3&gt;
&lt;p&gt;Tool Use 的核心，是让模型输出结构化调用，由外部运行时执行真实动作。&lt;/p&gt;
&lt;p&gt;从早期 Plugins、Function Calling，到 Toolformer、MRKL、后来的结构化输出和原生工具调用，行业逐步形成了一个基本范式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sequenceDiagram
    participant U as User
    participant M as LLM
    participant R as Runtime
    participant T as Tool

    U-&amp;gt;&amp;gt;M: 提出任务
    M-&amp;gt;&amp;gt;R: 生成工具调用
    R-&amp;gt;&amp;gt;T: 执行工具
    T--&amp;gt;&amp;gt;R: 返回结果
    R--&amp;gt;&amp;gt;M: 注入观察结果
    M--&amp;gt;&amp;gt;U: 继续推理或最终回答
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;工具调用让模型第一次从“建议者”变成“执行者”。模型可以查询天气、查数据库、调用企业 API、运行代码、读取文件、搜索网页。&lt;/p&gt;
&lt;p&gt;但 Tool Use 也很快暴露出风险：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;工具选择错误；&lt;/li&gt;
&lt;li&gt;参数生成错误；&lt;/li&gt;
&lt;li&gt;工具结果解释错误；&lt;/li&gt;
&lt;li&gt;缺少权限边界；&lt;/li&gt;
&lt;li&gt;工具输出过长，污染上下文；&lt;/li&gt;
&lt;li&gt;工具失败后缺少恢复策略。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，工具调用必须逐步引入 JSON Schema、工具白名单、参数校验、权限分级、结果摘要、错误反馈和人类确认。&lt;/p&gt;
&lt;h3&gt;4. AutoGPT：第一次自主 Agent 狂热与幻灭&lt;/h3&gt;
&lt;p&gt;RAG 和 Tool Use 让行业看到一个更激进的可能：既然模型能读资料、能调工具，能不能给它一个大目标，让它自己循环完成任务？&lt;/p&gt;
&lt;p&gt;AutoGPT 代表了这波狂热。它尝试让模型自己生成任务队列，执行工具，写入文件，评估进展，再生成下一步行动。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;长期目标&quot;] --&amp;gt; B[&quot;拆分任务&quot;]
    B --&amp;gt; C[&quot;选择工具&quot;]
    C --&amp;gt; D[&quot;执行动作&quot;]
    D --&amp;gt; E[&quot;观察结果&quot;]
    E --&amp;gt; F[&quot;更新记忆&quot;]
    F --&amp;gt; G{&quot;认为完成了吗&quot;}
    G -- &quot;否&quot; --&amp;gt; B
    G -- &quot;是&quot; --&amp;gt; H[&quot;输出结果&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它的演示效果很强，但幻灭也很快。根本问题在于开环控制：缺少可靠状态约束、终止条件、任务预算、验证器和人机协作节点。模型可能在错误方向上不断循环，也可能为了简单任务消耗大量调用成本。&lt;/p&gt;
&lt;p&gt;AutoGPT 的历史意义，是用失败证明了一个重要原则：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;工具调用加循环，不等于生产级 Agent。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;从这里开始，行业转向更工程化的工作流编排。&lt;/p&gt;
&lt;h2&gt;五、第三阶段：自主循环到工程编排，Agent 从演示走向工作流&lt;/h2&gt;
&lt;h3&gt;1. 阶段背景&lt;/h3&gt;
&lt;p&gt;AutoGPT 之后，行业逐渐放弃“给模型一个目标，让它自由发挥”的天真假设。真正可用的 Agent 必须有状态管理、计划约束、工具边界、评估机制和人工介入。&lt;/p&gt;
&lt;p&gt;这一阶段的核心工程问题是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;如何把模型的推理和行动放进可控流程里。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;2. ReAct：推理与行动交替&lt;/h3&gt;
&lt;p&gt;ReAct 代表了一个关键范式：Reasoning + Acting。模型先分析当前状态，再调用工具，读取观察结果后继续推理。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;任务&quot;] --&amp;gt; B[&quot;Reason&amp;lt;br/&amp;gt;分析状态&quot;]
    B --&amp;gt; C[&quot;Act&amp;lt;br/&amp;gt;调用工具&quot;]
    C --&amp;gt; D[&quot;Observe&amp;lt;br/&amp;gt;读取结果&quot;]
    D --&amp;gt; E{&quot;是否完成&quot;}
    E -- &quot;否&quot; --&amp;gt; B
    E -- &quot;是&quot; --&amp;gt; F[&quot;交付&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这比 AutoGPT 的自由循环更可控，因为每一步都围绕当前观察结果推进，也更容易插入日志、预算、检查点和人类确认。&lt;/p&gt;
&lt;h3&gt;3. Agentic Workflow 的几类模式&lt;/h3&gt;
&lt;p&gt;工程实践中，Agentic Workflow 逐渐沉淀出几类模式。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;模式&lt;/th&gt;
&lt;th&gt;核心思想&lt;/th&gt;
&lt;th&gt;适合任务&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prompt Chaining&lt;/td&gt;
&lt;td&gt;把复杂任务拆成连续步骤&lt;/td&gt;
&lt;td&gt;写作、分析、结构化转换&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Routing&lt;/td&gt;
&lt;td&gt;根据输入类型分发到不同处理路径&lt;/td&gt;
&lt;td&gt;客服、工单、工具选择&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evaluator-Optimizer&lt;/td&gt;
&lt;td&gt;生成器和评估器循环迭代&lt;/td&gt;
&lt;td&gt;代码、文案、测试生成&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Planner-Executor&lt;/td&gt;
&lt;td&gt;规划器拆任务，执行器逐步完成&lt;/td&gt;
&lt;td&gt;研究、开发、运维&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-agent Collaboration&lt;/td&gt;
&lt;td&gt;多个角色分工协作&lt;/td&gt;
&lt;td&gt;软件工程、复杂调研&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这类工作流的共同点，是把模型放进人类设计的控制结构中。可靠性逐步转向依赖规划、执行、观察、评估和修正的闭环。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;复杂目标&quot;] --&amp;gt; B[&quot;Planner&quot;]
    B --&amp;gt; C[&quot;Executor&quot;]
    C --&amp;gt; D[&quot;Tool / Environment&quot;]
    D --&amp;gt; E[&quot;Observation&quot;]
    E --&amp;gt; F[&quot;Evaluator&quot;]
    F --&amp;gt; G{&quot;通过&quot;}
    G -- &quot;否&quot; --&amp;gt; B
    G -- &quot;是&quot; --&amp;gt; H[&quot;交付&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4. Context Engineering：信息组织成为核心能力&lt;/h3&gt;
&lt;p&gt;随着工作流变长，Context Engineering 变成核心问题。&lt;/p&gt;
&lt;p&gt;早期做法是把尽可能多的信息塞进上下文。后来大家发现，上下文窗口越长，成本、延迟和注意力稀释越明显；无关信息也会污染模型判断。Context Engineering 的目标，是在尽可能少的 token 中提供足够支撑期望输出的信息。&lt;/p&gt;
&lt;p&gt;它解决四类问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;给什么信息；&lt;/li&gt;
&lt;li&gt;什么时候给；&lt;/li&gt;
&lt;li&gt;以什么结构给；&lt;/li&gt;
&lt;li&gt;旧信息如何压缩、保存和恢复。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;常见技术包括长上下文、RAG 2.0、上下文压缩、记忆分层、工具说明检索、源材料指针、对话 compaction。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;信息全集&quot;] --&amp;gt; B[&quot;检索&quot;]
    A --&amp;gt; C[&quot;过滤&quot;]
    A --&amp;gt; D[&quot;排序&quot;]
    B --&amp;gt; E[&quot;候选上下文&quot;]
    C --&amp;gt; E
    D --&amp;gt; E
    E --&amp;gt; F[&quot;压缩&quot;]
    F --&amp;gt; G[&quot;当前上下文&quot;]
    G --&amp;gt; H[&quot;模型推理&quot;]
    H --&amp;gt; I{&quot;值得保存&quot;}
    I -- &quot;是&quot; --&amp;gt; J[&quot;长期记忆&quot;]
    I -- &quot;否&quot; --&amp;gt; K[&quot;丢弃或保留在短期上下文&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;长期记忆更像硬盘，当前上下文更像内存。Agent 不能把所有历史都放进内存，只能在需要时从记忆、文件、日志和检索系统中恢复相关片段。&lt;/p&gt;
&lt;h3&gt;5. Reasoning Models：推理能力开始内化&lt;/h3&gt;
&lt;p&gt;随着 reasoning models 出现，部分原先依赖 Prompt 技巧的推理能力开始内化到模型本身。模型可以花更多计算做复杂推理，也能更稳定地处理多步任务。&lt;/p&gt;
&lt;p&gt;这并没有让 Agent 工程消失。原因很简单：推理能力增强，通常会让模型承担更复杂、更长链路、更高风险的任务。任务难度上升后，工具边界、上下文控制、里程碑检查、评估和回滚仍然必需。&lt;/p&gt;
&lt;p&gt;Reasoning Models 对 Agent 的影响主要有三点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;复杂规划能力提高；&lt;/li&gt;
&lt;li&gt;工具调用前的分析更细；&lt;/li&gt;
&lt;li&gt;任务成本、延迟和执行风险也随之上升。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，模型路由、预算控制、里程碑检查和人类确认变得更重要。&lt;/p&gt;
&lt;h3&gt;6. 这一阶段的历史意义&lt;/h3&gt;
&lt;p&gt;这一阶段把 Agent 从“演示很惊艳”推进到“流程可管理”。它让行业认识到，Agent 的可靠性来自工程结构，而不是单纯来自模型自由发挥。&lt;/p&gt;
&lt;h2&gt;六、第四阶段：协议、界面与软件工程，Agent 进入真实工作环境&lt;/h2&gt;
&lt;h3&gt;1. 阶段背景&lt;/h3&gt;
&lt;p&gt;当工作流编排逐渐成熟后，新的问题出现了：工具和环境太碎片化。&lt;/p&gt;
&lt;p&gt;每个 Agent 都要接不同工具，每个工具都要适配不同 Agent。开发者还要面对浏览器、IDE、终端、文件系统、企业后台、没有 API 的网页和各种 GUI 软件。&lt;/p&gt;
&lt;p&gt;这一阶段的核心工程问题是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Agent 如何标准化连接外部工具，并进入真实工作环境。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;2. MCP：从工具孤岛到协议层&lt;/h3&gt;
&lt;p&gt;MCP 的目标是降低工具接入复杂度。过去是 M 个 Agent 客户端适配 N 个工具，复杂度接近 M 乘 N；协议化后，每个工具实现一次 MCP Server，支持 MCP 的客户端即可调用，复杂度向 M 加 N 收敛。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    subgraph Before[&quot;协议化之前&quot;]
        A1[&quot;Agent A&quot;] --&amp;gt; T1[&quot;Tool 1&quot;]
        A1 --&amp;gt; T2[&quot;Tool 2&quot;]
        A2[&quot;Agent B&quot;] --&amp;gt; T1
        A2 --&amp;gt; T2
    end

    subgraph After[&quot;协议化之后&quot;]
        C1[&quot;Agent A&quot;] --&amp;gt; MCP[&quot;MCP&quot;]
        C2[&quot;Agent B&quot;] --&amp;gt; MCP
        MCP --&amp;gt; S1[&quot;MCP Server 1&quot;]
        MCP --&amp;gt; S2[&quot;MCP Server 2&quot;]
    end
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MCP 这类协议的意义不只是工具数量增加，更在于它把工具连接从产品私有能力推向生态基础设施。&lt;/p&gt;
&lt;p&gt;但协议标准化不等于安全。MCP Server 质量、权限模型、数据泄露、供应链攻击、工具输出污染，仍然需要产品和运行时系统处理。&lt;/p&gt;
&lt;h3&gt;3. Computer Use：从 API 世界进入 GUI 世界&lt;/h3&gt;
&lt;p&gt;Tool Use 主要连接 API，但现实世界里大量软件没有稳定 API。人类每天使用浏览器、表单、后台系统、IDE、网页按钮和桌面应用。Computer Use 的意义，是让 Agent 可以看屏幕、点击、输入、读取视觉反馈。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart LR
    A[&quot;API Tool Use&quot;] --&amp;gt; B[&quot;结构化接口&quot;]
    C[&quot;Computer Use&quot;] --&amp;gt; D[&quot;屏幕&quot;]
    D --&amp;gt; E[&quot;鼠标&quot;]
    D --&amp;gt; F[&quot;键盘&quot;]
    D --&amp;gt; G[&quot;浏览器 / GUI&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这扩大了 Agent 的行动空间，也引入了新风险：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;GUI 状态不稳定；&lt;/li&gt;
&lt;li&gt;坐标和元素识别脆弱；&lt;/li&gt;
&lt;li&gt;页面变化导致操作失效；&lt;/li&gt;
&lt;li&gt;模型可能误读视觉信息；&lt;/li&gt;
&lt;li&gt;高风险按钮需要确认；&lt;/li&gt;
&lt;li&gt;操作后的结果必须验证。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以 Computer Use 必须配合虚拟浏览器、沙箱、截图审查、DOM 辅助信息、动作确认和状态验证。&lt;/p&gt;
&lt;h3&gt;4. Coding Agent：软件工程最先落地&lt;/h3&gt;
&lt;p&gt;Coding Agent 是 Agent 最先大规模落地的场景之一。原因很明确：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;代码是文本，适合 LLM 处理；&lt;/li&gt;
&lt;li&gt;开发环境有文件、终端、测试、git、CI 等可观测工具；&lt;/li&gt;
&lt;li&gt;成功与失败相对容易验证；&lt;/li&gt;
&lt;li&gt;开发者能审查 diff；&lt;/li&gt;
&lt;li&gt;工程任务天然适合规划、执行、测试和回滚。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;从 Copilot 式补全，到 IDE Agent、CLI Agent、PR Agent，Agentic Coding 的形态逐渐变化：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;形态&lt;/th&gt;
&lt;th&gt;特征&lt;/th&gt;
&lt;th&gt;典型能力&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;补全助手&lt;/td&gt;
&lt;td&gt;人写代码，模型补局部&lt;/td&gt;
&lt;td&gt;自动补全、解释代码&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chat in IDE&lt;/td&gt;
&lt;td&gt;用户提问，模型回答&lt;/td&gt;
&lt;td&gt;查询文件、生成片段&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edit Agent&lt;/td&gt;
&lt;td&gt;模型修改项目文件&lt;/td&gt;
&lt;td&gt;多文件编辑、重构&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLI Agent&lt;/td&gt;
&lt;td&gt;模型操作终端和仓库&lt;/td&gt;
&lt;td&gt;跑测试、修 Bug、提交变更&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PR Agent&lt;/td&gt;
&lt;td&gt;模型参与代码审查和修复&lt;/td&gt;
&lt;td&gt;评审、修复 CI、生成说明&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;但 Coding Agent 也暴露出典型问题：代码能跑不代表架构正确；测试覆盖不足会放大幻觉；Agent 可能破坏安全边界；大型仓库上下文巨大；生成速度带来的主观提速可能掩盖真实交付成本。&lt;/p&gt;
&lt;p&gt;因此，Coding Agent 的成熟依赖测试、diff review、sandbox、权限控制、上下文检索、CI 验证和人工审查。&lt;/p&gt;
&lt;h3&gt;5. 这一阶段的历史意义&lt;/h3&gt;
&lt;p&gt;这一阶段让 Agent 真正进入工作环境。协议层解决工具连接问题，Computer Use 解决 GUI 操作问题，Coding Agent 证明 Agent 可以在一个可观测、可验证、可回滚的专业场景中产生真实生产力。&lt;/p&gt;
&lt;p&gt;它也说明一件事：Agent 越接近真实工作，越需要治理。&lt;/p&gt;
&lt;h2&gt;七、第五阶段：长期运行与 Harness，Agent 走向可治理系统&lt;/h2&gt;
&lt;h3&gt;1. 阶段背景&lt;/h3&gt;
&lt;p&gt;前几个阶段让 Agent 会说、会查、会调工具、会编排工作流、会进入软件环境。下一步的问题是：如何让 Agent 长期存在、持续跟进目标，并在风险可控的条件下运行。&lt;/p&gt;
&lt;p&gt;这一阶段包含 Skills、Memory、Tasks、Heartbeat、Background Agent 和 Harness Engineering。它们解决的是同一个更大的问题：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;如何把概率模型包装成可运行、可治理、可审计、可恢复的系统。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;2. Skills：从会用工具到会按组织方法做事&lt;/h3&gt;
&lt;p&gt;Tool Use 解决“能调用什么工具”。Skills 解决“如何专业地使用工具”。&lt;/p&gt;
&lt;p&gt;一个 Skill 通常不只是 API 描述，还可以包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;领域知识；&lt;/li&gt;
&lt;li&gt;操作流程；&lt;/li&gt;
&lt;li&gt;脚本；&lt;/li&gt;
&lt;li&gt;模板；&lt;/li&gt;
&lt;li&gt;示例；&lt;/li&gt;
&lt;li&gt;注意事项；&lt;/li&gt;
&lt;li&gt;验证方法；&lt;/li&gt;
&lt;li&gt;输出规范。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;例如，处理 Excel 是一个 Skill，制作 PPT 是一个 Skill，前端视觉审查是一个 Skill，安全代码审查也是一个 Skill。Agent 可以按任务加载专业方法，减少从零推理的成本。&lt;/p&gt;
&lt;p&gt;Skills 的关键工程思想是渐进式披露：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;任务&quot;] --&amp;gt; B[&quot;技能索引&quot;]
    B --&amp;gt; C{&quot;命中技能&quot;}
    C -- &quot;否&quot; --&amp;gt; D[&quot;普通推理&quot;]
    C -- &quot;是&quot; --&amp;gt; E[&quot;加载技能说明&quot;]
    E --&amp;gt; F{&quot;需要资源&quot;}
    F -- &quot;是&quot; --&amp;gt; G[&quot;加载脚本 / 模板 / 示例&quot;]
    F -- &quot;否&quot; --&amp;gt; H[&quot;执行&quot;]
    G --&amp;gt; H
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样可以避免把全部工具说明、领域文档、脚本模板一次性塞进上下文。&lt;/p&gt;
&lt;p&gt;Skills 也带来供应链风险。Skill 是 Agent 会阅读并遵循的文本和脚本，因此恶意 Skill 可以通过提示注入、脚本执行、数据外传改变 Agent 行为。未来 Skills 需要版本管理、签名、权限声明、来源审查和运行隔离。&lt;/p&gt;
&lt;h3&gt;3. Memory、Tasks 与 Heartbeat：Agent 获得长期存在感&lt;/h3&gt;
&lt;p&gt;Memory 让 Agent 跨会话保存用户偏好、项目约定、历史决策和失败经验。Tasks 让 Agent 可以管理待办事项。Heartbeat 让 Agent 可以按时间被唤醒，检查是否需要行动。&lt;/p&gt;
&lt;p&gt;这三者组合后，Agent 从一次性响应工具，变成有长期状态的参与者。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;用户目标&quot;] --&amp;gt; B[&quot;任务状态&quot;]
    B --&amp;gt; C[&quot;定期唤醒&quot;]
    C --&amp;gt; D[&quot;读取记忆&quot;]
    D --&amp;gt; E[&quot;检查环境&quot;]
    E --&amp;gt; F{&quot;需要行动&quot;}
    F -- &quot;否&quot; --&amp;gt; G[&quot;保持安静&quot;]
    F -- &quot;是&quot; --&amp;gt; H[&quot;执行或提醒&quot;]
    H --&amp;gt; I[&quot;更新记忆和任务状态&quot;]
    I --&amp;gt; C
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但长期存在也带来长期风险：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;长期权限风险；&lt;/li&gt;
&lt;li&gt;记忆污染；&lt;/li&gt;
&lt;li&gt;过期信息持续影响判断；&lt;/li&gt;
&lt;li&gt;频繁提醒造成打扰；&lt;/li&gt;
&lt;li&gt;背景任务失败后责任边界不清；&lt;/li&gt;
&lt;li&gt;用户忘记曾授权某个 Agent。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，长期运行 Agent 需要权限过期、记忆审查、通知策略、审计日志、任务取消、运行摘要和人工确认。&lt;/p&gt;
&lt;h3&gt;4. Harness Engineering：把概率模型变成稳定系统&lt;/h3&gt;
&lt;p&gt;Harness Engineering 是这一阶段的最高层抽象。它是一组围绕模型建立的运行时、治理和恢复机制。&lt;/p&gt;
&lt;p&gt;可以把 Agent 系统拆成几层：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;LLM 推理核心&quot;] --&amp;gt; B[&quot;上下文管理&quot;]
    B --&amp;gt; C[&quot;工具系统&quot;]
    C --&amp;gt; D[&quot;工作流 / 状态机&quot;]
    D --&amp;gt; E[&quot;权限与安全&quot;]
    E --&amp;gt; F[&quot;评估与观测&quot;]
    F --&amp;gt; G[&quot;失败恢复&quot;]
    G --&amp;gt; H[&quot;长期运行&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Harness 的目标是把随机生成系统变成稳定软件系统。它至少包含以下能力：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;能力&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;工具注册&lt;/td&gt;
&lt;td&gt;定义 Agent 能调用什么&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;权限控制&lt;/td&gt;
&lt;td&gt;限制 Agent 能做什么&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;状态机&lt;/td&gt;
&lt;td&gt;限制任务如何推进&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;上下文管理&lt;/td&gt;
&lt;td&gt;控制模型看到什么&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;记忆系统&lt;/td&gt;
&lt;td&gt;控制哪些信息长期保存&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;评估系统&lt;/td&gt;
&lt;td&gt;判断结果是否正确&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;可观测性&lt;/td&gt;
&lt;td&gt;记录工具调用和决策过程&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;沙箱&lt;/td&gt;
&lt;td&gt;限制危险动作影响范围&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;回滚机制&lt;/td&gt;
&lt;td&gt;出错后恢复状态&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human-in-the-loop&lt;/td&gt;
&lt;td&gt;高风险动作交给人确认&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;5. Harness 的六层工程结构&lt;/h3&gt;
&lt;p&gt;生产级 Agent 通常需要六层结构。&lt;/p&gt;
&lt;p&gt;第一层是信息边界层。它决定哪些信息进入模型、以什么优先级进入、哪些内容是全局规则、哪些内容是用户偏好、哪些内容是项目级约束。&lt;/p&gt;
&lt;p&gt;第二层是工具系统。工具需要统一规范，包括输入输出 Schema、读写属性、是否可并发、是否有破坏性、是否需要确认、失败时如何返回错误。&lt;/p&gt;
&lt;p&gt;第三层是编排运行层。复杂任务需要规划、拆分、并发、子 Agent、状态机和中途转向能力。&lt;/p&gt;
&lt;p&gt;第四层是状态与记忆层。Agent 需要区分短期上下文和长期记忆。短期上下文用于当前推理，长期记忆用于跨会话恢复。&lt;/p&gt;
&lt;p&gt;第五层是评估与观测层。Agent 完成任务后需要自检，关键节点需要里程碑检查，最终交付需要测试、评估和审查。&lt;/p&gt;
&lt;p&gt;第六层是约束与失败恢复层。失败后要能重试、注入错误原因、回滚状态、切换路径或请求人工介入。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;生产级 Agent&quot;]
    A --&amp;gt; L1[&quot;1. 信息边界&quot;]
    A --&amp;gt; L2[&quot;2. 工具系统&quot;]
    A --&amp;gt; L3[&quot;3. 编排运行&quot;]
    A --&amp;gt; L4[&quot;4. 状态与记忆&quot;]
    A --&amp;gt; L5[&quot;5. 评估与观测&quot;]
    A --&amp;gt; L6[&quot;6. 约束与失败恢复&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6. OpenClaw / OpenCloud 类框架的案例意义&lt;/h3&gt;
&lt;p&gt;以 OpenClaw、OpenCloud 这类 Agent 框架为例，可以看到 Harness 如何落地为一组具体机制：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用本地文件系统保存身份、偏好、项目规则和安全边界；&lt;/li&gt;
&lt;li&gt;把长期记忆从上下文窗口里拆出去；&lt;/li&gt;
&lt;li&gt;用 Skill 和 MCP 分别解决专业方法与工具连接；&lt;/li&gt;
&lt;li&gt;对工具调用设置读写属性、破坏性确认和并发控制；&lt;/li&gt;
&lt;li&gt;对长上下文做压缩、折叠、摘要和回源；&lt;/li&gt;
&lt;li&gt;失败后通过重试、回滚、路线切换和人工确认恢复。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    U[&quot;用户目标&quot;] --&amp;gt; I[&quot;意图捕获&quot;]
    I --&amp;gt; P[&quot;任务拆解&quot;]
    P --&amp;gt; C[&quot;上下文选择&quot;]
    C --&amp;gt; M[&quot;记忆检索&quot;]
    C --&amp;gt; S[&quot;Skill 加载&quot;]
    M --&amp;gt; T[&quot;工具选择&quot;]
    S --&amp;gt; T
    T --&amp;gt; G{&quot;安全门&quot;}
    G -- &quot;低风险&quot; --&amp;gt; X[&quot;自动执行&quot;]
    G -- &quot;高风险&quot; --&amp;gt; H[&quot;人工确认&quot;]
    H --&amp;gt; X
    X --&amp;gt; O[&quot;观测结果&quot;]
    O --&amp;gt; E{&quot;验证通过&quot;}
    E -- &quot;否&quot; --&amp;gt; R[&quot;重试 / 回滚 / 改路&quot;]
    R --&amp;gt; P
    E -- &quot;是&quot; --&amp;gt; D[&quot;交付并更新状态&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这类案例的价值，不在于某一个单点功能，而在于把长期运行 Agent 所需的多个工程部件组织到一起。&lt;/p&gt;
&lt;h3&gt;7. 这一阶段的历史意义&lt;/h3&gt;
&lt;p&gt;Agent 成熟的标志，是从“会做”走向“可治理地做”。随着 Agent 获得长期记忆、工具权限、后台时间和执行能力，安全、审计、恢复、权限和责任边界变成系统中心。&lt;/p&gt;
&lt;h2&gt;八、总表：五个阶段的尝试、问题和优化&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;阶段&lt;/th&gt;
&lt;th&gt;大致时间&lt;/th&gt;
&lt;th&gt;主导工程问题&lt;/th&gt;
&lt;th&gt;典型技术&lt;/th&gt;
&lt;th&gt;暴露问题&lt;/th&gt;
&lt;th&gt;后续优化&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;对话与 Prompt&lt;/td&gt;
&lt;td&gt;2022-2023&lt;/td&gt;
&lt;td&gt;如何让模型听懂任务&lt;/td&gt;
&lt;td&gt;Zero-shot、Few-shot、Role Prompt、CoT、Self-Consistency&lt;/td&gt;
&lt;td&gt;无外部事实、无行动能力、Prompt 脆弱&lt;/td&gt;
&lt;td&gt;系统提示、结构化输出、评估、工具调用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;外部知识与工具&lt;/td&gt;
&lt;td&gt;2023&lt;/td&gt;
&lt;td&gt;如何让模型接触世界&lt;/td&gt;
&lt;td&gt;RAG、Plugins、Function Calling、Toolformer、MRKL、AutoGPT&lt;/td&gt;
&lt;td&gt;检索不准、工具错误、开环失控、权限弱&lt;/td&gt;
&lt;td&gt;rerank、JSON Schema、工具白名单、状态机、step budget&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;自主循环到工程编排&lt;/td&gt;
&lt;td&gt;2023-2025&lt;/td&gt;
&lt;td&gt;如何让多步任务可控&lt;/td&gt;
&lt;td&gt;ReAct、Prompt Chaining、Routing、Planner-Executor、Multi-agent、Context Engineering、Reasoning Models&lt;/td&gt;
&lt;td&gt;框架复杂、状态混乱、上下文污染、成本增加&lt;/td&gt;
&lt;td&gt;trace、checkpoint、compaction、模型路由、human-in-loop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;协议、界面与软件工程&lt;/td&gt;
&lt;td&gt;2024-2026&lt;/td&gt;
&lt;td&gt;如何进入真实工作环境&lt;/td&gt;
&lt;td&gt;MCP、连接器、Computer Use、Coding Agent、IDE Agent、CLI Agent&lt;/td&gt;
&lt;td&gt;供应链风险、GUI 脆弱、误操作、代码质量风险&lt;/td&gt;
&lt;td&gt;Registry、最小权限、沙箱、DOM/截图验证、测试与 diff review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;长期运行与 Harness&lt;/td&gt;
&lt;td&gt;2025-2026&lt;/td&gt;
&lt;td&gt;如何长期、可靠、可治理地运行&lt;/td&gt;
&lt;td&gt;Skills、Memory、Tasks、Heartbeat、Background Agent、Harness Engineering&lt;/td&gt;
&lt;td&gt;长期权限、记忆污染、Skill 注入、责任边界&lt;/td&gt;
&lt;td&gt;权限过期、审计日志、记忆治理、动作分级、回滚、人工确认&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;九、结论：Agent 成熟的标志是稳定&lt;/h2&gt;
&lt;p&gt;LLM Agent 的发展，是一个工程系统逐步补齐能力的故事。&lt;/p&gt;
&lt;p&gt;第一阶段，人们通过 Prompt 让模型更好地理解任务。第二阶段，人们通过 RAG 和 Tool Use 让模型获得外部知识并执行真实动作。第三阶段，人们通过 ReAct、Workflow 和 Context Engineering 让模型在多步任务中保持节奏。第四阶段，人们通过 MCP、Computer Use 和 Coding Agent 把 Agent 接入真实工作环境。第五阶段，人们通过 Skills、Memory、Heartbeat 和 Harness Engineering 把这些能力放进一个可控、可测、可审计、可恢复的运行时系统。&lt;/p&gt;
&lt;p&gt;这条路线可以进一步压缩成一句话：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;从生成文本，到调用工具；从调用工具，到编排任务；从编排任务，到管理上下文；从管理上下文，到长期运行；从长期运行，到可治理、可审计、可恢复。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Prompt 是语言控制，Context 是信息控制，Tool Use 是动作扩展，Workflow 是过程控制，MCP 是连接控制，Computer Use 是界面控制，Skills 是能力封装，Memory 与 Heartbeat 是时间控制，Harness 是这些控制机制的总和。&lt;/p&gt;
&lt;p&gt;这才是 LLM Agent 的工程发展史。&lt;/p&gt;
</content:encoded></item><item><title>Claude Code 源码阅读：Tool 系统如何让模型行动</title><link>https://blog.huangnv.online/posts/26522/2/2/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26522/2/2/</guid><description>梳理 Claude Code 的 Tool 接口、工具上下文、工具注册、并发调度、权限检查和 tool_result 回填机制。</description><pubDate>Fri, 22 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Claude Code 源码阅读：Tool 系统如何让模型行动&lt;/h1&gt;
&lt;p&gt;Agent 和普通聊天机器人的关键差异在于工具。模型本身只负责生成文本和结构化工具请求，真正的文件读取、命令执行、代码编辑、网络访问和子任务调度，都由运行时的 Tool 系统完成。&lt;/p&gt;
&lt;p&gt;在 Claude Code 里，Tool 系统可以拆成五层：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Tool 接口：描述工具能力、参数结构、执行函数和结果映射。&lt;/li&gt;
&lt;li&gt;ToolUseContext：给工具执行提供会话状态、权限状态、工具池、AppState、取消信号和进度回调。&lt;/li&gt;
&lt;li&gt;Tool Pool：把内置工具、MCP 工具和功能开关下的扩展工具组合成当前可用工具集。&lt;/li&gt;
&lt;li&gt;Tool Orchestration：处理一次模型响应里多个 &lt;code&gt;tool_use&lt;/code&gt; 的串行和并发调度。&lt;/li&gt;
&lt;li&gt;Tool Execution：完成单个工具的参数校验、权限检查、Hook、执行和 &lt;code&gt;tool_result&lt;/code&gt; 封装。&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;模型输出 tool_use&quot;] --&amp;gt; B[&quot;工具调度层&quot;]
    B --&amp;gt; C[&quot;查找工具定义&quot;]
    C --&amp;gt; D[&quot;解析与校验输入&quot;]
    D --&amp;gt; E[&quot;执行 Hook 与权限检查&quot;]
    E --&amp;gt; F[&quot;调用 tool.call()&quot;]
    F --&amp;gt; G[&quot;映射为 tool_result&quot;]
    G --&amp;gt; H[&quot;作为用户消息回到模型上下文&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;理解这条链路后，就能看清 Tool 系统的核心职责：它把模型的动作意图变成可控、可审计、可恢复的真实操作。&lt;/p&gt;
&lt;h2&gt;Tool：工具是运行时契约&lt;/h2&gt;
&lt;p&gt;普通函数只需要输入和输出。Agent 工具还要告诉模型和运行时更多信息：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;工具叫什么。&lt;/li&gt;
&lt;li&gt;工具能做什么。&lt;/li&gt;
&lt;li&gt;输入参数需要满足什么结构。&lt;/li&gt;
&lt;li&gt;工具是否启用。&lt;/li&gt;
&lt;li&gt;工具是否只读。&lt;/li&gt;
&lt;li&gt;工具能否并发执行。&lt;/li&gt;
&lt;li&gt;工具是否具有破坏性。&lt;/li&gt;
&lt;li&gt;工具调用前需要怎样检查权限。&lt;/li&gt;
&lt;li&gt;工具输出如何映射为模型协议里的 &lt;code&gt;tool_result&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;工具进度和结果如何显示给 UI。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Claude Code 的 &lt;code&gt;Tool&lt;/code&gt; 接口很大，因为它同时服务模型协议、终端 UI、权限系统、MCP、遥测、转录记录和工具搜索。自研 Agent 的第一版可以更小，但需要保留四个核心能力：schema、执行、权限、结果映射。&lt;/p&gt;
&lt;p&gt;一个最小 Tool 接口可以抽象成这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;type Tool&amp;lt;Input, Output&amp;gt; = {
  name: string
  description(input: Input): string
  inputSchema: Schema&amp;lt;Input&amp;gt;
  checkPermissions(input: Input, context: ToolContext): Promise&amp;lt;PermissionDecision&amp;gt;
  call(input: Input, context: ToolContext): Promise&amp;lt;Output&amp;gt;
  mapResult(output: Output, toolUseId: string): ToolResultBlock
  isConcurrencySafe(input: Input): boolean
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里最容易被低估的是 &lt;code&gt;mapResult()&lt;/code&gt;。工具内部可以返回结构化对象，但模型下一轮看到的是标准化的 &lt;code&gt;tool_result&lt;/code&gt; block。这个映射层决定了工具结果能否被模型稳定消费。&lt;/p&gt;
&lt;h2&gt;ToolUseContext：工具执行时的运行环境&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;ToolUseContext&lt;/code&gt; 是工具执行时拿到的上下文，也是把工具调用和当前会话连接起来的运行环境。&lt;/p&gt;
&lt;p&gt;它通常包含这些信息：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当前工具池与 MCP 连接信息。&lt;/li&gt;
&lt;li&gt;当前模型、思考配置、非交互模式等运行选项。&lt;/li&gt;
&lt;li&gt;AbortController，用于用户中断和流式回退。&lt;/li&gt;
&lt;li&gt;AppState 读写函数，用于读取权限模式、MCP 状态、UI 状态等。&lt;/li&gt;
&lt;li&gt;文件读取缓存、文件历史状态和归因状态。&lt;/li&gt;
&lt;li&gt;当前消息列表。&lt;/li&gt;
&lt;li&gt;进度与通知回调。&lt;/li&gt;
&lt;li&gt;当前 subagent 身份和 query tracking 信息。&lt;/li&gt;
&lt;li&gt;工具结果预算、内容替换状态等长上下文治理信息。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这解释了为什么工具不能只接收一个 JSON 参数。读文件、写文件、运行命令、调用 MCP、生成通知、更新 UI 进度和处理取消信号，都需要会话级环境参与。&lt;/p&gt;
&lt;h2&gt;Tool Pool：模型能看到哪些工具&lt;/h2&gt;
&lt;p&gt;Tool Pool 决定当前模型请求里会暴露哪些工具。Claude Code 的工具来源大致有三类：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;内置工具，例如 Bash、Read、Edit、Write、Glob、Grep、WebFetch、WebSearch、TodoWrite、Agent、Skill。&lt;/li&gt;
&lt;li&gt;根据功能开关、运行模式和环境变量启用的扩展工具。&lt;/li&gt;
&lt;li&gt;MCP 服务器提供的外部工具。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;工具池生成后，还会经过权限过滤和去重。显式拒绝的工具会在发送给模型前被过滤掉，MCP 工具和内置工具合并时需要保证名称唯一。这样可以减少模型看到不可用工具的概率，也能让运行时权限和模型可见能力保持一致。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;内置工具&quot;] --&amp;gt; D[&quot;Tool Pool&quot;]
    B[&quot;功能开关工具&quot;] --&amp;gt; D
    C[&quot;MCP 工具&quot;] --&amp;gt; D
    D --&amp;gt; E[&quot;权限过滤&quot;]
    E --&amp;gt; F[&quot;名称去重&quot;]
    F --&amp;gt; G[&quot;生成模型可见工具集&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;工具池和工具执行是两件事。工具池解决“模型能请求什么”，工具执行解决“模型请求后怎样安全执行”。&lt;/p&gt;
&lt;h2&gt;工具调度：多个 tool_use 如何排序&lt;/h2&gt;
&lt;p&gt;模型一次响应里可能输出多个 &lt;code&gt;tool_use&lt;/code&gt;。例如它可能同时请求读取两个文件，再请求编辑一个文件。运行时需要判断哪些工具可以并发，哪些工具必须串行。&lt;/p&gt;
&lt;p&gt;Claude Code 的调度依据是 &lt;code&gt;isConcurrencySafe(input)&lt;/code&gt;。这比简单判断“只读工具就并发”更稳，因为并发安全取决于具体工具和具体输入。一个工具即使看起来只读，也可能依赖共享状态；一个工具即使可能写入，也可能在某些输入下不互相影响。&lt;/p&gt;
&lt;p&gt;调度逻辑可以简化为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for (const batch of partitionToolCalls(toolUses)) {
  if (batch.isConcurrencySafe) {
    runConcurrently(batch.blocks)
  } else {
    runSerially(batch.blocks)
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;并发批次会一起执行，串行批次一次执行一个。工具执行过程中如果产生 &lt;code&gt;contextModifier&lt;/code&gt;，串行路径会立即更新上下文；并发路径会先收集修改器，再按工具顺序应用。这个细节能避免共享上下文在并发执行中被不可预测地改写。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;tool_use 列表&quot;] --&amp;gt; B[&quot;按 isConcurrencySafe 分批&quot;]
    B --&amp;gt; C{&quot;当前批次可并发?&quot;}
    C --&amp;gt;|是| D[&quot;并发执行&quot;]
    C --&amp;gt;|否| E[&quot;串行执行&quot;]
    D --&amp;gt; F[&quot;收集结果与上下文修改&quot;]
    E --&amp;gt; F
    F --&amp;gt; G[&quot;生成 tool_result 消息&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;单个工具的执行生命周期&lt;/h2&gt;
&lt;p&gt;单个工具执行由 &lt;code&gt;runToolUse()&lt;/code&gt; 推进。它会包住完整生命周期，再在校验和授权通过后调用 &lt;code&gt;tool.call()&lt;/code&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;根据 &lt;code&gt;tool_use.name&lt;/code&gt; 查找工具定义，并兼容旧名称 alias。&lt;/li&gt;
&lt;li&gt;检查工具是否存在，不存在时返回错误型 &lt;code&gt;tool_result&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;用 &lt;code&gt;inputSchema.safeParse()&lt;/code&gt; 校验模型输出的参数结构。&lt;/li&gt;
&lt;li&gt;调用工具自己的 &lt;code&gt;validateInput()&lt;/code&gt; 做上下文相关校验。&lt;/li&gt;
&lt;li&gt;执行 PreToolUse Hook，允许 Hook 追加上下文、修改输入或阻止继续。&lt;/li&gt;
&lt;li&gt;调用权限链路，综合配置、Hook、classifier、用户确认和非交互模式。&lt;/li&gt;
&lt;li&gt;权限允许后调用 &lt;code&gt;tool.call()&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;记录执行耗时、结果大小和必要的遥测信息。&lt;/li&gt;
&lt;li&gt;运行 PostToolUse Hook。&lt;/li&gt;
&lt;li&gt;把工具输出映射成 &lt;code&gt;tool_result&lt;/code&gt;，追加到下一轮模型上下文。&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;runToolUse()&quot;] --&amp;gt; B[&quot;findToolByName()&quot;]
    B --&amp;gt; C[&quot;inputSchema.safeParse()&quot;]
    C --&amp;gt; D[&quot;validateInput()&quot;]
    D --&amp;gt; E[&quot;PreToolUse Hook&quot;]
    E --&amp;gt; F[&quot;权限决策&quot;]
    F --&amp;gt; G{&quot;allow?&quot;}
    G --&amp;gt;|否| H[&quot;返回错误型 tool_result&quot;]
    G --&amp;gt;|是| I[&quot;tool.call()&quot;]
    I --&amp;gt; J[&quot;PostToolUse Hook&quot;]
    J --&amp;gt; K[&quot;mapResult()&quot;]
    K --&amp;gt; L[&quot;createUserMessage(tool_result)&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个顺序很重要。参数结构无效时，不应该弹出权限确认；权限拒绝时，不应该执行工具；工具失败时，也要返回配对的错误结果。&lt;/p&gt;
&lt;h2&gt;权限：工具执行前的统一安全边界&lt;/h2&gt;
&lt;p&gt;工具系统最关键的安全边界是权限决策。模型可能请求删除文件、覆盖配置、运行危险命令、访问用户未授权目录，运行时必须在执行前统一判断。&lt;/p&gt;
&lt;p&gt;Claude Code 的权限链路会综合多个来源：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;显式 allow、deny、ask 规则。&lt;/li&gt;
&lt;li&gt;当前 permission mode。&lt;/li&gt;
&lt;li&gt;工具自己的 &lt;code&gt;checkPermissions()&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;PreToolUse Hook 和 PermissionRequest Hook。&lt;/li&gt;
&lt;li&gt;Bash 类命令的安全分类。&lt;/li&gt;
&lt;li&gt;交互式用户确认。&lt;/li&gt;
&lt;li&gt;非交互模式下的自动拒绝或外部授权机制。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;权限结果通常落在三类：允许、拒绝、询问。允许会继续执行工具；拒绝会生成错误型 &lt;code&gt;tool_result&lt;/code&gt;；询问会进入交互式确认或非交互处理。这样做的价值在于所有外部动作都经过同一个审计点，工具实现可以专注于能力本身。&lt;/p&gt;
&lt;h2&gt;tool_result：失败也必须回到模型&lt;/h2&gt;
&lt;p&gt;Agent 循环中最重要的不变量之一是 &lt;code&gt;tool_use&lt;/code&gt; 和 &lt;code&gt;tool_result&lt;/code&gt; 配对。模型发起工具调用后，无论执行成功、参数错误、权限拒绝、工具不存在、用户中断，运行时都要返回对应的 &lt;code&gt;tool_result&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;成功时，工具结果会经过映射函数转成模型可读内容。失败时，运行时会生成 &lt;code&gt;is_error: true&lt;/code&gt; 的结果，并把错误原因写入结果内容。这样下一轮模型可以看到失败原因，并决定改参、换工具或向用户解释。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;assistant: tool_use
user: tool_result
assistant: 根据结果继续回答
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这也是 Tool 系统和 Agent 主循环之间的核心契约：工具执行层负责保证结果结构完整，主循环负责把结果放回下一次模型调用。&lt;/p&gt;
&lt;h2&gt;设计启发&lt;/h2&gt;
&lt;p&gt;如果要实现一个自己的 Agent Tool 系统，可以先抓住下面几个边界：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;层次&lt;/th&gt;
&lt;th&gt;职责&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tool 定义&lt;/td&gt;
&lt;td&gt;声明名称、描述、schema、权限、执行和结果映射&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool Registry&lt;/td&gt;
&lt;td&gt;生成当前可用工具集，并过滤不可用工具&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool Scheduler&lt;/td&gt;
&lt;td&gt;对多个工具请求做并发和串行调度&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool Executor&lt;/td&gt;
&lt;td&gt;校验输入、执行 Hook、检查权限、调用工具&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Result Mapper&lt;/td&gt;
&lt;td&gt;把成功或失败结果转换成 &lt;code&gt;tool_result&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;第一版不需要复制 Claude Code 的完整复杂度。真正需要保留的是几个不变量：模型只生成请求；运行时负责校验和授权；工具输出必须标准化；失败也要回到模型；并发判断要由工具能力声明决定。&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;Tool 系统让 Agent 从“会回答”变成“能行动”。Claude Code 的设计重点可以概括为三句话：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Tool 接口把模型描述、输入 schema、执行函数、权限检查和结果映射放在同一个抽象下。&lt;/li&gt;
&lt;li&gt;ToolUseContext 把单次工具调用接入会话状态、权限状态、取消信号、MCP、UI 和上下文治理。&lt;/li&gt;
&lt;li&gt;工具调度和工具执行分层处理：前者决定多个工具如何跑，后者保证每个工具调用都经过校验、授权、执行和 &lt;code&gt;tool_result&lt;/code&gt; 回填。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;理解 Tool 系统后，再回看 Agent 主循环，&lt;code&gt;tool_use&lt;/code&gt; 到 &lt;code&gt;tool_result&lt;/code&gt; 的闭环就会更清晰：模型提出动作，运行时判断动作是否可执行，工具完成动作，结果再进入模型上下文。&lt;/p&gt;
</content:encoded></item><item><title>Claude Code 源码阅读：QueryEngine 与 query 主循环</title><link>https://blog.huangnv.online/posts/26522/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26522/1/1/</guid><description>对照 claude-code-sourcemap 源码梳理 Claude Code 的用户回合、模型流式响应、工具调用、工具结果回填和上下文治理机制。</description><pubDate>Fri, 22 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Claude Code 源码阅读：QueryEngine 与 query 主循环&lt;/h1&gt;
&lt;p&gt;阅读 Claude Code 的 Agent 机制，最适合从一次用户输入开始：用户提交问题后，系统构造上下文，模型流式输出内容；如果模型输出了 &lt;code&gt;tool_use&lt;/code&gt;，运行时执行工具，把 &lt;code&gt;tool_result&lt;/code&gt; 作为新的用户消息放回上下文，再次调用模型。这个过程持续到模型不再请求工具，本轮对话才结束。&lt;/p&gt;
&lt;p&gt;这篇文章重点看两个入口：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;QueryEngine.ts&lt;/code&gt;：headless 与 SDK 会话入口，负责维护会话状态，并把一次用户输入提交给 &lt;code&gt;query()&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;query.ts&lt;/code&gt;：Agent 主循环，负责上下文治理、模型调用、工具执行、恢复分支和停止条件。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;相关的辅助模块可以按职责理解：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;config.ts&lt;/code&gt;：在 &lt;code&gt;query()&lt;/code&gt; 入口快照运行时配置，例如 sessionId、流式工具执行开关、工具摘要开关和 fast mode。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;deps.ts&lt;/code&gt;：把模型调用、microcompact、autocompact、UUID 生成抽成 &lt;code&gt;QueryDeps&lt;/code&gt;，方便测试注入。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;stopHooks.ts&lt;/code&gt;：处理模型完成后的 stop hooks、任务状态、记忆提取、prompt suggestion 等收尾逻辑。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tokenBudget.ts&lt;/code&gt;：在 token budget 功能开启时判断是否继续推动本轮输出。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;用户回合与循环迭代&lt;/h2&gt;
&lt;p&gt;一次用户输入可以称为一个用户回合。用户回合内部可能包含多次循环迭代：模型先请求读文件工具，运行时返回文件内容；模型再请求编辑工具，运行时返回编辑结果；模型最后输出总结。这些内部迭代通常不会直接暴露给用户，但它们决定了 Agent 是否具备持续行动能力。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;用户提交输入&quot;] --&amp;gt; B[&quot;QueryEngine.submitMessage()&quot;]
    B --&amp;gt; C[&quot;整理会话状态与上下文&quot;]
    C --&amp;gt; D[&quot;query()&quot;]
    D --&amp;gt; E[&quot;queryLoop&quot;]
    E --&amp;gt; F[&quot;调用模型流式接口&quot;]
    F --&amp;gt; G{&quot;流中出现 tool_use?&quot;}
    G --&amp;gt;|是| H[&quot;执行工具&quot;]
    H --&amp;gt; I[&quot;生成 tool_result 用户消息&quot;]
    I --&amp;gt; E
    G --&amp;gt;|否| J[&quot;执行 stop hooks&quot;]
    J --&amp;gt; K[&quot;结束本轮用户回合&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个图里的关键点是：&lt;code&gt;tool_use&lt;/code&gt; 和 &lt;code&gt;tool_result&lt;/code&gt; 必须配对。模型提出动作请求后，运行时需要返回对应结果；结果进入下一次模型调用，模型才能基于观察继续推理。&lt;/p&gt;
&lt;h2&gt;QueryEngine：会话入口&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;QueryEngine&lt;/code&gt; 的文件注释说明，一个 &lt;code&gt;QueryEngine&lt;/code&gt; 对应一个 conversation，每次 &lt;code&gt;submitMessage()&lt;/code&gt; 开启同一会话中的新 turn。它内部持有 &lt;code&gt;mutableMessages&lt;/code&gt;、&lt;code&gt;abortController&lt;/code&gt;、&lt;code&gt;permissionDenials&lt;/code&gt;、&lt;code&gt;readFileState&lt;/code&gt; 和 &lt;code&gt;totalUsage&lt;/code&gt; 等状态。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;submitMessage()&lt;/code&gt; 的职责可以拆成四步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;从 &lt;code&gt;QueryEngineConfig&lt;/code&gt; 读取运行参数，包括 &lt;code&gt;cwd&lt;/code&gt;、&lt;code&gt;tools&lt;/code&gt;、&lt;code&gt;commands&lt;/code&gt;、&lt;code&gt;mcpClients&lt;/code&gt;、&lt;code&gt;canUseTool&lt;/code&gt;、模型配置和预算配置。&lt;/li&gt;
&lt;li&gt;处理用户输入，构造消息、附件、系统上下文和工具使用上下文。&lt;/li&gt;
&lt;li&gt;调用 &lt;code&gt;query({ ... })&lt;/code&gt;，并消费它产出的 async generator 事件。&lt;/li&gt;
&lt;li&gt;把 assistant、user、compact boundary 等消息写回会话状态和 transcript。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;简化后的调用关系如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart LR
    A[&quot;CLI、SDK、Headless 输入&quot;] --&amp;gt; B[&quot;QueryEngine&quot;]
    B --&amp;gt; C[&quot;submitMessage()&quot;]
    C --&amp;gt; D[&quot;processUserInputContext&quot;]
    D --&amp;gt; E[&quot;query()&quot;]
    E --&amp;gt; F[&quot;SDKMessage 与 transcript&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此，&lt;code&gt;QueryEngine&lt;/code&gt; 更像会话编排层。真正复杂的模型循环和工具循环集中在 &lt;code&gt;query.ts&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;query.ts：主循环骨架&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;query()&lt;/code&gt; 对外暴露 async generator，内部通过 &lt;code&gt;yield* queryLoop(...)&lt;/code&gt; 进入真正的循环。&lt;code&gt;queryLoop()&lt;/code&gt; 维护一个跨迭代的 &lt;code&gt;State&lt;/code&gt;，其中包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;messages&lt;/code&gt;：当前可发送给模型的消息上下文。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;toolUseContext&lt;/code&gt;：工具执行需要的环境，包括工具列表、权限、AbortController、AppState 等。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;autoCompactTracking&lt;/code&gt;：自动压缩状态。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;maxOutputTokensRecoveryCount&lt;/code&gt;：输出上限恢复次数。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pendingToolUseSummary&lt;/code&gt;：工具摘要的异步任务。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;stopHookActive&lt;/code&gt; 与 &lt;code&gt;turnCount&lt;/code&gt;：停止钩子与迭代计数。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;主循环的简化伪代码如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;while (true) {
  messagesForQuery = prepareMessages(messages)
  messagesForQuery = applyCompaction(messagesForQuery)

  assistantMessages = []
  toolUseBlocks = []
  needsFollowUp = false

  for await (const message of deps.callModel(...)) {
    yield message
    collectAssistantMessage(message)
    collectToolUseBlocks(message)
  }

  if (!needsFollowUp) {
    runStopHooks()
    return completed
  }

  toolResults = await runTools(toolUseBlocks)
  messages = [
    ...messagesForQuery,
    ...assistantMessages,
    ...toolResults,
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真实源码比这复杂得多，但主线很稳定：准备上下文，调用模型，收集 &lt;code&gt;tool_use&lt;/code&gt;，执行工具，把 &lt;code&gt;tool_result&lt;/code&gt; 回填，再进入下一轮。&lt;/p&gt;
&lt;h2&gt;模型调用前的上下文治理&lt;/h2&gt;
&lt;p&gt;在调用模型之前，&lt;code&gt;queryLoop()&lt;/code&gt; 会对上下文做一组治理动作：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;applyToolResultBudget()&lt;/code&gt;：限制历史工具结果的体积，避免单个工具输出拖垮上下文。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;snipCompactIfNeeded()&lt;/code&gt;：在开启 HISTORY_SNIP 时裁剪历史。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;deps.microcompact()&lt;/code&gt;：执行 microcompact。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;contextCollapse.applyCollapsesIfNeeded()&lt;/code&gt;：在功能开启时投影折叠后的上下文视图。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;deps.autocompact()&lt;/code&gt;：在接近窗口上限时生成摘要消息。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;appendSystemContext()&lt;/code&gt; 与 &lt;code&gt;prependUserContext()&lt;/code&gt;：把系统上下文和用户上下文拼入模型请求。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这部分说明 Claude Code 的 Agent 循环包含持续的上下文窗口管理。长会话很容易在工具输出、历史消息和附件中膨胀，真实产品需要在每次模型调用前控制上下文规模。&lt;/p&gt;
&lt;h2&gt;流式响应如何驱动工具执行&lt;/h2&gt;
&lt;p&gt;模型响应通过 &lt;code&gt;deps.callModel(...)&lt;/code&gt; 以 async stream 形式返回。stream 中可能包含文本、thinking block、&lt;code&gt;tool_use&lt;/code&gt; block、API 错误消息等。&lt;/p&gt;
&lt;p&gt;源码里有一个重要细节：&lt;code&gt;query.ts&lt;/code&gt; 以 streaming 过程中实际出现的 &lt;code&gt;tool_use&lt;/code&gt; block 作为主要判断依据。注释中明确写到 &lt;code&gt;stop_reason === &quot;tool_use&quot;&lt;/code&gt; 并不总可靠，所以循环会直接收集 assistant message 里的 &lt;code&gt;tool_use&lt;/code&gt; block，并用 &lt;code&gt;needsFollowUp&lt;/code&gt; 决定是否进入工具执行阶段。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;deps.callModel(...)&quot;] --&amp;gt; B[&quot;assistant stream&quot;]
    B --&amp;gt; C[&quot;yield 给上层 UI 与 SDK&quot;]
    B --&amp;gt; D[&quot;收集 assistantMessages&quot;]
    D --&amp;gt; E{&quot;message.content 中有 tool_use?&quot;}
    E --&amp;gt;|有| F[&quot;push 到 toolUseBlocks&quot;]
    F --&amp;gt; G[&quot;needsFollowUp = true&quot;]
    E --&amp;gt;|无| H[&quot;等待 stream 结束&quot;]
    G --&amp;gt; I[&quot;stream 结束后执行工具&quot;]
    H --&amp;gt; J[&quot;进入完成检查&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个设计更贴近流式模型的实际行为：循环关心实际出现的结构化内容，减少对单个 stop 字段的依赖。&lt;/p&gt;
&lt;h2&gt;工具调用：runTools 与 StreamingToolExecutor&lt;/h2&gt;
&lt;p&gt;工具执行有两条路径：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;开启流式工具执行时，&lt;code&gt;StreamingToolExecutor&lt;/code&gt; 会在 &lt;code&gt;tool_use&lt;/code&gt; block 流入时尽早排队执行。&lt;/li&gt;
&lt;li&gt;常规路径在模型响应结束后调用 &lt;code&gt;runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;runTools()&lt;/code&gt; 会根据工具的 &lt;code&gt;isConcurrencySafe(input)&lt;/code&gt; 把工具调用分批：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;连续的 concurrency-safe 工具可以并发执行，默认最大并发数来自 &lt;code&gt;CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY&lt;/code&gt;，未设置时为 10。&lt;/li&gt;
&lt;li&gt;需要独占上下文的工具串行执行，执行过程中可以通过 &lt;code&gt;contextModifier&lt;/code&gt; 更新 &lt;code&gt;ToolUseContext&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;Tool&lt;/code&gt; 接口里同时有 &lt;code&gt;isReadOnly(input)&lt;/code&gt; 和 &lt;code&gt;isConcurrencySafe(input)&lt;/code&gt;。并发调度使用的是 &lt;code&gt;isConcurrencySafe&lt;/code&gt;，默认值为 &lt;code&gt;false&lt;/code&gt;，因此未显式声明安全的工具会走保守路径。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;toolUseBlocks&quot;] --&amp;gt; B[&quot;partitionToolCalls()&quot;]
    B --&amp;gt; C{&quot;isConcurrencySafe?&quot;}
    C --&amp;gt;|是| D[&quot;runToolsConcurrently()&quot;]
    C --&amp;gt;|否| E[&quot;runToolsSerially()&quot;]
    D --&amp;gt; F[&quot;runToolUse()&quot;]
    E --&amp;gt; F
    F --&amp;gt; G[&quot;权限检查&quot;]
    G --&amp;gt; H[&quot;执行工具&quot;]
    H --&amp;gt; I[&quot;生成 tool_result&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;runToolUse()&lt;/code&gt; 会先按名称找到工具，再处理 alias、权限检查、取消信号和异常。如果工具不存在或执行出错，它会生成 &lt;code&gt;is_error: true&lt;/code&gt; 的 &lt;code&gt;tool_result&lt;/code&gt;，保证模型看到的是一个结构完整的失败结果。&lt;/p&gt;
&lt;h2&gt;工具结果如何回到下一轮模型调用&lt;/h2&gt;
&lt;p&gt;工具执行完成后，&lt;code&gt;query.ts&lt;/code&gt; 会把工具结果规范化为 API 可消费的 user message，并追加到下一轮上下文：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;assistant: tool_use(Read)
user: tool_result(Read output)
assistant: 基于读取结果继续推理
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;源码还会在工具后处理附件、记忆预取、技能发现、队列命令、MCP 工具刷新等逻辑。最后构造新的 &lt;code&gt;state&lt;/code&gt;，进入下一次 &lt;code&gt;while (true)&lt;/code&gt; 迭代。&lt;/p&gt;
&lt;p&gt;这就是 Agent 行动闭环的核心：模型只提出动作意图，运行时负责执行动作并把观察结果喂回模型。模型能否继续工作，取决于 &lt;code&gt;tool_use.id&lt;/code&gt; 与 &lt;code&gt;tool_result.tool_use_id&lt;/code&gt; 的配对是否完整。&lt;/p&gt;
&lt;h2&gt;没有 tool_use 后如何结束&lt;/h2&gt;
&lt;p&gt;当本轮模型响应没有产生 &lt;code&gt;tool_use&lt;/code&gt;，&lt;code&gt;query.ts&lt;/code&gt; 会进入完成检查：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;处理 prompt too long、media size、max output tokens 等可恢复错误。&lt;/li&gt;
&lt;li&gt;对 API 错误执行 stop failure hooks。&lt;/li&gt;
&lt;li&gt;调用 &lt;code&gt;handleStopHooks()&lt;/code&gt; 运行 stop hooks。&lt;/li&gt;
&lt;li&gt;在 TOKEN_BUDGET 功能开启时，通过 &lt;code&gt;checkTokenBudget()&lt;/code&gt; 判断是否追加一条 meta 用户消息继续推动输出。&lt;/li&gt;
&lt;li&gt;所有检查完成后返回 &lt;code&gt;{ reason: &quot;completed&quot; }&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;handleStopHooks()&lt;/code&gt; 会把本轮消息、系统提示、用户上下文、系统上下文和工具上下文组成 hook context，再执行 stop hooks、任务分类、记忆提取、prompt suggestion、auto dream 等收尾工作。它还可能返回 blocking errors，让主循环把错误消息追加进上下文后继续迭代。&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;Claude Code 的 Agent 主循环可以概括为三条不变量：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;一次用户输入会进入 &lt;code&gt;QueryEngine.submitMessage()&lt;/code&gt;，再交给 &lt;code&gt;query()&lt;/code&gt; 推进。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;queryLoop()&lt;/code&gt; 每轮都围绕“准备上下文、调用模型、收集工具请求、执行工具、回填结果”展开。&lt;/li&gt;
&lt;li&gt;只要模型输出 &lt;code&gt;tool_use&lt;/code&gt;，运行时就必须产生对应的 &lt;code&gt;tool_result&lt;/code&gt;；没有新的 &lt;code&gt;tool_use&lt;/code&gt; 后，才进入 stop hooks、预算检查和完成返回。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;理解这三点后，再看 fallback model、compact、stop hooks、streaming tool execution、token budget 等复杂分支，就能把它们放回同一个框架中：它们都在服务一个目标，让模型与本地环境之间形成稳定、可恢复、可持续的行动闭环。&lt;/p&gt;
</content:encoded></item><item><title>MySQL 行级锁加锁过程详解</title><link>https://blog.huangnv.online/posts/26514/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26514/1/1/</guid><description>深入解析 InnoDB 在可重复读隔离级别下对行级锁的加锁规则，涵盖 Record Lock、Gap Lock、Next-Key Lock 的退化条件与唯一索引 / 非唯一索引的加锁差异。</description><pubDate>Thu, 14 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;MySQL 行级锁加锁过程详解&lt;/h1&gt;
&lt;p&gt;行级锁是 InnoDB 实现高并发事务的核心机制。不同隔离级别下，行级锁的种类不同：读已提交（RC）下只有记录锁；可重复读（RR）下引入间隙锁和临键锁来防止幻读。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;graph TD
    A[&quot;行级锁&quot;] --&amp;gt; B[&quot;Record Lock&amp;lt;br/&amp;gt;记录锁&quot;]
    A --&amp;gt; C[&quot;Gap Lock&amp;lt;br/&amp;gt;间隙锁&quot;]
    A --&amp;gt; D[&quot;Next-Key Lock&amp;lt;br/&amp;gt;临键锁&quot;]

    D --&amp;gt; B
    D --&amp;gt; C

    style A fill:#f9f,stroke:#333
    style B fill:#9f9,stroke:#333
    style C fill:#ff9,stroke:#333
    style D fill:#9ff,stroke:#333
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;行级锁的种类&lt;/h2&gt;
&lt;h3&gt;Record Lock&lt;/h3&gt;
&lt;p&gt;Record Lock（记录锁）锁住的是一条索引记录，分为 S 锁（共享锁）和 X 锁（排他锁）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一个事务对某条记录加了 &lt;strong&gt;S 型记录锁&lt;/strong&gt;后，其他事务可以继续加 S 锁（兼容），但不能加 X 锁（互斥）。&lt;/li&gt;
&lt;li&gt;一个事务对某条记录加了 &lt;strong&gt;X 型记录锁&lt;/strong&gt;后，其他事务既不能加 S 锁，也不能加 X 锁。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;举例，当事务执行以下语句时：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;BEGIN;
SELECT * FROM t_test WHERE id = 1 FOR UPDATE;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;事务会对主键 &lt;code&gt;id = 1&lt;/code&gt; 的记录加上 X 型记录锁。其他事务对该记录的删除或更新操作会被阻塞。但其他事务插入一条 &lt;code&gt;id = 1&lt;/code&gt; 的新记录&lt;strong&gt;不会被阻塞&lt;/strong&gt;，而是直接报主键冲突错误——这是因为主键唯一性约束的保护。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E5%8A%A0%E9%94%81%E8%BF%87%E7%A8%8B/file-20260513232525470.png&quot; alt=&quot;Record Lock 加锁示意&quot; /&gt;&lt;/p&gt;
&lt;p&gt;事务执行 &lt;code&gt;COMMIT&lt;/code&gt; 后，过程中生成的所有锁都会被释放。&lt;/p&gt;
&lt;h3&gt;Gap Lock&lt;/h3&gt;
&lt;p&gt;Gap Lock（间隙锁）存在于可重复读和串行化隔离级别，目的是&lt;strong&gt;防止幻读&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;假设表中存在一个范围为 &lt;code&gt;(3, 5)&lt;/code&gt; 的间隙锁，其他事务就无法插入 &lt;code&gt;id = 4&lt;/code&gt; 的记录，从而有效阻止幻影记录的产生。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E5%8A%A0%E9%94%81%E8%BF%87%E7%A8%8B/file-20260513232656481.png&quot; alt=&quot;Gap Lock 加锁示意&quot; /&gt;&lt;/p&gt;
&lt;p&gt;间隙锁虽然有 S 型和 X 型之分，但两者之间&lt;strong&gt;完全兼容&lt;/strong&gt;——两个事务可以同时持有包含共同范围的间隙锁，不存在互斥关系。这是因为间隙锁的设计目标仅仅是阻止插入操作，而阻止插入不需要区分读写。&lt;/p&gt;
&lt;h3&gt;Next-Key Lock&lt;/h3&gt;
&lt;p&gt;Next-Key Lock（临键锁）是 Record Lock + Gap Lock 的组合，锁定一个范围&lt;strong&gt;并&lt;/strong&gt;锁定记录本身，即前开后闭区间 &lt;code&gt;(a, b]&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;假设表中存在一个范围为 &lt;code&gt;(3, 5]&lt;/code&gt; 的 Next-Key Lock，其他事务既不能插入 &lt;code&gt;id = 4&lt;/code&gt; 的记录，也不能修改或删除 &lt;code&gt;id = 5&lt;/code&gt; 的记录。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E5%8A%A0%E9%94%81%E8%BF%87%E7%A8%8B/file-20260513232843846.png&quot; alt=&quot;Next-Key Lock 加锁示意&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Next-Key Lock 兼具两种能力：&lt;strong&gt;保护已有记录&lt;/strong&gt;（通过记录锁）和&lt;strong&gt;阻止新记录插入到被保护范围前面的间隙中&lt;/strong&gt;（通过间隙锁）。&lt;/p&gt;
&lt;p&gt;但 Next-Key Lock 中的记录锁部分仍然遵循 S/X 兼容性规则。例如，一个事务持有了范围为 &lt;code&gt;(1, 10]&lt;/code&gt; 的 X 型 Next-Key Lock，另一个事务再获取相同范围的 X 型 Next-Key Lock 时会被阻塞——冲突来源于记录锁部分的互斥，而非间隙锁部分。&lt;/p&gt;
&lt;h2&gt;MySQL 是怎么加行级锁的&lt;/h2&gt;
&lt;p&gt;行级锁的加锁规则比较复杂，不同场景下加锁形式不同。核心规则如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;加锁对象&lt;/strong&gt;：索引记录。锁加在索引上，而非物理行上。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;默认锁粒度&lt;/strong&gt;：Next-Key Lock（前开后闭区间 &lt;code&gt;(a, b]&lt;/code&gt;），由记录锁和间隙锁组合而成。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;退化条件&lt;/strong&gt;：在仅靠记录锁或间隙锁就能避免幻读的场景下，Next-Key Lock 会退化。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;接下来用以下表结构进行实验说明：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE TABLE `user` (
  `id`   bigint       NOT NULL AUTO_INCREMENT,
  `name` varchar(30)  NOT NULL,
  `age`  int          NOT NULL,
  PRIMARY KEY (`id`),
  KEY `index_age` (`age`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中 &lt;code&gt;id&lt;/code&gt; 是主键索引（唯一索引），&lt;code&gt;age&lt;/code&gt; 是普通索引（非唯一索引），&lt;code&gt;name&lt;/code&gt; 是普通列。表中已有以下数据：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E5%8A%A0%E9%94%81%E8%BF%87%E7%A8%8B/file-20260513234346221.png&quot; alt=&quot;实验数据&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;唯一索引等值查询&lt;/h3&gt;
&lt;p&gt;唯一索引等值查询时，Next-Key Lock 的退化取决于查询记录是否存在：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;场景&lt;/th&gt;
&lt;th&gt;退化结果&lt;/th&gt;
&lt;th&gt;原因&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;记录&lt;strong&gt;存在&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;退化为&lt;strong&gt;记录锁&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;主键唯一性已阻止相同记录的插入，记录锁足以防止幻读&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;记录&lt;strong&gt;不存在&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;退化为&lt;strong&gt;间隙锁&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;不存在的记录无法加记录锁，只能锁住该位置的间隙以阻止插入&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[&quot;唯一索引等值查询&quot;] --&amp;gt; B{&quot;记录是否存在？&quot;}
    B --&amp;gt;|存在| C[&quot;Next-Key Lock 退化为&amp;lt;br/&amp;gt;Record Lock&quot;]
    B --&amp;gt;|不存在| D[&quot;Next-Key Lock 退化为&amp;lt;br/&amp;gt;Gap Lock&quot;]

    C --&amp;gt; C1[&quot;阻止其他事务&amp;lt;br/&amp;gt;更新/删除该记录&quot;]
    D --&amp;gt; D1[&quot;阻止其他事务&amp;lt;br/&amp;gt;在该间隙插入记录&quot;]

    style C fill:#9f9,stroke:#333
    style D fill:#ff9,stroke:#333
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;记录存在&lt;/h4&gt;
&lt;p&gt;假设事务 A 执行等值查询，查询的记录存在于表中：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;BEGIN;
SELECT * FROM user WHERE id = 1 FOR UPDATE;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;事务 A 会在 &lt;code&gt;id = 1&lt;/code&gt; 的记录上加 &lt;strong&gt;X 型记录锁&lt;/strong&gt;（Next-Key Lock 退化而来）。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E5%8A%A0%E9%94%81%E8%BF%87%E7%A8%8B/file-20260513235955998.png&quot; alt=&quot;唯一索引等值查询 - 记录存在&quot; /&gt;&lt;/p&gt;
&lt;p&gt;其他事务对 &lt;code&gt;id = 1&lt;/code&gt; 的记录执行更新或删除操作时会被阻塞，因为这些操作也需要加 X 型记录锁，而 X 锁与 X 锁互斥。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E5%8A%A0%E9%94%81%E8%BF%87%E7%A8%8B/file-20260514000024974.png&quot; alt=&quot;阻塞示例&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;退化原因分析&lt;/strong&gt;：在唯一索引等值查询且记录存在的场景下，仅靠记录锁就能避免幻读：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;主键唯一性阻止了其他事务插入 &lt;code&gt;id = 1&lt;/code&gt; 的新记录（主键冲突），保证结果集中不会&quot;多出&quot;记录。&lt;/li&gt;
&lt;li&gt;记录锁阻止了其他事务删除 &lt;code&gt;id = 1&lt;/code&gt; 的记录，保证结果集中不会&quot;丢失&quot;记录。&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;记录不存在&lt;/h4&gt;
&lt;p&gt;假设事务 A 执行等值查询，查询的记录不存在于表中：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;BEGIN;
SELECT * FROM user WHERE id = 2 FOR UPDATE;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;此时事务 A 会在 &lt;code&gt;id = 5&lt;/code&gt; 的主键索引上加&lt;strong&gt;间隙锁&lt;/strong&gt;，锁住的范围是 &lt;code&gt;(1, 5)&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E5%8A%A0%E9%94%81%E8%BF%87%E7%A8%8B/file-20260514000713459.png&quot; alt=&quot;唯一索引等值查询 - 记录不存在&quot; /&gt;&lt;/p&gt;
&lt;p&gt;其他事务插入 &lt;code&gt;id&lt;/code&gt; 为 2、3、4 的记录时会被阻塞。但插入 &lt;code&gt;id = 1&lt;/code&gt; 或 &lt;code&gt;id = 5&lt;/code&gt; 的记录不会被间隙锁阻塞——而是报主键冲突错误，因为这两条记录已经存在。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;退化原因分析&lt;/strong&gt;：锁加在索引上，而查询的记录本身不存在，自然无法对其加记录锁。InnoDB 转而在该位置的前后边界索引记录之间加间隙锁，锁住 &lt;code&gt;(1, 5)&lt;/code&gt; 这个区间以阻止新记录插入。&lt;/p&gt;
&lt;h3&gt;唯一索引（主键索引）范围查询&lt;/h3&gt;
</content:encoded></item><item><title>MySQL 锁机制详解：全局锁、表级锁与意向锁</title><link>https://blog.huangnv.online/posts/26511/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26511/1/1/</guid><description>系统梳理 MySQL 各层锁的设计意图与加锁规则，涵盖全局锁、表级锁（MDL、意向锁、AUTO-INC 锁）以及行级锁（Record Lock、Gap Lock、Next-Key Lock、插入意向锁）的核心原理与兼容性关系。</description><pubDate>Mon, 11 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;MySQL 锁机制详解：全局锁、表级锁与意向锁&lt;/h1&gt;
&lt;p&gt;MySQL 的锁机制按粒度可以分为&lt;strong&gt;全局锁&lt;/strong&gt;、&lt;strong&gt;表级锁&lt;/strong&gt;和&lt;strong&gt;行级锁&lt;/strong&gt;三个层次。本文系统梳理各层锁的设计意图、加锁规则与兼容性关系。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;graph TD
    A[&quot;MySQL 锁&quot;] --&amp;gt; B[&quot;全局锁&quot;]
    A --&amp;gt; C[&quot;表级锁&quot;]
    A --&amp;gt; D[&quot;行级锁&quot;]

    C --&amp;gt; C1[&quot;表锁 (READ/WRITE)&quot;]
    C --&amp;gt; C2[&quot;元数据锁 (MDL)&quot;]
    C --&amp;gt; C3[&quot;意向锁 (IS/IX)&quot;]
    C --&amp;gt; C4[&quot;AUTO-INC 锁&quot;]

    D --&amp;gt; D1[&quot;Record Lock (记录锁)&quot;]
    D --&amp;gt; D2[&quot;Gap Lock (间隙锁)&quot;]
    D --&amp;gt; D3[&quot;Next-Key Lock (临键锁)&quot;]
    D --&amp;gt; D4[&quot;插入意向锁&quot;]

    style A fill:#f9f,stroke:#333
    style B fill:#ff9,stroke:#333
    style C fill:#9ff,stroke:#333
    style D fill:#9f9,stroke:#333
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;全局锁&lt;/h2&gt;
&lt;p&gt;全局锁会锁定整个数据库实例，使其进入只读状态。典型使用场景是&lt;strong&gt;全库逻辑备份&lt;/strong&gt;（如 &lt;code&gt;mysqldump --single-transaction&lt;/code&gt; 出现之前的方案）。&lt;/p&gt;
&lt;p&gt;执行 &lt;code&gt;FLUSH TABLES WITH READ LOCK&lt;/code&gt; 后，整个实例只能读不能写。但在支持 MVCC 的存储引擎（如 InnoDB）中，备份操作通常可以使用一致性快照来完成，无需加全局锁。因此在生产环境中，全局锁的实际使用频率很低。&lt;/p&gt;
&lt;h2&gt;表级锁&lt;/h2&gt;
&lt;p&gt;MySQL 表级锁包含以下四类：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;锁类型&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;表锁&lt;/td&gt;
&lt;td&gt;显式锁定整张表&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;元数据锁 (MDL)&lt;/td&gt;
&lt;td&gt;隐式保护表结构&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;意向锁&lt;/td&gt;
&lt;td&gt;辅助判断表中是否存在行锁&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AUTO-INC 锁&lt;/td&gt;
&lt;td&gt;保证自增主键的连续性&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;表锁&lt;/h3&gt;
&lt;p&gt;表锁是最粗粒度的锁，分为&lt;strong&gt;共享锁（读锁）&lt;strong&gt;和&lt;/strong&gt;独占锁（写锁）&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- 表级共享锁（读锁）：当前会话可读，所有会话不可写
LOCK TABLES t_student READ;

-- 表级独占锁（写锁）：当前会话可读写，其他会话不可读写
LOCK TABLES t_student WRITE;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;表锁不仅限制其他线程，也限制当前线程自身的操作。例如，若线程 A 执行了 &lt;code&gt;LOCK TABLES t1 READ, t2 WRITE&lt;/code&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;其他线程对 t1 的写操作、对 t2 的所有操作均被阻塞&lt;/li&gt;
&lt;li&gt;线程 A 自身在执行 &lt;code&gt;UNLOCK TABLES&lt;/code&gt; 之前，只能读 t1、读写 t2，不能写 t1，也不能访问 t3 或其他表&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;会话退出时自动释放所有表锁。在 InnoDB 引擎中应尽量避免使用表锁——其粒度太粗，会严重影响并发性能。InnoDB 的行级锁能够将锁定范围缩小到具体行，大幅提升并发能力。&lt;/p&gt;
&lt;h3&gt;元数据锁（MDL）&lt;/h3&gt;
&lt;p&gt;元数据锁（Metadata Lock）是 MySQL 自动管理的锁，无需显式使用。当对表进行操作时，MySQL 会自动加 MDL：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CRUD 操作&lt;/strong&gt;（SELECT / INSERT / UPDATE / DELETE）→ 加 &lt;strong&gt;MDL 读锁&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DDL 操作&lt;/strong&gt;（ALTER / DROP / RENAME）→ 加 &lt;strong&gt;MDL 写锁&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;MDL 的核心目的是防止在数据读写期间表结构被变更。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sequenceDiagram
    participant A as 线程 A
    participant B as 线程 B
    participant C as 线程 C

    A-&amp;gt;&amp;gt;A: BEGIN（开启事务）
    A-&amp;gt;&amp;gt;A: SELECT ...（获得 MDL 读锁）
    B-&amp;gt;&amp;gt;B: SELECT ...（获得 MDL 读锁，不阻塞）
    C--&amp;gt;&amp;gt;C: ALTER TABLE ...（申请 MDL 写锁，被阻塞）
    Note over C: 等待线程 A 释放 MDL 读锁
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MDL 的关键特性：&lt;strong&gt;在事务提交后才释放&lt;/strong&gt;，而非语句执行完毕后。这意味着长事务会持续持有 MDL 读锁，带来潜在风险：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;线程 A 开启事务（不提交），执行 &lt;code&gt;SELECT&lt;/code&gt;，持有 MDL 读锁&lt;/li&gt;
&lt;li&gt;线程 B 执行同样的 &lt;code&gt;SELECT&lt;/code&gt;，读读不冲突，正常执行&lt;/li&gt;
&lt;li&gt;线程 C 执行 &lt;code&gt;ALTER TABLE&lt;/code&gt;，申请 MDL 写锁，被阻塞&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;后续所有对该表的 CRUD 请求都会被阻塞&lt;/strong&gt;——因为 MDL 锁的申请形成队列，写锁优先级高于读锁，一旦写锁等待，会阻塞后续所有操作&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;大量线程被阻塞后，数据库连接池很快耗尽。&lt;/p&gt;
&lt;h3&gt;意向锁&lt;/h3&gt;
&lt;p&gt;意向锁是 InnoDB 引入的&lt;strong&gt;表级辅助锁&lt;/strong&gt;，用于快速判断表中是否存在行级锁，从而决定能否加表锁。&lt;/p&gt;
&lt;p&gt;加锁规则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;对某行加 行级共享锁（Row S Lock） 之前，会先在表上加 意向共享锁（IS）&lt;/li&gt;
&lt;li&gt;对某行加 行级独占锁（Row X Lock） 之前，会先在表上加 意向独占锁（IX）&lt;/li&gt;
&lt;li&gt;普通 SELECT 属于一致性非锁定读（Consistent Nonlocking Read），通过 MVCC 读取快照数据，不加行锁，也不会主动加 IS/IX&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;显式加行级锁的方式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- 先加意向共享锁，再对读取的行加共享锁
SELECT ... LOCK IN SHARE MODE;

-- 先加意向独占锁，再对读取的行加独占锁
SELECT ... FOR UPDATE;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;意向锁的兼容性&lt;/h4&gt;
&lt;p&gt;意向锁之间本身通常互不冲突，它们主要用于协调“行锁”和“表锁”。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;IS&lt;/th&gt;
&lt;th&gt;IX&lt;/th&gt;
&lt;th&gt;S&lt;/th&gt;
&lt;th&gt;X&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;IS&lt;/td&gt;
&lt;td&gt;✔︎&lt;/td&gt;
&lt;td&gt;✔︎&lt;/td&gt;
&lt;td&gt;✔︎&lt;/td&gt;
&lt;td&gt;✘&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IX&lt;/td&gt;
&lt;td&gt;✔︎&lt;/td&gt;
&lt;td&gt;✔︎&lt;/td&gt;
&lt;td&gt;✘&lt;/td&gt;
&lt;td&gt;✘&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S&lt;/td&gt;
&lt;td&gt;✔︎&lt;/td&gt;
&lt;td&gt;✘&lt;/td&gt;
&lt;td&gt;✔︎&lt;/td&gt;
&lt;td&gt;✘&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;✘&lt;/td&gt;
&lt;td&gt;✘&lt;/td&gt;
&lt;td&gt;✘&lt;/td&gt;
&lt;td&gt;✘&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;IS（Intent Shared）&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;表示：“我准备在某些行上加 S 行锁”&lt;/li&gt;
&lt;li&gt;只是一种声明，不真正锁数据&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;IX（Intent Exclusive）&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;表示：“我准备在某些行上加 X 行锁”&lt;/li&gt;
&lt;li&gt;同样只是声明&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;S（Shared Table Lock）&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;表级共享锁&lt;/li&gt;
&lt;li&gt;允许别人读表&lt;/li&gt;
&lt;li&gt;不允许别人修改表&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;X（Exclusive Table Lock）&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;表级独占锁&lt;/li&gt;
&lt;li&gt;独占整张表&lt;/li&gt;
&lt;li&gt;不允许任何其他锁进入&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;兼容关系理解：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;IS 和 IX 兼容&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;两个事务都只是“声明未来可能锁行”&lt;/li&gt;
&lt;li&gt;不影响彼此&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;IS 和 S 兼容&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“有人准备锁部分行读”&lt;/li&gt;
&lt;li&gt;与“整表共享读”不冲突&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;IX 和 S 冲突&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一个事务准备修改某些行&lt;/li&gt;
&lt;li&gt;另一个事务想共享锁整张表&lt;/li&gt;
&lt;li&gt;会产生冲突&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;任何锁与 X 都冲突&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;因为 X 要独占整张表&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;为什么需要意向锁&lt;/h4&gt;
&lt;p&gt;如果没有意向锁，当要加表级独占锁时，必须遍历表中每一行来检查是否存在行级独占锁，效率极低。&lt;/p&gt;
&lt;p&gt;有了意向锁，加表级独占锁前只需检查表上是否存在意向独占锁——如果有，说明表中已有行被加锁，无需逐行扫描。意向锁本质上是一个&lt;strong&gt;快速预检标记&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;AUTO-INC 锁&lt;/h3&gt;
&lt;p&gt;表的主键通常声明为 &lt;code&gt;AUTO_INCREMENT&lt;/code&gt;，插入数据时由数据库自动分配递增的主键值。AUTO-INC 锁正是保证这种自增值连续性的机制。&lt;/p&gt;
&lt;p&gt;AUTO-INC 锁是一种特殊的表锁，其释放时机不同于普通表锁：&lt;strong&gt;在插入语句执行完成后立即释放&lt;/strong&gt;，而非等到事务提交。这减少了锁持有时间，但在大批量插入场景下仍可能阻塞其他事务的插入操作。&lt;/p&gt;
&lt;p&gt;从 MySQL 5.1.22 开始，InnoDB 提供了更轻量的自增实现：申请到自增值后立即释放锁，无需等待整条语句执行完毕。&lt;/p&gt;
&lt;p&gt;通过 &lt;code&gt;innodb_autoinc_lock_mode&lt;/code&gt; 系统变量控制锁模式：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;值&lt;/th&gt;
&lt;th&gt;行为&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;使用传统 AUTO-INC 锁，语句执行结束后释放&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1（默认）&lt;/td&gt;
&lt;td&gt;普通 INSERT 申请后立即释放；&lt;code&gt;INSERT ... SELECT&lt;/code&gt; 等批量语句等语句结束后释放&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;轻量级锁，申请自增主键后立即释放&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;innodb_autoinc_lock_mode = 2&lt;/code&gt; 性能最优，但配合 &lt;code&gt;binlog_format = statement&lt;/code&gt; 在主从复制场景下会导致数据不一致。&lt;/p&gt;
&lt;h4&gt;主从不一致问题&lt;/h4&gt;
&lt;p&gt;考虑以下场景：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sequenceDiagram
    participant SA as Session A
    participant SB as Session B

    Note over SA: 插入 4 行数据到表 t&amp;lt;br/&amp;gt;创建相同结构的表 t2

    par 并发插入表 t2
        SB-&amp;gt;&amp;gt;SB: INSERT INTO t2 SELECT * FROM t (先执行 2 行)
        SA-&amp;gt;&amp;gt;SA: INSERT INTO t2 SELECT * FROM t (申请 id=3)
        SB-&amp;gt;&amp;gt;SB: 继续执行剩余 2 行
    end

    Note over SA,SB: Session B 的 id 不连续：1,2,4,5&amp;lt;br/&amp;gt;Session A 的 id：3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当 &lt;code&gt;innodb_autoinc_lock_mode = 2&lt;/code&gt; 时：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Session B 先插入 2 行，获得 id 1、2&lt;/li&gt;
&lt;li&gt;Session A 申请自增 id 得到 3，插入 (3, 5, 5)&lt;/li&gt;
&lt;li&gt;Session B 继续插入 2 行，获得 id 4、5&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;主库上 Session B 的 id 不连续（1, 2, 4, 5），但 &lt;code&gt;binlog_format = statement&lt;/code&gt; 时，binlog 记录的是原始 SQL 语句。从库按顺序执行，Session B 的 id 变成连续的（1, 2, 3, 4），与主库不一致。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解决方案&lt;/strong&gt;：将 &lt;code&gt;binlog_format&lt;/code&gt; 设置为 &lt;code&gt;row&lt;/code&gt;，binlog 直接记录主库实际分配的自增结果并传输给从库，从库使用相同的值重放，从而避免不一致。&lt;/p&gt;
&lt;p&gt;因此，推荐配置组合为 &lt;code&gt;innodb_autoinc_lock_mode = 2&lt;/code&gt; + &lt;code&gt;binlog_format = row&lt;/code&gt;，兼顾并发性能与数据一致性。&lt;/p&gt;
&lt;h2&gt;行级锁&lt;/h2&gt;
&lt;p&gt;行级锁是 InnoDB 实现细粒度并发控制的核心，按锁定范围分为三类：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;锁类型&lt;/th&gt;
&lt;th&gt;锁定范围&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Record Lock&lt;/td&gt;
&lt;td&gt;仅锁定一条记录本身&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gap Lock&lt;/td&gt;
&lt;td&gt;锁定记录前面的间隙，不包含记录本身&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next-Key Lock&lt;/td&gt;
&lt;td&gt;Record Lock + Gap Lock 的组合，锁定范围并锁定记录本身&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;Record Lock&lt;/h3&gt;
&lt;p&gt;Record Lock（记录锁）锁住的是一条索引记录，分为 S 型和 X 型两种：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;S 型记录锁&lt;/strong&gt;：其他事务可以继续对该记录加 S 型记录锁（S 与 S 兼容），但不能加 X 型记录锁（S 与 X 不兼容）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;X 型记录锁&lt;/strong&gt;：其他事务既不能对该记录加 S 型记录锁，也不能加 X 型记录锁（X 与任何锁都不兼容）&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Gap Lock&lt;/h3&gt;
&lt;p&gt;Gap Lock（间隙锁）存在于&lt;strong&gt;可重复读&lt;/strong&gt;和&lt;strong&gt;串行化&lt;/strong&gt;两个隔离级别下，目的是防止幻读。&lt;/p&gt;
&lt;p&gt;假设表中存在一个 id 范围为 (3, 5) 的间隙锁，其他事务就无法在该间隙中插入 id=4 的记录，从而有效阻止了幻读的发生。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E9%94%81%E4%BB%8B%E7%BB%8D/file-20260511023155627.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;间隙锁之间是&lt;strong&gt;兼容&lt;/strong&gt;的——两个事务可以同时持有覆盖相同间隙的间隙锁，不存在互斥关系。这是因为间隙锁的唯一目的是防止插入幻影记录，多个事务&quot;防插入&quot;的意图互不冲突。&lt;/p&gt;
&lt;h3&gt;Next-Key Lock&lt;/h3&gt;
&lt;p&gt;Next-Key Lock（临键锁）是 Record Lock 与 Gap Lock 的组合，既锁定记录本身，又锁定记录前面的间隙。&lt;/p&gt;
&lt;p&gt;假设表中存在一个 id 范围为 (3, 5] 的临键锁，其他事务既不能插入 id=4 的记录，也不能修改 id=5 的记录。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E9%94%81%E4%BB%8B%E7%BB%8D/file-20260511023430547.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;临键锁的兼容性需要分两部分理解：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;间隙锁部分&lt;/strong&gt;：相同范围的间隙锁相互兼容（同 Gap Lock 的特性）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;记录锁部分&lt;/strong&gt;：X 型记录锁与 S 型记录锁冲突&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，如果一个事务持有范围 (1, 10] 的 X 型临键锁，另一个事务申请相同范围的 X 型临键锁时会被阻塞——因为记录锁部分产生了冲突。&lt;/p&gt;
&lt;h3&gt;插入意向锁&lt;/h3&gt;
&lt;p&gt;当事务插入一条记录时，需要检查插入位置是否已被其他事务加了间隙锁（Next-Key Lock 也包含间隙锁）。如果存在间隙锁，插入操作会被阻塞，直到拥有间隙锁的事务提交。在等待期间，该事务会生成一个&lt;strong&gt;插入意向锁&lt;/strong&gt;，表示&quot;有事务想在某个区间插入新记录，但当前处于等待状态&quot;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E9%94%81%E4%BB%8B%E7%BB%8D/file-20260511023611565.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;以图示为例：事务 A 对表加了范围 id 为 (3, 5) 的间隙锁。当事务 A 尚未提交时，事务 B 尝试插入 id=4 的记录，检测到该位置已被间隙锁保护，于是事务 B 生成插入意向锁并将锁状态设为等待。MySQL 的加锁流程是先生成锁结构再设置状态——等待状态意味着尚未成功获取锁，只有状态变为正常才代表获取成功。事务 B 将一直阻塞，直到事务 A 提交并释放间隙锁。&lt;/p&gt;
&lt;p&gt;需要注意两个关键点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;插入意向锁不是意向锁&lt;/strong&gt;。尽管名字中带有&quot;意向&quot;，但它实际上是一种特殊的间隙锁，属于行级锁。如果说普通间隙锁锁定的是一个区间，那么插入意向锁锁定的是区间内的一个具体插入点。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;插入意向锁与间隙锁互斥&lt;/strong&gt;。虽然普通间隙锁之间相互兼容，但一个事务持有间隙锁时，另一个事务不能在同一间隙区间内持有插入意向锁——这正是插入操作被阻塞的原因。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item><item><title>MySQL 深度分页优化策略详解</title><link>https://blog.huangnv.online/posts/2659/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2659/1/1/</guid><description>分析 MySQL LIMIT OFFSET 的性能瓶颈，介绍基于主键索引的深度分页优化方案，以及产品层面的规避策略。</description><pubDate>Sat, 09 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;MySQL 深度分页优化策略详解&lt;/h1&gt;
&lt;h2&gt;MySQL 架构基础&lt;/h2&gt;
&lt;p&gt;MySQL 内部分为 &lt;strong&gt;Server 层&lt;/strong&gt;和&lt;strong&gt;存储引擎层&lt;/strong&gt;，生产环境通常使用 InnoDB 引擎。&lt;/p&gt;
&lt;p&gt;Server 层包含多个模块，其中执行器负责与存储引擎交互。执行器调用存储引擎提供的接口逐行获取数据，满足条件的记录放入结果集，最终返回给客户端。&lt;/p&gt;
&lt;h2&gt;LIMIT 的执行机制&lt;/h2&gt;
&lt;h3&gt;基于主键索引的执行过程&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;SELECT * FROM page ORDER BY id LIMIT 0, 10;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;执行流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[Server 层调用 InnoDB 接口] --&amp;gt; B[在主键索引中定位第 0 条记录]
    B --&amp;gt; C[读取完整行数据]
    C --&amp;gt; D[返回给 Server 层]
    D --&amp;gt; E[放入结果集]
    E --&amp;gt; F{已获取 10 条?}
    F --&amp;gt;|否| B
    F --&amp;gt;|是| G[返回客户端]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当 &lt;code&gt;offset &amp;gt; 0&lt;/code&gt; 且值较小时，逻辑类似，区别在于需要丢弃前 &lt;code&gt;offset&lt;/code&gt; 条数据：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT * FROM page ORDER BY id LIMIT 6000000, 10;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;此时 Server 层需要从 InnoDB 获取 6000010 条记录，丢弃前 600 万条，仅保留最后 10 条。这些被丢弃的数据仍然需要完整读取，造成大量无效 I/O。&lt;/p&gt;
&lt;p&gt;当使用 &lt;code&gt;SELECT *&lt;/code&gt; 时，每条记录都需要拷贝完整行数据，进一步加剧了性能损耗。&lt;/p&gt;
&lt;h3&gt;基于非主键索引的执行过程&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;SELECT * FROM page ORDER BY user_name LIMIT 0, 10;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;执行流程与主键索引类似，但需要额外的&lt;strong&gt;回表&lt;/strong&gt;操作：先在二级索引中获取主键 ID，再回表到聚簇索引获取完整行数据。&lt;/p&gt;
&lt;p&gt;当 &lt;code&gt;offset&lt;/code&gt; 值很大时（如 600 万），优化器会评估回表成本。如果回表次数过多，优化器将放弃使用二级索引，转而选择全表扫描。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart LR
    A[LIMIT 查询] --&amp;gt; B{offset 大小?}
    B --&amp;gt;|小| C[使用索引]
    B --&amp;gt;|大| D[优化器评估]
    D --&amp;gt; E{回表成本高?}
    E --&amp;gt;|是| F[全表扫描]
    E --&amp;gt;|否| C
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;深度分页优化方案&lt;/h2&gt;
&lt;h3&gt;方案一：子查询延迟关联&lt;/h3&gt;
&lt;p&gt;通过子查询先获取目标 ID，再关联获取完整数据，避免大量无效数据的拷贝：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT *
FROM page
WHERE id &amp;gt;= (
    SELECT id
    FROM page
    ORDER BY id
    LIMIT 6000000, 1
)
ORDER BY id
LIMIT 10;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;执行过程分析：&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;子查询 &lt;code&gt;SELECT id FROM page ORDER BY id LIMIT 6000000, 1&lt;/code&gt; 在主键索引中遍历 6000001 条记录&lt;/li&gt;
&lt;li&gt;与直接 &lt;code&gt;SELECT *&lt;/code&gt; 不同，子查询只拷贝 &lt;code&gt;id&lt;/code&gt; 列，减少数据传输量&lt;/li&gt;
&lt;li&gt;假设获取到的 &lt;code&gt;id = 6000000&lt;/code&gt;，外层查询变为 &lt;code&gt;WHERE id &amp;gt;= 6000000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;InnoDB 通过 B+ 树在 O(log n) 时间内定位到该记录，向后取 10 条&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[子查询: 获取第 6000001 条的 ID] --&amp;gt; B[只拷贝 ID 列, 减少数据量]
    B --&amp;gt; C[假设获取 id=6000000]
    C --&amp;gt; D[外层查询: WHERE id &amp;gt;= 6000000]
    D --&amp;gt; E[B+ 树快速定位 O log n]
    E --&amp;gt; F[向后取 10 条记录]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;方案一的局限：非主键字段排序&lt;/h3&gt;
&lt;p&gt;子查询延迟关联&lt;strong&gt;仅对主键排序的深度分页有效&lt;/strong&gt;。当排序字段是非主键字段时，该方案存在根本性缺陷。&lt;/p&gt;
&lt;p&gt;以按 &lt;code&gt;user_name&lt;/code&gt; 排序为例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT *
FROM page
WHERE user_name &amp;gt;= (
    SELECT user_name
    FROM page
    ORDER BY user_name
    LIMIT 1000, 1
)
ORDER BY user_name
LIMIT 10;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;问题分析：&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;子查询在二级索引上遍历 1001 条记录，只拷贝 &lt;code&gt;user_name&lt;/code&gt; 列 — 这步确实避免了回表&lt;/li&gt;
&lt;li&gt;但外层查询拿到 &lt;code&gt;user_name &amp;gt;= &apos;xxx&apos;&lt;/code&gt; 后，&lt;strong&gt;仍需回表&lt;/strong&gt;才能获取 &lt;code&gt;SELECT *&lt;/code&gt; 的完整数据&lt;/li&gt;
&lt;li&gt;&lt;code&gt;user_name&lt;/code&gt; 不唯一，可能存在大量重复值，导致外层扫描范围不可控&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[子查询: 二级索引遍历 1001 条] --&amp;gt; B[只拷贝 user_name, 无回表]
    B --&amp;gt; C[获取 user_name = xxx]
    C --&amp;gt; D[外层: WHERE user_name &amp;gt;= xxx]
    D --&amp;gt; E[需要回表获取完整数据]
    E --&amp;gt; F[user_name 不唯一, 扫描范围不可控]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;非主键字段的可行方案：&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;th&gt;适用场景&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;覆盖索引&lt;/td&gt;
&lt;td&gt;只需查询索引字段&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SELECT user_name FROM page ORDER BY user_name LIMIT 1000, 10&lt;/code&gt;，避免回表&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;游标分页&lt;/td&gt;
&lt;td&gt;顺序遍历，不支持跳页&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WHERE user_name &amp;gt; {last_name} LIMIT 10&lt;/code&gt;，性能稳定&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Elasticsearch&lt;/td&gt;
&lt;td&gt;大数据量分页搜索&lt;/td&gt;
&lt;td&gt;终极方案，支持深度分页和全文检索&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;方案二：游标分页（记录上次位置）&lt;/h3&gt;
&lt;p&gt;如果业务需要遍历全表数据，使用游标方式按主键顺序分批获取：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- 第一批
SELECT * FROM page ORDER BY id LIMIT 100;

-- 后续批次，以上次最大 ID 作为起点
SELECT * FROM page WHERE id &amp;gt; {last_max_id} ORDER BY id LIMIT 100;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种方式每次查询都通过主键索引定位，然后向后遍历固定数量的记录，查询性能稳定，不受数据量增长影响。&lt;/p&gt;
&lt;h2&gt;深度分页的产品层面规避&lt;/h2&gt;
&lt;p&gt;深度分页问题在 MySQL 和 Elasticsearch 中都&lt;strong&gt;无法彻底解决&lt;/strong&gt;，只能通过限制或规避来缓解。&lt;/p&gt;
&lt;h3&gt;场景一：全表数据导出&lt;/h3&gt;
&lt;p&gt;直接 &lt;code&gt;SELECT *&lt;/code&gt; 会导致超时报错，使用 &lt;code&gt;LIMIT OFFSET&lt;/code&gt; 分批获取会随数据增长产生深度分页问题。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解决方案：&lt;/strong&gt; 使用游标分页（方案二），按主键顺序分批获取。&lt;/p&gt;
&lt;h3&gt;场景二：用户分页展示&lt;/h3&gt;
&lt;p&gt;需要评估业务合理性：用户是否真的需要翻到第 10 万页以后？&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;优化策略：&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;策略&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;限制结果数量&lt;/td&gt;
&lt;td&gt;搜索/筛选类页面控制在 1 万条以内&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;使用 Elasticsearch&lt;/td&gt;
&lt;td&gt;替代 MySQL 处理大数据量分页&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;禁用跳页&lt;/td&gt;
&lt;td&gt;仅支持上一页/下一页，配合游标分页&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;产品引导&lt;/td&gt;
&lt;td&gt;优化搜索条件，减少无意义的深度翻页&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;性能对比总结&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;th&gt;适用场景&lt;/th&gt;
&lt;th&gt;性能表现&lt;/th&gt;
&lt;th&gt;实现复杂度&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LIMIT OFFSET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;小数据量（&amp;lt; 1k）&lt;/td&gt;
&lt;td&gt;offset 越大越慢&lt;/td&gt;
&lt;td&gt;低&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;子查询延迟关联&lt;/td&gt;
&lt;td&gt;需要跳页的场景&lt;/td&gt;
&lt;td&gt;稳定，但仍有子查询开销&lt;/td&gt;
&lt;td&gt;中&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;游标分页&lt;/td&gt;
&lt;td&gt;顺序遍历全表&lt;/td&gt;
&lt;td&gt;始终稳定&lt;/td&gt;
&lt;td&gt;低&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Elasticsearch&lt;/td&gt;
&lt;td&gt;大数据量搜索&lt;/td&gt;
&lt;td&gt;优秀&lt;/td&gt;
&lt;td&gt;高&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;LIMIT offset, size&lt;/code&gt; 比 &lt;code&gt;LIMIT size&lt;/code&gt; 慢，offset 越大执行速度越慢&lt;/li&gt;
&lt;li&gt;深度分页问题目前无完美解决方案，只能通过限制查询数量或分批获取规避&lt;/li&gt;
&lt;li&gt;遇到深度分页需求时，应思考原始业务场景，多数情况下不应出现深度分页&lt;/li&gt;
&lt;li&gt;小数据量（千级）且增长可控时，直接使用 &lt;code&gt;LIMIT OFFSET&lt;/code&gt; 即可&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>MySQL 事务：ACID 特性、隔离级别与 MVCC 实现原理</title><link>https://blog.huangnv.online/posts/2659/2/2/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2659/2/2/</guid><description>深入解析 MySQL 事务的 ACID 四大特性、并发事务引发的脏读/不可重复读/幻读问题、四种隔离级别，以及 Read View 和 MVCC 的实现原理。</description><pubDate>Sat, 09 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;MySQL 事务：ACID 特性、隔离级别与 MVCC 实现原理&lt;/h1&gt;
&lt;h2&gt;事务的特性&lt;/h2&gt;
&lt;p&gt;事务看似简单，但要真正实现事务，必须满足 ACID 四个特性。&lt;/p&gt;
&lt;h3&gt;原子性（Atomicity）&lt;/h3&gt;
&lt;p&gt;一个事务中的所有操作，要么全部完成，要么全部不完成，不会结束在中间某个环节。事务在执行过程中发生错误时，会被回滚到事务开始前的状态，就像这个事务从来没有执行过一样。&lt;/p&gt;
&lt;h3&gt;一致性（Consistency）&lt;/h3&gt;
&lt;p&gt;事务操作前和操作后，数据必须满足完整性约束，数据库保持一致性状态。&lt;/p&gt;
&lt;p&gt;以转账为例：用户 A 和用户 B 分别有 800 元和 600 元，总额 1400 元。用户 A 向用户 B 转账 200 元，分为两步：从 A 扣除 200 元、向 B 增加 200 元。一致性要求操作完成后，A 剩余 600 元、B 变为 800 元，总额仍为 1400 元。不会出现 A 扣款成功但 B 未到账的情况（否则总额变为 1200 元）。&lt;/p&gt;
&lt;h3&gt;隔离性（Isolation）&lt;/h3&gt;
&lt;p&gt;数据库允许多个并发事务同时对数据进行读写和修改。隔离性防止多个事务并发执行时由于交叉执行导致数据不一致——每个事务都有独立的数据空间，对其他并发事务是隔离的。&lt;/p&gt;
&lt;h3&gt;持久性（Durability）&lt;/h3&gt;
&lt;p&gt;事务处理结束后，对数据的修改就是永久的，即便系统故障也不会丢失。&lt;/p&gt;
&lt;h3&gt;InnoDB 如何保证这四个特性&lt;/h3&gt;
&lt;p&gt;InnoDB 引擎通过以下技术来保证事务的 ACID 特性：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;特性&lt;/th&gt;
&lt;th&gt;实现机制&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;持久性&lt;/td&gt;
&lt;td&gt;redo log（重做日志）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;原子性&lt;/td&gt;
&lt;td&gt;undo log（回滚日志）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;隔离性&lt;/td&gt;
&lt;td&gt;MVCC（多版本并发控制）或锁机制&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;一致性&lt;/td&gt;
&lt;td&gt;由持久性 + 原子性 + 隔离性共同保证&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;并行事务引发的问题&lt;/h2&gt;
&lt;h3&gt;脏读&lt;/h3&gt;
&lt;p&gt;如果一个事务「读到」了另一个「未提交事务修改过的数据」，就意味着发生了「脏读」。&lt;/p&gt;
&lt;p&gt;假设有事务 A 和事务 B 同时处理：事务 A 先从数据库读取小林的余额数据，然后执行更新操作。此时事务 A 还没有提交，而事务 B 也从数据库读取小林的余额数据，那么事务 B 读取到的就是事务 A 更新后但未提交的数据。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E4%BA%8B%E5%8A%A1/file-20260509195054432.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;由于事务 A 随时可能回滚，如果发生回滚，事务 B 刚才得到的数据就是过期数据，这种现象就被称为脏读。&lt;/p&gt;
&lt;h3&gt;不可重复读&lt;/h3&gt;
&lt;p&gt;在一个事务内多次读取同一个数据，如果出现前后两次读到的数据不一样，就意味着发生了「不可重复读」。&lt;/p&gt;
&lt;p&gt;假设有事务 A 和事务 B 同时处理：事务 A 先读取小林的余额数据，然后继续执行逻辑处理。在此期间，事务 B 更新了这条数据并提交了事务。当事务 A 再次读取该数据时，会发现前后两次读到的数据不一致，这种现象就被称为不可重复读。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E4%BA%8B%E5%8A%A1/file-20260509195233017.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;幻读&lt;/h3&gt;
&lt;p&gt;在一个事务内多次查询某个符合查询条件的「记录数量」，如果出现前后两次查询到的记录数量不一样，就意味着发生了「幻读」。&lt;/p&gt;
&lt;p&gt;假设有事务 A 和事务 B 同时处理：事务 A 查询账户余额大于 100 万的记录，发现共 5 条；事务 B 按相同条件也查询出 5 条记录。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E4%BA%8B%E5%8A%A1/file-20260509195306894.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;接下来，事务 A 插入了一条余额超过 100 万的账号并提交事务，此时数据库中超过 100 万余额的账号个数变为 6。事务 B 再次查询账户余额大于 100 万的记录，发现有 6 条，和前一次查询结果不一致，就像发生了幻觉一样，这种现象就被称为幻读。&lt;/p&gt;
&lt;h2&gt;事务的隔离级别&lt;/h2&gt;
&lt;p&gt;这三个现象的严重性排序如下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E4%BA%8B%E5%8A%A1/file-20260509195827538.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;SQL 标准提出了四种隔离级别来规避这些现象，隔离级别越高，性能效率就越低：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;隔离级别&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;读未提交（Read Uncommitted）&lt;/td&gt;
&lt;td&gt;一个事务还没提交时，它做的变更就能被其他事务看到&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;读提交（Read Committed）&lt;/td&gt;
&lt;td&gt;一个事务提交之后，它做的变更才能被其他事务看到&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;可重复读（Repeatable Read）&lt;/td&gt;
&lt;td&gt;事务执行过程中看到的数据，一直跟事务启动时看到的数据一致。MySQL InnoDB 引擎的默认隔离级别&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;串行化（Serializable）&lt;/td&gt;
&lt;td&gt;对记录加上读写锁，多个事务对同一条记录进行读写操作时，后访问的事务必须等前一个事务执行完成才能继续&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;按隔离水平高低排序如下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E4%BA%8B%E5%8A%A1/file-20260509195851591.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;针对不同的隔离级别，并发事务时可能发生的现象也不同：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E4%BA%8B%E5%8A%A1/file-20260509195859848.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;隔离级别&lt;/th&gt;
&lt;th&gt;脏读&lt;/th&gt;
&lt;th&gt;不可重复读&lt;/th&gt;
&lt;th&gt;幻读&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;读未提交&lt;/td&gt;
&lt;td&gt;可能发生&lt;/td&gt;
&lt;td&gt;可能发生&lt;/td&gt;
&lt;td&gt;可能发生&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;读提交&lt;/td&gt;
&lt;td&gt;不可能&lt;/td&gt;
&lt;td&gt;可能发生&lt;/td&gt;
&lt;td&gt;可能发生&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;可重复读&lt;/td&gt;
&lt;td&gt;不可能&lt;/td&gt;
&lt;td&gt;不可能&lt;/td&gt;
&lt;td&gt;可能发生&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;串行化&lt;/td&gt;
&lt;td&gt;不可能&lt;/td&gt;
&lt;td&gt;不可能&lt;/td&gt;
&lt;td&gt;不可能&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;MySQL 在「可重复读」隔离级别下，可以很大程度上避免幻读现象的发生（注意是很大程度避免，并非彻底避免）。因此 MySQL 并不会使用「串行化」隔离级别来避免幻读，因为串行化会影响性能。&lt;/p&gt;
&lt;h3&gt;可重复读下如何解决幻读&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;快照读&lt;/strong&gt;（普通 &lt;code&gt;SELECT&lt;/code&gt; 语句）：通过 MVCC 方式解决幻读。在可重复读隔离级别下，事务执行过程中看到的数据一直跟事务启动时看到的数据一致，即使中途有其他事务插入了数据，也查询不出来，从而避免了幻读。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;当前读&lt;/strong&gt;（&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; 等语句）：通过 next-key lock（记录锁 + 间隙锁）解决幻读。执行 &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; 时会加上 next-key lock，如果有其他事务在锁范围内插入记录，插入语句会被阻塞，从而避免了幻读。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;但混合使用快照读和当前读仍可能出现幻读&lt;/strong&gt;，因为两种读取方式作用在不同层级：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- 事务 A
BEGIN;
SELECT * FROM user WHERE age = 18;  -- 快照读，结果：0 条

-- 事务 B
INSERT INTO user(id, age, name) VALUES(1, 18, &apos;tom&apos;);
COMMIT;

-- 事务 A
UPDATE user SET name = &apos;changed&apos; WHERE age = 18;  -- 当前读，可能影响 1 行
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前面 &lt;code&gt;SELECT&lt;/code&gt; 看不到 &lt;code&gt;age = 18&lt;/code&gt; 的记录，后面的 &lt;code&gt;UPDATE&lt;/code&gt; 却更新到了这条新插入的记录——因为 &lt;code&gt;SELECT&lt;/code&gt; 是快照读，&lt;code&gt;UPDATE&lt;/code&gt; 是当前读。&lt;/p&gt;
&lt;h3&gt;四种隔离级别的具体示例&lt;/h3&gt;
&lt;p&gt;有一张账户余额表，里面有一条账户余额为 100 万的记录。两个并发事务：事务 A 只负责查询余额，事务 B 将余额改成 200 万。按时间顺序执行如下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E4%BA%8B%E5%8A%A1/file-20260509205339734.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;读未提交&lt;/strong&gt;：事务 B 修改余额后，虽然没有提交事务，但此时的余额已经可以被事务 A 看见。事务 A 中余额 V1 查询的值是 200 万，V2、V3 自然也是 200 万。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;读提交&lt;/strong&gt;：事务 B 修改余额后，因为没有提交事务，事务 A 中余额 V1 的值还是 100 万。等事务 B 提交后，最新的余额数据才能被事务 A 看见，因此 V2、V3 都是 200 万。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;可重复读&lt;/strong&gt;：事务 A 只能看见启动事务时的数据，余额 V1、V2 的值都是 100 万。当事务 A 提交事务后，才能看见最新的余额数据，所以 V3 的值是 200 万。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;串行化&lt;/strong&gt;：事务 B 在执行将余额 100 万修改为 200 万时，由于此前事务 A 执行了读操作，发生了读写冲突，事务 B 会被锁住，直到事务 A 提交后才能继续执行。从 A 的角度看，余额 V1、V2 的值是 100 万，V3 的值是 200 万。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Read View&lt;/h2&gt;
&lt;p&gt;Read View 是 MVCC 实现的核心数据结构，用于判断事务对记录的可见性。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E4%BA%8B%E5%8A%A1/file-20260509205544320.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;当 SQL 执行快照读时就会产生 Read View，如 &lt;code&gt;SELECT * FROM user WHERE id = 1&lt;/code&gt;。普通的写操作不会产生 Read View，如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;BEGIN;
UPDATE user SET age = 20 WHERE id = 1;
COMMIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Read View 的四个字段&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;字段&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;m_ids&lt;/td&gt;
&lt;td&gt;创建 Read View 时，当前数据库中「活跃事务」的事务 ID 列表（不包括创建该 Read View 的事务自身）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;min_trx_id&lt;/td&gt;
&lt;td&gt;创建 Read View 时，当前活跃事务中事务 ID 的最小值，即 m_ids 的最小值&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;max_trx_id&lt;/td&gt;
&lt;td&gt;创建 Read View 时，下一个将要分配给新事务的事务 ID 值（全局事务 ID 计数器的当前值）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;creator_trx_id&lt;/td&gt;
&lt;td&gt;创建该 Read View 的事务的事务 ID&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;事务 ID 按时间严格递增。&lt;/p&gt;
&lt;h3&gt;聚簇索引的两个隐藏列&lt;/h3&gt;
&lt;p&gt;假设在账户余额表插入一条小林余额为 100 万的记录，该记录的示意图如下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E4%BA%8B%E5%8A%A1/file-20260509212213191.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;InnoDB 存储引擎的聚簇索引记录中包含两个隐藏列：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;trx_id&lt;/strong&gt;：当一个事务对某条聚簇索引记录进行改动时，会把该事务的事务 ID 记录在 trx_id 隐藏列里。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;roll_pointer&lt;/strong&gt;：每次对某条聚簇索引记录进行改动时，都会把旧版本的记录写入到 undo log 中，这个隐藏列是指针，指向每一个旧版本记录，可以通过它找到修改前的记录。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;可见性判断规则&lt;/h3&gt;
&lt;p&gt;在创建 Read View 后，可以根据记录中的 trx_id 划分为三种情况：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E4%BA%8B%E5%8A%A1/file-20260509213012014.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;一个事务访问记录时，除了「自己的更新记录总是可见」（即 &lt;code&gt;trx_id == creator_trx_id&lt;/code&gt; 时可见）之外，还有以下情况：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;trx_id &amp;lt; min_trx_id&lt;/strong&gt;：该版本由「创建 Read View 之前就已提交的事务」生成，对当前事务&lt;strong&gt;可见&lt;/strong&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;trx_id &amp;gt;= max_trx_id&lt;/strong&gt;：该版本由「创建 Read View 之后才启动的事务」生成，对当前事务&lt;strong&gt;不可见&lt;/strong&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;min_trx_id &amp;lt;= trx_id &amp;lt; max_trx_id&lt;/strong&gt;：该事务 ID 在 Read View 创建之前已分配，需要进一步判断 trx_id 是否在 m_ids 列表中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;trx_id 在 m_ids 中&lt;/strong&gt;：生成该版本的事务在创建 Read View 时仍然活跃（尚未提交），对当前事务&lt;strong&gt;不可见&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;trx_id 不在 m_ids 中&lt;/strong&gt;：该事务虽然 ID 已分配，但在创建 Read View 时已经提交，对当前事务&lt;strong&gt;可见&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;这种通过「版本链」来控制并发事务访问同一个记录时的行为就叫 MVCC（多版本并发控制）。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;以下是可见性规则与 InnoDB 源码分支的对应关系：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;规则描述&lt;/th&gt;
&lt;th&gt;源码判断&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;trx_id == creator_trx_id → 可见&lt;/td&gt;
&lt;td&gt;id == m_creator_trx_id → return true&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;trx_id &amp;lt; min_trx_id → 可见&lt;/td&gt;
&lt;td&gt;id &amp;lt; m_up_limit_id → return true&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;trx_id &amp;gt;= max_trx_id → 不可见&lt;/td&gt;
&lt;td&gt;id &amp;gt;= m_low_limit_id → return false&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;在区间内且在 m_ids 中 → 不可见&lt;/td&gt;
&lt;td&gt;binary_search(...) 命中 → return false&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;在区间内但不在 m_ids 中 → 可见&lt;/td&gt;
&lt;td&gt;binary_search(...) 未命中 → return true&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;可重复读是如何工作的？&lt;/h2&gt;
&lt;p&gt;可重复读隔离级别在启动事务时生成一个 Read View，然后整个事务期间都使用这个 Read View。&lt;/p&gt;
&lt;h3&gt;启动事务&lt;/h3&gt;
&lt;p&gt;假设在此之前，已经有一个事务 ID 为 50 的事务完成了对记录的插入并提交。随后事务 A（事务 ID 为 51）启动，紧接着事务 B（事务 ID 为 52）也启动了，两个事务创建的 Read View 如下：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;事务 A 的 Read View（事务 ID 为 51）：&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;字段&lt;/th&gt;
&lt;th&gt;值&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;creator_trx_id&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;m_ids&lt;/td&gt;
&lt;td&gt;[]&lt;/td&gt;
&lt;td&gt;空列表（A 是当前唯一的活跃事务，且不含自身）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;min_trx_id&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;td&gt;m_ids 为空时，等于 max_trx_id&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;max_trx_id&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;td&gt;下一个将要分配的事务 ID&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;事务 B 的 Read View（事务 ID 为 52）：&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;字段&lt;/th&gt;
&lt;th&gt;值&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;creator_trx_id&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;m_ids&lt;/td&gt;
&lt;td&gt;[51]&lt;/td&gt;
&lt;td&gt;只包含 A，不含 B 自身&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;min_trx_id&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;td&gt;m_ids 的最小值&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;max_trx_id&lt;/td&gt;
&lt;td&gt;53&lt;/td&gt;
&lt;td&gt;下一个将要分配的事务 ID&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;接着，在可重复读隔离级别下，事务 A 和事务 B 按顺序执行了以下操作：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;事务 B 读取小林的账户余额记录，读到余额是 100 万&lt;/li&gt;
&lt;li&gt;事务 A 将小林的账户余额记录修改成 200 万，并没有提交事务&lt;/li&gt;
&lt;li&gt;事务 B 读取小林的账户余额记录，读到余额还是 100 万&lt;/li&gt;
&lt;li&gt;事务 A 提交事务&lt;/li&gt;
&lt;li&gt;事务 B 读取小林的账户余额记录，读到余额依然还是 100 万&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;第一次读：事务 B 首次读取记录&lt;/h3&gt;
&lt;p&gt;事务 B 第一次读取小林的账户余额记录，找到记录后先看这条记录的 trx_id。此时发现 trx_id 为 50，比事务 B 的 Read View 中的 min_trx_id（51）还小，根据可见性规则，这意味着修改这条记录的事务早在事务 B 启动前就已提交，所以该版本对事务 B 可见，读到余额为 100 万。&lt;/p&gt;
&lt;h3&gt;事务 A 修改记录，形成版本链&lt;/h3&gt;
&lt;p&gt;接着，事务 A 通过 &lt;code&gt;UPDATE&lt;/code&gt; 语句将这条记录修改（还未提交事务），将小林的余额改成 200 万。MySQL 会记录相应的 undo log，并以链表的方式串联起来，形成版本链：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E4%BA%8B%E5%8A%A1/file-20260509215148400.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;由于事务 A 修改了该记录，以前的记录变成旧版本记录，最新记录和旧版本记录通过 roll_pointer 以链表的方式串起来，最新记录的 trx_id 是事务 A 的事务 ID（trx_id = 51）。&lt;/p&gt;
&lt;h3&gt;第二次读：事务 B 再次读取记录（事务 A 未提交）&lt;/h3&gt;
&lt;p&gt;事务 B 第二次读取该记录，发现 trx_id 为 51。对照事务 B 的 Read View：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;trx_id（51）不小于 min_trx_id（51），不满足「直接可见」的条件&lt;/li&gt;
&lt;li&gt;trx_id（51）小于 max_trx_id（53），落在 [min_trx_id, max_trx_id) 区间内&lt;/li&gt;
&lt;li&gt;需要判断 trx_id 是否在 m_ids = [51] 中——结果是在的&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这说明这条记录是由事务 B 的 Read View 创建时仍然活跃的事务（即事务 A）修改的，对事务 B &lt;strong&gt;不可见&lt;/strong&gt;。于是事务 B 沿着 roll_pointer 指向的 undo log 链条往下找旧版本记录，直到找到 trx_id 小于 min_trx_id 的第一条记录。&lt;/p&gt;
&lt;p&gt;沿着链条往下一条是 trx_id = 50 的旧版本，满足 50 &amp;lt; min_trx_id（51），所以事务 B 读取到的是这条旧版本，余额为 100 万。&lt;/p&gt;
&lt;h3&gt;第三次读：事务 B 再次读取记录（事务 A 已提交）&lt;/h3&gt;
&lt;p&gt;当事务 A 提交事务后，由于隔离级别是「可重复读」，事务 B 再次读取记录时，依然使用启动事务时创建的那个 Read View 来判断可见性。这个 Read View 在事务 B 的整个生命周期内都不会变。&lt;/p&gt;
&lt;p&gt;因此即使事务 A 已经把小林余额改成 200 万并提交，事务 B 的 Read View 里 m_ids 依然记录着 [51]，这条最新版本的 trx_id = 51 依旧会被判为不可见，依然会沿着 undo log 往下读到 trx_id = 50 的旧版本。&lt;/p&gt;
&lt;p&gt;所以事务 B 第三次读取记录时，读到的仍然是小林余额为 100 万的记录。&lt;/p&gt;
&lt;p&gt;通过这种方式，实现了在「可重复读」隔离级别下，事务期间读到的记录始终是事务启动前已提交的版本。&lt;/p&gt;
&lt;h2&gt;读提交是如何工作的？&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;读提交隔离级别在每次读取数据时，都会生成一个新的 Read View&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这意味着事务期间多次读取同一条数据，前后两次读的数据可能会出现不一致，因为可能这期间另外一个事务修改了该记录并提交了事务。&lt;/p&gt;
&lt;p&gt;还是以前面的例子来说明。假设事务 A（事务 ID 为 51）启动后，紧接着事务 B（事务 ID 为 52）也启动了，按顺序执行以下操作：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;事务 B 读取数据（创建 Read View），小林的账户余额为 100 万&lt;/li&gt;
&lt;li&gt;事务 A 修改数据（还没提交事务），将小林的账户余额从 100 万修改成 200 万&lt;/li&gt;
&lt;li&gt;事务 B 读取数据（创建 Read View），小林的账户余额为 100 万&lt;/li&gt;
&lt;li&gt;事务 A 提交事务&lt;/li&gt;
&lt;li&gt;事务 B 读取数据（创建 Read View），小林的账户余额为 200 万&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;第一步：事务 B 读取数据&lt;/h3&gt;
&lt;p&gt;创建的 Read View（此时 A 未提交，仍活跃）：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E4%BA%8B%E5%8A%A1/file-20260509215333767.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;第二步：事务 A 修改数据（还未提交）&lt;/h3&gt;
&lt;p&gt;形成版本链：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E4%BA%8B%E5%8A%A1/file-20260509215345089.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;第三步：事务 B 再次读取数据&lt;/h3&gt;
&lt;p&gt;创建的 Read View（此时 A 仍未提交，仍活跃，所以这个 Read View 和第一步完全一样）：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E4%BA%8B%E5%8A%A1/file-20260509215358672.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;事务 B 找到小林这条记录时，会看 trx_id 是 51。对照新创建的 Read View：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;trx_id（51）不小于 min_trx_id（51），不满足「直接可见」&lt;/li&gt;
&lt;li&gt;trx_id（51）小于 max_trx_id（53），落在 [min_trx_id, max_trx_id) 区间内&lt;/li&gt;
&lt;li&gt;需要判断 trx_id 是否在 m_ids 范围内——结果是在的&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;说明这条记录是被还未提交的事务修改的，事务 B 不会读取这个版本。于是沿着 undo log 链条往下找旧版本记录，直到找到 trx_id 小于 min_trx_id 的第一条记录，所以事务 B 读取到的是 trx_id 为 50 的记录，余额为 100 万。&lt;/p&gt;
&lt;h3&gt;第四步：事务 A 提交后，事务 B 再次读取&lt;/h3&gt;
&lt;p&gt;由于隔离级别是「读提交」，事务 B 每次读数据时会重新创建 Read View。此时事务 A 已经提交，不再是活跃事务，不会再出现在 m_ids 里。事务 B 第三次读取数据时创建的 Read View 如下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E4%BA%8B%E5%8A%A1/file-20260509215525302.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;事务 B 找到小林这条记录时，发现 trx_id 是 51，比 Read View 中的 min_trx_id（53）还小，说明修改这条记录的事务早已在创建 Read View 前提交过了，该版本对事务 B &lt;strong&gt;可见&lt;/strong&gt;。因此事务 B 能读到最新版本，余额为 200 万。&lt;/p&gt;
&lt;p&gt;正是因为读提交隔离级别下事务每次读数据时都重新创建 Read View，所以事务期间多次读取同一条数据可能会出现不一致。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;事务在 MySQL 引擎层实现，InnoDB 引擎支持事务，其四大特性为原子性、一致性、隔离性、持久性，本文重点讲解的是隔离性。&lt;/p&gt;
&lt;p&gt;当多个事务并发执行时，会引发脏读、不可重复读、幻读等问题。为避免这些问题，SQL 标准提出了四种隔离级别：读未提交、读提交、可重复读、串行化，从左往右隔离级别递增，隔离级别越高性能越差。InnoDB 引擎的默认隔离级别是可重复读。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;要解决脏读，需将隔离级别升级到读提交及以上&lt;/li&gt;
&lt;li&gt;要解决不可重复读，需将隔离级别升级到可重复读及以上&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对于幻读，不建议升级为串行化（会导致并发性能很差）。MySQL InnoDB 在「可重复读」隔离级别下很大程度上避免了幻读（并非完全解决），方案有两种：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;快照读&lt;/strong&gt;（普通 &lt;code&gt;SELECT&lt;/code&gt; 语句）：通过 MVCC 解决幻读。可重复读隔离级别下，事务看到的数据始终与事务启动时一致，即使中途有其他事务插入数据也查询不出来。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;当前读&lt;/strong&gt;（&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; 等语句）：通过 next-key lock（记录锁 + 间隙锁）解决幻读。执行时会加 next-key lock，其他事务在锁范围内插入记录会被阻塞。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对于「读提交」和「可重复读」隔离级别，它们通过 Read View 来实现，区别在于创建 Read View 的时机不同：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;读提交&lt;/strong&gt;：每次 &lt;code&gt;SELECT&lt;/code&gt; 都会生成一个新的 Read View，因此事务期间多次读取同一条数据可能不一致。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;可重复读&lt;/strong&gt;：启动事务时生成一个 Read View，整个事务期间都使用这个 Read View，保证事务期间读到的数据都是事务启动前的记录。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这两个隔离级别的实现，是通过 Read View 的字段与记录中两个隐藏列的比对来控制并发事务访问同一记录的行为，这就是 MVCC（多版本并发控制）。&lt;/p&gt;
&lt;p&gt;在可重复读隔离级别中，普通 &lt;code&gt;SELECT&lt;/code&gt; 语句是基于 MVCC 实现的快照读，不会加锁。而 &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; 是当前读，每次读取最新版本的数据，并对读到的记录加上 next-key lock。&lt;/p&gt;
</content:encoded></item><item><title>MySQL COUNT() 函数执行原理深度解析</title><link>https://blog.huangnv.online/posts/2658/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2658/1/1/</guid><description>详解 MySQL 中 COUNT(*)、COUNT(1)、COUNT(主键)、COUNT(字段) 的执行过程差异，以及优化器如何选择索引扫描策略。</description><pubDate>Fri, 08 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;MySQL COUNT() 函数执行原理深度解析&lt;/h1&gt;
&lt;h2&gt;COUNT() 函数的本质&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;COUNT()&lt;/code&gt; 是一个聚合函数，其参数不仅可以是字段名，也可以是任意表达式。该函数的作用是统计符合查询条件的记录中，指定参数不为 &lt;code&gt;NULL&lt;/code&gt; 的记录数量。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT COUNT(name) FROM t_order;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这条语句统计的是 &lt;code&gt;t_order&lt;/code&gt; 表中 &lt;code&gt;name&lt;/code&gt; 字段不为 &lt;code&gt;NULL&lt;/code&gt; 的记录数。如果某条记录的 &lt;code&gt;name&lt;/code&gt; 字段值为 &lt;code&gt;NULL&lt;/code&gt;，则不会被计入。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT COUNT(1) FROM t_order;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这条语句统计的是 &lt;code&gt;t_order&lt;/code&gt; 表中表达式 &lt;code&gt;1&lt;/code&gt; 不为 &lt;code&gt;NULL&lt;/code&gt; 的记录数。由于数字 &lt;code&gt;1&lt;/code&gt; 永远不为 &lt;code&gt;NULL&lt;/code&gt;，这条语句实际上统计的是表中的总记录数。&lt;/p&gt;
&lt;h2&gt;COUNT() 的通用执行流程&lt;/h2&gt;
&lt;p&gt;无论参数是什么，MySQL Server 层都会维护一个 &lt;code&gt;count&lt;/code&gt; 变量。执行流程如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[Server 层初始化 count = 0] --&amp;gt; B[向 InnoDB 请求一条记录]
    B --&amp;gt; C{记录是否存在?}
    C --&amp;gt;|否| D[返回 count 值给客户端]
    C --&amp;gt;|是| E{COUNT 参数是否为 NULL?}
    E --&amp;gt;|否| F[count = count + 1]
    E --&amp;gt;|是| G[跳过]
    F --&amp;gt; B
    G --&amp;gt; B
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;InnoDB 通过 B+ 树存储记录，索引分为两种类型：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;索引类型&lt;/th&gt;
&lt;th&gt;叶子节点内容&lt;/th&gt;
&lt;th&gt;特点&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;聚簇索引&lt;/td&gt;
&lt;td&gt;实际数据行&lt;/td&gt;
&lt;td&gt;叶子节点包含完整记录&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;二级索引&lt;/td&gt;
&lt;td&gt;主键值&lt;/td&gt;
&lt;td&gt;占用空间更小，需要回表获取完整数据&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;COUNT(主键字段) 执行过程&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;SELECT COUNT(id) FROM t_order;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;执行时，InnoDB 需要遍历索引并将记录返回给 Server 层，Server 层读取主键字段值判断是否为 &lt;code&gt;NULL&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;优化器的索引选择策略：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;若表只有主键索引：遍历聚簇索引&lt;/li&gt;
&lt;li&gt;若表存在二级索引：优先遍历二级索引&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;选择二级索引的原因是：相同数量的记录，二级索引占用的存储空间更小，遍历时 I/O 成本更低。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart LR
    A[COUNT 主键字段] --&amp;gt; B{存在二级索引?}
    B --&amp;gt;|是| C[遍历二级索引]
    B --&amp;gt;|否| D[遍历聚簇索引]
    C --&amp;gt; E[读取主键值判断 NULL]
    D --&amp;gt; E
    E --&amp;gt; F[累加 count]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;COUNT(1) 执行过程&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;SELECT COUNT(1) FROM t_order;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;COUNT(1)&lt;/code&gt; 与 &lt;code&gt;COUNT(主键字段)&lt;/code&gt; 的关键区别：不需要读取记录中的任何字段值，因为参数 &lt;code&gt;1&lt;/code&gt; 是常量，永远不为 &lt;code&gt;NULL&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;性能对比：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;若表只有主键索引：&lt;code&gt;COUNT(1)&lt;/code&gt; 省去了读取字段值的步骤，效率略高&lt;/li&gt;
&lt;li&gt;若表存在二级索引：两者都会选择扫描代价最低的二级索引，性能差异可忽略&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;COUNT(*) 执行过程&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;COUNT(*)&lt;/code&gt; 在 MySQL 内部会被转换为 &lt;code&gt;COUNT(0)&lt;/code&gt; 处理，而 &lt;code&gt;0&lt;/code&gt; 也是常量表达式，永远不为 &lt;code&gt;NULL&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;因此，&lt;strong&gt;&lt;code&gt;COUNT(*)&lt;/code&gt; 与 &lt;code&gt;COUNT(1)&lt;/code&gt; 的执行过程完全一致，性能没有差异&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;COUNT(字段) 执行过程&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;-- name 不是索引，普通字段
SELECT COUNT(name) FROM t_order;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;COUNT(字段)&lt;/code&gt; 与其他形式的根本区别：需要判断字段值是否为 &lt;code&gt;NULL&lt;/code&gt;，因此必须读取字段数据。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;执行策略：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;若字段没有索引：采用全表扫描，效率最差&lt;/li&gt;
&lt;li&gt;若字段有索引：可以使用索引扫描，但仍需读取字段值&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;性能对比总结&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;写法&lt;/th&gt;
&lt;th&gt;是否读取字段值&lt;/th&gt;
&lt;th&gt;有二级索引时的扫描方式&lt;/th&gt;
&lt;th&gt;性能排序&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COUNT(*)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;否&lt;/td&gt;
&lt;td&gt;二级索引&lt;/td&gt;
&lt;td&gt;最优&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COUNT(1)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;否&lt;/td&gt;
&lt;td&gt;二级索引&lt;/td&gt;
&lt;td&gt;最优&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COUNT(主键)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;是（主键值）&lt;/td&gt;
&lt;td&gt;二级索引&lt;/td&gt;
&lt;td&gt;次优&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COUNT(字段)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;是（字段值）&lt;/td&gt;
&lt;td&gt;全表扫描或字段索引&lt;/td&gt;
&lt;td&gt;最差&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;优化建议&lt;/h2&gt;
&lt;h3&gt;1. 建立合适的索引&lt;/h3&gt;
&lt;p&gt;执行 &lt;code&gt;COUNT(*)&lt;/code&gt;、&lt;code&gt;COUNT(1)&lt;/code&gt;、&lt;code&gt;COUNT(主键字段)&lt;/code&gt; 时，若表存在二级索引，优化器会自动选择 &lt;code&gt;key_len&lt;/code&gt; 最小的二级索引进行扫描。因此，建议在表上建立二级索引以提升统计效率。&lt;/p&gt;
&lt;h3&gt;2. 避免使用 COUNT(字段)&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;COUNT(字段)&lt;/code&gt; 的效率最差，会采用全表扫描。如果确实需要统计某字段不为 &lt;code&gt;NULL&lt;/code&gt; 的记录数，建议给该字段建立二级索引。&lt;/p&gt;
&lt;h3&gt;3. 使用近似值&lt;/h3&gt;
&lt;p&gt;如果业务对统计精度要求不高，可以使用 &lt;code&gt;EXPLAIN&lt;/code&gt; 命令获取估算值：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;EXPLAIN SELECT COUNT(*) FROM t_order;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;EXPLAIN&lt;/code&gt; 不会真正执行查询，返回的 &lt;code&gt;rows&lt;/code&gt; 字段是优化器对记录数的估算值。&lt;/p&gt;
&lt;h3&gt;4. 使用计数表&lt;/h3&gt;
&lt;p&gt;对于需要精确统计的场景，可以创建单独的计数表，在数据增删时同步更新计数值，避免每次查询都执行 &lt;code&gt;COUNT()&lt;/code&gt;。&lt;/p&gt;
</content:encoded></item><item><title>MySQL 联合索引：范围查询、索引设计与失效场景</title><link>https://blog.huangnv.online/posts/2657/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2657/1/1/</guid><description>从联合索引的范围查询命中规则出发，延伸到索引区分度、索引优化策略和索引失效场景的全面梳理。</description><pubDate>Thu, 07 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;MySQL 联合索引：范围查询、索引设计与失效场景&lt;/h1&gt;
&lt;h2&gt;背景&lt;/h2&gt;
&lt;p&gt;联合索引（也称复合索引）是将多个字段组合在一起创建的二级索引。以联合索引 &lt;code&gt;(a, b)&lt;/code&gt; 为例，B+ 树会&lt;strong&gt;先按 &lt;code&gt;a&lt;/code&gt; 的值排序，在 &lt;code&gt;a&lt;/code&gt; 相同时再按 &lt;code&gt;b&lt;/code&gt; 的值排序&lt;/strong&gt;。这个排序规则决定了查询优化器能否利用索引来加速某个条件的过滤。&lt;/p&gt;
&lt;p&gt;本文先通过两个相似的查询分析范围查询对索引命中情况的影响，再延伸到索引设计和失效场景。&lt;/p&gt;
&lt;h2&gt;范围查询下的索引命中&lt;/h2&gt;
&lt;h3&gt;查询一：a &amp;gt; 1 AND b = 2&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;SELECT * FROM t_table WHERE a &amp;gt; 1 AND b = 2;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;结论：只有 &lt;code&gt;a&lt;/code&gt; 字段用到了联合索引，&lt;code&gt;b&lt;/code&gt; 字段没有用到。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;原因如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;联合索引 &lt;code&gt;(a, b)&lt;/code&gt; 的 B+ 树先按 &lt;code&gt;a&lt;/code&gt; 排序，因此满足 &lt;code&gt;a &amp;gt; 1&lt;/code&gt; 的记录在索引中是&lt;strong&gt;物理相邻&lt;/strong&gt;的&lt;/li&gt;
&lt;li&gt;索引扫描可以从 &lt;code&gt;a &amp;gt; 1&lt;/code&gt; 的第一条记录开始，沿叶子节点链表向后遍历，直到 &lt;code&gt;a&lt;/code&gt; 不再满足条件——&lt;code&gt;a&lt;/code&gt; 字段的范围查询可以高效命中索引&lt;/li&gt;
&lt;li&gt;但在 &lt;code&gt;a &amp;gt; 1&lt;/code&gt; 这个&lt;strong&gt;范围&lt;/strong&gt;内，&lt;code&gt;a&lt;/code&gt; 的值跨越了多个不同的值（2、3、4……），而 &lt;code&gt;b&lt;/code&gt; 只在 &lt;code&gt;a&lt;/code&gt; 相同时才有序。不同 &lt;code&gt;a&lt;/code&gt; 值对应的 &lt;code&gt;b&lt;/code&gt; 值之间没有排序关系，因此 &lt;code&gt;b = 2&lt;/code&gt; 无法利用索引定位，只能回表过滤&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql-----%E7%B4%A2%E5%BC%95/file-20260507144956106.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;在 &lt;code&gt;a &amp;gt; 1&lt;/code&gt; 的范围里，&lt;code&gt;b&lt;/code&gt; 的值分布是散乱的（&lt;code&gt;b = 1, 3, 2, 5, 2, 4……&lt;/code&gt;），无法通过索引快速定位 &lt;code&gt;b = 2&lt;/code&gt; 的记录。&lt;/p&gt;
&lt;h3&gt;查询二：a &amp;gt;= 1 AND b = 2&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;SELECT * FROM t_table WHERE a &amp;gt;= 1 AND b = 2;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;结论：&lt;code&gt;a&lt;/code&gt; 和 &lt;code&gt;b&lt;/code&gt; 字段都用到了联合索引。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;两个查询表面上只差一个等号，但索引命中情况完全不同。关键区别在于：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;a &amp;gt;= 1&lt;/code&gt; 同样是一个范围条件，&lt;code&gt;a&lt;/code&gt; 字段可以命中索引，这一点与查询一相同&lt;/li&gt;
&lt;li&gt;但在遍历过程中，索引会先定位到 &lt;code&gt;a = 1&lt;/code&gt; 的所有记录。在 &lt;code&gt;a = 1&lt;/code&gt; 这个&lt;strong&gt;单点值&lt;/strong&gt;的范围内，&lt;code&gt;b&lt;/code&gt; 是完全有序的（因为联合索引在 &lt;code&gt;a&lt;/code&gt; 相同时按 &lt;code&gt;b&lt;/code&gt; 排序）&lt;/li&gt;
&lt;li&gt;因此 &lt;code&gt;b = 2&lt;/code&gt; 可以在 &lt;code&gt;a = 1&lt;/code&gt; 的子范围内利用索引直接定位，无需回表过滤&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql-----%E7%B4%A2%E5%BC%95/file-20260507145037248.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;当 &lt;code&gt;a&lt;/code&gt; 的值从 1 继续增大（&lt;code&gt;a = 2, 3, …&lt;/code&gt;）进入纯范围扫描后，&lt;code&gt;b&lt;/code&gt; 字段再次失去有序性，后续的 &lt;code&gt;b = 2&lt;/code&gt; 条件退化为回表过滤。但至少在 &lt;code&gt;a = 1&lt;/code&gt; 这个精确匹配的子范围上，&lt;code&gt;b&lt;/code&gt; 字段的索引是可用的。&lt;/p&gt;
&lt;h3&gt;核心规则&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;查询条件&lt;/th&gt;
&lt;th&gt;字段 a&lt;/th&gt;
&lt;th&gt;字段 b&lt;/th&gt;
&lt;th&gt;原因&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;a &amp;gt; 1 AND b = 2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;命中索引&lt;/td&gt;
&lt;td&gt;未命中&lt;/td&gt;
&lt;td&gt;&lt;code&gt;a &amp;gt; 1&lt;/code&gt; 是纯范围，&lt;code&gt;b&lt;/code&gt; 在范围内无序&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;a &amp;gt;= 1 AND b = 2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;命中索引&lt;/td&gt;
&lt;td&gt;命中（&lt;code&gt;a=1&lt;/code&gt; 子范围）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;a = 1&lt;/code&gt; 是精确值，&lt;code&gt;b&lt;/code&gt; 在该子范围内有序&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;更一般地：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;范围条件&lt;/strong&gt;（&lt;code&gt;&amp;gt;&lt;/code&gt;、&lt;code&gt;&amp;lt;&lt;/code&gt;、&lt;code&gt;BETWEEN&lt;/code&gt;、&lt;code&gt;LIKE &apos;abc%&apos;&lt;/code&gt;）会打断联合索引后续字段的有序性&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;等值条件&lt;/strong&gt;保持后续字段的有序性，后续字段可以继续利用索引&lt;/li&gt;
&lt;li&gt;当查询同时包含范围和等值条件时，索引从&lt;strong&gt;第一个范围条件&lt;/strong&gt;处&quot;断裂&quot;，该字段之后的字段无法利用索引排序&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这就是联合索引设计中&lt;strong&gt;最左前缀原则&lt;/strong&gt;的延伸：不仅要考虑字段的书写顺序，还要考虑查询条件是等值还是范围——范围条件会阻断其后字段的索引使用。&lt;/p&gt;
&lt;h2&gt;索引区分度&lt;/h2&gt;
&lt;p&gt;建立联合索引时的字段顺序对索引效率有很大影响。&lt;strong&gt;区分度&lt;/strong&gt;（Cardinality）是指字段中不重复值的数量与总行数的比值，区分度越高，索引过滤效果越好。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql-----%E7%B4%A2%E5%BC%95/file-20260507150121405.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;低区分度字段&lt;/strong&gt;（如性别，只有男/女两个值）：无论搜索哪个值，都可能命中接近一半的数据行，索引的价值有限&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;高区分度字段&lt;/strong&gt;（如 UUID、手机号）：每个值几乎唯一，索引可以快速定位到极少量的记录&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，建立联合索引时应将&lt;strong&gt;区分度大的字段排在前面&lt;/strong&gt;，使其更有可能被更多的 SQL 语句利用。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;当索引列的值命中率超过约 30% 时，MySQL 查询优化器可能会放弃使用索引，转为全表扫描——因为此时顺序 I/O 的效率反而高于频繁的随机 I/O。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;什么时候应该建索引&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;字段有&lt;strong&gt;唯一性约束&lt;/strong&gt;，如商品编码、身份证号&lt;/li&gt;
&lt;li&gt;经常出现在 &lt;code&gt;WHERE&lt;/code&gt; 条件中的字段，可提高查询速度；多个条件字段可建立联合索引&lt;/li&gt;
&lt;li&gt;经常出现在 &lt;code&gt;GROUP BY&lt;/code&gt; 和 &lt;code&gt;ORDER BY&lt;/code&gt; 中的字段，索引已经排好序，查询时无需额外排序操作&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;什么时候不需要建索引&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;WHERE&lt;/code&gt;、&lt;code&gt;GROUP BY&lt;/code&gt;、&lt;code&gt;ORDER BY&lt;/code&gt; 中用不到的字段——索引的价值在于快速定位，无法发挥作用的索引只会占用物理空间&lt;/li&gt;
&lt;li&gt;字段中存在&lt;strong&gt;大量重复数据&lt;/strong&gt;（如性别），且数据分布均匀——查询优化器会判断命中率过高而忽略索引，进行全表扫描&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;表数据量过小&lt;/strong&gt;（通常少于几万条）——全表扫描的代价很低，索引带来的收益不明显&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;频繁更新的字段&lt;/strong&gt;——每次修改都需要维护 B+ 树的有序性，频繁重建索引会拖累写入性能。例如电商项目的用户余额字段不适合建索引&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;索引优化策略&lt;/h2&gt;
&lt;h3&gt;前缀索引&lt;/h3&gt;
&lt;p&gt;对大文本字段（如 &lt;code&gt;TEXT&lt;/code&gt;、&lt;code&gt;VARCHAR(500)&lt;/code&gt;）建立索引时，不需要用全部内容，只需取&lt;strong&gt;前几个字符&lt;/strong&gt;作为索引键即可大幅减少索引空间。语法示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ALTER TABLE t_user ADD INDEX idx_email (email(10));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前缀长度的选择需要在区分度和空间之间权衡——可以通过 &lt;code&gt;SELECT COUNT(DISTINCT LEFT(email, n))&lt;/code&gt; 来测试不同长度的区分度。&lt;/p&gt;
&lt;h3&gt;覆盖索引&lt;/h3&gt;
&lt;p&gt;覆盖索引的目的是&lt;strong&gt;减少回表&lt;/strong&gt;。当查询所需的全部字段都包含在索引中时，MySQL 可以直接从索引返回结果，无需再回到聚簇索引读取完整行数据。&lt;/p&gt;
&lt;p&gt;例如，要查询女性中年龄为 21 岁的人，并且需要返回性别字段：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT gender, age FROM t_user WHERE gender = &apos;female&apos; AND age = 21;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;即使 &lt;code&gt;gender&lt;/code&gt; 字段单独的区分度很低，为了减少回表，也可以将其加入联合索引（如 &lt;code&gt;(age, gender)&lt;/code&gt;），让查询直接从索引中拿到 &lt;code&gt;gender&lt;/code&gt; 的值，避免回表开销。&lt;/p&gt;
&lt;p&gt;如果 &lt;code&gt;gender&lt;/code&gt; 只出现在 &lt;code&gt;WHERE&lt;/code&gt; 条件中而不需要 &lt;code&gt;SELECT&lt;/code&gt; 返回，则不必将其放入联合索引。&lt;/p&gt;
&lt;h3&gt;主键选择自增&lt;/h3&gt;
&lt;p&gt;InnoDB 的聚簇索引将数据存储在 B+ 树的叶子节点中，叶子节点内的数据按主键顺序排列。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;自增主键&lt;/strong&gt;：每次插入的新记录追加到当前索引的末尾，页面写满后开辟新页面，无需移动已有数据，插入效率最高&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;随机主键&lt;/strong&gt;（如 UUID）：每次插入可能落在已有数据页的中间位置，需要移动其他记录腾出空间，甚至触发&lt;strong&gt;页分裂&lt;/strong&gt;——将一个页面的数据拆分到两个页面，造成内存碎片和索引结构不紧凑，影响查询效率&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;索引列设为 NOT NULL&lt;/h3&gt;
&lt;p&gt;索引列应尽量设为 &lt;code&gt;NOT NULL&lt;/code&gt;，原因有二：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;优化器复杂度&lt;/strong&gt;：允许 &lt;code&gt;NULL&lt;/code&gt; 的列会使索引统计和值比较更复杂，例如 &lt;code&gt;COUNT&lt;/code&gt; 会忽略 &lt;code&gt;NULL&lt;/code&gt; 行，增加优化器选择最优执行计划的难度&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;存储空间&lt;/strong&gt;：InnoDB 的行格式中，允许 &lt;code&gt;NULL&lt;/code&gt; 的字段需要额外的 NULL 值列表（位图）来标记哪些字段为 &lt;code&gt;NULL&lt;/code&gt;，至少占用 1 字节&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql-----%E7%B4%A2%E5%BC%95/file-20260507152706298.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;上图紫色部分即为 Compact 行格式中 NULL 值列表所占用的空间。&lt;/p&gt;
&lt;h2&gt;防止索引失效&lt;/h2&gt;
&lt;p&gt;用上了索引不意味着查询时一定会使用索引。以下情况会导致索引失效，编写 SQL 时应尽量避免：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;失效场景&lt;/th&gt;
&lt;th&gt;示例&lt;/th&gt;
&lt;th&gt;原因&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;左模糊或左右模糊匹配&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LIKE &apos;%xx&apos;&lt;/code&gt;、&lt;code&gt;LIKE &apos;%xx%&apos;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;B+ 树按前缀排序，无法用前缀未知的模式定位&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;索引列参与计算或函数&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WHERE id + 1 = 20&lt;/code&gt;、&lt;code&gt;WHERE YEAR(create_time) = 2025&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;索引存储的是原始值，计算/函数后的结果无法匹配索引&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;违反最左前缀原则&lt;/td&gt;
&lt;td&gt;联合索引 &lt;code&gt;(a, b)&lt;/code&gt;，查询只用 &lt;code&gt;WHERE b = 2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;跳过了最左字段 &lt;code&gt;a&lt;/code&gt;，索引无法命中&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OR 连接的混合条件&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WHERE a = 1 OR b = 2&lt;/code&gt;（仅 &lt;code&gt;a&lt;/code&gt; 有索引）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OR&lt;/code&gt; 后的非索引列会导致整个查询退化为全表扫描&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql-----%E7%B4%A2%E5%BC%95/file-20260507153309837.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;联合索引的核心在于理解 B+ 树的&lt;strong&gt;排序规则&lt;/strong&gt;：等值条件保持后续字段的有序性，范围条件打断有序性。在此基础上，索引设计应遵循区分度优先、合理使用覆盖索引、选择自增主键、避免 NULL 等原则，同时注意常见的索引失效场景，才能让索引真正发挥作用。&lt;/p&gt;
</content:encoded></item><item><title>MySQL InnoDB 数据页结构与 B+ 树索引原理</title><link>https://blog.huangnv.online/posts/2657/2/2/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2657/2/2/</guid><description>从数据页的七个组成部分出发，讲解页内记录的链表组织和页目录索引机制，延伸到 B+ 树的多页查询、聚簇索引与二级索引的区别，以及回表和索引覆盖的概念。</description><pubDate>Thu, 07 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;MySQL InnoDB 数据页结构与 B+ 树索引原理&lt;/h1&gt;
&lt;h2&gt;为什么需要数据页&lt;/h2&gt;
&lt;p&gt;MySQL 支持多种存储引擎，不同引擎的数据存储方式各异，最常用的是 InnoDB。InnoDB 的读写不以「行」为单位——一次 I/O 操作只读一行数据效率极低。InnoDB 以&lt;strong&gt;数据页&lt;/strong&gt;为基本 I/O 单位，默认大小 16 KB，读取一条记录时会将其所在的整体页加载到内存中。&lt;/p&gt;
&lt;h2&gt;数据页的七个组成部分&lt;/h2&gt;
&lt;p&gt;一个 InnoDB 数据页由以下七个部分组成：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E6%95%B0%E6%8D%AE%E9%A1%B5%E5%AD%98%E5%82%A8/file-20260507162101320.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;各部分的作用如下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E6%95%B0%E6%8D%AE%E9%A1%B5%E5%AD%98%E5%82%A8/file-20260507162126539.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;页间的双向链表&lt;/h2&gt;
&lt;p&gt;File Header 中包含两个指针，分别指向上一个数据页和下一个数据页，将所有数据页串联为一条&lt;strong&gt;双向链表&lt;/strong&gt;：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E6%95%B0%E6%8D%AE%E9%A1%B5%E5%AD%98%E5%82%A8/file-20260507162449302.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;通过这条链表，InnoDB 可以在逻辑上按顺序遍历所有数据页。需要注意的是，这些页在逻辑上是连续的，但在物理磁盘上不一定相邻——页之间的物理位置取决于分配时的实际情况。&lt;/p&gt;
&lt;h2&gt;User Records：页内记录的链表组织&lt;/h2&gt;
&lt;p&gt;数据页的核心职责是存储记录，这一工作由 User Records 区域承担。&lt;/p&gt;
&lt;p&gt;页内的记录按照&lt;strong&gt;主键顺序&lt;/strong&gt;组成单向链表。每条记录的头信息中包含 &lt;code&gt;next_record&lt;/code&gt; 指针，指向页内下一条记录的位置。单向链表的优势在于插入和删除操作简单高效，但检索效率较低——最坏情况下需要遍历整条链表才能找到目标记录。&lt;/p&gt;
&lt;p&gt;为了弥补链表检索的不足，InnoDB 引入了页目录（Page Directory）作为页内的索引结构。&lt;/p&gt;
&lt;h2&gt;Page Directory：页内的索引机制&lt;/h2&gt;
&lt;p&gt;页目录的作用类似于一本书的目录：每个章节对应一个目录条目，想看某个章节时先查目录再翻到对应页码，避免从头逐页翻阅。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E6%95%B0%E6%8D%AE%E9%A1%B5%E5%AD%98%E5%82%A8/file-20260507202700980.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;页目录的构建过程&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;分组&lt;/strong&gt;：将页内所有记录划分为若干个组，包括 Infimum 和 Supremum 两条伪记录，但不包括已删除的记录&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;组内标记&lt;/strong&gt;：每组的最后一条记录即为该组的最大记录，其头信息中的 &lt;code&gt;n_owned&lt;/code&gt; 字段记录了该组包含多少条记录（上图中粉红色字段）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;存储槽位&lt;/strong&gt;：页目录中按顺序存储每组最后一条记录的地址偏移量，每个偏移量称为一个&lt;strong&gt;槽（Slot）&lt;/strong&gt;，相当于指向该组最后一条记录的指针&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Infimum 和 Supremum 是两条伪记录，不存储真实数据，分别作为页内记录链表的最小和最大哨兵节点。&lt;/p&gt;
&lt;h3&gt;分组规则&lt;/h3&gt;
&lt;p&gt;InnoDB 对每个分组的记录数量有明确规定：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;分组&lt;/th&gt;
&lt;th&gt;记录条数&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;第一个分组&lt;/td&gt;
&lt;td&gt;仅 1 条（Infimum）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;最后一个分组&lt;/td&gt;
&lt;td&gt;1 ~ 8 条&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;其余分组&lt;/td&gt;
&lt;td&gt;4 ~ 8 条&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;查找过程示例&lt;/h3&gt;
&lt;p&gt;以上图为例，假设 5 个槽的编号分别为 0、1、2、3、4，要查找主键为 11 的记录：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;第一次二分&lt;/strong&gt;：中间位 = (0 + 4) / 2 = 2，2 号槽最大记录的主键为 8。因为 11 &amp;gt; 8，目标在 2 号槽之后&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第二次二分&lt;/strong&gt;：中间位 = (2 + 4) / 2 = 3，3 号槽最大记录的主键为 12。因为 11 &amp;lt; 12，目标在 3 号槽内&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;组内遍历&lt;/strong&gt;：3 号槽对应最大主键 12，最小主键 9（通过槽 2 的最大记录 8 的下一条定位）。从主键 9 开始遍历 2 次，定位到主键 11 的记录&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这种&quot;二分查找 + 组内遍历&quot;的方式，将单页内的检索复杂度从 O(n) 降低到了接近 O(log n)。&lt;/p&gt;
&lt;h2&gt;B+ 树：跨页查询的索引结构&lt;/h2&gt;
&lt;p&gt;上述讨论都局限在单个数据页内部。当数据量增长到需要多个数据页时，如何快速定位目标记录所在的页？&lt;/p&gt;
&lt;p&gt;InnoDB 采用 &lt;strong&gt;B+ 树&lt;/strong&gt;作为索引结构。为了减少磁盘 I/O 次数，B+ 树被设计为&quot;矮胖&quot;结构——每一层容纳大量指针，使树的高度很低（通常 3~4 层就能存储上亿条记录）。B+ 树中的每个节点都是一个数据页：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E6%95%B0%E6%8D%AE%E9%A1%B5%E5%AD%98%E5%82%A8/file-20260507213550257.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;B+ 树的特点&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;只有叶子节点存储数据&lt;/strong&gt;，非叶子节点仅存储目录项（索引键 + 指针），作为索引使用&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;非叶子节点分层&lt;/strong&gt;，通过分层降低每一层的搜索量&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;所有节点按索引键排序&lt;/strong&gt;，叶子节点之间通过双向链表串联，便于范围查询&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;查找过程示例&lt;/h3&gt;
&lt;p&gt;以查找主键为 6 的记录为例：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;根节点&lt;/strong&gt;：通过二分法定位到范围 [1, 7) 对应的页指针，跳转到页 30&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;非叶子节点（页 30）&lt;/strong&gt;：继续二分，主键 6 &amp;gt; 5，跳转到叶子节点页 16&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;叶子节点（页 16）&lt;/strong&gt;：在页目录中二分定位到目标槽，再在槽内遍历找到主键 6 的记录&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;可以看到，整个过程在每一层都使用二分法定位，从根到叶子只需要 2~3 次磁盘 I/O。&lt;/p&gt;
&lt;h2&gt;聚簇索引与二级索引&lt;/h2&gt;
&lt;p&gt;索引按叶子节点存储内容的不同，分为&lt;strong&gt;聚簇索引&lt;/strong&gt;和&lt;strong&gt;二级索引&lt;/strong&gt;：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;索引类型&lt;/th&gt;
&lt;th&gt;叶子节点存储&lt;/th&gt;
&lt;th&gt;数量限制&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;聚簇索引&lt;/td&gt;
&lt;td&gt;完整的行数据&lt;/td&gt;
&lt;td&gt;每张表只能有一个&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;二级索引&lt;/td&gt;
&lt;td&gt;主键值&lt;/td&gt;
&lt;td&gt;每张表可以有多个&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;聚簇索引&lt;/h3&gt;
&lt;p&gt;聚簇索引的叶子节点存储的是实际数据，所有完整的用户记录都存放在聚簇索引的叶子节点中。由于数据在物理上只保存一份，每张表只能有一个聚簇索引。&lt;/p&gt;
&lt;p&gt;InnoDB 创建聚簇索引时的选择规则：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;有主键 → 使用主键作为索引键&lt;/li&gt;
&lt;li&gt;无主键但有不含 NULL 的唯一列 → 使用该唯一列&lt;/li&gt;
&lt;li&gt;以上都没有 → 自动生成隐式自增 &lt;code&gt;row_id&lt;/code&gt; 列&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;二级索引&lt;/h3&gt;
&lt;p&gt;二级索引（也称非聚簇索引或辅助索引）同样使用 B+ 树结构，但叶子节点存储的是&lt;strong&gt;主键值&lt;/strong&gt;而非实际数据：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql------%E6%95%B0%E6%8D%AE%E9%A1%B5%E5%AD%98%E5%82%A8/file-20260507215515523.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;回表与索引覆盖&lt;/h3&gt;
&lt;p&gt;使用二级索引查询时，根据查询字段的不同有两种情况：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;回表&lt;/strong&gt;：查询的数据不在二级索引中。需要先在二级索引中找到主键值，再到聚簇索引中查找完整行数据，相当于查了两棵 B+ 树&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;索引覆盖&lt;/strong&gt;：查询的数据恰好是主键值（或二级索引已包含的字段）。只需在二级索引中即可返回结果，无需回表，只查一棵 B+ 树&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;机制&lt;/th&gt;
&lt;th&gt;解决的问题&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;双向链表（File Header）&lt;/td&gt;
&lt;td&gt;页与页之间的逻辑顺序遍历&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;单向链表（User Records）&lt;/td&gt;
&lt;td&gt;页内记录的灵活插入和删除&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;页目录（Page Directory）&lt;/td&gt;
&lt;td&gt;页内记录的快速定位，弥补链表检索的不足&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B+ 树&lt;/td&gt;
&lt;td&gt;跨页的高效索引查找，3~4 次 I/O 定位上亿记录&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;聚簇索引&lt;/td&gt;
&lt;td&gt;叶子节点存实际数据，每表唯一&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;二级索引&lt;/td&gt;
&lt;td&gt;叶子节点存主键值，支持非主键字段的快速搜索&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;回表 / 索引覆盖&lt;/td&gt;
&lt;td&gt;二级索引查询时是否需要回到聚簇索引取数据&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
</content:encoded></item><item><title>MySQL 数据存储：从磁盘文件到 InnoDB 逻辑结构</title><link>https://blog.huangnv.online/posts/2656/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2656/1/1/</guid><description>介绍 MySQL 数据库在磁盘上的文件组织方式，以及 InnoDB 存储引擎内部的行、页、区、段四层逻辑存储结构，最后深入 Compact 行格式的内部组成。</description><pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;MySQL 数据存储：从磁盘文件到 InnoDB 逻辑结构&lt;/h1&gt;
&lt;h2&gt;数据库的磁盘文件&lt;/h2&gt;
&lt;p&gt;每创建一个 database，MySQL 都会在 &lt;code&gt;/var/lib/mysql/&lt;/code&gt; 目录下生成一个同名子目录，该表的结构定义和数据文件均存放于此。以 &lt;code&gt;t_order&lt;/code&gt; 表为例，目录中包含以下三个文件：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;文件&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;db.opt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;记录当前数据库的默认字符集与校验规则&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;t_order.frm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;存储 &lt;code&gt;t_order&lt;/code&gt; 的表结构定义（元数据）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;t_order.ibd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;存储 &lt;code&gt;t_order&lt;/code&gt; 的实际数据与索引（独立表空间）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;注意&lt;/strong&gt;：MySQL 8.0 已移除 &lt;code&gt;.frm&lt;/code&gt; 文件，表元数据改由数据字典（Data Dictionary）统一管理。上述 &lt;code&gt;.frm&lt;/code&gt; 描述适用于 MySQL 5.7 及更早版本。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;InnoDB 逻辑存储结构&lt;/h2&gt;
&lt;p&gt;InnoDB 的表空间在逻辑上由四层结构组成，从大到小依次为：&lt;strong&gt;段（Segment）→ 区（Extent）→ 页（Page）→ 行（Row）&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql-----%E6%95%B0%E6%8D%AE%E5%AD%98%E5%82%A8/file-20260506222244645.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;graph TD
    TS[表空间 Tablespace] --&amp;gt; S1[段 Segment]
    TS --&amp;gt; S2[段 Segment]
    S1 --&amp;gt; E1[区 Extent 1MB]
    S1 --&amp;gt; E2[区 Extent 1MB]
    E1 --&amp;gt; P1[页 Page 16KB]
    E1 --&amp;gt; P2[页 Page 16KB]
    E1 --&amp;gt; P3[... 共 64 页]
    P1 --&amp;gt; R1[行 Row]
    P1 --&amp;gt; R2[行 Row]
    P1 --&amp;gt; R3[... ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;下面逐层说明。&lt;/p&gt;
&lt;h3&gt;行（Row）&lt;/h3&gt;
&lt;p&gt;行是表中数据的最小逻辑单位，每一行对应一条完整的记录。InnoDB 默认以行格式 &lt;code&gt;DYNAMIC&lt;/code&gt; 存储，每行除了用户定义的列数据外，还包含事务 ID、回滚指针等隐藏字段，用于支持 MVCC 和事务特性。&lt;/p&gt;
&lt;h3&gt;页（Page）&lt;/h3&gt;
&lt;p&gt;页是 InnoDB &lt;strong&gt;磁盘 I/O 的最小单位&lt;/strong&gt;，默认大小为 16 KB。&lt;/p&gt;
&lt;p&gt;InnoDB 不会单独读写某一行，而是以页为粒度进行磁盘操作：一次最少从磁盘读取 16 KB 到内存，一次最少将内存中 16 KB 的内容刷新到磁盘。这样做的原因是随机 I/O 代价极高，按页读写可以将相邻记录一并加载，用一次 I/O 操作换取多行数据的访问效率。&lt;/p&gt;
&lt;p&gt;页的常见类型包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;数据页（B-tree Node）&lt;/strong&gt;：存储索引和行数据&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Undo 日志页&lt;/strong&gt;：存放事务回滚所需的旧版本数据&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;溢出页（Overflow Page）&lt;/strong&gt;：当行数据超过页内可容纳长度时，多余部分存入溢出页&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;系统页&lt;/strong&gt;：存储数据字典、事务系统等元信息&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;区（Extent）&lt;/h3&gt;
&lt;p&gt;当表的数据量增长到一定程度，逐页分配空间会导致 B+ 树中逻辑上相邻的页在物理上分散各处，顺序扫描退化为随机 I/O。&lt;/p&gt;
&lt;p&gt;为此，InnoDB 引入&lt;strong&gt;区&lt;/strong&gt;作为批量分配单位：每个区固定为 &lt;strong&gt;1 MB&lt;/strong&gt;，包含连续的 64 个页（64 × 16 KB = 1 MB）。以区为单位分配空间后，同一区内的页在磁盘上物理相邻，B+ 树叶子节点的链表遍历可以转化为顺序 I/O，大幅提升范围查询的性能。
需要注意的是，区保证的是&lt;strong&gt;物理连续&lt;/strong&gt;（页在磁盘上相邻），但不保证&lt;strong&gt;逻辑连续&lt;/strong&gt;（B+ 树叶子链表的遍历顺序）。以下面一个区内的 8 个页为例：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;物理排列（磁盘上的实际位置）：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┐
│ Page 1 │ Page 2 │ Page 3 │ Page 4 │ Page 5 │ Page 6 │ Page 7 │ Page 8 │
└────────┴────────┴────────┴────────┴────────┴────────┴────────┴────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;各页存储的数据范围：&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;页&lt;/th&gt;
&lt;th&gt;存储的 ID 范围&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Page 1&lt;/td&gt;
&lt;td&gt;301 ~ 400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page 2&lt;/td&gt;
&lt;td&gt;101 ~ 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page 3&lt;/td&gt;
&lt;td&gt;501 ~ 600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page 4&lt;/td&gt;
&lt;td&gt;701 ~ 800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page 5&lt;/td&gt;
&lt;td&gt;201 ~ 300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page 6&lt;/td&gt;
&lt;td&gt;601 ~ 700&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page 7&lt;/td&gt;
&lt;td&gt;401 ~ 500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page 8&lt;/td&gt;
&lt;td&gt;1 ~ 100&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;B+ 树叶子链表的逻辑遍历顺序：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Page 8 → Page 2 → Page 5 → Page 1 → Page 7 → Page 3 → Page 6 → Page 4
 (1-100)   (101-200)  (201-300)  (301-400)  (401-500)  (501-600)  (601-700)  (701-800)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看到，物理上相邻的页在逻辑遍历中可能相隔很远。区的意义在于：即使逻辑顺序和物理顺序不一致，同一个区内的页仍然在磁盘的同一区域，跨页跳转时磁头移动距离有限，比分散在整个表空间中的随机 I/O 效率高得多。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;数据量较小时（≤ 32 页），InnoDB 不会分配完整的区，而是从表空间中零散借用单个页（称为 fragment pages），最多借用 32 个。超过 32 页后才开始以区为单位分配。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;段（Segment）&lt;/h3&gt;
&lt;p&gt;段是区之上的逻辑分组，对应 B+ 树中具有不同职责的区域。InnoDB 中主要有三种段：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;段类型&lt;/th&gt;
&lt;th&gt;存放内容&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;数据段&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;B+ 树叶子节点的区集合，存储实际行数据&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;索引段&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;B+ 树非叶子节点的区集合，存储索引键和指针&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;回滚段&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;回滚数据的区集合，MVCC 多版本查询依赖此段获取旧版本记录&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;回滚段是 InnoDB 事务隔离机制的核心组件之一。当事务需要读取某行的历史版本时，会沿着回滚指针在回滚段中找到对应的 Undo 日志，重建出该行在事务开始时的状态。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;小结&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;层级&lt;/th&gt;
&lt;th&gt;大小&lt;/th&gt;
&lt;th&gt;职责&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;段&lt;/td&gt;
&lt;td&gt;不固定&lt;/td&gt;
&lt;td&gt;将不同用途的区（数据、索引、回滚）分组管理&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;区&lt;/td&gt;
&lt;td&gt;1 MB（64 页）&lt;/td&gt;
&lt;td&gt;批量分配物理空间，保证相邻页物理连续&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;页&lt;/td&gt;
&lt;td&gt;16 KB&lt;/td&gt;
&lt;td&gt;磁盘 I/O 的最小单位，缓存池管理的基本单元&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;行&lt;/td&gt;
&lt;td&gt;不固定&lt;/td&gt;
&lt;td&gt;单条记录的逻辑载体，包含隐藏的事务字段&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;InnoDB 行格式&lt;/h2&gt;
&lt;p&gt;了解了页和区的宏观结构后，下面深入到最基础的存储单元——行的内部格式。InnoDB 支持多种行格式（&lt;code&gt;COMPACT&lt;/code&gt;、&lt;code&gt;REDUNDANT&lt;/code&gt;、&lt;code&gt;DYNAMIC&lt;/code&gt;、&lt;code&gt;COMPRESSED&lt;/code&gt;），这里以 &lt;code&gt;COMPACT&lt;/code&gt; 格式为例说明。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql-----%E6%95%B0%E6%8D%AE%E5%AD%98%E5%82%A8/file-20260507001121232.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;一条完整的记录由两部分组成：&lt;strong&gt;记录的额外信息&lt;/strong&gt;和&lt;strong&gt;记录的真实数据&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;记录的额外信息&lt;/h3&gt;
&lt;p&gt;额外信息包含三个部分：变长字段长度列表、NULL 值列表、记录头信息。&lt;/p&gt;
&lt;h4&gt;变长字段长度列表&lt;/h4&gt;
&lt;p&gt;该部分存储当前行中各变长字段实际占用的字节数。以如下建表语句为例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE TABLE `t_user` (
  `id`    INT(11)      NOT NULL,
  `name`  VARCHAR(20)  DEFAULT NULL,
  `phone` VARCHAR(20)  DEFAULT NULL,
  `age`   INT(11)      DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=ascii ROW_FORMAT=COMPACT;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;表中 &lt;code&gt;name&lt;/code&gt; 和 &lt;code&gt;phone&lt;/code&gt; 是两个变长字段，它们在每行中的实际长度不固定，因此需要在额外信息中记录各自的字节数。例如 &lt;code&gt;name = &apos;abcd&apos;&lt;/code&gt; 时，变长字段长度列表中对应位置存储 &lt;code&gt;0x04&lt;/code&gt;（4 个字节）。每个变长字段的长度占用最多 2 字节，因此 &lt;code&gt;VARCHAR&lt;/code&gt; 的最大长度为 65535。&lt;/p&gt;
&lt;p&gt;变长字段长度列表采用&lt;strong&gt;逆序存储&lt;/strong&gt;。这是因为索引查询通常将最后一个字段作为最终判断条件，前面的字段在索引筛选阶段已经匹配完毕，逆序存储可以优先访问最后列的长度信息，减少不必要的解析开销。&lt;/p&gt;
&lt;h4&gt;NULL 值列表&lt;/h4&gt;
&lt;p&gt;当表中存在允许为 &lt;code&gt;NULL&lt;/code&gt; 的字段时，InnoDB 会用一个位图来标记哪些字段的值为 &lt;code&gt;NULL&lt;/code&gt;。每个字段对应一个比特位：值为 &lt;code&gt;NULL&lt;/code&gt; 时该位标记为 1，否则为 0。整个位图按字节对齐，例如 9 个可空字段需要 2 字节（16 位，高 7 位补零）。&lt;/p&gt;
&lt;p&gt;对于定义为 &lt;code&gt;NOT NULL&lt;/code&gt; 的字段，不占用 NULL 值列表的空间。&lt;/p&gt;
&lt;h4&gt;记录头信息&lt;/h4&gt;
&lt;p&gt;记录头信息固定占用 5 字节，其中几个关键字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;delete_mask&lt;/code&gt;&lt;/strong&gt;：标记该记录是否被删除。执行 &lt;code&gt;DELETE&lt;/code&gt; 语句时，InnoDB 并不会立即从磁盘移除记录，而是将此标记置为 1，后续由后台线程统一清理。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;next_record&lt;/code&gt;&lt;/strong&gt;：指向当前页内下一条记录的位置。记录之间通过单向链表串联，指针指向的是下一条记录的「记录头信息」与「真实数据」之间的位置——向左读取头信息，向右读取真实数据。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;record_type&lt;/code&gt;&lt;/strong&gt;：记录类型，&lt;code&gt;0&lt;/code&gt; 表示普通记录，&lt;code&gt;1&lt;/code&gt; 表示 B+ 树非叶子节点记录，&lt;code&gt;2&lt;/code&gt; 表示最小记录（Infimum），&lt;code&gt;3&lt;/code&gt; 表示最大记录（Supremum）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;记录的真实数据&lt;/h3&gt;
&lt;p&gt;真实数据区域除了用户定义的列值外，还包含三个隐藏字段：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql-----%E6%95%B0%E6%8D%AE%E5%AD%98%E5%82%A8/file-20260507002337323.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;隐藏字段&lt;/th&gt;
&lt;th&gt;大小&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;row_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6 字节&lt;/td&gt;
&lt;td&gt;仅在表未定义主键且无唯一约束时由 InnoDB 自动生成，非必需&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;trx_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6 字节&lt;/td&gt;
&lt;td&gt;生成该记录的事务 ID，MVCC 版本控制的核心字段，必需&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;roll_pointer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;7 字节&lt;/td&gt;
&lt;td&gt;指向上一个版本的 Undo 日志位置，用于版本链遍历，必需&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;如果建表时指定了主键或唯一约束列，&lt;code&gt;row_id&lt;/code&gt; 隐藏字段不会出现。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;行溢出处理&lt;/h2&gt;
&lt;p&gt;一个页的大小为 16 KB（16384 字节），而 &lt;code&gt;VARCHAR(n)&lt;/code&gt; 最大可存储 65532 字节，&lt;code&gt;TEXT&lt;/code&gt;、&lt;code&gt;BLOB&lt;/code&gt; 等大对象类型可能更大。当单行数据超过一页能容纳的上限时，就会发生&lt;strong&gt;行溢出&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;发生行溢出时，InnoDB 的处理策略是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;在原数据页的真实数据区域只保留该列的&lt;strong&gt;前 768 字节&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;剩余数据写入独立的&lt;strong&gt;溢出页（Overflow Page）&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;原记录中用 &lt;strong&gt;20 字节的指针&lt;/strong&gt;指向溢出页的地址&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;整体结构如下图所示：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/mysql-----%E6%95%B0%E6%8D%AE%E5%AD%98%E5%82%A8/file-20260507002623266.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;行溢出通常发生在单行数据接近或超过 8 KB 时——因为页内还需要预留空间给页头、页尾以及其他记录，实际可用空间小于 16 KB。
&lt;img src=&quot;./assets/mysql-----%E6%95%B0%E6%8D%AE%E5%AD%98%E5%82%A8/file-20260507002735535.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>HTTPS 原理：加密、证书与 TLS 握手</title><link>https://blog.huangnv.online/posts/26427/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26427/1/1/</guid><description>从明文 HTTP 的风险出发，梳理对称加密、非对称加密、数字证书与 TLS 握手之间的关系，说明 HTTPS 如何同时解决机密性与身份认证问题。</description><pubDate>Mon, 27 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;HTTP 本身不提供加密能力。如果账号密码、Cookie 或业务数据直接以明文在网络上传输，链路中的中间节点就可以窃听，甚至篡改内容。因此，HTTPS 的本质并不是“更安全的 HTTP 版本号”，而是在 HTTP 之下引入 TLS，让通信同时具备机密性、完整性和身份认证能力。&lt;/p&gt;
&lt;h2&gt;为什么不能只靠对称加密&lt;/h2&gt;
&lt;p&gt;对称加密的特点是加密和解密使用同一把密钥。它的优点很明显：计算开销相对更低，适合在正式传输阶段持续处理大量应用数据。&lt;/p&gt;
&lt;p&gt;但它有一个关键问题：密钥怎么安全地发给对方？&lt;/p&gt;
&lt;p&gt;如果客户端和服务器还没有建立可信信道，那么“把对称密钥直接发过去”这件事本身就会暴露密钥。一旦密钥在传输途中被窃取，后续所有加密数据都等于失去保护。&lt;/p&gt;
&lt;p&gt;所以，HTTPS 不能只依赖对称加密。它还需要一种机制，先把“如何安全协商会话密钥”这个问题解决掉。&lt;/p&gt;
&lt;h2&gt;非对称加密解决了密钥分发问题&lt;/h2&gt;
&lt;p&gt;非对称加密使用一对密钥：公钥可以公开，私钥只由持有者自己保存。通常可以这样理解：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;公钥加密的数据，只能由对应私钥解密&lt;/li&gt;
&lt;li&gt;私钥签名的数据，可以由对应公钥验证&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在 HTTPS 的语境里，非对称加密主要不是为了“大量传业务数据”，而是为了在握手阶段完成认证或密钥协商。因为它比对称加密更耗时，所以真正的应用数据传输，仍然主要依赖后续生成的对称会话密钥。&lt;/p&gt;
&lt;h2&gt;只知道公钥还不够&lt;/h2&gt;
&lt;p&gt;如果客户端拿到的服务器公钥一定真实，那么事情会简单很多：客户端可以基于这个公钥参与密钥协商，或者在某些旧模型里直接用它加密预主密钥，再交给服务器处理。&lt;/p&gt;
&lt;p&gt;问题在于，客户端怎么确认“眼前这个公钥真的是目标服务器的公钥”？&lt;/p&gt;
&lt;p&gt;如果没有额外的认证机制，中间人完全可以拦截第一次通信，把原本属于服务器的公钥替换成自己的公钥。这样一来：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;客户端误以为自己拿到了服务器公钥&lt;/li&gt;
&lt;li&gt;实际上它拿到的是攻击者公钥&lt;/li&gt;
&lt;li&gt;客户端后续发出的密钥材料会先被攻击者解开&lt;/li&gt;
&lt;li&gt;攻击者再用真正服务器的公钥重新建立另一条连接&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这样，攻击者就站在客户端和服务器之间，分别与两端建立“看起来正常”的加密通信。也就是说，只有加密能力还不够，必须再解决“公钥真实性”问题。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart LR
    C[客户端] --&amp;gt;|请求建立安全连接| M[中间人]
    M --&amp;gt;|转发请求| S[服务器]
    S --&amp;gt;|返回真实公钥| M
    M --&amp;gt;|替换为攻击者公钥| C
    C --&amp;gt;|用假公钥发送密钥材料| M
    M --&amp;gt;|解开后再与服务器建立连接| S
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这张图表达的核心很简单：如果客户端无法确认公钥的归属，那么“加密”本身并不能阻止中间人插在中间分别和两端通信。&lt;/p&gt;
&lt;h2&gt;数字证书如何建立信任&lt;/h2&gt;
&lt;p&gt;这就是数字证书出现的原因。&lt;/p&gt;
&lt;p&gt;证书不是“把服务器公钥再加密一遍”的结果，更准确地说，它是一份包含身份信息的声明，里面通常会包含：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;站点域名等主体信息&lt;/li&gt;
&lt;li&gt;服务器公钥&lt;/li&gt;
&lt;li&gt;证书颁发者信息&lt;/li&gt;
&lt;li&gt;有效期&lt;/li&gt;
&lt;li&gt;CA 的数字签名&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这里最关键的是最后一项。CA（证书颁发机构）会用自己的私钥对证书内容做签名，客户端再用系统或浏览器内置的 CA 公钥去验证这份签名是否成立。&lt;/p&gt;
&lt;p&gt;如果攻击者想把证书里的公钥替换成自己的公钥，就必须同时重新生成一份能够通过验证的签名；但没有 CA 私钥，他做不到这一点。因此，客户端一旦发现签名不对、证书已过期、域名不匹配，或者证书链不可信，就会拒绝这次连接。&lt;/p&gt;
&lt;p&gt;从这个角度看，证书解决的不是“如何保密传公钥”，而是“如何让客户端相信这个公钥确实属于目标站点”。&lt;/p&gt;
&lt;h2&gt;TLS 握手的简化流程&lt;/h2&gt;
&lt;p&gt;把前面的几个概念串起来，HTTPS 可以理解为“先认证身份，再协商会话密钥，最后用对称加密传应用数据”的过程。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[客户端发送 ClientHello] --&amp;gt; B[服务端返回 ServerHello 和证书]
    B --&amp;gt; C[客户端验证证书链、域名和有效期]
    C --&amp;gt; D[双方协商密钥材料]
    D --&amp;gt; E[导出会话密钥]
    E --&amp;gt; F[后续 HTTP 数据使用对称加密传输]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一个简化后的 TLS 握手流程可以分成下面几步：&lt;/p&gt;
&lt;h3&gt;1. 客户端发起握手&lt;/h3&gt;
&lt;p&gt;客户端首先发送 &lt;code&gt;ClientHello&lt;/code&gt;。这里会带上自己支持的 TLS 版本、密码套件、扩展信息等，意思是“我支持哪些安全能力”。&lt;/p&gt;
&lt;h3&gt;2. 服务端返回协商结果和证书&lt;/h3&gt;
&lt;p&gt;服务器收到请求后，会返回 &lt;code&gt;ServerHello&lt;/code&gt;，选定双方都支持的一组参数，同时把自己的证书发给客户端。&lt;/p&gt;
&lt;h3&gt;3. 客户端验证证书&lt;/h3&gt;
&lt;p&gt;客户端不会“解密证书得到公钥”，而是会验证证书的签名、证书链、域名和有效期。验证通过后，客户端才能确认：证书中的公钥确实属于自己正在访问的站点。&lt;/p&gt;
&lt;h3&gt;4. 双方协商会话密钥&lt;/h3&gt;
&lt;p&gt;完成身份确认后，双方开始协商本次连接要使用的会话密钥。&lt;/p&gt;
&lt;p&gt;这里要注意一个容易混淆的点：很多入门资料会用 RSA 来讲解这个步骤，例如“客户端生成一个随机密钥，再用服务器公钥加密后发给服务器”。这个模型有助于理解“公钥如何参与密钥交换”，但它更接近 TLS 早期版本中的 RSA key transport 思路。&lt;/p&gt;
&lt;p&gt;在现代 TLS 中，更常见的是基于 &lt;code&gt;(EC)DHE&lt;/code&gt; 的临时密钥交换。它的重点不是“客户端把完整对称密钥直接发给服务器”，而是双方各自提供一部分密钥材料，最终共同推导出同一份会话密钥。这样做的一个重要收益，是可以提供前向保密。&lt;/p&gt;
&lt;h3&gt;5. 后续数据进入加密传输阶段&lt;/h3&gt;
&lt;p&gt;一旦会话密钥生成完成，后续真正的 HTTP 请求和响应，就主要使用对称加密来传输。因为这一步已经不再依赖昂贵的非对称运算，所以既能保证效率，也能保证通信内容不被旁路监听者直接读懂。&lt;/p&gt;
&lt;h2&gt;一个更直观的 HTTPS 流程&lt;/h2&gt;
&lt;p&gt;如果不刻意区分 TLS 各版本细节，可以把 HTTPS 从建立连接到发送业务数据，概括成下面这条主线：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sequenceDiagram
    participant C as 客户端
    participant S as 服务器
    C-&amp;gt;&amp;gt;S: ClientHello
    S--&amp;gt;&amp;gt;C: ServerHello + 证书
    C-&amp;gt;&amp;gt;C: 验证证书
    C-&amp;gt;&amp;gt;S: 发送密钥交换材料
    C-&amp;gt;&amp;gt;C: 计算会话密钥
    S-&amp;gt;&amp;gt;S: 计算会话密钥
    C-&amp;gt;&amp;gt;S: 发送加密的 HTTP 请求
    S--&amp;gt;&amp;gt;C: 返回加密的 HTTP 响应
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;客户端向服务器发起 HTTPS 请求。&lt;/li&gt;
&lt;li&gt;服务器返回自己的证书，以及本次握手所需的协商参数。&lt;/li&gt;
&lt;li&gt;客户端验证证书是否合法。&lt;/li&gt;
&lt;li&gt;验证通过后，客户端与服务器开始协商会话密钥。&lt;/li&gt;
&lt;li&gt;双方各自计算出同一份会话密钥。&lt;/li&gt;
&lt;li&gt;后续 HTTP 请求和响应都使用这份会话密钥进行对称加密传输。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果把每一步再解释得具体一点，大致可以这样理解：&lt;/p&gt;
&lt;h3&gt;1. 客户端发起请求&lt;/h3&gt;
&lt;p&gt;浏览器访问 &lt;code&gt;https://&lt;/code&gt; 地址时，不会直接发送明文 HTTP 业务数据，而是先发起 TLS 握手。此时客户端首先关心的不是“我要发什么业务请求”，而是“我接下来能否安全地和对方通信”。&lt;/p&gt;
&lt;h3&gt;2. 服务器发送证书&lt;/h3&gt;
&lt;p&gt;服务器会把证书发给客户端。证书里包含服务器公钥，以及证明这把公钥确实属于该站点的签名信息。也就是说，服务器先要证明“我是谁”，后面才谈得上“怎么安全通信”。&lt;/p&gt;
&lt;h3&gt;3. 客户端校验证书&lt;/h3&gt;
&lt;p&gt;客户端会检查几件事：证书是不是可信 CA 签发的、域名是否匹配、证书是否过期、证书链是否完整。只有这些检查都通过，客户端才会继续后面的密钥协商。&lt;/p&gt;
&lt;h3&gt;4. 双方协商会话密钥&lt;/h3&gt;
&lt;p&gt;这一步才真正进入“生成加密用密钥”的阶段。&lt;/p&gt;
&lt;p&gt;在现代 TLS 中，常见做法是通过 &lt;code&gt;(EC)DHE&lt;/code&gt; 交换各自的临时密钥材料，再各自推导出同一份会话密钥；在很多教学资料里，也会把这一步简化成“客户端生成一个随机密钥，再用服务器公钥加密发给服务器”的 RSA 模型。两种说法的共同点都是：真正用于后续传输的，不是证书里的公钥，而是握手阶段协商出来的会话密钥。&lt;/p&gt;
&lt;h3&gt;5. 进入加密通信&lt;/h3&gt;
&lt;p&gt;当双方都拿到同一份会话密钥后，后续的 HTTP 请求头、请求体、响应头、响应体，都会基于这份密钥进行加密和完整性保护。这样即使中间人截获数据包，也无法直接读出里面的明文内容，更不能在不被发现的前提下随意篡改内容。&lt;/p&gt;
&lt;h2&gt;RSA 在 HTTPS 中处于什么位置&lt;/h2&gt;
&lt;p&gt;RSA 仍然是理解 HTTPS 的重要入口，但需要注意它的角色边界。&lt;/p&gt;
&lt;p&gt;第一，RSA 是一种非对称算法，本身可以用于加密，也可以配合签名场景理解公私钥关系。第二，在 HTTPS 的历史实现中，RSA 的确曾经直接参与过握手阶段的密钥传输。第三，在今天的 TLS 1.3 语境下，主流做法已经转向基于 &lt;code&gt;(EC)DHE&lt;/code&gt; 的密钥交换，而证书中的公钥更多承担“证明服务器身份”的作用。&lt;/p&gt;
&lt;p&gt;所以，把“RSA 通信流程”当成理解 HTTPS 的入门模型没有问题，但如果把它直接等同于现代 HTTPS 的完整实现，就会产生偏差。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;HTTPS 之所以成立，不是因为它只做了“加密”这一件事，而是把几个机制组合在了一起：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;对称加密负责高效传输应用数据&lt;/li&gt;
&lt;li&gt;非对称密码学负责认证或参与密钥协商&lt;/li&gt;
&lt;li&gt;数字证书负责证明服务器公钥的真实性&lt;/li&gt;
&lt;li&gt;TLS 握手负责把这些机制组织成一套完整流程&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;真正理解了这四层关系，就能看清 HTTPS 的核心逻辑：先确认你连的是谁，再安全地协商密钥，最后再用这个密钥保护后续通信内容。&lt;/p&gt;
</content:encoded></item><item><title>B 树和 B+ 树</title><link>https://blog.huangnv.online/posts/26421/2/2/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26421/2/2/</guid><description>从磁盘页访问成本出发，解释为什么数据库索引通常选择 B 树或 B+ 树，而不是普通二叉搜索树。</description><pubDate>Tue, 21 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;树结构&lt;/h2&gt;
&lt;p&gt;对于有序数据的查找，树结构天然适合作为索引。只要树的高度能够保持相对平衡，查询路径的长度通常就可以控制在 &lt;code&gt;O(log n)&lt;/code&gt; 量级，也就是通过少量节点跳转逐步缩小搜索范围，最终定位到目标数据。&lt;/p&gt;
&lt;p&gt;但在数据库这类以磁盘或页缓存为主要访问对象的场景中，普通二叉搜索树并不理想。以红黑树为例，一个节点通常只保存一个关键字，数据量变大后树高仍然偏高；而磁盘访问的成本主要不在比较次数，而在随机 I/O 和页加载次数。一次节点跳转可能就意味着一次新的页访问，树越高，整体查询成本越不可控。&lt;/p&gt;
&lt;p&gt;B 树和 B+ 树的核心思路，就是让一个节点保存多个关键字，并尽量让一个节点对应一个磁盘页。这样每次 I/O 都能带回更多索引信息，树的分叉数更大，高度更低，查找过程需要访问的页数也更少。这也是它们比普通二叉搜索树更适合做数据库索引的根本原因。&lt;/p&gt;
&lt;h2&gt;B 树&lt;/h2&gt;
&lt;p&gt;B 树相比红黑树的核心优势，是一个节点可以保存多个关键字和对应数据。数据库索引通常会让一个节点尽量贴近一个磁盘页的大小，这样一次页读取就能拿到一组有序关键字，再根据关键字范围决定下一次跳转到哪个子节点。&lt;/p&gt;
&lt;p&gt;换句话说，B 树不是通过“每次只二分到左子树或右子树”来降低查找范围，而是通过更大的分叉数降低树高。树高降低以后，查询过程中需要访问的页数也会减少。实际数据库中的阶数通常很大，因此两三层的 B 树就可以管理非常多的数据。&lt;/p&gt;
&lt;p&gt;假设有下面这组数据，每条记录包含 &lt;code&gt;id&lt;/code&gt; 和 &lt;code&gt;salary&lt;/code&gt;，并且以 &lt;code&gt;id&lt;/code&gt; 作为主键建立 B 树索引。为了方便演示，先假设一个节点最多存放 2 条记录：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;id&lt;/th&gt;
&lt;th&gt;salary&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;8000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;12000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;9000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;15000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;7000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;11000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;9500&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;插入 &lt;code&gt;1&lt;/code&gt; 和 &lt;code&gt;2&lt;/code&gt; 时，两个记录可以直接放在同一个节点中：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[1 / 8000 | 2 / 12000]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;继续插入 &lt;code&gt;3&lt;/code&gt; 时，节点已经超过了最多 2 条记录的限制，此时需要把中间记录提升为父节点，左右两侧记录分别放到子节点中：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;        [2 / 12000]
       /           \
[1 / 8000]     [3 / 9000]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;后续继续插入 &lt;code&gt;4&lt;/code&gt; 和 &lt;code&gt;5&lt;/code&gt;，新的记录会沿着关键字范围落到右侧节点；当右侧节点再次溢出时，中间记录 &lt;code&gt;4&lt;/code&gt; 会被提升到父节点：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;        [2 / 12000 | 4 / 15000]
       /            |            \
[1 / 8000]     [3 / 9000]     [5 / 7000]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;接着插入 &lt;code&gt;6&lt;/code&gt;。因为 &lt;code&gt;6&lt;/code&gt; 大于根节点中的 &lt;code&gt;2&lt;/code&gt; 和 &lt;code&gt;4&lt;/code&gt;，所以它会落到最右侧子节点中。此时最右侧节点还没有超过容量限制，只需要把 &lt;code&gt;5&lt;/code&gt; 和 &lt;code&gt;6&lt;/code&gt; 按顺序放在一起：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;        [2 / 12000 | 4 / 15000]
       /            |                 \
[1 / 8000]     [3 / 9000]     [5 / 7000 | 6 / 11000]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;继续插入 &lt;code&gt;7&lt;/code&gt; 时，&lt;code&gt;7&lt;/code&gt; 仍然会落到最右侧子节点。插入之后，最右侧节点临时变成 &lt;code&gt;[5, 6, 7]&lt;/code&gt;，超过了最多 2 条记录的限制：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;        [2 / 12000 | 4 / 15000]
       /            |                       \
[1 / 8000]     [3 / 9000]     [5 / 7000 | 6 / 11000 | 7 / 9500]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个节点需要分裂。中间记录 &lt;code&gt;6&lt;/code&gt; 被提升到父节点，&lt;code&gt;5&lt;/code&gt; 和 &lt;code&gt;7&lt;/code&gt; 分别成为它左右两侧的子节点。提升之后，父节点会临时变成 &lt;code&gt;[2, 4, 6]&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;        [2 / 12000 | 4 / 15000 | 6 / 11000]
       /            |             |             \
[1 / 8000]     [3 / 9000]    [5 / 7000]    [7 / 9500]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;此时还没有结束。父节点也超过了最多 2 条记录的限制，因此需要继续分裂。分裂前的中间状态可以看成这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
  temp[&quot;2 / 12000 | 4 / 15000 | 6 / 11000&quot;]
  n1[&quot;1 / 8000&quot;]
  n3[&quot;3 / 9000&quot;]
  n5[&quot;5 / 7000&quot;]
  n7[&quot;7 / 9500&quot;]

  temp --&amp;gt; n1
  temp --&amp;gt; n3
  temp --&amp;gt; n5
  temp --&amp;gt; n7
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;根节点分裂时，中间记录 &lt;code&gt;4&lt;/code&gt; 会成为新的根节点；&lt;code&gt;2&lt;/code&gt; 留在左子节点，&lt;code&gt;6&lt;/code&gt; 留在右子节点。分裂后的结构如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
  root[&quot;4 / 15000&quot;]
  left[&quot;2 / 12000&quot;]
  right[&quot;6 / 11000&quot;]
  n1[&quot;1 / 8000&quot;]
  n3[&quot;3 / 9000&quot;]
  n5[&quot;5 / 7000&quot;]
  n7[&quot;7 / 9500&quot;]

  root --&amp;gt; left
  root --&amp;gt; right
  left --&amp;gt; n1
  left --&amp;gt; n3
  right --&amp;gt; n5
  right --&amp;gt; n7
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以插入 &lt;code&gt;7&lt;/code&gt; 时实际发生了两次分裂：第一次是最右侧叶子节点分裂并把 &lt;code&gt;6&lt;/code&gt; 上提；第二次是原根节点继续分裂并把 &lt;code&gt;4&lt;/code&gt; 上提。第二次分裂发生在根节点上，因此整棵树的高度增加了一层。&lt;/p&gt;
&lt;p&gt;这个例子里的节点容量被刻意设得很小，所以树很快就发生了分裂。真实数据库索引中的一个节点通常可以容纳大量关键字，分叉数远大于这里的演示值，因此树高增长得很慢。B 树正是通过“节点内有序查找 + 节点间范围跳转”的方式，把磁盘 I/O 次数控制在较低水平。&lt;/p&gt;
&lt;p&gt;从这个插入过程可以看出，B 树在插入时可能会先出现一个短暂的“节点溢出”状态，然后再通过分裂和关键字上提恢复平衡。这个溢出状态只是插入算法中的中间过程，不是 B 树最终允许长期存在的结构。&lt;/p&gt;
&lt;p&gt;同时，B 树的每个节点既保存索引关键字，也可以保存对应的数据记录。如果记录本身包含很多字段，节点中能容纳的关键字数量就会减少，树的分叉数也会下降，最终可能导致树更高、页访问次数更多。也正因为这个问题，数据库索引中更常见的是 B+ 树：它把内部节点主要用于索引导航，把完整数据集中放在叶子节点中，从而让内部节点更轻、更适合做多路查找。&lt;/p&gt;
&lt;h2&gt;B+ 树&lt;/h2&gt;
&lt;p&gt;对应地再来看 B+ 树。它的插入过程和 B 树一样，也会在节点溢出时发生分裂，并把分裂后的分隔关键字放到上层节点中。但 B+ 树和 B 树有几个关键区别：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;非叶子节点只保存索引关键字，不保存完整数据记录。&lt;/li&gt;
&lt;li&gt;叶子节点保存全部数据记录，并且叶子节点之间会按主键顺序连接起来，常见实现中会使用双向链表。&lt;/li&gt;
&lt;li&gt;非叶子节点中出现的关键字，在叶子节点中仍然会保留一份；真正完整的数据集合只存在于叶子层。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;还是沿用上面的数据，并继续假设一个节点最多保存 2 条记录。为了突出 B+ 树的结构差异，图中的内部节点只写 &lt;code&gt;id&lt;/code&gt;，叶子节点写完整的 &lt;code&gt;id / salary&lt;/code&gt; 记录。插入 &lt;code&gt;1&lt;/code&gt; 到 &lt;code&gt;7&lt;/code&gt; 之后，最终结构可以表示为：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./bplus-tree-final.svg&quot; alt=&quot;B+ 树最终结构示意图&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这张图里，红色的根节点 &lt;code&gt;5&lt;/code&gt; 只是第一层索引分隔点，用来判断查询应该进入左侧还是右侧内部索引页；蓝色的 &lt;code&gt;3&lt;/code&gt; 和 &lt;code&gt;7&lt;/code&gt; 继续缩小查询范围。绿色的叶子页才保存完整的 &lt;code&gt;id / salary&lt;/code&gt; 记录，并且叶子页之间通过双向链表连接。&lt;/p&gt;
&lt;p&gt;因此，B+ 树的内部节点可以放下更多索引关键字，树的分叉数更大，高度更低。查找单条记录时，查询会从根节点一路走到叶子节点；做范围查询时，只要先定位到范围起点所在的叶子节点，就可以沿着叶子节点链表顺序向后扫描。&lt;/p&gt;
</content:encoded></item><item><title>项目性能调优</title><link>https://blog.huangnv.online/posts/26419/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26419/1/1/</guid><description>bilibili-im 性能调优。</description><pubDate>Sun, 19 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这篇文章记录了我最近在调试和压测 &lt;code&gt;bilibili-im&lt;/code&gt; 过程中，定位并修复一个 WebSocket 并发写问题的过程以及性能优化相关。&lt;/p&gt;
&lt;h2&gt;WebSocket Session 并发写入问题&lt;/h2&gt;
&lt;p&gt;项目里使用了 Spring Boot WebSocket 处理 IM 消息。一次消息发送大致会经过两个阶段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;WebSocket 回调在完成身份校验和权限校验后，会先给发送方返回一个“服务端已接收”的确认消息。&lt;/li&gt;
&lt;li&gt;消息写入 MQ 后，消费者再把真正的业务消息分发给发送方和接收方。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果 MQ 处理得足够快，这两条发送链路就可能几乎同时命中同一个 &lt;code&gt;WebSocketSession&lt;/code&gt;。一旦两个执行流并发向同一个 Session 写文本消息，就会触发 Tomcat 的状态机保护，并进一步进入 Spring Boot 的 WebSocket 异常处理逻辑，表现为连接异常断开。&lt;/p&gt;
&lt;p&gt;从日志里可以看到一个很典型的报错：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;java.lang.IllegalStateException: send websocket json message failed
...
Caused by: java.lang.IllegalStateException: The remote endpoint was in state [TEXT_PARTIAL_WRITING] which is an invalid state for called method
	at org.apache.tomcat.websocket.WsRemoteEndpointImplBase$StateMachine.checkState(WsRemoteEndpointImplBase.java:1213)
	at org.apache.tomcat.websocket.WsRemoteEndpointImplBase$StateMachine.textPartialStart(WsRemoteEndpointImplBase.java:1178)
	at org.apache.tomcat.websocket.WsRemoteEndpointImplBase.sendPartialString(WsRemoteEndpointImplBase.java:225)
	at org.apache.tomcat.websocket.WsRemoteEndpointBasic.sendText(WsRemoteEndpointBasic.java:48)
	at org.springframework.web.socket.adapter.standard.StandardWebSocketSession.sendTextMessage(StandardWebSocketSession.java:206)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;TEXT_PARTIAL_WRITING&lt;/code&gt; 说明前一次文本消息还没有写完，新的文本写操作又进来了。也就是说，这里的根因并不是连接突然断开，而是同一个 &lt;code&gt;WebSocketSession&lt;/code&gt; 被并发写入了。&lt;/p&gt;
&lt;h3&gt;解决方法&lt;/h3&gt;
&lt;p&gt;这里我最终使用了 &lt;code&gt;ConcurrentWebSocketSessionDecorator&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;它并不是普通的 &lt;code&gt;WebSocketSession&lt;/code&gt;，而是对原始 Session 做了一层并发保护。可以把它理解为给 WebSocket Session 增加了一层发送队列和串行化能力：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;业务线程先把消息提交给 &lt;code&gt;ConcurrentWebSocketSessionDecorator&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;装饰器负责把消息按顺序写到底层 Session&lt;/li&gt;
&lt;li&gt;从而避免多个线程同时直接操作同一个连接&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样处理之后，即使 MQ 消费速度很快，确认消息和业务消息也不会再并发写入同一个 Session，&lt;code&gt;TEXT_PARTIAL_WRITING&lt;/code&gt; 异常也就不会再出现。&lt;/p&gt;
&lt;h3&gt;问题本质&lt;/h3&gt;
&lt;p&gt;这个问题的本质不是 MQ 太快，而是消息发送路径上存在多个可能同时触发写操作的执行流，而底层 &lt;code&gt;WebSocketSession&lt;/code&gt; 又不是线程安全的。只要一个 Session 可能被多个线程同时写入，就需要在应用层显式做串行化保护。&lt;/p&gt;
&lt;h2&gt;MQ 入口可靠性&lt;/h2&gt;
&lt;p&gt;在解决 WebSocket 并发写问题之后，我继续检查了消息链路的可靠性，发现 MQ 入口本身也需要补齐确认机制。&lt;/p&gt;
&lt;p&gt;最开始的实现里，业务线程只是把消息直接投递到 MQ，并没有等待 Broker 的确认结果。这样做虽然发送路径短，但前链路无法准确判断这条消息是否真正进入了 MQ，也就很难在失败时做重试、补偿或者明确告警。&lt;/p&gt;
&lt;p&gt;后来我补上了 ACK 确认机制，但第一版实现仍然不够理想。当时的做法是让用户线程同步等待 ACK 回传。这个方案能拿到确认结果，却把业务线程阻塞在等待过程中，线程利用率很差，在并发压力上来之后尤其浪费。&lt;/p&gt;
&lt;p&gt;后面我把这一段改成了异步确认模型：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;try {
    rabbitTemplate.convertAndSend(
            imMqProperties.getExchange(),
            pendingConfirm.routingKey(),
            pendingConfirm.event(),
            mdcHeadersPostProcessor(pendingConfirm),
            correlationData
    );

    correlationData.getFuture().whenComplete((confirm, ex) -&amp;gt;
            handleConfirm(pendingConfirm.correlationId(), attempt, sentNanos, confirm, ex));
} catch (Exception ex) {
    // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的关键点在于，&lt;code&gt;convertAndSend(...)&lt;/code&gt; 和 &lt;code&gt;correlationData.getFuture().whenComplete(...)&lt;/code&gt; 虽然是顺序执行的，但不会因为“ACK 回来得太快”而漏掉确认结果。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;whenComplete(...)&lt;/code&gt; 的语义可以理解为“注册回调并检查完成状态”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果对应的确认结果还没有到达，它就把回调挂到这个 &lt;code&gt;Future&lt;/code&gt; 上，等 MQ 确认完成后再触发。&lt;/li&gt;
&lt;li&gt;如果确认结果在注册之前其实已经完成了，那么这个回调会在注册时直接进入执行，不会丢失这次 ACK。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，这里不存在“先收到 ACK、后注册回调，所以回调丢了”的竞态窗口。&lt;code&gt;Future&lt;/code&gt; 保存的是完成状态，而 &lt;code&gt;whenComplete(...)&lt;/code&gt; 既能处理未完成的情况，也能处理已经完成的情况。&lt;/p&gt;
&lt;p&gt;相比同步阻塞等待，这种方式的优势在于：业务线程只负责发消息和注册回调，不需要一直卡在那里等 ACK 返回。确认结果到达后，再由 &lt;code&gt;Future&lt;/code&gt; 的完成流程触发 &lt;code&gt;handleConfirm(...)&lt;/code&gt;。这样可以明显减少线程空等，提高吞吐量，也让发送链路的失败检测和重试逻辑更自然。&lt;/p&gt;
&lt;h3&gt;压测指标&lt;/h3&gt;
&lt;p&gt;在 &lt;code&gt;RATE=2000&lt;/code&gt; 的恒定速率下，我对“同步 confirm”和“异步 confirm”分别做了一轮复测，结果如下：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;指标&lt;/th&gt;
&lt;th&gt;同步 confirm&lt;/th&gt;
&lt;th&gt;异步 confirm&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;sent&lt;/td&gt;
&lt;td&gt;60000&lt;/td&gt;
&lt;td&gt;60000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;47738&lt;/td&gt;
&lt;td&gt;59817&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;成功率&lt;/td&gt;
&lt;td&gt;79.56%&lt;/td&gt;
&lt;td&gt;99.70%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;timeout/error&lt;/td&gt;
&lt;td&gt;12262&lt;/td&gt;
&lt;td&gt;183&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;accepted P95&lt;/td&gt;
&lt;td&gt;9764.15ms&lt;/td&gt;
&lt;td&gt;12ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;im.send.accept.total P95 bucket&lt;/td&gt;
&lt;td&gt;8.389ms&lt;/td&gt;
&lt;td&gt;4.194ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;队列总残留&lt;/td&gt;
&lt;td&gt;99964&lt;/td&gt;
&lt;td&gt;85997&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;从这组数据看，异步 confirm 带来的改进非常明显。&lt;/p&gt;
&lt;p&gt;首先，&lt;code&gt;accepted&lt;/code&gt; 从 &lt;code&gt;47738&lt;/code&gt; 提升到了 &lt;code&gt;59817&lt;/code&gt;，成功率也从 &lt;code&gt;79.56%&lt;/code&gt; 提升到了 &lt;code&gt;99.70%&lt;/code&gt;。这说明在相同发送压力下，前链路能够更稳定地完成消息接收，不再因为同步等待 ACK 而大量堆积和超时。&lt;/p&gt;
&lt;p&gt;其次，&lt;code&gt;timeout/error&lt;/code&gt; 从 &lt;code&gt;12262&lt;/code&gt; 降到了 &lt;code&gt;183&lt;/code&gt;，而 &lt;code&gt;accepted P95&lt;/code&gt; 更是从 &lt;code&gt;9764.15ms&lt;/code&gt; 下降到 &lt;code&gt;12ms&lt;/code&gt;。这说明优化之后，&lt;code&gt;accept&lt;/code&gt; 这条路径的尾延迟被大幅压缩，绝大多数请求都能在很短时间内返回。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;im.send.accept.total&lt;/code&gt; 的 P95 bucket 也从 &lt;code&gt;8.389ms&lt;/code&gt; 降到了 &lt;code&gt;4.194ms&lt;/code&gt;，说明系统内部这段处理链路本身也变得更短了。与此同时，队列总残留从 &lt;code&gt;99964&lt;/code&gt; 降到 &lt;code&gt;85997&lt;/code&gt;，虽然仍然存在积压，但相比同步 confirm 方案已经有所缓解。&lt;/p&gt;
&lt;p&gt;整体来看，把 MQ confirm 从同步阻塞等待改成异步回调之后，前链路的吞吐能力和稳定性都有了明显提升。这一轮优化最直接的收益，就是把业务线程从“等待确认结果”中释放出来，让它们更专注于处理真正的请求流量。&lt;/p&gt;
&lt;h2&gt;消息持久化消费者&lt;/h2&gt;
&lt;p&gt;在前链路优化之后，我继续把注意力放到 MQ 消费端。之前我对消费者的理解比较保守，后来才进一步意识到：RabbitMQ 的消费者不仅可以配置并发线程数，也可以通过相关参数控制单次拉取和处理的节奏。于是我尝试把消息持久化相关的消费者改成多线程消费，并对串行和并行两种模式做了对比。&lt;/p&gt;
&lt;h3&gt;消费吞吐量对比&lt;/h3&gt;
&lt;p&gt;先看最直观的结果。在相同时间窗口内，并行消费者能够处理掉更多消息，因此队列残留明显下降。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;队列&lt;/th&gt;
&lt;th&gt;serial after-20s 剩余&lt;/th&gt;
&lt;th&gt;parallel after-20s 剩余&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;im.message.persist.queue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12456&lt;/td&gt;
&lt;td&gt;8083&lt;/td&gt;
&lt;td&gt;并行多消费了约 4373 条&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;im.message.conversation.queue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;14155&lt;/td&gt;
&lt;td&gt;4701&lt;/td&gt;
&lt;td&gt;并行多消费了约 9454 条&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这说明把消费者改成并行后，系统在 20 秒窗口内的总体消费能力确实提升了，消息积压下降得更快。&lt;/p&gt;
&lt;h3&gt;单条消息处理耗时对比&lt;/h3&gt;
&lt;p&gt;不过，并行并不意味着“每一条消息都会处理得更快”。从单条消费者平均耗时来看，并行模式下反而略有上升：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Consumer&lt;/th&gt;
&lt;th&gt;serial 平均耗时&lt;/th&gt;
&lt;th&gt;parallel 平均耗时&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;single_message_persist&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3.294ms&lt;/td&gt;
&lt;td&gt;5.515ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;single_conversation_persist&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6.522ms&lt;/td&gt;
&lt;td&gt;7.150ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;single_realtime_push&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.804ms&lt;/td&gt;
&lt;td&gt;5.622ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这个现象并不意外。消费者线程数增加之后，数据库连接、缓存、锁、CPU 调度以及对象分配等共享资源上的竞争也会随之增加，因此单条消息的平均处理时间可能会上升。&lt;/p&gt;
&lt;h3&gt;结果解读&lt;/h3&gt;
&lt;p&gt;这组数据说明，多线程消费者带来的收益主要体现在整体吞吐量，而不是单条消息的绝对处理时延。&lt;/p&gt;
&lt;p&gt;换句话说，并行化之后，单个线程未必更快，但系统能够同时处理更多任务，因此总消费速度更高，队列积压下降更明显。对于消息持久化这类更关注总体处理能力的场景，这种取舍通常是值得的。但如果后续要继续优化，就不能只盯着消费者线程数，还需要进一步分析并发之后到底是数据库、锁竞争还是其他共享资源成了新的瓶颈。&lt;/p&gt;
&lt;h2&gt;消费者缓存&lt;/h2&gt;
&lt;p&gt;继续往下分析时，我发现消息持久化消费者仍然是整条消费链路里最慢的一段，平均执行时间大约在 &lt;code&gt;4ms&lt;/code&gt; 左右。进一步拆解后，主要耗时集中在两个步骤上：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;chat_message_insert&lt;/code&gt;：约 &lt;code&gt;1.37ms&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;contact_relation_upsert&lt;/code&gt;：约 &lt;code&gt;2.86ms&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;其中，&lt;code&gt;chat_message_insert&lt;/code&gt; 比较直接，就是把消息内容写入数据库；真正更重的是 &lt;code&gt;contact_relation_upsert&lt;/code&gt;。这条 SQL 的作用，是在用户 A 给用户 B 发消息时补齐联系人关系，并更新相关标志位，这样当 B 回复 A 之后，后续通信链路才能按预期工作。&lt;/p&gt;
&lt;p&gt;问题在于，&lt;code&gt;contact_relation_upsert&lt;/code&gt; 本质上是一个“插入，如果重复则更新”的操作。第一次建立关系时它是必要的，但在关系已经存在之后，后续大量重复执行这条 SQL 的收益就非常有限了。换句话说，这一步在很多场景下已经变成了高频、但价值不高的重复写操作。&lt;/p&gt;
&lt;p&gt;既然第一次写入之后，关系状态在很长一段时间内都不会发生关键变化，那么它就很适合做缓存优化。我的处理方式是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;第一次 &lt;code&gt;contact_relation_upsert&lt;/code&gt; 成功后，把结果回写到 Redis&lt;/li&gt;
&lt;li&gt;后续消费者处理同类消息时，先查询 Redis&lt;/li&gt;
&lt;li&gt;如果缓存命中，就跳过这条高成本的重复 &lt;code&gt;upsert&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样做之后，消费者不再频繁把时间花在重复维护联系人关系上，而是把更多时间留给真正必要的消息持久化逻辑。&lt;/p&gt;
&lt;h3&gt;优化结果&lt;/h3&gt;
&lt;p&gt;从平均处理时长来看，这次缓存优化的收益非常明显：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;版本&lt;/th&gt;
&lt;th&gt;平均处理时长&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;缓存前&lt;/td&gt;
&lt;td&gt;4.549ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;缓存后&lt;/td&gt;
&lt;td&gt;0.827ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;也就是说，这一段消费者逻辑的平均处理时间从 &lt;code&gt;4.549ms&lt;/code&gt; 降到了 &lt;code&gt;0.827ms&lt;/code&gt;。这不仅说明 &lt;code&gt;contact_relation_upsert&lt;/code&gt; 确实是之前的主要瓶颈，也说明把“首次建立关系”和“后续重复判断”拆开处理，是这条链路上非常有效的一次优化。&lt;/p&gt;
&lt;h2&gt;减少commit双刷次数&lt;/h2&gt;
&lt;p&gt;继续分析 SQL 监控数据时，我发现这时最耗时的语句已经不是业务 SQL，而是 &lt;code&gt;COMMIT&lt;/code&gt; 本身。也就是说，前面的优化把业务逻辑压缩下去之后，事务提交阶段反而变成了新的热点。&lt;/p&gt;
&lt;p&gt;我当时的数据库配置是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;innodb_flush_log_at_trx_commit = 1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sync_binlog = 1&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这组配置的特点是事务提交时会尽可能立即把持久化相关的数据刷盘，安全性最高，但代价也很直接：对于 IM 这种高频、小事务、强实时的场景，提交成本会被显著放大。消息持久化链路里有大量短事务时，&lt;code&gt;COMMIT&lt;/code&gt; 的累计开销很容易被放大成系统瓶颈。&lt;/p&gt;
&lt;p&gt;在这个前提下，我做了一个偏性能取向的调整：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;把 &lt;code&gt;innodb_flush_log_at_trx_commit&lt;/code&gt; 从 &lt;code&gt;1&lt;/code&gt; 调整为 &lt;code&gt;2&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;把 &lt;code&gt;sync_binlog&lt;/code&gt; 从 &lt;code&gt;1&lt;/code&gt; 调整为 &lt;code&gt;0&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样做的含义是，把“每次事务提交都尽量同步刷盘”改成“由系统按周期回刷”。代价是极端故障场景下会牺牲一部分数据持久性保证，但收益是显著降低每个小事务的提交开销。对于 IM 这种更强调实时响应、允许在极小窗口内接受一定持久化风险的业务，这个取舍是有意义的。&lt;/p&gt;
&lt;h3&gt;SQL 热点变化&lt;/h3&gt;
&lt;p&gt;从系统表统计结果可以直接看到，调整前 &lt;code&gt;COMMIT&lt;/code&gt; 的耗时非常突出：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260420-202746.png&quot; alt=&quot;600 msg/s 下 COMMIT 耗时占比较高&quot; /&gt;&lt;/p&gt;
&lt;p&gt;在继续提高压力之后，&lt;code&gt;COMMIT&lt;/code&gt; 虽然仍然存在，但已经不再像之前那样成为绝对主导的热点：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260420-203901.png&quot; alt=&quot;1200 msg/s 下 SQL Digest 分布变化&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这说明提交阶段的开销确实被压下去了，数据库能够把更多时间花在真正的业务读写上，而不是频繁做高成本的同步提交。&lt;/p&gt;
&lt;h3&gt;压测结果&lt;/h3&gt;
&lt;p&gt;从压测表现看，这次调整带来的提升也很直观：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;指标&lt;/th&gt;
&lt;th&gt;调整前&lt;/th&gt;
&lt;th&gt;调整后&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;第一次出现运行中积压&lt;/td&gt;
&lt;td&gt;300 对，600 msg/s&lt;/td&gt;
&lt;td&gt;600 对，1200 msg/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;说明&lt;/td&gt;
&lt;td&gt;600 msg/s 时 persist 最大 1135，conversation 最大 111&lt;/td&gt;
&lt;td&gt;600 msg/s 时最大只有 6，1200 msg/s 才超过 100&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这意味着系统从原来在 &lt;code&gt;300&lt;/code&gt; 对用户、&lt;code&gt;600 msg/s&lt;/code&gt; 左右就开始明显积压，提升到了大约 &lt;code&gt;600&lt;/code&gt; 对用户、&lt;code&gt;1200 msg/s&lt;/code&gt; 才进入类似状态，整体承载能力接近翻倍。&lt;/p&gt;
&lt;h3&gt;结果解读&lt;/h3&gt;
&lt;p&gt;这一轮优化说明，在大量小事务场景下，数据库默认的“最强持久化”配置未必就是最合适的选择。适当降低每次提交的刷盘强度，确实可以换来更高的吞吐量和更好的实时表现。&lt;/p&gt;
&lt;p&gt;当然，这种优化不是没有代价。它本质上是用一部分极端故障下的数据安全性，换取更高的运行时性能。因此是否采用，仍然要看业务对持久性和实时性的优先级排序。对 IM 这样的系统来说，这种权衡通常比在所有场景下无条件追求最强一致性更实际。&lt;/p&gt;
</content:encoded></item><item><title>Java 面向对象进阶：static、继承、多态与 final</title><link>https://blog.huangnv.online/posts/26416/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26416/1/1/</guid><description>围绕 Java 面向对象中的 static、继承、多态和 final，梳理它们的核心规则与底层直觉。</description><pubDate>Thu, 16 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这篇笔记用来整理我在学习 Java 面向对象过程中的一些关键概念，主要围绕 &lt;code&gt;static&lt;/code&gt;、继承、多态和 &lt;code&gt;final&lt;/code&gt; 展开。先从最容易混淆的 &lt;code&gt;static&lt;/code&gt; 成员变量开始。&lt;/p&gt;
&lt;h2&gt;static&lt;/h2&gt;
&lt;h3&gt;成员变量&lt;/h3&gt;
&lt;p&gt;在 Java 里，&lt;code&gt;static&lt;/code&gt; 成员变量属于类，而不属于某个具体对象。普通成员变量会随着对象创建进入每个实例，因此每个对象各自持有一份；&lt;code&gt;static&lt;/code&gt; 成员变量则只在类的层面保留一份，所有实例共享同一份数据。&lt;/p&gt;
&lt;p&gt;也就是说，&lt;code&gt;static&lt;/code&gt; 成员变量的生命周期依附于类，而不是依附于对象。它会随着类的加载、链接和初始化进入可用状态，而不是在每次创建对象时重新生成一份。&lt;/p&gt;
&lt;p&gt;可以先把它和普通成员变量放在一起理解：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;普通成员变量：每创建一个对象，就会生成一份对应的数据&lt;/li&gt;
&lt;li&gt;&lt;code&gt;static&lt;/code&gt; 成员变量：无论创建多少个对象，类级别通常都只有一份共享数据&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它的内存示意可以先按下图来理解：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260414-144716.png&quot; alt=&quot;static 成员变量内存示意&quot; /&gt;&lt;/p&gt;
&lt;p&gt;对象实例本身并不直接持有这份 &lt;code&gt;static&lt;/code&gt; 变量的数据，访问 &lt;code&gt;static&lt;/code&gt; 字段时，本质上是在通过类去访问类级别的共享状态。&lt;/p&gt;
&lt;p&gt;所以，&lt;code&gt;static&lt;/code&gt; 成员变量最核心的特征，不是“某个对象里多了一个特殊字段”，而是“这份数据的归属从对象变成了类”。这也是为什么只要有一个对象修改了它，其他对象再去读取时，看到的仍然是同一份结果。&lt;/p&gt;
&lt;h3&gt;静态函数&lt;/h3&gt;
&lt;p&gt;静态方法同样是“属于类”的，而不是“属于某个对象”的。也正因为如此，它最常出现的场景就是工具类。像求数组最大值、平均值、排序辅助这类逻辑，本质上只是在完成一个功能，并不依赖某个具体对象的状态，这时候就没有必要先 &lt;code&gt;new&lt;/code&gt; 一个对象再调用方法，直接通过 &lt;code&gt;类名.方法名&lt;/code&gt; 使用会更自然。&lt;/p&gt;
&lt;p&gt;先抓住静态方法最核心的一条结论：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260414-145827.png&quot; alt=&quot;静态方法访问规则总结&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这张图里的三句话可以整理成更严谨的表述：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;静态方法里没有 &lt;code&gt;this&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;静态方法只能直接访问静态成员&lt;/li&gt;
&lt;li&gt;非静态方法既可以访问实例成员，也可以访问静态成员&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;其中最容易混淆的是第二条。很多人会把它记成一个规定，但更重要的是理解它为什么会这样。&lt;/p&gt;
&lt;p&gt;下面这张图展示了一个典型场景：&lt;code&gt;Student&lt;/code&gt; 类里既有实例变量 &lt;code&gt;name&lt;/code&gt;，也有静态变量 &lt;code&gt;teacherName&lt;/code&gt;。如果在静态方法 &lt;code&gt;method()&lt;/code&gt; 里直接访问 &lt;code&gt;name&lt;/code&gt;，就会出问题；而访问 &lt;code&gt;teacherName&lt;/code&gt; 则是允许的。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260414-150212.png&quot; alt=&quot;静态方法不能直接访问实例成员&quot; /&gt;&lt;/p&gt;
&lt;p&gt;原因在于，静态方法在调用时不依附于某个具体对象。比如 &lt;code&gt;Student.method()&lt;/code&gt; 这种写法，本质上是“类在调用方法”，而不是“某个 &lt;code&gt;Student&lt;/code&gt; 对象在调用方法”。既然当前并没有一个确定的对象实例，JVM 就无法知道你想取的是哪一个对象里的 &lt;code&gt;name&lt;/code&gt;，因此不能直接访问实例变量。&lt;/p&gt;
&lt;p&gt;但 &lt;code&gt;teacherName&lt;/code&gt; 不一样。它本来就是类级别共享的一份数据，归属于 &lt;code&gt;Student&lt;/code&gt; 这个类本身，所以静态方法可以直接访问它。&lt;/p&gt;
&lt;p&gt;从运行时角度看，也可以这样理解：普通成员方法调用时，默认会带着一个隐含的 &lt;code&gt;this&lt;/code&gt; 引用，&lt;code&gt;this&lt;/code&gt; 指向当前对象。有了这个引用，方法内部才知道应该去哪个对象里取 &lt;code&gt;name&lt;/code&gt;、&lt;code&gt;age&lt;/code&gt; 这类实例字段。静态方法没有这个默认的 &lt;code&gt;this&lt;/code&gt;，因此它不能直接读写实例成员，也不能直接调用实例方法。&lt;/p&gt;
&lt;p&gt;当然，这里说的是“不能直接访问”。如果静态方法先明确拿到了某个对象，再通过这个对象去访问实例成员，是可以做到的。限制点不在于“静态方法永远碰不到对象”，而在于“它自身默认不绑定任何对象”。&lt;/p&gt;
&lt;p&gt;和静态方法相对，非静态方法的访问范围会更大。因为非静态方法本来就是跟着对象走的，调用时天然带有 &lt;code&gt;this&lt;/code&gt;，所以它既能访问当前对象的实例变量，也能访问类上的静态变量。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260414-151225.png&quot; alt=&quot;非静态方法可以访问实例成员和静态成员&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这也是为什么图里 &lt;code&gt;show()&lt;/code&gt; 方法既能访问 &lt;code&gt;name&lt;/code&gt;、&lt;code&gt;age&lt;/code&gt; 这类实例数据，也能访问 &lt;code&gt;teacherName&lt;/code&gt; 这样的静态数据。实例成员属于“当前对象”，静态成员属于“当前类”，而非静态方法同时具备这两侧的访问条件。&lt;/p&gt;
&lt;h2&gt;继承&lt;/h2&gt;
&lt;p&gt;继承可以先理解成：子类在保留父类已有能力的基础上，再扩展出自己的内容。讨论继承时，最常见的对象主要有三类：构造方法、成员变量和成员方法。不过，这三类东西在继承里的规则并不完全一样。&lt;/p&gt;
&lt;p&gt;可以先用下面这张图建立一个整体印象：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260414-191226.png&quot; alt=&quot;继承中构造方法、成员变量和成员方法的规则&quot; /&gt;&lt;/p&gt;
&lt;p&gt;把图里的信息整理一下，大致可以概括成下面三条：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;构造方法不能被子类继承&lt;/li&gt;
&lt;li&gt;成员变量可以跟着继承体系保留下来，但 &lt;code&gt;private&lt;/code&gt; 成员变量不能被子类直接访问&lt;/li&gt;
&lt;li&gt;非 &lt;code&gt;private&lt;/code&gt; 的成员方法可以被子类继承，&lt;code&gt;private&lt;/code&gt; 方法不能被子类继承&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;为什么构造方法不能继承&lt;/h3&gt;
&lt;p&gt;构造方法的作用，是在创建对象时完成初始化。它和类名强绑定，父类构造方法服务的是“父类对象的初始化”，子类构造方法服务的是“子类对象的初始化”，两者职责不同，所以构造方法本身不能直接被继承。&lt;/p&gt;
&lt;p&gt;不过，不能继承不代表父类构造方法完全没用。子类在创建对象时，通常还是要先调用父类构造方法，把从父类继承下来的那部分状态先初始化好，然后再完成子类自己的初始化逻辑。也正因为如此，Java 里子类构造器的第一行往往会隐式或显式地调用 &lt;code&gt;super(...)&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;构造方法的访问特点&lt;/h3&gt;
&lt;p&gt;继承里的构造方法有一个很容易让人混淆的地方：父类构造方法不会被子类继承，但子类每次创建对象时，往往又都要先调用父类构造方法。这里看起来像矛盾，其实说的是两件不同的事。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260414-191226.png&quot; alt=&quot;继承中构造方法的访问特点&quot; /&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“不会被继承”说的是成员归属。父类的构造方法不会变成子类自己的构造方法。&lt;/li&gt;
&lt;li&gt;“创建子类对象时会先调用”说的是初始化顺序。子类对象里包含父类那部分状态，所以在执行子类自己的初始化逻辑之前，必须先把父类那部分先初始化好。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;为什么一定要先做这一步？因为子类在初始化过程中，很可能会依赖父类中已经定义好的成员数据。如果父类那部分状态还没有建立完成，子类后续逻辑就没有稳定的基础可用。&lt;/p&gt;
&lt;p&gt;从语法上看，子类构造方法的第一行默认都会有一个隐含的 &lt;code&gt;super()&lt;/code&gt;。即使代码里没有手写，编译器也会默认补上这一步，用来先调用父类无参构造方法。并且，这个调用必须出现在构造方法的第一行，因为只有先完成父类部分的初始化，子类后续代码才有意义。&lt;/p&gt;
&lt;p&gt;如果父类没有无参构造方法，或者你希望调用的是父类的有参构造方法，那么就不能依赖默认补出来的 &lt;code&gt;super()&lt;/code&gt;，而是要在子类构造器里手动写出 &lt;code&gt;super(参数)&lt;/code&gt;，明确指定要走哪一个父类构造方法。&lt;/p&gt;
&lt;h3&gt;成员变量的继承规则&lt;/h3&gt;
&lt;p&gt;成员变量和构造方法不一样。只要父类里定义了成员变量，这部分状态通常会随着继承关系进入子类对象中，所以从“对象组成”的角度看，子类对象里是包含父类那部分成员数据的。&lt;/p&gt;
&lt;p&gt;但这里要区分“继承到了”与“能不能直接访问”。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;对于非 &lt;code&gt;private&lt;/code&gt; 成员变量，子类可以按访问权限规则直接使用&lt;/li&gt;
&lt;li&gt;对于 &lt;code&gt;private&lt;/code&gt; 成员变量，虽然它仍然属于父类那部分状态，但子类不能直接通过变量名访问它&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，&lt;code&gt;private&lt;/code&gt; 限制的不是“这份数据是否存在”，而是“子类能不能直接碰到这份数据”。如果父类愿意暴露 &lt;code&gt;getter&lt;/code&gt;、&lt;code&gt;setter&lt;/code&gt; 或其他受控方法，子类仍然可以间接操作这部分状态。&lt;/p&gt;
&lt;h3&gt;成员方法的继承规则&lt;/h3&gt;
&lt;p&gt;成员方法的规则和成员变量有点像，但又不完全一样。对于普通的非 &lt;code&gt;private&lt;/code&gt; 方法，子类可以直接继承下来使用；如果有需要，子类还可以在规则允许的范围内进行重写。&lt;/p&gt;
&lt;p&gt;但 &lt;code&gt;private&lt;/code&gt; 方法不行。因为 &lt;code&gt;private&lt;/code&gt; 方法本质上只属于父类自己的内部实现细节，子类既看不到它，也不能直接调用它，更谈不上把它当成可继承的方法来使用。&lt;/p&gt;
&lt;p&gt;所以，继承这一节真正要记住的不是一句“子类继承父类”，而是要拆开来看：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;构造方法：不能继承，但子类构造时要借助父类构造方法完成父类部分的初始化&lt;/li&gt;
&lt;li&gt;成员变量：可以继承，&lt;code&gt;private&lt;/code&gt; 变量不能直接访问&lt;/li&gt;
&lt;li&gt;成员方法：非 &lt;code&gt;private&lt;/code&gt; 方法可以继承，&lt;code&gt;private&lt;/code&gt; 方法不能继承&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;继承的方法表以及内存空间&lt;/h2&gt;
&lt;p&gt;讨论继承时，如果只停留在“子类能不能调用父类方法”这一层，其实还不够。再往下一层看，就会涉及一个很重要的概念：虚方法表。&lt;/p&gt;
&lt;p&gt;先说结论。父类中的成员方法，并不是所有方法都会进入子类可参与动态分派的那一部分。通常只有满足下面条件的方法，才会被当成虚方法来看待：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;非 &lt;code&gt;private&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;非 &lt;code&gt;static&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;非 &lt;code&gt;final&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，只有父类中可以被子类正常继承、并且后续可能发生重写的方法，才会进入这套虚方法分派体系。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260414-194814.png&quot; alt=&quot;成员方法是否可以被继承以及类方法区示意&quot; /&gt;&lt;/p&gt;
&lt;p&gt;上面这张图可以帮助先建立两个直觉：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;private&lt;/code&gt; 方法不能被子类继承，因此不会成为子类可用的虚方法&lt;/li&gt;
&lt;li&gt;子类对象创建出来以后，堆里存的是对象数据；而类的方法信息、方法表等内容，是跟着 &lt;code&gt;Class&lt;/code&gt; 元数据走的，不是直接塞在对象实例里面&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;什么是虚方法&lt;/h3&gt;
&lt;p&gt;这里的“虚方法”，可以先理解成“运行时需要根据对象实际类型来决定调用哪个实现的方法”。如果父类定义了一个普通成员方法，子类也对它进行了重写，那么程序在运行时到底调用父类版本还是子类版本，就要靠这套动态分派机制来决定。&lt;/p&gt;
&lt;p&gt;比如父类有一个 &lt;code&gt;show()&lt;/code&gt;，子类也重写了 &lt;code&gt;show()&lt;/code&gt;。当你拿着父类类型的引用去调用 &lt;code&gt;show()&lt;/code&gt; 时，真正执行的往往不是“引用写成什么类就调什么类”，而是“对象实际是什么类型，就调用那个类型对应的方法实现”。这背后依赖的，就是虚方法表这类分派结构。子类可以重写这些虚方法。&lt;/p&gt;
&lt;h3&gt;虚方法表是怎么跟着继承关系组织起来的&lt;/h3&gt;
&lt;p&gt;每个类在运行时都可以理解为有一份属于自己的虚方法表。子类在形成自己的虚方法表时，并不是完全从零开始，而是会以父类的虚方法表为基础，再把自己新增的虚方法补进去；如果子类对父类某个虚方法进行了重写，那么对应位置会替换成子类自己的实现。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260416-221654.png&quot; alt=&quot;继承链上的虚方法表构建示意&quot; /&gt;&lt;/p&gt;
&lt;p&gt;可以把这张图概括成一句话：&lt;strong&gt;子类的虚方法表，是在父类虚方法表基础上扩展和覆盖出来的。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这也是为什么：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;父类里的普通可继承方法，子类通常也能直接调用&lt;/li&gt;
&lt;li&gt;子类重写父类方法后，运行时会优先落到子类实现&lt;/li&gt;
&lt;li&gt;&lt;code&gt;private&lt;/code&gt;、&lt;code&gt;static&lt;/code&gt;、&lt;code&gt;final&lt;/code&gt; 这些不参与重写的方法，不会按这套虚方法规则处理&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;方法表和对象内存的关系&lt;/h3&gt;
&lt;p&gt;这一节名字里还有“内存空间”，这里也要区分清楚。&lt;/p&gt;
&lt;p&gt;对象创建出来以后，实例字段会进入对象本身的内存布局中；而方法代码、类的结构信息、虚方法表这类内容，更适合理解为它们归属于类，而不是归属于某一个具体对象。对象只是通过自己的实际类型，关联到对应类的这套方法分派信息。&lt;/p&gt;
&lt;p&gt;所以，从运行时角度看，可以粗略理解成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;堆中的对象：主要保存实例数据&lt;/li&gt;
&lt;li&gt;类对应的元数据区域：保存类结构、方法信息、虚方法表等内容&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样再回头看继承，就会更容易理解：子类对象之所以能调用继承而来的方法，不是因为这些方法被“复制进了对象实例”，而是因为这个对象所属的类，已经在类级别准备好了对应的方法表和分派关系。&lt;/p&gt;
&lt;h2&gt;多态&lt;/h2&gt;
&lt;p&gt;多态可以从“为什么要有它”这个角度来理解。只有继承还不够，因为如果没有多态，程序在面对同一类事物的不同子类时，往往只能在外部写大量 &lt;code&gt;if-else&lt;/code&gt; 来判断类型，再决定该调用哪一个实现。&lt;/p&gt;
&lt;p&gt;这样写的问题是很明显的：调用方会和具体子类强绑定，代码越来越难扩展；每新增一个子类，原来的判断逻辑往往也要跟着修改。换句话说，本来应该由对象自己决定“该怎么做”的事情，最后却变成了外部代码在不断猜测和分支。&lt;/p&gt;
&lt;p&gt;多态要解决的，正是这个问题。它让我们可以先统一按父类或接口来编程，把“真正执行哪一个实现”交给运行时根据对象的实际类型来决定。这样一来，调用方式可以保持一致，但不同对象仍然能够表现出不同的行为。&lt;/p&gt;
&lt;h3&gt;多态访问成员的特点&lt;/h3&gt;
&lt;p&gt;多态在真正使用时，有一个特别容易混淆的点：&lt;strong&gt;成员变量&lt;/strong&gt;和&lt;strong&gt;成员方法&lt;/strong&gt;的访问规则并不一样。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260414-202111.png&quot; alt=&quot;多态调用成员的内存图解&quot; /&gt;&lt;/p&gt;
&lt;p&gt;上面这张图最值得先记住的，其实就是两句话：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;调用成员变量：编译看左边，运行也看左边&lt;/li&gt;
&lt;li&gt;调用成员方法：编译看左边，运行看右边&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这里的“左边”和“右边”，指的是这类写法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Animal a = new Dog();
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;左边是引用类型，也就是 &lt;code&gt;Animal&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;右边是实际创建出来的对象类型，也就是 &lt;code&gt;Dog&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果访问的是成员变量，比如 &lt;code&gt;a.name&lt;/code&gt;，编译器会先检查 &lt;code&gt;Animal&lt;/code&gt; 里有没有这个字段；真正运行时，取值也仍然按左边的引用类型来处理，所以最后看到的是父类里的 &lt;code&gt;name&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;但如果调用的是成员方法，比如 &lt;code&gt;a.show()&lt;/code&gt;，情况就不一样了。编译阶段仍然先看左边，确认 &lt;code&gt;Animal&lt;/code&gt; 里确实声明了 &lt;code&gt;show()&lt;/code&gt;；可一旦进入运行阶段，真正执行的会是右边实际对象对应的方法实现，也就是 &lt;code&gt;Dog&lt;/code&gt; 重写后的 &lt;code&gt;show()&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;所以，多态最核心的表现其实就落在这里：&lt;strong&gt;字段访问更偏向“引用类型”，方法调用更偏向“对象实际类型”。&lt;/strong&gt; 这也是为什么同样是 &lt;code&gt;Animal a = new Dog();&lt;/code&gt;，&lt;code&gt;a.name&lt;/code&gt; 和 &lt;code&gt;a.show()&lt;/code&gt; 的最终表现会不一样。&lt;/p&gt;
&lt;h2&gt;final&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;final&lt;/code&gt; 可以理解成 Java 里一个很直接的“收口”关键字。它的核心作用，就是告诉编译器和阅读代码的人：这个地方到此为止，不再允许继续改动或扩展。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260414-203328.png&quot; alt=&quot;final 的三种常见作用&quot; /&gt;&lt;/p&gt;
&lt;p&gt;放到具体语境里，&lt;code&gt;final&lt;/code&gt; 最常见的有三种用法：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;修饰方法：表示这个方法不能被子类重写&lt;/li&gt;
&lt;li&gt;修饰类：表示这个类不能再被继承&lt;/li&gt;
&lt;li&gt;修饰变量：表示这个变量只能被赋值一次&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;其中，修饰方法和修饰类，本质上都是在限制继承体系继续向下扩展。比如一个方法如果被 &lt;code&gt;final&lt;/code&gt; 修饰，就不会再参与后续的重写；一个类如果被 &lt;code&gt;final&lt;/code&gt; 修饰，那么这个类本身就被封住了，别人不能再通过 &lt;code&gt;extends&lt;/code&gt; 去继承它。&lt;/p&gt;
&lt;p&gt;修饰变量时，&lt;code&gt;final&lt;/code&gt; 更接近“常量”这个概念。对于基本类型来说，它表示值不能再次变化；对于引用类型来说，它表示引用不能再指向别的对象，但对象内部的内容是否还能变化，还要看这个对象本身是不是可变的。&lt;/p&gt;
</content:encoded></item><item><title>Java String 的底层原理</title><link>https://blog.huangnv.online/posts/26413/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26413/1/1/</guid><description>从 String 的结构、不可变性和字符串常量池出发，解释它为什么被设计成 final。</description><pubDate>Mon, 13 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Java 里的 &lt;code&gt;String&lt;/code&gt; 是一个非常常见，但也非常容易被低估的类。它表面上只是用来保存文本，实际上却承担了稳定性、复用性和安全性等多个职责。&lt;/p&gt;
&lt;p&gt;这一章先从 &lt;code&gt;String&lt;/code&gt; 的结构说起，再解释为什么它必须是 &lt;code&gt;final&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;String 的结构&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;public final class String
    implements java.io.Serializable, Comparable&amp;lt;String&amp;gt;, CharSequence {

    private final char[] value;
    private int hash; // 缓存 hashCode
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以先抓住两个关键词：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;final class String&lt;/code&gt;：&lt;code&gt;String&lt;/code&gt; 本身不能被继承。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;private final char[] value&lt;/code&gt;：&lt;code&gt;String&lt;/code&gt; 内部保存字符内容的引用不会被重新指向。&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;注：在较新的 Java 版本中，&lt;code&gt;String&lt;/code&gt; 的内部实现已经从 &lt;code&gt;char[]&lt;/code&gt; 演进为 &lt;code&gt;byte[] + coder&lt;/code&gt;，但“不可变”这一设计思想没有变。这里先按经典写法来理解，最容易建立直觉。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;code&gt;hash&lt;/code&gt; 字段的作用是缓存 &lt;code&gt;hashCode()&lt;/code&gt; 的结果。因为字符串经常被用作 &lt;code&gt;HashMap&lt;/code&gt;、&lt;code&gt;HashSet&lt;/code&gt; 的 key，缓存哈希值可以减少重复计算，提高性能。&lt;/p&gt;
&lt;h2&gt;为什么 String 必须是 final&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;String&lt;/code&gt; 之所以要被设计成 &lt;code&gt;final&lt;/code&gt;，核心原因可以概括成两点：稳定和安全。&lt;/p&gt;
&lt;h3&gt;1. 复用字符串常量，避免内容被意外修改&lt;/h3&gt;
&lt;p&gt;Java 会把字符串字面量放到字符串常量池中。比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String a = &quot;test&quot;;
String b = &quot;test&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;a&lt;/code&gt; 和 &lt;code&gt;b&lt;/code&gt; 实际上指向的是同一个常量池对象，而不是两份独立的内容。&lt;/p&gt;
&lt;p&gt;如果 &lt;code&gt;String&lt;/code&gt; 不是不可变的，那么其中一个引用一旦修改内容，另一个引用看到的值也会一起变化。这样字符串常量池就失去了“共享同一份内容”的意义，也会破坏很多依赖稳定字符串的逻辑。&lt;/p&gt;
&lt;p&gt;所以，从复用角度看，&lt;code&gt;String&lt;/code&gt; 必须保持不可变。&lt;/p&gt;
&lt;h3&gt;2. 防止继承带来的行为风险&lt;/h3&gt;
&lt;p&gt;Java 支持继承和方法重写。假设 &lt;code&gt;String&lt;/code&gt; 允许被继承，那么子类就有机会修改某些关键行为，比如比较、转换、哈希计算等。&lt;/p&gt;
&lt;p&gt;这会带来一个很大的问题：很多系统默认会把字符串当成可信、稳定的基础数据来使用，例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;文件路径&lt;/li&gt;
&lt;li&gt;URL 和请求参数&lt;/li&gt;
&lt;li&gt;配置项名称&lt;/li&gt;
&lt;li&gt;业务标识&lt;/li&gt;
&lt;li&gt;签名串、密钥片段等安全敏感文本&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果这些基础行为可以被子类偷偷改写，那么某些依赖 &lt;code&gt;String&lt;/code&gt; 语义的场景就可能被注入额外逻辑，稳定性和安全性都会出问题。&lt;/p&gt;
&lt;p&gt;把 &lt;code&gt;String&lt;/code&gt; 设为 &lt;code&gt;final&lt;/code&gt;，就是为了从类型层面切断这类风险。&lt;/p&gt;
&lt;h2&gt;为什么内部字段也要是 final&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;String&lt;/code&gt; 的不可变，不只是“类不能被继承”，还体现在它内部状态不能被轻易改写。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;private final char[] value;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;final&lt;/code&gt; 表示：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;value&lt;/code&gt; 这个引用一旦初始化，就不能再指向别的数组&lt;/li&gt;
&lt;li&gt;&lt;code&gt;String&lt;/code&gt; 对象创建完成后，它承载的文本内容就固定下来了&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样一来，&lt;code&gt;String&lt;/code&gt; 才能真正做到“创建后不可变”。如果内部引用还能随意改写，字符串就不再可靠了。&lt;/p&gt;
&lt;h2&gt;不可变性带来的好处&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;String&lt;/code&gt; 之所以能成为 Java 中最常用的数据类型之一，和它的不可变性直接相关：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;可以安全共享，节省内存&lt;/li&gt;
&lt;li&gt;可以稳定作为 &lt;code&gt;HashMap&lt;/code&gt; 的 key&lt;/li&gt;
&lt;li&gt;可以放心用于字符串常量池&lt;/li&gt;
&lt;li&gt;可以减少并发场景下的同步问题&lt;/li&gt;
&lt;li&gt;可以让很多 API 默认把它当成可信文本处理&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这也是为什么 Java 在设计 &lt;code&gt;String&lt;/code&gt; 时，宁愿牺牲一点“可修改性”，也要换来更强的稳定性和复用性。&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;String&lt;/code&gt; 不是简单的“字符数组包装类”，而是 Java 为了稳定性、安全性和复用性专门设计出来的基础类型。&lt;/p&gt;
&lt;p&gt;它之所以是 &lt;code&gt;final&lt;/code&gt;，本质上是为了保证两件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;字符串内容不会被意外改变&lt;/li&gt;
&lt;li&gt;字符串相关行为不会被子类偷偷改写&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;理解了这一点，后面再看字符串常量池、&lt;code&gt;hashCode&lt;/code&gt; 缓存、&lt;code&gt;intern()&lt;/code&gt;，就会顺很多。&lt;/p&gt;
&lt;h2&gt;String 的构造方法&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;String&lt;/code&gt; 的构造方式很多，但在实际开发中，最常见的其实就两类：字符串字面量直接赋值，以及通过字符数组创建新对象。&lt;/p&gt;
&lt;h3&gt;1. 字符串字面量直接赋值&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;String a = &quot;test&quot;;
String b = &quot;test&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种写法并不是每次都创建一个新的字符串对象。JVM 会先去字符串常量池中检查是否已经存在 &lt;code&gt;&quot;test&quot;&lt;/code&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果不存在，就创建并放入字符串常量池&lt;/li&gt;
&lt;li&gt;如果已经存在，就直接复用这份内容&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以，&lt;code&gt;a&lt;/code&gt; 和 &lt;code&gt;b&lt;/code&gt; 往往指向的是同一个字符串常量池对象。这也是字符串字面量最省内存、最常用的原因。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260413-224553.png&quot; alt=&quot;字符串字面量与常量池&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;2. 使用 &lt;code&gt;new String(char[])&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;char[] chars = {&apos;a&apos;, &apos;b&apos;, &apos;c&apos;};
String s1 = new String(chars);
String s2 = new String(chars);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种写法会根据传入的字符数组创建新的 &lt;code&gt;String&lt;/code&gt; 对象。它的特点是每次调用都会生成一个新的实例，因此 &lt;code&gt;s1&lt;/code&gt; 和 &lt;code&gt;s2&lt;/code&gt; 的引用地址通常不同。&lt;/p&gt;
&lt;p&gt;也就是说，即使它们的内容相同，只要是通过 &lt;code&gt;new String(char[])&lt;/code&gt; 构造出来的，它们在堆中也会是两个独立的对象。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260413-224629.png&quot; alt=&quot;char数组构造 String&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;3. 其他构造方式&lt;/h3&gt;
&lt;p&gt;除了上面两种常见写法，&lt;code&gt;String&lt;/code&gt; 还支持：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;String()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;String(String original)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;String(byte[] bytes)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;String(byte[] bytes, Charset charset)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些构造方式本质上都是围绕“如何生成一个新的字符串对象”展开的。它们在具体行为上会有差异，但平时最常接触、也最值得先理解的，还是字面量赋值和数组构造这两种。&lt;/p&gt;
&lt;h2&gt;字面量和 &lt;code&gt;new&lt;/code&gt; 的区别&lt;/h2&gt;
&lt;p&gt;这两种写法最核心的区别在于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;字面量写法会优先复用字符串常量池中的内容&lt;/li&gt;
&lt;li&gt;&lt;code&gt;new String(...)&lt;/code&gt; 会显式创建新的对象&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，如果只是想表达一个固定文本，通常优先使用字面量。只有在确实需要从字符数组、字节数组或已有字符串中重新构造对象时，才会使用 &lt;code&gt;new String(...)&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;StringBuilder&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;String&lt;/code&gt; 是不可变的，所以在频繁拼接字符串的场景里，直接使用 &lt;code&gt;+&lt;/code&gt; 往往会产生大量中间对象，性能也会受到影响。&lt;/p&gt;
&lt;p&gt;比如下面这段代码：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String s = &quot;a&quot;;
s = s + &quot;b&quot;;
s = s + &quot;c&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这类写法在底层通常会被转换成字符串拼接流程，本质上仍然会创建新的中间结果。拼接次数越多，中间对象越多，内存分配和复制的开销也就越明显。&lt;/p&gt;
&lt;h2&gt;为什么需要 StringBuilder&lt;/h2&gt;
&lt;p&gt;为了解决频繁拼接带来的性能问题，Java 提供了 &lt;code&gt;StringBuilder&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;它和 &lt;code&gt;String&lt;/code&gt; 最大的不同在于：&lt;code&gt;StringBuilder&lt;/code&gt; 是可变的。它内部维护的是一块可以不断扩容的缓冲区，而不是每次拼接都重新生成一个新的字符串对象。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AbstractStringBuilder&lt;/code&gt; 的核心结构可以简化理解为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;abstract class AbstractStringBuilder {
    byte[] value;   // 存储字符数据
    byte coder;     // 编码方式：LATIN1 或 UTF16
    int count;      // 当前字符串长度
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看到，它没有像 &lt;code&gt;String&lt;/code&gt; 那样被设计成不可变类型。&lt;code&gt;value&lt;/code&gt; 这块数组会在容量不足时被扩容，并把旧内容复制到新数组中，从而继续承载后续追加的数据。&lt;/p&gt;
&lt;h2&gt;StringBuilder 的工作流程&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;StringBuilder sb = new StringBuilder();
sb.append(&quot;Hello&quot;);
sb.append(&quot; &quot;);
sb.append(&quot;World&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段代码在执行时，通常会经历下面几个步骤：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;检查当前容量是否足够&lt;/li&gt;
&lt;li&gt;如果容量不足，就申请更大的数组&lt;/li&gt;
&lt;li&gt;将原有内容复制到新数组中&lt;/li&gt;
&lt;li&gt;把新追加的字符写入数组&lt;/li&gt;
&lt;li&gt;更新 &lt;code&gt;count&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这样做的好处是，拼接过程主要发生在同一个可变缓冲区里，不需要每一步都创建新的字符串对象。&lt;/p&gt;
&lt;h2&gt;和直接拼接相比的区别&lt;/h2&gt;
&lt;p&gt;直接用 &lt;code&gt;String&lt;/code&gt; 拼接时，常见问题是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每次拼接都会产生新的中间结果&lt;/li&gt;
&lt;li&gt;中间结果越多，分配和复制的成本越高&lt;/li&gt;
&lt;li&gt;在循环或大量拼接场景下，性能差异会更明显&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而 &lt;code&gt;StringBuilder&lt;/code&gt; 的思路是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先准备一块可复用的缓冲区&lt;/li&gt;
&lt;li&gt;后续追加内容时尽量在原数组上扩展&lt;/li&gt;
&lt;li&gt;只有容量不足时才触发扩容&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以，如果是多次拼接文本，优先使用 &lt;code&gt;StringBuilder&lt;/code&gt; 通常会更合适。&lt;/p&gt;
&lt;h3&gt;扩容原理&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;StringBuilder&lt;/code&gt; 在创建时会先分配一块默认缓冲区。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;StringBuilder sb1 = new StringBuilder();      // 默认容量 16
StringBuilder sb2 = new StringBuilder(&quot;abc&quot;);  // 容量 = 16 + 3 = 19
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里要注意一点：&lt;code&gt;new StringBuilder(&quot;abc&quot;)&lt;/code&gt; 不是只给 &lt;code&gt;abc&lt;/code&gt; 分配 3 个位置，而是会额外预留一段空间，方便后续继续追加。&lt;/p&gt;
&lt;p&gt;当容量不足时，&lt;code&gt;StringBuilder&lt;/code&gt; 会触发自动扩容。它的扩容思路可以概括为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;新容量 = 旧容量 * 2 + 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;例如，假设当前容量是 16，那么下一次扩容后通常会变成 34；如果当前容量是 34，下一次通常会变成 70。这样设计的目的，是尽量减少频繁扩容带来的数组复制成本。&lt;/p&gt;
&lt;h4&gt;扩容什么时候会生效&lt;/h4&gt;
&lt;p&gt;自动扩容只有在“当前容量不够用”的时候才会触发。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;StringBuilder sb = new StringBuilder(); // 容量 16
sb.append(&quot;1234567890123456&quot;);          // 正好写满 16
sb.append(&quot;7&quot;);                         // 触发扩容
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第一句 &lt;code&gt;append&lt;/code&gt; 之后，缓冲区刚好被写满；第二句再追加一个字符时，容量不够了，&lt;code&gt;StringBuilder&lt;/code&gt; 就会申请更大的数组，把原内容复制过去，再写入新的字符。&lt;/p&gt;
&lt;p&gt;这种场景下，自动扩容是生效的。&lt;/p&gt;
&lt;h4&gt;扩容什么时候不会按默认公式生效&lt;/h4&gt;
&lt;p&gt;如果追加的内容太长，导致默认的 &lt;code&gt;旧容量 * 2 + 2&lt;/code&gt; 仍然不够用，&lt;code&gt;StringBuilder&lt;/code&gt; 就不会停留在这个结果上，而是直接把目标容量提升到 &lt;code&gt;minCapacity&lt;/code&gt;，也就是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;minCapacity = count + appendLen
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这表示“至少要能装下当前内容加上这次要追加的内容”。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;StringBuilder sb = new StringBuilder(); // 当前容量 16，count = 0
sb.append(&quot;1234567890123456789012345678901234567890&quot;); // 追加 40 个字符
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里 &lt;code&gt;minCapacity = 0 + 40 = 40&lt;/code&gt;，而默认扩容结果是 &lt;code&gt;16 * 2 + 2 = 34&lt;/code&gt;。因为 34 仍然装不下 40 个字符，所以最终不会采用 34，而是直接把容量扩到 40。&lt;/p&gt;
&lt;p&gt;这就是默认扩容公式“不够用”的场景。更准确地说，不是扩容机制失效了，而是它先算出一个目标容量，再和 &lt;code&gt;minCapacity&lt;/code&gt; 比较，最后取能满足需求的那个值。&lt;/p&gt;
&lt;p&gt;如果剩余容量本身就足够，&lt;code&gt;StringBuilder&lt;/code&gt; 还是不会扩容，只会直接把新内容写进原来的数组。&lt;/p&gt;
&lt;h4&gt;具体流程&lt;/h4&gt;
&lt;p&gt;当 &lt;code&gt;append&lt;/code&gt; 发生时，&lt;code&gt;StringBuilder&lt;/code&gt; 大致会按下面的顺序处理：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;计算追加后的总长度&lt;/li&gt;
&lt;li&gt;判断现有容量是否足够&lt;/li&gt;
&lt;li&gt;如果不够，按照扩容策略申请更大的数组&lt;/li&gt;
&lt;li&gt;把旧内容复制到新数组&lt;/li&gt;
&lt;li&gt;把新内容写入数组尾部&lt;/li&gt;
&lt;li&gt;更新 &lt;code&gt;count&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;所以，&lt;code&gt;StringBuilder&lt;/code&gt; 的优势不是“完全不分配内存”，而是“尽量减少重复创建中间字符串对象”，把多次拼接收敛到同一块可变缓冲区里完成。&lt;/p&gt;
&lt;h2&gt;Java 字符串拼接&lt;/h2&gt;
&lt;p&gt;字符串拼接可以分成两类：一类是纯常量拼接，另一类是包含变量的拼接。这两种情况的执行方式完全不同。&lt;/p&gt;
&lt;h3&gt;1. 纯常量拼接&lt;/h3&gt;
&lt;p&gt;像下面这种写法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String s = &quot;a&quot; + &quot;b&quot; + &quot;c&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它属于编译期常量表达式。编译器会在编译阶段直接把它折叠成一个字符串常量，也就是 &lt;code&gt;&quot;abc&quot;&lt;/code&gt;，运行时不会真的去执行三次拼接。&lt;/p&gt;
&lt;p&gt;换句话说，这种拼接的开销只发生在编译期，运行时基本没有额外成本。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260413-224553.png&quot; alt=&quot;纯常量拼接与常量池&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;2. 含变量的拼接&lt;/h3&gt;
&lt;p&gt;如果拼接表达式里出现了变量，情况就不一样了。&lt;/p&gt;
&lt;p&gt;例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;String s = &quot;a&quot;;
s = s + &quot;b&quot;;
s = s + &quot;c&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 JDK 9 之前，&lt;code&gt;javac&lt;/code&gt; 通常会把这类拼接改写成 &lt;code&gt;StringBuilder&lt;/code&gt; 的链式调用，先创建一个 &lt;code&gt;StringBuilder&lt;/code&gt;，再不断 &lt;code&gt;append&lt;/code&gt;，最后调用 &lt;code&gt;toString()&lt;/code&gt; 得到最终的 &lt;code&gt;String&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;所以上面这段代码可以理解成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;s = new StringBuilder(&quot;a&quot;).append(&quot;b&quot;).toString();
s = new StringBuilder(s).append(&quot;c&quot;).toString();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样一来，每一轮拼接通常都会先生成一个 &lt;code&gt;StringBuilder&lt;/code&gt;，最后再生成一个结果字符串。也就是说，单次拼接过程里会经历两次主要对象分配：一次是 &lt;code&gt;StringBuilder&lt;/code&gt;，一次是最终的 &lt;code&gt;String&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;如果像上面这样连续拼接两次，就会重复走两轮这个流程。相比直接操作常量，这类写法会产生更多中间对象。&lt;/p&gt;
&lt;p&gt;在 JDK 9 及之后，字符串拼接的编译实现改成了 &lt;code&gt;invokedynamic&lt;/code&gt; + &lt;code&gt;StringConcatFactory&lt;/code&gt;，但目标没有变，都是为了减少中间对象和提升拼接效率。&lt;/p&gt;
</content:encoded></item><item><title>Java 对象在内存中的分布</title><link>https://blog.huangnv.online/posts/26412/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26412/1/1/</guid><description>结合 `Student` 示例，说明 Java 类信息、对象实例和引用变量分别位于方法区、堆和栈中的位置。</description><pubDate>Sun, 12 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;很多人第一次看 Java 对象创建时，最容易混淆的就是这几个概念：类信息放在哪里，对象实例放在哪里，局部变量放在哪里，成员方法又放在哪里。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Student&lt;/code&gt; 这个例子正好可以把这些位置拆开看清楚。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public class Student {
    String name;
    int age;

    public void study() {
        System.out.println(&quot;好好学习&quot;);
    }
}

public class Test2Student {
    public static void main(String[] args) {
        Student s1 = new Student();
        s1.name = &quot;阿强&quot;;
        s1.age = 23;
        s1.study();

        Student s2 = new Student();
        s2.name = &quot;阿珍&quot;;
        s2.age = 24;
        s2.study();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;./f2cbb12abd7d4368b0f73da69d75fd7a~tplv-73owjymdk6-jj-mark-v1_0_0_0_0_5o6Y6YeR5oqA5pyv56S-5Yy6IEAgSW5zaWdodEZ1dHVyZQ==_q75.webp&quot; alt=&quot;两个对象的内存图&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这张图里最重要的结论有三个：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;Student.class&lt;/code&gt; 这类类元数据会先被 JVM 加载到方法区，HotSpot 8 以后主要对应元空间。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;s1&lt;/code&gt; 和 &lt;code&gt;s2&lt;/code&gt; 这样的局部变量，放在当前线程的栈帧里，它们保存的是对象引用，不是对象本体。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;new Student()&lt;/code&gt; 创建出来的实例，实际分配在堆上，&lt;code&gt;name&lt;/code&gt; 和 &lt;code&gt;age&lt;/code&gt; 这类成员变量也属于对象实例的一部分。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;类信息放在哪里&lt;/h2&gt;
&lt;p&gt;程序开始执行后，JVM 会先把 &lt;code&gt;Student&lt;/code&gt; 这个类加载进来。类的结构信息、方法字节码、字段定义等内容，不是跟着某个对象实例一起保存的，而是作为类元数据保存在方法区。&lt;/p&gt;
&lt;p&gt;所以，&lt;code&gt;study()&lt;/code&gt; 这个方法并不属于 &lt;code&gt;s1&lt;/code&gt; 或 &lt;code&gt;s2&lt;/code&gt; 其中某一个对象。对象只负责保存自己的状态，方法本身属于类。&lt;/p&gt;
&lt;h2&gt;对象实例放在哪里&lt;/h2&gt;
&lt;p&gt;执行 &lt;code&gt;Student s1 = new Student();&lt;/code&gt; 时，JVM 会在堆上开辟一块新的对象空间。&lt;/p&gt;
&lt;p&gt;这块空间里会保存：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;age&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;对象头&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;s1&lt;/code&gt; 本身只是一个引用变量，它存放在 &lt;code&gt;main&lt;/code&gt; 方法对应的栈帧里。也就是说，&lt;code&gt;s1&lt;/code&gt; 指向堆中的那一个 &lt;code&gt;Student&lt;/code&gt; 实例，但它自己不是对象。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Student s2 = new Student();&lt;/code&gt; 也是同样的过程，只不过这次堆里又多了一个新的对象实例，&lt;code&gt;s2&lt;/code&gt; 负责引用它。&lt;/p&gt;
&lt;h2&gt;成员变量和局部变量的区别&lt;/h2&gt;
&lt;p&gt;这段代码里有两种“变量”，它们位置完全不同：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;s1&lt;/code&gt;、&lt;code&gt;s2&lt;/code&gt; 是局部变量，存放在栈中。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt;、&lt;code&gt;age&lt;/code&gt; 是成员变量，跟着对象一起存放在堆中。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这也是初学者最容易混在一起的地方。&lt;code&gt;s1.name = &quot;阿强&quot;;&lt;/code&gt; 并不是把 &lt;code&gt;&quot;阿强&quot;&lt;/code&gt; 直接塞进了栈里，而是让对象里的 &lt;code&gt;name&lt;/code&gt; 引用指向了对应的字符串值。&lt;/p&gt;
&lt;p&gt;严格来说，字符串字面量还会涉及字符串常量池。为了便于理解，这里可以先把它看成对象字段指向了一个字符串引用。&lt;/p&gt;
&lt;h2&gt;方法调用发生了什么&lt;/h2&gt;
&lt;p&gt;当执行 &lt;code&gt;s1.study();&lt;/code&gt; 时，当前线程会进入 &lt;code&gt;study()&lt;/code&gt; 的调用过程。这个方法调用对应的是一次新的栈帧入栈和出栈，而不是把方法代码复制到对象里。&lt;/p&gt;
&lt;p&gt;换句话说：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;方法字节码在类信息里；&lt;/li&gt;
&lt;li&gt;方法调用记录在栈里；&lt;/li&gt;
&lt;li&gt;真正执行的是 JVM 调用流程，而不是对象自己“携带了一份方法副本”。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以，&lt;code&gt;s1.study()&lt;/code&gt; 和 &lt;code&gt;s2.study()&lt;/code&gt; 调用的是同一个 &lt;code&gt;study()&lt;/code&gt; 方法，只是接收这个调用的对象不同。&lt;/p&gt;
&lt;h2&gt;一个容易误解的点&lt;/h2&gt;
&lt;p&gt;很多人会把“对象地址”“对象内容”和“引用变量”混为一谈。&lt;/p&gt;
&lt;p&gt;其实更准确的理解应该是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Student&lt;/code&gt; 类先被加载，类信息放在方法区；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;new Student()&lt;/code&gt; 产生对象实例，放在堆中；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;s1&lt;/code&gt;、&lt;code&gt;s2&lt;/code&gt; 只是引用，放在栈中；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;study()&lt;/code&gt; 是类的方法，不属于某一个单独对象。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果把这四层关系分开，就不会再把“类”“对象”“引用”混成一团。&lt;/p&gt;
</content:encoded></item><item><title>Redis 数据类型</title><link>https://blog.huangnv.online/posts/26411/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26411/1/1/</guid><description>从 redisObject 出发，梳理 Redis 各种数据类型在对象层的统一表示，以及类型与底层编码之间的关系。</description><pubDate>Sat, 11 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;在理解了 Redis 常见底层数据结构之后，还需要继续往上一层看一个问题：Redis 对外提供的字符串、列表、哈希、集合和有序集合，究竟是怎样在对象层被统一管理的？&lt;/p&gt;
&lt;p&gt;答案就是 &lt;code&gt;redisObject&lt;/code&gt;。Redis 并不是直接把底层结构裸露给上层命令使用，而是先用一个统一的对象结构，把类型信息、编码方式、引用计数以及实际数据指针封装起来。也正因为有这一层抽象，Redis 才能在同一种逻辑数据类型下，根据数据规模和使用场景选择不同的底层编码实现。&lt;/p&gt;
&lt;h2&gt;redisObject&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;redisObject&lt;/code&gt; 可以看作 Redis 对象系统的入口。对外暴露的数据值，最终都会以这个结构体为外层包装；而对象内部真正的数据内容，则由 &lt;code&gt;ptr&lt;/code&gt; 指针指向具体的底层实现。&lt;/p&gt;
&lt;p&gt;需要注意的是，数据库中的键名本身通常是 &lt;code&gt;sds&lt;/code&gt;，并不是一个完整的 &lt;code&gt;redisObject&lt;/code&gt;；真正与数据类型、编码方式直接关联的，主要是键对应的值对象。&lt;/p&gt;
&lt;p&gt;源码中的定义大致如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;typedef struct redisObject {
    unsigned type:4;       // 对象的逻辑类型
    unsigned encoding:4;   // 对象当前采用的底层编码
    unsigned lru:24;       // LRU 时间戳或 LFU 统计信息
    int refcount;          // 引用计数
    void *ptr;             // 指向实际底层数据结构
} robj;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个结构体里最值得关注的是下面几个字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type&lt;/code&gt;&lt;br /&gt;
表示对象的逻辑类型，例如 &lt;code&gt;string&lt;/code&gt;、&lt;code&gt;list&lt;/code&gt;、&lt;code&gt;hash&lt;/code&gt;、&lt;code&gt;set&lt;/code&gt; 和 &lt;code&gt;zset&lt;/code&gt;。它回答的是“这个值在 Redis 语义上是什么”。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;encoding&lt;/code&gt;&lt;br /&gt;
表示对象当前采用的底层编码方式。它回答的是“这个值此刻是以什么实现形式存放的”。同一种逻辑类型，可能会对应多种编码，例如字符串对象可能采用 &lt;code&gt;int&lt;/code&gt;、&lt;code&gt;embstr&lt;/code&gt;、&lt;code&gt;raw&lt;/code&gt;，列表对象则可能采用 &lt;code&gt;quicklist&lt;/code&gt;。更底层的具体数据结构，会继续由 &lt;code&gt;ptr&lt;/code&gt; 指针指向。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lru&lt;/code&gt;&lt;br /&gt;
这个字段名字虽然叫 &lt;code&gt;lru&lt;/code&gt;，但它并不总是只服务于 &lt;code&gt;LRU&lt;/code&gt;。在开启 &lt;code&gt;LFU&lt;/code&gt; 相关策略时，这部分位还会被复用来记录访问频率和最近访问时间。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ptr&lt;/code&gt;&lt;br /&gt;
指向真正的数据结构，例如 &lt;code&gt;sds&lt;/code&gt;、&lt;code&gt;quicklist&lt;/code&gt;、&lt;code&gt;dict&lt;/code&gt;、&lt;code&gt;listpack&lt;/code&gt; 或 &lt;code&gt;skiplist&lt;/code&gt; 等。也就是说，&lt;code&gt;redisObject&lt;/code&gt; 更像是“统一外壳”，而不是实际存储内容的全部本体。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，理解 Redis 数据类型时，不能只看“字符串底层是 &lt;code&gt;sds&lt;/code&gt;”或“哈希底层可能是 &lt;code&gt;listpack&lt;/code&gt; / &lt;code&gt;dict&lt;/code&gt;”这样的结论，还要同时区分两个层次：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;逻辑层：Redis 把它当作什么类型来处理；&lt;/li&gt;
&lt;li&gt;编码层：Redis 当前选择了什么底层结构来承载这个对象。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后面再分析各种数据类型时，就可以沿着这条线展开：先看它的逻辑类型，再看它可能使用哪些编码，以及这些编码之间为什么会发生转换。&lt;/p&gt;
&lt;h2&gt;String&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;String&lt;/code&gt; 是 Redis 中最基础、也最常用的数据类型。单个字符串值的最大长度上限是 &lt;code&gt;512MB&lt;/code&gt;，但它在底层并不只有一种实现方式，而是会根据内容和长度在 &lt;code&gt;int&lt;/code&gt;、&lt;code&gt;embstr&lt;/code&gt;、&lt;code&gt;raw&lt;/code&gt; 等编码之间切换。&lt;/p&gt;
&lt;p&gt;当字符串较长，或者不再适合紧凑存储时，Redis 通常会使用 &lt;code&gt;raw&lt;/code&gt; 编码。&lt;/p&gt;
&lt;h3&gt;raw&lt;/h3&gt;
&lt;p&gt;在 &lt;code&gt;raw&lt;/code&gt; 编码下，字符串对象的内容由 &lt;code&gt;SDS&lt;/code&gt; 保存。此时，Redis 一般会分别分配两块内存：一块用于保存外层的 &lt;code&gt;redisObject&lt;/code&gt;，另一块用于保存实际字符串内容对应的 &lt;code&gt;SDS&lt;/code&gt; 结构。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;redisObject&lt;/code&gt; 中的 &lt;code&gt;ptr&lt;/code&gt; 指针指向 &lt;code&gt;SDS&lt;/code&gt; 的字符数组起始位置。由于 &lt;code&gt;flags&lt;/code&gt; 字段紧挨在字符数组前面，并且占用固定大小，Redis 可以通过偏移先取到 &lt;code&gt;flags&lt;/code&gt;，再判断当前 &lt;code&gt;SDS&lt;/code&gt; 属于哪一种 header 类型，进而继续解析前面头部中的 &lt;code&gt;len&lt;/code&gt;、&lt;code&gt;alloc&lt;/code&gt; 等元信息。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260410-222141.png&quot; alt=&quot;raw 编码字符串对象与 SDS 的关系&quot; /&gt;&lt;/p&gt;
&lt;p&gt;因此，&lt;code&gt;raw&lt;/code&gt; 编码的关键点可以概括为两层：外层是负责记录类型和编码信息的 &lt;code&gt;redisObject&lt;/code&gt;，内层则是负责真正存储字符串内容的 &lt;code&gt;SDS&lt;/code&gt;。两者通过 &lt;code&gt;ptr&lt;/code&gt; 连接起来，共同完成字符串对象的表示。&lt;/p&gt;
&lt;h3&gt;embstr&lt;/h3&gt;
&lt;p&gt;在经典实现中，当字符串长度较短时，Redis 会使用 &lt;code&gt;embstr&lt;/code&gt; 编码。一个常见阈值是字符串长度不超过 &lt;code&gt;44&lt;/code&gt; 字节，此时 &lt;code&gt;redisObject&lt;/code&gt; 和 &lt;code&gt;SDS&lt;/code&gt; 会一次性分配在一段连续内存中。&lt;/p&gt;
&lt;p&gt;这种布局的好处主要有两个：一是只需要一次内存分配，创建成本更低；二是对象头和字符串内容紧挨在一起，更容易提高缓存命中率。也正因为 &lt;code&gt;embstr&lt;/code&gt; 更偏向“紧凑、只读式”的表示方式，当字符串发生修改时，Redis 往往会把它转换成 &lt;code&gt;raw&lt;/code&gt; 编码。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260410-234317.png&quot; alt=&quot;embstr 编码下 redisObject 与 SDS 的连续内存布局&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;int&lt;/h3&gt;
&lt;p&gt;如果字符串对象保存的是一个可以用整数表示的值，Redis 还可能直接使用 &lt;code&gt;int&lt;/code&gt; 编码。此时不会再单独创建 &lt;code&gt;SDS&lt;/code&gt;，而是把整数值直接保存在 &lt;code&gt;redisObject&lt;/code&gt; 的 &lt;code&gt;ptr&lt;/code&gt; 中。&lt;/p&gt;
&lt;p&gt;这种做法的重点不是“&lt;code&gt;ptr&lt;/code&gt; 真的是一个普通指针”，而是 Redis 借用了这个字段的存储空间，把整数值按指针大小进行保存。对于纯整数场景，这样可以省掉额外的字符串结构和内存分配成本。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260410-234542.png&quot; alt=&quot;int 编码下字符串对象直接保存整数值&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;List&lt;/h2&gt;
&lt;p&gt;从 Redis &lt;code&gt;3.2&lt;/code&gt; 开始，&lt;code&gt;List&lt;/code&gt; 的统一外层实现就是 &lt;code&gt;quicklist&lt;/code&gt;。它把双向链表和紧凑存储块结合在一起，既保留了链表在头尾操作上的灵活性，又避免了传统链表“每个节点只存一个元素”带来的指针开销。&lt;/p&gt;
&lt;p&gt;需要注意的是，&lt;code&gt;quicklist&lt;/code&gt; 的节点内部在不同版本里会挂接不同的紧凑结构：较早版本通常是 &lt;code&gt;ziplist&lt;/code&gt;，而较新版本中已经更多地演进为 &lt;code&gt;listpack&lt;/code&gt;。不过从整体思路上看，它们都服务于同一个目标，就是把多个列表元素压缩地存放在一个节点内部。&lt;/p&gt;
&lt;h2&gt;Set&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;Set&lt;/code&gt; 的语义特点是元素唯一、无序，并且支持高效的交集、并集和差集运算。也正因为如此，Redis 会根据集合中的元素类型和元素数量，在 &lt;code&gt;intset&lt;/code&gt; 与 &lt;code&gt;hashtable&lt;/code&gt; 两种底层结构之间切换。&lt;/p&gt;
&lt;h3&gt;intset&lt;/h3&gt;
&lt;p&gt;当集合对象同时满足下面两个条件时，Redis 会使用 &lt;code&gt;intset&lt;/code&gt; 编码：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;集合中的所有元素都是整数值。&lt;/li&gt;
&lt;li&gt;元素数量不超过配置项 &lt;code&gt;set-max-intset-entries&lt;/code&gt;，默认值通常是 &lt;code&gt;512&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;intset&lt;/code&gt; 本质上是一段连续内存，内部元素按从小到大的顺序排列。这样既能节省空间，也方便 Redis 使用二分查找来定位成员。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./image-20220327120657455.png&quot; alt=&quot;Set 在 intset 编码下的结构示意&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;hashtable&lt;/h3&gt;
&lt;p&gt;当集合中出现了非整数元素，或者元素数量超过了 &lt;code&gt;intset&lt;/code&gt; 的适用范围，Redis 就会把 &lt;code&gt;Set&lt;/code&gt; 切换为 &lt;code&gt;hashtable&lt;/code&gt; 编码。&lt;/p&gt;
&lt;p&gt;在这种实现下，集合元素会作为字典的键保存下来，而字典的值通常并不承载实际业务数据，更多只是一个占位。也就是说，&lt;code&gt;Set&lt;/code&gt; 主要利用的是字典“键唯一、查找快”的这一层能力。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./image-20220327120702329.png&quot; alt=&quot;Set 在 hashtable 编码下的结构示意&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;ZSet&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;ZSet&lt;/code&gt; 不仅要保证成员唯一，还要按照 &lt;code&gt;score&lt;/code&gt; 维持有序性。因此它的底层实现也比普通集合更复杂，通常会根据数据规模选择“小对象紧凑编码”或“字典 + 跳表”的组合结构。&lt;/p&gt;
&lt;h3&gt;ziplist / listpack&lt;/h3&gt;
&lt;p&gt;在较早版本的 Redis 中，如果有序集合同时满足下面两个条件，通常会使用 &lt;code&gt;ziplist&lt;/code&gt; 编码：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;元素数量较少，经典阈值通常是 &lt;code&gt;128&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;每个成员的长度都比较短，经典阈值通常是 &lt;code&gt;64&lt;/code&gt; 字节。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;在较新版本中，这种紧凑编码已经逐步由 &lt;code&gt;listpack&lt;/code&gt; 取代，但整体思路没有变化，都是在“小而短”的场景里尽可能压缩内存占用。&lt;/p&gt;
&lt;h3&gt;dict + skiplist&lt;/h3&gt;
&lt;p&gt;当不再满足紧凑编码条件时，Redis 会同时使用 &lt;code&gt;dict&lt;/code&gt; 和 &lt;code&gt;skiplist&lt;/code&gt; 来实现 &lt;code&gt;ZSet&lt;/code&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;dict&lt;/code&gt; 用来保存成员到分值的映射，便于快速判断成员是否存在以及查询对应分值；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;skiplist&lt;/code&gt; 用来维护按分值排序后的有序视图，便于范围查询、排序遍历和排名计算。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这也是 &lt;code&gt;ZSet&lt;/code&gt; 设计里最有代表性的地方: 用两套结构分别承担“快速定位”和“保持有序”两类职责。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260411-001641.png&quot; alt=&quot;ZSet 在 dict + skiplist 编码下的结构示意&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Hash&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;Hash&lt;/code&gt; 用来保存 field-value 形式的键值对。和 &lt;code&gt;ZSet&lt;/code&gt; 类似，它在数据量较小时会优先采用更紧凑的连续存储；当字段较多或单个字段较长时，再切换为哈希表实现。&lt;/p&gt;
&lt;h3&gt;ziplist / listpack&lt;/h3&gt;
&lt;p&gt;在较早版本中，小型 &lt;code&gt;Hash&lt;/code&gt; 常用 &lt;code&gt;ziplist&lt;/code&gt; 编码；而在较新版本中，这部分实现已经更多地由 &lt;code&gt;listpack&lt;/code&gt; 接替。&lt;/p&gt;
&lt;p&gt;这种紧凑表示的核心思路是：把一个个 field-value 对按顺序连续写入同一段内存中。也就是说，保存同一组键值对的两个节点总是紧挨在一起，前面是 field，后面是 value。这样做的好处是结构紧凑、指针开销小，适合字段数量不多、每个字段都比较短的场景。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./image-20220327120647204.png&quot; alt=&quot;Hash 在 ziplist 编码下的结构示意&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;hashtable&lt;/h3&gt;
&lt;p&gt;当 &lt;code&gt;Hash&lt;/code&gt; 的字段数量持续增大，或者字段和值本身已经不适合紧凑编码时，Redis 就会改用 &lt;code&gt;hashtable&lt;/code&gt;。此时，哈希对象中的每一个 field-value 对都会对应字典中的一个键值对。&lt;/p&gt;
&lt;p&gt;这样做的代价是会引入更多指针和结构体开销，但换来的好处是字段查找、插入和更新都会更加直接，也更适合大体量哈希对象。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./image-20220327120652041.png&quot; alt=&quot;Hash 在 hashtable 编码下的结构示意&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>Mysql事务并发</title><link>https://blog.huangnv.online/posts/26421/1/1/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26421/1/1/</guid><description>。</description><pubDate>Sat, 11 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;并发事务问题&lt;/h2&gt;
&lt;p&gt;在多个事务同时访问同一批数据时，如果隔离级别不足，一个事务就可能读到另一个事务产生的中间状态或已提交变更，从而导致前后读取结果不一致。&lt;/p&gt;
&lt;p&gt;MySQL 中常见的并发读问题主要有三类：脏读、不可重复读和幻读。它们都和“一个事务在执行过程中能看到哪些其他事务的修改”有关，但影响范围不同。&lt;/p&gt;
&lt;h3&gt;脏读&lt;/h3&gt;
&lt;p&gt;脏读指一个事务读取到了另一个事务尚未提交的修改。&lt;/p&gt;
&lt;p&gt;这里的关键是读取顺序：读操作发生在另一个事务提交或回滚之前。此时这份数据还没有成为数据库中的最终结果，后续既可能提交，也可能回滚。&lt;/p&gt;
&lt;p&gt;如果对方事务最终回滚，读取方之前拿到的数据就相当于从未真实存在过；即使对方事务后来提交，读取方在读取时看到的也仍然是未提交状态下的数据。脏读的问题本质上不是“对方一定会回滚”，而是一个事务提前看到了另一个事务尚未确定的中间结果。&lt;/p&gt;
&lt;h3&gt;不可重复读&lt;/h3&gt;
&lt;p&gt;不可重复读指在同一个事务内，两次按照相同条件读取同一行数据，得到的结果不一致。&lt;/p&gt;
&lt;p&gt;它通常发生在两次读取之间，另一个事务已经提交完成了对这行数据的更新或删除。和脏读不同，不可重复读读到的不是未提交的中间状态，而是另一个事务已经提交生效后的数据。问题在于，同一个事务内部前后两次读取时，看到的数据版本发生了变化。&lt;/p&gt;
&lt;h3&gt;幻读&lt;/h3&gt;
&lt;p&gt;幻读指在同一个事务内，两次按照相同范围条件查询，返回的记录集合发生变化。&lt;/p&gt;
&lt;p&gt;它通常发生在两次范围查询之间，另一个事务提交了满足该范围条件的新增、删除或更新操作。不可重复读更关注“同一行数据是否发生变化”，幻读更关注“同一范围内的记录集合是否发生变化”。&lt;/p&gt;
&lt;p&gt;简单区分如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;脏读：读到了其他事务尚未提交的数据。&lt;/li&gt;
&lt;li&gt;不可重复读：同一事务内，多次读取同一行已提交数据，结果不一致。&lt;/li&gt;
&lt;li&gt;幻读：同一事务内，多次范围查询时，记录集合发生变化。&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Redis 内存淘汰策略与实现流程</title><link>https://blog.huangnv.online/posts/26410/26410/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/26410/26410/</guid><description>从 maxmemory 策略分类出发，梳理 Redis 中 LRU、LFU 与 eviction pool 的实现思路，以及实际内存淘汰流程。</description><pubDate>Fri, 10 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;内存淘汰，是指当 Redis 的内存使用达到预设阈值后，主动挑选一部分键删除，以便继续为新的写入请求腾出空间。换句话说，它处理的不是“键是否过期”，而是“当前实例是否已经没有足够可用内存”这个问题。&lt;/p&gt;
&lt;p&gt;在源码实现中，Redis 会在处理客户端命令的过程中尝试执行内存淘汰。也就是说，淘汰动作并不是一个完全独立于命令执行之外的后台流程，而是会在写请求推进过程中被触发，用来保证实例不会持续突破配置的内存上限。&lt;/p&gt;
&lt;p&gt;Redis 在运行前就需要通过配置指定内存淘汰策略，而不同策略会影响对象内部如何记录访问信息。比如在采用 &lt;code&gt;LRU&lt;/code&gt; 或 &lt;code&gt;LFU&lt;/code&gt; 相关策略时，&lt;code&gt;redisObject&lt;/code&gt; 结构中的关键字段就会承担“最近访问时间”或“访问频率”这类统计信息的存储职责：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS; /* 记录最后一次访问时间或访问频率 */
    int refcount;
    void *ptr;
} robj;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此，内存淘汰策略并不只是“删除时选哪一个键”这么简单，它还会影响 Redis 在对象层面保存哪些元信息，以及后续如何根据这些信息判断淘汰目标。&lt;/p&gt;
&lt;h2&gt;Redis 的内存淘汰策略&lt;/h2&gt;
&lt;p&gt;Redis 提供了多种内存淘汰策略，常见实现一共可以分为八种。理解这些策略时，可以先抓住两个维度：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;allkeys&lt;/code&gt; 表示淘汰范围是“所有键”；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;volatile&lt;/code&gt; 表示淘汰范围仅限于“设置了过期时间的键”。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在这个基础上，不同策略的区别主要体现在“按什么规则挑选要删除的键”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;noeviction&lt;/code&gt;
默认策略。不主动删除任何键；当内存达到上限后，新的写命令会直接报错，而读命令通常仍可继续执行。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;allkeys-lru&lt;/code&gt;
在所有键中，优先淘汰最近最少使用的键。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;volatile-lru&lt;/code&gt;
只在设置了过期时间的键中，优先淘汰最近最少使用的键。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;allkeys-lfu&lt;/code&gt;
在所有键中，优先淘汰访问频率最低的键。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;volatile-lfu&lt;/code&gt;
只在设置了过期时间的键中，优先淘汰访问频率最低的键。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;allkeys-random&lt;/code&gt;
在所有键中随机选择一部分键进行淘汰。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;volatile-random&lt;/code&gt;
只在设置了过期时间的键中随机淘汰。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;volatile-ttl&lt;/code&gt;
只在设置了过期时间的键中进行选择，并优先淘汰那些“剩余存活时间更短”的键。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;从分类上看，这些策略大致可以再分成两层：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;第一层是“淘汰范围”，也就是在所有键里选择，还是只在带过期时间的键里选择；&lt;/li&gt;
&lt;li&gt;第二层是“淘汰依据”，也就是按 &lt;code&gt;LRU&lt;/code&gt;、&lt;code&gt;LFU&lt;/code&gt;、随机，还是按剩余 &lt;code&gt;TTL&lt;/code&gt; 来决定淘汰目标。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;其中，真正需要额外维护访问信息的，主要是 &lt;code&gt;LRU&lt;/code&gt; 和 &lt;code&gt;LFU&lt;/code&gt; 两类策略。接下来可以先看 &lt;code&gt;LRU&lt;/code&gt; 在 Redis 中是如何实现的。&lt;/p&gt;
&lt;h3&gt;LRU&lt;/h3&gt;
&lt;p&gt;当一个键被访问时，Redis 会更新该对象内部用于记录访问信息的字段。对于 &lt;code&gt;LRU&lt;/code&gt; 相关策略来说，这里的核心就是当前的全局 &lt;code&gt;LRU&lt;/code&gt; 时钟，也就是 &lt;code&gt;server.lruclock&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;每次访问键时，Redis 都会把当前时钟值写入对应对象的 &lt;code&gt;redisObject-&amp;gt;lru&lt;/code&gt; 字段。这个动作本身非常轻量，本质上只是一次内存中的字段更新。&lt;/p&gt;
&lt;p&gt;而在真正发生内存淘汰时，Redis 并不会对所有键做严格全量排序，而是通过采样的方式比较这些对象记录下来的 &lt;code&gt;lru&lt;/code&gt; 值，从中挑选那些“距离当前时间更久没有被访问”的键，优先将它们淘汰出去。&lt;/p&gt;
&lt;h3&gt;LFU&lt;/h3&gt;
&lt;p&gt;相比 &lt;code&gt;LRU&lt;/code&gt; 记录“最近一次访问时间”，&lt;code&gt;LFU&lt;/code&gt; 更关注的是“一个键被访问得有多频繁”。为了节省内存，Redis 并没有为 &lt;code&gt;LFU&lt;/code&gt; 再额外增加一个新字段，而是继续复用了 &lt;code&gt;redisObject&lt;/code&gt; 里原本那 &lt;code&gt;24 bit&lt;/code&gt; 的 &lt;code&gt;lru&lt;/code&gt; 字段。&lt;/p&gt;
&lt;p&gt;在 &lt;code&gt;LFU&lt;/code&gt; 模式下，这 &lt;code&gt;24 bit&lt;/code&gt; 会被拆成两部分：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;高 &lt;code&gt;16 bit&lt;/code&gt; 用来记录上一次访问时间，精度是分钟级；&lt;/li&gt;
&lt;li&gt;低 &lt;code&gt;8 bit&lt;/code&gt; 用来记录访问频率计数器。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，在 &lt;code&gt;LFU&lt;/code&gt; 策略下，这个字段虽然名字仍然叫 &lt;code&gt;lru&lt;/code&gt;，但它保存的已经不再是单纯的“最近访问时钟”，而是“访问时间 + 频率计数器”的组合信息。&lt;/p&gt;
&lt;p&gt;不过，&lt;code&gt;LFU&lt;/code&gt; 的关键并不只是“访问一次就加一”。如果真的让计数器线性累加，那么高频热点键很快就会把计数打满，之后不同热点之间也就失去了区分度。为了解决这个问题，Redis 在底层实现里同时引入了“对数增长”和“定期衰减”两套机制。&lt;/p&gt;
&lt;h4&gt;对数增长&lt;/h4&gt;
&lt;p&gt;在 &lt;code&gt;LFU&lt;/code&gt; 模式下，计数器并不是每访问一次就必然加 &lt;code&gt;1&lt;/code&gt;。Redis 使用的是一种近似对数增长的方式：计数器越大，继续增长就越困难。&lt;/p&gt;
&lt;p&gt;源码里的核心逻辑可以概括为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;p = 1 / (baseval * lfu-log-factor + 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Redis 会生成一个 &lt;code&gt;0&lt;/code&gt; 到 &lt;code&gt;1&lt;/code&gt; 之间的随机数，只有当这个随机数小于上面的概率时，计数器才会真正增加。这样一来，低频键在初期增长较快，而高频键想继续往上增长就会越来越难。&lt;/p&gt;
&lt;p&gt;这里的增长速度还可以通过 &lt;code&gt;lfu-log-factor&lt;/code&gt; 调整：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;factor&lt;/code&gt; 越大，计数器增长越慢；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;factor&lt;/code&gt; 越小，计数器增长越快。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，&lt;code&gt;LFU&lt;/code&gt; 记录的并不是一个精确访问次数，而是一个经过压缩后的“访问热度近似值”。&lt;/p&gt;
&lt;h4&gt;自动衰减&lt;/h4&gt;
&lt;p&gt;如果一个键曾经很热门，但后来几乎不再被访问，那么它的频率也不应该永久保持在高位。为此，Redis 还会在 &lt;code&gt;LFU&lt;/code&gt; 中对计数器做衰减。&lt;/p&gt;
&lt;p&gt;衰减的依据是高 &lt;code&gt;16 bit&lt;/code&gt; 里记录的“上一次访问时间”。每次访问或参与淘汰比较时，Redis 都会先计算“距离上次访问已经过去了多少个衰减周期”，然后再把低 &lt;code&gt;8 bit&lt;/code&gt; 的频率计数器减小。&lt;/p&gt;
&lt;p&gt;这个衰减周期由 &lt;code&gt;lfu-decay-time&lt;/code&gt; 控制，默认通常是 &lt;code&gt;1&lt;/code&gt; 分钟。更准确地说：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果经过了 &lt;code&gt;N&lt;/code&gt; 个衰减周期，计数器就会减少 &lt;code&gt;N&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;如果减少后的结果小于 &lt;code&gt;0&lt;/code&gt;，就直接归零。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也正因为存在这种衰减机制，&lt;code&gt;LFU&lt;/code&gt; 并不是简单地“谁历史访问次数最多就永远保留谁”，而是会随着时间推移不断调整热点判断，让“过去的热点”逐渐失去优势。&lt;/p&gt;
&lt;h2&gt;Redis 淘汰流程&lt;/h2&gt;
&lt;p&gt;接下来可以把 Redis 的实际淘汰流程串起来看一遍。整体上，它并不是“内存一满就立刻全量扫描所有键”，而是在写命令推进过程中检查当前内存是否超出限制；如果确实超过 &lt;code&gt;maxmemory&lt;/code&gt;，才进入淘汰流程，按配置好的策略逐步删除键，直到内存重新回到目标范围内。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260409-162515.png&quot; alt=&quot;Redis 淘汰流程示意&quot; /&gt;&lt;/p&gt;
&lt;p&gt;整个流程大致可以分成四步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;触发淘汰&lt;br /&gt;
当 Redis 执行写命令时，会先检查当前实例的内存是否仍然充足。如果还没有达到 &lt;code&gt;maxmemory&lt;/code&gt; 限制，那么命令继续正常执行；只有在内存不足时，才会真正进入淘汰逻辑。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;确定淘汰范围&lt;br /&gt;
Redis 会先根据当前策略判断，候选键到底来自哪里：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果是 &lt;code&gt;allkeys-*&lt;/code&gt; 系列策略，候选键来自各个数据库保存实际数据的 &lt;code&gt;dict&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;如果是 &lt;code&gt;volatile-*&lt;/code&gt; 系列策略，候选键则来自保存过期键信息的字典，也就是带 &lt;code&gt;TTL&lt;/code&gt; 的那部分键。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;判断是“随机淘汰”还是“按规则淘汰”&lt;br /&gt;
如果当前策略是 &lt;code&gt;allkeys-random&lt;/code&gt; 或 &lt;code&gt;volatile-random&lt;/code&gt;，Redis 就会在候选范围内随机选择键并删除，然后再次检查释放出的内存是否已经满足要求；如果还不够，就继续重复这个过程。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用 &lt;code&gt;eviction pool&lt;/code&gt; 进行筛选&lt;br /&gt;
如果当前策略属于 &lt;code&gt;LRU&lt;/code&gt;、&lt;code&gt;LFU&lt;/code&gt; 或 &lt;code&gt;TTL&lt;/code&gt; 这类“需要比较优先级”的淘汰方式，Redis 就不会简单随机删除，而是会先构造一个 &lt;code&gt;eviction pool&lt;/code&gt;。它会遍历各个数据库，在每个数据库中随机抽取 &lt;code&gt;maxmemory_samples&lt;/code&gt; 个候选键，对这些键执行对应的策略计算，例如比较最近访问时间、访问频率，或者剩余过期时间。然后，Redis 会把“更应该被淘汰”的键放入 &lt;code&gt;eviction pool&lt;/code&gt; 中。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这里的 &lt;code&gt;eviction pool&lt;/code&gt; 大小是有限的，因此并不是所有候选键都能进入这个池子，只有那些在当前比较结果里更接近淘汰条件的键，才会被保留下来。需要注意的是，&lt;code&gt;eviction pool&lt;/code&gt; 本身是一个复用的候选池，而不是每轮淘汰都重新创建、结束后再整体清空的临时结构。Redis 会持续复用这块固定大小的空间，只是在取出候选键、发现候选键失效，或插入更合适的新候选项时，逐步更新池中的内容。&lt;/p&gt;
&lt;p&gt;等所有数据库都完成一轮采样之后，Redis 再从 &lt;code&gt;eviction pool&lt;/code&gt; 中取出最合适的目标键执行删除，并在每次删除后重新判断当前内存是否已经降到目标范围内。&lt;/p&gt;
&lt;p&gt;因此，Redis 的淘汰流程本质上是一种“边采样、边筛选、边删除”的机制：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;随机策略走的是“随机挑一个就删”的路径；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LRU&lt;/code&gt;、&lt;code&gt;LFU&lt;/code&gt;、&lt;code&gt;TTL&lt;/code&gt; 这类策略走的是“先进入 &lt;code&gt;eviction pool&lt;/code&gt; 比较，再挑最合适的删”的路径。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样设计的目的，就是避免为了一次内存淘汰去全量扫描所有键，同时又能尽可能接近目标策略想要表达的语义。&lt;/p&gt;
</content:encoded></item><item><title>Redis 过期键删除与内存回收机制</title><link>https://blog.huangnv.online/posts/2649/2649/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2649/2649/</guid><description>从 redisDb 的 expires 字典出发，梳理 Redis 中惰性删除、周期删除以及 FAST/SLOW 主动过期扫描的实现思路。</description><pubDate>Thu, 09 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;在 Redis 的源码实现中，&lt;code&gt;redisDb&lt;/code&gt; 是存储数据的核心容器。Redis 服务器默认会初始化 &lt;code&gt;16&lt;/code&gt; 个这样的逻辑数据库，每个数据库都维护着自己的键空间、过期信息以及一些运行时状态。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;typedef struct redisDb {
    dict *dict;                 /* 数据库键值对字典，保存所有键值对 */
    dict *expires;              /* 保存设置了过期时间的键及其过期时间（毫秒时间戳） */
    dict *blocking_keys;        /* 处于阻塞状态的键（例如 BLPOP） */
    dict *ready_keys;           /* 解除阻塞状态的键 */
    dict *watched_keys;         /* 被 WATCH 命令监视的键（用于事务） */
    int id;                     /* 数据库 ID */
    long long avg_ttl;          /* 数据库内键的平均存活时间（用于统计） */
    unsigned long expires_cursor; /* 过期键循环处理的游标 */
    list *defrag_later;         /* 等待进行碎片整理的键列表 */
} redisDb;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中，与过期策略最直接相关的是两个哈希字典：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;dict&lt;/code&gt; 用来保存数据库中的键值对本身，也就是实际的数据内容。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;expires&lt;/code&gt; 用来保存“哪些键设置了过期时间”以及“它们会在什么时候过期”。这里记录的值通常是毫秒级时间戳。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这种设计的好处在于，Redis 不需要把“值对象”和“过期时间”强行绑在同一个结构里，而是把过期信息单独维护。这样一来，只有设置了过期时间的键才会出现在 &lt;code&gt;expires&lt;/code&gt; 字典中，普通键不会额外承担这部分管理成本。&lt;/p&gt;
&lt;p&gt;在 Redis 中，我们经常会给某些数据设置过期时间。一个键过期之后，并不意味着 Redis 会立刻为它单独创建一个定时器去删除它；如果为大量键都维护独立定时任务，会带来明显的调度开销，也会影响 Redis 的整体处理效率。&lt;/p&gt;
&lt;p&gt;那么，Redis 是如何在保证性能的前提下完成过期数据清理的呢？核心思路是避免把删除操作完全交给实时定时器，而是通过两种主要策略配合完成过期键回收。&lt;/p&gt;
&lt;h2&gt;惰性删除&lt;/h2&gt;
&lt;p&gt;当我们为一个键设置 &lt;code&gt;TTL&lt;/code&gt; 时，Redis 会同时在当前 &lt;code&gt;redisDb&lt;/code&gt; 的两张字典中记录信息：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在 &lt;code&gt;dict&lt;/code&gt; 中保存真正的键值对；&lt;/li&gt;
&lt;li&gt;在 &lt;code&gt;expires&lt;/code&gt; 中保存这个键对应的过期时间。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;需要注意的是，&lt;code&gt;dict&lt;/code&gt; 和 &lt;code&gt;expires&lt;/code&gt; 虽然都以同一个键作为索引，但它们是两张独立的哈希表，Redis 只是分别通过同一个键去查找对应的数据和过期时间，并不是简单地共享同一个桶位置。&lt;/p&gt;
&lt;p&gt;所谓惰性删除，本质上就是“访问时检查”。当客户端对某个键执行读取、写入、删除或其他相关操作时，Redis 会先判断这个键是否已经过期：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果已经过期，就先执行过期删除，把它从键空间中清理掉；&lt;/li&gt;
&lt;li&gt;如果还没有过期，再继续执行后续操作，并返回对应结果。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，惰性删除并不会主动扫描所有过期键，而是只在这个键再次被访问时，才顺带完成过期检查和清理。这种方式的优点是实现简单，而且不会为没有被访问的键额外付出检查成本；缺点是如果某些过期键长期不再被访问，它们仍然会继续占用内存。&lt;/p&gt;
&lt;h2&gt;周期删除&lt;/h2&gt;
&lt;p&gt;如果说惰性删除解决的是“被访问到的过期键”，那么周期删除解决的就是“长期没有被访问、但已经过期的键”。它的核心思路不是实时追踪每一个键，而是由 Redis 在后台以固定节奏抽样检查一部分设置了过期时间的键，并把其中已经过期的键删除掉。&lt;/p&gt;
&lt;p&gt;在 Redis 中，周期删除主要有两种触发时机：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;serverCron&lt;/code&gt; 会按照 &lt;code&gt;server.hz&lt;/code&gt; 的频率定期执行过期键处理，这通常可以理解为较常规的后台清理过程；&lt;/li&gt;
&lt;li&gt;在事件循环进入休眠前，Redis 还会在 &lt;code&gt;beforeSleep()&lt;/code&gt; 阶段再做一次更快速的过期键处理，用来加快过期键回收。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;需要注意的是，这里的核心并不是“定时把所有键都扫描一遍”，而是“周期性地抽样一部分过期字典中的键”。这样做的原因很直接：如果每次都全量扫描所有设置了过期时间的键，代价会过高；而抽样删除虽然不是绝对实时，但能在内存占用和 CPU 开销之间取得更好的平衡。&lt;/p&gt;
&lt;h3&gt;SLOW 模式&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;SLOW&lt;/code&gt; 模式由 Redis 的时间事件 &lt;code&gt;serverCron&lt;/code&gt; 触发。它会按照 &lt;code&gt;server.hz&lt;/code&gt; 的频率周期性执行；在默认配置下，&lt;code&gt;hz&lt;/code&gt; 通常是 &lt;code&gt;10&lt;/code&gt;，也就是每秒触发 &lt;code&gt;10&lt;/code&gt; 次。&lt;/p&gt;
&lt;p&gt;这个模式最关键的特点，是它有明确的时间预算限制。Redis 不会让一次过期扫描无限执行下去，而是只在给定的时间片内做清理；默认配置下，这个时间预算通常可以近似理解为单次不超过约 &lt;code&gt;25ms&lt;/code&gt;。一旦本轮扫描耗时超过阈值，Redis 就会立刻停止，把剩余工作留到下一次 &lt;code&gt;serverCron&lt;/code&gt; 再继续处理。&lt;/p&gt;
&lt;p&gt;在一次 &lt;code&gt;SLOW&lt;/code&gt; 模式的过期清理中，Redis 会按数据库依次推进。对于每个 &lt;code&gt;redisDb&lt;/code&gt;，它会执行如下过程：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;从当前数据库的 &lt;code&gt;expires&lt;/code&gt; 字典中随机抽样一小批键，默认每轮抽取 &lt;code&gt;20&lt;/code&gt; 个；&lt;/li&gt;
&lt;li&gt;逐个检查这些键是否已经过期；&lt;/li&gt;
&lt;li&gt;对已经过期的键执行删除；&lt;/li&gt;
&lt;li&gt;统计这一批样本中“过期键”的比例。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这里还有一个重要的继续条件：如果这一轮抽样里，过期键比例仍然比较高，那么说明当前数据库中可能还积压着大量过期键，Redis 就会立刻继续下一轮抽样检查。默认情况下，这个阈值是 &lt;code&gt;25%&lt;/code&gt;，也就是抽取的 &lt;code&gt;20&lt;/code&gt; 个键里如果有超过 &lt;code&gt;5&lt;/code&gt; 个已经过期，就说明还有必要继续清理。&lt;/p&gt;
&lt;p&gt;因此，&lt;code&gt;SLOW&lt;/code&gt; 模式并不是“每次只扫固定 20 个就结束”，而是“只要时间预算还够，并且样本中过期键比例仍然偏高，就继续抽样和删除”。只有在以下两种情况下，它才会结束本轮清理：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当前这一轮抽样的 &lt;code&gt;20&lt;/code&gt; 个键里，过期键数量不超过 &lt;code&gt;5&lt;/code&gt; 个，也就是过期比例不再高于 &lt;code&gt;25%&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;本次扫描的累计耗时已经达到时间预算上限，默认配置下通常可近似理解为约 &lt;code&gt;25ms&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果是因为时间预算耗尽而中断，Redis 会保留当前的推进位置，在下一次 &lt;code&gt;serverCron&lt;/code&gt; 触发时继续向后处理。这样做的目的，就是把过期键清理拆散到多个时间片里完成，避免一次性扫描过久而阻塞主线程。&lt;/p&gt;
&lt;h3&gt;FAST 模式&lt;/h3&gt;
&lt;p&gt;如果说 &lt;code&gt;SLOW&lt;/code&gt; 模式更像常规的后台清理，那么 &lt;code&gt;FAST&lt;/code&gt; 模式就是 Redis 在事件循环“空档期”里做的一次快速补扫。它不是按照固定时间片独立调度，而是在 &lt;code&gt;beforeSleep()&lt;/code&gt; 阶段触发，也就是 Redis 处理完一轮事件、准备进入阻塞等待新的网络 &lt;code&gt;IO&lt;/code&gt; 之前，顺手再做一次较短的过期键检查。&lt;/p&gt;
&lt;p&gt;也正因为它发生在事件循环的尾部，&lt;code&gt;FAST&lt;/code&gt; 模式的触发频率通常会高于 &lt;code&gt;SLOW&lt;/code&gt; 模式；但与此同时，它对自己的约束也更严格，目的是尽量不影响正常命令处理。&lt;/p&gt;
&lt;p&gt;在默认配置下，&lt;code&gt;FAST&lt;/code&gt; 模式主要有三层限制：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;单次执行时间上限通常是 &lt;code&gt;1ms&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;距离上一次 &lt;code&gt;FAST&lt;/code&gt; 执行时间太近时，会直接跳过，默认可近似理解为至少间隔 &lt;code&gt;2ms&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;如果上一轮慢速扫描并没有表现出明显的“过期键积压”，那么这次快速扫描也可能根本不启动。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;换句话说，&lt;code&gt;FAST&lt;/code&gt; 模式并不是“每次事件循环都一定执行”，而是“只有在值得快速补扫时才执行”。这样可以避免它在事件循环中被过于频繁地触发，反而拖慢主线程。&lt;/p&gt;
&lt;p&gt;从底层实现上看，&lt;code&gt;FAST&lt;/code&gt; 和 &lt;code&gt;SLOW&lt;/code&gt; 共用同一套主动过期删除逻辑，只是传入的运行参数不同。因此它的基本流程和 &lt;code&gt;SLOW&lt;/code&gt; 模式非常接近：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;从上一次处理到的数据库位置继续推进；&lt;/li&gt;
&lt;li&gt;在当前数据库的 &lt;code&gt;expires&lt;/code&gt; 字典中随机抽样一批键，默认仍然是 &lt;code&gt;20&lt;/code&gt; 个；&lt;/li&gt;
&lt;li&gt;检查这些键是否过期，并删除已经过期的键；&lt;/li&gt;
&lt;li&gt;根据当前样本中过期键的比例，决定是否继续下一轮抽样。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;不过，&lt;code&gt;FAST&lt;/code&gt; 模式比 &lt;code&gt;SLOW&lt;/code&gt; 模式要克制得多。默认情况下，只有在以下条件同时满足时，它才可能继续做下一轮抽样：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当前样本中过期键比例仍然较高；&lt;/li&gt;
&lt;li&gt;总耗时还没有达到 &lt;code&gt;1ms&lt;/code&gt; 的时间上限；&lt;/li&gt;
&lt;li&gt;本次快速循环本身没有因为前置限制而被直接跳过。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一旦达到时间上限，或者快速扫描已经没有足够收益，&lt;code&gt;FAST&lt;/code&gt; 模式就会立刻退出，把主线程尽快交还给正常的事件处理流程。也正因为有 &lt;code&gt;FAST&lt;/code&gt; 模式的补充，Redis 在两次 &lt;code&gt;serverCron&lt;/code&gt; 之间，仍然有机会更及时地回收一部分过期键。&lt;/p&gt;
</content:encoded></item><item><title>Redis 核心底层数据结构梳理</title><link>https://blog.huangnv.online/posts/2648/2648/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2648/2648/</guid><description>从 SDS、intset、dict、ziplist、quicklist 到 skiplist，系统梳理 Redis 核心底层数据结构及其组织方式。</description><pubDate>Wed, 08 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;redis的set，list等这些结构也是由一个个底层的数据结构组合起来的&lt;/p&gt;
&lt;h2&gt;SDS（动态字符串）&lt;/h2&gt;
&lt;p&gt;Redis 没有直接使用 C 语言原生的 &lt;code&gt;char*&lt;/code&gt; 来表示字符串，而是封装了一层 &lt;code&gt;SDS&lt;/code&gt;（Simple Dynamic String）。它本质上是在字符数组前面再附加一段元数据，用来记录字符串长度和可用空间。&lt;/p&gt;
&lt;p&gt;以 &lt;code&gt;sdshdr16&lt;/code&gt; 为例，它的结构大致如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct sdshdr16 {
    uint16_t len;
    uint16_t alloc;
    unsigned char flags;
    char buf[];
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;除了 &lt;code&gt;sdshdr16&lt;/code&gt;，Redis 还定义了 &lt;code&gt;sdshdr5&lt;/code&gt;、&lt;code&gt;sdshdr8&lt;/code&gt;、&lt;code&gt;sdshdr32&lt;/code&gt; 和 &lt;code&gt;sdshdr64&lt;/code&gt; 等几种变体。它们的核心思路是一致的，只是用不同位宽来保存长度和容量信息，从而在“小字符串减少头部开销”和“大字符串支持更大容量”之间取得平衡。&lt;/p&gt;
&lt;p&gt;理解 SDS 时，&lt;code&gt;len&lt;/code&gt;、&lt;code&gt;alloc&lt;/code&gt;、&lt;code&gt;flags&lt;/code&gt; 和 &lt;code&gt;buf&lt;/code&gt; 这四个部分其实同样重要，它们分别承担了不同职责：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;len&lt;/code&gt; 表示当前已经使用了多少字节，也就是字符串的实际长度。因为长度被直接记录下来，所以 Redis 获取字符串长度时不需要像 C 字符串那样一路扫描到 &lt;code&gt;\0&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;alloc&lt;/code&gt; 表示 &lt;code&gt;buf&lt;/code&gt; 当前一共分配了多少可用空间。它统计的是字符数组本身的容量，不包含头部，也不包含末尾额外预留的 &lt;code&gt;\0&lt;/code&gt;。因此在追加内容时，Redis 可以先检查 &lt;code&gt;alloc&lt;/code&gt; 是否足够，再决定是否扩容。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;flags&lt;/code&gt; 用来标识当前 SDS 采用的是哪一种头部类型。由于 Redis 对外通常暴露的是 &lt;code&gt;buf&lt;/code&gt; 的地址，也就是一个 &lt;code&gt;char*&lt;/code&gt;，所以它需要依靠 &lt;code&gt;flags&lt;/code&gt; 才能反推出前面的头部结构。比如，&lt;code&gt;s[-1]&lt;/code&gt; 取到的就是紧挨在 &lt;code&gt;buf&lt;/code&gt; 前面的 &lt;code&gt;flags&lt;/code&gt; 字节；Redis 再根据这个类型标记，判断当前字符串对应的是 &lt;code&gt;sdshdr8&lt;/code&gt;、&lt;code&gt;sdshdr16&lt;/code&gt;、&lt;code&gt;sdshdr32&lt;/code&gt; 还是 &lt;code&gt;sdshdr64&lt;/code&gt;，从而继续读取正确的 &lt;code&gt;len&lt;/code&gt; 和 &lt;code&gt;alloc&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;buf&lt;/code&gt; 才是真正存放字符内容的区域，并且仍然会以 &lt;code&gt;\0&lt;/code&gt; 结尾。这样设计的好处是，SDS 一方面能用 &lt;code&gt;len&lt;/code&gt; 做到二进制安全和高效取长，另一方面又能在很多场景下兼容 C 风格字符串接口。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这里还要注意一点：SDS 的头部类型并不是一旦确定就永远不变。如果原本是 &lt;code&gt;sdshdr8&lt;/code&gt;，但某次追加的数据很多，导致新的目标长度已经超出了当前类型的可表示范围，那么 Redis 就会在扩容时重新选择更大的头部类型，比如升级为 &lt;code&gt;sdshdr16&lt;/code&gt;。再往上也是同样的道理，必要时还可以继续升级到 &lt;code&gt;sdshdr32&lt;/code&gt; 或 &lt;code&gt;sdshdr64&lt;/code&gt;。也就是说，&lt;code&gt;flags&lt;/code&gt; 不只是“标记当前类型”，它还让 Redis 能在字符串变长之后切换到更合适的 SDS 结构。&lt;/p&gt;
&lt;p&gt;正是因为这几个字段协同工作，SDS 才同时具备了几个关键能力：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;获取长度快，因为可以直接读取 &lt;code&gt;len&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;追加效率高，因为可以利用 &lt;code&gt;alloc&lt;/code&gt; 判断是否需要扩容&lt;/li&gt;
&lt;li&gt;扩容成本更可控，因为 Redis 会基于剩余空间和预分配策略减少频繁 &lt;code&gt;realloc&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;类型定位明确，因为可以通过 &lt;code&gt;flags&lt;/code&gt; 和 &lt;code&gt;s[-1]&lt;/code&gt; 反推出实际头部结构&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Redis 在 SDS 扩容上采用了预分配策略：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果扩容后的目标长度小于 &lt;code&gt;1MB&lt;/code&gt;，通常按两倍方式扩容&lt;/li&gt;
&lt;li&gt;如果扩容后的目标长度大于等于 &lt;code&gt;1MB&lt;/code&gt;，则不再继续翻倍，而是在目标长度基础上额外多分配 &lt;code&gt;1MB&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样做的目的，是在小字符串场景下尽量减少重复扩容，而在大字符串场景下避免因为持续翻倍而浪费过多内存。&lt;/p&gt;
&lt;p&gt;下面再看一个具体例子。&lt;/p&gt;
&lt;h3&gt;一个具体例子&lt;/h3&gt;
&lt;p&gt;假设当前 SDS 中存放的是 &lt;code&gt;hello&lt;/code&gt;。如果先按“刚好够用”的方式来理解，那么这时可以写成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;len = 5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;alloc = 5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;buf = &quot;hello\0&quot;&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这里的含义是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当前实际内容长度是 5&lt;/li&gt;
&lt;li&gt;&lt;code&gt;buf&lt;/code&gt; 的可用容量也是 5&lt;/li&gt;
&lt;li&gt;末尾额外保留一个 &lt;code&gt;\0&lt;/code&gt;，用于兼容 C 风格字符串接口&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果接下来再追加 &lt;code&gt;world&lt;/code&gt;，那么新的实际内容就会变成 &lt;code&gt;helloworld&lt;/code&gt;，长度是 &lt;code&gt;10&lt;/code&gt;。由于 &lt;code&gt;10 &amp;lt; 1MB&lt;/code&gt;，此时 Redis 会按照小于 &lt;code&gt;1MB&lt;/code&gt; 的预分配策略处理，一般会把容量扩到目标长度的两倍，也就是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;len = 10&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;alloc = 20&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;buf = &quot;helloworld\0&quot;&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;./sds-hello-expansion.svg&quot; alt=&quot;hello 追加 world 前后的 SDS 内存布局示意图&quot; /&gt;&lt;/p&gt;
&lt;p&gt;上图使用 &lt;code&gt;sdshdr8&lt;/code&gt; 作为示意。左侧是初始状态，右侧是追加 &lt;code&gt;world&lt;/code&gt; 之后的状态。可以看到，扩容后不仅 &lt;code&gt;len&lt;/code&gt; 从 &lt;code&gt;5&lt;/code&gt; 增长到 &lt;code&gt;10&lt;/code&gt;，&lt;code&gt;buf&lt;/code&gt; 后面还额外多出了一段 &lt;code&gt;unused&lt;/code&gt; 区域，这部分就是预分配出来的空闲空间，用来减少后续继续追加时的重复扩容。&lt;/p&gt;
&lt;p&gt;如果不是这个小例子，而是某次追加后目标长度已经达到或超过 &lt;code&gt;1MB&lt;/code&gt;，那么扩容策略就会切换。此时不会再把容量直接翻倍，而是改为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;新的 &lt;code&gt;alloc = 目标长度 + 1MB&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;例如，如果扩容后的目标长度是 &lt;code&gt;1.2MB&lt;/code&gt;，那么新的可用容量通常会分配到大约 &lt;code&gt;2.2MB&lt;/code&gt;。这样既保留了一定的追加空间，也避免了大字符串继续按倍数增长带来的内存膨胀。&lt;/p&gt;
&lt;p&gt;这种设计让 SDS 既保留了 C 字符串“可直接传递 &lt;code&gt;char*&lt;/code&gt;”的使用方式，又补上了原生字符串无法高效获取长度、扩容不安全等问题，因此成为 Redis 内部字符串实现的基础。&lt;/p&gt;
&lt;h2&gt;intset&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;intset&lt;/code&gt; 是 Redis 用来保存整数集合的一种紧凑结构，底层本质上是一块连续内存。它的结构定义如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;typedef struct intset {
    uint32_t encoding;  // 当前集合的编码方式
    uint32_t length;    // 当前集合中的元素个数
    int8_t contents[];  // 实际保存元素的连续内存区域
} intset;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;理解 &lt;code&gt;intset&lt;/code&gt; 时，最关键的是 &lt;code&gt;encoding&lt;/code&gt;、&lt;code&gt;length&lt;/code&gt; 和 &lt;code&gt;contents&lt;/code&gt; 这三个部分。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;encoding&lt;/code&gt; 表示当前集合中每个元素采用多少字节来存储。虽然 &lt;code&gt;contents&lt;/code&gt; 被声明成了 &lt;code&gt;int8_t[]&lt;/code&gt;，但它并不意味着集合里真的只存 8 位整数。真正决定元素类型的是 &lt;code&gt;encoding&lt;/code&gt;。在 Redis 中，常见的编码方式有 &lt;code&gt;INTSET_ENC_INT16&lt;/code&gt;、&lt;code&gt;INTSET_ENC_INT32&lt;/code&gt; 和 &lt;code&gt;INTSET_ENC_INT64&lt;/code&gt;，分别表示每个元素占用 &lt;code&gt;2&lt;/code&gt;、&lt;code&gt;4&lt;/code&gt;、&lt;code&gt;8&lt;/code&gt; 字节。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;length&lt;/code&gt; 表示当前集合中的元素个数，而不是字节数。比如一个 &lt;code&gt;length = 3&lt;/code&gt; 的 &lt;code&gt;intset&lt;/code&gt;，如果当前编码是 &lt;code&gt;INTSET_ENC_INT64&lt;/code&gt;，那么它表示集合里有 3 个整数，只不过每个整数都要占 8 字节。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;contents&lt;/code&gt; 才是真正存放整数值的区域。Redis 会根据 &lt;code&gt;encoding&lt;/code&gt; 把这段连续内存按 &lt;code&gt;int16_t[]&lt;/code&gt;、&lt;code&gt;int32_t[]&lt;/code&gt; 或 &lt;code&gt;int64_t[]&lt;/code&gt; 的方式去解释和访问。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;intset&lt;/code&gt; 能成立的前提，是它始终保持两条约束：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;集合中的元素按从小到大有序排列&lt;/li&gt;
&lt;li&gt;集合中的元素不能重复&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;正因为这两个条件同时成立，Redis 在查找某个整数时就可以直接使用二分查找来定位；如果要插入一个不存在的新值，也可以先通过二分查找确定它应该落在哪个位置，再把后面的元素整体后移。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;intset&lt;/code&gt; 的另一个关键点，是整个集合必须使用统一的编码方式。也就是说，集合里所有元素都按同一种位宽存储，而这个位宽取决于当前集合中“最占空间”的那个值。&lt;/p&gt;
&lt;p&gt;例如，若集合中的元素是 &lt;code&gt;[1, 3, 10000000]&lt;/code&gt;，那么由于 &lt;code&gt;10000000&lt;/code&gt; 已经超出了 &lt;code&gt;int16_t&lt;/code&gt; 的表示范围，整个集合就不能继续使用 &lt;code&gt;INTSET_ENC_INT16&lt;/code&gt;，而是至少要升级到 &lt;code&gt;INTSET_ENC_INT32&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./intset-layout.svg&quot; alt=&quot;intset 中 [1, 3, 10000000] 的最终内存布局示意图&quot; /&gt;&lt;/p&gt;
&lt;p&gt;上图展示的是这个例子的最终结构。此时 &lt;code&gt;encoding = 4&lt;/code&gt;，表示整个 &lt;code&gt;contents&lt;/code&gt; 区域都要按 &lt;code&gt;int32&lt;/code&gt; 的方式解释；&lt;code&gt;length = 3&lt;/code&gt;，表示当前集合中一共有 3 个逻辑元素。虽然 &lt;code&gt;contents&lt;/code&gt; 在结构体里声明为 &lt;code&gt;int8_t[]&lt;/code&gt;，但它本质上只是“一段连续字节内存”。在这个例子里，这段内存总长度是 &lt;code&gt;12&lt;/code&gt; 字节，因为 3 个元素都按 &lt;code&gt;int32&lt;/code&gt; 存储，所以每个元素都会占用连续 &lt;code&gt;4&lt;/code&gt; 个 &lt;code&gt;contents&lt;/code&gt; 字节。换句话说，&lt;code&gt;element 0&lt;/code&gt; 对应 &lt;code&gt;contents[0..3]&lt;/code&gt;，&lt;code&gt;element 1&lt;/code&gt; 对应 &lt;code&gt;contents[4..7]&lt;/code&gt;，&lt;code&gt;element 2&lt;/code&gt; 对应 &lt;code&gt;contents[8..11]&lt;/code&gt;。从总占用上看，&lt;code&gt;encoding&lt;/code&gt; 和 &lt;code&gt;length&lt;/code&gt; 各占 &lt;code&gt;4&lt;/code&gt; 字节，头部一共 &lt;code&gt;8&lt;/code&gt; 字节；&lt;code&gt;contents&lt;/code&gt; 一共 &lt;code&gt;12&lt;/code&gt; 字节，因此这份 &lt;code&gt;intset&lt;/code&gt; 的实际分配大小可以理解为 &lt;code&gt;20&lt;/code&gt; 字节。&lt;/p&gt;
&lt;p&gt;如果一个原本使用 &lt;code&gt;INTSET_ENC_INT16&lt;/code&gt; 的集合，突然插入了一个只能用 &lt;code&gt;INTSET_ENC_INT64&lt;/code&gt; 表示的新值，那么 Redis 会执行一次编码升级。这个过程可以概括为：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先根据新值计算出所需的更大编码类型&lt;/li&gt;
&lt;li&gt;按新编码重新申请一块更大的连续内存&lt;/li&gt;
&lt;li&gt;从尾部开始搬运旧元素，避免在搬运过程中覆盖还未处理的数据&lt;/li&gt;
&lt;li&gt;把新值插入到正确的位置&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这里从尾部开始搬运并不是随意选择的细节，而是升级过程中的关键点。因为旧数组和新数组的数据区域可能发生重叠，如果从前往后写，就有可能把后面还没搬的数据先覆盖掉；而从后往前搬，可以避免这个问题。&lt;/p&gt;
&lt;p&gt;还有一个很有意思的细节是：如果新插入的值导致编码升级，那么这个值一定会落在集合的最前面或最后面。原因是它既然已经超出了当前编码可表示的范围，那么它的数值一定比原集合里的所有值都更小，或者都更大。因此，Redis 在升级时只需要处理“头插”或“尾插”这两种情况，不需要在中间位置做复杂插入。&lt;/p&gt;
&lt;p&gt;整体来看，&lt;code&gt;intset&lt;/code&gt; 适合元素数量不大、并且所有成员都是整数的场景。它利用“有序 + 唯一 + 统一编码”这几个条件，把空间占用压得比较低，同时也让查找和插入逻辑保持在一个相对可控的复杂度内。&lt;/p&gt;
&lt;h2&gt;DICT&lt;/h2&gt;
&lt;p&gt;DICT 主要可以拆成三个层次来看，分别是哈希节点 &lt;code&gt;DictEntry&lt;/code&gt;、哈希表 &lt;code&gt;DictHashTable&lt;/code&gt; 和最外层的字典 &lt;code&gt;Dict&lt;/code&gt;。理解时比较自然的顺序是从下往上，也就是先看单个节点是怎么存的，再看这些节点如何组织成哈希表，最后再看整个字典结构如何管理它们。&lt;/p&gt;
&lt;h3&gt;哈希节点(DictEntry)&lt;/h3&gt;
&lt;p&gt;哈希节点是 DICT 中最基础的存储单元，它的数据结构如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;typedef struct dictEntry {
    void *key;                // 键 (通常是指向一个 SDS 字符串)
    union {                   // 值 (联合体，节省空间)
        void *val;            // 指向复杂对象 (如 Redis Object)
        uint64_t u64;         // 无符号 64 位整数
        int64_t s64;          // 有符号 64 位整数
        double d;             // 浮点数
    } v;
    struct dictEntry *next;   // 指向下一个哈希节点 (用于解决哈希冲突)
} dictEntry;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个结构里最关键的是 &lt;code&gt;key&lt;/code&gt;、&lt;code&gt;v&lt;/code&gt; 和 &lt;code&gt;next&lt;/code&gt; 三个部分。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;key&lt;/code&gt; 指向当前节点保存的键。在 Redis 中，这个键很多时候会是一个 SDS 字符串。Redis 会先根据 &lt;code&gt;key&lt;/code&gt; 计算哈希值，再结合哈希表长度和掩码，把这个节点放进对应的桶里。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;v&lt;/code&gt; 是一个联合体 &lt;code&gt;union&lt;/code&gt;，表示当前节点对应的值。这里之所以使用联合体，而不是把 &lt;code&gt;void *&lt;/code&gt;、&lt;code&gt;uint64_t&lt;/code&gt;、&lt;code&gt;int64_t&lt;/code&gt;、&lt;code&gt;double&lt;/code&gt; 全部同时放进结构体里，是因为一个节点在任意时刻只需要使用其中一种表示方式。联合体的含义是“这些字段共用同一块内存”，因此 &lt;code&gt;v&lt;/code&gt; 可以是指针、无符号整数、有符号整数或浮点数中的任意一种，但它本身只占一份最大成员所需的空间，而不是四份都加起来。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;next&lt;/code&gt; 用来指向同一个桶中的下一个节点。这是 Redis 处理哈希冲突的方式之一，也就是链地址法。两个不同的键经过哈希计算后，可能会因为桶数量有限、以及与掩码运算后的结果相同，被分配到同一个桶里。此时后插入的节点不会覆盖前面的节点，而是通过 &lt;code&gt;next&lt;/code&gt; 串成一条链。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，一个桶里并不一定只放一个 &lt;code&gt;dictEntry&lt;/code&gt;。如果没有冲突，那么这个桶里可能只有一个节点；如果发生冲突，那么这个桶里就会形成一条链表，而 &lt;code&gt;next&lt;/code&gt; 正是把这条链表连接起来的指针。&lt;/p&gt;
&lt;h3&gt;哈希表（DictHashTable）&lt;/h3&gt;
&lt;p&gt;哈希节点再往上一层，就是哈希表 &lt;code&gt;DictHashTable&lt;/code&gt;。它负责管理一组桶，并把多个 &lt;code&gt;dictEntry&lt;/code&gt; 组织起来。它的数据结构如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;typedef struct dictht {
    dictEntry **table;      // 哈希表数组（指向 一个个dictEntry 指针）
    unsigned long size;     // 哈希表大小（数组长度）
    unsigned long sizemask; // 哈希表大小掩码，用于计算索引（等于 size - 1）
    unsigned long used;     // 该哈希表已有节点的数量
} dictht;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;理解 &lt;code&gt;dictht&lt;/code&gt; 时，最关键的是 &lt;code&gt;table&lt;/code&gt;、&lt;code&gt;size&lt;/code&gt;、&lt;code&gt;sizemask&lt;/code&gt; 和 &lt;code&gt;used&lt;/code&gt; 这四个字段。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;table&lt;/code&gt; 指向整个哈希表的桶数组。数组中的每个位置并不是直接存键值对本身，而是存一个 &lt;code&gt;dictEntry*&lt;/code&gt;。如果某个桶里只有一个节点，那么这个指针就直接指向那个节点；如果某个桶里有多个节点发生冲突，那么这个指针就指向链表头节点，后续节点再通过 &lt;code&gt;next&lt;/code&gt; 串起来。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;size&lt;/code&gt; 表示当前哈希表中桶的总数，也就是 &lt;code&gt;table&lt;/code&gt; 数组的长度。Redis 的哈希表长度通常会保持为 &lt;code&gt;2&lt;/code&gt; 的幂，这样后面就可以配合 &lt;code&gt;sizemask&lt;/code&gt; 用位运算来快速计算桶下标。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sizemask&lt;/code&gt; 通常等于 &lt;code&gt;size - 1&lt;/code&gt;。Redis 在定位桶位置时，并不是直接做普通意义上的“取模”，而是利用 &lt;code&gt;hash &amp;amp; sizemask&lt;/code&gt; 来得到索引。只有当 &lt;code&gt;size&lt;/code&gt; 是 &lt;code&gt;2&lt;/code&gt; 的幂时，这种写法才成立，并且效率也更高。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;used&lt;/code&gt; 表示当前哈希表里已经存了多少个节点。这里统计的是节点总数，不是非空桶的数量。也就是说，即使多个节点因为哈希冲突落到同一个桶里，&lt;code&gt;used&lt;/code&gt; 依然会把它们全部计入。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;可以把 &lt;code&gt;dictht&lt;/code&gt; 理解成“桶数组 + 一组统计信息”的组合：&lt;code&gt;table&lt;/code&gt; 负责真正挂载节点，&lt;code&gt;size&lt;/code&gt; 和 &lt;code&gt;sizemask&lt;/code&gt; 决定节点应该落到哪个桶里，&lt;code&gt;used&lt;/code&gt; 则负责记录当前这张哈希表已经使用到了什么程度。&lt;/p&gt;
&lt;h3&gt;字典（Dict）&lt;/h3&gt;
&lt;p&gt;再往上一层，就是最外层的字典 &lt;code&gt;Dict&lt;/code&gt;。如果说 &lt;code&gt;dictEntry&lt;/code&gt; 负责保存单个键值对，&lt;code&gt;dictht&lt;/code&gt; 负责组织桶和节点，那么 &lt;code&gt;dict&lt;/code&gt; 负责管理整张哈希表的行为，包括如何计算哈希、如何比较键，以及何时进行 rehash。为了便于理解，可以先抓住它的几个核心字段：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;typedef struct dict {
    dictType *type;      // 类型相关函数，如计算哈希、比较 key 等
    void *privdata;      // 传给 type 中回调函数的私有数据
    dictht ht[2];        // 两张哈希表，渐进式 rehash 时同时使用
    long rehashidx;      // 当前 rehash 迁移到的位置；不在 rehash 时为 -1
    int16_t pauserehash; // rehash 是否被暂停，或暂停计数
} dict;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;理解 &lt;code&gt;dict&lt;/code&gt; 时，最关键的是 &lt;code&gt;type&lt;/code&gt;、&lt;code&gt;privdata&lt;/code&gt;、&lt;code&gt;ht[2]&lt;/code&gt;、&lt;code&gt;rehashidx&lt;/code&gt; 和 &lt;code&gt;pauserehash&lt;/code&gt; 这几个部分。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type&lt;/code&gt; 指向一组和具体键值类型相关的函数。比如，键应该如何计算哈希值、两个键应该如何比较、键和值在释放时是否需要特殊处理，这些行为都不是写死在哈希表里的，而是通过 &lt;code&gt;dictType&lt;/code&gt; 提供的回调函数来决定。也正因为如此，Redis 的同一套字典实现才能复用在不同场景下。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;privdata&lt;/code&gt; 是传给这些回调函数使用的私有数据。它本身并不直接参与哈希表存储，而是为 &lt;code&gt;type&lt;/code&gt; 里的函数提供额外上下文。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ht[2]&lt;/code&gt; 是 &lt;code&gt;dict&lt;/code&gt; 里最关键的字段之一。这里并不是永远都同时保存两张完整的哈希表。通常情况下，只有 &lt;code&gt;ht[0]&lt;/code&gt; 在承载当前数据，&lt;code&gt;ht[1]&lt;/code&gt; 为空；而当字典需要扩容或缩容时，Redis 会为 &lt;code&gt;ht[1]&lt;/code&gt; 准备一张新的哈希表，然后把旧表中的节点逐步迁移过去。这也是 Redis 支持渐进式 rehash 的基础。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rehashidx&lt;/code&gt; 用来记录当前 rehash 进行到了哪一个桶。当它等于 &lt;code&gt;-1&lt;/code&gt; 时，说明当前没有在进行 rehash；一旦开始 rehash，它就会指向当前正在迁移的桶下标。随着每次操作顺带搬运一部分数据，这个索引会不断向后推进，直到旧表迁移完成。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pauserehash&lt;/code&gt; 表示 rehash 当前是否被暂停。在概念上，可以先把它理解成“暂停状态”或“暂停计数”：当它处于暂停状态时，字典不会继续推进 rehash；恢复之后，迁移过程才会继续进行。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，&lt;code&gt;dict&lt;/code&gt; 的角色并不是简单“再包一层结构体”，而是把“节点如何存”“桶如何组织”和“整张表如何迁移”这些问题统一管理起来。特别是 &lt;code&gt;ht[2] + rehashidx&lt;/code&gt; 这一组字段，正是 Redis 能把一次大规模扩容拆成多次小迁移、避免阻塞式重哈希的关键。&lt;/p&gt;
&lt;h2&gt;一般情况下的 Dict&lt;/h2&gt;
&lt;p&gt;下面这张图可以把前面三层结构放在一起看。正常情况下，&lt;code&gt;dict&lt;/code&gt; 中真正承载数据的是 &lt;code&gt;ht[0]&lt;/code&gt;，而 &lt;code&gt;ht[1]&lt;/code&gt; 仍然保持为空，因此 &lt;code&gt;rehashidx = -1&lt;/code&gt;。&lt;code&gt;ht[0]&lt;/code&gt; 的 &lt;code&gt;table&lt;/code&gt; 会指向桶数组，桶数组中的非空位置再进一步指向具体的 &lt;code&gt;dictEntry&lt;/code&gt; 节点。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./dict-normal-layout.svg&quot; alt=&quot;一般情况下 Dict 的结构示意图&quot; /&gt;&lt;/p&gt;
&lt;p&gt;从这个视角看，&lt;code&gt;DictEntry&lt;/code&gt;、&lt;code&gt;DictHashTable&lt;/code&gt; 和 &lt;code&gt;Dict&lt;/code&gt; 三者的关系会更清楚：&lt;code&gt;dictEntry&lt;/code&gt; 是单个键值对节点，&lt;code&gt;dictht&lt;/code&gt; 负责组织桶数组和节点分布，而最外层的 &lt;code&gt;dict&lt;/code&gt; 则负责管理两张哈希表以及整个 rehash 状态。&lt;/p&gt;
&lt;h3&gt;渐进式rehash&lt;/h3&gt;
&lt;p&gt;前面已经看到，哈希表中的一个桶并不一定只挂一个节点。当冲突越来越多时，同一个桶上的链会变长，查找成本也会随之上升。因此，字典在元素数量变化到一定程度后，需要通过扩容或缩容来调整哈希表大小，而这背后的核心过程就是 &lt;code&gt;rehash&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;先看负载因子。它可以简单理解为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;LoadFactor = used / size
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中，&lt;code&gt;used&lt;/code&gt; 表示当前节点总数，&lt;code&gt;size&lt;/code&gt; 表示桶总数。负载因子越大，平均每个桶上挂的节点就越多，发生冲突的概率也越高。&lt;/p&gt;
&lt;p&gt;在当前 Redis 源码实现里，字典是否触发扩容或缩容，与负载因子以及当前是否允许 resize 有关。可以先抓住两个核心方向：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当负载因子上升到一定程度时，字典需要扩容，避免过多节点堆积到同一个桶里&lt;/li&gt;
&lt;li&gt;当负载因子下降到很低时，字典会考虑缩容，避免桶太多而元素太少，造成空间浪费&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;无论是扩容还是缩容，本质上都会创建一张新的哈希表。由于新表的 &lt;code&gt;size&lt;/code&gt; 和 &lt;code&gt;sizemask&lt;/code&gt; 都会变化，而键的桶下标又依赖 &lt;code&gt;sizemask&lt;/code&gt; 来计算，因此旧表中的每一个键都必须重新计算索引并迁移到新表里，这个过程就叫做 &lt;code&gt;rehash&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;在 Redis 中，这个过程不是一次性完成的，而是采用渐进式 rehash。整体流程可以概括为：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先根据当前是扩容还是缩容，计算新哈希表的大小。新表大小通常会取到不小于目标元素数量的下一个 &lt;code&gt;2&lt;/code&gt; 的幂，并且不会小于最小限制。&lt;/li&gt;
&lt;li&gt;按新的 &lt;code&gt;size&lt;/code&gt; 申请内存，创建新表，并把它挂到 &lt;code&gt;dict.ht[1]&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;把 &lt;code&gt;rehashidx&lt;/code&gt; 设为 &lt;code&gt;0&lt;/code&gt;，表示开始从旧表 &lt;code&gt;ht[0]&lt;/code&gt; 的第一个桶向后迁移。&lt;/li&gt;
&lt;li&gt;后续每次执行新增、查询、修改、删除等常见操作时，Redis 都会顺带搬迁一部分桶中的数据，而不是一次把整张表全部迁完。&lt;/li&gt;
&lt;li&gt;当 &lt;code&gt;ht[0]&lt;/code&gt; 中的节点全部迁移完成后，Redis 会让 &lt;code&gt;ht[1]&lt;/code&gt; 接管为新的 &lt;code&gt;ht[0]&lt;/code&gt;，再把 &lt;code&gt;ht[1]&lt;/code&gt; 重新置空，同时把 &lt;code&gt;rehashidx&lt;/code&gt; 设回 &lt;code&gt;-1&lt;/code&gt;，表示 rehash 结束。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;渐进式 rehash 的关键，不在于“把数据搬过去”这件事本身，而在于“把一次大迁移拆成很多次小迁移”。这样做可以避免单次操作承担过大的搬运成本，从而减少阻塞风险。&lt;/p&gt;
&lt;p&gt;在 rehash 进行过程中，字典的读写规则也会发生一点变化：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;新增操作会直接写入 &lt;code&gt;ht[1]&lt;/code&gt;，而不再写入旧表 &lt;code&gt;ht[0]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;查询、修改和删除操作则需要同时在 &lt;code&gt;ht[0]&lt;/code&gt; 和 &lt;code&gt;ht[1]&lt;/code&gt; 中查找&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样设计的目的，是让旧表只减不增。随着 rehash 持续推进，&lt;code&gt;ht[0]&lt;/code&gt; 中的数据会越来越少，直到最终完全清空。&lt;/p&gt;
&lt;p&gt;因此，渐进式 rehash 可以看成 Redis 字典的一种“边用边迁移”机制：表面上字典仍然正常提供增删改查能力，底层却在悄悄把数据从旧表搬到新表中。这也是 Redis 能在哈希表需要扩缩容时，尽量避免一次性重排整张表的关键原因。&lt;/p&gt;
&lt;h2&gt;ZipList（双端链表）&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;ZipList&lt;/code&gt; 可以理解成一种“紧凑连续内存”上的双端结构。它并不像传统双向链表那样把每个节点分散地挂在堆内存里，而是把整份数据压缩进一块连续空间中，因此更节省内存，但中间位置插入、删除或遍历的成本也会更高。&lt;/p&gt;
&lt;p&gt;一个完整的 &lt;code&gt;ZipList&lt;/code&gt; 主要由五个部分组成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;zlbytes&lt;/code&gt;，占 &lt;code&gt;4&lt;/code&gt; 字节，用来记录整个压缩列表当前一共占用了多少字节内存。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;zltail&lt;/code&gt;，占 &lt;code&gt;4&lt;/code&gt; 字节，用来记录尾节点距离起始地址的偏移量。借助这个字段，程序可以直接定位到最后一个 &lt;code&gt;entry&lt;/code&gt;，因此尾插、尾删会比较方便。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;zllen&lt;/code&gt;，占 &lt;code&gt;2&lt;/code&gt; 字节，用来记录当前节点数量。当节点数小于 &lt;code&gt;65535&lt;/code&gt; 时，这个值就是准确数量；如果超过这个范围，就需要通过遍历整张表来得到真实节点数。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;entry&lt;/code&gt;，长度不固定，是真正存放数据的节点区域。压缩列表里的所有元素都会依次放在这里。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;zlend&lt;/code&gt;，占 &lt;code&gt;1&lt;/code&gt; 字节，固定为 &lt;code&gt;0xFF&lt;/code&gt;，表示整个压缩列表的结束位置。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;./ziplist-overview.svg&quot; alt=&quot;ZipList 整体结构示意图&quot; /&gt;&lt;/p&gt;
&lt;p&gt;从使用特点上看，&lt;code&gt;ZipList&lt;/code&gt; 更适合“从头部或尾部操作”的场景。因为它内部是一块连续内存，虽然首尾定位比较方便，但如果在中间位置频繁插入、删除，或者大量遍历，整体性能并不理想。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ZipList&lt;/code&gt; 中最核心的部分是 &lt;code&gt;entry&lt;/code&gt;。每个 &lt;code&gt;entry&lt;/code&gt; 又可以拆成三个部分来理解：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./ziplist-entry.svg&quot; alt=&quot;ZipList 中单个 entry 的结构示意图&quot; /&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;previous_entry_length&lt;/code&gt;
这个字段用来记录前一个节点的长度。如果前一个节点长度小于 &lt;code&gt;254&lt;/code&gt; 字节，那么这个字段只占 &lt;code&gt;1&lt;/code&gt; 字节；如果前一个节点长度大于等于 &lt;code&gt;254&lt;/code&gt; 字节，那么这个字段就会扩展成 &lt;code&gt;5&lt;/code&gt; 字节，其中首字节固定为 &lt;code&gt;0xFE&lt;/code&gt;，后面 &lt;code&gt;4&lt;/code&gt; 个字节用来保存前一个节点的实际长度。它的作用很关键：程序拿到当前节点地址后，只要减去这个长度，就可以直接跳到前一个节点，从而支持从尾到头的反向遍历。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;encoding&lt;/code&gt;
这个字段用来记录当前节点数据的类型和长度。Redis 会根据当前存储的是整数还是字符串、以及数据本身的大小，选择不同的编码方式。也就是说，&lt;code&gt;encoding&lt;/code&gt; 不只是“标记类型”，还决定了后面的内容应该按多少字节来解析。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;content&lt;/code&gt;
这部分才是真正存放数据的区域。如果当前节点保存的是字符串，那么这里存的就是字符串字节；如果当前节点保存的是整数，那么这里存的就是对应整数编码后的内容。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;可以看到，&lt;code&gt;ZipList&lt;/code&gt; 的设计核心在于“尽量把元信息压缩到最少，同时又保留双端访问能力”。&lt;code&gt;zlbytes&lt;/code&gt;、&lt;code&gt;zltail&lt;/code&gt; 和 &lt;code&gt;zlend&lt;/code&gt; 负责管理整份列表，&lt;code&gt;entry&lt;/code&gt; 内部再通过 &lt;code&gt;previous_entry_length + encoding + content&lt;/code&gt; 描述每一个节点。正因为这些信息都紧贴在一起存放，&lt;code&gt;ZipList&lt;/code&gt; 才能在空间利用率上做得比较极致。&lt;/p&gt;
&lt;p&gt;不过，ZipList 的正向遍历并不算特别方便。程序必须从前往后逐个解析每一个 &lt;code&gt;entry&lt;/code&gt;，才能定位下一个节点；它不像反向遍历那样，能够直接借助 &lt;code&gt;previous_entry_length&lt;/code&gt; 快速回退。&lt;/p&gt;
&lt;h3&gt;ZipList 的连锁更新问题&lt;/h3&gt;
&lt;p&gt;ZipList 最著名的问题之一，就是连锁更新（cascade update）。它并不是每次插入都会发生，而是只会在某些节点长度恰好跨过临界值时被触发。但一旦触发，就可能把一次本来局部的修改，放大成后续多个节点的连续重写。&lt;/p&gt;
&lt;p&gt;这个问题的根源，正好就在前面提到的 &lt;code&gt;previous_entry_length&lt;/code&gt; 字段上。&lt;/p&gt;
&lt;p&gt;前面已经说过，&lt;code&gt;previous_entry_length&lt;/code&gt; 有两种编码方式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果前一个节点长度小于 &lt;code&gt;254&lt;/code&gt; 字节，那么当前节点只需要用 &lt;code&gt;1&lt;/code&gt; 字节记录它&lt;/li&gt;
&lt;li&gt;如果前一个节点长度大于等于 &lt;code&gt;254&lt;/code&gt; 字节，那么当前节点就必须用 &lt;code&gt;5&lt;/code&gt; 字节来记录它&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;问题就出在这个“&lt;code&gt;1&lt;/code&gt; 字节和 &lt;code&gt;5&lt;/code&gt; 字节之间切换”的边界上。&lt;/p&gt;
&lt;p&gt;假设原来某个节点长度比较小，后一个节点的 &lt;code&gt;previous_entry_length&lt;/code&gt; 只用了 &lt;code&gt;1&lt;/code&gt; 字节来记录它。现在如果我们在前面插入一个更大的节点，或者让原节点本身变长，使得它的长度从“小于 &lt;code&gt;254&lt;/code&gt;”变成了“大于等于 &lt;code&gt;254&lt;/code&gt;”，那么后一个节点原来的 &lt;code&gt;1&lt;/code&gt; 字节 &lt;code&gt;previous_entry_length&lt;/code&gt; 就不够用了，必须扩展为 &lt;code&gt;5&lt;/code&gt; 字节。&lt;/p&gt;
&lt;p&gt;而一旦这个后一个节点因为扩展 &lt;code&gt;previous_entry_length&lt;/code&gt; 自己也变长了，它后面的下一个节点就又可能受到影响：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;原本记录前一节点长度时，&lt;code&gt;1&lt;/code&gt; 字节够用&lt;/li&gt;
&lt;li&gt;现在前一节点因为扩容多了 &lt;code&gt;4&lt;/code&gt; 个字节&lt;/li&gt;
&lt;li&gt;结果当前节点的 &lt;code&gt;previous_entry_length&lt;/code&gt; 也可能需要从 &lt;code&gt;1&lt;/code&gt; 字节升级到 &lt;code&gt;5&lt;/code&gt; 字节&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样一来，更新就会像多米诺骨牌一样向后传播，这就是所谓的“连锁更新”。&lt;/p&gt;
&lt;p&gt;可以把它理解成这样：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;前面某个节点变长&lt;/li&gt;
&lt;li&gt;后面节点的 &lt;code&gt;previous_entry_length&lt;/code&gt; 不够用，必须扩容&lt;/li&gt;
&lt;li&gt;这个后面节点自己也随之变长&lt;/li&gt;
&lt;li&gt;再继续影响它后面的节点&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果后面连续很多个节点原本都处在这个临界附近，那么一次插入或修改，就可能导致后续一长串节点都要重新调整位置和长度。&lt;/p&gt;
&lt;p&gt;这也是 ZipList 的一个典型缺点：它虽然在空间利用率上很紧凑，但因为整份结构是一块连续内存，所以一旦发生这种连锁更新，就不仅仅是“改一个节点”，而是可能触发一连串内存重分配和数据搬移。节点越多，这个代价就越明显。&lt;/p&gt;
&lt;p&gt;也正因为这个问题，ZipList 后来逐渐被更适合紧凑存储的新结构替代。在理解 ZipList 时，连锁更新几乎是绕不过去的一个点，因为它非常典型地体现了这种数据结构的设计权衡：省空间，但某些修改操作的代价可能会被放大。&lt;/p&gt;
&lt;h2&gt;QuickList&lt;/h2&gt;
&lt;p&gt;前面已经看到，&lt;code&gt;ZipList&lt;/code&gt; 虽然节省内存，但它本质上仍然是一块连续空间。一旦节点数量变多，或者中间位置频繁插入、删除，整体搬移成本就会比较高。为了兼顾“紧凑存储”和“更可控的修改成本”，Redis 又引入了 &lt;code&gt;QuickList&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;可以把 &lt;code&gt;QuickList&lt;/code&gt; 理解成：它不再把整份列表压成一整块连续内存，而是拆成多个较小的紧凑块，再用双向链表把这些块串起来。这样一来，单个块内部仍然保持较高的空间利用率，而整个列表在扩展和修改时，也不必像单一 &lt;code&gt;ZipList&lt;/code&gt; 那样频繁搬动整块数据。&lt;/p&gt;
&lt;p&gt;从历史实现上看，早期 &lt;code&gt;QuickList&lt;/code&gt; 节点内部挂载的是 &lt;code&gt;ZipList&lt;/code&gt;；而在较新的 Redis 版本里，这种紧凑块已经进一步演进成了 &lt;code&gt;listpack&lt;/code&gt;。不过从设计思路上看，它们表达的核心思想是一致的：&lt;strong&gt;用“链表组织多个紧凑存储块”，来替代“单一超大连续块”&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./quicklist-layout.svg.png&quot; alt=&quot;QuickList 结构示意图&quot; /&gt;&lt;/p&gt;
&lt;p&gt;以常见的 &lt;code&gt;quicklist&lt;/code&gt; 结构为例，可以先抓住下面这些核心字段：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;typedef struct quicklist {
    quicklistNode *head;        // 指向头节点
    quicklistNode *tail;        // 指向尾节点
    unsigned long count;        // 所有紧凑节点中元素总数
    unsigned long len;          // quicklistNode 节点数量
    int fill : QL_FILL_BITS;    // 单个节点的大小或元素数量限制
    unsigned int compress : QL_COMP_BITS; // 两端保留未压缩节点的深度
} quicklist;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;理解 &lt;code&gt;quicklist&lt;/code&gt; 时，最关键的是 &lt;code&gt;head&lt;/code&gt;、&lt;code&gt;tail&lt;/code&gt;、&lt;code&gt;count&lt;/code&gt;、&lt;code&gt;len&lt;/code&gt;、&lt;code&gt;fill&lt;/code&gt; 和 &lt;code&gt;compress&lt;/code&gt; 这几个字段。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;head&lt;/code&gt; 和 &lt;code&gt;tail&lt;/code&gt; 分别指向双向链表的头节点和尾节点。有了这两个指针，列表就可以方便地从两端进行插入、删除和遍历。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;count&lt;/code&gt; 表示整个 &lt;code&gt;QuickList&lt;/code&gt; 中一共保存了多少个元素。它统计的是所有紧凑节点内部元素数量的总和，而不是节点个数。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;len&lt;/code&gt; 表示当前 &lt;code&gt;QuickList&lt;/code&gt; 一共有多少个 &lt;code&gt;quicklistNode&lt;/code&gt;。也就是说，&lt;code&gt;count&lt;/code&gt; 看的是“元素总数”，&lt;code&gt;len&lt;/code&gt; 看的是“块的数量”。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fill&lt;/code&gt; 用来限制单个节点内部能装多少内容。它和配置项 &lt;code&gt;list-max-ziplist-size&lt;/code&gt;（在较新实现中则对应紧凑节点大小限制）有关，本质上是在控制“每个块到底装多大”，避免单个节点重新变成过大的连续内存块。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;compress&lt;/code&gt; 用来控制压缩深度。一般来说，靠近头尾的若干节点会保持未压缩，便于频繁访问；中间较远的节点则可以被压缩，以进一步节省内存。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，&lt;code&gt;QuickList&lt;/code&gt; 可以看成是 Redis 对列表结构的一次折中设计：它不像传统链表那样每个元素都单独分配节点，也不像 &lt;code&gt;ZipList&lt;/code&gt; 那样把全部元素硬塞进一块连续内存，而是把“链表的灵活性”和“紧凑存储的高密度”结合在了一起。&lt;/p&gt;
&lt;p&gt;再往下一层看，&lt;code&gt;QuickList&lt;/code&gt; 中真正串起来的，其实是一个个 &lt;code&gt;quicklistNode&lt;/code&gt;。它的数据结构可以概括成下面这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;typedef struct quicklistNode {
    struct quicklistNode *prev; // 前驱节点指针
    struct quicklistNode *next; // 后继节点指针
    unsigned char *zl;          // 指向具体的紧凑存储块
    unsigned int sz;            // 当前紧凑块占用的字节数
    unsigned int count : 16;    // 当前节点中存储的元素个数
    unsigned int encoding : 2;  // 编码方式：原始存储或压缩存储
    // ... 其他标志位
} quicklistNode;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;理解 &lt;code&gt;quicklistNode&lt;/code&gt; 时，可以把它看成“链表节点 + 紧凑数据块描述信息”的组合。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;prev&lt;/code&gt; 和 &lt;code&gt;next&lt;/code&gt; 分别指向前驱节点和后继节点，它们共同把多个 &lt;code&gt;quicklistNode&lt;/code&gt; 串成一个双向链表。这部分继承的是链表结构的灵活性。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;zl&lt;/code&gt; 指向当前节点内部挂载的紧凑存储块。在早期实现里，这里通常指向一个 &lt;code&gt;ziplist&lt;/code&gt;；而在较新的实现中，底层紧凑块已经演进成了 &lt;code&gt;listpack&lt;/code&gt;。不过从设计角度看，这个指针表达的含义是一致的：&lt;strong&gt;一个 &lt;code&gt;quicklistNode&lt;/code&gt; 并不只存一个元素，而是指向一整块紧凑数据。&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sz&lt;/code&gt; 表示当前这块紧凑存储区域一共占用了多少字节。这个字段的作用是让系统快速知道当前节点内部数据块的大小，从而辅助判断是否还适合继续往这个节点里塞数据。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;count&lt;/code&gt; 表示当前这个节点里一共存了多少个元素。注意这里统计的是“当前节点内部”的元素个数，而不是整个 &lt;code&gt;QuickList&lt;/code&gt; 的总数；整个列表的总元素数由外层 &lt;code&gt;quicklist.count&lt;/code&gt; 维护。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;encoding&lt;/code&gt; 表示当前节点内部数据是以原始形式保存，还是已经经过压缩。比如在支持 LZF 压缩的场景下，中间节点可能会被压缩，而靠近两端、需要高频访问的节点则保持原始状态。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;可以看到，&lt;code&gt;quicklistNode&lt;/code&gt; 本身并不是一个普通链表节点。普通链表节点通常只挂一个元素，而 &lt;code&gt;quicklistNode&lt;/code&gt; 挂的是“一整块元素集合”。也正因为如此，&lt;code&gt;QuickList&lt;/code&gt; 才能在结构上保持链表的可扩展性，同时在节点内部保留紧凑存储的优势。&lt;/p&gt;
&lt;h2&gt;SkipList&lt;/h2&gt;
&lt;p&gt;跳表（&lt;code&gt;SkipList&lt;/code&gt;）是 Redis 在有序集合等场景中非常重要的一种结构。它可以理解成“建立了多层索引的有序链表”：最底层保存完整数据，上层则通过更稀疏的前进指针来加速查找。这样做的结果是，查找时不必像普通有序链表那样从头一个个走，而是可以先在高层快速跳跃，再逐层下降定位目标。&lt;/p&gt;
&lt;p&gt;Redis 中跳表的外层结构通常可以概括为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;typedef struct zskiplist {
    struct zskiplistNode *header, *tail; // 指向跳跃表的表头和表尾
    unsigned long length;                // 表中节点数量
    int level;                           // 当前跳表中最高层数
} zskiplist;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;理解 &lt;code&gt;zskiplist&lt;/code&gt; 时，最关键的是 &lt;code&gt;header&lt;/code&gt;、&lt;code&gt;tail&lt;/code&gt;、&lt;code&gt;length&lt;/code&gt; 和 &lt;code&gt;level&lt;/code&gt; 这几个字段。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;header&lt;/code&gt; 指向跳表的头节点。这个头节点本身通常不承载真实业务数据，而是作为所有层级的统一起点，方便从最高层开始往下查找。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tail&lt;/code&gt; 指向跳表的尾节点。有了它之后，程序在某些场景下可以更方便地定位最后一个真实节点。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;length&lt;/code&gt; 表示当前跳表中一共有多少个元素节点。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;level&lt;/code&gt; 表示当前整张跳表里最高的层数。注意它描述的是“当前跳表实际用到了多少层”，而不是理论允许的最大层高。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果说 &lt;code&gt;zskiplist&lt;/code&gt; 管理的是整张跳表，那么真正保存数据的就是 &lt;code&gt;zskiplistNode&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;typedef struct zskiplistNode {
    sds ele;                          // 成员对象
    double score;                     // 分值
    struct zskiplistNode *backward;   // 后退指针
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 前进指针
        unsigned long span;            // 跨度
    } level[];                        // 层级数组
} zskiplistNode;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;./skiplist-layout.svg&quot; alt=&quot;SkipList 结构示意图&quot; /&gt;&lt;/p&gt;
&lt;p&gt;理解 &lt;code&gt;zskiplistNode&lt;/code&gt; 时，重点要放在 &lt;code&gt;ele&lt;/code&gt;、&lt;code&gt;score&lt;/code&gt;、&lt;code&gt;backward&lt;/code&gt; 和 &lt;code&gt;level[]&lt;/code&gt; 上。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ele&lt;/code&gt; 表示节点实际保存的成员对象。在 Redis 中，它通常是一个 SDS 字符串。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;score&lt;/code&gt; 表示当前成员对应的分值。跳表中的节点排序，主要就是按 &lt;code&gt;score&lt;/code&gt; 来进行的；当 &lt;code&gt;score&lt;/code&gt; 相同时，再结合成员内容做进一步比较。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;backward&lt;/code&gt; 是后退指针，它只保留在最底层链路上，用来支持从尾到头的反向遍历。也就是说，跳表虽然有很多层，但并不是每一层都维护双向指针，真正负责反向移动的是底层链表。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;level[]&lt;/code&gt; 是这个结构最核心的部分。它是一个柔性数组，数组中的每个元素都代表节点在某一层上的状态，而每一层里又包含两个字段：&lt;code&gt;forward&lt;/code&gt; 和 &lt;code&gt;span&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这两个字段的职责需要分开理解：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;forward&lt;/code&gt; 表示当前层上指向下一个节点的前进指针。跳表之所以能“跳”，靠的就是这些不同层级上的 &lt;code&gt;forward&lt;/code&gt; 指针。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;span&lt;/code&gt; 表示当前层这个前进指针一次跨过了多少个底层节点。它的作用并不只是辅助跳转，更重要的是支持“按排名”相关的操作。因为 Redis 的有序集合不仅要按分值查找元素，还要支持求第几名、某个排名区间有哪些元素，这时 &lt;code&gt;span&lt;/code&gt; 就非常有用。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;从结构上看，跳表的查找过程可以概括成：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先从最高层开始向前走&lt;/li&gt;
&lt;li&gt;当再往前会超过目标时，就下降一层&lt;/li&gt;
&lt;li&gt;重复这个过程，直到落到最底层并定位到目标节点附近&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;因此，跳表的本质并不是“很多层链表简单叠在一起”，而是“高层负责快速跳跃，底层负责完整有序存储”。Redis 在这个基础上再加上 &lt;code&gt;span&lt;/code&gt; 和 &lt;code&gt;backward&lt;/code&gt;，就让它不仅适合按分值查找，也适合做排名、区间遍历和反向遍历等操作。&lt;/p&gt;
</content:encoded></item><item><title>Redis 底层：基于 epoll 的 Web 服务基本流程</title><link>https://blog.huangnv.online/posts/2646/2646/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2646/2646/</guid><description>从 epoll_create、epoll_ctl、epoll_wait 到 accept、读写处理，梳理基于 epoll 的 Web 服务基础工作流程。</description><pubDate>Mon, 06 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;基于epoll模式的web服务的基本流程&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;flowchart LR
    subgraph Kernel[&quot;内核空间&quot;]
        direction LR
        R[rb_root]
        L[list_head]
    end

    subgraph User[&quot;用户空间&quot;]
        direction LR
        A[服务端]
        B[epoll_create&amp;lt;br/&amp;gt;创建实例]
        C[创建serverSocket&amp;lt;br/&amp;gt;得到FD, 记做sfd]
        D[epoll_ctl&amp;lt;br/&amp;gt;监听FD]
        E[epoll_wait&amp;lt;br/&amp;gt;等待FD就绪]
        F{是否有&amp;lt;br/&amp;gt;FD就绪}
        G{判断事件&amp;lt;br/&amp;gt;类型}
        H{是否是&amp;lt;br/&amp;gt;sfd可读}
        I[accept&amp;lt;br/&amp;gt;接收客户端socket&amp;lt;br/&amp;gt;得到对应FD]
        J[读取&amp;lt;br/&amp;gt;请求数据]
        K[写出&amp;lt;br/&amp;gt;响应]
    end

    A --&amp;gt; B --&amp;gt; C --&amp;gt; D --&amp;gt; E --&amp;gt; F
    F -- 否 --&amp;gt; E
    F -- 是 --&amp;gt; G
    G -- EPOLLIN --&amp;gt; H
    G -- EPOLLOUT / EPOLLERR --&amp;gt; K
    H -- 是 --&amp;gt; I
    H -- 否 --&amp;gt; J
    I --&amp;gt; D
    J --&amp;gt; K

    D -. 注册FD .-&amp;gt; R
    R -. 红黑树&amp;lt;br/&amp;gt;记录监听的FD .-&amp;gt; B
    R -. 注册 ep_poll_callback&amp;lt;br/&amp;gt;当FD就绪时, 将就绪FD&amp;lt;br/&amp;gt;记录到 list_head 链表 .-&amp;gt; L
    L -. 链表&amp;lt;br/&amp;gt;记录就绪FD .-&amp;gt; R
    L -. 判断 list_head 是否为空 .-&amp;gt; E

    classDef main fill:#ffffff,stroke:#8d2746,stroke-width:1.5px,color:#222;
    classDef create fill:#9d1f45,stroke:#9d1f45,stroke-width:1.5px,color:#fff;
    classDef decision fill:#9d1f45,stroke:#9d1f45,stroke-width:1.5px,color:#fff;
    classDef top fill:#ffffff,stroke:#8d2746,stroke-width:1.5px,color:#222;

    class A,C,D,E,I,J,K main;
    class B create;
    class F,G,H decision;
    class R,L top;
    style Kernel fill:#dff3fb,stroke:#dff3fb,color:#256b74
    style User fill:#f3f2df,stroke:#f3f2df,color:#6b6b2d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这张图描述的是一个简化后的 epoll 驱动 Web 服务流程。主线可以分成两部分来看：一部分是监听关系的建立，另一部分是事件到来后的派发与处理。&lt;/p&gt;
&lt;p&gt;服务端启动后，会先通过 &lt;code&gt;epoll_create&lt;/code&gt; 创建一个 epoll 实例，然后创建 &lt;code&gt;serverSocket&lt;/code&gt;，拿到监听套接字对应的 &lt;code&gt;sfd&lt;/code&gt;。接下来通过 &lt;code&gt;epoll_ctl&lt;/code&gt; 把这个 &lt;code&gt;sfd&lt;/code&gt; 注册到 epoll 里。对 Linux 的常见实现来说，监听项会被组织在内核维护的红黑树中，用来管理“当前到底监听了哪些 fd”；而真正已经就绪的对象，则会通过回调路径被挂到 &lt;code&gt;list_head&lt;/code&gt; 对应的 ready list 中。&lt;/p&gt;
&lt;p&gt;之后服务线程进入 &lt;code&gt;epoll_wait&lt;/code&gt;。如果 ready list 里还没有就绪对象，线程就继续等待；一旦有 &lt;code&gt;fd&lt;/code&gt; 就绪，&lt;code&gt;epoll_wait&lt;/code&gt; 就会返回这些 ready 事件。返回之后，用户态不会再去扫描全部监听对象，而是直接处理这批已经 ready 的 &lt;code&gt;fd&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;接下来的分支判断，决定了服务端应该如何处理这次事件。如果是监听 &lt;code&gt;sfd&lt;/code&gt; 的可读事件，通常意味着有新的客户端连接到来，这时会调用 &lt;code&gt;accept&lt;/code&gt; 拿到新的客户端 socket，并把新的连接 &lt;code&gt;fd&lt;/code&gt; 再次通过 &lt;code&gt;epoll_ctl&lt;/code&gt; 注册到 epoll 中。如果不是监听 socket 的建连事件，而是普通连接上的读事件，那么就继续读取请求数据、解析命令并准备响应；如果连接已经可写，或者需要在异常分支里做收尾处理，则进入写回或关闭逻辑。&lt;/p&gt;
&lt;p&gt;从这个角度看，epoll 模式下的 Web 服务并不是“一个线程只盯着一个连接”，而是先把监听关系长期注册好，再由内核在事件真正到来时把 ready 对象返回给用户态处理。这也是它能够在大量连接场景下比传统阻塞模型更高效的关键原因。&lt;/p&gt;
&lt;h2&gt;redis网络模型&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260406-161024.png&quot; alt=&quot;Redis 单线程网络模型示意图&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这张图比前面的“通用 epoll Web 服务流程”更进一步，它描述的是 Redis 单线程网络模型里，IO 多路复用和事件派发是如何与命令执行过程结合起来的。&lt;/p&gt;
&lt;p&gt;Redis 启动后，会先创建 &lt;code&gt;server socket&lt;/code&gt;，然后把它注册到事件循环中。事件循环本身可以理解为“IO 多路复用 + 事件派发”的组合：前者负责等待哪些 &lt;code&gt;fd&lt;/code&gt; 就绪，后者负责根据事件类型调用对应的处理器。只要监听 socket 上出现可读事件，就说明有新的客户端连接到来，这时会进入 &lt;code&gt;tcpAcceptHandler&lt;/code&gt;，调用 &lt;code&gt;accept&lt;/code&gt; 拿到新的客户端 socket。随后，Redis 会为这条新连接创建一个对应的 &lt;code&gt;client&lt;/code&gt; 结构，并把新的客户端 &lt;code&gt;fd&lt;/code&gt; 继续注册到多路复用程序中。后续这条连接上的输入缓冲、输出缓冲以及请求处理状态，都会挂在这个 &lt;code&gt;client&lt;/code&gt; 对象上。&lt;/p&gt;
&lt;p&gt;当某个客户端连接上出现可读事件时，事件循环会把它分派给 &lt;code&gt;readQueryFromClient&lt;/code&gt;。这个处理器会先把请求数据写入对应客户端结构里的 &lt;code&gt;queryBuf&lt;/code&gt;，然后继续解析 &lt;code&gt;queryBuf&lt;/code&gt; 中的内容，把它转换成 Redis 命令。命令真正执行完成后，结果不会直接立刻写回，而是先写入客户端对象的 &lt;code&gt;reply&lt;/code&gt; 缓冲区。&lt;/p&gt;
&lt;p&gt;这时 Redis 会把“当前需要尝试发送回复的客户端”挂到 &lt;code&gt;server.clients_pending_write&lt;/code&gt; 这类待写队列中。这个队列里存放的是一批不同的 &lt;code&gt;client&lt;/code&gt; 对象，每一项都对应一条具体客户端连接，而不是某一个 &lt;code&gt;client&lt;/code&gt; 的多个阶段状态。这里的“队列”也不是“攒够一定数量再统一发送”的批处理缓存，它更像是一张待处理列表，用来记录“哪些 client 现在值得先试着写一次”。&lt;/p&gt;
&lt;p&gt;接下来 Redis 并不会立刻重新阻塞进多路复用等待，而是会先进入事件循环每一轮结束前的 &lt;code&gt;beforeSleep&lt;/code&gt; 阶段。之所以有这个阶段，是因为事件循环并不是始终都有事情可做：如果当前没有新的连接、没有新的读写事件、也没有立即要执行的任务，线程就应该进入一次阻塞等待，而不是空转消耗 CPU。Redis 正是在真正再次进入 &lt;code&gt;epoll_wait&lt;/code&gt; 之前，先利用 &lt;code&gt;beforeSleep&lt;/code&gt; 做一轮额外的收尾处理，其中就包括遍历 &lt;code&gt;clients_pending_write&lt;/code&gt;，逐个 &lt;code&gt;client&lt;/code&gt; 先尝试直接把回复写到对应 socket 上。&lt;/p&gt;
&lt;p&gt;这个“尝试直接写”的阶段本身是主线程串行进行的，但它并不要求某个 &lt;code&gt;client&lt;/code&gt; 一定一次写完，也不要求整个队列必须严格按先后顺序完整清空。因为这里的“socket 可写”只表示当前还可以继续发送一部分数据，并不保证一次就能把整个 &lt;code&gt;reply&lt;/code&gt; 缓冲区全部清空。也正因为如此，Redis 才会把发送流程拆成两个阶段：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先在 &lt;code&gt;beforeSleep&lt;/code&gt; 阶段，对待写队列里的 &lt;code&gt;client&lt;/code&gt; 逐个做一次“直接写”的尝试。&lt;/li&gt;
&lt;li&gt;如果某个 &lt;code&gt;client&lt;/code&gt; 在这一步已经全部写完，那么这次发送流程就结束了；只有当某个 &lt;code&gt;client&lt;/code&gt; 的回复仍然没有写完时，Redis 才会为它注册写事件处理器，也就是 &lt;code&gt;sendReplyToClient&lt;/code&gt;，等这个 &lt;code&gt;client&lt;/code&gt; 对应的 socket 后续再次变成可写时，再继续把剩余内容写回去。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这也是 Redis 输出链路里一个很重要的细节：写事件处理器并不是每个 &lt;code&gt;client&lt;/code&gt; 默认都会挂上，它更像是一种“续写机制”或“兜底路径”。只有直接写一次还不够时，才需要依赖可写事件继续发送剩余数据。&lt;/p&gt;
&lt;p&gt;如果把这个过程再拆细一点，可以用两个客户端的例子来理解。假设某一轮 &lt;code&gt;epoll_wait&lt;/code&gt; 返回时，&lt;code&gt;client A&lt;/code&gt; 和 &lt;code&gt;client B&lt;/code&gt; 的读事件都已经 ready。虽然 Redis 主线程在任意一个具体时刻只能处理一个 &lt;code&gt;fd&lt;/code&gt;，但它可以在这一轮里按顺序先处理 &lt;code&gt;A&lt;/code&gt;，再处理 &lt;code&gt;B&lt;/code&gt;。如果 &lt;code&gt;A&lt;/code&gt; 执行完命令后产生了回复，就先把 &lt;code&gt;A&lt;/code&gt; 挂进 &lt;code&gt;clients_pending_write&lt;/code&gt;；随后 &lt;code&gt;B&lt;/code&gt; 也执行完并产生回复，那么 &lt;code&gt;B&lt;/code&gt; 也会进入这个待写列表。于是当这一轮读事件处理结束时，待写队列里完全可能已经同时有多个 &lt;code&gt;client&lt;/code&gt;，这和“主线程一次只处理一个事件”并不矛盾。&lt;/p&gt;
&lt;p&gt;真正的遍历时机也不是“每处理一个 &lt;code&gt;fd&lt;/code&gt; 就立刻遍历待写队列”，而是当前一轮事件处理结束后，在 Redis 重新进入阻塞等待之前，由 &lt;code&gt;beforeSleep&lt;/code&gt; 阶段统一遍历当前已经挂入队列的待写客户端。这样做的目的，是先顺手把这批已经准备好回复的 &lt;code&gt;client&lt;/code&gt; 尝试写一次，看看能不能直接发完，尽量减少额外注册可写事件处理器的次数。&lt;/p&gt;
&lt;p&gt;这里还要再强调一点：&lt;code&gt;socket&lt;/code&gt; 的“可写”并不等于“一次一定能把全部数据写完”。可写只表示当前发送缓冲区还有一部分可用空间，因此一次写操作很可能只推进了部分字节，而不是把整个 &lt;code&gt;reply&lt;/code&gt; 缓冲区完全清空。也正因为如此，Redis 才会区分“先直接尝试写一次”和“后续靠 &lt;code&gt;sendReplyToClient&lt;/code&gt; 继续补发”这两个阶段。&lt;/p&gt;
&lt;p&gt;从机制上看，Redis 这套网络模型之所以能在单线程下支撑大量连接，关键并不是“处理器本身都在内核空间里执行”，而是它建立在非阻塞 socket 和 IO 多路复用之上。&lt;code&gt;socket&lt;/code&gt; 是否可读、可写，是由内核根据连接当前状态判断的；当这些事件真正就绪后，&lt;code&gt;epoll&lt;/code&gt; 才会把 ready 事件返回给用户态，Redis 主线程再据此调用 &lt;code&gt;tcpAcceptHandler&lt;/code&gt;、&lt;code&gt;readQueryFromClient&lt;/code&gt;、&lt;code&gt;sendReplyToClient&lt;/code&gt; 等处理逻辑。也就是说，内核空间负责的是“等待、判断和通知”，而真正的命令读取、协议解析、命令执行和回复组织，仍然发生在用户态。&lt;/p&gt;
&lt;p&gt;这也是 Redis 网络部分能够保持非阻塞的根本原因：主线程不会在“这条连接暂时没数据”或者“这次暂时写不进去”时被卡住，而是把等待网络事件这件事交给内核，自己只在事件真正 ready 时再进入处理逻辑。这样做确实大幅减少了线程空等网络的时间，但它并不意味着所有网络相关瓶颈都被彻底消灭。更准确地说，非阻塞 IO 解决的是“线程如何等待网络事件”的问题，而真正的网络吞吐、对端接收速度、以及命令执行本身，依然可能成为系统的性能上限。&lt;/p&gt;
&lt;p&gt;从这个流程可以看出，Redis 单线程并不是“收到请求后立刻一路同步写回”，而是把网络读、命令执行、结果暂存、网络写这几个阶段拆开，再通过事件循环把它们串起来。这样做的好处是，单线程不需要为每个连接单独分配线程，却依然可以同时管理大量客户端连接，并把真正的 CPU 时间集中用在命令解析和命令执行上。&lt;/p&gt;
</content:encoded></item><item><title>Netty 前置知识：从阻塞 IO 到 IO 多路复用</title><link>https://blog.huangnv.online/posts/2643/2643/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2643/2643/</guid><description>从同步阻塞、同步非阻塞到 select 与 epoll，系统梳理 Netty 之前必须理解的 IO 模型与 IO 多路复用。</description><pubDate>Fri, 03 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;很多人刚接触 Netty 时，最容易产生的疑问就是：为什么它的非阻塞模型，通常会比传统 Web 框架下那种“一请求一线程”的处理方式更高效？&lt;/p&gt;
&lt;p&gt;如果只停留在 “Netty 很快” 这个结论上，其实并不容易真正理解它为什么快，也不容易看明白它到底解决了什么问题。要把这个问题想清楚，必须先回到几个更基础的概念上：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;同步阻塞&lt;/li&gt;
&lt;li&gt;同步非阻塞&lt;/li&gt;
&lt;li&gt;&lt;code&gt;select&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;poll&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;epoll&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些概念本质上都在回答同一个问题：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;当一个线程同时面对很多连接时，它到底应该怎么等待、怎么检查、又怎么处理这些连接上的事件？&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;而 IO 多路复用，正是理解 Netty Reactor 模型的前置知识。&lt;/p&gt;
&lt;h2&gt;什么是 IO 多路复用&lt;/h2&gt;
&lt;p&gt;IO 多路复用可以先不用想得太复杂。你可以把它理解成：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;一个线程不再死等某一个连接，而是同时关注多个连接；哪个连接真正准备好了，再去处理哪个。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;它解决的核心问题不是“让 IO 变快”，而是“减少线程在无意义等待上的浪费”，让有限的线程能管理更多连接。&lt;/p&gt;
&lt;p&gt;所以在理解 IO 多路复用之前，最好先把最传统的模型，也就是同步阻塞 IO，想明白。&lt;/p&gt;
&lt;h2&gt;同步阻塞&lt;/h2&gt;
&lt;p&gt;同步阻塞是最容易理解的一种模型。&lt;/p&gt;
&lt;p&gt;假设现在有 &lt;code&gt;A&lt;/code&gt;、&lt;code&gt;B&lt;/code&gt;、&lt;code&gt;C&lt;/code&gt; 三个请求，如果当前线程先处理 &lt;code&gt;A&lt;/code&gt;，那么在 &lt;code&gt;A&lt;/code&gt; 完成之前，&lt;code&gt;B&lt;/code&gt; 和 &lt;code&gt;C&lt;/code&gt; 都只能排队等待。&lt;/p&gt;
&lt;p&gt;这里最关键的点在于：&lt;br /&gt;
即使 &lt;code&gt;A&lt;/code&gt; 当前并没有执行真正的业务逻辑，而只是卡在网络数据读取、客户端报文到达、或者等待内核把 &lt;code&gt;socket&lt;/code&gt; 数据准备好，这个线程也依然不能去处理别的连接。&lt;/p&gt;
&lt;p&gt;也就是说，在同步阻塞模型里，线程的状态往往是这样的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;接收一个请求。&lt;/li&gt;
&lt;li&gt;读取数据。&lt;/li&gt;
&lt;li&gt;如果数据还没准备好，就继续等。&lt;/li&gt;
&lt;li&gt;数据准备好后继续处理。&lt;/li&gt;
&lt;li&gt;处理完成后才能轮到下一个请求。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;可以把同步阻塞的处理过程简单画成下面这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    A[线程接收请求 A]
    B[开始读取 A 的数据]
    C{数据是否准备好?}
    D[线程阻塞等待]
    E[不能去处理 B / C / 其他连接]
    F[继续处理请求 A]
    G[A 处理完成]
    H[才能轮到请求 B]

    A --&amp;gt; B
    B --&amp;gt; C
    C -- 否 --&amp;gt; D
    D --&amp;gt; E
    E --&amp;gt; C
    C -- 是 --&amp;gt; F
    F --&amp;gt; G
    G --&amp;gt; H
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个模型的问题并不在于“它不能工作”，而在于它的等待成本太高。&lt;/p&gt;
&lt;p&gt;如果系统里有大量连接，而这些连接大部分时间都处于“等数据”“等响应”“等网络”的状态，那么线程资源就会被大量消耗在等待上。尤其是在 WebSocket 这种场景里，很多请求本身并不重，可能只是一次心跳、一次状态同步，或者一次很轻量的消息转发，真正执行业务逻辑的时间非常短；但如果仍然使用传统阻塞模型，线程往往大部分时间都卡在等待 IO 上，结果就是“处理本身很短，等待却很长”，整体吞吐和连接承载能力都会被拖慢。&lt;/p&gt;
&lt;p&gt;这也是传统阻塞模型在高并发连接场景下容易暴露出来的问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;连接一多，大量线程都会卡在等待 IO 上&lt;/li&gt;
&lt;li&gt;单次请求真正处理的时间可能很短，但线程空等的时间却很长&lt;/li&gt;
&lt;li&gt;系统资源被大量消耗在“等待”而不是“处理”上&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当连接继续增加时，线程数量、调度成本和整体吞吐问题才会进一步放大。&lt;/p&gt;
&lt;p&gt;所以从这里开始，就会自然引出后面的问题：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;如果一个线程在等待 &lt;code&gt;A&lt;/code&gt; 请求的数据时，能不能先去看看 &lt;code&gt;B&lt;/code&gt; 或 &lt;code&gt;C&lt;/code&gt; 有没有已经准备好的数据？&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这就是后面要继续展开的同步非阻塞和 IO 多路复用的出发点。&lt;/p&gt;
&lt;h2&gt;同步非阻塞&lt;/h2&gt;
&lt;p&gt;基于上面的同步阻塞模型，一个很自然的改进思路就是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;既然线程卡在 &lt;code&gt;A&lt;/code&gt; 上等待数据时不能做别的事，那我能不能不要一直傻等，而是主动去看看其他连接有没有已经准备好的数据？&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这就是同步非阻塞的基本想法。&lt;/p&gt;
&lt;p&gt;在这种模型里，线程不会在某一个连接上一直阻塞住，而是会主动去调用非阻塞的 &lt;code&gt;read&lt;/code&gt; / &lt;code&gt;recv&lt;/code&gt;。如果某个连接的数据还没有准备好，内核不会像同步阻塞那样让线程睡在那里等待，而是立即返回一个“现在还不能读”的结果，然后线程再继续检查下一个连接。&lt;/p&gt;
&lt;p&gt;也就是说，线程的工作方式从原来的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;卡在一个连接上等数据&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;变成了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;主动尝试读取某个连接&lt;/li&gt;
&lt;li&gt;读不到就立刻返回，不阻塞当前线程&lt;/li&gt;
&lt;li&gt;线程继续检查下一个连接&lt;/li&gt;
&lt;li&gt;哪个连接真正读到了数据，再处理哪个连接&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样一来，同步阻塞模型里那种“等待期间完全不能处理其他请求”的问题，确实被缓解了。对于连接很多、单次处理又很轻的场景，比如 WebSocket 心跳、在线状态同步、简单消息转发，这种方式至少能让线程在等待 &lt;code&gt;A&lt;/code&gt; 的时候，不至于完全闲着。&lt;/p&gt;
&lt;p&gt;如果既想看“同步非阻塞的执行流程”，又想从“用户空间 / 内核空间”的角度去理解它，可以把它合在一张图里来看：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TB
    subgraph User[&quot;用户空间&quot;]
        U1[线程开始遍历多个连接]
        U2[选择某个 fd]
        U3[主动调用非阻塞 read/recv]
        U4[读到数据后处理业务]
        U5[处理完成后继续检查下一个连接]
    end

    subgraph Kernel[&quot;内核空间&quot;]
        K1[对应 socket 的接收缓冲区]
        K2{数据是否已准备好?}
        K3[未准备好&amp;lt;br/&amp;gt;立即返回 EAGAIN / WOULDBLOCK]
        K4[已准备好&amp;lt;br/&amp;gt;返回可读数据]
    end

    U1 --&amp;gt; U2
    U2 --&amp;gt; U3
    U3 --&amp;gt; K1
    K1 --&amp;gt; K2
    K2 -- 否 --&amp;gt; K3
    K3 --&amp;gt; U5
    K2 -- 是 --&amp;gt; K4
    K4 --&amp;gt; U4
    U4 --&amp;gt; U5
    U5 --&amp;gt; U2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不过，同步非阻塞虽然解决了“一个连接把线程彻底卡死”的问题，但它很快又会暴露出新的成本：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;线程需要反复遍历大量连接&lt;/li&gt;
&lt;li&gt;即使大部分连接根本没有数据，也还是要不断发起非阻塞读调用&lt;/li&gt;
&lt;li&gt;每次调用都可能触发一次用户态和内核态之间的系统调用交互&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以当连接数量继续增加时，这种“不断遍历、不断询问”的方式本身就会变成一个很大的开销。&lt;/p&gt;
&lt;p&gt;换句话说，同步非阻塞并不是没有等待了，而是把“阻塞等待一个连接”变成了“反复对很多连接做非阻塞检查”。&lt;br /&gt;
前者的问题是线程被卡住，后者的问题则是线程虽然没被卡住，但会浪费大量时间做无效检查。&lt;/p&gt;
&lt;p&gt;这也正是后面 &lt;code&gt;select&lt;/code&gt;、&lt;code&gt;poll&lt;/code&gt;、&lt;code&gt;epoll&lt;/code&gt; 要继续解决的问题：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;能不能不要让我自己一遍遍地主动去问，而是由内核更高效地告诉我，哪些连接真的准备好了？&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;&lt;code&gt;select&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;在同步非阻塞模型里，问题的关键已经不再是“线程会不会被一个连接卡死”，而是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;我为了知道哪些连接有数据，不得不反复对每个连接发起检查，这种检查本身太浪费了。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;code&gt;select&lt;/code&gt; 的出现，就是为了改善这个问题。&lt;/p&gt;
&lt;p&gt;它的核心思路是：&lt;br /&gt;
不要再让用户线程自己一个一个去检查所有连接，而是把“检查哪些 &lt;code&gt;fd&lt;/code&gt; 已经准备好”这件事交给内核去做。&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;select&lt;/code&gt; 的基本工作方式&lt;/h3&gt;
&lt;p&gt;使用 &lt;code&gt;select&lt;/code&gt; 时，应用会先把自己关心的 &lt;code&gt;fd&lt;/code&gt; 放进一个集合里，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;哪些 &lt;code&gt;fd&lt;/code&gt; 需要检查可读&lt;/li&gt;
&lt;li&gt;哪些 &lt;code&gt;fd&lt;/code&gt; 需要检查可写&lt;/li&gt;
&lt;li&gt;哪些 &lt;code&gt;fd&lt;/code&gt; 需要检查异常状态&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;然后把这些集合传给 &lt;code&gt;select&lt;/code&gt;。接下来发生的事情大致是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;用户态把 &lt;code&gt;fd&lt;/code&gt; 集合拷贝到内核态。&lt;/li&gt;
&lt;li&gt;内核遍历这批 &lt;code&gt;fd&lt;/code&gt;，检查哪些已经就绪。&lt;/li&gt;
&lt;li&gt;如果当前没有任何 &lt;code&gt;fd&lt;/code&gt; 就绪：
&lt;ul&gt;
&lt;li&gt;如果是阻塞方式，&lt;code&gt;select&lt;/code&gt; 就会睡眠等待，直到有事件到来、超时或者被信号打断。&lt;/li&gt;
&lt;li&gt;如果是非阻塞方式，&lt;code&gt;select&lt;/code&gt; 会立即返回。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;一旦有 &lt;code&gt;fd&lt;/code&gt; 就绪，内核会把结果写回到返回集合里。&lt;/li&gt;
&lt;li&gt;用户态拿到结果后，再遍历一遍返回集合，找出到底是哪些 &lt;code&gt;fd&lt;/code&gt; 可以处理。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;也就是说，&lt;code&gt;select&lt;/code&gt; 并不是直接把“哪一个连接可读”以一个现成列表塞给你，而是返回一个“哪些位被置位”的结果集合，你仍然需要自己再扫描一遍。&lt;/p&gt;
&lt;p&gt;这里还有一个很容易误解的点：&lt;br /&gt;
&lt;code&gt;select&lt;/code&gt; 在阻塞等待时，确实可以在事件到来后被内核唤醒；但它被唤醒之后，并不是直接拿到一个现成的“就绪 fd 列表”，而是仍然要重新遍历这整批被监听的 &lt;code&gt;fd&lt;/code&gt;，再判断到底哪些真的已经就绪，然后再把结果写回返回集合。&lt;/p&gt;
&lt;h3&gt;可以怎样理解它&lt;/h3&gt;
&lt;p&gt;如果说同步非阻塞是在做：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;应用线程自己挨个问：&lt;code&gt;fd1&lt;/code&gt; 好了吗？&lt;code&gt;fd2&lt;/code&gt; 好了吗？&lt;code&gt;fd3&lt;/code&gt; 好了吗？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;那么 &lt;code&gt;select&lt;/code&gt; 更像是在做：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;应用线程先把一批 &lt;code&gt;fd&lt;/code&gt; 交给内核&lt;/li&gt;
&lt;li&gt;然后问一句：这批里面谁准备好了？&lt;/li&gt;
&lt;li&gt;内核检查完后，把结果返回给你&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样做的好处是，至少不用由用户线程反复对每个连接单独发起检查了，查找可操作对象的职责被转移到了内核态。&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;select&lt;/code&gt; 的处理流程&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;flowchart TB
    subgraph User[&quot;用户空间&quot;]
        U1[准备 fd 集合]
        U2[调用 select]
        U3[拿到返回结果集合]
        U4[遍历返回集合]
        U5[找到就绪 fd 并处理]
    end

    subgraph Kernel[&quot;内核空间&quot;]
        K1[接收 fd 集合]
        K2[遍历所有 fd]
        K3{是否有 fd 就绪?}
        K4[阻塞等待事件 / 超时 / 信号]
        K5[把就绪结果写回集合]
    end

    U1 --&amp;gt; U2
    U2 --&amp;gt; K1
    K1 --&amp;gt; K2
    K2 --&amp;gt; K3
    K3 -- 否 --&amp;gt; K4
    K4 --&amp;gt; K2
    K3 -- 是 --&amp;gt; K5
    K5 --&amp;gt; U3
    U3 --&amp;gt; U4
    U4 --&amp;gt; U5
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;code&gt;select&lt;/code&gt; 的优点&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;避免了应用线程自己对每个连接逐个发起非阻塞检查&lt;/li&gt;
&lt;li&gt;把“哪些连接已经就绪”的判断交给内核统一处理&lt;/li&gt;
&lt;li&gt;比最原始的同步非阻塞轮询更高效，也更像真正的事件等待模型&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;&lt;code&gt;select&lt;/code&gt; 的问题&lt;/h3&gt;
&lt;p&gt;不过，&lt;code&gt;select&lt;/code&gt; 并没有彻底解决问题，它只是把问题往前推进了一步。&lt;/p&gt;
&lt;p&gt;它的主要缺点有几个：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每次调用 &lt;code&gt;select&lt;/code&gt;，都要把 &lt;code&gt;fd&lt;/code&gt; 集合从用户态拷贝到内核态&lt;/li&gt;
&lt;li&gt;内核每次仍然需要遍历整批 &lt;code&gt;fd&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;即使已经有事件把 &lt;code&gt;select&lt;/code&gt; 唤醒了，醒来之后仍然要再扫描整批 &lt;code&gt;fd&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;返回之后，用户态还要再遍历一遍结果集合，才能找到具体是哪几个 &lt;code&gt;fd&lt;/code&gt; 就绪&lt;/li&gt;
&lt;li&gt;当连接数量很多时，这种“内核遍历一次，用户再遍历一次”的模式依旧会很耗时&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;还有一个很经典的限制：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在常见 Linux 环境里，&lt;code&gt;select&lt;/code&gt; 受 &lt;code&gt;fd_set&lt;/code&gt; 大小限制，默认通常只能处理 &lt;code&gt;1024&lt;/code&gt; 个 &lt;code&gt;fd&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以 &lt;code&gt;select&lt;/code&gt; 的意义并不是“已经足够完美”，而是它第一次把“等待事件”这件事从用户层的无脑轮询，推进到了“由内核统一帮你检查”这一步。&lt;/p&gt;
&lt;p&gt;也正因为它仍然存在集合拷贝、线性遍历和 &lt;code&gt;fd&lt;/code&gt; 数量限制，后面才会继续出现 &lt;code&gt;poll&lt;/code&gt; 和 &lt;code&gt;epoll&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;epoll&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;如果说 &lt;code&gt;select&lt;/code&gt; 已经把“检查哪些连接就绪”这件事交给了内核，那么 &lt;code&gt;epoll&lt;/code&gt; 要解决的就是 &lt;code&gt;select&lt;/code&gt; 还没解决完的那部分问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不想每次都把整批 &lt;code&gt;fd&lt;/code&gt; 集合从用户态拷贝到内核态&lt;/li&gt;
&lt;li&gt;不想每次被唤醒后还要重新扫描整批 &lt;code&gt;fd&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;希望内核能更直接地把“真正已经就绪的连接”交给用户态&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;epoll&lt;/code&gt; 正是在这样的背景下出现的。&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;epoll&lt;/code&gt; 的三个核心调用&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;epoll&lt;/code&gt; 最常见的三个接口是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;epoll_create&lt;/code&gt; / &lt;code&gt;epoll_create1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;epoll_ctl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;epoll_wait&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;它们各自负责的事情可以简单理解成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;epoll_create&lt;/code&gt; / &lt;code&gt;epoll_create1&lt;/code&gt;
创建一个 &lt;code&gt;epoll&lt;/code&gt; 实例，也可以理解为创建一个专门用来做事件管理的“内核对象”&lt;/li&gt;
&lt;li&gt;&lt;code&gt;epoll_ctl&lt;/code&gt;
把某个 &lt;code&gt;fd&lt;/code&gt; 注册到这个 &lt;code&gt;epoll&lt;/code&gt; 实例上，或者修改、删除它关心的事件&lt;/li&gt;
&lt;li&gt;&lt;code&gt;epoll_wait&lt;/code&gt;
等待事件发生，并直接拿到“已经就绪的事件列表”&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;&lt;code&gt;epoll&lt;/code&gt; 的核心思路&lt;/h3&gt;
&lt;p&gt;和 &lt;code&gt;select&lt;/code&gt; 最大的区别在于，&lt;code&gt;epoll&lt;/code&gt; 不是每次调用时临时把整批 &lt;code&gt;fd&lt;/code&gt; 传进去，而是先把“我长期关心哪些 &lt;code&gt;fd&lt;/code&gt;、关心哪些事件”注册到内核里。&lt;/p&gt;
&lt;p&gt;从概念上看，可以把它理解成内核里维护了两类东西：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一棵用于管理监听对象的结构
常见实现会用红黑树来组织已经注册的 &lt;code&gt;fd&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;一个就绪队列
哪些监听项真正发生了事件，就把对应对象加入这个队列，同时保留原有的监听关系&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，&lt;code&gt;epoll&lt;/code&gt; 更像是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;平时先把监听关系维护好&lt;/li&gt;
&lt;li&gt;真有事件发生时，把对应的就绪对象挂到 ready list&lt;/li&gt;
&lt;li&gt;&lt;code&gt;epoll_wait&lt;/code&gt; 醒来后，直接把 ready list 里的结果交给用户态&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这也是它和 &lt;code&gt;select&lt;/code&gt; 的本质差异。&lt;/p&gt;
&lt;h3&gt;为什么这里会有红黑树和回调关系&lt;/h3&gt;
&lt;p&gt;这里有一个很容易混淆的点：&lt;br /&gt;
&lt;code&gt;epoll&lt;/code&gt; 高效，并不是因为“事件来了以后，再去红黑树里精准查找 ready 的对象”。&lt;/p&gt;
&lt;p&gt;更准确的分工应该是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;红黑树这类结构，主要负责维护“当前到底监听了哪些 &lt;code&gt;fd&lt;/code&gt;”&lt;/li&gt;
&lt;li&gt;ready list 负责保存“这一次真正已经就绪的对象”&lt;/li&gt;
&lt;li&gt;回调关系 / 等待队列，则负责把“某个 &lt;code&gt;fd&lt;/code&gt; 发生事件”这件事通知给 &lt;code&gt;epoll&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，红黑树更像是在管理一份长期存在的监听集合，而不是在事件发生后临时拿来做全量扫描。&lt;/p&gt;
&lt;p&gt;那为什么不直接用一个普通线性表来保存监听对象？&lt;br /&gt;
因为这些监听项并不是注册完就再也不动了，后面还会不断发生：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;epoll_ctl ADD&lt;/code&gt; 新增监听&lt;/li&gt;
&lt;li&gt;&lt;code&gt;epoll_ctl MOD&lt;/code&gt; 修改关注事件&lt;/li&gt;
&lt;li&gt;&lt;code&gt;epoll_ctl DEL&lt;/code&gt; 删除监听&lt;/li&gt;
&lt;li&gt;判断某个 &lt;code&gt;fd&lt;/code&gt; 是否已经注册&lt;/li&gt;
&lt;li&gt;根据 &lt;code&gt;fd&lt;/code&gt; 找到对应监听项并更新它的状态&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果只是普通线性表，这些增删改查在监听对象很多时都会越来越低效。&lt;br /&gt;
所以从实现角度看，内核更需要一套适合维护监听集合的数据结构，常见实现就会用红黑树来组织这些对象。&lt;/p&gt;
&lt;p&gt;这里还要再强调一句：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;同一个 &lt;code&gt;epoll`` 实例里的 &lt;/code&gt;fd`，只是“一起被管理”，并不代表“它们会一起触发”。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;每个 &lt;code&gt;fd&lt;/code&gt; 是否可读、可写，取决于它自己对应的 socket 或文件对象状态。&lt;br /&gt;
当某个 &lt;code&gt;fd&lt;/code&gt; 真正发生事件时，内核会通过已经建立好的等待关系或回调路径，把这个就绪对象挂到 ready list 中。&lt;br /&gt;
所以 &lt;code&gt;epoll_wait&lt;/code&gt; 返回时，用户态拿到的是 ready list 里的结果，而不是再去把整棵树重扫一遍。&lt;/p&gt;
&lt;p&gt;再进一步说，也不是“整个系统只有一棵红黑树”。&lt;br /&gt;
更适合写进博客里的说法是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;一个 &lt;code&gt;epoll&lt;/code&gt; 实例通常可以看成一个独立的事件管理器，它会维护自己的监听对象结构和自己的 ready list。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;事件触发时到底发生了什么&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;epoll&lt;/code&gt; 在事件到来时的高效，并不是因为它重新去扫描整棵红黑树，也不是因为它像数组下标那样做一次简单索引。&lt;br /&gt;
真正的关键在于：在 &lt;code&gt;fd&lt;/code&gt; 注册到 &lt;code&gt;epoll&lt;/code&gt; 的那一刻，内核就已经把这个 &lt;code&gt;fd&lt;/code&gt;、它关注的事件，以及对应的等待关系建立好了。&lt;/p&gt;
&lt;p&gt;因此，当某个 socket 真正发生状态变化时，整体流程更接近下面这样：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;某个 &lt;code&gt;fd&lt;/code&gt; 对应的 socket 状态发生变化，例如接收缓冲区里有数据到达，变成可读&lt;/li&gt;
&lt;li&gt;内核沿着这个对象已经建立好的回调关系或等待队列，定位到和它关联的 &lt;code&gt;epoll&lt;/code&gt; 监听项&lt;/li&gt;
&lt;li&gt;对应的监听项被标记为 ready，并挂入 &lt;code&gt;epoll&lt;/code&gt; 的 ready list，同时仍然保留在监听集合中&lt;/li&gt;
&lt;li&gt;如果此时有线程正在 &lt;code&gt;epoll_wait&lt;/code&gt; 上阻塞等待，就会被唤醒&lt;/li&gt;
&lt;li&gt;&lt;code&gt;epoll_wait&lt;/code&gt; 返回时，用户态拿到的就是 ready list 中已经就绪的事件集合&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这里最重要的一点是：&lt;br /&gt;
事件发生后，内核并不会重新把整批监听对象扫描一遍，而是沿着预先建立好的关联路径，把真正发生事件的对象直接加入 ready list。&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;epoll&lt;/code&gt; 的处理流程&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;flowchart TB
    subgraph User[&quot;用户空间&quot;]
        U1[epoll_create / epoll_create1&amp;lt;br/&amp;gt;创建 epoll 实例]
        U2[epoll_ctl&amp;lt;br/&amp;gt;注册 fd 和关注事件]
        U3[epoll_wait&amp;lt;br/&amp;gt;阻塞等待]
        U4[直接拿到就绪事件列表]
        U5[处理就绪 fd]
    end

    subgraph Kernel[&quot;内核空间&quot;]
        K1[红黑树&amp;lt;br/&amp;gt;维护监听的 fd]
        K2[等待队列 / 回调关系]
        K3[某个 fd 发生就绪事件]
        K4[把就绪 fd 放入 ready list]
        K5[唤醒 epoll_wait]
    end

    U1 --&amp;gt; U2
    U2 --&amp;gt; K1
    K1 --&amp;gt; K2
    U3 --&amp;gt; K2
    K2 --&amp;gt; K3
    K3 --&amp;gt; K4
    K4 --&amp;gt; K5
    K5 --&amp;gt; U4
    U4 --&amp;gt; U5
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;这和 &lt;code&gt;select&lt;/code&gt; 到底差在哪&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;select&lt;/code&gt; 的模式更接近：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每次都带着整批 &lt;code&gt;fd&lt;/code&gt; 去问内核&lt;/li&gt;
&lt;li&gt;内核扫描整批 &lt;code&gt;fd&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;醒来后用户再扫描一遍结果&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而 &lt;code&gt;epoll&lt;/code&gt; 的模式更接近：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先把监听关系注册到内核&lt;/li&gt;
&lt;li&gt;真有事件发生时，内核把对应 &lt;code&gt;fd&lt;/code&gt; 放进 ready list&lt;/li&gt;
&lt;li&gt;&lt;code&gt;epoll_wait&lt;/code&gt; 返回时，用户态拿到的已经是“就绪事件集合”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以对用户态来说，&lt;code&gt;epoll_wait&lt;/code&gt; 返回后不需要再像 &lt;code&gt;select&lt;/code&gt; 那样去扫描整批监听集合，而是只处理真正 ready 的那些对象。&lt;/p&gt;
&lt;h3&gt;复杂度应该怎么理解&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;epoll&lt;/code&gt; 的高效，核心就在于避免了对整批监听对象的重复扫描，并且能够把真正 ready 的对象直接返回给用户态。&lt;/p&gt;
&lt;p&gt;从复杂度角度看：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;epoll_ctl&lt;/code&gt; 的添加、删除、修改，通常可以理解为和红黑树操作相关，复杂度接近 &lt;code&gt;O(logN)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;epoll_wait&lt;/code&gt; 返回结果时，更接近于按“就绪事件数量”工作，也就是常说的 &lt;code&gt;O(ready)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;它并不是严格意义上的“用户态复杂度就是 &lt;code&gt;O(1)&lt;/code&gt;”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以 &lt;code&gt;epoll&lt;/code&gt; 最值得强调的不是某一个绝对复杂度数字，而是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;它避免了像 &lt;code&gt;select&lt;/code&gt; 那样每次都对整批监听对象做线性扫描，而是把工作重点收敛到了真正发生事件的那些连接上。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;&lt;code&gt;epoll&lt;/code&gt; 的优点&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;不需要像 &lt;code&gt;select&lt;/code&gt; 那样每次都重复传整批 &lt;code&gt;fd&lt;/code&gt; 集合&lt;/li&gt;
&lt;li&gt;不需要在返回后再由用户态扫描整批监听集合&lt;/li&gt;
&lt;li&gt;更适合大量连接、长连接、高并发事件通知场景&lt;/li&gt;
&lt;li&gt;在网络服务器场景下，尤其适合 WebSocket、IM、网关、RPC 这类连接很多的系统&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;&lt;code&gt;epoll&lt;/code&gt; 的边界和注意点&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;epoll&lt;/code&gt; 也不是“任何场景都无脑更好”。&lt;/p&gt;
&lt;p&gt;更合适的理解是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;它的机制更复杂&lt;/li&gt;
&lt;li&gt;它在 Linux 下非常适合高并发连接场景&lt;/li&gt;
&lt;li&gt;当连接数量多、活跃连接比例相对较低时，它的优势会特别明显&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但如果只是连接很少、模型很简单，那它带来的收益可能不会像高并发场景那么显著。&lt;br /&gt;
所以不应该简单理解成“连接少时 epoll 一定不如 select”，而应该理解成：它的优势主要体现在大规模事件驱动场景里。&lt;/p&gt;
&lt;h3&gt;小结&lt;/h3&gt;
&lt;p&gt;一句话概括 &lt;code&gt;epoll&lt;/code&gt;：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;select&lt;/code&gt; 是“每次都把整批 fd 拿去检查一遍”，而 &lt;code&gt;epoll&lt;/code&gt; 更像是“先把监听关系注册好，真正发生事件时，只把 ready 的对象返回给你”。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Netty 在 Linux 下会优先利用 &lt;code&gt;epoll&lt;/code&gt; 能力，本质上也是因为它和高并发、事件驱动、长连接管理这几个目标天然更匹配。&lt;/p&gt;
</content:encoded></item><item><title>一次 WebSocket 握手与心跳压测的联合分析记录</title><link>https://blog.huangnv.online/posts/2641/2641/</link><guid isPermaLink="true">https://blog.huangnv.online/posts/2641/2641/</guid><description>基于 JMeter 客户端结果与 Micrometer 服务端埋点，对一次 WebSocket 握手与心跳压测进行联合分析，并总结公网压测下的噪声问题。</description><pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;最近我针对自己项目里的 WebSocket 接口做了一次偏“建连与保活”方向的压测。原始目标很简单：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先验证 WebSocket 握手阶段是否稳定&lt;/li&gt;
&lt;li&gt;再观察连接建立之后，心跳链路能不能持续工作&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;为了让结果不只停留在客户端视角，我同时在服务端加了 Micrometer 埋点，把握手次数、成功数、连接数、心跳收发情况都记录下来。这样一轮压测结束后，就可以把 JMeter 的客户端结果和服务端指标放在一起对照分析。&lt;/p&gt;
&lt;p&gt;这篇文章就是对这次压测过程和结果的一次整理。&lt;/p&gt;
&lt;h2&gt;压测目标&lt;/h2&gt;
&lt;p&gt;这次压测并不是追求极限吞吐，而是想先回答两个更基础的问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;当前 WebSocket 握手链路是否稳定。&lt;/li&gt;
&lt;li&gt;建连成功后，心跳链路是否能可靠维持。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果这两个基础能力都不稳定，那么后续无论继续优化 Spring WebSocket，还是切换到 Netty，都会缺少可靠的基线。&lt;/p&gt;
&lt;h2&gt;本轮压测配置&lt;/h2&gt;
&lt;p&gt;这次使用的是 JMeter 的 WebSocket 插件，通过 GUI 方式配置线程组、控制器和采样器。&lt;/p&gt;
&lt;p&gt;核心配置如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;线程数：&lt;code&gt;500&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Ramp-Up：&lt;code&gt;60s&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;线程组外层循环：&lt;code&gt;1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;单连接内部心跳循环：&lt;code&gt;10&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;心跳间隔：&lt;code&gt;15000ms&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;WebSocket 读超时：&lt;code&gt;6000ms&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;目标地址：&lt;code&gt;ws://150.158.146.80:8080/ws/im?token=...&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;从测试行为上看，这轮压测大致等价于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;500&lt;/code&gt; 个线程在 &lt;code&gt;60&lt;/code&gt; 秒内逐步建立连接&lt;/li&gt;
&lt;li&gt;每个连接最多发送 &lt;code&gt;10&lt;/code&gt; 次心跳&lt;/li&gt;
&lt;li&gt;最后再尝试主动关闭连接&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;下面这张图就是当时的 JMeter 脚本结构截图：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./QQ20260401-235151.png&quot; alt=&quot;JMeter 脚本结构截图&quot; /&gt;&lt;/p&gt;
&lt;p&gt;从结构上看，这次脚本比较聚焦，只覆盖了三类操作：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;WebSocket Open Connection&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;WebSocket request-response Sampler&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;WebSocket Close&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，这轮结果主要反映的是“握手、心跳、关闭”这三个阶段的表现。&lt;/p&gt;
&lt;h2&gt;服务端埋点设计&lt;/h2&gt;
&lt;p&gt;为了避免只看客户端报错，我在服务端也补了一组 Micrometer 指标，用来观察真正到达后端的事件。&lt;/p&gt;
&lt;p&gt;这次重点统计了下面这些指标：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;握手尝试次数&lt;/li&gt;
&lt;li&gt;握手成功次数&lt;/li&gt;
&lt;li&gt;握手耗时&lt;/li&gt;
&lt;li&gt;连接建立次数&lt;/li&gt;
&lt;li&gt;心跳接收次数&lt;/li&gt;
&lt;li&gt;心跳 ACK 发送成功/失败次数&lt;/li&gt;
&lt;li&gt;非法消息次数&lt;/li&gt;
&lt;li&gt;当前活跃会话数&lt;/li&gt;
&lt;li&gt;当前在线用户数&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当时对应的埋点代码大致如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;this.handshakeAttempts = Counter.builder(&quot;im.ws.handshake.attempts&quot;)
        .description(&quot;Total websocket handshake attempts&quot;)
        .register(meterRegistry);
this.handshakeSuccesses = Counter.builder(&quot;im.ws.handshake.success&quot;)
        .description(&quot;Successful websocket handshakes&quot;)
        .register(meterRegistry);
this.handshakeSuccessTimer = Timer.builder(&quot;im.ws.handshake.duration&quot;)
        .description(&quot;Websocket handshake duration&quot;)
        .tag(&quot;outcome&quot;, &quot;success&quot;)
        .register(meterRegistry);
this.handshakeFailureTimer = Timer.builder(&quot;im.ws.handshake.duration&quot;)
        .description(&quot;Websocket handshake duration&quot;)
        .tag(&quot;outcome&quot;, &quot;failure&quot;)
        .register(meterRegistry);

this.connectionOpenedCounter = Counter.builder(&quot;im.ws.connection.opened&quot;)
        .description(&quot;Opened websocket connections&quot;)
        .register(meterRegistry);
this.heartbeatReceivedCounter = Counter.builder(&quot;im.ws.heartbeat.received&quot;)
        .description(&quot;Received websocket heartbeats&quot;)
        .register(meterRegistry);
this.heartbeatAckSentCounter = Counter.builder(&quot;im.ws.heartbeat.ack.sent&quot;)
        .description(&quot;Sent websocket heartbeat acknowledgements&quot;)
        .register(meterRegistry);
this.heartbeatAckFailedCounter = Counter.builder(&quot;im.ws.heartbeat.ack.failed&quot;)
        .description(&quot;Failed websocket heartbeat acknowledgements&quot;)
        .register(meterRegistry);

Gauge.builder(&quot;im.ws.sessions.active&quot;, sessionRegistry, ImWebSocketSessionRegistry::countOpenSessions)
        .description(&quot;Current active websocket sessions&quot;)
        .register(meterRegistry);
Gauge.builder(&quot;im.ws.users.online&quot;, sessionRegistry, ImWebSocketSessionRegistry::countOnlineUsers)
        .description(&quot;Current online websocket users&quot;)
        .register(meterRegistry);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样做的好处很明显：客户端看到的是“采样器是否成功”，服务端看到的是“请求是否真的到达并处理完成”。两边合在一起，才能把问题位置缩小。&lt;/p&gt;
&lt;h2&gt;JMeter 客户端结果&lt;/h2&gt;
&lt;h3&gt;核心统计&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;采样器&lt;/th&gt;
&lt;th&gt;总次数&lt;/th&gt;
&lt;th&gt;成功&lt;/th&gt;
&lt;th&gt;失败&lt;/th&gt;
&lt;th&gt;错误率&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WebSocket Open Connection&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;492&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebSocket request-response Sampler&lt;/td&gt;
&lt;td&gt;5000&lt;/td&gt;
&lt;td&gt;135&lt;/td&gt;
&lt;td&gt;4865&lt;/td&gt;
&lt;td&gt;97.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebSocket Close&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total&lt;/td&gt;
&lt;td&gt;6000&lt;/td&gt;
&lt;td&gt;627&lt;/td&gt;
&lt;td&gt;5373&lt;/td&gt;
&lt;td&gt;89.55%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;主要错误类型&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;错误类型&lt;/th&gt;
&lt;th&gt;次数&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WebSocket I/O error: Connection reset by peer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4692&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WebSocket I/O error: Read timed out&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;478&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WebSocket I/O error: Connection reset&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;107&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Sampler configured for using existing connection, but there is no connection&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;No connection; nothing to close.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WebSocket I/O error: Connect timed out&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;客户端视角下的第一结论&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;握手阶段整体可用，错误率只有 &lt;code&gt;1.6%&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;心跳阶段几乎全部失败，错误率达到了 &lt;code&gt;97.3%&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;关闭阶段全部失败，但这个结果更像前面连接已经失效后的连带现象&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;只看 JMeter 的话，很容易得出“后端 WebSocket 稳定性很差”的判断，但这还不够，因为客户端报错并不一定等于后端业务逻辑出错。&lt;/p&gt;
&lt;h2&gt;服务端埋点结果&lt;/h2&gt;
&lt;h3&gt;服务端指标快照&lt;/h3&gt;
&lt;p&gt;会话与心跳：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;im.ws.sessions.active = 1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;im.ws.users.online = 1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;im.ws.heartbeat.received = 143&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;im.ws.heartbeat.ack.sent = 143&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;im.ws.heartbeat.ack.failed = 0&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;连接与握手：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;im.ws.handshake.attempts = 494&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;im.ws.handshake.success = 494&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;im.ws.connection.opened = 494&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;im.ws.connection.closed = 493&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;关闭原因：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;reason=code_1006&lt;/code&gt;：&lt;code&gt;334&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;reason=code_4500&lt;/code&gt;：&lt;code&gt;158&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;reason=code_1000&lt;/code&gt;：&lt;code&gt;1&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;服务端视角下的第一结论&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;握手阶段基本没有暴露出明显问题&lt;/li&gt;
&lt;li&gt;只要心跳包真正到达后端，后端都成功返回了 ACK&lt;/li&gt;
&lt;li&gt;大部分连接是在会话期内异常关闭，或者因为心跳不可靠而被清理&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这说明“后端有没有收到消息”和“客户端最终有没有采样成功”并不是同一件事。&lt;/p&gt;
&lt;h2&gt;联合分析&lt;/h2&gt;
&lt;h3&gt;为什么客户端和服务端数据会不一致&lt;/h3&gt;
&lt;p&gt;两边记录的不是同一个观察点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;JMeter 记录的是客户端采样器结果&lt;/li&gt;
&lt;li&gt;服务端记录的是真正到达应用侧之后的事件&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;客户端报错，不一定代表服务端处理逻辑出错&lt;/li&gt;
&lt;li&gt;只有真正到达后端的握手和心跳，才会出现在服务端指标里&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果把一条握手请求拆开来看，它中间其实还隔着完整的一条链路：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;JMeter -&amp;gt; 本地网络 -&amp;gt; 公网链路 -&amp;gt; Nginx / 网关 -&amp;gt; 应用服务&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;这就意味着，下面几种情况都会导致数字不一致：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;JMeter 发起了请求，但请求根本没到服务端&lt;/li&gt;
&lt;li&gt;请求到了接入层，但还没真正进入应用&lt;/li&gt;
&lt;li&gt;服务端已经判定握手成功，但客户端因为超时、连接抖动或插件行为，没有把它记成成功&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以客户端统计的是“我这边最终感知到的结果”，而服务端统计的是“真正进入应用并被处理的结果”。这两个数字接近时，通常说明大方向没问题；但它们不可能保证完全一致。&lt;/p&gt;
&lt;h3&gt;握手阶段基本正常&lt;/h3&gt;
&lt;p&gt;JMeter 结果：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;建连 &lt;code&gt;500&lt;/code&gt; 次&lt;/li&gt;
&lt;li&gt;成功 &lt;code&gt;492&lt;/code&gt; 次&lt;/li&gt;
&lt;li&gt;失败 &lt;code&gt;8&lt;/code&gt; 次&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;服务端结果：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;握手尝试 &lt;code&gt;494&lt;/code&gt; 次&lt;/li&gt;
&lt;li&gt;成功 &lt;code&gt;494&lt;/code&gt; 次&lt;/li&gt;
&lt;li&gt;连接建立 &lt;code&gt;494&lt;/code&gt; 次&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;把这两组数字放在一起看，其实能得到一个比较具体的解释：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;JMeter 一共发起了 &lt;code&gt;500&lt;/code&gt; 次建连&lt;/li&gt;
&lt;li&gt;服务端只看到了 &lt;code&gt;494&lt;/code&gt; 次握手尝试&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这说明至少有 &lt;code&gt;6&lt;/code&gt; 次请求没有真正进入应用服务，它们更可能丢在客户端本地、网络链路或者接入层。&lt;/p&gt;
&lt;p&gt;与此同时：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;JMeter 认为成功的是 &lt;code&gt;492&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;服务端记录成功的是 &lt;code&gt;494&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这说明还有一小部分请求虽然已经被服务端成功处理，但客户端并没有把它们记成成功。这种情况通常和客户端插件的判定口径、超时设置、连接建立后立即断开等因素有关。&lt;/p&gt;
&lt;p&gt;综合来看，握手阶段整体仍然是正常的。这里的差异更像发生在网络、接入层或者压测端本身，而不是应用里的握手逻辑。&lt;/p&gt;
&lt;h3&gt;真正的问题出在心跳维持阶段&lt;/h3&gt;
&lt;p&gt;JMeter 结果：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;心跳采样总数 &lt;code&gt;5000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;成功 &lt;code&gt;135&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;失败 &lt;code&gt;4865&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;服务端结果：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;heartbeat.received = 143&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;heartbeat.ack.sent = 143&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;heartbeat.ack.failed = 0&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个对比非常关键，它说明了一件事：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;后端 heartbeat 处理逻辑本身没有明显错误。只要心跳真正进入后端，后端就能稳定回 ACK。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;所以问题不在“后端收到心跳之后处理失败”，而在“很多连接根本没能稳定地把心跳送到后端”，或者在发送前连接就已经失效了。&lt;/p&gt;
&lt;p&gt;这一点和 JMeter 的错误类型也吻合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Connection reset by peer&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Read timed out&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;there is no connection to re-use&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Close 全部失败并不是根因&lt;/h3&gt;
&lt;p&gt;JMeter 里 &lt;code&gt;WebSocket Close&lt;/code&gt; 的失败率是 &lt;code&gt;100%&lt;/code&gt;，但这并不代表服务端没有关闭连接。&lt;/p&gt;
&lt;p&gt;服务端实际上记录到了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;connection.closed = 493&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这意味着更可能的情况是：当客户端执行到 &lt;code&gt;Close&lt;/code&gt; 采样器时，连接早就已经断了，或者已经被服务端清理掉了。换句话说，&lt;code&gt;Close&lt;/code&gt; 全部失败更像一个结果，而不是问题源头。&lt;/p&gt;
&lt;h3&gt;关闭原因也支持这个结论&lt;/h3&gt;
&lt;p&gt;从关闭原因看：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;code_1006 = 334&lt;/code&gt;
说明大量连接属于异常关闭&lt;/li&gt;
&lt;li&gt;&lt;code&gt;code_4500 = 158&lt;/code&gt;
结合代码实现，极大概率对应 &lt;code&gt;SESSION_NOT_RELIABLE&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;code_1000 = 1&lt;/code&gt;
正常关闭几乎没有&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这进一步说明：当前问题集中在“会话稳定维持”这件事上，而不是单纯的握手或单条心跳处理。&lt;/p&gt;
&lt;h2&gt;最可能的问题位置&lt;/h2&gt;
&lt;h3&gt;怀疑网络链路或接入层&lt;/h3&gt;
&lt;p&gt;第二个值得重点关注的是链路本身：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;客户端到服务端之间存在公网抖动&lt;/li&gt;
&lt;li&gt;入口层代理或网络设备对 WebSocket 长连接有额外影响&lt;/li&gt;
&lt;li&gt;高并发下连接被中间层提前断开&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果压测机和服务端不在同地域、同 VPC 或同内网环境下，那么公网噪声会显著放大。&lt;/p&gt;
&lt;h2&gt;这轮压测是否有价值&lt;/h2&gt;
&lt;p&gt;我认为这轮压测方向本身是对的，因为它覆盖了两个很关键的能力：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;WebSocket 握手建连能力&lt;/li&gt;
&lt;li&gt;建连后的心跳稳定性&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这两个维度都很适合作为后续继续优化时的对照基线，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当前 Spring WebSocket 实现的基线&lt;/li&gt;
&lt;li&gt;未来切到 Netty 之后的对照数据&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但这轮模型也有两个明显限制：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;压测机和服务端看起来不在完全受控的同环境里&lt;/li&gt;
&lt;li&gt;&lt;code&gt;500&lt;/code&gt; 连接共用同一个 token，和真实在线用户模型差距比较大&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;下一轮压测建议&lt;/h2&gt;
&lt;p&gt;如果我继续做下一轮，我会按这个顺序推进：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;把压测环境尽量放到和服务端同地域、同 VPC 或内网环境中，先降低公网噪声。&lt;/li&gt;
&lt;li&gt;改成多账号、多 token 模式，不再让 &lt;code&gt;500&lt;/code&gt; 个连接共用一个 token。&lt;/li&gt;
&lt;li&gt;保持当前“线程组只跑 1 轮”的模型，避免无限循环污染结果。&lt;/li&gt;
&lt;li&gt;做分级并发压测：&lt;code&gt;20 -&amp;gt; 50 -&amp;gt; 100 -&amp;gt; 200 -&amp;gt; 500&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;持续同时保留客户端报告和服务端埋点，对齐观察：
&lt;ul&gt;
&lt;li&gt;握手成功数&lt;/li&gt;
&lt;li&gt;心跳收到数&lt;/li&gt;
&lt;li&gt;ACK 成功数&lt;/li&gt;
&lt;li&gt;关闭原因分布&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;如果后续要做 Spring 与 Netty 对比，务必保持相同场景、相同压测机和相同指标口径。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;建议长期固定观察的关键指标&lt;/h2&gt;
&lt;p&gt;后续无论压 Spring 还是压 Netty，我都会固定关注这些指标：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;握手成功率&lt;/li&gt;
&lt;li&gt;握手 &lt;code&gt;p95&lt;/code&gt; / &lt;code&gt;p99&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;心跳成功率&lt;/li&gt;
&lt;li&gt;心跳 ACK 发送成功率&lt;/li&gt;
&lt;li&gt;异常关闭比例&lt;/li&gt;
&lt;li&gt;心跳超时清理比例&lt;/li&gt;
&lt;li&gt;峰值在线连接数&lt;/li&gt;
&lt;li&gt;JVM CPU / 内存 / GC&lt;/li&gt;
&lt;li&gt;Nginx 或入口层连接数与错误数&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;结论&lt;/h2&gt;
&lt;p&gt;这轮压测最终可以归纳成一句话：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;当前服务端“握手能力基本正常，heartbeat 处理也基本正常”，真正暴露问题的是“长连接在会话维持阶段的稳定性”。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;再往前推一步，其实还能得到一个更重要的判断：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;在公网环境下直接做这类 WebSocket 压测，噪声会非常大，结果很容易混入压测端插件行为、网络抖动和接入层影响，因此很难单靠一轮结果判断到底是服务器问题还是压测链路问题。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;所以这篇记录带给我的最大收获，不只是发现了“当前连接维持不稳定”，更是明确了下一轮压测应该优先先控制环境，再谈结论。&lt;/p&gt;
</content:encoded></item></channel></rss>