领取优惠券
需求介绍
-
优惠券业务需求介绍
- 新用户注册-发放后端配置的新人优惠券
- 用户可以主动领取优惠券
- 下单可以选择对应的优惠券抵扣
- 支持满减优惠券-无门槛优惠券两种
- 多种元数据配置
- 类型:无门槛、满减等
- 每人领劵次数限制
- 发券总量控制
- 优惠券开始时间和结束时间
- 优惠券状态配置
-
核心知识:
- 高并发下扣减劵库存
- 超发
- 单人超领取
- 高并发下扣减劵库存
-
原生分布式锁+redisson框架分布锁使用
- 分布式锁+最佳实践
-
数据库表介绍
#优惠券表
CREATE TABLE `coupon` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`category` varchar(11) DEFAULT NULL COMMENT '优惠卷类型[NEW_USER注册赠券,TASK任务卷,PROMOTION促销劵]',
`publish` varchar(11) DEFAULT NULL COMMENT '发布状态, PUBLISH发布,DRAFT草稿,OFFLINE下线',
`coupon_img` varchar(524) DEFAULT NULL COMMENT '优惠券图片',
`coupon_title` varchar(128) DEFAULT NULL COMMENT '优惠券标题',
`price` decimal(16,2) DEFAULT NULL COMMENT '抵扣价格',
`user_limit` int(11) DEFAULT NULL COMMENT '每人限制张数',
`start_time` datetime DEFAULT NULL COMMENT '优惠券开始有效时间',
`end_time` datetime DEFAULT NULL COMMENT '优惠券失效时间',
`publish_count` int(11) DEFAULT NULL COMMENT '优惠券总量',
`stock` int(11) DEFAULT '0' COMMENT '库存',
`create_time` datetime DEFAULT NULL,
`condition_price` decimal(16,2) DEFAULT NULL COMMENT '满多少才可以使用',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=23 DEFAULT CHARSET=utf8mb4;
#优惠券领劵记录
CREATE TABLE `coupon_record` (
`id` bigint(11) unsigned NOT NULL AUTO_INCREMENT,
`coupon_id` bigint(11) DEFAULT NULL COMMENT '优惠券id',
`create_time` datetime DEFAULT NULL COMMENT '创建时间获得时间',
`use_state` varchar(32) DEFAULT NULL COMMENT '使用状态 可用 NEW,已使用USED,过期 EXPIRED;',
`user_id` bigint(11) DEFAULT NULL COMMENT '用户id',
`user_name` varchar(128) DEFAULT NULL COMMENT '用户昵称',
`coupon_title` varchar(128) DEFAULT NULL COMMENT '优惠券标题',
`start_time` datetime DEFAULT NULL COMMENT '开始时间',
`end_time` datetime DEFAULT NULL COMMENT '结束时间',
`order_id` bigint(11) DEFAULT NULL COMMENT '订单id',
`price` decimal(16,2) DEFAULT NULL COMMENT '抵扣价格',
`condition_price` decimal(16,2) DEFAULT NULL COMMENT '满多少才可以使用',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=141 DEFAULT CHARSET=utf8mb4;
分页查询优惠券
思路:查询为发布状态并且是促销优惠券类型的优惠券信息
@Override
public Map<String, Object> pageCouponActivity(int page, int size) {
Page<CouponDO> pageInfo = new Page<>(page,size);
IPage<CouponDO> couponDOIPage = couponMapper.selectPage(pageInfo, new QueryWrapper<CouponDO>()
.eq("publish",CouponPublishEnum.PUBLISH)
.eq("category", CouponCategoryEnum.PROMOTION)
.orderByDesc("create_time"));
Map<String,Object> pageMap = new HashMap<>(3);
//总条数
pageMap.put("total_record", couponDOIPage.getTotal());
//总页数
pageMap.put("total_page",couponDOIPage.getPages());
pageMap.put("current_data",couponDOIPage.getRecords().stream().map(obj->beanProcess(obj)).collect(Collectors.toList()));
return pageMap;
}
private CouponVO beanProcess(CouponDO couponDO) {
CouponVO couponVO = new CouponVO();
BeanUtils.copyProperties(couponDO,couponVO);
return couponVO;
}
问题:HashMap初始化时为什么建议使用HashMap(int initialCapacity)
领取优惠券
思路:
* 领劵接口
* 1、获取优惠券是否存在
* 2、校验优惠券是否可以领取:时间、库存、超过限制
* 3、扣减库存
* 4、保存领劵记录
@Override
public JsonData addCoupon(long couponId, CouponCategoryEnum category) {
LoginUser loginUser = LoginInterceptor.threadLocal.get();
CouponDO couponDO = couponMapper.selectOne(new QueryWrapper<CouponDO>()
.eq("id",couponId)
.eq("category",category.name()));
//优惠券是否可以领取
this.checkCoupon(couponDO,loginUser.getId());
//构建领劵记录
CouponRecordDO couponRecordDO = new CouponRecordDO();
BeanUtils.copyProperties(couponDO,couponRecordDO);
couponRecordDO.setCreateTime(new Date());
couponRecordDO.setUseState(CouponStateEnum.NEW.name());
couponRecordDO.setUserId(loginUser.getId());
couponRecordDO.setUserName(loginUser.getName());
couponRecordDO.setCouponId(couponId);
couponRecordDO.setId(null);
//扣减库存 TODO
int rows = couponMapper.reduceStock(couponId);
// int rows = couponMapper.reduceStock(couponId,couponDO.getStock());
if(rows==1){
//库存扣减成功才保存记录
couponRecordMapper.insert(couponRecordDO);
}else {
log.warn("发放优惠券失败:{},用户:{}",couponDO,loginUser);
throw new BizException(BizCodeEnum.COUPON_NO_STOCK);
}
return JsonData.buildSuccess();
}
private void checkCoupon(CouponDO couponDO, Long userId) {
//优惠券不存在
if(couponDO==null){
throw new BizException(BizCodeEnum.COUPON_NO_EXITS);
}
//库存是否足够
if(couponDO.getStock()<=0){
throw new BizException(BizCodeEnum.COUPON_NO_STOCK);
}
//判断是否是否发布状态
if(!couponDO.getPublish().equals(CouponPublishEnum.PUBLISH.name())){
throw new BizException(BizCodeEnum.COUPON_GET_FAIL);
}
//是否在领取时间范围
long time = CommonUtil.getCurrentTimestamp();
long start = couponDO.getStartTime().getTime();
long end = couponDO.getEndTime().getTime();
if(time<start || time>end){
throw new BizException(BizCodeEnum.COUPON_OUT_OF_TIME);
}
//用户是否超过限制
int recordNum = couponRecordMapper.selectCount(new QueryWrapper<CouponRecordDO>()
.eq("coupon_id",couponDO.getId())
.eq("user_id",userId));
if(recordNum >= couponDO.getUserLimit()){
throw new BizException(BizCodeEnum.COUPON_OUT_OF_LIMIT);
}
}
控制超发
<!--扣减库存-->
<update id="reduceStock">
update coupon set stock=stock-1 where id = #{couponId} and stock > 0
</update>
分布式锁
- 设计分布式锁应该考虑的东西
- 排他性
- 在分布式应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行
- 容错性
- 分布式锁一定能得到释放,比如客户端奔溃或者网络中断
- 满足可重入、高性能、高可用
- 注意分布式锁的开销、锁粒度
- 排他性
基于Redis实现分布式锁的几种坑
-
实现分布式锁 可以用 Redis、Zookeeper、Mysql数据库这几种 , 性能最好的是Redis且是最容易理解
- 分布式锁离不开 key - value 设置
key 是锁的唯一标识,一般按业务来决定命名,比如想要给一种商品的秒杀活动加锁,key 命名为 “seckill_商品ID” 。value就可以使用固定值,比如设置成1
-
基于redis实现分布式锁,文档:http://www.redis.cn/commands.html#string
- 加锁 SETNX key value
setnx 的含义就是 SET if Not Exists,有两个参数 setnx(key, value),该方法是原子性操作 如果 key 不存在,则设置当前 key 成功,返回 1; 如果当前 key 已经存在,则设置当前 key 失败,返回 0
- 解锁 del (key)
得到锁的线程执行完任务,需要释放锁,以便其他线程可以进入,调用 del(key)
- 配置锁超时 expire (key,30s)
客户端奔溃或者网络中断,资源将会永远被锁住,即死锁,因此需要给key配置过期时间,以保证即使没有被显式释放,这把锁也要在一定时间后自动释放
- 综合伪代码
methodA(){ String key = "coupon_66" if(setnx(key,1) == 1){ expire(key,30,TimeUnit.MILLISECONDS) try { //做对应的业务逻辑 //查询用户是否已经领券 //如果没有则扣减库存 //新增领劵记录 } finally { del(key) } }else{ //睡眠100毫秒,然后自旋调用本方法 methodA() } }
- 存在问题
- 多个命令之间不是原子性操作,如
setnx
和expire
之间,如果setnx
成功,但是expire
失败,且宕机了,则这个资源就是死锁
- 多个命令之间不是原子性操作,如
使用原子命令:设置和配置过期时间 setnx / setex
如: set key 1 ex 30 nx
java里面 redisTemplate.opsForValue().setIfAbsent("seckill_1",1,30,TimeUnit.MILLISECONDS)
- 业务超时,存在其他线程勿删,key 30秒过期,假如线程A执行很慢超过30秒,则key就被释放了,其他线程B就得到了锁,这个时候线程A执行完成,而B还没执行完成,结果就是线程A删除了线程B加的锁
可以在 del 释放锁之前做一个判断,验证当前的锁是不是自己加的锁, 那 value 应该是存当前线程的标识或者uuid
String key = "coupon_66"
String value = Thread.currentThread().getId()
if(setnx(key,value) == 1){
expire(key,30,TimeUnit.MILLISECONDS)
try {
//做对应的业务逻辑
} finally {
//删除锁,判断是否是当前线程加的
if(get(key).equals(value)){
//还存在时间间隔
del(key)
}
}
}else{
//睡眠100毫秒,然后自旋调用本方法
}
- 进一步细化误删
- 当线程A获取到正常值时,返回带代码中判断期间锁过期了,线程B刚好重新设置了新值,线程A那边有判断value是自己的标识,然后调用del方法,结果就是删除了新设置的线程B的值
- 核心还是判断和删除命令 不是原子性操作导致
lua脚本
-
redis做分布式锁存在的问题
- 核心是保证多个指令原子性,加锁使用setnx setex 可以保证原子性,那解锁使用 判断和删除怎么保证原子性
- 文档:http://www.redis.cn/commands/set.html
- 多个命令的原子性:采用 lua脚本+redis, 由于【判断和删除】是lua脚本执行,所以要么全成功,要么全失败
//获取lock的值和传递的值一样,调用删除操作返回1,否则返回0 String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end"; //Arrays.asList(lockKey)是key列表,uuid是参数 Integer result = redisTemplate.execute(new DefaultRedisScript<>(script, Integer.class), Arrays.asList(lockKey), uuid);
- 全部代码
/** * 原生分布式锁 开始 * 1、原子加锁 设置过期时间,防止宕机死锁 * 2、原子解锁:需要判断是不是自己的锁 */ String uuid = CommonUtil.generateUUID(); String lockKey = "lock:coupon:"+couponId; Boolean nativeLock=redisTemplate.opsForValue().setIfAbsent(lockKey,uuid,Duration.ofSeconds(30)); if(nativeLock){ //加锁成功 log.info("加锁:{}",nativeLock); try { //执行业务 TODO }finally { String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end"; Integer result = redisTemplate.execute(new DefaultRedisScript<>(script, Integer.class), Arrays.asList(lockKey), uuid); log.info("解锁:{}",result); } }else { //加锁失败,睡眠100毫秒,自旋重试 try { TimeUnit.MILLISECONDS.sleep(100L); } catch (InterruptedException e) { } return addCoupon( couponId, couponCategory); } //原生分布式锁 结束
- 遗留一个问题,锁的过期时间,如何实现锁的自动续期 或者 避免业务执行时间过长,锁过期了?
- 原生方式的话,一般把锁的过期时间设置久一点,比如10分钟时间
Redisson
-
原生代码+redis实现分布式锁使用比较复杂,且有些锁续期问题更难处理
-
多种实现客户端框架
-
Redisson官方中文文档:https://github.com/redisson/redisson/wiki/目录
-
聚合工程锁定版本,common项目添加依赖(多个服务都会用到分布式锁)
<!--分布式锁-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.10.1</version>
</dependency>
- 创建redisson客户端
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private String redisPort;
@Value("${spring.redis.password}")
private String redisPwd;
/**
* 配置分布式锁
* @return
*/
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
//单机模式
//config.useSingleServer().setPassword("123456").setAddress("redis://8.129.113.233:3308");
config.useSingleServer().setPassword(redisPwd).setAddress("redis://"+redisHost+":"+redisPort);
//集群模式
//config.useClusterServers()
//.setScanInterval(2000)
//.addNodeAddress("redis://10.0.29.30:6379", "redis://10.0.29.95:6379")
// .addNodeAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);
return redisson;
}
Lock lock = redisson.getLock("lock:coupon:"+couponId);
//阻塞式等待,一个线程获取锁后,其他线程只能等待,和原生的方式循环调用不一样
lock.lock();
try {
CouponDO couponDO = couponMapper.selectOne(new QueryWrapper<CouponDO>().eq("id", couponId)
.eq("category", couponCategory)
.eq("publish", CouponPublishEnum.PUBLISH));
this.couponCheck(couponDO,loginUser.getId());
CouponRecordDO couponRecordDO = new CouponRecordDO();
BeanUtils.copyProperties(couponDO,couponRecordDO);
couponRecordDO.setCreateTime(new Date());
couponRecordDO.setUseState(CouponStateEnum.NEW.name());
couponRecordDO.setUserId(loginUser.getId());
couponRecordDO.setUserName(loginUser.getName());
couponRecordDO.setCouponId(couponId);
couponRecordDO.setId(null);
//高并发下扣减劵库存,采用乐观锁,当前stock做版本号,一次只能领取1张
int rows = couponMapper.reduceStock(couponId);
if(rows == 1){
//库存扣减成功才保存
couponRecordMapper.insert(couponRecordDO);
}else {
log.warn("发放优惠券失败:{},用户:{}",couponDO,loginUser);
throw new BizException(BizCodeEnum.COUPON_NO_STOCK);
}
}finally {
lock.unlock();
}
Redisson解决分布式锁里面的坑
-
Redis锁的过期时间小于业务的执行时间该如何续期?
- watch dog看门狗机制
负责储存这个分布式锁的Redisson节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。或者业务执行时间过长导致锁过期, 为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。 Redisson中客户端一旦加锁成功,就会启动一个watch dog看门狗。watch dog是一个后台线程,会每隔10秒检查一下,如果客户端还持有锁key,那么就会不断的延长锁key的生存时间 默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定
- 指定加锁时间
// 加锁以后10秒钟自动解锁 // 无需调用unlock方法手动解锁 lock.lock(10, TimeUnit.SECONDS); // 尝试加锁,最多等待100秒,上锁以后10秒自动解锁 boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS); if (res) { try { ... } finally { lock.unlock(); } }
事务
Spring常见的事务管理
-
事务:多个操作,要么同时成功,要么失败后一起回滚
- 具备ACID四种特性
- Atomic(原子性)
- Consistency(一致性)
- Isolation(隔离性)
- Durability(持久性)
- 具备ACID四种特性
-
常见的Spring事务管理方式
- 编程式事务管理
代码中调用beginTransaction()、commit()、rollback()等事务管理相关的方法,通过TransactionTempalte手动管理事务(用的少)
- 声明式事务管理
通过AOP实现,可配置文件方式或者注解方式实现事务的管理控制(用的多)
-
声明式事务管理本质:
-
本质是对方法前后进行拦截,底层是建立在 AOP 的基础之上
-
在目标方法开始之前创建或者加入一个事务,在执行完目标方法之后根据执行情况提交或者回滚事务
-
Spring事务的传播属性和隔离级别
-
事物传播行为介绍:
- 如果在开始当前事务之前,一个事务上下文已经存在,此时有若干选项可以指定一个事务性方法的执行行为
@Transactional(propagation=Propagation.REQUIRED) 如果有事务, 那么加入事务, 没有的话新建一个(默认情况下) @Transactional(propagation=Propagation.NOT_SUPPORTED) 不为这个方法开启事务 @Transactional(propagation=Propagation.REQUIRES_NEW) 不管是否存在事务,都创建一个新的事务,原来的挂起,新的执行完毕,继续执行老的事务 @Transactional(propagation=Propagation.MANDATORY) 必须在一个已有的事务中执行,否则抛出异常 @Transactional(propagation=Propagation.NEVER) 必须在一个没有的事务中执行,否则抛出异常(与Propagation.MANDATORY相反) @Transactional(propagation=Propagation.SUPPORTS) 如果其他bean调用这个方法,在其他bean中声明事务,那就用事务.如果其他bean没有声明事务,那就不用事务. @Transactional(propagation=Propagation.NESTED) 如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行; 如果当前没有事务,则该取值等价于Propagation.REQUIRED。
-
事务隔离级别
- 是指若干个并发的事务之间的隔离程度
@Transactional(isolation = Isolation.READ_UNCOMMITTED) 读取未提交数据(会出现脏读, 不可重复读) 基本不使用 @Transactional(isolation = Isolation.READ_COMMITTED) 读取已提交数据(会出现不可重复读和幻读) @Transactional(isolation = Isolation.REPEATABLE_READ) 可重复读(会出现幻读) @Transactional(isolation = Isolation.SERIALIZABLE) 串行化
-
MYSQL: 默认为REPEATABLE_READ级别
领劵接口微服务本地事务配置
-
领劵接口配置事务+测试
- 启动类增加注解 @EnableTransactionManagement
- 方法增加注解 @Transactional(rollbackFor=Exception.class,propagation=Propagation.REQUIRED)
- 指定方法需要事务再添加,不能全局都使用
-
分布式锁测试锁续期的时候
- 不能使用debug模式,不然看到不到自动续期
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED)
@Override
public JsonData addCoupon(long couponId, CouponCategoryEnum category) {
LoginUser loginUser = LoginInterceptor.threadLocal.get();
String uuid = CommonUtil.generateUUID();
String lockKey = "lock:coupon:" + couponId;
RLock rLock = redissonClient.getLock(lockKey);
rLock.lock();
log.info("领券接口加锁成功:{}", Thread.currentThread().getId());
try {
CouponDO couponDO = couponMapper.selectOne(new QueryWrapper<CouponDO>().eq("id", couponId).eq("category", category.name()));
this.checkCoupon(couponDO, loginUser.getId());
CouponRecordDO couponRecordDO = new CouponRecordDO();
BeanUtils.copyProperties(couponDO, couponRecordDO);
couponRecordDO.setCreateTime(new Date());
couponRecordDO.setUseState(CouponStateEnum.NEW.name());
couponRecordDO.setUserId(loginUser.getId());
couponRecordDO.setUserName(loginUser.getName());
couponRecordDO.setCouponId(couponId);
couponRecordDO.setId(null);
int row = couponMapper.reduceStock(couponId);
if (row == 1) {
couponRecordMapper.insert(couponRecordDO);
} else {
log.info("发放优惠券失败:{},用户:{}", couponDO, loginUser);
throw new BizException(BizCodeEnum.COUPON_NO_STOCK);
}
} finally {
rLock.unlock();
log.info("解锁成功");
}
return JsonData.buildSuccess();
}