egg-sequelize以及事务相关

Posted by Yeoman on 2018-11-19

emmm,其实有一些标题党。这篇文章主要是记录一下事务的一些核心概念,为了强行和Eggj.js搭上一点边,也顺便聊下egg-sequelize的简单使用体验。

0. 写在前面

Egg.js目前我还是停留在使用阶段,感觉在实践上更深入一些后,再去看源码相关效果会好一些。(懒

最近正好在看Mysql的东西,也对egg-sequelize有一些简单的实践,就以这篇文章记录一下。

1. egg-sequelize目前遇到的一些问题

Q:Sequelize在映射表的时候会自动映射表时会自动带上复数。

这句话是什么意思呢,比如说我们在工程里定义了一个User的model,那么这个model映射到数据库里的表名就是users这张表。

看起来好像也没有什么问题,但是比如说我们在建关系表的时候,我们通常会用两张表加下划线来命名。比如用户和用户组的关系表,模型命名UserGroup。而表命名就会显得比较奇怪,叫user_groups。

因此我在使用Squelize的时候,会打开freezeTableName这个配置,这样在映射的时候就不会自动带上复数了。

Q:数据库字段命名通常习惯用下划线命名,而JS的命名习惯是驼峰。

我们可以使用filed别名来进行转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
const GroupModule = app.model.define('group_module', {
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true
},
groupId: {
type: INTEGER,
allowNull: false,
field: 'group_id'
},
}
);
Q:Sequelize的CLI迁移工具在建表的时候不会自动生成组合唯一索引。

组合唯一索引算是一个常见的场景,但是根据文档上的配置去生成表之后,发现并不会带上唯一索引,目前我的解决办法是在提交建表工单的时候,自己在sql语句上加上。

这个问题看起来像是一个bug,不知道是不是我使用姿势不对- -

1
2
3
4
5
// Creating two objects with the same value will throw an error. The unique property can be either a
// boolean, or a string. If you provide the same string for multiple columns, they will form a
// composite unique key.
uniqueOne: { type: Sequelize.STRING, unique: 'compositeIndex' },
uniqueTwo: { type: Sequelize.INTEGER, unique: 'compositeIndex' },
Q:DBA通常不让使用外键,但是Sequelize在关联表查询的时候通常需要外键。

一般情况下不使用外键已经成了大家的一个共识,因为会带来一些性能问题。

当我们使用Sequelize进行关联表查询的时候,Sequelize要求我们使用belongsTo,hasMany等语句来进行表关联。然后才可以用include属性来达到join的查询效果。

但是当我们声明了关联关系时候,这时候去调用app.model.sync的时候,会发现Sequelize会自动给相应的字段加上外键。

我的处理办法是建表的时候不使用外键,然后只是在Model层声明表的关联关系。这样同样可以实现关联表查询的效果。

实际上,在Sequelize中声明了关联关系之后,Sequelize在生成join语句的时候,会自动带上类似user.id = user_group.user_id这样的语句。

Q:当事务在多个函数中或者service传递时,比较麻烦

我目前使用事务的场景其实比较简单,但是这个痛点想想感觉也挺明显的,在CNode上有一篇文章讲这个痛点的解决方案,我觉得挺值得借鉴的。

Q:使用体验

ORM框架的优势不用多说了,可以让我们以一种更面向对象的方式来操作数据库,但是缺点也是很明显的,不是那么灵活。

特别是在不熟悉一个ORM框架的时候,比如一个中间表关联查询,写sql语句只用2分钟,但是要摸索ORM中的写法可能需要半个小时。

2. 具体聊聊事务

第一部分记录了一些在使用Sequelize框架过程中遇到的一些问题,然后这部分会详细聊聊事务。

Sequelize文档对于事务的介绍其实比较简单,就是介绍了一下事务的使用以及设置,这部分我们来深入了解一下事务。

首先我们得明确一个概念,事务是数据库引擎来实现的,而Mysql支持支持几种基本的数据库引擎,最常见的是MyISAM和InnoDB,前者不支持明确的事务管理,而我们最常用的InnoDB是支持的。

2.1 绕不开的ACID

谈事务几乎一定绕不开ACID这个概念,ACID这几个属性也非常好的诠释了事务的作用,我们通过一些实际的场景来看这几个概念。

2.1.1 原子性(Atomicity)

通常我们讲原子操作是在讲多线程并发场景是会去提的一个概念,但是ACID中去描述这个场景是在I(隔离性)中,这里的A指的是另一回事。

比如说我们执行一个转账的操作,我们现在用户A的账户上扣100块钱,然后在用户B的账户上加100块钱,这是两条UPDATE语句。那么如果当A的账户上扣除100块成功之后,忽然发生网络异常或者服务故障,导致用户B的账户没有被加上100块钱,这时候系统就出错了。

事务的原子性就是防止这种错误的,简单起见,我们假设这个转账操作是在一个事务里完成的,那么如果在第二条sql执行失败之后,事务可以自动或者手动地进行回滚,也就是把第一条扣钱的操作回滚掉。当然,实际场景中也可以考虑失败重试的策略,但是这不是事务的范畴。

事务的原子性就是保证了在错误时中止事务,丢弃该事务之前进行的所有写入的变更。(事务不会回滚CREATE操作和DROP操作,实际上我们也不会这么做)

2.1.2 一致性(Consistency)

一致性描述了事务在执行前后数据都具有完整性,这个概念比较宽泛。比如转账这个操作,不能出现只扣钱不加钱,这是业务上要保证的数据一致性。

2.1.3 持久性(Durability)

先讲持久性,因为这个概念比较简单,持久性就是保证了当一个事务被Commit完成,写入的数据一定不会丢失。当然这里的数据不丢失不包含硬盘和备份损坏的情况。

2.1.4 隔离性(Isolation)

这里的隔离指的是多个事务之间的隔离。

我们假设这样一种场景,当我们在A事务中执行了操作-更新所有有VIP标志用户的用户的贷款额度为10000,这时候在事务B中,我们把用户小王更新成了VIP。那么事务A中小王的贷款额度的贷款额度到底有没有被更新成10000呢?隔离性就是来解决这种并发的场景。

这里涉及到一个概念,事务的隔离级别

2.2 事务隔离级别

在Squelize的文档里,我们可以去设置事务的隔离级别,一共有四种:

1
2
3
4
Sequelize.Transaction.ISOLATION_LEVELS.READ_UNCOMMITTED // "READ UNCOMMITTED"
Sequelize.Transaction.ISOLATION_LEVELS.READ_COMMITTED // "READ COMMITTED"
Sequelize.Transaction.ISOLATION_LEVELS.REPEATABLE_READ // "REPEATABLE READ"
Sequelize.Transaction.ISOLATION_LEVELS.SERIALIZABLE // "SERIALIZABLE"

我们来看一个例子(事务A和事务B并发执行),分别了解下这四种隔离级别到底是什么意思呢?

  • 事务A:
1
2
3
4
5
6

START TRANSACTION;

SELECT * from order where id = 1;

/* payed: 0 */
  • 事务B:
1
2
3
4

START TRANSACTION;

UPDATE order SET payed = 1 where id = 1;
2.1 读未提交(READ_UNCOMMITTED)

在READ_UNCOMMITTED的隔离级别下,如果在事务B还没有COMMIT的情况下,事务A在事务B执行完UPDATE语句并且未提交的时候,先后两次读到的订单支付状态是相反的。第一次是未支付,第二次是已支付。这种情况被称之为脏读(dirty reads);

实际情况上,我们是不允许出现脏读的,因为事务B很可能会回滚掉这个操作,那么就会导致事务A的操作出现业务错误。

2.2 读已提交(READ_COMMITTED)

为了解决脏读的问题,我们把隔离级别上升一个等级,叫读已提交。

从名字上就非常容易理解了,在这种隔离级别下,当事务B在写数据的时候,会给这一行数据加上排他锁,这么在事务A中既不能读也不能写这一行的数据。直到事务B提交或者中止,事务A才读取到这一行的数据。

虽然加了排他锁可以防止其他事务的读写,但是还会出现一种情况,就是当事务A的读取已经发生的进行过程中,在事务B中写了同一行记录,这样,同样对出现脏读的现象。因此,要解决这个现象,数据库在读操作的时候都会加上临时共享锁(一旦读完就释放共享锁)。

虽然这种隔离级别可以解决脏读脏写的问题,但是还是存在一个问题,就是我们在同一个事务里两次读取到的数据是不一致的,这种现象叫不可重复读。

2.3 可重复读(REPEATABLE_READ)

可重复读要实现的目标也很简单,我们希望在同一个事务内两次读到的数据都是一致的。

实际上要实现这个隔离级别,我们只需要把读已提交这个隔离级别中对读的操作的临时共享锁换成持续共享锁就可以了,也就是说在事务A读取到订单的整个过程中,事务B都不能对这个订单进行修改,直到事务A处理结束。

Mysql的默认事务隔离级别就是可重复读,Sequelize文档中也是一样。但是可重复读就完美了么?答案是否定的,我们假设这样一个场景。

  • 事务A:
1
2
3
4
5
6

START TRANSACTION;

SELECT COUNT(*) from user where id < 10;

/* count: 5 */
  • 事务B:
1
2
3
4

START TRANSACTION;

INSERT INTO `user` (name, age) VALUES('Tom', 8);

当事务B的insert操作执行完毕,在事务A中获取年龄小于10岁的孩子个数就增加了一位,这种现象我们称之为幻读。

也就是说我们虽然通过行级的共享锁和排他锁解决了脏读脏写问题,但是没有办法防止写入。

2.4 可序列化(SERIALIZABLE)

这种隔离级别是最高的,通过行级锁不能解决幻读问题,可以通过表级锁来解决。

但是可序列化也会带来一个很致命的问题,就是性能问题,锁的时间越长,范围越大,并发性就越差,也就会导致性能瓶颈。

因此隔离级别的选择是一个权衡利弊的过程,并不是隔离级别越高越好的。

  1. 上面的描述只是大概讲了各个隔离级别可能实现的原理,但是不同的数据库的实现都有些差异,感兴趣的话还得去看进阶的数据库书籍。
  2. 实际上Mysql的可重复读这个隔离级别通过间隙锁在一定程度解决了幻读的问题。

3. 总结

现在的开发大背景下,全栈开发已经越来越成为一个趋势。在前端往中后台靠的过程中,虽然通常前端自己写的后端业务都会比较简单,也不太有高并发和分布式这些场景。但是如果要真正理解服务端的很多概念,还是需要系统性地看书总结,去设想尽可能多的场景,这样才不至于变成全干工程师,而是真正的full stack,共勉。