Redis 常被一句话概括为“内存数据库”,但在真实项目里,它的价值远不止把数据放进内存。

登录态、热点缓存、验证码、排行榜、接口限流、延迟任务、抢购库存、分布式锁……这些看似不相干的需求,背后都在处理同一类问题:如何用低延迟、可共享、可原子操作的状态,支撑高并发业务。 Redis 恰好提供了这组能力。

本文以 Node.js + TypeScript 为示例,梳理 Redis 在项目开发中最常见的使用方式。重点不只是“命令怎么写”,更是“什么场景该用、边界在哪里、怎样避免线上坑”。

示例使用 ioredis。不同客户端的 API 略有差异,但 Redis 的命令语义一致。

一、先建立正确认知:Redis 适合解决什么问题

Redis 的核心特征有三点:

  • 数据主要存放在内存中,读写延迟很低;
  • 支持字符串、哈希、列表、集合、有序集合、Stream 等多种结构;
  • 单条命令天然具备原子性,并可借助 Lua 脚本完成多步原子操作。

因此,它尤其适合下面两类数据:

  1. 读多写少、可被短暂缓存的数据:例如商品详情、文章详情、配置项。
  2. 需要快速共享和协调的临时状态:例如会话、验证码、限流计数、锁、排队任务。

Redis 通常不是 MySQL、PostgreSQL 这类关系型数据库的替代品。订单、余额、用户资料等核心事实数据,仍应以持久化数据库为准;Redis 更适合作为加速层和协作层。

二、项目初始化:连接、序列化与 Key 规范

安装客户端:

1
npm install ioredis

封装一个可复用的连接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/lib/redis.ts
import Redis from 'ioredis'

export const redis = new Redis({
host: process.env.REDIS_HOST ?? '127.0.0.1',
port: Number(process.env.REDIS_PORT ?? 6379),
password: process.env.REDIS_PASSWORD,
db: 0,
maxRetriesPerRequest: 2,
enableReadyCheck: true,
})

redis.on('error', (error) => {
console.error('Redis connection error:', error)
})

业务对象写入 Redis 前通常需要序列化:

1
2
3
4
5
6
7
8
9
10
11
type ProductCache = {
id: number
title: string
price: number
updatedAt: string
}

await redis.set('product:detail:1001', JSON.stringify(product), 'EX', 300)

const raw = await redis.get('product:detail:1001')
const cachedProduct: ProductCache | null = raw ? JSON.parse(raw) : null

Key 的命名建议

Key 是 Redis 中最容易被忽略的“数据模型”。推荐统一采用冒号分层:

1
2
3
4
5
6
7
业务域:资源:标识:附加维度

user:profile:42
product:detail:1001
auth:token:8c2f...
rate-limit:login:ip:203.0.113.8
lock:order:pay:202607100001

这样做有三个好处:可读性更强、方便按前缀统计与清理、不同模块不容易发生 Key 冲突。不要把密码、身份证号、完整 Token 等敏感内容直接拼进 Key。

三、最常见场景:缓存数据库查询结果

1. Cache Aside:旁路缓存模式

绝大多数业务缓存都可以用这一模式:

  1. 先查询 Redis;命中则直接返回。
  2. 未命中时查询数据库。
  3. 将查询结果写入 Redis,并设置过期时间。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const CACHE_TTL_SECONDS = 5 * 60

async function getProductDetail(id: number) {
const key = `product:detail:${id}`
const cached = await redis.get(key)

if (cached) {
return JSON.parse(cached)
}

const product = await db.product.findUnique({ where: { id } })

// 避免把不存在的数据永久缓存;后文会讨论缓存穿透。
if (!product) return null

await redis.set(key, JSON.stringify(product), 'EX', CACHE_TTL_SECONDS)
return product
}

更新时,推荐先更新数据库,再删除缓存:

1
2
3
4
5
async function updateProduct(id: number, input: UpdateProductInput) {
const product = await db.product.update({ where: { id }, data: input })
await redis.del(`product:detail:${id}`)
return product
}

为什么不是“更新数据库后再更新缓存”?因为缓存结构通常不止一份:详情缓存、列表缓存、搜索缓存都可能受影响。删除可以让后续读请求自然重建,代码更容易保持正确。若对一致性要求很高,还应引入消息队列、订阅 binlog 或事务消息等机制,而不是只依赖一次 DEL

2. TTL 不是可选项

除非是经过明确设计的常驻配置,否则缓存都应设置过期时间。没有 TTL 的缓存会逐渐占满内存,也会让陈旧数据长期存在。

对于大量热点 Key,不要把 TTL 设成同一个精确时间;可以增加随机抖动,降低同一时刻集体失效的风险:

1
2
3
4
5
function withJitter(baseSeconds: number, jitterSeconds = 60) {
return baseSeconds + Math.floor(Math.random() * jitterSeconds)
}

await redis.set(key, value, 'EX', withJitter(300))

四、缓存三大问题:穿透、击穿与雪崩

这三个概念经常一起出现,关键是分清“谁在失效、影响范围多大”。

问题 典型原因 风险 常见处理方式
缓存穿透 请求不存在的数据 每次都落到数据库 缓存空值、布隆过滤器、参数校验
缓存击穿 一个热点 Key 在失效瞬间被大量请求访问 大量请求同时回源 互斥锁、逻辑过期、热点预热
缓存雪崩 大批 Key 同时失效或 Redis 故障 下游数据库被压垮 TTL 打散、多级缓存、限流与降级

1. 缓存穿透:缓存空值

对于确实不存在的 ID,也可以缓存一个短期占位符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const NULL_VALUE = '__NULL__'

async function getUser(id: number) {
const key = `user:profile:${id}`
const cached = await redis.get(key)

if (cached === NULL_VALUE) return null
if (cached) return JSON.parse(cached)

const user = await db.user.findUnique({ where: { id } })
if (!user) {
await redis.set(key, NULL_VALUE, 'EX', 60)
return null
}

await redis.set(key, JSON.stringify(user), 'EX', 300)
return user
}

空值 TTL 应更短,以免新建数据后仍被旧占位符遮挡。对于海量、明显非法的 ID 请求,可在缓存前加布隆过滤器,先快速判断“这个 ID 理论上是否可能存在”。

2. 缓存击穿:用互斥锁合并回源请求

热点 Key 失效时,只允许一个请求查询数据库并重建缓存,其他请求短暂等待或返回降级结果:

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
async function getHotProduct(id: number) {
const key = `product:detail:${id}`
const lockKey = `lock:product:rebuild:${id}`

const cached = await redis.get(key)
if (cached) return JSON.parse(cached)

const locked = await redis.set(lockKey, '1', 'NX', 'PX', 3000)
if (locked) {
try {
// 二次检查:等待锁期间,可能已经有其他实例重建完毕。
const retryCached = await redis.get(key)
if (retryCached) return JSON.parse(retryCached)

const product = await db.product.findUnique({ where: { id } })
if (product) {
await redis.set(key, JSON.stringify(product), 'EX', withJitter(300))
}
return product
} finally {
await redis.del(lockKey)
}
}

// 实际项目可短暂重试,或返回“稍后再试”的降级响应。
return null
}

这段代码用于说明思路。生产环境的锁释放需要“只释放自己的锁”,后文会给出正确写法。

五、登录态、验证码与一次性 Token

Redis 的过期能力非常适合保存临时凭证。

1. 短信或邮箱验证码

1
2
3
4
5
6
7
8
9
10
const key = `verify-code:login:${phone}`
await redis.set(key, code, 'EX', 300)

const savedCode = await redis.get(key)
if (savedCode !== inputCode) {
throw new Error('验证码错误或已过期')
}

// 验证成功立即删除,保证一次性使用。
await redis.del(key)

注意:验证码本身不应写日志;发送频率还必须额外限流,不能只依靠验证码 TTL。

2. Token 黑名单与会话管理

JWT 天生无状态,但“退出登录后立即失效”是常见需求。可以把 Token 的唯一标识 jti 放入黑名单,TTL 设为 Token 的剩余有效期:

1
2
3
4
await redis.set(`auth:blacklist:${jti}`, '1', 'EX', remainingSeconds)

const revoked = await redis.exists(`auth:blacklist:${jti}`)
if (revoked) throw new Error('登录状态已失效')

如果采用服务端 Session,则可以直接使用 session:{sessionId} 保存用户 ID、设备信息与过期时间。无论哪种方式,都要注意会话固定攻击、Token 泄露,以及多端登录策略。

六、排行榜、去重与共同关系:Set 和 ZSet

1. 用 ZSet 做排行榜

有序集合(Sorted Set)会按分数自动排序,非常适合积分、热度、游戏分数等场景:

1
2
3
4
5
6
const key = 'rank:weekly:2026w28'

await redis.zincrby(key, 10, 'user:42')
await redis.expire(key, 60 * 60 * 24 * 14)

const top10 = await redis.zrevrange(key, 0, 9, 'WITHSCORES')

ZREVRANGE 返回的数组是“成员、分数、成员、分数”的交替形式,业务层通常应转换成对象再返回给前端。

2. 用 Set 做点赞与去重

1
2
3
4
5
6
7
8
9
10
11
12
const key = 'article:liked-users:1001'

// 同一用户重复点赞不会重复计数。
const added = await redis.sadd(key, '42')
if (added === 1) {
await db.article.update({
where: { id: 1001 },
data: { likeCount: { increment: 1 } },
})
}

const liked = await redis.sismember(key, '42')

Set 也能高效计算交集,例如“共同关注”“共同好友”。但成员数非常大时,需谨慎使用 SMEMBERSSINTER 等可能返回或计算大量数据的命令。

七、接口限流:固定窗口与滑动窗口

最简单的固定窗口计数器可以通过 INCR 实现:

1
2
3
4
5
6
7
8
9
10
async function allowRequest(userId: string) {
const key = `rate-limit:api:user:${userId}`
const count = await redis.incr(key)

if (count === 1) {
await redis.expire(key, 60)
}

return count <= 100
}

这里有一个小细节:INCREXPIRE 是两条命令。应用若在中间崩溃,Key 可能没有过期时间。更严谨的做法是使用 Lua 脚本,将递增和首次设置过期合为一个原子操作:

1
2
3
4
5
6
7
8
9
const script = `
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return count
`

const count = await redis.eval(script, 1, key, 60)

固定窗口实现简单,但会有边界突刺:用户在 12:00:59 和 12:01:00 分别发送 100 次请求,实际两秒内 200 次却都可能通过。对登录、下单等更敏感的接口,可使用 ZSet 记录请求时间戳实现滑动窗口,或采用令牌桶算法。

八、分布式锁:能用,但必须正确使用

当服务部署多个实例时,进程内的 mutex 只能锁住当前进程。若多个实例可能同时执行同一任务,例如“同一订单只能支付一次”或“定时任务只由一台机器执行”,才需要考虑 Redis 分布式锁。

加锁要使用 SET key value NX PX milliseconds

1
2
3
4
5
6
7
8
9
import { randomUUID } from 'node:crypto'

const lockKey = `lock:order:pay:${orderId}`
const lockValue = randomUUID()

const acquired = await redis.set(lockKey, lockValue, 'NX', 'PX', 10_000)
if (!acquired) {
throw new Error('订单正在处理中,请勿重复提交')
}

锁值必须是唯一标识,释放锁时必须校验这个值。否则 A 的锁过期后,B 获得新锁,A 再执行一个无条件 DEL 就会错误地删除 B 的锁。

1
2
3
4
5
6
7
8
9
10
11
12
const unlockScript = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
`

try {
await processOrder(orderId)
} finally {
await redis.eval(unlockScript, 1, lockKey, lockValue)
}

还要认识到:锁不是业务正确性的唯一保障。比如防止重复扣款,数据库唯一约束、订单状态机和支付渠道幂等号仍然是最后防线。锁只是降低并发冲突的手段。

九、异步任务:List、Pub/Sub 与 Stream 如何选择

Redis 可承载轻量任务通信,但不同结构适合不同目标:

方式 适合场景 关键限制
List(LPUSH / BRPOP 简单的单消费者任务队列 消费确认、失败重试能力需要自己补齐
Pub/Sub 在线通知、缓存失效广播 消息不持久,离线订阅者收不到
Stream 需要消费组、确认与重放的事件流 运维和积压治理复杂度更高

一个基础队列示例:

1
2
3
4
5
6
7
8
9
10
11
12
// 生产者
await redis.lpush('queue:email', JSON.stringify({
to: 'user@example.com',
template: 'welcome',
}))

// 消费者:BRPOP 会阻塞等待任务,避免轮询。
const result = await redis.brpop('queue:email', 0)
if (result) {
const [, payload] = result
await sendEmail(JSON.parse(payload))
}

涉及订单、支付、发券等不能丢的任务时,不应把“成功从队列取出”直接等同于“任务执行成功”。需要设计幂等键、重试、死信队列和可观测性;许多团队会直接选择 BullMQ、RabbitMQ 或 Kafka 等更匹配的基础设施。

十、性能与稳定性:这些坑比命令更重要

1. 避免大 Key 与危险命令

一个 Key 中塞入过大的 JSON、几十万成员的集合,都会造成网络传输、序列化和 Redis 主线程阻塞。线上排查 Key 时避免使用 KEYS *,它会遍历整个键空间;使用渐进式的 SCAN

1
redis-cli --scan --pattern 'product:detail:*'

同样要谨慎执行 SMEMBERSLRANGE 0 -1、超大范围 ZRANGE。为集合设计分页和上限,比事后优化更重要。

2. 设置合理的内存淘汰策略

Redis 内存有限,达到 maxmemory 后的行为由淘汰策略决定。纯缓存场景常见 allkeys-lruallkeys-lfu;如果部分 Key 绝不能淘汰,不能只依赖“同一个 Redis 实例 + 默认配置”来保证。应根据业务隔离实例、设置监控告警,并明确哪些数据可以丢失。

3. 不要把 Redis 当作绝对可靠的事务数据库

Redis 支持持久化,但 RDB 快照与 AOF 策略决定了故障时可能丢失多少数据。对于库存、金额、订单状态等关键数据,要在关系型数据库中保留权威记录,并通过事务、唯一索引、幂等设计保证正确性。

4. 监控四类指标

  • 可用性:连接错误、超时、主从切换次数;
  • 性能:命令耗时、慢查询、CPU、网络带宽;
  • 容量:内存使用率、Key 数量、淘汰次数、过期次数;
  • 业务效果:缓存命中率、限流拦截数、队列积压量、锁竞争率。

没有命中率和淘汰指标,就很难判断“Redis 到底是在加速业务,还是已经成为瓶颈”。

十一、一个实用的落地清单

在为新需求引入 Redis 前,可以快速检查以下问题:

  • 这份数据的权威来源是什么?Redis 故障或丢失后能否重建?
  • Key 命名、TTL、最大体积和预计数量是否已定义?
  • 缓存未命中、热点失效和 Redis 不可用时,接口如何降级?
  • 写操作后如何保证缓存与数据库最终一致?
  • 是否需要原子性?单条命令够不够,还是需要 Lua 或数据库事务?
  • 是否存在重复请求?幂等键、唯一索引和状态机是否到位?
  • 是否具备命中率、内存、慢命令、错误率等监控?

结语

Redis 的学习路径不应停在 SETGETEXPIRE。真正决定项目质量的,是能否把数据生命周期、并发竞争、失败恢复和监控治理一起纳入设计。

一个成熟的 Redis 使用方案通常很朴素:核心数据仍由数据库兜底;缓存带 TTL;更新主动失效;热点有保护;关键操作可幂等;大 Key 与全量命令被严格禁止;出现故障时有降级和监控。把这些基础原则做好,Redis 才会成为高并发项目的助推器,而不是新的不确定性来源。