分布式锁的三种实现方式
一、基本概念
1、引入
传统的锁都是有JDK官方提供的锁的解决方案,也就是说这些锁只能在一个JVM进程内有效,我们把这种锁叫做单体应用锁。但是,在互联网高速发展的今天,单体应用锁能够满足我们的需求吗?
新的阅读体验:http://www.zhouhong.icu/post/143
本篇文章所有代码:https://github.com/Tom-shushu/Distributed-system-learning-notes/
2、互联网系统架构的演进
在互联网系统发展之初,系统比较简单,消耗资源小,用户访问量也比较少,我们只部署一个Tomcat应用就可以满足需求。系统架构图如下:
一个Tomcat可以看作是一个JVM进程,当大量的请求并发到达系统时,所有的请求都落在这唯一的一个Tomcat上,如果某些请求方法是需要加锁的,比如:秒杀扣减库存,是可以满足需求的,这和我们前面章节所讲的内容是一样的。但是随着访问量的增加,导致一个Tomcat难以支撑,这时我们就要集群部署Tomcat,使用多个Tomcat共同支撑整个系统
上图中,我们部署了两个Tomcat,共同支撑系统。当一个请求到达系统时,首先会经过Nginx,Nginx主要是做负载转发的,它会根据自己配置的负载均衡策略将请求转发到其中的一个Tomcat中。当大量的请求并发访问时,两个Tomcat共同承担所有的访问量,这时,我们同样在秒杀扣减库存的场景中,使用单体应用锁,还能够满足要求吗?
3、单体应用锁的局限性
如上图所示,在整个系统架构中,存在两个Tomcat,每个Tomcat是一个JVM。在进行秒杀业务的时候,由于大家都在抢购秒杀商品,大量的请求同时到达系统,通过Nginx分发到两个Tomcat上。我们通过一个极端的案例场景,可以更好地理解单体应用锁的局限性。假如,秒杀商品的数量只有1个,这时,这些大量的请求当中,只有一个请求可以成功的抢到这个商品,这就需要在扣减库存的方法上加锁,扣减库存的动作只能一个一个去执行,而不能同时去执行,如果同时执行,这1个商品可能同时被多个人抢到,从而产生超卖现象。加锁之后,扣减库存的动作一个一个去执行,凡是将库存扣减为负数的,都抛出异常,提示该用户没有抢到商品。通过加锁看似解决了秒杀的问题,但是事实上真的是这样吗?
我们看到系统中存在两个Tomcat,我们加的锁是JDK提供的锁,这种锁只能在一个JVM下起作用,也就是 在一个Tomcat内是没有问题的。当存在两个或两个以上的Tomcat时,大量的并发请求分散到不同的Tomcat上,在每一个Tomcat中都可以防止并发的产生,但是在多个Tomcat之间,每个Tomcat中获得锁的这个请求,又产生了并发,从而产生超卖现象。这也就是单体应用锁的局限性,它只能在一个JVM内加锁,而不能从这个应用层面去加锁。
那么这个问题如何解决呢?这就需要使用分布式锁了,在整个应用层面去加锁。什么是分布式锁呢?我们怎么去使用分布式锁呢?
4、什么是分布式锁
在说分布式锁之前,我们看一看单体应用锁的特点,单体应用锁是在一个JVM进程内有效,无法跨JVM、跨进程。那么分布式锁的定义就出来了,分布式锁就是可以跨越多个JVM、跨越多个进程的锁,这种锁就叫做分布式锁。
5、分布式锁的设计思路
在上图中,由于Tomcat是由Java启动的,所以每个Tomcat可以看成一个JVM,JVM内部的锁是无法跨越多个进程的。所以,我们要实现分布式锁,我们只能在这些JVM之外去寻找,通过其他的组件来实现分布式锁。系统的架构如图所示:
两个Tomcat通过第三方的组件实现跨JVM、跨进程的分布式锁。这就是分布式锁的解决思路,找到所有JVM可以共同访问的第三方组件,通过第三方组件实现分布式锁。
6、目前存在的分布式的方案
分布式锁都是通过第三方组件来实现的,目前比较流行的分布式锁的解决方案有:
- 数据库,通过数据库可以实现分布式锁,但是在高并发的情况下对数据库压力较大,所以很少使用。
- Redis,借助Redis也可以实现分布式锁,而且Redis的Java客户端种类很多,使用的方法也不尽相同。
- Zookeeper,Zookeeper也可以实现分布式锁,同样Zookeeper也存在多个Java客户端,使用方法也不相同。
二、电商平台中针对超卖的解决思路
① 单体架构下针对超卖的解决方案
1、超卖现象一
什么是超卖:某件商品库存数量10件,结果卖出了15件。
A和B同时下单,同时读到库存,同时减库存,同时更新数据库,这是库存减1,结果却下单了两件。
解决办法:
扣减库存不在程序中进行,同时通过数据库解决;向数据库传递库存增量,扣减1个库存,增量为-1;在数据库update语句计算库存,通过update行锁解决并发问题
2、超卖现象二
使用上述通过数据库行锁解决,会出现下面的第二种现象:数据库的库存会减为负数。
解决方案一
更新库存成功后,再次检索商品库存,如果商品为负数,抛出异常。
解决方案二
添加锁、将数据库库存校验和数据库库存更新绑定到一起加上锁,每次只能有一个得到这个锁,从而避免库存被减为负数(-1)的情况。
3、具体代码实现
- 创建数据库表
CREATE DATABASE /*!32312 IF NOT EXISTS*/`distribute` /*!40100 DEFAULT CHARACTER SET utf8mb4 */; USE `distribute`; DROP TABLE IF EXISTS `order`; CREATE TABLE `order` ( `id` int(11) NOT NULL AUTO_INCREMENT, `order_status` int(1) NOT NULL, `receiver_name` varchar(255) NOT NULL, `receiver_mobile` varchar(11) NOT NULL, `order_amount` decimal(11,0) NOT NULL, `create_time` time NOT NULL, `create_user` varchar(255) NOT NULL, `update_time` time NOT NULL, `update_user` varchar(255) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8mb4; DROP TABLE IF EXISTS `order_item`; CREATE TABLE `order_item` ( `id` int(11) NOT NULL AUTO_INCREMENT, `order_id` int(11) NOT NULL, `product_id` int(11) NOT NULL, `purchase_price` decimal(11,0) NOT NULL, `purchase_num` int(3) NOT NULL, `create_time` time NOT NULL, `create_user` varchar(255) NOT NULL, `update_time` time NOT NULL, `update_user` varchar(255) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4; DROP TABLE IF EXISTS `product`; CREATE TABLE `product` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'id', `product_name` varchar(255) NOT NULL COMMENT '商品名称', `price` decimal(11,0) NOT NULL COMMENT '价格', `count` int(5) NOT NULL COMMENT '库存', `product_desc` varchar(255) NOT NULL COMMENT '描述', `create_time` time NOT NULL COMMENT '创建时间', `create_user` varchar(255) NOT NULL COMMENT '创建人', `update_time` time NOT NULL COMMENT '更新时间', `update_user` varchar(255) NOT NULL COMMENT '更新人', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=100101 DEFAULT CHARSET=utf8mb4; insert into `product`(`id`,`product_name`,`price`,`count`,`product_desc`,`create_time`,`create_user`,`update_time`,`update_user`) values (100100,'测试商品','1',1,'测试商品','18:06:00','周红','19:19:21','xxx');/**后续分布式锁需要用到**/DROP TABLE IF EXISTS `distribute_lock`; CREATE TABLE `distribute_lock` ( `id` int(11) NOT NULL AUTO_INCREMENT, `business_code` varchar(255) NOT NULL COMMENT '根据业务代码区分,不同业务使用不同锁', `business_name` varchar(255) NOT NULL COMMENT '注释,标记编码用途', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4; insert into `distribute_lock`(`id`,`business_code`,`business_name`) values (1,'demo','test');
- 订单创建,库存减1等主要逻辑代码(这里使用 ReentrantLock 当然,也可以使用其他锁 )
// 注意:这边不能使用注解的方式回滚,不然会在事务提交前下一个线程会进来// @Transactional(rollbackFor = Exception.class) public Integer createOrder() throws Exception{ Product product = null; lock.lock(); try { // 开启事务 TransactionStatus transaction1 = platformTransactionManager.getTransaction(transactionDefinition); // 查询到所要购买的商品 product = productMapper.selectByPrimaryKey(purchaseProductId); if (product==null){ platformTransactionManager.rollback(transaction1); throw new Exception("购买商品:"+purchaseProductId+"不存在"); } // 获取商品当前库存 Integer currentCount = product.getCount(); System.out.println(Thread.currentThread().getName()+"库存数:"+currentCount); // 校验库存 (购买商品数量大于库存数量,抛出异常) if (purchaseProductNum > currentCount){ platformTransactionManager.rollback(transaction1); throw new Exception("商品"+purchaseProductId+"仅剩"+currentCount+"件,无法购买"); } productMapper.updateProductCount(purchaseProductNum,"xxx",new Date(),product.getId()); platformTransactionManager.commit(transaction1); }finally { lock.unlock(); } // 创建订单 TransactionStatus transaction = platformTransactionManager.getTransaction(transactionDefinition); Order order = new Order(); order.setOrderAmount(product.getPrice().multiply(new BigDecimal(purchaseProductNum))); order.setOrderStatus(1);//待处理 order.setReceiverName("xxx"); order.setReceiverMobile("15287653421"); order.setCreateTime(new Date()); order.setCreateUser("不不不不"); order.setUpdateTime(new Date()); order.setUpdateUser("哈哈哈哈"); orderMapper.insertSelective(order); // 创建订单明细 OrderItem orderItem = new OrderItem(); orderItem.setOrderId(order.getId()); orderItem.setProductId(product.getId()); orderItem.setPurchasePrice(product.getPrice()); orderItem.setPurchaseNum(purchaseProductNum); orderItem.setCreateUser("不不不"); orderItem.setCreateTime(new Date()); orderItem.setUpdateTime(new Date()); orderItem.setUpdateUser("哈哈哈哈"); orderItemMapper.insertSelective(orderItem); // 事务提交 platformTransactionManager.commit(transaction); return order.getId(); }
- 测试(使用五个线程同时并发的下单)
@Testpublic void concurrentOrder() throws InterruptedException { Thread.sleep(60000); CountDownLatch cdl = new CountDownLatch(5); CyclicBarrier cyclicBarrier = new CyclicBarrier(5); // 创建5个线程执行下订单操作 ExecutorService es = Executors.newFixedThreadPool(5); for (int i =0;i<5;i++){ es.execute(()->{ try { // 等5个线程同时达到 await()时再执行创建订单服务,这时候5个线程会堆积到同一时间执行 cyclicBarrier.await(); Integer orderId = orderService.createOrder(); System.out.println("订单id:"+orderId); } catch (Exception e) { e.printStackTrace(); }finally { // 每个线程执行完成之后会减一 cdl.countDown(); } }); } cdl.await(); es.shutdown(); }
② 分布式架构下分布式锁的实现
一、基于数据库实现分布式锁
多个进程、多个线程访问共同组件---数据库
通过select...for update 访问同一条数据、for update 锁定数据
- 在mapper.xml 里面加入如下自定义的SQL
<select id="selectDistributeLock" resultType="com.example.distributelock.model.DistributeLock"> select * from distribute_lock where business_code = #{businessCode,jdbcType=VARCHAR} for updateselect>
- 主要的逻辑实现
@RequestMapping("singleLock")/** * 没有添加 Transactional 注解前,查询分布式锁和sleep二者不是原子操作,在获取到分布式锁后自动提交事务, * 故不会阻止第二个请求获取锁。添加了注解后,在sleep结束前,事务一直未提交,故会等待sleep结束后再行提交事务, * 此时第二个请求才能从数据库中获取分布式锁 */@Transactional(rollbackFor = Exception.class)public String singleLock() throws Exception { log.info("我进入了方法!"); DistributeLock distributeLock = distributeLockMapper.selectDistributeLock("demo"); if (distributeLock==null) throw new Exception("分布式锁找不到"); log.info("我进入了锁!"); try { Thread.sleep(20000); } catch (InterruptedException e) { e.printStackTrace(); } return "我已经执行完成!"; }
- 另一个项目和这个相同,只需要更改端口号即可
优点:
- 简单方便,易于理解,易于操作
缺点:
- 并发量大,对数据库压力较大
建议:
- 作为锁的数据库与业务数据库分开
二、基于Redis的SetNX实现分布式锁
① 获取锁的Redis命令
- Set resource_name my_random_value NX PX 30000
- resource_name:资源名称,可根据不同的业务区分不同的锁
- my_random_value:随机值,每个线程的随机值都不相同,用于释放锁时的校验
- NX: key不存在是设置成功,key存在则设置不成功
- PX:自动失效时间,出现异常情况,锁可以过期失效
② 实现原理
- 利用NX的原子性,多个线程并发时,只有一个线程可以设置成功
- 设置成功即获得锁,可以执行后续的业务处理
- 如果出现异常,过了锁的有效期,锁自动释放。
- 释放锁采用了Redis的delete命令
- 释放锁时校验值钱设置的随机数,相同才能释放
- 释放锁的LUA脚本
if redis.call("get",KEYS[1])==argv[1] then return redis.call("del",KEYS[1])else return 0end
③ 为什么要添加LUA脚本校验:
没有校验可能导致锁的混乱,如上图所示:A可能释放掉了B的锁,会出现问题。
④ Redis分布式锁关键代码封装
package com.example.distributelock.lock; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.connection.RedisStringCommands; import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.RedisScript; import org.springframework.data.redis.core.types.Expiration; import java.util.Arrays; import java.util.List; import java.util.UUID; @Slf4jpublic class RedisLock implements AutoCloseable { private RedisTemplate redisTemplate; private String key; private String value; // 过期时间 单位:秒 private int expireTime; public RedisLock(RedisTemplate redisTemplate,String key,int expireTime){ this.redisTemplate = redisTemplate; this.key = key; this.expireTime=expireTime; this.value = UUID.randomUUID().toString(); } /** * 获取分布式锁 * @return */ public boolean getLock(){ RedisCallbackredisCallback = connection -> { //设置NX RedisStringCommands.SetOption setOption = RedisStringCommands.SetOption.ifAbsent(); //设置过期时间 Expiration expiration = Expiration.seconds(expireTime); //序列化key byte[] redisKey = redisTemplate.getKeySerializer().serialize(key); //序列化value byte[] redisValue = redisTemplate.getValueSerializer().serialize(value); //执行setnx操作 Boolean result = connection.set(redisKey, redisValue, expiration, setOption); return result; }; //获取分布式锁 Boolean lock = (Boolean)redisTemplate.execute(redisCallback); return lock; } // 释放锁 public boolean unLock() { String script = "if redis.call(\"get\",KEYS[1]) == ARGV[1] then\n" + " return redis.call(\"del\",KEYS[1])\n" + "else\n" + " return 0\n" + "end"; RedisScriptredisScript = RedisScript.of(script,Boolean.class); Listkeys = Arrays.asList(key); Boolean result = (Boolean)redisTemplate.execute(redisScript, keys, value); log.info("释放锁的结果:"+result); return result; } @Override public void close() throws Exception { unLock(); } }
⑤ 测试
@RequestMapping("redisLock")public String redisLock(){ log.info("我进入了方法!"); try (RedisLock redisLock = new RedisLock(redisTemplate,"redisKey",30)){ if (redisLock.getLock()) { log.info("我进入了锁!!"); Thread.sleep(15000); } } catch (InterruptedException e) { e.printStackTrace(); } catch (Exception e) { e.printStackTrace(); } log.info("方法执行完成"); return "方法执行完成"; }
⑥ 在项目中使用ESJOB定时任务没问题,但是如果项目中使用了SpringTask来定时,那么在集群中可能会出现任务重复执行的情况。
解决办法:使用分布式锁在定时到达后,在执行任务之前,哪个节点获取到了锁,哪个节点就来执行这个任务。
⑦ 代码实现
public class SchedulerService { @Autowired private RedisTemplate redisTemplate; @Scheduled(cron = "0/5 * * * * ?") public void sendSms(){ try(RedisLock redisLock = new RedisLock(redisTemplate,"autoSms",30)) { if (redisLock.getLock()){ log.info("每五秒执行这个程序!"); } } catch (Exception e) { e.printStackTrace(); } } }
三、基于Zookeeper实现分布式锁
zookeeper的观察器
- 可设置观察器的3个方法:getData();getChildren();exists();
- 节点数据发生变化,发送给客户端;
- 观察器只能监控一次,再监控需重新设置;
实现原理
- 利用zookeeper的瞬时有序节点的特性;
- 多线程并发创建瞬时节点时,得到有序的序列;
- 序列号最小的线程获得锁;
- 其他线程监听自己序号的前一个序号;
- 前一个线程执行完成,删除自己序号节点;
- 下一个序号的线程得到通知,继续执行;
- 以此类推,创建节点时,已经确定了线程的执行顺序;
代码实现:
package com.example.distributelock.lock; import lombok.extern.slf4j.Slf4j; import org.apache.zookeeper.*; import org.apache.zookeeper.data.Stat; import java.io.IOException; import java.util.Collections; import java.util.List; @Slf4jpublic class ZkLock implements Watcher,AutoCloseable { private ZooKeeper zooKeeper; private String businessName; private String znode; public ZkLock(String connectString,String businessName) throws IOException { this.zooKeeper = new ZooKeeper(connectString,30000,this); this.businessName = businessName; } public boolean getLock() throws KeeperException, InterruptedException { Stat existsNode = zooKeeper.exists("/" + businessName, false); if (existsNode == null){ zooKeeper.create("/" + businessName,businessName.getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); } znode = zooKeeper.create("/" + businessName + "/" + businessName + "_", businessName.getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL); znode = znode.substring(znode.lastIndexOf("/")+1); ListchildrenNodes = zooKeeper.getChildren("/" + businessName, false); Collections.sort(childrenNodes); String firstNode = childrenNodes.get(0); if (!firstNode.equals(znode)){ String lastNode = firstNode; for (String node:childrenNodes){ if (!znode.equals(node)){ lastNode = node; }else { zooKeeper.exists("/"+businessName+"/"+lastNode,true); break; } } synchronized (this){ wait(); } } return true; } @Override public void process(WatchedEvent watchedEvent) { if (watchedEvent.getType() == Event.EventType.NodeDeleted){ synchronized (this){ notify(); } } } @Override public void close() throws Exception { zooKeeper.delete("/"+businessName+"/"+znode,-1); zooKeeper.close(); log.info("我释放了锁"); } }
测试:
@RequestMapping("zkLock") public String zkLock(){ log.info("我进入了方法!"); try (ZkLock zkLock = new ZkLock("localhost:2181","order")){ if (zkLock.getLock()) { log.info("我进入了锁!!"); Thread.sleep(15000); } } catch (InterruptedException e) { e.printStackTrace(); } catch (Exception e) { e.printStackTrace(); } log.info("方法执行完成"); return "方法执行完成"; }