# Go 与 tRPC-Agent-Go 中文参考手册

按主题查阅。代码标注“完整程序”时可单独运行；集成片段需要所在小节列出的依赖和前置类型。交互演示在网页对应正文内。

## tRPC-Agent-Go

### 完整运行流程 · 3D

从应用接入、会话、模型与工具循环，到事件输出和收尾。

#### 这张运行图如何对应代码

先在顶部选择场景，再点击播放或逐步推进。三层平台分别是应用接入、Agent 运行和模型/数据服务；发光的有向路径表示本步骤的数据传递。点击三维组件或正文里的组件按钮可以聚焦并查看职责。

右侧/下方同步显示代码、参数、模型轮次、工具执行次数、回答文本和框架事件。事件不是最终答案：工具响应和运行状态不会被直接拼进用户回答。保存节点计数是教学路径中展示的次数，不代表 AppendEvent 的真实调用总数。

#### 完整流程与可选组件

默认订单查询展示启动组装、认证、Runner.Run、Session 读取、用户消息保存、LLMAgent 请求、工具参数产生、业务授权、后端查询、工具结果回填、第二轮模型输出、完整事件保存、runner.completion 和清理。Graph/RAG 场景单独展示可选的检索和条件路由；它们不是每个 Agent 请求都必须经过的节点。

组件的三维位置表示职责关系，不表示每个组件都要拆成微服务。流程按固定 v1.11.2 的公共 API 与关键源码路径整理，属于确定性教学示意，并未调用真实模型或数据库。

#### 错误、取消与恢复观察

认证拒绝发生在应用入口，因此模型与工具的执行数为零；模型错误即使后面出现 runner.completion，也不能把先前失败覆盖成成功。取消后可能保留部分答案，最终事件对断连客户端只尽力交付，不保证收到。

上一/下一步和时间轴使用完整状态快照，所以倒退、重置或切换场景不会遗留上一场景的回答与事件。浏览器页签隐藏或离开演示时停止播放；减少动态效果设置会停止粒子运动和镜头补间。

注意：模型提出工具调用，应用负责业务授权。可选 Graph、Knowledge、SSE 映射及模拟场景中的策略，不应当成框架自动保证的行为。

参考：tRPC-Agent-Go v1.11.2 Runner 源码 — https://github.com/trpc-group/trpc-agent-go/blob/v1.11.2/runner/runner.go

参考：Function Tool 参数解析 — https://github.com/trpc-group/trpc-agent-go/blob/v1.11.2/tool/function/function_tool.go

参考：事件与响应 — https://github.com/trpc-group/trpc-agent-go/tree/v1.11.2/model

---

### 框架总览

Go-native Agent runtime、工具、Session、Graph、Knowledge、事件与可观测性。

#### 组件职责

Runner 组织一次运行，Agent 描述行为，Model 适配供应商，Tool 执行真实能力，Session 管理对话边界，Event 把过程交给外层。

#### 一次运行

输入经过认证和 session 绑定后进入 Runner；模型可能产生文本、工具调用和错误事件，最终由应用层决定展示和持久化。

#### 最小架构

Transport → Application Service → Runner/Agent → Model/Tool/Session；不要让 HTTP 或数据库细节渗入 Agent 定义。

#### 选型边界

固定流程用 Graph，动态工具选择用 LLMAgent，多 Agent 只有在边界和责任清晰时才拆分。

#### 参考片段

```go
ag := llmagent.New("assistant", llmagent.WithModel(modelInstance))
r := runner.NewRunner("assistant", ag)
defer r.Close()
```

#### 把 Agent 看成有停止条件的循环

应用发出用户问题，模型返回文本或工具请求。若请求工具，程序执行并把结果补进上下文，再调用模型。模型基于结果给出回答或继续请求工具。

工具结果必须关联原调用 ID。仅把查询结果随意塞入普通用户消息，会失去结构化关系。多个工具并行时尤其需要保留对应关系。

#### 观察可见事件，而非假装读心

实验提供“查询成功”“工具失败”“达到预算”三条路径。动画显示发送了什么、哪个组件在运行、收到了什么结果。

错误并不意味着必须自动重试。只读查询可以设计有限重试；下单或转账等有副作用工具还需要幂等键和业务状态确认，避免重复执行。


```go
// 概念伪代码，不是 tRPC-Agent-Go API
for step := 0; step < maxSteps; step++ {
    response := callModel(messages, tools)
    if response.HasFinalAnswer() { return response.Text }
    results := validateAndExecute(response.ToolCalls)
    messages = append(messages, response, results)
}
return ErrBudgetExceeded
```

#### 给整个请求与每个工具设边界

总超时控制一次运行最多持续多久；工具超时避免单次调用卡死；循环预算限制模型反复请求工具；输出 token 预算控制生成规模。它们解决的是不同问题。

框架可以帮助组织执行，但不会替你决定用户有哪些权限、失败能否重试、何时必须人工确认。先把这些业务规则写清楚。

交互演示：网页的「框架总览」正文可操作。

注意：不要把“模型能回答”当成系统具备权限、幂等和可恢复能力。

---

### 安装与快速开始

Go 1.21+、模块依赖、模型环境变量和第一个 Runner。

#### 版本固定

在 go.mod 固定 trpc.group/trpc-go/trpc-agent-go 版本，升级时同时检查 model、runner、function 和 graph API。

#### 环境配置

MODEL_NAME、API key、Base URL 从环境或密钥管理注入；启动阶段校验，不要写进代码。

#### 最小运行

先运行固定输入和本地 Function Tool，再接真实模型；先验证事件消费和错误路径。

#### 构建检查

go test、race、vet 和 build 一起跑；将模型调用测试与不联网的编排测试分开。

#### 参考片段

```bash
go get trpc.group/trpc-go/trpc-agent-go@v1.11.2
go test ./...
OPENAI_API_KEY=... MODEL_NAME=... go run .
```

注意：不要直接复制旧版本示例，也不要用真实密钥作为单元测试前置条件。

---

### 模型接入

OpenAI-compatible、DeepSeek、Qwen 等变体与流式配置。

#### 模型接口

模型适配器负责消息、流式响应、工具调用和 provider 差异；Agent 只依赖统一 Model 能力。

#### 生成配置

Stream、temperature、max tokens 和推理参数要按 provider 能力配置；默认值写在配置层。

#### 错误分类

认证、参数、限流、超时、供应商故障和取消分别处理；有限重试要绑定总 deadline。

#### 验证方式

固定模型事件验证 Runner；真实模型只做少量集成和能力探测，不用全文相等作为回归标准。

#### 参考片段

```go
modelInstance := openai.New(os.Getenv("MODEL_NAME"))
agent := llmagent.New("assistant", llmagent.WithModel(modelInstance), llmagent.WithGenerationConfig(model.GenerationConfig{Stream:true}))
```

#### 模型看到的是本次请求的上下文

应用把消息、工具说明和必要资料组成请求。模型在这些输入基础上生成输出；它不会自动读取你的数据库，也不会凭空记住上次服务重启前的消息。

token 是模型处理文本等内容的单位，不等于汉字数。上下文窗口和输出长度都有限制。历史增长会增加成本，因此需要选择、截断或摘要，但摘要可能丢失信息。

#### 工具调用是结构化请求

模型可返回工具名称、调用 ID 和参数，例如查询某订单。Go 程序查找注册的工具、校验参数与权限、执行，再把结果作为关联的工具消息交回模型。

工具说明影响模型何时选它；schema 约束输入形状；真正的业务验证和授权必须由代码负责。模型声称“已操作成功”不能代替真实的工具执行结果。


```json
{
  "id": "call_01",
  "name": "lookup_order",
  "arguments": {"order_id": "A1024"}
}
```

#### 从一次调用到一个闭环

普通模型调用可以只做一次生成；Agent 会根据输出决定是否调用工具、继续检索或结束。循环需要明确停止条件、超时和步数预算。

浏览器里的演示是确定性的教学模拟，不会发送真实模型请求，也不会尝试推断或展示模型的私有思考过程。屏幕上的状态是程序可观察的事件。

注意：兼容 OpenAI 协议不代表工具调用和推理字段完全一致。

---

### Runner

用户、Session、消息、事件 channel 和生命周期。

#### Run 参数

Runner.Run 绑定 context、userID、sessionID 和消息；userID/sessionID 不能由模型或客户端任意替换。

#### 事件消费

启动 error 只说明 channel 未建立；channel 内还要检查 Response.Error、工具事件、完成状态和取消。

#### 生命周期

请求取消时停止读取和下游调用；服务退出时关闭 runner 使用的客户端、session store 和 exporter。

#### HTTP 适配

Transport 把框架事件转换成 answer.delta、tool.start、tool.result、run.error、run.done 等稳定协议。

#### 运行时会看到什么

实际运行时会先得到事件 channel；模型输出可能分多个 chunk，工具调用和错误不一定带 Delta.Content。

#### 参考片段

```go
events, err := r.Run(ctx, userID, sessionID, message)
if err != nil { return err }
for ev := range events { if err := consume(ev); err != nil { return err } }
```

#### 每个组件解决一个问题

Runner 是一次运行的入口，接收用户、会话和消息，组织执行并向外输出事件。Agent 定义要如何完成任务；LLMAgent 是围绕模型交互的一种 Agent 实现。

Model 适配模型供应商；Tool 封装程序可执行的能力；Session 保存会话相关状态与事件；Event 把运行过程交给调用方。它们有协作关系，但不是六个必须独立部署的微服务。

#### 用组合构建你的助手

先创建模型与工具，再创建 LLMAgent，最后交给 Runner。HTTP 框架可以在外层负责路由、鉴权和 SSE，不需要把 tRPC-Go 微服务框架作为这个组合的前置要求。

以下是组合片段，完整可运行版本在 examples/agent/main.go。固定依赖 v1.11.2；模型 ID 与地址通过环境变量配置，避免把过时模型名称写死在代码里。


```go
agent := llmagent.New("study-assistant",
    llmagent.WithModel(modelInstance),
    llmagent.WithTools([]tool.Tool{addTool}),
    llmagent.WithGenerationConfig(model.GenerationConfig{Stream: true}),
)
r := runner.NewRunner("study-app", agent)
defer r.Close()

events, err := r.Run(ctx, "user-1", "session-1",
    model.NewUserMessage("请用工具计算 2 + 3"))
```

#### 先跑通，再拆组件

先阅读 main.go 中的工具函数，再阅读创建 Model / Agent / Runner 的部分，最后看事件循环。把这个顺序和实验中的数据流对照。

框架演示是对公共组件关系的概念化表达，不代表内部调用栈的每一个函数，也不代表每一步恰好只发出一个事件。生产代码应以固定版本的源码和文档为准。

交互演示：网页的「Runner」正文可操作。

注意：不要只检查 Run 返回的 error，也不要把事件 channel 当成无限后台队列。

---

### Function Tool

请求 schema、描述、参数解析、业务验证和错误。

#### 工具结构

Function Tool 包含名称、描述、schema、参数解析、业务校验和执行函数；模型只提出调用，程序决定是否执行。

#### 权限与副作用

租户、用户和审批信息来自认证上下文；写操作需要幂等键、状态确认和必要的人审。

#### 错误协议

参数错误、无权限、无数据、下游超时和系统故障分别编码，让 Agent 决定澄清、重试或停止。

#### 测试边界

用固定输入测试 schema 和权限；用 fake 下游测试成功、取消、重复调用和部分失败。

#### 示例的类型与导入

上方是集成片段：searchOrders 需要实现 func(context.Context, Input) (Output, error)，并定义 Input/Output；modelInstance 是已初始化的 Model。tool 来自 trpc.group/trpc-go/trpc-agent-go/tool。WithTools 接收 []tool.Tool 切片，不能直接传单个 Tool。examples/agent/main.go 提供包含导入、输入输出结构和 Runner 的完整程序。

#### 参考片段

```go
t := function.NewFunctionTool(searchOrders,
    function.WithName("search_orders"),
    function.WithDescription("查询当前用户订单"),
)
ag := llmagent.New("assistant",
    llmagent.WithModel(modelInstance),
    llmagent.WithTools([]tool.Tool{t}),
)
```

#### 先写可靠函数，再让模型发现它

Function Tool 把 Go 函数包装成模型可发现和调用的工具。请求结构体表达字段，描述告诉模型何时使用，函数实现负责真正的行为。

名称应明确，如 lookup_order，比万能的 execute 更容易理解。输入只暴露业务需要的字段，不能让模型任意选择数据库表名、shell 命令或租户身份。


```go
type AddRequest struct {
    A float64 `json:"a" jsonschema:"required,description=第一个数"`
    B float64 `json:"b" jsonschema:"required,description=第二个数"`
}
type AddResponse struct {
    Result float64 `json:"result"`
}
func add(ctx context.Context, req AddRequest) (AddResponse, error) {
    if err := ctx.Err(); err != nil { return AddResponse{}, err }
    return AddResponse{Result: req.A + req.B}, nil
}
```

#### 包装与验证是不同责任

工具包装器提供名称、描述和输入输出 schema 等信息。模型产生参数后，框架进行参数处理并调用函数，但 schema 不等于所有业务条件都已满足。

例如数量是合法整数，仍可能为负数或超出库存。权限判断应依据服务端认证上下文，而不是模型传入的 user_id。本实验的校验节点是教学中的应用边界，不暗示框架自动完成所有授权。


```go
addTool := function.NewFunctionTool(add,
    function.WithName("add"),
    function.WithDescription("计算两个数的和，返回 result。"),
)
```

#### 让失败可以被看见

反序列化失败、权限不足、下游超时和正常的“没有查到”应区分。工具返回 error 后，应用应根据错误类别决定是交回可读错误、重试还是终止。

实验可改 a、b，并切换“合法参数”与“错误类型”，逐步观察 schema → 参数 → 校验 → Go 函数 → 结果。所有计算仅在本地完成。

交互演示：网页的「Function Tool」正文可操作。

注意：schema 合法不等于业务操作合法，不能让模型提供 tenantID 和授权结果。

---

### 事件系统

文本增量、工具响应、状态更新、完成与错误事件。

#### 事件分类

文本增量、工具调用、工具结果、状态更新、完成和错误事件的字段不同；不要全部转成字符串。

#### 消费顺序

按 event ID 或 sequence 维护顺序；客户端重连时要定义是否重放、从哪里恢复和何时结束。

#### 协议适配

应用协议要隐藏框架内部对象，保留稳定 type、run_id、step、payload 和 error 字段。

#### 背压与取消

慢客户端会造成事件积压；设置缓冲上限、写超时和取消传播，必要时丢弃可重建的增量。

#### 文本消费者与完整事件适配的区别

示例是返回 error 的函数内的文本消费者，events 为 Runner.Run 返回的 channel。先检查空事件和流内错误，再遍历 Choices；不能直接读取 Choices[0]。完整 Web 接口还需要按真实 Object、工具消息与完成事件设计状态映射。

Response.Error 是框架结构体，示例用其中 Message 构造 Go error；流结束后再查 ctx.Err，避免把取消误报为成功。answer.delta、run.done 是应用自己定义的事件名称，不是框架原生常量。

#### 参考片段

```go
for ev := range events {
    if ev == nil || ev.Response == nil { continue }
    if ev.Response.Error != nil {
        return fmt.Errorf("运行失败: %s", ev.Response.Error.Message)
    }
    if ev.Response.Object == model.ObjectTypeChatCompletionChunk {
        for _, choice := range ev.Response.Choices {
            fmt.Print(choice.Delta.Content)
        }
    }
}
return ctx.Err()
```

#### Run 返回之后，工作还在继续

Run 的返回值包括事件 channel 和启动阶段的 error。err==nil 只说明运行成功开始，不保证之后的模型或工具调用都成功。消费者需要持续读取事件。

channel 关闭表示这条流已结束，但最终业务状态还需结合错误、取消和终止原因判断。不要仅凭 UI 停止更新就标记成功。

#### 事件也有不同的载荷

Event 携带框架运行信息，并可包含 Model Response。流式文本通常来自 Choices 中的 Delta.Content。非流式完整消息使用 Message.Content；把两者同时盲目拼接，可能重复显示答案。

访问 Response、Choices 前做空值与长度判断。检查 Response.Error；工具事件没有正文文本也可能非常重要。完整示例只输出流式文本，并显式处理错误。


```go
for ev := range events {
    if ev == nil || ev.Response == nil { continue }
    if ev.Response.Error != nil {
        return fmt.Errorf("模型运行失败: %s", ev.Response.Error.Message)
    }
    if ev.Response.Object != "chat.completion.chunk" { continue }
    for _, choice := range ev.Response.Choices {
        fmt.Print(choice.Delta.Content)
    }
}
```

#### 给前端一个稳定的协议

可把框架事件适配成 answer.delta、tool.start、tool.result、run.error、run.done 等应用事件。这些名称是本手册设计的 UI 协议，不是声称框架原生就叫这些名字。

实验展示从模拟原始事件到 UI 分发的过程：状态面板接收工具事件，回答区只累加文本，错误区接收失败。测试流里插入空事件，确保代码不会越界访问。

交互演示：网页的「事件系统」正文可操作。

注意：不要把工具事件和文本增量拼在一个字符串里，也不要忽略流内错误。

---

### Session 与 Memory

多轮对话、摘要、长期记忆和存储服务。

#### 生命周期

Run 是一次执行，Session 是多轮边界，Memory 是跨轮或跨会话数据；三者必须分别定义读写权限和保留期。

#### 隔离键

服务端把认证主体映射到 userID、tenantID 和 sessionID；所有历史查询都带权限过滤。

#### 持久化

内存 store 适合测试；生产要有数据库、版本、并发更新和清理策略，摘要与原文也要区分。

#### 上下文窗口

历史过长时采用截断、摘要或检索；摘要要保留来源和不确定性，避免把猜测写成长期事实。

#### 参考片段

```go
events, err := r.Run(ctx, userID, sessionID, message)
// Session store 根据 userID + sessionID 读写历史
```

#### 三种不同时间尺度

一次 Run 是处理一条输入的执行过程；Session 对应持续多轮的对话；Memory 通常保存跨轮次甚至跨会话有价值的长期信息。聊天记录、摘要和偏好并不是同一种数据。

相同 user/session 标识可让运行关联到同一会话。但模型最终看到多少历史，还取决于上下文构造、过滤、摘要和预算策略。不要把所有历史无限追加。

#### 同一个人，也可以有多个会话

会话标识应在服务端和认证用户绑定，不能仅信任客户端传入的 userID。不同用户的对话必须隔离；不同 session 也不应无意共享完整历史。一个全局 map[string][]Message 如果只按 session 甚至不按 user 归属，仍可能被猜测的 sessionID 访问；生产存储应把 userID、sessionID 和权限一起作为边界。

实验可以切换 A、B 会话，写入“我偏好 Go”，再查询偏好。只有当前会话的历史被读取。开启模拟长期记忆后，同用户的另一个会话才可以读取已明确保存的偏好。

#### 存储位置决定重启后的行为

内存实现方便开发，但进程退出后数据通常丢失。生产环境按需求接入持久化 Session/Memory 服务，确认读写一致性、保留时间、删除能力和访问隔离。

长期记忆应经过筛选，避免把暂时的错误猜测或敏感信息永久保存。检索记忆也不应覆盖当前用户的明确指令。实验的状态仅在本课运行时保留，不会调用真实存储。

交互演示：网页的「Session 与 Memory」正文可操作。

注意：更换 sessionID 不会自动完成鉴权和数据隔离。

---

### Graph / GraphAgent

StateSchema、StateGraph、节点、边、条件路由、执行器和检查点。

#### 状态图模型

StateSchema 定义状态，StateGraph 注册节点和边；节点读状态并返回更新，边决定下一步。

#### 编译与执行

AddNode、AddEdge、SetEntryPoint、SetFinishPoint 后 Compile；编译期尽早发现缺失入口和终点。

#### 条件与循环

条件边基于可测试状态；循环设置最大迭代、deadline 和失败出口，并记录每一步。

#### 恢复与观测

需要人工中断、检查点和恢复时保存可序列化状态；每个节点打 trace 和输入输出摘要。

#### 运行时会看到什么

编译失败应在启动/测试阶段暴露；运行阶段每个节点的状态更新都应可序列化，便于恢复和回放。

#### 参考片段

```go
schema := graph.NewStateSchema()
workflow, err := graph.NewStateGraph(schema).AddNode("plan", plan).AddNode("act", act).SetEntryPoint("plan").AddEdge("plan", "act").SetFinishPoint("act").Compile()
```

#### 状态是数据，节点是操作，边是规则

图工作流把步骤显式建模：节点读写状态，边定义下一步，条件边按结果分支。例如检索 → 证据检查 → 回答；若证据不足，则进入改写查询或请求澄清。

tRPC-Agent-Go 的 Graph 能组织带状态的工作流。图中可以包含模型调用，也可以是确定性 Go 函数；不是每个节点都必须是一个自主 Agent。

#### 把条件写成可验证的规则

什么时候允许重试？什么结果必须人工审核？这些条件应能被测试。本实验设置证据是否足够、最大重试数以及高风险操作是否经过批准。

循环必须有预算和出口。达到最大尝试次数后返回明确失败或澄清请求；不能只画一条回环箭头而不说明何时结束。并行节点修改共享状态时还需要明确合并规则。


```go
// 概念路由函数，非 Graph 构建 API
func next(s State) string {
    if s.EnoughEvidence { return "answer" }
    if s.Attempts >= 2 { return "clarify" }
    return "rewrite"
}
```

#### 选择最简单的可控结构

路径固定时，普通 Go 函数或顺序工作流已经足够。任务需要动态选工具时，使用 LLMAgent 更自然。业务同时包含固定审核和开放式推理时，可在图中组合 Agent 节点。

v1.11.2 的 graph 包提供 NewStateSchema、NewStateGraph、AddNode、AddEdge、SetEntryPoint、SetFinishPoint 和 Compile；examples/agent/graph_test.go 给出一个可编译的最小版本。动画节点名仍是教学名称，完整业务流程要继续补充状态和错误处理。

交互演示：网页的「Graph / GraphAgent」正文可操作。

注意：节点越多不代表更智能；每条边都要有失败、取消和预算测试。

---

### Chain / Parallel / Cycle

顺序、并行和循环工作流。

#### Chain

Chain 适合固定顺序，把上一步结构化输出传给下一步；每一步要有独立超时和错误分类。

#### Parallel

Parallel 要定义并发上限、结果合并、部分失败和取消；共享写状态必须明确所有权。

#### Cycle

Cycle 需要最大次数、状态变化或 deadline 出口；每轮记录原因，防止模型重复调用同一工具。

#### 选型

固定流程优先 Chain/Graph；动态规划再用 Agent，避免用自由文本模拟确定性状态机。

#### 构造参数与数据传递

代码按 v1.11.2 的 options API 编写，first、second 是实现 agent.Agent 的实例，三个编排器是供选择的三种构造方式。它们分别来自 agent/chainagent、agent/parallelagent、agent/cycleagent。New 的第二个参数是 Option，不能把 first、second 直接当位置参数传入。

Chain 的先后执行不等于任意 Go 返回值自动传给下一个节点；需要依据子 Agent 读写的消息与 Session/State 约定设计数据传递。并行分支也不能同时写同一个未经同步的 map。

#### 参考片段

```go
pipeline := chainagent.New("pipeline",
    chainagent.WithSubAgents([]agent.Agent{first, second}),
)
parallel := parallelagent.New("parallel",
    parallelagent.WithSubAgents([]agent.Agent{first, second}),
)
cycle := cycleagent.New("refine",
    cycleagent.WithSubAgents([]agent.Agent{first, second}),
    cycleagent.WithMaxIterations(3),
)
```

注意：不要把多阶段拆分当作默认方案，节点越多，失败和观测成本越高。

---

### Knowledge 与 RAG

文档、切分、Embedding、检索、重排和引用。

#### 离线入库

解析、清洗、切分、元数据、Embedding 和索引是离线路径；来源、权限和更新时间不能丢。

#### 在线检索

先做用户权限过滤，再向量/关键词检索和重排；Top-K、最大 token 和超时必须固定。

#### 上下文拼装

证据带 source、chunk ID 和分数进入 prompt；模型回答引用时保留可回溯关系。

#### 无证据处理

没有足够证据时澄清或拒答；相似度只是检索信号，不是事实正确率和权限判断。

#### 参考片段

```go
chunks := retrieve(ctx, query, tenantID, topK)
if len(chunks) == 0 { return ErrInsufficientEvidence }
```

#### 入库路径：文档变成可检索片段

先解析文档，按语义和长度切分 chunk，保留标题、来源、更新时间和访问权限等元数据，再为片段生成 embedding，写入向量索引。

片段太小会丢失上下文，太大会引入噪声并占用 token。重叠切分可缓解边界问题，但也会造成重复，需要用评测检验。更新文档时，还要删除或替换失效片段。

#### 查询路径：先检索，再生成

用户问题生成查询向量；在经过权限过滤的候选文档中检索，再按需要重排，选出片段放进上下文。模型依据证据生成答案并带来源标识。

tRPC-Agent-Go 提供 Knowledge 相关能力，可按需要作为知识检索能力接入 Agent。实验重点解释数据路径，具体配置请跟随固定版本 Knowledge 示例。

#### 检索不到时，也需要正确行为

Top-K 只规定返回数量，不保证每个结果都相关。没有有效证据时，应澄清问题或说明资料不足，而不是强行把最近的几个片段当作答案。

实验可切换“知识内问题”和“资料外问题”，调整 K，观察检索候选、上下文和最终回答。演示分数是教学数据，不是在线向量数据库的实测。


```go
// 概念数据流，不是框架 API
chunks := retrieve(query, permissionFilter, topK)
evidence := filterRelevant(chunks)
if len(evidence) == 0 {
    return "现有资料不足以回答，请补充文档。"
}
return generateWithCitations(query, evidence)
```

交互演示：网页的「Knowledge 与 RAG」正文可操作。

注意：不要把未过滤的相似片段直接交给模型，也不要把向量分数当置信度。

---

### MCP Tool

通过 MCP 发现和调用远程工具。

#### 协议边界

MCP 负责工具/资源发现和调用协议；服务端认证、授权、限流、审计和数据隔离仍由应用负责。

#### 连接管理

远程 MCP 需要连接复用、握手超时、调用超时、重连和关闭；stdio 与 HTTP 传输的故障模型不同。

#### 工具治理

固定允许的工具集合和 schema，限制输入输出大小；外部返回值是数据，不是高优先级指令。

#### Agent 接入

发现工具后转成内部 Tool 接口，保留 trace、调用方和审批信息；破坏性动作需要幂等或人工确认。

#### 参考片段

```text
发现允许的工具 → 校验名称和 schema → 注册到 Agent
收到工具调用 → 验证参数和权限 → 调用 MCP 服务
收到结果 → 限制体积并记录事件 → 返回模型
```

#### MCP 连接的是能力提供方

MCP 提供客户端与服务端交换工具、资源等能力的协议。Agent 应用作为使用方，可以通过 MCP 客户端发现远程工具并发起调用；MCP 服务端再完成实际执行。

Function Tool 可以直接调用本地 Go 函数；MCP Tool 跨越协议与进程边界。后者需要额外考虑认证、网络延迟、超时和服务端可信度，不会因为使用标准协议就自动安全。

#### 多 Agent 是职责拆分，不是复制多个聊天框

可以把研究、检查、汇总分成不同 Agent，也可以并行获取独立来源。但应明确输入输出结构、共享状态、失败策略与合并方式。

多个 Agent 同时生成可能增加成本、延迟和冲突。先用单 Agent 加工具做出可评测基线；只有角色分工带来明确收益时，再使用 Chain、Parallel 或图结构。

#### 跨边界时只传必要数据

发送到远程工具的数据应最小化；凭据在服务端管理。工具返回的文字仍是外部数据，不应被当作高优先级指令。

接入前用固定样例检查工具名称、输入 schema、输出体积和错误格式。对破坏性操作添加真实的批准或业务约束，而非只让模型“自己判断是否安全”。

注意：使用 MCP 不会自动获得权限或安全保证。

---

### A2A / AG-UI

Agent 互通和前端事件协议。

#### 任务契约

跨 Agent 请求至少包含 task_id、caller、input、deadline、状态和错误；返回要能区分处理中、成功、失败和取消。

#### 边界选择

一个 Agent 加工具能完成的任务，不要先拆成远程 Agent；拆分应有独立权限、数据或扩展边界。

#### 幂等与重试

远程 Agent 重试必须带 task_id 和幂等键；网络读响应失败时不能盲目重放副作用。

#### 事件兼容

事件 schema 版本化，未知字段可忽略，错误包含可操作 code；前端协议与 Agent 内部事件分开。

#### 参考片段

```go
type TaskRequest struct { TaskID string; Caller string; Input string; Deadline time.Time }
```

注意：多 Agent 会增加延迟、状态同步和合并冲突，不能只看抽象层次。

---

### Agent Skills

可复用 SKILL.md 工作流和安全执行边界。

#### 技能结构

SKILL.md 应写适用条件、输入、步骤、输出、失败处理和限制；技能是流程资产，不是权限系统。

#### 执行边界

执行前校验用户权限、工具 schema、网络目标、文件范围和资源预算；技能不能绕过人工审批。

#### 版本管理

技能与提示词、工具 schema、评测样例一起版本化；变更要能回滚和比较质量。

#### 测试方法

用固定输入验证成功、缺参、越权、超时和副作用路径；不要只测试技能文本能被读取。

#### 参考片段

```markdown
# SKILL.md
## 适用条件
## 输入
## 步骤
## 输出
## 限制
## 失败处理
```

注意：技能文件描述流程，但不能替代代码中的授权、校验和资源限制。

---

### 可观测性

OpenTelemetry、Langfuse、指标、trace 和事件记录。

#### 运行指标

记录请求数、首 token、总延迟、模型/工具耗时、重试、token、队列等待和最终状态。

#### Trace 结构

HTTP、Runner、llm.generate、tool.*、db.query 和 mcp.call 使用父子 span；属性使用低基数稳定值。

#### 日志与隐私

完整 prompt、密钥、Authorization 和用户正文默认脱敏；保留可定位错误所需的摘要和 ID。

#### 质量信号

工具选择、引用支持率、无证据拒答、越权拦截和用户反馈进入评测，而不只看响应时间。

#### 参考片段

```go
ctx, span := tracer.Start(ctx, "agent.run")
defer span.End()
span.SetAttributes(attribute.String("agent.name", name))
```

注意：不要只记录最终答案字符数，也不要把 trace 当业务正文存储。

---

### 评测与基准

EvalSet、Metric、固定样例和回归评测。

#### 评测分层

工具单测验证确定性逻辑，编排测试验证事件和工具选择，真实模型测试验证供应商协议，离线 EvalSet 验证长期质量。

#### 样例设计

覆盖正常、澄清、无证据、权限不足、工具超时、重复调用和恶意输入；样例要固定知识库和工具版本。

#### 指标

答案质量之外记录引用支持率、工具选择、任务成功率、P95、token 成本、重试和越权行为。

#### 回归流程

提示词、模型、索引、工具 schema 或依赖改变后重跑；保存失败样例和 diff，避免只报一个平均分。

#### 参考片段

```go
type Case struct { Input string; WantTools []string; WantSources []string; MaxCost int }
```

#### 从确定性到不确定性分层验证

工具单元测试验证参数和业务逻辑；编排测试用模拟模型返回固定工具请求，验证调用、关联、取消与错误处理；集成测试再接真实模型和存储。

离线评测集包含问题、允许使用的资料、关键期望和禁止行为。不要只比较答案是否逐字相同，也要检查引用、工具选择和是否编造。

#### 记录一次运行的关键边界

给运行关联 trace ID，记录模型与工具耗时、错误类型、token 用量、重试次数和最终状态。数据可用于定位是模型慢、检索差还是工具失败。

日志不能无差别写入用户隐私和密钥。可观察性展示系统可见的请求、结果和状态，不需要捕获模型隐藏的内部思考。tRPC-Agent-Go 提供相关观测与评测能力，可在基本闭环稳定后引入。

#### 从 20 个问题开始

至少覆盖：可回答问题、无证据问题、工具超时、参数错误、用户越权、恶意文档、上下文过长、重复请求。将版本、配置和输入固定，比较改动前后的指标。

可以记录任务成功率、引用支持率、P95 延迟和每次任务成本。指标应根据真实任务定义，不能用动画里的模拟数字当作框架性能基准。

注意：一次成功演示不能替代可重复的回归评测。

---

### 生产化清单

超时、取消、并发、幂等、鉴权、部署和故障演练。

#### 可靠性预算

请求、模型、工具、数据库分别设置 timeout；限制并发、循环步数、输出长度、工具次数和事件缓存。

#### 副作用保护

写数据库、下单、发消息等工具使用幂等键、状态确认和必要人审；模型文本不能当成功凭证。

#### 部署与关闭

健康/就绪检查、优雅退出、迁移、配置校验、日志、trace 和告警一起准备；关闭时停止接收新任务并等待在途任务。

#### 故障演练

演练模型 429、MCP 不可达、数据库耗尽、客户端断开、重复事件和 exporter 故障，记录恢复动作。

#### 参考片段

```go
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
return runner.Run(ctx, userID, sessionID, message)
```

#### 最小架构先保持简单

浏览器通过 HTTP 发送问题；服务端认证并绑定 user/session；Runner 驱动 LLMAgent；工具查询业务或知识库；事件适配器把状态和文本转换成 SSE；Session 保存需要的历史。

先用一个 Go 服务和一种数据库。只有出现明确的独立扩缩容或团队边界需求，再考虑微服务。学习 tRPC-Agent-Go 并不要求同时搭建整套云原生基础设施。

#### 按四个可验证里程碑推进

里程碑一：命令行能用加法工具完成一次回答，能显示流内错误。里程碑二：HTTP + SSE 能持续输出，关闭连接后任务取消。

里程碑三：两名用户、两个会话相互隔离，历史按策略持久化。里程碑四：接入小型文档库，回答附可核验来源，无证据时明确说明，并通过固定评测集。

#### 发布前做真实的故障演练

把 API key 移出代码；固定依赖；设置连接与请求超时、并发上限和工具预算。准备健康检查、优雅退出和必要的存储迁移。

主动模拟供应商超时、数据库不可用、客户端断开和重复请求，确认没有泄漏任务、跨用户数据或重复副作用。部署不只是“容器能启动”，而是失败时仍能解释和恢复。


```bash
# 本地验证
go test ./...
go test -race ./...
go build ./...

# 固定依赖的示例见 examples/agent
# 配置环境变量后执行 go run .
```

#### 后续学习顺序

先补 Go 的内存模型、锁和 profiling，再深入 Session 存储、Knowledge 检索评测和 Graph 状态设计。最后按项目需要学习 MCP、多 Agent 与服务治理。

每增加一个抽象，都问自己：它解决了当前哪个具体问题？能否画出输入、状态变化、输出和失败路径？能画清楚，再写代码会轻松很多。

注意：容器能启动不代表 Agent 服务具备权限隔离、可恢复性和失败可观测性。

---

## 语法基础

### 准备开始

安装 Go、选择编辑器、创建第一个模块。

#### 工具链

go version、go env、go env GOPATH/GOMOD 用于确认环境；项目行为以 go.mod 和 toolchain 声明为准。

#### 模块目录

go mod init 建模块，go run 临时运行，go build 构建，go test 验证；命令应在模块根目录执行。

#### 可重复性

提交 go.mod/go.sum，固定关键依赖版本；不要提交缓存、密钥和本地二进制。

#### Agent 项目入口

先让无模型的工具和事件测试通过，再加入真实 provider；这样模型不可用时仍能验证编排。

#### 参考片段

```bash
go mod init example.com/agent
go mod tidy
go test ./...
go vet ./...
```

#### 先理解三个层次

一个 .go 文件装的是代码；同目录的 Go 源文件通常属于同一个 package；module 是一组包及其依赖的版本边界，由 go.mod 标识。main 包中的 main 函数是可执行程序的入口。

学习时先在单独目录建模块。go run . 编译并运行当前包，go build . 生成可执行文件；go fmt ./... 统一排版，go test ./... 执行测试。工具链是编译和运行代码的工具，不是应用依赖。


```bash
mkdir hello-agent
cd hello-agent
go mod init example.com/hello-agent
# 保存下面的 main.go 后执行
go run .
```

#### 你的第一个程序

import 声明当前文件使用的包。fmt.Println 把文本输出到终端。Go 通常不需要在行末手写分号，格式交给 gofmt。


```go
package main

import "fmt"

func main() {
    fmt.Println("你好，Go Agent")
}
```

#### 版本要固定，教程要对齐

本手册的框架示例固定 tRPC-Agent-Go v1.11.2，避免同一份教程随依赖更新而改变。源码标签、示例和 go.mod 应一起阅读。Go 版本先用 go version 检查，升级前阅读官方版本说明。

第一个参考链接是更新日志，适合了解语言演进；日常学习按本站基础 → 并发 → Agent 的顺序进行。不要把更新日志当作入门教材逐条背诵。

注意：不要把能运行一次当作环境已经可复现。

---

### 基本语法

包、导入、注释、语句、标识符和作用域。

#### 包与文件

同一目录的 Go 文件属于同一 package；package main 加 main 函数生成可执行程序。

#### 导入与可见性

导入未使用会编译失败；大写标识符导出，小写标识符留在包内。

#### 作用域

短声明只能在函数中使用，嵌套块可能遮蔽变量；大型函数应减少同名局部变量。

#### 编译反馈

gofmt、go test 和 go vet 尽早运行，编译器错误通常比运行时调试更便宜。

#### 参考片段

```go
package main
import "fmt"
func main(){ fmt.Println("hello") }
```

注意：不要通过空白标识符随意吞掉真正未使用的变量或错误。

---

### 数据类型

布尔、整数、浮点、复数、字符串、数组和结构体。

#### 内置类型总览

Go 的内置类型包括 bool、整数、浮点、复数、string、数组、slice、map、channel、函数、接口、指针和结构体。每种类型都有明确的零值，声明变量后不需要先手动创建空对象。

整数包括 int、int8、int16、int32、int64 及无符号类型。int 的位宽与平台有关；协议、文件格式和数据库字段需要稳定宽度时，应使用明确的 int32 或 int64。

string 是不可变的 UTF-8 字节序列；[]byte 是可变字节集合。中文文本要区分字节数量、rune 数量和显示宽度。

#### 零值与 nil

数字零值是 0，bool 是 false，string 是空字符串，指针、slice、map、channel、函数和接口可以是 nil。零值可用是 Go 的重要设计。

nil slice 可以 len、range 和 append；nil map 可以读取和删除，但写入会 panic；nil channel 的发送和接收会永久等待；nil 函数调用会 panic。

#### 类型转换与常量

int、int64、float64 即使都表示数字，也必须显式转换。转换可能截断小数、溢出或改变精度。无类型常量可以在赋值时获得目标类型，但变量之间不会隐式转换。

定义新类型后，它和底层类型具有不同的类型身份。类型转换只解决表示关系，不会自动完成业务校验。

#### 数组、切片与结构体

数组长度属于类型：[3]int 和 [4]int 是不同类型，赋值会复制全部元素。slice 是底层数组、长度和容量的描述信息，复制 slice 通常共享元素，append 可能复用或重新分配。

结构体按字段组织业务数据。结构体赋值会复制字段；如果字段里有 slice、map 或指针，复制后仍可能共享它们指向的数据。

#### 字符串、字节与 rune

len("中国") 得到 UTF-8 字节数，不是两个。range 遍历字符串时按 rune 解码；[]byte 适合协议和二进制处理，[]rune 适合需要按 Unicode 码点操作的场景。

字符串转换为 []byte 后可以修改，但可能产生复制。大量拼接使用 strings.Builder；不要为了省一次转换而牺牲清晰的所有权边界。

#### 在 Agent 服务中选择类型

API 请求字段、数据库字段、工具参数和事件协议应选择能表达边界的类型。金额不要直接用 float64，时间不要只用无时区字符串，用户 ID 不要和普通字符串混用。

Function Tool 的 JSON schema 只能约束输入形状；解析成 Go 类型后仍要检查范围、权限、幂等和副作用。

#### 参考片段

```go
package main

import (
    "fmt"
    "unicode/utf8"
)

type User struct {
    Name string
    Age  int
}

func main() {
    var count int
    var enabled bool
    var names []string
    var scores map[string]int
    _ = scores

    bytes := []byte("中国")
    fmt.Println(count, enabled, names == nil)
    fmt.Println(len(bytes), utf8.RuneCount(bytes))

    n := int64(42)
    f := float64(n)
    fmt.Println(f, User{Name: "Ada", Age: 36})
}
```

注意：不要把所有数字都写成 int、所有文本长度都当成字符数，也不要把 nil map 当成可写的空 map。

---

### 常量

常量声明、无类型常量和 iota。

#### 编译期值

常量必须是编译期可求值表达式；无类型常量在赋值或调用时取得目标类型。

#### iota 枚举

iota 适合连续编号和位掩码；公开协议应使用明确名称和兼容值。

#### 位运算

左移会改变位宽和溢出边界；定义新类型和方法能避免业务代码散落魔法数字。

#### 配置边界

常量适合默认值和标签，不适合运行期密钥、租户配置和动态模型参数。

#### 参考片段

```go
const ( Read = 1 << iota; Write; Admin )
flags := Read | Write
```

注意：不要因为 iota 方便就把位置变化直接写入持久化协议。

---

### 变量

var、短声明、零值、作用域和变量遮蔽。

#### 声明方式

var 适合包级变量和显式类型；:= 适合函数内短生命周期值，但必须至少产生一个新变量。

#### 零值

声明后的数字、bool、string、slice、map、指针等有明确零值；设计类型时优先让零值可用。

#### 遮蔽

if、for 和函数块中的同名变量会遮蔽外层值，尤其是 err :=；用清晰作用域和 lint 降低风险。

#### 并发共享

局部变量默认不共享；被闭包或 goroutine 捕获后要确认生命周期、同步和是否需要复制。

#### 参考片段

```go
var err error
if value, err := load(); err != nil { return err } else { use(value) }
```

#### 变量是有类型的存储位置

var n int 的零值是 0，string 的零值是空字符串，bool 的零值是 false。:= 在函数内声明并推断类型，赋值 = 则更新已有变量。

int 与 string 是不同类型。类型让编译器在程序运行前发现很多不合理操作。字符串的 len 计算字节数，中文通常不能按一个字节处理；遍历文本可用 range 得到 rune。


```go
n := 10
name := "小林"
var enabled bool
fmt.Println(n, name, enabled) // 10 小林 false
```

#### 函数得到的是参数值的副本

调用 change(n) 时，参数 x 获得 n 的值的副本。把 x 改成 99，并不会改变 n。之后学习指针时规则也一样：复制的是地址值，两个地址可以指向同一个对象。

函数可返回多个值。常见模式是 (结果, error)，调用方显式判断是否失败。参数名只是函数内部的变量名，不会自动关联外部同名变量。


```go
func change(x int) int {
    x = 99
    return x
}

n := 10
result := change(n)
fmt.Println(n, result) // 10 99
```

#### 控制流决定哪条路径被执行

if 根据布尔条件选择分支；for 可表达传统循环、条件循环和无限循环。range 遍历集合。break 结束当前循环，continue 跳过本次剩余语句。

用小函数把计算和 I/O 分开。Agent 的工具函数也遵循这个原则：输入结构体，验证参数，计算或查询，返回结果与错误。


```go
total := 0
for _, n := range []int{2, 3, 5} {
    if n < 3 {
        continue
    }
    total += n
}
fmt.Println(total) // 8
```

交互演示：网页的「变量」正文可操作。

注意：不要把短声明的遮蔽误认为更新了外层变量。

---

### 输入输出

fmt、标准输入、格式化输出和扫描。

#### 格式化

fmt.Printf 适合命令行和调试；占位符类型不匹配会产生可见错误输出。

#### 扫描

Scan 系列要处理空白、EOF 和转换错误；服务端不要用标准输入代替请求协议。

#### 协议输出

HTTP JSON、SSE 和日志必须分离；调试 fmt 输出不能混入机器可读响应。

#### 测试输入

用 strings.NewReader 和 bytes.Buffer 构造确定性输入输出，避免测试依赖终端。

#### 参考片段

```go
var name string
if _, err := fmt.Fscan(os.Stdin, &name); err != nil { return err }
fmt.Printf("hello %s\n", name)
```

注意：不要把 fmt 输出当成稳定的服务端 API。

---

### 条件控制

if、else、switch、类型 switch。

#### if 与初始化

if 可带初始化语句，变量作用域只覆盖条件和分支；复杂计算应提前命名。

#### switch

switch 默认不 fallthrough；按类型分支时用 comma-ok 或明确处理 nil。

#### 错误分支

错误优先返回，成功路径保持左对齐；避免多层嵌套让资源清理和取消路径消失。

#### Agent 路由

工具结果、权限和模型状态可以用显式状态机分支，不要用文本包含判断代替结构化状态。

#### 参考片段

```go
if err != nil { return fmt.Errorf("load config: %w", err) }
switch status { case "ready": start(); case "closed": return ErrClosed }
```

注意：不要在条件分支里偷偷修改全局状态或吞掉错误。

---

### 循环控制

for、range、break、continue 和标签。

#### 三种 for

for 初始化;条件;后置适合计数；for range 适合集合；for 条件适合事件循环。

#### range 副本

range 的值是副本，修改元素要用索引；map 的遍历顺序不能用于业务排序。

#### 退出条件

循环要有可证明的出口、context 取消或最大次数；continue 前确认资源不会泄漏。

#### Agent 循环

工具/模型循环记录 step、预算和最后状态，不能靠“模型应该会停”作为退出条件。

#### 参考片段

```go
for step := 0; step < maxSteps; step++ {
    if err := ctx.Err(); err != nil { return err }
    runStep()
}
```

注意：不要在没有取消和预算的 goroutine 中写无限循环。

---

### 切片

切片的长度、容量、底层数组和 append。

#### 切片描述

slice 是指针、len、cap 的视图；复制切片通常共享底层数组。

#### append 行为

append 可能原地扩容，也可能分配新数组；调用方和被调用方要约定所有权。

#### copy 隔离

需要防止外部修改时用 make+copy；返回内部缓存前也要考虑复制。

#### 服务输入

请求、事件和检索结果应设置最大长度，避免 append 造成无界内存增长。

#### 参考片段

```go
dst := make([]Item, len(src))
copy(dst, src)
```

#### 切片像一张取货单

数组 [3]int 的长度属于类型；切片 []int 则描述底层数组的一段。理解时可以把切片看成“底层数组引用、长度 len、容量 cap”三个信息。复制切片复制这些信息，并不复制所有元素。

a := []int{10,20,30}; b := a[:2] 时，a 和 b 共享前两个元素。执行 b[0] = 99，a[0] 也会变。容量决定在不重新分配底层数组时能增长到多长。


```go
a := []int{10, 20, 30}
b := a[:2]
b[0] = 99
fmt.Println(a) // [99 20 30]
fmt.Println(len(b), cap(b)) // 2 3
```

#### append 返回新的切片描述

当容量足够时，append 可以使用原底层数组；容量不足时，会分配新的底层数组并复制元素。新容量的具体增长策略不是应当依赖的语言保证。

即使 append 没有重新分配，调用者的切片长度也不会自动改变。因此通常返回切片并让调用者接住，而非习惯性使用 *[]T。需要独立副本时，使用 make 加 copy。


```go
func add(xs []int) []int {
    return append(xs, 4)
}
xs := []int{1, 2, 3}
xs = add(xs)

independent := make([]int, len(xs))
copy(independent, xs)
```

#### map 是键到值的映射

使用 make(map[string]int) 或字面量初始化后才能写入。读 nil map 会返回元素类型的零值，但写 nil map 会 panic。v, ok := m[key] 可以区分键不存在和键对应零值。

map 的遍历顺序没有保证。多个 goroutine 并发访问且包含写入时，需要同步。给局部参数 m 赋一个新 map 也不会替换调用方持有的 map。


```go
scores := map[string]int{"Go": 90}
score, ok := scores["Rust"]
fmt.Println(score, ok) // 0 false
scores["Agent"] = 95
delete(scores, "Go")
```

交互演示：网页的「切片」正文可操作。

注意：不要把重新切片或复制 slice 误认为深拷贝。

---

### 字符串

UTF-8、rune、byte、字符串构建和切分。

#### 编码单位

string 是不可变 UTF-8 字节序列；len 得到字节数，range 解码 rune。

#### 构建文本

少量拼接可直接 +，循环拼接使用 strings.Builder；Builder 不应跨 goroutine 共享。

#### 分割与截断

协议字段按字节约束，用户文本可能按 rune 或显示宽度约束；截断前先明确规则。

#### Agent 文本

prompt、工具描述、日志和用户正文采用不同转义、长度和脱敏策略。

#### 参考片段

```go
var b strings.Builder
for _, part := range parts { b.WriteString(part) }
text := b.String()
```

注意：不要按字节截断中文或把字符串拼接当作安全转义。

---

### 映射表

map 的创建、读取、删除、comma-ok 和遍历。

#### 读写语义

读取不存在的键得到零值；comma-ok 区分“没有键”和“值正好是零”。

#### 初始化

make 创建可写 map；nil map 只能读和 delete，写入会 panic。

#### 并发

map 不能并发读写；使用 Mutex、RWMutex、sync.Map 或单 goroutine 所有权。

#### Agent 状态

工具注册、Session 索引和缓存需要过期、容量和租户边界，不能只放全局 map。

#### 参考片段

```go
value, ok := counts[key]
if !ok { counts[key] = 1 } else { counts[key] = value + 1 }
```

注意：不要依赖遍历顺序，也不要让 map 充当持久化状态。

---

### 指针

取地址、解引用、nil、指针参数和指针接收者。

#### 地址与解引用

&x 取得地址，*p 读取或修改目标；指针本身仍按值传递，复制的是地址。

#### nil 与生命周期

解引用 nil 会 panic；返回局部变量地址是合法的，编译器会处理逃逸，但生命周期仍要清楚。

#### 重新绑定

函数内 p = other 只改局部指针；*p = other 修改共享目标，这是最容易混淆的区别。

#### Agent 数据边界

用指针表达可选值或大对象修改，但不要把可变请求对象在 goroutine 间裸共享。

#### 参考片段

```go
func set(v *int) { *v = 42 }
n := 1
set(&n)
```

#### 地址是一条连接

想象变量 n 是一个存放数字的格子，&n 是这个格子的位置，p := &n 让另一个变量记住这个位置。*p 表示沿地址访问格子里的值。

声明中的 *int 是“指向 int 的指针类型”；表达式中的 *p 是“解引用”。图中的 0xA0 等地址仅用于解释，不代表真实内存布局。


```go
n := 10
p := &n     // p 的类型是 *int
*p = 20     // 修改 p 指向的值
fmt.Println(n) // 20
```

#### 传指针，依然是值传递

set(&n) 复制 n 的地址给参数 p。虽然 p 是新变量，它与调用方的地址值都指向 n；所以 *p = 99 可以修改 n。

若在函数里执行 p = &other，只是让局部参数 p 指向别处，调用方原来的指针不会跟着换地址。分清“改变箭头终点”和“改变终点里的值”。


```go
func set(p *int) {
    *p = 99
}

func rebind(p *int) {
    other := 30
    p = &other // 只改变局部指针
}

n := 10
set(&n)
rebind(&n)
fmt.Println(n) // 99
```

#### nil 与对象生命周期

var p *int 的零值是 nil，没有可访问的目标，直接 *p 会 panic。new(int) 返回指向零值 int 的指针，但平时用已有变量取地址也很自然。

Go 允许返回局部变量的地址，编译器和运行时负责维持对象的有效生命周期。不要据此推断所有取地址的变量都一定分配在堆上，也不要为了性能把所有参数都改成指针。

交互演示：网页的「指针」正文可操作。

注意：不要把“传指针”理解成引用传递，也不要忽略 nil 检查和并发所有权。

---

### 函数

函数声明、闭包、递归、变长参数和多返回值。

#### 多返回值

结果与 error 是常见组合；调用方要处理每个返回值，不要用 _ 随意丢失错误。

#### 闭包

闭包捕获变量而不是值快照；循环中启动 goroutine 时把当前值作为参数传入。

#### 变长参数

... 传入 slice 时共享底层数组，调用方应约定是否允许修改。

#### Agent 函数边界

工具函数签名应接收 context、结构化输入并返回结构化结果和 error。

#### 参考片段

```go
func run(ctx context.Context, args ToolInput) (ToolOutput, error) {
    return execute(ctx, args)
}
```

注意：不要让闭包隐式捕获可变共享状态，也不要把 panic 当普通返回值。

---

### 结构体

字段、匿名字段、标签、组合和结构体字面量。

#### 字段与零值

结构体字段表达业务状态；字段名大小写决定包外可见性，标签只影响编码。

#### 字面量

优先使用带字段名的字面量，避免字段顺序变化造成隐蔽错误。

#### 组合

嵌入提供方法提升，不等于继承；接口和组合比层层嵌套更易替换。

#### 协议边界

请求 DTO、数据库模型和 Agent 工具输入最好分开，防止内部字段被直接暴露。

#### 参考片段

```go
type ChatRequest struct {
    Message string `json:"message"`
    Limit int `json:"limit"`
}
```

注意：不要把数据库模型直接当外部请求和模型工具 schema。

---

### 方法

值接收者、指针接收者和方法集。

#### 接收者选择

值接收者复制结构体；指针接收者可修改对象，也避免复制大对象。

#### 方法集

T 的方法集与 *T 不同；接口赋值失败时先检查接收者类型和方法集。

#### nil 接收者

某些方法可以处理 nil 指针，但必须明确约定；否则调用会在内部 panic。

#### Agent 类型设计

Agent、Tool、Store 用小接口表达消费者需要的能力，构造和生命周期留在具体实现。

#### 参考片段

```go
type Counter struct { n int }
func (c *Counter) Add() { c.n++ }
```

注意：不要只因为能定义方法就把所有状态和流程塞进一个大类型。

---

## 语法进阶

### 接口

隐式实现、方法集、动态类型和值。

#### 隐式实现

接口由方法集合决定；定义在消费方更容易替换实现和测试。

#### 动态值

interface 保存动态类型和值；携带 nil 指针时接口本身仍可能非 nil。

#### 断言与类型 switch

comma-ok 用于安全断言；errors.As 用于错误链；失败路径要返回可操作错误。

#### Agent 边界

Model、Tool、Session 和 Store 用窄接口隔离；不要定义包含几十个方法的万能 Agent 接口。

#### 参考片段

```go
type Store interface { Get(context.Context, string) (Item, error) }
func load(s Store, ctx context.Context, id string) (Item,error) { return s.Get(ctx,id) }
```

#### 结构体组织数据，方法组织行为

struct 把一组字段放在一起。方法的接收者说明行为属于哪个类型。指针接收者可修改原对象；值接收者获得副本，但副本中若含切片或指针，仍可能共享底层数据。

大写开头的名称可被其他包访问。JSON 编码常需要导出的结构体字段；字段标签用于指定外部字段名。


```go
type Counter struct {
    Value int
}

func (c *Counter) Add() {
    c.Value++
}

c := Counter{}
c.Add() // 对可取地址的 c，Go 可自动取地址
```

#### 接口规定“能做什么”

接口列出方法集合。某个类型具有所需方法，就实现了接口，不需要显式写 implements。消费方依赖小接口，可以在真实模型与测试替身之间切换。

调用接口方法时，执行的是其动态具体类型的实现。*T 的方法集合包含接收者为 T 和 *T 的方法；T 的方法集合只包含接收者为 T 的方法。自动取地址的便利不代表 T 就实现了含指针接收者方法的接口。


```go
type Speaker interface {
    Speak() string
}
type Local struct{}
func (Local) Speak() string { return "本地回答" }

func answer(s Speaker) string {
    return s.Speak()
}
fmt.Println(answer(Local{}))
```

#### 接口不只是一个指针

可把接口理解为携带“动态类型 + 动态值”。只有二者都为空，接口才是 nil。一个保存 (*MyType)(nil) 的接口仍有动态类型，因此不等于 nil。

在 Agent 项目中，小接口特别适合封装检索、模型调用与业务工具。初学时先学会替换一个实现，再考虑反射或复杂泛型。

交互演示：网页的「接口」正文可操作。

注意：不要用空接口掩盖类型约束，也不要让接口承担未使用的未来方法。

---

### 泛型

类型参数、约束、类型推断和泛型数据结构。

#### 类型参数

泛型把同一算法复用到多种类型；约束表达可用操作，类型推断减少调用样板。

#### 约束设计

约束越宽复用越多但语义越弱；业务模型通常用具体结构体和小接口更清楚。

#### 切片算法

泛型适合 map/filter、集合工具和类型安全容器；错误、context 和副作用仍由调用方设计。

#### 编译与性能

泛型代码在编译期实例化或字典化，先用基准确认性能，不要凭语法推断。

#### 参考片段

```go
func Map[T any, R any](items []T, f func(T) R) []R {
    out := make([]R, len(items)); for i, v := range items { out[i] = f(v) }; return out
}
```

注意：不要为了炫技把只有一种业务类型的代码泛化成难读的约束层。

---

### 迭代器

迭代器模式、yield 风格和惰性遍历。

#### 迭代器契约

迭代器要表达下一项、结束、错误和取消；只返回 bool 会丢失失败原因。

#### 资源释放

文件、数据库 rows 和网络流的迭代器必须提供 Close 或由 context 控制结束。

#### 惰性边界

惰性读取减少峰值内存，但消费者停止后生产者必须能收到取消。

#### Agent 使用

分页检索、事件流和批量工具适合迭代器，最大数量和 deadline 写进接口契约。

#### 参考片段

```go
for it.Next(ctx) {
    item, err := it.Value(); if err != nil { return err }; use(item)
}
```

注意：不要让迭代器在没有消费者时继续产生数据。

---

### 类型系统

定义类型、别名、类型转换、类型断言和反射边界。

#### 定义类型与别名

定义类型建立新身份，适合 UserID、Money 和 Status；别名保留原身份，常用于兼容迁移。

#### 转换

类型转换只改变表示关系，不会自动完成范围、格式和权限校验。

#### 断言

接口类型断言用 comma-ok；类型 switch 适合有限分支，未知类型要有默认错误。

#### Agent 输入

把 JSON、数据库和模型参数解析到业务新类型，再做校验，减少字符串互换带来的错误。

#### 参考片段

```go
type UserID string
func (id UserID) Valid() bool { return id != "" }
```

注意：不要用类型转换绕过业务校验。

---

### 错误处理

error 接口、包装、errors.Is、errors.As 和自定义错误。

#### 错误是契约

error 表达调用方能采取的动作；区分无数据、参数、权限、超时和系统故障。

#### 包装与判断

fmt.Errorf %w 保留原因；errors.Is 判断身份，errors.As 提取类型，避免字符串比较。

#### 边界映射

底层错误在服务边界映射成稳定 code，日志保留原因，用户提示避免内部细节。

#### Agent 失败

工具错误要告诉 Runner 是否可重试、需澄清还是终止；模型不能把错误文本当成功结果。

#### 参考片段

```go
if err != nil { return fmt.Errorf("load user %s: %w", id, err) }
if errors.Is(err, sql.ErrNoRows) { return ErrNotFound }
```

#### 错误是返回值，不是隐藏分支

网络调用、JSON 解析、数据库查询都可能失败。使用 (结果, error) 把失败交给调用者处理。用 fmt.Errorf 的 %w 包装错误，可保留 errors.Is / errors.As 的检查能力。

panic 适合表示某些无法正常继续的异常，不应替代日常业务错误。忽略 err 会让真正的原因在后面的 nil 访问中才暴露。


```go
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("除数不能为零")
    }
    return a / b, nil
}
```

#### defer 是函数退出时的清理清单

执行 defer 时把调用登记起来，所在函数返回时按后进先出顺序执行。不是等整个程序退出，也不是每次循环结束就执行。

调用参数在 defer 登记时就会求值。所以 defer fmt.Println(n) 记录的是当时的 n；而闭包里直接读取 n，可能读到退出时的值。


```go
func demo() {
    n := 1
    defer fmt.Println("A", n)
    n = 2
    defer fmt.Println("B", n)
    fmt.Println("工作中")
}
// 工作中 → B 2 → A 1
```

#### 把资源获取与释放放在一起

打开文件成功后马上 defer f.Close()；创建可取消 context 后马上 defer cancel()。先检查获取资源是否成功，再登记清理。

写文件时 Close 也可能报告错误，关键写入场景应处理。长循环中逐次打开文件后 defer，会累积到函数退出才关闭，可以把单次处理提取成函数。

交互演示：网页的「错误处理」正文可操作。

注意：不要只打印 error 然后返回 nil，也不要依赖错误字符串做协议判断。

---

### 文件与 IO

io.Reader、io.Writer、文件、缓冲和流复制。

#### Reader/Writer 契约

Reader 允许 n>0 与 err 同时出现；EOF 表示没有更多数据，不一定是业务失败。

#### 短写与缓冲

Writer 可能短写；io.Copy、bufio 和 io.ReadFull 适用于不同读取保证。

#### 资源关闭

文件、响应体、rows 和连接获取后尽早安排 Close；Close 错误按资源重要性处理。

#### Agent 流

SSE、模型流和上传下载都要限制 body 大小，客户端取消时停止复制。

#### 参考片段

```go
n, err := io.CopyN(dst, src, maxBytes)
if err != nil && !errors.Is(err, io.EOF) { return err }
```

注意：不要一次性读取无上限外部输入，也不要忽略短写。

---

### 反射

reflect 的类型和值、可设置性与运行时信息。

#### Type 与 Value

reflect.Type 描述类型，Value 描述值；Kind、IsValid、IsNil 和 CanSet 要先检查。

#### 适用边界

序列化、通用校验和框架 glue code 可用反射；业务核心优先具体类型。

#### 指针与可设置

Elem 只能对有效指针使用；未导出字段可能不可设置，错误要在运行时处理。

#### Agent schema

工具 schema 生成可用反射，但生成后仍需人工审查描述、范围和副作用。

#### 参考片段

```go
v := reflect.ValueOf(input)
if !v.IsValid() { return errors.New("invalid value") }
fmt.Println(v.Type(), v.Kind())
```

注意：不要用反射绕过类型系统来处理所有业务输入。

---

### 并发

goroutine、channel、select、同步和取消。

#### 并发模型

goroutine 是执行单元，channel 传递所有权或事件，锁保护共享不变量；三者可以组合。

#### 生命周期

每个 goroutine 都要有启动条件、退出条件和错误处理；context 是常用取消入口。

#### 竞态与死锁

go test -race 检测实际观察到的数据竞争；锁顺序、channel 关闭和等待顺序仍需设计。

#### Agent 并发

并行工具调用限制数量、预算和下游连接；一个请求取消要停止所有子任务。

#### 参考片段

```go
g, ctx := errgroup.WithContext(ctx)
for _, item := range items { item := item; g.Go(func() error { return run(ctx,item) }) }
return g.Wait()
```

#### goroutine 是可独立推进的任务

go f() 启动一个 goroutine；调用者继续执行。运行时调度多个任务，但执行顺序不保证。main 退出时，程序不会自动等待其他 goroutine 完成。

共享内存用互斥锁保护；通过 channel 传数据是另一种协作方式。无论采用哪种方式，都应明确任务何时结束、谁负责关闭、错误如何传回。

#### channel 的容量改变等待位置

无缓冲 channel 的发送与接收需要配对。缓冲 channel 在缓冲未满时可以完成发送，满了就等待接收释放空间。接收空 channel 会等待。

关闭后仍能读完缓冲里的值；读完再接收得到零值和 ok=false。向已关闭 channel 发送会 panic。通常由发送方在不再发送时关闭，而不是让接收方猜测。


```go
ch := make(chan int)
go func() {
    defer close(ch)
    ch <- 42
}()
for n := range ch {
    fmt.Println(n)
}
```

#### 并发必须有边界

大量工具请求不能无限制地启动 goroutine。worker pool、信号量或固定并发上限可以限制同时进行的工作。sync.WaitGroup 用于等待一组任务，sync.Mutex 保护共享临界区。

本实验按固定教学顺序安排两个角色，真实调度可能不同。观察“为什么阻塞”比记住某一次输出顺序更重要。

交互演示：网页的「并发」正文可操作。

注意：不要用 goroutine 数量掩盖没有背压和取消的系统。

---

### 模块

go.mod、go.sum、版本选择、replace 和工作区。

#### go.mod

module 声明导入路径和 Go 版本；require、replace 和 toolchain 共同影响构建。

#### 依赖操作

go get 升级，go mod tidy 清理，go mod verify 校验；升级前查看 API 变化和测试。

#### 工作区

多模块开发可用 go.work，但发布和 CI 仍应从目标模块验证，不要依赖本地 replace。

#### Agent 依赖

模型 SDK、tRPC-Agent-Go、exporter 和 MCP client 都要固定版本并记录兼容范围。

#### 参考片段

```bash
go mod init example.com/service
go get trpc.group/trpc-go/trpc-agent-go@v1.11.2
go mod tidy
```

注意：不要删除 go.sum 掩盖依赖冲突，也不要把本地 replace 带进生产构建。

---

### 测试

单元测试、表驱动测试、基准、模糊测试和测试替身。

#### 测试层次

单元测试验证纯逻辑，组件测试验证数据库/HTTP 边界，集成测试验证真实 provider，评测验证长期 Agent 质量。

#### 表驱动

输入、期望、错误和边界放在表中；测试名说明场景而不是实现细节。

#### 并发检查

go test -race 只能发现执行到的竞态；用确定性同步而不是 time.Sleep。

#### Agent 测试

固定模型事件测试 Runner、工具和 SSE；真实模型测试数量少且不依赖全文固定。

#### 参考片段

```go
func TestParseLimit(t *testing.T) {
    got, err := ParseLimit("20")
    if err != nil || got != 20 { t.Fatalf("got=%d err=%v", got, err) }
}
```

#### 包边界也是理解边界

小项目可从 main.go 开始，再按职责拆成工具、检索和 HTTP 入口。包之间不允许循环导入。不要一开始就制造很多没有职责的 utils 包。

函数优先接收它真正需要的数据。把外部模型封装在接口后，测试可用固定响应，不必每次联网花费 token。

#### 泛型复用的是类型模式

类型参数允许同一算法作用于一组类型。约束说明可以做哪些操作，例如 comparable 允许比较，适合 map 的键。并不是所有函数都需要泛型；业务输入输出结构体通常更容易理解。


```go
func Contains[T comparable](xs []T, target T) bool {
    for _, x := range xs {
        if x == target {
            return true
        }
    }
    return false
}
```

#### 测试描述外部可观察结果

测试文件以 _test.go 结尾，函数以 Test 开头。表驱动测试把输入和期望结果放在一起。并发代码配合 go test -race 检查运行中发生的数据竞争，但它不能证明所有并发路径都正确。


```go
func TestContains(t *testing.T) {
    cases := []struct {
        xs []int
        target int
        want bool
    }{
        {[]int{1, 2}, 2, true},
        {nil, 2, false},
    }
    for _, tc := range cases {
        if got := Contains(tc.xs, tc.target); got != tc.want {
            t.Errorf("got %v, want %v", got, tc.want)
        }
    }
}
```

注意：不要只测成功路径，也不要用过度 mock 隐藏真实协议。

---

### CGO

Go 与 C 的边界、指针规则和构建环境。

#### 边界成本

CGO 引入 C 编译器、链接和运行时边界，交叉编译、容器镜像和崩溃排查更复杂。

#### 指针规则

Go 指针传给 C 有生命周期和保留规则；C 不能随意保存 Go 指针或回调已释放内存。

#### 封装策略

把 CGO 隔离成 adapter，业务只依赖 Go 接口；构建标签区分平台实现。

#### 评估方式

先用基准证明收益，再评估部署、许可证、故障和安全成本；能用纯 Go 就不引入。

#### 参考片段

```go
// adapter 包隔离 C 类型，业务层只依赖普通 Go 接口
```

注意：不要因为 C API 看起来更快就默认引入 CGO。

---

### 性能分析

CPU、内存、阻塞、互斥和 trace 分析。

#### profile 类型

CPU 找热点，heap 找分配和存活对象，goroutine 看阻塞，mutex/block 看同步等待，trace 看调度和网络。

#### 采集方式

线上 profile 要控制权限、采样和开销；测试和 benchmark 先建立可复现基线。

#### 解释数据

模型等待、数据库等待和本地 CPU 热点要分开；一次 profile 不能代表所有负载。

#### Agent 优化

先限制 prompt、工具返回和并发，再根据 profile 优化编码、缓存和分配。

#### 参考片段

```bash
go test -bench=. -benchmem ./...
go tool pprof cpu.out
```

注意：不要凭感觉优化，也不要把远程模型延迟误判成本地 Go 热点。

---

## 标准库

### 标准库概览

包文档、约定、错误和兼容性。

#### 从数据流认识标准库

标准库不是必须逐个背下来的函数目录。可以沿一次任务的数据流组织它：os 打开文件，io 定义输入输出边界，bufio 提供缓冲，encoding 处理格式，errors 表达失败，context 传递取消，net/http 连接远端服务。先判断输入是什么、数据有多大、谁拥有资源，再选择包，比先找一个“大而全”的依赖更容易控制行为。标准库随工具链分发，无需单独 go get；具体 API 是否可用仍取决于项目支持的最低 Go 版本。

阅读文档时依次确认参数单位、零值是否可用、返回值是否可能部分成功、并发安全和生命周期。例如 time.Duration 是纳秒计数的类型，io.Reader 返回的是本次读取长度，bytes.Buffer 的零值可直接写入，而 os.File 是需要明确关闭的操作系统资源。标准库只提供机制，通常不会替你规定数据长度、业务重试或配置优先级。

#### io.Reader 的契约：先处理数据，再处理错误

Reader 的 Read(p []byte) 返回 n 和 err，n 表示写入 p 的有效字节数。一次调用可能只读到少量数据，也允许同时返回 n>0 与 io.EOF。错误不等于“这次没有数据”；如果先检查 err 然后退出，恰好处于文件末尾的一段数据就可能丢失。调用方只能使用 p[:n]，缓冲区剩余部分可能包含上次内容。

只想搬运全部内容时优先使用 io.Copy；读取恰好 N 个字节用 io.ReadFull；读取到 EOF 且输入已受限时可用 io.ReadAll。它们的错误语义不同：io.Copy 将正常 EOF 视为成功；io.ReadFull 在完全没有数据时返回 EOF、读取不足时返回 ErrUnexpectedEOF。协议头通常需要 ReadFull，文件复制通常需要 Copy。


```go
buf := make([]byte, 8)
for {
    n, err := r.Read(buf)
    consume(buf[:n]) // 即使 err 非 nil，也先处理数据
    if err == io.EOF { break }
    if err != nil { return err }
}
```

#### 接口组合与数据上限

io.LimitReader(r, n) 只允许下游观察最多 n 字节，它并不会告诉你原输入是否超限。若规则是“允许至多 N 字节，超过就拒绝”，可读取 N+1 字节再比较长度；直接读取 N 字节并接受，会把超长输入误当合法截断内容。HTTP 请求入口通常选择 http.MaxBytesReader，它在读取超过限制时返回具体错误。

io.TeeReader 会把实际被读取的数据同步写入另一个 Writer，适合一边处理一边计算哈希或保存审计副本；它不是后台广播，也不会提前读取。目标 Writer 写入失败会使读取失败。MultiReader 按顺序拼接多个 Reader，MultiWriter 则依序写入多个 Writer；任何一个目标失败都可能导致其他目标已收到部分数据，不能把它当事务。


```go
const max = 1024
b, err := io.ReadAll(io.LimitReader(r, max+1))
if err != nil { return err }
if len(b) > max { return fmt.Errorf("input exceeds %d bytes", max) }
```

#### 错误链、资源所有权和关闭时机

用 fmt.Errorf("read config: %w", err) 为错误增加上下文，再用 errors.Is 判断 EOF、取消或文件不存在等类别，用 errors.As 提取携带字段的具体错误。用字符串比较错误文本会依赖格式和平台；直接 err == target 则可能看不到包装链中的原因。只应在真正需要增加操作信息的位置包装，避免每一层重复记录同一个错误。

资源在创建成功后立即安排关闭，且由拥有生命周期的那一层负责。defer 放在长循环内会等函数退出才释放文件，应将每次迭代提取为函数或及时 Close。读取文件时关闭错误经常次要；写文件时最后的缓冲 Flush、文件 Close 可能暴露延迟错误，需要检查。defer 并不意味着错误可以忽略，也不会替你完成 bufio.Writer 的 Flush。

#### 可替换边界与可验证行为

下面的例子让摘要函数只接收 io.Writer 和 io.Reader，因此输入可以来自字符串、文件或网络，输出可以来自 bytes.Buffer、文件或响应。这种小接口使测试不需要真正访问磁盘。不要为了抽象而把每个结构都变成接口：只有调用方确实需要替换行为时，接口才提供价值，且通常由使用方声明最小方法集合。

go doc io.Reader 能快速查看本地安装版本的契约，go env GOROOT 可以定位对应源代码和测试，pkg.go.dev 则用于公开文档及版本浏览。代码能够编译只说明符号存在，不能证明超时、EOF、短写和取消边界正确。示例故意保持输入有限，真实程序应先定义最大字节数和错误处理策略，再复用这里的数据流组合。

#### 完整示例与运行结果

运行后输出 bytes=12 text=Go 标准库。数字是 UTF-8 字节数：Go 占 2 字节、空格占 1 字节、三个汉字占 9 字节。MultiReader 顺序提供两段输入，io.Copy 遇到最后的 EOF 正常结束，因此 err 为 nil。


```go
package main
import (
 "bytes"
 "fmt"
 "io"
 "strings"
)
func copyText(dst io.Writer, src io.Reader) (int64, error) {
 n, err := io.Copy(dst, src)
 if err != nil { return n, fmt.Errorf("copy text: %w", err) }
 return n, nil
}
func main() {
 var out bytes.Buffer
 input := io.MultiReader(strings.NewReader("Go"), strings.NewReader(" 标准库"))
 n, err := copyText(&out, input)
 if err != nil { panic(err) }
 fmt.Printf("bytes=%d text=%s\n", n, out.String())
}
```

在手册根目录运行：

```bash
go run ./examples/reference/stdlib/std-intro/main.go
```

预期输出：

```text
bytes=12 text=Go 标准库
```

注意：反例：if err != nil { return err }; use(p[:n]) 会丢掉 n>0、err==io.EOF 的最后一块。必须先消费有效数据，再判断错误。

参考：io 官方文档 — https://pkg.go.dev/io

参考：errors 官方文档 — https://pkg.go.dev/errors

---

### 编码与解码

json、xml、csv、gob 和文本编码。

#### 编码格式与 JSON 的类型映射

encoding 是多个格式包的入口，不是一个统一的序列化器。encoding/json 处理结构化文本，encoding/base64 将二进制转为可打印文本，encoding/hex 常用于十六进制诊断，encoding/binary 面向明确的字节序。Base64 和十六进制只是编码，不提供加密、完整性或保密；还原它们不需要密钥。选择协议前要明确接收端、兼容策略和大小开销。

JSON 对象通常映射为结构体或 map[string]T；数组映射为切片，null 可以映射为 nil 指针。结构体字段必须导出才会参与编码。json:"name" 改名，json:"-" 忽略字段，omitempty 忽略 false、0、空字符串、nil 指针以及长度为零的数组、切片和映射；它不能表达所有“没有提供”的业务含义，例如普通 int 无法区分缺失和明确写入零。

#### 用指针表达缺失，别依赖隐式业务校验

在补丁请求中可以使用 *int：字段不存在时指针为 nil，字段为 0 时指向零。可是首次解析到零值结构时，显式 null 也会得到 nil，所以需要区分“缺失、null、具体值”三态时，应使用自定义 UnmarshalJSON 或先解析 json.RawMessage 字段并检查键是否存在。复用旧结构体接收新请求还有残留字段问题，通常应为每次解码创建新变量。

Marshal 返回 []byte 和 error，无法编码函数、通道、循环引用和 NaN/Inf 浮点值。[]byte 默认编码成 Base64 字符串而不是整数数组；time.Time 自带 JSON 方法。解析成功只证明输入可以转换为目标类型，年龄负数、地址为空、字段组合冲突仍要在解析后验证，不要把类型检查当成领域规则。


```go
type Patch struct {
    Limit *int `json:"limit,omitempty"`
}
var p Patch
if err := json.Unmarshal([]byte(`{"limit":0}`), &p); err != nil { return err }
fmt.Println(p.Limit != nil, *p.Limit) // true 0
```

#### Decoder 是流解析器，单对象接口应检查尾部

Decoder.Decode 读取一个 JSON 值，并不保证整个 Reader 只有一个值。输入 {"name":"Ada"} {"name":"Bob"} 第一次 Decode 可以成功；若接口只允许单对象，第二次 Decode 必须返回 io.EOF。不能用 dec.More() 来判断顶层是否还有文档：More 的含义是当前数组或对象中是否存在下一项。

DisallowUnknownFields 只对解码到结构体时未匹配字段报错，可以捕获拼写错误，但也会拒绝客户端新增字段，采用前应明确接口兼容策略。默认解码对重复键按出现顺序处理，后值可能替换或合并先值；它不是严格禁止重复键的验证器。输入大小必须在 Reader 外层限制，不能在 ReadAll 后才限制内存占用。


```go
dec := json.NewDecoder(strings.NewReader(`{"name":"Ada"}`))
dec.DisallowUnknownFields()
var dst struct { Name string `json:"name"` }
if err := dec.Decode(&dst); err != nil { return err }
var extra any
if err := dec.Decode(&extra); err != io.EOF {
    if err == nil { return errors.New("multiple JSON values") }
    return fmt.Errorf("trailing input: %w", err)
}
```

#### 大整数、延迟解析和流式输出

解码到 any 时，JSON 数字默认变成 float64，大于 2 的 53 次方的整数可能失去精度。对订单号、数据库 ID 应使用明确的 int64 字段，或启用 Decoder.UseNumber 后得到 json.Number，再调用 Int64 并检查溢出。UseNumber 保留字面量但不会扩大 int64 的范围；任意精度需求可以交给 math/big 或使用协议约定的十进制字符串。

json.RawMessage 可以先保存一段合法 JSON，再根据 type 字段解析为不同的载荷，适合消息信封；它不替代第二阶段的字段验证。Encoder.Encode 会在输出后追加换行，连续调用形成多个 JSON 值，常用于 NDJSON；这不是一个 JSON 数组。通过网络实时发送还涉及缓冲刷新，编码器本身并不负责 HTTP Flush 或传输重试。

#### 转义、二进制编码与错误定位

JSON 字符串中出现 HTML 字符时，Marshal 默认会把 <、>、& 转义，以降低嵌入 HTML 脚本时的风险。Encoder.SetEscapeHTML(false) 只调整该行为，并不意味着可以把整个 JSON 字符串直接插入任意 HTML 上下文。JSON 编码与 HTML 模板转义服务于不同语法层，不能互相替代。

base64.StdEncoding 和 URLEncoding 使用不同字母表；RawURLEncoding 不带填充，常用于 URL 中的标识符，双方必须约定一致。解码时检查错误，不能忽略失败后返回的部分数据。诊断 JSON 时可用 errors.As 提取 *json.SyntaxError 的字节偏移、*json.UnmarshalTypeError 的字段及目标类型，同时避免把完整敏感请求写进日志。

#### XML：标签、属性、命名空间与流式处理

encoding/xml 适合与已有 XML 接口、配置文件或文档格式交换结构化数据。Marshal(v) 把导出字段编码成 XML，Unmarshal(data,&v) 把元素映射回结构体；XMLName 字段可以记录并约束根元素名称。标签 xml:"id,attr" 把 ID 映射为属性，xml:"title" 映射为子元素，xml:",chardata" 接收元素直接包含的文本，xml:"items>item" 表示嵌套路径。多个同名元素通常对应切片。属性、元素和直接文本是不同结构，不能只把 json 标签逐字改为 xml。

命名空间按 URI 识别，前缀只是当前文档的别名。下面 XMLName 的 xml:"urn:books book" 要求根元素的 Space 为 urn:books、Local 为 book；子标题同样指定命名空间。输入 b:book 的 b 经 xmlns:b 解析后得到 URI，更换前缀而保持 URI 不会改变语义。重新 Marshal 可以使用默认命名空间或不同前缀，不能靠字节完全相等判断 XML 往返正确。默认命名空间不自动作用于无前缀属性，所以示例 id 使用普通属性标签。

示例第一行输出 namespace=urn:books name=book id=7 title="Go & XML"，说明实体引用被解码且属性转成整数。第二行 roundtrip=true 比较的是结构体内容；第三行 wrongNamespaceRejected=true 说明 XMLName 中声明的命名空间约束生效。Marshal 不自动添加 XML 声明；需要声明时可以显式添加 xml.Header。map、channel 和函数不是通用 XML 映射对象，编码时应检查 UnsupportedTypeError；结构体路径标签互相冲突也可能产生 TagPathError。

大文档使用 xml.NewDecoder(reader) 配合 Token 或 DecodeElement，在目标起始元素出现时逐项解析，并在外层限制输入字节数。Token 的 Name.Space 是解析后的命名空间 URI，若保存 CharData 等令牌跨下一次读取使用，要按文档复制，避免底层缓冲复用。默认 Strict 进行 XML 语法检查，但不是 XSD 校验器，也不会拒绝所有未声明前缀；无法匹配到结构字段的正常元素通常被丢弃。因此根名正确不等于业务 schema 完整，必填字段、未知元素和范围要按接口另行校验。不要用 ,innerxml 接收原始文本后又把它当成已经净化的 HTML。

本节完整程序实测输出：
namespace=urn:books name=book id=7 title="Go & XML"
roundtrip=true
wrongNamespaceRejected=true


```go
package main

import (
	"encoding/xml"
	"fmt"
)

type Book struct {
	XMLName xml.Name `xml:"urn:books book"`
	ID      int      `xml:"id,attr"`
	Title   string   `xml:"urn:books title"`
}

func main() {
	input := `<b:book xmlns:b="urn:books" id="7"><b:title>Go &amp; XML</b:title></b:book>`
	var book Book
	if err := xml.Unmarshal([]byte(input), &book); err != nil {
		panic(err)
	}
	fmt.Printf("namespace=%s name=%s id=%d title=%q\n",
		book.XMLName.Space, book.XMLName.Local, book.ID, book.Title)

	encoded, err := xml.Marshal(book)
	if err != nil {
		panic(err)
	}
	var restored Book
	if err := xml.Unmarshal(encoded, &restored); err != nil {
		panic(err)
	}
	fmt.Printf("roundtrip=%t\n", restored == book)

	var wrong Book
	err = xml.Unmarshal([]byte(`<book xmlns="urn:other" id="7"/>`), &wrong)
	fmt.Printf("wrongNamespaceRejected=%t\n", err != nil)
}
```

#### CSV：引用字段、固定列数与 Flush 错误

encoding/csv 面向按行组织的表格交换，但一条记录可以跨多条物理文本行。逗号、双引号和换行都可能合法地出现在引用字段内部，不能用 strings.Split 按逗号或换行自己拆。Reader.Read 返回 []string 和 error，每个字段都是文本；标题行不会自动变成结构体字段，数字、日期和枚举还要显式转换。NewReader 默认使用逗号，可在读取前修改 Comma；Reader 将输入的 CRLF 规范化为 LF，空白行会被忽略，字段内空格默认保留。

FieldsPerRecord>0 要求每条记录具有指定字段数，==0 会用第一条记录建立后续列数要求，<0 才允许不定列数。示例明确要求两列，读到只有一列的记录后用 errors.Is(err,csv.ErrFieldCount) 识别错误。Read 在部分错误下可能同时返回已解析字段，不能因为 row 非空就接受为有效记录；导入流程应连同错误一起处理。可以 errors.As 提取 *csv.ParseError 的 StartLine、Line、Column 定位故障。ReuseRecord=true 会允许下一次 Read 复用 []string 存储，若要保留历史记录，必须先复制切片。

Writer.Write 接收一行 []string，按 CSV 规则自动引用和转义，默认写 LF；需要 CRLF 时在写入前设置 UseCRLF=true。Writer 带缓冲，Write 返回 nil 只代表当次处理尚未发现错误，不保证所有数据已到达底层 Writer。循环中检查每次 Write 的错误，结束时调用 Flush，然后调用 Error 检查延迟错误；Flush 本身没有返回值。示例的 brokenWriter 证明一个短记录可以先缓冲成功，真正 io.ErrClosedPipe 直到 Flush 后才出现在 Error 中。若底层是文件，仍需检查文件 Close，CSV Flush 不等于磁盘持久化。

前半段输出的 wire 使用 %q 显示完整转义，第二条记录里的逗号和换行被包在同一个引用字段中。Reader 还原为两行记录，第二行第二个字段为 Go, CSV\nline 2；末尾三项依次为 wrongColumns=true、writeBuffered=true、flushFailed=true。这种往返保留字段内容而不保留原始引用风格。CSV 编码也不会保护电子表格公式：如果导出给表格软件，类似 =SUM(...) 的不可信字段需要依据目标软件制定文本化策略，不能把正确加引号当作公式风险已消除。

本节完整程序实测输出：
wire="name,note\nAda,\"Go, CSV\nline 2\"\n"
row=["name" "note"]
row=["Ada" "Go, CSV\nline 2"]
wrongColumns=true
writeBuffered=true
flushFailed=true


```go
package main

import (
	"bytes"
	"encoding/csv"
	"errors"
	"fmt"
	"io"
	"strings"
)

type brokenWriter struct{}

func (brokenWriter) Write(p []byte) (int, error) { return 0, io.ErrClosedPipe }

func main() {
	var out bytes.Buffer
	writer := csv.NewWriter(&out)
	for _, row := range [][]string{{"name", "note"}, {"Ada", "Go, CSV\nline 2"}} {
		if err := writer.Write(row); err != nil {
			panic(err)
		}
	}
	writer.Flush()
	if err := writer.Error(); err != nil {
		panic(err)
	}
	fmt.Printf("wire=%q\n", out.String())

	reader := csv.NewReader(strings.NewReader(out.String()))
	reader.FieldsPerRecord = 2
	for {
		row, err := reader.Read()
		if err == io.EOF {
			break
		}
		if err != nil {
			panic(err)
		}
		fmt.Printf("row=%q\n", row)
	}

	bad := csv.NewReader(strings.NewReader("a,b\nonly-one\n"))
	bad.FieldsPerRecord = 2
	if _, err := bad.Read(); err != nil {
		panic(err)
	}
	_, err := bad.Read()
	fmt.Printf("wrongColumns=%t\n", errors.Is(err, csv.ErrFieldCount))

	failed := csv.NewWriter(brokenWriter{})
	err = failed.Write([]string{"buffered"})
	fmt.Printf("writeBuffered=%t\n", err == nil)
	failed.Flush()
	fmt.Printf("flushFailed=%t\n", errors.Is(failed.Error(), io.ErrClosedPipe))
}
```

#### gob：Go 类型契约、接口注册与流生命周期

encoding/gob 是适合 Go 程序之间传输类型化数据的二进制流，常用于受控进程通信或内部缓存。NewEncoder(io.Writer).Encode(value) 写入类型描述和数据，NewDecoder(io.Reader).Decode(&target) 按接收类型恢复。它不是跨语言公共接口的默认格式，也不是通过结构体内存拷贝实现的序列化；指针会被展开为值，不承诺恢复原有对象地址或共享指针图。顶层 nil 指针不能编码，循环引用也不应交给 gob 自动处理；函数和通道不能作为可传输值。

结构体通过导出字段名匹配，而不是要求两端 Go 类型名称、包路径或字段顺序完全一致。发送端多出的字段可被忽略，接收端缺少来源的字段不会被主动清空；所以通常解码到新建零值对象，复用已有对象前应明确重置。签名整数可以在 int32 与 int64 等宽度之间转换，但实际值必须放得下；有符号与无符号整数属于不同种类，不因为值当前为正就自动兼容。没有可匹配字段或同名字段类型不兼容时会报错。示例把 OldRecord 解到 NewRecord，得到 id=7 name=Ada note=""，最后证明 int32 到 uint64 的字段映射被拒绝。

接口字段需要额外的动态类型约定。示例 Envelope.Payload 为 any，实际装入 Event，调用 gob.Register(Event{}) 让流中的具体类型名能在解码端找到 Go 类型；真实的两个进程需要双方都注册相应类型。仅仅把一个结构体直接 Encode，并不等于已经把它编码为接口值；接口数据应通过接口字段或接口变量指针表达。解码后仍需类型断言并检查 ok，不能根据外部字符串直接执行任意类型行为。Register 是进程级注册且要求名称与类型映射一致，应在初始化阶段完成，不要在每次请求内重复随意注册。

一个连续流应复用同一 Encoder/Decoder，使后续值能引用此前发送的类型定义。若像示例一样重置 bytes.Buffer 并把内容当成独立消息，就同时重建 Encoder 和 Decoder；仅重置缓冲却复用旧 Encoder，可能使新消息缺失类型定义，无法被全新的 Decoder 解开。不要依赖 gob 的原始字节作为稳定签名或跨实现的规范形式。官方文档明确 gob Decoder 未针对恶意输入充分加固，内部长度检查不可配置；外层 LimitReader 只能限制输入量，不能单独保证解码分配和 CPU 成本安全，面向不可信网络输入应选择合适的协议与资源隔离。

本节完整程序实测输出：
id=7 name=Ada note=""
type=main.Event action=created
signedToUnsignedRejected=true


```go
package main

import (
	"bytes"
	"encoding/gob"
	"fmt"
)

type OldRecord struct {
	ID   int32
	Name string
}
type NewRecord struct {
	ID   int64
	Name string
	Note string
}
type Event struct{ Action string }
type Envelope struct{ Payload any }

func main() {
	var wire bytes.Buffer
	encoder := gob.NewEncoder(&wire)
	if err := encoder.Encode(OldRecord{ID: 7, Name: "Ada"}); err != nil {
		panic(err)
	}
	var dst NewRecord
	if err := gob.NewDecoder(&wire).Decode(&dst); err != nil {
		panic(err)
	}
	fmt.Printf("id=%d name=%s note=%q\n", dst.ID, dst.Name, dst.Note)

	// 接口中的具体类型须由两端在解码之前约定并注册；这里两端同进程。
	gob.Register(Event{})
	wire.Reset()
	encoder = gob.NewEncoder(&wire)
	if err := encoder.Encode(Envelope{Payload: Event{Action: "created"}}); err != nil {
		panic(err)
	}
	var envelope Envelope
	if err := gob.NewDecoder(&wire).Decode(&envelope); err != nil {
		panic(err)
	}
	event, ok := envelope.Payload.(Event)
	if !ok {
		panic("unexpected payload type")
	}
	fmt.Printf("type=%T action=%s\n", envelope.Payload, event.Action)

	wire.Reset()
	encoder = gob.NewEncoder(&wire)
	if err := encoder.Encode(OldRecord{ID: 7}); err != nil {
		panic(err)
	}
	var incompatible struct{ ID uint64 }
	err := gob.NewDecoder(&wire).Decode(&incompatible)
	fmt.Printf("signedToUnsignedRejected=%t\n", err != nil)
}
```

#### 完整示例与运行结果

第一行证明大整数完整保留，limit 的指针非 nil，因此知道客户端显式传了零。第二行的 limit 不会被 omitempty 删除，因为被判断的是指针是否为空。例子输入来自固定字符串，生产 HTTP 入口还需为请求体设置独立字节上限。


```go
package main
import (
 "encoding/json"
 "fmt"
 "io"
 "strings"
)
func main() {
 dec := json.NewDecoder(strings.NewReader(`{"id":9007199254740993,"limit":0}`))
 dec.UseNumber()
 dec.DisallowUnknownFields()
 var req struct {
  ID json.Number `json:"id"`
  Limit *int `json:"limit,omitempty"`
 }
 if err := dec.Decode(&req); err != nil { panic(err) }
 var extra any
 if err := dec.Decode(&extra); err != io.EOF { panic("unexpected trailing input") }
 id, err := req.ID.Int64()
 if err != nil { panic(err) }
 fmt.Printf("id=%d explicitZero=%t\n", id, req.Limit != nil && *req.Limit == 0)
 b, err := json.Marshal(req)
 if err != nil { panic(err) }
 fmt.Println(string(b))
}
```

在手册根目录运行：

```bash
go run ./examples/reference/stdlib/std-encode/main.go
```

预期输出：

```text
id=9007199254740993 explicitZero=true
{"id":9007199254740993,"limit":0}
```

注意：反例：把含 9007199254740993 的 JSON 先解到 any，再把 float64 转 int64，精度早已丢失；请使用 int64 字段、UseNumber 或字符串协议。

参考：encoding/json 官方文档 — https://pkg.go.dev/encoding/json

参考：encoding/base64 官方文档 — https://pkg.go.dev/encoding/base64

参考：encoding/xml 官方文档 — https://pkg.go.dev/encoding/xml

参考：encoding/csv 官方文档 — https://pkg.go.dev/encoding/csv

参考：encoding/gob 官方文档 — https://pkg.go.dev/encoding/gob

---

### 命令行 flag

参数声明、默认值、Usage 和子命令边界。

#### 定义参数与指针返回值

flag.String(name, defaultValue, usage) 返回 *string，解析会更新这个指针指向的值；StringVar 则把值写入已有变量。Int、Bool、Duration 遵循相同模式，Duration 接受 250ms、2s、1m30s 等 time.ParseDuration 语法。默认值是没有提供参数时的结果，不应被用作“必填已经完成”的证据，必填字段仍需在解析后验证。

包级 flag.Parse 使用全局 CommandLine 并读取 os.Args[1:]，适合简单 main。可以复用的解析函数更适合新建 FlagSet，它能接收显式 []string，避免测试之间共享全局注册表。先完成全部定义再调用 Parse；重复注册相同名称会 panic，因此不要在每次请求或每次函数调用时向全局集合追加同名 flag。

#### 布尔值、位置参数与停止规则

标准库支持 -name=value、-name value 和 --name=value。布尔参数可以只写 -verbose 表示 true，若要显式写 false，应使用 -verbose=false；-verbose false 中的 false 会被当作位置参数，而不是布尔参数值。负数作为带值参数的下一项可以正常解析，但参数名称本身不能以数字需求来推断。

解析遇到第一个非 flag 参数即停止，或者遇到单独的 -- 停止。后面的内容全部进入 Args，不会继续扫描后续选项。例如 tool input.txt -n=3 不会把 -n 解析出来。这一点与允许参数穿插的位置解析器不同。应在帮助文本里说明选项写在位置参数之前，且使用 NArg/Args 检查位置参数数量。


```go
fs := flag.NewFlagSet("demo", flag.ContinueOnError)
verbose := fs.Bool("verbose", false, "print details")
if err := fs.Parse([]string{"-verbose", "file.txt", "-n=3"}); err != nil { return err }
fmt.Println(*verbose, fs.Args()) // true [file.txt -n=3]
```

#### 选择错误策略并处理帮助请求

NewFlagSet 的第二个参数是错误处理策略：ContinueOnError 返回 error，ExitOnError 会终止进程，PanicOnError 会 panic。库和测试应优先 ContinueOnError，把是否退出交给 main；否则解析一个错误选项可能直接结束整个测试进程。SetOutput 可以把诊断和帮助输出重定向到 stderr 或测试缓冲区。

用户请求 -h 或 -help 且没有自行定义同名参数时，会触发帮助并返回 flag.ErrHelp。它应作为正常帮助路径处理，而非打印“系统错误”。解析错误和业务校验错误要区分：-n=abc 是类型错误，-n=-3 可以解析成功但违反范围；应将允许范围、默认值和单位写进 usage，让失败能够被用户修正。

#### 子命令使用独立 FlagSet

flag 没有完整的子命令框架，但可以由 main 取出第一个位置作为命令名，再把剩余参数传入该命令自己的 FlagSet。这样 run 的 -timeout 与 export 的 -format 不会冲突，也能分别输出帮助。全局参数若放在子命令之前，应先定义清楚切分规则，不能对同一参数切片反复调用不同解析器碰运气。

对重复出现的列表参数可以实现 flag.Value，String 提供默认值的显示，Set 每次收到一段文本时解析并追加。Set 要返回有意义的错误；它不应在解析阶段启动网络连接或写文件，否则后面的参数失败时会留下副作用。简单的一次性验证也可以用 FlagSet.Func，但仍建议在解析结束后统一执行实际操作。


```go
type names []string
func (n *names) String() string { return strings.Join(*n, ",") }
func (n *names) Set(s string) error {
    if strings.TrimSpace(s) == "" { return errors.New("name must not be empty") }
    *n = append(*n, s)
    return nil
}
// fs.Var(&list, "name", "repeatable name")
```

#### 配置优先级要保留“是否显式设置”

命令行、环境变量和配置文件的优先级由程序决定，flag 不会自动合并。常见规则是命令行覆盖环境变量，环境变量覆盖文件，文件覆盖默认值。如果直接用 if value == default 判断用户是否提供，会把显式设置为默认值的情况误判；可通过 Visit 遍历实际出现过的参数，建立显式设置集合后再做合并。

不要把口令作为普通参数的默认值或在 PrintDefaults 中展示，命令行还可能出现在进程列表和 shell 历史里。处理配置时将解析与使用分开：parse(args) 返回经过范围校验的 Config，run(config) 才执行工作。下面例子固定传入测试参数来展示可重复结果，真实入口只需改为 parse(os.Args[1:])。

#### 完整示例与运行结果

第一轮把文本参数解析为 int、Duration 和 bool，并单独保存位置参数。第二轮的零能成功转换成 int，但被领域范围检查拒绝。这说明语法解析、类型转换和业务校验是三个不同层次。示例主动丢弃帮助诊断以保持输出稳定，正式 CLI 应输出到 stderr。


```go
package main
import (
 "flag"
 "fmt"
 "io"
 "time"
)
type Config struct { N int; Timeout time.Duration; Verbose bool; File string }
func parse(args []string) (Config, error) {
 var c Config
 fs := flag.NewFlagSet("copy", flag.ContinueOnError)
 fs.SetOutput(io.Discard)
 fs.IntVar(&c.N, "n", 1, "copies, 1..10")
 fs.DurationVar(&c.Timeout, "timeout", time.Second, "operation timeout")
 fs.BoolVar(&c.Verbose, "verbose", false, "print details")
 if err := fs.Parse(args); err != nil { return c, err }
 if c.N < 1 || c.N > 10 { return c, fmt.Errorf("n must be 1..10") }
 if c.Timeout <= 0 { return c, fmt.Errorf("timeout must be positive") }
 if fs.NArg() != 1 { return c, fmt.Errorf("exactly one file required") }
 c.File = fs.Arg(0)
 return c, nil
}
func main() {
 c, err := parse([]string{"-n=2", "-timeout=250ms", "-verbose=false", "input.txt"})
 if err != nil { panic(err) }
 fmt.Printf("n=%d timeout=%s verbose=%t file=%s\n", c.N, c.Timeout, c.Verbose, c.File)
 _, err = parse([]string{"-n=0", "input.txt"})
 fmt.Println(err)
}
```

在手册根目录运行：

```bash
go run ./examples/reference/stdlib/std-flag/main.go
```

预期输出：

```text
n=2 timeout=250ms verbose=false file=input.txt
n must be 1..10
```

注意：反例：-verbose false 会把 false 当位置参数并停止继续解析。显式关闭布尔选项写 -verbose=false；选项应放在位置参数前。

参考：flag 官方文档 — https://pkg.go.dev/flag

---

### HTTP

客户端、服务端、Handler、中间件、超时和连接复用。

#### Handler 的输入输出与状态提交

http.Handler 只有 ServeHTTP(ResponseWriter, *Request) 一个方法，HandlerFunc 将普通函数适配成处理器。Request 包含方法、URL、Header、Body 和 Context；ResponseWriter 写响应头、状态码及内容。必须先设置 Header，再 WriteHeader，最后写 body；第一次 Write 若尚未提交状态会隐式提交 200，之后再写 404 无法撤销已经发出的状态。

处理器应在返回前完成同步响应。任意启动 goroutine 在 ServeHTTP 返回后继续写 ResponseWriter 都是不可靠的。读取请求体要限制大小并处理错误，JSON 请求还要校验媒体类型和字段。服务端负责关闭进入的请求体，通常不需要处理器单独管理该生命周期；客户端收到的响应体则由调用者关闭。

#### 路由规则与入口的错误边界

ServeMux 负责路径匹配。Go 1.22 起支持 "GET /items/{id}" 这样的带方法模式与 r.PathValue("id")；旧工具链或启用旧 mux 兼容行为时规则不同。为了支持更老的代码，仍可以注册路径并在 Handler 内检查 Method。HEAD 与 GET 的关系、末尾斜线的重定向和冲突模式的 panic 都值得在路由测试里明确，不要只测试一条成功路径。

http.Error 会写入指定状态和文本消息，调用后应该 return，否则后续成功内容可能追加到错误响应。错误信息应是客户端能够理解的说明，内部堆栈和数据库错误另行记录。请求 Context 在客户端断开、HTTP/2 取消或处理器结束时会被取消；将它传给下游，才能让一次取消传播到真正消耗资源的操作。


```go
mux.HandleFunc("/items", func(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        w.Header().Set("Allow", http.MethodGet)
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    io.WriteString(w, `{"items":[]}`)
})
```

#### Client、Transport 和响应体所有权

http.Client 可以被多个 goroutine 并发使用，应长期复用。Transport 维护连接池、代理和 TLS 等传输行为，也应复用；要定制默认行为可从 http.DefaultTransport.(*http.Transport).Clone() 开始，避免修改全局对象影响其他调用者。Client.Timeout 是整个请求的总时间上限，包括连接、重定向和读取 body，流式响应通常需要不同的时间策略。

Do 返回 err==nil 不表示 HTTP 状态成功，404 和 500 仍是可读取的正常响应。获得响应后立即安排 Body.Close，并对状态码分类。对于 HTTP/1.x，读取到 EOF 并关闭有利于复用连接，但是否最终复用还受服务端和传输状态影响；不能说“调用 Close 一定复用”。超大或不可信错误体可以限量读取后关闭，接受放弃该连接复用的代价。

#### 总超时、连接超时和服务器生命周期

客户端 NewRequestWithContext 把业务截止时间和取消信号绑定到请求。拨号超时、TLS 握手超时、响应头超时与总超时限制的是不同阶段；只设 Dialer.Timeout 不能防止对端连接后永远不发送响应。不要给所有请求盲目自动重试：非幂等操作失败时，服务端可能已执行成功，仅客户端没收到响应。

服务器用显式 http.Server 设置 ReadHeaderTimeout 和 IdleTimeout，必要时设置 ReadTimeout、WriteTimeout。它们与流式上传下载可能冲突，应按接口设计。Shutdown(ctx) 会停止接收新连接、关闭空闲连接并等待活动请求结束；超过 deadline 会返回错误，它不等于立即杀掉所有活动连接。ListenAndServe 在正常关闭时返回 http.ErrServerClosed，应与启动故障区分。


```go
srv := &http.Server{
    Addr: ":8080", Handler: mux,
    ReadHeaderTimeout: 5 * time.Second,
    IdleTimeout: 60 * time.Second,
}
// 退出信号到来时：
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil { log.Printf("shutdown: %v", err) }
```

#### 请求上限、流式刷新和可重复测试

http.MaxBytesReader(w, r.Body, limit) 能限制请求体实际读取量，超出时返回 *http.MaxBytesError；Content-Length 只能用于提前拒绝，不能替代读取时的上限，因为分块传输可能没有长度。响应也应限量读取。若仅使用 LimitReader，采用 limit+1 字节的策略才能识别超限，而不是默默接受截断文档。

httptest.NewRecorder 适合直接调用 Handler 测试状态和 body，httptest.NewServer 会启动本地真实 HTTP 服务器，适合验证 Client 行为。下面选后者并绑定自动分配端口，不依赖外网。SSE 等逐条响应还需要 http.Flusher 或 ResponseController.Flush；写入成功并不保证代理已转发，循环必须同时监测请求取消并检查写错误。

#### 完整示例与运行结果

执行输出 status=200 body=hello Go。请求通过本地临时端口完成真实连接，客户端确认状态后读取最多 1025 字节，并判断是否超过允许的 1024 字节。关闭响应体、取消 context 和关闭测试服务器都由明确的 defer 管理。


```go
package main
import (
 "context"
 "fmt"
 "io"
 "net/http"
 "net/http/httptest"
 "time"
)
func main() {
 mux := http.NewServeMux()
 mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
  if r.Method != http.MethodGet { http.Error(w, "method", 405); return }
  w.Header().Set("Content-Type", "text/plain; charset=utf-8")
  fmt.Fprint(w, "hello Go")
 })
 srv := httptest.NewServer(mux)
 defer srv.Close()
 client := srv.Client()
 client.Timeout = time.Second
 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
 defer cancel()
 req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/hello", nil)
 if err != nil { panic(err) }
 resp, err := client.Do(req)
 if err != nil { panic(err) }
 defer resp.Body.Close()
 if resp.StatusCode != http.StatusOK { panic(resp.Status) }
 b, err := io.ReadAll(io.LimitReader(resp.Body, 1025))
 if err != nil { panic(err) }
 if len(b) > 1024 { panic("response too large") }
 fmt.Printf("status=%d body=%s\n", resp.StatusCode, b)
}
```

在手册根目录运行：

```bash
go run ./examples/reference/stdlib/std-http/main.go
```

预期输出：

```text
status=200 body=hello Go
```

注意：反例：resp, _ := client.Get(url); json.NewDecoder(resp.Body).Decode(&x) 同时忽略网络错误、状态码和关闭，可能空指针、误解错误体并耗尽连接；三项都必须处理。

参考：net/http 官方文档 — https://pkg.go.dev/net/http

参考：net/http/httptest 官方文档 — https://pkg.go.dev/net/http/httptest

---

### 日志

log、slog、结构化字段和级别。

#### log 与 slog 的职责

log 提供传统文本日志，log.New(writer, prefix, flags) 可以建立独立 Logger，避免修改全局前缀影响其他包。标准 Logger 会协调并发写入，但记录内容仍是自由文本，查询通常依赖文本解析。Go 1.21 引入 log/slog，将日志表示为级别、消息和键值属性，更适合机器检索；选择它不意味着必须部署某一种日志服务。

slog.Logger 负责构造记录，Handler 决定是否启用、如何格式化和写到哪里。NewTextHandler 生成适合人工阅读的键值文本，NewJSONHandler 生成每行一个 JSON 对象。输出由 io.Writer 决定，可以是 stderr、文件或缓冲区。日志库不会替你做文件轮转、磁盘配额或远端可靠投递，这些属于输出层和运行环境。

#### 级别与结构化属性

默认 Handler 通常启用 Info 及更高等级，Debug 会被过滤。HandlerOptions.Level 可以设固定级别，也可传 *slog.LevelVar 在运行时安全改变阈值。常见约定是 Debug 记录诊断细节，Info 记录正常阶段，Warn 表示可恢复异常，Error 表示当前操作失败；级别没有强制业务语义，需要团队保持一致。

Info("saved", "id", id) 采用交替的键和值，如果漏掉值或键不是字符串，会产生 !BADKEY 属性。使用 slog.String、slog.Int、slog.Duration 等明确属性，并通过 LogAttrs(ctx, level, message, attrs...) 传入，更利于工具检查并减少临时装箱。结构化值应保留数字和布尔类型，不要全部 fmt.Sprintf 为字符串。


```go
logger.LogAttrs(ctx, slog.LevelInfo, "request done",
    slog.String("request_id", "r-7"),
    slog.Int("status", 200),
    slog.Duration("elapsed", 12*time.Millisecond),
)
```

#### With、Group 与请求关联

logger.With("service", "catalog") 返回带有固定属性的新 Logger，适合注入到一个服务对象。WithGroup("http") 会把之后添加的属性放入命名分组，JSON Handler 输出嵌套对象，Text Handler 通常用点连接键。分组能避免 user.id 与 order.id 冲突，但过深嵌套会增加查询成本，命名应该围绕业务实体。

InfoContext 会把 Context 传给 Handler，标准 Handler 不会自动把 request ID、用户或 trace ID 从 Context 中抽取出来。可以在入口提取可信标识，再构造 logger.With(...) 传给下游；也可以实现自定义 Handler 在 Handle 中增加属性。不要假设“用了 Context 版本”就自动拥有分布式追踪，也不要把整个 Context 或请求对象序列化。

#### 过滤并不消除参数计算成本

函数调用参数会在 Logger 方法执行前求值。因此 logger.Debug("payload", "dump", expensiveDump()) 即使 Debug 关闭，expensiveDump 仍会运行。对确实昂贵的计算，先用 logger.Enabled(ctx, slog.LevelDebug) 判断，或让值实现 slog.LogValuer，将展示方式延迟到日志解析阶段。LogValuer 还可以让敏感类型默认输出脱敏值。

HandlerOptions.ReplaceAttr 可以移除时间、改写字段或脱敏；它会接收非组属性并带有当前组路径，应该按键和类型谨慎处理，避免误改业务里同名字段。脱敏最好在数据模型或日志调用点就完成。输出过滤只是额外防线，已经放进其他属性或错误文本的原始口令不会被一个简单的键名规则自动删除。


```go
type Secret string
func (Secret) LogValue() slog.Value { return slog.StringValue("[redacted]") }
// logger.Info("configured", "token", Secret("do-not-print"))
// 不要先 string(token)，那会绕过 LogValue。
```

#### 退出行为、写入错误和测试

log.Fatal 会先记录再调用 os.Exit(1)，不会执行 defer；在库函数、HTTP Handler 或需要 Flush 的进程中使用，会跳过资源清理并杀掉整个程序。log.Panic 会引发 panic，也不适合作为普通错误传播方式。返回 error 让最外层 main 决定退出，日志通常在拥有处理策略的一层记录一次，避免同一个失败被重复放大。

slog.Logger 的记录方法不返回 Handler 写入错误；如果日志输出本身是关键数据，就需要专门的持久化接口、错误通道或自定义输出监控，而不是把业务事务寄托在一条 Info 上。测试可以把 Handler 写入 bytes.Buffer 并解析 JSON，确认级别和字段。下面去掉时间使输出可重复，但生产日志一般应保留时间以支持跨服务排查。

级别和输出具有独立的职责。把业务失败记录为 Error 不会自动报警，也不会改变 HTTP 状态；将 Debug 关闭也不代表敏感属性不存在。应明确每条记录的用途，例如排查一次请求需要 request_id 和耗时，统计失败率需要稳定的 error_code。消息文本可以便于人读，聚合查询则依赖稳定属性，避免把随机 ID 拼进日志消息形成无限多种事件名称。

#### 完整示例与运行结果

只输出一条 INFO JSON：Debug 被级别过滤，port 保留为数字，Secret 通过 LogValue 转成脱敏字符串。示例只删除最外层时间属性，因此每次运行文本一致，且不会误删业务分组内名为 time 的字段。


```go
package main
import (
 "context"
 "log/slog"
 "os"
)
type Secret string
func (Secret) LogValue() slog.Value { return slog.StringValue("[redacted]") }
func main() {
 level := new(slog.LevelVar)
 level.Set(slog.LevelInfo)
 handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
  Level: level,
  ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
   if len(groups) == 0 && a.Key == slog.TimeKey { return slog.Attr{} }
   return a
  },
 })
 logger := slog.New(handler).With("service", "demo")
 logger.Debug("hidden")
 logger.LogAttrs(context.Background(), slog.LevelInfo, "ready",
  slog.Int("port", 8080), slog.Any("token", Secret("private")))
}
```

在手册根目录运行：

```bash
go run ./examples/reference/stdlib/std-log/main.go
```

预期输出：

```text
{"level":"INFO","msg":"ready","service":"demo","port":8080,"token":"[redacted]"}
```

注意：反例：在 defer file.Close() 后调用 log.Fatal 会直接退出，Close 不会执行。库返回 error，main 完成清理后再决定退出状态。

参考：log 官方文档 — https://pkg.go.dev/log

参考：log/slog 官方文档 — https://pkg.go.dev/log/slog

---

### 数学

基础数学函数、随机数和数值边界。

#### 整数、浮点和 math 包的边界

math 主要提供 float64 数学函数和 IEEE 754 特殊值，整数计算通常直接使用语言运算符；任意精度整数、分数和高精度浮点分别在 math/big 的 Int、Rat、Float 中。选类型首先要确定精确性和数值范围：计数器适合整数，物理测量可接受浮点误差，金额常用最小货币单位的整数并显式定义舍入规则。不能因为展示两位小数就认为 float64 具有十进制金额精度。

float64 只有有限的有效二进制位，十进制 0.1 无法精确表示；表达式、累计顺序和数值量级会影响舍入。精确整数转换为浮点后也可能改变值，尤其超过 2 的 53 次方时。常量在编译期可以有更高精度，不能用只有常量的演示去替代运行期变量计算的行为。

#### 比较必须结合误差尺度

数学上相等的两个浮点计算结果可能不是逐位相等。对于近似测量，可判断绝对误差 abs(a-b) <= absTol，或使用相对误差 relTol*max(abs(a),abs(b))，并取两者最大值。绝对误差照顾接近零的值，相对误差照顾大数量级。容差取决于输入噪声和业务单位，没有适用于所有场景的固定 1e-9。

该规则还必须明确 NaN、Inf 的处理。a==b 可先接纳同号无穷，但一个无穷与有限数不应被“无穷小于等于无穷”的比较误判。下面的辅助函数先处理相等，再拒绝 NaN 和其余无穷，然后比较误差；它只适用于近似判断，不适合映射键、财务对账或要求传递性的排序关系。


```go
func near(a, b, absTol, relTol float64) bool {
    if a == b { return true }
    if math.IsNaN(a) || math.IsNaN(b) || math.IsInf(a, 0) || math.IsInf(b, 0) { return false }
    return math.Abs(a-b) <= math.Max(absTol, relTol*math.Max(math.Abs(a), math.Abs(b)))
}
```

#### 舍入方向、余数与边界

Floor 向负无穷取整，Ceil 向正无穷取整，Trunc 向零截断，因此 -1.8 分别得到 -2、-1、-1。Round 在中点时远离零，RoundToEven 在中点时选择偶数；-2.5 分别得到 -3 和 -2。它们返回 float64，不会自动转换为可安全保存的整数，转 int 前仍要检查范围以及 NaN、Inf。

Mod(x,y) 与 Remainder(x,y) 是不同定义：Mod 使用向零截断的商，结果与被除数同号；Remainder 使用最近整数商，并在中点采用偶数规则。周期角度规范化为 [0,m) 时，负输入的 Mod 结果还需要加 m。除数为零或输入无穷有特殊行为，不能把这些函数当成所有语言中 % 的简单同义词。


```go
fmt.Println(math.Floor(-1.8), math.Ceil(-1.8), math.Trunc(-1.8)) // -2 -1 -1
fmt.Println(math.Round(-2.5), math.RoundToEven(-2.5)) // -3 -2
x := math.Mod(-10, 360)
if x < 0 { x += 360 }
fmt.Println(x) // 350
```

#### 特殊值与数值稳定性

Sqrt(-1) 返回 NaN，Log(0) 返回负无穷，不会像整数除零那样一定 panic。NaN 不等于自身，判断必须用 IsNaN；IsInf(x, 1)、IsInf(x, -1)、IsInf(x, 0) 分别检查正、负或任意无穷。对用户输入应在计算前做定义域检查，在结果边界检查有限性，避免把 NaN 继续送入排序或 JSON 编码。

计算 sqrt(x*x+y*y) 时，中间的平方可能溢出，即便最终答案仍可表示。Hypot(x,y) 专门处理这种缩放问题。接近零时 Log1p(x) 比 Log(1+x) 更能保留精度，Expm1(x) 比 Exp(x)-1 更合适；求和规模很大时还要考虑补偿求和或分组算法。使用更稳定的 API 往往比事后放宽误差阈值更有效。

#### 随机数、精确分数和可重复结果

随机数属于 math/rand 或 Go 1.22 引入的 math/rand/v2。它们适合仿真和测试，不适合生成口令、会话令牌或密钥，安全随机数使用 crypto/rand。为了复现实验，应创建带明确种子的局部生成器，而不是依赖进程共享状态；并发使用前要核对所选生成器是否有同步保证。

math/big 的方法常把结果写入接收者，例如 z.Add(x,y)，不像普通数值赋值那样自动产生独立值。Rat 能精确表达 1/10 与 1/5 的和，但转换成有限小数仍要定义位数和舍入。下面把 float64 的舍入误差与 Rat 的精确结果放在一起，再展示 Hypot 对大数的处理，帮助把“显示效果”与“实际表示”区分开。

不要使用绝对值来回避整数溢出：最小有符号整数的正值超出同类型范围，先转成浮点再 Abs 还可能丢失精度。对大小、索引和配额应在整数域做显式范围判断，乘法前确认容量上限。数值代码的测试数据应覆盖零、负数、边界极值和特殊值，且把预期规则写成单位明确的断言，而不只观察一组正常输入的输出。

#### 完整示例与运行结果

先用变量相加，输出揭示运行期二进制浮点误差；Rat 得到精确的 3/10。两种舍入对 2.5 给出不同结果。最后 Hypot 返回有限的 5×10²⁰⁰，而直接先平方会超出 float64 可表示范围。


```go
package main
import (
 "fmt"
 "math"
 "math/big"
)
func main() {
 a, b := 0.1, 0.2
 fmt.Printf("float=%.17g\n", a+b)
 sum := new(big.Rat).Add(big.NewRat(1,10), big.NewRat(1,5))
 fmt.Println("rational=" + sum.RatString())
 fmt.Printf("round=%.0f even=%.0f\n", math.Round(2.5), math.RoundToEven(2.5))
 fmt.Println("sqrt-negative-is-NaN=", math.IsNaN(math.Sqrt(-1)))
 fmt.Printf("hypot=%.3e\n", math.Hypot(3e200, 4e200))
}
```

在手册根目录运行：

```bash
go run ./examples/reference/stdlib/std-math/main.go
```

预期输出：

```text
float=0.30000000000000004
rational=3/10
round=3 even=2
sqrt-negative-is-NaN= true
hypot=5.000e+200
```

注意：反例：用 float64 保存金额，再用 fmt.Sprintf("%.2f", x) 就声称金额精确。格式化只改变显示；应采用整数最小单位或明确的十进制/有理数运算及舍入。

参考：math 官方文档 — https://pkg.go.dev/math

参考：math/big 官方文档 — https://pkg.go.dev/math/big

---

### 网络

TCP、UDP、DNS、URL 和连接生命周期。

#### 选择连接 API 与地址格式

net 提供 TCP、UDP、Unix socket、DNS 和地址辅助函数。Dial(network,address) 建立连接，Listen(network,address) 创建监听器，常见 network 为 tcp、tcp4、tcp6 或 unix。TCP 地址通常由主机与端口组成，应使用 JoinHostPort 拼接；IPv6 地址中已经含有冒号，直接 host+":"+port 会形成歧义，JoinHostPort 会自动补方括号。

SplitHostPort 用于拆分带端口地址，不用于解析完整 URL；https://example.com/path 应交给 net/url。ParseIP 解析 IP 字面量，不查询 DNS。对于新的地址数据结构，可以考虑 net/netip 的值类型 Addr 和 AddrPort，它们便于比较和作为 map 键，但实际连接建立仍由 net 提供。不要把“能解析为主机名”当成“已经验证远端可访问”。

#### TCP 是字节流，消息边界由协议定义

一次 Write 与一次 Read 没有一一对应关系：发送端连续写两条消息，接收端可能一次读到两条，也可能分多次读完一条。Read 返回的 n 才是有效长度；连接正常关闭时通常表现为 EOF。应用必须选择分隔符、固定长度或长度前缀等 framing 规则，否则遇到不同的网络缓冲行为就会错分消息。

以四字节大端长度前缀为例，先用 io.ReadFull 读完整头，再用 binary.BigEndian.Uint32 解出长度，检查上限后分配载荷并再次 ReadFull。必须先校验长度再 make([]byte,n)，否则一个恶意长度即可造成过量内存分配。按行协议可使用 bufio.Reader 或 Scanner，但需要处理换行转义、最大行长度以及末尾残缺记录。


```go
var head [4]byte
if _, err := io.ReadFull(conn, head[:]); err != nil { return err }
n := binary.BigEndian.Uint32(head[:])
if n > 1<<20 { return fmt.Errorf("frame too large: %d", n) }
body := make([]byte, int(n))
if _, err := io.ReadFull(conn, body); err != nil { return err }
```

#### 拨号取消和连接 I/O 截止时间

Dialer.DialContext(ctx, network, address) 让 DNS 和连接建立响应取消，并可以配合 Dialer.Timeout。连接成功后，原拨号 Context 到期不会自动持续控制所有后续 Read/Write；已经返回的 Conn 需要 SetDeadline、SetReadDeadline、SetWriteDeadline 或由拥有者关闭。不要把连接超时误认为整段协议交互的总超时。

Deadline 是绝对时间点，不是每次操作的相对时长，且会影响当前等待和未来 I/O，直到重新设置。SetReadDeadline(time.Now().Add(d)) 才表示从现在起限制读取时间；time.Time{} 清除 deadline。超时后连接不一定永久不可用，协议是否还能恢复取决于有没有留下部分帧。对帧已经读了一半的连接，常见策略是直接关闭并重新建立会话。

#### 并发读写与关闭的职责

Conn 文档允许多个 goroutine 同时调用方法，但这并不意味着多个 goroutine 写完整“业务消息”不会交错。长度头与载荷分成两次写时，应在协议层串行化整帧写入；一个连接通常安排单独读循环，再将解析后的消息分发。独立的一个读 goroutine 和一个写 goroutine 是常见模式，错误与关闭需要统一协调。

Close 会使阻塞中的 Read/Write 返回错误，因此可以用关闭连接结束等待。应明确谁拥有关闭权，避免消费者因为完成自己的读取就关闭整个共享连接。TCPConn.CloseWrite 表示写方向半关闭，适合发送完成后继续读响应；它不是所有 net.Conn 的通用能力，需要类型断言及协议支持。不要将 EOF 一律解释为系统故障，也不要忽略未读完整帧的 UnexpectedEOF。

#### 监听、UDP 和测试替身

Listener.Accept 循环每次得到一条新连接，需要为并发数量和空闲时间设置边界。关闭 Listener 只能停止新的 Accept，不会自动关闭已交出的连接；优雅停机要跟踪活动连接或通过上层服务管理。UDP 则保留数据报边界，但不保证送达、顺序或不重复，应用不能直接照搬 TCP 的可靠传输假设。

net.Pipe 返回一对内存中的全双工 Conn，适合测试协议读写与 deadline；其写入需要对端并发读取，否则容易死锁。它不模拟真实 TCP 的拥塞、缓冲、DNS、TLS 或数据报，因此协议单元测试通过之后，仍可用本地监听器验证集成路径。下面使用 Pipe 和长度帧，重点证明一次完整消息可以在任意读取分块下被正确重建。

#### 完整示例与运行结果

并发写入让 Pipe 不会阻塞在无人读取的一端；接收方先重建长度，再按该长度读满消息，结果为 size=5 body=hello。最后一行显示 IPv6 主机和端口的正确拼接方式。该例不访问真实网络，因此不验证 TCP 栈及 DNS 行为。


```go
package main
import (
 "encoding/binary"
 "fmt"
 "io"
 "net"
 "time"
)
func main() {
 left, right := net.Pipe()
 defer left.Close()
 defer right.Close()
 if err := left.SetDeadline(time.Now().Add(time.Second)); err != nil { panic(err) }
 if err := right.SetDeadline(time.Now().Add(time.Second)); err != nil { panic(err) }
 done := make(chan error, 1)
 go func() {
  payload := []byte("hello")
  frame := make([]byte, 4+len(payload))
  binary.BigEndian.PutUint32(frame[:4], uint32(len(payload)))
  copy(frame[4:], payload)
  _, err := right.Write(frame)
  done <- err
 }()
 var header [4]byte
 if _, err := io.ReadFull(left, header[:]); err != nil { panic(err) }
 n := binary.BigEndian.Uint32(header[:])
 if n > 1024 { panic("frame too large") }
 body := make([]byte, int(n))
 if _, err := io.ReadFull(left, body); err != nil { panic(err) }
 if err := <-done; err != nil { panic(err) }
 fmt.Printf("size=%d body=%s\n", n, body)
 fmt.Println(net.JoinHostPort("::1", "8080"))
}
```

在手册根目录运行：

```bash
go run ./examples/reference/stdlib/std-net/main.go
```

预期输出：

```text
size=5 body=hello
[::1]:8080
```

注意：反例：n, _ := conn.Read(buf); decode(buf[:n]) 假设一次读取拿到整条消息，遇到拆包便失败。按协议使用长度前缀和 io.ReadFull，并先限制载荷长度。

参考：net 官方文档 — https://pkg.go.dev/net

参考：encoding/binary 官方文档 — https://pkg.go.dev/encoding/binary

---

### 排序

sort 接口、稳定排序和 slices 包。

#### 排序会修改原切片

sort.Ints、Strings 和 Float64s 原地修改切片，Sort 通过 Len、Less、Swap 三个方法操作任意序列。Slice 用下标比较函数减少自定义类型样板；SliceStable 保留相等元素的原始顺序。调用前应判断切片是否由当前代码独占，切片头复制并不会复制底层数组，因而排序一个子切片可能改变其他持有者看见的数据。

需要保留原数据时先复制，例如 dst := append([]Item(nil), src...)，或在 Go 1.21+ 使用 slices.Clone。普通复制对含指针、映射和切片的字段仍是浅复制；只要排序只交换外层元素，不修改字段内部对象，浅复制通常足够。排序不是并发安全的读操作，其他 goroutine 同时读取同一数组也需要同步。

#### 比较器必须表达严格弱序

Less(i,j) 必须表示元素 i 严格排在元素 j 前面，所以相等时必须返回 false，不能使用 <=。比较关系还必须保持一致与传递，不能在比较时读取不断变化的时间、随机数或修改元素。排序算法可以按任何顺序、任何次数调用 Less，不能依赖它“会从左往右比较一遍”。

多字段排序应按优先级逐个比较：先按分数降序，分数不同就立即返回；再按姓名升序；最后需要确定性时可用唯一 ID。若把两个条件简单写成 scoreA>scoreB || nameA<nameB，就可能在分数较低时仍因姓名较小返回 true，导致两个方向都认为自己排在前面。这样的错误可能让结果无序，也会使二分查找前提失效。


```go
sort.Slice(items, func(i, j int) bool {
    a, b := items[i], items[j]
    if a.Score != b.Score { return a.Score > b.Score }
    if a.Name != b.Name { return a.Name < b.Name }
    return a.ID < b.ID
})
```

#### 稳定性解决什么问题

稳定排序保持比较器认为相等的元素的原始顺序。比如数据已按录入时间排列，再稳定地按分组排序，同组内部便继续按录入顺序排列。稳定性只针对 Less 定义的等价类，并不是“保留所有字段原样”；如果比较器还包含姓名，分数相同但姓名不同的两项就不算相等。

一般 Sort/Slice 不承诺稳定；即使某个小样本恰好保留了顺序，也不能把它当契约。稳定排序常有额外性能成本，是否值得取决于展示和业务要求。若直接把次要排序字段写进比较器，普通排序也能给出确定次序；若输入来自 map，其迭代顺序不确定，稳定排序不能凭空生成缺失的全局顺序。

#### 二分查找返回的是边界位置

sort.Search(n, f) 在区间 [0,n) 中寻找第一个 f(i)==true 的位置，找不到返回 n。它要求谓词先全部 false、后全部 true。升序数组中用 a[i]>=target 可以找到插入点；返回的下标仍需检查 i<len(a) 且 a[i]==target，才能证明目标确实存在。返回值 n 是合法的插入位置，却不是合法数组索引。

SearchInts、SearchStrings 是常见升序查询的便捷函数，仍然有“插入点不等于命中”的规则。想求重复值范围，可分别寻找 >=target 的下界和 >target 的上界，区间 [lo,hi) 就是全部匹配项。数组必须按同一规则排序，如果按分数降序排列却用升序谓词搜索，结果即使偶尔正确也没有保证。


```go
a := []int{1, 2, 2, 4}
lo := sort.Search(len(a), func(i int) bool { return a[i] >= 2 })
hi := sort.Search(len(a), func(i int) bool { return a[i] > 2 })
fmt.Println(lo, hi, a[lo:hi]) // 1 3 [2 2]
```

#### 泛型 slices 与浮点 NaN

Go 1.21 起 slices.Sort、SortFunc、SortStableFunc 和 BinarySearch 提供泛型替代；SortFunc 的比较器返回负数、零或正数，而非 bool。需要兼容旧工具链时 sort 仍然有效。比较整数不要直接 return a-b，因为极值相减会溢出；使用 cmp.Compare 或显式分支，降序则交换比较参数。

浮点数含 NaN 时，普通 < 无法建立期望的顺序：NaN 与任何值两边比较都为 false，破坏等价关系的传递。sort.Float64s 明确定义 NaN 排在其他值前面；自定义浮点比较器也必须明确 NaN 和无穷的排序规则。近似相等的容差函数不适合充当排序比较器，因为“差值足够小”通常不具备传递性。下面用稳定排序与二分边界展示两种独立契约。

#### 完整示例与运行结果

Ada 与 Cyd 分数相同，稳定排序保留它们在输入中的次序；原切片仍为 Ada、Bob、Cyd，因为先复制了底层元素。查询数字 2 得到半开范围 [1,3)，查询 3 只得到插入点 3，存在性检查为 false。


```go
package main
import (
 "fmt"
 "sort"
)
type Item struct { Name string; Score int }
func main() {
 src := []Item{{"Ada", 90}, {"Bob", 80}, {"Cyd", 90}}
 items := append([]Item(nil), src...)
 sort.SliceStable(items, func(i,j int) bool { return items[i].Score > items[j].Score })
 for _, x := range items { fmt.Printf("%s:%d ", x.Name, x.Score) }
 fmt.Println()
 a := []int{1,2,2,4}
 lo := sort.Search(len(a), func(i int) bool { return a[i] >= 2 })
 hi := sort.Search(len(a), func(i int) bool { return a[i] > 2 })
 fmt.Printf("range=[%d,%d) values=%v\n", lo, hi, a[lo:hi])
 i := sort.SearchInts(a, 3)
 fmt.Printf("insert=%d found=%t\n", i, i<len(a) && a[i]==3)
 fmt.Println("original:", src)
}
```

在手册根目录运行：

```bash
go run ./examples/reference/stdlib/std-sort/main.go
```

预期输出：

```text
Ada:90 Cyd:90 Bob:80 
range=[1,3) values=[2 2]
insert=3 found=false
original: [{Ada 90} {Bob 80} {Cyd 90}]
```

注意：反例：Less 返回 a<=b 会令相等元素相互“小于”，违背排序契约；Search 返回 len(a) 时直接读 a[i] 还会越界。

参考：sort 官方文档 — https://pkg.go.dev/sort

参考：slices 官方文档 — https://pkg.go.dev/slices

---

### 数字与字符串转换

整数、浮点、布尔和字符串解析。

#### 数值与文本的边界

strconv 负责标量与字符串之间的转换；fmt 适合按格式组合输出，encoding/json 适合结构化协议，两者不应混用。Atoi(s) 返回当前平台 int，等价于十进制 ParseInt(s,10,0) 再转换；Itoa(i) 执行相反操作。数据库 ID 或跨平台协议应明确使用 int64/uint64，避免让 int 的位宽决定输入范围。

ParseInt(s,base,bitSize) 返回 int64，但 bitSize 控制允许范围，8 表示必须能放进 int8，32 表示 int32，0 表示 int。ParseUint 则不接受负号。不能因为函数返回 int64 就忽略 bitSize，它正是边界校验的一部分。函数失败可能同时返回饱和值和错误，所以必须先看 err，不能把返回值当正常结果继续计算。

#### 进制、前缀和错误类型

base 为 2..36 时按明确进制解析；base==0 会从前缀推断，例如 0x、0b、0o，以及前导零的八进制语义。协议规定十进制时应传 10，否则输入 "010" 会在自动推断模式变成 8。下划线分隔数字仅在 base==0 且符合 Go 整数字面量规则时接受；这不是通用的“去掉所有符号”清洗。

解析错误通常是 *strconv.NumError，包含函数、原始输入和底层 ErrSyntax 或 ErrRange。可以 errors.As 提取详情，也可 errors.Is(err,strconv.ErrRange) 区分溢出。空字符串、前后空白和单位后缀不会自动被 ParseInt 忽略；是否 TrimSpace 应由协议决定，过度宽松可能隐藏配置文件中的输入错误。


```go
n, err := strconv.ParseInt("128", 10, 8)
fmt.Println(n, errors.Is(err, strconv.ErrRange)) // 127 true
n, err = strconv.ParseInt("010", 0, 64)
fmt.Println(n, err) // 8 <nil>
n, err = strconv.ParseInt("010", 10, 64)
fmt.Println(n, err) // 10 <nil>
```

#### 浮点解析与输出精度

ParseFloat(s,bitSize) 接受十进制和十六进制浮点语法，也接受特定形式的 NaN、Inf、Infinity。bitSize 为 32 时会按 float32 精度舍入，但返回类型仍为 float64；这不意味着获得了额外精度。对必须为有限正数的配置值，解析后继续检查 math.IsNaN、math.IsInf 和范围，不能只判断 err==nil。

FormatFloat(f,fmt,prec,bitSize) 的格式字符决定小数或指数形式：f 为小数形式，e 为指数形式，g 选择更紧凑的形式。对 g 使用 prec=-1 可以输出在所指定位宽下能往返还原的最短表示；对 f 使用 prec=2 则表示小数点后两位，会舍入展示。bitSize 应与原始值精度一致，float32 提升成 float64 后用错误位宽格式化可能暴露额外尾数。


```go
x, err := strconv.ParseFloat("3.14159", 64)
if err != nil { return err }
fmt.Println(strconv.FormatFloat(x, 'f', 2, 64)) // 3.14
fmt.Println(strconv.FormatFloat(x, 'g', -1, 64)) // 3.14159
// 金额显示两位小数不等于内部变成精确十进制。
```

#### 布尔、引用与转义的语法归属

ParseBool 只接受文档规定的形式，包括 1、t、T、TRUE、true、True 以及对应 false 形式，不接受任意 yes/no/on/off。若配置协议需要这些词，应显式增加映射和帮助说明，不能误以为标准函数覆盖所有自然语言。FormatBool 统一输出 "true" 或 "false"，适合产生一致配置文本。

Quote、QuoteToASCII、Unquote 处理 Go 字符串字面量语法，例如换行、反斜杠和 Unicode 转义；它们不是 shell 参数转义、SQL 转义或 HTML 转义。不同语法层的特殊字符完全不同，拿 Quote 包裹 shell 命令仍可能出现注入。生成 JSON 字符串用 json.Marshal，SQL 用参数绑定，HTML 用 html/template，命令执行用独立参数数组。

#### Append API、性能与错误语义

AppendInt(dst,n,base)、AppendFloat 和 AppendBool 将文本追加到 []byte 并返回新的切片，适合构造大量协议行，减少中间字符串。必须保存返回值，因为扩容可能更换底层数组；忽略结果会丢失新长度，甚至看不到写入的新数据。它们不是并发同步工具，同一个缓冲区不能由多个 goroutine 无保护地追加。

优化前先用基准确认转换是热点，普通业务中 Itoa 和 FormatInt 更易读。错误处理通常比纳秒差异更重要：必须明确空值、负值、过大值和非有限浮点的策略。下面示例将溢出返回值单独打印为诊断，绝不拿它作为成功数据；也展示自动推断进制与固定十进制的差异，避免配置出现不易发现的数值改变。

外部输入的长度也属于转换边界：解析器能够返回错误，不等于值得允许任意长文本占用资源。读取配置、HTTP 参数或日志字段时，可以先限制字节数，再做转换；若错误包含原始输入，应避免把整段敏感或超长文本原样输出。将底层 NumError 包装为带字段名的错误更有帮助，例如“port 必须是 1..65535 的十进制整数”，同时保留 %w 供程序判断错误类别。

#### 完整示例与运行结果

010 在十进制和自动进制下分别为 10、8。128 超出有符号 8 位范围，返回 127 伴随 ErrRange，因此不能将它当成合法 127。AppendInt 产生 id=42，Quote/Unquote 的往返保留真实换行和中文。


```go
package main
import (
 "errors"
 "fmt"
 "strconv"
)
func main() {
 dec, err := strconv.ParseInt("010", 10, 64)
 if err != nil { panic(err) }
 auto, err := strconv.ParseInt("010", 0, 64)
 if err != nil { panic(err) }
 fmt.Printf("decimal=%d autodetect=%d\n", dec, auto)
 n, err := strconv.ParseInt("128", 10, 8)
 fmt.Printf("rangeValue=%d overflow=%t\n", n, errors.Is(err, strconv.ErrRange))
 buf := strconv.AppendInt([]byte("id="), 42, 10)
 fmt.Println(string(buf))
 quoted := strconv.Quote("Go\n语言")
 plain, err := strconv.Unquote(quoted)
 if err != nil { panic(err) }
 fmt.Printf("quoted=%s roundtrip=%t\n", quoted, plain=="Go\n语言")
}
```

在手册根目录运行：

```bash
go run ./examples/reference/stdlib/std-strconv/main.go
```

预期输出：

```text
decimal=10 autodetect=8
rangeValue=127 overflow=true
id=42
quoted="Go\n语言" roundtrip=true
```

注意：反例：n,_ := strconv.ParseInt("128",10,8) 会得到饱和值 127 并被误用；错误不是可忽略提示。协议十进制输入也不要随意使用 base=0。

参考：strconv 官方文档 — https://pkg.go.dev/strconv

---

### 字符串工具

查找、切分、替换、Builder 和 Reader。

#### 字符串是字节序列，API 单位要分清

Go string 是不可变的字节序列，并不保证内容是合法 UTF-8。len(s)、s[i]、切片下标、Index 和 LastIndex 都使用字节单位；range 和部分 Unicode 文本函数按 rune 解码。比如 "Go语言" 长度为 8 字节、4 个 rune，从位置 2 开始是汉字语的 UTF-8 起点，任意从位置 3 切开会得到无效编码片段。

Contains、HasPrefix、HasSuffix 和 Cut 很适合固定分隔符协议，因为它们按明确字节序列匹配，不受语言排序影响。文本显示宽度、用户感知字符数、大小写等价是另外的概念；emoji 组合与重音字符可能由多个 rune 构成。不要把 []rune 转换当成处理所有国际化需求的一步解法，它只解决按 Unicode 码点切分。

#### Split、Fields 与 Cut 的不同目的

Split(s,sep) 保留分隔产生的空字段，因此 strings.Split("a,,b,",",") 包含中间和尾部空字符串，适合需要保留列位置的数据。Fields 按 Unicode 空白分隔并丢弃首尾及连续空白，适合自然文本词项。两者不能互换：把 CSV 交给 Split 会忽略引号中的逗号和换行，正式 CSV 应使用 encoding/csv。

Cut(s,sep) 返回 before、after 和 found，只分第一次出现的分隔符；解析 key=value 时比 Split 更直接，也能区分未出现等号与值为空。SplitN(s,sep,n) 在 n>0 时最多生成 n 段，n==0 返回 nil，n<0 表示全部拆分。CutPrefix/CutSuffix 则同时返回移除后的字符串和是否匹配，适合明确前后缀协议。


```go
key, value, found := strings.Cut("token=a=b", "=")
fmt.Println(key, value, found) // token a=b true
fmt.Printf("%q\n", strings.Split("a,,b,", ",")) // ["a" "" "b" ""]
fmt.Printf("%q\n", strings.Fields(" a\t b  ")) // ["a" "b"]
```

#### Trim 的 cutset 不是完整子串

Trim(s,cutset) 从两端移除属于 cutset 的任意 rune，而不是移除一个完整单词。Trim("abtargetba","ab") 得到 target，因为 a 和 b 都是可去除字符；对路径或协议标签想移除固定前缀时应使用 TrimPrefix，固定后缀用 TrimSuffix。TrimSpace 使用 Unicode 空白定义，不只是 ASCII 空格。

同样，TrimLeft/TrimRight 处理字符集合，不能当作“去掉某个开头词”。错误用法 strings.TrimPrefix 是安全的精确匹配，而 strings.Trim(s,"https://") 会把集合中的 h、t、p、s、冒号、斜杠从两端任意剥离，可能破坏主机名。完整 URL 应通过 net/url 解析，文件路径应使用 filepath，而不是靠一串 Trim 操作猜测结构。

#### 大小写、替换与安全边界

EqualFold 进行 Unicode 简单大小写折叠比较，适合忽略大小写的文本等价检查，但不是特定语言区域的排序，也不是完整的 Unicode 规范化。视觉相同的重音字母可能有不同码点序列，ToLower 后也不一定相等。文件名、身份标识等规则必须由协议明确，不能以本机显示效果推断二进制等价。

Replace(s,old,new,n) 最多替换 n 次，n<0 表示全部；ReplaceAll 是常用的全部替换形式。NewReplacer 用一组 old/new 对构造可重复使用的替换器，适合固定 token 转换。它们不解析 HTML、SQL 或正则语言，字符串替换无法充当通用安全净化。包含嵌套、引用或转义的语法应该使用对应解析器。


```go
fmt.Println(strings.Trim("abtargetba", "ab")) // target
fmt.Println(strings.TrimPrefix("abtargetba", "ab")) // targetba
fmt.Println(strings.EqualFold("Go", "gO")) // true
r := strings.NewReplacer("<", "[", ">", "]")
fmt.Println(r.Replace("<Go>")) // [Go]，这不是 HTML 安全净化
```

#### Builder、Reader 与内存保留

strings.Builder 的零值可用，WriteString、WriteByte、WriteRune 用来逐步构建字符串，Grow(n) 预留未来 n 字节容量，String 读取当前结果。非零 Builder 禁止复制，包括按值传入函数或放入会复制元素的容器；应传 *strings.Builder。Reset 清空内容后可继续使用，但它不是并发安全对象，不能多个 goroutine 同时追加。

strings.NewReader 为字符串提供 io.Reader、ReaderAt、Seeker 等能力，适合把文本交给流式解析器。一个很小的子串可能仍引用很大的底层数据，长期缓存这类片段会保留整块原始内存；确认成为内存问题时，可用 strings.Clone 获得独立存储。不要无差别 Clone 所有文本，那会增加分配；先看对象生命周期和内存剖析证据。

Count 计算非重叠匹配的数量，空分隔符有特殊的码点边界语义，不宜用来随意统计字节。Index 返回 -1 表示未命中，随后直接使用 s[:i] 会 panic，必须先检查或使用 Cut 的 found 返回值。IndexRune 适合查找单个 rune，但返回值仍是字节位置；拿它与 []rune 的下标混用同样会错位。文本处理函数应在变量名或注释中保留单位，让字节偏移和码点计数不至于在后续修改时被混淆。

#### 完整示例与运行结果

字节长度 8 与 rune 数量 4 说明两种计数单位不同；Index 返回语的字节起点 2。Cut 保留 value 内的第二个等号，Split 保留空列而 Fields 合并空白。Builder 通过指针方法原地累积，最终得到 Hello Go，整个过程没有复制非零 Builder。


```go
package main
import (
 "fmt"
 "strings"
 "unicode/utf8"
)
func main() {
 text := "Go语言"
 fmt.Printf("bytes=%d runes=%d index=%d\n", len(text), utf8.RuneCountInString(text), strings.Index(text,"语"))
 key, value, ok := strings.Cut("token=a=b", "=")
 fmt.Printf("key=%s value=%s found=%t\n", key, value, ok)
 fmt.Printf("split=%q fields=%q\n", strings.Split("a,,b,",","), strings.Fields(" a\t b  "))
 fmt.Println("trim="+strings.Trim("abtargetba","ab"))
 var out strings.Builder
 out.Grow(16)
 out.WriteString("Hello")
 out.WriteByte(' ')
 out.WriteString("Go")
 fmt.Println(out.String())
}
```

在手册根目录运行：

```bash
go run ./examples/reference/stdlib/std-strings/main.go
```

预期输出：

```text
bytes=8 runes=4 index=2
key=token value=a=b found=true
split=["a" "" "b" ""] fields=["a" "b"]
trim=target
Hello Go
```

注意：反例：strings.Trim(url,"https://") 把参数视为字符集合，可能删坏地址。精确前缀用 TrimPrefix，真正 URL 用 net/url 解析。

参考：strings 官方文档 — https://pkg.go.dev/strings

---

### 模板

text/template、html/template、自动转义。

#### 先区分 HTML 与纯文本模板

text/template 用于普通文本生成，例如配置文件、邮件纯文本正文和代码片段；html/template 在相似 API 上增加与 HTML 上下文相关的自动转义，生成网页时应优先使用后者。模板作者被视为可信，Execute 的数据被视为不可信；允许用户自行编写任意模板是另一种执行边界，不能因为输出有转义就当成完全隔离的沙箱。

Template.New(name) 建立命名模板，Parse 编译模板文本，Execute(writer,data) 将数据应用到模板。解析错误和执行错误发生在不同阶段：语法错误通常在 Parse 时报出，字段不存在、函数错误或 Writer 失败可能在 Execute 时出现。把固定模板在启动时解析一次，比每个请求重复解析更节省工作，也能让部署时尽早发现错误。

#### 点号、管道与控制结构

模板中的点号 . 表示当前数据，.Name 读取导出字段或映射键。if 判断空值，with 在非空时改变点号，range 遍历集合并将点号改成当前元素；变量 $ 可以保存根对象，避免嵌套后丢失上下文。nil 指针与空集合的行为要用实际输入验证，不要把模板当成普通 Go 代码粘贴。

管道将前一步结果作为下一函数的最后一个参数，例如 {{.Name | printf "%q"}}。函数通过 FuncMap 注册，必须在解析引用该函数的模板之前调用 Funcs，否则解析器找不到名字。模板函数可以返回一个值，或者返回值加 error；非 nil error 会使执行中止。让函数只做简单展示转换，避免在模板循环中执行数据库查询造成隐藏的 N+1 请求。


```go
t, err := template.New("list").Funcs(template.FuncMap{
    "upper": strings.ToUpper,
}).Parse(`{{range .}}{{. | upper}} {{else}}empty{{end}}`)
if err != nil { return err }
if err := t.Execute(w, []string{"go", "sql"}); err != nil { return err }
// 输出：GO SQL
```

#### 自动转义依赖输出上下文

html/template 会根据数据位于 HTML 文本、属性、URL、JavaScript 或 CSS 等位置选择相应转义。普通字符串 "<b>Ada</b>" 在 HTML 文本中变成 &lt;b&gt;Ada&lt;/b&gt;，浏览器显示文字而不创建标签。危险 URL 协议可能被替换为 #ZgotmplZ，表明输入未被当成允许的链接值。不要绕过它去手工拼接 onclick 或 style 字符串。

template.HTML、template.JS、template.URL 等类型表示调用者对内容安全性的强信任断言，转换本身不验证或净化数据。把用户评论强制转为 template.HTML 会直接绕开保护，导致脚本或事件属性进入页面。若确实支持富文本，应先使用有明确定义的白名单净化器，再把结果标为可信，不能靠 strings.Replace 删除几个标签。

#### 命名模板、布局与缺失值策略

define 声明命名片段，template 调用片段，block 适合提供可以覆盖的布局默认内容。ParseFiles 和 ParseGlob 按文件建立关联模板，ExecuteTemplate(writer,"name",data) 能明确选择目标，避免因文件基名或解析顺序执行错模板。多个目录出现相同基名时要格外注意，名称属于同一模板集合。

Option("missingkey=error") 可使不存在的 map 键在普通键访问时变成执行错误，适合生成严格配置；默认行为可能输出空值提示，让缺失配置被误认为合法。它不是完整 schema 验证，也不能替代对 nil、必填字段和字段范围的检查。最好先验证输入，再使用模板负责展示，使错误信息更接近用户能修正的问题。


```go
t, err := template.New("config").Option("missingkey=error").Parse(`host={{.Host}}`)
if err != nil { return err }
var buf bytes.Buffer
if err := t.Execute(&buf, map[string]string{}); err != nil {
    return fmt.Errorf("render config: %w", err)
}
// 只有完整成功后，才把 buf 写到最终文件或响应。
```

#### 并发执行和部分输出

模板构建完成后，可以并发 Execute，但若多个调用共享同一个 Writer，输出可能交错，需要调用方同步。不要同时修改模板定义、注册函数和处理请求；启动阶段完成构建，再把模板作为只读依赖使用。自定义 FuncMap 中的函数若读写共享状态，也需要自己保证并发安全，模板引擎不会保护业务对象。

Execute 失败时可能已经向 Writer 写出一部分内容。HTTP 页面想在失败时返回干净的 500，可以先渲染到 bytes.Buffer，成功后再设置响应状态并一次写出；这会增加内存占用，应限制页面大小。写文件也可以渲染成功后再替换目标文件。下面同时展示 HTML 转义和危险 URL 过滤，并以纯文本模板对照，说明选择包本身决定了输出语义。

#### 完整示例与运行结果

HTML 模板把标签当作文本转义，并将 javascript 链接替换为 #ZgotmplZ；纯文本模板则原样输出 <b>Ada</b>。两次都先写入独立缓冲，只有 Execute 成功后才打印，避免把部分失败结果提交给最终输出。


```go
package main
import (
 "bytes"
 "fmt"
 html "html/template"
 text "text/template"
)
func main() {
 data := struct { Name, URL string }{"<b>Ada</b>", "javascript:alert(1)"}
 h, err := html.New("page").Parse(`<p>{{.Name}}</p><a href="{{.URL}}">link</a>`)
 if err != nil { panic(err) }
 var out bytes.Buffer
 if err := h.Execute(&out, data); err != nil { panic(err) }
 fmt.Println(out.String())
 t, err := text.New("plain").Parse(`name={{.Name}}`)
 if err != nil { panic(err) }
 out.Reset()
 if err := t.Execute(&out, data); err != nil { panic(err) }
 fmt.Println(out.String())
}
```

在手册根目录运行：

```bash
go run ./examples/reference/stdlib/std-template/main.go
```

预期输出：

```text
<p>&lt;b&gt;Ada&lt;/b&gt;</p><a href="#ZgotmplZ">link</a>
name=<b>Ada</b>
```

注意：反例：template.HTML(userInput) 不会消毒 HTML，只会把数据标成可信并绕过自动转义；不可信数据保持普通 string。

参考：html/template 官方文档 — https://pkg.go.dev/html/template

参考：text/template 官方文档 — https://pkg.go.dev/text/template

---

### 时间

时间点、时区、定时器、ticker 和 deadline。

#### Time 表示时间点，Duration 表示间隔

time.Time 表示时间点并携带用于显示的时区信息；time.Duration 是以纳秒为单位的有符号整数，适合超时和经过时长。time.Second、Minute、Hour 是单位常量，time.Sleep(100) 只睡 100 纳秒，并非 100 毫秒。把外部整数转换为 Duration 时应明确乘以单位，同时检查乘法溢出和允许范围。

Duration 没有 Day 或 Month 常量，因为日历中的一天不总等于 24 小时，月份天数也不同。固定经过时长用 Add(24*time.Hour)，日历意义的下一天用 AddDate(0,0,1)，两者跨夏令时切换时可能得到不同本地钟点。Unix 秒、毫秒与纳秒也要明确区分，可用 Unix、UnixMilli、UnixNano 避免手算混乱。

#### 格式化布局是参考时间，不是 YYYY 占位符

Format 和 Parse 使用参考时间 Mon Jan 2 15:04:05 MST 2006 的组成部分。例如 "2006-01-02 15:04:05" 表示年月日和二十四小时，"03:04 PM" 表示十二小时。写 "YYYY-MM-DD" 只会得到这些文字，不能套用其他语言的格式语法。互操作优先采用 time.RFC3339 或 RFC3339Nano，让日期、时间和偏移一起传输。

Parse(layout,value) 对没有时区的信息通常按 UTC 解释，ParseInLocation(layout,value,loc) 则使用指定 Location。输入包含数值偏移或时区缩写时还有匹配规则，不能把 CST 这类跨地区歧义缩写当成唯一地区标识。若业务需要本地民用时间，应保存地区名如 Asia/Shanghai，并通过 LoadLocation 加载规则，不能只保存一个固定小时偏移来代替长期时区。


```go
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil { return err }
t, err := time.ParseInLocation("2006-01-02 15:04", "2026-09-24 09:30", loc)
if err != nil { return err }
fmt.Println(t.UTC().Format(time.RFC3339)) // 2026-09-24T01:30:00Z
```

#### 时区、比较和单调时钟

同一个时间点可以在不同时区显示，t.In(loc) 只改变呈现位置，不改变实际瞬间。使用 t.Equal(u) 判断同一时刻，Before/After 比较先后；== 还会比较 Location 以及内部单调时钟信息，可能让同一瞬间判断为不相等。Time 作为 map 键前要统一地点和移除单调部分，例如 UTC().Round(0)，否则键语义可能与你期望不同。

time.Now 返回的值通常包含单调时钟读数，Sub、Since 等在双方可用时利用它计算经过时间，减少系统墙钟调整影响。序列化、Parse、Date 和某些变换不会保留单调信息，因此从 JSON 读回来的时间不等价于本进程计时器。记录事件时间使用墙钟，测量函数耗时使用 start:=time.Now(); time.Since(start)，不要通过格式化字符串相减来计时。

#### Timer 与 Ticker：触发不等于任务完成

NewTimer(d) 创建一次性计时器，至少等待 d 后可以从 C 接收时间；Ticker 周期性触发，消费者太慢时可能调整间隔或丢弃 tick，因此它不是保证每次周期任务都执行的持久调度器。耗时任务若不能重叠，应在同一循环同步执行，或采用有明确队列容量的调度方案。select 同时就绪时并不优先选择取消分支。

Go 1.23 起，未引用且未停止的 timer 和 ticker 可被垃圾回收，不再需要为了 GC 而无条件 Stop。Stop 仍用于停止未来触发和表达所有权；ticker.Stop 不会关闭 C，所以 range ticker.C 不会因 Stop 自动结束。Go 1.23 的通道计时器改为同步语义，Stop/Reset 后陈旧值问题的保证也改变；实际行为受主模块 go 版本及 GODEBUG=asynctimerchan 设置影响，不能把旧版“Stop 后必须排空”机械移植到所有程序。


```go
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
    select {
    case <-ctx.Done(): return ctx.Err()
    case <-ticker.C: refresh()
    }
}
// Stop 不关闭 C；循环结束由 ctx.Done 控制。
```

#### 超时组合、资源取消与可重复示例

一次操作的截止时间优先用 context.WithTimeout 并 defer cancel，让下游函数共享同一个预算；每层重新创建完整超时会使总耗时叠加。select 中 time.After 只提供等待事件，不会自动取消已经启动的 goroutine，必须把取消信号真正传到工作函数。Timer.AfterFunc 在 goroutine 执行回调，它的 C 为 nil，Reset 与并发回调的规则也不同于通道 Timer。

LoadLocation 依赖时区数据库，极简容器可以通过导入 time/tzdata 嵌入数据，或提供系统 zoneinfo；示例使用 FixedZone 只为确定的 +08:00 展示，不宣称它包含地区夏令时历史。下面以固定日期验证解析与时间转换，计时器仅打印触发事件而不比较精确毫秒，因此结果可重复；操作系统调度使实际唤醒时间可能晚于请求时间。

#### 完整示例与运行结果

09:30 的 +08:00 时间转换为 UTC 01:30，增加 90 分钟得到 11:00。Equal 认为转换前后是同一瞬间，== 因位置表示不同而为 false。Timer 等待至少 1ms 后打印，示例不对真实经过时长做精确断言，也不依赖 Stop 的旧版排空规则。


```go
package main
import (
 "fmt"
 "time"
)
func main() {
 loc := time.FixedZone("UTC+8", 8*60*60)
 t, err := time.ParseInLocation("2006-01-02 15:04", "2026-09-24 09:30", loc)
 if err != nil { panic(err) }
 fmt.Println(t.UTC().Format(time.RFC3339))
 fmt.Println(t.Add(90*time.Minute).Format("15:04"))
 same := t.UTC()
 fmt.Printf("equal=%t operatorEqual=%t\n", t.Equal(same), t==same)
 d, err := time.ParseDuration("1m30s")
 if err != nil { panic(err) }
 fmt.Printf("seconds=%.0f\n", d.Seconds())
 timer := time.NewTimer(time.Millisecond)
 defer timer.Stop()
 <-timer.C
 fmt.Println("timer fired")
}
```

在手册根目录运行：

```bash
go run ./examples/reference/stdlib/std-time/main.go
```

预期输出：

```text
2026-09-24T01:30:00Z
11:00
equal=true operatorEqual=false
seconds=90
timer fired
```

注意：反例：声称 Go 1.23+ Timer 必须 Stop 才能被 GC 已过时；同时 ticker.Stop 不关闭 ticker.C，依赖 range 自动退出仍会卡住。

参考：time 官方文档 — https://pkg.go.dev/time

参考：Go 1.23 Timer 变更说明 — https://go.dev/wiki/Go123Timer

---

### Unicode

rune、分类、规范化和宽度问题。

#### byte、rune 与用户感知字符

byte 是 uint8 的别名，rune 是 int32 的别名；在 UTF-8 文本中，一个 Unicode 码点通常由一到四个字节表示。string 可以包含任意字节，rune 也只是一个整数类型，不保证总处于合法 Unicode 标量值范围。unicode 提供字符分类和大小写映射，unicode/utf8 负责 UTF-8 编解码与合法性判断，unicode/utf16 处理 UTF-16 码元。

len("语言") 为 6，utf8.RuneCountInString("语言") 为 2，但 rune 数量仍不等于用户眼中的字形数量。e 加组合重音与预组字符 é 可以显示相似，却分别包含两个和一个 rune；国旗 emoji 与带肤色、连接符的 emoji 也可能包含多个码点。标准库没有完整字素簇切分和 Unicode 规范化 API，不能承诺 rune 截断会保留每一个视觉字符。

#### range 的下标是字节偏移

for i,r := range s 按 UTF-8 解码，i 是当前 rune 的起始字节位置，而不是第几个字符。对 "A语🙂"，起点依次为 0、1、4。如果需要码点序号，应另设计数变量；如果想截取原始子串，字节偏移恰好有用。直接 s[i] 则只读取一个字节，不会自动得到完整汉字或 emoji。

DecodeRuneInString 返回 rune 和消耗字节数。非法 UTF-8 通常返回 RuneError 且 size==1；合法编码的替换字符 U+FFFD 也返回 RuneError，但 size==3，因此不能只看 rune 值判定输入错误。ValidString 可以一次性判断整个字符串是否合法，处理外部文本前应确定是拒绝、替换还是保留原始字节，不要在无意间损失证据。


```go
s := "A语🙂"
for offset, r := range s {
    fmt.Printf("%d:%U ", offset, r)
}
// 0:U+0041 1:U+8BED 4:U+1F642
r, n := utf8.DecodeRuneInString(string([]byte{0xff}))
fmt.Println(r==utf8.RuneError, n) // true 1
```

#### 字符类别比 ASCII 区间更宽

unicode.IsLetter、IsDigit、IsNumber、IsSpace 和 IsPunct 基于 Unicode 分类表。IsDigit 指十进制数字类别，IsNumber 包含更广的数字字符；IsLetter 会接纳很多语言的字母，而不是只有 a-z。协议若限定 ASCII 标识符，应直接检查 ASCII 范围，不要用 IsLetter 然后以为结果仍满足仅英文数据库字段名的约束。

相反，面对用户姓名等自然语言输入，手写 r>="a"&&r<="z" 的思路会排除绝大多数非拉丁文字。校验规则应从用途出发：机器 token 往往明确使用 ASCII，显示名称通常允许更广字符并另设长度、控制字符和换行限制。Unicode 合法性不等于视觉安全，不同字符可能外观近似，账户唯一标识需要额外的产品与安全规则。

#### 大小写映射与规范化是不同操作

unicode.ToUpper/ToLower 对单个 rune 做映射，strings.ToUpper/ToLower 对整串应用相关转换。unicode.SimpleFold 遍历简单大小写折叠等价类，strings.EqualFold 用它完成不区分大小写的比较。它们不负责地区化排序，也不自动把分解重音变为预组字符；将所有文字 ToLower 后作为全世界统一用户键是不完整的设计。

Unicode 版本随 Go 工具链更新，unicode.Version 显示使用的表版本。如果持久化系统依赖字符分类结果，升级工具链后应关注新增字符和分类变化，不能声称表永远不变。需要规范化时通常使用 golang.org/x/text/unicode/norm 等外部实现，但本页示例保持标准库依赖，并明确展示两个视觉相似字符串仍可能字节不相等。


```go
fmt.Println(unicode.IsDigit('٣')) // true，阿拉伯印度数字
fmt.Println(unicode.IsLetter('语')) // true
fmt.Println(strings.EqualFold("Go", "gO")) // true
fmt.Println("é" == "e\u0301") // false，标准字符串比较不做规范化
```

#### 流式解码、UTF-16 与安全截断

网络分块可能把一个 UTF-8 字符分成两部分。utf8.FullRune 可以判断当前字节是否足以解出一个完整 rune，它对某些非法前缀也会认为足够，因为已经可以确定错误。解析器应保留尚未完成的尾部字节继续读取；不要把每一个网络块直接转 []rune 后拼接，否则跨块字符会变成替换字符。bufio.Reader.ReadRune 可以帮你管理这类缓冲。

UTF-16 的非 BMP 字符使用代理对，一个 uint16 不一定是完整字符。utf16.Encode/Decode 负责码点和码元转换，字节序则还需要 encoding/binary 处理。若限制 UTF-8 输出为最多 N 字节，可沿 range 的边界选择切点，避免截断编码；若要求最多 N 个视觉字符则需要字素簇库。下面同时输出码点偏移和非法输入判断，不把两种长度混为一谈。

#### 完整示例与运行结果

A、语、🙂 共 8 字节、3 个 rune，range 给出字节起点 0、1、4。非法字节 0xff 被解为 RuneError，且只消耗 1 字节；分类函数识别中文为字母、阿拉伯印度数字为十进制数字。最后一行用 false 证明字符串比较没有自动规范化。


```go
package main
import (
 "fmt"
 "unicode"
 "unicode/utf8"
)
func main() {
 s := "A语🙂"
 fmt.Printf("bytes=%d runes=%d\n", len(s), utf8.RuneCountInString(s))
 for i, r := range s { fmt.Printf("%d:%U ", i, r) }
 fmt.Println()
 bad := string([]byte{0xff})
 r, size := utf8.DecodeRuneInString(bad)
 fmt.Printf("valid=%t replacement=%t size=%d\n", utf8.ValidString(bad), r==utf8.RuneError, size)
 fmt.Printf("letter=%t digit=%t\n", unicode.IsLetter('语'), unicode.IsDigit('٣'))
 fmt.Printf("normalizedEqual=%t\n", "é" == "e\u0301")
}
```

在手册根目录运行：

```bash
go run ./examples/reference/stdlib/std-unicode/main.go
```

预期输出：

```text
bytes=8 runes=3
0:U+0041 1:U+8BED 4:U+1F642 
valid=false replacement=true size=1
letter=true digit=true
normalizedEqual=false
```

注意：反例：s[:3] 被当作取前三个汉字，实际取前三字节；对混合文字可能截断 UTF-8。range 按 rune 解码，但它的索引仍是字节偏移。

参考：unicode 官方文档 — https://pkg.go.dev/unicode

参考：unicode/utf8 官方文档 — https://pkg.go.dev/unicode/utf8

参考：unicode/utf16 官方文档 — https://pkg.go.dev/unicode/utf16

---

### unsafe

不安全指针、布局和编译器假设。

#### unsafe 是绕过类型系统的边界

unsafe 允许操作内存布局和指针表示，主要用于运行时、系统调用、与其他语言交互以及经过测量确认的底层优化。导入它不会自动提升性能，却会把原本由类型系统保证的条件交给作者：对象必须存活、地址必须对齐、访问不能越界、布局必须匹配、不可变数据不得被修改。unsafe 包不受通常的 Go 1 兼容性保证保护，相关代码可能随架构或编译器变化而失效。

普通业务首先考虑 []byte/string 转换、encoding/binary、反射或明确的数据结构。如果安全实现已经足够快，unsafe 带来的审核和维护成本通常没有收益。确有需要时应把不安全部分集中在小函数中，写清楚调用前提、所有权、长度与架构依赖，并保留安全实现用于对照；不要让原始指针在业务调用链里任意传播。

#### Sizeof、Alignof、Offsetof 的准确含义

Sizeof(x) 返回变量本身占用的字节数，不递归计算它引用的内存。切片的 Sizeof 是切片描述符大小，不是元素数组大小；string 同样只计算描述符，map 也不会因此得到所有桶的内存用量。结构体大小包含字段间和尾部填充，字段顺序可能影响最终大小，但具体结果取决于目标架构和字段类型。

Alignof(x) 返回类型所需对齐，Offsetof(s.Field) 返回字段相对结构体起点的字节偏移。它们可以帮助解释布局，不等于定义了网络协议。将整个结构体内存直接发到网络会泄漏填充字节，并受字节序、整数宽度与 ABI 影响；协议序列化应按字段明确编码。布局优化需要基准和内存剖析支持，不能只按“字段从大到小”就宣称整体性能更好。


```go
type Header struct { Kind byte; Length uint32 }
var h Header
fmt.Println(unsafe.Sizeof(h), unsafe.Alignof(h), unsafe.Offsetof(h.Length))
// 常见 64/32 位平台为 8 4 4，值属于目标架构布局，不是协议格式。
```

#### Pointer 与 uintptr 不是同一种引用

unsafe.Pointer 能在不同指针类型间转换，但 uintptr 是整数，垃圾回收器不会将它当作指向对象的引用。把指针转为 uintptr 保存，经过其他操作再转回 Pointer，不会自动保持对象存活，也可能因栈移动而失效。即使在当前机器试运行成功，也不表示满足文档允许的转换模式。

指针算术应优先使用 unsafe.Add(ptr,offset)，并保证结果仍在同一已分配对象内部。文档允许的 uintptr 算术转换通常要求在一个表达式中完成，不能任意拆成临时整数。runtime.KeepAlive 的作用是延长某些对象的可达生命周期，不能把任意非法指针转换变合法，更不能修复已经越界或错对齐的地址。


```go
// 错误示意，禁止照搬：
// u := uintptr(unsafe.Pointer(&value))
// doOtherWork()
// p := (*int)(unsafe.Pointer(u)) // uintptr 不保持 value 存活

// 已知对象中的合法偏移，仍需要自己证明边界：
p := unsafe.Add(unsafe.Pointer(&array[0]), unsafe.Sizeof(array[0]))
second := (*uint32)(p) // array 必须至少含两个 uint32
```

#### Slice 与 String 的长度和别名契约

unsafe.Slice(ptr,len) 从元素指针构造切片，要求对应内存确实可以容纳 len 个元素；nil 指针配非零长度会 panic。它不会分配元素，也不会检查底层对象的真实边界。unsafe.SliceData 返回底层数组指针；空切片与 nil 切片的返回细节不同，不能无条件解引用。普通 Go 数组转切片时应直接使用 array[:]，不需要 unsafe。

Go 1.20 的 unsafe.String(ptr,len) 与 StringData 可用于构造或取得字符串数据视图。String 创建期间以及字符串存活时，相应字节都不得修改；StringData 返回的字节也不得写入。把可复用网络缓冲区零拷贝转 string 后再回收修改缓冲，会悄悄改变看似不可变的字符串并引入数据竞争。若生命周期难以证明，string(buf) 的正常复制更可靠。

#### 验证工具与可移植性限制

go vet 能发现部分可疑 uintptr 用法，go test 或 go run 的 -gcflags=all=-d=checkptr=2 可增加指针检查，-race 可检查被执行路径的数据竞争；它们不是安全证明，漏掉的执行路径和错误前提仍可能产生未定义后果。unsafe 代码需要更强的边界测试，特别是零长度、nil、最大长度、对象移动与并发访问。

下面仅演示固定数组内部的合法指针偏移，以及已知长度的切片视图。数组元素存储连续且生命周期覆盖所有访问，偏移等于一个 uint32 的大小，视图长度固定为三，所以可逐项论证边界。程序不输出依赖指针宽度的描述符大小，保证这里的预期结果跨常见架构稳定；实际只在本机 darwin/arm64 运行验证，并不能据此声称所有架构都已测试。

#### 完整示例与运行结果

指针偏移一个 uint32 后读取第二项 20；unsafe.Slice 与原数组共享存储，修改视图第三项会让数组显示 [10 20 99]。uint32 固定为 4 字节，因此最后一行不依赖本机指针宽度。该示例没有演示非法内存访问，错误转换仅以注释反例呈现。


```go
package main
import (
 "fmt"
 "unsafe"
)
func main() {
 values := [3]uint32{10,20,30}
 // 目标地址仍位于 values 同一数组内，且按 uint32 对齐。
 ptr := unsafe.Add(unsafe.Pointer(&values[0]), unsafe.Sizeof(values[0]))
 second := (*uint32)(ptr)
 fmt.Println("second=", *second)
 // values 的已分配存储包含恰好三个元素；这里不借用外部内存。
 view := unsafe.Slice(&values[0], len(values))
 view[2] = 99
 fmt.Println("array=", values)
 fmt.Printf("elementBytes=%d\n", unsafe.Sizeof(values[0]))
 // 实际业务直接 values[:] 更简单；此处仅演示 API 契约。
}
```

在手册根目录运行：

```bash
go run ./examples/reference/stdlib/std-unsafe/main.go
```

预期输出：

```text
second= 20
array= [10 20 99]
elementBytes=4
```

注意：反例：把 uintptr 当持久指针保存，或把可修改 []byte 零拷贝转 string 后继续复用缓冲，都会破坏 GC 或不可变性契约；checkptr 通过也不能证明所有路径安全。

参考：unsafe 官方文档 — https://pkg.go.dev/unsafe

参考：runtime.KeepAlive 官方文档 — https://pkg.go.dev/runtime#KeepAlive

---

## 实现原理

### nil

nil 在指针、接口、slice、map、channel 和函数中的含义。

#### 先判断静态类型，再判断零值

nil 是预声明标识符，能作为指针、函数、slice、map、channel、接口的零值，不能直接赋给 int 或 string。单独写 x := nil 也无法推导类型。它不是一种“万能空对象”：同一个 nil 在不同类型上，可执行的操作完全不同。

判断一个值是否为空，首先看 API 要求的是“没有对象”“没有元素”还是“没有错误”。长度为零的 slice 可以是 nil，也可以已分配；接口内部可以存着一个 nil 指针，但接口本身已有动态类型。

#### 接口的动态类型不会因指针为 nil 而消失

接口值可以按“动态类型、动态值”理解。只有两者都不存在时，接口才等于 nil。把 (*Problem)(nil) 赋给 error，得到的动态类型是 *Problem，动态值才是 nil。运行时当前用类型信息和数据指针描述接口，但业务代码应依据规范判断，不依赖 unsafe 读取布局。


```go
package main
import "fmt"
type Problem struct{}
func (*Problem) Error() string { return "problem" }
func wrong() error { var p *Problem; return p }
func right() error { return nil }
func main() {
 var p *Problem
 var e error = p
 fmt.Println(p == nil, e == nil)
 fmt.Printf("%T\n", e)
 fmt.Println(wrong() == nil, right() == nil)
}
```

#### 逐行解释 typed nil 示例

第一行输出 true false：指针没有指向对象，接口却保留了 *main.Problem 这个动态类型。第二行打印这个类型。第三行输出 false true，说明返回接口的函数应在“无错误”路径显式返回 nil，而不是先声明具体错误指针，再把它装进 error。

示例的 Error 方法没有访问接收者，所以 nil 接收者也能调用它。如果方法读取接收者字段，就会 panic。方法能否接受 nil 属于该方法的实现约定，接口非 nil 不能证明其底层对象可解引用。

#### nil 容器允许哪些操作

nil slice 的 len、cap 都为零，可以 range、append、copy；append 的结果必须接回变量。nil map 可以查询、delete、range，却不能写入键。nil channel 的发送和接收永久阻塞，关闭会 panic；在 select 中，这种分支等同于被禁用。nil 函数可以比较，却不能调用。


```go
var s []int
s = append(s, 7) // 合法：保存新 slice
var m map[string]int
v, ok := m["x"] // v == 0, ok == false
_, _ = v, ok
// m["x"] = 7   // panic：写入 nil map
var ch chan int
select {
case <-ch:      // 永远不就绪
default:        // 立即选择
}
```

#### 长度为空不等于对象缺席

当函数接受 []byte，若只关心内容是否为空，用 len(b)==0 往往比 b==nil 更准确。若协议刻意区分“未提供”和“提供空列表”，就应保留 nil 与空 slice 的差异。不要为统一零值而随意初始化所有容器，这会改变序列化等上层可观察行为。

接口不能与任意动态值安全比较。两个接口若装着 slice、map、函数等不可比较类型，比较可能 panic；这与接口是否等于 nil 是不同的问题。接口与 nil 比较本身不需要比较底层不可比较值。

#### 用类型断言定位边界问题

在接口入口处，先判断接口为 nil；若契约允许特定指针，再使用类型断言检查该指针。反射的 IsNil 仅适用于若干 kind，对 int 等类型调用会 panic，因此不应把反射判空做成替代类型设计的通用补丁。


```go
e := wrong()
if p, ok := e.(*Problem); ok && p == nil {
 fmt.Println("接口装着 nil *Problem")
}
// 状态：error 非 nil → 断言成功 → 底层指针为 nil
```

#### 修复应落在值产生的位置

把错误构造器签名直接写成返回 error，并在成功分支 return nil，通常能从源头消除 typed nil。对指针接收者，明确决定 nil 是否有业务意义；如果没有，调用前验证真实对象。当前工具链 go1.27.1 的接口源码只用于辅助观察，这些判等结果由语言规范保证，与接口内部字段名称无关。

#### API 设计中的 nil 约定

公开函数应在文档中写清 nil 输入是拒绝、视为空集合，还是触发默认行为；同一个包不要让相邻函数采用相反含义。返回 slice 时可以统一返回 nil 或空 slice，但若 JSON、数据库更新或 patch 语义区分二者，就应在测试中固定约定。接口参数则要避免接收可能为 typed nil 的宽接口，或者在入口处明确检查。

检验边界时至少分别输入真正的 nil 接口、装有 nil 指针的接口和有效对象；三者走过的分支不同。若封装函数只转发接口，通常会保留动态类型，typed nil 不会因经过一层包装就自动变成空接口。

注意：不要把“接口 != nil”解释为“接口里的指针可以解引用”；应修复返回接口的成功路径。

参考：Go 语言规范 — https://go.dev/ref/spec

参考：Go 官方源码：runtime/runtime2.go — https://go.dev/src/runtime/runtime2.go

---

### defer

登记时求值、LIFO、返回值和资源释放。

#### 登记函数调用，退出当前函数时执行

执行 defer 语句时，会立即计算被调用函数、方法接收者和实参，然后把这次调用登记下来；被登记函数的函数体稍后才运行。同一函数中的多次登记按后进先出执行。defer 的作用域是函数调用，不是 for 循环的一次迭代，也不是任意一对花括号。

正常 return 和 panic 展开都会运行已登记的 defer；os.Exit 会直接终止进程，不能依靠 defer 清理。没有走到的 defer 语句不会被登记，例如资源申请失败时，应先处理错误，再登记关闭操作。

#### 实参保存旧值，闭包读取执行时的变量

下面同时放入实参调用和闭包。defer fmt.Println("arg", x) 保存整数 1；闭包则引用 x，执行时 x 已改成 2。运行顺序为 body 2、closure 2、arg 1；这是登记顺序和后进先出共同决定的。


```go
package main
import "fmt"
func values() {
 x := 1
 defer fmt.Println("arg", x)
 defer func() { fmt.Println("closure", x) }()
 x = 2
 fmt.Println("body", x)
}
func named() (n int) {
 defer func() { n++ }()
 return 10
}
func unnamed() int {
 n := 10
 defer func() { n++ }()
 return n
}
func main() {
 values()
 fmt.Println("returns", named(), unnamed())
}
```

#### return 先确定返回值，再执行 defer

named 中的 return 10 先把命名结果 n 赋成 10，defer 再把同一个结果变量加到 11，最后交给调用者。unnamed 没有命名结果，return n 先把 10 保存到返回位置；defer 改的是局部 n，最终仍返回 10。末行因此输出 returns 11 10。

不要把这个过程简化成“defer 在 return 前执行”，那会漏掉返回表达式求值和结果赋值。return build() 会先执行 build；若 build 自己 panic，当前函数已经登记的 defer 依然参与展开。

#### 接收者求值与指针指向内容是两回事

defer 保存的是实参的值；实参若为指针，保存的是地址而非对象快照。因此指针指向的对象在退出前发生变化，方法会读到变化后的内容。值接收者则会在调用登记时复制接收者值，这个复制仍可能包含 slice 或 map 等共享引用。


```go
type Counter struct { N int }
func (c *Counter) Show() { fmt.Println(c.N) }
func demo() {
 c := &Counter{N: 1}
 defer c.Show() // 保存 c 指向的对象地址
 c.N = 9
 c = &Counter{N: 100}
} // 输出 9：换变量指向不改变已保存的接收者
```

#### 循环中及时释放资源

在长循环里每次 Open 后 defer Close，会让所有文件一直保留到外层函数结束；即使循环体结束，也没有触发 defer。把单个任务提取为函数，让每次调用拥有自己的资源生命周期，既保留错误路径清理，也控制同时打开的句柄数量。


```go
func readOne(path string) error {
 f, err := os.Open(path)
 if err != nil { return err }
 defer f.Close()
 _, err = io.Copy(io.Discard, f)
 return err
}
// 片段需导入 os、io。每次 readOne 返回即关闭该文件。
```

#### 清理错误如何影响结果

读取文件时往往主要关心读取错误；写文件时 Close 可能报告最后的写入失败，不能一概忽略。可用命名 error 结果，让 defer 在主流程成功时接纳 Close 错误，或用 errors.Join 保留两者。应先确定调用者需要哪种错误契约，再决定返回值处理方式。

同理，defer mu.Unlock 应紧随成功 Lock；多把锁按固定顺序申请、反向释放。不要为了减少代码行数，把锁持有范围扩大到网络等待或整个请求处理过程。

#### 实现优化不改变调用语义

当前编译器可以把部分 defer 转成开放编码的清理路径，并非每次都一定分配一个堆对象。是否优化与控制流、编译器版本等有关，不能用早期实现推导固定开销。需要测量时用同一工具链的基准与编译诊断；先确保所有返回和 panic 路径的清理语义正确，再讨论性能。

#### 与资源所有权配合

defer 的最佳位置是成功取得资源之后、执行可能返回的主体之前。多个资源按取得的逆序释放，形成清晰的栈式所有权。若 Close 错误必须返回，就在命名结果中合并；若关闭只是尽力而为，则记录指标而不要用日志替代错误契约。这样代码阅读者能从登记位置直接看到清理覆盖的范围。

调试时可把函数入口、return 表达式、每次 defer 登记和延迟函数体分别打印出来，按事件顺序核对。尤其在命名错误结果被 defer 改写时，必须确认调用者收到的错误没有覆盖原始失败，并为两种失败都提供可追踪信息。

注意：defer 登记时保存实参，退出时执行函数体；返回值已先求值，命名结果仍可被 defer 修改。

参考：Go 语言规范 — https://go.dev/ref/spec

参考：Go 官方源码：runtime/panic.go — https://go.dev/src/runtime/panic.go

---

### panic

panic、recover、栈展开和错误边界。

#### panic 展开的是当前 goroutine 的调用栈

panic 表示当前正常控制流无法继续。运行时从发生点向外展开当前 goroutine 的函数调用，按各函数的后进先出顺序执行 defer。普通 error 是显式返回值，由调用者决定如何处理；panic 不应替代可预期的输入错误、连接超时或资源不足返回。

若展开到 goroutine 顶部仍未恢复，程序会失败退出，而不是只静默丢弃这个 goroutine。运行时自身的某些致命错误也不是普通可恢复 panic；不能用 recover 保证进程面对任何故障都继续运行。

#### 恢复边界返回，不会跳回故障行

示例在 boundary 的 defer 里直接调用 recover，把 panic 的值转成 error。panic 之后的打印永远不执行；先输出 cleanup，再输出 recovered: boom，最后 main 继续。恢复意味着结束这次受保护调用，不是让 panic 所在表达式重试。


```go
package main
import "fmt"
func boundary() (err error) {
 defer func() {
  if v := recover(); v != nil {
   err = fmt.Errorf("recovered: %v", v)
  }
 }()
 defer fmt.Println("cleanup")
 panic("boom")
 // 此处不会继续执行。
}
func main() {
 fmt.Println(boundary())
 fmt.Println("main continues")
}
```

#### recover 必须由正在执行的 defer 直接调用

在正常流程中调用 recover 会得到 nil。把 recover 藏进由 defer 再调用的普通辅助函数，也不能恢复外层 panic：有效调用必须直接发生在这个延迟函数里。辅助函数可以处理已经取出的 panic 值，例如格式化日志、分类错误，但获取动作应留在正确位置。


```go
func indirect() any { return recover() }
func wrongBoundary() {
 defer func() {
  fmt.Println(indirect()) // nil；辅助函数无法恢复这次 panic
 }()
 panic("still escapes")
}
// 故意错误片段：独立运行会失败退出，不属于上面成功示例。
```

#### 父 goroutine 的 recover 管不到子 goroutine

go 启动的新任务拥有独立调用栈，父函数的 defer 不在这条栈上。若后台任务允许在 panic 后上报失败，就在后台任务自己的入口登记恢复函数，并通过 channel 或结果结构把失败送回。父函数能等待结果，不代表它能够跨栈捕获异常。


```go
result := make(chan error, 1)
go func() {
 defer func() {
  if v := recover(); v != nil {
   result <- fmt.Errorf("worker panic: %v", v)
  }
 }()
 panic("worker failed")
}()
fmt.Println(<-result)
// 本片段只有 panic 路径；实际 worker 也必须发送成功/普通错误结果。
```

#### 恢复后要重新建立可用状态

如果 panic 发生在“扣库存成功、写订单失败”之间，释放互斥锁不等于撤销了库存修改。recover 只能恢复控制流，不能自动回滚业务状态。应先通过事务、先计算后提交、临时结构等设计保证不变量，再决定是否允许同一服务继续处理下一项任务。

在边界记录 panic 值和 debug.Stack 可以保留调用栈。直接吞掉 panic 并返回成功会把部分结果当作完整结果，后续错误反而更难定位。公开接口返回的信息可以简化，内部仍应保留足够的排查证据。

#### panic(nil) 的版本边界

从 Go 1.21 的默认行为起，panic(nil) 会产生非 nil 的 *runtime.PanicNilError，使直接 recover 能区分正在 panic 与正常返回；相关兼容开关见对应版本发布说明。不要拿旧版本“recover 得到 nil”的示例当成所有版本契约。手册示例使用字符串值，避免把兼容行为混进恢复机制本身。

#### 测试应区分恢复与退出

有效恢复示例可以在同一进程断言错误和执行顺序；验证未恢复 panic 应在子进程运行并断言非零退出，否则会终止整个测试程序。还要覆盖清理函数本身发生 panic 的情况，因为它可能改变最终暴露出的失败。不要依赖 panic 文本或内部栈格式在所有 Go 版本逐字一致。

#### 边界要保持窄而可审计

恢复边界通常放在 goroutine 入口、HTTP 请求入口或任务执行器，而不是每个小函数都加一层。边界应记录任务标识、panic 值和栈，并把该次任务标记失败；对共享服务状态则继续让显式错误和健康检查决定是否摘流量。恢复代码自身也要尽量简单，避免在异常路径再次访问可能已损坏的状态。

对库作者而言，公开文档应说明哪些无效参数会返回错误、哪些误用会 panic；调用者不能靠恢复边界替代参数检查。若选择把恢复值转换成 error，应保留错误类型或包装关系，避免日志中只剩一个缺乏来源的字符串。

注意：recover 只在当前 goroutine 的延迟函数中直接调用才生效；恢复后不会返回 panic 语句继续执行。

参考：Go 语言规范 — https://go.dev/ref/spec

参考：Go 官方源码：runtime/panic.go — https://go.dev/src/runtime/panic.go

参考：Go 1.21：panic(nil) — https://go.dev/doc/go1.21

---

### slice internals

slice header、扩容、copy 和逃逸影响。

#### slice 描述一段数组，不等于数组本身

slice 在语言上是对底层数组连续片段的描述。当前运行时布局包括数据指针、len 和 cap：len 限制合法索引，cap 表示从起点到底层可扩展范围。复制 slice 变量只复制描述信息，不会复制元素；多个描述可以指向同一块数组。

a[i] 要求 0≤i<len(a)，不能因为 cap 更大就直接索引。重新切片 a[:n] 可以在容量范围内扩大长度，但必须明确那些元素的来源。nil slice 的 len、cap 都为零，可以直接 append，空 slice 则未必为 nil。

#### 容量未耗尽时 append 会影响别名

下面 a 的长度为 2、容量为 4，b 是 a 的前缀。append(b, 9) 能复用底层数组，于是把 a[1] 改为 9。b 的新长度为 2，而 a 的长度仍由 a 自己的描述决定。slice 之间共享元素，并不共享长度字段。


```go
package main
import "fmt"
func main() {
 a := make([]int, 2, 4)
 a[0], a[1] = 1, 2
 b := a[:1]
 b = append(b, 9)
 fmt.Println(a, b, len(a), cap(a))
 c := a[:1:1]
 c = append(c, 7)
 c[0] = 8
 fmt.Println(a, c)
 dst := make([]int, len(a))
 n := copy(dst, a)
 dst[0] = 6
 fmt.Println(n, a, dst)
}
```

#### 三索引切片限制后续 append 的复用范围

c := a[:1:1] 的第三个索引把容量压到 1，因此追加第二个元素需要新数组；之后改 c[0] 不会改变 a。输出依次是 [1 9] [1 9] 2 4、[1 9] [8 7]、2 [1 9] [6 9]。

容量限制不是立即复制：执行 c := a[:1:1] 后、append 前，改 c[0] 仍会改 a[0]。它也不是并发隔离工具；其他 goroutine 若同时访问同一元素，仍需要同步。显式复制才适合表达独立拥有元素存储。

#### copy 复制多少由两边长度决定

copy(dst, src) 返回实际复制元素数，为两者长度的较小值，不看目的 slice 的容量。make([]T, 0, n) 虽然预留容量，copy 进去仍复制零个；应先建立所需长度。copy 允许源和目的重叠，可用于原地移动元素。


```go
a := []int{1, 2, 3, 4}
n := copy(a[1:], a[:3])
fmt.Println(n, a) // 3 [1 1 2 3]
dst := make([]int, 0, 10)
fmt.Println(copy(dst, a)) // 0：长度为零
```

#### 扩容倍率不是语言保证

append 只保证返回包含原内容与新增元素的 slice。增长到多少容量，取决于元素大小、申请长度、分配器大小级别和工具链实现；“小于某值翻倍，之后固定增长四分之一”只能描述特定历史源码的近似行为。

优化时先用 make([]T, 0, expected) 表达已知规模，再在真实数据上比较分配次数。不要写 cap 等于某个特定数值的业务断言，也不要用扩容后地址是否变化来推断长期稳定的所有权规则。

#### 截短不一定释放被引用对象

对 []*T 这样的指针 slice，a=a[:0] 只是改长度，底层数组里原来的指针可能仍让对象可达。删除元素后可清空不再使用的尾部，再缩短 slice；clear 从 Go 1.21 提供。即使清空元素，若还保留数组引用，底层数组本身也不会因此变小。


```go
// 删除索引 i；片段要求 0 <= i < len(a)。
copy(a[i:], a[i+1:])
a[len(a)-1] = nil // a 为 []*T，解除尾部重复引用
a = a[:len(a)-1]
// 若只留下巨大数组中的小片段，复制到较小新数组才能解除旧数组引用。
```

#### 复制元素不等于深复制对象

[]*Node 的 copy 只复制指针，[][]byte 的 copy 只复制内层 slice 描述；两个结果仍会共享下层对象。接口接受 slice 时应说明是否保留、修改或异步使用传入数据。要提供不可变快照，需要根据元素类型复制到正确深度，并在调用者开始修改前完成所有权交接。

#### 把切片所有权写进接口

异步函数若保存调用者传入的 slice，必须文档化“调用者不得再改”或在入口复制。返回内部缓冲会把封装边界暴露给调用者，调用者修改后可能破坏缓存索引。对于只读数据，可返回 string 或自定义只读抽象，但转换本身也要测量；关键是让谁拥有底层数组在接口上可判断。

最后还要把元素语义和切片语义分开，并明确接口边界。传入子切片时，容量可能仍覆盖调用者保留的尾部；被调用函数 append 就可能改写调用者认为与参数无关的数据。可在边界用三索引切片限制追加范围，若函数还会修改已有元素，则必须复制。测试同时覆盖容量充足和容量耗尽两种情况，避免只测到扩容路径而漏掉别名写入。

注意：append 必须接收返回值；限制 cap 不会立即复制，copy 也不会自动深复制指针指向的对象。

参考：Go 语言规范 — https://go.dev/ref/spec

参考：Go 官方源码：runtime/slice.go — https://go.dev/src/runtime/slice.go

参考：Go slices: usage and internals — https://go.dev/blog/slices-intro

---

### map internals

哈希、桶、扩容和并发读写。

#### 先掌握不随实现更换的语义

map 提供键到值的关联；键必须可比较。查询不存在的键得到值类型零值，逗号 ok 用于区分“缺席”和“存在但为零”。nil map 可以读取、遍历和删除，写入前需要 make。把 map 赋给另一个变量会共享同一个映射，不会复制其中的键值。

元素不是可寻址变量，所以不能对 m[k] 取地址，也不能直接给结构体值的字段赋值。需要取出结构体、修改后写回；或明确存放指针，并承担指针所指对象的并发保护责任。

#### 缺席、别名与稳定展示

示例用零值和缺席键比较逗号 ok，再通过别名新增键，说明两个变量引用同一映射。最后先收集和排序键再打印，显式获得稳定展示顺序；不要把某次运行恰好出现的 range 顺序写成程序前提。


```go
package main
import (
 "fmt"
 "sort"
)
func main() {
 m := map[string]int{"zero": 0}
 v, ok := m["zero"]
 fmt.Println(v, ok)
 v, ok = m["missing"]
 fmt.Println(v, ok)
 alias := m
 alias["two"] = 2
 keys := make([]string, 0, len(m))
 for k := range m { keys = append(keys, k) }
 sort.Strings(keys)
 for _, k := range keys { fmt.Println(k, m[k]) }
}
```

#### Go 1.24 默认切换到 Swiss Table

Go 1.24 发布说明明确内置 map 默认改用 Swiss Table。已核对的源码以 group 存放八个槽，控制字节保存槽状态及哈希的低位指纹，先批量筛出候选槽，再做完整键比较。高位哈希参与定位，探测继续寻找匹配键或空槽。

较大的 map 由目录选择 table，table 由多个 group 组成；扩展可涉及单表增长和拆分。小 map 有专门路径，因此不能把大 map 的目录结构套到每次 make 上。这里解释的是官方运行时实现，规范不保证组大小、指纹位数或增长阈值。


```text
键 → hash → 选择 table / 起始 group
             ↓
     控制字节筛选候选槽
             ↓
     完整键相等？是：返回值
             否：继续探测或判定缺席
这是查找过程示意，不是 Go 源码，也不代表每次都走大表目录。
```

#### 旧桶实现要标明历史版本

Go 1.23 的 runtime/map.go 使用旧的桶与溢出桶设计，包含 tophash、旧桶和渐进搬迁等机制。它能帮助阅读历史性能文章，但不能再称作“所有 Go map 的底层”。Go 1.24 曾提供 noswissmap 构建实验开关，开关是否仍存在应查目标工具链，不应建议长期依赖。

对比实现时固定 Go 版本、GOEXPERIMENT、键值类型和负载形态。一次字符串键基准变快，不能推导所有整数键、小 map 或删除密集工作负载都有相同收益。

#### 遍历期间修改的规定比随机更具体

规范规定：遍历中删除尚未到达的项，该项不会再产生；遍历中新增的项，可能产生，也可能跳过，且不同项可能不同。迭代顺序未指定，也不保证两次遍历一致。“随机”是实现层面的描述，不能进一步假设均匀抽样或可复现种子。


```go
m := map[int]string{1: "a", 2: "b", 3: "c"}
for k := range m { delete(m, k) }
fmt.Println(len(m)) // 0；遍历删除是允许的
// 要随机选择一项，应显式定义抽样算法，不能把 range 第一项当均匀随机。
```

#### 普通 map 的并发边界

没有并发写时，多个 goroutine 可以并发读取；一旦存在写入，所有与其冲突的访问都必须同步，包括查询、len、遍历和删除。运行时可能报告 concurrent map read and map write，但没有触发报错不代表没有数据竞争，也不能靠 recover 修复。

用 Mutex 包住整个读改写过程才能保护业务不变量。若锁保护映射而值是 *Item，解锁后修改 Item 字段仍可能竞争。race 检测器可以发现实际执行到的冲突，但不能证明所有潜在输入都安全。

#### 删除、预分配和内存观察

make(map[K]V, n) 的 n 是容量提示，不是长度，也不是固定容量承诺。delete 或 clear 移除条目，不承诺立即把内部存储归还操作系统。长生命周期映射从峰值回落时，可在合适的同步边界创建新 map、复制仍需的条目并替换引用，再用堆 profile 判断实际收益。

接口作为 map 键时，动态值也必须可比较：把一个 slice 装入 any 再作为键，不会绕过键约束。浮点 NaN 虽然类型可比较，却不等于自身，使用它作为键会产生很难按原值查询或删除的条目；业务键应优先选择语义稳定的整数或字符串。

注意：不要用旧 hmap/溢出桶讲解所有版本；语言契约稳定，Go 1.24 默认 Swiss Table 的内部细节仍可改变。

参考：Go 语言规范 — https://go.dev/ref/spec

参考：Go 1.24 发布说明 — https://go.dev/doc/go1.24

参考：Go 官方源码：internal/runtime/maps/map.go — https://go.dev/src/internal/runtime/maps/map.go

参考：Go 1.23 旧 map 源码 — https://github.com/golang/go/blob/go1.23.0/src/runtime/map.go

---

### string internals

字符串不可变、字节切片和转换成本。

#### string 保存只读字节序列

Go string 是不可变的字节序列，不承诺其中一定是有效 UTF-8。len 返回字节数，s[i] 返回 byte。当前运行时描述可以理解为数据指针和长度，但不能借助指针修改字符串内容；字符串值可共享底层字节，语言提供的不可变性是这种共享安全的基础。

byte 是 uint8 的别名，rune 是 int32 的别名。处理网络协议、文件格式或哈希时通常按字节；处理 Unicode 码点时按 rune。用户看到的一个字形还可能包含多个码点，rune 数也不等于所有语言中的视觉字符数。

#### 一个例子看清字节、码点和索引

字符串 A中🙂 包含 3 个码点、8 个 UTF-8 字节。range 返回每个码点起始位置的字节偏移和解码后的 rune，所以偏移是 0、1、4。s[1] 是中字的首字节 e4，不是整个中字，也不是第 2 个 rune。


```go
package main
import (
 "fmt"
 "unicode/utf8"
)
func main() {
 s := "A中🙂"
 fmt.Println(len(s), utf8.RuneCountInString(s))
 for i, r := range s { fmt.Printf("%d %U\n", i, r) }
 fmt.Printf("byte=%x\n", s[1])
 b := []byte(s)
 b[0] = 'B'
 fmt.Println(s, string(b))
 bad := string([]byte{0xff, 'A'})
 fmt.Println(utf8.ValidString(bad), utf8.RuneCountInString(bad))
}
```

#### 输出中的转换不改变原字符串

前三个码点依次显示 U+0041、U+4E2D、U+1F642。把 s 转成 []byte 后修改 b[0]，输出仍为 A中🙂 B中🙂：语言语义要求修改可变字节切片不能改变原字符串。无效字节串的末行是 false 2，说明无效 UTF-8 仍可存入 string。

range 或 utf8 解码遇到无效编码，会以 RuneError 表示并按解码规则前进。若协议要求有效文本，要先验证，再决定拒绝、保留原字节还是替换；仅转换成 string 不会完成编码校验。

#### 切片下标按字节，可能截断编码

s[:2] 会截取 A 加上中字的首字节，结果不再是有效 UTF-8。按 rune 截断可先转 []rune，但这需要解码并通常分配空间；如果只是寻找前 n 个码点的边界，可以 range 记录字节偏移后再切片。按用户字形截断还需要 Unicode 分段规则，不能只依赖 rune。


```go
s := "A中🙂"
fmt.Println(utf8.ValidString(s[:2])) // false
r := []rune(s)
fmt.Println(string(r[:2]))          // A中
// 示例片段需导入 fmt、unicode/utf8；[]rune 按码点切分，不按字形簇。
```

#### 转换成本由语义和优化共同决定

string(b) 得到的字符串不能在以后因 b 的合法写入而变化；[]byte(s) 也必须支持独立修改。编译器可在确认结果只读、短暂使用等条件下消除某些复制，不能因此承诺所有转换都是零复制。[]rune(s) 还包含 UTF-8 解码，成本与字节数量和内容有关。

用基准报告 ns/op、B/op 和 allocs/op，并让结果被使用，避免死代码消除。不要把 unsafe 零复制工具当作普通类型转换替代品：它会把不可变性和生命周期责任交还给调用者，尤其容易破坏 map 字符串键的稳定性。

#### 拼接与保留大块底层存储

少量固定拼接用 + 易读；循环构建大字符串可用 strings.Builder，并在知道规模时 Grow。Builder 使用后不能复制，导出的 String 必须遵循其 API 契约。fmt.Fprintf 的格式化更灵活，但也应在热点里实际测量。


```go
var b strings.Builder
b.Grow(32)
for _, part := range []string{"go", "-", "runtime"} {
 b.WriteString(part)
}
s := b.String()
fmt.Println(s) // go-runtime
// 片段需导入 fmt、strings；不要复制已经写入的 b。
```

#### 小子串也可能延长大字符串生命

从很大的字符串取出小子串，当前实现通常仍引用原字节存储，因此只保留几十字节也可能使大块内存继续可达。明确需要独立副本时可用 strings.Clone，再通过堆 profile 验证保留量。是否共享存储是性能层面的实现事实，不应通过地址比较写入业务逻辑；文本相等始终比较字节内容。

#### 文本边界应先定义编码

协议层常把长度按字节计算，而界面层按码点或字形截断；同一字段不能在不同层随意换算法。解析 UTF-8 时保留原始字节有助于签名和审计，显示时再替换无效序列。大小写折叠、规范化和分词也不是简单的 byte/rune 问题，应交给对应 Unicode 包并按版本测试。

如果文本用作数据库唯一键或认证标识，还要明确大小写和规范化规则；视觉相似的字符串可能拥有不同字节序列。Go 的字符串相等和排序是字节语义，不会自动做语言习惯的比较。输入验证与显示应分别处理这些差异。

注意：len 和索引按字节；range 按 rune 解码但返回字节偏移。string 既不保证有效 UTF-8，也不允许合法地原地修改。

参考：Go 语言规范 — https://go.dev/ref/spec

参考：标准库 unicode/utf8 文档 — https://pkg.go.dev/unicode/utf8

参考：标准库 strings 文档 — https://pkg.go.dev/strings

参考：Strings, bytes, runes and characters in Go — https://go.dev/blog/strings

---

### channel internals

发送、接收、关闭、缓冲和阻塞。

#### 通信与等待由同一个操作表达

channel 是带类型的通信通道。无缓冲通道的发送需要匹配接收，接收也需要匹配发送；带缓冲通道在仍有空槽时允许发送完成，空且未关闭时接收阻塞。阻塞会挂起 goroutine，让调度器执行其他工作，并不等于持续占用 CPU 自旋。

当前运行时 hchan 包含队列索引、缓冲区、发送与接收等待队列、关闭状态以及内部锁。这解释了通道如何协调等待者，但不能从内部队列形状推导多个竞争 goroutine 必然按启动顺序完成。

#### 关闭保留已有数据，随后返回零值

下面先写入 7、8，再 close。前两次接收仍得到数据且 ok 为 true；缓冲耗尽后每次接收立即返回零值和 false。关闭不是“抹掉队列”，也不是向队列追加一个普通零值；接收端必须用 ok 区分真实零值与结束状态。


```go
package main
import "fmt"
func main() {
 ch := make(chan int, 2)
 ch <- 7
 ch <- 8
 close(ch)
 for i := 0; i < 3; i++ {
  v, ok := <-ch
  fmt.Println(v, ok)
 }
 var disabled chan int
 select {
 case <-disabled:
  fmt.Println("unreachable")
 default:
  fmt.Println("nil disabled")
 }
 done := make(chan struct{})
 go func() { close(done) }()
 <-done
 fmt.Println("joined")
}
```

#### 不同状态对应不同阻塞与失败行为

nil channel 的发送、接收永远不就绪，close(nil) panic。向已关闭通道发送也 panic，重复关闭同样 panic；接收已关闭通道按“先排空再零值”的规则立即或最终继续。只读通道不能关闭，这是编译期方向约束。


```text
状态             发送                    接收
nil              永久阻塞                永久阻塞
无缓冲、无对端    阻塞                    阻塞
有缓冲且未关闭    满时阻塞，否则写入       空时阻塞，否则取出
已关闭            panic                   排空后返回零值、false
关闭不会等待接收者消费完；也不会等待所有工作函数返回。
```

#### close 的所有权需要明确

通常由能证明所有发送都已结束的一方关闭通道。单发送者可在退出时 defer close；多发送者应由协调者等待所有发送者完成后关闭。消费者提前 close 来取消生产者，会把仍在发送的生产者推向 panic。取消应使用独立的 Done 信号，让发送者自行选择退出。

sync.Once 可以阻止重复 close，却不能消除 close 与 send 的竞态。真正的条件是关闭之前没有未来发送；这是生命周期协议，不能靠先检查“是否关闭”再发送实现，因为检查和发送之间仍可发生关闭。

#### 同步保证比日志顺序更精确

发送之前的写入，在与该发送匹配的接收完成后可见；关闭之前的写入，在因关闭而获得零值的接收之后可见。无缓冲通道还提供接收与发送完成之间的同步关系；有缓冲通道允许发送领先接收，不能照搬无缓冲握手的推理。


```go
var answer int
done := make(chan struct{})
go func() {
 answer = 42
 close(done)
}()
<-done
fmt.Println(answer) // 42：close/接收建立同步
// 若把 <-done 删掉，读取 answer 就没有这个同步保证。
```

#### 缓冲是流量窗口，不是无限吞吐

容量让生产者最多领先一定数量，吸收短暂波动；消费者长期更慢，队列最终仍会填满。不要把容量调大当成修复泄漏：若接收者提前返回，生产者依然可能永久等待。发送和接收通常都应能选择取消，调用方还要等待 goroutine 完成，才能确定资源已经回收。

len(ch) 只给出一个瞬时排队数量，不能用于先判断非空再保证接收不会阻塞，也不能证明所有任务已处理。正确性用阻塞操作与生命周期信号表达，len 适合辅助观察。

#### 传值仍可能共享对象

channel 传递元素值的副本；元素为 slice、map 或指针时，只复制描述或地址，底层内容仍共享。把 []byte 发送出去后立即复用同一缓冲，接收端可能读到被改写的数据。应约定移交所有权、复制内容，或由确认信号决定何时允许复用；通道本身不会替你深复制对象。

通道容量还会影响背压出现的位置。无缓冲发送完成只能证明相应接收已发生，并不证明接收者已经处理完数据。若生产者需要知道“持久化成功”再复用资源，就增加应答消息，并让应答携带错误。用容量为零代替确认协议，会把通信完成误当成业务完成。

注意：关闭不是取消发送的安全手段；只有确认全部发送结束后才能 close，多个接收者也不保证按启动顺序消费。

参考：Go 语言规范 — https://go.dev/ref/spec

参考：Go 内存模型 — https://go.dev/ref/mem

参考：Go 官方源码：runtime/chan.go — https://go.dev/src/runtime/chan.go

---

### select

多路等待、默认分支、超时和公平性。

#### 一次选择一条当前可继续的通信

进入 select 时，接收通道表达式、发送通道和发送值表达式按源码顺序各求值一次。若一个或多个通信可以进行，从可进行的分支中作均匀伪随机选择；都不能进行时，有 default 就立即走 default，没有 default 就阻塞等待。

源码靠前不代表优先级，Done 分支也没有特殊优先级。某次操作选中了发送，只说明它当时可进行；不能据此保证之后没有取消。空 select 永久阻塞，因此有时用于演示，不应意外留在正常退出路径。

#### nil 分支禁用，关闭分支持续就绪

示例用一个 nil 通道和一个已缓冲的通道，只有缓冲接收能就绪。接着把关闭通道连续接收两次，均立即返回 false，说明一个未移除的关闭分支可能让循环不断空转。第一次处理完结束事件后，可把变量设为 nil 禁用该分支。


```go
package main
import "fmt"
func main() {
 var disabled <-chan int
 ready := make(chan int, 1)
 ready <- 7
 select {
 case <-disabled:
  fmt.Println("unreachable")
 case v := <-ready:
  fmt.Println("ready", v)
 }
 closed := make(chan int)
 close(closed)
 for i := 0; i < 2; i++ {
  select {
  case _, ok := <-closed: fmt.Println("closed", ok)
  }
 }
 closed = nil
 select {
 case <-closed:
 default: fmt.Println("disabled after close")
 }
}
```

#### 准备发送的值即使未选中也会求值

select 并不是延迟执行每个 case 的整个表达式。以下 build 会在选择前调用，即使该发送最终不可用而走了 default。昂贵编码、读取外部状态、带副作用的调用都不应随意放进发送右侧；先明确是否接受这次预计算。


```go
func build() int {
 fmt.Println("build called")
 return 9
}
func demo(ch chan int) {
 select {
 case ch <- build():
  fmt.Println("sent")
 default:
  fmt.Println("not sent")
 }
}
// demo(nil) 输出 build called、not sent。发送值并没有等到分支获选才计算。
```

#### 取消与结果同时到达时没有默认优先级

select 同时看到 <-ctx.Done() 和可发送结果时，两者都可能获选。提前检查 ctx.Err() 可以避免已知取消后的昂贵准备，却无法消除检查之后发生取消的窗口。若业务要求“截止后绝不提交”，需要在提交点使用受锁状态、事务或其他原子协议验证截止状态。


```go
select {
case out <- result:
 return nil
case <-ctx.Done():
 return ctx.Err()
}
// 两边同时就绪：可能发送，也可能取消；这不是取消信号失效。
// 取消是协作退出请求，不是撤销已完成副作用的事务。
```

#### default 适合显式非阻塞策略

default 可表达“队列满就拒绝”“当前没有消息就返回”。如果把它放进无限 for，而且默认分支不等待，循环会持续消耗 CPU；插入随意 Sleep 只是降低频率，没有建立正确唤醒协议。需要等待事件时删去 default，让通道、定时器或 context 提供唤醒。

带 default 的发送可以实现有意丢弃，但应把丢弃计数纳入观测，并确认数据允许丢失。请求结果、账务更新等不能无声丢弃的消息，应明确返回拥塞错误或等待处理。

#### 超时分支只让这一层停止等待

case <-time.After(d) 返回后，底层工作不会自动停止。要把 context 传入支持取消的操作，或显式关闭拥有的连接，并用完成信号等待工作结束。循环里不断新建超时也容易让预算每轮重置，应区分“整体截止时间”和“每次空闲超时”。

旧 Go 关于定时器回收和 Stop/Reset 的经验需要对照 Go 1.23 之后的时间包说明。使用目标版本的 API 契约，不要把历史排空 channel 的模板不加判断地复制到所有版本。

#### 公平性不等于实时调度保证

均匀伪随机选择描述的是一次 select 在就绪候选中的选择规则，并不保证每个任务在固定时间内获选，也不保证跨多个 goroutine 的先来先服务。调度延迟、分支持续就绪与代码耗时都会影响观测。需要优先级、限速或轮转时，应把队列与规则写成显式状态机，并用负载测试检查延迟。

select 组合复杂时可拆出明确的事件循环。当多个输入通道需要一直收集到全部关闭时，可维护活跃输入计数，每遇到关闭就把对应变量置 nil 并减一。计数归零后退出循环；否则全部分支都变 nil 的 select 会永久睡眠。测试应覆盖某个输入先关闭、另一个继续产生数据的路径。

注意：select 不优先取消分支；发送右侧会预先求值，关闭通道会持续就绪，default 循环可能忙等。

参考：Go 语言规范 — https://go.dev/ref/spec

参考：Go 官方源码：runtime/select.go — https://go.dev/src/runtime/select.go

参考：标准库 time 文档 — https://pkg.go.dev/time

---

### sync.Map

读多写少场景和类型安全权衡。

#### 并发映射的契约与适用负载

sync.Map 是可并发访问的专用映射，零值即可使用，第一次使用后不能复制。官方文档强调两种典型负载：条目写一次后读很多次，或不同 goroutine 主要操作互不重叠的键集合。普通 map 配锁仍能提供更强的类型约束，并更容易保护映射之外的业务不变量。

接口以 any 接收键值，键仍必须可比较。内部同步只保护映射操作，不会自动保护存入的指针、slice 或自定义对象；取得对象后对字段并发写入，仍要有对象自己的锁或不可变约定。

#### LoadOrStore 只决定哪个值被发布

两次调用同一键，第一次返回新值和 loaded=false，第二次返回原值和 loaded=true。它保证这一操作的原子发布，不保证传给它的值只计算一次：实参在调用前就已求值。昂贵初始化需要另行协调，例如每键保存一个带 Once 的条目。


```go
package main
import (
 "fmt"
 "sync"
)
func main() {
 var m sync.Map
 v, loaded := m.LoadOrStore("count", 1)
 fmt.Println(v, loaded)
 v, loaded = m.LoadOrStore("count", 99)
 fmt.Println(v, loaded)
 fmt.Println(m.CompareAndSwap("count", 1, 2))
 v, ok := m.Load("count")
 fmt.Println(v, ok)
 v, ok = m.LoadAndDelete("count")
 fmt.Println(v, ok)
 _, ok = m.Load("count")
 fmt.Println(ok)
}
```

#### 复合操作不会因单步安全而自动原子

示例的结果依次是 1 false、1 true、true、2 true、2 true、false。CompareAndSwap 把“检查旧值与替换”合成一步，而 Load 后再 Store 分开执行会丢更新。对计数可使用 CAS 重试，或将 *atomic.Int64 作为条目，让映射负责定位、原子变量负责计数。


```go
// 片段：key 已初始化为 int，且没有其他 goroutine 删除它。
for {
 old, _ := m.Load(key)
 n := old.(int)
 if m.CompareAndSwap(key, old, n+1) { break }
}
// 若允许删除/重建，必须定义不存在时的行为，不能直接断言类型。
```

#### Range 不是一致快照

Range 不保证看到某个统一时间点的全部内容；并发 Store/Delete 发生时，每个键对应的观察时间可能不同，但一个键不会被多次访问。回调返回 false 会停止遍历，但调用成本不应简单假定与访问到的条目数成正比，文档允许更广的工作量。

因此 Range 适合近似监控或不要求一致性的遍历；跨键对账、生成精确快照或保持两键总和，应使用能覆盖整体过程的锁或版本快照结构。把 Range 结果排序只能稳定展示顺序，不能补上它没有提供的一致性。

#### 从 read/dirty 到哈希树要注明版本

早期 sync.Map 使用只读快照、dirty 映射及 miss 计数的设计；Go 1.24 发布说明记录了新的哈希树实现。已核对的 go1.27.1 sync/map.go 包装 internal/sync.HashTrieMap，因此“读路径永远查 read、未命中提升 dirty”不能作为当前通用解释。

哈希树通过哈希片段定位分支并组织条目，具体锁粒度、节点布局和冲突处理由目标版本源码决定。它与 Go 1.24 内置 map 的 Swiss Table 是两项不同改动，不能因为发布日期接近就把两种结构混成同一个实现。


```text
内置 map：语言内建类型；并发冲突需调用方同步；Go 1.24 默认 Swiss Table
sync.Map：标准库并发容器；公开 Load/Store/CAS 等契约；Go 1.24 改为哈希树
旧文章 read/dirty：理解历史版本时有用；不能直接套在当前源码上
```

#### 发布可见性与值的可比较要求

sync.Map 文档精确定义了哪些调用属于读、哪些属于写：例如 LoadOrStore 仅在 loaded=false 时是写，CAS 仅在成功时是写。观察到某次写效果的读，与该写建立文档所述的同步关系，可用于安全发布已经初始化的不可变对象。

CompareAndSwap、CompareAndDelete 对旧值还有可比较要求；把 slice 作为旧值参与比较会有问题。即便某个值能 Store，也不表示它适合每一种条件更新方法，必须读取具体 API 的契约。

#### 选择容器前先定义一致性范围

如果业务更新同时涉及缓存条目、计数器和索引，用普通 map 加同一把锁通常更容易审计。若使用 sync.Map，应把跨键事务需求移到更高层，而不是堆叠多个安全方法后假定整体安全。基准需覆盖键分布、读写比例、冲突和条目生灭；只有读操作的微基准不足以决定生产中的容器选择。

LoadOrStore 返回 loaded=true 时，未被采用的候选对象由调用者负责清理，特别是候选构造过程中已经打开了连接或文件。惰性包装能避免重复昂贵初始化，但包装内部的错误缓存、取消和资源关闭仍需要单独约定。

注意：sync.Map 保证单个公开操作的并发契约；Load+Store、Range 快照和指针指向对象都需要额外设计。

参考：标准库 sync 文档 — https://pkg.go.dev/sync

参考：Go 内存模型 — https://go.dev/ref/mem

参考：Go 1.24：sync.Map 实现变更 — https://go.dev/doc/go1.24

参考：Go 官方源码：sync/map.go — https://go.dev/src/sync/map.go

---

### G-M-P 调度

goroutine、machine、processor 的调度关系。

#### G、M、P 分别解决什么问题

在 Go 官方运行时中，G 描述 goroutine 的执行状态和栈；M 对应执行代码的操作系统线程；P 提供执行 Go 代码所需的调度与分配等资源。M 要执行普通 Go 代码通常需要持有 P，处于系统调用的 M 则可能暂时不持有 P。

GOMAXPROCS 控制同时执行用户 Go 代码的 P 数量，不是 goroutine 总数，也不是进程操作系统线程数上限。许多等待中的 G 可以共存；线程数还会受系统调用、cgo、线程锁定和运行时工作影响。

#### 单个 P 也能推进多个 goroutine

示例暂时设置 GOMAXPROCS(1)，工作 goroutine 发送消息时阻塞，main 接收后双方继续。输出固定为 worker 1、worker 2、done，因为每次通信及关闭提供明确协调；这个顺序来自程序协议，并非一个 P 会按创建顺序执行任务。


```go
package main
import (
 "fmt"
 "runtime"
)
func main() {
 old := runtime.GOMAXPROCS(1)
 defer runtime.GOMAXPROCS(old)
 ch := make(chan int)
 go func() {
  defer close(ch)
  ch <- 1
  ch <- 2
 }()
 for n := range ch { fmt.Println("worker", n) }
 fmt.Println("done")
}
```

#### 可运行队列与工作窃取

P 维护本地可运行工作，运行时也有全局工作来源，并可从其他 P 获取工作，以降低共享队列争用和减少空闲。新 G 何时被调度、由哪个 M 执行，取决于当时调度状态；所谓 work stealing 描述负载分配机制，不是应用可以依赖的固定顺序。

当前源码还有 runnext 等快速路径，队列检查、窃取策略和批量大小可随版本调整。不要将某张历史调度图里的常量当成语言保证。需要串行处理的业务，显式使用单消费者队列或锁来表达。


```text
G 可运行 → 放入可运行来源 → M 持有 P → G 执行
G 等待 channel / 锁 / 可轮询 I/O → park → M/P 执行其他 G
事件完成 → G 变为可运行 → 等待再次获调度
“变为可运行”与“已经执行”之间仍可能存在排队延迟。
```

#### 不同阻塞对线程的影响不同

channel、同步等待和受 netpoll 管理的网络 I/O 通常停放 G，让线程继续执行别的 G。真正阻塞的系统调用或 cgo 可能占住 M；运行时可以把相关 P 交给其他 M，使 Go 工作继续。不能据此说“每次阻塞都创建新线程”，也不能说“所有 I/O 都不占线程”。

LockOSThread 把 G 绑定到特定线程，适合需要线程局部状态的系统接口，却可能影响可调度资源。普通业务若没有这种要求，不应通过锁线程追求虚构的 goroutine 固定身份。

#### 抢占提升响应，但不提供实时上限

现代 Go 运行时同时包含协作式安全点和异步抢占机制；Go 1.14 引入异步 goroutine 抢占。调度器能要求长时间运行的 G 让出执行机会，但实际时机受代码位置、运行时不可抢占区、操作系统和平台支持影响。

runtime.Gosched 只是主动让出当前执行机会，不是等待某个特定 goroutine 完成。Sleep 也只保证至少等待相应条件，不保证醒来立刻运行。若正确性依赖 Sleep“给另一个任务一点时间”，应改为 channel、WaitGroup 或其他明确同步。

#### 用 trace 区分执行、等待和排队

runtime.NumGoroutine 只能提供一个数量观察，无法回答 CPU 为什么忙、请求为什么慢。trace 能看到 goroutine 状态、系统调用和调度延迟；CPU profile 更适合分析实际耗费 CPU 的函数，阻塞 profile 则关注等待。选择与症状对应的数据，而不是只看 goroutine 数量。


```bash
# 在支持对应测试的项目中采集；这些命令不是本页运行示例输出。
go test -run '^TestWork$' -trace trace.out ./...
go tool trace trace.out
# 应观察：长时间 running、runnable 排队，还是 waiting / syscall。
# trace 与 profile 的具体 UI、事件细节受 Go 工具链版本影响。
```

#### 并行度设置要与部署环境核对

默认 GOMAXPROCS 的确定方式会随版本演进，例如 Go 1.25 开始加入容器 CPU 限额相关默认行为。应记录实际运行版本和 runtime.GOMAXPROCS(0)，再结合 CPU 限额、负载和延迟调整。把 P 数设得很大不会创造额外 CPU，反而可能增加竞争；把它设为 1 也不能消除共享内存数据竞争。

#### 调度观察不能替代同步

即使 trace 显示某 goroutine 先变为 runnable，另一个 goroutine 仍可能先运行。用 channel、锁、原子操作和 WaitGroup 建立可验证的 happens-before 关系，才能让结果稳定。调度器只负责提供执行机会；它不会把共享变量访问自动串行化，也不会把 goroutine 创建顺序转换成完成顺序。

记录调度性能时同时写下操作系统、CPU 限额和 Go 版本，否则两次测试中的默认并行度不同，结论就可能不可比较。

注意：GOMAXPROCS 限制 Go 代码并行度，不限制 goroutine 或线程总数；G 变为 runnable 不代表立刻运行。

参考：Go 官方源码：runtime/proc.go — https://go.dev/src/runtime/proc.go

参考：Go 调度器内部文档 — https://go.dev/src/runtime/HACKING.md

参考：标准库 runtime 文档 — https://pkg.go.dev/runtime

参考：Go 1.14 异步抢占 — https://go.dev/doc/go1.14

参考：Go 1.25 运行时更新 — https://go.dev/doc/go1.25

---

### 内存分配

栈、堆、逃逸分析和对象生命周期。

#### 分配位置由逃逸分析决定

Go 规范不把变量简单分成“局部栈变量”和“全局堆变量”；编译器可把对象放在栈上或堆上，只要程序语义不变。返回局部变量地址是安全的，因为编译器会让需要在函数外存活的对象逃逸。堆对象由垃圾回收器管理，栈帧则随调用返回回收。

“逃逸”是编译器对存活关系的判断，不是看到 &x 就能断定慢，也不是堆分配必然比栈分配独立贵到同一程度。闭包、接口装箱、切片扩容、返回指针和跨 goroutine 传递都是常见触发因素，具体要看编译器版本和类型。

#### 返回指针示例与编译诊断

函数返回局部整数指针，调用者能安全读取；编译器会在合适的位置分配并保持它的生命期。用 -gcflags=-m=2 可查看当前工具链的逃逸决策，但诊断文本不是语言契约。


```go
package main
import "fmt"
func makeValue() *int {
 x := 42
 return &x
}
func main() {
 p := makeValue()
 fmt.Println(*p)
}
// 运行：go run main.go
// 诊断：go build -gcflags=-m=2 main.go 2>&1 | rg 'makeValue|escapes|moved to heap'
```

#### 输出只验证语义，不验证位置

程序输出 42；无论 x 最终在栈还是堆，调用者看到的值都一样。把编译诊断中的 moved to heap 当成错误没有意义，关键是它是否造成可观测分配、延长了大对象生命，或出现在真正的热点。不同优化级别、架构和 Go 版本可能给出不同决策。

#### 接口与闭包可能扩大存活范围

把具体值交给 interface 可能需要装箱或建立可被接口引用的表示；返回闭包会让闭包捕获的变量活得更久；把指针交给 goroutine 则要求对象至少存活到异步读取结束。编译器会处理安全性，但不会替调用者建立业务上的所有权规则。

逃逸分析不是数据竞争分析。对象放堆上不等于并发安全，放栈上也不等于没有同步需求；共享关系由指针、slice、map、channel 和 goroutine 的通信决定。

#### 切片和扩容改变引用关系

局部 slice 头可以放栈上，但其底层数组可能因 append、返回或异步使用而存活在堆上。将一个大数组的小片段返回，会让大数组继续可达；复制出小数组能缩短保留生命。切片头复制本身很小，不能据此推导元素存储的分配位置。


```go
func prefix(data []byte) []byte {
 out := make([]byte, 16)
 copy(out, data[:min(16, len(data))])
 return out // 只保留独立的小数组
}
func min(a, b int) int { if a < b { return a }; return b }
// 是否值得复制，应由堆 profile 与实际保留量证明。
```

#### 用基准观察而不是猜测

基准必须阻止结果被编译器消除，并使用 -benchmem 查看分配次数和字节数。runtime.ReadMemStats 可做粗粒度观测，但会受其他 goroutine、GC 时机和运行环境影响；要定位对象来源，优先 pprof alloc/heap 与编译器诊断。


```go
func BenchmarkMakeValue(b *testing.B) {
 for i := 0; i < b.N; i++ { sink = makeValue() }
}
var sink *int
// 命令：go test -bench=MakeValue -benchmem ./...
// 结果数值依机器和 Go 版本变化，不能把一次运行写成固定保证。
```

#### 栈增长不是固定大小承诺

goroutine 栈从较小空间开始，需要时由运行时增长并复制活动帧；因此“每个 goroutine 固定 2KB 栈”的旧说法不准确。栈增长会更新指针并由编译器/运行时配合安全点完成。深递归或大局部数组仍可能导致栈增长和最终栈溢出，不能靠把 goroutine 当成无限栈。

栈帧实现、分段与连续栈的细节受版本影响。业务代码只应依赖函数调用语义；遇到内存问题用 profile 和诊断确认是堆保留、栈增长、分配速率还是外部资源泄漏。

#### 减少分配要维护清晰所有权

预分配、复用缓冲、值传递和结构调整可能降低分配，但也可能延长对象生命、引入锁或造成数据复用 bug。sync.Pool 适合临时对象，不能用作可靠缓存；放回池前要清理敏感或大容量内容，并确认不会被仍在使用的调用者引用。优化后的收益必须在目标负载与版本上重测。

编译器内联可能把原本逃逸的返回对象重新限定在调用者栈中，因此对单个函数的分析必须结合调用点阅读。

注意：逃逸分析是当前编译器的优化决定；返回局部地址安全，-m 输出和栈/堆布局不能当作跨版本契约。

参考：Go 语言规范 — https://go.dev/ref/spec

参考：Go 官方源码：cmd/compile/internal/escape — https://go.dev/src/cmd/compile/internal/escape

参考：Go GC guide — https://go.dev/doc/gc-guide

参考：标准库 runtime/pprof 文档 — https://pkg.go.dev/runtime/pprof

---

### 垃圾回收

三色标记、并发 GC 和暂停。

#### GC 判断可达性而不是“变量是否局部”

垃圾回收器从根集合出发，沿指针遍历仍可访问的对象；不可达对象才有资格回收。全局变量、活动 goroutine 栈、寄存器和运行时内部引用都可能成为根。把 slice 截短不一定让底层数组不可达，因为仍保留的 slice 头、数组元素或其他别名可能指向它。

可达不等于业务仍需要：缓存、全局注册表、goroutine 闭包和定时器都可能无意延长生命。释放业务引用是第一步，GC 何时回收、何时归还给操作系统则由运行时和内存分配器决定。

#### 三色标记是理解并发标记的模型

白色对象尚未确认可达，灰色对象已发现但其引用尚未扫描，黑色对象及其可达边已扫描。并发标记期间应用仍可能修改指针，写屏障帮助维持不漏标记的不变量。这个模型解释了为什么用户代码不应直接读取 GC 的内部颜色或假设某个对象此刻一定被回收。

实际实现包含标记队列、辅助标记、写屏障和清扫等更多阶段。把“三色”画成一次停顿的全堆扫描会误导性能判断；要理解延迟，结合 GC trace 和 pprof，而不是只数对象。

#### 最终对象生命不由 runtime.GC 决定

runtime.GC 会请求一次完整垃圾回收，适合测试或实验，不是常规请求路径的内存管理 API。它不能回收仍可达对象，也不保证同步归还所有内存给操作系统。debug.FreeOSMemory 更强地影响释放行为，使用前应确认它对延迟和吞吐的代价。


```go
package main
import (
 "fmt"
 "runtime"
)
func main() {
 var before, after runtime.MemStats
 runtime.ReadMemStats(&before)
 data := make([]byte, 8<<20)
 data[0] = 1
 runtime.KeepAlive(data)
 data = nil
 runtime.GC()
 runtime.ReadMemStats(&after)
 fmt.Println(after.NumGC > before.NumGC)
}
// 输出 true 只证明本次强制 GC 已完成；具体 HeapAlloc 受环境影响，不能断言固定差值。
```

#### 保持活跃引用到最后

编译器可判断某个变量在源码最后一次使用之前或之后已不再需要，runtime.KeepAlive(x) 用于把 x 的生命期延长到指定位置，常见于把资源句柄与终结器或系统调用配合。KeepAlive 不会阻止对象被修改、也不会释放资源；它只影响可达性分析的观察边界。


```go
f := openResource()
runtime.SetFinalizer(f, cleanup)
useNativeHandle(f.handle)
runtime.KeepAlive(f) // 确保上面的调用完成前 f 仍可达
// 终结器运行时间不确定，不能替代显式 Close。
```

#### 终结器与清理是两种契约

终结器在对象不可达后由运行时安排执行，时间不确定，进程退出时也不保证完成；它适合兜底诊断或少数系统资源场景，不适合作为业务提交、文件关闭或网络刷新机制。显式 Close、defer 和上下文取消表达确定的生命周期。

终结器还可能使对象及关联对象延迟回收，且受循环引用和并发状态影响。若资源需要及时归还，应把所有权放进结构体 API，让调用方在成功取得资源后承担 Close。

#### GC 参数平衡分配速率与占用

GOGC 影响触发目标，GOMEMLIMIT 影响运行时在有限内存环境中的策略。降低目标可能减小堆峰值但增加 GC CPU；提高目标可能减少 GC 频率但增加占用与尾延迟风险。参数是运行时版本和部署环境相关的调优旋钮，不是修复无限增长引用的替代品。

Go 的 GC 目标通常让短暂停顿与吞吐保持平衡，并非承诺固定暂停毫秒数。发布前应在代表性负载观察 CPU、heap goal、扫描工作、分配速率和尾延迟；单独测一个空循环无法说明服务行为。

#### 用观测定位“内存高”的原因

heap profile 看到的是仍可达对象的保留关系，alloc profile 更偏向分配历史；两者都需要结合采样和时间窗口理解。可用 GODEBUG=gctrace=1 查看周期日志，GODEBUG 的字段和格式随版本可能变化。先找持有引用的业务结构，再决定复制、清理、限流或调整 GC 参数。

堆仍在增长时先查看持有引用的路径。


```bash
# 仅用于实验，输出格式依 Go 版本而变。
GODEBUG=gctrace=1 go test -run '^$' -bench=BenchmarkAlloc -benchmem ./...
# 关注：分配速率、堆目标、标记/清扫耗时；不要解析日志字段作为稳定 API。
```

注意：GC 只回收不可达对象；runtime.GC 和终结器都不能替代确定性的 Close、取消和所有权释放。

参考：Go GC guide — https://go.dev/doc/gc-guide

参考：Go 官方源码：runtime/mgc.go — https://go.dev/src/runtime/mgc.go

参考：标准库 runtime 文档 — https://pkg.go.dev/runtime

参考：标准库 runtime/debug 文档 — https://pkg.go.dev/runtime/debug

---

### sysmon

运行时监控、抢占、定时器和系统调用。

#### sysmon 是运行时后台监控者

sysmon 是 runtime 的系统监控后台线程，运行时允许它不持有 P 执行，独立于普通 P 的运行循环，负责检查长时间运行、网络轮询、定时器和线程阻塞等条件。它协助调度器推进工作，但不提供业务级定时器或监控指标 API。具体轮询周期、唤醒条件和检查次序是实现细节，随版本变化。

把 sysmon 想成一条固定“每 20 微秒扫描所有 goroutine”的线程会误导。源码中的睡眠退避、抢占检查和平台调用共同决定行为；应用只能依赖公开的调度、时间和同步语义。

#### 抢占检查让长计算有机会让出

当一个 G 长时间运行而没有主动阻塞，运行时会在安全点或异步抢占路径请求它让出 P。以下 CPU 循环不应被用来证明一个固定毫秒数，而是观察另一个 goroutine 最终有机会运行。禁用抢占、cgo 或不可抢占运行时区都可能改变延迟。


```go
package main
import (
 "fmt"
 "runtime"
 "time"
)
func main() {
 runtime.GOMAXPROCS(1)
 done := make(chan struct{})
 go func() { fmt.Println("scheduled"); close(done) }()
 end := time.Now().Add(20 * time.Millisecond)
 var x uint64
 for time.Now().Before(end) { x++ }
 <-done
 fmt.Println(x > 0)
}
```

#### 示例只说明最终调度，不保证输出时间

输出通常是 scheduled、true；即使调度发生，打印的相对时间与抢占点也会受机器负载、编译器优化、操作系统和 Go 版本影响。测试中应设置等待超时，避免把 sysmon 观测实验变成永久阻塞。若业务依赖每个请求在硬截止时间内停止，必须在算法和 I/O 层显式设计截止点。

#### 系统调用与网络轮询

当 G 进入可能长时间阻塞的系统调用，运行时可以让 P 脱离当前 M，使其他 G 继续。网络连接通常交给 netpoll，sysmon 或调度路径在事件就绪时唤醒等待的 G。普通文件 I/O、cgo 和平台差异不能统称为“都由 epoll 异步完成”。

观察 syscall 阶段要结合 runtime/trace、阻塞 profile 与平台工具。sysmon 让运行时有机会发现问题，不会把阻塞的数据库驱动、锁或第三方 C 库自动变成可取消操作。

#### 定时器由运行时维护但仍是协作边界

time.Timer 和 time.Ticker 由运行时计时器系统管理，计时器到期后把事件交给相应通道或回调。Reset、Stop 的精确规则应查目标 Go 版本文档；不要用历史版本“必须先排空 channel”的模板覆盖新语义。停止不再使用的 ticker，避免其继续产生事件和持有引用。


```go
timer := time.NewTimer(10 * time.Millisecond)
defer timer.Stop()
select {
case <-timer.C:
 fmt.Println("expired")
case <-ctx.Done():
 fmt.Println("cancelled")
}
// 此 select 只处理等待；ctx 取消不自动停止其他业务工作。
```

#### 监控不等于可观测性

sysmon 维持运行时进展，pprof、trace、日志和指标才是应用判断延迟的观测工具。若看到 runnable G 持续堆积，可能是 CPU 不足或锁争用；若看到 syscall/waiting，则要找资源、网络、数据库或下游服务。不要用 goroutine 数量单独替代这些分类。

运行时内部调试变量、trace 事件名称和 gctrace 文本不宜被业务代码解析成长期协议。升级 Go 后重新检查诊断工具和采集脚本，并在容量测试中确认告警阈值仍有意义。

#### 版本化阅读源码

本页以 go1.27.1 本机源码中的 runtime/proc.go、time.go、netpoll.go 为核对依据；实现段落只描述这些源码当前采用的角色分工，语言级行为仍以规范和公开包文档为准。阅读其他文章时先固定 git tag，否则“当前 sysmon”可能其实是数年前的调度器。

#### 阻塞预算要落到调用点

入口可以给请求设置总体 deadline，数据库、HTTP 和锁等待再沿调用链消耗这份预算。sysmon 发现线程或网络状态后，仍需要调用点检查 ctx、设置 deadline、释放连接。排查卡顿时记录等待类型和剩余预算，比记录“sysmon 很忙”更能直接指导修复。

网络就绪、计时器到期和抢占请求都只是创造下一次调度机会。若系统 CPU 已饱和，任务仍可能在可运行队列等待，因此排查尾延迟时要同时看运行时间与排队时间，不能把所有延迟归因于定时器精度。

注意：sysmon 的职责是运行时进展与监控协助，不是应用级超时、取消或硬实时保证。

参考：Go 官方源码：runtime/proc.go — https://go.dev/src/runtime/proc.go

参考：Go 官方源码：runtime/netpoll.go — https://go.dev/src/runtime/netpoll.go

参考：Go 官方源码：runtime/time.go — https://go.dev/src/runtime/time.go

参考：标准库 runtime/trace 文档 — https://pkg.go.dev/runtime/trace

参考：Go 1.14 异步抢占 — https://go.dev/doc/go1.14

---

### Mutex

互斥锁、竞争、复制锁和锁顺序。

#### Mutex 保护临界区与不变量

sync.Mutex 提供 Lock/Unlock 互斥：同一时刻至多一个 goroutine 持有它，成功 Unlock 与之后 Lock 建立同步关系。Mutex 的价值是保护一组共享状态的不变量，而不仅是保护某个 map 语句。零值可用，使用后不能复制；复制一个已使用的锁会把状态分成两个互不协调的副本。

不要依靠“当前实现似乎公平”安排业务顺序。竞争激烈时运行时可能通过饥饿模式改善等待者进展，但公平细节不属于接口契约。锁住对象的字段、条件和更新必须在同一保护范围内，读也不能偷偷绕过锁。

#### 互斥不等于业务原子性

示例把余额读改写包在同一把锁中，两个扣款并发时不会同时基于旧余额成功。若只给读和写各自加锁，检查与扣减之间仍可被插入，最终可能透支。程序输出会是一个成功、一个失败，但谁先成功未指定。


```go
package main
import (
 "fmt"
 "sync"
)
type Account struct { mu sync.Mutex; balance int }
func (a *Account) Withdraw(n int) bool {
 a.mu.Lock()
 defer a.mu.Unlock()
 if a.balance < n { return false }
 a.balance -= n
 return true
}
func main() {
 a := &Account{balance: 100}
 var wg sync.WaitGroup
 result := make(chan bool, 2)
 for i := 0; i < 2; i++ { wg.Add(1); go func() { defer wg.Done(); result <- a.Withdraw(80) }() }
 wg.Wait(); close(result)
 for ok := range result { fmt.Println(ok) }
 fmt.Println(a.balance)
}
```

#### 解释结果和未保证顺序

两次结果一真一假，余额为 20。result 是缓冲 channel，避免工作 goroutine 在 main 尚未开始接收时阻塞；WaitGroup 只等待完成，不决定结果打印顺序。两个布尔值的先后可能变化，测试应检查集合或计数与最终余额，而不是固定 first/second。

若业务还需要记录交易顺序，要在锁内分配递增序号或交给串行账本；单凭互斥锁不产生可查询的公平顺序。

#### 锁顺序防止循环等待

多个锁同时使用时，为所有路径建立一致顺序，例如先锁账户 ID 小者，再锁大者。路径 A→B 与路径 B→A 会形成互相等待。defer 解锁应与成功 Lock 成对；若第二把锁失败或有提前返回，已持有的第一把必须释放。

把外部回调、网络请求、日志钩子放在锁内会让未知代码在临界区运行，既可能死锁，也会把慢操作放大成全局尾延迟。先复制需要的数据，释放锁，再调用外部代码。

#### TryLock 不能替代设计

sync.Mutex 不关联特定 goroutine，允许一个 goroutine Lock、另一个 Unlock；但跨 goroutine 解锁需要明确的所有权移交协议，否则很难证明错误路径仍会释放锁。它也没有公开的“持有者”查询。若确实需要尝试获取，可使用目标版本提供的 TryLock，但失败时要有明确退避或降级逻辑，不能在循环里无休止忙试。

锁竞争问题先通过缩短临界区、分片、批处理或改变数据结构解决。用更多细粒度锁前确认锁顺序和跨分片不变量，否则复杂度可能超过收益。

#### 竞争检测和锁复制

go test -race 能发现实际运行路径上的数据竞争，不能证明未覆盖路径安全。go vet 的 copylocks 检查和 sync/noCopy 提示有助于发现把带锁结构按值传递。把结构体放进 map、返回值或赋值时都要考虑是否复制了锁；通常使用指针承载带锁对象。


```go
type Cache struct {
 mu sync.Mutex
 data map[string]string
}
func use(c *Cache) {}
// use(Cache{}) // 类型不匹配；按值传递 Cache 会复制 mutex，应避免。
// 命令：go test -race ./...
```

#### 锁保护的是可证明的范围

可以用注释或方法名明确“调用者必须持锁”的内部 helper，但公开方法应自行维护锁契约。读多写少不等于自动适合 RWMutex；Mutex 更简单且常常更快。选择锁前先画出共享状态、不变量和调用图，再通过 profile 验证竞争是否是真正瓶颈。

锁的释放必须发生在共享状态重新满足不变量之后。即使每次字段赋值都在锁内，若两个字段分别在不同临界区更新，读者仍可能观察到逻辑上的中间状态。把整个状态转换放进同一临界区才能消除这种错误。

注意：Mutex 让临界区互斥，不会自动把跨多个资源的业务流程变成事务，也不提供固定先来先得顺序。

参考：标准库 sync 文档 — https://pkg.go.dev/sync

参考：Go 内存模型 — https://go.dev/ref/mem

参考：Go 官方源码：sync/mutex.go — https://go.dev/src/sync/mutex.go

参考：标准库 cmd/go 文档 — https://pkg.go.dev/cmd/go

---

### Once

只执行一次初始化和错误缓存。

#### Once 把初始化发布成一次性动作

sync.Once 的零值可用，Do(f) 保证多个 goroutine 中只有一次 f 被调用；返回的 Do 调用与执行 f 的调用建立同步关系，因此其他调用能观察到初始化写入。Once 使用后不能复制。它表达的是“这个动作最多执行一次并完成发布”，不是“只要成功就缓存结果”。

Do 的 f 在 Once 内部执行期间，若 f 再次对同一个 Once 调用 Do，会死锁，因为第一次调用必须等待 f 返回才能让其他调用继续。初始化函数应保持边界清楚，不要递归回到同一 Once。

#### panic 也会消耗 Once

官方文档明确：如果 f panic，Once 仍把它视为已返回，未来 Do 不会再次调用 f。示例第一次调用捕获 panic，第二次调用安静返回；这意味着 Once 不能表达自动重试初始化。


```go
package main
import (
 "fmt"
 "sync"
)
func main() {
 var once sync.Once
 for i := 0; i < 2; i++ {
  func() {
   defer func() { if v := recover(); v != nil { fmt.Println("panic", v) } }()
   once.Do(func() { fmt.Println("init"); panic("bad config") })
   fmt.Println("after Do")
  }()
 }
 fmt.Println("done")
}
```

#### 逐步解释“失败不重试”

输出 init、panic bad config、after Do、done。第一次在 f 中 panic，外层 defer 恢复后结束匿名函数，跳过当次 after Do；第二次发现 done 状态已完成，直接返回，所以只在第二次打印 after Do，没有第二个 init。恢复动作属于示例边界，生产初始化通常应把错误作为显式结果处理，而不是在 Once 内吞掉。

如果初始化结果需要错误，可把 error 存到外部变量：Once.Do 中设置 value 和 err，调用者读取已发布结果。若允许重试，就用带互斥的状态机记录未开始、进行中、成功、失败与重试策略，而不是重置 Once 的私有状态。

#### Get-or-init 的边界

Once 适合进程级共享的只读客户端、解析后的模板或一次性注册。它不适合带请求参数的缓存、需要按键失效的资源或需要关闭重建的连接池。按键初始化可使用 map+Mutex、sync.Map 中保存条目或 singleflight，但每个选择都有不同的错误缓存和取消语义。


```go
var once sync.Once
var client *Client
var initErr error
func getClient() (*Client, error) {
 once.Do(func() { client, initErr = newClient() })
 return client, initErr
}
// 后续调用复用同一个结果；若允许重连，需要另一种生命周期设计。
```

#### 发布与可变对象

Once 只保证初始化函数完成后的发布，不会让之后对 client 内部可变字段的并发修改变安全。初始化后应尽量把对象设为不可变，或由对象自身的锁/原子变量保护动态字段。把“初始化一次”误读为“永远不需要同步”是常见错误。

若 f 启动 goroutine 后立刻返回，Once 只发布“goroutine 已启动”的状态；它不会等待后台工作完成。需要等待就把初始化步骤做完整，或额外返回 ready channel/错误结果，并在关闭时有独立信号。

#### 与 atomic CAS 的差别

简单的 CAS 抢占标志可能让一个 goroutine 把 done 设为 true 后才执行 f，其他调用提前读到 done 并使用尚未初始化的数据。Once 的实现必须把“执行 f”和“让后来者观察完成”按正确顺序同步；官方源码也把这种看似简短的实现列为错误示例。

不要自行复制 Once 的内部字段或依赖 done 的布局。若要自定义重试、超时或可取消初始化，应设计公开状态转换，使用 Mutex、channel 和结果字段明确表达每个阶段。

#### 测试初始化契约

测试需覆盖并发多次 Do、f 成功、f panic、初始化依赖其他对象和失败后的调用。断言 f 次数为 1，同时检查所有调用者看到了完整结果。若业务要求失败可重试，测试应验证重试次数、并发合并和取消，而不是把 Once 替换后继续断言旧语义。

对可配置初始化还要注意第一次调用获胜：后续传入不同参数也不会重新初始化。若业务允许不同租户或不同配置，不应共用一个全局 Once，而应让初始化对象与配置身份具有一一对应的所有权。

注意：Once.Do 的函数即使 panic 也不会被第二次调用；需要重试请建模状态机或其他协调机制。

参考：标准库 sync 文档 — https://pkg.go.dev/sync

参考：Go 官方源码：sync/once.go — https://go.dev/src/sync/once.go

参考：Go 内存模型 — https://go.dev/ref/mem

---

### WaitGroup

等待一组 goroutine 完成。

#### WaitGroup 只计数完成，不传递结果

WaitGroup 维护一个等待计数：Add 增加任务，Done 减少，Wait 阻塞到计数为零。Done 与解除它的 Wait 建立同步关系。它不收集错误、不取消工作，也不保证 goroutine 的执行或完成顺序。

正 delta 从零开始时必须发生在 Wait 之前；最简单规则是在 go 语句前 Add。把 Add 放在新 goroutine 内有竞态：main 可能先进入 Wait 看到零并返回，子任务随后才增加计数。

#### 完整示例与可观察结果

示例在启动前 Add 三次，每个任务 defer Done，main Wait 后关闭结果 channel。每个任务发送输入的两倍，结果到达顺序不保证，排序后输出 [2 4 6]。channel 承担结果收集，WaitGroup 只负责知道所有发送已结束。


```go
package main
import (
 "fmt"
 "sort"
 "sync"
)
func main() {
 var wg sync.WaitGroup
 results := make(chan int, 3)
 for _, n := range []int{1, 2, 3} {
  wg.Add(1)
  go func(n int) { defer wg.Done(); results <- n * 2 }(n)
 }
 wg.Wait()
 close(results)
 got := []int{}
 for n := range results { got = append(got, n) }
 sort.Ints(got)
 fmt.Println(got)
}
```

#### 为什么可以在 Wait 后关闭 channel

每个发送发生在 Done 之前，因为 defer 在函数返回路径最后执行。Wait 返回说明三个 Done 都已完成，此时没有工作再向 results 发送，main 才有资格 close。若任务启动后台 goroutine 再返回，Done 可能先发生而后台仍发送，关闭就会 panic；任务边界必须覆盖真正的发送者。

不要让多个 goroutine 竞相关闭同一个结果 channel，除非另有明确协调。常见结构是一个专门的 closer 等待发送者，或者让生产者计数归零的一方负责 close。

#### 重用有阶段边界

WaitGroup 可以用于多个独立阶段，但下一阶段的正 Add 应在上一阶段所有 Wait 返回后开始。当前 Wait 尚未完成时把计数从零重新加到正数会造成误用；同一个 WaitGroup 也不能复制后分别使用。

Go 1.25 引入 WaitGroup.Go 便利方法，它把“Add、启动、Done”组合起来，但 f 不得 panic，且空组上的 Go 必须发生在 Wait 前。目标工具链没有该方法时，用显式 Add+defer Done；不要为兼容新 API 而修改项目版本约束。

#### 错误传播应使用结构化结果

工作任务失败时，把 error 放进带缓冲结果 channel，或由 errgroup 这类更高层协调器结合 context 取消。WaitGroup.Wait 只能告诉你“都结束了”，不能判断任务是否成功。共享 error 变量需要锁或每个任务独立发送，否则多个失败写入会数据竞争。


```go
type result struct { value int; err error }
results := make(chan result, count)
// 每个 worker: defer wg.Done(); results <- result{..., err}
// Wait 返回后 close(results)，再由单消费者汇总错误。
// 若首错需取消其余任务，另加 context 与 select。
```

#### Add 与退出路径要成对

在 goroutine 一开始 defer Done，能覆盖普通 return 和大多数错误分支；若 goroutine 内 recover panic，也要确保 defer 顺序仍会执行 Done。WaitGroup.Go 要求 f 不 panic，因为它的契约不替你提供 panic 传播。不要用 recover 吞掉失败后继续报告整体成功。

计数器变成负数会 panic，通常表示重复 Done、遗漏 Add 或把同一任务交给两个完成路径。先画任务所有权，再决定每个任务由谁 Done；不要在多个 helper 里“顺手”减少同一个计数。

#### 验证竞态与死锁

go test -race 能帮助发现结果共享和错误变量写入冲突；等待测试应给外层测试设置 timeout，避免忘记 Done 永久卡住整个测试进程。不要用 time.Sleep 等待“任务差不多结束”，因为这既不表达完成，也会在机器负载变化时偶尔失败。

#### 任务树需要明确收口人

先决定谁创建任务、谁关闭结果、谁汇总错误，再写 Add 和 Done。拥有关闭权的一方必须能证明所有发送者都已退出；拥有取消权的一方必须等待任务收口。把这三种角色混在一个 helper 中，常会导致提前 close、计数不平衡或错误丢失。

等待计数只表示任务完成，不能表示结果已消费。如果结果通道无缓冲，而主 goroutine 先 Wait 后接收，任务会卡在发送，无法执行 Done，主 goroutine 也永远等不到零。示例的容量覆盖任务数；未知任务规模时应让独立消费者并行读取，再由协调者等待并关闭，避免把缓冲容量当成死锁修复的唯一手段。

注意：WaitGroup 是完成计数器；Add 时机、Done 覆盖范围和 channel 关闭所有权比“记得 Wait”更关键。

参考：标准库 sync 文档 — https://pkg.go.dev/sync

参考：Go 官方源码：sync/waitgroup.go — https://go.dev/src/sync/waitgroup.go

参考：Go 内存模型 — https://go.dev/ref/mem

---

### Cond

条件变量、等待和通知。

#### Cond 把等待与共享条件分开

sync.Cond 关联一把 Locker，通常是 Mutex。调用 Wait 前必须持有锁；Wait 原子地解锁并让当前 goroutine 睡眠，被 Signal/Broadcast 唤醒后重新加锁再返回。等待者随后仍要检查共享条件，因为另一个 goroutine 可能先获得锁并消耗了条件。

Go 文档明确 Cond.Wait 不会像某些系统条件变量那样因“虚假唤醒”而无缘无故返回；但 Signal 只表示状态可能变化，重新竞争锁后条件仍可能为假。因此循环是条件变量协议要求，不是为了掩盖 Go 的虚假唤醒。

#### 生产者消费者完整示例

示例把队列和 has-item 条件都放在同一把锁保护下。消费者在队列为空时循环 Wait；生产者入队后 Signal；消费者取出时在锁内删除元素，解锁后处理。输出是消息内容，顺序由队列协议决定；多个消费者时谁先拿到下一项不保证。


```go
package main
import (
 "fmt"
 "sync"
)
type Queue struct { mu sync.Mutex; cond *sync.Cond; items []int }
func NewQueue() *Queue { q := &Queue{}; q.cond = sync.NewCond(&q.mu); return q }
func (q *Queue) Put(v int) { q.mu.Lock(); q.items = append(q.items, v); q.cond.Signal(); q.mu.Unlock() }
func (q *Queue) Get() int {
 q.mu.Lock(); defer q.mu.Unlock()
 for len(q.items) == 0 { q.cond.Wait() }
 v := q.items[0]; q.items = q.items[1:]
 return v
}
func main() {
 q := NewQueue(); done := make(chan struct{})
 go func() { fmt.Println(q.Get()); close(done) }()
 q.Put(7); <-done
}
```

#### 逐步解释锁状态

Get 先持锁检查 len=0，Wait 解锁并挂起；Put 获得同一把锁追加 7，Signal 标记一个等待者，然后解锁。Get 被唤醒后重新获得锁，再次检查循环条件，取走 7 并返回。若 Put 在 Get 醒来前又被其他消费者抢先取走，循环会再次等待；这就是必须使用 for 的真实竞争。

如果使用 if，第二次检查被省略，消费者可能在队列已空时索引越界或消费不存在的值。Signal 不把锁交给被唤醒者，也不保证它立刻运行。


```text
持锁检查条件 → 不满足 → Wait 原子释放锁并睡眠
Signal/Broadcast → 等待者可运行 → 重新竞争并取得锁
再检查条件 → 满足则消费；不满足则再次 Wait
```

#### Signal 与 Broadcast 的选择

一个入队项通常只需 Signal 一个消费者；一次改变可能满足多个不同等待条件，或状态变化让所有人都应重新判断时使用 Broadcast。Broadcast 会唤醒更多 goroutine，之后它们仍要逐个竞争锁并重新检查条件，可能形成惊群。

不要在没有修改共享条件时频繁 Broadcast；它只唤醒，不替你改变状态。条件、状态更新和 Signal 应在同一把锁保护下完成，使等待者观察到一致状态。

#### Cond 的生命周期与复制限制

Cond 必须关联有效 Locker，通常通过 NewCond 指定；不能把未设置 L 的零值直接用于 Wait，使用后也不能复制。等待期间 Locker 被解锁，因此外部代码可以更新条件；Wait 返回时锁已重新持有。调用 Wait 前未持锁是错误，会导致不可预测的同步行为。

Cond 没有关闭或取消方法。若等待必须响应 context，需要把 ctx.Done 纳入条件状态并由取消方在锁内改变状态后 Broadcast，或用 channel/select 表达等待。仅把 ctx 检查放在 Wait 前，检查后仍可能永远睡眠。

#### 很多场景 channel 更直观

一次性通知、任务队列、广播关闭和带取消的等待，channel 往往同时表达数据与生命周期。Cond 适合已有共享内存状态、需要唤醒后在锁内重查多个条件的场景，例如有界队列或资源池。选择时比较所有权、关闭语义、取消路径和是否需要精确保护不变量，不要因“看起来底层”就选 Cond。

#### 测试不应假定唤醒顺序

测试应等待明确的完成 channel，并断言队列最终状态；不要断言 Signal 唤醒了某个创建较早的 goroutine。用 race 检查条件和队列字段的锁覆盖。超时只用于报告协议错误，不能被当作成功同步手段。

#### 用条件不变量命名等待

与其写“等待通知”，不如写“等待 len(items)>0”或“等待 closed=true”。Signal 只提示条件可能改变，真正的判断始终读取共享状态。这样审查者可以检查每次状态写入是否在同一把锁内，并确认每条退出路径都会让等待者最终醒来。

条件检查与状态写入必须使用同一把锁。

注意：Go 的 Cond.Wait 不会虚假唤醒，但唤醒后必须重新竞争锁并在循环中检查条件。

参考：标准库 sync 文档 — https://pkg.go.dev/sync

参考：Go 官方源码：sync/cond.go — https://go.dev/src/sync/cond.go

参考：Go 内存模型 — https://go.dev/ref/mem

---

### context internals

取消树、deadline、Done 和 Value。

#### Context 是请求范围的生命周期信号

context.Context 携带 deadline、取消信号和少量请求范围值，沿调用树从父传给子。WithCancel、WithTimeout、WithDeadline 创建派生上下文；取消父会关闭子 Done，取消子不会反向取消父。Context 本身不可变，派生操作建立新节点。

Context 应作为函数第一个参数传递，不要存进结构体、传 nil 或用它承载可变配置。Value 适合跨 API 边界的请求元数据，如 trace ID；不要把必需业务参数藏进去，让函数签名失去可读性。

#### 取消树只向下传播

示例建立 parent→child，先 cancel child，child.Err 非 nil 而 parent.Err 仍 nil；再 cancel parent，parent 和 child 都为 context.Canceled。子取消还会从父的子集合中移除，帮助释放引用。CancelFunc 不等待工作停止，调用者若需要完成确认必须另行协调。


```go
package main
import (
 "context"
 "fmt"
)
func main() {
 parent, cancelParent := context.WithCancel(context.Background())
 child, cancelChild := context.WithCancel(parent)
 cancelChild()
 fmt.Println(child.Err(), parent.Err())
 cancelParent()
 fmt.Println(child.Err(), parent.Err())
}
```

#### 输出顺序与资源释放

两行输出 context canceled <nil>、context canceled context canceled。Err 的具体 error 值用于判断取消或 deadline；不要依赖错误字符串。创建 child 后应在拥有它的函数中 defer cancel，及时停止计时器和移除父引用，即使预计会等到父取消也应如此。

调用 cancel 只是广播停止请求，不会中断任意计算循环、关闭所有自定义资源或杀死 goroutine。被调用函数必须在阻塞点 select ctx.Done，在 CPU 循环中定期检查，返回时再由上层等待 Done。

#### 带取消的 worker 协议

worker 同时等待任务和 ctx.Done；收到取消后返回，生产者也必须能停止发送，否则会在无人接收时阻塞。完成 channel 用于让调用者确认 worker 已退出。把 cancel 只放在 main 而不把 ctx 传入实际 I/O，不能让下游自动停止。


```go
func worker(ctx context.Context, jobs <-chan int, done chan<- struct{}) {
 defer close(done)
 for {
  select {
  case <-ctx.Done(): return
  case n, ok := <-jobs:
   if !ok { return }
   _ = n // 处理任务；长计算中继续检查 ctx
  }
 }
}
// 取消：cancel(); <-done。cancel 本身不等待 done。
```

#### deadline 是建议性的预算

Deadline 让下游知道最晚完成时间；HTTP、数据库和网络包若正确接受 context，可能提前停止等待。它不是强制抢占：忽略 ctx 的函数仍能继续计算，已经完成的副作用不会被撤销。跨边界时应把 ctx 传给请求，并将 ctx.Err 与下游错误按调用契约分类。

使用 WithTimeout 作为每层独立超时会造成总预算叠加或提前耗尽。通常让入口建立总体 deadline，下游派生更短的局部预算时明确理由。没有 deadline 的 context 不等于无限安全，资源仍应有自己的超时和关闭规则。

#### Value 的键与类型

自定义键应使用未导出的类型避免包间碰撞，并在读取处进行类型断言。Value 查找是沿父链的只读查询，不是并发可变 map。把大对象、可选配置、数据库句柄放入 Value 会隐藏依赖并延长生命周期；优先显式参数或依赖结构。


```go
type requestIDKey struct{}
func withID(ctx context.Context, id string) context.Context {
 return context.WithValue(ctx, requestIDKey{}, id)
}
func idFrom(ctx context.Context) (string, bool) {
 v, ok := ctx.Value(requestIDKey{}).(string)
 return v, ok
}
```

#### AfterFunc 与取消竞态

AfterFunc 在取消时异步调用函数，stop 返回 false 只表示函数已开始或已完成，不能据此假定它已经结束；需要等待就给回调加完成 channel。取消和 stop 并发时，回调是否运行应由返回值和自身幂等设计处理。版本更新后请以 context 包文档确认细节。

context 的 Done channel 只读且可为 nil（永不取消）。select 中可以把 nil Done 当作禁用取消分支，但若把背景 context 当成所有请求的默认值，会失去上层 deadline。

#### 取消要覆盖每个阻塞点

函数收到 ctx 后，不能只在入口检查一次；队列接收、网络读写、重试睡眠和批量循环都要使用同一取消预算。对不支持 context 的第三方 API，可在独立 goroutine 中等待取消并关闭其资源，同时等待该 goroutine 完成，避免取消后留下悬挂任务。

上层应记录取消来源，便于区分用户主动停止与预算耗尽。取消原因可以通过 WithCancelCause 与 Cause 更细致地传递；Err 仍表达取消或截止这一类状态。调用者应区分“用于控制流的取消状态”和“用于诊断的具体原因”，避免把上游取消误报成独立的下游服务故障。

注意：取消只沿父到子传播，CancelFunc 不等待工作停止；Context 是生命周期信号，不是状态容器。

参考：标准库 context 文档 — https://pkg.go.dev/context

参考：Go 官方源码：context/context.go — https://go.dev/src/context/context.go

参考：Go 内存模型 — https://go.dev/ref/mem

---

### netpoll

网络 I/O 与 goroutine 挂起唤醒。

#### 网络调用的同步外观

net.Conn 的 Read/Write 看起来像普通阻塞调用，但网络 socket 的等待通常由运行时网络轮询器协调：goroutine 在等待可读/可写事件时挂起，事件到来后重新变为可运行。应用代码不需要直接调用 epoll/kqueue；平台实现与运行时版本决定具体机制。

netpoll 只解决等待就绪，不保证对端会发送数据、写入一定完成，也不替你设置业务超时。连接、TLS、DNS、文件和 cgo 的路径各有差异，不能把所有 I/O 都描述成同一套异步行为。

#### 超时让等待有明确上界

示例启动本地 TCP listener，服务端延迟写入；客户端设置 ReadDeadline，读取在截止时间到达时返回 timeout。deadline 是连接级状态，下一次操作也会继承，使用后要根据协议清除或更新。


```go
package main
import (
 "fmt"
 "net"
 "time"
)
func main() {
 ln, err := net.Listen("tcp", "127.0.0.1:0")
 if err != nil { panic(err) }
 defer ln.Close()
 serverDone := make(chan struct{})
 go func() {
  defer close(serverDone)
  c, err := ln.Accept(); if err != nil { return }
  defer c.Close(); time.Sleep(80*time.Millisecond)
 }()
 c, err := net.DialTimeout("tcp", ln.Addr().String(), time.Second)
 if err != nil { panic(err) }
 defer c.Close()
 if err := c.SetReadDeadline(time.Now().Add(10 * time.Millisecond)); err != nil { panic(err) }
 var b [1]byte
 _, err = c.Read(b[:])
 ne, ok := err.(net.Error)
 fmt.Println(ok, ne != nil && ne.Timeout())
 <-serverDone
}
```

#### 逐步解释 deadline 与 goroutine 状态

客户端建立连接后立即设置 10ms 截止并 Read；服务端 80ms 内不写，客户端等待被轮询器挂起，截止事件到达后被唤醒并返回一个实现 net.Error 的超时错误。输出 true true。服务端 goroutine 仍在 Sleep，客户端超时不会自动关闭或取消服务端工作；defer Close 与退出协议仍然必要。

测试中关闭 listener 和连接，给 Accept/Read 等待设置超时或通过 Close 唤醒。不要让示例依赖固定端口，也不要只用 Sleep 断言另一个 goroutine 已经运行。

#### pollDesc 与 goroutine 挂起

运行时为网络 fd 维护轮询描述，读写等待会把 G 挂到相应等待状态并让出执行资源；ready 事件到来后，poller 唤醒一个或多个相关 G。内部结构、边缘/水平触发策略、批量处理和平台系统调用属于实现细节，不能在应用中读取或依赖。

如果 socket 被关闭、deadline 变化或 context 触发上层 Close，等待者可能以错误返回而非“可读数据”返回。错误处理应区分 timeout、temporary（目标版本契约）和永久关闭，并把连接状态转给调用方。

#### context 取消需要连接协作

标准 net.Conn 方法不自动接受 context 参数；常见模式是 goroutine 监听 ctx.Done 并 Close 连接，让阻塞 Read/Write 返回。Close 与读写并发的错误细节需要按 net.Conn 文档处理，关闭动作应幂等或由单一所有者负责。


```go
func readWithContext(ctx context.Context, c net.Conn, p []byte) (int, error) {
 done := make(chan struct{})
 go func() {
  select { case <-ctx.Done(): c.Close(); case <-done: }
 }()
 defer close(done)
 return c.Read(p)
}
// 生产代码还要处理 Close 与 Read 并发、错误分类和 goroutine 完成确认。
```

#### 网络轮询不替代连接池与背压

每个连接都可由运行时高效等待，不代表可以无限创建连接、请求或 goroutine。连接池要限制总连接数与空闲时间，响应体必须关闭/消费，上传下载要有大小和时间预算。下游变慢时用队列长度、并发上限和取消传播形成背压，否则 netpoll 只是让大量等待更廉价，无法消除资源耗尽。

写入可能部分成功并返回 n>0 与 err；读取也可能同时返回数据和错误。调用方必须先处理 n，再按协议决定是否重试，不能只看 err 是否 nil。

#### 用 trace 和 profile 查网络延迟

runtime/trace 能显示网络阻塞、唤醒和调度时间；block profile 更适合同步等待，pprof CPU profile 则能判断时间是在解析、加密还是业务计算。应用指标应记录连接建立、首字节、完整响应和 deadline 错误。不要从 netpoll 事件数直接推导用户感知延迟，事件就绪后仍可能排队等待 CPU。

#### 连接生命周期必须闭合

建立连接、设置期限、读取响应、关闭响应体和归还连接是同一条生命周期。超时后若继续复用未清理的连接，可能把旧数据交给下一请求。连接池大小、每请求 deadline、最大响应体和取消关闭一起构成资源上限；netpoll 的高效等待不能替代这些上限。

示例等待服务端完成后才退出，避免把进程终止误当成后台清理已经成功。

注意：netpoll 让网络等待中的 goroutine 让出执行资源，但不提供数据到达、超时、取消或资源上限的业务保证。

参考：标准库 net 文档 — https://pkg.go.dev/net

参考：Go 官方源码：runtime/netpoll.go — https://go.dev/src/runtime/netpoll.go

参考：Go 官方源码：runtime/netpoll_epoll.go — https://go.dev/src/runtime/netpoll_epoll.go

参考：标准库 runtime/trace 文档 — https://pkg.go.dev/runtime/trace

参考：Go 内存模型 — https://go.dev/ref/mem

---

## 数据库与服务

### PostgreSQL

关系数据、事务、索引、JSONB 与 Go/pgx 接入。

#### PostgreSQL 和 pgx 分别是什么

PostgreSQL（常简称 Postgres）是数据库服务，负责 SQL 执行、持久化、事务、约束与索引。pgx 是 Go 程序连接 PostgreSQL 的驱动；仅阅读 pgx 的连接池 API，并不能代替数据库建模与查询知识。因此本页放在数据库章节，驱动细节保留在第三方库的 pgx 页。

关系表、外键、JOIN 和聚合适合用户、会话、消息、工具审计等有明确结构的数据；JSONB 适合附加属性；全文检索和可选的向量扩展适合不同的检索需求。它们可以组合，但 JSONB 不能替代约束，向量相似度也不能替代租户权限检查。

#### 安装、连接与可重复运行

需要一个可访问的 PostgreSQL 服务及 psql 命令行客户端。下面使用 PostgreSQL 17 容器作为固定大版本示例，并非表示最新版本；密码是本机练习值，容器只映射到 127.0.0.1。已有测试服务可直接设置 DATABASE_URL，不必再创建容器。

手册里的 Go 程序固定 pgx v5.6.0，并实际在独立的 PostgreSQL 14.20 临时实例验证。示例 SQL 使用 PostgreSQL 14 及以上共同支持的功能，不依赖仅在某个新版本出现的 MERGE 语法。生产连接应按证书配置使用 sslmode=verify-full；sslmode=disable 只用于受控本地测试。


```bash
docker run --name handbook-pg -d \
  -e POSTGRES_USER=handbook -e POSTGRES_PASSWORD=local-demo-only \
  -e POSTGRES_DB=handbook -p 127.0.0.1:55432:5432 postgres:17
# 等待输出 accepting connections；首次初始化期间可重复执行：
docker exec handbook-pg pg_isready -U handbook -d handbook
export DATABASE_URL='postgresql://handbook:local-demo-only@127.0.0.1:55432/handbook?sslmode=disable'
psql "$DATABASE_URL" -c 'select current_database(), current_user, version();'
# 从手册根目录进入服务示例模块：
cd examples/services
go mod download
go run ./postgres
```

#### 建表：类型、约束与 schema

PostgreSQL 的一个实例可管理多个数据库，一个数据库内又可有多个 schema；schema 是命名空间，不等于独立数据库。表名可以写成 public.documents。连接角色、search_path 和对象权限决定查询能访问什么。正式服务应使用最小权限角色，并用版本化迁移管理建表。

identity 生成主键；text 存文本；timestamptz 表示时间点并按会话时区显示，不保存原始地区名称。金额可用最小货币单位的 bigint 或经过约束的 numeric，避免浮点舍入。NOT NULL、CHECK、UNIQUE 和外键由数据库保护并发下的数据规则。复合唯一键 (tenant_id, external_key) 允许不同租户使用相同业务键。


```sql
-- 仅在专用练习库创建持久表；下文 SQL 以它为对象。
CREATE TABLE documents (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tenant_id text NOT NULL,
    external_key text NOT NULL,
    title text NOT NULL CHECK (length(title) > 0),
    metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
    created_at timestamptz NOT NULL DEFAULT now(),
    UNIQUE (tenant_id, external_key)
);
CREATE INDEX documents_tenant_id ON documents (tenant_id, id);
```

#### CRUD、RETURNING 与 UPSERT

INSERT/UPDATE/DELETE 都可用 RETURNING 把数据库最终写入的字段返回。不要用“再查最大的 ID”寻找刚插入的记录，并发请求会产生错配。ON CONFLICT 的目标需要相应唯一约束或唯一索引；冲突时通过 EXCLUDED 访问拟插入的新值。

在 Go 驱动里使用 $1、$2 占位符绑定值；不能用 ?，也不能用拼接或 fmt.Sprintf 构造带用户输入的 SQL。占位符不能代替表名和排序字段，动态标识符必须采用白名单。RowsAffected=0 可能表示没有满足条件的记录，不等于驱动故障。业务仍需区分不存在、已处理与无权限。


```sql
INSERT INTO documents (tenant_id, external_key, title, metadata)
VALUES ('demo', 'go-guide', 'Go 入门', '{"topic":"go","published":true}')
ON CONFLICT (tenant_id, external_key) DO UPDATE
SET title = EXCLUDED.title, metadata = EXCLUDED.metadata
RETURNING id, title;

UPDATE documents SET title = 'Go 与 Agent'
WHERE tenant_id = 'demo' AND external_key = 'go-guide'
RETURNING id, title;

-- 在 Go 中依次绑定 tenant、cursor、limit：
-- SELECT id, title FROM documents
-- WHERE tenant_id=$1 AND id>$2 ORDER BY id LIMIT $3;
```

#### 事务、MVCC、行锁与保存点

BEGIN/COMMIT 把多条操作合为一次事务，ROLLBACK 放弃未提交修改。PostgreSQL 默认 Read Committed，每条语句获取自己的可见性快照；同一事务前后两次 SELECT 可能看到不同的已提交数据。MVCC 让读取通常不需要阻塞普通写入，但不能理解成数据库从不加锁或事务永不冲突。

SELECT ... FOR UPDATE 可锁定选中的行，适合在同一短事务内检查和修改状态；原子的带条件 UPDATE 往往能减少一次往返。涉及多个对象时约定锁顺序。40001 序列化失败与 40P01 死锁需要重新执行完整事务并限制重试；Commit 的网络错误可能使结果不确定，副作用操作应有业务幂等键。

语句失败通常让当前事务进入失败状态，继续查询会收到 25P02；应回滚事务，或回滚到错误发生前的保存点。pgx.Tx.Begin 创建嵌套保存点，并不是另一条独立数据库连接。不要在持锁的事务里等待模型生成或用户确认。


```sql
BEGIN;
SAVEPOINT before_edit;
UPDATE documents SET title = '临时标题'
WHERE tenant_id = 'demo' AND external_key = 'go-guide';
ROLLBACK TO SAVEPOINT before_edit;
SELECT title FROM documents
WHERE tenant_id = 'demo' AND external_key = 'go-guide';
COMMIT;
-- SELECT 仍返回修改前的标题。
```

#### B-tree、EXPLAIN 和游标分页

复合 B-tree 索引 (tenant_id,id) 适合先按租户等值过滤，再按 id 范围扫描与排序。大 OFFSET 会扫描和跳过越来越多的记录，按唯一有序键分页可用 id > cursor。若按 created_at 排序，应追加 id 作为平局键，并将两者都放进游标；否则同一时刻的行可能漏读或重复。

EXPLAIN 展示预计计划；EXPLAIN (ANALYZE, BUFFERS) 会真实执行语句并报告实际行数、循环和缓冲访问。分析写 SQL 可能产生副作用，不应在生产上随意执行。小表选 Seq Scan 很正常，索引不是每次都更快；检查实际过滤率、统计信息、表大小和查询模式。VACUUM/autovacuum 处理死元组并维护可见性信息，长事务会妨碍清理。


```sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title FROM documents
WHERE tenant_id = 'demo' AND id > 0
ORDER BY id LIMIT 20;
-- 查看实际行数与预计行数，而不是仅确认出现 Index Scan。
```

#### JSONB：查询、更新和 GIN

json 保存输入 JSON 文本，jsonb 使用便于查询的二进制表示；jsonb 不保留原始空白和对象键顺序，重复键会被归并。因此不要用 JSONB 保存需要原样签名或逐字比对的原始载荷。-> 返回 JSON 值，->> 返回文本；@> 判断包含关系，? 判断顶层键是否存在。

GIN(metadata) 可加速部分包含与键存在查询，jsonb_path_ops 是针对支持的操作的另一种索引操作类，覆盖能力不同。若只按某个固定字段等值查询，也可考虑表达式 B-tree 索引。索引会增加存储与写入维护成本，不要对所有 JSONB 列默认建 GIN。经常用于 JOIN、排序或约束的字段通常应提升为普通列。


```sql
SELECT id, metadata->>'topic' AS topic
FROM documents
WHERE tenant_id = 'demo' AND metadata @> '{"published":true}'::jsonb;

UPDATE documents
SET metadata = jsonb_set(metadata, '{reviewed}', 'true'::jsonb, true)
WHERE tenant_id = 'demo' AND external_key = 'go-guide';

CREATE INDEX documents_metadata_gin ON documents USING gin(metadata);
CREATE INDEX documents_topic ON documents ((metadata->>'topic'));
```

#### 用 Go/pgx 打开连接池和读取结果

完整示例位于 examples/services/postgres/main.go，导入 context、encoding/json、pgx/v5 和 pgx/v5/pgxpool。进程内复用一个 pool；NewWithConfig 创建池后还需 Ping 才能确认连通，失败时关闭池。MaxConns 是数据库连接预算，应同时考虑其他服务实例与后台任务，不是设置得越大吞吐越高。

QueryRow 的错误在 Scan 时返回，无记录使用 errors.Is(err, pgx.ErrNoRows) 判断。Query 返回 rows 后 defer Close，遍历结束检查 rows.Err；不能忽略流中间的取消与读取失败。JSONB 可扫描到 json.RawMessage，按接口需要再解码成具体结构。请求 context 应传到每次调用，应用入口解析的认证租户不能由模型自由填写。


```go
func List(ctx context.Context, tx pgx.Tx, tenant string, after int64, limit int) ([]Note, error) {
	if tenant == "" || after < 0 || limit < 1 || limit > 100 {
		return nil, errors.New("invalid pagination")
	}
	rows, err := tx.Query(ctx, `SELECT id, title, metadata FROM handbook_notes
        WHERE tenant_id = $1 AND id > $2 ORDER BY id LIMIT $3`, tenant, after, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := make([]Note, 0)
	for rows.Next() {
		var n Note
		if err := rows.Scan(&n.ID, &n.Title, &n.Metadata); err != nil {
			return nil, err
		}
		out = append(out, n)
	}
	return out, rows.Err()
}
```

#### 独立完整示例：输出与失败验证

执行 go run ./postgres 后，程序创建一个连接内的临时表 handbook_notes，演示 UPSERT、跨租户过滤、JSONB 查询和保存点回滚，事务结束后自动删除临时表。它不会修改前文的持久 documents 表，也不会写入已有业务表。完整文件含连接池、导入、类型、事务、错误检查和 main，可以直接复制运行。

两次相同租户/业务键的 UPSERT 返回相同 ID；列表只能看到 demo 租户一行；JSONB 包含条件命中一行；保存点回滚后标题仍是 Go 与 Agent。不要断言 ID 连续：失败事务和 ON CONFLICT 都可能消耗序列值，序列不是无空洞编号器。


```bash
# 从手册根目录执行，复用前面配置的 DATABASE_URL：
cd examples/services
go run ./postgres
go test -v -race -count=1 ./postgres

# 预期程序输出：
# upsert_keeps_id= true
# tenant_rows=1 title=Go 与 Agent
# jsonb_matches= 1
# after_rollback= Go 与 Agent
```

#### Agent 存储、RLS 与可选 pgvector

会话和审计可使用普通表；附加结构化属性可放 JSONB；全文检索适合关键词，语义向量检索需要另行部署扩展或检索服务。pgvector 是独立扩展，不是安装 PostgreSQL 就自动具备的功能。必须先安装与服务端版本匹配的扩展文件，再执行 CREATE EXTENSION vector；本页没有实际安装或验证向量扩展。

检索工具必须在应用查询中保留租户条件。RLS 行级安全策略可作为进一步的数据库保护，但表所有者、超级用户和拥有 BYPASSRLS 的角色可能绕过普通策略；连接池中的会话设置还需正确复位。不能只设置 ENABLE ROW LEVEL SECURITY 就宣称已经完成隔离。

以下三维向量仅用于演示 SQL，实际维度要与 Embedding 模型一致；余弦距离用 <=>，升序表示更相近。HNSW 等近似索引的过滤后召回可能不足 k，应针对租户分布、索引和召回设置做评测。事务、权限和最终答案证据仍由应用设计。


```sql
-- 可选演示：需要管理员先安装 pgvector 扩展。
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE document_chunks (
    tenant_id text NOT NULL,
    chunk_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    body text NOT NULL,
    embedding vector(3) NOT NULL
);
-- Go 查询片段（绑定 tenant、查询向量字符串和 limit）：
-- SELECT chunk_id, body, embedding <=> $2::vector AS distance
-- FROM document_chunks WHERE tenant_id=$1
-- ORDER BY embedding <=> $2::vector LIMIT $3;
-- 示例参数："demo", "[0.1,0.2,0.3]", 5
```

注意：参数化 SQL 保护的是值的边界，不自动完成权限；短事务、租户过滤、RowsAffected/SQLSTATE 检查与连接池预算都必须由应用明确设计。

参考：PostgreSQL：官方教程与 SQL — https://www.postgresql.org/docs/14/tutorial.html

参考：PostgreSQL：事务隔离 — https://www.postgresql.org/docs/14/transaction-iso.html

参考：PostgreSQL：JSON 类型与索引 — https://www.postgresql.org/docs/14/datatype-json.html

参考：PostgreSQL：EXPLAIN — https://www.postgresql.org/docs/14/using-explain.html

参考：PostgreSQL：行级安全 — https://www.postgresql.org/docs/14/ddl-rowsecurity.html

参考：pgx v5.6.0 官方驱动文档 — https://pkg.go.dev/github.com/jackc/pgx/v5@v5.6.0

参考：pgvector 官方项目 — https://github.com/pgvector/pgvector

---

### MySQL

事务、索引、连接池和 Go database/sql。

#### 依赖安装与可运行文件

完整程序在 examples/services/mysql/main.go，使用同目录的 go.mod、go.sum 固定依赖。下文 Go 片段均摘自或放入这个 package main；所需导入为 database/sql、github.com/go-sql-driver/mysql，另有 context、fmt、time 等标准库，具体 import 列表以完整文件为准。命令从手册根目录执行，代码节选中的 ctx、客户端和配置均由完整程序初始化。


```bash
cd examples/services
go mod download
go test ./mysql
# 在自己的新模块中安装固定版本：
# go get github.com/go-sql-driver/mysql@v1.9.0
```

#### 版本、依赖与连接池

示例固定使用 Go 1.27.1、github.com/go-sql-driver/mysql v1.9.0。database/sql 只定义统一接口，驱动负责 MySQL 协议；import _ 只用于注册驱动，不能把连接字符串误当作已经连通。sql.Open 通常只创建连接池，因此启动阶段还要 PingContext，并给它设置短超时。连接池是进程级共享资源：每个请求创建一个 *sql.DB 会把空闲连接和并发上限分散到多个池，最终造成 MySQL 连接数耗尽。


```go
func Open(ctx context.Context, dsn string) (*sql.DB, error) {
	cfg, err := mysql.ParseDSN(dsn)
	if err != nil {
		return nil, fmt.Errorf("parse DSN: %w", err)
	}
	cfg.ParseTime = true
	cfg.Timeout = 2 * time.Second
	cfg.ReadTimeout = 3 * time.Second
	cfg.WriteTimeout = 3 * time.Second
	db, err := sql.Open("mysql", cfg.FormatDSN())
	if err != nil {
		return nil, err
	}
	db.SetMaxOpenConns(10)
	db.SetMaxIdleConns(5)
	db.SetConnMaxLifetime(3 * time.Minute)
	if err := db.PingContext(ctx); err != nil {
		db.Close()
		return nil, err
	}
	return db, nil
}
```

#### 事务边界与受影响行数

转账是两个写操作必须一起成功的典型场景。先 BeginTx，再用 ExecContext 让取消和 deadline 传给驱动；defer tx.Rollback 是安全兜底，Commit 成功后回滚会返回 sql.ErrTxDone，可以忽略。扣款语句把余额条件放进 WHERE，依靠数据库原子地判断余额，而不是先 SELECT 再在 Go 中判断。RowsAffected 返回 0 时表示账户不存在或余额不足，业务层应返回可识别错误；它不是数据库网络错误。第二条 UPDATE 影响 0 行则表示目标账户不存在，事务会回滚第一步。

事务隔离级别要跟业务冲突模型匹配。示例使用 ReadCommitted，仍然需要唯一键和条件更新；不要用字符串拼接用户输入来构造 WHERE。错误应保留原始 error 供日志和 metrics 分类，用 %w 包装后可用 errors.Is 或驱动类型判断。

两个相反方向的转账仍可能触发死锁，MySQL 会回滚其中一个事务；只能在明确识别可重试死锁并有幂等业务标识时重试整个事务。Commit 返回网络错误则可能存在提交结果不确定性，不能直接当作未提交后再次扣款；真实账务应记录唯一 transfer_id 与流水，并用查询和对账确认。示例聚焦数据库原子更新，不实现资金系统的完整幂等协议。


```go
var ErrInsufficientFunds = errors.New("account absent or insufficient funds")

func Transfer(ctx context.Context, db *sql.DB, from, to, cents int64) error {
	if from == to || cents <= 0 {
		return errors.New("invalid transfer")
	}
	tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
	if err != nil {
		return err
	}
	defer tx.Rollback()
	result, err := tx.ExecContext(ctx,
		"UPDATE accounts SET balance_cents=balance_cents-? WHERE id=? AND balance_cents>=?", cents, from, cents)
	if err != nil {
		return fmt.Errorf("debit: %w", err)
	}
	n, err := result.RowsAffected()
	if err != nil {
		return err
	}
	if n != 1 {
		return ErrInsufficientFunds
	}
	result, err = tx.ExecContext(ctx, "UPDATE accounts SET balance_cents=balance_cents+? WHERE id=?", cents, to)
	if err != nil {
		return fmt.Errorf("credit: %w", err)
	}
	n, err = result.RowsAffected()
	if err != nil {
		return err
	}
	if n != 1 {
		return errors.New("destination account absent")
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit: %w", err)
	}
	return nil
}
```

#### 查询、扫描和索引

QueryContext 返回 rows 后必须 defer Close，并在遍历结束检查 rows.Err；否则网络读取或服务器返回的后续错误会被吞掉。Scan 的目标字段类型要和 schema 对齐，允许 NULL 的列使用 sql.NullString、sql.NullTime 等类型，而不是把 NULL 扫到 string。列表查询应有稳定 ORDER BY 和 LIMIT，分页最好使用索引列的游标条件（id > ?），避免 OFFSET 在大表上逐页扫描。

这段代码对应 CREATE TABLE accounts(id BIGINT PRIMARY KEY, balance_cents BIGINT NOT NULL)；生产环境还应执行 EXPLAIN 验证 WHERE 与 ORDER BY 是否命中索引。连接池参数只是容量上限，不会提升慢查询本身；应结合 DB.Stats 观察 WaitCount、WaitDuration、InUse 和 MaxOpenConnections。


```go
type Account struct { ID, BalanceCents int64 }

func Accounts(ctx context.Context, db *sql.DB, after int64) ([]Account, error) {
	rows, err := db.QueryContext(ctx, "SELECT id,balance_cents FROM accounts WHERE id>? ORDER BY id LIMIT 20", after)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := make([]Account, 0, 20)
	for rows.Next() {
		var account Account
		if err := rows.Scan(&account.ID, &account.BalanceCents); err != nil {
			return nil, err
		}
		out = append(out, account)
	}
	return out, rows.Err()
}
```

#### 建表、复现与索引解释

在专用测试数据库 handbook 中执行以下 SQL，再设置 MYSQL_DSN 后运行 go run ./mysql；输出应列出账户 1 的 10000 分和账户 2 的 5000 分。命令行程序只执行读取，不隐式发起转账。调用 Transfer(ctx, db, 1, 2, 2500) 后应在同一库中看到 7500 与 7500；余额不足或目标不存在时，两行余额都应保持原值。金额使用整数分，避免二进制浮点表示小数金额造成舍入问题。

EXPLAIN 中查看 key、type 与 rows：这个主键游标查询可以走 PRIMARY 范围，不应为取下一页扫描整张表。复合索引按实际查询条件的顺序定义；例如按 tenant_id、created_at、id 排序分页时，需要把租户等值条件和排序键一起考虑，单独给每个字段建索引并不能自动满足组合排序。


```sql
CREATE DATABASE handbook CHARACTER SET utf8mb4;
USE handbook;
CREATE TABLE accounts (
  id BIGINT PRIMARY KEY,
  balance_cents BIGINT NOT NULL,
  CHECK (balance_cents >= 0)
) ENGINE=InnoDB;
INSERT INTO accounts VALUES (1, 10000), (2, 5000);
EXPLAIN SELECT id, balance_cents
FROM accounts WHERE id > 0 ORDER BY id LIMIT 20;
-- shell: MYSQL_DSN='user:password@tcp(127.0.0.1:3306)/handbook' go run ./mysql
```

注意：不要在事务里调用外部模型、HTTP 或长时间工具；数据库锁会一直持有。不要把 sql.ErrNoRows、RowsAffected 为 0、驱动网络错误混成同一个 500：前者通常对应业务未找到，后者才是重试或告警候选。DSN 密码来自环境或密钥管理，不要写入日志。

参考：Go database/sql transactions — https://go.dev/doc/database/execute-transactions

参考：go-sql-driver/mysql v1.9.0 README — https://github.com/go-sql-driver/mysql/tree/v1.9.0

---

### Redis

缓存、过期、原子操作、队列和分布式锁。

#### 依赖安装与可运行文件

完整程序在 examples/services/redis/main.go，使用同目录的 go.mod、go.sum 固定依赖。下文 Go 片段均摘自或放入这个 package main；所需导入为 github.com/redis/go-redis/v9，另有 context、fmt、time 等标准库，具体 import 列表以完整文件为准。命令从手册根目录执行，代码节选中的 ctx、客户端和配置均由完整程序初始化。


```bash
cd examples/services
go mod download
go test ./redis
# 在自己的新模块中安装固定版本：
# go get github.com/redis/go-redis/v9@v9.7.3
```

#### 版本、客户端和超时

示例固定 github.com/redis/go-redis/v9 v9.7.3。使用 redis.ParseURL 读取 redis:// 或 rediss://，再设置 DialTimeout、ReadTimeout 和 WriteTimeout；客户端的 ContextTimeoutEnabled 能让调用遵守 context deadline。启动时 Ping 只能证明当前节点可达，不能证明 Sentinel、Cluster 或 ACL 配置正确。一个进程复用一个 Client，它内部管理连接；每个请求创建 Client 会浪费连接并使统计失真。

Redis 的 GET 有一个特殊结果 redis.Nil，表示 key 不存在，不应直接当作服务故障。空字符串是有效 value，故示例用 bool 明确区分命中与未命中。缓存读失败时是否回源由业务决定；对强一致写路径不能悄悄吞掉 Redis 错误。


```go
func Cached(ctx context.Context, rdb *redis.Client, key string) (string, bool, error) {
	value, err := rdb.Get(ctx, key).Result()
	if errors.Is(err, redis.Nil) {
		return "", false, nil
	}
	if err != nil {
		return "", false, err
	}
	return value, true, nil
}
```

#### 过期、计数和原子性

SET key value EX ttl 是一次命令，适合写入有明确生命周期的缓存。计数器如果分开执行 INCR 和 EXPIRE，进程在两条命令之间崩溃就会留下永不过期的 key；示例用 Lua 脚本让 INCR 与第一次 PEXPIRE 在 Redis 内部原子执行。脚本返回 int64，第一次调用得到 1，后续调用递增且不重置过期时间，因此可以作为固定窗口近似限流。

键名要有命名空间，例如 agent:{tenant}:session:{id}，避免不同租户相互覆盖；值的 JSON schema 变更要带版本或兼容解码。TTL 不是一致性策略：更新数据库后应先写库，再按需要删除或更新缓存，并考虑双写失败时的补偿。


```go
var increment = redis.NewScript(`
local n = redis.call('INCR', KEYS[1])
if n == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
return n
`)
func IncrementWithTTL(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (int64, error) {
    if ttl < time.Millisecond { return 0, errors.New("invalid TTL") }
    return increment.Run(ctx, rdb, []string{key}, ttl.Milliseconds()).Int64()
}
```

#### 锁的令牌、释放和失败结果

分布式锁至少要满足唯一 token、过期时间和只释放自己的锁。SET NX PX 在一个命令中完成竞争与租期；持有者退出时不能无条件 DEL，因为旧持有者可能已经超时，新持有者刚获得同一个 key。示例 Lua 脚本只有 GET 等于 token 才 DEL。Acquire 返回 false 不是 Redis 错误，而是竞争失败；调用方要决定排队、快速失败或回退。

释放时请单独创建有界 cleanup context：原请求可能已经取消，用原 ctx 会导致释放命令立即失败。比较 token 后 DEL 只保护释放动作，不提供 fencing token；若旧持有者停顿后恢复，仍可能继续写外部系统。需要强一致互斥时让目标数据库按递增 fencing token 拒绝旧写入，或使用数据库条件更新。

锁过期不会自动续租，也不能证明任务已停止；长任务应使用有明确续租协议的实现，或者把工作设计成可幂等、可恢复。Redis Cluster 下 Lua 的 KEYS 必须落在同一 hash slot；锁和计数器不要随意跨 slot 写成一个脚本。


```go
var unlock = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
end
return 0
`)

func Acquire(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (string, bool, error) {
	if ttl < time.Millisecond {
		return "", false, errors.New("TTL must be >= 1ms")
	}
	tokenBytes := make([]byte, 16)
	if _, err := rand.Read(tokenBytes); err != nil {
		return "", false, err
	}
	token := hex.EncodeToString(tokenBytes)
	ok, err := rdb.SetNX(ctx, key, token, ttl).Result()
	return token, ok, err
}

func Release(ctx context.Context, rdb *redis.Client, key, token string) (bool, error) {
	n, err := unlock.Run(ctx, rdb, []string{key}, token).Int64()
	return n == 1, err
}
```

#### Pipeline 批处理和返回值

当同一个请求要读取会话值和剩余 TTL 时，可以先把 GET 与 TTL 放入 Pipeline，再 Exec。这样减少网络往返，但命令间不构成事务隔离；其他客户端仍可能在两条命令之间改 key。Exec 的 error 是整批的汇总信息，仍要分别读取每条命令的 Result。TTL 为 -1 表示 key 无过期时间，-2 表示 key 不存在；go-redis 用 time.Duration 的特殊负值承载它们，不要把这两个值当作正常秒数。

运行 REDIS_URL=redis://127.0.0.1:6379/0 go run ./redis 会写 handbook:demo:greeting，保存 1 分钟，并递增 handbook:demo:requests；正常输出 value="你好" hit=true，计数值取决于一分钟内运行次数。使用同一测试前缀便于辨识副作用，真实环境不要用 KEYS * 扫描整个库，应使用带 MATCH 与小 COUNT 的 SCAN 并接受重复键。


```go
func SessionTTL(ctx context.Context, rdb *redis.Client, key string) (string, time.Duration, error) {
	pipe := rdb.Pipeline()
	get := pipe.Get(ctx, key)
	ttl := pipe.TTL(ctx, key)
	_, err := pipe.Exec(ctx)
	if err != nil && !errors.Is(err, redis.Nil) {
		return "", 0, err
	}
	value, err := get.Result()
	if err != nil {
		return "", 0, err
	}
	lifetime, err := ttl.Result()
	return value, lifetime, err
}
```

注意：不要把 Redis 当成事务数据库：MULTI/EXEC 不会替你回滚已经执行的副作用，也不能替代 MySQL 的持久化约束。生产中先决定 RDB/AOF、淘汰策略、主从故障切换和重试幂等，再决定是否把它放在关键写路径。

参考：go-redis v9.7.3 README 与命令实现 — https://github.com/redis/go-redis/tree/v9.7.3

参考：Redis SET command — https://redis.io/docs/latest/commands/set/

参考：Redis transactions — https://redis.io/docs/latest/develop/interact/transactions/

---

### MongoDB

文档模型、索引、聚合和 Go 驱动。

#### 依赖安装与可运行文件

完整程序在 examples/services/mongo/main.go，使用同目录的 go.mod、go.sum 固定依赖。下文 Go 片段均摘自或放入这个 package main；所需导入为 go.mongodb.org/mongo-driver/v2/{bson,mongo,mongo/options,mongo/readpref}，另有 context、fmt、time 等标准库，具体 import 列表以完整文件为准。命令从手册根目录执行，代码节选中的 ctx、客户端和配置均由完整程序初始化。


```bash
cd examples/services
go mod download
go test ./mongo
# 在自己的新模块中安装固定版本：
# go get go.mongodb.org/mongo-driver/v2@v2.1.0
```

#### 版本与客户端生命周期

示例固定 go.mongodb.org/mongo-driver/v2 v2.1.0，导入路径中的 /v2 不能省略。mongo.Connect 创建客户端并启动后台连接管理；用带 ServerSelectionTimeout 的 URI 或 ClientOptions，随后 Ping Primary 把“配置可解析”和“当前可选主节点”区分开。Client 应在进程中复用，退出时用新的短 context Disconnect；不能用已经超时的请求 context 清理连接。

文档数据库仍然需要明确 schema：Task 用 bson 标签定义字段，状态只允许 pending、running 等约定值，tenant 与 key 形成业务唯一身份。写入前建立唯一索引，重复 upsert 时可通过 mongo.WriteException 判断 duplicate key，而不是靠应用层先查再插。


```go
func Open(ctx context.Context, uri string) (*mongo.Client, error) {
	client, err := mongo.Connect(options.Client().ApplyURI(uri).SetServerSelectionTimeout(2 * time.Second))
	if err != nil {
		return nil, err
	}
	if err := client.Ping(ctx, readpref.Primary()); err != nil {
		cleanup, cancel := context.WithTimeout(context.Background(), time.Second)
		defer cancel()
		_ = client.Disconnect(cleanup)
		return nil, err
	}
	return client, nil
}
```

#### 索引、upsert 和更新结果

Indexes().CreateOne 建立 tenant、key 的唯一复合索引，CreateOne 在重复创建相同定义时通常可安全调用。UpdateOne 的 $setOnInsert 只在 upsert 新文档时写入初始字段；返回的 MatchedCount、ModifiedCount 和 UpsertedCount 有不同含义：匹配到但值没变化时 ModifiedCount 可以为 0，新建时 UpsertedCount 为 1。把这些字段写入日志有助于区分重试成功和真正的业务变更。

更新文档时优先使用 $set、$inc 等操作符，避免把不完整的 Go struct 整体替换掉。数组与嵌套文档要明确更新路径，未定义字段不会自动帮你校验业务规则；若需要严格 schema，应在 MongoDB collection validator 或服务层增加验证。


```go
func EnsureIndex(ctx context.Context, coll *mongo.Collection) error {
	_, err := coll.Indexes().CreateOne(ctx, mongo.IndexModel{
		Keys:    bson.D{{Key: "tenant", Value: 1}, {Key: "key", Value: 1}},
		Options: options.Index().SetUnique(true),
	})
	return err
}
```

#### 原子领取、无文档与事务选择

FindOneAndUpdate 可以把“找到 pending 任务”和“改成 running”合成一次服务器端操作；SetReturnDocument(options.After) 让 Decode 得到更新后的 attempts。没有匹配文档时 Decode 返回 mongo.ErrNoDocuments，通常代表队列暂时为空，而不是 500。过滤器必须包含状态条件，否则两个 worker 可能重复领取已经 running 的任务。

单文档更新本身具备原子性；跨多个文档的强一致变化才考虑 WithTransaction，并评估复制集、重试提交和事务时长。长时间运行的 Agent 不应把外部 API 调用放在 MongoDB 事务里。


```go
func Claim(ctx context.Context, coll *mongo.Collection, tenant, key string) (*Task, error) {
	filter := bson.D{{Key: "tenant", Value: tenant}, {Key: "key", Value: key}, {Key: "status", Value: "pending"}}
	update := bson.D{{Key: "$set", Value: bson.D{{Key: "status", Value: "running"}}},
		{Key: "$inc", Value: bson.D{{Key: "attempts", Value: 1}}}}
	var task Task
	err := coll.FindOneAndUpdate(ctx, filter, update,
		options.FindOneAndUpdate().SetReturnDocument(options.After)).Decode(&task)
	if errors.Is(err, mongo.ErrNoDocuments) {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	return &task, nil
}
```

#### 有界查询、游标与聚合

Find 返回 cursor，错误可能发生在建立游标、Decode 或继续取下一批时，所以既要处理 Find 的 error，又要在 Next 循环结束后检查 cursor.Err。这里按 key 排序并限制 20 条；无匹配文档返回空切片，不是 ErrNoDocuments。为这个查询建立 tenant、status、key 的复合索引；前面的 tenant/key 唯一索引约束身份，但不等于最合适的状态列表索引。

下面第二个函数先 $match 租户，再按 status 做 $group 和 $sum，得到 [{_id:"pending",count:12},{_id:"running",count:3}] 形式的统计。先过滤再分组减少参与聚合的数据，状态集合有限时 All 才不会把任意大小结果一次读进内存。运行 go run ./mongo 第一次通常 upserted=1 并成功 claim，第二次 matched=1 modified=0 且 claimed=<nil>，因为运行中的任务不再满足 pending 条件。


```go
func ListPending(ctx context.Context, coll *mongo.Collection, tenant string) ([]Task, error) {
	cursor, err := coll.Find(ctx, bson.D{{Key: "tenant", Value: tenant}, {Key: "status", Value: "pending"}},
		options.Find().SetSort(bson.D{{Key: "key", Value: 1}}).SetLimit(20))
	if err != nil {
		return nil, err
	}
	defer cursor.Close(ctx)
	out := make([]Task, 0, 20)
	for cursor.Next(ctx) {
		var task Task
		if err := cursor.Decode(&task); err != nil {
			return nil, err
		}
		out = append(out, task)
	}
	return out, cursor.Err()
}

type StatusCount struct { Status string `bson:"_id"`; Count int64 `bson:"count"` }

func Counts(ctx context.Context, coll *mongo.Collection, tenant string) ([]StatusCount, error) {
	pipeline := mongo.Pipeline{
		bson.D{{Key: "$match", Value: bson.D{{Key: "tenant", Value: tenant}}}},
		bson.D{{Key: "$group", Value: bson.D{{Key: "_id", Value: "$status"}, {Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}}}},
		bson.D{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}},
	}
	cursor, err := coll.Aggregate(ctx, pipeline)
	if err != nil {
		return nil, err
	}
	defer cursor.Close(ctx)
	var out []StatusCount
	if err := cursor.All(ctx, &out); err != nil {
		return nil, err
	}
	return out, nil
}
```

注意：不要因为 MongoDB 允许不同文档有不同字段就跳过索引和迁移设计；集合名、字段类型和索引都是运行时契约。读取关注 secondary 时要接受复制延迟，领取任务或支付状态这类决策通常读 primary。

参考：MongoDB Go Driver v2.1.0 — https://github.com/mongodb/mongo-go-driver/tree/v2.1.0

参考：MongoDB Go Driver CRUD — https://www.mongodb.com/docs/drivers/go/current/crud/

参考：MongoDB findOneAndUpdate — https://www.mongodb.com/docs/drivers/go/current/crud/update/find/

---

### Elasticsearch

倒排索引、分词、过滤和相关性。

#### 依赖安装与可运行文件

完整程序在 examples/services/elastic/main.go，使用同目录的 go.mod、go.sum 固定依赖。下文 Go 片段均摘自或放入这个 package main；所需导入为 github.com/elastic/go-elasticsearch/v8/esapi，另有 context、fmt、time 等标准库，具体 import 列表以完整文件为准。命令从手册根目录执行，代码节选中的 ctx、客户端和配置均由完整程序初始化。


```bash
cd examples/services
go mod download
go test ./elastic
# 在自己的新模块中安装固定版本：
# go get github.com/elastic/go-elasticsearch/v8@v8.17.0
```

#### 版本、请求体与租户过滤

示例固定 github.com/elastic/go-elasticsearch/v8 v8.17.0。客户端请求成功只代表 HTTP 请求完成，Response 的 2xx/3xx/4xx/5xx 仍要检查；JSON 解码成功也不代表搜索完整，因为 timed_out 或 _shards.failed > 0 可能返回部分结果。全文字段用 match 参与相关性评分，tenant、权限、状态等硬约束用 bool.filter，不要把权限条件放在 must 里依赖分数。

请求体使用 encoding/json 构造而不是手拼 JSON；用户搜索词作为值传入，索引名来自固定配置或白名单。示例 Transport 把 esapi.SearchRequest 接到一个 origin，生产节点较多时应使用官方 elasticsearch.NewClient 的节点配置、认证和重试选项。


```go
func Search(ctx context.Context, transport esapi.Transport, tenant, text string) (*SearchResult, error) {
	body, err := json.Marshal(map[string]any{
		"size": 10, "track_total_hits": true,
		"query": map[string]any{"bool": map[string]any{
			"filter": []any{map[string]any{"term": map[string]any{"tenant": tenant}}},
			"must":   []any{map[string]any{"match": map[string]any{"title": text}}},
		}},
	})
	if err != nil {
		return nil, err
	}
	res, err := (esapi.SearchRequest{Index: []string{"handbook-docs"}, Body: strings.NewReader(string(body))}).Do(ctx, transport)
	if err != nil {
		return nil, fmt.Errorf("transport: %w", err)
	}
	defer res.Body.Close()
	if res.IsError() {
		detail, readErr := io.ReadAll(io.LimitReader(res.Body, 4096))
		if readErr != nil {
			return nil, fmt.Errorf("HTTP %d: read error body: %w", res.StatusCode, readErr)
		}
		return nil, fmt.Errorf("HTTP %d: %s", res.StatusCode, detail)
	}
	var result SearchResult
	if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
		return nil, err
	}
	if result.TimedOut || result.Shards.Failed > 0 {
		return nil, errors.New("search returned incomplete results")
	}
	return &result, nil
}
```

#### 状态码、解析与总数关系

Search API 返回 404 可能是索引不存在，400 常见于 DSL 或字段类型错误，401/403 是认证授权问题，429 表示集群拒绝或资源压力，5xx 需要结合集群健康判断是否可重试。读取错误响应前限制大小，避免把服务器返回的大段 HTML 或 JSON 写入日志。成功响应中的 hits.total.relation 可能是 eq 或 gte；只有 relation=eq 时 value 才是精确总数，track_total_hits=true 会增加成本，不应在所有高吞吐列表都打开。

timed_out=true 或 shards.failed>0 时，不能把结果包装成“完整检索成功”。可以把它作为降级结果返回并标记 partial，或者直接让上层重试；选择取决于搜索是否用于权限决策。

#### mapping、分页和 Agent 场景

索引 mapping 决定 analyzer、term 查询和排序行为：title 若是 text，match 会分词；精确过滤、聚合和去重应提供 keyword 子字段。大结果集不要用 from+size 无限翻页，使用 search_after 加稳定排序键，或者 PIT 保持一致的检索视图。删除和重建 mapping 往往需要新索引与 alias 切换，客户端代码应能接受索引别名而不是写死物理版本。

Agent 检索还要把召回与权限分层：每次 query 都带 tenant、用户可见范围或 ACL filter，引用原文前检查文档版本。向量 kNN、全文 BM25、重排和最终答案引用是不同步骤，先记录 query、命中 ID、分数和耗时，才能解释“为什么召回了这段”。


```go
for _, hit := range result.Hits.Hits {
    fmt.Printf("id=%s title=%q\n", hit.ID, hit.Source.Title)
}
if result.Hits.Total.Relation == "gte" {
    fmt.Printf("total is at least %d\n", result.Hits.Total.Value)
}
```

#### 创建 mapping 与可重复查询

以下请求采用本机无认证测试节点；启用安全功能的集群要配置证书与 API key，不能靠关闭证书校验绕过 TLS。先创建 tenant=keyword、title=text 的 mapping，再用固定 _id 写文档；固定 ID 的 PUT 会覆盖同 ID 文档，避免重复执行示例产生多份记录。refresh=wait_for 让这次写入等待可搜索，便于验证，但不要给高吞吐生产写入逐条强制刷新。

随后运行 ELASTICSEARCH_URL=http://127.0.0.1:9200 go run ./elastic，应命中 doc-1，total=1、relation=eq。若重复创建索引收到 400 resource_already_exists_exception，表示索引已存在，应检查现有 mapping，而非直接删索引。中文语义检索需要单独评估分词器，默认 standard analyzer 不等价于中文专业分词，也不等价于 embedding 召回。


```bash
curl -X PUT 'http://127.0.0.1:9200/handbook-docs' \
  -H 'Content-Type: application/json' \
  -d '{"mappings":{"properties":{"tenant":{"type":"keyword"},"title":{"type":"text"}}}}'
curl -X PUT 'http://127.0.0.1:9200/handbook-docs/_doc/doc-1?refresh=wait_for' \
  -H 'Content-Type: application/json' \
  -d '{"tenant":"demo","title":"Go database guide"}'
ELASTICSEARCH_URL=http://127.0.0.1:9200 go run ./elastic
```

注意：不要把 Elasticsearch 当作权限数据库或唯一事实来源；写入通常是近实时的，搜索结果可能暂时看不到刚写入的文档。重试只针对超时、连接断开或明确可重试状态，并给请求设置上限与退避，避免 429 时把集群压垮。

参考：Elasticsearch Go client v8.17.0 esapi — https://github.com/elastic/go-elasticsearch/tree/v8.17.0/esapi

参考：Elasticsearch search API — https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html

参考：Elasticsearch bool query — https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-bool-query.html

---

### HTTP 服务开发

HTTP 服务、REST、鉴权、中间件和部署。

#### 依赖安装与可运行文件

完整程序在 examples/services/httpserver/main.go，使用同目录的 go.mod、go.sum 固定依赖。下文 Go 片段均摘自或放入这个 package main；所需导入为 net/http、encoding/json、os/signal、log/slog，另有 context、fmt、time 等标准库，具体 import 列表以完整文件为准。命令从手册根目录执行，代码节选中的 ctx、客户端和配置均由完整程序初始化。


```bash
cd examples/services
go mod download
go test ./httpserver
go run ./httpserver
```

#### 标准库路由和请求边界

示例使用 Go 1.22+ net/http 的方法与路径模式（运行时为 Go 1.27.1），不额外引入框架。ServeMux 用 GET /healthz、POST /echo/{name} 直接表达路由；PathValue 读取路径变量。HTTP handler 负责协议边界：限制 body 大小、拒绝未知 JSON 字段、只接受一个 JSON 值、检查必填字段，并用明确的 4xx 返回客户端错误。业务函数可以接收已经解析的结构体，从而在单元测试中绕开网络。

请求体限制不能只依赖反向代理；MaxBytesReader 会让超限读取返回 MaxBytesError，示例将它映射为 413。Decoder.DisallowUnknownFields 适合严格内部 API，但版本演进时要先决定是否允许客户端携带新字段。响应写出前设置 Content-Type，写出后再改变状态码已经无效。


```go
type EchoRequest struct { Message string `json:"message"` }

func Handler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		_, _ = io.WriteString(w, "ok\n")
	})
	mux.HandleFunc("POST /echo/{name}", func(w http.ResponseWriter, r *http.Request) {
		r.Body = http.MaxBytesReader(w, r.Body, 4096)
		dec := json.NewDecoder(r.Body)
		dec.DisallowUnknownFields()
		var req EchoRequest
		if err := dec.Decode(&req); err != nil {
			var tooLarge *http.MaxBytesError
			if errors.As(err, &tooLarge) {
				http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
			} else {
				http.Error(w, "invalid JSON", http.StatusBadRequest)
			}
			return
		}
		var extra any
		if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
			var tooLarge *http.MaxBytesError
			if errors.As(err, &tooLarge) {
				http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
			} else {
				http.Error(w, "one JSON value required", http.StatusBadRequest)
			}
			return
		}
		if strings.TrimSpace(req.Message) == "" {
			http.Error(w, "message required", http.StatusBadRequest)
			return
		}
		payload, err := json.Marshal(map[string]string{"name": r.PathValue("name"), "message": req.Message})
		if err != nil {
			http.Error(w, "encode failed", http.StatusInternalServerError)
			return
		}
		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		if _, err := w.Write(append(payload, '\n')); err != nil {
			slog.Warn("write response", "error", err)
		}
	})
	return mux
}
```

#### 超时、鉴权和中间件顺序

Server 的 ReadHeaderTimeout、ReadTimeout、WriteTimeout 和 IdleTimeout 保护的是连接与读写阶段，不能替代业务 context deadline。Handler 应通过 r.Context() 把取消传给数据库、模型和下游 HTTP；外层可用 middleware 注入 request ID、鉴权主体、日志和 metrics。鉴权应在读取敏感业务数据前完成，审计日志记录主体、资源、结果和 request ID，不记录 Authorization 或完整 token。

HTTP 错误体应有稳定结构（例如 code、message、request_id），客户端可以按 code 分类。对 POST 重试前必须定义幂等键或业务去重；网络断开时客户端不知道服务端是否已经完成，不能仅凭超时就再次扣款。


```go
func run() error {
	srv := &http.Server{Addr: "127.0.0.1:8080", Handler: Handler(), ReadHeaderTimeout: 2 * time.Second, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second}
	stopped, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	result := make(chan error, 1)
	go func() { result <- srv.ListenAndServe() }()
	select {
	case err := <-result:
		return err
	case <-stopped.Done():
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		if err := srv.Shutdown(ctx); err != nil {
			_ = srv.Close()
			return err
		}
		err := <-result
		if errors.Is(err, http.ErrServerClosed) {
			return nil
		}
		return err
	}
}
```

#### 可测试的结果契约

httptest.NewRecorder 与 httptest.NewRequest 可以在没有端口监听的情况下覆盖路由、状态码、响应头和错误体。示例测试了 health、正常 echo、方法不允许、未知字段、多个 JSON 值、空消息和超大 body；这些测试证明的是 handler 契约，不是 TLS、代理转发或真实网络链路。生产部署仍需反向代理的 TLS、连接数和 body 限制，以及健康检查与负载摘除策略。

/healthz 只表示进程能响应；/readyz 才适合表示数据库、队列等关键依赖已经准备好。依赖短暂不可用时返回 503，并在日志中区分启动失败、业务 4xx 和下游 5xx，避免监控把客户端输错都算成服务故障。


```go
func TestHTTPContract(t *testing.T) {
    rr := httptest.NewRecorder()
    req := httptest.NewRequest(http.MethodPost, "/echo/alice", strings.NewReader(`{"message":"你好"}`))
    Handler().ServeHTTP(rr, req)
    if rr.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) }
    if got := rr.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") { t.Fatal(got) }
}
```

#### 手动请求、响应和退出

启动 go run ./httpserver 后，在另一个终端执行 curl。正确请求返回 200 与 {"message":"你好","name":"alice"}；GET /echo/alice 返回 405，message 为空返回 400，超过 4096 字节返回 413。完整 Handler 在第一份 JSON 后再 Decode 一次，只接受 io.EOF，因此 {"message":"x"}{} 不会被当作一个合法请求悄悄丢弃第二个值。

终端 Ctrl-C 触发 signal.NotifyContext，Shutdown 停止接受新连接并等待在途请求；超过 5 秒则主动 Close，run 把真实启动和关闭错误返回 main。WriteTimeout 对有限 JSON 响应合适，若做 SSE 或长流式响应要另行设计超时与 flush，不能照搬固定 5 秒后宣称流式接口可用。


```bash
curl -i http://127.0.0.1:8080/healthz
curl -i -X POST http://127.0.0.1:8080/echo/alice \
  -H 'Content-Type: application/json' -d '{"message":"你好"}'
curl -i -X POST http://127.0.0.1:8080/echo/alice \
  -H 'Content-Type: application/json' -d '{"message":"x"}{}'
go test -v ./httpserver
```

注意：不要把 http.ListenAndServe 的错误全部忽略；正常 Shutdown 会返回 http.ErrServerClosed，可将它视为正常结束，其他错误应让进程失败并触发重启。CORS、CSRF、认证和限流是部署策略的一部分，不能因为 handler 能返回 200 就默认已经安全。

参考：Go net/http package — https://pkg.go.dev/net/http

参考：Go HTTP server timeouts — https://go.dev/src/net/http/server.go

参考：Go httptest package — https://pkg.go.dev/net/http/httptest

---

### gRPC

gRPC、protobuf、流式 RPC 和错误模型。

#### 依赖安装与可运行文件

完整程序在 examples/services/grpc/main.go，使用同目录的 go.mod、go.sum 固定依赖。下文 Go 片段均摘自或放入这个 package main；所需导入为 google.golang.org/grpc、grpc/status、grpc/codes 和本模块 grpc/pb，另有 context、fmt、time 等标准库，具体 import 列表以完整文件为准。命令从手册根目录执行，代码节选中的 ctx、客户端和配置均由完整程序初始化。


```bash
cd examples/services
go mod download
go test ./grpc
# 在自己的新模块中安装固定版本：
# go get google.golang.org/grpc@v1.71.0 google.golang.org/protobuf@v1.36.5
go run ./grpc
```

#### protobuf 与生成代码

示例固定 google.golang.org/grpc v1.71.0、google.golang.org/protobuf v1.36.5。greeting.proto 定义 Greeter.Hello，protoc-gen-go 与 protoc-gen-go-grpc 生成消息和 client/server interface；应用代码实现 Greeter，而不是手写 wire protocol。字段编号是长期兼容契约，已经发布的编号不能复用；删除字段要 reserved。新增字段对旧客户端通常安全，改变字段类型或语义则需要新字段或新 service version。

客户端用 grpc.NewClient 和 transport credentials 建立连接，真正的 RPC 调用仍需 context deadline。服务端实现应检查 ctx.Err，参数错误用 status.Error(codes.InvalidArgument)，调用方通过 status.Code 分类；不要只比较 error 字符串。


```go
type Greeter struct { pb.UnimplementedGreeterServer }

func (Greeter) Hello(ctx context.Context, req *pb.HelloRequest) (*pb.HelloReply, error) {
	if err := ctx.Err(); err != nil {
		return nil, status.FromContextError(err).Err()
	}
	name := strings.TrimSpace(req.GetName())
	if name == "" {
		return nil, status.Error(codes.InvalidArgument, "name is required")
	}
	return &pb.HelloReply{Message: "你好，" + name}, nil
}
```

#### deadline、取消和重试

context.WithTimeout 应在调用边界创建，并传到所有下游。deadline 到期时服务端可能已经执行了一部分副作用，客户端收到 DeadlineExceeded 不能直接推断“没有写入”；写操作要使用幂等键或服务端去重。gRPC retry policy 只适合明确幂等、可重试的 codes（例如 Unavailable），不要对 InvalidArgument、PermissionDenied 或未知业务错误重试。

长连接和 streaming RPC 还要定义消息顺序、背压、半关闭和取消行为。服务端流向客户端时检查 Send 返回错误；客户端读取 io.EOF 表示正常结束，其他 error 需要取 status.Code。单次消息大小、keepalive 和连接空闲参数应按代理与服务端限制协商，而不是盲目调大。


```go
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
reply, err := client.Hello(ctx, &pb.HelloRequest{Name: "小明"})
if err != nil {
    switch status.Code(err) {
    case codes.DeadlineExceeded, codes.Unavailable:
        // 仅在操作幂等且有退避时重试
    case codes.InvalidArgument:
        return fmt.Errorf("client input: %w", err)
    }
    return err
}
fmt.Println(reply.GetMessage())
```

#### 拦截器、健康检查与测试

Unary interceptor 适合统一注入 trace、日志、metrics 和认证元数据；业务 handler 只处理业务输入。认证 metadata 需要 TLS 或受保护的通道，不能把 bearer token 放进 protobuf 消息或日志。grpc health checking 的 Check 结果应区分进程存活与依赖就绪，滚动发布时配合 GracefulStop 等待正在处理的 RPC。

in-process server 或 bufconn 测试可以不打开真实端口，但仍应覆盖 status code、deadline、取消和未知方法等协议行为。示例使用真实 loopback listener 验证生成 client/server 能互通，同时调用空 name 验证 InvalidArgument；没有外部服务依赖。


```go
func run() error {
	listener, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		return err
	}
	server := grpc.NewServer()
	pb.RegisterGreeterServer(server, Greeter{})
	serveErr := make(chan error, 1)
	go func() { serveErr <- server.Serve(listener) }()
	defer server.Stop()
	conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		return err
	}
	defer conn.Close()
	client := pb.NewGreeterClient(conn)
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	reply, err := client.Hello(ctx, &pb.HelloRequest{Name: "小明"})
	if err != nil {
		return fmt.Errorf("Hello code=%s: %w", status.Code(err), err)
	}
	fmt.Println(reply.GetMessage())
	_, err = client.Hello(ctx, &pb.HelloRequest{})
	if status.Code(err) != codes.InvalidArgument {
		return fmt.Errorf("expected InvalidArgument; got %v", err)
	}
	fmt.Println("empty name:", status.Code(err))
	server.GracefulStop()
	return <-serveErr
}
```

#### 接口文件与生成命令

完整示例随仓库提交生成文件，所以普通运行不要求本机安装 protoc。修改接口时才重新生成：生成插件固定 protoc-gen-go v1.36.5、protoc-gen-go-grpc v1.5.1，验证时 protoc 为 33.4；go_package 必须指向真实 Go 模块内的 pb 目录。UnimplementedGreeterServer 通过值嵌入为未来新增 RPC 提供 Unimplemented 默认响应。

语义上空 name 是 InvalidArgument，找不到用户是 NotFound，未认证是 Unauthenticated，无权读取是 PermissionDenied；不要全部返回 Internal，否则客户端无法区分输入问题和服务失败。示例 insecure.NewCredentials 仅用于本机明文演示，对跨机真实调用用 credentials.NewTLS 并验证服务名。


```protobuf
syntax = "proto3";
package handbook.greeting.v1;
option go_package = "handbook/services/grpc/pb;pb";
service Greeter {
  rpc Hello(HelloRequest) returns (HelloReply);
}
message HelloRequest { string name = 1; }
message HelloReply { string message = 1; }
```

#### 重新生成协议文件

下面命令从 examples/services 执行，要求已安装 protoc。生成完运行 go test ./grpc/... 和 go run ./grpc；示例会创建临时端口、完成两次 RPC、关闭连接和服务器，因此执行后不会留下常驻进程。


```bash
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.5
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1
# 将 go env GOPATH 输出目录下的 bin 加入 PATH 后执行：
protoc --go_out=. --go_opt=module=handbook/services \
  --go-grpc_out=. --go-grpc_opt=module=handbook/services grpc/pb/greeting.proto
go run ./grpc
```

注意：不要把 gRPC status code 当作 HTTP 状态码使用；Unavailable 与 DeadlineExceeded 可能来自网络、代理或服务端过载，需要结合 trace 和重试预算判断。proto 字段号一旦线上使用就不能改名复用，生成代码应放入受版本控制的接口包并在 CI 中重新生成检查差异。

参考：gRPC Go quick start — https://grpc.io/docs/languages/go/quickstart/

参考：gRPC status codes — https://grpc.io/docs/guides/status-codes/

参考：grpc-go v1.71.0 examples — https://github.com/grpc/grpc-go/tree/v1.71.0/examples

---

### tRPC-Go

腾讯 tRPC-Go 的 RPC 服务治理方向。

#### 依赖安装与可运行文件

完整程序在 examples/services/trpc/main.go，使用同目录的 go.mod、go.sum 固定依赖。下文 Go 片段均摘自或放入这个 package main；所需导入为 trpc.group/trpc-go/trpc-go、client、server、errs 和生成的协议包，另有 context、fmt、time 等标准库，具体 import 列表以完整文件为准。命令从手册根目录执行，代码节选中的 ctx、客户端和配置均由完整程序初始化。


```bash
cd examples/services
go mod download
go test ./trpc
# 在自己的新模块中安装固定版本：
# go get trpc.group/trpc-go/trpc-go@v1.0.3
go run ./trpc
```

#### 包、版本与生成服务

tRPC-Go 和 tRPC-Agent-Go 是两个层次：tRPC-Go（示例 trpc.group/trpc-go/trpc-go v1.0.3）提供 RPC server、client、协议、注册与治理；tRPC-Agent-Go（本手册引用 v1.11.2）提供 Agent runtime、模型、tool、workflow 等。Agent 可以把 tRPC-Go client 当作工具或下游调用方式，但不能把两者的 import、配置或生命周期混写。

tRPC-Go 的常规工作流是 protobuf + trpc-cmdline 生成 service descriptor、request/response 和 client proxy。服务端通过 RegisterGreeterService 注册实现，客户端 proxy 通过 client.WithTarget 与 client.WithTimeout 指定目标和单次调用超时。目标地址按框架支持的 URI 形式配置，示例使用 ip://127.0.0.1:port。


```go
func run() error {
	listener, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		return err
	}
	defer listener.Close()
	cfg := &trpc.Config{}
	cfg.Server.Service = []*trpc.ServiceConfig{{Name: "trpc.test.helloworld.Greeter", IP: "127.0.0.1", Port: uint16(listener.Addr().(*net.TCPAddr).Port), Protocol: "trpc"}}
	srv := trpc.NewServerWithConfig(cfg, server.WithListener(listener))
	helloworld.RegisterGreeterService(srv.Service("trpc.test.helloworld.Greeter"), Greeter{})
	done := make(chan error, 1)
	go func() { done <- srv.Serve() }()
	// The exact client address and timeout are per-call options; the generated
	// proxy still invokes client.Client, separate from tRPC-Agent-Go runtime.
	proxy := helloworld.NewGreeterClientProxy(client.WithTarget("ip://"+listener.Addr().String()), client.WithTimeout(2*time.Second))
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()
	reply, err := proxy.SayHello(ctx, &helloworld.HelloRequest{Msg: "world"})
	if err != nil {
		return fmt.Errorf("SayHello: %w", err)
	}
	fmt.Println(reply.GetMsg())
	_, err = proxy.SayHello(ctx, &helloworld.HelloRequest{})
	if errs.Code(err) != 10001 {
		return fmt.Errorf("expected code 10001; got %v", err)
	}
	fmt.Println("empty msg:", errs.Code(err))
	if err := srv.Close(nil); err != nil {
		return err
	}
	select {
	case err := <-done:
		if err != nil {
			return err
		}
	case <-time.After(time.Second):
		return errors.New("server did not stop")
	}
	return nil
}
```

#### 配置、协议服务和错误码

Config.Server.Service 是 []*trpc.ServiceConfig，每个条目表示一个逻辑 service，包含 Name、IP/Port、Protocol 和 Timeout；同一个进程可以添加多个 service，分别提供 trpc 或 http 协议。Register 到具体 s.Service(name) 能避免把实现意外挂到所有 service。服务启动错误应在启动阶段暴露，避免端口监听失败后进程仍然接受健康检查。

业务错误使用 tRPC 的错误码而不是 errors.New 字符串。客户端可以用 errs.Code(err) 识别 10001 这类业务错误，按错误码决定展示、告警或重试；deadline、节点不可用和业务拒绝必须分开统计。把原始错误直接返回给跨进程调用者可能泄漏 SQL、token 或内部地址，边界层应包装为稳定消息。


```go
_, err := proxy.SayHello(ctx, &helloworld.HelloRequest{})
if errs.Code(err) != 10001 {
    return fmt.Errorf("unexpected tRPC code: %v", err)
}
// 10001 是本示例定义的业务输入错误；它不是网络故障，不应盲目重试。
```

#### 生命周期、过滤器与 Agent 调用

tRPC-Go 的 filter 链可用于认证、限流、trace、熔断和统一日志；过滤器应保持短小，把超时和取消继续传递给下游。关闭时先停止接收新请求，再等待在途调用，释放数据库和模型客户端。Agent 工具调用 tRPC 服务时，工具 schema 负责输入校验，tRPC handler 仍必须再次鉴权和验证租户，不能因为请求来自 Agent 就信任参数。

用 loopback listener 进行集成测试可以验证生成 proxy、编码和 server 处理链；真实注册中心、TLS、跨机路由和治理插件需要单独的环境测试。源码示例只依赖本地模块缓存，不把 tRPC-Agent-Go 当作 tRPC-Go 的替代品。


```go
done := make(chan error, 1)
go func() { done <- srv.Serve() }()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
rsp, err := proxy.SayHello(ctx, &helloworld.HelloRequest{Msg: "world"})
if err != nil { return fmt.Errorf("tRPC call: %w", err) }
fmt.Println(rsp.GetMsg())
if err := srv.Close(nil); err != nil { return err }
```

#### 配置字段与生成桩的实际来源

本地可执行样例直接导入固定版本框架的 testdata/trpc/helloworld 包，里面包含已生成的 protobuf 消息和 tRPC proxy，目的是让示例不依赖未知版本生成器即可编译。它不是推荐的生产接口包；业务应把自己的 .proto 与生成代码放到独立契约模块，通过 tRPC 官方 trpc-cmdline 生成，再替换此演示导入。GreeterService 接口要求 SayHello 和 SayHi 两个方法，完整 Greeter 已实现两者。

常驻服务通常通过 trpc.NewServer() 读取当前工作目录 trpc_go.yaml；以下 YAML 的 service name 与注册时 s.Service(name) 必须一致，timeout 数值单位是毫秒。当前可运行示例改用 NewServerWithConfig 和预先打开的随机端口 listener，避开固定端口冲突；它既没有连接注册中心，也没有验证治理插件、TLS 或跨机器可达性。


```yaml
server:
  app: test
  server: helloworld
  service:
    - name: trpc.test.helloworld.Greeter
      ip: 127.0.0.1
      port: 19090
      network: tcp
      protocol: trpc
      timeout: 2000
```

注意：不要把 tRPC-Go 的生成 proxy、server config 或错误码当成 tRPC-Agent-Go API；两者版本、模块路径和职责不同。不要只设置服务端总超时而不设置客户端 deadline，也不要把注册中心可用误认为业务依赖健康。

参考：tRPC-Go v1.0.3 server 与配置 — https://github.com/trpc-group/trpc-go/tree/v1.0.3/server

参考：tRPC-Go v1.0.3 生成代理 — https://github.com/trpc-group/trpc-go/blob/v1.0.3/testdata/trpc/helloworld/helloworld.trpc.go

参考：tRPC-Go v1.0.3 快速开始 — https://github.com/trpc-group/trpc-go/blob/v1.0.3/docs/quick_start.zh_CN.md

---

### 服务可观测性

配置、日志、指标、Tracing 和健康检查。

#### 依赖安装与可运行文件

完整程序在 examples/services/observe/main.go，使用同目录的 go.mod、go.sum 固定依赖。下文 Go 片段均摘自或放入这个 package main；所需导入为 log/slog、go.opentelemetry.io/otel、sdk/trace、propagation、attribute、codes，另有 context、fmt、time 等标准库，具体 import 列表以完整文件为准。命令从手册根目录执行，代码节选中的 ctx、客户端和配置均由完整程序初始化。


```bash
cd examples/services
go mod download
go test ./observe
# 在自己的新模块中安装固定版本：
# go get go.opentelemetry.io/otel@v1.34.0 go.opentelemetry.io/otel/sdk@v1.34.0 go.opentelemetry.io/otel/exporters/stdout/stdouttrace@v1.34.0
go run ./observe
```

#### 配置和结构化日志

示例使用 Go 标准库 slog 与 OpenTelemetry Go v1.34.0。配置解析要在启动阶段完成：PORT 验证范围，REQUEST_TIMEOUT 使用 time.ParseDuration 并拒绝非正值。配置错误直接退出并带字段名，不能静默回退到不可预期端口。slog 使用 JSON handler 把 operation、status、duration_ms、trace_id 等字段作为结构化字段，便于按请求、租户和错误类别检索。

日志必须有脱敏边界：不要记录 Authorization、API key、完整 prompt、数据库 DSN 或未经筛选的用户文档。需要调试模型输入时记录哈希、长度、版本和采样后的安全摘要。日志级别和采样在运行时可调，但错误和审计事件不能因为采样全部消失。


```go
func LoadConfig() (Config, error) {
	cfg := Config{Port: 8080, RequestTimeout: 2 * time.Second}
	if raw := os.Getenv("PORT"); raw != "" {
		n, err := strconv.Atoi(raw)
		if err != nil || n < 1 || n > 65535 {
			return cfg, errors.New("PORT must be 1..65535")
		}
		cfg.Port = n
	}
	if raw := os.Getenv("REQUEST_TIMEOUT"); raw != "" {
		timeout, err := time.ParseDuration(raw)
		if err != nil || timeout <= 0 {
			return cfg, errors.New("REQUEST_TIMEOUT must be positive")
		}
		cfg.RequestTimeout = timeout
	}
	return cfg, nil
}
```

#### Trace、span 和跨边界传播

TracerProvider 应在启动时安装，并在退出时 Shutdown 刷新 BatchSpanProcessor；否则短命 CLI 或测试进程可能还没导出 span 就结束。每个用户请求创建根 span，数据库、HTTP、模型和工具各自创建子 span，span 名称应稳定（GET /greeting、store.lookup、agent.tool.call），动态 ID 放在 attributes。跨 HTTP 或 gRPC 边界用标准 propagator 注入/提取 traceparent，不能把 trace ID 只写日志而丢失上下游关联。

span attribute 记录 tenant、model、tool 名称和重试次数时要做低基数设计；用户输入全文、token、邮箱等高敏感值不要直接放 attribute。错误要 RecordError 并 SetStatus(codes.Error)，同时保留 context cancellation 与 deadline 的区分，才能知道是用户取消、上游超时还是服务内部失败。


```go
func SetupTracer() (*sdktrace.TracerProvider, error) {
	exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
	if err != nil {
		return nil, err
	}
	res := resource.NewWithAttributes("", attribute.String("service.name", "handbook-api"), attribute.String("service.version", "demo-1"))
	provider := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter), sdktrace.WithResource(res))
	otel.SetTracerProvider(provider)
	otel.SetTextMapPropagator(propagation.TraceContext{})
	return provider, nil
}
```

#### 指标、健康检查和故障定位

至少记录请求总数、耗时直方图、活跃请求、下游调用错误、重试次数和队列长度；标签使用 operation、dependency、result 等有限集合，避免把 request ID 当作指标标签制造高基数。数据库池的 WaitCount/WaitDuration、Redis 命中率、Elasticsearch timed_out 与 shard failure、RPC status code 都应进入各自指标。

/healthz 表示进程还活着，/readyz 表示依赖已准备好；readiness 失败时从负载均衡摘除，liveness 不应因为一个短暂数据库故障就让编排器无限重启。故障排查按 trace ID 串起入口、鉴权、模型、工具和存储，查看每个 span 的 deadline、状态、重试和返回大小，最后再看日志正文。


```go
func Lookup(ctx context.Context, key string) (string, error) {
	ctx, span := otel.Tracer("handbook/services").Start(ctx, "store.lookup")
	defer span.End()
	if err := ctx.Err(); err != nil {
		span.RecordError(err)
		span.SetStatus(codes.Error, "request canceled")
		return "", err
	}
	if key != "demo" {
		err := errors.New("key not found")
		span.RecordError(err)
		span.SetStatus(codes.Error, "key not found")
		return "", err
	}
	sc := trace.SpanContextFromContext(ctx)
	slog.InfoContext(ctx, "lookup completed", "trace_id", sc.TraceID().String(), "span_id", sc.SpanID().String())
	return "hello", nil
}
```

#### 传播与日志关联的完整链路

仅创建 span 不会自动注入 HTTP header；服务端先 Extract 得到远程父上下文，再 Start 服务端 span，出站请求在发送前 Inject。示例 TraceHTTP 与 Outbound 是可编译的封装函数，接入现有 handler 或 http.Client.Do 即可；生产可采用 OpenTelemetry 的 HTTP instrumentation 自动处理标准语义字段。只有携带同一个 context，下游 span 的 TraceID 才与入口一致。

运行 go run ./observe 时，stdout 先出现 hello，再输出 store.lookup 与 GET /greeting 的 JSON span；两者 TraceID 相同，lookup 的 Parent.SpanID 等于入口 SpanID。stderr 同时有结构化日志，其 trace_id 与 span_id 可关联到 lookup。stdout exporter 只是本地可读输出，不能代表 OTLP Collector、采样器或远端存储已经连通；切换 exporter 后需要单独验证导出错误与 Shutdown flush。


```go
func TraceHTTP(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := otel.GetTextMapPropagator().Extract(r.Context(), propagation.HeaderCarrier(r.Header))
		ctx, span := otel.Tracer("handbook/services").Start(ctx, "HTTP request", trace.WithSpanKind(trace.SpanKindServer))
		defer span.End()
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func Outbound(ctx context.Context, endpoint string) (*http.Request, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return nil, err
	}
	otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))
	return req, nil
}
```

注意：不要把“有日志”当成可观测性完成：没有关联 ID、稳定错误分类、耗时和下游边界，日志无法定位一次 Agent 请求。也不要把全部请求内容写入 trace；可观测性数据本身需要权限、保留期和脱敏策略。

参考：OpenTelemetry Go instrumentation — https://opentelemetry.io/docs/languages/go/instrumentation/

参考：OpenTelemetry Go v1.34.0 trace SDK — https://github.com/open-telemetry/opentelemetry-go/tree/v1.34.0/sdk/trace

参考：Go slog package — https://pkg.go.dev/log/slog

---

## 第三方库与工程

### Gin

路由、中间件、绑定和 HTTP API。

#### 它解决什么问题

Gin 在 net/http 之上提供路由树、参数绑定、分组和中间件，适合把一个 Agent 服务快速暴露成 HTTP API。它不会替你决定错误协议、认证、限流、幂等或会话存储。

如果团队已经大量使用标准库 Handler，Gin 的价值主要是路由和绑定便利；如果希望所有中间件都保持标准库形态，应比较 Chi。

#### 安装与最小服务

在模块目录执行 go get github.com/gin-gonic/gin。生产入口通常使用 gin.New()，显式注册 Logger、Recovery、RequestID、鉴权和追踪中间件；gin.Default() 只适合快速原型。

启动前从配置读取监听地址，并在服务关闭时调用 http.Server.Shutdown，而不是直接依赖 r.Run 阻塞到进程被杀。

#### 路由、参数和绑定

路径参数用 c.Param，查询参数用 c.Query 或 ShouldBindQuery，JSON 请求用 ShouldBindJSON。绑定只负责语法解析，业务层仍要检查长度、范围、租户和权限。

ShouldBindJSON 失败后由代码统一返回错误；BindJSON 会自动写 400 并中止上下文，后续再改状态码容易出现响应头已发送的警告。

#### 中间件顺序与错误协议

中间件在 c.Next 前执行前置逻辑，在 c.Next 后执行耗时、状态码和审计。认证失败要调用 c.Abort，并立即返回；只 return 不会阻止后续 handler。

建议统一返回 request_id、错误码和可读 message。模型错误、工具错误和参数错误不要全部映射成 500。

#### SSE 接入 tRPC-Agent-Go

Gin Handler 只负责把认证用户、sessionID 和 c.Request.Context() 交给 Runner，再把事件转换成稳定的 SSE 事件。Agent 包不应依赖 *gin.Context。

Flush 前设置 text/event-stream、禁用缓存和连接保持策略；客户端断开后用请求 context 取消 Runner，不能继续消耗模型和工具资源。

#### 测试与生产检查

用 httptest.NewRecorder 测试绑定失败、认证失败、SSE 事件顺序、客户端取消和下游错误。生产检查还要包括请求体上限、响应超时、优雅退出和访问日志脱敏。

#### 参考片段

```go
package main

import (
    "context"
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
)

type ChatRequest struct {
    Message string `json:"message" binding:"required,min=1,max=4000"`
}

func main() {
    r := gin.New()
    r.Use(gin.Logger(), gin.Recovery())
    r.POST("/v1/chat", func(c *gin.Context) {
        var req ChatRequest
        if err := c.ShouldBindJSON(&req); err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"code": "invalid_argument", "message": err.Error()})
            return
        }
        ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
        defer cancel()
        _ = ctx // 这里把 ctx 传给 Runner.Run
        c.JSON(http.StatusOK, gin.H{"accepted": true, "message": req.Message})
    })
    _ = r.Run(":8080")
}
```

注意：不要在 Gin handler 里直接拼装提示词、调用模型并无限等待；HTTP 边界和 Agent 编排要分层。

---

### Chi

贴近 net/http 的轻量路由。

#### 它解决什么问题

Chi 是贴近 net/http 的轻量路由器，路由器本身实现 http.Handler，所以标准库 Handler、中间件、测试和 Server 配置都能直接复用。

它适合希望显式控制中间件链、子路由和请求 context 的 Agent API；不提供 Gin 那样的绑定和 JSON 辅助，需要自己选编码方式。

#### 安装与路由树

执行 go get github.com/go-chi/chi/v5。用 Route 划分版本边界，用 Group 添加局部中间件，用 Mount 挂载独立模块。

把 /healthz、/readyz、/v1/chat 和 /v1/admin 分开，避免认证中间件误套到健康检查或内部回调。

#### 中间件和 context

RequestID、RealIP、Recoverer、超时和鉴权中间件按顺序注册。认证结果应通过私有 context key 写入请求上下文，handler 只读取已验证的主体。

context.Value 只放请求范围的数据，不要把数据库连接、可变配置或服务定位器塞进去。

#### 请求体与响应

使用 json.Decoder 解析请求时设置 DisallowUnknownFields 和请求体大小上限；写响应后检查编码错误和状态码。SSE 需要显式 Flush，并处理 r.Context().Done。

Chi 不会替你生成错误响应，建议在最外层统一错误编码，保证普通 JSON、SSE 和健康检查的错误格式可区分。

#### 接入 Agent Runner

handler 负责读取 body、鉴权和创建 deadline；应用服务负责调用 Runner；事件适配器负责把 Response、工具调用和错误转换成前端协议。

这样可以用同一套 Agent 服务同时挂到 HTTP、gRPC 或异步任务，而不把 Chi API 渗透到业务代码。

#### 测试与选型边界

用 httptest.NewRequest 和 httptest.NewRecorder 测路由是否命中、middleware 是否阻断、context 是否取消。

需要大量自动绑定、分组和快速上手时可选 Gin；需要标准库组合、低魔法和可控中间件时 Chi 更合适。

#### 参考片段

```go
r := chi.NewRouter()
r.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer)
r.Route("/v1", func(r chi.Router) {
    r.Use(AuthMiddleware)
    r.Post("/chat", chatHandler)
})
r.Get("/healthz", healthHandler)
server := &http.Server{Addr: ":8080", Handler: r}
log.Fatal(server.ListenAndServe())
```

注意：不要把 Chi 的 context 当作全局依赖容器，也不要把超时只放在模型客户端而不放在请求链路。

---

### GORM

ORM、关联、事务和迁移。

#### 它解决什么问题

GORM 用结构体、链式查询和事务 API 减少 CRUD 样板，适合用户、会话、消息和工具审计等常规关系数据。

复杂报表、锁、窗口函数和性能敏感路径仍要查看实际 SQL；ORM 不是数据库设计、权限系统或迁移系统的替代品。

#### 安装、连接和模型

执行 go get gorm.io/gorm 和对应 driver。使用 gorm.Open 创建一次 DB，注入应用服务并复用连接池；不要每个请求重新连接。

模型字段通过 gorm 标签映射列、索引和约束，但生产迁移应由版本化迁移工具管理，而不是启动时盲目 AutoMigrate。

#### 查询、预加载和分页

Where、Select、Order、Limit 和 Offset 组合查询；外部输入只能作为参数值，不能直接拼接列名或排序 SQL。

Preload 会增加查询数量和返回体积，Agent 历史查询要明确字段、页大小和最大深度，避免一次加载整个会话。

#### 事务、锁和错误

db.Transaction 在回调返回 error 时回滚；事务内只做短而确定的数据库操作，不要等待模型、MCP 或用户确认。

区分 gorm.ErrRecordNotFound、唯一约束冲突、context canceled 和连接错误，分别映射成 404、409、499/超时或 503。

#### 把数据库封装成 Agent 工具

工具从认证上下文取得 tenantID 和 userID，模型只能提交业务参数；查询条件、列白名单、行数上限和权限过滤由 Go 代码追加。

工具返回结构化结果和可解释错误，不要把 *gorm.DB、SQL 片段或内部栈直接暴露给模型。

#### 测试与观测

用临时 PostgreSQL 或测试容器验证事务、约束和并发；单元测试可对 repository 接口做少量替换。

打开慢查询日志并在 trace 中记录 operation、表类别和行数，不记录完整提示词、授权头或敏感业务字段。

#### 参考片段

```go
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
    var order Order
    if err := tx.Where("id = ? AND tenant_id = ? AND user_id = ?", id, tenantID, userID).
        First(&order).Error; err != nil {
        return err
    }
    if order.Status != "pending" {
        return fmt.Errorf("order is not pending")
    }
    return tx.Model(&order).Update("status", "paid").Error
})
if err != nil {
    return classifyDatabaseError(err)
}
```

注意：不要把 AutoMigrate 当完整生产迁移，也不要把模型生成的过滤条件当作权限边界。

---

### pgx

PostgreSQL 原生驱动和连接池。

#### 它解决什么问题

pgx 是 PostgreSQL 原生 Go 驱动，适合需要精确控制 SQL、类型、事务、批处理和连接池的服务。相比 ORM，它要求你更直接地管理扫描、关闭和错误。

在 Agent 工具里，pgx 很适合做受限检索、会话历史和审计写入，但必须把租户、权限和行数边界写进查询。

#### 连接池配置

执行 go get github.com/jackc/pgx/v5/pgxpool。启动时创建一个池，设置 MaxConns、MinConns、MaxConnLifetime 和健康检查；进程退出时 Close。

连接字符串来自密钥配置，不能在日志里打印。数据库不可用时启动探针应报告未就绪，避免流量继续进入。

#### 参数化查询与扫描

使用 $1、$2 占位符，绝不拼接模型输入。QueryRow 用于单行，Query 用于多行；rows.Close、rows.Err 和 Scan 错误都要处理。

对查询结果定义明确结构体，避免把任意列映射成 map[string]any 再交给模型。

#### 事务与批量

BeginTx 后用 defer Rollback 兜底，所有写入成功后 Commit；批量写入可用 Batch，但要限制批次大小和单次事务时长。

事务内部不要调用远程模型或工具，否则锁持有时间会被不可控的网络延迟拉长。

#### 超时、取消和 Agent 检索

每个查询都传入 Runner 派生的 context；用户断开、deadline 到期或 Agent 停止后，数据库驱动会收到取消。

向量检索或全文检索应先做权限过滤再排序，Top-K、最大 token 和返回字段都要有上限。

#### 测试与迁移

repository 测试应覆盖空结果、唯一冲突、取消、事务回滚和连接池耗尽；SQL 迁移在 CI 中从空库完整执行。

生产升级前检查索引、锁和回滚方案，不要把 go test 通过等同于数据库变更安全。

#### 参考片段

```go
pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
if err != nil { return err }
defer pool.Close()
rows, err := pool.Query(ctx,
    `SELECT id, status FROM orders
     WHERE tenant_id = $1 AND user_id = $2
     ORDER BY created_at DESC LIMIT $3`,
    tenantID, userID, 20)
if err != nil { return err }
defer rows.Close()
for rows.Next() {
    var id, status string
    if err := rows.Scan(&id, &status); err != nil { return err }
    results = append(results, OrderView{ID: id, Status: status})
}
return rows.Err()
```

注意：不要为每个请求创建连接池，也不要把任意 SQL、表名或租户条件交给模型。

---

### Zap

结构化高性能日志。

#### 它解决什么问题

Zap 提供结构化、低分配的日志记录，适合高并发 HTTP、模型调用和工具执行路径。它的重点是字段稳定、级别清晰和可检索，不是把所有文本都打印出来。

日志字段应围绕 request_id、trace_id、user_id_hash、agent、model、tool、duration_ms、status 和 error_type 设计。

#### 安装与初始化

执行 go get go.uber.org/zap。生产环境使用 zap.NewProduction 或自定义 Encoder、采样和输出；开发环境可用 zap.NewDevelopment。

把 logger 作为依赖注入，启动时统一配置 Sync 和 shutdown；不要在每个请求中反复创建 logger。

#### 字段和错误

优先使用 zap.String、zap.Int、zap.Duration、zap.Error 等类型化字段。错误要有 error_type 和可读 message，堆栈只在真正需要时记录。

同一事件使用稳定字段名，避免把 JSON 字符串嵌进 message 造成双重解析。

#### Agent 调用日志

模型调用记录 provider、model、stream、attempt、input_tokens、output_tokens、latency 和最终状态；工具调用记录名称、参数摘要、权限决策和耗时。

完整 prompt、用户正文、API key、Authorization、工具返回中的隐私数据默认不记录，必要时做哈希、截断和字段级脱敏。

#### 采样、同步和关联

高频成功日志可以采样，错误和安全审计不能被采样掉。关键审计日志要考虑同步写入、丢失策略和存储保留期。

从 HTTP context 或 OpenTelemetry span context 注入 request_id 和 trace_id，避免依赖全局可变字段。

#### 测试与故障排查

用 observer core 或测试 writer 检查字段和级别，不要只断言整行字符串。验证取消、超时、重试和敏感字段脱敏。

日志只说明发生了什么，跨服务耗时和因果关系应由 trace 补充。

#### 参考片段

```go
logger := zap.Must(zap.NewProduction())
defer logger.Sync()
start := time.Now()
logger.Info("tool finished",
    zap.String("request_id", requestID),
    zap.String("agent", agentName),
    zap.String("tool", toolName),
    zap.Duration("duration", time.Since(start)),
    zap.String("status", "ok"),
)
```

注意：不要用字符串拼接记录敏感输入，也不要把日志库当作事件总线或持久化审计数据库。

---

### Wire

编译期依赖注入。

#### 它解决什么问题

Wire 通过编译期生成依赖组装代码，适合组件较多的 Go 服务：配置、数据库、模型、检索器、工具集合、Agent 和 Runner。

它只解决“如何创建并连接对象”，不负责业务流程、权限、重试、超时或生命周期策略。

#### 安装与 provider

安装 wire 命令并执行 go install github.com/google/wire/cmd/wire@latest；项目代码引入 github.com/google/wire。

每个组件提供小而明确的构造函数，例如 NewStore、NewModel、NewToolSet、NewAgent 和 NewRunner；构造失败要返回 error。

#### ProviderSet 与接口绑定

wire.NewSet 把同一层的 provider 组合起来；wire.Bind 把具体实现绑定到接口。接口应在消费者包定义，避免 provider 包反向依赖业务接口。

生成文件应由团队决定是否提交；无论哪种策略，CI 都要运行 wire 和 go test，确保生成结果可复现。

#### 配置与生命周期

配置解析、连接池、TracerProvider 和 logger 的创建应集中在入口；关闭函数要和资源一起返回，按逆序释放。

不要让 provider 在初始化时偷偷发模型请求、执行迁移或启动 goroutine，这会让启动失败和测试变得不可预测。

#### 测试替换

生产 injector 绑定真实数据库和模型；测试 injector 绑定内存 store、固定模型和 fake clock。这样可以测试 Runner 的状态和错误，而不用联网。

如果依赖很少，手写构造函数通常更清晰；Wire 的收益来自依赖图复杂且需要多套组装方式。

#### 与 Agent 的边界

建议依赖方向为 Config → Infra → Model/Store → Tools → Agent → Runner → Transport。Wire 只在最外层拼装，避免把业务流程藏在生成代码中。

#### 参考片段

```go
//go:build wireinject

func InitializeApp(cfg Config) (*App, func(), error) {
    wire.Build(
        NewDB,
        NewTracer,
        NewModel,
        NewStore,
        NewToolSet,
        NewAgent,
        NewRunner,
        NewApp,
    )
    return nil, nil, nil
}
```

注意：不要为了两个依赖引入复杂生成流程，也不要把密钥读取和副作用操作藏进 provider。

---

### OpenTelemetry

Tracing、Metrics、Logs 和上下文传播。

#### 它解决什么问题

OpenTelemetry 统一 Trace、Metrics 和 Logs 的 API 与上下文传播。对 Agent 服务来说，它能把 HTTP、Runner、模型、工具、数据库和 MCP 调用串成一条链。

SDK/exporter 是应用部署选择；业务库只依赖 API，避免因为某个 exporter 把供应商锁死。

#### 初始化 Provider

安装 go.opentelemetry.io/otel、otel/sdk、对应 exporter 和 instrumentation。启动时创建 Resource、TracerProvider、MeterProvider，设置全局 provider，并在退出时 Shutdown。

没有初始化 SDK 时 API 可能是 no-op；这不是错误，但会让你误以为服务已经有可观测数据。

#### 手动 span 与 context

从请求 context 启动 span，把返回的 ctx 继续传给 Runner、HTTP 客户端、pgx/GORM 和工具。span 结束前记录状态、错误和有限的业务属性。

span 名称使用稳定的操作名，如 agent.run、llm.generate、tool.search、db.query；不要把用户输入拼进 span 名称造成高基数。

#### Agent 事件和指标

模型 span 可记录 provider、model、stream、attempt、input_tokens、output_tokens 和 latency；工具 span 记录 tool.name、authorization.result、rows 或 result_size。

指标建议包括请求数、首 token 延迟、总延迟、工具错误率、重试数、上下文长度和成本估算。完整 prompt 和用户隐私不要作为属性。

#### 采样、传播和隐私

跨 HTTP、gRPC、MCP 时启用 W3C Trace Context 传播；生产环境按租户、错误和延迟设计采样策略，错误请求不能全部被采掉。

Trace 是诊断数据，仍要配置访问控制、保留期和脱敏；不要把它当成无限期保存的业务日志。

#### 验证方式

先使用 stdout exporter 或测试 exporter 验证 span 名称、父子关系和错误状态，再接入 OTLP Collector。

用一次真实请求确认 trace_id 能从 HTTP 入口贯穿到模型、工具和数据库，而不是只看到四个互不相关的 span。

#### 参考片段

```go
ctx, span := tracer.Start(ctx, "agent.run",
    trace.WithAttributes(
        attribute.String("agent.name", agentName),
        attribute.String("session.id", sessionID),
    ))
defer span.End()

result, err := runner.Run(ctx, userID, sessionID, message)
if err != nil {
    span.RecordError(err)
    span.SetStatus(codes.Error, "runner failed")
    return err
}
span.SetStatus(codes.Ok, "completed")
return result
```

注意：不要只调用 otel.Tracer 就宣称已经有观测；先初始化 provider、exporter 和 shutdown，并检查敏感属性。

---

### OpenAI-compatible SDK

模型、消息、工具调用和流式响应。

#### 它解决什么问题

OpenAI-compatible 客户端把消息、流式 chunk、工具调用和生成参数映射到模型供应商协议。它是协议适配层，不是 Agent 编排层。

兼容接口不代表模型的工具调用、推理字段、上下文长度、限流和错误码完全一致，必须按 provider 做能力表。

#### 配置与客户端

模型名、API key、Base URL、超时和重试策略从环境或密钥管理系统注入，启动时校验必填项。API key 不能进入前端、日志或 Git。

客户端应复用连接和 transport；为不同供应商封装统一接口，避免业务代码到处判断 provider。

#### 普通响应和流式响应

普通请求读取完整 message 并检查 finish reason；流式请求逐个读取 chunk/delta，遇到 EOF、取消和 provider error 都要处理。

前端只需要文本时也不要丢掉 tool call、usage 和最终状态；事件适配层应该保留这些信息。

#### 工具调用解析

模型可能分多个 chunk 返回 function name 和 arguments。先缓冲完整 JSON，再解析到严格结构体，随后执行权限、范围、幂等和租户校验。

解析失败、未知工具和参数越界是模型输出错误，不应直接重试成无限循环。

#### 错误、重试和限流

区分认证/配置错误、4xx 参数错误、429 限流、5xx 供应商故障、超时和 context canceled。只有幂等且可恢复的调用才做有限指数退避。

重试要绑定总 deadline，并记录 attempt；如果已经执行了副作用工具，不要因为网络读响应失败就盲目重放。

#### 接入 tRPC-Agent-Go

通过 tRPC-Agent-Go 的 Model 接口接入模型，把 GenerationConfig、Tool、Session 和 Runner 留在 Agent 层。SDK 只负责请求和响应协议。

先用固定模型响应测试事件适配，再做少量真实模型集成测试；真实模型结果不能作为唯一回归依据。

#### 参考片段

```go
client := openai.NewClient(
    os.Getenv("OPENAI_API_KEY"),
    openai.WithBaseURL(os.Getenv("OPENAI_BASE_URL")),
    openai.WithTimeout(45*time.Second),
)
resp, err := client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
    Model: openai.F("MODEL_NAME"),
    Messages: openai.F([]openai.ChatCompletionMessageParamUnion{
        openai.UserMessage("请总结这段文本"),
    }),
})
if err != nil {
    return classifyProviderError(err)
}
return resp.Choices[0].Message.Content, nil
```

注意：不要把“兼容 OpenAI”当作行为完全相同；工具参数、流式事件和错误语义必须在目标 provider 上验证。

---

### Testify

断言、mock 和测试辅助。

#### 它解决什么问题

Testify 提供 assert、require、mock 和 suite，减少测试样板。它不能替代 Go testing，也不能替你决定应该测行为还是实现细节。

Agent 项目中最值得测试的是工具边界、事件顺序、权限拒绝、取消和错误恢复，而不是某个内部函数被调用了几次。

#### assert 与 require

require 适合前置条件失败后无法继续的场景，例如创建测试服务失败；assert 适合在一次测试中收集多个字段差异。

断言信息包含输入、期望和实际值，尤其是流式事件和结构化工具结果，否则失败时仍要重新调试。

#### mock 的边界

优先 mock 模型供应商、时间、随机数、数据库和远程 MCP 等不稳定外部边界；工具参数校验、状态机和错误分类应尽量使用真实确定性实现。

过度 mock 会让测试只证明“mock 按预设返回”，无法发现协议字段、context 取消和并发问题。

#### HTTP、SSE 和 Runner 测试

用 httptest 验证状态码、响应头、SSE 帧顺序和客户端断开；用固定模型事件验证 Runner 是否执行正确工具并发送最终状态。

测试成功、参数错误、权限不足、模型错误、工具超时、重复调用和未知事件，不能只覆盖 happy path。

#### 表驱动和并发

将输入、预期错误、事件序列和资源清理写成表驱动用例。共享 fake 状态要加锁，并用 go test -race 检查。

测试中不要用 time.Sleep 等待异步事件，使用 channel、context 或明确的同步点。

#### 集成与评测

单元测试保证确定性；少量集成测试验证真实 SDK；离线评测固定模型、提示词、知识库和工具版本，比较成功率、引用和成本。

真实模型偶尔返回不同措辞是正常的，应断言结构、工具选择和安全边界，而不是全文字符串完全相等。

#### 参考片段

```go
func TestToolRejectsOtherTenant(t *testing.T) {
    ctx := context.Background()
    tool := NewOrderTool(fakeStore{OrderTenant: "tenant-a"})

    _, err := tool.Run(ctx, ToolInput{TenantID: "tenant-b", OrderID: "o-1"})

    require.Error(t, err)
    assert.ErrorIs(t, err, ErrForbidden)
}

func TestRunnerEmitsDoneAfterToolResult(t *testing.T) {
    events := runWithFixedModel(t, toolCall("search", `{"q":"go"}`), toolResult("ok"), text("完成"))
    assert.Equal(t, []string{"tool.start", "tool.result", "answer.delta", "run.done"}, eventKinds(events))
}
```

注意：不要用大量 mock 断言内部调用次数来替代用户可观察结果，也不要在异步测试中靠固定 sleep。

---

### 工程依赖

go mod、版本锁定、CI 和发布。

#### 依赖治理目标

go.mod 和 go.sum 记录直接与间接依赖，但可复现构建还需要固定 Go 版本、构建参数、生成文件和供应商模型配置。

依赖治理不是一次 go mod tidy；要知道每个库为何存在、谁负责升级、是否有安全公告和兼容范围。

#### 日常命令与审查

提交前运行 go mod tidy、go list -m -u、go mod verify、gofmt、go test、go vet 和必要的 race/build。升级前阅读 release notes、API diff 和迁移说明。

不要为了“让 CI 绿”删除 go.sum、关闭 vet 或跳过 race；应把失败原因记录到变更说明。

#### 版本与生成代码

Go 版本、tRPC-Agent-Go 版本、OpenTelemetry exporter、MCP server schema、提示词、知识库快照和工具 schema 都要可追溯。

Wire、代码生成、前端 bundle 和数据库迁移的生成命令写进 CI，避免本地生成结果漂移。

#### 配置、密钥和供应链

环境变量只负责注入部署差异，启动阶段完成格式校验；密钥放在密钥管理系统，日志和错误中不得回显。

第三方 Agent 工具要记录来源、权限、网络目的地、输入输出上限和升级负责人；MCP 能力发现不等于可信。

#### 发布与回滚

发布清单至少包括二进制、配置 schema、迁移、模型/提示词版本、工具版本、观测告警和回滚步骤。

对模型或工具升级先在固定评测集和灰度流量验证，保留旧 provider 或旧工具 schema 的兼容窗口。

#### 持续检查

CI 需要同时跑静态检查、单测、race、构建、依赖漏洞扫描和最小启动检查。

生产事故后更新 runbook 和回归用例，而不是只在日志中记一句“下次注意”。

#### 参考片段

```go
go mod tidy
go mod verify
gofmt -w .
go test ./...
go test -race ./...
go vet ./...
go build ./...
go list -m -json all > build-modules.json
```

注意：能下载、能编译不等于能发布；模型、工具、迁移和依赖都要有可复现版本与回滚方案。

---

## Embedding：把语义放进可比较的空间

拖动一个点，让抽象的相似度变成可见的排序。

### 为什么文字可以被比较

embedding 模型把文本映射为一串数字。在合适的训练目标下，语义相关的文本可能在空间里更接近。检索时将问题和文档用匹配的 embedding 系统表示，再比较它们。

真实维度常远多于二维，坐标通常没有人能直接命名的轴。本实验使用人为构造的二维教学向量；它不是实际模型输出，也不声称二维可视化能保留高维的所有关系。

### 余弦看方向，距离看位置

余弦相似度是两向量点积除以模长乘积：cos(q,d) = q·d / (||q|| ||d||)。方向越一致，值越接近 1；接近直角时约为 0。零向量没有定义良好的余弦相似度。

欧氏距离衡量直线距离，越小越近；余弦主要看方向，与单纯把向量放大多少无关。检索库选择何种度量应与 embedding 模型和索引配置一致。


```go
// 两个非零向量的余弦相似度
dot := q[0]*d[0] + q[1]*d[1]
qNorm := math.Hypot(q[0], q[1])
dNorm := math.Hypot(d[0], d[1])
score := dot / (qNorm * dNorm)
```

### 相似不代表正确

高相似度只说明在该表示与度量下相近，不代表文档真实、最新或用户有权读取。实际检索可加入关键词匹配、元数据过滤、重排与最低相关性判断。

拖动查询点或使用键盘方向键，观察右侧排序；切换两种度量并比较。图中点名只帮助建立直觉，轴不代表“真实语义维度”。

注意：不要把余弦 0.8 解读成“80% 的概率正确”。相似度不是经过校准的事实置信度。

