ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

OpenResty高性能实践:Lua非阻塞操作Redis的完整指南

2026/8/26 20:42:45 拓冰建站 浏览量
OpenResty高性能实践:Lua非阻塞操作Redis的完整指南 1. 项目概述为什么要在OpenResty里用Lua操作Redis如果你正在用OpenResty做Web开发或者API网关那你肯定绕不开缓存和状态管理。Redis这个高性能的键值数据库几乎是解决这类问题的标配。但问题来了在OpenResty的架构里Nginx的工作进程是单线程、事件驱动的直接在里面发起一个阻塞的Redis网络I/O操作无异于自杀——它会卡住整个worker进程让高并发成为空谈。所以我们得请出Lua。OpenResty的核心魔力就在于把Nginx和LuaJIT深度捆绑让你能用Lua脚本在Nginx的各个执行阶段比如access_by_lua*,content_by_lua*里写业务逻辑。而通过Lua来操作Redis本质上就是利用了OpenResty提供的非阻塞客户端库让每个请求在等待Redis响应的同时能把CPU让给其他请求处理完美契合Nginx的事件模型。这不仅仅是“能操作”而是“必须这样操作”才能发挥OpenResty的高性能优势。今天要聊的就是怎么把这套组合拳打好从连接管理到复杂的数据操作再到那些实际开发中一定会踩的坑。2. 核心组件与连接管理策略在动手写代码之前得先搞清楚我们手里有哪些牌。OpenResty操作Redis主要依赖lua-resty-redis这个官方维护的库。它不是用传统的、会阻塞的Redis客户端而是完全基于OpenResty的cosocketAPI实现这意味着它的网络通信是百分百非阻塞的这是性能的基石。2.1 连接获取短连接 vs 连接池刚上手时你可能会写出这样的代码每个请求进来创建连接执行命令然后关闭。这在低并发下没问题但高并发下频繁创建销毁TCP连接的开销是巨大的。local redis require resty.redis local red redis:new() red:set_timeouts(1000, 1000, 1000) -- 设置连接、发送、读取超时毫秒 local ok, err red:connect(127.0.0.1, 6379) if not ok then ngx.log(ngx.ERR, failed to connect: , err) return ngx.exit(500) end -- ... 执行一些命令 ... local ok, err red:close() -- 每个请求都关闭连接 if not ok then ngx.log(ngx.ERR, failed to close: , err) end更优的做法是使用连接池。OpenResty的Redis客户端支持标准的set_keepalive方法。-- 在请求处理结束时代替 red:close() local ok, err red:set_keepalive(10000, 100) -- 连接池最大空闲时间10秒连接池大小100 if not ok then ngx.log(ngx.ERR, failed to set keepalive: , err) -- 如果放回连接池失败则强制关闭 red:close() end注意set_keepalive实际上是把当前连接标记为空闲放回连接池供后续请求复用。第一个参数是最大空闲时间毫秒超过这个时间连接池会自动关闭该连接。第二个参数是连接池大小即这个Nginx Worker进程最多能保持多少个空闲连接。这个值需要根据你的实际QPS和Redis服务器配置来调整不是越大越好。2.2 超时与重连策略网络是不稳定的。必须为所有网络操作设置合理的超时。set_timeouts函数接收三个参数连接超时、发送超时、读取超时。通常读取超时是最需要关注的因为它包含了Redis服务器处理命令并返回结果的时间。对于复杂命令或大数据量操作这个值要适当调大。关于重连lua-resty-redis库本身没有自动重连机制。如果连接断开比如Redis重启下一次命令操作会返回错误。一个健壮的实践是在每次执行命令前检查连接状态或者在捕获到连接类错误时进行一次性重连。但注意重连逻辑本身也要有超时和次数限制避免陷入死循环。local function execute_redis_cmd(red, cmd, ...) local res, err red[cmd](red, ...) if err then -- 检查错误类型是否为连接断开 if string.find(err, closed, 1, true) then -- 尝试重新连接一次 local ok, reconnect_err red:connect(127.0.0.1, 6379) if not ok then return nil, reconnect failed: .. reconnect_err end -- 重连后重试命令一次 res, err red[cmd](red, ...) end end return res, err end3. 基础数据操作与管道化优化掌握了连接我们来看看怎么用。lua-resty-redis的API设计基本与Redis命令一一对应只是变成了Lua的函数调用风格。3.1 字符串、哈希与列表操作基础操作非常直观但有些细节需要注意。-- 字符串 local ok, err red:set(user:1001:name, 张三) if not ok then ngx.log(ngx.ERR, err) end -- 带过期时间EX单位秒PX单位毫秒 local ok, err red:setex(user:1001:session, 3600, session_token_abc) if not ok then ngx.log(ngx.ERR, err) end -- 哈希表 local res, err red:hmset(user:1001:profile, age, 30, city, 北京, job, 工程师) if err then ngx.log(ngx.ERR, err) end -- 注意hmset返回的是字符串“OK”而不是true。判断要用 res ~ ngx.null and res OK实操心得Redis的nil返回值在Lua里会被转换成ngx.null这个特殊的用户数据。直接if not res判断会出错。正确的做法是if res ngx.null then ... end或者if res ~ ngx.null then ... end。这是新手最容易栽跟头的地方之一。3.2 管道Pipeline性能利器这是Redis客户端最重要的优化手段。它的原理是将多个命令打包一次性发送给Redis服务器服务器按顺序处理后再一次性返回结果。这避免了多次网络往返RTT的延迟在高并发场景下性能提升极其显著。-- 开启管道 red:init_pipeline() -- 将所有命令缓存起来而不是立即发送 red:set(counter, 0) red:incr(counter) red:get(counter) red:hset(config, version, 1.0) -- 提交管道所有命令被一次性发送并执行 local results, err red:commit_pipeline() if not results then ngx.log(ngx.ERR, failed to commit pipeline: , err) return end -- results 是一个Lua数组按命令提交顺序存放着每个命令的返回值 -- results[1] 对应 set 的 “OK” -- results[2] 对应 incr 后的值 1 -- results[3] 对应 get 到的值 “1” -- results[4] 对应 hset 的 1 (表示成功设置了一个新字段) for i, v in ipairs(results) do if v ~ ngx.null then ngx.log(ngx.INFO, Cmd , i, result: , v) end end重要提示管道内的命令没有事务保证和Redis的MULTI/EXEC不同。如果管道中某个命令执行失败它后面的命令依然会继续执行。你需要检查results数组中每个元素来判断单个命令的成功与否。另外管道中的命令共享同一个连接所以必须确保在commit_pipeline之前没有插入其他非管道命令否则会打乱顺序。4. 执行Lua脚本与原子性操作对于更复杂的逻辑比如需要先读后写并确保原子性管道就不够用了。这时需要Redis的EVAL命令也就是执行服务器端的Lua脚本。OpenResty的客户端也提供了对应接口。4.1 调用Redis Lua脚本假设我们有一个简单的库存扣减场景需要检查库存是否充足然后扣减。-- 定义Lua脚本。注意脚本里访问KEYS和ARGV数组是从1开始索引的 local script [[ local key KEYS[1] local decrement tonumber(ARGV[1]) local current redis.call(GET, key) if not current then return -1 -- 表示key不存在 end current tonumber(current) if current decrement then return -2 -- 表示库存不足 end redis.call(DECRBY, key, decrement) return current - decrement -- 返回扣减后的库存 ]] -- 将脚本加载到Redis服务器得到一个sha1哈希摘要后续用这个摘要来执行效率更高 local sha1, err red:script(LOAD, script) if not sha1 then ngx.log(ngx.ERR, failed to load script: , err) -- 降级方案直接使用EVAL -- res, err red:eval(script, 1, item:stock:1001, 2) end -- 使用evalsha执行脚本。参数sha1摘要 key的数量 key列表 参数列表 local res, err red:evalsha(sha1, 1, item:stock:1001, 2) if err then -- 如果错误是“NOSCRIPT”说明脚本未加载例如Redis重启了需要重新加载 if string.find(err, NOSCRIPT, 1, true) then sha1, err red:script(LOAD, script) if not sha1 then ... end -- 重试一次 res, err red:evalsha(sha1, 1, item:stock:1001, 2) else ngx.log(ngx.ERR, evalsha error: , err) end end if res then if res -1 then ngx.say(商品不存在) elseif res -2 then ngx.say(库存不足) else ngx.say(扣减成功剩余库存, res) end end4.2 脚本执行的注意事项原子性整个脚本在执行期间是原子的不会被其他命令打断这是它相比管道和普通命令组合的最大优势。脚本缓存使用SCRIPT LOAD和EVALSHA是生产环境的最佳实践。它避免了每次执行都要将冗长的脚本内容通过网络发送节省带宽。但要做好NOSCRIPT错误的处理。脚本复杂度Redis是单线程执行命令的一个运行时间过长的Lua脚本会阻塞整个Redis实例导致所有客户端超时。脚本里避免使用keys命令进行全库扫描循环操作也要非常小心。返回值转换Redis Lua脚本的返回值会被lua-resty-redis自动转换为对应的Lua类型。数字、字符串、表数组都能正确转换。nil同样会转换成ngx.null。5. 生产环境配置与高阶实践把代码跑起来只是第一步要让它在生产环境稳定高效地运行还需要一系列配置和策略。5.1 OpenResty配置集成通常我们会在nginx.conf的http块中通过init_by_lua_block或init_worker_by_lua_block来预加载模块和初始化全局配置。但注意不能在初始化阶段创建Redis连接因为连接是请求相关的不同worker进程是隔离的。http { # 预加载Lua模块提升单个请求的处理速度 init_by_lua_block { require resty.redis -- 可以在这里定义一些全局的常量或配置比如Redis服务器地址 redis_host 10.0.0.5 -- 注意这是全局变量所有worker共享。如果配置会变建议用共享字典。 redis_port 6379 redis_timeout 1000 redis_pool_size 100 redis_idle_timeout 10000 } server { location /api/cache { content_by_lua_block { local redis require resty.redis local red redis:new() red:set_timeouts(redis_timeout, redis_timeout, redis_timeout) -- 使用上面定义的全局变量或从共享字典读取 local ok, err red:connect(redis_host, redis_port) if not ok then ngx.log(ngx.ERR, Redis connect failed: , err) ngx.status 500 ngx.say({\error\:\internal error\}) return end -- ... 业务逻辑 ... -- 连接放回连接池 red:set_keepalive(redis_idle_timeout, redis_pool_size) } } } }5.2 连接池与健康检查连接池参数需要压测调整。set_keepalive的池大小如果设置过大可能会在业务低峰期造成Redis服务器维持大量无用连接。可以结合lua-resty-upstream-healthcheck等模块实现对Redis后端的高可用和健康检查。更复杂的场景如果使用Redis Sentinel或Cluster连接管理逻辑会更复杂通常需要借助额外的客户端库或自己实现故障转移逻辑。5.3 错误处理与降级任何外部服务调用都必须有完善的错误处理。对于Redis操作基本的错误处理包括连接失败记录日志返回给客户端一个友好的错误如“系统繁忙”并考虑是否有本地降级数据如Nginx共享字典lua_shared_dict。命令执行失败根据错误类型判断。如果是超时可能是Redis压力大或网络问题可以记录并降级。如果是业务逻辑错误如类型错误则返回具体的业务错误码。资源清理无论成功失败在content_by_lua*块结束前一定要确保连接被正确关闭或放回连接池。可以使用Lua的pcall或xpcall结合finally模式用defer类似的逻辑来保证。local function handle_request() local red redis:new() red:set_timeouts(1000, 1000, 1000) local ok, err red:connect(127.0.0.1, 6379) if not ok then ngx.log(ngx.ERR, Connect failed, will use local cache. Error: , err) -- 从共享字典获取降级数据 local cache ngx.shared.my_cache local data cache:get(fallback_data) return data or default value end -- 使用pcall保护命令执行 local res, cmd_err pcall(red.get, red, some_key) if not res then ngx.log(ngx.ERR, Redis cmd failed: , cmd_err) -- 执行降级逻辑 end -- 确保连接被回收 local ok, set_err red:set_keepalive(10000, 100) if not ok then ngx.log(ngx.WARN, Failed to set keepalive: , set_err) red:close() end return cmd_res end6. 常见问题排查与性能调优在实际部署中你肯定会遇到各种奇怪的问题。这里记录几个典型场景和排查思路。6.1 连接数暴涨或泄露现象Redis服务器监控显示连接数异常高甚至超过maxclients限制导致新连接被拒绝。Nginx错误日志中出现大量connect() failed (99: Cannot assign requested address)或类似的错误。排查思路检查连接池使用最可能的原因是忘记使用set_keepalive或者错误地在每个请求中都调用了red:close()。确保业务逻辑的所有分支包括异常分支最终都执行了set_keepalive。检查连接池参数set_keepalive的第二个参数连接池大小是否设置得过大一个Nginx worker进程保持几百个空闲连接可能就够用了设置几千会导致连接泄露。检查Redis配置Redis服务器的timeout参数客户端空闲多少秒后关闭连接是否设置过小如果小于OpenResty连接池的idle_timeout会导致Redis主动断开连接而OpenResty客户端可能还在尝试复用这个已失效的连接引发错误。使用工具分析在Redis客户端使用CLIENT LIST命令查看连接的idle时间、flags等信息判断连接是否来自Nginx以及是否空闲。在Nginx端可以通过lua-resty-redis提供的get_reused_times()方法查看当前连接被复用的次数如果总是0说明连接池没生效。6.2 请求延迟高或超时现象API响应时间变长监控显示Redis操作耗时增加甚至出现超时错误。排查思路区分网络延迟与Redis处理延迟在Nginx服务器上直接使用redis-cli执行一个简单的PING命令看延迟是否正常。如果redis-cli也慢问题可能在网络或Redis服务器本身。检查Redis服务器负载使用Redis的INFO命令查看instantaneous_ops_per_sec每秒操作数、used_memory内存使用、connected_clients连接数以及cpu使用情况。检查是否有慢查询SLOWLOG GET。检查OpenResty超时设置set_timeouts的三个值是否设置合理特别是读取超时对于HGETALL一个大哈希或LRANGE一个长列表默认的1秒可能不够。但盲目调大超时会增加系统在故障时的不可用时间更好的办法是优化数据结构和命令。检查是否使用了管道对于需要执行多个命令的场景没有使用管道会导致数倍的网络RTT延迟。用管道批量处理是降低延迟最有效的手段之一。检查Lua脚本是否执行了过于复杂的Lua脚本用SLOWLOG检查脚本执行时间。优化脚本逻辑避免在脚本里做大量循环或keys *这类操作。6.3 内存使用异常现象Nginx worker进程内存持续增长甚至被OOM Killer杀掉。排查思路检查Lua变量引用是否在Lua代码中将Redis返回的大对象比如一个包含几万条数据的数组保存在了模块级的全局变量中导致其无法被垃圾回收确保大数据只在请求生命周期内存在。检查连接池泄漏同6.1连接也是内存资源。检查Redis返回值处理对于可能返回超大数据的命令如HGETALL一个字段非常多的哈希要有保护措施。可以在执行前用HLEN判断大小或者使用HSCAN进行增量迭代避免单次操作拖垮单个Nginx worker。6.4 性能调优速查表问题现象可能原因排查/优化方向高延迟网络问题、Redis负载高、未用管道、复杂Lua脚本1. 网络链路诊断。2. 监控RedisINFO。3. 对多个命令使用管道。4. 分析并优化SLOWLOG中的脚本。高连接数连接池未生效、参数不合理、连接泄露1. 确保代码路径都调用set_keepalive。2. 调整连接池大小和空闲时间。3. 检查Redistimeout与Nginxidle_timeout的匹配关系。内存增长Lua全局变量持有大数据、连接泄露、大Key操作1. 避免大数据存入全局变量。2. 排查连接泄露。3. 对大Key使用SCAN系列命令分批处理。超时错误超时设置过短、Redis阻塞、网络抖动1. 适当调大set_timeouts需权衡。2. 检查Redis是否在执行BGSAVE、KEYS *或复杂脚本。3. 增加重试机制需幂等。nil值判断错误混淆Luanil与ngx.null所有Redis返回值判断前先检查res ngx.null。7. 进阶封装一个健壮的Redis客户端工具类在真实项目中我们不会在每个location里都写一遍连接、错误处理、释放的模板代码。封装一个工具类是必然选择。这个工具类需要处理连接池、重试、基本的超时和降级。-- file: lib/redis_cli.lua local redis require resty.redis local cjson require cjson.safe local _M { _VERSION 0.1 } local mt { __index _M } -- 配置可以从外部传入也可以写死 local default_config { host 127.0.0.1, port 6379, timeout 1000, -- 连接、发送、读取超时 pool_size 100, idle_timeout 10000, -- 连接池空闲时间 max_retry 1, -- 失败重试次数仅针对网络错误 } function _M.new(self, config) config config or {} setmetatable(config, { __index default_config }) return setmetatable({ config config }, mt) end function _M.get_connection(self) local red redis:new() red:set_timeouts(self.config.timeout, self.config.timeout, self.config.timeout) local ok, err red:connect(self.config.host, self.config.port) if not ok then return nil, failed to connect: .. (err or unknown error) end -- 可以在这里做一些连接后的初始化比如选择DB认证等 -- if self.config.password then -- local res, auth_err red:auth(self.config.password) -- if not res then ... end -- end -- if self.config.db then -- red:select(self.config.db) -- end return red end function _M.execute(self, cmd, ...) local red, conn_err self:get_connection() if not red then return nil, conn_err end local ret, err local retry 0 while retry self.config.max_retry do ret, err red[cmd](red, ...) if not err then break -- 成功则跳出重试循环 end -- 判断是否为可重试的错误如连接断开 if string.find(err, closed, 1, true) and retry self.config.max_retry then retry retry 1 ngx.log(ngx.WARN, Redis command failed, retrying (, retry, ): , err) -- 关闭旧连接获取新连接 red:close() red, conn_err self:get_connection() if not red then return nil, reconnect failed on retry: .. conn_err end else break -- 不可重试错误或重试次数用尽 end end -- 无论成功失败都尝试放回连接池 local ok, set_err red:set_keepalive(self.config.idle_timeout, self.config.pool_size) if not ok then ngx.log(ngx.WARN, Failed to set keepalive: , set_err) red:close() end if err then return nil, Redis command failed after retries: .. err end return ret end -- 封装管道操作 function _M.pipeline(self, commands) -- commands 是一个数组每个元素是 {cmd, arg1, arg2, ...} local red, conn_err self:get_connection() if not red then return nil, conn_err end red:init_pipeline() for _, cmd_table in ipairs(commands) do local cmd cmd_table.cmd local args cmd_table.args or {} red[cmd](red, unpack(args)) end local results, err red:commit_pipeline() local ok, set_err red:set_keepalive(self.config.idle_timeout, self.config.pool_size) if not ok then ngx.log(ngx.WARN, Failed to set keepalive: , set_err) red:close() end if not results then return nil, Pipeline failed: .. (err or unknown error) end return results end return _M使用这个封装类业务代码会简洁很多local redis_cli require lib.redis_cli local red redis_cli:new({ host 10.0.0.5, pool_size 50 }) local value, err red:execute(get, my_key) if err then ngx.log(ngx.ERR, err) -- 降级处理 else ngx.say(value: , value) end -- 使用管道 local cmds { { cmd set, args {a, 1} }, { cmd incr, args {a} }, { cmd get, args {a} } } local results, pipe_err red:pipeline(cmds)这个工具类只是一个起点你可以根据需要添加更多功能比如支持Sentinel/Cluster、集成监控指标上报、更复杂的熔断降级策略等。记住没有银弹最好的工具永远是贴合你自己业务需求的那一个。