# 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` 保证线程安全 - ✅ **兼容**: 不影响现有测试 **实现**: ```go 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 官方推荐的测试清理模式 **实现**: ```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}:*` **理由**: - ✅ **隔离**: 不同测试的键自动隔离 - ✅ **自动化**: 无需手动指定前缀 - ✅ **可追溯**: 键名包含测试名称,方便调试 - ✅ **支持嵌套**: 子测试继承父测试前缀 **实现**: ```go 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`,支持渐进迁移。 **理由**: - ✅ **风险低**: 现有测试继续正常运行 - ✅ **灵活**: 可以逐个文件迁移,不强制一次性完成 - ✅ **可观察**: 可以对比迁移前后的性能 - ✅ **可回退**: 如果新方案有问题,可以快速回退 **实现**: ```go // 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 性能较好 **实现**: ```go 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**,观察期后删除,确保稳定性