高并发与Redis Lua:阿里P6面试实战复盘与系统设计解析

发布时间:2026/9/8 0:14:10
高并发与Redis Lua:阿里P6面试实战复盘与系统设计解析 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层。静态资源商品图片、页面框架全部走CDNNginx层面做简单的限流比如按IP维度限制请求频率这样能把无效流量挡在最外层。第二层是网关层。网关里做了用户维度的频控比如同一用户5秒内只能请求一次秒杀接口避免脚本刷单。这个其实已经能用RedisLua来实现后面细说。第三层是业务逻辑层。这里有前置校验、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问题不大但分布式场景下就必须有全局的限流能力。我当时用的是RedisLua实现滑动窗口限流代码不算长但面试官对这个非常感兴趣一路追问到了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调用容易把问题搞复杂。读者可能会问为什么不直接用INCREXPIRE计数限流滑动窗口和固定窗口的区别在哪里这个问题我在面试里也主动提了。固定窗口的缺陷在于临界突变问题比如限制每分钟100次用户在59秒请求了100次第60秒又可以请求100次实际上两秒内通过了200次。滑动窗口则把时间刻度细化到秒级甚至毫秒级能平滑掉这种毛刺。不过滑动窗口的代价是内存占用更高ZSET里每个请求都存一个memberQPS高的时候需要关注内存增长。我的经验是纯限流场景如果有预算上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调到100maxIdle设为50并且加上了连接池的borrow检测确保从池子里拿到的连接是健康的。同时优化了代码把耗时操作尽量移出Redis调用的临界区缩短单次操作的持连接时间。压测数据从3000 QPS提高到6000以上效果非常明显。还有一个问题是缓存key没有设置过期时间导致的脏数据。当时有个优惠券发放的缓存每次用户领券后会更新库存缓存但这个缓存key没有设置过期时间就导致了一个非常隐蔽的Bug数据库已经回滚了但缓存里的券库存没有回滚用户看到有券但是下单时总是提示“库存不足”。从那以后我在定义缓存key时强制要求评估过期时间能设过期时间的绝不设置永不过期除非有极特殊的业务需求。并且在代码的缓存工具类中把“设置过期时间”作为必传参数用强约束来防止人为疏忽。说句心里话阿里的P6面试是我经历过的最硬核的面试之一。整个流程走完我更倾向于把这类面试理解成一次系统性的知识体检。它考察的不仅是你会不会用Redis更是你面对一个高并发场景能不能用最小的代价解决最大的问题。比如Redis遇到性能瓶颈怎么做分区缓存和数据库的一致性问题怎么权衡分布式下的事务怎么处理这些都不是纯靠背面试题能答好的必须真正在项目里摸爬滚打过。这个面试实录只是一个切面高并发与Redis Lua这两块尤其值得反复琢磨。如果你也是准备晋升或者跳槽建议把你的每一个项目都按照“背景-方案-踩坑-优化”的结构梳理一遍再针对性地看redis面试题和java面试八股文。只要能把知识体系和真实项目串起来面试官的问题再千变万化也逃不出你准备好的框架。从我个人经验来看最值钱的不是背熟某个答案的过程而是拷问自己“为什么”的那个过程。