Mobile wallpaper 1Mobile wallpaper 2Mobile wallpaper 3Mobile wallpaper 4
2194 字
11 分钟
Redis学习系列 | String 比你想象能打

“String 谁不会啊”#

上一篇文章聊了 Redis 的全局视角。这篇开始拆第一个数据结构:String。

String 是 Redis 里最基础的类型。基础到什么程度呢——很多人学 Redis 的前五分钟就用上 SETGET 了。但也正因为太简单,大部分人对它的理解停在了”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 + 1
db.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 # +1
INCRBY 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 60
EXEC

当然固定窗口限流有临界问题(第 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 0
end

实际项目里怎么做#

自己手写分布式锁处理了原子性、过期、误删,还有一个问题没解决——锁续期。业务执行时间不确定,设 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:20250516
BITCOUNT 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:1
if data is None:
data = query_database(1)
SET user:profile:1 data EX 600
return data

这是 Cache-Aside 模式:读缓存 → 未命中查 DB → 写缓存。绝大部分缓存场景都用这个。

过期时间怎么定#

这个问题没有标准答案,但有个原则:宁短勿长。数据变了没来得及更新缓存,脏数据的时间窗口就是过期时间。设得越短,不一致的可能性越小,但数据库压力越大。反过来设太久,数据就不准了。

一般业务缓存设 1-10 分钟。用户 Session 设 30 分钟。配置类的数据可以设几小时——因为它很少变。

不要什么都往 String 里塞#

刚开始用 Redis 缓存很容易走极端:所有 SQL 查询结果都缓存一遍。有几个坑:

  1. 大 Key 问题:一个 String 存几 MB 的数据,读写变慢,还影响主从同步。单 key 控制在 10KB 以内比较好,上限不要超过 1MB。
  2. 热 Key 问题:一个 key 被几百个请求同时抢,Redis 单线程反而变成瓶颈。解决方法是用多个 key 分摊(比如 user:1user:1:backup)。
  3. 对象存成 JSON String 还是 Hash:这是下一篇要重点聊的。简单说,如果你经常要单独更新某个字段,用 Hash;如果每次都是整出整进,String 没问题。

一张表总结 String 的用法#

场景核心命令关键点
缓存 JSONSET key val EX N设过期、控制大小
计数器INCR / INCRBY原子操作,替代 SELECT+UPDATE
分布式锁SET key uuid NX PX 30000原子加锁+过期,Lua 释放
限流INCR + EXPIRE固定窗口,生产建议用滑动窗口
签到SETBIT / BITCOUNToffset=日期-1,BITOP 算连续签到
在线状态SETBIT / GETBIT用户 ID 作为 offset
全局 ID 生成INCRBY批量取号减少 Redis 访问

String 看着简单,但它藏了 Redis 最关键的设计思路:把操作都做成原子的。计数器是,分布式锁是,Bitmap 也是。

下一篇聊 Hash——什么时候该把对象拆成字段存,什么时候该序列化成 JSON String。这个选择影响内存、性能、和代码复杂度。


这是 Redis 学习系列的第 2 篇。下一篇:Redis学习系列 | 对象存 JSON String 还是 Hash?

Redis学习系列 | String 比你想象能打
https://qiandaos.top/posts/redis-learning-series/2-string/
作者
千岛寒流
发布于
2025-05-14
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00