Java开发 - 数据库中的基本数据结构
前言
常用的数据库有哪些呢?以博主的认知为例,见过最多的就是以下这三种了:
oracle,sql server和mysql
虽然同为数据库,数据结构也是存在着一些细微的差别的,这个我们后面会简单提到,毕竟都是按照sql的统一规范设计的数据库,才有了相同的增删改查功能,所以他们的差别也大不到哪去,这里先提一嘴,这三者的隔离级别不同。
其实本来也想写写sql语句中的一些坑和注意事项的,但想了想,放在数据结构里略微不合适,还是后期专门开一篇讲讲sql语句中踩到的坑和注意事项吧。
锁
锁按照粒度分,可以分为两种:
- 表锁
- 行锁
从名字也不难推测出它们所处的位置。顾名思义,表锁是加在整张表上的,其他的事务将无法在当前事务执行期间访问整张表,行锁是加在单独的某一行上的,其他事务无法在当前事务执行时访问这一行。这也是我们平时所说的线程安全的一部分,只不过是放在数据库上面的。
锁按照类型来分,也可分为两类:
- 共享锁
- 排他锁
共享锁也叫share锁/s锁,既可以给表加,也可以给行加,因为其共享特性,所以加上了共享锁的数据,允许其他事务同时访问,但是绝对不允许其他事务给他再加排他锁,却依然可以允许其他事务加共享锁。多用于读操作。
排他锁有些地方也叫独占锁/x锁,被加上排他锁的数据,既不允许其他事务再加共享锁,也不允许其他事务再加排他锁,同时,在当前事务执行结束前,其他事务无法访问。多用于写操作。
数据库中,我们经常使用的读写sql语句为:
- select....
- insert...
- delete...
- update...
其中,增删改操作默认给操作的行数据加排它锁,select操作默认不加任何锁。
如果需要在查询时加锁,怎么做呢?
查询加共享锁
select...... 所有条件之后 + lock in share mode
查询加排它锁
select...... 所有条件之后 + for update
因为我们操作的都是行数据,所以基本上都是加行锁,除非使用模糊查询,才会加表锁。这个和数据库的存储引擎有关。以mysql为例,mysql从5.5开始,存储引擎变成InnoDB,默认加行锁,之前的引擎默认加表锁。
悲观锁和乐观锁
悲观锁和乐观锁不同于行锁和表锁,他们只是两种思想。
悲观锁:当多线程或者事务并发执行的时候,事务会悲观的认为,在自己访问数据期间,其他事务一定会同时访问,为了安全着想,事务再访问数据时,立即给这条数据加锁以保证数据是线程安全的。这很像是一个对自身保护过度的人,所以由此可见,悲观锁的效率一定是偏低的。排他锁符合这种特性,所以排他锁都是悲观锁。
乐观锁:和悲观锁相反,数据访问期间,事务很乐观,认为在自己访问期间不会有其他的事务访问,也不会有线程安全问题,所以就不加锁。但在实际中这是不存在的,一定会存在同时访问的情况,这时候,乐观锁会通过其他的机制来保证线程的安全,分别是版本号机制或CAS(compare and swap 比较并交换)。
假设我有一张user表:
id | name | age |
1 | 张三 | 20 |
2 | 李四 | 25 |
线程A:查询张三,并修改其年龄为23 1.查询张三数据 2.执行修改 3.提交事务 | 线程B:查询张三,并修改其年龄为24 1.查询张三数据 2.执行修改 3.提交事务 |
若是两个线程同时执行,那么A预期的年龄为23,B预期的年龄为24,不管谁先执行,最后得到的数据,只能是符合其中一个的预期,这样就有问题了。
我们用悲观锁的思想来解决,给行数据加锁,就会变成其中一个先执行,执行完之后另一个线程再执行,这当然可以。但我们想要用乐观锁来做,该怎么做呢?需要使用版本号机制。
版本号机制
依然是这张表,只是多了一个版本号,初始值我们给0.
id | name | age | version |
1 | 张三 | 20 | 0 |
2 | 李四 | 25 | 0 |
两条线程一起执行
线程A:查询张三,并修改其年龄为23 1.查询张三数据,version=0 2.执行修改,age=23,version=0 3.提交事务,需要对比版本号是否等于查询时的版本号0,相等,执行成功,version+1=1. | 线程B:查询张三,并修改其年龄为24 1.查询张三数据,version=0 2.执行修改,age=24,version=0 3.提交事务,需要对比版本号等于产巡视的版本号0,此时版本号为1,不等,执行失败。 |
版本号机制在提交事务的时候会对查询时的版本号进行对比,对比相等,提交,执行成功,对比 不想等,执行失败,事务回滚。
失败后,因为乐观锁中有一个自旋机制,在失败后会重新发起修改,此时线程A已经执行完毕,线程B中心执行,此时version=1,查询到version=1,提交时,只要没有其他线程参与,version仍为1,提交成功,此时version=2。如果有其他的线程参与导致此次修改再次失败,自旋不会无限自旋,它有一个设置时间,可自定义。
CAS
CAS其实在理解了版本号机制之后就很好理解了。
还是这张user表:
id | name | age |
1 | 张三 | 20 |
2 | 李四 | 25 |
还是这两个线程:
线程A:查询张三,并修改其年龄为23 1.查询张三数据 2.执行修改 3.提交事务 | 线程B:查询张三,并修改其年龄为24 1.查询张三数据 2.执行修改 3.提交事务 |
俩线程执行完之后,必然会有其中一个的值被覆盖,这就产生了线程安全问题。在版本号机制中,我们比较的是version,但是在CAS中,我们比较的是值本身,如果修改的时候值和查询时的值不一样,那么进行回滚,自旋机制启动,这时候就和version机制是一样的了。
这样一看,版本号机制和CAS是不是很相似呢?只是比较的对象不一样。
现在,你理解这两种机制了么?
在这里给大家留一个问题,悲观锁和乐观锁哪一个用的多?大家可以评论区讨论下并说说具体原因?
事务
什么是事务?
事务是数据库中执行操作的最小执行单元,不可再分,要么全都执行成功,要么全都执行失败。
事务的特性
- 原子性
- 事务是数据库的逻辑工作单位,原子性是指事务包含的所有操作要么全部成功,要么全部失败。
- 一致性
- 一致性是指事务必须使数据库从一个一致性状态变换到另一个一致性状态,也就是说一个事务执行之前和执行之后都必须处于一致性状态。一致性与原子性是密切相关的。
- 隔离性
- 一个事务的执行不能被其他事务干扰
- 持久性
- 一个事务一旦提交,它对数据库中数据的改变就应该是永久性的
在前面,我们已经接触到了一些特性,比如原子性,其他的特性也都是相关的联的,不可能单个存在。
数据库中的事务
数据库中的事务提交默认开启,可通过命令查询状态,关闭或者开启:
查询命令
show variables like 'autocommit'
关闭命令
set autocommit=off;
开启命令
set autocommit=on;
事务的管理
开启事务:beigin
提交事务:commit
回滚事务:rollback
尤其需要注意的是,增删改默认开启数据库事务,select不涉及任何事务。
在微服务中,当涉及到多步增删改的操作步骤时,一般我们使用dubbo调用其他模块数据库操作时会用到事务,以保证所有的操作要么一起成功,要么一起失败。此时就会为整个业务实现方法增加事务的注解。
数据库中的死锁
死锁在数据访问安全时时有发生,数据库也是数据访问的一种,虽然数据库提供了一些锁机制来保证线程安全,但,有人的地方就有江湖,数据库也无可避免的就会出现死锁的现象。
下面,我们举个例子来说明数据库中的死锁:
用户表:user1
| |||||||||||
|
|
| |||||||||
用户表:user2
|
我们可以想象一下,这俩操作会怎么样,上面我们说过,增删改都会增加排他锁,所以第一步操作都可以执行完成,且添加了排他锁,那么在第二步操作的时候,由于排他锁的存在,需要等待排他锁的释放,会导致事务A和事务B相互等待,无法继续执行,死锁产生。
那么出现死锁之后,数据库是怎么解决的呢?
在实际操作用会出现两种情况,一种是提示死锁,另一种是提示锁等待超时,这是因为数据库版本不一致的问题。
在mariadb中,对死锁是这么处理的:当检测到死锁后,让一端的事务回滚,并提示死锁DeadLock,接着让另一端的事务执行成功,一般是让后执行的事务进行回滚,回滚之后的事务执行失败,并且不会再次主动发起自旋执行。
数据库中的视图
什么是视图?
数据库中的视图是一张虚拟表,用于展示结果集,它并不保存数据,而是从已经存在的表中调取数据,后期如果要执行相同的sql,可以直接调用视图名称。听起来有点像我们对方法的封装,这里是对数据库sql语句的封装,只不过数据库没有方法的概念。
使用场景:我们要多次展示数据库中某张表或几张表中相同的数据,一样的sql语句需要多次使用,这时候,就可以为这个sql创建视图,在需要的地方直接调用视图,从视图中获取数据,简化sql语句。
操作视图:
创建视图:
create view view_name as select xx from xxxx_tb where xxxxxx;
也可以进行连表操作。
调用视图:
select xxx from view_name;
删除视图:
drop view view_name;
你会发现视图的操作和数据库的操作是一样的。
视图的注意事项
- 视图是对sql语句的封装,不是对查询结果集的封装,视图的存在并不存提高任何查询的效率,只是简化了sql语句;
- 视图一般只用于查询,不对数据进行写操作,所以不应该对视图进行写操作,但是数据库允许这么做。
- 视图来源于单表
- insert操作 成功
- delete操作 成功
- update操作 成功
- 视图来源于多表连查
- insert操作 失败
- delete操作 失败
- update操作
- 修改一张表中数据 成功
- 修改多张表中数据 失败
- 视图来源于单表
- 视图中不保存真是的数据,而是来源于真实的表中,所以,当真是表中的数据发生变化,视图数据也会发生变化。
事务隔离级别
事务的隔离级别有四种:
- read uncommitted 读未提交
- read committed 读已提交
- repeatable read 可重复读
- serializable 可串行化
读未提交
事务的结束表现为两种情况,一种是事务提交,另一种是事务的回滚,读未提交即表现在事务可以读取这两种状态下的数据,从而产生脏读。
什么是脏读?
脏读就是事务读取到了其他事务未提交或未回滚之后的内容,导致最终读取到的数据不存在,这就叫脏读。
我们想想这种情况怎么发生?
时刻 | 事务A | 事务B |
t1 | begin | begin |
t2 | 查询user.name='张三' | |
t3 | 修改user.name='李四' | |
t4 | 查询到user.name='李四' | |
t5 | 由于某些原因导致事务B失败,rollback | |
t6 | commit,此时表中user.name实际为'张三',读到的为'李四' | |
... |
很明显,脏读这种情况不应该出现,所以读未提交这种隔离级别不应该被使用,事实上,也没有数据库使用读未提交的隔离级别。要解决这种情况,产生了另一种隔离级别:读已提交。
读已提交
读已提交的出现就是为了解决读未提交的情况,事务职能读取到其他事务提交后或回滚后的内容,解决了脏读问题,这里应该有掌声👏👏。
不过问题来了,这又产生了新的问题:不可重复读!!!
不可重复读是什么?看下表
时刻 | 事务A | 事务B |
t1 | begin | begin |
t2 | 查询user.name='张三' | |
t3 | 修改user.name='李四' | |
t4 | commit | |
t5 | 再次查询user.name='李四' | |
t6 | commit | |
... |
两次查询发现查询结果不一样?那么是不是有问题,可能我们看着觉得没错,假设两次分别查询的是银行卡余额,第一次100w,第二次0,我只是查了两次,什么都没干,钱没了,那么你觉得问题大不大? 要不要报警?
在Java中,这就是并发线程的安全问题,所以我们也要避免事务访问期间,其他事务对我们访问的数据进行修改。
根据我们前面学到的内容,我们可以给这条数据加上排他锁,这就解决了这个问题,相信大家已经学会了,此时将产生新的隔离级别:可重复读!
可重复读
可重复读这个隔离级别完美吗?看到这里显然没有结束,所以它一定是不完美的,没错,新的问题又产生了:幻读!
幻读是什么?我们用下面这张表来解读下:
时刻 | 事务A | 事务B |
t1 | begin | begin |
t2 | 查询user表所有数据=2条 | |
t3 | 添加一个新用户 | |
t4 | commit | |
t5 | 再次查询useruser表中的数据=3条 | |
t6 | commit | |
... |
还以查询银行卡为例,第一次查100w,第二次查0,这是不正常的,因为事务A没有做任何处理。这和不可重复读很相似,但两者也有一定的区别,不可重复读是对数据进行修改,幻读是对数据库进行添加操作,两者都是前后查询到的结果不一致导致的。
我们考虑下幻读应不应该存在? 这种情况可以存在,我认为合法,但是我就需要两次读取到的数据保持一致,该怎么办?给表加排他锁!此时,两次查询结果一致。幻读问题解决!
加了锁,新的隔离级别也就产生了:可串行化!
可串行化
可串行化解决了幻读的问题,但是由于需要给整张表加锁,这样,数据的访问效率就大大降低,现在做开发,谁不使用并发的?所以实际开发中可串行化并不常用,只有在执行一些安全性要求极高的操作时才会使用这一等级。
用一张表来表示他们之间的关系:
隔离等级 | 产生脏读 | 产生不可重复读 | 产生幻读 |
读未提交 | true | true | true |
读已提交 | false | true | true |
可重复读 | false | false | true |
可串行化 | false | false | false |
隔离等级的优先级
和我上面给出的顺序是一致的,优先级由低到高分别是:读未提交-->读已提交-->可重复读-->可串行化。就像道生一,一生二,二生三,三生万物。
数据库使用的隔离级别
oracle和sql server 默认的隔离级别为读已提交。
mysql的 默认隔离级别为可重复读。
MVCC
MVCC是多版本并发控制,是用来实现可重复读的。它解决了并发安全的问题,大大提高了并发执行效率,优于加锁。
MVCC的实现分为三部分:
- undolog ,用于记录一些信息,我们下面会说到;
- mysql中的每张表里隐藏的三个字段
- row_id,Innodb引擎提供的隐藏主键,表中没有主键时自动创建,从1开始,自增;
- DB_trx_id,表示最后操作这条数据的事务的id;
- DB_roll_ptr,表示回滚指针,当前事务操作变更数据时,如果失败,需要回滚到之前的那条数据,这里保存的就是操作前数据的地址。
- ReadView
隐藏字段作用
下面,我们通过下面的表来表示三个隐藏字段工作的原理:
用户表user,事务A执行插入张三数据之后:
id | name | age | db_trx_id | db_roll_ptr |
1 | 张三 | 20 | 1 | null(刚插入,无回滚地址) |
用户表user,事务B执行修改张三年龄数据之后:
id | name | age | db_trx_id | db_roll_ptr |
1 | 张三 | 30 | 2 | 0x10010001(假设这是张三之前的地址) |
undolog:
事务A执行之后undolog内产生一条新纪录
id | name | age | db_trx_id | db_roll_ptr |
1 | 张三 | 20 | 1 | null |
事务B执行之后undolog内产生一条新纪录
id | name | age | db_trx_id | db_roll_ptr |
1 | 张三 | 30 | 2 | 0x10010001 |
如果还有其他的事务操作这条数据,以此类推即可。
ReadView
在事务执行的时候,就会生成当前事务的ReadView,用于保存当前事务之前活跃的所有事务id,之前活跃的事务的最小id,当前事务结束后即将分配的下一个id,创建ReadView的当前事务id,名字分别如下:
- m_ids: 截止到当前事务id之前,所有的活跃事务id
- min_trx_id: 记录以上活跃事务id中的最小id值
- max_trx_id: 保存当前事务结束后应分配的下一个id值
- creator_trx_id: 保存创建ReadView的当前事务的id
我们用几张表来表示下三者结合的工作过程:
给出一个事务操作后形成的用户表:
事务1插入张三
事务2插入李四
id | name | age | db_trx_id | db_roll_ptr |
1 | 张三 | 20 | 1 | null |
2 | 李四 | 25 | 2 | null |
模拟并发事务的工作过程:
时刻 | 事务A id:8 | 事务B id:9 |
t1 | begin | |
t2 | beigin | |
t3 | 查询张三的年龄为20 | |
t4 | 修改张三的年龄为30 | |
t5 | commit | |
t6 | 再次查询张三的age,MVCC下应为20 | |
t7 | commit |
事务AB工作时会分别生成自己的ReadView:
事务A的ReadView:
m_ids | min_trx_id | max_trx_id | creator_trx_id |
假设为3,4,5,6 | 3 | 9(下一个应分配事务id) | 8(当前事务id) |
所有的select不加锁,所以都是执行的快照读,所以后面的事务可以从undolog中读取到之前的事务执行的状态,所以在做查询时必须参考之前的快照。
现在开始分析事务8和9每个时刻的操作。
在t3时刻,查询张三的年龄时:
先去看这条数据是否在m_ids中,若果在,则处于活跃状态,说明这条数据还没提交,则不能访问,若不在,说明在当前事务之前已经提交,则可以访问,接着去查找创建这条数据的事务id是否小于当前事务id,如果小于,那一定是在当前事务之前已经执行完的事务,就可以读取到这条数据,否则,还未执行,不可访问。
上面这段话比较绕,但说的比较详细,如果理解的话,那么事务9的执行过程就很清晰了。
在t4时刻,修改用户表的年龄为30:
undolog产生第一条快照数据
id | name | age | db_trx_id | db_roll_ptr |
1 | 张三 | 20 | 1 | null |
事务9执行修改操作之后,用户表应为:
id | name | age | db_trx_id | db_roll_ptr |
1 | 张三 | 30 | 9 | 0x10010001 |
2 | 李四 | 25 | 2 | null |
张三产生回滚地址,当前事务id变化。
t5时刻提交:
事务9生效。
t6时刻再次查询张三的age:
此时拿最后一次执行的事务id-9去ReadView中去m_ids里找,找得到说明时活跃的,没有提交,活跃时不可访问,如果和creator_trx_id比较,相等,说明是自己的操作,可以访问,否则无法访问。如果不在m_ids,说明不再活跃,已提交,不再和当前创建事务id做比较,去和max_trx_id对比,如果db_trx_id大于等于max_trx_id,说明查询的数据在当前事务之后发生改变,无法访问,此时需要通过undolog快照去查找db_trx_id为当前事务id的那条数据,根据undolog表可知,张三的age为20。
这就是MVCC的实现过程,下面用文字来描述下隔离级别的实现:
事务访问数据库时,先判断trx_id是否在m_ids里面
如果在,说明事务是活跃的,继续判断trx_id于ReadView中createor_trx_id的关系
相等,说明当前事务再访问自己的操作,可以访问;
不等,说明当前事务访问的是其他活跃的未提交事务的数据,无法访问。
如果不存在于m_ids中,继续判断trx_id与ReadView中的max_trx_id的关系
若trx_id>=max_trx_id,说明访问的最新的数据是在当前事务后面的操作,无法访问
若trx_id<max_trx_id,说明访问的最新数据是当前事务之前已提交的数据,可以访问
结语
就写到这里吧,关于数据库的数据结构其实还有一些,比如索引,索引的底层B+tree,不过这些东西也很多,准备单独分出来说,咱们下一篇再见。码字不易,觉得还不错,就给个赞吧!