第一章:Go错误处理链路断裂:map[string]interface{}{} 掩盖error wrapping导致的17个SLO违规事件
在生产环境中,将 error 值强制转换为 map[string]interface{} 是一种隐蔽却高频的反模式。它直接切断了 Go 1.13+ 引入的 error wrapping 链路(%w、errors.Unwrap、errors.Is、errors.As),使可观测性系统无法追溯根本原因,最终触发 17 起 SLO 违规——全部源于同一类日志结构化逻辑。
错误链路断裂的典型场景
以下代码看似无害,实则摧毁错误上下文:
func handleRequest(req *http.Request) error {
err := doSomething()
if err != nil {
// ❌ 危险:将 error 转为 map 后,原始堆栈、wrapped error 全部丢失
logData := map[string]interface{}{
"path": req.URL.Path,
"error": err, // ← 此处 err 被 stringer 化,unwrap 信息彻底消失
}
log.WithFields(logData).Error("request failed")
return err
}
return nil
}
该写法导致 logData["error"] 实际调用 err.Error(),而非保留 err 本身;下游告警规则依赖 errors.Is(err, io.ErrUnexpectedEOF) 判断时永远返回 false。
可观测性修复方案
必须分离「结构化日志字段」与「错误对象」:
- ✅ 正确方式:使用日志库原生 error 字段(如
log.WithError(err)或slog.With("err", err)) - ✅ 补充诊断:显式记录 wrapped error 类型链
// 使用 slog(Go 1.21+)保留 error wrapping 语义
func logWithError(ctx context.Context, err error) {
var unwrapped []string
for e := err; e != nil; e = errors.Unwrap(e) {
unwrapped = append(unwrapped, fmt.Sprintf("%T: %v", e, e))
}
slog.With(
"error_chain", strings.Join(unwrapped, " → "),
"error", err, // ← 传入 error 类型,非 string
).Error("operation failed")
}
关键检查清单
- 所有
map[string]interface{}构造中禁止直接赋值error类型字段 - CI 流水线中启用
staticcheck规则SA1019(检测已弃用的 error 处理)及自定义检查:grep -r 'map\[string\]interface{}' --include="*.go" . | grep -i "error:" - 每个 HTTP handler 的
defer func()panic 捕获块必须调用errors.Unwrap递归提取 root cause
| 问题现象 | 根因 | 修复动作 |
|---|---|---|
告警无法匹配 io.EOF |
errors.Is() 返回 false |
改用 slog.With("err", err) |
| Jaeger 中 error.tag 为空 | error 对象被 string 化 | 移除 map 中的 "error" key |
第二章:map[string]interface{}{} 在错误传播中的隐式截断机制
2.1 interface{} 类型擦除与 error 接口动态行为的理论冲突
Go 的 interface{} 实现类型擦除:运行时仅保留值与类型元数据,无泛型约束。而 error 接口虽定义为 interface{ Error() string },其实际行为却依赖具体实现的动态方法绑定——这在 interface{} 转换中可能隐式丢失语义契约。
类型擦除的底层表现
var e error = fmt.Errorf("io timeout")
var any interface{} = e // ✅ 保存 *fmt.wrapError + method table
fmt.Printf("%T\n", any) // *fmt.wrapError —— 类型未丢失,但接口契约已“降级”
逻辑分析:any 仍持有原始类型指针与完整方法集,但编译器无法静态验证 any 是否满足 error;需运行时断言还原。
error 接口的动态性挑战
errors.Is()/errors.As()依赖错误链遍历与类型匹配- 若
error被先转为interface{}再传入,链路完整性依赖运行时反射,性能与安全性双降
| 场景 | 类型信息保留 | 动态行为可恢复 |
|---|---|---|
直接传 error |
✅ 完整 | ✅ 是 |
经 interface{} 中转 |
✅(底层) | ❌ 需显式断言 |
graph TD
A[error 值] --> B[赋值给 interface{}]
B --> C{调用 errors.As?}
C -->|无显式类型提示| D[反射遍历 → 开销↑]
C -->|有 type assertion| E[恢复 error 行为]
2.2 map[string]interface{}{} 序列化过程中 error wrapping 链的不可逆丢失实践复现
当 map[string]interface{} 作为通用序列化载体时,原生 error 值(含 fmt.Errorf("...: %w", err) 构建的 wrapping 链)会被强制转为字符串,导致 Unwrap() 调用链断裂。
数据同步机制中的典型误用
err := fmt.Errorf("db timeout: %w", io.ErrUnexpectedEOF)
payload := map[string]interface{}{
"code": 500,
"error": err, // ❌ 此处 err 被 json.Marshal 转为 string "db timeout: unexpected EOF"
}
json.Marshal(payload) 将 err 调用 Error() 方法后仅保留扁平字符串,原始 io.ErrUnexpectedEOF 的类型、栈帧、嵌套 Unwrap() 关系全部丢失。
错误传播路径对比
| 场景 | wrapping 链是否可追溯 | errors.Is(err, io.ErrUnexpectedEOF) |
|---|---|---|
直接传递 err 变量 |
✅ 是 | ✅ true |
经 map[string]interface{} 序列化再反序列化 |
❌ 否 | ❌ false(反序列化后为 string) |
graph TD
A[原始 error] -->|Wrap| B[wrappedErr]
B -->|json.Marshal| C["map[string]interface{}"]
C -->|json.Marshal| D["{error: \"db timeout: unexpected EOF\"}"]
D -->|json.Unmarshal| E[interface{} → string]
E -->|断链| F[无法 Unwrap/Is/As]
2.3 Go 1.13+ error unwrapping 语义与 map 序列化路径的兼容性失效分析
Go 1.13 引入 errors.Unwrap 和 Is/As 接口,要求 error 类型实现 Unwrap() error 方法以支持链式解包。但当 error 被嵌入 map[string]interface{} 后经 JSON/YAML 序列化(如日志采集、RPC 透传),原始结构信息丢失:
type WrappedErr struct {
Msg string
Orig error `json:"-"` // 被忽略 → 解包链断裂
}
// 序列化后仅剩 {"Msg":"timeout"},Orig 消失
逻辑分析:
json.Marshal默认跳过未导出字段及带-tag 的字段,导致Orig不参与序列化;反序列化后WrappedErr.Orig == nil,errors.Is(err, ctx.DeadlineExceeded())返回false。
核心冲突点
error是接口类型,序列化需具体值支撑map[string]interface{}无法保留方法集与指针语义
兼容性失效路径
graph TD
A[原始 error 链] --> B[Wrap → Wrap → ...]
B --> C[注入 map[string]interface{}]
C --> D[JSON Marshal]
D --> E[Orig 字段丢失]
E --> F[Unwrap() 返回 nil]
| 场景 | 是否保留 Unwrap 链 | 原因 |
|---|---|---|
| 直接传递 error 接口 | ✅ | 方法集完整 |
| map[string]any 中 | ❌ | 结构体字段被忽略或转为 nil |
根本矛盾在于:序列化是值投影,而 error unwrapping 依赖运行时方法绑定。
2.4 基于 go tool trace 与 pprof 的错误链断裂时序定位实验
当分布式调用中 error context 丢失导致链路追踪断裂,需结合 go tool trace 的微秒级 Goroutine 调度视图与 pprof 的阻塞/延迟采样进行交叉验证。
数据同步机制
func fetchWithTimeout(ctx context.Context, url string) ([]byte, error) {
// 使用带 cancel 的子 ctx,确保错误可传播
childCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel() // 防止 goroutine 泄漏
return http.DefaultClient.Do(childCtx, url) // 若父 ctx 已 cancel,此处立即返回 canceled 错误
}
该函数确保错误沿 context 向上传播;若 cancel() 被提前调用但未被上层 select{case <-ctx.Done():} 捕获,则 trace 中将显示 Goroutine 在 runtime.gopark 长期阻塞,而 pprof 的 block profile 显示 mutex 等待热点。
定位流程对比
| 工具 | 优势 | 局限 |
|---|---|---|
go tool trace |
可视化 goroutine 生命周期、网络阻塞点、GC STW 干扰 | 无语义标签,需手动关联 span ID |
pprof |
支持 CPU/block/mutex 多维采样,支持火焰图下钻 | 时间精度为毫秒级,无法捕获 sub-ms 断裂 |
graph TD
A[HTTP Handler] --> B[context.WithCancel]
B --> C[fetchWithTimeout]
C --> D{ctx.Done() 触发?}
D -->|是| E[return ctx.Err()]
D -->|否| F[Do request]
F --> G[网络超时或 panic]
G --> H[error 未注入 trace.Event]
H --> I[链路在 Span 末端断裂]
2.5 生产环境日志采样中 error.Is/error.As 失效的 17 起 SLO 违规归因验证
在高吞吐日志采样链路中,error.Is 和 error.As 因底层错误包装不一致,在采样决策点(如 sampler.Decide())频繁返回 false,导致结构性错误(如 *postgres.ErrNoRows、net.OpError)被误判为非错误,绕过告警通道。
根本原因:错误链断裂
Go 1.13+ 的 fmt.Errorf("wrap: %w", err) 仅保留最内层错误类型;但中间件(如 sqlx、pgx/v5)常使用 fmt.Errorf("%v", err) 丢弃 Unwrap() 链。
// ❌ 错误:破坏 error chain
err := fmt.Errorf("db query failed: %v", pgErr) // 无 %w → Unwrap() 返回 nil
// ✅ 正确:保留可追溯性
err := fmt.Errorf("db query failed: %w", pgErr) // 支持 error.Is(err, sql.ErrNoRows)
该写法使 error.Is(err, sql.ErrNoRows) 在采样器中恒为 false,17 起 SLO 违规均源于此。
| 违规服务 | 错误类型误判率 | SLO 影响时长 |
|---|---|---|
| payment-api | 92% | 4.2h |
| inventory-sync | 87% | 2.8h |
graph TD
A[原始 error] --> B[中间件 fmt.Errorf%v]
B --> C[丢失 Unwrap]
C --> D[error.Is/As 失效]
D --> E[SLO 违规]
第三章:Go 错误包装规范与结构化日志的协同治理
3.1 error wrapping 黄金准则:Wrap/Is/As/Unwrap 的语义边界与约束条件
Go 1.13 引入的错误包装机制并非语法糖,而是有严格语义契约的类型系统扩展。
核心契约三原则
Wrap只能添加一层上下文,不可嵌套包装同一错误多次;Is检查链式穿透(递归调用Unwrap()),但仅匹配底层原始错误类型或值;As仅尝试将最内层错误(或其任意包装层)转换为指定类型,不保证是直接包装者。
Unwrap 的隐式约束
type wrappedError struct {
msg string
err error // 必须非 nil 才可 Unwrap,否则返回 nil —— 这是 Is/As 链式终止的关键信号
}
func (e *wrappedError) Unwrap() error { return e.err }
逻辑分析:Unwrap() 返回 nil 表示错误链终结;若返回非 nil,Is/As 将继续递归。参数 e.err 是唯一可展开的子错误,不得为自身或循环引用。
| 方法 | 是否递归 | 是否类型断言 | 终止条件 |
|---|---|---|---|
Is |
✅ | ❌(值相等) | Unwrap() == nil 或匹配成功 |
As |
✅ | ✅(类型赋值) | Unwrap() == nil 或成功转换 |
graph TD
A[err] -->|Unwrap| B[err2]
B -->|Unwrap| C[err3]
C -->|Unwrap| D[ nil ]
D -->|Is/As 终止| E[返回结果]
3.2 结构化日志中 error 字段的 schema-aware 序列化方案(如 zap.Error, slog.With)
传统 fmt.Sprintf("%v", err) 会丢失错误链、堆栈与类型语义。现代日志库通过 schema-aware 序列化保留结构化元数据。
错误字段的语义化编码
// zap.Error 将 error 拆解为字段:msg, type, stack, cause, wrapped
logger.Error("db query failed",
zap.Error(err), // 自动展开 *errors.Error / stdlib error
zap.String("query", sql))
zap.Error 内部调用 err.Error() + fmt.Printf("%+v", err) 提取堆栈,并递归解析 Unwrap() 链,生成嵌套 JSON 对象。
slog 的轻量级等价实现
// slog.With 自动识别 error 类型并序列化其字段
log.With("err", err).Error("timeout occurred")
slog 在 Value.MarshalLog 接口实现中,对 error 类型特化处理,避免字符串扁平化。
| 库 | 是否保留 cause 链 | 是否含 stacktrace | 是否支持自定义 error 类型 |
|---|---|---|---|
| zap | ✅ | ✅(需启用) | ✅(实现 MarshalZap) |
| slog | ✅(Go 1.22+) | ❌(需手动注入) | ✅(实现 LogValue) |
graph TD
A[error interface] --> B{Has Unwrap?}
B -->|Yes| C[Recursively serialize cause]
B -->|No| D[Serialize msg + type + stack]
C --> D
3.3 自定义 error 类型与 json.RawMessage 替代 map[string]interface{}{} 的工程落地
为什么放弃 map[string]interface{}?
- 类型不安全,运行时 panic 风险高
- 无法静态校验字段存在性与类型
- 序列化/反序列化性能损耗显著(需反射遍历)
自定义 error 的实践范式
type ValidationError struct {
Code int `json:"code"`
Message string `json:"message"`
Fields []string `json:"fields,omitempty"`
}
func (e *ValidationError) Error() string { return e.Message }
逻辑分析:
ValidationError实现error接口,同时携带结构化元数据;Code用于 HTTP 状态映射,Fields支持前端精准定位校验失败字段。避免字符串拼接 error,提升可观测性与调试效率。
json.RawMessage 的零拷贝优势
| 场景 | map[string]interface{} |
json.RawMessage |
|---|---|---|
| 内存分配 | 多次反射解析 + 堆分配 | 直接引用原始字节切片 |
| 类型安全 | ❌ | ✅(延迟解码至具体结构) |
graph TD
A[HTTP Request Body] --> B{json.Unmarshal}
B -->|RawMessage| C[延迟绑定业务结构]
B -->|map[string]interface{}| D[即时反射解析]
D --> E[GC 压力↑, CPU 占用↑]
第四章:防御性错误处理链路重建实战
4.1 基于 ast 与 go/analysis 的 map[string]interface{}{} 错误注入静态检测插件开发
该插件定位运行时因 map[string]interface{} 非法嵌套或未初始化导致 panic 的典型场景,如 nil map 直接赋值。
核心检测逻辑
遍历 AST 中所有 *ast.CompositeLit 节点,识别类型为 map[string]interface{} 的字面量,并检查其是否出现在以下上下文中:
- 作为函数参数传递(尤其
json.Unmarshal、yaml.Unmarshal) - 被直接取地址(
&m)但未显式初始化 - 出现在结构体字段赋值中且父结构未初始化
关键代码片段
func (v *visitor) Visit(n ast.Node) ast.Visitor {
if lit, ok := n.(*ast.CompositeLit); ok {
if isMapStringInterface(lit.Type) {
// 检查是否在赋值语句右侧且左侧为 nil map 变量
if isUninitializedMapAssignment(lit) {
v.pass.Reportf(lit.Pos(), "unsafe map[string]interface{} literal: may cause panic on write")
}
}
}
return v
}
isMapStringInterface() 递归解析类型表达式,确认底层类型匹配;isUninitializedMapAssignment() 向上查找最近的 *ast.AssignStmt 并验证左操作数是否为未初始化的 map[string]interface{} 变量。
检测覆盖场景对比
| 场景 | 是否触发告警 | 原因 |
|---|---|---|
var m map[string]interface{} → m["k"] = v |
✅ | 未 make 初始化 |
m := make(map[string]interface{}) → m["k"] = v |
❌ | 安全初始化 |
json.Unmarshal(b, &map[string]interface{}{}) |
✅ | 字面量取址后传入 |
graph TD
A[AST 遍历] --> B{节点是 CompositeLit?}
B -->|是| C[类型匹配 map[string]interface{}?]
C -->|是| D[检查赋值/取址/调用上下文]
D --> E[报告高危模式]
4.2 中间件层 error context 注入与透明透传:从 http.Handler 到 grpc.UnaryServerInterceptor
在分布式系统中,错误上下文需跨协议边界一致携带。HTTP 和 gRPC 的中间件模型虽异,但可统一抽象为 context.Context 增强通道。
HTTP 层注入示例
func ErrorContextMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), "error_id", uuid.New().String())
next.ServeHTTP(w, r.WithContext(ctx))
})
}
r.WithContext() 替换请求上下文,"error_id" 作为诊断键;所有下游 handler 可通过 r.Context().Value("error_id") 安全读取。
gRPC 层对齐实现
| 维度 | HTTP Handler | gRPC UnaryServerInterceptor |
|---|---|---|
| 上下文注入点 | r.WithContext() |
ctx = metadata.AppendToOutgoingContext(...) |
| 错误透传方式 | context.Value(轻量) |
metadata.MD + status.Error(结构化) |
透传一致性保障
func ErrorContextInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
md, _ := metadata.FromIncomingContext(ctx)
// 提取并合并 error_id、trace_id 等关键字段到新 ctx
newCtx := context.WithValue(ctx, "error_id", md.Get("error-id")...)
return handler(newCtx, req)
}
拦截器从 metadata 提取上游 error-id,并注入 context.Value,确保业务 handler 与 HTTP 层语义一致。
graph TD A[HTTP Request] –>|Inject error_id via context| B[Business Handler] C[gRPC Request] –>|Extract & Inject via MD| D[Business Handler] B –> E[Shared Error Context Interface] D –> E
4.3 单元测试中模拟 error wrapping 断裂场景并验证恢复能力的 fuzz-driven 方法
为何传统 mock 失效
当 fmt.Errorf("wrap: %w", err) 被中间层错误处理逻辑意外截断(如 errors.Unwrap() 后未保留 wrapper 链),下游 errors.Is()/errors.As() 判断将失效。Fuzz 测试可系统性触发此类断裂。
Fuzz 驱动的断裂注入策略
- 随机插入
errors.Unwrap()、fmt.Sprintf("%v", err)、json.Marshal(err)等破坏 wrapper 链的操作 - 对比原始 error 与扰动后 error 的
errors.Is(targetErr)结果一致性
示例:fuzz target 定义
func FuzzErrorWrappingRecovery(f *testing.F) {
f.Add(uint8(0)) // seed
f.Fuzz(func(t *testing.T, seed uint8) {
err := io.EOF
wrapped := fmt.Errorf("service failed: %w", err)
// 模拟断裂:随机选择一种破坏方式
switch seed % 3 {
case 0:
wrapped = fmt.Errorf("lost wrap: %v", wrapped) // string coercion → wrapper chain broken
case 1:
wrapped = errors.Unwrap(wrapped) // unwrap without re-wrapping
}
// 验证恢复能力:能否仍识别为 io.EOF?
if !errors.Is(wrapped, io.EOF) {
t.Fatalf("recovery failed: expected io.EOF, got %T %+v", wrapped, wrapped)
}
})
}
该 fuzz target 通过
seed % 3控制三种 error 处理路径,强制暴露errors.Is在 wrapper 断裂后的脆弱点;fmt.Errorf("...%v", err)是典型断裂源——它丢弃%w语义,仅保留字符串表示,导致类型信息与 wrapper 链完全丢失。
关键断裂模式对照表
| 破坏操作 | 是否保留 wrapper 链 | errors.Is(err, io.EOF) 结果 |
|---|---|---|
fmt.Errorf("x: %w", err) |
✅ | true |
fmt.Errorf("x: %v", err) |
❌ | false |
errors.Unwrap(err) |
❌(单层) | 取决于原 err 是否为 io.EOF |
graph TD
A[原始 error] --> B{fuzz mutation}
B -->|“%w”格式化| C[完整 wrapper 链]
B -->|“%v”格式化| D[字符串化断裂]
B -->|Unwrap 未重 wrap| E[链断裂]
C --> F[errors.Is ✅]
D --> G[errors.Is ❌]
E --> H[errors.Is ⚠️]
4.4 SLO 监控看板集成 error chain depth 指标与自动告警阈值配置
error chain depth 衡量异常调用链中嵌套错误传播的层级深度,是识别级联故障的关键SLO健康信号。
数据同步机制
Prometheus 通过自定义 exporter 抓取服务端 otel-collector 输出的 error_chain_depth_bucket 直方图指标:
# prometheus.yml 片段
- job_name: 'error-chain-monitor'
static_configs:
- targets: ['otel-collector:8889'] # /metrics 端点暴露 OpenTelemetry metrics
该配置启用对 OTLP-metrics 协议的 HTTP 拉取;
8889是 collector 的 Prometheus exporter 默认端口;直方图支持计算 P95 深度,用于 SLO 违规判定。
告警阈值动态化
基于历史 P90 值自动校准阈值:
| 环境 | 基线 P90 depth | SLO 阈值(≤) | 触发条件 |
|---|---|---|---|
| prod | 3 | 5 | rate(error_chain_depth_sum[1h]) / rate(error_chain_depth_count[1h]) > 5 |
| staging | 2 | 4 | 同上,窗口缩至 15m |
告警联动逻辑
graph TD
A[Prometheus Alert] --> B[Alertmanager]
B --> C{SLO breach?}
C -->|Yes| D[Trigger PagerDuty + Auto-create error-chain trace link]
C -->|No| E[Log only]
告警规则中
rate(...sum)/rate(...count)精确计算加权平均深度,避免直方图桶偏移导致误判。
第五章:从17起SLO违规到可观测性驱动的错误治理范式升级
一次真实的SLO滑坡事件回溯
2024年Q2,某金融级支付网关服务在连续12天内触发17次SLO违规(P99延迟>300ms持续超5分钟),其中8次导致下游风控系统熔断。通过追溯原始trace ID与指标时间对齐,发现根本原因并非单点故障,而是三个看似独立的变更叠加:① 新增的地址标准化服务引入未限流的外部HTTP调用;② 日志采样率从1%提升至10%后,Fluentd队列堆积引发本地磁盘IO争用;③ Kubernetes Horizontal Pod Autoscaler配置中CPU阈值误设为85%(应为60%),导致扩容滞后。这17起违规分布在6个不同业务时段,传统告警聚合机制未能识别其共性模式。
可观测性数据资产化重构路径
团队将全链路数据按语义分层建模:
- 信号层:OpenTelemetry Collector统一采集trace、metrics、logs,禁用所有采样(生产环境保留100% trace头透传);
- 上下文层:通过Kubernetes label + Git commit SHA + Envoy x-envoy-attempt-count 构建动态关联图谱;
- 决策层:使用Prometheus Recording Rules预计算12类错误特征向量(如“慢查询突增+GC暂停>200ms”组合)。
错误根因自动归类看板
基于上述数据资产,构建实时错误聚类看板,关键字段如下:
| 聚类ID | 触发频次 | 共现服务 | 核心指标偏移 | 自动归因标签 |
|---|---|---|---|---|
| CL-2024-07-08A | 9次 | address-service, payment-gateway | P99 latency ↑320%, CPU idle ↓41% | io-bound-cpu-starvation |
| CL-2024-07-12B | 5次 | fraud-detect, kafka-consumer | Kafka lag ↑12k, GC pause ↑310ms | gc-triggered-consumer-stall |
治理闭环执行引擎
部署轻量级Orbiter引擎(Go编写,
- 调用Argo Rollbacks API回滚最近变更的Deployment;
- 向Slack #sre-alerts频道推送结构化诊断报告(含trace链示意图);
- 在Jira创建高优任务并绑定Git提交哈希与火焰图快照链接。
flowchart LR
A[新SLO违规事件] --> B{是否匹配已知聚类?}
B -->|是| C[触发预设修复剧本]
B -->|否| D[启动异常检测模型]
D --> E[生成新聚类ID]
E --> F[人工标注+注入知识图谱]
C --> G[验证SLO恢复状态]
G -->|失败| H[升级至跨团队战情室]
工程实践验证结果
上线后首月,SLO违规平均响应时间从47分钟缩短至8.3分钟;17起同类问题中,12起由Orbiter自动闭环,剩余5起均在首次告警15分钟内完成根因定位。关键改进在于将错误治理从“人找问题”转变为“问题自证身份”,例如address-service的IO争用问题,在第3次复现时即被标记为io-bound-cpu-starvation,运维人员直接跳过日志排查阶段,直奔iostat -x 1与/proc/PID/io分析。
组织协同机制升级
建立“可观测性契约”制度:每个微服务上线前必须提交三项声明——明确的SLO目标、至少2个可证伪的失败假设、对应trace span的语义命名规范(如payment.process.timeout-reason必须为枚举值)。该契约嵌入CI流水线,未通过静态校验的PR禁止合并。
