feat(t9): 按租户令牌桶限流(出站腾讯API配额保护,对齐设计3.2)
- RateLimiter(新): Redis+Lua 原子令牌桶,租户桶(quota_im_qps)+全局桶(globalImQps), 任一超限抛 BizException(429);Redis 异常 fail-open 放行 - RedisService: +eval(Lua) 通用脚本执行(令牌桶复合操作需原子) - TencentImClient.callApi: 开头 checkApiLimit,resolveTenantQps 查 quota_im_qps(Caffeine 60s) - 联调:sa quota=2,8 并发→2 放行+6 限流,被拒不调腾讯(api_call_log 仅 2 条) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
|||||||
|
package com.imutil.common;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.noear.solon.annotation.Component;
|
||||||
|
import org.noear.solon.annotation.Inject;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按租户令牌桶限流(出站腾讯 API 配额保护,对齐设计 3.2)
|
||||||
|
* <p>
|
||||||
|
* 合用 SDKAppID 后所有租户瓜分腾讯侧共享 QPS,本工具作为唯一出站入口,
|
||||||
|
* 在调腾讯 API 前做两层令牌桶限流:
|
||||||
|
* 1. 租户桶(tenant.quota_im_qps,缺省 defaultImQps)—— 防单租户突发;
|
||||||
|
* 2. 全局桶(globalImQps,腾讯限制的 80%)—— 兜底共享配额。
|
||||||
|
* 任一桶令牌耗尽即拒绝(抛 {@link BizException} 429)。
|
||||||
|
* <p>
|
||||||
|
* 令牌桶用 Redis + Lua 原子实现,多节点一致;Redis 异常时 fail-open(放行,避免拖垮业务)。
|
||||||
|
* 容量=填充速率=QPS,即严格每秒 QPS 次(不为突发额外放大容量,以稳守腾讯配额)。
|
||||||
|
*
|
||||||
|
* @author imutil
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class RateLimiter {
|
||||||
|
|
||||||
|
/** 令牌桶 Lua:按时间填充令牌、扣减,返回 1=放行 0=超限 */
|
||||||
|
private static final String TOKEN_BUCKET_LUA =
|
||||||
|
"local key = KEYS[1] " +
|
||||||
|
"local capacity = tonumber(ARGV[1]) " +
|
||||||
|
"local rate = tonumber(ARGV[2]) " +
|
||||||
|
"local now = tonumber(ARGV[3]) " +
|
||||||
|
"local requested = tonumber(ARGV[4]) " +
|
||||||
|
"local tokens = tonumber(redis.call('HGET', key, 'tokens')) " +
|
||||||
|
"local last = tonumber(redis.call('HGET', key, 'last')) " +
|
||||||
|
"if tokens == nil then tokens = capacity end " +
|
||||||
|
"if last == nil then last = now end " +
|
||||||
|
"local delta = math.max(0, now - last) / 1000.0 * rate " +
|
||||||
|
"tokens = math.min(capacity, tokens + delta) " +
|
||||||
|
"local allowed = 0 " +
|
||||||
|
"if tokens >= requested then tokens = tokens - requested; allowed = 1 end " +
|
||||||
|
"redis.call('HMSET', key, 'tokens', tokens, 'last', now) " +
|
||||||
|
"redis.call('EXPIRE', key, math.ceil(capacity / rate) + 60) " +
|
||||||
|
"return allowed";
|
||||||
|
|
||||||
|
@Inject("${imutil.ratelimit.globalImQps:200}")
|
||||||
|
private int globalImQps;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private RedisService redisService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 出站腾讯 API 限流检查:租户桶 + 全局桶,任一超限抛 BizException(429)
|
||||||
|
*
|
||||||
|
* @param tenantId 当前租户(无上下文为 system)
|
||||||
|
* @param tenantQps 该租户配额(QPS),由调用方解析(tenant.quota_im_qps 或默认)
|
||||||
|
*/
|
||||||
|
public void checkApiLimit(String tenantId, int tenantQps) {
|
||||||
|
// 1. 租户桶:防单租户突发打爆共享配额
|
||||||
|
if (!tryAcquire("rl:tenant:" + tenantId, tenantQps)) {
|
||||||
|
log.warn("租户IM API配额超限 tenant={} qps={}", tenantId, tenantQps);
|
||||||
|
throw new BizException(429, "租户[" + tenantId + "] IM API 配额超限,请稍后重试");
|
||||||
|
}
|
||||||
|
// 2. 全局桶:兜底共享 SDKAppID 总配额(腾讯限制的 80%)
|
||||||
|
if (!tryAcquire("rl:global", globalImQps)) {
|
||||||
|
log.warn("全局IM API配额超限 globalQps={}", globalImQps);
|
||||||
|
throw new BizException(429, "全局 IM API 配额超限,请稍后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 令牌桶尝试获取 1 个令牌(capacity=rate=qps,严格每秒 qps 次)
|
||||||
|
* <p>
|
||||||
|
* Redis 异常时 fail-open 放行:限流是保护措施,不可因 Redis 故障拖垮业务调用。
|
||||||
|
*
|
||||||
|
* @param key 桶键
|
||||||
|
* @param qps 每秒允许次数(容量与填充速率均取此值);<=0 表示不限流
|
||||||
|
* @return true=放行 false=超限
|
||||||
|
*/
|
||||||
|
private boolean tryAcquire(String key, int qps) {
|
||||||
|
if (qps <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
Object r = redisService.eval(TOKEN_BUCKET_LUA,
|
||||||
|
List.of(key),
|
||||||
|
List.of(String.valueOf(qps), String.valueOf(qps), String.valueOf(now), "1"));
|
||||||
|
// Lua 返回 number,Jedis 转 Long
|
||||||
|
long allowed = (r instanceof Number) ? ((Number) r).longValue() : Long.parseLong(String.valueOf(r));
|
||||||
|
return allowed == 1L;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("限流Redis异常,fail-open放行 key={} : {}", key, e.getMessage());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,4 +139,18 @@ public class RedisService {
|
|||||||
return j.ttl(key);
|
return j.ttl(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 Lua 脚本(原子操作,用于令牌桶限流等复合场景)
|
||||||
|
*
|
||||||
|
* @param script Lua 脚本
|
||||||
|
* @param keys KEYS 列表
|
||||||
|
* @param args ARGV 列表
|
||||||
|
* @return 脚本返回值(Jedis 按类型转 Long/String/List)
|
||||||
|
*/
|
||||||
|
public Object eval(String script, java.util.List<String> keys, java.util.List<String> args) {
|
||||||
|
try (Jedis j = jedisPool.getResource()) {
|
||||||
|
return j.eval(script, keys, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ package com.imutil.tencent;
|
|||||||
|
|
||||||
import com.imutil.common.Httpx;
|
import com.imutil.common.Httpx;
|
||||||
import com.imutil.common.Jsons;
|
import com.imutil.common.Jsons;
|
||||||
|
import com.imutil.common.LocalCache;
|
||||||
|
import com.imutil.common.RateLimiter;
|
||||||
import com.imutil.common.TenantContext;
|
import com.imutil.common.TenantContext;
|
||||||
import com.imutil.entity.ApiCallLog;
|
import com.imutil.entity.ApiCallLog;
|
||||||
|
import com.imutil.entity.Tenant;
|
||||||
import com.imutil.mapper.ApiCallLogMapper;
|
import com.imutil.mapper.ApiCallLogMapper;
|
||||||
|
import com.imutil.service.TenantService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.noear.snack4.ONode;
|
import org.noear.snack4.ONode;
|
||||||
import org.noear.solon.annotation.Component;
|
import org.noear.solon.annotation.Component;
|
||||||
@@ -49,6 +53,18 @@ public class TencentImClient {
|
|||||||
@Inject
|
@Inject
|
||||||
private ApiCallLogMapper apiCallLogMapper;
|
private ApiCallLogMapper apiCallLogMapper;
|
||||||
|
|
||||||
|
@Inject("${imutil.ratelimit.defaultImQps:50}")
|
||||||
|
private int defaultImQps;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private RateLimiter rateLimiter;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private TenantService tenantService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private LocalCache localCache;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成管理员 UserSig(长效,用于调后台 API)
|
* 生成管理员 UserSig(长效,用于调后台 API)
|
||||||
*/
|
*/
|
||||||
@@ -68,6 +84,10 @@ public class TencentImClient {
|
|||||||
String tenantId = TenantContext.get();
|
String tenantId = TenantContext.get();
|
||||||
String tid = (tenantId == null || tenantId.isEmpty()) ? "system" : tenantId;
|
String tid = (tenantId == null || tenantId.isEmpty()) ? "system" : tenantId;
|
||||||
|
|
||||||
|
// 出站腾讯 API 限流:按租户配额 + 全局共享配额(对齐设计 3.2),超限抛 429
|
||||||
|
// system(无租户上下文)用默认配额;补拉等后台任务超限由调用方 catch 跳过本轮
|
||||||
|
rateLimiter.checkApiLimit(tid, resolveTenantQps(tid));
|
||||||
|
|
||||||
String result;
|
String result;
|
||||||
try {
|
try {
|
||||||
String adminSig = genAdminSig();
|
String adminSig = genAdminSig();
|
||||||
@@ -110,6 +130,39 @@ public class TencentImClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析租户 IM API 配额:tenant.quota_im_qps,缺省 defaultImQps;本地缓存 60s
|
||||||
|
* <p>
|
||||||
|
* 配额变更(管理后台改 quota_im_qps)最长 60s 生效;system(无租户上下文)用默认配额。
|
||||||
|
*
|
||||||
|
* @param tid 租户ID
|
||||||
|
* @return 该租户 IM API QPS 配额
|
||||||
|
*/
|
||||||
|
private int resolveTenantQps(String tid) {
|
||||||
|
if (tid == null || tid.isEmpty() || "system".equals(tid)) {
|
||||||
|
return defaultImQps;
|
||||||
|
}
|
||||||
|
String cacheKey = "tenantQps:" + tid;
|
||||||
|
String cached = localCache.get(cacheKey);
|
||||||
|
if (cached != null) {
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(cached);
|
||||||
|
} catch (NumberFormatException ignore) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int qps = defaultImQps;
|
||||||
|
try {
|
||||||
|
Tenant t = tenantService.getById(tid);
|
||||||
|
if (t != null && t.getQuotaImQps() != null && t.getQuotaImQps() > 0) {
|
||||||
|
qps = t.getQuotaImQps();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("查询租户配额失败 tenant={},用默认 {} : {}", tid, defaultImQps, e.getMessage());
|
||||||
|
}
|
||||||
|
localCache.put(cacheKey, String.valueOf(qps));
|
||||||
|
return qps;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导入账号(创建 IM 用户,幂等:已存在亦返回 OK)
|
* 导入账号(创建 IM 用户,幂等:已存在亦返回 OK)
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user