AI 建议“更新数据库后删除缓存”,为什么仍可能造成长期脏数据
很多缓存一致性问题,最开始看起来都很简单。
例如商品服务需要修改一个商品价格,开发者通常会写出类似逻辑:
@TransactionalpublicvoidupdateProductPrice(LongproductId,BigDecimalnewPrice){productRepository.updatePrice(productId,newPrice);redisTemplate.delete("product:price:"+productId);}这段代码很常见。
数据库更新后删除缓存,下一次读取时缓存未命中,再从数据库加载新价格,似乎没有问题。
当你把“缓存更新不一致怎么办”交给 AI 时,它也经常会给出这类答案:
更新数据库 ↓ 删除缓存 ↓ 下次查询重新写入缓存这套思路并不完全错。
问题在于,它只描述了理想顺序,没有处理真实系统里的事务、并发读取、缓存重建和异常恢复。
尤其是下面这个场景:
- 事务尚未提交;
- 缓存已经被删除;
- 另一个请求读取商品价格;
- 它发现缓存为空,于是去数据库查询;
- 数据库事务还没有提交,它读到旧价格;
- 旧价格重新写进缓存;
- 原事务提交成功;
- 缓存里却留下了旧值。
这就是一种特别隐蔽的长期脏数据。
数据库已经是新价格,缓存却保留旧价格;服务没有报错,接口也能正常返回,只是部分用户持续读到旧数据。
一、最常见的错误:把“更新库后删缓存”理解成原子操作
很多人看到下面这段代码时,会认为它天然有顺序:
@TransactionalpublicvoidupdateProductPrice(LongproductId,BigDecimalnewPrice){productRepository.updatePrice(productId,newPrice);redisTemplate.delete("product:price:"+productId);}从代码执行顺序看,确实先执行数据库更新,再执行缓存删除。
但从数据库事务的角度看,updatePrice()只是把修改放进当前事务。
在事务真正提交前,其他请求是否能读到新数据,取决于隔离级别、查询路径和当前连接状态。
于是会出现这样的时序:
| 时间点 | 写请求 | 读请求 |
|---|---|---|
| T1 | 更新数据库,事务未提交 | |
| T2 | 删除缓存 | |
| T3 | 读取缓存,未命中 | |
| T4 | 查询数据库,读到旧价格 | |
| T5 | 把旧价格写回缓存 | |
| T6 | 数据库事务提交 | |
| T7 | 后续请求继续读缓存旧值 |
问题不在于 Redis 出错,也不在于 SQL 没执行。
问题是缓存失效发生得太早。
缓存删除应该和“事务已经确认提交”绑定,而不是只跟随代码行的执行顺序。
二、为什么 AI 很容易给出这个不完整方案
AI 在处理这类问题时,通常会根据大量常见代码模式给出一个“更新库 + 删缓存”的基础方案。
这个方案适合回答:
修改数据后,怎样让缓存尽快失效?
但它没有自动回答:
- 当前数据库事务什么时候提交;
- 缓存删除失败后如何恢复;
- 高并发下是否有人会在提交前重建旧缓存;
- 多个服务是否都能写同一份缓存;
- 重试任务是否会误删更新后的新缓存;
- 关键价格、库存、权益字段是否允许短暂读旧值。
也就是说,AI 可以迅速生成局部正确的实现,但局部正确不等于整体一致。
近期关于 AI 辅助开发的工程讨论,也越来越强调把团队规则、验证方式和评审标准沉淀成可复用资产,而不是只依赖某次提示词或某个开发者的即时判断。
对缓存一致性来说,真正需要沉淀的不是一句“更新后删缓存”,而是完整规则:
数据库提交成功 ↓ 缓存失效 ↓ 读取方允许重建缓存 ↓ 缓存写入必须校验版本或时间 ↓ 删除失败必须进入补偿链路三、正确的第一步:事务提交后再删除缓存
在 Spring 场景下,可以让缓存失效逻辑放到事务提交成功之后。
例如:
publicrecordProductPriceChangedEvent(LongproductId,BigDecimalprice){}业务层只负责修改数据库和发布领域事件:
@TransactionalpublicvoidupdateProductPrice(LongproductId,BigDecimalnewPrice){productRepository.updatePrice(productId,newPrice);applicationEventPublisher.publishEvent(newProductPriceChangedEvent(productId,newPrice));}缓存删除放在事务提交后:
@ComponentpublicclassProductCacheInvalidationListener{@TransactionalEventListener(phase=TransactionPhase.AFTER_COMMIT)publicvoidonPriceChanged(ProductPriceChangedEventevent){redisTemplate.delete("product:price:"+event.productId());}}这样做至少解决了一件事:
如果数据库事务最终回滚,就不会提前删除缓存。
同时,读请求在数据库提交前仍然可以继续读取旧缓存,不会因为缓存提前删除而把旧数据库值重新写回缓存。
但要注意,这不是所有问题的终点。
四、事务提交后删缓存,也可能失败
假设数据库事务已经提交成功,但 Redis 恰好出现网络异常:
@TransactionalEventListener(phase=TransactionPhase.AFTER_COMMIT)publicvoidonPriceChanged(ProductPriceChangedEventevent){redisTemplate.delete("product:price:"+event.productId());}如果delete()失败,数据库已经是新价格,缓存仍然是旧价格。
此时不能简单写一句:
try{redisTemplate.delete(key);}catch(Exceptione){log.error("delete cache failed",e);}因为日志并不会自动修复脏缓存。
更可靠的做法是把“缓存失效失败”视为一个需要补偿的工程事件。
例如记录待处理任务:
publicvoidinvalidateProductCache(LongproductId){Stringkey="product:price:"+productId;try{redisTemplate.delete(key);}catch(Exceptionex){cacheRetryRepository.save(CacheRetryTask.of("DELETE",key,"PRODUCT_PRICE_CHANGED"));throwex;}}然后由独立任务进行重试:
@Scheduled(fixedDelay=30000)publicvoidretryCacheInvalidation(){List<CacheRetryTask>tasks=cacheRetryRepository.findPendingTasks(100);for(CacheRetryTasktask:tasks){try{redisTemplate.delete(task.getCacheKey());cacheRetryRepository.markSuccess(task.getId());}catch(Exceptione){cacheRetryRepository.increaseRetryCount(task.getId());}}}这里要注意一个边界:
重试删除不能无限执行。
如果旧重试任务在很久之后才执行,它可能删除已经被新业务重新写入的缓存。
因此,重试任务需要带版本、事件时间或业务版本号,而不是只保存一个 Redis Key。
五、给缓存数据加版本,避免旧事件干扰新数据
例如缓存对象里增加业务版本:
publicrecordProductPriceCache(LongproductId,BigDecimalprice,Longversion,InstantcachedAt){}写入数据库时同步递增版本:
@TransactionalpublicvoidupdateProductPrice(LongproductId,BigDecimalnewPrice){Productproduct=productRepository.findByIdForUpdate(productId);product.changePrice(newPrice);product.increaseVersion();productRepository.save(product);applicationEventPublisher.publishEvent(newProductPriceChangedEvent(productId,newPrice,product.getVersion()));}缓存补偿任务也记录版本:
publicrecordCacheRetryTask(Longid,StringcacheKey,LongeventVersion,Stringstatus){}这样,补偿任务执行前可以判断:
待删除事件版本 < 当前数据库版本 ↓ 说明这是旧事件 ↓ 不能盲目执行删除或覆盖很多系统不需要一开始就引入复杂版本控制。
但对于价格、库存、权益、审批状态、限流策略等关键数据,至少要明确:
- 缓存是否允许短暂不一致;
- 旧事件能否影响新缓存;
- 补偿操作如何避免覆盖最新状态;
- 是否需要强制读取数据库确认。
六、把 AI 从“给代码”变成“帮你列验证清单”
让 AI 直接写缓存更新代码,得到的通常是一段可运行实现。
但更有价值的使用方式,是让它先帮你列出缓存一致性风险。
例如:
你是后端架构评审助手。 场景: 商品价格修改后需要失效 Redis 缓存。 数据库使用事务,缓存采用 Cache Aside 模式。 请不要直接只给“更新数据库后删除缓存”的代码。 请输出: 1. 事务未提交时提前删缓存可能造成的并发时序; 2. 缓存删除失败后的补偿方案; 3. 重试任务可能误删新缓存的风险; 4. 哪些字段适合允许短暂不一致; 5. 最少 6 个并发与异常测试场景; 6. 需要人工确认的业务边界。这样得到的输出,通常更适合作为代码评审前的检查清单。
对于已经把 ChatGPT Plus、GPT Plus 用在代码解释、设计讨论、测试补全和排障整理中的开发者来说,长期使用的价值不在于每次都让工具直接写完代码,而在于是否能把问题建模、约束条件和验证步骤逐步沉淀到自己的工作流里。
对已经确认有 AI 工具长期使用需求的开发者来说,工具准备不只是模型能力,还包括使用周期、说明理解、边界意识和异常处理路径;相关信息可按实际需要参考:gpt985com
七、这类缓存改造至少要测什么
缓存一致性问题最怕只测“正常更新后能不能读到新值”。
更应该覆盖这些场景:
| 测试场景 | 预期结果 |
|---|---|
| 数据库更新成功、缓存删除成功 | 下次读取从数据库加载新值 |
| 数据库更新回滚 | 原缓存不应被删除 |
| 缓存删除失败 | 产生可重试补偿任务 |
| 删除缓存后并发读取 | 不应长期写入旧值 |
| 补偿任务延迟执行 | 不应误删新版本缓存 |
| 两次快速更新同一商品 | 最终缓存必须对应最新版本 |
| Redis 短暂不可用 | 系统具备降级、告警和恢复路径 |
例如可以测试“事务回滚时缓存不失效”:
@TestvoidshouldNotEvictCacheWhenDatabaseTransactionRollsBack(){Stringkey="product:price:10001";redisTemplate.opsForValue().set(key,"99.00");assertThrows(BusinessException.class,()->productApplicationService.updatePriceWithFailure(10001L,newBigDecimal("109.00")));assertEquals("99.00",redisTemplate.opsForValue().get(key));}再测试“事务提交后才触发缓存失效”:
@TestvoidshouldEvictCacheOnlyAfterTransactionCommit(){Stringkey="product:price:10001";redisTemplate.opsForValue().set(key,"99.00");productApplicationService.updatePrice(10001L,newBigDecimal("109.00"));assertNull(redisTemplate.opsForValue().get(key));}这些测试的重点不是验证某个方法是否被调用,而是验证:
在异常、并发和重试条件下,最终读到的数据是否仍然符合业务预期。
八、结语
“更新数据库后删除缓存”不是错误方案。
它只是一个还没有写完的方案。
真正可靠的缓存一致性设计,至少要回答:
- 删除缓存发生在事务提交前还是提交后;
- 删除失败后如何补偿;
- 延迟重试会不会影响新数据;
- 高并发读请求是否可能重建旧缓存;
- 哪些数据允许短暂不一致;
- 线上如何发现缓存和数据库已经出现偏差。
AI 可以快速给出缓存更新代码,也可以帮你补测试和整理时序。
但能不能把缓存逻辑真正放进长期稳定的开发工作流,取决于你是否把事务、版本、补偿和验证这些边界一起设计进去。
缓存问题最怕的不是一次删除失败。
而是系统已经读错数据,却没有任何人知道它从什么时候开始错了。
