MyBatis 在 JDK8 中的性能問題

在一次客戶使用 TiDB 適配批處理場景中,處理數據的性能和預期相差很多。處理相同的數據量, Oracle 耗時 15 分鐘,TiDB 耗時 35 分鐘

遠程排查

登錄客戶集羣,通過 grafana 發現程序運行時,集羣的資源使用率非常低,判斷應用發來的壓力較小。將應用併發數從 40 提高到 100,資源使用率和 QPS 指標幾乎沒有變化。通過 connection count 監控看到,隨着併發數的增加,連接數也同樣增加了,確認併發數修改是生效的。但執行 show processlist 發現大部分連接是空閒狀態。簡單走查了下應用程序代碼,是 Spring batch + MyBatis 結構。因爲 Spring batch 設置併發的方式很傻瓜,考慮線程數的調整應該是生效且可以正常工作的。

雖然還沒有搞清資源使用率低的問題,但還是有其他收穫, ping 應用和 TiDB 集羣的網絡延遲,達到了 2~3 ms。爲了排除高網絡延遲的干擾,將應用部署到 TiDB 集羣內部運行,批處理耗時從 35 分鐘下降到 27 分鐘 ,但依然和 oracle 的耗時有較大差距。因爲數據庫本身沒有壓力,所以當時的情況調整數據庫參數也沒什麼意義。

因爲應用提高併發的效果不符合預期,所以考慮線程可能造成了阻塞,但也沒有證據,於是想了這樣的場景來簡單驗證到底是應用的問題還是數據庫的問題: 在 TiDB 集羣中創建兩個完全相同的 database, d1d2 ,使用兩個完全相同的批處理應用分別對 d1d2 中的數據進行處理,等同於雙倍壓力寫入 TiDB 集羣,預期結果是對於雙倍的數據量,同樣可以在 27 分鐘處理完,同時數據庫資源使用率應大於一個應用的。 測試結果符合預期,證明 應用提高併發沒有效果

客戶反饋給我們可能的幾種情況:

  1. 應用併發太高,CPU 繁忙導致應用性能瓶頸。
    應用服務器的 CPU 消耗只有 6%,不應該存在性能瓶頸。
  2. Spring batch 內部有一些元數據表,同時更新元數據表的同一條數據會造成阻塞。
    這種情況應該是阻塞在數據庫造成鎖等待或鎖超時,不應該阻塞在應用端。

客戶的解決思路:

  1. 多應用部署併發運行,性能隨應用部署數線性提升。
    不能解決單機應用性能瓶頸問題,對於業務高峯時的拓展也很不方便。
  2. 採用異步處理的方案,提高應用吞吐。
    目前是有些異步訪問數據庫的技術,但成熟度低,強烈不建議使用。

現場排查

現場使用 JDBC 編寫了一個 Demo 對問題集羣進行壓測,發現數據庫資源使用率隨着 demo 併發數提高而增長,證明提高併發數可以給數據庫製造更高的壓力,此時完全排除數據庫問題的可能。 通過 VisualVM 發現,應用程序的大量線程處於阻塞狀態,這種情況線程開的多其實也沒用上,實錘性能瓶頸來自應用。走查應用代碼,發現雖然有用到同步鎖等邏輯,但應該不會造成嚴重的線程阻塞。通過 dump 發現線程都阻塞在了 MyBatis 的堆棧中,

Locked ownable synchronizers:
    - <0x000000008523ca00> (a java.util.concurrent.ThreadPoolExecutor$worker)

"taskExecutorForHb-197" #342 prio=5 os_prio=0 tid=0x0007f5d7c72f800 nid=0x182c waiting for monitor entry [0x00007f5ccd6d4000]
    java.lang.thread.State: BLOCKED (on  object monitor)
    - waiting to lock <0x0000000080a772d8> (a java.util.concurrent.ConcurrentHashMap$Node)
    at org.apache.ibatis.reflection.DefaultReflection.DefaultReflectorFactory.fineForClass(DefaultReflectorFactory.java:1674)

是在 MyBatis 源碼中的這個位置, DefaultReflectorFactory.java

public Reflector findForClass(Class<?> type) {
    if (classCacheEnabled) {
        // synchronized (type) removed see issue #461
        return reflectorMap.computeIfAbsent(type, Reflector::new);
    } else {
        return new Reflector(type);
    }
}

這裏大致是這樣,MyBatis 在進行參數處理、結果映射等操作時,會涉及大量的反射操作。Java 中的反射雖然功能強大,但是代碼編寫起來比較複雜且容易出錯,爲了簡化反射操作的相關代碼, MyBatis 提供了專門的反射模塊,它對常見的反射操作做了進一步封裝,提供了更加簡潔方便的反射 API 。 DefaultReflectorFactory 提供的 findForClass() 會爲指定的 Class 創建 Reflector 對象,並將 Reflector 對象緩存到 reflectorMap 中,造成線程阻塞的就在對 reflectorMap 的操作上。 因爲 MyBatis 支持對 ReflectorFactory 自定義實現,所以當時的思路是繞過緩存的步驟,也就是將 classCacheEnabled 設爲 false ,走 return new Reflector(type) 的邏輯。但依然會在其他調用 ConcurrentHashmap.computeIfAbsent 的地方被阻塞。

到這看起來是一個通用問題,於是將注意力放到 concurrentHashmapcomputerIfAbsent上。 computerIfAbsent 是 JDK8 中爲 map 提供的新方法,

public V computeIfAbsent(K key, Function<? super K,? extends V> mappingFunction)

它首先判斷緩存 map 中是否存在指定 key 的值,如果不存在,會自動調用 mappingFunction (key) 計算 keyvalue ,然後將 key = value 放入到緩存 mapConcurrentHashMap 重寫了 computeIfAbsent 方法確保 mappingFunction 中的操作是線程安全的。

該方法在官方說明中一段:

The entire method invocation is performed atomically, so the function is applied at most once per key. Some attempted update operations on this map by other threads may be blocked while computation is in progress, so the computation should be short and simple, and must not attempt to update any other mappings of this map.

可以看到,爲了保證原子性,當對相同 key 進行修改時,可能造成線程阻塞。顯而易見這會造成比較嚴重的性能問題,在 Java 官方 Jira,也有用戶提到了同樣的問題。

JDK-8161372

總之,官方在 JDK9 中修復了這個問題。

驗證

將現場 JDK 版本升級到 9 ,應用在 500 併發,並排除網絡延遲干擾的情況下,批處理耗時 16 分鐘 。應用服務器 CPU 達到 85% 左右使用率,出現性能瓶頸。理論上,提高應用服務器配置、優化數據庫參數都可以進一步提升性能。

當時的結論

MyBatis 在緩存反射對象用到的 computerIfAbsent 方法在 JDK8 中性能不理想。需要升級 JDK9 及以上版本解決這個問題。對於 MyBatis 本身,沒有針對 JDK8 中的 computerIfAbsent 性能問題進行特殊處理,所以升級 MyBatis 版本也不能解決問題。

現在的結論

MyBatis 官方在收到我們的反饋後,非常效率地 fix 了這個問題。手動點贊。

可以看到 MyBatis 官方對 computerIfAbsent 進行了一層封裝,如果 value 已存在,則直接 return ,這樣操作相同 key 的線程阻塞問題就被繞過去了。MyBatis 會在 3.5.7 版本中合入這個 PR。

public class MapUtil {
  /**
   * A temporary workaround for Java 8 specific performance issue JDK-8161372 .<br>
   * This class should be removed once we drop Java 8 support.
   *
   * @see <a href="https://bugs.openjdk.java.net/browse/JDK-8161372">https://bugs.openjdk.java.net/browse/JDK-8161372</a>
   */
  public static <K, V> V computeIfAbsent(Map<K, V> map, K key, Function<K, V> mappingFunction) {
    V value = map.get(key);
    if (value != null) {
      return value;
    }
    return map.computeIfAbsent(key, mappingFunction::apply);
  }

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