事务的概念
事务的概念来自于两个独立的需求:并发数据库访问,系统错误恢复。
一个事务是可以被看作一个单元的一系列SQL语句的集合。
事务的特性(ACID)
- A,atomcity 原子性
事务必须是原子工作单位;对于其数据修改,要么全都执行,要么全都不执行。通常,与某个事务关联的曹祖具有共同的目标,并且是相互依赖的。如果系统只执行这些操作的一个子集,则可能会破坏事务的总体目标。原子性消除了系统处理操作子集的可能性。 - C,consistency 一致性
事务将数据库从一种一致状态转变为下一种一致状态。也就是说,事务在完成时,必须使所有的数据都保持一致状态(各种 constraint 不被破坏)。 - I,isolation 隔离性
由并发事务所作的修改必须与任何其他并发事务所作的修改隔离。事务查看数据时数据所处的状态,要么是另一并发事务修改它之前的状态,要么是另一事务修改它之后的状态,事务不会查看中间状态的数据。换句话说,一个事务的影响在该事务提交前对其他事务都不可见。 - D,durability 持久性
事务完成之后,它对于系统的影响是永久性的。该修改即使出现指明的系统古战也将一致保持。
事务的隔离级别
如果不对数据库进行并发控制,可能会产生异常情况:
- 脏读(Dirty Read)
当一个事务读取另一个事务尚未提交的修改时,产生脏读。
同一事务内不是脏读。一个事务开始读取了某行数据,但是另外一个事务已经更新了此数据但没有能够及时提交。这是相当危险的,因为很可能所有的操作都被回滚,也就是说读取除的数据其实是错误的。 - 非重复读(Nonrepeatable Read)
一个事务对同一行数据重复读取两次,但是却得到了不同的结果。同一查询在同一事务中多次进行,由于其他提交事务所做的修改或删除,每次返回不同的结果集,此时发生非重复读。 - 幻象读(Phantom Read,幻读)
事务在操作过程中进行两次查询,第二次查询的结果包含了第一次查询中未出现的数据(这里并不要求两次查询的SQL语句相同)。这是因为在两次查询过程中有另外一个事务插入数据造成的。
当对某行执行插入或删除操作,而该行属于某个事务正在读取的行的范围时,会产生幻读的问题。 - 丢失修改(Lost Update)
第一类:当两个事务更新相同的数据源,如果第一个事务被提交,第二个被撤销,那么连同第一个事务做的更新也被撤销。
第二类:有两个并发事务同时读取同一行数据,然后其中一个对它进行修改提交,而另一个也进行了修改提交。这就会造成第一次写操作失败。
为了兼顾并发效率和异常控制,在标准SQL规范中,定义了4个事务隔离级别,(Oracle 和 SQL Server 对标准隔离级别有不同的实现)
- 未提交读(Read Uncommitted)
直译就是“读为提交”,意思是即使一个更新语句没有提交,但是别的事务可以读到这个改变。允许脏读。 - 已提交读(Read Committed)
直译就是“读提交”,意思是语句提交以后,即执行了 Commit 以后别的事务就能读到这个改变,只能读取到已经提交的数据。 Oracle等多数数据库默认都是该级别。
不允许脏读,但会出现非重复读。 - 可重复度(Repeatable Read)
是说在同一个事务里面先后执行同一个查询语句的时候,得到的结果是一样的。
不允许脏读,不允许非重复度,但是会出现幻读。 - 串行读(Serializable)
序列化,是说这个事务执行的时候不允许别的事务并发执行。完全串行化的读,每次读都需要获得表级共享锁,读写相互都会阻塞。
不允许不一致现象的出现。
事务隔离的实现——锁
- 共享锁(S锁)
用于只读操作(SELECT),锁定共享的资源。共享锁不会阻止其他用户读,但是阻止其他的用户写和修改。 - 更新锁(U锁)
用于可更新的资源中。防止当多个会话在读取、锁定以及随后可能进行的资源更新时发生常见形式的死锁。 - 独占所(X锁,也叫排他锁)
一次只能有一个独占锁用在一个资源上,并且阻止其他所有的锁包括共享锁。写是独占锁,可以有效的防止“脏读”。
Read Uncommitted 如果一个事务已经开始写数据,则另外一个数据则不允许同时进行写操作,但允许其他事务读此行数据。该隔离级别可以通过“排他写锁”实现。
Read Committed 读取数据的事务允许其他事务继续访问该行数据,但是未提交的写事务将会禁止其他事务访问该行。可以通过“瞬间共享读锁”和“排他写锁”实现。
Repeatable Read 读取数据的事务将会禁止写事务(但允许读事务),写事务则禁止任何其他事务。可以通过“共享读锁”和“排他写锁”实现。
Serializable 读加共享锁,写加排他锁,读写互斥。
乐观锁 悲观锁
基本概念
乐观锁和悲观锁是两种思想,用于解决并发场景下的数据竞争问题。
- 乐观锁:在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。
- 悲观锁:在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。
实现方式
明确:乐观锁和悲观锁是两种思想,它们的使用是非常广泛的,不局限于某种编程语言或数据库。
悲观锁的实现方式是加锁,加锁既可以是对代码块加锁,也可以是对数据加锁(如MySQL中的排他锁)。
乐观锁的实现方式主要有两种:CAS机制和版本号机制。
CAS(Compare And Swap)
CAS操作包括了3个操作:
- 需要读写的内存位置(V)
- 进行比较的预期值(A)
- 拟写入的新值(B)
CAS操作逻辑如下:如果内存位置V的值等于预期的A值,则将该位置更新为新值B,否则不进行任何操作。许多CAS的操作是自旋的:如果操作不成功,会一直重试,直到操作成功为止。
这里引出一个新问题,既然CAS包含了Compare和Swap两个操作,它又如何保证原子性呢?答案是:CAS是由CPU支持的原子操作,其原子性是在硬件层面进行保证的。
版本号机制
除了CAS,版本号机制也可以用来实现乐观锁。版本号机制的基本思路是在数据中增加一个字段version,表示该数据的版本号,每当数据被修改,版本号加1.当某个线程查询数据时,将该数据的版本号一起查出来;当该新城更新数据时,判断当前版本号与之前的版本号是否一直,如果一直才进行操作。
需要主义的是,这里使用了版本号作为判断数据变化的标记,实际上可以根据实际情况选用其他能够编辑数据版本的字段,如时间戳等。
优缺点和适用场景
乐观锁和悲观锁并没有优劣之分,他们有格子适合的场景。
- 功能限制
与悲观锁相比,乐观锁适用的场景受到了更多的限制,无路是CAS还是版本号机制。
例如,CAS只能保证单个变量操作的原子性,当涉及到多个变量时,CAS是无能为力的,而synchronized则可以通过对整个代码块加锁来处理。再比如版本号机制,如果query的时候是针对表1,而update的时候是针对表2,也很难通过简单的版本号来实现乐观锁。 - 竞争激烈程度
如果悲观锁和乐观锁都可以使用,那么选择就要考虑竞争的激烈程度:
- 当竞争不激烈(出现并发冲突的概率小)时,乐观锁更有优势,因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发,而且加锁和释放锁都需要消耗额外的资源。
- 当竞争激烈(出现并发冲突的概率大)时,悲观锁更有优势,因为乐观锁在执行更新时频繁失败,需要不断重试,浪费CPU资源。
面试官追问:乐观锁加锁吗?
- 乐观锁本身是不加锁的,只是在更新时判断一下数据是否被其他线程更新了。
- 有时乐观锁可能与加锁操作合作,例如,MySQL在执行update时会加排他锁,但这只是乐观锁与加锁操作合作的例子,不能改变“乐观锁本身不加锁”这一事实。
面试官追问:CAS有哪些缺点?
CAS这种实现方式有什么缺点?
- ABA问题:
假设有两个线程——线程1和线程2,两个线程按照顺序进行一下操作:
(1) 线程1读取内存中数据为A
(2) 线程2将该数据修改为B
(3) 线程2将该数据修改为A
(4) 线程1对数据进行CAS操作
在第(4)步中,由于内存中数据仍然为A,因此CAS操作成功,但实际上该数据已经被线程2修改过了。这就是ABA问题。
ABA似乎没有什么危害。但在某些场景下,ABA却会带来隐患,例如栈顶问题:一个栈的栈顶经过两次(或多次)变化又恢复了原值,但是栈可能已发生了变化。
对于ABA问题,比较有效的方案是引入版本号,内存中的值发生一次变化,版本号都+1;在进行CAS操作时,不仅比较内存中的值,也会比较版本号,只有当二者都没有变化时,CAS才能执行成功。 - 高竞争下的开销问题
在并发冲突概率大的高竞争环境下,如果CAS一直失败,会一直重试,CPU开销较大。针对这个问题的一个思路是引入退出机制,如重试次数超过一定阀值后失败退出。当然,更重要的是避免在高竞争环境下使用乐观锁。 - 功能限制
CAS的功能比较受限,例如CAS只能保证单个变量(或者说单个内存值)操作的原子性,这意味着:
(1) 原子性不一定能保证线程安全
(2) 当涉及到多个变量(内存值)时,CAS也无能为力
除此之外,CAS的实现需要硬件层面处理器的支持,灵活性受到限制。