限流:从单机QPS计数器到分布式三层防御体系

大家好,我是程序员小策。

先说一个反直觉的事实:加了限流之后,你系统的成功请求数量反而可能变多。

听起来很荒诞对吧?限流的字面意思就是"拦住一部分请求",拦住了怎么可能变多?

但数据不会骗人:

场景 总请求数 成功数 成功率
不限流 50000 0 0%
加了限流 50000 5000 10%

不限流的时候,50000 个请求全部涌入数据库,连接池打满,超时重试又制造了一倍流量,雪崩导致所有接口全部失败------包括那些只想来浏览商品页的正常用户。

加了限流之后,50000 个请求里被拦掉了 45000 个,但这 45000 个被拦掉的人里,有 40000 个是恶意刷量的机器人。剩下的 5000 个真实用户的请求全部成功处理了。

限流不是在减少你的业务量------它是在帮你把有限的资源分配给真正有价值的那部分请求。

这就引出了今天要聊的问题:限流到底该怎么做?在什么位置做?用什么算法?限不住了怎么办?


问题定义:限流不是"不让用",是"让大家都能用一点"

很多人对限流有一个误解:限流 = 拒绝用户请求。

那是错的。限流的本质是用拒绝一小部分请求的方式,保证大部分请求能正常服务

想象两个场景:

场景 A(没限流):秒杀开始了,QPS 从 1000 飙到 50000。数据库连接池打满,服务开始超时。调用方的超时重试又制造了一倍流量。最后所有请求全部失败------包括只想来浏览商品页的用户。

场景 B(有限流):秒杀开始了,网关层限流 5000 QPS。前 5000 个请求被秒杀服务正常处理,第 5001 个收到"活动太火爆,请稍后再试"。浏览商品页的用户完全无感------因为秒杀接口被隔离限流了,不影响其他接口。

场景 A 的结局:50000 个请求,0 个成功。场景 B 的结局:50000 个请求,5000 个成功。

限流的"残忍"是为了让系统"仁慈"------对一部分人残忍,才能对大部分人仁慈。

限流(Rate Limiting):通过控制单位时间内进入系统的请求数量,防止系统被突发流量冲垮。它不是拒绝服务,而是用有限的拒绝换取整体的可用性。


核心概念:用"药店限购"理解限流

疫情期间你去药店买退烧药,店员告诉你:每人每天最多买 2 盒

这就是限流。

拆开看,这里面包含了限流的所有核心要素:

  • 限流对象:每个买药的人(对应系统中的"每个用户 IP / 每个接口")
  • 限流阈值:2 盒(对应 QPS 上限,比如每秒 1000 个请求)
  • 时间窗口:1 天(对应 1 秒 / 1 分钟 / 滑动窗口)
  • 限流算法:药店的实现很粗糙------人工记账,你今天来过了就看本子拒绝你。这对应"固定窗口"算法(凌晨 0 点重置计数)。

如果把药店的规则升级一下会怎样?

令牌桶算法:药店每天早上进货 100 盒,每卖一盒就从库存里减一。卖完了?只能等明天进货。但如果上午只卖了 20 盒,下午还剩下 80 盒可卖。没有固定窗口那种"最后一秒突然清零"的问题。

滑动窗口算法:不是"从凌晨 0 点开始算今天",而是"从现在往前推 24 小时,你买了没有超过 2 盒"。不会出现 23:59 买了 2 盒、00:01 又买 2 盒的漏洞。

漏桶算法:药店里只有一条取药通道,每次只能服务一个人,每个人最少间隔 5 分钟。不管来多少人,药剂师永远匀速工作。

翻译回技术语言:

  • 固定窗口:简单但边界有毛刺------窗口切换瞬间可能被翻倍流量打穿。
  • 令牌桶:允许一定程度的突发------桶里有令牌就能拿,但拿完了就得等。
  • 滑动窗口:平滑精确------但分布式环境下的实现复杂度高。
  • 漏桶:绝对平滑------强制匀速,完全消除了突发,但不适合需要快速响应的场景。

实现:企业级限流的三层防御体系

一次线上事故教会我:限流不能只在一个位置做。

第一层:网关层。 在最外层就拦住明显异常的流量------比如同一个 IP 一秒发来 500 个请求,直接封。不让他进到业务层。

第二层:应用层(单机限流)。 到了业务代码里,对每个核心接口做精细化的 QPS / 并发数控制。Sentinel 是这层的标杆方案。

第三层:分布式层。 当你有 10 台机器,每台机器限 100 QPS,总限 1000 QPS------但如果流量倾斜到某一台机器上,单机 100 QPS 不够用,另外 9 台闲着。你需要一个跨机器的总闸门。

三层从外到内逐级收敛,每一层都有自己不可替代的职责。

4.1 单机限流:以 Sentinel FlowRule 为例

以下代码来自阿里巴巴开源的 alibaba/Sentinel(30k+ stars),这是国内使用最广泛的限流框架。

java 复制代码
import com.alibaba.csp.sentinel.Entry;
import com.alibaba.csp.sentinel.SphU;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRule;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager;

import java.util.ArrayList;
import java.util.List;

public class SeckillRateLimitExample {

    public static void main(String[] args) {
        // 1. 定义限流规则
        List<FlowRule> rules = new ArrayList<>();

        FlowRule seckillRule = new FlowRule("seckill_submit")
            // grade: 1=QPS限流, 0=线程数限流
            .setGrade(RuleConstant.FLOW_GRADE_QPS)
            // count: QPS 阈值 --- 这个接口最多每秒处理 1000 个请求
            .setCount(1000)
            // controlBehavior: 0=直接拒绝, 1=Warm Up, 2=匀速排队(漏桶), 3=Warm Up + 匀速排队
            .setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_DEFAULT);

        rules.add(seckillRule);
        // 2. 加载规则 --- 规则可以动态加载,不需要重启
        FlowRuleManager.loadRules(rules);

        // 3. 业务代码:用 SphU.entry() 包裹需要限流的代码
        for (int i = 0; i < 5000; i++) {
            try (Entry entry = SphU.entry("seckill_submit")) {
                // 限流通过了,正常执行秒杀逻辑
                doSeckill();
            } catch (BlockException e) {
                // 被限流了 --- 返回"活动太火爆"提示
                System.out.println("请求被限流,请稍后重试");
            }
        }
    }

    private static void doSeckill() {
        // 秒杀核心逻辑:扣库存、生成订单
    }
}

为什么这样设计?

SphU.entry("seckill_submit") 是 Sentinel 的入口方法。它内部做的事远比"计数+判断"复杂:

  1. 调用 FlowSlot.checkFlow() ------检查 QPS 是否超阈值
  2. 调用 DegradeSlot ------检查下游服务是否熔断
  3. 调用 AuthoritySlot ------校验调用方是否有权限

整个调用链是责任链模式,每个 Slot 独立可插拔。限流只是其中一个 Slot。

再看 FlowRule 的核心字段(直接来自 Sentinel 源码,FlowRule.java):

java 复制代码
// grade: 0=线程数, 1=QPS --- 两种粒度
private int grade = RuleConstant.FLOW_GRADE_QPS;

// count: 阈值,类型是 double --- 可以设小数,比如 0.5 QPS = 2秒放一个
private double count;

// strategy: 0=直接, 1=关联, 2=链路
// 关联限流:秒杀接口 QPS 超了,连带把商品详情页也限流(保核心链路)
private int strategy = RuleConstant.STRATEGY_DIRECT;

// controlBehavior: 0=直接拒绝, 1=Warm Up, 2=漏桶匀速排队
// Warm Up:刚启动时阈值从 count/3 逐步上升到 count,给系统预热时间
private int controlBehavior = RuleConstant.CONTROL_BEHAVIOR_DEFAULT;

private int warmUpPeriodSec = 10;      // Warm Up 的预热时长
private int maxQueueingTimeMs = 500;   // 漏桶模式的最大排队时间
private boolean clusterMode;           // 是否启用集群限流

这六个字段组合起来可以覆盖绝大多数限流场景。为什么 count 用 double 而不是 int? 因为"每 2 秒放行 1 个"这种低频限流,count=0.5 比 int 更灵活。

4.2 分布式限流:Redis + Lua 原子令牌桶

单机限流的最大问题是:你有 10 台机器,每台限 QPS=100,总量 1000。但如果某一台因为哈希不均匀承受了 400 QPS,它自己被限了,其他 9 台加起来才 600------总的 QPS 远没到 1000,用户却被拒绝了。

分布式限流需要 Redis 做全局计数器 ,但要解决的核心问题是竞态条件:先读后写不是原子的。

下面是来自 lokeshpusarla100/java-redis-lua-distributed-rate-limiter 项目中的 Lua 脚本,实现了原子化的多计划令牌桶

lua 复制代码
-- acquire_token.lua --- 原子化多计划令牌桶实现
-- KEYS: 交替排列 [bucket_key_1, config_key_1, bucket_key_2, config_key_2, ...]
-- ARGV[1]: 请求的令牌数

local requested = tonumber(ARGV[1])

if #KEYS == 0 or #KEYS % 2 ~= 0 then
    return redis.error_reply("KEYS must be alternating pairs of bucket_key and config_key")
end

-- 1. 调用一次 TIME,所有计划共用同一个时间戳
-- 这是关键:保证 Refill 阶段对所有桶"看的是同一个时刻"
local time_res = redis.call('TIME')
local now_ms = (tonumber(time_res[1]) * 1000) + math.floor(tonumber(time_res[2]) / 1000)

local allow_all = true
local min_remaining = math.huge
local max_wait_ms = 0
local temp_updates = {}

-- 2. 读取与计算阶段:逐个计划检查
for i = 1, #KEYS, 2 do
    local bucket_key = KEYS[i]
    local config_key = KEYS[i+1]

    local config = redis.call('HMGET', config_key, 'capacity', 'refillRate')
    local capacity = tonumber(config[1])
    local refill_rate = tonumber(config[2])

    -- 当前桶里的令牌数和上次补充时间
    local state = redis.call('HMGET', bucket_key, 't', 'ts')
    local current_tokens = tonumber(state[1]) or capacity
    local last_refill = tonumber(state[2]) or 0

    -- 计算这段时间应该补充多少令牌
    local delta_ms = math.max(0, now_ms - last_refill)
    local refill = delta_ms * (refill_rate / 1000.0)
    local updated_tokens = math.min(capacity, current_tokens + refill)

    if updated_tokens >= requested then
        -- 这个计划允许 --- 记录扣除后的余额
        min_remaining = math.min(min_remaining, updated_tokens - requested)
        temp_updates[bucket_key] = updated_tokens - requested
    else
        -- 这个计划拒绝 --- 计算还要等多少毫秒
        allow_all = false
        max_wait_ms = math.max(max_wait_ms,
            math.ceil((requested - updated_tokens) * (1000.0 / refill_rate)))
    end
end

-- 3. 提交阶段(全或无 --- All-or-Nothing)
-- 只有所有计划都通过才真正写入,保证多维度限流的原子性
if not allow_all then
    return {0, min_remaining, max_wait_ms}
end

for bucket_key, remaining in pairs(temp_updates) do
    redis.call('HSET', bucket_key, 't', remaining, 'ts', now_ms)
end

return {1, min_remaining, 0}

为什么这样写?

  1. 一次 TIME 调用:所有计划共享同一个时间戳。如果每个计划单独调 TIME,两个计划拿到的毫秒数不同,会出现"A 计划认为补充了 3 个令牌,B 计划认为补充了 5 个"的不一致。
  2. 全或无提交:先计算后写入。如果某个计划不通过,整个请求失败,已扣令牌的假象不会发生。这是 Redis Lua 脚本的关键优势------整个脚本在 Redis 内原子执行。
  3. 返回剩余令牌数return {1, min_remaining, 0} 不仅仅返回通过/不通过,还返回了最小剩余令牌数。上层可以根据这个值做动态调整(比如当剩余接近 0 时提前告警)。

4.3 三层防御体系的完整架构

把单机限流和分布式限流拼起来:

复制代码
                    请求流入
                       │
          ┌────────────┼────────────┐
          │   网关层限流              │
          │   (IP 黑名单 / 全局 QPS)  │
          │   挡掉最明显的异常流量      │
          └────────────┬────────────┘
                       │ 通过
          ┌────────────┼────────────┐
          │   分布式限流              │
          │   (Redis Lua 令牌桶)      │
          │   跨机器的总闸门           │
          └────────────┬────────────┘
                       │ 通过
          ┌────────────┼────────────┐
          │   单机限流                │
          │   (Sentinel FlowRule)   │
          │   最后一道防线             │
          └────────────┬────────────┘
                       │ 通过
                   业务处理

三层各司其职:

  • 网关层:粗放------按 IP / AppKey / 全局 QPS 拦截,不需要知道业务细节
  • 分布式层:精准------跨机器总计,保证整体不超,代价是每次请求都要调 Redis
  • 单机层:快速------纯内存操作,性能损耗极小,但粒度是单机的

边界与陷阱:限流实现中五个容易踩的坑

陷阱一:固定窗口的"毛刺效应"。 限流规则设 QPS=100,你在 00:00:00.900 到 00:00:01.100 这 200 毫秒内可以发进来 200 个请求------前 100 个属于第 0 秒,后 100 个属于第 1 秒。两个窗口切换瞬间,没有哪个窗口单独被限流,但你的服务在 200ms 内承受了 2 倍的压力。解法:用滑动窗口或令牌桶替代固定窗口。

陷阱二:Sentinel 的规则不持久化。 Sentinel 的默认规则存储在内存中,服务重启后规则消失。如果你在 Dashboard 里配置了限流规则但没做持久化,重启后所有接口裸奔。解法:把规则推送到配置中心(Nacos / Apollo / ZooKeeper),启动时从配置中心拉取。

陷阱三:Redis Lua 脚本的 KEYS 哈希槽问题。 分布式限流的 Lua 脚本用多个 KEY,在 Redis Cluster 模式下,这些 KEY 必须属于同一个哈希槽,否则脚本执行失败。解法:用 {} 包裹 hashtag------比如 {rate_limit}:bucket:api1{rate_limit}:config:api1,保证相同业务限制在同一个 slot。

陷阱四:分布式限流的 Redis 穿透。 每次请求都调 Redis,当限流本身成为瓶颈就荒唐了。10000 QPS 的限流自身吃掉 10000 次 Redis 调用。解法:先过单机限流(纯内存),单机过了才去分布式层校验。加上本地预取(一次从 Redis 拿 50 个令牌存本地,用完再拿)。

陷阱五:兜底策略缺失。 限流后返回什么?直接返回 429 Too Many Requests 是最偷懒的做法。更好的做法是:提示用户大概要等多久(Retry-After 头)、对高价值用户放行一部分、返回降级数据(缓存中的商品信息而不是实时查询)。限流不是终点------被限的请求怎么处理,比怎么限更重要。


高级考量:从单机到全链路

当你的系统大到一定程度时,限流需要扩展到三个新维度:

一是自适应限流。 Sentinel 支持基于系统负载的自适应限流(SystemRule)------不再设固定 QPS,而是设定"系统 Load 不超过 4""CPU 使用率不超过 80%"等规则。系统自己根据当前负载动态调整放行量。比固定 QPS 更智能------夜里没人用可以全速跑,白天高峰期自动收紧。

二是集群限流。 Sentinel 提供了 token server(令牌服务器)模式。所有机器去一台中心化的 token server 上申请令牌,token server 自己维护全局 QPS。缺点是 token server 本身成了单点。Sentinel 的解决方式是集群限流------流量均匀分摊到多个 token server,一台挂了自动 failover 到备用。

三是热点参数限流。 限流不只是针对接口,还可以针对接口的某个参数。比如"同一个商品 ID 的秒杀请求每秒不超过 100 个"------如果有 1000 个不同商品在秒杀,总量远超过 100,但每个商品的限流各自独立。Sentinel 的 ParamFlowSlot 专门处理这个场景。


项目实战:在秒杀系统中落地三层限流

去年双十一之后,我们给秒杀系统重构了限流方案。场景是这样的:

项目背景: 电商平台,日均 20 万单。大促秒杀时,3 款爆品同时上架,瞬时 QPS 达到平时的 50-80 倍。

场景拆解: 三条链路需要分别管控------商品详情页(读)、库存查询(读)、下单(写)。写入链路最脆弱,必须单独限流。

方案落地:

  1. 网关层:基于 Nginx + OpenResty,IP 级别 100 QPS,全局 10000 QPS
  2. 分布式层:Redis Cluster 分片 + Lua 令牌桶,下单接口全局上限 500 QPS
  3. 应用层:Sentinel FlowRule,下单接口单机 100 QPS

核心代码------在 Spring Boot 中集成 Sentinel + 分布式限流:

java 复制代码
import com.alibaba.csp.sentinel.Entry;
import com.alibaba.csp.sentinel.SphU;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRule;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;

import java.util.Arrays;
import java.util.Collections;

@Service
public class RateLimitedSeckillService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    // 分布式限流 Lua 脚本(简化版滑动窗口)
    private static final String LUA_SLIDING_WINDOW =
        "local key = KEYS[1]\n" +
        "local window_ms = tonumber(ARGV[1])\n" +
        "local max_count = tonumber(ARGV[2])\n" +
        "local now = redis.call('TIME')\n" +
        "local now_ms = now[1] * 1000 + math.floor(now[2] / 1000)\n" +
        "local window_start = now_ms - window_ms\n" +
        "redis.call('ZREMRANGEBYSCORE', key, 0, window_start)\n" +
        "local current = redis.call('ZCARD', key)\n" +
        "if current < max_count then\n" +
        "    redis.call('ZADD', key, now_ms, now_ms .. '-' .. math.random())\n" +
        "    redis.call('PEXPIRE', key, window_ms)\n" +
        "    return 1\n" +
        "end\n" +
        "return 0";

    public String submitOrder(String userId, String productId) {
        // 第一层:单机限流(Sentinel,纯内存,极快)
        try (Entry entry = SphU.entry("seckill_submit")) {

            // 第二层:分布式限流(Redis 滑动窗口,全局精准)
            DefaultRedisScript<Long> script = new DefaultRedisScript<>();
            script.setScriptText(LUA_SLIDING_WINDOW);
            script.setResultType(Long.class);

            Long allowed = redisTemplate.execute(
                script,
                Collections.singletonList("seckill:global:window"),
                "1000",  // 窗口 1000ms = 1秒
                "500"    // 全局上限 500 QPS
            );

            if (allowed == null || allowed == 0) {
                return "活动太火爆,请稍后重试(全局限流)";
            }

            // 两层都通过了 --- 执行秒杀
            return doSeckill(userId, productId);

        } catch (BlockException e) {
            return "活动太火爆,请稍后重试(单机限流)";
        }
    }

    private String doSeckill(String userId, String productId) {
        // 扣库存、生成订单...
        return "秒杀成功";
    }
}

踩坑记录:

  • 坑一 :分布式限流使用 ZSET 滑动窗口时,忘了设 PEXPIRE。结果 ZSET key 没有过期时间,内存持续增长直到 OOM。Redis 的 ZREMRANGEBYSCORE 只清理了超出窗口的成员,但如果一个 key 已经很久没有请求进来了,ZSET 本身不会被删除。
  • 坑二 :Sentinel 的 blockHandlerfallback 是两回事。OpenFeign 整合 Sentinel 时,blockHandler 处理限流/熔断,fallback 处理业务异常。很多人写反了------在 blockHandler 里捕获了业务异常,限流反而没有生效。
  • 坑三 :Redis Cluster 分片模式下,Lua 脚本的 KEYS 如果跨 slot,Redis 直接报 CROSSSLOT 错误。我们用 {seckill} hashtag 把所有相关 key 框在同一个 slot 里,保证 Lua 脚本能正常执行。

兜底策略------限不住了怎么办?

限流不是万能的。如果流量大到连限流层本身都撑不住了(Redis CPU 100%、Sentinel 内存打满),需要有三层兜底:

  1. 接口降级:秒杀服务挂了?自动切到"预约模式"------用户点"抢购"变成"您已预约,开抢后通知"。比直接报 500 好得多。
  2. 静态化:商品详情页完全静态化推 CDN,不经过应用服务器。即使后端全挂,用户至少能看到商品信息。
  3. 手动熔断:运维 Dashboard 上有一个红色按钮,按下去直接把秒杀入口重定向到"活动已结束"页面。这个按钮那天救了我们的命。

对比表格

表格一:四种限流算法对比

算法 核心思路 突发处理 实现复杂度 典型场景
固定窗口 每个时间窗口独立计数 差:窗口切换瞬间可能翻倍 对精度要求不高的低频接口
滑动窗口 窗口随时间滑动,边界平滑 好:窗口内精确计数 中:单机简单,分布式需 ZSET API 限流、用户维度的精细化控制
令牌桶 固定速率补充令牌,桶满则停 好:允许短期突发(桶里有就能拿) 中:需维护桶状态和补充逻辑 秒杀、抢购,允许集中爆发
漏桶 固定速率漏水,超出则溢出 差:完全消除突发,强制匀速 低:只需队列 消息队列消费端、第三方 API 调用

表格二:三层限流位置对比

限流层 部署位置 粒度 性能损耗 能挡住什么
网关层 Nginx/网关 IP / AppKey / 全局 QPS 极低(C 语言级) 恶意刷量、单 IP 高频请求
分布式层 Redis Cluster 接口 / 用户 / 商品维度 中(一次网络 IO) 跨机器的超额流量,保证总 QPS 不超
应用层 JVM 进程内 接口 / 方法级别的精细化控制 低(纯内存) 单机资源保护、控制并发线程数

面试追问

追问 1:令牌桶和漏桶到底有什么区别?

回答方向:令牌桶允许突发(桶里存了多少就能瞬间发多少),漏桶强制匀速。选令牌桶意味着"我相信突发是正常的,系统扛得住",选漏桶意味着"我不相信任何突发,请匀速来"。秒杀用令牌桶,第三方 API 调用用漏桶------你不想因为发太快被对方封 IP。

追问 2:分布式限流中,Redis 挂了怎么办?

回答方向:必须做降级。Redis 不可用时,切换到纯单机 Sentinel 限流------精度下降但服务可用。更完善的方案是本地预取令牌 + 定期同步------先把 Redis 的 50 个令牌拿到本地用,用完了再去申请。Redis 短暂不可用期间,本地令牌还能撑一会。Sentinel 的 DegradeSlot 可以自动检测 Redis 是否可用并切换策略。

追问 3:为什么限流要分三层,一层不行吗?

回答方向:单一层的限流有各自的死穴。只用网关层------无法区分业务接口,商品浏览和秒杀下单被同等对待。只用分布式层------每次请求都要调 Redis,限流本身成为瓶颈。只用单机层------10 台机器,总 QPS 无法精确控制。三层互补:网关层挡掉最猛的、分布式层管住整体的、单机层保护自己的。哪一层挂了,另外两层还能撑住。


限流不是在拒绝用户------是在保护那些正常的用户。

读完这篇你应该能:理解四种限流算法的区别和适用场景、用自己的语言解释为什么限流要分三层而不是一层、用 Sentinel + Redis Lua 搭建一套生产可用的三层限流体系、在面试时说清楚"令牌桶和漏桶你怎么选"而不仅仅是"都用令牌桶"。

相关推荐
真上帝的左手1 个月前
10. 软件设计&架构-经典架构问题-幂等+限流
架构·限流·幂等
恼书:-(空寄2 个月前
Sentinel 限流降级:滑动窗口原理 + 生产实战全解
sentinel·限流
ai旅人2 个月前
Guava RateLimiter深度解析:非阻塞令牌桶限流原理与跑批实战
java·限流·guava
人间打气筒(Ada)2 个月前
go:如何实现接口限流和降级?
开发语言·中间件·go·限流·etcd·配置中心·降级
cq林志炫3 个月前
php 限流思路
redis·php·限流
zhglhy3 个月前
Java系统限流方法技术优劣
java·限流
递归尽头是星辰4 个月前
Sentinel + Spring Cloud Gateway 联动限流实战
系统架构·sentinel·限流·微服务治理·限流架构设计
蜂蜜黄油呀土豆4 个月前
高并发场景下的负载均衡、熔断降级与限流措施
负载均衡·高并发·限流·熔断·降级
stevenzqzq5 个月前
android fow 限流
android·限流·flow