不能直接在业务代码里写 db.query,因为换数据库时需全面修改调用点及事务逻辑;应通过抽象 store/tx 接口隔离实现,各数据库驱动(如 postgresql、sqlite)独立封装于不同包中,统一错误类型与初始化工厂,并用内存模式或 testcontainers 进行真实集成测试。

为什么不能直接在业务代码里写 db.Query?
因为一旦换数据库(比如从 PostgreSQL 换到 SQLite,或将来接入 TiDB),所有调用 db.Query、db.Exec 的地方都要改,还可能要重写事务逻辑、连接池配置、错误映射。真正的可插拔,不是“换个 driver”,而是把「存储行为」抽象成接口,让上层完全不感知底层实现。
核心做法是定义一组最小契约接口,比如:
type Store interface {
CreateUser(u User) error
GetUserByID(id int64) (User, error)
BeginTx() (Tx, error)
}
type Tx interface {
CreateUser(u User) error
Commit() error
Rollback() error
}
注意:不要暴露 *sql.DB 或 *gorm.DB 给业务层;也不要定义 Query 这类泛型方法——它会倒逼你做 SQL 字符串拼接,失去类型安全和方言隔离能力。
PostgreSQL 和 SQLite 实现必须隔离在哪一层?
在各自独立的包里,比如 store/pg 和 store/sqlite,且它们都只依赖 store 包中定义的接口,绝不能互相 import。每个实现包内部处理:
-
pg.Store用pgx.ConnPool或database/sql+lib/pq,建表语句用 PostgreSQL 特有语法(如GENERATED ALWAYS AS IDENTITY) -
sqlite.Store用mattn/go-sqlite3,主键用INTEGER PRIMARY KEY AUTOINCREMENT - 两者都把 SQL 错误码转成统一的错误类型(如
ErrNotFound、ErrConflict),不暴露pq.Error或sqlite3.ErrNoData
初始化时通过工厂函数注入:
func NewStore(driver string, dsn string) (store.Store, error) {
switch driver {
case "postgres":
return pg.New(dsn)
case "sqlite":
return sqlite.New(dsn)
default:
return nil, fmt.Errorf("unknown driver: %s", driver)
}
}
如何让测试不依赖真实数据库?
别 mock 接口——mock 容易过度耦合实现细节,也掩盖了事务边界、并发读写等真实问题。正确做法是:为每种引擎提供内存模式或轻量级实例。
- SQLite 天然支持
:memory:,sqlite.New(":memory:")即可获得干净、隔离的测试 store - PostgreSQL 可用
testcontainers-go启一个临时容器,或用pglogrepl+ 内存 backend(小众但可行) - 所有集成测试用
testing.T.Cleanup清库,而不是在每个 test 函数开头TRUNCATE
关键点:测试用的 store 实例,必须和生产用的是同一套实现,只是连接参数不同。否则你测的根本不是你要上线的代码。
哪些地方最容易破坏插拔性?
最常被忽略的是时间字段、JSON 字段、全文检索、索引策略这些非标准能力:
- PostgreSQL 有
jsonb和to_tsvector,SQLite 只有JSON1扩展和FULLTEXT,如果业务强依赖全文搜索,就不能假装它们行为一致 - 时间字段:PostgreSQL 默认带时区(
timestamptz),SQLite 存字符串或 int,time.Time序列化方式必须由 store 实现自己决定,不能靠 ORM 自动猜 - 外键约束、UPSERT 语法、批量插入返回值——各数据库差异极大,不能靠 “尽量兼容” 蒙混,该拆接口就拆(例如加
UpsertUser方法,并在各实现里分别写ON CONFLICT DO UPDATE或INSERT OR REPLACE)
插拔性不是“写一次跑所有库”,而是“换库时,只改存储层,且改得清楚、可测、可验证”。越早接受这个事实,越不容易在上线前两天发现 SQLite 不支持 RETURNING 导致订单号拿不到。











