一、为什么需要缓存策略

在构建高并发系统时,数据库往往是性能瓶颈。想象一下,每次用户请求都要从数据库读取数据,就像每次去超市都要从仓库搬货一样低效。缓存的作用,就是提前把热门商品摆到货架上,让顾客能快速拿到。

Elixir作为基于Erlang VM的函数式语言,天生适合构建高并发的分布式系统。但官方并没有提供"一刀切"的缓存方案,我们需要根据场景选择合适的技术。

二、ETS:轻量级内存缓存

ETS(Erlang Term Storage)是Erlang/OTP内置的内存键值存储,相当于进程间的共享内存。它的特点是简单直接,适合单节点缓存。

# 创建ETS表(技术栈:Elixir/Erlang)
:ets.new(:user_cache, [
  :set,            # 表类型为集合(不允许重复键)
  :public,         # 允许其他进程访问
  :named_table,    # 使用原子名称引用表
  {:read_concurrency, true}  # 优化读并发
])

# 写入缓存
:ets.insert(:user_cache, {"user:1001", %{name: "张三", age: 25}})

# 读取缓存
case :ets.lookup(:user_cache, "user:1001") do
  [{_key, value}] -> value
  [] -> nil 
end

# 带TTL的缓存(需要自行实现清理逻辑)
:ets.insert(:user_cache, {"temp_data", "expires_in_10s", System.system_time(:second) + 10})

优点

  • 零外部依赖,性能极高(微秒级读写)
  • 支持复杂的模式匹配查询

缺点

  • 数据不持久化,节点重启丢失
  • 集群环境下需要额外同步机制

三、Agent + GenServer:状态管理方案

如果需要更精细的控制,可以用Agent封装状态,或者用GenServer实现缓存逻辑。这种方式适合需要业务逻辑处理的场景。

defmodule CacheServer do
  use GenServer

  # 启动缓存服务
  def start_link(opts) do
    GenServer.start_link(__MODULE__, %{}, opts)
  end

  # 初始化空Map
  def init(_) do
    {:ok, %{ttl: 30}}
  end

  # 客户端API:写入缓存
  def put(pid, key, value) do
    GenServer.cast(pid, {:put, key, value})
  end

  # 客户端API:读取缓存
  def get(pid, key) do
    GenServer.call(pid, {:get, key})
  end

  # 回调函数:处理写入
  def handle_cast({:put, key, value}, state) do
    {:noreply, Map.put(state, key, {value, System.os_time(:second)})}
  end

  # 回调函数:处理读取(带过期检查)
  def handle_call({:get, key}, _from, state) do
    case Map.get(state, key) do
      {value, timestamp} when System.os_time(:second) - timestamp < state.ttl ->
        {:reply, value, state}
      _ ->
        {:reply, nil, Map.delete(state, key)}
    end
  end
end

适用场景

  • 需要自定义过期策略
  • 缓存数据需要关联业务逻辑
  • 单节点中小规模数据

四、分布式缓存:Redis适配

对于需要跨节点共享的缓存,Redis是经典选择。Elixir通过Redix库可以轻松集成:

# 启动Redis连接(技术栈:Elixir + Redis)
{:ok, conn} = Redix.start_link("redis://localhost:6379")

# 设置带TTL的缓存
Redix.command!(conn, ["SET", "user:1001", "{\"name\":\"李四\"}", "EX", "3600"])

# 使用管道批量操作
Redix.pipeline(conn, [
  ["INCR", "page_views"],
  ["EXPIRE", "page_views", 60]
])

# 集群支持
pool_opts = [
  name: :redis_pool,
  pool_size: 10,
  host: "cluster.redis.example.com"
]

children = [
  {Redix, pool_opts}
]

Supervisor.start_link(children, strategy: :one_for_one)

性能对比
| 方案 | 读延迟 | 写延迟 | 集群支持 | |-----------|---------|---------|-------| | ETS | 1-5μs | 2-8μs | ❌ | | GenServer | 50-100μs| 70-150μs| ❌ | | Redis | 0.5-2ms | 1-3ms | ✅ |

五、进阶技巧:缓存雪崩与击穿防护

实际项目中要特别注意缓存失效引发的连锁反应。以下是两种常见问题的解决方案:

1. 雪崩防护 - 给缓存TTL添加随机偏移:

ttl = 3600 + :rand.uniform(300)  # 基础1小时+随机5分钟

2. 击穿防护 - 使用进程字典实现简单的互斥锁:

def get_with_lock(key, loader_fn) do
  case Process.get({:cache_lock, key}) do
    true -> 
      # 已有其他进程在加载,短暂等待
      :timer.sleep(100)
      get_with_lock(key, loader_fn)
    nil ->
      Process.put({:cache_lock, key}, true)
      data = loader_fn.()  # 执行数据库查询
      Cache.put(key, data)
      Process.delete({:cache_lock, key})
      data
  end
end

六、如何选择合适的方案

根据你的业务特点做决策:

  • 临时数据:ETS足够(如用户会话)
  • 业务敏感数据:GenServer提供更多控制
  • 跨服务共享:Redis等外部存储
  • 超高并发:考虑多级缓存(ETS+Redis)

记住,没有完美的方案,只有适合当前场景的权衡。建议先用简单实现满足需求,再随着业务增长逐步优化。