现在的位置: 首页 > 综合 > 正文

数据库的锁机制

2014年10月25日 ⁄ 综合 ⁄ 共 7895字 ⁄ 字号 评论关闭

http://www.rigongyizu.com/distributed-locks-using-redis/

日拱一卒

关注分布式系统,NoSQL技术,记录工作与生活所感

跳至内容

基于Redis Lua脚本实现的分布式锁

最近项目中需要用到一个分布式的锁,考虑到基于会话节点实现的zookeeper锁性能不够,于是想使用redis来实现一个分布式的锁。看了网上的几个实现方案后,发现都不够严谨。比如这篇:用Redis实现分布式锁里面设计的锁有个最大的问题是锁的超时值TTL会一直被改写,“尽管C3没拿到锁,但它改写了C4设置的锁的超时值,不过这一点非常微小的误差带来的影响可以忽略不计”,其实在高并发的时候会导致进程“饿死”(也有文章称为死锁)。还有这篇文章“两种分布式锁实现方案2”里面的v2=getset(key,时间戮+超时+1),其加1秒操作在大并发下也会触发同样的问题。网上这篇文章解决了这个“无休止的TTL”问题,我简单翻译了下。


distributedLock

锁是编程中非常常见的概念。在维基百科上对锁有个相当精确的定义:

在计算机科学中,锁是一种在多线程环境中用于强行限制资源访问的同步机制。锁被设计用于执行一个互斥的并发控制策略。

In computer science, a lock is
a synchronization mechanism for enforcing limits on access to a resource in an environment where there are many threads of execution. A lock is
designed to enforce a mutual exclusion concurrency control policy.

***************************************************************************************************************************************

Redis并发问题

Redis为单进程单线程模式,采用队列模式将并发访问变为串行访问。Redis本身没有锁的概念,Redis对于多个客户端连接并不存在竞争,但是在Jedis客户端对Redis进行并发访问时会发生连接超时、数据转换错误、阻塞、客户端关闭连接等问题,这些问题均是由于客户端连接混乱造成。对此有2种解决方法:

1.客户端角度,为保证每个客户端间正常有序与Redis进行通信,对连接进行池化,同时对客户端读写Redis操作采用内部锁synchronized。

2.服务器角度,利用setnx实现锁。

对于第一种,需要应用程序自己处理资源的同步,可以使用的方法比较通俗,可以使用synchronized也可以使用lock;第二种需要用到Redis的setnx命令,但是需要注意一些问题。

SETNX命令(SET if Not eXists)

语法:

SETNX key value

功能:
将 key 的值设为 value ,当且仅当 key 不存在;若给定的 key 已经存在,则 SETNX 不做任何动作。

时间复杂度:
O(1)
返回值:
设置成功,返回 1 。
设置失败,返回 0 。

模式:将 SETNX 用于加锁(locking)

SETNX 可以用作加锁原语(locking primitive)。比如说,要对关键字(key) foo 加锁,客户端可以尝试以下方式:

SETNX lock.foo <current Unix time + lock timeout + 1>

如果 SETNX 返回 1 ,说明客户端已经获得了锁, key 设置的unix时间则指定了锁失效的时间。之后客户端可以通过 DEL lock.foo 来释放锁。

如果 SETNX 返回 0 ,说明 key 已经被其他客户端上锁了。如果锁是非阻塞(non blocking lock)的,我们可以选择返回调用,或者进入一个重试循环,直到成功获得锁或重试超时(timeout)。

但是已经证实仅仅使用SETNX加锁带有竞争条件,在特定的情况下会造成错误。

处理死锁(deadlock)

上面的锁算法有一个问题:如果因为客户端失败、崩溃或其他原因导致没有办法释放锁的话,怎么办?

这种状况可以通过检测发现——因为上锁的 key 保存的是 unix 时间戳,假如 key 值的时间戳小于当前的时间戳,表示锁已经不再有效。

但是,当有多个客户端同时检测一个锁是否过期并尝试释放它的时候,我们不能简单粗暴地删除死锁的 key ,再用 SETNX 上锁,因为这时竞争条件(race condition)已经形成了:

C1 和 C2 读取 lock.foo 并检查时间戳, SETNX 都返回 0 ,因为它已经被 C3 锁上了,但 C3 在上锁之后就崩溃(crashed)了。
C1 向 lock.foo 发送 DEL 命令。
C1 向 lock.foo 发送 SETNX 并成功。
C2 向 lock.foo 发送 DEL 命令。
C2 向 lock.foo 发送 SETNX 并成功。
出错:因为竞争条件的关系,C1 和 C2 两个都获得了锁。

幸好,以下算法可以避免以上问题。来看看我们聪明的 C4 客户端怎么办:

C4 向 lock.foo 发送 SETNX 命令。
因为崩溃掉的 C3 还锁着 lock.foo ,所以 Redis 向 C4 返回 0 。
C4 向 lock.foo 发送 GET 命令,查看 lock.foo 的锁是否过期。如果不,则休眠(sleep)一段时间,并在之后重试。
另一方面,如果 lock.foo 内的 unix 时间戳比当前时间戳老,C4 执行以下命令:
GETSET lock.foo <current Unix timestamp + lock timeout + 1>

因为 GETSET 的作用,C4 可以检查看 GETSET 的返回值,确定 lock.foo 之前储存的旧值仍是那个过期时间戳,如果是的话,那么 C4 获得锁。
如果其他客户端,比如 C5,比 C4 更快地执行了 GETSET 操作并获得锁,那么 C4 的 GETSET 操作返回的就是一个未过期的时间戳(C5 设置的时间戳)。C4 只好从第一步开始重试。
注意,即便 C4 的 GETSET 操作对 key 进行了修改,这对未来也没什么影响。

这里假设锁key对应的value没有实际业务意义,否则会有问题,而且其实其value也确实不应该用在业务中。

为了让这个加锁算法更健壮,获得锁的客户端应该常常检查过期时间以免锁因诸如 DEL 等命令的执行而被意外解开,因为客户端失败的情况非常复杂,不仅仅是崩溃这么简单,还可能是客户端因为某些操作被阻塞了相当长时间,紧接着 DEL 命令被尝试执行(但这时锁却在另外的客户端手上)。

GETSET命令

语法:

GETSET key value

功能:
将给定 key 的值设为 value ,并返回 key 的旧值(old value)。当 key 存在但不是字符串类型时,返回一个错误。

时间复杂度:
O(1)

返回值:
返回给定 key 的旧值;当 key 没有旧值时,也即是, key 不存在时,返回 nil 。

ref by

http://blog.csdn.net/hpb21/article/details/7893013
http://redis.readthedocs.org/en/latest/string/setnx.html

**********************************************************************************************************************************

锁有两种分类方法。
(1) 从数据库系统的角度来看
锁分为以下三种类型: 

•独占锁(Exclusive Lock)
独占锁锁定的资源只允许进行锁定操作的程序使用,其它任何对它的操作均不会被接受。执行数据更新命令,即INSERT、 UPDATE 或DELETE 命令时,SQL Server 会自动使用独占锁。但当对象上有其它锁存在时,无法对其加独占锁。独占锁一直到事务结束才能被释放。

•共享锁(Shared Lock)
共享锁锁定的资源可以被其它用户读取,但其它用户不能修改它。在SELECT 命令执行时,SQL Server 通常会对对象进行共享锁锁定。通常加共享锁的数据页被读取完毕后,共享锁就会立即被释放。

•更新锁(Update Lock)
更新锁是为了防止死锁而设立的。当SQL Server 准备更新数据时,它首先对数据对象作更新锁锁定,这样数据将不能被修改,但可以读取。等到SQL Server 确定要进行更新数据操作时,它会自动将更新锁换为独占锁。但当对象上有其它锁存在时,无法对其作更新锁锁定。

(2)从程序员的角度看
锁分为以下两种类型: 

•乐观锁(Optimistic Lock)
乐观锁假定在处理数据时,不需要在应用程序的代码中做任何事情就可以直接在记录上加锁、即完全依靠数据库来管理锁的工作。一般情况下,当执行事务处理时SQL Server会自动对事务处理范围内更新到的表做锁定。

•悲观锁(Pessimistic Lock)
悲观锁对数据库系统的自动管理不感冒,需要程序员直接管理数据或对象上的加锁处理,并负责获取、共享和放弃正在使用的数据上的任何锁。

数据库事务中的基本概念 

数据库事务是指,由一个或多个SQL语句组成的工作单元,这个工作单元中的SQL语句相互依赖,如果有一个SQL语句失败,那么整个操作都要撤销。在并发环境中,当多个事务同时访问同一资源时,可能会造成并发问题,此时可以使用数据库系统的事务隔离级别来避免各类并发问题。此外,在应用程序中还可以使用悲观锁和乐观锁来解决丢失更新的并发问题。 

数据库事务必须具备ACID的特征(Atomic原子性,Consistency一致性,Isolation隔离性,Durability持久性) 
    原子性,指整个数据库事务是不可分割的单元。只有所有的语句执行成功,才算成功。 
    一致性,指数据库事务不能破坏关系数据的完整性以及业务逻辑上的一致性。 
     隔离性,指在并发环境中,当不同的事务同时操作相同的数据时,每个事务都有各自的完整数据空间。 
     持久性,指的是只要事务成功结束,对数据库做的更新要永久保存下来. 

Transaction和Session的关系,应该注意以下几点 
      Transaction的rollback()和Session的close()方法都会抛出HibernateException 
     不论事务是否成功,最后都应该关闭Session。 
      即使事务中只包含只读操作,也应该在事务成功执行之后提交事务,并且在事务执行失败时撤销事务,因为在提交或撤销事务时,数据库系统会释放占用的资源。 

多个事务同时运行时的并发问题 
     第一类丢失更新,撤销一个事务时,把其他事务已提交的更新数据覆盖了。 
     脏读,一个事务读到另一个事务未提交的更新数据。 
     虚读,一个事务读到另一个事务已提交的新插入的数据。 
     不可重复读,一个事务读到另一个事务已提交的更新数据。 
      第二类更新,一个事务覆盖另一事务已提交的更新数据。 

数据库系统锁的基本原理 

按照锁定的资源的粒度,锁可以分为以下类型 
   数据库级锁: 锁定整个数据库 
   表级锁: 锁定一张表 
   区域级锁: 锁定数据库的特定区域 
   页面级锁: 锁定数据库的特定页面 
   见面级锁: 锁定数据库的特定页面 
   键值级锁: 锁定数据库表中带有索引的一行数据。 
   行级锁: 锁定数据库表中的但行数据(即一条记录) 


按照封锁的程度,锁可以分为: 共享锁,独占锁,更新锁 

共享锁, 共享锁用于读数据操作,它是非独占的,允许其他事务同时读取其锁定的资源,但不允许其他事务更新数据。共享锁的特征: 
加锁条件:当一个事务执行Select操作时,数据库系统就会为其分配一把共享锁,来锁定查询数据。 
     解锁条件:在默认情况下,数据读取完毕,共享锁就解除了。 
     与其他锁的兼容性:如果数据资源上放置了共享锁,还能再放置共享锁和更新锁。 
     并发性能:具有良好的并发性能,当多个事务读相同的数据时,每个事务都会获得共享锁,因此可以同时读锁定的数据。 

独占锁,也叫排它锁,适用于修改数据的场合。它锁定的资源,其他事务不能读取也不能更新。独占锁具有以下特征: 
    加锁的条件:当一个事务执行insert、update、或delete语句时,数据库就会为SQL所操作的数据使用独占锁。如果数据上有其他锁,那么就能放置独占锁. 
    解锁条件:到事务结束时才能被解除 
与其他锁的兼容性:独占锁不能和其他锁兼容。通用如果资源上有其他锁,那么也不能放置独占锁。 
    并发性能:性能较差,只允许有一个事务访问锁定的数据,如果其他事务也需要访问该数据,就必须等待,直到前一个事务结束,解除了独占锁,其他事务才有机会访问资源。 

更新锁,在更新操作的初始化阶段用来锁定可能要被修改的资源,这可以避免使用共享锁造成死锁的现象。更新具有以下特征: 
    加锁的条件:当一个事务执行update语句时,数据库系统会先为事务分配一把更新锁。 
    解锁条件:读取数据完毕,执行更新操作时,会把更新锁升级为独占锁。 
     与其他锁的兼容性:更新锁与共享锁是兼容的。也就是说,一个资源可以同时放置更新锁和共享锁,但是最多只能放置一把更新锁。 
    并发性能:允许多个事务同时访问资源。但不允许修改。 

死锁及其防止方法 
     合理安排表的访问顺序 
     使用短事务。将大事务分割为多个小事务执行。应该在处理事务之前就准备好用户必须提供的数据。 
     如果对数据的一致性要求不高,可以允许脏度。 
     如果可能,错开多个事务访问相同数据资源的时间,以防止锁冲突。 
     使用尽可能低的事务级别。 





数据库的隔离级别 
     尽管数据库系统允许用户在事务中显示的为数据资源加锁,但是首先应该考虑让数据库系统自动管理锁,它会分析事务中的SQL语句,然后自动为SQL语句所操作的数据资源加上合适的锁,而且在锁的数目太多时,数据库系统会自动的进行锁升级,以提供系统性能。 

数据库系统提供了四种事务隔离级别供用户选择: 

Serializable,串行化。 
      一个事务在执行过程中完全看不到事务对数据库所做的更新。当两个事务同时操作数据库中的数据时,如果第一个事务已经在访问该数据,那么第二个事务只能停下来等待。 
Repeatable Read:可重复读 
      一个事务在执行过程中可以看到其他事务已经提交的新插入的数据。但是不能看到其他事务对已有数据做的跟新。 
Read Commited:读已提交数据 
      一个事务在执行过程中可以看到其他事务已经提交的新插入的数据。可以看到其他对已有数据进行的更新。 
Read UnCommited:读取未提交 
     一个事务可以看到,其他事务没有提交新插入的数据。而且更新操作的记录也能看到。 

//: 在hibernate.cfg.xml中配置 
hibernate.connection.isolation=2 

在应用程序中才用悲观锁和乐观锁 

悲观锁与乐观锁的概念 
悲观锁,在应用程序中显示的为数据资源加锁。可以防止丢失更新和不可重复读问题。但是会影响性能。 
乐观锁,假设当前的事务操作的数据,不会有其他事务同时访问该数据资源,完全依靠数据库系统自动管理锁。因此可能会出现并发问题。 

利用数据库系统的独占锁来实现悲观锁 
悲观锁的两种实现方式 
应用中显示的指定采用数据库系统的独占锁来锁定数据资源。 
在数据库表中增加一个表明记录状态的LOCK字段,当它取值为Y时,表示该i记录已被某个事务锁定。如果为N,表示该条数据为空闲状态。 
在Hibernate中,当通过Session的get()和load()方式来加载一个对象时,可以采用以下方式使用悲观锁: 
Customer cus = (Customer)session.get(Customer.class, "c001", LockMode.UPGRADE); //: final org.hibernate.LockMode 






LockMode类表示的几种锁定模式 

锁定模式 描述

LockMode.NONE 如果在Hibernate缓存中存在Customer对象,就直接返回该引用。否则通过select加载。默认值

LockMode.READ 不管缓存中是否有,都使用select加载数据。如果映射文件设置了版本元素,就执行版本检查,比较缓存中的与取到的是否版本一致。

LockMode.UPGRADE 不管缓存中是否有,都使用select加载数据。就执行版本检查,比较缓存中的与取到的是否版本一致。如果数据库系统支持悲观锁,那么执行select…for update, 否则执行简单的select

LockMode.UPGRADE_NOWAIT 会执行LockMode.UPGRADE和它一样的功能。若果执行的select不能立即获得悲观锁,那么就会抛出异常

LockMode.WRITE 当Hibernate保存或更新对象时,会自动使用这种锁定模式。这种锁定模式只在Hibernate内部使用,所以在应用中不应该使用它



利用Hibernate的版本控制来实现乐观锁 
create table Accounts ( 
id bigint primary key, 
name varchar(15), 
balance numeric(10,2), 
LAST_UPDATED_TIME timestamp, 
version int 


在hbm中配置版本控制 
//: 在元素必须跟在<id>元素的后面 
<version name = "version" column = "VERSION" /> 

//: 使用该元素也可以实现版本控制 
<timestamp name=”lastUpdateTime” column=” LAST_UPDATED_TIME” /> 

对游离对象进行版本检查 
Session session1 = ....; 
trans1 = session1.beginTransaction(); 
Account a = (Account)session1.get(Account.class, new Long(1)); 
trans1.commit(); 
session1.close(); 

a.setBalance(a.getBalance()-100); 

Session session2 = ...; 
trans2 = session2.beginTransaction(); 
session2.lock(a, LockMode.READ); 
trans2.commit(); 
session2.close(); 

lock() 方法和update()方法之间的区别 
lock()方法在LockMode.READ模式下,立即进行版本控制。而update()方法不会立即执行版本检查,只有当Session清理缓存时,真正执行update时才进行版本检查 
lock()在默认的LockMode中不会执行update语句。而update()会计划执行一个update语句,如果数据库中没对应的记录那么会抛出异常。 

实现乐观锁的其他方式 
<class optimistic-lock="all" dynamic-update="true"> 

//: optimistic-lock=all/dirty 时必须设置dynamic-update为true 

抱歉!评论已关闭.