我们大部分人应该都遇到过,在购物或者在一些政府官方网站操作一些东西的时候,有弹出“系统错误,请稍后重试!”或者“当前访问人数过多,请稍后重试!”的文案,这明显是后台程序处理不过来,或者说系统的一种自我保护机制,放弃一部分流量来保证系统的稳定性。那么今天我们就聊一聊重试,以及基于真实业务场景的简单实现。市面上有很多重试框架,对于我们大部分应用都是使用spring作为基架,当然spring提供的spring-retry是首选,但是框架只是提供一个通用层面的抽象,对于一些特殊的业务场景有可能支撑不到,或者说为了实现一个简单的业务场景,又引入了一个框架和很多外部依赖,可能成本有点高,对于微服务横行的时代,可能也会有点重,接下来将一一进行分析和描述。
背景描述
对于会员发起的退款,会优先走自动化退款逻辑,如果不符合自动退逻辑,就流入人工队列处理,这样的话能够节省很大的人力投入和提高处理效率以及潜在的超时资损。对于自动退,是逆向交易发起退款后,消息进入我们这边走自动化退款流程,考虑到幂等性和潜在的消息重复性,以及我们服务分布式部署,要对退款编号加分布式锁来避免重复操作。那么对于锁失败(已经在处理中)的或者发生异常(外部依赖异常或者超时)的,但是又确实满足自动退条件,如果流入人工队列会增加人力成本和降低处理效率以及自动退占比,那这种情况应该如何处理呢?
问题分析
对于上述描述,分析可以知道,由于是自动退,不太适用使用人工重试解决问题,那么很明显我们考虑到“生产者-消费者”模型,自建一个队列,把加锁失败和异常的单子放入队列,然后由程序去消费重试,这里乍一听是这么回事,但是仔细考虑一下,这种简单的重试只能解决类似网络抖动类型的问题,还有几个问题需要考虑和解决:
1)外部依赖服务确实不可用,再重试也是失败
2)重试次数问题,不可能无边界地重试
3)重试时效问题,比如退款服务挂了,短时间重试解决不了问题,等退款服务重启后(10分钟)服务正常再次重试才有效果
解决方案
了解了需求,分析了存在的问题,那么我们就可以给出解决方案了;对于被锁定和异常的单子,我们需要放入队列,然后我们有有个线程专门消费这个队列的数据进行重试,如果消费失败,继续放入队列并记录重试次数,超过3次(如果重试3次都失败了,极有可能服务挂了,继续无休止的重试徒劳无益)就持久化到DB,然后开一个线程用来加载DB中的数据到一个新的队列,为什么不全部放到一个队列?因为加锁失败和异常时即时性比较强的,很有可能重试一次就成功了,如果放入一个队列,可能降低这一部分单子的处理效率,然后再开一个线程单独用于重试从DB加载的这部分数据,整理一下也即是:
1)成功就结束,失败就加入Queue1。
2)线程T1用于消费Queue1的数据,消费成功就从Queue1剥除,否则重试次数加1, 并重新放入Queue1;如果超过重试上限,就持久化到DB。
3)线程T2用于10分钟一次从DB加载数据并放入Queue2。
4)线程T3用于从Queue2消费退款单子,如果失败重新放入Queue2队列,并增加重试次数,如果这部分从DB加载的数据仍旧超过 重试次数上限,那么极有可能上游服务真的挂了,这种场景我们无能为力,我建议一种简单的做法是把DB中这部分数据标记为不可重试状态,并从Queue2队列移除,然后给出预警,等到上游服务恢复后我们再手工订正这部分数据,然后重新让T2加载这部分数据到Queue2(如果这部分数据量很大,考虑其他方案或者批量订正)。
代码实现
有了以上详细的分析和解决方案,接下来我们用最简单直接的方式,展示给研发人员一种更有体感的东西。在代码实现之前我们先看一张粗略的大图:
核心代码如下:
AutoRefundContext
/**
* 上下文
*/
public class AutoRefundContext {
private Long refundId;
private int retryTimes;
public AutoRefundContext(Long refundId, int retryTimes) {
this.refundId = refundId;
this.retryTimes = retryTimes;
}
public Long getRefundId() {
return refundId;
}
public void setRefundId(Long refundId) {
this.refundId = refundId;
}
public int getRetryTimes() {
return retryTimes;
}
public void setRetryTimes(int retryTimes) {
this.retryTimes = retryTimes;
}
}
AutoRefundManager
@Service
public class AutoRefundManager implements InitializingBean {
private static int MAX_RETRY_TIME = 3;
/**
* 线程池
*/
ScheduledExecutorService executorService = Executors.newScheduledThreadPool(3);
Map<String,BlockingDeque<AutoRefundContext>> map = new HashMap<>();
private static final String queue1 = "QUEUE1";
private static final String queue2 = "QUEUE2";
@PostConstruct
public void init () {
this.startRetrySchedule();
}
private void startRetrySchedule() {
long initDelay = 0;
long delay = 10;//定时10分钟
//加载DB数据到q2
executorService.scheduleWithFixedDelay(() -> {
System.out.println("加载DB数据到q2");
},initDelay,delay,TimeUnit.SECONDS);
executorService.scheduleWithFixedDelay(() -> {
System.out.println("从q1消费数据");
},initDelay,delay,TimeUnit.SECONDS);
executorService.scheduleWithFixedDelay(() -> {
System.out.println("从q2消费数据");
},initDelay,delay,TimeUnit.SECONDS);
}
/**
* 自动退
*/
public void autoRefund(AutoRefundContext autoRefundContext) {
this.autoRefund(autoRefundContext,map.get(queue1));
}
private void autoRefund(AutoRefundContext autoRefundContext,BlockingDeque queue) {
try {
this.doAutoRefund(autoRefundContext.getRefundId());
} catch (Exception e) {
e.printStackTrace();
System.out.println("发生异常");
this.enqueue(autoRefundContext,queue);
}
}
/**
* 入队
* @param autoRefundContext
* @param queue
*/
private void enqueue(AutoRefundContext autoRefundContext,BlockingDeque queue) {
BlockingDeque<AutoRefundContext> q1 = map.get(queue1);
BlockingDeque<AutoRefundContext> q2 = map.get(queue2);
int retryTiems = autoRefundContext.getRetryTimes();
if(retryTiems > MAX_RETRY_TIME) {
if(q1.equals(queue)) {//队列1
System.out.println("持久化到DB");
} else if(q2.equals(queue)) {//队列2
System.out.println("预警");
} else {
throw new RuntimeException("error occurs");
}
return;
}
retryTiems = 1;
try {
autoRefundContext.setRetryTimes(retryTiems);
queue.offer(autoRefundContext);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 自动退操作
* @param refundId
*/
private void doAutoRefund(Long refundId) {
Random random = new Random();
if(random.nextBoolean() == false) {
throw new RuntimeException("自动退款异常");
}
}
/**
* 初始化两个队列
* @throws Exception
*/
@Override
public void afterPropertiesSet() throws Exception {
map.put(queue1,new LinkedBlockingDeque(100));
map.put(queue2,new LinkedBlockingDeque(100));
}
}
启动测试
运行测试代码:
AbstractApplicationContext context = new ClassPathXmlApplicationContext("spring-root.xml");
context.start();
AutoRefundManager autoRefundManager = context.getBean(AutoRefundManager.class);
autoRefundManager.autoRefund(new AutoRefundContext(122L,0));
结果如下:
上述代码没有应用于真实场景,如果感兴趣可以自己根据真实场景编写并测试运行。
总结
通过上述一系列描述,我们根据真实的业务场景简单实现了重试逻辑,相对于spring-retry框架更轻量级,能够满足大部分应用场景。希望加深大家对于重试概念以及其必要性的理解。