《Java 消息中间件·可靠消息与消费治理》13.1 多租户与配额

客户端配额让吵闹的邻居变慢,而不是被踢掉。本篇在本机单节点上把 producer_byte_rate 配成 1024 B/s,用同一个 Java 生产者跑配额前后两组对照:385 ms 变成 15943 ms,客户端 produce-throttle-time 平均值 782 ms,16 条消息全部 ack、没有一条被拒;再给出按 client-id、按 user、按前缀与物理隔离的多租户方案。

一个租户可以拖慢所有人

一个 Kafka 集群通常不是给一个团队用的。借阅服务、库存服务、推荐服务、数据分析任务共享同一批 broker,谁的写入突然放大,谁就会占掉网络、页缓存和磁盘 IO。问题不在于「谁对谁错」,而在于共享资源没有边界。

多租户要解决三件事,顺序不能反:

  1. 限速:单个租户的流量不能超过约定值。
  2. 隔离:一个租户过载时,其他租户的延迟不被拖垮。
  3. 归责:出问题时能按租户把指标切开,知道是谁。

配额(quota)只能解决第一件和第三件的一部分。它不会拒绝请求,也不会把租户赶到别的 broker 上;它的语义是「你超了,我让你等」。这一点必须先说清楚,否则后面所有配置都会被误读成限流熔断。

配额的三个维度

Kafka 的客户端配额有三个可调维度:

维度配置项单位约束的是什么
生产字节速率producer_byte_rate字节/秒客户端写入的未压缩字节
消费字节速率consumer_byte_rate字节/秒客户端读取的字节
请求占比request_percentage百分比(0–100 之外也接受)客户端占用的请求处理线程时间

此外还有 controller_mutation_rate(4.x)和 connection_creation_rate,分别约束元数据变更与建连速率,平台治理会用,业务团队一般碰不到。

配额的作用对象有两种:

  • client-id:由客户端自己声明,ProducerConfig.CLIENT_ID_CONFIG 或 client.id。它可以被伪造,适合「同一个应用内区分不同模块」。
  • user:SASL 认证后的 principal,客户端改不了。适合真正的租户边界,前提是开了认证(13.2)。

两者还可以组合成 (user, client-id) 的精确配额。匹配顺序上,组合配置优先于单一维度;没配的那一档落到默认配额(quota.producer.default 等)。这意味着给某个 client-id 单独调大配额,不会影响同一 user 下的其他 client-id,但前提是那个 client-id 没有从更宽的 user 配额继承限制。

超限是降速,不是拒绝

这是本节最重要的一句话。达到配额后:

  • broker 不会返回错误码,消息不会进死信,连接不会断。
  • broker 在响应里带一个 throttle_time_ms,客户端看到后自行暂停向该 broker 发请求。
  • 生产者的 send() 仍然是成功的,只是 get() 返回得晚;消费者的 poll() 变慢。
  • 对于无法在响应里表达的场景,broker 还会直接静默(mute)这个连接一段时间。

所以配额对上层表现为延迟上升,不是错误率上升。监控如果只看错误率,配额打满时一切正常;要看 produce-throttle-time、端到端延迟和吞吐,才看得出被限速。

还有一个容易被忽略的边界:配额在请求处理之后才生效(响应被延迟),所以一条超大消息不会被配额切碎,它会一次性写进去,然后让这个客户端在下一个窗口里还债。配额不是消息大小上限,max.request.size / message.max.bytes 才是。

本机实测:把 1 KB/s 配额压到一个 client-id 上

环境:Temurin 25.0.5+8 / kafka-clients 4.3.0 / apache/kafka:4.3.0 KRaft 单节点,宿主端口 39192。实验程序 QuotaLab 用 client.id 指定身份,向单分区主题 library.loan.created 同步发送 16 条 1024 字节的消息,每条都 acks=all 并等待 RecordMetadata,最后从 producer.metrics() 里读 throttle 指标。

启动 broker 的命令与卷一相同,只把 EXTERNAL 端口换成 39192,这里不再重复。建主题:

docker exec mq-rel13-quota sh -c 'unset JMX_PORT; \
  /opt/kafka/bin/kafka-topics.sh --bootstrap-server 127.0.0.1:9092 \
  --create --topic library.loan.created --partitions 1 --replication-factor 1'

第一组:不配配额

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
CP="$HOME/.m2/repository/org/apache/kafka/kafka-clients/4.3.0/kafka-clients-4.3.0.jar:\
$HOME/.m2/repository/org/slf4j/slf4j-api/2.0.20/slf4j-api-2.0.20.jar:\
$HOME/.m2/repository/org/slf4j/slf4j-simple/2.0.20/slf4j-simple-2.0.20.jar"

$JAVA_HOME/bin/java -cp "out:$CP" QuotaLab 127.0.0.1:39192 quota-lab 16 1024

本机实测(单次运行;容器内 4 vCPU,/proc/loadavg 为 0.40/0.84/0.97;数字随调度波动,不作基准):

[send 00] partition=0 offset=0 latency=317 ms
[send 01] partition=0 offset=1 latency=5 ms
...
[send 15] partition=0 offset=15 latency=3 ms
[result] records=16 payload_bytes=1024 elapsed_ms=385
[result] client_side_records_per_sec=41.56
[metric] produce-throttle-time-avg=0.0
[metric] produce-throttle-time-max=0.0
[metric] record-send-total=16.0

第一条 317 ms 是建连与元数据,后面每条 3–5 ms。16 条约 17 KB,385 ms 内发完。

第二组:给同一个 client-id 配 1024 B/s

docker exec mq-rel13-quota sh -c 'unset JMX_PORT; \
  /opt/kafka/bin/kafka-configs.sh --bootstrap-server 127.0.0.1:9092 \
  --alter --entity-type clients --entity-name quota-lab \
  --add-config producer_byte_rate=1024'

输出与确认:

Completed updating config for client quota-lab.
Quota configs for client-id 'quota-lab' are producer_byte_rate=1024.0

不改任何客户端代码,只改 client.id 对应的服务端配置,再跑一次:

[send 09] partition=0 offset=25 latency=85 ms
[send 10] partition=0 offset=26 latency=1128 ms
[send 11] partition=0 offset=27 latency=2114 ms
[send 12] partition=0 offset=28 latency=3143 ms
[send 13] partition=0 offset=29 latency=4110 ms
[send 14] partition=0 offset=30 latency=5120 ms
[send 15] partition=0 offset=31 latency=3 ms
[result] records=16 payload_bytes=1024 elapsed_ms=15943
[result] client_side_records_per_sec=1.00
[metric] produce-throttle-time-avg=782.15
[metric] produce-throttle-time-max=5114.0
[metric] record-send-total=16.0

三件事同时发生:

  1. 总耗时从 385 ms 变成 15943 ms,约 41 倍。
  2. 客户端吞吐从 41.56 条/秒降到 1.00 条/秒,正好对上 1024 B/s ÷ 1024 B。
  3. 16 条全部 ack,offset 从 16 连续到 31,没有一条失败、没有一条进重试。

第 10–14 条的延迟以约 1 秒为步长递增,是典型的配额静默:客户端每发一条就被要求等一个窗口的额度,未还完的债累加到下一次。最后一条只等 3 ms,因为前面的等待已经把窗口内的超额抹平。

第三组:换个 client-id,同一时刻不受影响

配额绑定的是 client-id,不是主题,也不是 broker:

[send 15] partition=0 offset=47 latency=3 ms
[result] records=16 payload_bytes=1024 elapsed_ms=265
[result] client_side_records_per_sec=60.38
[metric] produce-throttle-time-avg=0.0
[metric] produce-throttle-time-max=0.0

同一个 broker、同一个主题、几乎同一时间,other-lab 265 ms 发完,throttle 为 0。这说明配额的作用域是身份,不是资源。反过来也成立:如果你给一个主题限速,却让所有客户端共用 client.id,那限的就是「所有用这个 id 的进程加起来」。

指标名在 4.3.0 里变了

网上大量文章让查 kafka.producer:type=producer-metrics,client-id=... 下的 throttle-time。在 4.3.0 的源码里这个名字已经不在生产者侧了:

// Sender.java:967
public static Sensor throttleTimeSensor(SenderMetricsRegistry metrics) {
    Sensor produceThrottleTimeSensor = metrics.sensor("produce-throttle-time");
    produceThrottleTimeSensor.add(metrics.produceThrottleTimeAvg, new Avg());
    produceThrottleTimeSensor.add(metrics.produceThrottleTimeMax, new Max());
    return produceThrottleTimeSensor;
}
// SenderMetricsRegistry.java:123
this.produceThrottleTimeAvg = createMetricName("produce-throttle-time-avg",
        "The average time in ms a request was throttled by a broker");
this.produceThrottleTimeMax = createMetricName("produce-throttle-time-max",
        "The maximum time in ms a request was throttled by a broker");

所以看板里要写 produce-throttle-time-avg。这也是本书反复强调「版本相关断言必须当场验证」的原因:同一个指标,换一个大版本就可能查不到。

默认配额与继承

配额不是「配了才有」。没有单独配置的客户端会落到 broker 的默认档;默认档通常给得很宽,目的是不误伤正常业务。本机第一组实验就是证据:同一个 broker、同一个主题,没配额的 client-id 16 条 385 ms 发完,produce-throttle-time-avg=0.0,看不出任何限制。

也就是说,默认档不构成租户边界。想给一整个 user 下的所有 client-id 一个总闸,就配 user 配额:

docker exec mq-rel13-quota sh -c 'unset JMX_PORT; \
  /opt/kafka/bin/kafka-configs.sh --bootstrap-server 127.0.0.1:9092 \
  --alter --entity-type users --entity-name libraryd \
  --add-config producer_byte_rate=8192,consumer_byte_rate=16384'

注意 --add-config 的多个键值要写在同一个字符串里,用逗号连接,不要拆成两个 --add-config:4.3.0 的 CLI 只接受一个这种参数,拆开会报参数重复。这条在卷一的工具章节提过一次,配配额时最容易踩。

优先级可以记成三层:(user, client-id) 组合 > 单一维度 > quota.*.default。给某个租户临时扩容时,优先改组合配额,不要动 user 配额,后者会连带放大该 user 下所有服务。

配额管不到的地方

把配额当成多租户的全部,是设计上最常见的误判。它至少有五个管不到的地方:

场景配额能不能管说明
单条超大消息不能请求先被处理,配额只延迟响应;大小上限靠 max.request.size
磁盘被写满不能要靠 retention、配额主题容量、独立的 log dir
消费延迟被拖垮部分能需要同时配 consumer_byte_rate,否则读不设限
故障扩散不能一个租户打满页缓存,其他租户照样抖;要物理隔离
配额本身被绕过不能client-id 可伪造;真正的租户边界要用 SASL user

前面三条可以靠配置补齐,后两条只能靠架构。配额是最后一道闸,不是隔离墙。

多租户隔离的五种做法

按成本从低到高排:

一、约定 client-id 前缀 + 一组配额。 例如 loan-svc-producer、inventory-svc-producer,每个前缀一条 quota 配置。落地最快,但只能防「无意打爆」,防不了「故意改 client.id」。

二、按 SASL user 配配额。 认证之后 principal 由 broker 认定,客户端改不了。需要先做 13.2 的认证,配额才有意义。这是大多数平台的起点。

三、逻辑隔离:主题前缀 + 独立消费组 + ACL。 租户 A 只能写 tenantA. 前缀的主题,只能读自己的组。它隔离的是权限与命名,不是资源。

四、物理隔离:独立集群或独立 broker 池。 大租户单独一套 broker,配额只作为兜底。成本最高,但只有它能隔离尾延迟和故障。

五、入口层限流。 在应用侧用令牌桶先削峰,broker 配额做兜底。两层都要有:应用层能给出友好的降级,broker 层能防住应用层失效。

本书的建议是二 + 三起步,四按租户等级升级。只做一,等于把边界交给客户端自觉。

配额配置的两个操作细节

第一,配额按 broker 计。三节点集群里,每个 broker 都独立执行同一条 producer_byte_rate;分区分散在三个 broker 上时,客户端的总吞吐上限接近三倍。做容量规划时不能用「单 broker 配额 × 1」。本机是单节点,所以上面测到的就是集群上限。

第二,改配额是动态的,不需要重启。kafka-configs.sh --alter 写进元数据后立即生效,已有连接也会被重新计费。但客户端侧的 produce-throttle-time 是滑动统计,改完立刻看指标可能还是旧值,等一个统计窗口再看。

另外,删除配额用 --delete-config producer_byte_rate,不要用 --add-config producer_byte_rate=-1:负值不是「取消」,含义在不同版本里并不一致。

三个常见误配

一是给主题限速。 Kafka 的客户端配额没有「按主题」这一维度。想限制某个主题的总写入量,只能限制写它的那些 client-id,或者用 ACL 把主题收归一个专用账号,再给账号配额。

二是用 request_percentage 代替字节速率。 它约束的是请求处理线程时间,对大消息、小消息混跑的负载不线性;一个租户用小消息刷请求数,字节速率没超,request_percentage 先打满。两种都要配。

三是消费配额配得比生产小一个数量级。 消费端要读的字节包括所有订阅主题,配小了会让 poll() 变慢、lag 上升,看起来像消费者性能问题,实际是配额。排查时先看 consumer_byte_rate 有没有被改过。

落地清单

  1. 先定身份,再谈配额:client.id 由平台统一分配,格式固定为 <租户>-<服务>-<角色>。
  2. 生产和消费都配:只配 producer_byte_rate,消费端仍可以把 broker 读垮。
  3. 配额值从实测基线来:先跑一周的 p99 字节速率,再乘 1.5–2 留余量;拍脑袋的值只会逼业务改 client.id。
  4. 监控三件套:produce-throttle-time-avg、端到端 p99、按 client-id 切分的字节速率。
  5. 配额变更走审批:它是容量承诺,不是调试开关。
  6. 大租户直接上物理隔离:配额救不了尾延迟。

本节判据

  1. 超过配额时 broker 返回什么? 答:不返回错误,返回 throttle_time_ms 让客户端自行等待;消息仍然成功写入。
  2. producer_byte_rate 的作用域是什么? 答:按 (user, client-id) 维度、在每个 broker 上独立生效,不是整个集群一个总额。
  3. 为什么给 client-id 配了配额,另一个 client-id 不受影响? 答:配额绑定身份,不绑定主题或 broker;本机 other-lab 在同一时刻仍是 265 ms 发完、throttle 为 0。
  4. 配额能防止一条超大消息吗? 答:不能。请求先被处理再延迟响应,大小要由 max.request.size 和 message.max.bytes 约束。
  5. 多租户只做 client-id 配额,最大的缺口是什么? 答:client-id 可伪造,且配额不隔离故障与尾延迟;真正的边界要靠 SASL user、ACL 与物理隔离。

阅读导航:上一节 12.3 消费端性能调优 | 下一节 13.2 SASL/ACL 与传输加密

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收