文章参考1:https://blog.youkuaiyun.com/wuzhiwei549/article/details/80692278
分布式锁学习
一、为什么要使用分布式锁
我们在开发应用的时候,如果需要对某一个共享变量进行多线程同步访问的时候,可以使用我们学到的Java多线程进行处理.
注意这是单机应用,也就是所有的请求都会分配到当前服务器的JVM内部,然后映射为操作系统的线程进行处理!而这个共享变量只是在这个JVM内部的一块内存空间!
后来业务发展,需要做集群,一个应用需要部署到几台机器上然后做负载均衡,大致如下图:
上图可以看到,变量A存在JVM1、JVM2、JVM3三个JVM内存中(这个变量A主要体现是在一个类中的一个成员变量,是一个有状态的对象,例如:UserController控制器中的一个整形类型的成员变量),如果不加任何控制的话,变量A同时都会在JVM分配一块内存,三个请求发过来同时对这个变量操作,显然结果是不对的!即使不是同时发过来,三个请求分别操作三个不同JVM内存区域的数据,变量A之间不存在共享,也不具有可见性,处理的结果也是不对的!
如果我们业务中确实存在这个场景的话,我们就需要一种方法解决这个问题!
为了保证一个方法或属性在高并发情况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用Java并发处理相关的API(如ReentrantLock或Synchronized)进行互斥控制。在单机环境中,Java中提供了很多并发处理相关的API。但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的Java API并不能提供分布式锁的能力。为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题!
二、分布式锁的三种实现方式
目前几乎很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。
在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。
1.基于数据库实现分布式锁;
2.基于缓存(Redis等)实现分布式锁;
3.基于Zookeeper实现分布式锁;
搭建环境
pom依赖
除了必须的mysql/jdbc/mybatis/springboot/web等还额外需要:
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!--使用jedis,使用setnx-->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.0.1</version>
</dependency>
<!--使用redisson,使用redlock-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.11.0</version>
</dependency>
<!--curatorframework,使用框架封装的锁-->
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>4.0.1</version>
</dependency>
<!--zkclient,自己实现zk锁-->
<dependency>
<groupId>com.101tec</groupId>
<artifactId>zkclient</artifactId>
<version>0.10</version>
</dependency>
创建domain层
/**
* 订单类
*/
@Data
public class Order {
private String orderId;
private String productId;
}
===========================
/**
* 库存类
*/
@Data
public class Store {
private String productId;
private int storeNum;
private int version;
}
创建UUID工具类,用于生成唯一id
public class UUIDUtil {
public static String getUUID() {
UUID uuid = UUID.randomUUID();
String s = uuid.toString().replaceAll("-", "");
return s;
}
}
创建controller
@RestController
public class MsController {
@Autowired
private MsService msService;
@GetMapping("/info")
public String getInfo(String id) {
return msService.info(id);
}
@GetMapping("/order")
public String getOrder(String id) {
return msService.order(id);
}
}
创建service
@Service
public class MsService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StoreMapper storeMapper;
//信息查询接口
public String info(String id) {
//查询商品库存
Store store = storeMapper.findProductNumById(id);
//查询订单个数,由于抢购,一个人只能买一个
int orderCount = orderMapper.findOrderCount();
return "商品总个数100,还剩" + store.getStoreNum() + "个,已经卖出" + orderCount;
}
/**
* 没有任何锁的情况,并发环境下会有超卖风险
*/
public String order(String id) {
/* 29.825 seconds
* 商品总个数100,还剩-85个,已经卖出185. 超卖了
* */
Store store = storeMapper.findProductNumById(id);
if (store.getStoreNum() < 1) {
return "卖没有了----";
}
//普通的更新方法
int i = storeMapper.updateProductNumById(store);
if (i == 1) {
//添加一个订单
Order order = new Order();
order.setOrderId(UUIDUtil.getUUID());
order.setProductId(id);
int i1 = orderMapper.addOrder(order);
} else {
return "没有抢到";
}
return info(id);
}
}
创建dao
只写xml了,接口省略
OrderMapper.xml
<select id="findOrderCount" resultType="int">
select count(*) from t_order
</select>
<insert id="addOrder" parameterType="com.jd.domain.Order">
insert into t_order
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="orderId !=null">
orderId,
</if>
<if test="productId !=null">
productId,
</if>
</trim>
values (#{orderId},#{productId})
</insert>
StoreMapper.xml
<select id="findProductNumById" resultType="com.jd.domain.Store">
select * from t_store
</select>
<update id="updateProductNumById" parameterType="com.jd.domain.Store">
update t_store set
storeNum = storeNum -1
where productId = #{productId}
</update>
简单测试
并且进行并发1000的测试
使用Apache_ab模拟1000个请求
ab -n1000 -c1000 http://127.0.0.1:8080/order?id=123
可以看到不使用任何锁的情况下,商品超卖了.
/* 29.825 seconds
* 商品总个数100,还剩-85个,已经卖出185. 超卖了
* */
三.基于数据库实现分布式锁
使用数据库乐观锁,使用version字段作为条件控制,当version版本与当前线程持有的版本不同时,说明线程持有的数据过期了,放弃修改.
为server添加order11111方法
/**
* 使用数据库乐观锁
*/
public String order11111(String id) {
/*18.860 seconds
* 商品总个数100,还剩0个,已经卖出100
* */
Store store = storeMapper.findProductNumById(id);
if (store.getStoreNum() < 1) {
return "卖没有了----";
}
//SQL语句中使用version字段
int i = storeMapper.updateProductNumById11111(store);
if (i == 1) {
System.out.println(i);
//添加一个订单II
Order order = new Order();
order.setOrderId(UUIDUtil.getUUID());
order.setProductId(id);
int i1 = orderMapper.addOrder(order);
} else {
return "没有抢到";
}
return info(id);
}
为StoreMapper.xml的update添加控制
<update id="updateProductNumById11111" keyProperty="com.jd.domain.Store">
update t_store set
storeNum = storeNum -1 ,
version=version+1
where productId = #{productId}
and version = #{version}
</update>
测试
并没有发生超卖.
/*18.860 seconds
* 商品总个数100,还剩0个,已经卖出100
* */
缺点:
1、这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。(可做分库)
2、这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。(定时任务,比较麻烦)
3、这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。(死循环解决)
4、这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。(可以通过线程信息控制)
四.基于redis的分布式锁
核心都是因为redis是单线程的,没有并发.核心命令:中的nx 和 px
SET key value NX PX 30000
nx: 只有该key没有才会创建成功
px: 失效时间
分别使用jedis客户端(自己写锁)以及redisson客户端(官方的实现)来实现分布式锁
4.1使用jedis
添加JedisLock类
public class JedisLock {
private static JedisPool jedisPool;
//实现单例jedisPool
private static ReentrantLock lock = new ReentrantLock();
static {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(1001);
config.setMaxIdle(40);
config.setTestOnBorrow(true);
if (null == jedisPool) {
try {
lock.lock();
jedisPool = new JedisPool(config, "192.168.25.25", 6379);
System.out.println(jedisPool);
} finally {
lock.unlock();
}
}
}
public static String getLock(String key) {
String value = null;
//获取客户端
Jedis jedis = jedisPool.getResource();
String random = UUIDUtil.getUUID();
try {
//配置参数nx以及ex失效时间
SetParams params = new SetParams();
params.nx().ex(10);
//循环获取锁,直到获取到才跳出
for (; ; ) {
//注意:如果直接使用setnx,需要保证原子操作(使用事务)
String set = jedis.set(key, random, params);
//成功之后返回"OK"
if ("OK".equals(set)) {
System.out.println("random===" + random);
value = random;
break;
}
//休眠一下,避免过于激烈的争抢
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} finally {
//关闭客户端
jedis.close();
}
return value;
}
public static void delLock(String key, String value) {
Jedis jedis = jedisPool.getResource();
try {
//这里必须是原子操作,使用redis事务
jedis.watch(key);
if (value.equals(jedis.get(key))) {
Transaction multi = jedis.multi();
multi.del(key);
//执行事务累计的操作
multi.exec();
}
jedis.unwatch();
} finally {
jedis.close();
}
}
}
添加service中的order22222()方法
/**
* 使用redis的set(key,value,SetParams)
* 这种方法要是不使用死循环的方式的话,基本上一次并发只有1-2个线程可以抢到
* 如果使用死循环或是递归,排队在前100位即可.
*/
public String order22222(String id) {
/*32.950 seconds
*商品总个数100,还剩0个,已经卖出100
* */
String lock = JedisLock.getLock(id);
System.out.println(Thread.currentThread().getName() + "获得了锁");
//开始抢购
String order = order(id);
//解锁
System.out.println("准备解锁");
JedisLock.delLock(id, lock);
return order;
}
测试
/*32.950 seconds
*商品总个数100,还剩0个,已经卖出100
* */
缺点
1.实现复杂,需要考虑超时/原子/误删等情况;
2.需要自旋,比较浪费资源.
4.2使用redisson
这是官方推荐使用的redlock,使用也很简单
创建RedissonRedLock类
public class RedissonRedLock {
private static RedissonClient redissonClient;
private static ReentrantLock lock=new ReentrantLock();
public static RedissonClient getClient() {
if (null==redissonClient) {
try {
lock.lock();
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.25.25:6379");
redissonClient = Redisson.create(config);
System.out.println(redissonClient);
}finally {
lock.unlock();
}
}
return redissonClient;
}
}
添加service中的order33333()方法
/**
* 使用官方推荐的redlock,redisson客户端已经封装了锁.
* 使用方式与ReentrantLock类似
*/
public String order33333(String id) {
/*23.340 seconds
*商品总个数100,还剩0个,已经卖出100
* */
RedissonClient client = RedissonRedLock.getClient();
//获取锁
RLock lock = client.getLock(id);
String order = null;
try {
if (lock.tryLock(10, TimeUnit.SECONDS)) {
order = order(id);
} else {
System.out.println("获取失败");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放锁
lock.unlock();
}
return order;
}
测试
使用官方的速度快,使用起来也很简单的.
/*23.340 seconds
*商品总个数100,还剩0个,已经卖出100
* */
五.基于zookeeper的分布式锁
利用的是zk的节点唯一性
zk中有四类节点:
1.持久节点 2.持久有序节点 3.临时节点 4.临时有序节点(锁)
和redis方式一样使用两种方式,一种是使用curatorFramework框架封装的zk锁,一种使用zkclient自己实现锁.
5.1使用curatorFramework框架
添加service中的order44444()方法即可
/**
* 使用CuratorFramework客户端封装的锁
* 使用框架封装的锁
*/
public String order44444(String id) {
/* 51.501 seconds
*商品总个数100,还剩0个,已经卖出100
* */
CuratorFramework client = CuratorFrameworkFactory.newClient("192.168.25.25:2181", new RetryNTimes(10, 5000));
//必须start(),并且只能开始一次.
client.start();
String order = null;
try {
//这是一个可重入锁,在/lock/id/下创建临时节点
InterProcessMutex lock = new InterProcessMutex(client, "/lock/" + id);
try {
//获取锁,也可以添加超时时间
lock.acquire();
order = order(id);
} finally {
//释放锁
lock.release();
//记得关闭客户端
client.close();
}
} catch (Exception e) {
e.printStackTrace();
}
return order;
}
测试
速度不如缓存实现,但是zk的可靠性高
/* 51.501 seconds
*商品总个数100,还剩0个,已经卖出100
* */
5.2使用ZkClient自己实现锁
添加ZkClientLock类
public class ZkClientLock {
private final String zkAddress = "192.168.25.25:2181";
private final String rootNode = "/lock";
private String zkNode;
private ZkClient zkClient;
//最后将完整路径返回,方便删除
private String lockPath;
public ZkClientLock(String zkNode) {
this.zkNode = "/" + zkNode + "_";
}
public void init() {
zkClient = new ZkClient("192.168.25.25:2181", 80000);
//根节点是否存在
boolean exists = zkClient.exists(rootNode);
if (!exists) {
zkClient.create(rootNode, null, CreateMode.PERSISTENT);
}
//创建临时有序的节点,并将值赋给lockPath
lockPath = zkClient.create(rootNode + zkNode, new Byte[0], CreateMode.EPHEMERAL_SEQUENTIAL);
}
public String tryLock() {
boolean getLock = false;
init();
//获取当前根节点下的所有的节点,并排序
List<String> zkNodeList = zkClient.getChildren(rootNode);
Collections.sort(zkNodeList);
//查看当前的节点在list中第一次出现的的位置,因为是有序,不重复的,so第一次出现的位置就是当前在根节点下的位置
//将节点去除掉根节点路径
String zkNode1 = lockPath.substring(rootNode.length() + 1);
//在rootnode中的位置
int index = zkNodeList.indexOf(zkNode1);
if (index == 0) {
System.out.println(Thread.currentThread().getName() + "----最小的节点,获取到锁");
//保证第2个可以监听到第一个的删除事件
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
return lockPath;
} else {
//监控上一个节点
//System.out.println(Thread.currentThread().getName() + "--监控上一个节点");
String preZkNode = rootNode + "/" + zkNodeList.get(index - 1);
//添加监听器
CountDownLatch countDownLatch = new CountDownLatch(1);
IZkDataListener preListener = new IZkDataListener() {
@Override
public void handleDataChange(String s, Object o) throws Exception {
}
@Override
public void handleDataDeleted(String s) throws Exception {
countDownLatch.countDown();
}
};
//将监听的节点和监听器绑定起来
zkClient.subscribeDataChanges(preZkNode, preListener);
//等待删除
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
//当其删除后
System.out.println(Thread.currentThread().getName() + "--上一个节点删除了");
zkClient.unsubscribeDataChanges(preZkNode, preListener);
return lockPath;
}
}
public void delLock(String lockPath) {
try {
boolean d = false;
if (zkClient.exists(lockPath)) {
//循环删除,保证一定要删除成功
while (!d) {
d = zkClient.deleteRecursive(lockPath);
}
}
} finally {
zkClient.close();
}
}
}
为service添加order55555()方法
/**
* 使用zkclient测试分布式锁
* 自己实现锁
*/
public String order55555(String id) {
/* 47.346 seconds
*商品总个数100,还剩0个,已经卖出100
* */
ZkClientLock zkLock = new ZkClientLock(id);
String s = null;
String order = null;
try {
s = zkLock.tryLock();
order = order(id);
} finally {
zkLock.delLock(s);
}
return order;
}
测试
/* 47.346 seconds
*商品总个数100,还剩0个,已经卖出100
* */
总结:
三种方案的比较
上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。
从性能角度(从高到低)
缓存 > Zookeeper >= 数据库
从可靠性角度(从高到低)
Zookeeper > 缓存 > 数据库
代码下载:
https://download.youkuaiyun.com/download/sqlgao22/11939653