外观
Redis
Redis 是后端工程师绕不开的一块。前端工程师在职业发展后期,技能点自然也会延伸到缓存、会话、限流、消息队列这些场景,而这些几乎都离不开 Redis。本文从实践出发,讲清楚 Redis 是什么、怎么装、怎么用,重点放在分布式场景和踩坑经验上。
Redis
Redis(REmote DIctionary Server)是一个基于内存的键值型数据库,使用 ANSI C 编写,由 Salvatore Sanfilippo(antirez)于 2009 年开源。它常被称作"数据结构服务器"——因为它的值不仅是字符串,还可以是列表、集合、有序集合、哈希、流等复杂数据结构,并配套了大量原子操作命令。
Redis 介绍
核心特性
- 基于内存:数据主要存放在内存中,读写性能极高(单机常见 10 万 QPS 量级)。
- 单线程命令处理:命令执行单线程串行,避免锁竞争与上下文切换;6.0 起引入 IO 多线程,仅网络读写多线程,命令仍单线程执行,因此每个命令本身是原子的。
- 丰富的数据结构:String、Hash、List、Set、Sorted Set、Stream、Bitmap、HyperLogLog、Geo 等。
- 持久化:支持 RDB(快照)和 AOF(追加日志)两种方式,可单独或混合使用。
- 高可用与分片:主从复制、哨兵(Sentinel)自动故障转移、Cluster 集群分片。
- 附加能力:Lua 脚本、事务(MULTI/EXEC)、发布订阅、Keyspace 通知、模块(RedisJSON/RediSearch 等)。
数据结构总览
- String:最基础类型,可存字符串、数字、序列化二进制,最大 512MB。常用作缓存、计数器、分布式锁。
- Hash:字段-值映射,适合存对象(如用户信息),可对单个字段独立操作。
- List:按插入顺序的字符串链表,两端均可高效推入/弹出,可做简单队列。
- Set:无序、去重的字符串集合,支持交集/并集/差集运算。
- Sorted Set:带分数的有序集合,按分数排序且去重,是排行榜、延迟队列的利器。
- Stream:5.0 引入的日志型数据结构,支持消费者组,是更可靠的消息队列方案。
- Bitmap:位图,在 String 之上做位操作,适合签到、在线状态等大数据量统计。
- HyperLogLog:基数估算,固定 12KB 内存估算海量去重计数(UV)。
- Geo:基于 Sorted Set 的地理坐标操作,支持范围查询与距离计算。
Docker 安装
本地开发无需在本机装 Redis,用 Docker 一行起即可。下方命令以 Redis 7.x 为准(生产建议锁定具体版本,如 redis:7.2)。
单机:docker run
bash
docker run -d \
--name redis \
-p 6379:6379 \
-v redis-data:/data \
-e REDIS_PASSWORD=your_strong_password \
redis:7.2 \
redis-server --requirepass your_strong_password --appendonly yes1
2
3
4
5
6
7
2
3
4
5
6
7
-p 6379:6379:映射端口到宿主机。-v redis-data:/data:持久化数据卷,重启不丢数据。--requirepass:设置访问密码。--appendonly yes:开启 AOF 持久化。
推荐:docker-compose.yml
团队协作更推荐 compose,配置即文档,便于复用:
yaml
services:
redis:
image: redis:7.2
container_name: redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis-data:/data
- ./redis.conf:/usr/local/etc/redis/redis.conf
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
sysctls:
# 关闭 THP 警告,避免大 Key 时延迟抖动(生产按需)
net.core.somaxconn: 1024
volumes:
redis-data:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
最小化 redis.conf(与上同目录):
text
requirepass your_strong_password
appendonly yes
appendfsync everysec
maxmemory 512mb
maxmemory-policy allkeys-lru1
2
3
4
5
2
3
4
5
配置项说明:
requirepass:访问密码,开发环境也建议设置,养成习惯。appendonly yes:开启 AOF。appendfsync everysec:每秒刷盘,性能与安全的折中。maxmemory/maxmemory-policy:内存上限与淘汰策略,避免宿主机被打爆。
连接验证
bash
# 进入容器执行 PING
docker exec -it redis redis-cli -a your_strong_password ping
# 期望输出:PONG1
2
3
2
3
生产环境
上述 compose 仅适合本地开发。生产环境请勿裸跑单机 Redis,至少使用哨兵(Sentinel)+ 主从保证高可用,并配置定期 RDB 备份与异地存储。集群部署见官方 Cluster 方案。
图形客户端推荐
- RedisInsight:官方出品,免费,支持数据浏览、CLI、内存分析。
- Another Redis Desktop Manager:跨平台开源客户端。
- DataGrip:JetBrains 全家桶,新版本已原生支持 Redis。
- 项目推荐使用 DataGrip,与 MySQL 等数据库统一管理。
常用命令
本节按数据结构分组,每组只列最常用命令。完整命令参考官方 COMMAND DOCS。所有命令均可通过 redis-cli 交互执行:
bash
# 连接 Redis CLI:-h 指定主机地址,-p 指定端口,-a 指定密码进行身份验证
redis-cli -h 127.0.0.1 -p 6379 -a your_strong_password1
2
2
连接与库选择
PING:连通性测试,返回PONG。AUTH <password>:密码鉴权(-a参数已隐式完成)。SELECT <db>:切换库(默认 0,共 16 个)。集群模式不支持 SELECT。DBSIZE:当前库 Key 数量。
String
bash
SET user:1 "tom" # 设置
SET user:2 "jerry" EX 60 # 设置并 60 秒过期
SET lock:order "uuid" NX PX 30000 # 不存在才设置 + 30s 过期(分布式锁基础)
GET user:1 # 获取
INCR counter # 自增 1(原子操作)
INCRBY stock 10 # 自增指定值
SETEX code:phone 60 "1234" # 设置并过期(等价于 SET ... EX)
MGET user:1 user:2 # 批量获取
DEL user:2 # 删除1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
通用 Key 命令
bash
EXPIRE user:1 60 # 给已有 Key 设过期
TTL user:1 # 查看剩余秒数(-1 永久,-2 不存在)
PERSIST user:1 # 移除过期
TYPE user:1 # 查看类型
EXISTS user:1 # 是否存在
SCAN 0 MATCH user:* COUNT 100 # 渐进式扫描,避免阻塞1
2
3
4
5
6
2
3
4
5
6
禁用 KEYS
KEYS pattern 会扫描全库并阻塞主线程,线上严禁使用。需要遍历 Key 时一律用 SCAN(及 HSCAN/SSCAN/ZSCAN)。
Hash
bash
HSET user:1 name "tom" age 18 role "admin" # 设置一个或多个字段
HGET user:1 name # 取单字段
HGETALL user:1 # 取全部
HINCRBY user:1 age 1 # 字段自增
HDEL user:1 role # 删字段1
2
3
4
5
2
3
4
5
List
bash
LPUSH queue:msg "a" "b" # 左侧入队
RPUSH queue:msg "c" # 右侧入队
LRANGE queue:msg 0 -1 # 查看全部
LPOP queue:msg # 左侧弹出
BLPOP queue:msg 30 # 阻塞式弹出,最多等 30 秒(简易队列基础)
LLEN queue:msg # 长度1
2
3
4
5
6
2
3
4
5
6
Set
bash
SADD tags:1 "js" "ts" "redis"
SADD tags:2 "redis" "mysql"
SMEMBERS tags:1 # 全部成员
SISMEMBER tags:1 "redis" # 是否存在
SINTER tags:1 tags:2 # 交集
SUNION tags:1 tags:2 # 并集
SREM tags:1 "js" # 删成员1
2
3
4
5
6
7
2
3
4
5
6
7
Sorted Set
bash
ZADD rank 100 "tom" 90 "jerry" 95 "lily" # 加入并指定分数
ZINCRBY rank 5 "jerry" # 分数自增
ZRANGE rank 0 -1 WITHSCORES # 升序
ZREVRANGE rank 0 9 WITHSCORES # 降序前 10(排行榜)
ZRANGEBYSCORE rank 90 100 # 按分数区间
ZSCORE rank "tom" # 取单成员分数
ZREM rank "jerry"1
2
3
4
5
6
7
2
3
4
5
6
7
Stream
bash
XADD stream:msg "*" name "tom" content "hi" # * 表示自动生成 ID
XLEN stream:msg
XREAD COUNT 10 STREAMS stream:msg 0 # 从 0 开始读
# 消费者组
XGROUP CREATE stream:msg group1 0
XREADGROUP GROUP group1 consumer1 COUNT 10 STREAMS stream:msg ">"
XACK stream:msg group1 <id> # 确认消费
XPENDING stream:msg group1 # 查看待确认消息1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
事务与脚本
bash
MULTI # 开启事务
SET k1 "v1"
INCR k2
EXEC # 原子执行
# WATCH key -> 乐观锁,被修改则 EXEC 失败
EVAL "return redis.call('INCR', KEYS[1])" 1 counter # Lua 脚本1
2
3
4
5
6
2
3
4
5
6
发布订阅
bash
# 终端 A 订阅
SUBSCRIBE chat:room1
# 终端 B 发布
PUBLISH chat:room1 "hello"1
2
3
4
2
3
4
Pub/Sub 不持久
发布订阅的消息不持久化,订阅断开期间的消息会丢失。需要可靠投递请用 Stream。
运维命令
bash
INFO # 服务器信息(内存/连接/持久化等)
INFO memory # 只看内存
CONFIG GET maxmemory # 查看配置
CLIENT LIST # 在线客户端
SLOWLOG GET 10 # 慢查询日志
MEMORY USAGE user:1 # 查看单个 Key 占用内存1
2
3
4
5
6
2
3
4
5
6
命令速查思路
记不住命令很正常,关键是:会用 redis-cli --help 与官方 COMMAND DOCS,遇到性能问题先 INFO + SLOWLOG + MEMORY USAGE 三件套。
常见使用场景(重点:分布式)
Redis 之所以成为分布式系统的"基础设施",核心在于它把"跨进程共享状态"这件事做得又快又简单。本节按场景组织,每个场景给出用法、命令/代码示例和本项目结合点。
缓存(Cache-Aside)
最基础也是最常用的场景。流程:应用先查 Redis,未命中查 DB,回写 Redis 并设过期。
bash
SET user:profile:1 "<json>" EX 600 # 缓存 10 分钟
GET user:profile:11
2
2
本项目即采用此模式,在 src/utils/RedisUtils.mts 中封装了 redisSet/redisGet/redisDel 等基础方法,src/services/user.mts 与 src/controllers/user.mts 用于用户信息缓存。
缓存三大问题见 常见坑 一节
缓存穿透 / 击穿 / 雪崩的成因与解决方案在最后一章统一讲解。
分布式锁
单机多进程或分布式多实例下,传统的进程内锁(如 Node 的互斥量、Java 的 synchronized)失效,需要跨进程的锁。Redis 实现分布式锁的标准做法:
bash
# 加锁:SET key value NX PX 30000
# value 必须是持有者唯一标识(如 UUID),用于安全释放
SET lock:order:100 "uuid-xxx" NX PX 30000
# 释放:必须校验 value 与自己一致,用 Lua 保证原子1
2
3
4
2
3
4
释放锁必须用 Lua 脚本校验 value,避免误删别人持有的锁:
lua
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end1
2
3
4
5
2
3
4
5
三个易踩的坑
- 不设过期时间 → 持有者进程崩溃后锁永不释放。务必加
PX。 - 释放时直接 DEL → 可能删掉别人的锁。必须校验 value。
- 业务执行超过锁过期时间 → 锁被别人拿走,出现并发。需"看门狗"续期或留足冗余时间。
Redlock 算法与争议
单实例 Redis 锁在主从故障切换时可能丢失(主挂掉,从未同步到锁就提升为主)。Redlock 由 antirez 提出,在多个互相独立的 Redis 节点上加锁,超过半数成功才算获取成功。
但分布式专家 Martin Kleppmann 指出 Redlock 依赖时钟同步,在 GC 暂停、时钟漂移下仍不可靠。实践建议:对正确性要求不极致的场景,单实例 SET NX PX + Lua 释放足够;强一致场景请用 etcd / Zookeeper。多数业务用 Redis 锁即可。
分布式限流(本项目实践)
限流是保护后端的标配。单机限流在多实例部署下会失准(每台机器各算各的),需要把计数器集中到 Redis,实现多实例共享计数。
本项目在 src/middlewares/RateLimitMiddleware.mts 即采用此方案,关键代码:
固定窗口计数分布式限流中间件代码实现
typescript
import { rateLimit } from "express-rate-limit";
import RedisStore from "rate-limit-redis";
import { createApiResponse } from "#utils/ResponseUtils";
import { connectRedis } from "#utils/RedisUtils";
import { wrapAsyncMiddleware } from "#utils/MiddlewareUtils";
/**
* express限速中间件
* 不仅限/api开头的接口请求
* 也限制html页面请求(防爬虫)
* 使用 Redis 存储实现分布式限流,多实例共享计数器
*/
export function rateLimitMiddleware(limitPer15Minutes: number) {
return wrapAsyncMiddleware(rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: limitPer15Minutes, // Limit each IP to 100 requests per `window` (here, per 15 minutes).
standardHeaders: "draft-8", // draft-6: `RateLimit-*` headers; draft-7 & draft-8: combined `RateLimit` header
legacyHeaders: false, // Disable the `X-RateLimit-*` headers.
statusCode: 429,
message: createApiResponse({
code: 429,
data: null,
message: "请求太频繁了,请稍后再试",
}),
store: new RedisStore({
sendCommand: async (...args: string[]) => {
const client = await connectRedis();
return client.sendCommand(args);
},
}),
}));
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
常见限流算法对比:
| 算法 | 特点 | 适用场景 |
|---|---|---|
| 固定窗口计数 | 实现简单,有边界突刺 | 粗粒度限流 |
| 滑动窗口 | 平滑无突刺,内存略高 | 精度要求较高 |
| 漏桶 | 匀速流出,无法应对突发 | 保护下游匀速系统 |
| 令牌桶 | 允许突发,弹性好 | API 网关、通用场景 |
实现要点
- 固定窗口:
INCR key+EXPIRE,超阈值拒绝。 - 滑动窗口:Sorted Set,score 为时间戳,
ZREMRANGEBYSCORE清理过期再ZCARD计数。 - 令牌桶:Lua 脚本原子地"取令牌+补令牌"。
令牌桶分布式限流中间件代码实现
本项目在 src/middlewares/TokenBucketRateLimitMiddleware.mts 实现了基于 Redis Lua 的令牌桶限流,采用高阶函数模式,支持按 projectId 隔离限流,超限直接丢弃并邮件告警:
typescript
import type { Request, Response, NextFunction } from "express";
import { checkTokenBucket, getRedisClient } from "#utils/RedisUtils";
import { sendSystemEmailSafe } from "#utils/EmailUtils";
import { wrapAsyncMiddleware } from "#utils/MiddlewareUtils";
type KeyResolver = (req: Request) => string;
interface TokenBucketConfig {
/** 桶容量(最大突发) */
capacity: number;
/** 每秒补充速率(QPS) */
refillRate: number;
/** 从请求中提取限流 key 的函数 */
keyResolver: KeyResolver;
/** Redis key 前缀,默认 "token_bucket" */
cacheKeyPrefix?: string;
/** 告警冷却时间(秒),默认 300(5 分钟) */
alertCooldownSeconds?: number;
/** 告警邮件主题前缀,默认 "令牌桶限流告警" */
alertSubject?: string;
}
/**
* 创建令牌桶限流中间件(高阶函数)
* @param config 令牌桶配置
* @returns Express 中间件
*/
export function createTokenBucketRateLimiter(config: TokenBucketConfig) {
const {
capacity,
refillRate,
keyResolver,
cacheKeyPrefix = "token_bucket",
alertCooldownSeconds = 300,
alertSubject = "令牌桶限流告警",
} = config;
return wrapAsyncMiddleware(async (req: Request, res: Response, next: NextFunction) => {
const key = keyResolver(req);
const bucketKey = `${cacheKeyPrefix}:${key}`;
const allowed = await checkTokenBucket(bucketKey, capacity, refillRate);
if (allowed) {
next();
return;
}
// 超限:丢弃请求,邮件告警(使用 Redis SET NX 实现分布式冷却,同一 key 在冷却期内不重复告警)
const alertKey = `${cacheKeyPrefix}:alert_cooldown:${key}`;
const client = getRedisClient();
const alertSent = await client.set(alertKey, "1", {
EX: alertCooldownSeconds,
NX: true,
});
if (alertSent) {
sendSystemEmailSafe({
subject: alertSubject,
text: `key=${key} 触发令牌桶限流,请求已被丢弃。当前配置:QPS=${refillRate}, Burst=${capacity}`,
});
}
res.status(429).json({ code: 429, message: "too many requests" });
});
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
Lua 脚本位于 src/utils/RedisUtils.mts,使用 HMGET/HMSET 存储令牌数和最后补充时间戳,每次请求时原子地"补充令牌 → 消费令牌":
LUA 令牌桶限流脚本实现
typescript
const TOKEN_BUCKET_SCRIPT = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local bucket = redis.call("HMGET", key, "tokens", "last_refill")
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now
local elapsed = now - last_refill
if elapsed > 0 then
local refill = math.floor(elapsed * rate)
if refill > 0 then
tokens = math.min(capacity, tokens + refill)
last_refill = now
end
end
if tokens >= 1 then
tokens = tokens - 1
redis.call("HMSET", key, "tokens", tokens, "last_refill", last_refill)
redis.call("EXPIRE", key, math.ceil(capacity / rate) + 1)
return 1
else
redis.call("HMSET", key, "tokens", tokens, "last_refill", last_refill)
redis.call("EXPIRE", key, math.ceil(capacity / rate) + 1)
return 0
end
`;
/**
* 检查令牌桶是否允许通过
* @param bucketKey Redis key
* @param capacity 桶容量(最大突发)
* @param refillRate 每秒补充速率(QPS)
* @returns true=允许,false=限流
*/
export const checkTokenBucket = async (
bucketKey: string,
capacity: number,
refillRate: number,
): Promise<boolean> => {
const client = getRedisClient();
const now = Math.floor(Date.now() / 1000);
const result = await client.eval(TOKEN_BUCKET_SCRIPT, {
keys: [bucketKey],
arguments: [String(capacity), String(refillRate), String(now)],
});
return result === 1;
};1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
会话存储(本项目实践)
多实例部署下,Session 存内存会导致"请求打到 A 实例登录,再打到 B 实例就未登录"。把 Session 集中到 Redis 是最直接的解法,且天然支持 TTL 自动过期。
本项目在 src/middlewares/SessionMiddleware.mts 使用 connect-redis:
javascript
// src/middlewares/SessionMiddleware.mts:11
store: new RedisStore({
client: getRedisClient(),
prefix: "session:",
}),1
2
3
4
5
2
3
4
5
prefix: "session:"给所有 Session Key 加统一前缀,便于区分与批量管理。cookie.maxAge与 Session 的 TTL 联动,过期自动清理。- 复用全局
getRedisClient()单例,避免连接泄漏。
排行榜 / Sorted Set(本项目实践)
Sorted Set 是 Redis 最有价值的结构之一:分数排序 + 去重 + O(logN) 操作。典型应用是排行榜、延迟队列、最近活跃列表。
本项目在 src/utils/RedisUtils.mts 封装了完整的 Sorted Set 操作,并用 Lua 脚本实现了一个多会话登录追踪的真实场景:
javascript
// src/utils/RedisUtils.mts:93
const TRACK_USER_LOGIN_SCRIPT = `
local members = redis.call("ZRANGE", KEYS[1], 0, -1)
for _, existingMember in ipairs(members) do
if string.sub(existingMember, 1, 6) ~= "login:" then
redis.call("ZREM", KEYS[1], existingMember)
end
end
redis.call("ZADD", KEYS[1], ARGV[1], ARGV[2])
redis.call("ZREMRANGEBYRANK", KEYS[1], 0, -(tonumber(ARGV[3]) + 1))
redis.call("EXPIRE", KEYS[1], ARGV[4])
return 1
`;1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
这段脚本把"清理非登录成员 → 写入新成员 → 截断超出最大会话数 → 设过期"四步合成一个原子操作,是 Sorted Set + Lua 的典型组合拳。
消息队列
两种主流方案:
- List 模式:
LPUSH入队 +BRPOP阻塞出队。简单但不保证可靠消费(消费者崩溃消息丢失需自行 ACK 机制)。 - Stream 模式(推荐):
XADD生产 +XREADGROUP消费 +XACK确认。原生支持消费者组、待确认列表(pending list)、死信处理,是更可靠的轻量级队列。
bash
# Stream:生产
XADD orders "*" id 1001 amount 99
# Stream:消费组消费
XREADGROUP GROUP order_group consumer_1 COUNT 10 BLOCK 5000 STREAMS orders ">"
# 处理完成后确认
XACK orders order_group <message_id>1
2
3
4
5
6
2
3
4
5
6
选型边界
轻量、低 QPS、可接受偶发丢失 → List 足够;要求可靠投递、多消费者、ACK 重试 → Stream;超大规模、复杂路由与延迟 → 上专业 MQ(RabbitMQ/Kafka/RocketMQ)。
发布订阅
适合实时广播:聊天室、配置热更新、多实例间事件同步。生产者 PUBLISH,订阅者 SUBSCRIBE。
但消息不持久、无 ACK、订阅者掉线即丢消息,不适合做可靠队列,可靠场景请用 Stream。
计数器与统计
- 精确计数:
INCR/INCRBY,原子自增,点赞、库存、限流计数皆用此。 - 去重 UV:HyperLogLog,
PFADD/PFCOUNT/PFMERGE,固定 12KB 估算百万级 UV。 - 位图统计:Bitmap,签到打卡、用户在线状态,
SETBIT/BITCOUNT,1 亿用户仅需约 12MB。
延迟队列
两种实现:
- Sorted Set:score 设为到期时间戳,定时
ZRANGEBYSCORE key 0 now取出到期任务再ZREM。简单但需保证取出与删除的原子性(建议 Lua)。 - Stream:基于 pending list 与消息 ID 时间戳做重投递,可靠性更好。
常见坑和解决方案
缓存穿透 / 击穿 / 雪崩
三者名称相似但成因不同,需区分对待。
缓存穿透:查询根本不存在的数据(如恶意攻击不存在的 ID),每次都打到 DB。
- 解决:缓存空值(短 TTL,如 60 秒);布隆过滤器前置拦截;参数校验拒绝非法 ID。
缓存击穿:单个热 Key 同时过期,瞬间大量请求穿透到 DB。
- 解决:热 Key 永不过期 + 后台异步刷新;加互斥锁(分布式锁)只让一个请求回源。
缓存雪崩:大量 Key 同时过期 或 Redis 整体宕机,请求洪流打到 DB。
- 解决:过期时间加随机扰动(如
EX 600 + random(0,60));多级缓存(本地 + Redis);Redis 高可用(主从/哨兵)兜底。
大 Key 问题
现象:单个 Key 过大(如 String 超过 10KB、集合元素超 1 万),导致操作延迟、网络阻塞、删除时主线程卡顿、主从同步延迟。
排查:
bash
redis-cli --bigkeys # 扫描大 Key
MEMORY USAGE <key> # 查看单 Key 内存1
2
2
解决:
- 拆分:大 Hash 按字段分片到多个 Key;大 List 改用 Stream 分片。
- 删除用
UNLINK(异步删除)替代DEL(同步阻塞)。 - 设计时避免把整张表塞进一个 Key。
热 Key 问题
现象:某个 Key 访问量极大(如热门商品),单节点成为瓶颈。
排查:redis-cli --hotkeys(需开启 LFU 淘汰策略)。
解决:
- 本地缓存(如应用内 LRU)拦截大部分请求。
- 多副本 Key:把同一份数据写多个 Key(
hot:1/hot:2...),随机读取分摊压力。 - 读写分离,热 Key 走从节点。
分布式锁的坑(续)
分布式锁场景 已列出三个常见坑,补充两点:
- 看门狗续期:业务执行超过锁 TTL 时,需后台线程定期
EXPIRE续期。Redisson(Java)内置此机制,Node 生态需自行实现或用redlock库。 - Redlock 时钟漂移:见 Redlock 争议,强一致场景请换 etcd。
过期策略与内存淘汰
Redis 删除过期 Key 用两种策略组合:
- 惰性删除:访问时才检查过期,节省 CPU,但会有过期 Key 长期占内存。
- 定期删除:周期性随机抽样删除过期 Key。
内存满时按 maxmemory-policy 淘汰,常见策略对比:
| 策略 | 含义 | 适用 |
|---|---|---|
noeviction | 不淘汰,写入直接报错 | 数据不可丢,需监控告警 |
allkeys-lru | 全库 LRU 淘汰 | 纯缓存(最常用) |
volatile-lru | 仅带过期的 Key LRU | 混合场景,持久数据保留 |
allkeys-lfu | 全库 LFU(按访问频率) | 有明显热点 |
volatile-ttl | 优先淘汰快过期的 | 让短 TTL 数据先走 |
纯缓存选 allkeys-lru
如果 Redis 仅作缓存,allkeys-lru 是最省心的选择;若同时存了不可丢的数据(如锁、Session),慎用 allkeys-*,建议 volatile-lru 并给重要数据不设过期。
持久化选型:RDB vs AOF
| 维度 | RDB | AOF |
|---|---|---|
| 形式 | 时间点快照(二进制) | 追加写命令日志 |
| 体积 | 小 | 大 |
| 恢复速度 | 快 | 慢(重放命令) |
| 数据安全 | 可能丢失最近一次快照后的数据 | 丢得更少(always 最安全,everysec 折中) |
实践建议:
- 纯缓存可都关,或仅 RDB。
- 有数据安全要求用混合持久化(
aof-use-rdb-preamble yes,4.0+),AOF 文件前半段是 RDB 快照、后半段是增量命令,兼顾恢复速度与数据安全。 appendfsync everysec是性能与安全的平衡点。
禁用 KEYS,用 SCAN
KEYS pattern 扫描全库阻塞主线程,几百万 Key 的库一次 KEYS * 足以让线上卡顿数秒。
bash
# 错误
KEYS user:*
# 正确
SCAN 0 MATCH user:* COUNT 1001
2
3
4
2
3
4
SCAN 渐进式返回游标,不阻塞,但可能返回重复或遗漏(迭代期间有写入),需调用方去重并多次调用直到游标为 0。
主从复制延迟与脑裂
- 复制延迟:主写从读架构下,从节点异步复制,强读从可能读到旧数据。写多读少或对一致性敏感的场景,直接读主。
- 脑裂:网络分区下旧主仍接受写入,哨兵提升新主后旧主写入丢失。可通过
min-slaves-to-write/min-replicas-max-lag(版本名不同)配置:从节点延迟超阈值时主节点拒绝写入。
集群 slot 限制
Redis Cluster 把数据按 16384 个 slot 分片,Key 通过 CRC16(key) % 16384 定位 slot。
- 多 Key 操作受限:
MGET/SINTER/事务等涉及多个 Key 的命令,要求所有 Key 落在同一 slot。 - 用 hash tag 强制同 slot:
{user}:1与{user}:2都按user计算 slot,必然同 slot。
长耗时 Lua 阻塞主线程
Lua 脚本在主线程执行,期间其他命令全部排队。脚本过长会拖垮整个实例。本项目 TRACK_USER_LOGIN_SCRIPT 这种短脚本没问题,但严禁在 Lua 里做循环遍历大集合。可通过 redis.conf 配 lua-time-limit(默认 5 秒)触发告警,超时后可用 SCRIPT KILL 中止(但若已执行了写命令则只能 SHUTDOWN NOSAVE)。
连接泄漏
常见错误:每次请求 createClient() 却不复用、订阅后忘记退订、异常未关闭连接。
本项目在 src/utils/RedisUtils.mts 采用单例 + 复用模式:
javascript
// src/utils/RedisUtils.mts:7
export const getRedisClient = (): RedisClientType => {
if (!redisClient) {
redisClient = createClient({ url: REDIS_URL, password: REDIS_PASSWORD || undefined });
// ...
}
return redisClient;
};1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
全应用共享一个 client 实例(node-redis 6.x 单连接已支持pipeline 与并发),既省连接又便于统一管理。禁止在业务代码里反复创建 client。
小结
Redis 的价值在于"快"和"结构丰富",分布式场景下几乎无处不可用。但好用不等于可以无脑用:
- 分布式锁、限流、会话是 Redis 在分布式系统中的三大基石。
- 大 Key、热 Key、
KEYS阻塞、锁误删是线上事故的高发地。 - 持久化、淘汰策略、主从延迟这些"运维向"配置,开发期就要定好,别等线上出事再补。
记住一条主线:Redis 命令是单线程串行的,凡是会让主线程卡住的操作都是危险操作。这条原则能帮你避开八成的坑。