为什么用Redis承担黑名单存储
IP黑名单的读写特征非常明显:写操作发生在检测到恶意行为之后,频率相对较低;读操作发生在每个请求入口,频率极高。如果每次请求都去关系型数据库里查询该IP是否被封禁,数据库连接和磁盘IO会成为瓶颈,尤其是在高并发场景下,这种设计会显著放大延迟。
Redis作为内存数据库,单次读写操作可以在亚毫秒级别完成,并且提供集合、字符串等多种数据结构,天然适合黑名单这种读多写少的场景。集中式的Redis实例还能保证多个应用节点共享同一份黑名单,避免本地缓存的节点间不一致问题。使用Redis后,业务服务只需要在进入核心逻辑前调用一次EXISTS或SISMEMBER,即可完成封禁判断。

当然,Redis也不是唯一选择。本地内存判断速度更快,但无法在多实例间同步;数据库方案一致性强,但延迟高。对于大多数Web应用和API网关来说,用Redis做集中式黑名单是在性能、一致性和维护成本之间的一个平衡点。后续还可以增加本地布隆过滤器作为第一层拦截,进一步降低对Redis的访问压力。
使用Set集合实现基础IP封禁
最直观的方案是使用Redis的Set结构保存被封禁的IP地址。Set中的成员具有唯一性,不会出现重复封禁,而且SISMEMBER命令可以在O(1)时间内判断成员是否存在。封禁一个IP时执行SADD blacklist:ip 192.168.1.100,解封时执行SREM blacklist:ip 192.168.1.100,查询时执行SISMEMBER blacklist:ip 192.168.1.100。这些命令简单直接,易于理解和维护。
下面给出一个Python实现示例,展示在请求入口处进行黑名单检查的逻辑。
import redis
r = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True)
def is_blocked(ip: str) -> bool:
# 检查IP是否在黑名单集合中
return r.sismember('blacklist:ip', ip)
def block_ip(ip: str):
# 将IP加入黑名单集合
r.sadd('blacklist:ip', ip)
def unblock_ip(ip: str):
# 将IP从黑名单集合中移除
r.srem('blacklist:ip', ip)
实际使用中,建议为黑名单键设置统一的命名空间,例如security:blacklist:ip,便于管理和监控。如果业务中需要区分封禁来源,比如手动封禁和自动封禁,可以在键名中加入来源标识,或使用多个Set分别存储。另外,Redis的Set操作本身是原子的,多个应用节点同时写入或删除时不会出现数据竞争,这也是选择Redis的一个优势。
不过,Set方案有一个明显缺陷:集合中的成员无法单独设置过期时间。一旦某个IP被加入Set,除非手动执行SREM或删除整个键,否则它会一直存在。对于使用动态IP的客户端,永久封禁可能误伤后续分配到该IP的正常用户。因此,更合理的做法是支持自动解封,让封禁具有时效性。
利用键过期实现自动解封
Redis的键级过期机制可以很自然地解决自动解封问题。具体做法是放弃Set集合,改为给每个被封禁的IP创建一个独立的字符串键,例如blacklist:ip:192.168.1.100,值可以设置为1。封禁时使用SETEX命令指定过期秒数,例如SETEX blacklist:ip:192.168.1.100 7200 1,表示封禁两小时。查询时使用EXISTS blacklist:ip:192.168.1.100判断键是否存在,若存在则说明IP仍处于封禁期,键过期后自动删除,无需人工干预。
这种方案的优点非常突出:每个IP的封禁时长可以独立设置,自动解封由Redis自身完成,不存在定时任务扫描或手动清理的问题。虽然从Set的一个键变成了多个字符串键,但Redis对字符串键的读写同样是O(1)复杂度,在常见黑名单规模下性能不会有明显下降。键名中包含IP地址还可以方便地通过SCAN命令按前缀查看当前被封禁的IP列表。
以下Python代码展示了带自动过期的封禁与检查逻辑。
import redis
r = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True)
def block_ip_for_seconds(ip: str, seconds: int):
# 每个IP独立成键,使用SETEX直接设置过期时间
r.setex(f'blacklist:ip:{ip}', seconds, '1')
def is_blocked(ip: str) -> bool:
# EXISTS判断键是否存在,键过期自动消失
return r.exists(f'blacklist:ip:{ip}') == 1
需要注意的是,当封禁IP数量达到数十万甚至更多时,这种多键方案会占用更多内存,因为每个键都有独立的Redis对象头和过期字典开销。但从实现简单性和自动解封需求来看,它非常适合中小规模场景。如果业务中已经使用Set并希望临时封禁,可以另外维护一个带过期时间的字符串键作为补充,逻辑判断时同时检查两个结构。
大规模黑名单的优化思路与布隆过滤器
当黑名单规模增长到百万级甚至更高时,即使使用Set或字符串键,内存占用也会变得可观。以IPv4地址为例,一个IP字符串大约占用十几字节,加上Redis内部对象和哈希表开销,百万个IP可能消耗几十MB到上百MB内存。对于内存资源紧张的实例,可以考虑使用布隆过滤器。
布隆过滤器是一种概率性数据结构,它用多个哈希函数将元素映射到一个位数组中。判断元素是否存在时,如果返回不存在,则元素一定不存在;如果返回存在,则元素可能存在,存在一定假阳性概率。Redis可以通过RedisBloom模块或Redis 7.0以上版本的BF.ADD和BF.EXISTS命令使用布隆过滤器。对于IP黑名单来说,假阳性意味着极小概率误封正常用户,可以通过调整位数组大小和哈希函数数量将误判率控制在可接受范围。
# 使用RedisBloom模块添加和检查IP BF.ADD ip_blacklist 203.0.113.55 BF.EXISTS ip_blacklist 203.0.113.55 # 返回1表示可能存在,返回0表示一定不存在
另一个优化方向是本地缓存与Redis结合。应用实例可以定期将黑名单拉取到本地内存,请求进来时先在本地哈希表或布隆过滤器中进行第一层判断,不确定时再回源Redis确认。这样可以明显降低Redis的查询压力,但需要处理缓存同步和过期清理问题。对于大多数中大型系统,推荐采用本地布隆过滤器加Redis精确黑名单的两级架构,既能控制内存,又能保证判断速度和准确性。
无论采用哪种方案,IP黑名单都只是安全防护的一环。实际项目中还应该配合频率限制、验证码、设备指纹等机制综合判断,避免单一IP封禁被绕过或误伤。Redis提供的高性能数据结构和过期能力,让这些防护逻辑可以更灵活地落地。