一、为什么需要限流?

在分布式系统中,流量就像洪水一样,有时候会突然暴涨。比如,电商大促时,秒杀活动可能瞬间涌入大量请求;或者某个API被恶意刷接口,导致服务器资源被耗尽。这时候,如果没有合理的限流措施,系统可能会直接崩溃,影响正常用户的体验。

限流的核心目标就是控制请求的速率,确保系统在可承受的范围内稳定运行。Erlang作为一种高并发的函数式语言,天生适合构建高可用系统,它的轻量级进程和消息传递机制让限流实现更加优雅。

二、Erlang限流的常见策略

Erlang的限流策略可以大致分为以下几种:

  1. 计数器算法:简单粗暴,统计单位时间内的请求数,超过阈值就拒绝。
  2. 漏桶算法:请求像水一样流入桶中,系统以固定速率处理,多余的请求会被丢弃或排队。
  3. 令牌桶算法:系统按固定速率生成令牌,请求必须拿到令牌才能被处理。
  4. 基于进程的限流:利用Erlang的进程邮箱机制,控制消息处理速度。

下面我们重点看看令牌桶算法在Erlang中的实现,因为它既能平滑突发流量,又能限制长期速率。

三、令牌桶算法的Erlang实现

我们使用Erlang/OTP来实现一个简单的令牌桶限流模块。

-module(token_bucket).
-behaviour(gen_server).

-export([start_link/1, get_token/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).

%% 启动限流器,参数:{容量, 填充速率(令牌/秒)}
start_link({Capacity, Rate}) ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, {Capacity, Rate}, []).

%% 尝试获取一个令牌,返回 {ok, remaining} 或 {error, no_token}
get_token(Pid) ->
    gen_server:call(Pid, get_token).

%% ====== 内部实现 ======
init({Capacity, Rate}) ->
    %% 初始化状态:当前令牌数、上次填充时间、容量、填充速率
    Now = erlang:system_time(millisecond),
    State = #{tokens => Capacity, last_fill => Now, capacity => Capacity, rate => Rate},
    {ok, State}.

handle_call(get_token, _From, State) ->
    #{tokens := Tokens, last_fill := LastFill, capacity := Cap, rate := Rate} = State,
    Now = erlang:system_time(millisecond),
    %% 计算应该补充的令牌数
    TimePassed = Now - LastFill,
    NewTokens = min(Cap, Tokens + (TimePassed * Rate) div 1000),
    UpdatedState = State#{tokens := NewTokens, last_fill := Now},
    
    case NewTokens > 0 of
        true ->
            %% 有令牌,允许通过
            {reply, {ok, NewTokens - 1}, UpdatedState#{tokens := NewTokens - 1}};
        false ->
            %% 无令牌,拒绝
            {reply, {error, no_token}, UpdatedState}
    end.

代码解析:

  1. start_link/1 初始化令牌桶,设置容量和填充速率。
  2. get_token/1 是客户端调用的接口,尝试获取令牌。
  3. 每次请求时,会根据时间差计算应该补充的令牌数,避免频繁填充。
  4. 如果令牌足够,返回 {ok, remaining},否则返回 {error, no_token}

四、结合ETS优化性能

上面的实现使用了 gen_server,适用于单节点限流。但在分布式环境下,可能需要共享限流状态。这时可以用ETS(Erlang Term Storage)来存储令牌桶数据。

-module(dist_token_bucket).
-export([init/0, get_token/1]).

init() ->
    %% 创建ETS表,存储限流状态
    ets:new(?MODULE, [named_table, public, {write_concurrency, true}]),
    ets:insert(?MODULE, {tokens, 10}),  % 初始令牌数
    ets:insert(?MODULE, {last_fill, erlang:system_time(millisecond)}),
    ets:insert(?MODULE, {rate, 1}),     % 1 token/sec
    ets:insert(?MODULE, {capacity, 10}).

get_token(Requester) ->
    [{tokens, Tokens}, {last_fill, LastFill}, {rate, Rate}, {capacity, Cap}] = ets:lookup(?MODULE, tokens),
    Now = erlang:system_time(millisecond),
    TimePassed = Now - LastFill,
    NewTokens = min(Cap, Tokens + (TimePassed * Rate) div 1000),
    
    case NewTokens > 0 of
        true ->
            ets:insert(?MODULE, [{tokens, NewTokens - 1}, {last_fill, Now}]),
            io:format("~p got token, remaining: ~p~n", [Requester, NewTokens - 1]),
            ok;
        false ->
            io:format("~p denied, no tokens left~n", [Requester]),
            denied
    end.

优化点:

  1. ETS支持并发读写,适合多进程共享数据。
  2. 使用 write_concurrency 提高写入性能。

五、应用场景与注意事项

适用场景

  1. API限流:防止恶意刷接口。
  2. 消息队列消费控制:避免消费者过载。
  3. 数据库访问限速:防止慢查询拖垮DB。

优缺点

  • 优点
    • 平滑突发流量,避免瞬间过载。
    • Erlang的轻量级进程模型让限流实现更高效。
  • 缺点
    • 单机限流难以应对分布式环境(需结合Redis等外部存储)。
    • 精确限流可能增加延迟。

注意事项

  1. 阈值设置:需根据压测结果调整,避免误杀正常请求。
  2. 监控与动态调整:限流策略应支持运行时调整参数。
  3. 失败处理:被限流的请求可以返回友好提示或进入队列重试。

六、总结

Erlang的并发模型让限流实现变得简单高效,无论是单机还是分布式场景,都能找到合适的方案。令牌桶算法是一个平衡了突发流量和长期速率的好选择,结合ETS或外部存储(如Redis)可以进一步优化性能。

在实际项目中,限流只是系统稳定性的一环,还需要结合熔断、降级等策略,才能构建真正高可用的服务。