<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Catvoの博客</title><description>记录 Java 后端与 AI 工程的实践笔记，涵盖并发编程、接口安全、认证授权、缓存设计、性能优化与 RAG 应用开发。</description><link>https://catvo.net/</link><templateTheme>Firefly</templateTheme><templateThemeVersion>V3.0.2</templateThemeVersion><templateThemeUrl>https://github.com/CuteLeaf/Firefly</templateThemeUrl><lastBuildDate>2026年10月17日 11:53:22</lastBuildDate><item><title>从运行机制谈 Among Us 模组的反破解加固：五层防御体系</title><link>https://catvo.net/posts/among-us-mod-anti-crack-hardening/</link><guid isPermaLink="true">https://catvo.net/posts/among-us-mod-anti-crack-hardening/</guid><description>以 FinalSuspect 的三重检测被一个 dnSpy + ret 指令组合一次性解除为例，说明托管层检测为何必然失效，并给出由弱到强的五层加固方案：混淆加密、IL 完整性自校验、原生 DLL 检测、分散时机加蜜罐、服务端验证。</description><pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate><content:encoded><![CDATA[<p>以 FinalSuspect 为例，它的三重检测被一个 dnSpy + ret 指令的组合就全部解除了。根本原因只有一个：<strong>所有检测逻辑都在托管代码层（IL）里</strong>，和攻击者的修改工具处于同一个信任层。本文从运行机制角度，讲清楚为什么现有方案会失败，以及如何一层一层加强。</p>
<h2>一、为什么现有检测容易被绕过</h2>
<p>FinalSuspect 的检测失败链路如下：</p>
<pre><code class="language-text">检测逻辑在 IL 层
    ↓
dnSpy 可以直接反编译看到
    ↓
直接把方法体改成 ret
    ↓
检测逻辑永远不执行
</code></pre>
<p>根本问题是<strong>检测代码和被检测的环境在同一个进程、同一个信任层里</strong>。任何在托管代码里写的检测，理论上都可以被同样在托管层运行的代码绕过。下面从弱到强介绍五个加固层级。</p>
<h2>二、层级 1：混淆 + 字符串加密</h2>
<p>FinalSuspect 已经用了 XOR 加密字符串，但逻辑本身没有混淆，反编译后整体结构一目了然。更进一步的做法是用 <strong>ConfuserEx</strong> 或 <strong>Obfuscar</strong> 在编译后对 DLL 做整体混淆。</p>
<pre><code class="language-xml">&lt;project&gt;
  &lt;rule pattern="true"&gt;

    &lt;protection id="ctrl flow" /&gt;

    &lt;protection id="constants" /&gt;

    &lt;protection id="ref proxy" /&gt;

    &lt;protection id="anti tamper" /&gt;

    &lt;protection id="anti debug" /&gt;
  &lt;/rule&gt;
&lt;/project&gt;
</code></pre>
<p>**局限：**混淆只是提高反编译成本，不能从根本上阻止修改。有经验的人依然可以动态调试绕过。</p>
<h2>三、层级 2：完整性自校验（Anti-Tamper）</h2>
<p>让 DLL 在运行时校验自身的 IL hash。如果有人修改了某个方法的指令，hash 就对不上，直接退出。</p>
<pre><code class="language-csharp">internal static class IntegrityChecker
{
    // 编译时预先计算好关键方法的 hash，硬编码进来
    // 注意：这些 hash 本身也要混淆存储，不能明文
    private static readonly Dictionary&lt;string, byte[]&gt; _expectedHashes = new()
    {
        // MethodToken → 预期的 IL bytes 的 SHA256
        { "DllChecker.OnInitialization", new byte[] { 0x3A, 0xF1, ... } },
        { "Git.Prefix",                  new byte[] { 0x7C, 0x2B, ... } },
    };

    internal static void Verify()
    {
        var asm = Assembly.GetExecutingAssembly();

        foreach (var type in asm.GetTypes())
        {
            foreach (var method in type.GetMethods(
                BindingFlags.Public | BindingFlags.NonPublic |
                BindingFlags.Static | BindingFlags.Instance))
            {
                string key = $"{type.Name}.{method.Name}";
                if (!_expectedHashes.TryGetValue(key, out byte[] expected))
                    continue;

                // 读取运行时实际的 IL bytes
                var body = method.GetMethodBody();
                if (body == null) { Quit(); return; }

                byte[] actual = SHA256.HashData(body.GetILAsByteArray());

                if (!actual.SequenceEqual(expected))
                {
                    // IL 被篡改，静默退出（不报错，防止攻击者定位触发点）
                    Quit();
                    return;
                }
            }
        }
    }

    private static void Quit()
    {
        // 故意延迟几秒再退出，让攻击者难以定位触发点
        Thread.Sleep(new Random().Next(2000, 8000));
        Application.Quit(1);
    }
}
</code></pre>
<p><strong>关键点：</strong></p>
<ul>
<li><code>Verify()</code> 要在多个时机调用（启动时、进入游戏时、每隔一段时间），不能只在一处</li>
<li>hash 值本身要混淆存储，否则攻击者直接改 hash 就绕过了</li>
<li>退出前故意加随机延迟，防止攻击者通过"哪里卡了"来定位检测代码</li>
</ul>
<h2>四、层级 3：把检测逻辑移到原生层</h2>
<p>这是最关键的跨越。<strong>托管代码（C#）可以被 dnSpy 修改；原生代码（C++ DLL）需要 x64dbg + IDA，门槛高出一个数量级。</strong></p>
<p>做法是写一个原生 DLL（C++），负责执行真正的检测逻辑，C# 只负责调用：</p>
<pre><code class="language-cpp">// NativeGuard.cpp（编译成 NativeGuard.dll）
#include &lt;windows.h&gt;
#include &lt;tlhelp32.h&gt;

// 扫描插件目录，发现白名单外的 DLL 返回 -1
extern "C" __declspec(dllexport)
int __stdcall ScanPluginDirectory(const wchar_t* pluginDir,
                                   const wchar_t** whitelist,
                                   int whitelistCount)
{
    WIN32_FIND_DATAW findData;
    wchar_t pattern[MAX_PATH];
    swprintf_s(pattern, L"%s\\*.dll", pluginDir);

    HANDLE hFind = FindFirstFileW(pattern, &amp;findData);
    if (hFind == INVALID_HANDLE_VALUE) return 0;

    int count = 0;
    do {
        bool allowed = false;
        for (int i = 0; i &lt; whitelistCount; i++) {
            if (wcscmp(findData.cFileName, whitelist[i]) == 0) {
                allowed = true;
                break;
            }
        }
        if (!allowed) {
            FindClose(hFind);
            return -1; // 发现非法 DLL
        }
        count++;
    } while (FindNextFileW(hFind, &amp;findData));

    FindClose(hFind);
    return count;
}

// 检测已知调试器进程
extern "C" __declspec(dllexport)
bool __stdcall IsDebuggerRunning()
{
    // 方法 1：Windows API
    if (IsDebuggerPresent()) return true;

    // 方法 2：检查调试器进程名
    const wchar_t* debuggers[] = {
        L"dnSpy.exe", L"x64dbg.exe", L"cheatengine-x86_64.exe",
        L"ida64.exe", L"OllyDbg.exe", L"ProcessHacker.exe"
    };

    HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if (snap == INVALID_HANDLE_VALUE) return false;

    PROCESSENTRY32W pe = { sizeof(pe) };
    if (Process32FirstW(snap, &amp;pe)) {
        do {
            for (auto&amp; dbg : debuggers) {
                if (_wcsicmp(pe.szExeFile, dbg) == 0) {
                    CloseHandle(snap);
                    return true;
                }
            }
        } while (Process32NextW(snap, &amp;pe));
    }
    CloseHandle(snap);
    return false;
}
</code></pre>
<p>在 C# 中调用：</p>
<pre><code class="language-csharp">internal static class NativeGuard
{
    [DllImport("NativeGuard.dll", CharSet = CharSet.Unicode)]
    private static extern int ScanPluginDirectory(
        string pluginDir,
        string[] whitelist,
        int whitelistCount);

    [DllImport("NativeGuard.dll")]
    private static extern bool IsDebuggerRunning();

    internal static void Check()
    {
        // 调试器检测
        if (IsDebuggerRunning())
        {
            SilentQuit();
            return;
        }

        // 插件目录扫描
        string pluginDir = Path.GetDirectoryName(
            Assembly.GetExecutingAssembly().Location)!;

        string[] whitelist = { "FinalSuspect.dll", "PolarNight.dll" };

        int result = ScanPluginDirectory(pluginDir, whitelist, whitelist.Length);
        if (result == -1)
        {
            SilentQuit();
        }
    }

    private static void SilentQuit()
    {
        Thread.Sleep(new Random().Next(3000, 10000));
        Application.Quit(1);
    }
}
</code></pre>
<p>**为什么有效：**攻击者要绕过这个，必须先找到原生 DLL（名字可以随便起，混在游戏 DLL 里），再用 x64dbg 动态调试原生层，最后找到具体函数并 patch。这个门槛比改 IL 高出一个数量级。</p>
<h2>五、层级 4：分散检测时机 + 蜜罐</h2>
<p>不要把检测集中在一个地方，分散到各个角落，让攻击者找不全：</p>
<pre><code class="language-csharp">// 在多个不相关的 Patch 里随机触发检测
[HarmonyPatch(typeof(HudManager), nameof(HudManager.Update))]
internal static class HudUpdatePatch
{
    private static int _frameCount = 0;

    public static void Postfix()
    {
        _frameCount++;
        // 每隔随机帧数触发一次检测，不规律、难预测
        if (_frameCount % (UnityEngine.Random.Range(800, 1200)) == 0)
        {
            IntegrityChecker.Verify();
            NativeGuard.Check();
        }
    }
}

[HarmonyPatch(typeof(AmongUsClient), nameof(AmongUsClient.OnGameJoined))]
internal static class JoinGamePatch
{
    public static void Prefix()
    {
        // 进游戏时也检测一次
        NativeGuard.Check();
    }
}

[HarmonyPatch(typeof(MeetingHud), nameof(MeetingHud.Start))]
internal static class MeetingStartPatch
{
    public static void Prefix()
    {
        // 每次开会议时也检测
        IntegrityChecker.Verify();
    }
}
</code></pre>
<p>配合蜜罐使用——故意留一个看起来像检测方法的"假靶子"，让攻击者优先清空它，真正的检测在别处：</p>
<pre><code class="language-csharp">// 这个方法什么都不做，就是个蜜罐
// 攻击者反编译后会优先清空"看起来最危险"的方法
internal static class FakeChecker
{
    public static void CheckPlugins() { }   // 空的，故意的
    public static void ScanDirectory() { }  // 空的，故意的
}
</code></pre>
<h2>六、层级 5：服务端验证（最终形态）</h2>
<p>如果你的模组有联网功能，可以把合规性验证放到服务端。检测逻辑不在客户端，本地改再多也没用：</p>
<pre><code class="language-text">客户端启动
    ↓
发送签名请求到服务器
（请求里包含插件目录的 hash 列表）
    ↓
服务器验证 hash 是否合规
    ↓
返回一次性 Token
    ↓
客户端没有 Token → 无法使用联网功能
</code></pre>
<pre><code class="language-csharp">internal static class ServerValidator
{
    private const string ValidateEndpoint = "https://your-server.com/api/validate";

    internal static async Task&lt;bool&gt; ValidateAsync()
    {
        string pluginDir = Path.GetDirectoryName(
            Assembly.GetExecutingAssembly().Location)!;

        // 计算插件目录所有 DLL 的 hash 列表
        var hashes = Directory
            .EnumerateFiles(pluginDir, "*.dll")
            .Select(f =&gt; new
            {
                name = Path.GetFileName(f),
                hash = Convert.ToHexString(
                    SHA256.HashData(File.ReadAllBytes(f)))
            });

        // 发送给服务端验证
        using var client = new HttpClient();
        var response = await client.PostAsJsonAsync(ValidateEndpoint, hashes);

        if (!response.IsSuccessStatusCode) return false;

        // 服务端返回一次性 Token，后续联网功能需要带上
        string token = await response.Content.ReadAsStringAsync();
        SessionToken.Set(token);
        return true;
    }
}
</code></pre>
<p>**代价：**需要维护一个服务器，且离线玩家无法使用联网功能。适合竞技或排行榜类场景。</p>
<h2>七、各层级对比与选型建议</h2>
<table>
<thead>
<tr>
<th>层级</th>
<th>方案</th>
<th>防护对象</th>
<th>被绕过难度</th>
<th>实现成本</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>混淆 + 字符串加密</td>
<td>静态分析</td>
<td>低（动态调试可绕）</td>
<td>低</td>
</tr>
<tr>
<td>2</td>
<td>IL 完整性自校验</td>
<td>IL 篡改</td>
<td>中（改 hash 可绕）</td>
<td>中</td>
</tr>
<tr>
<td>3</td>
<td>原生 DLL 检测</td>
<td>托管层修改</td>
<td>高（需 x64dbg + IDA）</td>
<td>中</td>
</tr>
<tr>
<td>4</td>
<td>分散时机 + 蜜罐</td>
<td>定点清除</td>
<td>高（找不全触发点）</td>
<td>低</td>
</tr>
<tr>
<td>5</td>
<td>服务端验证</td>
<td>所有本地修改</td>
<td>极高（逻辑不在本地）</td>
<td>高</td>
</tr>
</tbody></table>
<p>按需求选型：</p>
<ul>
<li>**提高普通用户绕过成本：**层级 1 + 层级 2</li>
<li>**对抗有经验的逆向者：**层级 3 + 层级 4</li>
<li>**竞技 / 排名场景：**层级 5</li>
</ul>
<h2>八、核心原则</h2>
<p><strong>没有 100% 防破解的方案，目标是让破解成本高于收益。</strong></p>
<p>FinalSuspect 失败的原因不是技术不好，而是所有鸡蛋放在一个篮子里——找到检测代码在哪儿，一次性清空就结束了。真正有效的防护是多层、分散、跨信任层的组合。攻击者面对的不是"找到一个开关关掉"，而是"拆解整个防御体系的每一层"，破解成本自然就上去了。</p>
]]></content:encoded></item><item><title>逆向解析 FinalSuspect 的&quot;独占运行&quot;机制：如何让它与其他模组共存</title><link>https://catvo.net/posts/finalsuspect-mod-coexist-principle/</link><guid isPermaLink="true">https://catvo.net/posts/finalsuspect-mod-coexist-principle/</guid><description>通过 dnSpy 反编译剖析 FinalSuspect 阻止其他模组共存的三重检测机制（Harmony 拦截插件加载、目录扫描强制闪退、伪装类加密暗桩），给出逐层解除方法，以及共存后缺失 YamlDotNet 依赖与更新检查卡死的修复方案。</description><pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate><content:encoded><![CDATA[<p>FinalSuspect 是一款 Among Us 的"原版体验"模组。它有一个让人头疼的特性：只要检测到 <code>BepInEx/plugins</code> 目录里存在其他模组，游戏就会直接闪退。本文从反编译角度剖析它的三重检测机制，并说明如何解除限制。</p>
<p>**声明：**本文仅用于技术研究与个人学习。FinalSuspect 是开源项目，修改后仅供自己使用；共存后产生的模组兼容性问题需自行承担。</p>
<h2>一、检测机制总览</h2>
<p>通过 dnSpy 反编译，FinalSuspect 把"反其他模组"逻辑归进了 <code>FinalAntiCheat</code>（把装其他模组视为作弊行为）。一共有三处，采用"双保险 + 暗桩"设计：</p>
<ol>
<li><strong>DisableOtherPlugins</strong>：Harmony 补丁拦截插件加载</li>
<li><strong>DllChecker</strong>：启动时扫描文件并强制退出</li>
<li><strong>Git（伪装类）</strong>：进入主菜单后二次检测，字符串经过加密隐藏</li>
</ol>
<h2>二、第一重：DisableOtherPlugins —— 拦截加载</h2>
<p>它给 BepInEx 的 <code>IL2CPPChainloader.LoadPlugin</code> 打了一个 Harmony 前置补丁。<code>Prefix</code> 返回 <code>false</code> 会跳过原方法，也就是阻止该插件被加载。逻辑上只放行 UnityExplorer，其余插件一律拦截：</p>
<pre><code class="language-csharp">[HarmonyPatch(typeof(IL2CPPChainloader), "LoadPlugin")]
public static class DisableOtherPlugins
{
    public static bool Prefix(
        [HarmonyArgument(0)] PluginInfo pluginInfo,
        [HarmonyArgument(1)] Assembly pluginAssembly)
    {
        // 仅当插件是 UnityExplorer 时返回 true（放行）
        // 其他所有插件返回 false → 跳过原方法 → 不加载
        return pluginInfo.Metadata.GUID == "com.sinai.unityexplorer";
    }
}
</code></pre>
<p>**解除方法：**让 <code>Prefix</code> 永远返回 <code>true</code>，放行所有插件。在 dnSpy 里编辑 IL，把方法体替换为两条指令：</p>
<pre><code class="language-csharp">ldc.i4.1   // 压入 true
ret        // 返回
</code></pre>
<h2>三、第二重：DllChecker —— 扫描目录并闪退</h2>
<p>这是闪退的直接来源。它在启动时扫描插件目录，只要发现 <code>FinalSuspect.dll</code> 和 <code>PolarNight.dll</code> 以外的任何文件，就调用 <code>Application.Quit(1)</code> 强制退出；另外还检查游戏根目录是否存在外挂文件（这部分属于正常反作弊）。</p>
<pre><code class="language-csharp">public static class DllChecker
{
    [PluginModuleInitializer(InitializePriority.High)]
    internal static void OnInitialization()
    {
        string[] blacklist = { "SickoMenu.dll", "version.dll" };
        string pluginDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
        string gameDir = Environment.CurrentDirectory;

        // ① 扫描 plugins 目录：白名单之外一律闪退
        if (pluginDir != null)
        {
            foreach (string file in Directory.EnumerateFiles(pluginDir, "*.*"))
            {
                string fileName = Path.GetFileName(file);
                bool isWhitelisted = fileName == "FinalSuspect.dll"
                                  || fileName == "PolarNight.dll";
                if (!isWhitelisted)
                {
                    FinalLogger.Error("检测到非法/模组文件: " + fileName + "！游戏将被强制终止。");
                    Application.Quit(1);   // ← 闪退元凶
                }
            }
        }

        // ② 检查游戏根目录的外挂文件（反作弊，建议保留）
        foreach (string dll in blacklist)
        {
            if (File.Exists(Path.Combine(gameDir, dll)))
            {
                FinalLogger.Error("检测到非法文件: " + dll + "！游戏将被强制终止。");
                Application.Quit(1);
            }
        }
    }
}
</code></pre>
<p>**解除方法：**在 dnSpy 里把 <code>OnInitialization</code> 的 IL 指令全部删除，只保留一条 <code>ret</code>。由于原方法含 <code>foreach</code> 循环，会生成 try/finally 异常处理块，必须同时在"异常处理程序"选项卡里删除所有条目，否则方法体无效、无法保存。</p>
<pre><code class="language-csharp">// 清空方法体，只保留：
ret

// 并清空"异常处理程序"选项卡中的所有条目
</code></pre>
<h2>四、第三重：伪装的 Git 类 —— 加密暗桩</h2>
<p>这是最隐蔽的一处。作者把同样的检测逻辑复制了一份，藏在一个名为 <code>Git</code> 的类里（起这个名字是为了迷惑查找者），并把所有敏感字符串用 XOR 加密隐藏。它作为 <code>MainMenuManager.Start</code> 的 Harmony 补丁，在进入主菜单后触发。</p>
<pre><code class="language-csharp">[HarmonyPatch(typeof(MainMenuManager), "Start")]
internal static class Git
{
    // XOR 解密：字符串常量都用密钥异或隐藏，防止被直接搜索到
    private static string Decrypt(byte[] data, byte key)
    {
        byte[] result = new byte[data.Length];
        for (int i = 0; i &lt; data.Length; i++)
            result[i] = (byte)(data[i] ^ key);
        return Encoding.UTF8.GetString(result);
    }

    public static void Prefix(MainMenuManager __instance)
    {
        string[] rootBlacklist = { Git.A, Git.B };   // 解密后 = SickoMenu.dll / version.dll
        string pluginDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
        string gameDir = Environment.CurrentDirectory;

        // ① 与 DllChecker 相同的 plugins 目录白名单检测（这里是杀模组的关键）
        if (pluginDir != null)
        {
            foreach (string file in Directory.EnumerateFiles(pluginDir, "*.*"))
            {
                string fileName = Path.GetFileName(file);
                // Git.C / Git.D 解密后 = FinalSuspect.dll / PolarNight.dll
                if (fileName != Git.C &amp;&amp; fileName != Git.D)
                {
                    FinalLogger.Error(Git.G, Git.J);   // "检测到非法/模组文件..."
                    Application.Quit(1);
                }
            }
        }

        // ② 检测外挂进程、外挂窗口，并挂一个每分钟一次的定时器（反作弊部分）
        if (Process.GetProcessesByName(Git.E).Length != 0) Application.Quit(1);
        if (FindWindow(null, Git.F) != IntPtr.Zero)        Application.Quit(1);
        // ...省略定时器逻辑
    }
}
</code></pre>
<p>加密字符串用密钥 85（0x55）异或后可还原为：</p>
<table>
<thead>
<tr>
<th>字段</th>
<th>解密内容</th>
<th>用途</th>
</tr>
</thead>
<tbody><tr>
<td>Git.C / Git.D</td>
<td>FinalSuspect.dll / PolarNight.dll</td>
<td>plugins 目录白名单（杀模组）</td>
</tr>
<tr>
<td>Git.A / Git.B</td>
<td>SickoMenu.dll / version.dll</td>
<td>外挂文件检测（反作弊）</td>
</tr>
<tr>
<td>Git.E / Git.F</td>
<td>外挂进程名 / 窗口标题</td>
<td>运行中外挂检测（反作弊）</td>
</tr>
<tr>
<td>Git.J</td>
<td>FAC</td>
<td>日志标签</td>
</tr>
</tbody></table>
<p>**解除方法：**同样把 <code>Git.Prefix</code> 的 IL 清空为一条 <code>ret</code>，并清空异常处理程序选项卡。注意此处会连带移除反外挂检测——不开挂即无影响。</p>
<h2>五、修改后的连锁问题</h2>
<p>解除检测后，模组虽能共存，但会暴露两个隐藏问题，需要一并处理：</p>
<h3>1. 缺少 YamlDotNet 依赖导致无限卡加载</h3>
<p>FinalSuspect 加载语言文件时依赖 <code>YamlDotNet.dll</code>。若使用自己搭建的 BepInEx 环境而非官方完整包，会因缺少依赖抛出异常、加载协程中断，表现为卡在加载界面。</p>
<pre><code class="language-text">FileNotFoundException: Could not load file or assembly 'YamlDotNet, Version=16.0.0.0'
   at FinalSuspect.Modules.Core.Plugin.Translator.LoadLangs()
   at FinalSuspect.Patches.System.LoadPatch.LoadEssentialResources(...)
</code></pre>
<p>**解决：**从官方完整发布包中提取以下 DLL，放入 <code>BepInEx/core/</code>：</p>
<pre><code class="language-text">YamlDotNet.dll        （必需，缺失会卡加载）
NAudio.dll
NAudio.Core.dll
NAudio.Wasapi.dll
NAudio.WinMM.dll
NAudio.Flac.dll
NAudio.Asio.dll       （音乐功能所需）
</code></pre>
<h3>2. 更新检查卡死 / 未响应</h3>
<p>进入主菜单时，<code>VersionChecker</code> 会连接更新源检查新版本，而这些源大多失效，导致主线程阻塞、游戏未响应。好在作者内置了离线模式开关，无需改 DLL，只需改配置文件。</p>
<pre><code class="language-csharp">// 触发更新检查的判断逻辑
bool flag = VersionChecker.FirstStart &amp;&amp; !ConfigManager.OfflineMode.Value;
if (flag)
{
    VersionChecker.StartTasks();   // ← 联网检查更新，卡死源头
}
</code></pre>
<p>**解决：**编辑 <code>BepInEx/config/cn.slok.finalsuspect.cfg</code>（需在游戏关闭状态下修改）：</p>
<pre><code class="language-ini">Offline Mode = true               # 跳过启动时的更新检查
Language Update Bypass = LongTerm # 长期跳过语言文件更新检查
</code></pre>
<h2>六、操作步骤总结</h2>
<ol>
<li>用 dnSpy 打开 <code>FinalSuspect.dll</code>，先备份原文件</li>
<li>修改 <code>DisableOtherPlugins.Prefix</code> → <code>ldc.i4.1</code> + <code>ret</code></li>
<li>清空 <code>DllChecker.OnInitialization</code> → 仅留 <code>ret</code>，并清空异常处理程序</li>
<li>清空伪装类 <code>Git.Prefix</code> → 仅留 <code>ret</code>，并清空异常处理程序</li>
<li>保存模块，覆盖回 <code>BepInEx/plugins/</code></li>
<li>补齐 <code>BepInEx/core/</code> 下的 YamlDotNet 与 NAudio 依赖</li>
<li>在配置文件中开启离线模式与语言更新绕过</li>
</ol>
<h2>七、注意事项</h2>
<ul>
<li>务必备份修改后的 DLL。模组自动更新或重装会覆盖修改，导致限制恢复。</li>
<li>切勿点击游戏内的更新按钮，否则修改会被覆盖。</li>
<li>FinalSuspect 的反作弊与玩法类模组（如 TOHE）可能 Hook 同一批游戏方法，共存后如出现异常应优先怀疑模组冲突。</li>
<li>本文所述技术仅供个人研究，请遵守相关服务条款与社区规则。</li>
</ul>
]]></content:encoded></item><item><title>我的第一篇文章</title><link>https://catvo.net/posts/hello-world/</link><guid isPermaLink="true">https://catvo.net/posts/hello-world/</guid><description>用后台写的第一篇文章，验证发布链路是否可用。</description><pubDate>Mon, 05 Oct 2026 14:00:00 GMT</pubDate><content:encoded><![CDATA[<h1>你好，世界</h1>
<p>这是通过博客后台写的第一篇文章。</p>
<ul>
<li>支持 <strong>Markdown</strong></li>
<li>支持 <code>代码</code></li>
<li>写完点右上角「发布」即可上线</li>
</ul>
]]></content:encoded></item><item><title>RAG 检索增强生成 10 种策略：分块、混合检索与重排序选型</title><link>https://catvo.net/posts/ai-rag-ten-strategies/</link><guid isPermaLink="true">https://catvo.net/posts/ai-rag-ten-strategies/</guid><description>按 RAG 两阶段流程整理 10 种检索增强生成策略：语义分块、领域 Embedding、元数据索引、Graph RAG、混合检索与重排序，并给出默认选型与升级触发条件。</description><pubDate>Thu, 27 Aug 2026 00:00:00 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>[!NOTE] 提示
本文按"准备阶段 → 检索阶段 → 范式升级"的顺序整理 10 种 RAG（Retrieval-Augmented Generation，检索增强生成）策略，覆盖分块、嵌入、索引、查询处理、混合检索、重排序与 Agentic RAG。每种策略统一按"问题 / 做法 / 原理 / 收益 / 局限 / 代价 / 结论"划分段落（按需取用），有论文来源的标注出处。</p>
</blockquote>
<h2>一、引子：RAG 死了吗</h2>
<p>大模型回答不了个人笔记、企业内部资料相关的问题，这些内容不在训练数据里，接入外部知识是刚需。RAG 的做法是先检索相关片段，再交给模型生成回答。</p>
<p>2024 年以来，百万 token 级上下文窗口的模型逐步普及，"把资料全塞进上下文，RAG 已死"的说法随之出现。这个判断在三个约束下站不住：</p>
<p><strong>1. 成本。</strong> 输入 token 按量计费。10 万 token 的文档全量输入与 2000 token 的相关片段相比，单次请求的输入成本差 50 倍；每次提问都携带整个文档库时，倍数随库规模继续放大。检索方案的成本只取决于本次召回多少片段，与文档库总量无关。提示缓存（prompt caching）能摊薄重复前缀的开销，但只在文档集固定、复用率高时生效，对每次命中不同片段的场景无效。</p>
<p><strong>2. 延迟。</strong> 输入越长，prefill 阶段的计算量越大，模型开始输出越慢，首 token 延迟（TTFT）随之上涨，交互式问答对这一延迟敏感。</p>
<p><strong>3. 准确性。</strong> 长上下文不是无损容器。Liu et al. 的《Lost in the Middle》（TACL 2024）实验显示：关键信息位于上下文开头或结尾时模型表现最好，位于中部时准确率明显下降，曲线呈 U 形。塞入的资料越多，关键信息落在中部被淹没的概率越大。</p>
<p>结论：在成本、延迟、准确性的约束下，只检索必要内容仍然优于全部塞进上下文。长上下文抬高的是 RAG 的容错下限（召回不够精准时还有兜底空间），不取消 RAG 本身。问题只剩怎么做好。</p>
<h2>二、RAG 总体流程</h2>
<p>RAG 分两大阶段：准备阶段离线执行（建库时一次，或随文档更新执行），检索阶段在每次提问时执行。十种策略在流程上的落位：</p>
<pre><code class="language-mermaid">flowchart TB
    subgraph offline ["准备阶段（离线）"]
        A["Chunking 分块&lt;br/&gt;策略 1 语义分块 · 策略 2 命题分块&lt;br/&gt;增强：上下文增强分块"]
        B["Embedding 向量化&lt;br/&gt;策略 3 领域特化 Embedding"]
        C["Indexing 入库&lt;br/&gt;策略 4 元数据索引 · 策略 5 Graph RAG"]
        A --&gt; B --&gt; C
    end
    subgraph online ["检索阶段（在线）"]
        D["Query Processing 查询处理&lt;br/&gt;策略 6 查询改写 · 策略 7 假设文档检索"]
        E["Retrieval 检索&lt;br/&gt;策略 8 混合检索"]
        F["Re-rank 重排序&lt;br/&gt;策略 9 重排序"]
        D --&gt; E --&gt; F
    end
    C -.-&gt; D
    F --&gt; G["拼接上下文，交给 LLM 生成回答"]
    AG["策略 10 Agentic RAG：循环替代单次 pipeline"] -. 升级 .-&gt; online
</code></pre>
<p>各策略解决的问题：</p>
<table>
<thead>
<tr>
<th>环节</th>
<th>策略</th>
<th>解决的问题</th>
</tr>
</thead>
<tbody><tr>
<td>分块</td>
<td>策略 1 语义分块、策略 2 命题分块</td>
<td>固定长度切分破坏语义</td>
</tr>
<tr>
<td>嵌入</td>
<td>策略 3 领域特化 Embedding</td>
<td>通用模型分不开专业术语</td>
</tr>
<tr>
<td>索引</td>
<td>策略 4 元数据索引、策略 5 Graph RAG</td>
<td>结构化过滤、跨块关系丢失</td>
</tr>
<tr>
<td>查询处理</td>
<td>策略 6 查询改写、策略 7 假设文档检索</td>
<td>查询与文档表达不一致</td>
</tr>
<tr>
<td>检索</td>
<td>策略 8 混合检索、策略 9 重排序</td>
<td>纯向量检索区分不了相似关键词</td>
</tr>
<tr>
<td>范式</td>
<td>策略 10 Agentic RAG</td>
<td>单次检索召回不足</td>
</tr>
</tbody></table>
<p>自适应路由（Adaptive Routing）是调度层，负责在上述策略间动态分流，不计入十种。固定长度分块、通用 Embedding、纯向量检索是各环节的基线选项，作为对照在下文一并说明。</p>
<h2>三、准备阶段：策略 1-5</h2>
<h3>策略 1：语义分块（Semantic Chunking）</h3>
<p><strong>问题</strong>：基线是固定长度切分，每 N 个 token 切一刀，配一段 overlap（常见取值为 chunk 512-1024 token、overlap 10%-20%）。实现简单，但切口落在句子中间时会产生碎块，半句话既难以被 embedding 准确表达，也难以被 LLM 利用。</p>
<p><strong>做法</strong>：逐句向量化，相邻句子相似度高于阈值则合并进同一块，低于阈值则在句间断开。阈值一般不写死，而是按分布取：相似度落差排在最末 5% 的位置断开（百分位法），或用标准差倍数、四分位距，避免固定阈值在文档长度与题材差异下失效。判定时通常还会把前后各 1-2 句纳入缓冲窗口，降低单句噪声的影响。</p>
<p><strong>局限</strong>：一是分块依据只有向量相似度，两句向量距离远、逻辑上强关联（例如结论句与相隔较远的依据句）时仍会拆开，提升的是边界质量，不解决关联内容分散。二是收益未必覆盖成本，Qu et al.《Is Semantic Chunking Worth the Computational Cost?》（arXiv 2410.13070，2024）在多个检索与问答数据集上对比后认为，语义分块相对固定长度切分没有稳定一致的增益，而预处理开销（每句一次向量化，而非每块一次）是确定发生的。</p>
<p><strong>结论</strong>：结构化文档优先按标题层级切分，成本最低且边界最可靠；无结构长文本再考虑语义分块，并用自己的评测集验证增益是否真实存在。</p>
<h3>分块增强：上下文增强（Contextual Retrieval）</h3>
<p>针对"关联内容分散"这条局限，Anthropic 在 2024 年 9 月提出上下文增强，作为分块环节的增强手段，不占编号，可与语义分块、命题分块叠加。</p>
<p><strong>做法</strong>：入库前用 LLM 为每个块生成一段前缀，说明它在全文中的位置与指代（"本段出自 2023 年 Q2 财报，讨论的是 ACME 公司的营收情况"），把前缀拼在块正文之前再做向量化与关键词索引。</p>
<p><strong>收益</strong>：官方公布的数据（top-20 召回失败率，基线 5.7%）：仅做上下文嵌入降至 3.7%（相对下降 35%）；叠加上下文 BM25 降至 2.9%（49%）；再加重排序降至 1.9%（67%）。</p>
<p><strong>代价</strong>：与命题分块同源，全库每个块各需一次 LLM 调用。前缀生成只依赖原文，可以用提示缓存把重复读取整篇文档的开销压下来。</p>
<h3>策略 2：命题分块（Proposition Chunking）</h3>
<p>出自《Dense X Retrieval: What Retrieval Granularity Should We Use?》（Chen et al., EMNLP 2024）。论文提出了一种新的检索单元：命题（proposition），即原子化、自包含、封装单一事实的自然语言陈述。</p>
<p><strong>做法</strong>：先按任意方式粗切，再把每个粗块拆解为命题，每个命题独立索引。论文没有靠提示词硬拆，而是训练了专用的拆解模型（Propositionizer），在 Wikipedia 语料上批量生成命题级索引。</p>
<p>拆分示例，原句：</p>
<blockquote>
<p>《民法典》第五百八十六条规定，定金合同自实际交付定金时成立，定金数额不得超过主合同标的额的百分之二十。</p>
</blockquote>
<p>拆为两条命题：</p>
<blockquote>
<ul>
<li>定金合同自实际交付定金时成立。</li>
<li>定金数额不得超过主合同标的额的 20%。</li>
</ul>
</blockquote>
<p>两条命题都补齐了主语，并去掉了"《民法典》第五百八十六条规定"这类需要跨句解析的引导成分。自包含是命题的硬要求，拆完仍带着"该条""前述"这类悬空指代的，等于没拆。</p>
<p><strong>收益</strong>：检索粒度从段落细化到事实，命中更精确。论文结论是命题级检索单元在下游问答任务上达到或优于段落级。</p>
<p><strong>局限</strong>：命题太短，单条往往不足以支撑生成。通行解法是"小块检索、大块生成"（small-to-big / parent document retrieval）：用命题做索引与匹配，命中后按父块 ID 回溯它所属的原始段落，把段落而非命题交给 LLM，索引粒度与生成粒度由此解耦。</p>
<p><strong>代价</strong>：预处理引入全量 LLM 调用，成本随文档库规模上涨。</p>
<p><strong>结论</strong>：适合准确性要求高、文档库相对稳定的场景（法规、合同、手册），不适合频繁变更的海量语料。</p>
<h3>策略 3：领域特化 Embedding</h3>
<p>通用 embedding 模型的代表：</p>
<ul>
<li>OpenAI text-embedding-3 系列（small / large），支持 <code>dimensions</code> 参数按 Matryoshka 表示学习截断维度，用精度换存储与检索速度</li>
<li>智源 BGE 系列，旗舰 bge-m3：100+ 语言、8192 token 上下文，单模型同时输出稠密 / 稀疏 / 多向量三种表示</li>
<li>阿里 Qwen3-Embedding：0.6B / 4B / 8B 三档，最小档即支持 32k 上下文，发布时位居 MTEB 多语言榜开源权重模型第一</li>
</ul>
<p><strong>问题</strong>：通用模型对专业术语的分辨率不足。法律场景的典型例子："定金"（担保性质，适用定金罚则，违约方可能丧失定金）与"订金"（预付款性质，原则上可退）在法律上是两回事，但两个词在通用模型的向量空间里距离很近，检索结果互相污染。</p>
<p><strong>做法</strong>：术语密集的领域选用领域特化模型（如法律检索的 voyage-law-2），或在领域语料上微调通用底座。</p>
<p><strong>代价</strong>：这项决策必须在建库前定。向量维度与语义空间由模型绑定，换 embedding 模型意味着全库重新向量化并重建索引，开销与首次建库同级，没有"先上通用模型，不行再换"的余地。</p>
<p><strong>结论</strong>：判断标准是术语是否密集、术语间差异是否细微，满足则领域模型优先；通用语料下通用模型够用，省掉选型与维护成本。MTEB 名次不能直接当选型依据：榜单任务分布未必匹配自己的语料，落地前用自己的评测集跑一遍召回对比。</p>
<h3>策略 4：结构化索引与元数据过滤</h3>
<p><strong>做法</strong>：存储选型按规模分两档：</p>
<table>
<thead>
<tr>
<th>规模</th>
<th>方案</th>
<th>索引形态</th>
</tr>
</thead>
<tbody><tr>
<td>小型项目（单机、嵌入式）</td>
<td>SQLite + sqlite-vec</td>
<td>暴力扫描，万级到十万级块可接受，支持元数据列与分区键</td>
</tr>
<tr>
<td>中大型项目</td>
<td>PostgreSQL + pgvector</td>
<td>HNSW（查询快、召回高，建索引慢且吃内存）或 IVFFlat（建索引快、占用小，召回依赖 <code>lists</code> 与 <code>probes</code> 调参）</td>
</tr>
</tbody></table>
<p>选型注意：旧教程里常见的 sqlite-vss 已被作者停止维护（基于 Faiss 的 C++ 绑定存在集成问题），2024 年起由纯 C 实现、零外部依赖的 sqlite-vec 接替，新项目直接用 sqlite-vec。</p>
<p>每条记录存三样东西：向量、原文、元数据（日期、来源、类型、版本、父块 ID 等）。"只搜 2024 年之后、来源为 A 系统的合同条款"这类需求，向量相似度做不到，靠元数据过滤实现。</p>
<p><strong>问题</strong>：元数据过滤与 ANN 索引存在冲突。近似最近邻索引按向量距离组织数据，不认识 <code>WHERE</code> 条件，过滤条件命中率低时，两种执行顺序都会出问题：</p>
<ul>
<li><strong>后过滤</strong>（先走 ANN 取 top k，再按条件筛）：筛完可能剩下不足 k 条，极端情况返回空结果，且这种召回缺失在接口层看不出异常</li>
<li><strong>前过滤</strong>（先按条件全表筛，再暴力比距离）：条件宽松时退化为全表扫描，延迟随库规模线性上涨</li>
</ul>
<p>pgvector 0.8.0 引入迭代索引扫描（iterative index scan）缓解后过滤的问题：结果不足时自动继续遍历索引，由 <code>hnsw.iterative_scan</code> / <code>ivfflat.iterative_scan</code> 与 <code>hnsw.max_scan_tuples</code> 等参数控制。上线前要用真实的过滤条件分布压一遍召回率，不要只测无过滤的场景。</p>
<p><strong>局限</strong>：向量库以块为单位独立存储，块之间的引用关系丢失。合同第十二条引用第五条时，普通向量库不保留这条跨块引用，检索命中第十二条也无法自动带上第五条。</p>
<h3>策略 5：Graph RAG（图结构检索）</h3>
<p><strong>做法</strong>：在块之外，用 LLM 从文档中抽取实体（条款、人物、概念）与关系（引用、因果、从属），存成图结构，节点是实体，边是关系。检索时先定位命中实体，再沿边扩展关联节点，召回一个相关上下文簇。跨块引用关系在图里天然保留，查第十二条时能顺着边走到第五条。</p>
<pre><code class="language-mermaid">flowchart LR
    subgraph vec ["普通向量库：块独立存储，引用关系丢失"]
        B12["合同第十二条"]
        B5["合同第五条"]
        B3["合同第三条"]
    end
    subgraph kg ["Graph RAG：实体为节点，关系为边"]
        N12(("第十二条：命中"))
        N5(("第五条"))
        NC(("甲方主体"))
        N12 --&gt;|引用| N5
        N12 --&gt;|涉及| NC
        N5 --&gt;|约束| NC
    end
    vec == 抽取实体与关系建图 ==&gt; kg
</code></pre>
<p><strong>收益</strong>：图的价值不止局部检索。微软的 GraphRAG（Edge et al., 2024，《From Local to Global》）在图上做社区检测（Leiden 算法），对聚出的社区逐层生成摘要，据此回答全局性问题（"这批文档的核心主题是什么"）。这类问题向量检索答不了：它只能召回与查询局部相似的块，而全局主题不存在于任何单个块里。GraphRAG 因此区分两种检索模式：局部搜索从命中实体出发扩展邻域，全局搜索走社区摘要做 map-reduce 式汇总。</p>
<p><strong>代价</strong>：实体关系抽取需要 LLM 全量处理文档库，开销与命题分块同级或更高（每个块通常不止一次调用）；抽取质量受限于 LLM，错误的实体消歧与关系会污染检索结果；文档更新会触发局部乃至全图的社区重算，维护负担持续存在。</p>
<p><strong>结论</strong>：只在关系密集、跨块引用是核心查询模式的文档（合同、规范、结构化知识库）上采用，先在单个文档集上试点，用评测集量出与"向量 + 元数据"基线的召回差距，确认收益后再全量铺开。若只需要全局摘要能力、不要求完整图谱，可评估微软后续的 LazyGraphRAG，它把大部分 LLM 开销从建库期推迟到查询期，官方给出的索引成本约为完整 GraphRAG 的 0.1%。</p>
<blockquote>
<p>[!CAUTION] 定位
Graph RAG 属于高收益高风险策略。默认场景用策略 4 的向量库 + 元数据；确认跨块关系查询或全局摘要是刚需后，再引入图结构。</p>
</blockquote>
<h2>四、检索阶段：策略 6-9</h2>
<h3>策略 6：查询改写（Query Transformation）</h3>
<p><strong>问题</strong>：用户原始 query 往往口语化、缺主语、一词多义，或是多个子问题拼成的复合问题。</p>
<p><strong>做法</strong>：改写有三个方向：</p>
<ul>
<li><strong>具体化</strong>：结合对话历史，把"这个怎么配"补成完整、明确的问题。多轮对话场景下这一步必须做，带指代的 query 直接向量化，几乎必然召回错误内容</li>
<li><strong>拆解</strong>：复合问题拆成子问题分别检索后合并；也可以反向做抽象，用 step-back prompting（Zheng et al., ICLR 2024）先退一步问出上位概念，再检索原理性材料</li>
<li><strong>扩展</strong>：生成多个查询变体各自检索，结果用 RRF 融合（即 RAG-Fusion），提升召回覆盖</li>
</ul>
<p><strong>代价</strong>：每次提问增加一轮 LLM 调用，延迟与成本同步上涨；多查询扩展还会把检索次数放大 3-5 倍。时延敏感的场景只保留多轮对话的指代消解，其余预算留给重排序。</p>
<h3>策略 7：假设文档检索（HyDE）</h3>
<p>出自《Precise Zero-Shot Dense Retrieval without Relevance Labels》（Gao et al., ACL 2023）。</p>
<p><strong>做法</strong>：先让 LLM 针对问题生成一篇假设答案，不要求事实正确，只要像一篇真正的回答；再用假设答案的向量去检索，替代原始 query 的向量。论文的具体做法是生成多篇假设文档，把它们的向量与原 query 向量一起取平均，用平均向量检索，摊薄单篇幻觉的影响。</p>
<p><strong>原理</strong>：query 与目标文档之间存在表达鸿沟：问题短、口语化，文档长、书面化，两者在向量空间中的分布本就不重合。假设答案与真实文档同为"文档"，分布更接近，检索更准。论文结论：HyDE 的零样本检索效果优于当时的无监督稠密检索器 Contriever。</p>
<p><strong>局限</strong>：冷门领域 LLM 缺乏背景知识，生成的假设文档偏离事实，会把检索方向带偏：召回内容与问题完全不相关，且从最终答案表面不易察觉（模型会基于错误上下文流畅作答）。应对：冷门或专有领域慎用；启用时与混合检索的关键词通道并行，保留原始 query 的检索结果做兜底；在评测集上分别记录开启与关闭 HyDE 的召回率，按语料实测决定。</p>
<h3>策略 8：混合检索（Hybrid Search）</h3>
<p><strong>问题</strong>：纯向量检索语义匹配强、精确区分弱。"1 型糖尿病"与"2 型糖尿病"在向量空间距离很近，语义检索可能把两者混着返回，对医学检索来说就是事实性错误。</p>
<p><strong>做法</strong>：同时跑两路：</p>
<ul>
<li><strong>稠密向量检索</strong>：负责语义相关，能命中同义表达与改述</li>
<li><strong>稀疏检索</strong>：负责精确 token 匹配，硬性区分"1 型 / 2 型"这类词面差异。这里要分清两个概念：BM25 是打分函数（按词频、逆文档频率与文档长度归一化计算得分），倒排索引是支撑它的数据结构，两者不是并列关系</li>
</ul>
<p>工程上有两条路：主流存储都已提供混合检索能力（pgvector 配合 PostgreSQL 全文检索、Elasticsearch / OpenSearch、Qdrant、Milvus）；也可以用 bge-m3 这类单模型同时产出稠密与稀疏表示，省掉维护两套索引的负担。</p>
<p><strong>原理</strong>：两路的分数不能直接相加：余弦相似度落在 [-1, 1]，BM25 无上界，量纲不同。加权融合要先做分数归一化，而归一化对分数分布敏感、跨查询不稳定。RRF（Reciprocal Rank Fusion）绕开了这个问题，它只用排名不用分数：</p>
<p>$$
\text{score}(d) = \sum_{r \in R} \frac{1}{k + \text{rank}_r(d)}
$$</p>
<p>$R$ 是各路检索器的集合，$\text{rank}_r(d)$ 是文档 $d$ 在第 $r$ 路结果中的排名，$k$ 是平滑常数（原论文取 60，用于抑制头部排名之间的权重落差）。无需调参、无需归一化，这是 RRF 成为默认融合方案的原因。确实需要给不同通道加权时，再换加权融合并自行处理归一化。</p>
<p><strong>结论</strong>：收益最大的语料是专有名词、编号、术语密集的类型（法规条文号、零件型号、药品名）。</p>
<h3>策略 9：重排序（Re-ranking）</h3>
<p><strong>做法</strong>：初筛与精排用不同架构的模型：</p>
<table>
<thead>
<tr>
<th>架构</th>
<th>编码方式</th>
<th>速度</th>
<th>精度</th>
<th>职责</th>
</tr>
</thead>
<tbody><tr>
<td>双编码器（bi-encoder）</td>
<td>query 与文档分别编码，文档向量可离线预计算</td>
<td>快，支持 ANN 索引</td>
<td>一般</td>
<td>初筛：从全库召回 top 100</td>
</tr>
<tr>
<td>迟交互（late interaction，ColBERT 类）</td>
<td>文档按 token 预计算多向量，查询时做 token 级匹配</td>
<td>中，需额外存储</td>
<td>较高</td>
<td>初筛或中间层，也可直接充当重排</td>
</tr>
<tr>
<td>交叉编码器（cross-encoder）</td>
<td>query 与候选拼成一对，联合编码打分</td>
<td>慢，无法预计算</td>
<td>高</td>
<td>精排：把 top 100 排成 top 5</td>
</tr>
</tbody></table>
<p>交叉编码器对每一对（query, 候选）都要完整前向计算，全库跑一遍成本不可接受。工程惯例是"先召后排"：bi-encoder 或混合检索做初筛，cross-encoder 只对几十条候选精排，把费用与延迟锁在候选层。ColBERT（Khattab &amp; Zaharia, SIGIR 2020）代表的迟交互是两者之间的折中：文档侧仍可预计算，匹配保留 token 粒度，代价是每篇文档存一组向量、存储明显放大，bge-m3 的多向量模式属于这一路。</p>
<p>模型选择：商用 API 的代表是 Cohere Rerank；自托管常用 BGE-Reranker 或 Jina Reranker 系列。另有让 LLM 对候选列表整体排序的做法（listwise，如 RankGPT），精度上限更高，但延迟与成本随候选数量线性上涨，通常只用于离线评测或候选极少的场景。</p>
<p><strong>结论</strong>：真正影响效果的参数是两个数：初筛候选数（决定召回上限，取小了重排无从挑选）与重排后送入 LLM 的条数。后者不是越多越好：受 Lost in the Middle 的位置效应影响，塞 20 条往往不如精准的 5 条。这两个数要在评测集上一起调。</p>
<h2>五、范式升级：策略 10 与组合策略</h2>
<p>单次 pipeline 隐含的假设是一次检索就能召回全部所需信息，而实际的查资料过程往往不是这样：查到一半发现缺一块，换个关键词再查。</p>
<h3>策略 10：Agentic RAG</h3>
<p><strong>做法</strong>：把单次 pipeline 换成循环，由 agent 决定每一轮做什么：</p>
<pre><code class="language-mermaid">flowchart TB
    subgraph pipeline ["单次 Pipeline"]
        Q1["提问"] --&gt; R1["检索一次"] --&gt; A1["直接生成"]
    end
    subgraph loop ["Agentic 循环"]
        Q2["提问"] --&gt; R2["Agent 决定调用哪个工具"]
        R2 --&gt; E{"信息足够？"}
        E --&gt;|是| A2["生成回答"]
        E --&gt;|否| L{"达到轮数上限？"}
        L --&gt;|否| W["改写查询，继续检索"]
        W --&gt; R2
        L --&gt;|是| A3["按已有信息生成&lt;br/&gt;并标注信息不完整"]
    end
</code></pre>
<p>Agent 的工具箱不限于语义检索：关键词检索、执行代码、读取原文、查元数据都可以作为工具挂载，多轮调用直至信息足以支撑回答。工具描述的质量直接决定调用准确率，写清楚每个工具的适用场景与参数含义，比增加工具数量更有效。</p>
<p><strong>收益</strong>：召回侧，多轮探索能补上单次检索遗漏的信息；工程侧，传统 pipeline 需要硬编码策略分支（什么时候改写、什么时候拆问题），Agentic 模式把这些决策交给 agent 判断，主流程只剩一个循环。</p>
<p><strong>局限</strong>：最大的风险是循环不收敛，agent 反复用近似的 query 检索同一批内容，token 消耗与延迟随轮数线性上涨，极端情况下打满上下文窗口后失败，成本无法预测、做不了容量规划。防护要做硬：</p>
<ul>
<li>最大轮数上限（工程上常取 5-8 轮）与单次问答的 token 预算，超限则用已有信息强制生成并标注"信息可能不完整"</li>
<li>每轮把已检索过的 query 与块 ID 记入状态，重复命中时直接终止</li>
<li>对多轮累积的上下文做去重与压缩，只保留被引用的片段</li>
</ul>
<p><strong>结论</strong>：简单问题跑 agent 循环不经济，查一个 API 用法用不着探索三轮。</p>
<h3>组合策略：自适应路由（Adaptive Routing）</h3>
<p><strong>做法</strong>：在入口处用轻量分类器评估问题难度，简单问题走传统 pipeline（混合检索 + 重排序），复杂问题升级为 Agentic RAG。</p>
<p>论文出处：Adaptive-RAG（Jeong et al., NAACL 2024）。原始设计是三档路由：简单问题不检索、由 LLM 直接回答；中等复杂度单步检索；复杂问题多步迭代检索。分类器不依赖人工标注，而是用各档位的实际问答结果做弱监督训练（哪一档最先答对，样本就归到那一档）。工程上常简化为两档：简单走 pipeline，复杂走 agent 循环。</p>
<p><strong>局限</strong>：误判是主要风险，尤其是把复杂问题判成简单：复杂问题走单次检索，召回不足直接答错，且路由决策发生在检索之前，后续环节没有纠正机会。对策：分类阈值向"判复杂"一侧倾斜（误判为复杂只是多花成本，误判为简单会答错）；生成后加一道自检，答案置信度低或引用不足时回退到 agent 路径。</p>
<h2>六、十种策略速览与选型</h2>
<table>
<thead>
<tr>
<th>#</th>
<th>策略</th>
<th>环节</th>
<th>解决的问题</th>
<th>主要代价</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>语义分块</td>
<td>准备</td>
<td>固定切分破坏语义</td>
<td>逐句向量化，且增益不稳定</td>
</tr>
<tr>
<td>2</td>
<td>命题分块</td>
<td>准备</td>
<td>检索粒度粗</td>
<td>LLM 全量处理，成本高</td>
</tr>
<tr>
<td>3</td>
<td>领域特化 Embedding</td>
<td>准备</td>
<td>专业术语分不开</td>
<td>选型与维护成本，换模型需全库重建</td>
</tr>
<tr>
<td>4</td>
<td>元数据索引</td>
<td>准备</td>
<td>无法结构化过滤</td>
<td>过滤与 ANN 索引冲突，跨块关系丢失</td>
</tr>
<tr>
<td>5</td>
<td>Graph RAG</td>
<td>准备</td>
<td>跨块引用与全局主题问题</td>
<td>构建与更新昂贵</td>
</tr>
<tr>
<td>6</td>
<td>查询改写</td>
<td>检索</td>
<td>query 表达质量差</td>
<td>多一轮 LLM 调用，检索次数放大</td>
</tr>
<tr>
<td>7</td>
<td>假设文档检索</td>
<td>检索</td>
<td>query 与文档向量分布不一致</td>
<td>冷门领域可能带偏检索</td>
</tr>
<tr>
<td>8</td>
<td>混合检索</td>
<td>检索</td>
<td>相似关键词分不清</td>
<td>维护两套索引，或换单模型方案</td>
</tr>
<tr>
<td>9</td>
<td>重排序</td>
<td>检索</td>
<td>初筛排序粗糙</td>
<td>交叉编码器计算贵</td>
</tr>
<tr>
<td>10</td>
<td>Agentic RAG</td>
<td>范式</td>
<td>单次召回不足</td>
<td>多轮调用，成本与延迟不可预测</td>
</tr>
<tr>
<td>—</td>
<td>上下文增强分块</td>
<td>准备（增强）</td>
<td>块脱离全文后指代不明</td>
<td>每块一次 LLM 调用</td>
</tr>
<tr>
<td>—</td>
<td>自适应路由</td>
<td>调度</td>
<td>简单问题被过度处理</td>
<td>分类器存在误判</td>
</tr>
</tbody></table>
<p>新项目默认配置，按顺序落地：</p>
<ol>
<li><strong>分块</strong>：结构化文档按标题层级切分，无结构长文本用语义分块；chunk 目标 512-1024 token，overlap 10%-20%</li>
<li><strong>Embedding</strong>：通用模型起步（text-embedding-3 / bge-m3 / Qwen3-Embedding），但必须在建库前定下来</li>
<li><strong>存储</strong>：sqlite-vec 或 pgvector，第一天就把元数据做全（日期、来源、版本、父块 ID）</li>
<li><strong>检索</strong>：混合检索 + RRF + 重排序，这组在成本与收益上性价比最高</li>
<li><strong>评测</strong>：与第 4 步同步搭起来，不要等策略调完再补</li>
<li><strong>查询处理</strong>：多轮对话的指代消解先上；改写与 HyDE 等延迟预算允许后再加</li>
</ol>
<p>第 4 步是整套里最值得先做对的部分。骨架如下，省略了检索器与重排模型的具体实现：</p>
<pre><code class="language-python">def rrf_fuse(rankings: list[list[str]], k: int = 60) -&gt; list[str]:
    """按 RRF 融合多路检索结果，输入为各路的有序 chunk_id 列表。"""
    scores: dict[str, float] = {}
    for ranking in rankings:
        for rank, chunk_id in enumerate(ranking, start=1):
            scores[chunk_id] = scores.get(chunk_id, 0.0) + 1.0 / (k + rank)
    return sorted(scores, key=lambda cid: scores[cid], reverse=True)


def retrieve(query: str, *, filters: dict, top_k: int = 5) -&gt; list[str]:
    # 1. 两路初筛，候选数取远大于 top_k 的值，给重排留出挑选空间
    dense_hits = dense_search(query, filters=filters, limit=100)
    sparse_hits = bm25_search(query, filters=filters, limit=100)

    # 2. RRF 融合，只用排名，不做分数归一化
    candidates = rrf_fuse([dense_hits, sparse_hits])[:60]

    # 3. 交叉编码器精排，计算成本锁在候选层
    reranked = cross_encoder_rerank(query, candidates)[:top_k]

    # 4. 命中的是命题或小块时，回溯父段落再交给 LLM（small-to-big）
    return [load_parent_text(cid) for cid in reranked]
</code></pre>
<p>三个数字需要在评测集上一起调：初筛 <code>limit</code>（决定召回上限）、送进重排的候选数（决定重排成本）、最终 <code>top_k</code>（受位置效应约束）。</p>
<p>升级触发条件：</p>
<ul>
<li><strong>文档关系密集、跨块引用或全局摘要类查询多</strong>：评估 Graph RAG，先小规模试点</li>
<li><strong>复杂问题占比高、多轮探索的收益在评测集上可见</strong>：Agentic RAG，配合自适应路由与轮数上限控制成本</li>
<li><strong>领域术语密集、通用模型检索互相污染</strong>：换领域特化 Embedding，同时加强关键词通道与布尔约束</li>
<li><strong>带过滤条件的召回率明显低于无过滤</strong>：调 ANN 索引参数或改用前过滤，见策略 4</li>
</ul>
<pre><code class="language-mermaid">flowchart TB
    D["新项目默认配置&lt;br/&gt;语义分块 / 标题层级切分 · 通用 Embedding&lt;br/&gt;sqlite-vec / pgvector + 元数据&lt;br/&gt;混合检索 + RRF + 重排序 + 评测集"]
    D --&gt;|领域术语密集，检索互相污染| E1["领域特化 Embedding"]
    D --&gt;|文档关系密集，跨块引用查询多| E2["Graph RAG（先小规模试点）"]
    D --&gt;|复杂问题占比高，多轮探索收益明显| E3["Agentic RAG + 自适应路由"]
    D --&gt;|块脱离全文后指代不明| E4["上下文增强分块"]
</code></pre>
<h2>七、质量与工程考量</h2>
<p>成本与延迟：</p>
<ul>
<li>只召回必要内容，控制输入上下文长度</li>
<li>重排放在初筛之后，只对候选层计算</li>
<li>时延敏感路径不引入额外的 LLM 预处理：查询改写与 HyDE 各要一轮调用，直接加在 TTFT 上</li>
<li>Agentic 路径必须有轮数与 token 上限，否则单次问答成本不可预测</li>
</ul>
<p>准确性与上下文稀释：</p>
<ul>
<li>分块质量是地基，chunk 语义不完整时，后面的策略都救不回来</li>
<li>混合检索 + 重排提升相关性；对召回结果做去重（不同块可能包含同一事实），减少上下文噪音</li>
<li>注意 Lost in the Middle 的位置效应：重要内容不要埋在长上下文中部，重排后的顺序要真的用上</li>
<li>要求模型给出引用来源，答案与召回块能对齐，幻觉才能被发现</li>
</ul>
<p>评测要分层，检索层与生成层分开测量，否则出问题定位不到环节：</p>
<table>
<thead>
<tr>
<th>层</th>
<th>指标</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>检索</td>
<td>Recall@k</td>
<td>期望文档是否出现在 top k，衡量召回上限</td>
</tr>
<tr>
<td>检索</td>
<td>nDCG@k / MRR</td>
<td>期望文档排得够不够前，衡量排序质量与重排收益</td>
</tr>
<tr>
<td>生成</td>
<td>忠实度（faithfulness）</td>
<td>答案是否只依据召回内容，用于定位幻觉</td>
</tr>
<tr>
<td>生成</td>
<td>答案相关性</td>
<td>答案是否回答了问题，与检索质量解耦</td>
</tr>
</tbody></table>
<p>固定一批问题与期望召回的文档做评测集，每次调整策略跑一遍。规模不必大，50-100 条覆盖典型查询类型即可起效；关键是每次只动一个变量，否则无法归因。检索指标能用脚本自动算，生成指标可用 RAGAS 一类框架做 LLM-as-judge，但要抽样人工校准判分标准。没有评测集的策略调优是在赌方向。</p>
<p>维护与迭代：</p>
<ul>
<li>元数据从第一天做全（日期、来源、版本、父块 ID），后续的过滤、增量更新、small-to-big 回溯都依赖它</li>
<li>增量更新按内容哈希判断块是否变化，只重算变化的块；Graph RAG 的更新流程单独设计，增量还是全图重算直接决定维护成本</li>
<li>把 embedding 模型与分块参数写进索引的元信息，换配置时能识别出哪些块属于旧版本</li>
</ul>
<h2>八、结语</h2>
<p>RAG 解决的是"如何召回信息"这一个问题。十种策略分布在准备与检索两个阶段，各管一段，没有通吃的那一种。</p>
<p>其中只有两件事值得无条件先做：混合检索加重排序（收益稳定、成本锁在候选层），以及在动手调策略之前先有评测集。其余都是带触发条件的加法：语义分块的增益争议、HyDE 在冷门领域的反作用、Graph RAG 的维护成本，都得靠自己的语料实测才知道，不要因为某个策略看起来更先进就默认启用。</p>
<p>检索只是 agent 的地基，往上还有 memory 设计、行为评估、数据闭环，那是另一套问题。</p>
<h2>九、参考资料</h2>
<ul>
<li><a href="https://arxiv.org/abs/2212.10496">Gao et al. Precise Zero-Shot Dense Retrieval without Relevance Labels（HyDE，arXiv 2212.10496，ACL 2023）</a></li>
<li><a href="https://arxiv.org/abs/2312.06648">Chen et al. Dense X Retrieval: What Retrieval Granularity Should We Use?（arXiv 2312.06648，EMNLP 2024）</a></li>
<li><a href="https://arxiv.org/abs/2404.16130">Edge et al. From Local to Global: A Graph RAG Approach to Query-Focused Summarization（arXiv 2404.16130，2024）</a></li>
<li><a href="https://aclanthology.org/2024.naacl-long.389/">Jeong et al. Adaptive-RAG: Learning to Adapt Retrieval-Augmented Large Language Models through Question Complexity（NAACL 2024）</a></li>
<li><a href="https://arxiv.org/abs/2307.03172">Liu et al. Lost in the Middle: How Language Models Use Long Contexts（arXiv 2307.03172，TACL 2024）</a></li>
<li><a href="https://arxiv.org/abs/2410.13070">Qu et al. Is Semantic Chunking Worth the Computational Cost?（arXiv 2410.13070，2024）</a></li>
<li><a href="https://arxiv.org/abs/2004.12832">Khattab &amp; Zaharia. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT（arXiv 2004.12832，SIGIR 2020）</a></li>
<li><a href="https://arxiv.org/abs/2310.06117">Zheng et al. Take a Step Back: Evoking Reasoning via Abstraction in Large Language Models（arXiv 2310.06117，ICLR 2024）</a></li>
<li><a href="https://dl.acm.org/doi/10.1145/1571941.1572114">Cormack et al. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods（SIGIR 2009，RRF 原始论文）</a></li>
<li><a href="https://www.anthropic.com/news/contextual-retrieval">Anthropic：Introducing Contextual Retrieval（2024-09）</a></li>
<li><a href="https://www.microsoft.com/en-us/research/blog/lazygraphrag-setting-a-new-standard-for-quality-and-cost/">Microsoft Research：LazyGraphRAG — Setting a New Standard for Quality and Cost</a></li>
<li><a href="https://github.com/pgvector/pgvector">pgvector（GitHub，含 HNSW / IVFFlat 与迭代索引扫描说明）</a></li>
<li><a href="https://github.com/asg017/sqlite-vec">sqlite-vec（GitHub，sqlite-vss 的继任者）</a></li>
<li><a href="https://blog.voyageai.com/2024/04/15/domain-specific-embeddings-and-retrieval-legal-edition-voyage-law-2/">Voyage AI：Domain-Specific Embeddings — Legal Edition（voyage-law-2）</a></li>
<li><a href="https://docs.ragas.io/">Ragas 文档（检索与生成侧评测指标）</a></li>
<li><a href="https://huggingface.co/spaces/mteb/leaderboard">MTEB Leaderboard（Hugging Face）</a></li>
</ul>
]]></content:encoded></item><item><title>前端性能优化十项主流：问题、方法与收益</title><link>https://catvo.net/posts/details-web-performance-optimization/</link><guid isPermaLink="true">https://catvo.net/posts/details-web-performance-optimization/</guid><description>整理前端与站点性能优化十项主流方法：性能分析、HTTP 缓存、CDN、Nginx 配置、加载策略、图片、字体与 JS 长任务治理，每项说明原因、做法与收益。</description><pubDate>Sun, 09 Aug 2026 00:00:00 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>[!NOTE] 提示
性能优化的手段多且杂，本文把它们归拢到「字节、往返、距离、主线程」四个变量上，按先测量后动手的顺序展开十项主流实践。每项统一说明问题原因、处理方法、提升幅度与注意事项，文末附端到端 Nginx 配置、踩坑清单与方法总览表，关键数据标注了来源。</p>
</blockquote>
<h2>引子</h2>
<p>一个常见场景：一个日均 PV 几千的内容型站点，4G 网络下首屏耗时 4 秒开外，Lighthouse 跑分不到 50。排查结果很典型：文章配图是没压缩的 2 MB 原图；静态资源全都没有缓存头，回访用户每次重新下载；服务器部署在境外，TTFB 接近 800 ms。三个问题对应三类浪费：字节太多、往返太多、距离太远。</p>
<p>业务侧的代价有数据口径：Google 的移动端速度研究显示，页面加载时间从 1 s 涨到 3 s，跳出概率上升 32%（来源：Google/SOASTA 研究，业界广泛引用）。</p>
<p>性能优化的每一项手段，最终都落在少数几个变量上。本文按「先测量，再动手」的顺序整理十项主流实践：性能分析、传输协议、传输压缩、HTTP 缓存、CDN、Nginx 配置、加载策略、图片、字体、JS 执行，每项按问题原因、处理方法、提升幅度、注意事项展开，数据来源统一列在参考资料。</p>
<h2>背景知识：度量框架与优化变量</h2>
<p>Core Web Vitals 是 Google 定义的三项真实用户体验指标，同时是搜索排名信号（web.dev）：</p>
<table>
<thead>
<tr>
<th>指标</th>
<th>含义</th>
<th>良好阈值</th>
</tr>
</thead>
<tbody><tr>
<td>LCP（最大内容绘制）</td>
<td>主内容渲染完成的时间</td>
<td>≤ 2.5 s</td>
</tr>
<tr>
<td>INP（交互到下一次绘制）</td>
<td>交互响应延迟，2024 年 3 月取代 FID</td>
<td>≤ 200 ms</td>
</tr>
<tr>
<td>CLS（累积布局偏移）</td>
<td>视觉稳定性</td>
<td>≤ 0.1</td>
</tr>
</tbody></table>
<p>页面加载耗时可以近似拆成三段：资源体积除以带宽、请求数乘以往返时间（RTT）、服务端处理时间。后文的方法都在改这几个变量：压缩和图片格式减少字节，缓存和协议升级减少往返，CDN 缩短 RTT 本身，JS 治理则发生在另一头的主线程上。</p>
<p>测量工具分两类：实验室数据（Lighthouse、WebPageTest，可复现，适合定位）与真实用户数据（CrUX、RUM，反映真实设备与网络）。PageSpeed Insights 两者都提供。优化决策以真实用户数据为准。</p>
<h2>十项主流优化方法</h2>
<h3>一、性能分析：先测量，再优化</h3>
<p><strong>问题原因</strong>：性能瓶颈很少符合直觉。没测过基线就动手，优化的大概率不是真正的瓶颈，改完也无法证明有没有效果。</p>
<p><strong>处理方法</strong>：</p>
<ul>
<li>实验室工具：Lighthouse（Chrome DevTools 内置，快速出分并给出审计清单）、WebPageTest（瀑布图最细，可选测试地区、设备与网络节流）、Chrome DevTools Performance 面板（定位长任务与强制重排）。</li>
<li>真实用户数据：PageSpeed Insights 自带 CrUX 字段数据；自建 RUM 可用 <code>web-vitals</code> 库在页面内上报指标。</li>
<li>工作流固定为三步：字段数据找问题，实验室数据定位原因，改完再用字段数据验收。</li>
</ul>
<p><strong>提升幅度</strong>：不直接产生提升，作用是把优化清单按预期收益排序。先看 TTFB（良好阈值 800 ms，web.dev）再看前端资源，避免服务端已经拖后腿时去抠前端字节。</p>
<p><strong>注意事项</strong>：Lighthouse 单次跑分有波动，同一页面两次能差几分，结论基于多次运行取中位数；CrUX 有流量门槛，小站点可能查不到字段数据，只能靠实验室工具加本地节流。</p>
<h3>二、HTTP 协议升级：HTTP/2、TLS 1.3 与 HTTP/3</h3>
<p><strong>问题原因</strong>：HTTP/1.1 同一 TCP 连接一次只能处理一个请求，浏览器靠每个域名开 6 个连接缓解队头阻塞，每个连接都要独立的 TCP 加 TLS 握手；TLS 1.2 握手需要 2 个 RTT。</p>
<p><strong>处理方法</strong>：</p>
<ul>
<li>全站 HTTPS（HTTP/2 的前提），Nginx 1.25.1 起用 <code>http2 on;</code> 指令开启。</li>
<li>启用 TLS 1.3（需 OpenSSL 1.1.1+）：握手降到 1-RTT，会话恢复 0-RTT。</li>
<li>有条件再上 HTTP/3（Nginx 1.25+ 支持 QUIC），通过 <code>Alt-Svc</code> 响应头让浏览器自动协商。</li>
</ul>
<p><strong>提升幅度</strong>：TLS 1.3 每次握手省 1 个 RTT。HTTP/3 的收益集中在弱网：CloudPanel 实测 4G 加 15% 丢包环境下，页面加载比 HTTP/2 快约 55%；DebugBear 的结论是收益具有场景性，稳定高速网络下提升有限。</p>
<p><strong>注意事项</strong>：QUIC 走 UDP，部分企业防火墙会拦截，必须保留 HTTP/2 兜底（浏览器按 Alt-Svc 自动降级）；HTTP/2 多路复用后，HTTP/1.1 时代的域名分片、雪碧图合并变成负优化，见踩坑一节。</p>
<h3>三、传输压缩：gzip、Brotli 与 Zstandard</h3>
<p><strong>问题原因</strong>：HTML/CSS/JS 是高度冗余的文本。不压缩时，一个 300 KB 的 JS 文件在 10 Mbps 带宽下纯传输就要 240 ms，还没算握手与排队。</p>
<p><strong>处理方法</strong>：</p>
<ul>
<li>基线：Nginx 开 gzip，覆盖 JS、CSS、JSON、SVG、XML 等文本类型。</li>
<li>进阶：Brotli（<code>ngx_brotli</code> 模块），压缩率更高，主流浏览器全支持。</li>
<li>关键取舍：静态资源在构建期预压缩成 <code>.br</code>/<code>.gz</code> 文件（<code>brotli_static</code>/<code>gzip_static</code>），可以用最高压缩级别；动态内容实时压缩只敢用中低级别，此时 zstd 的速度优势明显。</li>
</ul>
<p><strong>提升幅度</strong>：gzip 通常压掉文本资产体积的 60-75%（Blazing CDN 数据）；Brotli 最高级别再比 gzip 小 15-25%（Paul Calvano 测试）。压缩速度决定实时压缩的选型：zstd 约 510 MB/s、Brotli 约 290 MB/s、gzip 约 105 MB/s（OpenResty 数据）。</p>
<p><strong>注意事项</strong>：<code>gzip_comp_level</code> 超过 6 后体积收益递减、CPU 线性上涨；图片、视频、woff2 这类已压缩格式不要二次压缩，纯烧 CPU；CDN 场景必须回带 <code>Vary: Accept-Encoding</code>，否则可能把压缩版发给不支持解压的客户端。</p>
<h3>四、HTTP 缓存：让重复访问零请求</h3>
<p><strong>问题原因</strong>：默认行为下浏览器每次都问服务器「变了没」。回访用户重复下载没改过的 JS/CSS，每个未命中都是一次完整往返。</p>
<p><strong>处理方法</strong>：资源分两类，各给一种策略：</p>
<ul>
<li>带指纹的静态资源（如 <code>app.3f8a9c.js</code>）：<code>Cache-Control: public, max-age=31536000, immutable</code>，一年内浏览器不发任何验证请求。Vite、webpack、Astro 等构建工具默认输出带 hash 的文件名，指纹机制是现成的。</li>
<li>HTML 入口页：<code>Cache-Control: no-cache</code> 加 ETag 协商，内容没变返回 304（几百字节的响应头）。</li>
<li>进阶方向是 Service Worker 加 Cache API 做细粒度控制与离线，成本更高，内容型站点按需选。</li>
</ul>
<p><strong>提升幅度</strong>：命中强缓存的请求耗时趋近于 0。指纹化模式下，回访页面只剩 HTML 一个必发请求，其余静态资源全部来自本地；HTML 协商未变时，全量响应退化为 304。</p>
<p><strong>注意事项</strong>：长 <code>max-age</code> 只能配指纹化文件名，否则用户拿不到更新；Nginx 的 ETag由 mtime 和文件大小算出，多机部署时 mtime 不一致会让同一文件算出不同 ETag，命中率反常下降；HTTP Archive 统计里，使用 <code>immutable</code> 的站点绝大多数搭配 365 天以上的 max-age，属于成熟模式。</p>
<h3>五、CDN：把字节搬到用户旁边</h3>
<p><strong>问题原因</strong>：光速是硬约束。用户到源站的物理距离直接加进 TTFB：跨国 RTT 轻松超过 200 ms，页面几十个请求每个都要走完全程；源站带宽有限，静态下载还会挤占动态接口。</p>
<p><strong>处理方法</strong>：图片、JS、CSS、字体这些静态资源接入 CDN，DNS 指向边缘节点；配置回源策略，边缘未命中才回源，开启中间层缓存（如 Origin Shield）降低回源量；缓存头沿用上一节的方案，CDN 会尊重 <code>Cache-Control</code>。</p>
<p><strong>提升幅度</strong>：真实案例中改善区间常见 20-50%：Cloudflare 的 Pacsun 案例接入后整体快 27%。源站越远、用户分布越广，收益越大。</p>
<p><strong>注意事项</strong>：CDN 的价值取决于缓存命中率，缓存键（query string、header）配置不当会把命中率打到很低，性能反而不如直连源站；HTML 一般不进 CDN 长缓存，用短 TTL 或纯回源；静态资源用独立域名可以甩掉 Cookie，但 HTTP/2 下不要为分片拆太多域名。</p>
<h3>六、Nginx 与服务器配置</h3>
<p><strong>问题原因</strong>：默认配置按通用场景偏保守。静态文件默认经过用户态缓冲，多两次内存拷贝；短连接每个请求重新握手；TLS 参数保守时握手慢、加密套件低效。</p>
<p><strong>处理方法</strong>：</p>
<pre><code class="language-nginx">sendfile on;             # 内核态直接发文件，省掉用户态拷贝
tcp_nopush on;           # 配合 sendfile，攒满一个包再发，适合静态文件
tcp_nodelay on;          # 禁用 Nagle，keepalive 连接上的小包立即发
keepalive_timeout 65;    # 连接复用，减少重复握手
</code></pre>
<ul>
<li>TLS：<code>ssl_session_cache</code> 复用会话；OCSP Stapling 由服务器预取证书状态随握手下发，客户端不用再单独问 CA。</li>
<li>内核层：TCP 拥塞控制升级为 BBR，高丢包链路吞吐改善明显，应用层无需改动。</li>
</ul>
<p><strong>提升幅度</strong>：sendfile 减少内存拷贝与上下文切换，静态大文件吞吐提升明显；keepalive 让同连接的后续请求免掉整个 TCP 加 TLS 握手；OCSP Stapling 为客户端省一次对 CA 的查询（百毫秒级）。这几项属于消除明显浪费，具体提升幅度取决于基线有多差。</p>
<p><strong>注意事项</strong>：<code>worker_processes auto</code> 按核数配齐；改一项测一项，Nginx 配置错误直接 502；BBR 需要内核 4.9+，改内核参数前先确认宿主机支持。</p>
<h3>七、加载策略：资源提示与关键渲染路径</h3>
<p><strong>问题原因</strong>：浏览器的默认发现机制有结构性延迟：CSS 阻塞渲染，<code>&lt;script&gt;</code> 默认阻塞解析；CSS 里引用的字体要等 CSS 下载解析完才发起请求；跨域关键资源要现做 DNS、TCP、TLS。</p>
<p><strong>处理方法</strong>：</p>
<ul>
<li>脚本：默认 <code>defer</code>（不阻塞解析，DOM 建好后按序执行），独立的第三方脚本用 <code>async</code>。</li>
<li>资源提示按用途选：<ul>
<li><code>preconnect</code>：提前完成 DNS 加 TCP 加 TLS，只给确定马上要用的关键源（字体、CDN），字体必须带 <code>crossorigin</code>。</li>
<li><code>preload</code>：当前页高优先级资源（LCP 图、关键字体），必须写 <code>as</code> 属性。</li>
<li><code>prefetch</code>：下一跳页面的资源，空闲期低优先级拉取。</li>
<li><code>fetchpriority</code>：修正浏览器自己排错的优先级，典型用法是给 LCP 图标 <code>high</code>。</li>
</ul>
</li>
<li>首屏关键 CSS 内联，其余样式异步加载。</li>
</ul>
<p><strong>提升幅度</strong>：preconnect 省掉 1-3 个 RTT（按网络质量约 50-300 ms）；preload 字体消除「CSS 下载、发现字体、再下载字体」的串行瀑布；<code>defer</code> 消除脚本对解析的阻塞。</p>
<p><strong>注意事项</strong>：资源提示过用会反噬，preload 所有资源等于没有优先级，preconnect 控制在 4 个以内；prefetch 的资源可能在使用前被逐出，只适合大概率发生的导航。</p>
<h3>八、图片优化</h3>
<p><strong>问题原因</strong>：图片通常是页面字节的大头。没约束的原图、桌面端大图塞进手机、缺少尺寸属性引起的布局偏移，是三类最常见的浪费。</p>
<p><strong>处理方法</strong>：</p>
<ul>
<li>格式升级：<code>&lt;picture&gt;</code> 里 AVIF 打头、WebP 兜底、JPEG 收尾。</li>
<li>响应式：<code>srcset</code> 加 <code>sizes</code>，按视口宽度给不同分辨率。</li>
<li><code>width</code>/<code>height</code> 属性写死，浏览器提前算出宽高比，同时收获 CLS 收益。</li>
<li>LCP 主图：<code>fetchpriority="high"</code>，绝不懒加载；折叠以下图片一律 <code>loading="lazy"</code>。</li>
<li>用构建期图像管线（sharp、Astro 图像服务）统一转码，不靠手工。</li>
</ul>
<p><strong>提升幅度</strong>：WebP 比 JPEG 小 25-35%，AVIF 约 50%（Google 数据，web.dev）。格式升级加响应式，是单点收益最大的一类优化。</p>
<p><strong>注意事项</strong>：懒加载 LCP 图会直接推迟 LCP，首屏图务必排除；AVIF 编码慢，构建时间会变长；压缩质量过低时，伪影先出现在纯色渐变和文字边缘。</p>
<h3>九、字体优化</h3>
<p><strong>问题原因</strong>：字体请求排在 CSS 之后，天然串行；字体未就绪时浏览器要么空白（FOIT）要么替换闪烁（FOUT）；中文字体全量动辄数 MB。</p>
<p><strong>处理方法</strong>：</p>
<ul>
<li>统一 WOFF2；关键字体加 <code>preload</code>，带 <code>crossorigin</code>。</li>
<li><code>font-display: swap</code>，先用回退字体渲染，字体就绪后替换。</li>
<li>swap 带来的布局偏移，用 <code>size-adjust</code>/<code>ascent-override</code>/<code>line-gap-override</code> 把回退字体度量对齐到 webfont，可压到接近零 CLS。</li>
<li><code>unicode-range</code> 分片按需下载；中文场景做子集化，只保留实际用到的字符。</li>
<li>能用系统字体栈的场景优先系统字体，零请求零偏移。</li>
</ul>
<p><strong>提升幅度</strong>：WOFF2 相比 TTF 减约 60%、相比 WOFF 减约 30%；preload 消除字体请求的串行等待；中文子集化通常是数量级级别的缩减。</p>
<p><strong>注意事项</strong>：swap 必然有一次替换闪烁，正文可接受，强视觉的品牌标题慎用；子集化要覆盖动态内容的字符集，后到的字符会回退渲染；字体自托管比第三方托管省一次跨域连接。</p>
<h3>十、JavaScript 执行：长任务治理</h3>
<p><strong>问题原因</strong>：下载只是 JS 成本的开始，解析、编译、执行都占主线程，而主线程同一时刻只能跑一个任务。超过 50 ms 的任务定义为长任务（web.dev），长任务期间页面无法响应交互，这是 INP 不达标的首要原因。</p>
<p><strong>处理方法</strong>：</p>
<ul>
<li>少发：Tree-shaking 去死代码；按路由拆包（代码分割）；交互后再动态 <code>import()</code> 非关键模块。</li>
<li>不阻塞：非关键脚本 <code>defer</code>，或延迟到首次交互后加载。</li>
<li>拆任务：<code>scheduler.yield()</code>（或退化为 <code>setTimeout</code>）把长任务切成小块让出主线程；纯计算挪进 Web Worker。</li>
<li>高频事件（scroll、resize、mousemove）防抖节流；回调里读写分离，避免强制同步布局。</li>
</ul>
<p><strong>提升幅度</strong>：收益定性但方向明确：发送的字节越少，解析编译时间越短；长任务拆开后交互不再排队，目标是把 INP 压进 200 ms。</p>
<p><strong>注意事项</strong>：分割过细会产生大量碎片请求，按路由或交互边界拆；Web Worker 通信有序列化成本，不适合高频小任务；INP 要在真实设备上测，桌面开发机的性能会掩盖问题。</p>
<h2>实战演示：静态站点的端到端配置</h2>
<p>把前面几节落到一份 Nginx 配置（服务端部分）：</p>
<pre><code class="language-nginx">http {
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;

    gzip on;
    gzip_comp_level 6;
    gzip_types text/css application/javascript application/json image/svg+xml text/plain application/xml;
    gzip_static on;              # 优先发送构建期预压缩的 .gz
    # brotli on;                 # 安装 ngx_brotli 后启用
    # brotli_comp_level 6;
    # brotli_static on;
}

server {
    listen 443 ssl;
    http2 on;                    # Nginx 1.25.1+ 新语法
    listen 443 quic reuseport;   # HTTP/3，Nginx 1.25+
    add_header Alt-Svc 'h3=":443"; ma=86400' always;

    ssl_protocols TLSv1.3 TLSv1.2;
    ssl_session_cache shared:SSL:10m;
    ssl_stapling on;
    ssl_stapling_verify on;

    # 指纹化静态资源：一年强缓存
    location ~* \.(js|css|woff2|png|jpe?g|webp|avif|svg|ico)$ {
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # HTML：协商缓存
    location / {
        add_header Cache-Control "no-cache";
        try_files $uri $uri/index.html =404;
    }
}
</code></pre>
<p>注意 <code>add_header</code> 不继承：location 里只要出现一个 <code>add_header</code>，server 级的全部失效（见踩坑五），需要把 <code>Alt-Svc</code> 在各 location 内重复声明。</p>
<p>HTML 侧的资源提示与图片标记：</p>
<pre><code class="language-html">&lt;head&gt;
  &lt;!-- 关键第三方源提前建连 --&gt;
  &lt;link rel="preconnect" href="https://cdn.example.com" crossorigin&gt;
  &lt;!-- 关键字体提前加载，打断 CSS 到字体的串行瀑布 --&gt;
  &lt;link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin&gt;
  &lt;!-- LCP 主图提前加载并提高优先级 --&gt;
  &lt;link rel="preload" as="image" href="/images/hero.avif" fetchpriority="high"&gt;
  &lt;script defer src="/app.js"&gt;&lt;/script&gt;
&lt;/head&gt;

&lt;body&gt;
  &lt;!-- LCP 图：高优先级、绝不懒加载、带尺寸防布局偏移 --&gt;
  &lt;img src="/images/hero.avif" width="1200" height="600" fetchpriority="high" alt=""&gt;
  &lt;!-- 折叠以下：懒加载 --&gt;
  &lt;img src="/images/inline.webp" width="800" height="450" loading="lazy" alt=""&gt;
&lt;/body&gt;
</code></pre>
<h2>踩坑点 &amp; 注意事项</h2>
<h3>坑 1：HTTP/1.1 时代的优化在 HTTP/2 下反向</h3>
<p>域名分片、雪碧图、内联小文件是 6 连接时代的产物。HTTP/2 单连接多路复用后，分片破坏优先级调度，反而变慢。升级协议后要清理这批遗留手段。</p>
<h3>坑 2：只看 Lighthouse 分数</h3>
<p>实验室分不等于真实体验：跑分环境是快速桌面网络，真实用户在移动弱网。搜索排名用的是 CrUX 字段数据。正确的分工是实验室定位问题、字段数据验收效果。</p>
<h3>坑 3：一次改五项，无法归因</h3>
<p>五项一起上，指标变好说不清是谁的功劳，变差也拆不出谁拖累。性能优化按单变量迭代，每项改动前后对比同一指标、同一环境。</p>
<h3>坑 4：HTML 被长缓存</h3>
<p><code>immutable</code> 加指纹文件名的模式只对静态资源成立。HTML 引用的是文件名，文件名不变时用户会长期拿到旧页面。HTML 必须保持 <code>no-cache</code> 协商。</p>
<h3>坑 5：Nginx add_header 不继承</h3>
<p>location 中只要出现一个 <code>add_header</code>，server 级的所有 <code>add_header</code> 对该 location 失效。上面配置里 <code>Cache-Control</code> 与 <code>Alt-Svc</code> 同用就会踩到，需要在 location 内重复声明。</p>
<h2>优化方法总览</h2>
<table>
<thead>
<tr>
<th>方法</th>
<th>典型提升幅度</th>
<th>成本与风险</th>
</tr>
</thead>
<tbody><tr>
<td>性能分析</td>
<td>不直接提升，负责排序优化清单</td>
<td>只有时间成本</td>
</tr>
<tr>
<td>HTTP/2 + TLS 1.3</td>
<td>每次握手省 1 RTT</td>
<td>需全站 HTTPS</td>
</tr>
<tr>
<td>HTTP/3</td>
<td>弱网页面加载约 -55%（CloudPanel 实测），快网有限</td>
<td>UDP 可能被拦截，需 HTTP/2 兜底</td>
</tr>
<tr>
<td>传输压缩</td>
<td>gzip 压掉 60-75%；Brotli 再省 15-25%</td>
<td>实时压缩耗 CPU，静态资源应预压缩</td>
</tr>
<tr>
<td>HTTP 缓存</td>
<td>回访只剩 HTML 一个必发请求</td>
<td>必须配指纹化文件名</td>
</tr>
<tr>
<td>CDN</td>
<td>典型 -20% 到 -50%（案例 -27%）</td>
<td>命中率配置不当反而变慢</td>
</tr>
<tr>
<td>Nginx 与内核调优</td>
<td>消除拷贝与重复握手，幅度取决于基线</td>
<td>配置错误直接不可用</td>
</tr>
<tr>
<td>资源提示</td>
<td>preconnect 省 1-3 RTT</td>
<td>过用反噬优先级</td>
</tr>
<tr>
<td>图片优化</td>
<td>WebP -25% 到 -35%，AVIF 约 -50%</td>
<td>AVIF 编码慢</td>
</tr>
<tr>
<td>字体优化</td>
<td>WOFF2 比 TTF 约 -60%，子集化数量级缩减</td>
<td>swap 有一次替换闪烁</td>
</tr>
<tr>
<td>JS 长任务治理</td>
<td>目标 INP ≤ 200 ms</td>
<td>分割过细则请求碎片化</td>
</tr>
</tbody></table>
<h2>总结</h2>
<p>顺序比方法重要：先测量建立基线，再按「字节、往返、距离、主线程」四个变量逐项处理。对多数站点，收益最大的三件事是缓存策略、图片格式升级和传输压缩；协议升级与 CDN 是一次性投入、长期受益的基础设施项。手段会过时，FID 已被 INP 取代，HTTP/3 正在普及，但「先测量、单变量迭代、用真实用户数据验收」的流程不会变。最后一条经验：性能优化最贵的不是漏掉某个技巧，而是把时间花在不是瓶颈的环节上。</p>
<h2>参考资料</h2>
<ul>
<li><a href="https://web.dev/articles/vitals">web.dev：Web Vitals（LCP / INP / CLS 阈值定义）</a></li>
<li><a href="https://web.dev/learn/performance/resource-hints">web.dev：Resource hints（preconnect / preload / prefetch 用法）</a></li>
<li><a href="https://web.dev/learn/performance/image-performance">web.dev：Image performance（AVIF 节省 50%+ 的出处）</a></li>
<li><a href="https://web.dev/articles/optimize-long-tasks">web.dev：Optimize long tasks（50 ms 长任务定义与拆分）</a></li>
<li><a href="https://web.dev/articles/http-cache">web.dev：HTTP cache（指纹化 URL 与协商缓存）</a></li>
<li><a href="https://web.dev/learn/performance/optimize-web-fonts">web.dev：Optimize web fonts</a></li>
<li><a href="https://paulcalvano.com/2024-03-19-choosing-between-gzip-brotli-and-zstandard-compression/">Paul Calvano：Choosing Between gzip, Brotli and zStandard Compression</a></li>
<li><a href="https://speedvitals.com/blog/zstd-vs-brotli-vs-gzip/">SpeedVitals：ZSTD vs Brotli vs GZip 对比</a></li>
<li><a href="https://blog.openresty.com/en/edge-zstd/">OpenResty Edge：Zstd Compression（压缩速度对比）</a></li>
<li><a href="https://blog.blazingcdn.com/en-us/cdn-compression-performance-brotli-vs-gzip">Blazing CDN：CDN Compression Performance（Brotli vs gzip）</a></li>
<li><a href="https://www.cloudpanel.io/blog/http3-vs-http2/">CloudPanel：HTTP/3 vs HTTP/2（弱网实测 55%）</a></li>
<li><a href="https://www.debugbear.com/blog/http3-vs-http2-performance">DebugBear：HTTP/3 vs HTTP/2 performance（收益场景性结论）</a></li>
<li><a href="https://csswizardry.com/2019/03/cache-control-for-civilians/">CSS Wizardry：Cache-Control for Civilians（缓存头详解）</a></li>
<li><a href="https://discuss.httparchive.org/t/cache-control-immutable-a-year-later/1195">HTTP Archive 讨论：Cache-Control immutable, A Year Later</a></li>
<li><a href="https://www.cloudflare.com/learning/cdn/what-is-a-cdn/">Cloudflare：What is a CDN（原理与 Pacsun 案例）</a></li>
<li><a href="https://developer.chrome.com/docs/lighthouse/overview">Chrome for Developers：Lighthouse overview</a></li>
<li><a href="https://chenhe.me/zh/posts/nginx-https-optimization/">晨鹤部落格：Nginx HTTPS 性能优化（OCSP Stapling）</a></li>
<li><a href="https://huckabuy.com/20-important-page-speed-bounce-rate-and-conversion-rate-statistics/">Huckabuy：Page Speed Statistics（加载 1s 到 3s 跳出率 +32% 口径）</a></li>
</ul>
<hr />
<blockquote>
<p>[!NOTE] 提示
如果这篇文章对你有帮助，欢迎点赞收藏。有问题欢迎评论区交流。</p>
</blockquote>
]]></content:encoded></item><item><title>Java 线程池配置指南：核心参数、线程数估算与拒绝策略</title><link>https://catvo.net/posts/projects-java-thread-pool-configuration/</link><guid isPermaLink="true">https://catvo.net/posts/projects-java-thread-pool-configuration/</guid><description>Java ThreadPoolExecutor 配置指南，讲解核心参数、CPU/IO 线程数估算、有界队列、拒绝策略、监控告警和动态调优方法。</description><pubDate>Thu, 07 May 2026 00:00:00 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>[!NOTE] 提示
本文面向需要在生产环境配置 Java 线程池的开发者。核心目标：将 <code>ThreadPoolExecutor</code> 的七个参数、任务调度流程、CPU/IO 场景差异、线程数估算方法、队列选型原则、监控指标与动态调优方案整理为可执行的工程规范。重点审查项：线程数是否受下游资源约束、拒绝策略是否可观测、容器环境是否正确读取 CPU 核数。</p>
</blockquote>
<h2>一、核心摘要</h2>
<ul>
<li><strong>问题</strong>：手动创建线程存在资源失控、创建开销大、缺乏背压与降级机制三类风险。</li>
<li><strong>方案</strong>：使用 <code>ThreadPoolExecutor</code> 显式配置核心线程数、最大线程数、队列、拒绝策略与线程工厂，并通过监控指标闭环调优。</li>
<li><strong>关键约束</strong>：<code>corePoolSize</code> 不应超过下游最小连接池容量；必须使用有界队列；拒绝策略必须可观测。</li>
<li><strong>目标</strong>：读完本文后，能够针对 CPU 密集、IO 密集、混合型三种业务场景给出初始配置，并设计压测与监控方案。</li>
</ul>
<p><strong>适用边界</strong>：本文讨论应用内任务执行器，不覆盖消息队列消费者并发度、数据库连接池配置和 JVM GC 调优的完整方案。线程池参数必须以压测数据和下游容量为依据。</p>
<hr />
<h2>二、为什么需要线程池</h2>
<p>手动为每个请求创建线程，存在以下三类问题：</p>
<table>
<thead>
<tr>
<th>问题</th>
<th>具体表现</th>
<th>后果</th>
</tr>
</thead>
<tbody><tr>
<td>资源失控</td>
<td>每个线程默认约 1MB 栈空间（<code>-Xss</code> 可配置），1000 个并发请求约占用 1GB 堆外内存</td>
<td>突发流量下触发 OOM</td>
</tr>
<tr>
<td>创建开销</td>
<td>线程创建涉及 JVM 栈分配、OS 内核态切换，单次约 10~50μs</td>
<td>高频创建时 CPU 与延迟显著上升</td>
</tr>
<tr>
<td>管理缺失</td>
<td>无法限制并发上限、无法感知任务积压、无法优雅降级</td>
<td>故障时缺乏控制手段</td>
</tr>
</tbody></table>
<p>线程池提供的核心能力：</p>
<ol>
<li><strong>线程复用</strong>：降低线程创建与销毁开销。</li>
<li><strong>有界队列</strong>：提供背压机制，防止无限堆积。</li>
<li><strong>拒绝策略</strong>：在容量耗尽时执行降级或告警。</li>
</ol>
<hr />
<h2>三、ThreadPoolExecutor 核心参数</h2>
<p><code>ThreadPoolExecutor</code> 的构造函数包含七个参数，必须整体理解：</p>
<pre><code class="language-java">new ThreadPoolExecutor(
    corePoolSize,                         // 1. 核心线程数
    maximumPoolSize,                      // 2. 最大线程数
    keepAliveTime,                        // 3. 非核心线程空闲存活时间
    TimeUnit.SECONDS,                     // 4. 时间单位
    new LinkedBlockingQueue&lt;&gt;(1000),      // 5. 工作队列
    new NamedThreadFactory("order-exec"), // 6. 线程工厂
    new ThreadPoolExecutor.CallerRunsPolicy() // 7. 拒绝策略
);
</code></pre>
<h3>1、corePoolSize 与 maximumPoolSize</h3>
<table>
<thead>
<tr>
<th>参数</th>
<th>含义</th>
<th>行为</th>
</tr>
</thead>
<tbody><tr>
<td><code>corePoolSize</code></td>
<td>长期保活的核心线程数</td>
<td>即使空闲，默认也不回收（除非调用 <code>allowCoreThreadTimeOut(true)</code>）</td>
</tr>
<tr>
<td><code>maximumPoolSize</code></td>
<td>线程池允许创建的最大线程数</td>
<td>仅在工作队列满后才会扩张到此值</td>
</tr>
</tbody></table>
<p>两种典型策略：</p>
<ul>
<li><strong>固定大小</strong>：<code>corePoolSize = maximumPoolSize</code>。无弹性，依赖队列缓冲流量波动。</li>
<li><strong>弹性伸缩</strong>：<code>corePoolSize &lt; maximumPoolSize</code>。队列满后扩容，空闲线程超时回收。</li>
</ul>
<h3>2、keepAliveTime 与 TimeUnit</h3>
<ul>
<li>控制非核心线程的空闲回收时间。</li>
<li>建议值 30~120 秒。过短导致线程频繁创建销毁，引起 CPU 抖动；过长导致资源闲置。</li>
</ul>
<h3>3、工作队列</h3>
<p>队列类型决定排队行为与弹性空间，必须与线程数一起决策。详见第六章。</p>
<h3>4、线程工厂</h3>
<p>生产环境必须自定义线程工厂，原因：</p>
<ul>
<li><code>jstack</code> 或 arthas 排查时，<code>"pool-1-thread-1"</code> 无法判断业务归属；</li>
<li>命名清晰的线程（如 <code>"order-exec-1"</code>）可快速定位问题线程池。</li>
</ul>
<pre><code class="language-java">public class CustomThreadFactory implements ThreadFactory {
    private final String prefix;
    private final AtomicInteger counter = new AtomicInteger(1);

    public CustomThreadFactory(String prefix) {
        this.prefix = prefix;
    }

    @Override
    public Thread newThread(Runnable r) {
        Thread t = new Thread(r, prefix + "-" + counter.getAndIncrement());
        t.setDaemon(false);
        t.setUncaughtExceptionHandler((thread, ex) -&gt;
            log.error("线程 {} 发生未捕获异常", thread.getName(), ex));
        return t;
    }
}
</code></pre>
<h3>5、拒绝策略</h3>
<p>当工作队列满且线程数达到 <code>maximumPoolSize</code> 时，新任务触发拒绝策略：</p>
<table>
<thead>
<tr>
<th>策略</th>
<th>行为</th>
<th>适用场景</th>
<th>风险</th>
</tr>
</thead>
<tbody><tr>
<td><code>AbortPolicy</code>（默认）</td>
<td>抛出 <code>RejectedExecutionException</code></td>
<td>核心链路，必须让调用方感知拒绝</td>
<td>调用方需捕获异常</td>
</tr>
<tr>
<td><code>CallerRunsPolicy</code></td>
<td>由提交线程自身执行</td>
<td>不能丢任务，天然限流</td>
<td>可能阻塞调用线程</td>
</tr>
<tr>
<td><code>DiscardPolicy</code></td>
<td>静默丢弃</td>
<td>可幂等重试的非关键任务</td>
<td>无感知数据丢失，生产慎用</td>
</tr>
<tr>
<td><code>DiscardOldestPolicy</code></td>
<td>丢弃队头最老任务</td>
<td>实时性优先，允许淘汰旧请求</td>
<td>老请求静默失败</td>
</tr>
</tbody></table>
<p><strong>生产建议</strong>：优先实现自定义拒绝策略，记录指标、触发告警、执行降级。</p>
<hr />
<h2>四、任务提交与执行流程</h2>
<pre><code class="language-mermaid">flowchart TD
    A[提交任务]
    A --&gt; B{当前线程数 &lt; corePoolSize?}
    B --&gt;|是| C[创建核心线程并执行]
    B --&gt;|否| D[将任务放入工作队列]
    D --&gt; E{工作队列是否已满?}
    E --&gt;|否| F[任务在队列中等待]
    E --&gt;|是| G{当前线程数 &lt; maximumPoolSize?}
    G --&gt;|是| H[创建非核心线程并执行]
    G --&gt;|否| I[触发拒绝策略]
</code></pre>
<p><strong>关键认知</strong>：只有工作队列满了，线程池才会创建非核心线程。若使用无界队列，<code>maximumPoolSize</code> 永远不会生效。</p>
<hr />
<h2>五、CPU 密集型与 IO 密集型任务</h2>
<p>线程池大小最核心的决策依据是<strong>阻塞比（W/C）</strong>，即等待时间与计算时间的比值。</p>
<h3>1、CPU 密集型</h3>
<p><strong>典型场景</strong>：加密解密、数据压缩、图像处理、复杂排序、JSON 序列化。</p>
<ul>
<li>线程大部分时间占用 CPU 执行指令。</li>
<li>阻塞比 W/C 约等于 0。</li>
<li>线程切换是纯损耗，不会提升吞吐。</li>
<li><strong>线程数建议</strong>：<code>N_cpu + 1</code>。</li>
</ul>
<h3>2、IO 密集型</h3>
<p><strong>典型场景</strong>：数据库查询、HTTP 远程调用、文件读写、消息队列消费。</p>
<ul>
<li>线程大量时间处于 IO 等待状态。</li>
<li>阻塞比 W/C 可达几倍到几十倍。</li>
<li>CPU 空闲期间可调度更多线程，提升资源利用率。</li>
<li><strong>线程数建议</strong>：根据 Goetz 公式估算，并受下游连接池约束。</li>
</ul>
<h3>3、混合型任务</h3>
<p>典型 Web 请求链路示例：</p>
<table>
<thead>
<tr>
<th>阶段</th>
<th>类型</th>
<th>耗时占比（示例）</th>
</tr>
</thead>
<tbody><tr>
<td>接受 HTTP 请求</td>
<td>网络 IO</td>
<td>约 5%</td>
</tr>
<tr>
<td>参数校验 / 反序列化</td>
<td>CPU</td>
<td>约 5%</td>
</tr>
<tr>
<td>查询数据库</td>
<td>IO 等待</td>
<td>约 70%</td>
</tr>
<tr>
<td>业务规则计算</td>
<td>CPU</td>
<td>约 10%</td>
</tr>
<tr>
<td>写 Redis 缓存</td>
<td>IO</td>
<td>约 10%</td>
</tr>
</tbody></table>
<p>混合场景应分段分析，或通过压测统计整体阻塞比，避免直接套用单一公式。</p>
<hr />
<h2>六、线程数估算方法</h2>
<h3>1、Little's Law</h3>
<p>系统稳态下的基本关系：</p>
<pre><code class="language-text">平均并发数 L = 到达率 λ × 平均响应时间 W
</code></pre>
<p>推导出线程数下界：</p>
<pre><code class="language-text">N_threads ≥ λ × T_response
</code></pre>
<p><strong>示例</strong>：峰值 QPS = 500，平均 RT = 200ms = 0.2s，则最低需要 <code>500 × 0.2 = 100</code> 个并发线程维持吞吐。</p>
<h3>2、Brian Goetz 公式</h3>
<p>出处：《Java 并发编程实战》</p>
<pre><code class="language-text">N_threads = N_cpu × U_cpu × (1 + W/C)
</code></pre>
<table>
<thead>
<tr>
<th>参数</th>
<th>含义</th>
<th>获取方式</th>
</tr>
</thead>
<tbody><tr>
<td><code>N_cpu</code></td>
<td>CPU 核心数</td>
<td><code>Runtime.getRuntime().availableProcessors()</code></td>
</tr>
<tr>
<td><code>U_cpu</code></td>
<td>目标 CPU 利用率</td>
<td>建议 0.7~0.85，预留余量给 GC / OS / 其他进程</td>
</tr>
<tr>
<td><code>W/C</code></td>
<td>等待时间 / 计算时间</td>
<td>Profiler 或 APM 统计</td>
</tr>
</tbody></table>
<p><strong>示例</strong>：8 核机器，目标利用率 80%，IO 等待 200ms，计算 20ms。</p>
<ul>
<li>W/C = 200 / 20 = 10</li>
<li>N = 8 × 0.8 × (1 + 10) = 70.4，取 <strong>72</strong></li>
</ul>
<h3>3、公式的局限</h3>
<ol>
<li><strong>阻塞比难以准确测量</strong>：不同流量时段、不同数据量下差异巨大，单次测量值可能误导。</li>
<li><strong>忽略下游瓶颈</strong>：数据库连接池上限 50 时，设 200 个线程只会造成大量线程等待连接，反而劣化延迟。</li>
<li><strong>假设任务同质</strong>：同一池处理轻量查询与重量报表时，平均 W/C 失去意义。</li>
</ol>
<h3>4、估算流程</h3>
<pre><code class="language-text">1. 用 Goetz 公式或 Little's Law 得出理论初始值
2. 对比下游资源上限（DB 连接池、HTTP 连接池），取较小值
3. 结合机器内存校验：线程数 × 1MB（默认栈）不超过可用堆外内存的 50%
4. 以该值作为压测起点，做阶梯加压验证
</code></pre>
<h3>5、常见场景参考值</h3>
<table>
<thead>
<tr>
<th>场景</th>
<th>corePoolSize</th>
<th>maximumPoolSize</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>CPU 密集</td>
<td><code>N_cpu + 1</code></td>
<td><code>N_cpu + 1</code></td>
<td>+1 防止偶发 IO 阻塞</td>
</tr>
<tr>
<td>IO 密集（DB 为主）</td>
<td><code>min(N_cpu × 10, DB 连接池)</code></td>
<td>core × 1.2</td>
<td>严格受下游约束</td>
</tr>
<tr>
<td>IO 密集（HTTP 为主）</td>
<td><code>N_cpu × (1 + W/C)</code></td>
<td>core × 1.5</td>
<td>W/C 需实测</td>
</tr>
<tr>
<td>混合型 Web</td>
<td>实测后取 10~20× 核数</td>
<td>core × 1.25</td>
<td>务必压测验证</td>
</tr>
<tr>
<td>定时任务 / 批处理</td>
<td>2~`N_cpu`</td>
<td>core × 2</td>
<td>避免与业务线程池竞争</td>
</tr>
</tbody></table>
<hr />
<h2>七、工作队列选型</h2>
<table>
<thead>
<tr>
<th>队列类型</th>
<th>容量</th>
<th>行为特征</th>
<th>适配场景</th>
<th>配套线程数策略</th>
</tr>
</thead>
<tbody><tr>
<td><code>LinkedBlockingQueue(n)</code></td>
<td>有界</td>
<td>FIFO，满时触发拒绝策略</td>
<td>通用业务，需要背压</td>
<td>core = max = N，队列缓冲突发</td>
</tr>
<tr>
<td><code>SynchronousQueue</code></td>
<td>0</td>
<td>无缓冲，提交必须立即有线程接收</td>
<td>高吞吐低延迟</td>
<td>max 较大，<code>newCachedThreadPool</code> 底层</td>
</tr>
<tr>
<td><code>ArrayBlockingQueue(n)</code></td>
<td>有界，数组实现</td>
<td>内存局部性好，容量固定</td>
<td>严格控制内存</td>
<td>core 小，max 大，队列满才扩容</td>
</tr>
<tr>
<td><code>PriorityBlockingQueue</code></td>
<td>无界</td>
<td>按优先级出队</td>
<td>任务有优先级差异</td>
<td>必须控制提交速率</td>
</tr>
<tr>
<td><code>DelayQueue</code></td>
<td>无界</td>
<td>到期才出队</td>
<td>延迟任务、定时重试</td>
<td>core = max = 固定小值</td>
</tr>
</tbody></table>
<p><strong>生产红线</strong>：禁止使用 <code>Executors.newFixedThreadPool()</code> 与 <code>Executors.newCachedThreadPool()</code>。</p>
<ul>
<li><code>newFixedThreadPool</code> 使用无界 <code>LinkedBlockingQueue</code>，任务堆积可导致 OOM。</li>
<li><code>newCachedThreadPool</code> 的 <code>maximumPoolSize</code> 为 <code>Integer.MAX_VALUE</code>，突发流量下线程数失控。</li>
</ul>
<hr />
<h2>八、常见反模式</h2>
<h3>1、全局共用一个线程池</h3>
<p>核心链路与非核心链路共享线程池时，非核心任务突发会挤占核心任务资源。</p>
<pre><code class="language-java">// 错误：所有任务共用
@Bean("globalExecutor")
ThreadPoolExecutor global() { ... }

// 正确：按业务隔离
@Bean("paymentExecutor")   // 核心链路
ThreadPoolExecutor payment() { ... }

@Bean("logExecutor")       // 非核心，允许丢弃
ThreadPoolExecutor log() { ... }
</code></pre>
<h3>2、不考虑下游限制盲目设大</h3>
<p>下游 MySQL 连接池上限 50 时，设 500 个线程会导致 450 个线程空等连接，增加上下文切换与排队延迟。</p>
<p><strong>正确做法</strong>：<code>corePoolSize ≤ 下游最小连接池上限</code>。</p>
<h3>3、keepAliveTime 设置过短</h3>
<p>流量波动场景下，<code>keepAliveTime</code> 过短（如 1 秒）会导致非核心线程频繁创建销毁，造成 CPU 与内存抖动。</p>
<p><strong>正确做法</strong>：建议 30~120 秒。</p>
<h3>4、任务中嵌套提交任务</h3>
<pre><code class="language-java">// 危险：父任务等待子任务，子任务无法入队 → 死锁
executor.submit(() -&gt; {
    Future&lt;?&gt; child = executor.submit(() -&gt; { /* 子任务 */ });
    child.get();
});
</code></pre>
<p><strong>正确做法</strong>：使用 <code>ForkJoinPool</code> 处理父子依赖任务，或为子任务使用独立线程池。</p>
<h3>5、容器环境不修正 CPU 核数</h3>
<p>Docker 容器限制 2 核时，旧版 JDK 的 <code>Runtime.getRuntime().availableProcessors()</code> 可能返回宿主机 32 核，导致线程数虚高。</p>
<pre><code class="language-java">// 问题版本
int cores = Runtime.getRuntime().availableProcessors();

// 安全版本：读取容器 CPU 配额
private int getCpuCores() {
    String cpuLimit = System.getenv("CPU_LIMIT");
    if (cpuLimit != null) return Integer.parseInt(cpuLimit);

    try {
        Path quotaPath = Paths.get("/sys/fs/cgroup/cpu/cpu.cfs_quota_us");
        Path periodPath = Paths.get("/sys/fs/cgroup/cpu/cpu.cfs_period_us");
        long quota = Long.parseLong(Files.readString(quotaPath).trim());
        long period = Long.parseLong(Files.readString(periodPath).trim());
        if (quota &gt; 0) return (int) Math.ceil((double) quota / period);
    } catch (Exception ignored) {}

    return Runtime.getRuntime().availableProcessors();
}
</code></pre>
<h3>6、线程未命名</h3>
<p><code>jstack</code> 中 <code>"pool-1-thread-1"</code> 无法判断业务归属，应使用 <code>"order-exec-1"</code> 等命名。</p>
<hr />
<h2>九、企业级配置案例</h2>
<h3>1、需求分析清单</h3>
<p>配置线程池前必须明确：</p>
<ol>
<li>峰值 QPS、平均 RT、P99 RT 是多少？</li>
<li>任务是否存在外部依赖？下游连接池上限是多少？</li>
<li>SLA 要求：能否丢任务？允许多大延迟？</li>
<li>容器 / Pod 分配的 CPU 核数是多少？</li>
</ol>
<h3>2、订单查询服务配置示例</h3>
<p><strong>场景</strong>：8 核 Pod，DB 查询 60ms，本地计算 5ms，W/C ≈ 12，目标 CPU 利用率 80%，DB 连接池上限 50。</p>
<p><strong>计算过程</strong>：</p>
<pre><code class="language-text">Goetz 公式：8 × 0.8 × (1 + 12) = 83.2 → 取 84
约束检查：DB 连接池上限 50 → 线程数不应超过 50
内存检查：50 × 1MB = 50MB，在 2GB Pod 内可接受

最终决策：
  corePoolSize    = 50
  maximumPoolSize = 60
  queue           = LinkedBlockingQueue(300)
  keepAliveTime   = 60 秒
  拒绝策略         = 自定义（记录指标 + 抛异常）
</code></pre>
<p><strong>代码实现</strong>：</p>
<pre><code class="language-java">@Bean("orderExecutor")
public ThreadPoolExecutor orderExecutor() {
    int cores = Runtime.getRuntime().availableProcessors();
    int effectiveCores = Math.min(cores, Integer.parseInt(
        System.getenv().getOrDefault("CPU_LIMIT", String.valueOf(cores))));

    int dbPoolSize = 50;
    int coreSize = Math.min(effectiveCores * 10, dbPoolSize);
    int maxSize = (int) (coreSize * 1.2);
    int queueCap = coreSize * 6;

    ThreadPoolExecutor executor = new ThreadPoolExecutor(
        coreSize, maxSize,
        60L, TimeUnit.SECONDS,
        new LinkedBlockingQueue&lt;&gt;(queueCap),
        new CustomThreadFactory("order-exec"),
        new MetricsRejectedHandler("order")
    );
    executor.allowCoreThreadTimeOut(false);
    return executor;
}
</code></pre>
<p><strong>自定义拒绝处理器</strong>：</p>
<pre><code class="language-java">public class MetricsRejectedHandler implements RejectedExecutionHandler {
    private final String poolName;

    public MetricsRejectedHandler(String poolName) {
        this.poolName = poolName;
    }

    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        Metrics.counter("threadpool.rejected", "pool", poolName).increment();
        log.error("[{}] 任务被拒绝，队列积压:{} 活跃线程:{} 最大线程:{}",
            poolName, e.getQueue().size(), e.getActiveCount(), e.getMaximumPoolSize());
        throw new RejectedExecutionException("Pool " + poolName + " is saturated");
    }
}
</code></pre>
<h3>3、压测验证方案</h3>
<p>阶梯加压节奏：</p>
<pre><code class="language-text">10 并发  → 稳定 2 分钟 → 记录基准
50 并发  → 稳定 2 分钟 → 记录吞吐 / RT / 队列深度
100 并发 → 稳定 2 分钟 → 观察是否积压
150 并发 → 稳定 2 分钟 → 观察 P99 劣化点
200 并发 → 稳定 2 分钟 → 寻找拒绝临界点
</code></pre>
<p>压测期间重点采集：</p>
<pre><code class="language-java">pool.getActiveCount();          // 当前活跃线程数
pool.getQueue().size();         // 排队任务数
pool.getCompletedTaskCount();   // 已完成任务总数
pool.getLargestPoolSize();      // 历史最大线程数
pool.getTaskCount();            // 提交的总任务数
</code></pre>
<hr />
<h2>十、动态线程池</h2>
<h3>1、动态调整原理</h3>
<p><code>ThreadPoolExecutor</code> 支持运行时修改核心参数：</p>
<pre><code class="language-java">executor.setCorePoolSize(newCoreSize);
executor.setMaximumPoolSize(newMaxSize);
</code></pre>
<p>结合 Apollo / Nacos 配置中心可实现秒级热更新。</p>
<h3>2、实现示例</h3>
<pre><code class="language-java">@Component
public class DynamicThreadPoolManager {

    @Autowired
    private ThreadPoolExecutor orderExecutor;

    @NacosConfigListener(dataId = "thread-pool-config", groupId = "DEFAULT_GROUP")
    public void onConfigChange(String configJson) {
        ThreadPoolConfig cfg = JSON.parseObject(configJson, ThreadPoolConfig.class);

        int newCore = cfg.getCoreSize();
        int newMax = cfg.getMaxSize();

        // 扩大时先改 max，缩小时先改 core
        if (newCore &gt; orderExecutor.getMaximumPoolSize()) {
            orderExecutor.setMaximumPoolSize(newMax);
            orderExecutor.setCorePoolSize(newCore);
        } else {
            orderExecutor.setCorePoolSize(newCore);
            orderExecutor.setMaximumPoolSize(newMax);
        }

        log.info("线程池动态调整完成 pool=order core={} max={}", newCore, newMax);
    }
}
</code></pre>
<h3>3、队列容量动态修改</h3>
<p>标准 <code>BlockingQueue</code> 容量在构造时固定。如需动态队列，可选：</p>
<ol>
<li><strong>自实现 <code>ResizableLinkedBlockingQueue</code></strong>：重写 <code>capacity</code> setter，加锁保证并发安全。</li>
<li><strong>开源方案</strong>：<ul>
<li><strong>dynamic-tp</strong>（京东开源）：支持参数热更新、监控、告警一体化。</li>
<li><strong>Hippo4j</strong>（美团团队维护）：企业级动态线程池框架，支持多注册中心。</li>
</ul>
</li>
</ol>
<hr />
<h2>十一、可观测性：监控与告警</h2>
<h3>1、Prometheus 指标暴露</h3>
<pre><code class="language-java">public static void registerToPrometheus(ThreadPoolExecutor pool, String name) {
    MeterRegistry registry = Metrics.globalRegistry;

    Gauge.builder("threadpool.active", pool, ThreadPoolExecutor::getActiveCount)
         .tag("pool", name).description("活跃线程数").register(registry);

    Gauge.builder("threadpool.pool_size", pool, ThreadPoolExecutor::getPoolSize)
         .tag("pool", name).description("当前线程总数").register(registry);

    Gauge.builder("threadpool.queue_size", pool, p -&gt; p.getQueue().size())
         .tag("pool", name).description("队列积压任务数").register(registry);

    Gauge.builder("threadpool.utilization", pool,
             p -&gt; (double) p.getActiveCount() / p.getCorePoolSize())
         .tag("pool", name).description("核心线程利用率").register(registry);

    Gauge.builder("threadpool.largest_pool_size", pool, ThreadPoolExecutor::getLargestPoolSize)
         .tag("pool", name).description("历史最大线程数").register(registry);
}
</code></pre>
<h3>2、AlertManager 告警规则</h3>
<pre><code class="language-yaml">groups:
  - name: threadpool_alerts
    rules:
      - alert: ThreadPoolHighUtilization
        expr: threadpool_utilization &gt; 0.85
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "线程池 {{ $labels.pool }} 利用率超过 85%"

      - alert: ThreadPoolQueueBacklog
        expr: threadpool_queue_size / threadpool_queue_capacity &gt; 0.7
        for: 1m
        labels:
          severity: warning
        annotations:
          summary: "线程池 {{ $labels.pool }} 队列积压超过 70%"

      - alert: ThreadPoolRejection
        expr: increase(threadpool_rejected_total[1m]) &gt; 0
        for: 0m
        labels:
          severity: critical
        annotations:
          summary: "线程池 {{ $labels.pool }} 出现任务拒绝"

      - alert: ThreadPoolMaxSizeReached
        expr: threadpool_pool_size &gt;= threadpool_max_size
        for: 3m
        labels:
          severity: warning
        annotations:
          summary: "线程池 {{ $labels.pool }} 线程数触达最大值，持续 3 分钟"
</code></pre>
<h3>3、告警阈值参考</h3>
<table>
<thead>
<tr>
<th>指标</th>
<th>Warning</th>
<th>Critical</th>
<th>含义</th>
</tr>
</thead>
<tbody><tr>
<td>活跃线程 / 核心线程数</td>
<td>&gt; 80%</td>
<td>&gt; 95%</td>
<td>线程饱和，即将排队</td>
</tr>
<tr>
<td>队列积压量</td>
<td>&gt; 队列容量 50%</td>
<td>&gt; 80%</td>
<td>消费跟不上，延迟上涨</td>
</tr>
<tr>
<td>被拒绝任务数（1 分钟）</td>
<td>&gt; 0</td>
<td>&gt; 10</td>
<td>系统过载</td>
</tr>
<tr>
<td>线程数触达 maximumPoolSize</td>
<td>持续 1 分钟</td>
<td>持续 5 分钟</td>
<td>需要扩容或限流</td>
</tr>
</tbody></table>
<hr />
<h2>十二、性能调优建议</h2>
<h3>1、线程数调优</h3>
<ul>
<li><strong>初始值</strong>：使用 Goetz 公式或历史 QPS/RT 数据估算。</li>
<li><strong>约束校验</strong>：确保 <code>corePoolSize ≤ min(DB 连接池, HTTP 连接池, 内存可承载线程数)</code>。</li>
<li><strong>压测验证</strong>：以阶梯加压找到吞吐拐点，将 <code>corePoolSize</code> 设置在拐点并发量的 70%~80%。</li>
<li><strong>动态调整</strong>：流量波动明显的服务接入动态线程池。</li>
</ul>
<h3>2、队列深度调优</h3>
<ul>
<li>队列容量不宜过大：过大会隐藏延迟问题，导致 P99 劣化。</li>
<li>经验公式：<code>queueCapacity = corePoolSize × 平均 RT（秒） × 安全系数（2~3）</code>。</li>
<li>需要背压的场景使用有界队列，拒绝策略选择 <code>CallerRunsPolicy</code> 或自定义策略。</li>
</ul>
<h3>3、GC 与内存调优</h3>
<ul>
<li>关注线程栈内存占用：默认 1MB/线程，可通过 <code>-Xss</code> 调整。</li>
<li>高频创建/销毁线程会增加 Native Memory 分配压力，尽量复用线程。</li>
<li>容器环境开启 <code>-XX:+UseContainerSupport</code>（JDK 8u191+）。</li>
</ul>
<h3>4、上下文切换调优</h3>
<ul>
<li>当 <code>cs（上下文切换次数）/ 任务数</code> 持续升高时，说明线程数过多。</li>
<li>使用 <code>vmstat</code>、<code>pidstat -w</code> 监控上下文切换频率。</li>
<li>在线程饱和前扩容机器或优化任务计算逻辑。</li>
</ul>
<hr />
<h2>十三、常见问题解答（FAQ）</h2>
<h3>1、Q1：<code>corePoolSize</code> 和 <code>maximumPoolSize</code> 应该设成一样吗？</h3>
<p><strong>A</strong>：不一定。固定大小（core = max）适合负载稳定的场景，实现简单；弹性伸缩（core &lt; max）适合流量波动大、需要应对突发的场景。生产环境更推荐后者，配合有界队列使用。</p>
<h3>2、Q2：为什么线程池达到了 <code>maximumPoolSize</code> 但 CPU 使用率仍然很低？</h3>
<p><strong>A</strong>：可能原因：</p>
<ol>
<li>线程大部分时间阻塞在 IO 等待，CPU 未被有效利用；</li>
<li>下游服务（数据库、缓存、HTTP 接口）成为瓶颈；</li>
<li>锁竞争严重，线程实际并行度不足。</li>
</ol>
<p>应通过 APM 或 Profiler 定位具体阻塞点，而不是简单增加线程数。</p>
<h3>3、Q3：任务被拒绝时应该选择哪种策略？</h3>
<p><strong>A</strong>：核心链路推荐 <code>AbortPolicy</code> 或自定义策略（记录指标 + 抛异常），让调用方感知并降级；非核心但不可丢任务的场景可选 <code>CallerRunsPolicy</code>；可丢弃的非关键任务可选 <code>DiscardPolicy</code> 或 <code>DiscardOldestPolicy</code>。</p>
<h3>4、Q4：使用 <code>CompletableFuture</code> 时如何指定自定义线程池？</h3>
<p><strong>A</strong>：<code>CompletableFuture</code> 默认使用 <code>ForkJoinPool.commonPool()</code>，可能不适合业务场景。应显式传入：</p>
<pre><code class="language-java">CompletableFuture.supplyAsync(() -&gt; fetchOrder(orderId), orderExecutor)
    .thenApplyAsync(this::enrichOrder, orderExecutor);
</code></pre>
<h3>5、Q5：Spring 的 <code>@Async</code> 默认线程池有什么问题？</h3>
<p><strong>A</strong>：Spring 默认使用 <code>SimpleAsyncTaskExecutor</code>，每次任务都新建线程，且队列无界。生产环境应通过 <code>ThreadPoolTaskExecutor</code> 自定义：</p>
<pre><code class="language-java">@Bean("taskExecutor")
public ThreadPoolTaskExecutor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(16);
    executor.setMaxPoolSize(32);
    executor.setQueueCapacity(200);
    executor.setThreadNamePrefix("async-");
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    executor.initialize();
    return executor;
}
</code></pre>
<h3>6、Q6：如何优雅关闭线程池？</h3>
<p><strong>A</strong>：使用 <code>shutdown()</code> + <code>awaitTermination()</code> 组合：</p>
<pre><code class="language-java">executor.shutdown();
try {
    if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
        executor.shutdownNow();
    }
} catch (InterruptedException e) {
    executor.shutdownNow();
    Thread.currentThread().interrupt();
}
</code></pre>
<h3>7、Q7：<code>allowCoreThreadTimeOut(true)</code> 是否推荐？</h3>
<p><strong>A</strong>：适合流量波动极大的场景，可在低峰期回收核心线程节省资源。但会增加高峰期线程创建开销，核心链路建议保持默认 <code>false</code>。</p>
<hr />
<h2>十四、决策速查表</h2>
<table>
<thead>
<tr>
<th>场景</th>
<th>corePoolSize</th>
<th>maximumPoolSize</th>
<th>队列</th>
<th>拒绝策略</th>
</tr>
</thead>
<tbody><tr>
<td>CPU 密集</td>
<td><code>N_cpu + 1</code></td>
<td><code>N_cpu + 1</code></td>
<td>有界或小容量</td>
<td><code>AbortPolicy</code></td>
</tr>
<tr>
<td>IO 密集（有下游限制）</td>
<td><code>min(公式值, 下游连接池)</code></td>
<td>core × 1.2</td>
<td>有界</td>
<td>自定义 + 指标</td>
</tr>
<tr>
<td>高吞吐低延迟</td>
<td><code>N_cpu × 2</code></td>
<td>较大</td>
<td><code>SynchronousQueue</code></td>
<td><code>CallerRunsPolicy</code></td>
</tr>
<tr>
<td>批处理 / 定时任务</td>
<td>2~`N_cpu`</td>
<td>core × 2</td>
<td>有界</td>
<td><code>CallerRunsPolicy</code></td>
</tr>
<tr>
<td>混合型 Web</td>
<td>实测后 10~20× 核数</td>
<td>core × 1.25</td>
<td>有界</td>
<td>自定义 + 指标</td>
</tr>
</tbody></table>
<hr />
<h2>十五、总结</h2>
<ol>
<li><strong>永远不用 <code>Executors</code> 工厂方法</strong>，必须显式构造 <code>ThreadPoolExecutor</code>。</li>
<li><strong>必须使用有界队列</strong>，无界队列是 OOM 隐患。</li>
<li><strong>corePoolSize 不超过下游最小连接池</strong>，这是物理约束。</li>
<li><strong>拒绝策略必须可观测</strong>，静默丢弃在生产环境等于数据黑洞。</li>
<li><strong>暴露监控指标并设置告警</strong>，线程池问题不能靠感觉发现。</li>
</ol>
<p>合理工程流程：</p>
<pre><code class="language-mermaid">flowchart LR
    A[公式估算] --&gt; B[约束校验] --&gt; C[压测验证] --&gt; D[监控落地] --&gt; E[动态调整]
</code></pre>
<hr />
<h2>十六、参考资料</h2>
<ul>
<li>《Java 并发编程实战》Brian Goetz</li>
<li>阿里巴巴《Java 开发手册》</li>
<li><a href="https://github.com/dromara/dynamic-tp">dynamic-tp</a></li>
<li><a href="https://github.com/opengoofy/hippo4j">Hippo4j</a></li>
</ul>
]]></content:encoded></item></channel></rss>