Java常用类库-17-RateLimiter的许可债务与时间模型
每秒两个许可不等于最多两个请求
商品同步任务需要控制向下游发请求的速度。把 RateLimiter 配成每秒两个许可,再让每次请求申请一个许可,可以约束许可申请节奏,却不能据此证明任意一秒窗口最多出现两个请求,更不能证明同时执行的请求最多两个。空闲期间可积累许可,多许可请求还会把等待成本留给后续调用。
这一章使用 Guava 33.5.0-jre,分别检查公开 API 和固定版本 SmoothRateLimiter 的时间模型。Java 8 代码在 Zulu 8u472、Corretto 21.0.11 上运行。确定性模型使用可控时钟,真实时间另作观测;两类证据分开报告,模型中的精确小数不冒充操作系统调度精度。
第 16 篇区分结果完成与线程工作,本章把限流也拆成到达、许可申请、等待、执行、完成五个时刻。RateLimiter 只参与许可申请和必要等待,没有从任务完成处自动接收反馈。[固定版本 RateLimiter](https://github.com/google/guava/blob/8868c096cfdabbe38170b6e395369c315cfb72a1/guava/src/com/google/common/util/concurrent/RateLimiter.java)从 nextFreeTicketMicros 计算等待
默认 create(rate) 使用允许突发的实现。其关键状态包括稳定许可间隔、已存许可、最大已存许可,以及下一张许可最早可用时刻 nextFreeTicketMicros。把所有状态压缩成“令牌桶”三个字,会丢掉本例最重要的行为:当前调用返回哪个时刻,以及它给下一次调用增加多少成本。
固定源码 reserveEarliestAvailable 先按当前时间更新闲置积累,再保存原来的 nextFreeTicketMicros 作为本次返回值。随后消费已存许可,将剩余的新许可换算成时间,加到 nextFreeTicketMicros。公开 acquire 等待的是原先返回的可用时刻与当前时刻之差,而不是更新后的全部债务。SmoothRateLimiter 预订路径
因此,大请求不一定自己等待与许可数量成正比的时间。若之前没有欠下等待,一个请求申请四个许可可以立即返回;它为之后的申请推迟可用时刻。这个安排不会追溯撤销当前调用,也不会知道四个许可对应四个独立 HTTP 请求还是一批数据。许可的业务单位必须由调用者定义。
令稳定速率为每秒两个许可,稳定间隔就是 0.5 秒。可控时钟从零开始,默认实现最初没有已存许可,下一可用时刻为零。连续执行 acquire(4)、acquire(1)、acquire(1),时间表如下。这里的“调用时刻”是上一调用返回后立即发起下一调用,假设业务处理没有消耗时间。
| 调用 | 调用时刻 | 本次等待 | 本次返回时刻 | 预订后下一可用时刻 |
|---|---|---|---|---|
| acquire(4) | 0 | 0 | 0 | 2.0秒 |
| acquire(1) | 0 | 2.0秒 | 2.0秒 | 2.5秒 |
| acquire(1) | 2.0秒 | 0.5秒 | 2.5秒 | 3.0秒 |
模型测试断言等待序列 [0, 2.0, 0.5],并断言最终时钟为 2,500,000 微秒。这些值来自指定速率和固定源码的预订规则,不是对真实 sleep 耗时四舍五入后的展示。业务如果希望大批量请求自己先等待全部许可成本,需要选择适合该契约的调度方式,不能只根据 acquire 的参数名假定已经实现。
空闲积累与连续零等待
默认 SmoothBursty 的最大已存许可由速率与固定突发容量时间计算。此版本默认允许积累一秒对应的许可,因此每秒两个许可时,最多存两个。可控时钟先空闲三秒,再申请两个许可,这两个已存许可不增加额外等待;紧接着再申请一个新许可,也可能立即返回,只是把下一可用时刻推迟半秒。
模型于是出现相邻两次 acquire 等待均为零,总共申请三个许可。这足以否定“配置每秒两个就保证任意一秒窗口最多两个”的推论。RateLimiter 提供的是带有特定突发和预订语义的平均速率控制,不是一个按任意滑动窗口逐条计数的严格上限实现。
1 | |
空闲期间并没有后台线程不停补充许可。resync(now) 在调用进入时,根据现在与下一可用时刻的差补算积累,并按最大容量截断。这个实现解释了为什么只需要保存时间状态,也说明不能把它描述成一个每隔固定毫秒触发的定时器。SmoothRateLimiter.resync
tryAcquire 的超时约束
tryAcquire(permits, timeout, unit) 先检查最早可用时刻能否落进允许等待范围。若不满足,它立即返回 false,不会先睡完整 timeout 再报告失败;若满足,就完成预订并等待必要时长。负超时按零处理,但许可数仍必须为正。测试也确认 acquire(0) 被拒绝。
在上一节的三秒时刻,下一可用时间为 3.5 秒。允许等待 499 毫秒的申请失败,可控时钟仍为三秒;允许等待 500 毫秒的申请成功,时钟变为 3.5 秒。这个边界例子同时验证返回值、是否等待和时间推进,避免只测一个布尔值而漏掉副作用。
超时判断围绕本次申请可开始获得许可的时刻,不等价于对整个多许可工作量作完成期限保证。一个大请求在满足当前可用条件时被接受,仍可能为后续请求增加较长债务。业务若有“整批必须在某时刻前全部发送”的要求,还需在应用层建立批次调度和截止时间模型。
返回 false 后的策略也不由 RateLimiter 提供。商品同步可选择稍后重试、放入持久队列或拒绝请求;无条件立即循环重试会浪费 CPU,甚至让调用者难以观察限流失败。测试不实现重试器,只证明成功与失败边界,避免把重试间隔误算成限流器内部保证。
warmup 给已存许可不同的时间价格
带 warmupPeriod 的实现不是简单地“前四秒暂停,之后全速”。它把冷态的已存许可映射为更大的时间代价,随着许可消耗逐渐接近稳定间隔。源码中需要理解 thresholdPermits、maxPermits、coldIntervalMicros 与斜率,而不仅是看构造参数的时间单位。
本章使用每秒两个许可、预热期四秒、默认冷因子三。稳定间隔为 0.5 秒,最冷间隔为 1.5 秒;阈值许可数为 4,最大许可数为 8。高于阈值的部分,时间价格随已存许可量线性变化;一次消费跨过一段斜线时,其代价按梯形面积计算。SmoothWarmingUp 源码
最初冷态有八个已存许可。消耗第一个许可对应区间两端的时间价格为 1.5 秒和 1.25 秒,平均值为 1.375 秒;这笔成本影响下一次调用。随后三个许可依次产生 1.125、0.875、0.625 秒成本,再进入每许可 0.5 秒的稳定部分。模型实际 acquire 返回的等待序列是 [0, 1.375, 1.125, 0.875, 0.625, 0.5]。
第一个零等待再次体现“当前可用时刻”与“当前许可给以后留下的成本”不同。预热配置并不意味着第一次调用一定等待;如果监控只看第一个请求,就会误判预热未生效。应观测连续请求的等待序列,并将空闲后的恢复路径与稳定高负载路径分开。
冷却也通过时间状态与已存许可补算实现。一个长期空闲的预热限流器,后续请求会重新经历较冷的成本区间;它不能用来表达一次性、永不回退的服务启动进度。若下游有明确的启动状态或健康检查,RateLimiter 不会自动读取那些信号,预热期只是本地时间模型参数。
模型时钟和真实时间各证明什么
确定性测试放在 Guava 相同 package 下,以便使用测试可见的 SleepingStopwatch 注入点。自定义 Clock 在“等待”时只推进微秒计数,不发生操作系统睡眠。这允许准确检查预订、突发和预热公式,但依赖固定版本的非公开接口,只应留在实验中,不能作为生产代码跨版本稳定扩展点。
真实观测则调用公开 RateLimiter.create(20.0)。先申请一个许可,再测量 acquire(3) 从调用到返回的纳秒数,同时记录 acquire 返回的计划等待秒数。Java 8 本次计划等待 0.049990 秒,实际 54,240,125 纳秒,差值约 4.250 毫秒;Java 21 本次计划等待 0.049994 秒,实际 60,071,792 纳秒,差值约 10.078 毫秒。
这两次运行不能用于判断哪个 JDK 的限流性能更好。调度、计时开销、机器负载都会影响实际等待;样本也只有各一次。结果只显示模型计划值与实际经历时间并不完全相等。测试不把最大误差写成固定平台承诺,也没有隐藏原始纳秒输出。再次运行应以新的 stdout 为准。
测量使用单调时间来源 System.nanoTime 的差值,避免把墙上时钟调整混入等待长度。它测的是调用跨度,既包括睡眠,也包括同步、调度和方法开销;因此不能把差值简单命名为“操作系统睡眠误差”。内部时间以微秒计算,还存在取整,精度层次也必须分开解释。
测试的下界仅保留十毫秒容差,目的是发现明显违反计划等待的结果而不制造纳秒精确承诺。它没有给出严格上界,因为外部调度暂停可以让线程更晚恢复。若生产系统需要控制延迟分位数,应在实际负载下测量完整链路,不能从这两个教学样本推导服务容量。
并发申请和 Semaphore
并发实验让三个真实线程同时从同一个 RateLimiter 申请许可。申请成功后,三个线程都停在完成 latch 前。测试确认三个线程全部通过许可申请,才释放它们结束任务。这表明 RateLimiter 不会因为先前任务仍未结束就禁止新的任务开始。
两个 JDK 的实际返回等待序列各包含一个接近零、一个接近五十毫秒、一个接近一百毫秒的值,但它们与任务提交顺序的对应不一致。这是观测结果,不是公平性保证。API 明确不承诺公平;不能把线程先到达测试入口的次序当作取得许可的稳定次序。
Semaphore 对照采用两个许可:前两次 tryAcquire 成功,第三次失败,直到执行 release。它限制的是未归还许可数量。若每项任务开始前申请、结束后在 finally 归还,就能表达同时执行数量上限;RateLimiter 没有对应的任务完成 release 过程。Semaphore 文档
1 | |
某些下游同时需要每秒速率和最大并发数,两种限制可以同时存在,但申请顺序会影响资源占用。先拿并发槽再等速率,等待期间会占用槽;先等速率再拿槽,则实际执行时刻可能进一步延迟。应按真正要约束的阶段设计,并测试取消或失败时是否正确释放已取得的资源。
RateLimiter 的 acquire 使用不可中断等待语义,不能把线程 interrupt 自动视为撤销已经预订的许可。若请求有严格取消要求,应特别审阅等待位置与业务超时策略。本章没有证明任意取消方式能回收许可债务,因此没有提供这种接口承诺;取消任务与重置共享限流器也不是一回事。
单实例边界与业务配置
一个 RateLimiter 对象只维护自己的状态。两个进程各配每秒十个许可,不会自动共享成全局十个;同进程创建两份对象也没有共同配额。全局配额需要额外协调机制,本文的本地实验不承担分布式一致性证明。
许可单位也不一定是请求数。一次导入可能按商品条数或发送字节数申请多个许可,这时每秒的配置单位也应相应改变。把计费大小向下取整可能让小请求消耗零许可,而 API 又拒绝非正申请;单位换算、最小收费单位及溢出应由调用方在进入限流器前解决。
流量突发有时是可接受的,例如吞吐型后台导入;对脆弱下游,突发可能恰好是需要阻止的行为。不能仅因为长期平均速率正确就跳过短时间并发或批量成本验收。应把下游的真正约束写成明确的窗口、并发上限或批次额度,再判断默认实现是否匹配。
运行中调整速率也不等于清空全部历史等待。本章没有加入动态 setRate 的实验,因此不对调整后的每个过渡时刻给出保证;若配置中心会动态修改该值,应追加已有债务、空闲积累与并发修改的定向回归。静态参数下通过测试,不代表热更新路径已验收。
下面的独立示例记录一次真实等待。输出的 planned 是库返回的计划睡眠秒数,elapsed 是调用前后测得的时间;调度开销会使二者不同。生产代码不应使用固定误差阈值推断系统是否精确执行了某个请求配额。
1 | |
复现实验与练习
完整测试放在 `src/test/java/com/google/common/util/concurrent/`,不可放进其他章的 blog.libraries 路径。运行说明说明模型入口与真实时间的区别。两个 JDK 均完成 4 项测试,失败、错误、跳过均为零;所有线程池在 finally 释放 latch 并关闭。手算题:每秒两个许可、初始无债务,先 acquire(10),再立即 tryAcquire(1, 1秒)。第一次等待多少,第二次是否成功?第一次可立即返回,但留下五秒债务;第二次允许的等待不足,返回 false。不能用“十个许可应当先等五秒”替代固定实现的预订顺序。
改动练习:把突发实验的空闲时间从三秒改成半秒,逐次记录已存许可与 nextFreeTicketMicros,再验证 acquire(2) 和后续两次单许可的等待。随后只改变业务处理时间,观察执行过程本身如何消化部分债务;不要把模拟时间推进与真实线程 sleep 混进同一张精确公式表。
| 可迁移做法 | 适用边界 |
|---|---|
| 分开计算本次等待与新增未来债务 | 多许可请求、突发和预热行为 |
| 将速率、并发数量与完成期限分别验收 | 下游同时受多个资源约束 |
