壓測跑到一半抓了封包,發現每一個 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 包含的內容 |
|---|---|
none | connection controls、provider URL 的 host / port / scheme、java.naming.security.protocol、java.naming.ldap.version |
simple | 上述全部,再加上 java.naming.security.principal 與 java.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.protocol | System | plain(LDAPS 必須改) |
com.sun.jndi.ldap.connect.pool.authentication | System | none simple |
com.sun.jndi.ldap.connect.pool.initsize | System | 1 |
com.sun.jndi.ldap.connect.pool.prefsize | System | 無 |
com.sun.jndi.ldap.connect.pool.maxsize | System | 無上限 |
com.sun.jndi.ldap.connect.pool.timeout | System | 無 |
com.sun.jndi.ldap.connect.pool.debug | System | 無 |
com.sun.jndi.ldap.connect.pool | env | 無(要自己開) |
com.sun.jndi.ldap.connect.timeout | env | 無(無限等待) |
com.sun.jndi.ldap.read.timeout | env | 無(無限等待) |
system property 不代表只能用 -D。System.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.sh 或 JAVA_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 打開,看 Create 和 Use 的比例。這個比例是唯一誠實的指標,其他都是你以為。