首页 > 技术文章 > 数据库事务隔离引发的关于锁机制的思考

leo-chen-2014 2018-10-03 15:08 原文

DB提供两种机制来保证事务的ACID(原子性,一致性,隔离性和持久性)特性,日志预写(write-ahead loging)和锁(lock),前者用于保证原子性、一致性,后者用于保证隔离性。
事务在没有提交前的一系列修改都不能持久化,因此这一系列的操作都是依赖两种log来实现,redo-log和undo-log;修改前的数据由undo-log记录,修改后的数据由redo-log记录,事务提交成功则执行redo-log,失败则执行undo-log。

DB提供的事务隔离级别如下:
#1 未提交读,READ UNCOMMITTED
执行读操作时不加任何锁,执行写操作时添加行级共享锁,直到事务结束。多个事务可以并行读取某数据,但是一旦某个事务执行了写操作后,其他事务仍旧可以读数据,但需要等待锁释放后才可以执行写操作 。
问题:不能解决脏读,一个事务可能读取到另外一个事务最终没有提交的数据。

#2 已提交读,READ COMMITTED
执行读操作时添加行级共享锁,读完之后就释放锁;执行写操作时添加行级排他锁,直到事务结束。多个事务可以并行读取某数据,但是一旦某个事务执行了写操作后,其他事务就不再允许进行任何读写操作,因此可以解决当前事务读取的数据都是最终提交的。
问题:不能解决可重复读,另一个事务数据更新操作可能让当前事务连续两次读取的数据不一致。

#3 可重复读,REPEATED READ
执行读操作时添加行级共享锁,直到事务结束;执行写操作时添加行级排他锁,直到事务结束。多个事务可以并行读取某数据,但事务仅当数据上没有任何锁的时候才能添加排他锁进行写操作,直到当前事务结束后,其他事务才能尝试添加拍他所进行写操作。因此一旦某数据被读取之后,其他事务不能对其进行修改。
问题:不能解决幻读,另一个事务的新数据插入操作可能让当前事务连续两次读取的数据不一致。

#4 序列化读,SERIALIZABLE
执行读操作时添加表级共享锁,直到事务结束;执行写操作时添加表级排他锁,直到事务结束。因此涉及到表内某行数据的读写都串行发生。
问题:所有事务都串行发生,访问性能极大降低。

Racing Process中关于锁的机制

就用途而言,锁包含悲观锁、乐观锁、独占锁、共享锁、(非)公平锁、分布式锁和自旋锁。

独占锁(Exclusive Lock):
也叫做写锁(X锁,排它锁)资源只允许当前事务读写,其他事务的任何操作都不允许,添加之后一直到事务结束时才释放;当资源上已经有其他锁的时候无法添加独占锁;插入、更新和删除操作自动添加独占锁。

 

共享锁(Shared Lock):

也叫做读锁(S锁)资源允许当前事务读写,允许其他事务的读操作,不允许其他事务的写操作,读操作结束后共享锁就立即释放;查询操作自动添加共享锁。

 

悲观锁(Pessimistic Lock):
事务认为数据处理过程中都需要锁定数据,防止其他racing process的修改,一般都是依靠数据库锁机制实现,包含表锁、行锁等,一旦触发肯定会引起线程的阻塞等待

 

乐观锁(Optimistic Lock):
事务认为数据处理过程中不需要锁定数据;一般通过数据库表的版本列实现,为数据库表添加一列version,读取数据的时候同时读取version数值;之后写入的时候对version + 1并与当前DB中的version数值比较,如果大于数据库表当前的版本号则进行更新,否则认为是过期数据而不进行更新,其实也就是多版本并发控制(Multi-Version Cuoncurrency Controll)。
在Mysql的Innodb引擎中,数据库表会额外添加一列version字段,仅在事务级别为未提交读和已提交读的时候会开启mvcc,在保证数据一致性的前提下提供最大程度的并发。每一个racing process在读取数据的时候会在原始数据的基础上生成一条新的临时数据,同时version +1,这条数据在事务提交前对其他事务都是不可见的;一旦事务结束提交的时候会对比临时数据跟原始数据的version版本号,如果临时数据的version小于原始数据的version,则说明临时数据已经过期,放弃或者重新读取数据执行当前的操作。

 

分布式锁(Distributed Lock):
同一个进程内的多个线程之间可以共享内存,因此基于共享内存的synchnorized或者lock机制都可以实现锁,对于进程之间或者多台server之间基于共享内存方式实现的锁就不再能满足要求。分布式环境的锁需要提供一个所有server都可以访问到的存储介质(DB或者内存),最原始的分布式锁设计源于DB,多个业务系统之间的同步通过向同一张DB table中插入一条具有唯一性键值的数据,成功插入数据的业务系统获得锁,处理完自己的业务数据之后将DB table中的数据删除,其他业务系统就可以继续插入数据从而获取锁;可以通过timer task解决死锁问题,可以在table中增加业务系统标识column解决锁重入的问题;在并发量不是特别大,并且能保证DB系统足够稳定的前提下,DB based的分布式锁可以解决分布式锁的问题。

随着并发业务量的上升和访问延迟要求的提高,缓存锁逐渐替代了数据库锁,以Redis RedLock和ZooKeeper Mutex为代表的基于内存数据操作架构可以保证多业务系统对同一项操作的并发执行,最终只会有一个业务系统操作成功,或者有一个先后顺序(竞争排队);RedLock的核心利用的是Redis的命令set NX EX,NX属性表示仅当某个key不存在的时候才能成功set这个key,这样racing process同时对同一个key执行setnx,最终只有一个process可以设置成功,也表示获取锁成功;EX表示key值的过期时间,防止死锁的情况。

分布式锁主要用于处理在分布式环境中多个racing process对同一个资源进行访问时候的数据一致性问题,因此分布式锁需要满足五个特性:
#1 保证同一个方法在在同一时间点只能被一台机器上的一个线程执行:

#2 保证锁可以正常的释放:避免因加锁的racing process服务下线造成锁不能释放的场景;DB based实现可以通过timer task删除过期的record,Redis based实现可以通过setnx上设置key过期时间自动删除键值,ZooKeeper based实现通过判定session connection断开自动删除创建的节点。

#3 保证锁的实现为阻塞锁:racing process需要等待获取锁之后才继续往下执行业务;DB based实现和Redis based实现都可以通过while(true)循环实现等待(自旋锁),但不够优雅,ZooKeeper based实现通过event-watcher机制自动通知racing process锁已经获取。

#4 保证锁是可重入的:已经获得了锁的racing process可以访问被加了同一把锁的其他资源,避免自身死锁;DB based实现可在record上增加一列lock holder info来辨识当前获取锁的racing process,Redis based实现可将value设置成lock holder info来辨识,ZooKeeper based实现可以在创建的node上记录lock holder info。

#5 保证锁的获取和释放服务具有高可用性能:避免单点故障问题,DB,Redis和ZooKeeper都可以集群的方式提供分布式锁服务。

 

自旋锁(Self-Spin Lock):
racing process通过互斥同步实现访问一致性的时候,挂起和恢复线程都需要转到系统内核模式完成,频繁的切换会给并发性能带来压力;同时,大多数共享数据的锁定状态都只需要很短的时间,业务的开销甚至比系统切换开销还低,所以racing process可以暂时不用切换状态,而只需要执行一段时间的忙循环(也就是继续占用CPU时间片),从而等待共享数据的锁被释放,这样的策略就是自旋锁。

推荐阅读