一、为什么需要关注共享内存?
在电商大促的夜晚,我们的流量网关突然出现响应延迟飙升。查看监控发现,原本应该承担抗压重任的共享内存缓存层,此时竟然变成了性能瓶颈。这就是典型的共享内存使用不当的代价——当缓存命中率跌破警戒线、锁竞争导致线程阻塞、内存泄漏又悄悄吃掉服务器资源时,整个系统都会陷入泥潭。
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
这个看似平凡的代码包含三个优化技巧:
- 双检锁结构:在获取锁前后都检查缓存,避免重复更新
- 数据压缩:特别适合大文本数据,可以节省50%以上空间
- 独立锁域:使用独立内存区存储锁信息,防止误覆盖
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
这里有两个致命错误:
- 没有设置锁超时时间,一旦进程异常会导致死锁
- 异常分支没有释放锁
正确姿势应该是:
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
这里有两个致命错误:
- 使用table.concat拼接大字符串,导致LuaVM内存暴涨
- 共享内存操作是原子性的,多个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")
这个方案有三重保障:
- 使用FFI直接操作内存,避免Lua表的额外内存开销
- 每个请求独立缓冲区,避免全局竞争
- 使用共享内存的原子操作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; # 增大连接池
}
七、避坑指南:血的教训
数据类型陷阱:共享内存只能存储字符串,数字会自动转换
local ok, err = cache:set("count", 100) -- 正确 local count = cache:get("count") -- 返回字符串"100" local num = tonumber(count) or 0 -- 必须显式转换原子性魔咒:incr是原子操作,但get+incr组合不是
-- 错误:非原子操作 local value = cache:get("stock") or 0 cache:set("stock", value - 1) -- 正确:使用原子指令 cache:incr("stock", -1)内存溢出深渊:每次set操作必须检查返回值
local ok, err = cache:set(key, value) if not ok then ngx.log(ngx.ERR, "cache full: ", err) -- 触发LRU淘汰或报警 end
八、最佳实践手册
根据三年OpenResty优化经验,总结出三大黄金法则:
容量规划原则:
- 共享内存占用建议不超过物理内存的30%
- 单个key的value不超过1MB(超过建议分片存储)
- 预估公式:总容量 = (平均key大小 + 100字节) * 预估key数量 * 1.5
监控指标清单:
# 使用resty-cli查看内存状态 $ resty -e 'print(ngx.shared.DICT:stat())' { "capacity": 209715200, "free_space": 147391232, "total_items": 4321, "stale_items": 89 }重点监控stale_items与free_space的比例
压测经验值:
- 单个worker的QPS极限:约30k次/s get操作
- set操作的性能约为get的1/5
- 锁持有时间超过10ms将显著影响吞吐量
九、技术选型思考
经过多个项目验证,OpenResty共享内存适合:
✅ 高频访问的只读数据(如配置信息)
✅ 需要原子操作的计数器
✅ 分布式锁场景
但在以下场景建议换方案:
❌ 需要持久化的数据(推荐Redis)
❌ 复杂数据结构需求(推荐Redis)
❌ 超大数据包存储(推荐CDN)
十、未来演进之路
当我们把共享内存优化做到极致后,开始探索新方向:
- 混合存储架构:用共享内存做一级缓存,Redis做二级缓存
- 内存碎片整理:定期将冷数据转储到磁盘,重组内存空间
- 新型数据结构:基于FFI实现LRU队列、跳表等复杂结构
评论