面试刷题平台项目总结
项目简介:
面试刷题平台是一款基于 Spring Boot + Redis+ MySQL+ Elasticsearch 的 面试刷题平台,运用 Druid + HotKey + Sa-Token+ Sentinel 提高了系统的性能和安全性。
第一阶段,开发基础的刷题平台,带大家熟悉项目开发流程,实战 Spring Boot 应用的快速开发。
第二阶段,对项目功能进行扩展,精选4个真实业务场景,实战企业主流后端技术如 Redis 缓存和高级数据结构、Elasticsearch 搜索引擎、Druid 连接池、并发编程、热 key 探测的应用。
第三阶段,对项目安全性进行优化,比如基于Sentinel 进行网站流量控制和熔断、基于 Nacos 实现动态的 IP 黑白名单、基于 Sa-Token 实现同端登录冲突检测.基于 Redis 实现分级反爬虫策略等。
项目收获:
- 如何结合 Redis + Caffeine + Hotkey 构建高性能实时缓存
- 如何利用 Elasticsearch 实现灵活高效的内容搜索
- 如何巧用 Redisson 高级数据结构,实现高性能的接口
- 如何实现流量控制和动态IP 黑白名单,增强网站安全性
- 如何实现登录冲突检测和分级反爬虫策略,保护网站内容
用户刷题日历功能:
1、需求分析
为了鼓励用户多刷题并提升复盘体验,系统需支持刷题日历功能:每日用户首次浏览题目自动记录签到,用户可在前端通过图表查看某一年内每日的刷题签到情况。
2、技术方案
后端方案 - 位图 (Bitmap)
位图是一种用 bit 位 存储状态的极致节省空间的数据结构,适合大规模布尔值存储。在刷题签到场景,每个用户用位图记录一年 365 天的签到情况,每位表示一天是否签到(0未签到,1已签到)。
- 每年只需 46 字节 存储(46 × 8 ≥ 365天)。
- 100w 用户 ≈ 43.8MB,1000w 用户 ≈ 438MB,比Redis Set 节省数十倍内存。
- 位操作配合位运算,查询和写入效率高,适合低成本支撑千万级用户的签到服务。
3、后端开发
查询刷题签到记录接口优化
- 减少 Redis IO 次数(批量拉取,减少请求)
• 问题: 循环内 .get(offset) 每天一次 Redis 请求,365 天等于 365 次请求,极大浪费网络 IO。
• 优化方案: 用 RBitSet.asBitSet() 一次性从 Redis 拉完整的 BitSet 到内存,后续直接走 JVM 内存查询,只发一次请求。
- 精简返回值,减少数据传输
• 问题: 原本返回 Map<LocalDate, Boolean>,365 个键值对,数据量大、前端展示和后端计算都重。
• 优化方案: 直接返回签到天数 List,只返回用户签到的 具体天数,大幅减少数据量和后端开销,前端也好处理。
- 使用 nextSetBit 避免无意义遍历
• 问题: 以前用循环从 1 到 365 全量遍历,遇到未签到的天也强行 bitSet.get(),浪费 CPU。
• 优化方案: 用 bitSet.nextSetBit(fromIndex) 跳跃式查找签到天数,只遍历有效位,CPU 计算量进一步降低。
总结一句话
从「多次 Redis IO」→「一次拉取内存」,从「全量 Map 返回」→「只返回签到天数」,从「全量遍历」→「跳跃查找」,读请求的 Redis 压力、CPU 开销和网络带宽全方位优化,轻松应对 高并发、低成本、大规模 需求。
优化小结
总结出几个实用优化思路
- 减少网络请求或调用次数
- 减少接口传输数据的体积
- 减少循环和计算
- 通过客户端计算减少服务端的压力
分词题目搜索功能
1、 需求分析
MySQL 的 like 查询只能简单模糊匹配,不能同时匹配多个关键词,而且数据量一大就很慢,复杂条件写起来也费劲。而 Elasticsearch 是专门做搜索的,支持分词、多个关键词、相关性排序,搜索又快又准,用户体验更好。简单说:MySQL 查得出,Elasticsearch 查得爽。
2、 方案设计
设计 Elasticsearch 索引时,核心思路是根据业务搜索需求合理设置字段类型和分词策略。
- 标题、内容、答案这种需要全文搜索的字段,一般用 text 类型配合中文分词器(比如 ik_max_word 做索引,ik_smart 做搜索),支持更精准的匹配;
- 标签、状态这类用于精确过滤的字段,用 keyword;
- 数值型比如 userId 用 long,
- 时间字段用 date 并设置时间格式。
- 通过 aliases 可以实现索引平滑切换,方便后期无感知升级。
这样设计兼顾了搜索准确性、查询效率和索引的可维护性。
3.1、数据同步
全量同步,通过实现 CommandLineRunner 接口的run() 方法,在 Spring Boot 启动完毕后自动执行从数据库中全量获取数据。
- 定时任务 每分钟自动从数据库捞取近 5 分钟内有变动的题目数据,同步到 Elasticsearch,确保 ES 数据是准实时的。
- @Scheduled 定时任务:每分钟触发 run() 方法; MySQL 查询:listQuestionWithDelete(fiveMinutesAgoDate) 查 5 分钟内有改动的题;
3.2、ES接口降级策略实现
通过try-catch 实现降级策略,通过捕获 Elasticsearch 查询时的异常(比如超时、连接失败),当 ES 查询失败时,程序自动进入 catch 逻辑,执行 MySQL 的模糊查询作为兜底方案,确保接口不报错、服务可用。
3.3、防止重复执行定时任务
在分布式部署或集群环境中,多个实例的定时任务可能在同一时间触发同一个任务逻辑,造成:①任务重复执行,②数据重复写入,③外部服务被重复调用
解决方案:基于 Redis 的分布式锁RLock。通过给定时任务方法加上自定义注解 @DistributedLock,实现一个可配置、可自动释放的分布式锁拦截器,确保同一时刻只有一个节点能执行任务。
总结:使用 AOP + 分布式锁注解(使用 Redisson 提供的 RLock.tryLock() 方法),可以确保在分布式环境下,同一时刻只有一个线程(通常指一个服务节点)能成功执行该定时任务,其他线程或节点会被锁拦住或放弃执行。
题目批量管理功能
批处理优化
一般情况下,我们可以从以下多个角度对批处理任务进行优化。
健壮性
健壮性是指系统在面对 异常情况 或 不合法输入 时仍能表现出合理的行为。一个健壮的系统能够 预见和处理异常,并且即使发生错误,也不会崩溃或产生不可预期的行为。
1、参数校验提前
可以在数据库操作前校验参数,减少无效查询。在当前添加题目到题库的逻辑中,已校验非空和题目、题库存在性,但还缺少对题目是否已添加的校验,导致可能重复插入,存在不必要的数据库开销。
2、异常处理
目前虽然有插入结果校验和自定义异常,但部分异常未细化处理。可按异常类型分类处理,如唯一键冲突捕获 DataIntegrityViolationException,数据库或事务错误捕获 DataAccessException,其他异常记录详细日志,便于排查。
稳定性
1、避免长事务
为避免长事务,批量操作应按批次分段处理,减少单次事务体量。若单条数据异常,只回滚当前批次,避免 10w 条全回滚,提升性能与稳定性。可通过新增方法实现分批事务控制。
具体实现:通过外层循环分批调用带事务的方法,实现小批量事务处理,避免了长事务带来的性能瓶颈和大范围回滚风险,提升系统稳定性和并发处理能力。这里必须用 AopContext.currentProxy() 获取当前类的代理对象来调用事务方法,因为 Spring 的事务是基于代理实现的,若直接用 this 调用,绕过了代理,事务不会生效。用代理调用才能保证事务拦截器生效,实现真正的事务控制。
2、重试
对于可能由于网络不稳定等临时原因偶发失败的操作,可以设计 重试机制 提高系统的稳定性,适用于执行时间很长的任务。
具体实现:通过在for循环中捕获异常,失败时记录重试次数并继续尝试,成功则跳出循环,超过最大重试次数仍失败时抛出业务异常,实现简单的重试机制保障操作可靠性。
性能优化
1、批量操作
当前代码中,每个题目是单独插入数据库的,这会产生频繁的数据库交互。利用MyBatis Plus 的 saveBatch 方法,①降低数据库连接和提交的频率,②避免频繁的数据库交互,减少IO操作
2、SQL优化
最基本的 SQL优化原则,不使用 select * 来查询数据,只查出需要的字段即可。不要返回整个Question对象,只返回题目ID即可。
3、并发编程
利用并发包中的 CompletableFuture +线程池来并发处理多个任务。CompletableFuture默认使用 ForkJoinPool 线程池,适合分治和递归任务。ForkJoinPool 采用工作窃取算法(从其他线程“窃取”任务),提高 CPU 利用率,通过拆分小任务并行执行,最后合并结果。
CompletableFuture 默认使用全局共享的 ForkJoinPool.commonPool(),多任务共用可能导致资源争抢和阻塞,建议针对不同任务自定义线程池实现资源隔离。
自定义线程配置:核心线程数4、最大线程数10,线程空闲60秒后销毁超过核心数的线程,使用容量为1000的阻塞队列存放任务,拒绝策略为CallerRunsPolicy(由调用线程执行任务)
4、 异步任务优化
立即执行异步任务:@Async注解
定时任务:将数据保存在Mysql中,通过Spring Scheduler定时任务扫描数据库中未执行的任务。
消息队列:对于对于长时间的批量任务,可以用消息队列(RabbitMQ,Kafka)异步处理,将任务放入消息队列,由消费者后台异步执行。
5、数据库连接调优
通过Druid复用数据库连接,而不是在每次请求都新建和销毁连接
Druid配置:初始连接数为10,最小空闲连接数为10,最大空闲连接数为10,超时等待(没连接了,线程需要等多久)时间为6秒,每隔2秒检测一次。
数据一致性
1、事务管理
我们目前已经使用了 @Transactional(rollbackFor = Exception.class)来保证一致性。如果任意一步操作失败,整个事务会回滚,确保数据一致性。
2、并发管理
在高并发场景下,如果多个管理员同时向同一个题库添加题目,可能会导致冲突或性能问题。为了解决并发问题,确保数据一致性和稳定性,可以有2种常见的策略
- 增加 分布式锁 来防止同一个接口(或方法)在同一时间被多个管理员同时操作,比如使用 Redis + Redisson 实现分布式锁。
- 如果要精细地对某个数据进行并发控制,可以选用 乐观锁。比如通过给 questionBank 表增加一个 version 字段,在更新时检查版本号是否一致,确保对同一个题库的并发操作不会相互干扰。
自动缓存热门题库
1、需求分析
系统如何自动发现热点数据将其缓存在本地或Redis中?
具体的规则:对于获取题库详情的请求,如果5秒内访问>=10 次,就要使用本地缓存将题库详情缓存 10 分钟,之后都从本地缓存读取。
2、方案设计
自动缓存热门题库需要以下五个步骤:
- 记录访问:用户每访问一次题库,统计次数+1
- 访问统计:统计一段时间内题库的访问次数。这是最难实现的一部分
- 阈值判断:访问频率超过一定的阈值,变为热点数据。
- 缓存数据:缓存热点数据
- 获取数据:后续访问时,从缓存中获取数据
HotKey组成部分
- Worker:分布式计算集群,基于滑动窗口计算,根据dashboard里配置的rule规则,进行热key推送。
- Dashboard控制台:ETCD集群,修改热key规则配置信息
- 实例:拉起并监听各worker信息;采集 key 使用数据,传给 Worker,让 Worker 判断是不是热 key
流程:
- 各worker上报自己信息到ETCD,维持心跳。
- 实例监采集 key 使用数据,传给 Worker,让 Worker 判断是不是热key。
- 实例建立和各worker的长连接,基于netty,将探测的key按hash算法分发到不同worker进行计算。
- worker推送探测出的热key到各实例,实例接受热key信息,在JVM中缓存。
3、后端实现
HotKey配置规则:本地最大缓存Caffeine设置为10000(个键值对);每隔1秒推送热点Key
通过JdHotKeyStore.isHotKey(key) 用来判断 这个题目的 key 是否是热点 key,就是看看这个题是不是近期高频访问、特别“烫手”的热门数据。
再通过JdHotKeyStore.smartSet(key, questionBankVO)将判断为热点 key的数据缓存到本地缓存(Caffeine)
网站流量控制和熔断(Sentinel)
1、需求分析
对查看题目列表接口整体限流
限流规则:
- 策略:整个接口每秒钟不超过 10 次请求
- 阻塞操作:提示“系统压力过大,请耐心等待”
熔断规则:
- 熔断条件:如果接口异常率超过 10%,或者慢调用(响应时长>3秒)的比例大于 20%,触发 60 秒熔断。
- 熔断操作:直接返回本地数据(缓存或空数据)
单 IP 查看题目列表限流熔断
限流规则:
- 策略:每个IP 地址每分钟允许查看题目列表的次数不能超过 60 次。
- 阻塞操作:提示“访问过于频繁,请稍后再试”
熔断规则:
- 熔断条件:如果接口异常率超过 10%,或者慢调用(响应时长>3秒)的比例大于 20%,触发 60 秒熔断。
- 熔断操作:直接返回本地数据(缓存或空数据)。
2、后端开发
使用 Sentinel 来进行资源保护,主要分为几个步骤
- 定义资源
- 定义规则
- 检验规则是否生效
开发模式:用注解定义资源 +基于控制台定义规则
对查看题目列表接口整体限流
资源的定义是通过 @SentinelResource 注解完成的,value 字段就代表 “资源名称”。 blockHandler、fallback 分别指定 限流/降级兜底方法
每次调用这个接口,Sentinel 底层就会创建一个 Entry,资源名 = listQuestionBankVOByPage,开始走限流、降级判断;
单IP查看题目列表限流熔断
1️、定义资源
- 资源名称是:listQuestionVOByPage
- 通过:
- SphU.entry(“listQuestionVOByPage”, EntryType.IN, 1, remoteAddr)明确标识“按接口 + IP”作为限流和熔断的维度。
2️、限流规则(ParamFlowRule)
- 目标:针对单个 IP 限流
- 规则配置:
- setParamIdx(0):按第一个参数(remoteAddr,IP 地址)限流
- setCount(60):每个 IP 每分钟最多请求 60 次
- setDurationInSec(60):统计周期 60 秒
- 效果:短时间内某个 IP 请求次数超限,直接限流。
3️、熔断规则(DegradeRule)
- 慢调用熔断:
- 过去 30 秒内,调用量超过 10 次
- 慢调用(>3秒)的比例超过 20% 时熔断
- 熔断时间:60 秒
- 异常率熔断:
- 过去 30 秒内,调用量超过 10 次
- 异常比例超过 10% 时触发熔断
- 熔断时间:60 秒
- 核心效果:接口响应时间持续过长或异常率高时主动熔断,保护后端。
4️、代码兜底逻辑
- 限流/熔断命中时触发 fallback:
- handleFallback():可返回空数据或缓存数据
- Tracer.trace():主动记录非限流业务异常
- BlockException 判断:精准区分业务异常 vs 限流/熔断异常
按IP限流与单个接口限流的区别
资源名:listQuestionVOByPage 是唯一标识
无论是整体接口限流,还是基于 IP 限流,资源名都是一样的,都是 “listQuestionVOByPage”,它代表了业务接口这个“大对象”。
1️、对整个接口限流
- 不传热点参数,或限流规则不配置热点参数维度(ParamFlowRule):
- 调用时:SphU.entry(“listQuestionVOByPage”)(没有参数传入)
- 限流规则针对整个资源整体请求数限制,比如每秒不超过 1000 次
- 这时限流统计的是所有请求加起来的流量,不区分用户或 IP。
2️、对 IP 维度限流
- 调用时传热点参数(IP),并配置 ParamFlowRule 绑定参数索引:
- 调用时:SphU.entry(“listQuestionVOByPage”, EntryType.IN, 1, remoteAddr)
- 限流规则:setParamIdx(0),表示按第一个参数(IP)做限流
- Sentinel 会针对每个不同 IP 单独统计和限流,互不影响
动态IP黑名单过滤(Nacos)
1、需求分析
通过IP封禁,有效拉黑攻击者,防止资源滥用。
2、方案设计
- 使用 Nacos 配置中心存储和管理 IP 黑名单
- 后端服务利用 Web 过滤器判断每个用户请求的 IP
- 后端服务利用布隆过滤器过滤 IP 黑名单
3、后端实现
动态接收黑名单配置,用布隆过滤器高效判断 IP 是否被拉黑,兼顾“性能高”“内存省”“更新灵活”。
1️、存储结构:BitMapBloomFilter
- bloomFilter:用 布隆过滤器 存储黑名单 IP,快速判断是否存在,查询 O(1),内存占用极低,允许极小误判但不会漏判。
2️、isBlackIp() 方法
- isBlackIp(String ip):对外暴露接口,用布隆过滤器判断某个 IP 是否是黑名单命中
3️、rebuildBlackIp() 方法
- 支持 动态刷新黑名单:
- 接收最新的黑名单配置(configInfo),通常是字符串(YAML 格式)。
- 用 SnakeYAML 解析出 IP 列表。
- 同步加锁 确保黑名单重建时线程安全;对 BlackIpUtils.class 加锁,是为了确保 全局唯一 的布隆过滤器在更新时不会出现多线程并发覆盖,保障重建过程的原子性和数据完整性。
- 重新生成 bloomFilter 实例,切换成新的黑名单。
基于 Nacos 的动态配置监听机制
NacosListener 通过实现 InitializingBean 接口,结合线程安全的 AtomicInteger 和异步线程池,在 Spring 启动完成后第一时间注册监听器,同时配置变更的回调由 Nacos 客户端通过长轮询机制自动触发,通过注册 Listener 并保持 SDK 正常运行,就会在后台自动拉配置、自动比对并调用 receiveConfigInfo() 方法确保系统可以动态感知黑名单配置变更并实时刷新本地数据,提升可用性和动态调控能力。
创建黑名单过滤器
黑名单应该对所有请求生效(不止是 Controller 的接口),所以基于 WebFilter 实现而不是 AOP 切面。
WebFilter 的优先级高于 @Aspect 切面,因为它在整个 Web 请求生命周期中更早进行处理。请求进入时的顺序:
- WebFilter:首先,Webfilter 拦截 HTTP 请求,并可以根据逻辑决定是否继续执行请求。
- Spring AOP切面(@Aspect):如果请求经过过滤器并进入 Spring 管理的 Bean(例如 Controller层),此时切面生效,对匹配的Bean 方法进行拦截。
- Controller层:如果 @Aspect 没有阻止执行,最终请求到达 @Controller或@RestController 的方法。
题目分级反爬虫
1、需求分析
知识付费类网站以内容为核心,一旦被恶意爬虫大规模抓取,不仅可能造成数据泄露和滥用,还会加重服务器负担,影响正常用户体验。因此,需要部署有效的反爬虫机制来保障系统安全与内容价值。
2、技术方案
建议采用分级反爬虫策略,先告警、再采取强制措施,可以有效减少误封的风险:
- 如果每分钟超过 10 道题目,则给管理员发送告警,比如发送邮件或者短信。
- 如果每分钟超过 20 道题目,则直接将账号踢下线,且进行封号操作。(或者限制一段时间无法访问)
3、后端开发
设计 Redis 键值对要能区分出用户和时间窗,示例 key 为 user:access:{userId}:{timestamp_in_minutes}
- {userld} 是用户 ID。
- {timestamp_in_minutes}是当前的分钟级时间戳,即将当前时间戳转化为分钟,这样每分钟的访问都会被统计到一个 key 中。
每个 key 的 value,就是该用户在这分钟内的访问次数。
Redis操作逻辑
在反爬虫场景中,我们常用 Redis 来做 访问频率统计,通过统计单位时间内某个 IP 或用户的请求次数,判断是否触发限流或封禁。
传统写法的问题:
jedis.incr(key); // 第一步:计数
jedis.expire(key, 60); // 第二步:设置60秒过期。
问题:如果多线程访问,你原本想统计“某个 IP 在 60 秒内访问了多少次”; 实际上由于每次访问都把 TTL 重新设为 60s,所以这个 key 一直存在,永远不会自动清除;
解决办法:用 Lua 保证原子 + 只设一次 TTL 的
总结:在高并发场景中,incr 和 expire 是非原子操作,容易导致 TTL 被频繁重置,造成 Redis key 永久存在,失去限流窗口的意义。通过 Lua 脚本保证“只设一次 TTL”,可以有效避免这个问题。