一、在 NebulaChat 里,Redis 到底在干什么
项目地址:https://github.com/elaysia-feng/NebulaChat.git
当前和计划中的用途:
-
短信验证码缓存
-
key:
sms:<phone> -
value: 6 位数字验证码,字符串
-
TTL: 60 秒
-
用途: 注册、短信登录、找回密码
-
-
用户信息缓存
-
key 示例
-
user:phone:<phone>→{"id":1,"username":"Elias","phone":"123..."} -
user:name:<username>→{"id":1,"username":"Elias"}
-
-
用途
-
登录时快速根据用户名 / 手机号查到用户 id
-
减少 MySQL
SELECT压力
-
-
统一思想:
-
Redis 只是加速访问的「缓存层」
-
真正的权威数据存储在 MySQL
-
写操作必须以 MySQL 为准,Redis 可以删、可以重新写
二、最核心的缓存模式:旁路缓存 Cache-Aside
1. 读缓存的流程(以手机号登录为例)
函数: AuthService::loginByPhone(phone, userId, usernameOut)
伪代码:
cpp
bool loginByPhone(const std::string& phone, int& userId, std::string& usernameOut) {
std::string key = "user:phone:" + phone;
// 1. 先查 Redis
auto redisConn = RedisPool::Instance().getConnection();
if (redisConn) {
std::string cached;
if (redisConn->get(key, cached)) {
if (cached == "null") {
// 说明 DB 里也没有这个 phone
return false;
}
json j = json::parse(cached);
userId = j.value("id", 0);
usernameOut = j.value("username", "");
if (userId > 0) return true;
}
}
// 2. Redis 未命中 → 查 MySQL
auto conn = DBPool::Instance().getConnection();
MYSQL_RES* res = conn->query(
"SELECT id, username FROM users WHERE phone = '" + phone + "' LIMIT 1");
MYSQL_ROW row = mysql_fetch_row(res);
if (!row) {
mysql_free_result(res);
// 3. DB 中也没有 → 写一个短期空缓存,防止穿透
if (redisConn) redisConn->setEX(key, "null", 30);
return false;
}
userId = std::stoi(row[0]);
usernameOut = row[1] ? row[1] : "";
mysql_free_result(res);
// 4. 将查询结果写入 Redis,设置 TTL
if (redisConn) {
json j;
j["id"] = userId;
j["username"] = usernameOut;
j["phone"] = phone;
int baseTTL = 3600;
int randDelta = RandInt(0, 600); // 随机打散
int ttl = baseTTL + randDelta;
redisConn->setEX(key, j.dump(), ttl);
}
return true;
}
要点:
-
先查 Redis,命中直接返回
-
未命中再查 DB
-
查到后写入 Redis 并设置过期时间
-
查不到时写空值
"null",防止同一个垃圾 key 反复打 DB
这就是「读的时候走缓存」的标准写法。
2. 写操作的流程(注册 / 改名 / 改密码)
写操作的关键原则:
-
必须先更新数据库,保证权威数据正确
-
缓存不要直接改,最简单可靠的姿势是: 删缓存
-
下次有读请求时,走上面的流程自动重建缓存
以修改用户名为例:
cpp
bool AuthService::updateUsername(int userId,
const std::string& newName,
std::string& oldNameOut,
std::string& phoneOut) {
auto conn = DBPool::Instance().getConnection();
// 1. 查出旧的 username 和 phone (删除缓存要用)
MYSQL_RES* res = conn->query(
"SELECT username, phone FROM users WHERE id = " + std::to_string(userId) + " LIMIT 1");
MYSQL_ROW row = mysql_fetch_row(res);
if (!row) { mysql_free_result(res); return false; }
oldNameOut = row[0] ? row[0] : "";
phoneOut = row[1] ? row[1] : "";
mysql_free_result(res);
// 2. 更新数据库
std::string sql =
"UPDATE users SET username = '" + newName + "' WHERE id = " + std::to_string(userId);
if (!conn->update(sql)) return false;
// 3. 删除相关缓存
auto redisConn = RedisPool::Instance().getConnection();
if (redisConn) {
if (!oldNameOut.empty())
redisConn->del("user:name:" + oldNameOut);
if (!phoneOut.empty())
redisConn->del("user:phone:" + phoneOut);
redisConn->del("user:id:" + std::to_string(userId));
}
return true;
}
结论:
-
读: 查缓存, 缺了再查库, 查完再写缓存
-
写: 改库, 然后删缓存, 让下次读去重建
这是「缓存更新策略」里最推荐、也是很容易做到的一种。
三、验证码缓存和业务数据缓存的区别
-
验证码
-
特点: 强时效、只用一次
-
流程:
-
发送验证码: 生成 code,
setEX sms:phone code 60 -
校验验证码:
GET sms:phone比对, 成功后DEL sms:phone
-
-
不存在「一致性问题」: 它本来就不是数据库里的持久数据
-
-
用户信息缓存
-
特点: 相对稳定, 多次使用
-
DB 中有持久存储, Redis 中只是一层加速
-
有读写顺序、空值缓存、TTL、雪崩等一堆需要考虑的问题
-
记忆点:
验证码缓存 = 轻量、一次性
用户缓存 = 正儿八经的业务缓存,需要考虑各种边界问题
四、三个核心问题: 穿透、击穿、雪崩
1. 缓存穿透
定义:
-
请求的 key 在 Redis 里没有,数据库里也没有
-
Redis 每次 miss,所有请求都直接打数据库
-
恶意用户可以利用这个特点构造大量不存在的 key
在 NebulaChat 里的例子:
-
用各种乱七八糟的手机号不停请求登录
-
用不存在的用户名反复尝试登录
解决方案:
-
参数校验
-
手机号格式不合法的直接拒绝,不去访问 Redis 和 DB
-
用户名长度、字符合法性等也可以加
-
-
缓存空值
-
某个手机号 / 用户名在 DB 中查不到时,
写入 Redis:
setEX key "null" 短 TTL -
之后同样的请求会直接命中
"null",不打 DB
-
伪代码片段:
cpp
if (!row) { // DB 查不到
mysql_free_result(res);
if (redisConn) {
int ttl = 30 + RandInt(0, 30); // 30~60 秒
redisConn->setEX(key, "null", ttl);
}
return false;
}
2. 缓存击穿
定义:
-
某个「热点 key」在某一时刻刚好过期
-
结果大量请求同时到来,全部 miss
-
一下子全打到数据库
在 NebulaChat 里的例子:
-
某个热门用户, 很多连接频繁查询他的资料
-
user:phone:xxx缓存 TTL 都是 3600 秒 -
到第 3600 秒过期时, 突然很多请求并发到达 → 同时查 DB
解决方案:
-
TTL 加随机值
-
写缓存时, TTL 不要统一相同
-
比如:
cppint base = 3600; int ttl = base + RandInt(0, 600); // 1 小时到 1 小时 10 分钟 -
把不同 key 的过期时间打散,减少同一时刻过期的概率
-
-
互斥锁重建缓存(进阶)
-
针对某个 key 在某个时刻只允许一个线程去查 DB、重建缓存
-
其他线程要么等待,要么先返回旧值
-
这个可以以后再做,不急着在 NebulaChat 里实现
-
-
逻辑过期(进阶)
-
缓存里不仅存数据,还存一个逻辑过期时间
-
读取时即使发现逻辑上过期,也可以先返回旧数据,再由后台线程异步重建
-
对实时性要求不高的场景适用
-
3. 缓存雪崩
定义:
-
大量 key 在同一时间集中失效
-
或者 Redis 整体不可用
-
导致所有请求都访问数据库,可能把 DB 打崩
在 NebulaChat 里的例子:
-
所有
user:*缓存 TTL 都是 3600 -
项目启动一小时后,很多用户的缓存同时失效
-
或者 Redis 整体挂掉,所有登录请求都绕过 Redis 打 DB
解决方案(分层次):
-
TTL 随机化
- 把不同 key 的过期时间打散,是最简单有效的第一步
-
多级缓存
-
在进程内部加一层本地缓存,例如:
-
局部
unordered_map<std::string, User> -
保存最近 N 个手机号对应的用户信息
-
读取顺序变为: 本地缓存 → Redis → MySQL
-
-
即使 Redis 出问题,本地缓存还能挡掉一部分请求
简单本地缓存示例:
cppstruct UserCacheVal { int id; std::string username; std::chrono::steady_clock::time_point expire; }; class LocalUserCacheByPhone { public: bool get(const std::string& phone, int& id, std::string& name); void put(const std::string& phone, int id, const std::string& name); void erase(const std::string& phone); private: std::unordered_map<std::string, UserCacheVal> map_; std::mutex mu_; };然后在
loginByPhone里顺序是:-
先
g_localUserCacheByPhone.get(...) -
再查 Redis
-
最后查 MySQL
-
-
降级和限流(进阶)
-
按 RedisPool 提供一个
IsDown()标记 -
当发现 Redis 长时间不可用时:
核心接口(登录)仍然允许访问 DB,非核心接口(例如未来的某些列表查询)直接返回「系统繁忙」
-
为 DB 查询加一个简单的 QPS 限流器,防止被压死
-