目前,几乎很多大型网站和应用都是分布式部署的。分布式场景下的数据一致性一直是一个重要的话题。分布式CAP理论告诉我们“任何分布式系统都不能同时满足Consistency、Availability和Partitiontolerance,只能同时满足两者”。因此,很多系统在设计之初,就需要在这三者之间做出权衡。在互联网领域的大部分场景下,都需要牺牲强一致性来换取系统的高可用性。系统往往只需要保证“最终一致性”,只要最终时间在用户可接受的范围内即可。在很多场景下,为了保证数据的最终一致性,我们需要很多技术方案来支持,比如分布式事务、分布式锁。有时候,我们需要保证一个方法在同一时刻只能被同一个线程执行。在单机环境下,Java其实提供了很多与并发处理相关的API,但是这些API在分布式场景下就束手无策了。也就是说,纯JavaApi无法提供分布式锁的能力。因此,目前分布式锁的实现有很多解决方案。对于分布式锁的实现,目前常用的有以下几种方案:基于数据库实现分布式锁基于缓存实现分布式锁基于Zookeeper实现分布式锁在分析这些实现方案之前,我们先来想一想,我们应该所需的分布式锁是什么样的?(这里以方法锁为例,资源锁同理)可以保证在分布式部署的应用集群中,同一个方法在同一时间只能由一台机器上的一个线程执行。如果这个锁是可重入锁(避免死锁)这个锁肯定是阻塞锁(根据业务需要考虑是否要这个)有高可用的锁获取和释放锁函数来获取和释放锁锁的性能更好。基于数据库的分布式锁是基于数据库表实现的。要实现基于数据库表的分布式锁,最简单的方法可能是直接创建一个锁表,然后通过操作表中的数据来实现。当我们要锁定一个方法或资源时,我们在表中添加一条记录,当我们要释放锁时删除这条记录。创建这样一个数据库表:CREATETABLE`methodLock`(`id`int(11)NOTNULLAUTO_INCREMENTCOMMENT'主键',`method_name`varchar(64)NOTNULLDEFAULT''COMMENT'锁定的方法名',`desc`varchar(1024)NOTNULLDEFAULT'备注',`update_time`timestampNOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMPCOMMENT'保存数据时间,自动生成',PRIMARYKEY(`id`),UNIQUEKEY`uidx_method_name`(`method_name`)USINGBTREE)ENGINE=InnoDBDEFAULTCHARSET=utf8COMMENT='当方法被锁定时我们要锁定一个方法,执行如下SQL:insertinmethodLock(method_name,desc)values('method_name','desc')因为我们对method_name有唯一约束,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们可以认为操作成功的线程已经获得了方法的锁,可以执行方法体内容。方法执行完后,如果想释放锁,需要执行如下Sql:deletefrommethodLockwheremethod_name='method_name'上面的简单实现存在以下问题:1.这个锁强烈依赖于数据库的可用性,以及数据库是单点的,一旦数据库挂掉,业务系统将不可用。2.这个锁没有过期时间。一旦解锁操作失败,锁记录将一直保留在数据库中,其他线程将无法再获得锁。3.这个锁只能是非阻塞的,因为数据的插入操作一旦插入失败就会直接报错。没有获得锁的线程是不会进入排队队列的。要再次获取锁,必须再次触发锁获取操作。4.这个锁是不可重入的,同一个线程在释放锁之前不能再次获取锁。因为数据中的数据已经存在了。当然,我们也可以有其他的方法来解决上面的问题。数据库是单点的?搞了两个数据库,两路同步过数据。一旦挂了,迅速切换到备库。没有过期时间?就做一个定时任务,定时清理数据库中的超时数据。非阻塞?搞一个while循环,直到插入成功再返回成功。不可重入?在数据库表中增加一个字段,记录当前获取锁的机器的主机信息和线程信息,下次获取锁时先查询数据库,如果能查到当前机器的主机信息和线程信息在数据库中,直接把锁赋给他就行了。基于数据库排它锁,除了可以在数据表中增删记录外,其实还可以使用数据中的锁来实现分布式锁。我们还使用刚刚创建的数据库表。分布式锁可以通过对数据库的独占锁来实现。基于MySql的InnoDB引擎,可以使用以下方法实现加锁操作:returntrue;}}catch(Exceptione){}sleep(1000);}returnfalse;}在查询语句后添加forupdate,数据库在查询过程中会对数据库表加排他锁(这里多说一句,InnoDBengine是加锁的,只有通过索引查找的时候才会使用行级锁,否则会使用表级锁。这里要使用行级锁,所以需要在method_name上加一个索引。值得注意的是这个索引必须创建为Unique索引,否则会出现无法同时访问多个重载方法的问题。对于重载方法,建议加上参数类型。)当一条记录加排它锁后,其他线程就不能再给该行记录加排它锁。我们可以认为,获得独占锁的线程可以获得分布式锁。拿到锁之后,就可以执行方法的业务逻辑了。该方法执行后,使用如下方法解锁:publicvoidunlock(){connection.commit();}通过connection.commit()操作释放锁。该方法可以有效解决上述无法释放锁和阻塞锁的问题。阻塞锁?forupdate语句执行成功后会立即返回,执行失败时会阻塞直到成功。加锁后服务宕机无法释放?这样,当服务宕机后,数据库会自行释放锁。但是,它仍然不能直接解决数据库的单点和可重入问题。这里可能还有另外一个问题,虽然我们对method_name使用了唯一索引,并且展示了使用forupdate来使用行级锁。但是,MySql会优化查询。即使条件中使用了索引字段,是否使用索引检索数据也是MySQL通过判断不同执行计划的开销来决定的。如果MySQL认为全表扫描效率更高,比如对于一些非常小的表,它不会使用索引,这种情况下InnoDB会使用表锁而不是行锁。如果发生这种情况,那将是悲惨的。..还有一个问题就是分布式锁锁我们需要使用独占锁。如果排他锁长时间不提交,会占用数据库连接。一旦相似的连接多了,就有可能爆掉数据库连接池。总结使用数据库实现分布式锁的方式。这两种方法都依赖于数据库中的一张表,一种是通过表中表。记录的存在决定了当前是否有锁,另外一个就是通过数据库的独占锁来实现分布式锁。数据库实现分布式锁的优点直接使用数据库就很容易理解。在数据库中实现分布式锁的缺点会导致各种问题,使得整个解决方案在解决问题的过程中越来越复杂。操作数据库需要一定的开销,需要考虑性能问题。使用数据库的行级锁不一定靠谱,尤其是当我们的锁表不大的时候。与基于数据库实现分布式锁的方案相比,基于缓存实现分布式锁在性能上会表现的更好。而且很多缓存可以集群部署,可以解决单点问题。目前有很多成熟的缓存产品,包括Redis、memcached和我们内部的Tair。这里我们以Tair为例,分析一下使用缓存实现分布式锁的方案。网上关于Redis和memcached的相关文章很多,也有一些成熟的框架和算法可以直接拿来用。基于Tair的分布式锁的实现其实和Redis类似,主要的实现方式是使用TairManager.put方法。publicbooleantrylock(Stringkey){ResultCodecode=ldbTairManager.put(NAMESPACE,key,"ThisisaLock.",2,0);if(ResultCode.SUCCESS.equals(code))returntrue;elsereturnfalse;}publicbooleanunlock(Stringkey){ldbTairManager.invalid(NAMESPACE,key);}上面的实现方式也有几个问题:1.这个锁没有过期时间。一旦解锁操作失败,锁记录将永远在尾部,其他线程将无法再获得锁。2.这个锁只能是非阻塞的,不管成功失败直接返回。3.这个锁是不可重入的。一个线程获取到锁后,在释放锁之前不能再次获取到锁,因为使用的key已经存在于tail中。无法再执行put操作。当然,也有办法解决。没有过期时间?Tair的put方法支持传入过期时间,到达时间后会自动删除数据。非阻塞?while被重复执行。不可重入?一个线程获取到锁后,保存当前的主机信息和线程信息,下次获取前检查是否是当前锁的所有者。但是,过期时间应该设置多久呢?怎么设置过期时间太短,方法还没执行完锁就自动释放了,会造成并发问题。如果设置的时间过长,其他获取锁的线程可能无缘无故要等待一段时间。在使用数据库实现分布式锁的时候也存在这个问题。总结可以使用缓存代替数据库来实现分布式锁。这可以提供更好的性能。同时,很多缓存服务集群部署,可以避免单点问题。并且很多缓存服务都提供了可以用来实现分布式锁的方法,比如Tair的put方法,redis的setnx方法等,而且这些缓存服务还提供了数据过期后自动删除的支持,超时时间可以直接设置以控制锁的释放。使用缓存实现分布式锁的优点是性能好,实现起来更方便。使用缓存实现分布式锁的缺点通过timeout来控制锁的过期时间不是很靠谱。基于Zookeeper的分布式锁的实现是基于zookeeper临时有序节点可以实现的分布式锁。大致思路是:当每个client锁定某个方法时,在zookeeper上该方法对应的指定节点目录下生成一个唯一的瞬时有序节点。判断是否获取锁的方法很简单,只需要判断有序节点中序号最小的节点即可。释放锁时,删除瞬态节点即可。同时可以避免因服务宕机导致无法释放锁而导致的死锁问题。看看Zookeeper能不能解决上面提到的问题。无法解锁?使用Zookeeper可以有效解决锁无法释放的问题,因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端在获取到锁后突然挂掉(Session连接断开),那么这个临时节点该节点将被自动删除。其他客户端可以再次获取锁。非阻塞锁?阻塞锁可以使用Zookeeper来实现。客户端可以在ZK中创建一个顺序节点,并为该节点绑定一个监听器。一旦节点发生变化,Zookeeper会通知客户端,客户端可以查看自己创建的节点是否为当前节点。所有节点的序号都是最小的。如果是,那么锁就自己拿到了,业务逻辑就可以执行了。不可重入?使用Zookeeper也可以有效解决不可重入的问题。客户端在创建节点时,直接将当前客户端的主机信息和线程信息写入节点。只是比较里面的数据。如果和自己的信息相同,那么可以直接获取锁,如果不相同,则创建一个临时的序列节点参与排队。单点问题?使用Zookeeper可以有效解决单点问题。ZK部署在集群中。只要集群中有一半以上的机器存活下来,就可以对外提供服务。可以直接使用zookeeper第三方库Curator客户端,里面封装了一个可重入锁服务。publicbooleantryLock(longtimeout,TimeUnitunit)throwsInterruptedException{try{returninterProcessMutex.acquire(timeout,unit);}catch(Exceptione){e.printStackTrace();}returntrue;}publicbooleanunlock(){try{interProcessMutex.release();}catch(Throwablee){log.error(e.getMessage(),e);}finally{executorService.schedule(newCleaner(client,path),delayTimeForClean,TimeUnit.MILLISECONDS);}returntrue;}Curator提供的InterProcessMutex是分布式的锁定完成。acquire方法用户获取锁,release方法用于释放锁。使用ZK实现的分布式锁,似乎完全满足了本文开头我们对分布式锁的所有期待。然而,事实并非如此。Zookeeper实现的分布式锁其实有个缺点,就是性能可能没有缓存服务高。因为每次在创建和释放锁的过程中,都必须动态创建和销毁瞬时节点,才能实现锁功能。ZK中节点的创建和删除只能通过Leader服务器进行,??数据不会共享给所有的Follower机器。其实使用Zookeeper也可能会出现并发问题,但并不常见。考虑到这样的情况,由于网络抖动,客户端可能会与ZK集群的会话断开,那么ZK会在客户端认为客户端宕机的时候删除这个临时节点,其他客户端才能获得分布式锁。可能会出现并发问题。这个问题并不常见,因为zk有重试机制。一旦zk集群检测不到客户端的心跳,就会重试。Curator客户端支持多种重试策略。临时节点只有在多次重试失败后才会被删除。(因此,选择合适的重试策略也很重要,在锁的粒度和并发之间找到平衡。)总结使用Zookeeper实现分布式锁的优势,有效解决单点问题,不可重入问题,非常Blocking的问题和锁无法释放的问题。实现起来比较简单。使用Zookeeper实现分布式锁的缺点是不如使用缓存实现分布式锁。有必要了解ZK的原理。三种方案的比较以上几种方法,没有一种是十全十美的。就像CAP一样,不能同时满足复杂度、可靠性和性能。因此,根据不同的应用场景选择最合适的才是王道。从易懂性角度(从低到高)数据库>缓存>Zookeeper从实现复杂度角度(从低到高)Zookeeper>=缓存>数据库从性能角度(从高到低)缓存>Zookeeper>=从数据库可靠性来看(从高到低)Zookeeper>缓存>数据库【本文为专栏作者Hollis原创文章,作者微信公众号Hollis(ID:hollishuang)】点此查看更多好文作者的文章
