Redis限流踩坑记:我的incr+expire组合拳为何打出了永不过期的Key?
Redis限流踩坑记increxpire组合为何会制造永生Key那天下午运维群突然炸开了锅——OCR服务的错误率曲线像坐了火箭一样直线上升。用户反馈页面不断弹出操作过于频繁的提示可后台数据显示这些用户当天的调用次数还不到限额的十分之一。作为负责接口限流逻辑的开发者我盯着监控大屏上那些TTL显示为-1的Redis Key突然意识到自己掉进了一个经典的Redis陷阱。1. 故障现场限流系统为何突然失效事情要从三个月前上线的新版OCR服务说起。为了防范恶意刷接口我们设计了每分钟50次的调用限制。当时调研了几种方案后选择了最轻量级的Redis计数方案// 简化后的限流检查伪代码 func CheckRateLimit(userID string) bool { key : rate_limit: userID count, err : redis.Get(key) if err redis.Nil { // Key不存在时初始化 redis.Set(key, 1, time.Minute) return true } if count 50 { return false } redis.Incr(key) return true }这套逻辑在测试环境运行良好直到那天下午出现诡异现象部分用户的Key变成了永生状态。通过Redis命令查看问题Key时发现了两个致命特征127.0.0.1:6379 TTL rate_limit:user123 (integer) -1 # 永不过期 127.0.0.1:6379 GET rate_limit:user123 2147483647 # 已经累加到最大值更棘手的是这些Key由于没有设置过期时间会永久占用Redis内存。随着时间推移内存使用率持续攀升最终触发了OOM killer。2. 原理深挖incr与expire的原子性陷阱通过复现测试我们终于锁定了问题发生的精确条件Key的TTL剩余最后1秒时用户发起请求服务端通过GET判断未超限此时Key仍存在执行OCR识别耗时超过1秒网络I/O或计算密集型操作执行INCR时Key已自然过期此时Redis的行为出人意料操作时序Redis行为产生后果TTL1s时GET返回当前计数值判断未超限Key自然过期Redis自动删除Key原始计数消失执行INCR创建新Key并设为1TTL默认为-1关键发现INCR命令在操作不存在的Key时会将其初始化为1但不会继承之前的过期时间。这与SET命令的NX/XX参数行为完全不同。3. 临时补救双重检查真的可靠吗我们首先尝试了存在性检查TTL验证的复合方案func SafeIncr(key string) error { // 第一重检查Key是否存在 exists, err : redis.Exists(key) if exists 0 { return redis.Set(key, 1, time.Minute) } // 第二重检查TTL是否有效 ttl, err : redis.TTL(key) if ttl 0 { // -1表示无过期时间-2表示不存在 return redis.Expire(key, time.Minute) } _, err redis.Incr(key) return err }这套方案在大多数情况下工作正常但依然存在理论上的竞态条件检查TTL时返回剩余10ms网络延迟导致INCR命令在Key过期后到达依然会产生无过期时间的Key4. 终极方案Lua脚本实现原子操作Redis的Lua脚本可以保证多个命令的原子执行最终我们采用了如下方案-- KEYS[1] 限流Key -- ARGV[1] 限流阈值 -- ARGV[2] 过期时间(秒) local current redis.call(INCR, KEYS[1]) if current 1 then -- 首次设置时初始化过期时间 redis.call(EXPIRE, KEYS[1], ARGV[2]) else -- 检查是否永不过期 local ttl redis.call(TTL, KEYS[1]) if ttl -1 then redis.call(EXPIRE, KEYS[1], ARGV[2]) end end return current对应的Go实现var rateLimitScript redis.NewScript( local current redis.call(INCR, KEYS[1]) if current 1 then redis.call(EXPIRE, KEYS[1], ARGV[2]) else local ttl redis.call(TTL, KEYS[1]) if ttl -1 then redis.call(EXPIRE, KEYS[1], ARGV[2]) end end return current ) func AtomicIncr(key string, limit int, expiration time.Duration) (int, error) { return rateLimitScript.Run( redisClient, []string{key}, limit, int(expiration.Seconds()) ).Int() }这个方案完美解决了三个问题初始化计数与设置过期时间的原子性防止INCR导致Key永不过期避免多次网络往返带来的竞态条件5. 生产环境优化实践在实际部署中我们还做了以下优化内存保护措施对Lua脚本增加执行超时监控设置Redis的maxmemory-policy为volatile-lru增加监控告警规则检测TTL为-1的限流Key性能对比测试方案QPS平均延迟内存占用原始方案12k1.2ms持续增长双重检查9k1.8ms稳定Lua脚本11k1.3ms稳定异常处理建议// 良好的错误处理范例 func HandleOCRRequest(userID string) error { count, err : AtomicIncr(userKey(userID), 50, time.Minute) if err ! nil { metrics.Increment(redis_errors) return fallbackToLegacyRateLimit() // 降级方案 } if count 50 { return ErrRateLimitExceeded } // 处理业务逻辑 }那次事故后我们在所有使用Redis计数的场景都加上了防永生检查。现在每次看到TTL值为-1的Key都会想起那个手忙脚乱的下午——技术债总是要还的区别只是主动还是被动。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2583160.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!