lua脚本实战—— Redis并发原子性陷阱
需求分析
对于内容类网站,比如用户浏览题目的答案,需要先登录才能追溯,那么可以统计用户访问频率来限制数据的爬取。
可采用分级反爬虫策略,先告警、再采取强制措施:
- 如果每分钟超过 10 道题,给管理员发送警告
- 如果每分钟超过 20 道题,直接踢下线,进行封号操作
解决方案
统计访问频率 - 基于 Redis 统计(分布式)
分布式存储 Redis 的 string 结构支持 incr 累加操作,可以对每个用户分钟(或其他时间精度)级别的访问次数进行累加统计。
1)设计 Redis 键值对
要能区分出用户和时间窗,示例 key 为:user:access:{userId}:{timestamp_in_minutes}
- {userId} 是用户 ID。
- {timestamp_in_minutes}是当前的分钟级时间戳,即将当前时间戳转化为分钟,这样每分钟的访问都会被统计到一个 key 中。
每个 key 的value,就是该用户在这分钟内的访问次数。
2)Redis 操作逻辑
对 Redis 的操作包括设置 key、给 key 增加计数、给 key 设置过期时间。
如下代码:
//假设使用 jedis 客户端
// 使用 Redis 的 INCR 操作增加当前秒的访问次数
jedis.incr(redisKey);// 设置过期时间(TTL),例如只保存60秒的数据
jedis.expire(redisKey, 60); // 60秒后自动过期
然而,这种方法存在潜在的设计陷阱:incr 和 expire 是两个独立的操作。如果你在高并发情况下调用 incr() 之后发生上下文切换(比如另一个线程执行操作),可能会导致两个问题:
- 过期时间重置:如果在高并发场景下,多次调用 incr() 后又多次调用 expire(),可能会不断重置该 key 的过期时间,导致这个key 永远不会过期。(当前场景不会,但是连续性访问的场景就有可能发生)
- 非原子操作:由于 incr() 和 expire()是独立操作,在并发情况下,两个线程都可能先判断 key 不存在,然后各自执行 set 操作,导致计数逻辑出错。
所以我们要确保计数和过期时间的操作是原子性的,可以使用 Redis 的 Lua 脚本来完成。如果 key 不存在,则初始化并设置过期时间,否则只进行计数。
高并发场景的例子
假设你限制 1 分钟访问次数最多为 100 次,有很多用户在第 59 秒和第 60 秒猛戳接口。
这时你的 key user:count 已经存在,比如它只剩 1 秒 TTL:
TTL(user:count) = 1
这时候来了个请求,它调用:
jedis.incr("user:count"); // 现在 value = 88
jedis.expire("user:count", 60); // TTL 又被设置回了 60 秒!!!
现在这个 key 原本应该在 1 秒后过期,但被重新设置为 60 秒了。
正确做法如下:
String luaScript = "if redis.call('exists', KEYS[1]) == 1 then " +" return redis.call('incr', KEYS[1]); " +"else " +" redis.call('set', KEYS[1], 1); " +" redis.call('expire', KEYS[1], 180); " + // 设置 180 秒过期时间" return 1; " +"end";
这里一定要给 Redis key 设置过期时间!因为统计超过一分钟,之前的数据就没什么用了。
为什么不用Sentinel或HotKey?
对于该项目,主要的目的是反爬虫,而不是应对高并发大流量的请求,所以不需要结合 Sentinel 或 Hotkey 去精确统计流量。自主实现固定时间窗口(1 分钟)的访问频率统计就足够了。为了便于项目扩展为分布式,使用 Redis 方案来实现。