ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

网关限流熔断存储选型:Redis管实时状态,MongoDB管审计日志

网关限流熔断存储选型:Redis管实时状态,MongoDB管审计日志 1. 先搞清楚限流熔断到底需要什么样的存储很多团队在网关限流熔断选型时一看到“MongoDB vs Redis”就下意识觉得这问题不成立——一个是内存缓存一个是文档数据库赛道都不一样。但在微服务网关限流熔断这个具体场景里这个问题真实存在而且每次评审都能引发激烈争论。原因不复杂网关身上挂了太多职责限流熔断只是其中一环而真正要落地的数据组件往往同时要承担实时计数、熔断状态、规则存储、审计日志、监控指标好几类工作。我记得去年评审一个内部网关方案时后端负责人明确说“我们MongoDB集群已经很成熟了为什么还要再上一套RedisMongoDB用$inc做计数也不慢。”这个说法有一定道理但最终我们在生产环境里还是保持了Redis和MongoDB并存只是各自服务的对象完全不一样。要理解为什么得先把限流熔断对存储的真实诉求拆开看。1.1 热路径数据和冷路径数据必须分开限流和熔断在技术实现上有一个共同点它们都发生在请求处理的路径上而且每个请求都要做一次状态检查或更新。限流要查当前窗口的计数、决定是否放行熔断要查状态机的当前开关状态、判断要不要直接短路。这类数据我习惯叫热路径数据要求非常高单次读写延迟要稳定最好在1ms以内计数增减和窗口过期必须原子否则多个网关实例并发写会出错状态切换不能被并发请求带偏比如两个请求同时把熔断器从关闭改成打开逻辑上也不能分裂数据通常带生命周期限流窗口结束、熔断恢复之后就没必要继续保留。另一类是熔断降级结束后的审计信息、被限流请求的完整上下文、异常堆栈、调用链id、规则版本号等等。这类数据生命周期长需要支持多维查询和聚合统计写入偶尔慢几十毫秒也没关系。它们不需要在请求路径上等待结果应该异步落库。这就是MongoDB的舒适区文档结构灵活聚合管道强大TTL索引可以按天清理历史日志。很多人出问题不是因为某一个数据库不够好而是把热路径和冷路径混在一个存储里处理。用Redis存审计日志会发现内存根本扛不住把所有限流状态硬塞给MongoDB又发现写路径延迟抖动把网关拖垮。先分清热与冷后面的选型才谈得上对错。1.2 网关限流熔断场景对数据组件的核心诉求我梳理过一份“需求清单”每次做网关选型都会拿出来对照。诉求说明更适合谁低延迟限流计数、熔断状态必须在请求路径上快速读写Redis原子性自增、过期、状态切换这类复合操作不能有中间态Redis Lua脚本自动过期窗口结束自动清理计数熔断恢复后状态可失效Redis TTL持久性审计数据、账单、问题排查不能丢MongoDB灵活查询按路由、服务、状态码、时间维度统计降级次数MongoDB扩展性节点扩容、分片对应用透明或半透明Redis Cluster / MongoDB分片这也能解释为什么很多网关方案最后长得很像Redis负责状态MongoDB负责历史。真正要讨论的不是“谁替代谁”而是边界在哪里。2. Redis在限流熔断里到底强在哪先别急着看数据先说原理。Redis能成为限流熔断场景的默认选项不是靠“内存快”这一个理由。2.1 单线程事件循环带来的稳定读写在实践中很关键Redis的读写性能来自内存访问和单线程事件循环。单线程意味着没有锁竞争所有命令天然串行执行这就让“先判断再自增”这种复合逻辑有了非常干净的原子性基础。限流场景里最怕的不是慢而是两个请求同时读到同一个count、同时加1最后数值不一致。Redis把这类问题都省掉了同一个key的INCR命令在任何时刻都只有一个执行结果。当然MongoDB的WiredTiger引擎也有文档级并发控制单个文档的更新也是原子的但它的写路径远比Redis重。每次写入要进存储引擎、处理journal、考虑持久化单次操作落在1ms以内的概率不高。在网关的过滤器链里一个请求限量操作多一次网络调用还能接受但如果每个请求都要等一个3-5ms的数据库往返整个网关的吞吐就会很难看。2.2 固定窗口、滑动窗口、令牌桶用Lua落地很顺手限流算法本身并不复杂复杂的是“判断更新过期”要作为一个原子操作执行。Redis最常用的做法是把整个逻辑写进Lua脚本一次eval完成所有操作。先看最简单的固定窗口脚本local key KEYS[1] local limit tonumber(ARGV[1]) local windowSec tonumber(ARGV[2]) local current redis.call(INCR, key) if current 1 then redis.call(EXPIRE, key, windowSec) end if current limit then return 0 end return 1这个脚本虽然简单但有三个细节值得注意。第一个INCR和EXPIRE必须放在同一个Lua脚本里否则第一次请求INCR成功后进程退出key没有过期时间就会变成一个永不过期的计数把后面的请求全挡住。第二个窗口秒数不能设成0否则EXPIRE返回失败。第三个这种固定窗口存在临界问题——窗口末尾和下一个窗口开头交界处流量可以翻倍。这也是为什么要看滑动窗口。滑动窗口在Redis里的经典实现是用ZSET存储每个请求的时间戳local key KEYS[1] local now tonumber(ARGV[1]) local windowMs tonumber(ARGV[2]) local limit tonumber(ARGV[3]) redis.call(ZREMRANGEBYSCORE, key, 0, now - windowMs) local count redis.call(ZCARD, key) if count limit then return 0 end redis.call(ZADD, key, now, now .. - .. math.random(999999)) redis.call(PEXPIRE, key, windowMs) return 1ZSET滑动窗口比固定窗口精确但代价是每个请求都要写一条记录内存开销更大。高并发场景通常用简化方案把窗口切成更小的格子用Hash存每个格子的计数判断时只汇总窗口内的格子。无论哪种实现Lua脚本都能保证整个过程原子。2.3 TTL做窗口过期和数据清理是Redis天然的杀器限流窗口本质上就是“一个有过期时间的计数器”Redis的TTL机制正好长在这个需求上。INCR后设置EXPIRE窗口结束Redis自动把key删掉下一次请求重新计数。熔断器的失败窗口也是同样的模型统计过去N秒内的失败率用ZSET或Hash存储时间窗口不需要额外的清理任务。这里有个容易被忽略的点Redis清理过期key使用惰性删除加定期删除结合并不意味着窗口一结束计数立刻消失。如果你的限流脚本依赖“窗口结束后key必须马上不存在”来做下一步判断可能会踩坑。正确做法是让判断逻辑自己携带时间窗口参数而不是依赖key是否被删除。2.4 Redis不是银弹持久化和主从切换是硬伤Redis在限流熔断场景最大的短板是持久化。默认的RDB快照模式重启后只能恢复上一次快照的数据限流状态和熔断状态会丢打开AOF everysec模式极端情况下也会丢最后1秒内的写入。更麻烦的是主从切换如果主节点宕机从节点提升为主节点后没有最近几秒的计数限流窗口就相当于重置了。上游流量如果正好在这个时间窗口内暴涨限流很可能被打穿这就是常说的“故障时保护失效”。要规避这个问题通常不是让Redis持久化做得更完善而是在网关侧加一层兜底比如Redis不可用时降级成单机本地限流宁可限流精度下降也不能完全不限流。这部分后面会展开讲。3. MongoDB能不能做限流能做但代价藏在写路径里回到评审会议上那个问题“MongoDB能不能做限流”能但需要分清“能跑”和“适合生产”之间的差距。3.1 文档模型和原子更新确实能实现计数器MongoDB的单文档更新是原子的用$inc实现计数器非常自然。固定窗口限流可以这样做以“限流维度窗口起始时间”作为文档唯一键每次请求进来就做一次upsert更新。db.rate_limit.updateOne( { biz_key: route:/api/order:user:1001, window_start: { $gte: currentWindowStart } }, { $inc: { count: 1 }, $setOnInsert: { window_start: currentWindowStart, created_at: new Date() } }, { upsert: true } );然后判断当前文档的count是否超过阈值。逻辑上完全说得通单条更新也是原子的。但这里立刻会遇到一个工程问题如果窗口已经滚动到新周期当前查询条件找不到旧文档upsert会插入一条新文档那旧窗口的计数怎么办要么查询之后再删除要么依赖TTL索引延迟删除这会让代码复杂度明显上升。3.2 高并发下MongoDB的写路径撑不住同一个key的竞争限流场景的数据访问模式很特殊高并发的请求都集中在少量key上比如某个热门路由的全局限流所有请求都去更新同一个文档。WiredTiger虽然支持文档级并发但同一份文档的更新仍然需要串行化实际效果就是大量写请求在存储引擎排队。我在自建环境下做过简单压测同样的限流逻辑Redis的INCR操作稳定在亚毫秒级别MongoDB的同一个文档计数器在1000并发下p99能到几十毫秒这跟网络、磁盘、连接池都有关系但趋势是明确的MongoDB的写路径天然比Redis重不适合承载单key高频更新的热路径。如果你觉得某次压测结果看起来“还行”建议看长尾延迟而不只是平均值。网关限流不能接受每隔几十秒就出现一个600ms毛刺。3.3 TTL索引和限流窗口的过期逻辑不匹配MongoDB有TTL索引可以让数据在指定时间后自动删除听起来跟Redis的EXPIRE很像。但两者的运行机制完全不同。MongoDB的TTL删除由一个后台线程每分钟运行一次左右它扫描索引删除已过期的文档。这意味着数据过期时间和实际删除时间之间可能存在几十秒的误差。限流场景对窗口边界要求很高如果旧窗口的计数迟迟不删新窗口的请求会继续被旧计数影响出现“窗口已经过了但请求还是被限”的误伤。所以MongoDB的TTL索引适合清理日志类数据不适合作为限流窗口的过期机制。3.4 MongoDB在低QPS场景的可行性如果网关整体QPS很低比如内部管理系统、定时任务回调、管理端接口每秒钟只有几十个请求用MongoDB做限流计数并非不能运行。这个时候的单次写延迟可能只有几毫秒连接池足够宽MongoDB的持久化能力反而是优势。但你要把以下两点提前想到限流key一定不能太集中要打散到不同文档窗口过期逻辑要自己用“窗口起始时间”来控制不能完全依赖TTL。不过这只是过渡方案。一旦业务量增长或者网关前面接的流量变大迟早要回到Redis那条路。与其之后做迁移不如一开始就把热路径和冷路径分开。4. 熔断状态存储Redis做状态机MongoDB做审计仓库熔断和限流还不完全一样。限流的核心是计数器熔断的核心是状态机。状态机对原子性的要求更苛刻因为状态一旦错了整个服务的流量控制策略就乱了。4.1 熔断器状态机的本质就是一份共享状态一个标准熔断器要维护三类状态关闭、打开、半开。关闭状态下统计最近时间窗口的失败率或慢调用比例超过阈值就切到打开状态打开状态下直接短路所有请求快速失败等冷却时间过去再放少量请求进入半开探测半开状态下如果探测请求成功就合上熔断器如果失败重新打开。这个状态必须被多个网关实例共享。如果每个实例各自存一份熔断状态熔断效果无法全局生效。比如某个下游服务已经故障流量被负载均衡分散到5个网关实例如果每个实例都独立判定故障可能全部判定成功后把请求继续打入故障服务造成雪崩。用Redis存储熔断状态一般以“服务名接口路径”为key用Hash保存state、failureCount、successCount、openedAt等字段。状态切换写成一个Lua脚本保证从读到判断到写回是原子的。local stateKey KEYS[1] local now tonumber(ARGV[1]) local threshold tonumber(ARGV[2]) local coolDownMs tonumber(ARGV[3]) local probeMax tonumber(ARGV[4]) local state redis.call(HGET, stateKey, state) if not state then redis.call(HSET, stateKey, state, CLOSED, failureCount, 0, successCount, 0, openedAt, 0) state CLOSED end -- 其余状态机逻辑在这里扩展实际工程里的状态机逻辑会比这复杂很多但核心思路是一致的状态集中在Redis多个网关实例通过同一份Lua脚本操作状态不依赖网关进程内的可变变量。4.2 为什么不用MongoDB做主状态存储如果把熔断状态放进MongoDB逻辑上也能做每次检测到失败或成功就updateOne条件判断后更新状态。问题还是那个热路径上每个请求都在读写同一个文档再加上状态本身的读写频率比限流计数还高到了高并发下很容易成为瓶颈。还有一个更隐蔽的问题。MongoDB的读写如果配置了从节点读可能读到旧状态如果强制读主节点又会让主节点压力更大。总而言之它更像一个适合“记录状态变化之后的历史”的存储而不是“当前状态”的存储。熔断器打开或关闭一次之后这条事件记录放MongoDB后面做统计、复盘、告警都非常合适。4.3 MongoDB在熔断降级场景里真正擅长的事我在网关项目里看到最合理的MongoDB用法是保存降级事件。每个被熔断降级的请求按文档形式记录{ serviceId: order-service, routeId: create-order, ruleId: circuit-breaker-001, state: OPEN, reason: FAILURE_RATE_EXCEEDED, requestId: trace-id-xxx, userId: 10086, timestamp: ISODate(2025-01-01T10:00:00Z) }这种带嵌套上下文的记录用关系型数据库存储要设计一堆表用MongoDB就特别自然。字段可以灵活扩充线上排查时按requestId直接查到完整上下文复盘时按serviceId、routeId、state做聚合统计一条聚合管道就出日报。MongoDB的聚合框架能做的事情非常实用比如统计每个路由每小时的降级次数、TOP10熔断接口、平均冷却时间等。这些数据在Redis里很难查因为Redis是按key访问不是按字段查询。这也是我一直强调“分工”的原因Redis管当下MongoDB管以后。5. 网关侧真实落地规则中心、实时统计、审计日志怎么协作把前面几节的结论放到一个真实网关架构里看可以设计出一个比较稳妥的落地方案。5.1 分层规则加载、动态配置、状态存储别混在一起网关限流熔断通常有三层数据。第一层是规则定义比如“某个接口每秒允许100个请求”“失败率超过50%熔断10秒”。这些规则来自配置中心或管理后台网关启动时加载到本地内存变更时通过订阅推送到网关进程。规则本身不一定需要Redis或MongoDB用Nacos、Apollo这类配置中心更合适。第二层是实时状态包括每个限流key的当前计数、每个熔断器的当前状态。这层必须高可用、低延迟、强一致Redis是首选。第三层是历史数据包括限流日志、熔断事件、降级记录、调用指标。这层用MongoDB或者Elasticsearch来存重点是可查询、可聚合、可过期清理。把这三层混在一起是所有架构混乱的根源。我见过有人把限流规则直接写死在MongoDB里每次请求都查一次库这就是典型的“规则加载方式错误”。5.2 一个可复制的典型过滤器流程假设我们有一个自研网关或者基于Spring Cloud Gateway扩展限流熔断插件跑在过滤器链里。整体流程可以这样设计请求进入网关过滤器链先做鉴权和路由匹配限流过滤器生成限流key例如“userId routeId”执行Redis Lua脚本返回是否放行如果限流拒绝网关直接返回429同时把命中规则的请求上下文异步写入MongoDB的限流事件表如果放行进入熔断过滤器。先查Redis中熔断状态如果是OPEN直接走降级逻辑返回503或者fallback结果如果状态是CLOSED或HALF_OPEN正常转发请求并根据响应成功或失败异步更新Redis里的失败计数和总请求计数当熔断状态发生切换时向MongoDB写一条状态切换事件方便事故复盘后台任务每隔一段时间读取MongoDB事件表生成限流熔断统计报表或者触发告警。这套流程里有一个关键经验审计事件写入不能阻塞热路径。建议先写入本地异步队列或线程池批量提交到MongoDB。否则极端情况下每个限流请求都同步写一次库MongoDB反而成为新的瓶颈。5.3 和Sentinel、Resilience4j、ShenYu这类组件的关系很多团队不是从零自研网关而是基于开源组件改造。比如Sentinel提供了完整的限流熔断能力默认规则直接存在内存中同时支持通过DataSource扩展接入Nacos、Redis、ApolloShenYu这样的开源网关也支持插件化限流可以集成SentinelResilience4j则是进程内的高可用库本身不提供分布式状态存储。这里要提醒一句开源组件自带的“内存限流”在单机部署下够用但在多实例网关后面必须自己接一个分布式存储来共享状态。Sentinel的集群限流功能有专门方案本质也是用Redis或Token Server协调。我们的原则是能不能直接用组件内部实现取决于网关实例数量和下游敏感性。实例多、下游容易被打垮就一定要把状态放到Redis这一层。5.4 流量规模变化时架构怎么调整小规模场景比如QPS只有几百网关可以不需要Redis和MongoDB在进程内做限流和熔断审计日志写到本地文件或简单DB即可。中规模场景网关实例有3-10个分布式限流和熔断状态共享就需要Redis审计日志落到MongoDB。大规模场景比如日请求量过亿Redis要考虑Cluster分片限流key要做哈希打散MongoDB只能做异步落库不能承担任何在线查询的主路径。6. 选型决策清单什么时候选谁什么时候两个一起上把所有理论落到选型可以整理成一套决策清单。这不是标准答案但至少能帮团队快速达成一致。6.1 关键决策矩阵场景推荐方案核心原因需要分布式限流延迟敏感Redis原子操作和TTL机制天然匹配多个网关实例需要共享熔断状态Redis状态机切换需要全局一致需要长期保存限流/熔断事件做审计MongoDB文档灵活、聚合查询方便、支持TTL清理团队已有MongoDBQPS很低不想加组件短期可只用MongoDB成本低但需要接受延迟和窗口边界误差已经有配置中心只存规则不存状态不需要Redis或MongoDB规则属于配置不属于热路径数据既有实时状态又有审计团队规模允许Redis MongoDB分工稳定性和可观测性最好6.2 已经有MongoDB能不能省掉Redis这是一个很现实的成本问题。我的结论是如果你的网关QPS不超过每节点几百且限流精度要求不严格可以短期只用MongoDB。但一定要提前定义好转捩点比如“当p99延迟超过5ms”或者“单key并发更新超过每秒500次”就必须引入Redis。否则限流组件本身会成为故障点。反过来问已经有Redis能不能省掉MongoDB这更难。因为限流熔断场景里的审计和复盘数据Redis的存储成本和查询能力都不合适。你可以把关键审计数据落Elasticsearch或者落关系型数据库但直接用Redis存日志数据长期看基本不可行。Redis的内存成本太高而且没有复杂的条件查询能力。6.3 运维能力和团队熟悉度也必须计入选型从来不只是技术问题。一个团队如果对MongoDB的副本集、备份恢复、索引调优已经很熟练但对Redis的主从、持久化、集群迁移不熟悉那么引入Redis要付出的运维成本往往被低估。反过来如果团队本身就是缓存中间件团队那Redis就是第一选择。我见过一个团队为了“减少组件”把所有网关状态和审计都塞进MongoDB后来上线一个月发现运维根本抗不住慢查询和索引膨胀又花了两个星期迁移到Redis。组件不是越少越好而是每个组件的边界越清晰越好。7. 我在网关限流熔断改造里踩过的几个坑最后分享一些实操里踩过的坑。这些问题在文档里很难找到现成答案但对真实上线影响很大。7.1 限流key设计不散导致Redis热点和误杀最初做限流key时我们直接用了routeId作为key想的是“这个接口总访问量不能超过多少”。上线后发现某些接口被大量集中的IP刷流量所有用户共用同一个计数器实际上变成全局限流正常用户也会被误杀。后来的方案改成userId routeId同时针对IP单独设限。key设计这个事没有银弹只能按业务语义拆细。还有一次线上问题是因为有人在排查时执行了KEYS命令在Redis单线程模型下直接阻塞了网关的限流请求。排查命令用SCAN这是每一个用Redis做网关热路径存储的人都该记住的教训。7.2 Redis重启后限流状态丢失主从切换引发流量尖峰有次运维同学例行重启Redis主节点主从切换完成后限流计数全部清零。恰好当时有一个下游服务不稳定熔断状态也丢了大量请求瞬间涌入直接把下游打挂。那次事故之后我们做了两件事第一Redis配置改成AOF everysec尽量缩短状态丢失窗口第二在网关侧增加Redis不可用或状态丢失时的本地熔断降级兜底先保护下游优先而不是追求限流绝对精确。7.3 MongoDB TTL索引不是“自动限流清理”它只会慢半拍我见过一个团队把限流窗口数据写进MongoDB然后依赖TTL索引清理旧窗口。结果后台删除线程的扫描周期和删除时间不可控大量过期文档堆积索引膨胀写性能下降。后来改成“窗口起始时间”作为查询条件不再依赖TTL删旧数据问题才缓解。MongoDB的TTL索引做审计日志过期清理可以做限流窗口边界不行。7.4 安装和日常使用里的小问题也会在关键时候卡你一下环境层面的坑折腾起来也很烦。Windows上装Redis做本地开发内存限制、Windows服务方式都和Linux版有差异生产别用。MongoDB安装时如果报 unexpected error优先查数据目录权限和Windows服务账户7.0版本对目录权限要求更严。Spring Data MongoDB里如果直接调用MongoRepository.findAll()数据量大时没有任何过滤条件相当于全表扫描建议都加上Pageable或者DslQuery。这些小问题单个看不大堆在一起会让人分不清到底是限流方案的问题还是数据库使用的问题。7.5 踩过这么多坑之后我的固定套路现在我接到网关限流熔断方案评审时一般会直接给出这套固定建议限流计数和熔断状态放Redis做成Lua脚本原子操作限流熔断事件和审计日志放MongoDB异步写入规则配置走配置中心网关进程内保留一份最近N秒的限流缓存作为Redis故障时的降级方案。这样每个组件的压力都落在自己擅长的地方出了问题也方便排查。过去的经验反复证明不是选一个“更好”的数据库而是把每个数据库放到它真正该待的位置上。
返回列表