业务背景
我们在使用优惠券购物时,可以在订单结算页面查看自己的可用/不可用优惠券列表,可用的优惠券还会根据可扣减订单金额从大到小进行排序。
以下为美团外卖订单结算时优惠券可用/不可用列表:
Git 分支
先从 main 分支上查看,代码入口:CouponQueryController#listQueryCouponsBySync
。
查询用户优惠券&计算折扣金额
因为查询用户可用优惠券时,需要根据折扣金额进行排序,所以本质上在查询用户可用优惠券功能基础上,就把后者实现了。
1. 请求&返回参数
我们查询的时候,需要传入订单金额、商品集合以及店铺编号信息,因为我们优惠券服务这个接口不直接对用户,所以订单金额这些信息不需要额外验证。一般来说,会由聚合服务或者订单服务进行验证,然后路由到我们服务中。
响应参数里重点返回优惠券明细和优惠券减免金额。
代码如下所示:
@Data@Schema(description ="查询用户优惠券请求参数")publicclassQueryCouponsReqDTO{
/**
* 订单金额
*/@Schema(description ="订单金额", required =true)privateBigDecimal orderAmount;
/**
* 店铺编号
*/@Schema(description ="店铺编号", example ="1810714735922956666", required =true)privateString shopNumber;
/**
* 商品集合
*/@Schema(description ="商品集合", required =true)privateList<QueryCouponGoodsReqDTO> goodsList;}
@Data@NoArgsConstructor@AllArgsConstructor@Builder@Schema(description ="查询用户优惠券响应参数")publicclassQueryCouponsRespDTO{
/**
* 可用优惠券列表
*/@Schema(description ="可用优惠券列表")privateList<QueryCouponsDetailRespDTO> availableCouponList;
/**
* 不可用优惠券列表
*/@Schema(description ="不可用优惠券列表")privateList<QueryCouponsDetailRespDTO> notAvailableCouponList;}
@Data@NoArgsConstructor@AllArgsConstructor@Builder@Schema(description ="查询用户优惠券明细响应参数")publicclassQueryCouponsDetailRespDTO{
/**
* 优惠券id
*/@Schema(description ="优惠券id")privateString id;
/**
* 优惠对象 0:商品专属 1:全店通用
*/@Schema(description ="优惠对象 0:商品专属 1:全店通用")privateInteger target;
/**
* 优惠商品编码
*/@Schema(description ="优惠商品编码")privateString goods;
/**
* 优惠类型 0:立减券 1:满减券 2:折扣券
*/@Schema(description ="优惠类型 0:立减券 1:满减券 2:折扣券")privateInteger type;
/**
* 消耗规则
*/@Schema(description ="消耗规则")privateString consumeRule;
/**
* 优惠券金额
*/@Schema(description ="优惠券金额")privateBigDecimal couponAmount;}
2. 查询用户优惠券
首先我们需要将用户的优惠券列表从 Redis ZSet 缓存中查询出来,因为要从所有优惠券中查询可用优惠券并按照减免金额进行排序,所以我们直接 range 的范围是 0 到 -1。另外为了避免复杂计算,我们将现有优惠券按照全店通用以及指定商品可用拆分为两个分组,对这两个分组分别进行计算。
代码如下所示:
publicQueryCouponsRespDTOlistQueryUserCouponsBySync(QueryCouponsReqDTO requestParam){Set<String> rangeUserCoupons = stringRedisTemplate.opsForZSet().range(String.format(USER_COUPON_TEMPLATE_LIST_KEY,UserContext.getUserId()),0,-1);
List<String> couponTemplateIds = rangeUserCoupons.stream().map(each ->StrUtil.split(each,"_").get(0)).map(each -> redisDistributedProperties.getPrefix()+String.format(COUPON_TEMPLATE_KEY, each)).toList();List<Object> couponTemplateList = stringRedisTemplate.executePipelined((RedisCallback<String>) connection ->{
couponTemplateIds.forEach(each -> connection.hashCommands().hGetAll(each.getBytes()));returnnull;});
List<CouponTemplateQueryRespDTO> couponTemplateDTOList =JSON.parseArray(JSON.toJSONString(couponTemplateList),CouponTemplateQueryRespDTO.class);Map<Boolean, List<CouponTemplateQueryRespDTO>> partitioned = couponTemplateDTOList.stream().collect(Collectors.partitioningBy(coupon ->StrUtil.isEmpty(coupon.getGoods())));
// 拆分后的两个列表List<CouponTemplateQueryRespDTO> goodsEmptyList = partitioned.get(true);// goods 为空的列表List<CouponTemplateQueryRespDTO> goodsNotEmptyList = partitioned.get(false);// goods 不为空的列表
// ......}
我们解析下上述代码逻辑:
- 1.提取优惠券模板 ID:通过
StrUtil.split(each, "_").get(0)
操作,提取每个元素中的templateId
。假设 Redis 存储的格式是templateId_couponId
,则templateId
为第一个元素。 - 2.构建 Redis Key:将提取的
templateId
转换为 Redis 中存储优惠券模板数据的完整 Key(例如prefix_couponTemplate_{templateId}
)。 - 3.生成 Redis Key 列表:最终得到一个
List<String>
类型的 Redis Key 列表,准备从 Redis 中批量获取优惠券模板的详细信息。 - 4.使用 Redis Pipeline 批量获取数据:通过
stringRedisTemplate.executePipelined
方法,使用 Redis 管道(Pipeline)技术批量获取 Redis 中的优惠券模板信息。这样做能够减少与 Redis 服务器的网络交互次数,提高读取性能。 - 5.数据转换:通过
JSON.parseArray
方法,将couponTemplateList
转换为CouponTemplateQueryRespDTO
对象列表。 - 6.按
goods
字段分区:使用Collectors.partitioningBy
方法,将优惠券模板列表分为两部分:true
:goods
字段为空的优惠券模板,表示该优惠券是平台券或店铺券(没有特定商品限制)。false
:goods
字段不为空的优惠券模板,表示该优惠券是商品专属券(只能用于特定商品)。
3. 计算折扣金额
接下来就是通过优惠券的使用条件以及减免类型等计算订单减免金额。
代码如下所示:
publicQueryCouponsRespDTOlistQueryUserCouponsBySync(QueryCouponsReqDTO requestParam){// .....
// 针对当前订单可用/不可用的优惠券列表List<QueryCouponsDetailRespDTO> availableCouponList =newArrayList<>();List<QueryCouponsDetailRespDTO> notAvailableCouponList =newArrayList<>();
goodsEmptyList.forEach(each ->{JSONObject jsonObject =JSON.parseObject(each.getConsumeRule());QueryCouponsDetailRespDTO resultQueryCouponDetail =BeanUtil.toBean(each,QueryCouponsDetailRespDTO.class);BigDecimal maximumDiscountAmount = jsonObject.getBigDecimal("maximumDiscountAmount");switch(each.getType()){case0:// 立减券
resultQueryCouponDetail.setCouponAmount(maximumDiscountAmount);
availableCouponList.add(resultQueryCouponDetail);break;case1:// 满减券// orderAmount 大于或等于 termsOfUseif(requestParam.getOrderAmount().compareTo(jsonObject.getBigDecimal("termsOfUse"))>=0){
resultQueryCouponDetail.setCouponAmount(maximumDiscountAmount);
availableCouponList.add(resultQueryCouponDetail);}else{
notAvailableCouponList.add(resultQueryCouponDetail);}break;case2:// 折扣券// orderAmount 大于或等于 termsOfUseif(requestParam.getOrderAmount().compareTo(jsonObject.getBigDecimal("termsOfUse"))>=0){BigDecimal multiply = requestParam.getOrderAmount().multiply(jsonObject.getBigDecimal("discountRate"));if(multiply.compareTo(maximumDiscountAmount)>=0){
resultQueryCouponDetail.setCouponAmount(maximumDiscountAmount);}else{
resultQueryCouponDetail.setCouponAmount(multiply);}
availableCouponList.add(resultQueryCouponDetail);}else{
notAvailableCouponList.add(resultQueryCouponDetail);}break;default:thrownewClientException("无效的优惠券类型");}});
Map<String, QueryCouponGoodsReqDTO> goodsRequestMap = requestParam.getGoodsList().stream().collect(Collectors.toMap(QueryCouponGoodsReqDTO::getGoodsNumber,Function.identity(),(existing, replacement)-> existing));
goodsNotEmptyList.forEach(each ->{QueryCouponGoodsReqDTO couponGoods = goodsRequestMap.get(each.getGoods());if(couponGoods ==null){
notAvailableCouponList.add(BeanUtil.toBean(each,QueryCouponsDetailRespDTO.class));}JSONObject jsonObject =JSON.parseObject(each.getConsumeRule());QueryCouponsDetailRespDTO resultQueryCouponDetail =BeanUtil.toBean(each,QueryCouponsDetailRespDTO.class);switch(each.getType()){case0:// 立减券
resultQueryCouponDetail.setCouponAmount(jsonObject.getBigDecimal("maximumDiscountAmount"));
availableCouponList.add(resultQueryCouponDetail);break;case1:// 满减券// goodsAmount 大于或等于 termsOfUseif(couponGoods.getGoodsAmount().compareTo(jsonObject.getBigDecimal("termsOfUse"))>=0){
resultQueryCouponDetail.setCouponAmount(jsonObject.getBigDecimal("maximumDiscountAmount"));
availableCouponList.add(resultQueryCouponDetail);}else{
notAvailableCouponList.add(resultQueryCouponDetail);}break;case2:// 折扣券// goodsAmount 大于或等于 termsOfUseif(couponGoods.getGoodsAmount().compareTo(jsonObject.getBigDecimal("termsOfUse"))>=0){BigDecimal discountRate = jsonObject.getBigDecimal("discountRate");
resultQueryCouponDetail.setCouponAmount(couponGoods.getGoodsAmount().multiply(discountRate));
availableCouponList.add(resultQueryCouponDetail);}else{
notAvailableCouponList.add(resultQueryCouponDetail);}break;default:thrownewClientException("无效的优惠券类型");}});
// 与业内标准一致,按最终优惠力度从大到小排序
availableCouponList.sort((c1, c2)-> c2.getCouponAmount().compareTo(c1.getCouponAmount()));
returnQueryCouponsRespDTO.builder().availableCouponList(availableCouponList).notAvailableCouponList(notAvailableCouponList).build();}
定义可用和不可用的优惠券列表,用于存储当前订单可用以及不可用的优惠券列表。然后我们分为两个类型逻辑解析,分别是全店可用以及部分商品可用。
全店可用的优惠券计算逻辑如下所示:
- 1.遍历
goodsEmptyList
,根据订单金额判断优惠券是否可用; - 2.优惠券类型判断:根据
each.getType()
判断优惠券的类型,并根据不同类型设置resultQueryCouponDetail
中的优惠金额(couponAmount
);立减券(type = 0)
:直接将maximumDiscountAmount
作为优惠金额;满减券(type = 1)
:如果订单金额大于等于termsOfUse
,则将maximumDiscountAmount
作为优惠金额,否则加入不可用列表;折扣券(type = 2)
:如果订单金额大于等于termsOfUse
,则使用折扣比例计算优惠金额,并取maximumDiscountAmount
和计算结果中的最小值作为最终优惠金额。
部分商品可用的优惠券计算逻辑如下所示:
- 1.构建商品编号到商品请求对象的映射:将请求参数中的商品列表
requestParam.getGoodsList()
转换为一个Map<String, QueryCouponGoodsReqDTO>
,其中goodsNumber
作为 Key,QueryCouponGoodsReqDTO
对象作为 Value; - 2.遍历
goodsNotEmptyList
,根据商品信息判断优惠券是否可用; - 3.解析优惠券消费规则:将
each
转换为QueryCouponsDetailRespDTO
对象,并解析consumeRule
字段,获取优惠券的详细规则信息; - 4.优惠券类型判断:
立减券(type = 0)
:直接将最大优惠金额设置为优惠金额。满减券(type = 1)
:如果商品金额大于等于termsOfUse
,则优惠有效,否则加入不可用列表。折扣券(type = 2)
:如果商品金额大于等于termsOfUse
,则使用折扣比例计算优惠金额,并加入可用列表。
将上述逻辑完成后,对 availableCouponList
中的所有可用优惠券按 couponAmount
进行降序排序(优惠力度从大到小)。最终构建返回对象,至此,获取用户可用/不可用优惠券相关逻辑查询完成。
功能测试
代码其实理解起来并不难,但是问题难在哪?难就难在测试并不好构造数据。为此,我提供了单元测试构造相关数据以及相关的请求入参,大家可以按照流程进行测试。
1. 执行单元测试
首先在后管服务 merchant-admin 单元测试中搜索这个方法,执行一次后应该是报错的,然后修改 -ea 参数。
- MockSettlementCouponTemplateDataTests#mockCouponTemplateTest
然后再执行应该就是成功的,为此,我们的优惠券模板记录是已经初始化完成了。
共初始化了 5 个优惠券模板,对应的规则如下所示:
优惠券1
- 优惠类型:立减券
- 优惠对象:店铺券
- 优惠金额:立减 10 元
优惠券2
- 优惠类型:立减券
- 优惠对象:商品券 001
- 优惠金额:立减 3 元
优惠券3
- 优惠类型:满减券
- 优惠对象:店铺券
- 优惠金额:立减 10 元
- 需要满足条件:金额满足 100 元
优惠券4
- 优惠类型:折扣券
- 优惠对象:店铺券
- 折扣:0.6 折
- 最大优惠金额:20 元
- 需要满足条件:金额满足 100 元
优惠券5
- 优惠类型:折扣券
- 优惠对象:店铺券
- 折扣:0.6 折
- 最大优惠金额:40 元
- 需要满足条件:金额满足 300 元
2. 执行用户兑换优惠券
优惠券模板创建好了,我们要开始执行用户兑换优惠券模板。访问下述地址:
然后将下述请求参数的 couponTemplateId
替换为刚才创建的 5 个优惠券模板 ID 即可。
{"source":0,"shopNumber":"1810714735922956666","couponTemplateId":"xxx"}
3. 执行查询用户优惠券接口
访问下述地址:
然后将下述参数对应数据放入请求参数中,即可完成接口访问:
- 订单金额 226
- 商品1金额 80 编码:001
- 商品2金额 26 编码:002
- 商品3金额 100 编码:003
Post 接口 JSON 请求数据如下所示:
{"orderAmount":226,"shopNumber":"1810714735922956666","goodsList":[{"goodsNumber":"001","goodsAmount":80},{"goodsNumber":"002","goodsAmount":26},{"goodsNumber":"003","goodsAmount":100}]}
理论上,我们只有优惠券5的折扣券不能使用,其他都是可使用的。为了验证猜想,我们调用下获取用户优惠券列表接口进行测试。
返回数据如下所示:
{"code":"0","message":null,"data":{"availableCouponList":[{"id":"1843290380169883650","target":1,"goods":null,"type":2,"consumeRule":"{\"termsOfUse\":100,\"maximumDiscountAmount\":20,\"explanationOfUnmetConditions\":\"3\",\"discountRate\":\"0.6\",\"validityPeriod\":48}","couponAmount":20},{"id":"1843290379737870337","target":1,"goods":null,"type":1,"consumeRule":"{\"termsOfUse\":100,\"maximumDiscountAmount\":10,\"explanationOfUnmetConditions\":\"3\",\"validityPeriod\":48}","couponAmount":10},{"id":"1843290372464947202","target":1,"goods":null,"type":0,"consumeRule":"{\"maximumDiscountAmount\":10,\"explanationOfUnmetConditions\":\"3\",\"validityPeriod\":48}","couponAmount":10},{"id":"1843290379234553857","target":0,"goods":"001","type":0,"consumeRule":"{\"maximumDiscountAmount\":3,\"explanationOfUnmetConditions\":\"3\",\"validityPeriod\":48}","couponAmount":3}],"notAvailableCouponList":[{"id":"1843290380593508353","target":1,"goods":null,"type":2,"consumeRule":"{\"termsOfUse\":300,\"maximumDiscountAmount\":40,\"explanationOfUnmetConditions\":\"3\",\"discountRate\":\"0.6\",\"validityPeriod\":48}","couponAmount":null}]},"requestId":null,"success":true,"fail":false}
文末总结
本章节通过解析用户的订单信息,实现在订单结算时根据优惠券的不同类型(立减券、满减券、折扣券)进行分类和优惠金额计算。整个过程涉及到 Redis 数据获取、数据分区、消费规则解析和金额计算等多种操作。通过单元测试,我们验证了各种优惠券在不同订单金额和商品组合下的使用规则和优先级。
目前还有一个问题,就是如果用户领取优惠券比较多的情况下,同步查询可能会较慢一些,我们可以通过多线程方式进行优化,这个将在下一章节说明。
完结,撒花 🎉
这些代码是不是应该放在else里呀

请问查询出优惠券列表的信息以后,是否要先判断优惠券的店铺编号是否等于当前的店铺编号,如果不等于直接加入不可用列表?

我的理解是一个人的优惠券列表会列出所有的优惠券,分店铺就是有条件的搜索了
获取rawCouponDataList是不是少了判空逻辑呢?可能用户领取的优惠券中有的已经过期,缓存中没有就会导致返回空字典{},那在后面获取消费规则consumRule时就是null,传入handleCouponLogic方法获取"termsOfUse"就会报错。
int main() 回复 背单词:不是缓存没有,是key错了,你可以debug,key多了null
背单词 回复 int main():我去还真是,nullone-coupon_engine:template:1891478133355298818。多了null
int main() 回复 背单词:后面还有问题呢,就是存入redis里面的优惠券模板的时间格式,得改一下,要不然还是会报错
int main() 回复 背单词:其实接口幂等注解也有问题,你用jmeter测一下,一个线程,一秒循环10次,测试结果是十次都是成功的,但是理论上这个属于重复请求,应该只有一次成功。
花开富贵 回复 背单词:马哥之前在秒杀那块创建了Redis Key 序列化器,可自定义 Key Prefix,如果没使用的话,需要将CouponQueryServiceImpl类中使用redisDistributedProperties.getPrefix() 的地方给注掉,否则couponTemplateIds里面的key都是以null开头的
马哥,获取优惠券模版详情都是在缓存中获取的。这能保证模版一定在缓存中吗? 我的理解是要redis获取一边,然后检索哪些没拿到,再去db拿一遍
第⁴纪冰川 回复 X-in:我感觉不能保证,一个是内存淘汰策略,还有就是可能优惠券模板过期了,但是用户优惠券还没过期(用户领了券有一个过期时间),但是要是考虑这个问题,得改很多代码,因为Redis中用户优惠券列表只有模板id和用户券id,是没有shopNumber的,优惠券模板又是用shopNumber做分片键的,我感觉哈。
业务背景
在日常开发中,大家通常想到的性能优化方式就是引入多线程进行并行处理。在之前的计算逻辑中,我们是以单线程的串行方式逐个处理优惠券的计算。那是否可以尝试将每个优惠券的计算逻辑从串行改为多线程并行处理呢?
理论上,如果计算一个优惠券的优惠金额需要 1 毫秒,那么处理 10 个优惠券时串行执行就需要 10 毫秒,但如果采用并行执行方式,10 个任务同时计算就只需要 1 毫秒。带着这个优化思路,我们继续深入分析和改进。
Git 分支
先从 main 分支上查看,代码入口:CouponQueryController#listQueryCoupons
。
和上一章节《查询用户可用/不可用优惠券&计算折扣金额》业务完全一致,如果对下面业务代码不太了解的话,可以先去看下。
多线程计算优惠金额
1. 并行技术选型
多线程并行优化有多种实现方式,例如通过线程池结合 Future
或使用 CompletableFuture
等。在本场景中,我们选择使用 CompletableFuture
来实现多线程并行处理。如果大家想深入了解 CompletableFuture
的机制和使用场景,推荐阅读以下两篇文章:
- 美团技术团队:CompletableFuture 原理与实践 - 外卖商家端 API 的异步化:详细解析
CompletableFuture
在实际业务场景中的应用和性能提升效果。 - JavaGuide:CompletableFuture 详解:系统性讲解
CompletableFuture
的功能、常见使用模式及其底层原理。
通过阅读这两篇文章,可以更好地理解 CompletableFuture
的作用及其在提升系统性能中的实际应用。
2. 并行代码改造
在使用线程池结合 CompletableFuture
进行并行改造时,涉及到多个并发编程的细节和优化点。比如,如何合理设置线程池参数、处理多线程环境下集合的并发安全问题、以及如何在所有并发任务完成后再进行后续处理等。
代码如下所示:
// 在我们本次的业务场景中,属于是 CPU 密集型任务,设置 CPU 的核心数即可privatefinalExecutorService executorService =newThreadPoolExecutor(Runtime.getRuntime().availableProcessors(),Runtime.getRuntime().availableProcessors(),9999,TimeUnit.SECONDS,newSynchronousQueue<>(),newThreadPoolExecutor.CallerRunsPolicy());
@OverridepublicQueryCouponsRespDTOlistQueryUserCoupons(QueryCouponsReqDTO requestParam){// ......
// 拆分后的两个列表List<CouponTemplateQueryRespDTO> goodsEmptyList = partitioned.get(true);// goods 为空的列表List<CouponTemplateQueryRespDTO> goodsNotEmptyList = partitioned.get(false);// goods 不为空的列表
// 针对当前订单可用/不可用的优惠券列表List<QueryCouponsDetailRespDTO> availableCouponList =Collections.synchronizedList(newArrayList<>());List<QueryCouponsDetailRespDTO> notAvailableCouponList =Collections.synchronizedList(newArrayList<>());
// Step 2: 并行处理 goodsEmptyList 和 goodsNotEmptyList 中的每个元素CompletableFuture<Void> emptyGoodsTasks =CompletableFuture.allOf(
goodsEmptyList.stream().map(each ->CompletableFuture.runAsync(()->{QueryCouponsDetailRespDTO resultCouponDetail =BeanUtil.toBean(each,QueryCouponsDetailRespDTO.class);JSONObject jsonObject =JSON.parseObject(each.getConsumeRule());handleCouponLogic(resultCouponDetail, jsonObject, requestParam.getOrderAmount(), availableCouponList, notAvailableCouponList);}, executorService)).toArray(CompletableFuture[]::new));
Map<String, QueryCouponGoodsReqDTO> goodsRequestMap = requestParam.getGoodsList().stream().collect(Collectors.toMap(QueryCouponGoodsReqDTO::getGoodsNumber,Function.identity()));CompletableFuture<Void> notEmptyGoodsTasks =CompletableFuture.allOf(
goodsNotEmptyList.stream().map(each ->CompletableFuture.runAsync(()->{QueryCouponsDetailRespDTO resultCouponDetail =BeanUtil.toBean(each,QueryCouponsDetailRespDTO.class);QueryCouponGoodsReqDTO couponGoods = goodsRequestMap.get(each.getGoods());if(couponGoods ==null){
notAvailableCouponList.add(resultCouponDetail);}else{JSONObject jsonObject =JSON.parseObject(each.getConsumeRule());handleCouponLogic(resultCouponDetail, jsonObject, couponGoods.getGoodsAmount(), availableCouponList, notAvailableCouponList);}}, executorService)).toArray(CompletableFuture[]::new));
// Step 3: 等待两个异步任务集合完成CompletableFuture.allOf(emptyGoodsTasks, notEmptyGoodsTasks).thenRun(()->{// 与业内标准一致,按最终优惠力度从大到小排序
availableCouponList.sort((c1, c2)-> c2.getCouponAmount().compareTo(c1.getCouponAmount()));}).join();
// 构建最终结果并返回returnQueryCouponsRespDTO.builder().availableCouponList(availableCouponList).notAvailableCouponList(notAvailableCouponList).build();}
// 优惠券判断逻辑,根据条件判断放入可用或不可用列表privatevoidhandleCouponLogic(QueryCouponsDetailRespDTO resultCouponDetail,JSONObject jsonObject,BigDecimal amount,List<QueryCouponsDetailRespDTO> availableCouponList,List<QueryCouponsDetailRespDTO> notAvailableCouponList){BigDecimal termsOfUse = jsonObject.getBigDecimal("termsOfUse");BigDecimal maximumDiscountAmount = jsonObject.getBigDecimal("maximumDiscountAmount");
switch(resultCouponDetail.getType()){case0:// 立减券
resultCouponDetail.setCouponAmount(maximumDiscountAmount);
availableCouponList.add(resultCouponDetail);break;case1:// 满减券if(amount.compareTo(termsOfUse)>=0){
resultCouponDetail.setCouponAmount(maximumDiscountAmount);
availableCouponList.add(resultCouponDetail);}else{
notAvailableCouponList.add(resultCouponDetail);}break;case2:// 折扣券if(amount.compareTo(termsOfUse)>=0){BigDecimal discountRate = jsonObject.getBigDecimal("discountRate");BigDecimal multiply = amount.multiply(discountRate);if(multiply.compareTo(maximumDiscountAmount)>=0){
resultCouponDetail.setCouponAmount(maximumDiscountAmount);}else{
resultCouponDetail.setCouponAmount(multiply);}
availableCouponList.add(resultCouponDetail);}else{
notAvailableCouponList.add(resultCouponDetail);}break;default:thrownewClientException("无效的优惠券类型");}}
3. 并行代码逻辑解析
3.1 CompletableFuture.allOf
能够将多个 CompletableFuture
合并为一个新的 CompletableFuture<Void>
,并且只有当所有传入的 CompletableFuture
全部完成时(无论是成功还是失败),这个新的 CompletableFuture
才会被标记为完成。
工作原理:
CompletableFuture.allOf
返回的CompletableFuture<Void>
本身不包含每个单独任务的执行结果,它仅表示所有任务的状态:要么全部完成,要么至少有一个异常。- 如果任意一个传入的
CompletableFuture
异常完成,则allOfFuture
也会异常完成。 - 如果所有的
CompletableFuture
成功完成,则allOfFuture
也会成功完成。
CompletableFuture.allOf
通常用于以下几种场景:
- 等待所有异步任务完成后再执行操作: 当你希望所有异步任务都完成后,再触发某个后续操作(如汇总结果、更新数据库状态)时,使用
CompletableFuture.allOf
是一种理想的选择。 - 批量执行任务: 比如在微服务架构中,我们需要向多个服务发送异步请求,并在所有请求完成后进行后续处理。
- 并行处理: 当对一批数据进行并行处理时(如计算、过滤、转化等),可以利用
CompletableFuture.allOf
合并所有的异步任务,等所有任务完成后再继续执行。
对于 CompletableFuture.allOf
返回的 CompletableFuture<Void>
,通常会调用以下方法来等待其完成并进行后续操作:
- 1.
allOfFuture.join()
或allOfFuture.get()
: 阻塞等待所有任务完成。
CompletableFuture<Void> allOfFuture =CompletableFuture.allOf(future1, future2, future3);
allOfFuture.join();// 阻塞等待所有任务完成System.out.println("所有任务已完成");
- 1.
allOfFuture.thenRun(...)
或allOfFuture.thenAccept(...)
: 指定当所有任务完成后,要执行的回调操作。
CompletableFuture.allOf(future1, future2, future3).thenRun(()->System.out.println("所有任务已完成"));// 当所有任务完成时执行
- 1.组合多个
CompletableFuture
的结果: 使用CompletableFuture.allOf
时,返回的CompletableFuture<Void>
本身不包含各个任务的结果,所以如果要组合结果,可以使用以下方式:
CompletableFuture<String> future1 =CompletableFuture.supplyAsync(()->"结果1");CompletableFuture<String> future2 =CompletableFuture.supplyAsync(()->"结果2");
CompletableFuture<Void> allOfFuture =CompletableFuture.allOf(future1, future2);
// 获取所有子任务的结果
allOfFuture.thenRun(()->{try{String result1 = future1.get();String result2 = future2.get();System.out.println("所有任务结果:"+ result1 +", "+ result2);}catch(Exception e){
e.printStackTrace();}}).join();// 确保执行完成
像我们上面的场景,就是使用了方案三,使用 allOf
进行聚合,执行完所有任务后执行 thenRun
对可用优惠券进行排序,最终等待 thenRun
执行完成。
.thenRun(...)
本质上是一个非阻塞的操作,它会在前置 CompletableFuture
完成时立即提交回调任务,而不会等待它的执行完成。也就是说,.thenRun(...)
只安排了这个回调任务的执行,但它本身不会阻塞当前线程去等待回调任务的完成。因此,通常情况下如果希望确保 .thenRun(...)
中的逻辑执行完成,需要通过 join()
或 get()
来等待整个操作链的完成。
thenRun
的常见用途:
thenRun
主要用于 在前置任务完成后执行一个不依赖结果的任务,如简单地输出日志或做一些状态更新。- 由于
thenRun
不依赖前面的CompletableFuture
的执行结果,因此一般用于那些不需要获取结果的场景。如果需要处理前置任务的结果,可以使用thenApply
或thenCompose
。
3.2 为什么使用 Collections.synchronizedList
由于我们将计算金额和判定优惠券可用性的逻辑进行了抽象封装,因此在并发场景下,多个线程可能会同时向同一个集合中添加数据,从而引发线程安全问题。为了解决这个问题,我们使用了 Collections.synchronizedList
方法,将集合包装为线程安全的版本,从而保证在并发修改集合时不会发生数据不一致或异常的情况。
// 针对当前订单可用/不可用的优惠券列表List<QueryCouponsDetailRespDTO> availableCouponList =Collections.synchronizedList(newArrayList<>());List<QueryCouponsDetailRespDTO> notAvailableCouponList =Collections.synchronizedList(newArrayList<>());
为什么 new ArrayList<>() 不能支持并发操作?
ArrayList
本质上是一个动态数组,其内部维护了一个 Object
类型的数组来存储元素。在执行如 add
, remove
, set
这些方法时,ArrayList
不会对共享资源进行加锁或同步操作。由于没有任何同步机制,这些操作在多线程环境下会导致数据竞争和不一致性。
在多线程场景中,多个线程可能会同时对同一个 ArrayList
进行修改,比如:
- 添加元素时:多个线程同时执行
add
操作时,它们可能同时读取同一个size
值,然后尝试将元素添加到同一个位置,导致数组越界异常或元素覆盖。 - 删除元素时:多个线程同时删除时可能会导致
size
和数组内容不一致,抛出ConcurrentModificationException
。
使用 Collections.synchronizedList(new ArrayList<>())
方法将 ArrayList
转换为线程安全的集合。这种方式通过在 add
, remove
等方法上增加同步锁,确保在多线程环境中可以安全地操作。
部分代码如下所示:
staticclassSynchronizedList<E>extendsSynchronizedCollection<E>implementsList<E>{@java.io.Serialprivatestaticfinallong serialVersionUID =-7754090372962971524L;
@SuppressWarnings("serial")// Conditionally serializablefinalList<E> list;
SynchronizedList(List<E> list){super(list);this.list = list;}SynchronizedList(List<E> list,Object mutex){super(list, mutex);this.list = list;}
publicbooleanequals(Object o){if(this== o)returntrue;synchronized(mutex){return list.equals(o);}}publicinthashCode(){synchronized(mutex){return list.hashCode();}}
publicEget(int index){synchronized(mutex){return list.get(index);}}publicEset(int index,E element){synchronized(mutex){return list.set(index, element);}}// ......}
3.3 为什么不用 CopyOnWriteArrayList?
CopyOnWriteArrayList
是一种线程安全的 List
实现,它通过每次写操作时复制整个底层数组来实现线程安全,非常适合读多写少的场景。为什么我们的场景不使用这个性能更高的 List?
CopyOnWriteArrayList
的核心机制是 每次写操作(如 add
、set
、remove
)都会创建整个数组的副本。这种操作虽然可以保证线程安全,但每次修改时都需要复制整个数组,开销非常大。
- 当集合中的元素较多,且有频繁的写操作时,
CopyOnWriteArrayList
会显著增加内存使用和 CPU 资源消耗。 - 如果数据量大,可能导致内存消耗过高,甚至会引发
OutOfMemoryError
。
因此,CopyOnWriteArrayList
更适用于读多写少的场景,在高频写入的场景下并不合适。而我们场景仅是写入并且排序,直接就返回前端了,所以基本上没有读场景,所以可以直接忽略掉。
功能测试
测试方法和上一章节《查询用户可用/不可用优惠券&计算折扣金额》业务完全一致,可以复用同一个请求参数。
访问接口如下所示:
@RestController@RequiredArgsConstructor@Tag(name ="查询用户优惠券管理")publicclassCouponQueryController{
privatefinalCouponQueryService couponQueryService;
@Operation(summary ="查询用户可用/不可用优惠券列表")@PostMapping("/api/settlement/coupon-query")publicResult<QueryCouponsRespDTO>listQueryCoupons(@RequestBodyQueryCouponsReqDTO requestParam){returnResults.success(couponQueryService.listQueryUserCoupons(requestParam));}
// ......}
文末总结
在本章节中,我们通过多线程并行优化技术,将原先串行处理的优惠券计算逻辑改造为基于 CompletableFuture
的多线程并行处理方案。这种方式能够在处理大量优惠券时有效降低计算延时,提高系统响应速度。尤其是在每个优惠券的计算逻辑相对复杂时,多线程并行能够显著提升处理效率。
为了确保线程安全,我们使用了 Collections.synchronizedList
保护共享集合,避免多线程写入时出现数据不一致的问题。同时,通过合理设置线程池参数,保证系统资源的高效利用。
通过多线程并行执行,原本可能需要较长时间的优惠券计算任务在性能上得到了显著优化,为用户提供了更快速的查询体验。
上面的内容是理论层面的优化总结,很多同学在实际开发中往往认为引入并发编程就一定能够提升接口的响应性能。然而,如果你将我们这段改造后的并行代码和原先的同步请求逻辑进行对比,就会发现性能提升并不明显。甚至在使用 JMeter 进行高并发压测时,你可能会发现系统的吞吐量反而有所下降。
这是因为并发编程并不是万能的。引入多线程后,CPU 的上下文切换、线程池管理的开销以及数据同步等问题,都会导致系统的整体性能未必达到预期。关于这个结果背后的具体分析,我们会在接下来的压测章节中详细揭晓,敬请期待。
完结,撒花 🎉
Comments NOTHING