86、数据库缓存策略:Redis缓存、本地缓存、缓存穿透/击穿/雪崩、一致性哈希
做嵌入式数据库开发这些年,我越来越觉得——缓存不是银弹,但不用缓存是自杀。你想想看,一个嵌入式设备,CPU主频就那么点,内存就那么几兆,每次查询都去翻磁盘或者走网络,那响应时间根本扛不住。
我个人习惯,在设计数据库系统时,先把缓存层想清楚。说白了,缓存就是拿空间换时间。但怎么换、换多少、换出问题怎么兜底,这才是真功夫。
缓存到底缓存什么?
很多新手一上来就「全量缓存」,把整个表塞进内存。嗯,这在嵌入式环境里基本是找死。我见过一个项目,把10万条传感器记录全缓存在本地,结果内存直接爆了。
正确的做法是:只缓存热点数据。什么是热点?80%的请求落在20%的数据上。这20%就是你的缓存目标。
| 缓存层级 | 典型场景 | 容量 | 速度 |
|---|---|---|---|
| L1 本地缓存 | 嵌入式设备本地RAM | KB~MB级 | 纳秒级 |
| L2 Redis缓存 | 局域网内的缓存服务器 | MB~GB级 | 微秒级 |
| L3 数据库 | SQLite/MySQL等持久化存储 | GB~TB级 | 毫秒级 |
本地缓存:最直接但最容易被忽视
在嵌入式C语言开发中,本地缓存通常就是一个哈希表或者红黑树。我习惯用 uthash 这个库,轻量、无依赖,几行代码就能搭一个缓存。
#include "uthash.h"
struct cache_entry {
int key; // 数据ID
char value[64]; // 缓存数据
UT_hash_handle hh; // 哈希句柄
};
struct cache_entry *cache = NULL;
void cache_set(int key, char *value) {
struct cache_entry *entry;
HASH_FIND_INT(cache, &key, entry);
if (entry) {
strcpy(entry->value, value);
return;
}
entry = malloc(sizeof(struct cache_entry));
entry->key = key;
strcpy(entry->value, value);
HASH_ADD_INT(cache, key, entry);
}
char *cache_get(int key) {
struct cache_entry *entry;
HASH_FIND_INT(cache, &key, entry);
return entry ? entry->value : NULL;
}
time() 打时间戳,每次读取时检查是否超时,这是保命操作。
Redis缓存:嵌入式也能用
有人觉得Redis是「大厂玩具」,嵌入式用不上。其实不然。现在很多ARM Linux设备上跑Redis完全没问题。我去年做一个智能网关项目,就在一块树莓派CM4上跑了Redis,缓存了2000个设备的实时状态,响应时间从原来的120ms降到了3ms。
嵌入式环境用Redis,我建议注意三点:
- 内存预算:给Redis分配的内存不要超过系统总内存的30%,否则OOM killer会找你喝茶
- 持久化关掉:嵌入式场景下,Redis只做缓存,RDB和AOF都关掉,省IO
- 连接池:用
hiredis库时,别每次请求都新建连接,复用连接池
// 使用hiredis的简单示例
redisContext *c = redisConnect("127.0.0.1", 6379);
if (c == NULL || c->err) {
// 连不上Redis,降级到本地缓存或直接查库
return fallback_to_local_cache(key);
}
redisReply *reply = redisCommand(c, "GET %s", key);
if (reply->type == REDIS_REPLY_STRING) {
// 缓存命中
process(reply->str);
} else {
// 缓存未命中,查数据库并回写
char *val = query_database(key);
redisCommand(c, "SET %s %s", key, val);
}
freeReplyObject(reply);
redisFree(c);
缓存穿透、击穿、雪崩——三个要命的坑
这三个概念,我当年面试时被问过,后来在项目里全踩了一遍。咱们一个一个说。
缓存穿透
说白了就是:查一个肯定不存在的数据。比如用户查一个不存在的ID,缓存里没有,数据库里也没有。每次请求都穿透到数据库,要是有人恶意攻击,数据库直接被打挂。
我曾经在一个工控项目里遇到过:上位机每隔100ms查询一个已经不存在的传感器ID,数据库连接池瞬间耗尽,整个系统瘫痪了20分钟。
- 布隆过滤器:在缓存前面挡一道,判断key是否可能存在
- 缓存空值:即使数据库返回空,也缓存一个特殊标记,过期时间设短一点(比如30秒)
缓存击穿
一个热点key,在缓存过期的一瞬间,大量请求同时涌入。这些请求发现缓存没了,全部去查数据库。数据库瞬间压力爆炸。
我习惯的做法是:互斥锁。在C语言里可以用 pthread_mutex_t 或者原子操作。只有一个线程去查数据库,其他线程等待。
pthread_mutex_t cache_lock = PTHREAD_MUTEX_INITIALIZER;
char *get_hot_data(int key) {
char *val = cache_get(key);
if (val) return val; // 缓存命中,直接返回
pthread_mutex_lock(&cache_lock);
// 双重检查:拿到锁后再次检查缓存
val = cache_get(key);
if (val) {
pthread_mutex_unlock(&cache_lock);
return val;
}
// 查数据库
val = query_database(key);
cache_set(key, val);
pthread_mutex_unlock(&cache_lock);
return val;
}
缓存雪崩
大量缓存同时过期,或者Redis挂了。所有请求直接打到数据库,系统直接崩掉。
避坑指南:
- 过期时间加随机值:比如基础过期时间300秒,再加一个0~60秒的随机偏移
- 本地缓存兜底:Redis挂了,本地缓存还能顶一阵
- 限流降级:数据库扛不住时,直接返回旧数据或者错误提示
一致性哈希:缓存节点怎么扩缩容
当你有多个Redis实例时,怎么决定一个key存在哪个节点上?最简单的做法是取模:hash(key) % N。但问题来了——增加或减少一个节点,N变了,几乎所有key的映射关系都变了。大量缓存失效,这就是「缓存雪崩」的另一种形式。
一致性哈希就是来解决这个问题的。它的核心思想:把哈希值空间组织成一个环,每个节点在环上占据一个位置。key的哈希值落在环上,顺时针找到的第一个节点就是目标节点。
你看上图,Key1落在A和B之间,顺时针找到A。Key2落在C和A之间,顺时针找到C。如果增加一个节点D,只影响D和它逆时针方向邻居之间的key,其他key不受影响。
在C语言里实现一致性哈希,我一般用 MD5 或 CRC32 做哈希函数,然后用一个排序数组存储所有节点位置。查找时用二分查找,时间复杂度O(logN)。
// 一致性哈希的简化实现思路
#define MAX_NODES 256
typedef struct {
uint32_t hash; // 节点在环上的位置
char ip[16]; // 节点地址
} HashNode;
HashNode ring[MAX_NODES];
int node_count = 0;
// 添加节点(含虚拟节点)
void add_node(const char *ip, int virtual_count) {
for (int i = 0; i < virtual_count; i++) {
char key[64];
snprintf(key, sizeof(key), "%s#%d", ip, i);
ring[node_count].hash = crc32(key, strlen(key));
strcpy(ring[node_count].ip, ip);
node_count++;
}
// 按hash排序
qsort(ring, node_count, sizeof(HashNode), compare_hash);
}
// 查找key对应的节点
char *find_node(const char *key) {
uint32_t h = crc32(key, strlen(key));
// 二分查找第一个 >= h 的节点
int idx = binary_search(ring, node_count, h);
if (idx == node_count) idx = 0; // 环尾回到环头
return ring[idx].ip;
}
总结一下
缓存策略这东西,没有银弹。本地缓存快但容量小,Redis容量大但有网络开销。穿透、击穿、雪崩这三个问题,你只要做缓存就一定会遇到。我的经验是:先想好降级方案,再上线缓存。
一致性哈希是分布式缓存的基石。你想想看,如果哪天要扩容,取模方案得迁移多少数据?一致性哈希就优雅得多。
最后送大家一句话:缓存是手段,不是目的。别为了用缓存而用缓存,搞清楚你的数据访问模式,再选合适的策略。
- 本地缓存用哈希表,记得设过期时间
- Redis在嵌入式环境也能跑,控制内存预算
- 缓存穿透用布隆过滤器或空值缓存
- 缓存击穿用互斥锁+双重检查
- 缓存雪崩用随机过期+本地兜底+限流
- 一致性哈希用虚拟节点+二分查找