很多人认为电商场景对于一致性要求非常高。一定有很多高深技术保证强一致性。这是一种错误的理解,电商很多业务场景只需要保证最终一致性,往往不需要强一致性。而保证最终一致性的最重要方法就是重试。(电商业务往往要求系统最终严格一致......即最终一致性 + 较强的可靠性)
业务系统中保证最终一致性的主要方式是不断地进行重试。大部分系统的问题都是由于超时导致的,而重试通常可以解决大多数问题。
下面列举 3 个场景------通过重试解决最终一致性。
- 会员订单履约:系统给用户发放优惠券失败时,可以通过重试(采用幂等性操作)来保证订单履约最终成功。(用户已经支付成功,难道 1 次履约超时,就给用户退钱吗?)
- 库存回滚: 当订单退款成功后,需要回滚库存。如果库存回滚超时,可以通过重试来保证最终回滚成功(幂等)。
- 库存回滚: 当订单扣减库存成功后,提单后续流程失败,需要回滚库存。在这种情况下,系统不需要等待库存回滚成功后再返回用户提单失败的结果,而是可以采用异步回滚库存的方式,如果超时,则不断进行重试。
- 会员订单退款:先将用户的虚拟资产退回,然后调用订单退款API出现超时。在这种情况下,不需要回滚虚拟资产的状态到正常状态,只需要重试订单退款API(一定要幂等)即可。
接下来我将详细介绍四种非常通用的重试方案。
1. Spring Retryable 框架
Spring 提供 Retryable 注解提供重试能力,通过 Retryable 注解声明的 Aop 切面,在切面抛出异常后触发重试策略,通过重试保证系统的最终一致性。
1.1 使用示例
java
Java
代码解读
复制代码
@Retryable(value = Exception.class, maxAttempts = 3, backoff = @Backoff(delay = 100, multiplier = 2))
public int handle(int userId, String orderId) {
//do something
return 1;//返回结果
}
@Recover
public int recover(Exception e) {
//最终失败的处理。一般是打印日志或者上报异常
return 1;
}
1.2 使用方式
Retryable 配置在失败后需要重试的方法上。
-
value: 指定捕获哪类异常进行重试
-
maxAttempts: 指定最大重试次数。
-
backoff: 指定重试策略,其中
- delay: 指定了延迟多久重试
- multiplier: 指定了两次重试的间隔时间的乘积。例如为 2,第一次重试前间隔 100毫秒,第二次重试间隔 200毫秒,第三次间隔 400毫秒。
1.3 最终失败的处理逻辑放在 Recover 中
通过 Retryable 重试超过最大重试次数后,如果依然失败,则会调用被 Recover 修饰的方法。Recover 中不再适合再次重试,因为已经重试多次后依然失败,再重试也没意义,Recover 中上报异常到其他系统或记录到日志中心。稍后需要人工介入,排查问题。
需要注意的是无需在 Retryable 注解中声明 Recover 方法。Recover 方法只需要和 Retryable 方法在同一个类中,方法声明为 public,并且还需要满足保持返回值一致、参数列表一致。
- 参数列表中第一个是需捕获的异常,后续的其他参数需要和重试函数的参数列表保持一致。(不一致Recover 注解会失效)
- Recover 方法和重试方法的返回值应该保持一致。
1.4配置方式
1.4.1 pom 依赖
xml
xml
代码解读
复制代码
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
1.4.2 开启重试
typescript
Java
代码解读
复制代码
@EnableRetry
@SpringBootApplication
public class AppStarter {
public static void main(String[] args) {
SpringApplication.run(AppStarter.class, args);
}
}
1.5 异步重试
如果需要异步重试,可以通过在重试方法中添加 @Async注解,Spring 会自动将目标方法封装为 Task 提交到线程池中执行。
2. 分布式重试组件
Spring 重试可以解决大部分的重试问题,然而遇到复杂的业务场景,例如订单履约系统的重试间隔非常长,例如重试间隔从 1 秒重试到 10 分钟,如此长期的间隔时间要求系统具备较高的可靠性。如果通过 Spring 单机式的重试,当遇到服务发布时,很容易阻断重试过程。为此需要想到其他办法保证长期重试的可靠性。
这需要重试组件需要具备延迟重试的能力,因为上一次重试失败后,需要间隔一段时间才能再次重试。
方法异常后,将重试的内容发送到一个分布式中间件中,该分布式中间件具备延迟触发能力,即指定延迟间隔后,通知业务系统重新处理该请求。 一般情况下可借助于具备延迟能力的 MQ,是非常可靠的办法。
重试消息中应该包括重试次数,每重试失败一次,发送重试消息时,应该递增重试次数。当消费重试消息时,超过最大重试次数应该终止重试,并且设置合理的策略记录重试消息,人工介入排查问题。
2.1 通用的 分布式 重试组件
分布式重试能力是一个通用性的需求,目前 Spring 只有单机版的 Retryable 重试能力。在这里 五阳开发了一个通用性的分布式重试工具,仅供各位参考。
Gitee开源地址:gitee.com/juejinwuyan...
GitHub开源地址: github.com/juejin-wuya...
2.2 定义注解
定义的注解内容和 Spring Retryable注解极其相似,提供了如下能力
- 可自定义要捕获的异常类型
- 可配置延迟重试、重试的间隔时间、重试间隔的递增倍数。
- 可配置 重试最终失败的 fallback 方法。
csharp
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retryable {
public int initialDelaySeconds() default 5;
public int maxDelaySeconds() default 10;
public int maxTimes() default 5;
public double multiplier() default 1.0;
public boolean throwException() default false;
public Class<Exception>[] include() default {Exception.class};
public Class<Exception>[] exclude() default {};
public boolean hasFallback() default false;
}
2.3 定义异常捕获的切面
java
@Pointcut("@annotation(Retryable) && execution(public * *(..))")
public void pointcut() {
}
2.4 切面的拦截逻辑
ini
@Around(value = "pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
//无参方法不处理
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
Object[] args = joinPoint.getArgs();
//获取注解
Retryable annotation = method.getAnnotation(Retryable.class);
if (annotation != null && args != null && args.length > 0) {
try {
Object response = joinPoint.proceed();
return response;
} catch (Exception e) {
Object param = joinPoint.getArgs()[0];
String beanName = toLowerCaseFirst(joinPoint.getSignature().getDeclaringType().getSimpleName());
String methoName = ((MethodSignature) joinPoint.getSignature()).getMethod().getName();
int retryTimes = RetryLocalContext.getRetryTimes(beanName, methoName);
retryTimes++;
if (retryTimes > annotation.maxTimes()) {
// TODO: 2024/12/31 fallback逻辑
CommonLog.error("超过最大重试次数 maxTimes:{}, methodName:{} ,params:{}",
annotation.maxTimes(), method.getName(), args);
throw e;
}
if (param instanceof RetryableContext) {
RetryableContext retryableContext = ((RetryableContext) param);
retryableContext.setRetryTimes(retryTimes);
}
int initialDelayTime = annotation.initialDelaySeconds();
double multiplier = annotation.multiplier();
long delayTime = initialDelayTime * (int) Math.pow(multiplier, retryTimes - 1);
delayTime = delayTime > annotation.maxDelaySeconds() ? annotation.maxDelaySeconds() : delayTime;
RetryMessage message = new RetryMessage();
message.setBeanName(beanName);
message.setBeanClassName(joinPoint.getSignature().getDeclaringType().getName());
message.setMethodName(methoName);
message.setThrowException(annotation.throwException());
List<String> argsList = Lists.newArrayList(args).stream().map(RetryUtil::toJson).collect(Collectors.toList());
List<String> argsClassList = Arrays.stream(((MethodSignature) joinPoint.getSignature()).getParameterTypes())
.map(Class::getName).collect(Collectors.toList());
message.setArgsList(argsList);
message.setArgsClassList(argsClassList);
message.setRetryTimes(retryTimes);
message.setExpectedTime(TimeUtil.now() + TimeUnit.SECONDS.toMillis(delayTime));
retryService.addRetryMessage(message);
if (annotation.throwException()) {
throw e;
} else {
CommonLog.error("执行重试方式异常 message:{}", message, e);
}
return Defaults.defaultValue(method.getReturnType());
}
} else {
return joinPoint.proceed();
}
}
public static String toLowerCaseFirst(String str) {
if (str == null || str.isEmpty()) {
return str;
}
char firstChar = str.charAt(0);
char updatedFirstChar = Character.toLowerCase(firstChar);
String remainder = str.substring(1);
return updatedFirstChar + remainder;
}
2.5 上报重试消息
重试消息关键内容如下
- Bean ClassName。要求Retry 方法所在类必须是 Spring 的 Bean,故障消息只记录了 Bean 的类型名,在消费逻辑中,系统需要通过 Bean ClassName 从 Spring容器中获取 Bean。
scss
String beanName = toLowerCaseFirst(joinPoint.getSignature().getDeclaringType().getSimpleName());
applicationContext.getBean(ClassUtils.getClass(className)); 根据 className 获取 Bean
- methodName。 重试方法名。
joinPoint.getSignature().getName()
- 参数类型的列表。获取各个参数类型的全类名。
scss
List<String> argsClassList = Arrays.stream(((MethodSignature) joinPoint.getSignature()).getParameterTypes())
.map(Class::getName).collect(Collectors.toList());
- 参数值列表。各个参数JSON 序列化
scss
List<String> argsList = Lists.newArrayList(args).stream().map(RetryUtil::toJson).collect(Collectors.toList());
需要注意的是,Java中List等集合类依赖泛型,在序列化时,泛型信息后被丢弃,反序列化阶段无法准确找到对应的类型,因此为了更好的支持序列化,需要再JSON 中添加类名,这在应对 List集合类型的参数时非常有效。如下代码是 Jackson的序列化工具。
示例
如下代码中展示了,序列化一个List集合,集合中元素是UserTagDO 类,一个普通JavaBean。
ini
public void testJson(){
List<UserTagDO> tags= Lists.newArrayList();
UserTagDO tag =new UserTagDO();
tag.setKey("hello");
tag.setCount(22L);
tags.add(tag);
System.out.println(RetryUtil.toJson(tags));
}
输出 json内容,包含了List类型和元素类型,因此生成的 json 会更长!
css
["java.util.ArrayList",[["com.memberclub.domain.context.usertag.UserTagDO",{"key":"hello","count":22}]]]
标准的 json格式如下。
css
[{"key":"hello","count":22}]
标准 json在反序列化阶段,如果未指定 集合中的元素,则无法准确反序列化。
如上图示例,标准Json反序列化需要指定 new TypeReference<List<UserTagDO>>() {}
,明确声明反序列化的集合类型和元素类型。
然而带元素类型的 json结构,只需要声明类型为 List即可,无需指定元素类型,因为 json 中有明确元素类型。
Jackson 序列化带元素类型的 工具类
java
static {
// 指定要序列化的域,field,get和set,以及修饰符范围,ANY是都有包括private和public
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
// 指定序列化输入的类型,类必须是非final修饰的,final修饰的类,比如String,Integer等会跑出异常
objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
objectMapper.configure(FAIL_ON_EMPTY_BEANS, false);
objectMapper.disable(FAIL_ON_EMPTY_BEANS);
objectMapper.configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, true);
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
}
public static String toJson(Object object) {
try {
return objectMapper.writeValueAsString(object);
} catch (JsonProcessingException e) {
CommonLog.error("转成 JSON异常 ", e);
throw new RuntimeException(e);
}
}
public static <T> T fromJson(String json, Class<T> tClass) {
try {
return objectMapper.readValue(json, tClass);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static <T> T fromJson(String json, TypeReference<T> reference) {
try {
return objectMapper.readValue(json, reference);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
2.6 重试消息的消费逻辑
重试消息发送到 MQ 后,当达到延迟时间后,消费者可以消费消息,实现重试。下面放一些关键代码(只包含了重试的关键代码,没有MQ 消费订阅代码)
- 获取 Bean
applicationContext.getBean(ClassUtils.getClass(className));
- 获取要重试的方法
ini
Class<?>[] parameterClazzs = null;
if (CollectionUtils.isNotEmpty(message.getParameterClazzs())) {
parameterTypes = new Class[message.getParameterClazzs().size()];
for(int i = 0; i < message.getParameterClazzs().size(); ++i) {
parameterClazzs[i] = ClassUtils.getClass(message.getParameterClazzs().get(i));
}
}
// 获取方法
Method method = ReflectionUtils.findMethod(bean, message.getMethodName(), parameterClazzs);
- 获取参数列表
ini
Object[] arguments = null;
if (CollectionUtils.isNotEmpty(message.argumentList())) {
arguments = new Object[message.getParameterClazzs().size()];
for(int i = 0; i < parameterClazzs.length; ++i) {
arguments[i] = JSONObject.parseObject(message.argumentList().get(i), parameterClazzs[i]);
}
}
- 调用 方法
ReflectionUtils.invokeMethod(method, bean, argsArray);
2.7 完整消息重试代码
java
public void consumeMessage(RetryMessage msg) {
try {
LOG.info("开始消费重试消息 currentTimes:{}, msg:{}", msg.getRetryTimes(), msg);
RetryLocalContext.setRetryTimes(msg.getBeanName(), msg.getMethodName(), msg.getRetryTimes());
Object bean = ApplicationContextUtils.getContext().getBean(msg.getBeanName());
Class<?> clazz = Class.forName(msg.getBeanClassName());
Class<?>[] argsClassArray = new Class[msg.getArgsClassList().size()];
for (int i = 0; i < msg.getArgsClassList().size(); i++) {
argsClassArray[i] = ClassUtils.getClass(msg.getArgsClassList().get(i));
}
Method m = ReflectionUtils.findMethod(clazz, msg.getMethodName(), argsClassArray);
Object[] argsArray = new Object[msg.getArgsClassList().size()];
for (int i = 0; i < msg.getArgsList().size(); i++) {
argsArray[i] = RetryUtil.fromJson(msg.getArgsList().get(i), argsClassArray[i]);
}
try {
ReflectionUtils.invokeMethod(m, bean, argsArray);
} catch (Exception e) {
if (!msg.isThrowException()) {
LOG.error("调用重试方法异常:{}", argsArray, e);
}
}
} catch (Exception e) {
LOG.error("调用重试消息方法,重试组件异常:{}", msg, e);
} finally {
RetryLocalContext.clear();
}
}
以上是 分布式重试组件的关键代码。此外还有很多代码细节需要解决,例如 消息重试的次数如何保存?我的做法是在线程本地变量中保存重试次数,然后我遇到了一个问题。
当代码调用栈上有两个方法均声明了 重试注解,上层方法抛出异常重试成功后,会执行下层方法,此时线程本地变量还未清除,因此下层方法若抛出异常,则会重复使用线程本地变量~ 从而产生错误的执行结果~
此外开源中间件中有哪些 延迟队列的实现方案呢,这些内容都在 Memberclub中~
想要查看完整的代码实现方案,可以参考我的开源项目 memberclub,分布式重试组件代码地址
MemberClub是托管在Gitee/Github 平台的开源项目,提供了付费会员的交易解决方案,在各类购买场景下提供各类会员形态的履约及售后结算能力,一个非常好的项目,适合用来学习电商业务中台系统的建设,具体介绍可参见
Gitee开源地址:gitee.com/juejinwuyan...
GitHub开源地址: github.com/juejin-wuya...
在这个项目中你可以学习到 SpringBoot 集成 以下框架或组件。
- Mybatis-plus
- Sharding-sphere 多数据源分库分表
- Redis/redisson
- Apollo
- Springcloud(feign/enreka)
- RabbitMQ
- H2 内存数据库
- Swagger
- Lombok+MapStruct
同时你也可以学习到以下组件的实现原理
- 流程引擎
- 扩展点引擎
- 分布式重试组件
- 通用日志组件
- 商品库存
- 分布式锁组件
- Redis Lua的使用
- Spring 上下文工具类