引子
這是一個晴朗的午後,我沐浴着窗口灑落的陽光,懶洋洋地敲着代碼,喝着並不存在的咖啡,聽着窗外並不存在的熙熙攘攘。這是一個疫情中的午後,深圳二月份的天氣算是比較厚道,一件薄外套已經讓我微微出汗。我,又遇到bug了,調了一上午的bug,自己寫的bug,查了半天的bug,甚至讓我分不清此刻的汗水是氣溫還是bug導致的。
隨着時間的流逝,bug終究會解決,我們要做的,就是靜靜地等着。不知不覺已經到了晚上,果然,bug解決了。往往一個bug的持續時間決定了它是否值得被記錄。解決完這個bug時,我驚喜地意識到又可以水一篇博文了。呵呵。
在Vert.x中,Future是遵循Promise/Future原則的接口,是一個佔位符。按官方說的,它代表了一個可能已經發生、或可能還沒發生的動作的結果,即一個異步結果。讀取其結果的方法,通常是設置一個回調方法,但是注意,一個future只能設置一個回調方法,即一個Handler,或者更具體地說,如果設置多個Handler,則只有最後一個Handler有效。
val future = Promise.promise<String>().future();
future.setHandler { ar ->
if(ar.failed()){
// 處理失敗的情況
} else {
// 處理成功的情況
}
}
事故回放
使用場景
有一緩存需求:將一段讀取數據庫的代碼的結果緩存起來,緩存有效期十分鐘,過期後自動刷新,要求整個過程全異步。
爲了後期能夠隨時更換緩存實現,於是抽象出如下緩存接口
interface Cache<K, V> {
// get方法,第一個參數爲key,第二個參數爲緩存過期時獲取新的緩存的方法
fun get(key: K, mappingFunction: () -> Future<V>): Future<V>
// 刪除緩存值
fun invalidate(key: K)
}
並使用Caffeine實現上述接口
class CaffeineProxy<K, V> : Cache<K, V> {
private val cache: Cache<K, Future<V>> = Caffeine.newBuilder().build()
override fun get(key: K, mappingFunction: () -> Future<V>): Future<V> =
cache.get(key) { mappingFunction.invoke() }
override fun invalidate(key: K) = cache.invalidate(key!!)
}
在協程上下文中使用,如下
class ServiceImpl {
private val locationCache = LocationCache()
// 由於只緩存一段代碼的執行結果,因此只有一個key,用一個內部類將緩存包裹起來
inner class LocationCache {
// 創建緩存實例
private val innerCache = CaffeineProxy<String, List<Location>>()
// 取值方法,取的結果是Future實例
override suspend fun getCache(): Future<List<JsonObject>> = innerCache.get("UniqueCache") {
val promise = Promise.promise<List<JsonObject>>()
adminDao.getAvailableLocations(promise)
promise.future()
}
}
// 在方法1中使用該緩存
suspend fun fun1() {
val result = locationCache.getCache().await()
. . . 後續操作 . . .
}
// 在方法2中使用該緩存
suspend fun fun2() {
val result = locationCache.getCache().await()
. . . 後續操作 . . .
}
}
問題復現
併發較高的場景下,會出現部分方法調用無響應的情況。上述緩存方法放在Web代碼中,對應的就是多個會用到緩存的請求同時發起時,部分請求會永遠無響應,或者觸發系統的超時機制。
原因分析
上述緩存有一個大前提,即將Future緩存起來,並在之後的流通中反覆使用同一個被緩存的Future。前文中,我們在協程上下文中調用了Future的await()方法,該方法定義如下。
/**
* Awaits the completion of a future without blocking the event loop.
*/
suspend fun <T> Future<T>.await(): T = when {
succeeded() -> result()
failed() -> throw cause()
else -> suspendCancellableCoroutine { cont: CancellableContinuation<T> ->
setHandler { asyncResult ->
if (asyncResult.succeeded()) cont.resume(asyncResult.result() as T)
else cont.resumeWithException(asyncResult.cause())
}
}
}
可以看到,其邏輯是:如果成功則返回結果;如果失敗則拋出異常;Future未完成則調用setHandler()設置回調方法。
再來看看Future的實現FutureImpl的定義
class FutureImpl<T> implements Promise<T>, Future<T> {
private boolean failed;
private boolean succeeded;
private Handler<AsyncResult<T>> handler;
private T result;
private Throwable throwable;
. . . . . .
/**
* Set a handler for the result. It will get called when it's complete
*/
public Future<T> setHandler(Handler<AsyncResult<T>> handler) {
boolean callHandler;
synchronized (this) {
callHandler = isComplete();
if (!callHandler) {
this.handler = handler;
}
}
if (callHandler) {
handler.handle(this);
}
return this;
}
. . . . . .
}
可以看到,setHandler()會將傳入的handler直接覆蓋掉現有的handler屬性。
於是可以分析出正常情景和異常情景如下
-
異常情景
緩存已過期,此時方法1調用獲取緩存方法,拿到Future,該Future未完成,於是通過await()方法調用setHandler()設置了一個回調方法;在緩存中的Future尚未完成前,方法2也調用獲取緩存方法,得到同一個Future實例,同樣,由於它未完成,於是通過await()方法再次調用setHandler()設置了新的回調方法。
這樣,方法2設置的回調方法覆蓋了方法1設置的回調,當Future完成時,方法2的回調方法將得到通知,使得方法2能夠正常繼續執行;方法1的回調則會永遠等待被回調,直到超時。
-
正常情景
緩存有效,且Future已完成,根據await()方法的定義:先同步地讀取Future的結果,在本場景中,一直能夠讀取到Future結果,而不會進入到setHandler(),這樣無論併發多高,都能夠正確返回結果。
如果緩存時間設置很長,Future從創建到完成的時間很短,在單元測試階段甚至SIT都很難發現。很容易造成線上偶現的bug,並且相當地隱晦,可以說是非常難以發現了。
然後呢?
到這裏,原因找到了。但是仔細想想,從語義上,Future代表一個異步執行的結果,常規的使用方法是setHandler()設置回調方法,那一個結果被多處使用似乎是很自然的需求,Vert.x設置這樣一個限制,是不是有些反直覺,或者反人類呢?
或許我們可以在這個issue找到些許解釋。簡而言之,Future就這樣了,如果需要一次生成多次使用,請考慮其它庫來實現這樣的效果,如RxJava。或者等Vert.x4中將會有類似功能的實現。
我想吐槽的點在於,目前Vert.x的Future實現不修改沒有問題,但做一些針對上述問題的防護措施也是可以的,可是並沒有。
正確的做法
看來,Future是不能使用了,那我們應該使用什麼呢?官方推薦使用RxJava,查看文檔後,發現有如下幾個佔位符可選。
- Single — Single.cache()生成的Single,可被多次訂閱
- 訂閱Single的BehaviorSubject —— 可訂閱和被訂閱,用它訂閱Single,我們再訂閱它。特性上,它返回其訂閱的最近的消息,並永遠不會主動結束,即可被多次訂閱
二者綜合選其一,肯定是能直接使用Single就直接使用Single就好呀。
於是將我們的緩存實現修改成緩存Single的形式
/**
* Cache接口
*/
interface Cache<K, V> {
// get方法,第一個參數爲key,第二個參數爲緩存過期時獲取新的緩存的方法
fun get(key: K, mappingFunction: () -> Single<V>): Single<V>
. . . . . .
}
/**
* Cache實現
*/
class CaffeineProxy<K, V> : Cache<K, V> {
private val cache: Cache<K, Single<V>> = Caffeine.newBuilder().build()
override fun get(key: K, mappingFunction: () -> Single<V>): Single<V> =
cache.get(key) { mappingFunction.invoke() }
. . . . . .
}
使用時獲取Single並訂閱即可。
class ServiceImpl {
. . . . . .
suspend fun fun1() {
locationCache.getCache().subscribe({ result ->
// 對成功的處理
},{ cause ->
// 對失敗的處理
})
}
. . . . . .
}
合適的做法
使用Single雖然實現了異步緩存的功能,但在協程中使用RxJava卻是浪費了協程的特性。那如果我們在調用時想要使用Future呢。爲此可以將緩存包裝一下,同時暴露協程、Future、Single三種API風格的接口,任君選擇。
接口如下
interface CacheProxy<K, V> {
// Single API
fun singleGet(key: K, mappingFunction: () -> Single<V>): Single<V>
// Future API
fun futureGet(key: K, mappingFunction: () -> Future<V>): Future<V>
// 協程API
suspend fun coroutineGet(key: K, mappingFunction: suspend () -> V): V
}
實現類可以這麼做。
class CaffeineProxy<K, V>(val vertx: Vertx) : CacheProxy<K, V> {
private val cache: Cache<K, Single<V>> = Caffeine.newBuilder().build()
// 接收返回Single的方法,返回一個Single
override fun singleGet(key: K, mappingFunction: () -> Single<V>): Single<V> {
return cache.get(key!!) { mappingFunction.invoke() }!!
}
// 接收返回Future的方法,返回一個Future
override fun futureGet(key: K, mappingFunction: () -> Future<V>): Future<V> {
val promise = Promise.promise<V>()
cache.get(key) { SingleHelper.toSingle<V> { mappingFunction.invoke().setHandler(it) }.cache() }!!.subscribe({
promise.complete(it)
}, {
promise.fail(it)
})
return promise.future()
}
// 接收suspend方法,返回一個值
override suspend fun coroutineGet(key: K, mappingFunction: suspend () -> V) = withContext(vertx.dispatcher()) {
val promise = Promise.promise<V>()
cache.get(key) {
Single.create<V> {
launch {
try {
it.onSuccess(mappingFunction.invoke())
} catch (e: Exception) {
it.onError(e)
}
}
}.cache()
}!!.subscribe({
promise.complete(it)
}, {
promise.fail(it)
})
promise.future().await()
}!!
}
Rx風格的接口不必多說,直來直去;
Future風格的接口,使用了Vert.x提供的API在Single和Future之間進行了切換;協程會稍微麻煩點:
協程內部可以看做是同步執行的,在獲取Single時,爲了全異步特性,我們需要異步執行,因此使用launch啓動新協程是個好辦法。
至此,我們的緩存接口能夠適用於目前所有三種異步API,使用時無縫銜接。
總結
Future多次使用的問題,並不是我第一次遇到,只不過上次主要是靠前輩解決,以至於過了太久我都沒什麼印象,說來確實不太應該。
但好在問題解決了,同時也提出了一種通用的調用方法,在實用層面上,是值得參考的。