1. 项目整体描述
项目介绍:设计并实现多场景融合的本地生活服务平台,集成用户社交互动(签到/点评/关注)、实时数据统计、限时抢购等功能模块,通过Redis技术栈解决核心业务场景中的典型技术难题
技术架构:SpringBoot+MySQL+Mybatis-Plus+Redis (主技术栈) | Redisson (分布式锁) | Kafka (异步解耦) | Nginx (负载均衡)
-
无状态安全登录:基于Token无状态认证,Redis Hash集中管理会话信息,双拦截器动态刷新Token及校验权限,通过Redis Set构建手机号/IP黑名单拦截恶意请求
-
商户缓存与LBS服务:采用多级缓存 (Redis+Caffeine本地缓存),通过空值缓存+布隆过滤器防穿透,热点key互斥锁防击穿,随机过期防雪崩;基于Redis Geo实现附近商家检索 | (2000并发场景下商户查询平均响应时间↓29.6%)
-
分布式秒杀系统:Redis生成全局唯一ID (时间戳分片+自增序列),Lua脚本原子化库存预扣减,Redisson分布式锁实现"一人一单",Kafka异步解耦订单校验与创建 | (2000并发下平均响应时间↓32.1%,吞吐量↑43.3%)
-
系统高可用防护:基于Sentinel限流策略 (QPS=2000),系统在3000并发下实现平均响应时间533ms (↓34.5%)、吞吐量3312/sec (↑39.5%),保障突发流量下的稳定服务;集成熔断降级机制,下游异常触发熔断快速失败,恢复后渐进放量,保障高可用服务
-
实时统计与社交:基于BitMap实现连续签到统计,HyperLogLog计算UV/DAU,Redis SortedSet支撑Feed流滚动分页及点赞实时排行榜
2. 无状态安全登录模块
2.1 为什么选择基于Token的无状态认证而不是传统Session?如何解决Session共享问题?
(1) 首先解释一下什么是基于传统的Session登录
首先,介绍一个关键接口:HttpSession是Java Servlet API提供的会话管理接口,可以实现获取会话数据,存储会话数据以及销毁会话。第一次请求到来时,会自动创建HttpSession对象存储用户状态,并通过Set-Cookie返回JSessionID给客户端,后续请求前端会将该SessionID携带到后端,后端验证其会话有效性,从而实现会话保持。
(2) 再来说一下传统的基于Session登录的缺点
传统的Session认证通过服务端存储会话状态,即通过Session ID与用户信息实现映射。但是在分布式系统中,当有多台tomcat服务器时,当前端有请求到后端时,先会经过Nginx网关,负载均衡,将请求分配到不同的后端服务器。此时,就会出现不同服务器之间Session的共享难题。
(3) 基于上述缺点,为什么选择基于Token的登陆方式?
Token令牌是客户端与服务器之间用于身份验证的一种凭证机制,本质是一个经过加密或者签名的字符串,承载了用户的身份信息和权限数据。常见的Token类型包括:JWT令牌和Opaque Token不透明令牌。
使用UUID随机生成的字符串就是属于不透明令牌,他不携带任何用户信息,仅作为用户的唯一标识符,需通过服务端查询才能获得相关用户的数据。所以,就需要一个服务端的数据库将Token与对应的用户信息存储起来。
这时就需要选择一个数据库来存储该Token与用户数据的对应关系。需要满足:可实现服务器之间的共享,读写速度快,并且为key-value结构。所以Redis就满足这样的条件。
2.2 说一下基于Token实现无状态登录的过程?
当用户第一次请求时,会在服务端生成该用户对应的Token,服务端将其存储在Redis中,将Token作为key,UserID作为value。当用户再次发起请求时,会携带之前生成的Token到服务端,服务端根据该Token去数据库中查询是否存在。如果存在,则说明之前请求过,所以恢复会话。如果服务端没有该Token时,就需要进行请求数据库,查询该用户是否存在,如果存在,则生成对应的Token,存储在Redis中,如果不存在,则返回错误。
2.3 说一下双拦截器是怎么实现动态刷新Token的?
(1) 首先说一下双拦截器的拦截原理?
其中一个拦截器拦截需要用户登录才能访问的请求,另一个拦截器则拦截一切请求。当前端请求到达时,先被第一个拦截器拦截,获取Token,查询Redis用户,如果存在则保存用户信息到ThreadLocal,并刷新Token有效期,放行。如果不存在,也直接放行即可,因为还有一个拦截器。当到达第二个拦截器时,查询ThreadLocal的用户,如果存在,则放行,如果不存在就拦截。
(2) 为什么不能使用一个拦截器?
如果只使用一个拦截器,即只对需要登陆的请求进行拦截,然后进行刷新Token的话,就会导致:假如用户正在访问的请求不需要用户登录,就会导致拦截器失效,Token就无法刷新,这样即使用户一直在访问,过一段时间还会导致Token失效,用户体验不好。(这里有一点疑惑的地方,为什么不使用一个拦截器,拦截所有请求?)所以就在该拦截器前再加一个拦截器,用来拦截所有请求,刷新Token,具体的实现步骤就如(1)所述。
2.4 使用Redis存储Token所使用的数据结构是什么?为什么?
需求分析:我们需要存储Token以及用户信息如用户名,用户ID,用户手机号等。这样的存储需求可以使用String字符串实现,以JSON的格式来实现存储,但是这样的存储会导致很多额外的字段,比如说“token:”,“user:”等,这些字段也会占用一定的内存,当用户数量较多时,就会造成空间的浪费。除了String外,就是使用Hash结构,以Key-value的格式进行存储,占用的内存也比较小,只需要存储对应的数据即可,没有额外的字段。
确认了要使用Hash数据结构,接下来就是确认Key和Vlaue的选择。这里的Key的选择需要满足唯一性和方便携带,因为前端发送请求过来时,会携带Token来与Redis中的数据进行对比,看是否存在。所以就选择Token来作为Key,这里说一下为什么不使用手机号作为Key:如果使用手机号作为Key,需要返回给前端的也只能是手机号,这样就会造成数据的泄漏,所以就使用Token。
2.5 如何通过Redis Set实现高效黑名单拦截?遇到海量黑名单数据时如何优化?
(1) 首先说一下手机号黑名单的拦截原理
如果有恶意请求一直使用手机号获取验证码,这样就会导致Redis的压力变大,浪费Redis的内存。在我们的日常生活中,使用某些APP进行登录时,常常会发现一分钟或几分钟内只能获取一次验证码,并且会发现一天内只能获取一定数量的验证码,这就是手机号的黑名单拦截。具体实现就是一种锁的思想:当用户第一次获取验证码时,获取成功后,使用Redis Set向Redis中写入一条数据,即以该手机号+标识符作为key,value为1,有效时间设置为2分钟,这样就实现了在这两分钟内,无法获取验证码。并同时维护一条以该手机号+标识符为Key,值一直累加的Redis数据,有效期设置为24小时,作为限制该手机号一天内最多的登录次数,当达到一定次数就返回“已经被限制”,最终实现了手机号黑名单的拦截。
(2) 再说一下IP地址黑名单的原理
上边是对手机号的重复登录进行黑名单处理,还有一种可能的情况是使用同一个人使用不同的手机号进行恶意请求,这样也会造成Redis的压力。所以需要对IP地址也进行黑名单的处理。主要原理就是通过HttpServerletRequest对象,中的getRemoteAddr方法进行获取对应的IP地址,维护一个以该IP地址为key的数据,每当这个IP地址请求时,就将次数+1,达到一定次数就拒绝请求。这样就实现了IP地址的黑名单处理。
(3) 遇到海量黑名单可以怎么优化?
首先第一种策略就是,根据手机号和IP地址的特点,分片存储:单个Set过大会导致内存压力和查询效率下降,解决方案:按手机号前3位分片,或者IP地址按照IP段进行分片,这样就可以做到分散存储压力,并行查询,提升吞吐量。
给黑名单用户设置一个有效期,每隔一段时间清理一次。
2.6 如何防御Token泄露导致的中间人攻击?有哪些补充安全措施?
从三个层面构建防御体系:
传输安全:强制HTTPS加密传输,Token通过Authorization头传递而非URL参数
动态失效机制:若检测到异地登录或敏感操作时,立即删除Redis中的会话Hash并加入黑名单。
3. 商户缓存与LBS服务模块
3.1 为何选择多级缓存(Redis + Caffeine)?如何协调两级缓存的读写?
首先,选择二级缓存的目的就是在性能、资源成本和系统可用性之间取得最优平衡。接下来分别介绍一下Caffeine本地缓存和Redis缓存:
Caffeine是基于内存的本地缓存,直接存储Java对象在JVM堆内存中,避免网络IO与序列化开销,适用于高频访问的热点数据(如Top20%的商户信息)。由于是本地缓存,所以读写速度就比较快,所以适用于高并发场景。
(序列化开销:将对象转换为字节流(如JSON等)以进行网络传输或持久化存储,反序列化就是将字节流还原为对象。序列化与反序列化会消耗大量的CPU时间,而且需要中间内存实现转化。)
Redis是分布式缓存,作为全局唯一的数据源,存储大量数据,保障多服务与多节点之间的数据一致性。
总结回答:使用多级缓存的原因在于结合两者的优势并规避单一方案的局限性:仅用本地缓存虽然访问速度快,但受限于单机内存容量并且无法保证分布式环境下数据的一致性;仅用Redis虽然支持全局一致性和大容量存储,但是网络IO和序列化开销会导致高频请求的延迟上升。多级缓存通过本地缓存拦截80%以上的高频热点请求(如热点店铺数据),同时依赖Redis存储全量数据、保障跨节点一致性,并兜底缓存未命中的请求,最终在性能(平均响应时间降低)、资源成本(Redis负载下降)和可用性(Redis宕机本地缓存仍可支撑核心流量)之间取得平衡。
如何能确保不同请求读取到本地缓存呢?
一致性哈希负载均衡,可以实现同一个ID请求到的是同一台服务器,这样就可以实现每次请求都是相同的服务器;缓存预热机制:启动时加载热点数据:实例启动时默认从Redis中加载高频访问数据到本地缓存,并且定时刷新。本地缓存失效同步:当数据更新时,使用Kafka消息队列通知所有实例失效本地缓存。
怎么协调两级缓存的数据一致性?
首先:读操作优先查询本地缓存,未命中则穿透Redis/数据库进行查询并进行异步回填;写操作先更新数据库,再失效Redis并通过Kafka广播缓存失效事件,确保所有节点本地缓存同步失效。在高并发场景中,为避免缓存击穿并平衡一致性,采用以下策略:Redisson分布式锁实现”单线程回源“;本地缓存短TTL过期时间+随机过期策略避免集中回源与数据强制刷新一致;使用异步失效传播保证最终一致性。
3.2 如何通过布隆过滤器+空值缓存解决缓存穿透?误判率如何控制?
首先解释一下什么是缓存穿透:是指查询一个不存在的数据,导致请求直接穿透缓存层、频繁访问数据库。大量此类请求可能导致数据库压力过大甚至崩溃。
所以解决缓存穿透的策略是:布隆过滤器+空值缓存:当请求到达时,先经过布隆过滤器判断键是否存在,如果不存在,直接返回空值,无需查询数据库或缓存。如果存在继续查询缓存。当布隆过滤器判断键存在查询数据库,若数据库中不存在该数据,则将空值写入缓存,并设置一个简短的过期时间。新增数据时,需同步更新缓存和布隆过滤器。
布隆过滤器:是一种空间高效的概率型数据结构,用于快速判断某个元素是否属于一个集合,其核心是由一个二进制位数组和多个哈希函数组成:添加元素时,通过多个哈希函数将元素映射到位数组的多个位置,并将这些位置置为1。查询数据时,检查所有哈希函数对应的位置是否都为1,如有一位为0,则元素一定不存在,若全为1,则元素可能存在,因为存在一定误判率。但是布隆过滤器不支持删除元素。
误判率如何控制?
由于布隆过滤器的误判率取决于位数组的大小、哈希函数的数量、预期存储的元素数量,想要让误判率低,就需要使得位数组大小变大,内存成本增加。并且哈希数量越大,计算开销也越大。所以需要权衡。可以使用开源库(如Guava的BloomFilter
)自动计算参数。
3.3 互斥锁如何防止缓存击穿?分布式场景下如何避免死锁?
首先说一下什么是缓存击穿:缓存击穿是指热点数据过期瞬间,并且重建较复杂,就会导致大量的请求同时穿透缓存直达数据库,导致数据库负载剧增。
解决方案:单线程重建+双重检查:单线程重建:当缓存失效时,使用互斥锁(如Redis的SETNX或本地锁)让第一个线程负责重建缓存。其他线程阻塞等待或者轮询,直到重建完成。双重检查:线程先判断缓存是否存在,如果不存在,尝试获取锁,获取成功后再次检查缓存,避免重建。重建完成后释放锁。设置锁的过期时间,避免死锁。
分布式场景下避免死锁:死锁场景:线程A获取锁之后,崩溃,导致死锁。线程A获取锁之后,崩溃,导致锁超时释放,但是当A醒来时,会去释放锁,会导致锁的误删。避免死锁策略:超时释放:设置锁的超时释放时间,设置自动过期时间;唯一标识与原子释放:为锁设置一个唯一标识UUID,释放时先校验标识,避免误删;心跳续期:持有锁的线程定期续期如Redisson的看门狗机制,防止业务执行时间超过锁释放时间。
3.4 如何通过随机过期时间避免缓存雪崩?
首先解释一下缓存雪崩:大量缓存在同一时间集中过期生效,导致瞬间所有请求直接穿透到数据库,引发数据库负载激增。
如何解决:为每个缓存键的过期时间添加随机偏移量,一般为基准时间+-10%,可以将大量缓存的失效时间分散到不同时段,避免雪崩。
3.5 Redis Geo如何实现附近商家检索?GeoHash精度问题如何解决?
首先介绍一下Redis的Geo功能:是基于Redis的Sorted Set实现的,将地理位置编码为GeoHash字符串,并将其作为有序集合的score值存储,并支持GEORADIUS等命令快速检索附近商家。是基于原始经纬度数据,使用球面距离公式,进行精确计算。
GeoHash的精度问题,可以通过动态调整GeoHash位数进行解决,比如说8位GeoHash精度为0.019km,适用于高精度场景。而6位的,就适合低精度的场景。
3.6 如何应对商户地理位置数据大规模更新?
位置数据大规模更新涉及到更新期间仍需保证实时查询的可用性,所以需要保证低延迟。首先可以采用批量异步更新:使用Redis的GEOADD命令批量插入,减少网络开销。同时可以通过Kafka实现数据生产与消费异步解耦,消费者分批次消费队列,调用Redis批量更新接口;其次还可以使用数据分片策略Sharding,将商户数据按城市/地理区域划分到不同Redis实例,降低单节点压力。通过在应用层维护分片映射表如城市编码对应Redis实例IP;增量更新:仅更新发生变化的商户坐标;后台加载与预热:在流量低谷时段执行全量更新,更新完成后主动触发热门区域查询,填充缓存。
总结回答:首先将更新请求写入消息队列,消费者分批调用GEOAD批量写入;按地理位置分片存储数据,分散写入负载;增量识别变更数据;低峰期执行全量更新,更新后预热缓存;监控Redis指标与状态,异常时熔断降级。
4. 分布式秒杀系统
4.1 为什么选择Redis生成全局唯一ID?时间戳分片+自增序列的具体实现方式是什么?如何避免重复?
首先,在分布式系统中,为了使得生成的ID不重复,就需要一个全局统一的数据库来存储该ID值。并且Redis的读写速度也比较快,并且Redis可以实现自增,保证唯一性。所以就使用Redis来生成全局唯一ID。ID组成分为64位,第0位是符号位,始终为0,1-32位是时间戳,以秒为单位,可以使用69年。其次33-64位是序列号,实现区别同一秒内的ID,即使是高并发场景,也可以满足一秒内产生2^32的ID数量。在我们项目中自增是通过Redis的键increment对应的值自增实现的,其中键是按照一天来进行分片的,也有利于统计一天的总销量。
如果需要更多的ID,那么就可以使用更小的时间窗口分片,如毫秒级,避免单日的序列号溢出。
4.2 Lua脚本如何实现库存预扣减的原子性?如何处理超卖问题?
首先,在高并发场景中,库存扣减必须满足原子性和防超卖两个核心要求,而Redis的Lua脚本正是此设计的最佳方案。
Redis执行Lua脚本时,会将整个脚本作为一个单线程原子操作执行,不会被其他命令中断,天然避免了并发冲突。传统的分步操作可能会先获取判断,然后再执行扣减库存,但此时可能出现的问题就是在执行扣减操作时,有其他线程对该数据进行了操作,发生了线程安全问题,导致了超卖问题。就通过Lua脚本实现原子性检查扣减,即将库存检查和扣减操作合并为一个原子操作。先扣减库存再生成订单,如果订单创建失败,再回补库存,异步补偿。就解决了超卖问题。
使用Lua脚本的缺点就是:由于执行Lua脚本时,其他相关线程会阻塞,直至Lua脚本执行完毕,所以如果Lua脚本的逻辑较复杂时,会导致后续请求延迟,甚至引发超时。所以一般在Lua脚本中都只是进行轻量化操作,避免循环、复杂计算或IO,只是执行Redis操作,速度就会比较快。可以将库存分散到多个Redis实例中,比如说商品ID哈希到不同节点,实现数据分片。
4.3 Redisson分布式锁如何实现“一人一单”?锁的粒度如何设计?(可作为项目难点去讲)
首先解释一下一人一单:确保同一个用户UserID在同一时间只能成功下单一次。
所以为了实现一人一单,锁的粒度就是以用户ID为维度,进行设计锁。当用户发起请求时,尝试获取与用户ID绑定的分布式锁,若锁被占用,及获取锁失败,就返回。获取锁成功后,开始业务处理,处理完毕后,释放锁。需要给锁设置一个过期时间,防止死锁。
为什么使用Redisson分布式锁呢?
(1) 使用synchronized锁
使用synchronized锁可以实现一人一单,即确保同时是有一个线程可以执行synchronied中的代码块,下单的逻辑就可以写在这个代码块中,当多个线程并发时,也能够保证同一个用户只能下一次单。
使用synchronized是可以实现单节点下的一人一单问题,即synchronized锁是存储在JVM中的,不同JVM的synchronized锁是隔离的,所以在分布式系统中,无法解决一人一单问题。
(2) 使用分布式锁——基于Redis实现的分布式锁
分布式锁就是指在分布式系统中,也能够保证只有一个线程能够获取到这把锁执行业务,就保证了一人一单。首先介绍第一种分布式锁,就是基于Redis实现的分布式锁,使用SETNX指令,当已经下过单,就会在Redis中存在关于该用户的标识符,重复下单时,SETNX就会失败,从而实现了一人一单。释放锁的时候直接delete即可。
但是这样的锁存在一个问题,即当线程A获取到该锁时,由于某种原因导致线程A崩溃,导致A的锁超时释放,这时其他线程进入能够顺利获取到锁。当A继续执行时,执行结束就会去释放锁,但是此时的锁并不是线程A的,而是其他线程的,就导致了锁的误删问题,此时再有C线程进入时,同样可以获取锁,就发生了线程安全问题,就难以保证一人一单。
(3) 分布式锁——结合锁标识符实现Redis分布式锁
使用UUID+threadID来标识这个锁的主人,在释放锁之前先判断当前锁是否属于当前线程,如果属于就可以释放,如果不属于就不能释放。这样在一定程度上就可以解决锁的误删问题。
但是,上述方案中依然存在误删的可能。因为判断锁是否属于当前线程和释放锁这是两个步骤,不是原子性的,就有可能导致判断完之后是属于自己的,就在此时线程A停止,A的锁超时释放,等A苏醒过后,就会直接去释放锁,因为之前判断过就是属于他的锁,还是会导致线程安全问题,难以实现一人一单。
(4) 分布式锁——结合Lua脚本实现分布式锁
即将判断当前锁是否属于自己以及释放锁放到Lua脚本中进行操作,以保证原子性,从而实现分布式锁。
(5) 分布式锁优化——Redisson分布式锁
基于上述所实现的锁是使用Redis的SETNX指令来实现的,这样的锁有以下缺点:不可重入、超时释放问题、不可重试还有就是主从一致问题。不可重入:同一线程无法重复获取锁,导致嵌套调用时死锁;超时释放:业务执行时间可能超过锁超时释放时间,导致锁提前释放,其他线程获取锁之后可能或导致线程安全问题;不可重试:当第一次获取锁失败后,就会直接退出,不会进行重试;主从一致性问题:单点Redis宕机时,锁可能丢失,导致主从不一致。
以上都是基于Redis的SETNX实现的锁所存在的缺点,所以通过使用Redisson分布式锁可以解决上述问题。优点:自动锁续期:Watchdog机制(待学),获取锁之后启动后台线程,定期检查锁状态并续期。可重入锁:记录锁的持有线程和重入次数,同一线程可以多次获取锁,在释放锁时减重入次数即可,直到重入次数变为0释放锁;公平锁:按请求顺序分配锁、避免线程饥饿;支持Redis集群;超时与重试机制:可以指定获取锁失败后的策略即快速失败或者等待重试。
锁的使用与SETNX差不多,只不过在一些功能方面Redisson要更加健壮一点。
(6) 异步秒杀优化——阻塞队列
之前实现的秒杀业务都是串行执行的,如:查询优惠券、判断是否已经购买过、库存是否充足等都是对数据库的操作,并且只有当这些流程完成以后才能进行下单,扣减库存,生成订单才能结束这次业务。这会导致效率较慢,当高并发下性能较差。
所以期望实现异步下单。可以把查询优惠券、判断是否已经购买过、库存是否充足都看作是判断是否具有购买资格,而具体的减库存和创建订单是不影响我下一个线程来进行秒杀的,因为通过在Redis中提前扣减库存,就可以对下一个线程进行购买资格的校验了。将下单的流程交给阻塞队列,让单独的线程去处理阻塞队列中的任务,就实现了异步下单。
总结:异步秒杀优化的核心思路是通过业务解耦与异步处理解决高并发性能瓶颈:将秒杀流程拆分为同步资格校验和异步订单创建两个阶段,同步阶段利用Redis原子操作快速完成库存预扣减、用户重复购买校验等核心资格判断(耗时从80ms降至2ms),通过后立即响应请求并释放资源;异步阶段将订单数据封装为任务投递至阻塞队列,由独立线程池消费队列并执行数据库落库、订单生成等耗时操作,实现请求处理与资源占用的解耦。
(7) 异步秒杀优化——Stream消息队列
上述的阻塞队列,使用的是JVM本地的内存来存储消息,并且还可能造成消息的丢失,比如说当线程将阻塞队列中的消息取出后,无论处理失败成功,都是无法再次进行处理的,如果失败就会造成消息的丢失。
所以使用消息队列来完成异步下单,因为消息队列有比较完善的消息处理机制。基于Redis的消息队列有三种实现形式:基于List数据结构、基于PubSub、基于Stream的消息队列。(各自的优缺点需要掌握)最后选择基于Stream的消息队列来实现。
首先创建一个Stream类型的消息队列,修改之前的Lua脚本,在Lua脚本中增加在认定有购买资格后,直接向消息队列中添加消息,在项目启动时,就创建一个线程任务,消费消息队列中的任务。
这样的消息队列就可以实现消息的安全性,只有当消息正确处理后,才会清除消息,否则会一直消费。
(8) 异步秒杀优化——Kafka消息队列
基于Stream的消息队列有以下缺点:①数据持久化可靠性不足:Stream是基于Redis实现的,Redis是内存存储,虽然支持持久化,但是大规模的消息堆积可能会存在数据丢失的风险,而Kafka将消息持久化到磁盘,支持多副本,数据可靠性更高。②消费者组功能较弱:Redis Stream的消费者组功能较为基础,缺乏分区和动态扩容,Kafka通过多分区实现并行消费,支持动态扩容。③运维复杂度高:Redis Stream缺乏成熟的监控工具(如消息堆积告警、消费延迟监控);集群模式下,Stream数据分布不均匀,可能引发 热点问题。
选择Kafka消息队列,有以下优点:①高吞吐与水平扩展:通过分区机制,将数据分散到多个Broker,单集群可支撑百万级TPS,适合高并发。②数据持久化与可靠性:消息持久化到磁盘,支持多副本,即使部分节点故障也能保证数据不丢失。③消费者管理灵活:支持消费者组、消息重放、重置offset等功能。如当业务故障时,可重置offset重置消费消息,确保数据完整性。④Kafka具有成熟的生态,运维成熟度高。
具体实现:首先请求到达seckillVoucher方法中,使用Lua脚本进行秒杀资格校验,如果通过,则发送消息到Kafka,其中信息中包括orderId,voucherId,userId等,将消息发送到对应的topic,因为不同的topic,Kafka消费者处理请求的方式不同。当然资格校验可能存在不合格的情况,此时返回对应的错误信息即可。在topic对应的消费者中,会请求createVoucherOrder方法,该方法中会进行订单创建,扣减数据库中的库存。如果在这个过程中,出现了创建订单失败,也会将该消息发送到Kafka队列对应的topic,然后该topic对应的消费者就会去回滚数据库以及Redis中的数据,包括库存、订单信息、以及用户购买信息等。这个回滚操作也是使用Lua脚本进行的,因为要保证判断与回滚的原子性。
所以在这里,Kafka消息队列共使用了两次,一次是进行异步下单,另一次是订单创建失败,进行回滚时,也是异步进行的。
4.4 Kafka在系统中承担什么角色?如何保证订单校验与创建的可靠性?
首先Kafka在秒杀业务中有两处使用,分别是:异步创建订单和订单创建失败进行回滚操作。
异步创建订单实现了:异步解耦:订单校验与创建订单解耦分离,当判断有购买资格时,可以快速回应请求,Kafka异步去执行创建订单的操作如数据库写入和库存扣减等,提升系统吞吐量和响应速度。
流量削峰:当突发高并发请求写入Kafka队列时,消费者按处理能力匀速消费,避免数据库瞬时过载。
消息持久化:Kafka将消息持久化到磁盘(多副本),即使服务宕机也能保证数据不丢失。
可通过Kafka消息队列的以下机制实现订单校验与创建的可靠性:
生产者端:acks参数=all或-1,即生产者等待所有ISR副本确认后才认为消息发送成功过,防止数据丢失。并使用幂等发送:启用幂等发送,避免因为网络重试导致消息重复。
Broker端:多副本机制:每个分区的消息在多个Broker存储,单节点故障时实现自动切换。
消费者端:手动提交Offset:消息处理成功后,再提交Offset,避免因为消费失败导致消息丢失。消费者组重平衡:动态分配分区,确保消费者故障时,其他实例接管任务。
业务层容错:创建死信队列,多次重试失败的消息转入DLQ,人工介入处理。事务补偿:当订单创建失败时,进行数据的回滚操作。
4.5 如何应对Redis集群故障?库存数据如何持久化?
应对Redis集群故障:(1) 高可用架构设计:使用Redis Cluster模式,数据分片存储在多节点,单节点故障不影响整体服务。每个分片采用主存复制,主节点宕机时从节点自动晋升。采用Sentinel哨兵模式:监控主节点健康状态,自动触发故障转移。(2) 客户端容错策略:重试机制:客户端配置重试策略,避免瞬断故障;本地缓存降级:Redis不可用时,从本地缓存如Caffeine读取库存数据;直接降级数据库:极端情况下,绕过Redis直接操作MySQL库存,牺牲性能保可用性。(3) 监控与告警:集群健康监控:节点状态、内存使用率、主从同步延迟、OPS等。(4) 数据备份与恢复:RDB/AOF持久化,RDB定时快照,AOF记录所有写操作。
库存数据持久化:Redis层持久化:;异步双写数据库:写Redis成功后异步写MySQL;定时对账任务:每5分钟扫描Redis与MySQL差异,修复不一致(如Redis扣减库存成功但MySQL未更新,则回补库存);事务日志补偿:通过MySQL Binlog或Kafka消息记录操作流水,故障时回放日志修复数据;本地缓存兜底:Caffeine缓存库存数据,TTL设置为10s,Redis故障时短暂兜底。数据库限流:Redis不可用时,MySQL接口启用Sentinel限流,避免被击穿。
4.6 如何设计分库分表策略以支持海量订单?
4.7 如何实现秒杀系统的弹性伸缩?
(1) 流量预警与预案:基于往年大促流量曲线,预测本次秒杀流量趋势,提前预扩容资源;只用JMeter模拟不同并发量,建立TPS-资源消耗模型,明确扩容阈值。(2) 缓存层动态扩展:本地缓存兜底:使用Caffeine缓存热点数据,Redis故障时短暂支撑。Kafka分区动态扩展:秒杀前预增分区数,匹配消费者组并行度。(3) 数据库层弹性优化:读写分离,分库分表。(4) 流量调度与削峰:就近路由,按照地理位置调度到最近的可用区,减少延迟;消息队列削峰填谷,使用Kafka的延迟消息,非核心操作延迟处理,峰值时只处理关键路径。(5) 容灾降级与限流:使用Sentinel监控服务成功率,低于阈值时熔断非核心功能;静态降级:准备降级页面,Nginx直接返回静态页减少后端压力。精准限流:使用Sentinel进行限流。(6) 全链路监控
4.8 如何设计秒杀系统的全链路监控?
(1) 基础设施监控:监控目标:服务器、网络、存储等资源的健康状态,CPU/内存/磁盘使用率阈值警告;网络带宽/丢包率:检测DDos攻击或者网络攻击;(2) 中间件监控:Redis集群:内存使用率、慢查询、主从同步延迟;Kafka队列:Topic的堆积量、消费延迟、Broker CPU,如单个分区的消息堆积超过10万条或者消息延迟>30秒时,进行告警;MySQL数据库,针对QPS,慢查询率、主从延迟等进行监控;(3)业务指标监控:库存健康度,订单流水(如创建成功率、异常率),用户行为(参与用户数/UV)等进行监控;(4) 核心接口性能检测:秒杀资格校验、库存扣减、订单创建等接口的核心指标:QPS、平均响应时间、错误率等。
4.9 如果用户恶意刷单,如何防护?
(1) 前端防护:增加机器的请求成本
动态验证码:正常行为用户不弹验证步骤,异常请求(如1秒内点击多次)触发滑块、拼图或者短信验证。
(2) 流量过滤:精准识别异常请求
用户级限流:Redis计数器限制用户ID的QPS(如5次/秒);黑名单拦截,也可以接入第三方黑名单,拦截已知恶意IP。
(3) 业务规则:逻辑层防刷设计
库存分层校验,防止超量;用户行为规则:设置购买上限:同一用户/设备/手机号限购一件,也可以增加时间窗口设计如1天内仅可参与1次。订单校验:地址相似度检测,同一商品多个订单的收货地址、手机号高相似度拦截。
5. 系统高可用防护
5.1 限流策略与底层实现:为什么选择QPS=2000作为限流阈值?这个值是如何确定的?是否考虑过动态调整阈值?
该阈值是基于压测数据得来的。我们进行了多组实验,分别是1000并发,库存100,5秒循环、1500并发,库存150,5秒循环、2000并发,库存200,5秒循环、3000并发,库存300,5秒循环、5000并发,库存500、循环5秒。进行这样分组的目的是为了保证最后请求异常的比例是一样的,因为请求异常和正常响应时间是不一样的。所以为了在对比响应时间RT时公平,所以保证异常率一致。在以上实验中发现,当并发2000时,吞吐量可达到5000以上,RT200ms左右,而并发量3000时吞吐量2500左右,RT800ms,所以认为i性能瓶颈在2000左右,所以取QPS=2000作为限流阈值。(还需测试)
后期可通过结合Sentinel的动态规则推送功能,根据实时监控指标自动调整阈值。
总结回答:
5.2 熔断降级机制设计:熔断触发条件是如何设计的?熔断后如何实现“渐进式放量”?请结合Sentinel配置说明
首先,熔断策略分为三种,分别是:异常比例触发,如果调用次数超过请求数,并且异常请求数量超过了设定的比例,就会发生熔断;慢调用熔断:慢调用是指请求的时间超过阈值的请求,当请求书达到一定数量,并且慢调用的比例超过一定比例时,就会发生熔断;异常数熔断:单位时间内异常调用的次数超过阈值时,会发生熔断。
在我们的项目中设置的这里应该设置什么熔断策略,QPS和吞吐量不是一个东西
渐进式放量是指在熔断持续一定时间后开始进入半开状态,允许1个试探请求通过,若试探请求通过,则按照恢复斜率因子逐步增加放量比例,连续3个周期成功率>90%时,完全关闭熔断。
5.3 性能优化细节:在3000并发下实现响应时间降低34.5%的关键优化手段是什么?是否涉及线程模型或异步化改造?
优化手段就是通过限流实现的,基于2000QPS进行限流,
使用限流使得系统性能提高的本质原因是通过控制系统负载到最佳工作区间,避免因过载引发的资源竞争、缓存失效等问题。这并非直接提升性能,而是维持系统在其设计容量内的最优表现。
类比于我们排队进入食堂打饭,同样是3000人的情况下,排队进入就要比随便一股脑进去拥挤打饭的效率更高,这是因为在人数量少的的情况下减少了人与人之间的拥挤与争抢。换到并发请求中,人与人之间的拥挤就相当于线程之间的阻塞,锁的竞争,上下文的切换等。所以在一定数量下的请求要比一次性处理所有并发请求效率更高。
5.4 系统监控与压测:如何验证限流熔断策略的有效性?压测时如何模拟真实流量?
首先证明限流熔断策略有没有生效,可以通过模拟流量达到阈值时,系统能否准确触发限流/熔断,如果出现了,就证明策略生效了。验证结果在一个有效范围内就算是生效,比如说:限流误差率<+-5%,预设2000QPS,实际触发阈值在1900-2100之间。
有效性的验证就需要模拟现实场景中的真实流量。
在我们的项目中,模拟了现实场景中的简单并发情况,3000并发量,在一瞬间进行请求,且循环5秒。这种真实流量的模拟叫做脉冲式峰值,还可以进行模拟区域性网络抖动以及业务热点倾斜等。
5.5 容错与用户体验平衡:限流和熔断可能导致正常用户请求被拒绝,如何平衡系统稳定性和用户体验?
分层限流机制:针对不同的用户使用不同的限流策略:VIP客户请求拒绝率<1%,普通用户<15%。
柔性降级策略:限流触发:前端展示静态页面,如”排队中,预计等待15秒“;熔断触发:引导用户至降级页面(预售/优惠券)。使用本地缓存进行兜底。
动态决策:基于实时监控数据(CPU、线程池)自动调整限流阈值,高峰期给VIP用户保留20%额外带宽。
补偿机制:对因限流受阻的用户发放优惠券或者优先购买权,将技术限制转化为商业机会。
6. 实时统计与社交
6.1 BitMap如何存储用户的签到数据?一个用户的年度签到记录需要多少内存?
首先,如果使用String类型的数据结构存储用户的签到信息,不仅需要存储用户的个人信息,还需要存储签到日期以及签到结果,而且很多数据都是重复存储,这样计算的话,一个用户每天签到信息需要占用20字节左右的大小,一个月的话就是600左右字节,一年的话就是7000左右字节,这只是一个人的签到信息,当人数增多时,内存消耗会非常大。
所以需要更换一种记录签到的数据结构,因为考虑到用户签到只有两种状态,签或者不签,这样就可以使用二进制数来进行表示,1代表签到,0代表未签到,用连续的二进制位就知道日期是哪一天。一个月的签到信息只需要30位就可以表示,一年只需要365位即46字节左右,大大减小了内存的消耗。这种数据结构就是Redis的基于String类型的数据结构BitMap。
在存储时,键可以设置为用户ID和年份,值就是表示签到或者没签到的0/1二进制位,这样一个key下就可以存储一年的签到信息。
签到对应的Redis指令:SETBIT key 100 1 :表示一年中的第100天签到;
查询签到:GETBIT key 100:查询第10天的签到信息;
统计签到天数:BITCOUNT key:查询用户一年(需要看存储时的key是不是以一年为单位进行记录的)的签到天数。
如果用户过多,可以通过用户ID进行哈希分片存储或者进行年份隔离,防止一个key下存储的数据量过大。
6.2 如何用Redis命令实现连续签到天数计算?例如判断用户最近7天是否全勤。
在Redis中,使用BitMap存储每日签到状态,1表示签到,0表示未签到,可以通过Redis中的命令:BITFIELD计算连续签到的天数,如今天是一年中的第100天,要查询最近一周的连续签到情况,就可以使用指令:BITFIELD key GET u7 93,从偏移量93位开始读7位的无符号数,u代表无符号数,最后得到一个十进制数,即这7位二进制无符号数所对应的十进制数,如果为2^7=127,则表示最近7天都连续签到了,如果小于127,则表示没有连续签到。
但是如果需要统计用户的连续签到天数就需要遍历BITMAP中的每一位数,判断最长的来连续1的长度。可以通过Lua脚本计算,对于多年的或者多用户的场景,可以通过分块进行统计。
这样的统计方式,保证了原子操作,而且占用内存空间比较小,通过调整偏移量就可以统计任意的连续签到天数。
6.3 HyperLogLog的误差率是多少?为什么它能在极小内存下统计海量UV?
首先,HyperLogLog是一种用于基数估计的高效概率算法,通过哈希函数将元素映射到桶中,统计哈希值前导0的最大数量,来估计基数。无论数据规模多大,HLL仅需要维护固定数量的桶,Redis中默认桶的数量是16384,每个桶使用6位来记录最大前导0的数量,所以占16384*6=12KB的内存大小,占用内存的大小只与桶的数量有关系,与数据的多少没有关系,误差率也与桶数量有关系,16384桶的误差率为0.81%,这作为基数的统计是完全可以接受的。
统计不重复元素,最先想到的数据结构是Set,因为Set中不会出现重复元素,但是Set集合所占用的内存大小是随着数据量的大小来定的,可能还需要存储一些额外的数据,但这些数据对于统计基数来说是没有用的,所以当基数大时,就会造成占用内存空间很大的情况,不适合使用Set集合。bitMap也是一种统计基数的数据结构,是需要使用位数来统计即可,但是当用户量过大时,也会造成内存空间的消耗,而HLL消耗的内存空间大小,只与桶的数量有关,与基数的大小是没有关系的。
PFADD:将用户添加到HLL结构中;
PFCOUNT:估算HUV基数;
PFMERGE:合并多个HLL。(如分时段统计全天UV)
6.4 如果要合并多个HyperLogLog(如分时段统计全天UV),如何操作?会损失精度吗?
使用指令PFMERGE即可以合并统计多个HLL,其原理是按照桶取最大值进行合并,对每个桶取所有HLL中该桶的最大前导零的位数,和并后再基于新的桶值重新计算基数。合并中不会额外引入误差,只要两个HLL桶的数量是一样的16384,就还是0.81%的误差,并且还会自动去重,即使同一用户在两个HLL中都出现了,但是会只统计一次。
6.5 常用名词解释:PV、UV、DAU
PV:Page View,页面浏览量,用户每次对网站中的一个页面的请求或者访问均被记录1个PV,及一个用户多次访问,PV也会进行累计。
UV:Unique Visitor,独立访问用户数,一个电脑客户端算作是一个访客,0-24点内只会被统计一次,用来统计某个网站的访客数量。
DAU:Daily Active User,日活跃用户数量,常用于反映网站、APP应用等的运营情况,常用于统计一人或统计日之内,登录或者使用了某产品的用户的数量,一个用户只会被统计一次。
6.6 SortedSet的底层数据结构是什么?ZRANGE和ZREVRANGE的时间复杂度是多少?
6.6 Feed流分页时如何保证新数据实时插入后的分页一致性(如避免重复数据)?
6.7 如果用户量达到亿级,BitMap存储会遇到什么问题?如何优化?(例如分片或冷热数据分离)
6.8 用户签到可能存在跨时区问题(如美东/北京时间差异),如何设计BitMap的Key和签到时间处理逻辑?
6.9 如果发现某天HyperLogLog统计的UV比实际值低20%,可能是什么原因?如何排查?
6.10 Feed流中每条内容的“点赞数”用SortedSet存储,如何实现点赞数的实时更新和排行榜Top100的高效查询?
6.11 当SortedSet的成员数量达到千万级时,ZRANGE分页查询性能下降,如何优化?
6.12 为什么选择BitMap而不是Set来存储签到数据?两者的内存和性能差异是什么?
6.13 HyperLogLog和Bitmap在UV统计上的优劣对比?什么场景下必须用Bitmap?
6.14 如果Redis宕机,如何保证Feed流和排行榜的可用性?是否有降级方案?
6.15 用户签到成功后,如何保证BitMap更新与数据库事务的一致性?
6.16 Feed流中内容被删除后,SortedSet中的旧数据如何处理?如何避免脏数据?