# 数据库事务

云开发支持数据库事务,并保证事务的 ACID 特性。本文将介绍数据库事务的使用场景、案例、原理以及最佳实践,来帮助开发者完成更复杂的业务需求。

# 使用场景

云开发提供的云数据库是基于文档的非关系型数据库。不同于传统的关系型数据库,开发者可以直接在单文档中嵌套子文档,以描述更复杂的数据结构。

在大多数场景中,单文档完全可以满足需求。但在一些场景中,使用数据库事务的优势更明显 👇

  • 从传统关系型数据库迁移到云开发:数据模型平滑迁移,业务代码改造成本低
  • 涉及多个文档/多个集合的业务流程:保证一系列读写操作完全成功或者完全失败,防止出现中间态

# 支持的方法

目前支持 4 种操作事务流程的方法:

API 说明
startTransaction 发起事务
commit 提交事务
rollback 回滚事务
runTransaction 自动提交事务

目前支持事务中 5 种读写数据的方法:

API 说明
get 查询文档
add 插入文档
delete 删除文档
update 更新文档
set 更新文档,文档不存在时,会自动创建

您可以前往 数据库事务,查看更详细的 API 说明。

# 使用案例

为了帮助您快速体会到数据库事务的重要性和便捷性,这里以“清空购物车”的需求为例,介绍数据库事务在复杂业务场景中的使用。

假设商品数据放在了 goods 集合中,如下所示:

[
  {
    "_id": "item1",
    "inventory": 20,
    "name": "商品a",
    "price": 10
  },
  {
    "_id": "item2",
    "inventory": 10,
    "name": "商品b",
    "price": 5
  }
]

用户的数据放在了 users 集合中,如下所示:

[
  {
    "_id": "user1",
    "balance": "1000", // 账户余额
    "cart": [
      // 购物车
      {
        "id": "item1", // 商品id
        "num": 1 // 购买数量
      },
      {
        "id": "item2",
        "num": 1
      }
    ],
    "name": "用户1"
  }
]

当用户 1 清空购物车时,业务的整体流程是:

  • 计算购物车中的商品总价
  • 减少对应商品的库存
  • 更新用户 1 的账户余额
  • 清空用户 1 的购物车数据

我们将这些操作放入一个事务中执行,代码实现如下:

// Node.js 环境
const tcb = require('@cloudbase/node-sdk')

const app = tcb.init({
  env: 'xxxx'
})

const db = app.database()

async function main() {
  const userId = '12345'
  const transaction = await db.startTransaction()
  const usersCollection = transaction.collection('users')
  const goodsCollection = transaction.collection('goods')

  // 1. 获取用户信息
  const user = await usersCollection.doc(userId).get()

  // 2. 获取购物车数据和对应的商品信息
  const { cart, balance } = user.data
  const goods = []
  for (const { id } of cart) {
    const good = await goodsCollection.doc(id).get()
    goods.push(good.data)
  }

  let totalPrice = 0
  for (let i = 0; i < cart.length; ++i) {
    // 3. 计算购物车中的商品总价
    totalPrice += cart[i].num * goods[i].price
    // 4. 更新商品库存
    await goodsCollection.doc(goods[i]._id).set({
      inventory: goods[i].inventory - cart[i].num
    })
  }

  await usersCollection.doc(userId).set({
    balance: balance - totalPrice, // 5. 更新账户余额
    cart: [] // 6. 完成购买后,清空购物车
  })

  await transaction.commit()
}

注意

为了更简洁地体现事务在复杂业务场景中的优势,案例中没有对库存、余额等信息进行额外的代码检查。

从“清空购物车”的案例中可以看出,数据库事务极大地节省了开发的成本,避免引入复杂的数据库设计,让开发者的精力更聚焦于当前业务。

# 原理介绍

本小节会介绍数据库事务的底层原理,以加深您对数据库事务的理解,更好地使用数据库事务。

# 快照隔离

在调用 db.startTransaction() 开始事务之后,并没有立即生成一份“快照”,“快照”是在第一次读之后才会生成。在没有调用 transaction.commit() 提交事务前,所有的读写操作都是在“快照”上进行,不会影响文档原本的数据。在成功提交事务后,“快照”上的数据才会落盘,相关文档数据完成更新。

假设对于商品 A 来说,它的库存还有 13 个:

{
    "id": "xxxxxx",
    "name: "商品A",
    "inventory": 13
}

如果消费者发起了购买商品 A 的事务,在购买事务未成功提交前,所有的变更都是在快照上进行,不会影响商品 A 的数据,所以其他消费者看到的商品 A 的库存依然是 13。

# 锁与写冲突

当事务修改文档时,会锁定相关文档,使其不受其他更改的影响,直到事务结束。因此,外部的普通写入,会被阻塞。

如果一个事务无法获取到试图修改的文档的锁,可能是因为另一个事务已经持有该锁,那么事务会终止,并出现写冲突。

注意

读取文档的操作不需要与文档修改相同的锁。这意味着即使当前事务对某个文档进行了未提交的写操作,其他事务仍然可以读取这个文档的内容。

根据“快照隔离”,读取的文档内容是文档未提交的状态。

因此,为了使代码更健壮,推荐在进行事务操作时,使用 try-catch 来捕获异常。代码示例如下:

    # 最佳实践

    • 为了更好地使用事务,开发者应该遵循几种最佳实践。
    • 避免创建长时间运行的事务,或者执行过多操作。因为事务会创建快照,所有的后续写入操作都会在缓存中积累,直到事务提交或者终止。当一个事务中的操作过多时,可能会影响数据库的性能。当事务运行时间过长(通常指的是超过 30s),事务可能会被自动终止。推荐将事务拆分成更小的事务,以防止这些情况的发生。
    • 避免在进行 DDL 操作时(例如:创建索引、删除数据库),进行事务操作。在 DDL 操作期间,尝试访问相关资源的事务无法获得锁,从而导致新事务终止。
    • 在进行使用云开发提供的 sdk 操作事务时,推荐配合 try-catch 来捕获异常,从而尽早发现和处理写冲突、网络异常等问题。