云开发支持数据库事务,并保证事务的 ACID 特性。本文将介绍数据库事务的使用场景、案例、原理以及实践教程,来帮助开发者完成更复杂的业务需求。
使用场景
云开发提供的云数据库是基于文档的非关系型数据库。不同于传统的关系型数据库,开发者可以直接在单文档中嵌套子文档,以描述更复杂的数据结构。在大多数场景中,单文档完全可以满足需求。但在一些场景中,使用数据库事务的优势更明显:
从传统关系型数据库迁移到云开发:数据模型平滑迁移,业务代码改造成本低。
涉及多个文档或多个集合的业务流程:保证一系列读写操作完全成功或者完全失败,防止出现中间态。
说明:
目前数据库事务只支持在服务端运行,只有服务端 SDK 支持事务。
支持的方法
目前支持 4 种操作事务流程的方法:
API | 说明 |
startTransaction | 发起事务 |
commit | 提交事务 |
rollback | 回滚事务 |
runTransaction | 自动提交事务 |
目前支持事务中 5 种读写数据的方法:
API | 说明 |
get | 查询文档 |
add | 插入文档 |
delete | 删除文档 |
update | 更新文档 |
set | 更新文档,文档不存在时,会自动创建 |
使用案例
为了帮助您快速体会到数据库事务的重要性和便捷性,这里以清空购物车的需求为例,介绍数据库事务在复杂业务场景中的使用。
假设商品数据放在了
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. 计算购物车中的商品总价。
2. 减少对应商品的库存。
3. 更新用户 1 的账户余额。
4. 清空用户 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.dataconst goods = []for (const { id } of cart) {const good = await goodsCollection.doc(id).get()goods.push(good.data)}let totalPrice = 0for (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
来捕获异常,从而尽早发现和处理写冲突、网络异常等问题。