跳到主要內容
程式語言

Java LDAPS 的 connection pool:以為開了,其實沒開

在 JNDI 環境裡加上 com.sun.jndi.ldap.connect.pool=true,看起來 pool 就開好了。但只要連的是 LDAPS,這行設定完全不會生效,因為 pooling 的預設條件裡沒有 SSL。從驗證方法講起,給出一份完整的設定,並說明 pool key、閒置連線與歸還時機。

壓測跑到一半抓了封包,發現每一個 LDAP search() 前面都躺著一次完整的 TLS handshake。

連線從頭到尾沒有被重用過。而 JNDI 的環境設定裡,這一行明明白白寫著:

env.put("com.sun.jndi.ldap.connect.pool", "true");

這不是 bug,屬性名稱也沒拼錯。真相是 JNDI 的 LDAP provider 預設就不 pool SSL 連線,而且它不會發出任何警告。

這篇從「怎麼確認到底有沒有 pool 到」開始,再談為什麼 LDAPS 被排除在外、pool 是用什麼當 key、完整的設定該怎麼寫,以及幾個設對了參數還是會出事的地方。


先證明它沒有 pool 到

在猜原因之前,先拿到證據。JNDI 內建了 pool 的追蹤輸出:

-Dcom.sun.jndi.ldap.connect.pool.debug=fine

加上去重跑一次,System.err 會出現這種訊息:

Create com.sun.jndi.ldap.LdapClient@5d173[ldap.example.com:636]
Use com.sun.jndi.ldap.LdapClient@5d173
Release com.sun.jndi.ldap.LdapClient@5d173

判準很簡單:

  • 只有 Create,沒有 Use——每次都在開新連線,pool 完全沒作用
  • Create 一次,後面一連串 Use / Release——這才是有在重用

fine 只追蹤連線的建立與移除,想看更細節(包含連線是被哪個 identity 認領的)就換成 all

先跑這一步。沒有這個輸出,接下來所有設定都只是在盲改。


為什麼 LDAPS 被排除

JNDI 的 connection pool 有一組 pooling criteria(啟用條件)。一個連線必須同時符合「認證方式」與「傳輸協定」兩個白名單,才有資格進 pool。

而傳輸協定的白名單預設值是:

com.sun.jndi.ldap.connect.pool.protocol = plain

plain 就是明文的 LDAP。SSL 不在裡面。

所以當你走 LDAPS(ldaps://java.naming.security.protocol=ssl)時,provider 檢查完條件直接判定「這條連線不符合 pooling 條件」,然後靜靜繞過整個 pool。com.sun.jndi.ldap.connect.pool=true 這行設定它讀了,只是沒用上。

解法是把 ssl 加進白名單:

com.sun.jndi.ldap.connect.pool.protocol = plain ssl

這是空白分隔的清單,不是單選。只寫 ssl 會反過來把明文連線踢出 pool;除非整個系統確定只走 LDAPS,否則 plain ssl 比較安全。

認證方式的白名單同理,預設是 none simple。用 simple bind 不必改;走 SASL DIGEST-MD5 就得自己加上去:

com.sun.jndi.ldap.connect.pool.authentication = none simple DIGEST-MD5

這兩個屬性怎麼在程式裡設定,下面「完整的設定」那節會一次給完整版本——它們不能放進 env,這是另一個坑。


Pool 是用什麼當 key

改完 protocol,Use 出現了,但重用率還是很低——這通常是第二個坑。

JNDI 的 pool 不是一座共用的連線池。它是按 connection identity(連線身分) 分組的一堆小池子,每一組 identity 各自維護自己的連線,彼此不共用。

identity 由什麼組成,取決於認證方式:

認證方式identity 包含的內容
noneconnection controls、provider URL 的 host / port / scheme、java.naming.security.protocoljava.naming.ldap.version
simple上述全部,再加上 java.naming.security.principaljava.naming.security.credentials
DIGEST-MD5上述全部,再加上 authorizationId、realm、qop、strength 等一串 SASL 屬性

注意 simple 那一列:principal 和 credentials 都是 key 的一部分

這件事的後果很直接。如果你的 LDAP 用途是「驗證使用者密碼」,也就是拿使用者自己的 DN 和密碼去 bind,那每一個使用者都會產生一組獨立的 identity,也就是各自一個池子。一萬個使用者登入就是一萬組池子,每組裡面躺著一條沒人會再用到的連線。這種情境下 pool 不但沒幫助,還在浪費檔案描述子。

正確的做法是把兩種用途拆開。查詢走一個固定的 service account bind,全程共用同一組 identity,pool 的效益都在這裡;驗證密碼則用單獨的 context,用完就關,不進 pool。

還有一個和 identity 相關的限制:如果你自訂了 socket factory(java.naming.ldap.factory.socket,設定客製 truststore 時很常見),那個 factory class 必須實作 Comparator 介面,provider 才知道怎麼比對兩個 factory 是否等價。沒實作的話,連線一律不進 pool。

這是很多人「protocol 也設了、還是沒 pool 到」的真正原因。自訂 SSLSocketFactory 加上沒實作 Comparator,等於白做。


完整的設定

設定分成兩半,而且分界不能搞錯:pool.* 那七個是 system property,放進 env 完全沒作用;其他的才是 env property,跟著 context 走。

設定項放哪裡預設值
com.sun.jndi.ldap.connect.pool.protocolSystemplain(LDAPS 必須改)
com.sun.jndi.ldap.connect.pool.authenticationSystemnone simple
com.sun.jndi.ldap.connect.pool.initsizeSystem1
com.sun.jndi.ldap.connect.pool.prefsizeSystem
com.sun.jndi.ldap.connect.pool.maxsizeSystem無上限
com.sun.jndi.ldap.connect.pool.timeoutSystem
com.sun.jndi.ldap.connect.pool.debugSystem
com.sun.jndi.ldap.connect.poolenv無(要自己開)
com.sun.jndi.ldap.connect.timeoutenv無(無限等待)
com.sun.jndi.ldap.read.timeoutenv無(無限等待)

system property 不代表只能用 -DSystem.setProperty() 寫進的是同一份 JVM properties table,效果完全一樣,所以整份設定可以留在程式碼裡:

public final class LdapConfig {

    private static final String LDAP_URL = "ldaps://ldap.example.com:636";
    private static final String BIND_DN   = "cn=svc-search,ou=svc,dc=example,dc=com";
    private static final String BIND_PW   = System.getenv("LDAP_BIND_PASSWORD");

    /**
     * pool.* 是 JVM 全域設定,且只會被讀取一次。
     * 必須在第一個 LDAP context 建立之前呼叫,否則靜默失效。
     */
    public static void initPool() {
        // 沒有這行,LDAPS 連線永遠不會進 pool
        System.setProperty("com.sun.jndi.ldap.connect.pool.protocol", "plain ssl");
        System.setProperty("com.sun.jndi.ldap.connect.pool.authentication", "none simple");

        System.setProperty("com.sun.jndi.ldap.connect.pool.initsize", "2");
        System.setProperty("com.sun.jndi.ldap.connect.pool.prefsize", "5");
        System.setProperty("com.sun.jndi.ldap.connect.pool.maxsize", "20");

        // 閒置 5 分鐘就淘汰,要小於防火牆/LB 的 idle timeout
        System.setProperty("com.sun.jndi.ldap.connect.pool.timeout", "300000");
    }

    /** 查詢用的 context:固定 service account,全程共用同一組 pool identity。 */
    public static LdapContext newSearchContext() throws NamingException {
        Hashtable<String, String> env = new Hashtable<>();
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
        env.put(Context.PROVIDER_URL, LDAP_URL);
        env.put(Context.SECURITY_AUTHENTICATION, "simple");
        env.put(Context.SECURITY_PRINCIPAL, BIND_DN);
        env.put(Context.SECURITY_CREDENTIALS, BIND_PW);

        // 開 pool。少了這行,上面那些 pool.* 一個都不會被用到
        env.put("com.sun.jndi.ldap.connect.pool", "true");

        // maxsize 滿載時的等待上限,沒設就是無限期 block
        env.put("com.sun.jndi.ldap.connect.timeout", "5000");
        // 單次 LDAP 操作的讀取逾時,跟 pool 無關但同樣別漏
        env.put("com.sun.jndi.ldap.read.timeout", "10000");

        return new InitialLdapContext(env, null);
    }
}

呼叫端只要記得 initPool() 要夠早:

public static void main(String[] args) throws Exception {
    LdapConfig.initPool();   // 必須在任何 LDAP 連線之前
    Application.start();
}

「夠早」是這段設定唯一的陷阱,而且它值得單獨講。

那七個屬性由 com.sun.jndi.ldap.LdapPoolManager 在自己的 static 初始化區塊裡一次讀完,用的是 Integer.getInteger()System.getProperty()。static 區塊在整個 JVM 生命週期只跑一次,觸發時機是這個類別第一次被載入,也就是第一個要求 pooling 的 LDAP 連線建立的那一刻。

換句話說,System.setProperty() 只要晚於那一刻,設了也是白設。不會拋例外,不會有警告,System.getProperty() 讀回來甚至還是你設的新值——但 pool manager 手上握的是舊的。這種失效方式和文章開頭那個坑一模一樣:安靜。

所以有兩種情況還是該回去用 -D

跑在 Tomcat、WildFly 這類容器裡,應用程式的初始化順序不完全由你控制,而且同一個 JVM 可能有別的元件更早碰到 LDAP。這時候寫在 setenv.shJAVA_OPTS 最保險。

另一種是 pool.debug。除錯輸出要涵蓋 pool 建立的那一瞬間才有意義,用 -D 從 JVM 啟動就打開,不會有先後順序的問題。

最後補一句 maxsize 的行為,因為它的失敗方式最難查。預設無上限看起來寬鬆,但一旦設了值而池子滿了,取連線的請求不會拋例外,而是 block 住,一直等到有連線變成 idle 為止。com.sun.jndi.ldap.connect.timeout 平常的意思是「TCP 連線建立的逾時」,在 pooling 情境下它兼任「等待可用連線的最長時間」。設了 maxsize 卻漏掉它,等於在系統裡埋了一個會讓執行緒無聲卡死的地方。


連線是靠 close() 回去的

pool 沒有背景執行緒在偵測「這個 context 還有沒有人在用」。它判斷的依據只有一件事:你有沒有呼叫 Context.close()

沒 close 的 context,provider 就當你還在用,那條連線永遠不會回到 pool。跑一陣子後你會看到連線數只增不減,最後撞上 maxsize 卡死,或是把 LDAP server 的連線上限吃光。

LdapContext ctx = null;
try {
    ctx = new InitialLdapContext(env, null);
    NamingEnumeration<SearchResult> results = ctx.search(base, filter, sc);
    // ... 處理結果
    results.close();          // NamingEnumeration 也要關
} finally {
    if (ctx != null) {
        ctx.close();          // 這行才是把連線還回 pool 的動作
    }
}

close() 在這裡的語意有點特別。它不是關掉 TCP 連線,而是把底層連線標記成 idle,交還給 pool。下次同一個 identity 來要連線時,拿到的就是它。

NamingEnumeration 也記得關。它沒關會讓 context 上還掛著未讀完的結果。


閒置連線會被中間裝置砍掉

設定都對了、重用率也上來了,然後半夜開始零星冒出 CommunicationException

原因通常在 Java 之外。LDAP server 前面通常擺著防火牆或負載平衡器,它們有自己的 idle timeout,一般是 5 到 15 分鐘。閒置超過那個時間,中間裝置直接把 TCP 連線砍掉,而且不見得會送 RST。

pool 這邊完全不知情。它手上那條連線在它看來還是好的,於是照樣發給下一個請求,而請求打到的是一條已經死掉的連線。

pool.timeout 就是為這件事存在的,也就是上面設定裡的那個 300000。意思是「閒置超過 5 分鐘的連線,從 pool 裡移除」。原則是讓它明顯小於中間裝置的 idle timeout,由 pool 主動淘汰,而不是被動撿到屍體。

預設值是「沒有 timeout」,閒置連線會一直留著直到被 GC 回收。只要路徑上有防火牆或 LB,這個預設值就是錯的。

即使設了 pool.timeout,你還是應該在應用層對 CommunicationException 做一次重試。時間差永遠存在,連線有可能在你拿到它之後、送出請求之前被砍。


什麼時候不要用 pool

Oracle 的文件對此講得很明確:如果你打算對某個 context 呼叫 StartTLS,或者事後會改動它的安全性屬性(例如用 reconnect() 換 principal、切換 java.naming.security.protocol),就不該對這個 context 開 pooling。

理由不難想像。pool 的整個前提是「同一個 identity 的連線可以互換」。而 StartTLS 是在一條既有連線上就地升級成加密,這條連線的狀態已經和它的 identity 不一致了。它被歸還、又被另一個請求拿去用的時候,會發生什麼事沒有定義。

換句話說,StartTLS 和 connection pooling 二選一。要 pool 就走 LDAPS(ldaps://,636 port),把加密放在連線建立的那一刻,而不是建立之後。

還有一個小限制:開了 com.sun.jndi.ldap.trace.ber 做 BER 封包追蹤的連線,一律不會被 pool。除錯時看到重用率掉到零,先確認是不是這個。


小結

JNDI 的 LDAP pooling 有個共同特徵:它失敗的時候是安靜的

protocol 沒加 ssl,pool 不生效,沒有警告。System.setProperty() 呼叫得太晚,設定被忽略,沒有警告。socket factory 沒實作 Comparator,連線不進 pool,沒有警告。忘了 close(),連線不歸還,沒有警告。用使用者帳號 bind,池子碎成一萬份,還是沒有警告。它從頭到尾都會正常回應你的查詢,只是每一次都在重新做一次 TLS 交握。

所以這件事不能靠讀設定檔確認,只能靠證據。pool.debug=fine 打開,看 CreateUse 的比例。這個比例是唯一誠實的指標,其他都是你以為。