[{"content":"写在前面 为简化表达，本文使用cc代指Claude Code。希望另一个cc看完以后也能有所收获🤪。\n带有Note标记的链接是十分推荐阅读学习的，比如下面的官方文档，本文的很多内容也是直接取自官方文档。\n📝 备注 官方文档（支持中文）\n标题中带有【重点】的章节，请认真阅读学习。\n什么是Claude Code Claude Code 是 Anthropic 官方推出的 AI 编程助手，它是一个可以在终端中直接运行的命令行工具。与传统的 AI 聊天工具不同，Claude Code 具备以下特点：\n深度集成开发工作流 — 直接在终端中运行，可以读取、编辑文件，执行命令 强大的代码理解能力 — 基于 Claude 3.5/4.6 等大模型，理解复杂代码结构 MCP 协议支持 — 通过 Model Context Protocol 扩展能力，连接外部工具和服务 持久化会话 — 对话记录本地保存，随时恢复继续讨论 灵活的权限控制 — 多种权限模式适应不同使用场景 CLI命令 这部分命令在终端中直接执行。\n以下仅列出常用命令，查看完整参考。\n命令 描述 示例 claude update 更新到最新版本 claude \u0026ndash;version, -v 输出版本号 claude \u0026ndash;continue, -c 继续当前目录中最近的对话 claude \u0026ndash;resume, -r 按 ID 或名称恢复特定会话，或显示交互式选择器以选择会话 claude \u0026ndash;resume auth-refactor claude mcp list 列出所有配置的MCP服务器 claude \u0026ndash;permission-mode 以指定的 权限模式 开始 claude \u0026ndash;permission-mode plan claude \u0026ndash;name, -n 为会话设置显示名称，显示在 /resume 和终端标题中。您可以使用 claude --resume \u0026lt;name\u0026gt; 恢复命名会话。 claude -n \u0026ldquo;my-feature-work\u0026rdquo; 交互模式 这部分命令需要在终端中启动cc后，在cc交互页面中执行。\n以下仅列出常用命令，完整参考请查看对应链接。\n快速命令 完整参考\n符号 描述 示例 ? 查看当前环境中可用的快捷键 @ 感知：将文件/资源注入上下文 解释 @src/auth.ts 的逻辑 ! 行动：在提示框中直接执行 Shell ! git log \u0026ndash;oneline -5（结果注入上下文） 💡 提示 输入!执行持续性命令后，想再继续对话，需要敲击Esc键。若想后台运行命令，可使用以下方式：\n提示 Claude Code 在后台运行命令\n按 Ctrl+B 将常规 Bash 工具调用移到后台\n在命令的最后添加 \u0026amp;\n内置命令 完整参考\n以下各表中，\u0026lt;arg\u0026gt; 表示必需的参数，[arg] 表示可选参数。\n设置与配置 命令 描述 /help 显示帮助和可用命令 /model [model] 选择或更改 AI 模型。对于支持的模型，使用左/右箭头调整努力级别。更改立即生效，无需等待当前响应完成 /status 打开设置界面（状态选项卡），显示版本、模型、账户和连接性 /config 打开设置界面以调整主题、模型、输出样式和其他首选项。别名：/settings /permissions 查看或更新权限。别名：/allowed-tools /mcp 管理 MCP server 连接和 OAuth 身份验证 /skills 列出可用的 skills /agents 管理 agent 配置 /plugin 管理 Claude Code plugins /hooks 管理工具事件的 hook 配置 会话相关 命令 描述 /rename [name] 重命名当前会话。不使用名称时，从对话历史自动生成 /add-dir 将新的工作目录添加到当前会话 /resume [session] 按 ID 或名称恢复对话，或打开会话选择器。别名：/continue /init 使用 CLAUDE.md 指南初始化项目 /compact [instructions] 压缩对话，可选的焦点说明 /clear 清除对话历史并释放上下文。别名：/reset、/new /export [filename] 将当前对话导出为纯文本。使用文件名时，直接写入该文件。不使用时，打开对话框以复制到剪贴板或保存到文件 /plan 直接从提示进入 plan mode /tasks 列出和管理后台任务 /diff 打开交互式差异查看器，显示未提交的更改和每轮差异。使用左/右箭头在当前 git 差异和单个 Claude 轮次之间切换，使用上/下浏览文件 /btw 提出快速附加问题，无需添加到对话中 使用情况 命令 描述 /context 将当前上下文使用情况可视化为彩色网格 /cost 显示令牌使用统计信息 /stats 可视化每日使用情况、会话历史、连胜和模型偏好 其他 命令 描述 /doctor 诊断并验证您的 Claude Code 安装和设置 /exit 退出 CLI。别名：/quit 键盘快捷键 完整参考\n常规控制 快捷键 描述 上下文 Ctrl+C 取消当前输入或生成 标准中断 Ctrl+F 终止所有后台代理。在 3 秒内按两次以确认 后台代理控制 Ctrl+D 退出 Claude Code 会话 EOF 信号 Ctrl+G 在默认文本编辑器中打开 在默认文本编辑器中编辑您的提示或自定义响应 Ctrl+L 清除终端屏幕 保留对话历史 Ctrl+O 切换详细输出 显示详细的工具使用和执行情况 Ctrl+R 反向搜索命令历史 交互式搜索以前的命令 Ctrl+B 后台运行任务 后台运行 bash 命令和代理。Tmux 用户按两次 Ctrl+T 切换任务列表 在终端状态区域中显示或隐藏任务列表 Up/Down arrows 导航命令历史 回忆以前的输入 Esc + Esc 回退或总结 将代码和/或对话恢复到上一个点，或从选定的消息进行总结 Shift+Tab 或 Alt+M（某些配置） 切换权限模式 在自动接受模式、Plan Mode 和正常模式之间切换。 文本编辑 快捷键 描述 上下文 Ctrl+K 删除到行尾 存储已删除的文本以供粘贴 Ctrl+U 删除整行 存储已删除的文本以供粘贴 Ctrl+Y 粘贴已删除的文本 粘贴用 Ctrl+K 或 Ctrl+U 删除的文本 多行输入 快捷键 上下文 方法 \\ + Enter 在所有终端中工作 快速转义 Ctrl+J 多行的换行符 控制序列 配置 设置 完整参考\n权限模式 Claude Code 支持多种权限模式来控制工具的批准方式。在您的设置文件中设置 defaultMode：\n模式 描述 default 标准行为：在首次使用每个工具时提示权限 acceptEdits 自动接受会话的文件编辑权限 plan Plan Mode：Claude 可以分析但不能修改文件或执行命令 dontAsk 自动拒绝工具，除非通过 /permissions 或 permissions.allow 规则预先批准 bypassPermissions 跳过所有权限提示（需要安全环境，请参见下面的警告） 自定义状态行 状态行是 Claude Code 底部的可自定义栏，可以运行你配置的任何 shell 脚本。它通过 stdin 接收 JSON 会话数据，并显示你的脚本打印的任何内容，为你提供一个持久的、一目了然的上下文使用情况、成本、git 状态或任何其他你想跟踪的内容的视图。\nhttps://code.claude.com/docs/zh-CN/statusline\nPlugin组件 plugin 是一个自包含的组件目录，用于扩展 Claude Code 的自定义功能，包括 Skills、Agents、Hooks、MCP servers 和 LSP servers。\nSkills Skills 扩展了 Claude 能做的事情，skill是一个对外提供功能的“能力单元”，接收输入，执行逻辑，返回结果。\nhttps://code.claude.com/docs/zh-CN/skills\nAgents Subagents 是处理特定类型任务的专门 AI 助手。每个 subagent 在自己的 context window 中运行，具有自定义系统提示、特定的工具访问权限和独立的权限。当 Claude 遇到与 subagent 描述相匹配的任务时，它会委托给该 subagent，该 subagent 独立工作并返回结果。\nhttps://code.claude.com/docs/zh-CN/sub-agents\nHooks Hooks 是用户定义的 shell 命令、HTTP 端点或 LLM 提示，在 Claude Code 生命周期中的特定点自动执行。\nhttps://code.claude.com/docs/zh-CN/hooks-guide\nhttps://code.claude.com/docs/zh-CN/hooks\nMCP servers Plugins 可以捆绑 Model Context Protocol (MCP) servers 以将 Claude Code 与外部工具和服务连接。\n连接 MCP 服务器后，您可以要求 Claude Code：\n从问题跟踪器实现功能：“添加 JIRA 问题 ENG-4521 中描述的功能，并在 GitHub 上创建 PR。” 分析监控数据：“检查 Sentry 和 Statsig 以检查 ENG-4521 中描述的功能的使用情况。” 查询数据库：“根据我们的 PostgreSQL 数据库，查找使用功能 ENG-4521 的 10 个随机用户的电子邮件。” 集成设计：“根据在 Slack 中发布的新 Figma 设计更新我们的标准电子邮件模板” 自动化工作流：“创建 Gmail 草稿，邀请这 10 个用户参加关于新功能的反馈会议。“ https://code.claude.com/docs/zh-CN/mcp\nLSP servers Plugins 可以提供Language Server Protocol (LSP) servers，在处理代码库时为 Claude 提供实时代码智能。\nhttps://code.claude.com/docs/zh-CN/plugins-reference#lsp-servers\n实用仓库 ccusage https://github.com/ryoppippi/ccusage\nA CLI tool for analyzing Claude Code/Codex CLI usage from local JSONL files.\nopcode https://github.com/winfunc/opcode\nA powerful GUI app and Toolkit for Claude Code - Create custom agents, manage interactive Claude Code sessions, run secure background agents, and more.\neverything-claude-code https://github.com/affaan-m/everything-claude-code\nThe agent harness performance optimization system. Skills, instincts, memory, security, and research-first development for Claude Code, Codex, Opencode, Cursor and beyond.\nsuperpowers https://github.com/obra/superpowers\nAn agentic skills framework \u0026amp; software development methodology that works.\nclaude-code-tips https://github.com/ykdojo/claude-code-tips\n45 tips for getting the most out of Claude Code, from basics to advanced - includes a custom status line script, cutting the system prompt in half, using Gemini CLI as Claude Code\u0026rsquo;s minion, and Claude Code running itself in a container. Also includes the dx plugin.\nskills https://github.com/anthropics/skills\nPublic repository for Agent Skills.\n最佳实践 编写有效的 CLAUDE.md 运行 /init 根据你的当前项目结构生成启动 CLAUDE.md 文件，然后随时间精化。\nCLAUDE.md 是一个特殊文件，Claude 在每次对话开始时读取。包括 Bash 命令、代码风格和工作流规则。这给 Claude 提供了它无法从代码中推断的持久上下文。/init 命令分析你的代码库以检测构建系统、测试框架和代码模式，为你提供坚实的基础来精化。CLAUDE.md 文件没有必需的格式，但保持简短和易读。例如：\nCLAUDE.md\n1 2 3 4 5 6 7 # Code style - Use ES modules (import/export) syntax, not CommonJS (require) - Destructure imports when possible (eg. import { foo } from \u0026#39;bar\u0026#39;) # Workflow - Be sure to typecheck when you\u0026#39;re done making a series of code changes - Prefer running single tests, and not the whole test suite, for performance CLAUDE.md 在每个会话中加载，所以只包括广泛适用的东西。对于仅有时相关的域知识或工作流，改用 skills。Claude 按需加载它们，不会使每次对话都膨胀。保持简洁。对于每一行，问自己：“删除这个会导致 Claude 犯错吗？” 如果不会，删除它。膨胀的 CLAUDE.md 文件会导致 Claude 忽略你的实际指令！\n✅ 包括 ❌ 排除 Claude 无法猜测的 Bash 命令 Claude 可以通过读取代码弄清楚的任何东西 与默认值不同的代码风格规则 Claude 已经知道的标准语言约定 测试指令和首选测试运行器 详细的 API 文档（改为链接到文档） 存储库礼仪（分支命名、PR 约定） 经常变化的信息 特定于你的项目的架构决策 长解释或教程 开发者环境怪癖（必需的环境变量） 自明的实践，如”编写干净的代码” 常见陷阱或非显而易见的行为 文件逐个描述代码库 如果 Claude 继续做你不想要的事情，尽管有反对的规则，该文件可能太长，规则被遗漏了。如果 Claude 问你在 CLAUDE.md 中回答的问题，措辞可能不明确。像对待代码一样对待 CLAUDE.md：当事情出错时审查它，定期修剪它，并通过观察 Claude 的行为是否实际改变来测试更改。你可以通过添加强调（例如”IMPORTANT”或”YOU MUST”）来调整指令以改进遵守。将文件检入 git，以便你的团队可以贡献。该文件随时间增加价值。CLAUDE.md 文件可以使用 @path/to/import 语法导入其他文件：\nCLAUDE.md\n1 2 3 4 5 See @README.md for project overview and @package.json for available npm commands. # Additional Instructions - Git workflow: @docs/git-instructions.md - Personal overrides: @~/.claude/my-project-instructions.md 你可以在多个位置放置 CLAUDE.md 文件：\n主文件夹（~/.claude/CLAUDE.md）：适用于所有 Claude 会话 项目根目录（./CLAUDE.md）：检入 git 以与你的团队共享 父目录：对于 monorepos 有用，其中 root/CLAUDE.md 和 root/foo/CLAUDE.md 都会自动拉入 子目录：当处理这些目录中的文件时，Claude 按需拉入子 CLAUDE.md 文件 有效沟通 你与 Claude Code 沟通的方式显著影响结果的质量。\n提出代码库问题 问 Claude 你会问资深工程师的问题。\n当加入新代码库时，使用 Claude Code 进行学习和探索。你可以问 Claude 你会问另一个工程师的相同类型的问题：\n日志如何工作？ 我如何创建新的 API 端点？ foo.rs 第 134 行的 async move { ... } 做什么？ CustomerOnboardingFlowImpl 处理哪些边界情况？ 为什么这段代码在第 333 行调用 foo() 而不是 bar()？ 以这种方式使用 Claude Code 是一个有效的入职工作流，改进了加入时间并减少了对其他工程师的负担。无需特殊提示：直接提问。\n让 Claude 采访你【重点】 对于更大的功能，让 Claude 先采访你。从最小的提示开始，要求 Claude 使用 AskUserQuestion 工具采访你。\nClaude 会问你可能还没有考虑过的东西，包括技术实现、UI/UX、边界情况和权衡。\n1 2 3 4 5 I want to build [brief description]. Interview me in detail using the AskUserQuestion tool. Ask about technical implementation, UI/UX, edge cases, concerns, and tradeoffs. Don\u0026#39;t ask obvious questions, dig into the hard parts I might not have considered. Keep interviewing until we\u0026#39;ve covered everything, then write a complete spec to SPEC.md. 一旦规范完成，启动新会话来执行它。新会话有干净的 context，完全专注于实现，你有一个书面规范可以参考。\n管理你的会话 对话是持久的和可逆的。利用这一点！\n尽早且经常改正方向 一旦你注意到 Claude 偏离轨道，立即改正它。\n最好的结果来自紧密的反馈循环。虽然 Claude 有时会在第一次尝试时完美地解决问题，但快速改正它通常会更快地产生更好的解决方案。\nEsc：使用 Esc 键在中途停止 Claude。Context 被保留，所以你可以重定向。 Esc + Esc 或 /rewind：按 Esc 两次或运行 /rewind 来打开 rewind 菜单并恢复之前的对话和代码状态，或从选定的消息进行总结。 \u0026quot;撤销那个\u0026quot;：让 Claude 恢复其更改。 /clear：在不相关的任务之间重置 context。长会话与无关的 context 可能会降低性能。 如果你在一个会话中对同一问题改正了 Claude 两次以上，context 就充满了失败的方法。运行 /clear 并使用更具体的提示重新开始，该提示包含你学到的东西。干净的会话与更好的提示几乎总是优于长会话与累积的改正。\n积极管理 context【重点】 在不相关的任务之间频繁运行 /clear 来重置 context。\n当你接近 context 限制时，Claude Code 会自动压缩对话历史，这保留了重要的代码和决策，同时释放空间。在长会话中，Claude 的 context window 可能会充满无关的对话、文件内容和命令。这可能会降低性能，有时会分散 Claude 的注意力。\n在任务之间频繁使用 /clear 来完全重置 context window 当自动压缩触发时，Claude 总结最重要的东西，包括代码模式、文件状态和关键决策 为了更多控制，运行 /compact \u0026lt;instructions\u0026gt;，如 /compact Focus on the API changes 要仅压缩对话的一部分，使用 Esc + Esc 或 /rewind，选择消息检查点，并选择 从这里总结。这会压缩从该点开始的消息，同时保持早期 context 完整。 在 CLAUDE.md 中使用像 \u0026quot;When compacting, always preserve the full list of modified files and any test commands\u0026quot; 这样的指令来自定义压缩行为，以确保关键 context 在总结中存活 对于不需要留在 context 中的快速问题，使用 /btw。答案出现在可关闭的覆盖层中，永远不会进入对话历史，所以你可以检查细节而不增加 context。 使用 subagents 进行调查 使用 \u0026quot;use subagents to investigate X\u0026quot; 委托研究。它们在单独的 context 中探索，为实现保持你的主对话干净。\n由于 context 是你的基本约束，subagents 是可用的最强大的工具之一。当 Claude 研究代码库时，它读取许多文件，所有这些都消耗你的 context。Subagents 在单独的 context windows 中运行并报告摘要：\n1 2 Use subagents to investigate how our authentication system handles token refresh, and whether we have any existing OAuth utilities I should reuse. subagent 探索代码库、读取相关文件并报告发现，所有这些都不会使你的主对话混乱。你也可以在 Claude 实现某些东西后使用 subagents 进行验证：\n1 use a subagent to review this code for edge cases 使用检查点进行 Rewind Claude 进行的每个操作都会创建一个检查点。你可以将对话、代码或两者恢复到任何之前的检查点。\nClaude 在更改前自动检查点。双击 Escape 或运行 /rewind 来打开 rewind 菜单。你可以仅恢复对话、仅恢复代码、恢复两者或从选定的消息进行总结。有关详细信息，请参阅 Checkpointing。与其仔细规划每一步，你可以告诉 Claude 尝试一些冒险的事情。如果不起作用，rewind 并尝试不同的方法。检查点在会话中持续，所以你可以关闭你的终端并稍后仍然 rewind。\n检查点仅跟踪 Claude 进行的更改，不跟踪外部进程。这不是 git 的替代品。\n恢复对话 运行 claude --continue 来继续你离开的地方，或 --resume 来从最近的会话中选择。\nClaude Code 在本地保存对话。当任务跨越多个会话时，你不必重新解释 context：\n1 2 claude --continue # Resume the most recent conversation claude --resume # Select from recent conversations 使用 /rename 给会话起描述性名称，如 \u0026quot;oauth-migration\u0026quot; 或 \u0026quot;debugging-memory-leak\u0026quot;，以便你稍后可以找到它们。像对待分支一样对待会话：不同的工作流可以有单独的、持久的 context。\n处理大型输入 处理大量代码或长指令时：\n避免直接粘贴：Claude Code 可能难以处理非常长的粘贴内容 使用基于文件的工作流：将内容写入文件并要求 Claude 读取它 注意 VS Code 的限制：VS Code 终端特别容易截断长粘贴 常见错误和解决方法 1、API Error: The model has reached its context window limit. 达到上下文窗口限制。\n使用/clear清空当前会话的上下文，但这会导致cc不再记得之前聊过什么，可能会影响它对项目的理解。\n也可以先使用/compact压缩上下文，直到无法再压缩后，再使用/clear。\n2、Unable to connect 检查 .claude.json 文件的 hasCompletedOnboarding 参数，该参数含义为是否已完成新手引导，配置为true时可跳过登录等环节。\nMacOS \u0026amp; Linux 为 ~/.claude.json， Windows 为用户目录/.claude.json。\n⚠️ 警告 该参数需要配置在最高层级。\n1 2 3 { \u0026#34;hasCompletedOnboarding\u0026#34;: true } 3、Usage Policy 暂不清楚是何原因导致的，但是换种问法，就能正常继续对话。\n附录 📝 备注 🔗https://zhuanlan.zhihu.com/p/2009744974980331332\n📝 备注 🔗https://code.claude.com/docs/zh-CN/best-practices\n","date":"2026-02-08T10:00:00+08:00","permalink":"/posts/ai/claude-code-guide/","title":"Claude Code 使用指南"},{"content":".NET 里很少有哪个类型像 HttpClient 这样：API 简单到五分钟就能上手，却又被全社区的工程师集体用错了十年。它的每一个“坑”——socket 耗尽、DNS 不刷新、超时分不清——背后其实都是同一个设计张力的不同投影：\nHTTP 连接是昂贵资源，必须复用；而复用又与世界的变化（DNS、故障、超时）天然冲突。\n理解了这层张力，HttpClient 的“前世今生”就不再是一堆要背诵的最佳实践，而是一条清晰的演进脉络：从无脑 new、到静态单例、到 IHttpClientFactory、再到 .NET 8 的标准韧性管线，每一步都是在重新回答“怎么既复用、又保鲜”。\n一、前世：using (var client = new HttpClient()) 为什么是反模式 先看那段几乎每个 .NET 新手都写过的代码：\n1 2 3 4 5 public async Task\u0026lt;string\u0026gt; GetAsync(string url) { using var client = new HttpClient(); // ← 灾难的起点 return await client.GetStringAsync(url); } 它看起来人畜无害，但在高并发下，服务会以 Unable to connect to the remote server / SocketException 集体阵亡。根因不是 HttpClient 有 bug，而是你误用了 TCP。\nTCP 连接关闭后并不会立刻消失。 主动关闭方会进入 TIME_WAIT 状态，持续约 2×MSL（Linux 约 60 秒，Windows 约 240 秒），目的是让网络上残留的报文自然消亡，保证同一个四元组（源 IP:源端口 → 目的 IP:目的端口）能被安全复用。\n问题在于：new HttpClient() + Dispose 会把底层 TCP 连接主动关闭，于是每来一个请求，你就消耗一个临时源端口、把它踢进 TIME_WAIT。而操作系统的临时端口范围是有限的（Linux 约 32768–60999，Windows 约 49152–65535，几万个）。在每秒成百上千请求的压力下，几分钟内端口就会被耗尽——新连接无源端口可用，于是报 SocketException。\n这是 .NET 团队当年在官方文档里专门加警告的著名陷阱。Dispose 不但没帮你，反而加速了耗尽，因为它把本该复用的连接主动掐断了。\n排障信号：netstat -an | findstr TIME_WAIT（Windows）或 ss -tan | grep TIME-WAIT | wc -l（Linux）能看到成千上万的 TIME_WAIT 指向同一个目的端——就是这个反模式的铁证。\n二、第一次救赎：静态单例与 DNS 陈旧 既然不能每次 new，那就复用——进程内共享一个静态 HttpClient：\n1 private static readonly HttpClient _client = new(); socket 耗尽立刻消失，因为连接被池化复用、不再频繁关闭。但这套方案运行一段时间后，会在某些场景下出现一种更隐蔽的故障：服务偶尔连不上，且恰好发生在对端故障转移之后。\n这就是 DNS 陈旧（DNS staleness）。HttpClient 一旦建立连接，就会把 DNS 解析出的 IP 缓存住，此后一直复用这个 IP。当对端服务做了故障转移、蓝绿切换、K8s Pod 重建（IP 变了），你的 client 还在傻傻地连旧 IP，于是请求超时或被拒。\n于是我们陷入了一个死结：\n复用连接 → socket 不耗尽，但 DNS 不刷新； 每次新建 → DNS 是新的，但 socket 耗尽。 这是 HttpClient 演进的核心驱动力——如何在“连接复用”和“端点保鲜”之间找到平衡。后续所有方案（IHttpClientFactory、SocketsHttpHandler.PooledConnectionLifetime）本质上都在回答这一个问题。\n三、原理：HttpClient 其实是个“门面” 要看懂后面的方案，先得看清 HttpClient 的分层。它本身几乎是空的——真正干活的是它持有的 HttpMessageHandler：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 HttpClient ← 薄薄的门面（Facade），公开 GetAsync/PostAsync │ ▼ HttpMessageHandler 管线 ← 真正的架构在这里 │ DelegatingHandler：日志 / 追踪 ▼ │ DelegatingHandler：韧性（重试 / 熔断 / 超时） ▼ │ DelegatingHandler：认证 / 签名 ▼ SocketsHttpHandler（主处理器） ← 连接池、HTTP/2、HTTP/3，真正收发字节 │ ▼ TCP / HTTP/2 / HTTP/3 这张图藏着三层关键设计：\nHttpClient 是门面，不是实现。 你调用的 GetAsync 只是把请求顺着一条 handler 链往下传，最后由“主处理器”（primary handler）真正发到网络上。这解释了为什么“换一个 handler”就能换一套行为——比如测试时换成 mock handler，连线都不用发。\nDelegatingHandler 就是出站中间件。 它和 ASP.NET Core 的入站中间件是同一个思想，只不过方向相反——请求在这里被一层层加工：加日志、加重试、加 Authorization 头。横向关注点（cross-cutting concerns）从业务代码里剥离，干净地组合进管线。\n连接池不在 HttpClient 里，而在主处理器里。 这是最容易被忽略、却最关键的一点：HttpClient 是廉价的、可随意创建的；昂贵的是它背后的 handler 和连接池。 所以“复用 HttpClient”真正要复用的，是 handler。这一认知直接决定了后面所有的正确用法。\n四、SocketsHttpHandler：现代连接池的真正主角 从 .NET Core 2.1 起，所有平台的默认主处理器都是 SocketsHttpHandler（在此之前是基于各平台原生栈的 HttpClientHandler）。它用纯托管代码重写了传输层，带来几个关键能力：\n连接池化：通过 PooledConnectionLifetime、PooledConnectionIdleTimeout、MaxConnectionsPerServer 精细控制连接的生命周期与并发上限。 HTTP/2 多路复用：一个 TCP 连接上跑多条并发流，连接不再是“一个请求一个”的粗粒度资源，连接池的数学模型因此改变。 HTTP/3 (QUIC)：.NET 6+ 支持（需显式开启），基于 UDP，把传输层握手与队头阻塞问题一并优化。 低分配：基于 Span\u0026lt;T\u0026gt; / Memory\u0026lt;T\u0026gt; 的实现，请求路径上的内存分配大幅下降。 而 PooledConnectionLifetime 正是解开第二节那个死结的钥匙：它让池化连接按固定周期轮换。 连接不再“活到天荒地老”（默认 InfiniteTimeSpan，这正是 DNS 陈旧的根源），而是每隔一段时间（比如 5–15 分钟）被关闭、重建——重建时自然重新解析 DNS。于是“复用”与“保鲜”第一次被调和：\n1 2 3 4 5 6 // 现代“静态单例”的正确写法：复用 client，但让连接定期轮换以刷新 DNS private static readonly SocketsHttpHandler _handler = new() { PooledConnectionLifetime = TimeSpan.FromMinutes(15) }; private static readonly HttpClient _client = new(_handler, disposeHandler: false); 注意 disposeHandler: false——HttpClient 被 dispose 时不去动底层 handler，避免又把连接池掐死。这是 .NET 官方文档里和 IHttpClientFactory 并列推荐的两种方案之一，特别适合库代码、非 DI 场景或对性能极其敏感的路径。\n五、IHttpClientFactory：DI 时代的解法 IHttpClientFactory（.NET Core 2.1，Microsoft.Extensions.Http）用另一套思路解决同一个问题。它的核心招式一句话能说清：\n创建瞬态的 HttpClient，但让它共享一个按固定生命周期轮换的 handler 池。\n工厂把“昂贵的 handler”和“廉价的 HttpClient”彻底解耦：\nHttpClient 每次 CreateClient 都新建一个（瞬态），随便用、随便 dispose； 但这些 HttpClient 背后挂的是池化的 handler，handler 的 HandlerLifetime 默认 2 分钟，到期后旧 handler 退役、新 handler 上岗——退役与重建的时机正好让 DNS 自然刷新。 而且工厂创建 HttpClient 时用了 disposeHandler: false，所以 dispose 工厂创建的 HttpClient 是安全且廉价的——它只释放这一次 message，不碰池化 handler。这纠正了一个流传甚广的误区：“HttpClient 绝对不能 dispose”。对工厂创建的 client，你完全可以 using var resp = ...。\n三种用法，复杂度递增：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 1) 基础用法：从工厂拿一个默认 client public class Foo(IHttpClientFactory factory) { public async Task\u0026lt;string\u0026gt; Get() =\u0026gt; await factory.CreateClient().GetStringAsync(\u0026#34;https://api.example.com\u0026#34;); } // 2) 命名 client：预配置一组客户端 builder.Services.AddHttpClient(\u0026#34;github\u0026#34;, c =\u0026gt; { c.BaseAddress = new(\u0026#34;https://api.github.com\u0026#34;); c.DefaultRequestHeaders.UserAgent.ParseAdd(\u0026#34;my-blog\u0026#34;); }); // 取用：factory.CreateClient(\u0026#34;github\u0026#34;) // 3) 类型化 client（最推荐）：把配置和调用封装进一个类型 builder.Services.AddHttpClient\u0026lt;GitHubApi\u0026gt;(c =\u0026gt; c.BaseAddress = new(\u0026#34;https://api.github.com\u0026#34;)); public class GitHubApi(HttpClient client) // HttpClient 由工厂注入，已预配置 { public Task\u0026lt;Repo?\u0026gt; GetRepo(string owner, string name) =\u0026gt; client.GetFromJsonAsync\u0026lt;Repo\u0026gt;($\u0026#34;repos/{owner}/{name}\u0026#34;); } 类型化 client 把“基础地址、默认头、handler 管线”全部封装在 DI 里，业务代码只管调用——这是 ASP.NET Core 应用里最干净的写法，也是后续挂载韧性 handler 的入口。\n一个要权衡的成本：工厂抽象带来极小的额外开销，HandlerLifetime 设得过短会让 handler 频繁重建、抵消池化收益。默认 2 分钟对绝大多数场景都合适，别瞎调。\n六、DelegatingHandler：把横向关注点编进管线 回到第三节那张管线图。DelegatingHandler 让你能在请求“出门”前和响应“回来”后插入逻辑——加 traceId、统一鉴权、结构化日志。写一个自定义 handler 很直观：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public class CorrelationIdHandler : DelegatingHandler { protected override async Task\u0026lt;HttpResponseMessage\u0026gt; SendAsync( HttpRequestMessage request, CancellationToken ct) { var id = Guid.NewGuid().ToString(\u0026#34;N\u0026#34;); request.Headers.TryAddWithoutValidation(\u0026#34;X-Correlation-Id\u0026#34;, id); var response = await base.SendAsync(request, ct); // 交给下一层 response.Headers.TryAddWithoutValidation(\u0026#34;X-Correlation-Id\u0026#34;, id); return response; } } // 注册到某个命名 / 类型化 client 的管线 builder.Services.AddHttpClient\u0026lt;GitHubApi\u0026gt;(...) .AddHttpMessageHandler\u0026lt;CorrelationIdHandler\u0026gt;(); 注意 handler 的顺序即语义：先注册的在外层、先执行。把“日志”放在最外层，就能记录到含重试在内的完整耗时；把“鉴权”放在韧性层之内，就能让重试带上最新的 token。这种“顺序敏感的洋葱模型”和 ASP.NET Core 中间件如出一辙。\n七、韧性：从手写重试到 .NET 8 标准管线 网络请求天生不可靠：会超时、会抖动、会对端短暂故障。所以一个生产级 HTTP 客户端必须自带韧性（resilience）：重试、熔断、超时、降级。这条线同样经历了一次范式升级。\n早期：手写 try/catch + for 循环。 散落在各处、难以一致、几乎必然漏掉某种异常类型。\nPolly 时代：策略即对象。 Policy.Handle\u0026lt;HttpRequestException\u0026gt;().OrResult(...).WaitAndRetryAsync(...) 把“什么算可重试、退避多久、最多几次”抽象成可组合的策略对象，挂到管线上。这是巨大进步，但策略组合的写法仍有学习成本。\n.NET 8：标准韧性管线。 Microsoft.Extensions.Http.Resilience 在 Polly v8 的 ResiliencePipeline 之上，提供了一行就能挂载的“出厂合理”配置：\n1 2 builder.Services.AddHttpClient\u0026lt;GitHubApi\u0026gt;(...) .AddStandardResilienceHandler(); // 自带：尝试超时 + 重试 + 熔断，比例与退避都已调好 AddStandardResilienceHandler 组合了一条合理的管线：每次尝试有超时（attempt timeout）→ 失败按指数退避重试 → 连续失败触发熔断（circuit breaker）保护下游。默认值是 .NET 团队基于大量实践调出来的“安全默认”，开箱即用；也可通过 HttpStandardResilienceOptions 精细覆盖。\n还有 AddStandardHedgingHandler()——对冲（hedging）：发出第一个请求后，若它在指定时间内没返回，就并行再发一个。这是一种针对**尾延迟（tail latency）**的武器：在 p99 抖动严重的分布式系统里（想想 Jeff Dean 那张著名的“延迟尾部放大”图），对冲能把慢请求的尾部显著拉平，代价是多打了一些请求。\n韧性的设计思想值得单独点出来：它是一组横向关注点，应当作为管线的一部分组合进去，而不是用 try/catch 在每个调用点重复实现。 这正是把“网络不可靠”从业务逻辑里剥离出来的关键一步。\n八、设计思想：HttpClient 教会了我们什么 回头看，HttpClient 的演进浓缩了几个 .NET 生态里反复出现的设计哲学：\n“门面 + 可组合管线”。 HttpClient 本身是薄门面，真正的架构是那条 handler 链。同样的哲学你能在 ASP.NET Core（入站中间件）、日志（ILoggerProvider 链）、依赖注入里反复看到——把核心逻辑做成管线，把变化点做成可插拔的 handler / provider。\n“池化昂贵资源，按生命周期保鲜”。 连接池的核心张力是“复用 vs 新鲜”。解法不是二选一，而是给资源加生命周期——让池里的连接 / handler 定期轮换，既享受复用的性能，又定期接触真实世界（刷新 DNS）。这是一个可以推广到连接池、缓存、长连接的通用模式。\n“让正确的事变简单”。 IHttpClientFactory + 类型化 client + AddStandardResilienceHandler，本质上是在引导用户走向“开箱即正确”的默认路径——你只要照着模板写，就不会 socket 耗尽、不会 DNS 陈旧、不会裸奔无重试。好的框架用合理默认值把最佳实践固化下来。\n“横向关注点即中间件”。 鉴权、日志、追踪、韧性，都不该污染业务代码。把它们做成 DelegatingHandler、按顺序组合进管线，业务方法里就只剩下纯粹的领域调用。\n九、生产实践与排障清单 把上面的原理落到生产线，常用的判断与排查：\nsocket 耗尽：netstat / ss 统计 TIME_WAIT 数；根因几乎都是某处还在 new HttpClient()。修法：换工厂或静态单例。 连接复用率低：检查是否“每请求一个 client”；HTTP/2 场景下注意 MaxConnectionsPerServer 的语义变化（多路复用下不需要太多连接）。 超时分不清：HttpClient.Timeout 默认 100 秒，几乎一定要显式覆盖。注意超时抛的是 TaskCanceledException；.NET 6+ 起它会包一个 TimeoutException 作为 InnerException，借此区分“客户端超时”和“外部 CancellationToken 主动取消”。 DNS 故障转移失效：云上 / K8s 场景，确认 HandlerLifetime（工厂）或 PooledConnectionLifetime（单例）已设成合理值；要求高的场景可叠加 hedging。 gRPC：.NET 的 gRPC 客户端底层就是 HttpClient，同样走工厂与连接池，韧性机制完全通用。 十、决策清单：我该用哪一种 场景 推荐 库代码 / 非 DI / 极致性能 静态 HttpClient + SocketsHttpHandler{ PooledConnectionLifetime } ASP.NET Core 应用 IHttpClientFactory + 类型化 client + AddStandardResilienceHandler() 需要拉平尾延迟 叠加 AddStandardHedgingHandler() 永远别用 每请求 new HttpClient()（无论是否 using） 结语 HttpClient 的“难”，不在 API，而在它把一个分布式系统的本质矛盾——网络不可靠、资源要复用、世界会变化——直接暴露给了每一个写业务代码的人。从 socket 耗尽到韧性管线，它的每一次演进都在把这个矛盾往框架深处藏一点，让业务代码更干净一点。\n当你下一次写出 builder.Services.AddHttpClient\u0026lt;T\u0026gt;().AddStandardResilienceHandler() 时，你其实正站在一条十年的演进线上——背后是无数人踩过的 TIME_WAIT、调过的 DNS、修过的尾延迟。理解了这条脉络，HttpClient 就从一个“容易踩坑的类型”，变成一个“设计优雅的分布式客户端”。\n","date":"2026-02-04T10:00:00+08:00","permalink":"/posts/dotnet/httpclient-deep-dive/","title":"HttpClient 的前世今生：从 Socket 耗尽到 .NET 8 韧性管线"},{"content":"写在前面 本文是 .NET 新特性系列收官篇。.NET 10（2025-11）是当前最新的 LTS。C# 14 带来几个让人等了很久的特性：扩展成员（终于能扩展属性和静态成员）、field 关键字（访问自动属性的 backing field）、null 条件赋值。\n运行时继续打磨，生产可用性进一步巩固。\n一、版本概览 1 2 3 4 5 6 7 8 9 10 11 12 .NET 10 发布：2025-11 支持：LTS（3 年） C#：14 定位：当前最新 LTS 特点： ✓ 扩展成员（扩展属性/静态成员） ✓ field 关键字 ✓ null 条件赋值 ✓ 性能持续提升 ✓ 新项目首选 LTS 二、C# 14 语言特性（重点） 2.1 扩展成员（extension members）⭐ 等了很久的特性。以前只能写扩展方法，C# 14 终于能写扩展属性、扩展静态成员：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 以前（C# 3+）：只能扩展方法 public static class StringExtensions { public static bool IsBlank(this string s) =\u0026gt; string.IsNullOrWhiteSpace(s); } // C# 14：扩展属性（计算属性） public static class StringExtensions { extension(string s) { public bool IsBlank =\u0026gt; string.IsNullOrWhiteSpace(s); // 扩展属性 public string Reversed =\u0026gt; new string(s.Reverse().ToArray()); } } \u0026#34;abc\u0026#34;.IsBlank; // false（像属性一样用） \u0026#34;abc\u0026#34;.Reversed; // \u0026#34;cba\u0026#34; // 还能扩展静态成员 extension(string) { public static string Empty2 =\u0026gt; \u0026#34;\u0026#34;; // String.Empty2（静态） } 1 2 3 4 5 意义： C# 3 的扩展方法解决了\u0026#34;给现有类型加方法\u0026#34; C# 14 终于补齐\u0026#34;加属性、加静态成员\u0026#34; 统一了扩展的语法 写流畅 API、DSL 更自然 2.2 field 关键字 ⭐ 访问自动属性的 backing field（编译器生成的隐藏字段）：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 以前：要么全用自动属性（无法加逻辑），要么手写字段 public class User { private string _name = \u0026#34;\u0026#34;; public string Name { get =\u0026gt; _name; set =\u0026gt; _name = string.IsNullOrWhiteSpace(value) ? \u0026#34;\u0026#34; : value; } } // C# 14：field 关键字，访问自动属性的 backing field public class User { public string Name { get; set =\u0026gt; field = string.IsNullOrWhiteSpace(value) ? \u0026#34;\u0026#34; : value; } = \u0026#34;\u0026#34;; // field 就是编译器生成的 backing field，无需手写 _name } // 还能在 get 里用 public int Discount { get =\u0026gt; field \u0026gt; 0 ? field : 0; set; } 1 2 3 field 解决： 自动属性要加一点逻辑时，不用退回手写字段 保持自动属性的简洁 + 按需加逻辑 2.3 null 条件赋值 ⭐ 1 2 3 4 5 6 7 8 // 以前：要先判断 null if (user != null) user.Name = \u0026#34;new\u0026#34;; // C# 14：null 条件赋值 user?.Name = \u0026#34;new\u0026#34;; // user 非 null 才赋值 // 链式也行 config?.Database?.Timeout = 30; // 中间任意为 null 就跳过 2.4 其他 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // user-defined compound assignment（自定义复合赋值） // 配合 operator+ 等，自定义 += 行为 // partial events / partial constructors（配合源生成器） public partial class X { public partial void OnLoad(); // partial 方法（已有） public partial event EventHandler? Saved; // C# 14：partial 事件 } // nameof unbound generic string n = nameof(List\u0026lt;\u0026gt;); // \u0026#34;List\u0026#34;（不用指定类型参数） // 隐式 Span/ReadOnlySpan 转换 void TakeSpan(ReadOnlySpan\u0026lt;int\u0026gt; s) { } TakeSpan([1, 2, 3]); // 隐式转换更自然 // lambda 加 params Func\u0026lt;int, int\u0026gt; f = params (int x) =\u0026gt; x; // lambda 参数加 params（少见） 三、运行时与性能 1 2 3 4 5 6 7 8 9 10 .NET 10 性能持续打磨： ✓ JIT 改进（更多优化、向量化） ✓ NativeAOT 改进（更小体积、更快、更多兼容） ✓ Server GC 进一步调优 ✓ 异步、Span 持续优化 ✓ JIT for loops 改进 亮点： PGO 进一步增强（基于实际运行更精准） AOT 生态扩大（更多库 AOT 友好） 四、BCL 改进 1 2 3 4 5 6 7 8 // JsonSerializerOptions.Web（Web 场景默认选项） var opts = JsonSerializerOptions.Web; // camelCase、宽松、适合 API // 集合改进（Frozen、新增 API） // Numerics / TensorPrimitives 增强（AI 方向持续） // Source Generator 支持 JSON 序列化完善 // HttpClient、配置等改进 五、ASP.NET Core 10 1 2 3 4 5 ✓ 性能改进 ✓ Blazor 改进 ✓ OpenAPI 原生生成完善 ✓ AOT 支持扩大 ✓ 身份认证、授权改进 六、升级建议 1 2 3 4 5 6 7 .NET 10 是最新 LTS，新项目首选 从 8 升 10（同为 LTS）： ✓ 扩展成员、field、null 条件赋值（生产力） ✓ 性能 ✓ AOT 改进 兼容性好，升级风险低 七、小结 .NET 10 是当前最新 LTS：\nC# 14：扩展成员（属性/静态）、field 关键字、null 条件赋值、partial 事件、nameof 泛型、隐式 Span 转换 运行时：JIT/AOT/PGO 持续改进 BCL：JsonSerializerOptions.Web、AI 数值方向 ASP.NET Core：性能、Blazor、OpenAPI、AOT 定位：当前最新 LTS，新项目首选 系列总结 .NET 新特性六篇完结（5 → 10）：\n1 2 3 4 5 6 .NET 5（C# 9） 统一开端 record、init、顶级语句、模式匹配 .NET 6（C# 10） 首个统一LTS 全局using、文件命名空间、record struct、Minimal API .NET 7（C# 11） 表达力 原始字符串、列表模式、required、generic math .NET 8（C# 12） 成熟LTS 主构造函数、集合表达式、NativeAOT、TimeProvider .NET 9（C# 13） 精炼 params集合、Lock、HybridCache、TensorPrimitives .NET 10（C# 14） 最新LTS 扩展成员、field、null条件赋值 选型建议：\n新项目 / 生产 → 最新 LTS（.NET 10 或 8） 非 LTS（7/9）只用于尝鲜，不长期生产 升级收益看 LTS 版（6/8/10），ST 版（7/9）的语法特性只要项目支持就能用 演进脉络：\n语言：减少样板、增强表达（record/init → 原始字符串 → 主构造 → 扩展成员） 运行时：性能 + AOT（分层编译 → PGO → NativeAOT） BCL：现代化（DateOnly → TimeProvider → HybridCache） 方向：AI/数值（TensorPrimitives）、云原生（AOT、容器） 掌握 5→10 的演进，既能在新项目用最新特性，也能理解老代码的写法。\n","date":"2026-01-31T10:00:00+08:00","permalink":"/posts/dotnet/whats-new/06-dotnet10/","title":".NET 新特性（六）：.NET 10（C# 14）最新 LTS"},{"content":"写在前面 本文是 .NET 新特性系列第五篇。.NET 9（2024-11）是非 LTS 版，重点是精炼和优化：params 集合（性能）、新 Lock 类型、HybridCache（统一缓存抽象）、Server GC 动态调谐。\n语言上 C# 13 是小步改进，但运行时和 AI/数值方向投入很大（TensorPrimitives 等）。\n一、版本概览 1 2 3 4 5 6 7 8 9 10 11 .NET 9 发布：2024-11 支持：非 LTS（标准支持 18 个月） C#：13 定位：精炼优化、AI/数值方向布局 特点： ✓ params 集合、新 Lock 类型 ✓ HybridCache（统一缓存抽象） ✓ Server GC 动态调谐 ✓ TensorPrimitives（AI/数值） 二、C# 13 语言特性 2.1 params 集合 ⭐ params 不再限于数组，支持任意集合类型（ReadOnlySpan、IEnumerable、List 等）：\n1 2 3 4 5 6 7 8 9 10 11 12 // 以前：params 只能是数组（隐式 params object[] / int[]） void Sum(params int[] nums) { } // C# 13：params 支持任意集合类型 void Sum(params ReadOnlySpan\u0026lt;int\u0026gt; nums) { } // 栈分配，零 GC！ void Log(params IEnumerable\u0026lt;string\u0026gt; msgs) { } void Build(params List\u0026lt;int\u0026gt; ns) { } Sum(1, 2, 3); // 调用方式不变 // 关键收益：params + ReadOnlySpan = 零堆分配 // 高频变参方法（日志、拼接）受益 2.2 新 Lock 类型 ⭐ 新的 System.Threading.Lock，比 lock(object) 更快更清晰：\n1 2 3 4 5 6 7 8 9 10 // 以前：lock 任意对象 private readonly object _lock = new(); lock (_lock) { /* 临界区 */ } // C# 13：专用 Lock 类型 private readonly Lock _lock = new(); lock (_lock) { /* 临界区 */ } // lock(Lock) 走更快的路径 // Lock 还有 EnterScope() 等更现代的 API using (_lock.EnterScope()) { /* 临界区 */ } 2.3 其他 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // \\e 转义（ESC 字符，0x1B） char esc = \u0026#39;\\e\u0026#39;; // 以前要写 \u0026#39;\u001b\u0026#39; 或 (char)27 // ref struct 可作泛型类型参数（配合 allows ref struct 约束） static T Process\u0026lt;T\u0026gt;(T t) where T : allows ref struct { } Process\u0026lt;int\u0026gt;(1); // OK Process\u0026lt;Span\u0026lt;int\u0026gt;\u0026gt;(span); // OK（ref struct 作泛型参数） // method group 自然类型改进（重载解析更好） // 部分属性（partial properties，配合源生成器） public partial string Name { get; set; } // 属性模式隐式（更简洁） bool IsOrigin(Point p) =\u0026gt; p is { X: 0 } \u0026amp;\u0026amp; p is { Y: 0 }; 三、运行时与性能 3.1 Server GC 动态调谐 1 2 3 4 5 6 .NET 8 的 Server GC 动态适应，.NET 9 进一步调谐： 根据内存压力、工作负载自动调整 Gen 0/1/2 阈值动态化 容器内存限制感知更强 效果：内存占用更稳定，长跑服务不易内存膨胀 3.2 性能改进 1 2 3 4 ✓ JIT 改进（更多内联、常量折叠） ✓ AOT 改进（更多场景、更小体积） ✓ 异步改进 ✓ 循环优化 四、BCL 改进（重头） 4.1 HybridCache（统一缓存抽象）⭐ 1 2 3 4 5 6 7 8 9 10 11 12 13 // 以前：IDistributedCache / IMemoryCache 各自一套，要自己组装 // .NET 9：HybridCache 统一抽象（L1 内存 + L2 分布式） builder.Services.AddHybridCache(options =\u0026gt; { options.DefaultEntryOptions = new() { LocalCacheExpiration = TimeSpan.FromMinutes(10) }; }); public class UserService(HybridCache cache) { public async Task\u0026lt;User\u0026gt; GetAsync(int id) =\u0026gt; // 先查 L1 内存，miss 查 L2 分布式，再 miss 调工厂 await cache.GetOrCreateAsync($\u0026#34;user:{id}\u0026#34;, async ct =\u0026gt; await LoadFromDb(id), ct); } 1 2 3 4 5 HybridCache 解决： L1（内存）+ L2（Redis）自动协同 防缓存击穿（stampede 保护） 统一 API，不再手写多级缓存 （preview，后续版本完善） 4.2 OrderedDictionary（泛型版） 1 2 3 4 5 // 泛型有序字典（保持插入顺序） var od = new OrderedDictionary\u0026lt;string, int\u0026gt;(); od[\u0026#34;a\u0026#34;] = 1; od[\u0026#34;b\u0026#34;] = 2; // 遍历按插入顺序 4.3 TensorPrimitives / Numerics（AI 方向） 1 2 3 4 5 6 7 // 张量基础运算（为 AI/ML 优化） using System.Numerics.Tensors; TensorPrimitives.Add(span1, span2, resultSpan); // 向量加 TensorPrimitives.Dot(a, b); // 点积 // 配合 generic math（INumber\u0026lt;T\u0026gt;），数值计算更通用 // .NET 9 加大 AI/数值投入，为 ML.NET、ONNX 提供基础 4.4 其他 1 2 3 4 // System.Text.Json：IndentCharacter/Size（缩进控制）、required 成员 // Span 系列改进（更多 API） // TimeZoneInfo 改进 // SearchValues 增强 五、ASP.NET Core 9 1 2 3 4 ✓ 静态资产优化（更好的静态资源交付、压缩、缓存） ✓ OpenAPI 原生生成（Microsoft.OpenApi，轻量） ✓ Blazor 改进（重新连接体验、静态 SSR） ✓ 路由改进（TypedResults 增强） 六、升级建议 1 2 3 4 5 6 7 .NET 9 非 LTS，生产建议用 8/10（LTS） 但值得体验： ✓ params ReadOnlySpan（高频方法零分配） ✓ Lock 类型 ✓ HybridCache（preview） 从 8 升 9 风险低，可评估；生产建议等 10（LTS） 七、小结 .NET 9 是精炼优化的一版：\nC# 13：params 集合（+ ReadOnlySpan 零分配）、新 Lock 类型、\\e、ref struct 作泛型参数、partial 属性 运行时：Server GC 动态调谐、JIT/AOT 改进 BCL：HybridCache（统一缓存）、OrderedDictionary 泛型、TensorPrimitives（AI 数值）、JSON 改进 ASP.NET Core：静态资产优化、原生 OpenAPI、Blazor 定位：非 LTS，AI/数值方向布局，缓存统一 下一篇讲 .NET 10（C# 14）：扩展成员、field 关键字——最新 LTS。\n","date":"2026-01-27T10:00:00+08:00","permalink":"/posts/dotnet/whats-new/05-dotnet9/","title":".NET 新特性（五）：.NET 9（C# 13）精炼优化"},{"content":"写在前面 本文是 .NET 新特性系列第四篇。.NET 8（2023-11）是当前最成熟的 LTS。三大亮点：主构造函数（C# 终于有）、集合表达式（统一初始化）、NativeAOT 正式发布（编译成原生代码，启动极快）。\n加上 TimeProvider（可测试的时间）、Frozen 集合（极快只读）等，.NET 8 是目前生产首选。\n一、版本概览 1 2 3 4 5 6 7 8 9 10 11 .NET 8 发布：2023-11 支持：LTS（3 年） C#：12 定位：成熟、生产首选 LTS 亮点： ✓ NativeAOT 正式发布 ✓ 主构造函数、集合表达式 ✓ TimeProvider（可测试的时间） ✓ Frozen 集合、性能大幅提升 二、C# 12 语言特性（重点） 2.1 主构造函数（primary constructors）⭐ 类/结构体的构造参数直接成为类成员（C# 终于有，之前 VB / Record 有）：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 旧：字段 + 构造函数赋值 public class UserService { private readonly IUserRepo _repo; private readonly ILogger\u0026lt;UserService\u0026gt; _logger; public UserService(IUserRepo repo, ILogger\u0026lt;UserService\u0026gt; logger) { _repo = repo; _logger = logger; } } // 新（C# 12）：主构造函数，参数直接用 public class UserService(IUserRepo repo, ILogger\u0026lt;UserService\u0026gt; logger) { public User Get(int id) =\u0026gt; repo.Get(id); // 直接用 repo } // 配合 init-only / 全局可访问 public class Point(double x, double y) { public double X =\u0026gt; x; // 暴露为属性 public double Y =\u0026gt; y; public Point() : this(0, 0) { } // 链式调用其他构造 } 1 2 3 主构造函数大幅减少样板代码 DI 注入、值传递场景特别爽 注意：参数不是字段，要在用它的方法里捕获（或包成属性） 2.2 集合表达式（collection expressions）⭐ 统一所有集合的初始化语法：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 以前：不同集合不同初始化 int[] a = new[] { 1, 2, 3 }; List\u0026lt;int\u0026gt; b = new() { 1, 2, 3 }; IEnumerable\u0026lt;int\u0026gt; c = new List\u0026lt;int\u0026gt; { 1, 2, 3 }; Span\u0026lt;int\u0026gt; d = stackalloc int[] { 1, 2, 3 }; ImmutableArray\u0026lt;int\u0026gt; e = ImmutableArray.Create(1, 2, 3); // 现在：统一用 [ ... ] int[] a = [1, 2, 3]; List\u0026lt;int\u0026gt; b = [1, 2, 3]; IEnumerable\u0026lt;int\u0026gt; c = [1, 2, 3]; Span\u0026lt;int\u0026gt; d = [1, 2, 3]; ImmutableArray\u0026lt;int\u0026gt; e = [1, 2, 3]; // 展开（..）：合并集合 int[] all = [0, .. existingArray, .. otherList, 4]; 1 2 3 4 5 集合表达式编译器按目标类型选择最优实现： Span → stackalloc（栈分配，零 GC） ImmutableArray → 直接构造 List → List 统一、简洁、高性能 2.3 using 别名任意类型 1 2 3 4 5 6 7 8 // 别名不只是命名空间/类，可以是元组、泛型 using Point = (int X, int Y); using Dict = System.Collections.Generic.Dictionary\u0026lt;string, int\u0026gt;; Point p = (1, 2); Dict d = new(); // 元组别名让\u0026#34;轻量结构\u0026#34;更易用 2.4 ref readonly 参数 1 2 3 4 // in：按引用传，只读，防拷贝 // .NET 8：ref readonly 参数 void Process(ref readonly BigStruct data) { } // 明确表达：引用传 + 只读（in 的语义，但更显式） 2.5 其他 1 2 3 4 5 6 // inline arrays（[InlineArray(N)] 固定大小数组，安全 + 快） // lambda 默认参数 Func\u0026lt;int, int\u0026gt; f = (int x = 1) =\u0026gt; x * 2; // Interceptor（实验性，源生成器拦截方法调用，热重载基础） // ref struct 放宽 三、运行时与性能 3.1 NativeAOT 正式发布 ⭐ 1 2 3 4 # 编译成原生代码，无需 .NET 运行时 dotnet publish -c Release -r linux-x64 -p:PublishAot=true # 输出单个原生可执行文件 1 2 3 4 5 6 7 8 9 10 11 12 NativeAOT 的意义： ✓ 启动极快（毫秒级，无 JIT） ✓ 内存占用小（无运行时） ✓ 单文件部署，无依赖 ✓ 适合云函数、CLI、边缘、容器 限制： ✗ 反射受限（需 trim / 显式标注） ✗ 动态加载程序集不支持 ✗ 部分库不兼容 ASP.NET Core 也部分支持 AOT（Minimal API） 3.2 Server GC 动态适应 1 2 3 4 .NET 8 Server GC 根据工作负载动态调整堆大小 高负载 → 分配更多堆 低负载 → 释放内存给系统 容器环境特别友好（内存占用更可控） 3.3 性能改进 1 2 3 4 5 ✓ JIT 改进（更多向量化、内联） ✓ async state machine 更小 ✓ 字符串操作、Span 改进 ✓ System.Text.Json 性能（已是 .NET 最快 JSON） ✓ AOT、PGO 持续优化 四、BCL 改进（重头） 4.1 TimeProvider（可测试的时间）⭐ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 以前：DateTime.Now 难测试（依赖真实时间） // .NET 8：TimeProvider 抽象时间 public class OrderService(TimeProvider time) { public bool IsExpired(Order o) =\u0026gt; time.GetUtcNow() \u0026gt; o.ExpireTime; } // 生产用真实时间 new OrderService(TimeProvider.System); // 测试用虚拟时间 var fake = new FakeTimeProvider(); fake.SetUtcNow(new DateTimeOffset(2026, 1, 1)); new OrderService(fake); // 可控测试 4.2 Frozen 集合 ⭐ 1 2 3 4 5 6 7 // 构建后不可变，但读取极快（编译期优化） var frozen = FrozenDictionary.CreateRange(data); var set = FrozenSet.CreateRange(items); frozen.TryGetValue(key, out var v); // 比普通 Dictionary 读还快 // 适用：启动时构建、之后只读的查找表（配置、枚举映射） 4.3 SearchValues 1 2 3 // 快速字符集合查找（向量化） var digits = SearchValues.Create(\u0026#34;0123456789\u0026#34;); int idx = \u0026#34;abc123\u0026#34;.AsSpan().IndexOfAny(digits); // 3 4.4 System.Text.Json 多态 1 2 3 4 5 [JsonPolymorphic] [JsonDerivedType(typeof(Dog), \u0026#34;dog\u0026#34;)] [JsonDerivedType(typeof(Cat), \u0026#34;cat\u0026#34;)] public abstract class Animal { } // 序列化派生类带类型标识，反序列化还原正确类型 4.5 其他 1 2 3 4 5 Random.Shared.GetItems(arr, 5); // 随机取 N 个 Random.Shared.Shuffle(arr); // 洗牌 // KeyedDi（Keyed 服务，按 key 注入） builder.Services.AddKeyedTransient\u0026lt;IRepo, SqlRepo\u0026gt;(\u0026#34;sql\u0026#34;); // ConfigureContainer、Numerics 改进 五、ASP.NET Core 8 1 2 3 4 5 6 ✓ Blazor United（Server + WebAssembly 混合渲染） ✓ Server GC 默认开启（模板默认） ✓ AOT 支持（Minimal API 可 AOT） ✓ 缓存、限流（来自 7）完善 ✓ 身份认证改进、OpenAPI ✓ .NET Aspire（云原生开发框架，preview） 六、升级建议 1 2 3 4 5 6 7 8 .NET 8 是当前最稳的 LTS，强烈推荐生产使用 新项目直接 8（或更新的 LTS 10） 升级收益： ✓ NativeAOT（启动/内存） ✓ 主构造函数、集合表达式（生产力） ✓ TimeProvider（可测试） ✓ Frozen 集合、性能 七、小结 .NET 8 是成熟的 LTS：\nC# 12：主构造函数、集合表达式、using 别名任意类型、ref readonly 运行时：NativeAOT 正式、Server GC 动态适应、性能大幅提升 BCL：TimeProvider（可测试时间）、Frozen 集合、SearchValues、JSON 多态、Keyed DI ASP.NET Core：Blazor United、Server GC 默认、AOT 定位：生产首选 LTS 下一篇讲 .NET 9（C# 13）：params 集合、Lock 类型、HybridCache。\n","date":"2026-01-23T10:00:00+08:00","permalink":"/posts/dotnet/whats-new/04-dotnet8/","title":".NET 新特性（四）：.NET 8（C# 12）成熟与 NativeAOT"},{"content":"写在前面 本文是 .NET 新特性系列第三篇。.NET 7（2022-11）是非 LTS 版，但 C# 11 的表达力大幅提升：原始字符串、列表模式、required、generic math——这些让代码更简洁、更安全、更强大。\n运行时方面，Dynamic PGO 默认开启，性能继续攀升。\n一、版本概览 1 2 3 4 5 6 7 8 9 10 .NET 7 发布：2022-11 支持：非 LTS（标准支持 18 个月） C#：11 定位：表达力提升 + 性能打磨 特点： ✓ C# 11 语法大跃进（原始字符串、列表模式、required） ✓ Dynamic PGO 默认开启 ✓ 限流、输出缓存等 ASP.NET Core 中间件 二、C# 11 语言特性（重点） 2.1 原始字符串字面量（raw string literals）⭐ 三引号 \u0026quot;\u0026quot;\u0026quot; 包裹，无需转义，跨多行，写正则/JSON/HTML 爽：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 不用转义引号、反斜杠 var json = \u0026#34;\u0026#34;\u0026#34; { \u0026#34;name\u0026#34;: \u0026#34;张三\u0026#34;, \u0026#34;regex\u0026#34;: \u0026#34;\\\\d+\u0026#34; } \u0026#34;\u0026#34;\u0026#34;; // 插值用多个 $（$$ 表示字面 { 一个） var json2 = $$\u0026#34;\u0026#34;\u0026#34; { \u0026#34;id\u0026#34;: {{userId}} } \u0026#34;\u0026#34;\u0026#34;; // 写正则特别爽 var pattern = \u0026#34;\u0026#34;\u0026#34;\\d{4}-\\d{2}-\\d{2}\u0026#34;\u0026#34;\u0026#34;; 2.2 列表模式（list patterns）⭐ 匹配数组的结构和元素：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 int[] arr = { 1, 2, 3 }; // 匹配固定结构 _ = arr is [1, 2, 3]; // true _ = arr is [1, .., 3]; // true（.. 匹配中间任意个） _ = arr is [_, _, _]; // 长度 3 // 解构 if (arr is [var first, .. var middle, var last]) { // first=1, middle=[2], last=3 } // switch string Desc(int[] a) =\u0026gt; a switch { [] =\u0026gt; \u0026#34;空\u0026#34;, [single] =\u0026gt; $\u0026#34;一个：{single}\u0026#34;, [a, b] =\u0026gt; $\u0026#34;两个：{a},{b}\u0026#34;, [..] =\u0026gt; \u0026#34;更多\u0026#34; }; 2.3 required 成员 ⭐ 强制属性在构造时必须初始化：\n1 2 3 4 5 6 7 8 9 10 11 public class User { public required int Id { get; init; } // 必须初始化 public required string Name { get; init; } // 必须初始化 public string? Email { get; init; } } var u = new User(); // ❌ 编译错误：缺 Id、Name var u2 = new User { Id = 1, Name = \u0026#34;x\u0026#34; }; // ✅ 必填项已提供 // 比 [Required]（运行时验证）更强：编译期保证 2.4 generic math（静态抽象接口成员）⭐ 接口可以有 static abstract 成员，让泛型数学运算成为可能：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // 以前：无法对泛型 T 做 T + T（没有运算符约束） // C# 11：通过 static abstract 接口 // 内置：INumber\u0026lt;T\u0026gt;, IAdditionOperators\u0026lt;T,T,T\u0026gt; 等 static T Sum\u0026lt;T\u0026gt;(params T[] values) where T : INumber\u0026lt;T\u0026gt; { T sum = T.Zero; foreach (var v in values) sum += v; // 泛型类型也能 + 运算了！ return sum; } Sum(1, 2, 3); // int: 6 Sum(1.5, 2.5); // double: 4.0 // 自己定义运算符接口 public interface IAddable\u0026lt;T\u0026gt; where T : IAddable\u0026lt;T\u0026gt; { static abstract T operator +(T a, T b); } 1 2 3 4 意义： 以前泛型数学要写一堆重载 / 用 dynamic（性能差） 现在 INumber\u0026lt;T\u0026gt; 约束，一套代码处理所有数值类型 数值算法、向量运算极大受益 2.5 文件范围类型（file-scoped types） 类型只在当前文件可见：\n1 2 3 4 5 file class InternalHelper { } // 只在本 .cs 文件可见 // 解决： // 同一程序集多个文件想定义同名辅助类 // 或想限制类型可见性到单文件 2.6 UTF-8 字符串字面量 1 2 3 4 // u8 后缀：直接得到 UTF-8 字节（byte[]），不用 Encoding.GetBytes byte[] bytes = \u0026#34;Hello\u0026#34;u8; // new byte[]{ 72, 101, ... } // 网络协议、文件 IO 常需 UTF-8，省去运行时转换 2.7 其他 1 2 3 4 5 6 7 8 9 10 // ref struct 放宽（可用作泛型参数，配合 Span） // Span\u0026lt;char\u0026gt; 模式匹配 // checked 运算符（自定义 checked 版本） // generic attributes（泛型特性） public class MyAttr\u0026lt;T\u0026gt; : Attribute { } // new() 在泛型约束 static T Create\u0026lt;T\u0026gt;() where T : new() =\u0026gt; new T(); // nameof 作用域扩展 三、运行时与性能 3.1 Dynamic PGO 默认开启 1 2 3 4 5 .NET 6 引入 PGO 雏形，.NET 7 默认开启并增强： 运行时收集热点方法的 profile（分支走向） 重新编译时基于真实数据优化（内联、分支预测） 实际应用性能提升 5~20%（无需改代码） 3.2 On-Stack Replacement (OSR) 1 2 3 4 方法正在执行中（长循环）也能从 tier 0 升级到 tier 1 以前：方法要重新调用才优化 现在：长时间运行的方法中途也能优化 利好长任务、循环密集代码 3.3 AOT 改进 1 2 NativeAOT 实验性改进（.NET 8 才正式） publish AOT 持续优化 3.4 其他 1 2 3 4 ✓ JIT 改进（更多优化） ✓ 异步改进 ✓ 反射性能（源生成器 代替） ✓ ARM64、LoongArch 支持 四、BCL 改进 4.1 CollectionsMarshal 1 2 3 4 5 // 拿到 Dictionary 内部值的引用（避免拷贝、可原地改） ref int val = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, key, out bool exists); val += 1; // 直接改字典内值，无拷贝 // 高性能字典操作 4.2 其他 1 2 3 4 5 // Tar 类（读写 tar 归档） // System.Text.Json：多态（polymorphic）部分支持 // Microsoft.Extensions.* 改进 // Random.Shared（线程安全随机） // DateOnly/TimeOnly 增强 五、ASP.NET Core 7 5.1 速率限制中间件（Rate Limiter） 1 2 3 4 5 6 builder.Services.AddRateLimiter(o =\u0026gt; { o.GlobalLimiter = PartitionedRateLimiter.Create\u0026lt;HttpContext, string\u0026gt;(...); o.AddPolicy(\u0026#34;per-ip\u0026#34;, ctx =\u0026gt; ...); }); app.UseRateLimiter(); 5.2 输出缓存（Output Cache） 1 2 3 4 builder.Services.AddOutputCache(); app.UseOutputCache(); app.MapGet(\u0026#34;/hot\u0026#34;, () =\u0026gt; GetData()).CacheOutput(t =\u0026gt; t.Expire(TimeSpan.FromMinutes(1))); 5.3 其他 1 2 3 4 ✓ MTLS（双向 TLS）支持 ✓ HTTP/3 ✓ OpenAPI 改进 ✓ SignalR / Blazor 改进 六、升级建议 1 2 3 4 5 6 .NET 7 已停止支持（2024-05），非 LTS 不适合长期生产 → 升级到 8（LTS） 但 C# 11 特性（原始字符串、列表模式、required、generic math）： → 项目 ≥ 7 即可用，强烈推荐 → 原始字符串 + 列表模式 + required 是日常高频特性 七、小结 .NET 7 是表达力提升的一版：\nC# 11：原始字符串（\u0026quot;\u0026quot;\u0026quot;）、列表模式、required、generic math（static abstract 接口）、file 类型、UTF-8 字面量 运行时：Dynamic PGO 默认开启、OSR BCL：CollectionsMarshal、Tar、Random.Shared ASP.NET Core：限流中间件、输出缓存、MTLS、HTTP/3 定位：非 LTS，但 C# 11 特性极有价值 下一篇讲 .NET 8（C# 12）：主构造函数、集合表达式、NativeAOT 正式——成熟的一代 LTS。\n","date":"2026-01-19T10:00:00+08:00","permalink":"/posts/dotnet/whats-new/03-dotnet7/","title":".NET 新特性（三）：.NET 7（C# 11）表达力提升"},{"content":"写在前面 本文是 .NET 新特性系列第二篇。.NET 6（2021-11）是统一平台的首个 LTS——.NET 5 完成了品牌统一，.NET 6 让它稳定可生产，成为现代 .NET 真正的起点。\nC# 10 重点是减少样板代码（全局 using、文件命名空间），运行时性能继续提升，ASP.NET Core 引入 Minimal APIs 改变了 API 开发方式。\n一、版本概览 1 2 3 4 5 6 7 8 9 10 11 .NET 6 发布：2021-11 支持：LTS（3 年） C#：10 定位：首个统一 LTS，现代 .NET 生产起点 相比 .NET 5： ✓ LTS（企业敢用） ✓ 性能进一步打磨 ✓ Minimal APIs（API 开发革命） ✓ .NET MAUI（跨平台 UI）正式版 二、C# 10 语言特性 2.1 全局 using（global using） 每个文件不用重复写 using，定义一次全项目生效：\n1 2 3 4 5 6 7 8 9 10 11 12 13 // 方式一：代码声明（放 GlobalUsings.cs） global using System.Collections.Generic; global using System.Linq; // 方式二：.csproj 开启隐式 using // \u0026lt;ImplicitUsings\u0026gt;enable\u0026lt;/ImplicitUsings\u0026gt; // 自动启用 System / System.Collections.Generic / System.Linq 等一组 // 之后所有文件不用再写这些 using public class Service { public List\u0026lt;int\u0026gt; Get() =\u0026gt; new(); // 不用 using System.Collections.Generic } 2.2 文件范围命名空间（file-scoped namespace） 整个文件一个命名空间时，少一层缩进：\n1 2 3 4 5 6 7 8 9 10 // 旧：块作用域，多一层缩进 namespace MyApp.Services { public class UserService { } } // 新（C# 10）：分号结尾，少一层缩进 namespace MyApp.Services; public class UserService { } 2.3 record struct（值类型记录） C# 9 的 record 是引用类型。C# 10 增加 record struct（值类型）：\n1 2 3 4 5 6 7 8 9 // record（引用类型） public record Point(double X, double Y); // record struct（值类型，栈分配，无 GC） public readonly record struct Point(double X, double Y); // 都有 record 特性：值相等、解构、with、ToString var p1 = new Point(1, 2); var p2 = p1 with { X = 10 }; // 非破坏性修改 2.4 结构体改进 1 2 3 4 5 6 7 8 // 结构体可有无参构造 + 字段初始化 public struct Temperature { public double Value { get; init; } = 25.0; public Temperature() { } } // struct 也能用 with 表达式（C# 10） 2.5 const 插值字符串 1 2 3 const string App = \u0026#34;MyApp\u0026#34;; const string Version = \u0026#34;1.0\u0026#34;; const string FullName = $\u0026#34;{App} {Version}\u0026#34;; // C# 10：const 插值（组成部分都是常量时） 2.6 Lambda 改进 1 2 3 4 5 6 7 8 // 自然类型：编译器推断委托类型 var add = (int a, int b) =\u0026gt; a + b; // 自动 Func\u0026lt;int,int,int\u0026gt; // 显式声明返回类型 var parse = object (bool b) =\u0026gt; b ? (object)1 : \u0026#34;x\u0026#34;; // Lambda 可加特性 var handler = [Description(\u0026#34;处理\u0026#34;)] (string s) =\u0026gt; Process(s); 2.7 CallerArgumentExpression 参数为空时自动用另一个参数的表达式作异常信息：\n1 2 3 4 5 6 7 public static void ThrowIfNull(object? arg, [CallerArgumentExpression(nameof(arg))] string? name = null) { if (arg is null) throw new ArgumentNullException(name); } ThrowIfNull(user); // 抛：ArgumentNullException: user（自动带参数名） 2.8 其他 1 2 3 4 5 6 7 8 // record 的 ToString 可 sealed（防派生覆盖） public record Derived(string Name) : Base(Name) { public sealed override string ToString() =\u0026gt; Name; } // 解构在 using 中、属性模式改进（嵌套简写） static bool IsCenter(Point p) =\u0026gt; p is { X: 0, Y: 0 }; 三、运行时与性能 3.1 分层编译 + PGO 雏形 1 2 3 4 5 6 7 分层编译稳定： 方法首次执行：快速低优化编译（tier 0） 成为热点：重新高度优化（tier 1） 兼顾启动速度与峰值性能 PGO（Profile-Guided Optimization）雏形： 基于运行 profile 优化热点（.NET 6 引入，后续增强） 3.2 热重载（Hot Reload） 1 2 3 4 改代码不重启： ✓ ASP.NET Core 改控制器，浏览器刷新生效 ✓ Blazor 热重载 ✓ MAUI 3.3 Source Generator 成熟 1 2 3 4 5 编译时生成代码（代替反射）： ✓ 编译期生成，无运行时反射开销 ✓ 类型安全 ✓ AOT 友好 应用：JSON 源生成、日志、配置绑定 3.4 其他性能 1 2 3 4 ✓ Server GC 优化（多核） ✓ JIT 改进（内联、向量化） ✓ List\u0026lt;T\u0026gt; / Dictionary 优化 ✓ async state machine 开销降低 四、BCL 新增 4.1 DateOnly / TimeOnly 1 2 3 4 5 DateOnly date = DateOnly.FromDateTime(DateTime.Now); // 2026-05-23 TimeOnly time = TimeOnly.FromDateTime(DateTime.Now); // 14:30:00 // 解决 DateTime 既表日期又表时间的混乱 // 生日用 DateOnly，打卡用 TimeOnly 4.2 PriorityQueue 1 2 3 4 var pq = new PriorityQueue\u0026lt;string, int\u0026gt;(); pq.Enqueue(\u0026#34;普通\u0026#34;, 1); pq.Enqueue(\u0026#34;紧急\u0026#34;, 10); pq.Dequeue(); // \u0026#34;紧急\u0026#34;（优先级最高） 4.3 PeriodicTimer 1 2 3 var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); while (await timer.WaitForNextTickAsync()) DoWork(); // 每秒一次，异步友好 4.4 ThrowIf 辅助方法 1 2 3 4 ArgumentNullException.ThrowIfNull(arg); ArgumentNullException.ThrowIfNullOrEmpty(str); ArgumentOutOfRangeException.ThrowIfNegative(n); // 内部用 CallerArgumentExpression，自动带参数名 4.5 其他 1 2 Channel\u0026lt;int\u0026gt; ch = Channel.CreateBounded\u0026lt;int\u0026gt;(100); // Channel 稳定 var chunks = Enumerable.Range(0, 10).Chunk(3); // 分块 五、ASP.NET Core 6 5.1 Minimal APIs 1 2 3 4 5 6 7 8 9 // Program.cs 几行写完 API，不用 Controller var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.MapGet(\u0026#34;/\u0026#34;, () =\u0026gt; \u0026#34;Hello\u0026#34;); app.MapGet(\u0026#34;/users/{id}\u0026#34;, (int id, IUserService svc) =\u0026gt; svc.Get(id)); app.MapPost(\u0026#34;/users\u0026#34;, (User u) =\u0026gt; Create(u)); app.Run(); 5.2 其他 1 2 3 4 ✓ 热重载（开发体验） ✓ Blazor 大幅改进 ✓ .NET MAUI（跨平台 UI） ✓ 性能（HTTP/2、I/O） 六、升级建议 1 2 3 4 5 6 7 还在 .NET Core 3.1 / 5？ → 升级到 6+（3.1 已停止支持） → 用 upgrade-assistant（微软迁移工具） 新项目： → 直接从 LTS（6/8/10）开始 → 开启 ImplicitUsings、Nullable、分层编译 七、小结 .NET 6 是现代 .NET 的生产基石：\nC# 10：全局 using、文件命名空间、record struct、结构体改进、lambda 自然类型、CallerArgumentExpression 运行时：分层编译、PGO 雏形、热重载、Source Generator 成熟 BCL：DateOnly/TimeOnly、PriorityQueue、PeriodicTimer、ThrowIf 辅助 ASP.NET Core：Minimal APIs、热重载、Blazor、MAUI 定位：首个统一 LTS，企业生产首选起点 下一篇讲 .NET 7（C# 11）：原始字符串、列表模式、required、generic math。\n","date":"2026-01-15T10:00:00+08:00","permalink":"/posts/dotnet/whats-new/02-dotnet6/","title":".NET 新特性（二）：.NET 6（C# 10）首个统一 LTS"},{"content":"写在前面 本文是 .NET 新特性系列第一篇。.NET 5（2020-11）是\u0026quot;统一 .NET\u0026quot;的第一代——从此去掉 \u0026ldquo;Core\u0026rdquo; 后缀，把原来的 .NET Framework + .NET Core + Xamarin 合并为一个平台。\n虽然 .NET 5 是非 LTS 的过渡版本，但它带来的 C# 9 特性是划时代的：record、init、顶级语句、模式匹配增强——这些至今是日常代码的基石。从这版起，现代 C# 的面貌基本成型。\n一、版本概览 1 2 3 4 5 6 7 8 9 10 11 .NET 5 发布：2020-11 支持：非 LTS（标准支持 18 个月） C#：9 定位：统一时代开端 历史意义： ✓ 去掉 \u0026#34;Core\u0026#34; 后缀，统一品牌 ✓ Framework + Core + Xamarin 合并为一个 .NET ✓ 一个 BCL、一个运行时、一套工具链服务所有平台 ✓ 跳过版本 4（避免和 .NET Framework 4.x 混淆） 二、C# 9 语言特性（重点） 2.1 record（记录类型）⭐ C# 9 最重要的特性。一行定义不可变、值相等的\u0026quot;数据类\u0026quot;：\n1 2 3 4 5 6 7 8 9 10 11 12 // 一行定义（编译器自动生成属性、构造、相等、ToString、解构） public record User(int Id, string Name, string Email); var u1 = new User(1, \u0026#34;张三\u0026#34;, \u0026#34;z@test.com\u0026#34;); var u2 = new User(1, \u0026#34;张三\u0026#34;, \u0026#34;z@test.com\u0026#34;); u1 == u2; // true（值相等，普通 class 是 false） u1 with { Name = \u0026#34;李四\u0026#34; }; // with 表达式：复制并改一个字段 var (id, name, _) = u1; // 解构 // 传统 class 要手写一堆：构造、Equals、GetHashCode、ToString、Deconstruct // record 一行搞定 1 2 3 4 5 6 7 record 的本质： 基于值相等的引用类型（不可变） 自动生成：属性、主构造、Equals/GetHashCode、ToString、Deconstruct 支持 with（非破坏性修改） 适用：DTO、值对象、消息、不可变数据 C# 10 起还有 record struct（值类型版） 2.2 init 只读设置器 ⭐ 对象初始化后不可修改：\n1 2 3 4 5 6 7 8 9 10 11 12 13 public class User { public string Name { get; init; } public int Age { get; init; } } var u = new User { Name = \u0026#34;张三\u0026#34;, Age = 25 }; // 初始化时设置 u.Age = 26; // ❌ 编译错误！init 后不可改 // 对比： // get; set; — 任意读写（不安全） // get; — 只读（只能在构造里赋值） // get; init; — 初始化时写，之后只读（两全） 1 2 3 4 init 解决的问题： 对象初始化器（new X { ... }）的灵活性 + 创建后不可变的线程安全 配合 record，是不可变数据建模的核心 2.3 顶级语句（top-level statements）⭐ Program.cs 不用写 class 和 Main，直接写代码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // C# 9 之前：Program.cs using System; namespace MyApp { class Program { static void Main(string[] args) { Console.WriteLine(\u0026#34;Hello\u0026#34;); } } } // C# 9：整个 Program.cs 就一行 System.Console.WriteLine(\u0026#34;Hello\u0026#34;); // 还能用 async、args、返回值 var name = args.Length \u0026gt; 0 ? args[0] : \u0026#34;World\u0026#34;; Console.WriteLine($\u0026#34;Hello {name}\u0026#34;); 1 2 3 4 顶级语句大幅简化小程序 / API 入口 编译器自动包成 Main 方法 ASP.NET Core 的 Minimal API 基于此（.NET 6） 大型项目仍可用传统 Main 2.4 模式匹配增强 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // 关系模式（\u0026lt; \u0026gt; \u0026lt;= \u0026gt;=） static string Grade(int score) =\u0026gt; score switch { \u0026gt;= 90 =\u0026gt; \u0026#34;A\u0026#34;, \u0026gt;= 80 =\u0026gt; \u0026#34;B\u0026#34;, \u0026gt;= 60 =\u0026gt; \u0026#34;C\u0026#34;, _ =\u0026gt; \u0026#34;F\u0026#34; }; // 逻辑组合（and / or / not） static bool IsDigit(char c) =\u0026gt; c is \u0026gt;= \u0026#39;0\u0026#39; and \u0026lt;= \u0026#39;9\u0026#39;; static bool IsLetter(char c) =\u0026gt; c is \u0026gt;= \u0026#39;a\u0026#39; and \u0026lt;= \u0026#39;z\u0026#39; or \u0026gt;= \u0026#39;A\u0026#39; and \u0026lt;= \u0026#39;Z\u0026#39;; // not 模式 if (obj is not null) { } // 比 obj != null 更\u0026#34;模式化\u0026#34; // 类型 + 条件组合 static decimal GetFee(object o) =\u0026gt; o switch { int n when n \u0026gt; 100 =\u0026gt; 0.5m, string s =\u0026gt; 1.0m, _ =\u0026gt; 2.0m }; 2.5 target-typed new（目标类型 new） 省略类型名，由上下文推断：\n1 2 3 4 5 6 7 // 上下文已知类型，new() 即可 Dictionary\u0026lt;string, List\u0026lt;int\u0026gt;\u0026gt; dict = new(); List\u0026lt;User\u0026gt; users = new(); Process(new User(1, \u0026#34;x\u0026#34;)); // 参数类型已知，省略 // 对比旧写法：类型名写两遍 Dictionary\u0026lt;string, List\u0026lt;int\u0026gt;\u0026gt; dict = new Dictionary\u0026lt;string, List\u0026lt;int\u0026gt;\u0026gt;(); 2.6 协变返回类型 重写方法可以返回更具体的派生类型：\n1 2 3 4 5 6 7 8 class Animal { public virtual Animal Clone() =\u0026gt; new Animal(); } class Dog : Animal { public override Dog Clone() =\u0026gt; new Dog(); // 返回 Dog（更具体），合法 } 2.7 本地大小整数（nint / nuint） 1 2 3 4 5 // nint = 平台相关的整数（32位系统是 int，64位是 long） nint ptr = 123; // 64位系统上是 8 字节 nuint uptr = 456; // 用于互操作、指针运算（和 IntPtr 对应） 2.8 其他 1 2 3 4 5 6 7 8 9 10 11 12 // 静态匿名 lambda（不捕获，可缓存） Func\u0026lt;int, int\u0026gt; f = static x =\u0026gt; x * 2; // target-typed 条件表达式（两分支类型不同，但有公共目标类型） var result = condition ? 1 : 1.5; // 推断为 double // 函数指针（互操作，比 delegate 快） delegate* unmanaged\u0026lt;int, int\u0026gt; fp; // 模块初始化器（程序集加载时执行） [ModuleInitializer] internal static void Init() { /* 全局初始化 */ } 三、统一平台与运行时 3.1 统一 .NET 1 2 3 4 5 6 7 8 9 10 11 .NET 5 之前： .NET Framework（仅 Windows，老） .NET Core（跨平台，新） Xamarin/Mono（移动端） 互不兼容，库要分别维护 .NET 5 之后： 一个 .NET 运行所有平台 一个 BCL（基础类库） 一套 SDK / 工具链 库只需写一次，到处跑 3.2 Single-file 发布 1 2 3 4 # 打成单个可执行文件 dotnet publish -c Release -r linux-x64 --self-contained false /p:PublishSingleFile=true # 一个文件即可运行（无需装运行时，配合 self-contained） 3.3 Windows Runtime 支持 1 2 C#/WinRT：.NET 5 重新支持 Windows Runtime API（UWP、WinUI 互操作） 在 .NET Core 3.x 被移除，5 重新引入（通过 CsWinRT） 3.4 性能改进 1 2 3 4 5 ✓ GC 改进（Gen 0/1 更快） ✓ JIT 改进 ✓ ARM64 优化 ✓ 异步开销降低 ✓ System.Text.Json 性能（追赶 Newtonsoft） 四、BCL 改进 1 2 3 4 5 6 7 8 9 // System.Text.Json 大幅增强（接近生产可用） // 可空引用类型、记录、异步流 // Half（16位浮点） Half h = 1.5f; // System.Text.Json 可靠性提升（默认严格） // StringComparer 改进、Regex 性能 五、升级建议 1 2 3 4 5 6 .NET 5 已停止支持（2022-05），不应再用于生产 → 升级到 LTS（6/8/10） 但 C# 9 的特性（record、init、顶级语句）： → 仍是现代 C# 的核心，日常必用 → 只要项目 ≥ C# 9（.NET 5+）就能用 六、小结 .NET 5 是统一时代的开端：\n意义：去掉 Core 后缀，Framework+Core+Xamarin 统一 C# 9（划时代）：record、init、顶级语句、模式匹配（and/or/not、关系）、target-typed new、协变返回 平台：单一运行时/BCL/SDK，Single-file 发布，Windows Runtime 定位：非 LTS 过渡版，但 C# 9 特性奠定现代 C# 基础 下一篇讲 .NET 6（C# 10）：首个统一 LTS——全局 using、文件命名空间、record struct、Minimal API。\n","date":"2026-01-11T10:00:00+08:00","permalink":"/posts/dotnet/whats-new/01-dotnet5/","title":".NET 新特性（一）：.NET 5（C# 9）统一时代开端"},{"content":"写在前面 收官篇。讲两个行为型模式——命令和状态。选它俩收尾，是因为它们正好接上 DDD 系列的两个核心：命令模式 = CQRS 里的 Command 对象（DDD 第五篇）；状态模式 = 订单聚合里的状态流转（DDD 第二篇）。\n讲完这篇，设计模式系列和 DDD 系列就完美合龙了。\n一、命令模式：把\u0026quot;请求\u0026quot;变成对象 1 2 3 4 5 6 7 8 9 10 11 12 场景：一个编辑器，要做\u0026#34;加粗\u0026#34;\u0026#34;复制\u0026#34;\u0026#34;粘贴\u0026#34;等操作，还要支持【撤销/重做】。 朴素写法（直接调方法）： editor.Bold(); editor.Copy(); editor.Paste(); 问题： ✗ 没法撤销（不知道\u0026#34;刚才做了什么\u0026#34;） ✗ 没法排队、记录日志、宏录制 ✗ 调用者（按钮/快捷键）和具体操作绑死 反转：把\u0026#34;做一件事\u0026#34;封装成一个对象，里面记录\u0026#34;做什么 + 怎么撤\u0026#34;。 调用者只管\u0026#34;执行这个对象\u0026#34;，不知道里面是什么操作。 → 这就是命令模式。 1 2 3 4 5 6 7 8 9 10 11 12 13 意图：把请求封装成对象，从而可以参数化、排队、记录、撤销。 结构： ICommand ← 命令接口（Execute / Undo） ConcreteCommand ← 每个操作一个命令对象（持有接收者） Invoker ← 调用者（按钮、队列），触发命令 Receiver ← 真正干活的（Editor、Service） 价值： ✓ 撤销/重做（每个命令记录\u0026#34;怎么撤\u0026#34;） ✓ 队列/调度/延迟执行（命令是对象，可存可排） ✓ 宏/日志/重放（命令序列化、记录、批量） ✓ 调用者与接收者解耦 二、命令模式手写 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 // 命令接口：执行 + 撤销 public interface ICommand { void Execute(); void Undo(); } // 具体命令：加粗（持有接收者 Editor） public class BoldCommand(Editor _editor) : ICommand { private string? _previousText; // 记录撤销所需的状态 public void Execute() { _previousText = _editor.Text; _editor.BoldSelection(); } public void Undo() =\u0026gt; _editor.Text = _previousText!; } // 调用者：命令历史栈，支持撤销 public class CommandHistory { private readonly Stack\u0026lt;ICommand\u0026gt; _done = new(); public void Execute(ICommand cmd) { cmd.Execute(); _done.Push(cmd); } public void Undo() { if (_done.Count \u0026gt; 0) _done.Pop().Undo(); } } 1 2 3 4 关键：每个命令对象封装了\u0026#34;做什么 + 怎么撤\u0026#34;， 调用者（历史栈）只管 Push/Pop/Execute，不关心具体操作。 → 加新操作 = 加一个命令类，调用者不动（开闭）。 → 撤销/重做、命令队列、宏录制，全靠\u0026#34;命令是对象\u0026#34;这一点。 三、.NET 里的命令模式：CQRS 的 Command 我在 DDD 第五篇讲 CQRS 时说过：写侧的入口是\u0026quot;命令\u0026quot;（CancelOrderCommand）。那正是命令模式的工程化应用。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // CQRS 命令对象（MediatR 的 IRequest） public sealed record CancelOrderCommand(Guid OrderId, string Reason) : IRequest; // 命令处理器（接收者） public class CancelOrderHandler(IOrderRepository _repo) : IRequestHandler\u0026lt;CancelOrderCommand\u0026gt; { public async Task Handle(CancelOrderCommand cmd, CancellationToken ct) { var order = await _repo.Find(cmd.OrderId, ct) ?? throw new NotFoundException(); order.Cancel(cmd.Reason); await _repo.Save(order, ct); } } // 调用者：发命令，不关心谁处理 await _mediator.Send(new CancelOrderCommand(orderId, \u0026#34;误操作\u0026#34;)); 1 2 3 4 5 6 7 8 9 10 对应关系： ICommand（Execute） → CancelOrderCommand（数据载体）+ Handler（逻辑） Invoker → MediatR（_mediator.Send 派发） Receiver → Order 聚合 / 仓储 CQRS 把\u0026#34;命令\u0026#34;提升为一等公民： ✓ 可序列化、可入队（消息队列）、可重放 ✓ 写侧与读侧、调用方与处理方解耦 ✓ 配合事件溯源，命令/事件可审计 → 这就是命令模式在现代架构里的\u0026#34;高阶用法\u0026#34;。 四、状态模式：行为随状态变 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 场景：订单有 草稿/已下单/已支付/已发货/已取消 多种状态， 每种状态下，\u0026#34;能不能改地址\u0026#34;\u0026#34;能不能取消\u0026#34;\u0026#34;能不能再付款\u0026#34;的规则都不一样。 朴素写法（每个方法里一堆 switch 状态）： void Cancel() { switch (Status) { case Draft: ...; break; case Placed: ...; break; case Shipped: throw \u0026#34;已发货不能取消\u0026#34;; break; ... } } void ChangeAddress() { switch (Status) {...} } void Pay() { switch (Status) {...} } 病： ✗ 每个方法都重复判断状态，switch 满天飞 ✗ 加新状态 = 改所有方法的 switch ✗ 状态流转规则散落，看不出\u0026#34;某状态下能做什么\u0026#34; 反转：把\u0026#34;每种状态下的行为\u0026#34;封装成对象，让当前状态对象决定行为。 → 这就是状态模式。 1 2 3 4 5 6 7 8 9 10 11 意图：对象行为随状态变化，把每种状态封装成类，自动切换。 结构： IState ← 状态接口（Cancel/Pay/ChangeAddress） ConcreteState ← 每种状态一个实现（DraftState/PaidState...） Context ← 持有当前状态，把行为委托给它 vs 一堆 switch： ✓ 每种状态的行为内聚在一个类 ✓ 加新状态 = 加一个状态类，老的不动（开闭） ✓ 状态流转规则集中、清晰 五、状态模式 vs DDD 的状态机 在 DDD 第二篇里，订单聚合的状态流转是这样写的：\n1 2 3 4 5 6 7 8 9 10 11 12 13 public class Order : Entity\u0026lt;OrderId\u0026gt;, IAggregateRoot { public OrderStatus Status { get; private set; } public void Cancel(string reason) { if (Status \u0026gt;= OrderStatus.Shipped) throw new DomainException(\u0026#34;已发货不能取消\u0026#34;); Status = OrderStatus.Cancelled; } public void MarkPaid() { /* 仅 Placed → Paid */ } public void Ship() { /* 仅 Paid → Shipped */ } } 1 2 3 4 5 6 7 8 9 10 11 这是状态模式的\u0026#34;轻量版\u0026#34;： - 状态用枚举表示（OrderStatus） - 行为内聚在聚合根方法里（Cancel/MarkPaid/Ship 各自校验 + 流转） - 没有为每种状态单独建类（订单状态不多，枚举够用） 对比完整状态模式（每种状态一个类）： 当状态非常多、每个状态行为复杂、流转规则庞大时， 才值得拆成 IState + 各 State 类。 订单这种 5 个状态、规则简单的场景，枚举 + 聚合方法就够（YAGNI）。 → DDD 的 OrderStatus 是状态模式的务实落地，不是完整形态。 六、状态 vs 策略（极易混） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 两者结构几乎一样（都是\u0026#34;持有当前对象，委托行为\u0026#34;），但意图不同： 策略模式：客户端【主动选】一个算法 → 算法之间平等，可随时替换，互不转换 → 例：选折扣算法、选排序方式 → \u0026#34;用哪个\u0026#34;由外部决定 状态模式：对象【根据当前状态】自动表现不同行为，状态间会【转换】 → 状态之间有流转关系（Draft → Placed → Paid） → \u0026#34;现在是哪个\u0026#34;由对象自己管理 → 例：订单状态机、TCP 连接状态 鉴别： 选项会互相转换、有生命周期 → 状态 选项平行、随时换、互不转换 → 策略 七、何时用 / 何时别用 1 2 3 4 5 6 7 8 9 10 命令模式： ✓ 需要撤销/重做、命令队列、宏录制、任务调度 ✓ 调用者与执行者解耦（CQRS、事件驱动） ✗ 简单一次性调用、不需要撤销/排队 → 直接调方法 状态模式： ✓ 对象状态多、每状态行为不同、状态间有流转 ✓ switch(state) 满天飞、加状态要改一堆方法 ✗ 状态少、规则简单 → 枚举 + 几个 if 就够（DDD 订单就是） ✗ 是\u0026#34;选算法\u0026#34;而非\u0026#34;状态流转\u0026#34; → 那是策略 八、小结 命令模式：把请求封装成对象 → 可撤销/排队/记录/解耦 手写：ICommand（Execute/Undo）+ 接收者 + 历史栈 .NET 高阶：CQRS 的 Command + MediatR（呼应 DDD 第五篇） 状态模式：把每种状态的行为封装成类，行为随状态变 解决 switch(state) 满天飞，加状态加类（开闭） DDD 的 OrderStatus 是它的轻量落地（枚举够用就别拆类） 状态 vs 策略：状态有流转/生命周期，策略是平行可替换 务实原则：简单场景用枚举/直接调用，复杂了再上完整模式 九、设计模式系列总结 九篇下来，一条主线：\n（一）SOLID 是底座：把变化关进笼子，所有模式的共同骨架 （二）策略：算法族可替换，干掉 switch （三）工厂：封装创建，DI 容器是终极形态 （四）建造者：分步构造，对抗伸缩构造函数 （五）装饰器：透明叠加功能，组合优于继承 （六）责任链：处理者串链，ASP.NET Core 中间件的真相 （七）观察者：一对多通知，C# event 天生支持 （八）单例：全局唯一，优先 DI Singleton 别手写 （九）命令与状态：请求即对象（CQRS）、行为随状态变（状态机） 回到第一篇的初心：模式是\u0026quot;有名字的设计经验\u0026quot;，不是教条。.NET 里很多模式已经融化进语言和框架（event/委托/DI/ LINQ），你不必死记 UML，要记的是意图——什么问题、用什么解、付出什么代价。\n和 DDD 系列、微服务系列合在一起看，你会发现它们都在讲同一件事：把复杂度控制在边界内、把变化封装起来、让代码对扩展开放对修改关闭。SOLID 是原则，模式是套路，DDD 是建模，架构是组合——殊途同归。真正理解了这一层，写出来的代码，天然就是好代码。\n","date":"2026-01-03T10:00:00+08:00","permalink":"/posts/architecture/design-patterns/09-command-state/","title":"设计模式实战（九）：命令与状态模式——CQRS 与状态机的落地"},{"content":"写在前面 单例是 23 个模式里名声最差的一个——不是因为它没用，而是因为它被滥用得最狠。新手最爱写单例（Instance 多酷），然后收获一堆\u0026quot;测试不了、并发炸了、依赖藏起来\u0026quot;的坑。\n这篇不光讲怎么写单例，更要讲怎么别写单例——以及 .NET DI 容器提供的正确姿势。\n一、问题：全局唯一实例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 有些对象，整个进程只要一个： - 配置读取器（Configuration） - 日志工厂（LoggerFactory） - 数据库连接池 - 全局缓存（IMemoryCache） - 设备访问（打印机、硬件接口） 为什么必须唯一： - 多个实例浪费资源（重复加载配置、多个连接池） - 多个实例导致状态不一致（两个缓存各存各的） - 有些资源本身是单点的（一个打印机） → 需求：保证一个类只有一个实例，全局可访问。 → 这就是单例模式。 二、单例：保证全局唯一 1 2 3 4 5 6 7 8 意图：确保一个类只有一个实例，并提供全局访问点。 要点： 1. 构造函数私有（外部不能 new） 2. 类内持有一个静态实例 3. 提供一个静态访问点（Instance） 关键词：唯一 + 全局可访问。 三、.NET 线程安全的几种写法 单例最大的技术难点是多线程下也要保证唯一。几种写法，从糙到精：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 // ❌ 错：非线程安全，多线程下可能造出多个 public class BadSingleton { private static BadSingleton? _instance; public static BadSingleton Instance =\u0026gt; _instance ??= new BadSingleton(); // 竞态：两个线程同时进来 } // ✅ 写法 1：双检锁（经典，但要 volatile + lock） public class DclSingleton { private static DclSingleton? _instance; private static readonly object _lock = new(); public static DclSingleton Instance { get { if (_instance is null) // 一检：已有就直接返，避免锁开销 lock (_lock) if (_instance is null) // 二检：进了锁再确认一次 _instance = new DclSingleton(); return _instance; } } } // ✅✅ 写法 2：静态只读字段（最简洁，CLR 保证线程安全的懒初始化） public class StaticSingleton { public static readonly StaticSingleton Instance = new(); private StaticSingleton() { } // 私有构造 } // CLR 在首次访问类时初始化 static 字段，且保证线程安全 // ✅✅✅ 写法 3：Lazy\u0026lt;T\u0026gt;（显式控制，延迟到首次访问 Instance） public class LazySingleton { private static readonly Lazy\u0026lt;LazySingleton\u0026gt; _lazy = new(() =\u0026gt; new LazySingleton()); public static LazySingleton Instance =\u0026gt; _lazy.Value; // 线程安全 + 懒加载 private LazySingleton() { } } 1 2 现代 .NET 推荐：写法 2（static readonly，最简洁）或写法 3（Lazy\u0026lt;T\u0026gt;，最灵活）。 双检锁是 Java 时代的老黄历，C# 里基本不用了。 四、但请优先用 DI 容器的 Singleton 上面三种手写单例，现代 .NET 开发几乎都不用。理由是 DI 容器给了更干净的方案：\n1 2 3 4 5 6 7 8 9 10 // 注册为 Singleton —— 容器保证全局唯一 services.AddSingleton\u0026lt;IConfigurationReader, JsonConfigurationReader\u0026gt;(); services.AddSingleton\u0026lt;ILoggerFactory, LoggerFactory\u0026gt;(); services.AddSingleton\u0026lt;IMemoryCache, MemoryCache\u0026gt;(); // 用：构造函数注入，不直接碰 Instance public class OrderService(IConfigurationReader _config, IMemoryCache _cache) { ... } 1 2 3 4 5 6 7 8 9 DI Singleton vs 手写单例： ✓ 全局唯一由容器保证（你不用操心线程安全） ✓ 显式依赖（构造函数注入，依赖一目了然，不藏） ✓ 可测试（单测时换 Transient/假实现，改注册即可） ✓ 生命周期集中管理（Singleton/Scoped/Transient 统一规则） ✓ 没有静态全局状态（Instance 全局可访问 = 反模式，下面讲） → 唯一性需求，首选 services.AddSingleton，别手写 Instance。 五、单例的反模式面（为什么名声差） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 手写单例（静态 Instance）的五大罪： 1. 全局状态 Instance 是全局可变点，任何代码都能访问修改 → 状态在程序各处飘，谁改的、什么时候改的，无从追踪 2. 隐藏依赖 public void DoWork() { Config.Instance.Get(...) } → 方法签名看不出依赖 Config，像个隐式全局变量 → 代码可读性、可测试性都差 3. 测试困难 单元测试想隔离，但 Instance 是全局静态，没法 mock/替换 → 测试时不得不连真实配置/真实缓存 → 详见《.NET 单元测试（四）：依赖注入与可测试性》 4. 并发陷阱 单例被多线程共享，状态必须自己加锁保护 → 稍不留神就是竞态、死锁 5. 生命周期捕获（.NET DI 专属大坑） services.AddSingleton\u0026lt;Foo\u0026gt;(); Foo 注入了一个 Scoped 服务 → Singleton 持有 Scoped，Scoped 被强制升级为单例 → 跨请求共享本不该共享的状态，数据串了 → 容器会抛异常提醒（Cannot resolve scoped service from root provider） 所以社区共识：能不用手写单例就不用，用 DI Singleton 替代。 六、什么时候仍需要单例 1 2 3 4 5 6 7 8 9 10 11 12 不全是反模式，少数场景仍合理： ✓ 互斥的外部资源（硬件接口、单点打印机、全局信号量） ✓ 框架/底层组件的引导（LoggerFactory、Configuration 根） ✓ 性能敏感、确实只要一份的全局缓存 但即便这些，也优先用 DI Singleton 表达\u0026#34;唯一性\u0026#34;， 只在\u0026#34;必须用静态全局访问点\u0026#34;时（如扩展方法里取服务、遗留代码）才手写。 判断：要的是\u0026#34;唯一性\u0026#34;，还是\u0026#34;全局可访问\u0026#34;？ 多数时候只要\u0026#34;唯一性\u0026#34; → DI Singleton 就够。 真需要\u0026#34;全局随便谁能取\u0026#34;才手写 Instance。 七、何时用 / 何时别用 1 2 3 4 5 6 7 8 9 10 11 12 13 该用（以 DI Singleton 形式）： ✓ 确需进程内唯一的共享资源（配置、缓存、连接池、日志工厂） ✓ 构造昂贵、可复用的无状态服务 别用： ✗ 只是为了\u0026#34;方便取\u0026#34;就上单例 → 那是全局变量，用依赖注入 ✗ 有状态的\u0026#34;上下文\u0026#34;对象 → 单例会跨请求/跨线程串数据 ✗ 为了少传参数 → 把依赖藏进单例 → 测试灾难 ✗ Singleton 注入 Scoped 服务（生命周期捕获） 信号：当你想写 `static Instance` 时，先问自己： \u0026#34;我能不能用 services.AddSingleton + 构造函数注入 代替？\u0026#34; 99% 的答案是可以。 八、小结 意图：保证一个类只有一个实例，全局可访问 手写：私有构造 + 静态实例；线程安全用 static readonly 或 Lazy\u0026lt;T\u0026gt;（双检锁过时了） 正确姿势：services.AddSingleton\u0026lt;T\u0026gt;() —— 容器保证唯一，干净、可测 反模式五罪：全局状态、隐藏依赖、测试困难、并发陷阱、生命周期捕获（Singleton 注入 Scoped） 多数时候你要的是\u0026quot;唯一性\u0026quot;而非\u0026quot;全局可访问\u0026quot; → DI Singleton 足矣，别手写 Instance 要单例，先想 DI；手写静态 Instance 是最后手段 下一篇（收官）讲命令与状态模式——正好接上 DDD 里的 CQRS Command 和订单状态机，给整个模式系列收口。\n","date":"2025-12-30T10:00:00+08:00","permalink":"/posts/architecture/design-patterns/08-singleton/","title":"设计模式实战（八）：单例模式——全局唯一与它的反模式面"},{"content":"写在前面 我写 DDD 第四篇讲\u0026quot;领域事件\u0026quot;时说过：聚合做完自己的事，发个事件，通知所有感兴趣的下游。这套机制的底层，就是观察者模式。\n而 C# 是所有语言里观察者模式最舒服的——event 和委托是语言级支持，不用手写。这篇讲清观察者，并把它和 DDD 领域事件、Rx 串起来。\n一、问题：怎么通知\u0026quot;一群\u0026quot;依赖者？ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 场景：订单状态变了，要通知：库存系统、积分系统、通知系统、报表系统。 朴素写法（被观察者直接调用每个下游）： class Order { void StatusChanged() { _inventory.Handle(this); _points.Handle(this); _notifier.Handle(this); _report.Handle(this); } } 病： ✗ 订单模块依赖了库存、积分、通知、报表 → 强耦合 ✗ 加一个新下游（比如风控）要改 Order 类 → 违反开闭 ✗ 下游增减要在被观察者里改 病根：被观察者\u0026#34;主动知道\u0026#34;每个下游是谁。 反转：让下游\u0026#34;主动订阅\u0026#34;，被观察者只负责\u0026#34;广播\u0026#34;，不认识具体下游。 这就是观察者模式。 二、观察者：发布者 + 订阅者 1 2 3 4 5 6 7 8 9 10 11 12 意图：定义对象间一对多依赖。一个对象状态变化，所有依赖者自动收到通知。 角色： Subject（主题/发布者）—— 持有一组观察者，状态变化时遍历通知 Observer（观察者） —— 提供更新接口，被通知时执行 关键： - 发布者不认识具体观察者，只认识接口 - 订阅者随时 + / - 加入退出 - 一对多（一个发布者 → N 个订阅者） 应用：事件总线、消息广播、UI 数据绑定、领域事件、响应式编程 三、C# 的 event/委托 = 天生观察者 很多语言要手写 Subject/Observer 接口。C# 直接把这套做进了语言：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // 1. 声明一个事件（发布者） public class Order { // event 关键字 = 受控的观察者列表；EventHandler = 标准回调签名 public event EventHandler\u0026lt;OrderStatusChangedEventArgs\u0026gt;? StatusChanged; public void Cancel() { Status = Cancelled; // 2. 状态变化时，触发事件 = 通知所有订阅者 StatusChanged?.Invoke(this, new OrderStatusChangedEventArgs(Status)); } } // 3. 订阅者：用 += 注册，-= 取消 order.StatusChanged += Inventory.OnOrderStatusChanged; order.StatusChanged += Points.OnOrderStatusChanged; order.StatusChanged += Notifier.OnOrderStatusChanged; // 订单状态一变，三个订阅者自动被调用 order.Cancel(); 1 2 3 4 5 6 7 8 拆解： event → Subject 的\u0026#34;观察者列表\u0026#34;（语言帮你管，外部只能 +=/-=） EventHandler → Observer 接口的化身（一个回调签名） Invoke → Subject 遍历通知 += / -= → 订阅 / 取消订阅 → GoF 那套 Subject/Observer 抽象，C# 用 event 三行就实现了。 这就是上一篇说的\u0026#34;模式在 .NET 里融化进语言\u0026#34;的典型。 四、手写 IObservable / IObserver（标准接口） 如果不用 event，.NET 也提供了一对标准接口，常用于\u0026quot;推数据流\u0026quot;的场景：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 发布者 public class PriceFeed : IObservable\u0026lt;decimal\u0026gt; { private readonly List\u0026lt;IObserver\u0026lt;decimal\u0026gt;\u0026gt; _subs = new(); public IDisposable Subscribe(IObserver\u0026lt;decimal\u0026gt; observer) { _subs.Add(observer); return new Unsub(() =\u0026gt; _subs.Remove(observer)); // 返回\u0026#34;取消订阅\u0026#34;句柄 } public void Push(decimal price) { foreach (var s in _subs) s.OnNext(price); // 通知所有订阅者 } } // 订阅者 public class PriceLogger : IObserver\u0026lt;decimal\u0026gt; { public void OnNext(decimal price) =\u0026gt; Log($\u0026#34;价格: {price}\u0026#34;); // 收到数据 public void OnError(Exception e) =\u0026gt; Log($\u0026#34;出错: {e}\u0026#34;); public void OnCompleted() =\u0026gt; Log(\u0026#34;结束\u0026#34;); } 1 2 3 4 5 6 event vs IObservable： event → \u0026#34;发生了一件事\u0026#34;通知（离散事件，如 StatusChanged） IObservable → \u0026#34;数据流\u0026#34;推送（连续序列，如股价、鼠标移动） 后者配合 Rx（Reactive Extensions）能做强大的流式处理： 窗口聚合、防抖、过滤、合并 —— 把事件当集合用。 五、和 DDD 领域事件的关系 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 DDD 第四篇讲的\u0026#34;领域事件\u0026#34;，本质就是观察者模式在领域层的应用： 聚合（Order） = Subject，变更后发事件 领域事件（OrderPlaced） = 通知的载荷 下游订阅者（库存/积分/通知） = Observer 事件分发器（MediatR 等） = Subject 的通知遍历机制 对应关系： Order.Collect events → Dispatcher.Dispatch → 各 Handler.Handle 就是 Subject.Notify → 遍历 Observers → 各 Observer.Update 区别： C# event：进程内、同步（Invoke 直接调） 领域事件：可进程内，也可跨服务（配 Outbox 走消息队列，最终一致） → DDD 领域事件不是新东西，是观察者模式在\u0026#34;跨边界/异步\u0026#34;场景的工程化。 六、坑：事件订阅不取消 = 内存泄漏 观察者模式最经典的坑，C# 里尤其要注意：\n1 2 3 4 5 6 7 8 9 10 11 12 // 短生命周期对象订阅了长生命周期对象的事件 public class MyWindow { public MyWindow(Order longLivedOrder) { longLivedOrder.StatusChanged += OnChanged; // 订阅 } void OnChanged(object? s, EventArgs e) { ... } } // Window 关闭、被丢弃后，只要 Order 还活着、还持有这个事件委托， // 就等于持有 Window 的引用 → Window 永远不被 GC → 内存泄漏！ 1 2 3 4 5 6 7 8 9 原因：event 持有订阅者的委托 = 持有订阅者的引用。 订阅者想被回收，必须先 -= 取消订阅。 对策： 1. 订阅者销毁前 -= 取消（实现 IDisposable） 2. 短生命周期对象订阅长生命周期事件，用弱事件（WeakEventManager） 3. 局部订阅用完即退，别让 lambda 偷偷持有 this 这是观察者模式落地时最常见的 bug，面试常考。 七、何时用 / 何时别用 1 2 3 4 5 6 7 8 9 10 11 12 13 该用观察者： ✓ 一对多通知（一个变化，多个下游关心） ✓ 发布者和订阅者要解耦（互不认识） ✓ 订阅者动态增减 ✓ 事件驱动/响应式场景 别用： ✗ 就一对一回调 → 普通接口/委托就够，别上事件 ✗ 调用顺序严格、必须按序、不能少 → 观察者顺序不定，别用 ✗ 需要订阅者返回值/同步结果 → 事件是单向通知，不适合 ✗ 忘了管理生命周期 → 泄漏 信号：当 A 的变化要让 B、C、D 都反应，但 A 不想认识 B/C/D —— 上观察者。 八、小结 问题：一对多通知，发布者不该认识每个下游 观察者模式：Subject 持有一组 Observer，状态变化时遍历通知 C# event 是天生观察者：event = 观察者列表，+=/-= = 订阅/取消，Invoke = 通知 IObservable/IObserver：标准接口，适合数据流（配合 Rx） 领域事件就是观察者在 DDD 的工程化（进程内或跨服务） 大坑：事件订阅不 -= → 内存泄漏（订阅者被发布者持有，无法 GC） 信号：一变多通知、要解耦 → 观察者 下一篇讲单例模式——以及它为什么是\u0026quot;最该谨慎使用的模式\u0026quot;（反模式面 + DI 的正确姿势）。\n","date":"2025-12-26T10:00:00+08:00","permalink":"/posts/architecture/design-patterns/07-observer/","title":"设计模式实战（七）：观察者模式——C# event 的真相与领域事件"},{"content":"写在前面 这是本系列的重头戏。因为责任链模式在 .NET 后端有一个教科书级的应用——ASP.NET Core 的中间件管道。你天天写 app.UseAuthentication()、app.UseRouting()，它们的底子就是责任链。\n读完这篇，你不光懂这个模式，还会\u0026quot;从原理上\u0026quot;看透你写过的那 5 篇 ASP.NET Core 笔记里的中间件机制。\n一、问题：一个请求，多种处理，谁来管？ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 场景：一个 HTTP 请求进来，要依次经过： 异常处理 → 日志 → 鉴权 → 路由 → 限流 → 缓存 → 业务处理 朴素写法（在入口方法里一堆 if/调用）： Handle(req) { ExceptionFilter(req); Logger(req); Auth(req); Route(req); ... } 病： ✗ 顺序写死在代码里，改顺序要动入口 ✗ 每加一环改这里，违反开闭 ✗ 处理者互相耦合，没法单独插拔 ✗ 谁该处理、处理到哪停、怎么传递——全糊在一起 病根：把\u0026#34;一串处理者\u0026#34;硬编进了一个方法。 二、责任链：处理者串成链，请求沿链传递 1 2 3 4 5 6 7 8 9 10 11 12 13 14 意图：把多个处理者串成链，请求沿链传递。 每个处理者决定：自己处理、处理完往下传、还是直接短路。 结构： IHandler ← 处理者接口 ConcreteHandler（持有一个 next） ← 处理 + 决定是否传给下一个 关键： - 发送者不知道谁最终处理（解耦） - 每个处理者只关心自己的事 + 要不要传 - 顺序由链的组装决定（可配置） - 任何一环可以\u0026#34;短路\u0026#34;（不调 next，终止链） 适用：请求要经过一串处理步骤、顺序敏感、可能中途短路。 三、经典手写 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 // 处理者接口：处理 + 持有下一个 public abstract class Handler { private readonly Handler? _next; protected Handler(Handler? next) =\u0026gt; _next = next; public virtual void Handle(Request req) { _next?.Handle(req); // 默认：只往下传 } } // 具体处理者：鉴权 public class AuthHandler(Handler? next) : Handler(next) { public override void Handle(Request req) { if (!req.IsAuthenticated) { req.Respond(401); return; // 短路：不调 base.Handle，链终止 } base.Handle(req); // 通过 → 传给下一个 } } // 具体处理者：限流 public class RateLimitHandler(Handler? next) : Handler(next) { public override void Handle(Request req) { if (OverLimit(req)) { req.Respond(429); return; } base.Handle(req); } } // 组装链（顺序在这里定，可配置） var chain = new AuthHandler( new RateLimitHandler( new BusinessHandler(null))); chain.Handle(request); 1 2 3 4 重点看两种处理者的行为： - 通过 → base.Handle(req) → 传给 next - 拒绝 → return → 短路，next 不执行 发送方只管 chain.Handle(request)，不关心谁处理、在哪停。 四、重头：ASP.NET Core 中间件 = 责任链 ASP.NET Core 的请求管道，就是责任链模式的工业级实现。我写过 5 篇 ASP.NET Core 笔记，这里把它的原理彻底拆开。\n1 2 3 4 5 6 7 8 9 10 11 // 你写的中间件长这样： app.Use(async (context, next) =\u0026gt; { // —— 进站：请求往里走时执行 —— Console.WriteLine(\u0026#34;请求进入: \u0026#34; + context.Request.Path); await next(); // ← 把控制权交给链的下一个中间件 // —— 出站：响应往回走时执行 —— Console.WriteLine(\u0026#34;响应状态: \u0026#34; + context.Response.StatusCode); }); 1 2 3 4 5 6 7 这段代码的每一部分，对应责任链的每一环： next → 就是责任链里的 \u0026#34;_next\u0026#34;，\u0026#34;下一个处理者\u0026#34; await next() → base.Handle()，把请求传下去 不调 next → 短路（如 app.Run，或鉴权失败直接 401 返回） 进站逻辑 → 处理者\u0026#34;自己的事\u0026#34; 出站逻辑 → next() 返回后执行（洋葱模型，后面讲） 4.1 洋葱模型（Onion / Pipeline） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 请求像一个箭头穿入、再穿出洋葱： ┌─ 异常处理（最外层，最后兜底） │ ┌─ 日志 │ │ ┌─ 鉴权 │ │ │ ┌─ 路由 → 控制器 │ │ │ │ 请求 → → → → → → → → 处理 │ │ │ │ │ │ │ └─ 出站：鉴权后置逻辑 │ │ └──── 出站：日志记录响应 │ └─────── 出站：异常处理收尾 └────────── 每个中间件 next() 之前的代码 = \u0026#34;进站\u0026#34;（按外→内顺序执行） next() 之后的代码 = \u0026#34;出站\u0026#34;（按内→外顺序执行） → 这就是为什么\u0026#34;鉴权放最后注册能拦住所有人\u0026#34;\u0026#34;日志放最外层能记到所有响应\u0026#34; 注册顺序（UseAuthentication、UseRouting 的先后）决定一切。 4.2 中间件类的写法（封装成类） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 约定俗成的中间件类写法 public class RequestTimingMiddleware { private readonly RequestDelegate _next; // ← 这就是\u0026#34;下一个处理者\u0026#34; public RequestTimingMiddleware(RequestDelegate next) =\u0026gt; _next = next; public async Task InvokeAsync(HttpContext ctx) { var sw = Stopwatch.StartNew(); try { await _next(ctx); } // 传给下一个 finally { sw.Stop(); Log($\u0026#34;{ctx.Request.Path} {sw.ElapsedMilliseconds}ms\u0026#34;); } } } // 注册 app.UseMiddleware\u0026lt;RequestTimingMiddleware\u0026gt;(); 1 2 3 4 5 RequestDelegate = \u0026#34;下一个处理者\u0026#34;的类型别名： public delegate Task RequestDelegate(HttpContext context); → 它就是 Handler.Handle(Request) 的现代化身。 整个 ASP.NET Core 管道，就是一条由 RequestDelegate 串起来的责任链。 五、为什么用责任链（而不是别的） 1 2 3 4 5 6 7 8 9 10 11 中间件用责任链的好处： ✓ 解耦：每个中间件只管自己的事（鉴权、限流、日志互不知） ✓ 可插拔：加/删中间件 = 加/删一行注册，业务代码不动（开闭） ✓ 顺序可控：注册顺序即执行顺序，可配置 ✓ 短路能力：鉴权失败 / 限流触发 → 不调 next，链终止 ✓ 洋葱模型：进站、出站都能做事（前置 + 后置逻辑） 这五条，正好是一个 Web 框架处理\u0026#34;请求-响应\u0026#34;最需要的能力。 所以几乎所有主流 Web 框架的请求管道都是责任链变体 （Express/Koa、Spring Filter、ASP.NET Core 中间件）。 六、责任链 vs 装饰器 vs 策略 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 容易混，一次分清： 责任链：多个处理者串起来，请求沿链走，可短路 → 重点：流程/管道，顺序敏感（鉴权→路由→业务） → ASP.NET Core 中间件 装饰器：层层包裹，每层加功能，不短路（一定传到内核） → 重点：增强单一对象（日志/缓存/重试） → Stream、IRepository 装饰 策略：从一组算法里选一个执行 → 重点：多选一（折扣/支付方式） → 不串链、不叠加 鉴别： \u0026#34;要经过一串步骤，可能中途停\u0026#34; → 责任链 \u0026#34;要给一个对象叠加功能\u0026#34; → 装饰器 \u0026#34;要从几种做法里选一种\u0026#34; → 策略 七、何时用 / 何时别用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 该用责任链： ✓ 请求要经过有序的多步骤处理（Web 管道、审批流、事件处理链） ✓ 步骤可插拔、顺序可配置 ✓ 需要\u0026#34;任一环可决定终止\u0026#34;的短路能力 ✓ 处理者之间要解耦 别用： ✗ 步骤固定、就两三步、顺序不变 → 直接调函数更清楚 ✗ 是\u0026#34;选一种算法\u0026#34;而非\u0026#34;经过一串\u0026#34; → 那是策略 ✗ 是\u0026#34;叠加功能\u0026#34;而非\u0026#34;流程传递\u0026#34; → 那是装饰器 信号：当你写出一个超长的 Handle() 方法， 里面对一个请求依次做 N 件不同的事，还可能提前 return —— 拆成责任链。 八、小结 问题：一串处理步骤硬编在一个方法里，顺序死、难扩展 责任链：处理者串链，请求沿链传递，每环可处理/传递/短路 经典手写：Handler 抽象 + 持有 next + Handle 转发或短路 ASP.NET Core 中间件就是责任链：next = 下一个处理者，RequestDelegate = Handler 洋葱模型：next() 前是进站、后是出站，注册顺序决定执行顺序 核心价值：解耦、可插拔、顺序可控、短路能力 vs 装饰器/策略：责任链是\u0026quot;流程\u0026quot;，装饰器是\u0026quot;增强\u0026quot;，策略是\u0026quot;多选一\u0026quot; 这是 ASP.NET Core 管道的原理，看透了它，5 篇 ASP.NET Core 笔记里的中间件就全通了 下一篇讲观察者模式——C# 的 event 委托天生就是观察者，以及它和 DDD 领域事件的关系。\n","date":"2025-12-22T10:00:00+08:00","permalink":"/posts/architecture/design-patterns/06-chain-of-responsibility/","title":"设计模式实战（六）：责任链模式——ASP.NET Core 中间件的真相"},{"content":"写在前面 需求来了：给所有数据库操作的仓储加日志、加缓存、加重试。怎么办？\n新手通常去改 OrderRepository 的源码——把日志、缓存、重试塞进每个方法。这一塞就糟了：核心逻辑被淹没，加一个仓储要重抄一遍，改日志格式要动所有仓储。这篇讲装饰器模式——不动原类，把功能一层层\u0026quot;套\u0026quot;上去。\n一、问题：继承爆炸 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 想给 IRepository 加：日志、缓存、重试，三种功能自由组合。 用继承怎么做？ Repository ├── LoggedRepository ├── CachedRepository └── RetryRepository ├── LoggedRetryRepository ├── CachedLoggedRepository ├── CachedRetryRepository └── CachedLoggedRetryRepository ... (组合爆炸) 病： ✗ N 个功能 → 2^N 个子类，指数爆炸 ✗ 功能组合写死在类型里，运行时不能换 ✗ 违反\u0026#34;组合优于继承\u0026#34; 病根：把\u0026#34;叠加功能\u0026#34;当成\u0026#34;特化\u0026#34;，硬走继承。 二、装饰器：同接口 + 包裹一个同接口 1 2 3 4 5 6 7 8 9 10 11 意图：动态地给对象添加职责，不改其接口。 装饰器和被装饰者实现同一个接口，内部持有一个被装饰者。 结构： IComponent ← 共同接口 ConcreteComponent ← 被装饰的原始对象 Decorator ← 实现同接口，内部持有 IComponent（可嵌套） 关键：装饰器\u0026#34;是一个\u0026#34; IComponent，又\u0026#34;持有一个\u0026#34; IComponent。 所以可以无限套娃：A 装 B，B 装 C，对外都是 IComponent。 调用时，每层装饰器先做自己的事，再转给内层。 三、手写：给仓储叠加日志/缓存/重试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 // 1. 共同接口 public interface IOrderRepository { Task\u0026lt;Order?\u0026gt; Find(Guid id); } // 2. 原始实现（只管数据库，啥装饰都不带） public class SqlOrderRepository : IOrderRepository { public Task\u0026lt;Order?\u0026gt; Find(Guid id) { /* 查库 */ } } // 3. 装饰器基类：持有内部 IOrderRepository，方法默认转发 public class OrderRepoDecorator(IOrderRepository _inner) : IOrderRepository { public virtual Task\u0026lt;Order?\u0026gt; Find(Guid id) =\u0026gt; _inner.Find(id); } // 4. 具体装饰器：加日志 public class LoggingDecorator(IOrderRepository inner, ILogger log) : OrderRepoDecorator(inner) { public override async Task\u0026lt;Order?\u0026gt; Find(Guid id) { log.LogInformation(\u0026#34;Find {Id}\u0026#34;, id); var result = await base.Find(id); // 调内层 log.LogInformation(\u0026#34;Result: {Found}\u0026#34;, result is not null); return result; } } // 5. 具体装饰器：加缓存 public class CachingDecorator(IOrderRepository inner, IMemoryCache cache) : OrderRepoDecorator(inner) { public override Task\u0026lt;Order?\u0026gt; Find(Guid id) =\u0026gt; cache.GetOrCreateAsync(id, _ =\u0026gt; base.Find(id)); } 1 2 3 4 5 6 7 8 // 用：按需套，顺序自由 IOrderRepository repo = new LoggingDecorator( // 最外层：记日志 new CachingDecorator( // 中间层：查缓存 new SqlOrderRepository() // 最内层：真查库 ), logger); // 调用方只认 IOrderRepository，不知道套了几层 await repo.Find(id); 1 2 3 4 5 对比继承爆炸： ✓ 加功能 = 写一个装饰器类，不是 2^N 子类 ✓ 运行时自由组合顺序（先缓存还是先日志，拼装时决定） ✓ 原始类（SqlOrderRepository）纯净，只管核心职责 ✓ 每个 Decorator 单一职责（开闭、SRP 都满足） 四、.NET 的教科书案例：Stream 1 2 3 4 5 6 7 8 9 10 11 12 13 Stream 全家桶是装饰器的经典： FileStream fs = File.OpenRead(\u0026#34;a.txt\u0026#34;); var buffered = new BufferedStream(fs); // 套一层缓冲 var gzip = new GZipStream(buffered, ...); // 再套一层压缩 var reader = new CryptoStream(gzip, ...); // 再套一层加密 FileStream / BufferedStream / GZipStream / CryptoStream 都继承自 Stream（同接口），每个包一个 Stream（持有一个）。 → 你可以任意组合：加密 + 压缩 + 缓冲，顺序自由。 → 不需要\u0026#34;加密缓冲流\u0026#34;\u0026#34;压缩加密流\u0026#34;这种组合类。 认出这个套路，装饰器就懂了一半。 五、用 DI 让装饰器自动组装 手写套娃链（new LoggingDecorator(new CachingDecorator(...))）很烦。Scrutor 库给 .NET DI 加了装饰器支持，一行注册：\n1 2 3 4 5 services.AddScoped\u0026lt;IOrderRepository, SqlOrderRepository\u0026gt;(); services.Decorate\u0026lt;IOrderRepository, CachingDecorator\u0026gt;(); // 套缓存 services.Decorate\u0026lt;IOrderRepository, LoggingDecorator\u0026gt;(); // 再套日志 // 解析 IOrderRepository 时，容器自动按注册顺序套好装饰器返回 1 2 3 4 5 6 搭配 DI： ✓ 声明式组装，注册顺序即装饰顺序 ✓ 装饰器自身也能注入依赖（ILogger、IMemoryCache） ✓ 切换/增减装饰器 = 改注册，不改业务代码 这就是 ASP.NET Core 里做横切关注点（日志/缓存/重试/指标）的标准姿势。 六、装饰器 vs 代理 vs 适配器 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 三个结构型模式都\u0026#34;包一个对象\u0026#34;，容易混： 装饰器（Decorator）：同接口，加职责 → 重点：给对象增加功能（日志/缓存/重试） → 接口不变、行为增强 代理（Proxy）：同接口，控制访问 → 重点：管\u0026#34;能不能访问/什么时候访问\u0026#34;（延迟加载、权限、远程代理） → 接口不变、加\u0026#34;管控\u0026#34;，不是加业务功能 适配器（Adapter）：转换接口 → 重点：把不兼容的接口转成期望的（A 接口 → B 接口） → 接口变了（这才是关键区别） 一句话：装饰器加功能，代理加管控，适配器转接口。 七、何时用 / 何时别用 1 2 3 4 5 6 7 8 9 10 11 12 该用装饰器： ✓ 横切关注点（日志、缓存、重试、指标、鉴权、校验） ✓ 想给对象叠加功能，但不改原类 ✓ 功能要可组合、运行时配置顺序 别用： ✗ 功能和对象强绑定、永远不变 → 直接写进类更简单 ✗ 只有一层、不会扩展 → 别为了模式而模式 ✗ 需要\u0026#34;按条件选不同实现\u0026#34; → 那是策略/工厂的活 信号：当你发现自己在 N 个类的每个方法里 重复写\u0026#34;记日志 + try 重试 + 记指标\u0026#34; —— 就该上装饰器。 八、小结 问题：用继承叠加功能 → 子类组合爆炸 装饰器：同接口 + 内部持有一个同接口，可无限套娃 手写：装饰器基类转发 + 子类 override 加料，链式拼装 .NET 经典：Stream（FileStream/Buffered/GZip/Crypto）全家桶 DI 整合：Scrutor 的 Decorate\u0026lt;T, TDeco\u0026gt;() 一行组装 vs 代理/适配器：装饰器加功能、代理加管控、适配器转接口 典型场景：横切关注点（日志/缓存/重试/指标） 下一篇是本系列的重头——责任链模式，看 ASP.NET Core 中间件管道是怎么把它用到极致的。\n","date":"2025-12-18T10:00:00+08:00","permalink":"/posts/architecture/design-patterns/05-decorator/","title":"设计模式实战（五）：装饰器模式——透明地叠加功能"},{"content":"写在前面 上一篇工厂解决\u0026quot;造哪个\u0026quot;。这篇解决另一个常见痛点：对象很复杂，怎么造得清爽。\n想象一个对象有十几个字段，大多可选。你见过那种构造函数吗——new Order(cust, addr, null, null, true, null, \u0026quot;CNY\u0026quot;, 0, ...)，调用方数参数能数瞎。这就是伸缩构造函数反模式（Telescoping Constructor），建造者模式就是它的解药。\n一、问题：伸缩构造函数 1 2 3 4 5 6 7 8 9 10 11 12 13 // 反模式：为了覆盖各种可选组合，构造函数越开越多 public class Mail { public Mail(string from, string to) { ... } public Mail(string from, string to, string subject) { ... } public Mail(string from, string to, string subject, string body) { ... } public Mail(string from, string to, string subject, string body, IEnumerable\u0026lt;string\u0026gt; cc) { ... } public Mail(string from, string to, string subject, string body, IEnumerable\u0026lt;string\u0026gt; cc, bool html) { ... } // ... 还能继续开 } // 调用方噩梦：这个 true 是啥？两个 null 又是啥？ var m = new Mail(\u0026#34;a@x.com\u0026#34;, \u0026#34;b@y.com\u0026#34;, \u0026#34;Hi\u0026#34;, null, null, true); 1 2 3 4 5 6 7 8 9 10 11 这坨代码的病： ✗ 参数多，位置易错（传反了编译器不报） ✗ 一堆 null 占位，可读性极差 ✗ 新增字段 → 又要开构造函数，或改所有现有构造函数 ✗ 难以表达\u0026#34;必填 vs 可选\u0026#34; 另一种\u0026#34;病\u0026#34;：用无参构造 + 一堆 setter var m = new Mail(); m.From = ...; m.To = ...; m.Subject = ...; ✗ 对象在\u0026#34;半成品\u0026#34;状态就被别人看到（多线程下尤其危险） ✗ 字段可变，无法做不可变对象 二、建造者：分步构造 + 链式 1 2 3 4 5 6 7 8 9 10 11 12 意图：把复杂对象的构造与它的表示分离。 分步设置，最后一步统一\u0026#34;产出\u0026#34;，保证产出的对象是完整、一致的。 结构： Director/客户端 → 调 Builder 的一系列设置方法（链式）→ 最后 Build() 得对象 产出过程对象不可见（半成品状态不外泄） 好处： ✓ 参数有名字（.Subject(\u0026#34;Hi\u0026#34;) 比 null 位置清晰） ✓ 必填/可选分明，可在 Build() 时校验 ✓ 产出的对象可以是不可变的 ✓ 加字段 = 加一个方法，老调用不受影响（开闭） 三、手写一个 fluent builder 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 // 目标对象：不可变（只读字段） public sealed class Mail { public string From { get; } public string To { get; } public string Subject { get; } public string Body { get; } public IReadOnlyList\u0026lt;string\u0026gt; Cc { get; } public bool IsHtml { get; } private Mail(string from, string to, string subject, string body, IReadOnlyList\u0026lt;string\u0026gt; cc, bool isHtml) { From = from; To = to; Subject = subject; Body = body; Cc = cc; IsHtml = isHtml; } // 建造者（嵌套类，唯一能造 Mail 的途径） public class Builder { private string _from = \u0026#34;\u0026#34;, _to = \u0026#34;\u0026#34;, _subject = \u0026#34;\u0026#34;, _body = \u0026#34;\u0026#34;; private List\u0026lt;string\u0026gt; _cc = new(); private bool _isHtml; public Builder From(string v) { _from = v; return this; } public Builder To(string v) { _to = v; return this; } public Builder Subject(string v){ _subject = v; return this; } public Builder Body(string v) { _body = v; return this; } public Builder Cc(string v) { _cc.Add(v); return this; } public Builder AsHtml() { _isHtml = true; return this; } public Mail Build() { if (string.IsNullOrEmpty(_from) || string.IsNullOrEmpty(_to)) throw new InvalidOperationException(\u0026#34;From/To 必填\u0026#34;); return new Mail(_from, _to, _subject, _body, _cc, _isHtml); } } } // 用：链式调用，一目了然 var mail = new Mail.Builder() .From(\u0026#34;a@x.com\u0026#34;) .To(\u0026#34;b@y.com\u0026#34;) .Subject(\u0026#34;Hi\u0026#34;) .Body(\u0026#34;正文...\u0026#34;) .Cc(\u0026#34;boss@z.com\u0026#34;) .AsHtml() .Build(); // 校验通过才产出完整对象 1 2 3 4 5 对比开头的噩梦： - 每个参数有名字，传不错位置 - 必填校验在 Build() 里，半成品不会泄漏 - 产出的 Mail 不可变，天然线程安全 - 加字段 = Builder 加个方法，老代码不动 四、record 让建造者更简洁 如果对象不需要复杂校验，C# record + with 能省掉一大半样板：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // record 自带\u0026#34;with 表达式\u0026#34;，本质就是个轻量建造者 public record MailSettings(string From, string To, string Subject = \u0026#34;\u0026#34;, string Body = \u0026#34;\u0026#34;, bool IsHtml = false); // 用：必填位参 + 可选命名参数 + with 覆盖 var m = new MailSettings(\u0026#34;a@x.com\u0026#34;, \u0026#34;b@y.com\u0026#34;) with { Subject = \u0026#34;Hi\u0026#34;, IsHtml = true }; // 或要链式体验，用 record 写个轻量 builder public record MailBuilder(string From, string To) { public string Subject { get; init; } = \u0026#34;\u0026#34;; public string Body { get; init; } = \u0026#34;\u0026#34;; public bool IsHtml { get; init; } } var b = new MailBuilder(\u0026#34;a@x.com\u0026#34;, \u0026#34;b@y.com\u0026#34;) { Subject = \u0026#34;Hi\u0026#34;, IsHtml = true }; 1 2 3 4 5 6 7 record + with / init： ✓ 极少样板代码 ✓ 不可变 ✓ 命名清晰 适合：对象不复杂、校验少。 对象复杂、需要严格分步和校验时，还是用上面的经典 builder。 五、.NET 里建造者无处不在 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 你天天见 builder，认出来了吗： EF Core： modelBuilder.Entity\u0026lt;Order\u0026gt;() .HasKey(o =\u0026gt; o.Id) .HasOne(o =\u0026gt; o.Customer) .WithMany(c =\u0026gt; c.Orders); // ModelBuilder 是 builder ASP.NET Core： var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddSwagger(); var app = builder.Build(); // 经典分步构造 + Build() 配置： configurationBuilder.AddJsonFile(\u0026#34;app.json\u0026#34;) .AddEnvironmentVariables() .Build(); 字符串： new StringBuilder().Append(\u0026#34;a\u0026#34;).Append(\u0026#34;b\u0026#34;).ToString(); // 最朴素的 builder 它们共同点：链式设置 + 最后一步产出。 这就是建造者模式的\u0026#34;语言级习惯\u0026#34;。 六、建造者 vs 工厂 1 2 3 4 5 6 7 8 9 10 11 12 13 14 常被搞混，区别其实清楚： 工厂：一步产出对象 var order = OrderFactory.Create(type); → 关心\u0026#34;造哪个\u0026#34;，一次到位 建造者：分步产出对象 var order = new OrderBuilder().For(cust).With(items).Express().Build(); → 关心\u0026#34;怎么配置这一个\u0026#34;，逐步装配 选择： 对象创建简单、有几种类型 → 工厂 对象字段多、可选参数多、要分步配置 → 建造者 两者可配合：工厂返回一个 builder，让你再细调 七、何时用 / 何时别用 1 2 3 4 5 6 7 8 9 10 11 12 13 该用建造者： ✓ 字段多（5+），尤其大多是可选 ✓ 构造需要分步、有顺序、要校验 ✓ 想要产出的对象不可变 ✓ 配置类、DSL 式 API（Fluent 接口） 别用： ✗ 字段少、必填为主 → 普通构造函数 + record 就够 ✗ 对象本身就该可变、简单 → 别硬套 builder 增加样板 ✗ 只有一两种组合 → 工厂更直接 信号：当你写构造函数写到参数列表比方法体还长、 调用方需要数着位置传 null —— 就该上建造者了。 八、小结 问题：伸缩构造函数（参数越开越多）和 setter 半成品泄漏 建造者模式：分步链式设置 + 最后 Build() 产出，保证完整、可校验、可不可变 手写：嵌套 Builder 类，每个字段一个返回 this 的方法，Build() 时校验 现代 C#：record + with/init 能省掉大部分样板（简单场景） .NET 里随处可见：EF Core ModelBuilder、WebApplication、配置、StringBuilder vs 工厂：工厂一步造\u0026quot;哪个\u0026quot;，建造者分步配\u0026quot;这一个\u0026quot; 信号：构造参数列表长到要数 null，就上建造者 下一篇讲装饰器模式——怎么不改原类、透明地给对象叠加新功能（日志/缓存/重试）。\n","date":"2025-12-14T10:00:00+08:00","permalink":"/posts/architecture/design-patterns/04-builder/","title":"设计模式实战（四）：建造者模式——优雅地构造复杂对象"},{"content":"写在前面 上一篇策略模式解决了\u0026quot;选算法\u0026quot;。这篇讲创建型模式的代表——工厂模式，解决\u0026quot;造对象\u0026quot;。\n很多新手觉得工厂模式绕：简单工厂、工厂方法、抽象工厂，三个名字像、又不一样。这篇把它们一次讲清，并落到 .NET 上——你会发现 DI 容器本身就是个超级工厂，现代 .NET 开发里，大部分时候你不用手写工厂。\n一、问题：到处 new 1 2 3 4 5 6 7 8 9 10 11 // 反例：直接 new，和具体类型死死绑死 public class OrderService { public void Place(Order order) { var repo = new SqlOrderRepository(); // 写死具体实现 var notifier = new EmailNotifier(); // 写死 repo.Save(order); notifier.Send(order); } } 1 2 3 4 5 6 7 8 9 这坨代码的病： ✗ 依赖具体类（SqlOrderRepository），不是抽象 → 违反依赖倒置（DIP） ✗ 换实现（改 Mongo、换短信通知）要改这里 ✗ 没法注入假实现做单元测试 ✗ 如果构造对象本身复杂（要查配置、要条件判断），new 就更难看 病根：对象\u0026#34;怎么造\u0026#34;和对象\u0026#34;怎么用\u0026#34;搅在一起了。 解法：把\u0026#34;造\u0026#34;的责任抽出去——这就是工厂。 二、简单工厂（静态工厂） 最朴素：一个方法，按参数决定造哪个。\n1 2 3 4 5 6 7 8 9 10 11 public static class DiscountFactory { public static IDiscountStrategy Create(string type) =\u0026gt; type switch { \u0026#34;Normal\u0026#34; =\u0026gt; new NormalDiscount(), \u0026#34;Vip\u0026#34; =\u0026gt; new VipDiscount(), \u0026#34;Employee\u0026#34; =\u0026gt; new EmployeeDiscount(), _ =\u0026gt; throw new ArgumentException($\u0026#34;未知折扣类型: {type}\u0026#34;) }; } // 用：var d = DiscountFactory.Create(\u0026#34;Vip\u0026#34;); 1 2 3 4 5 6 7 特点： ✓ 简单，集中了创建逻辑，调用方不关心具体类 ✗ 严格说它不是\u0026#34;GoF 模式\u0026#34;，只是个习惯用法 ✗ 违反开闭：加新类型要改这个 switch 适合：创建逻辑简单、类型不多、不频繁变化。 （上一篇策略模式里的\u0026#34;字典派发\u0026#34;，其实就是简单工厂的现代版。） 三、工厂方法（Factory Method） 把\u0026quot;创建\u0026quot;推迟到子类：父类定义流程，子类决定造哪个。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 父类：定义\u0026#34;创建 + 使用\u0026#34;的流程，但\u0026#34;造哪个\u0026#34;交给子类 public abstract class NotificationSender { protected abstract INotifier CreateNotifier(); // 工厂方法（抽象） public void Send(string msg) { var notifier = CreateNotifier(); // 用子类造的对象 notifier.Send(msg); } } // 子类 A：决定造 EmailNotifier public class EmailSender : NotificationSender { protected override INotifier CreateNotifier() =\u0026gt; new EmailNotifier(); } // 子类 B：决定造 SmsNotifier public class SmsSender : NotificationSender { protected override INotifier CreateNotifier() =\u0026gt; new SmsNotifier(); } 1 2 3 4 5 6 vs 简单工厂： 简单工厂：一个方法里 switch（加类型改 switch） 工厂方法：每种类型一个子类（加类型加子类，老代码不动 → 开闭） 代价：类变多。 适合：创建逻辑需要配合一套\u0026#34;使用流程\u0026#34;，且类型会扩展。 四、抽象工厂（Abstract Factory） 造一\u0026quot;族\u0026quot;相关对象，保证它们配套。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 工厂接口：造一族 UI 控件 public interface IUiFactory { IButton CreateButton(); ITextBox CreateTextBox(); } // 一族：Windows 风格 public class WindowsUiFactory : IUiFactory { public IButton CreateButton() =\u0026gt; new WindowsButton(); public ITextBox CreateTextBox() =\u0026gt; new WindowsTextBox(); } // 另一族：Mac 风格 public class MacUiFactory : IUiFactory { public IButton CreateButton() =\u0026gt; new MacButton(); public ITextBox CreateTextBox() =\u0026gt; new MacTextBox(); } 1 2 3 4 5 6 7 8 9 vs 工厂方法： 工厂方法：造\u0026#34;一个\u0026#34;产品（一个方法） 抽象工厂：造\u0026#34;一族\u0026#34;配套产品（多个方法） 价值：保证造出来的一组对象是配套的（不会 Windows 按钮配 Mac 文本框）。 典型场景：跨平台 UI、多数据库方言、多主题样式。 注意：抽象工厂常被滥用。如果只有一种产品，用工厂方法/简单工厂就够， 别为了\u0026#34;显得有体系\u0026#34;上抽象工厂。 五、三者对比 1 2 3 4 5 6 7 8 9 10 造什么 加新产品 复杂度 适用 ───────────────────────────────────────────────────────────── 简单工厂 单个产品 改 switch 低 类型少、简单 工厂方法 单个产品 加子类 中 类型会扩展 抽象工厂 一族产品 加子类+改接口 高 多族配套、跨平台 记忆窍门： 简单工厂 = 一个函数 switch 工厂方法 = 把函数变成虚方法，让子类决定 抽象工厂 = 工厂方法升级版，一个工厂造多个配套产品 六、.NET DI 容器 = 超级工厂 现代 .NET 里，你几乎不用手写工厂——DI 容器（IServiceCollection / IServiceProvider）就是工厂的终极形态。\n1 2 3 4 5 6 7 8 9 10 // 注册：告诉\u0026#34;工厂\u0026#34;接口对应什么实现 services.AddScoped\u0026lt;IOrderRepository, SqlOrderRepository\u0026gt;(); services.AddSingleton\u0026lt;INotifier, EmailNotifier\u0026gt;(); services.AddTransient\u0026lt;IDiscountStrategy, VipDiscount\u0026gt;(); // 用：从\u0026#34;工厂\u0026#34;要对象，不关心怎么造 public class OrderService(IOrderRepository _repo, INotifier _notifier) { public void Place(Order o) { _repo.Save(o); _notifier.Send(o); } } 1 2 3 4 5 6 7 8 DI 容器相比手写工厂： ✓ 自动接管创建，包括依赖链（A 依赖 B，B 依赖 C，容器递归造） ✓ 生命周期（Singleton/Scoped/Transient）集中管理 ✓ 切换实现 = 改注册一行，不改业务代码 ✓ 测试时换假实现 = 改注册，详见《.NET 单元测试（四）：依赖注入与可测试性》 → 这就是为什么 ASP.NET Core 里很少看到手写 XxxFactory： DI 把\u0026#34;工厂模式\u0026#34;这件事彻底标准化了。 需要手写工厂的场景 1 2 3 4 5 6 7 8 9 10 11 12 // 场景：运行时才能决定造哪个（配置/类型/参数） services.AddScoped\u0026lt;IOrderRepository\u0026gt;(sp =\u0026gt; { var config = sp.GetRequiredService\u0026lt;IConfiguration\u0026gt;(); return config[\u0026#34;Db\u0026#34;] == \u0026#34;Mongo\u0026#34; ? new MongoOrderRepository(config[\u0026#34;Conn\u0026#34;]) : new SqlOrderRepository(config[\u0026#34;Conn\u0026#34;]); }); // 工厂 lambda：容器调用时才执行 // 场景：解析未知类型（插件/动态加载） var handler = ActivatorUtilities.CreateInstance(sp, typeFromConfig); // 或按 key 取一批已注册的服务（variance） 1 2 3 4 5 6 什么时候仍要手写工厂： - 运行时根据配置/类型决定实现（DI 注册时还不确定） - 动态解析、插件化、按 key 派发（容器不直接支持\u0026#34;按字符串取\u0026#34;） - 构造对象需要运行时参数（不能预先注册） 其余情况，让 DI 容器当你的工厂。 七、小结 问题：到处 new 把\u0026quot;造\u0026quot;和\u0026quot;用\u0026quot;绑死，违反依赖倒置 简单工厂：一个方法 switch 决定造哪个（类型少时够用） 工厂方法：抽象方法交给子类决定（加类型加子类，开闭） 抽象工厂：造一族配套产品（跨平台/多主题） 选型：简单→简单工厂；扩展→工厂方法；多族配套→抽象工厂 .NET DI 容器是终极工厂：日常开发基本不手写工厂，改注册即可 手写工厂仍有场景：运行时决定、动态解析、运行时参数 下一篇讲建造者模式——当构造对象本身很复杂（一堆可选参数）时，怎么造得优雅。\n","date":"2025-12-10T10:00:00+08:00","permalink":"/posts/architecture/design-patterns/03-factory/","title":"设计模式实战（三）：工厂模式——创建对象的烦恼与 DI 容器"},{"content":"写在前面 上一篇讲 SOLID 的开闭原则时埋了个雷：折扣逻辑别写成一坨 if/switch。怎么改？答案就是策略模式。\n这是后端开发用得最多的模式，没有之一。它解决的问题极常见：同一个动作有多种做法，需要按情况选一个。\n一、问题：一坨 switch 1 2 3 4 5 6 7 8 9 10 11 12 // 反例：折扣计算，每加一种活动就往这里塞 public decimal CalculateDiscount(string type, decimal price) { return type switch { \u0026#34;Normal\u0026#34; =\u0026gt; price, // 不打折 \u0026#34;Vip\u0026#34; =\u0026gt; price * 0.8m, // VIP 八折 \u0026#34;Coupon\u0026#34; =\u0026gt; price - 50m, // 满减券 \u0026#34;Employee\u0026#34; =\u0026gt; price * 0.5m, // 员工五折 _ =\u0026gt; price }; } 1 2 3 4 5 6 7 这坨代码的病： ✗ 违反开闭原则：加\u0026#34;双11满3件打7折\u0026#34;要改这个方法（动老代码） ✗ 越长越难维护，分支互相影响 ✗ 算法全堆一处，没法单独复用/测试 ✗ DiscountService 依赖了所有折扣细节 病根：把\u0026#34;选择哪个算法\u0026#34;和\u0026#34;算法本身\u0026#34;搅在一起了。 二、策略模式：把算法拆开 1 2 3 4 5 6 7 8 9 10 11 12 意图：定义一系列算法，各自封装，可互相替换。 算法的变化不影响使用它的客户端。 结构： IStrategy ← 算法的抽象接口 ConcreteStrategy ← 每个算法一个实现 Context ← 持有某个策略，把工作委托给它 好处： ✓ 新增算法 = 加一个类，老代码不动（开闭） ✓ 算法可单独测试、复用 ✓ 客户端不关心算法细节，只管\u0026#34;调它\u0026#34; 三、经典 OOP 实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 // 1. 策略接口 public interface IDiscountStrategy { decimal Apply(decimal price); } // 2. 每个算法一个实现 public class NormalDiscount : IDiscountStrategy { public decimal Apply(decimal price) =\u0026gt; price; } public class VipDiscount : IDiscountStrategy { public decimal Apply(decimal price) =\u0026gt; price * 0.8m; } public class EmployeeDiscount : IDiscountStrategy { public decimal Apply(decimal price) =\u0026gt; price * 0.5m; } // 3. Context：把\u0026#34;算折扣\u0026#34;委托给注入进来的策略 public class PricingService(IDiscountStrategy _discount) { public decimal GetFinalPrice(decimal price) =\u0026gt; _discount.Apply(price); } 1 2 现在加\u0026#34;双11折扣\u0026#34;：写一个 DoubleElevenDiscount 类， PricingService 一行不用改 → 开闭原则达成。 四、现代 C#：不必每次都建类层次 很多策略其实就是\u0026quot;一段逻辑\u0026quot;，用 Func\u0026lt;decimal, decimal\u0026gt; 或 delegate 更轻：\n1 2 3 4 5 6 7 8 9 10 11 12 // 策略就是一个函数 public class PricingService { private readonly Func\u0026lt;decimal, decimal\u0026gt; _discount; public PricingService(Func\u0026lt;decimal, decimal\u0026gt; discount) =\u0026gt; _discount = discount; public decimal GetFinalPrice(decimal price) =\u0026gt; _discount(price); } // 注册时直接传 lambda（配合 DI） services.AddTransient\u0026lt;PricingService\u0026gt;(_ =\u0026gt; new PricingService(p =\u0026gt; p * 0.8m)); // VIP 多策略按 key 选——经典的\u0026quot;策略 + 工厂\u0026quot;组合，用字典派发最干净：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 public class DiscountCalculator { // 字典：type → 算法函数 private readonly Dictionary\u0026lt;string, Func\u0026lt;decimal, decimal\u0026gt;\u0026gt; _strategies = new() { [\u0026#34;Normal\u0026#34;] = p =\u0026gt; p, [\u0026#34;Vip\u0026#34;] = p =\u0026gt; p * 0.8m, [\u0026#34;Coupon\u0026#34;] = p =\u0026gt; Math.Max(0, p - 50m), [\u0026#34;Employee\u0026#34;] = p =\u0026gt; p * 0.5m, }; public decimal Calculate(string type, decimal price) =\u0026gt; _strategies.TryGetValue(type, out var fn) ? fn(price) : price; } 1 2 3 4 5 6 7 对比开头的 switch： - 加新折扣 = 字典加一行，方法体不动 - 算法集中、清晰、可测 - 没有多余的类层次，但拿到了策略模式的所有好处 这就是 .NET 里策略模式的现代写法—— \u0026#34;意图\u0026#34;没变（算法族可替换），\u0026#34;实现\u0026#34;从类层次简化成函数。 五、.NET 里策略无处不在 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 你天天在用策略模式，只是没意识到： LINQ： list.OrderBy(x =\u0026gt; x.Name) // x =\u0026gt; x.Name 就是一个\u0026#34;排序策略\u0026#34; list.Where(x =\u0026gt; x.Active) // 过滤策略 → 同样的 OrderBy，传不同 keySelector = 不同策略，框架替你 dispatch 集合比较： new SortedSet\u0026lt;int\u0026gt;(Comparer\u0026lt;int\u0026gt;.Create((a, b) =\u0026gt; b - a)) // 降序比较策略 ASP.NET Core： config.AddJsonFile() / .AddEnvironmentVariables() // 每个\u0026#34;配置源\u0026#34;都是策略 serializer options：不同的命名策略、枚举转换策略 Task.Run / Parallel.ForEach 的并行度策略、 EF Core 的值比较器（IEqualityComparer\u0026lt;T\u0026gt;）…… 本质都是：把\u0026#34;可替换的一段逻辑\u0026#34;作为参数传进去。 六、经典 OOP 形态 vs 现代函数形态，怎么选 1 2 3 4 5 6 7 8 9 10 11 12 13 用类（IDiscountStrategy 接口 + 实现）： ✓ 算法复杂、有状态、需要注入依赖（VIP 折扣要查用户等级） ✓ 多处复用、需要独立测试某个算法 ✓ 团队习惯显式类型 用函数（Func / delegate / 字典）： ✓ 算法简单、无状态、就几行 ✓ 想轻量，不想为一小段逻辑建一堆类 ✓ 动态组合（字典派发、运行时拼装） 经验：简单用函数，复杂用类。 别为了\u0026#34;显得正规\u0026#34;给三行 lambda 套个接口。 （第一条讲的 YAGNI） 七、何时用 / 何时别用 1 2 3 4 5 6 7 8 9 10 11 12 该用策略模式： ✓ 一组同类算法，按条件选一个（折扣/支付/排序/校验…） ✓ 算法会随业务不断新增 ✓ 想把选择逻辑和算法实现分开 别用： ✗ 只有一两种情况，if/switch 清清楚楚 → 别过度设计 ✗ 分支不是\u0026#34;选算法\u0026#34;，而是\u0026#34;按数据走不同流程\u0026#34; → 那是状态/责任链的活 ✗ 算法之间共享大量状态、关系紧密 → 硬拆成策略反而更乱 信号：当 switch 分支多到让你皱眉、新增分支让你害怕， 就是策略模式该上场的时候。 八、小结 问题：一坨 switch 把\u0026quot;选算法\u0026quot;和\u0026quot;算法本身\u0026quot;搅一起，违反开闭 策略模式：算法各自封装、可替换，客户端只依赖抽象 经典实现：IStrategy 接口 + 多实现 + Context 委托 现代 C#：简单策略用 Func/delegate/字典派发，不必建类层次 .NET 里随处可见：LINQ 的 OrderBy/Where、配置源、比较器都是策略 选择：算法简单用函数，复杂/有状态用类 信号：switch 开始让你皱眉，就上策略 下一篇讲工厂模式——创建对象的烦恼，以及 .NET DI 容器怎么把\u0026quot;工厂\u0026quot;这件事彻底接管。\n","date":"2025-12-06T10:00:00+08:00","permalink":"/posts/architecture/design-patterns/02-strategy/","title":"设计模式实战（二）：策略模式——干掉那一坨 if/switch"},{"content":"写在前面 我写了 DDD 系列，里面反复出现\u0026quot;策略\u0026quot;\u0026ldquo;工厂\u0026quot;\u0026ldquo;观察者\u0026quot;这些词——它们都是设计模式。这篇开一个新坑：「设计模式实战（.NET 视角）」。\n市面上讲 23 个 GoF 模式的文章海了去了，这个系列的差异化有三点：用 现代 C#（Func/record/模式匹配）写，而不是 1998 年的 Java 式类层次；用 ASP.NET Core / .NET 源码做案例；回链你已有的内容（SOLID 接整洁架构、责任链接中间件、观察者接领域事件…）。\n第一篇不急着讲具体模式，先把地基打好：模式到底是什么，以及它们共同的底座——SOLID。\n一、模式到底是什么 1 2 3 4 5 6 7 8 9 10 常见误解：模式是\u0026#34;必须照着写的标准答案\u0026#34; 真相： 模式是\u0026#34;已被验证的、有名字的设计经验\u0026#34; - 它首先是\u0026#34;词汇\u0026#34;：你说\u0026#34;这里用策略模式\u0026#34;，对方秒懂你在说什么 → 沟通效率 \u0026gt;\u0026gt; 代码本身 - 其次是\u0026#34;经验浓缩\u0026#34;：前人踩过的坑、试过的解法，给它起个名 - 它有\u0026#34;适用场景\u0026#34;和\u0026#34;代价\u0026#34;，不是万能解 类比：模式之于编程，像\u0026#34;套路\u0026#34;之于棋手。 背套路不是为了死套，是为了不在低级地方浪费时间。 1 2 3 4 5 6 7 学模式的三个层次： 1. 知道有这个名字（能听懂别人说） 2. 会写出来（能照着实现） 3. 知道什么时候用、什么时候不用（真正会用）← 目标 最高境界：忘了模式名，但写出来的代码天然符合模式。 因为好设计的归宿就那几种，殊途同归。 二、模式的代价（别滥用） 1 2 3 4 5 6 7 8 9 10 模式不是免费的： ✗ 引入抽象 → 代码变多、间接层变多 ✗ 增加理解成本（读代码的人要认识这个模式） ✗ 用错地方 → 过度设计，比没用更糟 铁律：模式应对的是\u0026#34;真实存在、反复出现的复杂度\u0026#34;， 而不是\u0026#34;想象中、将来可能\u0026#34;的复杂度（YAGNI）。 一次 if/switch 能解决的事，别上策略模式。 等真的\u0026#34;第三个算法\u0026#34;出现、switch 开始膨胀，再重构不迟。 三、SOLID：所有模式的底座 23 个 GoF 模式，骨子里都在贯彻五条原则——SOLID。理解了 SOLID，模式就是它的具体应用。\n1 2 3 4 5 S — Single Responsibility Principle 单一职责 O — Open/Closed Principle 开闭原则 L — Liskov Substitution Principle 里氏替换 I — Interface Segregation Principle 接口隔离 D — Dependency Inversion Principle 依赖倒置 3.1 单一职责（SRP） 1 2 3 4 5 6 7 8 一个类/函数，只有一个变化的理由。 反例：一个 OrderService 又算价格、又发邮件、又写日志、又管权限 → 任何一个需求变化（改邮件模板、改权限规则）都要动这个类 → 改一处怕牵连，测试爆炸 正解：拆。算价归 PricingService，发邮件归 Notifier，各管一摊。 \u0026#34;变化的理由\u0026#34; = \u0026#34;谁会来要求改它\u0026#34;。 3.2 开闭原则（OCP） 1 2 3 4 5 6 7 8 9 对扩展开放，对修改关闭。 → 加新功能时，加新代码，不改老代码。 反例：折扣逻辑写在一串 if/switch 里 新增\u0026#34;满减\u0026#34;活动要往 switch 里塞分支 → 改老代码（有回归风险） 正解：抽象出 IDiscount，每种折扣一个实现 新增满减 = 加一个类，老代码一行不动 （这就是策略模式，下一篇讲） 3.3 里氏替换（LSP） 1 2 3 4 5 6 7 8 9 子类必须能无感替换父类，不破坏程序正确性。 经典反例：正方形继承长方形 Rectangle.SetWidth(5).SetHeight(3).Area() == 15 Square 重写 SetWidth 同时改高 → Area != 15 → 行为变了 规矩：子类可以\u0026#34;更强\u0026#34;，但不能\u0026#34;违约\u0026#34;。 覆写方法不能抛父类没声明的异常、不能改预期能接受的入参范围。 \u0026#34;is-a\u0026#34; 关系一旦在行为上不成立，继承就是错的。 3.4 接口隔离（ISP） 1 2 3 4 5 6 7 8 不要强迫调用方依赖它用不到的方法。 → 接口要小而专，不要胖接口。 反例：一个 IBird 既有 Fly() 又有 Walk() 企鹅实现 IBird 就得写个抛异常的 Fly() → 别扭 正解：拆成 IBird(Walk)、IFlyingBird(Fly)，各取所需 → 客户端只依赖它真正用的接口 3.5 依赖倒置（DIP）—— 最重要的一条 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 高层模块不该依赖低层模块，二者都该依赖抽象。 抽象不依赖细节，细节依赖抽象。 → \u0026#34;依赖方向永远指向抽象\u0026#34; 反例：OrderController 直接 new SqlOrderRepository() → Controller 依赖了具体的 SQL 实现 → 换 Mongo、换内存测试，都要改 Controller 正解：OrderController 依赖 IOrderRepository（抽象） 具体的 SqlOrderRepository 实现 IOrderRepository 由 DI 容器注入 这就是 DDD 第六篇「整洁架构」的核心—— 领域层定义接口（抽象），基础设施层实现，依赖方向向内。 SOLID 的 D，几乎是现代框架（ASP.NET Core DI）的立身之本。 四、SOLID 一张图串起来 1 2 3 4 5 6 7 8 9 SRP：一个类一个变化理由 → 控制\u0026#34;改起来怕牵连\u0026#34; OCP：加功能不改老代码 → 控制\u0026#34;扩展的代价\u0026#34; LSP：子类不违约 → 控制\u0026#34;继承的安全性\u0026#34; ISP：接口要小 → 控制\u0026#34;依赖的纯洁度\u0026#34; DIP：依赖抽象不依赖细节 → 控制\u0026#34;解耦程度\u0026#34; 五条本质就一句话： 把\u0026#34;变化\u0026#34;圈起来、关进笼子，让它只影响该影响的地方。 23 个 GoF 模式，全是这条原则的具体套路。 五、23 个模式分类速览 1 2 3 4 5 6 7 8 9 10 11 12 创建型（Creational）—— 怎么造对象 单例、工厂方法、抽象工厂、建造者、原型 结构型（Structural）—— 怎么组合对象 适配器、装饰器、代理、外观、组合、桥接、享元 行为型（Behavioral）—— 对象怎么协作 策略、责任链、观察者、命令、状态、模板方法、 迭代器、中介者、备忘录、访问者 本系列挑后端最常用的讲（不追求 23 个全覆盖）： 策略 / 工厂 / 建造者 / 装饰器 / 责任链 / 观察者 / 单例 / 命令 / 状态 六、.NET 里模式的\u0026quot;现代形态\u0026rdquo; 1 2 3 4 5 6 7 8 9 10 11 12 13 很多模式在 .NET 里已经\u0026#34;融化\u0026#34;进语言/框架，不用手写类层次了： 策略 → Func\u0026lt;T\u0026gt; / delegate / 字典派发（下一篇详讲） 工厂 → DI 容器（IServiceProvider）天然就是 观察者 → event / 委托 / IObservable\u0026lt;T\u0026gt; 内置 迭代器 → foreach + IEnumerable\u0026lt;T\u0026gt;，语言级支持 单例 → DI 的 Singleton 生命周期，一行注册 建造者 → 链式 API（EF Core ModelBuilder 等）随处可见 启示：学模式时，要分清\u0026#34;模式的意图\u0026#34;和\u0026#34;模式的经典实现\u0026#34;。 意图不变（解耦算法、封装创建、订阅变化…）， 实现可以很现代、很简洁。 死记 UML 类图没用，懂了意图，C# 三行就能写出来。 七、小结 模式 = 有名字的设计经验，首先是沟通词汇，不是必须照抄的答案 别滥用：模式应对真实反复的复杂度，不是想象中的（YAGNI） SOLID 是底座：SRP/OCP/LSP/ISP/DIP——本质都是\u0026quot;把变化关进笼子\u0026rdquo; DIP 最重要：依赖抽象不依赖细节，是整洁架构和 DI 的根基 23 个模式分创建/结构/行为三类；本系列挑后端最常用的 9 个（+SOLID） .NET 里模式常已融化进语言：学意图，别死记 UML 下一篇从最常用的策略模式开始——看它怎么把一坨 if/switch 变成干净的算法族。\n","date":"2025-12-02T10:00:00+08:00","permalink":"/posts/architecture/design-patterns/01-overview/","title":"设计模式实战（一）：总览——模式的本质与 SOLID"},{"content":"写在前面 前五篇讲了 DDD 的\u0026quot;道\u0026quot;——通用语言、战略设计、聚合、实体值对象、领域事件、CQRS。最后这篇讲\u0026quot;术\u0026quot;：这些概念在 .NET 工程里到底怎么放。\n答案是一套分层架构——名字很多（整洁架构、洋葱架构、六边形/端口适配器架构），本质就一句话：依赖方向永远向内，领域模型在最中心、不依赖任何人。这篇用 .NET 把它落地，并收口整个 DDD 系列。\n这篇的分层结构和微服务系列第八篇（单个微服务的内部结构）是一致的——那里搭的是\u0026quot;一个服务的骨架\u0026quot;，这里补上\u0026quot;DDD 建模在骨架里怎么落地\u0026quot;。\n一、为什么需要分层架构 1 2 3 4 5 6 7 8 不分层的痛： Controller 直接调 SqlConnection、直接写 SQL、直接 return → 业务逻辑散落在 Controller / SQL / 前端 各处 → 改数据库要动 Controller，改接口要动 SQL → 没法测试（测个业务规则还要连数据库） 根因：职责没分离，依赖方向乱。 分层架构解决的就是\u0026#34;什么依赖什么\u0026#34;的纪律问题。 二、整洁架构的内核：依赖向内 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 整洁架构（Clean Architecture）/ 洋葱架构 / 六边形架构，本质相同： ┌──────────────────────┐ │ API / UI（最外层） │ 依赖 ↓ │ ┌──────────────────┐ │ │ │ Infrastructure │ │ 依赖 ↓ │ │ ┌────────────┐ │ │ │ │ │Application │ │ │ 依赖 ↓ │ │ │ ┌───────┐ │ │ │ │ │ │ │Domain │ │ │ │ ← 最内，不依赖任何人 │ │ │ └───────┘ │ │ │ │ │ └────────────┘ │ │ │ └──────────────────┘ │ └──────────────────────┘ 铁律：依赖方向只能\u0026#34;向内\u0026#34;（外层依赖内层，内层绝不依赖外层） Domain（领域）在最中心： - 只有纯领域逻辑（实体、值对象、聚合、领域事件、接口） - 不依赖 EF Core、不依赖 ASP.NET Core、不依赖任何基础设施 - 是一个\u0026#34;纯 C# 类库\u0026#34;，谁都不认识 外层认识内层，内层不认识外层。 → 改数据库、换 Web 框架，领域模型一行不用动。 1 2 3 4 六边形架构（端口与适配器）是同一思想的不同说法： 端口（Port）= 领域定义的接口（IOrderRepository） 适配器（Adapter）= 基础设施对接口的实现（EfOrderRepository） 领域只定义端口，不关心适配器是谁 → 基础设施可替换（六边形的六个边 = 可插拔的适配器） 三、四层职责划分 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 Domain（领域层）—— 最内，纯逻辑 - 实体、值对象、聚合、聚合根 - 领域事件、领域服务 - 仓储接口（IOrderRepository）、其他端口接口 - 不依赖任何外部框架 → 前五篇讲的建模，全在这层 Application（应用层）—— 用例编排 - 命令/查询（CQRS）、用例服务（OrderService） - 调用领域层完成业务，调度事务、发事件 - 定义\u0026#34;应用服务接口\u0026#34;，不关心持久化细节 → 很薄，只编排，不写业务规则（规则归 Domain） Infrastructure（基础设施层）—— 技术实现 - 仓储实现（EfOrderRepository : IOrderRepository） - EF Core DbContext、数据库连接 - 消息队列、缓存、第三方 API 客户端 - 领域事件的分发器实现、Outbox 投递 → 所有\u0026#34;脏活累活\u0026#34;和技术细节，全收在这层 API / Presentation（表现层）—— 最外 - Controller / Minimal API - HTTP 入参出参、DTO 与领域对象的转换 - 认证、异常处理中间件 → 只做\u0026#34;协议转换\u0026#34;，不含业务逻辑 1 2 3 4 5 6 7 8 依赖关系（编译期引用）： API → Application → Domain Infrastructure → Application → Domain └──（实现 Domain 定义的接口） 注意：Infrastructure 引用 Domain（为了实现仓储接口）， 但 Domain 不引用 Infrastructure（依赖倒置）。 这正是\u0026#34;端口在内层定义、适配器在外层实现\u0026#34;的体现。 四、.NET 工程结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 OrderService/ （一个限界上下文 = 一个解决方案） ├── src/ │ ├── OrderService.Domain/ ← 领域层（纯，无框架依赖） │ │ ├── Aggregates/ │ │ │ └── Orders/ │ │ │ ├── Order.cs （聚合根） │ │ │ ├── OrderItem.cs （内部实体） │ │ │ └── OrderStatus.cs │ │ ├── ValueObjects/ │ │ │ ├── Money.cs Address.cs │ │ ├── Events/ │ │ │ └── OrderPlaced.cs （领域事件） │ │ └── Ports/ （端口接口） │ │ └── IOrderRepository.cs │ │ │ ├── OrderService.Application/ ← 应用层（编排） │ │ ├── Commands/ （CQRS 写侧） │ │ │ └── CancelOrderHandler.cs │ │ ├── Queries/ （CQRS 读侧） │ │ │ └── OrderQueryService.cs │ │ └── Abstractions/ │ │ └── IUnitOfWork.cs │ │ │ ├── OrderService.Infrastructure/ ← 基础设施（实现） │ │ ├── Persistence/ │ │ │ ├── EfOrderRepository.cs （实现 Domain 的 IOrderRepository） │ │ │ └── OrderDbContext.cs （EF Core） │ │ └── Events/ │ │ └── MediatRDispatcher.cs （领域事件分发） │ │ │ └── OrderService.Api/ ← 表现层（最外） │ ├── Program.cs │ ├── Controllers/ │ │ └── OrdersController.cs │ └── appsettings.json └── tests/ ├── OrderService.Domain.Tests/ ← 领域层测试（纯单元，超快） └── OrderService.Application.Tests/ ← 应用层测试 五、各层代码长什么样 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // Domain：纯领域，不认识 EF Core / ASP.NET Core namespace OrderService.Domain.Orders; public class Order : Entity\u0026lt;OrderId\u0026gt;, IAggregateRoot { public OrderStatus Status { get; private set; } public void Cancel(string reason) { if (Status \u0026gt;= OrderStatus.Shipped) throw new DomainException(\u0026#34;已发货不能取消\u0026#34;); Status = OrderStatus.Cancelled; } } // Domain 层定义端口（接口），不关心实现 public interface IOrderRepository { Task\u0026lt;Order?\u0026gt; Find(OrderId id, CancellationToken ct); } 1 2 3 4 5 6 7 8 9 10 11 // Application：编排，薄 namespace OrderService.Application.Commands; public class CancelOrderHandler(IOrderRepository repo, IUnitOfWork uow) { public async Task Handle(CancelOrderCommand cmd, CancellationToken ct) { var order = await repo.Find(cmd.OrderId, ct) ?? throw new NotFoundException(); order.Cancel(cmd.Reason); // 业务规则在领域层 await uow.SaveChangesAsync(ct); // 不关心是 EF 还是别的 } } 1 2 3 4 5 6 7 // Infrastructure：实现端口（依赖倒置） namespace OrderService.Infrastructure.Persistence; public class EfOrderRepository(OrderDbContext db) : IOrderRepository // 实现 Domain 接口 { public Task\u0026lt;Order?\u0026gt; Find(OrderId id, CancellationToken ct) =\u0026gt; db.Orders.FirstOrDefaultAsync(o =\u0026gt; o.Id == id, ct); } 1 2 3 4 5 6 7 // API：协议转换，无业务逻辑 app.MapPut(\u0026#34;/orders/{id}/cancel\u0026#34;, async (OrderId id, CancelRequest req, CancelOrderHandler h, CancellationToken ct) =\u0026gt; { await h.Handle(new(id, req.Reason), ct); return Results.NoContent(); }); 六、防贫血：业务逻辑回归领域层 1 2 3 4 5 6 7 8 9 10 11 12 整洁架构落地最容易跑偏的点：贫血模型 贫血（错）：Order 只有属性，规则写在 CancelOrderHandler 里 → Handler 变成\u0026#34;事务脚本\u0026#34;，领域层形同虚设 → 规则分散、难测、难复用 充血（对）：规则在 Order.Cancel() 内部 Handler 只负责\u0026#34;加载→调用→保存\u0026#34;，不掺合规则 → 规则内聚、可单测、可复用 落地纪律：应用层只编排，业务规则一律下沉到领域层。 问自己：\u0026#34;这段逻辑，放实体方法里是不是更合适？\u0026#34; 七、可测试性：分层最大的红利 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 领域层是纯 C#、无依赖 → 单元测试极快、极简单 （呼应我的《.NET 单元测试》系列） [Fact] public void Cancel_Should_Throw_When_Shipped() { var order = Order.Place(NewId(), AnyAddress(), [Line(Money(99), 1)]); order.MarkShipped(); var act = () =\u0026gt; order.Cancel(\u0026#34;误操作\u0026#34;); act.Should().Throw\u0026lt;DomainException\u0026gt;(\u0026#34;已发货不能取消\u0026#34;); } // 不连数据库、不起 Web 服务器、毫秒级跑完 // 因为领域层不依赖任何基础设施，测试就是 new 对象 + 断言 1 2 3 4 这正是分层 + 依赖倒置的核心回报： - 领域规则可以被快速、确定性地测试 - 基础设施（EF、HTTP）可以被替换/打桩 - 这就是《.NET 单元测试（四）：依赖注入与可测试性》的实战价值 八、DDD 系列小结 六篇 DDD，一条主线：\n（一）全景：通用语言是灵魂；战略（限界上下文）重于战术 （二）聚合：一致性边界；四原则——小聚合、ID 引用、一事务一聚合、内部强一致 （三）实体与值对象：身份 vs 值；优先用值对象；强类型 ID （四）领域事件：事实解耦；可靠投递用 Outbox；事件溯源按需 （五）CQRS：读写分家；事件投影同步；按痛点渐进落地 （六）落地：整洁架构，依赖向内，领域在中心，可测试是最大红利 DDD 的核心心法，其实就三条：\n先理解业务（通用语言、限界上下文）——比任何技术细节都重要 把规则关进聚合（一致性边界、充血模型）——让代码自我保护 用事件解耦（领域事件、最终一致、CQRS）——应对复杂与规模 九、回到整个架构系列 把 DDD 放回更大的架构图景里：\n1 2 3 4 5 6 7 8 9 10 11 12 微服务系列（单体→云原生）回答\u0026#34;系统怎么拆、怎么通信、怎么一致、怎么观测\u0026#34; DDD 系列（业务怎么建模）回答\u0026#34;拆开的边界里、领域怎么设计\u0026#34; 两者交汇点： 限界上下文 = 微服务/模块的拆分单位 聚合边界 = 事务/一致性边界 领域事件 = 事件驱动/最终一致的载体 整洁架构 = 每个服务内部的工程骨架 → 战略设计决定系统怎么拆（架构） → 战术设计决定拆开的单元怎么建（DDD） → 二者合一，才是\u0026#34;业务复杂度可控、技术可演进\u0026#34;的完整答案。 架构没有终点。不管你最终选单体还是微服务、用不用事件溯源、上不上 CQRS，理解业务、忠实建模、控制一致性边界、用事件解耦——这些原则永远适用。这才是这两个系列想留给你的东西。\n","date":"2025-11-24T10:00:00+08:00","permalink":"/posts/architecture/ddd/06-clean-architecture/","title":"领域驱动设计（六）：落地——整洁架构与 .NET 工程结构"},{"content":"写在前面 前三篇把\u0026quot;写\u0026quot;这一侧讲透了——实体、值对象、聚合、领域事件，都是围绕\u0026quot;怎么正确地变更业务状态\u0026quot;。但真实系统里，读和写的负载往往完全不对称：写要严谨一致，读要快、要灵活、要多形态。用同一个模型硬扛两边，两头不讨好。\n这就是 CQRS（Command Query Responsibility Segregation，命令查询职责分离） 要解决的。这篇从 DDD 的视角讲 CQRS——它和领域事件、事件溯源是天然搭档。\n这篇和微服务系列第五篇是互补关系：那篇从\u0026quot;数据一致性\u0026quot;角度讲 CQRS 解决最终一致；这篇从\u0026quot;领域建模\u0026quot;角度讲 CQRS 怎么把读写模型分开。\n一、CQRS 的本质 1 2 3 4 5 6 7 8 9 10 11 12 13 14 传统：一个模型既读又写（CRUD 一把梭） Order 实体 + OrderRepository + OrdersController → 写要校验、要维护不变量、要走聚合根 → 读要 JOIN、要排序、要返回 DTO、要支持各种查询 → 一个模型被两种截然不同的需求拉扯，越来越臃肿 CQRS：把\u0026#34;写\u0026#34;和\u0026#34;读\u0026#34;拆成两套模型 写侧（Command） 读侧（Query） ──────────────── ──────────────── 命令处理 查询服务 领域模型（聚合） 读模型（DTO/物化视图） 仓储 只读数据源 强一致、校验、规则 快、灵活、可反规范化 1 2 3 4 一句话：CQRS 不是技术，是\u0026#34;承认读写不一样，别硬凑一起\u0026#34;的设计观。 来源：Bertrand Meyer 的 CQS 原则（命令查询分离：方法要么改状态，要么返回值，不兼得） CQRS 把它从\u0026#34;方法级\u0026#34;放大到\u0026#34;架构级\u0026#34;——整个系统的读写分开。 二、为什么要分离 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 写（Command）的真实诉求： ✓ 严格校验业务规则（不能超卖、余额够不够） ✓ 维护不变量（聚合一致性） ✓ 强一致（一个事务一个聚合） ✓ 领域模型要\u0026#34;对\u0026#34;，要可维护 → 频率通常不高（用户操作） 读（Query）的真实诉求： ✓ 快（缓存、索引、反规范化） ✓ 灵活（各种筛选/排序/聚合查询） ✓ 多形态（列表页/详情页/报表，各有 DTO） ✓ 高并发（一次列表查 N 条，QPS 远高于写） → 频率通常很高（浏览、搜索） 一个模型同时满足\u0026#34;严格校验\u0026#34;和\u0026#34;高速查询\u0026#34;，本质矛盾。 分开后，各自优化到极致。 三、写侧：领域模型 1 2 3 4 5 6 7 8 写侧 = DDD 的主战场（前三篇的内容） 入口：命令（PlaceOrderCommand、CancelOrderCommand） 处理：应用服务 → 加载聚合 → 调聚合方法 → 保存 → 发事件 模型：聚合根（带完整不变量校验） 存储：仓储，按聚合根整体存取 写侧不关心\u0026#34;页面要显示什么\u0026#34;，只关心\u0026#34;变更是否合法\u0026#34;。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 写侧：命令处理（领域模型驱动） public sealed record CancelOrderCommand(OrderId OrderId, string Reason); public class OrderCommandHandler { private readonly IOrderRepository _repo; private readonly IUnitOfWork _uow; private readonly IDomainEventDispatcher _events; public async Task Handle(CancelOrderCommand cmd, CancellationToken ct) { var order = await _repo.Find(cmd.OrderId, ct) ?? throw new NotFoundException(); order.Cancel(cmd.Reason); // 聚合内校验 + 收集事件 await _repo.Save(order, ct); await _uow.SaveChangesAsync(ct); await _events.DispatchAsync(order.DequeueEvents(), ct); // 通知读侧更新 } } 四、读侧：读模型 1 2 3 4 5 6 7 8 9 10 11 12 读侧 = 为\u0026#34;查询\u0026#34;优化的模型，不管业务规则 特征： - 反规范化（denormalized）：把需要的字段拍平，免 JOIN - 直接返回 DTO，不经过领域模型 - 可以用完全不同的存储（关系库宽表 / Redis / Elasticsearch） - 不校验业务规则（读而已） 一个\u0026#34;订单列表\u0026#34;读模型可能是： OrderListItemDto { OrderId, CustomerName, Total, Status, PlacedOn } → 已经 JOIN 好客户名，列表查询一次搞定 而\u0026#34;订单详情\u0026#34;又是另一个读模型，各取所需。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 读侧：直接查、直接返 DTO，不碰领域模型 public class OrderQueryService { private readonly IDbConnection _db; // Dapper 直接查读库 public Task\u0026lt;IReadOnlyList\u0026lt;OrderListItemDto\u0026gt;\u0026gt; ListByCustomer(CustomerId cust, CancellationToken ct) =\u0026gt; _db.QueryAsync\u0026lt;OrderListItemDto\u0026gt;(@\u0026#34; SELECT o.id AS OrderId, c.name AS CustomerName, o.total AS Total, o.status AS Status, o.placed_on AS PlacedOn FROM order_list_view o JOIN customers c ON c.id = o.customer_id WHERE o.customer_id = @cust\u0026#34;, new { cust }); // 详情、报表……各有各的读模型，互不干扰 } public sealed record OrderListItemDto( Guid OrderId, string CustomerName, decimal Total, string Status, DateTime PlacedOn); 1 2 3 4 5 读侧自由度极高： - 不走聚合根（读不修改，没必要） - 不走仓储（仓储是为\u0026#34;整体存取聚合\u0026#34;设计的，读不需要） - 可以直接 SQL / Dapper / 查 ES / 读 Redis → 读侧就是\u0026#34;怎么快怎么来\u0026#34;，领域规则是写侧的事。 五、数据同步：读模型怎么跟写侧一致 1 2 3 4 5 6 7 8 9 10 11 写侧一更新，读侧怎么同步？用领域事件投影。 写侧 Order 状态变了 → 发 OrderCancelled 事件 → 读侧投影器（Projector）订阅 → 更新对应的读模型表 → 读侧最终一致 读模型表（投影）： order_list_view ← 订单列表用 order_detail_view ← 订单详情用 customer_orders_view ← 按客户聚合的视图 每个视图由各自的事件投影维护。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 读侧投影器：监听领域事件，更新读模型 public class OrderListProjector { private readonly IDbConnection _db; public async Task On(OrderPlaced e, CancellationToken ct) { await _db.ExecuteAsync(@\u0026#34; INSERT INTO order_list_view (id, customer_id, total, status, placed_on) VALUES (@OrderId, @CustomerId, @Total, \u0026#39;Placed\u0026#39;, @OccurredOn)\u0026#34;, e); } public async Task On(OrderCancelled e, CancellationToken ct) { await _db.ExecuteAsync( \u0026#34;UPDATE order_list_view SET status=\u0026#39;Cancelled\u0026#39; WHERE id=@OrderId\u0026#34;, e); } } 1 2 3 4 5 所以前面四篇是层层铺垫： 实体/值对象/聚合 → 写侧的领域模型 领域事件 → 写侧通知读侧的渠道 CQRS → 读写两侧正式分家，事件是同步纽带 这就是 DDD 的完整闭环。 六、CQRS + 事件溯源 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 CQRS 和事件溯源是黄金搭档（但可独立使用）： 事件溯源（第四篇）：写侧存\u0026#34;事件流\u0026#34;，状态由重放得到 CQRS（本篇）：读写分离 合体： 写侧：命令 → 聚合 → 存事件流（Event Store） 读侧：事件 → 投影 → 读模型（Materialized View） ┌─────────┐ 事件 ┌───────────┐ 投影 ┌──────────┐ │ 写侧 │ ──────→ │ 事件存储 │ ─────→ │ 读模型 │ ← 查询 │ (聚合) │ │ (Event │ │ (宽表/ES)│ └─────────┘ │ Store) │ └──────────┘ └───────────┘ 价值：写侧有完整审计（事件流），读侧极致优化（投影） 两者解耦，各自演进。 代价：复杂。只有读多写少 + 强审计需求才值得。 普通 CRUD，单独用 CQRS 都未必需要，更别说加事件溯源。 七、什么时候用 CQRS（和什么时候别用） 1 2 3 4 5 6 7 8 9 10 11 12 13 值得用 CQRS： ✓ 读写负载严重不对称（读 \u0026gt;\u0026gt; 写） ✓ 查询复杂、多形态（列表/详情/报表差异大） ✓ 需要独立扩容读侧（读加缓存、加副本，写保持单库） ✓ 已用领域事件，顺势做投影（边际成本低） 不要用 CQRS： ✗ 简单 CRUD（读写都很简单，分离只增复杂度） ✗ 读和写模型差异不大 ✗ 团队没准备好维护\u0026#34;两套模型 + 同步\u0026#34; 铁律：CQRS 是用\u0026#34;复杂度\u0026#34;换\u0026#34;读写各自优化\u0026#34;。 只有读写矛盾真的痛，才划得来。 1 2 3 4 5 6 7 渐进式落地： 1. 先把写侧领域模型建好（前三篇） 2. 读侧仍用同一套库，但用独立查询服务 + DTO（轻量 CQRS） 3. 哪个读场景扛不住了，单独给它建投影读模型 4. 读量真的大，再引入独立读存储（Redis/ES） 不要一上来就全套 CQRS + 事件溯源，按痛点逐步推进。 八、小结 CQRS 本质：承认读写需求不同，命令（写）和查询（读）分家 写侧：命令 → 领域模型（聚合），严格校验、强一致（前三篇内容） 读侧：为查询优化的读模型（反规范化 DTO），不经领域模型，怎么快怎么来 同步纽带：领域事件投影——写侧发事件，读侧投影更新读模型（最终一致） CQRS + 事件溯源：黄金搭档但复杂，按需组合 何时用：读写严重不对称 / 查询复杂多形态；简单 CRUD 别用 渐进落地：先领域模型，再独立查询服务，再按痛点加投影，最后才上独立读存储 闭环：实体/值对象/聚合（写模型）+ 领域事件（纽带）+ CQRS（读写分家）= DDD 完整架构 最后一篇，把这些落到工程结构——整洁架构与 .NET 项目分层。\n","date":"2025-11-20T10:00:00+08:00","permalink":"/posts/architecture/ddd/05-cqrs/","title":"领域驱动设计（五）：CQRS——读写分离与领域模型"},{"content":"写在前面 聚合设计有个铁律：一个事务只改一个聚合。那\u0026quot;下单后要扣库存、加积分、发通知\u0026quot;这种跨聚合的联动怎么办？答案就是——领域事件（Domain Event）。\n聚合做完自己的事，发一个事件声明\u0026quot;发生了什么\u0026quot;，至于谁来响应、怎么响应，它一概不管。这种解耦是 DDD 和微服务架构的黏合剂。这篇讲领域事件，并引出它的进阶玩法——事件溯源（Event Sourcing）。\n这篇和微服务系列第五篇（数据一致性）是一对：那篇讲\u0026quot;事件如何可靠跨服务投递（Outbox）\u0026quot;，这篇讲\u0026quot;事件本身怎么设计、怎么用\u0026quot;。\n一、领域事件是什么 1 2 3 4 5 6 7 8 9 10 11 12 领域事件：领域中\u0026#34;已经发生、业务关心\u0026#34;的事。 特征： - 过去式命名（OrderPlaced、PaymentReceived、ItemShipped） → \u0026#34;已下单\u0026#34;\u0026#34;已付款\u0026#34;\u0026#34;已发货\u0026#34;，是事实，不是请求 - 不可变（发生过的事不能改） - 自描述（带够重建上下文所需的信息） 价值：解耦 聚合 A 完成变更 → 发事件 聚合 B/C/D 各自订阅，独立响应 A 不需要知道有谁在听、有几个、在哪 1 2 3 4 5 6 7 为什么用\u0026#34;过去式\u0026#34;： OrderPlaced（订单已创建）—— 事实，谁都不能撤销 PlaceOrder（去下单）—— 这是\u0026#34;命令\u0026#34;，不是事件 事件 = 已经发生的客观事实 → 订阅者只能反应，不能拒绝 命令 = 要求做某事 → 可以被拒绝（余额不足、库存不够） 两者别混（下一节细讲）。 二、事件 vs 命令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 命令（Command）：表达\u0026#34;意图\u0026#34;，可被拒绝 名字：动词原形 / 祈使（PlaceOrder、CancelOrder） 方向：外部 → 领域 结果：可能成功，可能失败（业务规则不允许） 例：用户点\u0026#34;下单\u0026#34; → 发 PlaceOrder 命令 → 系统校验，成功则订单创建 事件（Event）：表达\u0026#34;事实\u0026#34;，不可拒绝 名字：过去式（OrderPlaced、OrderCancelled） 方向：领域 → 外部 结果：已成事实，订阅者只能据此反应 例：订单创建后 → 发 OrderPlaced → 库存扣减、积分、通知据此触发 链条： 命令 PlaceOrder →（校验通过）→ 订单创建 + 事件 OrderPlaced → 订阅者据此发新命令（如 ReserveStock）→ 成功 + StockReserved → ... 一个业务的完整流程 = 命令与事件交替推进 三、领域事件的设计 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 // 领域事件基类 public interface IDomainEvent { DateTime OccurredOn { get; } } // 具体事件：过去式 + 自描述（带够订阅者需要的信息） public sealed record OrderPlaced( OrderId OrderId, CustomerId CustomerId, IReadOnlyCollection\u0026lt;OrderLine\u0026gt; Lines, Address ShippingAddress, Money Total, DateTime OccurredOn ) : IDomainEvent; public sealed record OrderCancelled(OrderId OrderId, string Reason, DateTime OccurredOn) : IDomainEvent; // 聚合根内：变更完成后\u0026#34;收集\u0026#34;事件，由外部统一分发 public class Order : Entity\u0026lt;OrderId\u0026gt;, IAggregateRoot { private readonly List\u0026lt;IDomainEvent\u0026gt; _events = new(); public IReadOnlyCollection\u0026lt;IDomainEvent\u0026gt; DequeueEvents() { var e = _events.ToList(); _events.Clear(); return e; } public void Cancel(string reason) { if (Status \u0026gt;= OrderStatus.Shipped) throw new DomainException(\u0026#34;已发货不能取消\u0026#34;); Status = OrderStatus.Cancelled; _events.Add(new OrderCancelled(Id, reason, DateTime.UtcNow)); // 只收集，不直接处理 } } 1 2 3 4 5 6 设计要点： 1. 事件是 record（不可变）+ 过去式命名 2. 带够信息：订阅者不该为了处理事件再去回查聚合 （但也不必塞全部字段，按订阅者需要） 3. 聚合只\u0026#34;收集\u0026#34;事件，不直接处理（保持聚合纯净、可测） 4. 事件的分发由应用层统一做（下一节） 四、进程内事件分发 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 应用层：保存聚合后，把收集到的事件分发出去 public class OrderService { private readonly IOrderRepository _repo; private readonly IDomainEventDispatcher _dispatcher; public async Task CancelAsync(OrderId id, string reason, CancellationToken ct) { var order = await _repo.Find(id, ct) ?? throw new NotFoundException(); order.Cancel(reason); // 聚合内变更 + 收集事件 await _repo.Save(order, ct); // 先持久化（保证数据落库） foreach (var evt in order.DequeueEvents()) await _dispatcher.DispatchAsync(evt, ct); // 再分发 } } // 分发器：可以用 MediatR（进程内发布/订阅的标准选择） public sealed record OrderCancelledHandler(IInventoryService Inventory) : INotificationHandler\u0026lt;OrderCancelledNotification\u0026gt; { public Task Handle(OrderCancelledNotification n, CancellationToken ct) =\u0026gt; Inventory.ReleaseAsync(n.OrderId, ct); // 库存释放 } 1 2 3 4 5 关键顺序：先存库，再分发 如果先分发再存库：事件处理了，但聚合没存上 → 不一致 所以一定 先 Save 聚合 → 再 Dispatch 事件 进程内的同步分发，简单可靠，但有个隐患（下一节）。 五、可靠投递：跨进程用 Outbox 1 2 3 4 5 6 7 8 9 10 11 12 13 进程内分发的隐患： 聚合存了 ✅ → 分发事件时崩了 ❌ → 库存没释放、积分没加，事件丢了 跨服务（微服务）更严重：事件要发到消息队列，队列和网络都可能失败。 解决方案：Outbox 模式（微服务系列第五篇讲过） 1. 聚合变更 + 事件记录，写进同一个数据库事务（原子） 2. 一个独立 worker 轮询 outbox 表，把事件投递到消息队列 3. 投递成功标记已发送；失败重试 → 业务操作和事件发布，获得 ACID 级别的原子性。 → 跨服务的领域事件，必须走 Outbox，否则一定会丢。 六、事件溯源（Event Sourcing） 1 2 3 4 5 6 7 8 9 10 11 12 13 传统持久化：存\u0026#34;当前状态\u0026#34; 订单表：status=Shipped, total=299 → 只知道\u0026#34;现在是什么\u0026#34;，历史丢了（怎么变到这一步的？不知道） 事件溯源：存\u0026#34;导致状态变化的所有事件\u0026#34; 事件流： OrderPlaced(total=299) PaymentReceived(amount=299) OrderShipped(carrier=SF) → \u0026#34;当前状态\u0026#34; = 把事件流从头\u0026#34;重放\u0026#34;计算出来 订单的当前状态，就是按顺序应用这些事件得到的结果。 完整历史永久保留：能回溯到任意时间点、能审计、能做时态查询。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 事件溯源的好处： ✓ 完整审计轨迹（发生了什么，一清二楚） ✓ 时态查询（\u0026#34;上周三这个订单是什么状态？\u0026#34;） ✓ 和 DDD 天然契合（领域事件本就是\u0026#34;发生的事\u0026#34;） ✓ 模型与存储解耦（存事件，状态是派生的） 事件溯源的代价： ✗ 复杂度飙升（重放、快照、版本演进、并发） ✗ 查询难（要查\u0026#34;当前所有已发货订单\u0026#34;得重放全部 → 需要读侧投影，第五篇 CQRS） ✗ 事件 schema 演进困难（老事件怎么重放？） 结论：大多数业务不需要事件溯源。 只在\u0026#34;强审计/合规/时态\u0026#34;需求（金融、计费、医疗）才值得上。 别因为\u0026#34;看起来高级\u0026#34;就用。 6.1 一个最小示意 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 // 事件流持久化（伪代码） public interface IEventStore { Task AppendAsync(OrderId id, IEnumerable\u0026lt;IDomainEvent\u0026gt; events, int expectedVersion, CancellationToken ct); Task\u0026lt;IReadOnlyList\u0026lt;IDomainEvent\u0026gt;\u0026gt; LoadAsync(OrderId id, CancellationToken ct); } // 重建聚合：重放事件流 public class OrderRepository { private readonly IEventStore _store; public async Task\u0026lt;Order?\u0026gt; Find(OrderId id, CancellationToken ct) { var events = await _store.LoadAsync(id, ct); if (events.Count == 0) return null; var order = new Order(); // 空聚合 foreach (var e in events) order.Apply(e); // 逐个重放 return order; } } // 聚合内的 Apply：根据事件设置状态（不校验，因为是已发生的事实） public class Order { public void Apply(IDomainEvent e) { switch (e) { case OrderPlaced p: Id = p.OrderId; Status = OrderStatus.Placed; Total = p.Total; break; case PaymentReceived: Status = OrderStatus.Paid; break; case OrderShipped: Status = OrderStatus.Shipped; break; case OrderCancelled: Status = OrderStatus.Cancelled; break; } } } 1 2 3 4 快照（Snapshot）：重放太慢的优化 每 N 个事件存一个\u0026#34;状态快照\u0026#34; 重建时：从最近快照开始 + 重放之后的少量事件 → 长事件流的聚合才需要，先别过度设计。 七、何时用领域事件 / 事件溯源 1 2 3 4 5 6 7 8 9 10 11 12 13 领域事件：强烈推荐，几乎必用 ✓ 跨聚合解耦（聚合不直接调聚合） ✓ 副作用隔离（通知、积分、统计 = 订阅者，不污染核心） ✓ 跨服务异步协作（配 Outbox） 事件溯源：谨慎，按需 ✓ 金融/计费/医疗等强审计场景 ✗ 普通 CRUD 业务（用状态持久化就够） ✗ 团队没准备好应对复杂度 事件 ≠ 事件溯源。 用领域事件解耦，是基本操作； 用事件溯源存状态，是高级选项，别混为一谈。 八、小结 领域事件：已发生的事实，过去式命名，不可变，自描述 事件 vs 命令：命令=意图可拒绝（外部→领域）；事件=事实不可拒绝（领域→外部） 设计：事件用 record，带够信息；聚合只\u0026quot;收集\u0026quot;事件，分发交给应用层 顺序：先持久化聚合，再分发事件（保证一致） 可靠投递：跨进程/跨服务必须走 Outbox（业务与事件同事务） 事件溯源：存事件流而非当前状态；审计强但复杂度高，按需采用 关键认知：领域事件（解耦，必用）≠ 事件溯源（存状态，慎用） 下一篇讲 CQRS——读写分离架构，和领域事件/事件溯源天然搭档。\n","date":"2025-11-16T10:00:00+08:00","permalink":"/posts/architecture/ddd/04-domain-event/","title":"领域驱动设计（四）：领域事件与事件溯源——用事件驱动业务"},{"content":"写在前面 上一篇讲聚合时反复出现两个角色——实体（Entity） 和 值对象（Value Object）。它们是领域建模的最基础积木，但很多开发者只会用实体，把\u0026quot;值对象\u0026quot;这个被严重低估的工具晾在一边。\n这篇把它们讲清：什么时候该用实体、什么时候该用值对象、怎么用 .NET 实现、怎么映射到数据库。掌握值对象，你的模型会干净一大截。\n一、核心区别：身份 vs 值 1 2 3 4 5 6 7 8 9 10 11 实体（Entity）：靠\u0026#34;身份\u0026#34;区分 有唯一 ID，属性变了还是它 判等：ID 相同就是同一个 例：订单 Order（有 OrderId）、用户 Customer 问：\u0026#34;这是哪一个？\u0026#34; → 实体 值对象（Value Object）：靠\u0026#34;值\u0026#34;区分 没有 ID，所有属性相同就相等 不可变（改 = 换新对象） 例：地址 Address、金额 Money、日期范围 DateRange 问：\u0026#34;这是多少/什么样的？\u0026#34; → 值对象 1 2 3 4 5 6 7 8 9 10 一个判断技巧： 两个\u0026#34;完全一样\u0026#34;的东西，是两个还是同一个？ 两张一模一样的 100 元 → 是同一个\u0026#34;金额\u0026#34;（值对象 Money） （你不在乎是哪张钞票，只在乎面值） 两个同名同姓的用户 → 是两个不同的用户（实体 Customer） （你在乎\u0026#34;是哪一位\u0026#34;） 在乎\u0026#34;是哪一个\u0026#34; → 实体 不在乎、只在乎\u0026#34;值是什么\u0026#34; → 值对象 二、优先用值对象 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 经验法则：能用值对象，就别用实体。 值对象的好处： ✓ 不可变 → 天然线程安全、无副作用 ✓ 按值判等 → 测试和比较简单 ✓ 无身份管理 → 不用操心 ID 生成、生命周期 ✓ 自带校验 → 构造时即合法（金额不能负、邮箱要合规） ✓ 组合性强 → 小值对象拼出大概念 实体是\u0026#34;有状态、有生命周期\u0026#34;的东西，是少数。 现实中大多数概念其实是值对象，只是被误建成了实体。 典型误建： Address 做成有 AddressId 的实体 → 没必要，地址就是值 Money 做成实体 → 金额就是值 只有一个数量字段的\u0026#34;积分记录\u0026#34; → 可能是值 三、值对象的 .NET 实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 推荐：用 record / record struct，自动实现\u0026#34;不可变 + 按值判等\u0026#34; public readonly record struct Money(decimal Amount, string Currency) { // 构造时校验：保证\u0026#34;创建即合法\u0026#34; public Money { if (Amount \u0026lt; 0) throw new DomainException(\u0026#34;金额不能为负\u0026#34;); if (string.IsNullOrWhiteSpace(Currency)) throw new DomainException(\u0026#34;必须指定币种\u0026#34;); } public Money Add(Money other) { if (Currency != other.Currency) throw new DomainException(\u0026#34;币种不同，不能相加\u0026#34;); return this with { Amount = Amount + other.Amount }; // 返回新对象 } public Money Multiply(int times) =\u0026gt; this with { Amount = Amount * times }; } // 用法： // var price = new Money(99m, \u0026#34;CNY\u0026#34;); // var total = price.Multiply(3); // 新对象，price 不变 // price == new Money(99m, \u0026#34;CNY\u0026#34;) // true（按值判等） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // 复杂值对象用 record（class），可包含多字段和行为 public sealed record Address( string Province, string City, string Detail, string Zip) { public string FullText =\u0026gt; $\u0026#34;{Province}{City}{Detail}（{Zip}）\u0026#34;; public bool IsDomestic =\u0026gt; Zip.StartsWith(\u0026#34;1\u0026#34;) || /* … */; } // 日期范围：典型值对象，不可变 + 自带规则 public sealed record DateRange(DateTime Start, DateTime End) { public DateRange { if (End \u0026lt; Start) throw new DomainException(\u0026#34;结束不能早于开始\u0026#34;); } public bool Overlaps(DateRange other) =\u0026gt; Start \u0026lt; other.End \u0026amp;\u0026amp; other.Start \u0026lt; End; public TimeSpan Duration =\u0026gt; End - Start; } 1 2 3 4 值对象实现三要点： 1. 不可变（record 自动；class 的话所有 setter private/无 setter） 2. 按值判等（record 自动；class 要重写 Equals/GetHashCode） 3. 构造即校验（在构造函数/record 的 init 校验上下文里校验） 四、实体的 .NET 实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 // 实体基类：提供 ID 与按 ID 判等 public abstract class Entity\u0026lt;TId\u0026gt; where TId : notnull { public TId Id { get; protected set; } = default!; public override bool Equals(object? obj) =\u0026gt; obj is Entity\u0026lt;TId\u0026gt; other \u0026amp;\u0026amp; EqualityComparer\u0026lt;TId\u0026gt;.Default.Equals(Id, other.Id); public override int GetHashCode() =\u0026gt; Id.GetHashCode(); } // 实体：有身份、有状态流转 public class Customer : Entity\u0026lt;CustomerId\u0026gt; { public CustomerName Name { get; private set; } // 用值对象装字段 public Email Email { get; private set; } // 用值对象 public CustomerStatus Status { get; private set; } private readonly List\u0026lt;Address\u0026gt; _addresses = new(); // 值对象集合 public IReadOnlyCollection\u0026lt;Address\u0026gt; Addresses =\u0026gt; _addresses.AsReadOnly(); public Customer(CustomerId id, CustomerName name, Email email) : base() { Id = id; Name = name ?? throw new DomainException(\u0026#34;姓名必填\u0026#34;); Email = email ?? throw new DomainException(\u0026#34;邮箱必填\u0026#34;); Status = CustomerStatus.Active; } public void ChangeEmail(Email newEmail) { if (Status == CustomerStatus.Closed) throw new DomainException(\u0026#34;已注销客户不能改邮箱\u0026#34;); Email = newEmail; } } 1 2 3 4 5 实体要点： - 继承 Entity\u0026lt;TId\u0026gt;，按 ID 判等（不是按所有字段） - 状态流转通过方法（ChangeEmail），不暴露 setter - 字段尽量用值对象包装（CustomerName / Email），而非裸 string → 裸 string \u0026#34;abc\u0026#34; 不是合法邮箱；Email 值对象构造即校验 五、强类型 ID（标识类型） 1 2 3 4 5 6 7 8 9 10 11 痛点： public void Transfer(Guid fromAccountId, Guid toAccountId, decimal amount) → fromAccountId 和 toAccountId 都是 Guid，传反了编译器不报错！ 强类型 ID：给每个 ID 套一层类型 public readonly record struct AccountId(Guid Value); public readonly record struct CustomerId(Guid Value); void Transfer(AccountId from, AccountId to, Money amount) → 传反了？类型不匹配，编译直接报错。 → 顺手解决了\u0026#34;裸 Guid 满天飞、容易传错\u0026#34;的经典 bug。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public readonly record struct AccountId(Guid Value) { public static AccountId New() =\u0026gt; new(Guid.NewGuid()); public override string ToString() =\u0026gt; Value.ToString(); } public class Account : Entity\u0026lt;AccountId\u0026gt;, IAggregateRoot { public Money Balance { get; private set; } public void Withdraw(Money amount) { if (amount.Amount \u0026gt; Balance.Amount) throw new DomainException(\u0026#34;余额不足\u0026#34;); Balance = Balance.Add(amount with { Amount = -amount.Amount }); } } // 调用：Transfer(new AccountId(...), new AccountId(...), money) // —— 两个参数类型都是 AccountId，但位置固定，传错编译报错 六、映射到数据库（EF Core） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 // 实体 → 表 modelBuilder.Entity\u0026lt;Order\u0026gt;(b =\u0026gt; { b.HasKey(o =\u0026gt; o.Id); b.Property(o =\u0026gt; o.Status).HasConversion\u0026lt;string\u0026gt;(); // 聚合内的子实体（订单项）：同一个聚合，一起存取 b.OwnsMany(o =\u0026gt; o.Items, item =\u0026gt; { item.WithOwner().HasForeignKey(\u0026#34;OrderId\u0026#34;); item.HasKey(\u0026#34;Id\u0026#34;); item.Property(i =\u0026gt; i.Price).HasConversion( m =\u0026gt; m.Amount, v =\u0026gt; new Money(v, \u0026#34;CNY\u0026#34;)); }); }); // 值对象 → Owned Entity（值对象没有自己的表身份，归属宿主） modelBuilder.Entity\u0026lt;Customer\u0026gt;(b =\u0026gt; { b.OwnsOne(c =\u0026gt; c.Name); // CustomerName 作为宿主的一列/几列 b.OwnsOne(c =\u0026gt; c.Email); b.OwnsMany(c =\u0026gt; c.Addresses); // 值对象集合 → 关联表 }); // 强类型 ID → 转换为底层类型存储 modelBuilder.Entity\u0026lt;Account\u0026gt;(b =\u0026gt; { b.Property(a =\u0026gt; a.Id).HasConversion( id =\u0026gt; id.Value, v =\u0026gt; new AccountId(v)); }); 1 2 3 4 5 6 7 EF Core 映射心智模型： 实体（聚合根、子实体）→ 有主键 → 表/行 值对象 → 无独立身份 → OwnsOne（单值）/ OwnsMany（集合）→ 宿主的列或子表 关键：值对象不该有自己的 DbSet（它不独立）， 子实体的仓储也归聚合根（OrderItem 不单独存取）。 这与第二篇\u0026#34;按聚合根整体存取\u0026#34;一致。 七、一张速查表 1 2 3 4 5 6 7 8 9 场景 用实体还是值对象 ────────────────────────────────────────────────────── 订单、用户、账户（有生命周期） 实体 收货地址、金额、坐标 值对象 邮箱、手机号、用户名 值对象（自带格式校验） 日期范围、时间段 值对象 订单项（依附订单存在） 子实体（在聚合内，不独立） 订单项里的\u0026#34;单价\u0026#34; 值对象（Money） 两个\u0026#34;完全相同\u0026#34;是否算同一个 是→值对象；否→实体 八、小结 核心区别：实体靠身份（ID）判等，值对象靠值判等 判断技巧：在乎\u0026quot;是哪一个\u0026quot;→ 实体；只在乎\u0026quot;值是什么\u0026quot;→ 值对象 优先值对象：不可变、按值判等、构造即合法、组合性强——被低估的工具 .NET 实现：值对象用 record/record struct；实体继承 Entity\u0026lt;TId\u0026gt; 强类型 ID：给 ID 套类型，根治\u0026quot;裸 Guid 传错\u0026quot;的 bug EF Core：实体→表/行；值对象→OwnsOne/OwnsMany（无独立身份）；按聚合根整体存取 字段尽量值对象化：Email 比 string 安全得多 下一篇讲领域事件与事件溯源——聚合做完自己的事后，怎么用事件驱动整个系统。\n","date":"2025-11-12T10:00:00+08:00","permalink":"/posts/architecture/ddd/03-entity-value-object/","title":"领域驱动设计（三）：实体与值对象——建模积木的选择"},{"content":"写在前面 上一篇做了 DDD 全景导览，提到聚合（Aggregate）是 DDD 最关键、也最容易用错的概念。这篇就把它彻底讲透。\n很多人把聚合理解成\u0026quot;一组相关的对象\u0026quot;——这只对了一半。聚合真正的灵魂是四个字：一致性边界。理解了这四个字，你才知道怎么划聚合、聚合根该干什么、为什么一个事务只能改一个聚合。\n一、为什么需要聚合 1 2 3 4 5 6 7 8 9 10 11 12 13 14 没有聚合会怎样？ 一个订单 Order，里面有若干订单项 OrderItem。 如果没有边界规则： - 谁都能 new 一个 OrderItem 塞进去 - 谁都能改 OrderItem 的数量、价格 - 订单总价 = 各项小计之和，这个规则谁来保证？ 结果： → 任何一处漏改，总价就算错（不变量被破坏） → 业务规则散落各处，谁也不敢动 → 并发修改互相覆盖 聚合要解决的，就是\u0026#34;怎么让一组对象的规则不被绕过\u0026#34;。 1 2 3 4 5 聚合的本质： 把\u0026#34;必须一起保持一致\u0026#34;的对象圈起来， 指定一个入口（聚合根）， 所有修改只能从入口进， 规则在入口内部强制，外界绕不过去。 二、聚合与聚合根的定义 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 聚合（Aggregate） 一组作为整体被访问和持久化的领域对象。 它是一个\u0026#34;一致性边界\u0026#34;——边界内的对象，在任何时刻都满足业务规则。 聚合根（Aggregate Root） 聚合的入口对象，是外部访问聚合的唯一通道。 - 聚合外部的代码，只能持有聚合根的引用 - 不能直接引用聚合内部的子对象 - 所有对内部的修改，必须经过聚合根的方法 例：订单聚合 Order（聚合根） ├── OrderItem（内部实体） └── ShippingAddress（内部值对象） 外部代码： order.AddItem(...) ✅ 经过聚合根 order.Items[0].Qty = 5 ❌ 绕过聚合根直接改内部 三、聚合根的职责 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 聚合根要承担四件事： 1. 守门人 所有修改入口都在它身上（AddItem / Cancel / Ship ...） 内部对象不暴露可变状态（返回只读集合、值对象不可变） 2. 不变量维护者 业务规则在聚合根方法内部强制 例：总价 = Σ小计；库存≥0；状态只能正向流转 → 任何修改完成后，聚合一定处于\u0026#34;合法\u0026#34;状态 3. 身份与标识 持有聚合的唯一 ID（OrderId） 外部对聚合的引用，只用 ID，不持有对象 4. 领域事件的发布者 完成一次合法修改后，发出领域事件 （例：OrderPlaced）供外部订阅 四、聚合设计四原则 这是整篇的核心，背下来。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 原则 1：尽量设计小聚合 错：把 User + 所有 Order + 所有 Review 塞进\u0026#34;用户聚合\u0026#34; → 改一个评论要锁整个用户，并发灾难 对：聚合只包含\u0026#34;必须一起变更\u0026#34;的对象 Order 聚合 = Order + OrderItem；Review 单独一个聚合 越小越好，一个聚合根 + 少量子对象是理想形态。 原则 2：跨聚合引用，只引用 ID Order 持有 CustomerId，不持有 Customer 对象 → 解耦，避免加载一个聚合时拖出整张对象图 → 需要客户信息？另查，或读侧投影（第五篇 CQRS 讲） 原则 3：一个事务只修改一个聚合 跨聚合的变更 → 用领域事件 + 最终一致，不在一个事务里硬保 → 这就是微服务系列第五篇 Saga / 事件驱动的由来 → 聚合边界 = 事务边界 = 一致性边界（三位一体） 原则 4：内部强一致，跨聚合最终一致 聚合内：规则立即满足（强一致） 聚合间：通过事件异步对账（最终一致） 五、怎么划聚合边界（试金石） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 判断一组对象是不是该在一个聚合里，用这个问题： \u0026#34;这几个对象，是否必须在一个事务里一起改，才能保证正确？\u0026#34; 是 → 同一个聚合 否 → 拆成不同聚合，用事件联动 举例验证： Order 和 OrderItem： 改订单项数量，必须同时重算订单总价 → 同一聚合 ✅ Order 和 Customer： 下单时记录客户即可，改订单不影响客户 → 不同聚合（用 CustomerId）✅ Order 和 Inventory： 下单要扣库存，但\u0026#34;扣库存\u0026#34;是库存聚合自己的事 → 不同聚合，用 OrderPlaced 事件通知库存聚合 ✅ Order 和 Payment： 支付是独立的，订单只关心\u0026#34;是否已支付\u0026#34; → 不同聚合，事件联动 ✅ 1 2 3 4 5 一个常被问的问题：订单项算实体还是聚合？ OrderItem 有自己的 ID，是\u0026#34;实体\u0026#34;， 但它离开订单没有意义（没有\u0026#34;游离的订单项\u0026#34;）， 所以它不是独立聚合，而是 Order 聚合的内部实体。 → 有 ID ≠ 是聚合。是否独立可被外部直接访问，才决定它是不是聚合根。 六、聚合的加载与持久化 1 2 3 4 5 6 7 8 9 10 11 聚合作为一个整体被存取： 加载：一次把整个聚合（聚合根 + 子对象）从库里捞出来 保存：一次把整个聚合写回 仓储（Repository）按\u0026#34;聚合根\u0026#34;建，不按\u0026#34;表\u0026#34;建： IOrderRepository.Save(order) // 存整个订单（含订单项） IOrderRepository.Find(orderId) // 取整个订单 EF Core 里：一个聚合根对应一个 DbSet，子对象用 Owned / 导航属性一起加载。 → 不要为 OrderItem 单独建仓储/DbSet（它不独立）。 七、并发控制 1 2 3 4 5 6 7 8 9 10 11 12 多个请求同时改一个聚合，怎么不互相覆盖？ 首选：乐观锁（版本号） 聚合带一个 Version 字段 UPDATE ... WHERE Id = ? AND Version = ? → 有人在 你 之后 改过？Version 变了，你的更新影响 0 行 → 冲突，重试或报错 为什么乐观锁够用： 聚合边界小，冲突概率低 而且我们坚持\u0026#34;一事务一聚合\u0026#34;，锁的范围本来就小 EF Core：[Timestamp] / IsRowVersion() 自动生成乐观锁 八、完整 .NET 示例 把上面所有原则落到一个订单聚合上：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 // 强类型 ID（避免基础类型误用） public readonly record struct OrderId(Guid Value); public readonly record struct ProductId(Guid Value); // 聚合根 public class Order : Entity\u0026lt;OrderId\u0026gt;, IAggregateRoot { private readonly List\u0026lt;OrderItem\u0026gt; _items = new(); public IReadOnlyCollection\u0026lt;OrderItem\u0026gt; Items =\u0026gt; _items.AsReadOnly(); public OrderStatus Status { get; private set; } public Address ShippingAddress { get; private set; } private int _version; // 乐观锁版本号 // 仓储加载/EF Core 用，不对外 private Order(OrderId id, Address shipTo) : base(id) { Status = OrderStatus.Draft; ShippingAddress = shipTo; } // 工厂：创建即合法（保证不变量） public static Order Place(OrderId id, Address shipTo, IEnumerable\u0026lt;(ProductId, Money, int)\u0026gt; lines) { var order = new Order(id, shipTo); foreach (var (pid, price, qty) in lines) order.AddItem(pid, price, qty); // 复用规则 if (!order._items.Any()) throw new DomainException(\u0026#34;订单至少要有一项\u0026#34;); order.Status = OrderStatus.Placed; return order; } // 所有修改入口：内部强制不变量 public void AddItem(ProductId product, Money price, int qty) { if (Status != OrderStatus.Draft) throw new DomainException(\u0026#34;非草稿态不能加项\u0026#34;); if (qty \u0026lt;= 0) throw new DomainException(\u0026#34;数量必须 \u0026gt; 0\u0026#34;); if (price.Amount \u0026lt;= 0) throw new DomainException(\u0026#34;价格必须 \u0026gt; 0\u0026#34;); var existing = _items.FirstOrDefault(i =\u0026gt; i.ProductId == product); if (existing is not null) existing.Increase(qty); // 内部实体也只暴露方法 else _items.Add(new OrderItem(product, price, qty)); } public void Cancel() { if (Status \u0026gt;= OrderStatus.Shipped) throw new DomainException(\u0026#34;已发货不能取消\u0026#34;); Status = OrderStatus.Cancelled; // 发领域事件（第四篇展开） AddDomainEvent(new OrderCancelled(Id)); } // 派生值：不变量的体现，不存储 public Money Total =\u0026gt; _items.Aggregate( new Money(0m, _items[0].Price.Currency), (sum, i) =\u0026gt; sum.Add(i.SubTotal)); } // 内部实体：不独立、不对外，只通过聚合根改 public class OrderItem : Entity\u0026lt;Guid\u0026gt; { public ProductId ProductId { get; } public Money Price { get; } public int Qty { get; private set; } public Money SubTotal =\u0026gt; new(Price.Amount * Qty, Price.Currency); // 派生 internal void Increase(int n) // internal，外部调不到 { if (n \u0026lt;= 0) throw new DomainException(\u0026#34;增量必须 \u0026gt; 0\u0026#34;); Qty += n; } } public enum OrderStatus { Draft, Placed, Paid, Shipped, Cancelled } 1 2 3 4 5 6 7 8 这段代码体现了全部原则： - 聚合根守门：_items 私有，只暴露 AsReadOnly，外部改不了 - 不变量：AddItem 内校验数量/价格/状态；Total 永远 = Σ小计 - 工厂保证创建即合法（空订单建不出来） - 内部实体 OrderItem 的修改方法 internal，只能被聚合根调 - 跨聚合用 ID：OrderId/ProductId，不持有对方对象 - 领域事件：Cancel 后发 OrderCancelled，不直接去改别的聚合 - 乐观锁：_version 字段 九、常见错误 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 错误 1：大聚合（God Aggregate） 把所有相关对象塞一个聚合 → 锁大、并发差、加载慢 → 回到原则 1：小聚合，跨聚合用 ID + 事件 错误 2：贫血聚合 聚合根只有 getter/setter，规则写在 Service 里 → 不变量形同虚设。规则必须在聚合根方法内。 错误 3：跨聚合事务 为了\u0026#34;图省事\u0026#34;，一个事务改订单+库存+积分 → 破坏一致性边界，回到分布式事务地狱 → 跨聚合坚决用最终一致 错误 4：直接暴露内部集合 public List\u0026lt;OrderItem\u0026gt; Items { get; } → 外部 order.Items.Clear() 绕过规则 → 返回 IReadOnlyCollection，或私有 + 显式方法 错误 5：按表建聚合/仓储 OrderItem 单独建仓储 → 它不独立，违反\u0026#34;整体存取\u0026#34; → 仓储按聚合根建 十、小结 聚合 = 一致性边界：把必须一起变更的对象圈起来，规则内部强制 聚合根是唯一入口：守门、维护不变量、持身份、发事件 四原则：小聚合、跨聚合用 ID、一事务一聚合、内部强一致跨聚合最终一致 划界试金石：是否必须一个事务一起改？是→同聚合，否→拆开用事件 持久化：按聚合根整体存取，仓储对应聚合根而非表 并发：乐观锁（版本号）足够，因为坚持一事务一聚合 五大错误：大聚合、贫血、跨聚合事务、暴露内部集合、按表建仓储 下一篇讲建模的基础积木——实体与值对象：怎么选、怎么用 .NET 实现、怎么映射到数据库。\n","date":"2025-11-08T10:00:00+08:00","permalink":"/posts/architecture/ddd/02-aggregate-design/","title":"领域驱动设计（二）：聚合设计精讲——一致性边界的艺术"},{"content":"写在前面 在微服务系列第三篇里，我讲服务拆分时反复提到一个词——限界上下文，它就来自 DDD（Domain-Driven Design，领域驱动设计）。当时埋了个伏笔，这篇就来把它讲透。\nDDD 不是一种技术，而是一套应对复杂业务的方法论：怎么把真实世界的业务，忠实地映射成代码模型。很多人觉得 DDD 玄学，是因为一上来就扎进\u0026quot;实体、值对象、聚合\u0026quot;这些战术名词。其实 DDD 分两层——战略设计（拆分边界）比战术设计（建模积木）重要得多。\n这篇是 DDD 系列第一篇，做全景导览。后续会单独深入聚合设计、事件风暴落地等。\n一、DDD 要解决什么问题 1 2 3 4 5 6 7 8 9 10 11 12 13 软件失败的头号原因：不是技术选错，是\u0026#34;把业务理解错了\u0026#34; 现实：业务专家说一套，开发理解一套，代码实现又一套 → 三者逐渐分裂，代码越来越偏离业务真相 → 改一个需求，发现代码里的概念和业务对不上 → 最后系统变成\u0026#34;能跑但没人敢动\u0026#34;的黑洞 DDD 的核心目标：让\u0026#34;业务知识\u0026#34;顺畅地流进\u0026#34;代码模型\u0026#34; → 业务专家和开发说同一种话（通用语言） → 代码结构忠实反映业务结构（领域建模） → 复杂度被控制在边界内（限界上下文） 一句话：DDD 是关于\u0026#34;理解业务\u0026#34;的工程方法论。 1 2 3 4 5 什么时候该上 DDD： ✓ 业务复杂、规则多、领域知识深厚（金融、电商核心、ERP） ✗ CRUD 为主、逻辑简单的系统 → 用 DDD 是杀鸡用牛刀 DDD 的代价不低，只有业务复杂度配得上时才值得。 二、通用语言（Ubiquitous Language） DDD 的第一块基石，也是最容易被忽视的。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 通用语言：业务专家和开发共用同一套词汇 反例（没有通用语言）： 业务说\u0026#34;客户\u0026#34;，代码里叫 User 业务说\u0026#34;下单\u0026#34;，代码里叫 createOrder 业务说\u0026#34;退款\u0026#34;，代码里叫 handleReturn → 沟通要 constantly 翻译，翻译就会失真 通用语言： 业务说的每个术语 = 代码里的类/方法/字段名 \u0026#34;客户\u0026#34; 就是 Customer，\u0026#34;下单\u0026#34; 就是 placeOrder → 沟通零翻译，代码读起来像业务文档 怎么建立：业务专家和开发一起，把核心概念逐一命名、统一， 写进文档、写进代码、写进测试。 disagreements 当场解决。 通用语言是 DDD 的灵魂。没有它，后面的实体、聚合都是空中楼阁——因为你自己都不知道在建模什么。\n三、战略设计：划分边界 战略设计回答\u0026quot;系统怎么切\u0026quot;。这是 DDD 价值最大的部分。\n3.1 领域与子域 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 领域（Domain）：你的业务范围（如\u0026#34;电商\u0026#34;） 子域（Subdomain）：领域内的子问题，分三类： 核心子域（Core） —— 你的竞争优势，最复杂，最值得投入 电商的：定价、促销、推荐 → 自研，倾注最好的设计和人才 支撑子域（Supporting）—— 必要但非差异化 电商的：库存、物流 → 可自研可采购，够用就行 通用子域（Generic） —— 谁都需要，无差异化 电商的：认证、权限、通知 → 直接用现成方案，别自研 精力分配：核心域 \u0026gt; 支撑域 \u0026gt; 通用域 常见错误：在通用域上花大力气自研，核心域反而草草了事。 3.2 限界上下文（Bounded Context） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 限界上下文：一个模型成立的\u0026#34;边界范围\u0026#34; 同一个词，在不同上下文里含义不同： \u0026#34;商品\u0026#34; 商品上下文：上架的 SKU（详情、图片、分类） 订单上下文：下单时的快照（当时的价格、规格） 配送上下文：要发货的物理货品（重量、体积、库位） → 三个\u0026#34;商品\u0026#34;，三个上下文，三套模型。不能硬塞进一个 Product 类。 限界上下文 = 模型的自治边界： - 内部有统一的通用语言 - 内部模型自洽，不污染别的上下文 - 对外提供清晰的业务能力 价值：它天然就是\u0026#34;模块/微服务\u0026#34;的拆分单位 （详见微服务系列第三篇：服务拆分第一性原理） 3.3 上下文映射（Context Mapping） 划清上下文后，还要理清它们之间怎么协作。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 常见的上下文关系： 合作关系（Partnership）：两个上下文团队紧密协作，共同设计 → 适合无法明确划分责任的场景 共享内核（Shared Kernel）：两个上下文共享一小块核心模型 → 共享部分强耦合，改动要双方同意。慎用，容易退化成大泥球。 客户-供应商（Customer-Supplier）：上游供应，下游消费 → 上游优先，但要照顾下游需求。明确谁是甲方。 防腐层（Anti-Corruption Layer，ACL）：下游建一层翻译 → 防止上游（尤其是遗留系统）的烂模型\u0026#34;腐蚀\u0026#34;自己的干净模型 → 接入第三方/老系统时的标准做法，极其重要。 开放主机服务（OHS）/ 发布语言（PL）：上游提供标准开放接口 → 对外用一套稳定协议，屏蔽内部实现 实战中最有用：ACL（防腐层）—— 任何接外部/遗留系统的地方都该建。 四、战术设计：建模积木 战略设计画好边界后，在单个上下文内部用战术积木来建模。这些是 DDD 最出名的名词，但记住：它们服务于战略，不是 DDD 的全部。\n4.1 实体（Entity） 1 2 3 4 5 6 7 有\u0026#34;身份标识\u0026#34;（ID），即使属性全变，还是它自己。 例：订单 Order，有 OrderId → 改了收货地址、改了状态，它还是\u0026#34;那一笔订单\u0026#34; → 两个 Order 即使所有字段相同，ID 不同就是两笔 特征：有唯一 ID、有生命周期、靠 ID 判等 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 实体：靠 Id 判等，不靠属性 public class Order : Entity // Entity 基类提供 Id 与判等 { public OrderId Id { get; } public CustomerId CustomerId { get; private set; } public OrderStatus Status { get; private set; } public Address ShippingAddress { get; private set; } public void ChangeShipping(Address newAddress) { if (Status == OrderStatus.Shipped) throw new DomainException(\u0026#34;已发货不能改地址\u0026#34;); ShippingAddress = newAddress; } } 4.2 值对象（Value Object） 1 2 3 4 5 6 7 8 没有身份，靠\u0026#34;属性的值\u0026#34;判等。不可变。 例：地址 Address、金额 Money、坐标 Point → 两个 \u0026#34;北京市朝阳区\u0026#34; 是同一个地址，无需 ID 区分 → 改地址 = 换一个新对象，不修改原对象（不可变） 特征：无 ID、不可变、按值判等、通常很小 价值：被严重低估。能用值对象就别用实体，能省掉大量 ID 管理。 1 2 3 4 5 6 7 8 9 10 11 12 // 值对象：不可变，按值判等（两个金额相同就相等） public readonly record struct Money( decimal Amount, string Currency) { public Money Add(Money other) { if (Currency != other.Currency) throw new DomainException(\u0026#34;币种不同\u0026#34;); return this with { Amount = Amount + other.Amount }; } } // 用法：var total = price.Add(tax); // 不改原对象，返回新的 4.3 聚合与聚合根（Aggregate \u0026amp; Aggregate Root）—— DDD 的核心 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 聚合：一组紧密相关的对象，作为一个\u0026#34;一致性边界\u0026#34;整体被访问。 聚合根：聚合的入口对象，外部只能通过它访问聚合内部。 例：订单聚合 Order（聚合根） ├── OrderItem（订单项，若干） └── ShippingAddress（收货地址，值对象） 规则： - 外部不能直接 new OrderItem 或直接拿 OrderItem 改 - 必须通过 Order.AddItem(...) / Order.RemoveItem(...) - 订单项只在自己所属的订单上下文里有意义 聚合保证的\u0026#34;不变量\u0026#34;（Invariants）： 例：订单总价 = 所有订单项小计之和 库存不能为负 → 这些规则由聚合根在内 部强制维护，外界绕不过去 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // 聚合根：所有不变量在内部强制，外部无法绕过 public class Order : Entity, IAggregateRoot { private readonly List\u0026lt;OrderItem\u0026gt; _items = new(); public IReadOnlyCollection\u0026lt;OrderItem\u0026gt; Items =\u0026gt; _items; public Money Total =\u0026gt; _items.Aggregate( new Money(0, Currency), (sum, i) =\u0026gt; sum.Add(i.SubTotal)); public void AddItem(ProductId product, Money price, int qty) { if (Status != OrderStatus.Draft) throw new DomainException(\u0026#34;非草稿态不能加项\u0026#34;); if (qty \u0026lt;= 0) throw new DomainException(\u0026#34;数量必须大于 0\u0026#34;); var existing = _items.FirstOrDefault(i =\u0026gt; i.ProductId == product); if (existing is not null) existing.Increase(qty); else _items.Add(new OrderItem(product, price, qty)); // 不变量由聚合根维护，外部拿不到 _items 直接改 } } 聚合是 DDD 最关键也最容易用错的概念，第五节专门讲设计原则，后续会有整篇展开。\n4.4 领域服务、领域事件、仓储、工厂 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 领域服务（Domain Service） 承载\u0026#34;不属于任何单个实体\u0026#34;的业务逻辑 例：转账 = 涉及两个账户，不该塞进某一个 Account → 封装成 TransferService.Transfer(from, to, amount) 特征：无状态、纯领域逻辑 领域事件（Domain Event） 记录\u0026#34;领域中发生的事\u0026#34;，用于解耦 例：OrderPlaced（订单已创建）→ 触发扣库存、加积分、发通知 → 聚合做完自己的事，发事件，订阅者各自反应 → 微服务系列第五篇讲过：这是事件驱动 + 最终一致的基础 仓储（Repository） 聚合的\u0026#34;存取接口\u0026#34;，屏蔽持久化细节 对外提供 IOrderRepository.Find(id) / Save(order) 实现在基础设施层（EF Core / Dapper），领域层只认接口 → 一个聚合对应一个仓储，按\u0026#34;聚合根\u0026#34;整体存取，不按表 工厂（Factory） 封装复杂对象的创建（保证创建时就满足不变量） 简单创建用构造函数/静态工厂；复杂聚合用专门的 Factory 五、聚合设计原则（最容易踩的坑） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 1. 尽量设计小聚合 错误：把\u0026#34;用户、订单、商品、评价\u0026#34;塞进一个大\u0026#34;用户聚合\u0026#34; → 一改全锁，性能差、并发冲突多 正确：一个聚合 = 一致性边界，只包含必须一起变更的 2. 跨聚合引用用 ID，不用对象引用 Order 引用 CustomerId，不直接持有 Customer 对象 → 解耦，避免一个聚合加载整张图 3. 一个事务只修改一个聚合 跨聚合的变更 → 用领域事件 + 最终一致（不靠一个事务硬保） → 这就是微服务系列第五篇 Saga / 事件驱动的由来 4. 不变量在聚合内部强一致，跨聚合最终一致 核心：聚合边界 = 事务边界 = 一致性边界（三位一体） 判断聚合边界的试金石： \u0026#34;这几个对象，必须在一个事务里一起改才能保证正确吗？\u0026#34; 是 → 同一聚合；否 → 拆成不同聚合，用事件联动。 六、DDD 落地的常见误区 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 误区 1：贫血模型（Anemic Model） 实体只有 getter/setter，业务逻辑全写在 Service 里 → 实体退化成数据袋，丢失了\u0026#34;封装不变量\u0026#34;的精髓 → 这其实是\u0026#34;事务脚本\u0026#34;，不是 DDD 误区 2：为 DDD 而 DDD 简单 CRUD 系统硬套聚合/仓储/领域事件 → 复杂度暴涨，收益为零。CRUD 就老老实实 CRUD。 误区 3：战略缺失，只剩战术 不画限界上下文，直接开始抠实体值对象 → 战术在错误的边界里建模，比不用 DDD 还糟 误区 4：大聚合 什么都往一个聚合塞，变成分布式时代的\u0026#34;巨型对象\u0026#34; 误区 5：仓储当 DAO 用 按\u0026#34;表\u0026#34;建仓储（UserRepository 对应 User 表） → 仓储应该按\u0026#34;聚合根\u0026#34;建，整体存取，不是按表 CRUD 七、DDD 与微服务、分层架构的关系 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 DDD 不是孤岛，它和前面讲的所有架构概念是一体的： 战略设计（限界上下文） = 微服务的拆分单位（微服务系列第三篇） = 模块化单体的模块边界（系列第二篇） 领域事件 = 事件驱动 / 最终一致的基础（系列第五篇） = Outbox 模式投递的内容 聚合边界 = 事务边界 = 一致性边界 = 数据一致性的设计起点（系列第五篇） 仓储 + 接口隔离 = 整洁架构/洋葱架构的核心（系列第八篇 .NET 落地） 防腐层 ACL = 服务间通信的解耦手段（系列第六篇） → DDD 是\u0026#34;粘合剂\u0026#34;，把前面散落的架构概念，在\u0026#34;业务建模\u0026#34;层面统一起来。 → 这也是为什么把它放在架构系列里讲。 八、小结 DDD 的目标：让业务知识忠实流进代码模型，应对复杂业务 通用语言是灵魂：业务和代码说同一套话，术语即类名 战略设计 \u0026gt; 战术设计：领域/子域（核心/支撑/通用）、限界上下文、上下文映射（尤其防腐层） 战术积木：实体（有 ID）、值对象（按值/不可变）、聚合 + 聚合根（一致性边界）、领域服务/事件/仓储/工厂 聚合四原则：小聚合、跨聚合用 ID 引用、一事务一聚合、内部强一致跨聚合最终一致 五大误区：贫血模型、为 DDD 而 DDD、战略缺失、大聚合、仓储当 DAO DDD 是粘合剂：战略上下文 = 微服务边界；领域事件 = 最终一致基础；聚合 = 一致性边界 下一篇会深入聚合设计——怎么划聚合边界、聚合根该承担什么、跨聚合如何协作，附完整 .NET 代码。\n","date":"2025-11-04T10:00:00+08:00","permalink":"/posts/architecture/ddd/01-ddd-overview/","title":"领域驱动设计（一）：战略设计与战术设计全景"},{"content":"写在前面 前七篇全是道理：权衡、单体、拆分、分布式税、一致性、通信、可观测性。这一篇把理论落成代码——用 ASP.NET Core 搭一个最小可用的微服务骨架。\n它不会是一个完整生产系统，但会覆盖微服务的\u0026quot;承重墙\u0026quot;：项目结构、依赖注入、API 网关、健康检查、弹性（Polly）、可观测性（OpenTelemetry）。每一处都对应前面某一篇讲过的道理。\n前置：本篇假设你读过我的《ASP.NET Core 学习笔记》系列（中间件管道、DI、认证授权）。基于 .NET 8 LTS。\n一、整体结构 1 2 3 4 5 6 7 8 9 10 11 12 一个最小微服务系统（4 个工程）： Gateway/ API 网关（YARP，统一入口、路由、鉴权） OrderService/ 订单服务（业务 A） InventoryService/ 库存服务（业务 A 的依赖） Shared/ 共享内核（契约、公共类型） 对应前文： 拆分（第三篇）→ 按领域切 Order / Inventory 通信（第六篇）→ 网关 + 服务间 HTTP 分布式税（第四篇）→ 健康检查、Polly 弹性 可观测性（第七篇）→ OpenTelemetry 串联 1 2 3 4 5 6 7 8 工程依赖方向（单向，严禁循环）： Gateway ──→ Shared OrderService ──→ Shared InventoryService ──→ Shared OrderService ──调用──→ InventoryService（运行时 HTTP，非编译依赖） 关键：服务间运行时依赖走网络，编译期互不引用 → 这样才能独立编译、独立部署。 二、每个服务的内部分层 单个服务内部，按职责分层（不是按\u0026quot;被拆成服务\u0026quot;的技术层——区别于第三篇的反模式）：\n1 2 3 4 5 6 7 8 9 10 11 12 OrderService/ ├── OrderService.Api/ 表现层（Controller / 最小 API） │ └── 引用 Application、Domain ├── OrderService.Application/ 应用层（用例、编排、接口定义） │ └── 引用 Domain ├── OrderService.Domain/ 领域层（实体、领域服务、规则） │ └── 不依赖任何人（依赖倒置的核心） └── OrderService.Infrastructure/ 基础设施（DB、外部调用、实现） └── 引用 Application（实现接口） 依赖方向永远向内，Domain 在最中心、不依赖任何层。 这是\u0026#34;整洁架构/洋葱架构\u0026#34;的思路，保证领域逻辑纯净。 三、依赖注入：组装一切 ASP.NET Core 的 DI 是一等公民（见我的 ASP.NET Core 笔记一）。微服务里，所有跨层依赖都通过 DI 注入，这样才好测试、好替换。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 // OrderService.Application/Dependencies.cs public static class Dependencies { public static IServiceCollection AddOrderServices(this IServiceCollection services) { // 应用层服务 services.AddScoped\u0026lt;IOrderService, OrderService\u0026gt;(); // 端口适配器：接口在 Application，实现在 Infrastructure services.AddScoped\u0026lt;IInventoryClient, InventoryClient\u0026gt;(); services.AddScoped\u0026lt;IOrderRepository, OrderRepository\u0026gt;(); return services; } } // OrderService.Api/Program.cs var builder = WebApplication.CreateBuilder(args); builder.Services.AddOrderServices(); // 业务服务 builder.Services.AddControllers(); builder.Services.AddHealthChecks(); // 健康检查（第五节） builder.Services.AddHttpClientPolicies(); // Polly 弹性（第六节） var app = builder.Build(); app.MapControllers(); app.MapHealthChecks(\u0026#34;/health\u0026#34;); // 暴露健康检查端点 app.Run(); 单元测试里可以替换 IInventoryClient 为 mock，这就是第四篇《.NET 单元测试：依赖注入与可测试性》的价值。\n四、API 网关：YARP 统一入口 对外不直连各服务，用网关收口（第六篇）。.NET 生态用 YARP（Yet Another Reverse Proxy，微软出品，比 Ocelot 更现代）。\n1 2 3 4 5 6 7 8 // Gateway/Program.cs var builder = WebApplication.CreateBuilder(); builder.Services.AddReverseProxy() .LoadFromConfig(builder.Configuration.GetSection(\u0026#34;ReverseProxy\u0026#34;)); // 路由放配置 var app = builder.Build(); app.MapReverseProxy(); // 网关入口 app.Run(); 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 // Gateway/appsettings.json —— 路由配置 { \u0026#34;ReverseProxy\u0026#34;: { \u0026#34;Routes\u0026#34;: { \u0026#34;order-route\u0026#34;: { \u0026#34;ClusterId\u0026#34;: \u0026#34;order-cluster\u0026#34;, \u0026#34;Match\u0026#34;: { \u0026#34;Path\u0026#34;: \u0026#34;/api/orders/{**catch-all}\u0026#34; } }, \u0026#34;inventory-route\u0026#34;: { \u0026#34;ClusterId\u0026#34;: \u0026#34;inventory-cluster\u0026#34;, \u0026#34;Match\u0026#34;: { \u0026#34;Path\u0026#34;: \u0026#34;/api/inventory/{**catch-all}\u0026#34; } } }, \u0026#34;Clusters\u0026#34;: { \u0026#34;order-cluster\u0026#34;: { \u0026#34;Destinations\u0026#34;: { \u0026#34;d1\u0026#34;: { \u0026#34;Address\u0026#34;: \u0026#34;http://order-service:8080/\u0026#34; } } }, \u0026#34;inventory-cluster\u0026#34;: { \u0026#34;Destinations\u0026#34;: { \u0026#34;d1\u0026#34;: { \u0026#34;Address\u0026#34;: \u0026#34;http://inventory-service:8080/\u0026#34; } } } } } } 1 2 3 4 5 6 网关集中处理（不在每个服务里重复）： - 路由（/api/orders → 订单服务） - 鉴权（统一验 token） - 限流、熔断 - 协议转换 生产环境再配多实例 + 负载均衡，保证网关本身高可用。 五、健康检查：编排的基础 K8s 和负载均衡器靠健康检查决定\u0026quot;流量往哪打\u0026quot;（第四篇分布式税）。ASP.NET Core 内置 HealthCheck。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 // 自定义健康检查：探测下游依赖 public class InventoryHealthCheck : IHealthCheck { private readonly IInventoryClient _client; public InventoryHealthCheck(IInventoryClient client) =\u0026gt; _client = client; public async Task\u0026lt;HealthCheckResult\u0026gt; CheckHealthAsync( HealthCheckContext context, CancellationToken ct = default) { try { var ok = await _client.PingAsync(ct); return ok ? HealthCheckResult.Healthy(\u0026#34;inventory reachable\u0026#34;) : HealthCheckResult.Degraded(\u0026#34;inventory slow\u0026#34;); } catch { return HealthCheckResult.Unhealthy(\u0026#34;inventory down\u0026#34;); } } } // 注册 builder.Services.AddHealthChecks() .AddCheck\u0026lt;InventoryHealthCheck\u0026gt;(\u0026#34;inventory\u0026#34;, failureStatus: HealthStatus.Unhealthy); // 分别暴露存活探针 / 就绪探针（K8s 标准用法） app.MapHealthChecks(\u0026#34;/health/live\u0026#34;, new() { Predicate = _ =\u0026gt; false }); // 进程活着 app.MapHealthChecks(\u0026#34;/health/ready\u0026#34;, new() { Predicate = r =\u0026gt; r.Tags.Contains(\u0026#34;ready\u0026#34;) }); 1 2 3 4 5 K8s 里： livenessProbe → /health/live （进程活着吗） readinessProbe → /health/ready （依赖就绪、能接流量吗） 就绪探针失败 → K8s 把该实例摘出流量，但不重启； 存活探针失败 → K8s 重启容器。 六、弹性：Polly 应对分布式税 跨网络调用必加弹性（第四篇的超时/重试/熔断）。.NET 用 Polly，配合 HttpClientFactory。\n1 2 3 4 5 6 7 8 9 10 11 // 订单服务调用库存服务，加满弹性策略 builder.Services.AddHttpClient\u0026lt;IInventoryClient, InventoryClient\u0026gt;(client =\u0026gt; { client.BaseAddress = new(\u0026#34;http://inventory-service:8080/\u0026#34;); client.Timeout = TimeSpan.FromSeconds(3); // ① 超时 }) .AddTransientHttpErrorPolicy(p =\u0026gt; // ② 重试（仅对临时错误） p.WaitAndRetryAsync(3, i =\u0026gt; TimeSpan.FromMilliseconds(200 * Math.Pow(2, i)))) .AddTransientHttpErrorPolicy(p =\u0026gt; // ③ 熔断 p.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30))); // AddTransientHttpErrorPolicy 默认对 5xx、408、网络错误生效 1 2 3 4 5 这几行代码对应第四篇的整笔账： 超时 → 别傻等慢服务 重试 → 应对网络抖动（配合幂等，库存扣减接口必须幂等） 熔断 → 库存服务挂了，订单服务直接快速失败，不耗尽自己的线程池 → 切断雪崩的放大效应 七、可观测性：OpenTelemetry 串联 第七篇的链路追踪，几行代码接入。\n1 2 3 4 5 6 7 8 9 // 各服务统一接入 OpenTelemetry builder.Services.AddOpenTelemetry() .WithTracing(tp =\u0026gt; tp .AddAspNetCoreInstrumentation() // 自动追踪 HTTP 入站 .AddHttpClientInstrumentation() // 自动追踪 HttpClient 出站 .AddSqlClientInstrumentation() // 自动追踪 DB 调用 .AddOtlpExporter()); // 导出到 OTel Collector builder.Logging.AddOpenTelemetry(); // 日志自动带 traceId 1 2 3 4 5 效果：一次\u0026#34;下单\u0026#34;请求 网关(span) → 订单服务(span) → 库存服务(span) → DB(span) 自动连成一条完整 trace，traceId 自动透传、日志自动关联。 Jaeger 里一眼看到哪一段慢。 → 这就是第七篇说的\u0026#34;微服务神经系统\u0026#34;，接入成本极低。 八、配置与外部化 1 2 3 4 5 6 7 8 9 10 11 12 每个服务的配置分环境（开发/测试/生产）外置： appsettings.json —— 默认值 appsettings.Production —— 生产覆盖 环境变量 / K8s ConfigMap / Secret —— 敏感和环境相关 builder.Configuration .AddJsonFile(\u0026#34;appsettings.json\u0026#34;) .AddJsonFile($\u0026#34;appsettings.{env}.json\u0026#34;, optional: true) .AddEnvironmentVariables(); // 环境变量优先级最高，适合容器 分布式税里的\u0026#34;配置中心\u0026#34;（Nacos/Apollo）在规模上来后再引入， 小系统先用环境变量 + ConfigMap 足够。 九、把前七篇串起来 1 2 3 4 5 6 7 8 9 10 11 12 13 这个骨架里，每一处都对应前面某一篇： 工程结构、依赖方向 ← 第三篇：按领域切，单向依赖 DI 注入一切 ← 可测试性（ASP.NET Core 笔记四） YARP 网关 ← 第六篇：统一入口、路由、鉴权 健康检查 ← 第四篇：编排基础（K8s 探针） Polly 超时/重试/熔断 ← 第四篇：弹性，防雪崩 OpenTelemetry ← 第七篇：可观测性神经系统 服务间 HTTP + 幂等 ← 第五、六篇：一致性 + 通信 配置外置 ← 第四篇：分布式税中的配置 这就是一个\u0026#34;承重墙齐全\u0026#34;的微服务起点。 业务往上长，基础设施已就位。 十、回到第一篇：要不要这么做 1 2 3 4 5 6 7 8 9 10 11 12 13 14 最后，回到系列第一篇的\u0026#34;权衡\u0026#34;： 这个骨架是\u0026#34;当你要做微服务时\u0026#34;的标准起手式。 但你要不要做微服务，回到那几个问题： - 团队是不是大到需要拆分协作？ - 是不是有独立扩容 / 独立发布 / 故障隔离的真实需求？ - 有没有 K8s + CI/CD + 可观测性的运维能力？ 如果没有 → 第二篇的\u0026#34;模块化单体\u0026#34;才是你的答案。 把这套骨架的\u0026#34;DI + 分层 + 健康检查 + 可观测性\u0026#34; 用在单体内部，一样成立，还省了网络和分布式税。 记住：技术选型服务于问题和约束，不是反过来。 十一、系列小结 八篇下来，一条主线：\n（一）架构是权衡：没有银弹，一切看约束 （二）单体被低估：模块化单体是大多数团队的起点 （三）拆分看领域：按业务切，不按技术层切 （四）分布式要收税：网络、一致性、可观测、运维，样样要自建 （五）一致性靠模式：核心收敛强一致，周边用最终一致 + Saga + Outbox （六）通信看同步异步：REST/gRPC 与消息队列各擅其场 （七）可观测性是神经：日志 + 指标 + 链路，traceId 串联 （八）落地 ASP.NET Core：把理论变成可跑的代码 架构没有终点。系统会演进，约束会变化，今天的最优解是明天的技术债。保持权衡的思维、演进的视角、记录决策的习惯——这才是这套系列想留给你的，比任何具体技术都重要的东西。\n","date":"2025-10-27T10:00:00+08:00","permalink":"/posts/architecture/microservices/08-aspnetcore-microservice/","title":"后端架构实战（八）：落地——用 ASP.NET Core 搭一个微服务骨架"},{"content":"写在前面 单体里排查问题，打开一个日志文件翻一翻就行。微服务里，一个用户请求要穿越 5 个服务，出问题了你去哪翻日志？哪个服务慢？\n可观测性（Observability） 就是微服务的神经系统——没有它，系统就是个黑盒，只能靠玄学排查。这一篇讲清三大支柱：日志、指标、链路追踪。\n这篇和我写的《.NET Dump 诊断》《.NET 性能优化与 Profiling》呼应——那两篇是\u0026quot;单进程深度诊断\u0026quot;，这篇是\u0026quot;跨服务的全局可观测\u0026quot;。\n一、监控 vs 可观测性 1 2 3 4 5 6 7 8 9 10 11 监控（Monitoring）：你知道要问什么，系统告诉你答案 → 预设告警、仪表盘（CPU 高了？错误率涨了？） → 应对\u0026#34;已知的未知\u0026#34; 可观测性（Observability）：你不一定知道要问什么， 但系统能让你探索出答案 → 任意维度下钻、关联日志/指标/链路 → 应对\u0026#34;未知的未知\u0026#34; 微服务复杂度高，新问题层出不穷， 单靠预设监控不够，必须建可观测性。 二、三大支柱 1 2 3 4 5 6 7 8 9 10 11 12 13 14 ┌──────────────────────────────────────────────────┐ │ 可观测性三大支柱 │ │ │ │ 1. 日志 Logs —— 离散的事件记录 │ │ \u0026#34;14:03 用户 123 下单失败，余额不足\u0026#34; │ │ │ │ 2. 指标 Metrics —— 聚合的数值（时间序列） │ │ \u0026#34;过去 1 分钟订单服务 QPS=1200, p99=85ms\u0026#34; │ │ │ │ 3. 链路 Traces —— 一个请求跨服务的完整轨迹 │ │ \u0026#34;请求 → 网关 → 订单 → 库存(慢) → 账户\u0026#34; │ │ │ │ 用 traceId 把三者串起来 = 完整的可观测性 │ └──────────────────────────────────────────────────┘ 三、日志（Logs） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 日志是离散的事件，记录\u0026#34;发生了什么\u0026#34;。 好日志的特征： - 带上下文（谁、做了什么、结果） - 结构化（JSON，方便检索聚合，别只写纯文本） - 分级别（DEBUG/INFO/WARN/ERROR） - 带关联 ID（traceId，见第五节） 反例： log(\u0026#34;error\u0026#34;) ← 完全没用，错什么了？ log(\u0026#34;user error: \u0026#34; + e) ← 没有上下文，哪个用户？ 好例： log.error({ userId: 123, orderId: 456, traceId }, \u0026#34;下单失败：余额不足\u0026#34;) 微服务：日志分散在各服务，必须聚合到统一存储 ELK（Elasticsearch + Logstash + Kibana）/ Loki / Grafana 四、指标（Metrics） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 指标是聚合的数值，回答\u0026#34;系统整体怎么样\u0026#34;。 四大黄金信号（Google SRE）： 1. 延迟（Latency） —— p50/p99 响应时间 2. 流量（Traffic） —— QPS / 请求数 3. 错误（Errors） —— 错误率 / 5xx 比例 4. 饱和度（Saturation）—— CPU / 内存 / 连接数 / 队列堆积 特点： - 聚合（不是每条请求一条，是按时间窗口聚合） - 低成本（存的是数字，不是文本） - 适合告警（阈值触发） 指标 vs 日志的分工： 指标 → 知道\u0026#34;出问题了 / 在哪里\u0026#34;（大盘、告警） 日志 → 知道\u0026#34;具体怎么回事\u0026#34;（下钻查具体那条） 常见：Prometheus（采集存储）+ Grafana（可视化） .NET 自带 metrics API，可导出到 Prometheus。 1 2 3 4 5 6 为什么用 p99 不用平均： 100 个请求，99 个 10ms，1 个 10s 平均 = 110ms（看着还行） p99 = 10s（真相：有 1% 的用户体验极差） 平均会掩盖长尾。线上要看分位数（p95/p99）。 五、链路追踪（Traces）—— 微服务最重要的支柱 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 链路追踪：把一个请求经过的所有服务、每段的耗时，画成一条完整轨迹。 一个 trace = 一个请求的完整旅程 一个 span = 旅程中的一段（一次服务调用 / 一次 DB 查询） 示例 trace： [下单请求] 总 120ms ├─ 网关 2ms ├─ 订单服务.createOrder 118ms │ ├─ 库存服务.deduct 10ms │ ├─ DB.insert_order 5ms │ └─ 账户服务.debit 100ms ← 慢点在这！ └─ 响应 0ms 一眼看出：账户服务是瓶颈。 没有链路追踪，这种问题在微服务里几乎无从查起。 5.1 traceId 怎么传 1 2 3 4 5 6 7 8 9 10 11 12 关键机制：traceId 跨服务透传 网关生成 traceId → 放进 HTTP Header（W3C Trace Context: traceparent） → 每个服务收到请求，从 Header 取出 traceId → 调下游时再把 traceId 透传出去 → 日志里都打上这个 traceId 效果：一条请求在所有服务、所有日志、所有指标里，共享一个 traceId。 排查时按 traceId 一过滤，整条链路的来龙去脉全出来。 标准协议：W3C Trace Context（traceparent / tracestate） 几乎所有语言/框架都支持。 六、OpenTelemetry：统一的采集标准 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 痛点：以前日志用 A 厂商 SDK，指标用 B 厂商，链路用 C 厂商 → 换一套后端就要改一堆代码，被厂商绑架 OpenTelemetry（OTel）：CNCF 的统一可观测性标准 - 一套 API/SDK，同时采集 日志 + 指标 + 链路 - 厂商无关：采集和后端解耦 换后端（Jaeger/Prometheus/Tempo/Datadog…）只改配置，不改代码 - 自动埋点：HTTP、DB driver、K8s 等自动注入 trace - 已是事实标准，主流语言都支持（含 .NET） .NET 接入： OpenTelemetry.Extensions.Hosting + 各 Instrumentation 包（AspNetCore / HttpClient / SqlClient） → 几行代码，自动给所有 HTTP 调用和 DB 查询加上 span 1 2 3 4 5 6 7 8 典型架构： 应用（OTel SDK）─采集─→ OTel Collector ─路由─→ 后端 ├─ Jaeger/Tempo（链路） ├─ Prometheus（指标） └─ Loki/ES（日志） Collector 是中间层：接收、处理、转发，解耦应用和后端。 七、告警：可观测性的出口 1 2 3 4 5 6 7 8 9 10 11 12 13 采集了一堆数据，最终要变成\u0026#34;主动通知\u0026#34;——告警。 好告警的原则： 1. 基于症状，不是原因 告\u0026#34;下单错误率 \u0026gt; 1%\u0026#34;（用户真受影响），别告\u0026#34;CPU 80%\u0026#34;（可能没事） 2. 可执行 每条告警都该有对应的处理动作；收到的告警没人知道怎么处理 = 噪音 3. 少而精 告警风暴会让所有人麻木；宁可少，要准 4. 分级 P0 电话叫醒 / P1 工作时间处理 / P2 记录即可 常见反模式：告 CPU/内存/磁盘每一个阈值 → 天天报警，全员麻木。 八、可观测性的落地优先级 1 2 3 4 5 6 7 8 9 从零搭建，按这个顺序： 1. 结构化日志 + 日志聚合（先有日志能查） 2. 基础指标 + 黄金信号仪表盘（知道整体健康度） 3. 关键链路追踪（核心请求的 traceId 透传） 4. 统一用 OpenTelemetry 重新规范（前面用临时方案也不要紧） 5. 基于症状的告警 不要一上来追求全套，先把\u0026#34;出问题能查\u0026#34;建起来。 九、小结 可观测性 \u0026gt; 监控：应对\u0026quot;未知的未知\u0026quot;，能探索而不只是预设告警 三大支柱：日志（离散事件）、指标（聚合数值）、链路（请求轨迹），用 traceId 串联 指标看分位数（p99）不看平均，平均会掩盖长尾 链路追踪是微服务最重要的支柱：trace + span，traceId 跨服务透传 OpenTelemetry：统一采集标准，厂商无关，已成事实标准 告警：基于症状、可执行、少而精、分级 落地顺序：日志聚合 → 基础指标 → 链路追踪 → OTel 规范化 → 告警 最后一篇，把前七篇的理论落到 ASP.NET Core 代码，搭一个微服务骨架。\n","date":"2025-10-23T10:00:00+08:00","permalink":"/posts/architecture/microservices/07-observability/","title":"后端架构实战（七）：可观测性——微服务的神经系统"},{"content":"写在前面 上一篇解决了\u0026quot;跨服务数据怎么一致\u0026quot;，这一篇解决\u0026quot;跨服务怎么对话\u0026quot;。\n服务间通信是个看似简单、实则踩坑最密集的话题。选错通信方式，会带来延迟、耦合、故障放大一连串问题。核心决策只有一个：同步，还是异步？\n这篇会和我的中间件笔记呼应：《消息队列》系列讲了 RabbitMQ/Kafka 本身，《Nginx》系列讲了反向代理和负载均衡。这里讲的是\u0026quot;在架构层怎么选\u0026quot;。\n一、同步 vs 异步：第一性抉择 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 同步通信（打电话）： 调用方发请求 → 等 → 拿到响应 ✓ 直观、顺序清晰、易理解 ✓ 能立即知道结果（成功/失败） ✗ 调用方被阻塞，吞吐受限 ✗ 强耦合：被调方挂了，调用方也受影响 ✗ 故障会沿调用链同步放大 异步通信（发邮件）： 调用方发消息 → 立即返回 → 被调方按自己节奏处理 ✓ 解耦、高吞吐、削峰填谷 ✓ 调用方不等被调方，故障不直接传导 ✗ 不立即知道结果（要回调/查询） ✗ 一致性弱化（最终一致） ✗ 调试、追踪更复杂 1 2 3 4 5 6 选择原则： 必须立即拿到结果 → 同步（查余额、读商品、用户登录） 结果可以晚点、且能解耦 → 异步（发通知、记日志、算积分） 反模式：把所有通信都做成同步 → 一次请求串行调 5 个服务，延迟叠加、雪崩放大。 二、同步通信：REST vs RPC 2.1 REST 1 2 3 4 5 6 7 8 9 10 11 12 13 14 基于 HTTP，面向\u0026#34;资源\u0026#34; GET /orders/123 查 POST /orders 建 PUT /orders/123 改 DELETE /orders/123 删 ✓ 通用、标准化、人可读 ✓ 跨语言、跨平台、防火墙友好 ✓ 生态丰富（网关、监控、文档 Swagger） ✗ 基于文本（JSON），序列化开销大 ✗ 语义偏向 CRUD，复杂业务动作要绕（POST /orders/123/cancel） ✗ 没有强类型契约（靠 OpenAPI 补） 适合：对外 API、和前端/第三方对接、绝大多数业务场景 2.2 RPC（gRPC 为代表） 1 2 3 4 5 6 7 8 9 10 11 基于 HTTP/2 + Protobuf，面向\u0026#34;动作/方法\u0026#34; stub.CancelOrder(request) → response ✓ 二进制传输，体积小、速度快（比 JSON 快数倍） ✓ 强类型契约（.proto 生成各语言客户端） ✓ 支持 HTTP/2 多路复用、流式（stream） ✗ 不可读（二进制），调试要工具 ✗ 浏览器/前端不友好（要 gRPC-Web 网关） ✗ 强耦合：proto 一变客户端要重新生成 适合：内部服务间高频调用、低延迟、大数据量、流式场景 1 2 3 4 5 6 选型经验： 对外、前端、第三方 → REST 内部服务间、低延迟、高吞吐 → gRPC 大多数团队 → REST 打天下，内部热点链路再换 gRPC 不要一上来全 gRPC，proto 的耦合成本会被低估。 三、异步通信：消息队列 1 2 3 4 5 6 7 8 9 10 11 通过 Broker（消息中间件）解耦 生产者 → 消息队列 → 消费者 生产者不用知道谁消费、有几个、在哪 ✓ 解耦（生产者消费者互不感知） ✓ 削峰（流量高峰积压在队列，消费者按能力处理） ✓ 异步、高吞吐 ✓ 故障隔离（消费者挂了，消息留在队列） ✗ 一致性弱（最终一致） ✗ 增加架构复杂度（Broker 本身要高可用） 3.1 两大流派 1 2 3 4 5 6 7 8 9 10 11 12 13 RabbitMQ —— 传统消息队列（AMQP） 特点：丰富的路由（Exchange/Queue/Binding）、可靠投递、消息确认 模型：一条消息被消费后删除 强项：业务消息路由、可靠投递、延迟队列 弱项：吞吐和堆积能力一般 Kafka —— 分布式日志/流平台 特点：高吞吐、可堆积海量消息、分区有序、支持流处理 模型：消息是\u0026#34;日志\u0026#34;，可重复消费、按 offset 强项：日志、事件溯源、大数据管道、超高吞吐 弱项：路由能力弱、不适合复杂业务路由 详细对比见《消息队列（四）：RabbitMQ vs Kafka 深度对比》。 3.2 两种消息语义 1 2 3 4 5 6 7 8 点对点（Queue）：一条消息被一个消费者消费 → 任务分发（一个订单只被一个处理者处理） 发布订阅（Topic）：一条消息被所有订阅者消费 → 事件广播（订单创建后，积分、通知、统计都各收一份） 事件驱动架构（EDA）主要用 Pub/Sub： 订单服务发\u0026#34;订单已创建\u0026#34;，所有感兴趣的下游各自订阅。 四、消息可靠性的三个保证 用消息队列，必须想清楚这三件事，否则会丢消息：\n1 2 3 4 5 6 7 8 9 10 11 1. 生产端不丢：确认机制 + 重试 + 本地落库 → 关键消息用 Outbox 模式（上一篇讲过） 2. Broker 不丢：持久化 + 副本 + 确认 → 消息持久化磁盘、集群多副本、生产者等 broker 确认 3. 消费端不丢：手动确认 + 幂等 → 处理成功后再 ack（别自动 ack） → 处理失败重试，重试要幂等（消息会重复投递） 三个环节任何一个漏了，消息就会丢。 五、服务间通信的反模式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 反模式 1：同步链路过长 A→B→C→D→E，五个服务串行同步调用 → 延迟叠加、任意一环故障全链路崩 → 能异步化的副作用（日志、通知、统计）坚决异步 反模式 2：循环依赖 A 调 B，B 又调 A → 死锁可能、边界划分反了（回到第三篇：拆错了） 反模式 3：共享数据库当通信 两个服务通过读写同一张表\u0026#34;传话\u0026#34; → 表结构耦合，等于没拆，且并发冲突 → 服务通信要走 API/消息，不偷懒走数据库 反模式 4：消息不带版本 事件 schema 一改，所有消费者崩 → 事件契约要版本化、向后兼容 反模式 5：忽略超时和熔断 同步调用不设超时 → 一个慢服务拖垮调用方线程池 → 永远设超时 + 熔断（第四篇的分布式税） 六、网关：通信的统一入口 1 2 3 4 5 6 7 8 9 10 11 12 13 14 对外不用让客户端直连每个服务，前面加一层 API 网关： 客户端 → API 网关 → 各微服务 网关职责： - 统一鉴权（token 校验集中在一处） - 限流、熔断 - 路由（外部 REST 路由到内部 gRPC） - 协议转换（gRPC-Web、聚合多个服务） - 灰度、A/B 常见：Kong / APISIX / Nginx（见我的 Nginx 系列）/ Ocelot（.NET）/ YARP（.NET） 注意：网关是单点，必须高可用（多实例 + 负载均衡）。 七、一份选型速查 1 2 3 4 5 6 7 8 9 需求 选择 ────────────────────────────────────────────── 对外 API / 前端调用 REST 内部高频、低延迟调用 gRPC 必须立即拿结果 同步（REST/gRPC） 可解耦、可削峰、能异步 消息队列 事件广播、多下游订阅 Kafka / RabbitMQ Pub-Sub 可靠事件投递 Outbox + 消息队列 统一入口、鉴权、限流 API 网关 八、小结 第一性抉择：同步（要立即结果）还是异步（可解耦、能晚点） 同步：REST（通用、对外）vs gRPC（内部、低延迟、强类型） 异步：消息队列解耦、削峰、故障隔离；RabbitMQ 重路由，Kafka 重吞吐/流 可靠性三保证：生产端确认+Outbox、Broker 持久化+副本、消费端手动ack+幂等 五大反模式：同步链路过长、循环依赖、共享库通信、消息不版本化、忽略超时熔断 网关：统一入口，集中鉴权/限流/路由，必须高可用 下一篇讲把这些服务监控起来——可观测性，微服务的神经系统。\n","date":"2025-10-19T10:00:00+08:00","permalink":"/posts/architecture/microservices/06-service-communication/","title":"后端架构实战（六）：服务间通信——RPC、REST 还是消息"},{"content":"写在前面 上一篇把\u0026quot;分布式税\u0026quot;列了一遍，其中最重的一笔是数据一致性。这一篇就把它讲透。\n单体的世界里，一个数据库事务就能保证 ACID，干净利落。一旦拆成微服务、每个服务一个库，跨库就再也没有 ACID 了。那数据一致性怎么保证？答案是：放弃强一致，用一系列模式把\u0026quot;最终一致\u0026quot;工程化。\n前置：这篇假设你理解 ACID 和隔离级别。我在《数据库系列（四）：事务与 ACID》《数据库系列（五）：MVCC 与隔离级别》里讲过单机版。\n一、问题：跨服务的\u0026quot;事务\u0026quot;怎么办 1 2 3 4 5 6 7 8 9 10 11 12 13 经典场景：下单 = 创建订单 + 扣库存 + 扣余额 + 加积分 单体：一个数据库事务 BEGIN; insert 订单; update 库存; update 余额; insert 积分; COMMIT; → 要么全成功，要么全回滚。ACID 保护你。 微服务：订单/库存/账户/积分 各自独立的库 insert 订单 ✅ → 扣库存 ✅ → 扣余额 ❌（账户服务挂了） → 订单建了、库存扣了、余额没扣。钱少货没了。灾难。 跨库没有 ACID，怎么办？ 二、两条根本出路 1 2 3 4 5 6 7 8 出路 A：把强一致收敛在一个服务内（首选） → 核心数据合并到同一个库（甚至同一个服务） → 第三篇说过：核心交易该收敛在一个上下文里 → 能不分布式，就不分布式 出路 B：接受最终一致，用模式工程化（本篇重点） → 实在拆开了，就放弃 ACID，保证\u0026#34;最终\u0026#34;一致 → 下面四个模式：Saga / 事件驱动 / Outbox / CQRS 第一选择永远是 A。B 是\u0026quot;不得不拆\u0026quot;时的补救。不要为了用 Saga 而 Saga。\n三、Saga 模式 Saga：把一个分布式事务拆成一串本地事务，每个本地事务有对应的补偿动作。任何一步失败，就反向执行已完成步骤的补偿，最终达到\u0026quot;一致\u0026quot;。\n1 2 3 4 5 6 7 8 9 10 11 12 下单 Saga（每步都有补偿）： 正向： 补偿（失败时反向执行）： 1. 创建订单 → 取消订单 2. 扣库存 → 还库存 3. 扣余额 → 退余额 4. 加积分 → 扣积分 执行： 1 ✅ → 2 ✅ → 3 ❌（余额不足） → 触发补偿：还库存(2) → 取消订单(1) → 最终：订单取消、库存还原、余额没动。一致。 Saga 有两种协调方式：\n1 2 3 4 5 6 7 8 9 10 11 12 编排（Orchestration）—— 有一个中心协调者 Orchestrator 按顺序调用各服务，跟踪状态，失败时发补偿命令 ✓ 流程清晰、易监控、状态可控 ✗ 协调者成了单点（需高可用） 适合：流程复杂、步骤多的核心业务（如下单全流程） 协同（Choreography）—— 无中心，事件驱动 每个服务订阅事件，完成自己的事后发新事件 订单服务发\u0026#34;订单已创建\u0026#34; → 库存服务听到后扣库存并发\u0026#34;库存已扣\u0026#34; → ... ✓ 去中心化、易扩展、无单点 ✗ 流程隐式、难追踪（出问题不知道现在走到哪） 适合：流程简单、步骤少 Saga 的底子是分布式事务理论，我在《分布式系统学习笔记（四）：分布式事务》里讲过 2PC/TCC/Saga 的对比，这里只讲架构落地。\nSaga 的两个硬骨头 1 2 3 4 5 6 7 1. 补偿必须幂等 补偿动作可能被重试执行（网络抖动） → \u0026#34;扣库存\u0026#34;的补偿\u0026#34;还库存\u0026#34;必须能重复执行不出错 2. 不是所有操作都能补偿 \u0026#34;发短信\u0026#34;\u0026#34;扣款给第三方\u0026#34;这类副作用无法撤销 → 把不可补偿的步骤放到最后，或用\u0026#34;预占/确认\u0026#34;两阶段 四、事件驱动 + 最终一致性 1 2 3 4 5 6 7 8 9 10 11 核心理念：服务之间通过\u0026#34;事件\u0026#34;解耦，不强求同步一致 订单服务：创建订单后，发\u0026#34;订单已创建\u0026#34;事件（不关心谁消费） → 库存、积分、通知 各自订阅，异步处理 → 订单服务不等待它们，立即返回成功 一致性保证：弱化了 订单成功了，但积分可能晚几秒才加（最终一致） 需要业务接受这个延迟窗口 收益：解耦、异步、高可用、可扩展 最终一致性的前提：业务能容忍短暂不一致。账号余额晚 3 秒更新，用户感知不到；但支付晚 3 秒就不行。核心资金强一致，周边副作用最终一致——这是通用法则。\n五、Outbox 模式：解决\u0026quot;事件发了但没发出去\u0026quot; 事件驱动有个经典坑：业务操作和发事件不在一个事务里。\n1 2 3 4 5 6 7 8 9 10 问题： update 订单 ✅（提交了） 发\u0026#34;订单已创建\u0026#34;事件 ❌（发消息时网络挂了） → 订单建了，但下游永远收不到事件。 或者反过来： 发事件 ✅ → 写库 ❌ → 下游收到事件，但订单其实没建。 根因：数据库事务和消息中间件是两个系统，没法一起 ACID。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Outbox 模式（发件箱）： 把\u0026#34;事件\u0026#34;当成一行记录，和业务数据写进同一个库： BEGIN; insert 订单; insert into outbox(event) values(\u0026#39;订单已创建\u0026#39;); -- 同库同事务 COMMIT; → 订单和事件要么都成功，要么都失败。ACID 保护。 然后一个独立进程（或 CDC 工具）： 轮询 outbox 表 → 把事件投递到消息队列 → 标记已发送 常见：Debezium（监听 binlog）、自研轮询 worker 效果：业务操作和事件发布，获得\u0026#34;原子性\u0026#34;。 这是事件驱动架构的标准配置。 六、幂等性：分布式一致性的基石 1 2 3 4 5 6 7 8 9 10 11 12 为什么必须幂等： 网络会重试、消息会重复投递、补偿会重复执行 → 同一个操作可能被执行多次 → 必须保证\u0026#34;执行一次 = 执行多次\u0026#34;的结果相同 实现手段： 1. 唯一业务 ID + 去重表（处理过就跳过） 2. 状态机（只有特定状态才能流转） 3. 乐观锁版本号（update ... where version = x） 4. 数据库唯一约束（重复插入直接失败） 经验：所有跨服务、跨网络的写操作，默认就要按幂等设计。 七、CQRS：读写分离 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 CQRS（Command Query Responsibility Segregation） 传统：一个模型既读又写 CQRS：写模型（命令）和读模型（查询）分开 写模型：面向业务一致性，规范化存储（如关系库） 读模型：面向查询性能，反规范化（如 ES、Redis、物化视图） 数据流： 写 → 写库 → 发事件 → 同步到读库（最终一致） 读 → 直接读读库（快、可定制） 价值： ✓ 读写各自优化（写要一致，读要快） ✓ 读侧可水平扩展、多形态查询 ✓ 天然配合事件驱动 代价： ✗ 复杂度上升（要维护数据同步） ✗ 读写有延迟（最终一致） 适用：读多写少、查询复杂、读写负载差异大的场景。 不要在简单 CRUD 上用 CQRS。 八、一份选型清单 1 2 3 4 5 6 7 8 9 场景 推荐方案 ────────────────────────────────────────────────────── 核心交易（下单、支付、扣款） 强一致，尽量收敛在一个服务/库 跨服务流程，步骤多，需补偿 Saga（编排式） 跨服务流程，步骤少，去中心 事件驱动（协同式） 副作用通知（积分、通知、统计） 事件驱动 + 最终一致 事件可靠投递 Outbox 模式 读多写少、查询复杂 CQRS 所有跨网络写操作 必须幂等 九、小结 第一原则：核心数据尽量收敛在一个库/服务，能不分布式就不分布式 Saga：本地事务链 + 补偿；编排式（中心）适合复杂流程，协同式（事件）适合简单流程 事件驱动：服务间通过事件解耦，接受最终一致 Outbox 模式：把事件写入业务库同事务，解决\u0026quot;事件丢失\u0026quot; 幂等性：所有跨网络写操作的基石 CQRS：读写分离，读侧可极致优化，代价是复杂度和延迟 通用法则：核心资金强一致，周边副作用最终一致 下一篇讲服务之间怎么通信：同步的 RPC/REST，还是异步的消息？\n","date":"2025-10-15T10:00:00+08:00","permalink":"/posts/architecture/microservices/05-data-consistency/","title":"后端架构实战（五）：分布式数据一致性——ACID 之后怎么办"},{"content":"写在前面 前三篇我一直在\u0026quot;劝退\u0026quot;过早微服务化，不是反对微服务，是想让你想清楚再拆。这一篇就把账算明白：拆成微服务之后，你到底要为哪些隐性成本买单。\n我把这些成本叫做**\u0026ldquo;分布式税\u0026rdquo;**——单体里免费的东西，到了分布式环境，每一项都要你自己花人、花钱、花时间重新建起来。\n一、什么叫\u0026quot;分布式税\u0026quot; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 单体里（进程内）免费的能力： - 函数调用 = 直接 new、直接调，纳秒级 - 强一致性 = 一个数据库事务搞定 - 配置 = 读本地配置文件 - 日志 = 写同一个文件 - 鉴权 = 一个进程内的中间件 - 故障传播 = 进程挂了，全挂（简单粗暴） 微服务里（跨网络）每一项都要重新建： - 函数调用 → 网络调用（毫秒级、会超时、会丢包） - 强一致 → 分布式事务（Saga / TCC / 最终一致） - 配置 → 配置中心（集中下发、动态刷新） - 日志 → 日志聚合（收集到统一存储） - 鉴权 → 每个服务都要验 token - 故障 → 一个挂了，可能雪崩倒一片 这些\u0026#34;重建\u0026#34;，就是分布式税。 二、第一笔税：网络根本不可靠 这是分布式系统一切痛苦的根源。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 进程内调用 vs 网络调用： 进程内调用： ✓ 几乎不会失败（除非进程崩） ✓ 纳秒~微秒级 ✓ 没有序列化开销 网络调用： ✗ 会超时（对方慢、网络抖动） ✗ 会丢包、会重试、会乱序 ✗ 毫秒级起步，还要序列化 ✗ \u0026#34;超时\u0026#34;最可怕——不知道对方到底执行了没有 八大谬误（Peter Deutsch）： 1. 网络是可靠的 ❌ 2. 延迟为零 ❌ 3. 带宽是无限的 ❌ 4. 网络是安全的 ❌ 5. 拓扑不会变化 ❌ 6. 只有一个管理员 ❌ 7. 传输成本为零 ❌ 8. 网络是同构的 ❌ 代价：每一次跨服务调用，都要处理重试、超时、幂等、降级。单体里一个 try-catch 搞定的，微服务里是一整套弹性策略。\n网络为什么不可靠、CAP 为什么成立——我在《分布式系统学习笔记（一）：CAP 与一致性》里讲过底子。这里只说工程后果。\n三、第二笔税：服务发现与配置 单体里，所有代码在一个进程，互相调函数即可。微服务里，\u0026ldquo;找谁\u0026quot;和\u0026quot;用什么配置\u0026quot;成了问题。\n1 2 3 4 5 6 7 8 9 10 11 单体：函数地址 = 编译期固定 微服务：服务实例的 IP/端口是动态的（容器漂移、弹性扩缩） 必须自建的能力： - 服务注册与发现（谁在线、在哪） 常见：Consul / Nacos / etcd / K8s Service / Eureka - 配置中心（配置不再散落各服务） 常见：Nacos / Apollo / Consul KV / K8s ConfigMap - 动态刷新（改配置不重启） 这些都是单体里\u0026#34;不存在\u0026#34;的基础设施。 四、第三笔税：数据一致性 这是最重的一笔税，下一篇会专门展开，这里先列账：\n1 2 3 4 5 6 7 8 9 10 11 单体：一个事务保证 ACID 微服务：每个服务一个库，跨库没有 ACID 你必须自建： - 分布式事务方案（Saga、TCC、Outbox 模式） - 最终一致性补偿机制 - 幂等性保证（重试导致的重复执行） - 对账机制（数据不一致时的人工/自动核对） 详见我写的《分布式系统学习笔记（四）：分布式事务》。 本系列第五篇会讲它怎么在数据架构上落地。 五、第四笔税：可观测性 单体里，一个请求的处理在一个进程内，看一个日志文件就懂了。微服务里，一个请求要经过 5 个服务，出问题你怎么定位？\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 必须自建\u0026#34;微服务的神经系统\u0026#34;： - 链路追踪（Trace） 给每个请求一个 traceId，跨服务透传 常见：OpenTelemetry / Jaeger / Zipkin / SkyWalking - 指标监控（Metrics） 每个服务的 QPS、延迟、错误率 常见：Prometheus + Grafana - 日志聚合（Logs） 所有服务的日志收到一处，按 traceId 串联 常见：ELK / Loki 没有这三件套，微服务一旦出问题 = 黑盒，只能玄学排查。 第七篇会专门讲可观测性。 六、第五笔税：可靠性与弹性 1 2 3 4 5 6 7 8 9 10 11 12 13 单体里：一个函数失败 → 抛异常，调用方决定怎么办 微服务里：一个服务失败 → 故障会沿调用链放大，可能雪崩 必须自建： - 超时（Timeout）—— 别傻等 - 重试（Retry）—— 配合幂等 - 熔断（Circuit Breaker）—— 别打已经挂的服务 - 限流（Rate Limit）—— 别被流量压垮 - 降级（Fallback）—— 给个兜底响应 - 舱壁隔离（Bulkhead）—— 别让一个慢调用拖垮线程池 .NET 生态：Polly（这套东西几乎成了标配） 本系列第八篇落地时会用上。 1 2 3 4 5 雪崩示意： 服务 A 依赖 B，B 依赖 C C 慢了 → B 的线程被占满 → B 也慢 → A 的线程被占满 → A 也挂 一个底层小故障，逐层放大，拖垮整条链路。 熔断 + 舱壁就是用来切断这种放大效应的。 七、第六笔税：运维与部署 1 2 3 4 5 6 7 8 9 10 11 单体运维：1 个应用 × N 台机器 微服务运维：M 个服务 × N 个实例 × 动态变化 - 需要容器化（Docker）+ 编排（K8s） 见我写的 Docker / K8s 系列 - 需要 CI/CD（每个服务独立构建、独立发布） - 需要版本兼容（新老接口并存期） - 需要 API 网关（统一入口、鉴权、限流） - 需要 DevOps 能力（SRE 文化） 团队没有这些能力，上微服务 = 上灾难。 八、账单汇总：值得吗 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 拆成微服务，你要新增的基础设施清单： □ 服务注册发现 □ 配置中心 □ API 网关 □ 分布式事务/一致性 □ 链路追踪 □ 指标监控 □ 日志聚合 □ 熔断/限流/重试（Polly） □ 容器编排（K8s） □ CI/CD 流水线 □ 幂等 + 对账 □ 版本兼容策略 收益（只有规模到了才兑现）： ✓ 独立部署、独立扩容、技术栈解耦、故障隔离、团队解耦 判断标准： 规模/团队/解耦需求 带来的收益 \u0026gt; 上面这份税单 → 拆 否则 → 老老实实模块化单体 这就是第一篇说的\u0026#34;权衡\u0026#34;。 九、小结 分布式税：单体里免费的能力，微服务里每一项都要自建 网络不可靠是根源：每次跨服务调用都要处理超时/重试/幂等/降级 服务发现 + 配置中心：解决\u0026quot;找谁\u0026quot;和\u0026quot;用什么配置\u0026rdquo; 数据一致性是最重的税：Saga/最终一致/幂等/对账（下一篇详谈） 可观测性：链路追踪 + 指标 + 日志，否则微服务是黑盒 弹性：超时/重试/熔断/限流/降级/舱壁，防止雪崩 运维：K8s + CI/CD + 网关 + 版本兼容 决策：只有当解耦收益 \u0026gt; 这份税单，才值得拆 下一篇，把最重的一笔税——分布式数据一致性——彻底讲透。\n","date":"2025-10-11T10:00:00+08:00","permalink":"/posts/architecture/microservices/04-distributed-tax/","title":"后端架构实战（四）：微服务的\"分布式税\"——你准备好买单了吗"},{"content":"写在前面 上一篇讲到，模块化单体的关键是画对边界，而真正的边界是数据边界。这一篇就把这个问题彻底说透：服务（或模块）到底该按什么切？\n这是整个架构系列里最关键的一篇。拆分边界画错了，比不拆还惨——因为错误边界上的每一次跨服务调用，都是一次跨网络的分布式事务。\n一、三种常见的拆分思路 1 2 3 4 5 6 7 8 9 10 11 思路 A：按技术层切（最常见、最错误） 用户服务 = 所有用户的 Controller/Service/Dao ── 错。这是把单体里的\u0026#34;层\u0026#34;竖着切了，制造了一堆跨层网络调用。 思路 B：按数据表切 订单服务 = 订单表相关的一切 ── 接近正确，但只看数据不看业务，容易切碎。 思路 C：按业务领域切（正确答案） 订单服务 = \u0026#34;下单\u0026#34;这个业务能力涉及的全部（代码 + 数据） ── 这就是 DDD（领域驱动设计）的限界上下文。 二、为什么不能按技术层切 1 2 3 4 5 6 7 8 9 10 11 12 13 14 按技术层切的灾难： 假设拆成：Controller 服务、Service 服务、DAO 服务 一次\u0026#34;下单\u0026#34;要跨 3 个服务调用： API → Controller 服务 → Service 服务 → DAO 服务 → DB 问题： ✗ 一次业务操作 = 3 次网络往返（慢） ✗ 任意一层挂了，整个链路挂（脆弱） ✗ 改一个业务规则要动 3 个服务（没解耦） ✗ 事务跨 3 个服务（分布式事务地狱） 本质：技术层是\u0026#34;同一个业务的不同实现细节\u0026#34;， 把实现细节切成独立服务，是把内部的刀子变成了网络上的刀子。 铁律：服务的边界必须沿着业务能力切，绝不能沿着技术分层切。\n三、按领域切：DDD 限界上下文 领域驱动设计（DDD）给出了正确的拆分单位——限界上下文（Bounded Context）。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 限界上下文 = 一个业务子领域的\u0026#34;自治范围\u0026#34; 在这个范围内： - 有自己的领域模型（术语、实体、规则） - 有自己的数据 - 对外提供清晰的业务能力接口 电商的典型限界上下文： - 订单上下文（下单、支付、退款） - 商品上下文（上架、库存、SKU） - 用户上下文（注册、登录、资料） - 配送上下文（发货、物流、签收） 每个\u0026#34;上下文\u0026#34;就是一个天然的模块/服务候选。 3.1 怎么找限界上下文 1 2 3 4 5 6 7 8 9 10 11 12 方法 1：事件风暴（Event Storming） 把业务流程里发生的\u0026#34;领域事件\u0026#34;全列出来 （订单已创建、已支付、已发货……） 按相关性聚类 → 一簇事件 ≈ 一个上下文 方法 2：语言分析法 同一个词在不同语境下含义不同，就是上下文边界 例：\u0026#34;商品\u0026#34; - 在商品上下文：是指上架的 SKU（有详情、图片） - 在订单上下文：是指下单时的快照（有当时价格） - 在配送上下文：是指要发货的物理货品（有重量、体积） → 三个\u0026#34;商品\u0026#34;，属于三个上下文，不能硬塞一个模型。 3.2 一个概念，多个模型 1 2 3 4 5 6 7 8 9 10 11 新手错误：追求\u0026#34;一个全局统一的 User/Product 模型\u0026#34; → 模型越塞越大，字段来自所有部门，谁都改不动。 DDD 的正确姿势：每个上下文只保留自己需要的视图 User 在「用户上下文」：id, 手机号, 密码, 注册时间 User 在「订单上下文」：id, 收货地址, 会员等级（折扣用） User 在「配送上下文」：id, 收货地址, 联系电话 字段不重复冗余存储？不——必要时冗余（下一篇数据一致性会讲） 关键是：模型是上下文私有的，不互相污染。 四、拆错了的代价 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 错误拆分的典型症状（出现这些，说明边界画错了）： ✗ 跨服务调用密集 一个请求要链式调 5、6 个服务 → 拆得太碎或边界错了 ✗ 共享数据库 多个服务读写同一张表 → 边界根本没切干净 ✗ 频繁的分布式事务 动不动就要保证\u0026#34;跨服务的数据一致\u0026#34; → 这些本该在一个服务内 ✗ 改一个需求要动多个服务 本来一个模块内的改动，被迫协调多个团队 ✗ 服务间循环依赖 A 调 B，B 又调 A → 边界划分反了 判断边界好坏的最简单标准：高频一起变更的东西，应该在同一个服务里。变更频率和方向的耦合度，是验证边界的试金石。\n五、拆分粒度：多大算合适 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 太大：一个服务干所有事 → 退回单体（但有了网络成本） 太小：每个 API 一个服务 → \u0026#34;纳米服务\u0026#34;，运维灾难 实用经验： 1. 能由\u0026#34;一个小团队（2~3 人 Pizza Team）独立负责\u0026#34;为一个单位 → 这就是\u0026#34;两个披萨团队\u0026#34;原则的由来 2. 一个业务能力（动词）对应一个服务，而不是一个名词 \u0026#34;订单管理\u0026#34;是好服务名；\u0026#34;订单表\u0026#34;不是 3. 先粗后细 先按大领域切 4~6 个粗粒度服务， 跑稳了，哪个真的扛不住再二次拆分。 一次拆太细几乎必然拆错。 4. 别为了\u0026#34;扩容\u0026#34;拆服务 扩容可以用多实例解决（无状态水平扩展）， 不需要靠拆服务。拆服务是为了\u0026#34;解耦\u0026#34;，不是\u0026#34;扩容\u0026#34;。 六、一个实战例子 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 电商\u0026#34;下单\u0026#34;流程，看正确边界怎么让事情变简单： 错误边界（按技术层 / 按表）： 下单 = 调「订单服务」+「库存服务」+「价格服务」+「优惠券服务」 全程分布式事务，任何一个失败都要回滚，痛苦。 正确边界（按领域聚合）： 下单逻辑收敛在「订单上下文」内部： - 查商品快照、扣库存、算价格 都在订单服务内（同库事务） - 只在\u0026#34;成功后\u0026#34;异步通知「库存」「积分」服务（最终一致） 一次本地事务搞定核心，周边用事件解耦。 区别： 核心交易 → 强一致，收敛在一个服务内 周边副作用（积分、通知、统计）→ 异步、最终一致 这就引出第五篇的核心：核心数据强一致收敛在服务内，跨服务用最终一致性。\n七、小结 三种拆法：按技术层（错）、按表（接近）、按领域（对） 铁律：服务边界沿业务能力切，绝不沿技术分层切 DDD 限界上下文：业务子领域的自治范围，是天然的拆分单位 一概念多模型：每个上下文只保留自己需要的视图，不要追求全局统一模型 边界好坏的试金石：高频一起变更的，应在同一服务；出现密集跨服务调用/共享库/循环依赖，说明拆错了 粒度：一个小团队能独立负责为单位，先粗后细，别为扩容而拆 下一篇算一笔明白账：微服务的\u0026quot;分布式税\u0026quot;——拆服务之后，你到底要为哪些隐性成本买单。\n","date":"2025-10-07T10:00:00+08:00","permalink":"/posts/architecture/microservices/03-service-decomposition/","title":"后端架构实战（三）：服务拆分的第一性原理——按领域切，不是按技术切"},{"content":"写在前面 上一篇我说\u0026quot;架构是权衡\u0026quot;，不要跳级。这一篇就来论证：对绝大多数团队，单体 是起点，甚至是很长一段时间内的终点。\n但这里的\u0026quot;单体\u0026quot;不是那个被嘲笑的\u0026quot;大泥球\u0026quot;（Big Ball of Mud）。我要替它正名的是它的升级版——模块化单体（Modular Monolith）。它可能是你被低估得最厉害的一个选项。\n一、单体到底做错了什么 先搞清楚：单体为什么名声这么差？\n1 2 3 4 5 6 7 8 9 10 11 被骂的\u0026#34;单体\u0026#34;，其实是\u0026#34;没设计的单体\u0026#34;——大泥球： - 所有代码堆在一个工程里 - 模块间任意互相调用（没有边界） - 数据库一张大表谁都能读写 - 改一处怕牵连全身 - 发布必须整体一起发 这些问题，根因不是\u0026#34;单体\u0026#34;，是\u0026#34;没有边界\u0026#34;。 把同样的代码拆成 20 个微服务，边界还是乱的， 只是把\u0026#34;一个大泥球\u0026#34;变成了\u0026#34;20 个小泥球 + 网络\u0026#34;。 关键认知：单体 ≠ 混乱。单体也可以有清晰的模块边界。 混乱的是\u0026quot;没有边界\u0026quot;，不是\u0026quot;部署在一起\u0026quot;。\n二、单体被低估的优势 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 单体（即使不模块化）天然拥有的好东西： ✓ 一个进程内调用，没有网络开销 → 性能天花板高，调试简单 ✓ 强一致性\u0026#34;免费\u0026#34; → 一个数据库事务搞定，不用 Saga、不用最终一致 ✓ 部署简单 → 一个二进制 / 一个镜像，没有编排地狱 ✓ 开发体验好 → 一个 IDE 打开全栈，重构随便改，编译器帮你查 ✓ 监控简单 → 一个进程的日志、指标，不用分布式追踪 ✓ 没有\u0026#34;分布式税\u0026#34; → 不用服务发现、配置中心、链路追踪、熔断…… 这些优势，一旦你拆成微服务，全部要花钱、花人、花时间自己补回来。第四篇我会专门算这笔账。\n三、什么时候单体真的不够用 不替单体无脑洗白。它确实有扛不住的时候：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 单体扛不住的信号（出现这些，才考虑拆）： 1. 团队规模超过 8~10 人，互相踩脚 → 多人改同一个工程，合并冲突、发布互相阻塞 2. 局部需要独立扩容 → 只有\u0026#34;秒杀\u0026#34;模块要 100 倍算力，整体扩太浪费 3. 局部需要独立的技术栈 / 发布节奏 → AI 推理要用 Python，核心交易用 .NET，没法塞一起 4. 故障爆炸半径太大 → 一个小 bug 拖垮整个进程，影响所有功能 注意：上面任何一条单独出现，都不一定非要上微服务。 先看能不能用\u0026#34;模块化单体 + 进程内隔离\u0026#34;解决。 四、模块化单体：鱼和熊掌兼得 模块化单体的核心思想：部署还是一个，但内部按模块严格切分边界。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 ┌─────────────────────────────────────────────┐ │ 单体应用（一个进程） │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ 订单模块 │ │ 用户模块 │ │ 商品模块 │ │ │ │ Order │ │ User │ │ Product │ │ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ 只通过模块的公开接口调用 │ │ │ └──────────┬──────────────┘ │ │ ┌───┴────┐ │ │ │ 共享内核 │ │ │ └────────┘ │ └─────────────────────────────────────────────┘ 规则： - 模块之间不直接 new 对方的内部类 - 只调用对方 module 暴露的 Service / 接口 - 理想情况下，每个模块有自己独立的表（Schema 隔离） 它保留了单体的全部优势（一个进程、强一致、好调试），又解决了\u0026quot;边界混乱\u0026quot;的根因。\n4.1 怎么落地边界 1 2 3 4 5 6 7 8 9 10 11 12 13 14 1. 按业务能力划分模块（不是按技术分层） 订单、用户、商品、支付…… 而不是 Controller/Service/Dao 2. 模块对外只暴露少量接口（公开 API） 内部实现细节对其他模块不可见 3. 数据归属清晰 订单表只有订单模块写，别人要数据走订单模块的接口 （这条最难，也最关键——下一节展开） 4. 工程结构上做硬隔离 - .NET：每个模块一个 Project，依赖方向单向 - 用 InternalsVisibleTo / 架构测试（如 NetArchTest）强制约束 - 谁违规了编译/测试就挂 4.2 为什么不直接拆成微服务 1 2 3 4 5 6 7 8 9 10 11 12 模块化单体 vs 微服务： 模块化单体：先在进程内把边界画对 - 边界画错了？重构成本 = 改代码（小时级） - 跑通了再决定要不要物理拆开 直接上微服务：在网络上画边界 - 边界画错了？重构成本 = 跨服务迁移数据和流量（周/月级） - 而且多了网络、一致性、运维一堆税 结论：先用模块化单体验证边界，再考虑物理拆分。 这是最稳的演进路径。 五、最容易踩的坑：共享数据库 模块化单体最大的诱惑，也是最大的坑——所有模块共用一个数据库，互相直接读写对方的表。\n1 2 3 4 5 6 7 8 9 10 11 12 错误做法： 订单模块为了\u0026#34;方便\u0026#34;，直接 JOIN 用户表、商品表 → 表结构耦合，用户表一改，订单模块崩 → 边界形同虚设，又退回大泥球 正确做法： - 每个模块\u0026#34;拥有\u0026#34;自己的表（schema 或表前缀划分） - 别的模块要数据，调模块的接口，不直接读表 - 共享库可以，但读写权限按模块隔离 这一步做不好，模块化就是假的。 数据边界，才是真正的边界。 这正是下一篇的主题：服务/模块到底该按什么切。答案是按\u0026quot;数据和领域\u0026quot;切，不是按技术层切。\n六、真实世界的成功案例 别以为单体是\u0026quot;落后\u0026quot;的代名词：\n1 2 3 4 5 6 7 8 9 10 11 - Stack Overflow：几个单体应用撑住全球 Top 50 的流量 几百台服务器，没用微服务，靠的就是模块化 + 极致优化 - Shopify：早期巨型 Rails 单体，逐步模块化 到很大规模才开始\u0026#34;模块化单体 → 组件化\u0026#34;演进 - Basecamp（37signals）：长期单体 + 模块化 明确反对过早微服务化 共同点：先榨干单体的价值，确认边界，再按需拆。 没有一上来就微服务的。 七、小结 被骂的不是单体，是\u0026quot;没有边界的大泥球\u0026quot; 单体的天然优势：进程内调用、强一致免费、部署简单、好调试——拆了都要花钱补 拆分信号：团队踩脚、局部扩容、技术栈差异、爆炸半径——出现且模块化解决不了，才拆 模块化单体：部署还是一个，内部按业务模块严格切边界，鱼和熊掌兼得 边界关键在数据：每个模块拥有自己的表，禁止跨模块直接读写 演进路径：先用模块化单体验证边界，再考虑物理拆分——不要跳级 下一篇讲拆分的第一性原理：模块/服务到底按什么切——领域、数据，还是技术？\n","date":"2025-10-03T10:00:00+08:00","permalink":"/posts/architecture/microservices/02-modular-monolith/","title":"后端架构实战（二）：为单体正名——模块化单体才是被低估的最佳实践"},{"content":"写在前面 这是「后端架构实战」系列的第一篇。我写了不少 .NET、分布式、中间件、数据库的笔记，讲的都是\u0026quot;零件\u0026quot;。这个系列想聊的是另一件事：怎么把零件搭成一辆车——也就是软件架构。\n但开篇我不想聊微服务、不聊 DDD、不聊任何具体风格。我想先回答一个更根本的问题：到底什么是\u0026quot;好架构\u0026quot;？\n答案只有两个字：权衡。理解了这一点，后面七篇才有意义。\n一、架构到底是什么 很多人对架构的印象是\u0026quot;画框图\u0026quot;——几张分层图、几个箭头、一堆方框。那是架构的产出，不是架构本身。\n1 2 3 4 5 6 7 8 9 架构的本质：在约束下，做\u0026#34;职责如何分配\u0026#34;的决策 - 哪些东西放在一起，哪些东西拆开 - 哪些决策现在做，哪些决策推迟 - 哪些东西自研，哪些东西用别人的 - 哪些质量属性优先，哪些先牺牲 画图只是把这些决策记录下来。 没有决策依据的图，就是 PPT。 一个判断标准：如果你说不出\u0026quot;我为什么这么选、放弃了什么\u0026quot;，那你就没在做架构，只是在抄。\n二、第一性原理：一切都是权衡 Fred Brooks 有句名言：\u0026quot;没有银弹\u0026quot;（No Silver Bullet）——没有任何一种技术或方法能让软件工程的本质难题一夜消失。架构同理：\n1 2 3 4 5 6 7 8 不存在\u0026#34;最好的架构\u0026#34;，只存在\u0026#34;在当前约束下最合适的架构\u0026#34; 同一个系统，在不同团队、不同阶段、不同规模下， \u0026#34;好架构\u0026#34;的答案完全不同。 10 人的电商团队和 1000 人的电商团队， \u0026#34;正确\u0026#34;的架构天差地别—— 不是谁对谁错，是约束变了。 所以资深架构师最常说的话不是\u0026quot;应该用 X\u0026quot;，而是 \u0026ldquo;It depends\u0026rdquo;（看情况）。这不是圆滑，是因为架构决策永远依赖上下文。\n三、约束的四象限 \u0026ldquo;It depends\u0026rdquo; 到底 depend 什么？四个维度：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 ┌─────────────────────────────────────────────────────────┐ │ 1. 规模（Scale） │ │ 用户量、数据量、QPS、团队人数 │ │ → 决定要不要拆、要不要分布式 │ │ │ │ 2. 时间（Time） │ │ 上市压力、迭代节奏、技术债务容忍度 │ │ → 决定先糙快还是先打好地基 │ │ │ │ 3. 团队（Team） │ │ 人数、技能栈、分布式经验、运维能力 │ │ → 决定能 hold 住多复杂的架构 │ │ │ │ 4. 成本（Cost） │ │ 服务器、人力、第三方服务、维护开销 │ │ → 决定自研还是采购、能上多少中间件 │ └─────────────────────────────────────────────────────────┘ 四个维度互相牵制。规模涨了 → 团队要扩 → 成本要加 → 时间可能更紧。 架构决策 = 在这四个约束的\u0026#34;可行域\u0026#34;里找最优解。 关键认知：约束是会变的。今天的最优解，半年后可能就是技术债。所以架构不是一次定死，而是要能演进（第七节会展开）。\n四、几个经典的权衡 4.1 性能 vs 可维护性 1 2 3 4 5 6 7 8 9 10 追求极致性能 → 紧凑的代码、手工优化、绕过抽象 ✓ 跑得快 ✗ 晦涩难懂、改一处崩三处、新人接手难 追求可维护性 → 清晰的抽象、分层、命名 ✓ 好改、好懂、好测 ✗ 多一层调用、多一点开销 绝大多数业务系统：优先可维护性（性能够用就行） 少数热路径（交易撮合、广告竞价）：性能压倒一切 4.2 一致性 vs 可用性 这就是 CAP。分布式系统的所有痛苦都源于此：\n1 2 3 4 5 要强一致 → 协调多数节点、锁、阻塞 → 牺牲可用性和性能 要高可用 → 允许暂时不一致 → 牺牲一致性，要处理冲突 详见我写的《分布式系统学习笔记（一）：CAP 与一致性》。 本系列第五篇会讲它在数据层怎么落地。 4.3 简单 vs 灵活（最容易踩的坑） 1 2 3 4 5 6 7 8 9 过度设计（Over-engineering）： 为\u0026#34;将来可能要用\u0026#34;的需求，提前搭了一堆抽象层 → 现在用不上，将来真要用时发现猜错了 → 维护成本白白增加 YAGNI 原则：You Aren\u0026#39;t Gonna Need It。 不要为想象中的需求买单。 但也要留\u0026#34;可演进\u0026#34;的接缝——这不是矛盾，区别在于： 抽象 ≠ 灵活；接缝 ≠ 提前实现。 4.4 自研 vs 采购 1 2 3 4 5 6 7 8 9 10 自研：完全可控、贴合业务、无授权费 ✗ 要投入人力、要自己维护、要踩坑 采购/开源：成熟、快、社区支持 ✗ 黑盒、受制于人、可能有授权/合规问题 经验法则： 核心竞争力、差异化能力 → 自研 通用能力（消息队列、缓存、监控） → 用成熟方案 不要重新发明轮子，除非轮子是你的核心业务。 五、两大反模式 5.1 不设计（Cargo Cult 抄作业） 1 2 3 4 5 6 \u0026#34;大厂都用微服务，我们也上微服务\u0026#34; \u0026#34;别人用 K8s，我们也要 K8s\u0026#34; → 完全不看自己的规模、团队、问题 这是把\u0026#34;别人的解\u0026#34;套到\u0026#34;自己的题\u0026#34;上。 约束不同，照抄就是灾难。 5.2 过度设计（Resume Driven Development） 1 2 3 4 5 \u0026#34;我想在简历上写 K8s + Service Mesh + 事件驱动 + CQRS\u0026#34; → 一个日活 100 的内部系统，上了全套云原生 简历好看了，系统难维护了，公司买单了。 技术选型的依据应是\u0026#34;问题\u0026#34;，不是\u0026#34;我想学/想用\u0026#34;。 这两种病的共同点：决策没有回到约束和问题上。\n六、架构是演进的，不是一次定死 1 2 3 4 5 6 7 8 9 10 11 12 架构的演进路径（也是本系列的主线）： 单体 ──→ 模块化单体 ──→ 按领域拆分 ──→ 微服务 ──→ 云原生 每一步升级，都是在\u0026#34;旧架构扛不住新约束\u0026#34;时才发生： 单体扛不住团队规模 → 模块化 模块化扛不住并发 → 拆服务 服务多了扛不住运维 → 云原生 关键： 不要跳级。跳级 = 把未来的税提前交，还可能白交。 能用单体解决的问题，上微服务就是自找麻烦。 这也是为什么第二篇我要先为单体正名——大多数人低估了单体，高估了微服务。\n七、怎么做一个架构决策 给一个可落地的决策框架：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 1. 把问题讲清楚 不是\u0026#34;要不要上微服务\u0026#34;，而是 \u0026#34;当前的痛点是什么？是发布耦合、扩容困难，还是团队协作？\u0026#34; 2. 列出所有候选方案 至少 3 个。哪怕第 3 个是\u0026#34;什么都不做\u0026#34;。 只有一个方案不叫决策，叫拍脑袋。 3. 对照四象限约束评估每个方案 规模 / 时间 / 团队 / 成本 列出每个方案的收益和代价（尤其是代价）。 4. 选定，并记录为什么放弃其他方案 这一步最重要。用 ADR（Architecture Decision Record）记下来： - 背景、决策、理由、后果 半年后你会感谢自己。 5. 留好回滚 / 演进的接缝 决策可能是错的，约束会变。 确保架构能\u0026#34;进\u0026#34;也能\u0026#34;退\u0026#34;。 八、小结 架构 = 在约束下分配职责的决策，画图只是产出 没有银弹：不存在最好的架构，只有当前约束下最合适的 约束四象限：规模、时间、团队、成本——架构决策永远依赖上下文 四大经典权衡：性能↔可维护性、一致性↔可用性、简单↔灵活、自研↔采购 两大反模式：不设计（照抄）、过度设计（简历驱动） 架构是演进的：单体 → 模块化 → 拆服务 → 云原生，不要跳级 决策要记录：用 ADR 写清\u0026quot;为什么这么选、放弃了什么\u0026quot; 下一篇，我先替\u0026quot;单体\u0026quot;说句公道话——模块化单体才是大多数团队被低估的最佳实践。\n","date":"2025-09-29T10:00:00+08:00","permalink":"/posts/architecture/microservices/01-tradeoffs/","title":"后端架构实战（一）：好架构的本质——没有银弹，只有权衡"},{"content":"写在前面 本文是分布式系列收官篇，讲数据复制（多副本如何同步）和 Gossip 协议（最终一致的传播机制），顺带讲分布式 ID 和限流。这些是 Cassandra、Redis Cluster、Consul、比特币网络背后的技术。\n一、为什么要复制 1 2 3 4 5 6 7 8 9 10 11 12 数据复制 = 在多个节点存副本 目的： ✓ 高可用（一个挂了，其他还能服务） ✓ 读扩展（多副本分担读压力） ✓ 就近访问（副本部署在不同地域，降低延迟） ✓ 容灾（异地备份） 代价： ✗ 一致性问题（副本间可能不一致） ✗ 写入开销（要同步到多个副本） ✗ 冲突（多副本同时改） 二、三种复制架构 2.1 主从复制（Single-Leader） 1 2 3 4 5 6 7 8 9 10 11 12 一个主（Leader）负责写，多个从（Follower）复制 客户端 → Leader（写）→ 复制 → Follower1, Follower2 客户端 ← Follower（读） 特点： ✓ 写入无冲突（只有 Leader 写） ✓ 简单 ✗ Leader 是写瓶颈和单点 ✗ 主从延迟（异步复制时从节点数据滞后） 代表：MySQL 主从、Redis 主从、PostgreSQL 流复制、Kafka 分区 2.2 多主复制（Multi-Leader） 1 2 3 4 5 6 7 8 9 10 11 12 13 多个 Leader 都能写，互相复制 Leader1 ←→ Leader2 ↓ ↓ 副本 副本 特点： ✓ 写可用性高（一个 Leader 挂了，另一个还能写） ✓ 适合多数据中心 ✗ 写冲突（两个 Leader 同时改同一数据） ✗ 冲突解决复杂 代表：CouchDB、多数据中心部署的 MySQL（双主） 2.3 无主复制（Leaderless） 1 2 3 4 5 6 7 8 9 没有 Leader，客户端写多个节点，读多个节点比对 客户端 → 写 N 个节点（W 个成功即算成功） 客户端 ← 读 N 个节点（R 个返回一致即最新） W + R \u0026gt; N 保证强一致（Quorum） 典型：W=R=多数 代表：Cassandra、DynamoDB、Riak（Dynamo 风格） 三、同步 vs 异步复制 1 2 3 4 5 6 7 8 9 10 11 12 13 同步复制： Leader 写后，等所有 Follower 确认才返回客户端 ✓ 强一致 ✗ 慢、一个 Follower 慢全拖慢 异步复制： Leader 写后立即返回，Follower 异步追赶 ✓ 快 ✗ 数据可能滞后、Leader 挂了可能丢未同步数据 半同步复制： 至少一个 Follower 同步确认，其他异步 折中（MySQL 半同步复制） Redis 主从、MySQL 默认都是异步（性能优先） 四、复制日志格式 1 2 3 4 5 6 7 8 基于语句：复制 SQL 语句（now()、rand() 等有问题） 基于行（Row）：复制数据行的变更（最常用，准确） 混合模式：语句 + 行 逻辑日志：复制逻辑变更（跨数据库兼容） MySQL binlog 支持 STATEMENT/ROW/MIXED Kafka 用消息本身做日志 Redis 用命令/字节流 五、冲突解决 多主/无主复制下，并发写同一数据会产生冲突：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 1. 最后写入获胜（LWW） 用时间戳，新的覆盖旧的 ✗ 时钟漂移导致丢数据 ✗ 简单但会丢更新（适合可容忍的场景） 2. 向量时钟（Vector Clock） 记录每个写操作的逻辑时钟 能检测并发冲突，交给应用解决 ✓ 不依赖物理时钟 ✗ 复杂 3. 应用层解决 冲突版本都返回，应用合并（如购物车合并） 代表：Riak、CouchDB 4. CRDT（Conflict-free Replicated Data Type） 无冲突复制数据类型，自动可合并 ✓ 自动解决冲突 ✗ 数据类型受限（计数器、集合等） 代表：Redis（部分）、Automerge、Yjs（协同编辑） 六、Gossip 协议（流言协议） 最终一致系统的数据传播机制，像病毒扩散/流言传播。\n1 2 3 4 5 6 7 8 Gossip 核心思路： 每个节点周期性随机挑几个节点，交换状态信息 经过多轮，信息扩散到全网 一轮 Gossip： Node A 随机选 B → 交换彼此知道的所有节点状态 B 又选 C 交换 → ... O(log N) 轮后，全网收敛 1 2 3 4 5 6 7 8 9 10 11 12 13 14 Gossip 的特点： ✓ 极高可扩展性（节点数再多也 O(log N) 收敛） ✓ 容错（节点挂了不影响传播） ✓ 去中心化（无主节点） ✓ 最终一致 ✗ 不是强一致（有传播延迟） ✗ 可能传播过时信息（靠版本号解决） 代表： Cassandra — 节点状态、集群拓扑用 Gossip Consul — 成员管理、故障检测用 Gossip Redis Cluster — 节点发现用 Gossip 比特币 — 区块/交易传播 HashiCorp Serf — Gossip 库 Gossip 与故障检测 1 2 3 4 5 6 Gossip 还用于故障检测（Phi Accrual Failure Detector）： 节点间互相发心跳（Gossip 带心跳） 一段时间收不到某节点心跳 → 标记 suspect → 确认 down 多个节点独立判断，避免单点误判 比单纯超时更鲁棒（自适应、分布式） 七、分布式 ID 分布式系统需要全局唯一 ID：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 方案： 1. UUID ✓ 无中心、全局唯一 ✗ 长（128位）、无序、索引差 2. 雪花算法（Snowflake） 64位 = 时间戳(41) + 机器ID(10) + 序列号(12) ✓ 趋势递增、高性能、无中心 ✗ 依赖时钟（时钟回拨问题） 3. 数据库自增（分段） 各节点分配不同 ID 段 ✓ 简单 ✗ 依赖 DB 4. Redis INCR ✓ 简单、递增 ✗ 依赖 Redis、单点 5. 号段模式（Leaf、Tinyid） 预分配 ID 段，本地消费，用完再取 ✓ 高性能 ✗ 实现稍复杂 通用推荐：雪花算法 / 号段模式 八、分布式限流 1 2 3 4 5 6 7 8 9 10 11 12 13 14 单机限流（内存）管不到多机，需要分布式限流： 1. Redis + Lua（集中式） 所有请求到 Redis 计数，Lua 保证原子 ✓ 准确 ✗ Redis 单点/性能瓶颈 2. 令牌桶（Redis 实现） 按速率生成令牌，请求消耗令牌 3. 本地限流 + 中心同步 各节点本地限流（配额的 1/N），定期同步 ✓ 性能好 ✗ 不精确 .NET 8 内置限流 + Redis 可做分布式 九、小结 复制架构：主从（单Leader）、多主（多Leader）、无主（Leaderless + Quorum） 复制方式：同步（强一致慢）、异步（快有延迟）、半同步（折中） 冲突解决：LWW（简单丢数据）、向量时钟、应用层、CRDT（自动） Gossip：流言式传播，O(log N) 收敛，最终一致，高可扩展 分布式 ID：雪花算法（趋势递增无中心）、号段模式 分布式限流：Redis + Lua 集中式、本地+中心同步 系列总结 分布式系统五篇完结：\nCAP 与一致性：CP vs AP，BASE，一致性模型 Raft 共识：Leader 选举 + 日志复制 + Quorum，现代 CP 系统标配 分布式锁：Redis（AP 快）/ ZK·etcd（CP 可靠） 分布式事务：2PC/TCC/Saga/本地消息表，优先最终一致 复制与 Gossip：主从/多主/无主，Gossip 最终一致传播 核心心法：分布式系统的本质是\u0026quot;在不可靠的网络和节点上，构建可靠的协同\u0026quot;。CAP、共识、复制、一致性，都是在回答\u0026quot;如何取舍\u0026quot;的问题。没有完美方案，只有适合场景的方案。\n","date":"2025-09-21T10:00:00+08:00","permalink":"/posts/distributed/05-replication-gossip/","title":"分布式系统学习笔记（五）：复制与 Gossip"},{"content":"写在前面 本文讲分布式事务——跨多个服务/数据库如何保证数据一致。这是分布式系统最难的问题之一。本文梳理各种方案（2PC、TCC、Saga、本地消息表、事务消息），以及它们各自的取舍。\n一、问题：本地事务不够用了 1 2 3 4 5 6 7 8 9 10 11 12 13 单库：用数据库事务（ACID） BEGIN; 扣钱; 加积分; COMMIT; -- 要么全成功，要么全回滚 微服务/分库后： 下单 = 订单服务（订单库）+ 库存服务（库存库）+ 积分服务（积分库） 三个独立数据库，本地事务管不到彼此 问题： 订单创建成功，库存扣减失败 → 数据不一致 积分服务宕机 → 订单已建但积分没加 网络超时 → 不知道对方成功没 需要分布式事务：跨库/跨服务保证最终一致 二、2PC（两阶段提交） 1 2 3 4 5 6 7 8 9 10 11 协调者（Coordinator）协调多个参与者（Participant）： 阶段1：Prepare（准备） 协调者问所有参与者：\u0026#34;能不能提交？\u0026#34; 参与者执行操作、锁资源、写日志，回复 Yes/No 阶段2：Commit / Rollback 全部 Yes → 协调者发 Commit，参与者提交 任一 No → 协调者发 Rollback，参与者回滚 XA 协议就是 2PC 1 2 3 4 5 6 7 8 优点：强一致（所有参与者要么全提交要么全回滚） 缺点： ✗ 同步阻塞：prepare 阶段所有参与者锁资源，直到 commit ✗ 协调者单点：协调者挂了，参与者卡死 ✗ 数据不一致：commit 阶段部分参与者收到、部分没收到 ✗ 性能差：两轮网络 + 锁资源 实际很少用（性能太差），银行等强一致场景偶用 3PC（三阶段提交） 1 2 3 2PC + CanCommit 阶段 + 超时机制 减少阻塞、降低不一致 但更复杂、轮次更多，实际很少用 三、TCC（Try-Confirm-Cancel） 业务层面的两阶段，每个服务实现三个方法：\n1 2 3 4 5 6 7 8 9 10 Try — 预留资源（冻结库存、冻结金额） Confirm — 确认提交（扣减冻结的） Cancel — 取消（释放冻结的） 下单 TCC： Try： 订单（创建待确认）、库存（冻结10个）、积分（预加100） 全部 Try 成功 → Confirm Confirm：订单（确认）、库存（扣减冻结）、积分（实加） 任一 Try 失败 → Cancel Cancel： 订单（取消）、库存（解冻）、积分（撤销） 1 2 3 4 5 6 7 8 9 10 11 优点： ✓ 无全局锁，性能好 ✓ 最终一致 缺点： ✗ 业务侵入大（每个服务要写 Try/Confirm/Cancel 三套逻辑） ✗ 要保证 Confirm/Cancel 幂等（可能重试） ✗ 要处理空回滚、悬挂（Try 没到却收到 Cancel） 适用：核心交易（支付、扣库存），对一致性要求高 框架：Seata-TCC、Hmily 四、Saga 把长事务拆成一串本地事务，每步有对应的补偿操作：\n1 2 3 4 5 6 7 8 9 10 11 12 下单 Saga： T1 创建订单 ── 失败补偿 C1（取消订单） T2 扣减库存 ── 失败补偿 C2（恢复库存） T3 加积分 ── 失败补偿 C3（扣积分） T4 发优惠券 ── 失败补偿 C4（收回券） 正向：T1 → T2 → T3 → T4 T3 失败 → 反向补偿：C2 → C1（已执行的逐步回滚） 两种实现： 编排式（Choreography）：服务间事件驱动，无中心 协调式（Orchestration）：有中心协调器（推荐） 1 2 3 4 5 6 7 8 9 10 11 优点： ✓ 每步是本地事务，无长锁 ✓ 适合长流程业务 缺点： ✗ 没有隔离性（中间状态可见，可能脏读） ✗ 补偿逻辑复杂（不是所有操作都能完美补偿，如发短信） ✗ 需保证补偿幂等 适用：长流程业务（旅行预订、订单履约） 框架：Seata-Saga、Camunda 五、本地消息表（最终一致，最常用） 把分布式事务降级为\u0026quot;本地事务 + 消息\u0026quot;，保证最终一致：\n1 2 3 4 5 6 7 8 9 10 11 12 思路： A 服务执行本地业务 + 写一条\u0026#34;消息\u0026#34;到本地消息表（同一个本地事务） → 本地事务保证业务和消息一起成功 后台定时扫描消息表 → 投递到 MQ / 调用 B 服务 → B 处理成功 → 删除/标记消息 → 失败重试 A（扣钱）+ 本地消息表（加积分消息） ──同事务──→ ↓ 定时扫描 MQ / 调用 B ↓ B（加积分）→ ACK → 删除消息 1 2 3 4 5 6 7 8 9 10 11 优点： ✓ 简单可靠，不用重型框架 ✓ 最终一致 ✓ 解耦（A 不直接依赖 B） 缺点： ✗ 消息表要自己维护 ✗ 定时扫描有延迟 ✗ B 必须幂等（消息可能重复投递） 适用：绝大多数最终一致场景（最接地气） 六、事务消息（RocketMQ） 类似本地消息表，但 MQ 内置支持，不用自己维护消息表：\n1 2 3 4 5 6 7 8 RocketMQ 事务消息： 1. 发送「半消息」到 MQ（消费者看不到） 2. 执行本地事务（扣钱） 3. 本地事务成功 → 提交半消息（消费者可见） 本地事务失败 → 回滚半消息 4. 如果没收到提交/回滚 → MQ 回查本地事务状态 本质：把\u0026#34;本地事务 + 发消息\u0026#34;做成原子 1 2 3 优点：不用维护消息表，MQ 托管 缺点：依赖 RocketMQ（其他 MQ 没这功能） 适用：用 RocketMQ 的项目 七、方案对比 1 2 3 4 5 6 7 方案 一致性 性能 复杂度 业务侵入 适用 ──────────────────────────────────────────────────────── 2PC/3PC 强 差 中 低 强一致（银行） TCC 强 好 高 高 核心交易 Saga 最终 好 中 中 长流程业务 本地消息表 最终 好 低 中 大多数场景 事务消息 最终 好 低 中 RocketMQ 项目 八、最佳实践：尽量避免分布式事务 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 分布式事务成本高，最优解是\u0026#34;不引入它\u0026#34;： 1. 合理的服务/库拆分 强相关的数据放一起（同库同事务） 只有真正独立的才拆服务 2. 最终一致 + 幂等 大多数场景不需要强一致，最终一致 + 重试幂等就够 3. 事件驱动解耦 服务间用事件/消息，而非同步调用 天然异步、可重试 4. 业务容忍 设计上容忍短暂不一致（如积分晚几秒到账） 能用本地事务就用本地事务 实在要分布式 → 优先最终一致（本地消息表/事务消息） 核心强一致 → TCC 九、小结 2PC：强一致但阻塞、单点、性能差，少用 TCC：Try/Confirm/Cancel 业务两阶段，性能好但侵入大 Saga：长事务拆串 + 补偿，适合长流程，无隔离 本地消息表：本地事务 + 消息 + 重试，最常用的最终一致方案 事务消息：RocketMQ 内置的原子\u0026quot;业务+发消息\u0026quot; 核心：强一致用 TCC/2PC，最终一致用消息表/事务消息 最佳实践：尽量避免分布式事务，优先最终一致 + 幂等 下一篇讲复制与 Gossip 协议。\n","date":"2025-09-17T10:00:00+08:00","permalink":"/posts/distributed/04-distributed-transaction/","title":"分布式系统学习笔记（四）：分布式事务"},{"content":"写在前面 本文讲分布式锁——多个服务/节点之间如何互斥访问共享资源。它有几种经典实现（数据库、Redis、Zookeeper、etcd），各有优劣。理解它们的差异，才能在不同场景选对方案。\n一、为什么需要分布式锁 1 2 3 4 5 6 7 8 9 10 11 单机：用 lock / Mutex / 信号量（进程内） 分布式：多个进程/机器要互斥 单机锁管不到其他机器 需要一个\u0026#34;全局可见\u0026#34;的锁 典型场景： - 防止重复操作（下单、扣库存、发券） - 限流（全局 QPS） - 选主（只一个节点执行定时任务） - 资源独占（同时只一个能改配置） 1 2 3 4 5 6 7 分布式锁的必要条件： ✓ 互斥（Mutual Exclusion）— 任意时刻只有一个客户端持有 ✓ 避免死锁 — 持有者崩溃，锁要能自动释放 ✓ 可重入（可选）— 同一持有者可重复获取 ✓ 高可用 — 锁服务不能单点 ✓ 高性能 — 加锁/解锁要快 ✓ 公平性（可选）— 按请求顺序获取 二、实现方式对比 1 2 3 4 5 6 方式 一致性 性能 可靠性 复杂度 适用 ────────────────────────────────────────────────────────── 数据库 强 中 中 低 简单场景 Redis 弱(AP) 极高 中 低 高并发、容忍偶尔失效 Zookeeper 强(CP) 中 高 中 强一致、可靠 etcd 强(CP) 高 高 中 云原生、K8s 三、数据库实现 1 2 3 4 5 6 7 8 9 10 -- 方式一：唯一索引（加锁 = 插入，解锁 = 删除） INSERT INTO locks(resource, owner, expire_at) VALUES(\u0026#39;order:123\u0026#39;, \u0026#39;uuid\u0026#39;, NOW()+10s); -- 插入成功 = 获取锁；唯一冲突 = 被占 DELETE FROM locks WHERE resource=\u0026#39;order:123\u0026#39; AND owner=\u0026#39;uuid\u0026#39;; -- 方式二：悲观锁（SELECT ... FOR UPDATE） BEGIN; SELECT * FROM account WHERE id=1 FOR UPDATE; -- 加行锁 -- 业务 COMMIT; 1 2 3 优点：简单，不用引入新组件 缺点：性能差（DB 是瓶颈）、过期清理麻烦、FOR UPDATE 占连接 适用：并发不高、已有数据库、不想引入 Redis/ZK 四、Redis 实现 1 2 3 4 5 6 7 8 # 加锁：SET key value NX EX（原子） SET lock:order:123 \u0026lt;owner_uuid\u0026gt; NX EX 10 # NX：不存在才设置（互斥） # EX 10：10 秒过期（防死锁） # 返回 OK = 获取成功；nil = 已被占 # 解锁：必须验证 owner 再删（Lua 保证原子） # 否则可能删掉别人的锁 1 2 3 4 5 6 -- 解锁脚本（原子） if redis.call(\u0026#39;get\u0026#39;, KEYS[1]) == ARGV[1] then return redis.call(\u0026#39;del\u0026#39;, KEYS[1]) else return 0 end Redis 锁的问题 1 2 3 4 5 6 7 8 9 10 11 12 13 问题1：业务执行超过锁过期时间 锁到期自动释放，别的客户端拿到锁 → 两个客户端同时执行 解决：看门狗续期（定时延长过期时间） Redisson 框架自带看门狗 问题2：Redis 主从切换丢锁 主节点加锁成功 → 还没同步到从 → 主挂了 → 从升主（没锁数据）→ 别人能加锁 解决：Redlock（向多个独立 Redis 实例加锁，多数成功才算成功） 问题3：Redlock 的争议 Martin Kleppmann 认为 Redis（AP）做锁不可靠 因为 GC 停顿、时钟漂移可能导致锁失效 Redis 作者 antirez 反驳（认为实际够用） 1 2 3 4 5 Redis 锁的定位： ✓ 高性能、高并发 ✗ 不是绝对可靠（AP 系统的特性） 适用：对偶尔失效容忍的场景（如限流、防重复提交） 不适用：绝对不能错的场景（如扣款、扣库存）→ 用 ZK/etcd 五、Zookeeper 实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ZK 锁基于「临时顺序节点」+ Watch： 1. 创建顺序临时节点 /lock/node-0001, /lock/node-0002 ... 2. 判断自己是不是序号最小的 - 是 → 获得锁 - 否 → 监听前一个节点（只监听前一个，避免羊群效应） 3. 前一个节点删除（释放锁）→ 自己被唤醒 → 成为最小 → 获得锁 4. 持有者崩溃 → 临时节点自动删除 → 后继者被唤醒 特点： ✓ 强一致（ZAB 共识） ✓ 公平锁（顺序节点） ✓ 会话失效自动释放（临时节点） ✓ 无看门狗问题（会话心跳保活） ✗ 性能不如 Redis（CP，要多数确认） 1 2 3 羊群效应（Herd Effect）： ✗ 所有等待者都监听同一个节点 → 释放时全部唤醒抢锁 → 惊群 ✓ 改进：每个等待者只监听「前一个」节点 → 顺序唤醒 六、etcd 实现 1 2 3 4 5 6 7 8 9 10 11 12 13 etcd 锁基于 Lease（租约）+ 事务（TXN）+ Revision（全局递增版本）： 1. 创建带 Lease 的 key（Lease 保活，崩溃自动过期） 2. 用事务获取所有锁 key，判断自己 Revision 是否最小 - 是 → 获得锁 - 否 → 监听前一个 key 3. 类似 ZK 的顺序等待 特点（类似 ZK）： ✓ 强一致（Raft） ✓ Lease 自动过期（防死锁） ✓ 比 ZK 更轻量、性能更好、云原生标配 K8s 的分布式锁多用 etcd（或基于 etcd 的 leader election） 1 2 3 4 5 6 // etcd 分布式锁示例（Go concurrency 包） m, err := concurrency.NewSession(client) l := concurrency.NewMutex(m, \u0026#34;/lock/order/123\u0026#34;) l.Lock(ctx) // 加锁 defer l.Unlock(ctx) // 业务 七、CP vs AP 锁的本质区别 1 2 3 4 5 6 7 8 9 10 11 12 13 Redis 锁（AP）： 返回\u0026#34;加锁成功\u0026#34;时，可能主从还没同步 极端情况下锁可能失效（但你以为成功） → 快，但不可靠 ZK / etcd 锁（CP）： 返回\u0026#34;加锁成功\u0026#34;时，数据已通过共识写入多数节点 强一致保证，不会假成功 → 可靠，但慢 选择： 需要绝对可靠（金融、库存）→ CP（ZK/etcd） 容忍偶尔失效、追求性能 → AP（Redis） 八、使用建议 1 2 3 4 5 6 7 8 9 10 11 12 13 场景 推荐 ────────────────────────────────────────────────── 高并发、容忍偶尔失效（限流） Redis 防重复提交、幂等 Redis 扣库存、扣款（绝对不能错） ZK / etcd / 数据库 选主（Leader Election） ZK / etcd 配置/资源互斥 etcd（云原生）/ ZK 通用建议： 优先用成熟框架（Redisson、curator、etcd concurrency） 别自己造轮子（边界情况极多） 锁要设过期时间 + 校验 owner 业务要考虑\u0026#34;锁失效\u0026#34;的兜底（幂等） 九、小结 必要条件：互斥、防死锁、高可用、（可选）可重入/公平 数据库锁：简单但性能差，适合低并发 Redis 锁（AP）：极高性能，但不绝对可靠；Redlock 有争议 Zookeeper 锁（CP）：顺序临时节点，强一致公平锁 etcd 锁（CP）：Lease + 事务，云原生首选 本质：CP 锁可靠慢，AP 锁快但可能假成功；按业务对可靠性的要求选 下一篇讲分布式事务——跨服务/数据库如何保证一致性。\n","date":"2025-09-13T10:00:00+08:00","permalink":"/posts/distributed/03-distributed-lock/","title":"分布式系统学习笔记（三）：分布式锁"},{"content":"写在前面 本文讲共识算法（Consensus）——多个节点如何对某个值达成一致。它是 CP 系统（etcd、Zookeeper、TiKV）实现强一致性的核心。重点讲 Raft（比 Paxos 好懂），它是现代分布式系统的共识算法标配。\n一、什么是共识问题 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 共识（Consensus）：多个节点就某个决议（值）达成一致 典型场景： - 选主：哪个节点当 Leader - 日志复制：操作的顺序（先 A 后 B，还是先 B 后 A） - 状态机复制：所有副本状态一致 难点： 节点可能宕机 网络可能丢包/延迟/分区 没有全局时钟 但必须保证： 所有正常节点达成相同决议 决议一旦达成不可更改 能容忍少数节点故障 二、Paxos（经典但难懂） 1 2 3 4 5 6 7 8 9 10 Paxos（Lamport 1998）是共识算法的鼻祖 数学上严谨、正确 但极难理解、更难实现 核心思想：多数派（Quorum）+ 两阶段（Prepare/Accept） 只要多数节点同意，决议通过 实际系统很少直接用 Paxos（太复杂） 多用 Raft（更易懂的等价算法）或 Paxos 变体 （Doris 用 Paxos 变体、Chubby 用 Paxos、Spanner 用 Paxos） 三、Raft 核心思路 Raft（2013）的设计目标就是\u0026quot;好懂\u0026quot;。它把共识拆成三个子问题：\n1 2 3 4 5 6 7 8 9 10 11 1. Leader 选举（Leader Election） 选出一个 Leader 处理所有请求 2. 日志复制（Log Replication） Leader 接收请求，复制到其他节点，多数确认后提交 3. 安全性（Safety） 保证已提交的日志不被覆盖 核心简化：把\u0026#34;任意节点都可提议\u0026#34;简化为\u0026#34;只有 Leader 提议\u0026#34; 一切由 Leader 主导，逻辑清晰 3.1 节点状态 1 2 3 4 5 6 7 8 9 10 每个节点三种状态： Follower — 跟随者，被动接收 Leader 指令 Candidate — 候选者，发起选举 Leader — 领导者，处理所有请求 状态转换： Follower ──(超时无心跳)──→ Candidate Candidate ──(获多数票)──→ Leader Candidate ──(发现更高term的Leader)──→ Follower Leader ──(发现更高term)──→ Follower 四、Leader 选举 1 2 3 4 5 6 7 8 9 10 11 12 13 1. 初始都是 Follower 2. 一段时间没收到 Leader 心跳 → 超时 3. Follower 变 Candidate，term+1，给自己投票，发 RequestVote 4. 其他节点收到： - 如果 Candidate 日志不比自己旧 → 投票 - 每个 term 只投一票（先到先得） 5. Candidate 收到多数票 → 成为 Leader 6. Leader 定期发心跳，维持权威 term（任期）是 Raft 的逻辑时钟： 单调递增 每个 term 最多一个 Leader term 小的服从 term 大的 1 2 3 避免选主冲突（脑裂）： 随机化选举超时时间（150~300ms 随机） 避免多个节点同时超时、同时竞选 五、日志复制 1 2 3 4 5 6 7 8 9 10 11 12 13 14 Leader 处理客户端请求的流程： 1. 客户端发请求给 Leader（写命令） 2. Leader 写入本地日志（未提交） 3. Leader 并发发给所有 Follower（AppendEntries） 4. Follower 写入本地日志，回复 ACK 5. Leader 收到「多数」ACK → 提交（commit） 6. Leader 回复客户端「成功」 7. Leader 在下次心跳通知 Follower 提交 8. Follower 提交，状态机应用 关键：多数（Quorum）确认才提交 3 节点集群，2 个确认即可（容忍 1 个故障） 5 节点集群，3 个确认即可（容忍 2 个故障） 1 2 3 4 5 6 7 8 9 Quorum 公式： N 个节点，多数 = ⌊N/2⌋ + 1 可容忍故障 = N - 多数 3 节点：多数 2，容错 1 5 节点：多数 3，容错 2 7 节点：多数 4，容错 3 常用奇数节点（3/5/7），性价比高 六、安全性保证 Raft 保证几个关键不变式：\n1 2 3 4 5 6 7 8 9 10 11 12 1. Leader 完整性 如果一条日志在某个 term 提交了， 那它一定存在于所有后续 term 的 Leader 日志中 2. 状态机安全 所有节点按相同顺序应用相同日志 → 状态一致 3. 选举限制 投票时，Follower 只投给「日志至少和自己一样新」的 Candidate → 保证新 Leader 不会丢已提交日志 这些不变式保证了：已提交的数据绝不丢、所有副本最终一致 七、脑裂与 Quorum 1 2 3 4 5 6 7 8 9 10 脑裂（Split Brain）：网络分区导致两个 Leader 例：5 节点分区成 3 + 2 3 那边有多数，能选主、能提交（正常工作） 2 那边没多数，选不出主 / 无法提交（拒绝服务） → Quorum（多数派）天然防止脑裂 只有拥有多数的分区才能写入，少数分区自动失效 这就是 CP 系统\u0026#34;分区时牺牲可用性\u0026#34;的体现 八、实际应用 1 2 3 4 5 6 7 8 9 10 系统 共识算法 用途 ────────────────────────────────────────────── etcd Raft K8s 配置/服务发现 Consul Raft 服务发现/配置 TiKV Raft 分布式 KV（TiDB 底层） CockroachDB Raft 分布式 SQL Nacos Raft（自研） 配置/注册中心 Doris Paxos 变体 MPP 数据库 Zookeeper ZAB（Paxos 类） 协调服务 Spanner Paxos Google 全球数据库 1 2 3 4 5 6 7 Raft 为什么流行： ✓ 好懂（相比 Paxos） ✓ 易实现 ✓ 工程友好（Leader 模型清晰） ✓ 强一致性 + 容错 现代新系统几乎都选 Raft 九、共识 vs 一致性 vs 复制 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 容易混的三个概念： 复制（Replication） 数据在多节点存副本（手段） 主从复制、多主复制、无主复制 共识（Consensus） 多节点就值/顺序达成一致（强一致复制的核心机制） Raft、Paxos 一致性（Consistency） 客户端看到的数据一致性级别（结果） 强一致、最终一致 关系：共识算法（Raft）实现强一致的复制（手段），从而提供强一致性（结果） 十、小结 共识：多节点对决议达成一致，容忍少数故障 Paxos：鼻祖但难懂；Raft：好懂的等价算法，现代标配 Raft 三子问题：Leader 选举、日志复制、安全性 核心机制：Leader 主导 + Quorum（多数确认才提交）+ term（逻辑时钟） 容错：N 节点容忍 ⌊(N-1)/2⌋ 故障，常用 3/5/7 节点 脑裂：Quorum 天然防止，少数分区无法写入 应用：etcd/Consul/TiKV/CockroachDB 等 下一篇讲分布式锁——如何用共识系统/Redis 实现互斥。\n","date":"2025-09-09T10:00:00+08:00","permalink":"/posts/distributed/02-consensus-raft/","title":"分布式系统学习笔记（二）：共识算法 Raft"},{"content":"写在前面 本文是分布式系统系列第一篇。分布式系统的所有设计都绕不开一个理论：CAP 定理。它解释了为什么没有完美的分布式系统、为什么必须在一致性和可用性间取舍。\n一、CAP 定理 1.1 三个性质 1 2 3 4 5 6 7 8 9 10 11 12 13 14 C — Consistency（一致性） 所有节点在同一时刻看到相同数据 读操作总能读到最新写入 （严格一致性 / 线性一致性） A — Availability（可用性） 每个请求都能收到非错误响应（不保证是最新数据） 系统一直可用，不拒绝服务 P — Partition tolerance（分区容忍） 网络分区（节点间断连）时系统仍能运作 消息丢失、延迟、节点宕机不影响整体 CAP 定理：分布式系统最多同时满足三个中的两个 1.2 为什么是三选二 1 2 3 4 5 6 7 8 9 10 11 12 关键认知：P（分区容忍）不是可选的！ 网络一定会分区（光纤断、交换机故障、节点宕机） 这是物理现实，无法避免 所以 P 必须保证 于是真正的取舍是：C 和 A 二选一 当分区发生时： 选 C → 拒绝服务（保证一致性，牺牲可用性） 选 A → 继续服务（可能返回旧数据，牺牲一致性） 所以实际是 CP 还是 AP 之争 二、CP vs AP 系统 2.1 CP 系统（强一致性） 1 2 3 4 5 6 7 8 9 10 11 12 13 分区时优先保证一致性，可能拒绝服务 代表： Zookeeper — 强一致（ZAB 协议），写入需要多数节点同意 etcd — 强一致（Raft），K8s 用它存关键配置 HBase — 强一致 MongoDB — 单主，强一致 关系型数据库 — 主从复制时主写从读可能不一致，但单机内强一致 特点： ✓ 数据强一致，不会读到旧值 ✗ 分区时部分请求失败（不可用） ✗ 性能/延迟通常更高（要协调多数节点） 2.2 AP 系统（高可用） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 分区时优先保证可用性，允许数据暂时不一致（最终一致） 代表： Cassandra — 可调一致性，默认高可用 DynamoDB — 高可用 CouchDB — 最终一致 Redis 集群 — 主从异步复制，故障切换可能丢少量数据（偏 AP） Eureka — 服务注册，AP 特点： ✓ 始终可用，不拒绝请求 ✓ 性能高、延迟低（不需协调多数） ✗ 可能读到旧数据（最终一致） ✗ 需要解决冲突（Vector Clock、最后写入获胜） 1 2 3 CA 系统（没有 P）： 单机数据库（MySQL 单机）—— 不分布式，无分区问题 一旦分布式，P 不可选，退化成 CP 或 AP 三、BASE 理论 CAP 的 AP 取舍的实践指导：\n1 2 3 4 5 6 7 8 9 10 11 B — Basically Available（基本可用） 故障时允许损失部分可用性（降级、响应慢），保证核心可用 S — Soft state（软状态） 允许数据存在中间状态（不一致），不要求时刻强一致 E — Eventually consistent（最终一致性） 系统保证最终数据会一致，但中间可以有窗口 BASE = 对 AP 的工程化诠释 大型互联网系统（电商、社交）多采用 BASE + 最终一致 四、一致性模型 一致性有强弱之分，不只是\u0026quot;强一致 vs 不一致\u0026quot;。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 强一致性（线性一致性 Linearizability） 所有操作有全局顺序，读总能看到最新写 最严格，实现成本最高（CP 系统） 顺序一致性（Sequential Consistency） 所有节点看到操作顺序一致，但不一定是实时顺序 比线性一致弱（不要求按实时顺序） 因果一致性（Causal Consistency） 有因果关系的操作保序，无因果的可乱序 读己之所写（Read Your Writes） 你自己写入的数据，你自己后续一定读得到 单调读（Monotonic Reads） 不会读到比之前更旧的数据 最终一致性（Eventual Consistency） 停止写入后，最终所有节点一致（BASE 的核心） 1 2 3 4 5 6 强 ←────────────────────────────────────→ 弱 线性一致 顺序一致 因果一致 读己写 最终一致 越强越易用、越难实现、性能越差 越弱越快、越可用、但需要处理不一致 实际系统按业务选合适的级别 五、实际系统怎么选 1 2 3 4 5 6 7 8 9 10 11 12 场景 推荐 ────────────────────────────────────────────────── 金融账户、库存扣减 强一致（CP）— 宁可不可用也不能错 配置中心、元数据 强一致（CP）— etcd/Zookeeper 用户资料、社交动态 最终一致（AP）— 可用优先 搜索、推荐、日志 最终一致（AP） 购物车、点赞、计数 最终一致（AP） 分布式锁、选主 强一致（CP） 核心交易 → 强一致 社交/内容 → 最终一致 两者结合 → 不同模块不同策略 六、常见误解 1 2 3 4 5 6 7 8 9 10 11 误解1：CAP 是\u0026#34;任何时候三选二\u0026#34; 正解：只在「分区发生时」才二选一，平时可以既 C 又 A 误解2：AP 就是不要一致性 正解：AP 是「最终一致」，不是不要，只是不要求时刻强一致 误解3：强一致一定比最终一致好 正解：强一致成本高、可用性低，很多场景最终一致更合适 误解4：MySQL 是 CA（没有 P） 正解：单机 MySQL 不涉及分区；主从/集群 MySQL 是分布式的，有分区问题 七、小结 CAP：一致性、可用性、分区容忍三选二；P 不可避免，实际是 CP vs AP CP（Zookeeper/etcd/HBase）：强一致，分区时可能不可用 AP（Cassandra/DynamoDB/Redis 集群）：高可用，最终一致 BASE：基本可用 + 软状态 + 最终一致，AP 的工程实践 一致性模型：从线性一致到最终一致，有多个级别，按业务选 选择：核心交易强一致，社交/内容最终一致 下一篇讲共识算法 Raft——CP 系统如何达成强一致。\n","date":"2025-09-05T10:00:00+08:00","permalink":"/posts/distributed/01-cap-consistency/","title":"分布式系统学习笔记（一）：CAP 与一致性"},{"content":"写在前面 本文讲操作系统的内存管理：虚拟内存、页表、TLB、malloc、内存池。理解这些才能明白为什么\u0026quot;内存比磁盘快万倍\u0026quot;、为什么程序要少分配、GC 为什么会影响性能。\n一、虚拟内存 1.1 为什么需要虚拟内存 1 2 3 4 5 6 7 8 9 10 没有虚拟内存（早期）： 程序直接操作物理内存 问题： ✗ 进程间互相踩内存（A 写错地址破坏 B） ✗ 内存不够用（物理内存有限） ✗ 程序地址不固定（换台机器/换次运行地址就变） 有虚拟内存： 每个进程有自己独立的、连续的虚拟地址空间 CPU 看到的是虚拟地址，由 MMU 翻译成物理地址 1 2 3 4 5 6 虚拟内存的好处： ✓ 进程隔离（每个进程独立地址空间，互不干扰） ✓ 内存\u0026#34;变多\u0026#34;（虚拟空间可大于物理内存，用磁盘做扩展） ✓ 地址固定（程序总是从固定地址加载，不用关心物理位置） ✓ 按需分配（用到才分配物理页，懒加载） ✓ 共享内存（多个进程的虚拟页映射到同一物理页） 1.2 地址翻译 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 程序用虚拟地址 → MMU（内存管理单元）翻译 → 物理地址 虚拟地址空间（64位，实际用 48 位 = 256TB） ┌────────────────┐ │ 用户空间（高地址）│ │ 栈 ↓ │ │ 共享库 │ │ 堆 ↑ │ │ 数据段 │ │ 代码段 │ └────────────────┘ ┌────────────────┐ │ 内核空间 │ └────────────────┘ 每个进程都觉得自己独占这整个空间 实际物理内存由 OS 分配页 二、分页与页表 2.1 分页机制 1 2 3 4 5 6 7 8 9 10 11 12 13 虚拟内存按固定大小切成「页」（通常 4KB） 物理内存也切成「页框」（4KB） 虚拟页 → 映射到 → 物理页框 不连续：虚拟页 0/1/2 可映射到物理页框 5/100/3 虚拟页 ┌───┬───┬───┬───┐ │ 0 │ 1 │ 2 │...│ └─┬─┴─┬─┴───┴───┘ │ │ 物理页框 ┌─▼─┬─▼─┬───┬───┐ │ 5 │100│...│ 3 │ （页表记录这个映射） └───┴───┴───┴───┘ 2.2 页表 1 2 3 4 5 6 7 8 9 页表 = 记录「虚拟页 → 物理页」映射的数据结构 每个进程一个页表 多级页表（x86-64 通常 4 级）： 虚拟地址拆成 5 段，逐级查表 为什么多级：节省页表内存 单级：256TB/4KB = 640亿项，每进程页表就几百GB，不可能 多级：只存用到的部分，按需创建 2.3 TLB（Translation Lookaside Buffer） 1 2 3 4 5 6 7 8 9 10 问题：每次地址翻译要查多级页表（多次内存访问），太慢 TLB = CPU 内的页表缓存 缓存最近用过的「虚拟页→物理页」映射 命中 TLB → 一次拿到物理地址（极快） TLB 很小（几百项），但命中率极高（因为局部性） 这就是为什么上下文切换贵： 切换进程 → 页表换了 → TLB 失效 → 短期内翻译变慢 三、缺页中断 1 2 3 4 5 6 7 8 9 10 访问一个虚拟地址时： 1. 查页表，发现该页不在物理内存（未分配/被换出到磁盘） 2. 触发「缺页中断」（Page Fault） 3. OS 接管： - 分配新的物理页（首次访问） - 或从磁盘换回数据（之前被换出） 4. 更新页表，恢复执行 缺页中断是慢的（涉及磁盘 I/O） 频繁缺页 = 抖动（thrashing），性能暴跌 四、内存分配（malloc 的真相） 4.1 用户态分配 1 2 // C 的 malloc / C++ 的 new / 语言的分配 void *p = malloc(100); // 申请 100 字节 1 2 3 4 5 6 7 8 9 malloc 的实现（如 glibc ptmalloc）： 小块（\u0026lt; 128KB）：用 brk 扩展堆 brk 指针上移，堆增长 free 后内存不归还 OS，留作复用 大块（≥ 128KB）：用 mmap 直接映射 独立区域，free 后归还 OS malloc 本身是用户态内存分配器 真正向 OS 要内存用系统调用（brk/mmap） 4.2 内存碎片 1 2 3 4 5 内部碎片：申请 100 字节，分配器给了 128 字节（对齐），浪费 28 外部碎片：频繁分配/释放后，空闲内存碎成小块，无法满足大请求 即使总空闲内存够，也可能因为碎片导致分配失败 分配器（jemalloc/tcmalloc）的一大工作就是对抗碎片 4.3 高性能内存分配器 1 2 3 4 5 6 7 glibc ptmalloc — 通用，但有锁竞争、碎片问题 jemalloc — FreeBSD/Redis/Facebook 用，抗碎片、多线程好 tcmalloc — Google，线程缓存，多线程高性能 mimalloc — 微软，现代、快 高性能程序（Redis、.NET runtime、Go runtime）都有自己的分配器 共同优化：线程本地缓存（避免锁）、按大小类（对抗碎片）、多级分配 五、内存池 1 2 3 4 5 6 7 8 9 10 11 12 13 14 为什么要有内存池： malloc/free 频繁调用有开销（系统调用、锁、碎片） 高频分配场景（如每秒处理万级请求）开销大 内存池思路： 预先一次性申请大块内存 自己管理分配/回收 避免反复 malloc/free .NET 的 GC 堆、Nginx 的 pool、对象的 ArrayPool 都是内存池思想 Nginx pool（见 Nginx 深入原理篇）： 每个请求一个 pool，请求中所有分配从 pool 切 请求结束，pool 整体释放 → 极快，无碎片，无泄漏 六、页面置换 1 2 3 4 5 6 7 8 9 物理内存不够时，OS 把不常用的页换到磁盘（swap） 换出哪页？用置换算法： LRU（最近最少使用）— 实际常近似实现 LFU（最不经常使用） Clock（时钟算法，LRU 的近似） 抖动：频繁换页，磁盘 I/O 暴涨，性能崩溃 生产环境通常关闭 swap（或设低），避免抖动 七、为什么这些影响程序性能 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 1. 分配不是免费的 每次 malloc/new → 分配器工作 → 可能让 GC 回收 → 缺页 所以\u0026#34;减少分配\u0026#34;是性能优化的核心（Span、ArrayPool、对象池） 2. 访问局部性影响 TLB/缓存命中 顺序访问数组 → 缓存命中好 随机访问链表/指针追逐 → 缓存失效，慢 所以数组/连续内存比链表快（缓存友好） 3. GC 与内存管理 GC 堆 = 内存池的一种 分配越多 → GC 越频繁 → 暂停越久 减少分配 = 减少 GC = 提升性能 4. 上下文切换贵 TLB 失效是切换开销的重要部分 所以协程（不切进程/线程，TLB 不失效）比线程快 八、小结 虚拟内存：每进程独立地址空间，隔离+按需分配+可大于物理内存 分页+页表：4KB 页，多级页表映射虚拟→物理 TLB：页表缓存，命中则快；切换进程失效，所以上下文切换贵 缺页中断：页不在内存时触发，涉及磁盘，慢 malloc：小块用 brk（堆），大块用 mmap；碎片是分配器的头号敌人 内存池：预分配+自管理，避免反复 malloc（Nginx pool、GC 堆、ArrayPool） 对性能的影响：分配有成本、缓存局部性、GC、上下文切换 核心认知：内存不是无限免费的——分配、翻译、回收都有成本。高性能编程的本质之一，就是理解这些成本并尽量减少。\n系列总结 操作系统底层五篇完结：\nI/O 模型：5 种模型，前 4 种同步、异步 I/O 才是真异步 进程线程协程：资源/调度单位，协程轻量适合高并发 Reactor/Proactor：I/O 模型如何变成网络框架（Nginx/Netty/Redis） 零拷贝：sendfile/mmap/splice 减少多余拷贝 内存管理：虚拟内存、页表、TLB、malloc、内存池 这些是 Nginx、Redis、Kafka、Kestrel 高性能的底层根基。理解了它们，再看任何中间件、框架的性能文章，都能看懂\u0026quot;为什么\u0026quot;。\n","date":"2025-08-28T10:00:00+08:00","permalink":"/posts/linux/internals/05-memory-management/","title":"操作系统学习笔记（五）：内存管理"},{"content":"写在前面 本文讲零拷贝（Zero-Copy）——把数据从磁盘发到网络，能少拷贝几次就少几次。这是 Nginx 静态文件飞快、Kafka 高吞吐的关键技术之一。\n一、传统读写的开销 场景：服务器从磁盘读一个文件，通过网络发给客户端。\n1.1 传统方式 1 2 3 4 // 传统 4 步 char buf[4096]; read(fd, buf, 4096); // 磁盘 → 用户态 write(sock, buf, 4096); // 用户态 → 网络 看似两行，实际发生了4 次数据拷贝 + 4 次上下文切换：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 内核空间 用户空间 ┌─────────┐ ┌──────────┐ ┌──────────┐ │ 磁盘 │──DMA──→│ 内核缓冲区 │──CPU拷贝─→│ 用户buf │ read() └─────────┘ └──────────┘ └──────────┘ ↑ │ （步骤1,2） CPU拷贝 ↓ ┌──────────┐ ┌──────────┐ │ Socket │←─CPU拷贝─│ 用户buf │ write() │ 缓冲区 │ └──────────┘ └─────┬────┘ DMA ┌─────▼────┐ │ 网卡 │ └──────────┘ （步骤3,4） 4 次拷贝： 1. 磁盘 → 内核缓冲区（DMA） 2. 内核缓冲区 → 用户 buf（CPU） ← 多余！用户根本没处理数据 3. 用户 buf → Socket 缓冲区（CPU） ← 多余！ 4. Socket 缓冲区 → 网卡（DMA） 4 次上下文切换：read(2次) + write(2次) 1 2 3 4 5 6 问题： 数据从磁盘到网卡，根本不需要经过用户空间 但传统 read+write 强制数据绕道用户态 白白多了 2 次 CPU 拷贝 + 2 次上下文切换 零拷贝的目标：消除这 2 次多余的 CPU 拷贝 二、mmap + write（减少一次拷贝） mmap 把内核缓冲区和用户空间映射到同一块内存，省去内核→用户的拷贝。\n1 2 3 char *p = mmap(NULL, size, PROT_READ, MAP_SHARED, fd, 0); // 映射 write(sock, p, size); // 直接从映射区写 munmap(p, size); 1 2 3 4 5 6 7 8 拷贝流程： 磁盘 →(DMA)→ 内核缓冲区（= 用户映射区，共享） 内核缓冲区 →(CPU)→ Socket 缓冲区 →(DMA)→ 网卡 省了「内核缓冲区→用户buf」这次拷贝 剩 3 次拷贝（2 DMA + 1 CPU），4 次上下文切换 适合：用户需要读数据内容（如处理后再发） 三、sendfile（真正的零拷贝） Linux 2.1 引入，专门为\u0026quot;文件→Socket\u0026quot;传输设计。数据全程不进用户空间。\n1 2 #include \u0026lt;sys/sendfile.h\u0026gt; sendfile(sock, fd, \u0026amp;offset, size); // 一步到位 1 2 3 4 5 6 7 8 拷贝流程（原始 sendfile）： 磁盘 →(DMA)→ 内核缓冲区 →(CPU)→ Socket 缓冲区 →(DMA)→ 网卡 数据不进用户态！ 2 次拷贝（2 DMA）+ 1 次 CPU 拷贝 2 次上下文切换（一次系统调用） 对比传统：4次拷贝+4次切换 → 3次拷贝+2次切换 SG-DMA 优化（Linux 2.4+，终极零拷贝） 如果网卡支持 SG-DMA（Scatter-Gather DMA），连那 1 次 CPU 拷贝都省了：\n1 2 3 4 5 6 7 拷贝流程（SG-DMA）： 磁盘 →(DMA)→ 内核缓冲区 内核只把「缓冲区地址 + 长度」描述符发给 Socket 缓冲区 网卡根据描述符直接从内核缓冲区 DMA → 网卡 全程只有 2 次 DMA 拷贝，0 次 CPU 拷贝！ 这就是真正的\u0026#34;零拷贝\u0026#34;（CPU 零拷贝） 1 2 Nginx 的 sendfile on; 就是开启这个 静态文件传输极快的根本原因 四、splice（管道零拷贝） sendfile 只能文件→Socket。splice 更通用，任意两个 fd 之间（通过管道）零拷贝。\n1 2 3 4 int pipefd[2]; pipe(pipefd); splice(fd, \u0026amp;offset, pipefd[1], NULL, size, SPLICE_F_MOVE); // 文件 → 管道 splice(pipefd[0], NULL, sock, NULL, size, SPLICE_F_MOVE); // 管道 → Socket 1 2 适合：两个非 Socket 的 fd 间传输（如文件→文件、管道中转） 本质：用内核管道缓冲区做中转，避免数据进用户态 五、对比总结 1 2 3 4 5 6 7 方式 CPU拷贝 DMA拷贝 上下文切换 数据进用户态 ──────────────────────────────────────────────────────────────── 传统 read+write 2 2 4 是 mmap + write 1 2 4 是（映射） sendfile 1 2 2 否 sendfile + SG-DMA 0 2 2 否 ← 最优 splice 0 2 2 否 六、实际应用 6.1 Nginx 1 2 3 4 5 6 http { sendfile on; # 开启 sendfile 零拷贝 tcp_nopush on; # 配合 sendfile，等数据攒够再发 } # 静态文件传输直接走 sendfile，磁盘→网卡不进用户态 6.2 Kafka 1 2 3 4 5 6 7 Kafka 高吞吐的关键之一：消费者拉消息用 sendfile 消息存在磁盘日志（顺序写，快） 消费者拉取 → 直接 sendfile 从磁盘文件发到网络 不经过 JVM 堆，无 GC 压力，无内存拷贝 这让 Kafka 能轻松百万级 TPS 6.3 Java 的零拷贝 API 1 2 3 4 5 Java NIO： FileChannel.transferTo() → 底层就是 sendfile MappedByteBuffer → 底层是 mmap Kafka、Netty 都用这些实现零拷贝 七、.NET 的零拷贝 1 2 3 4 5 6 7 8 9 10 .NET 用 Stream 的 CopyToAsync 在内部尽量优化 但不是直接对应 sendfile ASP.NET Core 的静态文件中间件： 用一些平台优化（如 Linux 下用 sendfile 语义） + Pipelines 减少分配 Span\u0026lt;T\u0026gt; / Memory\u0026lt;T\u0026gt;： 不是网络零拷贝，但提供了\u0026#34;零拷贝切片\u0026#34;内存视图 避免切片字符串/数组时的拷贝 八、小结 传统读写：4 次拷贝（2 CPU + 2 DMA）+ 4 次切换，数据多余绕道用户态 mmap：内核/用户共享内存，省 1 次 CPU 拷贝（适合需要读内容） sendfile：文件→Socket 不进用户态，省拷贝 + 切换 sendfile + SG-DMA：CPU 零拷贝，只剩 2 次 DMA（最优） splice：任意两 fd 间零拷贝（通过管道） 应用：Nginx sendfile、Kafka transferTo、Java FileChannel.transferTo 零拷贝 + I/O 多路复用 + Reactor = 高性能网络服务器三件套。下一篇讲内存管理（虚拟内存、页表、malloc）。\n","date":"2025-08-24T10:00:00+08:00","permalink":"/posts/linux/internals/04-zero-copy/","title":"操作系统学习笔记（四）：零拷贝技术"},{"content":"写在前面 上一篇讲了 I/O 模型（epoll、AIO）。但 epoll 只是底层机制，要变成一个能用的网络服务器，需要一套设计模式把\u0026quot;事件分发\u0026quot;和\u0026quot;业务处理\u0026quot;组织起来——这就是 Reactor 和 Proactor。Netty、Nginx、Redis、Kestrel 都基于这些模式。\n一、Reactor 模式 1.1 核心思想 Reactor = 基于同步 I/O 多路复用的事件驱动模式。\n1 2 3 4 5 6 7 8 9 10 11 12 13 核心组件： 1. Reactor（事件分发器） 用 epoll/select 监听所有 fd，事件就绪时分发给对应 Handler 2. Acceptor（接收器） 处理新连接（accept） 3. Handler（处理器） 处理具体连接的读写、业务 流程： Reactor 监听 → 新连接来了，Acceptor 接收 → 注册到 Reactor → 数据来了，Reactor 通知 Handler → Handler 读写+处理 1 2 3 4 关键特征（同步）： I/O 操作（read/write）还是由 Handler 同步执行 Reactor 只负责\u0026#34;通知就绪\u0026#34;，不做实际 I/O 本质是「事件来了 → 你自己去读」 1.2 三种 Reactor 实现 单 Reactor 单线程 1 2 3 4 5 6 7 8 9 10 11 12 13 一个线程，一个 Reactor 干所有事： accept、read、业务处理、write 全在一个线程 ┌─────────────────┐ │ Reactor（1线程） │ │ accept + IO + 业务 │ └─────────────────┘ 代表：Redis 6.0 之前 优点：极简，无线程同步问题 缺点：一个连接的慢操作卡死所有连接 无法利用多核 单 Reactor 多线程 1 2 3 4 5 6 7 8 9 10 11 Reactor（1线程）只负责 accept + 读写分发 业务处理交给线程池 ┌──────────┐ ┌──────────────┐ │ Reactor │────→│ 业务线程池 │ │ accept+IO │ │ 多线程处理 │ └──────────┘ └──────────────┘ 优点：业务多线程，利用多核 缺点：Reactor 单线程仍是瓶颈（所有 I/O 集中） Handler 和业务线程间要同步 主从 Reactor 多线程（最常用） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 MainReactor 专门 accept 新连接 SubReactor（多个）处理已建立连接的读写 业务可选交给线程池 ┌────────────┐ │ MainReactor │ accept └─────┬──────┘ │ 新连接分发 ┌─────▼──────┐ ┌──────────┐ ┌──────────┐ │ SubReactor │ │SubReactor │ │SubReactor │ 各自 epoll 处理 IO └────────────┘ └──────────┘ └──────────┘ 代表：Netty、Nginx（主进程accept + worker处理） 优点：充分利用多核，accept 和 IO 分离，吞吐高 这是高性能服务器的标准架构 二、Nginx 的 Reactor 架构 1 2 3 4 5 6 7 8 9 10 11 12 13 Nginx = 主从 Reactor 多进程版： Master 进程 │ accept（但不处理，转交 worker） │ Worker 进程 ×N（= CPU 核数） 每个 Worker 自己的 epoll + Reactor 独立处理连接的读写、业务 特点： 多进程（不是多线程）→ 无锁、崩溃隔离 每个 Worker 一个 Reactor（单线程事件循环） 用共享内存（不是锁）通信 三、Redis 的事件模型 1 2 3 4 5 6 7 8 Redis 6.0 之前：单 Reactor 单线程 一个线程跑事件循环 + 命令执行 为什么快：内存操作极快 + 无锁 + 无上下文切换 Redis 6.0+：单 Reactor + IO 多线程 命令执行仍单线程（保证原子、无锁） 网络 read/write 用多线程（IO 瓶颈在多核上并行） 本质：IO 用 Reactor 多线程，业务仍串行 四、Proactor 模式 Proactor = 基于异步 I/O 的事件驱动模式。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 核心区别（对比 Reactor）： Reactor（同步）： 事件就绪 → 通知 Handler → Handler 自己 read（同步拷贝） \u0026#34;数据来了，你自己读\u0026#34; Proactor（异步）： 发起异步 read → 内核读完+拷贝完 → 通知 Handler 处理 \u0026#34;数据我已经读好了，你直接用\u0026#34; Proactor 流程： 1. 发起 aio_read（异步，立即返回） 2. Handler 继续干别的 3. 内核完成（等数据+拷贝）→ 通知 4. Handler 拿到的数据已就绪，直接处理 1 2 3 关键特征（异步）： I/O 操作由内核完成，Handler 只处理就绪的数据 本质是「我帮你读好了，告诉你」 五、Reactor vs Proactor 1 2 3 4 5 6 7 8 Reactor Proactor ────────────────────────────────────────────────────── I/O 模型 同步（epoll） 异步（IOCP/io_uring） I/O 谁做 Handler 自己 内核做 通知时机 数据就绪 数据已拷贝完 实现复杂度 简单 复杂 OS 支持 Linux epoll 成熟 Windows IOCP 成熟 / Linux io_uring 新 代表 Nginx/Netty/Redis Windows IOCP、Boost.Asio 1 2 3 4 5 6 7 为什么主流还是 Reactor： Linux 的异步 I/O（AIO）长期不成熟 io_uring（2019）才让 Linux 有了高性能异步 I/O epoll + Reactor 已足够高效，生态成熟 Windows 用 Proactor（IOCP） 趋势：io_uring 普及后，Linux 上的 Proactor 会越来越多 六、实际框架对应 1 2 3 4 5 6 7 8 9 10 框架/系统 模式 底层 ────────────────────────────────────────────────────── Nginx 主从 Reactor 多进程 epoll Redis Reactor（IO 多线程） epoll Netty（Java） 主从 Reactor 多线程 epoll/IOCP libuv（Node.js） Reactor epoll/kqueue/IOCP Kestrel（.NET） Reactor epoll/IOCP + Pipelines Tomcat NIO Reactor epoll/IOCP Boost.Asio 可 Reactor/Proactor io_uring/IOCP Windows IOCP Proactor IOCP 七、.NET 的 Kestrel 怎么对应 1 2 3 4 5 6 7 8 9 10 11 12 13 14 Kestrel 是 ASP.NET Core 的服务器，Transport 层是 Reactor： Transport（传输层）— Reactor 模式 基于 epoll（Linux）/ IOCP（Windows） 用 System.IO.Pipelines 处理网络数据 接收连接、读取字节流 应用层 — async/await 看似 Proactor 风格（await 不阻塞） 但底层 Transport 是 Reactor（事件就绪通知） await 在等数据时释放线程，数据就绪后继续 所以 Kestrel = Reactor（底层）+ 异步编程模型（上层） 这也是为什么 Kestrel 跨平台性能都强 八、小结 Reactor：同步 I/O 多路复用 + 事件分发，\u0026ldquo;数据来了你自己读\u0026rdquo; 单 Reactor 单线程（Redis 6.0 前） 单 Reactor 多线程 主从 Reactor 多线程/多进程（Nginx、Netty）—— 高性能标配 Proactor：异步 I/O，\u0026ldquo;我帮你读好了\u0026rdquo; Windows IOCP、Linux io_uring 主流是 Reactor，因为 Linux epoll 成熟；io_uring 普及后 Proactor 会更多 Kestrel = Reactor 底层 + async/await 上层 下一篇讲零拷贝技术（sendfile/mmap/splice）——高性能网络 I/O 的另一块拼图。\n","date":"2025-08-20T10:00:00+08:00","permalink":"/posts/linux/internals/03-reactor-proactor/","title":"操作系统学习笔记（三）：Reactor 与 Proactor 模式"},{"content":"写在前面 本文讲清三个执行单元：进程、线程、协程。它们的本质区别是什么、开销差多少、各自适合什么场景。理解了这些，才能理解为什么 Go 用协程、Nginx 用多进程、Java 用线程池。\n一、进程（Process） 1.1 什么是进程 1 2 3 4 5 6 7 8 9 进程 = 程序的一次运行实例，是资源分配的基本单位 每个进程有： - 独立的虚拟地址空间（代码、数据、堆、栈） - 独立的页表 - 文件描述符表、信号处理、工作目录等 - PID（进程ID） 隔离性极强：A 进程崩了不影响 B 进程 1.2 创建与开销 1 2 3 4 5 6 // Linux 创建进程 pid_t pid = fork(); // 复制当前进程 if (pid == 0) { // 子进程 exec(\u0026#34;/bin/program\u0026#34;); // 执行新程序 } 1 2 3 4 5 6 7 8 fork 的开销（很大）： - 复制页表（写时复制 COW 优化，但仍有开销） - 分配新的内核栈、task_struct - 拷贝文件描述符、信号表等 - 大约 几百微秒 ~ 几毫秒 进程间通信（IPC）也贵：管道、消息队列、共享内存、Socket 因为地址空间独立，数据要跨边界拷贝 二、线程（Thread） 2.1 什么是线程 1 2 3 4 5 6 7 8 9 线程 = 进程内的执行单元，是 CPU 调度的基本单位 同一进程的多个线程： ✓ 共享地址空间（代码、堆、全局变量） ✓ 共享文件描述符 ✗ 各自有独立的栈、寄存器、PC（程序计数器） 创建比进程轻量，通信比进程快（共享内存，不用跨边界） 但共享带来并发问题：需要锁 2.2 用户线程 vs 内核线程 1 2 3 4 5 6 7 8 9 内核线程： 由操作系统内核管理、调度 能被内核看到、能多核并行 .NET/Java 的线程默认是内核线程（1:1 模型） 用户线程（绿色线程/M:N）： 由用户态运行时管理（Go、Erlang） 内核看不到，M 个用户线程映射到 N 个内核线程 创建极轻量，调度在用户态完成 2.3 线程的开销 1 2 3 4 5 6 7 8 创建线程：约 几十微秒（比进程快，但仍不算便宜） 上下文切换：约 1~10 微秒 - 保存/恢复寄存器 - 切换内核栈 - TLB/缓存失效（隐性开销最大） 线程栈：默认 1~8MB（1万个线程 = 10GB+ 内存） 所以\u0026#34;一个连接一个线程\u0026#34;撑不住 C10K 三、进程 vs 线程 1 2 3 4 5 6 7 8 9 进程 线程 ───────────────────────────────────────────────── 地址空间 独立 共享 创建开销 大（几百μs~ms） 小（几十μs） 通信 贵（IPC） 便宜（共享内存） 切换开销 大 小 并发问题 无（隔离） 有（需锁） 崩溃影响 只影响自己 整个进程崩 多核利用 能 能 1 2 3 4 5 6 7 8 9 为什么 Nginx 选多进程： Worker 进程独立，一个崩了不影响其他 无锁，简单可靠 配合事件驱动，单进程抗万级并发 为什么 Java/.NET 传统用多线程： 线程比进程轻量 共享内存通信方便 但要小心锁和线程安全 四、协程（Coroutine） 4.1 什么是协程 1 2 3 4 5 6 7 8 9 10 协程 = 用户态的轻量级线程，由程序（运行时）自己调度 特点： ✓ 完全在用户态，内核不感知 ✓ 创建开销极小（几百字节，几纳秒） ✓ 切换不进内核，只保存寄存器（纳秒级） ✓ 一个线程内可以跑成千上万个协程 ✗ 协作式调度（需主动让出，或运行时在安全点抢占） 代表：Go goroutine、Kotlin coroutine、Python async/await、Rust async 4.2 协程的优势 1 2 3 4 5 6 7 8 9 10 11 12 创建成本： 线程：栈 1MB + 内核对象 → 创建几十微秒 协程：初始栈 2~8KB（可动态增长）→ 创建几纳秒 Go 程序轻松开 10 万 goroutine，毫无压力 开 10 万线程则内存和调度都崩 切换成本： 线程切换：进内核、保存寄存器、TLB 失效 → 1~10μs 协程切换：用户态保存寄存器 → 几十纳秒 差 100~1000 倍 4.3 协程的两种实现 1 2 3 4 5 6 7 8 9 1. 有栈协程（Stackful） 每个协程有自己的栈 可在任意位置挂起（抢占友好） 代表：Go goroutine、Lua coroutine 2. 无栈协程（Stackless） 基于状态机（编译器转换） 只能在特定点挂起（await 点） 代表：C# async/await、Rust async、C++20 coroutine、Python async/await 五、调度模型 5.1 抢占式 vs 协作式 1 2 3 4 5 6 7 8 9 10 11 抢占式调度（内核线程）： 内核按时间片强制切换 一个线程卡死不影响其他 .NET/Java 线程、操作系统进程 协作式调度（协程）： 协程主动让出 CPU（await / yield） 一个协程不让出，其他协程饿死 Go 在函数调用点抢占（部分抢占）；纯 async/await 需在 await 让出 这就是为什么\u0026#34;协程里不能阻塞\u0026#34;——阻塞会卡住整个线程上的所有协程 5.2 Go 的 GMP 模型 1 2 3 4 5 6 7 8 9 Go goroutine 的调度（M:N）： G = Goroutine（用户协程） M = Machine（内核线程） P = Processor（逻辑处理器，持有可运行 G 的本地队列） N 个 G 在 M 个 M 上跑，M 个 M 绑定到 P（通常 = CPU 核数） P 的本地队列 + 全局队列 + 工作窃取（work stealing） 效果：goroutine 创建极轻，调度高效，自动负载均衡多核 5.3 .NET 线程池 1 2 3 4 5 6 7 8 9 10 11 12 .NET 用内核线程 + 线程池： 池化复用线程，避免频繁创建销毁 按需增长（IO 密集型增长快） async/await 在线程池线程上跑，等待时释放线程 对比 Go： Go 用协程（M:N），轻量 .NET 用线程 + async（线程少，靠异步等 I/O 时不占线程） 思路不同，目标都是高并发： Go：协程够轻，开很多协程 .NET：协程少，靠异步不让线程等 六、上下文切换 1 2 3 4 5 6 7 8 9 10 11 12 13 14 上下文切换 = 保存当前执行状态、恢复另一个的状态 进程切换：最贵 切换地址空间（页表）→ TLB 失效 → 内存访问变慢 线程切换：中等 同进程内，地址空间不变，TLB 不全失效 但仍进内核、保存寄存器 协程切换：最便宜 纯用户态，只保存少量寄存器 不进内核，缓存不失效 所以协程能轻松百万并发，线程万级就吃力 七、如何选择 1 2 3 4 5 6 7 8 9 10 11 12 场景 推荐 ────────────────────────────────────────────────────── 需要强隔离（如浏览器多标签） 多进程（Chrome 每标签一进程） CPU 密集并行计算 线程池 / 协程池 高并发 I/O（万级连接） 协程 / 事件驱动（epoll） 语言原生支持协程（Go） goroutine 语言原生 async（C#/JS） async/await 传统多线程代码 线程 + 锁（但要小心） 通用建议： I/O 密集 → 协程或异步（不阻塞） CPU 密集 → 多线程/多进程（多核并行） 八、小结 进程：资源分配单位，独立地址空间，隔离强但开销大 线程：CPU 调度单位，共享地址空间，轻量但要锁 协程：用户态轻量线程，创建/切换极便宜，适合高并发 调度：抢占式（线程）vs 协作式（协程，不能阻塞） Go GMP：M:N 调度 + 工作窃取；.NET：线程池 + async/await 选择：I/O 密集用协程/异步，CPU 密集用多线程多核并行 下一篇讲 Reactor / Proactor 模式——I/O 模型如何变成实际的网络框架。\n","date":"2025-08-16T10:00:00+08:00","permalink":"/posts/linux/internals/02-process-thread-coroutine/","title":"操作系统学习笔记（二）：进程、线程与协程"},{"content":"写在前面 本文是操作系统底层系列第一篇，系统讲清楚 5 种 I/O 模型：阻塞、非阻塞、多路复用、信号驱动、异步。理解这些是理解 Nginx、Redis、Netty、Kestrel 为什么快的根基。\n之前在 Nginx 系列聊过 epoll，这里从操作系统层面把整个 I/O 模型体系讲全。\n一、I/O 的本质 1.1 为什么要懂 I/O 模型 1 2 3 4 5 6 7 8 9 10 程序 99% 的时间在等 I/O（网络、磁盘） CPU 处理只是零头 I/O 慢的根本： CPU：纳秒级 内存：百纳秒级 网络/磁盘：毫秒级（比 CPU 慢 100 万倍） 所以高效程序的核心：不让 CPU 陪 I/O 等 不同 I/O 模型 = 不同的\u0026#34;等待方式\u0026#34; 1.2 用户空间与内核空间 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 操作系统分两层： 用户空间（User Space）— 应用程序运行的地方 内核空间（Kernel Space）— 操作系统内核，能直接碰硬件 应用不能直接读写硬件（网卡、磁盘），必须通过「系统调用」请求内核代劳。 一次 read() 的流程： 1. 应用调用 read()（系统调用） 2. 切换到内核态 3. 内核等数据到达网卡/磁盘 4. 内核把数据从内核缓冲区拷贝到用户缓冲区 5. 切换回用户态，应用拿到数据 两个阶段： 阶段1：等待数据就绪（数据从硬件到内核缓冲区）—— 慢，主要耗时 阶段2：数据拷贝（内核缓冲区 → 用户缓冲区）—— 快 1 2 3 所有 I/O 模型的区别，就在于「应用如何处理这两个阶段」： 阶段1（等数据）：阻塞？轮询？被通知？ 阶段2（拷贝）：谁拷贝？什么时候拷？ 二、阻塞 I/O（Blocking I/O） 最传统的模型。应用调用 read() 后，一直阻塞到数据读完。\n1 2 // 应用代码 int n = read(fd, buf, size); // 阻塞在这，直到数据读完 1 2 3 4 5 6 7 8 9 10 11 时间线： 应用 read() ──阻塞─────────────────────── 拿到数据 │ 内核 等数据就绪 ──── 拷贝数据 ─→ （阶段1） （阶段2） 阶段1、阶段2 都阻塞应用线程 问题：一个线程只能等一个 fd 1万个连接 = 1万个线程傻等 这就是 C10K 问题的根源 三、非阻塞 I/O（Non-blocking I/O） 应用调用 read()，如果数据没就绪，立即返回错误（EAGAIN），不阻塞。\n1 2 3 4 5 6 7 8 9 10 11 12 // 设置 fd 为非阻塞 fcntl(fd, F_SETFL, O_NONBLOCK); // 循环轮询 while (1) { int n = read(fd, buf, size); if (n == -1 \u0026amp;\u0026amp; errno == EAGAIN) { // 数据没就绪，干点别的，待会儿再问 continue; } // 有数据了 } 1 2 3 4 5 6 7 8 9 10 11 时间线： 应用 read()→EAGAIN read()→EAGAIN read()→数据 拿到数据 （立即返回） （立即返回） （拷贝） │ 内核 等数据就绪 ──── 拷贝 ─→ 阶段1 不阻塞（轮询），但阶段2（拷贝）仍阻塞 问题：轮询浪费 CPU（空转） 不停 read 大部分返回 EAGAIN，CPU 全耗在询问上 很少单独用，通常配合多路复用 四、I/O 多路复用（select / poll / epoll） 一个线程同时监控多个 fd，谁有数据就处理谁。这是高并发的核心。\n1 2 3 4 5 6 7 8 9 10 11 12 // epoll 用法（简化） int epfd = epoll_create1(0); // 注册关心的 fd epoll_ctl(epfd, EPOLL_CTL_ADD, fd1, \u0026amp;event); epoll_ctl(epfd, EPOLL_CTL_ADD, fd2, \u0026amp;event); // 等待（阻塞，直到有 fd 就绪） int n = epoll_wait(epfd, events, max, -1); // n = 就绪的 fd 数量，只返回有数据的 for (i = 0; i \u0026lt; n; i++) handle(events[i]); // 处理（read 时阶段2仍阻塞） 1 2 3 4 5 6 7 8 9 10 时间线： 应用 epoll_wait() ──阻塞（等任意fd就绪）──→ 返回就绪 → read（拷贝） │ 内核 监听fd1,fd2... fd2数据到 → 通知 → 拷贝 阶段1：交给内核多路复用，应用只等通知（不轮询） 阶段2：read 拷贝时仍阻塞（但很快） 1个线程管理上万 fd，谁就绪处理谁 Nginx / Redis 的核心 select / poll / epoll 对比 1 2 3 4 5 6 7 8 select poll epoll ──────────────────────────────────────────────────────────────── 连接数限制 1024（FD_SETSIZE） 无限制 无限制 每次调用 传全部 fd 传全部 fd 只 epoll_wait 内核检查 遍历全部 fd O(n) 遍历全部 O(n) 就绪回调 O(1) 返回结果 全部 fd（再遍历） 全部（再遍历） 只返回就绪的 数据结构 位图 数组 红黑树+就绪链表 适用 连接少 连接多 连接多+活跃少（最佳） （epoll 细节见 Nginx 系列第二篇，这里不重复。）\n五、信号驱动 I/O（Signal-driven） 应用告诉内核\u0026quot;fd 有数据时给我发信号\u0026quot;，然后去干别的。数据就绪时内核发 SIGIO 信号。\n1 2 3 4 5 6 7 8 9 10 11 // 开启信号驱动 fcntl(fd, F_SETFL, O_ASYNC); fcntl(fd, F_SETOWN, getpid()); // 信号发给当前进程 // 注册信号处理函数 signal(SIGIO, handler); // 应用继续干别的，数据就绪时 handler 被调用 void handler(int sig) { read(fd, buf, size); // 此时数据已就绪，拷贝很快 } 1 2 3 4 5 6 7 8 9 时间线： 应用 注册信号 → 干别的 ... 被信号打断 → read（拷贝） ↑SIGIO 内核 等数据就绪 ─── 发信号 ─→ 拷贝 阶段1：不阻塞，靠信号通知 阶段2：read 仍阻塞 用得少（信号处理复杂、队列溢出问题），UDP 场景偶有使用 六、异步 I/O（AIO，真正的异步） 前 4 种模型，阶段2（数据拷贝）都还是要应用自己 read。异步 I/O 让内核连拷贝都做完，再通知应用。\n1 2 3 4 5 6 // POSIX AIO（Linux）/ IOCP（Windows） struct aiocb cb = { .aio_fildes = fd, .aio_buf = buf, .aio_nbytes = size }; aio_read(\u0026amp;cb); // 立即返回，内核自己去等+拷贝 // 应用继续干别的 // 内核完成（等数据 + 拷贝）后，发信号/回调通知 1 2 3 4 5 6 7 8 9 10 11 12 时间线： 应用 aio_read() → 立即返回，干别的 ... → 通知（数据已在 buf） ↑ 内核 等数据就绪 ─── 拷贝到 buf ─→ 通知 阶段1、阶段2 都由内核完成，应用完全不阻塞 这是真正的异步（前面 4 种都是\u0026#34;同步 I/O\u0026#34;的不同等待方式） Linux 的 AIO： - POSIX aio：用户态模拟，性能一般 - io_uring（Linux 5.1+）：新一代高性能异步 I/O，接近 Windows IOCP Windows IOCP：成熟的高性能异步 I/O 七、五种模型对比 1 2 3 4 5 6 7 阶段1(等数据) 阶段2(拷贝) 是否真异步 ────────────────────────────────────────────────────── 阻塞 I/O 阻塞 阻塞 否 非阻塞 I/O 轮询(不阻塞) 阻塞 否 I/O 多路复用 阻塞(等通知) 阻塞 否 信号驱动 I/O 信号通知 阻塞 否 异步 I/O 内核完成 内核完成 是 ✓ 1 2 3 4 5 6 前 4 种本质都是「同步 I/O」——阶段2（拷贝）都要应用自己干 只有第 5 种是「真正的异步」——内核把活全干了 Reactor 模式（Nginx/Redis/Netty）= 用 I/O 多路复用 + 同步处理 Proactor 模式（Windows IOCP） = 用异步 I/O （详见本系列 Reactor/Proactor 篇） 八、概念澄清：同步/异步 vs 阻塞/非阻塞 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 这两个维度容易混，区分清楚： 阻塞 vs 非阻塞（调用方的行为）： 阻塞 — 调用后线程挂起，直到有结果 非阻塞 — 调用立即返回（没有结果就返回错误） 同步 vs 异步（结果如何获取）： 同步 — 调用方主动去拿结果（read 自己拷贝数据） 异步 — 被调用方做完后通知调用方（内核拷贝完通知） 组合： 同步阻塞 = read()（最传统） 同步非阻塞 = 非阻塞 read() 轮询 异步 = aio_read()（被通知） 「异步」必然涉及「通知」，调用方不等，被通知后结果已就绪 九、小结 I/O 两阶段：等数据就绪（慢）+ 数据拷贝（快） 5 种模型： 阻塞：简单，一连接一线程 非阻塞：轮询，浪费 CPU 多路复用：一线程管多 fd，高并发核心（select/poll/epoll） 信号驱动：用得少 异步 I/O：内核全包，真正异步（io_uring/IOCP） 关键认知：前 4 种都是同步 I/O（拷贝阶段应用自己干），只有异步 I/O 是真异步 概念：阻塞/非阻塞是调用行为，同步/异步是结果获取方式 下一篇讲进程、线程、协程的本质与调度。\n","date":"2025-08-12T10:00:00+08:00","permalink":"/posts/linux/internals/01-io-models/","title":"操作系统学习笔记（一）：I/O 模型全解"},{"content":"写在前面 这是 .NET 性能系列的第三篇（前两篇是 Dump 诊断、高并发编程）。前两篇讲\u0026quot;出问题怎么查\u0026quot;和\u0026quot;怎么扛住流量\u0026quot;，这篇讲怎么跑得更快——如何用工具定位性能瓶颈，以及优化手段。\n性能优化最容易犯的错是\u0026quot;凭感觉优化\u0026quot;。本文的核心是：先测量，再优化。掌握工具链比记住一堆技巧重要。\n版本说明：基于 .NET 8 LTS。NativeAOT、InterpolatedStringHandler 等特性在 .NET 8 上均适用。\n一、性能优化的黄金法则 1.1 测量优先 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 性能优化第一定律： \u0026#34;过早优化是万恶之源\u0026#34; —— Donald Knuth 正确的流程： 1. 建立基线（当前性能是多少） 2. 用工具定位瓶颈（慢在哪、为什么慢） 3. 针对瓶颈优化 4. 测量优化效果（验证真的变快了） 5. 回归测试（没破坏功能） 错误的做法： ✗ 凭直觉优化（\u0026#34;我觉得这里慢\u0026#34;） ✗ 优化不重要的代码（占总时间 1% 的地方优化到极致没用） ✗ 没有基线就改（不知道改完是快了还是慢了） ✗ 牺牲可读性换微优化（得不偿失） 1.2 帕累托法则 1 2 3 4 5 6 7 8 9 10 11 80% 的时间花在 20% 的代码上 优化策略： 找到那 20%（热点路径）→ 集中优化 其余 80% 的代码 → 保持简洁可读 热点路径通常是： - 循环体 - 高频调用的方法 - I/O 操作（数据库、HTTP、磁盘） - 序列化/反序列化 1.3 优化的层级 1 2 3 4 5 6 7 8 9 10 优化收益从大到小： 1. 架构优化 — 加缓存、异步化、读写分离、消息队列（收益 10x~100x） 2. 算法优化 — O(n²) → O(n log n)（收益 10x） 3. I/O 优化 — 减少 DB 往返、批量、连接复用（收益 5x） 4. 实现优化 - LINQ 改循环、避免分配（收益 2x） 5. 微优化 — struct、Span、内联（收益 1.2x） 从上层往下做，微优化放最后。 架构不对，微优化再多是杯水车薪。 1.4 性能问题的分类 动手前先判断\u0026quot;是哪一类问题\u0026quot;，这决定了用什么工具、查什么方向。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 按资源维度分类： CPU 密集型 特征：CPU 持续 \u0026gt; 80%，吞吐上不去 场景：复杂计算、正则滥用、低效算法 看指标：cpu-usage 高 工具：dotnet-trace（火焰图找热点函数） 内存/GC 密集型 特征：% Time in GC 高、内存持续增长 场景：过度分配、内存泄漏、LOH 碎片 看指标：time-in-gc、gc-heap-size、gen-2-gc-count 工具：dotnet-gcdump / dotnet-dump I/O 密集型 特征：CPU 不高但响应慢 场景：数据库慢查询、外部 API、磁盘读写 看指标：cpu-usage 低 + 响应慢 工具：APM 链路追踪、数据库慢日志 锁竞争 特征：多核机器但只有一个核跑满，其他在等锁 场景：全局锁、lock 持有时间过长 看指标：contention rate 高 工具：dotnet-stack（看线程在等什么） 1 2 3 4 5 6 7 8 9 按症状快速排查： 症状 可能原因 排查方向 ───────────────────────────────────────────────────────────── 启动慢 依赖加载、JIT、初始化 启动日志、模块加载 请求响应慢 DB 查询、外部 API、计算 APM 追踪、慢日志 内存持续增长 泄漏、缓存无上限 gcdump 分析、GC 日志 CPU 飙升 死循环、异常、高频计算 火焰图、线程栈 间歇性卡顿 GC、定时任务、线程池耗尽 GC 日志、线程池监控 先分清类别，再选工具——避免拿着锤子到处找钉子。\n二、性能分析工具链 .NET 有世界级的诊断工具链（dotnet-* 全家桶），覆盖从监控到采样到内存分析。\n1 2 3 4 5 6 7 工具 用途 频率 ────────────────────────────────────────────────────── dotnet-counters 实时指标监控（CPU/GC/线程池） 实时 dotnet-trace CPU 采样、事件追踪、火焰图 录制 dotnet-stack 实时线程栈快照 实时 dotnet-dump 内存/线程/锁分析（离线） 抓取 dotnet-gcdump 托管堆分析（轻量） 抓取 2.1 dotnet-counters — 实时指标 第一步永远是看指标，判断瓶颈类型（CPU？内存？GC？线程池？）。\n1 2 3 4 5 6 7 8 9 # 安装 dotnet tool install -g dotnet-counters # 实时监控（默认 System.Runtime） dotnet-counters monitor -p 12345 # 指定监控的计数器 dotnet-counters monitor -p 12345 \\ System.Runtime[cpu-usage,gc-heap-size,gen-0-gc-count,gen-1-gc-count,gen-2-gc-count,time-in-gc,threadpool-queue-length] 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 关键指标解读： 指标 含义 正常 异常/告警 ─────────────────────────────────────────────────────────────────────── cpu-usage CPU 使用率 \u0026lt; 60% \u0026gt; 80% 持续 time-in-gc GC 占 CPU 比例 \u0026lt; 10% \u0026gt; 20% alloc-rate 分配速率 稳定 持续高位 gen-0-gc-count Gen 0 GC 次数 频繁（正常） — gen-1-gc-count Gen 1 GC 次数 偶尔 频繁 gen-2-gc-count Gen 2 GC 次数 很少（几分钟一次） 频繁 gc-heap-size GC 堆大小 稳定 持续增长（泄漏） threadpool-queue-length 线程池队列 \u0026lt; 10 \u0026gt; 100 exception-count 异常总数 极少 持续增长 working-set 内存占用 稳定 持续增长 诊断思路： cpu-usage 高 + time-in-gc 低 → 真正的 CPU 计算（算法/循环） cpu-usage 不高但卡 → I/O 等待或锁竞争 time-in-gc 高（\u0026gt; 20%） → 分配过多，要减少分配 threadpool-queue-length 高 → 线程阻塞（同步 I/O / 死锁） 2.2 dotnet-trace — CPU 采样与火焰图 dotnet-counters 告诉你\u0026quot;有问题\u0026quot;，dotnet-trace 告诉你\u0026quot;问题在哪行代码\u0026quot;。\n1 2 3 4 5 6 7 8 9 # 安装 dotnet tool install -g dotnet-trace # CPU 采样，录 30 秒，输出 speedscope 格式 dotnet-trace collect -p 12345 --profile cpu-sampling --format Speedscope -d 30 # 输出：xxx.speedscope.json # 也可以用 nettrace 格式（PerfView 打开） dotnet-trace collect -p 12345 -d 30 录完生成的 .speedscope.json 文件，上传到 https://speedscope.app 即可看火焰图。\n高级：直接指定 ETW Provider --profile cpu-sampling 是预设组合。需要更细的事件（比如只看 GC 分配），可以直接指定 ETW Provider 关键字：\n1 2 3 4 5 6 7 8 9 # 只收集 GC 事件（找 GC 热点） dotnet-trace collect -p 12345 \\ --providers Microsoft-DotNETCore-SampleProfiler,Microsoft-Windows-DotNETRuntime:0x1:4 \\ -d 30 # 专门收集堆分配（0x10000，找分配热点最有用） dotnet-trace collect -p 12345 \\ --providers Microsoft-Windows-DotNETRuntime:0x10000:4 \\ -d 30 1 2 3 4 5 6 7 8 DotNETRuntime 常用关键字（掩码）： 0x1 — GC（垃圾回收事件） 0x4 — JIT（即时编译） 0x10000 — GC Heap Allocation（堆分配，找分配热点最有用） 0x20000 — GC Heap Survive and Movement（对象存活/移动） 格式：Provider:关键字:级别（4 = Informational） 排查分配问题，0x10000 最有用 2.3 dotnet-stack — 实时线程栈 不抓 dump，直接看所有线程当前在执行什么。适合排查\u0026quot;瞬时卡顿\u0026quot;。\n1 2 3 4 5 6 7 8 # 安装 dotnet tool install -g dotnet-stack # 打印所有线程栈（一次性快照） dotnet-stack report -p 12345 # 持续轮询（每秒一次，看趋势） dotnet-stack report -p 12345 --duration 00:00:30 2.4 内存分析（dotnet-dump / dotnet-gcdump） 内存泄漏和分配分析详见 《.NET Dump 诊断》 那篇，这里只列要点：\n1 2 3 4 5 6 7 8 # 轻量堆 dump，看分配和引用 dotnet-gcdump collect -p 12345 # 完整 dump，深入分析 dotnet-dump collect -p 12345 dotnet-dump analyze xxx.dmp \u0026gt; dumpheap -stat # 按类型统计 \u0026gt; gcroot \u0026lt;地址\u0026gt; # 引用链 三、火焰图：定位 CPU 热点 火焰图是性能分析最有力的工具，但很多人不会读。专门讲一下。\n3.1 什么是火焰图 1 2 3 4 5 6 7 8 火焰图把采样数据可视化： - 横轴：函数调用栈（展开成\u0026#34;火焰\u0026#34;） - 纵轴：调用深度（底层是调用者，顶层是被调用者） - 宽度：该函数占用 CPU 的比例（越宽 = 越慢 = 优化重点） 读法核心： 看最宽的\u0026#34;墙\u0026#34;——那是最耗时的调用栈 从下往上读，找到最宽的那一层，就是瓶颈函数 3.2 火焰图示例 1 2 3 4 5 6 7 8 9 10 11 12 13 ┌──────────────────────────────────────────────────────────┐ │ [Idle] │ ← 空闲 ├──────────────────────────────────────────────────────────┤ │ Run() │ │ ├────────────────────────────┬─────────────────┤ │ │ │ ProcessRequest() │ LogRequest() │ │ │ │ ├───────────────┬────────┐ │ ├──────────┐ │ │ │ │ │ ParseJson() │ Query()│ │ │ Write() │ │ │ │ │ │ ███████████ │ ██████│ │ │ ██ │ │ │ │ │ │ (很宽=慢) │ │ │ │ │ │ │ └────────┴───────────────┴────────┘─┴─┴──────────┘────┘ ↑ 宽度=CPU占比 读图：ParseJson() 那块最宽 → 它是瓶颈 → 优化它 3.3 两种视角 1 2 3 4 5 6 7 8 speedscope 提供三种视图： 1. Time Order（时间顺序）— 按采样时间排列，看某个时刻在干嘛 2. Left Heavy（左重） — 按调用栈聚合，最常用，找最宽的块 3. Sandwich（三明治） — 按函数汇总，看每个函数的总耗时和调用次数 找瓶颈：用 Left Heavy，看最宽的栈 对比函数：用 Sandwich，按 Total Time 排序 3.4 常见火焰图形态 1 2 3 4 5 6 7 8 9 10 11 宽且平的\u0026#34;平台\u0026#34; → 某个函数本身耗时（CPU 密集计算） → 优化算法或实现 窄而深的\u0026#34;塔\u0026#34; → 调用链很长，但每层不耗时 → 通常不是瓶颈，看塔顶 突然变宽的\u0026#34;瓶颈层\u0026#34; → 某一层突然占满，下面层层都宽 → 那一层就是热点 大片 [Idle] → CPU 空闲，瓶颈在 I/O 或锁（不在 CPU） → 改用 dotnet-stack 看线程在等什么 四、BenchmarkDotNet — 微基准测试 想优化具体方法、对比两种实现，不要用 Stopwatch 手写——会测不准。用 BenchmarkDotNet。\n4.1 为什么不能用 Stopwatch 1 2 3 4 5 6 7 8 9 10 11 12 13 // 错误：手写基准 var sw = Stopwatch.StartNew(); for (int i = 0; i \u0026lt; 1000; i++) MethodA(); sw.Stop(); Console.WriteLine(sw.ElapsedMilliseconds); // 问题： // ✗ 没预热（JIT 第一次执行慢） // ✗ 没考虑 GC 时机（GC 恰好发生在 MethodA 里） // ✗ 编译器可能优化掉\u0026#34;没用的\u0026#34;循环 // ✗ 没考虑 CPU 频率波动、其他进程干扰 // ✗ 样本太少，没有统计意义 4.2 基本用法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 // 安装 // dotnet add package BenchmarkDotNet using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; [MemoryDiagnoser] // 报告内存分配和 GC 次数 public class StringBenchmarks { [Benchmark(Baseline = true)] public string StringConcat() { var s = \u0026#34;\u0026#34;; for (int i = 0; i \u0026lt; 100; i++) s += i.ToString(); return s; } [Benchmark] public string StringBuilder() { var sb = new StringBuilder(); for (int i = 100; i \u0026gt; 0; i--) sb.Append(i); return sb.ToString(); } } // 入口 var summary = BenchmarkRunner.Run\u0026lt;StringBenchmarks\u0026gt;(); 4.3 读懂报告 1 2 3 4 5 6 7 8 9 10 11 12 | Method | Mean | Error | StdDev | Ratio | Allocated | |--------------- |---------:|---------:|---------:|------:|----------:| | StringConcat | 15.32 us | 0.312 us | 0.456 us | 1.00 | 6000 B | | StringBuilder | 2.14 us | 0.043 us | 0.061 us | 0.14 | 320 B | 解读： Mean 平均耗时 StdDev 标准差（越小越稳定） Ratio 相对基线的倍数（0.14 = 比 StringConcat 快 7 倍） Allocated 分配的内存（6000B vs 320B，差异巨大） 结论：StringBuilder 比 += 快 7 倍，内存少 18 倍 4.4 常用特性 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 [SimpleJob(RuntimeMoniker.Net80)] // 指定运行时 [MemoryDiagnoser] // 内存/GC 诊断 [ThreadingDiagnoser] // 线程池诊断 public class MyBenchmarks { [Params(100, 1000, 10000)] // 多种参数 public int N { get; set; } [ParamsAllValues] // 所有值 public StringComparison SC { get; set; } [Benchmark] public void Method() { /* 用 N */ } [Benchmark] [Arguments(1, \u0026#34;test\u0026#34;)] // 直接传参 public void WithArgs(int a, string b) { } } 1 2 3 4 5 6 7 8 BenchmarkDotNet 的保证： ✓ 预热（JIT 编译、CPU 升频） ✓ 多轮采样，统计置信区间 ✓ 隔离 GC 干扰 ✓ 防止编译器\u0026#34;优化掉\u0026#34;测试代码 ✓ 报告 Mean / Median / StdDev / Allocated / Gen0/1/2 这是工业级的基准测试，结果可信 五、GC 调优 GC（垃圾回收）是 .NET 性能的核心。理解 GC 才能理解\u0026quot;为什么要减少分配\u0026quot;。\n5.1 GC 基础 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 .NET 的 GC 是分代的： Gen 0 — 短生命周期对象，回收最频繁、最快 Gen 1 — 缓冲区 Gen 2 — 长生命周期对象，回收最慢（全堆扫描） LOH — 大对象堆（≥ 85000 字节），直接进 Gen 2 回收流程： Gen 0 满 → 回收 Gen 0（快） 存活的对象晋升 Gen 1 Gen 1 满 → 回收 Gen 1（连带 Gen 0） 存活的晋升 Gen 2 Gen 2 满 → 完整 GC（慢，可能 STW 几十毫秒） 性能核心：减少 Gen 2 回收 Gen 2 GC 贵，会停顿所有线程（STW） 办法：减少分配、复用对象、避免大对象频繁分配 5.2 Workstation GC vs Server GC 1 2 3 4 5 6 7 \u0026lt;!-- runtimeconfig.json / .csproj --\u0026gt; { \u0026#34;configProperties\u0026#34;: { \u0026#34;System.Runtime.GC.Server\u0026#34;: true, // Server GC \u0026#34;System.Runtime.GC.Concurrent\u0026#34;: true // 后台 GC } } 1 2 3 4 5 6 7 8 9 10 Workstation GC Server GC ────────────────────────────────────────────────────────── 堆数量 1 个 每核 1 个（并行回收） 线程 少 多（每核 GC 线程） 吞吐量 低 高 内存占用 小 大（多堆） 适用 客户端、单核 服务器、多核、高并发 ASP.NET Core 默认 Server GC（多核服务器最优） 桌面应用用 Workstation GC 1 2 3 // 代码里判断当前 GC 模式 Console.WriteLine(GCSettings.IsServerGC); // 是否 Server GC Console.WriteLine(GCSettings.LatencyMode); // 延迟模式 5.3 GC 延迟模式 1 2 3 4 5 6 7 8 9 10 11 12 // 临时降低 GC 影响（关键路径不想被打断） var oldMode = GCSettings.LatencyMode; try { GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency; // 这段代码期间，GC 尽量不做完整回收 DoLatencySensitiveWork(); } finally { GCSettings.LatencyMode = oldMode; // 恢复 } 1 2 3 4 5 GCLatencyMode： Interactive 标准（平衡吞吐和延迟） LowLatency 短时低延迟（Gen 2 回收被抑制，仅 Gen 0/1） SustainedLowLatency 持续低延迟（适合实时场景） Batch 最高吞吐（不顾延迟，适合后台任务） 5.4 减少分配（GC 调优的根本） GC 压力的根源是分配。分配越少，GC 越少。\n1 2 3 4 5 6 7 8 减少分配的手段（实战角度，详见高并发文章的 Span/Pool 章节）： ✓ Span\u0026lt;T\u0026gt; / stackalloc — 零拷贝、栈分配 ✓ ArrayPool\u0026lt;T\u0026gt;.Shared — 复用数组 ✓ ObjectPool\u0026lt;T\u0026gt; — 复用对象 ✓ 预分配集合容量 — new List\u0026lt;T\u0026gt;(128) 避免 resize ✓ struct 替代 class — 栈分配，无 GC（适用时） ✓ 避免 LINQ 在热路径 — 每次都分配迭代器 ✓ string 插值替代拼接 — C# 10+ 的 InterpolatedStringHandler 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // GC 通知：在 Gen 2 回收前收到通知（适合做平滑处理） GC.RegisterForFullGCNotification(10, 10); // 在后台线程轮询 Task.Run(() =\u0026gt; { while (true) { GCNotificationStatus s = GC.WaitForFullGCApproach(); if (s == GCNotificationStatus.Succeeded) { // Gen 2 即将发生，可做些准备（如拒绝新请求） } GC.WaitForFullGCComplete(); } }); 六、JIT 与 AOT .NET 默认是 JIT（即时编译），但也有 AOT（提前编译） 选项。\n6.1 JIT 工作原理 1 2 3 4 5 6 7 8 9 10 11 12 源码 → IL（中间语言） → JIT → 机器码 JIT 的特点： ✓ 方法首次调用时才编译（按需） ✓ 基于实际运行情况优化（分层编译、PGO） ✓ 跨平台（IL 与 CPU 无关） ✗ 首次调用有编译开销（冷启动慢） ✗ 运行时占用内存（JIT + 优化代码） .NET 8 的优化： 分层编译（Tiered Compilation）— 先快速编译，热点再重新优化 动态 PGO（Dynamic PGO） — 根据运行 profile 优化热点 6.2 ReadyToRun（R2R）— 预编译 IL 1 2 # 发布时预编译为机器码（但仍是托管，可 JIT 兜底） dotnet publish -c Release -r win-x64 -p:PublishReadyToRun=true 1 2 3 4 5 6 7 R2R 的特点： ✓ 启动更快（部分方法已编译） ✓ 仍是托管代码（可被 JIT 进一步优化） ✗ 文件更大（包含预编译代码） ✗ 绑定平台（win-x64 / linux-x64） 适合：启动速度敏感、可接受更大体积的场景 6.3 NativeAOT — 完全提前编译 1 2 3 4 5 6 # .NET 8 正式支持 dotnet new console # 在 csproj 加 \u0026lt;PublishAot\u0026gt;true\u0026lt;/PublishAot\u0026gt; dotnet publish -c Release -r win-x64 # 输出：单个原生可执行文件，无需 .NET 运行时 1 2 3 4 5 6 7 8 9 10 11 12 13 14 NativeAOT 的特点： ✓ 启动极快（无 JIT） ✓ 内存占用小（无运行时） ✓ 单文件部署，无依赖 ✓ 部署到容器/边缘设备 ✗ 不支持反射（或需显式 trim） ✗ 不支持动态加载程序集 ✗ 编译时绑定平台 ✗ 部分库不兼容（需要 AOT 友好） 适合： CLI 工具、云函数（冷启动敏感）、微服务、边缘计算 不适合： 重度反射、动态代码生成的应用 1 2 3 4 JIT vs R2R vs NativeAOT 选择： 标准部署（服务器、长期运行） → JIT（运行时优化最强） 启动敏感、要兜底 → R2R 冷启动极致、单文件、无依赖 → NativeAOT 七、常见性能反模式 实际项目中最常见的性能坑，附优化前后对比。\n7.1 字符串拼接 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // ❌ 慢：循环里 += （每次创建新字符串） string result = \u0026#34;\u0026#34;; foreach (var item in items) result += item.ToString(); // O(n²)，大量中间字符串 // ✅ 快：StringBuilder var sb = new StringBuilder(); foreach (var item in items) sb.Append(item); var result = sb.ToString(); // O(n) // ✅ 更快：string.Join（C# 内部优化） var result = string.Join(\u0026#34;,\u0026#34;, items); // ✅ 现代插值（C# 10+，零分配优化） // InterpolatedStringHandler 直接写入目标，不分配中间 string $\u0026#34;Total: {count:N0} items\u0026#34;; // 高效 7.2 LINQ 误用 1 2 3 4 5 6 7 8 9 10 11 12 13 // ❌ 热路径用 LINQ（每次分配迭代器、委托） foreach (var x in list.Where(x =\u0026gt; x.Active).Select(x =\u0026gt; x.Value)) Process(x); // ✅ 热路径用显式循环 foreach (var x in list) { if (x.Active) Process(x.Value); } // LINQ 适合可读性优先的非热路径； // 性能敏感的热路径，显式循环更快（少分配） 7.3 装箱（Boxing） 1 2 3 4 5 6 7 8 9 10 11 12 // ❌ 装箱：值类型转 object，堆分配 ArrayList list = new ArrayList(); // 老式集合，存 object list.Add(42); // int → object，装箱！分配 // ✅ 泛型集合，无装箱 List\u0026lt;int\u0026gt; list = new List\u0026lt;int\u0026gt;(); list.Add(42); // 无装箱 // 隐蔽的装箱： Console.WriteLine(\u0026#34;Count: \u0026#34; + count); // string + int → 装箱 // C# 6+ 插值优化后： Console.WriteLine($\u0026#34;Count: {count}\u0026#34;); // 无装箱（InterpolatedStringHandler） 7.4 async 误用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // ❌ async void（异常无法捕获，会崩溃进程） async void DoWork() { await Task.Delay(100); throw new Exception(); } // ✅ async Task async Task DoWorkAsync() { await Task.Delay(100); } // ❌ 异步里同步阻塞（死锁风险 + 线程池耗尽） async Task BadAsync() { var result = SomeSyncMethod().Result; // .Result 阻塞！ } // ✅ 全程异步 async Task GoodAsync() { var result = await SomeMethodAsync(); } // ❌ Task.Run 包异步方法（多此一举，浪费线程） var x = await Task.Run(async () =\u0026gt; await httpClient.GetStringAsync(url)); // ✅ 直接 await（HttpClient 本身是异步 I/O） var x = await httpClient.GetStringAsync(url); 7.5 集合未预分配容量 1 2 3 4 5 6 7 8 9 10 11 12 13 // ❌ 不指定容量：List 多次扩容（每次复制数组） var list = new List\u0026lt;int\u0026gt;(); for (int i = 0; i \u0026lt; 10000; i++) list.Add(i); // 多次 resize + 复制 // ✅ 预分配容量：一次到位 var list = new List\u0026lt;int\u0026gt;(10000); for (int i = 0; i \u0026lt; 10000; i++) list.Add(i); // 无 resize // 同理：Dictionary、HashSet、StringBuilder 都支持容量 var dict = new Dictionary\u0026lt;string, int\u0026gt;(capacity: 1000); var sb = new StringBuilder(capacity: 256); 7.6 闭包捕获 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // ❌ 闭包捕获循环变量（分配委托 + 闭包对象） foreach (var item in items) { tasks.Add(Task.Run(() =\u0026gt; Process(item))); // 每次分配闭包 } // ✅ 显式传参（无闭包分配） foreach (var item in items) { var local = item; tasks.Add(Task.Run(() =\u0026gt; Process(local))); } // 热路径避免频繁创建 lambda / 闭包 八、减少分配实战（复习） 减少分配 = 减少 GC = 提升性能。这部分在高并发文章详细讲过，这里从性能角度快速复习。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 1. ArrayPool — 复用数组（热路径必备） byte[] buffer = ArrayPool\u0026lt;byte\u0026gt;.Shared.Rent(4096); try { Process(buffer); } finally { ArrayPool\u0026lt;byte\u0026gt;.Shared.Return(buffer); } // 2. stackalloc — 栈分配（小缓冲，零 GC） Span\u0026lt;byte\u0026gt; stackBuf = stackalloc byte[128]; // 3. string.Create — 高效构造字符串 string result = string.Create(length, state, (span, s) =\u0026gt; { // 直接在目标内存写入，无中间分配 for (int i = 0; i \u0026lt; span.Length; i++) span[i] = (char)(\u0026#39;0\u0026#39; + (s.Value % 10)); }); // 4. struct 而非 class（适用时，避免堆分配） // 小型、值语义的数据用 readonly struct public readonly struct Point { public int X; public int Y; } // 5. 避免防御性拷贝：用 readonly struct / in 参数 static void Process(in BigStruct data) { } // in = 按引用只读传入 九、性能优化流程总结 把前面所有内容串成一个可执行的流程：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 1. 建立基线 dotnet-counters 看整体指标 记录当前 QPS、P99 延迟、CPU、内存 2. 定位瓶颈类型 CPU 高？→ CPU 密集（算法/分配） CPU 低但卡？→ I/O 等待 / 锁竞争 time-in-gc 高？→ 分配过多 threadpool-queue 长？→ 线程阻塞 3. 定位瓶颈位置 CPU 密集 → dotnet-trace 录火焰图 → 找最宽的栈 瞬时卡顿 → dotnet-stack 看线程栈 内存问题 → dotnet-gcdump / dotnet-dump 4. 针对优化 算法层 → 换数据结构 / 算法 I/O 层 → 缓存、批量、异步、连接池 实现层 → 减少分配、Span、避免 LINQ 热路径 用 BenchmarkDotNet 验证优化效果 5. 验证收益 重新测量，对比基线 确认真的变快了（不是测量误差） 6. 回归测试 确保功能没坏 加上 benchmark 防止性能退化 优化决策树 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 慢在哪？ ├─ CPU 高 │ ├─ 火焰图找到热点函数 │ ├─ 算法可优化？→ 换算法（收益最大） │ ├─ 分配多？→ 减少分配（Span/Pool/容量） │ └─ 计算密集？→ 并行化（Parallel/PLINQ） ├─ I/O 等待 │ ├─ 数据库？→ 缓存、批量、索引、连接池 │ ├─ HTTP？→ 并发（Task.WhenAll）、连接池、重试 │ └─ 全异步化（async/await 全链路） ├─ 内存/GC │ ├─ time-in-gc 高？→ 减少分配 │ ├─ LOH 频繁？→ 复用大对象（ArrayPool） │ └─ Server GC 开启？ └─ 锁竞争 └─ 无锁化（Channel/并发集合/Interlocked） 十、实战案例 把前面的工具和流程串起来，看两个真实场景。\n10.1 场景一：Web API 响应慢 现象：用户反馈接口响应 5 秒+，但 APM 显示数据库查询只要 100ms。\n第一步：看指标判断类型\n1 2 3 dotnet-counters monitor -p 12345 System.Runtime # cpu-usage 飙到 95%，time-in-gc 正常 # → 判定：CPU 密集型（不是 I/O，不是 GC） 第二步：录火焰图找热点\n1 2 dotnet-trace collect -p 12345 --profile cpu-sampling --format Speedscope -d 30 # 在 speedscope 打开，发现 80% CPU 花在某个 LINQ 查询 第三步：定位代码\n1 2 3 4 5 6 // 火焰图指向这段：循环里调 .Any()，触发 N+1 查询 foreach (var order in orders) { var hasDetails = _context.OrderDetails .Any(x =\u0026gt; x.OrderId == order.Id); // 每个 order 一次 DB 往返 } 第四步：修复（批量查询替代循环查询）\n1 2 3 4 5 6 7 8 var orderIds = orders.Select(x =\u0026gt; x.Id).ToList(); var hasDetailsMap = _context.OrderDetails .Where(x =\u0026gt; orderIds.Contains(x.OrderId)) .GroupBy(x =\u0026gt; x.OrderId) .ToDictionary(g =\u0026gt; g.Key, g =\u0026gt; g.Any()); // 一次查询全部 foreach (var order in orders) _ = hasDetailsMap.ContainsKey(order.Id); 第五步：Benchmark 验证\n1 2 3 修复前：4500 ms, 1200 MB allocated 修复后： 120 ms, 25 MB allocated 提速 37 倍，分配减少 48 倍 10.2 场景二：内存持续增长（泄漏） 现象：进程内存每小时涨 200MB，最终 OOM 重启。\n第一步：确认是泄漏\n1 2 3 dotnet-counters monitor -p 12345 System.Runtime # gc-heap-size 持续增长，gen-2-gc-count 越来越频繁 # → 判定：托管内存泄漏 第二步：抓 gcdump 看谁占内存\n1 2 3 dotnet-gcdump collect -p 12345 # 用 PerfView / dotMemory 打开 # 发现 50 万个 DataItem 对象，疑似泄漏 第三步：用 dump 找引用链\n1 2 3 4 5 6 dotnet-dump collect -p 12345 dotnet-dump analyze xxx.dmp \u0026gt; dumpheap -type DataItem \u0026gt; gcroot \u0026lt;DataItem 地址\u0026gt; # 输出引用链： # EventHandler[] → DataService.DataReceived → DataItem 第四步：定位根因\n1 2 3 // 事件订阅了但从没取消，DataService 是长生命周期单例 service.DataReceived += OnDataReceived; // DataConsumer 被 DataReceived 持有，永不释放 第五步：修复\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public class DataConsumer : IDisposable { private readonly DataService _service; private readonly EventHandler\u0026lt;DataEventArgs\u0026gt; _subscription; public DataConsumer(DataService service) { _service = service; _subscription = OnDataReceived; _service.DataReceived += _subscription; } public void Dispose() { _service.DataReceived -= _subscription; // 释放时取消订阅 } } 总结：内存泄漏三件套——dumpheap -stat 找大户、gcroot 找引用链、改代码断开引用。详见 《.NET Dump 诊断》。\n十一、小结 本文系统讲解了 .NET 性能优化与 profiling：\n方法论：测量优先、帕累托、优化层级（架构 \u0026gt; 算法 \u0026gt; I/O \u0026gt; 实现 \u0026gt; 微优化） 工具链：dotnet-counters（指标）、dotnet-trace（火焰图）、dotnet-stack（线程栈）、dotnet-dump/gcdump（内存） 火焰图：怎么读，看最宽的栈找瓶颈 BenchmarkDotNet：工业级微基准，不要用 Stopwatch 手写 GC 调优：分代、Server GC、延迟模式、减少分配是根本 JIT / AOT：R2R、NativeAOT 的适用场景 反模式：字符串拼接、LINQ 热路径、装箱、async 误用、容量、闭包 优化流程：基线 → 定位 → 优化 → 验证 → 回归 实战案例：API 慢（N+1 查询）、内存泄漏（事件订阅）端到端排查 性能优化的核心心法：先测量，再优化；先架构，再微调；先热点，再全局。\n至此 .NET 性能系列三篇（Dump 诊断、高并发、性能优化）完结。\n","date":"2025-08-04T10:00:00+08:00","permalink":"/posts/dotnet/performance/performance-optimization/","title":".NET 性能优化与 Profiling：定位瓶颈与榨干性能"},{"content":"写在前面 高并发是后端绕不开的话题。面试问、生产遇、架构要想。.NET 在高并发这块其实很强——async/await 模型、Channel、Span、Kestrel 都是世界级的实现，只是被 Java/Go 的声量盖住了。\n本文系统梳理 .NET 处理高并发的全景：从理论概念，到异步编程、同步原语、并发集合、并行计算、高性能内存、缓存、限流熔断，最后落到数据库和监控。读完能建立完整的 .NET 高并发知识体系。\n版本说明：本文基于 .NET 8 LTS（代码风格与 API 均适用），最低要求 .NET 7（内置限流中间件 AddRateLimiter 是 .NET 7 引入）。所有特性向上兼容 .NET 9 / .NET 10。涉及的关键 API 引入版本见各章节注释。\n一、什么是高并发 1.1 定义 高并发没有绝对标准，通常指系统在短时间内处理大量请求的能力。但\u0026quot;高\u0026quot;是相对的：\n1 2 3 4 5 6 7 8 9 10 场景 QPS（每秒请求数） 并发连接数 ───────────────────────────────────────────────── 个人博客 10 ~ 100 \u0026lt; 100 中型网站 1k ~ 5k 1k ~ 5k 电商秒杀 1万 ~ 10万 1万 ~ 5万 双十一峰值 10万 ~ 百万 十万级 搜索/广告 百万级 百万级 高并发不是单一指标，是 QPS、响应时间、并发数、 错误率、资源占用等多个维度的综合表现。 1.2 核心指标 1 2 3 4 5 6 7 8 9 QPS / TPS — 每秒请求数 / 事务数（吞吐量） 并发数 — 同时处理的请求数 响应时间（RT） — P50 / P95 / P99（看尾部延迟，不只看平均） 错误率 — 失败请求占比 资源占用 — CPU、内存、网络、磁盘 I/O 黄金法则： 吞吐量（QPS）= 并发数 / 平均响应时间 想提高 QPS：要么提高并发数，要么降低响应时间 1.3 高并发的挑战 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 1. 资源竞争 多线程访问共享资源 → 需要同步（锁）→ 锁竞争降低性能 2. 数据一致性 并发写同一数据 → 脏读、丢失更新、超卖 3. 性能瓶颈 数据库连接、网络 I/O、CPU、内存都可能成为瓶颈 4. 系统稳定性 流量突增 → 雪崩（一个服务拖垮整个链路） 需要限流、熔断、降级 5. 可观测性 高并发下问题难定位 → 需要链路追踪、指标监控 二、概念澄清：并发、并行、异步 这三个词常被混用，但本质不同。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 并发（Concurrency）— 同时应对多件事（可能交替执行） 一个厨师同时做三道菜：切菜→炒A→切肉→炖汤→炒B... 单核 CPU 也能并发（时间片轮转） 并行（Parallelism）— 同时做多件事（真正同时） 三个厨师同时做三道菜 需要多核 CPU 才能真正并行 异步（Asynchronous）— 不等待，先干别的 厨师把菜放进烤箱，趁这时间去切菜，烤箱好了再回来 本质是\u0026#34;不阻塞等待 I/O\u0026#34; 关系： 异步是实现并发的一种方式（不阻塞线程） 并行是利用多核同时计算 高并发系统通常 = 异步 I/O + 适度并行 + 缓存 + 限流 1 2 3 4 .NET 中的对应： 异步 → async/await、Task（处理 I/O 等待） 并行 → Parallel.For、PLINQ（利用多核计算） 并发 → 并发集合、Channel（多线程协作） 三、异步编程：async/await（.NET 的招牌） async/await 是 .NET 高并发的基石。它让你写出看起来同步、实际异步的代码，不阻塞线程。\n3.1 为什么异步能扛高并发 1 2 3 4 5 6 7 8 9 10 11 12 13 同步代码： 请求1：读数据库（线程等 50ms）→ 处理 → 返回 请求2：读数据库（线程等 50ms）→ 处理 → 返回 每个请求占用一个线程，线程在等 I/O 时空转 线程池默认几十个线程 → 几十个并发就饱和 异步代码： 请求1：发起读数据库（线程释放）→ I/O 完成回调 → 处理 → 返回 请求2：发起读数据库（线程释放）→ ... 等 I/O 时不占线程，少量线程就能处理上万并发 这就是 Kestrel 单机能扛数十万并发的根本原因 3.2 基本用法 1 2 3 4 5 6 7 8 9 10 11 // 同步（阻塞线程） public User GetUser(int id) { return _db.Users.Find(id); // 线程在这里等数据库 } // 异步（不阻塞线程） public async Task\u0026lt;User\u0026gt; GetUserAsync(int id) { return await _db.Users.FindAsync(id); // 等待时线程释放 } 3.3 async/await 的本质：状态机 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 你写的代码 public async Task\u0026lt;string\u0026gt; GetDataAsync() { var a = await GetDataAAsync(); var b = await GetDataBAsync(); return a + b; } // 编译器生成的（简化） // 本质是一个状态机，await 之间是状态切换 public Task\u0026lt;string\u0026gt; GetDataAsync() { var stateMachine = new StateMachine(); stateMachine.builder = AsyncTaskMethodBuilder\u0026lt;string\u0026gt;.Create(); stateMachine.MoveNext(); // 执行到第一个 await return stateMachine.builder.Task; } // MoveNext 内部根据状态决定执行哪一段 // state 0: 调 GetDataAAsync，注册回调，return // state 1: A 完成，调 GetDataBAsync，注册回调，return // state 2: B 完成，拼接结果，完成 Task 1 2 3 4 关键理解： await 不是\u0026#34;阻塞等待\u0026#34;，而是\u0026#34;注册回调后返回\u0026#34; 线程在 await 处释放，I/O 完成后用线程池线程继续执行后续代码 这就是为什么异步不占线程 3.4 Task vs ValueTask 1 2 3 4 5 6 7 8 9 10 11 12 13 // Task — 引用类型，每次都分配对象 public async Task\u0026lt;int\u0026gt; CountAsync() { if (_cached) return _value; // 即使同步返回，也分配了 Task return await ComputeAsync(); } // ValueTask — 结构体，热路径可避免分配 public ValueTask\u0026lt;int\u0026gt; CountAsync() { if (_cached) return new ValueTask\u0026lt;int\u0026gt;(_value); // 零分配！ return new ValueTask\u0026lt;int\u0026gt;(ComputeAsync()); } 1 2 3 4 5 6 7 8 选择： Task — 一般场景，API 简单，默认用它 ValueTask — 高频调用、可能同步完成的热路径 （缓存命中、内存计算） 注意： ValueTask 只能 await 一次（不能多次 await） 不确定就用 Task，性能敏感再上 ValueTask 3.5 CancellationToken（取消） 高并发系统必须有取消机制——超时、用户放弃、服务降级时及时停止。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public async Task\u0026lt;User\u0026gt; GetUserAsync(int id, CancellationToken cancellationToken) { // 传递给下游 return await _db.Users.FindAsync(id, cancellationToken); } // 调用方控制取消 using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { var user = await GetUserAsync(1, cts.Token); } catch (OperationCanceledException) { // 超时或主动取消 } // 手动取消 cts.Cancel(); 1 2 3 4 5 最佳实践： ✓ 所有公开的异步方法都接受 CancellationToken ✓ 传递给下游（数据库、HTTP、延时） ✓ 在循环里检查 token.ThrowIfCancellationRequested() ✗ 不要吞掉 CancellationToken 参数 3.6 ConfigureAwait 1 2 3 4 5 // 库代码：不要捕获同步上下文（避免死锁、提升性能） await DoSomethingAsync().ConfigureAwait(false); // ASP.NET Core：没有同步上下文，ConfigureAwait(false) 无意义 // （但写了也没坏处，库代码建议写） 1 2 3 4 5 6 7 历史背景： 老的 ASP.NET / WinForms / WPF 有 SynchronizationContext await 默认回到原上下文 → 可能死锁 ConfigureAwait(false) 避免 ASP.NET Core 没有 SynchronizationContext → 不存在这问题 但写库时养成习惯，跨平台兼容 3.7 IAsyncEnumerable（异步流） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 异步流：边产生边消费，不用一次性全部加载 public async IAsyncEnumerable\u0026lt;User\u0026gt; GetUsersAsync( [EnumeratorCancellation] CancellationToken ct = default) { await foreach (var row in _db.Users.AsAsyncEnumerable().WithCancellation(ct)) { yield return row; // 产生一个就返回一个 } } // 消费 await foreach (var user in GetUsersAsync()) { Process(user); // 流式处理，内存友好 } 四、同步原语 先解释这个词，\u0026ldquo;同步原语\u0026rdquo;（Synchronization Primitive）不太好理解，拆开看：\n同步 — 协调多个线程/进程，让它们有序访问共享资源，避免抢成一团 原语 — 最基础、不可分割的操作单元（primitive，\u0026ldquo;原始的\u0026rdquo;） 合起来：操作系统/运行时提供的、用来协调多线程访问共享资源的最底层机制。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 为什么需要\u0026#34;原语\u0026#34;： 多个线程同时执行 _count++，看似一行，实际是三步： 1. 读 _count 到寄存器 2. 寄存器 +1 3. 写回 _count 线程 A、B 同时执行，可能互相覆盖 → 丢更新 你没法自己\u0026#34;实现\u0026#34;一个原子操作，必须用系统/运行时提供的工具 这些工具就是\u0026#34;原语\u0026#34;——它们是构建复杂同步逻辑的\u0026#34;原子积木\u0026#34; 打个比方： 同步原语 = 乐高的基础积木块（lock、Semaphore、Event...） Channel、生产者-消费者 = 用积木拼出来的复杂结构 .NET 里的同步原语： lock / Monitor — 互斥锁 Semaphore / SemaphoreSlim — 信号量 Interlocked — 原子操作 ManualResetEvent 系列 — 事件等待句柄 Mutex / ReaderWriterLockSlim — 互斥锁 / 读写锁 当多线程必须访问共享资源时，就用这些原语来协调。下面逐一介绍。\n4.1 选择指南 1 2 3 4 5 6 7 8 9 10 11 12 13 14 场景 推荐 ────────────────────────────────────────────── 简单的临界区保护 lock / Monitor 异步代码里的锁 SemaphoreSlim 原子计数/标志 Interlocked 读多写少 ReaderWriterLockSlim 进程内信号量/并发限制 SemaphoreSlim 跨进程互斥 Mutex 跨进程信号量/并发限制 Semaphore 跨进程事件通知 EventWaitHandle 线程间事件通知（进程内） ManualResetEventSlim 等待N个任务完成（fork-join） CountdownEvent 生产者-消费者 Channel\u0026lt;T\u0026gt;（不用自己加锁） 高并发字典 ConcurrentDictionary 4.2 lock / Monitor 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 private readonly object _lock = new(); private int _count; public void Increment() { lock (_lock) // 等价于 Monitor.Enter / Exit { _count++; } } // 注意： // 1. lock 对象要是 readonly private，别 lock(this)、lock(typeof(X)) // 2. lock 内不要 await（lock 不支持，会编译错误） // 3. 持锁时间尽量短 4.3 SemaphoreSlim（异步友好） lock 不能 await，异步代码用 SemaphoreSlim。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private readonly SemaphoreSlim _semaphore = new(1, 1); // 初始1，最大1 = 互斥 public async Task UpdateAsync() { await _semaphore.WaitAsync(); // 异步等待锁 try { await DoWorkAsync(); // 锁内可以 await } finally { _semaphore.Release(); // 必须释放 } } // 也可以做并发数限制（不只是互斥） private readonly SemaphoreSlim _concurrencyLimit = new(100); // 最多100并发 4.4 Semaphore（跨进程信号量） SemaphoreSlim 只能进程内，需要跨进程限制资源并发访问时用 Semaphore。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 命名信号量：跨进程可见 // 限制本机最多 3 个进程同时访问某资源 using var semaphore = new Semaphore( initialCount: 3, maximumCount: 3, name: @\u0026#34;Global\\MyAppResourceLimit\u0026#34;); // 命名 → 跨进程 semaphore.WaitOne(); // 获取（计数-1），满了就等 try { AccessSharedResource(); // 临界区，跨进程生效 } finally { semaphore.Release(); // 释放（计数+1） } 1 2 3 4 5 6 7 8 9 10 11 Semaphore vs SemaphoreSlim： Semaphore SemaphoreSlim ───────────────────────────────────────────────────────── 底层实现 OS 内核对象 托管对象（用户态） 异步等待 WaitAsync ✗（只有 WaitOne） ✓ 跨进程（命名） ✓ ✗ 性能 较低（内核态切换） 高 选择： 进程内 + 异步 → SemaphoreSlim（绝大多数场景） 跨进程限制并发 → Semaphore（命名信号量） 1 2 3 4 信号量 vs Mutex： Mutex — 互斥，同一时刻只允许 1 个 Semaphore — 计数，允许 N 个（initialCount 控制） 适合\u0026#34;资源池\u0026#34;场景（限制最多 N 个并发连接/任务） 4.5 事件等待句柄（信号通知） 用于线程（或进程）之间的信号通知——一个线程通知另一个线程某事件发生了。\n1 2 3 4 5 6 EventWaitHandle 家族： EventWaitHandle — 基类，支持命名（跨进程） ManualResetEvent — 手动重置（继承 EventWaitHandle） AutoResetEvent — 自动重置（继承 EventWaitHandle） ManualResetEventSlim — 轻量版，进程内，性能更好 CountdownEvent — 等待 N 个信号（fork-join） ManualResetEvent（闸门） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 像一道闸门：打开后保持打开，所有等待者都通过，直到手动关闭 var gate = new ManualResetEvent(initialState: false); // 初始关闭 // 多个工作线程等待开工信号 for (int i = 0; i \u0026lt; 10; i++) Task.Run(() =\u0026gt; { gate.WaitOne(); // 阻塞，直到闸门打开 DoWork(); }); // 主线程打开闸门 → 所有等待线程同时放行（广播） gate.Set(); // 闸门保持打开；需要再次阻塞时手动关闭 gate.Reset(); 1 2 特点：Set 后保持 signaled，所有 WaitOne 立即返回 适合：一次性广播通知（初始化完成、开始信号） AutoResetEvent（旋转门） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // 像旋转门：Set 放行一个等待者后，自动关上 var turnstile = new AutoResetEvent(initialState: false); // 消费者 Task.Run(() =\u0026gt; { while (true) { turnstile.WaitOne(); // 等待信号 ProcessItem(); } }); // 生产者：每来一个就 Set 一次（放行一个） turnstile.Set(); 1 2 3 特点：Set 释放一个等待者后自动 Reset 适合：一次通知一个（经典生产者-消费者信号） 但现代推荐用 Channel\u0026lt;T\u0026gt;，更强大 ManualResetEventSlim（轻量版） 1 2 3 4 5 6 7 8 9 // 进程内的 ManualResetEvent，性能更好 var slim = new ManualResetEventSlim(initialState: false); slim.Wait(); // 先自旋一小段，再阻塞（短等待更快） slim.Set(); slim.Reset(); // 区别：不能跨进程，但短时间等待性能更好 // 进程内场景优先用它 CountdownEvent（fork-join） 1 2 3 4 5 6 7 8 9 10 // 等待 N 个并行任务全部完成 var countdown = new CountdownEvent(5); // 等 5 个信号 Parallel.For(0, 5, i =\u0026gt; { DoTask(i); countdown.Signal(); // 完成 1 个，计数 -1 }); countdown.Wait(); // 等待全部完成 1 2 适合：分叉-汇合（fork-join）模式 启动 N 个并行任务，等所有完成 跨进程事件通知 1 2 3 4 5 6 7 8 // 命名事件：跨进程可见（两个进程协调） var evt = new EventWaitHandle( initialState: false, mode: EventResetMode.AutoReset, name: @\u0026#34;Global\\MyAppStartSignal\u0026#34;); evt.WaitOne(); // 进程A 等待 evt.Set(); // 进程B 通知 1 2 3 4 5 现代场景的替代： 异步等待信号 → TaskCompletionSource（把信号变成可 await 的 Task） 生产者-消费者 → Channel\u0026lt;T\u0026gt; 这些等待句柄是阻塞式的，async 场景不太合适 但跨进程协调、遗留代码兼容、特定同步场景仍不可替代 4.6 Interlocked（原子操作） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private int _counter; // 原子自增（无锁，极快） Interlocked.Increment(ref _counter); Interlocked.Decrement(ref _counter); Interlocked.Add(ref _counter, 10); // 原子读取/赋值（64位在32位系统上需要） long value = Interlocked.Read(ref _largeValue); Interlocked.Exchange(ref _counter, 0); // CAS（Compare-And-Swap）— 无锁编程基础 int original; do { original = _counter; } while (Interlocked.CompareExchange(ref _counter, original + 1, original) != original); 4.7 ReaderWriterLockSlim（读多写少） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private readonly ReaderWriterLockSlim _rwLock = new(); // 多个读线程可以同时进入 public User Read(int id) { _rwLock.EnterReadLock(); try { return _cache[id]; } finally { _rwLock.ExitReadLock(); } } // 写线程独占 public void Write(int id, User user) { _rwLock.EnterWriteLock(); try { _cache[id] = user; } finally { _rwLock.ExitWriteLock(); } } 五、并发集合与 Channel 多线程协作时，优先用并发集合而不是自己加锁。\n5.1 并发集合一览 1 2 3 4 5 6 7 集合 特点 适用 ────────────────────────────────────────────────────────────── ConcurrentDictionary 线程安全字典 高并发读写、缓存 ConcurrentQueue\u0026lt;T\u0026gt; 无锁 FIFO 队列 生产者-消费者 ConcurrentStack\u0026lt;T\u0026gt; 无锁 LIFO 栈 工作窃取 ConcurrentBag\u0026lt;T\u0026gt; 无序，线程本地存储 无顺序要求的并行 BlockingCollection\u0026lt;T\u0026gt; 带阻塞的集合（封装上面） 经典生产者-消费者 5.2 ConcurrentDictionary 1 2 3 4 5 6 7 8 9 10 11 12 var cache = new ConcurrentDictionary\u0026lt;string, User\u0026gt;(); // 原子的 GetOrAdd（不存在才添加） var user = cache.GetOrAdd(\u0026#34;key\u0026#34;, k =\u0026gt; LoadFromDb(k)); // 原子的 AddOrUpdate cache.AddOrUpdate(\u0026#34;key\u0026#34;, addValue: k =\u0026gt; new User(), updateValueFactory: (k, old) =\u0026gt; UpdateUser(old)); // 注意：GetOrAdd 的工厂可能被多次调用（非原子） // 高性能场景用 TryGetValue + TryAdd 手动控制 5.3 Channel（.NET 高并发的明星） System.Threading.Channels 是 .NET 专门为高并发生产者-消费者场景设计的，性能远超 ConcurrentQueue + BlockingCollection。\n1 2 3 4 5 6 7 8 // 安装 // dotnet add package System.Threading.Channels // 创建 channel（无界） var channel = Channel.CreateUnbounded\u0026lt;Order\u0026gt;(); // 创建 channel（有界，背压控制） var bounded = Channel.CreateBounded\u0026lt;Order\u0026gt;(1000); // 最多缓冲 1000 生产者 1 2 3 4 5 6 // 写入 await channel.Writer.WriteAsync(order); // 满了会等待（有界） channel.Writer.TryWrite(order); // 不等待，返回是否成功 // 完成 channel.Writer.Complete(); // 通知没有更多数据 消费者 1 2 3 4 5 6 7 // 读取 await foreach (var order in channel.Reader.ReadAllAsync()) { await ProcessAsync(order); } // channel 完成且读完时，循环自动结束 完整示例：订单处理管道 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 public class OrderPipeline { private readonly Channel\u0026lt;Order\u0026gt; _channel; public OrderPipeline(int capacity = 1000) { _channel = Channel.CreateBounded\u0026lt;Order\u0026gt;(new BoundedChannelOptions(capacity) { FullMode = BoundedChannelFullMode.Wait, // 满了等待 SingleReader = false, SingleWriter = false }); } // 生产者：接收订单 public async Task EnqueueAsync(Order order, CancellationToken ct) { await _channel.Writer.WriteAsync(order, ct); } // 消费者：多 worker 并行处理 public async Task ConsumeAsync(int workerCount, CancellationToken ct) { var workers = Enumerable.Range(0, workerCount) .Select(_ =\u0026gt; Task.Run(async () =\u0026gt; { await foreach (var order in _channel.Reader.ReadAllAsync(ct)) { await ProcessOrderAsync(order); } })); await Task.WhenAll(workers); } public void Complete() =\u0026gt; _channel.Writer.Complete(); } 1 2 3 4 5 6 7 8 Channel 的优势： ✓ 无锁或细粒度锁，性能极高 ✓ 支持 async/await（不阻塞线程） ✓ 支持背压（BoundedChannel 控制内存） ✓ 支持 SingleReader/SingleWriter 优化 ✓ 是 ASP.NET Core 内部用的（SignalR、Quartz 等） 可以说：Channel 是 .NET 替代 Go channel 的方案 5.4 不可变集合 1 2 3 4 5 6 7 8 9 // System.Collections.Immutable // 每次修改返回新实例，天然线程安全 var list = ImmutableList\u0026lt;int\u0026gt;.Empty; var list2 = list.Add(1); // list 不变，list2 是新的 var list3 = list2.Add(2); // 适合：配置、只读缓存、共享状态 // 修改开销大（复制），但读取绝对安全 六、并行计算：TPL CPU 密集型任务要利用多核，用 TPL（Task Parallel Library）。\n6.1 Parallel.For / Parallel.ForEach 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // 并行处理（CPU 密集型） var data = Enumerable.Range(0, 1_000_000).ToArray(); // 并行 For Parallel.For(0, data.Length, i =\u0026gt; { data[i] = Compute(data[i]); }); // 并行 ForEach Parallel.ForEach(data, item =\u0026gt; { Process(item); }); // 控制并发度 var options = new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }; Parallel.ForEach(data, options, item =\u0026gt; Process(item)); 6.2 PLINQ（并行 LINQ） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // AsParallel() 把 LINQ 变成并行 var result = data .AsParallel() .Where(x =\u0026gt; x % 2 == 0) .Select(x =\u0026gt; x * x) .OrderBy(x =\u0026gt; x) .ToList(); // 控制并发度 var result2 = data .AsParallel() .WithDegreeOfParallelism(8) .Select(Compute) .ToList(); 1 2 3 4 5 注意： ✗ 小数据集别用并行（调度开销 \u0026gt; 收益） ✗ I/O 密集型别用 Parallel/PLINQ（它们是为 CPU 设计的） I/O 用 Task.WhenAll ✓ 数据量大 + 计算密集才用 6.3 Task.WhenAll（并发 I/O） 1 2 3 4 5 6 7 8 9 10 11 12 13 // 并发请求多个 I/O（不是并行计算，是并发等待） var urls = new[] { \u0026#34;url1\u0026#34;, \u0026#34;url2\u0026#34;, \u0026#34;url3\u0026#34; }; // 错误：串行（一个等一个） var results = new List\u0026lt;string\u0026gt;(); foreach (var url in urls) { results.Add(await httpClient.GetStringAsync(url)); // 串行！ } // 正确：并发（一起发起，一起等） var tasks = urls.Select(url =\u0026gt; httpClient.GetStringAsync(url)); var results = await Task.WhenAll(tasks); // 总耗时 ≈ 最慢的那个 1 2 3 4 5 区分： Task.WhenAll — 并发 I/O（等网络，不占 CPU） Parallel.For — 并行计算（占 CPU 多核） 这是新手最常搞错的点：I/O 密集用 WhenAll，CPU 密集用 Parallel 七、高性能内存：Span、Pipelines、Pool .NET Core 之后引入了一系列高性能内存原语，这是 .NET 能和原生 C++ 掰手腕的资本。\n7.1 Span / Memory（零拷贝） 1 2 3 4 5 6 7 8 9 10 11 12 13 // Span\u0026lt;T\u0026gt; — 连续内存的视图，不复制数据 // 堆栈上分配（ref struct），极快 // 切分数组，零拷贝 byte[] buffer = new byte[1000]; Span\u0026lt;byte\u0026gt; slice = buffer.AsSpan(100, 50); // 从100开始，取50个 // stackalloc — 栈上分配（不进堆，无 GC） Span\u0026lt;int\u0026gt; stackData = stackalloc int[100]; // 栈上，方法结束自动回收 // 字符串操作零拷贝 string text = \u0026#34;Hello, World\u0026#34;; ReadOnlySpan\u0026lt;char\u0026gt; hello = text.AsSpan(0, 5); // \u0026#34;Hello\u0026#34;，不新建字符串 1 2 3 4 5 6 7 8 9 10 11 // 高性能解析示例 // 旧：每步都分配新字符串 var parts = input.Split(\u0026#39;,\u0026#39;); // 分配数组 + 多个字符串 // 新：用 Span 零拷贝解析 var reader = new SpanReader(input.AsSpan()); while (!reader.Done) { var token = reader.ReadUntil(\u0026#39;,\u0026#39;); Process(token); // token 是原始数据的切片，不分配 } 1 2 3 4 5 6 Span 限制（ref struct）： ✗ 不能作为类的字段 ✗ 不能跨 await ✗ 不能装箱 需要跨 await / 存字段 → 用 Memory\u0026lt;T\u0026gt;（堆上，可异步） 7.2 System.IO.Pipelines 专门为高性能网络 I/O 设计，Kestrel 内部就用它。解决\u0026quot;数据不完整、粘包\u0026quot;的问题。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 安装 // dotnet add package System.IO.Pipelines async Task ProcessPipeAsync(PipeReader reader) { while (true) { ReadResult result = await reader.ReadAsync(); ReadOnlySequence\u0026lt;byte\u0026gt; buffer = result.Buffer; // 尝试解析完整消息 while (TryParseMessage(ref buffer, out var message)) { ProcessMessage(message); } // 告诉 PipeReader 消费了多少、还剩多少 reader.AdvanceTo(buffer.Start, buffer.End); if (result.IsCompleted) break; } } 1 2 3 4 5 6 Pipelines 解决的痛点： ✓ 自动管理缓冲区（不用自己 new byte[]） ✓ 处理消息不完整（等更多数据） ✓ 处理消息粘包（一次读多条） ✓ 内存复用（零拷贝、少分配） ✓ 背压（消费慢时通知生产者暂停） 7.3 ArrayPool / ObjectPool 减少 GC 压力的利器——复用对象而不是反复分配。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 // ArrayPool — 复用数组 byte[] buffer = ArrayPool\u0026lt;byte\u0026gt;.Shared.Rent(1024); // 租借（可能更大） try { ProcessData(buffer); } finally { ArrayPool\u0026lt;byte\u0026gt;.Shared.Return(buffer); // 归还（不是 GC 回收） } // ObjectPool — 复用对象（Microsoft.Extensions.ObjectPool） var pool = new DefaultObjectPool\u0026lt;StringBuilder\u0026gt;( new DefaultPooledObjectPolicy\u0026lt;StringBuilder\u0026gt;()); var sb = pool.Get(); try { sb.Append(\u0026#34;...\u0026#34;); var result = sb.ToString(); } finally { sb.Clear(); pool.Return(sb); } 1 2 3 4 5 6 何时用 Pool： ✓ 高频分配 + 大对象（大 byte[]、StringBuilder） ✓ 热路径（每秒成千上万次） ✗ 低频场景（开销 \u0026gt; 收益） 原理：对象复用，减少 GC，降低内存分配压力 八、线程池与 Kestrel 8.1 线程池机制 1 2 3 4 5 6 7 8 9 10 11 12 13 .NET 线程池（ThreadPool）： - 托管所有工作线程 - 按需增长（IO 密集型增长快） - 复用线程（避免频繁创建销毁） 线程池的\u0026#34;饥饿\u0026#34;问题： - 所有线程都被阻塞（同步 I/O） - 新任务排队等待 - 响应时间飙升 这就是为什么强调异步： 异步释放线程 → 线程池可用线程多 → 抗并发 同步阻塞线程 → 线程池耗尽 → 系统卡死 1 2 3 4 5 6 7 // 配置线程池（.NET 中通常用环境变量或 ThreadPool.SetMinThreads） // 设置最小线程数，避免冷启动时线程增长慢 ThreadPool.SetMinThreads(workerThreads: 100, completionPortThreads: 100); // 推荐：在 Program.cs 早期设置 // 或用环境变量 // DOTNET_ThreadPool_MinThreads=100 8.2 Kestrel（ASP.NET Core 服务器） Kestrel 是 ASP.NET Core 的内置 Web 服务器，性能世界级。\n1 2 3 4 5 6 7 8 9 10 11 Kestrel 为什么快： ✓ 基于 libuv / IOCP（异步 I/O 完成端口） ✓ 完全异步架构（async/await 全链路） ✓ Pipelines 处理网络数据 ✓ 内存池（ArrayPool）减少分配 ✓ HTTP/2、HTTP/3 支持 性能参考（官方基准）： 简单 JSON 接口：单机 10万+ RPS Plaintext（TechEmpower）：单机百万级 RPS 位居 TechEmpower 前列（和 Rust/C++ 一个梯队） 1 2 3 4 5 6 7 8 9 10 11 // Program.cs 配置 Kestrel var builder = WebApplication.CreateBuilder(args); builder.WebHost.ConfigureKestrel(options =\u0026gt; { options.Limits.MaxConcurrentConnections = 10000; // 最大连接数 options.Limits.MaxConcurrentUpgradedConnections = 10000; // WebSocket options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10MB options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2); options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30); }); 九、缓存 缓存是高并发的第一道防线——能用缓存的绝不打数据库。\n9.1 多级缓存 1 2 3 4 5 6 7 8 浏览器缓存 — 客户端，静态资源 CDN — 边缘节点，静态 + 动态加速 Nginx 缓存 — 反向代理层，减少打到应用的请求 内存缓存 — 进程内（IMemoryCache），最快 分布式缓存 — Redis/Memcached，跨实例共享 数据库缓存 — MySQL 查询缓存（已废弃）、缓冲池 层级越靠前，速度越快，容量越小 9.2 IMemoryCache（进程内缓存） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 注册 builder.Services.AddMemoryCache(); // 使用 public class UserService { private readonly IMemoryCache _cache; public UserService(IMemoryCache cache) =\u0026gt; _cache = cache; public async Task\u0026lt;User\u0026gt; GetUserAsync(int id) { // 缓存键 var key = $\u0026#34;user:{id}\u0026#34;; // GetOrCreateAsync：不存在则加载并缓存 return await _cache.GetOrCreateAsync(key, async entry =\u0026gt; { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30); // 30分钟过期 entry.SlidingExpiration = TimeSpan.FromMinutes(10); // 10分钟无访问过期 return await _db.Users.FindAsync(id); }); } } 9.3 IDistributedCache + Redis 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 // 注册 Redis builder.Services.AddStackExchangeRedisCache(options =\u0026gt; { options.Configuration = \u0026#34;localhost:6379\u0026#34;; options.InstanceName = \u0026#34;myapp:\u0026#34;; }); // 使用（和 IMemoryCache 接口类似） public class CacheService { private readonly IDistributedCache _cache; public async Task\u0026lt;string\u0026gt; GetAsync(string key) { return await _cache.GetStringAsync(key); } public async Task SetAsync(string key, string value) { var options = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) }; await _cache.SetStringAsync(key, value, options); } } 1 2 3 4 5 IMemoryCache vs IDistributedCache： IMemoryCache — 进程内，最快，但多实例不共享，重启丢失 IDistributedCache — Redis 等，跨实例共享，持久，但网络开销 实战：本地缓存做一级（抗热点），Redis 做二级（一致性） 十、限流与熔断 高并发系统必须保护自己——流量超载时主动拒绝，而不是被拖垮。\n10.1 限流（.NET 7+ 内置） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 // .NET 7+ 内置限流中间件 builder.Services.AddRateLimiter(options =\u0026gt; { // 全局并发限制 options.GlobalLimiter = PartitionedRateLimiter.Create\u0026lt;HttpContext, string\u0026gt;( httpContext =\u0026gt; RateLimitPartition.GetConcurrencyLimiter( partitionKey: \u0026#34;global\u0026#34;, factory: _ =\u0026gt; new ConcurrencyLimiterOptions { PermitLimit = 1000, // 全局最多 1000 并发 QueueLimit = 100 })); // 按 IP 限流 options.AddPolicy(\u0026#34;per-ip\u0026#34;, httpContext =\u0026gt; RateLimitPartition.GetTokenBucketLimiter( partitionKey: httpContext.Connection.RemoteIpAddress!.ToString(), factory: _ =\u0026gt; new TokenBucketRateLimiterOptions { TokenLimit = 100, TokensPerPeriod = 100, ReplenishmentPeriod = TimeSpan.FromSeconds(1) })); options.OnRejected = async (context, ct) =\u0026gt; { context.HttpContext.Response.StatusCode = 429; await context.HttpContext.Response.WriteAsync(\u0026#34;Too Many Requests\u0026#34;, ct); }; }); var app = builder.Build(); app.UseRateLimiter(); 1 2 3 4 5 限流算法： 并发限流（ConcurrencyLimiter） — 限制同时在处理的请求数 令牌桶（TokenBucket） — 允许突发，平均速率限制 固定窗口（FixedWindow） — 每个时间窗口固定配额 滑动窗口（SlidingWindow） — 更平滑的窗口算法 10.2 熔断、重试、超时（Polly） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 安装 // dotnet add package Microsoft.Extensions.Http.Polly // dotnet add package Polly // HttpClient 熔断 + 重试 builder.Services.AddHttpClient(\u0026#34;api\u0026#34;) .AddTransientHttpErrorPolicy(policyBuilder =\u0026gt; policyBuilder.WaitAndRetryAsync(3, attempt =\u0026gt; TimeSpan.FromSeconds(Math.Pow(2, attempt)))) // 重试 3 次，指数退避 .AddTransientHttpErrorPolicy(policyBuilder =\u0026gt; policyBuilder.CircuitBreakerAsync( handledEventsAllowedBeforeBreaking: 5, // 连续失败 5 次 durationOfBreak: TimeSpan.FromSeconds(30))); // 熔断 30 秒 // 超时 .AddPolicyHandler(Policy.TimeoutAsync\u0026lt;HttpResponseMessage\u0026gt;(TimeSpan.FromSeconds(10))); 1 2 3 4 5 6 熔断器三种状态： Closed（关闭） — 正常请求 Open（打开） — 失败率达阈值，直接拒绝（快速失败） Half-Open — 试探性放行几个请求，成功则恢复 作用：下游服务挂了，快速失败，不把整个链路拖死 十一、数据库层面 数据库往往是高并发系统的瓶颈所在。\n11.1 连接池 1 2 3 4 5 6 7 8 // ADO.NET / EF Core 默认开启连接池 // 连接字符串配置 \u0026#34;Server=...;Database=...;Pooling=true;Max Pool Size=100;Min Pool Size=10;\u0026#34; // 注意： // 连接用完必须 Dispose（using 或 await using） // 否则连接泄漏，连接池耗尽 await using var conn = new SqlConnection(connStr); // 自动归还连接池 11.2 异步 EF Core 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 全异步，不阻塞线程 var users = await _db.Users .Where(u =\u0026gt; u.IsActive) .ToListAsync(); // 异步查询 // 注意 N+1 问题 // 错误（N+1 查询） foreach (var user in await _db.Users.ToListAsync()) { var orders = await _db.Orders.Where(o =\u0026gt; o.UserId == user.Id).ToListAsync(); // 100 个用户 = 101 次查询 } // 正确（Include / Join） var users = await _db.Users .Include(u =\u0026gt; u.Orders) .ToListAsync(); // 1 次查询 11.3 Dapper（高性能 ORM） 1 2 3 4 5 6 7 // Dapper 比 EF Core 快 5-10 倍（轻量，接近原生 ADO.NET） // 适合：性能敏感的查询、复杂 SQL using var conn = new SqlConnection(connStr); var users = await conn.QueryAsync\u0026lt;User\u0026gt;( \u0026#34;SELECT * FROM Users WHERE IsActive = @isActive\u0026#34;, new { isActive = true }); 1 2 3 4 5 EF Core vs Dapper： EF Core — 开发效率高，功能全，性能中等 Dapper — 性能极致，SQL 自己写 实战：核心 CRUD 用 EF Core，热点查询用 Dapper 11.4 数据库层面优化 1 2 3 4 5 6 ✓ 索引优化（避免全表扫描） ✓ 读写分离（主写从读） ✓ 分库分表（数据量大时） ✓ 慢查询日志 + EXPLAIN ✓ 批量操作（减少往返） ✓ 连接池合理配置 十二、监控与诊断 高并发系统必须有可观测性，否则出问题两眼一抹黑。\n12.1 性能计数器（dotnet-counters） 1 2 3 4 5 6 7 8 9 10 11 12 # 实时监控 .NET 应用指标 dotnet-counters monitor -p 12345 \\ System.Runtime \\ Microsoft.AspNetCore.Hosting # 关注指标： # cpu-usage CPU 使用率 # gc-heap-size GC 堆大小 # gen-0/1/2-gc-count GC 次数（频繁 GC = 分配过多） # time-in-gc GC 占用时间 # threadpool-queue-length 线程池队列（长了 = 线程不足/阻塞） # working-set 内存占用 12.2 火焰图（dotnet-trace） 1 2 3 # 录制性能数据 dotnet-trace collect -p 12345 --format Speedscope -d 30 # 用 https://speedscope.app 打开，看 CPU 时间花在哪 12.3 链路追踪（OpenTelemetry） 1 2 3 4 5 6 7 8 9 10 11 12 13 // 安装 // dotnet add package OpenTelemetry.Extensions.Hosting // dotnet add package OpenTelemetry.Instrumentation.AspNetCore // dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol builder.Services.AddOpenTelemetry() .WithTracing(tracing =\u0026gt; { tracing.AddAspNetCoreInstrumentation() // HTTP 请求 .AddHttpClientInstrumentation() // 外部调用 .AddEntityFrameworkCoreInstrumentation() // 数据库 .AddOtlpExporter(); // 导出到 Jaeger/Tempo }); 1 2 3 4 5 6 7 可观测性三支柱： Metrics（指标） — CPU、QPS、错误率（聚合数据） Tracing（追踪） — 一个请求经过的所有服务（链路） Logging（日志） — 具体的事件记录 工具：OpenTelemetry（标准）+ Prometheus + Grafana + Jaeger 或商业：Application Insights、Datadog 十三、高并发架构总结 把前面所有技术组合起来，一个典型的高并发 .NET 系统架构：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 客户端 │ ┌─────▼─────┐ │ CDN │ 静态资源缓存 └─────┬─────┘ │ ┌─────▼─────┐ │ Nginx │ 负载均衡 + 缓存 + 限流 + HTTPS │ 集群 │ └─────┬─────┘ │ ┌───────────┼───────────┐ │ │ │ ┌─────▼─────┐ ┌──▼───┐ ┌─────▼─────┐ │ Kestrel │ │Kestrel│ │ Kestrel │ ASP.NET Core 集群 │ App #1 │ │App #2 │ │ App #3 │ （全异步、Channel、Span） └─────┬─────┘ └───┬───┘ └─────┬─────┘ │ │ │ └─────┬─────┘───────────┘ │ ┌───────────┼───────────┐ │ │ │ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │ Redis │ │ 消息队列 │ │ 数据库 │ │ 缓存 │ │(削峰) │ │(读写分离)│ └─────────┘ └─────────┘ └─────────┘ 1 2 3 4 5 6 7 8 每一层的高并发手段： CDN/Nginx — 缓存、负载均衡、限流（前文 Nginx 系列讲过） Kestrel — 异步架构、Pipelines、内存池 应用层 — async/await、Channel、并发集合、对象池 缓存层 — IMemoryCache + Redis 多级缓存 消息队列 — 削峰填谷，异步解耦 数据库 — 连接池、读写分离、分库分表、索引 全链路 — 限流、熔断、重试（Polly）+ 监控（OpenTelemetry） 核心原则 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 1. 异步优先 I/O 用 async/await，绝不阻塞线程 2. 减少分配 Span、ArrayPool、对象池，降低 GC 压力 3. 无锁优先 Channel、并发集合、Interlocked，避免锁竞争 4. 缓存为王 能缓存的绝不查数据库，多级缓存 5. 保护自己 限流、熔断、降级，超载时主动拒绝 6. 水平扩展 无状态设计，随时加机器 7. 可观测 指标、追踪、日志，问题可定位 十四、小结 本文系统梳理了 .NET 高并发编程的全景：\n基础概念：高并发的指标、挑战，并发/并行/异步的区别 异步编程：async/await 状态机、Task/ValueTask、CancellationToken、IAsyncEnumerable 同步原语：lock、SemaphoreSlim、Interlocked、ReaderWriterLockSlim 并发集合：ConcurrentDictionary、Channel（明星）、不可变集合 并行计算：TPL、Parallel、PLINQ，区分 CPU 并行和 I/O 并发 高性能内存：Span/Memory、Pipelines、ArrayPool/ObjectPool 服务器：线程池机制、Kestrel 为什么快 缓存：IMemoryCache、Redis、多级缓存 保护机制：.NET 限流、Polly 熔断重试 数据库：连接池、异步 EF Core、Dapper、读写分离 可观测性：dotnet-counters、dotnet-trace、OpenTelemetry 整体架构：各层高并发手段 + 核心原则 .NET 在高并发领域的能力是被低估的——async/await 模型、Channel、Span、Kestrel 都是顶级实现。掌握这套全景，应对绝大多数高并发场景绰绰有余。\n下一篇将深入 .NET 性能优化与 profiling，讲解如何用火焰图定位性能瓶颈。\n","date":"2025-07-31T10:00:00+08:00","permalink":"/posts/dotnet/performance/high-concurrency/","title":".NET 高并发编程全景：从异步到高性能实战"},{"content":"写在前面 生产环境的应用出问题，往往不能直接 Attach 调试器——服务正在跑，停不了；问题转瞬即逝，复现不了；线上机器没装 IDE。这时候 dump（内存转储） 就是排错的救命稻草。\n.NET 应用现在普遍 Linux 部署（容器、K8s），但开发机往往是 Windows，分析工具链也大不相同。本文对比讲解两个平台如何抓 dump、如何分析，以及内存泄漏、CPU 飙高、死锁、崩溃四大典型场景。\n一、什么是 dump 1.1 定义 dump 是某个时刻进程内存的完整快照，相当于给运行中的程序\u0026quot;拍一张照片\u0026quot;。\n1 2 3 4 5 6 7 8 9 dump 包含什么： - 所有线程的调用栈 - 托管堆上的所有对象（.NET 对象） - 非托管内存（C++ 层） - GC 状态（各代堆大小） - 锁信息（Monitor、SyncBlock） - 异常对象 - AppDomain、Module 信息 - 寄存器和线程上下文 1.2 为什么用 dump 而不是直接调试 1 2 3 4 5 6 7 8 9 10 直接 Attach 调试器的问题： ✗ 需要停住进程，影响线上服务 ✗ 需要源码和符号文件 ✗ 问题可能已经发生过了，现场不在 dump 的优势： ✓ 抓取快（几秒），对线上影响小 ✓ 离线分析，不占用生产机器 ✓ 可以反复分析，团队协作 ✓ 捕获\u0026#34;案发现场\u0026#34;，事后也能查 二、Linux 和 Windows 的根本差异 很多人觉得\u0026quot;dump 不就那样\u0026quot;，但两个平台从文件格式、抓取工具到分析工具链差异很大。先建立整体认知。\n2.1 dump 文件格式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 平台 格式 抓取工具 ───────────────────────────────────────────────────────── Linux minidump（.NET 工具抓的） dotnet-dump / createdump Linux ELF core dump（OS 抓的） gcore / 系统崩溃 Windows Windows minidump (.dmp) dotnet-dump / Procdump / 任务管理器 关键点： .NET 官方工具（dotnet-dump、createdump）在两个平台都生成 统一的 minidump 格式 → 抓出来的 dump 可以跨平台分析 （Linux 上抓的 dump，拷到 Windows 用 WinDbg 也能分析，反之亦然） 但 OS 原生工具抓的不一样： gcore（Linux）→ ELF core dump，需要 LLDB 分析 任务管理器（Windows）→ minidump，WinDbg / VS / dotnet-dump 都能开 2.2 抓取方式的差异 1 2 3 4 5 6 7 8 9 10 11 12 13 Linux 的特点： ✓ 命令行为主（服务器无 GUI） ✓ 工具：dotnet-dump、createdump、gcore ✓ 环境变量配置 OOM 自动抓（生产标配） ✓ 容器环境要考虑镜像精简、权限 → 偏\u0026#34;运维向\u0026#34;，脚本化 Windows 的特点： ✓ GUI + 命令行都有 ✓ 工具：任务管理器（一键）、Procdump（条件触发）、WinDbg ✓ Procdump 的条件触发（CPU/内存/异常阈值）极其强大 ✓ 符号服务器集成完善 → 偏\u0026#34;开发向\u0026#34;，图形化体验好 2.3 分析方式的差异 1 2 3 4 5 6 7 8 9 10 11 12 13 Linux 的分析： ✓ 命令行为主 ✓ dotnet-dump analyze（官方，推荐） ✓ LLDB + SOS 插件（老牌，功能全但配置麻烦） ✗ 几乎没有图形化工具 Windows 的分析： ✓ 图形化为主 ✓ WinDbg（最强，命令 + GUI） ✓ Visual Studio（有源码时最舒服） ✓ PerfView（微软，免费，内存分析强） ✓ dotMemory（JetBrains，收费，体验最好） → 工具链远比 Linux 丰富 2.4 平台对比总览 1 2 3 4 5 6 7 8 9 10 维度 Linux Windows ──────────────────────────────────────────────────────────── 抓取主力 dotnet-dump / createdump Procdump / 任务管理器 自动抓 OOM 环境变量（DOTNET_Dbg*） Procdump -e 守护 GUI 抓取 无 任务管理器 / Process Explorer 分析主力 dotnet-dump analyze WinDbg / VS 图形化分析 无 WinDbg / VS / PerfView / dotMemory 符号 .pdb + dotnet 符号 符号服务器 _NT_SYMBOL_PATH 容器调试 主战场（Docker/K8s） Windows 容器较少 典型场景 生产排错 开发机分析 + 生产抓取 实战常见组合：生产环境是 Linux，抓完 dump 拷到 Windows 开发机，用 WinDbg/VS 图形化分析。这是最顺手的姿势。\n三、跨平台工具：dotnet-dump 和 dotnet-gcdump 这两个是 .NET 官方 CLI 工具，Linux 和 Windows 完全一样，是两个平台共通的基础。\n3.1 dotnet-dump 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # 安装（两个平台相同） dotnet tool install -g dotnet-dump # 找进程 dotnet-dump ps # 抓 Full dump dotnet-dump collect -p 12345 dotnet-dump collect -p 12345 --type Full # 完整（默认） dotnet-dump collect -p 12345 --type Heap # 堆 dotnet-dump collect -p 12345 --type Triage # 脱敏，适合分享 # 指定输出 dotnet-dump collect -p 12345 -o ./myapp.dmp # 直接分析（跨平台） dotnet-dump analyze ./myapp.dmp 3.2 dotnet-gcdump（轻量堆 dump） 1 2 3 4 5 dotnet tool install -g dotnet-gcdump # 只抓托管堆，文件几 MB dotnet-gcdump collect -p 12345 dotnet-gcdump collect -p 12345 -o ./myapp.gcdump 1 2 3 什么时候用哪个： Full dump（dotnet-dump） — 全面排查，看线程/锁/异常，文件大 gcdump（dotnet-gcdump） — 只查内存，文件小，VS/dotMemory 可打开 四、Linux 平台：抓 dump 4.1 createdump（.NET 运行时自带） 1 2 3 4 5 6 7 8 # 路径在 .NET runtime 旁边 # /usr/share/dotnet/shared/Microsoft.NETCore.App/\u0026lt;版本\u0026gt;/createdump # 抓 Full dump /usr/share/dotnet/shared/Microsoft.NETCore.App/8.0.0/createdump 12345 # 找路径的快捷方式 ls $(dirname $(readlink -f $(which dotnet)))/shared/Microsoft.NETCore.App/*/createdump 1 2 3 4 特点： .NET 自带，无需额外安装 生产镜像里直接可用 抓的是 minidump，dotnet-dump / LLDB 都能分析 4.2 gcore（系统通用，抓 ELF core） 1 2 3 4 5 6 # 通用工具，抓任何进程 gcore 12345 # 输出：core.12345（ELF core dump 格式） # 指定输出名 gcore -o /tmp/myapp 12345 1 2 3 4 注意： gcore 抓的是 ELF core dump（不是 minidump） 分析需要 LLDB + SOS，不能直接用 dotnet-dump analyze 通常推荐用 createdump 或 dotnet-dump，除非它们不可用 4.3 OOM / 崩溃自动抓（生产标配） 这是 Linux 生产环境最重要的配置——进程崩溃时自动留一份 dump。\n1 2 3 4 5 6 7 8 9 # 启动应用前设置环境变量 export DOTNET_DbgEnableMiniDump=1 export DOTNET_DbgMiniDumpType=4 # 4 = Full export DOTNET_DbgMiniDumpName=/tmp/coredump.%p # 启动应用 dotnet MyApp.dll # 进程 OOM 或未处理异常时，自动在 /tmp/ 生成 dump 1 2 3 4 5 6 7 8 DOTNET_DbgMiniDumpType 取值： 1 — Mini（小） 2 — Heap（堆） 3 — Triage（脱敏） 4 — Full（完整，推荐） 这个配置对 Windows 也有效，但 Linux 生产环境用得最多 （因为 Linux 服务通常在容器里，崩了就重启，没自动 dump 就彻底没现场） 4.4 容器环境（Docker / K8s） 容器里通常镜像精简（alpine 无 glibc），工具缺失，要特殊处理。\n1 2 3 4 5 6 7 8 9 10 11 # 方案一：宿主机抓（容器进程对宿主机可见） docker top myapp # 找容器内进程在宿主机的 PID dotnet-dump collect -p \u0026lt;宿主机PID\u0026gt; # 在宿主机抓 # 方案二：进容器抓（镜像里要有工具） docker exec -it myapp dotnet-dump collect -p 1 # 方案三：用 SDK 镜像临时调试（精简镜像的救星） kubectl debug pod/myapp-pod -it \\ --image=mcr.microsoft.com/dotnet/sdk:8.0 \\ --target=myapp 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # K8s Pod 配置 OOM 自动抓 + 持久化 dump apiVersion: v1 kind: Pod spec: containers: - name: myapp image: myapp:latest env: - name: DOTNET_DbgEnableMiniDump value: \u0026#34;1\u0026#34; - name: DOTNET_DbgMiniDumpType value: \u0026#34;4\u0026#34; - name: DOTNET_DbgMiniDumpName value: \u0026#34;/dumps/coredump.%p\u0026#34; volumeMounts: - name: dumps mountPath: /dumps volumes: - name: dumps emptyDir: {} 五、Windows 平台：抓 dump 5.1 任务管理器（最简单） 1 2 3 4 5 6 1. Ctrl+Shift+Esc 打开任务管理器 2. 切换到\u0026#34;详细信息\u0026#34;（Details）标签 3. 右键目标进程 → 创建转储文件（Create dump file） 4. 弹窗显示路径，通常在 %TEMP%\\\u0026lt;进程名\u0026gt;.DMP 适合：临时抓一下，啥工具都没装 5.2 Procdump（Sysinternals，最强大） Windows 抓 dump 的王牌，条件触发是它最强大的能力。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 # 下载：https://learn.microsoft.com/sysinternals/procdump # 抓一次 Full dump procdump -ma 12345 procdump -ma 12345 C:\\dumps\\ # 指定目录 # CPU 超过 80% 持续 5 秒时抓 procdump -ma -c 80 -s 5 12345 # 内存超过 2GB 时抓 procdump -ma -m 2000 12345 # 进程崩溃时自动抓（-e 异常触发，守护模式） procdump -ma -e 1 -f \u0026#34;\u0026#34; 12345 # 特定异常触发（如 OutOfMemoryException） procdump -ma -e 1 -f \u0026#34;OutOfMemoryException\u0026#34; 12345 # 抓 5 个，间隔 5 秒（看趋势） procdump -ma -n 5 -s 5 12345 # 进程无响应时抓 procdump -ma -t 12345 1 2 3 4 Procdump vs Linux 的 OOM 环境变量： Procdump 更灵活——可以按 CPU、内存、异常类型多种条件触发 还能抓多个 dump 看趋势 这是 Windows 平台的独门优势 5.3 Process Explorer（Sysinternals） 1 2 3 4 图形化进程管理器，也能抓 dump： 右键进程 → Create Dump → Mini / Full 好处：能直观看到进程树、CPU、内存，挑准了再抓 六、Linux 平台：分析 dump 6.1 dotnet-dump analyze（推荐） 1 2 dotnet-dump analyze ./myapp.dmp # 进入交互式命令行 1 2 3 4 5 6 7 8 9 10 \u0026gt; help # 所有命令 \u0026gt; clrstack # 当前线程调用栈 \u0026gt; clrstack -all # 所有线程 \u0026gt; dumpheap -stat # 堆对象按类型统计 \u0026gt; dumpheap -type User # 查 User 类型对象 \u0026gt; gcroot \u0026lt;地址\u0026gt; # 查引用链（内存泄漏关键） \u0026gt; syncblk # 锁状态（死锁排查） \u0026gt; pe # 当前异常 \u0026gt; eeheap -gc # GC 堆各代大小 \u0026gt; exit 6.2 LLDB + SOS（功能更全） LLDB 是 Linux 上的 WinDbg 对应物，配合 SOS 插件能分析 .NET dump。\n1 2 3 4 5 6 7 8 9 10 11 12 # 安装 LLDB apt install lldb # 加载 dump lldb --core ./core.12345 # 加载 SOS 插件 (lldb) plugin load /usr/share/dotnet/shared/Microsoft.NETCore.App/8.0.0/libsosplugin.so # 之后 SOS 命令可用（和 dotnet-dump analyze 的命令一致） (lldb) sos dumpheap -stat (lldb) sos clrstack 1 2 3 4 5 dotnet-dump analyze vs LLDB+SOS： dotnet-dump analyze — 官方推荐，开箱即用，命令够用 LLDB + SOS — 功能更全（能看原生栈、寄存器），但配置麻烦 绝大多数场景 dotnet-dump analyze 就够了 1 2 3 4 5 Linux 分析的现实： 命令行为主，没有图形化 老手用着顺手，新人上手陡 所以常见做法：Linux 抓 dump → scp 拷到 Windows → WinDbg/VS 图形化分析 七、Windows 平台：分析 dump Windows 的分析工具链远比 Linux 丰富，这是 Windows 平台的最大优势。\n7.1 WinDbg（最强） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 安装： Microsoft Store 搜 \u0026#34;WinDbg\u0026#34;（新版 WinDbg Preview） 或下载 Windows SDK 加载 dump： File → Open dump file 或 windbg -z myapp.dmp 加载 SOS（.NET 调试扩展）： .loadby sos coreclr # .NET Core / 5+ .loadby sos mscorwks # .NET Framework WinDbg 独有优势： ✓ 图形化调用栈、变量、内存 ✓ !analyze -v 自动分析崩溃 ✓ SOS 命令和 dotnet-dump 一致（dumpheap/gcroot/syncblk） ✓ 支持 .NET Framework 老项目 7.2 Visual Studio（有源码时最舒服） 1 2 3 4 5 6 7 8 直接用 VS 打开 .dmp： File → Open → File → 选 .dmp → 点\u0026#34;调试托管内存\u0026#34; 适合： 有源码和符号 想图形化查看对象树、调用栈 团队成员不熟命令行 7.3 PerfView（微软，免费） 1 2 3 4 5 6 7 分析 .gcdump 文件的利器： 打开 .gcdump → 看对象引用树 打开 .etl（性能日志）→ 看火焰图 特点： 界面朴素但功能强大 专门优化内存和性能分析 7.4 dotMemory（JetBrains，收费） 1 2 3 4 5 体验最好的内存分析工具： 支持 .gcdump / .dmp 两个 dump 对比，一眼看出增长的对象 可视化引用链 自动检测常见泄漏模式 八、跨平台通用分析命令 无论 Linux（dotnet-dump analyze）还是 Windows（WinDbg + SOS），核心分析命令都是同一套（SOS 命令）。这部分两个平台完全通用。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 线程相关： clrstack # 当前线程调用栈 clrstack -all # 所有线程（CPU/死锁排查） threads # 列出所有线程 setthread \u0026lt;id\u0026gt; # 切换线程 dso (dumpstackobjects) # 栈上的所有对象 堆相关： dumpheap -stat # 按类型统计（内存排查第一步） dumpheap -type \u0026lt;类名\u0026gt; # 查指定类型对象 dumpheap -mt \u0026lt;MT地址\u0026gt; # 按方法表查 dumpheap -min 10000 # 大于 10KB 的对象 eeheap -gc # GC 堆各代大小 对象相关： do \u0026lt;地址\u0026gt; (dumpobject) # 看对象内容 gcroot \u0026lt;地址\u0026gt; # 引用链（谁引用了它） dumpvc \u0026lt;MT\u0026gt; \u0026lt;地址\u0026gt; # 看值类型 锁相关： syncblk # 锁状态（死锁） dumpheap -thinlock # 轻量级锁 异常相关： pe (print exception) # 当前异常 dumpheap -type Exception # 所有异常 异步相关： dumpasync # 异步状态机 九、场景实战（命令跨平台，标注平台习惯） 9.1 内存泄漏 抓取：抓两个 dump 对比（任何平台）\n1 2 3 4 # 跨平台 dotnet-gcdump collect -p 12345 -o /tmp/d1.gcdump # 等 10-30 分钟 dotnet-gcdump collect -p 12345 -o /tmp/d2.gcdump 分析：\n1 2 3 4 # Linux / Windows 都一样 \u0026gt; dumpheap -stat # 第一步：找可疑类型 \u0026gt; dumpheap -type UserCache # 第二步：看具体对象 \u0026gt; gcroot 000002a3b4c50000 # 第三步：找引用链 1 2 3 4 5 6 7 gcroot 输出示例： HandleTable (pinned): → MyApp.Services.CacheService → Dictionary\u0026lt;string, UserCache\u0026gt; → UserCache (泄漏对象) 结论：静态 CacheService 的 Dictionary 持有对象，永不释放 9.2 CPU 飙高 dump 看 CPU 不够实时，先看平台对应的实时工具：\n1 2 3 4 5 6 7 8 9 10 11 12 # Linux / Windows 都能用的官方工具 dotnet-counters monitor -p 12345 System.Runtime[cpu-usage,gen-0-gc-count] dotnet-stack report -p 12345 # 实时线程栈 dotnet-trace collect -p 12345 --format Speedscope # 录火焰图 # 也可以抓两个 dump 对比调用栈 dotnet-dump collect -p 12345 -o cpu1.dmp sleep 5 dotnet-dump collect -p 12345 -o cpu2.dmp # 分析：找两次调用栈相同的线程（一直在跑同一段代码） \u0026gt; clrstack -all 9.3 死锁 抓一个 Full dump，用 syncblk\n1 2 3 4 5 6 7 8 \u0026gt; syncblk # 输出会显示： # Thread A 持有锁 12，等待锁 15 # Thread B 持有锁 15，等待锁 12 # → 互相等待 = 死锁 \u0026gt; setthread \u0026lt;Thread A\u0026gt; \u0026gt; clrstack # 看 A 卡在哪段代码 9.4 崩溃 / 异常 关键：提前配置自动抓\n1 2 3 4 5 6 7 # Linux：环境变量（生产标配） export DOTNET_DbgEnableMiniDump=1 export DOTNET_DbgMiniDumpType=4 export DOTNET_DbgMiniDumpName=/tmp/crash.%p.dmp # Windows：Procdump 守护 procdump -ma -e 1 -f \u0026#34;\u0026#34; 12345 C:\\dumps\\ 分析崩溃 dump\n1 2 3 4 \u0026gt; pe # 看导致崩溃的异常 # 输出：OutOfMemoryException / StackOverflowException / NullReferenceException \u0026gt; do \u0026lt;异常对象地址\u0026gt; # 看异常详情 # StackTrace 直接指到崩溃代码位置 十、平台选择建议 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 什么时候在 Linux 上分析： ✓ 生产是 Linux，想就地快速看一眼 ✓ 熟悉命令行，dotnet-dump analyze 够用 ✓ 没法把 dump 拷出来（体积大、网络受限） 什么时候拷到 Windows 分析： ✓ 开发机是 Windows，工具链熟 ✓ 需要图形化（WinDbg / VS / dotMemory） ✓ 团队协作，要发给别人看 ✓ .NET Framework 老项目（只能 Windows） 最佳实践组合： Linux 生产 → 抓 dump（dotnet-dump / createdump / OOM 自动） ↓ scp / kubectl cp Windows 开发机 → 图形化分析（WinDbg / VS / dotMemory） 十一、生产环境最佳实践 11.1 提前准备（部署时就配好） 1 2 3 4 5 6 7 8 9 # Linux 镜像预装诊断工具 + 开启自动 dump FROM mcr.microsoft.com/dotnet/aspnet:8.0 RUN dotnet tool install -g dotnet-dump \\ \u0026amp;\u0026amp; dotnet tool install -g dotnet-gcdump \\ \u0026amp;\u0026amp; dotnet tool install -g dotnet-counters ENV PATH=\u0026#34;$PATH:/root/.dotnet/tools\u0026#34; ENV DOTNET_DbgEnableMiniDump=1 ENV DOTNET_DbgMiniDumpType=4 ENV DOTNET_DbgMiniDumpName=/dumps/coredump.%p 11.2 符号文件 1 2 3 4 5 6 7 8 9 10 无论哪个平台，分析 dump 都需要符号（.pdb）才能看到方法和行号： Linux： 把 .pdb 放到 DLL 同目录 或打包归档，分析时对应上 Windows： 配置符号服务器（_NT_SYMBOL_PATH） WinDbg / VS 会自动从微软下载系统库符号 set _NT_SYMBOL_PATH=srv*c:\\symbols*https://msdl.microsoft.com/download/symbols 11.3 安全注意 1 2 3 4 5 dump 含敏感数据（密码、token、用户信息）： ✓ 加密存储、访问控制 ✓ 分析完及时删除 ✗ 不要提交 Git，不要传公共平台 ✓ 给第三方用 Triage 类型（dotnet-dump --type Triage 已脱敏） 11.4 排查流程速查 1 2 3 4 5 6 问题 Linux 抓取 Windows 抓取 分析命令 ────────────────────────────────────────────────────────────────────────── 内存泄漏 dotnet-gcdump ×2 dotnet-gcdump ×2 dumpheap -stat / gcroot CPU 飙高 dotnet-dump ×2 procdump -c 80 clrstack -all / dotnet-trace 死锁 dotnet-dump ×1 procdump -ma syncblk / clrstack 崩溃 OOM 环境变量自动抓 procdump -e 守护 pe / StackTrace 十二、小结 本文从 Linux 和 Windows 两个平台对比讲解了 .NET dump 诊断：\n根本差异：文件格式、抓取工具、分析工具链各不相同；但 .NET 官方工具（dotnet-dump）抓的 minidump 跨平台可分析 Linux 抓取：dotnet-dump / createdump / gcore + OOM 环境变量自动抓 + 容器调试 Windows 抓取：任务管理器一键 / Procdump 条件触发（独门优势） Linux 分析：dotnet-dump analyze / LLDB+SOS（命令行为主） Windows 分析：WinDbg / VS / PerfView / dotMemory（图形化，工具链丰富） 通用命令：SOS 命令（dumpheap/gcroot/syncblk/pe）两个平台完全一致 四大场景：内存泄漏、CPU 飙高、死锁、崩溃的抓取和分析 最佳组合：Linux 生产抓 dump → 拷到 Windows 图形化分析 核心思路：dump 是事后分析的\u0026quot;黑匣子\u0026quot;，平时配好自动抓取，出问题才有料可查；两个平台工具不同但目标一致。\n","date":"2025-07-27T10:00:00+08:00","permalink":"/posts/dotnet/performance/dump-diagnostics/","title":".NET Dump 诊断：从抓取到分析（Linux + Windows）"},{"content":"写在前面 本文是 ASP.NET Core 系列收官篇，讲生产环境用得多的进阶能力：过滤器（AOP）、后台服务、健康检查、性能优化、部署。\n一、过滤器（AOP 切面） 过滤器让你在请求管道的特定阶段插入逻辑，实现日志、缓存、权限、事务等横切关注点。\n1.1 六种过滤器（执行顺序） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 请求进来 ↓ 1. Authorization Filter 授权（最外层） ↓ 2. Resource Filter 资源（缓存、短路） ↓ 3. Action Filter 方法（前后） ↓ 4. Exception Filter 异常 ↓ 5. Result Filter 结果（前后） ↓ 响应出去 6. Always Run Filter 始终执行（Result 的变体，不被短路影响） 1.2 实现自定义过滤器 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 // Action 过滤器（方法执行前后） public class LoggingActionFilter : IAsyncActionFilter { private readonly ILogger\u0026lt;LoggingActionFilter\u0026gt; _logger; public LoggingActionFilter(ILogger\u0026lt;LoggingActionFilter\u0026gt; logger) =\u0026gt; _logger = logger; public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { _logger.LogInformation(\u0026#34;执行 {Action}\u0026#34;, context.ActionDescriptor.DisplayName); await next(); // 执行 Action _logger.LogInformation(\u0026#34;执行完成\u0026#34;); } } // 异常过滤器（捕获未处理异常） public class ApiExceptionFilter : IExceptionFilter { public void OnException(ExceptionContext context) { context.Result = new ObjectResult(new { message = context.Exception.Message }) { StatusCode = 500 }; context.ExceptionHandled = true; } } // 注册（全局） builder.Services.AddControllers(o =\u0026gt; { o.Filters.Add\u0026lt;LoggingActionFilter\u0026gt;(); o.Filters.Add\u0026lt;ApiExceptionFilter\u0026gt;(); }); 1.3 特性形式 1 2 3 4 5 6 // 用特性方式挂到控制器/方法 [ServiceFilter(typeof(LoggingActionFilter))] // 通过 DI 注入 public class UsersController : ControllerBase { ... } // 或写一个既是特性又是过滤器的类（用于无需 DI 的简单场景） public class ValidateAttribute : Attribute, IActionFilter { ... } 1 2 3 何时用过滤器 vs 中间件： 中间件 — 管道层，对每个请求都跑（日志、异常、CORS） 过滤器 — MVC 层，能感知控制器/Action（缓存、模型验证、特定授权） 二、后台服务（IHostedService） 需要在后台持续运行的任务（定时任务、消息消费、监控）。\n2.1 BackgroundService（推荐基类） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public class OrderTimeoutService : BackgroundService // 继承 BackgroundService { private readonly ILogger\u0026lt;OrderTimeoutService\u0026gt; _logger; public OrderTimeoutService(ILogger\u0026lt;OrderTimeoutService\u0026gt; logger) =\u0026gt; _logger = logger; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation(\u0026#34;订单超时服务启动\u0026#34;); while (!stoppingToken.IsCancellationRequested) { try { await CancelExpiredOrders(); } catch (Exception ex) { _logger.LogError(ex, \u0026#34;处理异常\u0026#34;); } await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); // 每 1 分钟 } } private async Task CancelExpiredOrders() { /* 业务 */ } } // 注册 builder.Services.AddHostedService\u0026lt;OrderTimeoutService\u0026gt;(); 2.2 托管队列（IHostedService + Channel） 后台任务排队执行，结合 Channel：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 public class EmailQueueService : BackgroundService { private readonly Channel\u0026lt;EmailMessage\u0026gt; _channel; public EmailQueueService(Channel\u0026lt;EmailMessage\u0026gt; channel) =\u0026gt; _channel = channel; protected override async Task ExecuteAsync(CancellationToken ct) { await foreach (var email in _channel.Reader.ReadAllAsync(ct)) { await SendEmailAsync(email); } } } // 注册 Channel + 服务 builder.Services.AddSingleton(Channel.CreateUnbounded\u0026lt;EmailMessage\u0026gt;()); builder.Services.AddHostedService\u0026lt;EmailQueueService\u0026gt;(); // 业务代码投递 public class OrderService { private readonly Channel\u0026lt;EmailMessage\u0026gt; _channel; public OrderService(Channel\u0026lt;EmailMessage\u0026gt; channel) =\u0026gt; _channel = channel; public async Task CreateAsync(Order order) { // ... 创建订单 await _channel.Writer.WriteAsync(new EmailMessage(order.UserEmail, \u0026#34;下单成功\u0026#34;)); } } 2.3 Worker Service 独立的后台服务项目（无 Web）：\n1 dotnet new worker -n MyWorker # 创建 Worker 项目 1 2 // 和 BackgroundService 一样的模型，适合纯后台任务 // 可部署为 Windows Service / systemd 服务 三、健康检查 生产环境必备——让负载均衡器、K8s 知道应用是否健康。\n1 2 3 4 5 6 7 builder.Services.AddHealthChecks() .AddSqlServer(connectionString, name: \u0026#34;sql\u0026#34;) .AddRedis(redisConnection, name: \u0026#34;redis\u0026#34;) .AddUrlGroup(new Uri(\u0026#34;https://api.github.com\u0026#34;), name: \u0026#34;github\u0026#34;); var app = builder.Build(); app.MapHealthChecks(\u0026#34;/health\u0026#34;); // GET /health 返回 200（健康）或 503（不健康） 1 2 3 4 5 6 7 8 用途： - K8s liveness/readiness 探针 - 负载均衡健康判断 - 监控告警 自定义健康检查： public class MyHealthCheck : IHealthCheck { ... } AddCheck\u0026lt;MyHealthCheck\u0026gt;(\u0026#34;my-check\u0026#34;) 四、性能优化 4.1 响应缓存 1 2 3 4 5 6 builder.Services.AddResponseCaching(); app.UseResponseCaching(); [ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any)] [HttpGet(\u0026#34;list\u0026#34;)] public List\u0026lt;Item\u0026gt; GetList() { ... } // 60 秒内相同请求走缓存 4.2 输出缓存（.NET 7+） 服务端缓存，比 ResponseCache（浏览器侧）更强：\n1 2 3 4 5 6 7 8 9 builder.Services.AddOutputCache(); app.UseOutputCache(); [OutputCache(Duration = 60)] [HttpGet(\u0026#34;hot\u0026#34;)] public async Task\u0026lt;List\u0026lt;Article\u0026gt;\u0026gt; Hot() { ... } // 标记失效（数据变了主动清除缓存） _cache EvictTagAsync(\u0026#34;articles\u0026#34;); 4.3 响应压缩 1 2 3 4 5 6 7 builder.Services.AddResponseCompression(opt =\u0026gt; { opt.EnableForHttps = true; opt.Providers.Add\u0026lt;BrotliCompressionProvider\u0026gt;(); opt.Providers.Add\u0026lt;GzipCompressionProvider\u0026gt;(); }); app.UseResponseCompression(); // 自动压缩 JSON/HTML 等 4.4 异步 + 性能要点 1 2 3 4 5 6 7 ✓ 全异步（async/await），不阻塞线程 ✓ 数据库用 EF Core 异步 + AsNoTracking（只读查询） ✓ 避免 N+1 查询（Include / 拆分查询） ✓ 连接池配置合理 ✓ 减少 JSON 分配（System.Text.Json 高效、Utf8JsonWriter 流式） ✓ 大对象用 ArrayPool 复用 ✓ 开 Server GC（多核服务器） 五、部署 5.1 发布 1 2 3 4 5 6 7 8 9 10 11 # 框架依赖（需装运行时） dotnet publish -c Release -o ./publish # 独立部署（含运行时，目标机不用装 .NET） dotnet publish -c Release -r linux-x64 --self-contained -o ./publish # 单文件 dotnet publish -c Release -r linux-x64 --self-contained -p:PublishSingleFile=true # AOT（启动极快、单文件、无运行时） dotnet publish -c Release -r linux-x64 -p:PublishAot=true 5.2 systemd 服务（Linux） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # /etc/systemd/system/myapp.service [Unit] Description=My App After=network.target [Service] WorkingDirectory=/var/www/myapp ExecStart=/var/www/myapp/MyApp --urls http://0.0.0.0:5000 Restart=always Environment=ASPNETCORE_ENVIRONMENT=Production User=www-data [Install] WantedBy=multi-user.target 5.3 反向代理架构 1 2 3 4 5 6 客户端 → Nginx（80/443，HTTPS 终端）→ Kestrel（5000，HTTP） Nginx 负责：HTTPS、静态文件、限流、负载均衡 Kestrel 负责：ASP.NET Core 应用 配合 UseForwardedHeaders 让应用拿到真实 IP/协议 5.4 Docker 部署 1 2 3 4 5 6 7 8 9 10 FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY . . RUN dotnet publish -c Release -o /app FROM mcr.microsoft.com/dotnet/aspnet:8.0 WORKDIR /app COPY --from=build /app . EXPOSE 80 ENTRYPOINT [\u0026#34;dotnet\u0026#34;, \u0026#34;MyApp.dll\u0026#34;] 六、小结 过滤器：六种过滤器实现 AOP（日志、缓存、异常、授权） 后台服务：BackgroundService 做定时/消费任务，Worker Service 独立后台项目 健康检查：AddHealthChecks + MapHealthChecks，供 K8s/负载均衡探活 性能优化：ResponseCache/OutputCache、压缩、全异步、Server GC 部署：dotnet publish、systemd、Nginx 反代、Docker、AOT 系列总结 ASP.NET Core 五篇完结：\n基础架构：中间件管道 + 依赖注入（两大支柱） 路由与控制器：路由、模型绑定、验证、Minimal API 认证与授权：JWT/Cookie、Claims、策略授权 API 设计：RESTful、Swagger、错误处理、版本控制、序列化 进阶与生产：过滤器、后台服务、健康检查、性能、部署 核心心法：理解中间件管道 + 依赖注入 + Claims 身份模型，ASP.NET Core 就彻底通了。 剩下的都是基于这些的组合应用。\n","date":"2025-07-19T10:00:00+08:00","permalink":"/posts/dotnet/aspnetcore/05-advanced-production/","title":"ASP.NET Core 学习笔记（五）：进阶与生产实践"},{"content":"写在前面 本文讲 API 工程化：RESTful 设计规范、Swagger 文档、统一错误处理、版本控制、序列化。这些是 API 从\u0026quot;能跑\u0026quot;到\u0026quot;专业\u0026quot;的关键。\n一、RESTful API 设计 1.1 资源命名 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 ✓ 用名词复数表示资源集合 GET /api/users 获取列表 POST /api/users 新建 GET /api/users/123 获取单个 PUT /api/users/123 全量更新 PATCH /api/users/123 部分更新 DELETE /api/users/123 删除 ✗ 别用动词（RPC 风格） /api/getUser /api/createUser /api/deleteUserById ✓ 嵌套表达从属关系 GET /api/users/123/orders 用户 123 的订单 GET /api/orders/456 全局订单（订单也有独立资源） ✓ 查询参数做过滤/排序/分页 GET /api/users?role=admin\u0026amp;sort=created\u0026amp;desc\u0026amp;page=2\u0026amp;size=20 1.2 HTTP 方法语义 1 2 3 4 5 6 7 8 9 GET 安全、幂等、无副作用 （查询） POST 非幂等 （新建） PUT 幂等（全量替换） （更新） PATCH 非幂等（部分更新） （更新） DELETE 幂等 （删除） 幂等性：多次执行结果相同 GET/PUT/DELETE 幂等 POST/PATCH 非幂等 1.3 状态码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 2xx 成功 200 OK 请求成功（有 body） 201 Created 新建成功（带 Location 头） 204 No Content 成功无 body（DELETE 常用） 4xx 客户端错误 400 Bad Request 参数错误/验证失败 401 Unauthorized 未认证（没登录） 403 Forbidden 已认证但无权限 404 Not Found 资源不存在 409 Conflict 冲突（如重复创建） 422 Unprocessable 语义错误 429 Too Many 限流 5xx 服务端错误 500 Internal Error 服务器异常 502/503/504 网关/服务不可用/超时 1 2 3 401 vs 403 容易混： 401 — 你是谁？（没登录/Token 失效） 403 — 你没权限。（登录了，但权限不够） 二、Swagger / OpenAPI 2.1 基本接入 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 // 安装 Swashbuckle // dotnet add package Swashbuckle.AspNetCore builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c =\u0026gt; { c.SwaggerDoc(\u0026#34;v1\u0026#34;, new OpenApiInfo { Title = \u0026#34;My API\u0026#34;, Version = \u0026#34;v1\u0026#34; }); // JWT 认证支持 c.AddSecurityDefinition(\u0026#34;Bearer\u0026#34;, new OpenApiSecurityScheme { Description = \u0026#34;JWT Authorization header. 例：Bearer {token}\u0026#34;, Name = \u0026#34;Authorization\u0026#34;, In = ParameterLocation.Header, Type = SecuritySchemeType.Http, Scheme = \u0026#34;bearer\u0026#34; }); c.AddSecurityRequirement(new OpenApiSecurityRequirement { { new OpenApiSecurityScheme { Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = \u0026#34;Bearer\u0026#34; } }, Array.Empty\u0026lt;string\u0026gt;() } }); }); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } // 访问 /swagger 看交互式文档 2.2 XML 注释文档 1 2 3 4 5 \u0026lt;!-- .csproj 里启用 XML 文档 --\u0026gt; \u0026lt;PropertyGroup\u0026gt; \u0026lt;GenerateDocumentationFile\u0026gt;true\u0026lt;/GenerateDocumentationFile\u0026gt; \u0026lt;NoWarn\u0026gt;$(NoWarn);1591\u0026lt;/NoWarn\u0026gt; \u0026lt;!-- 忽略缺少注释的警告 --\u0026gt; \u0026lt;/PropertyGroup\u0026gt; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // Program.cs builder.Services.AddSwaggerGen(c =\u0026gt; { var xmlFile = $\u0026#34;{Assembly.GetExecutingAssembly().GetName().Name}.xml\u0026#34;; var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); c.IncludeXmlComments(xmlPath); }); /// \u0026lt;summary\u0026gt;获取用户信息\u0026lt;/summary\u0026gt; /// \u0026lt;param name=\u0026#34;id\u0026#34;\u0026gt;用户 ID\u0026lt;/param\u0026gt; /// \u0026lt;returns\u0026gt;用户详情\u0026lt;/returns\u0026gt; /// \u0026lt;response code=\u0026#34;200\u0026#34;\u0026gt;返回用户\u0026lt;/response\u0026gt; /// \u0026lt;response code=\u0026#34;404\u0026#34;\u0026gt;用户不存在\u0026lt;/response\u0026gt; [HttpGet(\u0026#34;{id}\u0026#34;)] [ProducesResponseType(typeof(User), 200)] [ProducesResponseType(404)] public ActionResult\u0026lt;User\u0026gt; Get(int id) { ... } 三、统一错误处理 3.1 异常处理中间件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 // 全局异常兜底（放管道最前面） public class ExceptionMiddleware { private readonly RequestDelegate _next; private readonly ILogger\u0026lt;ExceptionMiddleware\u0026gt; _logger; public ExceptionMiddleware(RequestDelegate next, ILogger\u0026lt;ExceptionMiddleware\u0026gt; logger) { _next = next; _logger = logger; } public async Task InvokeAsync(HttpContext context) { try { await _next(context); } catch (Exception ex) { _logger.LogError(ex, \u0026#34;未处理异常 {Path}\u0026#34;, context.Request.Path); context.Response.StatusCode = 500; context.Response.ContentType = \u0026#34;application/json\u0026#34;; await context.Response.WriteAsync(JsonSerializer.Serialize(new { code = 500, message = app.Environment.IsDevelopment() ? ex.Message : \u0026#34;服务器内部错误\u0026#34; })); } } } app.UseMiddleware\u0026lt;ExceptionMiddleware\u0026gt;(); // 注册（最前面） 3.2 ProblemDetails（标准错误格式） ASP.NET Core 内置的标准化错误响应（RFC 7807）：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // [ApiController] 自带的验证错误就是 ProblemDetails 格式 // { \u0026#34;type\u0026#34;: \u0026#34;...\u0026#34;, \u0026#34;title\u0026#34;: \u0026#34;Validation error\u0026#34;, \u0026#34;status\u0026#34;: 400, ... } // 自定义返回 [HttpGet(\u0026#34;{id}\u0026#34;)] public IActionResult Get(int id) { var user = _service.Get(id); if (user == null) return NotFound(new ProblemDetails { Title = \u0026#34;用户不存在\u0026#34;, Status = 404, Detail = $\u0026#34;ID 为 {id} 的用户不存在\u0026#34; }); return Ok(user); } 3.3 自定义异常 + 映射 1 2 3 4 5 6 7 8 9 10 11 12 // 业务异常 public class NotFoundException : Exception { public NotFoundException(string msg) : base(msg) { } } // 异常→状态码映射中间件 catch (NotFoundException ex) { context.Response.StatusCode = 404; // 返回 ProblemDetails } 3.4 统一响应包装（可选） 有些团队喜欢所有响应统一格式：\n1 2 3 4 5 6 7 8 9 public class ApiResponse\u0026lt;T\u0026gt; { public int Code { get; set; } public string Message { get; set; } public T Data { get; set; } } // 但 RESTful 纯粹派认为：用 HTTP 状态码即可，不需要再包一层 code // 两种风格都常见，团队统一即可 四、版本控制 API 演进时需要版本控制，避免破坏老客户端。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // 安装：Asp.Versioning.Mvc builder.Services.AddApiVersioning(options =\u0026gt; { options.DefaultApiVersion = new ApiVersion(1, 0); options.AssumeDefaultVersionWhenUnspecified = true; options.ReportApiVersions = true; // 响应头显示支持版本 options.ApiVersionReader = new UrlSegmentApiVersionReader(); // URL 段 // 也可：Query string / Header }); // 控制器标版本 [ApiController] [ApiVersion(\u0026#34;1.0\u0026#34;)] [Route(\u0026#34;api/v{version:apiVersion}/users\u0026#34;)] public class UsersV1Controller : ControllerBase { ... } [ApiController] [ApiVersion(\u0026#34;2.0\u0026#34;)] [Route(\u0026#34;api/v{version:apiVersion}/users\u0026#34;)] public class UsersV2Controller : ControllerBase { ... } // /api/v1/users → V1 // /api/v2/users → V2 五、序列化（System.Text.Json） 5.1 配置 1 2 3 4 5 6 7 8 9 builder.Services.AddControllers() .AddJsonOptions(options =\u0026gt; { options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; options.JsonSerializerOptions.WriteIndented = false; options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; // null 不输出 options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); // 枚举转字符串 }); 5.2 常用特性 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class User { [JsonPropertyName(\u0026#34;user_id\u0026#34;)] // 自定义 JSON 字段名 public int Id { get; set; } [JsonIgnore] // 不序列化 public string Password { get; set; } [JsonPropertyOrder(1)] // 排序 public string Name { get; set; } } // 循环引用（EF Core 导航属性常见） [JsonReferenceHandler(ReferenceHandler.IgnoreCycles)] public class Order { ... } 5.3 驼峰与中文 1 2 3 4 5 6 7 默认 PascalCase（C# 风格）序列化后还是 PascalCase？ 前端通常要 camelCase → 配 PropertyNamingPolicy = CamelCase C# 的 UserId → JSON 的 userId 中文转义问题： 默认中文会被转义成 \\uXXXX 加 Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) 保留中文 六、小结 RESTful：名词资源、HTTP 方法语义、正确状态码（401 vs 403） Swagger：Swashbuckle 接入，XML 注释 + JWT 支持 错误处理：全局异常中间件 + ProblemDetails 标准格式 版本控制：URL 段 / Query / Header，ApiVersioning 库 序列化：System.Text.Json，camelCase、忽略 null、枚举字符串 下一篇讲进阶：过滤器、后台服务、健康检查、性能优化、部署。\n","date":"2025-07-15T10:00:00+08:00","permalink":"/posts/dotnet/aspnetcore/04-api-design/","title":"ASP.NET Core 学习笔记（四）：API 设计与错误处理"},{"content":"写在前面 本文讲 ASP.NET Core 的安全核心：认证（Authentication）和授权（Authorization）。这两个词常被混用，但在框架里是两个明确分开的阶段。理解它们才能做好登录、JWT、权限控制。\n一、认证 vs 授权 1 2 3 4 5 6 7 8 9 10 11 12 13 认证（Authentication）= 你是谁？ 验证用户身份（用户名密码、Token、证书） 填充 HttpContext.User \u0026#34;张三登录成功\u0026#34; 授权（Authorization）= 你能干什么？ 基于已认证的身份，判断能否访问资源 读取 HttpContext.User 做决策 \u0026#34;张三是管理员，可以删用户\u0026#34; 执行顺序（中间件顺序）： UseAuthentication → UseAuthorization 先认证（你是谁）→ 再授权（能干嘛） 二、Claims 模型（身份的核心） ASP.NET Core 用 Claims 模型表示身份，理解它很关键。\n1 2 3 4 5 6 7 8 9 10 11 12 13 // Claim = 一条身份信息（键值对） new Claim(ClaimTypes.Name, \u0026#34;张三\u0026#34;) new Claim(ClaimTypes.Email, \u0026#34;zhang@test.com\u0026#34;) new Claim(ClaimTypes.Role, \u0026#34;Admin\u0026#34;) // ClaimsIdentity = 一组 Claim + 认证类型 var identity = new ClaimsIdentity(claims, \u0026#34;MyAuthScheme\u0026#34;); // ClaimsPrincipal = 一个用户（可含多个 Identity） var principal = new ClaimsPrincipal(identity); // 赋值给 HttpContext（认证完成） HttpContext.User = principal; 1 2 3 4 5 6 层级关系： ClaimsPrincipal（用户） └── ClaimsIdentity（一种身份，如 JWT 身份） └── Claim（一条信息，如姓名、角色） 用户的 Name、Role 都是从 Claim 读出来的 三、JWT 认证（最常用） JSON Web Token，无状态、跨域、适合前后端分离和微服务。\n3.1 配置 1 2 3 4 5 6 7 8 9 // appsettings.json { \u0026#34;Jwt\u0026#34;: { \u0026#34;Issuer\u0026#34;: \u0026#34;jiwei.space\u0026#34;, \u0026#34;Audience\u0026#34;: \u0026#34;jiwei.space\u0026#34;, \u0026#34;SigningKey\u0026#34;: \u0026#34;this-is-a-very-long-secret-key-at-least-32-chars\u0026#34;, \u0026#34;ExpiresMinutes\u0026#34;: 120 } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 // Program.cs var builder = WebApplication.CreateBuilder(args); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options =\u0026gt; { var jwt = builder.Configuration.GetSection(\u0026#34;Jwt\u0026#34;).Get\u0026lt;JwtOptions\u0026gt;()!; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = jwt.Issuer, ValidateAudience = true, ValidAudience = jwt.Audience, ValidateLifetime = true, // 验证过期 ValidateIssuerSigningKey = true, // 验证签名 IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(jwt.SigningKey)), ClockSkew = TimeSpan.Zero // 不允许时间偏差 }; }); builder.Services.AddAuthorization(); var app = builder.Build(); app.UseAuthentication(); // 先认证 app.UseAuthorization(); // 后授权 3.2 颁发 Token（登录） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 public class AuthService { private readonly JwtOptions _jwt; public AuthService(IOptions\u0026lt;JwtOptions\u0026gt; opt) =\u0026gt; _jwt = opt.Value; public string CreateToken(User user) { var claims = new List\u0026lt;Claim\u0026gt; { new(ClaimTypes.NameIdentifier, user.Id.ToString()), new(ClaimTypes.Name, user.Username), new(ClaimTypes.Role, user.Role) }; var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.SigningKey)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: _jwt.Issuer, audience: _jwt.Audience, claims: claims, expires: DateTime.UtcNow.AddMinutes(_jwt.ExpiresMinutes), signingCredentials: creds); return new JwtSecurityTokenHandler().WriteToken(token); } } // 登录接口 [HttpPost(\u0026#34;login\u0026#34;)] public ActionResult Login(LoginDto dto) { var user = _userService.Verify(dto.Username, dto.Password); if (user == null) return Unauthorized(); var token = _auth.CreateToken(user); return Ok(new { token }); } 3.3 客户端使用 1 2 3 4 5 客户端登录拿到 token → 存 localStorage/cookie → 后续请求带上： Authorization: Bearer eyJhbGciOi... 框架自动验证 token，验证通过填充 HttpContext.User 3.4 JWT 结构 1 2 3 4 5 6 7 8 9 10 header.payload.signature（三段 Base64URL，用 . 分隔） header = 算法（HS256）和类型（JWT） payload = claims（用户信息、过期时间） signature = 用密钥对 header.payload 签名（防篡改） 特点： ✓ 无状态（服务端不存 session，靠签名验证） ✓ 可跨服务（微服务共享密钥即可验证） ✗ 无法主动失效（签发后到过期前一直有效，靠 Refresh Token / 黑名单缓解） 四、Cookie 认证（传统 Web） 适合 MVC/Razor Pages 这类服务端渲染、浏览器访问的场景。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options =\u0026gt; { options.LoginPath = \u0026#34;/login\u0026#34;; // 未登录跳转 options.LogoutPath = \u0026#34;/logout\u0026#34;; options.ExpireTimeSpan = TimeSpan.FromDays(7); options.SlidingExpiration = true; // 滑动过期 }); // 登录（创建 Cookie） public async Task Login(string username, string password) { var user = _userService.Verify(username, password); if (user == null) return; var claims = new[] { new Claim(ClaimTypes.Name, user.Username) }; var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); await HttpContext.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity)); } // 注销 await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); 1 2 3 4 5 6 JWT vs Cookie： JWT — 无状态、API/前后端分离、移动端、微服务 Cookie — 有状态、浏览器、MVC、SSO 前后端分离 → JWT 传统 Web → Cookie 五、授权 认证解决\u0026quot;你是谁\u0026quot;，授权解决\u0026quot;能干什么\u0026quot;。\n5.1 基础授权 1 2 3 4 5 6 7 8 9 10 [Authorize] // 必须登录 public class UsersController { ... } [Authorize] [HttpGet(\u0026#34;profile\u0026#34;)] public UserProfile GetProfile() { ... } [AllowAnonymous] // 允许匿名（覆盖 Authorize） [HttpPost(\u0026#34;register\u0026#34;)] public User Register() { ... } 5.2 角色授权 1 2 3 4 5 6 [Authorize(Roles = \u0026#34;Admin\u0026#34;)] [HttpDelete(\u0026#34;{id}\u0026#34;)] public void Delete(int id) { ... } // 只有 Admin 角色能访问 [Authorize(Roles = \u0026#34;Admin,Manager\u0026#34;)] // Admin 或 Manager public void Manage() { ... } 1 2 3 角色怎么来：登录时把角色写进 Claim new Claim(ClaimTypes.Role, \u0026#34;Admin\u0026#34;) 授权时框架从 User.Claims 读角色比对 5.3 策略授权（推荐，最强大） 基于策略，比角色灵活——可以组合多个条件、自定义逻辑。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 1. 定义策略（包含要求 Requirement） builder.Services.AddAuthorization(options =\u0026gt; { options.AddPolicy(\u0026#34;AtLeast18\u0026#34;, policy =\u0026gt; policy.RequireClaim(\u0026#34;Age\u0026#34;).RequireAssertion(ctx =\u0026gt; int.Parse(ctx.User.FindFirst(\u0026#34;Age\u0026#34;)!.Value) \u0026gt;= 18)); options.AddPolicy(\u0026#34;AdminOnly\u0026#34;, policy =\u0026gt; policy.RequireRole(\u0026#34;Admin\u0026#34;)); // 组合策略 options.AddPolicy(\u0026#34;SeniorAdmin\u0026#34;, policy =\u0026gt; policy.RequireRole(\u0026#34;Admin\u0026#34;).RequireClaim(\u0026#34;Department\u0026#34;, \u0026#34;IT\u0026#34;)); }); // 2. 使用 [Authorize(Policy = \u0026#34;AtLeast18\u0026#34;)] [HttpGet(\u0026#34;adult-content\u0026#34;)] public Content GetAdult() { ... } 5.4 自定义授权要求 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 // 1. 定义要求 public class MinimumAgeRequirement : IAuthorizationRequirement { public int MinimumAge { get; } public MinimumAgeRequirement(int min) =\u0026gt; MinimumAge = min; } // 2. 定义处理器（业务逻辑） public class MinimumAgeHandler : AuthorizationHandler\u0026lt;MinimumAgeRequirement\u0026gt; { protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, MinimumAgeRequirement requirement) { var ageClaim = context.User.FindFirst(\u0026#34;Age\u0026#34;); if (ageClaim != null \u0026amp;\u0026amp; int.Parse(ageClaim.Value) \u0026gt;= requirement.MinimumAge) { context.Succeed(requirement); // 满足 } return Task.CompletedTask; } } // 3. 注册 builder.Services.AddSingleton\u0026lt;IAuthorizationHandler, MinimumAgeHandler\u0026gt;(); builder.Services.AddAuthorization(options =\u0026gt; { options.AddPolicy(\u0026#34;Over18\u0026#34;, policy =\u0026gt; policy.Requirements.Add(new MinimumAgeRequirement(18))); }); // 4. 使用 [Authorize(Policy = \u0026#34;Over18\u0026#34;)] public IActionResult Drink() { ... } 5.5 基于资源的授权 授权决策依赖具体资源（如\u0026quot;只能编辑自己的文章\u0026quot;）：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public class ArticleOwnerHandler : AuthorizationHandler\u0026lt;ArticleOwnerRequirement, Article\u0026gt; { protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, ArticleOwnerRequirement req, Article article) { var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier); if (article.AuthorId == userId) context.Succeed(req); return Task.CompletedTask; } } // 使用（在 Action 里） [HttpGet(\u0026#34;{id}/edit\u0026#34;)] public async Task\u0026lt;IActionResult\u0026gt; Edit(int id) { var article = _service.Get(id); var success = await _authorization.AuthorizeAsync(User, article, \u0026#34;OwnerPolicy\u0026#34;); if (!success.Succeeded) return Forbid(); return Ok(article); } 六、安全最佳实践 1 2 3 4 5 6 7 8 9 ✓ 密码用 BCrypt/PBKDF2/Argon2 哈希，绝不存明文 ✓ JWT 密钥足够长（≥256bit），放配置/密钥库，不进代码 ✓ HTTPS 强制（UseHttpsRedirection + HSTS） ✓ Cookie 设 HttpOnly、Secure、SameSite ✓ 防 CSRF（Cookie 场景用 AntiForgeryToken） ✓ 防 XSS（输出转义、CSP 头） ✓ 限流（防暴力破解登录） ✓ 敏感操作要二次验证 ✓ 日志记录认证/授权事件 七、小结 认证 vs 授权：你是谁 / 能干什么，中间件顺序 UseAuthentication → UseAuthorization Claims 模型：Principal → Identity → Claim，身份的核心表示 JWT：无状态、API 首选；Cookie：有状态、传统 Web 授权：[Authorize] → 角色授权 → 策略授权（最强大）→ 基于资源授权 下一篇讲 API 设计：RESTful 规范、Swagger 文档、统一错误处理、版本控制。\n","date":"2025-07-11T10:00:00+08:00","permalink":"/posts/dotnet/aspnetcore/03-authentication-authorization/","title":"ASP.NET Core 学习笔记（三）：认证与授权"},{"content":"写在前面 本文是 ASP.NET Core 系列第二篇，讲清楚三件事：URL 如何匹配到代码（路由）、请求数据如何变成参数（模型绑定）、参数如何校验（验证）。这是 API 开发每天用的东西。\n一、路由 1.1 两种路由 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 特性路由（Attribute Routing）— API 推荐 [ApiController] [Route(\u0026#34;api/[controller]\u0026#34;)] // [controller] 占位符替换为控制器名 public class UsersController : ControllerBase { [HttpGet(\u0026#34;{id}\u0026#34;)] // GET /api/users/123 public User Get(int id) =\u0026gt; ...; [HttpPost] // POST /api/users public User Create([FromBody] User user) =\u0026gt; ...; [HttpPut(\u0026#34;{id}\u0026#34;)] // PUT /api/users/123 public void Update(int id, [FromBody] User user) =\u0026gt; ...; [HttpDelete(\u0026#34;{id}\u0026#34;)] // DELETE /api/users/123 public void Delete(int id) =\u0026gt; ...; } // 约定路由（Convention Routing）— MVC/Razor Pages 用 app.MapControllerRoute( name: \u0026#34;default\u0026#34;, pattern: \u0026#34;{controller=Home}/{action=Index}/{id?}\u0026#34;); 1 2 API 用特性路由（清晰、灵活、RESTful） 传统 MVC 用约定路由 1.2 路由模板与约束 1 2 3 4 5 [HttpGet(\u0026#34;users/{id:int}\u0026#34;)] // 只匹配整数 id [HttpGet(\u0026#34;users/{id:int:min(1)}\u0026#34;)] // id ≥ 1 [HttpGet(\u0026#34;files/{name}.{ext}\u0026#34;)] // /files/a.txt [HttpGet(\u0026#34;search/{*query}\u0026#34;)] // * 是 catch-all，匹配剩余路径 [HttpGet(\u0026#34;posts/{year:int}/{month:range(1,12)}\u0026#34;)] 1 2 3 4 5 6 7 常用约束： :int :long :double :bool 类型 :min() :max() :range() 范围 :minlength() :maxlength() 长度 :regex() 正则 :alpha 字母 :guid GUID 1.3 路由数据 1 2 3 4 5 6 // 路由参数自动绑定到方法参数（同名） [HttpGet(\u0026#34;{id}\u0026#34;)] public User Get(int id) { ... } // id 从路由 {id} 取 // 也可从 HttpContext 取路由值 var id = RouteData.Values[\u0026#34;id\u0026#34;]; 1.4 路由匹配原理 1 2 3 4 5 请求进来 → UseRouting 匹配端点 → UseAuthorization 检查权限 → 执行端点 路由匹配在 UseRouting 阶段完成（端点路由 Endpoint Routing） 匹配出\u0026#34;端点\u0026#34;（Endpoint）= 控制器 + Action 元信息 后续中间件可以基于匹配到的端点做事（如授权检查） 二、控制器 2.1 API 控制器 1 2 3 4 5 6 7 [ApiController] // 启用 API 专属行为 [Route(\u0026#34;api/[controller]\u0026#34;)] public class UsersController : ControllerBase // 注意是 ControllerBase 不是 Controller { // ControllerBase 提供了 API 常用成员，不含 View 支持 // Controller（MVC）才支持 View，API 不需要 } 1 2 3 4 5 6 [ApiController] 特性的作用（重要）： ✓ 自动 400 响应（模型验证失败直接返回 400，不用手动检查 ModelState） ✓ 推断绑定源（[FromBody] 等可省略） ✓ 多部分/form 绑定推断 ✓ 问题详情（ProblemDetails）错误格式 ✓ 必填路由参数检查 2.2 Action 返回类型 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 // 简单返回（直接返回对象，自动序列化为 JSON） [HttpGet(\u0026#34;{id}\u0026#34;)] public User Get(int id) =\u0026gt; _service.Get(id); // ActionResult（可返回不同状态码） [HttpPost] public ActionResult\u0026lt;User\u0026gt; Create(User user) { var created = _service.Create(user); return CreatedAtAction(nameof(Get), new { id = created.Id }, created); // 返回 201 + Location 头 + body } // IActionResult（最灵活） [HttpGet(\u0026#34;{id}\u0026#34;)] public IActionResult Get(int id) { var user = _service.Get(id); if (user == null) return NotFound(); // 404 return Ok(user); // 200 } // ActionResult\u0026lt;T\u0026gt;（推荐，类型安全 + 灵活） public ActionResult\u0026lt;User\u0026gt; Get(int id) { var user = _service.Get(id); return user == null ? NotFound() : user; } 2.3 常用返回辅助方法 1 2 3 4 5 6 7 8 9 10 Ok(obj) 200 Created(location,obj) 201（新建资源） CreatedAtAction(...) 201 + Location NoContent() 204（成功无内容） NotFound() 404 BadRequest(obj) 400 Unauthorized() 401 Forbid() 403 Conflict(obj) 409 StatusCode(code,obj) 自定义状态码 三、Minimal API .NET 6+ 引入的轻量写法，不用 Controller，直接在 Program.cs 写端点。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 var builder = WebApplication.CreateBuilder(args); builder.Services.AddScoped\u0026lt;IUserService, UserService\u0026gt;(); var app = builder.Build(); // 一行定义一个端点 app.MapGet(\u0026#34;/api/users/{id}\u0026#34;, async (int id, IUserService svc) =\u0026gt; { var user = await svc.GetAsync(id); return user is null ? Results.NotFound() : Results.Ok(user); }); app.MapPost(\u0026#34;/api/users\u0026#34;, async (User user, IUserService svc) =\u0026gt; { var created = await svc.CreateAsync(user); return Results.Created($\u0026#34;/api/users/{created.Id}\u0026#34;, created); }); app.Run(); 1 2 3 4 5 6 Controller vs Minimal API： Controller — 适合大型项目、复杂业务、团队协作、AOP（过滤器） Minimal API — 适合小型项目、微服务、快速原型 共同点：都跑在端点路由上，模型绑定、DI 都一样 Minimal API 性能略好（少一层 Controller 激活） 四、模型绑定 把请求数据（body / query / route / header）转换成方法参数。\n4.1 绑定来源 1 2 3 4 5 6 7 8 9 10 11 12 13 14 [HttpGet(\u0026#34;{id}\u0026#34;)] public User Get([FromRoute] int id) { ... } // 路由参数 [HttpGet] public List\u0026lt;User\u0026gt; Search([FromQuery] string name, [FromQuery] int age) { ... } // GET /api/users?name=张三\u0026amp;age=25 [HttpPost] public User Create([FromBody] User user) { ... } // 请求体（JSON） [HttpGet] public User Get([FromHeader] string token) { ... } // 请求头 public void Do([FromServices] IUserService svc) { ... } // 从 DI 容器 1 2 [ApiController] 启用后，复杂类型默认 FromBody，简单类型默认 FromQuery/Route 通常可省略特性，但显式写更清晰 4.2 复杂类型绑定 1 2 3 4 5 6 7 8 9 10 // Query 自动绑定到对象 // GET /api/users?name=张三\u0026amp;age=25 [HttpGet] public List\u0026lt;User\u0026gt; Search([FromQuery] UserQuery query) { ... } public class UserQuery { public string Name { get; set; } public int Age { get; set; } } 4.3 自定义绑定 1 2 3 4 5 6 7 8 9 10 11 12 13 // 自定义模型绑定器（复杂场景） public class TrimModelBinder : IModelBinder { public Task BindModelAsync(ModelBindingContext bindingContext) { var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); if (value != ValueProviderResult.None) { bindingContext.Result = ModelBindingResult.Success(value.ToString().Trim()); } return Task.CompletedTask; } } 五、参数验证 5.1 DataAnnotations（内置） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class CreateUserDto { [Required] public string Username { get; set; } [Required][EmailAddress] public string Email { get; set; } [Required][StringLength(20, MinimumLength = 6)] public string Password { get; set; } [Range(0, 150)] public int Age { get; set; } [Url] public string Website { get; set; } [RegularExpression(@\u0026#34;^1\\d{10}$\u0026#34;)] public string Phone { get; set; } } [HttpPost] public ActionResult\u0026lt;User\u0026gt; Create(CreateUserDto dto) { // [ApiController] 会自动验证，失败直接 400，不用手动检查 return _service.Create(dto); } 1 2 3 4 常用验证特性： [Required] [StringLength] [Range] [MinLength] [MaxLength] [EmailAddress] [Url] [Phone] [RegularExpression] [Compare]（和另一属性比较，如密码确认） 5.2 手动检查 ModelState（非 ApiController 时） 1 2 3 4 5 6 7 [HttpPost] public ActionResult Create(CreateUserDto dto) { if (!ModelState.IsValid) return BadRequest(ModelState); // 返回验证错误详情 // ... } 5.3 FluentValidation（复杂验证） 1 2 3 4 5 6 7 8 9 10 11 12 13 // 安装：FluentValidation.AspNetCore public class CreateUserDtoValidator : AbstractValidator\u0026lt;CreateUserDto\u0026gt; { public CreateUserDtoValidator() { RuleFor(x =\u0026gt; x.Username).NotEmpty().Length(3, 20); RuleFor(x =\u0026gt; x.Email).NotEmpty().EmailAddress(); RuleFor(x =\u0026gt; x.Password).NotEmpty().MinimumLength(8) .Must(p =\u0026gt; p.Any(char.IsDigit)).WithMessage(\u0026#34;密码必须包含数字\u0026#34;); RuleFor(x =\u0026gt; x.Age).InclusiveBetween(0, 150); } } // 自动接入 ModelState，和 DataAnnotations 一样自动 400 1 2 3 4 DataAnnotations vs FluentValidation： DataAnnotations — 简单、内置、声明式、适合简单规则 FluentValidation — 强大、流式、易测试、适合复杂业务规则 大型项目推荐 FluentValidation 六、小结 路由：API 用特性路由（[Route] + [HttpGet]），支持模板和约束 控制器：API 继承 ControllerBase，加 [ApiController] 享便利（自动 400、推断绑定） Minimal API：轻量端点写法，适合小项目/微服务 模型绑定：[FromRoute]/[FromQuery]/[FromBody]/[FromHeader]/[FromServices] 验证：DataAnnotations（简单）或 FluentValidation（复杂），自动 400 下一篇讲认证与授权：JWT、Cookie、基于策略的权限控制。\n","date":"2025-07-07T10:00:00+08:00","permalink":"/posts/dotnet/aspnetcore/02-routing-controllers/","title":"ASP.NET Core 学习笔记（二）：路由与控制器"},{"content":"写在前面 本文是 ASP.NET Core 学习笔记系列的第一篇。ASP.NET Core 是 .NET 后端的核心框架，但它和老的 ASP.NET（Framework）差别极大——完全重写、跨平台、高性能、模块化。很多人用着用着会觉得\u0026quot;能跑但不清楚为什么这么设计\u0026quot;。\n本系列从架构层面讲透 ASP.NET Core。第一篇聚焦最核心的两件事：中间件管道和依赖注入——理解了这两个，整个框架就通了。\n版本说明：基于 .NET 8 LTS。从 .NET 6 起统一用 Program.cs（top-level statements），不再有 Startup.cs。\n一、ASP.NET Core 是什么 1.1 定义 ASP.NET Core 是微软的开源、跨平台、高性能 Web 框架，用来构建 Web 应用、API、微服务、实时应用（SignalR）等。\n1 2 3 4 5 6 7 8 9 10 11 它能构建： - Web API（RESTful 后端，最常见） - Web 应用（MVC、Razor Pages、Blazor） - 实时应用（SignalR、WebSocket） - 后台服务（Worker Service） - gRPC 服务 - 微服务 跨平台： Windows / Linux / macOS 都能跑 部署到裸机、容器、K8s、云函数都行 1.2 和老 ASP.NET 的区别 1 2 3 4 5 6 7 8 9 10 ASP.NET (Framework) ASP.NET Core ──────────────────────────────────────────────────────────────── 平台 仅 Windows 跨平台 运行时 .NET Framework .NET Core / 5+ Web 服务器 IIS Kestrel（内置）+ 反向代理 配置 web.config 多源配置（json/env/命令行） 依赖注入 需第三方 内置（一等公民） 性能 一般 极高（TechEmpower 前列） 模块化 整体框架 按需引入 NuGet 包 开源 否 是（MIT） 1.3 设计哲学 ASP.NET Core 的所有设计围绕两个核心：\n1 2 3 4 5 6 7 8 9 10 11 1. 中间件管道（Middleware Pipeline） 请求像流水线一样依次经过一个个中间件 每个中间件处理一件事（认证、日志、路由、CORS...） 组合优于继承，按需组装 2. 依赖注入（Dependency Injection）无处不在 所有组件通过 DI 容器管理 面向接口编程，解耦 内置 DI 容器，无需第三方 理解这两点 = 理解 ASP.NET Core 的一切 二、Program.cs 与启动流程 2.1 最简启动 .NET 6+ 之后，Program.cs 用 top-level statements，几行就能启动一个 API：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 // Program.cs var builder = WebApplication.CreateBuilder(args); // 注册服务（DI） builder.Services.AddControllers(); var app = builder.Build(); // 配置中间件管道 app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run(); // 启动，开始监听 这五行做了非常多的事。拆开看。\n2.2 启动流程详解 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 1. WebApplication.CreateBuilder(args) 创建 WebApplicationBuilder，预配置： - Kestrel（Web 服务器） - 配置系统（appsettings.json、环境变量、命令行） - 日志系统 - 依赖注入容器 - 泛型 Host → 返回 builder，你通过它继续配置 2. builder.Services.AddXxx() 向 DI 容器注册服务 这里注册的东西，后面能通过构造函数注入 例：AddControllers、AddDbContext、AddSwagger、AddHttpClient 3. builder.Build() 构建 WebApplication 实例 此后不能再注册服务（容器已固化） 转入\u0026#34;配置中间件\u0026#34;阶段 4. app.UseXxx() / app.MapXxx() 组装中间件管道、映射端点 顺序很重要！（后面详述） 5. app.Run() 启动应用，开始监听 HTTP 请求 阻塞主线程，直到应用停止 1 2 3 4 5 关键分水岭：builder.Build() Build 之前 → 注册阶段（配服务、配配置） Build 之后 → 运行阶段（配中间件、配管道） 不能在 Build 后注册服务，不能在 Build 前加中间件 2.3 两个阶段的清晰对照 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 var builder = WebApplication.CreateBuilder(args); // ========== 注册阶段（builder）========== builder.Services.AddControllers(); builder.Services.AddDbContext\u0026lt;AppDb\u0026gt;(opt =\u0026gt; opt.UseSqlServer(\u0026#34;...\u0026#34;)); builder.Services.AddScoped\u0026lt;IUserService, UserService\u0026gt;(); builder.Logging.AddConsole(); var app = builder.Build(); // ← 分水岭 // ========== 管道阶段（app）========== app.UseExceptionHandler(); app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run(); // ← 启动 三、中间件管道（核心） 中间件管道是 ASP.NET Core 处理请求的主干道。每个请求依次穿过所有中间件，响应再原路返回。\n3.1 管道模型 1 2 3 4 5 6 7 8 9 10 请求 → [中间件1] → [中间件2] → [中间件3] → [端点处理] → 返回 │ │ │ │ │ │ │ │ 响应 ← [中间件1] ← [中间件2] ← [中间件3] ←─────┘ 每个中间件： - 处理请求（进）→ 调用下一个 → 处理响应（出） - 可以决定是否调用下一个（短路） 像洋葱模型：层层进入，层层出来 3.2 三种写法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // 写法一：app.Use —— 最通用，可以调用下一个 app.Use(async (context, next) =\u0026gt; { // 进：处理请求（before） Console.WriteLine(\u0026#34;请求进来\u0026#34;); await next(); // 调用下一个中间件 // 出：处理响应（after） Console.WriteLine(\u0026#34;响应出去\u0026#34;); }); // 写法二：app.Run —— 终结中间件，不调用下一个（管道终点） app.Run(async context =\u0026gt; { await context.Response.WriteAsync(\u0026#34;Hello\u0026#34;); }); // 写法三：app.Map —— 按路径分支 app.Map(\u0026#34;/api\u0026#34;, apiApp =\u0026gt; { apiApp.Run(async ctx =\u0026gt; await ctx.Response.WriteAsync(\u0026#34;API\u0026#34;)); }); 3.3 顺序非常重要 中间件的注册顺序决定执行顺序，错了会导致功能异常：\n1 2 3 4 5 6 7 8 9 10 11 // ✅ 正确顺序（官方推荐） app.UseExceptionHandler(); // 1. 异常处理（最外层，兜底） app.UseHsts(); // 2. HSTS app.UseHttpsRedirection(); // 3. HTTP→HTTPS app.UseStaticFiles(); // 4. 静态文件 app.UseRouting(); // 5. 路由匹配 app.UseCors(); // 6. CORS app.UseAuthentication(); // 7. 认证（谁是谁） app.UseAuthorization(); // 8. 授权（能干什么） app.UseSession(); // 9. Session（按需） app.MapControllers(); // 10. 端点执行 1 2 3 4 5 6 7 8 9 10 11 为什么顺序重要： UseRouting 必须在 UseAuthorization 之前 → 先匹配出端点，才能对端点做授权检查 UseAuthentication 必须在 UseAuthorization 之前 → 先认证身份（填充 User），才能判断权限 UseExceptionHandler 必须在最前面 → 任何中间件抛异常都能被它兜住 记忆：异常 → 协议 → 静态 → 路由 → CORS → 认证 → 授权 → 端点 3.4 自定义中间件 复杂中间件建议封装成类，而不是内联 lambda：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 // 自定义中间件类（约定：有 InvokeAsync 方法） public class RequestTimingMiddleware { private readonly RequestDelegate _next; private readonly ILogger\u0026lt;RequestTimingMiddleware\u0026gt; _logger; public RequestTimingMiddleware(RequestDelegate next, ILogger\u0026lt;RequestTimingMiddleware\u0026gt; logger) { _next = next; _logger = logger; } public async Task InvokeAsync(HttpContext context) { var sw = Stopwatch.StartNew(); try { await _next(context); // 调下一个 } finally { sw.Stop(); _logger.LogInformation(\u0026#34;{Method} {Path} 耗时 {Ms}ms\u0026#34;, context.Request.Method, context.Request.Path, sw.ElapsedMilliseconds); } } } // 扩展方法（优雅注册） public static class RequestTimingExtensions { public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app) { return app.UseMiddleware\u0026lt;RequestTimingMiddleware\u0026gt;(); } } // Program.cs 里用 app.UseRequestTiming(); 四、依赖注入（核心） ASP.NET Core 的 DI 是一等公民——所有组件都通过 DI 管理。理解 DI 是写出松耦合、可测试代码的关键。\n4.1 三种生命周期 1 2 3 4 5 6 7 8 9 10 11 12 // 1. Transient —— 每次请求都新建（最轻量，最常用） builder.Services.AddTransient\u0026lt;IEmailSender, SmtpEmailSender\u0026gt;(); // 每次注入都拿新实例 // 2. Scoped —— 每个 HTTP 请求一个（请求内共享） builder.Services.AddScoped\u0026lt;IUserService, UserService\u0026gt;(); // 同一个 HTTP 请求内拿到同一个实例，请求结束释放 // EF Core 的 DbContext 默认是 Scoped // 3. Singleton —— 全局唯一（应用生命周期） builder.Services.AddSingleton\u0026lt;IClock, SystemClock\u0026gt;(); // 所有请求共享一个实例，应用停止才释放 1 2 3 4 5 6 7 8 生命周期选择： Transient → 轻量、无状态的服务 Scoped → 需要在一次请求内共享（DbContext、当前用户上下文） Singleton → 全局共享、线程安全、重资源（缓存、配置、连接池） ⚠️ 陷阱： Singleton 不能依赖 Scoped 服务（Scoped 在请求间释放，Singleton 持有会出错） 反过来可以：Scoped 可以依赖 Singleton/Transient 4.2 注册方式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // 接口到实现 builder.Services.AddScoped\u0026lt;IUserService, UserService\u0026gt;(); // 多实现（注入时用 IEnumerable\u0026lt;IUserService\u0026gt; 拿全部） builder.Services.AddScoped\u0026lt;INotifier, EmailNotifier\u0026gt;(); builder.Services.AddScoped\u0026lt;INotifier, SmsNotifier\u0026gt;(); // 工厂注册（需要复杂构造） builder.Services.AddScoped\u0026lt;IConnectionFactory\u0026gt;(sp =\u0026gt; { var config = sp.GetRequiredService\u0026lt;IConfiguration\u0026gt;(); return new ConnectionFactory(config.GetConnectionString(\u0026#34;Db\u0026#34;)); }); // 自身注册（不需要接口） builder.Services.AddScoped\u0026lt;UserService\u0026gt;(); // TryAdd（不存在才注册，避免覆盖） builder.Services.TryAddScoped\u0026lt;IUserService, UserService\u0026gt;(); 4.3 注入方式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 // 构造函数注入（推荐，最常用） public class UserController : ControllerBase { private readonly IUserService _userService; private readonly ILogger\u0026lt;UserController\u0026gt; _logger; public UserController(IUserService userService, ILogger\u0026lt;UserController\u0026gt; logger) { _userService = userService; _logger = logger; } } // [FromServices] 注入（Minimal API、单个方法用） app.MapGet(\u0026#34;/users\u0026#34;, ([FromServices] IUserService svc) =\u0026gt; svc.GetAll()); // IServiceProvider 手动解析（少用，需要时） public class Worker { private readonly IServiceProvider _sp; public Worker(IServiceProvider sp) =\u0026gt; _sp = sp; public void DoWork() { using var scope = _sp.CreateScope(); // 手动创建 Scope var svc = scope.ServiceProvider.GetRequiredService\u0026lt;IUserService\u0026gt;(); svc.DoSomething(); } } 4.4 一个完整示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 // 接口 public interface IUserService { Task\u0026lt;User\u0026gt; GetUserAsync(int id); } // 实现 public class UserService : IUserService { private readonly AppDbContext _db; // Scoped private readonly ICache _cache; // Singleton private readonly ILogger\u0026lt;UserService\u0026gt; _logger; // 构造函数注入三个依赖 public UserService(AppDbContext db, ICache cache, ILogger\u0026lt;UserService\u0026gt; logger) { _db = db; _cache = cache; _logger = logger; } public async Task\u0026lt;User\u0026gt; GetUserAsync(int id) =\u0026gt; await _db.Users.FindAsync(id); } // 注册 builder.Services.AddDbContext\u0026lt;AppDbContext\u0026gt;(); // Scoped builder.Services.AddSingleton\u0026lt;ICache, MemoryCache\u0026gt;(); // Singleton builder.Services.AddScoped\u0026lt;IUserService, UserService\u0026gt;(); // Scoped // 使用 public class UserController : ControllerBase { private readonly IUserService _userService; public UserController(IUserService userService) =\u0026gt; _userService = userService; [HttpGet(\u0026#34;{id}\u0026#34;)] public async Task\u0026lt;User\u0026gt; Get(int id) =\u0026gt; await _userService.GetUserAsync(id); } 五、配置系统 ASP.NET Core 的配置非常强大——多源、分层、强类型。\n5.1 配置源 1 2 3 4 5 6 7 8 9 10 // 默认按顺序读取（后者覆盖前者）： // 1. appsettings.json // 2. appsettings.{Environment}.json （如 appsettings.Production.json） // 3. 用户机密（开发环境，secrets.json） // 4. 环境变量 // 5. 命令行参数 // 读取配置 var connStr = builder.Configuration.GetConnectionString(\u0026#34;DefaultConnection\u0026#34;); var name = builder.Configuration[\u0026#34;AppName\u0026#34;]; 1 2 3 4 5 6 7 8 9 10 11 12 // appsettings.json { \u0026#34;AppName\u0026#34;: \u0026#34;MyBlog\u0026#34;, \u0026#34;ConnectionStrings\u0026#34;: { \u0026#34;DefaultConnection\u0026#34;: \u0026#34;Server=...;Database=...;\u0026#34; }, \u0026#34;Jwt\u0026#34;: { \u0026#34;Issuer\u0026#34;: \u0026#34;jiwei.space\u0026#34;, \u0026#34;ExpiresMinutes\u0026#34;: 60, \u0026#34;Secret\u0026#34;: \u0026#34;...\u0026#34; } } 5.2 强类型选项（Options 模式） 不要到处用字符串 key 读配置，用强类型 Options：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 1. 定义配置类 public class JwtOptions { public string Issuer { get; set; } public int ExpiresMinutes { get; set; } public string Secret { get; set; } } // 2. 绑定 builder.Services.Configure\u0026lt;JwtOptions\u0026gt;(builder.Configuration.GetSection(\u0026#34;Jwt\u0026#34;)); // 3. 注入使用 public class TokenService { private readonly JwtOptions _jwt; public TokenService(IOptions\u0026lt;JwtOptions\u0026gt; options) =\u0026gt; _jwt = options.Value; public string CreateToken() { // _jwt.Issuer / _jwt.ExpiresMinutes / _jwt.Secret } } 1 2 3 4 5 IOptions\u0026lt;T\u0026gt; — 单例，启动时绑定，值固定 IOptionsSnapshot\u0026lt;T\u0026gt; — 每个请求重新绑定（配置热更新生效） IOptionsMonitor\u0026lt;T\u0026gt; — 单例，但能监听变化 需要配置热更新 → 用 IOptionsSnapshot 六、日志 6.1 基本用法 1 2 3 4 5 6 7 8 9 10 11 12 13 public class UserService { private readonly ILogger\u0026lt;UserService\u0026gt; _logger; public UserService(ILogger\u0026lt;UserService\u0026gt; logger) =\u0026gt; _logger = logger; public void Login(string username) { _logger.LogInformation(\u0026#34;用户 {Username} 登录\u0026#34;, username); _logger.LogWarning(\u0026#34;用户 {Username} 登录失败次数过多\u0026#34;, username); _logger.LogError(ex, \u0026#34;登录异常 {Username}\u0026#34;, username); } } 1 2 3 4 5 6 7 8 9 10 11 日志级别（从低到高）： Trace \u0026lt; Debug \u0026lt; Information \u0026lt; Warning \u0026lt; Error \u0026lt; Critical 生产通常设 Information 或 Warning（过滤掉 Debug/Trace） 结构化日志（重要）： ✅ _logger.LogInformation(\u0026#34;用户 {Username} 登录\u0026#34;, username); → 占位符 {Username} 是结构化字段，日志系统能按字段检索 ❌ _logger.LogInformation($\u0026#34;用户 {username} 登录\u0026#34;); → 字符串插值，丢失结构化信息，无法按字段查询 6.2 配置 1 2 3 4 5 6 7 8 9 // appsettings.json { \u0026#34;Logging\u0026#34;: { \u0026#34;LogLevel\u0026#34;: { \u0026#34;Default\u0026#34;: \u0026#34;Information\u0026#34;, \u0026#34;Microsoft.AspNetCore\u0026#34;: \u0026#34;Warning\u0026#34; } } } 1 2 3 4 // Program.cs 加日志提供程序 builder.Logging.AddConsole(); builder.Logging.AddDebug(); builder.Logging.AddSeq(); // Serlog Seq 等 七、Host 与 Kestrel 7.1 泛型 Host ASP.NET Core 跑在泛型 Host 之上，统一管理：\n1 2 3 4 5 6 7 8 9 Host 管理的东西： - 依赖注入容器 - 配置 - 日志 - 生命周期（启动、停止） - 后台服务（IHostedService） WebApplication.CreateBuilder 自动配好了一个带 Web 功能的 Host Worker Service 用纯 Host（无 Web） 7.2 Kestrel（内置 Web 服务器） 1 2 3 4 5 6 7 8 9 10 11 12 13 Kestrel 是 ASP.NET Core 自带的跨平台 Web 服务器： ✓ 跨平台（Windows/Linux/macOS） ✓ 极高性能（基于 Pipelines、异步全链路） ✓ 内置，不需要 IIS/Nginx 也能跑 生产部署架构： 客户端 → Nginx（反向代理，HTTPS）→ Kestrel（HTTP） 为什么前面要 Nginx： - HTTPS 终端（Kestrel 处理 HTTP） - 静态文件、缓存、限流 - 更成熟的安全防护 （详见本站 Nginx 系列） 1 2 3 4 5 6 // 配置 Kestrel builder.WebHost.ConfigureKestrel(options =\u0026gt; { options.Limits.MaxConcurrentConnections = 10000; options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10MB }); 八、环境与配置 8.1 环境标识 1 2 3 4 5 6 7 8 通过环境变量 ASPNETCORE_ENVIRONMENT 设置： Development — 开发（本地） Staging — 预发布 Production — 生产 不同环境加载不同配置文件： Development → appsettings.json + appsettings.Development.json Production → appsettings.json + appsettings.Production.json 1 2 3 4 5 6 7 8 9 10 11 12 13 // 代码里判断环境 var env = builder.Environment; if (env.IsDevelopment()) { app.UseSwagger(); app.UseDeveloperExceptionPage(); // 开发显示详细错误 } else { app.UseExceptionHandler(\u0026#34;/error\u0026#34;); // 生产用友好错误页 app.UseHsts(); } 8.2 启动配置 1 2 3 4 5 6 7 8 # 设置环境 export ASPNETCORE_ENVIRONMENT=Production # 设置监听端口 export ASPNETCORE_URLS=http://0.0.0.0:5000 # 启动 dotnet run 九、小结 本文是 ASP.NET Core 系列的开篇，建立了整体认知：\n定位：跨平台、高性能、模块化的现代 Web 框架 两大支柱：中间件管道（处理请求）+ 依赖注入（管理组件） 启动流程：CreateBuilder → 注册服务 → Build → 配置中间件 → Run 中间件管道：请求像洋葱层层穿过，顺序非常重要 依赖注入：三种生命周期（Transient/Scoped/Singleton），构造函数注入 配置：多源 + 强类型 Options 模式 日志：ILogger 结构化日志 Host/Kestrel：泛型 Host 管理生命周期，Kestrel 是高性能内置服务器 核心心法：理解了中间件管道和依赖注入，ASP.NET Core 就理解了一半。 后续所有功能（路由、认证、EF Core）都是建立在这两个基础上的。\n下一篇将讲路由与控制器：URL 如何匹配到代码、数据如何绑定、请求如何验证。\n","date":"2025-07-03T10:00:00+08:00","permalink":"/posts/dotnet/aspnetcore/01-aspnetcore-basics/","title":"ASP.NET Core 学习笔记（一）：基础架构与启动流程"},{"content":"写在前面 本文是 .NET 单元测试系列的最后一篇，介绍测试的工程化实践：命名规范、项目组织、代码覆盖率、TDD 工作流和 CI/CD 集成。前置知识：集成测试（第六篇）。\n一、命名规范 1.1 测试方法命名 1 2 3 4 5 6 7 8 推荐格式：MethodName_Scenario_Expected 示例： Add_TwoPositiveNumbers_ReturnsSum Divide_ByZero_ThrowsDivideByZeroException CreateOrder_EmptyItems_ThrowsArgumentException GetUser_NonExistingId_ReturnsNull ProcessPayment_InsufficientFunds_ReturnsFailed 1.2 测试类命名 1 2 3 4 被测类 + Tests UserService → UserServiceTests OrderService → OrderServiceTests Calculator → CalculatorTests 1.3 测试项目命名 1 2 3 MyApp.Services → MyApp.Services.Tests （单元测试） MyApp.Services → MyApp.Services.IntegrationTests（集成测试） MyApp.Web → MyApp.Web.Tests 1.4 文件组织 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 tests/ ├── MyApp.Services.Tests/ │ ├── Services/ │ │ ├── UserServiceTests.cs │ │ ├── OrderServiceTests.cs │ │ └── PaymentServiceTests.cs │ ├── Validators/ │ │ ├── UserValidatorTests.cs │ │ └── OrderValidatorTests.cs │ └── Helpers/ │ └── TestDataBuilder.cs ├── MyApp.Services.IntegrationTests/ │ ├── Repository/ │ │ └── UserRepositoryTests.cs │ └── Infrastructure/ │ └── DatabaseMigrationTests.cs └── MyApp.Web.Tests/ ├── Controllers/ │ └── UserControllerTests.cs └── Middleware/ └── ExceptionHandlingMiddlewareTests.cs 二、测试分类 2.1 按速度分类 1 2 3 4 5 单元测试（Unit） — 毫秒级，不依赖外部 集成测试（Integration） — 秒级，依赖数据库/文件 端到端测试（E2E） — 十秒级，完整用户流程 按比例：70% 单元 / 20% 集成 / 10% E2E 2.2 用 Trait 标记分类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 [Trait(\u0026#34;Category\u0026#34;, \u0026#34;Unit\u0026#34;)] public class CalculatorTests { [Fact, Trait(\u0026#34;Category\u0026#34;, \u0026#34;Unit\u0026#34;)] public void Add_ReturnsSum() { } } [Trait(\u0026#34;Category\u0026#34;, \u0026#34;Integration\u0026#34;)] public class UserRepositoryTests { [Fact, Trait(\u0026#34;Category\u0026#34;, \u0026#34;Integration\u0026#34;)] public void Save_WritesToDatabase() { } } [Trait(\u0026#34;Category\u0026#34;, \u0026#34;Slow\u0026#34;)] public class PerformanceTests { [Fact, Trait(\u0026#34;Category\u0026#34;, \u0026#34;Slow\u0026#34;)] public void LargeDataset_ProcessesWithinTimeout() { } } 2.3 按分类运行 1 2 3 4 5 6 7 8 9 10 11 # 只跑单元测试 dotnet test --filter \u0026#34;Category=Unit\u0026#34; # 只跑集成测试 dotnet test --filter \u0026#34;Category=Integration\u0026#34; # 排除慢测试 dotnet test --filter \u0026#34;Category!=Slow\u0026#34; # 组合条件 dotnet test --filter \u0026#34;Category=Unit\u0026amp;FullyQualifiedName~UserService\u0026#34; 2.4 分项目组织 1 2 3 4 5 6 7 8 9 10 src/ ├── MyApp.Core/ ├── MyApp.Services/ └── MyApp.Web/ tests/ ├── MyApp.Core.Tests/ # 单元测试 ├── MyApp.Services.Tests/ # 单元测试 ├── MyApp.Services.IntegrationTests/ # 集成测试 └── MyApp.Web.IntegrationTests/ # API 集成测试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 \u0026lt;!-- 目录结构约定：让单元测试和集成测试分开 --\u0026gt; \u0026lt;!-- Unit Tests csproj --\u0026gt; \u0026lt;Project Sdk=\u0026#34;Microsoft.NET.Sdk\u0026#34;\u0026gt; \u0026lt;PropertyGroup\u0026gt; \u0026lt;TargetFramework\u0026gt;net8.0\u0026lt;/TargetFramework\u0026gt; \u0026lt;/PropertyGroup\u0026gt; \u0026lt;ItemGroup\u0026gt; \u0026lt;PackageReference Include=\u0026#34;Microsoft.NET.Test.Sdk\u0026#34; Version=\u0026#34;17.*\u0026#34; /\u0026gt; \u0026lt;PackageReference Include=\u0026#34;xunit\u0026#34; Version=\u0026#34;2.*\u0026#34; /\u0026gt; \u0026lt;PackageReference Include=\u0026#34;xunit.runner.visualstudio\u0026#34; Version=\u0026#34;2.*\u0026#34; /\u0026gt; \u0026lt;PackageReference Include=\u0026#34;Moq\u0026#34; Version=\u0026#34;4.*\u0026#34; /\u0026gt; \u0026lt;/ItemGroup\u0026gt; \u0026lt;/Project\u0026gt; \u0026lt;!-- Integration Tests csproj --\u0026gt; \u0026lt;Project Sdk=\u0026#34;Microsoft.NET.Sdk\u0026#34;\u0026gt; \u0026lt;PropertyGroup\u0026gt; \u0026lt;TargetFramework\u0026gt;net8.0\u0026lt;/TargetFramework\u0026gt; \u0026lt;/PropertyGroup\u0026gt; \u0026lt;ItemGroup\u0026gt; \u0026lt;PackageReference Include=\u0026#34;Microsoft.NET.Test.Sdk\u0026#34; Version=\u0026#34;17.*\u0026#34; /\u0026gt; \u0026lt;PackageReference Include=\u0026#34;xunit\u0026#34; Version=\u0026#34;2.*\u0026#34; /\u0026gt; \u0026lt;PackageReference Include=\u0026#34;xunit.runner.visualstudio\u0026#34; Version=\u0026#34;2.*\u0026#34; /\u0026gt; \u0026lt;PackageReference Include=\u0026#34;Microsoft.AspNetCore.Mvc.Testing\u0026#34; Version=\u0026#34;8.*\u0026#34; /\u0026gt; \u0026lt;/ItemGroup\u0026gt; \u0026lt;/Project\u0026gt; 三、测试数据构建 3.1 Builder 模式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 public class UserBuilder { private int _id = 1; private string _name = \u0026#34;测试用户\u0026#34;; private string _email = \u0026#34;test@example.com\u0026#34;; private int _age = 25; private MemberLevel _level = MemberLevel.Regular; public UserBuilder WithId(int id) { _id = id; return this; } public UserBuilder WithName(string name) { _name = name; return this; } public UserBuilder WithEmail(string email) { _email = email; return this; } public UserBuilder WithAge(int age) { _age = age; return this; } public UserBuilder AsVIP() { _level = MemberLevel.VIP; return this; } public User Build() =\u0026gt; new() { Id = _id, Name = _name, Email = _email, Age = _age, Level = _level }; // 静态工厂方法 public static UserBuilder Typical() =\u0026gt; new(); public static UserBuilder VIP() =\u0026gt; new UserBuilder().AsVIP(); } // 使用 [Fact] public void Test() { var user = UserBuilder.Typical() .WithName(\u0026#34;张三\u0026#34;) .WithAge(30) .Build(); var vip = UserBuilder.VIP() .WithEmail(\u0026#34;vip@test.com\u0026#34;) .Build(); } 3.2 Order Builder 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 public class OrderBuilder { private readonly List\u0026lt;OrderItem\u0026gt; _items = new(); private string _customerEmail = \u0026#34;customer@test.com\u0026#34;; private PaymentMethod _payment = PaymentMethod.CreditCard; public OrderBuilder WithItem(decimal price, int quantity = 1) { _items.Add(new OrderItem { Price = price, Quantity = quantity }); return this; } public OrderBuilder WithCustomerEmail(string email) { _customerEmail = email; return this; } public OrderBuilder WithPayment(PaymentMethod payment) { _payment = payment; return this; } public OrderBuilder Empty() { _items.Clear(); return this; } public Order Build() =\u0026gt; new() { Id = Guid.NewGuid(), CustomerEmail = _customerEmail, PaymentMethod = _payment, Items = _items.ToList() }; } // 使用 [Fact] public void CalculateTotal_MultipleItems_SumsCorrectly() { var order = new OrderBuilder() .WithItem(100, 2) // 200 .WithItem(50, 1) // 50 .Build(); var total = service.CalculateTotal(order); Assert.Equal(250m, total); } [Fact] public void CreateOrder_EmptyOrder_ThrowsException() { var order = new OrderBuilder().Empty().Build(); Assert.Throws\u0026lt;ArgumentException\u0026gt;(() =\u0026gt; service.CreateOrder(order)); } 3.3 Object Mother 模式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // 预定义的常用测试数据 public static class TestData { public static User TypicalUser =\u0026gt; new() { Id = 1, Name = \u0026#34;张三\u0026#34;, Email = \u0026#34;zhang@test.com\u0026#34;, Age = 25 }; public static User VIPUser =\u0026gt; new() { Id = 2, Name = \u0026#34;李四\u0026#34;, Email = \u0026#34;li@test.com\u0026#34;, Age = 30, Level = MemberLevel.VIP }; public static Order TypicalOrder =\u0026gt; new OrderBuilder() .WithItem(100, 2) .Build(); public static Order EmptyOrder =\u0026gt; new OrderBuilder().Empty().Build(); } // 使用 [Fact] public void Test() =\u0026gt; service.Process(TestData.TypicalUser); 四、代码覆盖率 4.1 收集覆盖率 1 2 3 4 5 6 7 8 # 安装工具 dotnet tool install --global dotnet-coverage # 收集覆盖率 dotnet-coverage collect \u0026#34;dotnet test\u0026#34; --output-format cobertura --output coverage.xml # 或者用 coverlet.msbuild dotnet test --collect:\u0026#34;XPlat Code Coverage\u0026#34; 4.2 添加 coverlet 包 1 2 3 4 5 6 \u0026lt;ItemGroup\u0026gt; \u0026lt;PackageReference Include=\u0026#34;coverlet.collector\u0026#34; Version=\u0026#34;6.*\u0026#34;\u0026gt; \u0026lt;IncludeAssets\u0026gt;runtime; build; native; contentfiles; analyzers\u0026lt;/IncludeAssets\u0026gt; \u0026lt;PrivateAssets\u0026gt;all\u0026lt;/PrivateAssets\u0026gt; \u0026lt;/PackageReference\u0026gt; \u0026lt;/ItemGroup\u0026gt; 1 2 # 收集并生成报告 dotnet test --collect:\u0026#34;XPlat Code Coverage\u0026#34; --results-directory ./coverage 4.3 生成可视化报告 1 2 3 4 5 6 7 8 # 安装 ReportGenerator dotnet tool install --global dotnet-reportgenerator-globaltool # 生成 HTML 报告 reportgenerator \\ -reports:coverage/**/coverage.cobertura.xml \\ -targetdir:coverage/report \\ -reporttypes:Html 4.4 覆盖率配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 \u0026lt;!-- 在 csproj 或 runsettings 中配置 --\u0026gt; \u0026lt;!-- runsettings.xml --\u0026gt; \u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;utf-8\u0026#34;?\u0026gt; \u0026lt;RunSettings\u0026gt; \u0026lt;DataCollectionRunSettings\u0026gt; \u0026lt;DataCollectors\u0026gt; \u0026lt;DataCollector friendlyName=\u0026#34;XPlat code coverage\u0026#34;\u0026gt; \u0026lt;Configuration\u0026gt; \u0026lt;Format\u0026gt;cobertura\u0026lt;/Format\u0026gt; \u0026lt;Include\u0026gt;[MyApp.*]*\u0026lt;/Include\u0026gt; \u0026lt;!-- 只统计自己的代码 --\u0026gt; \u0026lt;Exclude\u0026gt;[MyApp.*Tests]*\u0026lt;/Exclude\u0026gt; \u0026lt;!-- 排除测试代码 --\u0026gt; \u0026lt;ExcludeByAttribute\u0026gt;ObsoleteAttribute\u0026lt;/Exclude\u0026gt; \u0026lt;ExcludeByFile\u0026gt;**/Migrations/**\u0026lt;/Exclude\u0026gt; \u0026lt;!-- 排除迁移文件 --\u0026gt; \u0026lt;Threshold\u0026gt;80\u0026lt;/Threshold\u0026gt; \u0026lt;!-- 覆盖率低于 80% 失败 --\u0026gt; \u0026lt;/Configuration\u0026gt; \u0026lt;/DataCollector\u0026gt; \u0026lt;/DataCollectors\u0026gt; \u0026lt;/DataCollectionRunSettings\u0026gt; \u0026lt;/RunSettings\u0026gt; 1 dotnet test --settings runsettings.xml 4.5 覆盖率的意义和误区 1 2 3 4 5 6 7 8 9 10 覆盖率的意义： ✓ 发现未测试的代码路径 ✓ 衡量测试充分度的参考指标 ✓ CI 中设置最低门槛 覆盖率的误区： ✗ 100% 覆盖率 ≠ 没有Bug ✗ 只说明代码被执行了，不说明测试有效 ✗ 不要为了数字而写无意义测试 ✗ getter/setter 不需要测 五、TDD 简介 5.1 红-绿-重构 1 2 3 4 5 6 TDD 流程： 1. 红（Red） — 先写一个失败的测试 2. 绿（Green） — 写最少的代码让测试通过 3. 重构（Refactor）— 优化代码，保持测试通过 循环往复，逐步完善功能。 5.2 TDD 实战示例 1 2 3 4 5 6 需求：实现一个密码验证器 - 长度至少8位 - 包含大写字母 - 包含小写字母 - 包含数字 - 包含特殊字符 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 // 第1轮：测试长度 [Fact] public void Validate_ShortPassword_ReturnsFalse() { var result = PasswordValidator.Validate(\u0026#34;Ab1!\u0026#34;); Assert.False(result.IsValid); Assert.Contains(\u0026#34;长度至少8位\u0026#34;, result.Errors); } // 实现最简代码 public static class PasswordValidator { public static ValidationResult Validate(string password) { var errors = new List\u0026lt;string\u0026gt;(); if (password.Length \u0026lt; 8) errors.Add(\u0026#34;长度至少8位\u0026#34;); return new ValidationResult { IsValid = errors.Count == 0, Errors = errors }; } } // 第2轮：测试大写字母 [Fact] public void Validate_NoUpperCase_ReturnsFalse() { var result = PasswordValidator.Validate(\u0026#34;abcdefg1!\u0026#34;); Assert.False(result.IsValid); Assert.Contains(\u0026#34;需包含大写字母\u0026#34;, result.Errors); } // 补充实现 if (!password.Any(char.IsUpper)) errors.Add(\u0026#34;需包含大写字母\u0026#34;); // 第3轮：测试小写字母 // ... 逐步添加测试和实现 // 第4轮：测试数字 // ... // 第5轮：测试特殊字符 // ... // 最终：测试有效密码 [Fact] public void Validate_ValidPassword_ReturnsTrue() { var result = PasswordValidator.Validate(\u0026#34;Abcdefg1!\u0026#34;); Assert.True(result.IsValid); Assert.Empty(result.Errors); } 5.3 TDD 的优缺点 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 优点： - 迫使你先想清楚接口设计 - 测试覆盖率高 - 代码天然可测试 - 重构有安全感 缺点： - 上手门槛高，需要练习 - 开发初期速度慢 - 需求频繁变化时维护成本高 - 不是所有场景都适合（如 UI、探索性代码） 建议： - 核心/复杂业务逻辑用 TDD - 简单 CRUD 不需要 TDD - 先学会写测试，再学 TDD 六、测试坏味道 6.1 常见坏味道和修复 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 // 坏味道1：测试之间有依赖（顺序敏感） [Fact] public void Test1_Insert() { /* 插入数据 */ } [Fact] public void Test2_Query() { /* 查询 Test1 插入的数据 */ } // 修复：每个测试独立准备数据 // 坏味道2：一个测试测太多 [Fact] public void UserTest() { // 测试创建 // 测试查询 // 测试更新 // 测试删除 // 一次测试全部 CRUD } // 修复：拆分为多个独立的测试方法 // 坏味道3：魔法数字 Assert.Equal(200m, order.Total); // 200 是怎么来的？ // 修复：用有意义的变量 var expectedTotal = 100m * 2; // 单价 * 数量 Assert.Equal(expectedTotal, order.Total); // 坏味道4：过度 Mock mockRepo.Setup(r =\u0026gt; r.GetAll()).Returns(users); mockRepo.Setup(r =\u0026gt; r.GetById(1)).Returns(user); mockRepo.Setup(r =\u0026gt; r.Save(It.IsAny\u0026lt;User\u0026gt;())); mockRepo.Setup(r =\u0026gt; r.Delete(It.IsAny\u0026lt;int\u0026gt;())); // 用了 10 个 Setup，但只测了一个方法 // 修复：只 Mock 当前测试需要的 // 坏味道5：测试不确定性（随机失败） [Fact] public void RandomTest() { var result = service.GetRandomUser(); // 有时通过有时不通过 } // 修复：固定输入，断言确定的输出 // 坏味道6：sleep 等待 [Fact] public async Task BadSleep() { service.Start(); Thread.Sleep(5000); // 等待5秒 Assert.True(service.IsReady); } // 修复：用轮询 + 超时 [Fact] public async Task GoodPolling() { service.Start(); await WaitUntil(() =\u0026gt; service.IsReady, timeout: TimeSpan.FromSeconds(10)); Assert.True(service.IsReady); } 6.2 坏味道速查 1 2 3 4 5 6 7 测试代码重复 → 提取公共方法或基类 测试太长 → 拆分为多个测试 测试太慢 → 减少 I/O，多用 Mock 测试脆弱（经常挂） → 减少对实现细节的依赖 测试名不清晰 → 用 MethodName_Scenario_Expected 格式 断言不足 → 每个 Act 至少一个 Assert 断言太多 → 可能在测多件事，考虑拆分 七、CI/CD 中的测试 7.1 GitHub Actions 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 name: Test on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x - name: Restore run: dotnet restore - name: Build run: dotnet build --no-restore - name: Unit Tests run: dotnet test --no-build --filter \u0026#34;Category=Unit\u0026#34; --logger \u0026#34;trx\u0026#34; - name: Integration Tests run: dotnet test --no-build --filter \u0026#34;Category=Integration\u0026#34; - name: Coverage run: | dotnet test --collect:\u0026#34;XPlat Code Coverage\u0026#34; \\ --settings runsettings.xml \\ --results-directory ./coverage - name: Coverage Report uses: danielpalme/ReportGenerator-GitHub-Action@5 with: reports: coverage/**/coverage.cobertura.xml targetdir: coverage/report reporttypes: \u0026#39;HtmlInline;Cobertura\u0026#39; - name: Upload Coverage uses: actions/upload-artifact@v4 with: name: coverage-report path: coverage/report 7.2 分层运行策略 1 2 3 4 5 6 7 8 # 快速反馈：PR 提交时跑单元测试（秒级） - name: Unit Tests run: dotnet test --filter \u0026#34;Category=Unit\u0026#34; # 完整验证：合并到 main 时跑所有测试（分钟级） - name: All Tests if: github.ref == \u0026#39;refs/heads/main\u0026#39; run: dotnet test 7.3 测试报告 1 2 3 4 5 # 生成 TRX 报告（Visual Studio 格式） dotnet test --logger \u0026#34;trx;LogFileName=test_results.trx\u0026#34; # 生成 JUnit 格式报告（CI 兼容） dotnet test --logger \u0026#34;junit;LogFileName=test_results.xml\u0026#34; 八、dotnet test 常用命令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 # 基本运行 dotnet test # 详细输出 dotnet test --verbosity normal # 只跑某个项目 dotnet test tests/MyApp.Services.Tests # 按名称过滤 dotnet test --filter \u0026#34;FullyQualifiedName~UserService\u0026#34; # 按分类过滤 dotnet test --filter \u0026#34;Category=Unit\u0026#34; dotnet test --filter \u0026#34;Category!=Slow\u0026#34; # 组合过滤 dotnet test --filter \u0026#34;Category=Unit\u0026amp;FullyQualifiedName~Add\u0026#34; # 运行指定方法 dotnet test --filter \u0026#34;FullyQualifiedName=MyApp.Tests.CalculatorTests.Add_TwoNumbers_ReturnsSum\u0026#34; # 监视模式（文件变化自动重跑） dotnet test --watch # 不构建直接跑（需要先构建） dotnet test --no-build # 指定运行设置 dotnet test --settings runsettings.xml # 指定框架 dotnet test --framework net8.0 # 并行运行 dotnet test --parallel # 超时控制 dotnet test --blame-hang --blame-hang-timeout 60s 九、开发者测试工作流 9.1 日常开发流程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 写新功能： 1. 先写测试用例（至少覆盖正常和异常） 2. 运行测试 → 红色（失败） 3. 写实现代码 4. 运行测试 → 绿色（通过） 5. 重构代码 6. 再跑一遍测试 → 仍然绿色 修 Bug： 1. 先写一个复现 Bug 的测试 → 红色 2. 修复代码 3. 测试变绿 → Bug 确认修复 4. 回归测试 重构： 1. 确保现有测试全绿 2. 重构代码 3. 跑测试确认没有破坏 4. 如有必要更新测试 9.2 什么时候写测试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 必须写： - 核心业务逻辑 - 复杂算法和计算 - 支付/金额相关 - 权限和安全检查 建议写： - Service 层的公开方法 - 数据验证逻辑 - 状态转换逻辑 可以不写： - 简单的 CRUD - 纯 UI 代码 - 第三方库的包装（没有自定义逻辑） - getter/setter 十、系列总结 10.1 知识体系回顾 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 第一篇：单元测试基础 - xUnit 环境搭建、AAA 模式、断言详解 第二篇：xUnit 进阶 - 数据驱动测试、共享上下文、并行控制 第三篇：Mock 与隔离 - Moq 框架、Test Double 分类、Mock 反模式 第四篇：依赖注入与可测试性 - 接口设计、构造函数注入、重构不可测代码 第五篇：难测场景实战 - 数据库、HTTP、时间、文件系统、配置 第六篇：集成测试 - WebApplicationFactory、认证授权、中间件测试 第七篇：测试工程化 - 命名规范、覆盖率、TDD、CI/CD 10.2 核心原则 1 2 3 4 5 6 7 8 1. 测行为，不测实现 2. 隔离外部依赖 3. 每个测试只测一件事 4. 测试命名就是文档 5. 保持测试简单可读 6. 不要为了覆盖率写无意义测试 7. 先学会写测试，再学 TDD 8. 把测试当作投资，不是负担 10.3 检查清单 1 2 3 4 5 6 7 8 9 10 11 12 13 14 代码质量： □ 所有公共方法有测试覆盖 □ 正常路径和异常路径都测了 □ 边界条件有测试 测试质量： □ 测试之间互相独立 □ 测试命名清晰 □ 测试运行快速（秒级） 工程化： □ 测试在 CI 中自动运行 □ 覆盖率有门槛 □ 测试失败能快速定位问题 ","date":"2025-06-25T10:00:00+08:00","permalink":"/posts/dotnet/unit-test/07-test-engineering/","title":".NET 单元测试（七）：测试工程化"},{"content":"写在前面 本文是 .NET 单元测试系列的第六篇，介绍 ASP.NET Core 应用的集成测试：WebApplicationFactory、真实数据库测试、中间件和认证测试。前置知识：难测场景实战（第五篇）。\n一、为什么需要集成测试 1.1 单元测试的局限 1 2 3 4 5 6 7 单元测试： ✓ 逻辑正确性 ✗ 组件协作是否正确 ✗ 配置是否正确 ✗ 中间件是否生效 ✗ 数据库查询是否正确 ✗ 依赖注入注册是否正确 1.2 集成测试的范围 1 2 3 4 验证多个组件协作： Controller → Service → Repository → Database Middleware → Controller → Response 配置注入 → 实际使用 二、WebApplicationFactory 2.1 基本用法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 // 被测 API 项目：MyApp.Web [Program] // 让测试项目能访问 Program 类 public partial class TestProgram { } // 测试项目 public class CustomWebApplicationFactory : WebApplicationFactory\u0026lt;Program\u0026gt; { protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services =\u0026gt; { // 替换数据库为 InMemory var descriptor = services.SingleOrDefault( d =\u0026gt; d.ServiceType == typeof(DbContextOptions\u0026lt;AppDbContext\u0026gt;)); if (descriptor != null) services.Remove(descriptor); services.AddDbContext\u0026lt;AppDbContext\u0026gt;(options =\u0026gt; options.UseInMemoryDatabase(\u0026#34;TestDb\u0026#34;)); // 替换外部服务为 Mock services.AddScoped\u0026lt;IEmailService\u0026gt;(_ =\u0026gt; { var mock = new Mock\u0026lt;IEmailService\u0026gt;(); mock.Setup(e =\u0026gt; e.SendAsync(It.IsAny\u0026lt;string\u0026gt;(), It.IsAny\u0026lt;string\u0026gt;())) .Returns(Task.CompletedTask); return mock.Object; }); }); } } 2.2 测试基类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 public abstract class IntegrationTestBase : IClassFixture\u0026lt;CustomWebApplicationFactory\u0026gt; { protected readonly HttpClient Client; protected readonly CustomWebApplicationFactory Factory; protected IntegrationTestBase(CustomWebApplicationFactory factory) { Factory = factory; Client = factory.CreateClient(); } protected async Task ResetDatabaseAsync() { using var scope = Factory.Services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService\u0026lt;AppDbContext\u0026gt;(); context.Database.EnsureDeleted(); context.Database.EnsureCreated(); } protected async Task SeedDataAsync(params object[] entities) { using var scope = Factory.Services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService\u0026lt;AppDbContext\u0026gt;(); foreach (var entity in entities) { context.Add(entity); } await context.SaveChangesAsync(); } } 2.3 基本 API 测试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 public class UserControllerTests : IntegrationTestBase { public UserControllerTests(CustomWebApplicationFactory factory) : base(factory) { } [Fact] public async Task GetUserById_ExistingUser_ReturnsOk() { // 准备数据 await SeedDataAsync(new User { Id = 1, Name = \u0026#34;张三\u0026#34;, Email = \u0026#34;zhang@test.com\u0026#34; }); // 调用 API var response = await Client.GetAsync(\u0026#34;/api/users/1\u0026#34;); // 验证 response.EnsureSuccessStatusCode(); var user = await response.Content.ReadFromJsonAsync\u0026lt;UserDto\u0026gt;(); Assert.Equal(\u0026#34;张三\u0026#34;, user!.Name); } [Fact] public async Task CreateUser_ValidInput_ReturnsCreated() { var request = new CreateUserRequest { Name = \u0026#34;李四\u0026#34;, Email = \u0026#34;li@test.com\u0026#34; }; var response = await Client.PostAsJsonAsync(\u0026#34;/api/users\u0026#34;, request); Assert.Equal(HttpStatusCode.Created, response.StatusCode); var user = await response.Content.ReadFromJsonAsync\u0026lt;UserDto\u0026gt;(); Assert.Equal(\u0026#34;李四\u0026#34;, user!.Name); Assert.True(user.Id \u0026gt; 0); } [Fact] public async Task CreateUser_DuplicateEmail_ReturnsConflict() { await SeedDataAsync(new User { Name = \u0026#34;张三\u0026#34;, Email = \u0026#34;dup@test.com\u0026#34; }); var request = new CreateUserRequest { Name = \u0026#34;李四\u0026#34;, Email = \u0026#34;dup@test.com\u0026#34; }; var response = await Client.PostAsJsonAsync(\u0026#34;/api/users\u0026#34;, request); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); } [Fact] public async Task GetUserById_NonExisting_ReturnsNotFound() { var response = await Client.GetAsync(\u0026#34;/api/users/999\u0026#34;); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } } 三、数据库集成测试 3.1 用 Testcontainers 跑真实数据库 1 2 dotnet add package Testcontainers.MsSql dotnet add package Testcontainers.Redis 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 public class RealDatabaseTests : IAsyncLifetime { private MsSqlContainer _container = null!; private HttpClient _client = null!; public async Task InitializeAsync() { // 启动 SQL Server 容器 _container = new MsSqlBuilder() .WithImage(\u0026#34;mcr.microsoft.com/mssql/server:2022-latest\u0026#34;) .Build(); await _container.StartAsync(); // 用真实数据库创建 WebApplicationFactory var factory = new CustomWebApplicationFactory(builder =\u0026gt; { builder.ConfigureServices(services =\u0026gt; { var descriptor = services.SingleOrDefault( d =\u0026gt; d.ServiceType == typeof(DbContextOptions\u0026lt;AppDbContext\u0026gt;)); if (descriptor != null) services.Remove(descriptor); services.AddDbContext\u0026lt;AppDbContext\u0026gt;(options =\u0026gt; options.UseSqlServer(_container.GetConnectionString())); }); }); _client = factory.CreateClient(); // 运行迁移 using var scope = factory.Services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService\u0026lt;AppDbContext\u0026gt;(); await context.Database.MigrateAsync(); } [Fact] public async Task CreateUser_WithRealDb_Succeeds() { var request = new CreateUserRequest { Name = \u0026#34;张三\u0026#34;, Email = \u0026#34;zhang@test.com\u0026#34; }; var response = await _client.PostAsJsonAsync(\u0026#34;/api/users\u0026#34;, request); Assert.Equal(HttpStatusCode.Created, response.StatusCode); } public async Task DisposeAsync() { await _container.DisposeAsync(); } } 3.2 事务回滚模式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // 每个测试用事务包裹，测试完回滚，数据不残留 public class TransactionTestBase : IntegrationTestBase, IDisposable { private readonly AppDbContext _context; private readonly IDbContextTransaction _transaction; public TransactionTestBase(CustomWebApplicationFactory factory) : base(factory) { var scope = factory.Services.CreateScope(); _context = scope.ServiceProvider.GetRequiredService\u0026lt;AppDbContext\u0026gt;(); _transaction = _context.Database.BeginTransaction(); } public void Dispose() { _transaction.Rollback(); _transaction.Dispose(); _context.Dispose(); } } 四、测试中间件 4.1 测试自定义中间件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 // 被测中间件：请求日志 public class RequestLoggingMiddleware { private readonly RequestDelegate _next; private readonly ILogger\u0026lt;RequestLoggingMiddleware\u0026gt; _logger; public RequestLoggingMiddleware(RequestDelegate next, ILogger\u0026lt;RequestLoggingMiddleware\u0026gt; logger) { _next = next; _logger = logger; } public async Task InvokeAsync(HttpContext context) { _logger.LogInformation(\u0026#34;请求：{Method} {Path}\u0026#34;, context.Request.Method, context.Request.Path); await _next(context); _logger.LogInformation(\u0026#34;响应：{StatusCode}\u0026#34;, context.Response.StatusCode); } } // 测试 [Fact] public async Task RequestLoggingMiddleware_LogsRequestAndResponse() { // 手动构建 HttpContext var mockLogger = new Mock\u0026lt;ILogger\u0026lt;RequestLoggingMiddleware\u0026gt;\u0026gt;(); var mockNext = new Mock\u0026lt;RequestDelegate\u0026gt;(); mockNext.Setup(next =\u0026gt; next(It.IsAny\u0026lt;HttpContext\u0026gt;())) .Returns(Task.CompletedTask); var middleware = new RequestLoggingMiddleware(mockNext.Object, mockLogger.Object); var context = new DefaultHttpContext(); context.Request.Method = \u0026#34;GET\u0026#34;; context.Request.Path = \u0026#34;/api/test\u0026#34;; context.Response.StatusCode = 200; await middleware.InvokeAsync(context); mockNext.Verify(n =\u0026gt; n(context), Times.Once); } 4.2 测试异常处理中间件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 public class ExceptionHandlingMiddleware { private readonly RequestDelegate _next; public ExceptionHandlingMiddleware(RequestDelegate next) =\u0026gt; _next = next; public async Task InvokeAsync(HttpContext context) { try { await _next(context); } catch (ValidationException ex) { context.Response.StatusCode = 400; await context.Response.WriteAsJsonAsync(new { error = ex.Message }); } catch (NotFoundException ex) { context.Response.StatusCode = 404; await context.Response.WriteAsJsonAsync(new { error = ex.Message }); } catch (Exception) { context.Response.StatusCode = 500; await context.Response.WriteAsJsonAsync(new { error = \u0026#34;服务器内部错误\u0026#34; }); } } } [Fact] public async Task ExceptionMiddleware_ValidationException_Returns400() { var mockNext = new Mock\u0026lt;RequestDelegate\u0026gt;(); mockNext.Setup(n =\u0026gt; n(It.IsAny\u0026lt;HttpContext\u0026gt;())) .Throws(new ValidationException(\u0026#34;名称不能为空\u0026#34;)); var middleware = new ExceptionHandlingMiddleware(mockNext.Object); var context = new DefaultHttpContext(); context.Response.Body = new MemoryStream(); await middleware.InvokeAsync(context); Assert.Equal(400, context.Response.StatusCode); } [Fact] public async Task ExceptionMiddleware_UnhandledException_Returns500() { var mockNext = new Mock\u0026lt;RequestDelegate\u0026gt;(); mockNext.Setup(n =\u0026gt; n(It.IsAny\u0026lt;HttpContext\u0026gt;())) .Throws(new Exception(\u0026#34;未知错误\u0026#34;)); var middleware = new ExceptionHandlingMiddleware(mockNext.Object); var context = new DefaultHttpContext(); context.Response.Body = new MemoryStream(); await middleware.InvokeAsync(context); Assert.Equal(500, context.Response.StatusCode); } 五、认证和授权测试 5.1 测试需要认证的接口 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public class AuthenticatedApiTests : IntegrationTestBase { public AuthenticatedApiTests(CustomWebApplicationFactory factory) : base(factory) { // 方式一：配置工厂不使用认证（简化测试） } [Fact] public async Task GetProfile_WithoutAuth_Returns401() { var response = await Client.GetAsync(\u0026#34;/api/users/profile\u0026#34;); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task GetProfile_WithAuth_ReturnsProfile() { // 模拟已认证用户 var token = GenerateTestToken(\u0026#34;user-1\u0026#34;, \u0026#34;张三\u0026#34;); Client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(\u0026#34;Bearer\u0026#34;, token); var response = await Client.GetAsync(\u0026#34;/api/users/profile\u0026#34;); response.EnsureSuccessStatusCode(); } } 5.2 模拟认证用户 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 // 自定义 WebApplicationFactory，跳过真实认证 public class AuthenticatedWebApplicationFactory : WebApplicationFactory\u0026lt;Program\u0026gt; { protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services =\u0026gt; { // 替换认证方案为测试用 services.AddAuthentication(defaultScheme: \u0026#34;TestScheme\u0026#34;) .AddScheme\u0026lt;AuthenticationSchemeOptions, TestAuthHandler\u0026gt;( \u0026#34;TestScheme\u0026#34;, _ =\u0026gt; { }); }); } } // 测试认证 Handler public class TestAuthHandler : AuthenticationHandler\u0026lt;AuthenticationSchemeOptions\u0026gt; { public const string TestUserId = \u0026#34;test-user-1\u0026#34;; public TestAuthHandler( IOptionsMonitor\u0026lt;AuthenticationSchemeOptions\u0026gt; options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { } protected override Task\u0026lt;AuthenticateResult\u0026gt; HandleAuthenticateAsync() { var claims = new[] { new Claim(ClaimTypes.NameIdentifier, TestUserId), new Claim(ClaimTypes.Name, \u0026#34;测试用户\u0026#34;), new Claim(ClaimTypes.Role, \u0026#34;Admin\u0026#34;), }; var identity = new ClaimsIdentity(claims, \u0026#34;TestScheme\u0026#34;); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, \u0026#34;TestScheme\u0026#34;); return Task.FromResult(AuthenticateResult.Success(ticket)); } } // 使用 public class AdminApiTests : IClassFixture\u0026lt;AuthenticatedWebApplicationFactory\u0026gt; { private readonly HttpClient _client; public AdminApiTests(AuthenticatedWebApplicationFactory factory) { _client = factory.CreateClient(); // 所有请求自动带认证信息 } [Fact] public async Task AdminEndpoint_WithTestAuth_ReturnsOk() { var response = await _client.GetAsync(\u0026#34;/api/admin/users\u0026#34;); response.EnsureSuccessStatusCode(); } } 5.3 测试 RBAC 授权 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 // 模拟不同角色 public class RoleBasedTestAuthHandler : AuthenticationHandler\u0026lt;AuthenticationSchemeOptions\u0026gt; { private readonly string _role; public RoleBasedTestAuthHandler( IOptionsMonitor\u0026lt;AuthenticationSchemeOptions\u0026gt; options, ILoggerFactory logger, UrlEncoder encoder, string role = \u0026#34;User\u0026#34;) : base(options, logger, encoder) { _role = role; } protected override Task\u0026lt;AuthenticateResult\u0026gt; HandleAuthenticateAsync() { var claims = new[] { new Claim(ClaimTypes.NameIdentifier, \u0026#34;test-user\u0026#34;), new Claim(ClaimTypes.Role, _role), }; var identity = new ClaimsIdentity(claims, \u0026#34;TestScheme\u0026#34;); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, \u0026#34;TestScheme\u0026#34;); return Task.FromResult(AuthenticateResult.Success(ticket)); } } // 测试 [Theory] [InlineData(\u0026#34;Admin\u0026#34;, HttpStatusCode.OK)] [InlineData(\u0026#34;User\u0026#34;, HttpStatusCode.Forbidden)] public async Task AdminEndpoint_RoleCheck(string role, HttpStatusCode expectedStatus) { var factory = new RoleBasedWebApplicationFactory(role); var client = factory.CreateClient(); var response = await client.GetAsync(\u0026#34;/api/admin/settings\u0026#34;); Assert.Equal(expectedStatus, response.StatusCode); } 六、Snapshot 测试 6.1 响应结构验证 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 [Fact] public async Task GetUserById_ReturnsExpectedStructure() { await SeedDataAsync(new User { Id = 1, Name = \u0026#34;张三\u0026#34;, Email = \u0026#34;zhang@test.com\u0026#34; }); var response = await Client.GetAsync(\u0026#34;/api/users/1\u0026#34;); var body = await response.Content.ReadAsStringAsync(); // 验证 JSON 结构包含预期字段 using var doc = JsonDocument.Parse(body); var root = doc.RootElement; Assert.True(root.TryGetProperty(\u0026#34;id\u0026#34;, out _)); Assert.True(root.TryGetProperty(\u0026#34;name\u0026#34;, out _)); Assert.True(root.TryGetProperty(\u0026#34;email\u0026#34;, out _)); Assert.Equal(\u0026#34;张三\u0026#34;, root.GetProperty(\u0026#34;name\u0026#34;).GetString()); } 七、集成测试最佳实践 7.1 测试隔离 1 2 3 4 每个测试有独立数据： - 用不同的数据库（每个测试 new 一个 InMemory 实例） - 或者用事务回滚 - 或者测试前后清理数据 7.2 性能优化 1 2 3 4 5 6 7 8 WebApplicationFactory 很重： - 用 IClassFixture 共享 Factory - 用 CollectionFixture 跨类共享 - 不要每个测试方法都 new Factory 数据库操作： - 用 InMemory 代替真实数据库（快速验证流程） - Testcontainers 跑少量关键路径 7.3 测试分类 1 2 3 4 5 6 7 8 9 10 // 用 Trait 分类 [Fact, Trait(\u0026#34;Category\u0026#34;, \u0026#34;Integration\u0026#34;)] public void IntegrationTest1() { } [Fact, Trait(\u0026#34;Category\u0026#34;, \u0026#34;Unit\u0026#34;)] public void UnitTest1() { } // 运行指定类别 // dotnet test --filter \u0026#34;Category=Integration\u0026#34; // dotnet test --filter \u0026#34;Category=Unit\u0026#34; 八、小结 本文学习了 ASP.NET Core 集成测试：\nWebApplicationFactory 的使用和配置 API 测试（GET/POST/PUT/DELETE） 数据库集成测试（InMemory、Testcontainers、事务回滚） 中间件测试 认证和授权测试 测试隔离和最佳实践 下一篇将学习测试工程化：命名规范、覆盖率、TDD 和 CI/CD 集成。\n","date":"2025-06-21T10:00:00+08:00","permalink":"/posts/dotnet/unit-test/06-integration-test/","title":".NET 单元测试（六）：集成测试"},{"content":"写在前面 本文是 .NET 单元测试系列的第五篇，逐个击破实际开发中最常见的难测场景：数据库、HTTP 调用、时间、文件系统、配置和静态方法。前置知识：依赖注入与可测试性（第四篇）。\n一、数据库测试 1.1 测试策略选择 1 2 3 4 5 6 7 8 方案1：Mock IRepository — 单元测试，不碰数据库 方案2：EF Core InMemory — 集成测试，内存数据库 方案3：SQLite InMemory — 集成测试，更接近真实 SQL 方案4：Testcontainers + 真实数据库 — 集成测试，最真实 选择建议： - 测业务逻辑（Service 层）→ Mock Repository - 测 Repository 实现本身 → InMemory 或真实数据库 1.2 方案一：Mock Repository（推荐） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 // Repository 接口 public interface IUserRepository { User GetById(int id); IEnumerable\u0026lt;User\u0026gt; GetAll(); void Add(User user); void Update(User user); void Delete(int id); Task\u0026lt;User\u0026gt; GetByIdAsync(int id); } // Service 测试 public class UserServiceTests { private readonly Mock\u0026lt;IUserRepository\u0026gt; _mockRepo; private readonly UserService _service; public UserServiceTests() { _mockRepo = new Mock\u0026lt;IUserRepository\u0026gt;(); _service = new UserService(_mockRepo.Object); } [Fact] public void GetUser_ExistingId_ReturnsUser() { var user = new User { Id = 1, Name = \u0026#34;张三\u0026#34; }; _mockRepo.Setup(r =\u0026gt; r.GetById(1)).Returns(user); var result = _service.GetUser(1); Assert.Equal(\u0026#34;张三\u0026#34;, result.Name); } [Fact] public void GetUser_NonExistingId_ThrowsNotFoundException() { _mockRepo.Setup(r =\u0026gt; r.GetById(999)).Returns((User?)null); Assert.Throws\u0026lt;NotFoundException\u0026gt;(() =\u0026gt; _service.GetUser(999)); } [Fact] public void CreateUser_ValidUser_SavesToRepo() { var user = new User { Name = \u0026#34;张三\u0026#34;, Email = \u0026#34;zhang@test.com\u0026#34; }; _service.CreateUser(user); _mockRepo.Verify(r =\u0026gt; r.Add(It.Is\u0026lt;User\u0026gt;( u =\u0026gt; u.Name == \u0026#34;张三\u0026#34; \u0026amp;\u0026amp; u.Email == \u0026#34;zhang@test.com\u0026#34;)), Times.Once); } } 1.3 方案二：EF Core InMemory 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 // 测试 Repository 实现本身 public class EfUserRepositoryTests : IDisposable { private readonly AppDbContext _context; private readonly EfUserRepository _repo; public EfUserRepositoryTests() { var options = new DbContextOptionsBuilder\u0026lt;AppDbContext\u0026gt;() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; _context = new AppDbContext(options); _repo = new EfUserRepository(_context); } [Fact] public async Task AddAsync_ValidUser_SavesToDatabase() { var user = new User { Name = \u0026#34;张三\u0026#34;, Email = \u0026#34;zhang@test.com\u0026#34; }; await _repo.AddAsync(user); var saved = await _context.Users.FirstOrDefaultAsync(u =\u0026gt; u.Name == \u0026#34;张三\u0026#34;); Assert.NotNull(saved); Assert.Equal(\u0026#34;zhang@test.com\u0026#34;, saved.Email); } [Fact] public async Task GetByIdAsync_ExistingUser_ReturnsUser() { _context.Users.Add(new User { Id = 1, Name = \u0026#34;张三\u0026#34; }); await _context.SaveChangesAsync(); var result = await _repo.GetByIdAsync(1); Assert.Equal(\u0026#34;张三\u0026#34;, result!.Name); } [Fact] public async Task DeleteAsync_ExistingUser_RemovesFromDatabase() { var user = new User { Id = 1, Name = \u0026#34;张三\u0026#34; }; _context.Users.Add(user); await _context.SaveChangesAsync(); await _repo.DeleteAsync(1); Assert.Empty(_context.Users); } public void Dispose() =\u0026gt; _context.Dispose(); } 1.4 方案三：SQLite InMemory（更接近真实） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 public class SqliteUserRepositoryTests : IDisposable { private readonly AppDbContext _context; private readonly SqliteConnection _connection; public SqliteUserRepositoryTests() { // SQLite InMemory 需要保持连接打开 _connection = new SqliteConnection(\u0026#34;Filename=:memory:\u0026#34;); _connection.Open(); var options = new DbContextOptionsBuilder\u0026lt;AppDbContext\u0026gt;() .UseSqlite(_connection) .Options; _context = new AppDbContext(options); _context.Database.EnsureCreated(); // 创建表结构 } [Fact] public async Task AddAsync_WithConstraints_EnforcesUniqueEmail() { // SQLite 会执行真实的约束检查，InMemory 不会 _context.Users.Add(new User { Email = \u0026#34;test@example.com\u0026#34;, Name = \u0026#34;A\u0026#34; }); await _context.SaveChangesAsync(); _context.Users.Add(new User { Email = \u0026#34;test@example.com\u0026#34;, Name = \u0026#34;B\u0026#34; }); await Assert.ThrowsAsync\u0026lt;DbUpdateException\u0026gt;( () =\u0026gt; _context.SaveChangesAsync()); } public void Dispose() { _context.Dispose(); _connection.Dispose(); } } 1.5 InMemory vs SQLite 对比 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 EF Core InMemory： + 速度最快 + 配置最简单 - 不执行真实 SQL（没有约束检查、没有级联删除） - 行为可能和真实数据库不一致 SQLite InMemory： + 执行真实 SQL + 有约束检查、级联等 + 更接近生产环境 - 稍慢 - SQLite 特有语法和 SQL Server/MySQL 不完全一样 建议： 快速验证 Repository 逻辑 → InMemory 需要验证约束和 SQL 行为 → SQLite 需要完全真实环境 → Testcontainers 二、HTTP 调用测试 2.1 Mock HttpMessageHandler 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 // 被测服务 public class WeatherClient { private readonly HttpClient _client; public WeatherClient(HttpClient client) =\u0026gt; _client = client; public async Task\u0026lt;WeatherInfo\u0026gt; GetWeatherAsync(string city) { var response = await _client.GetAsync($\u0026#34;/api/weather?city={Uri.EscapeDataString(city)}\u0026#34;); response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(); return JsonSerializer.Deserialize\u0026lt;WeatherInfo\u0026gt;(json)!; } } // Mock Handler public class MockHttpMessageHandler : HttpMessageHandler { private readonly Func\u0026lt;HttpRequestMessage, CancellationToken, Task\u0026lt;HttpResponseMessage\u0026gt;\u0026gt; _handler; public MockHttpMessageHandler( Func\u0026lt;HttpRequestMessage, CancellationToken, Task\u0026lt;HttpResponseMessage\u0026gt;\u0026gt; handler) { _handler = handler; } public List\u0026lt;HttpRequestMessage\u0026gt; SentRequests { get; } = new(); protected override Task\u0026lt;HttpResponseMessage\u0026gt; SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { SentRequests.Add(request); return _handler(request, cancellationToken); } } // 测试 public class WeatherClientTests { [Fact] public async Task GetWeatherAsync_ReturnsWeatherInfo() { var handler = new MockHttpMessageHandler((req, ct) =\u0026gt; { var json = \u0026#34;\u0026#34;\u0026#34;{\u0026#34;city\u0026#34;:\u0026#34;北京\u0026#34;,\u0026#34;temperature\u0026#34;:25,\u0026#34;description\u0026#34;:\u0026#34;晴\u0026#34;}\u0026#34;\u0026#34;\u0026#34;; return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, \u0026#34;application/json\u0026#34;) }); }); var client = new HttpClient(handler) { BaseAddress = new Uri(\u0026#34;https://api.weather.com\u0026#34;) }; var weatherClient = new WeatherClient(client); var result = await weatherClient.GetWeatherAsync(\u0026#34;北京\u0026#34;); Assert.Equal(\u0026#34;北京\u0026#34;, result.City); Assert.Equal(25, result.Temperature); } [Fact] public async Task GetWeatherAsync_SendsCorrectRequest() { HttpRequestMessage? sentRequest = null; var handler = new MockHttpMessageHandler((req, ct) =\u0026gt; { sentRequest = req; return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(\u0026#34;{}\u0026#34;) }); }); var client = new HttpClient(handler) { BaseAddress = new Uri(\u0026#34;https://api.weather.com\u0026#34;) }; var weatherClient = new WeatherClient(client); await weatherClient.GetWeatherAsync(\u0026#34;北京\u0026#34;); Assert.NotNull(sentRequest); Assert.Equal(HttpMethod.Get, sentRequest.Method); Assert.Contains(\u0026#34;/api/weather?city=\u0026#34;, sentRequest.RequestUri!.ToString()); } [Fact] public async Task GetWeatherAsync_ServerError_ThrowsException() { var handler = new MockHttpMessageHandler((req, ct) =\u0026gt; Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError))); var client = new HttpClient(handler) { BaseAddress = new Uri(\u0026#34;https://api.weather.com\u0026#34;) }; var weatherClient = new WeatherClient(client); await Assert.ThrowsAsync\u0026lt;HttpRequestException\u0026gt;( () =\u0026gt; weatherClient.GetWeatherAsync(\u0026#34;北京\u0026#34;)); } } 2.2 简化版 Mock Handler 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 // 通用简化版 public class StubHttpMessageHandler : HttpMessageHandler { private readonly HttpStatusCode _statusCode; private readonly string _content; public StubHttpMessageHandler(HttpStatusCode statusCode, string content) { _statusCode = statusCode; _content = content; } protected override Task\u0026lt;HttpResponseMessage\u0026gt; SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { return Task.FromResult(new HttpResponseMessage(_statusCode) { Content = new StringContent(_content, Encoding.UTF8, \u0026#34;application/json\u0026#34;) }); } } // 使用 var handler = new StubHttpMessageHandler(HttpStatusCode.OK, \u0026#34;\u0026#34;\u0026#34;{\u0026#34;id\u0026#34;:1,\u0026#34;name\u0026#34;:\u0026#34;张三\u0026#34;}\u0026#34;\u0026#34;\u0026#34;); var client = new HttpClient(handler) { BaseAddress = new Uri(\u0026#34;https://api.example.com\u0026#34;) }; 三、时间相关测试 3.1 问题：DateTime.Now 不可控 1 2 3 4 5 6 7 8 // 不可测 public class SubscriptionService { public bool IsExpired(Subscription sub) { return sub.ExpiryDate \u0026lt; DateTime.UtcNow; // 每次运行结果不同 } } 3.2 方案一：TimeProvider（.NET 8+） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 public class SubscriptionService { private readonly TimeProvider _timeProvider; public SubscriptionService(TimeProvider timeProvider) { _timeProvider = timeProvider; } public bool IsExpired(Subscription sub) { return sub.ExpiryDate \u0026lt; _timeProvider.GetUtcNow(); } } // 测试 [Fact] public void IsExpired_ExpiredDate_ReturnsTrue() { // 用 FakeTimeProvider 控制时间 var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero)); var service = new SubscriptionService(fakeTime); var sub = new Subscription { ExpiryDate = new DateTime(2026, 4, 30) }; Assert.True(service.IsExpired(sub)); } [Fact] public void IsExpired_FutureDate_ReturnsFalse() { var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero)); var service = new SubscriptionService(fakeTime); var sub = new Subscription { ExpiryDate = new DateTime(2026, 6, 1) }; Assert.False(service.IsExpired(sub)); } // 推进时间 [Fact] public void Subscription_ExpiresAfterTimeAdvance() { var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero)); var service = new SubscriptionService(fakeTime); var sub = new Subscription { ExpiryDate = new DateTime(2026, 5, 10) }; Assert.False(service.IsExpired(sub)); // 推进时间到过期之后 fakeTime.Advance(TimeSpan.FromDays(15)); Assert.True(service.IsExpired(sub)); } FakeTimeProvider 在 Microsoft.Extensions.TimeProvider.Testing 包中。\n3.3 方案二：自定义接口 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 适用于 .NET 8 以下版本 public interface IDateTimeProvider { DateTime UtcNow { get; } DateTime Now { get; } } public class SystemDateTimeProvider : IDateTimeProvider { public DateTime UtcNow =\u0026gt; DateTime.UtcNow; public DateTime Now =\u0026gt; DateTime.Now; } // 测试时注入可控实现 public class TestDateTimeProvider : IDateTimeProvider { public DateTime UtcNowValue { get; set; } public DateTime NowValue { get; set; } public DateTime UtcNow =\u0026gt; UtcNowValue; public DateTime Now =\u0026gt; NowValue; } 四、文件系统测试 4.1 方案一：注入路径 + 临时目录 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 public class ReportGenerator { private readonly string _outputDir; public ReportGenerator(string outputDir) { _outputDir = outputDir; Directory.CreateDirectory(outputDir); } public string GenerateReport(string content) { var fileName = $\u0026#34;report_{Guid.NewGuid():N}.txt\u0026#34;; var filePath = Path.Combine(_outputDir, fileName); File.WriteAllText(filePath, content); return filePath; } public string ReadReport(string filePath) =\u0026gt; File.ReadAllText(filePath); } // 测试 public class ReportGeneratorTests : IDisposable { private readonly string _tempDir; private readonly ReportGenerator _generator; public ReportGeneratorTests() { _tempDir = Path.Combine(Path.GetTempPath(), $\u0026#34;test_{Guid.NewGuid():N}\u0026#34;); _generator = new ReportGenerator(_tempDir); } [Fact] public void GenerateReport_CreatesFile() { var path = _generator.GenerateReport(\u0026#34;测试内容\u0026#34;); Assert.True(File.Exists(path)); Assert.Equal(\u0026#34;测试内容\u0026#34;, File.ReadAllText(path)); } [Fact] public void ReadReport_ReturnsContent() { var path = _generator.GenerateReport(\u0026#34;Hello\u0026#34;); var content = _generator.ReadReport(path); Assert.Equal(\u0026#34;Hello\u0026#34;, content); } public void Dispose() { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); } } 4.2 方案二：注入 Stream 抽象 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 public class CsvExporter { public void Export(IEnumerable\u0026lt;User\u0026gt; users, Stream output) { using var writer = new StreamWriter(output, leaveOpen: true); writer.WriteLine(\u0026#34;Id,Name,Email\u0026#34;); foreach (var user in users) { writer.WriteLine($\u0026#34;{user.Id},{user.Name},{user.Email}\u0026#34;); } } } // 测试：用 MemoryStream，不需要真实文件 [Fact] public void Export_WritesCsvFormat() { var users = new List\u0026lt;User\u0026gt; { new() { Id = 1, Name = \u0026#34;张三\u0026#34;, Email = \u0026#34;zhang@test.com\u0026#34; }, new() { Id = 2, Name = \u0026#34;李四\u0026#34;, Email = \u0026#34;li@test.com\u0026#34; }, }; var exporter = new CsvExporter(); using var stream = new MemoryStream(); exporter.Export(users, stream); stream.Position = 0; using var reader = new StreamReader(stream); var csv = reader.ReadToEnd(); Assert.Contains(\u0026#34;Id,Name,Email\u0026#34;, csv); Assert.Contains(\u0026#34;1,张三,zhang@test.com\u0026#34;, csv); Assert.Contains(\u0026#34;2,李四,li@test.com\u0026#34;, csv); } 4.3 方案三：System.IO.Abstractions（全面抽象） 1 2 dotnet add package System.IO.Abstractions dotnet add package System.IO.Abstractions.TestingHelpers 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 // 生产代码：注入 IFileSystem public class ConfigManager { private readonly IFileSystem _fs; private readonly string _configPath; public ConfigManager(IFileSystem fs, string configPath) { _fs = fs; _configPath = configPath; } public bool ConfigExists() =\u0026gt; _fs.File.Exists(_configPath); public string ReadConfig() =\u0026gt; _fs.File.ReadAllText(_configPath); public void WriteConfig(string content) { var dir = _fs.Path.GetDirectoryName(_configPath)!; _fs.Directory.CreateDirectory(dir); _fs.File.WriteAllText(_configPath, content); } } // 测试：用 MockFileSystem，不需要真实磁盘 [Fact] public void ConfigExists_FileExists_ReturnsTrue() { var fs = new MockFileSystem(); fs.AddFile(\u0026#34;/app/config.json\u0026#34;, new MockFileData(\u0026#34;\u0026#34;\u0026#34;{\u0026#34;key\u0026#34;:\u0026#34;value\u0026#34;}\u0026#34;\u0026#34;\u0026#34;)); var manager = new ConfigManager(fs, \u0026#34;/app/config.json\u0026#34;); Assert.True(manager.ConfigExists()); } [Fact] public void WriteConfig_CreatesFile() { var fs = new MockFileSystem(); var manager = new ConfigManager(fs, \u0026#34;/app/config.json\u0026#34;); manager.WriteConfig(\u0026#34;\u0026#34;\u0026#34;{\u0026#34;key\u0026#34;:\u0026#34;new_value\u0026#34;}\u0026#34;\u0026#34;\u0026#34;); Assert.True(fs.File.Exists(\u0026#34;/app/config.json\u0026#34;)); Assert.Contains(\u0026#34;new_value\u0026#34;, fs.File.ReadAllText(\u0026#34;/app/config.json\u0026#34;)); } [Fact] public void ReadConfig_FileNotFound_Throws() { var fs = new MockFileSystem(); // 空文件系统 var manager = new ConfigManager(fs, \u0026#34;/app/not-exist.json\u0026#34;); Assert.Throws\u0026lt;FileNotFoundException\u0026gt;(() =\u0026gt; manager.ReadConfig()); } 五、配置测试 5.1 Mock IOptions 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 public class DatabaseService { private readonly DatabaseSettings _settings; public DatabaseService(IOptions\u0026lt;DatabaseSettings\u0026gt; options) { _settings = options.Value; } public string GetConnectionString() =\u0026gt; _settings.ConnectionString; } // 测试 [Fact] public void GetConnectionString_ReturnsConfiguredValue() { var mockOptions = new Mock\u0026lt;IOptions\u0026lt;DatabaseSettings\u0026gt;\u0026gt;(); mockOptions.Setup(o =\u0026gt; o.Value).Returns(new DatabaseSettings { ConnectionString = \u0026#34;Server=test;Database=testdb\u0026#34; }); var service = new DatabaseService(mockOptions.Object); Assert.Equal(\u0026#34;Server=test;Database=testdb\u0026#34;, service.GetConnectionString()); } // 更简洁的方式：不用 Mock，直接创建 OptionsWrapper [Fact] public void GetConnectionString_WithOptionsWrapper() { var options = new OptionsWrapper\u0026lt;DatabaseSettings\u0026gt;(new DatabaseSettings { ConnectionString = \u0026#34;Server=test;Database=testdb\u0026#34; }); var service = new DatabaseService(options); Assert.Equal(\u0026#34;Server=test;Database=testdb\u0026#34;, service.GetConnectionString()); } 5.2 Mock IConfiguration 1 2 3 4 5 6 7 8 9 10 11 12 13 14 [Fact] public void GetConfigValue_FromConfiguration() { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary\u0026lt;string, string?\u0026gt; { [\u0026#34;App:Name\u0026#34;] = \u0026#34;TestApp\u0026#34;, [\u0026#34;App:Version\u0026#34;] = \u0026#34;2.0\u0026#34; }) .Build(); Assert.Equal(\u0026#34;TestApp\u0026#34;, config[\u0026#34;App:Name\u0026#34;]); Assert.Equal(\u0026#34;2.0\u0026#34;, config[\u0026#34;App:Version\u0026#34;]); } 六、多线程和并发测试 6.1 基本并发测试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 [Fact] public async Task ConcurrentAccess_ThreadSafe() { var counter = new ThreadSafeCounter(); var tasks = Enumerable.Range(0, 1000) .Select(_ =\u0026gt; Task.Run(() =\u0026gt; counter.Increment())); await Task.WhenAll(tasks); Assert.Equal(1000, counter.Count); } [Fact] public async Task ConcurrentAccess_NotThreadSafe_DetectsIssue() { var counter = new Counter(); // 非线程安全 var tasks = Enumerable.Range(0, 1000) .Select(_ =\u0026gt; Task.Run(() =\u0026gt; counter.Increment())); await Task.WhenAll(tasks); // 大概率不等于 1000（竞态条件） // 注意：这个测试不稳定，仅用于演示 Assert.NotEqual(1000, counter.Count); } 6.2 AsyncLocal 测试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class TenantContext { private static readonly AsyncLocal\u0026lt;string?\u0026gt; _tenantId = new(); public static string? CurrentTenant { get =\u0026gt; _tenantId.Value; set =\u0026gt; _tenantId.Value = value; } } [Fact] public async Task AsyncLocal_FlowsAcrossAsyncCalls() { TenantContext.CurrentTenant = \u0026#34;tenant-1\u0026#34;; await Task.Yield(); Assert.Equal(\u0026#34;tenant-1\u0026#34;, TenantContext.CurrentTenant); } 七、私有方法测试 7.1 不建议直接测私有方法 1 2 私有方法应该通过公共方法间接测试。 如果私有方法复杂到需要单独测，说明应该提取为独立的类。 7.2 如果必须测 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 // 方案一：internal + InternalsVisibleTo // 生产代码 internal decimal CalculateTax(decimal amount) =\u0026gt; amount * 0.13m; // AssemblyInfo.cs 或 csproj [assembly: InternalsVisibleTo(\u0026#34;MyApp.Services.Tests\u0026#34;)] // 测试代码（可以访问 internal 方法） [Fact] public void CalculateTax_ReturnsCorrectAmount() { var service = new OrderService(repo, email); Assert.Equal(13m, service.CalculateTax(100m)); } // 方案二：通过反射（最后手段） [Fact] public void PrivateMethod_Reflection() { var service = new OrderService(repo, email); var method = typeof(OrderService).GetMethod(\u0026#34;CalculateTax\u0026#34;, BindingFlags.NonPublic | BindingFlags.Instance); var result = (decimal)method!.Invoke(service, new object[] { 100m })!; Assert.Equal(13m, result); } 八、场景选择速查 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 数据库 → Mock Repository（单元测试） EF Core InMemory（Repository 集成测试） SQLite InMemory（约束验证） HTTP 调用 → Mock HttpMessageHandler 时间 → TimeProvider / FakeTimeProvider（.NET 8+） 自定义 IDateTimeProvider（旧版本） 文件系统 → 临时目录 + IDisposable Stream 注入 System.IO.Abstractions 配置 → OptionsWrapper\u0026lt;T\u0026gt; ConfigurationBuilder + AddInMemoryCollection 多线程 → Task.WhenAll 并发执行 私有方法 → 通过公共方法间接测试 internal + InternalsVisibleTo 九、小结 本文学习了常见难测场景的解决方案：\n数据库：Mock Repository、EF Core InMemory、SQLite InMemory HTTP 调用：Mock HttpMessageHandler 时间：TimeProvider + FakeTimeProvider 文件系统：临时目录、Stream 注入、System.IO.Abstractions 配置：OptionsWrapper、ConfigurationBuilder 多线程和并发测试 私有方法测试策略 下一篇将学习集成测试：WebApplicationFactory、真实数据库和中间件测试。\n","date":"2025-06-17T10:00:00+08:00","permalink":"/posts/dotnet/unit-test/05-hard-to-test/","title":".NET 单元测试（五）：难测场景实战"},{"content":"写在前面 本文是 .NET 单元测试系列的第四篇，介绍如何通过依赖注入设计可测试的代码，以及如何重构不可测的代码。前置知识：Mock 与隔离（第三篇）。\n一、可测试性原则 1.1 什么是可测试性 可测试性是指代码容易被自动化测试的程度。\n1 2 3 4 5 6 7 8 9 10 11 12 13 容易测试的代码： ✓ 依赖通过构造函数注入 ✓ 方法是纯函数（相同输入 → 相同输出） ✓ 没有隐藏的静态调用 ✓ 没有直接 new 具体实现 ✓ 可以控制所有输入 难测试的代码： ✗ 在方法内部 new 依赖 ✗ 调用 DateTime.Now、File.Read 等静态方法 ✗ 依赖单例或静态状态 ✗ 方法做太多事（上帝方法） ✗ 密封类、静态方法 1.2 依赖倒置原则（DIP） 1 2 高层模块不应该依赖低层模块，两者都应该依赖抽象。 抽象不应该依赖细节，细节应该依赖抽象。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 // 不可测：直接依赖具体实现 public class OrderService { private readonly SqlOrderRepository _repo = new(); // 硬编码 private readonly SmtpEmailService _email = new(); // 硬编码 public void CreateOrder(Order order) { _repo.Save(order); _email.Send(order.CustomerEmail, \u0026#34;订单已创建\u0026#34;); } } // 可测：依赖抽象（接口） public class OrderService { private readonly IOrderRepository _repo; private readonly IEmailService _email; public OrderService(IOrderRepository repo, IEmailService email) { _repo = repo; _email = email; } public void CreateOrder(Order order) { _repo.Save(order); _email.Send(order.CustomerEmail, \u0026#34;订单已创建\u0026#34;); } } 二、接口设计 2.1 什么时候该抽接口 1 2 3 4 5 6 7 8 9 需要抽接口： - 涉及外部系统（数据库、HTTP、文件、消息队列） - 有多种实现的业务逻辑（不同策略、不同租户） - 需要在测试中 Mock 的依赖 不需要抽接口： - 纯内存的数据处理（直接用真实对象测） - 只有一个实现且不会有第二个 - 简单的值对象和数据类 2.2 接口粒度 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 // 反模式：一个巨大的接口（违反接口隔离原则） public interface IRepository { User GetUser(int id); void SaveUser(User user); void DeleteUser(int id); Order GetOrder(int id); void SaveOrder(Order order); void DeleteOrder(int id); // ... 50 个方法 } // 好的做法：小而专注的接口 public interface IUserRepository { User GetById(int id); void Save(User user); void Delete(int id); IEnumerable\u0026lt;User\u0026gt; GetAll(); } public interface IOrderRepository { Order GetById(int id); void Save(Order order); IEnumerable\u0026lt;Order\u0026gt; GetByUserId(int userId); } 2.3 接口命名规范 1 2 3 I + 名词 — IRepository、IUserService I + 动词 + able — IDisposable、IComparable I + 描述 + Provider — ITimeProvider、IFileProvider 三、构造函数注入 3.1 基本模式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public class PaymentService { private readonly IPaymentGateway _gateway; private readonly IOrderRepository _repo; private readonly ILogger\u0026lt;PaymentService\u0026gt; _logger; // 所有依赖通过构造函数注入 public PaymentService( IPaymentGateway gateway, IOrderRepository repo, ILogger\u0026lt;PaymentService\u0026gt; logger) { _gateway = gateway ?? throw new ArgumentNullException(nameof(gateway)); _repo = repo ?? throw new ArgumentNullException(nameof(repo)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public PaymentResult ProcessPayment(Order order) { _logger.LogInformation(\u0026#34;处理支付：{OrderId}\u0026#34;, order.Id); var result = _gateway.Charge(order.Total, order.PaymentMethod); if (result.Success) { order.Status = OrderStatus.Paid; _repo.Save(order); } return result; } } 3.2 测试时注入 Mock 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public class PaymentServiceTests { private readonly Mock\u0026lt;IPaymentGateway\u0026gt; _mockGateway; private readonly Mock\u0026lt;IOrderRepository\u0026gt; _mockRepo; private readonly Mock\u0026lt;ILogger\u0026lt;PaymentService\u0026gt;\u0026gt; _mockLogger; private readonly PaymentService _service; public PaymentServiceTests() { _mockGateway = new Mock\u0026lt;IPaymentGateway\u0026gt;(); _mockRepo = new Mock\u0026lt;IOrderRepository\u0026gt;(); _mockLogger = new Mock\u0026lt;ILogger\u0026lt;PaymentService\u0026gt;\u0026gt;(); _service = new PaymentService(_mockGateway.Object, _mockRepo.Object, _mockLogger.Object); } [Fact] public void ProcessPayment_Success_UpdatesOrderStatus() { var order = new Order { Total = 100, PaymentMethod = \u0026#34;CreditCard\u0026#34; }; _mockGateway.Setup(g =\u0026gt; g.Charge(100, \u0026#34;CreditCard\u0026#34;)) .Returns(new PaymentResult { Success = true }); _service.ProcessPayment(order); Assert.Equal(OrderStatus.Paid, order.Status); _mockRepo.Verify(r =\u0026gt; r.Save(order), Times.Once); } } 3.3 依赖太多怎么办 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // 反模式：构造函数参数太多（通常说明类做了太多事） public class OrderService { public OrderService( IOrderRepository repo, IUserRepository userRepo, IProductRepository productRepo, IEmailService email, ISmsService sms, IPaymentGateway payment, ILogger logger, ICacheService cache, IConfiguration config) { // 9 个依赖 → 这个类一定做了太多事 } } // 解决：拆分职责 // OrderValidationService — 验证逻辑 // OrderPricingService — 定价计算 // OrderNotificationService — 通知 // OrderService — 编排（只依赖上面几个服务） 四、重构不可测代码 4.1 场景一：方法内部 new 依赖 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 // 不可测 public class ReportService { public string GenerateReport(int userId) { var repo = new SqlUserRepository(); // 内部 new，测试无法替换 var user = repo.GetById(userId); return $\u0026#34;报告：{user.Name}\u0026#34;; } } // 重构：通过构造函数注入 public class ReportService { private readonly IUserRepository _repo; public ReportService(IUserRepository repo) { _repo = repo; } public string GenerateReport(int userId) { var user = _repo.GetById(userId); return $\u0026#34;报告：{user.Name}\u0026#34;; } } // 测试 [Fact] public void GenerateReport_ReturnsUserName() { var mockRepo = new Mock\u0026lt;IUserRepository\u0026gt;(); mockRepo.Setup(r =\u0026gt; r.GetById(1)) .Returns(new User { Id = 1, Name = \u0026#34;张三\u0026#34; }); var service = new ReportService(mockRepo.Object); var result = service.GenerateReport(1); Assert.Equal(\u0026#34;报告：张三\u0026#34;, result); } 4.2 场景二：直接调用静态方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 // 不可测 public class GreetingService { public string GetGreeting(string name) { var hour = DateTime.Now.Hour; // 静态调用，无法控制 var greeting = hour switch { \u0026lt; 6 =\u0026gt; \u0026#34;凌晨好\u0026#34;, \u0026lt; 12 =\u0026gt; \u0026#34;早上好\u0026#34;, \u0026lt; 18 =\u0026gt; \u0026#34;下午好\u0026#34;, _ =\u0026gt; \u0026#34;晚上好\u0026#34; }; return $\u0026#34;{greeting}，{name}\u0026#34;; } } // 重构：注入 TimeProvider（.NET 8 内置） public class GreetingService { private readonly TimeProvider _timeProvider; public GreetingService(TimeProvider timeProvider) { _timeProvider = timeProvider; } public string GetGreeting(string name) { var hour = _timeProvider.GetLocalNow().Hour; var greeting = hour switch { \u0026lt; 6 =\u0026gt; \u0026#34;凌晨好\u0026#34;, \u0026lt; 12 =\u0026gt; \u0026#34;早上好\u0026#34;, \u0026lt; 18 =\u0026gt; \u0026#34;下午好\u0026#34;, _ =\u0026gt; \u0026#34;晚上好\u0026#34; }; return $\u0026#34;{greeting}，{name}\u0026#34;; } } // 测试 [Fact] public void GetGreeting_Morning_ReturnsMorningGreeting() { // 控制时间为早上 9 点 var mockTime = new Mock\u0026lt;TimeProvider\u0026gt;(); mockTime.Setup(t =\u0026gt; t.GetLocalNow()) .Returns(new DateTimeOffset(2026, 5, 1, 9, 0, 0, TimeSpan.FromHours(8))); var service = new GreetingService(mockTime.Object); var result = service.GetGreeting(\u0026#34;张三\u0026#34;); Assert.Equal(\u0026#34;早上好，张三\u0026#34;, result); } 4.3 场景三：隐藏的文件操作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 // 不可测 public class ConfigLoader { public AppConfig Load() { var json = File.ReadAllText(\u0026#34;config.json\u0026#34;); // 硬编码文件路径 return JsonSerializer.Deserialize\u0026lt;AppConfig\u0026gt;(json); } } // 重构方式1：注入路径，测试时指向临时文件 public class ConfigLoader { private readonly string _configPath; public ConfigLoader(string configPath) { _configPath = configPath; } public AppConfig Load() { var json = File.ReadAllText(_configPath); return JsonSerializer.Deserialize\u0026lt;AppConfig\u0026gt;(json)!; } } // 重构方式2：注入 Stream（更灵活） public class ConfigLoader { public AppConfig Load(Stream stream) { using var reader = new StreamReader(stream); var json = reader.ReadToEnd(); return JsonSerializer.Deserialize\u0026lt;AppConfig\u0026gt;(json)!; } } // 测试 [Fact] public void Load_ValidJson_ReturnsConfig() { var json = \u0026#34;\u0026#34;\u0026#34;{\u0026#34;AppName\u0026#34;:\u0026#34;TestApp\u0026#34;,\u0026#34;Version\u0026#34;:\u0026#34;1.0\u0026#34;}\u0026#34;\u0026#34;\u0026#34;; var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); var loader = new ConfigLoader(); var config = loader.Load(stream); Assert.Equal(\u0026#34;TestApp\u0026#34;, config.AppName); Assert.Equal(\u0026#34;1.0\u0026#34;, config.Version); } 4.4 场景四：静态 HttpClient 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 // 不可测 public class WeatherService { private static readonly HttpClient _client = new(); public async Task\u0026lt;string\u0026gt; GetWeatherAsync(string city) { return await _client.GetStringAsync($\u0026#34;https://api.weather.com?city={city}\u0026#34;); } } // 重构：注入 HttpClient（通过 IHttpClientFactory） public class WeatherService { private readonly HttpClient _client; public WeatherService(HttpClient client) { _client = client; } public async Task\u0026lt;string\u0026gt; GetWeatherAsync(string city) { return await _client.GetStringAsync($\u0026#34;/weather?city={city}\u0026#34;); } } // 测试：用 HttpMessageHandler Mock public class WeatherServiceTests { [Fact] public async Task GetWeatherAsync_ReturnsWeatherData() { var handler = new MockHttpMessageHandler( \u0026#34;\u0026#34;\u0026#34;{\u0026#34;city\u0026#34;:\u0026#34;北京\u0026#34;,\u0026#34;temp\u0026#34;:25}\u0026#34;\u0026#34;\u0026#34;); var client = new HttpClient(handler) { BaseAddress = new Uri(\u0026#34;https://api.weather.com\u0026#34;) }; var service = new WeatherService(client); var result = await service.GetWeatherAsync(\u0026#34;北京\u0026#34;); Assert.Contains(\u0026#34;北京\u0026#34;, result); } } // 辅助类：Mock HttpMessageHandler public class MockHttpMessageHandler : HttpMessageHandler { private readonly string _response; public MockHttpMessageHandler(string response) =\u0026gt; _response = response; public List\u0026lt;HttpRequestMessage\u0026gt; SentRequests { get; } = new(); protected override Task\u0026lt;HttpResponseMessage\u0026gt; SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { SentRequests.Add(request); var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(_response, Encoding.UTF8, \u0026#34;application/json\u0026#34;) }; return Task.FromResult(response); } } 五、工厂模式与多实现选择 5.1 问题：运行时选择不同实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 场景：不同租户使用不同的短信服务商 public interface ISmsProvider { string Name { get; } Task SendAsync(string phone, string message); } public class AliyunSmsProvider : ISmsProvider { public string Name =\u0026gt; \u0026#34;aliyun\u0026#34;; public Task SendAsync(string phone, string message) { /* ... */ } } public class TencentSmsProvider : ISmsProvider { public string Name =\u0026gt; \u0026#34;tencent\u0026#34;; public Task SendAsync(string phone, string message) { /* ... */ } } 5.2 工厂模式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public class SmsProviderFactory { private readonly Dictionary\u0026lt;string, ISmsProvider\u0026gt; _providers; public SmsProviderFactory(IEnumerable\u0026lt;ISmsProvider\u0026gt; providers) { _providers = providers.ToDictionary(p =\u0026gt; p.Name); } public ISmsProvider GetProvider(string name) { return _providers.TryGetValue(name, out var provider) ? provider : throw new ArgumentException($\u0026#34;未知的短信服务商：{name}\u0026#34;); } } // 注册 // builder.Services.AddTransient\u0026lt;ISmsProvider, AliyunSmsProvider\u0026gt;(); // builder.Services.AddTransient\u0026lt;ISmsProvider, TencentSmsProvider\u0026gt;(); // builder.Services.AddTransient\u0026lt;SmsProviderFactory\u0026gt;(); 5.3 使用和测试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 public class SmsService { private readonly SmsProviderFactory _factory; public SmsService(SmsProviderFactory factory) { _factory = factory; } public Task SendAsync(string tenantId, string phone, string message) { var providerName = GetProviderNameForTenant(tenantId); var provider = _factory.GetProvider(providerName); return provider.SendAsync(phone, message); } private string GetProviderNameForTenant(string tenantId) =\u0026gt; /* 从配置读取 */; } // 测试 [Fact] public async Task SendAsync_UsesCorrectProvider() { var mockAliyun = new Mock\u0026lt;ISmsProvider\u0026gt;(); mockAliyun.SetupGet(p =\u0026gt; p.Name).Returns(\u0026#34;aliyun\u0026#34;); var mockTencent = new Mock\u0026lt;ISmsProvider\u0026gt;(); mockTencent.SetupGet(p =\u0026gt; p.Name).Returns(\u0026#34;tencent\u0026#34;); var factory = new SmsProviderFactory(new[] { mockAliyun.Object, mockTencent.Object }); var service = new SmsService(factory); await service.SendAsync(\u0026#34;tenant-aliyun\u0026#34;, \u0026#34;13800138000\u0026#34;, \u0026#34;验证码\u0026#34;); mockAliyun.Verify(p =\u0026gt; p.SendAsync(\u0026#34;13800138000\u0026#34;, \u0026#34;验证码\u0026#34;), Times.Once); mockTencent.Verify(p =\u0026gt; p.SendAsync(It.IsAny\u0026lt;string\u0026gt;(), It.IsAny\u0026lt;string\u0026gt;()), Times.Never); } 六、DI 容器在测试中的角色 6.1 单元测试不用 DI 容器 1 2 3 4 5 6 7 8 9 10 11 12 13 // 单元测试：手动创建和注入 [Fact] public void Test() { var mockRepo = new Mock\u0026lt;IUserRepository\u0026gt;(); var mockEmail = new Mock\u0026lt;IEmailService\u0026gt;(); var service = new UserService(mockRepo.Object, mockEmail.Object); // 直接测试 } // 不要在单元测试中用 ServiceCollection // 那是集成测试的事 6.2 集成测试可以用 DI 容器 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // 集成测试：用真实的 DI 容器，替换部分依赖 public class IntegrationTests { private readonly IServiceProvider _services; public IntegrationTests() { var services = new ServiceCollection(); services.AddMyAppServices(); // 注册生产代码的服务 // 替换外部依赖为 Mock services.AddScoped\u0026lt;IEmailService\u0026gt;(_ =\u0026gt; { var mock = new Mock\u0026lt;IEmailService\u0026gt;(); return mock.Object; }); _services = services.BuildServiceProvider(); } } 七、设计模式与可测试性 7.1 策略模式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 // 不同折扣策略 public interface IDiscountStrategy { decimal Calculate(decimal total); } public class NoDiscount : IDiscountStrategy { public decimal Calculate(decimal total) =\u0026gt; 0; } public class PercentDiscount : IDiscountStrategy { private readonly decimal _percent; public PercentDiscount(decimal percent) =\u0026gt; _percent = percent; public decimal Calculate(decimal total) =\u0026gt; total * _percent; } public class FixedDiscount : IDiscountStrategy { private readonly decimal _amount; public FixedDiscount(decimal amount) =\u0026gt; _amount = amount; public decimal Calculate(decimal total) =\u0026gt; Math.Min(_amount, total); } // 使用 public class PricingService { private readonly IDiscountStrategy _discount; public PricingService(IDiscountStrategy discount) =\u0026gt; _discount = discount; public decimal GetFinalPrice(decimal total) =\u0026gt; total - _discount.Calculate(total); } // 测试 [Fact] public void GetFinalPrice_WithPercentDiscount() { var service = new PricingService(new PercentDiscount(0.2m)); Assert.Equal(80m, service.GetFinalPrice(100m)); } 7.2 模板方法模式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public abstract class DataProcessor { public ProcessResult Process(string input) { var validated = Validate(input); var transformed = Transform(validated); var result = Save(transformed); return result; } protected abstract string Validate(string input); protected abstract string Transform(string input); protected abstract ProcessResult Save(string data); } // 测试具体实现 public class TestableDataProcessor : DataProcessor { public Func\u0026lt;string, string\u0026gt; ValidateFn { get; set; } = s =\u0026gt; s; public Func\u0026lt;string, string\u0026gt; TransformFn { get; set; } = s =\u0026gt; s; public Func\u0026lt;string, ProcessResult\u0026gt; SaveFn { get; set; } = _ =\u0026gt; new ProcessResult { Success = true }; protected override string Validate(string input) =\u0026gt; ValidateFn(input); protected override string Transform(string input) =\u0026gt; TransformFn(input); protected override ProcessResult Save(string data) =\u0026gt; SaveFn(data); } 八、可测试性检查清单 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 依赖注入： □ 所有外部依赖通过构造函数注入 □ 不在方法内部 new 具体实现 □ 依赖接口不依赖具体类 静态依赖： □ DateTime.Now → TimeProvider □ File/Directory → 注入路径或 Stream □ HttpClient → IHttpClientFactory 或注入 HttpClient □ Console → 注入 TextWriter □ Environment → 注入或包装 设计原则： □ 单一职责（一个类做一件事） □ 方法短小（一个方法做一件事） □ 接口小而专注 □ 避免上帝类和上帝方法 可测试代码特征： □ 可以控制所有输入 □ 可以观察所有输出 □ 可以隔离外部依赖 □ 每次运行结果一致 九、小结 本文学习了依赖注入与可测试性：\n可测试性原则和依赖倒置 接口设计和命名 构造函数注入模式 重构不可测代码（4 个实战场景） 工厂模式与多实现选择 DI 容器在测试中的角色 设计模式提升可测试性 下一篇将逐个击破常见的难测场景：数据库、HTTP、时间、文件系统等。\n","date":"2025-06-13T10:00:00+08:00","permalink":"/posts/dotnet/unit-test/04-di-testability/","title":".NET 单元测试（四）：依赖注入与可测试性"},{"content":"写在前面 本文是 .NET 单元测试系列的第三篇，介绍如何使用 Mock 隔离外部依赖，让测试只关注业务逻辑。前置知识：xUnit 进阶（第二篇）。\n一、为什么要隔离 1.1 问题场景 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 // 一个依赖数据库和邮件的服务 public class OrderService { private readonly IOrderRepository _repo; private readonly IEmailService _email; public OrderService(IOrderRepository repo, IEmailService email) { _repo = repo; _email = email; } public OrderResult CreateOrder(Order order) { // 业务逻辑 if (order.Items.Count == 0) throw new ArgumentException(\u0026#34;订单不能为空\u0026#34;); order.Total = order.Items.Sum(i =\u0026gt; i.Price * i.Quantity); order.CreatedAt = DateTime.UtcNow; // 依赖1：保存到数据库 _repo.Save(order); // 依赖2：发送邮件 _email.SendOrderConfirmation(order); return new OrderResult { Success = true, OrderId = order.Id }; } } 1 2 3 4 5 如果不隔离依赖： - 测试需要真实数据库 → 慢、不稳定、难搭建 - 测试需要真实邮件服务 → 会发垃圾邮件 - 数据库挂了测试就挂了 → 不是业务逻辑的问题 - 无法模拟异常场景 → 数据库超时怎么测？ 1.2 隔离的好处 1 2 3 4 快速 — 不需要真实的外部服务 稳定 — 不受外部环境影响 可控 — 可以模拟任何场景（成功、失败、超时） 聚焦 — 只测业务逻辑，不测依赖 二、Test Double 分类 2.1 四种 Test Double 1 2 3 4 Stub（桩） — 提供预设的返回值，让测试能跑下去 Mock（模拟） — 验证交互行为（是否调用了？调了几次？参数对不对？） Fake（伪造） — 有真实逻辑的轻量实现（如内存数据库） Spy（间谍） — 记录调用信息，同时使用真实逻辑 2.2 举例说明 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 // 接口定义 public interface IOrderRepository { Order GetById(int id); void Save(Order order); } // Stub — 只要返回数据，让测试能继续 // \u0026#34;调用 GetById(1) 就返回这个 Order\u0026#34; // 不关心是否真的调用了 // Mock — 验证行为 // \u0026#34;我预期 Save 方法被调用了一次，参数是这个 order\u0026#34; // 如果没调用或者参数不对 → 测试失败 // Fake — 真实但轻量的实现 public class FakeOrderRepository : IOrderRepository { private readonly Dictionary\u0026lt;int, Order\u0026gt; _orders = new(); public Order GetById(int id) =\u0026gt; _orders.GetValueOrDefault(id); public void Save(Order order) =\u0026gt; _orders[order.Id] = order; } // Spy — 记录 + 真实逻辑 public class SpyEmailService : IEmailService { public List\u0026lt;string\u0026gt; SentEmails { get; } = new(); public void SendOrderConfirmation(Order order) { SentEmails.Add(order.CustomerEmail); // 记录 // 实际不发送，但做了真实逻辑 } } 2.3 什么时候用什么 1 2 3 4 只需要返回值 → Stub 需要验证是否调用了 → Mock 需要轻量级替代实现 → Fake 需要记录调用但保留逻辑 → Spy 三、Moq 框架详解 Moq 是 .NET 最流行的 Mock 框架。\n3.1 安装 1 dotnet add package Moq 3.2 基本 Setup（Stub 行为） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 [Fact] public void GetUser_ExistingId_ReturnsUser() { // Arrange var mockRepo = new Mock\u0026lt;IUserRepository\u0026gt;(); // Setup：当调用 GetById(1) 时返回这个 User mockRepo.Setup(r =\u0026gt; r.GetById(1)) .Returns(new User { Id = 1, Name = \u0026#34;张三\u0026#34; }); var service = new UserService(mockRepo.Object); // Act var result = service.GetUser(1); // Assert Assert.Equal(\u0026#34;张三\u0026#34;, result.Name); } 3.3 参数匹配 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // 精确匹配 mockRepo.Setup(r =\u0026gt; r.GetById(42)).Returns(user42); // 任意参数 mockRepo.Setup(r =\u0026gt; r.GetById(It.IsAny\u0026lt;int\u0026gt;())).Returns(user); // 条件匹配 mockRepo.Setup(r =\u0026gt; r.GetById(It.Is\u0026lt;int\u0026gt;(id =\u0026gt; id \u0026gt; 0))) .Returns(user); // 范围匹配 mockRepo.Setup(r =\u0026gt; r.GetUsers(It.IsInRange(1, 100, Range.Inclusive))) .Returns(users); // 正则匹配（字符串） mockRepo.Setup(r =\u0026gt; r.FindByName(It.IsRegex(\u0026#34;^张\u0026#34;))) .Returns(users); // 空值匹配 mockRepo.Setup(r =\u0026gt; r.FindByName(It.IsNull\u0026lt;string\u0026gt;())) .Throws\u0026lt;ArgumentException\u0026gt;(); 3.4 返回值 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // 固定返回值 mockRepo.Setup(r =\u0026gt; r.GetById(1)).Returns(user); // 动态返回值（根据参数计算） mockRepo.Setup(r =\u0026gt; r.GetById(It.IsAny\u0026lt;int\u0026gt;())) .Returns\u0026lt;int\u0026gt;(id =\u0026gt; new User { Id = id, Name = $\u0026#34;用户{id}\u0026#34; }); // 返回 Task（异步方法） mockRepo.Setup(r =\u0026gt; r.GetByIdAsync(1)) .ReturnsAsync(user); // 返回 ValueTask mockRepo.Setup(r =\u0026gt; r.GetByIdAsync(1)) .Returns(new ValueTask\u0026lt;User\u0026gt;(user)); 3.5 验证行为（Mock 行为） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 [Fact] public void CreateOrder_SavesToRepo() { // Arrange var mockRepo = new Mock\u0026lt;IOrderRepository\u0026gt;(); var mockEmail = new Mock\u0026lt;IEmailService\u0026gt;(); var service = new OrderService(mockRepo.Object, mockEmail.Object); var order = new Order { Items = new List\u0026lt;OrderItem\u0026gt; { new() } }; // Act service.CreateOrder(order); // Assert — 验证 Save 被调用了一次 mockRepo.Verify(r =\u0026gt; r.Save(It.IsAny\u0026lt;Order\u0026gt;()), Times.Once); } [Fact] public void CreateOrder_SendsEmail() { var mockRepo = new Mock\u0026lt;IOrderRepository\u0026gt;(); var mockEmail = new Mock\u0026lt;IEmailService\u0026gt;(); var service = new OrderService(mockRepo.Object, mockEmail.Object); var order = new Order { CustomerEmail = \u0026#34;test@example.com\u0026#34;, Items = new List\u0026lt;OrderItem\u0026gt; { new() } }; service.CreateOrder(order); // 验证邮件发送的参数 mockEmail.Verify( e =\u0026gt; e.SendOrderConfirmation( It.Is\u0026lt;Order\u0026gt;(o =\u0026gt; o.CustomerEmail == \u0026#34;test@example.com\u0026#34;)), Times.Once); } 3.6 Verify 的 Times 选项 1 2 3 4 5 mockRepo.Verify(r =\u0026gt; r.Save(order), Times.Once); // 恰好一次 mockRepo.Verify(r =\u0026gt; r.Save(order), Times.Never); // 没调用过 mockRepo.Verify(r =\u0026gt; r.Save(order), Times.Exactly(3)); // 恰好三次 mockRepo.Verify(r =\u0026gt; r.Save(order), Times.AtLeast(2)); // 至少两次 mockRepo.Verify(r =\u0026gt; r.Save(order), Times.AtMost(5)); // 最多五次 3.7 Callback — 捕获参数和副作用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 [Fact] public void CreateOrder_SetsCreatedAtBeforeSaving() { var mockRepo = new Mock\u0026lt;IOrderRepository\u0026gt;(); var mockEmail = new Mock\u0026lt;IEmailService\u0026gt;(); var service = new OrderService(mockRepo.Object, mockEmail.Object); Order? savedOrder = null; mockRepo.Setup(r =\u0026gt; r.Save(It.IsAny\u0026lt;Order\u0026gt;())) .Callback\u0026lt;Order\u0026gt;(o =\u0026gt; savedOrder = o); // 捕获保存的 Order var order = new Order { Items = new List\u0026lt;OrderItem\u0026gt; { new() } }; service.CreateOrder(order); // 验证保存前设置了 CreatedAt Assert.NotNull(savedOrder); Assert.True(savedOrder.CreatedAt \u0026lt;= DateTime.UtcNow); } 3.8 Throws — 模拟异常 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 [Fact] public void CreateOrder_DatabaseDown_ThrowsException() { var mockRepo = new Mock\u0026lt;IOrderRepository\u0026gt;(); mockRepo.Setup(r =\u0026gt; r.Save(It.IsAny\u0026lt;Order\u0026gt;())) .Throws(new SQLException(\u0026#34;连接超时\u0026#34;)); var mockEmail = new Mock\u0026lt;IEmailService\u0026gt;(); var service = new OrderService(mockRepo.Object, mockEmail.Object); Assert.Throws\u0026lt;SQLException\u0026gt;( () =\u0026gt; service.CreateOrder(new Order { Items = new List\u0026lt;OrderItem\u0026gt; { new() } })); } [Fact] public async Task GetUserAsync_Timeout_ThrowsException() { var mockRepo = new Mock\u0026lt;IUserRepository\u0026gt;(); mockRepo.Setup(r =\u0026gt; r.GetByIdAsync(It.IsAny\u0026lt;int\u0026gt;())) .ThrowsAsync(new TimeoutException(\u0026#34;请求超时\u0026#34;)); var service = new UserService(mockRepo.Object); await Assert.ThrowsAsync\u0026lt;TimeoutException\u0026gt;( () =\u0026gt; service.GetUserAsync(1)); } 3.9 属性 Mock 1 2 3 4 5 6 7 8 9 10 11 12 13 // 自动实现所有属性（StubProperties） var mock = new Mock\u0026lt;IOptions\u0026lt;DatabaseSettings\u0026gt;\u0026gt;(); mock.SetupGet(o =\u0026gt; o.Value).Returns(new DatabaseSettings { ConnectionString = \u0026#34;Server=.;Database=Test\u0026#34; }); // 或者 var mockOptions = new Mock\u0026lt;IOptions\u0026lt;DatabaseSettings\u0026gt;\u0026gt;(); mockOptions.Setup(o =\u0026gt; o.Value).Returns(new DatabaseSettings { ConnectionString = \u0026#34;Test\u0026#34; }); 3.10 Loose vs Strict Mock 1 2 3 4 5 6 7 8 9 10 // Loose（默认）— 没有 Setup 的方法返回默认值（null、0、false） var looseMock = new Mock\u0026lt;IUserRepository\u0026gt;(); looseMock.Object.GetById(1); // 返回 null，不报错 // Strict — 没有 Setup 的方法被调用时抛异常 var strictMock = new Mock\u0026lt;IUserRepository\u0026gt;(MockBehavior.Strict); strictMock.Object.GetById(1); // 抛出 MockException！ // Strict 适合：确保测试明确声明了所有预期行为 // Loose 适合：只关心部分方法，其他忽略 3.11 VerifyAll 和 VerifyNoOtherCalls 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 [Fact] public void CreateOrder_AllInteractionsVerified() { var mockRepo = new Mock\u0026lt;IOrderRepository\u0026gt;(MockBehavior.Strict); var mockEmail = new Mock\u0026lt;IEmailService\u0026gt;(MockBehavior.Strict); var service = new OrderService(mockRepo.Object, mockEmail.Object); // Setup 所有预期行为 mockRepo.Setup(r =\u0026gt; r.Save(It.IsAny\u0026lt;Order\u0026gt;())); mockEmail.Setup(e =\u0026gt; e.SendOrderConfirmation(It.IsAny\u0026lt;Order\u0026gt;())); service.CreateOrder(new Order { Items = new List\u0026lt;OrderItem\u0026gt; { new() } }); // 验证所有 Setup 都被调用了 mockRepo.VerifyAll(); mockEmail.VerifyAll(); // 验证没有其他未预期的调用 mockRepo.VerifyNoOtherCalls(); mockEmail.VerifyNoOtherCalls(); } 四、Mock 的实战示例 4.1 完整的 Service 测试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 public class OrderServiceTests { private readonly Mock\u0026lt;IOrderRepository\u0026gt; _mockRepo; private readonly Mock\u0026lt;IEmailService\u0026gt; _mockEmail; private readonly Mock\u0026lt;ILogger\u0026lt;OrderService\u0026gt;\u0026gt; _mockLogger; private readonly OrderService _service; public OrderServiceTests() { _mockRepo = new Mock\u0026lt;IOrderRepository\u0026gt;(); _mockEmail = new Mock\u0026lt;IEmailService\u0026gt;(); _mockLogger = new Mock\u0026lt;ILogger\u0026lt;OrderService\u0026gt;\u0026gt;(); _service = new OrderService(_mockRepo.Object, _mockEmail.Object, _mockLogger.Object); } [Fact] public void CreateOrder_ValidOrder_SavesAndSendsEmail() { var order = new Order { CustomerEmail = \u0026#34;test@example.com\u0026#34;, Items = new List\u0026lt;OrderItem\u0026gt; { new() { Price = 100, Quantity = 2 } } }; var result = _service.CreateOrder(order); Assert.True(result.Success); Assert.Equal(200, order.Total); // 100 * 2 _mockRepo.Verify(r =\u0026gt; r.Save(It.Is\u0026lt;Order\u0026gt;( o =\u0026gt; o.Total == 200)), Times.Once); _mockEmail.Verify(e =\u0026gt; e.SendOrderConfirmation( It.IsAny\u0026lt;Order\u0026gt;()), Times.Once); } [Fact] public void CreateOrder_EmptyItems_ThrowsException() { var order = new Order { Items = new List\u0026lt;OrderItem\u0026gt;() }; var ex = Assert.Throws\u0026lt;ArgumentException\u0026gt;( () =\u0026gt; _service.CreateOrder(order)); Assert.Equal(\u0026#34;订单不能为空\u0026#34;, ex.Message); // 确保没有保存和发邮件 _mockRepo.Verify(r =\u0026gt; r.Save(It.IsAny\u0026lt;Order\u0026gt;()), Times.Never); _mockEmail.Verify(e =\u0026gt; e.SendOrderConfirmation(It.IsAny\u0026lt;Order\u0026gt;()), Times.Never); } [Fact] public void CreateOrder_EmailFails_StillSavesOrder() { var order = new Order { CustomerEmail = \u0026#34;test@example.com\u0026#34;, Items = new List\u0026lt;OrderItem\u0026gt; { new() { Price = 50, Quantity = 1 } } }; // 邮件发送失败 _mockEmail.Setup(e =\u0026gt; e.SendOrderConfirmation(It.IsAny\u0026lt;Order\u0026gt;())) .Throws(new SmtpException(\u0026#34;邮件服务器不可用\u0026#34;)); // 不应该影响订单保存 var result = _service.CreateOrder(order); Assert.True(result.Success); _mockRepo.Verify(r =\u0026gt; r.Save(It.IsAny\u0026lt;Order\u0026gt;()), Times.Once); } } 4.2 Mock ILogger 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 // ILogger 的 Log 方法参数复杂，直接 Mock 很麻烦 // 推荐用扩展方法验证 public static class LoggerExtensions { public static void VerifyLog\u0026lt;T\u0026gt;( this Mock\u0026lt;ILogger\u0026lt;T\u0026gt;\u0026gt; logger, LogLevel level, string message, Times times) { logger.Verify( x =\u0026gt; x.Log( level, It.IsAny\u0026lt;EventId\u0026gt;(), It.Is\u0026lt;It.IsAnyType\u0026gt;((v, _) =\u0026gt; v.ToString()!.Contains(message)), It.IsAny\u0026lt;Exception?\u0026gt;(), It.IsAny\u0026lt;Func\u0026lt;It.IsAnyType, Exception?, string\u0026gt;\u0026gt;()), times); } } // 使用 [Fact] public void CreateOrder_LogsInfo() { _service.CreateOrder(new Order { Items = new List\u0026lt;OrderItem\u0026gt; { new() } }); _mockLogger.VerifyLog( LogLevel.Information, \u0026#34;订单已创建\u0026#34;, Times.Once); } 五、常见 Mock 反模式 5.1 过度 Mock 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 // 反模式：Mock 所有东西 [Fact] public void Bad_OverMocking() { var mockMapper = new Mock\u0026lt;IMapper\u0026gt;(); var mockValidator = new Mock\u0026lt;IValidator\u0026lt;Order\u0026gt;\u0026gt;(); var mockCalculator = new Mock\u0026lt;IPriceCalculator\u0026gt;(); var mockRepo = new Mock\u0026lt;IOrderRepository\u0026gt;(); var mockEmail = new Mock\u0026lt;IEmailService\u0026gt;(); var mockLogger = new Mock\u0026lt;ILogger\u0026lt;OrderService\u0026gt;\u0026gt;(); // 全是 Mock，测试的是 Mock 之间的交互，不是业务逻辑 // 这种测试价值很低 } // 好的做法：只 Mock 外部依赖，内部逻辑用真实对象 [Fact] public void Good_MockOnlyExternalDeps() { // 内部逻辑用真实对象 var validator = new OrderValidator(); var calculator = new PriceCalculator(); // 只 Mock 外部依赖 var mockRepo = new Mock\u0026lt;IOrderRepository\u0026gt;(); var mockEmail = new Mock\u0026lt;IEmailService\u0026gt;(); var service = new OrderService(validator, calculator, mockRepo.Object, mockEmail.Object); } 5.2 Mock 被测类本身 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 反模式：Mock 被测类的方法 [Fact] public void Bad_MockSystemUnderTest() { var mockService = new Mock\u0026lt;OrderService\u0026gt; { CallBase = true }; mockService.Setup(s =\u0026gt; s.ValidateOrder(It.IsAny\u0026lt;Order\u0026gt;())) .Returns(true); // 跳过了验证逻辑 // 你在测 Mock，不是测真正的代码 } // 好的做法：直接实例化被测类 [Fact] public void Good_TestRealImplementation() { var service = new OrderService(repo, email); // 测试真实行为 } 5.3 验证实现细节 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // 反模式：验证内部实现 [Fact] public void Bad_VerifyInternalDetails() { service.CreateOrder(order); // 谁关心你先调 Save 还是先发邮件？ mockRepo.Verify(r =\u0026gt; r.Save(It.IsAny\u0026lt;Order\u0026gt;()), Times.Once); mockEmail.Verify(e =\u0026gt; e.SendOrderConfirmation(It.IsAny\u0026lt;Order\u0026gt;()), Times.Once); // 顺序验证更过分 } // 好的做法：只验证可观察的行为（结果） [Fact] public void Good_VerifyObservableResult() { var result = service.CreateOrder(order); Assert.True(result.Success); Assert.NotEqual(Guid.Empty, result.OrderId); } 六、NSubstitute（替代方案） 如果你觉得 Moq 的 Setup/Verify 语法太繁琐，可以试试 NSubstitute。\n6.1 安装 1 dotnet add package NSubstitute 6.2 对比 Moq 1 2 3 4 5 6 7 8 9 // Moq var mockRepo = new Mock\u0026lt;IUserRepository\u0026gt;(); mockRepo.Setup(r =\u0026gt; r.GetById(1)).Returns(user); mockRepo.Verify(r =\u0026gt; r.Save(It.IsAny\u0026lt;User\u0026gt;()), Times.Once); // NSubstitute var repo = Substitute.For\u0026lt;IUserRepository\u0026gt;(); repo.GetById(1).Returns(user); repo.Received(1).Save(Arg.Any\u0026lt;User\u0026gt;()); 6.3 常用语法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // 返回值 repo.GetById(1).Returns(user); repo.GetAll().Returns(new List\u0026lt;User\u0026gt; { user1, user2 }); // 异步返回 repo.GetByIdAsync(1).Returns(user); // 参数匹配 repo.FindByName(Arg.Is\u0026lt;string\u0026gt;(n =\u0026gt; n.StartsWith(\u0026#34;张\u0026#34;))).Returns(users); repo.FindByName(Arg.Any\u0026lt;string\u0026gt;()).Returns(users); // 验证调用 repo.Received().Save(Arg.Any\u0026lt;User\u0026gt;()); // 被调用了 repo.DidNotReceive().Delete(Arg.Any\u0026lt;int\u0026gt;()); // 没被调用 repo.Received(2).Save(Arg.Any\u0026lt;User\u0026gt;()); // 被调用了2次 // 抛异常 repo.When(r =\u0026gt; r.Save(null!)).Throw\u0026lt;ArgumentNullException\u0026gt;(); // 事件 repo.UserAdded += Raise.Event\u0026lt;UserEventHandler\u0026gt;(user); 6.4 选择建议 1 2 3 4 Moq — 最主流，社区资料多，功能最全 NSubstitute — 语法更简洁，学习曲线低 两者都能完成任务，选团队熟悉的即可 本系列后续使用 Moq 七、小结 本文学习了 Mock 与隔离：\n为什么需要隔离外部依赖 Test Double 分类（Stub、Mock、Fake、Spy） Moq 框架详解（Setup、Verify、Callback、Throws） 实战示例和 Mock ILogger Mock 反模式（过度 Mock、Mock 被测类、验证实现细节） NSubstitute 简介和选择建议 下一篇将学习依赖注入与可测试性：如何设计代码让它容易测试。\n","date":"2025-06-09T10:00:00+08:00","permalink":"/posts/dotnet/unit-test/03-mock-isolation/","title":".NET 单元测试（三）：Mock 与隔离"},{"content":"写在前面 本文是 .NET 单元测试系列的第二篇，深入 xUnit 的高级特性：数据驱动测试、共享上下文、并行控制和自定义扩展。前置知识：单元测试基础（第一篇）。\n一、数据驱动测试 上一篇的 [Fact] 只能测固定数据。当你需要用多组数据验证同一个逻辑时，用 [Theory]。\n1.1 InlineData — 少量固定数据 1 2 3 4 5 6 7 8 9 10 11 12 [Theory] [InlineData(1, 2, 3)] [InlineData(0, 0, 0)] [InlineData(-1, 1, 0)] [InlineData(100, 200, 300)] public void Add_TwoNumbers_ReturnsSum(int a, int b, int expected) { var calculator = new Calculator(); var result = calculator.Add(a, b); Assert.Equal(expected, result); } 1 2 3 优势：一个测试方法覆盖多组数据 每个 [InlineData] 生成一个独立的测试用例 测试资源管理器中可以看到每组数据 1.2 MemberData — 从属性/方法获取数据 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public static IEnumerable\u0026lt;object[]\u0026gt; AdditionData =\u0026gt; new List\u0026lt;object[]\u0026gt; { new object[] { 1, 2, 3 }, new object[] { -1, 1, 0 }, new object[] { 100, 200, 300 }, new object[] { int.MaxValue, 0, int.MaxValue }, }; [Theory] [MemberData(nameof(AdditionData))] public void Add_TwoNumbers_ReturnsSum(int a, int b, int expected) { var calculator = new Calculator(); var result = calculator.Add(a, b); Assert.Equal(expected, result); } 1.3 MemberData 从其他类获取 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 // 集中管理测试数据 public static class CalculatorTestData { public static IEnumerable\u0026lt;object[]\u0026gt; DivisionData =\u0026gt; new List\u0026lt;object[]\u0026gt; { new object[] { 10, 2, 5.0 }, new object[] { 9, 3, 3.0 }, new object[] { 7, 2, 3.5 }, new object[] { -10, 2, -5.0 }, }; public static IEnumerable\u0026lt;object[]\u0026gt; EdgeCaseData =\u0026gt; new List\u0026lt;object[]\u0026gt; { new object[] { 0, 1, 0.0 }, new object[] { 1, 1, 1.0 }, }; } [Theory] [MemberData(nameof(CalculatorTestData.DivisionData), MemberType = typeof(CalculatorTestData))] [MemberData(nameof(CalculatorTestData.EdgeCaseData), MemberType = typeof(CalculatorTestData))] public void Divide_TwoNumbers_ReturnsQuotient(int a, int b, double expected) { var calculator = new Calculator(); var result = calculator.Divide(a, b); Assert.Equal(expected, result); } 1.4 ClassData — 从类获取数据 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public class AdditionTestData : IEnumerable\u0026lt;object[]\u0026gt; { public IEnumerator\u0026lt;object[]\u0026gt; GetEnumerator() { yield return new object[] { 1, 2, 3 }; yield return new object[] { -1, 1, 0 }; yield return new object[] { 100, 200, 300 }; } IEnumerator IEnumerable.GetEnumerator() =\u0026gt; GetEnumerator(); } [Theory] [ClassData(typeof(AdditionTestData))] public void Add_TwoNumbers_ReturnsSum(int a, int b, int expected) { var calculator = new Calculator(); Assert.Equal(expected, calculator.Add(a, b)); } 1.5 TheoryData — 强类型数据 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // TheoryData\u0026lt;T1, T2, ...\u0026gt; 提供类型安全 public static TheoryData\u0026lt;string, int, bool\u0026gt; UserValidationData =\u0026gt; new() { { \u0026#34;张三\u0026#34;, 25, true }, // 正常 { \u0026#34;\u0026#34;, 25, false }, // 名字为空 { null!, 25, false }, // 名字为 null { \u0026#34;李四\u0026#34;, 17, false }, // 未成年 { \u0026#34;王五\u0026#34;, 150, false }, // 年龄不合理 }; [Theory] [MemberData(nameof(UserValidationData))] public void ValidateUser_VariousInputs_ReturnsExpected( string name, int age, bool expected) { var result = UserValidator.Validate(name, age); Assert.Equal(expected, result); } 1.6 自定义 DataAttribute — 从数据库/CSV/JSON 加载 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 // 从 CSV 文件加载测试数据 [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] public class CsvDataAttribute : DataAttribute { private readonly string _filePath; public CsvDataAttribute(string filePath) =\u0026gt; _filePath = filePath; public override IEnumerable\u0026lt;object[]\u0026gt; GetData(MethodInfo testMethod) { var lines = File.ReadAllLines(_filePath); foreach (var line in lines.Skip(1)) // 跳过标题行 { var parts = line.Split(\u0026#39;,\u0026#39;); yield return parts.Select(p =\u0026gt; (object)int.Parse(p)).ToArray(); } } } [Theory] [CsvData(\u0026#34;TestData/addition.csv\u0026#34;)] public void Add_FromCsv_ReturnsSum(int a, int b, int expected) { Assert.Equal(expected, new Calculator().Add(a, b)); } 1.7 数据驱动测试的选择 1 2 3 4 5 InlineData — 3-5 组简单数据，直接写在方法上 MemberData — 中等数量，需要代码生成或复用 ClassData — 大量数据，需要复杂初始化逻辑 TheoryData — 强类型，编译期检查 自定义 — 从文件/数据库加载（慎用，测试不应依赖外部） 二、共享上下文 测试经常需要共享昂贵的资源（数据库连接、文件系统、大对象）。xUnit 提供了三种级别的共享。\n2.1 三种共享级别 1 2 3 无共享（默认） — 每个测试方法创建新的测试类实例 ClassFixture — 同一个测试类中所有测试共享一个实例 CollectionFixture — 多个测试类共享一个实例 2.2 默认行为（无共享） 1 2 3 4 5 6 7 8 9 10 11 12 13 // xUnit 默认：每个 [Fact] 方法执行前都会 new 一个新的测试类实例 // 这保证了测试之间完全隔离 public class ExampleTests { private int _counter = 0; // 每个测试方法看到的是不同的实例 [Fact] public void Test1() =\u0026gt; Assert.Equal(0, _counter++); [Fact] public void Test2() =\u0026gt; Assert.Equal(0, _counter++); // 也是 0，不是 1 } 2.3 ClassFixture — 类级别共享 适用于：创建代价大但只读的共享对象。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 // 1. 定义 Fixture public class DatabaseFixture : IDisposable { public SqlConnection Connection { get; } public DatabaseFixture() { // 所有测试方法执行前，只执行一次 Connection = new SqlConnection(\u0026#34;Server=.;Database=TestDb;Trusted_Connection=true;\u0026#34;); Connection.Open(); // 初始化测试数据 InitTestData(); } private void InitTestData() { using var cmd = Connection.CreateCommand(); cmd.CommandText = \u0026#34;INSERT INTO Users (Name, Age) VALUES (\u0026#39;测试用户\u0026#39;, 25)\u0026#34;; cmd.ExecuteNonQuery(); } public void Dispose() { // 所有测试方法执行完后，清理资源 Connection?.Dispose(); } } // 2. 测试类实现 IClassFixture\u0026lt;T\u0026gt; public class UserRepositoryTests : IClassFixture\u0026lt;DatabaseFixture\u0026gt; { private readonly DatabaseFixture _fixture; public UserRepositoryTests(DatabaseFixture fixture) { _fixture = fixture; // 通过构造函数注入 } [Fact] public void GetUserById_ExistingUser_ReturnsUser() { var repo = new UserRepository(_fixture.Connection); var user = repo.GetById(1); Assert.NotNull(user); Assert.Equal(\u0026#34;测试用户\u0026#34;, user.Name); } [Fact] public void GetAll_ReturnsUsers() { var repo = new UserRepository(_fixture.Connection); var users = repo.GetAll(); Assert.NotEmpty(users); } } 2.4 CollectionFixture — 集合级别共享 适用于：多个测试类需要共享同一个 Fixture。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 // 1. 定义 Fixture public class SharedContext : IDisposable { public StringWriter LogOutput { get; } public SharedContext() { LogOutput = new StringWriter(); } public void Dispose() { LogOutput?.Dispose(); } } // 2. 定义 Collection Definition（注意不能有构造函数） [CollectionDefinition(\u0026#34;SharedContextCollection\u0026#34;)] public class SharedContextCollection : ICollectionFixture\u0026lt;SharedContext\u0026gt; { // 空类，只用来标记 Collection 名称 } // 3. 使用 Collection 的测试类 [Collection(\u0026#34;SharedContextCollection\u0026#34;)] public class OrderServiceTests { private readonly SharedContext _context; public OrderServiceTests(SharedContext context) { _context = context; } [Fact] public void CreateOrder_WritesLog() { var service = new OrderService(_context.LogOutput); service.CreateOrder(new Order()); Assert.Contains(\u0026#34;订单已创建\u0026#34;, _context.LogOutput.ToString()); } } [Collection(\u0026#34;SharedContextCollection\u0026#34;)] public class PaymentServiceTests { private readonly SharedContext _context; public PaymentServiceTests(SharedContext context) { _context = context; } [Fact] public void ProcessPayment_WritesLog() { var service = new PaymentService(_context.LogOutput); service.ProcessPayment(new Payment()); Assert.Contains(\u0026#34;支付已处理\u0026#34;, _context.LogOutput.ToString()); } } 2.5 共享级别选择 1 2 3 不需要共享 → 默认行为（最简单，最安全） 同一类中共享 → ClassFixture 跨多个类共享 → CollectionFixture 三、构造函数和 Dispose 3.1 构造函数 = TestInitialize 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 public class UserServiceTests : IDisposable { private readonly UserService _service; private readonly string _testDir; public UserServiceTests() { // 每个测试方法执行前都会调用 _testDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Directory.CreateDirectory(_testDir); _service = new UserService(_testDir); } [Fact] public void SaveUser_CreatesFile() { _service.SaveUser(new User { Name = \u0026#34;张三\u0026#34; }); Assert.True(File.Exists(Path.Combine(_testDir, \u0026#34;张三.json\u0026#34;))); } [Fact] public void LoadUser_ReturnsSavedUser() { _service.SaveUser(new User { Name = \u0026#34;张三\u0026#34;, Age = 25 }); var user = _service.LoadUser(\u0026#34;张三\u0026#34;); Assert.Equal(25, user.Age); } public void Dispose() { // 每个测试方法执行后都会调用 if (Directory.Exists(_testDir)) Directory.Delete(_testDir, true); } } 3.2 生命周期总结 1 2 3 4 5 6 7 8 9 10 xUnit 生命周期： ┌──────────────────────────────────┐ │ ClassFixture 构造函数（一次） │ │ ┌──────────────────────────────┐ │ │ │ 测试类构造函数（每个方法前） │ │ │ │ 执行测试方法 │ │ │ │ 测试类 Dispose（每个方法后） │ │ │ └──────────────────────────────┘ │ │ ClassFixture Dispose（一次） │ └──────────────────────────────────┘ 四、并行测试 4.1 xUnit 并行策略 1 2 3 4 默认行为： - 不同测试类并行执行 - 同一测试类内串行执行 - 同一 Collection 内串行执行 4.2 控制并行 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 程序集级别：禁止并行 [assembly: CollectionBehavior(DisableTestParallelization = true)] // 程序集级别：设置最大并行度 [assembly: CollectionBehavior(MaxParallelThreads = 4)] // 类级别：禁止并行（测试类内串行） // 默认就是串行，不需要额外设置 // 标记不互相干扰的测试类可以并行 [Collection(\u0026#34;Sequential\u0026#34;)] public class TestClass1 { } [Collection(\u0026#34;Sequential\u0026#34;)] public class TestClass2 { } // 同一个 Collection 的测试类不会并行执行 4.3 并行测试注意事项 1 2 3 4 1. 测试之间不能有共享可变状态 2. 不要依赖文件系统的固定路径 3. 不要依赖数据库的固定数据 4. 需要并行的测试类不要放在同一个 Collection 五、跳过测试 5.1 条件跳过 1 2 3 4 5 6 7 8 9 10 11 12 13 14 [Fact(Skip = \u0026#34;Bug #123 未修复，暂时跳过\u0026#34;)] public void BrokenTest() { // 这个测试不会运行 } [Fact] public void OnlyOnWindows() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; // 或者用 Skip // Windows 专属测试 } 5.2 条件编译 1 2 3 4 5 6 7 8 9 10 // 只在 Debug 模式下运行 [Fact] public void DebugOnlyTest() { #if DEBUG // 测试代码 #else Assert.True(true); // Release 模式下直接通过 #endif } 六、自定义 xUnit 扩展 6.1 自定义 FactAttribute 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 只在特定条件下运行测试 public class WindowsOnlyFactAttribute : FactAttribute { public WindowsOnlyFactAttribute() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) Skip = \u0026#34;只在 Windows 上运行\u0026#34;; } } // 使用 [WindowsOnlyFact] public void RegistryTest() { // Windows 专属测试 } 6.2 自定义 TheoryAttribute 1 2 3 4 5 6 7 8 9 // 只在 CI 环境运行 public class CiOnlyTheoryAttribute : TheoryAttribute { public CiOnlyTheoryAttribute() { if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable(\u0026#34;CI\u0026#34;))) Skip = \u0026#34;只在 CI 环境运行\u0026#34;; } } 七、FluentAssertions（可选增强） 虽然 xUnit 自带的 Assert 够用，但 FluentAssertions 让断言更易读。\n7.1 安装 1 dotnet add package FluentAssertions 7.2 使用对比 1 2 3 4 5 6 7 8 9 10 // xUnit 原生 Assert.Equal(5, result); Assert.True(list.Contains(3)); Assert.Throws\u0026lt;ArgumentException\u0026gt;(() =\u0026gt; method()); // FluentAssertions result.Should().Be(5); list.Should().Contain(3); Action act = () =\u0026gt; method(); act.Should().Throw\u0026lt;ArgumentException\u0026gt;(); 7.3 更多示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // 字符串 name.Should().StartWith(\u0026#34;张\u0026#34;).And.EndWith(\u0026#34;三\u0026#34;); name.Should().NotBeNullOrEmpty(); // 集合 numbers.Should().HaveCount(5).And.ContainInOrder(1, 2, 3); numbers.Should().OnlyContain(n =\u0026gt; n \u0026gt; 0); numbers.Should().BeInAscendingOrder(); // 异常 var act = () =\u0026gt; calculator.Divide(10, 0); act.Should().Throw\u0026lt;DivideByZeroException\u0026gt;() .WithMessage(\u0026#34;除数不能为零*\u0026#34;); // 对象 user.Should().BeEquivalentTo(new { Name = \u0026#34;张三\u0026#34;, Age = 25 }); // 日期 date.Should().BeCloseTo(DateTime.Now, TimeSpan.FromSeconds(1)); 八、小结 本文学习了 xUnit 的进阶特性：\n数据驱动测试（Theory + InlineData/MemberData/ClassData/TheoryData） 共享上下文（ClassFixture、CollectionFixture） 测试生命周期（构造函数、Dispose） 并行测试控制 自定义扩展 FluentAssertions 增强 下一篇将学习 Mock 与隔离：如何隔离外部依赖，专注测试业务逻辑。\n","date":"2025-06-05T10:00:00+08:00","permalink":"/posts/dotnet/unit-test/02-xunit-advanced/","title":".NET 单元测试（二）：xUnit 进阶"},{"content":"写在前面 本文是 .NET 单元测试系列的第一篇，介绍单元测试的基本概念和 xUnit 框架入门。无论你之前有没有写过测试，读完这篇都能开始动手写。\n一、什么是单元测试 1.1 定义 单元测试是对软件中最小可测试单元（通常是一个方法）进行验证的自动化测试。\n1 2 3 4 5 6 核心特征： 1. 自动化 — 不需要人工干预，一条命令就能跑 2. 快速 — 毫秒级完成，不能依赖外部服务 3. 隔离 — 只测试目标方法，不连带测试依赖项 4. 可重复 — 每次运行结果一致 5. 自我检查 — 不需要人工判断结果对不对 1.2 为什么要写单元测试 1 2 3 4 5 尽早发现 Bug — 写代码时就能发现问题，不是等到测试同学提 重构的安全网 — 有测试在，改代码不怕改坏 文档作用 — 测试用例就是最好的使用文档 设计反馈 — 难写的测试说明代码设计有问题 提升信心 — 上线前跑一遍全绿，心里踏实 1.3 测试金字塔 1 2 3 / E2E 测试 \\ 少量，慢，覆盖完整流程 / 集成测试 \\ 适量，中速，覆盖组件交互 / 单元测试 \\ 大量，快速，覆盖业务逻辑 1 2 3 单元测试 — 数量最多，速度最快，只测一个方法 集成测试 — 测多个组件协作（如服务 + 数据库） E2E 测试 — 从用户视角测完整流程 本系列重点讲单元测试和集成测试。E2E 测试一般用 Playwright 等工具，不在本系列范围内。\n1.4 .NET 测试框架对比 1 2 3 4 5 xUnit — 最流行，社区推荐，ASP.NET Core 团队使用 NUnit — 老牌框架，功能丰富，类似 JUnit MSTest — 微软官方，集成好但功能少 推荐：xUnit（本系列全程使用 xUnit） 二、环境准备 2.1 创建解决方案结构 1 2 3 4 5 6 7 8 9 10 MyApp/ ├── src/ │ └── MyApp.Services/ # 业务代码 │ ├── MyApp.Services.csproj │ └── Calculator.cs ├── tests/ │ └── MyApp.Services.Tests/ # 测试代码 │ ├── MyApp.Services.Tests.csproj │ └── CalculatorTests.cs └── MyApp.sln 2.2 创建项目 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # 创建解决方案 dotnet new sln -n MyApp # 创建业务类库 dotnet new classlib -n MyApp.Services -o src/MyApp.Services # 创建测试项目（xUnit 模板） dotnet new xunit -n MyApp.Services.Tests -o tests/MyApp.Services.Tests # 添加引用 dotnet add tests/MyApp.Services.Tests reference src/MyApp.Services # 添加到解决方案 dotnet sln add src/MyApp.Services dotnet sln add tests/MyApp.Services.Tests 2.3 测试项目 csproj 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 \u0026lt;Project Sdk=\u0026#34;Microsoft.NET.Sdk\u0026#34;\u0026gt; \u0026lt;PropertyGroup\u0026gt; \u0026lt;TargetFramework\u0026gt;net8.0\u0026lt;/TargetFramework\u0026gt; \u0026lt;ImplicitUsings\u0026gt;enable\u0026lt;/ImplicitUsings\u0026gt; \u0026lt;Nullable\u0026gt;enable\u0026lt;/Nullable\u0026gt; \u0026lt;IsPackable\u0026gt;false\u0026lt;/IsPackable\u0026gt; \u0026lt;/PropertyGroup\u0026gt; \u0026lt;ItemGroup\u0026gt; \u0026lt;PackageReference Include=\u0026#34;Microsoft.NET.Test.Sdk\u0026#34; Version=\u0026#34;17.*\u0026#34; /\u0026gt; \u0026lt;PackageReference Include=\u0026#34;xunit\u0026#34; Version=\u0026#34;2.*\u0026#34; /\u0026gt; \u0026lt;PackageReference Include=\u0026#34;xunit.runner.visualstudio\u0026#34; Version=\u0026#34;2.*\u0026#34; /\u0026gt; \u0026lt;/ItemGroup\u0026gt; \u0026lt;ItemGroup\u0026gt; \u0026lt;ProjectReference Include=\u0026#34;..\\..\\src\\MyApp.Services\\MyApp.Services.csproj\u0026#34; /\u0026gt; \u0026lt;/ItemGroup\u0026gt; \u0026lt;/Project\u0026gt; 2.4 运行测试 1 2 3 4 5 6 7 8 9 10 11 # 运行所有测试 dotnet test # 运行并显示详细输出 dotnet test --verbosity normal # 只运行某个项目 dotnet test tests/MyApp.Services.Tests # 持续运行（文件变化自动重跑） dotnet test --watch 三、第一个测试 3.1 被测代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // src/MyApp.Services/Calculator.cs namespace MyApp.Services; public class Calculator { public int Add(int a, int b) =\u0026gt; a + b; public int Subtract(int a, int b) =\u0026gt; a - b; public int Multiply(int a, int b) =\u0026gt; a * b; public double Divide(int a, int b) { if (b == 0) throw new DivideByZeroException(\u0026#34;除数不能为零\u0026#34;); return (double)a / b; } } 3.2 编写测试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // tests/MyApp.Services.Tests/CalculatorTests.cs using MyApp.Services; namespace MyApp.Services.Tests; public class CalculatorTests { [Fact] public void Add_TwoNumbers_ReturnsSum() { // Arrange（准备） var calculator = new Calculator(); // Act（执行） var result = calculator.Add(2, 3); // Assert（断言） Assert.Equal(5, result); } } 3.3 关键概念 1 2 3 [Fact] — 标记一个无参数的测试方法 AAA 模式 — Arrange（准备）→ Act（执行）→ Assert（断言） Assert — 验证结果是否符合预期 四、断言详解 4.1 相等性断言 1 2 3 4 5 6 7 8 9 10 11 12 13 [Fact] public void EqualityAssertions() { // 相等 Assert.Equal(5, 2 + 3); // 数值 Assert.Equal(\u0026#34;hello\u0026#34;, \u0026#34;hello\u0026#34;); // 字符串 // 不等 Assert.NotEqual(5, 2 + 4); // 浮点数精度 Assert.Equal(3.14, 3.141, precision: 2); // 精确到小数点后2位 } 4.2 布尔断言 1 2 3 4 5 6 [Fact] public void BooleanAssertions() { Assert.True(1 \u0026gt; 0); Assert.False(1 \u0026lt; 0); } 4.3 集合断言 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 [Fact] public void CollectionAssertions() { var numbers = new List\u0026lt;int\u0026gt; { 1, 2, 3, 4, 5 }; // 包含 Assert.Contains(3, numbers); Assert.DoesNotContain(6, numbers); // 集合相等（顺序也要一致） Assert.Equal(new[] { 1, 2, 3, 4, 5 }, numbers); // 集合相等（不关心顺序） Assert.Equal(new[] { 5, 4, 3, 2, 1 }, numbers.OrderBy(_ =\u0026gt; _)); // 空集合 Assert.Empty(new List\u0026lt;string\u0026gt;()); Assert.NotEmpty(numbers); // 单个元素 Assert.Single(new[] { 1 }); // 所有元素满足条件 Assert.All(numbers, n =\u0026gt; Assert.True(n \u0026gt; 0)); } 4.4 异常断言 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 [Fact] public void Divide_ByZero_ThrowsDivideByZeroException() { var calculator = new Calculator(); // 断言抛出异常 var ex = Assert.Throws\u0026lt;DivideByZeroException\u0026gt;( () =\u0026gt; calculator.Divide(10, 0)); Assert.Equal(\u0026#34;除数不能为零\u0026#34;, ex.Message); } [Fact] public void Constructor_NullName_ThrowsArgumentNullException() { // 也可以用 Record.Exception var ex = Record.Exception(() =\u0026gt; new User(null!)); Assert.NotNull(ex); Assert.IsType\u0026lt;ArgumentNullException\u0026gt;(ex); } 4.5 类型断言 1 2 3 4 5 6 7 8 9 10 11 12 13 14 [Fact] public void TypeAssertions() { object obj = \u0026#34;hello\u0026#34;; // 是某类型 Assert.IsType\u0026lt;string\u0026gt;(obj); // 可以赋值给某类型（包含子类） Assert.IsAssignableFrom\u0026lt;object\u0026gt;(obj); // 不是某类型 Assert.NotType\u0026lt;int\u0026gt;(obj); } 4.6 范围和比较断言 1 2 3 4 5 6 7 8 [Fact] public void RangeAssertions() { // 注意：xUnit 的 Assert 不直接支持范围断言 // 用 True 断言组合 var age = 25; Assert.True(age \u0026gt;= 18 \u0026amp;\u0026amp; age \u0026lt;= 65, $\u0026#34;年龄 {age} 不在 18-65 范围内\u0026#34;); } 4.7 null 断言 1 2 3 4 5 6 7 8 [Fact] public void NullAssertions() { string? name = null; Assert.Null(name); Assert.NotNull(\u0026#34;hello\u0026#34;); } 4.8 事件断言 1 2 3 4 5 6 7 8 9 10 11 [Fact] public void PropertyChanged_Raised() { var user = new User(\u0026#34;张三\u0026#34;); string? changedProperty = null; user.PropertyChanged += (_, e) =\u0026gt; changedProperty = e.PropertyName; user.Name = \u0026#34;李四\u0026#34;; Assert.Equal(\u0026#34;Name\u0026#34;, changedProperty); } 五、AAA 模式详解 5.1 三段式结构 1 2 3 Arrange（准备）— 创建测试数据、Mock 对象、初始化状态 Act（执行） — 调用被测方法 Assert（断言） — 验证结果、验证副作用 5.2 完整示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 public class DiscountServiceTests { [Fact] public void CalculateDiscount_VipMember_Returns20Percent() { // Arrange var member = new Member { Level = MemberLevel.VIP }; var order = new Order { Total = 1000m }; var service = new DiscountService(); // Act var discount = service.CalculateDiscount(member, order); // Assert Assert.Equal(200m, discount); // 1000 * 20% = 200 Assert.Equal(800m, order.FinalTotal); // 副作用验证 } [Fact] public void CalculateDiscount_RegularMember_Returns5Percent() { var member = new Member { Level = MemberLevel.Regular }; var order = new Order { Total = 1000m }; var service = new DiscountService(); var discount = service.CalculateDiscount(member, order); Assert.Equal(50m, discount); } [Fact] public void CalculateDiscount_NullMember_ThrowsArgumentNullException() { var order = new Order { Total = 1000m }; var service = new DiscountService(); Assert.Throws\u0026lt;ArgumentNullException\u0026gt;( () =\u0026gt; service.CalculateDiscount(null!, order)); } } 5.3 AAA 的注意事项 1 2 3 4 1. Arrange 要完整 — 不要让读者猜测试前提是什么 2. Act 只有一个 — 一个测试只测一个行为 3. Assert 可以多个 — 但都和同一个行为相关 4. 不要在 Act 和 Assert 之间加逻辑 六、测试命名规范 6.1 常见命名风格 1 2 3 4 5 6 7 8 9 风格1：MethodName_Scenario_Expected（推荐） Add_TwoPositiveNumbers_ReturnsSum Divide_ByZero_ThrowsDivideByZeroException 风格2：Given_When_Then GivenTwoNumbers_WhenAdd_ThenReturnsSum 风格3：中文描述（适合团队内部） 两个正数相加_返回正确结果 6.2 命名原则 1 2 3 4 1. 看名字就知道测什么（不用看代码） 2. 看名字就知道期望结果 3. 测试失败时，名字本身就是错误描述 4. 避免用 Test1、Test2 这种无意义名字 七、测试的组织结构 7.1 测试类组织 1 2 一个生产类 → 一个测试类 一个公共方法 → 一组测试（正常、边界、异常） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 按功能分组 public class CalculatorTests { public class Add { [Fact] public void ReturnsSum() { } [Fact] public void HandlesNegativeNumbers() { } [Fact] public void HandlesOverflow() { } } public class Divide { [Fact] public void ReturnsQuotient() { } [Fact] public void ByZero_ThrowsException() { } } } 7.2 项目结构 1 2 3 4 5 6 7 8 9 tests/ ├── MyApp.Services.Tests/ │ ├── CalculatorTests.cs │ ├── DiscountServiceTests.cs │ └── UserServiceTests.cs ├── MyApp.Services.IntegrationTests/ │ └── UserRepositoryTests.cs └── MyApp.Web.Tests/ └── OrderControllerTests.cs 八、在 Visual Studio 和 VS Code 中运行测试 8.1 Visual Studio 1 2 3 4 测试资源管理器 — 菜单：测试 → 测试资源管理器 运行全部 — Ctrl+R, A 运行单个 — 右键测试方法 → 运行测试 调试测试 — 右键测试方法 → 调试测试（可以打断点） 8.2 VS Code 1 2 3 安装扩展：C# Dev Kit（自带测试资源管理器） 侧边栏会出现测试图标 → 展开运行 或者在测试方法上方点击 ▶ 运行按钮 8.3 命令行 1 2 3 4 dotnet test # 运行所有 dotnet test --filter \u0026#34;FullyQualifiedName~Add\u0026#34; # 按名称过滤 dotnet test --filter \u0026#34;Category=Slow\u0026#34; # 按分类过滤 dotnet test --logger \u0026#34;console;verbosity=detailed\u0026#34; # 详细输出 九、常见误区 9.1 什么不是单元测试 1 2 3 4 5 ✗ 需要数据库的测试 → 集成测试 ✗ 需要文件系统的测试 → 集成测试 ✗ 需要网络的测试 → 集成测试 ✗ 需要手动检查结果的测试 → 不算自动化测试 ✗ 顺序依赖的测试 → 设计有问题 9.2 FIRST 原则 1 2 3 4 5 Fast — 快速（毫秒级） Independent — 独立（不依赖其他测试） Repeatable — 可重复（任何环境结果一致） SelfValidating — 自我验证（自动判断通过/失败） Timely — 及时（最好在写代码时同步写测试） 十、小结 本文学习了单元测试的基础：\n什么是单元测试和测试金字塔 xUnit 环境搭建 第一个测试和 AAA 模式 断言详解（相等、布尔、集合、异常、类型） 测试命名和组织规范 FIRST 原则 下一篇将深入学习 xUnit 进阶特性：数据驱动测试、共享上下文和测试生命周期。\n","date":"2025-06-01T10:00:00+08:00","permalink":"/posts/dotnet/unit-test/01-unit-test-basics/","title":".NET 单元测试（一）：基础入门"},{"content":"写在前面 经过 9 篇深入关系数据库的底层原理，本文作为系列收官，跳出 RDBMS，俯瞰整个数据存储生态。\n本文要回答：\nOLTP、OLAP、HTAP、数仓、Lakehouse、NoSQL 都是干什么的？怎么选？Snowflake / BigQuery / TiDB / Iceberg / Doris / ClickHouse 在生态中各处于什么位置？\n一、数据存储的分类全景 1.1 一张总图 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 数据存储生态 │ ┌──────────────────────┼──────────────────────┐ │ │ │ 关系数据库 NoSQL 大数据生态 (RDBMS) (非关系) (数据湖/数仓) │ │ │ ┌───┴───┐ ┌─────┴─────┐ ┌────┴─────┐ │ │ │ │ │ │ OLTP OLAP 键值/列族 文档/图形 数据仓库 数据湖 MySQL Doris Redis MongoDB Snowflake S3/HDFS PG ClickHouse Cassandra Neo4j BigQuery Oracle StarRocks DynamoDB Snowflake 横切维度： ─────────────────────────────── HTAP TiDB / CockroachDB / SingleStore Lakehouse Iceberg / Hudi / Delta / Paimon 时序 InfluxDB / TimescaleDB / Prometheus 图 Neo4j / JanusGraph / NebulaGraph 1.2 分类维度 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 按数据模型： - 关系：MySQL / PG / Oracle - 键值：Redis / DynamoDB - 列族：Cassandra / HBase / Bigtable - 文档：MongoDB / CouchDB - 图：Neo4j / Dgraph - 时序：InfluxDB / TimescaleDB - 全文：Elasticsearch 按负载类型： - OLTP（联机事务处理）：高并发短事务 - OLAP（联机分析处理）：长查询聚合 - HTAP（混合）：两者兼顾 按部署形态： - 单机：传统 RDBMS - 分布式：Cassandra / HBase / Spanner - 云原生：Snowflake / BigQuery / Aurora - 一体机：Oracle Exadata / Teradata 二、OLTP（联机事务处理） 2.1 特征 1 2 3 4 5 6 7 8 9 10 11 12 OLTP 工作负载： - 大量短事务（毫秒级） - 高并发（数千 QPS） - 按主键点查、按索引范围查 - 严格 ACID - 行存为主 典型业务： - 电商交易 - 银行账户 - 用户登录 - 在线游戏 2.2 代表产品 产品 特点 MySQL 互联网首选，简单稳定 PostgreSQL 复杂查询强，扩展丰富 Oracle 企业级标杆，商用 SQL Server 微软生态首选 Aurora AWS 的\u0026quot;云原生 MySQL/PG\u0026quot;，存算分离 TiDB 兼容 MySQL 协议的 HTAP CockroachDB 兼容 PG 协议的全球分布式 2.3 OLTP 的边界 1 2 3 4 5 6 7 OLTP 不擅长： - 跨表大规模聚合（数据分析） - 千万行以上的范围扫描 - 复杂的窗口函数 - 海量历史数据存储 → 这些场景交给 OLAP / 数据仓库 三、OLAP（联机分析处理） 3.1 特征 1 2 3 4 5 6 7 8 9 10 11 12 OLAP 工作负载： - 少量长查询（秒~分钟级） - 大范围扫描 - 多维聚合（GROUP BY 多列） - 容忍弱一致性 - 列存为主 典型业务： - BI 报表 - 实时大屏 - 用户行为分析 - 日志检索 3.2 代表产品 1 2 3 4 5 6 7 8 9 10 11 12 传统 OLAP（MPP）： - Teradata（一体机） - Greenplum（PG 衍生） - Vertica（列存） - Neteeza（IBM，已停） 新一代 OLAP： - ClickHouse：极致单表性能 - Apache Doris：开箱即用的实时数仓（详见本博客 Doris 系列） - StarRocks：Doris 商业升级，联邦查询强 - Apache Druid：时序 + 实时分析 - Apache Pinot：LinkedIn 出品，类似 Druid 3.3 OLAP 引擎坐标 1 2 3 4 5 6 7 8 9 10 11 12 13 高并发查询 ↑ │ Doris │ Druid │ Pinot ─────────────────┼──────────────→ 实时性 │ ClickHouse │ │ Greenplum │ Vertica ↓ 海量批处理 1 2 3 4 本博客已发布的 OLAP 笔记（坐标定位，不再展开）： - Elasticsearch 系列：搜索引擎 + 实时分析 - Doris 系列：实时数仓 / OLAP - Redis 系列：缓存（KV，不是 OLAP 但常配合） 四、HTAP（混合负载） 4.1 HTAP 的动机 1 2 3 4 5 6 7 8 9 10 11 12 13 传统架构： OLTP（MySQL）→ CDC / ETL → OLAP（ClickHouse / Hive） 问题： - 数据延迟（T+1 或分钟级） - 数据冗余（多套存储） - ETL 维护复杂 - 一致性难保证 HTAP 目标： 一个数据库同时扛 OLTP + OLAP - 实时（无 ETL） - 单一数据源 - 简化架构 4.2 HTAP 的实现思路 1 2 3 4 5 6 7 8 9 思路 1：行列双存（Oracle IMCS / TiDB TiFlash） - 行存（用于 OLTP）+ 列存（用于 OLAP） - 后台同步两份 思路 2：单存优化（SingleStore） - 一种\u0026#34;通用\u0026#34;存储同时支持两种负载 思路 3：存算分离 + 多引擎（Snowflake / BigQuery） - 计算层动态选择执行引擎 4.3 代表产品 产品 特点 TiDB MySQL 兼容，TiKV（行）+ TiFlash（列） CockroachDB PG 兼容，全球分布式 SingleStore MemSQL 改名，统一存储 Oracle 12c+ In-Memory 选项 SQL Server Columnstore + 行存 OceanBase 阿里，金融级 HTAP 4.4 HTAP 的代价 1 2 3 4 5 6 7 8 9 10 11 12 13 14 代价： - 写入放大（双存） - 资源占用高 - 调优复杂（两套优化） - 不可能同时极致优化两种负载 适用场景： - 中等规模（TB 级） - 实时分析需求强 - 不想维护 ETL 链路 不适用： - 超大规模 OLAP（PB 级）→ 单独的 OLAP 引擎 - 极致 OLTP 性能 → 单纯的 OLTP 五、数据仓库（Data Warehouse） 5.1 数仓的定义 1 2 3 4 5 6 7 8 9 10 数据仓库（Inmon 定义）： - 面向主题 - 集成 - 时变 - 非易失 特点： - 来自多个源系统的统一数据存储 - 用于决策支持、报表、分析 - 历史 ETL 数据沉淀 5.2 数仓的演进 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 第一代：单机数据库 + OLAP 工具（90s） - Oracle / SQL Server + Hyperion / BO 第二代：MPP 数据仓库（2000s） - Teradata / Greenplum / Vertica - Shared-Nothing 架构 - 性能远超单机 第三代：Hadoop / Hive（2010s） - 基于 HDFS - 便宜但慢 - 离线数仓 T+1 第四代：云原生数仓（2015+） - Snowflake / BigQuery / Redshift - 存算分离 - 弹性扩展 - 按用量付费 第五代：实时数仓 / Lakehouse（2020+） - Doris / StarRocks - Iceberg / Hudi / Delta - 实时 + 离线一体 5.3 数仓的经典分层 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 数据分层（Kimball 模型）： ODS（原始数据层）：源系统原始数据 │ │ 清洗、转换 ▼ DWD（明细数据层）：标准化的事实明细 │ │ 轻度聚合 ▼ DWS（汇总数据层）：按主题汇总 │ │ 业务加工 ▼ ADS（应用数据层）：报表、大屏、API 数据 ETL 工具：Hive / Spark / Flink / dbt 5.4 云原生数仓 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 Snowflake（2015）： - 完全 SaaS，无运维 - 存算分离（S3 + 计算） - 多云（AWS / Azure / GCP） - 按用量付费 - 自动扩展 BigQuery（2010）： - Google 内部 Dremel 开源 - Serverless - PB 级查询 - 标准 SQL Redshift（2013）： - AWS 的 MPP 数仓 - ParAccel 改造 - 性价比好 Azure Synapse： - 微软的云数仓 - 整合 Power BI / ADF 六、Lakehouse（湖仓一体） 6.1 Lakehouse 的诞生 1 2 3 4 5 6 7 8 9 10 11 传统架构痛点： 数据湖（HDFS/S3 + 文件）： - 便宜，能存所有数据 - 但没有事务、没有 schema 强制 - \u0026#34;数据沼泽\u0026#34; 数据仓库： - 强 schema、ACID - 但贵、闭环 Lakehouse：把数据仓库的特性（ACID、schema、查询）带到数据湖上 6.2 Lakehouse 表格式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 开源三剑客 + 一匹黑马： Apache Iceberg（Netflix）： - 表格式 - ACID 事务（基于快照） - 时间旅行 - Schema 演化 - 分区演化 - Spark / Flink / Trino / Doris / StarRocks 都支持 Apache Hudi（Uber）： - 表格式 + 数据管理 - 支持 Upsert（更新插入） - 增量查询 - CDC 友好 Delta Lake（Databricks）： - Databricks 出品 - ACID 事务 - 与 Spark 深度集成 - 商业版有优化 Apache Paimon（Flink 表格）： - 流批一体 - Flink 主推 - 中国社区活跃（阿里主导） 6.3 四种格式对比 维度 Iceberg Hudi Delta Paimon 出身 Netflix Uber Databricks Flink 主推场景 分析 CDC / 增量 Spark 生态 流批一体 Schema 演化 ✅ 强 ✅ ✅ ✅ 时间旅行 ✅ ✅ ✅ ✅ ACID ✅ ✅ ✅ ✅ Upsert 性能 中 强 中 强 生态支持 最广 中 Spark 强 Flink 强 6.4 Lakehouse 架构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 ┌────────────────────────────────────────┐ │ 查询引擎（计算层） │ │ Spark / Flink / Trino / Doris │ └──────────────────┬─────────────────────┘ │ │ 读 / 写表格式 ▼ ┌────────────────────────────────────────┐ │ 表格式（元数据 + 事务） │ │ Iceberg / Hudi / Delta / Paimon │ └──────────────────┬─────────────────────┘ │ │ 元数据指向数据文件 ▼ ┌────────────────────────────────────────┐ │ 对象存储（存储层） │ │ S3 / OSS / HDFS / Azure Blob │ └────────────────────────────────────────┘ 特点： - 存储、表格式、计算解耦 - 每层可独立选择 - 多引擎共享同一份数据 - 弹性扩展 七、NoSQL 速览 7.1 NoSQL 的\u0026quot;非\u0026quot;关系 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 NoSQL = Not Only SQL 不同 NoSQL 解决不同问题： KV（键值）： - Redis / Memcached（内存） - DynamoDB / etcd（持久） 特点：极致性能，简单数据模型 列族（Wide Column）： - Cassandra / HBase / ScyllaDB 特点：超高写入吞吐，海量稀疏数据 文档（Document）： - MongoDB / CouchDB 特点：JSON 数据，灵活 schema 图（Graph）： - Neo4j / Dgraph / NebulaGraph 特点：关系密集数据（社交、推荐） 时序（Time Series）： - InfluxDB / TimescaleDB / Prometheus 特点：时间戳索引，写入密集 全文检索： - Elasticsearch / OpenSearch / Solr 特点：倒排索引，文本搜索（详见本博客 ES 系列） 7.2 CAP 定理 1 2 3 4 5 6 7 8 9 10 11 12 13 CAP 定理（Brewer）： - C（Consistency）：一致性 - A（Availability）：可用性 - P（Partition tolerance）：分区容忍 只能选两个（实际上 P 必选，所以是 C vs A）： CP：Cassandra（默认）/ HBase / MongoDB AP：CouchDB / DynamoDB（默认） 不同选择对应不同业务场景： - 银行 / 关键业务：CP（保证一致） - 社交 / 推荐：AP（容忍最终一致） 7.3 BASE 与最终一致性 1 2 3 4 5 6 7 BASE = Basically Available + Soft state + Eventually consistent 对应 ACID 的对立面 最终一致性的典型场景： - 微信朋友圈：你刚发的动态，朋友可能延迟 1 秒看到 - 电商库存：超卖允许（事后对账） - 用户画像：分钟级同步 八、选型决策树 8.1 按场景选型 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 Q1：你的负载是 OLTP 还是 OLAP？ → OLTP：看 Q2 → OLAP：看 Q4 → 两者都有：看 Q6（HTAP） Q2：数据规模？ → 单库 \u0026lt; 5TB：MySQL / PostgreSQL → 单库 \u0026gt; 5TB 但可控：分库分表（ShardingSphere / Vitess） → 单库压不下：分布式数据库（TiDB / CockroachDB） Q3：业务复杂度？ → 简单 CRUD：MySQL → 复杂查询 / JSON / GIS：PostgreSQL → 微软生态：SQL Server → 预算充足 / 关键业务：Oracle Q4：查询模式？ → 实时多维分析：Doris / StarRocks → 单表极致性能：ClickHouse → 时序 + 分析：Druid / Pinot → 文本搜索：Elasticsearch Q5：数据规模 + 实时性？ → PB 级 + 离线：Hive / Spark on S3 → PB 级 + 实时：Lakehouse（Iceberg / Hudi） → TB 级 + Serverless：Snowflake / BigQuery Q6：HTAP 评估？ → 数据规模 TB 级 + 不想维护 ETL：TiDB / CockroachDB → 关键业务 + 预算充足：Oracle IMCS / OceanBase 8.2 实战决策 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 典型组合： 小型 SaaS（百万级用户）： MySQL（OLTP）+ Redis（缓存）+ Elasticsearch（搜索） 互联网公司（亿级用户）： MySQL 分片 + Redis 集群 + Doris（实时数仓）+ Hive（离线） 传统企业（关键业务）： Oracle RAC（核心）+ SQL Server（部门）+ Power BI 数据驱动公司： PostgreSQL（业务）+ Snowflake（数仓）+ dbt（ETL） IoT / 监控： TimescaleDB / InfluxDB（时序）+ Grafana（可视化） 推荐系统： MySQL / TiDB（OLTP）+ Neo4j（图）+ ClickHouse（行为分析） 九、回顾与系列总结 9.1 本系列 10 篇回顾 篇 主题 核心知识 1 总览 一行 UPDATE 串联的 10 个原理子系统 2 存储引擎 物理页 + HEAP/IOT + 行存/列存 3 索引 B+ 树 + 聚集/非聚集 + 失效坑 4 事务 ACID Undo（A）+ Redo（D）+ WAL 5 MVCC 多版本 + 可见性 + 隔离级别 6 锁 Record/Gap/Next-Key + 死锁检测 7 日志恢复 ARIES + Checkpoint + PITR 8 复制高可用 物理/逻辑复制 + MGR/AlwaysOn/ADG 9 SQL 优化器 CBO + 统计信息 + 执行计划 10 全景 OLTP/OLAP/HTAP/数仓/Lakehouse 9.2 关键认知 1 2 3 4 5 6 7 8 9 10 11 12 13 14 1. 关系数据库的\u0026#34;原理\u0026#34;是共通的 - 四大家解决同一组问题 - 只是命名和细节不同 - 学透一个，迁移成本很低 2. 选型看场景 - 没有最好的数据库 - 只有最合适的数据库 - 选错比\u0026#34;不会用\u0026#34;代价大 3. 底层原理决定上限 - 性能调优必须懂底层 - 排错必须懂底层 - 升级迁移必须懂底层 9.3 后续学习路径 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 本系列是\u0026#34;原理地图\u0026#34;，深入某一项可看： 存储引擎： 《Database Internals》（Alex Petrov） 《Designing Data-Intensive Applications》（Martin Kleppmann） 事务： 《Transaction Processing》（Jim Gray） 《Database System Concepts》（Silberschatz） PostgreSQL： 《The Internals of PostgreSQL》 官方文档\u0026#34;Internals\u0026#34;章节 MySQL： 《MySQL 实战 45 讲》（林晓斌） 《High Performance MySQL》 分布式： 《Designing Data-Intensive Applications》 Raft Paper / Paxos Paper 数仓 / Lakehouse： Kimball《数据仓库工具箱》 Iceberg / Hudi / Delta 官方文档 9.4 系列正式完结 本系列 10 篇覆盖：\n关系数据库的核心原理（存储、索引、事务、MVCC、锁、日志、复制、SQL） 四大数据库（Oracle / SQL Server / MySQL / PostgreSQL）的横向对比 数据存储生态全景（OLTP / OLAP / HTAP / 数仓 / Lakehouse / NoSQL） 希望这份笔记对你建立系统的数据库知识体系有所帮助。后续如需深入某个方向，欢迎继续探索。\n1 2 3 4 记住三句话： 1. 数据库原理是一张网，不是一条线 2. 选型 = 场景 + 约束 + 经验，没有标准答案 3. 学无止境——技术会过时，原理不会 完。\n","date":"2025-05-24T10:00:00+08:00","permalink":"/posts/database/fundamentals/10-storage-landscape/","title":"数据库系列（十）：数据存储全景 — OLTP / OLAP / HTAP / 数仓 / Lakehouse 一图看懂"},{"content":"写在前面 前面 8 篇都在讲存储与事务的底层机制，本文跳出\u0026quot;物理层\u0026quot;，进入\u0026quot;逻辑层\u0026quot;——SQL 方言与查询优化器。\n本文要回答：\n同样的 SQL，为什么在 PostgreSQL 比 MySQL 快 10 倍？RBO 和 CBO 有什么区别？统计信息为什么这么重要？为什么参数嗅探是个坑？\n一、SQL 标准与方言 1.1 SQL 标准的演进 1 2 3 4 5 6 7 8 9 10 SQL-86（SQL-87）：第一版，IBM DB2 主导 SQL-89：minor update SQL-92（SQL2）：重大升级，加入 JOIN 语法 SQL:1999（SQL3）：触发器、递归、正则 SQL:2003：窗口函数、XML、自动生成 ID SQL:2006：XML 增强 SQL:2008：INSTEAD OF、TRUNCATE SQL:2011：时态表 SQL:2016：JSON、行模式识别 SQL:2019：多维数组 1 2 3 4 观察： - SQL 标准 ≠ SQL 实现 - 每家数据库都\u0026#34;接近标准 + 自有扩展\u0026#34; - 实际开发中以厂商文档为准 1.2 各家方言的特点 数据库 方言 风格 Oracle PL/SQL 过程化、Ada 风格 SQL Server T-SQL 过程化、C 风格 MySQL SQL（兼容标准） 极简 PostgreSQL PL/pgSQL 类 PL/SQL、Oracle 风格 1 2 3 4 5 关键观察： - Oracle 的 PL/SQL 是最\u0026#34;重\u0026#34;的（包、过程、触发器、对象类型） - PG 的 PL/pgSQL 模仿 Oracle（让 Oracle 用户过渡平滑） - MySQL 没有强\u0026#34;方言\u0026#34;，更多是 ANSI SQL - SQL Server 的 T-SQL 在微软生态独特 二、数据类型对比 2.1 数值类型 用途 Oracle SQL Server MySQL PostgreSQL 整数（小） NUMBER(7,0) TINYINT / SMALLINT TINYINT / SMALLINT smallint 整数（标准） NUMBER(10,0) INT INT integer 整数（大） NUMBER(19,0) BIGINT BIGINT bigint 自定义精度 NUMBER(p,s) DECIMAL(p,s) DECIMAL(p,s) numeric(p,s) 浮点 BINARY_FLOAT/DOUBLE REAL/FLOAT FLOAT/DOUBLE real/double 自增 SEQUENCE + 触发器 IDENTITY AUTO_INCREMENT SERIAL/BIGSERIAL 1 2 3 4 5 陷阱： - Oracle 没有 INT 关键字（其实有别名，但底层都是 NUMBER） - Oracle 的 NUMBER(10,0) 占 8~21 字节，比 PG 的 int（4 字节）大 - MySQL 的 TINYINT(1) 经常被当成 boolean，但存储的还是数字 - PostgreSQL 的 SERIAL 是 SEQUENCE + DEFAULT 的语法糖 2.2 自增主键 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 -- Oracle 12c+ 终于有 IDENTITY CREATE TABLE t (id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY); -- 12c 之前：SEQUENCE + 触发器 CREATE SEQUENCE seq_t START WITH 1; CREATE TABLE t (id INT PRIMARY KEY); CREATE TRIGGER t_trg BEFORE INSERT ON t FOR EACH ROW BEGIN SELECT seq_t.NEXTVAL INTO :new.id FROM dual; END; -- SQL Server CREATE TABLE t (id INT IDENTITY(1,1) PRIMARY KEY); -- MySQL CREATE TABLE t (id INT AUTO_INCREMENT PRIMARY KEY); -- PostgreSQL CREATE TABLE t (id SERIAL PRIMARY KEY); -- 9.x 经典 -- 或 CREATE TABLE t (id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY); -- 10+ 2.3 字符串类型 用途 Oracle SQL Server MySQL PostgreSQL 定长 CHAR CHAR CHAR char 变长 VARCHAR2 VARCHAR VARCHAR varchar 大文本 CLOB VARCHAR(MAX) / TEXT TEXT/LONGTEXT text 二进制 BLOB VARBINARY(MAX) BLOB/LONGBLOB bytea 1 2 3 4 5 坑： - Oracle 的 VARCHAR2 vs VARCHAR：官方推荐 VARCHAR2 - MySQL 的 VARCHAR(255) 之内只用 1 字节存长度，256+ 用 2 字节 - PostgreSQL 没有 VARCHAR2，统一 varchar - SQL Server 的 NVARCHAR 是 UTF-16，VARCHAR 是单字节 2.4 时间类型 用途 Oracle SQL Server MySQL PostgreSQL 日期 DATE（含时间） DATE DATE date 时间 TIMESTAMP TIME TIME time 时间戳 TIMESTAMP DATETIME2 / DATETIME DATETIME / TIMESTAMP timestamp 时区感知 TIMESTAMP WITH TIME ZONE DATETIMEOFFSET TIMESTAMP（无） timestamptz 区间 INTERVAL DAY TO SECOND —— —— interval 1 2 3 4 5 MySQL 的 TIMESTAMP 坑： - 范围 1970~2038（4 字节） - 自动 UTC 转换 - 5.6.4+ 支持小数秒 - DATETIME 不做时区转换，8 字节 2.5 JSON 与高级类型 1 2 3 4 5 6 7 8 9 10 11 12 13 14 -- MySQL 5.7+ CREATE TABLE t (data JSON); SELECT data-\u0026gt;\u0026#39;$.name\u0026#39; FROM t; -- PostgreSQL 9.2+（JSON）/ 9.4+（JSONB） CREATE TABLE t (data JSONB); CREATE INDEX idx_data ON t USING GIN(data); SELECT * FROM t WHERE data @\u0026gt; \u0026#39;{\u0026#34;name\u0026#34;:\u0026#34;Alice\u0026#34;}\u0026#39;; -- SQL Server 2016+ CREATE TABLE t (data NVARCHAR(MAX) CHECK (ISJSON(data) \u0026gt; 0)); -- Oracle 12c+ CREATE TABLE t (data CLOB CHECK (data IS JSON)); 1 2 3 4 观察： - PG 的 JSONB 最强（二进制存储、GIN 索引、丰富的操作符） - MySQL 的 JSON 是文本 + 解析（功能完整但性能不如 JSONB） - SQL Server / Oracle 都是\u0026#34;存文本 + 验证\u0026#34; 三、CTE 与窗口函数 3.1 CTE（Common Table Expression） 1 2 3 4 5 6 7 8 9 10 11 12 -- 标准 SQL，四家都支持 WITH active_users AS ( SELECT id, name FROM users WHERE status = \u0026#39;active\u0026#39; ), recent_orders AS ( SELECT user_id, COUNT(*) AS cnt FROM orders WHERE created_at \u0026gt; NOW() - INTERVAL \u0026#39;7 days\u0026#39; GROUP BY user_id ) SELECT u.name, COALESCE(r.cnt, 0) AS order_count FROM active_users u LEFT JOIN recent_orders r ON u.id = r.user_id; 3.2 递归 CTE 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 -- 经典场景：组织架构、菜单树、路径搜索 WITH RECURSIVE org_tree AS ( -- 基础查询：CEO SELECT id, name, parent_id, 1 AS level FROM employees WHERE id = 1 UNION ALL -- 递归：下属 SELECT e.id, e.name, e.parent_id, ot.level + 1 FROM employees e JOIN org_tree ot ON e.parent_id = ot.id ) SELECT level, name FROM org_tree ORDER BY level; 1 2 3 4 5 四家支持情况： - PostgreSQL：WITH RECURSIVE - SQL Server：WITH（不需 RECURSIVE 关键字） - Oracle：WITH（11g R2+） - MySQL：WITH RECURSIVE（8.0+） 3.3 窗口函数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 -- 排名：每个部门薪水前 3 SELECT dept, name, salary, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn, RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS rk, DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS dense_rk FROM employees ORDER BY dept, rn; -- 累计求和 SELECT date, sales, SUM(sales) OVER (ORDER BY date) AS cum_sales FROM daily_sales; -- 移动平均 SELECT date, sales, AVG(sales) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7 FROM daily_sales; -- LAG / LEAD（前后行比较） SELECT date, sales, LAG(sales) OVER (ORDER BY date) AS prev_sales, sales - LAG(sales) OVER (ORDER BY date) AS diff FROM daily_sales; 1 2 3 4 5 6 支持情况： - 窗口函数：SQL:2003 标准 - PostgreSQL 8.4+ - SQL Server 2005+ - Oracle 8i+ - MySQL 8.0+（最晚加入的） 四、查询优化器 4.1 RBO vs CBO 1 2 3 4 5 6 7 8 9 10 11 12 13 RBO（Rule-Based Optimizer）： - 基于规则 - 比如：\u0026#34;有索引就走索引\u0026#34; - 不考虑数据分布 - 简单但僵化 - Oracle 早期用，现代基本淘汰 CBO（Cost-Based Optimizer）： - 基于代价 - 估算每种执行计划的代价（CPU + I/O） - 选最优 - 需要\u0026#34;统计信息\u0026#34;支撑 - 现代主流 4.2 CBO 的工作流程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 1. 解析 SQL → AST 2. 查询重写（Query Rewrite） - 视图展开 - 谓词下推 - 子查询展开 - 常量折叠 3. 计划枚举 - 单表访问路径（全表扫 / 索引扫 / 索引合并） - JOIN 顺序枚举（A JOIN B vs B JOIN A） - JOIN 方法（Nested Loop / Hash / Merge） 4. 代价估算 - 单表代价 = 行数 × 选择率 × 每行代价 - JOIN 代价 = 左代价 + 右代价 + 连接代价 - 综合考虑 CPU + I/O 5. 选择最小代价的计划 6. 计划执行 4.3 各家优化器 数据库 优化器 特点 Oracle CBO 业界标杆，统计信息丰富，Hint 完善 SQL Server CBO 基于 Cascades 框架 MySQL CBO（5.7+） 相对简单，Hint 不如 Oracle PostgreSQL CBO + GEQO 大表 JOIN 用遗传算法 4.4 PostgreSQL 的 GEQO 1 2 3 4 5 6 7 8 9 10 11 12 问题：多表 JOIN 的顺序枚举复杂度爆炸 - N 张表 JOIN，组合数 ~ N! - 12 表 JOIN：~4.79 亿种顺序 PG 的解法： - 表数 ≥ geqo_threshold（默认 12） - 切换到 GEQO（Genetic Query Optimization） - 用遗传算法找近似最优 代价： - 不保证最优 - 但避免了组合爆炸 五、统计信息 5.1 CBO 的\u0026quot;输入\u0026quot; 1 2 3 4 5 6 7 8 9 10 11 12 CBO 基于统计信息估算： - 表行数 - 列的基数（distinct value 数） - 列的最值 - 数据分布（直方图） - 索引高度、叶子数 - 索引聚簇因子（Cluster Factor） 没有统计信息： - CBO 只能猜 - 经常选错索引 - 经常全表扫 5.2 直方图 1 2 3 4 5 6 7 8 9 10 11 12 13 14 直方图：解决\u0026#34;数据分布不均\u0026#34; 例：status 字段 99% 是 \u0026#39;A\u0026#39;，1% 是 \u0026#39;B\u0026#39; WHERE status = \u0026#39;A\u0026#39; → 99% 行匹配 WHERE status = \u0026#39;B\u0026#39; → 1% 行匹配 没直方图：CBO 估算都是 50%（默认假设均匀分布） 有直方图：CBO 知道 \u0026#39;A\u0026#39; 99%，\u0026#39;B\u0026#39; 1%，做精确选择 类型： - 频率直方图（Frequency Histogram） - 等高直方图（Height-Balanced Histogram） - 顶级频率（Top-Frequency） - 混合（Hybrid，Oracle 12c+） 5.3 收集统计信息 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 -- Oracle EXEC DBMS_STATS.GATHER_TABLE_STATS(\u0026#39;SCHEMA\u0026#39;, \u0026#39;TABLE\u0026#39;, cascade =\u0026gt; TRUE); EXEC DBMS_STATS.GATHER_SCHEMA_STATS(\u0026#39;SCHEMA\u0026#39;); -- SQL Server UPDATE STATISTICS t; -- 自动：默认开启（auto_update_statistics） -- MySQL ANALYZE TABLE t; -- 自动：8.0+ 自动采样 -- PostgreSQL ANALYZE t; -- 自动：autovacuum 自动分析 1 2 3 4 5 关键配置： - 目标行数（采样精度） - 是否生成直方图 - 收集频率 - 收集时机（避开业务高峰） 六、执行计划 6.1 四家的查看方式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 -- Oracle EXPLAIN PLAN FOR SELECT * FROM t WHERE id = 1; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY); -- 或更详细： SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(format =\u0026gt; \u0026#39;ALLSTATS LAST\u0026#39;)); -- SQL Server SET SHOWPLAN_TEXT ON; GO SELECT * FROM t WHERE id = 1; GO -- 或图形：SSMS → 显示估计的执行计划 -- MySQL EXPLAIN SELECT * FROM t WHERE id = 1; EXPLAIN FORMAT=JSON SELECT * FROM t WHERE id = 1; EXPLAIN ANALYZE SELECT * FROM t WHERE id = 1; -- 8.0+，实际执行 -- PostgreSQL EXPLAIN SELECT * FROM t WHERE id = 1; EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM t WHERE id = 1; 6.2 读懂执行计划 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 关键字： Seq Scan：顺序扫描（全表扫） Index Scan：索引扫描（取行） Index Only Scan：覆盖索引（不回表） Bitmap Heap Scan：先索引位图，再批量取行 Hash Join：哈希连接（适合大表） Nested Loop：嵌套循环（适合小表） Merge Join：归并连接（已有序） HashAggregate：哈希聚合 Sort：排序 阅读顺序： - 从最里层（缩进最深）开始读 - 自下而上组合 - 关注 rows、cost、actual time 判断： - \u0026#34;rows 估算 = 实际行数\u0026#34; → 统计信息准确 - \u0026#34;rows 估算 \u0026lt;\u0026lt; 实际行数\u0026#34; → 统计信息过时 - \u0026#34;Seq Scan 在大表\u0026#34; → 缺索引 - \u0026#34;Hash Join 内存爆炸\u0026#34; → 内存配置低 6.3 EXPLAIN ANALYZE 的威力 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 -- PostgreSQL EXPLAIN (ANALYZE, BUFFERS, VERBOSE) SELECT u.name, COUNT(*) AS cnt FROM users u JOIN orders o ON u.id = o.user_id WHERE u.created_at \u0026gt; \u0026#39;2026-01-01\u0026#39; GROUP BY u.name ORDER BY cnt DESC LIMIT 10; -- 输出包含： - 估算行数 vs 实际行数 - 估算代价 vs 实际时间 - 缓冲区命中（shared hits vs read） - 内存使用（Hash Memory） - IO 时间 七、常见坑与优化 7.1 隐式类型转换 1 2 3 4 5 6 7 8 -- 经典坑：phone 是 VARCHAR，传 INT SELECT * FROM users WHERE phone = 13800000000; MySQL：自动转换 → CAST(phone AS INT) → 不走索引 PG：报错（更严格，避免错误） Oracle：可能转换 → 索引失效 → 解决：传字符串 \u0026#39;13800000000\u0026#39; 7.2 参数嗅探（Parameter Sniffing） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 SQL Server 的经典问题： CREATE PROCEDURE get_user(@age INT) AS SELECT * FROM users WHERE age = @age; GO -- 第一次执行：@age = 18 -- CBO 看 \u0026#39;age=18\u0026#39; 选择率高 → 走索引 -- 缓存这个计划 -- 第二次执行：@age = 30 -- 实际选择率低，应该全表扫 -- 但用了缓存的\u0026#34;走索引\u0026#34;计划 → 慢 解决： - OPTIMIZE FOR UNKNOWN：用平均选择率 - OPTION (RECOMPILE)：每次重新编译 - 局部变量绕过嗅探 7.3 绑定变量 vs 字面量 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 Oracle 经典问题：共享池污染 不用绑定变量： SELECT * FROM t WHERE id = 1; SELECT * FROM t WHERE id = 2; SELECT * FROM t WHERE id = 3; → 每条都是不同 SQL → 都要解析 → 共享池爆炸 用绑定变量： SELECT * FROM t WHERE id = :1; → 一次解析多次复用 → 高效 MySQL： - 没有\u0026#34;共享池\u0026#34;概念 - 但 prepared statement 也有类似优化 7.4 谓词下推 1 2 3 4 5 6 7 8 9 10 11 -- 优化前：先 JOIN 再过滤 SELECT * FROM big_table b JOIN small_table s ON b.id = s.id WHERE s.status = \u0026#39;active\u0026#39;; -- 优化后（CBO 自动）：先过滤 small_table，再 JOIN SELECT * FROM big_table b JOIN ( SELECT * FROM small_table WHERE status = \u0026#39;active\u0026#39; ) s ON b.id = s.id; -- 四家 CBO 都会自动做谓词下推 -- 但视图 / 子查询嵌套深时可能失败 → 手动改写 7.5 N+1 查询问题 1 2 3 4 5 6 7 8 9 10 11 ORM 常见问题： users = User.objects.all()[:100] # 1 次查询 for u in users: print(u.profile.bio) # 每次循环都查一次 → 100 个用户做了 101 次查询（N+1） 解决： - ORM 的 prefetch_related / eager loading - 手写 JOIN - DataLoader 模式 八、Hint（提示） 8.1 强制 CBO 选某个计划 1 2 3 4 5 6 7 8 9 10 11 12 -- Oracle SELECT /*+ INDEX(t idx_name) */ * FROM t WHERE name = \u0026#39;Alice\u0026#39;; SELECT /*+ FULL(t) */ * FROM t WHERE id = 1; SELECT /*+ LEADING(t1 t2) USE_HASH(t2) */ ... FROM t1 JOIN t2 ON ...; -- MySQL 8.0+ 也支持 SELECT /*+ NO_RANGE_OPTIMIZATION(t PRIMARY) */ * FROM t WHERE id \u0026lt; 100; SELECT /*+ JOIN_ORDER(a, b, c) */ ... -- PostgreSQL：不支持 Hint（设计哲学不同） -- 替代：调整统计信息、SET enable_xxx = off、改写 SQL 1 2 3 4 PG 反对 Hint 的哲学： - Hint 让 SQL 变得\u0026#34;非声明式\u0026#34; - 数据变化后 Hint 仍然生效 → 性能反而变差 - 应该让 CBO 不断改进，而不是手工指定 8.2 Hint 何时用 1 2 3 4 5 6 7 8 适用场景： - CBO 选错索引（统计信息不足） - 临时强制某种 JOIN 方法 - 临时强制某种顺序 不适用： - 长期解决方案（应该修统计信息） - 每条 SQL 都加（说明统计信息严重缺失） 九、查询重写技巧 9.1 IN vs EXISTS 1 2 3 4 5 6 7 8 9 -- 经典问题：IN vs EXISTS 谁快？ -- 没有定论，取决于： - 子查询大小 - 外查询大小 - CBO 实现 -- 现代优化器都能互相转换，效果相同 -- 经验：小表用 IN，大表用 EXISTS（CBO 时代无所谓了） 9.2 NOT IN vs NOT EXISTS 1 2 3 4 5 6 7 8 9 10 11 -- NOT IN 的 NULL 陷阱 SELECT * FROM a WHERE id NOT IN (SELECT a_id FROM b); -- 如果 b.a_id 有 NULL → 整个查询返回空！ -- NOT EXISTS 更安全 SELECT * FROM a WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.a_id = a.id); -- 解决方案：IS NOT NULL SELECT * FROM a WHERE id NOT IN ( SELECT a_id FROM b WHERE a_id IS NOT NULL ); 9.3 避免 SELECT * 1 2 3 4 5 6 7 8 9 不要 SELECT *： - 占用带宽（无用列也传输） - 无法用覆盖索引 - 表结构变化时可能出 bug - 阻断某些视图优化 例外： - 临时调试 - COUNT(*)（COUNT(1) 没有差异） 十、小结 本文学习了 SQL 方言与查询优化器：\nSQL 标准的演进与各家方言差异 数据类型对比（数值 / 字符串 / 时间 / JSON） CTE 与窗口函数（递归、排名、累计、LAG/LEAD） RBO vs CBO 的工作原理 统计信息（行数、基数、直方图）的重要性 执行计划的查看与阅读 常见坑：隐式转换、参数嗅探、绑定变量、N+1 Hint 使用与限制 查询重写技巧 1 2 3 4 记住三句话： 1. 90% 的\u0026#34;数据库慢\u0026#34;问题，根因是统计信息过时 2. EXPLAIN ANALYZE（或等效命令）是性能调优的第一工具 3. 优化 SQL 不是优化\u0026#34;语法\u0026#34;，是优化\u0026#34;执行计划\u0026#34; 下一篇是系列收官：跳出关系数据库，俯瞰整个数据存储生态——OLTP / OLAP / HTAP / 数仓 / Lakehouse / NoSQL 一图看懂。\n","date":"2025-05-20T10:00:00+08:00","permalink":"/posts/database/fundamentals/09-sql-dialect-optimizer/","title":"数据库系列（九）：SQL 方言与查询优化器 — 为什么同样的 SQL 性能差 10 倍"},{"content":"写在前面 承接前一篇日志，本文进入\u0026quot;复制与高可用\u0026quot;——单机数据库解决不了的问题。\n本文要回答：\n主从怎么同步？半同步和异步有什么区别？为什么 MySQL 有 MGR、SQL Server 有 AlwaysOn、Oracle 有 RAC、PG 有流复制？脑裂怎么防？Paxos 和 Raft 是什么？\n一、为什么需要复制 1.1 单机的瓶颈 1 2 3 4 5 6 7 8 9 10 11 单机数据库的天花板： - 容量：单机磁盘上限（TB 级） - 性能：单机 CPU/内存（万级 QPS） - 可用性：单点故障 - 地理分布：跨地域延迟 复制解决： - 高可用：主库挂了切从库 - 读扩展：读分摊到多个从库 - 备份：从库做统计/备份，不影响主库 - 跨地域：在多地部署从库 1.2 复制的基本问题 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 1. 复制什么？ - 物理日志（Redo/WAL） - 逻辑日志（Binlog/逻辑复制） 2. 同步还是异步？ - 异步：主写完就返回，不等从 - 同步：主等所有从确认才返回 - 半同步：主等至少一个从确认 3. 谁来选主？ - 人工指定（传统主从） - 自动选主（MGR / AlwaysON / Patroni） 4. 怎么处理脑裂？ - Quorum（多数派） - Fencing（隔离故障主） - 见证服务器 二、物理复制 vs 逻辑复制 2.1 物理复制 1 2 3 4 5 6 7 8 9 10 11 复制\u0026#34;字节流\u0026#34;——主库 Redo/WAL 直接传给从库 主库 从库 Redo Log ─────────────────→ 应用到本地数据页 特点： ✅ 速度快（字节流直接应用） ✅ 一致性强（页级精确） ❌ 从库必须同版本、同平台 ❌ 不能跨大版本 ❌ 不能选择性复制 2.2 逻辑复制 1 2 3 4 5 6 7 8 9 10 11 12 复制\u0026#34;逻辑变更\u0026#34;——主库解析出\u0026#34;哪行被改了\u0026#34;，传给从库 主库 从库 Binlog / 逻辑日志 ────────→ 解析为 INSERT/UPDATE/DELETE 特点： ✅ 跨版本（甚至跨数据库引擎） ✅ 可选择性复制（按表 / 库） ✅ 异构系统（OLTP→OLAP） ❌ 性能略低于物理复制 ❌ DDL 不会自动复制（PG） ❌ 一致性不如物理复制 2.3 四家选择 数据库 默认复制方式 Oracle 物理（Data Guard）+ 逻辑（GoldenGate） SQL Server 物理（AlwaysOn） MySQL 逻辑（Binlog，传统主从） PostgreSQL 物理（流复制）+ 逻辑（10+） 三、MySQL 复制体系 3.1 传统异步复制 1 2 3 4 5 6 7 8 9 10 11 架构： Master（Binlog）──────→ IO Thread（Slave）──→ Relay Log ↓ SQL Thread（Slave） ↓ 应用到数据 特点： - 主库写完 Binlog 即返回，不等从库确认 - 从库异步拉取 Binlog - 可能丢数据（主库崩溃时未同步的事务） 3.2 半同步复制（Semi-Sync） 1 2 3 4 5 6 7 8 9 10 11 5.5+ 引入，5.7+ 增强 工作流程： 1. 主库写 Binlog 2. 主库 COMMIT 时，至少一个从库 ACK 3. 主库收到 ACK 后才返回客户端成功 特点： - 主库挂了不丢已 ACK 的事务 - 至少一个从库有数据 - 性能比异步慢（多等一个网络往返） 1 2 3 4 5 6 7 8 -- 主库 INSTALL PLUGIN rpl_semi_sync_master SONAME \u0026#39;semisync_master.so\u0026#39;; SET GLOBAL rpl_semi_sync_master_enabled = 1; SET GLOBAL rpl_semi_sync_master_timeout = 1000; -- ms，超时降级为异步 -- 从库 INSTALL PLUGIN rpl_semi_sync_slave SONAME \u0026#39;semisync_slave.so\u0026#39;; SET GLOBAL rpl_semi_sync_slave_enabled = 1; 3.3 组复制（MGR） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 MySQL 8.0+ 提供的高可用方案 基于 Paxos 协议 架构： Primary ─────┐ │ Secondary ───┤ 组（Group） │ Secondary ───┘ 所有成员通过 Paxos 同步事务 特点： - 多主复制（可以多 Primary 写入） - 自动故障检测和选主 - 强一致性（事务提交需多数派确认） - 至少 3 节点（容忍 1 节点失败） 要求： - 必须用 GTID - 表必须有主键 - 默认 RR 隔离级别 - 不支持外键、Savepoint 跨节点 1 2 3 4 5 6 7 8 9 # my.cnf 示例 [mysqld] gtid_mode = ON enforce_gtid_consistency = ON plugin_load_add = \u0026#39;group_replication.so\u0026#39; group_replication_group_name = \u0026#34;aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa\u0026#34; group_replication_local_address = \u0026#34;node1:33061\u0026#34; group_replication_group_seeds = \u0026#34;node1:33061,node2:33061,node3:33061\u0026#34; transaction_write_set_extraction = XXHASH64 3.4 三种方案对比 方案 一致性 性能 自动故障转移 部署复杂度 异步复制 弱 高 ❌ 简单 半同步复制 中 中 ❌ 中 MGR 强 中 ✅ 复杂 1 2 3 4 选型建议： - 互联网 OLTP（容忍少量数据丢失）：异步 + MHA - 重要业务（要数据安全）：半同步 + Orchestrator - 关键业务（要求强一致）：MGR（或上 TiDB） 四、PostgreSQL 复制 4.1 流复制（Streaming Replication） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 物理复制，基于 WAL 架构： Primary ────WAL stream────→ Standby（连续应用） 工作流程： 1. Primary 写 WAL 2. WAL Sender 进程推送给 Standby 3. Standby 的 WAL Receiver 接收 4. Standby 进程应用 WAL 特点： - 物理字节流 - 支持同步 / 异步 - 支持\u0026#34;级联复制\u0026#34;（Standby 也能转发） - Standby 默认只读（Hot Standby） 1 2 3 4 5 6 7 8 # postgresql.conf wal_level = replica # 或 logical synchronous_commit = on synchronous_standby_names = \u0026#39;*\u0026#39; # 同步复制（指定 Standby 名） # Standby recovery.conf / postgresql.auto.conf primary_conninfo = \u0026#39;host=primary port=5432\u0026#39; hot_standby = on # 允许在恢复中查询 4.2 同步复制 1 2 3 4 5 6 PG 同步复制级别（synchronous_commit）： remote_apply = 等从库应用完事务（最严格） remote_flush = 等从库 fsync WAL remote_write = 等从库写 OS Cache local = 只等本地 fsync off = 不等任何东西 4.3 逻辑复制（10+） 1 2 3 4 5 6 7 8 9 10 基于\u0026#34;Publication / Subscription\u0026#34;模型 发布者（主库）： CREATE PUBLICATION pub_orders FOR TABLE orders; -- 只发布 orders 表 订阅者（从库）： CREATE SUBSCRIPTION sub_orders CONNECTION \u0026#39;host=primary\u0026#39; PUBLICATION pub_orders; 1 2 3 4 5 6 7 8 9 10 逻辑复制用途： - 选择性复制（只复制部分表） - 异构系统（PG → Doris / ClickHouse） - 滚动升级（旧版 → 新版） - 多活双向（Bidirectional，复杂） 限制： - 不复制 DDL（要手动同步） - 不复制大对象 - 没有 schema 一致性保证 4.4 PG 高可用方案 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 PG 本身不提供\u0026#34;自动故障转移\u0026#34;，需要外部工具： - Patroni（Zalando 开源，最流行） 基于 etcd / ZooKeeper / Consul 自动选主 + 自动重配 - repmgr（2ndQuadrant 开源） 基于 PG 流复制 简单但功能有限 - pg_auto_failover（Citus 开源） 基于见证节点（monitor） 部署简单 - Stolon（基于 etcd） 类似 Patroni 五、SQL Server AlwaysOn 5.1 两种 AlwaysOn 1 2 3 4 5 6 7 8 9 10 11 AlwaysOn Availability Group（AG）： - 应用层高可用 - 数据库级故障转移 - 多个副本（1 主 + 8 副） - 副本可读 AlwaysOn Failover Cluster Instance（FCI）： - 实例层高可用 - 共享存储（SAN） - 节点级故障转移 - 经典 Windows 集群方案 5.2 AG 工作流程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 架构： Primary Replica ──┬──→ Secondary Replica 1（同步） │ └──→ Secondary Replica 2（异步） 特点： - 基于 SQL Server Network Name（AD 域） - Listener：一个虚拟 IP，应用连接它 - 故障自动转移（次要副本升级为 Primary） - 副本可读（默认 NO，配置后可读） 可用性模式： SYNCHRONOUS_COMMIT：等副本同步（强一致） ASYNCHRONOUS_COMMIT：不等副本（性能优先） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 -- 创建 AG（T-SQL） CREATE AVAILABILITY GROUP MyAG WITH ( AUTOMATED_BACKUP_PREFERENCE = PRIMARY, DB_FAILOVER = ON, DTC_SUPPORT = NONE ) FOR DATABASE MyDB REPLICA ON \u0026#39;Node1\u0026#39; WITH ( ENDPOINT_URL = \u0026#39;TCP://node1:5022\u0026#39;, AVAILABILITY_MODE = SYNCHRONOUS_COMMIT, FAILOVER_MODE = AUTOMATIC, SECONDARY_ROLE(ALLOW_CONNECTIONS = ALL) ), \u0026#39;Node2\u0026#39; WITH ( ENDPOINT_URL = \u0026#39;TCP://node2:5022\u0026#39;, AVAILABILITY_MODE = SYNCHRONOUS_COMMIT, FAILOVER_MODE = AUTOMATIC, SECONDARY_ROLE(ALLOW_CONNECTIONS = READ_ONLY) ); 六、Oracle 高可用 6.1 Active Data Guard（ADG） 1 2 3 4 5 6 7 8 9 10 11 12 物理 Standby，可以同时读 架构： Primary ──Redo Log Stream──→ Standby（Active） ↓ 开放只读查询 特点： - 物理复制（Redo 流） - Standby 应用 Redo 时仍可读（Real Apply Time） - Fast-Start Failover（FSFO）：自动故障转移 - 可以\u0026#34;读时备份\u0026#34;，主库无压力 6.2 Oracle RAC（Real Application Cluster） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 多节点共享存储集群 架构： ┌──────┐ ┌──────┐ ┌──────┐ │Node 1│ │Node 2│ │Node 3│ ← 多实例 └──┬───┘ └──┬───┘ └──┬───┘ │ │ │ └─────────┼─────────┘ │ 共享存储（ASM / SAN） 特点： - 共享存储（所有节点访问同一数据） - Cache Fusion：节点间内存共享 - 任何节点都能写 - 单节点崩溃不影响业务 - 商业版独有 + 价格昂贵 适用： - 关键业务（金融核心） - 高并发 + 不能停服 - 预算充足 6.3 Oracle Sharding 1 2 3 4 5 12.2+ 引入的分片方案 - 按列分片（hash/range/list） - 每个分片独立数据库 - 应用通过 Sharding Key 路由 - 类似 MySQL 分库分表 七、共识协议 7.1 为什么需要共识 1 2 3 4 5 6 7 8 9 分布式系统的根本问题： - 多个节点如何对一个值达成一致？ - 节点可能崩溃、网络可能延迟、消息可能丢 共识协议（Consensus Algorithm）： - Paxos（1989 Lamport） - Raft（2014 Ongaro） - ZAB（ZooKeeper 用） - Gossip（Cassandra 用，弱一致） 7.2 Raft 简化理解 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 Raft 把共识拆成三部分： 1. Leader 选举 2. 日志复制 3. 安全性 角色： Leader：唯一写入入口 Follower：被动接受 Candidate：候选人（选举中） 选举： - Leader 心跳超时 → Follower 成为 Candidate - Candidate 拉票 → 获多数票 → 成为 Leader - 一个 Term 最多一个 Leader 日志复制： 1. 客户端请求 → Leader 2. Leader 写本地日志，向 Follower 复制 3. 多数派 ACK → COMMIT 4. 返回客户端 特点： - 多数派（Majority）原则 - 容忍 (N-1)/2 节点失败 - 3 节点容忍 1 失败，5 节点容忍 2 失败 7.3 数据库中的应用 数据库 共识协议 MySQL MGR Paxos 变种（Mencius / EPaxos） CockroachDB Raft TiDB / TiKV Raft（Multi-Raft） YugabyteDB Raft Spanner Paxos 八、脑裂与防护 8.1 什么是脑裂 1 2 3 4 5 6 7 8 9 10 场景：网络分区 - 主库和多数派失去联系 - 多数派选了新主 - 旧主以为自己是主，继续接受写 - → 两个主同时写 → 数据冲突 危险： - 数据不一致（双主写入） - 业务错乱 - 恢复困难 8.2 防护机制 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 1. Quorum（多数派） - 只有获得多数派支持才能成为主 - 网络分区时少数派自动降级 2. Fencing（隔离） - STONITH（Shoot The Other Node In The Head） - 物理断电旧主 - PG 的\u0026#34;wal_loghints\u0026#34; + 多数派 3. 见证服务器（Witness） - 独立第三方仲裁 - SQL Server AlwaysOn 见证 - Patroni 的 DCS（etcd） 4. Split-Brain Avoidance - 多个心跳路径 - 多数派 + 时间窗口 8.3 各家的脑裂处理 1 2 3 4 5 6 7 8 9 10 11 12 13 14 MySQL MGR：多数派 - 失去多数派的分区自动进入\u0026#34;只读\u0026#34; - group_replication_unreachable_majority_timeout PostgreSQL Patroni：DCS（etcd）+ 多数派 - 主库失去 DCS 连接 → 自动降级为 Standby - 类似\u0026#34;租约\u0026#34;（Lease） SQL Server AlwaysOn：Windows Failover Cluster 仲裁 - Node Majority / Node + Disk Witness / Node + File Share Witness Oracle RAC：CSS（Cluster Synchronization Service） - 节点驱逐（Node Eviction） - 网络心跳 + 磁盘心跳 九、读写分离 9.1 架构模式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 模式 1：应用层路由 Application ──┬──→ Primary（读写） │ └──→ Standby（只读） 模式 2：代理层路由 Application ──→ Proxy（ProxySQL / HAProxy） ↓ ┌──┴──┐ Primary Standby 模式 3：驱动层路由 JDBC URL 配多节点 Connector/J 自动路由 9.2 一致性问题 1 2 3 4 5 6 7 8 9 10 \u0026#34;写后读\u0026#34;问题： T1: 用户写评论 → 主库 T2: 用户刷新 → 读从库（延迟）→ 看不到自己的评论 → 用户体验差 解决方案： 1. 关键路径强制走主库（用户自己的数据） 2. 会话粘滞（写后 5 秒内读主库） 3. 半同步复制降低延迟 4. 业务设计容忍延迟 9.3 主从延迟监控 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 -- MySQL SHOW SLAVE STATUS\\G -- 关注：Seconds_Behind_Master -- PostgreSQL SELECT * FROM pg_stat_replication; -- 关注：write_lag / flush_lag / replay_lag -- SQL Server SELECT * FROM sys.dm_hadr_database_replica_states; -- 关注：log_send_queue_size / redone_queue_size -- Oracle ADG SELECT * FROM v$dataguard_stats; -- 关注：apply lag 十、小结 本文学习了复制与高可用：\n复制动机：高可用、读扩展、备份、跨地域 物理复制 vs 逻辑复制 MySQL 三套方案：异步 / 半同步 / MGR PostgreSQL 流复制 + 逻辑复制 + 外部高可用工具 SQL Server AlwaysOn AG / FCI Oracle ADG + RAC + Sharding Raft / Paxos 共识协议 脑裂防护（Quorum / Fencing / 见证服务器） 读写分离与一致性方案 1 2 3 4 记住三句话： 1. 强一致 = 性能代价（同步复制慢） 2. 多数派共识是分布式数据库的核心（MGR / TiDB / CockroachDB） 3. 高可用 = 复制 + 自动故障转移 + 脑裂防护，三者缺一不可 下一篇进入 SQL 方言与查询优化器：为什么同样的 SQL 在 PG 比 MySQL 快 10 倍？统计信息、执行计划、CBO 的差异。\n","date":"2025-05-16T10:00:00+08:00","permalink":"/posts/database/fundamentals/08-replication-ha/","title":"数据库系列（八）：复制与高可用 — 主从、半同步、MGR、AlwaysOn、ADG"},{"content":"写在前面 承接前几篇，本文聚焦\u0026quot;日志与崩溃恢复\u0026quot;——这是数据库 D（持久性）的实现核心。\n本文要回答：\nRedo Log、Undo Log、Binlog、WAL、Archive Log、Transaction Log——这么多日志都是干嘛的？数据库崩溃后是怎么自动恢复的？为什么 Oracle 重启很快但 MySQL 重启有时很慢？\n一、ARIES 协议 1.1 数据库恢复的理论基础 ARIES（Algorithm for Recovery and Isolation Exploiting Semantics）是 1992 年 IBM 提出的恢复算法，所有现代关系数据库都基于它（变体）。\n1.2 三阶段恢复 1 2 3 4 5 6 7 8 ARIES 三阶段： 1. Analysis（分析）：扫描日志，重建事务表 + 脏页表 2. Redo（重做）：重放所有\u0026#34;已提交但未刷盘\u0026#34;的修改 3. Undo（回滚）：回滚所有\u0026#34;未提交但已写盘\u0026#34;的修改 关键概念： - LSN（Log Sequence Number）：日志序号，全局递增 - WAL 原则：数据页刷盘前，对应日志必须先刷盘 1.3 完整流程示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 时间线： T1: BEGIN T1: UPDATE balance SET v = 100 WHERE id = 1; - 写 Undo Log: id=1, old_v = 50 - 写 Redo Log: id=1, new_v = 100 T2: BEGIN T2: UPDATE balance SET v = 200 WHERE id = 2; - 写 Undo Log: id=2, old_v = 80 - 写 Redo Log: id=2, new_v = 200 T1: COMMIT - 写 COMMIT Log - fsync Redo Log [崩溃] 重启后： Analysis： - T1 已提交（看到 COMMIT） - T2 未提交（没有 COMMIT） Redo（从某个 LSN 开始）： - 重做 T1: id=1 改成 100 - 重做 T2: id=2 改成 200 Undo： - 回滚 T2: 用 Undo Log 改回 id=2 = 80 最终： - id=1: 100（T1 已提交） - id=2: 80（T2 已回滚） 二、Redo Log vs Undo Log 2.1 物理日志 vs 逻辑日志 1 2 3 4 5 6 7 8 9 10 11 12 13 物理日志（Physical Log）： - 记录\u0026#34;页 X 偏移 Y 改成值 Z\u0026#34; - Redo Log 都是物理日志（或物理到逻辑混合） - 重放时精确还原页面 逻辑日志（Logical Log）： - 记录\u0026#34;事务 T 在表 t 改了什么\u0026#34;（行级、SQL 级） - Undo Log 多为逻辑日志 - Binlog 是逻辑日志 为什么 Redo 必须物理？ - 重放时不能假设页处于某种状态 - 物理日志幂等（重复执行结果一致） 2.2 Redo Log 作用 1 2 3 4 5 6 7 8 1. 崩溃恢复：重做未刷盘的已提交事务 2. 加速 COMMIT：顺序写 Redo 比\u0026#34;刷数据页\u0026#34;快得多 3. 支持 PITR（Point-in-Time Recovery，配合归档日志） 特点： - 物理日志 - 顺序追加（部分循环覆盖） - 大小固定（一组文件） 2.3 Undo Log 作用 1 2 3 4 5 6 7 8 1. 事务回滚：失败时撤销修改 2. MVCC：保留旧版本供并发读 3. 崩溃恢复：回滚未提交事务 特点： - 逻辑日志（\u0026#34;改回什么值\u0026#34;） - 通常顺序追加 - 大小动态（不固定） 三、四大数据库的日志体系 3.1 Oracle 1 2 3 4 5 6 7 8 9 10 11 12 13 14 日志类型： - Redo Log（在线 + 归档） - Undo Segment（独立表空间） 文件结构： online redo log：3+ 组，每组 1+ 文件，循环写 redo01.log → redo02.log → redo03.log → 覆盖 redo01.log ... archive log：归档模式（ARCHIVELOG）下，被覆盖前复制到归档位置 关键参数： LOG_BUFFER：Redo Log Buffer 大小 LOG_ARCHIVE_DEST_n：归档目的地 FAST_START_MTTR_TARGET：崩溃恢复目标时间 3.2 SQL Server 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 日志类型： - Transaction Log（.ldf 文件） - 同时承担 Redo 和 Undo（没有独立 Undo Log） 特点： - 单一日志（不像其他三家分开） - 通过版本号在日志中区分 Redo/Undo 部分 - 数据库每个都有独立的 .ldf 工作流程： 1. 修改数据 → 写 Log Buffer 2. COMMIT → flush Log Buffer 到 .ldf 3. 后台 Checkpoint 刷数据页 4. 崩溃恢复：扫 .ldf 重做 / 回滚 关键参数： recovery interval：目标恢复时间 target_recovery_time：每库目标恢复时间（秒） 3.3 MySQL InnoDB 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 日志类型： - Redo Log（ib_redo*，8.0+ 改名） - Undo Log（undo tablespace） - Binlog（Server 层，独立于引擎） 为什么要三套？ - Redo + Undo：引擎内部崩溃恢复 - Binlog：主从复制 + 备份 - 内部 XA 协调 Redo 和 Binlog 工作流程（详见第 4 篇）： 1. 改 Buffer Pool 2. 写 Undo Log 3. PREPARE Redo Log（fsync） 4. 写 Binlog（fsync） 5. COMMIT Redo Log（fsync） 关键参数： innodb_flush_log_at_trx_commit：Redo 刷盘策略（0/1/2） sync_binlog：Binlog 刷盘策略（0/1/N） innodb_log_file_size：单个 Redo Log 文件大小 innodb_log_files_in_group：Redo Log 文件数 3.4 PostgreSQL 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 日志类型： - WAL（Write-Ahead Log，pg_wal/ 目录） - 没有 Undo Log（多版本直接存在 Heap） 特点： - 单一日志（WAL） - 物理日志为主（8.0+ 部分逻辑） - 8.0+ 支持 logical replication（基于逻辑日志） 工作流程： 1. 改 Buffer（标记脏页） 2. 写 WAL Buffer 3. COMMIT → fsync WAL Buffer 4. 后台 Checkpoint 刷脏页 关键参数： wal_level：minimal / replica / logical synchronous_commit：on / off / remote_write / remote_flush max_wal_size：WAL 大小上限（自动 checkpoint） wal_keep_size：为流复制保留的 WAL 大小 3.5 横向对比 维度 Oracle SQL Server MySQL PostgreSQL Redo Redo Log Transaction Log Redo Log WAL Undo Undo Segment （在 .ldf 中） Undo Log Heap 内（旧 tuple） 归档 Archive Log Log Backup Binlog WAL Archive 复制 Redo Stream Transaction Log Binlog WAL Stream 四、Checkpoint 4.1 为什么需要 Checkpoint 1 2 3 4 5 6 7 8 9 10 没有 Checkpoint 的问题： - 崩溃恢复时，需要从头扫描所有 Redo Log - Log 文件越来越大 → 恢复时间越来越长 - 不可能接受 Checkpoint 的作用： - 定期把所有脏页刷盘 - 在 Redo Log 中记录\u0026#34;Checkpoint LSN\u0026#34; - 崩溃恢复时从 Checkpoint LSN 开始扫 - 缩短恢复时间（MTTR） 4.2 工作机制 1 2 3 4 5 6 7 8 9 Checkpoint 流程： 1. 记录当前 Redo Log LSN（记为 C） 2. 把 Buffer Pool 中所有\u0026#34;LSN ≤ C\u0026#34;的脏页刷盘 3. 在 Redo Log 中写入 Checkpoint 记录 4. 更新控制文件中的\u0026#34;最新 Checkpoint LSN\u0026#34; 崩溃恢复： - 从 Checkpoint LSN 开始扫 Redo Log - 之前的数据已经持久化，不需要重做 4.3 各家的 Checkpoint 数据库 触发机制 关键参数 Oracle 基于时间（MTTR） FAST_START_MTTR_TARGET、FAST_START_IO_TARGET SQL Server 基于时间 + LSN recovery interval、indirect checkpoint MySQL InnoDB 基于 LSN 上限 innodb_max_dirty_pages_pct、innodb_io_capacity PostgreSQL 基于 WAL 大小 + 时间 max_wal_size、checkpoint_timeout 1 2 3 4 5 6 7 -- MySQL：控制 Checkpoint 速度 SET GLOBAL innodb_max_dirty_pages_pct = 75; SET GLOBAL innodb_io_capacity = 2000; -- PostgreSQL：调整 Checkpoint 频率 ALTER SYSTEM SET max_wal_size = \u0026#39;4GB\u0026#39;; ALTER SYSTEM SET checkpoint_timeout = \u0026#39;15min\u0026#39;; 4.4 Fuzzy Checkpoint 1 2 3 4 5 6 7 8 9 现代数据库都用\u0026#34;Fuzzy Checkpoint\u0026#34;： - 不停服 - 不冻结写操作 - 后台慢慢刷脏页 - 用户无感 代价： - 需要\u0026#34;两阶段 Checkpoint\u0026#34;（开始/结束 LSN） - 恢复时小范围可能重复 Redo（可接受） 五、崩溃恢复全过程 5.1 通用流程 1 2 3 4 5 6 7 8 9 10 11 1. 读取控制文件，找到最新 Checkpoint LSN 2. 从该 LSN 开始扫 Redo Log - 重建事务表（哪些事务 BEGIN / COMMIT / ROLLBACK） - 重建脏页表（哪些页被修改过） 3. Redo 阶段 - 重放 Checkpoint 之后的所有修改 - 已提交 + 未提交都重做（保证页面状态） 4. Undo 阶段 - 扫描未提交事务的 Undo Log - 回滚这些事务的修改 5. 数据库进入正常状态，开始接受连接 5.2 影响恢复时间的因素 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 1. Checkpoint LSN 到崩溃时刻的距离 - 距离越远，Redo 量越大 - 调小 Checkpoint 间隔可以缩短恢复时间 2. 未提交事务的数量 - 长事务多 → Undo 量大 - 长事务是恢复性能杀手 3. 磁盘随机 I/O 性能 - 恢复涉及大量随机读（读脏页） - SSD 比 HDD 恢复快 10~100 倍 4. 并行恢复能力 - Oracle：多线程并行 Redo / Undo - PG：单线程，慢 - MySQL：InnoDB 8.0+ 部分并行 5.3 实战：恢复时间监控 1 2 3 4 5 6 7 8 9 10 11 12 13 -- Oracle：看上次恢复的预估时间 SELECT estimated_mttr FROM v$instance_recovery; -- MySQL：InnoDB 恢复日志 -- 在 mysqld 启动日志中查看： -- \u0026#34;InnoDB: Doing recovery: scanned X log\u0026#34; -- \u0026#34;InnoDB: Database was not shutdown normally\u0026#34; -- PostgreSQL：在启动日志中 -- \u0026#34;starting archive recovery\u0026#34; -- \u0026#34;redo starts at X\u0026#34; -- \u0026#34;redo done at Y\u0026#34; -- \u0026#34;recovery stopping after commit timestamp Z\u0026#34; 六、刷盘策略与\u0026quot;双一\u0026quot;标准 6.1 刷盘时机 1 2 3 4 5 每次写文件系统的\u0026#34;写\u0026#34;操作其实分两步： 1. 写 OS Page Cache（write()） 2. 刷到磁盘（fsync()） 数据库的\u0026#34;刷盘\u0026#34;通常指 fsync()。 6.2 MySQL 的双一标准 1 2 3 4 5 6 7 8 9 10 11 12 13 innodb_flush_log_at_trx_commit = 1 sync_binlog = 1 含义： - innodb_flush_log_at_trx_commit = 1 每次 COMMIT 都 fsync Redo Log → D（持久性）保证 - sync_binlog = 1 每次事务都 fsync Binlog → 复制一致性保证 为什么叫\u0026#34;双一\u0026#34;？ - 两个参数都 = 1 - 金融场景必须 - 性能代价：每事务多 1~2 次 fsync 6.3 不同级别权衡 1 2 3 4 5 6 7 8 9 innodb_flush_log_at_trx_commit: 0 = 每秒 fsync（崩溃丢最多 1 秒事务）→ 性能好 1 = 每次 fsync（永不丢）→ 默认、最安全 2 = 每次 COMMIT 写 OS Cache，每秒 fsync（OS 崩溃丢，DB 崩溃不丢） sync_binlog: 0 = 由 OS 决定刷盘时机 1 = 每次事务都 fsync → 默认（5.7+） N = 每 N 个事务 fsync 一次 6.4 PostgreSQL 的对应 1 2 3 4 5 6 synchronous_commit = on -- 等于 innodb_flush_log_at_trx_commit = 1 synchronous_commit = off -- 等于 0 synchronous_commit = local -- 类似 1（单机场景） wal_sync_method = fsync -- 刷盘方法 wal_writer_delay = 200ms -- WAL Writer 后台刷盘间隔 七、Binlog 详解 7.1 Binlog 是什么 1 2 3 4 5 6 7 8 MySQL Server 层的逻辑日志 - 记录\u0026#34;所有已提交事务的修改\u0026#34; - 用于主从复制、PITR、CDC（变更数据捕获） Binlog 格式： - STATEMENT：记录 SQL 语句（小，但有些函数 / NOW() 不安全） - ROW：记录每行的修改（大，但安全） ← 推荐 - MIXED：自动选择 7.2 Binlog vs Redo Log 维度 Redo Log Binlog 层级 引擎层（InnoDB） Server 层 内容 物理日志（页/偏移） 逻辑日志（行变更） 写入 循环覆盖 追加写，文件按序号 用途 崩溃恢复 复制、备份、CDC 大小 固定（几个 G） 一直增长 7.3 为什么需要内部 XA 1 2 3 4 5 6 7 8 9 10 11 场景：Redo Log 已写但 Binlog 还没写时崩溃 - 重启后：InnoDB 看到 PREPARE 状态，回滚事务 - 但如果该事务的 Redo 已经被从库消费（不可能，因为还没 COMMIT）... 实际场景：Redo 写完、Binlog 写了一半时崩溃 - 重启后：扫 Redo 找到 PREPARE 事务 - 检查 Binlog 是否完整： * 完整 → 重做（保证从库能消费） * 不完整 → 回滚（保证与从库一致） → 内部 XA 协议保证 Redo + Binlog 的一致性 八、归档与 PITR 8.1 为什么需要归档 1 2 3 4 5 6 7 8 9 10 11 Redo Log / WAL 是循环写的，会被覆盖 - 在线 Redo 容量有限（通常几个 G） - 几小时前的修改可能已经丢失 归档（Archive）： - 把\u0026#34;将要被覆盖\u0026#34;的 Redo / WAL 复制到归档目录 - 归档文件永久保存（直到备份策略清理） - 用于： * 物理备份恢复到任意时间点（PITR） * 增量备份基础 * 流复制从库延迟追赶 8.2 各家的归档 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Oracle：Archive Log - 数据库必须开 ARCHIVELOG 模式 - 后台进程 ARCn 自动归档 - RMAN 备份 / 恢复工具 SQL Server：Transaction Log Backup - 数据库必须 FULL 或 BULK_LOGGED 恢复模式 - 主动执行 BACKUP LOG 命令 MySQL：Binlog（自动归档） - 默认 binlog 一直保留 - binlog_expire_logs_seconds 控制保留时间 PostgreSQL：WAL Archive - archive_mode = on - archive_command = \u0026#39;cp %p /backup/%f\u0026#39; - 11+ 支持 replication slot + 物理备份 8.3 PITR（Point-in-Time Recovery） 1 2 3 4 5 6 7 8 9 10 11 12 场景：今天 14:00 误删了重要表，需要恢复到 13:59:59 流程： 1. 用一周前的全量备份恢复（base restore） 2. 应用归档日志，重放到目标时间点 3. 用 RESETLOGS / 类似命令打开数据库 各家命令： Oracle：RMAN RECOVER DATABASE UNTIL TIME \u0026#34;TO_DATE(\u0026#39;2026-06-15 13:59:59\u0026#39;)\u0026#34; SQL Server：RESTORE LOG ... WITH STOPAT=\u0026#39;2026-06-15 13:59:59\u0026#39; MySQL：mysqlbinlog --stop-datetime=\u0026#39;2026-06-15 13:59:59\u0026#39; binlog.* | mysql PostgreSQL：recovery_target_time = \u0026#39;2026-06-15 13:59:59\u0026#39; 九、小结 本文学习了日志与崩溃恢复：\nARIES 协议：Analysis → Redo → Undo 三阶段 物理日志（Redo）vs 逻辑日志（Undo/Binlog） 四大数据库的日志体系差异 Checkpoint 机制与 Fuzzy Checkpoint 崩溃恢复全过程（影响 MTTR 的因素） MySQL 的\u0026quot;双一\u0026quot;标准（持久性 + 复制一致性） Binlog 与 Redo Log 的内部 XA 协调 归档与 PITR 1 2 3 4 记住三句话： 1. WAL 是数据库持久性的根基——数据页刷盘前必须先写日志 2. Checkpoint 是恢复时间的\u0026#34;分水岭\u0026#34;——之前的不重做 3. MySQL 同时有 Redo 和 Binlog 是历史包袱，但要靠内部 XA 保证一致 下一篇进入复制与高可用：MySQL MGR、PostgreSQL 流复制、SQL Server AlwaysOn、Oracle ADG/RAC，以及共识协议（Paxos / Raft）。\n","date":"2025-05-12T10:00:00+08:00","permalink":"/posts/database/fundamentals/07-log-recovery/","title":"数据库系列（七）：日志与崩溃恢复 — ARIES、WAL、Checkpoint"},{"content":"写在前面 承接前一篇 MVCC，本文继续讨论并发控制——但聚焦锁。MVCC 解决\u0026quot;读写并发\u0026quot;，锁解决\u0026quot;写写并发\u0026quot;。\n本文要回答：\n行锁到底是怎么实现的？SQL Server 为什么会\u0026quot;锁升级\u0026quot;？InnoDB 的 Gap Lock 到底锁了什么？PostgreSQL 怎么用 Advisory Lock 做分布式锁？\n一、锁粒度层级 1.1 多层级的锁 1 2 3 4 5 6 7 8 9 数据库 → 表 → 页 / Extent → 行 层级关系： 上层加意向锁（IS/IX），下层加实际锁（S/X） → 加行锁前，先在表上加意向锁 为什么不直接锁行？ - 防止\u0026#34;表级操作（DROP TABLE）\u0026#34;看不到正在进行的行锁 - 意向锁是一种\u0026#34;快速检测冲突\u0026#34;的机制 1.2 锁模式矩阵 锁模式 含义 兼容 S（Shared） 共享锁，读用 与 S、IS、IX 兼容 X（Exclusive） 排他锁，写用 与所有锁互斥 IS（Intent Shared） 意向共享 与 IS、IX、S 兼容 IX（Intent Exclusive） 意向排他 与 IS、IX 兼容 1 2 3 4 5 6 7 8 9 10 11 12 兼容性矩阵（行级 / 表级）： S X IS IX S ✅ ❌ ✅ ❌ X ❌ ❌ ❌ ❌ IS ✅ ❌ ✅ ✅ IX ❌ ❌ ✅ ✅ 关键观察： - S 和 X 互斥（读和写不能同时） - X 和任何锁互斥（写需要独占） - IS 和 IX 互相兼容（意向只是\u0026#34;标记\u0026#34;，不实际锁定） - S 和 IX 不兼容（表级 S 锁阻止 IX，因为 IX 暗示有行级 X 锁） 二、SQL Server 的锁体系 SQL Server 是四家里锁体系最复杂的，因为它不依赖 MVCC（默认情况下），用锁解决所有并发。\n2.1 多粒度锁 1 2 3 4 5 6 7 8 9 10 11 SQL Server 锁粒度： - RID（行 ID，堆表） - KEY（索引键，clustered/nonclustered） - PAGE（页） - EXTENT（区，8 页） - HoBT（堆或 B 树） - TABLE（表） - FILE（文件） - DATABASE（数据库） → SQL Server 会自动选择锁粒度（行/页/表），称为\u0026#34;动态锁\u0026#34; 2.2 锁升级（Lock Escalation） 1 2 3 4 5 6 7 8 9 10 SQL Server 的\u0026#34;特点\u0026#34;： 每个事务持有过多锁时，自动升级粒度 - 行锁 → 页锁 - 页锁 → 表锁 阈值：单语句持锁超过 5000 个 问题： - 升级后锁范围变大，并发变差 - \u0026#34;我的 SELECT 突然阻塞了所有写入\u0026#34; 这种诡异问题 1 2 3 4 5 6 7 8 9 -- 查看锁 SELECT * FROM sys.dm_tran_locks WHERE resource_database_id = DB_ID(); -- 禁用表锁升级 ALTER TABLE t SET (LOCK_ESCALATION = DISABLE); -- 升级到 partition 而非 table ALTER TABLE t SET (LOCK_ESCALATION = AUTO); 2.3 其他三家的对比 1 2 3 4 5 6 7 8 Oracle / MySQL / PostgreSQL：不锁升级 - 一旦行锁就锁到底（即使持 100 万行锁也不升表锁） - 锁管理器设计成可以处理海量锁 - SQL Server 因为历史原因（早期内存紧张）做了升级 代价： - Oracle / PG 内存占用高（每行锁一个 entry） - 但并发性更好 三、InnoDB 的行锁三档 InnoDB 的锁是四家里最精巧的，因为它要同时支持 RR（防幻读）和高并发。\n3.1 三种行锁 1 2 3 4 5 6 7 8 9 10 11 12 Record Lock（记录锁）： 锁定索引上的一条记录 WHERE id = 5 → 锁 id=5 的索引节点 Gap Lock（间隙锁）： 锁定索引上的\u0026#34;区间\u0026#34;，但不含端点 WHERE id \u0026gt; 5 AND id \u0026lt; 10 → 锁 (5, 10) 间隙 防止其他事务在这个区间 INSERT Next-Key Lock（Next-Key = Record + Gap）： 锁定\u0026#34;一条记录 + 该记录之前的间隙\u0026#34; WHERE id BETWEEN 5 AND 10 → 锁 (3, 5], (5, 8], (8, 10] 等多个 Next-Key 3.2 加锁规则（RR 隔离级别下） 1 2 3 4 5 6 7 InnoDB RR 下的加锁规则（基于《MySQL 实战 45 讲》）： 1. 原则 1：基本单位是 Next-Key Lock 2. 原则 2：查找过程中访问到的对象才加锁 3. 优化 1：唯一索引等值查询，命中记录 → 退化为 Record Lock 4. 优化 2：等值查询，向右遍历到最后一个不满足条件的值 → Next-Key 退化为 Gap Lock 5. 唯一索引范围查询：会访问到不满足条件的第一个值 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 示例表：id (1, 5, 10, 15, 20)，主键 场景 1：等值命中唯一索引 SELECT * FROM t WHERE id = 10 FOR UPDATE; → 锁 id=10 的 Record Lock（不锁间隙） 场景 2：等值未命中唯一索引 SELECT * FROM t WHERE id = 7 FOR UPDATE; → 锁 (5, 10) 的 Gap Lock（防止插入 7） 场景 3：范围查询 SELECT * FROM t WHERE id \u0026gt;= 10 AND id \u0026lt; 15 FOR UPDATE; → 锁 [10, 15) 的 Next-Key + 锁 (10, 15) 的 Gap 场景 4：SELECT * FROM t WHERE id \u0026gt; 10 FOR UPDATE; → 锁 (10, 15], (15, 20], (20, +∞] 3.3 唯一索引 vs 非唯一索引的差异 1 2 3 4 5 6 唯一索引等值命中 → Record Lock（窄） 非唯一索引等值命中 → Next-Key（多个 Gap） 为什么？ - 唯一索引：相同值只能有一个，不需要防止\u0026#34;再插一个相同值\u0026#34; - 非唯一索引：可能有多条相同值记录，需要锁定间隙防止幻读 3.4 RC 级别下 1 2 3 4 5 RC 级别下，InnoDB 退化为只加 Record Lock： - 没有 Gap Lock - 没有 Next-Key Lock - 不防幻读 - 并发性更好 四、PostgreSQL 的锁 4.1 表级锁 1 2 3 4 5 6 7 8 9 10 11 LOCK TABLE t IN {MODE} MODE; 模式（强度递增）： ACCESS SHARE ← SELECT 自动加 ROW SHARE ← SELECT FOR UPDATE/SHARE 自动加 ROW EXCLUSIVE ← INSERT/UPDATE/DELETE 自动加 SHARE UPDATE EXCL ← VACUUM 自动加 SHARE ← CREATE INDEX 自动加 SHARE ROW EXCL ← 手动 EXCLUSIVE ← 手动 ACCESS EXCLUSIVE ← ALTER/DROP/TRUNCATE 自动加 4.2 行级锁 1 2 3 4 5 6 7 8 9 PG 行级锁类型： FOR UPDATE ← 排他，允许其他事务读，阻止写 FOR NO KEY UPDATE ← 排他（弱），允许其他 FOR KEY SHARE FOR SHARE ← 共享 FOR KEY SHARE ← 共享（弱） PG 行锁不存独立结构，记录在行的 xmax： - 优点：节省内存（不限制锁数量） - 缺点：检查冲突需要读行（多版本查询） 4.3 Advisory Lock（应用层分布式锁） 1 2 3 4 5 6 7 8 9 10 11 12 -- 会话级 Advisory Lock SELECT pg_advisory_lock(12345); -- 申请锁 SELECT pg_advisory_unlock(12345); -- 释放 -- 事务级 Advisory Lock（事务结束自动释放） SELECT pg_advisory_xact_lock(12345); -- 非阻塞尝试 SELECT pg_try_advisory_lock(12345); -- 返回 true/false -- 双 int 参数版本（支持 8 字节 key） SELECT pg_advisory_lock(id1, id2); 1 2 3 4 5 6 7 8 9 10 11 12 13 应用场景： - 应用层分布式锁（避免引入 Redis / ZooKeeper） - 跨服务协调（多个服务连同一 PG） - 任务调度（多个 worker 抢任务） 优势： - 与业务事务原子性一致（事务级） - 不需要额外基础设施 - 自动释放（连接断开 / 事务结束） 劣势： - 只能在 PG 内有效 - 不适合跨数据库场景 五、Oracle 的锁 5.1 DML 锁 1 2 3 4 5 6 7 8 9 10 11 Oracle 行锁： - 通过 ITL（块的事务槽）实现 - 锁信息存在数据块头部，不存独立结构 - 不会\u0026#34;锁升级\u0026#34; 锁模式： - RX（Row Exclusive）：INSERT/UPDATE/DELETE - RS（Row Shared）：SELECT FOR SHARE - SRX（Shared Row Exclusive）：手工声明 - S（Shared）：手工声明 - X（Exclusive）：手工声明 5.2 DDL 锁 1 2 3 4 DDL 操作时锁定 schema 对象： - Exclusive DDL Lock：CREATE/ALTER/DROP 时，完全独占 - Share DDL Lock：CREATE PROCEDURE 等，允许其他 DDL 但不允许 DROP - Breakable Parse Lock：缓存依赖关系 5.3 Oracle 没有锁升级 1 2 3 4 5 Oracle 设计哲学： - 锁信息存在数据块里，不占专门内存 - 一行锁 = 块头部 ITL 的一项（几字节） - 100 万行锁也只是 100 万个 ITL entry - 永不升级 六、SELECT FOR UPDATE 家族 6.1 标准语法 1 2 3 4 5 6 -- 标准 SELECT * FROM t WHERE id = 1 FOR UPDATE; -- 加 X 锁，其他事务读可（MVCC），写不行 SELECT * FROM t WHERE id = 1 FOR SHARE; -- 加 S 锁，其他事务可以读，但不能写 6.2 NOWAIT / SKIP LOCKED 1 2 3 4 5 6 7 8 9 10 11 12 -- NOWAIT：拿不到锁立即报错 SELECT * FROM t WHERE id = 1 FOR UPDATE NOWAIT; -- 错误：ORA-00054 / Lock wait timeout exceeded -- SKIP LOCKED：跳过被锁的行（不阻塞） SELECT * FROM t WHERE id IN (1, 2, 3) FOR UPDATE SKIP LOCKED; -- 假设 id=2 被锁，返回 id=1 和 id=3 -- 用途：任务队列 SELECT * FROM task_queue WHERE status = \u0026#39;pending\u0026#39; FOR UPDATE SKIP LOCKED LIMIT 10; -- 多个 worker 同时取任务，互不阻塞 1 2 3 4 5 支持情况： - Oracle：NOWAIT ✅ / SKIP LOCKED ✅ - SQL Server：NOWAIT ✅（WITH NOWAIT）/ SKIP LOCKED ✅（READPAST） - MySQL：NOWAIT ✅（8.0+）/ SKIP LOCKED ✅（8.0+） - PostgreSQL：NOWAIT ✅ / SKIP LOCKED ✅ 6.3 锁列（FOR UPDATE OF） 1 2 3 -- 联合查询时只锁特定表 SELECT * FROM orders o JOIN users u ON o.uid = u.id FOR UPDATE OF o; -- 只锁 orders 行，不锁 users 七、死锁 7.1 死锁场景 1 2 3 4 5 6 7 8 9 事务A 事务B BEGIN; BEGIN; UPDATE t SET v=1 WHERE id=1; UPDATE t SET v=1 WHERE id=2; -- 持有 id=2 锁 UPDATE t SET v=1 WHERE id=2; -- 等待 id=2 锁 UPDATE t SET v=1 WHERE id=1; -- 等待 id=1 锁 → 死锁！双方互相等待 7.2 检测机制 1 2 3 4 5 6 7 8 9 10 11 Wait-For Graph（等待图）： - 数据库维护一个\u0026#34;事务等待关系图\u0026#34; - 检测图中是否有环 - 有环 → 死锁 - 选择\u0026#34;代价最小\u0026#34;的事务回滚（victim） 各家实现： - Oracle：自动检测，回滚代价最小的事务（错误 ORA-00060） - SQL Server：自动检测，默认 5 秒间隔 - MySQL InnoDB：自动检测，立即回滚（默认 deadlock_detect=ON） - PostgreSQL：自动检测，默认 1 秒间隔 7.3 如何避免死锁 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 1. 固定加锁顺序 - 所有事务按相同顺序加锁（如按主键升序） - 避免环 2. 缩短事务 - 持锁时间短，降低死锁概率 - 不要在事务中调用外部 API 3. 用 NOWAIT + 重试 - 拿不到锁立即失败 - 应用层捕获错误后重试 4. 用 SKIP LOCKED - 任务队列场景，跳过被锁任务 5. 控制并发度 - 同一资源并发不要太高 八、悲观锁 vs 乐观锁 8.1 悲观锁（Pessimistic） 1 2 3 4 5 6 7 8 假设冲突必然发生，提前加锁 SELECT * FROM t WHERE id = 1 FOR UPDATE; -- 加锁后才修改 UPDATE t SET v = ... WHERE id = 1; COMMIT; 适合：写冲突频繁 代价：持锁期间其他事务阻塞 8.2 乐观锁（Optimistic） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 假设冲突很少，提交时检测 实现：版本号 / 时间戳字段 CREATE TABLE t (id INT, v INT, version INT); -- 读 SELECT id, v, version FROM t WHERE id = 1; -- 假设读到 version = 10 -- 写（带版本检查） UPDATE t SET v = v + 1, version = version + 1 WHERE id = 1 AND version = 10; -- 影响行数 = 0 → 说明版本变了 → 冲突 -- 应用层重试 适合：读多写少、冲突少 代价：失败时需要重试 8.3 选型建议 1 2 3 4 5 6 7 8 冲突率高（\u0026gt;20%）→ 悲观锁（减少重试开销） 冲突率低 → 乐观锁（避免锁开销） 业务场景： - 库存扣减：悲观（必然冲突） - 用户更新资料：乐观（极少冲突） - 抢购：悲观 + 队列 - 投票计数：乐观 + 重试 九、常见锁问题排查 9.1 MySQL 1 2 3 4 5 6 7 8 9 10 11 12 -- 看当前事务 SELECT * FROM information_schema.innodb_trx; -- 看锁（8.0+） SELECT * FROM performance_schema.data_locks; SELECT * FROM performance_schema.data_lock_waits; -- 死锁日志 SHOW ENGINE INNODB STATUS\\G -- 锁等待超时 SHOW VARIABLES LIKE \u0026#39;innodb_lock_wait_timeout\u0026#39;; 9.2 PostgreSQL 1 2 3 4 5 6 7 8 9 10 11 -- 看锁 SELECT l.locktype, l.relation::regclass, l.pid, l.mode, l.granted, a.query FROM pg_locks l JOIN pg_stat_activity a ON l.pid = a.pid WHERE NOT l.granted; -- 看等待链 SELECT * FROM pg_stat_activity WHERE wait_event_type = \u0026#39;Lock\u0026#39;; -- 杀会话 SELECT pg_terminate_backend(pid); 9.3 Oracle 1 2 3 4 5 6 7 8 9 -- 看锁 SELECT s.sid, s.serial#, l.type, l.mode_held, o.object_name FROM v$lock l JOIN v$session s ON l.sid = s.sid JOIN dba_objects o ON l.id1 = o.object_id WHERE l.type = \u0026#39;TM\u0026#39;; -- 杀会话 ALTER SYSTEM KILL SESSION \u0026#39;sid,serial#\u0026#39;; 9.4 SQL Server 1 2 3 4 5 -- 看阻塞 SELECT * FROM sys.dm_exec_requests WHERE blocking_session_id \u0026lt;\u0026gt; 0; -- 看锁 SELECT * FROM sys.dm_tran_locks WHERE request_status = \u0026#39;WAIT\u0026#39;; 十、小结 本文学习了锁与并发控制：\n锁粒度层级：DB → 表 → 页 → 行 锁模式矩阵：S/X/IS/IX 的兼容性 SQL Server 的锁升级机制（5000 行阈值） InnoDB 的三档锁：Record / Gap / Next-Key InnoDB RR 下的加锁规则 PostgreSQL 的 Advisory Lock（应用层分布式锁） Oracle 的 ITL 实现行锁 SELECT FOR UPDATE 家族（NOWAIT / SKIP LOCKED） 死锁的 Wait-For Graph 检测 悲观锁 vs 乐观锁 1 2 3 4 记住三句话： 1. MVCC 解决读写并发，锁解决写写并发 2. SQL Server 唯一会锁升级，其他三家锁到底 3. InnoDB 的 Next-Key Lock 是\u0026#34;为了 RR 防幻读\u0026#34;的代价 下一篇进入日志与崩溃恢复：ARIES 协议、WAL、Checkpoint、崩溃恢复流程，以及四大数据库的日志实现差异。\n","date":"2025-05-08T10:00:00+08:00","permalink":"/posts/database/fundamentals/06-locking-mechanism/","title":"数据库系列（六）：锁与并发控制 — 从行锁到死锁检测"},{"content":"写在前面 承接事务主题，本文深入 ACID 中的 I——隔离性。MVCC（多版本并发控制）是现代数据库读写并发的核心机制，它让\u0026quot;读不阻塞写、写不阻塞读\u0026quot;成为可能。\n本文要回答：\n为什么 SQL 标准定义的四个隔离级别，各家数据库实现的行为却不一样？为什么 MySQL Repeatable Read 能防幻读，PostgreSQL 不能？为什么 PostgreSQL 表会膨胀？\n一、并发问题：标准定义的四种异常 SQL 标准定义了四种并发异常，按严重程度递减：\n1.1 脏读（Dirty Read） 1 2 3 4 5 6 7 8 9 事务 A 读到了事务 B 未提交的修改。 时间线： T1: 事务B：UPDATE balance SET bal = bal - 100 WHERE id=1; (未提交) T2: 事务A：SELECT bal FROM balance WHERE id=1; → 读到 -100 后的值 T3: 事务B：ROLLBACK; → A 读到了\u0026#34;从未存在\u0026#34;的值 几乎所有现代数据库都默认不允许脏读。 1.2 不可重复读（Non-repeatable Read） 1 2 3 4 5 6 7 事务 A 同一行读两次，结果不同（其他事务已提交）。 时间线： T1: 事务A：SELECT bal FROM balance WHERE id=1; → 1000 T2: 事务B：UPDATE balance SET bal = 900 WHERE id=1; COMMIT; T3: 事务A：SELECT bal FROM balance WHERE id=1; → 900 → A 两次读结果不同 1.3 幻读（Phantom Read） 1 2 3 4 5 6 7 事务 A 同一查询两次，结果集行数不同（其他事务插入/删除了匹配行）。 时间线： T1: 事务A：SELECT * FROM users WHERE age \u0026gt; 18; → 10 行 T2: 事务B：INSERT INTO users (age) VALUES (20); COMMIT; T3: 事务A：SELECT * FROM users WHERE age \u0026gt; 18; → 11 行 → A 多了一行\u0026#34;幻影\u0026#34; 1.4 丢失更新（Lost Update） 1 2 3 4 5 6 7 8 9 两个事务都基于读到的旧值更新，后写的覆盖前写的。 T1: 事务A：读 bal = 1000 T2: 事务B：读 bal = 1000 T3: 事务A：写 bal = 1000 + 100 = 1100 T4: 事务B：写 bal = 1000 + 200 = 1200 → A 的更新丢失 SQL 标准没列这个，但所有数据库都要处理。 1.5 异常矩阵 隔离级别 脏读 不可重复读 幻读 丢失更新 Read Uncommitted ✅ ✅ ✅ ✅ Read Committed（RC） ❌ ✅ ✅ ✅ Repeatable Read（RR） ❌ ❌ ✅ ✅ Serializable ❌ ❌ ❌ ❌ 二、MVCC 基本原理 2.1 朴素的锁方案 1 2 3 4 5 6 7 读：加共享锁 S 写：加排他锁 X S-X 互斥，X-X 互斥 问题：写事务持有 X 锁期间，所有读都被阻塞 - 长事务写入 → 大量 SELECT 等待 - 实际不可用 2.2 MVCC 思路 1 2 3 4 5 6 7 8 9 10 11 12 核心：每行有多个版本，每个事务看到自己\u0026#34;快照\u0026#34; UPDATE 不修改原行，而是： 1. 把原行标记为\u0026#34;旧版本\u0026#34; 2. 写入新行（新版本） 3. 不同事务根据\u0026#34;可见性规则\u0026#34;看到不同版本 事务A（开始早）→ 看到旧版本 事务B（开始晚）→ 看到新版本 → 读和写不冲突！ → 性能远高于锁方案 2.3 MVCC 的代价 1 2 3 4 5 6 7 8 9 10 11 12 代价 1：存储膨胀 - 旧行版本不能立即删 - 等到所有\u0026#34;可能看到旧行\u0026#34;的事务结束后才能清理 - PostgreSQL 这点尤其严重（详见第 7 节） 代价 2：写放大 - 一次 UPDATE = 写一行 + 标记旧行 + 更新索引 - 比直接\u0026#34;原地改\u0026#34;成本高 代价 3：长事务问题 - 长事务持有\u0026#34;旧版本\u0026#34;，导致 Undo/WAL 不能清理 - 拖累整个实例 三、四种 MVCC 实现 四家的 MVCC 实现思路类似（多版本 + 可见性判断），但具体机制不同。\n3.1 Oracle：Undo Segment + SCN 1 2 3 4 5 6 7 8 9 10 11 核心机制： - 每行有\u0026#34;块头 ITL（事务槽）\u0026#34;，记录最后修改它的事务 - Undo Segment 存\u0026#34;旧版本\u0026#34; - SCN（System Change Number）：全局递增的事务编号 可见性判断： - 事务开始时记录一个\u0026#34;快照 SCN\u0026#34; - 读行时： - 行的 ITL SCN ≤ 快照 SCN → 可见 - 行的 ITL SCN \u0026gt; 快照 SCN → 不可见，去 Undo Segment 找旧版本 - 一直往回找，直到找到 ≤ 快照 SCN 的版本 1 2 3 4 5 6 7 8 查询流程： SELECT balance FROM accounts WHERE id = 1; 1. 读到当前行的 balance = 900，ITL SCN = 200 2. 我的快照 SCN = 150 3. 200 \u0026gt; 150 → 不可见 4. 通过 ITL 找到 Undo Segment 中 SCN = 100 的旧版本 balance = 1000 5. 100 ≤ 150 → 可见，返回 1000 3.2 MySQL InnoDB：Undo Log + ReadView 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 核心机制： - 每行隐藏两个列：DB_TRX_ID（最后修改事务 ID）、DB_ROLL_PTR（指向 Undo 链） - Undo Log 中存旧版本，通过 DB_ROLL_PTR 串成链 - ReadView：事务开始时创建的\u0026#34;快照\u0026#34; ReadView 内容： - m_ids：当前活跃（未提交）事务 ID 列表 - min_trx_id：m_ids 最小值 - max_trx_id：下一个要分配的事务 ID - creator_trx_id：创建 ReadView 的事务 ID 可见性判断： - 行的 trx_id \u0026lt; min_trx_id → 该事务已提交 → 可见 - 行的 trx_id \u0026gt;= max_trx_id → 该事务在我之后开始 → 不可见 - 行的 trx_id 在 m_ids 中 → 该事务未提交 → 不可见 - 行的 trx_id == creator_trx_id → 自己改的 → 可见 - 否则 → 可见 1 2 3 4 5 6 7 RC vs RR 的实现差异： - Read Committed：每条 SELECT 都创建新 ReadView → 总是看到最新已提交数据 → 但同一事务内同一行可能看到不同值（不可重复读） - Repeatable Read：事务内只创建一次 ReadView（第一次读时） → 整个事务看到的数据快照一致 → 不会发生不可重复读 3.3 PostgreSQL：xmin/xmax + clog 1 2 3 4 5 6 7 8 9 10 11 12 13 14 核心机制： - 每行有 xmin（创建事务 ID）和 xmax（删除/更新事务 ID） - 没有独立 Undo，旧行就留在 Heap 里 - clog（commit log）：记录每个事务的状态（committed/aborted/in-progress） 可见性判断： - 行的 xmin 已提交且 \u0026lt; 当前快照 → 可见 - 行的 xmax 已提交且 ≤ 当前快照 → 已被删除，不可见 - 复杂规则见 PostgreSQL 源码 HeapTupleSatisfiesMVCC 特点： - 不需要 Undo Log - 旧 tuple 直接留在 heap 中（\u0026#34;表膨胀\u0026#34;） - VACUUM 后台清理 3.4 SQL Server：RCSI / SI（基于 TempDB） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 核心机制： - 行版本存在 TempDB（不是本表） - 两种模式： RCSI（Read Committed Snapshot Isolation）： - Read Committed 默认走行版本 - 每条语句一个快照 SI（Snapshot Isolation）： - 类似 RR，整个事务一个快照 - 需要应用显式启用 启用： ALTER DATABASE mydb SET READ_COMMITTED_SNAPSHOT ON; -- RCSI ALTER DATABASE mydb SET ALLOW_SNAPSHOT_ISOLATION ON; -- SI 注意： - SQL Server 默认 RC 是\u0026#34;读加 S 锁\u0026#34;，不是 MVCC - 启用 RCSI 后才变成 MVCC 行为 3.5 四家对比 维度 Oracle SQL Server MySQL InnoDB PostgreSQL 旧版本存放 Undo Segment TempDB Undo Log Heap 内 事务标识 SCN TID DB_TRX_ID xmin/xmax 清理机制 SMON 自动 TempDB 自动 Purge Thread autovacuum 默认隔离级别 Read Committed Read Committed Repeatable Read Read Committed RR 防幻读 ✅（Serializable） ❌ ✅ ❌ 四、幻读与 Next-Key Lock 4.1 MySQL InnoDB 的 RR：为什么能防幻读 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 InnoDB 在 RR 级别下，普通 SELECT 用 MVCC（快照读），不会幻读 但 UPDATE/DELETE/SELECT FOR UPDATE 用当前读，要靠锁防幻读 Next-Key Lock = Gap Lock + Record Lock 示例： 表：id (1, 5, 10, 15, 20) 索引：id 主键 事务A：SELECT * FROM t WHERE id BETWEEN 5 AND 15 FOR UPDATE; → 锁住 id ∈ [5, 15] 的所有行（Record Lock） → 同时锁住 (15, 20) 之间的\u0026#34;间隙\u0026#34;（Gap Lock） → 防止其他事务插入 id ∈ (10, 15) 或 (15, 20) 事务B：INSERT INTO t VALUES (12); → 阻塞（Gap Lock 拦截） 1 2 3 4 Gap Lock 的副作用： - 锁范围比\u0026#34;实际行\u0026#34;大 - 高并发插入场景容易死锁 - 唯一索引等值查询会\u0026#34;退化\u0026#34;（不需要 Gap Lock） 4.2 PostgreSQL 的 RR：不能防幻读 1 2 3 4 5 6 7 8 PG 的 RR 完全基于 MVCC，没有 Gap Lock： - 整个事务一个快照 - 重复读不会变化（不会不可重复读） - 但其他事务插入的新行，事务后期能看到（幻读） 事务A：SELECT * FROM t WHERE id \u0026gt; 10; → 3 行 事务B：INSERT INTO t VALUES (15); COMMIT; 事务A：SELECT * FROM t WHERE id \u0026gt; 10; → 4 行 ← 幻读！ 1 2 3 4 PG 怎么防幻读？ → 用 Serializable 级别（SSI 实现） → 通过 SIREAD 锁 + 冲突检测 → 性能代价大 4.3 SQL Server：SI 同样不防幻读 1 2 3 4 5 6 SI（Snapshot Isolation）：类似 PG 的 RR - 不会不可重复读 - 但会幻读 - 写冲突时报错（需要重试） 要防幻读 → Serializable 五、Serializable 的\u0026quot;真假\u0026quot; 5.1 真 Serializable vs SSI 1 2 3 4 5 6 7 8 9 10 真 Serializable： - 表级锁 / 范围锁 - 性能极差，几乎不用 - 2PL（Two-Phase Locking）的严格实现 SSI（Serializable Snapshot Isolation）： - PostgreSQL 9.1+、SQL Server（部分） - 基于 MVCC + 冲突检测 - 性能远好于传统 Serializable - 但仍有写冲突代价 5.2 各家 Serializable 实现 数据库 实现 性能 Oracle 表/范围锁 差（生产几乎不用） SQL Server 范围锁 差 MySQL InnoDB 2PL + Next-Key Lock 中 PostgreSQL SSI 较好 5.3 实践建议 1 2 3 4 5 6 7 8 9 10 11 默认隔离级别选择： - Oracle / SQL Server / PG → Read Committed（默认） - MySQL InnoDB → Repeatable Read（默认） 需要\u0026#34;严格串行化\u0026#34;： - 高一致性场景（金融）→ Serializable - 否则 RC/RR 已经够用 PG SSI 用法： SET default_transaction_isolation = \u0026#39;serializable\u0026#39;; -- 业务代码需要重试机制（写冲突会报错） 六、Read Committed 的细节差异 6.1 Oracle / PG 的 RC：语句级快照 1 2 3 4 5 6 每条 SELECT 创建一个新快照，看到当时最新已提交数据。 事务A： SELECT * FROM t WHERE id = 1; -- 假设看到 value = 100 [其他事务改并提交 value = 200] SELECT * FROM t WHERE id = 1; -- 看到 value = 200 6.2 MySQL InnoDB 的 RC：与 Oracle/PG 类似 1 2 3 4 InnoDB 在 RC 级别下： - 每条 SELECT 创建新 ReadView - 不能 Next-Key Lock（退化到 Record Lock） - 不能防不可重复读 6.3 SQL Server 默认 RC：基于锁 1 2 3 4 5 6 7 8 SQL Server 默认 RC 是\u0026#34;读加 S 锁\u0026#34;： - SELECT 期间持 S 锁 - 写事务被阻塞 - 不是 MVCC 启用 RCSI 后变成 MVCC（语句级快照）： ALTER DATABASE mydb SET READ_COMMITTED_SNAPSHOT ON; -- 这是 SQL Server 性能优化关键步骤 七、PostgreSQL 表膨胀与 VACUUM 7.1 为什么 PG 会膨胀 1 2 3 4 5 6 7 PG 没有 Undo Segment，旧 tuple 留在 heap： UPDATE → 插入新 tuple + 标记旧 tuple xmax 旧 tuple 仍在 heap 中，占用空间 长期高频 UPDATE： 原表 1GB → 实际有效数据 200MB + 死 tuple 800MB → 性能下降（扫描慢、缓存命中率低） 7.2 autovacuum 1 2 3 4 5 6 7 PG 自动清理死 tuple 的后台进程： - 默认启用 - 每 N 行变更触发一次 VACUUM - 关键参数： autovacuum_vacuum_threshold = 50（基础行数） autovacuum_vacuum_scale_factor = 0.2（变更比例） → 满足条件：50 + 表行数 × 0.2 1 2 3 4 5 6 7 8 9 -- 手动触发 VACUUM ANALYZE accounts; -- 普通 VACUUM + 更新统计 VACUUM FULL accounts; -- 锁表 + 重写表（回收磁盘空间，慢） REINDEX TABLE accounts; -- 重建索引 -- 监控 SELECT relname, n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE n_dead_tup \u0026gt; 10000; 7.3 长事务的危害 1 2 3 4 5 6 7 8 9 10 11 12 PG 长事务的连锁问题： 1. 旧 tuple 不能被 VACUUM 清理（事务可能还要看到） 2. clog 不能截断 3. 复制槽（replication slot）可能阻塞 WAL 删除 4. 表空间持续膨胀 排查： SELECT pid, age(now(), xact_start) AS duration, query FROM pg_stat_activity WHERE state = \u0026#39;idle in transaction\u0026#39;; → 长事务杀掉：SELECT pg_terminate_backend(pid); 八、常见问题排查 8.1 死锁 1 2 3 4 5 -- MySQL 看最近一次死锁 SHOW ENGINE INNODB STATUS\\G -- PostgreSQL 看日志 -- 默认会记录：ERROR: deadlock detected 8.2 锁等待 1 2 3 4 5 6 -- MySQL 看锁等待 SELECT * FROM information_schema.innodb_trx; SELECT * FROM performance_schema.data_locks; -- PostgreSQL 看锁 SELECT * FROM pg_locks WHERE NOT granted; 8.3 长事务 1 2 3 4 5 6 7 8 9 -- MySQL SELECT * FROM information_schema.innodb_trx WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) \u0026gt; 60; -- PostgreSQL SELECT pid, xact_start, query FROM pg_stat_activity WHERE state IN (\u0026#39;active\u0026#39;, \u0026#39;idle in transaction\u0026#39;) ORDER BY xact_start; 九、小结 本文学习了 MVCC 与隔离级别：\n四种并发异常：脏读、不可重复读、幻读、丢失更新 MVCC 的核心：多版本 + 可见性判断 四家 MVCC 实现：Oracle Undo、InnoDB ReadView、PG xmin/xmax、SQL Server TempDB 隔离级别实现差异：MySQL RR 防幻读（Next-Key Lock），PG RR 不防 Serializable 的真假：传统 2PL vs SSI PostgreSQL 的表膨胀与 VACUUM 机制 长事务的危害 1 2 3 4 记住三句话： 1. RC 是语句级快照，RR 是事务级快照，Serializable 是真串行 2. PG 没有 Undo，旧 tuple 留在 heap → 必须配置 autovacuum 3. MySQL InnoDB 默认 RR + Next-Key Lock 防幻读，但代价是死锁风险 下一篇进入锁与并发控制：从行锁到死锁检测，深入 InnoDB 的 Record/Gap/Next-Key 三档锁、SQL Server 的锁升级、PostgreSQL 的 Advisory Lock。\n","date":"2025-05-04T10:00:00+08:00","permalink":"/posts/database/fundamentals/05-mvcc-isolation/","title":"数据库系列（五）：MVCC 与隔离级别 — 为什么数据库不再读阻塞写"},{"content":"写在前面 承接前三篇，本文进入第三个子系统：事务。ACID 是关系数据库区别于 NoSQL 最核心的特征，但 A、C、I、D 各自怎么实现？为什么 MySQL 写一条数据要刷三次盘？分布式事务为什么又慢又复杂？\n本文横向对比 Oracle、SQL Server、MySQL、PostgreSQL 四家在事务实现上的差异。\n一、ACID 严格定义 ACID 是 1983 年 Andreas Reuter 和 Theo Härder 提出的概念：\n性质 含义 直觉解释 Atomicity（原子性） 事务要么全部成功，要么全部回滚 \u0026ldquo;要么都做，要么都不做\u0026rdquo; Consistency（一致性） 事务前后数据库满足约束 \u0026ldquo;钱不会凭空消失或出现\u0026rdquo; Isolation（隔离性） 并发事务互不干扰 \u0026ldquo;你转账时我看到的是旧余额\u0026rdquo; Durability（持久性） 提交后数据永久保存 \u0026ldquo;断电也不丢\u0026rdquo; 1.1 C 不是引擎保证的 1 2 3 4 5 6 7 8 9 10 11 很多人误以为\u0026#34;一致性是数据库做的\u0026#34;，其实不是： - 引擎保证 A、I、D - C 是\u0026#34;应用 + 约束 + 触发器\u0026#34;共同保证 - 引擎提供约束（外键、CHECK、唯一）作为工具 - 但 C 的根本责任在业务代码 举例： 约束：账户余额不能为负 事务：UPDATE accounts SET balance = balance - 100 WHERE id = 1; - 如果余额只有 50：数据库靠 CHECK 约束拦截（C 由数据库帮助保证） - 如果转账总额对不上：数据库无能为力（C 由应用代码保证） 1.2 谁负责实现 A、I、D 性质 实现机制 A Undo Log（回滚日志） I 锁 + MVCC（详见第 5、6 篇） D Redo Log + WAL + fsync C 应用 + 数据库约束（外键、CHECK） 本文聚焦 A 和 D，I 留给第 5、6 篇。\n二、原子性靠 Undo 2.1 为什么需要 Undo 1 2 3 4 5 6 7 8 事务：UPDATE accounts SET balance = balance - 100 WHERE id = 1; INSERT INTO log VALUES (...); UPDATE summary SET count = count + 1; 如果第二条 INSERT 失败 → 整个事务必须回滚 → 第 1 条 UPDATE 已经改了内存页 balance → 必须知道\u0026#34;原值\u0026#34;才能撤销 → 这就是 Undo Log 的作用 2.2 Undo 的工作流程 1 2 3 4 5 6 7 1. 事务开始 2. 修改行之前，先把\u0026#34;旧值\u0026#34;写入 Undo Log Undo Log: (tx_001, account.id=1, balance: 1000 → ...) 3. 修改 Buffer Pool 中的数据页（balance 变 900） 4. 后续操作失败 → 用 Undo Log 把 balance 改回 1000 5. 事务提交 → Undo Log 标记为\u0026#34;可清理\u0026#34; （但 MVCC 场景下不能立即删，详见第 5 篇） 2.3 四家的 Undo 实现差异 数据库 Undo 存放 命名 特点 Oracle Undo 表空间（独立段） Undo Segment 进程可独立管理，能扩能缩 SQL Server TempDB（短事务）/ 数据库日志 —— 长事务 Undo 在 Transaction Log MySQL InnoDB Undo TableSpace Undo Log 5.6+ 独立表空间，5.7+ 可独立配置 PostgreSQL Heap 表内（旧 tuple） Old Tuple 不存独立 Undo，靠多版本 1 2 3 4 5 6 关键差异：PG 不用独立 Undo - PG 的每行有 xmin/xmax（创建/删除事务 ID） - UPDATE = 标记旧行 xmax + 插入新行（新 xmin） - 旧 tuple 留在 heap 里，直到 VACUUM 清理 - 优点：回滚极快（标记 xmax = 0 即可） - 缺点：表会\u0026#34;膨胀\u0026#34;，需要 VACUUM 维护 2.4 Savepoint 与嵌套事务 1 2 3 4 5 6 7 8 9 -- 标准 SQL Savepoint BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; SAVEPOINT before_log; INSERT INTO log VALUES (...); -- 假设失败 ROLLBACK TO before_log; -- 只回滚到 Savepoint -- 第 1 条 UPDATE 仍然有效 INSERT INTO log VALUES (\u0026#39;fallback\u0026#39;, ...); -- 重试 COMMIT; 四家都支持 SAVEPOINT，但真正的嵌套事务只有 Oracle 部分支持（通过 Autonomous Transaction）。其他三家都靠 Savepoint 模拟。\n2.5 隐式提交的坑 1 2 3 4 5 -- DDL 通常会隐式 COMMIT BEGIN; INSERT INTO log VALUES (\u0026#39;test\u0026#39;); CREATE TABLE temp (id INT); -- 隐式 COMMIT！前一个 INSERT 也被提交 ROLLBACK; -- 无法回滚 INSERT 1 2 3 4 5 6 7 8 9 10 11 12 隐式 COMMIT 的语句： - DDL（CREATE / ALTER / DROP / TRUNCATE） - 部分 DCL（GRANT / REVOKE） - 部分 ADMIN（ANALYZE / VACUUM in PG 不触发） 各家差异： - MySQL：DDL 必隐式 COMMIT（即使是 InnoDB） - PostgreSQL：DDL 也可以在事务内（CREATE TABLE 不触发 COMMIT） - Oracle：DDL 必隐式 COMMIT - SQL Server：DDL 可在事务内 → PG 这点是优势，可以\u0026#34;事务化 DDL\u0026#34;，回滚 CREATE TABLE 没问题 三、持久性靠 Redo + WAL 3.1 为什么需要 Redo 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 问题：内存改了，磁盘还没刷 - Buffer Pool 改了 balance = 900（内存） - 数据页还没刷到磁盘 - 此时断电 → 改动丢失 朴素方案：每次 COMMIT 都把数据页刷盘 - 一个数据页 16KB - 改 1 字节也要刷 16KB - 100 个事务改不同行 → 100 次随机 I/O - 极慢 正确方案：每次 COMMIT 只刷\u0026#34;Redo Log\u0026#34; - Redo Log 只记录\u0026#34;改了什么\u0026#34;（物理日志） - 顺序写（追加到日志文件末尾） - 一次 fsync 即可 - 后台异步把数据页刷盘 3.2 WAL（Write-Ahead Logging） 1 2 3 4 5 6 7 8 9 10 WAL 原则： - 数据页刷盘之前，对应的 Redo Log 必须先刷盘 - 否则崩溃时数据页是\u0026#34;半新半旧\u0026#34;状态，无法恢复 流程： 1. 改 Buffer Pool（dirty page） 2. 写 Redo Log Buffer 3. COMMIT → fsync Redo Log Buffer 到磁盘 4. 后台：dirty page 缓慢刷盘（每次 checkpoint） 5. 崩溃恢复：用 Redo Log 重做未刷盘的页 1 2 3 4 5 6 顺序写 vs 随机写： - 顺序写：100~300 MB/s（SSD） - 随机写：5~50 MB/s（SSD） - 差距 10~30 倍 → 这就是为什么 COMMIT 性能远高于\u0026#34;直接刷数据页\u0026#34; 3.3 四家的 Redo 命名 数据库 Redo Log 关键参数 Oracle Redo Log（online + archive） LOG_BUFFER、FAST_START_MTTR_TARGET SQL Server Transaction Log（.ldf） recovery interval、target_recovery_time MySQL Redo Log（ib_logfile，8.0 改名 ib_redo） innodb_flush_log_at_trx_commit PostgreSQL WAL（pg_wal/ 目录） wal_level、synchronous_commit 3.4 fsync 的代价 1 2 3 4 5 6 7 8 9 10 11 12 \u0026#34;双一\u0026#34;标准（金融场景）： - innodb_flush_log_at_trx_commit = 1：每次 COMMIT 都 fsync Redo Log - sync_binlog = 1：每次事务都 fsync Binlog fsync 性能： - HDD：每次 ~10ms（每秒最多 100 个事务） - SSD：每次 ~1ms（每秒最多 1000 个事务） - NVMe：每次 ~0.1ms（每秒最多 10000 个事务） 优化： - Group Commit：多个事务的 COMMIT 合并成一次 fsync - 异步 fsync（牺牲持久性换性能）：innodb_flush_log_at_trx_commit = 2 四、Group Commit 4.1 朴素 COMMIT 的性能问题 1 2 3 4 5 6 7 8 9 事务 A → COMMIT → fsync → 等 1ms 事务 B → COMMIT → fsync → 等 1ms 事务 C → COMMIT → fsync → 等 1ms 3 个事务共 3ms 事务 A、B、C 几乎同时到达 → 仍然各自 fsync → 3ms → fsync 是顺序的，每次都是磁盘写 4.2 Group Commit 的思路 1 2 3 4 5 6 7 事务 A、B、C 几乎同时到达： 1. A 进入队列，成为 Leader 2. B、C 看到队列有人，加入（成为 Follower） 3. Leader 把 ABC 的 Redo Log 一次性 fsync 4. ABC 全部 COMMIT 成功 → 3 个事务共 1ms（吞吐量 3 倍） 4.3 四家的 Group Commit 数据库 实现名称 关键参数 Oracle Log Writer Group Commit 自动 SQL Server Log Writer Grouping 自动 MySQL 5.7+ Binary Log Group Commit binlog_group_commit_sync_delay PostgreSQL Group Commit（自动） synchronous_commit = on 配合 1 2 3 -- MySQL：开启 Group Commit SET GLOBAL binlog_group_commit_sync_delay = 1000; -- 微秒 SET GLOBAL binlog_group_commit_sync_no_delay_count = 10; -- 满 10 个立即提交 五、两阶段提交（2PC） 为什么 MySQL InnoDB 有 Redo Log 还有 Binlog？它们怎么协调？\n5.1 Binlog vs Redo Log 1 2 3 4 5 6 7 8 9 10 11 Redo Log： - InnoDB 引擎层 - 物理日志（\u0026#34;页号 X 偏移 Y 改成 Z\u0026#34;） - 用于崩溃恢复 - 循环写（覆盖式） Binlog： - Server 层 - 逻辑日志（\u0026#34;行 R 的字段 C 改成 V\u0026#34;） - 用于主从复制、数据回放 - 追加写（不覆盖） 1 2 3 4 5 为什么不只用一种？ - 只用 Redo Log：从库要执行物理日志（页级别），跨版本不兼容 - 只用 Binlog：崩溃恢复时无法精确重放（Binlog 是逻辑日志，无\u0026#34;页是否刷盘\u0026#34;信息） → 两套日志各自有用途，必须协调一致 5.2 内部 2PC 流程 1 2 3 4 5 6 7 8 9 10 11 1. InnoDB 改 Buffer Pool，写 Redo Log（内存） 2. 【Prepare 阶段】 - Redo Log 写入 PREPARE 标记 - fsync Redo Log 3. 【Server 层】 - 写 Binlog - fsync Binlog 4. 【Commit 阶段】 - Redo Log 写入 COMMIT 标记 - fsync Redo Log 5. 返回客户端成功 5.3 崩溃恢复时怎么处理 1 2 3 4 5 6 7 重启后扫描 Redo Log： - 找到所有\u0026#34;PREPARE 但没 COMMIT\u0026#34;的事务 - 检查 Binlog 是否完整： - Binlog 已写完整 → 重做该事务（保证主从一致） - Binlog 不完整 → 回滚该事务 → 这就是\u0026#34;MySQL 内部 XA\u0026#34;——保证 Redo Log 和 Binlog 一致 5.4 半同步复制用到了 2PC 1 2 3 4 5 6 7 8 9 10 半同步复制（Semi-Sync）： - 主库 COMMIT 时，至少一个从库 ACK 收到 Binlog - 否则主库降级为异步复制 阶段： 1. 主库 PREPARE 2. 主库写 Binlog 3. 主库推送 Binlog 到从库 4. 从库 ACK 5. 主库 COMMIT 六、分布式事务 跨多个数据库实例的事务，需要外部协调。\n6.1 XA 标准 1 2 3 4 5 6 7 8 9 10 11 12 XA = X/Open DTP 模型 - TM（Transaction Manager）：协调者 - RM（Resource Manager）：参与者（数据库） - 通信协议：两阶段提交（2PC） 阶段 1：Prepare TM → 各 RM：PREPARE 各 RM → TM：OK / FAIL 阶段 2：Commit / Rollback 全部 OK → TM → 各 RM：COMMIT 任一 FAIL → TM → 各 RM：ROLLBACK 6.2 四家的分布式事务实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 Oracle：XA 接口（OCI） - 长期支持 X/Open XA - 跨多个 Oracle DB 或异构数据源 SQL Server：MSDTC（Microsoft Distributed Transaction Coordinator） - Windows 系统服务 - .NET 通过 TransactionScope 使用 - 跨多个 SQL Server 或异构数据源 MySQL：XA 协议 - XA START \u0026#39;xid\u0026#39;; ... XA END \u0026#39;xid\u0026#39;; XA PREPARE \u0026#39;xid\u0026#39;; XA COMMIT \u0026#39;xid\u0026#39;; - 性能较差，生产较少使用 - 通常用业务层 TCC / SAGA 替代 PostgreSQL：Prepared Transaction - PREPARE TRANSACTION \u0026#39;xid\u0026#39;; COMMIT PREPARED \u0026#39;xid\u0026#39;; - 实现标准 2PC - max_prepared_transactions 控制上限 1 2 3 4 5 6 7 8 9 -- PG 2PC 示例 BEGIN; UPDATE account_a SET balance = balance - 100 WHERE id = 1; PREPARE TRANSACTION \u0026#39;transfer_001\u0026#39;; -- 此处可以连接另一个数据库做对应操作 -- 完成后： COMMIT PREPARED \u0026#39;transfer_001\u0026#39;; -- 或者失败： ROLLBACK PREPARED \u0026#39;transfer_001\u0026#39;; 6.3 2PC 的痛点 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 1. 同步阻塞 - PREPARE 后所有 RM 锁定资源，直到 COMMIT - 一个慢节点拖垮整个事务 2. 协调者单点 - TM 挂了 → 参与者卡在 PREPARE 状态 - 数据锁死，需要人工干预 3. 数据不一致（极端情况） - 阶段 2 时部分 RM 收到 COMMIT，部分没收到 - 需要\u0026#34;重试 + 补偿\u0026#34;机制 4. 性能差 - 多次网络往返 - 锁定时间长 6.4 替代方案：TCC 与 SAGA 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 TCC（Try-Confirm-Cancel）： - 业务层补偿事务 - Try：预留资源（如冻结余额） - Confirm：确认（扣减冻结） - Cancel：取消（解冻） - 性能好，但侵入业务 SAGA： - 长事务拆成多个本地事务 - 每个本地事务有对应的\u0026#34;补偿事务\u0026#34; - 失败时按逆序补偿 - 最终一致性 本地消息表： - 业务表 + 消息表同事务写入 - 后台扫描消息表，推送到 MQ - 下游消费后回调 - 解耦性强，最常用 七、实战：不同持久性级别的权衡 7.1 MySQL 的 innodb_flush_log_at_trx_commit 1 2 3 4 -- 值含义： 0 = 每秒 fsync Redo Log（崩溃可能丢 1 秒数据） 1 = 每次 COMMIT fsync Redo Log（最安全，最慢） ← 默认 2 = 每次 COMMIT 写 OS Cache，每秒 fsync（OS 崩溃丢数据） 1 2 3 4 5 6 7 实测性能（同硬件，1000 个事务）： - 0：1 秒 - 1：30 秒 - 2：3 秒 → 0 和 2 性能好但都不\u0026#34;金融级安全\u0026#34; → 1 是必须的双一标准之一 7.2 PostgreSQL 的 synchronous_commit 1 2 3 4 5 6 7 8 9 -- 值含义： off = 不等待 fsync（极快，可能丢事务） local = 等待本地 fsync（默认） on = 等待本地 + 同步副本 fsync remote_write = 等待副本写入 OS Cache remote_flush = 等待副本 fsync -- 可在会话级调整 SET synchronous_commit = off; -- 临时降低持久性换性能 7.3 事务隔离级别速览 留给第 5 篇深入，这里先列出名称：\n隔离级别 脏读 不可重复读 幻读 Read Uncommitted ✅ ✅ ✅ Read Committed ❌ ✅ ✅ Repeatable Read ❌ ❌ 部分解决 Serializable ❌ ❌ ❌ 八、小结 本文学习了事务与 ACID：\nACID 各性质的实现责任：引擎做 A/I/D，应用做 C 原子性靠 Undo Log（PG 用多版本 tuple） 持久性靠 Redo Log + WAL + fsync 顺序写 fsync 是性能瓶颈，Group Commit 是核心优化 MySQL 的内部 XA：Redo Log 与 Binlog 的两阶段提交 分布式事务的 XA 标准、痛点与替代方案（TCC / SAGA / 本地消息表） 持久性 vs 性能的权衡参数 1 2 3 4 记住三句话： 1. A 靠 Undo，D 靠 Redo，C 靠约束，I 靠锁 + MVCC 2. COMMIT 慢的本质是 fsync，Group Commit 是关键 3. 分布式事务能用本地消息表就别用 XA 下一篇进入隔离性的核心机制——MVCC，深入 Oracle SCN、InnoDB ReadView、PG xmin/xmax 的实现差异。\n","date":"2025-04-30T10:00:00+08:00","permalink":"/posts/database/fundamentals/04-transaction-acid/","title":"数据库系列（四）：事务与 ACID — 从单机原子性到分布式一致性"},{"content":"写在前面 承接前一篇存储引擎的内容，本文进入第二个子系统：索引。索引是数据库性能的核心——一个表加对索引可以让查询从秒级降到毫秒级，加错索引也能让写入变成灾难。\n本文要回答的核心问题：\n为什么几乎所有关系数据库的索引都是 B+ 树？PG 的 GIN/GiST/BRIN 是什么？为什么联合索引要最左前缀？\n一、为什么是 B+ 树 1.1 一个朴素问题 100 万行的表，按 id 查询：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 方案 1：顺序扫描（Seq Scan） 平均读 50 万行 → 几百毫秒 方案 2：哈希索引（Hash Index） 哈希一次 → O(1) 找到行 限制：只支持等值查询（=、IN），范围查询不支持 方案 3：二叉搜索树（BST） 高度 log2(100万) ≈ 20 每次比较一次 + 读一个节点 → 20 次 I/O（如果每节点一页） 限制：树太高，磁盘 I/O 多 方案 4：B 树（B-Tree） 多叉树，每节点存多个 key + 数据 高度 logm(N)，m 通常 100+ → 100 万行高度 2~3 限制：内部节点也存数据 → 节点能放的 key 数少 → 树高较高 方案 5：B+ 树（B+Tree） B 树的变种：所有数据只在叶子节点 内部节点只放 key，叶子节点放完整数据 叶子节点之间用双向链表连接 → 内部节点能放更多 key，树高更低 → 范围查询走叶子链表，极快 1.2 B+ 树的结构 1 2 3 4 5 6 7 8 9 10 11 [10│30│50│70] ← 根（内部节点，只放 key） / | | \\ [1│3│5│8] [11│15│25│28] [31│38│45│48] [51│55│65│68] [71│75│85│88] ↑────────叶子节点链表────────↑ 叶子节点存完整数据（IOT）或行指针（二级索引） 特性： - 高度通常 2~4（千万级数据） - 范围查询：从根定位到叶子，沿链表扫描 - 等值查询：从根定位到叶子 - 每节点 = 一个 Page（默认 8K / 16K） 1.3 为什么 B+ 树胜出 数据结构 等值查询 范围查询 排序 树高（1亿行） Hash ✅ O(1) ❌ ❌ —— 二叉 BST O(log n) ✅ 慢 ✅ 慢 27 红黑树 O(log n) ✅ 慢 ✅ 慢 27 跳表 O(log n) ✅ ✅ 27 B 树 O(logm n) ✅ ✅ 4~5 B+ 树 O(logm n) ✅ 极快 ✅ 极快 2~3 1 2 3 4 5 B+ 树的核心优势： 1. 树矮：每节点放百级 key，3 层就能索引亿级行 2. 范围查询强：叶子链表，连续 I/O 3. 排序免费：B+ 树本身有序 4. 节点 = Page：天然适配缓冲池 1.4 跳表的故事：为什么 Redis 用跳表而 MySQL 用 B+ 树 1 2 3 4 5 6 7 8 9 10 11 Redis 用跳表（ZSet 底层）： - 全内存，I/O 不是瓶颈 - 跳表实现简单（无锁 CAS 友好） - 不在乎树高（27 层和 3 层在内存里差不多） MySQL 用 B+ 树： - 数据在磁盘，I/O 是瓶颈 - 必须让树矮（3 层就够） - 节点要按 Page 组织，匹配磁盘 I/O 单位 一句话：磁盘选 B+ 树，内存选跳表 / 哈希。 二、聚集索引 vs 非聚集索引 承接前一篇的 HEAP vs IOT，索引视角下要再讲一遍，因为这是四大数据库最大的术语混淆点。\n2.1 名词对照 概念 Oracle SQL Server MySQL InnoDB PostgreSQL 主键索引（数据本身） IOT Clustered Index Clustered Index （无，主键也只是索引） 二级索引 Normal Index Nonclustered Index Secondary Index Index 二级索引指向什么 rowid（堆表）/ PK（IOT） RID（堆）/ Clustered Key 主键值 ctid 2.2 二级索引的\u0026quot;回表\u0026quot;成本 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 查询：SELECT name, balance FROM accounts WHERE name = \u0026#39;Alice\u0026#39;; InnoDB： 1. 走 name 二级索引（B+ 树）→ 找到主键值 id=1 2. 走主键索引（B+ 树）→ 找到 id=1 的整行（\u0026#34;回表\u0026#34;） → 2 次 B+ 树查找 PostgreSQL： 1. 走 name 索引 → 找到 ctid=(0,1) 2. 走 Heap → 直接定位到行（\u0026#34;回 heap\u0026#34;） → 1 次索引 + 1 次随机读 差异： - InnoDB：二级索引查到的主键值，再到主键 B+ 树走一遍 - PG：二级索引直接给出物理位置，一次定位到 heap - 单条查询 PG 通常更省 I/O，但需要二级索引足够\u0026#34;宽\u0026#34; 2.3 SQL Server 的两种表状态 1 2 3 4 5 6 7 8 9 10 -- 状态 1：堆表（Heap），无 Clustered Index CREATE TABLE t_heap (id INT, name VARCHAR(50)); -- 二级索引指向 RID（FileID:PageID:SlotID，8 字节） -- 状态 2：Clustered Table（有 Clustered Index） CREATE TABLE t_clustered ( id INT PRIMARY KEY, -- 自动建 Clustered Index name VARCHAR(50) ); -- 二级索引指向 Clustered Key（id 值） 2.4 Oracle 的 IOT 选项 1 2 3 4 5 6 7 8 9 10 -- 默认堆表 CREATE TABLE t1 (id INT PRIMARY KEY, name VARCHAR2(50)); -- PK 是独立的二级索引，指向 rowid -- 显式 IOT CREATE TABLE t2 ( id INT PRIMARY KEY, name VARCHAR2(50) ) ORGANIZATION INDEX; -- 表本身就是按 id 排序的 B+ 树 2.5 设计原则 1 2 3 4 5 6 7 IOT / Clustered Key 的选择原则（适用 InnoDB / MSSQL Clustered / Oracle IOT）： 1. 短：让所有二级索引都跟着变小 2. 单调递增：减少 B+ 树页分裂（如自增 ID） 3. 不变：改主键 → 所有二级索引重建 4. 唯一：Clustered Key 必须唯一（不唯一时引擎会加后缀） PG 反而没这些约束：主键可以随意选，因为所有索引都指向 ctid 三、PG 的索引武器库 PostgreSQL 是四家里索引类型最丰富的。除了 B-tree，还有 GIN、GiST、BRIN、SP-GiST、Hash 五种。\n3.1 B-tree（默认） 1 2 3 4 CREATE INDEX idx_name ON t(name); -- 适用：等值、范围、排序、唯一约束 -- 不适用：JSON 文档检索、全文搜索、地理数据 3.2 GIN（Generalized Inverted Index，倒排索引） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 倒排索引：把\u0026#34;元素 → 行\u0026#34;的映射存起来 适用：数组、JSONB、全文检索 示例表：tags 是 text[] id | tags ---|------ 1 | {a, b, c} 2 | {b, c, d} 3 | {a, c} GIN 索引内部： a → [1, 3] b → [1, 2] c → [1, 2, 3] d → [2] 查询 WHERE \u0026#39;b\u0026#39; = ANY(tags) → 直接用 GIN，O(log n) 1 2 3 4 5 6 7 8 9 10 11 -- 数组索引 CREATE INDEX idx_tags ON t USING GIN(tags); SELECT * FROM t WHERE tags @\u0026gt; ARRAY[\u0026#39;b\u0026#39;]; -- JSONB 索引 CREATE INDEX idx_data ON t USING GIN(data); SELECT * FROM t WHERE data @\u0026gt; \u0026#39;{\u0026#34;city\u0026#34;: \u0026#34;Shanghai\u0026#34;}\u0026#39;; -- 全文索引 CREATE INDEX idx_fts ON docs USING GIN(to_tsvector(\u0026#39;english\u0026#39;, body)); SELECT * FROM docs WHERE to_tsvector(\u0026#39;english\u0026#39;, body) @@ to_tsquery(\u0026#39;postgres \u0026amp; index\u0026#39;); 1 2 3 4 GIN 特点： - 查询快（O(log n)） - 写入慢（每条记录要拆成多个元素入索引） - 适合\u0026#34;低频更新、高频查询\u0026#34;场景 3.3 GiST（Generalized Search Tree） 1 2 3 4 5 6 7 8 9 GiST 是一个\u0026#34;框架\u0026#34;，可以塞各种数据类型： - 地理数据（PostGIS 核心依赖） - 范围类型（int4range、tstzrange） - 模糊匹配（pg_trgm） 示例：找\u0026#34;附近 1km 的店铺\u0026#34; CREATE INDEX idx_location ON shops USING GIST(location); SELECT * FROM shops WHERE ST_DWithin(location, ST_Point(121.4, 31.2), 1000); 3.4 BRIN（Block Range Index） 1 2 3 4 5 6 BRIN：块范围索引 适用：超大的、物理顺序与某列相关的表（如按时间追加的日志） 原理： 每 128 个 Page 一组，记录这组的最小/最大值 查询时根据 min/max 跳过大块 1 2 3 4 5 -- 时序日志场景 CREATE INDEX idx_log_ts ON logs USING BRIN(ts); SELECT * FROM logs WHERE ts \u0026gt;= \u0026#39;2026-06-01\u0026#39; AND ts \u0026lt; \u0026#39;2026-06-02\u0026#39;; -- BRIN 让 PG 跳过 ts 不在范围的大块 1 2 3 4 BRIN 特点： - 索引极小（KB 级，不是 GB 级） - 适合\u0026#34;自然有序\u0026#34;的数据（时间序列） - 不适合随机写入的数据 3.5 SP-GiST（Space-Partitioned GiST） 1 2 SP-GiST：用于\u0026#34;非平衡\u0026#34;数据结构（如 trie 树、四叉树、k-d 树） 适用：电话区号、IP 路由、字符串前缀匹配 3.6 Hash 1 2 3 4 5 6 7 PG Hash 索引： - 早期版本（\u0026lt; 10）有 bug，不推荐用 - 10+ 修复，支持 WAL 复制 - 只支持等值查询（=） - 比 B-tree 略快，但功能受限 CREATE INDEX idx_id ON t USING HASH(id); 3.7 PG 索引选型表 场景 索引类型 等值、范围、排序 B-tree（默认） JSONB 查询、数组包含 GIN 地理、范围、模糊匹配 GiST 大表 + 时间范围扫描 BRIN 前缀匹配、路由 SP-GiST 仅等值 + 极致性能 Hash 四、Oracle 的索引特色 4.1 B-Tree Index（默认） 1 2 3 4 CREATE INDEX idx_name ON t(name); -- Oracle 的 B-Tree 实际是 B+ 树 -- 叶子节点存：索引键 + rowid 4.2 Bitmap Index（位图索引） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 适用：低基数列（如性别、状态、地区） 原理：每个 key 值对应一个位图，每一位代表一行 表（5 行）： id | gender ---|------- 1 | M 2 | F 3 | M 4 | M 5 | F Bitmap Index on gender: M → [1, 0, 1, 1, 0] F → [0, 1, 0, 0, 1] 查询 WHERE gender=\u0026#39;M\u0026#39; AND region=\u0026#39;East\u0026#39;: M 位图 AND East 位图 → 一次位运算 1 2 3 4 CREATE BITMAP INDEX idx_gender ON users(gender); -- 适合：低基数列 + 数据仓库（不频繁更新） -- 灾难场景：高并发 OLTP 写入 → 锁整个位图段 4.3 Reverse Key Index（反向键索引） 1 2 3 4 5 6 7 8 9 10 解决\u0026#34;热块\u0026#34;问题： 场景：主键是顺序 ID（1, 2, 3, ...），所有插入都打到 B+ 树最右侧叶子 → 该页成为热块，并发插入互相阻塞 反向键索引：把 key 字节反转 原 key: 12345 → 反向: 54321 原 key: 12346 → 反向: 64321 → 插入分散到 B+ 树不同叶子 代价：范围查询失效（不再连续） 1 CREATE INDEX idx_id_reverse ON t(id) REVERSE; 4.4 Function-Based Index 1 2 3 4 5 6 7 -- 经典场景：大小写不敏感查询 CREATE INDEX idx_lower_name ON t(LOWER(name)); SELECT * FROM t WHERE LOWER(name) = \u0026#39;alice\u0026#39;; -- 走索引 -- 计算列 CREATE INDEX idx_amount_usd ON t(amount * 7.2); SELECT * FROM t WHERE amount * 7.2 \u0026gt; 100; 4.5 Oracle 索引特色总览 索引类型 适用场景 B-Tree 通用 Bitmap 低基数列 + 数仓 Reverse 热块消除 Function-based 函数查询 IOT 索引组织表 Domain 用户自定义（如 Spatial） 五、联合索引与最左前缀 5.1 联合索引的结构 1 2 3 4 CREATE INDEX idx_a_b_c ON t(a, b, c); -- B+ 树叶子节点按 (a, b, c) 排序： -- (1, 1, 1) (1, 1, 2) (1, 2, 1) (1, 2, 3) (2, 1, 1) (2, 1, 5) ... 5.2 最左前缀原则 1 2 3 4 5 6 7 8 9 能用上 idx_a_b_c 的查询： WHERE a = 1 ✅ 用 a WHERE a = 1 AND b = 2 ✅ 用 a, b WHERE a = 1 AND b = 2 AND c = 3 ✅ 用 a, b, c WHERE a = 1 AND c = 3 ✅ 用 a（c 用不上，跳过 b） WHERE a \u0026gt; 1 AND b = 2 ✅ 用 a（范围后断） WHERE b = 2 ❌ 不用（缺最左 a） WHERE b = 2 AND c = 3 ❌ 不用 WHERE a = 1 ORDER BY b, c ✅ 用 a + 排序免费 5.3 为什么这样 1 2 3 4 5 6 B+ 树是有序的，按 (a, b, c) 排序： (1, 1, 1) \u0026lt; (1, 1, 2) \u0026lt; (1, 2, 1) \u0026lt; (2, 1, 1) WHERE a = 1：从 (1, ?, ?) 段开始扫 WHERE a = 1 AND b = 2：进一步缩到 (1, 2, ?) 段 WHERE b = 2：B+ 树不是按 b 排序的，无法定位 → 退化为全索引扫描 5.4 范围查询\u0026quot;截断\u0026quot;后续列 1 2 3 4 5 6 7 WHERE a \u0026gt; 1 AND b = 2： a 是范围 → B+ 树定位到 a \u0026gt; 1 的范围，但 b 在这个范围内无序 → 只能用 a，b 不能用索引 如何绕过： 方案 1：调整索引列顺序（按\u0026#34;等值在前，范围在后\u0026#34;排） 方案 2：把范围改成 IN 列表（a IN (2, 3, 4) AND b = 2） 5.5 设计建议 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 联合索引列顺序的优先级： 1. 等值查询的列（最左） 2. 排序列（中间，利用 B+ 树天然有序） 3. 范围查询列（最右，避免截断） 反例： WHERE status = \u0026#39;A\u0026#39; AND create_time \u0026gt; \u0026#39;2026-06-01\u0026#39; → idx(status, create_time)：先等值定位 status，再范围扫 create_time ✅ → idx(create_time, status)：先范围扫 create_time，再过滤 status ❌（无效） 观察执行计划验证： MySQL: EXPLAIN SELECT ... → key_len 看用了几列 PG: EXPLAIN (BUFFERS) SELECT ... → \u0026#34;Index Cond\u0026#34; 看用了哪些列 Oracle: EXPLAIN PLAN → \u0026#34;Access Predicates\u0026#34; vs \u0026#34;Filter Predicates\u0026#34; SQL Server: SET SHOWPLAN_TEXT ON → Seek Predicate 是真用，Predicate 是过滤 六、覆盖索引、索引下推、条件下推 6.1 覆盖索引（Covering Index） 1 2 3 4 5 6 7 如果 SELECT 的所有列都在索引里 → 不需要回表 InnoDB 示例： CREATE INDEX idx_name_age ON users(name, age); SELECT name, age FROM users WHERE name = \u0026#39;Alice\u0026#39;; → 命中 idx_name_age，叶子节点存 (name, age, id) → 所有需要的列都在 → \u0026#34;Using index\u0026#34;（不回表） 1 2 3 4 5 6 7 -- MySQL 验证 EXPLAIN SELECT name, age FROM users WHERE name = \u0026#39;Alice\u0026#39;; -- Extra: Using index → 覆盖索引生效 -- 故意触发回表 EXPLAIN SELECT name, age, email FROM users WHERE name = \u0026#39;Alice\u0026#39;; -- Extra: \u0026lt;空\u0026gt; → 需要回表取 email 6.2 MySQL 的\u0026quot;覆盖\u0026quot;延伸：包含列 1 2 3 4 5 6 -- MySQL 8.0+ CREATE INDEX idx_name ON users(name) INVISIBLE; ALTER TABLE users ADD INDEX idx_name_age_email (name) INCLUDE (age, email); -- INCLUDE 列只放在叶子节点，不参与排序 -- Oracle / SQL Server / PG 都有类似 INCLUDE 子句 6.3 索引下推（Index Condition Pushdown, ICP） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 MySQL 5.6+ 引入，针对联合索引： CREATE INDEX idx_a_b ON t(a, b); SELECT * FROM t WHERE a = 1 AND b LIKE \u0026#39;%abc%\u0026#39;; 无 ICP（5.6 之前）： 1. 走 idx_a_b 找到 a = 1 的所有行（比如 1000 行） 2. 全部回表取整行 3. 在 Server 层过滤 b LIKE \u0026#39;%abc%\u0026#39; → 1000 次回表 有 ICP： 1. 走 idx_a_b 找到 a = 1 的所有行（1000 行） 2. 在存储引擎层直接用 b LIKE \u0026#39;%abc%\u0026#39; 过滤（剩 10 行） 3. 只回表 10 次 → 减少 100 倍回表 1 2 EXPLAIN SELECT * FROM t WHERE a = 1 AND b LIKE \u0026#39;%abc%\u0026#39;; -- Extra: Using index condition → ICP 生效 6.4 条件下推到存储层 1 2 3 4 5 6 PG 和 Oracle 都有类似的下推机制： PG：把 WHERE 条件推到 Scan 节点（Index Scan 的 Index Cond） Oracle：谓词下推到 Table Access SQL Server：Seek Predicate（下推）vs Predicate（残留过滤） 判断方法：执行计划里\u0026#34;过滤发生得越早越好\u0026#34; 七、索引失效与\u0026quot;隐形索引\u0026quot; 7.1 索引失效的常见原因 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 1. 函数包装列 WHERE LOWER(name) = \u0026#39;alice\u0026#39; -- 不走 idx_name → 解决：建函数索引 或 改写为 name = \u0026#39;Alice\u0026#39; COLLATE ... 2. 隐式类型转换（经典坑） WHERE phone = 13800000000 -- phone 是 VARCHAR，传 INT → MySQL：函数 CAST(phone) → 不走索引 → PG：报错（更严格） → 解决：传字符串 \u0026#39;13800000000\u0026#39; 3. LIKE 前缀通配 WHERE name LIKE \u0026#39;%abc\u0026#39; -- 不走索引 WHERE name LIKE \u0026#39;abc%\u0026#39; -- 走索引 → 解决：用全文索引（PG GIN / MySQL FULLTEXT） 4. OR 中部分列无索引 WHERE a = 1 OR b = 2 -- 如果 b 没索引，整条不走索引 → 解决：UNION ALL 或给 b 加索引 5. 计算 / 表达式 WHERE id + 1 = 100 -- 不走索引 → 改写为 WHERE id = 99 6. NULL 不被索引（部分数据库） Oracle：单列索引不存 NULL → 解决：建复合索引包含 NOT NULL 列 7.2 隐形索引（Invisible Index） 1 2 3 4 5 6 7 8 用途：临时禁用索引，观察是否真的没用，再决定删 MySQL 8.0+： ALTER TABLE t ALTER INDEX idx_name INVISIBLE; -- 优化器看不见 → 如果性能没变差 → 可以 DROP -- 如果变差 → 改回 VISIBLE Oracle / PG（PG 12+ 也支持）都有类似机制 1 2 3 4 5 -- PG 用 ALTER INDEX ... ALTER COLUMN ... SET (attndims = 0) 不直接 -- 推荐用 extension：hypopg（虚拟索引，模拟） CREATE EXTENSION hypopg; SELECT * FROM hypopg_create_index(\u0026#39;CREATE INDEX idx_test ON t(name)\u0026#39;); -- 不真建索引，只让优化器\u0026#34;以为\u0026#34;有 → 看 EXPLAIN 是否选择 7.3 索引选择性 1 2 3 4 5 6 7 8 索引值不值得建？看选择性： 选择性 = 不同值的数量 / 总行数 低选择性（\u0026lt; 0.1）：性别、状态、布尔 → 一般不建 中选择性（0.1 ~ 0.5）：地区、年龄段 → 看场景 高选择性（\u0026gt; 0.5）：用户 ID、订单号 → 必建 PG：SELECT 1.0 * COUNT(DISTINCT col) / COUNT(*) FROM t; 八、索引维护代价 8.1 写入放大 1 2 3 4 5 6 7 8 9 每加一个索引，每次 INSERT 都多写一份： 原表 + N 个索引 = N + 1 次写入 UPDATE / DELETE 也要同步维护索引 经验值： - 一张表索引数建议 ≤ 5 个 - 高写入表 ≤ 3 个 - 数仓表（只读为主）可以多 8.2 页分裂与碎片 1 2 3 4 5 6 7 8 9 10 11 12 B+ 树插入时，如果目标页满了： → 把页分裂成两页，各放一半 → 物理位置不连续，磁盘 I/O 变慢 顺序插入（自增主键）：基本不分裂 随机插入（UUID 主键）：频繁分裂 碎片化监控： MySQL: ANALYZE TABLE t; SHOW TABLE STATUS LIKE \u0026#39;t\u0026#39;; PG: VACUUM (VERBOSE) t; 看 free space SQL Server: SELECT * FROM sys.dm_db_index_physical_stats(...) Oracle: ANALYZE INDEX idx VALIDATE STRUCTURE; 8.3 索引重建 1 2 3 4 5 6 7 8 9 10 11 12 13 14 -- MySQL（在线 DDL，8.0+） ALTER TABLE t ENGINE=InnoDB; -- 重建表（含所有索引） ALTER TABLE t DROP INDEX idx, ADD INDEX idx (...); -- 单独重建 -- PostgreSQL（CONCURRENTLY 不阻塞写） REINDEX INDEX CONCURRENTLY idx_name; REINDEX TABLE CONCURRENTLY t; -- SQL Server ALTER INDEX ALL ON t REBUILD; ALTER INDEX ALL ON t REORGANIZE; -- 轻量整理 -- Oracle ALTER INDEX idx_name REBUILD ONLINE; 8.4 索引统计信息 1 2 3 4 5 6 7 8 优化器依赖统计信息决定走不走索引： 行数、基数、最值、直方图 定期收集： MySQL: ANALYZE TABLE t; （8.0 默认自动采样） PG: ANALYZE t; （autovacuum 自动） Oracle: DBMS_STATS.GATHER_TABLE_STATS(...) SQL Server: UPDATE STATISTICS t; （自动） 九、小结 本文学习了索引原理：\n为什么 B+ 树是关系数据库的标准索引 聚集 vs 非聚集（Clustered vs Secondary）的术语差异 InnoDB 主键即数据 PG/Oracle/SQL Server 都是堆表 + 二级索引 PG 的索引武器库：B-tree / GIN / GiST / BRIN / SP-GiST / Hash Oracle 的特色：Bitmap / Reverse / Function-based 联合索引的最左前缀原则与范围截断 覆盖索引、索引下推、条件下推 索引失效的六大原因与\u0026quot;隐形索引\u0026quot;调试法 索引维护的代价：写入放大、页分裂、统计信息 1 2 3 4 记住三句话： 1. 关系数据库索引绝大多数是 B+ 树——因为磁盘 I/O 模型 2. InnoDB 二级索引存\u0026#34;主键值\u0026#34;，PG/Oracle 存\u0026#34;行位置\u0026#34;——这是回表成本的根源 3. 索引不是免费的——每加一个，写入代价线性增加 下一篇将进入事务主题：ACID 怎么实现？原子性靠 Undo，持久性靠 Redo，分布式事务怎么做？\n","date":"2025-04-26T10:00:00+08:00","permalink":"/posts/database/fundamentals/03-index-internals/","title":"数据库系列（三）：索引原理 — B+ 树、位图、倒排与四大数据库的实现差异"},{"content":"写在前面 承接上一篇的\u0026quot;原理地图\u0026quot;，本文进入第一个子系统：存储引擎。我们要回答的核心问题是——\n一行数据，在磁盘上到底长什么样？为什么 MySQL InnoDB 的主键就是数据，而 PostgreSQL 不是？\n理解存储引擎是理解索引、事务、MVCC、日志所有后续主题的基础。本文横向对比 Oracle、SQL Server、MySQL（InnoDB）、PostgreSQL 四家的物理存储层。\n一、数据在磁盘上长什么样 1.1 朴素模型：行 + 列 逻辑上一张表就是二维结构：\n1 2 3 4 5 id | name | age | balance ----|--------|-----|-------- 1 | Alice | 28 | 1000 2 | Bob | 35 | 2500 3 | Carol | 22 | 800 但磁盘上不能直接放这个二维表，需要某种物理格式。所有关系数据库的答案都是同一个——把多行打包成一个固定大小的页（Page），页是磁盘 I/O 的最小单位。\n1.2 为什么需要\u0026quot;页\u0026quot; 1 2 3 4 5 6 7 8 9 10 朴素方案：一行一行存 - 一行 100 字节，磁盘扇区 4KB - 读一行实际读了 4KB（40 倍浪费） - 写一行也要刷 4KB 正确方案：把行打包成页（如 16KB 一个） - 一次 I/O 读 / 写一整页 - 内部按行寻址（offset） - 缓冲池管理也是以页为单位 - 局部性原理：相邻行经常一起访问 1.3 行存 vs 列存 页内部如何组织多行？有两种思路：\n1 2 3 4 5 6 7 8 9 10 11 12 13 行存（Row-Based）：每一行连续存储 [id1, name1, age1, bal1][id2, name2, age2, bal2]... 适合：SELECT * FROM t WHERE id=1 （整行取出） UPDATE t SET age=age+1 WHERE id=1 （改一行） → OLTP 场景 列存（Columnar）：每一列连续存储 id 列: [1, 2, 3, ...] name 列: [Alice, Bob, Carol, ...] age 列: [28, 35, 22, ...] 适合：SELECT AVG(age) FROM t （只读 age 列） 按列压缩率高（同质数据） → OLAP 场景 四大数据库默认都是行存，但 SQL Server / Oracle 都加了列存扩展用于 OLAP（详见第 5 节）。\n二、物理存储单位对比 四家的命名不同，但层级结构惊人地一致：Block/Page → Extent → Segment → Tablespace。\n2.1 层级结构总览 1 2 3 4 5 6 7 8 9 10 11 12 13 Tablespace（表空间） │ Segment（段） 一个表 / 索引 = 一个或多个段 │ Extent（区） 连续多个 Page 组成 │ Page / Block（页 / 块） 磁盘 I/O 最小单位 │ Row / Tuple（行 / 元组） 实际的数据 2.2 各家的实际大小 层级 Oracle SQL Server MySQL InnoDB PostgreSQL Page/Block 2K/4K/8K/16K（默认 8K） 8K 16K（可配 4K/8K） 8K（编译时定） Extent 64K（8 个 block） 64K（8 页） 1M（64 页） ——（无明确区） Segment 表 / 索引一个段 表 / 索引 表 / 索引（每表一个段） ——（PG 用文件直接管理） Tablespace 系统 / 用户 / Undo 系统 / 用户 系统 / file-per-table pg_default / pg_global 行内最大长度 块大小限制（4K） 8K（行不能跨页） ½ 页（约 8K，可溢出） 2GB（TOAST 拆分） 2.3 Oracle 的 Block 结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 一个 8KB Oracle Block 内部： ┌─────────────────────────────┐ │ Block Header（块头） │ ~100 字节 │ - 类型（data/index/undo） │ │ - SCN、事务槽 (ITL) │ ├─────────────────────────────┤ │ Row Directory（行目录） │ 每行 2~4 字节偏移 │ 指向下面的行实际位置 │ ├─────────────────────────────┤ │ Free Space（空闲区） │ 从中间向两边生长 ├─────────────────────────────┤ │ Row Data（行数据） │ 实际行 + 列值 └─────────────────────────────┘ 关键：ITL（Interested Transaction List） - 每个被修改的 block 记录涉及的事务 - 是 Oracle MVCC 实现的关键（详见第 5 篇） 2.4 MySQL InnoDB 的 Page 结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 一个 16KB InnoDB Page 内部： ┌─────────────────────────────┐ │ File Header（38B） │ │ - 页号、表空间、前后页指针 │ ├─────────────────────────────┤ │ Page Header（56B） │ │ - 索引 ID、记录数、空闲指针 │ ├─────────────────────────────┤ │ Infimum + Supremum Records │ 系统伪行（最小/最大） ├─────────────────────────────┤ │ User Records │ 实际行记录 │ （从下往上增长） │ ├─────────────────────────────┤ │ Free Space │ ├─────────────────────────────┤ │ Page Directory（槽位） │ 二分查找索引 ├─────────────────────────────┤ │ File Trailer（8B） │ 校验和、LSN └─────────────────────────────┘ 每行结构（COMPACT 格式）： [变长字段长度][NULL 位图][记录头][事务ID][回滚指针][列1][列2]... ↑ ↑ DB_TRX_ID DB_ROLL_PTR （用于 MVCC，详见第 5 篇） 2.5 PostgreSQL 的 Page 结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 一个 8KB PostgreSQL Page： ┌─────────────────────────────┐ │ Page Header（24B） │ │ - LSN、校验和、行数 │ ├─────────────────────────────┤ │ ItemId Array（行指针） │ 4B/项 ├─────────────────────────────┤ │ Free Space │ ├─────────────────────────────┤ │ Tuples（实际行） │ │ （从下往上生长） │ └─────────────────────────────┘ 每个 Tuple 头（23B 起）： [xmin][xmax][cid][ctid][infomask][...][columns] ↑ ↑ ↑ 创建 删除/更新 当前物理位置（用于 HOT 更新） 事务 事务 1 2 3 4 5 观察： - 三家结构非常相似：Header + Directory + Free + Data - InnoDB / PG 都把事务信息（MVCC）直接放在行头 - Oracle 用独立的 ITL 槽（块级别）记录事务 - PG 8KB 是写死的（编译时定），其他三家可配 2.6 临时表的存储 数据库 临时对象存放 特点 Oracle Temporary Tablespace 每会话独立段，自动回收 SQL Server TempDB 系统级共享库，承载所有临时对象 + 排序 MySQL 临时表空间（ibtmp1） 全局共享 + 会话临时表空间（8.0+） PostgreSQL pg_default 或专用表空间 默认在 base/ 目录 1 2 3 4 关键差异： - SQL Server 用 TempDB 统一承载，是性能调优重点 - MySQL 8.0 之前临时表都在 ibdata1，导致系统表空间膨胀 - PG 临时表和普通表共用存储，但通过 relpersistence 区分 三、堆表 vs 索引组织表 这是四大数据库最本质的差异之一：一张表的数据，是按\u0026quot;主键有序\u0026quot;存放，还是无序堆放？\n3.1 两种组织方式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 堆表（HEAP）： 数据行无序堆放在数据页中 主键只是\u0026#34;另一个二级索引\u0026#34; 索引指向行的物理位置（rowid / ctid / RID） ┌────────┐ ┌──────────────────┐ │ 主键索引 │ ──────→│ Heap Page │ │ B+树 │ │ [row3][row1][row2]│ ← 无序 └────────┘ └──────────────────┘ 特点： - 主键索引和数据分离 - 主键查询要\u0026#34;两次 B+ 树查找\u0026#34;（先找索引，再读数据页） - 二级索引可以指向 rowid（短） - 代表：PostgreSQL、Oracle（默认）、SQL Server（默认） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 索引组织表（IOT / Clustered Table）： 数据行直接按主键有序存放在 B+ 树的叶子节点 \u0026#34;主键索引就是数据本身\u0026#34; 主键 B+ 树（叶子就是数据） │ ▼ ┌─────────────────────────────┐ │ [PK=1, row1][PK=2, row2]... │ ← 主键有序 └─────────────────────────────┘ 特点： - 没有独立的\u0026#34;堆\u0026#34;，整张表就是一棵 B+ 树 - 主键查询只需走一棵树 - 二级索引指向主键值（长，且需要二次查找） - 代表：MySQL InnoDB、SQL Server（Clustered）、Oracle IOT 3.2 四家的实际选择 数据库 默认组织方式 可选 IOT？ Oracle 堆表（默认） ✅ IOT 显式声明 SQL Server 堆表（无主键时）/ Clustered（有主键时） ✅ Clustered Index MySQL InnoDB 强制 IOT ❌ 没有\u0026quot;堆\u0026quot;概念 PostgreSQL 堆表（唯一选项，12+ 抽象了 TAM 接口） ❌ 3.3 深入 MySQL InnoDB：为什么主键即数据 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 InnoDB 中： - 每张表 = 一棵按主键排序的 B+ 树（Clustered Index） - 这棵树的叶子节点直接存\u0026#34;完整行\u0026#34; - 主键之外的索引叫 Secondary Index，叶子节点存的是\u0026#34;主键值\u0026#34; 表 accounts(id PK, name, balance): Clustered Index (按 id): [10│20] ← 内部节点 / \\ [1│2│...10] [11│...│20] ← 叶子 = 整行数据 ↓ ↓ id=1 的完整行 id=11 的完整行 Secondary Index (name): [Alice│Bob] ← 内部节点 / \\ [Alice→1][Bob→2] [Carol→3] ← 叶子 = name + 主键值 （查到主键后，回 Clustered 树再查一次） 1 2 3 4 5 6 -- 一次查询，两种索引路径差异 SELECT balance FROM accounts WHERE id = 1; -- 直接走 Clustered 树，1 次 I/O SELECT balance FROM accounts WHERE name = \u0026#39;Alice\u0026#39;; -- 走 Secondary 树找到主键 id=1 → 回 Clustered 树找到 balance → 2 次 I/O（\u0026#34;回表\u0026#34;） 3.4 深入 PostgreSQL：堆表 + ctid 1 2 3 4 5 6 7 8 9 10 11 12 13 14 PostgreSQL 中： - 表数据存在 Heap（堆）中，无序 - 所有索引（包括主键）都是\u0026#34;二级索引\u0026#34; - 索引指向 ctid（物理位置：页号 + 行偏移） Heap File: Page 0: [row(id=1)][row(id=3)][row(id=2)] ← 任意顺序 Page 1: [row(id=5)][row(id=4)][row(id=6)] Primary Key Index (id): [2│4│6] / | \\ [1→(0,1)] [2→(0,3)] [3→(0,2)] 叶子 = id + ctid 1 2 3 4 5 6 7 8 9 10 11 -- 看看实际的 ctid SELECT ctid, id, name FROM accounts; -- ctid id name -- (0,1) 1 Alice -- (0,2) 3 Carol -- (0,3) 2 Bob -- 物理顺序 ≠ 主键顺序 -- 主键查询也要两步：索引找 ctid → heap 取数据 SELECT balance FROM accounts WHERE id = 1; -- 走 PK 索引找到 ctid=(0,1) → 回 heap 读 → 2 次 I/O 3.5 两种模型的性能权衡 维度 IOT（InnoDB/MSSQL Clustered） HEAP（PG/Oracle 默认） 主键查询 快（一棵树） 慢（两次查找） 二级索引查询 慢（需要回主键树） 快（直接到 heap） 范围查询（按主键） 快（叶子有序连续） 慢（需要排序） 插入 主键有序时慢（B+ 树页分裂） 快（追加到堆末尾） 二级索引大小 大（存主键值） 小（存 ctid/rowid） 二级索引重建（改主键时） 极慢（所有二级索引都要改） 快（指向物理位置，不变） 1 2 3 4 5 6 7 8 9 10 实战含义： - InnoDB 中改主键 = 灾难（所有二级索引失效重建） → 所以 InnoDB 主键推荐：自增 BIGINT，永不变 - PG 中改主键 = 改个约束（索引重建，但 heap 不动） → PG 改主键代价相对小 - InnoDB 二级索引查询性能 \u0026lt; PG（需要\u0026#34;回主键树\u0026#34;） - 但 InnoDB 主键范围查询（按时间、按 ID 范围）\u0026gt; PG 这就是为什么 MySQL 表设计强调\u0026#34;主键设计\u0026#34;，PG 强调\u0026#34;索引设计\u0026#34;。 四、缓冲池 修改的是内存中的页，不是磁盘上的页。这块内存就叫\u0026quot;缓冲池\u0026quot;（Buffer Pool）。\n4.1 缓冲池的作用 1 2 3 4 5 6 7 8 没有缓冲池： 读：每次 SELECT 都从磁盘读页 → 极慢 写：每次 UPDATE 都直接刷盘 → 极慢 + 磁盘磨损 有缓冲池： 读：先查 Buffer Pool，命中直接返回；未命中则从磁盘读入 写：直接改 Buffer Pool 中的页（变成\u0026#34;脏页\u0026#34; dirty page） 后台异步刷盘（详见第 7 篇 Checkpoint） 4.2 替换算法：LRU 与冷热分离 朴素 LRU 的问题：一次全表扫描会把热点页全冲掉（\u0026ldquo;缓存污染\u0026rdquo;）。所以四大数据库都对 LRU 做了改进。\n1 2 3 4 5 6 7 8 9 10 11 12 13 改进版 LRU（中点插入策略）： 把 LRU 链分成两段： [Old sublist（冷区）] | [New sublist（热区）] ↑ ↑ midpoint midpoint move threshold ↑ 新读入的页插这里 （而不是直接进热区） 真正热的数据：在冷区停留超过 innodb_old_blocks_time（默认 1 秒） 才会被推入热区 → 全表扫描的页只会短暂留在冷区，被快速淘汰，不会冲掉热区 4.3 四家的实现 数据库 缓冲池名称 冷热分离 多实例 后台写 Oracle Buffer Cache ✅（KEEP/RECYCLE/DEFAULT 池） ✅（多池） DBWn SQL Server Buffer Pool ✅（Just-In-Time） 单一池 Checkpoint / Lazy Writer MySQL InnoDB Buffer Pool ✅（young/old sublist） ✅（多 instance） Page Cleaner Thread PostgreSQL Shared Buffers ✅（环形缓冲区） 单一池 bgwriter / checkpointer 4.4 关键参数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 -- Oracle ALTER SYSTEM SET db_cache_size = 8G; -- Buffer Cache 大小 ALTER SYSTEM SET db_keep_cache_size = 1G; -- 热点小表保留 -- SQL Server -- \u0026#34;max server memory\u0026#34; 控制整个 Buffer Pool 上限 EXEC sp_configure \u0026#39;max server memory (MB)\u0026#39;, 16384; RECONFIGURE; -- MySQL [mysqld] innodb_buffer_pool_size = 8G -- 最重要参数，建议物理内存 60~70% innodb_buffer_pool_instances = 8 -- 多实例，减少锁竞争 innodb_old_blocks_time = 1000 -- 冷区停留时间（ms） -- PostgreSQL shared_buffers = 2GB -- 推荐 25% 物理内存 effective_cache_size = 8GB -- 仅给优化器参考，不实际分配 1 2 3 4 5 调优经验： - MySQL：innodb_buffer_pool_size 是 OLTP 性能第一参数，通常设为物理内存 70% - PostgreSQL：shared_buffers 一般不超过物理内存 25%（PG 还要用 OS page cache） - SQL Server：max server memory 通常留 4~8GB 给 OS - Oracle：SGA（含 Buffer Cache）+ PGA 总和 ≈ 物理内存 80% 五、列存扩展 行存适合 OLTP，列存适合 OLAP。四大数据库都做了列存扩展来支持分析查询。\n5.1 SQL Server Columnstore 1 2 3 4 5 6 7 8 9 10 11 SQL Server 2012+ 引入 Columnstore Index： - 一张表可以有 Columnstore Index（聚集或非聚集） - 行组（Rowgroup）：每 100 万行为一组，按列编码存储 - 列段（Column Segment）：每个列段存储一组数据 - 配合 Batch Mode 执行（向量化） CREATE CLUSTERED COLUMNSTORE INDEX cci ON big_table; -- 整表变成列存 CREATE NONCLUSTERED COLUMNSTORE INDEX ncci ON big_table(col1, col2); -- 局部列存（2014+） 5.2 Oracle In-Memory 1 2 3 4 5 6 7 8 9 10 11 12 Oracle 12.1.0.2+ 引入 In-Memory Column Store（IMCS）： - 数据在 SGA 中以\u0026#34;行存 + 列存\u0026#34;双格式存储 - 列存叫 In-Memory Compression Unit（IMCU） - OLTP 走行存（Buffer Cache），OLAP 走列存（IMCS） ALTER TABLE big_table INMEMORY; -- 该表的列被加载到 IMCS 特点： - 行/列双写：内存占用大 - 透明：优化器自动选择 - 商业版高级特性 5.3 PostgreSQL 的列存方案 PG 本身没有内置列存，但有方案：\n1 2 3 4 5 6 7 8 9 10 11 12 1. cstore_fdw（早期，Citus 出品） - 用 FDW（Foreign Data Wrapper）实现 - 已基本停更 2. Citus（已并入 PG 生态） - 列式压缩 + 分布式 3. TimescaleDB（时序场景） - 时序数据自动按时间分区 + 压缩 4. Greenplum（PG 分支） - MPP 数据库，原生列存支持 5.4 MySQL 的列存现状 1 2 3 4 MySQL 8.x 没有列存。 - HeatWave（MySQL Cloud 在 OCI 上的扩展）有列存加速 - 但开源 MySQL 没有列存 - 分析查询需要走 OLAP 引擎（如外接 Doris / ClickHouse） 5.5 行存 vs 列存 选型 场景 推荐 高并发 OLTP（按主键点查 / 范围更新） 行存（默认） 多维聚合分析（GROUP BY 多列、SUM/AVG） 列存 混合负载（HTAP） 行存为主 + 列存索引 / IMCS 大宽表 + 高压缩比 列存 六、内置存储引擎矩阵 6.1 MySQL：可插拔引擎之王 1 2 3 4 5 6 7 8 9 10 SHOW ENGINES; -- 主要引擎： -- InnoDB 默认，事务、行锁、MVCC -- MyISAM 老引擎，表锁，崩溃恢复弱 -- Memory 内存表，重启丢失 -- Archive 高压缩归档，只支持插入 -- NDB 分布式集群（MySQL Cluster） -- Blackhole 什么都不存（用于复制中转） -- 第三方：RocksDB（MyRocks）、TokuDB、TiDB 1 2 3 4 设计： 每张表可以选不同引擎 CREATE TABLE orders (...) ENGINE=InnoDB; CREATE TABLE logs (...) ENGINE=Archive; 6.2 Oracle：段类型 + 表压缩 1 2 3 4 5 6 7 8 9 10 11 12 13 Oracle 不是\u0026#34;多引擎\u0026#34;，而是统一引擎 + 多种段类型： 段类型： - Heap Table（默认堆表） - Index-Organized Table（IOT，索引组织表） - Partitioned Table（分区表） - Cluster Table（聚簇表） - External Table（外部表，HDFS 文件等） 压缩选项： - OLTP Table Compression - Advanced Compression - Hybrid Columnar Compression（HCC，Exadata 专属） 6.3 SQL Server：堆表 vs Clustered 1 2 3 4 5 6 7 8 9 SQL Server 一张表的状态： - 堆表（Heap）：没有 Clustered Index - Clustered Table：有 Clustered Index（按主键组织） CREATE TABLE t (id INT PRIMARY KEY, ...); -- PRIMARY KEY 隐式创建 Clustered Index → 表变成 Clustered ALTER TABLE t DROP CONSTRAINT pk_t; -- 删主键后，表变回 Heap 1 2 3 4 内存优化表（In-Memory OLTP）： - SQL Server 2014+ 引入 - 完全在内存中，无锁（用 MVCC） - 用于极高性能 OLTP 6.4 PostgreSQL：Table Access Method 抽象 1 2 3 4 5 6 7 8 PG 12 引入 Table Access Method (TAM) 抽象： - 12 之前：所有表都是 Heap - 12+：可以插件化实现新表类型 - 目前主流还是 heap TAM 扩展： - zheap（PG 社区研发，目标减少表膨胀，未发布） - 其他自定义 TAM 七、性能影响 7.1 为什么 OLTP 必须行存 1 2 3 4 5 6 7 8 9 10 11 12 13 14 OLTP 工作负载特征： - 大量短事务 - 按主键 / 索引点查 - 修改单行多列（UPDATE） OLAP 工作负载特征： - 少量长查询 - 大范围扫描 - 按列聚合（GROUP BY + SUM/AVG） 行存优势（OLTP）： - 一次 I/O 读出整行（修改单行多列只需 1 次页 I/O） - 局部性好（同时访问同一行的多列） - 事务实现简单（锁单行） 7.2 表设计原则 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 InnoDB 表设计： 1. 必须有主键（推荐 BIGINT AUTO_INCREMENT） → 没有 PK 的话会选唯一键，再没有就生成隐藏 6 字节 ROW_ID 2. 主键尽量短（让二级索引小） 3. 主键不要变更（否则所有二级索引重建） 4. 主键单调递增（减少 B+ 树页分裂） PostgreSQL 表设计： 1. 主键选 UUID 或 SERIAL/BIGSERIAL 都行（不影响 heap） 2. 关键在索引设计 3. 注意 fillfactor（默认 100，频繁更新降到 80~90 留 HOT 空间） SQL Server 表设计： 1. 选择 Clustered Index 列（默认主键） 2. Clustered 列要短、单调、不变 3. 大表考虑分区 + Columnstore 索引（混合） Oracle 表设计： 1. 默认 Heap Table，分析场景考虑 IOT 或分区表 2. 用 Sequence 而非 AUTO（Oracle 12c+ 才有 IDENTITY） 3. HCC 压缩用于 Exadata 上的数仓 7.3 常见性能陷阱 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 1. MySQL InnoDB：UUID 主键 问题：UUID 无序 → B+ 树频繁页分裂 → 插入性能差 + 索引膨胀 方案：用 BIGINT AUTO_INCREMENT 或有序 UUID 2. PostgreSQL：高更新场景 问题：MVCC 写新 tuple + 旧 tuple 不删 → 表膨胀 方案：autovacuum 配置、fillfactor 降低、pg_repack 重整 3. SQL Server：Clustered Index 选错 问题：用 GUID 当 Clustered Key → 页分裂 + 缓存碎片 方案：Clustered 用 IDENTITY INT/BIGINT，GUID 用 ROWGUIDCOL + NonClustered 4. Oracle：HCC 压缩误用 问题：在 OLTP 表上用 HCC → 锁升级 + 性能崩溃 方案：HCC 仅用于只读 / 批量加载场景 八、小结 本文学习了存储引擎的核心原理：\n物理存储层级：Tablespace → Segment → Extent → Page → Row 四家的页大小：Oracle 8K / MSSQL 8K / InnoDB 16K / PG 8K 行存 vs 列存：OLTP 用行存，OLAP 用列存 堆表 vs 索引组织表（IOT）：本质差异 InnoDB 强制 IOT，主键就是数据 PG/Oracle 默认堆表，所有索引都是二级 SQL Server 由是否 Clustered 决定 缓冲池：LRU 冷热分离避免缓存污染 列存扩展：MSSQL Columnstore / Oracle IMCS / PG 第三方 各家内置存储引擎矩阵 1 2 3 4 记住三句话： 1. 物理存储 = 页为单位的 B+ 树 / 堆文件 2. InnoDB 主键就是数据，PG 主键只是另一个索引 3. 修改是先改内存页，后台异步刷盘（这就是\u0026#34;事务持久性\u0026#34;实现的基础） 下一篇将深入索引原理：为什么几乎所有索引都是 B+ 树？PG 的 GIN/GiST 是什么？为什么联合索引要最左前缀？\n","date":"2025-04-22T10:00:00+08:00","permalink":"/posts/database/fundamentals/02-storage-engine-compare/","title":"数据库系列（二）：存储引擎 — 堆表 vs 索引组织表 vs 列存"},{"content":"写在前面 本文是数据库系列的第一篇。这个系列计划写 10 篇，主线是横向对比 Oracle、SQL Server、MySQL、PostgreSQL 四大关系数据库的底层原理，并在最后跳出来俯瞰整个数据存储生态（OLAP / 数仓 / Lakehouse）。\n开篇不深入任何单一原理，目标是建立一张\u0026quot;原理地图\u0026quot;——用一行 UPDATE 语句把整本系列的 10 个主题串起来，让你看后面每一篇时都知道它处在地图的哪一格。\n一、从一行 UPDATE 说起 考虑这条最普通的 SQL：\n1 UPDATE accounts SET balance = balance - 100 WHERE id = 42; 在你按下回车到返回\u0026quot;1 row affected\u0026quot;之间，数据库内部发生的事情远比你想象的多：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 1. 连接层： 校验会话、权限、参数绑定 2. 解析器： 词法/语法分析，生成 AST 3. 优化器： 决定走哪个索引、用什么 JOIN 顺序 4. 执行器： 按\u0026#34;打开表 → 定位行 → 改字段\u0026#34;的步骤执行 5. 事务层： 写 Undo Log（保证可回滚）+ 写 Redo Log（保证持久） 6. 缓冲池： 修改的是内存中的页，并不立即落盘 7. 锁系统： 对 id=42 这行加 X 锁，避免其他事务改 8. MVCC： 旧版本写入 Undo 链，让并发读不会看到新值 9. 提交： Redo Log fsync 到磁盘，事务正式可见 10. 复制： Redo/WAL 流式推送给从库（如果是主从架构） 后续： - 后台 Checkpoint 把脏页刷盘 - 崩溃重启时用 Redo 重放、Undo 回滚 - Binlog 也会被消费同步到下游（订阅 / 数仓） 这一行 SQL 牵出了 10 个原理子系统，正是本系列要逐个深挖的：\n步骤 涉及子系统 本系列对应篇 4-5 存储引擎（数据怎么放、页怎么组织） 第 2 篇 3,4 索引（怎么定位 id=42 这行） 第 3 篇 5,9 事务 ACID（原子性 + 持久性） 第 4 篇 8 MVCC 与隔离级别 第 5 篇 7 锁与并发控制 第 6 篇 5,9 日志与崩溃恢复 第 7 篇 10 复制与高可用 第 8 篇 2,3 SQL 方言与查询优化器 第 9 篇 —— 数据存储全景（OLAP/数仓/Lakehouse） 第 10 篇 读完这 10 篇，再回头看这行 UPDATE，你应当能说清每一步在四大数据库里叫什么名字、走什么代码路径、踩到哪些坑。\n二、四大关系数据库的身世 2.1 Oracle：商业数据库的标杆 1 2 3 4 5 出身：1979 年由 Larry Ellison 创立，关系数据库商业化的先驱 版本：Oracle 7 → 8i → 9i → 10g → 11g → 12c → 18c → 19c → 21c → 23ai 定位：企业级核心交易系统、大型 OLTP、数据仓库 关键词：SGA/PGA、RAC（共享存储集群）、Data Guard、ASM、Exadata 一体机 许可证：商业，按 CPU 插槽计费，昂贵 2.2 SQL Server：微软生态的深度集成 1 2 3 4 5 出身：1989 年微软与 Sybase 合作开发，后独立分叉 版本：6.5 → 7.0 → 2000 → 2005 → ... → 2019 → 2022 定位：Windows 生态首选、企业 OLTP、近年也做 Linux + 容器 关键词：T-SQL、SQLOS、AlwaysOn、MSDTC、Columnstore、SSIS/SSAS/SSRS 许可证：商业，按 Core 计费，Express 免费但有限制 2.3 MySQL：互联网公司的最爱 1 2 3 4 5 6 出身：1995 年瑞典 MySQL AB 公司，2008 被 Sun 收购，2010 被 Oracle 收购 版本：3.x → 4.x → 5.x → 8.0 → 8.4 → 9.x（创新版） 分支：MariaDB（MySQL 创始人 fork）、Percona Server（性能版） 定位：互联网 OLTP、Web 应用、电商 关键词：InnoDB（默认引擎）、Binlog、MGR（Group Replication）、半同步复制 许可证：GPL + 商业双授权 2.4 PostgreSQL：最像 Oracle 的开源数据库 1 2 3 4 5 出身：1996 年从 Berkeley 的 POSTGRES 项目演化而来，2010 后社区爆发 版本：9.x → 10 → 11 → ... → 17 定位：复杂查询、地理数据（PostGIS）、JSON、扩展生态 关键词：进程模型、WAL、流复制 + 逻辑复制、丰富的索引类型、扩展（Extension） 许可证：PostgreSQL License（BSD-like），完全开源 2.5 四家定位坐标 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 OLAP 能力 ↑ │ Oracle ───────┼──────── PostgreSQL │ SQL Server │ │ ──────────────────┼────────────── 成熟度 → │ MySQL │ │ OLTP 能力 ↑（其实是同一轴） 不同象限的擅长领域： - Oracle/SQL Server：金融核心、ERP、关键交易系统 - MySQL：互联网高并发 OLTP - PostgreSQL：复杂分析、地理数据、JSON 存储 三、进程/线程模型对比 四大数据库的\u0026quot;骨架\u0026quot;——也就是它们如何组织进程/线程——差异巨大。这个差异决定了内存架构、连接方式、并发性能的根本走向。\n3.1 Oracle：多进程 + 共享内存（SGA） 1 2 3 4 5 6 7 8 9 10 11 12 一个 Oracle 实例由多个进程组成，进程间通过共享内存段（SGA）通信： ┌──────────────────────────────────────┐ │ SGA（共享内存） │ │ Buffer Cache / Redo Buffer / │ │ Shared Pool / Large Pool / Java Pool│ └────┬──────┬──────┬──────┬──────┬─────┘ │ │ │ │ │ PMON SMON DBWn LGWR CKPT ...（后台进程） ▲ │ Server Process（每个会话一个，或共享） 1 2 3 4 5 6 特点： - 后台进程分工：DBWn 写脏页、LGWR 写 Redo、SMON 系统监控、PMON 进程监控 - 两种连接模式： * Dedicated Server：每个会话一个 server 进程（默认） * Shared Server：通过 dispatcher 共享，适合大量短连接 - 进程间通过共享内存（SGA）高效通信，无 IPC 开销 3.2 SQL Server：单进程多线程（SQLOS） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 SQL Server 是一个进程，里面跑数千个线程，由 SQLOS（调度器）管理： ┌──────────────────────────────────────┐ │ sqlservr.exe（单进程） │ │ ┌─────────────────────────────┐ │ │ │ SQLOS │ │ │ │ Scheduler（每 CPU 一个） │ │ │ └────────┬────────┬────────┬───┘ │ │ │ │ │ │ │ Worker Worker Worker │ │ (线程) (线程) (线程) │ └──────────────────────────────────────┘ │ Buffer Pool（用户态内存） 1 2 3 4 5 6 特点： - 单进程多线程，所有连接共用一个进程 - SQLOS：自研的用户态调度器，避免 OS 上下文切换 - 每个 CPU 一个 Scheduler，Worker 跑在上面 - 内存：Buffer Pool 是用户态分配，不需要共享内存段 - 线程切换比进程切换便宜，适合极高并发 3.3 MySQL：多线程（每个连接一个线程） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 MySQL 服务器进程是单进程多线程，类似 SQL Server： ┌──────────────────────────────────────┐ │ mysqld（单进程） │ │ ┌────────────────────┐ │ │ │ Server Layer │ │ │ │ （解析/优化/执行） │ │ │ └─────────┬──────────┘ │ │ │ │ │ ┌─────┴───────┐ │ │ ▼ ▼ │ │ InnoDB 其他引擎 │ │ （独立线程池） │ └──────────────────────────────────────┘ 线程类型： - connection thread：每个客户端连接一个 - innodb master thread：刷盘、合并 - purge thread：清理 Undo - page cleaner thread：刷脏页 - IO thread：AIO 读写 1 2 3 4 5 特点： - Server Layer + Storage Engine 双层架构（引擎可插拔） - 默认每个连接一个线程（thread-per-connection） - 5.5+ 支持 thread pool（企业版 / Percona / MariaDB） - InnoDB 内部有独立的线程池负责后台任务 3.4 PostgreSQL：多进程（每个连接一个进程） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 PostgreSQL 是最传统的\u0026#34;多进程\u0026#34;架构： ┌──────────────────────────────────────┐ │ postmaster（主进程） │ │ fork │ │ ├── backend（连接 1） │ │ ├── backend（连接 2） │ │ └── backend（连接 3） │ │ │ │ + 后台进程： │ │ checkpointer、bgwriter、walwriter │ │ autovacuum、stats collector │ └──────────────────────────────────────┘ │ Shared Buffers（共享内存段，System V / POSIX shm） 1 2 3 4 5 6 7 特点： - 每个连接 fork 一个 backend 进程（进程模型） - 进程间通过共享内存通信（Shared Buffers） - 类似 Oracle，但更\u0026#34;原始\u0026#34;：没有 dispatcher 模式 - 优点：进程隔离好，崩溃不影响其他连接 - 缺点：进程比线程重，大量连接时内存开销大 - 连接池方案：pgbouncer / pgcat（在客户端和数据库之间） 3.5 横向对比表 维度 Oracle SQL Server MySQL PostgreSQL 进程/线程 多进程 多线程（SQLOS） 多线程 多进程 共享内存 SGA Buffer Pool（用户态） Buffer Pool（用户态） Shared Buffers 连接模型 Dedicated/Shared Thread pool Thread-per-conn Process-per-conn 后台任务 DBWn/LGWR/SMON 后台线程 Master/Purge/Cleaner bgwriter/walwriter 大量连接方案 Shared Server 默认就支持 企业版线程池 pgbouncer 1 2 3 4 关键差异： - Oracle/PG：操作系统级进程，崩溃影响小但开销大 - MSSQL/MySQL：用户态线程，开销小但单线程 bug 会拖垮整个实例 - PG 是四家里连接模型最\u0026#34;重\u0026#34;的，所以 pgbouncer 几乎是生产标配 四、整体架构 抛开进程/线程细节，关系数据库的逻辑架构大体是相似的：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 ┌──────────────────────────────────────┐ │ 客户端 / Driver │ └─────────────────┬────────────────────┘ │ SQL（TDS/MySQL/Wire/Net8 协议） ▼ ┌──────────────────────────────────────┐ │ 连接层 │ │ 鉴权 / 会话 / 进程或线程分配 │ └─────────────────┬────────────────────┘ ▼ ┌──────────────────────────────────────┐ │ 解析器 Parser │ │ SQL 文本 → AST 语法树 │ └─────────────────┬────────────────────┘ ▼ ┌──────────────────────────────────────┐ │ 优化器 Optimizer │ │ AST → 执行计划（RBO / CBO） │ └─────────────────┬────────────────────┘ ▼ ┌──────────────────────────────────────┐ │ 执行器 Executor │ │ 算子：Scan/Filter/Join/Agg/Sort │ └─────────────────┬────────────────────┘ ▼ ┌──────────────────────────────────────┐ │ 存储引擎 Storage Engine │ │ 页/块读写、缓冲池、索引、事务、日志 │ └─────────────────┬────────────────────┘ ▼ 磁盘 / SSD 4.1 MySQL 的特殊之处：可插拔存储引擎 1 2 3 4 5 6 7 8 9 10 11 12 13 14 Server Layer（解析/优化/执行）和 Storage Engine 是分离的： Server Layer ├── InnoDB ← 默认，事务、行锁、MVCC ├── MyISAM ← 老引擎，表锁，已不推荐 ├── Memory ← 内存表 ├── Archive ← 压缩归档 ├── NDB ← 集群（MySQL Cluster） └── 第三方：RocksDB、TokuDB、TiDB（兼容协议） 其他三家： - Oracle：可换段类型（heap / index-organized / partitioned），但引擎统一 - SQL Server：可换页压缩、列存索引，但引擎统一 - PostgreSQL：12+ 有 Table Access Method 抽象，目前主要是 heap MySQL 这种设计的好处和代价：\n好处：可以针对场景选引擎（OLTP 用 InnoDB，归档用 Archive） 代价：跨引擎功能不统一（MyISAM 不支持事务、InnoDB 才有外键等） 4.2 解析器与优化器 数据库 解析器 优化器 执行器 Oracle 自研 CBO + RBO Row-based + Vectorized（12c+） SQL Server 自研 CBO（Cost-Based） Row + Batch mode MySQL 自研（8.0+ 重大重写） CBO（5.7+ 完善） Iterator + Volcano PostgreSQL 自研（基于 flex/bison） CBO + Genetic（GEQO） Volcano model 后续第 9 篇会深入展开优化器对比，这里先建立\u0026quot;解析器 → 优化器 → 执行器\u0026quot;的概念骨架。\n五、本系列 10 篇要解决的 10 个问题 把 10 篇的目标用一句话讲清楚：\n篇 主题 一句话问题 1（本篇） 开篇总览 关系数据库内部到底有几个子系统？ 2 存储引擎 一行数据在磁盘上长什么样？为什么 MySQL 主键就是数据？ 3 索引原理 为什么几乎所有索引都是 B+ 树？PG 的 GIN/GiST 又是什么？ 4 事务 ACID 原子性怎么实现？Durability 怎么保证？分布式事务怎么落？ 5 MVCC 与隔离级别 为什么读不阻塞写？为什么 PostgreSQL 表会膨胀？ 6 锁与并发控制 行锁怎么实现？SQL Server 为什么会锁升级？死锁怎么破？ 7 日志与崩溃恢复 Redo、Undo、Binlog、WAL 这么多日志都是干嘛的？ 8 复制与高可用 主从怎么同步？脑裂怎么防？RAC、MGR、AlwaysON 各是什么？ 9 SQL 方言与优化器 同样的 SQL 为什么在 PG 比 MySQL 快 10 倍？ 10 数据存储全景 OLTP、OLAP、HTAP、数仓、Lakehouse 怎么选？ 每篇都会包含三个层次：\n原理：核心机制 + 算法 + 数据结构 对比：四家差异 + 命名对照 + 优劣 实战：执行计划、配置参数、踩坑案例 六、核心术语对照表 四大数据库的术语往往表达同一个概念，但叫法完全不同。这张表是后续阅读的\u0026quot;翻译字典\u0026quot;。\n6.1 内存相关 概念 Oracle SQL Server MySQL (InnoDB) PostgreSQL 数据缓存 Buffer Cache Buffer Pool Buffer Pool Shared Buffers 日志缓存 Redo Log Buffer Log Cache Redo Log Buffer WAL Buffers 共享内存区 SGA ——（用户态分配） ——（用户态分配） Shared Memory 私有内存 PGA —— Thread stack Process Mem SQL/计划缓存 Shared Pool (Library Cache) Plan Cache Query Cache（已废弃） plpgsql cache 6.2 后台任务 任务 Oracle SQL Server MySQL PostgreSQL 写脏页 DBWn Checkpoint/Lazy Writer Page Cleaner bgwriter / checkpointer 写日志 LGWR Log Writer Log Manager walwriter 清理旧版本 SMON（Undo 表空间） ——（tempdb 清理） Purge Thread autovacuum 统计信息收集 自动（GATHER_STATS_JOB） 自动 自动（默认开启） autovacuum 分析 6.3 日志类型 用途 Oracle SQL Server MySQL PostgreSQL Redo（重做） Redo Log Transaction Log Redo Log（ib_logfile） WAL Undo（回滚） Undo 表空间 Transaction Log（同一份） Undo Log（undo tablespace） Old tuple in heap 归档 Archive Log ——（备份 log backup） Binlog WAL archive 主从复制 Redo + Archive Transaction Log Binlog WAL stream 1 2 3 4 5 关键观察： - Oracle / PG 把 Redo 和 Undo 完全分离 - SQL Server 在同一个 Transaction Log 里同时承担 Redo/Undo - MySQL InnoDB 同时有 Redo Log 和 Undo Log，再加上 Binlog - 复制日志：MySQL 用 Binlog，其他三家直接用 Redo/WAL/Transaction Log 6.4 索引术语 概念 Oracle SQL Server MySQL (InnoDB) PostgreSQL 主键索引 IOT / 普通索引 Clustered Index Clustered Index（=数据） 普通索引（堆表） 二级索引 Normal Index Nonclustered Index Secondary Index Index 唯一索引 Unique Index Unique Index Unique Index Unique Index 联合索引 Composite Composite Composite Composite 函数索引 Function-based Computed Column Functional（5.7+） Expression Index 位图索引 Bitmap ——（仅 DW 用） —— —— 倒排索引 —— —— —— GIN 部分索引 —— Filtered —— Partial Index 后续第 3 篇会展开。\n七、选型哲学 讲完架构，最后聊点选型。每家数据库都有它最舒服的场景，没有\u0026quot;银弹\u0026quot;。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 Oracle 适合： - 核心金融、ERP，对一致性要求极高 - 预算充足、需要商业支持 - 需要 RAC 共享存储集群（单库扩展到多节点） SQL Server 适合： - 已是 Windows + .NET 生态 - 需要 BI 全家桶（SSIS/SSAS/SSRS） - 内部团队熟悉 T-SQL - 也适合 Linux 部署（2017+） MySQL 适合： - 互联网高并发 OLTP - 数据规模可控（单库 TB 级以内） - 团队熟悉 LAMP/LEMP 栈 - 需要多种存储引擎灵活搭配 PostgreSQL 适合： - 复杂查询、分析型负载 - 需要 JSON / JSONB / GIS（PostGIS） - 希望使用 BSD-like 许可证 - 想用 Oracle 但预算不够（PG 的语法/特性最像 Oracle） 7.1 一些常见的误区 1 2 3 4 5 6 7 8 9 10 11 12 13 14 误区 1：MySQL 比 PostgreSQL 慢 真相：纯单条 OLTP 写入，InnoDB 通常更快；复杂分析查询 PG 更快。 误区 2：Oracle 一定比开源好 真相：除非用 RAC / Exadata / 高级安全特性，PG/MySQL 8.x 能覆盖大部分场景。 误区 3：PostgreSQL 不能做高并发 真相：配合 pgbouncer + 多实例，PG 也能撑住极高并发；只是默认配置不如 MySQL。 误区 4：SQL Server 只能跑 Windows 真相：2017+ 官方支持 Linux + Docker，但生态工具仍以 Windows 为主。 误区 5：四大数据库的功能都差不多 真相：底层架构差异巨大——进程模型、日志、MVCC、复制、锁——这些都影响生产实践。 八、小结 本文作为开篇，主要做了三件事：\n用一行 UPDATE 串联起本系列要讲的 10 个原理子系统 横向对比了四大数据库的身世、进程模型、整体架构 给出四方言核心术语对照表（内存、后台、日志、索引） 1 2 3 4 记住三句话： 1. 四大数据库解决的是同一组问题，但答案不同 2. 进程/线程模型是其他所有差异的根源 3. 同一个概念在四家里叫不同名字，建立\u0026#34;翻译字典\u0026#34;是基本功 下一篇将从\u0026quot;数据在磁盘上长什么样\u0026quot;开始，深入存储引擎的世界：页、块、Extent、Segment、堆表 vs 索引组织表，以及为什么 MySQL 的主键就是数据本身。\n","date":"2025-04-18T10:00:00+08:00","permalink":"/posts/database/fundamentals/01-storage-overview/","title":"数据库系列（一）：从一行 UPDATE 看懂关系数据库"},{"content":"写在前面 承接上一篇，本文聚焦 Doris 的实战核心：表设计、分区与分桶、索引体系、查询优化、物化视图、Colocate Join，以及典型应用场景（实时数仓 / 多维分析）。基于 Doris 2.1.x。\n一、表设计核心：分区与分桶 Doris 一张表的物理结构是 Partition（分区）→ Bucket（分桶）→ Tablet（数据片）。表设计是否合理，80% 取决于分区和分桶。\n1.1 分区（Partition） 分区是逻辑切分，最常见的是按时间分区。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 CREATE TABLE access_log ( dt DATE, ts DATETIME, user_id BIGINT, page VARCHAR(128), cost_ms INT ) DUPLICATE KEY(dt, ts, user_id) PARTITION BY RANGE(dt) () DISTRIBUTED BY HASH(user_id) BUCKETS 4 PROPERTIES( \u0026#34;dynamic_partition.enable\u0026#34; = \u0026#34;true\u0026#34;, \u0026#34;dynamic_partition.time_unit\u0026#34; = \u0026#34;DAY\u0026#34;, \u0026#34;dynamic_partition.start\u0026#34; = \u0026#34;-30\u0026#34;, -- 保留最近 30 天 \u0026#34;dynamic_partition.end\u0026#34; = \u0026#34;3\u0026#34;, -- 预创建未来 3 天 \u0026#34;dynamic_partition.prefix\u0026#34; = \u0026#34;p\u0026#34;, \u0026#34;dynamic_partition.replication_num\u0026#34; = \u0026#34;1\u0026#34; ); 1 2 3 4 5 6 7 8 动态分区： - 自动按天创建/删除分区 - 常用配置：start=-30（删历史 30 天前的），end=3（预创建 3 天） 为什么必须分区： - 按时间裁剪：SELECT ... WHERE dt=\u0026#39;2026-06-01\u0026#39; 只扫一个分区 - 生命周期管理：TTL 删旧分区，比逐行删除快几个数量级 - 避免扫描全表：OLAP 查询 99% 都是按时间筛选 1.2 分桶（Bucket / Tablet） 分桶是物理切分，决定数据如何分布到 BE 节点，以及查询并行度。\n1 DISTRIBUTED BY HASH(user_id) BUCKETS 8 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 关键选择： 1. 分桶列： - 选高基数、常用于过滤/JOIN 的列 - 一般选 user_id、order_id 等 - 不要选 dt（dt 已经是分区列，重复使用没意义） - 不要选低基数列（如性别：会造成数据倾斜） 2. 分桶数（BUCKETS）： - 单 Tablet 大小推荐 1~10GB - 估算：每天数据量 / (单 Tablet 大小) = 分桶数 - 例：每天 100GB 数据，单 Tablet 2GB → BUCKETS = 50 3. 并发度： - 一条查询的并行度 ≈ 涉及 Tablet 数 - 太少（如 1 个）查询无法并行 - 太多（如几千个）元数据开销大 1.3 估算分桶数的公式 1 2 3 4 5 6 7 8 9 10 假设： - 日增量 100GB（含索引、压缩后实际存储约 30GB） - 单 Tablet 推荐 1~2GB - 按天分区 分桶数 = 单分区数据量 / 单 Tablet 大小 = 30GB / 1.5GB ≈ 20 桶 后验：通过 SHOW PARTITIONS 查看每个 Tablet 的大小，调整。 二、索引体系 Doris 的索引分两类：内置索引（建表自动）和显式索引（用户创建）。\n2.1 前缀索引（Prefix Index） Doris 默认对Key 列的前 36 字节建立前缀索引，类似 MySQL 的聚簇索引。\n1 2 3 4 5 CREATE TABLE t ( dt DATE, ts DATETIME, user_id BIGINT, page VARCHAR(128) ) DUPLICATE KEY(dt, ts, user_id) -- 这三列自动建立前缀索引 ... 1 2 3 4 5 6 7 8 9 含义： WHERE dt=\u0026#39;2026-06-01\u0026#39; ✅ 走前缀索引 WHERE dt=\u0026#39;2026-06-01\u0026#39; AND ts \u0026gt; \u0026#39;10:00\u0026#39; ✅ 走前缀索引 WHERE user_id = 1001 ❌ 不走（跳过了 dt、ts） 设计建议： - Key 列顺序按\u0026#34;查询过滤频率从高到低\u0026#34;排 - 通常顺序：dt → ts → user_id → ... - Key 列总长度 ≤ 36 字节（超出部分不进入前缀索引） 2.2 倒排索引（Inverted Index） Doris 2.0+ 引入，对字符串/数值列做全文检索，类似 Elasticsearch。\n1 2 3 4 5 6 7 ALTER TABLE access_log ADD INDEX idx_page (page) USING INVERTED PROPERTIES(\u0026#34;parser\u0026#34; = \u0026#34;english\u0026#34;) ; ALTER TABLE access_log ADD INDEX idx_user (user_id) USING INVERTED; ALTER TABLE access_log BUILD INDEX; 1 2 3 4 5 -- 用法：直接 WHERE，自动用索引 SELECT * FROM access_log WHERE page MATCH_ANY \u0026#39;home list detail\u0026#39;; SELECT * FROM access_log WHERE user_id = 1001; 1 2 3 4 特点： - 大幅提升等值/范围查询 - 支持全文检索（MATCH_ALL / MATCH_ANY） - 替代了老版本的 BITMAP_INDEX（更通用） 2.3 Bloom Filter 索引 针对高基数列的等值查询，简单且高效：\n1 2 3 ALTER TABLE access_log SET ( \u0026#34;bloom_filter_columns\u0026#34; = \u0026#34;user_id,page\u0026#34; ); 1 2 3 4 5 6 7 8 9 适用： - 高基数列（user_id, trace_id 等） - 等值查询（=） - IN 列表查询 不适用： - 低基数列（性别、状态） - 范围查询（\u0026gt;, \u0026lt;） - 加入了反而浪费空间 2.4 索引选型 场景 推荐索引 Key 列前缀范围查询 前缀索引（自动） 高基数列等值 Bloom Filter 字符串全文检索 倒排索引 低基数列等值/范围 倒排索引 三、查询优化 3.1 CBO 优化器 Doris 默认开启 CBO（Cost-Based Optimizer）。\n1 2 3 -- 查看实际执行计划 EXPLAIN VERBOSE SELECT dt, COUNT(*) FROM access_log GROUP BY dt; 1 2 3 4 5 6 CBO 会考虑： - 表的统计信息（行数、基数、选择率） - JOIN 顺序（小表驱动大表） - 是否广播 vs 重分布 需要：定期 ANALYZE TABLE 让统计信息新鲜 1 ANALYZE TABLE access_log WITH SYNC; 3.2 JOIN 策略 Doris 有三种 JOIN 执行方式：\n1 2 3 4 5 6 7 8 9 10 11 1. Broadcast Join（广播） 小表（\u0026lt; 1GB 默认）广播到所有 BE 节点 适合：大表 JOIN 小表（如事实表 JOIN 维度表） 2. Shuffle Join（重分布） 两表都按 JOIN KEY 重分布，再本地 JOIN 适合：大表 JOIN 大表 3. Colocate Join（同分布，零 Shuffle） 两表 JOIN KEY 类型一致、分桶方式一致、副本一致 适合：频繁 JOIN 的大表（如订单表 JOIN 用户表） Colocate Join 配置示例：\n1 2 3 4 5 6 7 8 -- 1. 建一个 colocate group ALTER TABLE orders SET (\u0026#34;colocate_with\u0026#34; = \u0026#34;user_grp\u0026#34;); ALTER TABLE users SET (\u0026#34;colocate_with\u0026#34; = \u0026#34;user_grp\u0026#34;); -- 2. 两表都按 user_id 分桶，自动按相同方式分布 -- 3. JOIN 时 FE 识别到 colocate，跳过网络 Shuffle SELECT a.order_id, b.name FROM orders a JOIN users b USING(user_id); 1 2 3 性能差异（实测）： Shuffle Join： ~10s（网络 + 落盘） Colocate Join： ~1s（零 Shuffle） 3.3 Runtime Filter 大表 JOIN 小表时，FE 会动态生成一个过滤条件下推到扫描节点。\n1 2 3 4 5 -- 大表 JOIN 小表，会自动启用 Runtime Filter SELECT count(*) FROM access_log a JOIN user_dim b ON a.user_id = b.user_id WHERE b.country = \u0026#39;CN\u0026#39;; 1 2 3 4 5 6 原理： 1. 先扫描 user_dim（小表）过滤后拿到 user_id 集合 2. 把这个集合推给 BE 节点扫描 access_log 时使用 3. 大表扫描时直接过滤掉不匹配的行 效果：扫描数据量减少 10~1000 倍 3.4 常见慢查询排查 1 2 3 4 5 6 7 8 9 10 11 12 -- 1. 看查询 Profile SET enable_profile = true; -- 跑一次慢查询，然后 Web UI → QueryProfile 查看每一步耗时 -- 2. 慢的常见原因 -- - 没走分区裁剪：扫描了所有分区 -- - 没走索引：Key 列顺序设计不当 -- - 数据倾斜：某个 Tablet 特别大 -- - JOIN 错：用了 Shuffle 而非 Colocate -- 3. 看 Tablet 分布是否均衡 SHOW TABLET FROM access_log; 四、物化视图（Materialized View） 物化视图 = 预计算 + 自动重写查询。\n4.1 同步物化视图（Rollup） 适合单表聚合加速，写入时自动维护：\n1 2 3 4 CREATE MATERIALIZED VIEW mv_user_pv AS SELECT dt, user_id, COUNT(*) AS pv FROM access_log GROUP BY dt, user_id; 1 2 3 4 原理： - 创建后，新写入的数据会同时写入主表和物化视图 - 查询时优化器自动判断是否使用物化视图 - 用户无需改 SQL，对应用透明 4.2 异步物化视图（Multi-Table） Doris 2.1+ 支持，可以跨表、跨库聚合：\n1 2 3 4 5 6 7 8 9 10 11 12 13 CREATE MATERIALIZED VIEW mv_daily_summary DISTRIBUTED BY HASH(dt) BUCKETS 4 REFRESH ASYNC EVERY(INTERVAL 1 HOUR) -- 每小时异步刷新 AS SELECT a.dt, b.country, COUNT(*) AS pv, COUNT(DISTINCT a.user_id) AS uv, SUM(a.cost_ms) AS total_cost FROM access_log a JOIN user_dim b ON a.user_id = b.user_id GROUP BY a.dt, b.country; 1 2 3 4 适用场景： - 复杂的实时大盘报表 - 上百个 Dashboard 的指标预聚合 - 流量减少：把 1TB 大表查询变成 10GB 物化视图查询 4.3 物化视图 vs Rollup 维度 Rollup（同步） 异步物化视图 单表/多表 单表 单/多表 维护 写入时同步 异步刷新 延迟 实时 分钟级 复杂度 简单 较复杂 推荐 简单聚合 复杂报表 五、数据导入实战 5.1 Stream Load（实时小批量） 1 2 3 4 5 6 curl -u root: \\ -H \u0026#34;label:order_20260614_001\u0026#34; \\ -H \u0026#34;column_separator:,\u0026#34; \\ -H \u0026#34;columns:order_id,user_id,amount,created_at\u0026#34; \\ -T /tmp/orders.csv \\ http://localhost:8030/api/demo/orders/_stream_load 1 2 3 4 5 6 7 8 9 10 11 关键参数： - label：导入的唯一标识，保证幂等（同一 label 不会重复导入） - column_separator：CSV 分隔符 - columns：CSV 列顺序 → 表列映射 返回值（成功示例）： { \u0026#34;Status\u0026#34;: \u0026#34;Success\u0026#34;, \u0026#34;NumberTotalRows\u0026#34;: 1000, \u0026#34;NumberLoadedRows\u0026#34;: 1000 } 5.2 Routine Load（消费 Kafka） 1 2 3 4 5 6 7 8 9 10 CREATE ROUTINE LOAD rl_orders ON orders WITH PROPERTIES ( \u0026#34;format\u0026#34; = \u0026#34;json\u0026#34;, \u0026#34;jsonpaths\u0026#34; = \u0026#34;[\\\u0026#34;$.order_id\\\u0026#34;,\\\u0026#34;$.user_id\\\u0026#34;,\\\u0026#34;$.amount\\\u0026#34;,\\\u0026#34;$.ts\\\u0026#34;]\u0026#34; ) FROM KAFKA ( \u0026#34;kafka_broker_list\u0026#34; = \u0026#34;kafka:9092\u0026#34;, \u0026#34;kafka_topic\u0026#34; = \u0026#34;orders\u0026#34;, \u0026#34;kafka_group_id\u0026#34; = \u0026#34;doris_orders\u0026#34; ); 1 2 3 4 5 6 7 特点： - 持续消费 Kafka topic - 支持 Exactly-Once（通过 label + Kafka offset） - 自动容错：BE 失败自动重试 典型链路： MySQL → Canal/Debezium → Kafka → Doris Routine Load → 实时数仓 5.3 Broker Load（批量导入） 1 2 3 4 5 6 7 8 9 10 LOAD LABEL demo.broker_load_001 ( DATA INFILE(\u0026#34;hdfs://namenode:8020/data/orders/*.parquet\u0026#34;) INTO TABLE orders FORMAT AS PARQUET ) WITH BROKER \u0026#34;broker1\u0026#34; PROPERTIES ( \u0026#34;timeout\u0026#34; = \u0026#34;3600\u0026#34; ); 适合一次性大规模历史数据迁移（数百 GB ~ TB）。\n六、典型应用场景 6.1 实时数仓 1 2 3 4 5 6 7 8 9 10 11 12 13 数据链路： 业务 DB → Canal → Kafka → Doris Routine Load ↓ Doris 实时表（明细模型 / 主键模型） ↓ Doris 物化视图（按小时/天聚合） ↓ BI / 报表查询 特点： - 秒级延迟（数据写入即可查） - 主键模型保证 UPSERT 一致性 - BI 直接用 MySQL 协议连接 6.2 多维分析（OLAP） 1 2 3 4 5 6 7 8 9 10 11 -- 用户行为分析：UV / PV / 漏斗 SELECT dt, page, COUNT(DISTINCT user_id) AS uv, COUNT(*) AS pv, AVG(cost_ms) AS avg_cost FROM access_log WHERE dt \u0026gt;= \u0026#39;2026-06-01\u0026#39; GROUP BY dt, page ORDER BY dt, uv DESC; 1 2 3 4 为什么 Doris 适合： - BITMAP_UNION 高效去重（UV 计算） - 列存 + 向量化扫描快 - 物化视图加速常用维度组合 6.3 日志检索 1 2 3 4 5 6 -- 利用倒排索引做日志搜索 SELECT * FROM access_log WHERE page MATCH_ALL \u0026#39;home error\u0026#39; AND ts \u0026gt;= \u0026#39;2026-06-14 00:00:00\u0026#39; ORDER BY ts DESC LIMIT 100; 新版本 Doris 的倒排索引能力，可以部分替代 Elasticsearch 做日志场景。\n七、小结 本文学习了 Doris 的进阶内容：\n表设计核心：分区（按时间）+ 分桶（按基数列）+ 副本 索引体系：前缀索引、倒排索引、Bloom Filter 查询优化：CBO 优化器、JOIN 策略、Colocate Join、Runtime Filter 物化视图：同步 Rollup（单表）和异步物化视图（多表） 数据导入：Stream Load、Routine Load、Broker Load 典型场景：实时数仓、多维分析、日志检索 1 2 3 4 5 6 落地建议： - 表设计：先按时间分区，再选高基数列分桶，BUCKETS 数按数据量算 - 模型选择：明细用 Duplicate，更新用 Primary Key - 索引：低基数 + 字符串检索 → 倒排；高基数等值 → Bloom Filter - JOIN：维度表用 Broadcast，事实表频繁 JOIN 用 Colocate - 加速：高频聚合查询用物化视图，复杂报表用异步物化视图 Doris 的核心价值在于用极简的部署 + MySQL 协议，覆盖实时数仓和 OLAP 分析两大场景。后续如果需要进一步深入，可以研究：\nFE 元数据选主机制（BDB-JE） BE 向量化执行引擎 CBO 优化器的代价模型 存算分离架构（Doris 3.x Cloud 版） ","date":"2025-04-10T10:00:00+08:00","permalink":"/posts/middleware/doris/02-doris-advanced/","title":"Doris 学习笔记（二）：进阶与实战"},{"content":"写在前面 本文是 Doris 学习笔记系列的第一篇，介绍 Apache Doris 的核心定位、FE/BE 架构、四种数据模型的差异，以及 Docker 部署和基础 SQL 操作。基于 Doris 2.1.x 版本。\n一、Doris 是什么 1.1 定义 Apache Doris 是一个高性能、实时的分析型数据库（OLAP MPP 数据库），最初由百度 PALO 项目开源，2018 年进入 Apache 孵化器，2022 年成为顶级项目。\n1 2 3 4 5 6 核心特征： 1. MPP 架构 — 大规模并行处理，查询可水平扩展 2. MySQL 协议 — 兼容 MySQL 语法，可用 MySQL 客户端直接连接 3. 实时写入 — Stream Load / Routine Load 支持秒级数据可见 4. 列式存储 — 高压缩比 + 向量化执行，OLAP 查询效率高 5. 不需要外部依赖 — FE + BE 两类节点，部署简单（不需要 Hadoop/Spark） 1.2 解决什么问题 1 2 3 4 5 6 7 8 9 传统离线数仓（Hive / HDFS + Spark）： - 数据延迟：T+1，今天看昨天的数据 - 查询慢：交互式 BI 查询几秒到几十秒 - 链路复杂：HDFS、YARN、Hive、Spark、Presto…… Doris 试图解决： - 实时数仓：Flink/写入 → 秒级 / 分钟级数据可见 - 高并发查询：单集群可扛上千 QPS 的多维分析 - 极简运维：FE + BE 两类角色，二进制部署 1.3 同类产品对比 维度 Doris ClickHouse StarRocks Druid 出身 百度 → Apache Yandex Doris 商业分支 Apache SQL 兼容 MySQL 协议 自有方言 MySQL 协议 有限 实时写入 强（Stream Load） 弱（合并机制） 强 中 JOIN 能力 中上（Colocate/Broadcast/Shuffle） 强（大表 JOIN） 强 弱 物化视图 支持 不支持（用 Projection） 支持 支持 Rollup 运维复杂度 简单 中等 简单 复杂 1 2 3 4 一句话总结： - 选 Doris：开箱即用、运维简单、实时 + OLAP 兼顾、生态完整 - 选 ClickHouse：单表极致性能、JOIN 强大、超大规模 - 选 StarRocks：Doris 升级版，外表联邦查询更好，性能更激进 1.4 什么是联邦查询 联邦查询（Federated Query） 指查询引擎在查询时直接访问外部异构数据源（MySQL、Hive、Iceberg、Hudi、Kafka、ES 等），把外部表当本地表来查，甚至跨源 JOIN，不实际搬移数据。\n1 2 3 4 5 6 7 8 9 10 订单表在 MySQL + 行为日志在 Doris 本地 + 用户画像在 Hive → 一条 SQL 直接 JOIN，Doris/StarRocks 自己协调怎么拉、怎么算 SELECT m.province, SUM(m.amount), COUNT(DISTINCT l.user_id) FROM mysql_catalog.db.orders m -- MySQL 外表 JOIN local.behavior_log l USING(user_id) -- 本地表 JOIN hive_catalog.db.profile h USING(user_id) -- Hive 外表 WHERE m.dt \u0026gt;= \u0026#39;2026-06-07\u0026#39; GROUP BY m.province; 联邦查询 vs 传统 ETL：\n方式 数据搬移 实时性 维护成本 ETL 进数仓 需要 有延迟（T+1 / 分钟级） 高（多任务 + 调度 + 对账） 联邦查询 不需要 实时 低（建个外表即可） 1 2 3 4 5 6 7 8 9 10 11 12 13 典型应用： - 数据湖分析：直接查 S3/HDFS 上的 Iceberg/Hudi/Delta/Paimon - 跨库报表：业务库 MySQL + 数据仓库 Hive 不想 ETL 又想统一查询 - 避免重复存储：数据湖一份，多个引擎查询 StarRocks 为什么在联邦查询上更强： - Catalog 体系成熟早，Iceberg/Hudi/Delta Native Reader 优化激进 （直接读 Parquet/ORC，不经 Hive Metastore 中转） - CBO 优化器对外表统计信息、谓词下推、分区裁剪做得更细 - 实测 Iceberg 大表查询比 Doris 快 1.5~2 倍 Doris 也补齐了大部分外表能力（Hive / Iceberg / JDBC Catalog 等）， 但对数据湖 + 联邦这一块仍以 StarRocks 为优。 二、架构 2.1 整体架构 Doris 是存算耦合架构（2.1 起开始支持存算分离模式，但默认还是存算耦合）。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 ┌────────────────────────────────────┐ │ 客户端 / 应用 │ │ (MySQL Client / JDBC / Stream) │ └─────────────────┬──────────────────┘ │ ▼ ┌───────────────────────────────────────┐ │ FE (Frontend) │ │ 元数据 / SQL 解析 / 查询计划 / 调度 │ │ │ │ ┌──────┐ ┌──────┐ ┌──────┐ │ │ │ FE-1 │ │ FE-2 │ │ FE-3 │ │ │ │Master│←→│Follw.│←→│Follw.│ │ │ └──────┘ └──────┘ └──────┘ │ └─────────────────┬─────────────────────┘ │ ┌─────────────────────┼─────────────────────┐ ▼ ▼ ▼ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ BE-1 │ │ BE-2 │ │ BE-3 │ │ 存储+计算 │ │ 存储+计算 │ │ 存储+计算 │ └──────────┘ └──────────┘ └──────────┘ 2.2 FE（Frontend） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 职责： - 元数据管理（数据库、表、分区、副本位置） - SQL 解析、查询优化（CBO 优化器） - 查询计划生成与调度 - 元数据高可用（基于 BDB-JE） 角色： - Master FE：唯一，负责元数据写入 - Follower FE：参与选主，可读元数据 - Observer FE：只读，仅用于扩展查询能力 推荐部署： - 至少 3 个 FE（1 Master + 2 Follower），保证多数派可用 - Observer 用于读扩展，可以任意数量 2.3 BE（Backend） 1 2 3 4 5 6 7 8 9 10 11 12 13 职责： - 数据存储（按 Tablet 分片，副本存放） - 查询执行（向量化执行引擎） - 数据导入（Stream Load / Routine Load 实际处理者） 存储结构： - 物理上：Table → Partition → Tablet → Rowset → Segment - Tablet 是数据分片和副本管理的最小单位 - 默认每张表 3 副本（可配置） 推荐部署： - 至少 3 个 BE（满足默认 3 副本分布） - 每个 BE 单独部署在一台物理机 / 容器 2.4 一条查询的执行流程 1 2 3 4 5 6 7 8 9 1. 客户端发送 SQL 到任意 FE 2. FE 解析 SQL → 生成逻辑计划 3. CBO 优化器生成最优物理计划 4. FE 将计划分发到 BE 节点 5. BE 并行执行（MPP） - 扫描节点（Scan Node）：读本地数据 - 计算：过滤、聚合、JOIN - 数据通过网络 Shuffle / Broadcast 6. 汇总结果到 FE，返回客户端 三、Docker 部署 最快的体验方式是用官方的 Docker 镜像跑一个单机版（1 FE + 1 BE）。\n3.1 网络 1 docker network create doris-net 3.2 启动 FE 1 2 3 4 5 6 7 docker run -d \\ --name doris-fe \\ --network doris-net \\ -p 8030:8030 \\ -p 9030:9030 \\ -e FE_SERVERS=\u0026#34;fe1:172.20.80.2:9010:9020:9030\u0026#34; \\ apache/doris:fe-2.1.7 1 2 3 4 5 端口说明： - 8030：FE Web UI - 9030：MySQL 协议端口（用 mysql 客户端连接） - 9010：FE 内部通信 - 9020：FE 元数据编辑日志 3.3 启动 BE 1 2 3 4 5 6 7 docker run -d \\ --name doris-be \\ --network doris-net \\ -p 8040:8040 \\ -e BE_ADDR=\u0026#34;172.20.80.3:9050\u0026#34; \\ -e FE_SERVERS=\u0026#34;fe1:172.20.80.2:9010:9020:9030\u0026#34; \\ apache/doris:be-2.1.7 3.4 添加 BE 到集群 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # 用 mysql 客户端连接 FE mysql -h 127.0.0.1 -P 9030 -uroot # 默认无密码 # 查看 FE 状态 SHOW FRONTENDS; # Expected：Alive = true # 查看 BE 状态（首次为空） SHOW BACKENDS; # 添加 BE ALTER SYSTEM ADD BACKEND \u0026#34;172.20.80.3:9050\u0026#34;; # 再次查看 SHOW BACKENDS; # Expected：Alive = true，HeartbeatPort = 9050 3.5 Web UI 访问 http://localhost:8030 查看集群状态：\nSystem Info → 看 FE / BE 列表 PlayGround → 直接在网页上跑 SQL 四、数据模型 Doris 提供 4 种数据模型，区别在于\u0026quot;同一主键（或维度）的数据如何处理\u0026quot;。选对模型是 Doris 表设计的核心。\n4.1 明细模型（Duplicate Key） 最简单：所有写入的数据原样保留，不合并。适合日志、明细数据。\n1 2 3 4 5 6 7 8 9 10 CREATE TABLE event_log ( event_time DATETIME NOT NULL, user_id BIGINT, event_type VARCHAR(32), page VARCHAR(128), duration INT ) DUPLICATE KEY(event_time, user_id) DISTRIBUTED BY HASH(user_id) BUCKETS 8 PROPERTIES(\u0026#34;replication_num\u0026#34; = \u0026#34;1\u0026#34;); 1 2 3 4 特点： - DUPLICATE KEY 仅用于数据排序，不合并 - 写入即可查，无需任何聚合 - 适合：原始日志、明细数据 4.2 聚合模型（Aggregate） 预先声明聚合方式（SUM / MIN / MAX / REPLACE / BITMAP_UNION / HLL_UNION），相同 Key 的数据自动合并。\n1 2 3 4 5 6 7 8 9 10 CREATE TABLE user_metric_agg ( dt DATE, user_id BIGINT, pv BIGINT SUM, -- 求和 uv BITMAP BITMAP_UNION, -- BITMAP_UNION 计数 stay_sec INT MAX -- 取最大 ) AGGREGATE KEY(dt, user_id) DISTRIBUTED BY HASH(user_id) BUCKETS 8 PROPERTIES(\u0026#34;replication_num\u0026#34; = \u0026#34;1\u0026#34;); 1 2 3 4 5 6 7 8 应用场景： - 数据仓库的汇总表（按天/小时聚合） - UV 统计（BITMAP_UNION） - 适合：查询前数据需要聚合的场景 注意： - 写入相同 Key 多次，只保留聚合结果 - 读取时自动按 Key 再做一次合并 4.3 唯一模型（Unique Key，旧版） 保证主键唯一，后写覆盖前写。适合主键更新场景，但读性能不如主键模型。\n1 2 3 4 5 6 7 8 9 CREATE TABLE user_profile ( user_id BIGINT, name VARCHAR(32), age INT, updated_at DATETIME ) UNIQUE KEY(user_id) DISTRIBUTED BY HASH(user_id) BUCKETS 8 PROPERTIES(\u0026#34;replication_num\u0026#34; = \u0026#34;1\u0026#34;); 1 2 3 4 5 6 7 8 特点（旧版 Unique）： - 实现：Read 时合并（Merge on Read） - 写入：多次写同主键，最后写入的\u0026#34;胜出\u0026#34; - 适合：维度表、用户画像（不频繁更新） 性能提示： - 旧版 Unique 查询会触发合并，性能不如主键模型 - 新版本建议优先用 Primary Key 模型 4.4 主键模型（Primary Key，新版推荐） Doris 1.2+ 引入，真正支持高效 UPSERT 和部分列更新。\n1 2 3 4 5 6 7 8 9 10 11 12 13 CREATE TABLE orders ( order_id BIGINT, user_id BIGINT, status VARCHAR(16), amount DECIMAL(10, 2), created_at DATETIME ) PRIMARY KEY(order_id) DISTRIBUTED BY HASH(order_id) BUCKETS 8 PROPERTIES( \u0026#34;replication_num\u0026#34; = \u0026#34;1\u0026#34;, \u0026#34;enable_unique_key_merge_on_write\u0026#34; = \u0026#34;true\u0026#34; ); 1 2 3 4 5 6 7 8 9 10 特点： - 主键唯一约束，UPSERT 自动覆盖 - enable_unique_key_merge_on_write = true 时 → 写时合并（Copy on Write），读性能接近明细模型 - 支持部分列更新（UPDATE col1 = ... WHERE pk = ...） 应用场景： - 订单表（订单状态机：created → paid → shipped） - 实时画像（频繁更新某些字段） - CDC 同步（MySQL binlog 实时写入） 4.5 四种模型对比 模型 主键语义 合并时机 适合场景 UPSERT 支持 Duplicate 仅排序 不合并 日志、明细 ❌ Aggregate 聚合维度 写入 + 读取 汇总表、UV/PV ❌（覆盖语义） Unique（旧） 唯一 读取时合并 维度表 ✅（但慢） Primary（新） 唯一 写入时合并 订单、画像、CDC ✅（高性能） 1 2 3 4 5 选型建议： - 日志/原始明细 → Duplicate - 已经按维度聚合过的统计表 → Aggregate - 需要主键更新（订单/画像/CDC） → Primary Key - 旧版 Unique 除非兼容老系统，否则用 Primary 替代 五、基础 SQL 操作 5.1 建库 1 2 CREATE DATABASE demo; USE demo; 5.2 建表（示例：明细模型） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 CREATE TABLE access_log ( dt DATE, ts DATETIME, user_id BIGINT, page VARCHAR(128), cost_ms INT ) DUPLICATE KEY(dt, ts, user_id) PARTITION BY RANGE(dt) ( PARTITION p20260601 VALUES [(\u0026#39;2026-06-01\u0026#39;), (\u0026#39;2026-06-02\u0026#39;)), PARTITION p20260602 VALUES [(\u0026#39;2026-06-02\u0026#39;), (\u0026#39;2026-06-03\u0026#39;)) ) DISTRIBUTED BY HASH(user_id) BUCKETS 4 PROPERTIES(\u0026#34;replication_num\u0026#34; = \u0026#34;1\u0026#34;); 1 2 3 4 关键概念： PARTITION（分区）：按 dt 按天分区，便于按时间裁剪和管理 BUCKETS（分桶）：分区内按 user_id 哈希分桶，控制并行度 副本：replication_num = 1（单机测试，生产至少 3） 5.3 数据导入 Doris 提供多种导入方式，最常用的是 Stream Load（HTTP 推送）：\n1 2 3 4 5 # 通过 curl 推送 CSV curl -u root: -H \u0026#34;label:load_001\u0026#34; \\ -H \u0026#34;column_separator:,\u0026#34; \\ -T data.csv \\ http://localhost:8030/api/demo/access_log/_stream_load 1 2 3 4 5 -- 也可以在 SQL 客户端用 INSERT 插入 INSERT INTO access_log VALUES (\u0026#39;2026-06-01\u0026#39;, \u0026#39;2026-06-01 10:00:00\u0026#39;, 1001, \u0026#39;/home\u0026#39;, 12), (\u0026#39;2026-06-01\u0026#39;, \u0026#39;2026-06-01 10:01:00\u0026#39;, 1001, \u0026#39;/list\u0026#39;, 35), (\u0026#39;2026-06-01\u0026#39;, \u0026#39;2026-06-01 10:02:00\u0026#39;, 1002, \u0026#39;/home\u0026#39;, 10); 1 2 3 4 5 导入方式对比： Stream Load — HTTP 推送，适合小批量 / 实时写入 Routine Load — 持续消费 Kafka，做实时数仓 Broker Load — 从 HDFS / S3 批量导入 INSERT INTO SELECT — 从一张表导到另一张表 5.4 查询 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 -- 普通查询，标准 SQL SELECT * FROM access_log ORDER BY ts DESC LIMIT 10; -- 按天聚合 SELECT dt, COUNT(*) AS pv, COUNT(DISTINCT user_id) AS uv FROM access_log GROUP BY dt ORDER BY dt; -- 窗口函数 SELECT user_id, dt, cost_ms, SUM(cost_ms) OVER (PARTITION BY user_id ORDER BY dt) AS cum_cost FROM access_log; -- 查看执行计划 EXPLAIN SELECT dt, COUNT(*) FROM access_log GROUP BY dt; 5.5 表管理 1 2 3 4 5 6 7 8 9 10 11 12 -- 动态新增分区（按天滚动） ALTER TABLE access_log ADD PARTITION p20260603 VALUES [(\u0026#39;2026-06-03\u0026#39;), (\u0026#39;2026-06-04\u0026#39;)); -- 查看分区 SHOW PARTITIONS FROM access_log; -- 修改副本数 ALTER TABLE access_log SET (\u0026#34;default.replication_num\u0026#34; = \u0026#34;3\u0026#34;); -- 删除分区（不影响其他分区） DROP PARTITION p20260601; 六、小结 本文学习了 Doris 的基础：\nDoris 是什么，与 ClickHouse / StarRocks 的差异 FE / BE 架构，存算耦合的设计 Docker 部署单机版 四种数据模型（明细、聚合、唯一、主键）的差异和选型 基础 SQL：建库建表、导入、查询、分区管理 下一篇将学习 Doris 的进阶实战：表设计、索引、查询优化、物化视图和典型应用场景。\n","date":"2025-04-06T10:00:00+08:00","permalink":"/posts/middleware/doris/01-doris-basics/","title":"Doris 学习笔记（一）：基础与架构"},{"content":"写在前面 本文是 Nginx 学习笔记系列的最后一篇，深入 Nginx 的底层实现：Master/Worker 进程模型、事件驱动机制、请求处理的 11 个阶段、内存管理，以及 Nginx 为什么这么快。最后对比 Nginx、OpenResty 和 Envoy。\n一、Master/Worker 进程模型 1.1 进程架构 1 2 3 4 5 6 7 8 9 10 Nginx 启动后的进程结构： PID PPID COMMAND 1001 1 nginx: master process ← Master 进程（以 root 运行） 1002 1001 nginx: worker process ← Worker 进程（以 www-data 运行） 1003 1001 nginx: worker process 1004 1001 nginx: worker process 1005 1001 nginx: worker process ← 4 个 Worker = 4 核 CPU 1006 1001 nginx: cache manager process ← 缓存管理 1007 1001 nginx: cache loader process ← 缓存加载 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 进程分工： Master 进程： - 读取和验证配置文件 - 创建、绑定、监听 Socket - 管理 Worker 进程（fork、监控、重启） - 处理信号（reload、reopen、stop） - 不处理任何业务请求 Worker 进程： - 接收和处理客户端请求 - 每个 Worker 独立、互不干扰 - 一个 Worker 崩溃不影响其他 Worker - Worker 之间通过共享内存通信 Cache Manager： - 管理磁盘缓存 - 检查缓存有效期，删除过期缓存 Cache Loader： - Nginx 启动时加载磁盘缓存到内存索引 - 加载完成后退出 1.2 为什么不用多线程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 多进程 vs 多线程： Nginx 选择多进程： ✓ Worker 崩溃不影响其他 Worker（隔离性好） ✓ 无锁设计，无死锁风险 ✓ 配合 epoll 事件驱动，单进程处理万级并发 ✓ 方便利用多核（每个 Worker 绑定一个 CPU 核心） 多线程的问题： ✗ 线程间共享内存，需要大量锁 ✗ 锁竞争导致性能下降 ✗ 一个线程崩溃可能导致整个进程崩溃 ✗ 调试困难 实际上 Nginx 的 Worker 内部也有辅助线程（file aio）， 但核心请求处理是单线程事件驱动。 1.3 进程管理 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # 查看 Nginx 进程 ps aux | grep nginx # Worker 数量配置 # nginx.conf worker_processes auto; # 自动 = CPU 核心数 # 或手动指定 worker_processes 4; # 绑定 Worker 到 CPU 核心（减少 CPU 切换开销） worker_cpu_affinity auto; # Worker 进程优先级 worker_priority -5; # 值越小优先级越高（-20 到 19） # Worker 最大打开文件数 worker_rlimit_nofile 65535; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Master 处理信号： TERM, INT → 优雅停止 QUIT → 优雅停止（等请求处理完） HUP → 重新加载配置 USR1 → 重新打开日志文件 USR2 → 升级可执行文件（热升级） WINCH → 优雅停止 Worker reload 时发生了什么： 1. Master 收到 HUP 信号 2. 重新读取配置文件 3. 如果配置合法，fork 新的 Worker 进程 4. 向旧 Worker 发送 QUIT 信号 5. 旧 Worker 处理完当前请求后退出 6. 全程无中断（零停机 reload） 二、事件驱动机制（epoll） 2.1 为什么需要事件驱动 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 传统方式（多进程/线程模型）： 每个连接分配一个线程 连接空闲时，线程阻塞在 read() 上 1万个连接 = 1万个线程 问题： ✗ 线程占用内存（每个线程约 8MB 栈空间） ✗ 线程切换开销大（上下文切换、CPU 缓存失效） ✗ 难以扩展到 C10K（万级并发） 事件驱动模型： 一个线程管理所有连接 只在连接有事件（可读/可写）时才处理 空闲连接不消耗 CPU 优势： ✓ 少量线程即可处理万级并发 ✓ 空闲连接零开销 ✓ CPU 只在做有用的工作 2.2 I/O 多路复用技术 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 Linux I/O 多路复用的演进： 1. select - 最多监听 1024 个文件描述符（FD） - 每次调用需要传入所有 FD，内核遍历全部 - O(n) 复杂度，性能差 2. poll - 没有连接数限制 - 仍然需要传入所有 FD，内核遍历全部 - O(n) 复杂度 3. epoll（Linux 2.6+） - 没有连接数限制 - 只返回就绪的 FD，不需要遍历全部 - O(1) 复杂度，性能极好 - Nginx 在 Linux 上的默认选择 4. kqueue（FreeBSD / macOS） - 类似 epoll，性能优秀 - Nginx 在 macOS 上的默认选择 2.3 epoll 工作原理 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 epoll 的三个系统调用： 1. epoll_create() 创建 epoll 实例（红黑树 + 就绪链表） 2. epoll_ctl() 注册/修改/删除要监听的 FD 内核会为每个 FD 注册回调 3. epoll_wait() 阻塞等待就绪事件 只返回有事件的 FD（不需要遍历全部） 工作流程： ┌─────────────┐ │ epoll 实例 │ │ ┌───────────┐ │ │ │ 红黑树 │ │ ← 存储所有注册的 FD │ │ (FD 集合) │ │ │ └───────────┘ │ │ ┌───────────┐ │ │ │ 就绪链表 │ │ ← 有事件的 FD（由内核回调自动添加） │ │ (活跃 FD) │ │ │ └───────────┘ │ └─────────────┘ 网络数据到达 → 网卡中断 → 内核回调 → FD 加入就绪链表 epoll_wait() 返回就绪链表 → Nginx 处理活跃连接 关键优势： 不需要遍历所有连接，只处理有事件的 1万个连接中可能只有 100 个活跃 → 只处理 100 个 2.4 Nginx 的事件模型 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # nginx.conf events 块 events { # 使用 epoll（Linux 默认） use epoll; # 每个 Worker 的最大连接数 worker_connections 65535; # 尽可能多地接收连接 multi_accept on; # 禁用 accept 互斥锁（Worker 数少时） accept_mutex off; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 Nginx Worker 的事件循环（简化版）： while (true) { events = epoll_wait(epoll_fd, ...); // 等待事件 for (event in events) { if (event 是新连接) { accept 新连接; 将新连接注册到 epoll; } else if (event 可读) { 读取请求数据; 处理请求; 生成响应; } else if (event 可写) { 发送响应数据; } } } 注意： - 事件循环中不执行阻塞操作 - 文件 I/O 用线程池（file aio） - DNS 解析有缓存，避免阻塞 2.5 连接处理流程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 一个 HTTP 请求的完整生命周期： 1. 客户端发起 TCP 连接 → 三次握手完成，epoll 通知有新连接 2. Worker accept 连接 → 创建 connection 对象 → 注册到 epoll 监听读事件 3. 客户端发送 HTTP 请求 → epoll 通知可读事件 → Worker 读取请求数据 → 解析 HTTP 请求行、头部、请求体 4. 处理请求 → 匹配 location → 执行 rewrite、access、content 等阶段 → 生成响应 5. 发送响应 → 注册可写事件到 epoll → epoll 通知可写时发送数据 6. keepalive 复用或关闭连接 → keepalive：连接保持，等待下一个请求 → 超时或 Connection: close：关闭连接 整个过程：一个 Worker 线程处理，不创建新线程 三、请求处理完整流程（11 个阶段） 3.1 HTTP 请求处理管道 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 Nginx 把 HTTP 请求处理分为 11 个阶段，模块按阶段执行： ┌─────────────────────────────────────────┐ │ HTTP Request │ └──────────────┬──────────────────────────┘ ▼ ┌─────────────────────────────────────────┐ │ 1. POST_READ 读取请求后 │ ngx_http_realip_module │ （获取真实 IP） │ └──────────────┬──────────────────────────┘ ▼ ┌─────────────────────────────────────────┐ │ 2. SERVER_REWRITE server 级重写 │ ngx_http_rewrite_module │ （server 块中的 rewrite） │ └──────────────┬──────────────────────────┘ ▼ ┌─────────────────────────────────────────┐ │ 3. FIND_CONFIG 查找配置 │ Nginx 核心 │ （匹配 location） │ └──────────────┬──────────────────────────┘ ▼ ┌─────────────────────────────────────────┐ │ 4. REWRITE location 级重写 │ ngx_http_rewrite_module │ （location 块中的 rewrite） │ └──────────────┬──────────────────────────┘ ▼ ┌─────────────────────────────────────────┐ │ 5. POST_REWRITE 重写后处理 │ Nginx 核心 │ （检查是否需要重新匹配 location） │ └──────────────┬──────────────────────────┘ ▼ ┌─────────────────────────────────────────┐ │ 6. PREACCESS 访问前检查 │ ngx_http_limit_conn_module │ （限流、连接数限制） │ ngx_http_limit_req_module └──────────────┬──────────────────────────┘ ▼ ┌─────────────────────────────────────────┐ │ 7. ACCESS 访问控制 │ ngx_http_access_module │ （IP 黑白名单、认证） │ ngx_http_auth_basic_module └──────────────┬──────────────────────────┘ ▼ ┌─────────────────────────────────────────┐ │ 8. POST_ACCESS 访问后处理 │ Nginx 核心 │ （satisfy 配合 access 阶段） │ └──────────────┬──────────────────────────┘ ▼ ┌─────────────────────────────────────────┐ │ 9. PRECONTENT 内容前处理 │ ngx_http_try_files_module │ （try_files） │ └──────────────┬──────────────────────────┘ ▼ ┌─────────────────────────────────────────┐ │ 10. CONTENT 生成内容 │ ngx_http_proxy_module │ （代理、静态文件、FastCGI） │ ngx_http_static_module └──────────────┬──────────────────────────┘ ▼ ┌─────────────────────────────────────────┐ │ 11. LOG 记录日志 │ ngx_http_log_module │ （access_log） │ └─────────────────────────────────────────┘ 3.2 各阶段详解 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 # 以下配置展示了各阶段对应的指令 server { listen 80; server_name example.com; # 阶段 1: POST_READ — 获取真实 IP set_real_ip_from 10.0.0.0/8; real_ip_header X-Forwarded-For; real_ip_recursive on; # 阶段 2: SERVER_REWRITE — server 级重写 rewrite ^/old-api/(.*)$ /api/$1 last; location /api/ { # 阶段 4: REWRITE — location 级重写 rewrite ^/api/v1/(.*)$ /api/v2/$1 break; # 阶段 6: PREACCESS — 限流 limit_req zone=api_limit burst=50 nodelay; limit_conn conn_limit 20; # 阶段 7: ACCESS — 访问控制 allow 10.0.0.0/8; allow 192.168.0.0/16; deny all; auth_basic \u0026#34;Restricted\u0026#34;; auth_basic_user_file /etc/nginx/.htpasswd; # 阶段 9: PRECONTENT — try_files try_files $uri $uri/ @proxy; # 阶段 10: CONTENT — 生成内容 proxy_pass http://backend; } location @proxy { proxy_pass http://backend; } # 阶段 11: LOG — 日志 access_log /var/log/nginx/example.com.access.log detailed; } 3.3 阶段执行的特点 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 1. 顺序执行 请求按阶段 1→2→3→...→11 顺序经过各阶段 2. 模块注册 每个模块可以注册到特定阶段 同一阶段可以有多个模块，按配置顺序执行 3. 阶段跳转 rewrite 的 last 会跳回阶段 3（FIND_CONFIG） rewrite 的 break 跳过后续 rewrite，进入下一阶段 return 直接跳到阶段 11（LOG） 4. 内容阶段只有一个执行 proxy_pass、fastcgi_pass、root/alias 只能选一个 先匹配到的执行 5. 日志阶段总是执行 无论前面的阶段返回什么状态码，日志阶段都会执行 四、内存管理 4.1 Nginx 的内存管理策略 1 2 3 4 5 6 7 8 9 10 11 12 为什么不直接用 malloc/free： 问题： ✗ 频繁的 malloc/free 导致内存碎片 ✗ 每次分配有系统调用开销 ✗ 容易忘记 free 导致内存泄漏 ✗ 多线程环境下需要锁 Nginx 的方案： ✓ 内存池（pool）— 批量分配，一次性释放 ✓ 共享内存 — Worker 间通信 ✓ slab 分配器 — 管理共享内存中的固定大小对象 4.2 内存池（ngx_pool_t） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 内存池结构： ngx_pool_t ├── d: last, end, next, failed ← 数据区链表 ├── max: 最大可分配大小 ├── current: 当前工作的小块内存池 ├── chain: 缓冲区链表 ├── large: 大块内存链表 ├── cleanup: 清理回调链表 └── hostnet: 子内存池 ┌──────────────────────────────┐ │ pool │ ← 一次 malloc 分配一整块（如 4KB） │ ┌────────────────────────┐ │ │ │ 已使用 │ 空闲 │ │ ← last 指针标记空闲起始位置 │ └────────────────────────┘ │ │ next pool │ ← 空间不够时，再 malloc 一块链上 │ ┌────────────────────────┐ │ │ │ 空闲 │ │ │ └────────────────────────┘ │ └──────────────────────────────┘ 小块分配（≤ max，通常 ≤ 4096 字节）： 从当前 pool 的空闲区域直接切出 移动 last 指针即可，极快 不需要 free，整个 pool 一起释放 大块分配（\u0026gt; max）： 直接 malloc 分配 加入 large 链表管理 pool 销毁时一起释放 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 内存池的分配和释放： 创建： pool = ngx_create_pool(4096, log) // 分配 4KB 内存池 小块分配： p = ngx_palloc(pool, 128) // 从池中切 128B，极快 p = ngx_pnalloc(pool, 256) // 同上，不要求对齐 大块分配： p = ngx_palloc(pool, 8192) // \u0026gt; max，直接 malloc 使用完毕： ngx_destroy_pool(pool) // 一次性释放所有内存 重置（复用）： ngx_reset_pool(pool) // 重置 last 指针，不释放内存 优势： ✓ 小块分配只需要移动指针，O(1) 复杂度 ✓ 没有内存碎片（同一 pool 中连续分配） ✓ 不需要逐个 free，一键销毁 ✓ 每个请求一个 pool，请求结束销毁，不会泄漏 4.3 请求与内存池的生命周期 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 一个 HTTP 请求的内存管理： 请求开始 → 创建请求级内存池（request pool） 请求处理过程中 → 解析请求行 → palloc 分配 → 解析请求头 → palloc 分配 → 读取请求体 → palloc 分配 → 生成响应 → palloc 分配 → 所有分配都从池中获取，不需要 free 请求结束 → 销毁请求级内存池 → 所有内存一次性释放 → 不会泄漏任何字节 对比传统方式： 传统：malloc/free 配对，容易漏 free Nginx：只管分配，不管释放，请求结束统一释放 4.4 共享内存 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 Worker 进程之间需要共享数据（如限流的计数器）： Nginx 共享内存： ngx_shm_zone_t ├── shm_zone: 共享内存区域名称 ├── shm_size: 共享内存大小 ├── data: 自定义数据 └── init: 初始化回调 使用场景： limit_req_zone — 限流计数器 limit_conn_zone — 连接数计数器 ssl_session_cache — SSL 会话缓存 proxy_cache — 代理缓存 upstream — 负载均衡状态 配置示例： limit_req_zone $binary_remote_addr zone=api:10m rate=100r/s; # ↑ IP ↑ 名称 ↑ 10MB 共享内存 4.5 slab 分配器 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 共享内存中使用 slab 分配器管理固定大小的对象： slab 分配器结构： ┌─────────────────────────────────────────────┐ │ 共享内存（如 10MB） │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ Page 0 │ │ Page 1 │ │ Page 2 │ ... │ │ └─────────┘ └─────────┘ └─────────┘ │ │ 每个 Page 4KB │ │ │ │ Page 内部按 slot 大小分割： │ │ slot 大小：8, 16, 32, 64, 128, 256, ... │ │ │ │ 如 slot_size = 64： │ │ ┌───┬───┬───┬───┬───┬───┬───┬───┐ │ │ │64B│64B│64B│64B│64B│64B│64B│64B│ ← 8个slot│ │ └───┴───┴───┴───┴───┴───┴───┴───┘ │ └─────────────────────────────────────────────┘ 工作方式： 分配：根据对象大小选择合适的 slot，从 page 中分配一个 slot 释放：标记 slot 为空闲，可以复用 无碎片：固定大小分配，不会产生碎片 应用场景： 限流：每个 IP 的计数器是固定大小（如 64B），用 slab 分配 SSL 缓存：每个会话条目大小固定 负载均衡：upstream 的状态信息 五、为什么 Nginx 这么快 5.1 性能数据 1 2 3 4 5 6 7 8 9 10 11 12 13 典型性能基准： 静态文件服务： 简单 HTML：50,000+ req/s（单机） 小文件（\u0026lt;10KB）：30,000+ req/s 反向代理： 简单代理：20,000+ req/s 带 SSL：10,000+ req/s 对比 Apache（prefork 模式）： 同等硬件：Nginx 快 2-10 倍 内存占用：Nginx 低 5-20 倍 5.2 快的原因总结 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 1. 事件驱动 + epoll 单线程处理万级并发 只处理活跃连接，空闲连接零开销 epoll O(1) 复杂度，不随连接数增长而变慢 2. 零拷贝（sendfile） 静态文件不需要经过用户空间 内核直接从文件描述符拷贝到 Socket 减少两次内存拷贝和上下文切换 传统方式： 磁盘 → 内核缓冲区 → 用户空间 → Socket 缓冲区 → 网卡 sendfile： 猖盘 → 内核缓冲区 → 网卡（直接 DMA 传输） 3. 内存池 批量分配，一次性释放 小块内存分配只需移动指针（O(1)） 无内存碎片，无泄漏 4. 无锁设计 Worker 进程独立，互不干扰 不需要互斥锁，无锁竞争 共享内存用原子操作和 slab 分配器 5. 高效的 I/O 处理 sendfile 零拷贝 tcp_nopush 优化数据包发送 tcp_nodelay 禁用 Nagle 算法 文件 AIO（异步 I/O）处理大文件 6. 精心优化的数据结构 红黑树：定时器、location 匹配 单/双链表：缓冲区管理 哈希表：变量查找 基数树：IP 匹配 7. 模块化架构 只编译需要的模块 减少不必要的代码路径 5.3 Nginx 优化配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 # 综合性能优化配置 user www-data; worker_processes auto; # 自动匹配 CPU 核心数 worker_cpu_affinity auto; # 自动绑定 CPU worker_rlimit_nofile 100000; # 提高文件描述符限制 events { worker_connections 65535; # 每个Worker最大连接数 use epoll; # 使用 epoll multi_accept on; # 一次性接收所有新连接 accept_mutex off; # Worker多时关闭互斥锁 } http { sendfile on; # 零拷贝 tcp_nopush on; # 优化数据包发送 tcp_nodelay on; # 禁用 Nagle keepalive_timeout 65; keepalive_requests 100000; # 一个长连接最多处理多少请求 # 文件描述符缓存 open_file_cache max=10000 inactive=60s; open_file_cache_valid 90s; open_file_cache_min_uses 2; open_file_cache_errors on; # 连接优化 reset_timedout_connection on; # 超时连接直接 reset client_body_timeout 12; send_timeout 10; # 压缩 gzip on; gzip_comp_level 4; gzip_min_length 256; gzip_types text/plain text/css application/javascript application/json; # 输出缓冲 output_buffers 1 32k; postpone_output 1460; # 累积到 MSS 大小再发送 } 六、Nginx vs OpenResty vs Envoy 6.1 对比总览 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 特性 Nginx OpenResty Envoy ───────────────────────────────────────────────────────────── 基础 Nginx 核心 Nginx + LuaJIT C++ 自研 语言 C C + Lua C++ 配置 静态配置文件 静态 + Lua 动态 xDS 动态 API 可编程性 模块（C） Lua 脚本 WASM / Lua / C++ 动态配置 需 reload 运行时动态 实时推送 负载均衡 基础策略丰富 同 Nginx + Lua 高级（区域感知、加权等） 可观测性 基础日志 Lua 扩展 内置（Metrics、Tracing、Logging） 服务发现 手动配置 Lua 脚本 原生支持（DNS、xDS、EDS） gRPC 支持 基础 基础 原生（双向流） HTTP/3 实验性 实验性 支持 管理 API 信号（reload） Lua API REST gRPC API 定位 Web/代理服务器 可编程 Web 平台 服务网格代理 诞生年份 2004 2011 2016 6.2 OpenResty 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 OpenResty = Nginx + LuaJIT + 大量 Lua 库 核心特点： 在 Nginx 中嵌入 Lua 虚拟机 可以用 Lua 脚本处理请求（不限于静态配置） 几乎可以访问 Nginx 所有内部 API 典型用途： - API 网关（Kong 基于 OpenResty） - WAF（ModSecurity 替代） - 动态路由 - 限流、认证（复杂逻辑） - 缓存（Redis + Lua） 示例： location /api { content_by_lua_block { local redis = require \u0026#34;resty.redis\u0026#34; local red = redis:new() red:set_timeouts(1000, 1000, 1000) local ok, err = red:connect(\u0026#34;127.0.0.1\u0026#34;, 6379) local res, err = red:get(\u0026#34;cache:\u0026#34; .. ngx.var.uri) if res then ngx.say(res) return end -- 缓存未命中，请求后端 local res = ngx.location.capture(\u0026#34;/backend\u0026#34; .. ngx.var.uri) red:setex(\u0026#34;cache:\u0026#34; .. ngx.var.uri, 300, res.body) ngx.say(res.body) } } 适用场景： 需要动态逻辑、API 网关、复杂认证 不想写 C 模块但需要超越静态配置 6.3 Envoy 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 Envoy = Lyft 开源的 L4/L7 代理，CNCF 项目 核心特点： 面向微服务和云原生设计 动态配置 API（xDS 协议） 内置可观测性（Metrics、Tracing、Logging） Istio 的数据平面 优势： - 动态配置：通过 xDS API 实时更新，不需要 reload - 服务发现：原生支持 DNS、EDS、Consul 等 - 高级负载均衡：区域感知、故障注入、灰度发布 - 可观测性：原生 Prometheus 指标、分布式追踪 - gRPC 一等公民 - WASM 扩展 xDS 协议： LDS（Listener） — 监听器配置 RDS（Route） — 路由配置 CDS（Cluster） — 集群（upstream）配置 EDS（Endpoint） — 集群成员（服务发现） SDS（Secret） — 证书配置 适用场景： 微服务架构、服务网格 Kubernetes Ingress / Gateway 需要动态配置和高级可观测性的场景 6.4 如何选择 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 选 Nginx： ✓ 传统 Web 服务、反向代理、负载均衡 ✓ 静态文件服务 ✓ 配置变更不频繁 ✓ 团队熟悉 Nginx ✓ 社区最成熟，资料最多 选 OpenResty： ✓ 需要动态逻辑（API 网关、WAF） ✓ 想用脚本扩展 Nginx ✓ 已有 Nginx 基础设施 ✓ 使用 Kong API 网关 选 Envoy： ✓ 微服务架构 / 服务网格 ✓ 需要动态配置（Kubernetes 环境） ✓ Istio 服务网格 ✓ 需要高级可观测性 ✓ gRPC 为主的服务 很多公司的架构： 边缘层：Nginx（HTTPS 终端、静态资源、基础代理） 内部层：Envoy（微服务间通信、服务网格） 七、小结 本文深入了 Nginx 的底层原理：\n进程模型：Master 管理 Worker，Worker 独立处理请求，崩溃不扩散 事件驱动：epoll 实现高效 I/O 多路复用，单线程处理万级并发 请求处理：11 个阶段按顺序执行，模块注册到特定阶段 内存管理：内存池批量分配/释放，slab 管理共享内存 为什么快：事件驱动 + 零拷贝 + 内存池 + 无锁 + 高效数据结构 技术选型：Nginx（传统 Web）、OpenResty（可编程代理）、Envoy（云原生/服务网格） 系列总结 四篇 Nginx 学习笔记到此结束：\n基础与核心配置 — 安装、配置结构、静态文件、虚拟主机、location 反向代理与负载均衡 — 代理配置、负载策略、健康检查、四层/七层 HTTPS、性能优化与实战 — 证书、压缩、缓存、限流、.NET 部署 深入原理 — 进程模型、epoll、11 阶段、内存管理、技术对比 Nginx 的学习曲线：配置入门不难，但深入理解原理需要时间。建议边学边练，在自己的服务器上实际配置和调优。\n","date":"2025-03-29T10:00:00+08:00","permalink":"/posts/middleware/nginx/04-nginx-internals/","title":"Nginx 学习笔记（四）：深入原理"},{"content":"写在前面 本文是 Nginx 学习笔记系列的第三篇，聚焦生产环境实战：HTTPS 证书配置、性能优化技巧（压缩、缓存、限流、跨域），以及 .NET 应用部署和常见问题排查。\n一、HTTPS 配置 1.1 为什么需要 HTTPS 1 2 3 4 5 6 7 8 9 10 HTTP 的问题： - 明文传输 — 数据可被窃听 - 不验证身份 — 可能被冒充（钓鱼） - 数据可被篡改 — 运营商插广告、劫持 HTTPS = HTTP + TLS - 加密传输 — 数据被加密，无法窃听 - 身份验证 — 证书验证服务器身份 - 完整性校验 — 数据被篡改可检测 - SEO 加分 — Google 优先收录 HTTPS 1.2 申请免费证书（Let\u0026rsquo;s Encrypt） 1 2 3 4 5 6 7 8 9 10 11 # 安装 Certbot sudo apt install certbot python3-certbot-nginx # 自动获取并配置证书（一条命令搞定） sudo certbot --nginx -d example.com -d www.example.com # 仅获取证书（手动配置） sudo certbot certonly --nginx -d example.com # 手动获取（DNS 验证，适合泛域名） sudo certbot certonly --manual --preferred-challenges dns -d \u0026#34;*.example.com\u0026#34; -d example.com 1 2 3 4 5 6 7 8 证书文件位置： /etc/letsencrypt/live/example.com/fullchain.pem — 证书（含中间证书） /etc/letsencrypt/live/example.com/privkey.pem — 私钥 证书有效期：90 天 自动续期：Certbot 安装时会自动创建定时任务 查看：systemctl list-timers | grep certbot 手动续期：sudo certbot renew --dry-run 1.3 自动续期配置 1 2 3 4 5 6 7 8 9 10 # 检查自动续期定时任务 systemctl status certbot.timer # 手动测试续期（不会真的续） sudo certbot renew --dry-run # 续期后自动重载 Nginx（配置 hook） # /etc/letsencrypt/renewal-hooks/post/reload-nginx.sh #!/bin/bash nginx -t \u0026amp;\u0026amp; systemctl reload nginx 1.4 手动配置 HTTPS 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 server { # HTTP → HTTPS 重定向 listen 80; server_name example.com www.example.com; return 301 https://www.example.com$request_uri; } server { listen 443 ssl http2; # 开启 HTTP/2 server_name www.example.com; # 证书配置 ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # SSL 参数 ssl_protocols TLSv1.2 TLSv1.3; # 只允许 TLS 1.2 和 1.3 ssl_ciphers HIGH:!aNULL:!MD5; # 高强度加密套件 ssl_prefer_server_ciphers on; # 优先使用服务器端的加密套件 # SSL 会话缓存 ssl_session_cache shared:SSL:10m; # 10MB 缓存，约 4万个会话 ssl_session_timeout 10m; # 会话超时 10 分钟 ssl_session_tickets off; # 禁用 Session Tickets（前向安全） # OCSP Stapling（在线证书状态检查） ssl_stapling on; ssl_stapling_verify on; resolver 8.8.8.8 8.8.4.4 valid=300s; resolver_timeout 5s; # HSTS（告诉浏览器只能用 HTTPS 访问） add_header Strict-Transport-Security \u0026#34;max-age=31536000; includeSubDomains\u0026#34; always; # 安全头部 add_header X-Frame-Options \u0026#34;SAMEORIGIN\u0026#34; always; add_header X-Content-Type-Options \u0026#34;nosniff\u0026#34; always; add_header X-XSS-Protection \u0026#34;1; mode=block\u0026#34; always; add_header Referrer-Policy \u0026#34;strict-origin-when-cross-origin\u0026#34; always; root /var/www/html; index index.html; location / { try_files $uri $uri/ =404; } } 1.5 SSL 安全等级测试 1 2 3 4 5 6 7 8 9 10 11 12 # 用 SSL Labs 测试（在线） # https://www.ssllabs.com/ssltest/ # 用 openssl 测试连接 openssl s_client -connect example.com:443 -servername example.com # 测试支持的协议 openssl s_client -connect example.com:443 -tls1_2 openssl s_client -connect example.com:443 -tls1_3 # 查看证书信息 openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -text -noout 1.6 自签名证书（开发环境） 1 2 3 4 5 6 # 生成自签名证书 openssl req -x509 -nodes -days 365 \\ -newkey rsa:2048 \\ -keyout selfsigned.key \\ -out selfsigned.crt \\ -subj \u0026#34;/CN=localhost\u0026#34; 1 2 3 4 5 6 7 server { listen 443 ssl; server_name localhost; ssl_certificate /etc/nginx/ssl/selfsigned.crt; ssl_certificate_key /etc/nginx/ssl/selfsigned.key; } 二、性能优化 2.1 gzip 压缩 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 http { # 开启 gzip gzip on; gzip_vary on; # 添加 Vary: Accept-Encoding 头 gzip_proxied any; # 代理请求也压缩 gzip_comp_level 4; # 压缩级别 1-9（4 是最佳平衡点） gzip_min_length 256; # 小于 256 字节不压缩 gzip_buffers 16 8k; # 压缩缓冲区 gzip_http_version 1.1; # 最低 HTTP 版本 gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml application/rss+xml application/atom+xml image/svg+xml font/opentype font/ttf font/woff2 application/wasm; } 1 2 3 4 5 6 7 8 9 压缩效果对比： 未压缩 HTML（50KB）→ gzip 后（~8KB） 减少 84% 未压缩 CSS（100KB）→ gzip 后（~15KB） 减少 85% 未压缩 JS（200KB） → gzip 后（~50KB） 减少 75% 注意： - 图片（jpg/png/gif）和视频已经压缩过，不要再 gzip - gzip_comp_level 不是越高越好，6 以上 CPU 开销增大但压缩收益递减 - 推荐值 4-6 2.2 Brotli 压缩（比 gzip 更好） 1 2 3 4 5 6 7 8 9 10 11 12 13 # 需要安装 ngx_brotli 模块 # Docker 可用 nginx:1.26-alpine 自行编译或使用 openresty http { brotli on; brotli_comp_level 6; brotli_types text/plain text/css application/javascript application/json; # 同时支持 gzip 和 brotli # Nginx 会根据客户端 Accept-Encoding 自动选择 gzip on; gzip_comp_level 4; } 1 2 3 4 5 Brotli vs gzip： Brotli 通常比 gzip 多压缩 15-25% 但压缩速度比 gzip 慢（CPU 开销更大） 大多数现代浏览器都支持 建议静态资源预压缩用 Brotli，动态内容用 gzip 2.3 浏览器缓存 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 server { root /var/www/html; # 静态资源 — 长期缓存 location ~* \\.(css|js)$ { expires 1y; # 1 年 add_header Cache-Control \u0026#34;public, immutable\u0026#34;; # immutable：告诉浏览器不需要重新验证 } # 图片 — 中期缓存 location ~* \\.(jpg|jpeg|png|gif|ico|svg|webp|avif)$ { expires 6M; # 6 个月 add_header Cache-Control \u0026#34;public\u0026#34;; } # 字体 — 长期缓存 location ~* \\.(woff|woff2|ttf|otf|eot)$ { expires 1y; add_header Cache-Control \u0026#34;public, immutable\u0026#34;; } # HTML — 不缓存或短缓存 location ~* \\.html$ { add_header Cache-Control \u0026#34;no-cache\u0026#34;; # no-cache：每次都验证（304 Not Modified） } } 1 2 3 4 5 6 7 8 9 10 11 缓存策略总结： 文件类型 策略 时间 ────────────────────────────────────────── HTML no-cache 每次验证 CSS/JS（带hash） immutable 1 年 图片 public 6 个月 字体 immutable 1 年 API 响应 no-store 不缓存 带 hash 的文件名（app.3a4b5c.js）→ 可以永久缓存 不带 hash 的文件名（app.js） → 不能长期缓存 2.4 限流 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 http { # 基于 IP 的请求速率限制 # binary_remote_addr：二进制格式的 IP，占用更少内存 # zone=api_limit:10m：共享内存区域，10MB（约 16万个 IP） # rate=100r/s：每个 IP 每秒 100 个请求 limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/s; # 基于IP的并发连接数限制 limit_conn_zone $binary_remote_addr zone=conn_limit:10m; server { # 请求速率限制 location /api/ { limit_req zone=api_limit burst=50 nodelay; # burst=50：允许短时突发 50 个请求 # nodelay：突发请求不延迟处理 proxy_pass http://backend; } # 并发连接限制 location /download/ { limit_conn conn_limit 10; # 每个 IP 最多 10 个并发连接 limit_rate 500k; # 每个连接限速 500KB/s } # 限流后的自定义响应 limit_req_status 429; # 返回 429 Too Many Requests limit_conn_status 429; } } 1 2 3 4 5 6 7 8 9 10 11 限流参数说明： rate=10r/s — 每秒 10 个请求 rate=600r/m — 每分钟 600 个请求（更适合 API） burst=N — 允许排队的请求数 nodelay — 突发请求立即处理（不延迟） 不加 burst： 超过 rate 立即返回 429 加 burst + nodelay： 允许瞬时突发，但总速率不超过 rate 2.5 跨域（CORS）配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 server { # CORS 配置 location /api/ { # 允许的源 add_header Access-Control-Allow-Origin \u0026#34;https://www.example.com\u0026#34; always; # 允许的方法 add_header Access-Control-Allow-Methods \u0026#34;GET, POST, PUT, DELETE, OPTIONS\u0026#34; always; # 允许的头部 add_header Access-Control-Allow-Headers \u0026#34;Authorization, Content-Type, X-Requested-With\u0026#34; always; # 允许携带凭证（Cookie） add_header Access-Control-Allow-Credentials \u0026#34;true\u0026#34; always; # 预检请求缓存时间 add_header Access-Control-Max-Age 3600 always; # 处理 OPTIONS 预检请求 if ($request_method = \u0026#39;OPTIONS\u0026#39;) { add_header Access-Control-Allow-Origin \u0026#34;https://www.example.com\u0026#34; always; add_header Access-Control-Allow-Methods \u0026#34;GET, POST, PUT, DELETE, OPTIONS\u0026#34; always; add_header Access-Control-Allow-Headers \u0026#34;Authorization, Content-Type\u0026#34; always; add_header Access-Control-Max-Age 3600 always; add_header Content-Length 0; add_header Content-Type text/plain; return 204; } proxy_pass http://backend; } } 1 2 3 4 5 6 7 8 9 CORS 要点： Access-Control-Allow-Origin： - 只能设置一个源，不能设置多个 - 动态设置可以用 $http_origin 变量（但需维护白名单） 预检请求（OPTIONS）： - 浏览器自动发送 - 必须正确响应，否则实际请求不会发出 - 非简单请求（带自定义头、PUT/DELETE 等）会触发预检 2.6 动态 CORS（多域名白名单） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 # 定义允许的域名 map $http_origin $cors_origin { default \u0026#34;\u0026#34;; \u0026#34;https://www.example.com\u0026#34; \u0026#34;https://www.example.com\u0026#34;; \u0026#34;https://app.example.com\u0026#34; \u0026#34;https://app.example.com\u0026#34;; \u0026#34;https://admin.example.com\u0026#34; \u0026#34;https://admin.example.com\u0026#34;; \u0026#34;http://localhost:3000\u0026#34; \u0026#34;http://localhost:3000\u0026#34;; } server { location /api/ { # 动态设置 CORS add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Methods \u0026#34;GET, POST, PUT, DELETE, OPTIONS\u0026#34; always; add_header Access-Control-Allow-Headers \u0026#34;Authorization, Content-Type\u0026#34; always; if ($cors_origin = \u0026#34;\u0026#34;) { return 403; # 不在白名单的源直接拒绝 } if ($request_method = \u0026#39;OPTIONS\u0026#39;) { add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Methods \u0026#34;GET, POST, PUT, DELETE, OPTIONS\u0026#34; always; add_header Access-Control-Max-Age 3600 always; return 204; } proxy_pass http://backend; } } 三、.NET 应用部署实战 3.1 部署架构 1 2 3 4 5 6 7 8 9 10 11 12 13 客户端 → Nginx（443/80）→ .NET 应用（5000） Nginx 负责： ✓ HTTPS 终端（.NET 应用不需要处理 TLS） ✓ 静态文件服务（wwwroot） ✓ 压缩、缓存 ✓ 限流、安全 ✓ 负载均衡（多实例） .NET 应用负责： ✓ API 处理 ✓ 业务逻辑 ✓ SSR（Blazor Server / Razor Pages） 3.2 发布 .NET 应用 1 2 3 4 5 6 7 8 # 发布应用 dotnet publish -c Release -o /app/publish # 运行 cd /app/publish ./MyApp --urls http://0.0.0.0:5000 # 推荐用 systemd 管理 3.3 systemd 服务配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # /etc/systemd/system/myapp.service [Unit] Description=My .NET Application After=network.target [Service] Type=notify WorkingDirectory=/app/publish ExecStart=/app/publish/MyApp --urls http://0.0.0.0:5000 Restart=always RestartSec=10 SyslogIdentifier=myapp User=www-data Environment=ASPNETCORE_ENVIRONMENT=Production Environment=ASPNETCORE_URLS=http://0.0.0.0:5000 Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false [Install] WantedBy=multi-user.target 1 2 3 4 5 6 7 8 9 10 # 启用并启动 sudo systemctl daemon-reload sudo systemctl enable myapp sudo systemctl start myapp # 查看状态 sudo systemctl status myapp # 查看日志 sudo journalctl -u myapp -f 3.4 Nginx 配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 # /etc/nginx/conf.d/myapp.conf # HTTP → HTTPS 重定向 server { listen 80; server_name api.example.com; return 301 https://$host$request_uri; } # HTTPS server { listen 443 ssl http2; server_name api.example.com; # SSL 证书 ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem; # SSL 参数 include /etc/nginx/snippets/ssl-params.conf; # 安全头部 add_header X-Frame-Options \u0026#34;SAMEORIGIN\u0026#34; always; add_header X-Content-Type-Options \u0026#34;nosniff\u0026#34; always; add_header Strict-Transport-Security \u0026#34;max-age=31536000\u0026#34; always; # 请求体大小 client_max_body_size 50m; # API 请求转发到 .NET location /api/ { proxy_pass http://127.0.0.1:5000; proxy_http_version 1.1; proxy_set_header Connection \u0026#34;\u0026#34;; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 5s; proxy_send_timeout 30s; proxy_read_timeout 30s; } # Swagger（仅开发/测试环境） location /swagger/ { proxy_pass http://127.0.0.1:5000/swagger/; } # 健康检查 location /health { access_log off; proxy_pass http://127.0.0.1:5000/health; } } 3.5 ASP.NET Core 配合 Nginx 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // Program.cs var builder = WebApplication.CreateBuilder(args); // 配置转发头（必须在使用路径基之前） builder.Services.Configure\u0026lt;ForwardedHeadersOptions\u0026gt;(options =\u0026gt; { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.KnownNetworks.Clear(); options.KnownProxies.Clear(); }); var app = builder.Build(); // 必须在其他中间件之前调用 app.UseForwardedHeaders(); // 其他中间件 app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run(); 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // appsettings.json { \u0026#34;Urls\u0026#34;: \u0026#34;http://0.0.0.0:5000\u0026#34;, \u0026#34;Kestrel\u0026#34;: { \u0026#34;Endpoints\u0026#34;: { \u0026#34;Http\u0026#34;: { \u0026#34;Url\u0026#34;: \u0026#34;http://0.0.0.0:5000\u0026#34; } }, \u0026#34;Limits\u0026#34;: { \u0026#34;MaxRequestBodySize\u0026#34;: 52428800 } } } 1 2 3 4 关键配置： UseForwardedHeaders() — 让 ASP.NET Core 读取 X-Forwarded-* 头 KnownNetworks.Clear() — 信任所有代理（按需限制） Kestrel 只监听 HTTP — HTTPS 由 Nginx 处理 3.6 Docker Compose 部署 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 version: \u0026#39;3.8\u0026#39; services: myapp: image: myapp:latest container_name: myapp environment: - ASPNETCORE_ENVIRONMENT=Production - ASPNETCORE_URLS=http://0.0.0.0:5000 expose: - \u0026#34;5000\u0026#34; restart: always networks: - internal nginx: image: nginx:1.26 container_name: nginx ports: - \u0026#34;80:80\u0026#34; - \u0026#34;443:443\u0026#34; volumes: - ./nginx/conf:/etc/nginx - ./nginx/ssl:/etc/nginx/ssl - ./nginx/logs:/var/log/nginx depends_on: - myapp restart: always networks: - internal networks: internal: internal: true # 内部网络，不对外暴露 1 2 3 4 5 好处： - myapp 只在内部网络，外部无法直接访问 - Nginx 是唯一的入口 - 升级 myapp 不影响 Nginx - 可以轻松扩展 myapp 实例 四、常见问题排查 4.1 502 Bad Gateway 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 原因：Nginx 无法连接到后端服务 排查步骤： 1. 检查后端是否运行 systemctl status myapp curl http://127.0.0.1:5000/health 2. 检查端口是否正确 ss -tlnp | grep 5000 3. 检查防火墙 sudo iptables -L -n 4. 查看 Nginx 错误日志 tail -f /var/log/nginx/error.log # 常见错误： # connect() failed (111: Connection refused) # connect() failed (113: No route to host) 4.2 504 Gateway Timeout 1 2 3 4 5 6 7 8 原因：后端响应超时 解决： # 增加超时时间 proxy_read_timeout 120s; # 默认 60s，慢接口可以增大 proxy_connect_timeout 10s; # 或者优化后端性能 4.3 413 Request Entity Too Large 1 2 3 4 原因：请求体超过限制 解决： client_max_body_size 50m; # 默认 1m，按需调大 4.4 配置不生效 1 2 3 4 5 6 7 8 9 10 11 12 # 检查配置语法 nginx -t # 重新加载配置 nginx -s reload # 检查是否加载了正确的配置文件 nginx -T | grep \u0026#34;configuration file\u0026#34; # 确认 include 路径是否正确 # 检查是否有缓存 curl -H \u0026#34;Cache-Control: no-cache\u0026#34; http://localhost 4.5 权限问题 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 常见错误：403 Forbidden 排查： 1. 检查文件权限 ls -la /var/www/html/ # Nginx 用户（www-data）需要读取权限 2. 检查 SELinux（CentOS/RHEL） getenforce # 如果是 Enforcing，需要设置上下文 chcon -R -t httpd_sys_content_t /var/www/html/ 3. 检查 user 指令 # nginx.conf 中的 user 是否有权限访问文件 user www-data; 4.6 性能排查 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 # 查看 Nginx 状态（需要 stub_status 模块） location /nginx_status { stub_status; access_log off; allow 127.0.0.1; deny all; } # 访问状态 curl http://localhost/nginx_status # Active connections: 5 # server accepts handled requests # 10 10 100 # Reading: 0 Writing: 1 Waiting: 4 # 分析访问日志（慢请求） awk \u0026#39;$NF \u0026gt; 1 {print $0}\u0026#39; /var/log/nginx/access.log | sort -kNF -rn | head -20 # 查看 Nginx 进程状态 ps aux | grep nginx top -p $(pgrep nginx | tr \u0026#39;\\n\u0026#39; \u0026#39;,\u0026#39;) 五、生产环境检查清单 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 # ✅ 一个完整的生产级配置模板 server { listen 443 ssl http2; server_name example.com; # --- SSL --- ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; ssl_stapling on; ssl_stapling_verify on; # --- 安全头部 --- add_header Strict-Transport-Security \u0026#34;max-age=31536000; includeSubDomains\u0026#34; always; add_header X-Frame-Options \u0026#34;SAMEORIGIN\u0026#34; always; add_header X-Content-Type-Options \u0026#34;nosniff\u0026#34; always; add_header X-XSS-Protection \u0026#34;1; mode=block\u0026#34; always; add_header Referrer-Policy \u0026#34;strict-origin-when-cross-origin\u0026#34; always; # --- 性能 --- gzip on; gzip_vary on; gzip_min_length 256; gzip_comp_level 4; gzip_types text/plain text/css application/javascript application/json; # --- 安全 --- client_max_body_size 50m; server_tokens off; # 隐藏 Nginx 版本号 # --- 代理 --- location / { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection \u0026#34;\u0026#34;; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } # HTTP → HTTPS server { listen 80; server_name example.com; return 301 https://$host$request_uri; } 六、小结 本文学习了 Nginx 生产环境实战：\nHTTPS 配置（Let\u0026rsquo;s Encrypt、手动配置、安全参数） SSL 安全优化（HSTS、OCSP Stapling、安全头部） 性能优化（gzip、Brotli、浏览器缓存、限流） CORS 跨域配置（静态和动态白名单） .NET 应用部署（systemd、Docker Compose） ASP.NET Core 配合 Nginx（ForwardedHeaders） 常见问题排查（502、504、413、权限、性能） 生产环境配置检查清单 下一篇将深入 Nginx 的底层原理：进程模型、事件驱动机制、请求处理流程和内存管理。\n","date":"2025-03-25T10:00:00+08:00","permalink":"/posts/middleware/nginx/03-nginx-https-performance/","title":"Nginx 学习笔记（三）：HTTPS、性能优化与实战"},{"content":"写在前面 本文是 Nginx 学习笔记系列的第二篇，深入讲解反向代理的原理和配置、负载均衡策略、upstream 健康检查，以及四层和七层代理的区别。这是 Nginx 在生产环境最核心的用途。\n一、反向代理 1.1 正向代理 vs 反向代理 1 2 3 4 5 6 7 8 9 10 11 12 13 正向代理（代理客户端）： 客户端 → [代理服务器] → 目标服务器 客户端知道要访问谁，代理帮客户端转发 例：VPN、科学上网、公司出口代理 反向代理（代理服务端）： 客户端 → [代理服务器] → 后端服务器 客户端不知道真正提供服务的是谁 例：Nginx、CDN、API 网关 关键区别： 正向代理 — 客户端配置代理，服务端不知道真实客户端 反向代理 — 服务端躲在代理后面，客户端不知道真实服务端 1.2 反向代理能做什么 1 2 3 4 5 6 7 1. 隐藏后端服务器 — 客户端只看到 Nginx 的 IP 2. 负载均衡 — 请求分发到多台后端服务器 3. SSL 终端 — Nginx 处理 HTTPS，后端用 HTTP 4. 缓存 — 缓存后端响应，减少后端压力 5. 压缩 — Nginx 压缩响应，减少带宽 6. 安全 — 统一的访问控制、限流、WAF 7. 静态文件分离 — Nginx 处理静态，后端处理动态 1.3 基本配置 1 2 3 4 5 6 7 8 server { listen 80; server_name api.example.com; location / { proxy_pass http://192.168.1.100:5000; # 后端地址 } } 就这么简单，所有请求都会被转发到 http://192.168.1.100:5000。\n1.4 完整的反向代理配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 server { listen 80; server_name api.example.com; location / { proxy_pass http://192.168.1.100:5000; # 传递客户端真实信息 proxy_set_header Host $host; # 原始域名 proxy_set_header X-Real-IP $remote_addr; # 客户端真实 IP proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 代理链 proxy_set_header X-Forwarded-Proto $scheme; # 原始协议（http/https） # 超时设置 proxy_connect_timeout 10s; # 连接后端超时 proxy_send_timeout 60s; # 发送请求超时 proxy_read_timeout 60s; # 读取响应超时 # 缓冲设置 proxy_buffering on; # 开启缓冲（默认开启） proxy_buffer_size 4k; # 响应头缓冲区 proxy_buffers 8 16k; # 响应体缓冲区（8 个 16KB） # 错误处理 proxy_intercept_errors on; # 拦截后端错误响应 proxy_next_upstream error timeout http_502 http_503 http_504; # 出错时尝试下一个后端 } } 1.5 proxy_pass 的路径规则 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # 情况一：proxy_pass 带 URI（带 / 或路径） location /api/ { proxy_pass http://backend/v1/; # /api/users → http://backend/v1/users # /api/ 被替换为 /v1/ } # 情况二：proxy_pass 不带 URI（没有路径） location /api/ { proxy_pass http://backend; # /api/users → http://backend/api/users # /api/ 前缀被保留 } # 情况三：精确替换 location /old/ { proxy_pass http://backend/new/; # /old/users → http://backend/new/users } 1 2 3 4 5 ⚠️ 带不带尾斜线的区别： proxy_pass http://backend; → 保留 location 前缀 proxy_pass http://backend/; → 替换 location 前缀 这是 Nginx 最容易踩的坑之一，务必记住。 1.6 WebSocket 代理 1 2 3 4 5 6 7 8 9 10 11 12 location /ws/ { proxy_pass http://backend; # WebSocket 必需的头部 proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \u0026#34;upgrade\u0026#34;; # 超时设置（WebSocket 长连接） proxy_read_timeout 3600s; # 1 小时 proxy_send_timeout 3600s; } 二、负载均衡 2.1 upstream 基本配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # 定义后端服务器组 upstream backend { server 192.168.1.101:5000; server 192.168.1.102:5000; server 192.168.1.103:5000; } server { listen 80; server_name api.example.com; location / { proxy_pass http://backend; # 引用 upstream } } 2.2 负载均衡策略 轮询（Round Robin，默认） 1 2 3 4 5 6 upstream backend { server 192.168.1.101:5000; server 192.168.1.102:5000; server 192.168.1.103:5000; # 请求依次分配：101 → 102 → 103 → 101 → ... } 加权轮询（Weight） 1 2 3 4 5 6 upstream backend { server 192.168.1.101:5000 weight=5; # 5/8 的请求 server 192.168.1.102:5000 weight=2; # 2/8 的请求 server 192.168.1.103:5000 weight=1; # 1/8 的请求 # 适合服务器配置不同的场景 } 1 2 3 4 权重分配示例（总权重 8）： 101（性能好）→ 62.5% 请求 102（普通） → 25% 请求 103（低配） → 12.5% 请求 IP 哈希（ip_hash） 1 2 3 4 5 6 upstream backend { ip_hash; # 基于客户端 IP 做哈希 server 192.168.1.101:5000; server 192.168.1.102:5000; server 192.168.1.103:5000; } 1 2 3 4 5 6 7 特点： 同一客户端 IP 的请求总是打到同一台后端 适合需要会话保持（Session）的场景 缺点： - 后端服务器变化时，大量会话重新分配 - 大量请求来自同一 IP（如公司出口 NAT）会导致负载不均 最少连接（least_conn） 1 2 3 4 5 6 upstream backend { least_conn; # 分配给当前连接数最少的服务器 server 192.168.1.101:5000; server 192.168.1.102:5000; server 192.168.1.103:5000; } 1 2 3 适合场景： 请求处理时间差异大（有的快有的慢） 避免某些服务器积压大量慢请求 一致性哈希（hash） 1 2 3 4 5 6 upstream backend { hash $request_uri consistent; # 基于请求 URI 做一致性哈希 server 192.168.1.101:5000; server 192.168.1.102:5000; server 192.168.1.103:5000; } 1 2 3 4 特点： 同一 URI 总是路由到同一台后端（提高缓存命中率） consistent 参数：添加/删除节点时只影响相邻节点 可以基于 $uri、$arg_user_id 等任何变量 随机（random） 1 2 3 4 5 6 upstream backend { random two least_conn; # 随机选 2 个，挑连接少的 server 192.168.1.101:5000; server 192.168.1.102:5000; server 192.168.1.103:5000; } 2.3 策略选择总结 1 2 3 4 5 6 7 8 场景 推荐策略 ────────────────────────────────────────────────── 一般 Web 应用 轮询（默认） 服务器性能不均 加权轮询 需要会话保持 ip_hash / hash $cookie_sessionid 请求耗时差异大 least_conn 缓存命中率优先 hash $request_uri consistent 分布式文件/缓存 一致性哈希 三、upstream 服务器状态 1 2 3 4 5 6 7 8 9 10 11 12 13 upstream backend { server 192.168.1.101:5000 weight=5; server 192.168.1.102:5000 weight=3; server 192.168.1.103:5000 backup; # 备用服务器，其他全挂才启用 server 192.168.1.104:5000 down; # 标记为下线，不参与负载 server 192.168.1.105:5000 max_fails=3 fail_timeout=30s; # max_fails=3：30s 内失败 3 次认为不可用 # fail_timeout=30s：不可用后 30s 后再尝试 } 1 2 3 4 5 6 7 8 参数说明： weight=N — 权重（默认 1） max_conns=N — 最大并发连接数（默认 0，不限制） max_fails=N — 最大失败次数（默认 1） fail_timeout=T — 失败超时时间（默认 10s） backup — 备份服务器 down — 标记下线 resolve — 动态解析域名（适合 Docker/K8s 环境） 3.1 动态解析域名 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 upstream backend { # resolve：当后端是域名时，Nginx 会定期重新解析 # 适合 Docker/K8s 环境，容器 IP 可能变化 server api-service:5000 resolve; } server { resolver 127.0.0.11 valid=10s; # Docker 内部 DNS # 或者使用公共 DNS # resolver 8.8.8.8 8.8.4.4 valid=30s; location / { # 使用变量触发运行时解析（另一种方式） set $backend_host \u0026#34;api-service:5000\u0026#34;; proxy_pass http://$backend_host; } } 四、健康检查 4.1 被动健康检查（内置） Nginx 开源版只支持被动健康检查：在实际请求失败时才标记服务器不可用。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 upstream backend { server 192.168.1.101:5000 max_fails=3 fail_timeout=30s; server 192.168.1.102:5000 max_fails=3 fail_timeout=30s; proxy_next_upstream error timeout http_502 http_503 http_504; } server { location / { proxy_pass http://backend; proxy_next_upstream error timeout http_502 http_503 http_504; proxy_next_upstream_timeout 10s; # 重试总时间 proxy_next_upstream_tries 3; # 最多重试 3 次 } } 1 2 3 4 5 6 工作流程： 1. 请求到达 Nginx → 转发给 backend-101 2. 101 超时或返回 502/503/504 → 记录一次失败 3. 自动重试下一个服务器 → 转发给 backend-102 4. 101 失败 3 次（max_fails=3）→ 标记为不可用 5. 30s 后（fail_timeout=30s）→ 再次尝试 101 4.2 主动健康检查（Nginx Plus / 第三方模块） 开源版 Nginx 不支持主动健康检查，但有替代方案：\n方案一：用 location 模拟 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 # 健康检查端点 server { listen 80; # 简单的健康检查 location /health { access_log off; return 200 \u0026#34;healthy\\n\u0026#34;; } # 带后端检测的健康检查 location /health/backend { access_log off; proxy_pass http://backend/health; proxy_intercept_errors on; error_page 502 503 504 = /health/unhealthy; } location /health/unhealthy { return 503 \u0026#34;unhealthy\\n\u0026#34;; } } 方案二：用第三方模块 nginx_upstream_check_module 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # 需要编译时添加模块 upstream backend { server 192.168.1.101:5000; server 192.168.1.102:5000; # 主动健康检查（Tengine / 第三方模块） check interval=3000 rise=2 fall=3 timeout=1000 type=http; check_http_send \u0026#34;GET /health HTTP/1.0\\r\\n\\r\\n\u0026#34;; check_http_expect_alive http_2xx http_3xx; } # 查看健康状态 location /status { check_status; access_log off; } 方案三：外部健康检查脚本 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 #!/bin/bash # health-check.sh — 配合 crontab 使用 BACKENDS=(\u0026#34;192.168.1.101:5000\u0026#34; \u0026#34;192.168.1.102:5000\u0026#34;) NGINX_CONF=\u0026#34;/etc/nginx/conf.d/backends.conf\u0026#34; for backend in \u0026#34;${BACKENDS[@]}\u0026#34;; do host=$(echo $backend | cut -d: -f1) port=$(echo $backend | cut -d: -f2) if curl -sf -o /dev/null \u0026#34;http://$backend/health\u0026#34;; then echo \u0026#34;$backend: healthy\u0026#34; else echo \u0026#34;$backend: unhealthy\u0026#34; # 动态更新配置，标记 down sed -i \u0026#34;s/server $backend;/server $backend down;/\u0026#34; $NGINX_CONF nginx -s reload fi done 五、四层与七层代理 5.1 区别 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 OSI 七层模型： 7. 应用层 — HTTP、HTTPS、FTP、SMTP 6. 表示层 — SSL/TLS、JPEG 5. 会话层 — RPC 4. 传输层 — TCP、UDP 3. 网络层 — IP 2. 数据链路层 — 以太网 1. 物理层 — 网线 四层代理（L4）：工作在传输层（TCP/UDP） - 只看到 IP + 端口 - 不解析应用层协议 - 速度更快，开销更小 - 不支持基于路径/域名的路由 七层代理（L7）：工作在应用层（HTTP） - 能看到 HTTP 请求内容 - 支持基于域名、路径、头部、Cookie 的路由 - 支持 rewrite、缓存、压缩等 - 开销更大 5.2 四层代理配置（stream 模块） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 # 注意：stream 块和 http 块是平级的，都在 main 层级 # /etc/nginx/nginx.conf stream { # TCP 负载均衡 — MySQL upstream mysql { server 192.168.1.101:3306; server 192.168.1.102:3306; } server { listen 3306; proxy_pass mysql; proxy_connect_timeout 5s; proxy_timeout 300s; } # TCP 负载均衡 — Redis upstream redis { server 192.168.1.101:6379; server 192.168.1.102:6379; } server { listen 6379; proxy_pass redis; } # UDP 负载均衡 — DNS upstream dns { server 8.8.8.8:53; server 8.8.4.4:53; } server { listen 53 udp; proxy_pass dns; } } 1 2 3 4 5 6 四层代理的典型场景： - 数据库代理（MySQL、PostgreSQL） - Redis 代理 - 邮件代理（SMTP、IMAP） - 游戏服务器代理 - MQTT 代理 5.3 七层代理（http 模块，前面讲的反向代理） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 # 这就是我们一直在用的 proxy_pass http { upstream api { server 192.168.1.101:5000; server 192.168.1.102:5000; } server { listen 80; # 按路径路由 location /api/ { proxy_pass http://api/; } location /admin/ { proxy_pass http://admin-service/; } # 按请求头路由（灰度发布） location /api/ { if ($http_x_version = \u0026#34;v2\u0026#34;) { proxy_pass http://api-v2; } proxy_pass http://api-v1; } } } 5.4 四层 vs 七层选择 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 选择四层代理： ✓ 非 HTTP 协议（MySQL、Redis、MQTT） ✓ 追求极致性能（不需要解析应用层） ✓ 只需要简单的 TCP/UDP 转发 选择七层代理： ✓ HTTP/HTTPS 服务 ✓ 需要基于路径/域名/头部的路由 ✓ 需要 SSL 终端 ✓ 需要缓存、压缩、限流 常见架构：四层 + 七层组合 客户端 → L4 负载均衡（VIP）→ L7 Nginx 集群 → 后端服务 L4 做入口流量分发 L7 做精细路由和业务处理 六、实战：完整的代理配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 # /etc/nginx/conf.d/api.conf upstream api_servers { least_conn; server 10.0.1.101:5000 weight=3 max_fails=3 fail_timeout=30s; server 10.0.1.102:5000 weight=3 max_fails=3 fail_timeout=30s; server 10.0.1.103:5000 weight=2 max_fails=3 fail_timeout=30s; server 10.0.1.104:5000 backup; keepalive 32; # 保持与后端的长连接 } server { listen 80; server_name api.example.com; # 请求体大小限制 client_max_body_size 50m; location / { proxy_pass http://api_servers; # 传递真实信息 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 长连接 proxy_http_version 1.1; proxy_set_header Connection \u0026#34;\u0026#34;; # 超时 proxy_connect_timeout 5s; proxy_send_timeout 30s; proxy_read_timeout 30s; # 缓冲 proxy_buffering on; proxy_buffer_size 8k; proxy_buffers 8 32k; # 失败重试 proxy_next_upstream error timeout http_502 http_503 http_504; proxy_next_upstream_timeout 10s; proxy_next_upstream_tries 3; } # WebSocket location /ws/ { proxy_pass http://api_servers; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \u0026#34;upgrade\u0026#34;; proxy_set_header Host $host; proxy_read_timeout 3600s; } # 健康检查 location /health { access_log off; proxy_pass http://api_servers/health; } } 七、获取客户端真实 IP 多层代理后，如何获取客户端真实 IP？\n7.1 单层代理 1 2 3 4 5 6 7 8 # Nginx 代理层 proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 后端获取（以 ASP.NET Core 为例） var ip = HttpContext.Connection.RemoteIpAddress; // Nginx 的 IP var realIp = HttpContext.Request.Headers[\u0026#34;X-Real-IP\u0026#34;]; // 客户端真实 IP var forwarded = HttpContext.Request.Headers[\u0026#34;X-Forwarded-For\u0026#34;]; // 代理链 7.2 多层代理 1 2 3 4 客户端(1.1.1.1) → CDN(2.2.2.2) → Nginx(3.3.3.3) → 后端 X-Forwarded-For: 1.1.1.1, 2.2.2.2 X-Real-IP: 1.1.1.1 1 2 3 4 5 6 7 8 9 10 11 # 第一层 Nginx（或 CDN） proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 第二层 Nginx # 使用 set 指令获取最左侧的 IP（最原始的客户端） set_real_ip_from 10.0.0.0/8; # 信任的代理 IP 段 set_real_ip_from 172.16.0.0/12; real_ip_header X-Forwarded-For; # 从这个头部获取真实 IP real_ip_recursive on; # 递归排除信任的代理 IP # 现在 $remote_addr 就是客户端真实 IP 了 7.3 ASP.NET Core 配置 1 2 3 4 5 6 7 8 9 10 11 // Program.cs builder.Services.Configure\u0026lt;ForwardedHeadersOptions\u0026gt;(options =\u0026gt; { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.KnownNetworks.Clear(); // 信任所有代理（按需调整） options.KnownProxies.Clear(); }); // 在 UseHttpsRedirection 之前调用 app.UseForwardedHeaders(); 八、小结 本文学习了 Nginx 反向代理和负载均衡：\n正向代理 vs 反向代理的区别 反向代理配置（proxy_pass、proxy_set_header、超时、缓冲） proxy_pass 路径规则（带不带尾斜线的坑） WebSocket 代理 负载均衡策略（轮询、加权、ip_hash、least_conn、一致性哈希） upstream 服务器状态管理 被动/主动健康检查 四层代理（stream）vs 七层代理（http） 获取客户端真实 IP 下一篇将学习 HTTPS 配置、性能优化和 .NET 应用部署实战。\n","date":"2025-03-21T10:00:00+08:00","permalink":"/posts/middleware/nginx/02-nginx-proxy-lb/","title":"Nginx 学习笔记（二）：反向代理与负载均衡"},{"content":"写在前面 本文是 Nginx 学习笔记系列的第一篇，介绍 Nginx 的核心概念、安装方式、配置文件结构，以及静态文件托管和虚拟主机配置。基于 Nginx 1.26 稳定版。\n一、Nginx 是什么 1.1 定义 Nginx（engine-x）是一个高性能的 HTTP 和反向代理服务器，也是一个 IMAP/POP3/SMTP 代理服务器。\n1 2 3 4 5 6 7 核心能力： - Web 服务器 — 高性能静态文件服务 - 反向代理 — 转发请求到后端应用 - 负载均衡 — 分发流量到多台服务器 - 缓存 — 缓存后端响应，降低负载 - 流媒体 — MP4、FLV 流媒体支持 - 邮件代理 — IMAP/POP3/SMTP 代理（用得少） 1.2 为什么选择 Nginx 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 1. 高并发 单机支持数万并发连接（C10K 问题而设计） 官方基准：单机 5万+ 并发连接 2. 低资源 内存占用极小（相比 Apache） 1万空闲连接仅占约 2.5MB 内存 3. 高可靠性 Master/Worker 架构，Worker 崩溃不影响整体 支持热加载配置（不停机 reload） 4. 功能丰富 反向代理、负载均衡、缓存、限流、HTTPS 模块化设计，按需扩展 5. 生态成熟 全球 Top 1000 网站中超过 30% 使用 Nginx OpenResty、Tengine 等衍生版本 1.3 Nginx vs Apache 1 2 3 4 5 6 7 8 9 对比项 Nginx Apache ─────────────────────────────────────────────── 并发模型 事件驱动（epoll） 进程/线程驱动 内存占用 低（万级连接几MB） 高（每连接一线程） 静态文件 极快 快 动态内容 需要反向代理 原生支持（mod_php 等） 配置语法 简洁 复杂（.htaccess） 适用场景 高并发、反向代理 动态内容、.htaccess 市场份额 ~34%（持续增长） ~30%（缓慢下降） 二、安装 2.1 Linux 安装 1 2 3 4 5 6 7 8 9 10 # Ubuntu / Debian sudo apt update sudo apt install nginx # CentOS / RHEL sudo yum install nginx # 查看版本 nginx -v # nginx version: nginx/1.26.2 2.2 Docker 安装（推荐） 1 2 3 4 5 6 7 8 docker run -d \\ --name nginx \\ -p 80:80 \\ -p 443:443 \\ -v /opt/nginx/conf:/etc/nginx \\ -v /opt/nginx/html:/usr/share/nginx/html \\ -v /opt/nginx/logs:/var/log/nginx \\ nginx:1.26 1 2 3 4 5 6 7 目录映射说明： /etc/nginx — 配置文件目录 /usr/share/nginx/html — 默认网站根目录 /var/log/nginx — 日志目录 建议生产环境用 Docker Compose 管理： 方便升级、回滚、扩容 2.3 Windows 安装 1 2 3 4 5 6 # 方式一：winget winget install Nginx.Nginx # 方式二：下载解压 # https://nginx.org/en/download.html # 解压后运行 nginx.exe 即可 1 2 3 4 Windows 注意事项： - 仅适合开发测试，不推荐生产环境 - 性能远低于 Linux（IOCP 不如 epoll） - 不支持某些高级功能（如 sendfile 优化） 2.4 验证 1 2 3 4 5 6 7 8 9 10 11 # 检查配置文件语法 nginx -t # nginx: the configuration file /etc/nginx/nginx.conf syntax is ok # nginx: configuration file /etc/nginx/nginx.conf test is successful # 查看版本和编译参数 nginx -V # 快速测试 curl http://localhost # 返回 Welcome to nginx! 页面 三、常用命令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 # 启动 nginx # 直接启动 systemctl start nginx # systemd 方式 # 停止 nginx -s stop # 立即停止 nginx -s quit # 优雅停止（处理完当前请求再停） systemctl stop nginx # 重载配置（不停机，生产环境常用） nginx -s reload systemctl reload nginx # 重新打开日志文件（日志切割后使用） nginx -s reopen # 检查配置 nginx -t # 检查语法 nginx -T # 检查语法 + 打印完整配置 # 指定配置文件 nginx -c /etc/nginx/nginx.conf # 查看帮助 nginx -h 1 2 3 生产环境常用操作： 修改配置 → nginx -t 检查 → nginx -s reload 重载 永远不要用 stop + start，用 reload 实现零停机 四、配置文件结构 4.1 目录结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 /etc/nginx/ ├── nginx.conf # 主配置文件 ├── conf.d/ # 额外配置（推荐放在这里） │ └── *.conf # 每个站点一个文件 ├── sites-available/ # 可用站点（Debian/Ubuntu） │ └── default ├── sites-enabled/ # 已启用站点（符号链接） │ └── default -\u0026gt; ../sites-available/default ├── modules-enabled/ # 动态模块 ├── snippets/ # 可复用的配置片段 │ ├── ssl-params.conf │ └── proxy-params.conf ├── mime.types # MIME 类型映射 ├── fastcgi_params # FastCGI 参数 ├── scgi_params # SCGI 参数 └── uwsgi_params # uWSGI 参数 4.2 主配置文件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 # /etc/nginx/nginx.conf # 全局块 — 影响 Nginx 整体 user www-data; # 运行用户 worker_processes auto; # Worker 进程数（auto = CPU 核心数） error_log /var/log/nginx/error.log; # 错误日志 pid /run/nginx.pid; # PID 文件 # events 块 — 影响网络连接 events { worker_connections 1024; # 每个 Worker 最大连接数 # 总最大并发 = worker_processes × worker_connections # 4核 × 1024 = 4096 并发连接 } # http 块 — HTTP 服务器配置 http { include mime.types; # MIME 类型映射 default_type application/octet-stream; # 日志格式 log_format main \u0026#39;$remote_addr - $remote_user [$time_local] \u0026#39; \u0026#39;\u0026#34;$request\u0026#34; $status $body_bytes_sent \u0026#39; \u0026#39;\u0026#34;$http_referer\u0026#34; \u0026#34;$http_user_agent\u0026#34;\u0026#39;; access_log /var/log/nginx/access.log main; sendfile on; # 零拷贝发送文件 tcp_nopush on; # 优化数据包发送 tcp_nodelay on; # 禁用 Nagle 算法 keepalive_timeout 65; # 长连接超时 types_hash_max_size 2048; # gzip 压缩 gzip on; gzip_types text/plain text/css application/json; # 包含其他配置文件 include /etc/nginx/conf.d/*.conf; include /etc/nginx/sites-enabled/*; } 4.3 配置层级关系 1 2 3 4 5 6 7 8 9 10 11 12 13 14 nginx.conf ├── 全局块（main） │ ├── user │ ├── worker_processes │ └── error_log ├── events 块 │ └── worker_connections └── http 块 ├── 全局 HTTP 配置 ├── upstream 块（负载均衡组） └── server 块（虚拟主机） ├── 全局 server 配置 └── location 块（URL 路由） └── 具体处理规则 1 2 3 4 记忆口诀： 从外到内：main → events → http → server → location 嵌套关系：http 包含 server，server 包含 location 配置继承：内层可以覆盖外层的配置 五、静态文件托管 5.1 基本静态网站 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 server { listen 80; # 监听端口 server_name example.com; # 域名 root /var/www/html; # 网站根目录 index index.html index.htm; # 默认首页 location / { try_files $uri $uri/ =404; # 尝试找文件，找不到返回 404 } # 静态文件缓存 location ~* \\.(jpg|jpeg|png|gif|ico|css|js|svg|woff2)$ { expires 30d; # 浏览器缓存 30 天 add_header Cache-Control \u0026#34;public, immutable\u0026#34;; } # 禁止访问隐藏文件 location ~ /\\. { deny all; } } 5.2 try_files 详解 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # 按顺序查找文件 location / { try_files $uri $uri/ /index.html; # 1. 查找请求的文件（$uri） # 2. 查找请求的目录（$uri/） # 3. 都找不到则返回 /index.html（SPA 应用常用） } # 自定义错误页面 location / { try_files $uri $uri/ @fallback; } location @fallback { proxy_pass http://backend; } 1 2 3 4 SPA 应用（Vue/React）的典型配置： try_files $uri $uri/ /index.html; 所有未匹配的路径都返回 index.html 让前端路由（history 模式）来处理 5.3 目录列表 1 2 3 4 5 6 7 # 开启目录浏览（适合文件服务器） location /files/ { alias /data/files/; autoindex on; # 开启目录列表 autoindex_exact_size off; # 显示人类可读的文件大小 autoindex_localtime on; # 显示本地时间 } 六、虚拟主机 虚拟主机允许一台 Nginx 服务器同时托管多个网站。\n6.1 基于域名的虚拟主机（最常用） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 # 网站 A server { listen 80; server_name www.site-a.com site-a.com; root /var/www/site-a; index index.html; location / { try_files $uri $uri/ =404; } access_log /var/log/nginx/site-a.access.log; error_log /var/log/nginx/site-a.error.log; } # 网站 B server { listen 80; server_name www.site-b.com; root /var/www/site-b; index index.html; location / { try_files $uri $uri/ =404; } access_log /var/log/nginx/site-b.access.log; error_log /var/log/nginx/site-b.error.log; } 1 2 3 4 5 6 7 8 原理： Nginx 根据请求头中的 Host 字段匹配 server_name 不同域名 → 不同的 server 块 → 不同的 root 目录 推荐做法： 每个虚拟主机一个 .conf 文件 放在 /etc/nginx/conf.d/ 目录下 如：site-a.conf、site-b.conf 6.2 基于端口的虚拟主机 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # 端口 80 — 主站 server { listen 80; server_name example.com; root /var/www/main; } # 端口 8080 — 测试站 server { listen 8080; server_name example.com; root /var/www/test; } # 端口 9090 — 管理后台 server { listen 9090; server_name example.com; root /var/www/admin; } 6.3 基于IP的虚拟主机（少用） 1 2 3 4 5 6 7 8 9 10 11 server { listen 192.168.1.10:80; server_name example.com; root /var/www/site-a; } server { listen 192.168.1.11:80; server_name example.com; root /var/www/site-b; } 七、location 匹配规则 location 是 Nginx 配置的核心，决定请求如何被处理。\n7.1 匹配语法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 # 精确匹配（最高优先级） location = /exact/path { # 只匹配 /exact/path，不匹配 /exact/path/ } # 前缀匹配（优先级高于正则） location ^~ /images/ { # 匹配以 /images/ 开头的请求 # 一旦匹配，不再检查正则 } # 正则匹配（区分大小写） location ~ \\.php$ { # 匹配以 .php 结尾的请求 } # 正则匹配（不区分大小写） location ~* \\.(jpg|png|gif)$ { # 匹配以 .jpg/.png/.gif 结尾的请求（不区分大小写） } # 普通前缀匹配（最低优先级） location /docs/ { # 匹配以 /docs/ 开头的请求 } 7.2 匹配优先级 1 2 3 4 5 6 7 8 9 10 优先级从高到低： 1. = 精确匹配 location = /path 2. ^~ 前缀匹配 location ^~ /path 3. ~ 正则匹配（区分） location ~ /path 4. ~* 正则匹配（不区分） location ~* /path 5. 普通前缀匹配 location /path 记忆：先精确 \u0026gt; 前缀^~ \u0026gt; 正则 \u0026gt; 普通 正则之间按配置文件中的顺序匹配（先写的优先） 7.3 实际案例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 server { listen 80; server_name example.com; root /var/www/html; # 1. 精确匹配首页 location = / { # 只匹配 / } # 2. 静态资源（前缀匹配，跳过正则检查，性能更好） location ^~ /static/ { expires 30d; alias /data/static/; } # 3. 图片（正则匹配） location ~* \\.(jpg|jpeg|png|gif|ico|svg|webp)$ { expires 7d; add_header Cache-Control \u0026#34;public\u0026#34;; } # 4. PHP 文件 location ~ \\.php$ { fastcgi_pass unix:/run/php/php-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } # 5. 其他请求 location / { try_files $uri $uri/ =404; } } 八、常用变量 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 # 请求相关 $request # 完整的请求行（GET /path HTTP/1.1） $request_method # 请求方法（GET、POST、PUT 等） $request_uri # 完整的原始 URI（包含参数） $uri # 当前 URI（可能被重写，不含参数） $args # 查询参数（? 后面的部分） $query_string # 同 $args # 客户端相关 $remote_addr # 客户端 IP $remote_port # 客户端端口 $http_user_agent # 用户代理（浏览器信息） $http_referer # 来源页面 $http_cookie # Cookie # 服务器相关 $host # 请求头中的 Host 字段 $server_name # 当前 server 块的 server_name $server_port # 服务器监听端口 $document_root # 当前请求的 root 目录 $request_filename # 请求对应的文件完整路径 # 响应相关 $status # 响应状态码（200、404 等） $body_bytes_sent # 发送给客户端的字节数（不含响应头） $request_time # 请求处理时间（秒） 1 2 3 4 5 6 变量可以在以下位置使用： - if 条件判断 - rewrite 重写规则 - access_log 日志格式 - proxy_set_header 设置请求头 - return 返回内容 九、rewrite 重写规则 9.1 基本语法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 # rewrite regex replacement [flag]; # flag: # last — 重写后重新开始匹配 location（默认） # break — 重写后不再匹配后续 rewrite，但继续在当前 location 中处理 # redirect — 返回 302 临时重定向 # permanent — 返回 301 永久重定向 server { listen 80; # HTTP 重定向到 HTTPS return 301 https://$host$request_uri; # 旧路径重定向到新路径 rewrite ^/old-page$ /new-page permanent; rewrite ^/blog/(\\d+)/(.*)$ /article/$2?category=$1 last; # 域名重定向 if ($host != \u0026#39;www.example.com\u0026#39;) { rewrite ^/(.*)$ https://www.example.com/$1 permanent; } } 9.2 if 指令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 # if 可以使用的条件： # 变量名 — 非空非0为 true # = != — 字符串比较 # ~ ~* — 正则匹配 # -f !-f — 文件是否存在 # -d !-d — 目录是否存在 # -e !-e — 文件/目录/符号链接是否存在 location / { # 根据UA返回不同内容 if ($http_user_agent ~* \u0026#34;Mobile\u0026#34;) { rewrite ^ /mobile$uri last; } # 防盗链 location ~* \\.(jpg|png|gif)$ { valid_referers none blocked server_names *.example.com; if ($invalid_referer) { return 403; } } # 维护页面 if (-f /var/www/maintenance.html) { return 503; } } 1 2 3 4 5 6 7 8 9 10 ⚠️ if 的陷阱（if is evil）： Nginx 的 if 不像编程语言的 if 它属于 rewrite 模块，行为可能不符合预期 安全用法： return、rewrite 指令 危险用法： 在 if 里使用 proxy_pass、try_files 等 建议：能用 location 匹配就不使用 if 十、日志配置 10.1 访问日志 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 http { # 自定义日志格式 log_format detailed \u0026#39;$remote_addr - $remote_user [$time_local] \u0026#39; \u0026#39;\u0026#34;$request\u0026#34; $status $body_bytes_sent \u0026#39; \u0026#39;\u0026#34;$http_referer\u0026#34; \u0026#34;$http_user_agent\u0026#34; \u0026#39; \u0026#39;$request_time\u0026#39;; # 使用自定义格式 access_log /var/log/nginx/access.log detailed; # 某个 location 不记录日志 location /health { access_log off; return 200 \u0026#34;ok\u0026#34;; } } 10.2 错误日志 1 2 3 # 错误日志级别：debug | info | notice | warn | error | crit | alert | emerg error_log /var/log/nginx/error.log warn; # 全局 error_log /var/log/nginx/error.log error; # 只记录 error 及以上 10.3 日志轮转 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # 手动切割日志 mv /var/log/nginx/access.log /var/log/nginx/access.log.$(date +%Y%m%d) nginx -s reopen # 用 logrotate 自动管理（大多数系统自带） # /etc/logrotate.d/nginx /var/log/nginx/*.log { daily missingok rotate 30 compress delaycompress notifempty create 0640 www-data adm sharedscripts postrotate [ -f /run/nginx.pid ] \u0026amp;\u0026amp; kill -USR1 $(cat /run/nginx.pid) endscript } 十一、Docker Compose 示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 # docker-compose.yml version: \u0026#39;3.8\u0026#39; services: nginx: image: nginx:1.26 container_name: nginx ports: - \u0026#34;80:80\u0026#34; - \u0026#34;443:443\u0026#34; volumes: - ./conf:/etc/nginx - ./html:/usr/share/nginx/html - ./logs:/var/log/nginx restart: always networks: - web networks: web: external: true 1 2 3 4 5 6 7 8 9 10 11 12 目录结构： docker-compose.yml conf/ ├── nginx.conf ├── conf.d/ │ ├── site-a.conf │ └── site-b.conf └── mime.types html/ ├── site-a/ └── site-b/ logs/ 十二、小结 本文学习了 Nginx 的基础：\nNginx 是什么和为什么选择 Nginx 安装方式（Linux、Docker、Windows） 常用命令（启动、停止、重载、检查） 配置文件结构和层级关系 静态文件托管和 try_files 虚拟主机（基于域名、端口、IP） location 匹配规则和优先级 常用变量和 rewrite 规则 日志配置和轮转 下一篇将学习反向代理与负载均衡。\n","date":"2025-03-17T10:00:00+08:00","permalink":"/posts/middleware/nginx/01-nginx-basics/","title":"Nginx 学习笔记（一）：基础与核心配置"},{"content":"写在前面 本文是消息队列系列的最后一篇，深度对比 RabbitMQ 和 Kafka 的架构、性能、可靠性、适用场景，帮你做出正确的选型决策。前置知识：RabbitMQ 深入（第二篇）、Kafka 深入（第三篇）。\n一、设计哲学对比 1.1 RabbitMQ：消息代理 1 2 3 4 5 6 7 8 9 10 定位：通用的消息代理（Message Broker） 设计理念： - 以 Queue 为核心 - 消息消费后删除 - 丰富的路由规则（Exchange） - Push 模式（主动推给消费者） 起源：从企业消息系统演化而来 擅长：复杂的消息路由、企业集成、RPC 1.2 Kafka：分布式日志 1 2 3 4 5 6 7 8 9 10 定位：分布式事件流平台（Event Streaming Platform） 设计理念： - 以 Topic/Partition 为核心 - 消息持久化，保留一段时间 - 简单的路由（Producer 决定 Partition） - Pull 模式（消费者主动拉取） 起源：从 LinkedIn 的大数据管道演化而来 擅长：高吞吐日志/事件处理、流计算、消息回溯 1.3 根本差异 1 2 3 4 5 RabbitMQ：消息被消费就完成了使命（传递消息） Kafka： 消息是持久化的日志（记录事实） RabbitMQ 像邮递员：把信送到就完了 Kafka 像日记本： 记录下来，谁想看随时来看 二、架构对比 2.1 消息模型 1 2 3 4 5 6 7 8 9 RabbitMQ： Producer → Exchange → Binding → Queue → Consumer 路由在 Broker 完成（Exchange 决定发到哪个 Queue） 一个 Queue 的消息只能被一个 Consumer 消费（竞争消费） Kafka： Producer → Topic → Partition → Consumer Group → Consumer 路由在 Producer 完成（Producer 决定发到哪个 Partition） 一个 Topic 可以被多个 Consumer Group 各自消费（发布订阅） 2.2 消息存储 1 2 3 4 5 6 7 8 9 10 11 RabbitMQ： - 内存为主，持久化到磁盘（Mnesia） - 消息消费后删除 - 队列深度有限（内存限制） - 不支持消息回溯（消费了就没了） Kafka： - 追加写入磁盘日志 - 消息保留一段时间（如 7 天）后删除 - 支持无限堆积（磁盘有多大就能存多少） - 支持消息回溯（修改 Offset 重新消费） 2.3 消费模型 1 2 3 4 5 6 7 8 9 10 11 RabbitMQ：Push 模型 - Broker 主动推送消息给 Consumer - 通过 Prefetch Count 控制推送速率 - Consumer 处理慢时 Broker 会积压 - 消费确认后消息删除 Kafka：Pull 模型 - Consumer 主动从 Broker 拉取 - Consumer 控制自己的消费速率 - 处理慢了只是 LAG 增大，不影响 Broker - 通过 Offset 管理消费进度 三、性能对比 3.1 吞吐量 1 2 3 4 5 6 7 8 9 10 11 RabbitMQ： 单机吞吐量：万级 ~ 十万级/秒 百万级消息需要集群 + 优化 Kafka： 单机吞吐量：百万级/秒 三个 Broker 的集群可达千万级/秒 差距原因： Kafka：顺序写磁盘 + 零拷贝 + 页缓存 + 批量发送 RabbitMQ：随机写 + 内存管理开销 + 单条处理 3.2 延迟 1 2 3 4 5 6 7 8 9 10 11 12 13 RabbitMQ： 微秒级延迟（μs） 消息量小时延迟极低 消息堆积时延迟增加 Kafka： 毫秒级延迟（ms） 受 LingerMs 和批量大小影响 追求吞吐量时延迟偏高 结论： 延迟敏感 → RabbitMQ 吞吐量优先 → Kafka 3.3 消息堆积能力 1 2 3 4 5 6 7 8 9 10 11 RabbitMQ： - 消息堆积在内存中，受内存限制 - 百万级堆积后性能明显下降 - 可以用惰性队列（Lazy Queue）写磁盘缓解 - 堆积过多会触发流控（阻塞生产者） Kafka： - 天然持久化到磁盘，堆积是常态 - 千万级甚至亿级消息没有压力 - 堆积不影响生产者性能 - 只要磁盘够，可以一直堆积 四、可靠性对比 4.1 消息不丢 1 2 3 4 5 6 7 8 9 10 11 12 13 RabbitMQ： 生产端：Publisher Confirm（确认写入 Broker） Broker：持久化（Exchange + Queue + Message） 消费端：手动 ACK（处理完再确认） 高可用：镜像队列 / Quorum Queue Kafka： 生产端：acks=all（确认写入所有 ISR） Broker：副本机制（Replica + ISR） 消费端：手动提交 Offset（处理完再提交） 高可用：多副本 + Leader 选举 两者都能做到消息不丢，机制不同但效果相同 4.2 消息不重复 1 2 3 4 5 6 7 8 9 10 11 12 13 RabbitMQ： - 没有内置的 Exactly-Once 支持 - 需要业务层实现幂等（去重表、唯一约束、状态机） - Consumer 去重需要自己维护已处理消息 ID Kafka： - 幂等生产者（enable.idempotence=true） - Kafka 事务（跨 Partition 的原子写入） - 事务性消费-处理-生产（Consume-Transform-Produce） - 框架层面的 Exactly-Once 支持 结论： Kafka 在消息不重复方面更成熟 4.3 消息顺序 1 2 3 4 5 6 7 8 9 10 11 12 13 RabbitMQ： - 单 Queue 单 Consumer → 顺序保证 - 多 Consumer → 无法保证顺序 - 需要顺序的场景只能单消费者，牺牲并发 Kafka： - 同一 Partition 内消息严格有序 - 相同 Key 路由到同一 Partition - 可以多个 Partition 并行，同一 Key 内有序 - 在顺序和并发之间取得平衡 结论： Kafka 的 Partition 模型更适合顺序消费场景 4.4 消息回溯 1 2 3 4 5 6 7 8 9 10 11 12 RabbitMQ： ✗ 消息消费后删除，无法回溯 ✗ 如果需要重新消费，需要重新发送 Kafka： ✓ 消息持久化，保留一段时间 ✓ 可以修改 Offset 重新消费 ✓ 可以按时间戳查找消息 ✓ 可以创建新的 Consumer Group 从头消费 结论： Kafka 天然支持消息回溯，RabbitMQ 不支持 五、功能对比 5.1 路由能力 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 RabbitMQ： Direct — 精确匹配 ✓ Fanout — 广播 ✓ Topic — 通配符匹配 ✓ Headers — 头部匹配 ✓ 路由灵活度：★★★★★ Kafka： Producer 指定 Partition（通过 Key Hash 或自定义） 没有服务端路由 路由灵活度：★★☆☆☆ 结论： 复杂路由场景 → RabbitMQ 简单的 Topic 分区 → Kafka 5.2 延迟消息 1 2 3 4 5 6 7 8 9 10 11 12 RabbitMQ： ✓ TTL + DLX 方案（原生支持） ✓ 延迟消息插件（rabbitmq_delayed_message_exchange） 支持程度：★★★★☆ Kafka： ✗ 不原生支持延迟消息 需要自己实现（定时任务 + 外部存储） 支持程度：★☆☆☆☆ 结论： 延迟消息场景 → RabbitMQ 5.3 协议支持 1 2 3 4 5 6 7 8 9 10 11 RabbitMQ： AMQP 0-9-1（核心协议） AMQP 1.0（插件） MQTT（插件） STOMP（插件） 多协议支持好，适合异构系统集成 Kafka： 自定义二进制协议 通过 Kafka Connect 支持外部系统集成 REST Proxy 提供 HTTP 接口 5.4 管理和监控 1 2 3 4 5 6 7 8 9 10 11 RabbitMQ： ✓ Web 管理界面（rabbitmq_management 插件） ✓ 可视化 Queue、Exchange、Consumer ✓ 实时消息速率和堆积监控 管理便利度：★★★★★ Kafka： 命令行工具为主 需要第三方监控（Kafka Manager、CMAK、Confluent Control Center） 社区有开源方案但需要额外部署 管理便利度：★★★☆☆ 六、运维对比 6.1 部署复杂度 1 2 3 4 5 6 7 8 9 10 11 RabbitMQ： 单机：docker run -d rabbitmq — 一步搞定 集群：3 节点 + HA Policy 依赖：无（Erlang 自包含） 复杂度：★★☆☆☆ Kafka： 单机：Broker + ZooKeeper（或 KRaft 模式） 集群：3+ Broker + 3 ZooKeeper + 监控 依赖：ZooKeeper（KRaft 模式可去掉） 复杂度：★★★★☆ 6.2 扩容 1 2 3 4 5 6 7 8 9 RabbitMQ： 加节点 → 加入集群 → 设置 Policy Queue 数量不变，只是分布到更多节点 不需要改应用配置 Kafka： 加 Broker → 创建新 Topic 或迁移 Partition 增加 Partition 需要考虑 Key 分布 Consumer 可能需要调整 6.3 运维痛点 1 2 3 4 5 6 7 8 9 10 11 RabbitMQ： - 内存管理需要调优 - 大量 Queue 时性能下降 - 镜像队列同步慢 - 惰性队列性能权衡 Kafka： - Partition 数量管理（只能增不能减） - Rebalance 导致消费暂停 - ZooKeeper 运维（KRaft 模式可解决） - 磁盘空间管理 七、.NET 生态对比 7.1 客户端库 1 2 3 4 5 6 7 8 9 10 RabbitMQ： RabbitMQ.Client — 官方库，底层 API MassTransit — 高级抽象，支持 Saga、请求/响应 EasyNetQ — 简化 API，自动重连 CAP — 分布式事务（和数据库绑定） Kafka： Confluent.Kafka — 官方推荐，基于 librdkafka KafkaFlow — 高级抽象 CAP — 同样支持 Kafka 7.2 代码复杂度 1 2 3 4 5 6 7 8 9 10 11 12 13 RabbitMQ（RabbitMQ.Client）： - Exchange/Queue 声明和绑定 - 手动管理 Channel - Confirm 和 ACK 处理 - 死信配置 代码量较多，但控制精细 Kafka（Confluent.Kafka）： - Producer/Consumer 配置 - Topic 和 Partition 管理 - Offset 提交 - Rebalance 处理 配置项多但代码结构简单 八、适用场景对比 8.1 选 RabbitMQ 的场景 1 2 3 4 5 6 7 8 ✓ 消息路由复杂（多种 Exchange、灵活绑定） ✓ 需要延迟消息（TTL + DLX、定时任务） ✓ 企业系统集成（支持 AMQP、MQTT、STOMP） ✓ 中小规模消息量（万级/秒以内） ✓ 需要低延迟（微秒级） ✓ 需要请求/响应模式（RPC over MQ） ✓ 快速原型和小型项目 ✓ 团队已有 RabbitMQ 经验 8.2 选 Kafka 的场景 1 2 3 4 5 6 7 8 9 ✓ 高吞吐量（百万级/秒以上） ✓ 大数据量消息堆积（千万、亿级） ✓ 事件驱动架构（Event Sourcing、CQRS） ✓ 多个消费者独立消费同一数据（Consumer Group） ✓ 需要消息回溯（重新消费历史数据） ✓ 流计算（Kafka Streams、Flink、Spark） ✓ 日志收集和监控数据管道 ✓ 需要 Exactly-Once 语义 ✓ 大数据平台（和 Hadoop、Spark 生态集成） 8.3 都能用的场景 1 2 3 4 5 ✗ 异步任务处理（发短信、发邮件） ✗ 系统解耦（上下游解耦） ✗ 流量削峰（秒杀、抢购） 这些场景两者都能胜任，看团队技术栈和消息规模 九、选型决策树 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 消息量级？ ├── 万级/秒以内 │ ├── 需要复杂路由？ → RabbitMQ │ ├── 需要延迟消息？ → RabbitMQ │ └── 简单的发布订阅 → RabbitMQ 或 Kafka │ ├── 万级 ~ 十万级/秒 │ ├── 路由复杂 + 延迟消息 → RabbitMQ（需要调优） │ ├── 需要消息回溯 → Kafka │ └── 都可以，看团队偏好 │ └── 十万级/秒以上 └── Kafka（RabbitMQ 吞吐量不够） 特殊需求： ├── 需要 Exactly-Once → Kafka ├── 需要消息回溯 → Kafka ├── 需要流计算 → Kafka ├── 需要多协议支持 → RabbitMQ └── 需要快速上手 → RabbitMQ 十、常见误解 10.1 \u0026ldquo;Kafka 比 RabbitMQ 好\u0026rdquo; 1 2 3 4 5 6 7 8 错。它们解决不同的问题： Kafka 擅长高吞吐的事件流处理 RabbitMQ 擅长灵活的消息路由 很多场景 RabbitMQ 更合适： - 订单系统异步通知（发短信、邮件） - 企业内部系统解耦 - 需要延迟消息的业务 10.2 \u0026ldquo;RabbitMQ 性能不够\u0026rdquo; 1 2 3 大多数业务场景远远到不了 RabbitMQ 的性能瓶颈。 万级/秒的吞吐量对绝大多数系统够用了。 只有在日志收集、大数据管道等场景才需要 Kafka 的百万级吞吐。 10.3 \u0026ldquo;选 MQ 就选 Kafka，它是趋势\u0026rdquo; 1 2 3 4 5 技术选型不是选最流行的，而是选最适合的： - 消息量不大、路由复杂 → RabbitMQ 更简单高效 - 消息量巨大、需要回溯 → Kafka 更合适 - 运维能力有限 → RabbitMQ 部署更简单 - 团队已有经验 → 沿用最省力 十一、系列总结 11.1 知识体系回顾 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 第一篇：消息队列核心概念 - 消息模型、路由模式、确认机制 - 持久化、重试、死信、幂等性、投递语义 第二篇：RabbitMQ 深入 - 架构、Exchange、可靠性、死信、延迟消息 - .NET 实战、集群、性能调优 第三篇：Kafka 深入 - 架构、存储机制、生产者、消费者 - 副本、事务、Exactly-Once、.NET 实战 第四篇：RabbitMQ vs Kafka 深度对比 - 设计哲学、架构、性能、可靠性、功能 - 运维、.NET 生态、选型决策 11.2 核心结论 1 2 3 4 5 1. RabbitMQ 和 Kafka 不是替代关系，而是互补 2. 根据消息量级、路由需求、运维能力做选择 3. 大多数业务系统 RabbitMQ 足够 4. 大数据和高吞吐场景选 Kafka 5. 不确定时先用 RabbitMQ（更简单），规模上来再考虑 Kafka ","date":"2025-03-09T10:00:00+08:00","permalink":"/posts/middleware/mq/04-mq-comparison/","title":"消息队列（四）：RabbitMQ vs Kafka 深度对比"},{"content":"写在前面 本文是消息队列系列的第三篇，深入 Kafka 的架构设计、存储机制、生产者/消费者模型和 .NET 实战。前置知识：消息队列核心概念（第一篇）。\n一、Kafka 架构 1.1 整体架构 1 2 3 4 5 6 7 8 9 10 11 12 13 Producer → Broker Cluster → Consumer Group ↑ ZooKeeper / KRaft（元数据管理） 核心组件： - Broker — Kafka 服务器节点 - Topic — 消息分类（逻辑概念） - Partition — Topic 的物理分片 - Replica — Partition 的副本 - Producer — 消息生产者 - Consumer — 消息消费者 - Consumer Group — 消费者组 - ZooKeeper/KRaft — 集群元数据和协调 1.2 和 RabbitMQ 的本质区别 1 2 3 4 5 6 7 8 9 10 11 12 RabbitMQ： - 以 Queue 为中心 - 消息消费后删除 - 路由逻辑在 Exchange - Push 模式 Kafka： - 以 Topic/Partition 为中心 - 消息持久化，保留一段时间 - 路由逻辑在 Producer（决定发到哪个 Partition） - Pull 模式 - 设计目标是高吞吐的分布式日志系统 1.3 消息流转过程 1 2 3 4 5 1. Producer 决定消息发到哪个 Topic 的哪个 Partition 2. Partition Leader 接收并写入本地日志 3. Follower 从 Leader 拉取并复制 4. Consumer Group 中的 Consumer 各自消费分配到的 Partition 5. Consumer 定期提交 Offset 记录消费进度 二、Topic 和 Partition 2.1 Partition 的作用 1 2 3 4 1. 并行处理 — 不同 Partition 可以被不同 Consumer 并行消费 2. 水平扩展 — 增加 Partition 提高吞吐量 3. 顺序保证 — 同一 Partition 内消息有序 4. 容错 — 每个 Partition 有多个副本 2.2 Partition 数量选择 1 2 3 4 5 6 7 8 9 10 11 考虑因素： - 吞吐量需求 — Partition 越多，并行度越高 - Consumer 数量 — 一个 Partition 只能被 Consumer Group 中一个 Consumer 消费 - Broker 数量 — Partition 尽量均匀分布在各 Broker 经验值： - 小规模：每个 Topic 3-6 个 Partition - 中规模：每个 Topic 12-24 个 Partition - 大规模：可以到几百个 注意：Partition 数量只能增不能减 2.3 Key 和 Partition 路由 1 2 3 4 5 Producer 发送消息时可以指定 Key： - Key 为 null — 轮询分配到不同 Partition（Round Robin） - Key 不为 null — 对 Key 做 Hash，相同 Key 到同一个 Partition 这保证了相同业务 ID 的消息（如同一订单）都在同一个 Partition，保持顺序 1 2 3 4 5 6 7 8 // 指定 Key 发送 var message = new Message\u0026lt;string, string\u0026gt; { Key = $\u0026#34;order-{orderId}\u0026#34;, // 相同订单 ID → 同一 Partition Value = JsonSerializer.Serialize(orderEvent) }; await producer.ProduceAsync(\u0026#34;orders\u0026#34;, message); 三、存储机制 3.1 日志追加（Append-Only Log） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 Kafka 的核心存储设计： 每个 Partition 是一个追加写入的日志文件： Offset Message 0 {\u0026#34;orderId\u0026#34;:1,\u0026#34;action\u0026#34;:\u0026#34;created\u0026#34;} 1 {\u0026#34;orderId\u0026#34;:1,\u0026#34;action\u0026#34;:\u0026#34;paid\u0026#34;} 2 {\u0026#34;orderId\u0026#34;:2,\u0026#34;action\u0026#34;:\u0026#34;created\u0026#34;} 3 {\u0026#34;orderId\u0026#34;:1,\u0026#34;action\u0026#34;:\u0026#34;shipped\u0026#34;} ... 特点： - 顺序写磁盘 → 极快（600MB/s 顺序写 vs 100KB/s 随机写） - 不可修改 → 只能追加 - 通过 Offset 随机读取 3.2 Segment 分段 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 一个 Partition 由多个 Segment 组成： Partition (topic-order-0/) ├── 00000000000000000000.log ← Segment 1（Offset 0 开始） ├── 00000000000000000000.index ← 偏移量索引 ├── 00000000000000000000.timeindex ← 时间索引 ├── 00000000000000000036.log ← Segment 2（Offset 36 开始） ├── 00000000000000000036.index └── 00000000000000000036.timeindex Segment 滚动条件： - 达到 1GB（log.segment.bytes） - 达到 7 天（log.segment.ms） 好处： - 快速定位消息（二分查找 Segment + 索引） - 方便清理过期数据（删除整个 Segment） 3.3 零拷贝（Zero-Copy） 1 2 3 4 5 6 7 8 传统数据传输（4 次拷贝）： 磁盘 → 内核缓冲区 → 用户空间 → Socket 缓冲区 → 网卡 Kafka 零拷贝（2 次拷贝）： 磁盘 → 内核缓冲区 → 网卡 通过 Java 的 FileChannel.transferTo()（Linux 的 sendfile 系统调用） 配合页缓存（Page Cache），Kafka 极少做用户空间的数据拷贝 3.4 页缓存（Page Cache） 1 2 3 4 5 6 Kafka 不自己管理缓存，而是利用操作系统的页缓存： - 写入时先写页缓存，由 OS 异步刷盘 - 读取时先从页缓存读，命中就不用访问磁盘 - 生产者和消费者访问的是同一块页缓存（热数据在内存中） 这就是 Kafka 高吞吐的关键：顺序写 + 页缓存 + 零拷贝 3.5 数据保留和清理 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 保留策略（二选一）： - 按时间：log.retention.hours=168（默认 7 天） - 按大小：log.retention.bytes=1073741824（1GB） 清理策略： - delete — 删除整个 Segment（默认） - compact — 保留每个 Key 的最新值（适合 changelog） Compact 示例： 写入：key=user1, value=张三 写入：key=user2, value=李四 写入：key=user1, value=王五（更新） Compact 后只保留： key=user1, value=王五 key=user2, value=李四 四、生产者 4.1 发送模式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 1. 发后即忘（Fire and Forget）— 最快，可能丢 producer.Produce(\u0026#34;orders\u0026#34;, message); // 2. 同步发送 — 等待 ACK，最慢但最安全 var result = await producer.ProduceAsync(\u0026#34;orders\u0026#34;, message); Console.WriteLine($\u0026#34;Offset: {result.Offset}\u0026#34;); // 3. 异步发送 + 回调 — 推荐，兼顾性能和可靠性 producer.Produce(\u0026#34;orders\u0026#34;, message, report =\u0026gt; { if (report.Error.Code != ErrorCode.NoError) logger.LogError(\u0026#34;发送失败：{Error}\u0026#34;, report.Error.Reason); else logger.LogInformation(\u0026#34;发送成功：Partition={Partition}, Offset={Offset}\u0026#34;, report.Partition, report.Offset); }); 4.2 ACK 配置 1 2 3 4 5 6 7 8 var config = new ProducerConfig { BootstrapServers = \u0026#34;localhost:9092\u0026#34;, Acks = Acks.All, // 等同于 acks=all // Acks.None — 不等 ACK（最快，可能丢） // Acks.Leader — Leader 写入就返回（默认） // Acks.All — 所有 ISR 副本都写入（最安全） }; 4.3 批量发送和缓冲 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 var config = new ProducerConfig { BootstrapServers = \u0026#34;localhost:9092\u0026#34;, // 批量大小（字节） BatchSize = 16384, // 16KB // 等待时间（毫秒）— 攒够一批或超时就发 LingerMs = 5, // 5ms // 缓冲区大小 BufferMemory = 33554432, // 32MB // 压缩 CompressionType = CompressionType.Lz4, // 重试 MessageSendMaxRetries = 3, RetryBackoffMs = 100, }; 1 2 3 4 5 6 7 8 9 10 批量发送原理： Producer 内部有一个缓冲区： - 消息先写入缓冲区 - 攒够一个 Batch（BatchSize）或等待超时（LingerMs）就发送 - 大幅减少网络请求次数 LingerMs 和 BatchSize 的权衡： LingerMs 大 → 批更大但延迟高 LingerMs 小 → 批更小但延迟低 建议：LingerMs=5-20ms，BatchSize=16-64KB 4.4 幂等生产者 1 2 3 4 5 6 7 8 9 10 11 var config = new ProducerConfig { BootstrapServers = \u0026#34;localhost:9092\u0026#34;, Acks = Acks.All, EnableIdempotence = true, // 开启幂等（自动设置 acks=all） // 幂等生产者保证： // 即使网络重试，同一条消息也只写入一次 // 通过 Producer ID + Sequence Number 去重 // 注意：只保证单分区的单会话幂等 }; 五、消费者 5.1 Consumer Group 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 核心概念： - 一个 Consumer Group 中的 Consumer 共同消费一个 Topic - 每个 Partition 只被 Group 中的一个 Consumer 消费 - 不同 Group 各自独立消费（发布订阅） 示例： Topic \u0026#34;orders\u0026#34; 有 3 个 Partition Group \u0026#34;sms-group\u0026#34;： Consumer-1 → Partition-0 Consumer-2 → Partition-1 Consumer-3 → Partition-2 Group \u0026#34;points-group\u0026#34;： Consumer-A → Partition-0, 1, 2（只有一个 Consumer，消费所有） 注意： - Consumer 数量 \u0026gt; Partition 数量 → 多余的 Consumer 空闲 - Consumer 数量 \u0026lt; Partition 数量 → 有的 Consumer 消费多个 Partition 5.2 Offset 管理 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 Offset：消息在 Partition 中的位置（从 0 递增） 消费进度管理： - Consumer 定期提交已消费的 Offset - 重启后从上次提交的 Offset 继续消费 提交方式： 1. 自动提交（enable.auto.commit=true） - 定期自动提交（默认 5 秒） - 可能重复消费（处理完但没提交就挂了） - 简单但不可靠 2. 手动提交（enable.auto.commit=false） - 处理完业务后手动调用 Commit - 更可靠但代码更复杂 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 // 手动提交 var config = new ConsumerConfig { BootstrapServers = \u0026#34;localhost:9092\u0026#34;, GroupId = \u0026#34;order-sms-group\u0026#34;, EnableAutoCommit = false, // 关闭自动提交 AutoOffsetReset = AutoOffsetReset.Earliest // 无 Offset 时从头开始 }; using var consumer = new ConsumerBuilder\u0026lt;string, string\u0026gt;(config).Build(); consumer.Subscribe(\u0026#34;orders\u0026#34;); while (!cancellationToken.IsCancellationRequested) { var result = consumer.Consume(CancellationToken.None); try { // 处理业务 await ProcessOrder(result.Message.Value); // 处理成功，提交 Offset consumer.Commit(result); } catch (Exception ex) { logger.LogError(ex, \u0026#34;处理失败，不提交 Offset\u0026#34;); // 不提交，下次重启会重新消费 } } 5.3 Rebalance 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 Consumer Group 中 Consumer 变化时触发 Rebalance： - 新 Consumer 加入 - Consumer 离开（崩溃、关闭） - 订阅的 Topic Partition 数量变化 Rebalance 过程： 1. 所有 Consumer 撤回分配的 Partition 2. 重新分配 Partition 给各 Consumer 3. 各 Consumer 从新分配的 Partition 继续 Rebalance 的问题： - 短暂的消费暂停（Stop The World） - 可能重复消费（Rebalance 前 Commit 了吗？） - 频繁 Rebalance 影响性能 减少 Rebalance： - 合理设置 session.timeout.ms（默认 45s） - 合理设置 heartbeat.interval.ms（默认 3s） - 避免消费者处理太慢（超过 session.timeout 会被踢出） - 使用 CooperativeStickyAssignor（增量 Rebalance） 5.4 消费者配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 var config = new ConsumerConfig { BootstrapServers = \u0026#34;localhost:9092\u0026#34;, GroupId = \u0026#34;order-sms-group\u0026#34;, // Offset 提交 EnableAutoCommit = false, // 无 Offset 时从哪里开始 AutoOffsetReset = AutoOffsetReset.Earliest, // 最早 // AutoOffsetReset.Latest — 最新的 // AutoOffsetReset.Error — 报错 // 心跳和超时 SessionTimeoutMs = 30000, // 30 秒无心跳认为挂了 HeartbeatIntervalMs = 3000, // 3 秒发一次心跳 MaxPollIntervalMs = 300000, // 两次 poll 最大间隔（5 分钟） // 拉取配置 FetchMinBytes = 1, // 最少拉取 1 字节 FetchMaxWaitMs = 500, // 最多等 500ms MaxPartitionFetchBytes = 1048576, // 每个 Partition 最多拉 1MB }; 六、副本和高可用 6.1 副本机制 1 2 3 4 5 6 7 8 9 10 11 12 每个 Partition 有多个 Replica： - Leader Replica — 处理读写请求 - Follower Replica — 从 Leader 复制数据，不处理客户端请求 副本因子（replication.factor）： - 通常设为 3（1 Leader + 2 Follower） - 不同 Broker 上各放一个副本 ISR（In-Sync Replicas）： - 和 Leader 保持同步的副本集合 - Follower 落后太多会被移出 ISR - Leader 挂了，从 ISR 中选新 Leader 6.2 Leader 选举 1 2 3 4 5 6 7 8 9 10 11 12 13 Leader 挂了时： 1. Controller（集群中的一个 Broker）检测到 2. 从 ISR 中选一个 Follower 作为新 Leader 3. 通知所有 Broker 和 Consumer 配置： unclean.leader.election.enable=false（默认） → 只允许 ISR 中的副本成为 Leader（安全） → 如果 ISR 为空，Partition 不可用 unclean.leader.election.enable=true（不推荐） → 允许非 ISR 副本成为 Leader → 可用性更高但可能丢数据 6.3 副本配置 1 2 3 4 5 6 7 8 9 10 # Topic 级别配置 kafka-topics.sh --create \\ --topic orders \\ --partitions 6 \\ --replication-factor 3 \\ --config min.insync.replicas=2 # min.insync.replicas=2 配合 acks=all # → 至少 2 个副本（含 Leader）写入成功才返回 # → 3 个副本容忍 1 个宕机 七、Kafka 事务 7.1 Exactly-Once 语义 1 2 3 4 Kafka 事务保证： - 原子性：一批消息要么全部成功要么全部失败 - 跨 Partition：可以同时写入多个 Partition - 消费-处理-生产：从 Topic A 消费，处理完写入 Topic B，整个流程 Exactly-Once 7.2 事务生产者 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 var config = new ProducerConfig { BootstrapServers = \u0026#34;localhost:9092\u0026#34;, TransactionalId = \u0026#34;order-tx-producer-1\u0026#34;, // 事务 ID（必须唯一） EnableIdempotence = true, Acks = Acks.All }; using var producer = new ProducerBuilder\u0026lt;string, string\u0026gt;(config).Build(); producer.InitTransactions(TimeSpan.FromSeconds(10)); try { producer.BeginTransaction(); // 发送多条消息到不同 Partition/Topic await producer.ProduceAsync(\u0026#34;orders\u0026#34;, new Message\u0026lt;string, string\u0026gt; { Key = \u0026#34;order-1\u0026#34;, Value = \u0026#34;...\u0026#34; }); await producer.ProduceAsync(\u0026#34;payments\u0026#34;, new Message\u0026lt;string, string\u0026gt; { Key = \u0026#34;pay-1\u0026#34;, Value = \u0026#34;...\u0026#34; }); // 提交事务 producer.CommitTransaction(TimeSpan.FromSeconds(10)); } catch { // 回滚事务 producer.AbortTransaction(TimeSpan.FromSeconds(10)); } 7.3 事务消费者 1 2 3 4 5 6 7 8 var config = new ConsumerConfig { BootstrapServers = \u0026#34;localhost:9092\u0026#34;, GroupId = \u0026#34;order-processor\u0026#34;, IsolationLevel = IsolationLevel.ReadCommitted, // ReadCommitted — 只读已提交的事务消息（推荐） // ReadUncommitted — 读所有消息包括未提交的 }; 八、.NET 实战 8.1 使用 Confluent.Kafka 1 dotnet add package Confluent.Kafka 8.2 完整 Producer 封装 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 public class KafkaProducerService : IDisposable { private readonly IProducer\u0026lt;string, string\u0026gt; _producer; private readonly ILogger\u0026lt;KafkaProducerService\u0026gt; _logger; public KafkaProducerService(IConfiguration config, ILogger\u0026lt;KafkaProducerService\u0026gt; logger) { _logger = logger; var producerConfig = new ProducerConfig { BootstrapServers = config[\u0026#34;Kafka:BootstrapServers\u0026#34;], Acks = Acks.All, EnableIdempotence = true, LingerMs = 10, BatchSize = 32768, CompressionType = CompressionType.Lz4, MessageSendMaxRetries = 3, RetryBackoffMs = 100 }; _producer = new ProducerBuilder\u0026lt;string, string\u0026gt;(producerConfig).Build(); } public async Task ProduceAsync\u0026lt;T\u0026gt;(string topic, string key, T value) { var message = new Message\u0026lt;string, string\u0026gt; { Key = key, Value = JsonSerializer.Serialize(value) }; var result = await _producer.ProduceAsync(topic, message); _logger.LogDebug(\u0026#34;发送到 {Topic} Partition:{Partition} Offset:{Offset}\u0026#34;, topic, result.Partition, result.Offset); } public void Dispose() =\u0026gt; _producer?.Dispose(); } 8.3 完整 Consumer 封装 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 public class KafkaConsumerService\u0026lt;T\u0026gt; : BackgroundService { private readonly IConsumer\u0026lt;string, string\u0026gt; _consumer; private readonly ILogger\u0026lt;KafkaConsumerService\u0026lt;T\u0026gt;\u0026gt; _logger; private readonly Func\u0026lt;string, Task\u0026gt; _handler; private readonly string _topic; public KafkaConsumerService( IConfiguration config, ILogger\u0026lt;KafkaConsumerService\u0026lt;T\u0026gt;\u0026gt; logger, string topic, string groupId, Func\u0026lt;string, Task\u0026gt; handler) { _logger = logger; _handler = handler; _topic = topic; var consumerConfig = new ConsumerConfig { BootstrapServers = config[\u0026#34;Kafka:BootstrapServers\u0026#34;], GroupId = groupId, EnableAutoCommit = false, AutoOffsetReset = AutoOffsetReset.Earliest, SessionTimeoutMs = 30000, MaxPollIntervalMs = 300000 }; _consumer = new ConsumerBuilder\u0026lt;string, string\u0026gt;(consumerConfig) .SetErrorHandler((_, e) =\u0026gt; _logger.LogError(\u0026#34;Kafka 错误：{Error}\u0026#34;, e.Reason)) .Build(); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _consumer.Subscribe(_topic); while (!stoppingToken.IsCancellationRequested) { try { var result = _consumer.Consume(stoppingToken); await _handler(result.Message.Value); _consumer.Commit(result); } catch (ConsumeException ex) { _logger.LogError(ex, \u0026#34;消费异常\u0026#34;); } catch (OperationCanceledException) { break; } catch (Exception ex) { _logger.LogError(ex, \u0026#34;处理消息异常\u0026#34;); // 不提交，下次重新消费 } } _consumer.Close(); } public override void Dispose() { _consumer?.Dispose(); base.Dispose(); } } 8.4 注册和使用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 注册 Producer builder.Services.AddSingleton\u0026lt;KafkaProducerService\u0026gt;(); // 注册 Consumer（BackgroundService） builder.Services.AddHostedService(sp =\u0026gt; new KafkaConsumerService\u0026lt;OrderEvent\u0026gt;( sp.GetRequiredService\u0026lt;IConfiguration\u0026gt;(), sp.GetRequiredService\u0026lt;ILogger\u0026lt;KafkaConsumerService\u0026lt;OrderEvent\u0026gt;\u0026gt;\u0026gt;(), topic: \u0026#34;orders\u0026#34;, groupId: \u0026#34;order-sms-group\u0026#34;, handler: async (json) =\u0026gt; { var order = JsonSerializer.Deserialize\u0026lt;OrderEvent\u0026gt;(json); await SendSmsAsync(order!.CustomerEmail, \u0026#34;您的订单已创建\u0026#34;); })); 九、常用运维命令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 # Topic 管理 kafka-topics.sh --create --topic orders --partitions 6 --replication-factor 3 --bootstrap-server localhost:9092 kafka-topics.sh --list --bootstrap-server localhost:9092 kafka-topics.sh --describe --topic orders --bootstrap-server localhost:9092 kafka-topics.sh --alter --topic orders --partitions 12 --bootstrap-server localhost:9092 # 只能增 # 消费者组 kafka-consumer-groups.sh --list --bootstrap-server localhost:9092 kafka-consumer-groups.sh --describe --group order-sms-group --bootstrap-server localhost:9092 # 查看消费进度（LAG） kafka-consumer-groups.sh --describe --group order-sms-group --bootstrap-server localhost:9092 # 输出：CURRENT-OFFSET LOG-END-OFFSET LAG # LAG \u0026gt; 0 说明消费有积压 # 重置 Offset kafka-consumer-groups.sh --reset-offsets --group order-sms-group --topic orders --to-earliest --execute --bootstrap-server localhost:9092 # 配置修改 kafka-configs.sh --alter --entity-type topics --entity-name orders --add-config retention.ms=604800000 --bootstrap-server localhost:9092 # 查看配置 kafka-configs.sh --describe --entity-type topics --entity-name orders --bootstrap-server localhost:9092 十、小结 本文深入学习了 Kafka：\n架构（Broker、Topic、Partition、Consumer Group） 和 RabbitMQ 的本质区别 存储机制（追加日志、Segment、零拷贝、页缓存） 生产者（发送模式、ACK、批量、幂等） 消费者（Consumer Group、Offset、Rebalance） 副本和高可用 Kafka 事务和 Exactly-Once .NET 实战（Confluent.Kafka） 运维常用命令 下一篇将对比 RabbitMQ 和 Kafka，帮助你在实际项目中做出选型。\n","date":"2025-03-05T10:00:00+08:00","permalink":"/posts/middleware/mq/03-kafka-deep-dive/","title":"消息队列（三）：Kafka 深入"},{"content":"写在前面 本文是消息队列系列的第二篇，深入 RabbitMQ 的架构、Exchange 路由、可靠性保证和 .NET 实战。前置知识：消息队列核心概念（第一篇）。\n一、RabbitMQ 架构 1.1 整体架构 1 2 3 4 5 6 7 8 9 10 11 Producer → Exchange → Binding → Queue → Consumer ↑ Routing Key 核心组件： - Connection — TCP 连接 - Channel — 连接内的轻量级通道（一个连接多路复用） - Virtual Host — 逻辑隔离（类似数据库的 Schema） - Exchange — 消息路由器 - Queue — 消息存储 - Binding — Exchange 和 Queue 的绑定规则 1.2 消息流转过程 1 2 3 4 5 6 1. Producer 创建消息，指定 Exchange 和 Routing Key 2. Exchange 根据 Binding 规则路由到一个或多个 Queue 3. Queue 存储消息（如果持久化则写入磁盘） 4. Consumer 从 Queue 中消费消息 5. Consumer 处理完后发送 ACK 6. Queue 收到 ACK 后删除消息 1.3 Virtual Host 1 2 3 4 5 6 7 8 9 10 11 一个 RabbitMQ 实例可以有多个 VHost，互相隔离： / — 默认 VHost /dev — 开发环境 /staging — 预发环境 /prod — 生产环境 每个 VHost 有独立的： - Exchange - Queue - Binding - 用户权限 二、Exchange 详解 2.1 Direct Exchange 精确匹配 Routing Key。\n1 2 3 4 Producer → [Exchange] → Queue \u0026#34;order-created\u0026#34;（key=\u0026#34;order.created\u0026#34;） → Queue \u0026#34;order-cancelled\u0026#34;（key=\u0026#34;order.cancelled\u0026#34;） 只有 Routing Key 完全匹配才会路由到对应 Queue 1 2 3 4 5 6 7 8 9 10 11 12 13 // 声明 Exchange channel.ExchangeDeclare(\u0026#34;order.exchange\u0026#34;, ExchangeType.Direct); // 声明 Queue channel.QueueDeclare(\u0026#34;order-created\u0026#34;, durable: true, exclusive: false, autoDelete: false, arguments: null); channel.QueueDeclare(\u0026#34;order-cancelled\u0026#34;, durable: true, exclusive: false, autoDelete: false, arguments: null); // 绑定 channel.QueueBind(\u0026#34;order-created\u0026#34;, \u0026#34;order.exchange\u0026#34;, \u0026#34;order.created\u0026#34;); channel.QueueBind(\u0026#34;order-cancelled\u0026#34;, \u0026#34;order.exchange\u0026#34;, \u0026#34;order.cancelled\u0026#34;); // 发送 channel.BasicPublish(\u0026#34;order.exchange\u0026#34;, \u0026#34;order.created\u0026#34;, null, body); 2.2 Fanout Exchange 忽略 Routing Key，广播到所有绑定的 Queue。\n1 2 3 4 5 Producer → [Exchange] → Queue A（短信） → Queue B（积分） → Queue C（通知） 所有 Queue 都会收到消息，不管 Routing Key 是什么 1 2 3 4 5 6 7 8 9 10 channel.ExchangeDeclare(\u0026#34;order.fanout\u0026#34;, ExchangeType.Fanout); channel.QueueDeclare(\u0026#34;sms-queue\u0026#34;, durable: true, exclusive: false, autoDelete: false, null); channel.QueueDeclare(\u0026#34;points-queue\u0026#34;, durable: true, exclusive: false, autoDelete: false, null); channel.QueueDeclare(\u0026#34;notify-queue\u0026#34;, durable: true, exclusive: false, autoDelete: false, null); // Fanout 绑定不需要 routing key channel.QueueBind(\u0026#34;sms-queue\u0026#34;, \u0026#34;order.fanout\u0026#34;, \u0026#34;\u0026#34;); channel.QueueBind(\u0026#34;points-queue\u0026#34;, \u0026#34;order.fanout\u0026#34;, \u0026#34;\u0026#34;); channel.QueueBind(\u0026#34;notify-queue\u0026#34;, \u0026#34;order.fanout\u0026#34;, \u0026#34;\u0026#34;); 2.3 Topic Exchange 通配符匹配 Routing Key。\n1 2 3 4 5 6 7 8 9 10 11 Routing Key 格式：用 . 分隔的单词，如 order.created.payment 通配符： * 匹配一个单词 # 匹配零或多个单词 示例： order.* → 匹配 order.created, order.cancelled order.# → 匹配 order.created, order.created.payment, order.cancelled.refund *.created → 匹配 order.created, user.created # → 匹配所有（等同于 Fanout） 1 2 3 4 5 6 7 8 9 10 channel.ExchangeDeclare(\u0026#34;order.topic\u0026#34;, ExchangeType.Topic); // 短信服务关心所有订单事件 channel.QueueBind(\u0026#34;sms-queue\u0026#34;, \u0026#34;order.topic\u0026#34;, \u0026#34;order.*\u0026#34;); // 财务服务只关心支付相关 channel.QueueBind(\u0026#34;finance-queue\u0026#34;, \u0026#34;order.topic\u0026#34;, \u0026#34;order.*.payment\u0026#34;); // 统计服务关心所有事件 channel.QueueBind(\u0026#34;stats-queue\u0026#34;, \u0026#34;order.topic\u0026#34;, \u0026#34;#\u0026#34;); 2.4 Headers Exchange 基于消息头匹配，忽略 Routing Key。\n1 2 3 4 5 匹配模式： - x-match=all — 所有头部字段都匹配 - x-match=any — 任一头部字段匹配 适用：复杂的多维度路由（实际使用较少，Topic 通常够用） 2.5 Exchange 类型选择 1 2 3 4 Direct — 精确匹配，点对点或简单路由 Fanout — 广播，所有消费者都要 Topic — 模式匹配，灵活路由（最常用） Headers — 复杂条件匹配（性能差，少用） 三、消息可靠性保证 3.1 三个环节 1 2 3 生产端可靠性 — 确保消息成功到达 Broker Broker 可靠性 — 确保消息在 Broker 中不丢 消费端可靠性 — 确保消息被正确处理 3.2 生产端：Publisher Confirm 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 开启 Confirm 模式 channel.ConfirmSelect(); // 发送消息 channel.BasicPublish(\u0026#34;order.exchange\u0026#34;, \u0026#34;order.created\u0026#34;, null, body); // 等待确认 channel.WaitForConfirmsOrDie(TimeSpan.FromSeconds(5)); // 成功 → 继续 // 超时 → 抛异常，可以重试 // 批量确认（性能更好） for (int i = 0; i \u0026lt; 1000; i++) { channel.BasicPublish(\u0026#34;order.exchange\u0026#34;, \u0026#34;order.created\u0026#34;, null, bodies[i]); } channel.WaitForConfirms(); // 一次性确认所有 3.3 生产端：事务（不推荐） 1 2 3 4 5 6 7 8 9 10 11 12 13 // 事务模式（性能差，不推荐） channel.TxSelect(); try { channel.BasicPublish(\u0026#34;order.exchange\u0026#34;, \u0026#34;order.created\u0026#34;, null, body); channel.TxCommit(); } catch { channel.TxRollback(); } // Confirm 模式比事务快 10 倍以上，优先用 Confirm 3.4 Broker 端：持久化 1 2 3 4 5 6 7 8 9 10 11 12 13 14 三个层面都要持久化： 1. Exchange 持久化 channel.ExchangeDeclare(\u0026#34;order.exchange\u0026#34;, ExchangeType.Direct, durable: true); 2. Queue 持久化 channel.QueueDeclare(\u0026#34;order-queue\u0026#34;, durable: true, exclusive: false, autoDelete: false, null); 3. Message 持久化 var props = channel.CreateBasicProperties(); props.DeliveryMode = 2; // 2 = 持久化 channel.BasicPublish(\u0026#34;order.exchange\u0026#34;, \u0026#34;order.created\u0026#34;, props, body); 注意：持久化影响性能，根据业务需求选择 3.5 消费端：手动 ACK 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 // 关闭自动确认 var consumer = new EventingBasicConsumer(channel); consumer.Received += (model, ea) =\u0026gt; { try { var body = ea.Body.ToArray(); var message = Encoding.UTF8.GetString(body); // 处理业务逻辑 ProcessOrder(message); // 手动确认 channel.BasicAck(ea.DeliveryTag, multiple: false); } catch (Exception ex) { // 处理失败 // requeue=true → 重新入队（可能无限循环） // requeue=false → 丢弃或进死信 channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: false); } }; // autoAck: false → 手动确认 channel.BasicConsume(\u0026#34;order-queue\u0026#34;, autoAck: false, consumer); 3.6 可靠性总结 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 消息不丢的完整配置： 生产端： ✓ Confirm 模式 ✓ 持久化消息（DeliveryMode=2） ✓ 发送失败重试 Broker： ✓ Exchange 持久化（durable=true） ✓ Queue 持久化（durable=true） ✓ 队列镜像（HA Policy，集群模式） 消费端： ✓ 手动 ACK（autoAck=false） ✓ 处理完业务再 ACK ✓ 失败进死信队列 四、死信队列 4.1 死信产生条件 1 2 3 1. 消费者 nack/reject 且 requeue=false 2. 消息 TTL 过期 3. 队列达到最大长度 4.2 配置死信队列 1 2 3 4 5 6 7 8 9 10 11 12 // 业务队列：指定死信交换机 var args = new Dictionary\u0026lt;string, object\u0026gt; { { \u0026#34;x-dead-letter-exchange\u0026#34;, \u0026#34;order.dlx\u0026#34; }, { \u0026#34;x-dead-letter-routing-key\u0026#34;, \u0026#34;order.dead\u0026#34; } }; channel.QueueDeclare(\u0026#34;order-queue\u0026#34;, durable: true, exclusive: false, autoDelete: false, args); // 死信交换机和队列 channel.ExchangeDeclare(\u0026#34;order.dlx\u0026#34;, ExchangeType.Direct); channel.QueueDeclare(\u0026#34;order-dead-queue\u0026#34;, durable: true, exclusive: false, autoDelete: false, null); channel.QueueBind(\u0026#34;order-dead-queue\u0026#34;, \u0026#34;order.dlx\u0026#34;, \u0026#34;order.dead\u0026#34;); 4.3 死信处理 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 消费死信队列 var consumer = new EventingBasicConsumer(channel); consumer.Received += (model, ea) =\u0026gt; { var body = ea.Body.ToArray(); var message = Encoding.UTF8.GetString(body); // 记录失败原因 var reason = ea.BasicProperties.Headers[\u0026#34;x-death\u0026#34;] as List\u0026lt;object\u0026gt;; logger.LogError(\u0026#34;消息进入死信：{Message}, 原因：{Reason}\u0026#34;, message, reason); // 告警通知 alertService.Notify($\u0026#34;消息处理失败：{message}\u0026#34;); channel.BasicAck(ea.DeliveryTag, false); }; channel.BasicConsume(\u0026#34;order-dead-queue\u0026#34;, autoAck: false, consumer); 五、延迟消息 5.1 TTL + DLX 方案 1 2 3 4 5 6 7 8 9 原理： 消息设置 TTL → 过期后进入死信队列 → 消费者消费死信队列 流程： Producer → Queue（TTL=30min, DLX=order.dlx） ↓ 过期 order.dlx → dead-queue → Consumer 实现订单 30 分钟未支付自动取消 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 延迟队列：消息过期后进入死信 var delayArgs = new Dictionary\u0026lt;string, object\u0026gt; { { \u0026#34;x-dead-letter-exchange\u0026#34;, \u0026#34;order.dlx\u0026#34; }, { \u0026#34;x-dead-letter-routing-key\u0026#34;, \u0026#34;order.timeout\u0026#34; }, { \u0026#34;x-message-ttl\u0026#34;, 1800000 } // 30 分钟（毫秒） }; channel.QueueDeclare(\u0026#34;order-wait-queue\u0026#34;, durable: true, exclusive: false, autoDelete: false, delayArgs); // 死信交换机和消费队列 channel.ExchangeDeclare(\u0026#34;order.dlx\u0026#34;, ExchangeType.Direct); channel.QueueDeclare(\u0026#34;order-timeout-queue\u0026#34;, durable: true, exclusive: false, autoDelete: false, null); channel.QueueBind(\u0026#34;order-timeout-queue\u0026#34;, \u0026#34;order.dlx\u0026#34;, \u0026#34;order.timeout\u0026#34;); // 发送延迟消息 channel.BasicPublish(\u0026#34;\u0026#34;, \u0026#34;order-wait-queue\u0026#34;, null, orderBody); // 30 分钟后，消息过期进入 order-timeout-queue 被消费 5.2 延迟消息插件 1 2 # 安装 rabbitmq_delayed_message_exchange 插件 rabbitmq-plugins enable rabbitmq_delayed_message_exchange 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // 使用延迟插件（更灵活，支持每条消息不同延迟） var args = new Dictionary\u0026lt;string, object\u0026gt; { { \u0026#34;x-delayed-type\u0026#34;, \u0026#34;direct\u0026#34; } }; channel.ExchangeDeclare(\u0026#34;order.delay\u0026#34;, \u0026#34;x-delayed-message\u0026#34;, durable: true, arguments: args); // 发送时指定延迟时间 var props = channel.CreateBasicProperties(); props.Headers = new Dictionary\u0026lt;string, object\u0026gt; { { \u0026#34;x-delay\u0026#34;, 60000 } // 延迟 60 秒 }; channel.BasicPublish(\u0026#34;order.delay\u0026#34;, \u0026#34;order.timeout\u0026#34;, props, body); 六、.NET 实战 6.1 使用 RabbitMQ.Client 1 dotnet add package RabbitMQ.Client 6.2 封装 Producer 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 public class RabbitMqProducer : IDisposable { private readonly IConnection _connection; private readonly IModel _channel; public RabbitMqProducer(string connectionString) { var factory = new ConnectionFactory { Uri = new Uri(connectionString), AutomaticRecoveryEnabled = true, // 自动重连 NetworkRecoveryInterval = TimeSpan.FromSeconds(5) }; _connection = factory.CreateConnection(); _channel = _connection.CreateModel(); // 开启 Confirm 模式 _channel.ConfirmSelect(); } public void Publish(string exchange, string routingKey, string message) { var body = Encoding.UTF8.GetBytes(message); var props = _channel.CreateBasicProperties(); props.DeliveryMode = 2; // 持久化 props.ContentType = \u0026#34;application/json\u0026#34;; props.MessageId = Guid.NewGuid().ToString(); _channel.BasicPublish(exchange, routingKey, props, body); _channel.WaitForConfirmsOrDie(TimeSpan.FromSeconds(5)); } public void Dispose() { _channel?.Dispose(); _connection?.Dispose(); } } 6.3 封装 Consumer 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 public class RabbitMqConsumer : IDisposable { private readonly IConnection _connection; private readonly IModel _channel; public RabbitMqConsumer(string connectionString) { var factory = new ConnectionFactory { Uri = new Uri(connectionString), AutomaticRecoveryEnabled = true, DispatchConsumersAsync = true // 异步消费 }; _connection = factory.CreateConnection(); _channel = _connection.CreateModel(); // QoS：每次只推 N 条消息（防止消费者积压） _channel.BasicQos(prefetchSize: 0, prefetchCount: 10, global: false); } public void Subscribe(string queue, Func\u0026lt;string, Task\u0026gt; onMessage) { var consumer = new AsyncEventingBasicConsumer(_channel); consumer.Received += async (model, ea) =\u0026gt; { try { var body = ea.Body.ToArray(); var message = Encoding.UTF8.GetString(body); await onMessage(message); _channel.BasicAck(ea.DeliveryTag, false); } catch (Exception ex) { // 重试次数判断 var retryCount = GetRetryCount(ea); if (retryCount \u0026lt; 3) { // 重新入队 _channel.BasicNack(ea.DeliveryTag, false, requeue: true); } else { // 进入死信 _channel.BasicNack(ea.DeliveryTag, false, requeue: false); } } }; _channel.BasicConsume(queue, autoAck: false, consumer); } private int GetRetryCount(BasicDeliverEventArgs ea) { if (ea.BasicProperties.Headers?.ContainsKey(\u0026#34;x-death\u0026#34;) == true) { var death = ea.BasicProperties.Headers[\u0026#34;x-death\u0026#34;] as List\u0026lt;object\u0026gt;; return death?.Count ?? 0; } return 0; } public void Dispose() { _channel?.Dispose(); _connection?.Dispose(); } } 6.4 使用 MassTransit（更高级的封装） 1 dotnet add package MassTransit.RabbitMQ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 // 注册 builder.Services.AddMassTransit(x =\u0026gt; { x.AddConsumer\u0026lt;OrderCreatedConsumer\u0026gt;(); x.UsingRabbitMq((context, cfg) =\u0026gt; { cfg.Host(\u0026#34;localhost\u0026#34;, \u0026#34;/\u0026#34;, h =\u0026gt; { h.Username(\u0026#34;guest\u0026#34;); h.Password(\u0026#34;guest\u0026#34;); }); // 配置重试 cfg.UseMessageRetry(r =\u0026gt; r.Exponential(3, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(5))); // 配置死信 cfg.ConfigureEndpoints(context); }); }); // 消费者 public class OrderCreatedConsumer : IConsumer\u0026lt;OrderCreatedEvent\u0026gt; { private readonly ILogger\u0026lt;OrderCreatedConsumer\u0026gt; _logger; public OrderCreatedConsumer(ILogger\u0026lt;OrderCreatedConsumer\u0026gt; logger) =\u0026gt; _logger = logger; public async Task Consume(ConsumeContext\u0026lt;OrderCreatedEvent\u0026gt; context) { _logger.LogInformation(\u0026#34;收到订单创建事件：{OrderId}\u0026#34;, context.Message.OrderId); // 处理业务逻辑 await SendSmsAsync(context.Message); } } // 生产者 public class OrderService { private readonly IPublishEndpoint _publishEndpoint; public OrderService(IPublishEndpoint publishEndpoint) =\u0026gt; _publishEndpoint = publishEndpoint; public async Task CreateOrderAsync(Order order) { // 保存订单... // 发布事件 await _publishEndpoint.Publish(new OrderCreatedEvent { OrderId = order.Id, CustomerEmail = order.CustomerEmail, Total = order.Total }); } } 七、集群和高可用 7.1 集群模式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 普通集群： - Queue 只存在于一个节点 - 其他节点可以转发消费请求 - 节点挂了，Queue 不可用 镜像队列（Classic Queue Mirroring）： - Queue 在多个节点有副本 - 主节点写入，同步到从节点 - 主节点挂了，从节点自动提升 Quorum Queue（仲裁队列，推荐）： - RabbitMQ 3.8+ 新模式 - 基于 Raft 协议实现一致性 - 替代镜像队列，性能和可靠性更好 7.2 Quorum Queue 配置 1 2 3 4 5 6 // 声明仲裁队列 var args = new Dictionary\u0026lt;string, object\u0026gt; { { \u0026#34;x-queue-type\u0026#34;, \u0026#34;quorum\u0026#34; } }; channel.QueueDeclare(\u0026#34;order-queue\u0026#34;, durable: true, exclusive: false, autoDelete: false, args); 八、性能调优 8.1 生产端优化 1 2 3 4 批量发送 — 减少 RTT，一次发送多条 Confirm 批量确认 — 多条消息一起确认 异步 Confirm — 注册回调处理 ACK/NACK 连接池 — 复用 Connection 和 Channel 8.2 消费端优化 1 2 3 4 5 6 7 8 Prefetch Count — 控制一次性推送的消息数 太小 → 吞吐量低 太大 → 消费者积压 建议：10-100，根据处理速度调整 多消费者 — 一个 Queue 多个 Consumer 并行消费 多 Channel — 一个 Connection 多个 Channel 手动 ACK — 避免处理一半自动确认 8.3 Broker 端优化 1 2 3 4 消息持久化权衡 — 持久化慢但安全，非持久化快但可能丢 惰性队列 — 消息直接写磁盘，内存占用低，适合百万级堆积 流控 — 内存/磁盘告警时限制生产者发送速度 节点数量 — 3-5 个节点，兼顾可用性和性能 九、运维常用操作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 # 用户管理 rabbitmqctl add_user admin password rabbitmqctl set_user_tags admin administrator rabbitmqctl set_permissions -p / admin \u0026#34;.*\u0026#34; \u0026#34;.*\u0026#34; \u0026#34;.*\u0026#34; # 队列管理 rabbitmqctl list_queues name messages consumers rabbitmqctl list_queues name durable auto_delete # 交换机 rabbitmqctl list_exchanges name type # 连接和通道 rabbitmqctl list_connections rabbitmqctl list_channels # 集群状态 rabbitmqctl cluster_status # 插件 rabbitmq-plugins enable rabbitmq_management # Web 管理界面 rabbitmq-plugins enable rabbitmq_delayed_message_exchange # Web 管理界面 # http://localhost:15672 # 默认账号：guest / guest（只能本地访问） 十、小结 本文深入学习了 RabbitMQ：\n架构（Connection、Channel、VHost、Exchange、Queue、Binding） 五种 Exchange 类型和路由规则 消息可靠性保证（Confirm、持久化、手动 ACK） 死信队列和延迟消息 .NET 实战（RabbitMQ.Client、MassTransit） 集群高可用（镜像队列、Quorum Queue） 性能调优 下一篇将深入 Kafka：架构、存储、生产者/消费者和 .NET 实战。\n","date":"2025-03-01T10:00:00+08:00","permalink":"/posts/middleware/mq/02-rabbitmq-deep-dive/","title":"消息队列（二）：RabbitMQ 深入"},{"content":"写在前面 本文是消息队列系列的第一篇，介绍消息队列的核心概念、常见模式和可靠性保证机制。这些知识是理解 RabbitMQ 和 Kafka 的基础。\n一、为什么需要消息队列 1.1 同步调用的问题 1 2 3 4 5 6 7 8 9 10 11 12 13 14 用户下单流程（同步）： 1. 创建订单 200ms 2. 扣减库存 150ms 3. 发送短信通知 300ms 4. 发放积分 100ms 5. 更新搜索引擎 200ms 总耗时：950ms 问题： - 用户等了近 1 秒才看到结果 - 短信服务挂了，下单也失败 - 新增下游业务需要改下单代码 - 秒杀时流量直接打到数据库 1.2 异步解耦 1 2 3 4 5 6 7 8 9 10 用户下单流程（异步）： 1. 创建订单 200ms 2. 发送消息到 MQ 5ms 总耗时：205ms（用户感知） MQ 下游各自消费： - 短信服务 → 消费消息 → 发短信 - 积分服务 → 消费消息 → 发积分 - 搜索服务 → 消费消息 → 更新索引 1.3 三大核心作用 1 2 3 异步处理 — 非核心逻辑异步化，降低响应时间 系统解耦 — 上游不依赖下游，各自独立演进 流量削峰 — 突发流量堆积在 MQ，后端按能力消费 二、消息模型 2.1 点对点模式（Point-to-Point） 1 2 3 4 5 6 7 8 Producer → Queue → Consumer 特点： - 一条消息只能被一个消费者消费 - 消费后从队列中移除 - 适用：任务分发（一个任务只执行一次） 示例：订单系统中，一个订单只被一个发货服务处理 2.2 发布订阅模式（Pub/Sub） 1 2 3 4 5 6 7 8 9 10 → Consumer A（短信服务） Producer → Topic → Consumer B（积分服务） → Consumer C（搜索服务） 特点： - 一条消息被所有订阅者消费 - 每个消费者有自己的消费进度 - 适用：事件通知（一个事件触发多个动作） 示例：订单创建后，短信、积分、搜索各做各的 2.3 两种模型对比 1 2 3 4 5 点对点 — 一消息一消费，消费即删除，适合任务分发 发布订阅 — 一消息多消费，各自维护进度，适合事件广播 RabbitMQ — 以 Queue 为中心，通过 Exchange 实现两种模式 Kafka — 以 Topic/Partition 为中心，天然发布订阅 三、核心概念详解 3.1 Producer（生产者） 1 2 3 4 5 6 7 职责：创建消息并发送到 MQ 关键决策： - 发到哪个 Topic/Queue？ - 消息格式是什么？（JSON、Protobuf、Avro） - 发送失败怎么处理？（重试、记录、告警） - 要保证消息不丢吗？（确认机制） 3.2 Consumer（消费者） 1 2 3 4 5 6 7 8 职责：从 MQ 拉取消息并处理 关键决策： - 推模式（Push）还是拉模式（Pull）？ - Push：MQ 主动推给消费者（RabbitMQ 默认） - Pull：消费者主动拉取（Kafka 默认） - 处理失败怎么处理？（重试、死信） - 消费进度怎么管理？（自动提交 vs 手动提交） 3.3 Broker（消息代理） 1 2 3 4 5 6 7 8 MQ 服务器本身，负责： - 接收和存储消息 - 路由消息到正确的队列 - 管理消费者连接 - 持久化和副本 RabbitMQ：一个 Broker 就是一个 RabbitMQ Server Kafka：一个集群有多个 Broker，每个 Broker 管理一部分 Partition 3.4 Queue 和 Topic 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Queue（队列）： - RabbitMQ 的核心实体 - FIFO 顺序（默认） - 消息被消费后删除（默认） - 可以设置 TTL、死信等 Topic（主题）： - Kafka 的核心实体 - 一个 Topic 分为多个 Partition - 消息持久化，不会因消费而删除 - 可以被多个 Consumer Group 各自消费 Partition（分区）： - Topic 的物理分片 - 每个 Partition 内消息有序 - 不同 Partition 可以并行消费 - 是 Kafka 高吞吐的关键 3.5 消息结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 一条消息包含： 消息头（Metadata）： - Topic / Queue 名称 - Key（路由键） - 时间戳 - 消息 ID 消息体（Payload）： - 实际的业务数据 - JSON / Protobuf / 二进制 属性： - 优先级 - 过期时间（TTL） - 持久化标志 四、消息路由模式 4.1 直连路由（Direct） 1 2 3 4 5 Producer → Exchange → Queue（routing key 完全匹配） 示例： routing key = \u0026#34;order.created\u0026#34; → 订单队列 routing key = \u0026#34;order.cancelled\u0026#34; → 取消队列 4.2 主题路由（Topic） 1 2 3 4 5 6 7 8 9 10 Producer → Exchange → Queue（routing key 模式匹配） 支持通配符： * 匹配一个单词 # 匹配零或多个单词 示例： \u0026#34;order.*\u0026#34; 匹配 order.created、order.cancelled \u0026#34;order.#\u0026#34; 匹配 order.created.payment、order.cancelled.refund \u0026#34;*.created\u0026#34; 匹配 order.created、user.created 4.3 广播路由（Fanout） 1 2 3 4 Producer → Exchange → 所有绑定的 Queue（忽略 routing key） 示例： 订单创建事件 → 同时发送到短信队列、积分队列、统计队列 4.4 头部路由（Headers） 1 2 不基于 routing key，而是基于消息头部的键值对匹配 比 Topic 更灵活，但性能更低，实际使用较少 五、消息确认机制 5.1 为什么需要确认 1 2 3 4 5 6 7 8 9 10 11 12 消息在传输过程中可能丢失的场景： 生产者 → Broker： 网络抖动，消息没到 Broker Broker 内部： Broker 宕机，内存中的消息丢失 Broker → 消费者： 消费者拿到消息后崩溃，消息丢了 每一步都需要确认机制来保证不丢 5.2 生产者确认 1 2 3 4 5 6 7 8 9 RabbitMQ：Publisher Confirm - 消息成功写入 Broker 后，Broker 回复 ACK - 生产者收到 ACK 才认为发送成功 - 可以批量确认提高性能 Kafka：acks 配置 - acks=0 — 发出去就不管了（最快，可能丢） - acks=1 — Leader 写入成功就返回（默认） - acks=all — 所有副本都写入才返回（最安全） 5.3 消费者确认 1 2 3 4 5 6 7 8 9 10 RabbitMQ：Consumer ACK - 自动确认（autoAck=true）— 消息发出就确认，可能丢 - 手动确认（autoAck=false）— 处理完业务后调用 basic.ack - 拒绝（nack/reject）— 处理失败，可以重入队列或进死信 Kafka：Offset Commit - 自动提交 — 定期提交 offset，可能重复消费 - 手动提交 — 处理完业务后提交 offset - 同步提交 — 阻塞等待提交成功 - 异步提交 — 不阻塞，性能更好但可能丢提交 六、持久化 6.1 为什么需要持久化 1 2 3 MQ 默认把消息存在内存中： - Broker 重启后消息全部丢失 - 生产环境必须考虑持久化 6.2 持久化层次 1 2 3 4 5 6 7 8 9 10 11 RabbitMQ： - Exchange 持久化 — 声明时设置 durable=true - Queue 持久化 — 声明时设置 durable=true - Message 持久化 — 发送时设置 delivery_mode=2 - 三者缺一不可，少一个重启后就丢了 Kafka： - 天然持久化到磁盘（追加写日志） - 通过副本（Replica）保证高可用 - 副本因子（replication.factor）通常设为 3 - 数据保留策略：按时间（如 7 天）或按大小 七、重试和死信 7.1 消费失败的处理 1 2 3 4 5 6 7 8 9 10 消费者处理消息可能失败： - 网络超时 - 数据库暂时不可用 - 业务校验失败 - 代码 Bug 处理策略： 1. 重试 — 失败后重新消费（适用于临时性故障） 2. 死信 — 重试多次后放入死信队列（永久性故障） 3. 丢弃 — 明确不需要的消息直接丢弃 7.2 重试策略 1 2 3 4 立即重试 — 适合瞬时故障（网络抖动） 延迟重试 — 指数退避（1s, 2s, 4s, 8s...） 固定次数 — 超过 N 次进入死信 无限重试 — 不推荐，可能阻塞消费 7.3 死信队列（DLQ） 1 2 3 4 5 6 7 8 9 10 死信产生条件： 1. 消息被拒绝（nack/reject）且不重入队列 2. 消息 TTL 过期 3. 队列满了，无法放入新消息 死信处理： - 人工排查修复 - 定时任务重新投递 - 告警通知 - 永久记录用于审计 八、消息顺序性 8.1 为什么顺序重要 1 2 3 4 5 6 7 8 场景：订单状态变更 创建 → 支付 → 发货 如果消费乱序： 先消费\u0026#34;发货\u0026#34; → 订单状态变为\u0026#34;已发货\u0026#34; 再消费\u0026#34;创建\u0026#34; → 订单状态回退到\u0026#34;待支付\u0026#34; 业务出错！ 8.2 保证顺序的方法 1 2 3 4 5 6 7 8 9 10 11 12 13 RabbitMQ： - 一个 Queue 只有一个 Consumer → 保证顺序但牺牲并发 - 或者用 priority + 去重 → 复杂但可并发 Kafka： - 同一个 Partition 内消息有序 - 相同 Key 的消息发到同一个 Partition - 每个 Partition 只被 Consumer Group 中一个 Consumer 消费 - 所以：相同业务 Key 的消息全局有序 最佳实践： - 需要顺序的消息用相同 Key（如订单 ID） - 不同 Key 的消息可以并行处理 九、消息幂等性 9.1 为什么需要幂等 1 2 3 4 5 6 7 8 9 消息可能被重复消费： - 生产者重试导致重复发送 - 消费者处理完但 ACK 失败，MQ 重新投递 - Rebalance 后重复消费 如果消费逻辑不幂等： 发送短信 → 收到两条 扣减余额 → 扣了两次 发放积分 → 发了两次 9.2 幂等方案 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 方案1：数据库唯一约束 用消息 ID 作为唯一键，重复插入会失败 方案2：乐观锁/版本号 UPDATE account SET balance = balance - 100, version = version + 1 WHERE id = 123 AND version = 5 方案3：状态机 只有特定状态才能转换 订单是\u0026#34;待支付\u0026#34;才能执行\u0026#34;支付成功\u0026#34; 方案4：去重表 消费前先查去重表，处理过就跳过 用消息 ID + 业务类型作为唯一键 方案5：Kafka 事务 / RabbitMQ 幂等生产者 框架层面保证 十、消息投递语义 10.1 三种语义 1 2 3 4 5 6 7 8 9 10 11 At Most Once（最多一次） — 消息可能丢，但不会重复 先提交 offset，再处理消息 适用：日志、监控数据（丢了无所谓） At Least Once（至少一次） — 消息不会丢，但可能重复 先处理消息，再提交 offset 适用：大部分业务场景（配合幂等使用） Exactly Once（精确一次） — 消息不丢不重复 需要事务支持 适用：金融、支付（不能多也不能少） 10.2 各 MQ 支持 1 2 3 4 5 6 7 8 9 RabbitMQ： 默认 At Least Once 通过事务或 Publisher Confirm 实现可靠投递 Exactly Once 需要业务层配合（幂等） Kafka： 默认 At Least Once 通过幂等生产者（enable.idempotence=true）实现 Exactly Once 通过 Kafka 事务实现跨分区的 Exactly Once 十一、常见消息模式 11.1 工作队列（Work Queue） 1 2 3 4 5 6 Producer → Queue → Consumer 1 → Consumer 2 → Consumer 3 多个消费者竞争消费，一条消息只被处理一次 适用：任务分发、耗时操作 11.2 发布订阅（Pub/Sub） 1 2 3 4 5 6 Producer → Topic → Consumer A → Consumer B → Consumer C 一条消息被所有消费者处理 适用：事件通知、数据同步 11.3 路由（Routing） 1 2 3 4 5 6 Producer → Exchange → Queue A（key=error） → Queue B（key=info） → Queue C（key=error,warning） 根据 routing key 分发到不同队列 适用：日志分级、按类型处理 11.4 延迟消息（Delayed Message） 1 2 3 4 Producer → Queue（TTL=30min）→ 死信 Queue → Consumer 消息在队列中等待 30 分钟后过期，进入死信队列被消费 适用：订单超时取消、定时提醒 11.5 RPC（远程过程调用） 1 2 3 4 5 6 7 8 9 Client → Request Queue → Server Client ← Reply Queue ← Server 用消息队列实现 RPC： 1. 客户端发送消息到请求队列，带上 ReplyTo 和 CorrelationId 2. 服务端处理后将结果发到 ReplyTo 指定的队列 3. 客户端根据 CorrelationId 匹配响应 适用：跨服务的同步调用（不推荐，用 gRPC 更好） 十二、小结 本文学习了消息队列的核心概念：\n消息队列的作用（异步、解耦、削峰） 两种消息模型（点对点、发布订阅） 核心概念（Producer、Consumer、Broker、Queue、Topic、Partition） 消息路由模式（Direct、Topic、Fanout） 确认机制和持久化 重试和死信 消息顺序性和幂等性 投递语义（At Most Once / At Least Once / Exactly Once） 常见消息模式 下一篇将深入 RabbitMQ：架构、Exchange、确认机制和 .NET 实战。\n","date":"2025-02-25T10:00:00+08:00","permalink":"/posts/middleware/mq/01-mq-basics/","title":"消息队列（一）：核心概念"},{"content":"写在前面 本文是 Elasticsearch 学习笔记系列的最后一篇，介绍集群架构、节点角色、分片分配、索引生命周期管理和运维监控。前置知识：进阶查询与优化（第四篇）。\n一、集群架构 1.1 节点角色 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 Master 节点（master） - 管理集群元数据（索引、Mapping、分片位置） - 不参与数据存储和查询 - 生产环境建议 3 个专用 Master 节点（避免脑裂） Data 节点（data） - 存储数据和执行查询 - 最核心的节点，IO 和 CPU 消耗最大 - 根据数据量增加 Data 节点 Coordinating 节点 - 接收客户端请求，分发到 Data 节点，汇总返回 - 不存储数据，不做 Master - 适合做查询入口，减轻 Data 节点压力 Ingest 节点（ingest） - 数据预处理（Pipeline） - 类似 Logstash 的功能 ML 节点（ml） - 机器学习任务（异常检测等） 1.2 最小生产集群 1 2 3 3 Master 节点 — 高可用（2 个挂了还能选主） 3 Data 节点 — 数据存储和查询（副本需要 3 节点） 2 协调节点 — 查询入口（可选） 1.3 配置节点角色 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 # elasticsearch.yml # Master 节点 node.roles: [master] # Data 节点 node.roles: [data] # 协调节点 node.roles: [] # Data + Master（小规模，合一部署） node.roles: [data, master] # Data Hot / Warm / Cold（冷热分层） node.roles: [data_hot] node.roles: [data_warm] node.roles: [data_cold] 二、分片和副本 2.1 分片分配 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 创建索引时指定分片数： PUT /products { \u0026#34;settings\u0026#34;: { \u0026#34;number_of_shards\u0026#34;: 3, \u0026#34;number_of_replicas\u0026#34;: 1 } } 3 个主分片 + 3 个副本分片 = 6 个分片 分片分配规则： - 主分片和副本不在同一节点 - 尽量均匀分布到各节点 - 分片数创建后不能修改（只能 reindex） 2.2 副本的作用 1 2 3 4 5 6 7 1. 高可用 — 主分片挂了，副本自动提升为主 2. 负载均衡 — 查询可以命中主分片或副本（读性能翻倍） 3. 容灾 — 节点故障不影响数据完整性 副本数可以动态调整： PUT /products/_settings { \u0026#34;number_of_replicas\u0026#34;: 2 } 2.3 分片数规划 1 2 3 4 5 6 7 8 9 10 11 12 经验值： 每个分片 10-50GB 每个节点不超过 20 个分片/GB 堆内存 计算： 数据量 100GB，每分片 30GB → 4 个主分片 3 节点集群 → 每个节点约 1-2 个主分片 + 副本 避免： - 分片太小（\u0026lt; 1GB）→ 集群管理开销大 - 分片太大（\u0026gt; 50GB）→ 恢复慢，查询慢 - 分片数过多（\u0026gt; 1000/节点）→ 内存压力 三、分片分配策略 3.1 分片过滤 1 2 3 4 5 6 7 8 9 // 节点级别打标签 // elasticsearch.yml // node.attr.box_type: hot // 索引分配到指定节点 PUT /products/_settings { \u0026#34;index.routing.allocation.require.box_type\u0026#34;: \u0026#34;hot\u0026#34; } 3.2 冷热架构 1 2 3 4 5 6 7 8 9 // Hot 节点：SSD，存储近期热数据 // Warm 节点：HDD，存储较冷数据 // Cold 节点：低成本存储，归档数据 // 索引从热迁移到温 PUT /products-2026-03/_settings { \u0026#34;index.routing.allocation.require.box_type\u0026#34;: \u0026#34;warm\u0026#34; } 四、索引生命周期管理（ILM） 4.1 ILM 策略 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 PUT /_ilm/policy/logs-policy { \u0026#34;policy\u0026#34;: { \u0026#34;phases\u0026#34;: { \u0026#34;hot\u0026#34;: { \u0026#34;min_age\u0026#34;: \u0026#34;0ms\u0026#34;, \u0026#34;actions\u0026#34;: { \u0026#34;rollover\u0026#34;: { \u0026#34;max_primary_shard_size\u0026#34;: \u0026#34;30gb\u0026#34;, \u0026#34;max_age\u0026#34;: \u0026#34;7d\u0026#34; }, \u0026#34;set_priority\u0026#34;: { \u0026#34;priority\u0026#34;: 100 } } }, \u0026#34;warm\u0026#34;: { \u0026#34;min_age\u0026#34;: \u0026#34;7d\u0026#34;, \u0026#34;actions\u0026#34;: { \u0026#34;shrink\u0026#34;: { \u0026#34;number_of_shards\u0026#34;: 1 }, \u0026#34;forcemerge\u0026#34;: { \u0026#34;max_num_segments\u0026#34;: 1 }, \u0026#34;allocate\u0026#34;: { \u0026#34;require\u0026#34;: { \u0026#34;box_type\u0026#34;: \u0026#34;warm\u0026#34; } }, \u0026#34;set_priority\u0026#34;: { \u0026#34;priority\u0026#34;: 50 } } }, \u0026#34;cold\u0026#34;: { \u0026#34;min_age\u0026#34;: \u0026#34;30d\u0026#34;, \u0026#34;actions\u0026#34;: { \u0026#34;allocate\u0026#34;: { \u0026#34;require\u0026#34;: { \u0026#34;box_type\u0026#34;: \u0026#34;cold\u0026#34; } }, \u0026#34;set_priority\u0026#34;: { \u0026#34;priority\u0026#34;: 0 } } }, \u0026#34;delete\u0026#34;: { \u0026#34;min_age\u0026#34;: \u0026#34;90d\u0026#34;, \u0026#34;actions\u0026#34;: { \u0026#34;delete\u0026#34;: {} } } } } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 生命周期阶段： hot — 活跃数据，频繁读写，SSD 存储 warm — 较少查询，只读，HDD 存储 cold — 极少查询，低成本存储 delete — 过期删除 触发条件（rollover）： max_primary_shard_size — 主分片达到大小 max_age — 索引存在时间 max_docs — 文档数量 自动操作： rollover — 滚动创建新索引 shrink — 减少分片数（冷数据不需要多分片） forcemerge — 合并 Segment（减少文件数） allocate — 迁移到指定节点 delete — 删除索引 4.2 使用 ILM 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 // 索引模板中关联 ILM 策略 PUT /_index_template/logs_template { \u0026#34;index_patterns\u0026#34;: [\u0026#34;logs-*\u0026#34;], \u0026#34;template\u0026#34;: { \u0026#34;settings\u0026#34;: { \u0026#34;number_of_shards\u0026#34;: 3, \u0026#34;number_of_replicas\u0026#34;: 1, \u0026#34;index.lifecycle.name\u0026#34;: \u0026#34;logs-policy\u0026#34;, \u0026#34;index.lifecycle.rollover_alias\u0026#34;: \u0026#34;logs\u0026#34; } } } // 创建第一个索引 PUT /logs-000001 { \u0026#34;aliases\u0026#34;: { \u0026#34;logs\u0026#34;: { \u0026#34;is_write_index\u0026#34;: true } } } // 写入 logs 别名 → 自动写入 logs-000001 // 7 天后或 30GB → 自动创建 logs-000002 → 切换写入 // 30 天后 → 迁移到 warm 节点 // 90 天后 → 自动删除 五、集群监控 5.1 集群健康 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 集群整体健康 GET /_cluster/health // status: green / yellow / red // green — 所有主分片和副本都正常 // yellow — 所有主分片正常，部分副本缺失 // red — 部分主分片不可用（数据丢失风险） // 节点状态 GET /_cat/nodes?v // 显示：name, ip, heap.percent, ram.percent, cpu, load_1m, node.role // 分片状态 GET /_cat/shards?v // 显示哪些分片在哪个节点，是否正常 // 未分配分片 GET /_cat/shards?v\u0026amp;h=index,shard,pristate,state,unassigned.reason\u0026amp;s=state 5.2 索引监控 1 2 3 4 5 6 7 8 // 索引大小和文档数 GET /_cat/indices?v\u0026amp;h=index,docs.count,store.size,pri.store.size\u0026amp;s=store.size:desc // 索引健康 GET /_cluster/health?level=indices // 分片详情 GET /_cat/shards/products?v 5.3 性能指标 1 2 3 4 5 6 7 8 9 10 11 // 搜索性能 GET /_stats/search?human // 索引性能 GET /_stats/indexing?human // 合并性能 GET /_stats/merges?human // 段信息 GET /products/_segments 5.4 慢查询日志 1 2 3 4 5 6 7 8 9 // 开启慢查询日志 PUT /products/_settings { \u0026#34;index.search.slowlog.threshold.query.warn\u0026#34;: \u0026#34;5s\u0026#34;, \u0026#34;index.search.slowlog.threshold.query.info\u0026#34;: \u0026#34;2s\u0026#34;, \u0026#34;index.search.slowlog.threshold.fetch.warn\u0026#34;: \u0026#34;1s\u0026#34;, \u0026#34;index.indexing.slowlog.threshold.index.warn\u0026#34;: \u0026#34;10s\u0026#34; } // 超过阈值的查询会记录到慢查询日志 六、备份和恢复 6.1 创建快照仓库 1 2 3 4 5 6 7 8 // 注册仓库（文件系统） PUT /_snapshot/my_backup { \u0026#34;type\u0026#34;: \u0026#34;fs\u0026#34;, \u0026#34;settings\u0026#34;: { \u0026#34;location\u0026#34;: \u0026#34;/data/es_backup\u0026#34; } } 6.2 创建快照 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 备份指定索引 PUT /_snapshot/my_backup/snapshot_20260530 { \u0026#34;indices\u0026#34;: \u0026#34;products,orders\u0026#34;, \u0026#34;ignore_unavailable\u0026#34;: true, \u0026#34;include_global_state\u0026#34;: false } // 备份所有索引 PUT /_snapshot/my_backup/snapshot_all // 查看快照进度 GET /_snapshot/my_backup/snapshot_20260530/_status // 查看所有快照 GET /_cat/snapshots/my_backup?v 6.3 恢复 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 从快照恢复 POST /_snapshot/my_backup/snapshot_20260530/_restore { \u0026#34;indices\u0026#34;: \u0026#34;products\u0026#34;, \u0026#34;index_settings\u0026#34;: { \u0026#34;index.number_of_replicas\u0026#34;: 1 } } // 恢复到新索引名 POST /_snapshot/my_backup/snapshot_20260530/_restore { \u0026#34;indices\u0026#34;: \u0026#34;products\u0026#34;, \u0026#34;rename_pattern\u0026#34;: \u0026#34;(.+)\u0026#34;, \u0026#34;rename_replacement\u0026#34;: \u0026#34;$1_restored\u0026#34; } // 恢复为 products_restored 七、常见运维操作 7.1 节点维护 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 排空节点上的分片（维护前） PUT /_cluster/settings { \u0026#34;transient\u0026#34;: { \u0026#34;cluster.routing.allocation.exclude._ip\u0026#34;: \u0026#34;192.168.1.100\u0026#34; } } // ES 会自动将该节点上的分片迁移到其他节点 // 迁移完成后可以安全停机 // 维护完成后恢复 PUT /_cluster/settings { \u0026#34;transient\u0026#34;: { \u0026#34;cluster.routing.allocation.exclude._ip\u0026#34;: null } } 7.2 索引操作 1 2 3 4 5 6 7 8 9 10 11 12 // 强制合并段（减少 Segment 数量，降低资源消耗） POST /products/_forcemerge?max_num_segments=1 // 适合只读索引 // 清理缓存 POST /products/_cache/clear // 刷新 POST /products/_refresh // 刷盘 POST /products/_flush 7.3 Reindex（重建索引） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 // 从旧索引重建到新索引（修改 Mapping 后常用） POST /_reindex { \u0026#34;source\u0026#34;: { \u0026#34;index\u0026#34;: \u0026#34;products-v1\u0026#34; }, \u0026#34;dest\u0026#34;: { \u0026#34;index\u0026#34;: \u0026#34;products-v2\u0026#34; } } // 带查询条件重建 POST /_reindex { \u0026#34;source\u0026#34;: { \u0026#34;index\u0026#34;: \u0026#34;products-v1\u0026#34;, \u0026#34;query\u0026#34;: { \u0026#34;range\u0026#34;: { \u0026#34;created_at\u0026#34;: { \u0026#34;gte\u0026#34;: \u0026#34;2026-01-01\u0026#34; } } } }, \u0026#34;dest\u0026#34;: { \u0026#34;index\u0026#34;: \u0026#34;products-v2\u0026#34; } } // 远程集群重建（迁移数据） POST /_reindex { \u0026#34;source\u0026#34;: { \u0026#34;remote\u0026#34;: { \u0026#34;host\u0026#34;: \u0026#34;http://old-cluster:9200\u0026#34; }, \u0026#34;index\u0026#34;: \u0026#34;products\u0026#34; }, \u0026#34;dest\u0026#34;: { \u0026#34;index\u0026#34;: \u0026#34;products\u0026#34; } } 八、常见问题排查 8.1 集群 Red 1 2 3 4 5 6 7 8 9 10 11 12 13 1. 查看哪个索引有问题 GET /_cluster/health?level=indices 2. 查看未分配的分片 GET /_cat/shards?v\u0026amp;h=index,shard,prirep,state,unassigned.reason 3. 查看未分配原因 GET /_cluster/allocation/explain 4. 常见原因： - 节点宕机 - 磁盘满了（水位超过 flood-stage） - 副本数超过节点数 8.2 磁盘水位 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ES 磁盘水位阈值： cluster.routing.allocation.disk.watermark.low: 85%（默认） → 不再分配新分片到该节点 cluster.routing.allocation.disk.watermark.high: 90%（默认） → 开始迁移该节点上的分片 cluster.routing.allocation.disk.watermark.flood_stage: 95%（默认） → 所有索引设为只读 恢复方法： 1. 清理磁盘空间 2. 解除只读 PUT /products/_settings { \u0026#34;index.blocks.read_only_allow_delete\u0026#34;: null } 8.3 性能慢 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 排查思路： 1. 是写入慢还是查询慢？ GET /_stats/indexing,search?human 2. 查看慢查询日志 检查 ES 日志目录中的慢查询记录 3. 检查 JVM 堆内存 GET /_cat/nodes?v\u0026amp;h=name,heap.percent,ram.percent,cpu 堆使用 \u0026gt; 85% → 增加 JVM 内存或优化查询 4. 检查 GC 情况 GET /_nodes/stats/jvm?filter_path=*.jvm.gc 5. 检查线程池 GET /_cat/thread_pool?v\u0026amp;h=node_name,name,active,rejected,completed rejected \u0026gt; 0 → 请求被拒绝，需要优化或扩容 九、系列总结 9.1 知识体系回顾 1 2 3 4 5 6 7 8 9 10 11 12 13 14 第一篇：基础入门 - 核心概念、倒排索引、索引管理、Mapping 第二篇：文档操作与搜索 - CRUD、Bulk、全文搜索、精确搜索、bool 复合查询 第三篇：聚合分析 - 指标聚合、桶聚合、嵌套聚合、实战场景 第四篇：进阶查询与优化 - 深度分页、分词器、中文分词、评分调优、性能优化 第五篇：集群与运维 - 节点角色、分片分配、ILM、监控、备份恢复 9.2 核心要点 1 2 3 4 5 6 7 8 9 10 1. ES 的核心是倒排索引，理解它就理解了搜索的本质 2. Mapping 要提前设计，生产环境不要依赖动态映射 3. text 字段做全文搜索，keyword 字段做精确匹配和聚合 4. 查询条件用 filter 不用 must（性能差距显著） 5. 中文场景必须用 IK 分词器 6. 深分页用 search_after，不要用 from/size 7. 日志类数据用 ILM 自动管理生命周期 8. 分片规划：每分片 10-50GB，不要太多也不要太少 9. 集群至少 3 个 Master 节点保证高可用 10. 磁盘水位和 JVM 堆内存是最常出问题的地方 ","date":"2025-02-17T10:00:00+08:00","permalink":"/posts/middleware/es/05-es-cluster/","title":"Elasticsearch 学习笔记（五）：集群与运维"},{"content":"写在前面 本文是 Elasticsearch 学习笔记系列的第四篇，介绍进阶查询技巧、分词器、中文分词、搜索调优和索引性能优化。前置知识：聚合分析（第三篇）。\n一、深度分页问题 1.1 from/size 的问题 1 2 3 4 5 6 7 8 9 10 11 12 from + size 的工作方式： 假设 5 个分片，请求 from=9990, size=10 1. 协调节点向 5 个分片各请求前 10000 条 2. 每个分片返回 10000 条（共 50000 条） 3. 协调节点合并排序 50000 条 4. 取第 9991-10000 条返回 从 = 9990 时，实际处理了 50000 条数据 from 越大，性能越差 ES 默认限制 from + size \u0026lt;= 10000 1.2 解决方案 1 2 3 4 5 6 7 8 9 10 11 12 方案1：search_after（推荐，实时翻页） 适合：UI 上的\u0026#34;加载更多\u0026#34;/\u0026#34;下一页\u0026#34; 特点：基于排序值定位，性能恒定，不能跳页 方案2：scroll（适合批量导出） 适合：全量数据导出、数据迁移 特点：创建快照，不适合实时查询 方案3：增大 max_result_window（不推荐） PUT /products/_settings { \u0026#34;index.max_result_window\u0026#34;: 50000 } 只是提高上限，深分页性能问题没解决 1.3 search_after 详解 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 第1次查询 GET /products/_search { \u0026#34;size\u0026#34;: 100, \u0026#34;sort\u0026#34;: [ { \u0026#34;created_at\u0026#34;: \u0026#34;desc\u0026#34; }, { \u0026#34;_id\u0026#34;: \u0026#34;asc\u0026#34; } ] } // 返回结果中最后一条的 sort 值：[\u0026#34;2026-03-15\u0026#34;, \u0026#34;42\u0026#34;] // 第2次查询 GET /products/_search { \u0026#34;size\u0026#34;: 100, \u0026#34;sort\u0026#34;: [ { \u0026#34;created_at\u0026#34;: \u0026#34;desc\u0026#34; }, { \u0026#34;_id\u0026#34;: \u0026#34;asc\u0026#34; } ], \u0026#34;search_after\u0026#34;: [\u0026#34;2026-03-15\u0026#34;, \u0026#34;42\u0026#34;] } // 基于上一页最后的排序值继续查 // 注意：sort 字段必须唯一（用 _id 兜底） 二、分词器详解 2.1 分词流程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 输入文本 → Character Filters → Tokenizer → Token Filters → 倒排索引 Character Filters（字符过滤器）： - 去除 HTML 标签 - 字符替换 - 正则替换 Tokenizer（分词器）： - 按规则拆分为 Token（词元） - standard：按单词边界切分 - whitespace：按空格切分 Token Filters（词元过滤器）： - 小写转换 - 去停用词（the, a, is） - 词干提取（running → run） - 同义词替换 2.2 内置分词器 1 2 3 4 5 6 standard — 标准分词器（默认），按单词边界切分 + 小写 simple — 按非字母字符切分 + 小写 whitespace — 按空格切分（不做小写） keyword — 不分词，整体作为一个 Token pattern — 按正则表达式切分 language — 特定语言分词（english、french 等） 2.3 测试分词效果 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 使用 _analyze API 测试 GET /_analyze { \u0026#34;analyzer\u0026#34;: \u0026#34;standard\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;华为 Mate 60 Pro 手机\u0026#34; } // 返回分词结果：[\u0026#34;华为\u0026#34;, \u0026#34;mate\u0026#34;, \u0026#34;60\u0026#34;, \u0026#34;pro\u0026#34;, \u0026#34;手\u0026#34;, \u0026#34;机\u0026#34;] // 指定索引的某个字段分析器 GET /products/_analyze { \u0026#34;field\u0026#34;: \u0026#34;name\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;华为 Mate 60 Pro\u0026#34; } // 查看索引字段的分词结果 GET /products/_doc/1/_termvectors?fields=name 三、中文分词 3.1 问题：standard 对中文效果差 1 2 3 4 5 standard 分词器处理中文： \u0026#34;华为旗舰手机\u0026#34; → [\u0026#34;华\u0026#34;, \u0026#34;为\u0026#34;, \u0026#34;旗\u0026#34;, \u0026#34;舰\u0026#34;, \u0026#34;手\u0026#34;, \u0026#34;机\u0026#34;] 问题：每个字作为一个词，无法按词搜索 搜索 \u0026#34;旗舰\u0026#34; 时，\u0026#34;旗\u0026#34; 和 \u0026#34;舰\u0026#34; 分别在不同的文档中也会匹配 3.2 IK 分词器 1 2 3 4 5 安装（ES 8.15）： ./bin/elasticsearch-plugin install \\ https://github.com/infinilabs/analysis-ik/releases/download/v8.15.0/elasticsearch-analysis-ik-8.15.0.zip 安装后重启 ES 3.3 IK 两种模式 1 2 3 4 5 ik_max_word — 最细粒度切分（索引时用） \u0026#34;华为旗舰手机\u0026#34; → [\u0026#34;华为\u0026#34;, \u0026#34;旗舰\u0026#34;, \u0026#34;手机\u0026#34;] ik_smart — 最粗粒度切分（搜索时用） \u0026#34;华为旗舰手机\u0026#34; → [\u0026#34;华为\u0026#34;, \u0026#34;旗舰\u0026#34;, \u0026#34;手机\u0026#34;] 3.4 使用 IK 分词器 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // 创建索引时指定 PUT /products { \u0026#34;mappings\u0026#34;: { \u0026#34;properties\u0026#34;: { \u0026#34;name\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;text\u0026#34;, \u0026#34;analyzer\u0026#34;: \u0026#34;ik_max_word\u0026#34;, \u0026#34;search_analyzer\u0026#34;: \u0026#34;ik_smart\u0026#34; }, \u0026#34;description\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;text\u0026#34;, \u0026#34;analyzer\u0026#34;: \u0026#34;ik_max_word\u0026#34;, \u0026#34;search_analyzer\u0026#34;: \u0026#34;ik_smart\u0026#34; } } } } // analyzer — 索引时使用的分词器（细粒度，多建倒排索引） // search_analyzer — 搜索时使用的分词器（粗粒度，精确匹配） 3.5 自定义词库 1 2 3 4 5 6 7 8 9 10 // 扩展词典（添加新词） // config/IKAnalyzer.cfg.xml \u0026lt;properties\u0026gt; \u0026lt;entry key=\u0026#34;ext_dict\u0026#34;\u0026gt;custom.dic\u0026lt;/entry\u0026gt; \u0026lt;/properties\u0026gt; // custom.dic（每行一个词） Mate60 鸿蒙 麒麟芯片 3.6 IK 分词测试 1 2 3 4 5 6 7 8 9 10 11 12 13 GET /_analyze { \u0026#34;analyzer\u0026#34;: \u0026#34;ik_max_word\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;华为旗舰手机搭载麒麟芯片\u0026#34; } // [\u0026#34;华为\u0026#34;, \u0026#34;旗舰\u0026#34;, \u0026#34;手机\u0026#34;, \u0026#34;搭载\u0026#34;, \u0026#34;麒麟\u0026#34;, \u0026#34;芯片\u0026#34;] GET /_analyze { \u0026#34;analyzer\u0026#34;: \u0026#34;ik_smart\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;华为旗舰手机搭载麒麟芯片\u0026#34; } // [\u0026#34;华为\u0026#34;, \u0026#34;旗舰\u0026#34;, \u0026#34;手机\u0026#34;, \u0026#34;搭载\u0026#34;, \u0026#34;麒麟芯片\u0026#34;] 四、相关性评分调优 4.1 评分机制（BM25） 1 2 3 4 5 6 7 8 9 10 ES 默认使用 BM25 算法计算相关性评分 影响因素： 1. 词频（TF） — 搜索词在文档中出现的次数越多，分数越高 2. 文档频率（IDF） — 搜索词在整个索引中越罕见，分数越高 3. 字段长度 — 字段越短，匹配到的词越重要 搜索 \u0026#34;华为\u0026#34;： 文档A：\u0026#34;华为 Mate 60 Pro\u0026#34;（短字段，\u0026#34;华为\u0026#34; 占比高）→ 高分 文档B：\u0026#34;这是一款华为生产的旗舰手机，搭载麒麟芯片...\u0026#34;（长字段）→ 低分 4.2 function_score（自定义评分） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 // 搜索 \u0026#34;手机\u0026#34;，价格低的排名靠前 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;function_score\u0026#34;: { \u0026#34;query\u0026#34;: { \u0026#34;match\u0026#34;: { \u0026#34;name\u0026#34;: \u0026#34;手机\u0026#34; } }, \u0026#34;functions\u0026#34;: [ { \u0026#34;field_value_factor\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;rating\u0026#34;, \u0026#34;factor\u0026#34;: 2, \u0026#34;modifier\u0026#34;: \u0026#34;sqrt\u0026#34; } } ], \u0026#34;boost_mode\u0026#34;: \u0026#34;multiply\u0026#34; } } } // modifier 选项： // none — 不处理 // log — 取对数（压缩范围） // sqrt — 平方根 // square — 平方 // reciprocal — 1/x 4.3 boosting（提升/降低权重） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 搜索 \u0026#34;手机\u0026#34;，华为品牌加权，小品牌降权 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;boosting\u0026#34;: { \u0026#34;positive\u0026#34;: { \u0026#34;bool\u0026#34;: { \u0026#34;must\u0026#34;: [ { \u0026#34;match\u0026#34;: { \u0026#34;name\u0026#34;: \u0026#34;手机\u0026#34; } } ], \u0026#34;should\u0026#34;: [ { \u0026#34;term\u0026#34;: { \u0026#34;brand\u0026#34;: { \u0026#34;value\u0026#34;: \u0026#34;华为\u0026#34;, \u0026#34;boost\u0026#34;: 2 } } } ] } }, \u0026#34;negative\u0026#34;: { \u0026#34;term\u0026#34;: { \u0026#34;brand\u0026#34;: \u0026#34;山寨\u0026#34; } }, \u0026#34;negative_boost\u0026#34;: 0.5 } } } // positive 匹配的正常评分 // negative 匹配的评分 × 0.5 4.4 constant_score 1 2 3 4 5 6 7 8 9 10 11 12 // 不计算相关性评分，所有匹配的文档评分都一样（适合纯过滤） GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;constant_score\u0026#34;: { \u0026#34;filter\u0026#34;: { \u0026#34;term\u0026#34;: { \u0026#34;category\u0026#34;: \u0026#34;手机\u0026#34; } }, \u0026#34;boost\u0026#34;: 1.2 } } } 五、索引性能优化 5.1 Bulk 批量写入 1 2 3 4 5 6 7 8 9 10 11 最佳实践： - 每批 1000-5000 条文档 - 批量大小 5-15MB - 多线程并发写入 // 批量写入示例 POST /_bulk {\u0026#34;index\u0026#34;:{\u0026#34;_index\u0026#34;:\u0026#34;products\u0026#34;,\u0026#34;_id\u0026#34;:\u0026#34;1\u0026#34;}} {\u0026#34;name\u0026#34;:\u0026#34;华为 Mate 60\u0026#34;,\u0026#34;price\u0026#34;:6999} {\u0026#34;index\u0026#34;:{\u0026#34;_index\u0026#34;:\u0026#34;products\u0026#34;,\u0026#34;_id\u0026#34;:\u0026#34;2\u0026#34;}} {\u0026#34;name\u0026#34;:\u0026#34;小米 14\u0026#34;,\u0026#34;price\u0026#34;:3999} 5.2 Refresh 和 Flush 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 写入流程： 1. 写入 Index Buffer 2. Refresh（默认 1 秒）→ Buffer 写入新的 Segment → 可搜索 3. Flush → Segment 持久化到磁盘 + 清除 Translog 调优： - 提高刷新间隔（批量写入时） PUT /products/_settings { \u0026#34;index.refresh_interval\u0026#34;: \u0026#34;30s\u0026#34; } - 批量写入完成后恢复 PUT /products/_settings { \u0026#34;index.refresh_interval\u0026#34;: \u0026#34;1s\u0026#34; } - 批量导入时可以设为 -1（关闭自动刷新） 导入完成后手动刷新：POST /products/_refresh 5.3 Translog 配置 1 2 3 4 5 6 7 PUT /products/_settings { \u0026#34;index.translog.durability\u0026#34;: \u0026#34;async\u0026#34;, \u0026#34;index.translog.sync_interval\u0026#34;: \u0026#34;30s\u0026#34; } // async：异步刷盘，性能更好但有丢失风险 // request：每次请求都刷盘（默认，安全但慢） 5.4 索引设计优化 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 1. 合理设置分片数 - 小索引（\u0026lt; 1GB）：1 个分片 - 中等索引（1-50GB）：3-5 个分片 - 大索引（\u0026gt; 50GB）：按 30-50GB/分片计算 2. 避免过多字段 - 字段过多 → Mapping 膨胀 → 性能下降 - 不需要搜索的字段设置 \u0026#34;index\u0026#34;: false - 不需要聚合的字段设置 \u0026#34;doc_values\u0026#34;: false 3. 使用 _source 过滤 - 不需要返回的大字段可以用 store + _source excludes 4. 索引按时间滚动 - logs-2026.05.01, logs-2026.05.02 - 方便删除过期数据（直接删整个索引） 5.5 查询优化 1 2 3 4 5 6 1. 用 filter 代替 query（不评分，会缓存） 2. 避免深分页（用 search_after） 3. 只返回需要的字段（_source 过滤） 4. 避免用 wildcard/regexp 前缀通配符（如 *华为） 5. 控制返回的桶数量（terms 的 size） 6. 避免在高基数字段上做聚合 六、Kibana 使用技巧 6.1 Dev Tools 1 2 3 4 5 Kibana Dev Tools 快捷键： Ctrl + Enter — 执行当前请求 Ctrl + / — 注释/取消注释 Ctrl + Space — 自动补全 Ctrl + ↑/↓ — 跳转到上/下一个请求 6.2 Discover（数据浏览） 1 2 3 4 5 1. 选择索引模式（如 logs-*） 2. 设置时间范围 3. 用 KQL 或 Lucene 语法过滤 4. 添加字段列显示 5. 保存搜索条件 6.3 Dashboard（仪表盘） 1 2 3 4 5 6 7 8 1. 创建可视化（Visualization） - 柱状图、饼图、折线图、数据表格 - 基于聚合结果展示 2. 组合到 Dashboard - 多个可视化组件排布 - 全局时间过滤 - 共享给团队 七、小结 本文学习了进阶查询与优化：\n深度分页问题（search_after、scroll） 分词器详解（Analyzer、Tokenizer、Token Filter） 中文分词（IK 分词器、自定义词库） 相关性评分调优（BM25、function_score、boosting） 索引性能优化（Bulk、Refresh、Flush、分片设计） 查询优化技巧 Kibana 使用技巧 下一篇将学习集群运维：节点角色、分片分配、ILM 和监控排查。\n","date":"2025-02-13T10:00:00+08:00","permalink":"/posts/middleware/es/04-es-advanced/","title":"Elasticsearch 学习笔记（四）：进阶查询与优化"},{"content":"写在前面 本文是 Elasticsearch 学习笔记系列的第三篇，介绍 ES 的聚合分析框架：指标聚合、桶聚合、嵌套聚合和实战统计场景。前置知识：文档操作与搜索（第二篇）。\n一、聚合分析概述 1.1 什么是聚合 聚合（Aggregation）是对数据进行统计计算和分析，类似 SQL 的 GROUP BY + 聚合函数。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 SQL： SELECT brand, COUNT(*), AVG(price) FROM products WHERE category = \u0026#39;手机\u0026#39; GROUP BY brand ORDER BY COUNT(*) DESC ES 聚合： \u0026#34;query\u0026#34;: { \u0026#34;term\u0026#34;: { \u0026#34;category\u0026#34;: \u0026#34;手机\u0026#34; } }, \u0026#34;aggs\u0026#34;: { \u0026#34;by_brand\u0026#34;: { \u0026#34;terms\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;brand\u0026#34; }, \u0026#34;aggs\u0026#34;: { \u0026#34;avg_price\u0026#34;: { \u0026#34;avg\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;price\u0026#34; } } } } } 1.2 聚合分类 1 2 3 指标聚合（Metric） — 计算数值指标（avg、sum、max、min） 桶聚合（Bucket） — 按规则分组，每组一个桶（terms、date_histogram） 管道聚合（Pipeline） — 基于其他聚合的结果再聚合 二、指标聚合（Metric） 2.1 基本统计 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // 单个指标 GET /products/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;aggs\u0026#34;: { \u0026#34;avg_price\u0026#34;: { \u0026#34;avg\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;price\u0026#34; } }, \u0026#34;max_price\u0026#34;: { \u0026#34;max\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;price\u0026#34; } }, \u0026#34;min_price\u0026#34;: { \u0026#34;min\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;price\u0026#34; } }, \u0026#34;sum_price\u0026#34;: { \u0026#34;sum\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;price\u0026#34; } } } } // stats 一次返回多个指标 GET /products/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;aggs\u0026#34;: { \u0026#34;price_stats\u0026#34;: { \u0026#34;stats\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;price\u0026#34; } } } } // 返回：count, min, max, avg, sum 2.2 去重计数 1 2 3 4 5 6 7 8 9 10 GET /products/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;aggs\u0026#34;: { \u0026#34;unique_brands\u0026#34;: { \u0026#34;cardinality\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;brand\u0026#34; } } } } // 类似 SQL 的 COUNT(DISTINCT brand) // 注意：cardinality 是近似值（HyperLogLog 算法） // precision_threshold 控制精度（默认 3000） 2.3 百分位统计 1 2 3 4 5 6 7 8 9 10 11 12 13 GET /products/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;aggs\u0026#34;: { \u0026#34;price_percentiles\u0026#34;: { \u0026#34;percentiles\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;price\u0026#34;, \u0026#34;percents\u0026#34;: [25, 50, 75, 95, 99] } } } } // 返回各百分位的价格值 2.4 文档计数 1 2 3 4 5 6 7 8 GET /products/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;aggs\u0026#34;: { \u0026#34;value_count\u0026#34;: { \u0026#34;value_count\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;price\u0026#34; } } } } // 有 price 字段的文档数量 三、桶聚合（Bucket） 3.1 terms（按字段值分组） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 GET /products/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;aggs\u0026#34;: { \u0026#34;by_brand\u0026#34;: { \u0026#34;terms\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;brand\u0026#34;, \u0026#34;size\u0026#34;: 20 } } } } // 按品牌分组，返回每个品牌的文档数 // size 控制返回多少个桶（默认 10） // 排序 GET /products/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;aggs\u0026#34;: { \u0026#34;by_brand\u0026#34;: { \u0026#34;terms\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;brand\u0026#34;, \u0026#34;size\u0026#34;: 20, \u0026#34;order\u0026#34;: { \u0026#34;_key\u0026#34;: \u0026#34;asc\u0026#34; } // 按品牌名排序 // \u0026#34;order\u0026#34;: { \u0026#34;_count\u0026#34;: \u0026#34;desc\u0026#34; } // 按文档数排序（默认） } } } } 3.2 date_histogram（按时间分组） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 // 按月统计商品数量 GET /products/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;aggs\u0026#34;: { \u0026#34;by_month\u0026#34;: { \u0026#34;date_histogram\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;created_at\u0026#34;, \u0026#34;calendar_interval\u0026#34;: \u0026#34;month\u0026#34;, \u0026#34;format\u0026#34;: \u0026#34;yyyy-MM\u0026#34; } } } } // 按天统计 GET /products/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;aggs\u0026#34;: { \u0026#34;by_day\u0026#34;: { \u0026#34;date_histogram\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;created_at\u0026#34;, \u0026#34;calendar_interval\u0026#34;: \u0026#34;day\u0026#34;, \u0026#34;format\u0026#34;: \u0026#34;yyyy-MM-dd\u0026#34;, \u0026#34;min_doc_count\u0026#34;: 0, \u0026#34;extended_bounds\u0026#34;: { \u0026#34;min\u0026#34;: \u0026#34;2026-01-01\u0026#34;, \u0026#34;max\u0026#34;: \u0026#34;2026-06-30\u0026#34; } } } } } // min_doc_count: 0 — 没有数据的日期也返回（补零） // extended_bounds — 扩展时间范围 1 2 3 4 5 6 7 时间间隔选项： calendar_interval: \u0026#34;minute\u0026#34;, \u0026#34;hour\u0026#34;, \u0026#34;day\u0026#34;, \u0026#34;week\u0026#34;, \u0026#34;month\u0026#34;, \u0026#34;quarter\u0026#34;, \u0026#34;year\u0026#34; fixed_interval: \u0026#34;30s\u0026#34;, \u0026#34;1m\u0026#34;, \u0026#34;5m\u0026#34;, \u0026#34;1h\u0026#34;, \u0026#34;12h\u0026#34;, \u0026#34;1d\u0026#34; （固定时长，不考虑日历，适合精确间隔） 3.3 histogram（按数值区间分组） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 按价格区间统计（每 2000 元一个区间） GET /products/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;aggs\u0026#34;: { \u0026#34;price_ranges\u0026#34;: { \u0026#34;histogram\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;price\u0026#34;, \u0026#34;interval\u0026#34;: 2000, \u0026#34;min_doc_count\u0026#34;: 0 } } } } // 返回：0-2000, 2000-4000, 4000-6000, ... 3.4 range（自定义区间） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 GET /products/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;aggs\u0026#34;: { \u0026#34;price_bands\u0026#34;: { \u0026#34;range\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;price\u0026#34;, \u0026#34;ranges\u0026#34;: [ { \u0026#34;key\u0026#34;: \u0026#34;低价位\u0026#34;, \u0026#34;to\u0026#34;: 2000 }, { \u0026#34;key\u0026#34;: \u0026#34;中低价位\u0026#34;, \u0026#34;from\u0026#34;: 2000, \u0026#34;to\u0026#34;: 5000 }, { \u0026#34;key\u0026#34;: \u0026#34;中高价位\u0026#34;, \u0026#34;from\u0026#34;: 5000, \u0026#34;to\u0026#34;: 10000 }, { \u0026#34;key\u0026#34;: \u0026#34;高价位\u0026#34;, \u0026#34;from\u0026#34;: 10000 } ] } } } } 3.5 filter（过滤桶） 1 2 3 4 5 6 7 8 9 10 11 12 GET /products/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;aggs\u0026#34;: { \u0026#34;in_stock_count\u0026#34;: { \u0026#34;filter\u0026#34;: { \u0026#34;term\u0026#34;: { \u0026#34;in_stock\u0026#34;: true } } } } } // 统计有库存的商品数量 3.6 filters（多过滤桶） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 GET /products/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;aggs\u0026#34;: { \u0026#34;stock_status\u0026#34;: { \u0026#34;filters\u0026#34;: { \u0026#34;filters\u0026#34;: { \u0026#34;in_stock\u0026#34;: { \u0026#34;term\u0026#34;: { \u0026#34;in_stock\u0026#34;: true } }, \u0026#34;out_of_stock\u0026#34;: { \u0026#34;term\u0026#34;: { \u0026#34;in_stock\u0026#34;: false } } } } } } } 四、嵌套聚合 4.1 桶内嵌套指标聚合 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 按品牌分组，每组计算平均价格和最高评分 GET /products/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;aggs\u0026#34;: { \u0026#34;by_brand\u0026#34;: { \u0026#34;terms\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;brand\u0026#34;, \u0026#34;size\u0026#34;: 20 }, \u0026#34;aggs\u0026#34;: { \u0026#34;avg_price\u0026#34;: { \u0026#34;avg\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;price\u0026#34; } }, \u0026#34;max_rating\u0026#34;: { \u0026#34;max\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;rating\u0026#34; } }, \u0026#34;product_count\u0026#34;: { \u0026#34;value_count\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;name.keyword\u0026#34; } } } } } } 返回结果：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 \u0026#34;buckets\u0026#34;: [ { \u0026#34;key\u0026#34;: \u0026#34;华为\u0026#34;, \u0026#34;doc_count\u0026#34;: 3, \u0026#34;avg_price\u0026#34;: { \u0026#34;value\u0026#34;: 5765.67 }, \u0026#34;max_rating\u0026#34;: { \u0026#34;value\u0026#34;: 4.8 }, \u0026#34;product_count\u0026#34;: { \u0026#34;value\u0026#34;: 3 } }, { \u0026#34;key\u0026#34;: \u0026#34;苹果\u0026#34;, \u0026#34;doc_count\u0026#34;: 2, \u0026#34;avg_price\u0026#34;: { \u0026#34;value\u0026#34;: 11999.0 }, \u0026#34;max_rating\u0026#34;: { \u0026#34;value\u0026#34;: 4.9 }, \u0026#34;product_count\u0026#34;: { \u0026#34;value\u0026#34;: 2 } } ] 4.2 多级嵌套 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 按品牌分组 → 按分类再分组 → 计算平均价格 GET /products/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;aggs\u0026#34;: { \u0026#34;by_brand\u0026#34;: { \u0026#34;terms\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;brand\u0026#34; }, \u0026#34;aggs\u0026#34;: { \u0026#34;by_category\u0026#34;: { \u0026#34;terms\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;category\u0026#34; }, \u0026#34;aggs\u0026#34;: { \u0026#34;avg_price\u0026#34;: { \u0026#34;avg\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;price\u0026#34; } } } } } } } } 4.3 按品牌分组 + 按月统计趋势 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 GET /products/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;aggs\u0026#34;: { \u0026#34;by_brand\u0026#34;: { \u0026#34;terms\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;brand\u0026#34; }, \u0026#34;aggs\u0026#34;: { \u0026#34;by_month\u0026#34;: { \u0026#34;date_histogram\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;created_at\u0026#34;, \u0026#34;calendar_interval\u0026#34;: \u0026#34;month\u0026#34;, \u0026#34;format\u0026#34;: \u0026#34;yyyy-MM\u0026#34; } } } } } } 五、实战场景 5.1 电商数据分析 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 需求：手机分类的各品牌销量占比和平均价格 GET /products/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;query\u0026#34;: { \u0026#34;term\u0026#34;: { \u0026#34;category\u0026#34;: \u0026#34;手机\u0026#34; } }, \u0026#34;aggs\u0026#34;: { \u0026#34;brands\u0026#34;: { \u0026#34;terms\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;brand\u0026#34;, \u0026#34;size\u0026#34;: 10 }, \u0026#34;aggs\u0026#34;: { \u0026#34;avg_price\u0026#34;: { \u0026#34;avg\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;price\u0026#34; } }, \u0026#34;price_distribution\u0026#34;: { \u0026#34;histogram\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;price\u0026#34;, \u0026#34;interval\u0026#34;: 1000 } } } } } } 5.2 日志分析 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 // 假设索引 logs-2026.05 // 1. 每个服务的错误数 GET /logs-*/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;query\u0026#34;: { \u0026#34;term\u0026#34;: { \u0026#34;level\u0026#34;: \u0026#34;ERROR\u0026#34; } }, \u0026#34;aggs\u0026#34;: { \u0026#34;by_service\u0026#34;: { \u0026#34;terms\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;service\u0026#34;, \u0026#34;size\u0026#34;: 20 } } } } // 2. 错误数时间趋势（每小时） GET /logs-*/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;query\u0026#34;: { \u0026#34;term\u0026#34;: { \u0026#34;level\u0026#34;: \u0026#34;ERROR\u0026#34; } }, \u0026#34;aggs\u0026#34;: { \u0026#34;errors_over_time\u0026#34;: { \u0026#34;date_histogram\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;timestamp\u0026#34;, \u0026#34;fixed_interval\u0026#34;: \u0026#34;1h\u0026#34;, \u0026#34;format\u0026#34;: \u0026#34;yyyy-MM-dd HH:mm\u0026#34; } } } } // 3. 错误数 TOP 5 服务 + 每个服务的错误类型分布 GET /logs-*/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;query\u0026#34;: { \u0026#34;term\u0026#34;: { \u0026#34;level\u0026#34;: \u0026#34;ERROR\u0026#34; } }, \u0026#34;aggs\u0026#34;: { \u0026#34;top_services\u0026#34;: { \u0026#34;terms\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;service\u0026#34;, \u0026#34;size\u0026#34;: 5 }, \u0026#34;aggs\u0026#34;: { \u0026#34;by_error_type\u0026#34;: { \u0026#34;terms\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;error_type\u0026#34;, \u0026#34;size\u0026#34;: 10 } } } } } } 5.3 用户行为分析 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 // 假设索引 user_actions // 每天的活跃用户数 GET /user_actions/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;aggs\u0026#34;: { \u0026#34;daily_active\u0026#34;: { \u0026#34;date_histogram\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;timestamp\u0026#34;, \u0026#34;calendar_interval\u0026#34;: \u0026#34;day\u0026#34;, \u0026#34;format\u0026#34;: \u0026#34;yyyy-MM-dd\u0026#34; }, \u0026#34;aggs\u0026#34;: { \u0026#34;unique_users\u0026#34;: { \u0026#34;cardinality\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;user_id\u0026#34; } } } } } } // 各操作的占比 GET /user_actions/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;aggs\u0026#34;: { \u0026#34;by_action\u0026#34;: { \u0026#34;terms\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;action\u0026#34; } } } } 六、聚合注意事项 6.1 text 字段不能直接聚合 1 2 3 4 5 6 7 8 9 10 11 12 13 text 字段会分词，无法用于聚合。 要对文本字段聚合，需要用它的 keyword 子字段： 错误： \u0026#34;field\u0026#34;: \u0026#34;name\u0026#34; 正确： \u0026#34;field\u0026#34;: \u0026#34;name.keyword\u0026#34; 或者在 Mapping 中定义多字段： \u0026#34;name\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;text\u0026#34;, \u0026#34;fields\u0026#34;: { \u0026#34;keyword\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;keyword\u0026#34; } } } 6.2 size 的影响 1 2 3 4 5 6 7 8 9 查询的 size 控制返回的文档数（文档级别的结果） 聚合的 size 控制返回的桶数（聚合级别的结果） // size: 0 表示不返回文档，只返回聚合结果 GET /products/_search { \u0026#34;size\u0026#34;: 0, \u0026#34;aggs\u0026#34;: { ... } } 6.3 性能建议 1 2 3 4 5 1. 聚合查询加 size: 0（不需要文档只要统计结果） 2. 用 filter 代替 query 减少评分开销 3. 控制桶的数量（terms 的 size 不要太大） 4. 避免深度嵌套聚合（最多 2-3 层） 5. 对聚合字段开启 doc_values（keyword 默认开启） 七、小结 本文学习了聚合分析：\n聚合分类（指标、桶、管道） 指标聚合（avg、sum、max、min、cardinality、percentiles） 桶聚合（terms、date_histogram、histogram、range、filter） 嵌套聚合（桶内嵌套指标和多级嵌套） 实战场景（电商分析、日志分析、用户行为分析） 下一篇将学习进阶查询与优化：深度分页、分词器、中文分词和搜索调优。\n","date":"2025-02-09T10:00:00+08:00","permalink":"/posts/middleware/es/03-es-aggregation/","title":"Elasticsearch 学习笔记（三）：聚合分析"},{"content":"写在前面 本文是 Elasticsearch 学习笔记系列的第二篇，介绍文档 CRUD 操作和各种搜索查询。前置知识：基础入门（第一篇）。\n一、文档 CRUD 1.1 准备测试数据 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 PUT /products { \u0026#34;mappings\u0026#34;: { \u0026#34;properties\u0026#34;: { \u0026#34;name\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;text\u0026#34;, \u0026#34;analyzer\u0026#34;: \u0026#34;ik_max_word\u0026#34;, \u0026#34;fields\u0026#34;: { \u0026#34;keyword\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;keyword\u0026#34; } } }, \u0026#34;brand\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;keyword\u0026#34; }, \u0026#34;price\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;float\u0026#34; }, \u0026#34;category\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;keyword\u0026#34; }, \u0026#34;in_stock\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;boolean\u0026#34; }, \u0026#34;rating\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;float\u0026#34; }, \u0026#34;created_at\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;date\u0026#34;, \u0026#34;format\u0026#34;: \u0026#34;yyyy-MM-dd\u0026#34; }, \u0026#34;description\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;text\u0026#34;, \u0026#34;analyzer\u0026#34;: \u0026#34;ik_max_word\u0026#34; }, \u0026#34;tags\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;keyword\u0026#34; } } } } 1.2 Index（创建/覆盖） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // 指定 ID 创建（如果已存在则覆盖） PUT /products/_doc/1 { \u0026#34;name\u0026#34;: \u0026#34;华为 Mate 60 Pro\u0026#34;, \u0026#34;brand\u0026#34;: \u0026#34;华为\u0026#34;, \u0026#34;price\u0026#34;: 6999, \u0026#34;category\u0026#34;: \u0026#34;手机\u0026#34;, \u0026#34;in_stock\u0026#34;: true, \u0026#34;rating\u0026#34;: 4.8, \u0026#34;created_at\u0026#34;: \u0026#34;2026-01-15\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;华为旗舰手机，搭载麒麟芯片，支持卫星通信\u0026#34;, \u0026#34;tags\u0026#34;: [\u0026#34;5G\u0026#34;, \u0026#34;旗舰\u0026#34;, \u0026#34;国产\u0026#34;] } // 自动生成 ID POST /products/_doc { \u0026#34;name\u0026#34;: \u0026#34;小米 14\u0026#34;, \u0026#34;brand\u0026#34;: \u0026#34;小米\u0026#34;, \u0026#34;price\u0026#34;: 3999, \u0026#34;category\u0026#34;: \u0026#34;手机\u0026#34;, \u0026#34;in_stock\u0026#34;: true } 1.3 Get（查询） 1 2 3 4 5 6 7 8 9 10 11 // 按 ID 查询 GET /products/_doc/1 // 只返回 source（不要元数据） GET /products/_source/1 // 只要特定字段 GET /products/_doc/1?_source=name,price // 检查文档是否存在（不返回内容） HEAD /products/_doc/1 1.4 Update（更新） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 // 部分更新（只更新指定字段） POST /products/_update/1 { \u0026#34;doc\u0026#34;: { \u0026#34;price\u0026#34;: 6499, \u0026#34;in_stock\u0026#34;: false } } // 脚本更新 POST /products/_update/1 { \u0026#34;script\u0026#34;: { \u0026#34;source\u0026#34;: \u0026#34;ctx._source.price -= params.discount\u0026#34;, \u0026#34;params\u0026#34;: { \u0026#34;discount\u0026#34;: 500 } } } // upsert（存在则更新，不存在则插入） POST /products/_update/1 { \u0026#34;doc\u0026#34;: { \u0026#34;price\u0026#34;: 5999 }, \u0026#34;upsert\u0026#34;: { \u0026#34;name\u0026#34;: \u0026#34;华为 Mate 60 Pro\u0026#34;, \u0026#34;brand\u0026#34;: \u0026#34;华为\u0026#34;, \u0026#34;price\u0026#34;: 5999, \u0026#34;category\u0026#34;: \u0026#34;手机\u0026#34; } } 1.5 Delete（删除） 1 2 3 4 5 6 7 8 9 10 11 12 // 按 ID 删除 DELETE /products/_doc/1 // 按查询删除 POST /products/_delete_by_query { \u0026#34;query\u0026#34;: { \u0026#34;match\u0026#34;: { \u0026#34;brand\u0026#34;: \u0026#34;已下架\u0026#34; } } } 1.6 Bulk（批量操作） 1 2 3 4 5 6 7 8 9 10 11 12 13 // 批量操作（注意：每行之间不能有空行） POST /_bulk {\u0026#34;index\u0026#34;:{\u0026#34;_index\u0026#34;:\u0026#34;products\u0026#34;,\u0026#34;_id\u0026#34;:\u0026#34;2\u0026#34;}} {\u0026#34;name\u0026#34;:\u0026#34;小米 14\u0026#34;,\u0026#34;brand\u0026#34;:\u0026#34;小米\u0026#34;,\u0026#34;price\u0026#34;:3999,\u0026#34;category\u0026#34;:\u0026#34;手机\u0026#34;,\u0026#34;in_stock\u0026#34;:true,\u0026#34;rating\u0026#34;:4.5,\u0026#34;created_at\u0026#34;:\u0026#34;2026-02-01\u0026#34;,\u0026#34;tags\u0026#34;:[\u0026#34;5G\u0026#34;,\u0026#34;性价比\u0026#34;]} {\u0026#34;index\u0026#34;:{\u0026#34;_index\u0026#34;:\u0026#34;products\u0026#34;,\u0026#34;_id\u0026#34;:\u0026#34;3\u0026#34;}} {\u0026#34;name\u0026#34;:\u0026#34;苹果 iPhone 15 Pro\u0026#34;,\u0026#34;brand\u0026#34;:\u0026#34;苹果\u0026#34;,\u0026#34;price\u0026#34;:8999,\u0026#34;category\u0026#34;:\u0026#34;手机\u0026#34;,\u0026#34;in_stock\u0026#34;:true,\u0026#34;rating\u0026#34;:4.7,\u0026#34;created_at\u0026#34;:\u0026#34;2026-01-20\u0026#34;,\u0026#34;tags\u0026#34;:[\u0026#34;5G\u0026#34;,\u0026#34;旗舰\u0026#34;]} {\u0026#34;index\u0026#34;:{\u0026#34;_index\u0026#34;:\u0026#34;products\u0026#34;,\u0026#34;_id\u0026#34;:\u0026#34;4\u0026#34;}} {\u0026#34;name\u0026#34;:\u0026#34;华为 MatePad Pro\u0026#34;,\u0026#34;brand\u0026#34;:\u0026#34;华为\u0026#34;,\u0026#34;price\u0026#34;:3299,\u0026#34;category\u0026#34;:\u0026#34;平板\u0026#34;,\u0026#34;in_stock\u0026#34;:true,\u0026#34;rating\u0026#34;:4.6,\u0026#34;created_at\u0026#34;:\u0026#34;2026-03-01\u0026#34;,\u0026#34;tags\u0026#34;:[\u0026#34;平板\u0026#34;,\u0026#34;办公\u0026#34;]} {\u0026#34;index\u0026#34;:{\u0026#34;_index\u0026#34;:\u0026#34;products\u0026#34;,\u0026#34;_id\u0026#34;:\u0026#34;5\u0026#34;}} {\u0026#34;name\u0026#34;:\u0026#34;MacBook Pro 14\u0026#34;,\u0026#34;brand\u0026#34;:\u0026#34;苹果\u0026#34;,\u0026#34;price\u0026#34;:14999,\u0026#34;category\u0026#34;:\u0026#34;笔记本\u0026#34;,\u0026#34;in_stock\u0026#34;:true,\u0026#34;rating\u0026#34;:4.9,\u0026#34;created_at\u0026#34;:\u0026#34;2026-02-15\u0026#34;,\u0026#34;tags\u0026#34;:[\u0026#34;办公\u0026#34;,\u0026#34;创作\u0026#34;]} {\u0026#34;update\u0026#34;:{\u0026#34;_index\u0026#34;:\u0026#34;products\u0026#34;,\u0026#34;_id\u0026#34;:\u0026#34;1\u0026#34;}} {\u0026#34;doc\u0026#34;:{\u0026#34;price\u0026#34;:6499}} {\u0026#34;delete\u0026#34;:{\u0026#34;_index\u0026#34;:\u0026#34;products\u0026#34;,\u0026#34;_id\u0026#34;:\u0026#34;6\u0026#34;}} 1 2 3 4 5 6 7 8 9 10 Bulk 支持的操作： index — 创建/覆盖文档 create — 创建（已存在则失败） update — 更新 delete — 删除 性能建议： - 每批 1000-5000 条 - 批量大小控制在 5-15MB - 不要太大，否则 ES 内存压力大 二、全文搜索 2.1 match（最常用） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 // 全文搜索：对搜索词分词后匹配 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;match\u0026#34;: { \u0026#34;name\u0026#34;: \u0026#34;华为旗舰\u0026#34; } } } // \u0026#34;华为旗舰\u0026#34; 分词为 [\u0026#34;华为\u0026#34;, \u0026#34;旗舰\u0026#34;] // 匹配 name 中包含 \u0026#34;华为\u0026#34; 或 \u0026#34;旗舰\u0026#34; 的文档（OR 关系） // AND 关系 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;match\u0026#34;: { \u0026#34;name\u0026#34;: { \u0026#34;query\u0026#34;: \u0026#34;华为旗舰\u0026#34;, \u0026#34;operator\u0026#34;: \u0026#34;and\u0026#34; } } } } // 必须同时包含 \u0026#34;华为\u0026#34; 和 \u0026#34;旗舰\u0026#34; 2.2 match_phrase（短语匹配） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 短语搜索：分词后必须按顺序连续出现 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;match_phrase\u0026#34;: { \u0026#34;name\u0026#34;: \u0026#34;华为 Mate\u0026#34; } } } // \u0026#34;华为 Mate\u0026#34; 必须连续出现，\u0026#34;Mate 华为\u0026#34; 不匹配 // 允许间隔（slop） GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;match_phrase\u0026#34;: { \u0026#34;name\u0026#34;: { \u0026#34;query\u0026#34;: \u0026#34;华为 Pro\u0026#34;, \u0026#34;slop\u0026#34;: 2 } } } } // 允许中间隔 2 个词 2.3 multi_match（多字段搜索） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 在多个字段中搜索 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;multi_match\u0026#34;: { \u0026#34;query\u0026#34;: \u0026#34;华为旗舰\u0026#34;, \u0026#34;fields\u0026#34;: [\u0026#34;name\u0026#34;, \u0026#34;description\u0026#34;, \u0026#34;tags\u0026#34;] } } } // 指定字段权重（^ 后面是权重倍数） GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;multi_match\u0026#34;: { \u0026#34;query\u0026#34;: \u0026#34;华为旗舰\u0026#34;, \u0026#34;fields\u0026#34;: [\u0026#34;name^3\u0026#34;, \u0026#34;description^2\u0026#34;, \u0026#34;tags\u0026#34;] } } } // name 匹配的得分 × 3，description 匹配 × 2，tags 匹配 × 1 2.4 query_string 1 2 3 4 5 6 7 8 9 10 // 支持Lucene 语法的查询 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;query_string\u0026#34;: { \u0026#34;query\u0026#34;: \u0026#34;(华为 OR 苹果) AND 旗舰\u0026#34;, \u0026#34;fields\u0026#34;: [\u0026#34;name\u0026#34;, \u0026#34;description\u0026#34;] } } } 三、精确搜索 3.1 term（精确匹配） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // term：不分词，精确匹配（用于 keyword 字段） GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;term\u0026#34;: { \u0026#34;brand\u0026#34;: \u0026#34;华为\u0026#34; } } } // 注意：不要对 text 字段用 term（text 字段存储的是分词后的结果） // terms：匹配多个值（类似 SQL 的 IN） GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;terms\u0026#34;: { \u0026#34;brand\u0026#34;: [\u0026#34;华为\u0026#34;, \u0026#34;苹果\u0026#34;] } } } 3.2 range（范围查询） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;range\u0026#34;: { \u0026#34;price\u0026#34;: { \u0026#34;gte\u0026#34;: 3000, \u0026#34;lte\u0026#34;: 7000 } } } } // 日期范围 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;range\u0026#34;: { \u0026#34;created_at\u0026#34;: { \u0026#34;gte\u0026#34;: \u0026#34;2026-01-01\u0026#34;, \u0026#34;lt\u0026#34;: \u0026#34;2026-04-01\u0026#34; } } } } 1 2 3 4 5 操作符： gt — 大于 gte — 大于等于 lt — 小于 lte — 小于等于 3.3 exists 和 missing 1 2 3 4 5 6 7 8 9 // 字段存在 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;exists\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;description\u0026#34; } } } 3.4 prefix、wildcard、regexp 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 // 前缀匹配 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;prefix\u0026#34;: { \u0026#34;name.keyword\u0026#34;: { \u0026#34;value\u0026#34;: \u0026#34;华为\u0026#34; } } } } // 通配符匹配（性能差，慎用） GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;wildcard\u0026#34;: { \u0026#34;brand\u0026#34;: { \u0026#34;value\u0026#34;: \u0026#34;华*\u0026#34; } } } } // 正则匹配（性能差，慎用） GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;regexp\u0026#34;: { \u0026#34;brand\u0026#34;: \u0026#34;华.*\u0026#34; } } } 四、复合查询（bool） 4.1 bool 查询结构 1 2 3 4 5 6 7 8 9 10 11 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;bool\u0026#34;: { \u0026#34;must\u0026#34;: [], \u0026#34;should\u0026#34;: [], \u0026#34;must_not\u0026#34;: [], \u0026#34;filter\u0026#34;: [] } } } 1 2 3 4 must — 必须匹配，参与评分 should — 至少匹配一个（或全部不匹配也行），参与评分 must_not — 必须不匹配，不参与评分 filter — 必须匹配，不参与评分（性能更好，会缓存） 4.2 实战组合 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // 需求：华为品牌的手机，价格 3000-7000，有库存 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;bool\u0026#34;: { \u0026#34;must\u0026#34;: [ { \u0026#34;match\u0026#34;: { \u0026#34;name\u0026#34;: \u0026#34;华为\u0026#34; } } ], \u0026#34;filter\u0026#34;: [ { \u0026#34;term\u0026#34;: { \u0026#34;category\u0026#34;: \u0026#34;手机\u0026#34; } }, { \u0026#34;range\u0026#34;: { \u0026#34;price\u0026#34;: { \u0026#34;gte\u0026#34;: 3000, \u0026#34;lte\u0026#34;: 7000 } } }, { \u0026#34;term\u0026#34;: { \u0026#34;in_stock\u0026#34;: true } } ], \u0026#34;must_not\u0026#34;: [ { \u0026#34;term\u0026#34;: { \u0026#34;brand\u0026#34;: \u0026#34;已下架\u0026#34; } } ] } } } 4.3 must vs filter 的区别 1 2 3 4 5 6 7 8 9 10 11 12 13 14 must： - 参与相关性评分（_score） - 匹配程度影响排序 - 适合：用户搜索关键词 filter： - 不参与评分（_score = 0） - 只判断是/否 - 性能更好（ES 会缓存 filter 结果） - 适合：精确过滤条件（品牌、分类、价格范围、日期范围） 最佳实践： 用户搜索的关键词 → must 过滤条件 → filter 4.4 should 的行为 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 // 有 must/filter 时：should 不强制匹配（加分项） GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;bool\u0026#34;: { \u0026#34;must\u0026#34;: [ { \u0026#34;match\u0026#34;: { \u0026#34;name\u0026#34;: \u0026#34;手机\u0026#34; } } ], \u0026#34;should\u0026#34;: [ { \u0026#34;term\u0026#34;: { \u0026#34;brand\u0026#34;: \u0026#34;华为\u0026#34; } }, { \u0026#34;term\u0026#34;: { \u0026#34;brand\u0026#34;: \u0026#34;苹果\u0026#34; } } ] } } } // 必须匹配 \u0026#34;手机\u0026#34;，如果品牌是华为或苹果会排名更靠前 // 没有 must/filter 时：should 至少匹配一个 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;bool\u0026#34;: { \u0026#34;should\u0026#34;: [ { \u0026#34;term\u0026#34;: { \u0026#34;brand\u0026#34;: \u0026#34;华为\u0026#34; } }, { \u0026#34;term\u0026#34;: { \u0026#34;brand\u0026#34;: \u0026#34;苹果\u0026#34; } } ], \u0026#34;minimum_should_match\u0026#34;: 1 } } } 五、排序 5.1 基本排序 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // 按价格升序 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;match_all\u0026#34;: {} }, \u0026#34;sort\u0026#34;: [ { \u0026#34;price\u0026#34;: \u0026#34;asc\u0026#34; } ] } // 多字段排序 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;match_all\u0026#34;: {} }, \u0026#34;sort\u0026#34;: [ { \u0026#34;in_stock\u0026#34;: \u0026#34;desc\u0026#34; }, { \u0026#34;price\u0026#34;: \u0026#34;asc\u0026#34; } ] } // 先按是否有库存排，有库存的在前；再按价格升序 5.2 按相关性排序 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // 默认按 _score 降序（相关性最高的在前） GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;match\u0026#34;: { \u0026#34;name\u0026#34;: \u0026#34;华为\u0026#34; } } } // 显式指定 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;match\u0026#34;: { \u0026#34;name\u0026#34;: \u0026#34;华为\u0026#34; } }, \u0026#34;sort\u0026#34;: [ \u0026#34;_score\u0026#34;, { \u0026#34;price\u0026#34;: \u0026#34;asc\u0026#34; } ] } 六、分页 6.1 from / size（基本分页） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 第1页，每页10条 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;match_all\u0026#34;: {} }, \u0026#34;from\u0026#34;: 0, \u0026#34;size\u0026#34;: 10 } // 第2页 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;match_all\u0026#34;: {} }, \u0026#34;from\u0026#34;: 10, \u0026#34;size\u0026#34;: 10 } 1 2 3 4 5 from — 跳过前 N 条（0 开始） size — 返回条数（默认 10，最大 10000） 限制：from + size 不能超过 10000（index.max_result_window） 超过需要用 search_after 或 scroll 6.2 search_after（深度分页） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 第1次查询 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;match_all\u0026#34;: {} }, \u0026#34;size\u0026#34;: 10, \u0026#34;sort\u0026#34;: [ { \u0026#34;created_at\u0026#34;: \u0026#34;desc\u0026#34; }, { \u0026#34;_id\u0026#34;: \u0026#34;asc\u0026#34; } ] } // 下一页：用上一页最后一条的 sort 值 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;match_all\u0026#34;: {} }, \u0026#34;size\u0026#34;: 10, \u0026#34;sort\u0026#34;: [ { \u0026#34;created_at\u0026#34;: \u0026#34;desc\u0026#34; }, { \u0026#34;_id\u0026#34;: \u0026#34;asc\u0026#34; } ], \u0026#34;search_after\u0026#34;: [\u0026#34;2026-03-01\u0026#34;, \u0026#34;4\u0026#34;] } 6.3 分页方案对比 1 2 3 4 5 6 7 8 9 10 from/size — 简单，适合浅分页（前100页） 深分页性能差（from=9990 需要跳过 9990 条） search_after — 适合深度分页和实时数据 每次请求都基于上一页最后一条 不能跳页，只能翻页 scroll — 适合批量导出全量数据 创建快照，逐批拉取 不适合实时查询（数据不是最新的） 七、高亮 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;match\u0026#34;: { \u0026#34;name\u0026#34;: \u0026#34;华为旗舰\u0026#34; } }, \u0026#34;highlight\u0026#34;: { \u0026#34;pre_tags\u0026#34;: [\u0026#34;\u0026lt;em\u0026gt;\u0026#34;], \u0026#34;post_tags\u0026#34;: [\u0026#34;\u0026lt;/em\u0026gt;\u0026#34;], \u0026#34;fields\u0026#34;: { \u0026#34;name\u0026#34;: {}, \u0026#34;description\u0026#34;: {} } } } 返回结果中高亮字段：\n1 2 3 4 \u0026#34;highlight\u0026#34;: { \u0026#34;name\u0026#34;: [\u0026#34;\u0026lt;em\u0026gt;华为\u0026lt;/em\u0026gt; Mate 60 \u0026lt;em\u0026gt;旗舰\u0026lt;/em\u0026gt; 手机\u0026#34;], \u0026#34;description\u0026#34;: [\u0026#34;\u0026lt;em\u0026gt;华为\u0026lt;/em\u0026gt;\u0026lt;em\u0026gt;旗舰\u0026lt;/em\u0026gt;手机，搭载麒麟芯片\u0026#34;] } 八、_source 过滤 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 只返回指定字段 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;match_all\u0026#34;: {} }, \u0026#34;_source\u0026#34;: [\u0026#34;name\u0026#34;, \u0026#34;price\u0026#34;, \u0026#34;brand\u0026#34;] } // 排除指定字段 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;match_all\u0026#34;: {} }, \u0026#34;_source\u0026#34;: { \u0026#34;excludes\u0026#34;: [\u0026#34;description\u0026#34;, \u0026#34;tags\u0026#34;] } } // 通配符 GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;match_all\u0026#34;: {} }, \u0026#34;_source\u0026#34;: [\u0026#34;name\u0026#34;, \u0026#34;price*\u0026#34;] } 九、count（计数） 1 2 3 4 5 6 7 // 满足条件的文档数量 GET /products/_count { \u0026#34;query\u0026#34;: { \u0026#34;term\u0026#34;: { \u0026#34;brand\u0026#34;: \u0026#34;华为\u0026#34; } } } 十、搜索查询速查 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 全文搜索： match — 分词后搜索（最常用） match_phrase — 短语匹配（必须连续） multi_match — 多字段搜索 精确搜索： term — 精确匹配（keyword 字段） terms — 多值匹配（IN） range — 范围查询 exists — 字段存在 复合查询： bool.must — 必须匹配（参与评分） bool.filter — 必须匹配（不参与评分，更快） bool.should — 加分项 bool.must_not — 必须不匹配 排序：sort 分页：from/size, search_after 高亮：highlight 字段过滤：_source 十一、小结 本文学习了文档操作和搜索查询：\n文档 CRUD（Index、Get、Update、Delete、Bulk） 全文搜索（match、match_phrase、multi_match） 精确搜索（term、terms、range、exists） 复合查询（bool：must/filter/should/must_not） 排序、分页和高亮 _source 过滤和计数 下一篇将学习聚合分析：指标聚合、桶聚合和实战统计。\n","date":"2025-02-05T10:00:00+08:00","permalink":"/posts/middleware/es/02-es-search/","title":"Elasticsearch 学习笔记（二）：文档操作与搜索"},{"content":"写在前面 本文是 Elasticsearch 学习笔记系列的第一篇，介绍 ES 的核心概念、倒排索引原理和基本操作。基于 ES 8.15 版本。无论你是做搜索还是日志分析，这篇都是入门的基础。\n一、Elasticsearch 是什么 1.1 定义 Elasticsearch 是一个基于 Lucene 的分布式搜索和分析引擎。\n1 2 3 4 5 6 核心能力： - 全文搜索 — 毫秒级从海量文本中找到匹配结果 - 结构化搜索 — 按字段精确过滤（数值、日期、枚举） - 聚合分析 — 统计、分组、指标计算 - 分布式 — 水平扩展，处理 PB 级数据 - 近实时 — 数据写入后约 1 秒可搜索 1.2 典型应用场景 1 2 3 4 5 应用搜索 — 电商商品搜索、文档搜索 日志分析 — ELK 技术栈（ES + Logstash + Kibana） 监控告警 — 系统指标、业务指标聚合分析 安全分析 — SIEM，安全事件检索和关联 业务数据看板 — 实时统计报表 1.3 ELK 生态 1 2 3 4 Elasticsearch — 存储和搜索引擎 Kibana — 可视化和查询界面 Logstash — 数据采集和转换 Beats — 轻量级数据采集器（Filebeat、Metricbeat 等） 二、核心概念 2.1 与关系型数据库的类比 1 2 3 4 5 6 7 8 9 MySQL Elasticsearch ───────────────── ───────────────── Database Index（索引） Table Index（一个 Index 就是一张表） Row Document（文档） Column Field（字段） Schema Mapping（映射） SQL DSL（Query DSL / REST API） PRIMARY KEY _id 2.2 核心术语 1 2 3 4 5 6 7 8 Index（索引） — 文档的集合，类似数据库 Document（文档） — 一条数据，JSON 格式 Field（字段） — 文档中的一个属性 Mapping（映射） — 定义索引的字段类型和属性 Shard（分片） — 索引的物理分片，分布式存储 Replica（副本） — 分片的复制，提供高可用 Node（节点） — 一个 ES 实例 Cluster（集群） — 多个节点组成 2.3 文档结构 1 2 3 4 5 6 7 8 9 10 11 12 { \u0026#34;_index\u0026#34;: \u0026#34;products\u0026#34;, \u0026#34;_id\u0026#34;: \u0026#34;1\u0026#34;, \u0026#34;_source\u0026#34;: { \u0026#34;name\u0026#34;: \u0026#34;华为 Mate 60 Pro\u0026#34;, \u0026#34;brand\u0026#34;: \u0026#34;华为\u0026#34;, \u0026#34;price\u0026#34;: 6999, \u0026#34;category\u0026#34;: \u0026#34;手机\u0026#34;, \u0026#34;in_stock\u0026#34;: true, \u0026#34;created_at\u0026#34;: \u0026#34;2026-01-15\u0026#34; } } 1 2 3 _index — 文档所在的索引 _id — 文档唯一标识（自动生成或指定） _source — 文档原始内容（业务数据） 三、倒排索引 3.1 正排索引 vs 倒排索引 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 正排索引（传统数据库）： Doc 1 → \u0026#34;华为 Mate 60 Pro\u0026#34; Doc 2 → \u0026#34;华为 Mate Pad\u0026#34; Doc 3 → \u0026#34;苹果 iPhone 15\u0026#34; 查询 \u0026#34;华为\u0026#34; → 扫描每一条记录 → 慢 倒排索引（ES）： \u0026#34;华为\u0026#34; → [Doc 1, Doc 2] \u0026#34;mate\u0026#34; → [Doc 1, Doc 2] \u0026#34;60\u0026#34; → [Doc 1] \u0026#34;pro\u0026#34; → [Doc 1] \u0026#34;苹果\u0026#34; → [Doc 3] \u0026#34;iphone\u0026#34; → [Doc 3] \u0026#34;15\u0026#34; → [Doc 3] 查询 \u0026#34;华为\u0026#34; → 直接查倒排索引 → [Doc 1, Doc 2] → 快 3.2 倒排索引的构建过程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 原文： \u0026#34;华为 Mate 60 Pro\u0026#34; 1. 分词（Tokenization） → [\u0026#34;华为\u0026#34;, \u0026#34;mate\u0026#34;, \u0026#34;60\u0026#34;, \u0026#34;pro\u0026#34;] 2. 归一化（Normalization） → 小写、去停用词、词干提取等 → [\u0026#34;华为\u0026#34;, \u0026#34;mate\u0026#34;, \u0026#34;60\u0026#34;, \u0026#34;pro\u0026#34;] 3. 建立倒排表 Token → Doc IDs \u0026#34;华为\u0026#34; → [1] \u0026#34;mate\u0026#34; → [1] \u0026#34;60\u0026#34; → [1] \u0026#34;pro\u0026#34; → [1] 3.3 为什么 ES 快 1 2 3 4 1. 倒排索引 — 直接定位文档，不需要全表扫描 2. 分片并行 — 查询分发到多个分片并行执行 3. 缓存 — 查询结果缓存、文件系统缓存 4. 列式存储 — Doc Values 支持高效的聚合和排序 四、安装和连接 4.1 Docker 安装（开发环境） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # 创建网络 docker network create elastic # 启动 ES（单节点） docker run -d \\ --name elasticsearch \\ --net elastic \\ -p 9200:9200 \\ -e \u0026#34;discovery.type=single-node\u0026#34; \\ -e \u0026#34;xpack.security.enabled=false\u0026#34; \\ -e \u0026#34;ES_JAVA_OPTS=-Xms512m -Xmx512m\u0026#34; \\ docker.elastic.co/elasticsearch/elasticsearch:8.15.0 # 启动 Kibana docker run -d \\ --name kibana \\ --net elastic \\ -p 5601:5601 \\ -e \u0026#34;ELASTICSEARCH_HOSTS=http://elasticsearch:9200\u0026#34; \\ docker.elastic.co/kibana/kibana:8.15.0 1 2 3 ES API： http://localhost:9200 Kibana UI： http://localhost:5601 Dev Tools： http://localhost:5601/app/dev_tools 4.2 验证连接 1 2 3 4 5 # curl 方式 curl http://localhost:9200 # Kibana Dev Tools GET / 返回：\n1 2 3 4 5 6 { \u0026#34;name\u0026#34;: \u0026#34;elasticsearch\u0026#34;, \u0026#34;version\u0026#34;: { \u0026#34;number\u0026#34;: \u0026#34;8.15.0\u0026#34; } } 五、索引管理 5.1 创建索引 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // 显式创建索引 + Mapping PUT /products { \u0026#34;settings\u0026#34;: { \u0026#34;number_of_shards\u0026#34;: 1, \u0026#34;number_of_replicas\u0026#34;: 1 }, \u0026#34;mappings\u0026#34;: { \u0026#34;properties\u0026#34;: { \u0026#34;name\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;text\u0026#34;, \u0026#34;analyzer\u0026#34;: \u0026#34;ik_max_word\u0026#34; }, \u0026#34;brand\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;keyword\u0026#34; }, \u0026#34;price\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;float\u0026#34; }, \u0026#34;category\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;keyword\u0026#34; }, \u0026#34;in_stock\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;boolean\u0026#34; }, \u0026#34;created_at\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;date\u0026#34;, \u0026#34;format\u0026#34;: \u0026#34;yyyy-MM-dd\u0026#34; }, \u0026#34;description\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;text\u0026#34;, \u0026#34;analyzer\u0026#34;: \u0026#34;ik_max_word\u0026#34; }, \u0026#34;tags\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;keyword\u0026#34; } } } } 5.2 查看索引 1 2 3 4 5 6 7 8 9 10 11 // 查看索引信息 GET /products // 查看索引 Mapping GET /products/_mapping // 查看索引 Settings GET /products/_settings // 查看所有索引 GET /_cat/indices?v 5.3 删除索引 1 DELETE /products 5.4 修改索引设置 1 2 3 4 5 6 7 8 9 // 修改副本数（可以动态修改） PUT /products/_settings { \u0026#34;index\u0026#34;: { \u0026#34;number_of_replicas\u0026#34;: 2 } } // 注意：分片数（number_of_shards）创建后不能修改 六、Mapping 详解 6.1 字段类型 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 文本类型： text — 全文搜索，会分词（用于搜索） keyword — 精确匹配，不分词（用于过滤、排序、聚合） 数值类型： integer, long, float, double, scaled_float 日期类型： date — 支持多种格式 布尔类型： boolean 复杂类型： object — 嵌套对象 nested — 嵌套数组（独立查询） array — 数组（ES 没有专门的 array 类型，任何字段都可以是数组） 特殊类型： geo_point — 经纬度 ip — IP 地址 completion — 自动补全建议 flattened — 整个对象作为 keyword 6.2 text vs keyword 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // text：分词后建立倒排索引 \u0026#34;name\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;text\u0026#34; } // \u0026#34;华为 Mate 60\u0026#34; → [\u0026#34;华为\u0026#34;, \u0026#34;mate\u0026#34;, \u0026#34;60\u0026#34;] // 搜索 \u0026#34;华为\u0026#34; 可以匹配 // keyword：不分词，整体建立索引 \u0026#34;brand\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;keyword\u0026#34; } // \u0026#34;华为 Mate 60\u0026#34; → [\u0026#34;华为 Mate 60\u0026#34;] // 必须完全匹配 \u0026#34;华为 Mate 60\u0026#34; 才能搜到 // 常见用法：同时支持全文搜索和精确匹配 \u0026#34;title\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;text\u0026#34;, \u0026#34;fields\u0026#34;: { \u0026#34;keyword\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;keyword\u0026#34; } } } // title 字段：text 模式用于全文搜索 // title.keyword 子字段：keyword 模式用于精确匹配、排序、聚合 6.3 动态映射 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ES 默认启用动态映射：写入文档时，如果字段不存在，自动推断类型 自动推断规则： 字符串 → text + keyword 子字段 整数 → long 浮点数 → float 布尔值 → boolean 日期 → date（如果格式匹配） 对象 → object 问题： - 类型推断可能不准确 - 一旦确定无法修改（只能 reindex） 建议： 生产环境提前定义 Mapping，关闭动态映射或设为 strict 1 2 3 4 5 6 7 8 9 10 PUT /products { \u0026#34;mappings\u0026#34;: { \u0026#34;dynamic\u0026#34;: \u0026#34;strict\u0026#34;, \u0026#34;properties\u0026#34;: { \u0026#34;name\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;text\u0026#34; } } } } // 写入未定义的字段会报错 6.4 常用 Mapping 设置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 PUT /products { \u0026#34;mappings\u0026#34;: { \u0026#34;properties\u0026#34;: { \u0026#34;name\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;text\u0026#34;, \u0026#34;analyzer\u0026#34;: \u0026#34;ik_max_word\u0026#34;, \u0026#34;index\u0026#34;: true }, \u0026#34;description\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;text\u0026#34;, \u0026#34;analyzer\u0026#34;: \u0026#34;ik_max_word\u0026#34;, \u0026#34;index\u0026#34;: true }, \u0026#34;price\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;scaled_float\u0026#34;, \u0026#34;scaling_factor\u0026#34;: 100 }, \u0026#34;is_active\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;boolean\u0026#34; }, \u0026#34;created_at\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;date\u0026#34;, \u0026#34;format\u0026#34;: \u0026#34;yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis\u0026#34; }, \u0026#34;metadata\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;flattened\u0026#34; } } } } 1 2 3 4 index: true/false — 是否索引（false 则不能搜索但可以存储） analyzer — 分词器 format — 日期格式（|| 分隔多种格式） scaling_factor — 浮点数缩放因子（scaled_float 存为 long，避免精度问题） 七、索引模板 7.1 创建模板 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // 匹配 logs-* 的索引自动应用此模板 PUT /_index_template/logs_template { \u0026#34;index_patterns\u0026#34;: [\u0026#34;logs-*\u0026#34;], \u0026#34;priority\u0026#34;: 100, \u0026#34;template\u0026#34;: { \u0026#34;settings\u0026#34;: { \u0026#34;number_of_shards\u0026#34;: 3, \u0026#34;number_of_replicas\u0026#34;: 1, \u0026#34;index.lifecycle.name\u0026#34;: \u0026#34;logs-policy\u0026#34; }, \u0026#34;mappings\u0026#34;: { \u0026#34;properties\u0026#34;: { \u0026#34;timestamp\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;date\u0026#34; }, \u0026#34;level\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;keyword\u0026#34; }, \u0026#34;service\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;keyword\u0026#34; }, \u0026#34;message\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;text\u0026#34;, \u0026#34;analyzer\u0026#34;: \u0026#34;ik_max_word\u0026#34; }, \u0026#34;trace_id\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;keyword\u0026#34; }, \u0026#34;host\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;keyword\u0026#34; } } } } } 7.2 组件模板 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 可复用的设置组件 PUT /_component_template/common_settings { \u0026#34;template\u0026#34;: { \u0026#34;settings\u0026#34;: { \u0026#34;number_of_replicas\u0026#34;: 1, \u0026#34;refresh_interval\u0026#34;: \u0026#34;5s\u0026#34; } } } // 在索引模板中引用 PUT /_index_template/app_template { \u0026#34;index_patterns\u0026#34;: [\u0026#34;app-*\u0026#34;], \u0026#34;composed_of\u0026#34;: [\u0026#34;common_settings\u0026#34;], \u0026#34;template\u0026#34;: { \u0026#34;mappings\u0026#34;: { \u0026#34;properties\u0026#34;: { \u0026#34;name\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;text\u0026#34; } } } } } 八、索引别名 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 // 创建别名 POST /_aliases { \u0026#34;actions\u0026#34;: [ { \u0026#34;add\u0026#34;: { \u0026#34;index\u0026#34;: \u0026#34;products-v1\u0026#34;, \u0026#34;alias\u0026#34;: \u0026#34;products\u0026#34; } } ] } // 通过别名操作（应用层不需要知道实际索引名） GET /products/_search { \u0026#34;query\u0026#34;: { \u0026#34;match_all\u0026#34;: {} } } // 无缝切换索引（零停机） POST /_aliases { \u0026#34;actions\u0026#34;: [ { \u0026#34;remove\u0026#34;: { \u0026#34;index\u0026#34;: \u0026#34;products-v1\u0026#34;, \u0026#34;alias\u0026#34;: \u0026#34;products\u0026#34; } }, { \u0026#34;add\u0026#34;: { \u0026#34;index\u0026#34;: \u0026#34;products-v2\u0026#34;, \u0026#34;alias\u0026#34;: \u0026#34;products\u0026#34; } } ] } 九、_cat API 速查 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 # 集群健康 GET /_cat/health?v # 节点信息 GET /_cat/nodes?v # 索引列表 GET /_cat/indices?v # 分片状态 GET /_cat/shards?v # 别名 GET /_cat/aliases?v # 已分配的分片 GET /_cat/allocation?v # 恢复进度 GET /_cat/recovery?v # 所有 ?v 参数表示显示列名 # ?format=json 输出 JSON 格式 # ?h=field1,field2 只显示指定列 十、小结 本文学习了 Elasticsearch 的基础：\nES 是什么和应用场景 核心概念（Index、Document、Mapping、Shard、Replica） 倒排索引原理 安装和连接 索引管理（创建、查看、删除、Settings） Mapping 详解（字段类型、text vs keyword、动态映射） 索引模板和别名 _cat API 速查 下一篇将学习文档 CRUD 和搜索查询：全文搜索、精确搜索和复合查询。\n","date":"2025-02-01T10:00:00+08:00","permalink":"/posts/middleware/es/01-es-basics/","title":"Elasticsearch 学习笔记（一）：基础入门"},{"content":"写在前面 本文是 Redis 学习笔记系列的最后一篇，介绍 Redis 的持久化机制、主从复制、哨兵、Cluster 集群和运维排查。前置知识：分布式锁（第三篇）。\n一、持久化 1.1 为什么需要持久化 1 2 3 4 5 6 Redis 是内存数据库，重启后数据全部丢失。 持久化将内存数据保存到磁盘，重启后可以恢复。 两种持久化方式： RDB（Redis Database） — 定时快照 AOF（Append Only File） — 追加写日志 1.2 RDB 快照 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 原理：在某个时间点，把内存中的所有数据生成一份快照文件（dump.rdb） 触发方式： 1. 自动触发（配置规则） 2. 手动触发（SAVE / BGSAVE） 3. 关闭时触发（shutdown） 自动触发配置： save 900 1 — 900秒内有1次修改就触发 save 300 10 — 300秒内有10次修改 save 60 10000 — 60秒内有10000次修改 BGSAVE 工作方式： 1. 主进程 fork 子进程 2. 子进程将内存数据写入临时 RDB 文件 3. 写完后替换旧的 RDB 文件 4. 主进程继续处理请求（不影响服务） 优点： - 文件紧凑，恢复速度快 - fork 子进程不影响主进程 - 适合备份和容灾 缺点： - 不是实时的，两次快照之间的数据可能丢失 - fork 子进程时如果数据量大，可能短暂影响性能 1.3 AOF 日志 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 原理：将每个写命令追加到 AOF 文件（appendonly.aof） 工作流程： 1. 客户端发送写命令 2. Redis 执行命令 3. 将命令追加到 AOF 缓冲区 4. 根据策略将缓冲区写入文件 刷盘策略（appendfsync）： always — 每个写命令都刷盘（最安全，最慢） everysec — 每秒刷盘一次（推荐，最多丢1秒数据） no — 由操作系统决定（最快，可能丢较多数据） 优点： - 数据更安全（最多丢1秒） - AOF 文件可读（可以手动修改恢复数据） 缺点： - 文件比 RDB 大 - 恢复速度比 RDB 慢 1.4 AOF 重写 1 2 3 4 5 6 7 8 9 10 问题：AOF 文件越来越大（SET a 1, SET a 2, SET a 3 → 只需要 SET a 3） AOF 重写：压缩 AOF 文件，只保留最终状态的数据 触发方式： auto-aof-rewrite-min-size 64mb — AOF 文件超过 64MB 触发 auto-aof-rewrite-percentage 100 — 文件大小比上次重写后增长 100% 手动触发： BGREWRITEAOF 1.5 RDB + AOF 混合持久化（Redis 4.0+） 1 2 3 4 5 6 7 8 9 10 11 配置：aof-use-rdb-preamble yes（默认开启） 工作方式： - AOF 重写时，前半部分用 RDB 格式写入（快速加载） - 后半部分用 AOF 格式写入增量命令 - 结合了两者的优点 推荐配置： 开启 AOF（appendonly yes） 开启混合持久化（aof-use-rdoc-preamble yes） 刷盘策略 everysec 1.6 持久化选择 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 纯缓存（丢了无所谓）： 关闭持久化，性能最好 save \u0026#34;\u0026#34; appendonly no 缓存 + 允许少量丢失： 只用 RDB 适合：Session、验证码 数据不能丢： AOF + everysec 适合：业务数据、计数器 生产环境推荐： RDB + AOF 混合持久化 RDB 用于快速恢复和备份 AOF 保证数据安全 二、主从复制 2.1 作用 1 2 3 1. 数据备份 — 从节点是主节点的完整副本 2. 读写分离 — 写请求到主节点，读请求到从节点 3. 高可用基础 — 主节点故障时从节点可以提升为主 2.2 配置 1 2 3 4 5 6 7 8 # 从节点配置（redis.conf 或命令） REPLICAOF master-ip 6379 # 或运行时设置 REPLICAOF 192.168.1.100 6379 # 查看主从关系 INFO replication 2.3 复制过程 1 2 3 4 5 6 7 8 1. 从节点连接主节点 2. 主节点执行 BGSAVE 生成 RDB 快照 3. 主节点将 RDB 发送给从节点 4. 从节点加载 RDB（全量同步） 5. 之后主节点将新的写命令持续发送给从节点（增量同步） 全量同步：初次连接或断开较久时触发 增量同步：正常情况下的持续同步 2.4 注意事项 1 2 3 4 5 6 7 8 1. 主从复制是异步的（主节点写入后立即返回，不等从节点确认） → 主节点宕机时可能丢失少量数据 2. 从节点默认只读（read-only） → 不应该往从节点写数据 3. 一个主节点可以有多个从节点 → 从节点也可以作为其他从节点的主节点（链式复制） 三、哨兵（Sentinel） 3.1 问题：主节点宕机了怎么办 1 2 3 4 5 6 主从复制中： 主节点宕机 → 需要人工把从节点提升为主节点 → 需要通知所有客户端新的主节点地址 → 整个过程需要人工干预 哨兵：自动完成这个过程 3.2 哨兵功能 1 2 3 4 1. 监控 — 持续检查主节点和从节点是否正常 2. 通知 — 节点故障时通知管理员或应用 3. 自动故障转移 — 主节点故障时自动将从节点提升为主节点 4. 配置提供者 — 客户端从哨兵获取当前主节点地址 3.3 哨兵部署 1 2 3 4 5 6 7 8 9 至少 3 个哨兵节点（保证选举的多数派） 部署结构： Redis Master — 192.168.1.100:6379 Redis Slave 1 — 192.168.1.101:6379 Redis Slave 2 — 192.168.1.102:6379 Sentinel 1 — 192.168.1.100:26379 Sentinel 2 — 192.168.1.101:26379 Sentinel 3 — 192.168.1.102:26379 1 2 3 4 5 6 # sentinel.conf port 26379 sentinel monitor mymaster 192.168.1.100 6379 2 sentinel down-after-milliseconds mymaster 5000 sentinel failover-timeout mymaster 10000 sentinel parallel-syncs mymaster 1 1 2 3 4 monitor — 监控的主节点名称、IP、端口、法定人数 down-after — 多久无响应判定为主观下线 failover-timeout — 故障转移超时 parallel-syncs — 故障转移后多少个从节点同时同步新主节点 3.4 故障转移流程 1 2 3 4 5 6 7 1. 哨兵每秒向主节点发送 PING 2. 超过 down-after 时间无响应 → 主观下线 3. 多数哨兵（≥ 2 个）认为主节点下线 → 客观下线 4. 哨兵选举一个从节点提升为新主节点 5. 其他从节点改为复制新主节点 6. 哨兵更新配置，通知客户端新主节点地址 7. 旧主节点恢复后变为新主节点的从节点 3.5 .NET 连接哨兵 1 2 3 4 5 6 7 8 9 10 11 12 13 // StackExchange.Redis 支持哨兵 var config = new ConfigurationOptions { ServiceName = \u0026#34;mymaster\u0026#34;, // 哨兵监控的 master 名称 AbortOnConnectFail = false }; config.EndPoints.Add(\u0026#34;192.168.1.100:26379\u0026#34;); config.EndPoints.Add(\u0026#34;192.168.1.101:26379\u0026#34;); config.EndPoints.Add(\u0026#34;192.168.1.102:26379\u0026#34;); var connection = ConnectionMultiplexer.Connect(config); // 自动从哨兵获取当前主节点地址 // 主节点切换后自动重连新主节点 四、Cluster 集群 4.1 为什么需要 Cluster 1 2 3 4 5 6 7 8 9 主从 + 哨兵的问题： - 主节点只有一个，写入压力集中在主节点 - 主节点内存有限，单机无法存储更多数据 - 水平扩展困难 Cluster 解决： - 数据分片（Sharding）— 数据分散到多个主节点 - 水平扩展 — 加节点即可扩容 - 高可用 — 每个主节点有从节点，自动故障转移 4.2 Cluster 架构 1 2 3 4 5 6 7 8 9 最小集群：6 个节点（3 主 3 从） Master-1 (slot 0-5460) ←→ Slave-1 Master-2 (slot 5461-10922) ←→ Slave-2 Master-3 (slot 10923-16383) ←→ Slave-3 共 16384 个 Hash Slot 每个主节点负责一部分 Slot key 的 slot = CRC16(key) % 16384 4.3 创建 Cluster 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # 每个节点的 redis.conf port 7001 cluster-enabled yes cluster-config-file nodes.conf cluster-node-timeout 5000 appendonly yes # 启动 6 个节点（7001-7006） redis-server redis.conf # 创建集群 redis-cli --cluster create \\ 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 \\ 127.0.0.1:7004 127.0.0.1:7005 127.0.0.1:7006 \\ --cluster-replicas 1 4.4 Cluster 操作 1 2 3 4 5 6 7 8 9 10 # 查看集群信息 redis-cli -c -p 7001 cluster info redis-cli -c -p 7001 cluster nodes # 查看某个 key 在哪个 slot redis-cli -c -p 7001 cluster keyslot \u0026#34;user:1\u0026#34; # 加减节点 redis-cli --cluster add-node new_host:port existing_host:port redis-cli --cluster reshard host:port 4.5 Cluster 限制 1 2 3 4 5 6 7 8 1. 不支持跨 slot 的多 key 操作（MSET、SUNION 等涉及多个 key 的命令） → 可以用 Hash Tag 强制同一 slot：{user}:1, {user}:2 2. 不支持 SELECT 切换数据库（只用 db0） 3. 批量操作需要确保 key 在同一个 slot 4. 集群至少 3 主 3 从 五、运维常用操作 5.1 信息查看 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 # 服务器信息 INFO server # 内存使用 INFO memory # 关注：used_memory_human（已用内存） # maxmemory（最大内存限制） # mem_fragmentation_ratio（内存碎片率，\u0026gt;1.5 需要关注） # 客户端连接 INFO clients # 关注：connected_clients（当前连接数） # 命令统计 INFO stats # 关注：total_commands_processed（总命令数） # instantaneous_ops_per_sec（每秒操作数） # 持久化状态 INFO persistence # 关注：rdb_last_save_time（上次 RDB 保存时间） # aof_current_size（AOF 当前大小） # 复制状态 INFO replication 5.2 性能分析 1 2 3 4 5 6 7 8 9 10 11 # 慢查询日志 CONFIG SET slowlog-log-slower-than 10000 # 超过 10ms 记录 CONFIG SET slowlog-max-len 128 # 最多记录 128 条 SLOWLOG GET 10 # 查看最近 10 条慢查询 SLOWLOG LEN # 慢查询数量 # 客户端列表 CLIENT LIST # 所有连接 # 监控命令（调试用，不要在生产长时间开） MONITOR # 实时显示所有命令 5.3 内存优化 1 2 3 4 5 6 7 8 9 10 # 查看内存使用 MEMORY USAGE key # 单个 key 的内存占用 MEMORY DOCTOR # 内存诊断建议 # 大 key 排查 redis-cli --bigkeys # 扫描大 key redis-cli --memkeys # 按内存排序的 key # 清理过期 key SCAN 0 MATCH session:* COUNT 1000 # 安全扫描 5.4 常用运维命令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 # 查看配置 CONFIG GET maxmemory CONFIG GET maxmemory-policy # 在线修改配置 CONFIG SET maxmemory 2gb CONFIG SET maxmemory-policy allkeys-lru # 查看连接数 CLIENT LIST | wc -l # 断开客户端 CLIENT KILL ADDR:PORT # 数据库大小 DBSIZE # 清空当前数据库 FLUSHDB ASYNC # 异步清空（不阻塞） # 查看所有数据库的 key 数量 INFO keyspace 六、常见问题排查 6.1 内存满了 1 2 3 4 5 6 7 8 9 10 11 12 现象：OOM，写入返回错误 排查： 1. INFO memory → 看 used_memory 和 maxmemory 2. redis-cli --bigkeys → 找大 key 3. 检查是否有过期策略 解决： - 设置 maxmemory-policy（推荐 allkeys-lru） - 清理无用 key - 扩容（增加内存或用 Cluster） - 检查是否有大 value（拆分为多个小 key） 6.2 响应变慢 1 2 3 4 5 6 7 8 9 10 11 12 排查： 1. SLOWLOG GET 10 → 慢查询 2. INFO memory → 看碎片率（mem_fragmentation_ratio） 3. INFO clients → 连接数是否过多 4. INFO persistence → 是否在频繁 BGSAVE 常见原因： - 大 key 操作（DEL 一个很大的 key 会阻塞） - 持久化阻塞（fork 耗时） - 内存碎片太多（重启可以解决） - 连接数过多 - 使用了 KEYS * 等危险命令 6.3 主从延迟 1 2 3 4 5 6 7 8 9 10 11 12 13 14 排查： INFO replication → master_repl_offset 和 slave_repl_offset 差距大 原因： - 网络延迟 - 从节点性能不足 - 主节点写入量太大 - 大 key 同步耗时 解决： - 检查网络 - 提升从节点配置 - 避免写入大 value 七、系列总结 7.1 知识体系回顾 1 2 3 4 5 6 7 8 9 10 11 第一篇：基础与数据类型 - 核心概念、5种数据类型、通用命令、.NET 客户端 第二篇：缓存实战 - 缓存模式、过期策略、淘汰策略、穿透/击穿/雪崩 第三篇：分布式锁 - 锁原理、Lua 解锁、看门狗、可重入、RedLock、.NET 封装 第四篇：持久化与高可用 - RDB、AOF、主从复制、哨兵、Cluster、运维排查 7.2 核心要点 1 2 3 4 5 6 7 8 1. Redis 单线程 + IO 多路复用 = 高性能 2. 5 种数据类型各有适用场景，选对了事半功倍 3. 缓存用 Cache Aside 模式，更新时删缓存不是更新缓存 4. 缓存三大问题必须能答：穿透（布隆过滤器）、击穿（互斥锁）、雪崩（随机过期） 5. 分布式锁三要素：SET NX EX、唯一值、Lua 解锁 6. 生产环境开启 AOF + everysec，推荐混合持久化 7. 高可用至少 3 节点：主从 + 哨兵，或 Cluster 8. 避免 KEYS *、大 value、热点 key 过期时间相同 ","date":"2025-01-24T10:00:00+08:00","permalink":"/posts/middleware/redis/04-redis-persistence-ha/","title":"Redis 学习笔记（四）：持久化与高可用"},{"content":"写在前面 本文是 Redis 学习笔记系列的第三篇，深入分布式锁的实现原理、常见问题和 .NET 实战封装。前置知识：缓存实战（第二篇）。\n一、为什么需要分布式锁 1.1 单机锁的问题 1 2 3 4 5 6 7 单机环境： lock (obj) { ... } — Monitor SemaphoreSlim — 信号量 Mutex — 互斥体 这些锁只在当前进程内有效。 部署多个实例后，各进程的锁互不影响，无法保证互斥。 1.2 分布式场景 1 2 3 4 5 6 7 8 场景：多个订单服务实例，防止同一笔订单被重复处理。 实例 A：处理订单 12345 实例 B：也收到订单 12345 的请求 如果用单机锁，A 和 B 各自加锁成功 → 重复处理 需要一把跨进程、跨机器的锁 → 分布式锁 1.3 分布式锁的要求 1 2 3 4 5 1. 互斥性 — 任意时刻只有一个客户端持有锁 2. 可重入 — 同一线程/进程可以重复获取同一把锁 3. 不会死锁 — 持有锁的客户端宕机后锁能自动释放 4. 高性能 — 加锁/解锁要快 5. 高可用 — 锁服务不能成为单点故障 二、Redis 实现分布式锁 2.1 基本原理 1 2 3 4 5 6 7 加锁：SET lock_key unique_value NX EX 30 NX — key 不存在才设置（保证互斥） EX — 过期时间（防止死锁） unique_value — 客户端唯一标识（防止误删别人的锁） 解锁：Lua 脚本（原子操作） 先判断 value 是否匹配 → 匹配才删除 2.2 为什么解锁要用 Lua 脚本 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 不用 Lua 脚本的问题： 1. 客户端 A 执行 GET lock_key → 返回 \u0026#34;A\u0026#34; 2. 客户端 A 判断 value == \u0026#34;A\u0026#34; → 是我的锁 3. 【此时锁恰好过期，Redis 自动删除】 4. 客户端 B 执行 SET lock_key \u0026#34;B\u0026#34; NX → 成功 5. 客户端 A 执行 DEL lock_key → 删了 B 的锁！ Lua 脚本保证\u0026#34;判断 + 删除\u0026#34;是原子操作： if redis.call(\u0026#34;get\u0026#34;, KEYS[1]) == ARGV[1] then return redis.call(\u0026#34;del\u0026#34;, KEYS[1]) else return 0 end 2.3 基础实现 1 2 3 4 5 # 加锁 SET lock:order:12345 \u0026#34;client-uuid-abc\u0026#34; NX EX 30 # 解锁（Lua 脚本） EVAL \u0026#34;if redis.call(\u0026#39;get\u0026#39;, KEYS[1]) == ARGV[1] then return redis.call(\u0026#39;del\u0026#39;, KEYS[1]) else return 0 end\u0026#34; 1 lock:order:12345 \u0026#34;client-uuid-abc\u0026#34; 三、锁续期 3.1 问题：业务没处理完锁就过期了 1 2 3 4 5 6 7 设置锁过期时间 30 秒，但业务处理需要 40 秒： 1. 客户端 A 加锁（30 秒） 2. A 开始处理业务（预计 40 秒） 3. 30 秒后锁自动过期 4. 客户端 B 加锁成功 5. A 处理完业务，释放了 B 的锁 → 严重问题！ 3.2 解决：看门狗机制 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 看门狗（Watchdog）：后台线程定期延长锁的过期时间。 工作方式： - 加锁成功后，启动看门狗线程 - 每隔 lock_time / 3 检查一次 - 如果锁还持有（value 匹配），重置过期时间 - 如果业务处理完成，看门狗停止续期 示例： 锁过期时间 30 秒 看门狗每 10 秒续期一次 业务处理 40 秒： 0s 加锁（30s 过期） 10s 续期（30s 过期） 20s 续期（30s 过期） 30s 续期（30s 过期） 40s 业务完成，主动释放锁，看门狗停止 四、可重入锁 4.1 问题：同一线程多次加锁 1 2 3 方法 A 加锁 → 调用方法 B → 方法 B 也加同一把锁 如果锁不可重入，方法 B 等待方法 A 释放 → 死锁 4.2 实现：Hash + 计数器 1 2 3 4 5 6 7 8 9 # 加锁（可重入） # key = lock name, field = client_id, value = 重入次数 HSET lock:order:12345 \u0026#34;client-uuid-abc\u0026#34; 1 # 重入时计数 +1 HINCRBY lock:order:12345 \u0026#34;client-uuid-abc\u0026#34; 1 # 解锁时计数 -1 HINCRBY lock:order:12345 \u0026#34;client-uuid-abc\u0026#34; -1 # 计数归零时删除 key 五、.NET 实战封装 5.1 基础分布式锁 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 public interface IDistributedLock : IDisposable { Task\u0026lt;bool\u0026gt; TryAcquireAsync(TimeSpan timeout); Task ReleaseAsync(); } public class RedisDistributedLock : IDistributedLock { private readonly IDatabase _db; private readonly string _lockKey; private readonly string _lockValue; private readonly TimeSpan _expiry; private bool _disposed; // Lua 解锁脚本（只删自己的锁） private static readonly LuaScript UnlockScript = LuaScript.Prepare( @\u0026#34;if redis.call(\u0026#39;get\u0026#39;, @key) == @value then return redis.call(\u0026#39;del\u0026#39;, @key) else return 0 end\u0026#34;); public RedisDistributedLock( IDatabase db, string lockKey, TimeSpan expiry) { _db = db; _lockKey = lockKey; _lockValue = Guid.NewGuid().ToString(); // 唯一标识 _expiry = expiry; } public async Task\u0026lt;bool\u0026gt; TryAcquireAsync(TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; var retryDelay = TimeSpan.FromMilliseconds(50); while (DateTime.UtcNow \u0026lt; deadline) { var acquired = await _db.StringSetAsync( _lockKey, _lockValue, _expiry, When.NotExists); if (acquired) return true; await Task.Delay(retryDelay); } return false; // 超时未获取到锁 } public async Task ReleaseAsync() { await _db.ScriptEvaluateAsync(UnlockScript, new { key = (RedisKey)_lockKey, value = _lockValue }); } public void Dispose() { if (!_disposed) { ReleaseAsync().GetAwaiter().GetResult(); _disposed = true; } } } 5.2 使用方式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public class OrderService { private readonly IConnectionMultiplexer _redis; public OrderService(IConnectionMultiplexer redis) =\u0026gt; _redis = redis; public async Task ProcessOrderAsync(string orderId) { var db = _redis.GetDatabase(); var lockKey = $\u0026#34;lock:order:{orderId}\u0026#34;; await using var @lock = new RedisDistributedLock( db, lockKey, TimeSpan.FromSeconds(30)); // 尝试获取锁，最多等 5 秒 var acquired = await @lock.TryAcquireAsync(TimeSpan.FromSeconds(5)); if (!acquired) { throw new InvalidOperationException($\u0026#34;获取订单 {orderId} 的锁失败\u0026#34;); } // 获取锁成功，处理业务 await DoProcessOrder(orderId); // 离开 using 块时自动释放锁 } } 5.3 带看门狗的分布式锁 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 public class RedisDistributedLockWithWatchdog : IDistributedLock { private readonly IDatabase _db; private readonly string _lockKey; private readonly string _lockValue; private readonly TimeSpan _expiry; private readonly TimeSpan _renewInterval; private CancellationTokenSource? _cts; private bool _disposed; private static readonly LuaScript UnlockScript = LuaScript.Prepare( @\u0026#34;if redis.call(\u0026#39;get\u0026#39;, @key) == @value then return redis.call(\u0026#39;del\u0026#39;, @key) else return 0 end\u0026#34;); private static readonly LuaScript RenewScript = LuaScript.Prepare( @\u0026#34;if redis.call(\u0026#39;get\u0026#39;, @key) == @value then return redis.call(\u0026#39;expire\u0026#39;, @key, @ttl) else return 0 end\u0026#34;); public RedisDistributedLockWithWatchdog( IDatabase db, string lockKey, TimeSpan expiry) { _db = db; _lockKey = lockKey; _lockValue = Guid.NewGuid().ToString(); _expiry = expiry; _renewInterval = TimeSpan.FromTicks(expiry.Ticks / 3); } public async Task\u0026lt;bool\u0026gt; TryAcquireAsync(TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; var retryDelay = TimeSpan.FromMilliseconds(50); while (DateTime.UtcNow \u0026lt; deadline) { var acquired = await _db.StringSetAsync( _lockKey, _lockValue, _expiry, When.NotExists); if (acquired) { StartWatchdog(); return true; } await Task.Delay(retryDelay); } return false; } private void StartWatchdog() { _cts = new CancellationTokenSource(); _ = Task.Run(async () =\u0026gt; { while (!_cts.Token.IsCancellationRequested) { await Task.Delay(_renewInterval, _cts.Token); try { await _db.ScriptEvaluateAsync(RenewScript, new { key = (RedisKey)_lockKey, value = _lockValue, ttl = (int)_expiry.TotalSeconds }); } catch (OperationCanceledException) { break; } } }, _cts.Token); } public async Task ReleaseAsync() { _cts?.Cancel(); await _db.ScriptEvaluateAsync(UnlockScript, new { key = (RedisKey)_lockKey, value = _lockValue }); } public void Dispose() { if (!_disposed) { ReleaseAsync().GetAwaiter().GetResult(); _cts?.Dispose(); _disposed = true; } } } 六、RedLock 算法 6.1 单节点锁的问题 1 2 3 4 5 6 7 Redis 主从复制场景： 1. 客户端 A 在 Master 加锁成功 2. Master 还没同步到 Slave 就宕机了 3. Slave 被提升为 Master（锁数据丢失） 4. 客户端 B 加锁成功 → 两把锁同时存在！ RedLock：在多个独立的 Redis 实例上加锁，多数成功才算成功。 6.2 RedLock 流程 1 2 3 4 5 6 7 假设 5 个独立的 Redis 节点： 1. 记录开始时间 2. 依次向 5 个节点加锁（SET NX EX，很快） 3. 计算加锁耗时 4. 如果在 N/2+1（即 3）个节点加锁成功，且耗时 \u0026lt; 锁有效期 → 加锁成功 5. 失败则向所有节点释放锁 6.3 .NET 使用 RedLock.net 1 2 dotnet add package RedLock.net dotnet add package RedLock.net.SERedis 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 // 注册 RedLockFactory builder.Services.AddSingleton\u0026lt;RedLockFactory\u0026gt;(sp =\u0026gt; { var connection = sp.GetRequiredService\u0026lt;IConnectionMultiplexer\u0026gt;(); return RedLockFactory.Create(connection); }); // 使用 public class OrderService { private readonly RedLockFactory _lockFactory; public OrderService(RedLockFactory lockFactory) =\u0026gt; _lockFactory = lockFactory; public async Task ProcessOrderAsync(string orderId) { var resource = $\u0026#34;lock:order:{orderId}\u0026#34;; var expiry = TimeSpan.FromSeconds(30); var wait = TimeSpan.FromSeconds(5); var retry = TimeSpan.FromMilliseconds(200); await using var redLock = await _lockFactory.CreateLockAsync( resource, expiry, wait, retry); if (redLock.IsAcquired) { await DoProcessOrder(orderId); } else { throw new InvalidOperationException(\u0026#34;获取锁失败\u0026#34;); } } } 七、分布式锁注意事项 7.1 锁过期时间设置 1 2 3 4 5 6 7 太短：业务没处理完锁就过期了 → 失去互斥保护 太长：客户端宕机后锁很久才释放 → 其他客户端等待太久 建议： - 根据业务最大执行时间设置（通常是秒级） - 加上看门狗续期机制 - 一般设置 10-30 秒 7.2 锁粒度 1 2 3 4 5 6 7 粒度太粗：锁整个业务 → 并发度低 粒度太细：锁太多 key → 管理复杂、性能开销大 建议：锁到具体业务实体 lock:order:12345 — 锁一个订单 lock:inventory:SKU001 — 锁一个 SKU 的库存 lock:user:phone:13800138000 — 锁一个手机号的发送频率 7.3 锁的释放 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 一定要在 finally 中释放锁： try { var acquired = await @lock.TryAcquireAsync(timeout); if (acquired) { await DoWork(); } } finally { await @lock.ReleaseAsync(); // 无论如何都释放 } 或者用 using/await using 自动释放 八、小结 本文学习了分布式锁：\n为什么需要分布式锁 Redis 分布式锁原理（SET NX EX + Lua 解锁） 锁续期（看门狗机制） 可重入锁 .NET 实战封装（基础锁、带看门狗的锁） RedLock 算法和 RedLock.net 锁的注意事项（过期时间、粒度、释放） 下一篇将学习持久化、高可用和运维。\n","date":"2025-01-20T10:00:00+08:00","permalink":"/posts/middleware/redis/03-redis-distributed-lock/","title":"Redis 学习笔记（三）：分布式锁"},{"content":"写在前面 本文是 Redis 学习笔记系列的第二篇，介绍缓存的使用模式、过期和淘汰策略，以及缓存穿透、击穿、雪崩三大问题的解决方案。前置知识：基础与数据类型（第一篇）。\n一、缓存模式 1.1 Cache Aside（旁路缓存） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 最常用的缓存模式，应用代码同时管理缓存和数据库。 读取： 1. 先查缓存 2. 缓存命中 → 直接返回 3. 缓存未命中 → 查数据库 → 写入缓存 → 返回 更新： 1. 先更新数据库 2. 再删除缓存（不是更新缓存） 为什么是删除而不是更新缓存？ - 更新缓存在并发场景下可能导致数据不一致 - 删除更简单，下次读取时自然会重建 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 public class ProductService { private readonly IDatabase _redis; private readonly IProductRepository _repo; public ProductService(IConnectionMultiplexer redis, IProductRepository repo) { _redis = redis.GetDatabase(); _repo = repo; } // 读取：先缓存后数据库 public async Task\u0026lt;Product?\u0026gt; GetProductAsync(int id) { var cacheKey = $\u0026#34;cache:product:{id}\u0026#34;; // 1. 查缓存 var cached = await _redis.StringGetAsync(cacheKey); if (cached.HasValue) { return JsonSerializer.Deserialize\u0026lt;Product\u0026gt;(cached!); } // 2. 查数据库 var product = await _repo.GetByIdAsync(id); if (product == null) return null; // 3. 写入缓存（过期时间 30 分钟） var json = JsonSerializer.Serialize(product); await _redis.StringSetAsync(cacheKey, json, TimeSpan.FromMinutes(30)); return product; } // 更新：先数据库后删缓存 public async Task UpdateProductAsync(Product product) { // 1. 更新数据库 await _repo.UpdateAsync(product); // 2. 删除缓存 await _redis.KeyDeleteAsync($\u0026#34;cache:product:{product.Id}\u0026#34;); } } 1.2 Read/Write Through 1 2 3 4 5 6 7 8 9 缓存代理层负责读写数据库，应用只和缓存交互。 读取： 应用 → 缓存 → （未命中）缓存层自动查数据库并回填 写入： 应用 → 缓存 → 缓存层同步更新数据库 特点：应用代码更简单，但需要缓存中间件支持 1.3 Write Behind（异步写回） 1 2 3 4 5 写入时只更新缓存，异步批量写入数据库。 优点：写入性能极高 缺点：数据可能丢失（宕机时未落盘的数据丢失） 适用：写密集、允许少量丢失的场景 二、过期策略 2.1 设置过期时间 1 2 3 4 5 6 7 8 9 10 11 12 13 # 设置时指定过期 SET cache:key \u0026#34;data\u0026#34; EX 1800 # 秒 SETEX cache:key 1800 \u0026#34;data\u0026#34; # 等价 # 对已存在的 key 设置过期 EXPIRE cache:key 1800 # 秒 PEXPIRE cache:key 1800000 # 毫秒 # 指定过期时间点 EXPIREAT cache:key 1740000000 # Unix 时间戳 # 移除过期时间 PERSIST cache:key 1 2 3 // StackExchange.Redis await db.StringSetAsync(\u0026#34;cache:key\u0026#34;, \u0026#34;data\u0026#34;, TimeSpan.FromMinutes(30)); await db.KeyExpireAsync(\u0026#34;cache:key\u0026#34;, TimeSpan.FromMinutes(30)); 2.2 过期删除机制 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 Redis 采用两种策略结合： 1. 惰性删除（Lazy Expiration） - 访问 key 时检查是否过期 - 过期了才删除 - 优点：CPU 友好 - 缺点：过期了但没人访问的 key 会占用内存 2. 定期删除（Periodic Expiration） - 每秒执行 10 次（默认） - 随机检查一批设置了过期的 key - 删除已过期的 - 如果过期比例超过 25%，继续检查 - 控制在 25ms 以内（避免阻塞） 两者结合： 定期删除保证内存不会被大量过期 key 占满 惰性删除保证访问时一定能拿到正确结果 三、淘汰策略 当内存使用达到 maxmemory 限制时，Redis 如何选择删除哪些 key。\n3.1 配置 1 2 3 # redis.conf 或运行时设置 CONFIG SET maxmemory 1gb CONFIG SET maxmemory-policy allkeys-lru 3.2 8 种淘汰策略 1 2 3 4 5 6 7 8 9 10 11 12 13 不淘汰（默认）： noeviction — 内存满了直接报错，不删除任何 key 只淘汰设置了过期的 key： volatile-lru — 淘汰设置了过期的、最久未使用的 key volatile-lfu — 淘汰设置了过期的、使用频率最低的 key（Redis 4.0+） volatile-ttl — 淘汰设置了过期的、TTL 最短的 key volatile-random — 随机淘汰设置了过期的 key 淘汰所有 key： allkeys-lru — 淘汰最久未使用的 key（缓存场景最常用） allkeys-lfu — 淘汰使用频率最低的 key（Redis 4.0+） allkeys-random — 随机淘汰 3.3 策略选择 1 2 3 4 5 6 7 8 9 缓存场景（Redis 主要做缓存）： allkeys-lru — 推荐，淘汰最不常用的数据 allkeys-lfu — 更精确，但要更多内存记录频率 缓存 + 持久化场景： volatile-lru — 只淘汰有过期时间的，持久化的数据不受影响 不确定： allkeys-lru — 最稳妥的选择 四、缓存穿透 4.1 问题描述 1 2 3 4 5 6 7 查询一个数据库中不存在的数据，缓存中也没有。 流程： 查缓存 → 没有 → 查数据库 → 也没有 → 返回 null → 不写缓存 问题：每次请求都会打到数据库。 如果有恶意攻击，大量请求查询不存在的数据，数据库可能被打挂。 4.2 解决方案 方案一：缓存空值\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public async Task\u0026lt;Product?\u0026gt; GetProductAsync(int id) { var cacheKey = $\u0026#34;cache:product:{id}\u0026#34;; var cached = await _redis.StringGetAsync(cacheKey); if (cached.HasValue) { if (cached == \u0026#34;NULL\u0026#34;) return null; // 命中空值缓存 return JsonSerializer.Deserialize\u0026lt;Product\u0026gt;(cached!); } var product = await _repo.GetByIdAsync(id); if (product == null) { // 缓存空值，短过期时间（防止占用太多内存） await _redis.StringSetAsync(cacheKey, \u0026#34;NULL\u0026#34;, TimeSpan.FromMinutes(5)); return null; } await _redis.StringSetAsync(cacheKey, JsonSerializer.Serialize(product), TimeSpan.FromMinutes(30)); return product; } 1 2 3 4 5 优点：简单直接 缺点： - 占用额外内存 - 过期时间短，频繁查数据库 - 攻击者换不同的 key 就失效了 方案二：布隆过滤器\n1 2 3 4 5 6 7 8 9 10 11 在缓存前面加一层布隆过滤器： - 所有可能存在的数据哈希到布隆过滤器中 - 请求先过布隆过滤器 - 布隆过滤器说不存在 → 一定不存在，直接返回 - 布隆过滤器说存在 → 可能有，再去查缓存和数据库 优点：内存占用极小 缺点： - 需要预先加载数据 - 有误判率（说存在但实际不存在） - 删除困难 方案三：参数校验\n1 2 3 4 5 6 最简单的方式：在入口处校验请求参数。 - ID 不合法直接返回 - 不存在的枚举值直接返回 - 不合理的范围直接返回 这是第一道防线，必须做。 五、缓存击穿 5.1 问题描述 1 2 3 4 5 6 7 一个热点 key 过期的瞬间，大量并发请求同时到达。 流程： key 存在时 → 所有请求命中缓存 → 正常 key 过期瞬间 → 大量请求同时查数据库 → 数据库压力暴增 典型场景：热点新闻、秒杀商品 5.2 解决方案 方案一：互斥锁\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 public async Task\u0026lt;Product?\u0026gt; GetProductWithLockAsync(int id) { var cacheKey = $\u0026#34;cache:product:{id}\u0026#34;; var lockKey = $\u0026#34;lock:cache:product:{id}\u0026#34;; // 1. 查缓存 var cached = await _redis.StringGetAsync(cacheKey); if (cached.HasValue) { if (cached == \u0026#34;NULL\u0026#34;) return null; return JsonSerializer.Deserialize\u0026lt;Product\u0026gt;(cached!); } // 2. 获取分布式锁（只有一个请求能查数据库） var lockAcquired = await _redis.StringSetAsync( lockKey, \u0026#34;1\u0026#34;, TimeSpan.FromSeconds(10), When.NotExists); // NX if (lockAcquired) { try { // 获得锁，查数据库 var product = await _repo.GetByIdAsync(id); if (product == null) { await _redis.StringSetAsync(cacheKey, \u0026#34;NULL\u0026#34;, TimeSpan.FromMinutes(5)); return null; } await _redis.StringSetAsync(cacheKey, JsonSerializer.Serialize(product), TimeSpan.FromMinutes(30)); return product; } finally { await _redis.KeyDeleteAsync(lockKey); } } else { // 没获得锁，等一下再重试 await Task.Delay(100); return await GetProductWithLockAsync(id); // 递归重试 } } 方案二：逻辑过期（不设置真正过期时间）\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 // 缓存对象中包含逻辑过期时间 public class CacheData\u0026lt;T\u0026gt; { public T? Data { get; set; } public DateTime ExpireTime { get; set; } } public async Task\u0026lt;Product?\u0026gt; GetProductWithLogicalExpireAsync(int id) { var cacheKey = $\u0026#34;cache:product:{id}\u0026#34;; var cached = await _redis.StringGetAsync(cacheKey); if (!cached.HasValue) { // 缓存中没有，查数据库并缓存（不过期） return await LoadToCacheAsync(id); } var cacheData = JsonSerializer.Deserialize\u0026lt;CacheData\u0026lt;Product\u0026gt;\u0026gt;(cached!); // 判断逻辑过期 if (cacheData!.ExpireTime \u0026gt; DateTime.UtcNow) { return cacheData.Data; // 未过期，直接返回 } // 逻辑过期，异步更新缓存（不阻塞当前请求） _ = Task.Run(() =\u0026gt; LoadToCacheAsync(id)); // 返回旧数据（用户能接受短暂的旧数据） return cacheData.Data; } private async Task\u0026lt;Product?\u0026gt; LoadToCacheAsync(int id) { var product = await _repo.GetByIdAsync(id); var cacheData = new CacheData\u0026lt;Product\u0026gt; { Data = product, ExpireTime = DateTime.UtcNow.AddMinutes(30) }; await _redis.StringSetAsync( $\u0026#34;cache:product:{id}\u0026#34;, JsonSerializer.Serialize(cacheData)); return product; } 1 2 3 4 5 6 7 互斥锁 vs 逻辑过期： 互斥锁：保证数据一致，但并发高时部分请求等待 逻辑过期：不阻塞请求，但可能返回旧数据 选择： 数据一致性要求高 → 互斥锁 高性能优先、允许短暂旧数据 → 逻辑过期 方案三：热点 key 永不过期\n1 2 对确定的热点 key 不设置过期时间，由后台任务定期更新。 简单但有风险：后台任务挂了数据就不更新了。 六、缓存雪崩 6.1 问题描述 1 2 3 4 5 6 7 8 大量缓存 key 在同一时刻过期，或者 Redis 宕机。 场景1：大量 key 设置了相同的过期时间（如 30 分钟） → 30 分钟后这些 key 同时过期 → 请求全部打到数据库 场景2：Redis 节点宕机 → 所有请求打到数据库 6.2 解决方案 方案一：过期时间加随机值\n1 2 3 4 5 // 过期时间 = 基础时间 + 随机时间 var baseExpiry = TimeSpan.FromMinutes(30); var randomExpiry = TimeSpan.FromSeconds(Random.Shared.Next(1, 300)); await _redis.StringSetAsync(cacheKey, json, baseExpiry + randomExpiry); // 30 分钟 ~ 35 分钟内随机过期，避免同时失效 方案二：多级缓存\n1 2 3 4 5 6 7 8 9 10 L1 缓存（本地内存）→ L2 缓存（Redis）→ 数据库 请求流程： 本地缓存命中 → 返回 本地缓存未命中 → Redis 缓存命中 → 写入本地缓存 → 返回 Redis 也未命中 → 数据库 → 写入 Redis + 本地缓存 → 返回 工具： .NET MemoryCache 做本地缓存 缓存一致性由过期时间控制 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public class MultiLevelCacheService { private readonly IMemoryCache _localCache; private readonly IDatabase _redis; public MultiLevelCacheService(IMemoryCache localCache, IConnectionMultiplexer redis) { _localCache = localCache; _redis = redis.GetDatabase(); } public async Task\u0026lt;T?\u0026gt; GetAsync\u0026lt;T\u0026gt;(string key) where T : class { // L1: 本地缓存 if (_localCache.TryGetValue(key, out T? localValue)) { return localValue; } // L2: Redis var redisValue = await _redis.StringGetAsync(key); if (redisValue.HasValue) { var result = JsonSerializer.Deserialize\u0026lt;T\u0026gt;(redisValue!); // 回填本地缓存（短过期） _localCache.Set(key, result, TimeSpan.FromMinutes(1)); return result; } return null; } } 方案三：高可用部署\n1 2 3 4 Redis 宕机导致雪崩的预防： - 主从复制 + 哨兵（自动故障转移） - Redis Cluster（分片 + 高可用） - 限流降级（数据库前加限流，防止被打挂） 七、缓存最佳实践 7.1 过期时间设置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 短过期（1-5 分钟）： - 验证码 - Session - 短时效的查询结果 中过期（30-60 分钟）： - 商品信息 - 用户资料 - 配置信息 长过期（1-24 小时）： - 不常变化的数据 - 字典数据 - 静态内容 永远不过期： - 热点数据（由应用主动更新） 7.2 缓存粒度 1 2 3 4 粒度太粗：缓存整个页面 → 更新频繁，命中率低 粒度太细：缓存每个字段 → 管理复杂，请求次数多 推荐：按业务实体缓存（一个商品、一个用户） 7.3 缓存预热 1 2 3 4 5 6 系统启动时，主动加载热点数据到缓存： - 启动时加载排行榜 TOP 100 - 启动时加载热门商品 - 启动时加载系统配置 避免：系统刚启动时大量请求直接打到数据库 八、三大问题对比 1 2 3 4 5 问题 原因 解决方案 ──────── ────────────────── ────────────── 穿透 查询不存在的数据 缓存空值、布隆过滤器、参数校验 击穿 热点 key 过期瞬间 互斥锁、逻辑过期、永不过期 雪崩 大量 key 同时过期 过期时间加随机、多级缓存、高可用 九、小结 本文学习了缓存实战：\n缓存模式（Cache Aside、Read/Write Through） 过期策略（惰性删除 + 定期删除） 淘汰策略（LRU、LFU、TTL 等 8 种） 缓存穿透及解决方案（缓存空值、布隆过滤器） 缓存击穿及解决方案（互斥锁、逻辑过期） 缓存雪崩及解决方案（随机过期、多级缓存） 缓存最佳实践 下一篇将学习分布式锁：实现原理、锁续期和 .NET 封装。\n","date":"2025-01-16T10:00:00+08:00","permalink":"/posts/middleware/redis/02-redis-caching/","title":"Redis 学习笔记（二）：缓存实战"},{"content":"写在前面 本文是 Redis 学习笔记系列的第一篇，介绍 Redis 的核心概念、5 种基本数据类型及其应用场景，以及 .NET 客户端的使用。基于 Redis 7.x 版本。\n一、Redis 是什么 1.1 定义 Redis（Remote Dictionary Server）是一个基于内存的键值存储系统，可以用作数据库、缓存和消息中间件。\n1 2 3 4 5 6 核心特征： 1. 基于内存 — 读写极快（10万+ QPS） 2. 单线程 — 无并发竞争问题（Redis 6.0+ 网络 I/O 多线程） 3. 持久化 — 支持RDB和AOF两种方式，数据不丢 4. 丰富数据类型 — String、Hash、List、Set、Sorted Set 等 5. 高可用 — 主从复制、哨兵、集群 1.2 为什么 Redis 这么快 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 1. 基于内存 内存读取速度是纳秒级，磁盘是毫秒级 差距 10万 倍 2. 单线程 + IO 多路复用 单线程避免了锁竞争和上下文切换 epoll/kqueue 实现高效网络 IO 命令排队串行执行，没有并发问题 3. 高效的数据结构 底层用 C 实现了 SDS、跳表、压缩列表等高效结构 4. Redis 6.0+ 的多线程 网络读写用多线程（IO 多路复用仍然是单线程） 命令执行仍然是单线程 只提升网络 IO 性能，不改变数据一致性模型 1.3 典型应用场景 1 2 3 4 5 6 7 8 缓存 — 最常见的用法，热点数据缓存 分布式锁 — setnx 实现互斥 计数器 — 文章阅读数、点赞数 排行榜 — Sorted Set 实现 会话存储 — 用户登录状态（Session） 限流 — 滑动窗口限流 地理位置 — 附近的人、店铺 发布订阅 — 消息通知 二、安装 2.1 Docker 安装 1 2 3 4 5 docker run -d \\ --name redis \\ -p 6379:6379 \\ -v redis-data:/data \\ redis:7.4 redis-server --appendonly yes 1 2 3 4 --appendonly yes — 开启 AOF 持久化 连接： redis-cli -h localhost -p 6379 2.2 Windows 安装 1 2 3 4 5 # 方式一：winget winget install Redis.Redis # 方式二：下载安装包 # https://github.com/tporadowski/redis/releases 2.3 验证 1 2 3 4 5 redis-cli ping # 返回 PONG 表示连接成功 # 查看版本 redis-server --version 三、数据类型详解 3.1 String（字符串） 最基础的类型，可以存储字符串、数字、JSON、二进制数据（最大 512MB）。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 # 基本操作 SET name \u0026#34;张三\u0026#34; GET name # \u0026#34;张三\u0026#34; # SET 选项 SET lock \u0026#34;1\u0026#34; EX 30 NX # EX 过期时间（秒），NX 不存在才设置（分布式锁） SET user:1 \u0026#39;{\u0026#34;name\u0026#34;:\u0026#34;张三\u0026#34;,\u0026#34;age\u0026#34;:25}\u0026#39; # 存储 JSON # 批量操作 MSET key1 \u0026#34;v1\u0026#34; key2 \u0026#34;v2\u0026#34; key3 \u0026#34;v3\u0026#34; MGET key1 key2 key3 # 数值操作 SET counter 100 INCR counter # 101（原子自增） INCRBY counter 10 # 111 DECR counter # 110 INCRBYFLOAT price 0.5 # 浮点数增减 # 过期时间 SET session:token \u0026#34;user_data\u0026#34; EX 1800 # 30 分钟后过期 EXPIRE name 60 # 设置已存在的 key 过期时间 TTL name # 查看剩余过期时间（-1 永不过期，-2 已过期） # 其他 STRLEN name # 字符串长度 APPEND name \u0026#34;先生\u0026#34; # 追加 SETEX cache:key 300 \u0026#34;data\u0026#34; # 设置值 + 过期时间（原子操作） 1 2 3 4 5 6 应用场景： - 缓存（存 JSON） - 计数器（INCR 原子操作） - 分布式锁（SET NX EX） - Session 存储 - 限流（INCR + EXPIRE） 3.2 Hash（哈希） 键值对集合，类似 C# 的 Dictionary\u0026lt;string, string\u0026gt;。适合存储对象。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 # 基本操作 HSET user:1 name \u0026#34;张三\u0026#34; age 25 email \u0026#34;zhang@test.com\u0026#34; HGET user:1 name # \u0026#34;张三\u0026#34; HGET user:1 age # \u0026#34;25\u0026#34; # 批量操作 HMSET user:2 name \u0026#34;李四\u0026#34; age 30 role \u0026#34;admin\u0026#34; # 获取所有字段 HGETALL user:1 # name: \u0026#34;张三\u0026#34;, age: \u0026#34;25\u0026#34;, email: \u0026#34;zhang@test.com\u0026#34; # 获取指定字段 HMGET user:1 name age # 字段操作 HDEL user:1 email # 删除字段 HEXISTS user:1 name # 字段是否存在（1/0） HLEN user:1 # 字段数量 # 数值操作 HINCRBY user:1 age 1 # age + 1 # 批量设置字段值（Redis 7.4+） HSET user:3 name \u0026#34;王五\u0026#34; age 28 role \u0026#34;user\u0026#34; 1 2 3 4 5 6 7 8 9 10 11 应用场景： - 存储对象（用户信息、商品信息） - 比用 String 存 JSON 更灵活（可以只读/写某个字段） - 购物车（用户ID为key，商品ID为field，数量为value） String 存对象 vs Hash 存对象： String: SET user:1 \u0026#39;{\u0026#34;name\u0026#34;:\u0026#34;张三\u0026#34;,\u0026#34;age\u0026#34;:25}\u0026#39; → 修改 age 要读出来改完再写回去 Hash: HSET user:1 name \u0026#34;张三\u0026#34; age 25 → HINCRBY user:1 age 1（直接修改某个字段） 3.3 List（列表） 有序的字符串列表，按插入顺序排序，支持从两端操作。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 # 从左/右推入 LPUSH tasks \u0026#34;task3\u0026#34; LPUSH tasks \u0026#34;task2\u0026#34; LPUSH tasks \u0026#34;task1\u0026#34; # list: [task1, task2, task3] RPUSH tasks \u0026#34;task4\u0026#34; # list: [task1, task2, task3, task4] # 从左/右弹出 LPOP tasks # \u0026#34;task1\u0026#34; RPOP tasks # \u0026#34;task4\u0026#34; # 阻塞弹出（阻塞直到有数据或超时） BLPOP queue:email 30 # 30 秒内等数据 # 查询 LRANGE tasks 0 -1 # 查看所有元素 LRANGE tasks 0 2 # 前3个元素 LLEN tasks # 列表长度 # 按索引操作 LINDEX tasks 0 # 第一个元素 LSET tasks 0 \u0026#34;updated\u0026#34; # 修改指定位置 # 裁剪（保留指定范围） LTRIM tasks 0 99 # 只保留前100个 1 2 3 4 5 应用场景： - 消息队列（LPUSH + BRPOP） - 最新列表（最新文章、最新动态，LTRIM 保留最新N条） - 栈（LPUSH + LPOP） - 队列（LPUSH + RPOP） 3.4 Set（集合） 无序、不重复的字符串集合。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 # 基本操作 SADD tags \u0026#34;Java\u0026#34; \u0026#34;Python\u0026#34; \u0026#34;Go\u0026#34; SADD tags \u0026#34;Rust\u0026#34; # 查询 SMEMBERS tags # 所有成员 SCARD tags # 成员数量 SISMEMBER tags \u0026#34;Java\u0026#34; # 是否存在（1/0） # 删除 SREM tags \u0026#34;Rust\u0026#34; SPOP tags # 随机弹出一个 # 集合运算 SADD set-a \u0026#34;a\u0026#34; \u0026#34;b\u0026#34; \u0026#34;c\u0026#34; \u0026#34;d\u0026#34; SADD set-b \u0026#34;c\u0026#34; \u0026#34;d\u0026#34; \u0026#34;e\u0026#34; \u0026#34;f\u0026#34; SINTER set-a set-b # 交集：c, d SUNION set-a set-b # 并集：a, b, c, d, e, f SDIFF set-a set-b # 差集（a有b没有）：a, b # 随机获取 SRANDMEMBER tags 2 # 随机取2个（不删除） 1 2 3 4 5 应用场景： - 标签系统（文章标签、用户标签） - 去重（点赞用户列表、签到用户） - 交集运算（共同好友、共同关注） - 抽奖（SRANDMEMBER / SPOP 随机抽取） 3.5 Sorted Set（有序集合） 每个成员关联一个分数（score），按分数排序。Redis 中最实用的数据结构之一。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 # 添加（score + member） ZADD leaderboard 100 \u0026#34;张三\u0026#34; 95 \u0026#34;李四\u0026#34; 88 \u0026#34;王五\u0026#34; 92 \u0026#34;赵六\u0026#34; # 按分数排名（升序，0开始） ZRANGE leaderboard 0 -1 WITHSCORES # 王五:88, 赵六:92, 李四:95, 张三:100 # 降序排名 ZREVRANGE leaderboard 0 -1 WITHSCORES # 张三:100, 李四:95, 赵六:92, 王五:88 # 取 TOP N ZREVRANGE leaderboard 0 2 WITHSCORES # 前三名 # 查询成员信息 ZSCORE leaderboard \u0026#34;张三\u0026#34; # 100（查分数） ZRANK leaderboard \u0026#34;张三\u0026#34; # 排名（升序，从0开始） ZREVRANK leaderboard \u0026#34;张三\u0026#34; # 排名（降序，从0开始） # 增减分数 ZINCRBY leaderboard 5 \u0026#34;李四\u0026#34; # 李四分数 +5 # 按分数范围查询 ZRANGEBYSCORE leaderboard 90 100 WITHSCORES # 90-100 分的 # 按排名范围删除 ZREMRANGEBYRANK leaderboard 0 2 # 删除排名最低的3个 # 数量 ZCARD leaderboard # 总成员数 ZCOUNT leaderboard 90 100 # 分数在 90-100 之间的数量 1 2 3 4 5 应用场景： - 排行榜（积分榜、销量榜、热度榜） - 延迟队列（score = 执行时间戳） - 滑动窗口限流（score = 时间戳） - 带权重的数据（按分数排序取 TOP N） 四、其他数据类型 4.1 Bitmap（位图） 1 2 3 4 SETBIT sign:202605 0 1 # 第1天签到 SETBIT sign:202605 4 1 # 第5天签到 GETBIT sign:202605 0 # 第1天是否签到 BITCOUNT sign:202605 # 本月签到次数 1 应用场景：签到打卡、在线状态、布隆过滤器 4.2 HyperLogLog（基数统计） 1 2 3 PFADD uv:20260501 \u0026#34;user1\u0026#34; \u0026#34;user2\u0026#34; \u0026#34;user3\u0026#34; PFADD uv:20260501 \u0026#34;user2\u0026#34; \u0026#34;user4\u0026#34; PFCOUNT uv:20260501 # 近似去重数量：4 1 2 应用场景：UV 统计、去重计数（占用极小，12KB 可统计 2^64 个不同元素） 注意：结果有 0.81% 的误差 4.3 Stream（流） 1 2 3 4 5 6 7 8 9 10 11 # 添加消息 XADD orders * user_id 1 product \u0026#34;手机\u0026#34; amount 3999 # 返回消息 ID：1740000000000-0 # 读取消息 XREAD COUNT 10 BLOCK 5000 STREAMS orders $ # 从最新位置读取，阻塞等待5秒 # 消费者组 XGROUP CREATE orders order-group $ MKSTREAM XREADGROUP GROUP order-group consumer1 COUNT 1 STREAMS orders \u0026gt; 1 应用场景：消息队列（比 List 更强大，支持消费组、ACK、持久化） 五、通用命令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 # 键操作 KEYS * # 查看所有 key（生产环境禁用，用 SCAN） SCAN 0 MATCH user:* COUNT 100 # 安全遍历 EXISTS key1 # key 是否存在 DEL key1 key2 # 删除 key UNLINK key1 # 异步删除（大 key 用这个） TYPE key1 # 查看 key 类型 RENAME key1 key2 # 重命名 # 过期时间 EXPIRE key1 60 # 60秒后过期 PEXPIRE key1 60000 # 毫秒 TTL key1 # 剩余秒数 PERSIST key1 # 移除过期时间 # 数据库 SELECT 0 # 切换数据库（0-15，默认0） DBSIZE # 当前数据库 key 数量 FLUSHDB # 清空当前数据库 FLUSHALL # 清空所有数据库 # 信息查看 INFO memory # 内存使用情况 INFO keyspace # 各数据库 key 统计 六、.NET 客户端 6.1 安装 1 dotnet add package StackExchange.Redis 6.2 基本连接 1 2 3 4 5 6 7 8 9 // 连接 Redis var connection = ConnectionMultiplexer.Connect(\u0026#34;localhost:6379\u0026#34;); // 获取数据库（默认 db0） var db = connection.GetDatabase(); // 也可以用连接字符串 var connection = ConnectionMultiplexer.Connect( \u0026#34;localhost:6379,abortConnect=false,connectTimeout=5000,syncTimeout=5000\u0026#34;); 6.3 注册为服务 1 2 3 4 5 6 7 8 9 10 // Program.cs builder.Services.AddSingleton\u0026lt;IConnectionMultiplexer\u0026gt;( ConnectionMultiplexer.Connect(builder.Configuration.GetConnectionString(\u0026#34;Redis\u0026#34;))); // appsettings.json { \u0026#34;ConnectionStrings\u0026#34;: { \u0026#34;Redis\u0026#34;: \u0026#34;localhost:6379,abortConnect=false\u0026#34; } } 6.4 String 操作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 public class RedisCacheService { private readonly IDatabase _db; public RedisCacheService(IConnectionMultiplexer redis) { _db = redis.GetDatabase(); } // 设置值 public async Task SetAsync(string key, string value, TimeSpan? expiry = null) { await _db.StringSetAsync(key, value, expiry); } // 获取值 public async Task\u0026lt;string?\u0026gt; GetAsync(string key) { return await _db.StringGetAsync(key); } // 设置对象（序列化为 JSON） public async Task SetObjectAsync\u0026lt;T\u0026gt;(string key, T obj, TimeSpan? expiry = null) { var json = JsonSerializer.Serialize(obj); await _db.StringSetAsync(key, json, expiry); } // 获取对象 public async Task\u0026lt;T?\u0026gt; GetObjectAsync\u0026lt;T\u0026gt;(string key) { var value = await _db.StringGetAsync(key); if (value.IsNull) return default; return JsonSerializer.Deserialize\u0026lt;T\u0026gt;(value!); } // 删除 public async Task DeleteAsync(string key) { await _db.KeyDeleteAsync(key); } // 是否存在 public async Task\u0026lt;bool\u0026gt; ExistsAsync(string key) { return await _db.KeyExistsAsync(key); } // 设置过期时间 public async Task ExpireAsync(string key, TimeSpan expiry) { await _db.KeyExpireAsync(key, expiry); } } 6.5 Hash 操作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 // 存储用户信息 public class UserRedisService { private readonly IDatabase _db; public UserRedisService(IConnectionMultiplexer redis) =\u0026gt; _db = redis.GetDatabase(); public async Task SetUserAsync(int userId, string name, int age, string email) { var key = $\u0026#34;user:{userId}\u0026#34;; await _db.HashSetAsync(key, new HashEntry[] { new(\u0026#34;name\u0026#34;, name), new(\u0026#34;age\u0026#34;, age), new(\u0026#34;email\u0026#34;, email) }); await _db.KeyExpireAsync(key, TimeSpan.FromHours(24)); } public async Task\u0026lt;Dictionary\u0026lt;string, string\u0026gt;\u0026gt; GetUserAsync(int userId) { var key = $\u0026#34;user:{userId}\u0026#34;; var entries = await _db.HashGetAllAsync(key); return entries.ToDictionary( e =\u0026gt; e.Name.ToString(), e =\u0026gt; e.Value.ToString()); } public async Task\u0026lt;string?\u0026gt; GetUserFieldAsync(int userId, string field) { return await _db.HashGetAsync($\u0026#34;user:{userId}\u0026#34;, field); } public async Task IncrementUserAgeAsync(int userId) { await _db.HashIncrementAsync($\u0026#34;user:{userId}\u0026#34;, \u0026#34;age\u0026#34;); } } 6.6 Sorted Set 操作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 public class LeaderboardService { private readonly IDatabase _db; public LeaderboardService(IConnectionMultiplexer redis) =\u0026gt; _db = redis.GetDatabase(); // 更新分数 public async Task UpdateScoreAsync(string userId, double score) { await _db.SortedSetAddAsync(\u0026#34;leaderboard\u0026#34;, userId, score); } // 增加分数 public async Task AddScoreAsync(string userId, double increment) { await _db.SortedSetIncrementAsync(\u0026#34;leaderboard\u0026#34;, userId, increment); } // 获取 TOP N（降序） public async Task\u0026lt;List\u0026lt;LeaderboardEntry\u0026gt;\u0026gt; GetTopNAsync(int count) { var entries = await _db.SortedSetRangeByRankWithScoresAsync( \u0026#34;leaderboard\u0026#34;, 0, count - 1, Order.Descending); return entries.Select((e, i) =\u0026gt; new LeaderboardEntry { Rank = i + 1, UserId = e.Element.ToString(), Score = e.Score }).ToList(); } // 获取用户排名 public async Task\u0026lt;long\u0026gt; GetRankAsync(string userId) { return await _db.SortedSetRankAsync(\u0026#34;leaderboard\u0026#34;, userId, Order.Descending) + 1; } } 七、Key 命名规范 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 格式：业务:实体:标识 示例： user:1 — 用户信息 user:1:profile — 用户详细资料 cache:product:100 — 商品缓存 session:token:abc123 — Session lock:order:12345 — 分布式锁 leaderboard:daily:20260531 — 排行榜 queue:email — 邮件队列 原则： - 用冒号分隔层级 - 简短有意义 - 避免特殊字符 - 统一前缀方便管理 八、小结 本文学习了 Redis 的基础：\nRedis 是什么和为什么快 5 种核心数据类型（String、Hash、List、Set、Sorted Set） 其他类型（Bitmap、HyperLogLog、Stream） 通用命令 .NET 客户端 StackExchange.Redis Key 命名规范 下一篇将学习缓存实战：缓存模式、过期策略和三大经典问题。\n","date":"2025-01-12T10:00:00+08:00","permalink":"/posts/middleware/redis/01-redis-basics/","title":"Redis 学习笔记（一）：基础与数据类型"},{"content":"写在前面 本文是 K8s 学习笔记系列的最后一篇，介绍生产环境的排错方法、监控告警和 Helm 包管理。前置知识：安全与 RBAC（第七篇）。\n一、排错方法论 1.1 排错流程 1 2 3 4 5 6 1. 定位问题层级（应用 / K8s / 基础设施） 2. 查看 Pod 状态和事件 3. 查看容器日志 4. 进入容器排查 5. 查看相关资源状态（Service、Ingress、PVC 等） 6. 检查节点状态和资源 1.2 按症状排查 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 Pod 一直 Pending → kubectl describe pod 看 Events → 原因：资源不足、PVC 未绑定、镜像拉取失败 Pod CrashLoopBackOff → kubectl logs --previous 看崩溃日志 → 原因：应用启动失败、健康检查失败、OOMKilled Pod ImagePullBackOff → kubectl describe pod 看 Events → 原因：镜像名错误、仓库认证失败、网络不通 Service 无法访问 → kubectl get endpoints 检查是否有后端 → 原因：selector 不匹配、Pod 不 Ready、端口不对 Ingress 不通 → kubectl describe ingress 看 Events → 原因：Ingress Controller 未安装、规则配置错误、DNS 未解析 Pod 间网络不通 → 用 debug Pod 测试 DNS 和连通性 → 原因：DNS 问题、NetworkPolicy 阻断、CNI 插件问题 1.3 排错命令速查 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # Pod 排错 kubectl describe pod \u0026lt;pod\u0026gt; # 看 Events kubectl logs \u0026lt;pod\u0026gt; -f --tail=100 # 看日志 kubectl logs \u0026lt;pod\u0026gt; --previous # 看上次崩溃日志 kubectl exec -it \u0026lt;pod\u0026gt; -- sh # 进入容器 kubectl get events --sort-by=.lastTimestamp # 看集群事件 # Service 排错 kubectl get endpoints \u0026lt;service\u0026gt; # 检查后端 Pod kubectl describe svc \u0026lt;service\u0026gt; # 看 selector 和端口 # 节点排错 kubectl describe node \u0026lt;node\u0026gt; # 看条件和资源 kubectl top nodes # 看资源使用 # 网络排错 kubectl run debug --image=busybox --rm -it -- sh # 在 debug Pod 中： nslookup \u0026lt;service\u0026gt; # DNS 测试 wget -qO- http://\u0026lt;service\u0026gt;:\u0026lt;port\u0026gt; # 连通性测试 二、监控 2.1 监控体系 1 2 3 4 Metrics Server — K8s 内置指标（CPU/内存） Prometheus — 指标采集和存储 Grafana — 可视化仪表盘 Alertmanager — 告警通知 2.2 Metrics Server 1 2 3 4 5 6 7 8 9 10 # 安装 minikube addons enable metrics-server # 或 kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml # 使用 kubectl top nodes # 节点资源 kubectl top pods # Pod 资源 kubectl top pods -n dev # 指定命名空间 kubectl top pods --sort-by=memory # 按内存排序 2.3 Prometheus + Grafana 1 2 3 4 5 6 7 8 9 10 11 12 # 用 Helm 安装 kube-prometheus-stack（包含 Prometheus + Grafana + Alertmanager） helm repo add prometheus-community https://prometheus-community.github.io/helm-charts helm repo update helm install monitoring prometheus-community/kube-prometheus-stack -n monitoring --create-namespace # 查看 kubectl get pods -n monitoring # 访问 Grafana kubectl port-forward svc/monitoring-grafana 3000:80 -n monitoring # 浏览器访问 http://localhost:3000 # 默认账号：admin / prom-operator 2.4 关键监控指标 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Pod 层面： CPU 使用率 → container_cpu_usage_seconds_total 内存使用率 → container_memory_working_set_bytes 重启次数 → kube_pod_container_status_restarts_total OOMKilled → kube_pod_container_status_last_terminated_reason 节点层面： CPU 使用率 → node_cpu_seconds_total 内存使用率 → node_memory_MemAvailable_bytes 磁盘使用 → node_filesystem_avail_bytes 网络流量 → node_network_transmit_bytes_total 应用层面： HTTP 请求量 → 自定义 metrics 响应时间 → 自定义 metrics 错误率 → 自定义 metrics 三、日志管理 3.1 日志架构 1 2 3 4 5 6 7 容器 stdout/stderr → 容器运行时收集 → kubelet 存储 ↓ 日志收集 Agent（Fluentd/Filebeat） ↓ 日志存储（Elasticsearch / Loki） ↓ 日志查询（Kibana / Grafana） 3.2 应用日志最佳实践 1 2 3 4 5 6 7 // 日志输出到 stdout/stderr（K8s 自动收集） log.Println(\u0026#34;处理请求\u0026#34;, \u0026#34;path\u0026#34;, r.URL.Path, \u0026#34;status\u0026#34;, statusCode) // 结构化日志（方便日志平台解析） // 使用 JSON 格式 log.Printf(`{\u0026#34;time\u0026#34;:\u0026#34;%s\u0026#34;,\u0026#34;level\u0026#34;:\u0026#34;INFO\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;请求处理\u0026#34;,\u0026#34;path\u0026#34;:\u0026#34;%s\u0026#34;,\u0026#34;status\u0026#34;:%d}`, time.Now().Format(time.RFC3339), r.URL.Path, statusCode) 1 2 3 4 5 原则： 1. 日志输出到 stdout/stderr 2. 不写文件（或写文件也同步到 stdout） 3. 使用 JSON 格式（方便解析） 4. 包含 trace_id（方便链路追踪） 3.3 查看日志 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 单个 Pod kubectl logs \u0026lt;pod\u0026gt; -f # 多副本 Deployment 的所有 Pod kubectl logs -l app=web-app --all-containers # 指定时间段 kubectl logs \u0026lt;pod\u0026gt; --since=1h # 多容器 Pod kubectl logs \u0026lt;pod\u0026gt; -c \u0026lt;container\u0026gt; # 之前的容器（崩溃后） kubectl logs \u0026lt;pod\u0026gt; --previous 四、Helm 包管理 Helm 是 K8s 的包管理器，类似 apt/yum，简化应用的部署和管理。\n4.1 安装 Helm 1 2 3 4 5 6 7 8 # Windows winget install Helm.Helm # macOS brew install helm # 验证 helm version 4.2 基本使用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 # 添加仓库 helm repo add bitnami https://charts.bitnami.com/bitnami helm repo update # 搜索 Chart helm search repo nginx helm search repo nginx --versions # 查看所有版本 # 安装 helm install my-nginx bitnami/nginx # 默认配置 helm install my-nginx bitnami/nginx -n dev # 指定命名空间 helm install my-nginx bitnami/nginx -f values.yaml # 自定义配置 # 查看 helm list # 查看已安装的 Release helm status my-nginx # 查看 Release 状态 helm history my-nginx # 查看历史版本 # 更新 helm upgrade my-nginx bitnami/nginx -f values.yaml # 回滚 helm rollback my-nginx 1 # 回滚到版本1 # 卸载 helm uninstall my-nginx helm uninstall my-nginx -n dev 4.3 自定义 values 1 2 3 4 5 # 查看 Chart 的默认配置 helm show values bitnami/nginx \u0026gt; values.yaml # 编辑 values.yaml 后安装 helm install my-nginx bitnami/nginx -f values.yaml 常用 values 示例：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # values.yaml replicaCount: 3 resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 256Mi service: type: NodePort port: 80 ingress: enabled: true hostname: app.example.com tls: true tlsSecret: tls-secret 4.4 常用 Helm 仓库 1 2 3 bitnami — https://charts.bitnami.com/bitnami prometheus-community — https://prometheus-community.github.io/helm-charts ingress-nginx — https://kubernetes.github.io/ingress-nginx 4.5 常用应用安装 1 2 3 4 5 6 7 8 9 10 11 12 13 # Nginx Ingress Controller helm install ingress-nginx ingress-nginx/ingress-nginx \\ -n ingress-nginx --create-namespace # Prometheus + Grafana helm install monitoring prometheus-community/kube-prometheus-stack \\ -n monitoring --create-namespace # Redis helm install redis bitnami/redis -n dev # MySQL helm install mysql bitnami/mysql -n dev 五、CI/CD 集成 5.1 常用方案 1 2 3 4 GitLab CI + Helm — 提交代码 → 构建镜像 → helm upgrade GitHub Actions + kubectl — 推送到 main → 构建镜像 → kubectl apply ArgoCD — GitOps 方式，监控 Git 仓库自动同步到集群 Jenkins + Helm — 经典方案，适合复杂流水线 5.2 GitOps 模式（推荐） 1 2 3 4 5 6 7 8 9 10 1. 开发提交代码到 Git 2. CI 构建镜像并推送镜像仓库 3. 更新 K8s YAML 中的镜像版本 4. ArgoCD 检测到 Git 变更，自动同步到集群 优势： - 所有变更都有 Git 记录 - 配置和代码一起管理 - 回滚就是 git revert - 多环境管理清晰 5.3 开发者工作流 1 2 3 4 5 6 7 8 9 10 开发阶段： 1. 本地开发（minikube 或 Docker Compose） 2. 写好 K8s YAML（Deployment + Service + ConfigMap） 3. 本地测试通过 部署阶段： 1. 构建镜像推到仓库 2. 更新 YAML 中的镜像版本 3. kubectl apply 或 helm upgrade 4. 验证部署结果 六、运维常用操作 6.1 集群维护 1 2 3 4 5 6 7 8 9 # 节点维护（驱逐 Pod + 标记不可调度） kubectl drain \u0026lt;node\u0026gt; --ignore-daemonsets --delete-emptydir-data # 维护完成后恢复 kubectl uncordon \u0026lt;node\u0026gt; # 查看集群资源总量 kubectl top nodes kubectl describe nodes | grep -A 5 \u0026#34;Allocated resources\u0026#34; 6.2 清理操作 1 2 3 4 5 6 7 8 9 # 清理 Completed 的 Pod kubectl delete pods --field-selector=status.phase=Succeeded -A # 清理 Evicted 的 Pod kubectl get pods -A | grep Evicted | \\ awk \u0026#39;{print \u0026#34;kubectl delete pod \u0026#34;$2\u0026#34; -n \u0026#34;$1}\u0026#39; | sh # 清理失败的 Job kubectl delete jobs --field-selector status.successful!=1 -A 6.3 资源用量分析 1 2 3 4 5 6 7 8 9 # 查看所有 Deployment 的镜像版本和副本数 kubectl get deploy -A -o custom-columns=\u0026#39;NAMESPACE:.metadata.namespace,NAME:.metadata.name,REPLICAS:.spec.replicas,IMAGE:.spec.template.spec.containers[0].image\u0026#39; # 查看各命名空间的 Pod 数量 kubectl get pods -A --no-headers | awk \u0026#39;{print $1}\u0026#39; | sort | uniq -c | sort -rn # 查看资源配额使用 kubectl describe resourcequota -n dev kubectl describe limitrange -n dev 七、小结 本文学习了 K8s 的排错与运维：\n排错方法论和常见问题排查 监控体系（Metrics Server、Prometheus、Grafana） 日志管理和最佳实践 Helm 包管理 CI/CD 集成和 GitOps 运维常用操作 到此 K8s 学习笔记系列全部完成。从集群搭建、Pod 管理、工作负载、网络、存储、调度、安全到运维，覆盖了 K8s 的核心知识。\n","date":"2025-01-04T10:00:00+08:00","permalink":"/posts/cloudnative/k8s/fundamentals/08-k8s-ops/","title":"K8s 学习笔记（八）：排错与运维"},{"content":"写在前面 本文是 K8s 学习笔记系列的第七篇，介绍 K8s 的安全机制：Namespace 隔离、RBAC 权限控制、SecurityContext 和安全最佳实践。前置知识：调度与扩缩（第六篇）。\n一、Namespace 隔离 Namespace 是 K8s 资源隔离的基本单位。\n1.1 Namespace 的作用 1 2 3 4 资源隔离 — 不同团队/环境的资源互相不可见 权限控制 — RBAC 按 Namespace 授权 资源配额 — 限制每个 Namespace 的资源使用 命名隔离 — 不同 Namespace 中资源可以同名 Namespace 只隔离资源对象，不隔离网络（默认情况下 Pod 可以跨 Namespace 通信）。\n1.2 Namespace 管理 1 2 3 4 5 6 7 8 9 10 11 12 13 # 查看所有 Namespace kubectl get ns # 创建 kubectl create ns dev kubectl create ns staging kubectl create ns prod # 查看某个 Namespace 的所有资源 kubectl get all -n dev # 在指定 Namespace 操作 kubectl apply -f deploy.yaml -n dev 1.3 环境划分示例 1 2 3 4 5 6 default — 默认命名空间（别放正式资源） dev — 开发环境 staging — 预发环境 prod — 生产环境 kube-system — 系统组件（kube-apiserver、CoreDNS 等） monitoring — 监控组件（Prometheus、Grafana） 1.4 ResourceQuota（资源配额） 限制 Namespace 的总资源使用：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 apiVersion: v1 kind: ResourceQuota metadata: name: dev-quota namespace: dev spec: hard: requests.cpu: \u0026#34;10\u0026#34; # 总 CPU 上限 requests.memory: \u0026#34;20Gi\u0026#34; # 总内存上限 limits.cpu: \u0026#34;20\u0026#34; limits.memory: \u0026#34;40Gi\u0026#34; pods: \u0026#34;50\u0026#34; # 最大 Pod 数 services: \u0026#34;20\u0026#34; # 最大 Service 数 persistentvolumeclaims: \u0026#34;20\u0026#34; # 最大 PVC 数 1.5 LimitRange（默认资源限制） 给 Namespace 中的 Pod 设置默认 resources：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 apiVersion: v1 kind: LimitRange metadata: name: dev-limits namespace: dev spec: limits: - max: cpu: \u0026#34;2\u0026#34; memory: \u0026#34;2Gi\u0026#34; min: cpu: \u0026#34;50m\u0026#34; memory: \u0026#34;64Mi\u0026#34; default: cpu: \u0026#34;500m\u0026#34; memory: \u0026#34;256Mi\u0026#34; defaultRequest: cpu: \u0026#34;100m\u0026#34; memory: \u0026#34;128Mi\u0026#34; type: Container 二、RBAC 权限控制 RBAC（Role-Based Access Control）控制谁能在集群中做什么。\n2.1 RBAC 核心概念 1 2 3 4 Role — 角色，定义权限（Namespace 级别） ClusterRole — 集群角色，定义权限（集群级别） RoleBinding — 把角色绑定给用户/组（Namespace 级别） ClusterRoleBinding — 把集群角色绑定给用户/组（集群级别） 2.2 Role 示例 1 2 3 4 5 6 7 8 9 10 11 12 apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: dev-developer namespace: dev rules: - apiGroups: [\u0026#34;\u0026#34;, \u0026#34;apps\u0026#34;] # \u0026#34;\u0026#34; = core API, \u0026#34;apps\u0026#34; = Deployment 等 resources: [\u0026#34;pods\u0026#34;, \u0026#34;deployments\u0026#34;, \u0026#34;services\u0026#34;, \u0026#34;configmaps\u0026#34;] verbs: [\u0026#34;get\u0026#34;, \u0026#34;list\u0026#34;, \u0026#34;watch\u0026#34;, \u0026#34;create\u0026#34;, \u0026#34;update\u0026#34;, \u0026#34;delete\u0026#34;] - apiGroups: [\u0026#34;\u0026#34;] resources: [\u0026#34;secrets\u0026#34;] verbs: [\u0026#34;get\u0026#34;, \u0026#34;list\u0026#34;] # Secret 只读 2.3 RoleBinding 示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: dev-developer-binding namespace: dev subjects: - kind: User name: zhangsan # 绑定给用户 - kind: ServiceAccount name: dev-sa # 绑定给 ServiceAccount roleRef: kind: Role name: dev-developer apiGroup: rbac.authorization.k8s.io 2.4 ClusterRole 示例 1 2 3 4 5 6 7 8 apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: pod-reader rules: - apiGroups: [\u0026#34;\u0026#34;] resources: [\u0026#34;pods\u0026#34;] verbs: [\u0026#34;get\u0026#34;, \u0026#34;list\u0026#34;, \u0026#34;watch\u0026#34;] 2.5 常用 verbs 1 2 3 4 5 6 7 8 get — 获取单个资源 list — 列出资源 watch — 监听资源变化 create — 创建资源 update — 更新资源 delete — 删除资源 patch — 部分更新 * — 所有操作 2.6 常用 ClusterRole 绑定场景 1 2 3 4 5 6 7 8 9 10 # 给用户只读权限 kubectl create clusterrolebinding zhangsan-view \\ --clusterrole=view \\ --user=zhangsan # 给用户 admin 权限（限于某个 Namespace） kubectl create rolebinding zhangsan-admin \\ --clusterrole=admin \\ --user=zhangsan \\ --namespace=dev 三、ServiceAccount ServiceAccount 是 Pod 访问 K8s API 的身份。\n3.1 默认 ServiceAccount 每个 Namespace 都有一个默认的 ServiceAccount（default），Pod 默认使用它。\n3.2 创建 ServiceAccount 1 2 3 4 5 apiVersion: v1 kind: ServiceAccount metadata: name: app-sa namespace: dev 3.3 Pod 指定 ServiceAccount 1 2 3 spec: serviceAccountName: app-sa # 指定 ServiceAccount automountServiceAccountToken: true 3.4 应用场景 1 2 3 Pod 调用 K8s API → 用 ServiceAccount + RBAC 授权 CI/CD 部署 → 用 ServiceAccount 的 kubeconfig 第三方集成 → 用 ServiceAccount 认证 四、SecurityContext SecurityContext 控制 Pod 和容器的安全设置。\n4.1 Pod 级别 1 2 3 4 5 spec: securityContext: runAsNonRoot: true # 不允许以 root 运行 runAsUser: 1000 # 以 UID 1000 运行 fsGroup: 2000 # 文件系统组 4.2 容器级别 1 2 3 4 5 6 7 8 spec: containers: - name: app securityContext: allowPrivilegeEscalation: false # 不允许提权 readOnlyRootFilesystem: true # 只读根文件系统 capabilities: drop: [\u0026#34;ALL\u0026#34;] # 去掉所有 Linux capabilities 4.3 安全最佳实践 1 2 3 4 5 6 7 8 9 10 11 12 13 # 安全的 Pod 配置 spec: securityContext: runAsNonRoot: true runAsUser: 1000 fsGroup: 2000 containers: - name: app securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: [\u0026#34;ALL\u0026#34;] 五、Secret 安全 5.1 Secret 风险 1 2 3 默认只是 base64 编码，不是加密 etcd 中明文存储（除非开启加密） 任何人有权限就能解码查看 5.2 安全措施 1 2 3 4 1. 开启 etcd 加密（EncryptionConfiguration） 2. 使用外部密钥管理（HashiCorp Vault、AWS Secrets Manager） 3. RBAC 限制 Secret 访问权限 4. 审计日志记录 Secret 访问 5.3 开启 Secret 加密 1 2 3 4 5 6 7 8 9 10 11 apiVersion: apiserver.config.k8s.io/v1 kind: EncryptionConfiguration resources: - resources: - secrets providers: - aescbc: keys: - name: key1 secret: \u0026lt;base64-encoded-secret\u0026gt; - identity: {} 六、安全检查清单 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 Pod 安全： □ 不以 root 运行（runAsNonRoot: true） □ 只读文件系统（readOnlyRootFilesystem: true） □ 去掉 capabilities（drop ALL） □ 设置 resources limits（防止资源耗尽） RBAC： □ 不用 cluster-admin 给普通用户 □ 按 Namespace 和角色分配最小权限 □ ServiceAccount 按需创建，不用 default Secret： □ 敏感信息用 Secret 不用 ConfigMap □ 开启 etcd 加密 □ RBAC 限制 Secret 访问 网络： □ 使用 NetworkPolicy 限制 Pod 间通信 □ Ingress 配置 TLS □ 不暴露不必要的 NodePort 七、小结 本文学习了 K8s 的安全机制：\nNamespace 隔离和资源配额 RBAC 权限控制（Role、ClusterRole、Binding） ServiceAccount 身份管理 SecurityContext 容器安全 Secret 安全和加密 安全检查清单 下一篇将学习排错与运维：排错方法、监控告警和 Helm 包管理。\n","date":"2024-12-31T10:00:00+08:00","permalink":"/posts/cloudnative/k8s/fundamentals/07-k8s-security/","title":"K8s 学习笔记（七）：安全与 RBAC"},{"content":"写在前面 本文是 K8s 学习笔记系列的第六篇，介绍 Pod 调度机制和自动扩缩容：标签与选择器、nodeSelector、亲和性、污点与容忍、HPA。前置知识：配置与存储（第五篇）。\n一、标签与选择器 标签（Label）是 K8s 最核心的组织机制，几乎所有资源都可以打标签。\n1.1 标签规范 1 2 3 4 5 6 7 metadata: labels: app: web-app # 应用名 env: prod # 环境 tier: frontend # 层级 version: v2.0 # 版本 team: backend # 团队 1 2 3 4 5 标签规则： - 前缀可选：team.example.com/role=admin - 值最多63字符 - 只能字母、数字、-、_、. - 建议用统一的标签体系 1.2 标签操作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # 查看标签 kubectl get pods --show-labels kubectl get nodes --show-labels # 按标签筛选 kubectl get pods -l app=web-app kubectl get pods -l \u0026#39;env in (prod, staging)\u0026#39; kubectl get pods -l \u0026#39;env!=dev\u0026#39; kubectl get pods -l \u0026#39;version in (v1, v2),app=web-app\u0026#39; # 添加/修改标签 kubectl label pod \u0026lt;pod-name\u0026gt; env=prod kubectl label pod \u0026lt;pod-name\u0026gt; env=staging --overwrite # 删除标签 kubectl label pod \u0026lt;pod-name\u0026gt; env- # 给节点打标签 kubectl label node \u0026lt;node-name\u0026gt; disktype=ssd kubectl label node \u0026lt;node-name\u0026gt; zone=cn-east-1 1.3 选择器在 YAML 中的使用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # Service 匹配 Pod spec: selector: matchLabels: app: web-app # Deployment 匹配 Pod spec: selector: matchLabels: app: web-app matchExpressions: - key: env operator: In values: [\u0026#34;prod\u0026#34;, \u0026#34;staging\u0026#34;] 二、Pod 调度 2.1 调度过程 1 2 3 4 5 1. 用户创建 Pod 2. kube-scheduler 监听到未调度的 Pod 3. 过滤：排除不满足条件的节点 4. 打分：对可用节点评分 5. 绑定：将 Pod 绑定到得分最高的节点 2.2 nodeSelector（最简单） 直接指定 Pod 调度到有特定标签的节点：\n1 2 # 给节点打标签 kubectl label node node-1 disktype=ssd 1 2 3 spec: nodeSelector: disktype: ssd # 只调度到有这个标签的节点 2.3 nodeAffinity（更灵活） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 spec: affinity: nodeAffinity: # 必须满足（硬性要求） requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: zone operator: In values: [\u0026#34;cn-east-1\u0026#34;, \u0026#34;cn-east-2\u0026#34;] # 尽量满足（软性偏好） preferredDuringSchedulingIgnoredDuringExecution: - weight: 80 # 权重 preference: matchExpressions: - key: disktype operator: In values: [\u0026#34;ssd\u0026#34;] IgnoredDuringExecution 表示 Pod 已运行后，节点标签变化不会驱逐 Pod。\n2.4 podAffinity / podAntiAffinity 控制 Pod 之间的亲和性（倾向在一起或分开）：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 spec: affinity: # Pod 亲和：倾向调度到已有相同标签 Pod 的节点 podAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: app: cache # 和 cache Pod 在同一节点 topologyKey: kubernetes.io/hostname # Pod 反亲和：避免调度到已有相同 Pod 的节点（分散部署） podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchLabels: app: web-app # 尽量分散到不同节点 topologyKey: kubernetes.io/hostname 反亲和在生产中很常用：确保同一 Deployment 的副本分布在不同节点上，一个节点挂了不会影响全部实例。\n三、污点与容忍 Taint（污点）让节点排斥 Pod，Toleration（容忍）让 Pod 接受污点。\n3.1 节点污点 1 2 3 4 5 6 7 8 9 10 # 添加污点 kubectl taint nodes node-1 dedicated=gpu:NoSchedule # 不调度新 Pod kubectl taint nodes node-1 special=true:NoExecute # 不调度且驱逐已有 Pod kubectl taint nodes node-1 maintenance=true:NoSchedule # 查看污点 kubectl describe node node-1 | grep Taints # 删除污点 kubectl taint nodes node-1 dedicated=gpu:NoSchedule- 3.2 污点效果 1 2 3 NoSchedule — 不调度新 Pod（已有的不受影响） PreferNoSchedule — 尽量不调度（不是强制的） NoExecute — 不调度新 Pod + 驱逐已有的 Pod 3.3 Pod 容忍 1 2 3 4 5 6 7 8 9 10 11 12 13 spec: tolerations: - key: \u0026#34;dedicated\u0026#34; operator: \u0026#34;Equal\u0026#34; value: \u0026#34;gpu\u0026#34; effect: \u0026#34;NoSchedule\u0026#34; # 容忍所有污点（DaemonSet 常见配置） - operator: \u0026#34;Exists\u0026#34; # 容忍 Master 节点污点 - key: \u0026#34;node-role.kubernetes.io/control-plane\u0026#34; effect: \u0026#34;NoSchedule\u0026#34; 3.4 常见场景 1 2 3 4 GPU 节点 → 打 taint dedicated=gpu:NoSchedule，只有标记容忍的 Pod 能调度 Master 节点 → 默认有污点，只有系统组件有容忍 专用节点 → 打 taint 给特定业务，其他 Pod 不受影响 节点维护 → 打 taint NoExecute，驱逐 Pod 四、HPA 自动扩缩容 HPA（Horizontal Pod Autoscaler）根据指标自动调整 Pod 副本数。\n4.1 基于 CPU 的 HPA 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: web-app-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: web-app minReplicas: 2 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 # CPU 使用率超过 70% 就扩容 4.2 基于内存的 HPA 1 2 3 4 5 6 7 metrics: - type: Resource resource: name: memory target: type: Utilization averageUtilization: 80 4.2 多指标组合 1 2 3 4 5 6 7 8 9 10 11 12 13 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 - type: Resource resource: name: memory target: type: Utilization averageUtilization: 80 多指标时，HPA 取需要最多副本的指标。\n4.3 命令行创建 HPA 1 2 3 4 5 6 7 8 # 快速创建 kubectl autoscale deployment web-app --min=2 --max=10 --cpu-percent=70 # 查看 HPA kubectl get hpa # 查看 HPA 详情 kubectl describe hpa web-app-hpa 4.4 HPA 工作原理 1 2 3 4 1. Metrics Server 采集 Pod 的 CPU/内存指标 2. HPA Controller 定期检查指标（默认15秒） 3. 计算目标副本数 = 当前副本数 × (当前指标值 / 目标值) 4. 调整 Deployment 的 replicas 4.5 安装 Metrics Server 1 2 3 4 5 6 7 8 9 # minikube minikube addons enable metrics-server # 通用安装 kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml # 验证 kubectl top nodes kubectl top pods 4.6 扩缩行为配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 spec: behavior: scaleUp: stabilizationWindowSeconds: 0 # 扩容不等待 policies: - type: Percent value: 100 # 一次最多扩容 100%（翻倍） periodSeconds: 15 scaleDown: stabilizationWindowSeconds: 300 # 缩容等待5分钟 policies: - type: Percent value: 10 # 一次最多缩容 10% periodSeconds: 60 五、调度场景总结 1 2 3 4 5 指定节点 → nodeSelector 灵活调度 → nodeAffinity Pod 分散 → podAntiAffinity 专用节点 → Taint + Toleration 自动扩缩容 → HPA 六、小结 本文学习了 K8s 的调度与扩缩：\n标签与选择器 nodeSelector 和 nodeAffinity podAffinity / podAntiAffinity Taint 和 Toleration HPA 自动扩缩容 下一篇将学习安全与 RBAC。\n","date":"2024-12-27T10:00:00+08:00","permalink":"/posts/cloudnative/k8s/fundamentals/06-k8s-scheduling/","title":"K8s 学习笔记（六）：调度与扩缩"},{"content":"写在前面 本文是 K8s 学习笔记系列的第五篇，介绍配置管理（ConfigMap、Secret）和存储（PV/PVC、StorageClass）。前置知识：服务发现与网络（第四篇）。\n一、ConfigMap ConfigMap 用于存储非敏感的配置数据，让配置和镜像解耦。\n1.1 创建 ConfigMap 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 从字面量创建 kubectl create configmap app-config \\ --from-literal=APP_ENV=production \\ --from-literal=APP_PORT=8080 \\ --from-literal=DB_HOST=mysql.prod # 从文件创建 kubectl create configmap nginx-config --from-file=nginx.conf # 从目录创建（每个文件变成一个 key） kubectl create configmap app-config --from-file=./config/ # 从 YAML 创建 kubectl apply -f configmap.yaml 1.2 ConfigMap YAML 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 apiVersion: v1 kind: ConfigMap metadata: name: app-config data: # 键值对 APP_ENV: \u0026#34;production\u0026#34; APP_PORT: \u0026#34;8080\u0026#34; DB_HOST: \u0026#34;mysql.prod\u0026#34; # 完整配置文件 application.yml: | server: port: 8080 spring: datasource: url: jdbc:mysql://mysql.prod:3306/mydb 1.3 在 Pod 中使用 方式1：环境变量 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 spec: containers: - name: app env: - name: APP_ENV valueFrom: configMapKeyRef: name: app-config key: APP_ENV - name: DB_HOST valueFrom: configMapKeyRef: name: app-config key: DB_HOST # 或者全部加载为环境变量 envFrom: - configMapRef: name: app-config 方式2：挂载为文件 1 2 3 4 5 6 7 8 9 10 11 12 spec: containers: - name: app volumeMounts: - name: config-volume mountPath: /etc/config readOnly: true volumes: - name: config-volume configMap: name: app-config 挂载后文件结构：\n1 2 3 4 5 /etc/config/ ├── APP_ENV # 内容：production ├── APP_PORT # 内容：8080 ├── DB_HOST # 内容：mysql.prod └── application.yml # 内容：完整 YAML 方式3：挂载单个文件 1 2 3 4 5 6 7 volumes: - name: config-volume configMap: name: app-config items: # 只挂载指定 key - key: application.yml path: application.yml 1.4 更新 ConfigMap 1 2 3 4 5 # 直接编辑 kubectl edit configmap app-config # 更新后，挂载为文件的 Pod 会自动刷新（有延迟，约1分钟） # 通过环境变量注入的不会自动更新，需要重启 Pod 二、Secret Secret 用于存储敏感信息（密码、Token、证书），数据以 base64 编码存储。\nSecret 只是编码不是加密，生产环境建议开启 EncryptionConfiguration 或用外部密钥管理（Vault）。\n2.1 创建 Secret 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # 从字面量创建 kubectl create secret generic db-secret \\ --from-literal=username=admin \\ --from-literal=password=\u0026#39;P@ssw0rd\u0026#39; # 从文件创建 kubectl create secret generic tls-secret \\ --from-file=tls.crt=./server.crt \\ --from-file=tls.key=./server.key # 创建 Docker 仓库认证 Secret kubectl create secret docker-registry regcred \\ --docker-server=registry.example.com \\ --docker-username=user \\ --docker-password=password 2.2 Secret YAML 1 2 3 4 5 6 7 8 9 10 11 12 13 apiVersion: v1 kind: Secret metadata: name: db-secret type: Opaque # 通用类型 data: username: YWRtaW4= # base64 编码的 \u0026#34;admin\u0026#34; password: UEBzc3cwcmQ= # base64 编码的 \u0026#34;P@ssw0rd\u0026#34; # 也可以用 stringData（明文，K8s 会自动编码） stringData: username: admin password: P@ssw0rd 2.3 Secret 类型 1 2 3 4 5 6 Opaque — 通用（默认） docker-registry — Docker 仓库认证 tls — TLS 证书 basic-auth — 基础认证 ssh-auth — SSH 认证 token — Token 2.4 在 Pod 中使用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 # 环境变量 env: - name: DB_PASSWORD valueFrom: secretKeyRef: name: db-secret key: password # 挂载为文件 volumeMounts: - name: secret-volume mountPath: /etc/secrets readOnly: true volumes: - name: secret-volume secret: secretName: db-secret # 拉取私有镜像 spec: imagePullSecrets: - name: regcred containers: - name: app image: registry.example.com/my-app:1.0 2.5 查看 Secret 内容 1 2 3 4 5 6 7 8 # 查看列表 kubectl get secrets # 查看详情（不显示内容） kubectl describe secret db-secret # 解码查看内容 kubectl get secret db-secret -o jsonpath=\u0026#39;{.data.password}\u0026#39; | base64 --decode 三、Volume Volume 解决容器数据持久化问题。容器重启后文件会丢失，Volume 让数据独立于容器生命周期。\n3.1 Volume 类型 1 2 3 4 5 emptyDir — 临时目录，Pod 删除后数据丢失（Pod 内容器共享） hostPath — 挂载宿主机目录（单节点，不适合多节点） configMap — 挂载 ConfigMap secret — 挂载 Secret persistentVolumeClaim — 挂载持久化存储（推荐） 3.2 emptyDir Pod 内容器共享临时目录，Pod 删除后数据丢失：\n1 2 3 4 5 6 7 8 9 10 11 12 13 spec: containers: - name: app volumeMounts: - name: cache mountPath: /cache - name: sidecar volumeMounts: - name: cache mountPath: /cache volumes: - name: cache emptyDir: {} 3.3 hostPath 挂载宿主机目录（开发/测试用，不推荐生产）：\n1 2 3 4 5 volumes: - name: data hostPath: path: /data/app # 宿主机路径 type: DirectoryOrCreate 四、持久化存储（PV/PVC） PV（PersistentVolume）是集群级别的存储资源，PVC（PersistentVolumeClaim）是用户对存储的申请。\n4.1 存储架构 1 2 3 4 5 6 7 管理员创建 PV（或由 StorageClass 动态创建） ↓ 用户创建 PVC（声明需要的存储大小和访问模式） ↓ K8s 绑定 PVC 到合适的 PV ↓ Pod 通过 PVC 使用存储 4.2 PV 示例 1 2 3 4 5 6 7 8 9 10 11 12 13 apiVersion: v1 kind: PersistentVolume metadata: name: pv-10gi spec: capacity: storage: 10Gi accessModes: - ReadWriteOnce # 单节点读写 persistentVolumeReclaimPolicy: Retain # 释放后保留数据 storageClassName: standard hostPath: # 本地存储（测试用） path: /data/pv-10gi 4.3 PVC 示例 1 2 3 4 5 6 7 8 9 10 11 apiVersion: v1 kind: PersistentVolumeClaim metadata: name: app-data spec: accessModes: - ReadWriteOnce resources: requests: storage: 5Gi storageClassName: standard 4.4 在 Pod 中使用 PVC 1 2 3 4 5 6 7 8 9 10 spec: containers: - name: app volumeMounts: - name: data mountPath: /var/lib/data volumes: - name: data persistentVolumeClaim: claimName: app-data 4.5 访问模式 1 2 3 4 ReadWriteOnce（RWO） — 单节点读写（最常用） ReadOnlyMany（ROX） — 多节点只读 ReadWriteMany（RWX） — 多节点读写（需要存储后端支持） ReadWriteOncePod（RWOP） — 单 Pod 读写（K8s 1.27+） 4.6 回收策略 1 2 3 Retain — 保留数据，需要手动清理（生产推荐） Delete — 自动删除 PV 和底层存储 Recycle — 已废弃，用 Delete 替代 五、StorageClass StorageClass 定义存储类型，支持动态创建 PV。\n5.1 StorageClass 示例 1 2 3 4 5 6 7 8 9 10 apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: fast-ssd provisioner: kubernetes.io/aws-ebs # 存储提供者 parameters: type: gp3 fsType: ext4 reclaimPolicy: Delete volumeBindingMode: WaitForFirstConsumer 5.2 PVC 动态分配 1 2 3 4 5 6 7 8 9 10 11 12 # 不需要手动创建 PV，StorageClass 自动创建 apiVersion: v1 kind: PersistentVolumeClaim metadata: name: dynamic-data spec: accessModes: - ReadWriteOnce storageClassName: fast-ssd # 指定 StorageClass resources: requests: storage: 10Gi 5.3 minikube 的默认存储 1 2 3 4 5 6 7 8 9 # minikube 自带 standard StorageClass kubectl get storageclass # NAME PROVISIONER RECLAIMPOLICY # standard (default) k8s.io/minikube-hostpath Delete # 创建 PVC 后自动创建 PV kubectl apply -f pvc.yaml kubectl get pv # 自动创建的 PV kubectl get pvc # PVC 自动绑定 六、StatefulSet + PVC 模式 StatefulSet 配合 volumeClaimTemplates，每个 Pod 自动获得独立的持久化存储：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 apiVersion: apps/v1 kind: StatefulSet metadata: name: mysql spec: serviceName: mysql replicas: 3 template: spec: containers: - name: mysql image: mysql:8.0 volumeMounts: - name: data mountPath: /var/lib/mysql volumeClaimTemplates: - metadata: name: data spec: accessModes: [\u0026#34;ReadWriteOnce\u0026#34;] storageClassName: standard resources: requests: storage: 10Gi 1 2 3 4 5 6 自动创建的 PVC： mysql-data-mysql-0 → mysql-0 的存储 mysql-data-mysql-1 → mysql-1 的存储 mysql-data-mysql-2 → mysql-2 的存储 Pod 重建后，PVC 不会删除，Pod 自动绑定回原来的 PVC 七、开发者视角：配置管理最佳实践 7.1 配置分层 1 2 3 4 环境变量 → 非敏感配置（APP_ENV、APP_PORT） ConfigMap → 配置文件、环境变量（不同环境不同 ConfigMap） Secret → 密码、Token、证书 PVC → 需要持久化的数据 7.2 应用适配 1 2 3 4 5 6 7 8 func main() { // 从环境变量读取配置 dbHost := os.Getenv(\u0026#34;DB_HOST\u0026#34;) dbPassword := os.Getenv(\u0026#34;DB_PASSWORD\u0026#34;) // 本地开发用 .env 文件，K8s 用 ConfigMap/Secret 注入 // 代码不需要感知配置来源 } 7.3 不同环境管理 1 2 3 4 5 dev 环境配置 → app-config-dev staging 配置 → app-config-staging prod 配置 → app-config-prod 部署时通过 -n 或 -f 指定对应的 ConfigMap 八、小结 本文学习了 K8s 的配置与存储：\nConfigMap：非敏感配置管理 Secret：敏感信息管理 Volume：容器存储 PV/PVC：持久化存储 StorageClass：动态存储分配 StatefulSet + PVC 模式 配置管理最佳实践 下一篇将学习调度与扩缩：标签选择器、节点亲和性和 HPA。\n","date":"2024-12-23T10:00:00+08:00","permalink":"/posts/cloudnative/k8s/fundamentals/05-k8s-config-storage/","title":"K8s 学习笔记（五）：配置与存储"},{"content":"写在前面 本文是 K8s 学习笔记系列的第四篇，介绍 K8s 的服务发现和网络机制：Service、Ingress 和 DNS。前置知识：工作负载管理（第三篇）。\n一、Service 概述 Pod 的 IP 是不固定的（重启后会变），Service 提供稳定的访问入口。\n1 客户端 → Service（固定 IP + 固定 DNS）→ Pod（通过标签选择器自动转发） 1.1 Service 工作原理 1 2 3 4 1. Service 通过 selector 匹配 Pod 的标签 2. kube-proxy 在每个节点上配置转发规则 3. 客户端访问 Service IP，流量被转发到后端 Pod 4. Pod 变化时，Service 自动更新后端列表（Endpoints） 二、Service 类型 2.1 ClusterIP（默认） 集群内部访问，外部不可达。\n1 2 3 4 5 6 7 8 9 10 11 apiVersion: v1 kind: Service metadata: name: web-app spec: type: ClusterIP # 默认值，可省略 selector: app: web-app ports: - port: 80 # Service 端口 targetPort: 8080 # Pod 端口 1 2 3 4 集群内访问方式： - Service 名称：http://web-app:80 - Service IP：http://10.100.200.50:80 - 完整 DNS：http://web-app.default.svc.cluster.local:80 2.2 NodePort 通过节点端口暴露服务，外部可以通过 节点IP:端口 访问。\n1 2 3 4 5 6 7 8 9 10 11 12 apiVersion: v1 kind: Service metadata: name: web-app spec: type: NodePort selector: app: web-app ports: - port: 80 # Service 端口 targetPort: 8080 # Pod 端口 nodePort: 30080 # 节点端口（30000-32767，可省略自动分配） 1 2 外部访问方式： - http://\u0026lt;任意节点IP\u0026gt;:30080 NodePort 适合测试和小规模使用，生产环境一般用 Ingress 或 LoadBalancer。\n2.3 LoadBalancer 云厂商提供的负载均衡器（AWS ELB、GCP LB 等）。\n1 2 3 4 5 6 7 8 9 10 11 apiVersion: v1 kind: Service metadata: name: web-app spec: type: LoadBalancer selector: app: web-app ports: - port: 80 targetPort: 8080 本地环境（minikube）没有真正的 LoadBalancer。可以用 minikube tunnel 模拟。\n2.4 ExternalName 将 Service 映射到外部 DNS 名称，不创建 Endpoints。\n1 2 3 4 5 6 7 apiVersion: v1 kind: Service metadata: name: external-db spec: type: ExternalName externalName: db.example.com # 外部数据库地址 1 2 集群内访问：mysql://external-db:3306 实际解析到：mysql://db.example.com:3306 三、Headless Service 不分配 ClusterIP，直接返回后端 Pod 的 IP。常配合 StatefulSet 使用。\n1 2 3 4 5 6 7 8 9 10 apiVersion: v1 kind: Service metadata: name: mysql spec: clusterIP: None # Headless selector: app: mysql ports: - port: 3306 1 2 3 4 DNS 解析结果： mysql-0.mysql.default.svc.cluster.local → Pod-0 的 IP mysql-1.mysql.default.svc.cluster.local → Pod-1 的 IP mysql-2.mysql.default.svc.cluster.local → Pod-2 的 IP StatefulSet 的每个 Pod 有固定的 DNS 名称，适合需要知道具体连接哪个实例的场景（如 MySQL 主从）。\n四、Endpoints Endpoints 是 Service 和 Pod 之间的桥梁。\n1 2 3 4 5 6 7 # 查看 Service 对应的 Endpoints kubectl get endpoints web-app # NAME ENDPOINTS AGE # web-app 10.244.1.5:8080,10.244.2.3:8080,10.244.3.7:8080 # Service 没有 selector 时，需要手动创建 Endpoints # 用于代理集群外部的服务 手动 Endpoints 示例（代理外部服务）：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # Service（不指定 selector） apiVersion: v1 kind: Service metadata: name: external-api spec: ports: - port: 80 targetPort: 80 --- # 手动指定 Endpoints apiVersion: v1 kind: Endpoints metadata: name: external-api # 和 Service 同名 subsets: - addresses: - ip: 192.168.1.100 # 外部服务 IP ports: - port: 80 五、Ingress Ingress 是 K8s 的 HTTP 路由层，类似 Nginx 反向代理，根据域名和路径转发到不同 Service。\n5.1 为什么需要 Ingress 1 2 3 4 5 6 7 8 没有 Ingress： 每个 Service 都需要 NodePort/LoadBalancer 端口管理混乱，无法按域名路由 有 Ingress： 只需要一个入口（LoadBalancer 或 NodePort） 按域名和路径路由到不同 Service 支持 TLS 证书 5.2 Ingress 示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: app-ingress annotations: nginx.ingress.kubernetes.io/rewrite-target: / spec: ingressClassName: nginx # 指定 Ingress Controller rules: - host: app.example.com # 域名 http: paths: - path: / pathType: Prefix backend: service: name: web-app # 转发到这个 Service port: number: 80 - host: api.example.com # 另一个域名 http: paths: - path: / pathType: Prefix backend: service: name: api-service port: number: 80 5.3 路径路由 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 rules: - host: app.example.com http: paths: - path: /api pathType: Prefix backend: service: name: api-service port: number: 80 - path: /static pathType: Prefix backend: service: name: static-service port: number: 80 - path: / pathType: Prefix backend: service: name: web-app port: number: 80 5.4 TLS 配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 spec: tls: - hosts: - app.example.com secretName: tls-secret # 包含 TLS 证书的 Secret rules: - host: app.example.com http: paths: - path: / pathType: Prefix backend: service: name: web-app port: number: 80 5.5 安装 Ingress Controller 1 2 3 4 5 # minikube 启用 Ingress 插件 minikube addons enable ingress # 验证 Ingress Controller kubectl get pods -n ingress-nginx 六、DNS K8s 集群内置 DNS 服务（CoreDNS），Pod 自动配置 DNS 解析。\n6.1 DNS 命名规则 1 2 3 4 \u0026lt;service-name\u0026gt; # 同命名空间 \u0026lt;service-name\u0026gt;.\u0026lt;namespace\u0026gt; # 跨命名空间 \u0026lt;service-name\u0026gt;.\u0026lt;namespace\u0026gt;.svc # 完整写法 \u0026lt;service-name\u0026gt;.\u0026lt;namespace\u0026gt;.svc.cluster.local # 完整 FQDN 6.2 跨命名空间访问 1 2 3 4 5 # 在 dev 命名空间访问 prod 命名空间的 Service mysql://mysql.prod.svc.cluster.local:3306 # 简写 mysql://mysql.prod:3306 6.3 DNS 调试 1 2 3 4 5 6 7 8 9 10 # 启动调试 Pod kubectl run debug --image=busybox --rm -it --restart=Never -- sh # 在调试 Pod 中 nslookup web-app # 同命名空间 nslookup web-app.default.svc.cluster.local # 完整域名 nslookup web-app.prod # 跨命名空间 # 查看Pod 的 DNS 配置 cat /etc/resolv.conf 七、网络策略 NetworkPolicy 控制 Pod 之间的网络访问（默认全部互通）。\n7.1 网络策略示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: api-policy namespace: prod spec: podSelector: matchLabels: app: api-server # 策略应用到这些 Pod policyTypes: - Ingress # 入站规则 ingress: - from: - podSelector: # 只允许这些 Pod 访问 matchLabels: app: web-app ports: - port: 8080 protocol: TCP 7.2 常见策略 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 # 拒绝所有入站（默认拒绝） spec: podSelector: {} policyTypes: - Ingress # 允许同命名空间访问 spec: podSelector: {} ingress: - from: - podSelector: {} # 允许指定命名空间访问 spec: podSelector: {} ingress: - from: - namespaceSelector: matchLabels: env: dev NetworkPolicy 需要网络插件支持（Calico、Cilium 等），minikube 默认不支持。\n八、开发者视角：服务接入 8.1 微服务间的调用方式 1 2 3 // 不要硬编码 IP，用 Service 名称 resp, err := http.Get(\u0026#34;http://user-service:8080/api/users\u0026#34;) resp, err := http.Get(\u0026#34;http://order-service.prod.svc.cluster.local:8080/api/orders\u0026#34;) 8.2 服务接入检查清单 1 2 3 4 5 6 7 8 9 10 11 12 13 14 1. Service selector 是否匹配 Pod 的 labels？ → kubectl get endpoints \u0026lt;service\u0026gt; 检查是否有后端 2. 端口是否正确？ → Service port → targetPort → containerPort 要对应 3. Pod 是否 Ready？ → readinessProbe 没通过的 Pod 不会接收流量 4. 跨命名空间是否用了完整 DNS？ → 用 \u0026lt;service\u0026gt;.\u0026lt;namespace\u0026gt; 格式 5. 外部访问是否配置了 Ingress？ → 检查 Ingress 规则和域名解析 九、小结 本文学习了 K8s 的服务发现与网络：\nService 四种类型（ClusterIP、NodePort、LoadBalancer、ExternalName） Headless Service 和 StatefulSet 的配合 Endpoints 和外部服务代理 Ingress HTTP 路由和 TLS DNS 命名规则和跨命名空间访问 NetworkPolicy 网络策略 开发者视角的服务接入 下一篇将学习配置与存储：ConfigMap、Secret、PV/PVC。\n","date":"2024-12-19T10:00:00+08:00","permalink":"/posts/cloudnative/k8s/fundamentals/04-k8s-network/","title":"K8s 学习笔记（四）：服务发现与网络"},{"content":"写在前面 本文是 K8s 学习笔记系列的第三篇，介绍 K8s 的工作负载管理：Deployment、StatefulSet、DaemonSet、Job 和 CronJob。前置知识：Pod 与容器管理（第二篇）。\n一、Deployment Deployment 是最常用的工作负载，管理无状态应用（Web 服务、API 等）。\n1.1 Deployment 完整示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 apiVersion: apps/v1 kind: Deployment metadata: name: web-app labels: app: web-app spec: replicas: 3 # 副本数 selector: matchLabels: app: web-app # 匹配 Pod 标签 strategy: type: RollingUpdate # 滚动更新策略 rollingUpdate: maxSurge: 1 # 更新时最多多出1个 Pod maxUnavailable: 0 # 更新时允许0个不可用 template: metadata: labels: app: web-app spec: containers: - name: web-app image: my-app:v1.0 ports: - containerPort: 8080 resources: requests: cpu: \u0026#34;100m\u0026#34; memory: \u0026#34;128Mi\u0026#34; limits: cpu: \u0026#34;500m\u0026#34; memory: \u0026#34;256Mi\u0026#34; livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 10 periodSeconds: 10 readinessProbe: httpGet: path: /ready port: 8080 initialDelaySeconds: 5 periodSeconds: 5 1.2 滚动更新 Deployment 更新镜像时会自动滚动更新：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 方式1：set image kubectl set image deployment/web-app web-app=my-app:v2.0 # 方式2：edit 直接编辑 kubectl edit deployment web-app # 方式3：apply 新的 YAML kubectl apply -f deployment-v2.yaml # 查看滚动更新状态 kubectl rollout status deployment/web-app # 查看更新历史 kubectl rollout history deployment/web-app 1.3 回滚 1 2 3 4 5 6 7 8 # 查看历史版本 kubectl rollout history deployment/web-app # 回滚到上一版本 kubectl rollout undo deployment/web-app # 回滚到指定版本 kubectl rollout undo deployment/web-app --to-revision=2 1.4 滚动更新策略 1 2 3 4 5 6 spec: strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 # 更新时额外创建的 Pod 数（可以是数字或百分比） maxUnavailable: 0 # 更新时允许不可用的 Pod 数 1 2 maxSurge: 1, maxUnavailable: 0 → 先起新 Pod，再删旧 Pod（最安全，需要多余资源） maxSurge: 0, maxUnavailable: 1 → 先删旧 Pod，再起新 Pod（省资源，有短暂不可用） 1.5 扩缩容 1 2 3 4 5 6 # 手动扩缩容 kubectl scale deployment web-app --replicas=5 kubectl scale deployment web-app --replicas=2 # 基于指标自动扩缩（HPA） kubectl autoscale deployment web-app --min=2 --max=10 --cpu-percent=70 二、StatefulSet StatefulSet 用于有状态应用（数据库、消息队列等），提供稳定的标识和持久化存储。\n2.1 和 Deployment 的区别 1 2 3 4 5 6 7 8 9 10 11 Deployment： - Pod 名随机（web-app-7854ff8879-abcde） - Pod IP 不固定 - 没有稳定的持久化标识 - 适合无状态应用 StatefulSet： - Pod 名有序（mysql-0, mysql-1, mysql-2） - 每个 Pod 有稳定的网络标识 - 每个 Pod 有独立的持久化存储 - 适合有状态应用 2.2 StatefulSet 示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 apiVersion: apps/v1 kind: StatefulSet metadata: name: mysql spec: serviceName: mysql # 关联的 Headless Service replicas: 3 selector: matchLabels: app: mysql template: metadata: labels: app: mysql spec: containers: - name: mysql image: mysql:8.0 ports: - containerPort: 3306 env: - name: MYSQL_ROOT_PASSWORD valueFrom: secretKeyRef: name: mysql-secret key: root-password volumeMounts: - name: data mountPath: /var/lib/mysql volumeClaimTemplates: # 每个 Pod 独立的 PVC - metadata: name: data spec: accessModes: [\u0026#34;ReadWriteOnce\u0026#34;] resources: requests: storage: 10Gi 2.3 Headless Service StatefulSet 通常配合 Headless Service 使用：\n1 2 3 4 5 6 7 8 9 10 apiVersion: v1 kind: Service metadata: name: mysql spec: clusterIP: None # Headless：不分配 ClusterIP selector: app: mysql ports: - port: 3306 Headless Service 让每个 Pod 有固定的 DNS 名称：mysql-0.mysql.default.svc.cluster.local\n三、DaemonSet DaemonSet 确保每个节点上运行一个 Pod 副本，常用于日志收集、监控、网络插件。\n3.1 DaemonSet 示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 apiVersion: apps/v1 kind: DaemonSet metadata: name: log-collector labels: app: log-collector spec: selector: matchLabels: app: log-collector template: metadata: labels: app: log-collector spec: tolerations: # 容忍 Master 节点的污点 - key: node-role.kubernetes.io/control-plane effect: NoSchedule containers: - name: fluentd image: fluentd:latest volumeMounts: - name: varlog mountPath: /var/log volumes: - name: varlog hostPath: path: /var/log # 挂载宿主机日志目录 3.2 常见使用场景 1 2 3 4 日志收集 — Fluentd、Filebeat 监控 Agent — Prometheus Node Exporter 网络插件 — Calico、Flannel 存储插件 — CSI Driver 四、Job Job 运行一次性任务，完成后退出。\n4.1 Job 示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 apiVersion: batch/v1 kind: Job metadata: name: data-migration spec: completions: 1 # 需要1次成功完成 parallelism: 1 # 并行数 backoffLimit: 3 # 失败重试次数 activeDeadlineSeconds: 300 # 最长运行时间（秒） template: spec: restartPolicy: Never # Job 不能用 Always containers: - name: migrate image: my-app:latest command: [\u0026#34;python\u0026#34;, \u0026#34;migrate.py\u0026#34;] 4.2 批处理 Job 1 2 3 4 # 并行处理多个任务 spec: completions: 10 # 需要成功10次 parallelism: 3 # 3个 Pod 并行执行 五、CronJob CronJob 按时间计划运行 Job。\n5.1 CronJob 示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 apiVersion: batch/v1 kind: CronJob metadata: name: backup spec: schedule: \u0026#34;0 2 * * *\u0026#34; # 每天凌晨2点（cron 格式） concurrencyPolicy: Forbid # 禁止并发执行 successfulJobsHistoryLimit: 3 # 保留3个成功记录 failedJobsHistoryLimit: 1 # 保留1个失败记录 jobTemplate: spec: template: spec: restartPolicy: OnFailure containers: - name: backup image: my-app:latest command: [\u0026#34;sh\u0026#34;, \u0026#34;-c\u0026#34;, \u0026#34;pg_dump ... | gzip \u0026gt; /backup/db.sql.gz\u0026#34;] 5.2 Cron 格式 1 2 3 4 5 6 7 8 9 10 11 12 13 ┌──────── 分钟 (0-59) │ ┌────── 小时 (0-23) │ │ ┌──── 日 (1-31) │ │ │ ┌── 月 (1-12) │ │ │ │ ┌ 星期 (0-6, 0=周日) │ │ │ │ │ * * * * * \u0026#34;*/5 * * * *\u0026#34; → 每5分钟 \u0026#34;0 * * * *\u0026#34; → 每小时整点 \u0026#34;0 2 * * *\u0026#34; → 每天凌晨2点 \u0026#34;0 0 * * 1\u0026#34; → 每周一零点 \u0026#34;0 0 1 * *\u0026#34; → 每月1号零点 5.3 手动触发 CronJob 1 kubectl create job --from=cronjob/backup backup-manual 六、工作负载选型 1 2 3 4 5 Web 服务 / API → Deployment 数据库 / 消息队列 → StatefulSet 日志 / 监控 Agent → DaemonSet 数据迁移 / 批处理 → Job 定时备份 / 报表 → CronJob 七、小结 本文学习了 K8s 的工作负载管理：\nDeployment：无状态应用、滚动更新、回滚 StatefulSet：有状态应用、稳定的网络标识和存储 DaemonSet：每个节点运行一个 Pod Job：一次性任务 CronJob：定时任务 工作负载选型 下一篇将学习服务发现与网络：Service、Ingress 和 DNS。\n","date":"2024-12-15T10:00:00+08:00","permalink":"/posts/cloudnative/k8s/fundamentals/03-k8s-workload/","title":"K8s 学习笔记（三）：工作负载管理"},{"content":"写在前面 本文是 K8s 学习笔记系列的第二篇，深入 Pod 的生命周期、多容器模式、健康检查和资源限制。前置知识：集群搭建和基本操作（第一篇）。\n一、Pod 基础 1.1 什么是 Pod Pod 是 K8s 最小的调度单元，包含一个或多个容器，共享网络和存储。\n1 2 3 4 5 6 Pod ├── 容器1（应用容器） ├── 容器2（Sidecar） ├── 共享网络（同一个 IP，localhost 互通） ├── 共享存储（Volume） └── 共享配置（环境变量、ConfigMap） 为什么不直接调度容器？因为有些容器需要紧密协作（如应用 + 日志收集），把它们放在一个 Pod 里共享网络和存储。\n1.2 Pod YAML 详解 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 apiVersion: v1 kind: Pod metadata: name: my-app labels: app: my-app env: prod spec: containers: - name: app image: my-app:1.0 ports: - containerPort: 8080 env: - name: APP_ENV value: \u0026#34;production\u0026#34; - name: DB_PASSWORD valueFrom: secretKeyRef: name: db-secret key: password resources: requests: memory: \u0026#34;128Mi\u0026#34; cpu: \u0026#34;100m\u0026#34; limits: memory: \u0026#34;256Mi\u0026#34; cpu: \u0026#34;500m\u0026#34; restartPolicy: Always 1.3 Pod 生命周期 1 2 3 4 5 创建 Pod → 调度到节点 → 拉取镜像 → 启动容器 → 运行中 ↓ 崩溃/健康检查失败 ↓ 重启容器 Pod 的 phase（阶段）：\n1 2 3 4 5 Pending — 等待调度或拉取镜像 Running — 至少一个容器在运行 Succeeded — 所有容器正常退出（不会重启） Failed — 至少一个容器异常退出 Unknown — 无法获取状态 1.4 容器重启策略 1 2 3 4 spec: restartPolicy: Always # 总是重启（默认，适合长期运行的服务） restartPolicy: OnFailure # 只在失败时重启（适合 Job） restartPolicy: Never # 从不重启 二、多容器模式 2.1 Sidecar（边车模式） 主容器 + 辅助容器，最常用的模式：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 apiVersion: v1 kind: Pod metadata: name: app-with-sidecar spec: containers: - name: app # 主容器：业务应用 image: my-app:1.0 volumeMounts: - name: logs mountPath: /var/log/app - name: log-collector # Sidecar：日志收集 image: busybox command: [\u0026#34;sh\u0026#34;, \u0026#34;-c\u0026#34;, \u0026#34;tail -f /var/log/app/*.log\u0026#34;] volumeMounts: - name: logs mountPath: /var/log/app volumes: - name: logs emptyDir: {} # 共享的临时目录 2.2 常见多容器场景 1 2 3 4 Sidecar — 日志收集、代理（如 Envoy/Istio） Ambassador — 代理外部服务连接 Adapter — 标准化输出（如监控指标转换） Init 容器 — 启动前执行初始化任务 2.3 Init 容器 在主容器启动前执行，完成后退出：\n1 2 3 4 5 6 7 8 9 10 spec: initContainers: - name: init-db image: busybox command: [\u0026#34;sh\u0026#34;, \u0026#34;-c\u0026#34;, \u0026#34;until nslookup mysql-service; do echo waiting; sleep 2; done\u0026#34;] # 等待数据库服务可用后再启动主容器 containers: - name: app image: my-app:1.0 Init 容器按顺序执行，全部成功后才开始主容器。任何一个失败，Pod 都会重启。\n三、健康检查 K8s 通过探针（Probe）检查容器健康状态。\n3.1 三种探针 1 2 3 livenessProbe — 存活检查：失败则重启容器 readinessProbe — 就绪检查：失败则从 Service 摘除流量 startupProbe — 启动检查：先等这个通过，再执行上面两个 3.2 探针配置方式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 spec: containers: - name: app image: my-app:1.0 # 存活检查：挂了就重启 livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 10 # 容器启动后等10秒开始检查 periodSeconds: 10 # 每10秒检查一次 failureThreshold: 3 # 连续失败3次才判定不健康 # 就绪检查：没准备好就不接收流量 readinessProbe: httpGet: path: /ready port: 8080 initialDelaySeconds: 5 periodSeconds: 5 # 启动检查：慢启动应用用这个（避免被 liveness 杀掉） startupProbe: httpGet: path: /healthz port: 8080 failureThreshold: 30 # 允许失败30次 periodSeconds: 10 # 最长等 30*10=300秒 3.3 探针类型 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # HTTP GET（最常用） httpGet: path: /health port: 8080 # TCP 检查（适合非 HTTP 服务） tcpSocket: port: 3306 # 执行命令（适合自定义检查） exec: command: - cat - /tmp/healthy 3.4 探针配置建议 1 2 3 4 Web 服务 → httpGet + liveness + readiness 数据库 → tcpSocket + readiness 慢启动应用 → startupProbe + liveness + readiness 健康检查接口 → 返回 200 表示健康，返回 500 或超时表示不健康 四、资源限制 4.1 requests 和 limits 1 2 3 4 5 6 7 resources: requests: # 调度依据：保证最少有这么多资源 memory: \u0026#34;128Mi\u0026#34; cpu: \u0026#34;100m\u0026#34; # 100m = 0.1 核 limits: # 上限：最多用这么多，超过会被限制或杀掉 memory: \u0026#34;256Mi\u0026#34; cpu: \u0026#34;500m\u0026#34; # 500m = 0.5 核 1 2 3 4 5 requests — 调度器用来决定 Pod 放在哪个节点 limits — 运行时的硬上限 CPU 超限 → 被限流（throttled），不会杀容器 内存超限 → 被杀掉（OOMKilled） 4.2 CPU 单位 1 2 3 1 = 1 核 CPU 500m = 0.5 核（500 millicores） 100m = 0.1 核 4.3 内存单位 1 2 3 Mi = Mebibytes（1024 KiB，K8s 中常用） Gi = Gibibytes（1024 Mi） M = Megabytes（1000 KB，不推荐用） 4.4 QoS 等级 根据 requests 和 limits 的设置，Pod 分为三个等级：\n1 2 3 Guaranteed — requests = limits（最高优先级，最后被杀） Burstable — 设置了 requests 但不等于 limits BestEffort — 没设置 requests 和 limits（最先被杀） 建议生产环境所有 Pod 都设置 requests 和 limits，保证 Guaranteed 或 Burstable。\n4.5 LimitRange（默认资源限制） 给命名空间设置默认值，防止忘记配置：\n1 2 3 4 5 6 7 8 9 10 11 12 13 apiVersion: v1 kind: LimitRange metadata: name: default-limits spec: limits: - default: # 默认 limits cpu: \u0026#34;500m\u0026#34; memory: \u0026#34;256Mi\u0026#34; defaultRequest: # 默认 requests cpu: \u0026#34;100m\u0026#34; memory: \u0026#34;128Mi\u0026#34; type: Container 五、环境变量与配置注入 5.1 直接定义 1 2 3 4 5 env: - name: APP_ENV value: \u0026#34;production\u0026#34; - name: APP_PORT value: \u0026#34;8080\u0026#34; 5.2 从 ConfigMap 引用 1 2 3 4 5 6 env: - name: DB_HOST valueFrom: configMapKeyRef: name: app-config # ConfigMap 名 key: database_host # ConfigMap 中的 key 5.3 从 Secret 引用 1 2 3 4 5 6 env: - name: DB_PASSWORD valueFrom: secretKeyRef: name: db-secret # Secret 名 key: password # Secret 中的 key 5.4 挂载为文件 1 2 3 4 5 6 7 8 9 10 11 12 spec: containers: - name: app volumeMounts: - name: config-volume mountPath: /etc/config readOnly: true volumes: - name: config-volume configMap: name: app-config 六、Pod 排错 6.1 常见问题速查 1 2 3 4 5 6 Pending → 资源不足，kubectl describe pod 看 Events CrashLoopBackOff → 容器启动后崩溃，kubectl logs --previous 看上次日志 ImagePullBackOff → 镜像拉取失败，检查镜像名和仓库权限 OOMKilled → 内存超限，增大 limits.memory Completed → 容器执行完退出了，正常（Job）或命令写错 ContainerCreating → 一直在创建，检查镜像拉取和挂载 6.2 排查步骤 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # 1. 查看 Pod 状态 kubectl get pods -o wide # 2. 查看 Pod 详情和事件 kubectl describe pod \u0026lt;pod-name\u0026gt; # 3. 查看容器日志 kubectl logs \u0026lt;pod-name\u0026gt; kubectl logs \u0026lt;pod-name\u0026gt; --previous # 上次崩溃的日志 kubectl logs \u0026lt;pod-name\u0026gt; -c \u0026lt;container\u0026gt; # 指定容器 # 4. 进入容器排查 kubectl exec -it \u0026lt;pod-name\u0026gt; -- sh # 5. 查看资源使用 kubectl top pods 七、小结 本文学习了 Pod 的核心知识：\nPod 的概念和生命周期 多容器模式（Sidecar、Init 容器） 健康检查（liveness、readiness、startup 探针） 资源限制（requests/limits、QoS） 环境变量与配置注入 Pod 常见问题排查 下一篇将学习工作负载管理：Deployment 滚动更新、StatefulSet 和 DaemonSet。\n","date":"2024-12-11T10:00:00+08:00","permalink":"/posts/cloudnative/k8s/fundamentals/02-k8s-pod/","title":"K8s 学习笔记（二）：Pod 与容器管理"},{"content":"写在前面 这是 Kubernetes 学习笔记系列的第一篇，介绍 K8s 的核心概念和架构，并带你搭建本地学习环境。读完本文你将理解 K8s 是什么、为什么需要它，并能独立部署一个应用到集群中。\n本系列适合有后端开发经验、刚开始学 K8s 的开发者。\n一、什么是 Kubernetes 1.1 容器编排的痛点 用 Docker 跑一两个容器很简单，但生产环境会面临：\n1 2 3 4 5 6 - 容器挂了怎么自动重启？ - 怎么滚动更新不停服？ - 怎么自动扩容应对流量高峰？ - 多个容器怎么互相发现和通信？ - 怎么管理配置和密钥？ - 怎么保证资源分配和隔离？ Kubernetes（简称 K8s）就是解决这些问题的容器编排平台。\n1.2 核心概念 1 2 3 4 5 6 Cluster — 集群，一组节点的集合 Node — 节点，集群中的一台机器（物理机或虚拟机） Pod — K8s 最小调度单元，包含一个或多个容器 Service — 为一组 Pod 提供稳定的访问入口 Deployment — 管理 Pod 的副本数、更新策略 Namespace — 资源隔离的逻辑分区 1.3 架构概览 1 2 3 4 5 6 7 8 9 10 11 Master 节点（控制面）： ├── kube-apiserver — API 入口，所有操作都经过它 ├── etcd — 存储集群状态数据 ├── kube-scheduler — 负责把 Pod 调度到合适的节点 ├── kube-controller-manager — 控制器（Deployment、ReplicaSet 等） └── cloud-controller-manager — 云厂商相关控制器 Worker 节点（数据面）： ├── kubelet — 管理节点上的 Pod 生命周期 ├── kube-proxy — 负责网络规则和 Service 转发 └── Container Runtime — 容器运行时（containerd、Docker） 开发者主要和 kube-apiserver 交互（通过 kubectl），不需要直接操作其他组件。\n二、搭建本地环境 学习 K8s 不需要真实集群，本地工具足够。\n2.1 方案对比 1 2 3 4 minikube — 最成熟，功能全，推荐初学者 kind — 用 Docker 容器模拟节点，启动快 k3s — 轻量级 K8s，适合边缘计算和本地 Docker Desktop — 自带 K8s，Windows/Mac 一键开启 2.2 安装 minikube 1 2 3 4 5 6 7 8 9 # Windows（用 winget） winget install Kubernetes.minikube # macOS brew install minikube # Linux curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64 sudo install minikube-linux-amd64 /usr/local/bin/minikube 2.3 启动集群 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # 启动（默认用 Docker 驱动） minikube start # 指定 K8s 版本 minikube start --kubernetes-version=v1.30.0 # 查看集群状态 minikube status # 查看集群信息 kubectl cluster-info # 验证节点 kubectl get nodes # NAME STATUS ROLES AGE VERSION # minikube Ready control-plane 60s v1.30.0 2.4 常用 minikube 命令 1 2 3 4 5 6 7 minikube start # 启动集群 minikube stop # 停止集群（不删除） minikube delete # 删除集群 minikube dashboard # 打开 Web 控制台 minikube ssh # SSH 进入节点 minikube addons list # 查看可用插件 minikube addons enable metrics-server # 启用指标服务 2.5 安装 kubectl 1 2 3 4 5 6 7 8 # Windows winget install Kubernetes.kubectl # macOS brew install kubectl # 验证 kubectl version --client minikube 启动后会自动配置 kubectl，可以直接使用。\n三、第一个应用部署 3.1 部署一个 Nginx 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # 创建 Deployment kubectl create deployment nginx --image=nginx:latest # 查看 Deployment kubectl get deployments # NAME READY UP-TO-DATE AVAILABLE AGE # nginx 1/1 1 1 30s # 查看 Pod kubectl get pods # NAME READY STATUS RESTARTS AGE # nginx-7854ff8879-abcde 1/1 Running 0 30s # 查看 Pod 详情 kubectl describe pod \u0026lt;pod-name\u0026gt; 3.2 暴露服务 1 2 3 4 5 6 7 8 9 10 11 # 创建 Service（NodePort 类型，允许外部访问） kubectl expose deployment nginx --port=80 --target-port=80 --type=NodePort # 查看 Service kubectl get services # NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE # kubernetes ClusterIP 10.96.0.1 \u0026lt;none\u0026gt; 443/TCP 5m # nginx NodePort 10.100.200.50 \u0026lt;none\u0026gt; 80:31234/TCP 10s # minikube 直接打开浏览器访问 minikube service nginx 3.3 扩容 1 2 3 4 5 6 7 8 9 # 扩展到 3 个副本 kubectl scale deployment nginx --replicas=3 # 查看 Pod（变成 3 个） kubectl get pods # NAME READY STATUS RESTARTS AGE # nginx-7854ff8879-abcde 1/1 Running 0 3m # nginx-7854ff8879-fghij 1/1 Running 0 10s # nginx-7854ff8879-klmno 1/1 Running 0 10s 3.4 清理 1 2 3 # 删除 Service 和 Deployment kubectl delete service nginx kubectl delete deployment nginx 四、用 YAML 部署 命令行操作适合临时测试，正式环境都用 YAML 文件。\n4.1 Deployment YAML 创建 nginx-deployment.yaml：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 apiVersion: apps/v1 kind: Deployment metadata: name: nginx labels: app: nginx spec: replicas: 3 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:1.25 ports: - containerPort: 80 resources: requests: memory: \u0026#34;128Mi\u0026#34; cpu: \u0026#34;100m\u0026#34; limits: memory: \u0026#34;256Mi\u0026#34; cpu: \u0026#34;200m\u0026#34; 4.2 Service YAML 创建 nginx-service.yaml：\n1 2 3 4 5 6 7 8 9 10 11 apiVersion: v1 kind: Service metadata: name: nginx-service spec: selector: app: nginx # 匹配 Pod 的标签 ports: - port: 80 # Service 端口 targetPort: 80 # Pod 端口 type: ClusterIP # 集群内部访问 4.3 应用 YAML 1 2 3 4 5 6 7 8 9 10 # 创建/更新资源 kubectl apply -f nginx-deployment.yaml kubectl apply -f nginx-service.yaml # 也可以把多个资源写在一个文件里（用 --- 分隔），一次应用 kubectl apply -f nginx-all.yaml # 删除 kubectl delete -f nginx-deployment.yaml kubectl delete -f nginx-service.yaml 4.4 YAML 结构说明 1 2 3 4 5 6 7 8 apiVersion: \u0026lt;API 版本\u0026gt; # 如 apps/v1, v1, batch/v1 kind: \u0026lt;资源类型\u0026gt; # 如 Deployment, Service, Pod metadata: # 元数据 name: \u0026lt;资源名称\u0026gt; namespace: \u0026lt;命名空间\u0026gt; # 可选，默认 default labels: {} # 标签 spec: # 规格（每种资源不同） ... 五、K8s 资源类型速览 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 工作负载： Pod — 最小调度单元 Deployment — 无状态应用（Web 服务、API） StatefulSet — 有状态应用（数据库、消息队列） DaemonSet — 每个节点跑一个 Pod（日志、监控） Job — 一次性任务 CronJob — 定时任务 服务发现： Service — Pod 的稳定访问入口 Ingress — HTTP 路由和域名接入 配置存储： ConfigMap — 配置文件 Secret — 敏感信息 PV/PVC — 持久化存储 安全： Namespace — 资源隔离 RBAC — 权限控制 NetworkPolicy — 网络隔离 六、开发者视角：应用如何适配 K8s 作为开发者，把应用部署到 K8s 需要关注：\n6.1 应用要做的 1 2 3 4 5 - 提供健康检查接口（/health、/ready） - 通过环境变量读取配置（不要硬编码） - 日志输出到 stdout/stderr（K8s 自动收集） - 优雅关闭（处理 SIGTERM 信号） - 无状态化（Session 等状态存 Redis） 6.2 配置外部化示例（Go） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 从环境变量读取配置 func main() { port := os.Getenv(\u0026#34;APP_PORT\u0026#34;) if port == \u0026#34;\u0026#34; { port = \u0026#34;8080\u0026#34; } dbHost := os.Getenv(\u0026#34;DB_HOST\u0026#34;) // 从 ConfigMap/Secret 注入 dbPort := os.Getenv(\u0026#34;DB_PORT\u0026#34;) // 启动 HTTP 服务 http.HandleFunc(\u0026#34;/health\u0026#34;, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) log.Fatal(http.ListenAndServe(\u0026#34;:\u0026#34;+port, nil)) } 6.3 对应的 K8s 配置思路 1 2 3 4 5 应用配置 → ConfigMap 数据库密码 → Secret 健康检查 → Deployment 的 livenessProbe / readinessProbe 资源限制 → Deployment 的 resources.requests / limits 对外暴露 → Service + Ingress 七、小结 本文完成了以下内容：\nK8s 的核心概念和架构 本地环境搭建（minikube + kubectl） 第一个应用的部署和访问 YAML 文件编写和部署 K8s 资源类型速览 开发者如何适配 K8s 下一篇将深入 Pod 与容器管理，包括生命周期、健康检查和资源限制。\n","date":"2024-12-07T10:00:00+08:00","permalink":"/posts/cloudnative/k8s/fundamentals/01-k8s-basics/","title":"K8s 学习笔记（一）：入门与集群搭建"},{"content":"写在前面 本文整理了日常使用 Kubernetes 最常用的 kubectl 命令，涵盖资源查看、部署管理、排错调试和集群运维。每个命令都力求实用，避免罗列用不到的冷门参数。\n一、基础操作 1.1 集群信息 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # 查看集群信息 kubectl cluster-info # 查看集群节点 kubectl get nodes # 查看节点详细信息 kubectl describe node \u0026lt;node-name\u0026gt; # 查看节点资源使用 kubectl top nodes # 查看 kubeconfig 配置 kubectl config view # 切换命名空间（需要 kubectx 或手动切换） kubectl config set-context --current --namespace=\u0026lt;namespace\u0026gt; 1.2 命名空间 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # 查看所有命名空间 kubectl get namespaces kubectl get ns # 创建命名空间 kubectl create namespace dev # 删除命名空间 kubectl delete namespace dev # 在指定命名空间下操作（-n 或 --namespace） kubectl get pods -n dev # 查看所有命名空间的资源 kubectl get pods --all-namespaces kubectl get pods -A 1.3 通用操作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 # 查看资源列表 kubectl get \u0026lt;resource\u0026gt; # 如 kubectl get pods kubectl get \u0026lt;resource\u0026gt; -o wide # 显示更多信息 kubectl get \u0026lt;resource\u0026gt; -o yaml # 输出 YAML 格式 kubectl get \u0026lt;resource\u0026gt; -o json # 输出 JSON 格式 kubectl get \u0026lt;resource\u0026gt; -o name # 只显示资源名 kubectl get \u0026lt;resource\u0026gt; --show-labels # 显示标签 kubectl get \u0026lt;resource\u0026gt; --sort-by=.metadata.creationTimestamp # 按创建时间排序 # 查看资源详情 kubectl describe \u0026lt;resource\u0026gt; \u0026lt;name\u0026gt; # 创建/更新资源 kubectl apply -f manifest.yaml # 推荐，幂等操作 kubectl apply -f ./manifests/ # 应用目录下所有文件 kubectl apply -f https://url/manifest.yaml # 从 URL 应用 # 删除资源 kubectl delete -f manifest.yaml # 按文件删除 kubectl delete pod \u0026lt;pod-name\u0026gt; # 按名称删除 kubectl delete pod \u0026lt;pod-name\u0026gt; --grace-period=0 --force # 强制删除（卡住时用） # 编辑资源 kubectl edit \u0026lt;resource\u0026gt; \u0026lt;name\u0026gt; # 打开编辑器修改 # 查看资源定义帮助 kubectl explain pod kubectl explain pod.spec.containers 二、Pod 管理 2.1 查看 Pod 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 # 列出 Pod kubectl get pods kubectl get pods -o wide # 显示 IP 和所在节点 kubectl get pods -w # 持续监控变化（watch） kubectl get pods --field-selector status.phase=Running # 按状态过滤 # 查看所有命名空间的 Pod kubectl get pods -A # 按标签筛选 kubectl get pods -l app=nginx kubectl get pods -l \u0026#39;app in (nginx, redis)\u0026#39; # 查看 Pod 详情 kubectl describe pod \u0026lt;pod-name\u0026gt; # 查看 Pod 的 YAML kubectl get pod \u0026lt;pod-name\u0026gt; -o yaml 2.2 Pod 生命周期 1 2 3 4 5 6 7 8 Pod 状态： Pending — 正在调度或拉取镜像 Running — 正在运行 Succeeded — 执行完成（Job/CronJob） Failed — 执行失败 Unknown — 无法获取状态（通常是节点失联） CrashLoopBackOff — 容器反复崩溃重启 ImagePullBackOff — 镜像拉取失败 2.3 快速创建 Pod 1 2 3 4 5 6 # 用命令行快速创建（临时测试用） kubectl run nginx --image=nginx:latest kubectl run busybox --image=busybox --rm -it -- /bin/sh # 运行后进入，退出自动删除 # 从 YAML 生成（不执行，输出到文件） kubectl run nginx --image=nginx --dry-run=client -o yaml \u0026gt; pod.yaml 2.4 容器交互 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 # 查看容器日志 kubectl logs \u0026lt;pod-name\u0026gt; # 查看当前日志 kubectl logs \u0026lt;pod-name\u0026gt; -f # 实时跟踪日志 kubectl logs \u0026lt;pod-name\u0026gt; -f --tail=100 # 最后100行，持续跟踪 kubectl logs \u0026lt;pod-name\u0026gt; -c \u0026lt;container-name\u0026gt; # 指定容器（多容器 Pod） kubectl logs \u0026lt;pod-name\u0026gt; --previous # 查看上一次崩溃的日志（排错神器） kubectl logs \u0026lt;pod-name\u0026gt; --since=1h # 最近1小时的日志 kubectl logs \u0026lt;pod-name\u0026gt; --since-time=\u0026#34;2026-01-01T00:00:00Z\u0026#34; # 进入容器 kubectl exec -it \u0026lt;pod-name\u0026gt; -- /bin/bash # 进入容器 shell kubectl exec -it \u0026lt;pod-name\u0026gt; -- /bin/sh # 没有 bash 时用 sh kubectl exec -it \u0026lt;pod-name\u0026gt; -c \u0026lt;container\u0026gt; -- sh # 指定容器 # 在容器中执行命令（不进入） kubectl exec \u0026lt;pod-name\u0026gt; -- cat /etc/config/app.conf kubectl exec \u0026lt;pod-name\u0026gt; -- env # 查看环境变量 kubectl exec \u0026lt;pod-name\u0026gt; -- df -h # 查看磁盘 # 文件拷贝 kubectl cp \u0026lt;pod-name\u0026gt;:/tmp/file.txt ./file.txt # 从容器拷贝到本地 kubectl cp ./file.txt \u0026lt;pod-name\u0026gt;:/tmp/file.txt # 从本地拷贝到容器 kubectl cp ./file.txt \u0026lt;pod-name\u0026gt;:/tmp/ -c \u0026lt;container\u0026gt; # 指定容器 2.5 端口转发 1 2 3 4 5 # 本地端口转发到 Pod（调试用） kubectl port-forward \u0026lt;pod-name\u0026gt; 8080:80 # 本地8080 → Pod的80 kubectl port-forward \u0026lt;pod-name\u0026gt; 8080:80 3306:3306 # 同时转发多个端口 kubectl port-forward svc/\u0026lt;service-name\u0026gt; 8080:80 # 转发到 Service kubectl port-forward deploy/\u0026lt;deploy-name\u0026gt; 8080:80 # 转发到 Deployment 三、工作负载管理 3.1 Deployment 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 # 查看 Deployment kubectl get deployments kubectl get deploy # 创建 Deployment kubectl create deployment nginx --image=nginx:latest --replicas=3 kubectl create deployment nginx --image=nginx --replicas=3 --dry-run=client -o yaml \u0026gt; deploy.yaml # 扩缩容 kubectl scale deployment nginx --replicas=5 # 更新镜像（触发滚动更新） kubectl set image deployment/nginx nginx=nginx:1.25 kubectl edit deployment nginx # 直接编辑 # 查看滚动更新状态 kubectl rollout status deployment/nginx # 查看更新历史 kubectl rollout history deployment/nginx # 回滚到上一版本 kubectl rollout undo deployment/nginx # 回滚到指定版本 kubectl rollout undo deployment/nginx --to-revision=2 # 暂停/恢复滚动更新（用于多次修改一次性生效） kubectl rollout pause deployment/nginx kubectl set image deployment/nginx nginx=nginx:1.25 kubectl set resources deployment/nginx -c nginx --limits=memory=512Mi kubectl rollout resume deployment/nginx # 查看 ReplicaSet（每个版本对应一个 RS） kubectl get replicaset 3.2 StatefulSet 1 2 3 4 5 6 7 8 9 # 查看 StatefulSet kubectl get statefulsets kubectl get sts # 扩缩容 kubectl scale statefulset mysql --replicas=3 # 查看详情 kubectl describe statefulset mysql 3.3 DaemonSet 1 2 3 4 5 6 # 查看 DaemonSet kubectl get daemonsets kubectl get ds # 查看每个节点上的 Pod kubectl get pods -o wide | grep \u0026lt;daemonset-name\u0026gt; 3.4 Job 和 CronJob 1 2 3 4 5 6 7 8 9 10 11 # 查看 Job kubectl get jobs # 查看 CronJob kubectl get cronjobs # 手动触发 CronJob kubectl create job --from=cronjob/\u0026lt;cronjob-name\u0026gt; \u0026lt;job-name\u0026gt; # 删除 Job kubectl delete job \u0026lt;job-name\u0026gt; 四、服务与网络 4.1 Service 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # 查看 Service kubectl get services kubectl get svc # 查看 Service 详情（查看 Endpoints） kubectl describe svc \u0026lt;service-name\u0026gt; # 查看 Endpoints kubectl get endpoints \u0026lt;service-name\u0026gt; # 暴露 Deployment 为 Service kubectl expose deployment nginx --port=80 --target-port=80 --type=ClusterIP kubectl expose deployment nginx --port=80 --target-port=80 --type=NodePort kubectl expose deployment nginx --port=80 --target-port=80 --type=LoadBalancer # 常用 Service 类型： # ClusterIP — 集群内部访问（默认） # NodePort — 通过节点端口外部访问（30000-32767） # LoadBalancer — 云厂商提供的负载均衡器 # ExternalName — 映射到外部 DNS 名 4.2 Ingress 1 2 3 4 5 6 # 查看 Ingress kubectl get ingress kubectl get ing # 查看 Ingress 详情 kubectl describe ingress \u0026lt;ingress-name\u0026gt; 4.3 网络诊断 1 2 3 4 5 6 7 8 9 10 11 12 13 # 临时启动一个网络调试 Pod kubectl run debug --image=busybox --rm -it --restart=Never -- sh # 在调试 Pod 中测试 DNS nslookup \u0026lt;service-name\u0026gt; nslookup \u0026lt;service-name\u0026gt;.\u0026lt;namespace\u0026gt;.svc.cluster.local # 测试 Service 连通性 wget -qO- http://\u0026lt;service-name\u0026gt;:\u0026lt;port\u0026gt; curl http://\u0026lt;service-name\u0026gt;:\u0026lt;port\u0026gt; # 查看集群 DNS 配置 kubectl get configmap coredns -n kube-system 五、配置与存储 5.1 ConfigMap 1 2 3 4 5 6 7 8 9 10 11 12 13 # 从字面量创建 kubectl create configmap app-config --from-literal=DB_HOST=mysql --from-literal=DB_PORT=3306 # 从文件创建 kubectl create configmap app-config --from-file=config.properties # 从目录创建（目录下每个文件变成一个 key） kubectl create configmap app-config --from-file=./config/ # 查看 ConfigMap kubectl get configmaps kubectl get cm kubectl describe configmap app-config 5.2 Secret 1 2 3 4 5 6 7 8 9 10 11 12 # 创建 Secret kubectl create secret generic db-secret --from-literal=username=admin --from-literal=password=\u0026#39;P@ssw0rd\u0026#39; # 从文件创建 kubectl create secret generic tls-secret --from-file=tls.crt=./cert.pem --from-file=tls.key=./key.pem # 查看 Secret（内容是 base64 编码的） kubectl get secrets kubectl describe secret \u0026lt;secret-name\u0026gt; # 解码查看 Secret 内容 kubectl get secret \u0026lt;secret-name\u0026gt; -o jsonpath=\u0026#39;{.data.password}\u0026#39; | base64 --decode 5.3 PV 和 PVC 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 查看持久卷 kubectl get persistentvolumes kubectl get pv # 查看持久卷声明 kubectl get persistentvolumeclaims kubectl get pvc # 查看 PVC 绑定状态 kubectl get pvc -o wide # 查看 PV 详情 kubectl describe pv \u0026lt;pv-name\u0026gt; kubectl describe pvc \u0026lt;pvc-name\u0026gt; 六、排错调试 6.1 排查流程 1 2 3 4 5 Pod 一直 Pending → kubectl describe pod 查看 Events，通常是资源不足 Pod 一直 CrashLoopBackOff → kubectl logs --previous 查看上次崩溃日志 Pod ImagePullBackOff → 检查镜像名和镜像仓库权限 Service 无法访问 → 检查 Endpoints 是否有 Pod，标签是否匹配 Pod 间无法通信 → 检查 NetworkPolicy，检查 DNS 解析 6.2 常用排错命令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 # 查看 Pod 事件（排错第一步） kubectl describe pod \u0026lt;pod-name\u0026gt; | grep -A 20 Events # 查看 Pod 状态和重启次数 kubectl get pods -o wide # 查看容器日志 kubectl logs \u0026lt;pod-name\u0026gt; --previous # 上次崩溃的日志 kubectl logs \u0026lt;pod-name\u0026gt; -c \u0026lt;container\u0026gt; -f # 指定容器，实时跟踪 # 进入容器排查 kubectl exec -it \u0026lt;pod-name\u0026gt; -- sh # 查看 Pod 失败原因 kubectl get pod \u0026lt;pod-name\u0026gt; -o jsonpath=\u0026#39;{.status.conditions[?(@.type==\u0026#34;Ready\u0026#34;)].message}\u0026#39; # 查看节点事件 kubectl get events --sort-by=\u0026#39;.lastTimestamp\u0026#39; kubectl get events --field-selector involvedObject.name=\u0026lt;pod-name\u0026gt; # 查看 API 资源消耗 kubectl get --raw /metrics 6.3 资源使用 1 2 3 4 5 6 7 8 9 10 11 # 查看节点资源使用（需要 metrics-server） kubectl top nodes # 查看 Pod 资源使用 kubectl top pods kubectl top pods -n \u0026lt;namespace\u0026gt; kubectl top pods --sort-by=memory # 按内存排序 kubectl top pods --sort-by=cpu # 按 CPU 排序 # 安装 metrics-server（如果 top 命令报错） kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml 七、标签与选择器 7.1 标签操作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # 查看标签 kubectl get pods --show-labels # 添加标签 kubectl label pod \u0026lt;pod-name\u0026gt; env=prod # 修改标签（覆盖已有值） kubectl label pod \u0026lt;pod-name\u0026gt; env=staging --overwrite # 删除标签 kubectl label pod \u0026lt;pod-name\u0026gt; env- # 按标签筛选 kubectl get pods -l app=nginx kubectl get pods -l \u0026#39;tier in (frontend,backend)\u0026#39; kubectl get pods -l \u0026#39;env!=debug\u0026#39; kubectl get pods -l \u0026#39;version in (v1, v2),app=nginx\u0026#39; 7.2 注解 1 2 3 4 5 6 7 8 # 添加注解 kubectl annotate pod \u0026lt;pod-name\u0026gt; description=\u0026#34;测试 Pod\u0026#34; # 查看注解 kubectl describe pod \u0026lt;pod-name\u0026gt; | grep -A 5 Annotations # 删除注解 kubectl annotate pod \u0026lt;pod-name\u0026gt; description- 八、集群运维 8.1 节点管理 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # 查看节点详情 kubectl describe node \u0026lt;node-name\u0026gt; # 节点资源使用 kubectl top nodes # 标记节点为不可调度（维护前操作） kubectl cordon \u0026lt;node-name\u0026gt; # 恢复节点调度 kubectl uncordon \u0026lt;node-name\u0026gt; # 驱逐节点上的 Pod（维护用） kubectl drain \u0026lt;node-name\u0026gt; --ignore-daemonsets --delete-emptydir-data # 查看节点标签 kubectl get nodes --show-labels # 给节点打标签（用于调度） kubectl label node \u0026lt;node-name\u0026gt; disktype=ssd 8.2 集群资源 1 2 3 4 5 6 7 8 9 10 11 12 # 查看 API 资源类型 kubectl api-resources # 查看 API 版本 kubectl api-versions # 查看所有资源（包括 CRD） kubectl get all -A # 查看集群组件状态 kubectl get componentstatuses kubectl get cs 8.3 RBAC 1 2 3 4 5 6 7 8 9 10 11 # 查看角色 kubectl get roles kubectl get clusterroles # 查看角色绑定 kubectl get rolebindings kubectl get clusterrolebindings # 查看角色详情 kubectl describe role \u0026lt;role-name\u0026gt; kubectl describe clusterrole \u0026lt;clusterrole-name\u0026gt; 九、常用组合命令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 # 快速查看所有命名空间的 Pod 状态（排除 Running 的） kubectl get pods -A --field-selector=status.phase!=Running # 查看 CrashLoopBackOff 的 Pod kubectl get pods -A | grep -E \u0026#34;CrashLoop|Error|Pending\u0026#34; # 批量删除 Completed 状态的 Pod kubectl get pods -A --field-selector=status.phase=Succeeded -o json | \\ kubectl delete -f - # 批量删除 Evicted 状态的 Pod kubectl get pods -A | grep Evicted | awk \u0026#39;{print $2 \u0026#34; --namespace=\u0026#34; $1}\u0026#39; | xargs -n 2 kubectl delete pod # 查看 Deployment 的 Pod 模板镜像版本 kubectl get deployment \u0026lt;name\u0026gt; -o jsonpath=\u0026#39;{.spec.template.spec.containers[*].image}\u0026#39; # 查看所有 Deployment 的镜像版本 kubectl get deployments -A -o custom-columns=\u0026#39;NAMESPACE:.metadata.namespace,NAME:.metadata.name,IMAGES:.spec.template.spec.containers[*].image\u0026#39; # 快速导出资源定义（去掉集群特有字段） kubectl get \u0026lt;resource\u0026gt; \u0026lt;name\u0026gt; -o yaml | kubectl neat # 查看资源配额 kubectl describe resourcequota -n \u0026lt;namespace\u0026gt; # 查看限制范围 kubectl describe limitrange -n \u0026lt;namespace\u0026gt; 十、kubectl 命令速查 按场景选命令 1 2 3 4 5 6 7 8 9 10 查状态 → kubectl get pods/svc/deploy -A 查日志 → kubectl logs \u0026lt;pod\u0026gt; -f --tail=100 查原因 → kubectl describe pod \u0026lt;pod\u0026gt; 进容器 → kubectl exec -it \u0026lt;pod\u0026gt; -- sh 调端口 → kubectl port-forward \u0026lt;pod\u0026gt; 8080:80 更新镜像 → kubectl set image deploy/\u0026lt;name\u0026gt; \u0026lt;container\u0026gt;=\u0026lt;image\u0026gt; 扩缩容 → kubectl scale deploy \u0026lt;name\u0026gt; --replicas=5 回滚 → kubectl rollout undo deploy/\u0026lt;name\u0026gt; 看资源 → kubectl top pods/nodes 节点维护 → kubectl drain \u0026lt;node\u0026gt; --ignore-daemonsets 输出格式 1 2 3 4 5 -o wide # 更多列（IP、节点等） -o yaml # YAML 格式 -o json # JSON 格式 -o name # 只显示资源名 -o custom-columns # 自定义列 常用简写 1 2 3 4 5 6 7 8 9 10 11 12 po/pods → pod svc/services → service deploy/deployments → deployment sts/statefulsets → statefulset ds/daemonsets → daemonset cm/configmaps → configmap sec/secrets → secret ns/namespaces → namespace pv/persistentvolumes → persistentvolume pvc/persistentvolumeclaims → persistentvolumeclaim ing/ingress → ingress no/nodes → node ","date":"2024-11-29T10:00:00+08:00","permalink":"/posts/cloudnative/k8s/k8s-cheatsheet/","title":"Kubectl 命令速查手册"},{"content":"写在前面 本文是 Docker 系列收官篇。前面的内容让容器\u0026quot;能跑\u0026quot;，这篇讲让容器\u0026quot;在生产环境跑得稳、跑得安全\u0026quot;：日志、监控、健康检查、资源限制、优雅关闭、安全加固，以及 Docker vs K8s 的选择。\n一、生产环境清单 1 2 3 4 5 6 7 8 9 10 一个生产级容器应用应该有： ✓ 健康检查（让编排器知道是否健康） ✓ 资源限制（防止单容器吃光资源） ✓ 日志方案（收集、聚合） ✓ 监控（指标、告警） ✓ 重启策略（崩溃自动恢复） ✓ 优雅关闭（处理完请求再停） ✓ 非 root 用户 ✓ 镜像扫描（无漏洞） ✓ 密钥管理（密钥不进镜像） 二、健康检查 1 2 3 # Dockerfile 里定义健康检查 HEALTHCHECK --interval=30s --timeout=3s --retries=3 \\ CMD curl -f http://localhost:8080/health || exit 1 1 2 3 4 5 6 7 8 9 # docker-compose.yml services: app: healthcheck: test: [\u0026#34;CMD\u0026#34;, \u0026#34;curl\u0026#34;, \u0026#34;-f\u0026#34;, \u0026#34;http://localhost:8080/health\u0026#34;] interval: 30s timeout: 3s retries: 3 start_period: 10s # 启动宽限期 1 2 3 4 5 状态：starting → healthy → unhealthy 健康检查失败 → unhealthy 编排器（Docker Swarm / K8s）根据状态决定是否重启/摘流量 应用要提供 /health 端点（检查自身 + 依赖） 三、资源限制 1 2 3 4 5 6 7 8 9 10 11 # docker-compose.yml services: app: deploy: resources: limits: cpus: \u0026#34;1.5\u0026#34; # 最多 1.5 核 memory: 512M # 最多 512M 内存 reservations: # 保底 cpus: \u0026#34;0.5\u0026#34; memory: 128M 1 2 3 4 5 # docker run 限制 docker run --cpus=\u0026#34;1.5\u0026#34; --memory=\u0026#34;512m\u0026#34; --memory-swap=\u0026#34;1g\u0026#34; myapp # OOM：容器内存超限会被内核 OOM Kill（重启） # CPU 限流：超 cpu 配额会被节流（慢，但不杀） 1 2 3 4 为什么要限制： 不限制 → 一个容器内存泄漏/死循环，吃光宿主机 → 影响其他容器（\u0026#34;吵闹的邻居\u0026#34;） 限制 → 隔离资源，单容器故障不扩散 四、重启策略 1 2 3 4 5 docker run --restart=no # 不重启（默认） docker run --restart=on-failure # 非正常退出才重启 docker run --restart=on-failure:5 # 最多重启 5 次 docker run --restart=always # 总是重启（包括手动 stop？stop 例外） docker run --restart=unless-stopped # 总是重启，除非手动停止（推荐） 1 2 3 4 unless-stopped 最常用： 崩溃 → 自动重启 手动 stop → 不重启 宿主机重启 → 容器跟着起来 五、优雅关闭 容器收到停止信号（SIGTERM），应处理完当前请求再退出：\n1 2 # 应用要监听 SIGTERM # .NET/Java/Go 等运行时会处理，但要正确配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 docker stop 默认： 1. 发 SIGTERM 2. 等待 10 秒（grace period） 3. 没退出就 SIGKILL（强杀，可能丢数据） docker stop -t 30 web # 等 30 秒 应用要做： ✓ 监听 SIGTERM ✓ 停止接收新请求 ✓ 处理完已有请求 ✓ 释放资源（关闭连接、刷盘） ✓ 退出 否则：强杀 → 请求中断、数据丢失 六、日志管理 1 2 3 4 5 6 7 Docker 日志两种： 1. 容器 stdout/stderr（应用日志） docker logs web 日志驱动（logging driver）决定怎么处理 2. 容器内文件日志 要挂载出来或用 agent 收集 1 2 3 4 5 6 7 8 # 配置日志驱动 + 日志轮转（防日志撑爆磁盘） services: app: logging: driver: json-file options: max-size: \u0026#34;10m\u0026#34; # 单文件最大 10M max-file: \u0026#34;3\u0026#34; # 保留 3 个 1 2 3 4 5 6 7 8 日志驱动： json-file（默认）— 存文件 fluentd / syslog / journald — 转发到日志系统 none — 不存 生产方案： 应用日志输出到 stdout → Docker 收集 → 转发到 ELK/Loki （容器十二要素应用原则：日志当事件流，输出到 stdout） 七、监控 1 2 3 # 内置：实时资源占用 docker stats # CONTAINER CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O 1 2 3 4 5 6 7 8 9 10 生产监控栈： cAdvisor — 采集容器指标（CPU/内存/网络/磁盘） Prometheus — 存储时序指标 Grafana — 可视化看板 Node Exporter — 宿主机指标 应用内部指标 → Prometheus client 暴露 /metrics 容器指标 → cAdvisor 日志 → ELK/Loki 告警 → Alertmanager 八、安全加固 8.1 最小权限 1 2 3 4 5 6 7 8 9 10 11 12 services: app: user: \u0026#34;1000:1000\u0026#34; # 非 root 运行 read_only: true # 根文件系统只读 tmpfs: - /tmp # 需要写的目录用 tmpfs cap_drop: - ALL # 丢弃所有 Linux capabilities cap_add: - NET_BIND_SERVICE # 只加需要的 security_opt: - no-new-privileges # 禁止提权 8.2 镜像安全 1 2 3 4 5 6 ✓ 用可信基础镜像（官方、固定版本 tag） ✓ 镜像扫描：docker scout / trivy / clair 扫描已知漏洞（CVE） ✓ 用 distroless/scratch（减小攻击面） ✓ 多阶段构建（不带源码/构建工具） ✓ 不用 root 8.3 密钥管理 1 2 3 4 5 6 7 ❌ 密钥写进镜像 / 环境变量明文（会泄露） ✓ Docker Secret（Swarm）/ K8s Secret ✓ 挂载文件（只读，不进镜像、不进 env） ✓ 外部密钥管理（Vault、云 KMS） 环境变量也不安全（inspect 能看到、子进程能读） 生产用 Secret 文件挂载或外部 KMS 8.4 更强的隔离 1 2 3 4 5 6 7 默认容器共享宿主机内核 → 内核漏洞可能逃逸 更强隔离方案： gVisor — 用户态内核，拦截系统调用（Google） Kata Containers — 轻量虚拟机跑容器（VM 级隔离） Firecracker — 微 VM（AWS，极快启动） 多租户、高安全场景用这些替代默认 runc 九、Docker vs Kubernetes 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 Docker / Docker Compose： ✓ 单机 ✓ 简单、上手快 ✓ 本地开发、CI、小项目 ✗ 不能跨机器、故障自愈弱、无滚动更新 Kubernetes： ✓ 多机集群、自动调度 ✓ 自愈（容器挂了自动重启/迁移） ✓ 滚动更新、回滚 ✓ 服务发现、负载均衡、扩缩容 ✓ 配置/密钥管理 ✗ 复杂、运维成本高 选择： 单机 / 小项目 / 本地 → Docker Compose 多机 / 生产 / 微服务 → Kubernetes 中间过渡 → Docker Swarm（简单，但已式微） 生产正规军：Docker 打镜像 → K8s 编排运行 （Docker 是构建/打包工具，K8s 是运行/编排平台） 十、CI/CD 集成 1 2 3 4 5 6 7 8 9 10 11 典型流程（结合本站 GitHub Actions 自动部署文章）： 开发者 push 代码 → GitHub Actions 触发 → docker build 构建镜像 → docker push 到镜像仓库 → 服务器 docker pull + docker compose up -d （或 K8s kubectl apply） Docker 让\u0026#34;构建一次，到处运行\u0026#34;成为可能 是 CI/CD 流水线的核心组件 十一、小结 生产清单：健康检查、资源限制、重启策略、日志、监控、优雅关闭、安全 健康检查：HEALTHCHECK + /health 端点 资源限制：limits/reservations，防止\u0026quot;吵闹邻居\u0026quot; 优雅关闭：监听 SIGTERM，处理完请求再退出 日志：输出 stdout，日志驱动转发，轮转防爆盘 监控：cAdvisor + Prometheus + Grafana 安全：非 root、只读根、cap_drop、镜像扫描、密钥管理、gVisor Docker vs K8s：Docker 打包构建，K8s 编排运行 系列总结 Docker 五篇完结：\n基础与原理：容器 vs 虚拟机、namespace/cgroups/UnionFS Dockerfile：多阶段构建、镜像瘦身、安全 Compose：多服务编排、服务发现、依赖管理 网络与存储：bridge 自定义网络、Volume/Bind/tmpfs、无状态原则 生产实践：健康检查、资源限制、日志监控、安全加固、Docker vs K8s 核心心法：容器让应用与运行环境解耦——一次构建，到处运行。Docker 负责打包和单机，K8s 负责多机编排。理解容器的隔离（namespace）、限制（cgroups）、分层（UnionFS），就理解了云原生的根基。\n","date":"2024-11-21T10:00:00+08:00","permalink":"/posts/cloudnative/docker/05-docker-production/","title":"Docker 学习笔记（五）：生产实践与安全"},{"content":"写在前面 容器是隔离的——隔离进程、文件系统、网络。但应用需要对外通信（网络）和保存数据（存储）。本文讲 Docker 的网络模型和存储机制，这是把容器用到生产的关键。\n一、Docker 网络模型 1.1 四种网络模式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 bridge（默认）— 桥接 容器连到虚拟网桥 docker0，通过 NAT 访问外部 容器间用端口映射对外 最常用 host — 主机网络 容器直接用宿主机网络栈（无隔离） 性能最好，但端口冲突 适合对网络性能极致要求的场景 none — 无网络 完全隔离，只有 lo 回环 用于不需要网络的计算任务 container:xxx — 共享容器网络 新容器和某已有容器共享网络栈 少用 macvlan — 容器有独立 MAC/IP，像物理机（进阶） overlay — 跨主机容器网络（Docker Swarm / K8s 用） 1.2 bridge 网络详解 1 2 3 4 5 6 7 8 9 10 11 12 默认 bridge（docker0）： 所有容器默认连这 ✗ 容器间只能用 IP 互访（没有 DNS 名字解析） ✗ 旧，不推荐生产 自定义 bridge（推荐）： docker network create mynet ✓ 自带 DNS，容器间用「容器名」互访 ✓ 更好的隔离 ✓ 可配置子网、网关 → 生产/开发都该用自定义网络，不用默认 bridge 1 2 3 4 5 6 7 8 # 创建网络 docker network create mynet # 容器加入网络 docker run -d --name web --network mynet nginx docker run -d --name app --network mynet myapp # app 容器里：curl http://web ← 用容器名访问（DNS 自动解析） 1.3 端口映射 1 2 3 4 5 6 7 8 # 端口映射（宿主机:容器） docker run -p 8080:80 nginx # 宿主机 8080 → 容器 80 docker run -p 127.0.0.1:8080:80 nginx # 只绑本地回环 docker run -p 8080:80 -p 8443:443 nginx # 多端口 docker run -P nginx # 随机映射宿主机高端口 # 查看端口映射 docker port web 二、跨主机网络（Overlay） 1 2 3 4 5 6 7 8 9 单机：bridge 够用 多机：容器跨机器通信需要 overlay 网络 overlay 网络在多台机器间建虚拟二层网络 容器像在同一局域网 依赖：键值存储（etcd/consul）或 Docker Swarm 实际多机方案多用 K8s（自己的网络模型 CNI：Calico/Flannel/Cilium） 纯 Docker 多机用 Swarm（已式微） 三、Docker 存储 容器删除后，容器内的数据消失。要持久化，三种方式：\n3.1 Volume（卷，推荐） 1 2 3 4 5 6 # 命名卷（Docker 管理） docker volume create mydata docker run -v mydata:/var/lib/mysql mysql # 匿名卷 docker run -v /var/lib/mysql mysql 1 2 3 4 5 6 7 8 Volume： Docker 管理的存储（Linux 下 /var/lib/docker/volumes/） ✓ 跨容器共享 ✓ 生命周期独立于容器（容器删了卷还在） ✓ 性能好（绕过联合文件系统） ✓ 支持 volume driver（NFS、云盘等） → 数据库数据、应用状态等持久化数据，首选 Volume 3.2 Bind Mount（绑定挂载） 1 2 3 4 # 宿主机目录/文件挂到容器 docker run -v /host/path:/container/path nginx docker run -v /host/conf:/etc/nginx/conf.d:ro nginx # 只读 docker run --mount type=bind,source=/host,target=/container nginx # 新语法 1 2 3 4 5 6 7 8 Bind Mount： 直接映射宿主机路径 ✓ 开发时挂源码（改了立即生效，热重载） ✓ 挂配置文件 ✗ 依赖宿主机路径（移植性差） ✗ 容器能改宿主机文件（小心） → 本地开发挂代码、挂配置，用 Bind Mount 3.3 tmpfs（内存挂载） 1 2 3 # 数据存在内存，不落盘 docker run --tmpfs /cache nginx docker run --mount type=tmpfs,target=/cache,tmpfs-size=100m nginx 1 2 3 4 5 6 7 tmpfs： 数据在内存，容器停了就没 ✓ 极快 ✓ 适合临时/敏感数据（不想落盘） ✗ 不持久 → 临时缓存、密钥文件，用 tmpfs 四、三种存储对比 1 2 3 4 5 6 7 Volume Bind Mount tmpfs ────────────────────────────────────────────────────────────── 位置 Docker 管理 宿主机路径 内存 移植性 好 差 好 持久化 是 是 否 管理命令 docker volume 文件系统 无 适合 持久数据（DB） 开发挂载/配置 临时数据 五、Volume 实战 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # 创建/列出/删除 docker volume create pgdata docker volume ls docker volume rm pgdata # 查看卷详情 docker volume inspect pgdata # 备份卷 docker run --rm -v pgdata:/data -v $(pwd):/backup alpine \\ tar czf /backup/pgdata.tar.gz -C /data . # 恢复卷 docker run --rm -v pgdata:/data -v $(pwd):/backup alpine \\ tar xzf /backup/pgdata.tar.gz -C /data # 多容器共享卷 docker run -d --name app1 -v sharedata:/data myapp docker run -d --name app2 -v sharedata:/data myapp # 共享同一卷 六、数据共享模式 1 2 3 4 5 6 7 8 9 10 11 1. 容器间共享：多个容器挂同一个命名卷 docker run -v sharevol:/data ... docker run -v sharevol:/data ... 2. 只读配置共享：宿主机配置挂成只读 docker run -v /host/nginx.conf:/etc/nginx/nginx.conf:ro nginx 3. 数据容器模式（旧，已少用）：专门一个容器持有卷，其他 --volumes-from 4. 开发热重载：源码 bind mount，代码改了容器内即时生效 docker run -v $(pwd)/src:/app/src myapp 七、生产实践 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 网络： ✓ 用自定义 bridge，不用默认 docker0 ✓ 服务名做 DNS 互访 ✓ 对外端口收敛（只暴露必要端口） ✓ 生产用反向代理（Nginx）收口 存储： ✓ 数据库数据用命名 Volume（独立于容器） ✓ 配置用 Bind Mount（方便修改） ✓ 敏感文件用 tmpfs 或 Docker Secret ✓ 定期备份重要 Volume ✓ 不要把数据存在容器内（容器是无状态的，随时可删） 容器无状态原则： 应用代码 → 进镜像 配置 → 环境变量 / 挂载 数据 → Volume（容器外） → 容器本身随时可删可重建，数据不丢 八、小结 网络模式：bridge（默认，用自定义）、host（高性能）、none、overlay（跨机） 自定义 bridge：自带 DNS，容器间用服务名互访（推荐） 端口映射：-p 宿主机:容器 存储三种： Volume（Docker 管理，持久数据首选） Bind Mount（宿主机路径，开发/配置） tmpfs（内存，临时数据） 无状态原则：数据放 Volume，容器随时可删可重建 下一篇讲 Docker 生产实践与安全。\n","date":"2024-11-17T10:00:00+08:00","permalink":"/posts/cloudnative/docker/04-docker-network-storage/","title":"Docker 学习笔记（四）：网络与存储"},{"content":"写在前面 一个应用往往不止一个容器（Web + 数据库 + 缓存 + 反向代理）。docker run 一个个敲很痛苦。Docker Compose 用一个 YAML 文件定义和运行多容器应用——本地开发、测试环境、CI 的利器。\n一、什么是 Docker Compose 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Docker Compose = 多容器编排工具 用一个 docker-compose.yml 描述所有服务 一条命令启动/停止整个应用栈 docker compose up -d # 一键启动所有服务 docker compose down # 一键停止并清理 适合： ✓ 本地开发环境（一键拉起 Web+DB+Redis） ✓ CI/CD 流水线 ✓ 测试环境 ✓ 单机部署（小型项目） 不适合： ✗ 大规模生产（那是 K8s 的事） ✗ 跨机器部署（单机工具） 二、docker-compose.yml 结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 version: \u0026#34;3.8\u0026#34; # compose 文件版本（新版可省略） services: # 定义所有服务 web: image: nginx:1.25 ports: - \u0026#34;8080:80\u0026#34; volumes: - ./html:/usr/share/nginx/html depends_on: - app app: build: ./myapp # 从 Dockerfile 构建 environment: - DB_HOST=db - REDIS_HOST=redis depends_on: db: condition: service_healthy redis: condition: service_started db: image: postgres:16 environment: POSTGRES_PASSWORD: secret POSTGRES_DB: myapp volumes: - dbdata:/var/lib/postgresql/data healthcheck: test: [\u0026#34;CMD\u0026#34;, \u0026#34;pg_isready\u0026#34;, \u0026#34;-U\u0026#34;, \u0026#34;postgres\u0026#34;] interval: 5s retries: 5 redis: image: redis:7 volumes: # 命名卷（持久化） dbdata: 三、核心配置 3.1 服务定义 1 2 3 4 5 6 7 8 9 10 11 12 13 14 services: app: image: myapp:1.0 # 用现有镜像 build: # 或从 Dockerfile 构建 context: ./app dockerfile: Dockerfile.prod args: VERSION: 1.0 # 构建参数 container_name: myapp # 容器名 restart: unless-stopped # 重启策略 working_dir: /app user: \u0026#34;1000:1000\u0026#34; stdin_open: true # -i tty: true # -t 3.2 端口与网络 1 2 3 4 5 6 7 8 9 ports: - \u0026#34;8080:80\u0026#34; # 宿主机:容器 - \u0026#34;443:443\u0026#34; expose: - \u0026#34;9000\u0026#34; # 只暴露给其他服务（不映射到宿主机） networks: # 加入自定义网络 - frontend - backend 3.3 数据卷 1 2 3 4 volumes: - ./code:/app # 绑定挂载（宿主机目录） - dbdata:/var/lib/mysql # 命名卷 - /etc/localtime:/etc/localtime:ro # 只读 3.4 环境变量 1 2 3 4 5 6 7 environment: - DB_HOST=db # 方式1 - DB_PORT=5432 env_file: # 方式2：从文件读 - .env environment: # 方式3：map DB_HOST: ${DB_HOST} # 引用宿主机环境变量 1 2 3 # .env 文件（compose 自动读取） DB_HOST=db DB_PASSWORD=secret 四、服务间依赖 1 2 3 4 5 6 7 services: app: depends_on: # 依赖关系 db: condition: service_healthy # 等 db 健康检查通过 redis: condition: service_started # 等 redis 启动即可 1 2 3 4 5 6 7 注意： depends_on 只控制「启动顺序」 不等「服务就绪」（除非用 healthcheck + service_healthy） 例：db 容器启动了，但 MySQL 还没接受连接 → app 连不上 → 应用启动失败 → 用 healthcheck 让 app 等 db 真正就绪 五、网络与服务发现 1 2 3 4 5 6 7 8 9 services: web: networks: [appnet] db: networks: [appnet] networks: appnet: driver: bridge 1 2 3 4 5 同一 Compose 项目、同一网络的容器，可以用「服务名」互相访问： web 容器里访问 db → 连接 db:5432（不用 IP） DNS 自动解析服务名 这就是 Compose 内置的服务发现（简单版） 六、常用命令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 # 启动（后台） docker compose up -d # 启动并强制重新构建镜像 docker compose up -d --build # 查看运行状态 docker compose ps # 查看日志 docker compose logs docker compose logs -f app # 跟踪某个服务 docker compose logs -f # 跟踪所有 # 停止并删除容器/网络（保留卷） docker compose down # 停止并删除卷（彻底清理） docker compose down -v # 单独操作某服务 docker compose start app docker compose stop app docker compose restart app # 在运行的服务里执行命令 docker compose exec app bash # 重新拉镜像后更新 docker compose pull docker compose up -d 七、实战：完整应用栈 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 # docker-compose.yml — 一个 .NET + PostgreSQL + Redis + Nginx 栈 version: \u0026#34;3.8\u0026#34; services: nginx: image: nginx:1.25 ports: [\u0026#34;80:80\u0026#34;, \u0026#34;443:443\u0026#34;] volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro - ./certs:/etc/nginx/certs:ro depends_on: [web] web: build: ./src environment: ConnectionStrings__Db: \u0026#34;Host=db;Database=myapp;Username=postgres;Password=secret\u0026#34; Redis__Configuration: \u0026#34;redis:6379\u0026#34; depends_on: db: { condition: service_healthy } redis: { condition: service_started } restart: unless-stopped db: image: postgres:16 environment: POSTGRES_PASSWORD: secret POSTGRES_DB: myapp volumes: - dbdata:/var/lib/postgresql/data healthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;pg_isready -U postgres\u0026#34;] interval: 5s timeout: 3s retries: 5 redis: image: redis:7-alpine command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru volumes: dbdata: 1 2 3 # 一键启动整个环境 docker compose up -d # → Nginx + Web + PostgreSQL + Redis 全部拉起，互相连通 八、进阶 8.1 多环境（profiles / override） 1 2 3 4 5 6 7 # 用 profiles 区分环境 services: app: image: myapp debug-tools: image: busybox profiles: [\u0026#34;debug\u0026#34;] # 只在 --profile debug 时启动 1 2 docker compose up -d # 不含 debug-tools docker compose --profile debug up -d # 含 debug-tools 1 2 3 4 # override 文件（docker-compose.override.yml 自动叠加） # docker-compose.yml（基础） # docker-compose.override.yml（本地覆盖：加调试端口、挂源码） # docker compose up 自动合并 8.2 资源限制 1 2 3 4 5 6 7 8 9 services: app: deploy: resources: limits: cpus: \u0026#34;1.0\u0026#34; memory: 512M reservations: memory: 256M 九、小结 Compose：一个 YAML 定义多容器，一条命令管理 适合：本地开发、CI、测试、单机小型生产 核心：services（服务）/ volumes（持久化）/ networks（网络） 服务发现：同网络容器用服务名互访（DNS） 依赖：depends_on + healthcheck 控制启动与就绪顺序 命令：up/down/logs/exec/ps 下一篇讲 Docker 网络与存储——容器的隔离与持久化细节。\n","date":"2024-11-13T10:00:00+08:00","permalink":"/posts/cloudnative/docker/03-docker-compose/","title":"Docker 学习笔记（三）：Docker Compose 多服务编排"},{"content":"写在前面 本文讲 Dockerfile——如何把应用打包成镜像。重点讲多阶段构建和镜像瘦身，这是写出生产级镜像的关键。一个臃肿的镜像（2GB）和一个精简的镜像（50MB），在拉取、部署、安全上差距巨大。\n一、Dockerfile 指令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 # 基础镜像 FROM nginx:1.25 # 维护者（已弃用，用 LABEL） LABEL maintainer=\u0026#34;jiwei\u0026#34; # 运行命令（构建时执行） RUN apt-get update \u0026amp;\u0026amp; apt-get install -y curl # 设置工作目录 WORKDIR /app # 复制文件（推荐用 COPY 而非 ADD） COPY . . COPY package.json ./ # ADD：能解压 tar、能下 URL（但少用） ADD https://example.com/app.tar.gz /tmp/ # 环境变量 ENV NODE_ENV=production ENV PATH=\u0026#34;/app/node_modules/.bin:$PATH\u0026#34; # 构建参数（构建时可变） ARG VERSION=1.0 # 暴露端口（文档作用，实际映射要 -p） EXPOSE 8080 # 启动命令 CMD [\u0026#34;node\u0026#34;, \u0026#34;server.js\u0026#34;] # 入口点（和 CMD 配合） ENTRYPOINT [\u0026#34;docker-entrypoint.sh\u0026#34;] CMD vs ENTRYPOINT 1 2 3 4 5 6 7 8 9 10 11 CMD：容器默认启动命令，可被 docker run 后的参数覆盖 CMD [\u0026#34;nginx\u0026#34;, \u0026#34;-g\u0026#34;, \u0026#34;daemon off;\u0026#34;] docker run myimage command → 用 command 替换 CMD ENTRYPOINT：固定入口，docker run 的参数追加在后面 ENTRYPOINT [\u0026#34;nginx\u0026#34;] docker run myimage -v → 实际执行 nginx -v 最佳实践：ENTRYPOINT（固定）+ CMD（默认参数） ENTRYPOINT [\u0026#34;node\u0026#34;] CMD [\u0026#34;server.js\u0026#34;] 二、一个典型 Web 应用镜像 1 2 3 4 5 6 7 8 9 10 11 12 13 # .NET 应用示例 FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY MyApp.csproj . RUN dotnet restore COPY . . RUN dotnet publish -c Release -o /app FROM mcr.microsoft.com/dotnet/aspnet:8.0 WORKDIR /app COPY --from=build /app . EXPOSE 8080 ENTRYPOINT [\u0026#34;dotnet\u0026#34;, \u0026#34;MyApp.dll\u0026#34;] 1 2 3 4 5 6 7 8 # Node.js 应用示例 FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --production COPY . . EXPOSE 3000 CMD [\u0026#34;node\u0026#34;, \u0026#34;server.js\u0026#34;] 三、多阶段构建（重要） 多阶段构建是镜像瘦身的核心——用大镜像构建，把产物拷到小镜像。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # 阶段1：构建（大镜像，含 SDK/编译器） FROM golang:1.22 AS builder WORKDIR /app COPY . . RUN go build -o myapp . # 编译出二进制 # 阶段2：运行（极小镜像） FROM alpine:3.19 COPY --from=builder /app/myapp /usr/local/bin/ CMD [\u0026#34;myapp\u0026#34;] # 效果： # builder 阶段镜像 ~800MB（含 Go 工具链） # 最终镜像 ~15MB（只有 alpine + 二进制） # SDK/源码/中间产物都不进最终镜像 1 2 3 4 5 6 7 为什么重要： 不用多阶段：镜像含 SDK（GB 级）、源码、构建工具 → 大、不安全 多阶段：最终镜像只含运行时 + 产物 → 小、安全、快 Go/Rust：最终镜像可到几 MB（静态二进制 + scratch/alpine） .NET/Java：用 runtime 镜像（不含 SDK） Node/Python：用 alpine 变体 四、镜像瘦身最佳实践 4.1 选小基础镜像 1 2 3 4 5 6 7 基础镜像大小对比（Node 为例）： node:20 ~1GB （完整 Debian） node:20-slim ~250MB （精简 Debian） node:20-alpine ~150MB （Alpine，最小） 生产优先 alpine 或 slim 注意：alpine 用 musl libc，少数库不兼容（要测试） 4.2 合并 RUN、清理缓存 1 2 3 4 5 6 7 8 9 # ❌ 多个 RUN，每层都大，缓存残留 RUN apt-get update RUN apt-get install -y curl RUN apt-get install -y git # ✅ 合并 RUN，同一层内清理 RUN apt-get update \u0026amp;\u0026amp; apt-get install -y --no-install-recommends \\ curl git \\ \u0026amp;\u0026amp; rm -rf /var/lib/apt/lists/* 4.3 利用构建缓存 1 2 3 4 5 6 7 8 # ❌ 先 COPY 全部，依赖没变也重建 COPY . . RUN npm ci # ✅ 先 COPY 依赖描述，依赖没变就用缓存 COPY package*.json ./ RUN npm ci # 缓存命中（除非 package.json 变） COPY . . # 代码变只重新 COPY 1 2 3 4 Dockerfile 指令顺序影响构建速度： 把\u0026#34;变化少的\u0026#34;放前面（依赖、系统包） 把\u0026#34;变化多的\u0026#34;放后面（源代码） → 充分利用缓存，加快构建 4.4 .dockerignore 1 2 3 4 5 6 7 8 9 # .dockerignore（类似 .gitignore） node_modules .git *.log .env dist build 避免 COPY . . 把无用文件打进镜像（减小体积、加速构建、防泄密） 4.5 用 distroless / scratch 1 2 3 4 5 6 7 distroless：Google 出品，只有运行时，没有 shell/包管理器 → 极小、极安全（攻击面小） gcr.io/distroless/nodejs20-debian12 scratch：空镜像，只放静态二进制 → 最小（几 MB） 适合 Go/Rust 静态编译的程序 五、安全实践 1 2 3 4 5 6 7 8 9 10 11 12 13 # ❌ 默认用 root 运行（危险） FROM node:20 CMD [\u0026#34;node\u0026#34;, \u0026#34;server.js\u0026#34;] # ✅ 创建非 root 用户 FROM node:20 RUN groupadd -r app \u0026amp;\u0026amp; useradd -r -g app appuser USER appuser # 切换用户 CMD [\u0026#34;node\u0026#34;, \u0026#34;server.js\u0026#34;] # 或用基础镜像自带的非 root 用户 FROM node:20 USER node # node 镜像自带 node 用户 1 2 3 4 5 6 7 其他安全要点： ✓ 用非 root 用户（USER 指令） ✓ 最小权限（只装需要的） ✓ 用特定版本 tag（node:20.11），别用 latest（不可控） ✓ 扫描漏洞（docker scout / trivy） ✓ 不把密钥/密码写进镜像（用环境变量/secret） ✓ 多阶段构建（不暴露源码和构建工具） 六、构建与推送 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # 构建（注意最后的 . 表示上下文目录） docker build -t myapp:1.0 . docker build -t myapp:1.0 -f Dockerfile.prod . # 指定 Dockerfile # 多架构构建（ARM/x86） docker buildx build --platform linux/amd64,linux/arm64 -t myapp:1.0 . # 打标签 docker tag myapp:1.0 registry.example.com/myapp:1.0 # 登录仓库 docker login registry.example.com # 推送 docker push registry.example.com/myapp:1.0 七、常见问题 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 镜像太大？ → 多阶段构建 + alpine/slim + .dockerignore + 合并 RUN 构建慢？ → 调整指令顺序利用缓存（依赖在前，代码在后） CMD 不生效？ → docker run 后的参数会覆盖 CMD；用 ENTRYPOINT 固定 apt-get 卡住？ → 配国内镜像源，或用 alpine（apk add） 时区不对？ → RUN ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime 或设 TZ 环境变量 八、小结 Dockerfile 指令：FROM/RUN/COPY/WORKDIR/ENV/CMD/ENTRYPOINT/EXPOSE CMD vs ENTRYPOINT：CMD 可覆盖，ENTRYPOINT 固定；推荐 ENTRYPOINT + CMD 多阶段构建：大镜像构建 → 产物拷到小镜像，瘦身核心 瘦身：alpine/slim 基础镜像、合并 RUN 清理、利用缓存、.dockerignore、distroless/scratch 安全：非 root 用户、固定版本 tag、漏洞扫描、密钥不入镜像 缓存优化：变化少的指令在前，变化多的在后 下一篇讲 Docker Compose——本地多容器编排。\n","date":"2024-11-09T10:00:00+08:00","permalink":"/posts/cloudnative/docker/02-dockerfile-best-practices/","title":"Docker 学习笔记（二）：Dockerfile 与镜像最佳实践"},{"content":"写在前面 本文是 Docker 系列第一篇。容器化是云原生的基石，K8s、微服务、CI/CD 都建立在它之上。本文讲清楚 Docker 是什么、容器的底层原理（namespace/cgroups）、核心概念和基本用法。\n系列定位：本系列已有 K8s 系列，Docker 是它的前置基础。K8s 系列里提到的容器概念，这里能找到根源。\n一、Docker 是什么 1.1 定义 1 2 3 4 5 6 Docker = 容器化平台 把应用 + 依赖（库、环境、配置）打包成一个「容器」 容器在任何装了 Docker 的机器上都能一致运行 解决的核心问题： \u0026#34;在我机器上能跑啊！\u0026#34; → 容器保证了环境一致 1.2 容器 vs 虚拟机 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 虚拟机（VM）： 硬件级虚拟化 每个 VM 完整操作系统（内核 + 用户空间） 重、慢、占资源（GB 级） 容器（Container）： 操作系统级虚拟化 共享宿主机内核，只隔离进程 轻、快、省资源（MB 级） 虚拟机 容器 ┌──────────────┐ ┌──────────────┐ │ App │ App │ │ App │ App │ │ 库 │ 库 │ │ 库 │ 库 │ │ 完整OS │完整OS │ │ 容器运行时 │ │ Hypervisor │ │ 宿主机OS内核 │ │ 物理硬件 │ │ 物理硬件 │ └──────────────┘ └──────────────┘ 启动分钟级、GB 启动秒级、MB 二、核心概念 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 镜像（Image） 只读模板，包含应用 + 依赖 + 环境 分层存储（Layer） 类比：面向对象的「类」、程序的「安装包」 容器（Container） 镜像的运行实例 可读写（在镜像层上加一层） 类比：「对象」、安装包「运行起来」 仓库（Registry） 存放镜像的地方 Docker Hub（公共）、Harbor（私有）、阿里云/腾讯云镜像服务 类比：代码的 GitHub、npm registry Dockerfile 描述如何构建镜像的文本文件 一系列指令（FROM/RUN/COPY...） 三、容器底层原理 容器不是凭空实现的，它依赖 Linux 内核三个特性：\n3.1 Namespace（隔离） 1 2 3 4 5 6 7 8 9 10 11 Namespace 让容器\u0026#34;以为\u0026#34;自己独占系统 PID Namespace — 进程隔离（容器内 PID 从 1 开始，看不到宿主机进程） NET Namespace — 网络隔离（独立的网卡、IP、端口、路由） IPC Namespace — 进程间通信隔离 MOUNT Namespace — 文件系统挂载点隔离 UTS Namespace — 主机名隔离 USER Namespace — 用户隔离（容器内 root ≠ 宿主机 root） Cgroup Namespace — cgroup 视图隔离 → 容器 = 一个被 namespace 包裹的进程 3.2 Cgroups（资源限制） 1 2 3 4 5 6 7 8 Cgroups（Control Groups）限制容器能用多少资源 CPU — 限制 CPU 核数/份额 内存 — 限制内存上限（超了 OOM Kill） 磁盘IO — 限制读写速率 网络 — 限速 → 防止一个容器吃光宿主机资源 3.3 联合文件系统（UnionFS） 1 2 3 4 5 6 7 镜像分层存储的基础 镜像由多个只读层叠加 容器在镜像上加一个可写层（Copy-on-Write） 多个容器共享相同的镜像层（省空间） 例：基础镜像层 + 安装依赖层 + 应用代码层 + 容器可写层 1 2 3 一句话总结容器本质： 容器 = Namespace（隔离）+ Cgroups（限制）+ UnionFS（分层镜像） 它不是虚拟机，是一个被特殊隔离和限制的进程 四、安装 1 2 3 4 5 6 7 8 9 10 11 # Linux（Ubuntu/Debian） curl -fsSL https://get.docker.com | sh sudo systemctl enable --now docker # 将当前用户加入 docker 组（免 sudo） sudo usermod -aG docker $USER # 重新登录生效 # 验证 docker version docker run hello-world 1 2 3 4 5 6 macOS / Windows： 安装 Docker Desktop（图形化，内置 Docker Engine） Windows 用 WSL2 后端性能更好 国内加速（镜像仓库）： 配置 registry-mirrors 指向国内镜像源 五、基本命令 5.1 镜像命令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # 搜索镜像 docker search nginx # 拉取镜像（默认 Docker Hub） docker pull nginx docker pull nginx:1.25 # 指定版本 docker pull nginx:latest # 列出本地镜像 docker images # 删除镜像 docker rmi nginx # 构建镜像（见 Dockerfile 篇） docker build -t myapp:1.0 . 5.2 容器命令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 # 运行容器 docker run -d --name web -p 8080:80 nginx # -d 后台运行 # --name web 容器名 # -p 8080:80 端口映射（宿主机:容器） # nginx 镜像名 # 列出运行中的容器 docker ps docker ps -a # 包括已停止的 # 启动/停止/重启 docker start web docker stop web docker restart web # 进入容器 docker exec -it web bash # 开个终端进去 # 查看日志 docker logs web docker logs -f web # 跟踪输出 # 删除容器 docker rm web docker rm -f web # 强制删除运行中的 # 查看容器详情/资源占用 docker inspect web docker stats # 实时资源（CPU/内存） 六、第一个容器 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # 跑一个 Nginx docker run -d --name mynginx -p 8080:80 nginx # 访问 http://localhost:8080 → 看到 Nginx 欢迎页 # 这个 Nginx 跑在容器里，和宿主机隔离 # 进入容器看 docker exec -it mynginx bash ls /usr/share/nginx/html # 网站文件 exit # 查看日志 docker logs mynginx # 停止并删除 docker stop mynginx \u0026amp;\u0026amp; docker rm mynginx 1 2 3 4 5 发生了什么： 1. docker pull nginx（拉镜像） 2. 创建容器（隔离 namespace、限制 cgroups、挂载镜像层） 3. 端口映射（宿主机 8080 → 容器 80） 4. Nginx 进程在容器内跑（PID 1） 七、端口、数据、环境 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # 端口映射（-p 宿主机:容器） docker run -p 8080:80 -p 8443:443 nginx # 数据卷（持久化，容器删了数据还在） docker run -v /host/data:/container/data nginx docker run -v mydata:/var/lib/mysql mysql # 命名卷 # 环境变量 docker run -e MYSQL_ROOT_PASSWORD=123456 mysql # 工作目录、用户 docker run -w /app -u appuser myimage # 重启策略 docker run --restart=always nginx # 总是自动重启 八、小结 Docker：容器化平台，解决环境一致性问题 容器 vs 虚拟机：容器共享内核、轻量（MB）、秒启动；VM 完整 OS、重（GB） 核心概念：镜像（模板）、容器（实例）、仓库（存储）、Dockerfile（构建脚本） 底层原理：Namespace（隔离）+ Cgroups（限制）+ UnionFS（分层） 基本命令：pull/run/ps/exec/logs/build 下一篇讲 Dockerfile——如何把你的应用打包成镜像。\n","date":"2024-11-05T10:00:00+08:00","permalink":"/posts/cloudnative/docker/01-docker-basics/","title":"Docker 学习笔记（一）：基础与核心原理"},{"content":"写在前面 本文是 Go 学习笔记系列的第七篇，介绍 Go 的进阶特性：泛型、反射、逃逸分析和性能分析工具。前置知识：标准库与工程实践（第六篇）。\n一、泛型（Go 1.18+） 1.1 泛型函数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // 泛型函数：支持多种类型 func Print[T any](value T) { fmt.Println(value) } Print(42) // int Print(\u0026#34;hello\u0026#34;) // string Print(3.14) // float64 // 带约束的泛型 func Sum[T int | float64](nums []T) T { var total T for _, n := range nums { total += n } return total } Sum([]int{1, 2, 3}) // 6 Sum([]float64{1.1, 2.2}) // 3.3 1.2 类型约束 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 自定义类型约束 type Number interface { int | int8 | int16 | int32 | int64 | float32 | float64 } func Max[T Number](a, b T) T { if a \u0026gt; b { return a } return b } // comparable 约束（可比较的类型，支持 == 和 !=） func Contains[T comparable](slice []T, target T) bool { for _, v := range slice { if v == target { return true } } return false } 1.3 泛型切片工具 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 通用的 Map 函数（对切片每个元素做变换） func Map[T any, U any](slice []T, fn func(T) U) []U { result := make([]U, len(slice)) for i, v := range slice { result[i] = fn(v) } return result } // 通用的 Filter 函数 func Filter[T any](slice []T, fn func(T) bool) []T { var result []T for _, v := range slice { if fn(v) { result = append(result, v) } } return result } // 使用 nums := []int{1, 2, 3, 4, 5} doubled := Map(nums, func(n int) int { return n * 2 }) // [2 4 6 8 10] evens := Filter(nums, func(n int) bool { return n%2 == 0 }) // [2 4] 1.4 泛型结构体 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 // 泛型栈 type Stack[T any] struct { items []T } func (s *Stack[T]) Push(item T) { s.items = append(s.items, item) } func (s *Stack[T]) Pop() (T, bool) { if len(s.items) == 0 { var zero T return zero, false } top := s.items[len(s.items)-1] s.items = s.items[:len(s.items)-1] return top, true } func (s *Stack[T]) Len() int { return len(s.items) } // 使用 stack := Stack[int]{} stack.Push(1) stack.Push(2) v, _ := stack.Pop() // 2 二、反射（reflect） 反射用于在运行时检查类型信息，主要场景：JSON 序列化、ORM、通用框架。\n2.1 基本用法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import \u0026#34;reflect\u0026#34; type User struct { Name string `json:\u0026#34;name\u0026#34;` Age int `json:\u0026#34;age\u0026#34;` } user := User{Name: \u0026#34;张三\u0026#34;, Age: 25} // 获取类型信息 t := reflect.TypeOf(user) fmt.Println(t.Name()) // User fmt.Println(t.Kind()) // struct // 获取值信息 v := reflect.ValueOf(user) fmt.Println(v.Kind()) // struct // 遍历结构体字段 for i := 0; i \u0026lt; t.NumField(); i++ { field := t.Field(i) value := v.Field(i) fmt.Printf(\u0026#34;%s (%s) = %v, tag=%s\\n\u0026#34;, field.Name, field.Type, value.Interface(), field.Tag.Get(\u0026#34;json\u0026#34;)) } // Name (string) = 张三, tag=name // Age (int) = 25, tag=age 2.2 修改值 1 2 3 4 5 6 7 8 // 必须传指针才能修改 user := User{Name: \u0026#34;张三\u0026#34;} v := reflect.ValueOf(\u0026amp;user).Elem() // 修改字段 nameField := v.FieldByName(\u0026#34;Name\u0026#34;) nameField.SetString(\u0026#34;李四\u0026#34;) fmt.Println(user.Name) // 李四 2.3 调用方法 1 2 3 4 5 6 7 8 9 10 11 12 13 type Calculator struct{} func (c Calculator) Add(a, b int) int { return a + b } c := Calculator{} v := reflect.ValueOf(c) method := v.MethodByName(\u0026#34;Add\u0026#34;) args := []reflect.Value{reflect.ValueOf(1), reflect.ValueOf(2)} results := method.Call(args) fmt.Println(results[0].Int()) // 3 反射的代价：反射比直接调用慢很多，可读性也差。能用泛型替代的就不要用反射。\n三、闭包进阶 3.1 闭包捕获变量 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 func counter(start int) func() int { count := start return func() int { count++ return count } } c := counter(0) fmt.Println(c()) // 1 fmt.Println(c()) // 2 fmt.Println(c()) // 3 // 独立的闭包，互不影响 c2 := counter(100) fmt.Println(c2()) // 101 3.2 闭包的常见用法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // 中间件模式 func withLogging(fn http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { start := time.Now() fn(w, r) log.Printf(\u0026#34;%s %s %v\u0026#34;, r.Method, r.URL.Path, time.Since(start)) } } // 延迟计算 func lazyCompute(fn func() int) func() int { var result int var computed bool return func() int { if !computed { result = fn() computed = true } return result } } 四、内存与逃逸分析 4.1 栈和堆 1 2 栈 — 分配释放快，函数结束时自动回收 堆 — 需要 GC 回收，分配和回收都有开销 Go 编译器会自动决定变量分配在栈还是堆（逃逸分析）：\n4.2 逃逸到堆的情况 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 // 1. 返回局部变量的指针 func newUser() *User { u := User{Name: \u0026#34;张三\u0026#34;} return \u0026amp;u // u 逃逸到堆 } // 2. 发送到 channel func sendToChannel(ch chan *User) { u := User{Name: \u0026#34;张三\u0026#34;} ch \u0026lt;- \u0026amp;u // u 逃逸到堆 } // 3. 在闭包中引用 func closure() func() int { x := 0 return func() int { x++ // x 逃逸到堆 return x } } // 4. 接口类型 func print(v interface{}) { fmt.Println(v) // v 可能逃逸 } // 5. 切片扩容 s := make([]int, 0) for i := 0; i \u0026lt; 10000; i++ { s = append(s, i) // 可能多次扩容，逃逸到堆 } 4.3 查看逃逸分析 1 2 3 4 5 6 7 8 9 # 编译时查看逃逸分析 go build -gcflags=\u0026#34;-m\u0026#34; main.go # 更详细的输出 go build -gcflags=\u0026#34;-m -m\u0026#34; main.go # 输出示例： # ./main.go:5:6: \u0026amp;u escapes to heap # ./main.go:5:6: moved to heap: u 4.4 减少逃逸的技巧 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 不好的写法：返回指针 func createUser() *User { return \u0026amp;User{Name: \u0026#34;张三\u0026#34;} } // 好的写法：返回值类型 func createUser() User { return User{Name: \u0026#34;张三\u0026#34;} } // 不好的写法：频繁使用 interface{} func process(v interface{}) { } // 好的写法：使用泛型（Go 1.18+） func process[T any](v T) { } // 预分配切片容量，避免扩容 s := make([]int, 0, 1000) // 一步到位 五、性能分析（pprof） 5.1 CPU 分析 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import \u0026#34;runtime/pprof\u0026#34; func main() { // 创建 CPU profile 文件 f, err := os.Create(\u0026#34;cpu.prof\u0026#34;) if err != nil { log.Fatal(err) } defer f.Close() // 开始 CPU profiling pprof.StartCPUProfile(f) defer pprof.StopCPUProfile() // 你的业务代码 doWork() } 1 2 3 4 5 6 7 8 9 10 11 # 分析 CPU profile go tool pprof cpu.prof # 常用 pprof 交互命令： # top — 显示最耗 CPU 的函数 # top 20 — 显示前20个 # list func — 显示某函数的代码和耗时 # web — 生成可视化图表（需要安装 graphviz） # 或者用 Web UI 查看 go tool pprof -http=:8080 cpu.prof 5.2 内存分析 1 2 3 4 5 6 7 8 9 func main() { doWork() // 写入内存 profile f, _ := os.Create(\u0026#34;mem.prof\u0026#34;) defer f.Close() runtime.GC() // 先触发 GC pprof.WriteHeapProfile(f) } 1 2 3 # 分析内存 profile go tool pprof mem.prof go tool pprof -http=:8080 mem.prof 5.3 HTTP 服务中集成 pprof 1 2 3 4 5 6 7 8 9 10 11 import _ \u0026#34;net/http/pprof\u0026#34; func main() { // 只需 import pprof，然后启动 HTTP 服务 // 访问 http://localhost:8080/debug/pprof/ 即可 go func() { log.Println(http.ListenAndServe(\u0026#34;localhost:6060\u0026#34;, nil)) }() // 你的业务代码... } 1 2 3 4 # 远程分析运行中的服务 go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30 # CPU go tool pprof http://localhost:6060/debug/pprof/heap # 内存 go tool pprof http://localhost:6060/debug/pprof/goroutine # goroutine 5.4 benchmark 生成 profile 1 2 3 4 5 6 7 8 # 生成 CPU profile go test -bench=. -cpuprofile=cpu.prof # 生成内存 profile go test -bench=. -memprofile=mem.prof # 分析 go tool pprof cpu.prof 六、代码优化技巧 6.1 减少内存分配 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 使用 sync.Pool 复用对象 var bufPool = sync.Pool{ New: func() any { return new(bytes.Buffer) }, } func process() { buf := bufPool.Get().(*bytes.Buffer) defer func() { buf.Reset() bufPool.Put(buf) }() buf.WriteString(\u0026#34;hello\u0026#34;) // 使用 buf... } // 用 strings.Builder 拼接字符串（比 + 高效） var builder strings.Builder for _, s := range parts { builder.WriteString(s) } result := builder.String() 6.2 预分配容量 1 2 3 4 5 // 切片预分配 users := make([]User, 0, len(ids)) // Map 预分配 m := make(map[string]int, len(keys)) 6.3 避免 []byte 和 string 频繁转换 1 2 3 // 频繁转换会产生内存分配 // 如果只是读，可以用 unsafe 零拷贝（高级用法，谨慎使用） // 一般情况下用 bytes 包操作 []byte，减少转换 七、小结 本文学习了 Go 的进阶特性：\n泛型（类型参数、约束、泛型函数和结构体） 反射（获取类型信息、修改值、调用方法） 闭包进阶用法 逃逸分析（理解栈和堆的分配） 性能分析（pprof 的 CPU、内存分析） 代码优化技巧 到此 Go 学习笔记的语法和工具部分已经覆盖完毕。下一篇将进入实战项目，把前面学到的知识综合运用。\n","date":"2024-10-28T10:00:00+08:00","permalink":"/posts/go/fundamentals/07-go-advanced/","title":"Go 学习笔记（七）：进阶特性"},{"content":"写在前面 本文是 Go 学习笔记系列的第六篇，介绍 Go 标准库的常用功能，以及工程实践中需要掌握的技能：文件操作、HTTP 服务、数据库、命令行和单元测试。前置知识：并发编程（第五篇）。\n一、文件操作 1.1 读取文件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import \u0026#34;os\u0026#34; // 方式1：一次性读取整个文件（小文件适用） data, err := os.ReadFile(\u0026#34;config.yaml\u0026#34;) if err != nil { log.Fatal(err) } fmt.Println(string(data)) // 方式2：逐行读取（大文件适用） file, err := os.Open(\u0026#34;access.log\u0026#34;) if err != nil { log.Fatal(err) } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() fmt.Println(line) } if err := scanner.Err(); err != nil { log.Fatal(err) } 1.2 写入文件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // 方式1：一次性写入 err := os.WriteFile(\u0026#34;output.txt\u0026#34;, []byte(\u0026#34;Hello Go\u0026#34;), 0644) // 方式2：创建并写入（覆盖） file, err := os.Create(\u0026#34;output.txt\u0026#34;) if err != nil { log.Fatal(err) } defer file.Close() file.WriteString(\u0026#34;Hello Go\\n\u0026#34;) // 方式3：追加写入 file, err := os.OpenFile(\u0026#34;output.txt\u0026#34;, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) if err != nil { log.Fatal(err) } defer file.Close() file.WriteString(\u0026#34;追加的内容\\n\u0026#34;) // 方式4：带缓冲写入 writer := bufio.NewWriter(file) writer.WriteString(\u0026#34;缓冲写入\\n\u0026#34;) writer.Flush() // 别忘了 Flush 1.3 文件和目录操作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 创建目录 os.Mkdir(\u0026#34;mydir\u0026#34;, 0755) // 创建一层 os.MkdirAll(\u0026#34;a/b/c\u0026#34;, 0755) // 递归创建多层 // 删除 os.Remove(\u0026#34;file.txt\u0026#34;) // 删除文件 os.RemoveAll(\u0026#34;mydir\u0026#34;) // 递归删除目录 // 重命名/移动 os.Rename(\u0026#34;old.txt\u0026#34;, \u0026#34;new.txt\u0026#34;) // 获取文件信息 info, err := os.Stat(\u0026#34;file.txt\u0026#34;) fmt.Println(info.Size()) // 文件大小 fmt.Println(info.IsDir()) // 是否目录 fmt.Println(info.ModTime()) // 修改时间 // 判断文件是否存在 _, err := os.Stat(\u0026#34;file.txt\u0026#34;) if os.IsNotExist(err) { fmt.Println(\u0026#34;文件不存在\u0026#34;) } 1.4 io 工具函数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import \u0026#34;io\u0026#34; // 复制（从 Reader 复制到 Writer） io.Copy(dst, src) // 读取全部内容 data, err := io.ReadAll(reader) // 字符串当 Reader 用 reader := strings.NewReader(\u0026#34;hello\u0026#34;) // 多个 Reader 拼接 reader := io.MultiReader(r1, r2, r3) // 写入多个 Writer writer := io.MultiWriter(w1, w2) 二、HTTP 服务 2.1 基础 HTTP 服务 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 import \u0026#34;net/http\u0026#34; func main() { // 注册路由 http.HandleFunc(\u0026#34;/\u0026#34;, func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(\u0026#34;Hello, Go!\u0026#34;)) }) http.HandleFunc(\u0026#34;/api/user\u0026#34;, handleUser) // 启动服务 fmt.Println(\u0026#34;服务启动在 :8080\u0026#34;) log.Fatal(http.ListenAndServe(\u0026#34;:8080\u0026#34;, nil)) } func handleUser(w http.ResponseWriter, r *http.Request) { // 根据 Method 区分操作 switch r.Method { case http.MethodGet: w.Header().Set(\u0026#34;Content-Type\u0026#34;, \u0026#34;application/json\u0026#34;) w.Write([]byte(`{\u0026#34;name\u0026#34;: \u0026#34;张三\u0026#34;, \u0026#34;age\u0026#34;: 25}`)) case http.MethodPost: // 读取请求体 body, _ := io.ReadAll(r.Body) defer r.Body.Close() fmt.Println(\u0026#34;收到:\u0026#34;, string(body)) w.WriteHeader(http.StatusCreated) default: w.WriteHeader(http.StatusMethodNotAllowed) } } 2.2 处理请求参数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 func handler(w http.ResponseWriter, r *http.Request) { // 获取 Query 参数 name := r.URL.Query().Get(\u0026#34;name\u0026#34;) page := r.URL.Query().Get(\u0026#34;page\u0026#34;) // 获取路径参数（需要自定义路由或用框架） // 标准库不直接支持，推荐用 Gin 等框架 // 获取 Header auth := r.Header.Get(\u0026#34;Authorization\u0026#34;) // 获取 Content-Type contentType := r.Header.Get(\u0026#34;Content-Type\u0026#34;) // 读取请求体 body, err := io.ReadAll(r.Body) defer r.Body.Close() } 2.3 发送 HTTP 请求 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import \u0026#34;net/http\u0026#34; // GET 请求 resp, err := http.Get(\u0026#34;https://api.example.com/users\u0026#34;) if err != nil { log.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) fmt.Println(string(body)) // POST 请求（JSON） data := strings.NewReader(`{\u0026#34;name\u0026#34;: \u0026#34;张三\u0026#34;}`) resp, err := http.Post(\u0026#34;https://api.example.com/users\u0026#34;, \u0026#34;application/json\u0026#34;, data) defer resp.Body.Close() // 自定义请求（设置 Header、超时等） req, _ := http.NewRequest(\u0026#34;GET\u0026#34;, \u0026#34;https://api.example.com/users\u0026#34;, nil) req.Header.Set(\u0026#34;Authorization\u0026#34;, \u0026#34;Bearer xxx\u0026#34;) client := \u0026amp;http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) defer resp.Body.Close() 2.4 JSON 响应 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 func jsonHandler(w http.ResponseWriter, r *http.Request) { user := User{Name: \u0026#34;张三\u0026#34;, Age: 25} w.Header().Set(\u0026#34;Content-Type\u0026#34;, \u0026#34;application/json\u0026#34;) json.NewEncoder(w).Encode(user) } // 接收 JSON 请求 func createUser(w http.ResponseWriter, r *http.Request) { var user User if err := json.NewDecoder(r.Body).Decode(\u0026amp;user); err != nil { http.Error(w, \u0026#34;无效的 JSON\u0026#34;, http.StatusBadRequest) return } fmt.Println(user.Name) } 三、数据库操作 3.1 基础连接 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import \u0026#34;database/sql\u0026#34; import _ \u0026#34;github.com/go-sql-driver/mysql\u0026#34; // MySQL 驱动 func main() { // 连接数据库 db, err := sql.Open(\u0026#34;mysql\u0026#34;, \u0026#34;user:password@tcp(127.0.0.1:3306)/mydb?charset=utf8mb4\u0026amp;parseTime=true\u0026#34;) if err != nil { log.Fatal(err) } defer db.Close() // 验证连接 if err := db.Ping(); err != nil { log.Fatal(err) } // 设置连接池 db.SetMaxOpenConns(25) // 最大连接数 db.SetMaxIdleConns(5) // 最大空闲连接 db.SetConnMaxLifetime(5 * time.Minute) } 3.2 查询操作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // 查询单行 var name string var age int err := db.QueryRow(\u0026#34;SELECT name, age FROM users WHERE id = ?\u0026#34;, 1).Scan(\u0026amp;name, \u0026amp;age) if err == sql.ErrNoRows { fmt.Println(\u0026#34;未找到\u0026#34;) } // 查询多行 rows, err := db.Query(\u0026#34;SELECT id, name, age FROM users WHERE age \u0026gt; ?\u0026#34;, 18) if err != nil { log.Fatal(err) } defer rows.Close() for rows.Next() { var id, age int var name string if err := rows.Scan(\u0026amp;id, \u0026amp;name, \u0026amp;age); err != nil { log.Fatal(err) } fmt.Println(id, name, age) } 3.3 增删改 1 2 3 4 5 6 7 8 9 10 11 12 13 // 插入 result, err := db.Exec(\u0026#34;INSERT INTO users (name, age) VALUES (?, ?)\u0026#34;, \u0026#34;张三\u0026#34;, 25) if err != nil { log.Fatal(err) } id, _ := result.LastInsertId() // 获取自增 ID // 更新 result, err := db.Exec(\u0026#34;UPDATE users SET age = ? WHERE id = ?\u0026#34;, 26, 1) affected, _ := result.RowsAffected() // 影响行数 // 删除 result, err := db.Exec(\u0026#34;DELETE FROM users WHERE id = ?\u0026#34;, 1) 3.4 事务 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 tx, err := db.Begin() if err != nil { log.Fatal(err) } // 执行事务中的操作 _, err = tx.Exec(\u0026#34;UPDATE accounts SET balance = balance - 100 WHERE id = ?\u0026#34;, 1) if err != nil { tx.Rollback() log.Fatal(err) } _, err = tx.Exec(\u0026#34;UPDATE accounts SET balance = balance + 100 WHERE id = ?\u0026#34;, 2) if err != nil { tx.Rollback() log.Fatal(err) } // 提交 if err := tx.Commit(); err != nil { log.Fatal(err) } 实际项目中推荐用 sqlx 或 GORM 等库简化数据库操作。\n四、命令行参数 4.1 os.Args 1 2 3 4 5 6 7 8 9 func main() { // os.Args[0] 是程序名，之后是参数 if len(os.Args) \u0026lt; 2 { fmt.Println(\u0026#34;用法: myapp \u0026lt;name\u0026gt;\u0026#34;) os.Exit(1) } name := os.Args[1] fmt.Println(\u0026#34;Hello,\u0026#34;, name) } 4.2 flag 包 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import \u0026#34;flag\u0026#34; func main() { // 定义命令行参数 host := flag.String(\u0026#34;host\u0026#34;, \u0026#34;localhost\u0026#34;, \u0026#34;服务器地址\u0026#34;) port := flag.Int(\u0026#34;port\u0026#34;, 8080, \u0026#34;端口号\u0026#34;) debug := flag.Bool(\u0026#34;debug\u0026#34;, false, \u0026#34;调试模式\u0026#34;) // 也可以绑定到变量 var config string flag.StringVar(\u0026amp;config, \u0026#34;config\u0026#34;, \u0026#34;app.yaml\u0026#34;, \u0026#34;配置文件路径\u0026#34;) // 解析参数 flag.Parse() fmt.Printf(\u0026#34;host: %s, port: %d, debug: %v, config: %s\\n\u0026#34;, *host, *port, *debug, config) } // 使用： // go run main.go -host 0.0.0.0 -port 3000 -debug -config prod.yaml 4.3 获取环境变量 1 2 3 4 5 6 7 8 9 10 11 12 13 // 获取环境变量 dbHost := os.Getenv(\u0026#34;DB_HOST\u0026#34;) dbPort := os.Getenv(\u0026#34;DB_PORT\u0026#34;) // 带默认值 func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != \u0026#34;\u0026#34; { return value } return defaultValue } host := getEnv(\u0026#34;DB_HOST\u0026#34;, \u0026#34;localhost\u0026#34;) 五、单元测试 5.1 基本测试 Go 的测试文件以 _test.go 结尾，测试函数以 Test 开头：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // math.go package math func Add(a, b int) int { return a + b } // math_test.go（同包下） package math import \u0026#34;testing\u0026#34; func TestAdd(t *testing.T) { result := Add(1, 2) if result != 3 { t.Errorf(\u0026#34;Add(1, 2) = %d, want 3\u0026#34;, result) } } 运行测试：\n1 2 3 4 5 6 7 8 9 10 11 # 运行当前包的测试 go test # 详细输出 go test -v # 运行指定测试函数 go test -run TestAdd # 运行所有测试 go test ./... 5.2 表驱动测试（推荐） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 func TestAdd(t *testing.T) { tests := []struct { name string a, b int expected int }{ {\u0026#34;正数相加\u0026#34;, 1, 2, 3}, {\u0026#34;负数相加\u0026#34;, -1, -2, -3}, {\u0026#34;零值相加\u0026#34;, 0, 0, 0}, {\u0026#34;正负混合\u0026#34;, 5, -3, 2}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := Add(tt.a, tt.b) if result != tt.expected { t.Errorf(\u0026#34;Add(%d, %d) = %d, want %d\u0026#34;, tt.a, tt.b, result, tt.expected) } }) } } 5.3 基准测试 1 2 3 4 5 func BenchmarkAdd(b *testing.B) { for i := 0; i \u0026lt; b.N; i++ { Add(1, 2) } } 1 2 3 # 运行基准测试 go test -bench=. go test -bench=. -benchmem # 显示内存分配情况 5.4 测试 HTTP Handler 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import \u0026#34;net/http/httptest\u0026#34; func TestHandler(t *testing.T) { req := httptest.NewRequest(\u0026#34;GET\u0026#34;, \u0026#34;/api/user?name=张三\u0026#34;, nil) w := httptest.NewRecorder() handleUser(w, req) if w.Code != http.StatusOK { t.Errorf(\u0026#34;状态码 = %d, want %d\u0026#34;, w.Code, http.StatusOK) } body := w.Body.String() fmt.Println(body) } 六、日志 6.1 标准库 log 1 2 3 4 5 6 7 8 9 10 import \u0026#34;log\u0026#34; log.Println(\u0026#34;普通日志\u0026#34;) log.Printf(\u0026#34;用户 %s 登录\u0026#34;, name) log.Fatal(\u0026#34;致命错误，程序退出\u0026#34;) // 输出后调用 os.Exit(1) log.Panic(\u0026#34;严重错误\u0026#34;) // 输出后调用 panic // 自定义日志格式 logger := log.New(os.Stdout, \u0026#34;[APP] \u0026#34;, log.LstdFlags|log.Lshortfile) logger.Println(\u0026#34;自定义格式日志\u0026#34;) 6.2 log/slog（Go 1.21+，结构化日志） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import \u0026#34;log/slog\u0026#34; // 基本使用 slog.Info(\u0026#34;用户登录\u0026#34;, \u0026#34;user\u0026#34;, \u0026#34;张三\u0026#34;, \u0026#34;ip\u0026#34;, \u0026#34;192.168.1.1\u0026#34;) slog.Error(\u0026#34;操作失败\u0026#34;, \u0026#34;err\u0026#34;, err, \u0026#34;path\u0026#34;, \u0026#34;/api/users\u0026#34;) // 结构化日志输出（JSON 格式） handler := slog.NewJSONHandler(os.Stdout, \u0026amp;slog.HandlerOptions{Level: slog.LevelDebug}) logger := slog.New(handler) slog.SetDefault(logger) slog.Info(\u0026#34;请求处理完成\u0026#34;, \u0026#34;method\u0026#34;, \u0026#34;GET\u0026#34;, \u0026#34;path\u0026#34;, \u0026#34;/api/users\u0026#34;, \u0026#34;status\u0026#34;, 200, \u0026#34;duration\u0026#34;, time.Since(start), ) // {\u0026#34;time\u0026#34;:\u0026#34;...\u0026#34;,\u0026#34;level\u0026#34;:\u0026#34;INFO\u0026#34;,\u0026#34;msg\u0026#34;:\u0026#34;请求处理完成\u0026#34;,\u0026#34;method\u0026#34;:\u0026#34;GET\u0026#34;,\u0026#34;path\u0026#34;:\u0026#34;/api/users\u0026#34;,\u0026#34;status\u0026#34;:200,\u0026#34;duration\u0026#34;:\u0026#34;1.2ms\u0026#34;} 七、项目结构规范 Go 项目没有强制的目录结构，但社区有推荐的规范：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 myproject/ ├── cmd/ // 可执行程序入口 │ └── server/ │ └── main.go ├── internal/ // 私有代码（不能被外部导入） │ ├── handler/ // HTTP 处理器 │ ├── service/ // 业务逻辑 │ └── repository/ // 数据访问 ├── pkg/ // 可被外部导入的公共包 │ └── utils/ ├── config/ // 配置文件 │ └── app.yaml ├── go.mod ├── go.sum └── Makefile internal 目录是 Go 编译器强制约束的：其他模块无法导入 internal 下的包。\n八、小结 本文学习了 Go 的标准库和工程实践：\n文件操作（os、io、bufio） HTTP 服务与客户端（net/http） 数据库操作（database/sql） 命令行参数与环境变量 单元测试（testing、表驱动测试、httptest） 日志（log、slog） 项目结构规范 下一篇将学习 Go 的进阶特性：泛型、反射、性能分析。\n","date":"2024-10-24T10:00:00+08:00","permalink":"/posts/go/fundamentals/06-go-stdlib-and-practice/","title":"Go 学习笔记（六）：标准库与工程实践"},{"content":"写在前面 本文是 Go 学习笔记系列的第五篇，介绍 Go 的并发编程。并发是 Go 的核心优势，goroutine + channel 的模型简洁而强大。前置知识：接口与错误处理（第四篇）。\n一、goroutine 1.1 启动 goroutine 1 2 3 4 5 6 7 8 9 10 // 普通函数调用（同步） doSomething() // 加 go 关键字启动 goroutine（异步） go doSomething() // 匿名函数启动 go func() { fmt.Println(\u0026#34;goroutine 中执行\u0026#34;) }() 1.2 goroutine 基本示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 func main() { // 启动多个 goroutine go func() { for i := 0; i \u0026lt; 3; i++ { fmt.Println(\u0026#34;goroutine A:\u0026#34;, i) } }() go func() { for i := 0; i \u0026lt; 3; i++ { fmt.Println(\u0026#34;goroutine B:\u0026#34;, i) } }() // 问题：main 函数结束后所有 goroutine 都会被终止 // 输出可能什么都看不到，因为 main 先退出了 } 1.3 等待 goroutine 结束 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import \u0026#34;sync\u0026#34; func main() { var wg sync.WaitGroup for i := 0; i \u0026lt; 5; i++ { wg.Add(1) // 计数器 +1 go func(id int) { defer wg.Done() // 计数器 -1 fmt.Printf(\u0026#34;worker %d 完成\\n\u0026#34;, id) }(i) } wg.Wait() // 阻塞直到计数器归零 fmt.Println(\u0026#34;所有 worker 完成\u0026#34;) } 注意：循环变量要作为参数传入 goroutine，否则闭包会捕获循环变量的引用（Go 1.22 之前会出问题）。\n1.4 goroutine 特点 1 2 3 4 - 极轻量：初始栈只有 2KB（可动态增长），比线程轻得多 - 非阻塞：goroutine 的调度由 Go 运行时管理，不依赖 OS 线程 - 数量：轻松创建数万个 goroutine - 调度：GMP 模型（Goroutine-Machine-Processor），由运行时自动调度 二、channel channel 是 goroutine 之间通信的管道，Go 的并发哲学：不要通过共享内存来通信，而要通过通信来共享内存。\n2.1 创建和使用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 创建 channel ch := make(chan int) // 无缓冲 channel ch := make(chan int, 10) // 带缓冲 channel，容量10 // 发送数据 ch \u0026lt;- 42 // 接收数据 value := \u0026lt;-ch // 接收并判断是否关闭 value, ok := \u0026lt;-ch if !ok { fmt.Println(\u0026#34;channel 已关闭\u0026#34;) } // 关闭 channel close(ch) 2.2 无缓冲 channel 1 2 3 4 5 6 7 8 9 // 无缓冲 channel：发送和接收必须同时就绪（同步） ch := make(chan string) go func() { ch \u0026lt;- \u0026#34;hello\u0026#34; // 发送后阻塞，直到有人接收 }() msg := \u0026lt;-ch // 阻塞，直到有数据 fmt.Println(msg) // hello 无缓冲 channel 也叫同步 channel，发送和接收是握手操作。\n2.3 带缓冲 channel 1 2 3 4 5 6 7 8 9 10 11 12 13 // 带缓冲 channel：缓冲区满之前发送不阻塞 ch := make(chan int, 3) // 容量3 ch \u0026lt;- 1 // 不阻塞 ch \u0026lt;- 2 // 不阻塞 ch \u0026lt;- 3 // 不阻塞 // ch \u0026lt;- 4 // 阻塞，缓冲区满了 fmt.Println(len(ch)) // 3（当前缓冲区中的元素数） fmt.Println(cap(ch)) // 3（容量） v1 := \u0026lt;-ch // 1 v2 := \u0026lt;-ch // 2 2.4 关闭 channel 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 ch := make(chan int, 5) // 生产者发送完数据后关闭 ch \u0026lt;- 1 ch \u0026lt;- 2 ch \u0026lt;- 3 close(ch) // 消费者用 range 遍历（自动在 close 后退出） for v := range ch { fmt.Println(v) // 1 2 3 } // 注意： // - 只有发送方应该关闭 channel // - 关闭后不能再发送（panic） // - 关闭后仍可以接收（返回零值和 false） // - 不需要关闭的 channel 就不要关（GC 会回收） 2.5 单向 channel 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // 只发送（生产者） func producer(ch chan\u0026lt;- int) { for i := 0; i \u0026lt; 5; i++ { ch \u0026lt;- i } close(ch) } // 只接收（消费者） func consumer(ch \u0026lt;-chan int) { for v := range ch { fmt.Println(v) } } func main() { ch := make(chan int, 5) go producer(ch) consumer(ch) } 三、select select 同时监听多个 channel，类似 switch 但专门用于 channel 操作。\n3.1 基本用法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 ch1 := make(chan string) ch2 := make(chan string) go func() { time.Sleep(1 * time.Second) ch1 \u0026lt;- \u0026#34;来自 ch1\u0026#34; }() go func() { time.Sleep(2 * time.Second) ch2 \u0026lt;- \u0026#34;来自 ch2\u0026#34; }() // 哪个先就绪就执行哪个 for i := 0; i \u0026lt; 2; i++ { select { case msg1 := \u0026lt;-ch1: fmt.Println(msg1) case msg2 := \u0026lt;-ch2: fmt.Println(msg2) } } 3.2 超时控制 1 2 3 4 5 6 select { case result := \u0026lt;-ch: fmt.Println(\u0026#34;收到:\u0026#34;, result) case \u0026lt;-time.After(3 * time.Second): fmt.Println(\u0026#34;超时了\u0026#34;) } 3.3 非阻塞操作 1 2 3 4 5 6 select { case msg := \u0026lt;-ch: fmt.Println(\u0026#34;收到:\u0026#34;, msg) default: fmt.Println(\u0026#34;没有数据，做其他事\u0026#34;) } 四、sync 包 4.1 Mutex — 互斥锁 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import \u0026#34;sync\u0026#34; type SafeCounter struct { mu sync.Mutex m map[string]int } func (c *SafeCounter) Inc(key string) { c.mu.Lock() defer c.mu.Unlock() c.m[key]++ } func (c *SafeCounter) Get(key string) int { c.mu.Lock() defer c.mu.Unlock() return c.m[key] } // 使用 counter := SafeCounter{m: make(map[string]int)} counter.Inc(\u0026#34;hello\u0026#34;) fmt.Println(counter.Get(\u0026#34;hello\u0026#34;)) // 1 4.2 RWMutex — 读写锁 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 type Cache struct { mu sync.RWMutex data map[string]string } // 写操作用写锁（互斥） func (c *Cache) Set(key, value string) { c.mu.Lock() defer c.mu.Unlock() c.data[key] = value } // 读操作用读锁（共享，多个读者可以同时读） func (c *Cache) Get(key string) string { c.mu.RLock() defer c.mu.RUnlock() return c.data[key] } 读多写少的场景用 RWMutex 比Mutex 性能更好。\n4.3 sync.Once — 只执行一次 1 2 3 4 5 6 7 8 9 var once sync.Once var instance *Config func GetConfig() *Config { once.Do(func() { instance = loadConfig() // 无论多少 goroutine 调用，只执行一次 }) return instance } 4.4 sync.Map — 并发安全的 Map 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 var m sync.Map // 写入 m.Store(\u0026#34;name\u0026#34;, \u0026#34;张三\u0026#34;) m.Store(\u0026#34;age\u0026#34;, 25) // 读取 val, ok := m.Load(\u0026#34;name\u0026#34;) if ok { fmt.Println(val) // 张三 } // 遍历 m.Range(func(key, value any) bool { fmt.Println(key, value) return true // 返回 false 停止遍历 }) // 删除 m.Delete(\u0026#34;age\u0026#34;) sync.Map 适合读多写少的场景。读写都频繁的场景用 map + Mutex 更好。\n五、context context 用于在 goroutine 之间传递取消信号、超时、截止时间和请求级别的值。\n5.1 取消（WithCancel） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 import \u0026#34;context\u0026#34; func worker(ctx context.Context, id int) { for { select { case \u0026lt;-ctx.Done(): fmt.Printf(\u0026#34;worker %d 收到取消信号，退出\\n\u0026#34;, id) return default: fmt.Printf(\u0026#34;worker %d 工作中...\\n\u0026#34;, id) time.Sleep(500 * time.Millisecond) } } } func main() { ctx, cancel := context.WithCancel(context.Background()) go worker(ctx, 1) go worker(ctx, 2) time.Sleep(2 * time.Second) cancel() // 通知所有 worker 退出 time.Sleep(1 * time.Second) // 等待 worker 退出 } 5.2 超时（WithTimeout） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 func doWork(ctx context.Context) error { // 模拟耗时操作 select { case \u0026lt;-time.After(5 * time.Second): return nil case \u0026lt;-ctx.Done(): return ctx.Err() // context deadline exceeded } } func main() { // 3秒超时 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := doWork(ctx); err != nil { fmt.Println(\u0026#34;操作失败:\u0026#34;, err) // context deadline exceeded } } 5.3 传值（WithValue） 1 2 3 4 5 6 7 8 9 10 11 func main() { // 传请求级别的值（如 trace_id） ctx := context.WithValue(context.Background(), \u0026#34;traceID\u0026#34;, \u0026#34;abc-123\u0026#34;) handleRequest(ctx) } func handleRequest(ctx context.Context) { traceID := ctx.Value(\u0026#34;traceID\u0026#34;) fmt.Println(\u0026#34;traceID:\u0026#34;, traceID) // abc-123 } WithValue 只用于请求级别的跨函数传值（trace_id、user_id 等），不要用来传可选参数。\n5.4 context 使用规则 1 2 3 4 5 - 不要把 context 放在结构体里，作为函数第一个参数传递 - 传递 context 给子函数，子函数可以随时被取消 - context.WithValue 的 key 建议用自定义类型，避免冲突 - 函数接收 context 时，不要传 nil，用 context.TODO() 代替 - cancel() 要 defer 调用，避免资源泄漏 六、常见并发模式 6.1 Worker Pool（工作池） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 func workerPool(jobs \u0026lt;-chan int, results chan\u0026lt;- int, id int) { for job := range jobs { fmt.Printf(\u0026#34;worker %d 处理任务 %d\\n\u0026#34;, id, job) results \u0026lt;- job * 2 } } func main() { jobs := make(chan int, 10) results := make(chan int, 10) // 启动3个 worker for w := 1; w \u0026lt;= 3; w++ { go workerPool(jobs, results, w) } // 发送5个任务 for j := 1; j \u0026lt;= 5; j++ { jobs \u0026lt;- j } close(jobs) // 收集结果 for r := 1; r \u0026lt;= 5; r++ { fmt.Println(\u0026#34;结果:\u0026#34;, \u0026lt;-results) } } 6.2 Pipeline（管道模式） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 // 阶段1：生成数据 func generate(nums ...int) \u0026lt;-chan int { out := make(chan int) go func() { for _, n := range nums { out \u0026lt;- n } close(out) }() return out } // 阶段2：平方 func square(in \u0026lt;-chan int) \u0026lt;-chan int { out := make(chan int) go func() { for n := range in { out \u0026lt;- n * n } close(out) }() return out } func main() { // 组装管道：生成 → 平方 → 输出 ch := square(square(generate(2, 3, 4))) for v := range ch { fmt.Println(v) // 16, 81, 256 } } 6.3 Fan-out / Fan-in（扇出/扇入） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 // Fan-out：多个 goroutine 从同一个 channel 读取（自动分发） ch1 := square(generate(1, 2, 3, 4, 5)) ch2 := square(generate(1, 2, 3, 4, 5)) // 两个 worker 同时处理 // Fan-in：合并多个 channel 到一个 func merge(cs ...\u0026lt;-chan int) \u0026lt;-chan int { out := make(chan int) var wg sync.WaitGroup for _, c := range cs { wg.Add(1) go func(ch \u0026lt;-chan int) { defer wg.Done() for v := range ch { out \u0026lt;- v } }(c) } go func() { wg.Wait() close(out) }() return out } 七、小结 本文学习了 Go 的并发编程：\ngoroutine（轻量级协程，go 关键字启动） channel（goroutine 间通信，无缓冲 vs 带缓冲） select（多路复用、超时控制） sync 包（Mutex、RWMutex、WaitGroup、Once、sync.Map） context（取消、超时、传值） 常见并发模式（Worker Pool、Pipeline、Fan-out/Fan-in） 下一篇将学习标准库与工程实践，包括 HTTP 服务、数据库操作和单元测试。\n","date":"2024-10-20T10:00:00+08:00","permalink":"/posts/go/fundamentals/05-go-concurrency/","title":"Go 学习笔记（五）：并发编程"},{"content":"写在前面 本文是 Go 学习笔记系列的第四篇，介绍 Go 的接口和错误处理机制。接口是 Go 实现多态的核心，错误处理是 Go 的设计哲学（没有 try-catch）。前置知识：结构体和方法（第三篇）。\n一、接口基础 1.1 定义接口 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // 定义接口 type Speaker interface { Speak() string } // 实现接口（隐式实现，不需要 implements 关键字） type Dog struct { Name string } func (d Dog) Speak() string { return d.Name + \u0026#34;：汪汪汪\u0026#34; } type Cat struct { Name string } func (c Cat) Speak() string { return c.Name + \u0026#34;：喵喵喵\u0026#34; } Go 的接口是隐式实现：只要一个类型实现了接口的所有方法，它就自动实现了该接口。不需要声明 implements。\n1.2 使用接口 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 接口作为参数（多态） func makeSound(s Speaker) { fmt.Println(s.Speak()) } func main() { d := Dog{Name: \u0026#34;旺财\u0026#34;} c := Cat{Name: \u0026#34;小橘\u0026#34;} makeSound(d) // 旺财：汪汪汪 makeSound(c) // 小橘：喵喵喵 // 接口切片 animals := []Speaker{d, c} for _, a := range animals { fmt.Println(a.Speak()) } } 1.3 接口变量 1 2 3 4 5 6 7 8 var s Speaker s = Dog{Name: \u0026#34;旺财\u0026#34;} fmt.Println(s.Speak()) // 接口变量包含两部分： // 1. 类型信息（动态类型） // 2. 值信息（动态值） 二、类型断言与类型选择 2.1 类型断言 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 var s Speaker = Dog{Name: \u0026#34;旺财\u0026#34;} // 断言（如果类型不对会 panic） d := s.(Dog) fmt.Println(d.Name) // 安全断言（推荐） d, ok := s.(Dog) if ok { fmt.Println(\u0026#34;是 Dog:\u0026#34;, d.Name) } // 错误的断言 c, ok := s.(Cat) fmt.Println(ok) // false 2.2 类型选择（type switch） 1 2 3 4 5 6 7 8 9 10 func checkType(s Speaker) { switch v := s.(type) { case Dog: fmt.Println(\u0026#34;Dog:\u0026#34;, v.Name) case Cat: fmt.Println(\u0026#34;Cat:\u0026#34;, v.Name) default: fmt.Println(\u0026#34;未知类型\u0026#34;) } } 三、常用接口 3.1 Stringer（类似 Java 的 toString） 1 2 3 4 5 6 7 8 9 10 11 12 type User struct { Name string Age int } // 实现 fmt.Stringer 接口 func (u User) String() string { return fmt.Sprintf(\u0026#34;%s (%d岁)\u0026#34;, u.Name, u.Age) } user := User{Name: \u0026#34;张三\u0026#34;, Age: 25} fmt.Println(user) // 张三 (25岁) 3.2 error 接口 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // error 是一个内置接口 type error interface { Error() string } // 自定义错误（实现 Error() 方法即可） type NotFoundError struct { Resource string ID int } func (e NotFoundError) Error() string { return fmt.Sprintf(\u0026#34;%s #%d 未找到\u0026#34;, e.Resource, e.ID) } 3.3 io.Reader 和 io.Writer 1 2 3 4 5 6 7 8 9 10 11 12 13 // io.Reader 接口 type Reader interface { Read(p []byte) (n int, err error) } // io.Writer 接口 type Writer interface { Write(p []byte) (n int, err error) } // 文件同时实现了 Reader 和 Writer // net.Conn 也同时实现了两者 // 这两个接口是 Go I/O 的基础 3.4 空接口 interface 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 空接口没有任何方法，所有类型都实现了它 // 类似 Java 的 Object 或 Python 的 Any func printAnything(v interface{}) { fmt.Println(v) } printAnything(42) printAnything(\u0026#34;hello\u0026#34;) printAnything([]int{1, 2, 3}) // Go 1.18+ 可以用 any 代替 interface{} func printAnything(v any) { fmt.Println(v) } 空接口要慎用，能确定类型就用具体类型或泛型。\n四、错误处理 4.1 基本模式 Go 没有 try-catch，错误通过返回值传递：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 函数返回错误 func divide(a, b float64) (float64, error) { if b == 0 { return 0, errors.New(\u0026#34;除数不能为零\u0026#34;) } return a / b, nil } // 调用时检查错误 result, err := divide(10, 0) if err != nil { fmt.Println(\u0026#34;出错:\u0026#34;, err) return } fmt.Println(\u0026#34;结果:\u0026#34;, result) nil 表示没有错误。Go 的错误处理就是 if err != nil，写多了就习惯了。\n4.2 创建错误 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import \u0026#34;errors\u0026#34; import \u0026#34;fmt\u0026#34; // 方式1：errors.New err1 := errors.New(\u0026#34;文件不存在\u0026#34;) // 方式2：fmt.Errorf（支持格式化，最常用） err2 := fmt.Errorf(\u0026#34;用户 %s 不存在\u0026#34;, name) // 方式3：自定义错误类型 type ValidationError struct { Field string Message string } func (e ValidationError) Error() string { return fmt.Sprintf(\u0026#34;验证失败: %s — %s\u0026#34;, e.Field, e.Message) } err3 := ValidationError{Field: \u0026#34;email\u0026#34;, Message: \u0026#34;格式不正确\u0026#34;} 4.3 errors.Is 和 errors.As（Go 1.13+） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import \u0026#34;errors\u0026#34; // 定义哨兵错误（Sentinel Error） var ( ErrNotFound = errors.New(\u0026#34;未找到\u0026#34;) ErrForbidden = errors.New(\u0026#34;无权限\u0026#34;) ) // errors.Is：判断错误链中是否包含指定错误 err := someFunc() if errors.Is(err, ErrNotFound) { fmt.Println(\u0026#34;资源不存在\u0026#34;) } // errors.As：从错误链中提取指定类型的错误 var valErr *ValidationError if errors.As(err, \u0026amp;valErr) { fmt.Println(\u0026#34;字段:\u0026#34;, valErr.Field) } 4.4 错误包装（Wrapping） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 用 %w 包装错误（保留原始错误信息） func readConfig(path string) error { data, err := os.ReadFile(path) if err != nil { return fmt.Errorf(\u0026#34;读取配置文件失败: %w\u0026#34;, err) } // ... return nil } // 调用方可以用 errors.Is / errors.As 追溯到原始错误 err := readConfig(\u0026#34;config.yaml\u0026#34;) if errors.Is(err, os.ErrNotExist) { fmt.Println(\u0026#34;配置文件不存在\u0026#34;) } 4.5 常见错误处理模式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 // 模式1：逐层返回 func handler() error { if err := validate(); err != nil { return fmt.Errorf(\u0026#34;参数校验失败: %w\u0026#34;, err) } if err := process(); err != nil { return fmt.Errorf(\u0026#34;处理失败: %w\u0026#34;, err) } return nil } // 模式2：错误后清理资源 func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } defer out.Close() _, err = io.Copy(out, in) return err } // 模式3：记录日志后继续 if err := retryableOp(); err != nil { log.Printf(\u0026#34;操作失败，稍后重试: %v\u0026#34;, err) // 不返回，继续执行 } 五、panic 与 recover 5.1 panic panic 用于不可恢复的严重错误，类似其他语言的异常抛出：\n1 2 3 4 5 6 7 func mustParse(s string) int { n, err := strconv.Atoi(s) if err != nil { panic(fmt.Sprintf(\u0026#34;无法解析 %q 为整数\u0026#34;, s)) } return n } 正常业务逻辑不要用 panic，应该返回 error。panic 适合程序初始化阶段的致命错误。\n5.2 recover recover 用于捕获 panic，只在 defer 中有效：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 func safeCall() { defer func() { if r := recover(); r != nil { fmt.Println(\u0026#34;捕获到 panic:\u0026#34;, r) } }() panic(\u0026#34;出了大问题\u0026#34;) } func main() { safeCall() fmt.Println(\u0026#34;程序继续运行\u0026#34;) // 会正常执行 } 5.3 panic vs error 1 2 3 4 5 6 error — 正常的错误处理方式，调用方决定怎么处理 panic — 不可恢复的错误，程序崩溃（除非 recover） 使用场景： - 返回 error：文件不存在、网络超时、参数无效等可预期的错误 - panic：数组越界、空指针、初始化失败等程序 bug 或致命错误 六、接口组合 Go 通过接口组合实现类似继承的效果：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 type Reader interface { Read(p []byte) (n int, err error) } type Writer interface { Write(p []byte) (n int, err error) } // 接口组合 type ReadWriter interface { Reader Writer } // 一个类型只需同时实现 Read 和 Write 就自动实现 ReadWriter type File struct{} func (f File) Read(p []byte) (int, error) { return 0, nil } func (f File) Write(p []byte) (int, error) { return 0, nil } var rw ReadWriter = File{} // 合法 七、小结 本文学习了 Go 的接口和错误处理：\n接口的隐式实现和多态 类型断言和类型选择 常用接口（Stringer、error、Reader/Writer） 错误处理的惯用模式（if err != nil） errors.Is / errors.As / 错误包装 panic 与 recover 接口组合 下一篇将学习 Go 的并发编程：goroutine、channel 和 context。\n","date":"2024-10-16T10:00:00+08:00","permalink":"/posts/go/fundamentals/04-go-interface-error/","title":"Go 学习笔记（四）：接口与错误处理"},{"content":"写在前面 本文是 Go 学习笔记系列的第三篇，介绍 Go 的复合数据类型：数组、切片、字典、结构体，以及 JSON 处理和字符串操作。前置知识：Go 基础语法（第二篇）。\n一、数组 1.1 声明与初始化 1 2 3 4 5 6 7 8 9 10 11 12 13 // 声明指定长度的数组 var nums [5]int // [0 0 0 0 0] var names [3]string // [\u0026#34;\u0026#34; \u0026#34;\u0026#34; \u0026#34;\u0026#34;] // 声明并初始化 colors := [3]string{\u0026#34;红\u0026#34;, \u0026#34;绿\u0026#34;, \u0026#34;蓝\u0026#34;} nums := [5]int{1, 2, 3, 4, 5} // 让编译器计算长度 nums := [...]int{1, 2, 3, 4, 5} // 长度自动推断为 5 // 指定索引初始化 nums := [5]int{1: 10, 3: 30} // [0 10 0 30 0] 1.2 数组操作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 nums := [5]int{1, 2, 3, 4, 5} // 访问元素 fmt.Println(nums[0]) // 1 fmt.Println(nums[4]) // 5 // 修改元素 nums[0] = 10 // 获取长度 fmt.Println(len(nums)) // 5 // 遍历 for i, v := range nums { fmt.Println(i, v) } 注意：Go 数组的长度是类型的一部分，[3]int 和 [5]int 是不同的类型，不能互相赋值。实际开发中很少直接用数组，基本都用切片。\n二、切片（Slice） 切片是 Go 中最常用的数据结构，可以理解为动态数组。\n2.1 创建切片 1 2 3 4 5 6 7 8 9 10 // 从数组创建 arr := [5]int{1, 2, 3, 4, 5} s1 := arr[1:4] // [2 3 4]，左闭右开 // 直接创建 s2 := []int{1, 2, 3} // 切片字面量 // 用 make 创建（推荐，可指定容量） s3 := make([]int, 5) // 长度5，容量5，元素全为0 s4 := make([]int, 0, 10) // 长度0，容量10（预分配） 2.2 切片三要素 1 2 3 指针 — 指向底层数组的地址 长度 — len(s)，当前元素个数 容量 — cap(s)，从指针位置到底层数组末尾的元素数 1 2 3 s := make([]int, 3, 5) fmt.Println(len(s)) // 3 fmt.Println(cap(s)) // 5 2.3 添加元素 1 2 3 4 5 6 7 8 9 s := []int{1, 2, 3} // append 返回新切片（必须接收返回值） s = append(s, 4) // [1 2 3 4] s = append(s, 5, 6) // [1 2 3 4 5 6] // 追加另一个切片 other := []int{7, 8} s = append(s, other...) // [1 2 3 4 5 6 7 8] append 可能触发扩容，扩容后地址可能改变，所以必须用 s = append(s, x) 接收返回值。\n2.4 切片截取 1 2 3 4 5 6 s := []int{1, 2, 3, 4, 5} s1 := s[1:3] // [2 3] s2 := s[:3] // [1 2 3] s3 := s[2:] // [3 4 5] s4 := s[:] // [1 2 3 4 5]（完整拷贝视图） 切片截取和原切片共享底层数组，修改一个会影响另一个。要完全独立需要用 copy。\n2.5 复制切片 1 2 3 4 5 6 src := []int{1, 2, 3} dst := make([]int, len(src)) copy(dst, src) // 深拷贝，互不影响 // 或者用 append 创建独立副本 dst2 := append([]int{}, src...) 2.6 删除元素 Go 没有内置的删除方法，需要手动操作：\n1 2 3 4 5 6 7 8 s := []int{1, 2, 3, 4, 5} // 删除索引 2 的元素（保持顺序） s = append(s[:2], s[3:]...) // [1 2 4 5] // 删除索引 2 的元素（不保持顺序，性能更好） s[2] = s[len(s)-1] s = s[:len(s)-1] 2.7 扩容机制 切片容量不够时，append 会自动扩容：\n1 2 新容量 \u0026lt; 256 → 新容量 = 旧容量 * 2（翻倍） 新容量 \u0026gt;= 256 → 新容量 = 旧容量 * 1.25 + 192（渐进增长） 建议：如果知道大致元素数量，用 make([]T, 0, n) 预分配容量，避免频繁扩容。\n三、字典（Map） 3.1 创建与初始化 1 2 3 4 5 6 7 8 9 10 11 12 13 // 字面量创建 ages := map[string]int{ \u0026#34;张三\u0026#34;: 25, \u0026#34;李四\u0026#34;: 30, } // 用 make 创建 scores := make(map[string]int) scores[\u0026#34;数学\u0026#34;] = 90 scores[\u0026#34;英语\u0026#34;] = 85 // 空的 map（可以直接使用） m := make(map[string]string) var m map[string]string 声明的是 nil map，直接赋值会 panic。必须用 make 或字面量初始化。\n3.2 基本操作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 m := map[string]int{\u0026#34;a\u0026#34;: 1, \u0026#34;b\u0026#34;: 2, \u0026#34;c\u0026#34;: 3} // 取值 fmt.Println(m[\u0026#34;a\u0026#34;]) // 1 fmt.Println(m[\u0026#34;x\u0026#34;]) // 0（不存在的 key 返回零值） // 判断 key 是否存在 value, ok := m[\u0026#34;x\u0026#34;] if ok { fmt.Println(\u0026#34;存在:\u0026#34;, value) } else { fmt.Println(\u0026#34;不存在\u0026#34;) } // 修改 m[\u0026#34;a\u0026#34;] = 10 // 删除 delete(m, \u0026#34;b\u0026#34;) // 遍历（顺序不确定） for key, value := range m { fmt.Println(key, value) } // 获取长度 fmt.Println(len(m)) 3.3 Map 的注意事项 1 2 3 4 - 遍历顺序不确定（每次运行可能不同） - Map 不是并发安全的，并发读写需要加锁或用 sync.Map - Map 的 key 必须是可比较的类型（不能用 slice、map、func 作为 key） - 取值时用 value, ok 模式判断 key 是否存在 四、结构体（Struct） 4.1 定义与实例化 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // 定义结构体 type User struct { Name string Age int } // 实例化 u1 := User{Name: \u0026#34;张三\u0026#34;, Age: 25} // 指定字段名（推荐） u2 := User{\u0026#34;李四\u0026#34;, 30} // 按顺序（不推荐，不好维护） u3 := User{} // 零值初始化：Name=\u0026#34;\u0026#34; Age=0 // 访问和修改 fmt.Println(u1.Name) // 张三 u1.Age = 26 // 指针结构体（避免大结构体拷贝） u4 := \u0026amp;User{Name: \u0026#34;王五\u0026#34;, Age: 28} u4.Age = 29 // Go 自动解引用，不需要 -\u0026gt; // new 创建（返回指针，所有字段零值） u5 := new(User) // *User，Name=\u0026#34;\u0026#34; Age=0 4.2 结构体方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 type Circle struct { Radius float64 } // 值接收者：不修改原对象，操作的是副本 func (c Circle) Area() float64 { return 3.14 * c.Radius * c.Radius } // 指针接收者：可以修改原对象 func (c *Circle) Scale(factor float64) { c.Radius = c.Radius * factor } // 调用 c := Circle{Radius: 5} fmt.Println(c.Area()) // 78.5 c.Scale(2) fmt.Println(c.Radius) // 10 何时用指针接收者：需要修改对象、结构体较大（避免拷贝）、保持一致性（建议一个类型的方法统一用指针或统一用值）。\n4.3 结构体嵌套 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 type Address struct { City string Street string } type Employee struct { Name string Address // 匿名嵌套（类似继承） } e := Employee{ Name: \u0026#34;张三\u0026#34;, Address: Address{ City: \u0026#34;北京\u0026#34;, Street: \u0026#34;长安街\u0026#34;, }, } // 直接访问内嵌字段 fmt.Println(e.City) // 北京（直接访问，不需要 e.Address.City） 4.4 结构体标签（Tag） 标签常用于 JSON、数据库映射：\n1 2 3 4 5 6 type User struct { ID int `json:\u0026#34;id\u0026#34; db:\u0026#34;user_id\u0026#34;` Name string `json:\u0026#34;name\u0026#34;` Email string `json:\u0026#34;email,omitempty\u0026#34;` // omitempty: 零值时省略 Password string `json:\u0026#34;-\u0026#34;` // - 表示忽略 } 五、JSON 处理 5.1 序列化（结构体 → JSON） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import \u0026#34;encoding/json\u0026#34; type User struct { Name string `json:\u0026#34;name\u0026#34;` Age int `json:\u0026#34;age\u0026#34;` Email string `json:\u0026#34;email,omitempty\u0026#34;` } user := User{Name: \u0026#34;张三\u0026#34;, Age: 25, Email: \u0026#34;zhangsan@example.com\u0026#34;} // 转成 JSON 字符串 data, err := json.Marshal(user) if err != nil { log.Fatal(err) } fmt.Println(string(data)) // {\u0026#34;name\u0026#34;:\u0026#34;张三\u0026#34;,\u0026#34;age\u0026#34;:25,\u0026#34;email\u0026#34;:\u0026#34;zhangsan@example.com\u0026#34;} // 格式化输出（带缩进） data, _ = json.MarshalIndent(user, \u0026#34;\u0026#34;, \u0026#34; \u0026#34;) fmt.Println(string(data)) 5.2 反序列化（JSON → 结构体） 1 2 3 4 5 6 7 8 9 jsonStr := `{\u0026#34;name\u0026#34;:\u0026#34;李四\u0026#34;,\u0026#34;age\u0026#34;:30}` var user User err := json.Unmarshal([]byte(jsonStr), \u0026amp;user) if err != nil { log.Fatal(err) } fmt.Println(user.Name) // 李四 fmt.Println(user.Age) // 30 5.3 处理不确定结构 1 2 3 4 5 6 7 8 9 // 用 map 接收不确定结构的 JSON var result map[string]interface{} json.Unmarshal([]byte(jsonStr), \u0026amp;result) fmt.Println(result[\u0026#34;name\u0026#34;]) // 李四 // 读取嵌套字段（需要类型断言） if data, ok := result[\u0026#34;address\u0026#34;].(map[string]interface{}); ok { fmt.Println(data[\u0026#34;city\u0026#34;]) } 六、字符串处理 6.1 strings 包 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 import \u0026#34;strings\u0026#34; s := \u0026#34;Hello, World\u0026#34; // 查找 strings.Contains(s, \u0026#34;World\u0026#34;) // true strings.HasPrefix(s, \u0026#34;Hello\u0026#34;) // true strings.HasSuffix(s, \u0026#34;World\u0026#34;) // true strings.Index(s, \u0026#34;World\u0026#34;) // 7 // 变换 strings.ToUpper(s) // \u0026#34;HELLO, WORLD\u0026#34; strings.ToLower(s) // \u0026#34;hello, world\u0026#34; strings.TrimSpace(\u0026#34; hi \u0026#34;) // \u0026#34;hi\u0026#34; strings.Trim(\u0026#34;--hi--\u0026#34;, \u0026#34;-\u0026#34;) // \u0026#34;hi\u0026#34; // 分割与合并 strings.Split(\u0026#34;a,b,c\u0026#34;, \u0026#34;,\u0026#34;) // [\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;, \u0026#34;c\u0026#34;] strings.Join([]string{\u0026#34;a\u0026#34;,\u0026#34;b\u0026#34;}, \u0026#34;-\u0026#34;) // \u0026#34;a-b\u0026#34; // 替换 strings.Replace(\u0026#34;foo bar foo\u0026#34;, \u0026#34;foo\u0026#34;, \u0026#34;baz\u0026#34;, 1) // \u0026#34;baz bar foo\u0026#34;（替换1次） strings.ReplaceAll(\u0026#34;foo bar foo\u0026#34;, \u0026#34;foo\u0026#34;, \u0026#34;baz\u0026#34;) // \u0026#34;baz bar baz\u0026#34; // 重复 strings.Repeat(\u0026#34;Go\u0026#34;, 3) // \u0026#34;GoGoGo\u0026#34; 6.2 strconv 包 1 2 3 4 5 6 7 8 9 10 11 import \u0026#34;strconv\u0026#34; // 数字 → 字符串 strconv.Itoa(42) // \u0026#34;42\u0026#34; strconv.FormatFloat(3.14, \u0026#39;f\u0026#39;, 2, 64) // \u0026#34;3.14\u0026#34; strconv.FormatBool(true) // \u0026#34;true\u0026#34; // 字符串 → 数字 n, err := strconv.Atoi(\u0026#34;42\u0026#34;) // 42 f, err := strconv.ParseFloat(\u0026#34;3.14\u0026#34;, 64) // 3.14 b, err := strconv.ParseBool(\u0026#34;true\u0026#34;) // true 七、小结 本文学习了 Go 的复合数据类型：\n数组（长度固定，实际少用）和切片（动态数组，最常用） Map（字典，注意零值和并发问题） 结构体（方法、嵌套、标签） JSON 序列化与反序列化 strings 和 strconv 包 下一篇将学习接口与错误处理，这是 Go 的核心设计理念。\n","date":"2024-10-12T10:00:00+08:00","permalink":"/posts/go/fundamentals/03-go-data-structures/","title":"Go 学习笔记（三）：复合数据类型"},{"content":"写在前面 本文是 Go 学习笔记系列的第二篇，介绍 Go 的基础语法，包括变量声明、数据类型、常量、流程控制和函数。前置知识：已完成 Go 环境搭建（第一篇）。\n一、变量声明 1.1 var 声明 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 基本声明（类型在变量名后面） var name string = \u0026#34;Go\u0026#34; // 类型推断（省略类型） var age = 18 // 声明但不赋值（使用零值） var score int // 0 var active bool // false var msg string // \u0026#34;\u0026#34;（空字符串） // 批量声明 var ( x int y int flag bool ) 1.2 短变量声明 := 1 2 3 4 5 6 7 8 9 // 只能在函数内使用 name := \u0026#34;Go\u0026#34; // 等价于 var name string = \u0026#34;Go\u0026#34; age := 18 // 等价于 var age int = 18 x, y := 1, 2 // 多变量同时声明 // 注意：左边至少要有一个新变量才能用 := name := \u0026#34;Go\u0026#34; name := \u0026#34;Python\u0026#34; // 编译错误，name 已声明 name, lang := \u0026#34;Go\u0026#34;, 1 // 正确，lang 是新变量 何时用 var，何时用 :=：函数内优先用 :=；包级别变量、需要零值或指定类型时用 var。\n1.3 零值 Go 的变量声明后如果没有赋值，会自动初始化为零值：\n1 2 3 4 5 6 7 8 9 10 int → 0 float64 → 0.0 bool → false string → \u0026#34;\u0026#34; pointer → nil slice → nil map → nil chan → nil interface → nil func → nil 1.4 匿名变量 1 2 3 4 5 // 用 _ 忽略不需要的返回值 _, err := os.Stat(\u0026#34;/tmp/test\u0026#34;) if err != nil { fmt.Println(\u0026#34;文件不存在\u0026#34;) } 二、基本数据类型 2.1 整型 1 2 3 4 5 6 7 8 9 10 11 var a int = 10 // 平台相关（32位或64位） var b int8 = 127 // -128 到 127 var c int16 = 1000 var d int32 = 100000 var e int64 = 1000000 var f uint = 10 // 无符号，平台相关 var g uint8 = 255 // 0 到 255（也叫做 byte） var h uintptr // 存放指针 // 实际开发中最常用的是 int 和 int64 // uint8 的别名是 byte，int32 的别名是 rune（表示一个 Unicode 字符） 2.2 浮点型 1 2 3 4 5 6 7 var pi float32 = 3.14 var e float64 = 2.71828 // 默认推断为 float64 x := 3.14 // float64 // 实际开发中基本都用 float64 2.3 布尔型 1 2 3 4 5 var active bool = true var deleted bool // false // 不能用 0/1 代替布尔值 // var flag bool = 1 // 编译错误 2.4 字符串 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 s1 := \u0026#34;Hello\u0026#34; // 双引号，可转义 s2 := `Hello\\nWorld` // 反引号，原样输出（\\n 不会转义） s3 := \u0026#34;你好，Go\u0026#34; // UTF-8 编码，天然支持中文 // 字符串拼接 greeting := \u0026#34;Hello\u0026#34; + \u0026#34; \u0026#34; + \u0026#34;Go\u0026#34; // 字符串长度 fmt.Println(len(\u0026#34;Go\u0026#34;)) // 2（字节数） fmt.Println(len(\u0026#34;你好\u0026#34;)) // 6（每个中文3个字节） // 字符串不可变（不能修改某个字符） // s1[0] = \u0026#39;h\u0026#39; // 编译错误 // 遍历字符串 for i, ch := range \u0026#34;你好Go\u0026#34; { fmt.Printf(\u0026#34;%d: %c\\n\u0026#34;, i, ch) // 按 rune 遍历，不会乱码 } 2.5 字符类型 1 2 3 4 5 6 7 8 // byte 是 uint8 的别名，表示 ASCII 字符 // rune 是 int32 的别名，表示一个 Unicode 字符 var ch byte = \u0026#39;A\u0026#39; // 65 var zh rune = \u0026#39;中\u0026#39; // 20013 fmt.Printf(\u0026#34;%c\\n\u0026#34;, ch) // A fmt.Printf(\u0026#34;%c\\n\u0026#34;, zh) // 中 2.6 类型转换 Go 没有隐式类型转换，必须显式转换：\n1 2 3 4 5 6 7 8 9 10 var i int = 42 var f float64 = float64(i) // int → float64 var j int = int(f) // float64 → int // string 和数字互转（需要用 strconv 包） import \u0026#34;strconv\u0026#34; s := strconv.Itoa(42) // int → string：\u0026#34;42\u0026#34; n, err := strconv.Atoi(\u0026#34;42\u0026#34;) // string → int：42 f := strconv.FormatFloat(3.14, \u0026#39;f\u0026#39;, 2, 64) // float64 → string：\u0026#34;3.14\u0026#34; 三、常量 3.1 基本用法 1 2 3 4 5 6 7 8 const pi = 3.14159 const greeting string = \u0026#34;Hello\u0026#34; // 批量声明 const ( StatusOK = 200 StatusError = 500 ) 3.2 iota 枚举 iota 是 Go 的常量生成器，在 const 块中每行自动加 1：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 const ( Sunday = iota // 0 Monday // 1 Tuesday // 2 Wednesday // 3 Thursday // 4 Friday // 5 Saturday // 6 ) // 跳过值 const ( _ = iota // 0（跳过） KB = 1 \u0026lt;\u0026lt; (10 * iota) // 1 \u0026lt;\u0026lt; 10 = 1024 MB // 1 \u0026lt;\u0026lt; 20 GB // 1 \u0026lt;\u0026lt; 30 TB // 1 \u0026lt;\u0026lt; 40 ) 四、流程控制 4.1 if-else 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 age := 18 // 基本用法 if age \u0026gt;= 18 { fmt.Println(\u0026#34;成年\u0026#34;) } else { fmt.Println(\u0026#34;未成年\u0026#34;) } // if 带初始化语句（常用模式） if err := doSomething(); err != nil { fmt.Println(\u0026#34;出错:\u0026#34;, err) } // err 只在 if-else 块内有效 // 多条件 if score \u0026gt;= 90 { fmt.Println(\u0026#34;优秀\u0026#34;) } else if score \u0026gt;= 60 { fmt.Println(\u0026#34;及格\u0026#34;) } else { fmt.Println(\u0026#34;不及格\u0026#34;) } Go 的 if 不需要小括号，但花括号必须有。\n4.2 for 循环 Go 只有 for 一种循环，没有 while：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 // 经典 for for i := 0; i \u0026lt; 10; i++ { fmt.Println(i) } // 当作 while 用 n := 0 for n \u0026lt; 10 { fmt.Println(n) n++ } // 无限循环 for { // break 或 return 退出 break } // for-range 遍历（类似 foreach） names := []string{\u0026#34;Go\u0026#34;, \u0026#34;Python\u0026#34;, \u0026#34;Java\u0026#34;} for index, value := range names { fmt.Println(index, value) } // 只要 value 不要 index for _, value := range names { fmt.Println(value) } 4.3 switch 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 day := \u0026#34;Monday\u0026#34; // 基本用法（自动 break，不需要手动加） switch day { case \u0026#34;Monday\u0026#34;: fmt.Println(\u0026#34;周一\u0026#34;) case \u0026#34;Tuesday\u0026#34;: fmt.Println(\u0026#34;周二\u0026#34;) default: fmt.Println(\u0026#34;其他\u0026#34;) } // 多值匹配 switch score { case 90, 100: fmt.Println(\u0026#34;优秀\u0026#34;) case 80: fmt.Println(\u0026#34;良好\u0026#34;) } // 无条件 switch（替代 if-else if 链） score := 85 switch { case score \u0026gt;= 90: fmt.Println(\u0026#34;优秀\u0026#34;) case score \u0026gt;= 80: fmt.Println(\u0026#34;良好\u0026#34;) case score \u0026gt;= 60: fmt.Println(\u0026#34;及格\u0026#34;) default: fmt.Println(\u0026#34;不及格\u0026#34;) } // fallthrough：穿透到下一个 case（少用） switch x { case 1: fmt.Println(\u0026#34;1\u0026#34;) fallthrough // 无条件执行下一个 case case 2: fmt.Println(\u0026#34;2\u0026#34;) } 4.4 defer defer 延迟执行，在函数返回前执行，常用于资源释放：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 func readFile(path string) { file, err := os.Open(path) if err != nil { return } defer file.Close() // 函数返回前自动关闭文件 // 读取文件... } // 多个 defer 按 LIFO（后进先出）顺序执行 func main() { defer fmt.Println(\u0026#34;第一\u0026#34;) defer fmt.Println(\u0026#34;第二\u0026#34;) defer fmt.Println(\u0026#34;第三\u0026#34;) fmt.Println(\u0026#34;先执行\u0026#34;) } // 输出： // 先执行 // 第三 // 第二 // 第一 // defer 的参数在声明时就确定了 func main() { x := 1 defer fmt.Println(x) // 输出 1，不是 2 x = 2 } 4.5 break 和 continue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 // break 跳出当前循环 for i := 0; i \u0026lt; 10; i++ { if i == 5 { break } fmt.Println(i) // 0 1 2 3 4 } // continue 跳过本次迭代 for i := 0; i \u0026lt; 10; i++ { if i%2 == 0 { continue } fmt.Println(i) // 1 3 5 7 9 } // 跳出外层循环（使用标签） outer: for i := 0; i \u0026lt; 3; i++ { for j := 0; j \u0026lt; 3; j++ { if i == 1 \u0026amp;\u0026amp; j == 1 { break outer // 跳出外层循环 } } } 五、函数 5.1 基本定义 1 2 3 4 5 6 7 8 9 10 11 12 // 基本函数 func add(a int, b int) int { return a + b } // 相同类型的参数可以省略前面的类型 func add(a, b int) int { return a + b } // 调用 result := add(1, 2) 5.2 多返回值 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // Go 函数可以返回多个值（非常常用） func divide(a, b float64) (float64, error) { if b == 0 { return 0, errors.New(\u0026#34;除数不能为零\u0026#34;) } return a / b, nil } // 调用 result, err := divide(10, 3) if err != nil { fmt.Println(\u0026#34;出错:\u0026#34;, err) } else { fmt.Println(\u0026#34;结果:\u0026#34;, result) } 5.3 命名返回值 1 2 3 4 5 6 // 给返回值命名，可以在函数内直接赋值 func rectangle(length, width float64) (area, perimeter float64) { area = length * width perimeter = 2 * (length + width) return // 裸 return，自动返回 area 和 perimeter } 5.4 可变参数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 可变参数（类似 Java 的 ...params） func sum(nums ...int) int { total := 0 for _, n := range nums { total += n } return total } // 调用 sum(1, 2, 3) // 6 sum(1, 2, 3, 4, 5) // 15 // 展开切片 numbers := []int{1, 2, 3} sum(numbers...) // 传切片时加 ... 5.5 函数是一等公民 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 // 函数作为变量 add := func(a, b int) int { return a + b } fmt.Println(add(1, 2)) // 3 // 函数作为参数（回调） func apply(nums []int, fn func(int) int) []int { result := make([]int, len(nums)) for i, n := range nums { result[i] = fn(n) } return result } doubled := apply([]int{1, 2, 3}, func(n int) int { return n * 2 }) // doubled = [2, 4, 6] // 函数作为返回值（闭包） func counter() func() int { count := 0 return func() int { count++ return count } } c := counter() fmt.Println(c()) // 1 fmt.Println(c()) // 2 fmt.Println(c()) // 3 六、指针 6.1 基本用法 1 2 3 4 5 6 7 8 x := 42 p := \u0026amp;x // \u0026amp; 取地址，p 是 *int 类型 fmt.Println(p) // 地址，如 0xc0000b2008 fmt.Println(*p) // * 解引用，输出 42 *p = 100 // 通过指针修改值 fmt.Println(x) // 100 6.2 指针作为函数参数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 不用指针：值传递，函数内修改不影响外部 func double(n int) { n = n * 2 // 只修改了副本 } // 用指针：可以在函数内修改外部变量 func doublePtr(n *int) { *n = *n * 2 } x := 10 double(x) fmt.Println(x) // 10（没变） doublePtr(\u0026amp;x) fmt.Println(x) // 20（变了） Go 指针不能做指针运算（不能 p++），比 C 安全得多。实际开发中指针主要用于结构体方法（接收者）和避免大对象拷贝。\n七、小结 本文学习了 Go 的基础语法：\n变量声明（var、:=、零值） 基本数据类型（整型、浮点、字符串、布尔）和类型转换 常量与 iota 枚举 流程控制（if、for、switch、defer） 函数（多返回值、可变参数、一等公民） 指针基础 下一篇将学习复合数据类型：数组、切片、字典和结构体。\n","date":"2024-10-08T10:00:00+08:00","permalink":"/posts/go/fundamentals/02-go-syntax/","title":"Go 学习笔记（二）：基础语法与数据类型"},{"content":"写在前面 这是 Go 学习笔记系列的第一篇，目标是从零搭建 Go 开发环境，理解项目结构和常用工具链。读完本文你将能够独立创建、编译、运行一个 Go 项目。\n本系列适合有其他语言编程经验、刚开始学 Go 的开发者。\n一、安装 Go 1.1 下载安装 到 Go 官方网站下载对应系统的安装包：\n官网：https://go.dev/dl/ 国内镜像：https://golang.google.cn/dl/ 1 2 3 4 5 6 7 # 安装后验证 go version # go version go1.24.x linux/amd64 # 查看 Go 安装路径 go env GOROOT # /usr/local/go 1.2 环境变量 Go 安装后会自动配置，一般不需要手动设置。了解即可：\n1 2 3 GOROOT — Go 的安装目录（类似 JAVA_HOME） GOPATH — Go 的工作空间目录（旧版本项目放这里，go mod 之后不太重要了） GOBIN — go install 安装的可执行文件存放路径（默认 $GOPATH/bin） 1 2 3 4 5 # 查看所有环境变量 go env # 设置 Go 代理（国内加速模块下载） go env -w GOPROXY=https://goproxy.cn,direct 1.3 开发工具 推荐使用 VS Code + Go 扩展，或 GoLand（JetBrains 出品）。\nVS Code 安装 Go 扩展后会自动提示安装相关工具（gopls、dlv 等），全部安装即可。\n二、第一个 Go 程序 2.1 创建项目 1 2 3 4 5 6 7 8 # 创建项目目录 mkdir hello \u0026amp;\u0026amp; cd hello # 初始化 Go 模块（类似 npm init） go mod init example.com/hello # 会生成 go.mod 文件，内容： # module example.com/hello # go 1.24 2.2 编写代码 创建 main.go：\n1 2 3 4 5 6 7 package main import \u0026#34;fmt\u0026#34; func main() { fmt.Println(\u0026#34;Hello, Go!\u0026#34;) } 2.3 运行 1 2 3 4 5 6 7 8 9 10 # 直接运行（不生成可执行文件） go run main.go # Hello, Go! # 编译生成可执行文件 go build -o hello main.go # 运行编译后的文件 ./hello # Hello, Go! 三、代码结构解析 3.1 package 声明 1 2 3 4 5 6 7 // 每个 Go 文件必须以 package 声明开头 // main 包是特殊的，定义了可执行程序的入口 package main // 其他包名通常和目录名一致 package utils // 放在 utils/ 目录下 package models // 放在 models/ 目录下 3.2 import 导入 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // 单个导入 import \u0026#34;fmt\u0026#34; // 多个导入（推荐写法） import ( \u0026#34;fmt\u0026#34; \u0026#34;os\u0026#34; \u0026#34;strings\u0026#34; ) // 导入但不直接使用（只执行 init 函数） import _ \u0026#34;github.com/lib/pq\u0026#34; // 给包起别名 import ( f \u0026#34;fmt\u0026#34; ) // 使用别名调用 f.Println(\u0026#34;hello\u0026#34;) 注意：Go 要求导入的包必须使用，未使用的导入会编译报错。\n3.3 main 函数 1 2 3 4 5 // main 包中的 main 函数是程序入口 // 没有 main 函数无法编译成可执行文件 func main() { // 程序从这里开始执行 } 3.4 语句规则 1 2 3 4 5 6 7 8 9 10 11 12 // 不需要分号（编译器自动添加） fmt.Println(\u0026#34;hello\u0026#34;) // 左花括号不能换行（编译错误） // 错误写法： // func main() // { // } // 正确写法： func main() { } 四、go mod 包管理 Go 从 1.11 开始使用 Go Modules 管理依赖，类似 npm、pip。\n4.1 常用命令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 初始化模块 go mod init example.com/myproject # 添加依赖（import 后执行，自动下载） go mod tidy # 下载依赖到本地缓存 go mod download # 查看依赖列表 go list -m all # 查看某个依赖的可用版本 go list -m -versions github.com/gin-gonic/gin 4.2 go.mod 文件 1 2 3 4 5 6 module example.com/myproject // 模块名 go 1.24 // Go 版本 require ( github.com/gin-gonic/gin v1.10.0 // 直接依赖 ) 4.3 安装第三方包 1 2 3 4 5 6 7 8 9 10 11 # 安装指定包（会加到 go.mod） go get github.com/gin-gonic/gin@latest # 安装指定版本 go get github.com/gin-gonic/gin@v1.10.0 # 清理未使用的依赖 go mod tidy # 把依赖复制到项目的 vendor 目录 go mod vendor 4.4 项目结构示例 1 2 3 4 5 6 7 8 9 10 11 12 13 myproject/ ├── go.mod // 模块定义和依赖 ├── go.sum // 依赖的校验和（自动生成，不要手动改） ├── main.go // 程序入口 ├── internal/ // 私有代码（其他模块不能导入） │ └── handler/ │ └── user.go ├── pkg/ // 可被外部导入的包 │ └── utils/ │ └── string.go └── cmd/ // 多个可执行程序 └── server/ └── main.go 五、工具链 5.1 常用命令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 # 运行 go run main.go # 编译 go build # 编译当前包 go build -o app . # 指定输出文件名 go build ./... # 编译当前目录下所有包 # 测试 go test # 运行当前包的测试 go test ./... # 运行所有测试 go test -v ./... # 详细输出 go test -run TestName # 运行指定测试函数 # 格式化代码 go fmt ./... # 格式化所有文件 gofmt -w . # 另一种写法（-w 直接修改文件） # 静态检查 go vet ./... # 发现常见错误 # 安装可执行工具 go install github.com/go-delve/delve/cmd/dlv@latest 5.2 go run vs go build 1 2 go run — 编译并立即运行，适合开发调试 go build — 编译生成可执行文件，适合部署分发 5.3 交叉编译 Go 支持交叉编译，在任意平台编译其他平台的可执行文件：\n1 2 3 4 5 6 7 8 9 10 11 # 编译 Linux 可执行文件（在 Windows/Mac 上执行） GOOS=linux GOARCH=amd64 go build -o app-linux . # 编译 Windows 可执行文件 GOOS=windows GOARCH=amd64 go build -o app.exe . # 编译 Mac 可执行文件 GOOS=darwin GOARCH=arm64 go build -o app-mac . # Windows 下交叉编译（PowerShell） $env:GOOS=\u0026#34;linux\u0026#34;; $env:GOARCH=\u0026#34;amd64\u0026#34;; go build -o app-linux . 六、输出与基本 I/O 6.1 fmt 包 — 格式化输出 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 package main import \u0026#34;fmt\u0026#34; func main() { name := \u0026#34;Go\u0026#34; version := 1.24 // 基本输出 fmt.Println(\u0026#34;Hello\u0026#34;, name) // 输出后换行 fmt.Printf(\u0026#34;版本: %.2f\\n\u0026#34;, version) // 格式化输出 // 格式化并返回字符串（不输出） s := fmt.Sprintf(\u0026#34;Hello %s\u0026#34;, name) // 格式化动词 fmt.Printf(\u0026#34;字符串: %s\\n\u0026#34;, \u0026#34;hello\u0026#34;) // 字符串 fmt.Printf(\u0026#34;整数: %d\\n\u0026#34;, 42) // 十进制整数 fmt.Printf(\u0026#34;浮点: %.2f\\n\u0026#34;, 3.14159) // 保留2位小数 fmt.Printf(\u0026#34;布尔: %t\\n\u0026#34;, true) // 布尔值 fmt.Printf(\u0026#34;类型: %T\\n\u0026#34;, name) // 变量类型 fmt.Printf(\u0026#34;值: %v\\n\u0026#34;, struct{}{}) // 默认格式 fmt.Printf(\u0026#34;详细: %+v\\n\u0026#34;, struct{}{}) // 带字段名 } 6.2 常用格式化动词 1 2 3 4 5 6 7 8 9 10 %s — 字符串 %d — 十进制整数 %f — 浮点数（%.2f 保留2位小数） %t — 布尔值 %T — 类型 %v — 默认格式（万能） %+v — 带字段名的格式（用于结构体） %p — 指针地址 %c — 字符（Unicode 码点转字符） %02d — 至少2位，不足补0 七、小结 本文完成了以下内容：\nGo 环境安装与配置 第一个 Go 程序的编写与运行 Go 代码的基本结构（package、import、main） go mod 依赖管理 常用工具链命令 下一篇将学习 Go 的基础语法，包括变量声明、数据类型、流程控制和函数。\n","date":"2024-10-04T10:00:00+08:00","permalink":"/posts/go/fundamentals/01-go-basics/","title":"Go 学习笔记（一）：环境搭建与入门基础"},{"content":"写在前面 本文整理了 Linux 系统性能排查的常用命令和思路，涵盖 CPU、内存、磁盘、网络四大方向。按照\u0026quot;先定位问题方向，再深入排查\u0026quot;的逻辑组织，方便遇到性能问题时快速入手。\n一、排查思路总览 遇到性能问题时，建议按以下顺序定位：\n1 2 3 4 5 6 7 1. top / htop — 快速看全局，确认是 CPU、内存、还是 I/O 问题 2. 针对性深入排查： - CPU 高 → 进程级别分析（第三章） - 内存高 → 内存分配分析（第四章） - 磁盘慢 → I/O 分析（第五章） - 网络慢 → 网络分析（第六章） 3. 结合日志和监控确认根因 二、全局概览 2.1 top — 系统资源总览 1 2 3 4 5 6 7 8 9 10 # 实时查看系统资源使用情况 top # 常用交互操作： # P — 按 CPU 排序 # M — 按内存排序 # 1 — 展开显示每个 CPU 核心 # c — 显示完整命令行 # k — 终止指定进程（输入 PID） # q — 退出 关键字段解读：\n1 2 3 4 5 6 7 8 top - 14:30:01 up 30 days, 3:20, 2 users, load average: 2.5, 1.8, 1.2 # 当前时间 运行时间 用户数 负载均值（1分钟/5分钟/15分钟） %Cpu(s): 25.0 us, 3.0 sy, 0.0 ni, 70.0 id, 1.5 wa, 0.5 si # 用户态 内核态 空闲 I/O等待 软中断 MiB Mem : 7823.0 total, 256.0 free, 4096.0 used, 3471.0 buff/cache # 总内存 空闲 已使用 缓冲/缓存 load average：三个数字分别表示过去 1、5、15 分钟的平均负载。数值接近 CPU 核心数时系统满载，超过则有过载。比如 4 核机器，load 到 4 就是满载。\nwa（iowait）：持续高于 5% 说明磁盘 I/O 是瓶颈。\n2.2 htop — 增强版 top 1 2 3 4 # 更直观的资源监控（需安装） htop # 优势：支持鼠标、颜色区分、快捷键更友好、可直接看到每个核心使用率 2.3 uptime — 快速看负载 1 2 3 # 一行输出系统运行时间和负载 uptime # 14:30:01 up 30 days, 3:20, 2 users, load average: 2.5, 1.8, 1.2 2.4 vmstat — 系统活动统计 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # 每隔2秒采样一次，共采5次 vmstat 2 5 # 关键列： # r — 运行队列中的进程数（持续大于 CPU 核心数说明 CPU 瓶颈） # b — 等待 I/O 的进程数（持续大于 0 说明 I/O 瓶颈） # swpd — 已使用的 swap 大小（持续增长说明内存不够） # si — 每秒从 swap 读入内存（swap in） # so — 每秒从内存写入 swap（swap out） # bi — 每秒从磁盘读入的块数 # bo — 每秒写入磁盘的块数 # us — 用户态 CPU 时间百分比 # sy — 内核态 CPU 时间百分比 # id — 空闲 CPU 时间百分比 # wa — I/O 等待时间百分比 三、CPU 排查 3.1 定位高 CPU 进程 1 2 3 4 5 6 7 8 9 10 11 # 按CPU使用率排序，显示前10个进程 ps aux --sort=-%cpu | head -10 # 或者用 top 的批处理模式（适合脚本采集） top -b -n 1 | head -20 # 查看指定进程的线程CPU占用 top -H -p \u0026lt;PID\u0026gt; # 查看进程的所有线程 ps -T -p \u0026lt;PID\u0026gt; 3.2 分析进程在做什么 1 2 3 4 5 6 7 8 9 10 11 12 # 查看进程的系统调用（跟踪进程在干什么） strace -p \u0026lt;PID\u0026gt; -c # -c 会统计每种系统调用的次数和耗时，Ctrl+C 后输出汇总 # 实时跟踪进程的系统调用 strace -p \u0026lt;PID\u0026gt; # 跟踪进程并统计耗时排序 strace -p \u0026lt;PID\u0026gt; -c -S time # 只跟踪网络相关的系统调用 strace -p \u0026lt;PID\u0026gt; -e trace=network 3.3 perf — 性能分析 1 2 3 4 5 6 7 8 9 10 11 # 对指定进程做 CPU profiling（采样10秒） sudo perf top -p \u0026lt;PID\u0026gt; # 记录性能数据 sudo perf record -p \u0026lt;PID\u0026gt; -g -- sleep 10 # 查看记录的性能报告 sudo perf report # 查看系统全局的热点函数 sudo perf top 3.4 常见 CPU 高的原因 1 2 3 4 计算密集型业务 → 正常，考虑优化算法或扩容 频繁 GC → java 应用的常见问题，查看 GC 日志 死循环 → 代码 bug，用 strace/perf 定位 上下文切换多 → 线程数过多，用 vmstat 看 cs 列 3.5 查看上下文切换 1 2 3 4 5 6 7 8 # 查看每秒上下文切换次数 vmstat 1 5 # 看 cs 列（context switch） # 查看进程级别的自愿/非自愿上下文切换 pidstat -w 1 5 # cswch/s — 自愿上下文切换（主动让出 CPU） # nvcswch/s — 非自愿上下文切换（被内核抢占，说明 CPU 竞争激烈） 四、内存排查 4.1 查看内存使用 1 2 3 4 5 6 7 8 9 # 查看内存总览 free -h # total used free shared buff/cache available # Mem: 7.6Gi 4.0Gi 256Mi 16Mi 3.4Gi 3.2Gi # Swap: 2.0Gi 512Mi 1.5Gi # 重要：看 available 而不是 free # available = 系统实际可用内存（包含可回收的缓存） # free 只是完全未使用的内存，通常很少 buff/cache 是内核用作缓冲的内存，应用需要时会自动释放，不算\u0026quot;被占用\u0026quot;。\n4.2 进程内存排名 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 按内存使用率排序，显示前10 ps aux --sort=-%mem | head -10 # 查看指定进程的内存详情 cat /proc/\u0026lt;PID\u0026gt;/status | grep Vm # VmRSS — 实际使用的物理内存（最常关注的） # VmSize — 虚拟内存大小 # VmSwap — 使用的 swap 大小 # 查看进程内存映射 pmap -x \u0026lt;PID\u0026gt; # 更直观的进程内存排序 smem -t -k -s rss # 需安装 smem 4.3 查看 swap 使用情况 1 2 3 4 5 6 7 8 9 10 11 12 # 查看哪些进程在使用 swap for pid in $(ls /proc | grep -E \u0026#39;^[0-9]+$\u0026#39;); do swap=$(cat /proc/$pid/status 2\u0026gt;/dev/null | grep VmSwap | awk \u0026#39;{print $2}\u0026#39;) if [ -n \u0026#34;$swap\u0026#34; ] \u0026amp;\u0026amp; [ \u0026#34;$swap\u0026#34; -gt 0 ]; then name=$(cat /proc/$pid/cmdline 2\u0026gt;/dev/null | tr \u0026#39;\\0\u0026#39; \u0026#39; \u0026#39;) echo \u0026#34;PID: $pid Swap: ${swap}kB CMD: $name\u0026#34; fi done | sort -k4 -rn | head -10 # 简化版：查看系统 swap 使用 swapon --show cat /proc/swaps 4.4 slab 缓存分析 1 2 3 4 5 6 7 8 # 查看内核 slab 缓存占用 slabtop # 查看内核内存使用概况 cat /proc/meminfo | grep -i slab # Slab — slab 缓存总量 # SReclaimable — 可回收的 slab # SUnreclaim — 不可回收的 slab（持续增长需关注） 4.5 常见内存问题的排查方向 1 2 3 4 available 持续下降 → 内存泄漏，用 pmap 或应用工具定位 Swap 使用量高 → 物理内存不够，考虑加内存或优化进程 进程 RSS 异常大 → 检查是否有内存泄漏，查看 heap dump 系统 OOM Kill → dmesg | grep -i oom 查看被杀的进程 4.6 OOM 日志查看 1 2 3 4 5 6 7 # 查看 OOM killer 记录 dmesg | grep -i \u0026#34;out of memory\u0026#34; dmesg | grep -i \u0026#34;killed process\u0026#34; # 从系统日志查看 grep -i \u0026#34;oom\u0026#34; /var/log/messages journalctl -k | grep -i \u0026#34;oom\u0026#34; 五、磁盘 I/O 排查 5.1 iostat — 磁盘 I/O 统计 1 2 3 4 5 6 7 8 9 10 11 12 # 查看所有磁盘的 I/O 统计（每秒刷新） iostat -x 1 # 关键指标： # %util — 磁盘繁忙程度（接近 100% 说明磁盘饱和） # await — 平均 I/O 等待时间（ms），高于 10ms 需关注 # svctm — 平均服务时间 # r/s — 每秒读次数 # w/s — 每秒写次数 # rkB/s — 每秒读速度（KB） # wkB/s — 每秒写速度（KB） # avgqu-sz — 平均 I/O 队列长度（持续大于 1 说明有积压） await 持续高于 10ms 或 %util 持续高于 80%，说明磁盘是瓶颈。\n5.2 iotop — 查看哪个进程在疯狂读写磁盘 1 2 3 4 5 6 7 8 # 实时查看各进程的磁盘 I/O（需 root） sudo iotop # 只看有 I/O 活动的进程 sudo iotop -o # 批处理模式（适合脚本采集） sudo iotop -b -o -n 3 5.3 pidstat — 按进程查看 I/O 1 2 3 4 5 6 7 # 查看各进程的磁盘读写（每秒刷新） pidstat -d 1 # 输出说明： # kB_rd/s — 每秒读 KB # kB_wr/s — 每秒写 KB # kB_ccwr/s — 每秒取消写入的 KB 5.4 查看磁盘使用空间 1 2 3 4 5 6 7 8 9 10 11 # 查看各磁盘使用率 df -h # 查看目录大小 du -sh /var/log/* # 找出最大的文件 find / -type f -size +500M -exec ls -lh {} \\; 2\u0026gt;/dev/null | sort -k5 -rh | head -20 # 查看inode使用率（inode满了也会报 No space left on device） df -i 5.5 磁盘性能测试 1 2 3 4 5 6 7 8 9 # 测试顺序读速度 sudo hdparm -Tt /dev/sda # 用 dd 测试写入速度（写一个 1GB 文件） dd if=/dev/zero of=/tmp/testfile bs=1M count=1024 oflag=dsync # 看输出中的速度，一般在 100MB/s 以上为正常（SSD 更高） # 测试完成后删除测试文件 rm /tmp/testfile 5.6 常见磁盘问题 1 2 3 4 5 6 磁盘空间满 → df -h 定位，然后 du -sh * 找大文件 inode 满 → df -i 检查，通常是大量小文件导致 I/O wait 高 → iostat -x 1 看 %util 和 await 某个进程疯狂写盘 → iotop -o 定位进程 日志文件太大 → 用 logrotate 管理日志轮转 删除文件空间未释放 → lsof | grep deleted 查看，重启占用进程释放 5.7 查找已删除但未释放空间的文件 1 2 3 4 5 # 查看已删除但被进程占用未释放的文件 lsof | grep deleted # 查看指定进程打开的文件 lsof -p \u0026lt;PID\u0026gt; 六、网络排查 6.1 网络连通性 1 2 3 4 5 6 7 8 9 10 11 12 # 测试网络是否通 ping 8.8.8.8 # 测试指定端口是否通 telnet 192.168.1.100 8080 # 或 nc -zv 192.168.1.100 8080 # 测试到目标的网络路径和延迟 traceroute 8.8.8.8 # 或 mtr 8.8.8.8 # 持续版本的 traceroute（需安装） 6.2 mtr — 网络链路质量 1 2 3 4 5 6 7 8 # 持续探测到目标的每一跳 mtr --report baidu.com # 关键指标： # Loss% — 丢包率（大于 0 需关注） # Avg — 平均延迟 # Worst — 最差延迟 # StDev — 延迟波动（越大越不稳定） 6.3 网卡流量与错误 1 2 3 4 5 6 7 8 9 10 11 12 # 查看网卡流量 ip -s link show eth0 # 实时监控网卡流量（需安装） iftop -i eth0 # 查看网卡统计信息（关注错误和丢包） cat /proc/net/dev # 关注：errors（错误数）、dropped（丢包数） # 用 nload 看实时流量（需安装） nload 6.4 TCP 连接统计 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 查看所有 TCP 连接状态统计 ss -ant | awk \u0026#39;{print $1}\u0026#39; | sort | uniq -c | sort -rn # 常见状态： # ESTABLISHED — 已建立的连接 # TIME_WAIT — 等待关闭的连接（大量堆积需关注） # CLOSE_WAIT — 等待应用关闭的连接（大量堆积说明应用没正确关闭连接） # SYN_SENT — 正在建立连接（多说明连接慢或目标不可达） # 查看连接数最多的 IP ss -ant | grep ESTAB | awk \u0026#39;{print $5}\u0026#39; | cut -d: -f1 | sort | uniq -c | sort -rn | head -10 # 查看指定端口的连接数 ss -ant state established \u0026#39;( dport = :8080 or sport = :8080 )\u0026#39; | wc -l 6.5 sar — 网络历史统计 1 2 3 4 5 6 7 8 9 10 11 # 查看历史网络流量（需安装 sysstat） sar -n DEV # 查看今天的网络流量，每秒刷新 sar -n DEV 1 # 查看 TCP 统计 sar -n TCP 1 # 查看 SOCK 统计（socket 使用情况） sar -n SOCK 1 6.6 抓包分析 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # 抓取指定网卡的所有包 sudo tcpdump -i eth0 # 抓取指定端口的包 sudo tcpdump -i eth0 port 8080 # 抓取指定 IP 的包 sudo tcpdump -i eth0 host 192.168.1.100 # 抓取并保存到文件（用 Wireshark 分析） sudo tcpdump -i eth0 -w capture.pcap # 抓取指定端口的前 100 个包，显示内容 sudo tcpdump -i eth0 port 8080 -c 100 -A # 组合过滤 sudo tcpdump -i eth0 host 192.168.1.100 and port 8080 -nn 6.7 curl 测量接口耗时 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # 测量请求各阶段耗时 curl -o /dev/null -s -w \u0026#34;\\ time_namelookup: %{time_namelookup}s\\n\\ time_connect: %{time_connect}s\\n\\ time_starttransfer: %{time_starttransfer}s\\n\\ time_total: %{time_total}s\\n\\ http_code: %{http_code}\\n\u0026#34; https://example.com/api # 各阶段含义： # time_namelookup — DNS 解析耗时 # time_connect — TCP 连接耗时 # time_starttransfer — 首字节到达耗时（含服务端处理） # time_total — 总耗时 # 判断依据： # DNS 慢 → time_namelookup 高 # 网络慢 → time_connect 高 # 服务端慢 → time_starttransfer - time_connect 高 # 传输慢 → time_total - time_starttransfer 高 6.8 常见网络问题 1 2 3 4 5 6 连接超时 → 防火墙、路由、目标服务未启动 丢包严重 → mtr 逐跳排查，定位哪一跳开始丢包 TIME_WAIT 过多 → 短连接太多，考虑长连接或调整内核参数 CLOSE_WAIT 过多 → 应用没正确关闭连接，检查代码 带宽跑满 → iftop 定位哪个连接占用带宽 DNS 解析慢 → 换 DNS 或配置 /etc/hosts 七、综合排查场景 场景1：服务器响应变慢 1 2 3 4 5 6 7 8 9 10 11 12 13 # 第一步：快速全局查看 top # 看 load average、%us、%sy、wa、内存 # 第二步：确认方向 vmstat 1 5 # r 列（运行队列）、wa（I/O等待）、swpd（swap使用） # 第三步：根据方向深入 # CPU 高 → ps aux --sort=-%cpu | head -10 # 内存高 → free -h \u0026amp;\u0026amp; ps aux --sort=-%mem | head -10 # 磁盘慢 → iostat -x 1 # 网络慢 → curl 测耗时，iftop 看流量 场景2：CPU 使用率突然飙高 1 2 3 4 5 6 7 8 9 10 11 12 13 # 找到占用 CPU 最高的进程 ps aux --sort=-%cpu | head -5 # 看这个进程的线程情况 top -H -p \u0026lt;PID\u0026gt; # 跟踪进程在做什么 strace -p \u0026lt;PID\u0026gt; -c # 如果是 Java 应用 jstack \u0026lt;PID\u0026gt; \u0026gt; thread_dump.txt # 或者 jcmd \u0026lt;PID\u0026gt; Thread.print 场景3：应用频繁 OOM 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # 查看 OOM 记录 dmesg | grep -i \u0026#34;killed process\u0026#34; # 查看当前内存使用 free -h # 找出内存占用最大的进程 ps aux --sort=-%mem | head -10 # 查看进程内存详情 pmap -x \u0026lt;PID\u0026gt; # 如果是 Java 应用 jmap -heap \u0026lt;PID\u0026gt; jmap -histo \u0026lt;PID\u0026gt; | head -20 场景4：磁盘 I/O 导致应用变慢 1 2 3 4 5 6 7 8 9 10 11 12 # 确认 I/O wait top # 看 wa 百分比 vmstat 1 5 # 看 wa 列和 b 列 # 查看哪个磁盘繁忙 iostat -x 1 # 找到疯狂读写磁盘的进程 sudo iotop -o # 查看磁盘空间 df -h 场景5：网络请求慢 1 2 3 4 5 6 7 8 9 10 11 12 # 测试网络连通性和延迟 ping target-server mtr target-server # 测量接口各阶段耗时 curl -o /dev/null -s -w \u0026#34;DNS: %{time_namelookup}s\\nConnect: %{time_connect}s\\nFirstByte: %{time_starttransfer}s\\nTotal: %{time_total}s\\n\u0026#34; http://target-server/api # 查看连接状态 ss -ant | awk \u0026#39;{print $1}\u0026#39; | sort | uniq -c | sort -rn # 抓包分析 sudo tcpdump -i eth0 port 8080 -nn -c 100 八、常用工具速查 按场景选工具 场景 首选命令 辅助命令 快速全局查看 top / htop vmstat、uptime CPU 高 ps、top -H strace、perf、pidstat 内存高 free、ps pmap、slabtop 磁盘慢 iostat -x iotop、pidstat -d 网络慢 ping、mtr curl、iftop、tcpdump 查看进程详情 /proc/PID/* pmap、lsof 查看系统日志 dmesg journalctl、/var/log/* 工具安装 1 2 3 4 5 6 7 # CentOS / RHEL sudo yum install -y sysstat iotop htop iftop nload mtr # Ubuntu / Debian sudo apt install -y sysstat iotop htop iftop nload mtr-tiny # sysstat 包含：iostat、mpstat、pidstat、sar ","date":"2024-09-26T10:00:00+08:00","permalink":"/posts/linux/linux-performance-diagnostics-cheatsheet/","title":"Linux 性能诊断速查手册"},{"content":"写在前面 本文整理了日常开发和运维中最常用的 Linux 命令，涵盖参数说明与实际示例，方便随时查阅。每个命令都力求实用，避免罗列用不到的冷门参数。\n一、文件与目录 1.1 ls — 列出目录内容 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 # 列出当前目录文件 ls # 显示详细信息（权限、大小、时间） ls -l # 显示隐藏文件（以 . 开头的文件） ls -a # 按时间倒序排列（最新的在前） ls -lt # 按文件大小倒序排列 ls -lS # 人类可读的文件大小（KB、MB、GB） ls -lh # 只显示目录名，不显示内容 ls -d */ # 递归列出所有子目录 ls -R 1.2 cd — 切换目录 1 2 3 4 5 6 7 8 9 10 11 12 # 切换到指定目录 cd /home/user/project # 切换到上一级目录 cd .. # 切换到用户主目录 cd ~ cd # 切换到上一次所在的目录 cd - 1.3 pwd — 显示当前目录 1 2 # 显示当前所在目录的完整路径 pwd 1.4 mkdir — 创建目录 1 2 3 4 5 6 7 8 # 创建目录 mkdir my-folder # 递归创建多级目录 mkdir -p project/src/main/java # 创建时指定权限 mkdir -m 755 my-folder 1.5 cp — 复制 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 复制文件 cp file.txt backup.txt # 复制目录（递归） cp -r my-folder my-folder-backup # 复制前确认是否覆盖 cp -i file.txt /target/ # 保持文件属性（权限、时间戳等） cp -p file.txt /target/ # 显示复制过程 cp -rv my-folder /target/ 1.6 mv — 移动/重命名 1 2 3 4 5 6 7 8 9 10 11 # 重命名文件 mv old-name.txt new-name.txt # 移动文件到目录 mv file.txt /home/user/ # 移动目录 mv my-folder /home/user/ # 移动前确认是否覆盖 mv -i file.txt /target/ 1.7 rm — 删除 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 删除文件 rm file.txt # 删除前确认 rm -i file.txt # 强制删除（不提示确认） rm -f file.txt # 递归删除目录及其内容 rm -r my-folder # 强制递归删除目录（谨慎使用） rm -rf my-folder 注意：rm -rf 不可恢复，使用前确认路径正确。千万不要执行 rm -rf /。\n1.8 touch — 创建空文件/更新时间戳 1 2 3 4 5 6 7 8 # 创建空文件 touch newfile.txt # 同时创建多个文件 touch file1.txt file2.txt file3.txt # 更新文件的修改时间（文件已存在时不改变内容） touch existing-file.txt 1.9 ln — 创建链接 1 2 3 4 5 6 7 8 # 创建软链接（类似快捷方式） ln -s /usr/local/java/jdk-17 java17 # 创建硬链接（共享同一个 inode） ln original.txt hardlink.txt # 查看链接指向 ls -l java17 软链接可以跨文件系统，删源文件后链接失效；硬链接不能跨文件系统，删源文件后仍可访问。\n1.10 find — 查找文件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 # 按文件名查找 find /home -name \u0026#34;*.log\u0026#34; # 按文件名查找（不区分大小写） find /home -iname \u0026#34;*.JPG\u0026#34; # 按类型查找（f=文件，d=目录，l=链接） find /var -type f -name \u0026#34;*.conf\u0026#34; find /home -type d -name \u0026#34;project\u0026#34; # 按大小查找（大于100MB的文件） find / -type f -size +100M # 按修改时间查找（7天内修改过的文件） find /home -type f -mtime -7 # 按权限查找 find /home -type f -perm 755 # 查找并执行操作（删除7天前的日志） find /var/log -name \u0026#34;*.log\u0026#34; -mtime +7 -delete # 查找并执行命令（对结果执行 ls -l） find /home -name \u0026#34;*.txt\u0026#34; -exec ls -l {} \\; 1.11 tree — 树形显示目录结构 1 2 3 4 5 6 7 8 9 10 11 # 显示目录树 tree # 只显示目录，不显示文件 tree -d # 指定显示深度 tree -L 2 # 显示文件大小 tree -h 二、文件查看与编辑 2.1 cat — 查看文件内容 1 2 3 4 5 6 7 8 9 10 11 # 查看文件全部内容 cat file.txt # 显示行号 cat -n file.txt # 合并多个文件 cat file1.txt file2.txt \u0026gt; merged.txt # 追加内容到文件 cat file1.txt \u0026gt;\u0026gt; file2.txt 2.2 head — 查看文件开头 1 2 3 4 5 6 7 8 # 查看前10行（默认） head file.txt # 查看前20行 head -n 20 file.txt # 查看前1KB内容 head -c 1024 file.txt 2.3 tail — 查看文件末尾 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 查看最后10行（默认） tail file.txt # 查看最后50行 tail -n 50 file.txt # 实时查看日志（最常用） tail -f app.log # 实时查看并显示行号 tail -fn 100 app.log # 从第100行开始显示 tail -n +100 file.txt 2.4 less — 分页查看 1 2 3 4 5 # 分页查看大文件 less large-file.log # 带行号查看 less -N file.txt less 内操作：空格/b 翻页，/ 搜索，q 退出，G 跳到末尾，gg 跳到开头。\n2.5 grep — 文本搜索 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 # 搜索包含关键词的行 grep \u0026#34;ERROR\u0026#34; app.log # 不区分大小写搜索 grep -i \u0026#34;error\u0026#34; app.log # 显示行号 grep -n \u0026#34;ERROR\u0026#34; app.log # 显示匹配行的前后各3行（上下文） grep -C 3 \u0026#34;ERROR\u0026#34; app.log # 只显示匹配的文件名 grep -l \u0026#34;ERROR\u0026#34; *.log # 递归搜索目录下所有文件 grep -r \u0026#34;ERROR\u0026#34; /var/log/ # 反向搜索（不包含关键词的行） grep -v \u0026#34;DEBUG\u0026#34; app.log # 统计匹配行数 grep -c \u0026#34;ERROR\u0026#34; app.log # 使用正则表达式 grep -E \u0026#34;ERROR|WARN\u0026#34; app.log # 只匹配完整单词 grep -w \u0026#34;error\u0026#34; app.log 2.6 wc — 统计 1 2 3 4 5 6 7 8 9 10 11 # 统计行数 wc -l file.txt # 统计单词数 wc -w file.txt # 统计字节数 wc -c file.txt # 统计文件行数（配合 find 使用） find . -name \u0026#34;*.java\u0026#34; | xargs wc -l 2.7 sort — 排序 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 默认按字母升序 sort file.txt # 按数字大小排序 sort -n numbers.txt # 倒序排列 sort -r file.txt # 按第2列排序 sort -k2 file.txt # 去重排序 sort -u file.txt 2.8 uniq — 去重 1 2 3 4 5 6 7 8 9 10 11 # 去除连续重复行（需先排序） sort file.txt | uniq # 统计每行出现的次数 sort file.txt | uniq -c # 只显示重复的行 sort file.txt | uniq -d # 只显示不重复的行 sort file.txt | uniq -u 2.9 awk — 文本处理 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 打印第1列 awk \u0026#39;{print $1}\u0026#39; file.txt # 打印第1列和第3列 awk \u0026#39;{print $1, $3}\u0026#39; file.txt # 指定分隔符 awk -F: \u0026#39;{print $1}\u0026#39; /etc/passwd # 按条件过滤（第3列大于100） awk \u0026#39;$3 \u0026gt; 100 {print $0}\u0026#39; file.txt # 统计文件总行数 awk \u0026#39;END {print NR}\u0026#39; file.txt 2.10 sed — 流编辑器 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 替换每行第一个匹配 sed \u0026#39;s/old/new/\u0026#39; file.txt # 替换所有匹配 sed \u0026#39;s/old/new/g\u0026#39; file.txt # 删除匹配行 sed \u0026#39;/^#/d\u0026#39; file.txt # 显示指定行（第10到20行） sed -n \u0026#39;10,20p\u0026#39; file.txt # 直接修改文件（不加 -i 只输出到终端） sed -i \u0026#39;s/old/new/g\u0026#39; file.txt 三、权限与用户 3.1 chmod — 修改权限 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 给所有者添加执行权限 chmod u+x script.sh # 设置具体权限（所有者读写执行，组读执行，其他人读执行） chmod 755 script.sh # 递归修改目录权限 chmod -R 755 /var/www/ # 常用权限值 # 755 — 所有者全权限，其他读和执行 # 644 — 所有者读写，其他只读 # 600 — 只有所有者能读写 # 777 — 所有人全权限（不推荐在生产环境使用） 3.2 chown — 修改所有者 1 2 3 4 5 6 7 8 # 修改文件所有者 chown user file.txt # 修改文件所有者和所属组 chown user:group file.txt # 递归修改目录 chown -R user:group /var/www/ 3.3 sudo — 以管理员权限执行 1 2 3 4 5 6 7 8 9 10 11 # 以 root 权限执行命令 sudo command # 以指定用户执行 sudo -u username command # 切换到 root 用户 sudo su - # 编辑需要 root 权限的文件 sudo vim /etc/nginx/nginx.conf 3.4 用户管理 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 # 创建用户 useradd username # 创建用户并创建主目录 useradd -m username # 设置用户密码 passwd username # 删除用户 userdel username # 删除用户及其主目录 userdel -r username # 查看当前用户 whoami # 查看用户信息 id username # 切换用户 su - username 3.5 用户组管理 1 2 3 4 5 6 7 8 9 10 11 # 创建用户组 groupadd devgroup # 将用户添加到组 usermod -aG devgroup username # 查看用户所属的组 groups username # 删除用户组 groupdel devgroup 四、进程管理 4.1 ps — 查看进程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # 查看当前终端的进程 ps # 查看所有进程（常用） ps aux # 查看所有进程（BSD 格式，更可读） ps -ef # 查找指定进程 ps aux | grep nginx # 按内存使用排序（取前10） ps aux --sort=-%mem | head -10 # 按CPU使用排序（取前10） ps aux --sort=-%cpu | head -10 # 查看进程树 ps -ef --forest 4.2 top / htop — 实时监控 1 2 3 4 5 6 7 8 9 10 # 实时查看进程资源占用 top # 按内存排序（进入 top 后按 M） # 按CPU排序（进入 top 后按 P） # 按进程ID排序（进入 top 后按 N） # 退出（按 q） # htop 是增强版（需安装），支持鼠标和快捷键 htop 4.3 kill — 终止进程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 正常终止进程 kill 12345 # 强制终止进程 kill -9 12345 # 按名称终止进程 killall nginx # 按名称模糊终止 pkill -f \u0026#34;java -jar app\u0026#34; # 查看所有信号 kill -l 常用信号：15 (SIGTERM) 正常终止，9 (SIGKILL) 强制终止，1 (SIGHUP) 重新加载配置。\n4.4 nohup — 后台运行 1 2 3 4 5 6 7 8 9 10 11 # 后台运行，输出到 nohup.out nohup java -jar app.jar \u0026amp; # 指定日志输出文件 nohup java -jar app.jar \u0026gt; app.log 2\u0026gt;\u0026amp;1 \u0026amp; # 查看后台任务 jobs # 将后台任务调到前台 fg %1 4.5 systemctl — 服务管理 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 # 启动服务 sudo systemctl start nginx # 停止服务 sudo systemctl stop nginx # 重启服务 sudo systemctl restart nginx # 重新加载配置（不中断服务） sudo systemctl reload nginx # 查看服务状态 sudo systemctl status nginx # 设置开机自启 sudo systemctl enable nginx # 取消开机自启 sudo systemctl disable nginx # 查看所有已启动的服务 sudo systemctl list-units --type=service --state=running 五、网络 5.1 ip / ifconfig — 查看网络信息 1 2 3 4 5 6 7 8 9 # 查看所有网卡IP地址 ip addr ip a # 查看指定网卡 ip addr show eth0 # 老命令（部分系统仍可用） ifconfig 5.2 ping — 测试网络连通性 1 2 3 4 5 6 7 8 # 测试是否可达 ping 8.8.8.8 # 指定次数（发4个包后停止） ping -c 4 baidu.com # 指定间隔（每2秒发一次） ping -i 2 baidu.com 5.3 curl — HTTP 请求 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 # GET 请求 curl https://example.com # 只显示响应头 curl -I https://example.com # POST 请求（JSON） curl -X POST https://api.example.com/users \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;name\u0026#34;: \u0026#34;test\u0026#34;}\u0026#39; # 下载文件 curl -O https://example.com/file.tar.gz # 指定超时时间（秒） curl --connect-timeout 5 --max-time 10 https://example.com # 显示请求耗时 curl -o /dev/null -s -w \u0026#34;time_total: %{time_total}s\\n\u0026#34; https://example.com # 带认证 curl -u username:password https://api.example.com 5.4 wget — 下载文件 1 2 3 4 5 6 7 8 9 10 11 # 下载文件 wget https://example.com/file.tar.gz # 指定保存文件名 wget -O myfile.tar.gz https://example.com/file.tar.gz # 后台下载 wget -b https://example.com/large-file.zip # 断点续传 wget -c https://example.com/large-file.zip 5.5 netstat / ss — 查看端口与连接 1 2 3 4 5 6 7 8 9 10 11 12 # 查看所有监听端口 ss -tlnp # 查看指定端口占用 ss -tlnp | grep 8080 # 查看所有网络连接 ss -an # 老命令（部分系统仍可用） netstat -tlnp netstat -an | grep 8080 5.6 nslookup / dig — DNS 查询 1 2 3 4 5 6 7 8 # 查询域名解析 nslookup baidu.com # 查询指定DNS服务器 nslookup baidu.com 8.8.8.8 # 详细DNS查询 dig baidu.com 5.7 防火墙（firewalld） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # 查看防火墙状态 sudo firewall-cmd --state # 开放端口 sudo firewall-cmd --add-port=8080/tcp --permanent # 开放服务 sudo firewall-cmd --add-service=http --permanent # 重新加载配置 sudo firewall-cmd --reload # 查看已开放的端口 sudo firewall-cmd --list-ports # 查看所有规则 sudo firewall-cmd --list-all 六、磁盘与内存 6.1 df — 查看磁盘空间 1 2 3 4 5 6 7 8 # 查看所有磁盘使用情况 df -h # 查看指定目录所在磁盘 df -h /home # 显示文件系统类型 df -T 6.2 du — 查看目录大小 1 2 3 4 5 6 7 8 9 10 11 # 查看当前目录总大小 du -sh # 查看各子目录大小 du -sh * # 查看指定目录大小 du -sh /var/log # 按大小排序（找出最大的目录） du -sh * | sort -rh | head -10 6.3 free — 查看内存使用 1 2 3 4 5 6 7 8 # 查看内存使用（人类可读） free -h # 每隔2秒刷新一次 free -h -s 2 # 以MB为单位显示 free -m 6.4 mount / umount — 挂载磁盘 1 2 3 4 5 6 7 8 9 10 11 # 查看已挂载的磁盘 mount # 挂载磁盘到目录 sudo mount /dev/sdb1 /data # 卸载磁盘 sudo umount /data # 查看可用磁盘 lsblk 七、压缩与解压 7.1 tar — 打包与解包 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # 打包并gzip压缩 tar -czf archive.tar.gz folder/ # 打包并gzip压缩（显示过程） tar -czvf archive.tar.gz folder/ # 解压 .tar.gz tar -xzf archive.tar.gz # 解压到指定目录 tar -xzf archive.tar.gz -C /target/ # 解压 .tar.bz2 tar -xjf archive.tar.bz2 # 只查看压缩包内容（不解压） tar -tzf archive.tar.gz # 追加文件到已有包 tar -rf archive.tar newfile.txt 参数记忆：-c 创建，-x 解压，-t 查看，-z gzip，-j bzip2，-v 显示过程，-f 指定文件名。\n7.2 zip / unzip 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 压缩文件 zip archive.zip file1.txt file2.txt # 压缩目录 zip -r archive.zip my-folder/ # 解压 unzip archive.zip # 解压到指定目录 unzip archive.zip -d /target/ # 查看压缩包内容 unzip -l archive.zip 八、系统信息 8.1 系统基础信息 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 # 查看系统版本 cat /etc/os-release # 查看内核版本 uname -r # 查看全部系统信息 uname -a # 查看主机名 hostname # 查看系统运行时间 uptime # 查看CPU信息 lscpu # 或 cat /proc/cpuinfo # 查看内存信息 cat /proc/meminfo 8.2 环境变量 1 2 3 4 5 6 7 8 9 10 11 12 13 # 查看所有环境变量 env # 查看指定环境变量 echo $PATH echo $JAVA_HOME # 设置环境变量（当前会话生效） export MY_VAR=\u0026#34;hello\u0026#34; # 永久生效（写入配置文件） echo \u0026#39;export JAVA_HOME=/usr/local/java/jdk-17\u0026#39; \u0026gt;\u0026gt; ~/.bashrc source ~/.bashrc 九、远程与传输 9.1 ssh — 远程连接 1 2 3 4 5 6 7 8 # 连接远程服务器 ssh user@192.168.1.100 # 指定端口 ssh -p 2222 user@192.168.1.100 # 使用密钥登录 ssh -i ~/.ssh/my-key.pem user@192.168.1.100 9.2 scp — 远程复制 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 上传本地文件到远程 scp file.txt user@192.168.1.100:/home/user/ # 上传目录到远程 scp -r my-folder user@192.168.1.100:/home/user/ # 从远程下载文件 scp user@192.168.1.100:/home/user/file.txt ./ # 从远程下载目录 scp -r user@192.168.1.100:/home/user/my-folder ./ # 指定端口 scp -P 2222 file.txt user@192.168.1.100:/home/user/ 9.3 rsync — 高效同步 1 2 3 4 5 6 7 8 9 10 11 # 同步本地目录到远程 rsync -avz my-folder/ user@192.168.1.100:/home/user/my-folder/ # 同步远程目录到本地 rsync -avz user@192.168.1.100:/home/user/my-folder/ ./my-folder/ # 显示传输进度 rsync -avz --progress my-folder/ user@192.168.1.100:/home/user/my-folder/ # 删除目标端多余的文件（保持完全一致） rsync -avz --delete my-folder/ user@192.168.1.100:/home/user/my-folder/ rsync 只传输差异部分，比 scp 快很多，适合大文件和频繁同步。\n十、管道与重定向 10.1 管道 | 1 2 3 4 5 6 7 8 9 10 11 # 查找进程 ps aux | grep nginx # 统计日志中ERROR出现次数 grep \u0026#34;ERROR\u0026#34; app.log | wc -l # 查找并排序 cat file.txt | sort | uniq -c | sort -rn # 查看占用80端口的进程 ss -tlnp | grep :80 10.2 重定向 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # 输出重定向到文件（覆盖） echo \u0026#34;hello\u0026#34; \u0026gt; file.txt # 输出追加重定向 echo \u0026#34;hello\u0026#34; \u0026gt;\u0026gt; file.txt # 错误输出重定向 command 2\u0026gt; error.log # 标准输出和错误都重定向 command \u0026gt; all.log 2\u0026gt;\u0026amp;1 # 丢弃所有输出 command \u0026gt; /dev/null 2\u0026gt;\u0026amp;1 # 输入重定向 command \u0026lt; input.txt 十一、常用组合命令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # 查找占用指定端口的进程并终止 ss -tlnp | grep :8080 | awk \u0026#39;{print $7}\u0026#39; | cut -d/ -f1 | xargs kill -9 # 统计代码行数（按文件类型） find . -name \u0026#34;*.java\u0026#34; | xargs wc -l | tail -1 # 批量杀死匹配名称的进程 ps aux | grep \u0026#34;java -jar\u0026#34; | grep -v grep | awk \u0026#39;{print $2}\u0026#39; | xargs kill -9 # 实时监控日志中的关键词 tail -f app.log | grep --color \u0026#34;ERROR\u0026#34; # 查找大文件并排序 find / -type f -size +100M -exec ls -lh {} \\; | sort -k5 -rh # 统计 Nginx 访问日志中各 IP 的访问次数（取前10） awk \u0026#39;{print $1}\u0026#39; access.log | sort | uniq -c | sort -rn | head -10 # 批量替换文件内容 sed -i \u0026#39;s/old-domain.com/new-domain.com/g\u0026#39; *.conf ","date":"2024-09-22T10:00:00+08:00","permalink":"/posts/linux/linux-cheatsheet/","title":"Linux 命令速查手册"},{"content":"写在前面 本文整理了日常开发中最常用的 Git 命令，涵盖参数说明与实际示例，方便随时查阅。每个命令都力求实用，避免罗列用不到的冷门参数。\n一、基础操作 1.1 git init — 初始化仓库 1 2 3 4 5 # 在当前目录初始化 git init # 初始化到指定目录 git init my-project 1.2 git clone — 克隆远程仓库 1 2 3 4 5 6 7 8 9 10 11 # 克隆到当前目录下的同名文件夹 git clone https://github.com/user/repo.git # 克隆到指定目录名 git clone https://github.com/user/repo.git my-folder # 克隆指定分支 git clone -b main https://github.com/user/repo.git # 浅克隆（只取最近一次提交，大仓库速度快） git clone --depth=1 https://github.com/user/repo.git 1.3 git add — 添加到暂存区 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 添加指定文件 git add README.md # 添加多个文件 git add file1.md file2.md # 添加当前目录下所有变更 git add . # 添加所有变更（包括已删除的文件） git add -A # 交互式添加（按块选择要暂存的内容） git add -p 1.4 git commit — 提交 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 不带参数会打开编辑器让你写提交信息 git commit # -m 直接指定提交信息（message） git commit -m \u0026#34;feat: 添加用户登录功能\u0026#34; # -a 提交所有已跟踪文件的修改（跳过 git add） git commit -a -m \u0026#34;fix: 修复空指针异常\u0026#34; # --amend 修改上一次提交（未推送到远程时使用） git commit --amend -m \u0026#34;feat: 添加用户登录和注册功能\u0026#34; # --allow-empty 空提交（用于触发 CI 或占位） git commit --allow-empty -m \u0026#34;chore: 触发部署\u0026#34; 1.5 git status — 查看状态 1 2 3 4 5 6 7 8 # 查看工作区和暂存区状态 git status # 简短输出（一行一个文件） git status -s # 显示被忽略的文件 git status --ignored 状态标记说明：\n标记 含义 ?? 未跟踪的新文件 A 新添加到暂存区 M 已修改 D 已删除 R 已重命名 !! 被忽略的文件 左侧为暂存区状态，右侧为工作区状态。例如 MM 表示暂存区和工作区都有修改。\n1.6 git log — 查看提交历史 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 # 查看完整提交历史 git log # 单行显示 git log --oneline # 显示最近 5 条 git log --oneline -5 # 图形化显示分支合并历史 git log --oneline --graph --all # 显示每次提交的文件变更统计 git log --stat # 显示指定文件的提交历史 git log -- path/to/file # 按作者筛选 git log --author=\u0026#34;张三\u0026#34; # 按时间筛选 git log --since=\u0026#34;2026-01-01\u0026#34; --until=\u0026#34;2026-03-31\u0026#34; # 按提交信息搜索 git log --grep=\u0026#34;修复\u0026#34; 二、分支管理 2.1 git branch — 分支操作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # 查看所有本地分支 git branch # 查看所有分支（含远程） git branch -a # 创建新分支（不切换） git branch feature/login # 删除已合并的分支 git branch -d feature/login # 强制删除分支（无论是否合并） git branch -D feature/login # 重命名当前分支 git branch -m new-branch-name # 重命名指定分支 git branch -m old-name new-name 2.2 git checkout / switch — 切换分支 1 2 3 4 5 6 7 8 9 10 11 12 13 # 切换到已有分支 git checkout main # 创建并切换到新分支 git checkout -b feature/login # 切换到上一个分支 git checkout - # Git 2.23+ 推荐用 switch 替代 git switch main git switch -c feature/login # 创建并切换 git switch - # 切换到上一个分支 2.3 git merge — 合并分支 1 2 3 4 5 6 7 8 9 10 11 # 将 feature 分支合并到当前分支 git merge feature/login # 合并时不使用快进（保留分支历史） git merge --no-ff feature/login # 只合并指定文件 git merge feature/login -- path/to/file # 取消正在进行的合并 git merge --abort 2.4 git rebase — 变基 1 2 3 4 5 6 7 8 9 10 11 # 将当前分支变基到 main git rebase main # 交互式变基（修改最近 3 次提交） git rebase -i HEAD~3 # 取消正在进行的变基 git rebase --abort # 变基遇到冲突解决后继续 git rebase --continue merge vs rebase：merge 保留完整分支历史，rebase 产生线性历史更整洁。已推送到远程的分支不要 rebase。\n三、远程协作 3.1 git remote — 远程仓库管理 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 查看远程仓库名 git remote # 查看远程仓库详细信息（URL） git remote -v # 添加远程仓库 git remote add origin https://github.com/user/repo.git # 修改远程仓库地址 git remote set-url origin https://github.com/user/new-repo.git # 删除远程仓库 git remote remove origin 3.2 git fetch — 获取远程更新 1 2 3 4 5 6 7 8 # 获取所有远程分支的更新（不合并） git fetch origin # 获取指定分支 git fetch origin main # 获取所有远程仓库的更新 git fetch --all 3.3 git pull — 拉取并合并 1 2 3 4 5 6 7 8 9 10 11 # 拉取当前分支的远程更新（使用默认远程和分支） git pull # 拉取指定远程分支的更新并合并 git pull origin main # 使用 rebase 方式拉取（避免产生合并提交） git pull --rebase origin main # 拉取所有远程分支 git pull --all 3.4 git push — 推送到远程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # 推送到当前分支的跟踪远程（使用默认配置） git push # 推送当前分支到指定远程分支 git push origin main # 首次推送并建立跟踪关系 git push -u origin feature/login # 推送所有本地分支 git push --all origin # 推送标签 git push origin v1.0.0 # 推送所有标签 git push --tags # 删除远程分支 git push origin --delete feature/login 四、撤销与回退 4.1 git reset — 回退提交 1 2 3 4 5 6 7 8 9 10 11 12 # 回退到上一个提交（默认 --mixed 模式） git reset HEAD~1 # 软回退：保留修改在暂存区（撤销 commit，不撤销 add） git reset --soft HEAD~1 git reset --mixed HEAD~1 # 硬回退：丢弃所有修改（谨慎使用） git reset --hard HEAD~1 # 回退到指定提交 git reset --soft abc1234 HEAD~1 表示上一个提交，HEAD~3 表示往上三个提交。\n4.2 git revert — 撤销提交（生成新提交） 1 2 3 4 5 6 7 8 9 # 撤销指定提交（会生成一个新的撤销提交） git revert abc1234 # 撤销最近一次提交 git revert HEAD # 撤销多个提交（不自动提交，最后一起提交） git revert --no-commit HEAD~3..HEAD git commit -m \u0026#34;revert: 撤销最近三次提交\u0026#34; reset vs revert：reset 是\u0026quot;回退历史\u0026quot;，适合未推送的提交；revert 是\u0026quot;新增一个撤销提交\u0026quot;，适合已推送的提交，不影响他人。\n4.3 git checkout — 恢复文件 1 2 3 4 5 6 7 8 9 10 11 # 恢复工作区指定文件（丢弃未暂存的修改） git checkout -- file.md # Git 2.23+ 推荐用 restore 替代 git restore file.md # 从暂存区恢复文件到工作区 git restore --staged file.md # 恢复某个文件到指定提交的版本 git restore --source=abc1234 file.md 4.4 git stash — 暂存工作区 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 # 暂存当前修改 git stash # 暂存时附加说明 git stash save \u0026#34;开发到一半的功能\u0026#34; # 暂存时包含未跟踪的文件 git stash -u # 查看暂存列表 git stash list # 恢复最近一次暂存 git stash pop # 恢复指定暂存（不删除记录） git stash apply stash@{1} # 删除指定暂存 git stash drop stash@{0} # 清空所有暂存 git stash clear 五、标签与发布 5.1 git tag — 标签管理 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # 查看所有标签 git tag # 创建轻量标签 git tag v1.0.0 # 创建附注标签（推荐，包含作者、日期、说明） git tag -a v1.0.0 -m \u0026#34;正式版本 1.0.0\u0026#34; # 给指定提交打标签 git tag -a v0.9.0 abc1234 -m \u0026#34;补打标签\u0026#34; # 查看标签详情 git show v1.0.0 # 删除本地标签 git tag -d v1.0.0 # 删除远程标签 git push origin --delete v1.0.0 六、查看与搜索 6.1 git diff — 查看差异 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # 查看工作区与暂存区的差异 git diff # 查看暂存区与最新提交的差异 git diff --staged # 查看两个提交之间的差异 git diff abc1234 def5678 # 查看指定文件的差异 git diff -- path/to/file # 只显示变更的文件名 git diff --name-only # 显示差异统计（增删行数） git diff --stat 6.2 git blame — 查看文件每行的修改者 1 2 3 4 5 6 7 8 # 查看文件每行的最后修改者和提交 git blame README.md # 指定行范围（第 10 到 30 行） git blame -L 10,30 README.md # 只显示邮箱 git blame -e README.md 6.3 git grep — 在仓库中搜索 1 2 3 4 5 6 7 8 9 10 11 # 在所有已跟踪文件中搜索关键词 git grep \u0026#34;TODO\u0026#34; # 显示行号 git grep -n \u0026#34;TODO\u0026#34; # 只显示文件名 git grep -l \u0026#34;TODO\u0026#34; # 在指定提交中搜索 git grep \u0026#34;TODO\u0026#34; abc1234 七、实用技巧 7.1 git cherry-pick — 摘取提交 1 2 3 4 5 6 7 8 # 将指定提交应用到当前分支 git cherry-pick abc1234 # 摘取多个提交 git cherry-pick abc1234 def5678 # 摘取提交但不自动提交（方便修改后再提交） git cherry-pick -n abc1234 7.2 git bisect — 二分查找问题提交 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 开始二分查找 git bisect start # 标记当前提交有问题 git bisect bad # 标记某个正常的提交 git bisect good v1.0.0 # Git 会自动切换到中间提交，测试后标记 git bisect good # 或 git bisect bad # 找到问题提交后结束查找 git bisect reset 7.3 git reflog — 查看所有操作记录 1 2 3 4 5 6 7 8 # 查看所有操作记录（包括已删除的提交和分支切换） git reflog # 查看指定分支的操作记录 git reflog show feature/login # 恢复误删的提交 git checkout abc1234 reflog 记录了 HEAD 的每一次移动，是误操作的救命稻草。\n7.4 git worktree — 多分支同时工作 1 2 3 4 5 6 7 8 # 创建工作树（在同一仓库下同时工作在多个分支） git worktree add ../hotfix-branch hotfix/urgent # 查看所有工作树 git worktree list # 删除工作树 git worktree remove ../hotfix-branch 7.5 常用配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 # 设置用户名和邮箱 git config --global user.name \u0026#34;张三\u0026#34; git config --global user.email \u0026#34;zhangsan@example.com\u0026#34; # 设置默认分支名为 main git config --global init.defaultBranch main # 设置默认编辑器 git config --global core.editor \u0026#34;code --wait\u0026#34; # 设置别名 git config --global alias.st status git config --global alias.co checkout git config --global alias.br branch git config --global alias.ci commit git config --global alias.lg \u0026#34;log --oneline --graph --all\u0026#34; # 查看所有配置 git config --list # 存储凭据（避免每次输入密码） git config --global credential.helper manager 八、提交信息规范 推荐使用 Conventional Commits 格式：\n1 2 3 \u0026lt;type\u0026gt;(\u0026lt;scope\u0026gt;): \u0026lt;subject\u0026gt; \u0026lt;body\u0026gt; 常用 type：\n类型 说明 feat 新功能 fix 修复 Bug docs 文档变更 style 代码格式（不影响逻辑） refactor 重构（不是新功能也不是修复） perf 性能优化 test 增加或修改测试 chore 构建工具或辅助工具变更 ci CI/CD 配置变更 示例：\n1 2 3 git commit -m \u0026#34;feat(auth): 添加 OAuth2 第三方登录\u0026#34; git commit -m \u0026#34;fix(api): 修复分页查询越界问题\u0026#34; git commit -m \u0026#34;docs: 更新 API 接口文档\u0026#34; ","date":"2024-09-14T10:00:00+08:00","permalink":"/posts/devtools/git-cheatsheet/","title":"Git 命令速查手册"},{"content":"写在前面 每次写完博客，都要 SSH 登服务器 git pull + hugo 重新构建——写了三五十篇之后，这事就变得很烦。本文记录我用 GitHub Actions + SSH 搭建自动部署的完整过程：push 到 GitHub，服务器自动拉代码、重新构建，全程零手动。\n这套方案适合自己有 VPS、用 Nginx 托管静态站点的同学。读完能直接照着配。\n一、为什么要自动部署 1.1 手动部署的痛点 1 2 3 4 5 6 7 写完文章 → 本地预览 → git push → SSH 登服务器 → cd 目录 → git pull → hugo 构建 → 完成 每篇文章都要重复后三步，而且： ✗ 容易忘（push 了但没部署，线上还是旧的） ✗ 多设备时混乱（公司 push 了，家里没同步） ✗ 服务器上手动操作易出错（命令敲错、忘记更新主题） ✗ 出门没电脑就改不了博客 1.2 自动化之后 1 2 3 4 5 6 写完文章 → git push → 自动部署 → 线上更新 ✓ push 即部署，所见即所得 ✓ 任何设备、任何地方都能更新 ✓ 不用记服务器命令 ✓ 主题更新、配置改动都自动同步 二、部署方案对比 Hugo 静态站点的自动部署有几种主流方案：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 方案 原理 优点 缺点 ────────────────────────────────────────────────────────────────────────────── 1. Actions SSH GitHub 通过 SSH 登服务器 复用现有流程 依赖服务器环境 执行 执行 git pull + hugo 服务器改动小 （git/hugo） 配置简单 2. Actions 构建 GitHub 跑 Hugo，rsync 推 构建不依赖服务器 要配 rsync + + rsync 静态文件到服务器 环境，更标准 nginx 目录 3. 服务器 cron 服务器定时 git pull + hugo 零 GitHub 配置 有几分钟延迟 最简单 4. 托管平台 Cloudflare Pages / Vercel 免服务器、全球 要改 DNS、 托管 / Netlify CDN、免费 可能不符现有架构 选型建议 1 2 3 4 有自己的 VPS + 现在手动 SSH 部署 → 方案1（本文） 有自己的 VPS，想让构建脱离服务器 → 方案2 不想碰 GitHub Actions → 方案3 没有服务器 / 想要免费 CDN → 方案4 本文用方案1：最贴合\u0026quot;已经在手动 SSH 部署\u0026quot;的现状，服务器几乎零改动，迁移成本最低。\n三、方案1 的原理 很多人对 GitHub Actions 的 runs-on 有误解，先讲清楚。\n3.1 整体流程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 你 push 代码到 GitHub │ ▼ ┌───────────────────┐ │ GitHub 虚拟机 │ ← runs-on: ubuntu-latest │ (GitHub 免费提供) │ 这只是个\u0026#34;跑腿的\u0026#34;临时环境 │ │ │ 唯一的动作： │ │ 通过 SSH 连你的服务器│ └─────────┬─────────┘ │ SSH ▼ ┌───────────────────┐ │ 你的 VPS (Debian等) │ ← 真正部署发生的地方 │ │ │ cd 仓库目录 │ │ git pull │ │ hugo 构建 │ │ → nginx 目录更新 │ └───────────────────┘ 3.2 runs-on 不是你的服务器 1 2 3 4 5 6 7 8 9 10 11 12 13 runs-on: ubuntu-latest 这指定的是 GitHub Actions 的\u0026#34;执行环境\u0026#34;—— GitHub 免费给你的一台临时 Ubuntu 虚拟机。 它和你的服务器系统（Debian/CentOS 都行）无关。 这台虚拟机只负责一件事：发起 SSH 连接到你的服务器。 任务完成即销毁。 打个比方： runs-on 的 Ubuntu = 快递员（跑腿的） 你的服务器 = 收件人（真正干活的地方） 快递员是谁不重要，能把包裹（SSH 命令）送到就行 理解这点很关键：部署逻辑跑在你的服务器上，GitHub 虚拟机只是触发器。\n四、前置准备 4.1 服务器要求 1 2 3 4 ✓ 一台 VPS / 云服务器，有 SSH 和 root（或 sudo）权限 ✓ 已安装 git 和 hugo（手动部署过的话肯定有） ✓ Nginx（或其他 Web 服务器）已配置好，能托管静态文件 ✓ 仓库已 git clone 到服务器（手动部署过的话肯定有） 4.2 生成专用部署密钥 关键：给 GitHub Actions 单独生成一对密钥，不要和你日常 SSH 的 key 混用。这样更安全（最小权限、可随时撤销）。\nSSH 登服务器执行：\n1 2 3 4 5 6 7 8 9 10 11 12 # 1. 生成密钥对（ed25519 更安全更快，无密码） ssh-keygen -t ed25519 -f ~/.ssh/github_actions -N \u0026#34;\u0026#34; -C \u0026#34;github-actions-deploy\u0026#34; # 生成两个文件： # ~/.ssh/github_actions 私钥（给 GitHub） # ~/.ssh/github_actions.pub 公钥（留在服务器） # 2. 把公钥加入授权列表（让持有私钥的 GitHub 能登录） cat ~/.ssh/github_actions.pub \u0026gt;\u0026gt; ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys # 3. 查看并复制私钥内容（下一步要填到 GitHub） cat ~/.ssh/github_actions 1 2 3 4 5 6 复制私钥时要注意： ✓ 完整复制，包含 -----BEGIN/END OPENSSH PRIVATE KEY----- 两行 ✓ 包含所有换行 ✗ 不要漏掉任何字符 这把私钥只填到 GitHub Secrets，绝不放进代码仓库 五、配置 GitHub Secrets Secrets 是 GitHub 加密存储的变量，workflow 里通过 ${{ secrets.XXX }} 引用，不会出现在日志和代码里。\n打开仓库 → Settings → Secrets and variables → Actions → New repository secret，添加 3 个：\nSecret 名 值 说明 SERVER_HOST 服务器 IP 或域名 如 1.2.3.4 或 jiwei.space SERVER_USER SSH 用户名 如 root SERVER_SSH_KEY 第四步复制的私钥 完整内容 1 2 3 如果你的 SSH 端口不是 22： 额外加一个 SERVER_PORT（如 2222） workflow 里把 port 那行的注释去掉 六、编写 workflow 在仓库根目录创建 .github/workflows/deploy.yml：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 # Hugo 自动部署：push 到 main 后，通过 SSH 登服务器执行 git pull + hugo name: Deploy on: push: branches: [main] # push 到 main 时触发 workflow_dispatch: # 也允许在 Actions 页面手动触发 jobs: deploy: runs-on: ubuntu-latest # GitHub 提供的执行环境（不是你的服务器） steps: - name: SSH 登录服务器并部署 uses: appleboy/ssh-action@v1 # 成熟的 SSH Action with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SERVER_SSH_KEY }} # 端口非 22 时，取消下行注释并配置 SERVER_PORT # port: ${{ secrets.SERVER_PORT }} script: | set -e cd /var/www/blog/site # ← 改成你服务器上仓库的路径 git pull --ff-only # git submodule update --init --recursive # 更新主题时取消注释 hugo --minify --cleanDestinationDir -d /var/www/blog/public echo \u0026#34;✅ 部署完成 $(date \u0026#39;+%F %T\u0026#39;)\u0026#34; 6.1 逐段讲解 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 on: push: branches: [main] # 监听 main 分支的 push workflow_dispatch: # 加这个能在 Actions 页面点按钮手动跑 → 每次 push 文章/配置，自动触发；也能手动重跑 uses: appleboy/ssh-action@v1 → 社区维护的 SSH Action，稳定好用，不用自己写 SSH 逻辑 set -e → 任何命令失败立即中止（避免 pull 失败还继续 hugo） cd /var/www/blog/site → 进到服务器上的仓库目录（手动部署时你 cd 的那个） git pull --ff-only → 拉最新代码，--ff-only 禁止产生 merge commit（服务器不该有本地改动） hugo --minify --cleanDestinationDir -d /var/www/blog/public → 三个参数的作用见下节 6.2 hugo 命令的三个关键参数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 -d /var/www/blog/public 指定输出目录（-d = destination） 直接输出到 nginx 的 web 根目录，省去 cp/rsync 步骤 --minify 压缩 HTML/CSS/JS（去注释、去空白） 省带宽、提升加载速度、对 SEO 友好 生产环境建议加上 --cleanDestinationDir 构建前清理目标目录里不在生成的文件 ⚠️ 重要：如果不加，删了某篇文章后旧的 HTML 还会残留 访问者可能撞到已删除的页面 前提：目标目录只放 Hugo 输出（别混别的文件） 6.3 主题是 submodule 的话 1 2 3 4 5 6 7 8 如果主题用 git submodule 管理（很多 Hugo 主题推荐这么做）， git pull 不会自动同步 themes/ 目录。 哪天你更新了主题（git submodule update --remote themes/stack）， 记得把这行的注释取消： git submodule update --init --recursive 不更新主题就不用管，保持注释即可。 七、触发与验证 7.1 提交触发 1 2 3 git add .github/workflows/deploy.yml git commit -m \u0026#34;ci: 添加自动部署\u0026#34; git push 这次 push 本身就会触发首次部署（因为 workflow 监听 push）。\n7.2 查看执行结果 打开仓库的 Actions 标签页（github.com/\u0026lt;用户\u0026gt;/\u0026lt;仓库\u0026gt;/actions）：\n1 2 3 🟢 绿色 ✓ → 成功！以后每次 push 自动部署 🔴 红色 ✗ → 失败，点进去看哪一步报错 🟡 黄色 → 正在运行 点开失败的 run，能看到每一步的日志，定位问题。\n7.3 手动触发 配置了 workflow_dispatch 后，在 Actions 页面：\n左侧选 Deploy workflow 右侧点 Run workflow → 选 main 分支 → Run 适合改了 secrets 或服务器配置后，不 push 代码也能重新部署。\n八、常见问题 8.1 hugo: command not found 最常见的坑。 SSH 的 non-login shell 环境变量不全，可能找不到 hugo（尤其 snap 安装的）。\n1 2 3 # 在服务器上查 hugo 的绝对路径 which hugo # 可能输出：/usr/local/bin/hugo 或 /snap/bin/hugo 然后把 workflow 里的 hugo 改成绝对路径：\n1 2 3 4 5 script: | set -e cd /var/www/blog/site git pull --ff-only /usr/local/bin/hugo --minify --cleanDestinationDir -d /var/www/blog/public 8.2 git pull 失败 1 2 3 4 5 6 7 8 9 10 11 12 原因1：路径不对 cd 的目录不是 git 仓库（没有 .git） → 确认服务器上仓库的真实路径 原因2：服务器仓库有本地改动 --ff-only 会拒绝产生 merge → 服务器上别手动改文件，所有改动走 git push 原因3：仓库是 private 服务器 git pull 需要认证 → 给服务器配 deploy key，或用 https + token （public 仓库无此问题） 8.3 SSH 连接失败 1 2 3 4 5 6 7 8 9 10 Permission denied (publickey) → 私钥没配对，检查 SERVER_SSH_KEY 内容是否完整 → 公钥是否加到 authorized_keys Connection timeout → 防火墙/安全组没放行 SSH 端口 → 或 SERVER_HOST 填错 端口非 22 → 加 SERVER_PORT secret，workflow 取消 port 行注释 8.4 部署成功但页面没更新 1 2 3 4 5 6 7 8 可能1：浏览器缓存 → 强制刷新（Ctrl+F5）或无痕模式 可能2：CDN 缓存 → 清 CDN 缓存，或等缓存过期 可能3：hugo 构建输出了，但 nginx root 指向的目录不对 → 确认 -d 的目录 = nginx server 块的 root 九、安全注意事项 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 1. 用专用密钥，不共用 给 GitHub Actions 单独一对密钥 和你日常 SSH 的 key 分开 万一泄露，只影响部署，且能单独撤销 2. 私钥只进 Secrets，绝不进代码 .gitignore 不用管（Secrets 根本不在仓库里） 别手滑把私钥贴到代码或日志 3. 最小权限（进阶） 本文用了 root，简单但权限过大 更安全的做法：建专用 deploy 用户，只给仓库和 nginx 目录的权限 不能 sudo，限制 SSH 只能执行特定命令 4. 密钥可随时撤销 怀疑泄露 → 服务器删掉 authorized_keys 里那行公钥即可 重新生成一对，更新 GitHub Secret 5. 限制来源 IP（进阶） 服务器防火墙只允许 GitHub IP 段 SSH（但 GitHub IP 会变） 或用部署密钥 + 强制命令 十、进阶优化（可选） 跑通基础版后，可以按需增强：\n10.1 部署失败通知 GitHub Actions 默认会在失败时发邮件给仓库 owner。想更及时：\n1 2 3 4 5 6 7 8 9 10 11 jobs: deploy: steps: # ... 部署步骤 ... - name: 失败通知 if: failure() run: | # 调用钉钉/飞书/企业微信 webhook curl -X POST \u0026#34;https://oapi.dingtalk.com/robot/send?access_token=XXX\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;msgtype\u0026#34;:\u0026#34;text\u0026#34;,\u0026#34;text\u0026#34;:{\u0026#34;content\u0026#34;:\u0026#34;博客部署失败！\u0026#34;}}\u0026#39; 10.2 多环境部署 1 2 3 4 main 分支 → 生产服务器 dev 分支 → 测试服务器 用分支触发不同 job，或用 environment 区分 secrets 10.3 加上构建产物校验 1 2 3 4 5 6 7 8 script: | set -e cd /var/www/blog/site git pull --ff-only hugo --minify --cleanDestinationDir -d /var/www/blog/public # 校验关键页面是否生成 test -f /var/www/blog/public/index.html || { echo \u0026#34;构建失败\u0026#34;; exit 1; } echo \u0026#34;✅ 部署完成 $(date \u0026#39;+%F %T\u0026#39;)\u0026#34; 十一、小结 本文记录了用 GitHub Actions + SSH 自动部署 Hugo 博客的完整流程：\n方案选型：对比 4 种方案，方案1（Actions SSH 执行）最贴合手动 SSH 部署的现状 原理：runs-on 是 GitHub 的跑腿虚拟机，真正的部署在你的服务器上发生 密钥：生成专用 ed25519 密钥对，公钥留服务器，私钥进 GitHub Secrets Secrets：SERVER_HOST / SERVER_USER / SERVER_SSH_KEY（端口非 22 加 SERVER_PORT） workflow：用 appleboy/ssh-action，执行 git pull + hugo，三个关键参数（-d / \u0026ndash;minify / \u0026ndash;cleanDestinationDir） 触发：push 自动触发 + workflow_dispatch 手动触发 排坑：hugo PATH（最常见）、git pull 失败、SSH 连接、页面缓存 安全：专用密钥、最小权限、可撤销 配好之后，写博客的流程简化为：\n1 2 3 hugo new posts/xxx.md # 写文章 hugo server # 本地预览 git add \u0026amp;\u0026amp; git commit \u0026amp;\u0026amp; git push # 发布，自动部署 从此告别 SSH 手动操作，专注写内容。这正是静态博客 + CI/CD 的理想工作流。\n","date":"2024-09-06T10:00:00+08:00","permalink":"/posts/sitebuilding/github-actions-deploy/","title":"Hugo 博客自动部署实战：GitHub Actions + SSH"},{"content":"创建新网站 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 创建新项目（推荐） hugo new project site-name # 旧写法（仍然可用） hugo new site site-name # 强制创建到非空目录 hugo new project site-name --force hugo new project site-name -f # 指定配置格式 hugo new project site-name --format yaml hugo new project site-name --format json hugo new project site-name --format toml 创建内容 1 2 3 4 5 6 7 8 # 使用 archetype 创建新文章 hugo new posts/my-article.md # 创建到子目录 hugo new posts/category/my-article.md # 指定内容类型 hugo new --kind term posts/my-article.md 开发服务器 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 # 启动开发服务器（默认 http://localhost:1313） hugo server # 包含草稿文章 hugo server --buildDrafts hugo server -D # 指定端口 hugo server --port 8080 hugo server -p 8080 # 禁用 Fast Render（完整重建） hugo server --disableFastRender # 绑定到所有网络接口（局域网访问） hugo server --bind 0.0.0.0 # 查看草稿和未来日期的文章 hugo server --buildDrafts --buildFuture hugo server -D -F # 监听文件变化 hugo server --watch hugo server -w 构建网站 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 # 构建到 public/ 目录 hugo # 构建包含草稿 hugo --buildDrafts hugo -D # 清理构建缓存后构建 hugo --cleanDestinationDir # 指定构建输出目录 hugo --destination /path/to/output hugo -d /path/to/output # 指定配置文件 hugo --config my-config.toml hugo -c my-config.toml # 指定主题 hugo --theme my-theme hugo -t my-theme # 指定 baseURL hugo --baseURL https://example.com/ hugo -b https://example.com/ 配置与查询 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 查看所有配置 hugo config # 列出所有页面 hugo list all # 列出所有草稿 hugo list drafts # 列出所有过期页面 hugo list expired # 列出所有未来发布页面 hugo list future 内容管理 1 2 3 4 5 6 7 8 # 删除构建缓存和生成的文件 hugo --cleanDestinationDir # 删除资源缓存 rm -rf resources/ # 删除 public 目录 rm -rf public/ 实用选项 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 # 显示详细输出 hugo --verbose hugo -v # 显示调试信息 hugo --debug # 安静模式（减少输出） hugo --quiet hugo -q # 显示版本信息 hugo version # 显示帮助 hugo help # 指定内容目录 hugo --contentDir /path/to/content hugo -c /path/to/content # 指定源目录 hugo --source /path/to/source hugo -s /path/to/source # 指定布局目录 hugo --layoutDir /path/to/layouts hugo -l /path/to/layouts # 指定环境 hugo --environment production hugo -e production 快捷操作 1 2 3 4 5 6 7 8 # 清理并重新启动开发服务器 rm -rf public resources \u0026amp;\u0026amp; hugo server --disableFastRender # 创建并启动编辑 hugo new posts/article.md \u0026amp;\u0026amp; code content/posts/article.md # 构建并测试 hugo \u0026amp;\u0026amp; hugo server 常见问题 1 2 3 4 5 6 7 8 # 查看所有页面及其状态 hugo list all # 检查页面是否被构建 hugo server | grep \u0026#34;Total in\u0026#34; # 查看特定页面信息 hugo list all | grep \u0026#34;页面名称\u0026#34; 常用简写对照 简写 全称 -D --buildDrafts -F --buildFuture -p --port -d --destination -t --theme -c --config 或 --contentDir -v --verbose -q --quiet -w --watch -b --baseURL -s --source -l --layoutDir -e --environment ","date":"2024-09-02T10:00:00+08:00","permalink":"/posts/sitebuilding/hugo-cheatsheet/","title":"Hugo 命令速查手册"}]