在Laravel项目里,把数据写入数据库并使用缓存加速读取是非常普遍的做法。但当这些操作被包裹在数据库事务中时,如果处理顺序不当,就容易出现缓存中已经保存了新值、而数据库事务却因后续异常被回滚的情况。这种不一致会让用户读到根本不存在的数据,并且在很长缓存周期内难以自愈。理解事务生命周期与缓存操作的边界,是构建可靠系统的必要基础。

为什么事务内写缓存会导致不一致
数据库事务具备原子性,只有执行到commit之后,变更才对其他连接可见。Laravel的DB::transaction闭包在内部依次执行你的代码、捕获异常并决定提交或回滚。很多开发者习惯在更新模型后立即调用Cache::put,此时事务尚未提交,一旦后面某步失败触发rollBack,数据库恢复原状,但缓存已经落地。
举一个典型错误顺序:先$user->update(['name'=>'test']),紧接着Cache::forever('user_1', $user),再执行一个会抛异常的第三方接口调用。事务回滚后,数据库里name还是旧值,缓存却是test。后续请求读缓存拿到错误数据,且因为缓存未过期,不一致会持续很久。这种问题在测试环境不易暴露,却会在生产高峰引发大量客诉。
从隔离级别看,即使是READ COMMITTED,事务内未提交的数据对外不可见,但缓存层独立于数据库事务,没有参与回滚机制。因此任何在commit之前对外部存储的写入,都是对原子边界的破坏。我们必须把缓存写入视作“事务成功后的副作用”,而非事务步骤本身。
使用afterCommit实现提交后同步
Laravel从8.x开始为模型事件与缓存场景提供了afterCommit配置。你可以在模型里声明protected static $afterCommit = true;,这样模型的saved、updated等事件只会在事务真正提交后触发。借助这个机制,我们把缓存更新逻辑挪到事件监听中,天然避开回滚污染。
下面是一个利用模型事件写缓存的示例,注意事件处理器里不要直接操作数据库事务,只做缓存同步:
<?php
namespace AppListeners;
use AppEventsUserUpdated;
use IlluminateSupportFacadesCache;
class SyncUserCache
{
public function handle(UserUpdated $event)
{
$user = $event->user;
// 只有事务提交后才会走到这里
Cache::put('user_' . $user->id, $user, 3600);
}
}
如果你不想依赖模型事件,也可以在闭包事务中手动控制。Laravel的DB::transaction在闭包正常返回时会提交,你可以在闭包外、调用处紧接着写缓存。但更优雅的是用DB::afterCommit回调,它接收一个闭包,仅在当前事务提交后执行,回滚则不运行。
<?php
use IlluminateSupportFacadesDB;
use IlluminateSupportFacadesCache;
DB::transaction(function () use ($userId) {
$user = User::find($userId);
$user->name = 'new_name';
$user->save();
});
// 上面提交成功后才会注册执行
DB::afterCommit(function () use ($userId) {
$user = User::find($userId);
Cache::put('user_' . $userId, $user, 3600);
});
这种方式的优点是语义清晰:缓存操作明确挂靠在提交动作之后。缺点是如果代码分散多处,容易遗漏afterCommit注册。在团队开发中,建议封装一个transactWithCache辅助函数,统一模板。
Cache Aside模式与并发下的防击穿
另一种被广泛采用的策略是Cache Aside:写数据时只删除缓存,不主动写缓存,读数据时若未命中再回源数据库并补缓存。在事务里,我们同样要把删缓存动作推迟到提交后,否则事务回滚后缓存被误删,下次读会穿透到库,虽不错误但增加负载。
在并发场景中,单纯删缓存可能遇到“删完瞬间旧值被写入”的竞态。比如线程A提交事务删缓存,线程B在A提交前已读旧值并在A删缓存后写回旧值。解决方法是引入分布式锁,保证“写库+删缓存”与“读库+写缓存”互斥。Laravel的Cache::lock可以很方便地实现。
<?php
use IlluminateSupportFacadesCache;
use IlluminateSupportFacadesDB;
$lock = Cache::lock('user_sync_' . $userId, 5);
if ($lock->get()) {
try {
DB::transaction(function () use ($userId) {
$user = User::find($userId);
$user->score = 100;
$user->save();
});
DB::afterCommit(function () use ($userId) {
Cache::forget('user_' . $userId);
});
} finally {
$lock->release();
}
}
对于读路径,也要用锁避免缓存击穿:未命中时只放一个请求去查库,其余请求等待。这样即便写事务提交后删缓存,也不会因为大量并发回源把数据库压垮。综合来看,afterCommit负责写路径一致性,锁负责并发安全,两者配合能在绝大多数业务里做到最终一致且无性能悬崖。
最后需要提醒,如果使用了队列去同步缓存,要确认队列任务是在事务提交之后派发。Laravel的dispatchAfterCommit正是为此设计,它保证事件仅在本事务成功后入队,避免回滚后队列仍执行缓存更新。根据业务对一致性的严苛程度,你可以在“删缓存”“更新缓存”“队列异步”之间灵活组合,但核心原则始终是:缓存写入不允许早于数据库提交。