业务-抽奖减库存-数据库实现分布式锁
一、什么是分布式锁及常见的解决方案
随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的Java API并不能提供分布式锁的能力。为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题!
分布式锁主流的实现方案:
- 基于数据库实现分布式锁
- 基于缓存(Redis等)
- 基于Zookeeper
- 每一种分布式锁解决方案都有各自的优缺点:
- 性能:redis最高
- 可靠性:zookeeper最高
-
最近项目有单需求,抽奖活动,当客户命中某个奖品时,会减去奖品的库存量,项目是分布式的并且没有用任何的缓存服务器,所有只能借助于数据库
二、数据库设计。
1、建表 business_lock
CREATE TABLE huas.business_lock ( name VARCHAR(64) NOT NULL PRIMARY KEY, lock_until VARCHAR(14) , locked_at VARCHAR(14) , locked_by VARCHAR(32) , lock_ip CHAR(32) , id VARCHAR(32) ) engine=InnoDB; ALTER TABLE huas.business_lock MODIFY name VARCHAR(64) NOT NULL COMMENT '锁名称'; ALTER TABLE huas.business_lock MODIFY lock_until VARCHAR(14) COMMENT '锁的结束时间'; ALTER TABLE huas.business_lock MODIFY locked_at VARCHAR(14) COMMENT '锁的开始时间'; ALTER TABLE huas.business_lock MODIFY locked_by VARCHAR(32) COMMENT '锁定人'; ALTER TABLE huas.business_lock MODIFY lock_ip CHAR(32) COMMENT '锁定ip'; ALTER TABLE huas.business_lock MODIFY id VARCHAR(32) COMMENT '当前锁的id';
字段解析
name : 锁的名称,主键
lock_until:锁的结束时间,即过期时间,为什么要有这个,为了防止死锁,比如A服务器先加锁,然后宕机了,就释放不了锁了,此后任何请求过来都获取不到锁了,设置过期时间,是在获取的时候判断上一个锁有没有过期,有就先释放上一个锁。
locked_at:锁的开始时间
locked_by:锁定人
lock_ip:当前锁的主机IP
id:锁的序号,为什么要加一个序号?因为当A机加锁后,A服务器卡顿,B机先判断锁否过期,过期后B机加锁,此时A服务器恢复,然后删除,就会把B机的锁给释放掉,所以加了UUID防止误删除,通过ID判断,是否是自己加的锁,只能释放自己的锁。
三、程序设计。
之前写过公司另外一个项目组的项目需求,另外一个项目组里面的自动任务为了保证互斥性也使用到了分布式锁,为了保证程序的高可用然后又参考自动任务锁的解决方案,我们这里采用了自定义注解,加Spring AOP的方式来实现。
1、自定义注解
package com.mangoubiubiu.annotation; import java.lang.annotation.*; /** * 分布式锁 * @author mangoubiubiu */ //作用在方法上 @Target(value = {ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME)//注解可以在运行期的加载阶段被加载到Class对象中。那么在程序运行阶段,我们可以通过反射得到这个注解,并通过判断是否有这个注解或这个注解中属性的值,从而执行不同的程序代码段 @Documented//是被用来指定自定义注解是否能随着被定义的java文件生成到JavaDoc文档当中。 public @interface BusinessLockAno { String name() default "";//锁的名称 String time() default "";//锁的时长 }
2、切面类
package com.mangoubiubiu.aspect; import com.mangoubiubiu.annotation.BusinessLockAno; import com.mangoubiubiu.entities.BusinessLock; import com.mangoubiubiu.mapper.BusinessLockMapper; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.springframework.stereotype.Component; import java.net.InetAddress; import java.text.ParseException; import java.text.SimpleDateFormat; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Calendar; import java.util.Date; import java.util.UUID; /** * 分布式锁切面类 * @author mangoubiubiu */ @Slf4j @Aspect @Component @AllArgsConstructor public class LockAspect { private BusinessLockMapper lockMapper; /** * 切入点 */ @Pointcut("@annotation(com.mangoubiubiu.annotation.BusinessLockAno)") public void pointCut(){} /** * 环绕通知 * @param joinPoint * @return */ @Around("@annotation(businessLockAno) && pointCut()") public Object around(ProceedingJoinPoint joinPoint, BusinessLockAno businessLockAno) { Object result = null; try{ //锁的名称 String name = businessLockAno.name(); //锁的时间 String time = businessLockAno.time(); //true 为锁定状态 false为解锁状态 boolean lockFlag = this.getLock(name); if(!lockFlag){ String uuid = UUID.randomUUID().toString().replace("-", ""); String hostName = InetAddress.getLocalHost().getHostName(); String currentIpAddress = InetAddress.getByName(hostName).getHostAddress(); //加锁 BusinessLock lock = new BusinessLock(); lock.setName(name); lock.setId(uuid); lock.setLockedAt(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))); lock.setLockIp(currentIpAddress); lock.setLockUntil(getLockerTime(Long.valueOf(time))); boolean insertFlag = insert(lock); if(insertFlag){ //执行业务方法 //执行目标方法 result = joinPoint.proceed(); //解锁 解铃还须系铃人 解锁的人 必须是锁定者, lockMapper.delLock(name,uuid,currentIpAddress); }else{ } } }catch (Throwable e){ e.printStackTrace(); } return result; } private String getLockerTime(Long time){ SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); Date date = new Date(); long l = date.getTime(); long lockTime = l+time; Date date2 = new Date(); date2.setTime(lockTime); return sdf.format(date2); } private boolean insert(BusinessLock businessLock){ boolean flag=true; try { lockMapper.insert(businessLock); }catch (Exception e){ flag = false; } return flag; } private String getCurrentTime(Long time){ SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); Date date = new Date(); return sdf.format(date); } /** * 获取锁 true 为锁定状态 false为解锁状态 * @param name * @return */ public boolean getLock(String name) throws ParseException { boolean flag = true; //通过锁的名字获取锁 BusinessLock businessLock = lockMapper.selectByPrimaryKey(name); if(businessLock != null){ //判断锁是否过期 Date nowTime = new Date(); Calendar nowCalendar = Calendar.getInstance(); nowCalendar.setTime(nowTime); String lockedUntil = businessLock.getLockUntil(); SimpleDateFormat sf= new SimpleDateFormat("yyyyMMddHHmmss"); Date parse = sf.parse(lockedUntil); Calendar lockedTime = Calendar.getInstance(); lockedTime.setTime(parse); //锁定时间在当前时间之后释放锁 if(nowCalendar.after(lockedTime)){ lockMapper.deleteByPrimaryKey(name); flag = false; } }else { flag = false; } return flag; } }
四、测试。
1、准备测试数据及方法
先来张临时表,来上10个库存。
CREATE TABLE huas.temp ( id VARCHAR(32) NOT NULL PRIMARY KEY, num VARCHAR(32) ) engine=InnoDB;
这里使用ab测试工具模拟并发
具体安装步骤详见
https://www.cnblogs.com/mangoubiubiu/p/15799999.html
测试Service
package com.mangoubiubiu.service.impl; import com.mangoubiubiu.annotation.BusinessLockAno; import com.mangoubiubiu.entities.Temp; import com.mangoubiubiu.mapper.TempMapper; import com.mangoubiubiu.service.TempService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @Slf4j @Service @RequiredArgsConstructor public class TempServiceImpl implements TempService { final TempMapper mapper; @BusinessLockAno(name = "operLock",time = "6000") @Override public void testLock() { Temp temp = mapper.selectByPrimaryKey("0001"); String num = temp.getNum(); if(!"0".equals(num)){ log.info("{}测试加锁减去库存---------》{}",Thread.currentThread().getName(),num); temp.setNum(String.valueOf(Integer.valueOf(num)-1)); mapper.updateByPrimaryKey(temp); } } }
2、测试不加分布式锁的情况
注释掉咱们注解的注解
开启服务,模拟10个请求2个并发
ab -n 10 -c 2 http://192.168.10.1:8180/huas/user/test
发现同一个资源被操作了多次,还剩4个库存。
3、测试加分布式锁的情况
打开自定义注解
还是10个请求2个并发,模拟后发现,同一个资源被操作了一次,但是还是会剩4个,这里就有库存遗留问题。
五、优化方案,解决库存遗留问题。
库存遗留如果是Redis的话,可以用LUA脚本来解决,详见:https://www.cnblogs.com/mangoubiubiu/p/15810289.html
而我们上面数据库之前的处理,没有获取到锁就不执行业务方法了。
优化,没有获取到锁等待一秒,重新获取。
修改代码
package com.mangoubiubiu.aspect; import com.mangoubiubiu.annotation.BusinessLockAno; import com.mangoubiubiu.entities.BusinessLock; import com.mangoubiubiu.mapper.BusinessLockMapper; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.springframework.stereotype.Component; import java.net.InetAddress; import java.text.ParseException; import java.text.SimpleDateFormat; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Calendar; import java.util.Date; import java.util.UUID; /** * 分布式锁切面类 * @author mangoubiubiu */ @Slf4j @Aspect @Component @AllArgsConstructor public class LockAspect { private BusinessLockMapper lockMapper; /** * 切入点 */ @Pointcut("@annotation(com.mangoubiubiu.annotation.BusinessLockAno)") public void pointCut(){} /** * 环绕通知 * @param joinPoint * @return */ @Around("@annotation(businessLockAno) && pointCut()") public Object around(ProceedingJoinPoint joinPoint, BusinessLockAno businessLockAno) { Object result = null; try{ //锁的名称 String name = businessLockAno.name(); //锁的时间 String time = businessLockAno.time(); //true 为锁定状态 false为解锁状态 boolean lockFlag = this.getLock(name); if(!lockFlag){ String uuid = UUID.randomUUID().toString().replace("-", ""); String hostName = InetAddress.getLocalHost().getHostName(); String currentIpAddress = InetAddress.getByName(hostName).getHostAddress(); //加锁 BusinessLock lock = new BusinessLock(); lock.setName(name); lock.setId(uuid); lock.setLockedAt(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))); lock.setLockIp(currentIpAddress); lock.setLockUntil(getLockerTime(Long.valueOf(time))); boolean insertFlag = insert(lock); if(insertFlag){ //执行目标业务方法 result = joinPoint.proceed(); //解锁 解铃还须系铃人 解锁的人 必须是锁定者。 lockMapper.delLock(name,uuid,currentIpAddress); }else{ } } }catch (Throwable e){ e.printStackTrace(); } return result; } private String getLockerTime(Long time){ SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); Date date = new Date(); long l = date.getTime(); long lockTime = l+time; Date date2 = new Date(); date2.setTime(lockTime); return sdf.format(date2); } private boolean insert(BusinessLock businessLock){ boolean flag=true; try { lockMapper.insert(businessLock); }catch (Exception e){ try { //第一次没有获取锁,这里等待一秒重新获取。 Thread.sleep(1000); lockMapper.insert(businessLock); } catch (Exception ex) { flag = false; } } return flag; } private String getCurrentTime(Long time){ SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); Date date = new Date(); return sdf.format(date); } /** * 获取锁 true 为锁定状态 false为解锁状态 * @param name * @return */ public boolean getLock(String name) throws ParseException { boolean flag = true; //通过锁的名字获取锁 BusinessLock businessLock = lockMapper.selectByPrimaryKey(name); if(businessLock != null){ //判断锁是否过期 Date nowTime = new Date(); Calendar nowCalendar = Calendar.getInstance(); nowCalendar.setTime(nowTime); String lockedUntil = businessLock.getLockUntil(); SimpleDateFormat sf= new SimpleDateFormat("yyyyMMddHHmmss"); Date parse = sf.parse(lockedUntil); Calendar lockedTime = Calendar.getInstance(); lockedTime.setTime(parse); //锁定时间在当前时间之后释放锁 if(nowCalendar.after(lockedTime)){ lockMapper.deleteByPrimaryKey(name); flag = false; } }else { flag = false; } return flag; } }
重新测试,发现有些时候扔然减不掉全部库存,比如我这里第一次还是3个,但是这种情况概率很小,优化这种方案可以使用递归的方式获取锁(详见:https://www.cnblogs.com/mangoubiubiu/p/16538381.html)。