版本号机制在业务中的应用

2025/06/13

Categories: 技术 Tags: 业务开发

版本号机制

使用版本号实现乐观锁是一种常见的做法,尤其在需要处理并发更新的场景中。以下是使用版本号实现乐观锁的步骤:

  1. 添加版本号字段:在需要支持乐观锁的表中,添加一个版本号字段,通常是整型。
  2. 读取数据时获取版本号:当读取记录时,同时获取该记录的版本号。
  3. 更新数据时检查版本号:在更新记录之前,检查数据库中的版本号是否与之前读取的版本号一致。
  4. 更新版本号:如果版本号一致,执行更新操作,并递增版本号。
  5. 处理更新失败的情况:如果版本号不一致,说明记录已被其他事务更新,此时更新操作应该失败,并根据业务逻辑进行相应处理,比如重试或报错。

项目举例

需求

以账户余额增减的业务场景举例,使用版本号机制实现账户余额增减,同时生成账户流水。需要保证余额操作的最终一致性,在高并发场景下读写正确,在分布式环境下也不会有并发问题,实现省略用户登录态信息。

表结构设计

创建用户账户表和流水表,省略用户信息表。

表结构定义
CREATE TABLE `account` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `user_id` bigint NOT NULL COMMENT '用户id',
  `balance` decimal(18,2) NOT NULL COMMENT '当前余额,精确到分',
  `version` int NOT NULL DEFAULT '0' COMMENT '版本号',
  `status` tinyint NOT NULL DEFAULT '1' COMMENT '账户状态(1:正常、2:冻结)',
  `created_at` datetime NOT NULL,
  `updated_at` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `udx_uid` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

CREATE TABLE `account_flow` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `flow_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '流水号',
  `account_id` bigint NOT NULL COMMENT '关联的账户id',
  `amount` decimal(18,2) NOT NULL COMMENT '变动金额(正:进账,负:出账)',
  `balance_before` decimal(18,2) NOT NULL COMMENT '变动前余额',
  `balance_after` decimal(18,2) NOT NULL COMMENT '变动后余额',
  `type` tinyint NOT NULL COMMENT '流水类型(1:充值、2:消费、3:退款、4:提现)',
  `biz_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '业务单号',
  `version_seq` int NOT NULL COMMENT '关联账户的版本号(用于追溯)',
  `created_at` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `udx_fno` (`flow_no`),
  KEY `idx_aid_cat` (`account_id`,`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

假设一个用户只能有一个账户,对用户 ID 创建唯一索引。流水表记录操作关联的版本和变动前后余额,方便追溯操作和统计分析。流水号全局唯一,示例用 UUID(具体业务可使用分布式 ID 来保证唯一性)同时创建了唯一索引约束,其他索引可根据业务需求自行增加。

代码逻辑实现

使用 Ginsqlx 实现简单的业务逻辑,数据库为 MySQL。包含初始化用户账户、查看用户账户信息、更新账户并生成流水三个接口实现。完整代码见 Github

关键代码逻辑如下,该方法实现了根据传入的金额和流水类型来操作账户余额并生成流水,返回更新后的账户信息和流水记录。

关键方法实现
// 接口路由定义
func main() {
    account := biz.NewAccountBiz()

    // NOTE: 仅做演示,具体业务中不会直接将 uid 放到路由中暴露,一般会从登录信息中提取。
    r := gin.Default()
    r.GET("/accounts/:uid", account.GetUserAccount)
    r.POST("/accounts/:uid/actions/init", account.InitUserAccount)
    r.POST("/accounts/:uid/actions/update", account.UpdateUserAccountAndCreateFlow)

    r.Run(":3000")
}
// 更新账户并创建流水操作
func (s *AccountSvc) UpdateUserAccountAndCreateFlow(userId int64, amount float64, bizNo string, flowType model.AccountFlowType) (*model.Account, *model.AccountFlow, error) {
    // NOTE: 在主从架构下,需在主库执行事务,避免主从延迟导致的版本号不一致,导致大量失败的事务回滚
    tx, err := s.ds.Beginx()
    if err != nil {
        return nil, nil, err
    }
    defer tx.Commit()

    // NOTE: 在高频写入场景下,需要加 FOR UPDATE,否则会导致大量失败的事务回滚
    // NOTE: 在读多写少且低频写入的场景下,可以不加 FOR UPDATE,避免频繁的锁竞争(X 型行锁)
    account := new(model.Account)
    err = tx.Get(account, "SELECT * FROM account WHERE user_id = ? FOR UPDATE", userId) // NOTE: 可只查询必要字段,使用联合索引来避免回表操作
    if err != nil {
        tx.Rollback()
        return nil, nil, err
    }

    result, err := tx.NamedExec(`UPDATE account SET balance = balance + :amount, version = version + 1 WHERE id = :id AND version = :version`, map[string]any{"amount": amount, "id": account.ID, "version": account.Version})
    if err != nil {
        tx.Rollback()
        return nil, nil, err
    }

    // NOTE: 抛出错误,由客户端主动重试,此处也可以使用自动重试,但需要考虑重试次数和重试间隔
    if rowsAffected, _ := result.RowsAffected(); rowsAffected == 0 {
        tx.Rollback()
        return nil, nil, errors.New("version conflict, please retry")
    }

    accountFlow := &model.AccountFlow{
        FlowNo:        uuid.NewString(),
        AccountID:     account.ID,
        Amount:        amount,
        BalanceBefore: account.Balance,
        BalanceAfter:  account.Balance + amount,
        Type:          int32(flowType),
        BizNo:         bizNo,
        VersionSeq:    account.Version + 1,
        CreatedAt:     time.Now().Local(),
    }

    result, err = tx.NamedExec(`INSERT INTO account_flow (flow_no, account_id, amount, balance_before, balance_after, type, biz_no, version_seq, created_at) VALUES (:flow_no, :account_id, :amount, :balance_before, :balance_after, :type, :biz_no, :version_seq, :created_at)`, accountFlow)
    if err != nil {
        tx.Rollback()
        return nil, nil, err
    }

    accountFlow.ID, _ = result.LastInsertId()
    return account, accountFlow, nil
}

关键步骤逻辑:

  1. 开启事务,使用 MySQL 当前读来读取当前事务中的账户版本号和其他信息。
  2. 执行更新操作,对比事务读到的版本和数据库记录的当前版本,一致则更新并递增版本号。
  3. 根据更新操作返回的影响行数判断更新操作是否成功,更新行数为 0 则说明更新失败,需要报错并回滚事务(也可自动触发重试操作)。
  4. 更新成功后计算对应的流水前后金额,记录操作的账户版本号,生成账户流水记录并写入。

接口压测

使用 Jmeter 对更新操作接口进行压测,模拟在高频写操作的环境中是否会出现一致性问题。

压测准备

测试环境使用本地 MySQL 8.4 版本,存储引擎为 InnoDB,隔离级别为 REPEATABLE READ 可重复读。

  1. 首先查看下 MySQL 默认的连接数,防止线程过多超出连接数量限制。

  1. 临时修改下 MySQL 最大连接数为 1100,预留部分连接数量保证不会由于连接数限制而更新失败。
SET GLOBAL max_connections=1100
  1. 初始化一个 uid 为 1 的用户,初始化后的账户余额为 0,版本号为 0。
curl --location --request POST 'http://127.0.0.1:3000/accounts/1/actions/init'

结果分析

Jmeter 配置启动 1000 个用户线程发起 HTTP 请求,每次向账户中增加 9.99 的余额,请求和配置如下:

curl --location --request POST 'http://127.0.0.1:3000/accounts/1/actions/update' -d '{"amount":9.99,"type":1,"bizNo":"xxxxxxxx"}'

持续运行 30s,最终统计结果如下:

Label # Samples Average Min Max Std. Dev. Error % Throughput Received KB/sec Sent KB/sec Avg. Bytes
POST /actions/update 28602 889 7 1504 329.63 0.000% 910.51476 485.58 240.08 546.1
TOTAL 28602 889 7 1504 329.63 0.000% 910.51476 485.58 240.08 546.1

MySQL 最后插入的 30 条更新流水如下:

用户的账户信息如下:

每秒插入的流水数量如下:

可以看到更新请求的失败率为 0%,数据库最大 id,版本号都和压测结果中的 Samples 数量一致,账户余额也与预期的结果一致。说明在 1000 左右的并发写操作下能保证结果一致。

相关问题

示例中的读账户信息的独占锁是否可以修改为共享锁(LOCK IN SHARE MODE)或者 MVCC 快照读模式?

-- 独占锁(X 型锁)
SELECT * FROM account WHERE user_id = 1 FOR UPDATE;

-- 共享锁(S 型锁)
SELECT * FROM account WHERE user_id = 1 LOCK IN SHARE MODE;

-- 当前读(MVCC)
SELECT * FROM account WHERE user_id = 1;

分别修改查询语句为另外两种模式,重新执行压测流程。

共享锁模式

使用共享锁模式,在相同的设置下持续运行 30s,最终结果如下:

Label # Samples Average Min Max Std. Dev. Error % Throughput Received KB/sec Sent KB/sec Avg. Bytes
POST /actions/update 55523 450 1 1430 227.95 99.661% 1824.31411 464.84 481.02 260.9
TOTAL 55523 450 1 1430 227.95 99.661% 1824.31411 464.84 481.02 260.9

请求错误率高达 99.66%,错误全部为触发死锁导致事务回滚,数据库仅成功更新了 188 次。

快照读模式

使用快照读模式结果如下:

Label # Samples Average Min Max Std. Dev. Error % Throughput Received KB/sec Sent KB/sec Avg. Bytes
POST /actions/update 77287 322 2 1301 147.93 99.746% 2548.62325 507.32 672.00 203.8
TOTAL 77287 322 2 1301 147.93 99.746% 2548.62325 507.32 672.00 203.8

请求错误率高达 99.75%,错误全部为版本冲突,即手动检测到未更新成功,数据库仅成功更新了 196 次。

结论

在高频写入的场景下,使用当前读的排它锁模式获取版本号和余额操作写入性能最好。

在当前读的共享锁模式下,容易发生数据库死锁问题,且处理的请求数量低于快照读,所以在低频写入高频读的场景下,可以使用快照读模式减少锁竞争提升性能,避免使用当前读的共享锁模式。