DedeCMS数据库分离与跨服务器跨库取数实战

发布时间:2026/11/3 21:11:58
DedeCMS数据库分离与跨服务器跨库取数实战

把数据库从网站服务器上拆出去这件事,我最早是被逼着干的。那会儿一个内容站跑在一台配置还算凑合的机器上,站点程序和 MySQL 挤在一起,白天访问一上来,磁盘 IO 先被数据库吃满,PHP 进程跟着排队,页面打开要转好几秒。后来把库单独挪到另一台机器,站点只负责渲染,数据库只负责读写,两边的负载互不干扰,问题一下子缓过来一大截。这之后又碰到新麻烦:库搬走了,可是另一个站的库还在原来的机器上,两个库之间要互相取数据,这才引出"网站与数据库不在同一服务器,跨服务器的 DedeCMS 数据库之间怎么调数据"这一整套问题。这篇东西就是把我这些年在 DedeCMS 这类老牌内容系统上做库分离、跨服务器取数的经验摊开讲一遍,包括前置配置怎么改、DedeCMS 那边要动哪几行、跨库调数据有哪几条路、每条路的坑在哪。如果你正打算把库抽出去,或者手上已经有两台机器各放一个库、需要让他们互相看得见,下面的内容应该能直接拿去用。读者不需要多深的基础,会改配置文件、会敲几条命令行就行,涉及原理的地方我会拿生活里的例子掰开讲。

1. 拆库之前,先把问题定义清楚

1.1 一台机器装全套,什么时候就该拆了

先说判断标准。很多人一上来就问"要不要拆",其实拆不拆不是架构审美问题,是资源竞争问题。站点程序和数据库跑在同一台机器上,抢的是同样的东西:CPU、内存、磁盘 IO、网络带宽。DedeCMS 这种系统本身比较"重",栏目页、列表页、内容页动不动就查几十次表,再加上后台生成静态、采集、批量替换这些操作,数据库那边的压力是脉冲式的。你如果观察到下面几种现象,基本可以考虑拆了:一是数据库进程常驻内存占用很高,和 PHP-FPM 抢内存,导致两边都不够用;二是磁盘 IO 在访问高峰长期打满,慢查询日志里出现大量因为等待 IO 而变慢的语句;三是备份的时候整站卡顿,因为 mysqldump 把磁盘带宽吃光了;四是站点要做多台横向扩展,而数据库只能有一份主库。这四条里中了两条以上,拆的收益就很明显了。

但拆也是有代价的,这个必须承认。原来本机连接走的是 Unix Socket 或者 127.0.0.1,延迟基本可以忽略;拆开之后走 TCP,一次查询多出零点几毫秒的网络往返,如果一个页面查两百次库,累计起来就是几十毫秒,这在高峰期会被放大。另外运维复杂度上去了,两台机器的时间同步、字符集配置、账号权限、防火墙规则,任何一处不一致都会变成"莫名其妙连不上"。所以我一般的建议是:库的数据量不大、单机还能扛,就先别拆,把慢查询和索引优化做完再说;真的到了资源争抢阶段,再动手。

1.2 三种常见分离形态,选错了后面全是债

拆的方式不止一种,实际项目里常见的是下面三种,选之前最好想清楚未来一年的走向。

形态具体做法适合场景主要代价
同机房双机站点一台、数据库一台,走内网专线互通单站点、数据量中等、访问稳定数据库仍是单点,需要做主从或定期备份
站点集群 + 独立库多台 Web 机器连同一个数据库访问量大、需要横向扩 Web 层数据库压力集中,需要读写分离
多库并存互通多个站各自有库,库之间要互取数据站群、子站体系、老系统整合数据一致性、同步延迟、ID 冲突

第一种最简单,也是最常见的起点。第二种是在第一种基础上把 Web 层复制几份,只要数据库那台扛得住就行,改造成本低。第三种才是标题里说的真正麻烦的场景——不是"站点连不上库",而是"库和库之间要互相看得见"。很多人把这两种情况混为一谈,结果在配置上绕了半天,其实前者只是改一个 host 地址,后者才涉及跨服务器数据调用方案的选择。

1.3 动手前必须确认的四件事

第一件,两台机器(或者多台)之间是不是内网互通。这一点决定了你后面所有方案的上限。走公网直连数据库这件事我劝你彻底放弃,哪怕加了白名单也不行,理由在第 6 节细说。第二件,两边的 MySQL 版本是否一致或者兼容。5.5 和 5.7、8.0 之间在字符集默认值、认证插件、排序规则上都有差异,跨版本同步时最容易踩的就是这些。第三件,两边的表结构和表前缀是否冲突。DedeCMS 默认前缀是dede_,如果你两个站都用了默认前缀,同步到同一个库里必然打架。第四件,时间基准是否一致。这个最容易被忽略,但同步任务、增量抓取全都依赖时间字段,两台机器差几分钟,增量数据就会漏。

2. 让两台机器上的数据库能互相说话

2.1 监听地址是第一个坎

新装的 MySQL 默认只监听回环地址,bind-address = 127.0.0.1,这意味着除了本机,谁都连不上。要让它接受来自内网另一台机器的连接,得改配置文件里的这一项。改成本机内网 IP 是最稳妥的做法,比如数据库机器内网是10.0.3.21,那就写bind-address = 10.0.3.21,只在这张网卡上监听;写成0.0.0.0是监听全部网卡,功能上没问题,但一旦这台机器有公网 IP,风险就来了。所以我的习惯是永远写具体的内网地址,多网卡的机器尤其要注意。

配置文件的位置按系统和安装方式不同,常见的是/etc/my.cnf、/etc/mysql/my.cnf或者/etc/mysql/mysql.conf.d/mysqld.cnf。改完必须重启服务才生效,重启前先用mysqld --verbose --help | grep -A1 "Default options"确认一下到底读的是哪个文件,这个细节能省掉很多"我明明改了却没生效"的困惑。改完在数据库机器上执行ss -lntp | grep 3306,看到的应该是10.0.3.21:3306而不是127.0.0.1:3306。

注意:如果用云主机或者虚拟化环境,网卡地址可能会在重启后变化,绑定具体 IP 有风险。这种情况下可以绑定0.0.0.0,但必须同时配好安全组规则,只放行指定内网网段,绝对不能对全量地址开放。

2.2 授权不是给个 ALL 就完事

监听改好只是第一步,账号授权才是真正的门禁。MySQL 的账号是"用户名 + 来源主机"一起构成的,'site'@'localhost'和'site'@'10.0.3.%'是两个完全不同的账号。DedeCMS 站点搬到另一台机器后连接不上,八成就卡在这里——账号建了,但来源主机写的是localhost。

-- 给站点机器所在的网段授权,只授权它真正需要的库 CREATE USER 'site_rw'@'10.0.3.%' IDENTIFIED BY '一串足够长的口令'; GRANT SELECT, INSERT, UPDATE, DELETE ON `dede_main`.* TO 'site_rw'@'10.0.3.%'; -- 给同步任务单独一个只读账号,权限更小 CREATE USER 'sync_ro'@'10.0.3.%' IDENTIFIED BY '另一串口令'; GRANT SELECT, LOCK TABLES ON `dede_main`.* TO 'sync_ro'@'10.0.3.%'; FLUSH PRIVILEGES;

网段通配10.0.3.%比%安全得多,也比写死单个 IP 灵活。但要注意一点:MySQL 匹配来源主机时,如果同时存在'site_rw'@'10.0.3.%'和'site_rw'@'10.0.3.15'两个账号,会优先匹配更具体的那一个,口令却可能不一样,结果就是你改了前者发现没生效。所以建账号的时候最好一个用户名只对应一条授权记录,别留多个同名不同源的账号。另外,MySQL 8.0 默认认证插件是caching_sha2_password,一些老版本的 PHP 客户端驱动不认,连上去会报认证失败,解决办法是给这个账号单独指定mysql_native_password。

2.3 网络通不通,用这三条命令依次验证

配置改完不要急着开站点去试,按顺序验证能快速定位问题出在哪一层。第一步验证端口通不通:

# 从站点机器上测数据库机器的 3306 端口 nc -zv 10.0.3.21 3306 # 或者 telnet 10.0.3.21 3306

不通就是网络层或者防火墙的问题,跟 MySQL 配置无关。第二步,端口通了但连不上,用 mysql 客户端直接试:

mysql -h 10.0.3.21 -P 3306 -u site_rw -p -e "SELECT 1"

如果报Host '10.0.3.15' is not allowed to connect,是授权问题;报Access denied for user,是口令或用户名问题;连接超时,多半是防火墙把包丢了。第三步,能连上但 DedeCMS 还是白屏,那就是站点侧的配置问题,进入第 3 节。这个由外到内的验证顺序,比一上来就翻 PHP 代码效率高得多。

2.4 两个经常被忽略的隐性不一致

字符集。DedeCMS 老版本的库普遍是utf8(也就是 MySQL 里的utf8mb3),新装的 MySQL 8.0 默认字符集是utf8mb4。把老库导入新实例,如果不显式指定字符集,表的字符集会跟库的不一致,表现出来就是中文乱码,或者某些生僻字、表情符号写入报错。处理办法是导入时明确指定--default-character-set=utf8mb4,并且建库时写死DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci。排序规则不一致还会导致跨库 JOIN 时索引失效,这个坑更隐蔽。

时区。time_zone参数不设的时候,MySQL 用的是系统时区,两台机器如果一台设了Asia/Shanghai一台是 UTC,NOW()出来的时间就差 8 小时。同步任务如果用update_time > 上次时间做增量,时间基准一乱,要么重复同步要么漏数据。我的做法是两边都在配置文件里写死default-time-zone = '+08:00',不依赖系统时区。

3. DedeCMS 侧要改的其实就那么几行

3.1 配置文件里每个变量负责什么

DedeCMS 的数据库连接信息集中在data/common.inc.php这个文件里,结构非常简单,就是一组全局变量。把它拆开看:

变量作用常见取值注意事项
$cfg_dbhost数据库主机地址拆库前localhost,拆库后内网 IP填 IP,不要填域名,避免 DNS 解析引入延迟
$cfg_dbname数据库名如dede_main大小写敏感,以实际库名为准
$cfg_dbuser连接账号如site_rw不要用 root
$cfg_dbpwd账号口令长随机串文件权限要收紧
$cfg_dbprefix表前缀默认dede_多库合并时最容易冲突的地方
$cfg_db_language字符集标识utf8或utf8mb4必须和实际表字符集一致
$cfg_dbport端口默认 3306部分版本没有这一项,需要手动加

改的时候只动$cfg_dbhost、$cfg_dbuser、$cfg_dbpwd这三项基本就够了。但有个细节:某些版本的连接类里,端口是硬编码 3306 的,如果你的数据库换了端口,光改这个文件没用,得找到include/dedesql.class.php里的连接部分,把端口拼进去。改之前先看看这个文件里是不是有$this->linkID = @mysql_connect($cfg_dbhost, $cfg_dbuser, $cfg_dbpwd)这类代码,没有带端口参数的话就得自己补上。

文件权限这件事得单独说一句。common.inc.php里躺的是明文口令,权限至少收到 640,属主是 Web 运行用户。更稳妥的做法是把它挪到 Web 根目录之外,在入口文件里用绝对路径引入,这样即使哪天配置出错导致源码泄露,口令也不会跟着出去。

3.2 改完还连不上,先查这三个缓存

DedeCMS 有几层缓存会"记住"旧的数据库信息,改完配置不清缓存,经常出现"明明改了却还在连老库"的诡异现象。第一层是data/cache/目录下的各种缓存文件,其中config.cache.inc.php会缓存一部分系统配置, safest 的做法是整个目录清空,让系统重新生成。第二层是后台的"系统"菜单里的缓存更新,包括栏目缓存、文档缓存,这些不清的话,前台可能读到旧数据。第三层是 PHP 自己的 OPcache,如果开了opcache.validate_timestamps=0,改了 PHP 文件不会立即生效,需要重载 PHP-FPM。

还有一个容易忽略的点:安装目录install/一定要删掉。有些站迁移完忘了删,后面被人重新跑一遍安装流程,数据库直接指向了新库,站点就成空壳了。这个不是理论风险,我在实际项目里见过两次。

3.3 连接失败报错对照表

报错信息片段大概率原因处理方式
Can't connect to MySQL server端口不通 / 服务没起 / 监听地址不对用 nc 测端口,检查 bind-address
Host 'x.x.x.x' is not allowed账号来源主机不匹配补授权'user'@'网段'
Access denied for user口令错 / 认证插件不兼容核对口令,8.0 环境改用 native 插件
Unknown database库名写错 / 库没建登录数据库SHOW DATABASES确认
Table 'x' doesn't exist表前缀配错核对$cfg_dbprefix与实际表名
页面白屏无报错PHP 报错被关闭临时开display_errors看具体错误
中文变成问号字符集不一致统一 utf8mb4,检查连接字符集

4. 跨服务器跨库取数,四条路各有各的命

4.1 直连远程库:上手最快,也最容易翻车

最直觉的做法是让 A 站的程序直接去连 B 站的数据库,多开一个连接对象就行。在 DedeCMS 里,主连接走的是$dsql这个全局对象,要连第二个库,我建议不要动它,直接另起一个 PDO 实例,互不干扰。

// 只读连到另一个库,注意超时一定要设 function get_remote_pdo() { static $pdo = null; if ($pdo instanceof PDO) { return $pdo; } $dsn = 'mysql:host=10.0.3.31;port=3306;dbname=dede_sub;charset=utf8mb4'; $pdo = new PDO($dsn, 'sync_ro', '你的口令', [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_TIMEOUT => 3, // 连接超时,秒 PDO::ATTR_PERSISTENT => false, // 跨库不建议长连接 ]); return $pdo; }

这条路的问题不在代码,在于它把两个系统的可用性绑在了一起。B 库稍微慢一点,A 站页面就跟着卡;B 库挂了,A 站直接 500。而且每次页面渲染都要跨网络查,QPS 一上来,两边的连接数都会飙升。所以我只推荐在两种情况下用它:数据量很小、查询频率很低(比如一天几百次的配置读取);或者只是临时排查数据,不上生产。

4.2 定时同步 + 本地副本:我实际项目里最常用的

把远程库需要的数据定期拉到本地,前台只查本地表。这个方案的本质是"用空间换稳定",牺牲一点实时性,换来的是前台查询零跨网延迟、远程库挂了也不影响展示。同步任务的写法有推和拉两种,拉更简单,不用在远程库上装东西。

增量同步的关键是找到一个可靠的增量标识。update_time是最常用的,但它有两个坑:一是同一秒可能有多条记录,光靠时间会漏;二是有些表压根没有更新时间字段。处理办法是用复合游标(update_time, id),查询写成WHERE update_time > ? OR (update_time = ? AND id > ?),这样同一秒内的数据也能按 id 依次取全。

-- 落到本地表,主键冲突时更新而不是报错 INSERT INTO local_article (id, title, body, type_id, pub_time, update_time, is_deleted) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE title = VALUES(title), body = VALUES(body), type_id = VALUES(type_id), pub_time = VALUES(pub_time), update_time = VALUES(update_time), is_deleted = VALUES(is_deleted);

软删除也要考虑。远程库删了数据,本地如果没有对应标记,就会一直挂着一条幽灵记录。所以同步的时候不能只同步"存在的",还要定期跑一次全量比对,把本地有、远程没有的标记为已删除,或者直接物理清理。

4.3 中间服务层:把数据库彻底藏起来

再往前走一步,是不让站点直接碰远程数据库,而是在数据库那台机器(或者同网段的另一台)上跑一个轻量的接口服务,A 站通过 HTTP 拿数据。这个方案的好处很明显:数据库只暴露给这一台中间服务,站点侧拿不到库的账号;接口层可以自己做限流、缓存、字段裁剪;将来库结构变了,只要接口返回格式不变,调用方不用改。

接口层不用搞得很复杂,一个入口文件加几个动作就够了:

// api.php 极简示例,只做演示,生产要做鉴权与限流 $action = $_GET['action'] ?? ''; $token = $_SERVER['HTTP_X_API_TOKEN'] ?? ''; if (!hash_equals('约定好的长随机串', $token)) { http_response_code(401); exit(json_encode(['code' => 401, 'msg' => 'unauthorized'])); } $pdo = new PDO('mysql:host=127.0.0.1;dbname=dede_sub;charset=utf8mb4', 'api_ro', '口令'); switch ($action) { case 'article_list': $typeId = (int)($_GET['type_id'] ?? 0); $stmt = $pdo->prepare( 'SELECT id, title, pub_time FROM dede_archives WHERE typeid = :t AND arcrank >= 0 ORDER BY id DESC LIMIT 20' ); $stmt->execute([':t' => $typeId]); echo json_encode(['code' => 0, 'data' => $stmt->fetchAll()]); break; default: http_response_code(404); echo json_encode(['code' => 404, 'msg' => 'not found']); }

调用侧用 curl 加超时和失败兜底,拿不到数据就降级显示本地缓存,不要让接口故障把整页拖垮。

4.4 数据库层的联邦与复制

MySQL 自带一个 FEDERATED 引擎,可以在一台库里建一张"本地表",实际数据存在远程库上,查询时自动转发。听起来很美,但它默认是关闭的,需要在配置文件里加federated,重启才生效。用起来是这样:

CREATE TABLE remote_article ( id INT NOT NULL, title VARCHAR(200), pub_time INT, PRIMARY KEY (id) ) ENGINE=FEDERATED CONNECTION='mysql://sync_ro:口令@10.0.3.31:3306/dede_sub/dede_archives';

问题在于性能。FEDERATED 表不支持索引下推,很多查询会把远程全表拉回来再过滤,数据量大一点就慢得离谱,而且没有事务保障。我只在数据量几百行、只做简单点查的场景下用过,多一点都不敢。

另一个选择是主从复制。把从库建在另一台机器上,从库可以承担读请求。这条路适合"一份数据、多个读取方"的场景,不适合"两个独立的库互相取数",因为复制是单向的、整库级别的,而且从库延迟一旦上来,读到的就是旧数据。延迟监控的简单办法是SHOW SLAVE STATUS里的Seconds_Behind_Master,超过 10 秒就该告警了。

4.5 四种方案横向对比

方案实时性实现成本前台性能抗故障适用规模
远程直连实时最低差(每次跨网)差低频、小数据
定时同步本地副本分钟级中好(本地查询)好中大型内容站
中间服务层实时或准实时中高较好(可加缓存)中多系统对接
FEDERATED / 复制实时 / 准实时中一般中只读扩展

选的时候按"前台能不能接受跨网查询"这一条先筛,能接受就直连,不能接受就往同步走。我个人的倾向很明确:前台一律走本地,跨服务器的事情全部交给后台任务和接口层。

5. 一次完整的落地过程

5.1 场景设定与目标

假设一个站群场景:主站部署在10.0.3.15,数据库dede_main在10.0.3.21;一个专栏子站部署在同一台 Web 机上,数据库dede_sub在10.0.3.31。需求是主站首页要展示子站最新更新的 10 篇文章标题,以及子站每个栏目的文章数统计。要求:子站数据库不可用时,主站首页不能崩。

这几条需求直接决定了方案。首页是高频访问,绝对不能每次渲染都跨网查,所以排除直连;要展示"最新更新",可以接受几分钟的延迟,所以选定时同步本地副本。栏目统计这种聚合数据,单独用一张统计表,跟着同步任务一起刷新。

5.2 本地建表与索引

先在主站的库里建一张接收表。字段不要照抄远程表结构,只留用得上的,能显著减少传输量。

CREATE TABLE IF NOT EXISTS local_sub_article ( id INT UNSIGNED NOT NULL COMMENT '子站文档ID', title VARCHAR(200) NOT NULL DEFAULT '', shorttitle VARCHAR(120) NOT NULL DEFAULT '', type_id INT UNSIGNED NOT NULL DEFAULT 0, pub_time INT UNSIGNED NOT NULL DEFAULT 0, update_time INT UNSIGNED NOT NULL DEFAULT 0, is_deleted TINYINT NOT NULL DEFAULT 0, synced_at INT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_type_pub (type_id, pub_time), KEY idx_update (update_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='子站文章本地副本'; CREATE TABLE IF NOT EXISTS local_sub_type_stat ( type_id INT UNSIGNED NOT NULL, type_name VARCHAR(80) NOT NULL DEFAULT '', arc_count INT UNSIGNED NOT NULL DEFAULT 0, refreshed_at INT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (type_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='子站栏目文章数';

索引这一块,idx_type_pub是为了首页按栏目取最新,idx_update是为了同步任务查游标。别小看这两个索引,同步任务跑全量扫描的时候,有没有索引差的是几个数量级。同步字段synced_at记录本地落库时间,排查问题时很有用。

5.3 同步脚本:游标、批量、防重入

脚本的核心是游标持久化和批量写入。游标存在本地一张小表里,不要存文件,避免多实例跑的时候打架。

<?php // sync_sub_article.php $local = new PDO('mysql:host=127.0.0.1;dbname=dede_main;charset=utf8mb4', 'local_rw', '口令', [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, ]); $remote = new PDO('mysql:host=10.0.3.31;dbname=dede_sub;charset=utf8mb4', 'sync_ro', '口令', [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_TIMEOUT => 5, ]); // 取上次游标 $cursor = $local->query("SELECT last_time, last_id FROM sync_cursor WHERE task='sub_article'") ->fetch() ?: ['last_time' => 0, 'last_id' => 0]; $batchSize = 500; $sql = "SELECT id, title, shorttitle, typeid, pubdate, senddate FROM dede_archives WHERE (senddate > :t) OR (senddate = :t2 AND id > :i) ORDER BY senddate ASC, id ASC LIMIT :lim"; $stmt = $remote->prepare($sql); $stmt->bindValue(':t', $cursor['last_time'], PDO::PARAM_INT); $stmt->bindValue(':t2', $cursor['last_time'], PDO::PARAM_INT); $stmt->bindValue(':i', $cursor['last_id'], PDO::PARAM_INT); $stmt->bindValue(':lim', $batchSize, PDO::PARAM_INT); $stmt->execute(); $rows = $stmt->fetchAll(); if (!$rows) { exit(0); } $upsert = $local->prepare( "INSERT INTO local_sub_article (id, title, shorttitle, type_id, pub_time, update_time, synced_at) VALUES (:id, :title, :shorttitle, :type_id, :pub_time, :update_time, :synced_at) ON DUPLICATE KEY UPDATE title = VALUES(title), shorttitle = VALUES(shorttitle), type_id = VALUES(type_id), pub_time = VALUES(pub_time), update_time = VALUES(update_time), synced_at = VALUES(synced_at)" ); $now = time(); $local->beginTransaction(); foreach ($rows as $r) { $upsert->execute([ ':id' => $r['id'], ':title' => $r['title'], ':shorttitle' => $r['shorttitle'], ':type_id' => $r['typeid'], ':pub_time' => $r['pubdate'], ':update_time' => $r['senddate'], ':synced_at' => $now, ]); } $last = end($rows); $local->prepare("UPDATE sync_cursor SET last_time=?, last_id=? WHERE task='sub_article'") ->execute([$last['senddate'], $last['id']]); $local->commit(); echo "synced " . count($rows) . " rows\n";

这里有几个设计取舍值得说明。senddate是 DedeCMS 里的"最后修改时间"字段,用它做游标比pubdate靠谱,因为后者只在发布时写一次。批量设成 500 是经验值,太小了任务启动开销占比高,太大了事务时间长、锁的范围大,还可能超过max_allowed_packet。游标和业务数据放在同一个事务里提交,保证不会出现"数据写进去了但游标没动"导致重复同步的情况。

5.4 定时任务怎么配才稳

*/5 * * * * /usr/bin/flock -n /tmp/sync_sub_article.lock /usr/bin/php /data/scripts/sync_sub_article.php >> /data/logs/sync_sub_article.log 2>&1

flock -n是关键,上一轮没跑完,这一轮直接跳过,不会出现两个进程同时同步导致游标错乱。日志重定向必须有,出问题第一件事就是看日志。频率设成 5 分钟,实际场景里内容更新没那么频繁,完全够用。如果确实需要接近实时,可以把频率提到 1 分钟,但要先确认脚本单次执行时间远小于这个间隔,否则就是在给自己挖坑。

栏目统计那张表不需要实时算,每小时刷一次就够:

INSERT INTO local_sub_type_stat (type_id, type_name, arc_count, refreshed_at) SELECT t.id, t.typename, COUNT(a.id), UNIX_TIMESTAMP() FROM dede_sub.dede_arctype t LEFT JOIN dede_sub.dede_archives a ON a.typeid = t.id AND a.arcrank >= 0 GROUP BY t.id, t.typename ON DUPLICATE KEY UPDATE type_name = VALUES(type_name), arc_count = VALUES(arc_count), refreshed_at = VALUES(refreshed_at);

聚合查询在远程库上跑,注意加条件和限制,别把远程库拖慢。如果远程库承受不了,就改成拉列表回来本地算。

5.5 前台读取与降级

前台查询只碰本地表,一条 SQL 带个缓存就够了。缓存用文件或者 Redis 都行,key 里带上栏目和数量,过期时间设 300 秒,比同步任务间隔略长一点,避免频繁穿透到数据库。

function get_latest_articles($typeId, $limit = 10) { $key = "sub_art_{$typeId}_{$limit}"; $cached = cache_get($key); // 自己实现的缓存读取 if ($cached !== false) { return $cached; } $pdo = get_local_pdo(); $stmt = $pdo->prepare( "SELECT id, title, pub_time FROM local_sub_article WHERE type_id = :t AND is_deleted = 0 ORDER BY pub_time DESC LIMIT :l" ); $stmt->bindValue(':t', $typeId, PDO::PARAM_INT); $stmt->bindValue(':l', $limit, PDO::PARAM_INT); $stmt->execute(); $rows = $stmt->fetchAll(); cache_set($key, $rows, 300); return $rows; }

降级的意思是,本地表里查不到数据(比如第一次部署还没同步),就返回空数组,前台模板按"暂无内容"渲染,不要抛异常,更不要在模板里回退去连远程库。这个纪律必须守住,否则前面所有的隔离设计就白做了。

6. 排查实录:那些年踩过的坑

6.1 连接层的问题,八成出在授权和防火墙

最常见的是"昨天还好好的,今天连不上"。第一反应先看服务在不在,systemctl status mysqld;再看端口通不通,nc -zv;最后看数据库的错误日志。数据库错误日志里如果出现大量Aborted connection,说明有连接建立后没正常关闭,多半是客户端超时时间比服务端短,或者程序里有连接泄漏。

还有一个坑是max_connections。DedeCMS 本身偶尔会产生一些未及时释放的连接,加上同步脚本、后台任务、监控采集,很快就能把默认的 151 个连接占满。报错是Too many connections,这时候连管理员都登不进去。解决办法是提前把max_connections调高到 500 左右(前提是内存够),并且给管理员留一个预留连接数。根治还是要查连接泄漏,把SHOW PROCESSLIST里长时间Sleep的连接找出来,看是哪段代码忘了关。

6.2 数据层:乱码、时区、主键撞车

乱码前面提过,根源是字符集在链路的某一环不一致。完整的链路包括:库的默认字符集、表的字符集、字段的字符集、连接字符集、客户端展示字符集。任何一环不同都可能出乱码。排查办法是执行SHOW VARIABLES LIKE 'character_set%',看character_set_client、character_set_connection、character_set_results是不是都是utf8mb4。PDO 的 DSN 里写了charset=utf8mb4就基本没问题。

主键撞车是多库合并时的高频问题。两个库各自自增,ID 很可能重复,直接往一张表里灌必然冲突。解法有三种:一是把两个库的自增起始值错开,用auto_increment_increment和auto_increment_offset,一个库从 1 开始步长 2,另一个从 2 开始步长 2,保证不撞;二是在本地表加一个来源字段构成联合主键;三是用全局唯一 ID 生成方案。第一种最省事,但会改变自增规律,如果有外部系统依赖连续 ID 要谨慎。

时区问题最阴。表现是同步过来的时间和实际差 8 小时,或者增量任务永远查不到新数据。前面说过在配置文件里写死default-time-zone,但还要注意 PHP 侧的时区,date_default_timezone_set设的和数据库不一致,同样会错位。统一的办法是应用层和数据库层都用东八区,时间戳一律用整型存,别用datetime字符串,这样天然没有时区歧义。

6.3 性能层:同步任务把库拖死

这个我吃过一次亏。同步脚本没加LIMIT的分批,直接全表拉取,跑起来的一瞬间远程库 CPU 打到 100%,业务查询全部排队。教训是:同步任务必须有三个约束——分批大小、单次执行时长上限、执行时段避开高峰。分批前面已经写了,时长上限可以在脚本里加个计时,超过 30 秒就主动退出,让下一轮接着跑。时段上,全量比对这种重活放到凌晨。

另一个容易被忽视的是SELECT *。远程表字段多的时候,传输量可能是实际需要的好几倍。只取用得到的字段,这个习惯在跨网场景下能省下大量带宽和时间。

6.4 安全层:几条不能破的底线

数据库账号一律不用 root,按用途拆成读写账号和只读账号,权限精确到库甚至表。账号来源限制到内网网段,不要用%。数据库端口不要暴露到公网,这是底线,哪怕改了端口号也不行,端口扫描是常态。备份要加密,mysqldump出来的文件里是全部业务数据,躺在服务器上被下载走就是事故。所有配置文件里的明文口令,common.inc.php、同步脚本、接口服务,权限都要收到最小。

注意:如果业务上确实需要跨机房访问,务必走专线或者点对点加密链路,并且只放行固定 IP 与固定端口。公网直连数据库在任何场景下都不应该成为选项。

7. 几条不太写进文档的经验

同步表千万不要当成远程表的完整镜像。只同步业务上真正需要展示的字段,一是省资源,二是减少耦合。远程表加了字段、改了字段名,你的同步脚本不受影响,这在两套系统分属不同团队维护的时候尤其重要。

游标的健壮性比同步逻辑本身更重要。我见过太多"跑着跑着就漏数据"的案例,全是游标处理不当:用了非唯一字段、用了可被回填的时间、或者程序异常退出时游标已经更新但数据没落库。前面说的"游标和数据同事务提交"就是为这个,别嫌麻烦。

给同步任务加个监控。最简单的做法是每次执行往一张表里写一条记录,写清楚开始时间、结束时间、同步条数。然后另起一个脚本,检查最近一次执行时间是不是超过阈值,超了就发告警。这个成本极低,但能把"同步悄悄停了三天没人发现"这种事故挡在门外。

最后一点,跨服务器取数这件事,能用"拉"就别用"推"。让消费方主动去拉,主动权在自己手上,出问题好排查;让对方主动推过来,一旦对方逻辑有问题,你连数据怎么进来的都搞不清。这个原则我在好几个项目里验证过,基本没错。