1 前言
我之前的博客架构是家庭数据中心(主节点+热备节点) + 腾讯云(容灾节点),属于比较典型的”单节点读写”方案。由于日常只有主节点负责处理数据库的读写请求,所以数据库之间并不需要实时同步:每当我新增文章、修改内容,或者批准、回复评论,导致 WordPress 的数据库发生变化时,如果刚好有心情,我会手动将主节点的 MariaDB 中的 wordpress 库导出为 wordpress.sql 文件,并放进 Syncthing 的同步目录里。接下来,Syncthing 会将这个文件同步到热备节点和容灾节点的指定目录,这2个节点上的 inotify 脚本监测到目录发生变化后,自动触发数据库导入脚本,将 wordpress.sql 文件导入对应的 MariaDB 中,从而完成主节点和其他节点之前的数据同步。
不过现在,博客架构升级成了家庭数据中心”主写副读” + Racknerd 芝加哥节点”主读”的双活架构,平时正常的访问流量会命中APO缓存中的内容,而需要回源的访问请求会通过 Cloudflare Tunnel的单个 tunnel 中多个 connector 回源到不同节点。由于 Cloudflare Tunnel 的多 connector 负载均衡策略相当”随心所欲且任性”,虽然理论上,在多 connector 场景下,回源请求会优先命中离用户最近的芝加哥节点,但实际使用中,依然无法保证所有请求都乖乖听话,尤其当主从节点之间的数据库尚未同步完成时,就可能出现评论丢失、文章回滚,甚至页面展示内容不一致等问题。
更关键的是,过去靠我手动导出 wordpress.sql 并触发同步的方式,操作虽然简单,但确实没太大技术含量,不仅机械、无趣,还很容易忘,很没有”逼格”,感觉不太符合我的身份~~。因此,在新架构下,为了尽可能保证家庭数据中心中的”主写副读”节点变更数据后,能在短时间内同步到芝加哥”主读节点”,避免回源流量命中芝加哥”主读”节点时出现内容差异,我不得不重新思考数据同步机制,希望实现一整套更智能、更自动的数据库变更感知与自动化同步流程——这也是这周我没写双活架构而写这篇文章的原因,因为这是升级成wordpress双活架构之后,要实现日常自动化运维这个目的而必须解决的关键问题。
2 如何判断 “主写”节点WordPress 是否发生内容更新
2.1 wordpress数据库变更感知
上一节内容中我提到了”更自动的数据库变更感知”,要实现这一功能,就涉及到如何选择一种最合适的数据库内容变更检测机制。一般来说,我们可以考虑的方案主要有几种:
- 使用 binlog(Binary Log)机制:这是最直接、最精确的方式。MySQL/MariaDB 的 binlog 会记录所有对数据库的写操作(INSERT、UPDATE、DELETE),非常适合用于主从复制、增量同步或数据恢复。但这需要在数据库层启用 binlog、配置文件支持、且要解析日志内容或引入中间件系统,部署和维护成本较高。
- 使用触发器(Trigger)机制:可以为数据库中的关键表设置 AFTER INSERT/UPDATE/DELETE 触发器,将变更记录写入一个独立表或发送到消息队列。这种方式更灵活,但修改了原有数据库结构,也容易引入额外负担和副作用。
- 使用文件系统层级的变更检测(如 inotify):理论上可以监测数据库文件是否有读写行为变化,但实际中这在 Linux 上才能实现,且 MariaDB 的文件变更频率非常高(不等于内容发生实质改变),在 macOS 等平台也不可用,不适合做精准检测(我的wordpress主节点是跑在macos上)。
- 周期性全量比较(如 checksum 或 diff):暴力又简单,但效率低下,且无法做到实时响应变更。不过,回到 WordPress 这个具体场景,我们并不需要那么”全量”地感知所有数据库变更。绝大多数重要的数据更新,都集中在几个核心表中:
- wp_posts:文章、页面、附件都在这张表里。字段 post_modified_gmt 表示最后修改时间,post_date_gmt 表示创建时间;
- wp_comments:所有评论数据都在这里,关键字段是 comment_date_gmt;
因此,我们可以通过以下两个 SQL 语句,快速获取 WordPress 内容是否在最近发生过变化:
SELECT MAX(GREATEST(post_modified_gmt, post_date_gmt)) FROM wp_posts;
SELECT MAX(comment_date_gmt) FROM wp_comments;
如果某次查询的最大时间戳晚于上一次记录,就说明 WordPress 站点发生了实际更新(无论是新增评论、编辑文章、发布页面等),这时就可以触发数据库的导出和同步流程。
这种方式不需要改动数据库结构、不依赖 binlog、不引入中间件,部署极其轻量,非常适合做为自动同步系统的”变更感知信号”。
2.2 创建定时查询数据库变更的脚本
2.2.1 源码部署的mariadb
2.2.1.1 前置工作:在工作目录下添加”.my.cnf”文件(可选)
步骤总结:
- 打开终端,进入当前用户的主目录:
cd ~
- 创建 .my.cnf 文件(推荐用编辑器,比如 nano 或 vim):
vim .my.cnf
- 写入以下内容并保存(根据你的实际数据库用户名和密码填写):
[client]
user=root
password=你的数据库密码
- 设置权限(防止其他用户读取):
chmod 600 .my.cnf
- 测试是否生效:
mysql -e "SHOW DATABASES;"
说明
- 只要你用这个用户执行 mysql 或 mysqldump 命令,都会自动读取 ~/.my.cnf 中
[client]
段的信息。 - 如果你有多个用户登录系统,每个用户都可以有自己的 ~/.my.cnf 文件,互不干扰。
- 不建议将 .my.cnf 放在系统级目录(如 /etc/my.cnf)里暴露密码,放在用户主目录下更安全可控。
- 这一节内容不是必须的,只不过如果不想在检测脚本中直接使用”mysql -uxxx -pxxx”的方式来显示指定用户名和密码的话可以如此操作,毕竟显示指定的方式不太安全,当然,不介意的朋友可以跳过这节内容。
2.2.1.2 创建查询脚本
- 进入当前用户的主目录,新建一个脚本目录并进入:
cd ~
mkdir -p script
cd script
- 创建database-query.sh(推荐用编辑器,比如 nano 或 vim):
vim database-query.sh
- 写入以下内容并保存:
#!/bin/bash
# 设置路径
STATE_FILE="/usr/local/var/wp_sync_state"
DUMP_DIR="/usr/local/var/wp_dumps"
DB_NAME="wordpress"
DATE_NOW=(date +%Y%m%d%H%M%S)
# 运行 SQL 查询获取最新修改时间
LATEST_POST_TIME=(mysql -N -e "SELECT MAX(GREATEST(post_modified_gmt, post_date_gmt)) FROM wp_posts;" DB_NAME)
LATEST_COMMENT_TIME=(mysql -N -e "SELECT MAX(comment_date_gmt) FROM wp_comments;" DB_NAME)
CURRENT_STATE="{LATEST_POST_TIME}|{LATEST_COMMENT_TIME}"
# 如果 state 文件不存在,初始化并退出
if [ ! -f "STATE_FILE" ]; then
echo "CURRENT_STATE">"STATE_FILE"
echo "初次运行,记录当前状态:CURRENT_STATE"
exit 0
fi
# 读取上一次状态
PREV_STATE=(cat "STATE_FILE")
# 比较当前状态与上一次
if [ "CURRENT_STATE" != "PREV_STATE" ]; then
echo "数据库发生变更,触发导出和同步..."
# 更新状态文件
echo "CURRENT_STATE" > "STATE_FILE"
# 导出数据库(注意替换为你的实际路径)
DUMP_FILE="DUMP_DIR/wp_dump_DATE_NOW.sql"
mysqldumpDB_NAME > "DUMP_FILE"
# 同步动作(你可以替换成 rsync/scp 等)
# scp "DUMP_FILE" user@chicago:/data/wp_sync/
else
echo "无变化,无需同步。"
fi
注:脚本最后关联了发现数据库变化之后执行的同步wordpress.sql文件的方式,这里只是一个示范,具体选择什么方式根据大家自己的环境而定,比如选择scp、rsync或者干脆通过syncthing的自动同步来实现。如果要通过syncthing自动同步,需要在导出数据库时替换为syncthing同步的文件目录。
- 给脚本设置执行权限:
chmod +x database-query.sh
另,也提供一个不使用.my.cnf
来存放账号密码,直接使用-u -p
参数的脚本:
#!/bin/bash
# 设置路径
STATE_FILE="/usr/local/var/wp_sync_state"
DUMP_DIR="/usr/local/var/wp_dumps"
DB_NAME="wordpress"
DATE_NOW=(date +%Y%m%d%H%M%S)
# 运行 SQL 查询获取最新修改时间
LATEST_POST_TIME=(mysql -uroot -pyourpassword -N -e "SELECT MAX(GREATEST(post_modified_gmt, post_date_gmt)) FROM wp_posts;" DB_NAME)
LATEST_COMMENT_TIME=(mysql -uroot -pyourpassword -N -e "SELECT MAX(comment_date_gmt) FROM wp_comments;" DB_NAME)
CURRENT_STATE="{LATEST_POST_TIME}|{LATEST_COMMENT_TIME}"
# 如果 state 文件不存在,初始化并退出
if [ ! -f "STATE_FILE" ]; then
echo "CURRENT_STATE">"STATE_FILE"
echo "初次运行,记录当前状态:CURRENT_STATE"
exit 0
fi
# 读取上一次状态
PREV_STATE=(cat "STATE_FILE")
# 比较当前状态与上一次
if [ "CURRENT_STATE" != "PREV_STATE" ]; then
echo "数据库发生变更,触发导出和同步..."
# 更新状态文件
echo "CURRENT_STATE" > "STATE_FILE"
# 导出数据库(注意替换为你的实际路径)
DUMP_FILE="DUMP_DIR/wp_dump_DATE_NOW.sql"
mysqldumpDB_NAME > "DUMP_FILE"
# 同步动作(你可以替换成 rsync/scp 等)
# scp "DUMP_FILE" user@chicago:/data/wp_sync/
else
echo "无变化,无需同步。"
fi
注1:这种方式的最大问题是在另一个终端中执行ps aux | grep mysql
命令时,就可能直接看到:
mysql -uroot -ppassword -e ...
在多人共享环境、CI/CD 构建中都存在潜在的密码泄露风险,如果只是自己个人使用环境倒不是很所谓。
注2:主写节点成功导出wordpress库的sql文件之后,其实还可以通过bark给手机发送系统级通知:
curl -s "https://bark.example.com/your_token/wordpress通知/wordpress数据库导入成功" > /dev/null
当然,导入wordpress库的sql文件的节点,也可以以相同的方式通过bark给手机发送通知。
2.2.2 docker部署的mariadb
2.2.2.1 前置工作:在宿主机创建”.my.cnf”文件并挂载到mariadb容器中
一、创建 “.my.cnf”文件(宿主机)
在宿主机某个目录下(比如mariadb的docker目录)创建:
vim /docker/mariadb/.my.cnf
内容如下(以 root 用户为例):
[client]
user=root
password=你的数据库密码
注意:
- user= 要和你连接 MariaDB 时用的用户一致(一般是 root)。
- password= 请填入实际密码,不要加引号。
- 这个文件权限建议设为 600(只有你能读):
chmod 600 /docker/mariadb/.my.cnf
二、挂载”.my.cnf”文件到容器
使用docker run格式命令的”-v”参数将宿主机的”.my.cnf”挂载到容器内部:
-v /docker/mariadb/.my.cnf:/root/.my.cnf:ro
说明:”-v /docker/mariadb/.my.cnf:/root/.my.cnf:ro” 表示把你宿主机的配置文件挂载到容器内 root 用户的家目录中,其中的”:ro” 表示只读挂载,更安全。
如果是docker-compose方式部署,可以用以下方式挂载:
volumes:
- /docker/mariadb/.my.cnf:/root/.my.cnf:ro
2.2.2.2 创建查询脚本
这个查询脚本是在宿主机上运行的,创建步骤和之前类似,我就不重复了,最后脚本内容如下:
#!/bin/bash
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
# Docker 容器名(MariaDB 容器)
CONTAINER=mariadb
# 数据库名
DB=wordpress
# MySQL 用户和密码(建议设置低权限备份用户)
USER=root
PASS='p@ssw0rd'
# 输出 SQL 的路径(Syncthing 同步目录)
OUTPUT="/mnt/sync/wordpress/db/wordpress.sql"
# 临时文件防止未完成写入就同步
TMP_OUTPUT="{OUTPUT}.tmp"
# 状态文件路径
STATE_FILE=/tmp/wp-db-last-update.txt
# 获取 WordPress 数据库的最新修改时间(文章 + 评论)
LATEST_TIME=(docker exec CONTAINER \
mysql -N -uUSER -pPASS -e "
SELECT UNIX_TIMESTAMP(
GREATEST(
(SELECT MAX(GREATEST(post_modified_gmt, post_date_gmt)) FROMDB.wp_posts),
(SELECT MAX(comment_date_gmt) FROM DB.wp_comments)
)
);
")
# 如果状态文件不存在,初始化
if [ ! -f "STATE_FILE" ]; then
echo "LATEST_TIME">"STATE_FILE"
echo "[INIT] 状态文件初始化完成"
exit 0
fi
# 读取上次记录时间
LAST_TIME=(cat "STATE_FILE")
# 对比判断
if [ "LATEST_TIME" -gt "LAST_TIME" ]; then
echo "[+] 检测到数据库变更,开始导出"
# 导出 SQL 到临时文件
docker exec CONTAINER \
mysqldump -uUSER -pPASS --single-transaction --max_allowed_packet=64MDB \
> "TMP_OUTPUT"
# 检查导出成功才覆盖
if [? -eq 0 ]; then
mv "TMP_OUTPUT" "OUTPUT"
echo "[✓] 导出成功,已移动到 OUTPUT"
echo "LATEST_TIME" > "STATE_FILE"
else
echo "[✗] 导出失败,保留上次状态"
rm -f "TMP_OUTPUT"
fi
else
echo "[=] 没有数据库变更,跳过导出"
fi
2.3 定时运行数据库查询脚本
这个不同系统的实现方式就不一样了,对于linux系统或者macos系统,可以使用crontab
来实现:
crontab -e
添加如下内容(每 5 分钟检测一次):
*/5 * * * * /path/to/database_query.sh >> /tmp/wp-detect.log 2>&1
注意事项
- 脚本中默认容器名是 mariadb,你可以用 docker ps 查看实际容器名。
- 如果容器是通过 docker-compose 启动的,容器名可能是 yourproject_mariadb_1,需要改脚本里的 CONTAINER=。
- 如果你在 docker run 时指定了 –volume,也可以把 STATE_FILE 放到宿主机持久化目录中。
2.4 用服务的方式运行数据库变更监测脚本(可选)
2.4.1 “cron”方式的缺点
一般来说,如果只是在本地环境,想非常轻量地运行探测脚本,其实使用 cron 就足够了。它简单、稳定,只需要设定一个定时任务,就可以每隔几分钟执行一次数据库变更检测逻辑,非常适合无人值守的常规需求。
但是对我而言,情况要复杂一些。我并不是希望这个探测脚本全年无休地运行,因为每周我都会在周一进行 WordPress 新文章的发布,并伴随着一系列内容整理工作,比如调整文章格式、更新站点地图、临时开启 TranslatePress 插件的自动翻译功能来翻译新增的文章内容,然后在翻译完成后再关闭插件的自动翻译功能(为了省钱~)。
这些操作在一两个小时内集中完成,而这个时间段内数据库内容其实是频繁变动的。这时候如果探测脚本还在运行,就会频繁检测到数据库”变化”,触发不必要的同步流程,甚至影响文章尚未定稿的内容。
因此,我的理想使用方式是:每周一发布文章前,先停掉数据库变更探测脚本,待发布流程和所有必要操作完成后再手动恢复运行。这个控制流程在 cron 的机制下显得非常繁琐——我总不可能每周一都临时修改 crontab 配置、注释掉任务,再在发布后重新改回来吧?
这种场景下,将探测脚本包装成一个系统服务(如 Linux下的systemd service 或 macOS 下的 launchd plist 项)就更合适了。这样我可以像控制普通服务一样,使用 systemctl stop detector 或 launchctl unload 暂停任务,在需要的时候再一条命令恢复运行。既可控,又省心。
2.4.2 Linux下的systemd service
还是以之前的脚本”database_query.sh”为例,如果其路径是”/root/script/database-query.sh”
新建一个service文件:
vim /etc/systemd/system/database-query.service
粘贴并保存如下内容:
[Unit]
Description=WordPress Change Detection Service
[Service]
ExecStart=/root/script/database-query.sh
Restart=always
[Install]
WantedBy=multi-user.target
然后运行如下命令:
systemctl daemon-reexec
systemctl daemon-reload
systemctl enable database-query.service
systemctl start database-query.service
需要暂时停止探测的时候,只需要运行如下命令即可:
systemctl stop database-query.service
2.4.3 macos下的launchd 管理守护进程
macOS 没有 systemctl,但有launchd。你可以创建一个 plist 文件,例如:
- 保存脚本到 /usr/local/bin/database-query.sh并赋可执行权限:
chmod +x /usr/local/bin/database-query.sh
- 创建 LaunchAgent 文件(比如~/Library/LaunchAgents/com.local.databasequery.plist):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.local.databasequery</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/database-query.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>300</integer>
<key>StandardOutPath</key>
<string>~/databasequery.log</string>
<key>StandardErrorPath</key>
<string>~/databasequery.err</string>
</dict>
</plist>
- 加载启动:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.local.databasequery.plist
launchctl list | grep com.local.databasequery
- 停止/卸载:
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.local.databasequery.plist
- 检查日志输出:
tail -f ~/databasequery.log
tail -f ~/databasequery.err
相比 crontab,这么做的优势:
特性 | crontab | Systemd / launchd |
---|---|---|
精确控制启停 | 无法手动中断一个 job | 可以随时启/停服务 |
日志输出更稳定 | 需手动 redirect | 支持日志文件配置 |
状态跟踪 | 没有 | systemctl status/launchctl list |
自动重启 | 不支持 | 崩溃后自动重启 |
3 总结
这部分内容,算是我在 WordPress 双活节点架构下,实现自动化运维的最后一块拼图。严格来说,即便不做这套数据库变更感知机制,每次有更新后手动导出 wordpress.sql 文件并放入 Syncthing 同步目录,一样可以完成数据同步。毕竟,文章的发布、评论的审核这些操作,本就发生在我明确”有空也有心情”的时候,一切都在掌控之中。
但问题也正是出在这种”掌控感”——它并不可靠。忘记导出一次、同步延迟几分钟,在之前”单点读写”结构下都无所谓,可在”主写副读 + 异地主读”的双活架构下(且回源还不可控),这样的懈怠可能就会造成内容差异,甚至引发数据冲突。而作为系统架构的设计者,我不允许自己的懒惰成为稳定性的变量。
于是,秉承着「可以不用,但必须得有」的工程哲学,这个小小的自动化模块,还是被我加进来了。它不会每天都发挥作用,但一旦需要,它就在那儿,悄无声息地把本该手动的事自动化完成。
或许,除了我,没有人会关心这套机制是否存在、是否精准;但作为这套系统的唯一用户,也是唯一维护者,我清楚,这不仅仅是为了”跑得更快”,更是为了构建一种自洽的秩序感:系统会在我忘记的时候记得,在我不在的时候运转。
最终,写这一套机制,不只是为了多一项功能,而是为了让我能少一点担心,多一点”它一定没问题”的笃定。
注:当 WordPress 收到新的评论时,无论评论是否已被审核,都会立刻写入数据库的 wp_comments 表中,状态由 comment_approved 字段标识:’0′ 表示未批准,’1′ 表示已批准,’spam’ 表示垃圾评论,’trash’ 表示已删除。因此,即便评论处于”待审核”状态,依然会引发一次数据库变更。进一步地,当我手动审核并批准该评论时,comment_approved 字段会更新为 ‘1’,这同样属于一次数据库写操作。也就是说,一条评论从提交到最终通过审核,在我现在的架构下至少会触发两次数据库同步事件。
在当前”变更驱动同步”的机制下,这种行为虽会带来额外同步操作,但对内容一致性反而是一种保障。