应用系统数据库与缓存一致性解决思路
前言1
随着互联网应用的普及,内容信息越来越复杂,用户数和访问量越来越大,我们的应用需要支持更多的并发量,同时我们的应用服务与数据库服务器所做的计算也越来越多。但是我们的应用服务器的资源是有限的,很多时候性能的瓶颈会出现在各种IO上面。此时,就需要我们引入缓存机制,减少IO,减少计算,提高响应速度,优化用户体验。
架构图
一般来说,当我们引入缓存机制之后,整体的架构图应该是类似下面这样的:
在服务层下面,DB层上面的地方专门有一个处理缓存的地方。所有的请求在访问DB层之前会先访问缓存层,如果缓存层中存在数据,那就直接返回,如果缓存层中没有数据,那再从DB层中读取数据,存入缓存层,最后再返回。
数据库与缓存一致性问题
一般来说,当我们引入缓存之后,业务端的处理流程大体是这样的:
从上面的图中我们看出来,相当于一份数据存储在两个容器中,在缓存中有一份,在数据库中也有一份。由于为了提高服务响应速度,我们会优先从缓存中读取数据,当缓存不存在时,才会从数据库中读取数据。如果缓存中的数据和数据库中的数据不一致,那对于应用系统来说影响就非常严重了。
不同缓存策略下缓存数据库不一致问题
一般来说处理缓存与数据同步有两种策略:缓存更新与缓存失效,下面我们针对这两种策略分别说说缓存数据库不一致问题是如何产生的
缓存更新策略
- 线程2先发起缓存读的操作,此时缓存中没有数据,然后线程2开始读数据库,从数据库中读出来
count=10
,此时线程2被挂起, - 线程1开始执行更新操作,线程1将数据库更新为
count=20
,同时更新缓存为count=20
。 - 然后线程2得到CPU使用权,更新缓存为
count=10
。 - 最终缓存中count=10,数据库中count=20。缓存与数据库发生不一致现象。
缓存删除策略
同样的缓存删除策略也存在数据库缓存不一致的问题。
- 线程2先发起缓存读的操作,此时缓存中没有数据,然后线程2开始读数据库,从数据库中读出来
count=10
,此时线程2被挂起, - 线程1开始执行更新操作,线程1将数据库更新为
count=20
,同时删除缓存。 - 然后线程2得到CPU使用权,更新缓存为
count=10
。 - 最终缓存中count=10,数据库中count=20。缓存与数据库发生不一致现象。
根本原因
从上面两个图可以看出来出现缓存不一致问题的根本原因在于:线程2从数据库中读取到数据后,更新缓存之前,缓存数据被人修改了,也就是发生了读写并发。如果可以保证读写互斥,那就可以解决这个不一致的问题。
基于缓存删除策略解决数据库与缓存不一致问题
单机环境
缓存读取流程图
在单机环境下,我们需要使用读写锁来保证缓存安全,缓存读取有两种方式:
- 在缓存中获取对象后返回对象,由于缓存中的对象被
return
了,也就失去了读写锁的保护,所以依旧存在并发安全的隐患,但是大部分场景下我们依然使用的这种逻辑。为了避免缓存对象使用期间被其他线程修改,我们一般会给缓存对象一个version,用来做乐观锁。
- 直接在缓存中使用缓存对象。这种方式相当的严谨同时也是
ReentrantReadWriteLock
中CachedData
的样例版本,但是实际使用起来在编码层面并不太友好(可能我菜…)
代码实现
@Component
public class MyLocalCacheServiceImpl{
private final Map<String,MyObj> cacheMap = new HashMap<>();
private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
@Autowired
private MyDao myDao;
/**
* 一个简单的缓存读取的样例,使用读写锁兼顾效率与安全。
* 这个方法一般用来配合Controller层读取缓存数据使用,
* 对于后端使用缓存来说,因为缓存对应已经被return,
* 导致失去了锁的保护,可能会导致不可重复读的情况出现。
* 严谨处理的话,MyObj对象上应该存个版本属性,
* 通过乐观锁来避免无锁状态下缓存中的MyObj被修改的情况
* @param id
* @return
*/
public MyObj getById(String id){
ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
MyObj myObj;
//要读缓存,先上读锁。
//因为只是取出来缓存并返回,所以本来也可以不上锁的,
//但是因为HashMap非线程安全,在无锁情况下容易导致并发安全问题,所以还是需要上锁
readLock.lock();
try {
myObj = cacheMap.get(id);
if (myObj != null) {
//缓存命中,在finally中释放读锁
return myObj;
}
}finally {
readLock.unlock();
}
ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
//大家竞争写锁
writeLock.lock();
try {
//先来个经典的double check
myObj = cacheMap.get(id);
if (myObj != null) {
return myObj;
}
myObj = myDao.getById(id);
cacheMap.put(id, myObj);
return myObj;
}finally {
writeLock.unlock();
}
}
/**
* 经典的缓存读时写入操作,
* 与ReentrantReadWriteLock中给出的CacheObject的写法是一样的,只是use变成了一个回调接口,AfterGet。
* 可以保证缓存在使用期间线程安全,但是会产生回调地狱。
* @param id
* @param afterGet
*/
public void getById(String id,IAfterGet afterGet){
ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
//读缓存,先上读锁
readLock.lock();
try {
MyObj myObj = cacheMap.get(id);
if (myObj == null) {
//缓存未命中,释放读锁
readLock.unlock();
ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
//开始写缓存,上写锁
writeLock.lock();
try {
//二次校验
myObj = cacheMap.get(id);
if (myObj == null) {
//如果缓存中确实没有数据,那就从数据库里读一下
myObj = myDao.getById(id);
}
//到这里,写操作完毕,为了保证此时的myObj对象线程安全,
//即缓存里当前id对应的对象与当前的myObj对象是同一个对象
//这里需要上读锁
readLock.lock();
}finally {
writeLock.unlock();
}
}
//缓存使用期线程安全,别的写操作需要等待当前的读操作完毕后,再执行
afterGet.afterGet(myObj);
}finally {
readLock.unlock();
}
}
/**
* 缓存资源修改
*/
public void save(MyObj myObj){
ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
//修改缓存需要上写锁,避免并发读写
writeLock.lock();
try {
//先写数据库
myDao.save(myObj);
//缓存失效
cacheMap.remove(myObj.getId());
}finally {
writeLock.unlock();
}
}
public void delete(String id){
ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
writeLock.lock();
try {
myDao.delete(id);
cacheMap.remove(id);
}finally {
writeLock.unlock();
}
}
}
- 在单机环境下为了同时兼顾线程安全与效率,我们需要使用读写锁
ReentrantReadWriteLock
来管理缓存。 - 使用读写锁后,读读并发,读写互斥,写写互斥。
- 当缓存对象被修改时,先调用数据库操作,然后从缓存中删除对应的缓存对象。
- 读取对象时,先从缓存中读取,如果缓存中没有,再从数据库中读取。
分布式环境
当我们处于分布式环境下时,该如何处理数据库与缓存的一致性问题呢?其实还是读写锁,只不过这次我们需要使用的是分布式读写锁
这里的分布式读写锁都是基于单节点的redis实现的,也就是都是使用的Redisson。
在分布式环境下缓存的存储还分为两种情况:
分布式缓存在redis中
缓存读取流程图
当我们把缓存存储在redis中时,我们需要考虑的东西就多了很多。相应的,缓存的读取流程也会变得更复杂。
- 从redis中获取缓存后,直接返回对象,整体业务流程概要信息如下:
- 由于缓存是存储在redis中的,所以缓存只能由一个节点负责重建,不能由多个节点同时重建,所以会存在一把缓存重建锁。
- 通过写锁使读库+写缓存具备原子性
- 缓存重建锁与分布式读写锁不是同一个锁,这样的处理有两个好处,一方面锁的原则更清晰,另一方面可以提高并行度。
- 获取到缓存对象后不直接返回,通过回调器的方式直接缓存使用缓存对。在缓存被使用期间内读写互斥。大体流程如下图所示:
- 因为要在回调器中使用缓存对象,所以需要保证缓存在使用期间内并发安全。所以需要先上读锁,在从缓存中get数据。
- 如果缓存中没有数据,在重建缓存的二次校验中依然需要先加读锁,再读缓存,如果有数据,就可以直接释放重建锁后通过
callBack
来使用缓存数据了。
代码实现
/**
* 从redis中获取缓存数据的Service实现
*/
@Component
public class MyRedisCacheImpl implements MyService {
@Autowired
private MyDao myDao;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private RedissonClient redissonClient;
@Autowired
private ObjectMapper objectMapper;
/**
* 空缓存内容
*/
private static final String EMPTY_CACHE_STR = "{}";
/**
* 有效期5天
*/
private static final int MYOBJ_CACHE_TIMEOUT = 60 * 60 * 24 * 5;
private static final String MYOBJ_KEY_PREFIX = "myobj:id:";
/**
* 缓存重建锁前缀
*/
private static final String LOCK_CACHE_REBUILD_MYOBJ_PREFIX = "lock:cache_rebuild:myobj:";
/**
* 数据库操作锁前缀
*/
private static final String LOCK_DB_OPERATE_MYOBJ_PREFIX = "lock:db_operate:myobj:";
@Override
public MyObj getById(String id) {
//这里不需要上读锁是因为取出来直接返回了
//而且redis本身也是并发安全的
MyObj myObj = getFromRedis(id);
if (myObj != null) {
return myObj;
}
//cacheRebuildLock的作用是保证在多节点情况下,只有一个节点可以执行缓存重建的操作
RLock cacheRebuildLock = redissonClient.getLock(LOCK_CACHE_REBUILD_MYOBJ_PREFIX + id);
cacheRebuildLock.lock();
try {
//二次校验
myObj = getFromRedis(id);
if (myObj != null) {
return myObj;
}
//dbOperateLock才是保证读写一致性的
RReadWriteLock dbOperateLock = redissonClient.getReadWriteLock(LOCK_DB_OPERATE_MYOBJ_PREFIX + id);
dbOperateLock.writeLock().lock();
try {
myObj = myDao.getById(id);
writeData2Redis(id, myObj);
} finally {
dbOperateLock.writeLock().unlock();
}
} finally {
cacheRebuildLock.unlock();
}
return myObj;
}
private void writeData2Redis(String id, MyObj myObj) {
if (myObj == null) {
stringRedisTemplate.opsForValue().set(MYOBJ_KEY_PREFIX + id, EMPTY_CACHE_STR, getNullDataCacheTimeOut(), TimeUnit.SECONDS);
} else {
try {
String myObjStr = objectMapper.writeValueAsString(myObj);
stringRedisTemplate.opsForValue().set(MYOBJ_KEY_PREFIX + id, myObjStr, getMyObjCacheTimeout(), TimeUnit.SECONDS);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
/**
* 无效数据24小时内失效
*
* @return
*/
public int getNullDataCacheTimeOut() {
return ThreadLocalRandom.current().nextInt(24) * 60 * 60;
}
/**
* 获得缓存的失效时间
*
* @return 标准失效时间+随机5以内天数
*/
public int getMyObjCacheTimeout() {
return MYOBJ_CACHE_TIMEOUT + ThreadLocalRandom.current().nextInt(5) * 60 * 60 * 24;
}
/**
* 从redis中读取数据同时为缓存更新续命时间
*
* @param id
* @return
*/
private MyObj getFromRedis(String id) {
String redisKey = MYOBJ_KEY_PREFIX + id;
String myObjStr = stringRedisTemplate.opsForValue().get(redisKey);
if (!StringUtils.hasText(myObjStr)) {
return null;
}
if (EMPTY_CACHE_STR.equals(myObjStr)) {
//无效数据续命
stringRedisTemplate.expire(redisKey, getNullDataCacheTimeOut(), TimeUnit.SECONDS);
MyObj myObj = new MyObj();
myObj.setId(id);
return myObj;
}
try {
//有效数据续命
stringRedisTemplate.expire(redisKey, getMyObjCacheTimeout(), TimeUnit.SECONDS);
return objectMapper.readValue(myObjStr, MyObj.class);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
@Override
public void getById(String id, IAfterGet afterGet) {
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(LOCK_DB_OPERATE_MYOBJ_PREFIX + id);
//先上读锁,因为要在afterGet中使用数据,所以需要进行保护
RLock readLock = readWriteLock.readLock();
readLock.lock();
try {
MyObj myObj = getFromRedis(id);
if (myObj == null) {
readLock.unlock();
//缓存中没有说明需要重建
RLock cacheRebuildLock = redissonClient.getLock(LOCK_CACHE_REBUILD_MYOBJ_PREFIX + id);
cacheRebuildLock.lock();
try {
//缓存重建中的二次校验
readLock.lock();
myObj = getFromRedis(id);
if (myObj == null) {
readLock.unlock();
//dbOperateLock才是保证读写一致性问题的
RReadWriteLock dbOperateLock = redissonClient.getReadWriteLock(LOCK_DB_OPERATE_MYOBJ_PREFIX + id);
dbOperateLock.writeLock().lock();
try {
//此时加锁成功应该说明没有其他的服务或者线程可以操作数据库了
myObj = myDao.getById(id);
writeData2Redis(id, myObj);
readLock.lock();
} finally {
dbOperateLock.writeLock().unlock();
}
}
}finally {
cacheRebuildLock.unlock();
}
}
//在ReadLock的包裹下执行业务逻辑
afterGet.afterGet(myObj);
}finally {
readLock.unlock();
}
}
@Override
public void save(MyObj myObj) {
RReadWriteLock dbOperateLock = redissonClient.getReadWriteLock(LOCK_DB_OPERATE_MYOBJ_PREFIX + myObj.getId());
dbOperateLock.writeLock().lock();
try {
myDao.save(myObj);
stringRedisTemplate.delete(MYOBJ_KEY_PREFIX + myObj.getId());
} finally {
dbOperateLock.writeLock().unlock();
}
}
@Override
public void delete(String id) {
RReadWriteLock dbOperateLock = redissonClient.getReadWriteLock(LOCK_DB_OPERATE_MYOBJ_PREFIX + id);
dbOperateLock.writeLock().lock();
try {
myDao.delete(id);
stringRedisTemplate.delete(MYOBJ_KEY_PREFIX + id);
} finally {
dbOperateLock.writeLock().unlock();
}
}
}
分布式缓存在jvm中
在网上的很多资料中都是把redis作为缓存来使用的,但是将redis设置为缓存依旧存在一些问题。很多时候我们使用缓存的目的是为了提高系统的响应速度,但是如果使用redis作为缓存存储介质的话,每次访问缓存数据必然至少访问一次redis。所以如果在响应速度优先的场景下,还是jvm的本地缓存效率更高。但是,同时我们必须解决多个服务间缓存同步的问题。
也就是说,我们需要在A(Availability可用性)P和C(Consistency一致性)P中做选择。
在缓存同步的方案上实现CP的难度比较高,所以我就选择了基于AP的实现方案。也就是说,最终我们需要满足BASE理论:
- 基本可用 Basically Available
- 软状态 Soft-state
- 最终一致性 Eventually Consistent
BASE的主要思想是:我们无法做到强一致,但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性
缓存通知机制
由于缓存是在本地的,所以当缓存发生变化时需要让其他服务知道缓存已经变化。所以我们需要一定的通知机制。所以我们需要在缓存移除时对外发出消息。由于之前使用Redisson我们已经引入了Redis,所以简单起见我们可以依赖Redis的发布/订阅
机制来实现缓存删除的通知机制。
代码实现
MyDistributedLocalCacheServiceImpl
与之前的LocalCache版本相比,缓存存储介质由简单的HashMap替换成了一个Bean
MyCache
,继承自HashMap,在实现存储功能的同时增加了发送消息的能力。通过读写锁保证HashMap的并发安全。
RedisMessageSender
,将消息基于Redis发送出去。面型接口编程的好处就是将来有需要的话,我们可以实现其他的Sender。
- 在
CacheConfig
中装配一个MyCache
的bean
- 创建一个
RedisMessageListener
,监听来自Redis订阅的消息。将消息体反序列化为对象,然后进行本地的缓存清除。
- 创建Redis订阅容器,不同的缓存组使用不同的通道进行订阅,只订阅本服务可以收集到的缓存资源。
- 测试用例,在测试用例中我们一共执行3次读取,一次保存。
- 执行结果
- 完整的源码地址:CacheSpringBootMain
思考
理论上我们的缓存确实可以实现同步了,但是在偶发情况下依旧可能存在多个服务间缓存不一致的问题:
- 服务A的messageSender发送消息时数据库事物还未提交
- 服务B接收到缓存同步的消息后删除了本地缓存
- 服务B由于业务需要开始重建缓存,由于服务A尚未提交事物,导致服务B读取到的数据依旧是旧数据
- 服务A业务逻辑执行完毕,提交事物
- 服务A与服务B出现缓存不一致问题
解决办法
实际上缓存同步的消息应该在数据库事物提交后发出,这样可以保证其他服务在刷新缓存时一定可以拉取到最新的数据。