Spring Retry配合Kotlin协程async调用时重试失效问题
问题描述
注意:下述test函数是通过launch协程发起调用的。
如下代码可正常触发Spring Retry重试逻辑:
@Retryable(value=[RetryException::class]) suspend fun test() { throw RetryException("blah") }
但在函数中新增async异步调用逻辑后,重试机制就不再生效,对应实现代码如下:
@Retryable(value=[RetryException::class]) suspend fun test() { val deferred = supervisorScope { async { library.method() } } deferred.await() throw RetryException("blah") }
根本原因
核心问题是Spring Retry的AOP拦截逻辑和Kotlin协程的执行、异常传播机制不兼容:
- Spring Retry基于动态代理实现,拦截逻辑仅作用于代理方法的初始调用栈,只有方法在初始调用栈帧中直接抛出匹配配置规则的异常时,才会触发重试。
- 当suspend函数内存在挂起点(示例代码中的
supervisorScope、deferred.await()都属于挂起函数)时,函数执行到挂起点会暂停,后续逻辑会在协程调度器分配的线程中通过Continuation恢复执行,这部分恢复执行的代码已经脱离了Spring AOP代理的原始调用栈,后续抛出的RetryException无法被Spring Retry的拦截器捕获,自然不会触发重试。 - 能正常触发重试的示例代码中,
test()方法进入后直接抛出异常,没有触发任何挂起操作,异常始终在代理的原始调用栈中抛出,因此可以被正常识别。 - 额外影响点:
supervisorScope的异常隔离逻辑会包装子协程抛出的异常,就算异常在挂起前抛出,包装后的异常类型也会和配置的RetryException不匹配,同样会导致重试失效。
解决方案
按落地成本从低到高排序:
- 方案1:调整代码执行顺序
如果异常判断逻辑不需要依赖library.method()的执行结果,直接把抛出RetryException的逻辑移到所有挂起点之前,保证异常在代理原始调用栈中抛出即可。 - 方案2:升级Spring Retry版本+手动解包异常
将Spring Retry升级到1.3.4及以上版本(该版本新增了对Kotlin挂起函数的初步支持),同时在代码中手动剥离协程包装的异常外壳,抛出原始匹配的异常:// 异常解包扩展函数 fun Throwable.unwrap(): Throwable { var current = this // 递归剥离协程包装的异常类型 while (current is CancellationException || current is CompletionException) { current = current.cause ?: break } return current } @Retryable(value=[RetryException::class]) suspend fun test() { val result = try { supervisorScope { val deferred = async { library.method() } deferred.await() } } catch (e: Exception) { throw e.unwrap() } // 挂起逻辑执行完后,抛出的异常也要保证是未被包装的原始类型 if (!result.isSuccess) { throw RetryException("blah") } } - 方案3:替换为协程原生重试组件(推荐)
直接使用专门为Kotlin协程设计的重试组件,这类组件不依赖AOP代理,原生支持协程上下文传播、挂起函数执行逻辑,不会出现调用栈脱离、异常包装导致的重试失效问题,是协程场景下的最优解。
内容的提问来源于stack exchange,提问作者Nayan Seth
相关产品推荐
相关产品推荐

