背景
数据库的特性ACID大家都知道,如果单纯的只使用数据库,正常开启和关闭事务就可以了,但是如果加上了缓存,那么如果操作不当,就有可能会引发一些严重的问题
阅读本文大约花费10分钟
场景描述
假设有一个电商后台,其中有一个Product模块,有获取详情,更新状态两个方法,基础代码如下:
@Service
public class ProductService {
@Autowired
private ProductMapper productMapper;
@Autowired
private ProductCache productCache;
@Transactional
public void updateStatus(Integer productId, Integer newStatus) {
//检查数据有效性
Product beforeUpdate = productMapper.get(productId);
if (beforeUpdate == null) {
throw new RuntimeException("商品不存在!");
}
//执行更新操作
productMapper.updateStatus(productId, newStatus);
//删除缓存
productCache.del(productId);
}
public Product get(Integer id) {
//查缓存
Product product = productCache.get(id);
if (product == null) {
product = productMapper.get(id);
productCache.set(product);
}
return product;
}
}
更新核心逻辑:检查数据是否存在 → 更新 → 删缓存
get方法逻辑:查缓存 → 未命中就查库 → 写缓存 → 返回
基本上常规的CRUD代码都是这么写的,如果业务就这么简单,确实没什么问题。
然而,大多数产品都是迭代开发的,现在来了一个新需求,要对产品做搜索,因此引入了ElasticSearch(以下简称ES),将所有Product的信息同步到ES中,用于搜索业务。
那么显然,当商品信息更新后,需要更新ES的数据,代码该怎么写呢?
当然首先是要有一个操作ES的类
@Component
public class ProductES {
@Autowired
private ProductService productService;
public void updateES(Integer productId) {
Product product = productService.get(productId);
if (product != null){
//刷新es的操作
}
}
}
然后在Product信息更新后,去调用这个updateES方法。通常情况下,我们会将更新ES放到更新Product的事务内,否则就可能出现搜索结果不准确的情况。
所以ProductService.update方法,就需要再加一行代码
看似好像很合理,而且按照我之前的经验,很多人也都是这么写的,但实际上这里隐藏着巨大的危机
在分析问题前,先看看这个业务的流程图
重点关注一下红圈里面的流程,这里会调用ProductService的get方法,假如缓存未命中,就会把最新的数据查出来并写入缓存(最新是指当前事务更新后的数据)
这里就引发了第一个问题
数据库的四大特性之隔离性:事务在提交前,别的事务是无法查询到它的修改的。
但是这里,在事务提交前就把最新数据写入了redis,那么别的请求就能够获取到,但实际上,事务还未提交,严格来说,这是不应该的。
接着看第二个问题
流程接着往下走,就是更新ES数据了,假如这时候,ES Server发生了一些网络抖动或者其他什么原因,导致这次更新失败,抛了异常,这就会导致整个事务回滚,数据库的更新会被还原,但是,redis里的数据,却没办法还原。
这种就是相当严重的数据错误了,后续的查询,竟然得到了被回滚掉的数据!!!
怎么解决
办法一
这种更新ES的操作,就直接查数据库了,不用走Service
优点:简单直接
缺点:只有当你意识到有这个问题的时候,才会去采取相应的措施。实际项目中,经常见到很多张表互相关联,一张表更新后,要同步多张表,并且如果业务流程复杂,很有可能就会在事务中,调用了带缓存的查询方法
办法二(本文的重点)
Spring的事务回调机制,工具类:TransactionSynchronizationManager
先说结论
- 在事务内执行写操作,如果需要删除缓存,调用工具方法注册一个事务回调,在事务提交后再去删缓存
- 如果某个查询需要加缓存,判断下当前是否正在事务中,如果是,那么就直接查库返回(完全忽略掉缓存的存在)
解释一下
首先是TransactionSynchronizationManager这个类,它是Spring提供的事务相关的类,比较实用的方法有:
- isActualTransactionActive:可以判断当前线程是否处在事务中
- registerSynchronization:注册一个事务相关的回调函数TransactionSynchronization(注意:如果当前未开启事务,这个方法会报错)
- 事务回调函数TransactionSynchronization,常用的方法有:beforCommit(事务提交前的操作),afterCommit(事务提交后的操作),afterRollback(回滚后的操作),afterCompletion(回滚或提交后的操作)
为什么要在事务提交后和提交前都去删缓存?
-
提交前删缓存,是因为假如删缓存失败,抛出异常,这样可以保证缓存和数据库之间的一致性(假如数据库更新成功,但删缓存却失败了,那么后续的查询,拿到的,仍然是旧数据)
-
提交后删缓存,因为大多数后端都是集群部署的,比如在删除缓存后,提交事务前,服务器遇到了一次500ms的fullgc,在这500ms内,别的服务器恰巧执行了一次查询(Oh No,旧数据又被写进redis了),所以在事务提交后,再删一次,算是很保险的操作(当然,redis缓存一致性,也有延迟双删的操作,可以结合起来使用)
为什么事务内的查询都跳过缓存?
因为不管缓存是否命中,都会引发问题:
-
如果命中,那么拿到的就是旧数据,比如ES这种场景,那就是拿旧数据去做同步,导致业务逻辑错误
-
如果未命中,那么就会把事务内的修改提前暴露到redis中,导致非常严重的数据错误
-
当然,这可能会增加一些数据库主库的压力,不过大多数情况下,问题不大(写操作除了读取数据外,还要写redolog,undolog,处理事务等等,这些开销相比于读来说要大得多,所以增加一点读压力,一般也没多大影响)
结尾
这是一个常见的问题,如果是CRUD程序员,也许不用去关注(出了问题再改也没多大问题)。
但如果你是后端的leader,就应该重点关注一下了(除了做业务,还应该要未雨绸缪,及早找出系统中的隐患)