
如果你正在搭建一个直播平台,那么API接口的限流这件事,迟早是要面对的。我记得第一次线上事故就是因为没做好限流,一个主播直播间突然涌进来几万请求,服务器直接被打挂。那天晚上我们团队从凌晨两点一直搞到早上六点,累得够呛。从那以后,我就开始认真研究限流策略,把这块内容系统地梳理了一遍。
这篇文章我想从实际出发,用最接地气的方式聊聊直播api开放接口限流策略到底是怎么回事,代码怎么写才能既稳定又高效。中间会穿插一些我踩过的坑和经验教训,希望能给正在做这方面工作的朋友一些参考。
直播这个业务场景天然就带有流量洪峰的特点。一场带货直播可能同时有几十万甚至上百万人在线,礼物特效、弹幕互动、点赞刷屏这些操作会在短时间内产生海量的API请求。如果你没有做好限流设计,轻则影响用户体验,重则整个服务挂掉。
举个真实的例子。某次我们搞了一场头部主播的带货活动,开播五分钟之后,弹幕接口的QPS直接飙升到正常值的50倍。那时候我们还没上线限流机制,服务器CPU瞬间被打到100%,紧接着就是大量超时和报错。后来紧急扩容才勉强撑住,但已经造成了负面影响。从那之后,我们团队就把限流当成了基础设施的一部分,任何新接口上线必须同步配置限流规则。
限流不仅仅是保护服务器,它其实是一个多方面的收益的事情。稳定的接口响应能提升用户留存,合理的资源调度能降低成本,另外在某些场景下,限流还能起到防刷、防作弊的作用。可以说,限流是直播系统稳定性的第一道防线。
在说代码实现之前,先把几个基础概念讲清楚。限流说白了就是控制请求的速率,让系统能够在可承受的范围内处理请求。这个过程需要关注几个关键指标:

市面上常见的限流算法有四种,我简单说说它们的特点。
这是最简单粗暴的方式。设定一个时间窗口,比如1分钟允许1000次请求。每来一个请求,计数器加一,计数到达阈值就拒绝新的请求。窗口结束后计数器清零。
这个算法的优点是实现简单,缺点是存在”突刺”问题。假设窗口的前半段已经用完了1000次额度,后半段来的请求就全被拒绝,而窗口结束后又瞬间放开。这种不平滑的流量控制很可能造成用户体验的波动。
滑动窗口是计数器算法的改进版。它把一个大的时间窗口拆成多个小窗口,比如1分钟分成6个10秒的小窗口。每次请求来的时候,把当前时间点之前的所有小窗口的请求数加起来判断是否超限。

这个方法比纯计数器要平滑很多,但实现稍微复杂一点。适合对精度有一定要求的场景。
令牌桶的思路是这样的:系统以固定速率往桶里放令牌,每个请求需要从桶里拿一个令牌才能执行。如果桶空了,请求就被拒绝或者等待。
这个算法有个好处,它允许一定程度的突发流量。比如桶里有100个令牌,突然来了50个请求,可以一次性处理完,之后再慢慢补充。这正好符合直播场景的特点——弹幕可能会在短时间内密集爆发。
漏桶和令牌桶相反,它是按固定速率往外”漏”请求。无论来多少请求,都先放到桶里,然后按固定速率处理。桶满了就拒绝新的请求。
这个算法强调的是流量整形,把不规则的请求变成平滑的输出。但它不允许突发流量,可能导致资源利用率不高。
对比下来,直播场景我建议用令牌桶算法,因为它既能限流又能容纳突发流量,比较符合业务特点。
接下来我们看具体怎么实现。这里我以声网的架构为背景,写一个完整的限流模块。代码主要用Java实现,思路是通用的,你可以根据自己的技术栈做调整。
首先定义一个限流器接口,这一步是为了后面方便扩展不同的算法实现。
public interface RateLimiter {
/
* 尝试获取请求许可
* @return true表示允许通过,false表示被限流
*/
boolean tryAcquire();
/
* 获取当前剩余的许可数量
*/
long availablePermits();
}
然后是令牌桶算法的具体实现。我用的是Guava的RateLimiter吗?不是的,这里我们自己写一个原生实现,方便定制。
public class TokenBucketRateLimiter implements RateLimiter {
// 桶的最大容量
private final long maxPermits;
// 令牌发放速率(每秒)
private final double permitsPerSecond;
// 当前桶中的令牌数量
private volatile double storedPermits;
// 上一次补充令牌的时间戳
private long lastRefillTime;
public TokenBucketRateLimiter(long maxPermits, double permitsPerSecond) {
this.maxPermits = maxPermits;
this.permitsPerSecond = permitsPerSecond;
this.storedPermits = maxPermits; // 初始化时装满
this.lastRefillTime = System.currentTimeMillis();
}
@Override
public synchronized boolean tryAcquire() {
refill();
if (storedPermits >= 1) {
storedPermits -= 1;
return true;
}
return false;
}
@Override
public long availablePermits() {
refill();
return (long) Math.floor(storedPermits);
}
private void refill() {
long now = System.currentTimeMillis();
long elapsed = now - lastRefillTime;
// 计算这段时间应该补充的令牌数
double tokensToAdd = (elapsed / 1000.0) * permitsPerSecond;
if (tokensToAdd > 0) {
double newPermits = Math.min(storedPermits + tokensToAdd, maxPermits);
storedPermits = newPermits;
lastRefillTime = now;
}
}
}
这个实现有几个点值得说一下。storedPermits用double是为了支持更精细的令牌计算,但返回值用long是怕调用方误解。lastRefillTime每次refill的时候都会更新,这里有个小问题,如果很长时间没有请求,桶里会慢慢积累令牌直到满为止,这是令牌桶的特性。
单机限流在单机部署的情况下够用,但现在直播系统肯定都是集群部署。所以我们需要一个分布式限流的方案。这里用Redis来实现,因为Redis性能好,支持原子操作,而且声网的架构里本身就有Redis集群。
public class RedisRateLimiter {
private JedisPool jedisPool;
// 限流的key前缀
private static final String RATE_LIMIT_KEY = "rate_limit:";
// lua脚本,用于原子性的限流判断
private static final String LUA_SCRIPT =
"local key = KEYS[1] " +
"local limit = tonumber(ARGV[1]) " +
"local current = tonumber(redis.call('get', key) or '0') " +
"if current < limit then " +
" redis.call('incr', key) " +
" return current + 1 " +
"else " +
" return -1 " +
"end";
public RedisRateLimiter(JedisPool jedisPool) {
this.jedisPool = jedisPool;
}
/
* 尝试获取限流许可
* @param key 限流的唯一标识,比如接口名或者用户ID
* @param limit 限流阈值
* @param windowSeconds 时间窗口(秒)
* @return -1表示被限流,其他值表示当前请求的序号
*/
public long tryAcquire(String key, int limit, int windowSeconds) {
try (Jedis jedis = jedisPool.getResource()) {
String fullKey = RATE_LIMIT_KEY + key;
// 加载lua脚本
ScriptEvalSha script = new ScriptEvalSha(jedis,
DigestUtils.sha1Hex(LUA_SCRIPT));
// 执行限流判断
Object result = jedis.eval(LUA_SCRIPT,
Collections.singletonList(fullKey),
Collections.singletonList(String.valueOf(limit)));
// 注意:这里实际项目中应该处理滑动窗口,
// 单纯用incr无法实现精确的滑动窗口
if (result instanceof Long) {
long count = (Long) result;
if (count == -1) {
return -1; // 被限流
}
// 第一次访问时设置过期时间
if (count == 1) {
jedis.expire(fullKey, windowSeconds);
}
return count;
}
return -1;
}
}
}
上面的代码其实有个问题,它用的是固定窗口,不是滑动窗口。固定窗口有个缺点,在窗口边界可能流量会翻倍。更好的做法是用滑动窗口,但滑动窗口在Redis里实现起来稍微复杂一些,需要用sorted set来存时间戳。
我给你写一个滑动窗口的改进版本:
public class RedisSlidingWindowRateLimiter {
private JedisPool jedisPool;
private static final String RATE_LIMIT_KEY = "sw_rate_limit:";
private static final String SLIDING_WINDOW_SCRIPT =
"local key = KEYS[1] " +
"local now = tonumber(ARGV[1]) " +
"local window = tonumber(ARGV[2]) " +
"local limit = tonumber(ARGV[3]) " +
"local userKey = ARGV[4] " +
" " +
-- 移除时间窗口之外的数据
"redis.call('zremrangebyscore', key, 0, now - window * 1000) " +
" " +
-- 统计当前窗口内的请求数
"local current = redis.call('zcard', key) " +
" " +
"if current < limit then " +
" redis.call('zadd', key, now, userKey .. ':' .. now) " +
" redis.call('pexpire', key, window * 1000) " +
" return current + 1 " +
"else " +
" return -1 " +
"end";
public RedisSlidingWindowRateLimiter(JedisPool jedisPool) {
this.jedisPool = jedisPool;
}
public boolean tryAcquire(String key, int limit, int windowSeconds) {
try (Jedis jedis = jedisPool.getResource()) {
String fullKey = RATE_LIMIT_KEY + key;
long now = System.currentTimeMillis();
String requestId = String.valueOf(now) + ":" +
Thread.currentThread().getId();
Object result = jedis.eval(SLIDING_WINDOW_SCRIPT,
Collections.singletonList(fullKey),
Arrays.asList(
String.valueOf(now),
String.valueOf(windowSeconds),
String.valueOf(limit),
requestId
));
if (result instanceof Long) {
return (Long) result != -1;
}
return false;
}
}
}
滑动窗口用Redis的sorted set来做的,score存时间戳,value存请求的唯一标识。zremrangebyscore负责清理过期的时间窗口,zcard统计窗口内的请求数。这个方案精度更高,但内存消耗也更大,具体用哪个要看你的场景。
说完技术实现,再聊聊业务层面怎么配置限流策略。直播场景下,不同接口的重要性、QPS要求都不一样,不能一刀切。
下面这个表格是我整理的一个配置参考:
| 接口类型 | 限流维度 | 推荐阈值 | 策略说明 |
| 弹幕发送 | 单用户QPS | 20次/秒 | 防止刷屏,用户体验优先 |
| 礼物请求 | 单用户QPS | 5次/秒 | 金额敏感,需要更严格的限制 |
| 点赞请求 | 全局QPS | 5000次/秒 | 可合并请求,阈值可以宽松 |
| 进入直播间 | 单房间QPS | 1000次/秒 | 瞬时流量大,需要平滑处理 |
| 获取回放 | 全局QPS | 2000次/秒 | CDN资源敏感,限制回源流量 |
这个配置不是死的,要根据实际情况调整。比如弹幕的阈值,如果你的用户群体比较活跃,可能需要放宽到30次/秒。另外,建议把限流配置放到配置中心里,方便动态调整,不用每次改配置都重新发版。
这些年用下来,我总结了几个实战经验。
第一,限流分级处理很重要。当请求触发限流时,不要直接返回错误,可以根据限流程度做不同的处理。比如轻微限流时可以返回降级数据或者提示用户稍后再试,严重限流时才返回429状态码。这样用户体验会好很多。
第二,监控和告警必须跟上。你需要知道每个接口当前的QPS是多少,触发了多少次限流,限流趋势是怎样的。我们之前就是因为没有做好监控,有一次限流配置写错了,阈值设得太低,导致大量用户被误拦截,将近两个小时才发现。
第三,限流规则要支持动态下发。直播活动的流量变化很快,今天头部主播带货,明天可能又有新的活动。如果每次调整限流参数都要发版,那效率太低了。建议把限流配置放到Redis或者配置中心里,业务方可以通过管理后台实时调整。
第四,熔断和限流要配合使用。限流是预防措施,熔断是善后措施。当系统已经出现问题的时候,限流可能挡不住所有的异常请求,这时候需要熔断机制来快速失败,避免故障扩散。
第五,测试阶段要模拟真实的流量洪峰。我们之前在测试环境做压测,每次都是慢慢加压,结果上线后遇到突发流量还是扛不住。后来我们学乖了,每次上线前都要做一次脉冲测试,就是瞬间把流量打上去,看看系统能不能撑住。
最后给你写一个完整的限流拦截器,整合前面说的单机限流和分布式限流。这个拦截器可以配置到你的API网关或者框架里。
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
@Autowired
private RedisSlidingWindowRateLimiter redisRateLimiter;
@Autowired
private RateLimitConfig rateLimitConfig;
@Autowired
private MeterRegistry meterRegistry;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String uri = request.getRequestURI();
String userId = getUserId(request);
RateLimitRule rule = rateLimitConfig.getRule(uri);
if (rule == null) {
return true; // 没有限流规则,放行
}
// 构建限流key
String limitKey = buildLimitKey(uri, userId, rule);
// 尝试获取限流许可
boolean allowed = redisRateLimiter.tryAcquire(
limitKey, rule.getLimit(), rule.getWindowSeconds());
if (!allowed) {
// 记录被限流的次数
meterRegistry.counter("rate_limit_blocked_total",
"uri", uri).increment();
// 返回限流响应
response.setStatus(429);
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(
"{\"code\":429,\"msg\":\"访问过于频繁,请稍后再试\"}");
return false;
}
return true;
}
private String buildLimitKey(String uri, String userId,
RateLimitRule rule) {
switch (rule.getLimitType()) {
case USER:
return uri + ":" + userId;
case ROOM:
return uri + ":" + request.getParameter("roomId");
case GLOBAL:
return uri;
default:
return uri + ":" + userId;
}
}
private String getUserId(HttpServletRequest request) {
// 从Token中解析用户ID
String token = request.getHeader("Authorization");
if (StringUtils.isBlank(token)) {
return "anonymous";
}
return parseUserIdFromToken(token);
}
}
这个拦截器用到了Spring的HandlerInterceptor,你可以把它注册到WebMvcConfigurer里。配置方面,需要一个RateLimitConfig来存放限流规则,这个可以做成从数据库或者配置中心读取。
拦截器的逻辑不复杂:根据请求的URI找到对应的限流规则,构建限流key,然后调用Redis限流器判断是否允许通过。被拦截的时候返回429状态码和一个友好的提示文案。
实际项目中,你还可以在这个基础上加一些扩展,比如支持按IP地址限流、支持白名单、支持不同业务场景使用不同的限流算法等等。
限流这个话题看似简单,但要真正做好,做到线上稳如老狗,还是需要不少积累的。从算法选型到代码实现,从配置管理到监控告警,每一个环节都不能马虎。
我这篇文章里给的代码都是简化版本,生产环境用的话还要考虑更多细节。比如Redis的连接池配置、异常处理、分布式锁的问题、性能优化等等。但核心思路是完整的,你可以在这个基础上做扩展。
如果你正在搭建直播系统,建议把限流当作一个基础设施来做,而不是事后补救。早上线,早发现问题,早优化。毕竟直播的流量谁也说不准什么时候就来一下,做好准备总是没错的。
有什么问题的话,欢迎一起交流。
