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 的账号是用户名 来源主机一起构成的sitelocalhost和site10.0.3.%是两个完全不同的账号。DedeCMS 站点搬到另一台机器后连接不上八成就卡在这里——账号建了但来源主机写的是localhost。-- 给站点机器所在的网段授权只授权它真正需要的库 CREATE USER site_rw10.0.3.% IDENTIFIED BY 一串足够长的口令; GRANT SELECT, INSERT, UPDATE, DELETE ON dede_main.* TO site_rw10.0.3.%; -- 给同步任务单独一个只读账号权限更小 CREATE USER sync_ro10.0.3.% IDENTIFIED BY 另一串口令; GRANT SELECT, LOCK TABLES ON dede_main.* TO sync_ro10.0.3.%; FLUSH PRIVILEGES;网段通配10.0.3.%比%安全得多也比写死单个 IP 灵活。但要注意一点MySQL 匹配来源主机时如果同时存在site_rw10.0.3.%和site_rw10.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-setutf8mb4并且建库时写死DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci。排序规则不一致还会导致跨库 JOIN 时索引失效这个坑更隐蔽。时区。time_zone参数不设的时候MySQL 用的是系统时区两台机器如果一台设了Asia/Shanghai一台是 UTCNOW()出来的时间就差 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_timestamps0改了 PHP 文件不会立即生效需要重载 PHP-FPM。还有一个容易忽略的点安装目录install/一定要删掉。有些站迁移完忘了删后面被人重新跑一遍安装流程数据库直接指向了新库站点就成空壳了。这个不是理论风险我在实际项目里见过两次。3.3 连接失败报错对照表报错信息片段大概率原因处理方式Cant connect to MySQL server端口不通 / 服务没起 / 监听地址不对用 nc 测端口检查 bind-addressHost x.x.x.x is not allowed账号来源主机不匹配补授权user网段Access denied for user口令错 / 认证插件不兼容核对口令8.0 环境改用 native 插件Unknown database库名写错 / 库没建登录数据库SHOW DATABASES确认Table x doesnt 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:host10.0.3.31;port3306;dbnamedede_sub;charsetutf8mb4; $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:host127.0.0.1;dbnamedede_sub;charsetutf8mb4, 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) ) ENGINEFEDERATED CONNECTIONmysql://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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT子站栏目文章数;索引这一块idx_type_pub是为了首页按栏目取最新idx_update是为了同步任务查游标。别小看这两个索引同步任务跑全量扫描的时候有没有索引差的是几个数量级。同步字段synced_at记录本地落库时间排查问题时很有用。5.3 同步脚本游标、批量、防重入脚本的核心是游标持久化和批量写入。游标存在本地一张小表里不要存文件避免多实例跑的时候打架。?php // sync_sub_article.php $local new PDO(mysql:host127.0.0.1;dbnamedede_main;charsetutf8mb4, local_rw, 口令, [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, ]); $remote new PDO(mysql:host10.0.3.31;dbnamedede_sub;charsetutf8mb4, 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 tasksub_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 tasksub_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 21flock -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 里写了charsetutf8mb4就基本没问题。主键撞车是多库合并时的高频问题。两个库各自自增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. 几条不太写进文档的经验同步表千万不要当成远程表的完整镜像。只同步业务上真正需要展示的字段一是省资源二是减少耦合。远程表加了字段、改了字段名你的同步脚本不受影响这在两套系统分属不同团队维护的时候尤其重要。游标的健壮性比同步逻辑本身更重要。我见过太多跑着跑着就漏数据的案例全是游标处理不当用了非唯一字段、用了可被回填的时间、或者程序异常退出时游标已经更新但数据没落库。前面说的游标和数据同事务提交就是为这个别嫌麻烦。给同步任务加个监控。最简单的做法是每次执行往一张表里写一条记录写清楚开始时间、结束时间、同步条数。然后另起一个脚本检查最近一次执行时间是不是超过阈值超了就发告警。这个成本极低但能把同步悄悄停了三天没人发现这种事故挡在门外。最后一点跨服务器取数这件事能用拉就别用推。让消费方主动去拉主动权在自己手上出问题好排查让对方主动推过来一旦对方逻辑有问题你连数据怎么进来的都搞不清。这个原则我在好几个项目里验证过基本没错。