这是面试遇到的一道场景题。
面试官问我:
有这么一个场景,现在有一个网站,这个网站访问的人非常多,在登录的时候有些坏人来这里捣乱,怎么去设计一个系统实现把这些坏人找到的逻辑。
条件说一下:
登录的时候你要的用户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(用户ID,保证唯一性,去重) |
| 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的前缀和常量
1
2
3
4
5
6
7
8
9
10
11
12
|
public class RedisKeyConstants {
// 设备登录记录Key前缀
public static final String LOGIN_DEVICE_KEY_PREFIX = "login:device:";
// 恶意设备黑名单Key前缀
public static final String MALICIOUS_DEVICE_KEY_PREFIX = "malicious:device:";
// 滑动窗口大小:10分钟(600秒)
public static final int WINDOW_SIZE_SECONDS = 600;
// 坏人阈值:≥5个不同userId
public static final int MALICIOUS_THRESHOLD = 5;
// 过期时间:10分钟
public static final int EXPIRE_TIME_SECONDS = 600;
}
|
(2)服务实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
|
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class LoginRiskControlService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 检查是否为恶意设备
* @param userId 用户ID
* @param deviceId 设备ID
* @return true-是恶意设备,false-正常设备
*/
public boolean checkMaliciousDevice(String userId, String deviceId) {
// 1. 构造Redis Key
String loginKey = RedisKeyConstants.LOGIN_DEVICE_KEY_PREFIX + deviceId;
String maliciousKey = RedisKeyConstants.MALICIOUS_DEVICE_KEY_PREFIX + deviceId;
// 2. 先查黑名单:如果已经是恶意设备,直接返回true
if (Boolean.TRUE.equals(redisTemplate.hasKey(maliciousKey))) {
return true;
}
// 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. 判断是否为坏人:数量≥5
if (count != null && count >= RedisKeyConstants.MALICIOUS_THRESHOLD) {
// 标记为恶意设备,存入黑名单,过期时间可以设长一点(比如24小时)
redisTemplate.opsForValue().set(maliciousKey, "1", 24, TimeUnit.HOURS);
return true;
}
// 9. 正常设备,返回false
return false;
}
}
|
(3)在登录接口中调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
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")
public class LoginController {
@Autowired
private LoginRiskControlService riskControlService;
@PostMapping
public 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 压力,又能保证安全可控。