在 OpenResty + Canal + Redis 的架构中,Redis 缓存预热 的核心目标是:系统启动 / 数据变更时,提前将热点数据加载到 Redis,避免缓存穿透、冷启动时的数据库压力,同时保证缓存与数据库数据一致。
结合你已有的 Canal 监听能力(实时感知数据库变更),缓存预热可分为两种核心场景:
- 全量预热:系统启动 / 重启时,批量加载数据库中已有的热点数据到 Redis(解决冷启动问题);
- 增量预热:运行时通过 Canal 监听数据库增删改事件,实时同步更新 Redis 缓存(解决数据一致性问题,相当于 "动态预热")。
下面基于 OpenResty 实现这两种预热方案,同时兼顾性能、非阻塞特性和数据一致性。
一、核心逻辑与前置准备
1. 缓存预热核心原则
- 非阻塞执行 :OpenResty 是事件驱动模型,预热任务(尤其是全量预热)必须异步执行(用
ngx.timer),避免阻塞主进程启动; - 分批加载:全量预热时,数据库数据量大时需分页查询,避免一次性加载过多数据导致内存溢出或数据库压力飙升;
- 热点筛选:优先预热高频访问的数据(如用户表、商品表核心字段),而非全量数据库数据;
- 幂等性:增量预热(Canal 触发)需保证重复事件(如 Canal 重试)不会导致缓存数据错误;
- 过期兜底:缓存设置合理过期时间,避免数据长期不一致(配合 Canal 实时更新,双重保障)。
2. 前置依赖安装
需确保 OpenResty 已安装 MySQL 非阻塞客户端(用于全量预热时读取数据库):
bash
运行
# 安装 lua-resty-mysql(非阻塞 MySQL 客户端)
sudo /usr/local/openresty/luajit/bin/luarocks install lua-resty-mysql
二、方案实现:全量预热 + 增量预热(混合模式)
1. 整体架构设计
- 全量预热 :OpenResty 启动时,通过
init_worker_by_lua_block异步触发,分批查询数据库热点表,写入 Redis; - 增量预热 :复用之前的 Canal 监听逻辑,在处理
INSERT/UPDATE/DELETE事件时,同步更新 Redis 缓存(覆盖全量预热后的数据变更); - 配置管理 :用
lua_shared_dict存储热点表清单、预热状态,支持动态调整。
2. 步骤 1:配置 OpenResty(nginx.conf)
新增缓存预热相关配置,包括数据库连接信息、热点表配置、异步预热触发:
nginx
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
lua_jit on;
# 共享内存:存储配置、预热状态(10MB 足够)
lua_shared_dict cache_warmup_config 10m;
# 初始化配置:数据库连接、热点表、Redis 配置
init_by_lua_block {
local config = ngx.shared.cache_warmup_config
-- 数据库配置
config:set("db_host", "127.0.0.1")
config:set("db_port", 3306)
config:set("db_user", "root")
config:set("db_password", "123456")
config:set("db_name", "user_db") -- 目标数据库
-- Redis 配置
config:set("redis_host", "127.0.0.1")
config:set("redis_port", 6379)
config:set("redis_password", "")
-- 热点表配置(key:表名,value:缓存 key 前缀 + 主键字段 + 过期时间)
local hot_tables = {
user_info = {
cache_prefix = "user:", -- Redis key:user:123
primary_key = "id", -- 主键字段(用于唯一标识数据)
expire = 86400, -- 缓存过期时间(1 天)
batch_size = 1000 -- 分批查询大小(每次查 1000 条)
},
product = {
cache_prefix = "product:",
primary_key = "pid",
expire = 3600,
batch_size = 2000
}
}
config:set("hot_tables", cjson.encode(hot_tables))
-- 预热状态(未开始/进行中/完成)
config:set("warmup_status", "pending")
}
# 系统启动时触发全量缓存预热(异步执行,避免阻塞)
init_worker_by_lua_block {
-- 1. 启动 Canal 监听(增量预热依赖)
local canal_listener = require("canal_listener")
ngx.timer.at(0, function() canal_listener.start() end)
-- 2. 启动全量缓存预热(延迟 1 秒执行,等待其他组件初始化)
local cache_warmup = require("cache_warmup")
ngx.timer.at(1, function() cache_warmup.full_warmup() end)
}
server {
listen 80;
server_name localhost;
# 预热状态查询接口(用于监控)
location /warmup/status {
content_by_lua_block {
local config = ngx.shared.cache_warmup_config
local status = config:get("warmup_status")
local progress = config:get("warmup_progress") or 0
ngx.say(cjson.encode({
status = status,
progress = progress .. "%",
update_time = os.date("%Y-%m-%d %H:%M:%S")
}))
}
}
# 手动触发全量预热接口(可选,用于运维操作)
location /warmup/trigger {
content_by_lua_block {
local cache_warmup = require("cache_warmup")
ngx.timer.at(0, function() cache_warmup.full_warmup() end)
ngx.say(cjson.encode({ code = 200, msg = "全量预热已触发" }))
}
}
}
}
3. 步骤 2:编写全量预热模块(cache_warmup.lua)
创建 /usr/local/openresty/lualib/cache_warmup.lua,实现数据库分批查询、Redis 写入逻辑:
lua
local mysql = require("resty.mysql")
local redis = require("resty.redis")
local cjson = require("cjson")
local config = ngx.shared.cache_warmup_config
-- 连接 MySQL(非阻塞)
local function connect_mysql()
local db = mysql:new()
db:set_timeout(3000) -- 连接超时 3 秒
local ok, err = db:connect({
host = config:get("db_host"),
port = config:get("db_port"),
database = config:get("db_name"),
user = config:get("db_user"),
password = config:get("db_password"),
charset = "utf8mb4"
})
if not ok then
ngx.log(ngx.ERR, "MySQL 连接失败: ", err)
return nil, err
end
return db
end
-- 连接 Redis(非阻塞,复用连接池)
local function connect_redis()
local red = redis:new()
red:set_timeout(1000)
local ok, err = red:connect(config:get("redis_host"), config:get("redis_port"))
if not ok then
ngx.log(ngx.ERR, "Redis 连接失败: ", err)
return nil, err
end
-- 若 Redis 有密码,执行认证
local redis_pwd = config:get("redis_password")
if redis_pwd and redis_pwd ~= "" then
local ok, err = red:auth(redis_pwd)
if not ok then
ngx.log(ngx.ERR, "Redis 认证失败: ", err)
return nil, err
end
end
-- 设置连接池(复用连接,减少开销)
red:set_keepalive(60000, 100) -- 空闲 60 秒,最大 100 个连接
return red
end
-- 单表分批预热
local function warmup_single_table(table_info)
local table_name = table_info.table_name
local cache_prefix = table_info.cache_prefix
local primary_key = table_info.primary_key
local expire = table_info.expire
local batch_size = table_info.batch_size
ngx.log(ngx.INFO, "开始预热表: ", table_name, ", 分批大小: ", batch_size)
local db, err = connect_mysql()
if not db then
return false, err
end
local red, err = connect_redis()
if not red then
db:close()
return false, err
end
-- 步骤 1:查询表总行数(用于计算进度)
local count_sql = "SELECT COUNT(" .. primary_key .. ") AS total FROM " .. table_name
local count_res, err = db:query(count_sql)
if not count_res or #count_res == 0 then
ngx.log(ngx.ERR, "查询表 ", table_name, " 总行数失败: ", err)
db:close()
return false, err
end
local total = tonumber(count_res[1].total)
if total == 0 then
ngx.log(ngx.INFO, "表 ", table_name, " 无数据,跳过预热")
db:close()
return true
end
-- 步骤 2:分批查询并写入 Redis
local offset = 0 -- 分页偏移量
local success_count = 0 -- 成功预热的记录数
while offset < total do
-- 分页查询 SQL(按主键排序,避免重复/遗漏)
local query_sql = string.format(
"SELECT * FROM %s ORDER BY %s LIMIT %d OFFSET %d",
table_name, primary_key, batch_size, offset
)
local res, err = db:query(query_sql)
if not res then
ngx.log(ngx.ERR, "查询表 ", table_name, " 失败(offset: ", offset, "): ", err)
db:close()
return false, err
end
-- 批量写入 Redis(用 pipeline 提升性能)
red:init_pipeline()
for _, row in ipairs(res) do
local cache_key = cache_prefix .. row[primary_key]
-- 将行数据序列化为 JSON 存入 Redis
red:set(cache_key, cjson.encode(row))
-- 设置过期时间
red:expire(cache_key, expire)
success_count = success_count + 1
end
-- 执行 pipeline
local ok, err = red:commit_pipeline()
if not ok then
ngx.log(ngx.ERR, "Redis 批量写入失败: ", err)
db:close()
return false, err
end
-- 更新预热进度
local progress = math.floor((success_count / total) * 100)
config:set("warmup_progress", progress)
ngx.log(ngx.INFO, "表 ", table_name, " 预热进度: ", progress, "% (", success_count, "/", total, ")")
-- 偏移量递增
offset = offset + batch_size
-- 短暂休眠(避免压垮数据库,可选)
ngx.sleep(0.1) -- 100 毫秒
end
db:close()
ngx.log(ngx.INFO, "表 ", table_name, " 预热完成,共加载 ", success_count, " 条数据")
return true
end
-- 全量缓存预热(入口函数)
function _M.full_warmup()
local config = ngx.shared.cache_warmup_config
local current_status = config:get("warmup_status")
-- 避免重复执行(若正在预热中,直接返回)
if current_status == "running" then
ngx.log(ngx.WARN, "全量预热正在进行中,无需重复触发")
return
end
-- 更新预热状态为"进行中"
config:set("warmup_status", "running")
config:set("warmup_progress", 0)
-- 读取热点表配置
local hot_tables_json = config:get("hot_tables")
local hot_tables = cjson.decode(hot_tables_json)
-- 遍历所有热点表,依次预热
for table_name, table_info in pairs(hot_tables) do
table_info.table_name = table_name -- 补充表名
local ok, err = warmup_single_table(table_info)
if not ok then
ngx.log(ngx.ERR, "表 ", table_name, " 预热失败: ", err)
config:set("warmup_status", "failed")
config:set("warmup_error", err)
return
end
end
-- 所有表预热完成
config:set("warmup_status", "completed")
config:set("warmup_progress", 100)
ngx.log(ngx.INFO, "全量缓存预热全部完成!")
end
return _M
4. 步骤 3:优化 Canal 增量预热(canal_listener.lua)
复用之前的 Canal 监听模块,在 handle_event 函数中添加 增量缓存更新逻辑,实现 "数据变更即预热":
lua
-- 在 canal_listener.lua 中添加 Redis 连接逻辑(复用 cache_warmup 的连接池逻辑)
local redis = require("resty.redis")
local cjson = require("cjson")
local config = ngx.shared.cache_warmup_config
-- 连接 Redis(复用连接池,与全量预热共用)
local function connect_redis()
local red = redis:new()
red:set_timeout(1000)
local ok, err = red:connect(config:get("redis_host"), config:get("redis_port"))
if not ok then
ngx.log(ngx.ERR, "Redis 连接失败: ", err)
return nil, err
end
local redis_pwd = config:get("redis_password")
if redis_pwd and redis_pwd ~= "" then
red:auth(redis_pwd)
end
red:set_keepalive(60000, 100)
return red
end
-- 优化 handle_event 函数,添加增量缓存更新
local function handle_event(event)
ngx.log(ngx.INFO, "收到 binlog 事件: ", cjson.encode(event))
-- 读取热点表配置,判断当前表是否需要预热
local hot_tables_json = config:get("hot_tables")
local hot_tables = cjson.decode(hot_tables_json)
local table_info = hot_tables[event.table]
if not table_info then
ngx.log(ngx.WARN, "表 ", event.table, " 非热点表,跳过缓存更新")
return
end
local red, err = connect_redis()
if not red then
return
end
local cache_prefix = table_info.cache_prefix
local primary_key = table_info.primary_key
local expire = table_info.expire
-- 根据操作类型更新缓存
if event.op == "INSERT" or event.op == "UPDATE" then
-- 新增/更新:写入缓存(覆盖旧数据)
for _, row in ipairs(event.data) do
local cache_key = cache_prefix .. row[primary_key]
red:set(cache_key, cjson.encode(row))
red:expire(cache_key, expire)
ngx.log(ngx.INFO, "增量预热: ", cache_key, " (", event.op, ")")
end
elseif event.op == "DELETE" then
-- 删除:移除缓存
for _, row in ipairs(event.data) do
local cache_key = cache_prefix .. row[primary_key]
red:del(cache_key)
ngx.log(ngx.INFO, "缓存删除: ", cache_key)
end
end
end
三、关键优化与注意事项
1. 性能优化
- 分批查询 + 分页排序 :全量预热时按主键排序分页(
ORDER BY primary_key LIMIT offset, batch_size),避免数据库分页漏数据(如无排序时数据插入导致偏移量失效); - Redis Pipeline :批量写入 Redis 时使用
init_pipeline()+commit_pipeline(),减少网络往返次数(单批次 1000 条数据可提升 10x 性能); - 连接池复用 :MySQL 和 Redis 均使用
set_keepalive()复用连接,避免频繁建立 / 关闭连接的开销; - 异步执行 :全量预热通过
ngx.timer异步触发,不阻塞 OpenResty 启动;单表预热时可通过ngx.thread.spawn()实现多表并行预热(需控制并发数,避免数据库压力过大)。
2. 数据一致性保障
- 过期时间兜底:所有缓存设置过期时间(如 1 天),即使 Canal 同步失败或全量预热遗漏,缓存过期后会自动回源数据库更新,避免数据长期不一致;
- 幂等性处理 :Canal 可能重复推送事件(如网络波动),
INSERT/UPDATE操作直接覆盖缓存,DELETE操作重复执行无副作用,天然支持幂等; - 过滤非热点表:仅对配置中的热点表进行预热,减少无效缓存和数据库压力。
3. 避免风险
-
数据库压力控制 :全量预热时设置合理的
batch_size(如 1000-2000 条)和休眠时间(如 100ms),避免一次性占用数据库所有连接; -
内存控制:OpenResty 工作进程内存有限,分批加载时避免单次读取过多数据(如大表分 1000 批,每批 1000 条);
-
监控告警 :通过
/warmup/status接口监控预热状态,对接 Prometheus/Grafana 配置告警(如预热失败、进度停滞 10 分钟); -
手动回滚 :新增
/warmup/clear接口,用于清理缓存(如预热数据错误时):lua
-- cache_warmup.lua 中新增清理函数 function _M.clear_cache() local red, err = connect_redis() if not red then return false, err end local hot_tables = cjson.decode(config:get("hot_tables")) for _, table_info in pairs(hot_tables) do local cache_key = table_info.cache_prefix .. "*" local keys = red:keys(cache_key) if keys and #keys > 0 then red:del(unpack(keys)) ngx.log(ngx.INFO, "清理缓存: ", cache_key, " (", #keys, " 条)") end end return true end
4. 扩展场景
-
定时全量预热 :通过
ngx.timer.every()实现定时全量预热(如每天凌晨 2 点执行),适用于数据变更不频繁的场景:lua
-- init_worker_by_lua_block 中添加 ngx.timer.every(86400, function() -- 86400 秒 = 1 天 cache_warmup.full_warmup() end) -
热点数据动态调整 :通过
lua_shared_dict动态更新hot_tables配置(如新增商品表预热),无需重启 OpenResty; -
分库分表支持 :若数据库分库分表,可在
hot_tables中配置多个分表,或通过 Canal 监听所有分表的 binlog 事件。
四、测试验证
1. 全量预热测试
bash
运行
# 启动 OpenResty
sudo /usr/local/openresty/nginx/sbin/nginx -c /usr/local/openresty/nginx/conf/nginx.conf
# 查看预热状态
curl http://localhost/warmup/status
# 输出:{"status":"completed","progress":"100%","update_time":"2025-12-02 15:30:00"}
# 验证 Redis 缓存(以 user 表为例)
redis-cli get user:1
# 输出:{"id":1,"name":"test","age":20,...}(数据库中的用户数据)
2. 增量预热测试
bash
运行
# 操作数据库(更新用户数据)
mysql -u root -p123456 user_db -e "UPDATE user_info SET name='test_update' WHERE id=1"
# 查看 OpenResty 日志
tail -f /usr/local/openresty/nginx/logs/error.log
# 输出:增量预热: user:1 (UPDATE)
# 验证 Redis 缓存已更新
redis-cli get user:1
# 输出:{"id":1,"name":"test_update","age":20,...}
3. 缓存删除测试
bash
运行
# 删除数据库数据
mysql -u root -p123456 user_db -e "DELETE FROM user_info WHERE id=1"
# 验证 Redis 缓存已删除
redis-cli get user:1
# 输出:(nil)
五、方案总结
本方案基于 OpenResty + Canal + Redis 实现了 "全量预热 + 增量预热" 的混合缓存预热策略,核心优势:
- 冷启动优化:系统启动时全量加载热点数据,避免缓存穿透;
- 实时同步:运行时通过 Canal 监听数据变更,增量更新缓存,保证数据一致性;
- 高性能:非阻塞 I/O + 连接池 + Pipeline 优化,支持大表分批预热;
- 可运维:提供预热状态查询、手动触发 / 清理接口,便于监控和故障处理。