目前几乎所有的大型网站及应用都是采用分布式部署的方式,分布式系统开发带来的优点很多,高可用,高并发,水平扩展,分开部署等。但分布式的开发也带来了一些新问题,有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,Java中其实提供了很多并发处理相关的API ,也就是我们常说的“锁”(如synchronized,lock),但是这些API在分布式场景中就无能为力了,也就是说Java没有提供分布式锁的功能。
基于分布式锁的实现有多种方案,常见的有基于数据库本身的锁来实现,或者基于zookeeper的API实现,或者是基于缓存来实现分布式锁等等,这些方案都各有可取之处,今天我们介绍的是基于redis的缓存实现分布式锁的方案,大家如果对其他方案有兴趣的可以上网搜索研究。
redis是基于key-value的一种NoSql数据库,广泛应用于分布式的应用中,一般用于放置缓存数据。安装的方法也比较简单,楼主安装的是windows版本的,选择最新的zip版,下载完之后直接解压即可。下载地址:https://github.com/MicrosoftArchive/redis/tags
redis中有一个命令setnx (SET IF NOT EXISTS) , 如果不存在,就设置key,将 key
的值设为 value
,当且仅当 key 不存在。若给定的 key
已经存在,则 SETNX 不做任何动作。基于这个特性,我们可以对需要锁住的对象加上key,这样,同一时间就只能有一个线程拥有这把锁,从而达到分布式锁的效果。下面用一个具体的Java实例来展示redis的分布式锁效果。
Java操作redis需要用到第三方的库类,所以先在pom.xml中引入依赖。
加入依赖后,做一个redis的工具方法,分别实现的是加锁和解锁的功能。
public class RedisLock { @Autowired private StringRedisTemplate redisTemplate; /** * 加锁 * * @param key * @param value 当前时间+超时时间 * @return */ public boolean lock(String key, String value) { //相当于setnx命令 if (redisTemplate.opsForValue().setIfAbsent(key, value)) { return true; } //下面的这段代码是判断之前加的锁是否超时,是的话就更新,一定要加这段代码 //不然就有可能出现死锁。 String currentValue = redisTemplate.opsForValue().get(key); //如果锁过期 if (!StringUtils.isEmpty(currentValue) && Long.parseLong(currentValue) < System.currentTimeMillis()) { //获取上一个锁的时间,这段代码的判断是防止多线程进入这里,只会有一个线程拿到锁 String oldValue = redisTemplate.opsForValue().getAndSet(key, value); if (!StringUtils.isEmpty(oldValue) && oldValue.equals(currentValue)) { return true; } } return false; } /** * 解锁 * * @param key * @param value */ public void unLock(String key, String value) { try { String currentValue = redisTemplate.opsForValue().get(key); if (!StringUtils.isEmpty(currentValue) && currentValue.equals(value)) { redisTemplate.opsForValue().getOperations().delete(key); } } catch (Exception e) { log.error("【redis分布式锁】 解锁异常,{}", e); } } }
现在,我们模拟一个下单的场景,假设有一个秒杀的活动,同一时间有多个线程对同一个产品进行访问,然后分别看看加锁和没加锁的结果来做对比。下面是秒杀的模拟代码:
public class SecKillController { @Autowired private SecKillService secKillService; /** * 查询秒杀活动特价商品的信息 * @param productId * @return * @throws Exception */ @GetMapping("/query/{productId}") public String query(@PathVariable String productId) throws Exception{ return secKillService.querySecKillProductInfo(productId); } /** * 秒杀的方法 * @param productId * @return * @throws Exception */ @GetMapping("/order/{productId}") public String skill(@PathVariable String productId) throws Exception{ log.info("@skill request ,productId:" +productId); secKillService.orderProductKill(productId); return secKillService.querySecKillProductInfo(productId); } }
public class SecKillServiceImpl implements SecKillService { private static final int TIME_OUT = 1 * 1000; @Autowired private RedisLock redisLock; static Map<String, Integer> products; static Map<String, Integer> stock; static Map<String, String> orders; static { /** * 模拟多个表,商品信息表,库存表,秒杀成功订单表 */ products = new HashMap<>(); stock = new HashMap<>(); orders = new HashMap<>(); products.put("123", 100000); stock.put("123", 100000); } /** * @param productId 订单id * @return */ private String queryMap(String productId) { return "限量份数" + products.get(productId) + "还剩:" + stock.get(productId) + "份" + "该商品成功下单用户数目:" + orders.size() + "人"; } @Override public String querySecKillProductInfo(String productId) { return this.queryMap(productId); } @Override public void orderProductKill(String productId) { //1.查询该商品库存,为0则活动结束 int stockNum = stock.get(productId); if (stockNum == 0) { throw new RuntimeException("活动结束"); } else { //2.下单(模拟不同用户openid不同) orders.put(KeyUtil.getUniqueKey(), productId); //3.减库存 stockNum = stockNum - 1; try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } stock.put(productId, stockNum); } } }
先模拟没加锁的下单状态,我们开启工程后,用Apache ab作为压测工具来模拟高并发访问过程
在浏览器上访问查询后的订单数量,结果显示如下:
可以看到,再高并发的访问环境下,如果我们没有对订单做锁的处理,那么就可能出现数据的紊乱,导致结果不对应,这显然不符合我们的需求,下面我们来看看加上redis锁之后的访问情况,先把service中的秒杀代码加上锁。
@Override public void orderProductKill(String productId) { //加锁,保证下面的代码单线程的访问 long time = System.currentTimeMillis() + TIME_OUT; if (!redisLock.lock(productId, String.valueOf(time))) { throw new RuntimeException( "下单失败"); } //1.查询该商品库存,为0则活动结束 int stockNum = stock.get(productId); if (stockNum == 0) { throw new RuntimeException("活动结束"); } else { //2.下单(模拟不同用户openid不同) orders.put(KeyUtil.getUniqueKey(), productId); //3.减库存 stockNum = stockNum - 1; try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } stock.put(productId, stockNum); } //解锁 redisLock.unLock(productId, String.valueOf(time)); }
然后再进行同样的操作
我们可以看到,加上锁之后的订单处理数量是正确的,也就是redis锁是起到了作用的,这是符合我们的需求的。
上面的例子相对比较简单,因为精力能力有限,楼主没法给大家展示真正的分布式锁的实现效果,但从原理上其实是一样的,都是用redis的setnx命令来加上锁,保证分布式环境下锁住的对象只能被一个线程访问,而且从实现方式上来说也比较简单 (只需要一个命令就行,很深入人心 ) ,因此,redis在分布式锁的应用中也被广泛使用。