缓存穿透
缓存穿透是指查询缓存和数据库中都不存在的数据,导致所有的查询压力全部给到了数据库。
问题复现
比如查询一个学生信息并对其进行缓存,一般的逻辑是先查询缓存中是否存在该学生信息,如果存在则直接返回,否则再查询数据库并将查询结果进行缓存:
int TestProblem()
{
if (!initRedisAndDB())
return -1;
int id = 8;
std::string tableName = "student";
std::string key = tableName + "_id_" + std::to_string(id);
std::string info;
// 模拟客户端发送5次查询key的请求
for (int i=0; i<5; i++)
{
// redis存在key,则获取value
if (g_redis->exists(key))
{
g_redis->getValue(key, info);
if (info.empty())
{
printf("==== select from cache , data is empty ====\n");
}
}
else
{
// redis不存在key,查询数据库
printf("==== select from db ====\n");
std::string queryDB = queryInfoFromDB(table, id);
if (!queryDB.empty())
{
// 写回redis
g_redis->setValue(key, queryDB);
printf("get info from db success, info = %s, id = %d\n", queryDB.c_str(), id);
}
}
}
finiRedisAndDB();
return 0;
}
运行结果:
==== select from db ====
==== select from db ====
==== select from db ====
==== select from db ====
==== select from db ====
解决方案一:缓存空对象
针对缓存穿透问题,缓存空对象可以有效避免所产生的影响:当查询一条不存在的数据时,在缓存中存储一个空对象并设置一个过期时间(设置过期时间是为了避免出现数据库中存在了数据但是缓存中仍然是空数据现象),这样可以避免所有请求全部查询数据库的情况。
int TestSlove1CacheNullValue()
{
if (!initRedisAndDB())
return -1;
int id = 8;
std::string tableName = "student";
std::string key = tableName + "_id_" + std::to_string(id);
std::string info;
for (int i=0; i<5; i++)
{
// redis存在key,则获取value
if (g_redis->exists(key))
{
g_redis->getValue(key, info);
if (info.empty())
{
printf("==== select from cache , data is empty ====\n");
}
}
else
{
// redis不存在key,查询数据库
printf("==== select from db ====\n");
std::string queryDB = queryInfoFromDB(table, id);
// 查到该数据,写回redis;如果没查到该数据,将空值写回redis
g_redis->setValue(key, queryDB);
//printf("get info from db success, info = %s, id = %d\n", queryDB.c_str(), id);
}
}
finiRedisAndDB();
return 0;
}
运行结果:
==== select from db ====
==== select from cache , data is empty ====
==== select from cache , data is empty ====
==== select from cache , data is empty ====
==== select from cache , data is empty ====
缺点
- 无论数据存不存在都需要查询一次数据库,并且redis中存储了大量的空数据。
- 缓存层和数据库层的数据可能会有一段时间不一致,可能会对业务有一定影响。
例如过期时间设置为5分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致。这时候可以利用消息队列或者其它异步方式清理缓存中的空对象。
解决方案二:布隆过滤器
针对缓存空对象的缺陷,我们可以在查找缓存之前,加一个布隆过滤器,如果布隆过滤器没有找打该key,则直接返回。
什么是布隆过滤器
布隆过滤器是一个连续的 bit 数组,数组的每个元素都是一个 bit 位,即0
或者1
, 来标识数据是否存在。
存储数据的时时候,使用 K 个不同的哈希函数将这个 key 映射为 bit 数组的 K 个下标,把下标所在位置设为1:
如果这些下标的值都为1,表示该 key 可能存在(存在hash冲突的原因);如果某一位为0,则该值一定不存在。
/**
* 布隆过滤器添加元素伪代码
*/
BitArr* bit = new BitArr[10000]; // 新建一个二进制数组
ist<string> insertData = {"A", "B", "C"}; // 待添加元素
for (auto insertDatum : insertData) {
for (int i=1;i<=3;i++){ // 使用3中hash算法计算出3个数组下标
int bitIdx = hash_i(insertDatum); //hash1(insertDatum),hash2(insertDatum),hash3(insertDatum)
bit[bitIdx]=1; // 将下标元素置为1
}
}
/**
* 布隆过滤器查找元素伪代码
*/
BitArr* bit = new BitArr[10000];
for (int i=1; i<=3; i++){
int bitIdx = hash_i("E"); //计算E的数组下标
if(bit[bitIdx] == 0){ //如果对应的元素为0,则一定不存在
return false;
}
}
return true;
布隆过滤器的设计
在使用布隆过滤器时有两个核心参数,分别是预估的数据量 size 以及期望的误判率 fpp,这两个参数我们可以根据自己的业务场景和数据量进行自主设置。
在实现布隆过滤器时,有两个核心问题,分别是 hash 函数的选取个数 n 以及确定 bit 数组的大小 len。
- 根据预估数据量size和误判率fpp,可以计算出bit数组的大小len。
- 根据预估数据量size和bit数组的长度大小len,可以计算出所需要的hash函数个数n。
单机版布隆过滤器
目前单机版的布隆过滤器实现方式有很多,比如Guava提供的BloomFilter,Hutool工具包中提供的BitMapBloomFilter等。
分布式版布隆过滤器
我们可以使用redis官方提供的布隆过滤器,安装教程参见:redisBloom安装教程。
下面是模拟使用布隆过滤器解决缓存穿透的代码:
int TestSlove1BloomFilter()
{
if (!initRedisAndDB())
return -1;
// 先将所有的key同步到布隆过滤器中
std::string filter = "filter1";
std::string tableName = "student";
for (int i=1; i<5; i++)
{
std::string keyTmp = tableName + "_id_" + std::to_string(i);
g_redis->putBloomFilterRedis(filter, keyTmp);
}
for (int id=4; id<9; id++)
{
std::string key = tableName + "_id_" + std::to_string(id);
// 布隆过滤器中是否有可能存在这个key
bool b = g_redis->existBloomFilterRedis(filter, key);
if(!b){
// 如果不存在,直接返回空
printf("==== select from bloomFilter , not find ====\n");
continue;
}
// redis存在key,直接获取value
std::string info;
g_redis->getValue(key, info);
if (!info.empty())
{
printf("==== select from cache success ====\n");
}
else
{
// redis不存在key,查询数据库
printf("==== select from db ====\n");
std::string queryDB = queryInfoFromDB(table, id);
// 查到该数据,写回redis
if (!queryDB.empty())
{
g_redis->setValue(key, queryDB);
}
}
}
finiRedisAndDB();
return 0;
}
运行结果:
==== select from db ====
==== select from bloomFilter , not find ====
==== select from bloomFilter , not find ====
==== select from bloomFilter , not find ====
==== select from bloomFilter , not find ====
缺点
虽然布隆过滤器可以有效的解决缓存穿透问题,并且实现的算法查找效率也很快。但是,也存在一定的缺点:
- 由于存在hash冲突的原因,过滤器存在一定的误判率(某个在过滤器中并不存在的key,但是通过hash计算出来的下标值都为1)。
- 布隆过滤器的删除比较困难(如果将一个数组位置为0,那么这个位置有可能也代表其他key的值,会影响到其他的key)。
缓存击穿
缓存击穿是指访问某个热点key时,缓存中并不存在该数据或者缓存过期了,而且重建这个缓存比较耗时,因此,这个时候全部的请求都打到了数据库。
问题复现
下面是模拟缓存击穿的代码:
int TestProblem()
{
if (!initRedisAndDB())
return -1;
int id = 4;
std::string tableName = "student";
std::string key = tableName + "_id_" + std::to_string(id);
std::string info;
// 模拟客户端发送5次查询key的请求
for (int i=0; i<5; i++)
{
// redis存在key,则获取value
if (g_redis->exists(key))
{
g_redis->getValue(key, info);
if (info.empty())
{
printf("==== select from cache , data is empty ====\n");
}
}
else
{
// redis不存在key,查询数据库
printf("==== select from db ====\n");
std::string queryDB = queryInfoFromDB(table, id);
if (!queryDB.empty())
{
// 写回redis.非常耗时,这里用注释来代表耗时
// g_redis->setValue(key, queryDB);
printf("get info from db success, info = %s, id = %d\n", queryDB.c_str(), id);
}
}
}
finiRedisAndDB();
return 0;
}
运行结果:
==== select from db ====
get info from db success, info = {"age":19,"id":4,"name":"liming"}, id = 4
==== select from db ====
get info from db success, info = {"age":19,"id":4,"name":"liming"}, id = 4
==== select from db ====
get info from db success, info = {"age":19,"id":4,"name":"liming"}, id = 4
==== select from db ====
get info from db success, info = {"age":19,"id":4,"name":"liming"}, id = 4
==== select from db ====
get info from db success, info = {"age":19,"id":4,"name":"liming"}, id = 4
解决方案一:热点数据不设置过期时间
热点数据不设置过期时间,当后台更新热点数据数需要同步更新缓存中的数据,这种解决方式适用于不严格要求缓存一致性的场景。
int SetNoExpire()
{
if (!initRedisAndDB())
return -1;
int id = 4;
std::string tableName = "student";
std::string key = tableName + "_id_" + std::to_string(id);
std::string info;
// 模拟客户端发送5次查询key的请求
for (int i=0; i<5; i++)
{
// redis存在key,则获取value
if (g_redis->exists(key))
{
g_redis->getValue(key, info);
if (info.empty())
{
printf("==== select from cache , data is empty ====\n");
}
else
{
printf("==== select from cache , get data success ====\n");
}
}
else
{
// redis不存在key,查询数据库
printf("==== select from db ====\n");
std::string queryDB = queryInfoFromDB(table, id);
if (!queryDB.empty())
{
// 写回redis,设置key永不过期
g_redis->setValue(key, queryDB);
printf("get info from db success, info = %s, id = %d\n", queryDB.c_str(), id);
}
}
}
finiRedisAndDB();
return 0;
}
运行结果:
==== select from cache , get data success ====
==== select from cache , get data success ====
==== select from cache , get data success ====
==== select from cache , get data success ====
==== select from cache , get data success ====
解决方案二:使用互斥锁
如果是单机部署的环境下可以使用 synchronized 或 lock 来处理,保证同时只能有一个线程来查询数据库,其他线程可以等待数据缓存成功后在被唤醒,从而直接查询缓存即可。
如果是分布式部署,可以采用分布式锁来实现互斥。
// 获取一个redis分布式锁
bool Redis::lock(std::string lockKey, std::string lockVal, int expire_time) {
while(1) {
// 使用SET命令设置key-value-NX-EX
redisReply *reply = (redisReply*)redisCommand(m_redisConn, "SET %s %s NX EX %d", lockKey.c_str(), lockVal.c_str(), expire_time);
// 判断返回结果是否为OK
if (reply != nullptr && reply->type== REDIS_REPLY_STATUS && reply->str!=nullptr && std::string(reply->str).compare("OK")==0)
{
freeReplyObject(reply);
return true;
}
else if (reply != nullptr)
{
freeReplyObject(reply);
}
}
return false;
}
// 释放一个redis分布式锁
void Redis::unlock(std::string lockKey, std::string lockVal) {
char * script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redisReply* reply = (redisReply*)redisCommand(m_redisConn, "EVAL %s 1 %s %s", script, lockKey.c_str(), lockVal.c_str());
if (reply != NULL && reply->type == REDIS_REPLY_INTEGER && reply->integer == 1)
{
//printf("get lock and unlock sucess.\n");
}
else {
printf("get lock fail.\n");
}
freeReplyObject(reply);
}
void workThread(std::string redisKey, std::string lockKey, std::string lockVal, uint64_t timeout,
Redis* pRedis, MySQL* pMysql, std::string tableName, int id)
{
std::string info;
pRedis->getValue(redisKey, info);
if (!info.empty())
{
printf("==== select from cache , get data success ====\n");
}
else
{
bool lockFlag = pRedis->lock(lockKey, lockVal, timeout); //尝试加锁
// 如果加锁成功,先再次查询缓存,有可能上一个线程查询并添加到缓存了
if (lockFlag)
{
std::string retValue;
pRedis->getValue(redisKey, retValue);
if (!retValue.empty())
{
printf("==== select from cache, get data sucess! ====\n");
}
else
{
printf("==== select from db ====\n");
std::string queryDB = queryInfoFromDB(table, id, pMysql);
if (!queryDB.empty())
{
// 写回redis,设置key永不过期
pRedis->setValue(redisKey, queryDB);
printf("get info from db success, info = %s, id = %d\n", queryDB.c_str(), id);
}
}
pRedis->unlock(lockKey, lockVal);
}
}
}
/* ================================= solve2 : UseMutex ===========================================*/
int UseMutex()
{
initRedisAndDBs(4);
std::string redisKey = "student_id_4";
std::string tableName = "student";
int id = 4;
struct timespec time = {0, 0};
clock_gettime(CLOCK_REALTIME, &time);
std::string lockVal = std::to_string(time.tv_nsec);
std::string lockKey = "redis.lock";
uint64_t timeout = 300;
// 多个客户端同时查询 key 的情况
std::vector<std::thread> threads(10);
for (int i=0; i<mysqls.size(); i++)
{
threads[i] = std::thread(workThread, redisKey, lockKey, lockVal, timeout, redises[i], mysqls[i], tableName, id);
// 为了模拟出第一个线程成功回写缓存后,其他线程直接访问缓存现象
std::this_thread::sleep_for(std::chrono::milliseconds(500));
}
for (int i=0; i<mysqls.size(); i++)
{
threads[i].join();
}
finiRedisAndDBs();
return 0;
}
int main()
{
UseMutex();
return 0;
}
// 运行结果:
==== select from db ====
get info from db success, info = {"age":19,"id":4,"name":"liming"}, id = 4
==== select from cache , get data success ====
==== select from cache , get data success ====
==== select from cache , get data success ====
运行结果表明,第一个线程获得锁之后,将数据库内容回写到缓存,等到其他线程获取到锁后,先查看缓存,发现缓存里已经有数据了,就不会再访问数据库了。从而避免全部的请求都打到数据库。
缓存雪崩
缓存雪崩是指对热点key设置了相同的过期时间,在同一时间这些热点key大批量发生过期,而且重建这些缓存比较耗时,请求全部转发到数据库,从而导致数据库压力骤增,甚至宕机。
与缓存击穿不同的是,缓存击穿是单个热点key过期,而缓存雪崩是大批量热点key过期。
针对缓存雪崩问题,常见的解决方案有多种:比如设置随机的过期时间或者不设置过期时间、搭建高可用的缓存架构避免redis服务宕机、服务降级等。
解决方案一:设置随机的过期时间
将key的过期时间后面加上一个随机数,这个随机数值的范围可以根据自己的业务情况自行设定,这样可以让key均匀的失效,避免大批量的同时失效。
/* ================================= svolve1 : set random expire time ===========================================*/
void setRandomExpireTime(std::string redisKey)
{
// 为了rand()每次生成不同的数
srand((unsigned)time(NULL));
// 生成[a, b]范围的随机数,使用 rand()%(b-a+1) + a
int rd = rand()%(10-1+1) + 10; //生成一个[1, 10]内的一个随机数
g_redis->setExpireTime(redisKey, 5 + rd); //设置key的过期时间为 5+rd
}
解决方案二:不设置过期时间
不设置过期时间时,需要注意的是,在更新数据库数据时,同时也需要更新缓存数据,否则数据会出现不一致的情况。这种方式比较适用于不严格要求缓存一致性的场景。
/* ================================= svolve2 : not set expire time ===========================================*/
void notSetExpireTime(std::string redisKey, std::string redisVal)
{
// set key value 命令默认不设置过期时间
g_redis->setValue(redisKey, redisVal);
}
数据一致性
通常情况下,使用缓存的直接目的是为了提高系统的查询效率,减轻数据库的压力。经典的读写模式如下:读的时候,先读缓存,缓存没有的话,那么就读数据库,然后取出数据后放入缓存,同时返回响应。
这么搞好像看上去并没有啥问题,那么会有一个细节问题:当一条数据存入缓存后,数据库中的这条数据立刻又被修改了,那么这个时候缓存该如何更新呢。不更新肯定不行,这样导致了缓存中的数据与数据库中的数据不一致。一般情况下对于缓存更新有下面这几种情况:
- 先更新缓存,再更新数据库
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
情形1.先更新缓存,再更新数据库
先更新缓存,再更新数据库这种情况下,如果业务执行正常,不出现网络等问题,这么操作不会有啥问题,两边都可以更新成功。
但是,如果缓存更新成功了,但是当更新数据库时或者在更新数据库之前出现了异常,导致数据库无法更新。这种情况下,导致缓存和数据库中的数据不一致。
情形2.先更新数据库,再更新缓存
这种情况跟情形1基本一致,如果更新缓存失败,会导致数据库中是最新的数据,缓存中是旧数据。
情形3.先删除缓存,再更新数据库
先删除缓存,再更新数据库这种情况,如果并发量不大用起来不会有啥问题。
但是在并发场景下会有这样的问题:线程A在删除缓存后,在写入数据库前发生了阻塞。这时线程B查询了这条数据,发现缓存中不存在,继而向数据库发起查询请求,并将查询结果缓存到了redis。当线程B执行完成后,线程A继续向下执行更新了数据库,那么这时缓存中的数据为旧数据,与数据库中的值不一致。
情形4.先更新数据库,再删除缓存
先更新数据库,再删除缓存也并不是绝对安全的。在高并发场景下:
1、如果线程A查询一条在缓存中不存在的数据(这条数据有可能过期被删除了),查询数据库后,再将查询结果缓存到redis时发生了阻塞。
2、这个时候线程B发起了更新数据请求,先更新了数据库,再次删除了缓存。
3、线程A继续向下执行,将查询结果缓存到了redis中,那么此时缓存中的数据与数据库中的数据发生了不一致。
解决数据不一致方案
延时双删
延时双删,即在写数据库之前删除一次缓存,写完数据库后,再删除一次缓存,在第二次删除时,并不是立即删除,而是等待一定时间在做删除。
具体的步骤就是:
(1)先删除缓存;
(2)再写数据库;
(3)休眠X时间;
(4)再次删除缓存。
那么,这个X时间怎么确定的,具体该休眠多久呢?
需要评估自己的项目的读数据业务逻辑的耗时。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
当然这种策略还要考虑redis和数据库主从同步的耗时。最后的的写数据的休眠时间:则在读数据业务逻辑的耗时基础上,加几百ms即可。比如:休眠1秒。
该方案弊端:可能在延时的这段时间出现缓存跟数据库不一致的情况。
/*
* 1. 创建3个线程,线程1更新数据库,线程2、3读取缓存
* 2. 线程1加锁,删除缓存
* 3. 线程1更新数据库
* 4. 线程1休眠 x 秒
* 5. 线程1再次删除缓存
*/
void updateDB(std::string redisKey, Redis* pRedis, MySQL* pMysql, std::string tableName, int id, std::string newName, uint64_t delayTime)
{
// 不加锁,直接删除缓存
pRedis->delValue(redisKey);
printf("del redis first time!\n");
// 更新数据库
char sql[1024] = {0};
sprintf(sql, "update student set name = '%s' where id = %d", newName.c_str(), id);
pMysql->update(sql);
printf("update database!\n");
// 延迟删除缓存
std::this_thread::sleep_for(std::chrono::milliseconds(delayTime));
pRedis->delValue(redisKey);
printf("del redis second time!\n");
}
void searchData(std::string redisKey, Redis* pRedis, MySQL* pMysql, std::string tableName, int id)
{
// redis存在key,则获取value
std::string info;
pRedis->getValue(redisKey, info);
if (!info.empty())
{
printf("==== select from redis, data : %s ====\n", info.c_str());
}
else
{
// redis不存在key,查询数据库
std::string queryDB = queryInfoFromDB(table, id, pMysql);
if (!queryDB.empty())
{
// 写回redis
pRedis->setValue(redisKey, queryDB);
printf("==== search db and write to redis success, data = %s. ====\n", queryDB.c_str());
}
}
}
int main()
{
initRedisAndDBs(5);
int id = 4;
std::string tableName = "student";
std::string redisKey = tableName + "_id_" + std::to_string(id);
std::string newName = "wwwwww";
uint64_t delayTime = 60;
std::vector<std::thread> threads(10);
threads[0] = std::thread(updateDB, redisKey, redises[0], mysqls[0], tableName, id, newName, delayTime);
for (int i=1; i<mysqls.size(); i++)
{
threads[i] = std::thread(searchData, redisKey, redises[i], mysqls[i], tableName, id);
std::this_thread::sleep_for(std::chrono::milliseconds(30));
}
for (int i=0; i<mysqls.size(); i++)
{
threads[i].join();
}
finiRedisAndDBs();
return 0;
}
运行结果:
del redis first time!
==== search db and write to redis success, data = {"age":19,"id":4,"name":"kkkkkk"}. ====
update database!
==== select from redis, data : {"age":19,"id":4,"name":"kkkkkk"} ====
==== select from redis, data : {"age":19,"id":4,"name":"kkkkkk"} ====
del redis second time!
==== search db and write to redis success, data = {"age":19,"id":4,"name":"wwwwww"}. ====
更新student表中id=4的数据,oldName="kkkkk", newName="wwwwww"。
运行结果中看出,线程1删除缓存后,线程2读取redis失败,转而读取数据库的oldData并写回redis,线程1然后用newData更新了数据库,线程3和线程4读取redis中oldData,然后线程1第二次删除缓存,线程5读取redis失败,转而读取数据库的newData并写回redis.这是,redis中的数据和db中的数据是一致的。
引用文献:
Redis缓存穿透/击穿/雪崩以及数据一致性的解决方案 - 掘金