Redis题目
String使用
1.实现一个接口,统计文章浏览量,每次访问+1,要求高并发安全。考点:INCR、原子性
在Java中直接使用Redis的INCR方法实现,而不要先计算再赋值,或将count++写回Redis。因为先计算再赋值本身不具备原子性。而count++看起来是一步,实际上CPU执行起来是三步。先读取count的值,再计算count+1,最后写回count。同样不具备原子性。
Redis的INCR方法本身具有原子性,是一步操作到位
追问1:如果这篇文章突然成为热点,每秒访问量达到 100 万,你发现 Redis 的这个 INCR article_view_count 成为了瓶颈,你会如何改造这个浏览量统计系统?
我的回答:分层统计,将article_view_count 分为多个不同的key,如article_view_count_1,客户端访问时随机到这些key中,最后再将这些key相加统计总和
优秀回答: 对于热点文章,单个 Redis Key 会成为热点,因此采用分片计数。比如将 article_view_count 拆成多个 shard key,访问时通过 hash(userId) 或随机算法选择 shard,然后执行 INCR。查询时可以聚合多个 shard 的值,或者通过异步任务定时合并到数据库。对于普通文章使用单 key 即可,只有热点文章开启分片,避免大量无效 key 占用 Redis。
追问2:假设你的分片计数方案上线后,运营发现浏览量必须“绝对准确”,不能允许丢失任何一次访问,同时 Redis 宕机也不能影响统计,你会如何重新设计?
如果是这样的话,单纯用Redis的INCR是不够的,因为
- Redis故障时可能丢数据
- 网络异常时客户端不知道是否成功
- 主从复制存在延迟
- AOF/RDB架构存在数据风险
应该调整架构。
生产环境一般采用消息队列削峰+异步累计
结构:
用户访问
|
v
业务服务
|
+-----> Kafka / RocketMQ
|
v
消费者集群
|
v
Redis分片计数
|
v
MySQL
-
用户访问先到达MQ(如Kafka),MQ能保证消息不会丢失。因为只有客户端(即消费者)确认接收时才会释放消息。
-
消费者接收到消息,执行INCR,如果成功则传递回确认消息。如果消费者挂了,没有确认接收,MQ会重新投递消息,避免消息丢失。
注:通常MQ会保证至少一次投递消息。也就是说,有重复投递的风险
- 定时同步数据库(如Mysql),作为数据的长期存储。
追问3:MQ 消息重复消费怎么办?
因为MQ保证的是至少一次投递消息,可能会出现重复投递的情况。
消息A
第一次消费成功
但是ACK丢失
MQ再次发送
第二次消费
如果出现以上情况就会导致浏览量+2
因此,我们需要对消息进行去重操作。
- 可以在用户访问时添加一个唯一事件id。即每次访问生成
eventId=uuid。 - 消费者每次收到消息事件,先检查
eventId是否存在,如果该消息存在则跳过。不存在则添加。这样保证了幂等性。
幂等性:对同一个操作执行一次和执行多次,最终产生的结果是一样的
优秀回答:
如果要求绝对准确,我不会直接依赖 Redis INCR,因为 Redis 本身不是可靠事件存储。可以采用 MQ 作为访问事件缓冲层,请求产生浏览事件写入 Kafka/RocketMQ,通过多副本保证消息不丢。消费者消费消息后进行分片计数,并通过幂等机制避免重复消费。Redis用于实时展示,数据库用于最终一致性存储。如果业务要求强一致,则直接以数据库事件表作为事实来源,Redis作为缓存。
2.使用 Redis 生成全局唯一订单号,要求趋势递增、不重复。 考点:INCR + 时间戳
我的回答:用时间戳来作为订单号,在生成前,在业务代码中维护一个偏移值,先检测Redis中是否存在这个key,如果存在,就调用INCR加上偏移值,然后偏移值加一。
问题:偏移值维护在业务代码里是很危险的。业务代码里的内存变量不是分布式共享状态。多实例环境下不能保证唯一。
优质回答:
使用时间戳 + Redis INCR生成序列号。时间戳保证趋势递增,Redis INCR保证分布式环境下序列号唯一。例如每天创建一个Redis key:
order:id:20260807
每次生成订单号执行:
INCR order:id:20260807
得到当天递增序列,然后拼接时间:
yyyyMMdd + sequence
Redis的INCR是原子操作,多实例部署下也能保证序列不重复。
追问1:序列号太长可能会溢出,怎么防止序列号过长?
可以设计类似 Twitter Snowflake的结构。订单号由以下三部分组成
时间戳 | 机器ID | 序列号
追问2:Redis挂了怎么办
订单创建
|
生成订单号失败
方案:
- Redis Cluster
- 主从复制
- Sentinel
- 本地号段缓存
实际上很多大型系统不会让 Redis 直接承担唯一ID核心职责,而会采用:
- Snowflake
- Leaf
- 数据库号段模式(更推荐,在追问3中有讲述)
号段模式使Redis从强依赖转为低频依赖,如果Redis突然挂掉,已经申请好的号段仍可在业务部分继续处理
优质回答:
首先不会让Redis成为订单号生成的唯一依赖。简单方案可以使用Redis Sentinel保证高可用,但由于Redis主从复制存在延迟,不能保证绝对不重复。生产环境更推荐号段模式,订单服务提前通过Redis INCRBY申请一批ID,在本地缓存使用,降低Redis依赖。如果Redis短暂故障,已经申请的号段仍可以继续使用。对于更大规模系统,可以采用独立ID生成服务或者Snowflake算法。
追问3:如果你的订单系统每天产生 1 亿订单,Redis 的 INCR order:id:20260807 这个单 Key 会不会成为瓶颈?如果会,如何设计?
Redis单Key的瓶颈主要来自所有请求竞争同一个序列号生成点(即同一个功能服务),需要进行分片或改造生成方式。
不过这里的分片和浏览量统计的分片有区别。因为浏览量的分片最终只是求和。
方案1:Redis号段模式(推荐)
不要每一个订单号都访问Redis。防止Redis阻塞。改为一次向 Redis 申请一批号码。申请的号码由业务逻辑代码生成(而非Redis)
Redis
INCRBY 10000
|
v
服务节点本地缓存号段
10001 - 20000
创建订单
|
v
本地取ID
优点:
- Redis压力降低10000倍
- 保证唯一
- 趋势递增
缺点: - 服务宕机会浪费部分号段
例如:
服务拿到:
10001-20000
只用了10001-10500
然后机器挂了
剩余号码废弃。
通常订单号允许有空洞,所以没问题。
方案2:Snowflake算法
如果要求:
- 高性能
- 不依赖 Redis
- 多机部署
可以使用 Snowflake。结构:
时间戳 | 机器ID | 序列号
例如:
41 bit 时间
10 bit 机器
12 bit 序列
同一毫秒:
机器1:
timestamp + 001 + 0001
timestamp + 001 + 0002
机器2:
timestamp + 002 + 0001
保证:
- 不重复
- 趋势递增
- 单机每毫秒支持大量生成
缺点:
需要解决机器ID分配。
3.将用户对象序列化后存入 Redis,查询时若不存在则从 DB 加载并写入 Redis。考点:缓存穿透、序列化
缓存穿透(Cache Penetration) 是高并发系统中常见的一种缓存问题,指的是:大量请求查询一个缓存和数据库中都不存在的数据,导致请求绕过缓存直接打到数据库,使数据库压力骤增,甚至崩溃。
我的回答:
将对象序列化存到Redis中,查询时若不存在则向DB中查询并写入Redis。若DB中也不存在,则同样将该请求写入Redis,值为none。下次再有相同请求时,直接由Redis返回,防止缓存穿透,减小DB的性能压力
优质回答:
查询用户对象时,首先查询 Redis,如果存在则反序列化返回。如果 Redis 不存在,则查询数据库,查询成功后将对象进行序列化,例如转换成 JSON 格式存入 Redis,并设置合理过期时间。
如果数据库也不存在,为了防止缓存穿透,可以采用缓存空对象方案,将不存在的数据以特殊值例如 null 写入 Redis,同时设置较短 TTL,避免数据长期不一致。
在高并发场景下,还需要考虑缓存击穿问题,可以通过分布式锁保证只有一个线程查询 DB。另外对于恶意请求大量不存在 ID 的情况,可以使用布隆过滤器提前拦截。
追问1:什么是布隆过滤器?怎么解决缓存穿透?
布隆过滤器核心由两部分组成:
- 一个很大的位数组(bit array)
- 多个哈希函数(hash function)
它最大的特点:
- 如果布隆过滤器判断不存在,那么一定不存在
- 如果判断存在,那么可能存在,也可能不存在(误判)
优质回答:
布隆过滤器是一种基于位数组和多个哈希函数实现的概率型数据结构,用于快速判断一个元素是否存在。它具有空间占用小、查询效率高的特点,但是存在一定误判率。
在缓存穿透场景中,可以将数据库中已有的数据ID提前加载到布隆过滤器,请求到达时先经过过滤器判断。如果判断不存在,则直接返回,避免大量不存在的数据请求访问数据库。不过由于存在误判,判断存在的数据仍需要继续查询 Redis 和数据库。
4.限制某接口 1 分钟内最多访问 100 次。考点:INCR + EXPIRE
优质回答:
可以使用 Redis 的 INCR + EXPIRE 实现固定窗口限流。每个用户和接口生成一个唯一 key,请求时执行 INCR 增加访问次数,第一次访问时设置 EXPIRE 60 秒。当计数超过100时拒绝请求。为了保证 INCR 和 EXPIRE 的原子性,生产环境可以使用 Lua 脚本实现。
5.使用 Redis 实现一个可重入的分布式锁。考点:SET NX EX、Lua、看门狗
优质回答:
Redis实现可重入分布式锁,首先使用 SET key value NX EX 保证只有一个客户端能够获取锁,并设置过期时间防止死锁。value保存线程唯一标识,用于释放锁时校验所有权。释放锁使用Lua脚本保证判断和删除的原子性。为了支持可重入,需要记录线程ID和重入次数,重复获取时增加计数,释放时减少计数,直到计数为0才删除锁。如果业务执行时间不确定,可以使用看门狗机制自动续期,避免锁提前过期。
使用 Redis 实现可重入分布式锁,主要考察:
SET NX EX:保证锁的获取安全性- Lua 脚本:保证释放锁的原子性
- 看门狗(Watchdog):自动续期,防止业务执行时间超过锁过期时间
可重入的分布式锁(Reentrant Distributed Lock) 指的是:
在分布式环境下,同一个线程(或同一个客户端实例)已经获取了一把锁后,再次尝试获取这把锁时,不会被自己阻塞,而是允许再次获取,并记录获取次数;只有释放次数与获取次数相等时,锁才真正释放。
拆开理解:
- 分布式锁:解决多个服务节点同时操作共享资源的问题。
- 可重入:同一个持有锁的人,可以再次进入,不会把自己锁死。
锁的value应该是:
{
"threadId":"abc123",//代表线程的id
"count":2//代表进入次数
}
获取锁(第一次需要NX)
Redis 命令:
SET lock:order:1001 uuid NX EX 30
含义:
| 参数 | 作用 |
|---|---|
lock:order:1001 |
锁的 key |
uuid |
当前线程唯一标识 |
NX |
不存在才创建 |
EX 30 |
30秒自动过期 |
可重入加锁逻辑
通过Lua脚本实现原子化释放锁
if redis.call('exists', KEYS[1]) == 0 then
-- 第一次获取锁
redis.call(
'hset',
KEYS[1],
ARGV[1],
1
)
redis.call(
'expire',
KEYS[1],
ARGV[2]
)
return 1
elseif redis.call(
'hexists',
KEYS[1],
ARGV[1]
) == 1 then
-- 同一个线程重入
redis.call(
'hincrby',
KEYS[1],
ARGV[1],
1
)
return 1
else
-- 别人的锁
return 0
end
逻辑:
锁不存在
|
+--> 创建锁 count=1
锁存在
|
+--> owner是自己
| |
| +--> count++
|
+--> owner不是自己
|
+--> 获取失败
锁释放
释放锁需要判断:
当前锁是不是我自己的?
Redis value 保存线程唯一 ID:
key:
lock:order
value:
uuid-123
count-2
释放时:
Lua:
if redis.call('hexists', KEYS[1], ARGV[1]) == 1 then
local count = redis.call(
'hincrby',
KEYS[1],
ARGV[1],
-1
)
if count == 0 then
redis.call('del', KEYS[1])
end
return count
else
return 0
end
执行逻辑:
释放锁:
1. 判断是不是自己的锁
2. 如果是:
count - 1
3. 如果 count == 0:
删除锁
为什么用 Lua?因为普通代码:
GET
判断
DEL
三个步骤不是原子操作。可能:
GET成功
锁过期
别人获取锁
DEL删除别人锁
Lua 可以让 Redis 一次性执行完成。
看门狗(锁续期)
看门狗解决的问题是:当业务执行时间超过了锁的过期时间时,业务代码还在运行时却失去了锁,而被其他线程进入占有锁。
看门狗设计思想:在锁快要过期时,自动延长过期时间
获取锁
|
TTL=30s
|
后台线程启动
|
10秒后
|
续期30秒
|
业务完成
|
释放锁
生产环境一般不用自己手动实现,而通过Redisson实现
Redisson提供了所有常见的需求的完整解决方案
Hash使用
6.用户信息缓存,用 Hash 存储用户信息(id、name、age),支持字段级更新。考点:HSET、HGET
HSET user:1001 id 1001 name Tom age 20
获取单个字段
redis/db1> HGET user:1001 id
1001
设置单个字段
redis/db1> hset user:1001 id 1100
0
redis/db1> HGET user:1001 id
1100
获取所有字段
redis/db1> HGETALL user:1001
1) id
2) 1001
3) name
4) Tom
5) age
6) 20
检查某个字段是否存在
redis/db1> EXISTS user:1001
1
redis/db1> EXISTS user:1000
0
检查Hash的某个字段是否存在
redis/db1> HEXISTS user:1001 id
1
redis/db1> HEXISTS user:1001 addr
0
7.商品库存管理,使用 Hash 存储商品库存和价格,支持库存扣减。考点:原子更新、Lua
商品库存和价格需要用Hash类型来存储,因为库存和价格都是经常变动的字段,因为Hash支持字段级更新,用Hash来存储可以避免每次都更新整个结构。而在更新时要使用Lua脚本更新,因为Lua能保证操作原子性
注:尽管HINCRBY也是原子操作,但库存扣减往往是带有条件的扣减,而不是直接无条件扣减值。
假设商品库存 Hash:
HSET product:1001 stock 100 price 199
如果只是无条件扣减:
HINCRBY product:1001 stock -1
等价于:
stock = stock - 1
这是原子的,可以用。
例如:
HINCRBY product:1001 stock -5
扣 5 个库存。
但是实际库存扣减通常需要:
库存必须大于购买数量,才允许扣减。
例如:
库存 = 3
用户购买 = 5
不能变成:
stock = -2
这时:
HINCRBY product:1001 stock -5
就有问题,因为它不知道业务规则,只负责数学运算。Redis 也不会突然产生商业常识,毕竟它只是一个速度很快的键值数据库,不是仓库管理员。
正确做法
例如:
HSET product:1001 stock 100 price 199
Lua:
local stock = redis.call('HGET', KEYS[1], 'stock')
if tonumber(stock) >= tonumber(ARGV[1]) then
redis.call('HINCRBY', KEYS[1], 'stock', -tonumber(ARGV[1]))
return 1
else
return 0
end
调用:
EVAL "local stock = redis.call('HGET', KEYS[1], 'stock'); if tonumber(stock) >= tonumber(ARGV[1]) then redis.call('HINCRBY', KEYS[1], 'stock', -tonumber(ARGV[1])); return 1 else return 0 end" 1 product:1001 5
执行过程:
读取库存
↓
判断库存是否足够
↓
扣减库存
整个 Lua 脚本作为一个 Redis 命令执行,中间不会被其他客户端插入。
优质回答
如果只是简单扣库存,可以使用 HINCRBY key field -数量,因为它本身是原子操作。但实际库存扣减需要先判断库存是否充足,再执行扣减,判断和更新必须作为一个整体原子执行,因此通常使用 Lua 脚本,在脚本中通过 HGET 查询库存,再通过 HINCRBY 完成扣减。
8.购物车,每个用户一个购物车,商品 ID → 数量。考点:Hash vs String 设计
我的回答:用Hash来设计购物车更好,用商品id当做Hash元素的key,用商品数量当做Hash元素的value。因为用户可能经常改变购物车的状态,如增加商品,或某件商品不想要了而随时改变商品。Hash支持字段级更新,当购物车信息改变时,不必更新所有字段,只要小范围更新即可。如果用String来设计购物车的话,可以,但不推荐。因为用户每次修改购物车信息都要修改整个value值,也就是所有商品的信息。如果用户购物车内商品很多的话,性能差距将会变的非常明显。
优质回答:
购物车场景推荐使用Redis Hash结构。
Key设计为cart:{userId},Hash的field保存商品ID,value保存购买数量。
例如cart:10001中,sku1001对应数量2。
选择Hash主要有三个原因:
第一,购物车修改频繁,Hash支持字段级更新,可以通过HINCRBY修改数量,通过HDEL删除商品,不需要像String一样整体序列化和覆盖。
第二,Hash操作天然支持原子更新,可以避免并发修改时的覆盖问题。
第三,减少网络传输和序列化成本。
商品详情信息不会直接存购物车,只保存skuId和数量。商品名称、价格等信息通过商品缓存或者数据库查询,因为商品信息变化频繁,不适合和购物车绑定。
需要注意的是,Hash并不是绝对性能更高,它的主要优势是数据模型匹配和部分更新能力。
追问1:商品信息为什么不直接存在Hash里面?
购物车的设计目的只是为了保存用户的购买意向,而不是商品数据。
而且,如果商品数据发生变动,如商品价格修改,则购物车内的商品数据就会作废。
因此,更好的选择是购物车内存放商品的id,根据商品id在需要时去查询商品数据
追问2:Hash是否一定比String性能好?
如果购物车内只有几个商品,Hash和String的性能差距可能很小。
真正的优势不是单纯速度,而是
- 部分更新
- 原子操作
- 数据结构和表达能力
Hash的优势主要在字段级操作和减少数据传输,而不是简单认为Hash一定比String快。
Redis内部Hash有两种编码,小Hash用ziplist/listpack。大Hash用hashtable
追问3:Key设计细节
可以补充:
cart:{userId}
为什么加 {}?
如果 Redis Cluster:
cart:{10001}
order:{10001}
会保证相同用户的数据落在同一个slot。
虽然购物车单Key通常够用,但体现分布式经验。
Redis Cluster 为什么需要这个{}?
Redis Cluster 会把数据分散到多个节点。
例如:
Redis Cluster
节点A
slot 0-5000
节点B
slot 5001-10000
节点C
slot 10001-16383
每个 Key 会计算一个槽:
slot = CRC16(key) % 16384
例如:
user:10001
|
↓
计算hash
|
↓
slot 8000
|
↓
节点B
所以不同 Key 可能在不同机器。
假设你的系统有:
购物车:
cart:10001
订单:
order:10001
用户:
user:10001
Redis Cluster计算:
CRC16(cart:10001)
CRC16(order:10001)
CRC16(user:10001)
结果可能:
cart:10001 → 节点A
order:10001 → 节点C
user:10001 → 节点B
三个数据被拆散。
改成:
cart:{10001}
order:{10001}
user:{10001}
Redis只计算:
10001
所以:
CRC16(10001)
三个Key结果一样:
cart:{10001} → 节点B
order:{10001} → 节点B
user:{10001} → 节点B
这样同一个用户相关的数据会落到同一个节点。
9.统计用户属性,统计某个用户的登录次数、下单次数、评论次数。考点:Hash 计数器
我的回答:设计键usercount:userid,类型为Hash,值为各个统计的子项。如logincount:value,bugcount:value
每次触发登录、下单、评论事件都用HINCR给对应字段自增。因为在Redis中,HINCR是原子操作,所以无需担心竞争问题
键设计成
usercount:{userid}更好,因为可以把相关数据分配到同一个节点
Redis key 一般采用:
业务:对象:ID:属性
方便:
- 模糊查询
- 分析
- 删除
- 运维
优质回答:
用户属性统计可以使用 Redis Hash,每个用户对应一个 Hash,例如 user:count:{userId},field 保存不同指标,比如 login_count、order_count、comment_count。
当用户发生对应行为时,通过 HINCRBY 对字段进行原子递增。Redis 单线程执行命令,因此多个请求同时更新同一个字段时不会产生并发覆盖问题。
如果是生产环境,需要考虑 Redis 与业务数据库的一致性,一般通过订单成功事件发送 MQ,由消费端异步更新统计数据,同时增加失败重试机制保证最终一致性。
10.消息队列,使用 Redis List 实现一个简单消息队列。考点:LPUSH / BRPOP
redis/db1> LPUSH names john bob chang
3
redis/db1> RPUSH names ele
4
redis/db1> BRPOP names 5
1) names
2) ele
redis/db1> BRPOP names 5
3) names
4) john
redis/db1> BRPOP names 5
5) names
6) bob
redis/db1> BRPOP names 5
7) names
8) chang
redis/db1>
redis/db1> BRPOP names 5
NULL
BRPOP的作用是从list的右侧弹出一个元素,返回给调用它的客户端。(可以通过Java代码调用BRPOP将弹出的结果存储到变量中)
优质回答:
Redis List 可以实现简单消息队列,生产者使用 LPUSH 将消息写入 List,消费者使用 BRPOP 阻塞式读取消息。BRPOP 在没有消息时会阻塞等待,避免消费者频繁轮询。
但是 List 实现 MQ 存在可靠性问题,例如消息消费后立即删除,没有 ACK 机制,消费者宕机会导致消息丢失,也不支持消息重试。因此生产环境通常使用 Redis Streams 或专业消息队列如 Kafka、RabbitMQ。
追问1:为什么不用RPOP而用BRPOP?
如果用RPOP进行轮询消费的话,会产生大量不必要的NULL空值,不如用BRPOP进行阻塞等待消费,当list内真正进入值时再消费。
RPOP缺点:
- 浪费CPU
- 延迟不可控
- 大量空请求
追问2:是实现了怎样的消息队列模型?
Redis List 实现消息队列,本质是:
Producer
|
| LPUSH
↓
Redis List
|
| BRPOP
↓
Consumer
生产者负责写消息,消费者负责阻塞读取消息。
追问3:List队列有哪些缺陷?
- 消息丢失:消息已经从list中移出,但是客户端没有接收到消息。更现代的方案:Redis Streams
- 没有消息确认ACK
- 无法消息重试
Redis Streams
Redis 5.0 提供:
XADD
XREADGROUP
XACK
支持:
- 消费者组
- ACK确认
- pending消息
- 消息重试
更接近 Kafka。
11.最新消息列表,显示用户最近 10 条操作记录。考点:LPUSH + LTRIM
我的回答:每次有新的消息记录出现时,通过Lua脚本将LPUSH和LTRIM 0 9绑定在一起,实现原子话操作,避免竞争。
优质回答:
最近 10 条用户操作记录可以使用 Redis List 实现,以 userId 作为 key。每次产生新操作时使用 LPUSH 插入头部,然后使用 LTRIM 保留 0-9 的元素,保证列表长度始终为 10。为了避免并发情况下 LPUSH 和 LTRIM 之间被其他请求打断,可以通过 Lua 脚本保证两个操作的原子性。查询时使用 LRANGE 0 9 获取最新记录。如果需要支持按时间范围查询或者复杂排序,则可以考虑使用 Sorted Set。另外实际生产中需要注意控制记录大小、设置过期时间以及避免存储过大的对象。
追问1: 为什么不用 Sorted Set?(ZSet)
如果只是简单实现用户最近10条操作的话,普通List实现最简单
List的优点:
- 天然有顺序
- 插入O(1)
- 不需要维护score
如果需要按时间排序、范围查询、删除某条指定记录,那么ZSet更合适
追问2:如果用户操作记录要求支持分页查询,比如查看最近10000条,并且可以按时间范围搜索,你还会使用 List 吗?
我的回答:如果要按照时间范围搜索,那么就需要根据时间进行严格排序,需要使用ZSet。将时间戳设为score,在按时间范围查询时,使用ZRANGE key min max BYSCORE。如果要实现分页查询,按数量:ZRANGE key start stop。如果按照时间范围分页:ZRANGE user:action:10001 1723000000 1723086400 BYSCORE LIMIT 0 100
优质回答:
如果只是展示最近10条操作记录,List 更合适,因为插入和读取简单。但是如果需要支持最近10000条、时间范围查询、分页查询,则应该使用 Sorted Set。将操作时间戳作为 score,操作记录作为 member,通过 ZRANGEBYSCORE 或 Redis 6.2 的 ZRANGE BYSCORE 实现时间范围查询,通过 ZRANGE 的 offset 和 count 参数实现分页。同时需要限制 ZSet 最大长度,避免用户操作记录无限增长,可以通过 ZREMRANGEBYRANK 定期删除旧数据。实际生产还需要注意 member 唯一性问题。
追问3:如果用户有1000万条操作记录,ZSet一直保存怎么办?
增加容量控制,删除旧的元素,只保留最新的一万条(即zset最右面的一万条)
ZADD user:action:10001 timestamp action
ZREMRANGEBYRANK user:action:10001 0 -10001
删除从0~倒数第10001的所有元素
12.异步任务队列,任务入队、消费者阻塞获取任务。考点:阻塞队列
我的回答:可以用list结构设计一个异步任务队列,因为list天然带有默认顺序而且实现简单。可以用LPUSH向队列中放入任务,用BRPOP来实现消费者阻塞获取任务。Redis的list天然支持这种方式。但是这种方式虽然实现简单,但也可能会发生一些异常问题,如使用BRPOP任务已经从list中被删除,但是客户端没有拿到、没有ACK确认码、没有重投机制。如果在要求更为严格的业务场景下,如果一定要用Redis实现,可以使用Redis-Stream,他支持这些机制,更接近于Kafka。如果可以用其他,推荐使用Kafka等MQ,因为MQ的设计就是专门为了解决这些问题,方案更完善,覆盖面更广,更专用。
不错,够用
13.秒杀排队,秒杀开始后,用户先进入队列,按顺序处理。考点:List 顺序性
我的回答:用list实现,因为list天然带有默认的顺序。简单可以通过LPUSH向队列中按照默认顺序添加用户,然后客户端调用BRPOP依次进行处理。但是这种方式在生产环境下有致命的缺点:容易丢数据,所以一般不会直接使用。而是 通过Redis-Stream来实现,因为Redis-Stream有消息确认、重传机制,更接近于Kafka,更专业,也更有安全保障。因为用户较多,在设计list的时候还应采用分段的模式,如设计十个list,将用户采用轮询或随机的方式投入队列,十个队列可以实现分布式部署,位于不同的机器上,这样可以有效减缓单台服务器的压力
优质回答:
简单秒杀排队可以使用Redis List实现,通过LPUSH/RPOP或者RPUSH/LPOP形成FIFO队列,消费者使用BRPOP阻塞等待任务。但是List存在可靠性问题,消费者获取消息后消息立即删除,如果处理过程中宕机,会导致任务丢失。
生产环境通常使用Redis Stream实现,它支持消费者组、ACK确认和pending消息重新消费,可以保证消息可靠性。
秒杀系统核心是削峰填谷,大量用户请求不会直接访问数据库,而是先进入Redis队列,由多个消费者按照固定速率消费,执行库存扣减和订单创建。
如果单队列吞吐不足,可以根据用户ID进行hash分片,将请求分散到多个队列,由多个消费者并行处理。但需要注意,分片后无法保证全局严格顺序,如果业务要求强顺序,需要采用单队列或者其他消息系统方案。
追问1:Redis Stream已经有消息确认机制了,为什么大型秒杀系统还经常使用Kafka,而不是Redis Stream?
优质回答
Redis Stream和Kafka都可以实现可靠消息队列,但是大型秒杀系统通常选择Kafka,主要因为Kafka面向大规模消息场景设计,具备更高吞吐、更好的水平扩展能力以及更长时间的消息保存能力。Kafka通过partition实现天然分布式扩展,一个topic可以拆分到多个broker,而Redis Stream的数据模型更依赖Redis节点。另外秒杀系统通常不仅需要订单消费,还需要风控、数据分析等多个业务订阅消息,Kafka在多消费者、消息回放以及生态集成方面更加成熟。不过如果业务规模有限,Redis Stream凭借低延迟和简单运维也完全可以满足需求。
RedisStream和Kafka都可以作为消息队列,但大型秒杀系统更倾向于Kafka,主要原因有以下几点
1.Kafka的吞吐能力更强,更适合超大规模流量。
RedisStream的数据主要存储在Redis内存中
优势是延迟低、读写快,但是缺点也很明显。单机内存有限、数据规模受内存限制、持久化能力弱于Kafka
Kafka的设计目标就是高吞吐消息系统
它采用顺序磁盘写、PageCache、批量发送、零拷贝等方式,可以达到百万级甚至更高消息吞吐。
此外,Kafka还可以通过增加partition水平扩展。
2.Kafka天然支持大规模水平扩展。
RedisStream虽然支持消费者组,但是Redis本身扩展能力有限
比如Redis Cluster:
node1
node2
node3
数据通过slot分片。
但是一个Stream属于一个key。
它不会像Kafka partition一样天然拆分:
Kafka:
topic
|
+ partition0
+ partition1
+ partition2
每个partition可以分布到不同broker。
大型秒杀:
10亿请求
topic:
seckill
partition:
p0 -> broker1
p1 -> broker2
p2 -> broker3
扩展更加自然。
3.Kafka消息保留能力更强
RedisStream可以设置MAXLEN限制长度,主要用于短期消息。
Kafka的消息可以保存几小时、几天、几个月甚至永久保存
之后:
- 订单服务消费
- 风控服务消费
- 数据分析服务消费
秒杀订单消息的保存是有价值的,多个系统都可以重新读取信息
4.Kafka支持多个消费业务独立消费
这是非常重要的一点
比如
- 消费者组1——订单服务:负责创建订单
- 消费者组2——风控服务:负责风险检测
- 消费者组3——统计服务:负责数据统计
每个消费者组都有自己的消费进度。
Redis Stream也支持消费者组,但是大型生态和能力方面Kafka更成熟。
5.Kafka生态更完善
大型公司通常不仅需要队列。还需要:
- 消息追踪
- 重放
- 数据同步
- 流计算
Kafka可以连接:
Kafka
|
+ Flink实时计算
|
+ Elasticsearch
|
+ 数据仓库
|
+ 监控系统
例如:
秒杀数据:
用户点击
↓
Kafka
↓
Flink
↓
实时统计库存压力
这套生态Redis Stream无法替代。
既然Kafka这么可靠,那秒杀系统为什么还需要Redis?为什么不直接Kafka削峰?
我的回答:Kafka虽然可靠,但是不够轻量化。对于小型的业务来说,Redis足够轻便,仅使用Redis可以显著提高速度和运维成本,且足够满足需求。在大型的业务中,往往不是Kafka和Redis选一个的场景,而是同时采用。将Kafka作为可靠消息队列削峰,Redis作为高速缓存
优质回答:
Kafka主要解决“消息可靠传输、异步削峰、持久化和解耦”;Redis主要解决“热点数据访问、库存预扣减、快速限流和前置拦截”。
Kafka和Redis在大型秒杀系统中通常不是二选一,而是承担不同职责。Redis主要负责高并发场景下的低延迟操作,例如限流、热点数据查询、库存预扣减以及判断用户是否已经参与秒杀。通过Redis可以在请求进入消息队列之前过滤掉大量无效请求,比如库存已经为0时直接返回,避免大量无效消息进入Kafka。对于真正抢到库存的请求,再把订单消息发送到Kafka。Kafka负责异步削峰、消息持久化和服务解耦,订单服务通过消费者异步创建订单并最终落库。
所以整体链路可以设计成:用户请求先经过限流和Redis库存判断,Redis预扣减成功后发送Kafka消息,订单服务消费Kafka消息并写入数据库。Redis解决的是高并发下的快速访问和前置流量过滤,Kafka解决的是异步处理、削峰和可靠消息传递,而不是简单地把Redis当成Kafka的缓存。
用户请求
↓
限流 / 防刷
↓
Redis判断库存
↓
Redis预扣减库存
↓
Kafka
↓
异步创建订单
↓
DB
1.为什么不直接把请求全部扔给Kafka?
因为Kafka是消息系统,不是秒杀请求的第一道防线。
如果一百万人抢100件商品,如果库存已经没了,Kafka这个消息系统还要处理一百万个无用请求,这显然是不合适的。
Redis可以在更靠前的位置快速判断
Redis库存:
stock = 0
↓
直接返回“已售罄”
大量无效请求甚至不需要进入 Kafka。
2. Redis在秒杀中最重要的作用之一是库存控制
例如:
seckill:stock:1001 = 100
用户请求到达后,通过 Redis 原子操作扣库存:
if stock > 0:
stock--
return success
else:
return sold_out
实际实现可以使用 Lua 脚本保证判断库存和扣减库存的原子性。
于是:
100万请求
↓
Redis
↓
只有100个请求成功抢到库存
↓
Kafka
↓
订单服务
这才是真正的削峰 + 前置过滤。
当然,实际系统还需要处理“Redis扣成功但Kafka发送失败”等一致性问题,这已经是更深入的设计了。
3.Kafka负责异步化和削峰
Redis确认用户抢到资格后,再把订单消息发给Kafka:
Redis
↓
抢购资格
↓
Kafka
↓
订单消费者
↓
MySQL
这样数据库不需要直接面对秒杀洪峰。
比如数据库只能处理5000TPS,而秒杀瞬间可能有1000000QPS,Kafka负责把流量变成一个相对稳定的消费速度:
瞬时100万请求
↓
Kafka
↓
5000/s
↓
DB
4. Redis还有一个很重要的价值:低延迟
Redis基于内存,适合处理秒杀这种对响应时间非常敏感的操作。
例如:
查询库存
判断用户是否已经购买
限流
防重复提交
库存预扣减
这些操作都希望尽可能快速完成。
Kafka不适合承担这些同步查询职责。
所以可以简单理解成:
Redis:
“你有没有资格抢?”
Kafka:
“你抢到了,后面的订单处理慢慢做。”
MySQL:
“最终把订单和库存落下来。”
这个职责划分比“Redis是缓存,Kafka是队列”更加准确。

浙公网安备 33010602011771号