nest-typeorm之事务

1,107 阅读6分钟

事务简介

事务是数据库开发中不可或缺的存在,其能够保证多个操作的数据保持一致,即:要成功一起成功,要失败一起失败回到解放前

事务有四个特性:原子性、一致性、隔离性、持久性

  • 原子性: 即把多个操作看成一个整体
  • 一致性: 让数据库从一个一致性状态,变化成另一个一致性状态
  • 隔离性: 每个事务相互隔离运行,感觉不到其他事务的存在,隔离也有四个级别:
    1.读未提交(Read Uncommitted):允许事务读取未被其他事务提交的数据,可能导致脏读、不可重复读和幻读问题。
    2.读已提交(Read Committed):确保事务只能读取已经提交的数据,避免了脏读问题,但可能遇到不可重复读和幻读问题。
    3.可重复读(Repeatable Read):在同一个事务中多次读取同一数据时,保证数据的一致性,避免了不可重复读问题,但可能遇到幻读问题。
    4.串行化(Serializable):最高隔离级别,通过强制事务串行执行来避免所有并发问题,但可能导致性能下降。
  • 持久性: 一旦提交,数据库会永久发生改变

隔离级别

事务并发过程中会出现一些问题:脏读、不可重复读、幻读,每提升一个隔离级别,都会解决一个问题

读未提交:最低隔离级别,存在上面的所有问题

读已提交:能解决脏读问题

可重复读:解决了不可重复读的问题(mysql、innoDB默认)

串行化:解决了幻读问题

脏读:是指一个事务读取了另一个事务未提交的数据,从而出现的问题,例如:事务a将数据1更改为2,但未提交,事务b读取到了该数据2,但此时数据2失败回滚为1,此时事务b出现了脏读问题

image.png

不可重复读: 是指一个事务读取同一条时,前后读取的的值不一样,例如:事务a读取数据1,事务b也读取数据1,此时事务a将数据1更改为2,并提交,事务b此时读取同一条数据1变成了2,因此出现了同一条数据,前后读取不一致的问题

image.png

幻读:是指同一个事务前后查询一组数据时,前后查询到的行数不一致,不过这种一般比较符合预期,因此不单独设置串行化隔离级别,除非有必要

image.png

死锁(多线程中行锁才会出现)

通过上面也可以看出,有很多策略解决数据读取不正确问题,然而解决的方法主要是阻塞,同一条数据事务操作后会锁上,其他事务想操作,只能等待其提交才可以,然而其在多线程中也会出现另外一个问题,那就是死锁,一个线程只持有一个锁,一个线程请求资源时会先请求锁,此过程并不会释放自己手中的锁,请求不到锁就只能等待,如果两个(多个)线程中,都持有对方需要的锁而不释放,就会产生死锁

案例:两个线程中的事务1、2进行如下操作,如果此事务1锁上一个值A,要读取另外一个中的某个值B,而事务2,锁上这个值B,等待获取A的内容,此时就会出现互相等待的现象,但他们谁也不会放弃这就是死锁

怎么降低死锁:

  • 尽可能提高事务执行效率,减少事务持有锁的时间,例如设置索引等
  • 事务尽可能保持资源获取的顺序一致,避免交叉获取,例如:A事务操作1、2,B事务操作2、1可以的话都调整为1、2或者2、1
  • 降低隔离级别,对于上面出现的问题要求不高的情况可以降低隔离级别,那自然意味着会出现脏读、不可重复等问题
  • 可以检测死锁,可以取消或者重试

ps:我看 mysql 默认会开启死锁检测,当出现死锁时会报错,使用 typeorm测试时,发现死锁后会自动抛出异常(我之前测试了js单线程,并发情况也会出现死锁,只要满足条件就会死锁)

ps2:并不是说能检测死锁就可以随心所欲了,减少死锁率也是可以大幅度提高我们项目运行效率和体验的,不也不想有些操作用户总是失败吧,能够规范避免最好避免掉才是上上策

typeorm 事务

typeorm 中早期的事务用着也很不方便,当我开始了解的时候,就发现了,老文档上面的事务基本上都用不了,然后找了下代码,找到了事务相关的一些内容(再加上参考了 prisma),发现 typeorm 的事务早已经改动得非常方便了,只需要使用我们 任何一个仓库,使用他的 manager 属性,然后调用 transaction 事务功能,即可使用

直接上代码演示一下,就知道怎么用了

// user user2
await this.userRepository.manager.transaction(async (manager) => {
    //这save 可以传递对象,也可以传递数组结果是一样的
    await manager.save(user)
    await manager.save([user, user2, article]); 
});

上面可以发现,这save 可以传递对象,也可以传递数组,那么直接传递数组,不就完事了,可以这样,但是这个方法是通用的(跟prisma的交互式事务很像),有些是有执行顺序的,需要按照依次保存,那这个就很有必要了

//设置隔离级别,mysql 默认是 REPEATABLE READ,即可重复读,一般都是使用这个无需额外设置
//"READ UNCOMMITTED" | "READ COMMITTED" | "REPEATABLE READ" | "SERIALIZABLE"
await this.userRepository.manager.transaction('SERIALIZABLE', async (manager) => {})

案例

给一个测试案例,一看就知道怎么用的

const user = await this.userRepository.findOne({
    where: {
        id: 2,
    },
});
user.age = 21;

const user2 = new User()
user2.account = 'admin5'
user2.nickname = '哈哈哈'
user2.age = 25;

const article = await this.articleRepository.findOne({
    where: {
        id: 17,
    },
});
article.desc = '笑哭😂';
try {
    await this.userRepository.manager.transaction(async (manager) => {
        //假设不需要设置外键,没有顺序,直接写这一步就行
        // await manager.save([user, user2, article]); 
        //假设需要设置userId外键,这个是需要顺序的,那就不是一行解决的了
        const user = await manager.save(user2)
        article.userId = user.id
        await manager.save(article)
        //update、delete、insert
    });
} catch (err) {
    console.log(err);
}

ps:能提前查询的推荐在事务外面查询,避免增加事务执行时长,进而避免阻塞其他操作,提升效率

ps2:如果有些操作有先后顺序,例如:外键需要先创建外键表数据,再关联,可以提前处理好关系,外键关联对象直接赋值entity,这样创建后 entity中就会出现id,外键更新时由于传入关联对象,通过引用关系,可以访问到创建后的主键id,这样就可以在事务中直接先后保存,就可以更新关系了,这样可以避免在事务内处理额外的逻辑,以优化性能