一、为什么需要分布式缓存?

想象一下你正在开发一个电商平台,每当用户查看商品详情时,系统都要从数据库查询一次。当访问量暴增时,数据库就会成为瓶颈,整个系统响应变慢。这时候,缓存就该登场了。

缓存就像是你办公桌上的便利贴,把常用的信息记下来,随用随取。而分布式缓存则是把这个便利贴复制多份,放在不同同事的桌上,大家都能快速获取信息。

在Erlang的世界里,我们可以用进程和ETS表来构建这样的系统。Erlang进程轻量到可以轻松创建数百万个,ETS表则提供了内存中的键值存储,两者结合就是天然的缓存方案。

二、ETS表的基础使用

ETS(Erlang Term Storage)是Erlang内置的内存数据库,我们先看看它的基本操作:

%% 技术栈:Erlang/OTP 25+

%% 创建一个名为user_cache的ETS表,类型为set(不允许重复键)
%% public表示所有进程都可访问
%% {write_concurrency, true} 启用写并发优化
%% {read_concurrency, true} 启用读并发优化
UserCache = ets:new(user_cache, [set, public, {write_concurrency, true}, {read_concurrency, true}]),

%% 插入数据
ets:insert(UserCache, {1, #{name => "张三", age => 25}}),
ets:insert(UserCache, {2, #{name => "李四", age => 30}}),

%% 查找数据
case ets:lookup(UserCache, 1) of
    [] -> io:format("用户不存在~n");
    [{_, User}] -> io:format("找到用户: ~p~n", [User])
end,

%% 更新数据
ets:update_element(UserCache, 1, {2, #{name => "张三", age => 26}}),

%% 删除数据
ets:delete(UserCache, 2).

ETS表有几个关键特性需要注意:

  1. 默认情况下,ETS表属于创建它的进程,进程退出表也会消失
  2. 可以通过选项设置为不随进程退出而销毁
  3. 支持多种表类型:set(集合)、ordered_set(有序集合)、bag(允许重复键)等

三、用进程管理缓存生命周期

单纯使用ETS表还不够健壮,我们需要用进程来管理:

%% 技术栈:Erlang/OTP 25+

-module(cache_server).
-behaviour(gen_server).

%% API
-export([start_link/0, get/1, put/2, delete/1]).

%% gen_server回调
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).

start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

get(Key) ->
    gen_server:call(?MODULE, {get, Key}).

put(Key, Value) ->
    gen_server:call(?MODULE, {put, Key, Value}).

delete(Key) ->
    gen_server:call(?MODULE, {delete, Key}).

init([]) ->
    %% 创建ETS表,设置owner为true表示随进程退出而销毁
    {ok, ets:new(?MODULE, [set, protected, named_table, {heir, none}, {write_concurrency, true}])}.

handle_call({get, Key}, _From, Table) ->
    Reply = case ets:lookup(Table, Key) of
        [] -> {error, not_found};
        [{Key, Value}] -> {ok, Value}
    end,
    {reply, Reply, Table};

handle_call({put, Key, Value}, _From, Table) ->
    ets:insert(Table, {Key, Value}),
    {reply, ok, Table};

handle_call({delete, Key}, _From, Table) ->
    ets:delete(Table, Key),
    {reply, ok, Table}.

这个简单的缓存服务器展示了Erlang的典型模式:

  1. 用gen_server管理状态(ETS表)
  2. 通过消息传递提供线程安全的操作
  3. 进程注册名字后全局可访问

四、跨节点数据共享

真正的分布式缓存需要跨节点工作,Erlang的分布式能力让这变得简单:

%% 技术栈:Erlang/OTP 25+

%% 在节点1上启动
(cache1@host1)> net_kernel:start([cache1@host1, shortnames]),
(cache1@host1)> global:register_name(cache_server, spawn(fun() -> cache_server:start_link() end)).

%% 在节点2上启动并连接节点1
(cache2@host2)> net_kernel:start([cache2@host2, shortnames]),
(cache2@host2)> net_adm:ping('cache1@host1'),
(cache2@host2)> global:sync().

%% 现在可以从节点2访问节点1的缓存
(cache2@host2)> gen_server:call({global, cache_server}, {put, user1, #{name => "王五"}}).
(cache2@host2)> gen_server:call({global, cache_server}, {get, user1}).

这里用到了Erlang的分布式特性:

  1. 节点间通过ping建立连接
  2. global模块提供了全局进程注册
  3. 可以透明地调用远程进程

五、实现一致性缓存

分布式环境下,缓存一致性是个挑战。我们可以实现一个简单的版本号机制:

%% 技术栈:Erlang/OTP 25+

-module(consistent_cache).
-behaviour(gen_server).

%% API
-export([start_link/0, get/1, put/2, sync/1]).

%% gen_server回调
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).

-record(state, {
    table,       % ETS表
    version = 0, % 全局版本号
    nodes = []   % 其他节点列表
}).

put(Key, Value) ->
    gen_server:call(?MODULE, {put, Key, Value}).

get(Key) ->
    gen_server:call(?MODULE, {get, Key}).

sync(Node) ->
    gen_server:cast(?MODULE, {sync, Node}).

handle_call({put, Key, Value}, _From, State = #state{table = T, version = V}) ->
    NewV = V + 1,
    ets:insert(T, {Key, {NewV, Value}}),
    %% 广播更新到其他节点
    broadcast({update, Key, NewV, Value}, State#state.nodes),
    {reply, ok, State#state{version = NewV}};

handle_call({get, Key}, _From, State = #state{table = T}) ->
    Reply = case ets:lookup(T, Key) of
        [] -> {error, not_found};
        [{Key, {_V, Value}}] -> {ok, Value}
    end,
    {reply, Reply, State}.

handle_cast({sync, Node}, State) ->
    NewNodes = lists:usort([Node | State#state.nodes]),
    {noreply, State#state{nodes = NewNodes}};

handle_cast({update, Key, Version, Value}, State = #state{table = T, version = V}) ->
    %% 只接受更高版本的更新
    case Version > V of
        true -> 
            ets:insert(T, {Key, {Version, Value}}),
            {noreply, State#state{version = Version}};
        false -> {noreply, State}
    end.

broadcast(Msg, Nodes) ->
    lists:foreach(fun(Node) ->
        gen_server:cast({?MODULE, Node}, Msg)
    end, Nodes).

这个实现包含了:

  1. 每次更新递增版本号
  2. 只接受更高版本的更新
  3. 更新时广播到其他节点
  4. 简单的节点发现机制

六、实际应用中的考量

在实际项目中,我们还需要考虑以下问题:

  1. 缓存淘汰策略:ETS表不会自动清理,需要实现LRU等策略
%% 简单的LRU实现思路
-record(cache_item, {
    key,
    value,
    last_used = erlang:system_time(second)
}).

%% 定期清理最久未使用的项
cleanup() ->
    gen_server:cast(?MODULE, cleanup).

handle_cast(cleanup, State = #state{table = T}) ->
    %% 获取所有项并按最后使用时间排序
    Items = lists:sort(fun(A, B) -> 
        A#cache_item.last_used =< B#cache_item.last_used 
    end, ets:tab2list(T)),
    %% 保留最近使用的1000个
    ToKeep = lists:sublist(Items, max(0, length(Items) - 1000)),
    ets:delete_all_objects(T),
    [ets:insert(T, Item) || Item <- ToKeep],
    {noreply, State}.
  1. 故障恢复:节点崩溃后如何恢复缓存
  • 可以定期将ETS表内容持久化到磁盘
  • 使用Mnesia替代ETS获得更强的持久性
  1. 性能优化
  • 对大值考虑压缩
  • 热点数据可以放在本地缓存+分布式缓存两层

七、这种方案的适用场景

这种基于Erlang进程和ETS的分布式缓存特别适合:

  1. Erlang生态内的应用:如果你已经在用Erlang/Elixir,这是最自然的方案
  2. 高并发读场景:ETS的读并发性能极佳
  3. 需要快速原型:Erlang的分布式特性让开发变得简单
  4. 对强一致性要求不高的场景:最终一致性足够时

八、与其他方案的对比

与Redis等专业缓存系统相比,这个方案有这些特点:

优点:

  1. 无外部依赖,部署简单
  2. 与Erlang应用深度集成
  3. 进程模型天然隔离不同缓存
  4. 可以利用Erlang的热代码升级

缺点:

  1. 功能不如Redis丰富
  2. 持久化需要自己实现
  3. 跨数据中心的延迟较高

九、开发中的注意事项

在实现过程中,要特别注意:

  1. ETS表的选项选择

    • 读多写少用read_concurrency
    • 写多读少用write_concurrency
    • 注意进程退出时的表生命周期
  2. 消息传递的可靠性

    • 网络分区时如何处理
    • 消息积压时的背压机制
  3. 监控和调试

    • 添加适当的日志
    • 监控ETS表内存使用

十、总结

用Erlang构建分布式缓存就像用乐高积木搭房子 - 进程是砖块,ETS是粘合剂,消息传递是设计图。虽然不如专业工具功能全面,但在Erlang生态内却有着独特的优势。

这种方案特别适合已经在使用Erlang/OTP的项目,可以快速实现一个性能不错的分布式缓存。对于更复杂的需求,可以考虑基于此方案扩展,或者直接使用Redis等专业工具。

记住,没有最好的方案,只有最适合的方案。理解你的需求,了解工具的特性,才能做出明智的选择。