在 OpenResty 中监听 Canal(数据库 binlog 同步工具),核心是 对接 Canal 的输出协议(TCP 原生协议或 HTTP 协议),接收数据库变更事件(增删改),再通过 Lua 脚本实现后续业务逻辑(如缓存更新、消息推送、数据校验等)。
Canal 支持两种核心输出模式,OpenResty 需针对性适配:
- TCP 长连接模式(Canal Server 主动推送 binlog 事件):适合高吞吐、低延迟场景,OpenResty 作为 TCP 客户端连接 Canal Server;
- HTTP 回调模式(Canal 主动 POST 事件到 OpenResty 接口):适合简单场景,无需维护长连接,配置更简单。
一、前置准备
1. Canal 环境配置
先确保 Canal Server 已启动,且能正常解析数据库 binlog(以 MySQL 为例):
-
参考 Canal 官方文档 完成安装配置(开启 binlog、创建 Canal 账号、配置 instance);
-
关键配置(
canal/conf/canal.properties):properties
# 启用 TCP 协议(默认开启,端口 11111) canal.port = 11111 # 启用 HTTP 协议(可选,用于 HTTP 回调模式,端口 8089) canal.serverMode = tcp,http canal.http.port = 8089 -
启动 Canal Server:
sh bin/startup.sh
2. OpenResty 依赖安装
需安装 Lua 非阻塞网络库(对接 TCP 协议)和 JSON 解析库(解析 Canal 事件):
bash
运行
# 安装 LuaRocks(OpenResty 自带,若未安装则手动安装)
sudo /usr/local/openresty/luajit/bin/luarocks install lua-resty-tcp # 非阻塞 TCP 客户端
sudo /usr/local/openresty/luajit/bin/luarocks install lua-cjson # JSON 解析库
二、方案 1:TCP 长连接监听(推荐高吞吐场景)
OpenResty 作为 TCP 客户端,与 Canal Server 建立长连接,持续接收 binlog 事件(Canal 主动推送)。核心是用 lua-resty-tcp 实现非阻塞连接,避免影响 OpenResty 主进程。
1. OpenResty 配置与 Lua 脚本
创建配置文件 nginx.conf,新增独立的 Lua 模块处理 Canal 连接:
nginx
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
lua_jit on;
# 配置 Canal 连接参数(可通过 env 或配置文件注入)
lua_shared_dict canal_config 1m; # 共享内存存储配置
init_by_lua_block {
local config = ngx.shared.canal_config
config:set("host", "127.0.0.1") # Canal Server 地址
config:set("port", 11111) # Canal TCP 端口
config:set("username", "") # Canal 用户名(默认空)
config:set("password", "") # Canal 密码(默认空)
config:set("destination", "example") # Canal instance 名称(默认 example)
}
# 启动时初始化 Canal 长连接(用 ngx.timer 异步执行,避免阻塞启动)
init_worker_by_lua_block {
local canal_listener = require("canal_listener")
ngx.timer.at(0, function()
canal_listener.start() # 启动 Canal 监听
end)
}
server {
listen 80;
server_name localhost;
# 健康检查接口(可选)
location /canal/health {
content_by_lua_block {
local config = ngx.shared.canal_config
local status = config:get("connection_status") or "disconnected"
ngx.say(cjson.encode({ status = status }))
}
}
}
}
2. 编写 Canal 监听模块(canal_listener.lua)
创建文件 /usr/local/openresty/lualib/canal_listener.lua,实现 TCP 连接、认证、事件接收与解析:
lua
local tcp = require("resty.tcp")
local cjson = require("cjson")
local config = ngx.shared.canal_config
-- Canal 协议常量(参考 Canal 官方协议文档)
local CANAL_MAGIC_NUMBER = 0x12345678
local CANAL_VERSION = 1
local CANAL_COMMAND_CONNECT = 1
local CANAL_COMMAND_CONNECT_ACK = 2
local CANAL_COMMAND_ROWS = 3
-- 连接 Canal Server
local function connect()
local sock = tcp:new()
sock:set_timeout(3000) -- 连接超时 3 秒
local host = config:get("host")
local port = config:get("port")
local ok, err = sock:connect(host, port)
if not ok then
ngx.log(ngx.ERR, "Canal 连接失败: ", err)
return nil, err
end
ngx.log(ngx.INFO, "Canal 连接成功: ", host, ":", port)
config:set("connection_status", "connected")
return sock
end
-- Canal 认证(发送连接请求)
local function auth(sock)
local auth_data = {
magicNumber = CANAL_MAGIC_NUMBER,
version = CANAL_VERSION,
destination = config:get("destination"),
username = config:get("username"),
password = config:get("password"),
clientId = "openresty-canal-listener", -- 客户端 ID,自定义
filter = "" -- 过滤规则(如 "db1.table1,db2.table2",空表示所有表)
}
-- 序列化认证数据(Canal 协议要求:4 字节长度 + JSON 字符串)
local json_str = cjson.encode(auth_data)
local len = string.pack(">I4", #json_str) -- 大端序 4 字节长度
local ok, err = sock:send(len .. json_str)
if not ok then
ngx.log(ngx.ERR, "Canal 认证发送失败: ", err)
return false, err
end
-- 接收认证响应
local ack_len, err = sock:receive(4) -- 先读 4 字节长度
if not ack_len then
ngx.log(ngx.ERR, "Canal 认证响应长度读取失败: ", err)
return false, err
end
local ack_body_len = string.unpack(">I4", ack_len)
local ack_body, err = sock:receive(ack_body_len)
if not ack_body then
ngx.log(ngx.ERR, "Canal 认证响应读取失败: ", err)
return false, err
end
local ack = cjson.decode(ack_body)
if ack.command ~= CANAL_COMMAND_CONNECT_ACK or ack.status ~= 0 then
ngx.log(ngx.ERR, "Canal 认证失败: ", cjson.encode(ack))
return false, "auth failed"
end
ngx.log(ngx.INFO, "Canal 认证成功")
return true
end
-- 解析 Canal binlog 事件
local function parse_event(data)
local event = cjson.decode(data)
if event.command ~= CANAL_COMMAND_ROWS then
return nil -- 忽略非数据变更事件
end
-- 提取核心信息(数据库、表名、操作类型、变更数据)
local result = {
db = event.dbName,
table = event.tableName,
op = event.eventType, -- INSERT/UPDATE/DELETE
ts = event.executeTime, -- 执行时间戳
data = event.rows -- 变更数据(INSERT/UPDATE 为新数据,DELETE 为旧数据)
}
return result
end
-- 处理 binlog 事件(自定义业务逻辑)
local function handle_event(event)
ngx.log(ngx.INFO, "收到 binlog 事件: ", cjson.encode(event))
-- 示例 1:更新 Redis 缓存(如用户表更新后刷新缓存)
if event.db == "user_db" and event.table == "user_info" then
local redis = require("resty.redis")
local red = redis:new()
red:set_timeout(1000)
red:connect("127.0.0.1", 6379)
for _, row in ipairs(event.data) do
local user_id = row.id
red:set("user:" .. user_id, cjson.encode(row)) -- 缓存用户数据
red:expire("user:" .. user_id, 3600) -- 1 小时过期
end
red:close()
end
-- 示例 2:推送消息到 MQ(如订单创建后触发后续流程)
-- if event.db == "order_db" and event.table == "order" and event.op == "INSERT" then
-- local http = require("resty.http")
-- local httpc = http.new()
-- httpc:request_uri("http://mq-server/push", {
-- method = "POST",
-- body = cjson.encode(event),
-- headers = { ["Content-Type"] = "application/json" }
-- })
-- end
end
-- 循环接收 Canal 事件(非阻塞)
local function receive_events(sock)
while true do
-- 读取事件长度(4 字节大端序)
local len, err = sock:receive(4)
if not len then
ngx.log(ngx.ERR, "Canal 事件长度读取失败: ", err)
break
end
local body_len = string.unpack(">I4", len)
if body_len <= 0 then
ngx.log(ngx.WARN, "Canal 空事件,跳过")
goto continue
end
-- 读取事件体
local body, err = sock:receive(body_len)
if not body then
ngx.log(ngx.ERR, "Canal 事件体读取失败: ", err)
break
end
-- 解析并处理事件
local event = parse_event(body)
if event then
handle_event(event)
end
::continue::
end
-- 连接断开,触发重连
sock:close()
config:set("connection_status", "disconnected")
ngx.log(ngx.WARN, "Canal 连接断开,5 秒后重连")
ngx.timer.at(5, function() start() end) -- 5 秒后重连
end
-- 启动监听(入口函数)
function _M.start()
local sock, err = connect()
if not sock then
ngx.timer.at(5, function() start() end) -- 连接失败,5 秒后重连
return
end
local ok, err = auth(sock)
if not ok then
sock:close()
ngx.timer.at(5, function() start() end) -- 认证失败,5 秒后重连
return
end
-- 非阻塞接收事件(用 coroutine 避免阻塞)
ngx.thread.spawn(receive_events, sock)
end
return _M
3. 启动与测试
bash
运行
# 启动 OpenResty
sudo /usr/local/openresty/nginx/sbin/nginx -c /usr/local/openresty/nginx/conf/nginx.conf
# 查看日志(确认连接与事件接收)
tail -f /usr/local/openresty/nginx/logs/error.log
测试步骤:
- 操作数据库(如
INSERT INTO user_db.user_info (id, name) VALUES (1, "test")); - 查看 OpenResty 日志,应打印
收到 binlog 事件: {"db":"user_db",...}; - 验证 Redis 缓存:
redis-cli get user:1,应返回插入的用户数据。
三、方案 2:HTTP 回调模式(简单场景)
Canal 主动将 binlog 事件通过 HTTP POST 推送到 OpenResty 的接口,无需维护长连接,配置更简单(适合低吞吐、对延迟不敏感的场景)。
1. Canal 配置 HTTP 回调
修改 Canal instance 配置(canal/conf/example/instance.properties),添加 HTTP 回调地址:
properties
# 启用 HTTP 回调(Canal 会 POST 事件到该地址)
canal.instance.eventMode = http
canal.instance.http.callback.url = http://127.0.0.1/canal/callback # OpenResty 接口地址
canal.instance.http.callback.contentType = application/json # 事件格式为 JSON
重启 Canal Server:sh bin/restart.sh
2. OpenResty 配置回调接口
修改 nginx.conf,添加接收 Canal 事件的 HTTP 接口:
nginx
http {
lua_jit on;
server {
listen 80;
server_name localhost;
# Canal HTTP 回调接口
location /canal/callback {
content_by_lua_block {
local cjson = require("cjson")
local ngx_req = require("ngx.req")
-- 读取 Canal 推送的事件(JSON 格式)
ngx_req.read_body()
local body = ngx_req.get_body_data()
if not body then
ngx.log(ngx.ERR, "Canal 回调无数据")
ngx.exit(400)
end
-- 解析事件(Canal HTTP 回调格式与 TCP 一致)
local events = cjson.decode(body)
for _, event in ipairs(events) do
-- 提取核心信息(同 TCP 模式)
local result = {
db = event.dbName,
table = event.tableName,
op = event.eventType,
ts = event.executeTime,
data = event.rows
}
-- 自定义业务逻辑(如更新缓存、推送 MQ,同 TCP 模式的 handle_event)
ngx.log(ngx.INFO, "HTTP 回调收到事件: ", cjson.encode(result))
-- 示例:更新 Redis 缓存(复用 TCP 模式的逻辑)
if result.db == "user_db" and result.table == "user_info" then
local redis = require("resty.redis")
local red = redis:new()
red:connect("127.0.0.1", 6379)
for _, row in ipairs(result.data) do
red:set("user:" .. row.id, cjson.encode(row))
end
red:close()
end
end
-- 响应 Canal(必须返回 200,否则 Canal 会重试)
ngx.say(cjson.encode({ code = 200, msg = "success" }))
}
}
}
}
3. 测试
- 重启 OpenResty:
sudo /usr/local/openresty/nginx/sbin/nginx -s reload; - 操作数据库(如更新用户数据);
- 查看 OpenResty 日志,应打印
HTTP 回调收到事件: ...,且 Redis 缓存同步更新。
四、关键注意事项
-
非阻塞 I/O 原则 :对接 Canal 时,必须使用 OpenResty 生态的 非阻塞库 (如
lua-resty-tcp、lua-resty-redis、lua-resty-http),避免使用阻塞 I/O(如原生 Lua socket),否则会导致 OpenResty 工作进程阻塞,严重影响并发。 -
重连机制 :TCP 模式下,需处理 Canal 连接断开的情况(如 Canal 重启、网络波动),通过
ngx.timer实现自动重连,确保监听不中断。 -
事件过滤与限流:
- 若无需监听所有表的变更,可在 Canal 配置中设置过滤规则(如
filter = "db1.table1,db2.table2"),减少无效事件; - 高并发场景下,可通过
lua_shared_dict实现限流(如限制每秒处理 1000 个事件),避免业务逻辑过载。
- 若无需监听所有表的变更,可在 Canal 配置中设置过滤规则(如
-
数据一致性:Canal 同步 binlog 是异步的,可能存在延迟(毫秒级),若需强一致性(如更新数据库后立即读取缓存),需结合业务设计(如先更新缓存再写数据库,或使用分布式锁)。
-
日志与监控:关键事件(连接失败、认证失败、数据处理异常)需打印日志,建议对接 Prometheus/Grafana 监控 Canal 连接状态、事件处理量、错误率。
五、两种方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| TCP 长连接模式 | 高吞吐、低延迟、无重复推送 | 需维护长连接、配置复杂 | 高并发业务(如电商、支付) |
| HTTP 回调模式 | 配置简单、无需维护连接 | 延迟略高、可能重复推送(需幂等) | 简单场景(如日志收集、低并发更新) |
若你的业务对并发和延迟要求较高,优先选择 TCP 长连接模式 ;若仅需简单同步数据,可使用 HTTP 回调模式。