领取优惠券
开发流程
页面原型分析,接口统计,数据库设计,生成代码,引入枚举状态
接口开发
查询发放中的优惠券
根据页面原型和接口分析和前端设计的要求,获得四要素
@Override
public List<CouponVO> queryIssuingCoupons() {
// 1.查询发放中的优惠券列表
List<Coupon> coupons = lambdaQuery()
.eq(Coupon::getStatus, ISSUING)
.eq(Coupon::getObtainWay, ObtainType.PUBLIC)
.list();
if (CollUtils.isEmpty(coupons)) {
return CollUtils.emptyList();
}
// 2.统计当前用户已经领取的优惠券的信息
List<Long> couponIds = coupons.stream().map(Coupon::getId).collect(Collectors.toList());
// 2.1.查询当前用户已经领取的优惠券的数据
List<UserCoupon> userCoupons = userCouponService.lambdaQuery()
.eq(UserCoupon::getUserId, UserContext.getUser())
.in(UserCoupon::getCouponId, couponIds)
.list();
// 2.2.统计当前用户对优惠券的已经领取数量
Map<Long, Long> issuedMap = userCoupons.stream()
.collect(Collectors.groupingBy(UserCoupon::getCouponId, Collectors.counting()));
// 2.3.统计当前用户对优惠券的已经领取并且未使用的数量
Map<Long, Long> unusedMap = userCoupons.stream()
.filter(uc -> uc.getStatus() == UserCouponStatus.UNUSED)
.collect(Collectors.groupingBy(UserCoupon::getCouponId, Collectors.counting()));
// 3.封装VO结果
List<CouponVO> list = new ArrayList<>(coupons.size());
for (Coupon c : coupons) {
// 3.1.拷贝PO属性到VO
CouponVO vo = BeanUtils.copyBean(c, CouponVO.class);
list.add(vo);
// 3.2.是否可以领取:已经被领取的数量 < 优惠券总数量 && 当前用户已经领取的数量 < 每人限领数量
vo.setAvailable(
c.getIssueNum() < c.getTotalNum()
&& issuedMap.getOrDefault(c.getId(), 0L) < c.getUserLimit()
);
// 3.3.是否可以使用:当前用户已经领取并且未使用的优惠券数量 > 0
vo.setReceived(unusedMap.getOrDefault(c.getId(), 0L) > 0);
}
return list;
}
遇到的问题-登录拦截放行问题
查询发放中的优惠券时会遇到登录拦截问题,所以需要进行登录拦截放行问题
在咱们项目中的tj-auth
模块下,提供了一个tj-auth-resource-sdk
模块
其作用有两个:
-
帮我们获取登录用户信息
-
校验登录状态,未登录则报错
任何微服务只要引入了tj-auth-resource-sdk
模块,自然就具备了以上两个功能。这两个功能都是基于SpringMVC的拦截器来实现的。
根据拦截器源码分析:
用户信息拦截器的作用仅仅是获取用户信息,无论获取成功或者失败,最终都会放行。不会拦截用户请求。
登录拦截器就是判断用户是否登录,未登录会直接拦截并且返回错误码。不过这个拦截器是通过UserContext.getUser()
方法来判断用户是否登录的。也就是说它依赖于UserInfoInterceptor,因此两个拦截器是有先后顺序的
为什么我们要把登录用户信息获取、登录拦截分别写到两个拦截器呢?
回答:这是因为并不是所有的接口都对登录用户有需要,有些接口可能登录或未登录都能访问。
那么我们该怎么控制是否做登录拦截呢?
拦截器定义好了以后要想生效必须经过SpringMVC的配置,并且设置要拦截的路径,这些配置同样定义在tj-auth-resource-sdk
模块下,通过阅读源码可以得知:
这里有几个关键的点:
-
用户信息获取的拦截器一定会生效。
-
登录拦截器不一定生效,取决于
authProperties.getEnable()
的值,为true则生效,false则不生效-
登录拦截生效的前提下,通过
authProperties.getIncludeLoginPaths()
配置要拦截的路径 -
登录拦截生效的前提下,通过
authProperties.getExcludeLoginPaths()
配置要放行的路径
-
因此,要不要做登录拦截,要拦截哪些路径,完全取决于authProperties的属性
我们只要把需要放行的接口路径通过tj.auth.resource.excludeLoginPaths配置进去即可。
领取优惠券
思路
代码实现
@Override
@Transactional
public void receiveCoupon(Long couponId) {
// 1.查询优惠券
Coupon coupon = couponMapper.selectById(couponId);
if (coupon == null) {
throw new BadRequestException("优惠券不存在");
}
// 2.校验发放时间
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(coupon.getIssueBeginTime()) || now.isAfter(coupon.getIssueEndTime())) {
throw new BadRequestException("优惠券发放已经结束或尚未开始");
}
// 3.校验库存
if (coupon.getIssueNum() >= coupon.getTotalNum()) {
throw new BadRequestException("优惠券库存不足");
}
Long userId = UserContext.getUser();
// 4.校验每人限领数量
// 4.1.统计当前用户对当前优惠券的已经领取的数量
Integer count = lambdaQuery()
.eq(UserCoupon::getUserId(), userId)
.eq(UserCoupon::getCouponId(), couponId)
.count();
// 4.2.校验限领数量
if(count != null && count >= coupon.getUserLimit()){
throw new BadRequestException("超出领取数量");
}
// 5.更新优惠券的已经发放的数量 + 1
couponMapper.incrIssueNum(coupon.getId());
// 6.新增一个用户券
saveUserCoupon(coupon, userId);
}
private void saveUserCoupon(Coupon coupon, Long userId) {
// 1.基本信息
UserCoupon uc = new UserCoupon();
uc.setUserId(userId);
uc.setCouponId(coupon.getId());
// 2.有效期信息
LocalDateTime termBeginTime = coupon.getTermBeginTime();
LocalDateTime termEndTime = coupon.getTermEndTime();
if (termBeginTime == null) {
termBeginTime = LocalDateTime.now();
termEndTime = termBeginTime.plusDays(coupon.getTermDays());
}
uc.setTermBeginTime(termBeginTime);
uc.setTermEndTime(termEndTime);
// 3.保存
save(uc);
}
更新优惠券的已经领取数量需要自定义SQL语句。
@Update("UPDATE coupon SET issue_num = issue_num + 1 WHERE id = #{couponId}")
int incrIssueNum(@Param("couponId") Long couponId);
兑换码兑换优惠券
思路
代码实现
兑换码兑换优惠
@Override
@Transactional
public void exchangeCoupon(String code) {
// 1.校验并解析兑换码
long serialNum = CodeUtil.parseCode(code);
// 2.校验是否已经兑换 SETBIT KEY 4 1 ,这里直接执行setbit,通过返回值来判断是否兑换过
boolean exchanged = codeService.updateExchangeMark(serialNum, true);
if (exchanged) {
throw new BizIllegalException("兑换码已经被兑换过了");
}
try {
// 3.查询兑换码对应的优惠券id
ExchangeCode exchangeCode = codeService.getById(serialNum);
if (exchangeCode == null) {
throw new BizIllegalException("兑换码不存在!");
}
// 4.是否过期
LocalDateTime now = LocalDateTime.now();
if (now.isAfter(exchangeCode.getExpireTime()) {
throw new BizIllegalException("兑换码已经过期");
}
// 5.校验并生成用户券
// 5.1.查询优惠券
Coupon coupon = couponMapper.selectById(exchangeCode.getCouponId());
// 5.2.查询用户
Long userId = UserContext.getUser();
// 5.3.校验并生成用户券,更新兑换码状态
checkAndCreateUserCoupon(coupon, userId, serialNum);
} catch (Exception e) {
// 重置兑换的标记 0
codeService.updateExchangeMark(serialNum, false);
throw e;
}
}
private void checkAndCreateUserCoupon(Coupon coupon, Long userId, Integer serialNum){
// 1.校验每人限领数量
// 1.1.统计当前用户对当前优惠券的已经领取的数量
Integer count = lambdaQuery()
.eq(UserCoupon::getUserId, userId)
.eq(UserCoupon::getCouponId, coupon.getId())
.count();
// 1.2.校验限领数量
if(count != null && count >= coupon.getUserLimit()){
throw new BadRequestException("超出领取数量");
}
// 2.更新优惠券的已经发放的数量 + 1
couponMapper.incrIssueNum(coupon.getId());
// 3.新增一个用户券
saveUserCoupon(coupon, userId);
// 4.更新兑换码状态
if (serialNum != null) {
codeService.lambdaUpdate()
.set(ExchangeCode::getUserId, userId)
.set(ExchangeCode::getStatus, ExchangeCodeStatus.USED)
.eq(ExchangeCode::getId, serialNum)
.update();
}
}
private void saveUserCoupon(Coupon coupon, Long userId) {
// 1.基本信息
UserCoupon uc = new UserCoupon();
uc.setUserId(userId);
uc.setCouponId(coupon.getId());
// 2.有效期信息
LocalDateTime termBeginTime = coupon.getTermBeginTime();
LocalDateTime termEndTime = coupon.getTermEndTime();
if (termBeginTime == null) {
termBeginTime = LocalDateTime.now();
termEndTime = termBeginTime.plusDays(coupon.getTermDays());
}
uc.setTermBeginTime(termBeginTime);
uc.setTermEndTime(termEndTime);
// 3.保存
save(uc);
}
其中利用BitMap来标记兑换码的兑换状态功能,属于兑换码功能,我们需要封装到com.tianji.promotion.service.IExchangeCodeService
中
@Override
public boolean updateExchangeMark(long serialNum, boolean mark) {
Boolean boo = redisTemplate.opsForValue().setBit(COUPON_CODE_MAP_KEY, serialNum, mark);
return boo != null && boo;
}
代码优化-并发安全问题
超卖问题
解决思路
对并发安全问题,最广为人知的解决方案就是加锁。不过,加锁的方式多种多样,大家熟悉的Synchronized、ReentrantLock只是其中最基础的锁。
我们今天先不讨论具体的锁的实现方式,而是讲讲加锁的思想。从实现思想上来说,锁可以分为两大类:
-
悲观锁
-
乐观锁
何为悲观锁?
悲观锁是一种独占和排他的锁机制,保守地认为数据会被其他事务修改,所以在整个数据处理过程中将数据处于锁定状态。
何为乐观锁?
乐观锁是一种较为乐观的并发控制方法,假设多用户并发的不会产生安全问题,因此无需独占和锁定资源。但在更新数据前,会先检查是否有其他线程修改了该数据,如果有,则认为可能有风险,会放弃修改操作
超卖这样的线程安全问题,解决方案有哪些?
-
悲观锁:添加同步锁,让线程串行执行
-
优点:简单粗暴
-
缺点:性能一般
-
-
乐观锁:不加锁,在更新时判断是否有其它线程在修改
-
优点:性能好
-
缺点:存在成功率低的问题
-
我们最终的执行SQL是这样的
UPDATE coupon SET issue_num = issue_num + 1 WHERE id = 1 AND issue_num < total_num
锁失效问题
除了优惠券库存判断,领券时还有对于用户限领数量的判断,
是不是跟上面一样,使用乐观锁解决?
显然不行,因为乐观锁常用在更新,而且这里用户和优惠券的关系并不具备唯一性,因此新增时无法基于乐观锁做判断。
所以,这里只能采用悲观锁方案,也就是大家熟悉的Synchronized或者Lock.
锁对象问题
用户限领数量判断是针对单个用户的,因此锁的范围不需要是整个方法,只要锁定某个用户即可。所以这里建议采用Synchronized的代码块,而不是同步方法。
userId是Long类型,其中toString方法,这里竟然采用的是new String()的方式。
也就是说,哪怕是同一个用户,其id是一样,但toString()得到的也是多个不同对象!也就是多把不同的锁!
经过测试,发现并发安全问题依然存在,锁没有生效!!!什么情况?
加了锁,但锁没生效,可能的原因是什么?答案是用了不同的锁
解决方案:
String类中提供了一个intern()
方法
只要两个字符串equals的结果为true,那么intern就能保证得到的结果用 ==判断也是true,其原理就是获取字符串字面值对应到常量池中的字符串常量。因此只要两个字符串一样,intern()返回的一定是同一个对象。
事务边界问题
经过同步锁的改造,理论上用户限领数量判断的逻辑应该已经是解决了。
不过,经过测试后,发现问题依然存在,用户还是会超领。这又是怎么回事呢?
其实这次的问题并不是由于锁导致的,而是由于事务的隔离导致。
由于锁过早释放,导致了事务尚未提交,判断出现错误,最终导致并发安全问题发生。
这其实就是事务边界和锁边界的问题。
解决方案很简单,就是调整边界:
-
业务开始前,先获取锁,再开启事务
-
业务结束后:先提交事务,再释放锁
在事务和锁并行存在时,一定要考虑事务和锁的边界问题。由于事务的隔离级别问题,可能会导致不同事务之间数据不可见,往往会产生一些不可预期的现象。
由于事务方法需要public修饰,并且被spring管理。
事务失效问题
虽然解决了并发安全问题,但其实我们的改造却埋下了另一个隐患。一起测试一下。
我们在领券业务的最后故意抛出一个异常
经过测试,发现虽然抛出了异常,但是库存、用户券都没有回滚!事务失效了!
事务方法非public修饰
由于Spring的事务是基于AOP的方式结合动态代理来实现的。因此事务方法一定要是public的,这样才能便于被Spring做事务的代理和增强。
非事务方法调用事务方法
@Service
public class OrderService {
public void createOrder(){
// ... 准备订单数据
// 生成订单并扣减库存
insertOrderAndReduceStock();
}
@Transactional
public void insertOrderAndReduceStock(){
// 生成订单
insertOrder();
// 扣减库存
reduceStock();
}
}
可以看到,insertOrderAndReduceStock
方法是一个事务方法,肯定会被Spring事务管理。Spring会给OrderService
类生成一个动态代理对象,对insertOrderAndReduceStock
方法做增加,实现事务效果。
但是现在createOrder
方法是一个非事务方法,在其中调用了insertOrderAndReduceStock
方法,这个调用其实隐含了一个this.
的前缀。也就是说,这里相当于是直接调用原始的OrderService中的普通方法,而非被Spring代理对象的代理方法。那事务肯定就失效了!
事务方法的异常被捕获了
@Service
public class OrderService {
@Transactional
public void createOrder(){
// ... 准备订单数据
// 生成订单
insertOrder();
// 扣减库存
reduceStock();
}
private void reduceStock() {
try {
// ...扣库存
} catch (Exception e) {
// 处理异常
}
}
}
在这段代码中,reduceStock方法内部直接捕获了Exception类型的异常,也就是说方法执行过程中即便出现了异常也不会向外抛出。
而Spring的事务管理就是要感知业务方法的异常,当捕获到异常后才会回滚事务。
现在事务被捕获,就会导致Spring无法感知事务异常,自然不会回滚,事务就失效了。
事务异常类型不对
@Service
public class OrderService {
@Transactional(rollbackFor = RuntimeException.class)
public void createOrder() throws IOException {
// ... 准备订单数据
// 生成订单
insertOrder();
// 扣减库存
reduceStock();
throw new IOException();
}
}
Spring的事务管理默认感知的异常类型是RuntimeException
,当事务方法内部抛出了一个IOException
时,不会被Spring捕获,因此就不会触发事务回滚,事务就失效了。
所以
@Transactional(rollbackFor = Exception.class)
事务传播行为不对
@Service
public class OrderService {
@Transactional
public void createOrder(){
// 生成订单
insertOrder();
// 扣减库存
reduceStock();
throw new RuntimeException("业务异常");
}
@Transactional
public void insertOrder() {
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void reduceStock() {
}
}
在示例代码中,事务的入口是createOrder()
方法,会开启一个事务,可以成为外部事务。在createOrder()方法内部又调用了insertOrder()
方法和reduceStock()
方法。这两个都是事务方法。
不过,reduceStock()
方法的事务传播行为是REQUIRES_NEW
,这会导致在进入reduceStock()
方法时会创建一个新的事务,可以成为子事务。insertOrder()
则是默认,因此会与createOrder()
合并事务。
因此,当createOrder
方法最后抛出异常时,只会导致insertOrder
方法回滚,而不会导致reduceStock
方法回滚,因为reduceStock
是一个独立事务。
所以,一定要慎用传播行为,注意外部事务与内部事务之间的关系。
没有被Spring管理
这个属于比较低级的错误,OrderService
类没有添加@Service
注解,因此就没有被Spring管理。你在方法上添加的@Transactional
注解根本不会有人帮你动态代理,事务自然失效。
解决问题
为了控制事务边界,我们改变了事务注解标记的位置,这就导致了非事务方法调用了事务方法。
既然事务失效的原因是方法内部调用走的是this,而不是代理对象。那我们只要想办法获取代理对象不就可以了嘛。
我们可以借助AspectJ来实现
练习
查询我的优惠券
@Override
public PageDTO<CouponVO> queryMyCouponPage(UserCouponQuery query) {
// 1.获取当前用户
Long userId = UserContext.getUser();
// 2.分页查询用户券
Page<UserCoupon> page = lambdaQuery()
.eq(UserCoupon::getUserId, userId)
.eq(UserCoupon::getStatus, query.getStatus())
.page(query.toMpPage(new OrderItem("term_end_time", true)));
List<UserCoupon> records = page.getRecords();
if (CollUtils.isEmpty(records)) {
return PageDTO.empty(page);
}
// 3.获取优惠券详细信息
// 3.1.获取用户券关联的优惠券id
Set<Long> couponIds = records.stream().map(UserCoupon::getCouponId).collect(Collectors.toSet());
// 3.2.查询
List<Coupon> coupons = couponMapper.selectBatchIds(couponIds);
// 4.封装VO
return PageDTO.of(page, BeanUtils.copyList(coupons, CouponVO.class));
}
完善兑换优惠券功能
这个回来再写
优惠券过期提醒
优惠券发放给用户后,一定要被用户使用才有意义,才能起到该有的作用。因此,当用户领券以后,一定要及时提醒用户去使用,避免优惠券过期。
需求:自己设计一个方案,在优惠券即将过期前以短信方式提醒用户。
我的分析:我觉得需要借助xx-job定义一个任务,定时查询快要过期的优惠卷信息,并且通知相关用户
面试
1
面试官:如何解决优惠券的超发问题?
答:超发、超卖问题往往是由于多线程的并发访问导致的。所以解决这个问题的手段就是加锁。可以采用悲观锁,也可以采用乐观锁。
如果并发量不是特别高,就使用悲观锁就可以了。不过性能会受到一定的影响。
如果并发相对较高,对性能有要求,那就可以选择使用乐观锁。
当然,乐观锁也有自己的问题,就是多线程竞争时,失败率比较高的问题。并行访问的N个线程只会有一个线程成功,其它都会失败。
所以,针对这个问题,再结合库存问题的特殊性,我们不一定要是有版本号或者CAS机制实现乐观锁。而是改进为在where条件中加上一个对库存的判断即可。
比如,在where条件中除了优惠券id以外,加上库存必须大于购买数量的条件。这样如果库存不足,where条件不成立,自然也会失败。
这样做借鉴了乐观锁的思想,在线程安全的情况下,保证了并发性能,同时也解决了乐观锁失败率较高的问题,一举多得。
2
面试官:Spring事务失效的情况碰到过吗?或者知不知道哪些情况会导致事务失效?
答:Spring事务失效的原因有很多,比如说:
-
事务方法不是public的
-
非事务方法调用事务方法
-
事务方法的异常被捕获了
-
事务方法抛出异常类型不对
-
事务传播行为使用错误
-
Bean没有被Spring管理
等等。。
在我们项目中确实有碰到过,我想一想啊。
我记得是在优惠券业务中,一开始我们的优惠券只有一种领取方式,就是发放后展示在页面,让用户手动领取。领取的过程中有各种校验。那时候没碰到什么问题,项目也都正常运行。
后来产品提出了新的需求,要加一个兑换码兑换优惠券的功能。这个功能开发完以后就发现有时候会出现优惠券发放数量跟实际数量对不上的情况,就是实际发放的券总是比设定的要少。一开始一直找不到原因。
后来发现是某些情况下,在领取失败的时候,扣减的优惠券库存没有回滚导致的,也就是事务没有生效。仔细排查后发现,原来是在实现兑换码兑换优惠券的时候,由于很多业务逻辑跟手动领取优惠券很像,所以就把其中的一些数据库操作抽取为一个公共方法,然后在两个业务中都调用。因为所有数据库操作都在这个共享的方法中嘛,所以就把事务注解放到了抽取的方法上。当时没有注意,这恰好就是在非事务方法中调用了事务方法,导致了事务失效。
3
面试官:在开发中碰到过什么疑难问题,最后是怎么解决的?
答:我想一下啊,问题肯定是碰到过的。
比如在开发优惠券功能的时候,优惠券有一个发放数量的限制,也就是库存。还有一个用户限量数量的限制,这个是设置优惠券的时候管理员配置的。
因此我们在用户领取优惠券的时候必须做库存校验、限领数量的校验。由于库存和领取数量都需要先查询统计,再做判断。因此在多线程时可能会发生并发安全问题。
其中库存校验其实是更新数据库中的已经发放的数量,因此可以直接基于乐观锁来解决安全问题。但领取数量不行,因为要临时统计当前用户已经领取了多少券,然后才能做判断。只能是采用悲观锁的方案。但是这样会影响性能。
所以为了提高性能,我们必须减少锁的范围。我们就把统计已经领取数量、判断、新增用户领券记录的这部分代码加锁,而且锁的对象是用户id。这样锁的范围就非常小了,业务的并发能力就有一定的提升。
想法是很好的,但是在实际测试的时候,我们发现尽管加了锁,但是还会出现用户超领的现象。比如限领2张,用户可能会领取3张、4张,甚至更多。也就是说并发安全问题并没有解决。
锁本身经过测试,肯定是没有问题的,所以一开始这个问题确实觉得挺诡异的。后来调试的时候发现,偶然发现,有的时候,当一个线程完成了领取记录的保存,另一个线程在统计领券数量时,依然统计不到这条记录。
这个时候猜测应该是数据库的事务隔离导致的,因为我们领取的整个业务外面加了事务,而加锁的是其中的限领数量校验的部分。因此业务结束时,会先释放锁,然后等整个业务结束,才会提交事务。这就导致在某些情况下,一个线程新增了领券记录,释放了锁;而另一个线程获取锁时,前一个线程事务尚未提交,因此读取不到未提交的领券记录。
为了解决这个问题,我们将事务的范围缩小,保证了事务先提交,再释放锁,最终线程安全问题不再发生了。
标签:事务,优惠券,coupon,用户,public,day10,方法,复盘,tjxt From: https://blog.csdn.net/weixin_46321761/article/details/141598904