-- 技术栈:Lua 5.1+ (兼容OpenResty环境)

一、 为什么我们需要缓存?从生活到代码

想象一下,你每天都要去一家很远的超市买牛奶。每次开车来回要花半小时,既费油又费时间。后来你聪明了,在家门口的小冰箱里存上几盒牛奶,想喝的时候随手就能拿到,只有冰箱空了才需要去大超市“补货”。这个“小冰箱”,就是我们的缓存

在程序世界里,道理一模一样。有些数据获取成本很高:比如从遥远的数据库里查询,或者进行一次复杂的计算。如果每次需要时都去重复这个“高成本”操作,程序就会变得很慢,数据库也可能被压垮。缓存的作用,就是把那些经常用到的、或者来之不易的数据,暂时存放在一个能快速拿到的地方(通常是内存里),下次需要时直接从这里取,速度飞快。

今天,我们就来聊聊如何在Lua这个轻巧灵活的语言里,亲手打造一个既智能又高效的“小冰箱”。我们会给它装上两个核心功能:LRU(淘汰最不常用的)和TTL(淘汰过期的)。

二、 构建基石:双向链表与哈希表

要造一个智能缓存,我们需要两种基础数据结构默契配合,就像汽车的发动机和变速箱。

  1. 双向链表:它负责记录数据的“访问顺序”。最近被访问的放在链表头部(最热),最久没被访问的放在链表尾部(最冷)。当缓存满了,需要淘汰时,直接从尾部把最冷的那个“扔掉”就行。链表让调整顺序(比如某个数据被再次访问,要提到头部)变得很快。
  2. 哈希表:这就是我们熟悉的Lua table。它负责“快速查找”。给你一个键(比如用户ID),它能瞬间告诉你对应的值存在哪里,以及这个值在链表中的对应节点。没有它,我们找数据就得遍历整个链表,那可就太慢了。

它们俩分工合作:哈希表负责“快找”,链表负责“管理热度”。下面我们看看如何用Lua实现一个简单的双向链表节点。

-- 技术栈:Lua 5.1+ (兼容OpenResty环境)

-- 定义一个链表节点
local Node = {}
Node.__index = Node

function Node.new(key, value)
    -- 创建一个新的节点对象,包含键、值、指向前一个和后一个节点的指针
    local instance = setmetatable({}, Node)
    instance.key = key   -- 存储的键
    instance.value = value -- 存储的值
    instance.prev = nil  -- 前驱节点
    instance.next = nil  -- 后继节点
    instance.expire_at = nil -- 过期时间戳(为TTL功能预留)
    return instance
end

有了节点,我们就可以把它们串起来了。但别急,我们先看看完整的缓存框架长什么样。

三、 核心实现:一个完整的LRU缓存

让我们把链表、哈希表和LRU逻辑组合起来,实现第一个核心功能:当缓存空间不足时,自动淘汰最近最少使用的那个数据。

-- 技术栈:Lua 5.1+ (兼容OpenResty环境)

local LRUCache = {}
LRUCache.__index = LRUCache

-- 创建缓存实例
-- @param capacity: 缓存最大容量
function LRUCache.new(capacity)
    if capacity <= 0 then
        error("缓存容量必须大于0")
    end
    local instance = setmetatable({}, LRUCache)
    instance.capacity = capacity -- 最大容量
    instance.size = 0 -- 当前大小
    instance.cache = {} -- 哈希表,用于快速查找: key -> Node
    -- 初始化双向链表的头尾哨兵节点,它们不存储实际数据,只是为了简化边界操作
    instance.head = Node.new(nil, nil) -- 头节点,代表最热
    instance.tail = Node.new(nil, nil) -- 尾节点,代表最冷
    instance.head.next = instance.tail
    instance.tail.prev = instance.head
    return instance
end

-- 私有方法:将节点移动到链表头部(标记为最近使用)
function LRUCache:_move_to_head(node)
    -- 1. 先把节点从当前位置“摘下来”
    self:_remove_node(node)
    -- 2. 再把节点“插入”到头部哨兵节点之后
    self:_add_to_head(node)
end

-- 私有方法:从链表中移除一个节点
function LRUCache:_remove_node(node)
    local prev_node = node.prev
    local next_node = node.next
    prev_node.next = next_node
    next_node.prev = prev_node
end

-- 私有方法:在链表头部添加一个节点
function LRUCache:_add_to_head(node)
    -- 插入到头节点和原第一个真实节点之间
    node.next = self.head.next
    node.prev = self.head
    self.head.next.prev = node
    self.head.next = node
end

-- 私有方法:移除链表尾部节点(最久未使用)并返回该节点
function LRUCache:_pop_tail()
    local node = self.tail.prev -- 尾哨兵的前一个就是最冷的节点
    if node == self.head then -- 如果链表里除了哨兵没别的节点
        return nil
    end
    self:_remove_node(node)
    return node
end

-- 核心方法:从缓存获取数据
-- @param key: 要查找的键
-- @return: 找到的值,如果未找到则返回nil
function LRUCache:get(key)
    local node = self.cache[key]
    if not node then -- 缓存未命中
        return nil
    end
    -- 缓存命中!将该节点标记为最近使用
    self:_move_to_head(node)
    return node.value
end

-- 核心方法:向缓存写入数据
-- @param key: 数据的键
-- @param value: 数据的值
function LRUCache:put(key, value)
    local node = self.cache[key]
    if not node then
        -- 键不存在,是新增操作
        node = Node.new(key, value)
        self.cache[key] = node -- 存入哈希表
        self:_add_to_head(node) -- 新节点放到最热位置
        self.size = self.size + 1

        -- 检查是否超出容量
        if self.size > self.capacity then
            -- 超出容量,需要淘汰最久未使用的节点
            local removed_node = self:_pop_tail()
            if removed_node then
                self.cache[removed_node.key] = nil -- 从哈希表中删除
                self.size = self.size - 1
                -- 这里可以添加回调,通知数据被移除(可选)
                -- print(string.format("[LRU淘汰] 键: %s", removed_node.key))
            end
        end
    else
        -- 键已存在,是更新操作
        node.value = value
        self:_move_to_head(node) -- 更新后也标记为最近使用
    end
end

-- 辅助方法:打印当前缓存内容(按访问顺序从新到旧)
function LRUCache:print()
    local current = self.head.next
    local items = {}
    while current ~= self.tail do
        table.insert(items, string.format("(%s:%s)", current.key, current.value))
        current = current.next
    end
    print("当前缓存 (最近使用 -> 最久未使用): " .. table.concat(items, " -> "))
end

-- 使用示例
local my_cache = LRUCache.new(3) -- 创建一个容量为3的缓存

my_cache:put("user:1001", "Alice")
my_cache:put("user:1002", "Bob")
my_cache:put("user:1003", "Charlie")
my_cache:print() -- 输出: (user:1003:Charlie) -> (user:1002:Bob) -> (user:1001:Alice)

print(my_cache:get("user:1002")) -- 输出: Bob, 访问后user:1002被提到最前面
my_cache:print() -- 输出: (user:1002:Bob) -> (user:1003:Charlie) -> (user:1001:Alice)

my_cache:put("user:1004", "David") -- 加入第四个,容量已满,最久未用的user:1001被淘汰
my_cache:print() -- 输出: (user:1004:David) -> (user:1002:Bob) -> (user:1003:Charlie)

print(my_cache:get("user:1001")) -- 输出: nil, 已被淘汰

看,一个基本的LRU缓存就完成了!它保证了最活跃的数据总是留在缓存里。但有时候,数据“冷”不是因为访问少,而是因为它本身就有“保质期”,比如一个验证码,5分钟后就应该失效。这就需要TTL策略了。

四、 增强功能:给缓存加上“保质期” (TTL)

TTL(生存时间)让缓存条目像食品一样,超过设定时间就自动失效。实现TTL的核心思路是:在存储时记录“过期时间点”,在获取时检查是否已过期

我们将扩展上面的LRU缓存,让它支持TTL。为了高效清理过期数据,我们采用惰性删除为主的方式:即在getput时检查当前节点是否过期,如果过期就删除。我们也可以提供一个cleanup方法主动扫描。

-- 技术栈:Lua 5.1+ (兼容OpenResty环境)

local LRU_TTLCache = {}
LRU_TTLCache.__index = LRU_TTLCache

-- 创建支持TTL的缓存实例
-- @param capacity: 缓存最大容量
-- @param default_ttl: 默认的TTL时间(秒,可选)
function LRU_TTLCache.new(capacity, default_ttl)
    local instance = LRUCache.new(capacity) -- 复用LRU的基础结构
    setmetatable(instance, LRU_TTLCache)
    instance.default_ttl = default_ttl -- 默认过期时间
    return instance
end

-- 重写put方法,支持TTL
-- @param key: 键
-- @param value: 值
-- @param ttl: 可选的特定TTL(秒),优先于默认TTL
function LRU_TTLCache:put(key, value, ttl)
    local node = self.cache[key]
    local now = os.time() -- 获取当前时间戳

    -- 确定过期时间
    local expire_ttl = ttl or self.default_ttl
    local expire_at = expire_ttl and (now + expire_ttl) or nil -- 如果没设置TTL,则永不过期

    if not node then
        -- 新增
        node = Node.new(key, value)
        node.expire_at = expire_at -- 设置过期时间戳
        self.cache[key] = node
        self:_add_to_head(node)
        self.size = self.size + 1

        -- LRU容量检查
        if self.size > self.capacity then
            self:_evict_one() -- 淘汰一个(可能是过期的,也可能是LRU的)
        end
    else
        -- 更新
        node.value = value
        node.expire_at = expire_at -- 更新时也刷新过期时间
        self:_move_to_head(node)
    end
end

-- 重写get方法,检查过期
function LRU_TTLCache:get(key)
    local node = self.cache[key]
    if not node then
        return nil
    end

    -- 关键:检查是否过期
    if node.expire_at and os.time() > node.expire_at then
        -- 已过期,删除该节点
        self:_remove_node(node)
        self.cache[key] = nil
        self.size = self.size - 1
        -- print(string.format("[TTL过期] 键: %s", key))
        return nil
    end

    -- 没过期,标记为最近使用并返回值
    self:_move_to_head(node)
    return node.value
end

-- 私有方法:淘汰一个节点。策略:先尝试淘汰过期的,没有再按LRU淘汰。
function LRU_TTLCache:_evict_one()
    -- 方案1:简单从尾部开始淘汰(惰性,遇到过期就删,否则删第一个不过期的)
    local node = self.tail.prev
    local now = os.time()
    while node ~= self.head do
        if node.expire_at and now > node.expire_at then
            -- 发现过期节点,删除它
            self:_remove_node(node)
            self.cache[node.key] = nil
            self.size = self.size - 1
            -- print(string.format("[淘汰时发现过期] 键: %s", node.key))
            return
        end
        node = node.prev
    end

    -- 方案2:如果没有找到过期节点,则按LRU策略淘汰最久未使用的
    local to_remove = self:_pop_tail()
    if to_remove then
        self.cache[to_remove.key] = nil
        self.size = self.size - 1
        -- print(string.format("[LRU淘汰] 键: %s", to_remove.key))
    end
end

-- 主动清理所有过期条目
function LRU_TTLCache:cleanup()
    local now = os.time()
    local current = self.head.next
    local count = 0
    while current ~= self.tail do
        local next_node = current.next -- 先保存下一个,因为当前节点可能被删除
        if current.expire_at and now > current.expire_at then
            self:_remove_node(current)
            self.cache[current.key] = nil
            self.size = self.size - 1
            count = count + 1
        end
        current = next_node
    end
    -- print(string.format("[主动清理] 移除了 %d 个过期条目", count))
    return count
end

-- 使用示例:带TTL的缓存
local my_ttl_cache = LRU_TTLCache.new(3, 5) -- 容量3,默认TTL 5秒

my_ttl_cache:put("token:abc", "secret123") -- 使用默认5秒TTL
my_ttl_cache:put("config:site_name", "MySite", 30) -- 自定义30秒TTL
my_ttl_cache:put("temp:lock", "locked", 2) -- 2秒后过期

print("初始状态:")
my_ttl_cache:print() -- 三个都在

print("\n等待3秒后...")
os.execute("sleep 3") -- 模拟等待(注意:os.execute('sleep')在非Unix环境可能不同)
print("获取 temp:lock:", my_ttl_cache:get("temp:lock")) -- 应该返回nil,已过期
my_ttl_cache:cleanup() -- 主动清理一下
my_ttl_cache:print() -- temp:lock 应该被清除了

print("\n再添加一个,触发LRU:")
my_ttl_cache:put("user:1001", "Alice") -- 此时缓存可能已满,会触发淘汰
my_ttl_cache:print()

现在,我们的缓存就既聪明(LRU)又守时(TTL)了!它在很多场景下都能大显身手。

五、 应用场景与优劣分析

典型应用场景:

  1. Web应用(如OpenResty):缓存数据库查询结果、API响应、渲染后的页面片段,大幅降低后端压力,提升响应速度。例如,缓存用户信息、商品详情。
  2. 游戏服务器:缓存玩家状态、道具信息、地图数据等,避免频繁读写数据库。
  3. 计算密集型服务:缓存耗时的计算结果,如推荐算法模型输出、图像处理后的缩略图地址等。
  4. 限流与分布式锁:利用TTL实现简单的访问频率控制或锁的自动释放。

优点:

  • 性能提升显著:内存访问速度远超数据库或网络I/O。
  • 降低后端负载:保护数据库等核心服务不被突发流量击垮。
  • 实现简单灵活:纯Lua实现,不依赖外部组件,易于理解和定制。
  • 资源控制精细:通过LRU和TTL,可以精确控制缓存的内存占用和数据有效性。

缺点与注意事项:

  • 数据一致性问题:这是缓存最大的挑战。如果源数据(如数据库)被修改,缓存中的数据就变成了“脏数据”。解决方法有:设置较短的TTL、在数据更新时主动失效缓存(删除或更新)、或者使用更复杂的一致性协议。
  • 内存容量限制:缓存受限于机器内存,无法存储无限数据,需要精心设计容量和淘汰策略。
  • 实现复杂度:一个生产级的缓存需要考虑更多,如持久化、集群同步、监控指标(命中率)等。
  • “缓存穿透”:频繁查询一个根本不存在的数据,导致请求每次都绕过缓存打到数据库。可以用“空值缓存”或布隆过滤器来缓解。
  • “缓存雪崩”:大量缓存数据在同一时间过期,导致所有请求瞬间涌向数据库。可以给TTL加上随机扰动。

六、 总结

我们从“为什么需要缓存”这个生活化比喻开始,一步步用Lua构建了一个功能完备的缓存模块。核心是利用哈希表实现快速访问,用双向链表维护LRU顺序,并通过给节点添加过期时间戳来实现TTL。我们看到了惰性删除与主动清理相结合的实践。

这个自实现的缓存库虽然轻量,但涵盖了核心思想。在实际项目中,如果需求更复杂,你可能会选择功能更强大的lua-resty-lrucache(OpenResty内置)或直接使用Redis。但理解其底层原理,能让你在任何技术选型中都游刃有余。

记住,引入缓存是优化系统性能的利器,但也带来了复杂性。务必根据业务特点,仔细权衡一致性、成本和收益,并做好监控。希望这个亲手打造的“Lua小冰箱”,能帮你更好地为你的应用“保鲜”数据,提升体验。