一、为什么需要关注共享内存?

在电商大促的夜晚,我们的流量网关突然出现响应延迟飙升。查看监控发现,原本应该承担抗压重任的共享内存缓存层,此时竟然变成了性能瓶颈。这就是典型的共享内存使用不当的代价——当缓存命中率跌破警戒线、锁竞争导致线程阻塞、内存泄漏又悄悄吃掉服务器资源时,整个系统都会陷入泥潭。

OpenResty的shared dict就像我们团队里的公共白板:所有人都能随时查看(读操作),但每次修改都要擦掉重写(写互斥)。这个比喻能帮助我们理解后续所有的优化逻辑。

二、万丈高楼平地起:共享内存初始化

工欲善其事必先利其器,我们先配置共享内存的基础设施:

http {
    # 开辟名为cache_zone的共享内存区,200MB大小足够缓存商品信息
    lua_shared_dict cache_zone 200m;
    
    # 锁专用内存区,10MB足够应对高并发场景
    lua_shared_dict lock_zone 10m;
}

这样我们就有了两个独立沙箱:

  • cache_zone:存储缓存数据的主战场
  • lock_zone:专门管理分布式锁的战场

这样设计是经过血泪教训的——早年间我们将锁和缓存混用,结果缓存淘汰导致锁信息丢失,引发集群锁失效的连锁反应。

三、缓存优化

3.1 基础缓存模式实战

让我们看一个商品详情缓存的完整示例:

-- 获取商品详情的标准模式
function get_product_detail(product_id)
    local cache = ngx.shared.cache_zone
    local key = "product_"..product_id
    
    -- 第一层:内存缓存检查
    local value, flags = cache:get(key)
    if value then
        return value
    end
    
    -- 第二层:互斥锁检查,防止缓存击穿
    local lock_key = key.."_lock"
    local lock = require "resty.lock"
    local locker, err = lock:new("lock_zone")
    if not locker then
        return nil, "create lock failed: "..err
    end
    
    -- 等待锁的最长时间设置为500ms
    local elapsed, err = locker:lock(lock_key, 500)
    if not elapsed then
        return nil, "wait lock timeout"
    end
    
    -- 获得锁后再次检查缓存(双检逻辑)
    value = cache:get(key)
    if value then
        locker:unlock()
        return value
    end
    
    -- 第三层:真实数据库查询
    local db = mysql_connect()
    local res, err = db:query("SELECT * FROM products WHERE id="..product_id)
    if not res then
        locker:unlock()
        return nil, "db query error"
    end
    
    -- 数据压缩:将JSON数据进行gzip压缩节省空间
    local compressed = compress(res)
    cache:set(key, compressed, 60)  -- 缓存60秒
    
    locker:unlock()
    return compressed
end

这个看似平凡的代码包含三个优化技巧:

  1. 双检锁结构:在获取锁前后都检查缓存,避免重复更新
  2. 数据压缩:特别适合大文本数据,可以节省50%以上空间
  3. 独立锁域:使用独立内存区存储锁信息,防止误覆盖

3.2 缓存更新的玄机

多数人只知道被动过期,但主动更新才是制胜关键:

-- 主动更新缓存的定时任务
local function cache_warm_up()
    local cache = ngx.shared.cache_zone
    local db = mysql_connect()
    
    -- 获取最近1小时有更新的商品ID
    local res = db:query("SELECT id FROM products WHERE update_time > NOW()-3600")
    for _, item in ipairs(res) do
        local key = "product_"..item.id
        if not cache:get(key) then
            -- 批量查询优化,每次取100个商品
            local products = db:query("SELECT * FROM products WHERE id IN (...)")
            for _, p in ipairs(products) do
                local compressed = compress(p)
                cache:set("product_"..p.id, compressed, 3600)  -- 缓存1小时
            end
        end
    end
end

-- 每5分钟执行预热
local ok, err = ngx.timer.every(300, cache_warm_up)

这种预加载模式可以确保热点数据永不过期,特别适合促销商品页。通过批量查询优化,将原来的N+1查询变成1+1查询。

四、锁机制的修罗场

分布式锁是共享内存中最暗藏杀机的部分,看这段典型错误示例:

-- 错误示范:没有设置过期时间的锁
local locker = require "resty.lock"
local lock = locker:new("lock_zone")

local function bad_lock_demo()
    local elapsed, err = lock:lock("my_key")
    -- 某个异常分支没有释放锁...
    if math.random() > 0.5 then
        return ngx.exit(500)
    end
    lock:unlock()
end

这里有两个致命错误:

  1. 没有设置锁超时时间,一旦进程异常会导致死锁
  2. 异常分支没有释放锁

正确姿势应该是:

local function safe_lock_demo()
    local lock_opts = {
        exptime = 10,  -- 锁最大持有时间10秒
        timeout = 3    -- 等待锁的最长时间3秒
    }
    
    local locker = require "resty.lock"
    local lock, err = locker:new("lock_zone", lock_opts)
    if not lock then
        return nil, "create lock failed"
    end
    
    local elapsed, err = lock:lock("safe_key")
    if not elapsed then
        return nil, "lock timeout"
    end
    
    -- 用pcall包裹业务逻辑
    local ok, ret = pcall(function()
        -- 业务代码...
        if math.random() > 0.5 then
            error("unexpected error")
        end
        return "success"
    end)
    
    -- 确保无论如何都会释放锁
    local unlock_ok, unlock_err = lock:unlock()
    if not unlock_ok then
        ngx.log(ngx.ERR, "unlock failed: ", unlock_err)
    end
    
    if not ok then
        return nil, ret
    end
    return ret
end

这段代码像瑞士钟表般严谨:

  • 设置双重超时控制(持有时间和等待时间)
  • 使用pcall捕获所有异常
  • finally块确保锁释放

五、内存泄漏的狩猎行动

去年我们遇到一个诡异现象:内存使用率每天增长2%,直到触发OOM。经过三天排查,终于发现是日志模块的内存泄漏:

-- 危险的日志缓存模式
local log_buffer = {}  -- 模块级变量

local function risky_log(level, msg)
    -- 将日志先存入数组
    table.insert(log_buffer, {level=level, msg=msg})
    
    -- 每满100条批量写入
    if #log_buffer >= 100 then
        local batch = table.concat(log_buffer, "\n")
        ngx.shared.log_zone:set("buffer", batch)
        log_buffer = {}
    end
end

这里有两个致命错误:

  1. 使用table.concat拼接大字符串,导致LuaVM内存暴涨
  2. 共享内存操作是原子性的,多个worker并发写入会导致数据覆盖

修复后的版本:

-- 安全日志处理方案
local function safe_log(level, msg)
    -- 使用FFI直接操作内存缓冲区
    local ffi = require "ffi"
    ffi.cdef[[
        typedef struct {
            char level[8];
            char message[256];
        } log_entry;
    ]]
    
    -- 每个请求独立缓冲区,避免worker间竞争
    local buffer = ffi.new("log_entry[100]")
    local count = 0
    
    local function flush()
        if count == 0 then return end
        -- 通过共享内存的原子队列写入
        local ok, err = ngx.shared.log_zone:lpush("log_queue", ffi.string(buffer, count * ffi.sizeof("log_entry")))
        if not ok then
            ngx.log(ngx.ERR, "log queue full: ", err)
        end
        count = 0
    end
    
    return function(level, msg)
        if count >= 100 then
            flush()
        end
        buffer[count].level = ffi.new("char[8]", level)
        buffer[count].message = ffi.new("char[256]", msg)
        count = count + 1
    end
end

-- 每个请求生成独立的日志处理器
local log = safe_log()
log("INFO", "user login")

这个方案有三重保障:

  1. 使用FFI直接操作内存,避免Lua表的额外内存开销
  2. 每个请求独立缓冲区,避免全局竞争
  3. 使用共享内存的原子操作lpush替代set

六、性能优化

在真实的万人秒杀场景中,我们通过以下参数调优将QPS提升了3倍:

location /api {
    access_by_lua_block {
        -- 调整共享内存配置
        local cache = ngx.shared.cache_zone
        cache:set_max_dead_nodes(1000)  -- 允许更多过期节点
        cache:set_flush_expired(500)    -- 每次最多清理500过期项
    }
    
    content_by_lua_file app.lua;
}

这些参数需要根据内存区大小动态计算:

  • set_max_dead_nodes:建议设置为总key数的20%
  • set_flush_expired:设置为平均每秒请求数的1%

配合Nginx参数优化:

events {
    worker_connections 20480;  # 增大连接数
}

http {
    lua_socket_pool_size 512;  # 增大连接池
}

七、避坑指南:血的教训

  1. 数据类型陷阱:共享内存只能存储字符串,数字会自动转换

    local ok, err = cache:set("count", 100)  -- 正确
    local count = cache:get("count")         -- 返回字符串"100"
    local num = tonumber(count) or 0         -- 必须显式转换
    
  2. 原子性魔咒:incr是原子操作,但get+incr组合不是

    -- 错误:非原子操作
    local value = cache:get("stock") or 0
    cache:set("stock", value - 1)
    
    -- 正确:使用原子指令
    cache:incr("stock", -1)
    
  3. 内存溢出深渊:每次set操作必须检查返回值

    local ok, err = cache:set(key, value)
    if not ok then
        ngx.log(ngx.ERR, "cache full: ", err)
        -- 触发LRU淘汰或报警
    end
    

八、最佳实践手册

根据三年OpenResty优化经验,总结出三大黄金法则:

  1. 容量规划原则

    • 共享内存占用建议不超过物理内存的30%
    • 单个key的value不超过1MB(超过建议分片存储)
    • 预估公式:总容量 = (平均key大小 + 100字节) * 预估key数量 * 1.5
  2. 监控指标清单

    # 使用resty-cli查看内存状态
    $ resty -e 'print(ngx.shared.DICT:stat())'
    {
      "capacity": 209715200,
      "free_space": 147391232,
      "total_items": 4321,
      "stale_items": 89
    }
    

    重点监控stale_items与free_space的比例

  3. 压测经验值

    • 单个worker的QPS极限:约30k次/s get操作
    • set操作的性能约为get的1/5
    • 锁持有时间超过10ms将显著影响吞吐量

九、技术选型思考

经过多个项目验证,OpenResty共享内存适合:

✅ 高频访问的只读数据(如配置信息)

✅ 需要原子操作的计数器

✅ 分布式锁场景

但在以下场景建议换方案:

❌ 需要持久化的数据(推荐Redis)

❌ 复杂数据结构需求(推荐Redis)

❌ 超大数据包存储(推荐CDN)

十、未来演进之路

当我们把共享内存优化做到极致后,开始探索新方向:

  1. 混合存储架构:用共享内存做一级缓存,Redis做二级缓存
  2. 内存碎片整理:定期将冷数据转储到磁盘,重组内存空间
  3. 新型数据结构:基于FFI实现LRU队列、跳表等复杂结构