写代码的人都知道Redis,但知道你可能在「浪费」它吗?
作为一个在后端泥潭里摸爬滚打了些年的开发者,我见过太多团队把Redis当成了一个「高级String存储」。KV放进去,GET出来,完事。好像花了大价钱买了个能抗高并发的缓存,结果只用了它10%的能力。
今天聊聊那些在生产环境里真正能扛事儿的Redis骚操作。不是入门教程,是让你「原来还能这么干」的实战经验。
一、Pipeline:为所欲为地批量
你还在逐条执行命令吗?来,看个真实场景:
# 大多数人的写法(别笑,我真见过)
start = time.time()
for item in range(1000):
r.set(f"user:{item}", f"data_{item}")
print(f"逐条耗时: {time.time() - start:.2f}s")
# 正确姿势
start = time.time()
pipe = r.pipeline()
for item in range(1000):
pipe.set(f"user:{item}", f"data_{item}")
pipe.execute()
print(f"Pipeline耗时: {time.time() - start:.2f}s")
实测结果:1000条命令,Pipeline比逐条快10倍以上。因为它把N条命令打包成一次网络往返,而逐条执行意味着你要等1000次网络RTT。
重点来了:Pipeline不是事务,不会回滚。它的意义纯粹是减少网络开销。所以当你需要批量操作且不在意原子性的时候,Pipeline是你最好的朋友。
二、Lua脚本:原子性问题的终极大招
Pipeline解决了批量问题,但原子性呢?假设场景:扣减库存,不能超卖。
# 你以为的原子操作(其实不是)
current = r.get("stock:iphone")
if int(current) > 0:
r.decr("stock:iphone") # 这两步之间,100个请求都进来了
经典的TOCTOU问题(Time-of-check to time-of-use)。解决方式:Lua脚本。
lua_script = """
local stock = redis.call('GET', KEYS[1])
if not stock then
return -1
end
if tonumber(stock) < tonumber(ARGV[1]) then
return 0
end
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1
"""
result_val = r.eval(lua_script, 1, "stock:iphone", "1")
if result_val == 1:
print("扣减成功")
elif result_val == 0:
print("库存不足")
else:
print("商品不存在")
Lua脚本在Redis中是原子执行的,一个请求进来,脚本跑完才能处理下一个。这意味着你可以用Lua实现任何复杂的原子操作,而不用担心并发问题。
一个血的教训:Lua脚本不能写太复杂,Redis是单线程的,你的脚本执行时间会影响所有其他请求。曾经有个同事写了50行的Lua脚本做复杂的库存校验,结果线上Redis卡成PPT。
三、Bitmap:用户签到统计的正确打开方式
假设你要记录100万用户的365天签到记录。你会怎么存?
- MySQL?100万 * 365 = 3.65亿条记录
- 普通Key?100万个Key,每个存一个SET or String
来看看Bitmap的骚操作:
# user_id=12345 在 2024-03-15 签到了
# 用时间戳转成天数偏移(以2020-01-01为基准)
base_date = datetime(2020, 1, 1)
target_date = datetime(2024, 3, 15)
day_offset = (target_date - base_date).days
r.setbit("sign:2024", 12345 + day_offset * 100000, 1)
# 按月统计更实用
month_key = "sign:202403"
r.setbit(month_key, user_id, 1)
count = r.bitcount(month_key)
# 计算连续签到天数
check_script = """
local key = KEYS[1]
local uid = tonumber(ARGV[1])
local days = 0
for i = 0, 30 do
if redis.call('getbit', key, uid + i * 100000) == 1 then
days = days + 1
else
break
end
end
return days
"""
Bitmap的空间占用是多少?100万用户,1天 = 100万bit = 125KB。365天 = 45MB。如果用普通String存,至少需要几GB。
适合场景:签到、在线状态、每日活跃、用户行为追踪。
四、HyperLogLog:去重计数的黑魔法
「有多少不重复的访问用户?」这是运营天天问的问题。SQL的DISTINCT要扫全表,GROUP BY要跑半天。Redis的HyperLogLog可以让你「假装」精确统计:
# 伪代码示例
def add_visitor(visitor_id):
r.pfadd("daily:uv:20240315", visitor_id)
def get_uv():
return r.pfcount("daily:uv:20240315")
# HyperLogLog的误差率大约是0.81%
# 也就是说,如果实际UV是100万,HyperLogLog可能会返回99.2万或100.8万
# 但对于「UV统计」这种场景,0.8%的误差重要吗?
HyperLogLog的内存占用是固定的,12KB左右,可以统计2^64个元素的基数。不管你有10万用户还是10亿用户,内存占用一模一样。这玩意儿简直是为我这种穷开发者量身定做的。
五、Stream:别再用List做消息队列了
大多数人的Redis消息队列是这样的:
# 生产者
r.lpush("queue:orders", json.dumps(order))
# 消费者
while True:
task = r.brpop("queue:orders", timeout=30)
if task:
process(task)
问题来了:没法多个消费者同时消费同一条消息,没法知道谁消费了啥,没有ack机制,消息丢了都不知道。
Stream了解一下:
# 生产者
r.xadd("orders", {"user": "张三", "amount": 999}, maxlen=10000)
# 消费者组方式
r.xgroup_create("orders", "processors", "0")
# 消费消息(自动ack)
result_val = r.xreadgroup("processors", "worker1", {"orders": ">"}, count=10)
for message_id, fields in result_val:
process_order(fields)
r.xack("orders", "processors", message_id)
Stream的优势:消费者组实现了「消息分发」,多个worker不会抢同一条消息;ACK机制确保消息处理后才删除;支持消息ID,可以追溯;支持pel(Pending Entries List),worker挂了没ack的消息会自动重新分配。
用一个实际经验:之前用List做延迟任务队列,改成Stream之后,消费吞吐翻了3倍,而且再也没出现过消息丢失的情况。
六、Sorted Set:排行榜的正确姿势
游戏排行榜、月榜、周榜,这类需求怎么做?
# 更新用户积分
r.zadd("rank:monthly", {"user_9527": 1500, "user_6666": 2300})
# 获取Top 10
top10 = r.zrevrange("rank:monthly", 0, 9, withscores=True)
# 获取用户排名(注意排名从0开始)
rank = r.zrevrank("rank:monthary", "user_9527")
# 获取用户在指定分数区间的排名
users_in_range = r.zrevrangebyscore("rank:monthly", 1000, 2000, start=0, num=10)
Sorted Set内部是跳表(Skip List)实现的,插入/删除/查找的复杂度都是O(logN),即便是百万级别的排行榜,查询也只需要几十次比较。
进阶用法:如果你要做「本周积分清零,下周重新开始」的逻辑,不用删数据重建,直接用一个固定格式的key,比如rank:weekly:2024-W12,每周换一个key就行。
最后说几句
Redis远不只是一个缓存。它的数据结构设计背后藏着很多精妙的思想:
- Pipeline 解决的是批处理效率问题
- Lua脚本 解决的是原子性保证问题
- Bitmap/HyperLogLog 解决的是空间效率问题
- Stream 解决的是消息可靠传输问题
- Sorted Set 解决的是有序场景下的查找效率问题
每一种数据结构,对应的是一种你在生产环境中会遇到的实际痛点。问题 + 数据结构 = 解决方案。这个公式比背八股文有用多了。
下次写代码之前,先想想你真正的需求是什么。也许你需要的不是一个更快的数据库,而是一个更适合你场景的数据结构。
有问题欢迎评论区聊聊,架构这东西,交流才有进步。