大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
String:不只是字符串,还是计数器和锁
这一课你将能做到
- 熟练使用
SET的四个关键选项:EX/PX(过期)、NX(不存在才设)、XX(存在才设),并写出「分布式锁雏形」命令SET key val NX EX 30 - 用
INCR/INCRBY/DECR做原子计数,解释清楚为什么高并发下它不会数错而「先 GET 再 SET」会 - 读懂本站
RedisRequestRateLimiter的完整 Java 实现,并用StringRedisTemplate写出带过期时间的标准缓存写入
为什么要学
String 是 Redis 使用率最高的类型,但多数初学者只会 SET key value 三个词。真实工程里的全部价值藏在选项和原子性里:
- 接口限流要「计数 + 60 秒过期」,两条命令分开写就有竞态
- 缓存写入要「存 JSON + 5 分钟过期」,Java 里对应一次
set(key, json, Duration)调用 - 「只允许一个实例执行定时任务」需要
SET lockkey uuid NX EX 30一条命令
本站 RedisRequestRateLimiter(backend/src/main/java/com/codeforge/config/RedisRequestRateLimiter.java)就是用 String 的 INCR + EXPIRE 实现的固定窗口限流。学完这一课,你能逐行读懂它,还能解释它为什么并发安全。
先建立图像
String 在 Redis 里是一块字节数组,但不只是「文本」——它是四种角色的合体:
SET name "xiaolin" → 文本
SET json '{"a":1}' → JSON 缓存(本站 tracks 列表就是这么存的)
INCR visits → 数字(内部按整数优化存储)
SET lock "uuid" NX EX 30 → 带自动过期的互斥标记
可以把 String 想成一张「便签」,写的时候带可选动作:
- 顺手定个闹钟(
EX秒 /PX毫秒) - 写之前检查柜子空不空(
NX:空才写) - 写之前检查柜子有没有东西(
XX:有才写)
跟着做
步骤 1:SET 的选项组合实验
docker exec -it redis-dev redis-cli
SET cache:demo "v1" EX 60
TTL cache:demo # (integer) 60 左右,倒计时
SET cache:demo "v2" NX EX 60 # (nil) —— key 已存在,NX 拒绝写入
GET cache:demo # "v2" —— 上一条的值没被覆盖
SET cache:demo "v3" XX # OK —— XX:存在才写,成功
SET cache:nope "v" XX # (nil) —— 不存在,XX 拒绝
步骤 2:INCR 原子计数
DEL pv
INCR pv # (integer) 1 —— key 不存在时先当 0 再 +1
INCR pv # (integer) 2
INCRBY pv 10 # (integer) 12
DECR pv # (integer) 11
注意:INCR 只能作用于「能解析成整数」的值,对 "xiaolin" INCR 会报 value is not an integer or out of range。
步骤 3:并发安全小实验(可选进阶)
docker exec redis-dev sh -c 'for i in 1 2 3 4 5; do redis-cli INCR concurrent:test > /dev/null & done; wait'
docker exec redis-dev redis-cli GET concurrent:test # 一定是 "5"
对比 MySQL 里「读出来 +1 再写回去」的两个事务互相覆盖,INCR 是在服务端一条命令内完成读-改-写,没有插队的机会。
步骤 4:读本站限流器源码
打开 backend/src/main/java/com/codeforge/config/RedisRequestRateLimiter.java,核心逻辑四行:
String redisKey = "rl:" + key; // ① 前缀隔离
Long n = stringRedisTemplate.opsForValue().increment(redisKey); // ② 原子 +1
if (n != null && n == 1L) { // ③ 窗口内第一次
stringRedisTemplate.expire(redisKey, window); // 给计数器上闹钟
}
return n == null || n <= max; // ④ 超过配额就拒绝
这就是「固定窗口限流」:同一个 key(如客户端 IP)在 window 时间内最多放行 max 次,第 max+1 次被拒。
步骤 5:Java 写缓存的标准姿势
// 带 TTL 写入 JSON(综合实战课你会写完整版)
stringRedisTemplate.opsForValue()
.set("coderoadmap:tracks:list", json, Duration.ofMinutes(5));
带 Duration 的重载一步完成「写值 + 过期」,等价于 SET key value EX 300,避免「写了值忘了过期」的经典事故。
步骤 6:MSET/MGET 批量操作
MSET a:1 "x" a:2 "y" a:3 "z"
MGET a:1 a:2 a:3 # 一次网络往返取回 3 个值
Java 侧对应 opsForValue().multiGet(keys)。批量命令省的是网络往返次数,不是内存。
讲清楚要点
- INCR 的原子性来自「命令在服务端单线程执行」:不是靠加锁,而是根本没有第二个执行者能插队。多实例共用同一个 Redis 计数,天然全局一致——这正是本站限流用 Redis 而不用 JVM 内存 Map 的原因(内存 Map 在多实例下各算各的,配额形同虚设)。
SET NX EX是一切分布式锁的地基:NX 保证只有一个客户端写成功,EX 保证持有者崩溃后锁自动释放。完整的生产锁还要考虑「误删别人的锁」(值存唯一 ID + Lua 脚本校验再删),这是进阶话题,本课先建立直觉。- 值最大 512MB,但大值是性能杀手:10MB 的 JSON 每次 GET 都要网络传输和内存复制。本站约定缓存只存查询结果,单 key 控制在几百 KB 内。
increment+expire分两步有极小概率窗口:如果 n==1 时连接恰好断开、expire 没执行,计数器就永生了。严谨实现用 Lua 脚本把两步原子化;本站用「只在 n==1 时 expire」把风险压缩到窗口起点这一个点。- String 存 JSON 是本站缓存的基本形态:读时 get → 反序列化;miss 时查 MySQL → 序列化 → set + TTL。第 6 课的 Cache-Aside 和第 12 课的实战都围绕它。
你可能会懵的地方
-
INCR 为什么不等于「GET 再 SET」? 两步之间别的客户端可以插进来执行自己的 INCR,你的 SET 会覆盖它的结果,计数丢失。INCR 是服务端一条命令内完成读-改-写,中间不可分割。
-
NX 和 XX 有什么实际用武之地? NX:分布式锁、防止重复初始化、幂等标记。XX:只更新已存在的缓存(避免 miss 时误写脏数据)。日常最常用的还是不带任何选项的普通 SET。
-
限流器里为什么 n == 1 才 expire,第 2~N 次不续期吗? 固定窗口的定义就是「窗口起点 + 固定长度」。第一次 INCR 即窗口起点,上一次闹钟即可;后续每次都 expire 会把窗口往后推,语义变成滑动窗口,配额就失真了。
-
SET 会返回 (nil) 吗? 会:带 NX/XX 且条件不满足时返回
(nil)而不是OK。Java 里对应返回 null,业务判断要区分「写入成功」和「条件不满足被拒」。 -
opsForValue().increment()返回 Long 还是 long? 返回包装类型Long(管道/事务场景下可能为 null)。本站限流代码里n != null &&的判空就是防它。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| SET 完再单独 EXPIRE | 两步之间崩溃 → key 永生 | SET key val EX 60 或 Java 的 Duration 重载 |
| 计数用 GET +1 再 SET | 并发下丢更新 | INCR / INCRBY |
| 分布式锁只有 NX 没有 EX | 持有者崩溃后死锁 | SET lock uuid NX EX 30 |
| 缓存值塞几 MB 的大 JSON | 传输、内存、网络全面放大 | 只缓存查询结果,控制单 key 大小 |
| 对非整数值 INCR | 报 value is not an integer | 先确认值类型;浮点用 INCRBYFLOAT |
| 限流 key 先 SET "0" 再 INCR | 白白多一次写,还引入竞态 | 直接 INCR(不存在时自动当 0) |
卡点急救
(error) ERR value is not an integer or out of range:对"abc"INCR 了。先DEL后重设数字,并排查是谁写脏了这个 key。- TTL 返回 -1:key 存在但没过期时间——大概率 SET 时漏了 EX,或更新时把 TTL 覆盖没了。补
EXPIRE key 60。 - 限流计数器永不过期:历史上 n==1 那次 expire 调用失败了。
TTL rl:xxx确认后手工EXPIRE补上。 - Java set 报 SerializationException:StringRedisTemplate 只存字符串,对象要先经 ObjectMapper 转成 JSON(第 7 课详讲序列化)。
- MGET 返回列表里有 null:对应下标的 key 不存在。Java 里逐个判空,别直接解引用。
面试常问
-
Redis 怎么做接口限流? 固定窗口:
INCR rl:{key}原子计数,首次(返回 1)时EXPIRE window设置窗口,比较计数与 max 决定放行。结合本站代码讲:RedisRequestRateLimiter,key 前缀rl:,多实例共享同一计数。进阶可提 Lua 原子化两步、ZSet 滑动窗口方案。 -
为什么 INCR 是原子的?单线程怎么理解? Redis 命令在服务端由单线程串行执行,一条 INCR 内部完成读-改-写,中间不可能插入其他命令,因此无需锁也不会丢更新。多实例部署时所有实例打到同一个 Redis,计数天然全局一致。
-
SETNX 和 SET NX EX 的区别? SETNX 是老命令,等价于
SET key value NX,不能带过期时间——做锁时持有者一崩溃就死锁。正确姿势是一条SET key value NX EX seconds原子完成「抢锁 + 自动释放」。
自测三问
- 写出「key 不存在时才设置、60 秒过期」的一条命令,并说明它返回 OK 和 (nil) 分别代表什么。
- 本站限流器为什么在 n == 1 时才 EXPIRE?如果每次都 EXPIRE 会怎样?
- 用 StringRedisTemplate 写一个 5 分钟过期的 JSON 缓存,代码是什么?等价的 redis-cli 命令是什么?
小结一句话
String 的工程价值在选项与原子命令:NX/EX 组合出锁与幂等,INCR 出全局一致的计数器,本站限流器就是这两个特性的直接应用。