做小程序最怕的不是页面写不出来,而是数据悄悄算错了。典型的表现是:一共只有100件库存,卖完之后订单却有120个;一个用户手速快,双击两次领红包按钮,钱包里多了一笔钱。这类问题在开发环境几乎复现不出来,一上线人数一多就集中爆发。归根结底,是多个人同时读写同一条数据时没有做并发控制。微信小程序云开发提供的数据库事务,正是解决这类问题的官方方案。这篇文章用库存扣减和红包领取两个真实场景,把事务的用法和踩坑点讲清楚。

为什么普通更新操作会出问题
先看一段没有事务的扣库存代码,这是很多初学者的写法:
// 云函数中执行
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
exports.main = async (event, context) => {
const { goodsId, userId } = event
const goods = await db.collection('goods').doc(goodsId).get()
if (goods.data.stock > 0) {
await db.collection('goods').doc(goodsId).update({
data: { stock: goods.data.stock - 1 }
})
await db.collection('orders').add({
data: { goodsId, userId, createdAt: Date.now() }
})
return { code: 0, msg: '下单成功' }
}
return { code: 1, msg: '已售罄' }
}这段代码的逻辑看起来完全正确:先读库存,判断大于0再减一。问题出在读取和写入之间存在时间差。假设库存只剩最后1件,A、B两个用户几乎同时下单,A读到stock为1,B在A写入之前也读到了stock为1,两人都通过了判断,各自执行了一次update,订单就多出来了。这就是经典的读改写竞态,只要两个请求的间隔小于一次数据库往返的耗时,就会触发。
有人会说,我用db.command.inc(-1)做原子自减不就行了?原子操作确实能避免减成负数,配合where条件过滤stock大于0,也能实现防超卖。但原子操作只能覆盖单条写操作,无法保证读库存、扣库存、写订单这三步的原子性。如果扣库存成功后,写订单那一步因为网络原因失败了,库存少了订单却没生成,数据就永远对不上了。事务的价值就在这里:要么三步全部成功,要么全部回滚,不存在中间状态。
用事务实现库存扣减
云开发数据库的事务API以db.startTransaction()为入口,返回一个事务实例,之后所有的读写都通过这个实例发起,最后调用commit提交或rollback回滚。下面是改造后的扣库存代码:
exports.main = async (event, context) => {
const { goodsId, userId } = event
const transaction = await db.startTransaction()
try {
// 在事务内读取文档,会持有快照,其他事务无法并发修改同一文档
const goodsRes = await transaction
.collection('goods')
.doc(goodsId)
.get()
if (goodsRes.data.stock <= 0) {
await transaction.rollback()
return { code: 1, msg: '已售罄' }
}
// 扣减库存
await transaction.collection('goods').doc(goodsId).update({
data: { stock: goodsRes.data.stock - 1 }
})
// 写入订单
await transaction.collection('orders').add({
data: { goodsId, userId, createdAt: Date.now() }
})
await transaction.commit()
return { code: 0, msg: '下单成功' }
} catch (err) {
// 任何一步出错,全部回滚
await transaction.rollback()
return { code: -1, msg: '事务失败', detail: err }
}
}这段代码的关键点有三个。第一,读取必须使用transaction.collection().doc().get()而不是db.collection(),后者读到的是普通快照,不参与事务的并发控制,等于白开事务。第二,事务内不支持db.command里的自增自减等操作符,所以这里直接用读到的值减一后写入,安全性由事务的快照隔离保证。第三,rollback和commit必须成对出现,任何一个分支漏掉rollback,事务会一直挂到超时,占用数据库资源。
还需要注意事务的几个限制:事务只能运行在云函数端,小程序端不能直接开启事务;单个事务的执行时长和涉及文档数都有上限,不要把批量大批量数据的处理塞进一个事务;事务中的操作必须串行执行,不要在事务内部使用Promise.all并发提交多个读写,会导致事务冲突。如果业务允许,尽量把事务拆小,只锁真正需要保持一致性的那几条文档。
红包领取场景:防重复领取
红包场景和库存的区别在于,除了要防止总额超发,还要防止同一个用户重复领取。也就是说,事务内要做两层校验:红包剩余金额是否足够,以及领取记录表里是否已存在该用户的记录。实现思路是给每个红包拆成若干随机金额的子项,用户领取时从池子里取一个,并在领取记录表插入一条唯一约束的数据。
exports.main = async (event, context) => {
const { redPacketId, userId } = event
const transaction = await db.startTransaction()
try {
// 读取红包主记录,锁定剩余个数
const packet = await transaction
.collection('red_packets')
.doc(redPacketId)
.get()
if (packet.data.remainCount <= 0) {
await transaction.rollback()
return { code: 1, msg: '红包已被抢完' }
}
// 检查是否重复领取
const existed = await transaction
.collection('red_packet_records')
.where({ redPacketId, userId })
.get()
if (existed.data.length > 0) {
await transaction.rollback()
return { code: 2, msg: '您已领取过该红包' }
}
// 从红包池中取出一个子项
const pool = await transaction
.collection('red_packet_pool')
.where({ redPacketId, taken: false })
.limit(1)
.get()
if (pool.data.length === 0) {
await transaction.rollback()
return { code: 1, msg: '红包已被抢完' }
}
const item = pool.data[0]
await transaction.collection('red_packet_pool').doc(item._id).update({
data: { taken: true, takenBy: userId, takenAt: Date.now() }
})
await transaction.collection('red_packet_records').add({
data: {
redPacketId, userId,
amount: item.amount,
createdAt: Date.now()
}
})
await transaction.collection('red_packets').doc(redPacketId).update({
data: { remainCount: packet.data.remainCount - 1 }
})
await transaction.commit()
return { code: 0, amount: item.amount, msg: '领取成功' }
} catch (err) {
await transaction.rollback()
return { code: -1, msg: '领取失败', detail: err }
}
}这段代码把防重复领取的判断也放进了事务里,这一点非常重要。如果先在事务外查一次领取记录再开事务,两个并发请求可能同时通过检查,各自插入一条记录,重复领取就发生了。判断和写入必须在同一个事务里,快照隔离才能发挥作用。
另一个实践建议是预拆分金额。拼手气红包不要在领取时实时计算随机金额,而是在发红包时就把总金额拆成N份写入池子。这样领取事务只需要标记和记账,不需要做浮点数运算和金额校验,既避免了小数精度问题,也让事务执行时间更短,冲突概率更低。金额存储建议用分为单位的整数,不要用浮点类型,否则对账时会出现几厘钱的差额。
事务之外的选择与取舍
事务不是唯一的方案,也不总是最优方案。云开发数据库还提供了条件更新这种乐观锁思路:在update时用where带上版本号或库存条件,影响行数为0说明并发冲突,客户端重试。这种方式不用长时间持有锁,吞吐量更高,但业务代码要自己处理重试逻辑,适合冲突概率低的场景。
// 乐观锁风格的库存扣减,适合读多写少场景
const _ = db.command
const res = await db.collection('goods')
.where({ _id: goodsId, stock: _.gt(0) })
.update({ data: { stock: _.inc(-1) } })
if (res.stats.updated === 1) {
// 扣减成功,继续写订单
await db.collection('orders').add({ data: { goodsId, userId, createdAt: Date.now() } })
} else {
return { code: 1, msg: '已售罄或并发冲突' }
}两种方式的边界可以这样划分:单条文档的原子扣减,条件更新就够了;涉及多个集合、需要强一致的业务,比如扣库存加写订单、领红包加记账,必须用事务;超高并发的秒杀活动,可以再加一层云函数内的内存预扣减或者用消息队列削峰,把数据库压力控制在可承受范围内。
最后提醒一点,事务不能包住所有错误。事务保证的是数据一致性,不保证业务正确性。比如用户余额不足、商品下架这类业务判断,应该在开事务之前先做一遍粗校验,减少不必要的事务开销。事务开启后执行的时间越短越好,事务内不要调用外部接口、不要做复杂计算,只做纯数据库读写。掌握了这些原则,库存、红包、余额转账这类并发写场景就都能稳妥地落地了。