在上一篇中阐述了基于zuul实现网关进行权限校验的技术点,本篇主要阐述下单服务的相关逻辑:
基于seata实现分布式订单服务
下单服务的业务逻辑包括两个部分: 扣减库存、生成订单,其中扣减库存调用库存服务的接口,生成订单调用订单服务的接口,这2个接口的调用需要保证原子性,即库存扣减了订单必须要生成;如果订单生成失败则扣减的库存需要重新加上。另外订单生成之后有一个待付款的时间,一般为30分钟,如果超时未支付,则关闭该订单且将库存重新加上,整体的业务时序图如下:
这里实现的难点在于如何保证2个接口调用的原子性,即扣减库存和创建订单必须同时成功同时失败,如果库存扣减失败则需要将创建的订单删除;如果创建订单失败则需要将扣减的库存恢复.
seata服务依赖eureka,需要单独部署启动,用户服务模块、库存服务模块、订单服务模块需要配置seata:
seata:
enabled: true
application-id: ${spring.application.name}
tx-service-group: seata-demo
service:
vgroup-mapping:
seata-demo: seata-server
config:
type: file
file:
name: file.conf
registry:
type: eureka
eureka:
application: eureka
service-url: http://localhost:8001/eureka
weight: 1
用户服务下单的业务逻辑如下(加上@GlobalTransactional注解开启seata事务):
@RequestMapping("/placeOrder")
@Transactional
@GlobalTransactional(timeoutMills = 60000 * 2)
@ResponseBody
public OrderResponse placeOrder(@RequestParam("commodityId") String commodityId,
@RequestParam("count") Integer count) throws BusinessException {
String orderId = OrderUtil.createOrderId(commodityId);
DeductWarehouseResponse deduct = warehouseService.deductWareHouse(commodityId, orderId, count);
if (null != deduct.getErrMsg()) {
throw new BusinessException(deduct.getErrMsg());
}
return orderService.createOrder(commodityId, orderId, count);
}
库存服务deductWarehouse接口的定义如下:
@TwoPhaseBusinessAction(name = "deductWarehouse", commitMethod = "commitDeduct", rollbackMethod = "cancelDeduct")
DeductWarehouseResponse deductWarehouse(
@BusinessActionContextParameter(paramName = "commodityId") String commodityId,
@BusinessActionContextParameter(paramName = "orderId") String orderId,
@BusinessActionContextParameter(paramName = "count") Integer count) throws BusinessException;
boolean commitDeduct(BusinessActionContext context);
boolean cancelDeduct(BusinessActionContext context);
在@TwoPhaseBusinessAction注解中定义commit方法和rollback方法,即原方法中做尝试扣减库存的操作:
deductWarehouse完成预扣减库存操作,通过以下脚本实现,retailer-warehouse是商品的库存信息表,扣减库存之后,将待扣减的数量存入到fronzen-warehouse表中暂时冻结
local record_id = KEYS[1]
local record_num = KEYS[2]
local order_id = KEYS[3]
if (redis.call('HEXISTS', 'retailer-warehouse', record_id) == 0) then
return '\"ERROR_WAREHOUSE_NOTEXIST\"'
end
local total_num = redis.call('HGET', 'retailer-warehouse', record_id)
if (tonumber(total_num) < tonumber(record_num)) then
return '\"ERROR_NOT_ENOUGH_WAREHOUSE\"'
end
redis.call('HSET', 'retailer-warehouse', record_id, total_num - record_num)
redis.call('HSET', 'fronzen-warehouse', order_id, record_num)
return '\"OK\"'
commitDeduct完成确认操作,清除fronzen-warehouse表中冻结的库存
cancelDeduct完成库存恢复的操作,通过以下lua脚本实现,根据订单id在fronzen-warehouse表中查找冻结的数量,将该数量重新加回到库存表中
local record_id = KEYS[1]
local order_id = KEYS[2]
if (redis.call('HEXISTS', 'retailer-warehouse', record_id) == 0) then
return '\"ERROR_WAREHOUSE_NOTEXIST\"'
end
local total_num = redis.call('HGET', 'retailer-warehouse', record_id)
if (redis.call('HEXISTS', 'fronzen-warehouse', order_id) == 0) then
return '\"ERROR_WAREHOUSE_NOTEXIST\"'
end
local fronzen_num = redis.call('HGET', 'fronzen-warehouse', order_id)
redis.call('HSET', 'retailer-warehouse', record_id, total_num + fronzen_num)
redis.call('HDEL', 'fronzen-warehouse', order_id)
return '\"OK\"'
订单服务createOrder接口定义如下:
@TwoPhaseBusinessAction(name = "createOrder", commitMethod = "commitOrder", rollbackMethod = "cancelOrder")
OrderMessage createOrder(@BusinessActionContextParameter(paramName = "orderId") String orderId,
@BusinessActionContextParameter(paramName = "userId") Long userId,
@BusinessActionContextParameter(paramName = "commodityId") String commodityId,
@BusinessActionContextParameter(paramName = "count") Integer count);
boolean commitOrder(BusinessActionContext context);
boolean cancelOrder(BusinessActionContext context);
createOrder方法完成订单的预创建,即此时创建的订单对外不可见:
commitOrder方法修改订单状态为待支付
cancelOrder方法删除订单
考虑到下单高并发情况下写数据库的压力,这里将待支付的订单都写入到redis中,等订单支付完成之后再同步写入到mysql中。
下单完成之后,一般会预留一个30分钟的支付窗口期,如果超时未支付则删除该订单同时恢复库存,这里利用redis的过期时间和过期监听机制来实现:
首先订单写入到redis中时,设置一个超时时间;当超时时间到时,基于监听机制来实现库存的恢复。
redis中添加如下配置开启监听机制
notify-keyspace-events Ex
监听机制基于KeyExpirationEventMessageListener类实现:
@Component
public class RedisKeyExpiredListener extends KeyExpirationEventMessageListener {
private static final Logger logger = LoggerFactory.getLogger(RedisKeyExpiredListener.class);
@Autowired
private WarehouseService warehouseService;
public RedisKeyExpiredListener(RedisMessageListenerContainer listenerContainer) {
super(listenerContainer);
}
@Override
public void onMessage(Message message, byte[] pattern) {
String expiredKey = message.toString();
if (!RedisUtil.isRedisKeyForOrderId(expiredKey)) {
return;
}
if (!RedisUtil.isRedisKeyForOrderId(expiredKey)) {
return;
}
String commodityId = RedisUtil.getCommodityIdFromRedisKey(expiredKey);
if (StringUtils.isNotEmpty(commodityId)) {
logger.info("订单{}超时未支付, 准备恢复商品{}的库存", expiredKey, commodityId);
warehouseService.restoreWarehouse(commodityId, expiredKey);
}
}
}
由于redis的键超时之后,通过监听器只能获取到键值,无法获取到其中的内容,而恢复商品的库存还需要商品id信息,为了简便处理,订单信息在写入到redis中键按照以下规则生成: 订单id-商品id,这样通过过期的键值便可以获取到商品id。
下面演示一下效果:
假设商品1001的库存为2000件,现在需要购买10件商品
rest接口返回订单信息,redis中查询到订单信息:
此时商品1001的库存为1990件
订单超过30分钟未支付,redis中订单信息删除,同时库存恢复为2000件
模拟异常情况,创建订单之后抛出异常:
seata回滚事务,库存未发生变化: