Little 定律与容量估算:从在途请求算到资源预算
一个稳定运行的接口每秒完成 200 次请求,每次在应用内平均停留 100 ms,那么应用内平均约有 20 个请求。它们可能正在计算,也可能等待数据库连接。这个数字首先描述在途工作量,线程数和连接数还需要继续拆解。
Little 定律把吞吐、驻留时间和平均数量联系起来。使用它做容量估算,最重要的工作是选定同一批对象的进入点和离开点,再决定哪些资源占用可以从这些测量中推算。
平均数量、吞吐与驻留时间的关系
先画出计数范围
用 L = λ × W 表示 Little 定律:
| 符号 | 含义 | 单位 |
|---|---|---|
| L | 范围内的时间平均对象数 | 个 |
| λ | 长期平均进入率;稳定无积压增长时也等于离开率 | 个/秒 |
| W | 每个对象在该范围内的平均驻留时间 | 秒 |
200 请求/秒 × 0.1 秒 = 20 请求,单位可以直接检查。把毫秒当成秒会产生一千倍误差;将一个数据库查询的耗时与 HTTP 请求率相乘,则混淆了工作单元。
同一条链路可以选不同范围:
HTTP 请求进入应用 ───────────────────── 完整离开应用
W_http
等待借连接 ─── 获取连接 ────── 归还连接
W_wait W_hold
SQL 发出 ── 结果读完
W_sqlHTTP 在途数使用请求进入率和 W_http;平均借出连接数使用借用次数率和 W_hold;平均等连接人数使用等待区域的进入率和等待时长。一次请求借三次连接、查询十次时,要分别统计每个范围的访问次数。连接已经拿到但仍在组装响应,也会继续占用连接。
没有等连接的借用可以按等待零秒纳入全部借用次数;若只统计真正进入等待区的借用,就必须同时使用等待者到达率。把“所有借用的速率”和“仅等待者的平均等待时间”配对会高估等待人数。
Little 定律不要求泊松到达、指数服务时间或先进先出,这些是某些具体排队模型的额外假设。用于长期平均关系时,需要相关平均值存在且有限;持续无限增长的积压无法给出一个稳定的有限 L。样本路径与面积推导可查 Columbia University 的 Little 定律讲义。
时间平均与请求平均如何连接
某个请求停留 100 ms,相当于在时间线上贡献一个“高度为1、宽度为100 ms”的矩形。将所有请求矩形叠起来,高度就是当时的在途数 N(t)。于是,在空系统开始、所有工作离开后结束的一轮实验中:
在途数量曲线的面积 A = 所有请求驻留时间之和 ΣWi
时间平均在途数 L = A / T
该窗口完成率 X = n / T
请求平均驻留时间 W = ΣWi / n
所以 L = X × W这是有限窗口内的记账恒等式。即使整轮是一次突发、没有达到稳态,只要窗口从空到空、每个请求完整计入,上式仍成立。它验证的是统计对象一致,而不是系统能够长期承受同样的到达率。
线上窗口通常从繁忙时刻开始,也在繁忙时刻结束。此时,窗口开始前已经存在的请求会贡献部分面积;结束时尚未完成的请求也已经贡献面积。只用窗口内完成请求的完整耗时,会与时间平均在途数出现边缘偏差。扩大窗口可以降低部分边缘效应,但不能修复计数遗漏或持续过载。
平均值不能直接变成并发上限
L=20 表示时间平均有20个请求。突发期间可能有80个,低谷时可能只有2个。若把并发上限直接设为20,限制器可能在大量正常波动期间拒绝请求;若无限放大上限,排队又会耗尽内存或超过调用期限。
同样,λ×p99 不是 Little 定律给出的“p99并发”。分位数与请求数的联合变化涉及到达过程、相关性和重叠区间,不能由两个单独统计量相乘得到。分布与负载模型的基础见延迟、吞吐、分位数与排队。
从工作量推算资源与扩容规模
每请求的资源需求
容量模型可以按资源分别建立。设每次业务请求平均消耗 D_cpu 个 CPU 秒,目标有效吞吐为 X,那么每秒需要的 CPU 时间约为 X×D_cpu。如果一次请求消耗 4 ms CPU、目标500次/秒,需要约2个CPU持续工作;按60%的目标利用率估算,名义预算约为 2/0.6=3.33 个CPU。
这是测量值固定时的线性估算。更大数据集、更高并发下的锁竞争、GC和缓存未命中,都可能改变 D_cpu。容器资源预算要使用可获得的CPU配额,并观察节流;宿主有16核而容器只允许1核,不能按16核计算。Docker 资源约束文档说明了内存和CPU限制方式。
连接占用用另一个例子计算:每秒500个请求,其中40%访问数据库,每次访问借两次连接,每次平均持有15 ms,则平均借出连接约为:
500 × 40% × 2 × 0.015 = 6 个连接连接池的最大值要同时考虑峰值等待、数据库可承受并发和全部应用实例的总连接数。平均借出6个,不能直接推出“配置6个足够”,也不能推出“配置60个更快”。连接持有时间的测量方法见线程池、连接池、IO与序列化。
内存也可以先估量级。若平均在途200个请求,每个保留100 KiB临时对象,只有这一部分就约占19.5 MiB。除此之外还有常驻缓存、堆余量、线程栈、直接内存、JIT代码和本地库。对象保留时间及大小通常存在长尾,应再检查峰值与堆外内存,不能把平均在途内存当成容器上限。
串行比例与瓶颈上限
扩大某一类资源,只能改善使用该资源的部分。如果一次请求有60%的时间能随CPU并行度线性加速,余下40%保持不变,理想的四倍并行加速上限约为 1/(0.4+0.6/4)=1.82 倍。这里采用的是Amdahl模型的简化条件;实际任务还会增加协调和通信成本。Cornell 并行计算课程资料给出了公式及适用条件。
更常见的约束是下游:数据库每秒只能完成800次目标查询,而每次业务请求平均查询2次,则其他条件不变时,业务吞吐上限约400次/秒。把应用实例翻倍,可能只会把更多连接和排队送入同一个数据库。
用请求类型分别建表,比用全站平均值可靠。读详情、批量导出和创建订单的CPU、连接、网络与写放大差异很大;流量结构从九成读变为一半写时,旧的“每实例RPS”不再代表相同工作量。
正常余量与失效余量分别计算
假设某实例在指定数据与配置下,满足延迟、错误率和资源条件的可用吞吐为400次/秒。目标峰值为900次/秒,希望失去一个实例后仍能承载这批流量,则最少需要满足:
(实例总数 - 1) × 400 ≥ 900
实例总数 ≥ 4400应取自真实负载下的可用工作点,而不是最后一次没有崩溃的极限成绩。若该工作点已包含利用率余量,不要不加说明再连续乘多个“安全系数”;需求增长、流量不均和节点失效应列为独立假设,便于追踪为什么需要更多资源。
上式还假设剩余节点能够接到流量、共享下游有余量、扩容或故障转移期间没有更大的突发。可用区故障可能一次失去一组实例,因此应按故障域重新代入。容量预留、负载拒绝与恢复的关系可查 Google SRE 过载处理。
自动扩容存在检测、调度、拉取、启动、预热和流量迁移时间。若一分钟内净流入比处理速度多300次/秒,扩容前就可能新增18,000个积压请求。能否暂存这些请求,要同时看队列内存、最老任务年龄和调用期限。
用真实执行器验证统计范围
测量16个任务的进入与离开
下载Little 定律实验包,解压并进入 little-law-lab。环境是Linux与Docker,宿主使用有Docker权限的普通用户;编译身份为宿主UID/GID,运行容器身份为10001。无需网络服务,也不访问生产数据。
mkdir -p classes
docker run --rm --user "$(id -u):$(id -g)" --network none \
-v "$PWD:/lab" -w /lab eclipse-temurin:25.0.4_7-jdk \
javac --release 17 -Xlint:all -Werror -d classes LittleLawLab.java
docker run --rm --user 10001:10001 --network none \
-v "$PWD/classes:/lab:ro" eclipse-temurin:25.0.4_7-jdk \
java -cp /lab LittleLawLab镜像要预先取得;同一源码也支持 eclipse-temurin:17.0.20_8-jdk。编译成功无错误,运行正常应退出0。目录不可写时修正宿主解压目录权限;若执行器未排空或任务异常,先检查异常,不继续解释性能数字。
程序向四线程、有限队列的执行器提交16个任务。闩锁在全部提交后释放,每个任务再持有60 ms。前四个任务占用worker时,后续任务留在队列中等待。执行器的worker与队列规则见 ThreadPoolExecutor API。
关键记录如下:
long entered = ledger.change(+1); // 进入执行器范围
pool.execute(() -> {
// 等待统一释放,并等待可用 worker
long started = System.nanoTime();
// 受控工作占用
long exited = ledger.change(-1);
jobs.add(new Job(entered, started, exited));
});这是计时位置的摘录;完整源码用 finally 保证离开事件被记录,并检查任务异常和执行器终止。所有在途事件在同一同步区取得时间,避免事件顺序与时间顺序不一致;计时源使用 System.nanoTime,只计算同一进程内的间隔。
程序用两条独立的记录计算面积:一条累积 在途数×相邻事件间隔,另一条累积每个任务的 exited-entered。一次Java25运行得到:
completed=16 drained=0 peak=16 window_s=0.2537
X=63.076/s W=0.153939s S=0.060256s L=9.709777 XW=9.709777 XS=3.800686
area_ns=2463020421 residence_sum_ns=2463020421
finite-window identity verified; no steady-state capacity claim不同机器会有不同的调度和耗时;应保持一致的是完成16个、排空为0、两个纳秒面积相等,以及L与XW在浮点误差内相等。peak=16 与平均约9.71同时成立,也展示了平均和峰值的差别。
故意只使用服务时间
保持同一个实际任务流程,要求程序用S代替W:
if OUTPUT=$(docker run --rm --user 10001:10001 --network none \
-v "$PWD/classes:/lab:ro" eclipse-temurin:25.0.4_7-jdk \
java -cp /lab LittleLawLab --wrong-boundary 2>&1); then
printf '%s\n' "$OUTPUT"
echo '预期失败却成功'; exit 1
else
printf '%s\n' "$OUTPUT"
case "$OUTPUT" in
*'wrong boundary: service time excludes the executor queue'*) ;;
*) echo '不是预期的统计范围错误'; exit 1 ;;
esac
fi负例仍完整执行16个任务,然后非零退出并指出 wrong boundary。X乘平均服务时间约为3.8,它近似描述忙碌worker数;全系统平均在途数还包含等待worker的任务,所以约为9.7。省略排队时间改变了测量对象,不能继续与原来的L比较。
这里使用空到空的短突发来演示面积关系。想用某个吞吐预测长期容量,还要在持续负载下验证请求分布、积压趋势、资源需求和目标延迟;这部分的实验组织见基线、压测与性能回归。
估算与实际运行不一致时怎样定位
L与λW差距很大
先将三个量的范围写到同一行:例如“借连接次数/秒、从借到还的平均秒数、时间平均借出连接数”。如果一个来自HTTP入口、一个来自SQL监控、另一个来自连接池,先修正口径,再研究系统行为。
接着检查采集方式。每秒采样一次的瞬时并发,可能错过毫秒级尖峰;带丢样的平均耗时可能只保留成功任务;进程重启会重置计数器。若窗口两端仍有大量工作,使用完整事件记录处理部分驻留时间,或扩大窗口并观察积压变化。取消与超时也要按各自范围的真实离开点统计。
连接数按公式够用,等待却很长
查持有时间分布与事务范围。某些请求在持有连接时等待远程HTTP,少数慢事务就能占住大量连接;平均15 ms可能掩盖少数几秒的占用。再检查流量是否同时集中到同一实例、同一租户或同一热点行。
扩大连接池前,观察数据库活动会话、锁等待、CPU和IO是否已有压力。如果下游已经饱和,增加借出连接只会扩展竞争人数。可以先缩短持有区间、降低访问次数或限制慢任务并发,再重新测量W与吞吐。
扩容以后吞吐没有按比例增长
重新测量各项资源需求。共享数据库、分片热点、远程限额和锁都可能成为新瓶颈;缓存冷启动与JIT预热会在短期内增加每请求成本。还要查看新增实例的实际请求量:流量没有按预期分配时,检查负载均衡和长连接复用。
队列持续增长时应先让进入率回到可处理范围。对于积压B、可用处理率μ、持续进入率λ,在 μ>λ 且工作量近似均匀时,粗略排空时间为 B/(μ-λ);如果两者几乎相等,排空会很慢。保护入口与小流量恢复的具体操作见限流、降级与容量保护。
权威资料与规范地址
- Columbia University,Notes on Little’s Law:https://www.columbia.edu/~ks20/stochastic-I/stochastic-I-LL.pdf
- Docker 资源约束:https://docs.docker.com/engine/containers/resource_constraints/
- Cornell,Amdahl's Law:https://cvw.cac.cornell.edu/parallel/efficiency/amdahls-law
- Google SRE,Handling Overload:https://sre.google/sre-book/handling-overload/
- Java ThreadPoolExecutor:https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/ThreadPoolExecutor.html
- Java System.nanoTime:https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/System.html#nanoTime()
