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挂了,本地缓存还能顶一阵
  • 限流降级:数据库扛不住时,直接返回旧数据或者错误提示
我曾经踩过的坑:在一个冷链监控系统里,所有传感器数据的缓存过期时间都设成了整点过期。结果每到整点,数据库CPU直接飙到100%,冷藏车温度数据延迟了5分钟才更新。后来改成「基础时间+随机偏移」,再也没出过问题。

一致性哈希:缓存节点怎么扩缩容

当你有多个Redis实例时,怎么决定一个key存在哪个节点上?最简单的做法是取模:hash(key) % N。但问题来了——增加或减少一个节点,N变了,几乎所有key的映射关系都变了。大量缓存失效,这就是「缓存雪崩」的另一种形式。

一致性哈希就是来解决这个问题的。它的核心思想:把哈希值空间组织成一个环,每个节点在环上占据一个位置。key的哈希值落在环上,顺时针找到的第一个节点就是目标节点。

一致性哈希环示意图 A Node A B Node B C Node C A1 B1 C1 Key1 Key2 Key顺时针找到第一个节点,增加节点只影响相邻区域

你看上图,Key1落在A和B之间,顺时针找到A。Key2落在C和A之间,顺时针找到C。如果增加一个节点D,只影响D和它逆时针方向邻居之间的key,其他key不受影响。

在C语言里实现一致性哈希,我一般用 MD5CRC32 做哈希函数,然后用一个排序数组存储所有节点位置。查找时用二分查找,时间复杂度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;
}
我的建议:虚拟节点数量一般设150~200个。太少会导致数据分布不均,太多会浪费内存。我在一个128MB内存的设备上,设了200个虚拟节点,效果很好。

总结一下

缓存策略这东西,没有银弹。本地缓存快但容量小,Redis容量大但有网络开销。穿透、击穿、雪崩这三个问题,你只要做缓存就一定会遇到。我的经验是:先想好降级方案,再上线缓存

一致性哈希是分布式缓存的基石。你想想看,如果哪天要扩容,取模方案得迁移多少数据?一致性哈希就优雅得多。

最后送大家一句话:缓存是手段,不是目的。别为了用缓存而用缓存,搞清楚你的数据访问模式,再选合适的策略。

核心要点回顾:
  • 本地缓存用哈希表,记得设过期时间
  • Redis在嵌入式环境也能跑,控制内存预算
  • 缓存穿透用布隆过滤器或空值缓存
  • 缓存击穿用互斥锁+双重检查
  • 缓存雪崩用随机过期+本地兜底+限流
  • 一致性哈希用虚拟节点+二分查找

公众号:蓝海资料掘金营,微信deep3321