高并发与Redis Lua:阿里P6面试实战复盘与系统设计解析
2026/9/8 0:14:02 网站建设 项目流程

1. 面试前的整体准备与流程梳理

老实说,收到阿里面试通知那会儿,我心里是既兴奋又没底。P6这个级别在阿里的职级体系里对应的是“高级开发工程师”,面试考察的绝不是你会写几个接口、背几条命令那么简单。看了很多java面试题和面经之后,我总结出P6面试的核心逻辑:基础必须扎实、项目必须真实、原理必须讲透、实战必须能落地。

这一轮完整流程大概包括:简历筛选、技术电话面、两轮技术现场面(也有一轮在线笔试)、交叉面、HR面,前后持续了三周左右。今天这篇实录,我重点拆解两段让我印象最深的内容——高并发场景下的系统设计与Redis Lua脚本的深度应用。这两块是P6面试的高频考点,也是日常工作里最容易踩坑的地方。

在正式讲面试内容之前,先把我整理的准备思路放在前面。很多同学准备面试喜欢背八股文,我觉得八股文可以背,但不能死背。你需要把每一个知识点放到真实的业务场景里去理解,比如 Synchronized 和 ReentrantLock 的区别,如果你只是在背“一个是JVM层面的锁,一个是JDK层面的锁”,那面试官追问两句你就会卡住。但如果你从“秒杀场景下库存扣减如何保证线程安全”这个角度切入,自然就能带出锁升级、可重入、公平非公平、中断响应、条件队列等一串知识点,这才是有机的知识网络。

还有一个很重要的点:准备面试时要学会反推。你简历上写了“使用了Redis缓存”,那你就得提前想好面试官会问什么——Redis的数据结构有哪些?缓存穿透、缓存击穿、缓存雪崩分别是什么?缓存和数据库的一致性怎么保证?分布式锁怎么做?Redis为什么快?这些热搜词里反复出现的redis面试题、java面试八股文,本质上就是在提醒你:核心知识点永远逃不开这些东西,关键是你能不能结合自己的项目讲出深度。

我的简历上写了一个电商秒杀系统的项目,所以高并发和Redis Lua这块我做了特别充分的准备。下面我把面试中被深挖的部分还原出来,配合我自己的思考和复盘,尽可能还原当时的真实场景。

2. 高并发实战场景:秒杀系统的技术方案选型

2.1 面试官的第一个问题:秒杀场景下,你的系统架构是怎么设计的

面试官上来没有让我做自我介绍,直接点了一下简历上的项目:“你讲讲你这个秒杀系统,QPS大概是什么量级,整体架构怎么设计的?”

我当时愣了一下,然后快速镇定下来。秒杀系统我确实做过,也压过测,所以这个问题的回答核心是“真实”和“有数据支撑”。我告诉面试官:这个系统压测到单机QPS 4000左右,部署了6台应用服务器,整体QPS大概在2万左右,核心思路是层层过滤、削峰填谷。

我把架构拆成了四层:

第一层是CDN和Nginx层。静态资源(商品图片、页面框架)全部走CDN,Nginx层面做简单的限流,比如按IP维度限制请求频率,这样能把无效流量挡在最外层。

第二层是网关层。网关里做了用户维度的频控,比如同一用户5秒内只能请求一次秒杀接口,避免脚本刷单。这个其实已经能用Redis+Lua来实现,后面细说。

第三层是业务逻辑层。这里有前置校验、Redis预扣库存、MQ异步下单、数据库最终扣减。

第四层是数据库层。数据库只处理实际落单的请求,通过乐观锁版本号方式扣减库存,保证最终一致性。

面试官点点头,追了一句:“你Redis预扣库存和数据库扣库存之间怎么保证一致性?如果Redis扣成功了,数据库扣失败了怎么办?”

这个问题我用一个真实上线时的Bug来回答:当时的设计是Redis扣减库存之后发MQ消息,下游消费者执行数据库扣减,消息消费失败就重试,重试超过3次进入死信队列。但后来发现一个概率性事故——Redis扣减成功之后、发送MQ之前应用宕机了,这条消息就丢了,用户在前端看到下单成功了,但订单数据在数据库里根本不存在。

所以后来改成了把“发送MQ”这个动作放在Redis扣减成功之后,并且记录一条本地事务消息表,用一个定时任务扫描表中未发送的消息补发。这个方案不能说绝对完美,但至少把丢失窗口缩小到了“记录事务表”这一步本身失败的情况。用事务消息(比如RoacketMQ)可以更优雅地解决,但当时团队的技术栈用的是自研的MQ,所以选择了事务表+定时任务的方案。

2.2 缓存三大难题:穿透、击穿、雪崩的排查与应对

面试官紧接着就问:“缓存穿透、击穿、雪崩,你项目里碰到过哪个?怎么解决的?”

这三个概念我相信背八股文的同学都熟悉,但面试官要的显然不是定义。我说我实际处理过缓存穿透,当时的情况是前台页面有一个商品搜索接口,用户搜索一个根本不存在的商品ID时,请求会直接打到数据库。因为有刷单脚本会随机生成大量不存在的ID去请求,数据库压力暴涨。

我给出的方案是布隆过滤器。把所有可能的商品ID初始化到布隆过滤器里,请求进来先判断ID是否可能存在,不存在直接返回。后来考虑到布隆过滤器存在误判率,又加了一个兜底策略:对不存在的key进行短期空值缓存,比如缓存一个null值,过期时间设置成60秒。这样即使布隆过滤器误判放行了,下游的缓存也能挡住大部分请求。

关于缓存击穿,网上最常见的方案是用互斥锁或者热点数据永不过期。互斥锁的思路是:某个热点key失效的瞬间,只允许一个请求去数据库加载数据并重建缓存,其他请求在这个期间进行自旋等待,等缓存重建完成之后直接读缓存。这个方案实现简单,但存在一个隐患——如果重建缓存的过程比较慢,所有请求都会积压在这把锁上,接口的响应时间会明显上升,甚至引起上游超时。

热点数据永不过期方案更适用于读多写极少、且数据变更不频繁的场景。做法是把过期时间放到value里,后台起一个定时任务或者异步线程,发现逻辑过期之后主动去刷新缓存。不过这个方案要求你在代码里对每个缓存key的取值都做一次“逻辑过期判断”,侵入性稍微高一点。

雪崩的话,我当时的核心手段是过期时间加随机值,比如5分钟加上0到60秒的随机数,避免大量key在同一时刻集体失效。另外就是Redis集群本身的容灾,我们用的是哨兵模式,主节点挂了能自动切换到从节点。后面聊到Redis Cluster时面试官挺感兴趣,我也讲了一下槽位分配和集群模式下的key分散问题。

2.3 分布式锁的原理与实践:从 setnx 到 Redisson

分布式锁这题几乎是P6面试必考点,热搜词里的redis分布式锁也说明大家都在关注这个。面试官的问题很直接:“分布式场景下,你怎么保证多个实例不会同时扣减库存?”

我说第一版用Redis的setnx命令,加锁就是SET lock_key unique_value NX PX 30000,释放锁的时候用Lua脚本判断value是不是自己再删除。这里的关键点有三个:第一,锁必须有过期时间,防止持有锁的实例宕机导致死锁;第二,value必须是唯一的,通常是UUID或者业务ID,避免误删别人的锁;第三,判断和删除必须是原子操作,不能先get后del,而要用Lua脚本。

但面试官马上就追问:“过期时间到了,但是业务还没执行完怎么办?锁被别人拿走了怎么办?”

这就是setnx分布式锁最大的痛点。我当时给出的方案是看门狗机制——Redisson里默认的锁超时时间是30秒,如果业务没执行完,Redisson的看门狗会自动帮锁续期,每10秒刷一次,把锁的过期时间重新拉回30秒。如果业务都执行完了还没到过期时间,那就手动释放锁。Java代码里的实现大概是这样:

RLock lock = redissonClient.getLock("inventory:" + skuId); boolean locked = false; try { locked = lock.tryLock(0, 30, TimeUnit.SECONDS); if (locked) { // 扣减库存、写订单等核心逻辑 } else { throw new BusinessException("系统繁忙,请稍后重试"); } } finally { if (locked) { lock.unlock(); } }

这里有一个特别容易踩的坑:如果你的业务代码里catch住了异常,但finally里释放锁的时候用了lock.unlock(),恰好此时锁已经到了看门狗续期的临界点,可能会出现当前线程持有的锁已经被服务端删除,但当前线程的本地锁状态还是持有中,unlock时会报IllegalMonitorStateException。Redisson新版本对这个问题做了优化,但老版本是有概率复现的。我的建议是:tryLock方法最好显式传入leaseTime,不依赖看门狗自动续期,同时业务执行时间必须评估清楚,宁可设长一点,也别让锁提前失效。

分布式锁这块还有一个演进方向是RedLock。每个master节点都去加锁,超过半数节点加锁成功才算持有锁。这个方案在极端场景下有争议,而且实现成本高。对于P6面试来说,能讲清楚setnx方案的局限性、看门狗续期机制、以及为什么多数业务场景下Redisson的可重入锁已经足够,就已经能拿不少分了。

3. Redis Lua 深度解析:原子性脚本的设计与应用

3.1 面试官切入Lua的方式:你限流怎么做

单机场景下限流用Guava的RateLimiter问题不大,但分布式场景下就必须有全局的限流能力。我当时用的是Redis+Lua实现滑动窗口限流,代码不算长,但面试官对这个非常感兴趣,一路追问到了Lua脚本的设计细节。

滑动窗口限流的Lua脚本核心逻辑是这样的:用ZSET存储请求时间戳,score是时间戳,member是唯一标识。每次请求进来时,先ZREMRANGEBYSCORE把窗口外的旧数据清掉,然后ZCARD统计当前窗口内的请求数,如果小于阈值就ZADD加入当前请求并返回1,否则返回0。

local key = KEYS[1] local window = tonumber(ARGV[1]) local threshold = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local member = ARGV[4] redis.call('ZREMRANGEBYSCORE', key, 0, now - window) local count = redis.call('ZCARD', key) if count < threshold then redis.call('ZADD', key, now, member) redis.call('PEXPIRE', key, window) return 1 end return 0

这段脚本有几个设计细节我特别想强调。第一是PEXPIRE的使用,每次写入都重置过期时间,避免ZSET变成永远清不掉的大key;第二是member要用唯一值,比如UUID或userId + 时间戳,如果member重复,ZADD会覆盖旧的score,导致计数不准;第三是脚本里所有和时间相关的参数都必须通过ARGV传入,不能直接在脚本里调用redis.time(),因为Lua脚本在执行时是原子的,嵌套的Redis调用容易把问题搞复杂。

读者可能会问:为什么不直接用INCR+EXPIRE计数限流?滑动窗口和固定窗口的区别在哪里?这个问题我在面试里也主动提了。固定窗口的缺陷在于临界突变问题,比如限制每分钟100次,用户在59秒请求了100次,第60秒又可以请求100次,实际上两秒内通过了200次。滑动窗口则把时间刻度细化到秒级甚至毫秒级,能平滑掉这种毛刺。不过滑动窗口的代价是内存占用更高,ZSET里每个请求都存一个member,QPS高的时候需要关注内存增长。我的经验是:纯限流场景,如果有预算上Sentinel这类中间件,直接用现成的;如果团队依赖Redis,滑动窗口Lua脚本是最稳妥的方案。

3.2 Redis为什么需要Lua:原子性与减少网络开销

面试官在这块问了一个很关键的问题:“你为什么选择把这段逻辑写在Lua脚本里,而不是用Java代码实现?”

这个问题在P6面试里很常见,考察的是你对中间件底层机制的理解。我在准备java面试八股文时特别注意过这个点,它真正的答案是两个层面。

第一是原子性。Redis执行Lua脚本时,整个脚本会被包装成一个原子操作,脚本执行期间不会被其他命令插入打断。这就避免了传统Java代码中“先ZREMRANGEBYSCORE再ZCARD”两步操作之间的竞态问题。你需要同时保证判断和写入的原子性时,Lua几乎是Redis生态里最简单的解法。如果用Java代码实现,你得引入分布式锁来保护这段逻辑,但加锁本身又引入了新的复杂性。用Lua脚本,等于把并发控制的职责直接下沉到了Redis内部。

第二是网络开销。一次Lua脚本调用只需要一个RTT,而如果不用Lua,完成同样的功能可能需要3到4次Redis命令,在QPS很高的情况下,网络开销会显著拖慢接口响应。虽然可以用pipeline批量发送,但pipeline不保证原子性,对需要“判断+写入”联动操作的场景并不适用。

把这两个点讲清楚,面试官基本就能确认你是真的在项目中用过Lua,而不是只看了概念。他后面追问的“Lua脚本里能不能调用随机函数”“能不能打印日志”“脚本执行出错怎么办”这些偏细节的问题,其实都是在验证你有没有真实踩过坑。

3.3 Lua脚本在缓存一致性中的应用:库存回补与防重

限流只是Lua的一个应用场景。我在实际项目中更多用Lua处理那些“判断+操作”必须原子的逻辑。

一个典型的场景是库存回补。用户下单后超时未支付,订单关闭,库存要回补到Redis缓存里。但如果回补操作和当前正在发生的扣减操作出现并发,就可能覆盖掉别人的扣减结果。用Lua可以保证回补是原子的:

local key = KEYS[1] local stock = tonumber(redis.call('GET', key)) local returnCount = tonumber(ARGV[1]) local maxStock = tonumber(ARGV[2]) if stock + returnCount > maxStock then redis.call('SET', key, maxStock) else redis.call('INCRBY', key, returnCount) end return redis.call('GET', key)

如果库存上限是100,当前剩余是95,回补10个,直接INCRBY会变成105,超过初始库存,所以回补前必须校验上限。这个逻辑如果用Java代码实现,GET和INCRBY两行之间一旦有其他请求插入,数据就错了。

类似的场景还有防重:同一个用户同一场活动中只能领取一次优惠券。用Lua的SADD来实现,如果返回值是0说明用户已经领过了,直接拒绝;返回1才放行后续的写库操作。这些场景的共同特征是:两步甚至多步操作必须当作一步执行,用Lua脚本就是最直接的解法。

3.4 Lua调试:那些坑了我很久的问题

热搜词里有lua string.char、lua其他调试工具、idea lua插件下载、lua安装包,看来大家对Lua的开发环境还是有不少疑问。我在面试里也被问到“你怎么调试Lua脚本”,说实话当时有点紧张,因为日常开发里Lua调试确实是一个容易被忽略的环节。

实际开发中,Lua脚本的调试主要有这么几个层次:

第一个是Redis命令行直接调试。把脚本保存到本地文件,用redis-cli --eval传参执行,先确认语法没问题、逻辑符合预期。这一步是最基础的,也是最先应该做的。

第二个是日志调试。Redis的Lua脚本本身不支持直接向应用侧打印日志,但你可以在脚本里使用redis.call('SET', 'debug:xxx', value)的方式把中间变量写到一个调试key里,跑完脚本后主动去查这个key,相当于“埋点打印”。这种方式在定位复杂脚本的中间状态时非常关键。

第三个是单元测试。Redis官方提供了redis-lua调试器,也支持在IDE里装Redis插件,通过stacktrace定位错误行。不过我在实际项目中积累的经验是:把Lua脚本的核心算法先用Java复刻一遍,用Java的测试框架覆盖各种边界条件,逻辑验证确定后再翻译成Lua。这样既能保证逻辑正确性,又能在出了线上问题时用Java代码快速模拟复现场景。很多同学拿到复杂脚本就直接往Redis里跑,出了问题只能靠肉眼盯脚本,效率极低。

另外要说一个容易忽略的点:Lua脚本里对Redis key的访问必须遵循集群模式的约束——同一个脚本中访问的key必须落在同一个slot里。如果你用了Redis Cluster,脚本里最好使用Hash Tag,让相关key的键名包含相同的{tag},确保它们路由到同一个slot。这个在单机环境下验证不出问题,但一上集群环境就可能报CROSSSLOT错误。这个经验我当时是踩过坑之后才总结出来的。

4. 常见问题与面试复盘:哪些地方容易翻车

4.1 关于Redis数据类型的追问

面试中有一轮面试官让我说说Redis有哪些数据类型,并且结合场景分析。我说了String、Hash、List、Set、ZSet五种基础类型,以及Bitmap、HyperLogLog、Geo等扩展类型。但这次面试不一样的是,他让我现场分析一个实际场景:“统计一个用户30天的登录天数,怎么设计key?”

我当时的思路是用Bitmap。以login:202501为key,第10天登录就把第10个bit位设为1。统计的时候用BITCOUNT就能得到这个月登录了多少天。如果按用户维度统计30天内的登录天数,key可以是login:{userId}:{month},BitCount结果为登录天数。这个方法空间利用率极高,30天只需要4字节左右,比存一个数组或者set都划算。

如果是统计日活UV,可以用HyperLogLog。这个结构的特点就是牺牲精度换空间,标准误差0.81%左右,但对于千万级日活量级,12KB的固定内存开销已经是极大的优势了。面试官问我为什么不用Set去重,我说Set在百万级UV时内存占用会是HyperLogLog的几千倍,这是一个典型的用空间换精度的决策场景。

这个追问的考点其实是:你不仅要熟悉数据结构,还要能在真实业务中做出合理选型。很多时候面试官考察的不是你背得熟不熟,而是你有没有在真实项目中面对过“Redis存什么、怎么存、用哪种结构更合理”这些问题。我建议大家在准备时,针对每一种数据结构都想一个自己真正用过的业务场景。

4.2 缓存一致性:先更新数据库还是先删除缓存

面试中有一道题我记得很清楚,他说:“一个商品的详情缓存,用户在后台修改了商品价格,你应该是先更新数据库再删除缓存,还是先删缓存再更新数据库?”

这个问题的标准答案是先更新数据库,再删除缓存。因为先删缓存再更新数据库会有一个竞态问题:线程A删除缓存,线程B此时读缓存未命中然后去数据库读到旧值写入缓存,这时候线程A再更新数据库为新值,缓存里存储的就是脏数据,而且这个脏数据可能要等到缓存过期才被纠正。

但是先更新数据库再删除缓存也有极低概率会出现同样的问题:线程A更新数据库为新值,线程B此时读到旧值并写入缓存。这个问题有两种解法,进阶一点的叫“延迟双删”,即更新数据库之后先删除一次缓存,隔几百毫秒再删除一次,第二次删除是为了清掉并发读线程写入的旧缓存。还有一个更优的办法是订阅数据库的binlog,把删除缓存的操作放到binlog的消费端异步执行。

面试的时候我没有直接说一个固定方案,而是把两种方案的适用场景和风险点完整推到面试官面前,他频频点头的反馈说明这个思路是加分的。他后面补了一句“那如果缓存删除失败了怎么办”,我顺势引入了重试机制和MQ异步补偿。把这些问题串起来回答,比背任何标准答案效果都好。

4.3 高并发压测中发现的坑

这个部分我想拿出来单独说,因为它是真实发生的、踩得很痛的坑。

秒杀系统上线前做全链路压测的时候发现一个问题:QPS压到3000左右时,接口的RT突然飙升。排查了半天,最后定位到是Redis的连接池被打满了。我们的代码在每次扣减库存时都会创建一个RedisTemplate实例,这是极度糟糕的写法,应该是作为单例Bean注入。但更隐蔽的问题是:Redis操作没有合理配置连接池参数,默认的maxTotal只有8,压测线程一多,全部阻塞在连接池的等待队列里。

那次的修复方案很直接:连接池的maxTotal调到100,maxIdle设为50,并且加上了连接池的borrow检测,确保从池子里拿到的连接是健康的。同时优化了代码,把耗时操作尽量移出Redis调用的临界区,缩短单次操作的持连接时间。压测数据从3000 QPS提高到6000以上,效果非常明显。

还有一个问题是缓存key没有设置过期时间导致的脏数据。当时有个优惠券发放的缓存,每次用户领券后会更新库存缓存,但这个缓存key没有设置过期时间,就导致了一个非常隐蔽的Bug:数据库已经回滚了,但缓存里的券库存没有回滚,用户看到有券但是下单时总是提示“库存不足”。从那以后,我在定义缓存key时强制要求评估过期时间,能设过期时间的绝不设置永不过期,除非有极特殊的业务需求。并且在代码的缓存工具类中把“设置过期时间”作为必传参数,用强约束来防止人为疏忽。

说句心里话,阿里的P6面试是我经历过的最硬核的面试之一。整个流程走完,我更倾向于把这类面试理解成一次系统性的知识体检。它考察的不仅是你会不会用Redis,更是你面对一个高并发场景,能不能用最小的代价解决最大的问题。比如Redis遇到性能瓶颈怎么做分区,缓存和数据库的一致性问题怎么权衡,分布式下的事务怎么处理,这些都不是纯靠背面试题能答好的,必须真正在项目里摸爬滚打过。

这个面试实录只是一个切面,高并发与Redis Lua这两块尤其值得反复琢磨。如果你也是准备晋升或者跳槽,建议把你的每一个项目都按照“背景-方案-踩坑-优化”的结构梳理一遍,再针对性地看redis面试题和java面试八股文。只要能把知识体系和真实项目串起来,面试官的问题再千变万化,也逃不出你准备好的框架。从我个人经验来看,最值钱的不是背熟某个答案的过程,而是拷问自己“为什么”的那个过程。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询