第一章:数据库连接未释放——Golang内存泄露的隐性杀手
在 Go 应用中,数据库连接(如 *sql.DB)本身是连接池管理器,并不直接对应单个底层 TCP 连接。但若频繁创建未关闭的 *sql.Rows、未释放的 *sql.Tx,或长期持有 *sql.Stmt 而未调用 Close(),将导致连接池中的活跃连接无法复用,进而触发连接泄漏——最终表现为内存持续增长、runtime.MemStats.Alloc 持续上升、net.Conn 对象堆积,甚至触发 too many open files 错误。
常见泄漏场景
- 查询后未显式关闭
rows:rows, err := db.Query(...)后忘记defer rows.Close() - 手动事务未回滚或提交:
tx, _ := db.Begin()后未执行tx.Commit()或tx.Rollback() - 预处理语句未释放:
stmt, _ := db.Prepare(...)后未调用stmt.Close()
修复示例:安全查询模式
func getUserByID(db *sql.DB, id int) (string, error) {
// ✅ 使用 defer 确保 rows.Close() 在函数返回前执行
rows, err := db.Query("SELECT name FROM users WHERE id = ?", id)
if err != nil {
return "", err
}
defer rows.Close() // 关键:必须放在 err 检查之后、业务逻辑之前
if rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return "", err
}
return name, nil
}
return "", sql.ErrNoRows
}
连接池健康检查建议
| 检查项 | 推荐阈值 | 监控方式 |
|---|---|---|
db.Stats().OpenConnections |
≤ db.SetMaxOpenConns(n) |
定期调用 db.Stats() 打印日志 |
db.Stats().InUse |
短时峰值 ≤ 80% MaxOpen | Prometheus + sql_exporter |
goroutine 中残留 net.Conn |
0(应随请求结束自动回收) | pprof/goroutine + net/http/pprof |
启用连接泄漏检测(仅开发/测试环境):
db.SetConnMaxLifetime(30 * time.Second) // 缩短连接生命周期,加速暴露问题
db.SetMaxOpenConns(5) // 限制最大连接数,使泄漏更快触达上限
log.Println("DB stats:", db.Stats()) // 在关键路径打印连接状态
第二章:goroutine堆积——并发失控的根源剖析与实战治理
2.1 数据库查询中defer db.Close()的常见误用与正确模式
常见误用场景
defer db.Close() 若在连接池初始化后立即调用,会导致整个应用生命周期内数据库连接被提前释放:
func badExample() {
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close() // ⚠️ 错误:函数退出即关闭,后续查询失败
rows, _ := db.Query("SELECT id FROM users")
// ... 处理 rows
}
逻辑分析:sql.Open 仅初始化连接池,并不真正建连;defer db.Close() 在函数返回时关闭整个池,使后续所有 Query/Exec 调用返回 sql: database is closed。db 是连接池句柄,应长期持有。
正确资源管理模式
✅ 应在应用退出时(如 main 函数结尾或 os.Interrupt 信号处理中)关闭:
| 场景 | 推荐时机 | 原因 |
|---|---|---|
| Web 服务 | http.Server.Shutdown 后 |
确保无活跃请求再释放 |
| CLI 工具 | main() 函数末尾 |
生命周期与进程一致 |
| 测试环境 | TestMain 或 defer 在 Test 外层 |
避免测试间连接干扰 |
关键原则
db.Close()是一次性终结操作,不可重入;- 单个
*sql.DB实例应全局复用,而非每次查询新建; - 查询级资源(如
rows)需各自defer rows.Close(),与db.Close()无关。
2.2 基于sql.DB连接池原理的goroutine泄漏路径建模分析
sql.DB 并非单个连接,而是带连接池与生命周期管理的抽象。当 QueryContext 或 ExecContext 被调用但未显式关闭结果集或忽略上下文取消,底层驱动可能启动常驻 goroutine 监听网络响应。
典型泄漏触发点
- 忘记调用
rows.Close() - 使用
db.Query()后未遍历完rows即返回 - 上下文超时后,驱动未及时中止读取协程(如
pq驱动在 SSL 握手异常时残留监听 goroutine)
// ❌ 危险:未关闭 rows 且未完全扫描
rows, _ := db.Query("SELECT id FROM users WHERE active = $1", true)
// 若此处 return 或 panic,rows 未 Close → 连接不归还,goroutine 持续等待
该调用触发
(*Stmt).Query→(*Conn).prepare→ 底层驱动启动readLoopgoroutine;若rows.Close()缺失,conn.finalClose不被触发,readLoop永不退出。
泄漏路径建模(简化状态机)
| 状态 | 触发动作 | 风险行为 |
|---|---|---|
| Idle | db.Query() |
返回 *Rows 未 Close |
| Active | rows.Next() |
context.DeadlineExceeded 后未 cancel conn |
| Zombie | rows.Close() 缺失 |
readLoop goroutine 持续阻塞在 net.Conn.Read |
graph TD
A[db.Query] --> B[alloc Conn + start readLoop]
B --> C{rows.Close called?}
C -->|Yes| D[readLoop exit, Conn recycled]
C -->|No| E[Zombie goroutine + Conn leak]
2.3 使用pprof+trace定位goroutine堆积的完整诊断链路
启动带追踪能力的服务
需在程序中启用 net/http/pprof 并开启 trace 收集:
import _ "net/http/pprof"
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// 主业务逻辑...
}
该代码注册了 /debug/pprof/ 和 /debug/trace 端点;6060 端口用于采集运行时指标,其中 trace 接口生成 goroutine 执行时序快照(采样率默认 100ms)。
快速定位堆积点
执行以下命令抓取 5 秒 trace 数据:
curl -s "http://localhost:6060/debug/trace?seconds=5" > trace.out
go tool trace trace.out
参数说明:
seconds=5控制采样时长;go tool trace解析二进制 trace 文件并启动 Web UI,支持火焰图、Goroutine 分析视图及调度延迟检测。
关键诊断路径
| 视图 | 作用 |
|---|---|
| Goroutines | 查看活跃/阻塞 goroutine 数量趋势 |
| Scheduler | 识别 P 阻塞、G 饥饿或系统调用卡顿 |
| Network I/O | 定位阻塞在 read/write 的协程 |
调试流程概览
graph TD
A[启动服务+pprof] --> B[请求 /debug/trace?seconds=5]
B --> C[生成 trace.out]
C --> D[go tool trace 打开分析界面]
D --> E[筛选长时间 Running/Blocked 状态]
E --> F[关联源码定位 channel/send 或 mutex.Lock]
2.4 在HTTP handler中嵌套数据库调用引发的goroutine雪崩复现实验
复现场景构造
启动一个高并发压测(如 ab -n 10000 -c 500 http://localhost:8080/query),handler 内直接调用阻塞式 DB 查询:
func badHandler(w http.ResponseWriter, r *http.Request) {
rows, _ := db.Query("SELECT sleep(0.1), id FROM users LIMIT 1") // 模拟100ms慢查询
defer rows.Close()
// ... 处理逻辑
}
该代码未限制并发,每个请求独占 goroutine;当 QPS > 10 时,goroutine 数线性突破 500+,触发调度器过载。
雪崩关键指标对比
| 指标 | 正常模式 | 雪崩模式 |
|---|---|---|
| 平均响应时间 | 12ms | 320ms |
| Goroutine 数峰值 | 42 | 2187 |
| HTTP 超时率 | 0% | 63% |
根本路径分析
graph TD
A[HTTP Request] --> B[新建goroutine]
B --> C[阻塞DB Query]
C --> D[等待MySQL网络/锁]
D --> E[goroutine持续驻留]
E --> F[runtime调度器积压]
核心问题:无上下文超时、无连接池限流、无熔断机制。
2.5 通过context.WithCancel配合sync.WaitGroup实现goroutine安全退出
核心协作机制
context.WithCancel 提供取消信号,sync.WaitGroup 确保 goroutine 完全退出后再释放资源,二者互补:前者是“通知”,后者是“等待”。
典型协作模式
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
// 启动工作 goroutine
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done(): // 收到取消信号
return
default:
// 执行任务(如HTTP轮询)
time.Sleep(100 * time.Millisecond)
}
}
}()
// 主动触发退出
cancel()
wg.Wait() // 阻塞直至 goroutine 安全结束
逻辑分析:ctx.Done() 返回只读 channel,goroutine 通过 select 非阻塞监听;cancel() 关闭该 channel,触发 return;wg.Done() 在 defer 中确保计数器准确递减。
关键参数说明
| 参数/变量 | 作用 | 注意事项 |
|---|---|---|
ctx |
传递取消信号与超时控制 | 不可跨 goroutine 复用 cancel 函数 |
wg |
跟踪活跃 goroutine 数量 | Add() 必须在 go 前调用 |
数据同步机制
graph TD
A[main goroutine] -->|cancel()| B[ctx.Done channel closed]
B --> C[worker goroutine select 分支命中]
C --> D[执行 defer wg.Done()]
D --> E[wg.Wait() 返回]
第三章:context超时失效——分布式调用中上下文失效的连锁反应
3.1 context.WithTimeout在database/sql.QueryContext中的穿透失效场景解析
数据同步机制
当 QueryContext 调用链中存在中间层(如连接池复用、驱动封装)未透传 context.Context,WithTimeout 将被静默忽略。
失效典型路径
- 驱动未实现
QueryContext接口,回退至无上下文的Query - 连接复用时缓存了过期/非透传的
ctx - 中间件(如 SQL 日志拦截器)未调用
ctx.WithValue或新建ctx
// ❌ 错误:显式丢弃 ctx,导致 timeout 失效
func badQuery(db *sql.DB, query string) (*sql.Rows, error) {
// 忽略传入 ctx,强制使用 background
return db.Query("SELECT * FROM users") // timeout 不生效!
}
该调用绕过 QueryContext,底层 driver.Stmt.QueryContext 从未被触发,context.Deadline 完全丢失。
| 场景 | 是否透传 | Timeout 生效 | 原因 |
|---|---|---|---|
原生 db.QueryContext(ctx, ...) |
✅ | ✅ | 直达驱动实现 |
| 封装函数未接收 ctx 参数 | ❌ | ❌ | 上下文链断裂 |
自定义 driver.Stmt 未实现 QueryContext |
❌ | ❌ | 回退 Query |
graph TD
A[User: QueryContext with 5s timeout] --> B[database/sql.QueryContext]
B --> C{Driver implements QueryContext?}
C -->|Yes| D[Timeout enforced at driver level]
C -->|No| E[Fallback to Query → timeout ignored]
3.2 driver底层未响应cancel信号导致context超时形同虚设的源码级验证
context.CancelFunc 的预期行为
context.WithTimeout 生成的 CancelFunc 应在超时时触发 done channel 关闭,并通知所有监听者。但驱动层若忽略该信号,则 timeout 失效。
driver.Cancel() 方法缺失实现
以某数据库驱动为例,其 driver.Conn 接口未实现 Cancel() 方法:
// 源码片段:driver.go(简化)
type Conn interface {
Prepare(query string) (Stmt, error)
Close() error
// ❌ 缺少 Cancel(ctx context.Context) error
}
该接口缺失使 sql.DB 在执行 queryContext 时无法向下传递 cancel 信号,ctx.Done() 被阻塞在 driver 层外。
调用链断点分析
| 调用层级 | 是否响应 cancel | 原因 |
|---|---|---|
sql.Rows.NextContext |
✅ 是 | 检查 ctx.Done() 并提前返回 |
driver.Stmt.QueryContext |
⚠️ 依赖实现 | 若 driver 未重写,默认调用无 context 版本 |
net.Conn.Read(底层 socket) |
❌ 否 | 阻塞读不感知 context,需 SetReadDeadline 配合 |
核心问题流程图
graph TD
A[context.WithTimeout] --> B[sql.DB.QueryContext]
B --> C{driver.Stmt.QueryContext?}
C -->|Yes, 实现了| D[调用 driver.Cancel]
C -->|No, fallback| E[调用无 context 的 Query]
E --> F[阻塞在 syscall.Read]
F --> G[timeout 无法中断]
3.3 构建可中断的长事务与批量操作:基于context.Context的重试与熔断实践
数据同步机制
在跨微服务数据一致性场景中,长事务需支持超时控制、手动取消与失败回退。context.Context 是天然的生命周期管理载体。
重试策略实现
func retryWithCtx(ctx context.Context, fn func() error, maxRetries int) error {
var lastErr error
for i := 0; i <= maxRetries; i++ {
select {
case <-ctx.Done():
return ctx.Err() // 上游已取消
default:
lastErr = fn()
if lastErr == nil {
return nil
}
if i < maxRetries {
time.Sleep(time.Second * time.Duration(1<<uint(i))) // 指数退避
}
}
}
return lastErr
}
逻辑分析:该函数将重试嵌入 ctx 生命周期内,每次重试前检查 ctx.Done();maxRetries 控制最大尝试次数;退避时间随轮次指数增长(1s, 2s, 4s…),避免雪崩。
熔断状态表
| 状态 | 触发条件 | 行为 |
|---|---|---|
| Closed | 错误率 | 正常执行 |
| HalfOpen | 熔断超时后首次请求成功 | 允许试探性调用 |
| Open | 连续3次失败或错误率 ≥ 20% | 直接返回熔断错误 |
批量操作中断流程
graph TD
A[Start Batch] --> B{Context Done?}
B -- Yes --> C[Return ctx.Err]
B -- No --> D[Process Item]
D --> E{Success?}
E -- Yes --> F[Next Item]
E -- No --> G[Apply Circuit Breaker Logic]
F --> B
G --> B
第四章:三位一体内存泄露的协同效应与系统化防御体系
4.1 利用go tool pprof + go tool trace构建内存+goroutine+context三维度联合分析流水线
三维度协同诊断价值
单一工具难以定位复合型性能问题:pprof 擅长内存与 CPU 火焰图,trace 揭示 goroutine 调度与阻塞事件,而 context 的生命周期泄漏需结合二者交叉验证。
典型分析流水线
# 启动带 profiling 的服务(启用 trace 和 memprofile)
GODEBUG=gctrace=1 go run -gcflags="-m" main.go &
# 采集数据
curl -s "http://localhost:6060/debug/pprof/heap" > heap.pb.gz
curl -s "http://localhost:6060/debug/pprof/goroutine?debug=2" > goroutines.txt
curl -s "http://localhost:6060/debug/trace?seconds=5" > trace.out
上述命令依次捕获堆内存快照、活跃 goroutine 栈、5 秒调度轨迹。
-gcflags="-m"输出逃逸分析,辅助判断 context.Value 是否引发隐式内存持有。
分析组合策略
| 工具 | 关键指标 | 关联 context 问题线索 |
|---|---|---|
go tool pprof -alloc_space |
高分配对象路径 | context.WithValue 链式传递导致闭包逃逸 |
go tool trace |
Goroutine blocked on chan | ctx.Done() 未被 select 监听,goroutine 泄漏 |
联合归因流程
graph TD
A[trace.out] -->|识别长期阻塞的 goroutine| B(定位其栈帧)
B --> C[pprof heap]
C -->|匹配相同 goroutine ID 对应的 alloc| D[发现 context.Value 存储大对象]
D --> E[确认 context.WithCancel 未被 cancel]
4.2 基于go vet与staticcheck的静态规则增强:自动识别db.Query无defer、context未传递等高危模式
高危模式示例与检测原理
以下代码片段存在资源泄漏与上下文失效风险:
func BadQuery() {
rows, _ := db.Query("SELECT * FROM users") // ❌ 缺少defer rows.Close()
for rows.Next() {
// ...
}
}
逻辑分析:db.Query 返回 *sql.Rows,必须显式调用 Close() 释放底层连接;staticcheck 通过 AST 分析识别未被 defer 或 defer rows.Close() 覆盖的 rows 变量生命周期,触发 SA1019 规则。
context 传递缺失检测
func BadHandler(w http.ResponseWriter, r *http.Request) {
// ❌ context.Background() 替代 r.Context(),丢失超时/取消信号
_, _ = db.QueryContext(context.Background(), "SELECT ...")
}
参数说明:r.Context() 携带请求生命周期控制,替换为 context.Background() 将导致数据库操作无法响应 HTTP 请求中断。
规则覆盖对比(部分)
| 工具 | 检测能力 | 覆盖高危模式 |
|---|---|---|
go vet |
基础语法与常见误用 | ✅ defer 缺失提示 |
staticcheck |
扩展语义分析(含 context 流) | ✅ context 传播链断裂识别 |
graph TD
A[源码解析] --> B[AST 构建]
B --> C[数据流分析]
C --> D{rows.Close 是否可达?}
C --> E{context 是否源自 request?}
D -->|否| F[报告 SA1019]
E -->|否| G[报告 SA1028]
4.3 在Go Module中集成database/sql健康检查中间件:连接泄漏预警与自动回收机制
核心设计目标
- 实时监控
*sql.DB连接池状态 - 检测超时未归还的连接(>30s)并触发告警
- 自动回收泄漏连接,避免
maxOpenConnections耗尽
健康检查中间件实现
func NewDBHealthMiddleware(db *sql.DB, interval time.Duration) *DBHealthChecker {
return &DBHealthChecker{
db: db,
interval: interval,
logger: log.Default(),
}
}
type DBHealthChecker struct {
db *sql.DB
interval time.Duration
logger *log.Logger
}
func (h *DBHealthChecker) Start() {
go func() {
ticker := time.NewTicker(h.interval)
defer ticker.Stop()
for range ticker.C {
h.checkLeakage()
}
}()
}
func (h *DBHealthChecker) checkLeakage() {
stats := h.db.Stats()
if stats.InUse > int64(stats.MaxOpenConnections)*0.9 {
h.logger.Printf("⚠️ High in-use connections: %d/%d", stats.InUse, stats.MaxOpenConnections)
h.forceCloseIdleConnections()
}
}
逻辑分析:
db.Stats()返回实时连接池指标,含InUse(当前活跃连接数)、MaxOpenConnections(最大开放连接数)等关键字段;- 当
InUse超过阈值(90%),触发forceCloseIdleConnections()强制关闭空闲连接(非泄漏连接),缓解压力; ticker控制检测频率,默认设为10s,兼顾实时性与开销。
连接泄漏判定依据
| 指标 | 正常范围 | 风险信号 |
|---|---|---|
WaitCount |
持续增长 → 连接池瓶颈 | |
MaxOpenConnections |
≥ 20 | |
InUse / MaxOpen |
> 0.9 → 泄漏高概率 |
自动回收策略流程
graph TD
A[定时采集Stats] --> B{InUse > 90%?}
B -->|Yes| C[记录告警日志]
B -->|Yes| D[调用db.PingContext]
D --> E[清理空闲连接]
E --> F[重置连接池状态]
集成方式(Go Module)
- 在
main.go初始化后调用:health := NewDBHealthMiddleware(db, 10*time.Second) health.Start() - 通过
go.mod确保依赖版本锁定,避免database/sql接口变更导致兼容问题。
4.4 生产环境内存泄露应急响应SOP:从告警触发到热修复的7步闭环流程
告警识别与初步确认
立即拉取 Prometheus + Grafana 内存趋势图,比对 JVM heap_used 与 non_heap_used 异常陡升曲线,并交叉验证 GC 频次(jvm_gc_collection_seconds_count{gc="G1 Young Generation"})是否激增。
快速堆快照采集
# 在不中断服务前提下生成实时堆转储
jmap -dump:format=b,file=/tmp/heap_$(date +%s).hprof $(pgrep -f "java.*-Dspring.profiles.active=prod")
逻辑说明:
pgrep -f精准匹配生产 JVM 进程;-dump:format=b生成二进制 HPROF 格式;文件名含时间戳避免覆盖。参数-XX:+HeapDumpOnOutOfMemoryError需预置,此处为兜底手动触发。
内存分析三连查
- 使用 Eclipse MAT 打开
.hprof,按 Dominator Tree 排序定位最大保留集 - 检查 Leak Suspects Report 自动生成的可疑链路
- 过滤
java.util.HashMap/org.springframework.context.support.DefaultListableBeanFactory实例数异常增长
热修复决策矩阵
| 场景 | 可热修复 | 工具 | 风险等级 |
|---|---|---|---|
| 静态集合持续 add() | ✅ | JMX removeFromCache() 或 Arthas watch + ognl 清理 |
中 |
| ThreadLocal 未 remove() | ⚠️ | Arthas thread -n 10 定位线程后 ognl 调用 remove() |
高 |
| ClassLoader 泄露 | ❌ | 必须重启 | 极高 |
流程闭环验证
graph TD
A[告警触发] --> B[进程级内存快照]
B --> C[MAT 分析定位根因]
C --> D{是否支持热修复?}
D -->|是| E[Arthas 动态执行清理]
D -->|否| F[灰度重启+回滚预案]
E --> G[监控指标回落+日志验证]
F --> G
G --> H[归档分析报告]
第五章:走出内存迷宫——Golang数据库应用的健壮性终局思考
在高并发订单系统中,某电商服务曾因 sql.DB 连接池配置不当引发雪崩:SetMaxOpenConns(5) 与 SetMaxIdleConns(5) 在峰值 QPS 3000 场景下,导致连接等待超时率飙升至 42%,P99 响应延迟突破 8s。根本原因并非 SQL 效率低下,而是连接资源被阻塞在 driverConn.waitCount 等待队列中,而 goroutine 却持续创建新连接请求,最终耗尽内存并触发 GC 频繁暂停。
连接泄漏的隐形杀手
真实生产日志显示:某支付回调接口未显式调用 rows.Close(),导致 sql.Rows 持有底层连接长达 17 分钟(超过 ConnMaxLifetime),连接池中 idle 连接数持续下降,新请求被迫新建连接。通过 pprof 的 goroutine profile 发现 237 个 goroutine 卡在 database/sql.(*DB).conn 调用栈中。
Context 驱动的查询生命周期管理
以下代码片段演示如何用 context 控制查询边界:
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
row := db.QueryRowContext(ctx, "SELECT balance FROM accounts WHERE id = $1", userID)
err := row.Scan(&balance)
if errors.Is(err, context.DeadlineExceeded) {
metrics.Inc("db.query.timeout")
return fmt.Errorf("balance query timeout for user %d", userID)
}
连接池健康度监控指标
| 指标名 | 计算方式 | 健康阈值 | 采集方式 |
|---|---|---|---|
pool_wait_count |
sql.DB.Stats().WaitCount |
Prometheus exporter | |
idle_closed |
sql.DB.Stats().IdleClosed |
自定义 metrics hook | |
max_open_exceeded |
sql.DB.Stats().MaxOpenConnections |
= 0 | 日志告警 |
内存泄漏的链式排查路径
使用 go tool pprof -alloc_objects 分析发现:github.com/lib/pq.(*conn).recvMessage 占用 68% 的堆对象分配,进一步追踪到 pq.Scanner 对 []byte 的重复拷贝。解决方案是启用 pq.EnableCopyIn 并改用 sql.NullString 替代原始 *string 解析。
事务嵌套的陷阱规避
当 service 层调用 repository 方法时,若未传递外层事务 context,会导致隐式开启新事务:
func (s *Service) Transfer(ctx context.Context, from, to int64, amount float64) error {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead})
if err != nil { return err }
// ❌ 错误:repository.New() 创建独立 db 实例,脱离当前 tx
repo := repository.New(s.db)
// ✅ 正确:repository.NewWithTx(tx)
repo := repository.NewWithTx(tx)
}
数据库驱动的 GC 友好实践
pgx/v5 驱动通过 pgx.ConnConfig.AfterConnect 注入连接初始化逻辑,避免每次查询都重建 time.Location;同时禁用 pgx.ParseTimezone 可减少 37% 的 time 包内存分配。实测将 pgxpool.Config.MaxConns 设为 120 后,GC pause 时间从 12ms 降至 2.3ms。
生产环境熔断策略
基于 gobreaker 实现数据库级熔断:
graph LR
A[Query Start] --> B{Error Rate > 60%?}
B -- Yes --> C[Open Circuit]
B -- No --> D[Execute Query]
C --> E[Sleep 30s]
E --> F{Half-Open State}
F --> G[Allow 10% Traffic]
G --> H{Success Rate > 95%?}
H -- Yes --> I[Close Circuit]
H -- No --> C
某金融系统上线该熔断机制后,数据库故障期间 API 错误率从 100% 降至 12%,且恢复时间缩短至 47 秒。
