【面试真题拆解10】高并发场景下“恶意登录设备识别”系统设计

四季读书网 2 0
【面试真题拆解10】高并发场景下“恶意登录设备识别”系统设计

这是面试遇到的一道场景题。

面试官问我:

有这么一个场景,现在有一个网站,这个网站访问的人非常多,在登录的时候有些坏人来这里捣乱,怎么去设计一个系统实现把这些坏人找到的逻辑。

条件说一下:

登录的时候你要的用户userid,时间 ,设备deviceid这些信息都有;

坏人的定义是:最近十分钟之内,一个设备上登录了大于等于5个user,我就认为这个是坏人。

场景梳理

  • 输入信息:用户登录时的 userId(用户ID)、timestamp(登录时间戳)、deviceId(设备ID);
  • 坏人定义:最近10分钟(600秒)内,同一个deviceId上登录了≥5个不同的userId;
  • 输出结果:标记该deviceId为“恶意设备”,后续可以做拦截、验证码、风控等处理。

实现方案

考虑用Redis ZSet实现滑动窗口。

为什么选Redis?

主要是因为:

  1. 单线程高性能,适合高并发场景

  2. 内存数据库,读写速度极快

  3. 支持丰富的数据结构(比如这次场景需要的ZSet)

  4. 支持过期时间,自动清理数据

为什么选ZSet?

ZSet(有序集合)是 Redis 中最适合做 “时间窗口、排行榜、去重 + 排序” 的数据结构。

它有以下几个特点:Member(成员)唯一,Member 不能重复,天然去重;每个 Member 对应一个 Score(分数),Score 是一个浮点数,用来排序;ZSet 会自动按 Score 从小到大进行排序,如果Score 相同则按 Member 字典序排序。

ZSet 的底层是哈希表 + 跳表,哈希表保证 Member 唯一、O (1) 查找,跳表保证有序、O (log n) 范围查询。

数据模型

维度
说明
Keylogin:device:{deviceId}
(比如 login:device:abc123
Value(ZSet Member)userId
(用户ID,保证唯一性,去重)
Score(ZSet Score)登录时间戳
(用于滑动窗口)

理一下整个流程

  1. 用户发起登录请求:携带 userIddeviceIdtimestamp
  2. 先查黑名单:如果已经是恶意设备,直接拦截;
  3. 写入登录记录到Redis ZSet:以 deviceId 为Key,userId 为Member,timestamp 为Score,写入ZSet;
  4. 清理过期数据:删除ZSet中Score < 当前时间 - 10分钟的元素;
  5. 统计最近10分钟内的不同userId数量:用 ZCard 命令统计ZSet的元素个数;
  6. 判断是否为坏人:如果数量≥5,标记该deviceId为恶意设备,存入Redis黑名单;
  7. 返回登录结果:如果是恶意设备,返回“请完成验证码”或“登录失败”;否则正常登录。

简化版的代码实现

用Spring Boot + RedisTemplate写一个简化版的实现,加深理解一下:

(1)先定义Redis Key的前缀和常量

publicclassRedisKeyConstants{// 设备登录记录Key前缀publicstaticfinal String LOGIN_DEVICE_KEY_PREFIX = "login:device:";// 恶意设备黑名单Key前缀publicstaticfinal String MALICIOUS_DEVICE_KEY_PREFIX = "malicious:device:";// 滑动窗口大小:10分钟(600秒)publicstaticfinalint WINDOW_SIZE_SECONDS = 600;// 坏人阈值:≥5个不同userIdpublicstaticfinalint MALICIOUS_THRESHOLD = 5;// 过期时间:10分钟publicstaticfinalint EXPIRE_TIME_SECONDS = 600;}

(2)服务实现

import org.springframework.beans.factory.annotation.Autowired;import org.springframework.data.redis.core.RedisTemplate;import org.springframework.stereotype.Service;import java.util.concurrent.TimeUnit;@ServicepublicclassLoginRiskControlService{@Autowiredprivate RedisTemplate<String, String> redisTemplate;/**     * 检查是否为恶意设备     * @param userId 用户ID     * @param deviceId 设备ID     * @return true-是恶意设备,false-正常设备     */publicbooleancheckMaliciousDevice(String userId, String deviceId){// 1. 构造Redis Key        String loginKey = RedisKeyConstants.LOGIN_DEVICE_KEY_PREFIX + deviceId;        String maliciousKey = RedisKeyConstants.MALICIOUS_DEVICE_KEY_PREFIX + deviceId;// 2. 先查黑名单:如果已经是恶意设备,直接返回trueif (Boolean.TRUE.equals(redisTemplate.hasKey(maliciousKey))) {returntrue;        }// 3. 获取当前时间戳(秒)long currentTime = System.currentTimeMillis() / 1000;// 计算窗口起始时间:当前时间 - 10分钟long windowStartTime = currentTime - RedisKeyConstants.WINDOW_SIZE_SECONDS;// 4. 写入当前登录记录到ZSet:Member=userId,Score=当前时间戳        redisTemplate.opsForZSet().add(loginKey, userId, currentTime);// 5. 清理过期数据:删除Score < 窗口起始时间的元素        redisTemplate.opsForZSet().removeRangeByScore(loginKey, 0, windowStartTime);// 6. 统计最近10分钟内的不同userId数量(ZSet的元素个数)        Long count = redisTemplate.opsForZSet().zCard(loginKey);// 7. 设置Key的过期时间:10分钟(防止内存泄漏)        redisTemplate.expire(loginKey, RedisKeyConstants.EXPIRE_TIME_SECONDS, TimeUnit.SECONDS);// 8. 判断是否为坏人:数量≥5if (count != null && count >= RedisKeyConstants.MALICIOUS_THRESHOLD) {// 标记为恶意设备,存入黑名单,过期时间可以设长一点(比如24小时)            redisTemplate.opsForValue().set(maliciousKey, "1"24, TimeUnit.HOURS);returntrue;        }// 9. 正常设备,返回falsereturnfalse;    }}

(3)在登录接口中调用

import org.springframework.beans.factory.annotation.Autowired;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;@RestController@RequestMapping("/login")publicclassLoginController{@Autowiredprivate LoginRiskControlService riskControlService;@PostMappingpublic String login(String userId, String deviceId){// 1. 先检查是否为恶意设备boolean isMalicious = riskControlService.checkMaliciousDevice(userId, deviceId);if (isMalicious) {return"登录失败:检测到异常设备,请完成验证码验证";        }// 2. 正常登录逻辑(校验账号密码、生成Token等)// ...return"登录成功";    }}

高并发场景的优化

在访问量非常大的场景下,上面的代码肯定扛不住,所以还需要做一些优化。

1. 布隆过滤器快速过滤正常设备

如果每个登录请求都查Redis,Redis的压力会很大。

可以考虑用布隆过滤器先快速过滤:

在布隆过滤器里只存“潜在恶意设备”(比如最近10分钟内登录过≥3个userId的设备),登录时先查布隆过滤器,如果不在里面,直接放行,不查Redis;如果在里面,再查Redis确认。

2. 异步处理

如果Redis查询慢,会阻塞主登录流程,影响用户体验。

考虑把“写入ZSet、清理过期数据、统计数量、判断坏人”这些步骤用线程池或消息队列异步执行,这样的话主登录流程只做“账号密码校验、生成Token”,用户体验会更好一点。

小贴士

异步会有极短的延迟,但是风控允许这种小误差。

3. 本地缓存+Redis双层缓存

如果恶意设备黑名单查询很频繁,每次都查Redis还是会有压力。

考虑用本地缓存(比如Caffeine)+ Redis 的双层缓存。

这样一来本地缓存存“最近1分钟内的恶意设备黑名单”,登录时先查本地缓存,命中直接返回,没命中再查Redis,查到后写入本地缓存。

小贴士:

本地缓存不能设 10 分钟,因为多实例部署时,本地缓存互不同步,会导致风控失效。

设 1 分钟既能减轻 Redis 压力,又能保证安全可控。

抱歉,评论功能暂时关闭!