原创

Spring afterCommit:数据库提交后,再删缓存

数据库更新成功,缓存却又变回旧值,问题可能发生在事务真正提交之前。

发布文章时把 UPDATE 和缓存删除挨着写,并没有建立“提交后删除”的关系。Spring 默认代理事务是在业务方法返回后提交的:方法里面已经删了缓存,外部读请求却还看不见未提交的数据。它从数据库取出旧状态,再填回缓存,随后写事务才提交。SQL 的执行顺序没错,读者看到的时间线错了。

@Transactional(rollbackFor = Exception.class)
public void publish(Long articleId) {
    articleDao.markPublished(articleId);
    articleCache.evict(articleId);
}

这段代码还有一个更隐蔽的信号:删缓存抛异常时,事务可能回滚;缓存删除成功时,又可能因为随后提交失败而回滚。缓存和数据库从来没有共同参与这个本地事务。

先把提交时机固定下来

下面实现面向 Spring Framework 5.3 的命令式本地事务,使用 Java 8 语法。它只做一件事:存在真实事务时,把动作登记到该事务的提交回调中。

import java.util.Objects;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

public final class CommitActions {

    private CommitActions() {
    }

    public static void afterCommit(Runnable action) {
        Objects.requireNonNull(action, "action");
        if (!TransactionSynchronizationManager.isActualTransactionActive()
                || !TransactionSynchronizationManager.isSynchronizationActive()) {
            throw new IllegalStateException("This action requires an active transaction");
        }
        TransactionSynchronizationManager.registerSynchronization(
                new TransactionSynchronization() {
                    @Override
                    public void afterCommit() {
                        action.run();
                    }
                });
    }
}

业务入口调用方式如下,DAO 和缓存组件是已有的项目组件:

@Transactional(rollbackFor = Exception.class)
public void publish(Long articleId) {
    articleDao.markPublished(articleId);
    CommitActions.afterCommit(() -> articleCache.evict(articleId));
}

这里故意没有“无事务就立刻执行”的兜底。如果调用方承诺提交后处理,缺失事务就应当暴露出来。常见原因是入口没加事务、对象没有交给 Spring 管理,或同类方法直接调用绕过代理。静默兜底会把这些配置错误藏得很深。

检查 isActualTransactionActive() 与 isSynchronizationActive() 也并非重复。同步上下文可以存在,而真实资源事务尚未开始,例如某些 SUPPORTS 场景;只看同步开关不足以承诺数据库已进入事务。

回调跟着实际参与的事务走。REQUIRED 内层方法登记后返回,不会立即触发;外层提交才触发,外层回滚就不触发。REQUIRES_NEW 则跟随自己新开的独立事务。评审这类代码时,要沿着调用链找到实际的事务边界,不能只找离回调最近的注解。

最难处理的是“提交了,但后续失败了”

afterCommit() 执行时,文章已经落库。Redis 超时不会把它撤销。直接注册的同步回调如果抛出运行时异常,还可能让提交调用方收到异常,于是出现“接口失败,文章实际发布成功”的结果。

这个状态需要写进接口语义。对于可重建的缓存,可以捕获预期的缓存异常,保留文章 ID、版本和失败原因,由 TTL 或持久化补偿处理。若选择把异常继续往外抛,调用方也必须具备幂等能力,不能把一次超时理解成写入没有发生。

日志可以证明失败过,不能让重启后的进程找回任务。需要重试,就得回答任务存在哪里、谁来领取、执行成功如何确认。不能用“稍后重试”四个字代替实现。

回调默认占用提交线程。里面多一次慢 HTTP 调用,接口仍会多等一次;换成线程池只能改变等待方式,还会引入拒绝、任务丢失和进程退出的问题。原线程上的事务也不会自动传播到新线程。

还有一个边界:提交后删除缓存只能缩小其中一类并发窗口。更早开始的读请求可能已经拿到旧版本,并在删除之后才写入缓存;缓存删除本身也会失败。TTL、版本比较、失败补偿仍有自己的职责。

事务事件可以换写法,不能凭名字猜异常行为

项目如果已有领域事件或应用事件,可以在事务方法中通过注入的 ApplicationEventPublisher 发布事件,由另一个 Spring Bean 处理:

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onPublished(ArticlePublished event) {
    articleCache.evict(event.getArticleId());
}

事件只携带稳定的文章 ID 或必要的不可变快照,避免在提交后继续依赖延迟加载对象。发布事件时必须有活动事务;默认没有事务就不执行。fallbackExecution = true 会改变这个约定,并不能修复一个失效的事务代理。

直接注册 afterCommit() 与 @TransactionalEventListener(AFTER_COMMIT) 也不是完全相同的调用路径。在 Spring 标准同步事务处理中,后者是 AFTER_COMPLETION 阶段的特化,监听异常通常由完成回调记录;前者的运行时异常则可能向提交调用方传播。若项目改造了事件执行器,应按实际错误处理机制判断。

提交完成时,连接等资源可能还绑定在线程上。此时还能拿到连接,不代表继续写库会被原事务再提交一次。需要写独立回执,应经过另一个 Spring Bean 的代理开启新事务:

@Transactional(propagation = Propagation.REQUIRES_NEW,
        rollbackFor = Exception.class)
public void saveReceipt(Long articleId) {
    receiptDao.insert(articleId);
}

这个回执的新事务失败,不会撤销文章。更重要的是,如果回执本身承担“后续动作不能丢”的职责,那么等主事务提交后才创建它,时间就太晚了。

可靠性要求决定任务放在哪里

从数据库提交成功到缓存删除完成之间,进程随时可能退出。内存回调和内存事件都没有恢复入口。

对必须补做的动作,应该在修改文章的同一个本地事务里落下一条待处理任务;提交后由轮询器或 CDC 读取。这样主业务和“有工作要做”一起提交。处理器成功后再标记完成,领取超时能够恢复,失败能够延迟重试。

任务持久化之后仍然可能重复执行:动作完成了,处理状态没来得及更新,下一轮还会再领取。所以任务 ID 要稳定,处理要幂等;同一文章的连续更新还需要版本或顺序约束,防止旧任务覆盖新状态。这里不展开表结构,任务表的领取协议和事件平台需要结合部署方式决定。

验证提交时机时,最好用 TransactionTemplate 明确划出一次提交与一次回滚。测试方法若默认带事务并最终回滚,提交回调不触发是正确行为。真正值得验证的是:外层回滚是否抑制动作、回调失败时主数据是否已提交、进程重启后持久化任务能否继续。缓存一致性最终要靠这些故障路径站住脚。

正文到此结束
评论插件初始化中...
Loading...