跳到主要内容

数据库事务

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

使用场景#

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

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

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

目前数据库事务只支持在服务端运行,只有服务端 SDK 支持事务。

支持的方法#

目前支持 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 cloudbase = require('@cloudbase/node-sdk')
const app = cloudbase.init({})
const db = app.database()
exports.main = async (event, context) => {  const userId = 'user1'  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()
  // 从数据库中查询最新的用户,库存信息  const usersInfo = await db.collection('users').get()  const goodsInfo = await db.collection('goods').get()  return {    usersInfo,    goodsInfo  }}
注意

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

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

原理介绍#

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

快照隔离#

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

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

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

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

锁与写冲突#

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

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

注意

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

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

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

const cloudbase = require("@cloudbase/node-sdk");
const app = cloudbase.init({  env: "xxx"});// 1. 获取数据库引用const db = app.database();
// 2. 模拟事务操作async function main() {  try {    const transaction = await db.startTransaction();    // ... ...    // 涉及文档更改的事务操作    // ... ...    await transaction.commit();  } catch (error) {    // 发生写冲突时,进行异常处理    console.error(error.message);  }}

最佳实践#

为了更好地使用事务,开发者应该遵循几种最佳实践:

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