目录
[1 介绍一下你的项目](#1 介绍一下你的项目)
[2 为什么这个项目要用 Redis?](#2 为什么这个项目要用 Redis?)
[3 缓存三兄弟分别是什么,常见的解决方法分别是什么?你项目里怎么解决的?](#3 缓存三兄弟分别是什么,常见的解决方法分别是什么?你项目里怎么解决的?)
[4 秒杀为什么会超卖,怎么解决](#4 秒杀为什么会超卖,怎么解决)
[5 怎么实现一人一单?](#5 怎么实现一人一单?)
[6 为什么不用 Java 加锁?](#6 为什么不用 Java 加锁?)
[7 Redis 分布式锁怎么实现,为什么会误删?](#7 Redis 分布式锁怎么实现,为什么会误删?)
[8 点赞功能为什么用 Redis 的 ZSet 不用 Set?](#8 点赞功能为什么用 Redis 的 ZSet 不用 Set?)
[9 为什么用 Redis Stream 消息队列](#9 为什么用 Redis Stream 消息队列)
[10 这个项目中你遇到的最大技术挑战是什么?最终是如何解决的?](#10 这个项目中你遇到的最大技术挑战是什么?最终是如何解决的?)
[11 布隆过滤器](#11 布隆过滤器)
[12 ThreadLocal](#12 ThreadLocal)
[13 附近店铺怎么实现?](#13 附近店铺怎么实现?)
[14 流量增加 100 倍怎么重构?](#14 流量增加 100 倍怎么重构?)
[15 Lua 脚本防超卖怎么写的?](#15 Lua 脚本防超卖怎么写的?)
[16 本地缓存+Redis协同工作](#16 本地缓存+Redis协同工作)
[17 数据库里面的主键是怎么做的?](#17 数据库里面的主键是怎么做的?)
[18 秒杀里面怎么防止有人拿程序去恶意的刷优惠劵](#18 秒杀里面怎么防止有人拿程序去恶意的刷优惠劵)
[19 如果秒杀8点开始,那设置【缓存的存活时间包含这个时间】可以解决什么问题?缺点?](#19 如果秒杀8点开始,那设置【缓存的存活时间包含这个时间】可以解决什么问题?缺点?)
[20 削峰解耦后系统吞吐量提升了多少?具体的压测数据记得吗?](#20 削峰解耦后系统吞吐量提升了多少?具体的压测数据记得吗?)
[21 Redis 为什么这么快?](#21 Redis 为什么这么快?)
[22 Redis 持久化机制有哪些?](#22 Redis 持久化机制有哪些?)
[23 Redis 都有哪些数据结构?项目里怎么选?](#23 Redis 都有哪些数据结构?项目里怎么选?)
[24 更新数据库和删除缓存,顺序为什么这样设计?](#24 更新数据库和删除缓存,顺序为什么这样设计?)
[25 为什么秒杀队列用 Stream,不用 List?](#25 为什么秒杀队列用 Stream,不用 List?)
1 介绍一下你的项目
我做的是一个基于 SpringBoot 和 Redis 的高并发点评系统后端项目,类似大众点评。
核心功能包括:用户登录认证、商户信息查询、秒杀优惠券、Redis 缓存优化、分布式锁控制一人一单、笔记发布与点赞、关注与粉丝流。
项目重点主要放在 Redis 高并发场景优化上,因为像商户查询、点赞、秒杀这些都是高频访问场景,直接访问 MySQL 容易造成数据库压力过大。
针对高并发下常见问题,我重点解决了缓存穿透、缓存击穿、缓存雪崩以及秒杀超卖问题。
具体实现上,查询类业务采用 Redis 缓存提升读取性能,秒杀场景使用 Redis 预扣库存 + Lua 脚本保证原子性,同时通过 Redis Stream 异步下单实现削峰填谷,并结合分布式锁保证一人一单。
2 为什么这个项目要用 Redis?
这个项目使用 Redis 是因为 Redis 在整个系统里承担了多个核心角色,既做缓存,也做高并发控制和异步削峰组件,还有适合业务需求的高级数据结构。
首先在商户查询场景 中,它作为缓存层,主要解决高频读请求问题,比如首页商铺列表、详情页、附近店铺查询,这些请求频率很高,如果全部走 MySQL,数据库压力会非常大,所以使用 Redis 做热点数据缓存,提高响应速度。Redis 基于内存存储,读写速度远高于磁盘数据库,因此非常适合做热点数据缓存。这里会:先查 Redis,命中直接返回,未命中查数据库并回写 Redis。
其次在秒杀场景 中,Redis 还承担了 高并发控制层 的作用,因为秒杀最大的特点是"瞬时请求量极大",如果请求直接打到数据库,很容易把数据库打崩。所以我把库存预扣减、一人一单校验、分布式锁控制都前移到 Redis。比如库存用 String 存储(库存是数值类型,需要原子扣减、原子增加,Redis String 提供 DECR、INCR 等原子操作,能保证并发安全,适合实现库存扣减),用户购买记录用 Set 存储(需要判断用户是否重复购买,Set 结构自动去重,并支持快速判断成员是否存在,非常适合实现 "一人一单" 的限购逻辑),Lua 脚本保证库存扣减和重复校验原子执行,再通过 Stream 把订单消息异步写入队列,后台慢慢消费落库。这样数据库只承担最终写入,不承担高峰流量。
同时,Redis 的高级数据结构也非常适合业务需求 。比如点赞和关注流场景中,点赞使用 ZSet,是因为 ZSet 本身支持排序,我可以把 userId 作为 member,把点赞时间戳作为 score,这样既能快速判断用户是否点过赞,也能按时间顺序查询最近点赞用户。关注推送和粉丝流通常用 Feed 流结构,本质上就是给每个用户维护一个"收件箱",比如我关注了某个博主,博主发笔记后,系统把这条笔记 ID 推送到我的收件箱列表里,这样我打开首页时可以快速看到关注用户的新内容。
3 缓存三兄弟分别是什么,常见的解决方法分别是什么?你项目里怎么解决的?
缓存穿透 是指请求查询的数据本身不存在 ,无论 Redis 还是数据库都查不到,如果有恶意请求大量打这种 key,就会持续穿透缓存直接打到数据库,导致数据库压力飙升。我采用的是缓存空对象方案 ,也就是数据库查不到时,向 Redis 写入一个空值并设置较短过期时间,防止同样请求反复访问数据库。如果进一步优化,也可以使用布隆过滤器在请求进入缓存前先做一次存在性判断。
缓存击穿 是指某个热点 key 在高并发场景下刚好失效 ,瞬间大量请求同时访问数据库,导致数据库压力骤增。我这里主要用了两种方案:互斥锁 和逻辑过期。互斥锁方案是缓存失效时只允许一个线程去重建缓存,其他线程等待;逻辑过期方案是 Redis 不设置真实 TTL,而是在 value 中维护一个逻辑过期时间expireTime,查询时即使过期也先返回旧数据,后台线程异步重建缓存。
缓存雪崩 是指大量缓存 key 在同一时间集中失效 ,导致海量请求直接打到数据库。我主要通过随机过期时间解决,比如在固定 TTL 基础上加随机值,避免同一时刻大面积失效;同时热点数据采用永不过期 + 逻辑过期方式进一步降低风险。
缓存击穿和缓存穿透区别? 穿透是 数据根本不存在 ,击穿是 **热点数据存在,但缓存失效。**穿透查不存在数据,击穿查热点失效数据。
4 秒杀为什么会超卖,怎么解决
超卖的本质是并发安全问题,就是高并发下多个线程同时读取库存 ,例如库存只剩 1 件,两个请求几乎同时读取到库存 > 0,然后都去下单,最终卖出 2 件,这就是典型的并发安全问题。总体来说解决方案使用了 Redis 预扣库存 + Lua 保证原子性 + Steam削峰。
即没有直接让请求进入数据库,而是先进入 Redis,在 Redis 中维护库存数据,通常使用 String 类型存储库存数量,例如 seckill:stock:voucherId。秒杀请求到来后,通过 Lua 脚本一次性完成库存校验、重复下单校验、库存扣减和用户记录四步操作。由于 Redis 执行 Lua 脚本是单线程串行执行的,所以这四步天然具备原子性,不会出现并发超卖问题。之后再通过 Redis Stream 消息队列将订单消息异步投递给消费者线程,由后台线程真正写入数据库,实现前端快速响应和后端削峰。
乐观锁 → 悲观锁 → 分布式锁 → UUID 防误删 → Lua 原子释放 → Stream 削峰
**单机单 JVM 超卖乐观锁:**一开始如果是单机单 JVM 场景,因为是添加任务,所以立马想到可以用 乐观锁解决,比如版本号机制或者 CAS 思想。具体就是更新库存时加条件,比如:
sql
update voucher
set stock = stock - 1
where id = ? and stock > 0
这样即使多个线程同时执行,也只有一个线程更新成功。
**一人一单悲观锁:**但是后来发现仅解决超卖还不够,同一个用户可能重复抢购,那作为商户我们肯定是希望有更多的人能抢到秒杀券,也是对自己的店的一个宣传,因此出现了 一人一单 问题。所以在 Java 层我又加了 悲观锁,比如对 userId 加 synchronized 锁,保证同一个用户只能一个线程下单。
**集群下多机多 JVM 分布式锁:**如果系统部署成多个 Tomcat、多台服务器,Java 锁只能锁住当前 JVM,多个服务实例之间无法互斥,这时候锁失效,所以必须引入 分布式锁。
分布式锁本质上就是在外面找一个所有 JVM 都能看到的公共锁,Redis 就非常适合。最基础方案是:
java
SETNX key value EX 10
Spring 里一般写成:
java
stringRedisTemplate.opsForValue().setIfAbsent()
这样所有服务实例竞争同一把 Redis 锁。
**误删锁 UUID + ThreadId 唯一标识:**但是最原始的锁还有问题:它只判断"锁是否存在",不判断"是不是自己的锁",所以可能出现误删。比如线程 A 拿到锁,业务执行太久、程序突然崩溃、线程被阻塞等等原因,导致锁超时自动释放;线程 B 又拿到了这把锁;此时线程 A 执行完毕后再DEL,就把线程 B 的锁删掉了。所以需要在 value 中加入线程唯一标识 UUID:ThreadId 。
- UUID:区分不同服务器实例(不同 JVM),每个服务实例启动时生成一个 UUID。
- ThreadId:区分同一台服务器内部不同线程,加锁时,UUID 拼接当前线程 ID。
释放锁时先判断 Redis 中 value 是否等于当前线程标识,相等才删除。
**原子操作 Lua 脚本:**GET -> 判断 -> DEL这三步不是原子操作,中间仍然可能发生线程切换。所以最终方案是使用 Lua 脚本,把判断和删除写成一个原子操作:
Lua
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0
**消息队列削峰:**然后再往后,高并发场景下发现即使 Redis 扛得住,数据库异步落库压力仍然很大,所以继续引入 Redis Stream 异步削峰。
除此之外,在真实生产环境里,更推荐使用 Redisson,它解决了原始锁的几个核心缺陷:
第一,可重入。同一个线程多次进入同一个业务方法时不会死锁。
第二,可重试。拿不到锁时支持等待和重试,而不是立即失败。
第三,自动续期。通过 watchdog 机制自动续租,避免业务执行时间过长导致锁提前过期。
第四,更适合集群环境。
5 怎么实现一人一单?
一人一单的核心是防止同一个用户重复抢购同一张优惠券。我使用 Redis Set 来记录已下单用户,例如 key 可以设计为 seckill:order:voucherId,value 存储 userId。因为Set 天然支持去重 ,所以非常适合做这种业务约束。用户抢购时,在 Lua 脚本中先通过SISMEMBER 判断该用户是否已经存在于集合中,如果存在直接返回重复下单;如果不存在,则继续执行库存扣减并通过 SADD 将用户 ID 写入集合。之所以一定要放到 Lua 中,是为了保证判断和写入操作原子执行,避免并发下重复下单。
6 为什么不用 Java 加锁?
Java 锁不是不能用,而是有使用范围。在单机单 JVM 下,比如一个 Tomcat 实例,其实 Java 锁是完全可以解决问题的。Java 锁本质上是 JVM 级别的本地锁,只能控制当前服务实例内部线程互斥。
但是项目上线后通常是集群部署,比如 3 台 SpringBoot 服务实例,每台都有自己的 JVM 和自己的锁对象。这时候 JVM1 上线程 A 加锁,并不会影响 JVM2 上线程 B,因为它们看到的是不同内存空间里的锁对象。那么不同服务器上的线程之间无法通过 Java 锁互斥,这时候多个实例仍然可能同时处理同一个秒杀请求,导致超卖问题。所以 Java 锁只能解决 单机并发问题 ,无法解决 分布式并发问题。
Redis 分布式锁本质上是利用 Redis 作为统一的共享存储,让所有服务实例竞争同一把锁,因此可以实现跨机器互斥,这就是为什么分布式场景必须使用 Redis 锁而不能只用 Java 锁,也就是把锁从 JVM 内存搬到了所有机器共享可见的 Redis 中。
7 Redis 分布式锁怎么实现,为什么会误删?
SETNX key value EX 10,Spring中是setIfAbsent()。
使用唯一线程标识作为 value,防止误删别人的锁。
误删的场景是这样的:线程 A 获取锁,设置过期时间 10 秒,但由于业务执行太久、程序突然崩溃、线程被阻塞等等原因,10 秒后锁自动过期;此时线程 B 成功获取到了同一把锁。如果线程 A 业务执行完毕后直接 DEL 删除锁,就会把线程 B 的锁误删掉,导致线程 B 失去锁保护。所以我在 value 中存储唯一标识,通常是 UUID + ThreadId,UUID 用于区分不同服务实例,ThreadId (具体是什么,有点不太懂)用于区分同一实例不同线程。释放锁时先判断 Redis 中 value 是否等于当前线程标识,只有相等才允许删除,并通过 Lua 保证判断和删除原子执行。
为什么释放锁要判断线程标识?
防止锁超时后被其他线程获取,当前线程误删别人的锁。删除锁时使用 Lua 保证判断和删除原子性。
8 点赞功能为什么用 Redis 的 ZSet 不用 Set?
点赞功能之所以使用 Redis ZSet 而不是 Set,是因为业务不仅需要判断用户是否点赞,还需要按照点赞时间排序,例如展示最早点赞用户、最近点赞前五用户等。Set 只能去重,无法排序;而 ZSet 底层会按照 score 自动排序,所以我使用 userId 作为 member,时间戳作为 score,这样既能通过 ZSCORE 判断是否点赞,也能通过 ZRANGE 快速获取按时间排序的点赞列表。
9 为什么用 Redis Stream 消息队列
使用 Redis Stream 的核心原因是 削峰 和 解耦,不仅可以降低数据库压力,还能提高系统吞吐量。秒杀请求在高并发下如果同步写数据库,数据库很容易被瞬时流量压垮。
所以我将请求先在 Redis 中完成库存和重复校验,然后将订单信息写入 Stream 队列,由后台消费者线程异步消费并创建订单。这样可以把瞬时高峰流量平滑为稳定消费流量,显著降低数据库压力,同时提升系统吞吐量。相比 List,Stream 还支持消费组和 Pending List,消息可靠性更好,更适合生产环境。(这里加一个举例子,就是类比这个消息队列就是外卖柜、丰巢那一类的东西)
流程为:请求 -> Redis校验 -> 写入消息队列 -> 异步消费 -> 创建订单
10 这个项目中你遇到的最大技术挑战是什么?最终是如何解决的?
我遇到的最大技术挑战是秒杀场景下的高并发一致性问题,主要包括超卖、一人多单和数据库压力过大三个问题。最开始我先尝试用数据库乐观锁解决超卖,然后用 Java 悲观锁控制一人一单,但后来发现集群部署下 JVM 锁失效,所以升级为 Redis 分布式锁。之后又发现锁误删问题,所以加入 UUID + ThreadId 唯一标识,并通过 Lua 保证释放锁原子性。再往后高并发流量下数据库写入压力过大,于是引入 Redis Stream 做异步削峰。
11 布隆过滤器
布隆过滤器用来干啥的?
布隆过滤器主要用于解决缓存穿透问题,用来快速判断某个 key 是否可能存在。
底层怎么实现的?
底层本质是一个位数组加多个哈希函数,多个哈希函数会把同一个 key 映射到多个 bit 位上并置为 1。查询时只要有一个 bit 位为 0,就一定不存在;如果都为 1,则可能存在。
有什么缺点?
它的缺点是假阳性,也就是明明不存在但可能判断为存在,因为不同 key 可能哈希冲突;但不会出现假阴性,因为插入过的数据对应位一定为 1。
支不支持删除?不支持删除有什么别的方案?
普通布隆过滤器不支持删除,因为删除一个 bit 位可能影响其他元素。替代方案是计数布隆过滤器,用计数器代替 bit 位,可以支持删除和更新。
12 ThreadLocal
Threadlocal 里面存了哪些信息?底层怎么实现的?
ThreadLocal 主要用于保存当前线程独享的数据,比如当前登录用户信息,在点评项目里通常存储用户登录态,例如 userId、token 对应用户信息。底层是每个 Thread 内部维护一个 ThreadLocalMap,key 是 ThreadLocal 弱引用,value 是实际数据。
Threadlocal 有什么问题?为什么JVM 里的GC不能回收value?
问题在于 key 是弱引用,GC 后 key 可能被回收,但 value 仍然被强引用,只要线程还活着,value 就不会被回收,导致内存泄漏。因此使用完必须手动 try-catch-finally 调用 remove() 清理。
13 附近店铺怎么实现?
附近店铺查询我使用的是 Redis GEO 功能。商户经纬度信息通过 GEOADD 存入 Redis,例如 key 为 shop:geo:typeId,value 存储商户 ID 和经纬度。用户查询附近店铺时,根据用户当前位置调用 GEOSEARCH 或 GEORADIUS,按距离范围和排序规则返回附近商户列表,再根据商户 ID 查询详细信息。
14 流量增加 100 倍怎么重构?
秒杀项目落地了,流量爆炸增加了100倍,你觉得你现在的这个架构在什么地方会出问题?你会怎么去重构它,怎么去解决这些问题。
如果流量暴增 100 倍,我首先会考虑服务集群扩容和 Redis 集群化,避免单点瓶颈。其次秒杀流量入口前增加限流,比如令牌桶或漏桶算法,防止恶意刷请求。数据库层面做读写分离和分库分表。消息队列可以从 Redis Stream 升级为专业 MQ,例如 Apache Kafka 或 RabbitMQ,提升可靠性和吞吐量。
第一层:入口限流。第一层一定是入口限流,因为如果流量直接增加 100 倍,最先出问题的不是数据库,而是应用服务器和 Redis。很多请求其实可能是恶意刷接口或者重复点击。所以第一步要在网关层做限流。比如令牌桶(比如每秒放 1000 个令牌,请求来了必须先拿令牌,拿到才允许访问,拿不到直接拒绝)、漏桶(请求先进入桶中,再按固定速度流出,这样流量会被平滑),防止恶意刷接口。
第二层:服务扩容,集群部署。原来 1 台 Tomcat,扩成 5 台甚至更多。前面加负载均衡,比如 Nginx,负责把请求平均分配到多台 Tomcat。
第三层:Redis 集群,避免单 Redis 成为瓶颈。
第四层:数据库优化,读写分离、分库分表。
第五层:MQ 消息队列升级,Redis Stream 更适合中小型项目,但超大规模更推荐 Apache Kafka 或 RabbitMQ。
15 Lua 脚本防超卖怎么写的?
查库存 → 查重复 → 扣库存 → 扣库存 → 记录用户
Lua
local stockKey = KEYS[1]
local orderKey = KEYS[2]
local userId = ARGV[1]
if tonumber(redis.call('get', stockKey)) <= 0 then
return 1
end
if redis.call('sismember', orderKey, userId) == 1 then
return 2
end
redis.call('decr', stockKey)
redis.call('sadd', orderKey, userId)
return 0
16 本地缓存+Redis协同工作
本地缓存和 Redis 的协同一般采用两级缓存架构,访问优先级通常是 本地缓存 > Redis > MySQL。之所以本地缓存优先,是因为它直接存储在当前 JVM 内存中,访问速度最快,通常适合存放高频热点数据,比如热门商铺信息、首页分类信息等。
具体流程是:请求先查本地缓存,如果命中直接返回;未命中再查 Redis;Redis 也未命中时再查 MySQL,并按顺序回写 Redis 和本地缓存。这样做的好处是既能减少 Redis 网络开销,又能进一步降低数据库压力。
一致性方面,核心问题是集群环境下不同节点的本地缓存可能不一致,所以一般采用"更新数据库后删除缓存 "策略,并通过消息队列 MQ 广播缓存失效消息,通知所有服务节点同步删除本地缓存和 Redis 缓存,保证最终一致性。简单理解就是:本地缓存负责极致性能,Redis 负责共享缓存,一致性靠删除 + MQ 通知兜底。
17 数据库里面的主键是怎么做的?
通常是 Redis 全局唯一 ID 生成方案,时间戳 + 自增序列。
这样设计有几个优点:第一,能够保证分布式环境下多个服务实例生成的 ID 不冲突;第二,整体趋势递增,适合数据库索引,提高插入性能;第三,时间戳部分天然带有时间信息,后续做订单统计、分时分析、日志排查都比较方便。相比数据库自增主键,这种方案更适合分布式系统。
18 秒杀里面怎么防止有人拿程序去恶意的刷优惠劵
秒杀场景下恶意刷券是非常常见的问题,我一般会从 身份校验、流量控制、行为风控 三层去防。
第一层是身份校验 ,比如登录态校验、短信验证码或图形验证码,防止脚本批量模拟请求。
第二层是接口限流,例如同一个用户每秒只能请求 3 次,同一个 IP 每分钟限制访问次数,这里可以在 Redis 中用计数器配合 TTL 实现。
第三层是行为风控,比如记录用户历史秒杀行为、异常高频访问 IP等,对异常账号加入黑名单。
除此之外,真正下单前还会通过 Redis Set 做一人一单校验,即使脚本绕过前面的请求限制,也无法重复领取同一张优惠券。你可以总结成一句话:入口防刷 + 请求限流 + 最终业务幂等校验三层防护。
19 如果秒杀8点开始,那设置【缓存的存活时间包含这个时间】可以解决什么问题?缺点?
优点:避免秒杀开始瞬间缓存失效导致击穿。如果秒杀活动是晚上 8 点开始,我会特别关注缓存的 TTL 是否覆盖这个时间点。这样做的核心目的是防止在秒杀开始瞬间缓存刚好失效,导致大量请求同时穿透到数据库,引发缓存击穿。比如库存缓存如果 7:59:59 失效,那么 8 点一到所有用户请求都会直接打到数据库,非常危险。所以一般会让缓存 TTL 至少覆盖活动高峰期,或者直接使用逻辑过期。
逻辑过期 的特点是 Redis 中的数据不会真正删除,key 一直都在,只是在 value 里面额外维护一个过期时间字段,查询时即使发现已经过了逻辑过期时间,也不会立刻返回空,而是先返回旧数据 ,同时开启一个后台线程去异步重建缓存。所以逻辑过期不会出现"8 点瞬间所有请求直接穿透数据库"的问题,这一点比真实 TTL 更适合热点场景。但是它仍然有自己的问题。第一个问题是短时间数据不一致 。第二个问题是业务时效性要求高时不一定适合。例如库存这种强实时业务,如果旧库存继续返回给用户,可能会让用户看到"还有库存",但实际上已经售罄,所以秒杀库存这种场景通常不会单纯依赖逻辑过期,而是页面展示用逻辑过期,真正下单用 Lua 实时扣减。
缺点:TTL 设置不合理可能导致库存数据提前失效或延迟更新。如果 TTL 设置过长,活动结束后库存数据可能不能及时失效,导致用户还能看到旧库存信息;如果 TTL 设置过短,又容易在高峰时刻失效。
TTL 要和业务峰值时间对齐,避免热点 key 在高峰期失效。
20 削峰解耦后系统吞吐量提升了多少?具体的压测数据记得吗?
使用 Apache JMeter 做标准化压测,主要关注三个核心指标:QPS、RT 和数据库 CPU 占用率。
第一,QPS(Queries Per Second),表示系统每秒能够处理多少请求。比如优化前系统每秒只能处理 500 个秒杀请求,优化后提升到 3000 个,这就说明吞吐量明显提升。
第二,RT(Response Time),也就是平均响应时间,表示用户发起请求到收到结果花了多久。比如优化前平均 800ms,优化后降到 120ms,说明用户体验明显变好。
第三,数据库 CPU 占用率,就是数据库服务器的 CPU 使用率。如果优化前数据库 CPU 接近 90%,说明数据库压力很大;削峰后降到 40% 左右,就说明大量请求已经被 Redis 和消息队列分流掉了。
21 Redis 为什么这么快?
一方面Redis基于内存存储,数据读写不需要磁盘 IO,底层使用高效的数据结构,比如 SDS**(Simple Dynamic String** ,也就是 Redis 自己实现的字符串结构,不是 Java 那种普通 String。它内部会额外维护:当前字符串长度len、剩余可用空间free、真正存储内容buf[] 支持动态扩容和预分配空间)、HashTable(数组 + 链表,查询、插入平均时间复杂度 O(1),Hash 类型、Set 去重、ZSet 辅助索引都用了HashTable)、跳表(本质上是"多层链表",像字典一样,特别适合:排序 + 范围查询);另一方面,采用单线程 + IO 多路复用模型提高网络处理能力(解释一下这个模型)。
Redis 为什么单线程还这么快? Redis 的瓶颈通常不是 CPU 算力不够,而是:网络等待和内存访问速度,Redis采用了 IO 多路复用可以同时监听多个连接请求,所以整体吞吐量依然很高。
22 Redis 持久化机制有哪些?
Redis 提供 两种持久化:
RDB( Redis Database Backup 数据库备份_定时快照**)**:定时把内存数据保存成文件。存储的是数据本身,恢复速度极快,文件体积小,数据安全一般,最后一次拍照到断电之间的数据会消失。
AOF( Append Only File 只追加文件_记录日志**)** :每执行一条写命令就记录。记录写命令日志,恢复速度较慢。重启时,重新执行一遍所有命令,恢复数据。数据更安全(最多丢 1 秒)但文件更大。
项目里主要用 Redis 做缓存和高并发控制,重点关注性能,所以更偏向 RDB;如果是生产环境,为了数据安全一般会同时开启 AOF。
23 Redis 都有哪些数据结构?项目里怎么选?
Redis 常见数据结构主要有:
String、Hash、List、Set、ZSet、GEO、Bitmap、HyperLogLog。
- String 是最常用的数据结构,本质上可以理解为 key 对应一个字符串值,但这个值也可以是数字。它支持 INCR、DECR、SETNX 这类原子操作,所以特别适合做 计数、库存扣减、分布式锁、验证码和 token 存储。
- Hash 适合存储对象类型数据,可以理解成一个 key 对应一个小型 Map,里面有多个 field-value。比如用户信息、商户详情都可以用 Hash 存储。相比把整个对象转成 JSON 存 String,Hash 的好处是可以单独更新某个字段,而不用整体覆盖。
- List 本质上是一个双向链表,适合按顺序存储数据,比如简单消息队列、历史记录、最近浏览列表等。不过在我的项目里用了更适合生产环境的 Stream,因为 Steam 有比较完善的消息队列模型,消息可回溯。可以多消费者争抢消息,加快消费速度。可以阻塞读取。没有消息漏读的风险。有消息确认机制,保证消息至少被消费一次。
- Set 的特点是天然去重,并且支持快速判断元素是否存在SISMEMBER。
- ZSet 有序集合是我项目里用得比较多的结构。它和 Set 的区别在于每个元素都有一个 score,并且 Redis 会自动按 score 排序。所以特别适合做 排行榜、时间排序、点赞列表。
- GEO 本质上它底层还是基于 ZSet,只不过 Redis 对经纬度做了封装。用于附近店铺搜索,我把商户经纬度信息存进去,然后根据用户当前位置和搜索半径查询附近店铺,比如 3km 内的餐饮店。
- Bitmap 适合做状态位记录,比如 用户签到、活跃天数统计。因为一个 bit 就能表示一天是否签到,所以非常节省空间。比如一个月签到只需要 31 个 bit。
- HyperLogLog 主要用于 UV(独立访客)统计。它最大的特点是占用内存极小,即使统计上亿用户访问量,内存消耗也很低,不过它是近似统计,存在一定误差。
我项目里主要用了以下几种:
- String:支持INCR、DECR、SETNX,天然适合原子计数和锁控制。
因此秒杀库存、token、验证码、分布式锁都用到了 String。 - Set:天然去重,且有 SISMEMBER 直接判断是否存在该值。
适合用于一人一单seckill:order:1001,value 存 userId。 - ZSet:ZRANGE、ZREVRANGE支持排序。
用于点赞和时间排序,因为不仅要判断是否点赞,还要按时间排序,查最近点赞前五用户。 - GEO:用于附近店铺,通过经纬度做附近搜索。
24 更新数据库和删除缓存,顺序为什么这样设计?
缓存和数据库双写一致性里,核心有三个问题。
- 更新缓存还是删除缓存?
推荐删除缓存,不推荐更新缓存。因为更新缓存会有两个问题:第一大量无效写。比如某个店铺信息一天改 10 次,但没人查。你每次都更新缓存,其实是浪费 IO。第二并发冲突风险更高。多个线程同时更新缓存,可能覆盖。所以更推荐:查询时按需重建缓存,也就是懒加载。 - 先删缓存还是先更新数据库?
推荐先更新数据库再删除缓存。如果先删缓存,这时候有线程进来查询,发现缓存没了。去数据库查,但数据库还没更新完成,于是把旧值重新写回缓存。这就产生脏数据。 - 延迟双删
先更新数据库 → 删除缓存 → 延迟一小段时间 sleep → 再次删除缓存。
因为极端情况下,线程A更新数据库,线程B正好在第一次删缓存后查到旧数据库值,又写回缓存,所以延迟一段时间再删一次。
25 为什么秒杀队列用 Stream,不用 List?
秒杀队列我选择 Redis Stream 而不是 List,核心原因是 Stream 更适合生产级消息队列场景,尤其是高并发秒杀这种需要削峰、可靠消费和失败重试的业务。
首先,如果用 List,通常只能用 LPUSH + RPOP 或者 BRPOP 去实现一个简单队列,它本质上更像一个"先进先出容器"。这种方式虽然也能完成异步下单,但它有一个明显问题:消息一旦被消费者取走,如果消费者在业务处理过程中宕机,消息就直接丢了。因为 List 没有"消费确认"机制,Redis 不知道这条消息到底是"处理成功了",还是"刚取出来服务就挂了"。
而 Stream 不一样,它本身就是 Redis 为消息队列场景设计的数据结构,天然支持 消费组(Consumer Group)、ACK 确认机制和 Pending List。
比如秒杀场景下,用户请求经过 Redis + Lua 校验后,会先把订单消息写入 Stream。后台消费者线程通过消费组去拉取消息处理订单。
如果订单处理成功,就执行 XACK 确认消费。
如果消费者在处理过程中宕机,比如订单还没写入数据库服务就挂了,这条消息不会丢失,而是会进入 Pending List(待确认消息列表)。服务恢复后可以重新拉取这些未确认消息继续处理,这样可以保证消息可靠性。
你可以把它类比成快递柜:
- List 像普通箱子:东西拿出来就算没了,丢了没人知道
- Stream 像丰巢柜:有取件记录、未取件提醒、异常补领机制