Files
huang b68e7ec013
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 15s
优化测试数据库连接管理
- 创建全局单例连接池,性能提升 6-7 倍
- 实现 NewTestTransaction/GetTestRedis/CleanTestRedisKeys
- 移除旧的 SetupTestDB/TeardownTestDB API
- 迁移所有测试文件到新方案(47 个文件)
- 添加测试连接管理规范文档
- 更新 AGENTS.md 和 README.md

性能对比:
- 旧方案:~71 秒(204 测试)
- 新方案:~10.5 秒(首次初始化 + 后续复用)
- 内存占用降低约 80%
- 网络连接数从 204 降至 1
2026-01-22 14:38:43 +08:00

9.7 KiB

Design: 优化测试数据库连接管理

Context

当前测试架构在每个测试用例中独立创建数据库和 Redis 连接,导致严重的性能问题和资源浪费。随着测试用例数量增长(已达 200+),测试套件运行时间从最初的 10 秒增长到 70+ 秒,严重影响开发效率。

现状:

  • 每个测试调用 SetupTestDB(t) 创建新连接
  • 每次连接都执行 AutoMigrate(检查 8 张表结构)
  • 连接配置硬编码在 testutils/setup.go
  • Redis 连接在每个测试中独立创建和关闭

约束:

  • 必须保持远程测试数据库(开发机器资源有限)
  • 必须保持测试间完全隔离(事务回滚)
  • 必须支持向后兼容(渐进迁移)
  • 必须确保资源自动清理(防止泄漏)

Goals / Non-Goals

Goals

  1. 性能提升: 测试套件运行速度提升 ≥ 5 倍
  2. 资源节省: 内存占用降低 ≥ 70%,网络连接数降低到 1
  3. 简洁 API: 测试代码行数减少,意图更清晰
  4. 自动清理: 使用 t.Cleanup() 确保资源自动释放
  5. 向后兼容: 新旧方案共存,支持渐进迁移
  6. 规范化: 建立测试连接管理的标准规范

Non-Goals

  1. 本地数据库支持(开发机器资源有限)
  2. Mock 数据库方案(需要真实数据库验证业务逻辑)
  3. 并发测试优化(GORM 事务非线程安全)
  4. 一次性强制迁移所有测试(渐进式迁移更安全)

Decisions

Decision 1: 全局单例连接池

决策: 使用 sync.Once 实现全局单例数据库和 Redis 连接,整个测试套件共享。

理由:

  • 性能: 只创建一次连接,消除重复开销
  • 简单: Go 标准库支持,无需引入依赖
  • 安全: sync.Once 保证线程安全
  • 兼容: 不影响现有测试

实现:

var (
    testDBOnce    sync.Once
    testDB        *gorm.DB
    testRedisOnce sync.Once
    testRedis     *redis.Client
)

func GetTestDB(t *testing.T) *gorm.DB {
    testDBOnce.Do(func() {
        // 创建连接和 AutoMigrate (只执行一次)
    })
    return testDB
}

替代方案:

  • 每个测试独立连接: 现状,性能差
  • TestMain 初始化: 不灵活,无法在子包中使用
  • 依赖注入框架: 过度设计,增加复杂度

Decision 2: 基于 t.Cleanup() 的自动清理

决策: 使用 Go 1.14+ 的 t.Cleanup() 机制替代 defer,确保资源自动释放。

理由:

  • 可靠: 即使测试 panic 也能执行清理
  • 简洁: 无需手动 defer,API 更直观
  • 灵活: 支持注册多个清理函数
  • 标准: Go 官方推荐的测试清理模式

实现:

func NewTestTransaction(t *testing.T) *gorm.DB {
    tx := GetTestDB(t).Begin()
    t.Cleanup(func() {
        tx.Rollback()
    })
    return tx
}

替代方案:

  • 手动 defer: 需要开发者记住,容易遗漏
  • TeardownTestDB: 需要配对调用,样板代码多
  • TestMain 清理: 无法针对单个测试清理

Decision 3: 测试名称作为 Redis 键前缀

决策: 自动使用 t.Name() 作为 Redis 键前缀,格式: test:{TestName}:*

理由:

  • 隔离: 不同测试的键自动隔离
  • 自动化: 无需手动指定前缀
  • 可追溯: 键名包含测试名称,方便调试
  • 支持嵌套: 子测试继承父测试前缀

实现:

func CleanTestRedisKeys(t *testing.T) {
    testPrefix := fmt.Sprintf("test:%s:", t.Name())
    // 清理已有键
    keys, _ := rdb.Keys(ctx, testPrefix+"*").Result()
    if len(keys) > 0 {
        rdb.Del(ctx, keys...)
    }
    // 注册清理函数
    t.Cleanup(func() {
        keys, _ := rdb.Keys(ctx, testPrefix+"*").Result()
        if len(keys) > 0 {
            rdb.Del(ctx, keys...)
        }
    })
}

替代方案:

  • 手动指定前缀: 容易冲突,样板代码多
  • UUID 前缀: 不可读,调试困难
  • 全局清理: 可能误删其他测试的数据

Decision 4: 向后兼容策略

决策: 保留旧的 SetupTestDB/TeardownTestDB,标记为 Deprecated,支持渐进迁移。

理由:

  • 风险低: 现有测试继续正常运行
  • 灵活: 可以逐个文件迁移,不强制一次性完成
  • 可观察: 可以对比迁移前后的性能
  • 可回退: 如果新方案有问题,可以快速回退

实现:

// Deprecated: 使用 NewTestTransaction 和 CleanTestRedisKeys 代替
// 迁移示例:
//   旧: db, rdb := SetupTestDB(t); defer TeardownTestDB(t, db, rdb)
//   新: tx := NewTestTransaction(t); CleanTestRedisKeys(t)
func SetupTestDB(t *testing.T) (*gorm.DB, *redis.Client) {
    // 保留现有实现
}

替代方案:

  • 直接删除旧函数: 破坏所有现有测试,风险极高
  • 立即迁移所有测试: 工作量大,容易引入 bug
  • 维护两套独立实现: 增加维护成本

Decision 5: 事务隔离级别

决策: 使用数据库默认事务隔离级别(PostgreSQL: READ COMMITTED),不强制指定。

理由:

  • 简单: 无需额外配置
  • 足够: 测试隔离只需回滚,不需要特殊隔离级别
  • 兼容: 与生产环境保持一致
  • 性能: READ COMMITTED 性能较好

实现:

tx := db.Begin()  // 使用默认隔离级别

替代方案:

  • SERIALIZABLE: 性能差,测试不需要
  • READ UNCOMMITTED: 可能读到脏数据
  • REPEATABLE READ: 可能死锁,测试不需要

Risks / Trade-offs

Risk 1: 连接池耗尽

风险: 如果测试并发运行,单个连接池可能成为瓶颈。

影响: 测试可能等待连接,耗时增加。

缓解措施:

  • 监控连接池使用情况
  • 如有需要,调整 max_open_conns 配置
  • 测试默认串行运行,并发风险较低

Risk 2: 事务长时间持有

风险: 如果单个测试运行时间过长,事务长时间持有可能影响其他测试。

影响: 数据库锁等待,测试变慢。

缓解措施:

  • 优化慢测试,确保单个测试 < 1 秒
  • 避免在测试中执行耗时操作(如 HTTP 请求)
  • 使用 t.Parallel() 需要每个测试独立事务

Risk 3: Redis 键命名冲突

风险: 如果测试名称包含特殊字符,可能导致键名冲突。

影响: 测试间数据污染。

缓解措施:

  • 使用 t.Name() 自动生成前缀,降低冲突风险
  • 文档说明 Redis 键命名规范
  • 提供手动指定前缀的选项(如有需要)

Trade-off 1: 连接创建延迟 vs 内存占用

选择: 全局单例连接,首次创建耗时 ~300ms,之后复用。

权衡:

  • 优点: 后续测试几乎零开销,总耗时大幅降低
  • ⚠️ 缺点: 首个测试需要承担初始化时间
  • 决策: 接受首次延迟,换取整体性能提升

Trade-off 2: 向后兼容 vs 代码简洁

选择: 保留旧函数,标记为 Deprecated,而非直接删除。

权衡:

  • 优点: 现有测试无需修改,渐进迁移
  • ⚠️ 缺点: 维护两套 API,增加维护成本
  • 决策: 接受短期维护成本,换取迁移灵活性

Migration Plan

Phase 1: 创建新工具(不影响现有测试)

时间: 1 天

步骤:

  1. 创建 tests/testutils/db.go
  2. 实现全局单例连接管理函数
  3. 添加完整的文档注释

验证:

  • 运行现有测试,确保无影响
  • 手动测试新 API 功能正确性

Phase 2: 小规模验证(选择 2-3 个测试)

时间: 1 天

步骤:

  1. 选择 2-3 个简单的单元测试
  2. 迁移到新的连接管理方式
  3. 对比迁移前后的性能

验证:

  • 功能正确性: 测试通过,结果一致
  • 性能提升: 单测耗时降低 > 80%
  • 资源清理: 无连接泄漏

Phase 3: 批量迁移(渐进式)

时间: 3-5 天

步骤:

  1. 迁移所有 unit 测试(约 20 个文件)
  2. 迁移所有 integration 测试(约 13 个文件)
  3. 每迁移一批,运行测试验证

优先级:

  • 高: 高频测试(每次 PR 都运行)
  • 中: 功能测试
  • 低: 边缘 case 测试

回退策略:

  • 如果新方案有问题,保留旧代码可立即回退
  • 每批迁移前创建 git commit,方便回滚

Phase 4: 标记旧 API 为 Deprecated

时间: 1 天

步骤:

  1. SetupTestDB/TeardownTestDB 添加 Deprecated 注释
  2. 提供迁移指引
  3. 更新文档和 AGENTS.md

验证:

  • IDE 显示 Deprecated 警告
  • 文档包含迁移示例

Phase 5: 观察期(可选)

时间: 1-2 周

步骤:

  1. 监控测试套件运行时间
  2. 收集开发者反馈
  3. 优化文档和常见问题

决策点:

  • 如果稳定运行 2 周,可以移除旧 API
  • 如果发现问题,继续优化新方案

Rollback Plan

触发条件:

  • 新方案导致测试不稳定
  • 发现关键 bug 无法快速修复
  • 性能提升不如预期

回滚步骤:

  1. 恢复使用 SetupTestDB/TeardownTestDB
  2. 将已迁移的测试回退到旧方案
  3. 分析问题,优化新方案后再尝试

Open Questions

Q1: 是否需要支持本地数据库?

背景: 当前只支持远程数据库,开发机器资源有限。

选项:

  • A: 保持现状,只支持远程数据库
  • B: 提供 Docker Compose 支持本地数据库(可选)

决策: 待定,暂时保持 A,未来可扩展 B


Q2: 是否需要支持并发测试?

背景: 当前方案使用事务隔离,不支持 t.Parallel()

选项:

  • A: 不支持,文档说明限制
  • B: 每个并发测试开启独立事务

决策: B,但文档说明需要手动处理


Q3: 旧 API 何时移除?

背景: 保留 Deprecated API 增加维护成本。

选项:

  • A: 永久保留(向后兼容)
  • B: 迁移完成后立即删除
  • C: 观察 1-2 个月后删除

决策: C,观察期后删除,确保稳定性