------ 本文复盘的是我在 Linux-Kernel-Email-List-Analyzer 中写的
SingleImapConnectionImpl。 它只有 220 行,却是整个项目里我改得最多、想得最久的一个类。 全文所有代码都来自真实的 git 提交记录,包括那些我后来才发现是错的写法。
引子:需要解决什么问题?
我正在做一个 Linux 内核邮件列表分析器。需要一条流水线能从 Gmail 中拉取内核补丁邮件丢进 RabbitMQ,再交给 DeepSeek 分析归档,最后持久化到本地或者 OSS 服务。
拉邮件走的是 IMAP 协议,用的是 jakarta.mail。核心对象是 Store,简要流程如下:
java
Session session = Session.getInstance(props);
Store store = session.getStore(); // 拿到一个 IMAPSSLStore
store.connect(username, password); // TCP + TLS + LOGIN
Folder inbox = store.getFolder("INBOX");
一上手就有两个绕不开的事实:
1. 建立连接很贵
Store 的 connect() 方法要走完 TCP 三次握手、TLS 握手、IMAP LOGIN 认证。对着 Gmail 服务器、还挂着代理,这一套下来几百毫秒到几秒不等。每小时拉一次邮件,如果每次都重新连,纯属浪费。
2. Store 是有状态的,且不是线程安全的
jakarta.mail 的 Store 底层维护着一条 IMAP 协议连接,IMAP 是带标签的请求-响应协议 ------客户端发 a001 SELECT INBOX,服务端回 a001 OK。两个线程同时往同一条连接上写命令,标签和响应就会串台,行为完全未定义。
于是需求就清楚了:全局维护一个长连接,让所有收件操作串行地、独占地使用它,并且在连接断掉时能自动恢复。
听起来不难。我第一版就是这么写的,然后连着踩了四个坑。
顺带一提 IMAP 协议的文档可以去访问 rfc-editor.org/rfc/rfc3501... 获取,后面我也会引用这个文档的内容。
第一版:getStore() ------ 一个把锁还给调用方的设计
2026-06-19,第一版落地(commit d009f5c)。接口长这样:
java
/** 单邮件服务 IMAP 连接实例管理接口。*/
public interface SingleImapConnection
{
/**
* 获取邮件服务连接,
* Store 具体的实现类是 {@link IMAPSSLStore}。
* 目前的负载下单连接实足矣,
* 所有的邮件收取操作抢一把 {@link ReentrantLock} 可重入锁,
* 锁等待则返回 null, 上游需要注意判断。
*/
Store getStore();
}
实现:
java
@Override
public Store getStore()
{
final long waitTimeout
= this.properties.getStoreLockWaitTimeout().toSeconds();
boolean isLocked = false;
try
{
isLocked = this.lock.tryLock(waitTimeout, TimeUnit.SECONDS);
if (!isLocked)
{
log.warn("Failed to acquire lock within {} seconds.", waitTimeout);
return null;
}
if (!this.isConnected())
{
if (this.connectCounts.get() == 0) {
log.info("Initialization IMAP connection...");
}
else {
log.warn("Connection disconnected, restart...");
}
this.connect();
// 重连后再次校验,如果还是不行,这次调用就算失败
if (!this.isConnected())
{
log.error("Reconnection attempt failed.");
return null;
}
}
return this.store;
}
catch (InterruptedException exception)
{
log.warn("", exception);
Thread.currentThread().interrupt();
return null;
}
finally
{
if (isLocked) {
lock.unlock();
}
}
}
现在回头看,这段代码有一个致命的问题,而且它藏在最不起眼的地方。
坑 1:锁的作用域和 Store 的使用期完全对不上
注意 finally 块:方法返回之前,锁就已经解开了。
也就是说,getStore() 保护的只是"拿到 Store 引用"这个瞬间。而调用方拿到引用之后,真正的危险操作------getFolder("INBOX")、inbox.open()、search()、fetch()、setFlags()------全部发生在锁外面。
java
Store store = singleImapConnection.getStore(); // ← 锁在这行结束时就没了
Folder inbox = store.getFolder("INBOX"); // ← 裸奔
inbox.open(Folder.READ_WRITE); // ← 裸奔
Message[] messages = inbox.search(...); // ← 裸奔
这把锁只保护了取引用,没保护用引用 。两个线程完全可以先后拿到同一个 Store,然后并发地往同一条 IMAP 连接上灌命令。我加锁的初衷(串行化 Store 的使用)一点都没实现。
这是个典型的错误:用锁保护了对象的获取,却没保护对象的使用。 对于无状态对象这没问题,但 Store 恰恰是有状态的。
顺带一提接口注释里那句"锁等待则返回 null,上游需要注意判断"------这是把复杂度甩给调用方 。一旦返回 null 语义存在,每个调用点都得写 if (store == null) return;,漏写一个就是 NPE。而且 null 到底代表"锁超时"还是"连接失败"?调用方无从区分,也就无从决定该重试还是该放弃。
坑 2:@Retryable 加在了内部私有调用上
java
@Retryable(
retryFor = MessagingException.class,
maxAttempts = 2,
backoff = @Backoff(multiplier = 2.0)
)
public void connect()
{
// ...
catch (MessagingException exception)
{
log.error("Connecting to email service failed...", exception);
// re-throw 异常触发重试
throw new RuntimeException(exception);
}
}
这段代码有三处都是错的,而且互相掩盖:
第一,Spring AOP 的自调用陷阱。 @Retryable 靠动态代理实现:Spring 生成一个代理对象包住 SingleImapConnectionImpl,外部调用先进代理、再进真实方法。但 connect() 是被同类内部 的 getStore() 通过 this.connect() 调用的------this 指向的是原始对象 ,不是代理对象。代理被完全绕过,@Retryable 一次都不会生效。
这是 Spring AOP 最经典的坑,@Transactional、@Async、@Cacheable 全都有同样的问题。写的时候我以为加了注解就有重试了,实际上那个注解从头到尾就是个装饰。
第二,retryFor 和实际抛出的异常类型对不上。 注解写的是 retryFor = MessagingException.class,但 catch 块里抛的是 new RuntimeException(exception)------一个 RuntimeException,不是 MessagingException。就算代理生效了,异常类型也匹配不上,照样不会重试。
第三,异常类型被偷偷改写了。 MessagingException 是受检异常,把它包进 RuntimeException 之后,上层的 catch (MessagingException) 再也接不住它了。异常的语义在这里丢失了。
三个 bug 叠在一起,效果就是:这个重试机制从来没跑起来过,而且没有任何日志会告诉你它没跑起来。
坑 3:connect() 失败会留下一个损坏的 store
java
this.store = this.session.getStore(); // ① 先赋值给字段
store.connect(username, password); // ② 再连接,这行可能抛异常
顺序错了。如果 ② 抛异常,this.store 已经在 ① 被覆盖成了一个未连接的 Store。
原来那个可能还活着的连接引用,就这么被弄丢了------既没关闭(连接泄漏),也不能用了。这是典型的赋值时机错误破坏了对象不变量:字段在中间态被外部可见了。
第二版:把"操作"传进来,而不是把"连接"传出去
2026-06-25,commit afddf42,标题是《修复:Store 实例错误的所有权管理、错误的 AOP 注解使用与数据竞争问题》。
三个词------所有权、AOP、数据竞争------正好对应上面三个坑。
核心思路:控制反转
第一版的根本矛盾是:Store 的所有权跑到了调用方手里,而锁留在了管理器手里。 所有权和保护措施分了家,怎么补都是补丁。
解决办法是反过来:别把 Store 交出去,让调用方把"要做的事"交进来。
java
/**
* 由于 {@link jakarta.mail.Store} 自身有状态的设计,
* 导致并发的操作这个实例会导致数据竞争,所以最佳时间就是让每一个收件操作
* 独占 Store 的实例,这种串行的设计避免了数据竞争也符合 jakarta.mail 的设计初衷,
* 本函数式接口就是 "独占 Store 实例进行收件处理操作" 的操作的抽象。
*/
@FunctionalInterface
public interface StoreOperator<T>
{
/** 独占 Store 实例进行收件处理操作。*/
T execute(final Store store) throws MessagingException;
}
接口随之简化成一个方法:
java
public interface SingleImapConnection
{
/** 在锁和自动重连保护下执行任意 Store 操作。*/
<T> T execute(final StoreOperator<T> operation) throws MessagingException;
}
这就是 Execute Around 设计模式 。Store 从头到尾没有离开过管理器,调用方只能在管理器划定的一段临界区里"借用"它。
它带来的好处是结构性的:
- 锁的作用域天然覆盖整个业务操作,不再是 "取引用的一瞬间";
Store不会不小心泄漏到锁外,因为调用方拿不到长期引用(注意这个措辞------下一节会拆穿它);- 重连逻辑可以复用 ------管理器持有
operation,失败时可以重新执行它; null返回值消失了 ,失败一律抛MessagingException。
注意 StoreOperator<T> 的 execute 声明了 throws MessagingException。这是刻意的:如果用 JDK 自带的 Function<Store, T>,业务代码里每个受检异常都得手动包一层 RuntimeException------又回到坑 2 的老路。自定义函数式接口最大的价值,往往就是让它能抛受检异常。
插曲:这个"所有权模型"其实一捅就破
写完第二版我挺得意,直到某天想到一个问题:StoreOperator 凭什么能保证 Store 不逃逸?
答案有点扎心------凭自觉。
StoreOperator 本质上是我手写的一套所有权模型 (ownership model):Store 的所有权始终归 SingleImapConnectionImpl,调用方只在 execute() 的作用域内获得一个临时借用(borrow),出了这个作用域借用就失效。
这套说辞在 Rust 里是编译器强制的。Rust 的借用检查器会紧盯着生命周期,一旦你想把借用的引用存到比它活得更久的地方,直接编译不过:
rust
// Rust:这段代码编译器会直接拒绝
fn execute<F>(&mut self, f: F) where F: FnOnce(&Store) {
f(&self.store);
}
let mut leaked: Option<&Store> = None;
conn.execute(|store| { leaked = Some(store); }); // ❌ error[E0521]:
// borrowed data escapes outside of closure
而 Java 没有借用检查器 。Store 就是一个普普通通的对象引用,lambda 参数也是普普通通的局部变量。所谓"所有权",从 JVM 的角度看根本不存在------那只是我写在注释里的一个承诺。
于是对象所有权逃逸易如反掌:
java
/*
* 恶意代码演示:
* 用一个单元素数组(或 AtomicReference)把借用的引用 "偷渡" 到 execute() 之外
*/
final Store[] smuggled = new Store[1];
singleImapConnection.execute(store -> {
smuggled[0] = store; // 把借用存进外部容器
return null;
}); // <- execute() 返回,锁在 finally 里已经释放
// 此刻我们持有一个"越狱"成功的 Store 引用,
// 锁没了,重连保护没了,但这个引用照样能用
Folder inbox = smuggled[0].getFolder("INBOX");
inbox.open(Folder.READ_WRITE);
inbox.getMessages(); // 完全裸奔,我们又回到了最开始的处境
这段代码能编译、能运行、还不报错。
为什么必须绕一个数组?因为 Java 的 lambda 只能捕获 effectively final 的局部变量,直接写
smuggled = store;会被编译器拒绝。但这个限制拦的是变量的重新赋值 ,不是对象内部状态的修改 。数组、AtomicReference、任何可变容器、乃至外部类的一个字段,都是现成的越狱通道。这也顺带说明了一件常被误解的事:effectively final 从来就不是为了封装或安全设计的,它只是为了避免 lambda 捕获语义的歧义(值捕获 vs 引用捕获)。不能把它当安全边界,那属于误用。
更 "优雅" 一点的逃逸方式还有几种(恼):
java
// ① 直接把 Store 当返回值送出来 ------ 连数组都省了
Store escaped = singleImapConnection.execute(store -> store);
// 泛型 T 无任何限制,被推导成 Store 签名完全合法
// ② 把借用塞进任何生命周期更长的东西里
singleImapConnection.execute(store -> {
this.cachedStore = store; // 存成字段
someExecutor.submit(() -> store.getFolder("INBOX")); // 送进别的线程,
return null; // 甚至能在 execute() 返回后才执行
});
// ③ 从借用对象顺藤摸瓜 ------ 最隐蔽的一种
Folder leakedFolder = singleImapConnection.execute(store -> store.getFolder("INBOX"));
// Folder 内部持有对 Store 的引用,等于间接把连接带了出去
// 当然这个不完全算恶意代码,更像是一种调用方的失误
第 ① 种最要命:<T> T execute(StoreOperator<T>) 里的 T 是无界泛型 ,把 T 推导成 Store 本身完全合法,一行代码就把整个模型拆了。
第 ③ 种最隐蔽,而且最可能被无意中写出来 ------它甚至不像逃逸,看起来就是个正常的"返回查询结果"。但 Folder 内部持有 Store 的引用,等于把连接的一部分带出了临界区,之后对这个 Folder 的任何操作都是在锁外操作同一条 IMAP 连接。
那要不要防?
理论上有一些办法:
- 包一层受限视图 :不直接传
Store,而是传一个RestrictedStoreView,只暴露白名单方法,并在execute()返回后把内部引用置空("吊销"借用)。之后再调用就抛IllegalStateException。这是 JDK 自己的做法------java.lang.foreign.Arena的MemorySegment就是靠这套时序校验实现"作用域外访问直接抛异常"的。 - 收紧返回值类型 :把
<T> T换成受限的类型上界,堵死第 ① 种。 - 返回值做运行时检查 :
if (result instanceof Store || result instanceof Folder) throw ...。
但我最后一个都没做,理由很实在:
1. 威胁模型不对 这个类的调用方是我自己 ,是同一个代码库里的另外两个组件(KernelEmailPusherImpl 和 ImapConnectionKeepAlive)。防御性设计要防的是失误 ,不是防蓄意破坏 ------一个想拿到 Store 的人,反射一行 getDeclaredField("store").setAccessible(true) 就绕过了你所有的包装,防不胜防。
2. 成本收益不划算 受限视图意味着 Store 每加一个方法我就要同步一次代理类,还要处理 Folder 这类"二级逃逸"(Folder 也得包一层,Message 也得包......一路包到底)。为一个内部组件付这个代价不值。
3. 真正的防线在别处 与其在类型系统上死磕,不如把约束写清楚、让人一眼看懂:
java
/**
* 由于 Store 自身有状态的设计,
* 导致并发的操作这个实例会导致数据竞争,所以最佳时间就是让每一个收件操作
* 独占 Store 的实例......
*/
这段注释(原文就在仓库里)+ 唯一的 execute() 入口 + code review,对一个自用组件来说已经够了。
不过这次思考让我对"设计模式"这件事的理解变了:
Execute Around 的价值不在于它"防住了"什么,而在于它让正确的写法变成了阻力最小的路径。
对比一下:第一版的 getStore() 里,滥用是默认行为 ------你拿到 Store 之后爱怎么用怎么用,写出正确代码反而需要额外的自觉(比如自己去加锁)。第二版的 execute() 里,正确是默认行为 ------你顺着 lambda 往下写,自然就在锁的保护之下;想逃逸反而得特意绕一圈 去写数组、写字段、写 store -> store。
而"特意绕一圈"这件事有个额外的好处:它在 code review 里非常显眼 。看到 Store[] smuggled = new Store[1] 或者 execute(store -> store),任何 reviewer 都会停下来问一句 "你要干嘛?"。而 第一版 那种在锁外调 inbox.open() 的代码,看起来和正常代码一模一样,根本审不出来。
这就是把不安全的操作变得显眼 (make illegal states loud),一种在缺乏语言级保障时非常实用的替代策略。Java 里大量 API 都是这个思路:
Iterator的 fail-fast、Stream的"只能消费一次"、Arena的作用域校验------它们都拦不住一个铁了心要绕过去的人,但都能让无意的误用立刻暴露。
所以这套"所有权模型"确实是纸糊的。但纸糊的护栏也是护栏------它拦不住翻墙的人,却能让所有正常走路的人不掉下去。对于一个内部组件,这就够了。
调用方的变化
改造前,KernelEmailPusherImpl 里是这样的:
java
final Store store = this.singleImapConnection.getStore();
Folder inbox = null;
try {
// ... 一大坨收件逻辑
}
catch (MessagingException exception) {
log.error("Get INBOX folder failed.", exception); // 吞掉异常
}
finally {
// 手动关 inbox
}
改造后:
java
/** 推送操作的核心逻辑。*/
private StoreOperator<Void> doPush()
{
return (store) -> {
Folder inbox = null;
try {
// ... 同样的收件逻辑,但异常直接往外抛
}
finally {
// 关 inbox
}
return null;
};
}
@Override
public void push()
{
if (!this.pushing.compareAndSet(false, true)) {
log.warn("Previous push task is still running, skip this round.");
return;
}
try {
this.singleImapConnection.execute(this.doPush());
}
catch (MessagingException exception) {
log.error("Push lkml email to message queue failed.", exception);
}
finally {
this.pushing.set(false);
}
}
关键差别:业务 lambda 不再自己吞 MessagingException ,而是让它穿透到 execute()。因为只有 execute() 才知道这个异常是不是"连接掉了"、要不要重连重试。在错误的层次捕获异常,等于把重试的可能性提前掐死。
顺便这次还删掉了一行 bug:
java
if (unreadCount == 0)
{
- inbox.close(false); // ← 删掉
return EMPTY_MESSAGE_ARRAY;
}
这里在提前 return 前手动关了 inbox,但 finally 块里还会再关一次------重复关闭 。资源的关闭责任应该只由 finally 一处承担。
重连:先判断"值不值得重试"
java
/**
* {@link MessagingException} 是一个非常宽泛地异常,
* 我们需要进一步判断是否值得重试。
*/
private boolean isRetryException(MessagingException exception)
{
return !isConnected() ||
exception instanceof FolderClosedException ||
exception instanceof StoreClosedException;
}
MessagingException 太宽泛了:认证失败是它,配额超限是它,解析邮件报错也是它。无差别重试等于把一个必然失败的操作再做一遍------认证失败重试一万次也还是失败,纯粹浪费时间还刷屏日志。
这里只认三种情况:Store 已经不是连接状态、FolderClosedException、StoreClosedException。这三种都明确指向"底层连接没了",重连之后确实有救。
配合上重连流程:
java
try {
this.ensureConnected();
return operation.execute(this.store);
}
catch (MessagingException exception)
{
if (this.isRetryException(exception))
{
log.warn("Connection lost during operation, attempting reconnect.", exception);
this.store = null; // 丢弃坏连接
this.ensureConnected(); // 重建
return operation.execute(this.store); // 重试一次
}
throw exception;
}
只重试一次,不做指数退避循环。理由:这个方法整个跑在锁里,重试循环会把锁一直攥着,后面排队的线程全被拖死。连接类故障"重连一次还不行"通常意味着服务端或网络出了更大的问题,交给上层的定时任务下个周期再来,比在锁里死磕更健康。
顺手修掉的 connect()
java
private void connect() throws MessagingException
{
final String username = this.properties.getUsername();
final Store newStore = this.session.getStore(); // ① 局部变量
newStore.connect(
username,
this.applicationApiKeysRepository.findByAppName(username)
);
this.store = newStore; // ② 连接成功后才发布到字段
}
对比第一版,有以下两点改动:
- 先用局部变量
newStore,连接成功后才赋给this.store。 这样connect()失败时this.store保持原状,不会被污染成一个半死不活的对象。这就是安全发布:字段要么是旧的有效值,要么是新的有效值,绝不会是中间态。 @Retryable直接删掉,方法降为private。 既然自调用让它形同虚设,与其留着误导人,不如换成execute()里那套看得见摸得着的手写重试。
这里有个小教训:当一个声明式注解和一段手写逻辑做同一件事时,优先信手写的。 注解生效与否依赖框架的代理机制,条件很隐蔽(自调用、
final方法、private方法、Bean 是否被代理等等);手写逻辑写在哪里就是哪里,debug 时能一行行跟下去。
三、第三版:连接会 "自然死亡" ------ keep-alive 的引入
改完第二版,跑了一周,出现了新现象:服务空转几个小时后,第一次拉邮件必定失败一次,然后重试成功。
原因不难猜:定时任务 每小时才拉一次邮件,中间近一小时连接完全空闲。而 IMAP 服务端(尤其是 Gmail)和中间的 NAT 网关、负载均衡器都会回收空闲连接。RFC 3501 允许服务端在 30 分钟不活动后单方面断开,协议原文如下:
txt
Crispin Standards Track [Page 21]
RFC 3501 IMAPv4 March 2003
5.4. Autologout Timer
If a server has an inactivity autologout timer, the duration of that
timer MUST be at least 30 minutes. The receipt of ANY command from
the client during that interval SHOULD suffice to reset the
autologout timer.
自动注销计时器
如果服务器设有因不活动而自动注销的计时器,则该计时器的时长必须至少为 30 分钟。
在此期间,收到来自客户端的任何命令都应足以重置该自动注销计时器。
更麻烦的是这种断开经常是静默 的:TCP 连接在客户端这边看起来还"活着",store.isConnected() 甚至可能返回 true(它只查本地状态标志,不发探测包),直到你真的发一条命令过去才会发现对端早就走了。
第二版的重连机制确实能兜住------但代价是每次任务的第一个操作都要交一次"学费":先失败、抛异常、判断、重连、重试。这个过程发生在锁里,还伴随一次完整的 TLS 握手。
解法:主动发 NOOP
2026-07-03,commit fd9cf53,加入 ImapConnectionKeepAlive 组件。思路很朴素:与其等它死了再救,不如定期戳一下让它别死。
IMAP 协议里正好有个为此而生的命令:NOOP。它什么都不做,唯一作用就是产生一次通信、重置服务端的空闲计时器。
在 IMPA 协议中,对 NOOP 命令的定义如下:
txt
6.1.2. NOOP Command
Arguments: none
Responses: no specific responses for this command (but see below)
Result: OK - noop completed
BAD - command unknown or arguments invalid
The NOOP command always succeeds. It does nothing.
Since any command can return a status update as untagged data, the
NOOP command can be used as a periodic poll for new messages or
message status updates during a period of inactivity (this is the
preferred method to do this). The NOOP command can also be used
to reset any inactivity autologout timer on the server.
NOOP 命令总是执行成功,但它不执行任何实际操作。
由于任何命令都可能以 "未标记数据"(untagged data)的形式返回状态更新,
因此 NOOP 命令可用于在连接空闲期间定期轮询新邮件或邮件状态更新(这是实现此目的的首选方法)。
此外,NOOP 命令还可用于重置服务器上的空闲自动注销计时器。
Example: C: a002 NOOP
S: a002 OK NOOP completed
. . .
C: a047 NOOP
S: * 22 EXPUNGE
S: * 23 EXISTS
S: * 3 RECENT
S: * 14 FETCH (FLAGS (\Seen \Deleted))
S: a047 OK NOOP completed
我在服务中这样实现的:
java
/** 往邮箱服务发送 NOOP 命令并处理响应操作的实现。*/
private static Object noop(Protocol protocol) throws ProtocolException
{
final Response[] responses = protocol.command("NOOP", null);
/*
* 通知 jakarta.mail 内部注册的的 ResponseHandler,比如:
* 监听新邮件到达(EXISTS、RECENT 等 untagged 响应)
* 处理 EXPUNGE(邮件被删除)
* IDLE 模式下的消息推送
* 其他内部状态更新
*/
protocol.notifyResponseHandlers(responses);
// 处理末尾的结果响应(成功、失败、结束等)
protocol.handleResult(responses[responses.length - 1]);
return Arrays.stream(responses)
.map(Response::toString)
.collect(Collectors.joining(" | "));
}
这里有个细节值得展开:protocol.command() 拿到响应之后,必须 自己调 notifyResponseHandlers() 和 handleResult()。
因为 IMAP 服务端在回 NOOP 时会顺带推送 未标签响应 (untagged response),比如 * 4 EXISTS(收件箱现在有 4 封)、* 1 EXPUNGE(第 1 封被删了)。这些是服务端主动告知的状态变化。如果不调 notifyResponseHandlers(),jakarta.mail 内部缓存的 folder 状态就和服务端对不上账 了------后面 getMessageCount() 之类的调用可能返回过期数据。
handleResult() 则负责检查最后那条带标签的响应,如果是 NO 或 BAD 就抛 ProtocolException。不调它,命令失败了你也不知道。
用底层 API 就得承担底层 API 的义务,这是绕不过去的。
关键:保活操作必须走同一把锁
java
/** 执行一次连接保活操作。*/
private void performKeepAlive()
{
if (!this.running) { return; }
try {
this.singleImapConnection
.execute(ImapConnectionKeepAliveUtils::keepAlive);
}
catch (Exception exception) {
log.warn("IMAP keep-alive execute failed, will retry in the next cycle.", exception);
}
}
保活组件没有 自己去碰 Store,而是老老实实调 singleImapConnection.execute(...)
这是第二版重构的红利:保活本质上也是"独占 Store 做一件事",天然就是一个 StoreOperator。它自动获得了互斥和重连保护,完全不可能和正在进行的收件操作撞车。
如果还是第一版的 getStore() 设计,这里就会是灾难------保活线程和收件线程各自拿到同一个 Store,一个在发 NOOP,一个在发 FETCH,两条命令的响应在同一条 TCP 流上交错,协议直接崩掉。
一个好的抽象,会让后来新增的需求"恰好"就能塞进去。 这是我重构完第二版之后最有成就感的一刻。
另外注意调度用的是 scheduleWithFixedDelay() 而不是 scheduleAtFixedRate():
java
/*
* 使用 scheduleWithFixedDelay() 而非 scheduleAtFixedRate()
* 避免在获取连接耗时较长情况下的任务队列堆积导致的高频执行。
*/
scheduleAtFixedRate 按固定频率 触发。假如保活任务因为抢不到锁(收件任务正占着)阻塞了 5 分钟,而保活间隔是 1 分钟,那这 5 分钟里积压的任务会在锁一释放时连续触发 5 次 。scheduleWithFixedDelay 从上次执行结束开始计时,天然不会堆积。
经验:只要任务执行时间可能超过调度间隔,就该用
scheduleWithFixedDelay。scheduleAtFixedRate只适合执行时间稳定远小于间隔、且要求严格节拍的场景。
顺带的职责分离
2026-07-08,commit 821c66f,把 noop() / keepAlive() 这些纯协议操作 从 ImapConnectionKeepAlive 里挪进了工具类 ImapConnectionKeepAliveUtils(-68 行 / +77 行)。
分完之后职责很清爽:
ImapConnectionKeepAliveUtils:做什么------怎么发 NOOP,怎么处理响应(无状态、静态方法、可单独测试)ImapConnectionKeepAlive:什么时候做------调度、生命周期、开关标志SingleImapConnectionImpl:在什么保护下做------锁、连接、重连
于是 performKeepAlive 里那行方法引用 ImapConnectionKeepAliveUtils::keepAlive 读起来就非常顺------它的签名 (Store) -> Object throws MessagingException 正好就是 StoreOperator<Object>。
第四版:关不掉的服务------中断、锁与优雅停机
2026-07-18,commit 5953032,标题很直白:《问题:IMAP 连接实例 keep-alive 定期保活组件无法优雅关闭》。
现象:docker compose stop 之后,容器要等到超时才被强杀。JVM 收到 SIGTERM 后迟迟退不出去。
死锁链条
复盘下来是这样一条链:
- 收件任务拿着锁,正卡在一个网络 IO 上(比如
fetch()一批大邮件,或者代理不通在等超时); - 保活任务到点触发,在
tryLock(waitSeconds)上阻塞排队; - 这时
SIGTERM来了,Spring 开始销毁 Bean,调用@PreDestroy的stop(); stop()里执行executor.shutdown()+awaitTermination(timeout);- 但
shutdown()是温和 的------它只是不再接受新任务,已经在跑的任务会等它自己跑完; - 于是主线程卡在
awaitTermination上,等着那个卡在tryLock上的保活任务;而保活任务等着那个卡在 IO 上的收件任务......
整条链上没有任何一环会主动放弃。
原来的 stop() 是这么写的:
java
this.imapConnectionKeepAliveExecutor.shutdown();
try {
final long timeout = this.properties.getKeepAliveShutdownWaitTimeout().toSeconds();
final boolean terminated
= this.imapConnectionKeepAliveExecutor.awaitTermination(timeout, TimeUnit.SECONDS);
if (!terminated) {
log.warn("IMAP Keep-Alive executor did not terminate gracefully, forcing shutdown.");
this.imapConnectionKeepAliveExecutor.shutdownNow();
}
}
catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
this.imapConnectionKeepAliveExecutor.shutdownNow();
}
看着挺周全------先温和关、超时了再强关。问题在于这段"周全"本身就在延长停机时间 ,而且它守着的那个"优雅"其实毫无价值:服务都要关了,还等一个保活任务做完干什么? 保活的唯一目的是让连接活到下一次业务操作,现在没有下一次了。
这里我犯的是一个目的和手段错位 的错误:我把"优雅关闭"当成了一个要无脑执行的仪式,而没有问"这个任务的结果对关闭过程还有意义吗"。对于纯粹的后台维护型任务,正确的关闭姿势就是立刻掐死。
三处改动
改动一:把 Future 存下来,关闭时直接 cancel(true)
java
/** 执行保活任务操作的 Future 实例。*/
private volatile ScheduledFuture<?> performKeepAliveFuture;
@PostConstruct
public void start()
{
this.performKeepAliveFuture
= this.imapConnectionKeepAliveExecutor
.scheduleWithFixedDelay(
this::performKeepAlive, interval, interval, TimeUnit.SECONDS
);
}
@PreDestroy
public void stop()
{
this.running = false;
// 如果有保活任务还在执行直接掐掉(服务关闭时等待保活操作完成无意义)
if (Objects.nonNull(this.performKeepAliveFuture))
{
log.info(
"IMAP Keep-Alive future canceled (return {})",
this.performKeepAliveFuture.cancel(true)
);
}
if (this.isExecutorShutdown()) {
log.debug("IMAP keep-alive executor already stopped.");
return;
}
log.info("Shutting down IMAP Keep-Alive executor...");
// 关闭连接保活操作专用单线程执行器
this.imapConnectionKeepAliveExecutor.shutdownNow();
log.info("IMAP connection Keep-Alive component stopped.");
}
cancel(true) 里的 true 是标志位 mayInterruptIfRunning------它会给正在执行的线程发中断信号 。而 tryLock(timeout, unit) 是响应中断 的,会立刻抛 InterruptedException 退出等待。整个 awaitTermination 那一大段直接删掉,换成 shutdownNow()。
start() 之前压根没保存返回的 ScheduledFuture,导致任务提交出去就再也管不着了。提交周期任务时一定要留住它的 Future,否则你就失去了取消它的唯一手段。
改动二:换成 ThreadPoolTaskScheduler
java
@Bean(name = "imap-connection-keepalive-executor")
public ScheduledExecutorService imapConnectionKeepAliveExecutor()
{
final ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
final int awaitTimeout
= (int) this.emailReceiverProperties.getKeepAliveShutdownWaitTimeout().toSeconds();
scheduler.setPoolSize(1); // 单线程
scheduler.setThreadNamePrefix("imap-keepalive-");
scheduler.setDaemon(true); // 守护线程
scheduler.setAwaitTerminationSeconds(awaitTimeout); // 关闭时超时时间
scheduler.setWaitForTasksToCompleteOnShutdown(true); // 优雅关闭,等待任务完成
scheduler.setRemoveOnCancelPolicy(true); // 队列中的线程被中断则立刻离队
scheduler.initialize();
return scheduler.getScheduledExecutor();
}
三个关键点:
setDaemon(true):守护线程不阻止 JVM 退出。这是最后一道保险------万一前面所有清理逻辑都失灵了,JVM 该退还是能退,不会挂死在那里。setRemoveOnCancelPolicy(true):被取消的任务立刻从延迟队列里移除,而不是留在队列里等到它的预定时间才被丢弃。对周期任务尤其重要,能让队列迅速清空。- 去掉了 Bean 上的
destroyMethod = "shutdown":之前@Bean(destroyMethod = "shutdown")会让 Spring 在销毁时调shutdown()。但我们已经在@PreDestroy里手动shutdownNow()了,两套关闭逻辑并存,执行顺序还不确定------清理逻辑必须收敛到一处。
顺便说一句,原来那版用的是 Executors.newSingleThreadScheduledExecutor(虚拟线程工厂)。这里其实还有个隐性问题:虚拟线程不适合做调度器的载体线程 ------ScheduledThreadPoolExecutor 的工作线程需要长期驻留、反复从延迟队列取任务,这正是平台线程的强项,而虚拟线程的优势(海量、短生命周期、频繁阻塞)在这里一点用不上。
第五版:一次纯代码审查揪出的四个 bug
2026-08-29。这一版有点特殊:它不是由任何线上故障触发的。
服务跑得好好的,日志干干净净。我只是在给这篇博客整理素材、逐行重读 execute() 时,盯着那个 catch 块愣了一下:
等等------
catch (MessagingException)和try是平级 的。那tryLock超时时抛出的那个MessagingException,不也会落进这个catch里吗?
顺着这一问,连拔出四个 bug。而且没有一个是新引入的 ------它们从第二版就埋在那儿,其中最严重的一个,恰恰是第四版"把嵌套 try 拍平"时放大的。
bug 1:锁超时会走进重试分支 ------ 于是在无锁状态下操作 Store
先看第四版之后的结构(简化):
java
try
{
isLocked = this.lock.tryLock(waitSeconds, TimeUnit.SECONDS);
if (!isLocked) {
throw new MessagingException("Failed to acquire store lock..."); // ← 注意这里
}
this.ensureConnected();
return operation.execute(this.store);
}
catch (MessagingException exception) // ← 它同时接住了上面那个异常
{
if (this.isRetryException(exception))
{
this.store = null; // 无锁写共享字段
this.ensureConnected(); // 无锁建连接
return operation.execute(this.store); // 无锁执行业务逻辑
}
throw exception;
}
finally {
if (isLocked) { this.lock.unlock(); }
}
抛出 MessagingException 的来源有两个 ,而我写 catch 时脑子里只想着第二个:
operation.execute()执行失败 ------ 此时持有锁,重试没问题;tryLock超时 抛出的那个 ------ 此时isLocked == false,根本没拿到锁。
第 2 条走进 catch 会发生什么?看 isRetryException() 的第一个判断:
java
return !isConnected() || // ← 就是它
exception instanceof FolderClosedException ||
exception instanceof StoreClosedException;
只要此刻连接恰好是断的(服务刚启动、或连接刚掉),!isConnected() 就是 true → 判定"值得重试" → 于是在完全没有锁 的情况下把 store 置空、重建连接、执行业务逻辑。
锁超时本是保护机制,结果反而成了突破锁的入口。 兜了一圈,又回到第一版那个"并发操作同一个有状态 Store"的大坑里------而且这次更糟,因为 this.store = null 还会把别的线程正在使用的连接引用直接抹掉。
这个 bug 的来历值得说一句:第二版原本是嵌套
try------重试逻辑写在内层,天然只能捕获operation.execute()抛出的异常,tryLock超时的异常越不过去。第四版为了少一层缩进把它拍平成平级catch,可读性是好了,catch的作用域却被无意扩大了。教训:重构控制流时,"少一层缩进"从来不是零成本的------
try的作用域变了,catch能接住的东西就变了。
bug 2:改成递归重试后,递归没有终止条件
发现 bug 1 之后,第一反应是:既然问题是"重试时可能没锁",那让重试走完整入口重新抢锁不就行了?
java
return this.execute(operation); // 重试一次
这个思路是对的。ReentrantLock 是可重入的,所以:
- 持锁时重试 → 同一线程重入,
hold count1→2,内外层finally各解一次,正确; - 未持锁时重试 → 老老实实重新排队抢锁。
但它引入了一个更严重的问题:"最多重试一次"的语义没了。
原来 operation.execute(this.store) 是平坦调用 ,失败就直接往外抛。改成 this.execute(operation) 之后,重试走的是完整入口,于是它自带 catch 块,于是它还能再重试......
txt
execute() → catch → execute() → catch → execute() → ...
只要 isRetryException() 持续为真就永不终止 。而它的第一个条件恰恰最容易长期为真:鉴权失败、代理挂了、网络断了------ensureConnected() 一直抛异常,isConnected() 一直是 false。
结局二选一,都很糟:
StackOverflowError------ 它是Error不是Exception,catch (MessagingException)接不住,会直接冲垮上游push()里的异常处理;- 线程挂死 ------ 递归中途反复
tryLock超时(60 秒 × N 次),把保活线程和推送线程一起拖垮。
而且这个 bug 在开发环境几乎不会出现(网络好、鉴权对),只在生产真正出故障时才爆发------正是那种"故障时刻的二次故障",最难查的一类。
修法是给递归一个重试预算,用私有重载传递:
java
/** 对外入口:默认只允许重试一次。*/
@Override
public <T> T execute(StoreOperator<T> operation) throws MessagingException {
return this.executeWithRetries(operation, 1, false);
}
private <T> T executeWithRetries(
final StoreOperator<T> operation,
final int remainingRetries, // ← 终止条件
final boolean discardStore
) throws MessagingException { /* ... */ }
bug 3:catch 里残留的两行,依然在锁外
修完前两个,我以为收工了。结果 catch 块长这样:
java
this.store = null;
this.ensureConnected();
return this.executeWithRetries(operation, remainingRetries - 1, true);
前两行忘删了。
它们的功能已经被搬进了递归调用的锁内(discardStore 分支),但原件还杵在 catch 里。于是变成做两遍 :先在锁外丢弃+重连,再递归进去把刚建好的连接又丢掉、重新建一次。三个后果:
- bug 1 根本没修好 ------
tryLock超时路径下,这两行照样是无锁操作; - 连接泄漏 ------ 锁外建好的
Store被递归里的store = null直接丢弃,永远不会close(),每次重试泄漏一条 TCP + TLS 连接; - 多一次无谓的 TLS 握手 ------ 白白几百毫秒。
正确做法是:catch 块里不做任何 Store 操作 ,把"丢弃坏连接"这个意图作为参数传下去,由递归调用在锁内执行:
java
catch (MessagingException exception)
{
// 在重试预算耗尽、线程已经被中断或异常不值得重试的情况下,一律向外传播异常。
if (
remainingRetries <= 0 ||
Thread.currentThread().isInterrupted() ||
!this.isRetryException(exception)
)
{ throw exception; }
log.warn("Connection lost during operation, attempting reconnect.", exception);
// 消耗一次 remainingRetries 执行重试,且下次重试要重建连接。
// 注意:坏连接的丢弃与重建统一交给递归调用在锁内完成 ------
// 走到这里时可能压根没拿到锁(例如 tryLock 超时抛出的异常同样会落入本 catch 块)。
return this.executeWithRetries(operation, remainingRetries - 1, true);
}
锁内则多一行:
java
// 上一轮判定连接已失效,在锁内丢弃坏连接,交由 ensureConnected 重建。
if (discardStore) { this.store = null; }
// (2) 在锁内确保连接可用
this.ensureConnected();
顺便把守卫改成了 early-return (不满足条件就抛),比原来的 if (值得重试) { ... } throw 少一层缩进,重试路径也更醒目。
bug 4:一段看起来很专业、实则是空操作的代码
第四版在 catch 开头留了这么一段:
java
if (Thread.currentThread().isInterrupted()) {
Thread.currentThread().interrupt();
}
我当时给它编的理由是:"保存中断状态,防止底层库捕获 InterruptedException 时把标志清掉。"
听起来很专业。但它是错的,而且这段代码是纯粹的空操作。
关键在于:isInterrupted()(无参、实例方法 )不清除中断标志。所以:
- 标志还在 → 条件为
true→interrupt()再设一次 → 等于x = true之后又x = true; - 标志真被清了 → 条件为
false→ 这行压根不执行,救不了。
两种情况下都不起作用。 我那句"确保标志不会丢失"恰恰把它的语义讲反了------真想恢复丢失的标志,得用一个独立变量记住"我曾被中断过",而不是拿 isInterrupted() 当条件。
会清除标志的是 Thread.interrupted()(静态方法 )和抛出 InterruptedException 的阻塞调用。这两个 API 名字只差一个 is,是 Java 并发里最著名的命名陷阱之一。 如果当初写的是 Thread.interrupted()(读并清),那段代码就真的有意义了。
而这个类里真正正确 的中断处理一直都在,只是在另一个 catch 里:
java
catch (InterruptedException exception)
{
Thread.currentThread().interrupt(); // ← 这才是必需的
throw new MessagingException("Interrupted during waiting store lock.");
}
InterruptedException 抛出时标志已被清除,所以必须手动补回去,让上层调用栈还能感知到"我该退出了"。这才是"中断是协作契约"的实际含义。
至于那段空操作------删掉,并把中断判断挪到真正有用的地方:重试守卫里(见 bug 3 的代码)。已被中断的线程不该再去申请新连接,这和第四版在临界区入口做的检查是同一个意图。
附带修复:给 store 加上 volatile
java
/** 邮件服务 IMAP 连接实例。*/
private volatile Store store;
排查过程中顺带确认了这个字段的所有访问点,有三条理由(按重要性排序):
1. connect() 里的不安全发布(最重要)
java
newStore.connect(username, ...); // 写入大量对象内部状态
this.store = newStore; // 发布引用
这两组写之间没有 happens-before 边 。其他线程可能看到非 null 的新引用,却看不到 connect() 写入的内部状态 ------即读到一个"已发布但未初始化完毕"的 Store。这就是经典的不安全发布 ,与双重检查锁定失效同源。volatile 写建立的 happens-before 关系,才让"连接已建好"对读线程真正可见。
澄清一个流传很广的误解:这里的风险不是 "编译器把
this.store = newStore重排到connect()之前"------connect()是一次不透明的方法调用,JIT 不会做这种重排。真正的风险在读线程侧:两组内存写之间无序,读线程可以按任意顺序观察到它们。
2. 存在真正的锁外读
isRetryException() → isConnected() → 读 this.store,而它是在 catch 块里 执行的------那时可能没有锁(就是 bug 1 那条路径)。没有 volatile 可能读到旧值,进而误判"值不值得重试"。
3. @PreDestroy close() 不持锁
它由 Spring 容器线程调用,不抢锁就读 store 并调 close()。这个窗口很小,后果也轻(漏关一条连接,而进程正在退出、fd 马上被 OS 回收),属于"顺手防"而非"必须防"。
close()不持锁是刻意的:停机时若去抢锁,就会重蹈第四版那个"等一个卡在 IO 上的任务"的死锁。这是一个明确的取舍------用极小的竞态风险,换停机的确定性。
一个反直觉的插曲:这次能用 @Retryable 吗?
改到一半我冒出个念头:现在 execute() 全部是外部调用 (KernelEmailPusherImpl 和 ImapConnectionKeepAlive 都是注入接口后调用的),代理不会被绕过------坑 2 的自调用问题不存在了 ,那能不能直接用 @Retryable 省掉这堆手写逻辑?
答案是不能,而且理由跟 AOP 一点关系都没有:
致命问题:@Retryable 的重试发生在锁外。
代理的结构决定了 execute() 的 finally(解锁)一定先于 拦截器捕获异常。也就是说每次重试之间锁是完全释放的:
javascript
调用方 → [RetryInterceptor] → execute() { tryLock ... finally unlock }
↑ ↓
└────────── 重试在这里发起(此时无锁) ────────┘
于是"失败 → 丢弃坏连接 → 重连 → 重试"就不再是一个原子段------线程 A 第一次失败解锁后,保活线程 B 可能抢进去把连接重建了,A 重试时面对的是一个和第一次完全不同的 store 。而递归写法因为 ReentrantLock 可重入,持锁失败时重试全程不放锁。
还有三个次要问题:isRetryException() 是依赖实例运行时状态 的判别式,retryFor 只认异常类型;重试前必须执行的"丢弃坏连接"副作用没地方安放 (@Recover 是重试耗尽后的兜底,不是每次重试前的钩子);@Backoff 默认用 Thread.sleep(),会削弱第四版好不容易修好的中断响应。
这是"坑 2"的一次镜像 :当年
@Retryable失效是因为技术上不生效 (自调用);如今它技术上生效了,却因为语义上不匹配而依然不该用。注解式重试适合"无状态、幂等、纯失败即重试"的场景(比如调一个 HTTP 接口)。而这里的重试是"有状态资源的故障恢复",恢复逻辑和临界区强绑定,天然属于手写。
这一版的真正价值
这四个 bug 是纯靠读代码找出来的,一行都没跑。
而且它们的严重程度并不低------无锁裸奔会退化回第一版的数据竞争,无限递归会在生产故障时叠加 StackOverflowError,锁外重连会持续泄漏连接。但它们在开发环境几乎不可能复现:需要"锁竞争 + 连接恰好断开"同时发生,或者需要一次真实的持续性网络故障。
靠测试和线上日志,可能几年都撞不上一次。能揪出来靠的是几个可以机械执行的检查点:
catch块里,前面try中的哪些前置条件还成立? 平级catch最容易出的错就是"以为异常只来自最后一步"------bug 1 的根就在这。- 递归 / 重试有明确的终止条件吗?它依赖的状态会不会恒为真? (
!isConnected()就是) - 这个字段的所有读写都在锁内吗? 把访问点列一遍------
close()和isRetryException()这两个锁外读就是这么揪出来的。 - 对象发布出去时,它初始化完了吗?
这些审查十几分钟就能做完,不需要几个月的生产反馈。如果说前四版是"故障教我做事",第五版就是"故障还没来,先自己把账查一遍"。
具体的修复记录可以详见 PR:
终版
复盘:六个可迁移的教训
1. 锁的作用域必须覆盖被保护对象的整个使用期,而不只是获取期。
这是第一版最大的教训。凡是 getXxx() 返回一个有状态、非线程安全对象的设计,都值得警惕------你保护得了返回的那一瞬间,保护不了之后的任何一行。把操作传进去(Execute Around),比把资源传出来更安全。
2. 声明式注解的生效条件很隐蔽,用之前先确认它真的生效了。
@Retryable 那三重 bug 叠在一起,最可怕的地方是完全静默 ------没有报错、没有警告,只是什么都没发生。Spring AOP 的自调用问题对 @Transactional、@Async、@Cacheable 一视同仁。判断标准很简单:这个方法是被 this. 调用的吗?如果是,代理一定被绕过了。
3. "优雅关闭"要分清任务类型。
业务任务(正在处理用户数据)值得等;维护型任务(保活、心跳、指标上报)应该立刻掐掉------等它完成没有任何收益,只有停机时间的损失。搞清楚"这个任务的结果对关闭过程还有意义吗",答案通常是没有。
4. 在 Java 里,"所有权"只是一个约定,不是一道防线。
StoreOperator 拦不住 execute(store -> store) 这种一行逃逸。但这不代表模式失败了------它的价值在于把正确的写法变成默认路径,把危险的写法变得显眼。 缺少语言级保障时,"让误用大声报错"(fail-fast、一次性消费、作用域校验)通常比"让误用无法编译"更现实。分清你在防失误还是防恶意,前者值得投入,后者在同一个进程里基本防不住。
5. 重构控制流时,最该复查的是 catch 的作用域。
第五版那个最严重的 bug,源头是第四版"把嵌套 try 拍平成平级 catch"------少了一层缩进,catch 能接住的异常来源却变多了 。写 catch 时问一句:这个 catch 到底能接住几个来源的异常?前面 try 里的哪些前置条件(比如"锁已持有")在这里还成立? 这一问就能拦住一大类"在错误的状态下做正确的事"。
6. 有些 bug 永远等不到它自己暴露。
第五版的四个 bug 全是纯代码审查找出来的,且都需要"锁竞争 + 连接恰好断开"这类小概率组合才会显形------靠测试和线上日志可能几年都撞不上 。对于并发和故障恢复这类代码,主动审查的性价比远高于被动等待 :盯住 catch 的作用域、递归的终止条件、字段的锁外访问、对象的安全发布,十几分钟就能过一遍。
写在最后
从第一版到第五版,跨度两个多月,最终代码只有 220 行。但每一行"多余"的判断背后都对应着一次真实的故障------或者一次本来会发生的故障:
| 版本 | 时间 | 解决的问题 | 核心手段 |
|---|---|---|---|
| v1 | 06-19 | 建立连接开销大 | 单例长连接 + ReentrantLock |
| v2 | 06-25 | 锁保护不到位、AOP 失效、所有权混乱 | Execute Around + 判别式重试 + 安全发布 |
| v3 | 07-03 | 空闲连接被服务端静默回收 | NOOP 保活(复用 execute()) |
| v4 | 07-18 | 停机时死锁 | Future.cancel(true) + 中断检查 + 守护线程 |
| v5 | 08-29 | 无锁重试、无限递归、连接泄漏、不安全发布 | 重试预算 + 锁内丢弃 + volatile |
最让我有感触的是 v2 到 v3 的衔接 :v2 那次重构纯粹是为了修 bug,当时完全没想过保活的事。但正因为抽象抽对了,一个月后加保活功能时,我只写了一行 singleImapConnection.execute(ImapConnectionKeepAliveUtils::keepAlive),就白拿了互斥保护和自动重连。
好的抽象不是设计出来的,是在修够了 bug 之后长出来的。 而它的回报,往往出现在你当初根本没预料到的地方。
而 v5 给了我另一个提醒:代码"能跑"和"是对的"之间,隔着的往往正是那些永远不会自己暴露的路径。
完整代码见 Linux-Kernel-Email-List-Analyzer,欢迎交流指正。