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消费者集群|vRedis分片计数|vMySQL
-
用户访问先到达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)
RedisINCRBY 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 1elseif redis.call('hexists',KEYS[1],ARGV[1]) == 1 then-- 同一个线程重入redis.call('hincrby',KEYS[1],ARGV[1],1)return 1else-- 别人的锁return 0end
逻辑:
锁不存在|+--> 创建锁 count=1锁存在|+--> owner是自己| || +--> count++|+--> owner不是自己|+--> 获取失败
锁释放
释放锁需要判断:
当前锁是不是我自己的?
Redis value 保存线程唯一 ID:
key:
lock:ordervalue:
uuid-123
count-2
释放时:
Lua:
if redis.call('hexists', KEYS[1], ARGV[1]) == 1 thenlocal count = redis.call('hincrby',KEYS[1],ARGV[1],-1)if count == 0 thenredis.call('del', KEYS[1])endreturn countelsereturn 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]) thenredis.call('HINCRBY', KEYS[1], 'stock', -tonumber(ARGV[1]))return 1
elsereturn 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 完成扣减。
