第一章:Go事务封装的核心挑战与设计哲学
在 Go 生态中,数据库事务的封装远非简单地调用 Begin()、Commit() 和 Rollback()。其本质挑战源于语言原生缺乏面向切面(AOP)和上下文自动传播机制,导致事务生命周期与业务逻辑耦合紧密、错误处理路径分散、跨函数/协程边界时状态易丢失。
事务生命周期管理的脆弱性
标准 sql.Tx 是一次性对象:一旦 Commit() 或 Rollback() 被调用,后续操作将 panic。更严峻的是,Go 的 context.Context 不自动携带事务实例——开发者必须显式传递 *sql.Tx 参数,稍有疏漏便退化为隐式连接池事务(即无事务保障),且难以静态校验。
错误传播与回滚语义不一致
常见反模式是仅检查 err != nil 后调用 Rollback(),却忽略 Rollback() 自身也可能返回错误(如网络中断)。正确做法需双重判断,并确保无论主逻辑成功与否,只要未 Commit() 就必须 Rollback():
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err // 无法开启事务,无需 Rollback
}
defer func() {
if r := recover(); r != nil {
tx.Rollback() // panic 场景兜底
panic(r)
}
}()
if _, err = tx.ExecContext(ctx, "INSERT ..."); err != nil {
tx.Rollback() // 显式失败:回滚并返回错误
return err
}
return tx.Commit() // 成功提交,不再回滚
上下文感知封装的必要性
理想封装应满足:
- 事务实例与
context.Context绑定,通过ctx.Value()安全提取; - 提供
WithTransaction高阶函数统一管理生命周期; - 支持嵌套调用时的“逻辑嵌套”而非物理嵌套(即子函数复用父事务,非新建);
- 透明拦截
sql.DB方法,自动路由至当前事务或默认连接。
| 特性 | 原生 sql.Tx | 健壮封装方案 |
|---|---|---|
| 协程安全 | ❌(需手动同步) | ✅(基于 context) |
| 多层调用事务透传 | ❌(需层层传参) | ✅(ctx.Value 查找) |
| Panic 自动回滚 | ❌ | ✅(defer + recover) |
事务封装的本质,是将状态机(开启→执行→提交/回滚)与控制流(错误分支、panic、并发调度)解耦,让开发者专注业务契约,而非资源生命周期编排。
第二章:pgxpool/v5深度集成与事务生命周期管理
2.1 pgxpool连接池与事务上下文的语义绑定实践
在高并发 Go 应用中,pgxpool.Pool 本身不持有事务状态,但业务逻辑常需“连接—事务—上下文”三者语义一致。直接复用连接可能破坏隔离性。
事务绑定的核心模式
使用 pool.BeginTx(ctx, txOptions) 获取带上下文感知的事务,而非 pool.Acquire() 后手动 conn.Begin():
tx, err := pool.BeginTx(ctx, pgx.TxOptions{
IsoLevel: pgx.ReadCommitted,
AccessMode: pgx.ReadWrite,
})
if err != nil {
return err
}
defer tx.Rollback(ctx) // 非幂等,需显式 Commit 或 Rollback
// 此 tx 绑定唯一连接,且 ctx 超时/取消会中断事务
✅
BeginTx内部自动从池中获取连接并启动事务,确保ctx的 Deadline/Cancel 传导至 PostgreSQL 后端;❌ 若先Acquire再Begin,则上下文无法约束事务生命周期。
常见陷阱对比
| 场景 | 连接复用安全 | 上下文传播 | 自动清理 |
|---|---|---|---|
pool.BeginTx() |
✅(独占连接) | ✅(全链路) | ❌(需手动) |
pool.Acquire() → conn.Begin() |
❌(连接可能被复用) | ⚠️(仅作用于 conn) | ❌ |
graph TD
A[HTTP Request] --> B[ctx.WithTimeout]
B --> C[pool.BeginTx]
C --> D[PostgreSQL Backend]
D --> E[事务受 ctx 控制]
2.2 基于context.Context的事务传播与超时控制实现
事务上下文的自然传递
context.Context 不仅承载取消信号,还可携带事务句柄(如 *sql.Tx)和隔离级别元数据,实现跨 Goroutine 的事务一致性传播。
超时控制的双重保障
ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
defer cancel() // 防止 goroutine 泄漏
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return err // ctx.DeadlineExceeded 可在此处被捕获
}
WithTimeout创建可取消子上下文,超时后自动触发cancel();BeginTx将上下文传入驱动层,底层 SQL 连接池据此中断阻塞操作;defer cancel()确保资源及时释放,避免上下文泄漏。
关键行为对比
| 场景 | context 未超时 | context 已超时 |
|---|---|---|
db.BeginTx |
成功返回事务 | 返回 context.DeadlineExceeded |
tx.QueryRowContext |
正常执行 | 立即返回错误 |
graph TD
A[HTTP Handler] --> B[WithTimeout]
B --> C[Service Layer]
C --> D[Repository Layer]
D --> E[DB Driver]
E -- 超时检测 --> F[Cancel Network I/O]
2.3 可重入事务与嵌套事务的边界判定与panic防护
边界判定的核心原则
可重入事务允许同一goroutine重复进入,但需严格区分“逻辑嵌套”与“物理嵌套”。关键判据是事务上下文(*sql.Tx)是否已存在且未提交/回滚。
panic防护机制
Go中事务若在defer中rollback却遭遇panic,易引发双重panic。应使用recover()隔离并确保资源清理:
func withTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
if r := recover(); r != nil {
tx.Rollback() // 安全回滚,避免panic传播
panic(r) // 重新抛出原始panic
}
}()
if err := fn(tx); err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}
逻辑分析:
defer中recover()捕获panic后立即回滚,防止tx处于未知状态;panic(r)保证业务异常不被吞没。参数ctx控制超时,fn封装业务逻辑,解耦事务生命周期与业务代码。
嵌套事务支持矩阵
| 方案 | 可重入 | 真嵌套 | Panic安全 | 备注 |
|---|---|---|---|---|
sql.Tx原生 |
❌ | ❌ | ❌ | 不支持嵌套 |
pgx.Tx(savepoint) |
✅ | ✅ | ✅ | 依赖savepoint模拟嵌套 |
| 自定义TxManager | ✅ | ⚠️ | ✅ | 逻辑嵌套,物理单层 |
graph TD
A[入口调用withTx] --> B{panic发生?}
B -->|否| C[执行fn]
B -->|是| D[recover捕获]
D --> E[tx.Rollback]
E --> F[re-panic]
2.4 连接泄漏检测与事务未提交/回滚的编译期静态分析钩子
静态分析钩子在字节码增强阶段注入事务边界与连接生命周期断言,捕获 Connection#close() 缺失及 Transaction#commit()/rollback() 遗漏。
核心检测策略
- 扫描所有
@Transactional方法体内的try-finally结构完整性 - 匹配
DataSource.getConnection()调用点与对应close()调用路径可达性 - 检查
TransactionStatus对象是否在所有分支中被显式commit()或rollback()
典型误用模式(反例)
@Transactional
public void updateUser(User user) {
jdbcTemplate.update("UPDATE users SET name=? WHERE id=?", user.getName(), user.getId());
// ❌ 编译期钩子标记:无显式异常处理,事务状态对象未被检查
// ❌ 连接由 JdbcTemplate 管理,但钩子仍验证其资源释放链完整性
}
该代码块中,虽由 Spring 自动管理连接与事务,但静态钩子仍校验字节码中是否存在 TransactionAspectSupport.currentTransactionStatus() 的分支覆盖——若存在手动事务控制逻辑却遗漏回滚,则触发编译警告。
检测能力对比表
| 检测项 | 支持 | 说明 |
|---|---|---|
Connection.close() 路径可达性 |
✅ | 基于 CFG 控制流图分析 |
TransactionStatus.isCompleted() 分支覆盖 |
✅ | 注入字节码级断言 |
| SQL 注入漏洞 | ❌ | 属于语义层,非本钩子范畴 |
graph TD
A[编译期 javac] --> B[ASM 字节码插桩]
B --> C{检测 Connection 获取点}
C --> D[构建资源释放路径图]
C --> E[追踪 TransactionStatus 使用]
D --> F[报告未关闭路径]
E --> G[报告未 commit/rollback 分支]
2.5 pgxpool事务钩子(BeforeExec/AfterExec)在审计日志中的落地应用
pgxpool 的 BeforeExec 和 AfterExec 钩子为无侵入式 SQL 审计提供了底层支撑。通过注册回调函数,可在每条语句执行前/后捕获上下文。
审计上下文提取关键字段
- 当前事务 ID(
txID) - 执行用户(
pgx.Conn().User()) - SQL 模板与参数(需启用
pgx.QueryEx或解析pgconn.CommandTag) - 执行耗时(纳秒级,由
AfterExec中time.Since(start)计算)
示例:注册审计钩子
pool, _ := pgxpool.New(context.Background(), connStr)
pool.BeforeExec(func(ctx context.Context, conn *pgx.Conn, sql string, args ...interface{}) error {
log.Printf("[AUDIT-BEFORE] user=%s, sql=%s, args=%v", conn.User(), sql, args)
ctx = context.WithValue(ctx, "audit_start", time.Now())
return nil
})
pool.AfterExec(func(ctx context.Context, conn *pgx.Conn, sql string, args []interface{}, err error) error {
start := ctx.Value("audit_start").(time.Time)
log.Printf("[AUDIT-AFTER] duration=%v, err=%v", time.Since(start), err)
return nil
})
逻辑说明:
BeforeExec注入起始时间至context;AfterExec提取并记录耗时。注意args在AfterExec中为切片([]interface{}),而BeforeExec中为可变参数,需统一处理。
| 字段 | 来源 | 是否敏感 | 用途 |
|---|---|---|---|
conn.User() |
pgx.Conn | 是 | 标识操作主体 |
sql |
Hook 参数 | 否 | 归类操作类型 |
time.Since |
context.Value 携带 |
否 | 性能基线与异常检测 |
graph TD
A[SQL Query] --> B{BeforeExec Hook}
B --> C[注入审计上下文]
C --> D[执行语句]
D --> E{AfterExec Hook}
E --> F[记录耗时/错误/影响行数]
第三章:sqlc生成层的事务契约建模与类型安全增强
3.1 SQL模板中显式事务标记(BEGIN/COMMIT/ROLLBACK)的AST解析与校验
SQL模板引擎在解析阶段需精准识别显式事务边界。AST节点需标记 TransactionStmt 类型,并验证嵌套合法性。
事务语句识别规则
BEGIN、START TRANSACTION视为事务起点COMMIT、ROLLBACK必须成对出现在同一作用域内- 禁止
BEGIN嵌套于未结束事务中
AST校验逻辑示例
-- 示例模板片段
BEGIN;
INSERT INTO users VALUES ({{id}}, '{{name}}');
UPDATE logs SET status = 'done' WHERE id = {{log_id}};
COMMIT;
解析器将生成含
TransactionStart和TransactionEnd节点的AST子树;{{id}}等占位符保留在InsertStmt/UpdateStmt子节点中,不参与事务结构校验。
| 节点类型 | 是否允许嵌套 | 校验要点 |
|---|---|---|
| TransactionStart | 否 | 上下文不得存在未关闭事务 |
| TransactionEnd | 否 | 必须匹配最近的未完成 START |
graph TD
A[Parse SQL Template] --> B{Contains BEGIN?}
B -->|Yes| C[Build TransactionScope Node]
B -->|No| D[Plain Stmt List]
C --> E[Validate COMMIT/ROLLBACK Pairing]
3.2 sqlc扩展插件开发:为Query方法注入Tx-aware接口签名
sqlc 默认生成的 Query 方法仅接受 *sql.DB,无法直接参与事务上下文。要支持事务感知(Tx-aware),需通过自定义 Go 插件扩展其代码生成逻辑。
核心改造点
- 修改
query.go.tmpl模板,为每个查询方法添加execer Execer参数(Execer接口兼容*sql.Tx和*sql.DB) - 保留原有方法签名作为重载入口(通过函数重命名实现)
生成签名对比
| 原始签名 | Tx-aware 签名 |
|---|---|
func (q *Queries) GetUser(ctx context.Context, id int) (User, error) |
func (q *Queries) GetUser(ctx context.Context, execer Execer, id int) (User, error) |
// Execer 接口定义(兼容 DB/Tx)
type Execer interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
该接口使同一方法可安全用于事务内执行(传 tx)或独立调用(传 db),无需重复封装。
graph TD
A[sqlc generate] --> B[插件解析SQL AST]
B --> C{是否启用Tx-aware?}
C -->|是| D[注入execer参数 + 接口约束]
C -->|否| E[保持默认DB签名]
D --> F[生成双模式Query方法]
3.3 基于sqlc YAML配置驱动的事务隔离级别元数据注入与IDE提示生成
sqlc 允许在 sqlc.yaml 中为查询显式声明事务语义,从而将隔离级别作为结构化元数据注入生成器流水线:
# sqlc.yaml
version: "2"
packages:
- name: "db"
path: "./db"
queries: "./query"
schema: "./schema.sql"
engine: "postgresql"
emit_json_tags: true
emit_interface: true
# ⬇️ 隔离级别元数据注入点
overrides:
- table: "orders"
operations: ["*"]
isolation_level: "Serializable" # 支持: ReadUncommitted, ReadCommitted, RepeatableRead, Serializable
该配置被 sqlc 解析器捕获后,注入到生成的 Go 方法签名注释及 //go:generate 可读元数据中,供 IDE 插件(如 VS Code 的 sqlc 扩展)提取并渲染为悬停提示。
IDE 提示生成原理
- sqlc 在生成
*.go文件时,向QueryXXX方法添加// @isolation: Serializable注释; - LSP 服务扫描该注释,动态注入 HoverProvider 提示;
- 开发者在调用处悬停即可查看当前查询的强制隔离约束。
支持的隔离级别映射表
| SQL 标准值 | sqlc 配置值 | PostgreSQL 对应行为 |
|---|---|---|
READ UNCOMMITTED |
ReadUncommitted |
实际降级为 ReadCommitted |
READ COMMITTED |
ReadCommitted |
默认行为,无显式 BEGIN TRANSACTION |
REPEATABLE READ |
RepeatableRead |
使用 SERIALIZABLE 模拟(PG ≥14) |
SERIALIZABLE |
Serializable |
强制 BEGIN SERIALIZABLE |
graph TD
A[sqlc.yaml isolation_level] --> B[Parser 提取元数据]
B --> C[Codegen 注入 // @isolation 注释]
C --> D[LSP Server 解析注释]
D --> E[VS Code Hover 提示渲染]
第四章:ent ORM与事务协同的声明式封装体系构建
4.1 ent.Mutation Hook链中事务状态感知与自动回滚熔断机制
在 ent 框架中,ent.Mutation Hook 链需实时感知事务生命周期状态,避免在 Tx 已 rollback 或 commit 后继续执行副作用操作。
事务状态钩子注入点
Hook 通过 ent.Hook 接口注入,关键判定点为:
mutation.Tx() != nil:当前处于事务上下文tx.Status() == tx.StatusRolledBack:事务已熔断
自动熔断逻辑实现
func AbortOnRollback(hook ent.Hook) ent.Hook {
return func(next ent.Mutator) ent.Mutator {
return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
if tx := m.Tx(); tx != nil && tx.Status() == ent.TxStatusRolledBack {
return nil, fmt.Errorf("transaction aborted: hook execution halted")
}
return next.Mutate(ctx, m)
})
}
}
逻辑分析:该 Hook 在每次 Mutation 执行前检查事务状态。
m.Tx()返回当前绑定的*ent.Tx实例;tx.Status()是 ent 内置状态枚举(TxStatusActive/TxStatusCommitted/TxStatusRolledBack)。一旦检测到RolledBack,立即返回错误终止链式调用,防止脏写或 panic。
状态流转示意
graph TD
A[Hook 开始] --> B{Tx != nil?}
B -->|否| C[跳过状态检查]
B -->|是| D{Tx.Status == RolledBack?}
D -->|是| E[返回熔断错误]
D -->|否| F[继续执行 next.Mutate]
4.2 Ent Client泛型包装器:支持Tx/Pool双模式自动适配的接口抽象
Ent Client 原生不区分事务上下文与连接池调用,导致业务层需手动判断 *ent.Tx 或 *ent.Client 类型,侵入性强。泛型包装器 Client[T ent.Querier] 统一抽象二者行为。
核心设计思想
- 利用 Go 泛型约束
ent.Querier(*ent.Client与*ent.Tx均实现) - 所有查询方法通过
T实例委托执行,屏蔽底层差异
type Client[T ent.Querier] struct {
client T
}
func (c *Client[T]) User() *UserQuery[T] {
return &UserQuery[T]{client: c.client} // 泛型透传,零运行时开销
}
UserQuery[T]内部所有WithTx()/Exec(ctx)调用均直接作用于T,无需类型断言或分支判断。
模式自动适配机制
| 场景 | 传入类型 | 行为表现 |
|---|---|---|
| 全局访问 | *ent.Client |
直接复用连接池 |
| 事务内调用 | *ent.Tx |
自动绑定当前事务上下文 |
graph TD
A[调用 Client.User().Where(...).All(ctx)] --> B{client 是 *ent.Tx?}
B -->|Yes| C[执行于事务隔离上下文]
B -->|No| D[执行于连接池共享上下文]
4.3 基于ent.Schema的事务约束注解(@tx:required、@tx:readonly)与代码生成联动
Ent 通过 Schema 注释驱动事务行为生成,@tx:required 和 @tx:readonly 直接影响 ent.Tx 方法签名与调用链路。
注解语义与生成逻辑
@tx:required:强制包裹在ent.WithTx()中,生成带*ent.Tx参数的 Repository 方法@tx:readonly:禁用Save()/Update(),仅生成Query().All(ctx)类只读方法
示例 Schema 片段
// +ent:generate
// +ent:schema
type User struct {
ent.Schema
}
// +ent:fields
func (User) Fields() []ent.Field {
return []ent.Field{
field.String("name").
// @tx:readonly ← 此注释作用于整个实体操作粒度
Annotations(entx.TxReadonly{}),
}
}
该注解被
entc插件捕获,在gen.go中注入TxReadOnly标记,进而跳过Mutation构建器生成,并为UserQuery自动添加WithTx(ctx, tx)防误用校验。
生成效果对比表
| 注解类型 | 生成方法示例 | 是否含 *ent.Tx 参数 |
是否允许 Create() |
|---|---|---|---|
@tx:required |
CreateUser(ctx, tx, input) |
✅ | ✅ |
@tx:readonly |
QueryUsers(ctx).All(ctx) |
❌(但校验传入 ctx 是否含 Tx) | ❌ |
graph TD
A[Schema 文件] -->|解析注解| B(entc 插件)
B --> C{@tx:required?}
C -->|是| D[生成 Tx-aware 方法]
C -->|否| E{@tx:readonly?}
E -->|是| F[屏蔽写操作 + 加只读上下文校验]
4.4 IDE智能提示增强:通过gopls定制lsp.Server实现事务方法调用链路高亮与误用预警
核心扩展点:拦截并增强textDocument/definition响应
gopls 允许通过 lsp.Server 的 Handler 链注入自定义逻辑,关键在于重写 Definition 方法,识别 BeginTx/Commit/Rollback 等事务边界调用。
func (s *txServer) Definition(ctx context.Context, params *lsp.DefinitionParams) ([]lsp.Location, error) {
// 1. 委托原生解析获取基础跳转位置
locs, err := s.baseServer.Definition(ctx, params)
if err != nil {
return locs, err
}
// 2. 若当前符号为事务方法,触发链路分析(需AST+PackageCache)
if isTxMethod(params.TextDocument.URI, params.Position) {
return append(locs, buildTxChainLocations(ctx, params)...), nil
}
return locs, nil
}
isTxMethod基于token.Position定位 AST 节点,结合types.Info判断是否为*sql.Tx方法;buildTxChainLocations返回跨文件的调用链lsp.Location列表,含高亮范围与语义标签。
误用预警规则示例
| 场景 | 触发条件 | 提示等级 |
|---|---|---|
Commit() 后续调用 Exec() |
检测同一 *sql.Tx 实例上 Commit 后存在非 Close() 方法调用 |
Error |
Rollback() 前无 BeginTx() |
控制流图(CFG)中无前置事务开启节点 | Warning |
链路高亮流程
graph TD
A[用户悬停 Commit] --> B{是否在 Tx 上下文?}
B -->|是| C[构建调用图 CFG]
C --> D[标记 BeginTx → Query → Commit 路径]
D --> E[向 LSP 发送 SemanticTokensDelta]
第五章:从理论到生产:一套可落地的Go事务封装范式总结
核心设计原则
事务封装必须满足ACID一致性边界与业务语义对齐。我们摒弃全局事务管理器,采用显式*sql.Tx透传+闭包回调模式,在调用栈最外层开启事务,内层服务函数仅接收context.Context和Querier接口(含*sql.DB或*sql.Tx),实现存储层解耦。
接口契约定义
type Querier interface {
QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
}
// 事务执行器
type TxExecutor interface {
ExecuteInTx(ctx context.Context, fn func(Querier) error) error
}
生产级实现示例
func (s *Service) ExecuteInTx(ctx context.Context, fn func(Querier) error) error {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return fmt.Errorf("failed to begin tx: %w", err)
}
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p)
}
}()
if err := fn(tx); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
log.Error("tx rollback failed", "err", rbErr)
}
return fmt.Errorf("tx execution failed: %w", err)
}
return tx.Commit()
}
典型业务场景:订单创建+库存扣减
| 步骤 | 操作 | 事务内依赖 |
|---|---|---|
| 1 | 创建订单主记录(orders表) |
必须成功 |
| 2 | 扣减商品库存(inventory表,CAS校验) |
依赖步骤1的order_id与sku_id |
| 3 | 记录订单事件(order_events表) |
依赖步骤1、2均成功 |
错误处理策略矩阵
| 异常类型 | 处理方式 | 是否重试 | 示例 |
|---|---|---|---|
sql.ErrNoRows |
业务逻辑忽略 | 否 | 查询优惠券不存在 |
foreign key violation |
转换为ErrInvalidInput |
否 | 关联用户ID不存在 |
| 网络超时/Deadlock | 指数退避重试(≤3次) | 是 | context.DeadlineExceeded |
流程图:事务执行生命周期
flowchart TD
A[调用 ExecuteInTx] --> B[db.BeginTx]
B --> C{fn 执行成功?}
C -->|是| D[tx.Commit]
C -->|否| E[tx.Rollback]
D --> F[返回 nil]
E --> G[返回封装错误]
B --> H[panic 捕获]
H --> E
单元测试关键断言
使用github.com/DATA-DOG/go-sqlmock模拟事务行为,重点验证:
BeginTx被调用且参数含LevelReadCommittedCommit()仅在无error路径下触发Rollback()在任意error路径下触发(含panic路径)- 回调函数接收的
Querier实际为*sql.Tx实例
日志与可观测性增强
在ExecuteInTx入口注入结构化日志字段:tx_id=uuid.NewString(),并在所有SQL操作中透传该字段;结合OpenTelemetry,自动将事务生命周期作为span嵌套在父请求trace中,支持按tx_status=committed/rolled_back快速筛选慢事务。
并发安全注意事项
sql.Tx本身不支持并发执行多个查询——所有QueryRowContext/ExecContext必须串行调用。我们在服务层通过sync.Once确保事务上下文初始化仅一次,并在文档中强制要求业务方法不得启动goroutine访问同一Querier实例。
MySQL与PostgreSQL适配差异
PostgreSQL需启用pgx驱动并配置runtime_params: {'search_path': 'public'}避免schema污染;MySQL则需在tx.Options中显式设置&sql.TxOptions{ReadOnly: false},否则某些版本会默认开启只读模式导致INSERT失败。
