这是面试遇到的一道场景题。
面试官问我:
有这么一个场景,现在有一个网站,这个网站访问的人非常多,在登录的时候有些坏人来这里捣乱,怎么去设计一个系统实现把这些坏人找到的逻辑。
条件说一下:
登录的时候你要的用户userid,时间 ,设备deviceid这些信息都有;
坏人的定义是:最近十分钟之内,一个设备上登录了大于等于5个user,我就认为这个是坏人。
场景梳理
输入信息:用户登录时的 userId(用户ID)、timestamp(登录时间戳)、deviceId(设备ID);坏人定义:最近10分钟(600秒)内,同一个deviceId上登录了≥5个不同的userId; 输出结果:标记该deviceId为“恶意设备”,后续可以做拦截、验证码、风控等处理。
实现方案
考虑用Redis ZSet实现滑动窗口。
为什么选Redis?
主要是因为:
单线程高性能,适合高并发场景
内存数据库,读写速度极快
支持丰富的数据结构(比如这次场景需要的ZSet)
支持过期时间,自动清理数据
为什么选ZSet?
ZSet(有序集合)是 Redis 中最适合做 “时间窗口、排行榜、去重 + 排序” 的数据结构。
它有以下几个特点:Member(成员)唯一,Member 不能重复,天然去重;每个 Member 对应一个 Score(分数),Score 是一个浮点数,用来排序;ZSet 会自动按 Score 从小到大进行排序,如果Score 相同则按 Member 字典序排序。
ZSet 的底层是哈希表 + 跳表,哈希表保证 Member 唯一、O (1) 查找,跳表保证有序、O (log n) 范围查询。
数据模型
| Key | login:device:{deviceId}login:device:abc123) |
| Value(ZSet Member) | userId |
| Score(ZSet Score) | 登录时间戳 |
理一下整个流程
用户发起登录请求:携带 userId、deviceId、timestamp;先查黑名单:如果已经是恶意设备,直接拦截; 写入登录记录到Redis ZSet:以 deviceId为Key,userId为Member,timestamp为Score,写入ZSet;清理过期数据:删除ZSet中Score < 当前时间 - 10分钟的元素; 统计最近10分钟内的不同userId数量:用 ZCard命令统计ZSet的元素个数;判断是否为坏人:如果数量≥5,标记该deviceId为恶意设备,存入Redis黑名单; 返回登录结果:如果是恶意设备,返回“请完成验证码”或“登录失败”;否则正常登录。
简化版的代码实现
用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 压力,又能保证安全可控。