数据库事务
云开发支持数据库事务,并保证事务的 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
来捕获异常。代码示例如下:
- Node.js
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
来捕获异常,从而尽早发现和处理写冲突、网络异常等问题。
注意事项
- 当前事务使用中。仅支持
doc
方法,不支持where
方法。当前事务仅支持单文档。