背景
目前大部分应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。
分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”
取舍
在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。
为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等
选择标准:
1 保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。
2 有高可用的获取锁和释放锁功能
方案
- 有高可用的获取锁和释放锁功能
- 基于缓存(redis,memcached,tair)实现分布式锁
- 基于Zookeeper实现分布式锁
1 基于数据库实现分布式锁
最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。 当我们要锁住某个方法或资源lock_name时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录。 原理:对lock_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。
代码操作:
-- 锁住某个方法时,执行以下SQL:insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)-- 当方法执行完毕之后,想要释放锁的话,需要执行以下Sql:delete from methodLock where method_name ='method_name'
这种简单实现能会出现的问题:
1、这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。2、这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。3、这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。4、这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
问题的解决方案:
1 数据库是单点?搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。2 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。3 非阻塞的?搞一个while循环,直到insert成功再返回成功。4 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
基于数据库排他锁
除了可以通过增删操作数据表中的记录以外,其实还可以借助数据中自带的锁来实现分布式的锁。
基于MySql的InnoDB引擎,可以使用以下方法来实现加锁操作:
public boolean lock(){ connection.setAutoCommit(false) while(true){ try{ result = select * from methodLock where method_name=xxx for update; if(result==null){ return true; } }catch(Exception e){ } sleep(1000); } return false;}
释放锁: 通过connection.commit()
操作来释放锁。
数据库分布式锁总结
数据库实现分布式锁的优点直接借助数据库,容易理解。数据库实现分布式锁的缺点会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。操作数据库需要一定的开销,性能问题需要考虑。使用数据库的行级锁并不一定靠谱,尤其是当我们的锁表并不大的时候。
基于缓存实现分布式锁
相比较于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点。而且很多缓存是可以集群部署的,可以解决单点问题。
redis分布式锁原理:
SETNX命令(SET if Not eXists)语法:SETNX key value功能:当且仅当 key 不存在,将 key 的值设为 value ,并返回1;若给定的 key 已经存在,则 SETNX 不做任何动作,并返回0。GETSET命令语法:GETSET key value功能:将给定 key 的值设为 value ,并返回 key 的旧值 (old value),当 key 存在但不是字符串类型时,返回一个错误,当key不存在时,返回nil。GET命令语法:GET key功能:返回 key 所关联的字符串值,如果 key 不存在那么返回特殊值 nil 。DEL命令语法:DEL key [KEY …]功能:删除给定的一个或多个 key ,不存在的 key 会被忽略。兵贵精,不在多。分布式锁,我们就依靠这四个命令处理死锁在上面的处理方式中,如果获取锁的客户端端执行时间过长,进程被kill掉,或者因为其他异常崩溃,导致无法释放锁,就会造成死锁。所以,需要对加锁要做时效性检测。因此,我们在加锁时,把当前时间戳作为value存入此锁中,通过当前时间戳和Redis中的时间戳进行对比,如果超过一定差值,认为锁已经时效,防止锁无限期的锁下去
代码枷锁:
public class RedisTool { private static final String LOCK_SUCCESS = "OK"; private static final String SET_IF_NOT_EXIST = "NX"; private static final String SET_WITH_EXPIRE_TIME = "PX"; /** * 尝试获取分布式锁 * @param jedis Redis客户端 * @param lockKey 锁 * @param requestId 请求标识 * @param expireTime 超期时间 * @return 是否获取成功 */ public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) { String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime); if (LOCK_SUCCESS.equals(result)) { return true; } return false; }}
代码 解锁:
public class RedisTool { private static final Long RELEASE_SUCCESS = 1L; /** * 释放分布式锁 * @param jedis Redis客户端 * @param lockKey 锁 * @param requestId 请求标识 * @return 是否释放成功 */ public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId)); if (RELEASE_SUCCESS.equals(result)) { return true; } return false; }}
redis 2
package com.wow.supplychain.service.lock;import com.wow.supplychain.redis.JedisManager;import com.wow.supplychain.service.config.LogisticsConfig;import org.apache.commons.lang.StringUtils;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.stereotype.Component;import redis.clients.jedis.Jedis;@Component("distributedLock")public class DistributedLock implements Lock { /** * 日志 */ private static final Logger logger = LoggerFactory.getLogger(DistributedLock.class); private static final String lockkey = "SHOP"; @Override public Boolean fetch(String key) { return fetch(key, 300000L); } @Override public Boolean fetch(String key, Long timeout) { String fullKey = lockkey +LogisticsConfig.getInstance().getLockKey() + key; /** * 尝试获取分布式锁,超时时间默认是轮询时间的1/2 */ Jedis jedis = null; try { logger.debug("try lock key: " + key); jedis = JedisManager.getJedisPool().getResource(); String ret = jedis.set(fullKey, fullKey, "NX", "PX", timeout); if (StringUtils.equalsIgnoreCase("OK", ret)) { logger.info("get lock, key: " + fullKey + " , expire in " + timeout + " seconds."); return Boolean.TRUE; } else { // 存在锁 logger.warn("key: " + fullKey + " locked by another business!"); return Boolean.FALSE; } } catch (Exception e) { logger.error("fetch lock exception: ", e); return Boolean.FALSE; } finally { if (jedis != null) { jedis.close(); } } } @Override public void realease(String key) { Jedis jedis = null; try { String fullKey =lockkey+LogisticsConfig.getInstance().getLockKey() + key; jedis = JedisManager.getJedisPool().getResource(); jedis.del(fullKey); } finally { if (jedis != null) { jedis.close(); } } }}
分布式锁总结:
使用缓存实现分布式锁的优点性能好,实现起来较为方便。使用缓存实现分布式锁的缺点通过超时时间来控制锁的失效时间并不是十分的靠谱。
基于Zookeeper实现分布式锁
基于zookeeper临时有序节点可以实现的分布式锁。每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。
zookeeper 优点:
锁无法释放?使用Zookeeper可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。非阻塞锁?使用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。不可重入?使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。单点问题?使用Zookeeper可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务
zookeeper 缺点:
Zookeeper实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同不到所有的Follower机器上。
zookeeper 可能存在的问题:
其实,使用Zookeeper也有可能带来并发问题,只是并不常见而已。考虑这样的情况,由于网络抖动,客户端可ZK集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。就可能产生并发问题。这个问题不常见是因为zk有重试机制,一旦zk集群检测不到客户端的心跳,就会重试,Curator客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。(所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡。)
三种方案的比较
上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。
从理解的难易程度角度(从低到高)
数据库 > 缓存 > Zookeeper
从实现的复杂性角度(从低到高)
Zookeeper >= 缓存 > 数据库
从性能角度(从高到低)
缓存 > Zookeeper >= 数据库
从可靠性角度(从高到低)
Zookeeper > 缓存 > 数据库