Vert.x — 從緩存Future說起

引子

這是一個晴朗的午後,我沐浴着窗口灑落的陽光,懶洋洋地敲着代碼,喝着並不存在的咖啡,聽着窗外並不存在的熙熙攘攘。這是一個疫情中的午後,深圳二月份的天氣算是比較厚道,一件薄外套已經讓我微微出汗。我,又遇到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多次使用的問題,並不是我第一次遇到,只不過上次主要是靠前輩解決,以至於過了太久我都沒什麼印象,說來確實不太應該。

但好在問題解決了,同時也提出了一種通用的調用方法,在實用層面上,是值得參考的。

參考文檔

  1. Vert.x官方手冊 - 核心部分

  2. exlipse-vertx/vert.x - issue#1920

  3. Reactivex Java - Single

  4. Reactivex Java - Subject

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章