一、为什么需要分布式缓存?
想象一下你正在开发一个电商平台,每当用户查看商品详情时,系统都要从数据库查询一次。当访问量暴增时,数据库就会成为瓶颈,整个系统响应变慢。这时候,缓存就该登场了。
缓存就像是你办公桌上的便利贴,把常用的信息记下来,随用随取。而分布式缓存则是把这个便利贴复制多份,放在不同同事的桌上,大家都能快速获取信息。
在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表有几个关键特性需要注意:
- 默认情况下,ETS表属于创建它的进程,进程退出表也会消失
- 可以通过选项设置为不随进程退出而销毁
- 支持多种表类型: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的典型模式:
- 用gen_server管理状态(ETS表)
- 通过消息传递提供线程安全的操作
- 进程注册名字后全局可访问
四、跨节点数据共享
真正的分布式缓存需要跨节点工作,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的分布式特性:
- 节点间通过ping建立连接
- global模块提供了全局进程注册
- 可以透明地调用远程进程
五、实现一致性缓存
分布式环境下,缓存一致性是个挑战。我们可以实现一个简单的版本号机制:
%% 技术栈: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).
这个实现包含了:
- 每次更新递增版本号
- 只接受更高版本的更新
- 更新时广播到其他节点
- 简单的节点发现机制
六、实际应用中的考量
在实际项目中,我们还需要考虑以下问题:
- 缓存淘汰策略: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}.
- 故障恢复:节点崩溃后如何恢复缓存
- 可以定期将ETS表内容持久化到磁盘
- 使用Mnesia替代ETS获得更强的持久性
- 性能优化:
- 对大值考虑压缩
- 热点数据可以放在本地缓存+分布式缓存两层
七、这种方案的适用场景
这种基于Erlang进程和ETS的分布式缓存特别适合:
- Erlang生态内的应用:如果你已经在用Erlang/Elixir,这是最自然的方案
- 高并发读场景:ETS的读并发性能极佳
- 需要快速原型:Erlang的分布式特性让开发变得简单
- 对强一致性要求不高的场景:最终一致性足够时
八、与其他方案的对比
与Redis等专业缓存系统相比,这个方案有这些特点:
优点:
- 无外部依赖,部署简单
- 与Erlang应用深度集成
- 进程模型天然隔离不同缓存
- 可以利用Erlang的热代码升级
缺点:
- 功能不如Redis丰富
- 持久化需要自己实现
- 跨数据中心的延迟较高
九、开发中的注意事项
在实现过程中,要特别注意:
ETS表的选项选择:
- 读多写少用
read_concurrency - 写多读少用
write_concurrency - 注意进程退出时的表生命周期
- 读多写少用
消息传递的可靠性:
- 网络分区时如何处理
- 消息积压时的背压机制
监控和调试:
- 添加适当的日志
- 监控ETS表内存使用
十、总结
用Erlang构建分布式缓存就像用乐高积木搭房子 - 进程是砖块,ETS是粘合剂,消息传递是设计图。虽然不如专业工具功能全面,但在Erlang生态内却有着独特的优势。
这种方案特别适合已经在使用Erlang/OTP的项目,可以快速实现一个性能不错的分布式缓存。对于更复杂的需求,可以考虑基于此方案扩展,或者直接使用Redis等专业工具。
记住,没有最好的方案,只有最适合的方案。理解你的需求,了解工具的特性,才能做出明智的选择。
评论