从线上事故看mongodb事务ACID强弱

在未使用mongodb副本集引入事务能力前,我们来通过一些例子看看mongodb在没有事务的情况下的影响,并再一次从案例去验证强事务的系统是否适合使用mongodb,以及判断在引入副本集后实际的事务能力。

事务的原子性(Atomic)

背景

某一天,突然发现,我们的一个上传excel需求,上传后提示报错,但是数据正常上传成功了。

数据结构如下:文件表 + 订单表,订单表关联文件表ID

文件表

1
2
3
4
5
6
7
8

{
"_id": ObjectId("61ea6ee776d12fd2c261c105"),
"fileName": "2.xlsx",
"creator": ObjectId("5eba176654c70a2bc8df0719"),
"uploadDate": ISODate("2022-01-21T08:29:27.823Z"),
"creatorName": "张云",
}

订单表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"_id": ObjectId("61ea6d78703190d0efca84df"),
"org": "DKWSH",
"contact": "翁文斌",
"salesId": ObjectId("5ef3214dc15bc674d6976365"),
"remark": null,
"items": [
{
"detailId": "MX21305683",
"materialName": "PerCP anti-human CD11c",
"spec": "100 tests",
"itemNum": "337234",
"batchNumber": "B332675",
"amount": NumberInt("6"),
"manufacturer": "Biolegend"
}
],
"file": ObjectId("61ea6ee776d12fd2c261c105"),
}
1
2
3
4
5
6
7
8
9
// 创建文件
const file = await ctx.model.Delivery.File.create(files)

// 创建订单
const orders = await ctx.model.Delivery.VirtualOrder.create(orders)

// 发送通知
const sendRes = await ctx.app.noticeQueue.addBulk(datas)

排查

经过日志排查,发现 创建文件 创建订单都成功了,但是发送通知失败了,错误原因为redis版本过低导致异常无法正常发送。

理论

什么是事务的原子性

  • 一个事务包含多个操作,这些操作要么全都执行,要么全都不执行。
  • 实现事务的原子性,要支持回滚操作,在某个操作失败后,回滚到事务执行前的状态。

结论

我们可以理解为 创建文件 创建订单 发送通知这三个步骤是一个事务,要么全部成功,要们全部不执行,
发送通知失败的时候,我们应当将创建文件 创建订单进行回滚,从而达到 创建文件 创建订单 发送通知这三个步骤都不执行。

事务的隔离性(Isolation)

背景

某一天,销售反馈,我的确认操作无法提交了。

数据表如下:订单表 + 发票表 + 发票池表

订单确认操作过程
1、校验订单是否开过发票发票池
2、创建发票池表数据,而后创建发票表

发票表中invoiceNum具有唯一索引

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 1.校验是否开过发票和发票池

// 2.创建开票池
const invoiceItemOpts = {
org: 'XXX',
order: 'XXX',
detailId: 'XXX',
materialName: 'XXX',
}
const invoiceItems = await ctx.model.Delivery.InvoiceItem.create(
invoiceItemOpts
)

// 3.创建发票
let invoiceOpts = {
invoiceNum: ctx.helper.createInvoiceNum('P', parseInt(next_invoice_num)),
}
const invoice = await ctx.model.Delivery.Invoice.create(invoiceOpts)

排查

经过排查,我们先发现数据库发票编号invoiceNum唯一索引报错了。说明多个请求拿到了同一个invoiceNum发票编号

1
2022-02-21 10:58:17,497 ERROR 30265 [-/116.233.76.38/-/25ms POST /orders/6212ff47fa481876394ee21c/status] error_handler: MongoError: E11000 duplicate key error collection: biocitydb.sys_invoices index: invoiceNum_1 dup key: { invoiceNum: "P2202211321" }

我们继续排查发现一共有2次请求,拿到了同一个invoiceNum发票编号,说明出现了并发问题

第一次请求,销售员王璐成功使用P2202211296发票编号创建了发票,未遇到唯一索引

第二次请求,销售员沈梦婷,因为在几乎同一时刻与销售员王璐发出请求,发票编号未有事务加锁,导致发生了脏读

注意看请求的时间与invoiceNum,发现请求时间几乎同一时刻,相同的发票编号。

1
2
3
4
2022-02-21 10:57:38,020 INFO 30265 发票invoiceOpts {
invoiceNum: 'P2202211296'
saleName: '王璐',
}
1
2
3
4
2022-02-21 10:57:38,022 INFO 30265 发票invoiceOpts {
invoiceNum: 'P2202211296',
saleName: '沈梦婷',
}

并发脏读图解:

T1 王璐 T2 沈梦婷
(1)读发票编号P2202211296
(2)创建发票池 (1)读发票编号P2202211296 -> T1未完成就读取现在的发票编号,导致脏读
(3)创建发票 (2)创建发票池
(4)更新当前发票自增编号 (3)创建发票
(4)更新当前发票自增编号

理论

  • 脏读
    事务A修改了一个数据,但未提交,事务B读到了事务A未提交的更新结果,如果事务A提交失败,事务B读到的就是脏数据。

  • 不可重复读
    同一事务中,对于同一份数据读取到的结果不一致。如事务B在事务A提交前后读取的数据不一致。
    原因:事务并发修改记录。
    解决:加锁。但这会导致锁竞争加剧,影响性能。另一种方法是通过MVCC可以在无锁的情况下,避免不可重复读。

  • 幻读
    同一事务中,同一个查询多次返回的结果不一致。如事务B在事务A提交前后查询到的数据记录变多了。
    原因:并发事务增加记录。
    解决:串行。

事务的隔离级别从低到高有:

  • Read Uncommitted

最低的隔离级别,什么都不需要做,一个事务可以读到另一个事务未提交的结果。所有的并发事务问题都会发生。

  • Read Committed

只有在事务提交后,其更新结果才会被其他事务看见。可以解决脏读问题。

  • Repeated Read

在一个事务中,对于同一份数据的读取结果总是相同的,无论是否有其他事务对这份数据进行操作,以及这个事务是否提交。可以解决脏读、不可重复读

  • Serialization

事务串行化执行,隔离级别最高,牺牲了系统的并发性。可以解决并发事务的所有问题

结论

  • 2个并发请求,导致出现事务的脏读问题,2个并发同时拿到了同一个自增编号(发票编号),mongodb支持的锁机制弱,无法使用悲观锁,虽然乐观锁无法解决脏读,但是可以使用乐观锁+事务回滚。可查看了没有mongodb事务的支持下,我这种思路的解决:分布式锁设计实践
  • 出现脏读问题后,因为数据库有唯一索引,创建失败后,出现多表操作的原子性问题。

事务的一致性(Consistency)

todo

事务的持久性(Durability)

todo