Spring Security + Redis + Kafka 构建分布式安全风控系统的核心链路设计
发布时间:2026/10/22 8:29:11 锦皓数字建站

前阵子帮几个朋友做模拟面试Java方向的同学几乎人手一道安全风控题Spring Security、Kafka、Redis 三个词堆在一起要求现场设计一套安全风控系统架构。有人能背出三个中间件的概念但一落到“怎么把认证、实时判定、异步分析串成一条完整链路”就卡壳。这篇文章就把我当时拆解这道面试题的全过程连同代码骨架、降级方案和复盘笔记一起整理出来适合正在准备大厂面试、或者对分布式安全风控系统感兴趣的同学参考。1. 面试官抛出的场景题暴露的不只是框架背诵1.1 一个高频真实场景登录防爆破与抢购防刷这类题目极少直接问“Spring Security的Filter顺序是什么”更多是给一个业务场景让你现场设计。我印象最深的一个版本是这样某电商平台在秒杀开始后登录接口和下单接口同时出现异常流量一批IP在短时间内发送大量密码尝试请求部分账号在异地设备上频繁登录。你要设计一个安全风控系统要求既能拦截实时攻击又不能影响正常用户体验。这个场景背后有三个目标实时性毫秒级判定不能拖垮正常请求、准确性误杀率要低不能把老用户当机器人、可用性大促期间系统本身不能被打挂。面试官真正想看的不是你会不会背某个框架而是遇到这种多目标约束时你如何拆解架构。1.2 从单体Filter到分布式风控的演进逻辑初阶答案通常是“加一个拦截器”。单体应用时代确实可以这么做写一个Filter把每个请求的IP记到本地Map里超过阈值就拒绝。但这套方案在面试里只能拿到及格分因为它有三个硬伤本地Map数据重启即丢失、多节点部署时各自计数互不相同、单机内存会被冷数据撑爆。于是你会自然想到引入Redis通过它的原子计数和TTL让多节点共享同一份计数状态。这个演进很关键它反映了架构师思考问题的方式先发现单机方案的瓶颈再引入分布式组件解决问题。但问题还没完实时拦截只是风控的一半另一半是“事后分析”比如登录成功后需要审计日志需要从百万条事件中找出“同一设备切换多个账号”的异常模式这些计算如果都塞进请求主链路系统的RT会高得没法看。当实时判定与复杂分析发生冲突时你就会想到用Kafka把请求主链路和异步分析链路解耦。再加上Spring Security负责认证授权与身份上下文三个组件刚好覆盖了接入治理、实时判定、异步编排三个层面。这道题答到这里才算从“会用框架”升级到“会搭架构”。2. 先在纸上推演整体架构三个组件各自的边界2.1 技术选型的底层逻辑很多人一上来就讨论Kafka和RocketMQ哪个好Redis和Caffeine哪个快这是本末倒置。先想清楚每个组件在这个系统里背负的责任再谈选型。组件核心职责在风控链路上的位置为什么用它Spring Security认证、授权、会话管理请求接入层标准化的安全框架支持自定义Filter和Provider身份信息可传递给风控判定模块Redis实时计数、分布式锁、短period特征存储实时判定层原子操作、毫秒级延迟、自带过期机制适合保存高频刷接口的计数状态Kafka异步事件解耦、削峰、审计落地异步分析层高吞吐、消息积压能力强天然适合把“事中拦截”和“事后分析”拆开有一点值得强调选Kafka不是因为“大厂都在用”而是风控事件流的特点是量大、速度快、允许短暂延迟。请求进来了实时判定必须H0级响应但审计日志多等两三秒完全没问题。Kafka的主题分区模型还天然支持按照用户ID或设备ID做局部有序这对分析“同一用户的操作序列”非常有利。2.2 架构分层与请求行走路径整个系统的请求路径是面试回答里必须能口述清楚的。我用文字描述一遍建议你也照着画一遍请求先进入接入层由网关或负载均衡做基础流量分发Spring Security过滤器链接管完成身份认证与基础权限校验把用户ID写入安全上下文认证通过后请求进入风控过滤器提取特征用户ID、IP、User-Agent、设备ID、请求路径、时间戳风控服务把这些特征送到Redis中执行滑动窗口计数和规则判定返回一个动作指令放行、验证码、拒绝、人工审核请求按动作指令继续走业务逻辑或被拦截与此同时请求信息异步发送到Kafka对应主题由消费端完成审计入库、离线规则计算、可疑关联分析。这个链路有一个很关键的纪律同步判定只做最核心、最快的规则凡是耗时超过几十毫秒的分析全部放到异步侧。哪怕Redis宕机了主流程也不能直接不可用而是降级到可接受的安全策略。2.3 故障边界与降级设计面试追问里十有八九会出现“如果一个组件挂了怎么办”。这条线必须在架构设计阶段就想清楚。Redis挂了不能阻塞业务请求。常规做法是快速失败并降级到本地限流器例如用本地RateLimiter做单机版本控制同时开启开关把风控策略临时切换为“只拦截极端异常”。误判率会升高但总比用户完全无法下单要好。Kafka挂了不能丢了关键审计数据。可以使用本地文件队列或内存阻塞队列暂存事件等Kafka恢复后补偿发送。更稳妥的方案是在客户端封装一个消息缓冲层Kafka不可达时降级写日志。Spring Security的过滤器链如果因为误配置而抛出异常网关层需要兜底返回401/403不能让错误堆栈直接暴露给前端。降级方案是区分“资深架构师”和“初级开发”的分水岭。只说正常链路不提故障态面试官会觉得你只是背了图。3. 实战走查Spring Security 在认证链路中如何融入风控3.1 不要在过滤器链末尾做风控认证与风控应交错Spring Security的过滤器链是有严格顺序的SecurityContextHolderFilter、AbstractAuthenticationProcessingFilter处理表单登录或Token登录、AuthorizationFilter做授权判定。风控过滤器不是只能加在最外面更好的做法是在认证前、认证中、认证后各放一个检查点。认证前能做IP黑名单、设备指纹黑名单、验证码强制校验避免无谓的密码尝试认证中能在AuthenticationProvider里校验“这个用户是否被临时锁定”“当前登录地址是否属于常用地”认证后则能触发“异常登录提醒”“二次验证强制开启”等后置动作。这段回答能体现你对Spring Security内部机制的理解而不只是用过几个注解。3.2 自定义AuthenticationProvider把风控判断织入校验逻辑先看一段精简的可运行骨架它表达的是“风控判断如何嵌入认证逻辑”Component public class RiskAwareAuthenticationProvider implements AuthenticationProvider { private final DaoAuthenticationProvider delegate; private final RiskService riskService; public RiskAwareAuthenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder, RiskService riskService) { this.delegate new DaoAuthenticationProvider(userDetailsService); this.delegate.setPasswordEncoder(passwordEncoder); this.riskService riskService; } Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { // 前置风控在密码校验前检查请求上下文比如当前IP短时间尝试次数 RiskContext context RiskContextHolder.get(); riskService.checkBeforeAuthentication(context); // 委托Spring Security默认逻辑做用户名密码校验 Authentication result delegate.authenticate(authentication); // 后置风控账号密码正确但设备/地域异常抛异常或要求二次认证 riskService.checkAfterAuthentication(result, context); return result; } Override public boolean supports(Class? authentication) { return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); } }这种做法最大的好处是不破坏Spring Security的既有语义登录成功与否依然由标准认证流程决定只是在关键节点插入了风控逻辑。同时要注意校验失败不要抛过于具体的异常避免攻击者探测出“用户名存在但密码错误”和“用户名不存在”的差异这会变成账号枚举漏洞。3.3 用SecurityContextHolder和事件驱动记录行为日志认证成功之后SecurityContextHolder里存储了用户身份但这只是内存级状态。风控审计需要把每次登录事件落下来所以我会注册一个ApplicationListener监听AuthenticationSuccessEvent和AuthenticationFailureEvent把用户ID、IP、设备信息封装成一个RiskEvent对象发送到Kafka。这里有个容易被忽略的细节事件监听器里不要做同步的数据库写入。登录本来就是高并发入口如果在主线程里写审计表数据库压力会非常大。改成发Kafka消息后消费端异步批量落库既保证事件不丢又不会增加请求RT。4. Redis 滑动窗口计数与多维度特征聚合4.1 固定窗口 vs 滑动窗口临界突破问题很多限流方案会用固定窗口每5分钟一个窗口窗口内计数超过阈值就拦截。但这种方案有个著名的临界问题假如窗口期是1分钟限制是60次攻击者可以卡在第59秒和第61秒的边界各发60次实际两秒内发120次突破了限流目的。滑动窗口没有固定边界问题它看的是“当前时间往前追溯一个完整窗口期”的请求数。比如现在时间是15:30:20那就统计15:29:20到15:30:20之间有多少次请求。实现上通常用Redis的ZSetmember存请求标识score存时间戳。4.2 核心ZSet命令组合与Lua脚本先说数据设计key risk:login:uid:{userId} member 请求唯一ID可以用UUID或 时间戳随机数 score 当前毫秒级时间戳统计时先清掉窗口外的旧数据再统计窗口内的数量。完整逻辑可以封装在一个Lua脚本里保证原子性local key KEYS[1] local now tonumber(ARGV[1]) local window tonumber(ARGV[2]) local threshold tonumber(ARGV[3]) redis.call(ZREMRANGEBYSCORE, key, 0, now - window) local current redis.call(ZCARD, key) if current threshold then return 0 end redis.call(ZADD, key, now, ARGV[4]) redis.call(PEXPIRE, key, window * 2) return 1脚本只做一件事先清理过期成员、判断当前窗口计数是否达到上限若没有达到则添加当前请求并返回允许。整个判定过程在Redis端一次完成没有竞态条件。这里我踩过个坑一开始把ZREMRANGEBYSCORE和ZCARD分成两次Java调用结果并发高时出现两个线程同时读到旧计数的情况白白放进来一批请求。改成Lua脚本之后才真正稳下来。4.3 key结构设计与热key问题风控规则不会只限“用户维度”还要组合IP、设备维度。key的通用设计是risk:{场景}:{特征类型}:{特征值}key 示例含义限制目标risk:login:uid:10086用户10086的登录频率防撞库、防暴力破解risk:login:ip:203.0.113.7IP的登录频率防IP段批量攻击risk:login:device:DVC-7F3A设备指纹的登录频率防模拟器批量登录risk:order:uid:10086用户下单频率防恶意下单、刷单组合命中通常先取并集或加权得分。比如某用户本身操作量不高但他所在的IP已经触发黑名单阈值就会提高整体风险分。热key问题主要体现在网红用户或公司出口IP上。大量请求打同一个keyRedis单实例CPU会升高。缓解手段包括在Redis前面加一层本地热点缓存对热门key做短TTL的本地计数合并或者对key做分片把一个用户的请求分散到多个slot上再聚合。后者实现成本高一般中小体量先用本地缓存解决就够。4.4 Redis不可用时的快速降级链路风控系统最怕Redis抖动导致所有请求判定失败。我的处理方式是判定的主流程采用“软超时”给Redis操作设置比如50毫秒的timeout一旦超时立即走本地降级策略。降级策略通常是一个可配置的枚举值DROP_OPEN全部放行牺牲安全保可用LOCAL_RATE_LIMIT用本地限流器做单机限流防极端流量FAIL_CLOSE拒绝部分高风险操作。我用过一个组合默认LOCAL_RATE_LIMIT同时启动一个异步线程每100毫秒探测Redis是否恢复恢复后自动切回主流程。这个设计在面试里也可以讲“我有明确的降级开关和自动恢复机制”这比只说“缓一下就好了”要扎实得多。5. Kafka 在风控链路中的编排与兜底5.1 为什么风控必须异步实时判定与复杂分析的冲突很多人会问既然Redis计数已经很快为什么还要Kafka参与原因是安全风控不仅仅需要“挡”还需要“查”和“算”。以一个场景为例攻击者手上有1000个账号在5台设备上轮流登录。单看每个账号的登录频率都正常单看每个IP的频率也正常。必须把“设备-账号-IP”的关系拉出来做关联分析才能发现这批账号共享同一设备指纹。这类图计算和离线任务跑一次可能耗时几十秒甚至更久塞到同步链路完全不可接受。所以Kafka在这条链路里承担三个任务异步审计存证每个风险事件都落盘、批量离线特征计算供人工风控后台使用、触发实时策略补偿比如高疑似事件稍后判定封禁。5.2 事件定义与幂等消费Kafka中传输的RiskEvent对象我会定义成如下模板{ eventId: uuid, eventType: LOGIN, userId: 10086, ip: 203.0.113.7, deviceId: DVC-7F3A, action: ALLOW, riskScore: 35, timestamp: 1737450000000 }eventId是整个事件模型里最重要的字段。因为有重试机制存在生产者发送成功但响应超时时会重发同一条消息消费者如果不做去重审计日志会翻倍。我在消费端第一行处理就是拿eventId去Redis查是否存在或者用数据库唯一索引兜底。Redis查重适合高频场景数据库唯一索引适合低频、强一致场景两者按需选择。分区策略上是按userId做key确保同一个用户的事件始终发送到同一个分区。这样后续要做“时间序列分析”时某个消费者可以拿到这个用户的完整事件流避免乱序。5.3 消息积压、消费失败与双写降级大促流量冲击下Kafka消费速度赶不上生产速度消息积压会成为一个显性问题。监控指标是Consumer的Lag我习惯给消费端配置两条告警积压超过1万条触发提示超过5万条触发扩容消费者实例。单个事件消费失败时先尝试重试三次指数退避仍然失败就发送到一个死信主题由专人处理。不要把失败消息卡在同一个分区里否则会造成分区头阻塞影响同一用户后续所有事件的处理。还有一个很容易被面试追问的问题Kafka集群不可用了风控系统是否完全瘫痪我的答案是不会因为我可以双写降级。生产者封装一层RiskEventPublisher内部先判断Kafka可用状态不可用时把事件写入本地文件或嵌入式队列同时启动一个补偿线程等Kafka恢复后按序补发。代价是牺牲近实时分析能力但主流程不受影响。6. 一条完整链路的最小可实现代码骨架6.1 从Filter到Redis判定再到Kafka审计的串联听完前面的架构拆解再看这段最小编排代码整个链路就比较立体了。第一步是在Spring Security的过滤器链中插入我们自己的风控过滤器Component public class RiskFilter extends OncePerRequestFilter { private final RiskService riskService; private final RiskEventPublisher eventPublisher; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { RiskContext context RiskContextBuilder.build(request); if (riskService.isAllowed(context)) { chain.doFilter(request, response); eventPublisher.publish(buildAllowEvent(context)); } else { response.setStatus(429); response.getWriter().write({\code\:429,\msg\:\risk blocked\}); eventPublisher.publish(buildBlockEvent(context)); } } }第二步RiskService内部调用Redis的Lua脚本做实时计数与判定Service public class RiskService { private final StringRedisTemplate redisTemplate; private final DefaultRedisScriptLong riskScript; public boolean isAllowed(RiskContext context) { String key risk: context.getScene() :uid: context.getUserId(); ListString keys List.of(key); Long result redisTemplate.execute(riskScript, keys, String.valueOf(System.currentTimeMillis()), String.valueOf(TimeUnit.MINUTES.toMillis(1)), String.valueOf(ruleThreshold(context)), UUID.randomUUID().toString()); return result ! null result 1L; } }第三步Kafka侧通过一个封装好的Publisher异步发送审计事件不在请求线程内等待Broker确认Component public class RiskEventPublisher { private final KafkaTemplateString, String kafkaTemplate; public void publish(RiskEvent event) { String payload JSON.toJSONString(event); // 生产端不阻塞主流程send是异步的自带buffer失败时走降级回调 kafkaTemplate.send(risk-event-topic, event.getUserId(), payload); } }实际工程里还会在Publisher里增加Kafka可用性开关做本地缓冲降级具体逻辑我在5.3已经讲过。面试时把这段代码逻辑讲清楚足够证明你理解整条链路。6.2 面试追问Redis和Kafka同时挂了怎么办这是一个压力测试式的问题。如果Redis和Kafka同时不可用意味着实时判定和异步分析都断了这时系统不能裸奔。我的预案是风控过滤器检查到Redis操作超时后立即切换到一个本地同步限流表按IP和用户做简单的本地窗口计数Kafka不可用时审计事件写入本地文件由后台任务定时尝试重传如果连本地缓存也不够抗压通过配置中心关闭非核心风控只保留“黑名单IP直拒”这种纯本地规则。降级不是简单地“放行”而是“在安全与可用之间找一个可接受的平衡点”。面试官最想听的就是这几个字的判断。6.3 面试复盘值得带走的三个拔高思路最后聊聊我自己梳理这套架构时得到一个结论不要堆技术名词要把系统讲成一条活着的数据流。第一要强调同步与异步分离。实时判定链路越短越好复杂分析放到Kafka异步链路这是整个架构的核心思想而不是因为“Kafka吞吐高所以用它”。第二要强调多级降级和自动恢复。Redis超时怎么办、Kafka积压怎么办、降级后怎么自动恢复这些问题比正常链路更能体现工程素养。第三要体现对安全领域业务本身的理解。风控不只靠“计数超限就拉黑”还包括账号风险分、设备指纹、关联分析、黑产团伙挖掘。面试时能结合一两个真实攻防场景如撞库、占击、批量注册讲出对应策略说服力完全不一样。我在模拟面试中反复被追问的始终不是“框架怎么配置”而是“某个环节出问题之后你会怎么处理”。这也是我最希望大家从这套解析里真正带走的东西架构不只是一张把组件连起来的图而是你对故障、延迟和数据一致性的一整套判断。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。