“String 谁不会啊”
上一篇文章聊了 Redis 的全局视角。这篇开始拆第一个数据结构:String。
String 是 Redis 里最基础的类型。基础到什么程度呢——很多人学 Redis 的前五分钟就用上 SET 和 GET 了。但也正因为太简单,大部分人对它的理解停在了”key-value 存取”这一层。
我刚开始用 Redis 的时候也这样。缓存一个 API 返回结果,SET user:1 '{"name":"zhangsan","score":1200}',再 GET user:1,完事。当时觉得 String 就这样了,没什么好深挖的。
后来陆续遇到了几个需求,才慢慢意识到 String 能做的事情远比”存 JSON”多:
- 一个秒杀接口要扣库存,并发下不准超卖——
INCR的原子性能帮我省掉整段事务代码 - 一个定时任务多台机器同时跑,必须只跑一台——
SET NX几行搞定 - 上亿用户的每日签到统计,DB 方案要建十几张表——Bitmap 用十几兆内存就解决了
这些全在 String 类型里。下面一个一个说。
计数器:INCR 为什么好用
假设你在写一个文章的阅读量功能。最直觉的写法:
# SQL 做法article = db.query("SELECT views FROM articles WHERE id=1")new_views = article.views + 1db.execute("UPDATE articles SET views = ? WHERE id=1", new_views)大多数时候这没问题。但并发上来以后就不行了:两个请求同时读到 views=100,都更新成 101,实际应该到 102。你得加行锁或者用 UPDATE ... SET views = views + 1 这种 SQL 技巧。
Redis 的做法:
INCR article:1:views一条命令,原子递增。没有读-改-写的间隙,不需要加锁。Redis 计数器好用就这一个原因——操作本身是原子的。
几个常用变体:
INCR article:1:views # +1INCRBY article:1:views 10 # +10(批量增加)DECR stock:sku:10001 # -1(扣库存)INCRBYFLOAT user:1:balance -50.5 # 浮点增减做限流也很自然:以 IP 或用户 ID 为 key,每次请求 INCR 一次,再设个过期时间。
MULTI INCR rate:192.168.1.1 EXPIRE rate:192.168.1.1 60EXEC当然固定窗口限流有临界问题(第 59 秒和第 61 秒可能放过双倍流量),但要写个五分钟的简单频控,这就够了。生产环境的滑动窗口限流后面聊 ZSet 的时候细说。
分布式锁:从 SETNX 到 SET NX PX
计数器还算好理解。String 做分布式锁这件事,我第一次见的时候觉得有点”跨界”——锁不是应该用 synchronized 或者 ReentrantLock 吗,跟 Redis 有什么关系?
场景是这样的:一个定时任务部署在三台机器上,但你只想让一台执行。这就是分布式锁要解决的问题——跨进程的互斥。
Redis 做锁的核心思路:谁先 SET 成功,谁就拿到锁。
第一代:SETNX(别用了)
SETNX lock:send_email "server_1"SETNX 的意思是”SET if Not eXists”。如果这个 key 不存在,设置成功,拿到锁;如果已存在,设置失败,锁被别人占着。
问题来了——拿到锁的机器挂了,key 永远不会被删除。死锁。
第二代:SETNX + EXPIRE(也不行)
SETNX lock:send_email "server_1"EXPIRE lock:send_email 10给锁加个过期时间,挂了也能自动释放。但这两条命令不是原子的。如果在 SETNX 成功之后、EXPIRE 执行之前进程崩溃了——还是死锁。
第三代:SET NX PX(这才是对的)
SET lock:send_email "server_1" NX PX 10000一条命令完成”加锁 + 设过期”。Redis 2.6.12 之后支持,现在的主流写法。
NX = 不存在才设置,PX 10000 = 10 秒后自动过期。原子操作,消除了死锁根源。
释放锁的坑
加锁对了,释放还有坑。
# 危险的写法def release_lock(key): redis.delete(key)如果业务执行超过了锁的过期时间,锁自动释放了,另一个进程拿到了锁——然后你的 delete 把别人的锁删了。
正确的做法是:value 放一个唯一标识(UUID),释放时先判断是不是自己的锁。而且判断和删除必须原子(Lua 脚本):
if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1])else return 0end实际项目里怎么做
自己手写分布式锁处理了原子性、过期、误删,还有一个问题没解决——锁续期。业务执行时间不确定,设 10 秒可能不够,设 60 秒又太久。
Java 生态里 Redisson 把这些都封装好了:默认 30 秒过期,后台每 10 秒自动续期(看门狗机制),可重入、公平锁都支持。生产环境直接用就行,不用重复造轮子。
RLock lock = redisson.getLock("lock:send_email");lock.lock(); // 自动续期,阻塞等待try { doSomething();} finally { lock.unlock();}Bitmap:挤出 String 的最后一滴
Bitmap 不是独立的数据类型。它就是 String,只不过不把内容当字符串看,而是当一串二进制位来操作。
每个位(bit)只能存 0 或 1。听起来好像没什么用,但如果是”今天签没签到""这个用户是否在线”这种二值状态,一个 bit 存一个用户,内存效率极高。
算一笔账:一亿用户的每日签到状态,如果用 MySQL,user_id + date + status 一行算 30 字节,一亿行 ≈ 3GB。Redis Bitmap 只需要一亿 bit ≈ 12 MB。
签到功能
# 用户 89757 在 5 月 16 日签到(16 号对应 offset 15,因为 offset 从 0 开始)SETBIT sign:89757:202505 15 1
# 检查 5 月 16 日签没签GETBIT sign:89757:202505 15# → 1(签了)
# 统计 5 月签到天数BITCOUNT sign:89757:202505# → 19(签了 19 天)
# 第一次签到是哪天BITPOS sign:89757:202505 1# → 0(5 月 1 号)连续签到统计
更有用的是 BITOP——多个 Bitmap 之间做与或非运算。查”连续 7 天都签到的用户”:
BITOP AND sign:consecutive:week1 sign:20250510 sign:20250511 sign:20250512 sign:20250513 sign:20250514 sign:20250515 sign:20250516BITCOUNT sign:consecutive:week1七个 Bitmap 做 AND,结果里 bit=1 的位置就是连续 7 天签到的用户 ID。
其他 Bitmap 场景
- 用户在线状态:每个用户一个 bit,在线置 1,离线置 0
- 布隆过滤器:Redis 有专门的 BloomFilter 模块,但基础版也可以用 Bitmap 实现
- 活跃用户统计:
BITCOUNT快速统计日活,比COUNT DISTINCT快两个数量级
缓存是怎么做的
String 最常用的场景还是缓存。但”缓存”两个字背后有一些设计选择。
基本缓存模式
# 缓存一个 API 响应,10 分钟过期SET user:profile:1 '{"name":"zhangsan","email":"z@test.com"}' EX 600
# 读取时data = GET user:profile:1if data is None: data = query_database(1) SET user:profile:1 data EX 600return data这是 Cache-Aside 模式:读缓存 → 未命中查 DB → 写缓存。绝大部分缓存场景都用这个。
过期时间怎么定
这个问题没有标准答案,但有个原则:宁短勿长。数据变了没来得及更新缓存,脏数据的时间窗口就是过期时间。设得越短,不一致的可能性越小,但数据库压力越大。反过来设太久,数据就不准了。
一般业务缓存设 1-10 分钟。用户 Session 设 30 分钟。配置类的数据可以设几小时——因为它很少变。
不要什么都往 String 里塞
刚开始用 Redis 缓存很容易走极端:所有 SQL 查询结果都缓存一遍。有几个坑:
- 大 Key 问题:一个 String 存几 MB 的数据,读写变慢,还影响主从同步。单 key 控制在 10KB 以内比较好,上限不要超过 1MB。
- 热 Key 问题:一个 key 被几百个请求同时抢,Redis 单线程反而变成瓶颈。解决方法是用多个 key 分摊(比如
user:1、user:1:backup)。 - 对象存成 JSON String 还是 Hash:这是下一篇要重点聊的。简单说,如果你经常要单独更新某个字段,用 Hash;如果每次都是整出整进,String 没问题。
一张表总结 String 的用法
| 场景 | 核心命令 | 关键点 |
|---|---|---|
| 缓存 JSON | SET key val EX N | 设过期、控制大小 |
| 计数器 | INCR / INCRBY | 原子操作,替代 SELECT+UPDATE |
| 分布式锁 | SET key uuid NX PX 30000 | 原子加锁+过期,Lua 释放 |
| 限流 | INCR + EXPIRE | 固定窗口,生产建议用滑动窗口 |
| 签到 | SETBIT / BITCOUNT | offset=日期-1,BITOP 算连续签到 |
| 在线状态 | SETBIT / GETBIT | 用户 ID 作为 offset |
| 全局 ID 生成 | INCRBY | 批量取号减少 Redis 访问 |
String 看着简单,但它藏了 Redis 最关键的设计思路:把操作都做成原子的。计数器是,分布式锁是,Bitmap 也是。
下一篇聊 Hash——什么时候该把对象拆成字段存,什么时候该序列化成 JSON String。这个选择影响内存、性能、和代码复杂度。
这是 Redis 学习系列的第 2 篇。下一篇:Redis学习系列 | 对象存 JSON String 还是 Hash?
部分信息可能已经过时





