# Mongo 开启事务的副本集大坑

最近开发用户新增的功能时,为了给用户设置自增长的 ID,我专门创建了一个维护自增长的 counters

考虑实现的逻辑是每次新增用户之前,先从 counters 表中获取当前的 ID 值,然后自增加 1,将获取的最新 ID 值合并到前端传递的用户信息中

接着将合并后的信息统一调用 save 方法保存到 users 表中

但是,在合并用户信息时出了一些错误,保存的方法没有执行。于是乎,counters 表中的 ID 字段发生了增长,导致下一次成功添加用户时,用户的 ID 发生了跳跃。从 10000 直奔 10003 了, 10002 的添加失败了,但是 counters 表的 ID 发生了增长。

这明显是不可以的,从 counters表的 ID 自增长,到 users表的 save 保存,这是一个链路,如果中间的任何一步发生了失败。应该回滚回原来的数据状态。

于是乎,我用到了 mongoose 的事务 (opens new window)

mongoose 事务实例 (opens new window)

但是,当我仔细看完上面的英文文档后和应用实例后。我开始我的代码,我碰到了第一个报错: Transaction numbers are only allowed on a replica set member or mongos

真是查遍了各种文档,最后发现了一个大佬的微博 (opens new window)

抄一下内容,放置以后链接不好用了:

# mongo 的事务

要使用 mongo 的自带事务,需要配置成 副本集或者分片模式,上面的报错我理解就是因为我没有副本集。

TIP

mongodb 副本集: 副本集是一组服务器,其中一个是主服务器,用于处理客户请求;还有多个备份服务器,用于保存主服务器的数据副本。 官方推荐的副本集机器数量为至少 3 个。

在非事务时 mongo,插入数据时会自动建立表结构,但是使用事务中如何相关表不存在,就会报错。所有事务使用时建议先建立好对应的表结构。

如上所说,我只有电脑上安装的这一个 mongodb,没有 3 个。所以,单节点 mongo 事务配置

# 单节点副本集模式配置

  1. 首先找到 mongo 的配置文件: mongod.conf。更改配置内容:
systemLog:
  destination: file
  path: /usr/local/var/log/mongodb/mongo.log
  logAppend: true
storage:
  dbPath: /usr/local/var/mongodb
net:
  bindIp: 127.0.0.1

# 就是添加下方的内容
replication:
  replSetName: rs0 # 这个 replSetName 必须所有节点都得一致
  1. 然后,重新启动 mongo 使 mongo 能够重新读取配置文件

注意,重启 mongo 并不是直接执行 mongod -f /usr/local/etc/mongod.conf

可以参考 mongodb 的安装

  1. 执行 mongosh 进入 mongo 的执行环境

如果当前的版本连 mongosh 指令也没有,那就下载一个 mongoDB Shell (opens new window)吧,我参考了这个帖子 (opens new window)

mongo 的执行环境下可以直接运行 mongdo 的各种数据库指令,比如 show dbs 展示数据库

在该环境下执行如下命令:

rs.initiate()  # 初始化一个新的副本集


# 在一些文档中可能会看到执行下面两条指令,但是都已废弃。
# 可以通过 rs.help 获取信息
rs.slaveOk() # 已废弃
rs.secondaryOk() # 已废弃


# 看到上面两条执行下面这个指令就好
db.getMongo().setReadPref()

指令的话看这里 (opens new window)

  1. 接下来你大概能看到类似下面的前缀
rs0 [direct: primary] test>

# 实现最初的事务逻辑

// 用户管理模块
const router = require("koa-router")();
const mongoose = require("mongoose");

const util = require("../utils/util");

const User = require("../models/userSchema");
const Counter = require("../models/counterSchema");

router.post("/add", async (ctx) => {
  const { username, userEmail } = ctx.request.body;

  if (!username || !userEmail) {
    ctx.body = util.fail("参数错误", util.CODE.PARAM_ERROR);
    return;
  }

  // 先查找当前用户是否存在
  const hasInfo = await User.findOne(
    { $or: [{ username }, { userEmail }] },
    "_id userName userEmail"
  );
  if (hasInfo) {
    ctx.body = util.fail(
      `已有该用户,信息如下: ${hasInfo.username}-${hasInfo.userEmail}`
    );
    return;
  }

  // 开始事物
  const session = await mongoose.connection.startSession();
  session.startTransaction();

  // id 是 userId 的查询条件; $inc 对某个字段自增,key 是表里字段名称,1 是自增多少
  // new: true 自动加1后,将最新的值返回回来
  // 返回的是更新后的最新文档
  const doc = await Counter.findOneAndUpdate(
    { _id: "userId" },
    { $inc: { sequence_value: 1 } },
    { new: true }
  ).session(session);

  try {
    // 新增的话,需要 new model 实例化一个 document
    const user = new User({
      userId: doc.sequence_value,
      username,
      password: md5("123456"),
      userEmail,
      role: 1, // 默认是普通用户
      roleList,
      job,
      state,
      deptId,
      mobile,
    });

    // 可以在这里测试抛错
    throw Error("测试事务错误");

    await user.save({ session });

    // 在这里提交事务
    await session.commitTransaction();

    // 结束事务
    session.endSession();

    ctx.body = util.success("", "用户创建成功");
  } catch (error) {
    // 报错了就阻止事务的执行
    await session.abortTransaction();
    ctx.body = util.fail(error.stack, "用户创建失败");
  }
});

有一个大佬的帖子用了 mongo 提供的两种方式,可以在这里查看 (opens new window) 当然我还完整的看了 mongoose 的事务文档 (opens new window),也挺收益的,就是英文很难受,有时间我要翻译一个汉语的版本放到博客下。