CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
String 与计数 SET/GET、INCR、过期时间。
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 出全局一致的计数器,本站限流器就是这两个特性的直接应用。
延伸
SET 命令
INCR 命令
下一课:五种数据结构 ——Hash、List、Set、ZSet 登场:收藏、排行、队列、去重各该用什么结构,以及本站 tracks 列表为什么坚持用 String 存 JSON。