大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
String 与 Bitmap:验证码、计数器与打卡位图
这一课你将能做到
- 熟练使用 String 的读写、批量操作和原子计数命令
- 用「计数器 + TTL」独立实现判题提交限流
- 用 Bitmap 实现用户做题打卡和日活统计,并说出它省内存的原因
为什么要学
String 是 Redis 最简单、也是使用频率最高的类型。别被名字骗了,它不只是「字符串」——网站里的验证码、浏览量、接口限流计数、分布式锁标记,底层几乎都是 String。
本站判题服务有个硬需求:防止用户写脚本疯狂提交,把判题机刷爆。规则是「同一个用户 10 秒内最多提交 5 次」。这种计数有两个要求:每次加一必须是原子的(高并发下不能计错),计数窗口必须自动归零。用 MySQL 行锁做又慢又重,用 Redis 的 INCR + TTL,两行命令搞定。
另外,打卡功能(用户今天做了哪几道题)如果为每个用户每天存一条记录,用户量一大很浪费。Bitmap(位图)能把「是/否」状态压到一个个二进制位里,1 万个用户一天的打卡信息只占约 1.2 KB。
先建立图像
String 在 Redis 内部就是一个字节数组,二进制安全——文字、数字、序列化内容、图片字节都能存:
SET site:views 10086
┌──────────────────────────────┐
│ key: site:views │
│ value: "10086" (字节序列) │ ← INCR 时 Redis 按整数解释
└──────────────────────────────┘
Bitmap 本质还是 String,只是我们按「位」来操作:
key: checkin:2026-10-07
位编号: 0 1 2 3 4 5 6 ...
值: 0 0 1 0 0 1 0 ...
↑ ↑
用户2做了题 用户5做了题
跟着做
步骤 1:基础读写与带选项的 SET
127.0.0.1:6379> SET captcha:13800000000 482916
OK
127.0.0.1:6379> GET captcha:13800000000
"482916"
127.0.0.1:6379> STRLEN captcha:13800000000
(integer) 6
SET 的完整选项在 Redis 7 中非常实用:
SET key value [EX 秒] [PX 毫秒] [NX|XX] [KEEPTTL] [GET]
EX 300:300 秒后过期;NX:只在键不存在时才设置成功(分布式锁的核心,第 9 课细讲);XX:只在键已存在时才设置;GET:设置新值的同时返回旧值。
验证码标准写法(5 分钟有效):
127.0.0.1:6379> SET captcha:13800000000 482916 EX 300
OK
步骤 2:原子计数 INCR / DECR
127.0.0.1:6379> SET problem:1024:views 0
OK
127.0.0.1:6379> INCR problem:1024:views
(integer) 1
127.0.0.1:6379> INCRBY problem:1024:views 10
(integer) 11
127.0.0.1:6379> DECR problem:1024:views
(integer) 10
127.0.0.1:6379> INCRBYFLOAT problem:1024:views 0.5
"10.5"
重点:INCR 是原子的。哪怕同一时刻一千个请求同时加一,Redis 单线程串行执行,结果一定准确。如果你在应用里先 GET、加一、再 SET,高并发下必然丢计数。
步骤 3:实现判题限流(固定窗口)
规则:用户 1001 在每 10 秒窗口内最多提交 5 次。用户每次点提交时执行:
127.0.0.1:6379> INCR ratelimit:judge:user:1001
(integer) 1
127.0.0.1:6379> EXPIRE ratelimit:judge:user:1001 10
(integer) 1
连续提交,观察计数涨到 6:
127.0.0.1:6379> INCR ratelimit:judge:user:1001
(integer) 6
应用里的判断逻辑:INCR 的返回值如果等于 1,说明窗口刚开启,顺手设 10 秒过期;如果大于 5,拒绝这次提交并提示「提交过于频繁」。10 秒后键自动消失,下一次提交开启新窗口。
注意:上面两条命令在极端情况下(设完 INCR 进程崩溃)会导致键永不过期。生产中可以用第 9 课的 Lua 脚本把「计数 + 首次设过期」合成原子操作,或者直接用 SET 的变体思路。先理解原理,原子化后面统一解决。
步骤 4:批量操作减少网络往返
127.0.0.1:6379> MSET problem:1024:views 100 problem:1025:views 50 problem:1026:views 8
OK
127.0.0.1:6379> MGET problem:1024:views problem:1025:views problem:1026:views
1) "100"
2) "50"
3) "8"
首页一次展示十道题的浏览量,用 MGET 比循环 GET 十次快得多——省的是网络往返时间。
其他常用命令:
127.0.0.1:6379> APPEND captcha:13800000000 -old
(integer) 10 # 返回追加后的总长度
127.0.0.1:6379> GETRANGE captcha:13800000000 0 5
"482916"
127.0.0.1:6379> GETSET problem:1024:views 0 # 取旧值并重置
"100"
步骤 5:用 Bitmap 记录做题打卡
假设用户 ID 是数字,今天(2026-10-07)用户 2 和用户 5 做了题:
127.0.0.1:6379> SETBIT checkin:2026-10-07 2 1
(integer) 0
127.0.0.1:6379> SETBIT checkin:2026-10-07 5 1
(integer) 0
检查某个用户今天有没有做题:
127.0.0.1:6379> GETBIT checkin:2026-10-07 2
(integer) 1
127.0.0.1:6379> GETBIT checkin:2026-10-07 3
(integer) 0
统计今天一共有多少人做了题(做题日活):
127.0.0.1:6379> BITCOUNT checkin:2026-10-07
(integer) 2
Redis 7 中 BITCOUNT 可以加范围单位:BITCOUNT key 0 -1 BYTE(默认按字节)或 BIT 按位统计。
步骤 6:找出第一个做题的人和多日合并
127.0.0.1:6379> BITPOS checkin:2026-10-07 1
(integer) 2
想统计「本周有 3 天以上做过题的人数」,可以把 7 个每日位图按位与/或合并:
127.0.0.1:6379> BITOP OR checkin:week:41 checkin:2026-10-05 checkin:2026-10-06 checkin:2026-10-07
(integer) 1
127.0.0.1:6379> BITCOUNT checkin:week:41
(integer) 2
BITOP 支持 AND、OR、XOR、NOT。每日键记得设置过期(比如保留 400 天),周/月统计键按需保留。
讲清楚要点
- String 的值就是字节,数字是「看起来像数字」的字节:
INCR要求值能被解释成 64 位整数,否则报value is not an integer or out of range。 - INCR/DECR/INCRBY 天然原子:这是 Redis 计数器方案成立的根基,永远不要用「读-改-写」三步代替。
- 固定窗口限流的优缺点:优点是简单、一条命令;缺点是窗口边界处可能放过两倍流量(比如第 9 秒刷 5 次、第 11 秒又刷 5 次)。要求更平滑可以用滑动窗口(ZSET,第 6 课)或令牌桶(Lua,第 9 课)。
- Bitmap 的位编号从 0 开始,自动扩容:
SETBIT key 999999 1会自动分配约 12.5 KB。因此位编号必须是稠密的小整数(如自增用户 ID),用随机大数字当编号会浪费大量内存。 - Bitmap 不是独立类型:它就是 String,
TYPE看到的是string,所以 String 命令(如GET、STRLEN)对位图键同样适用,只是一般不这么用。
你可能会懵的地方
-
INCR 一个不存在的键会怎样? Redis 会先把它当 0,再执行加一,所以结果是 1,不需要提前初始化。
-
验证码为什么不要直接存用户输入的明文对比? Redis 里存的是服务端生成的答案,用户提交的验证码拿来和
GET结果比对。另外验证码比对应忽略大小写,建议统一转小写后存储和比对;验证通过立即DEL,防止同一个码被反复使用。 -
固定窗口限流的窗口从什么时候开始算? 从该用户在这个周期内第一次请求、键被创建的那一刻开始,而不是整点。这叫「滚动的固定窗口」,好处是每个用户的窗口互相错开,不会集中在整点冲击系统。
-
1 亿用户的 Bitmap 要占多少内存? 1 亿个 bit = 约 12.5 MB。相比「用户 ID 集合」动辄几百 MB,位图在大规模布尔统计上几乎无敌,代价是只能表示是/否,且要求 ID 为整数。
-
BITCOUNT 统计的是字节数还是人数? 统计的是值为 1 的二进制位的数量,在打卡场景里就是「做了题的不同用户数」,因为每个用户只占一个固定位。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
GET 计数 → 应用里 +1 → SET | 高并发丢更新 | 直接用 INCR |
INCR 后忘记 EXPIRE | 限流键永不过期,用户被永久限死 | 首次计数时设置窗口 TTL,或用 Lua 原子化 |
| 用 String 存整个用户对象的 JSON | 改一个字段要重写整个值 | 用 Hash(第 5 课) |
| 用 UUID 当 Bitmap 的位编号 | 编号巨大,内存爆炸 | 位图只配合自增整数 ID |
| 验证码验证成功后不删除 | 验证码在有效期内可被重放 | 比对成功立即 DEL |
| 循环发几十条 GET | 网络往返耗时 | 用 MGET 批量取 |
卡点急救
- INCR 报
not an integer:该键的值不是整数形式(比如存过小数或带空格)。换一个键,或确认INCRBYFLOAT是否才是你想要的。 - 限流不准,有人在边界刷了两倍请求:这是固定窗口算法的固有特性,不是 Bug。收紧阈值、改用滑动窗口 ZSET 或 Lua 令牌桶。
- BITCOUNT 结果对不上:检查是否同一个用户被写了多个位(ID 映射错),以及统计范围参数;可以用
GETBIT key 用户ID逐个抽查。
面试常问
-
Redis 怎么做限流? 固定窗口:
INCR计数 + 首次EXPIRE窗口时间,超过阈值拒绝;要求更高可用 ZSET 滑动窗口(按时间戳打分,先删窗口外旧成员再判断数量),或 Lua 实现令牌桶保证原子。 -
Bitmap 统计 UV 的原理?为什么省内存? 把用户 ID 映射为位偏移,访问就把对应位置 1,
BITCOUNT即得去重人数。1 亿用户只需约 12.5 MB。缺点是要求 ID 为整数;非整数 ID 可哈希后取模,但有误差,或改用 HyperLogLog。 -
SETNX、SET NX、SET ... EX 有什么区别?
SETNX是老命令,只判断存在性,不能同时设过期;现代写法统一用SET key value NX EX 30,一条命令同时保证「不存在才设置」和「自动过期」,避免加锁后崩溃导致死锁。
自测三问
- 用
INCR实现「每 60 秒最多 10 次」的限流,写出命令并说明返回值怎么用。 - 为什么高并发计数不能用 GET + SET 而要用 INCR?
- 100 万用户的日活打卡位图大约占多少内存?写出统计今天打卡人数的命令。
小结一句话
String 用 SET/GET 装一切、用 INCR 做原子计数、用 Bitmap 把海量布尔状态压进二进制位,验证码和限流这两个高频面试场景就都拿下了。
延伸
- String 命令文档
- Bitmap 介绍
- 下一课:List 列表——把判题任务排成队列,以及它作为消息队列的边界。