← 中文资源

简体中文版本

事务回滚与冲突重试

封装数据库事务:出错时回滚,遇到序列化冲突按短退避重试,并在上下文取消时立即停止。

阅读英文原文

功能说明

在可串行化隔离级别或高并发写入场景下,数据库可能返回序列化冲突,事务需要整体重试,而不是只重试失败的那条语句。手写重试容易出现忘记回滚、在已提交事务上继续操作、或对不可重试错误反复尝试等问题。WithTx 把开始事务、执行工作、提交或回滚统一封装,只在错误被判定为冲突时重试,其余错误立即返回。

transfer 演示真实用法:先尝试扣减余额,条件中包含余额充足判断;受影响行数为零说明余额不足,直接返回业务错误。扣减成功后再增加对方余额,两步处于同一事务,任何一步失败都会回滚。

关键代码

WithTx 把尝试次数下限校正为一,保存最后一次错误。每轮开始新事务,工作函数返回错误时回滚并记录;成功时提交,提交失败也进入错误处理。errors.Is 用来兼容被包装的错误,只有冲突才继续循环。退避时间随尝试次数线性增加,select 同时监听上下文取消,避免服务关闭时还在等待。最终错误用 %w 包装,保留原始错误链。transfer 使用参数占位符,杜绝拼接 SQL。

使用注意

重试必须保证工作函数可重复执行,不能在函数内发送邮件、调用外部支付等有副作用且不可回滚的操作。冲突判定应基于驱动提供的错误码或可识别错误,全部错误重试会放大故障。事务持有连接和锁,退避期间应结束事务,本例在每轮重新开始,符合要求。提交失败的结果不确定,客户端需要幂等键配合。重试次数和总时限应受请求预算约束,不能无限重试。

验证方式

该片段的主函数只用于展示函数签名与错误值,无法脱离数据库运行。接入真实数据库后,可让两个事务并发更新同一行,人为制造序列化冲突,观察日志中至少发生一次重试后成功。把工作函数改为返回普通错误,应只执行一次并立即回滚返回。

代码示例

package main

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"time"
)

var ErrConflict = errors.New("serialization conflict")

func WithTx(
	ctx context.Context,
	db *sql.DB,
	attempts int,
	work func(*sql.Tx) error,
) error {
	if attempts < 1 {
		attempts = 1
	}
	var last error
	for attempt := 0; attempt < attempts; attempt++ {
		tx, err := db.BeginTx(ctx, nil)
		if err != nil {
			return err
		}
		err = work(tx)
		if err == nil {
			if commitErr := tx.Commit(); commitErr == nil {
				return nil
			} else {
				last = commitErr
			}
		} else {
			_ = tx.Rollback()
			last = err
		}
		if !errors.Is(last, ErrConflict) {
			return last
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(time.Duration(attempt+1) * 50 * time.Millisecond):
		}
	}
	return fmt.Errorf("transaction failed after %d attempts: %w", attempts, last)
}

func transfer(ctx context.Context, db *sql.DB, from, to, amount int64) error {
	return WithTx(ctx, db, 3, func(tx *sql.Tx) error {
		result, err := tx.ExecContext(
			ctx,
			"UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?",
			amount, from, amount,
		)
		if err != nil {
			return err
		}
		affected, err := result.RowsAffected()
		if err != nil {
			return err
		}
		if affected == 0 {
			return errors.New("insufficient funds")
		}
		_, err = tx.ExecContext(
			ctx,
			"UPDATE accounts SET balance = balance + ? WHERE id = ?",
			amount, to,
		)
		return err
	})
}

func main() {
	fmt.Println(transfer != nil, ErrConflict)
}