CQRS在订单详情与订单列表的优化
在我们过去的实践中详情与列表是每次查询动态生成出来的,效率低下且故障率高、耦合高。通常我们是站在纯粹技术的角度是解决这类问题,比如添加索引,比如添加缓存。今天提出一种特别的方式处理这个问题。
服务现状
list接口
list接口在我们目前(2023年5月23日)的实践中是如下,延时800毫秒,依赖接口非常多。
代码如下,在es查询到当前页订单ID列表的时候需要根据id列表查询补全几乎所有其他订单信息,最后根据数据在内存中将数据构建成我们需要的展示用的数据模型与数据格式。
// 构建订单列表上下文(并发)
OrderListContext.ContextBuilder contextBuilder = OrderListContext.newBuilder(orderResponseEntity, orderCommonTaskExecutor)
.getFirstFreeOrderInfoResp(() -> getFirstFreeOrderInfoResp(wsfUserDetails.getUserId()))
.subUserInfoMap(() -> commonBathGetDataService.getSubUserInfoMap(wsfUserDetails.getUserId()))
.userInfoGetResp(() -> UserHelper.getUserInfo(wsfUserDetails.getUserId()))
.configServerCategoryMap(commonBathGetDataService::getAllConfigServerCategoryMap)
.configServerTypeMap(commonBathGetDataService::getAllOrderServerTypeMap)
.orderGoodsCategoryMap(() -> commonBathGetDataService.getOrderGoodsCategoryMap(goodsCategoryIds))
.orderRewardInfoMap(() -> commonBathGetDataService.getOrderRewardInfoMap(allOrderIds))
.orderMarkMap(() -> commonBathGetDataService.getOrderMarkMap(wsfUserDetails.getUserId(), allOrderIds))
.shortRefundInfoListMap(() -> commonRefundService.shortRefundInfoListMap(wsfUserDetails.getUserId(), allGlobalOrderTraceIds, globalIdAndOrderStatusMap, null))
.orderRelaInfosMap(() -> commonBathGetDataService.getOrderRelaInfosMap(allOrderIds))
.orderCountdownTimeMap(() -> commonBathGetDataService.getOrderCountdownTimeMap(allOrderIds))
.divisionIdFullNameMap(() -> commonBathGetDataService.divisionIdFullNameMap(divisionIds))
.orderGoodsFirstImgMap(() -> this.getOrderGoodsFirstImgMap(allOrderIds))
.subOrderToPayNum(() -> commonBathGetDataService.getSubOrderToPayNum(allOrderBase))
.isMaxOfferNumMap(() -> commonBathGetDataService.isMaxOfferNumMap(allGlobalOrderTraceIds, wsfUserDetails.getUserId()))
.allEnterpriseInfoMap(commonBathGetDataService::getErpsInfoMap)
.appointEnterpriseInfoMap(() -> commonBathGetDataService.getMastersInfoMap(enterpriseIds, AccountType.ENTERPRISE.code))
.appointMasterInfoMap(() -> commonBathGetDataService.getMastersInfoMap(masterIds, AccountType.MASTER.code))
.orderServeNodeInfoMap(() -> commonBathGetDataService.getOrderServeNodeInfoMap(wsfUserDetails.getUserId(), allOrderBase))
.orderMasterHeadUrlMap(() -> this.getOrderMasterHeadUrlMap(orderResponseEntity.getRecords()))
.orderServerDetailMap(() -> this.getOrderServerDetailMap(allGlobalOrderTraceIds))
.orderServeMasterMap(() -> this.getOrderServeMasterMap(allGlobalOrderTraceIds))
.behaviorAwardActivityMap(() -> this.getOrderActivityRecordInfoMap(allOrderIds))
.orderSubTaskInfoMap(() -> commonBathGetDataService.orderSubTaskInfoMap(allOrderIds))
.orderGoodsNumMap(() -> this.getOrderGoodsNumMap(allOrderIds))
.orderPaidPriceMap(() -> this.getOrderPaidPriceMap(allOrderIds, wsfUserDetails.getUserId()))
.cancelTocOrderMap(() -> this.getCancelTocOrderMap(allOrderBase, wsfUserDetails.getUserId()))
.rateListRespBeanList(() -> this.getRateListRespBeanList(allGlobalOrderTraceIds))
.qualityAssuranceRelaMap(() -> this.getQualityAssuranceRelaMap(allGlobalOrderTraceIds))
.thirdpartOrderTradeInfoMap(() -> this.getThirdpartOrderTradeInfoMap(allOrderIds))
.contractMasterInfoMap(() -> this.getContractMasterInfoMap(contractMasterGlobalOrderTraceIds));
if (enterpriseOrderList.size() > 0) {
contextBuilder
.isCanModifyEnterpriseOrderMap(() -> commonBathGetDataService.getIsCanModifyEnterpriseOrderMap(enterpriseGlobalOrderTraceIds))
.payTypeOfUserSupplierMap(() -> this.getPayTypeOfUserSupplierMap(wsfUserDetails.getUserId(), enterpriseIds));
}
if (masterOrderList.size() > 0) {
contextBuilder
.orderViewNumMap(() -> commonBathGetDataService.getViewNumberByIds(wsfUserDetails.getUserId(), AccountType.USER.code, needGetViewNumIds))
.orderPolicyFeeMap(() -> commonBathGetDataService.getOrderPolicyFeeMap(masterOrderIds, wsfUserDetails));
}
return contextBuilder.build();
虽然这里的接口都是非阻塞进行且做了接口降级处理,但是接口性能还是有一些问题的,尤其是在大量商品与大量报价的时候。再者是依赖过多的外部接口会导致故障率提高,尽管我们的服务是高可用的。
detail接口
detail接口的实践如下,延时700毫秒,依赖的接口也非常多。
代码如下,每次获取详情都需要对每一个商品的每一个属性查询与生成数据:
/**
* 处理常规属性
*/
private void handleRegularAttribute(List<DisplayAttribute> attributes, ServiceInfoExt serviceInfo){
List<ServiceAttribute> rootAttributeDetailList = serviceInfo.getRootAttributeDetailList();
for (ServiceAttribute serviceAttribute : rootAttributeDetailList) {
// 隐藏属性不形式
if(Boolean.TRUE.equals(serviceAttribute.getIsWithoutAttributeName())){
continue;
}
// 不可用属性不显示
if(Integer.valueOf(0).equals(serviceAttribute.getIsAvailable())){
continue;
}
String attributeKey = serviceAttribute.getAttributeKey();
// 不为空且不相等则视为相同属性
DisplayAttribute displayAttribute = attributes.stream().filter(i -> StringUtils.isNotBlank(attributeKey) && Objects.equals(i.getAttributeKey(), attributeKey)).findAny().orElse(null);
if(Objects.nonNull(displayAttribute)){
// 说明已经被特殊处理过了,不需要再处理
continue;
}
displayAttribute = new DisplayAttribute();
displayAttribute.setAttributeKey(serviceAttribute.getAttributeKey());
displayAttribute.setAttributeTag(serviceAttribute.getAttributeTag());
displayAttribute.setAttributeName(serviceAttribute.getAttributeName());
displayAttribute.setSort(serviceAttribute.getSort());
List<ServiceAttributeValue> values = serviceAttribute.getChildList();
if(DataInputSourceEnum.fileInput.name().equals(serviceAttribute.getDataInputSource())){
// 处理文件类型
displayAttribute.setType("file");
displayAttribute.setValue("--");
displayAttribute.setValues(Collections.emptyList());
if(CollectionUtils.isNotEmpty(values)){
List<AttributeExpand> expands = new ArrayList<>();
for (ServiceAttributeValue value : values) {
expands.add(value.getExpand());
}
displayAttribute.setValues(expands);
}
}else {
// 处理值类型
displayAttribute.setType("text");
if(CollectionUtils.isEmpty(values)){
displayAttribute.setValue("--");
}else {
// 在这里可以将连接的多个空格简化成一个,或者说,文本与数字之间的连接符号简化成一个
displayAttribute.setValue(serviceInfoTool.buildValue(serviceAttribute));
}
}
attributes.add(displayAttribute);
}
}
尽管基础平台服务配置已经添加了缓存,但是我们需要每次都查询基础平台配置然后根据配置在每次查看详情的时候实时生成数据,并且在订单导出的时候也需要实时查询然后生成数据。尽管数据在内存中组织处理是非常快的。
职责分离、快照(视图)信息提前
对于订单信息,在不同的页面,其需要的数据模型是不一样的,通常我们只有一份原始数据(Raw Data),需要针对不同的场景展示不同的视图数据(view data),比如下面的例子:
门窗安装在下单与修改订单场景下,门尺寸属性需要展示各个规则的分项信息,而在详情场景下仅仅只需要展示一个字符串,而在列表页,压根就用不到门尺寸信息。
但是实际上在订单详情为了构建这个门尺寸信息,需要从基础平台获取服务配置然后根据配构建这个属性的值,而在订单列表为了拿商品类别信息(旧工程单),需要对取出每个商品然后通过每个商品的商品类目id查询到类目名称然后补全进去。
if(OrderServeVersion.isDynamicVersion(orderInfoResp.getOrderBase().getOrderServeVersion())){
List<OrderServiceAttributeInfo> orderServiceAttributeInfos = orderInfoResp.getOrderServiceAttributeInfos();
Set<String> childIds = orderServiceAttributeInfos.stream().filter(i -> i.getGoodsCategoryChildId() != null).map(i -> i.getGoodsCategoryChildId().toString()).collect(Collectors.toSet());
Set<String> categoryIds = orderServiceAttributeInfos.stream().filter(i -> i.getGoodsCategoryId() != null).map(i -> i.getGoodsCategoryId().toString()).collect(Collectors.toSet());
goodsCategoryIds.addAll(childIds);
goodsCategoryIds.addAll(categoryIds);
}else{
List<OrderGoods> orderGoods = orderInfoResp.getOrderGoods();
goodsCategoryIds.addAll(orderGoods.stream().map(vo -> vo.getGoodsCategoryId().toString()).collect(Collectors.toSet()));
goodsCategoryIds.addAll(orderGoods.stream().map(vo -> vo.getGoodsCategoryChildId().toString()).collect(Collectors.toSet()));
}
对于创建订单与修改订单这两个操作(可以认为是用户创建订单与用户修改订单两个命令或者说事件),在写入视角下是将订单原始数据入库,其内部可能会损失大量的配置信息与我们认为的可能冗余的、占用内存的信息,所以我们没有存储类目名称,服务类型名称等等信息;而在查询视角下在订单生成或者修改完成的那一刻,其订单信息就已经是固定的快照信息了,比如类目类别、商品名称、门尺寸、是否到货与预计到货时间等等等等。
所以我们可以将一些固定的展示数据提前到创建订单而非查询的时候处理,在查询的时候直接查询已经构建好的可以针对视图直接展示的数据。
常规情况下我们需要提前生成的都是一些固定的数据,比如客户地址信息,在create和modify的时候生成对应格式的文本就ok了。但是业务是变化的,世界上没有银弹,也没有一种设计可以适应所有变化。现有的逻辑里面每个类目的属性列表是不一样的,且有可能随时变化,常规情况下我们会设计一个存储所有字段的表格(列建模)用来存储每个属性值,但是其弊端是很多的,一是会存在大量null值属性,二是扩展性差。
orderId | orderGoodsId | attribute1 | attribute2 | attribute3 | attribute4 | attribute5 | attributeN |
---|---|---|---|---|---|---|---|
987981723 | 3183192 | 皮床 | 席梦思 | null | 不可水洗 | 带床头柜 数量 1个 | 属性N属性 |
为了支持可扩展性,进一步的方案是采用行建模,将每一个属性作为一个行处理,使得属性数据可以适应扩展
orderId | orderServiceId | attributeId | attributeName | attributeValueId | attributeValue |
---|---|---|---|---|---|
987981723 | 3183192 | 123133 | 规格 | 31898 | 12cm × 90cm |
987988718 | 3183893 | 123133 | 规格 | 31898 | 19.6 |
每个属性在各个类目的类型是不一样的,和上面一样同一个规格属性在A订单是String,在B订单是Number,为了支持排序等等类型相关的操作,需要细化一下,使得可以支持排序支持比较等等
EAV模型(entity-attribute-value)
- tb_attribute_value_number
orderId | orderServiceId | attributeId | attributeName | attributeValueId | attributeValue |
---|---|---|---|---|---|
987988718 | 3183893 | 123133 | 规格 | 31898 | 19.6 |
- tb_attribute_value_str
orderId | orderServiceId | attributeId | attributeName | attributeValueId | attributeValue |
---|---|---|---|---|---|
987981723 | 3183192 | 123133 | 规格 | 31898 | 12cm × 90cm |
如此设计,我们只需要在create和modify的时候查询属性配置就ok了,我们将展示数据在create与modify的时候就格式化好,存入数据库,是订单详情与导出等等对应的场景只需要获取结果就行了,而不需要查询配置之后实时格式化,这样可以在展示的时候节省大量的处理时间。
非关系型数据库
解决需求的路不止一条,没必要在sql上死磕。用mongodb,neo4j,clickhouse等等nosql数据库或许更加适合场景与业务。
推而广之
在查看多修改少的接口、在数据入库之后很少变更值上面我们可是使用这样的模式:在数据修改的时候发送Event,然后视图数据处理器重新生成视图数据,保证数据最终一致,保证查询效率。
在我们的业务场景中商品库列表也是可以应用这样的处理方式,师傅信息与总包信息也可以在各自领域上使用这样的处理方式。
参考
[1] 数据库 EAV 模型 - 掘金
李哥牛逼!
李哥我偶像