Redis 缓存预热

在 OpenResty + Canal + Redis 的架构中,Redis 缓存预热 的核心目标是:系统启动 / 数据变更时,提前将热点数据加载到 Redis,避免缓存穿透、冷启动时的数据库压力,同时保证缓存与数据库数据一致

结合你已有的 Canal 监听能力(实时感知数据库变更),缓存预热可分为两种核心场景:

  1. 全量预热:系统启动 / 重启时,批量加载数据库中已有的热点数据到 Redis(解决冷启动问题);
  2. 增量预热:运行时通过 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 实现了 "全量预热 + 增量预热" 的混合缓存预热策略,核心优势:

  1. 冷启动优化:系统启动时全量加载热点数据,避免缓存穿透;
  2. 实时同步:运行时通过 Canal 监听数据变更,增量更新缓存,保证数据一致性;
  3. 高性能:非阻塞 I/O + 连接池 + Pipeline 优化,支持大表分批预热;
  4. 可运维:提供预热状态查询、手动触发 / 清理接口,便于监控和故障处理。
相关推荐
秦jh_41 分钟前
【Qt】Qt 概述
开发语言·qt
稚辉君.MCA_P8_Java42 分钟前
在Java中,将`Short`(包装类)或`short`(基本类型)转换为`int`
java·开发语言
木易 士心43 分钟前
Node.js 性能诊断利器 Clinic.js:原理剖析与实战指南
开发语言·javascript·node.js
一只乔哇噻43 分钟前
java后端工程师+AI大模型进修ing(研一版‖day59)
java·开发语言·算法·语言模型
武子康44 分钟前
Java-182 OSS 权限控制实战:ACL / RAM / Bucket Policy 与错误排查
java·数据库·阿里云·云计算·oss·fastdfs·fdfs
报错小能手44 分钟前
C++流类库 概述及流的格式化输入/输出控制
开发语言·c++
2301_789015621 小时前
C++:list(带头双向链表)增删查改模拟实现
c语言·开发语言·c++·list
深圳佛手1 小时前
Consul热更新的原理与实现
java·linux·网络
扣脚大汉在网络1 小时前
关于一句话木马
开发语言·网络安全