从CRUD到每秒10万:你不知道的Redis骚操作

2026-05-26 11 0

写代码的人都知道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 解决的是有序场景下的查找效率问题

每一种数据结构,对应的是一种你在生产环境中会遇到的实际痛点。问题 + 数据结构 = 解决方案。这个公式比背八股文有用多了。

下次写代码之前,先想想你真正的需求是什么。也许你需要的不是一个更快的数据库,而是一个更适合你场景的数据结构。

有问题欢迎评论区聊聊,架构这东西,交流才有进步。

相关文章

不想折腾了?让别人帮你一键部署 AI 工具,不香吗?
后端接口写烂被队友锤?我从血泪史里扒出了这10个致命毛病
懒得折腾?让小龙虾帮你一键部署 AI 神器,省心又省力!
懒得折腾?让小龙虾帮你一键部署 AI 神器,省心又省力!
你的数据库正在疯狂新建连接,而你在疯狂重启服务
你的API为什么要返回”系统繁忙”?——一个后端人的自我检讨

发布评论