MariaDB
MariaDB 是一个可自主部署的关系型数据库。它接收 MySQL 客户端协议,支持事务、约束、索引、关联查询、复制和在线运维,但已经与 MySQL 独立演进。本文从产品边界开始,先解释为什么选或不选,再从一台可用实例连续做到安全接入、高可用、备份恢复、升级迁移和故障处理。
一、是什么
1. 产品定位与边界
MariaDB Server 负责持久保存结构化数据,并通过 SQL 提供查询和事务能力。默认事务存储引擎 InnoDB 提供聚簇索引、MVCC、行锁、undo 和 redo;Server 层负责连接、认证、SQL 解析、优化、执行、权限、二进制日志和复制。
它解决的是“有关系、有约束、需要事务的数据如何可靠读写”,不是所有数据问题。订单、账户、库存、工单、配置和权限等强结构化数据属于典型 OLTP;有合适索引且聚合范围受控的报表也可以在 MariaDB 完成。自主部署还能控制数据位置、版本、插件、备份和升级节奏。
图片、视频和大文件应存对象存储,数据库只保存元数据与地址;复杂全文检索、相关性排序和海量日志检索通常交给 OpenSearch、Elasticsearch 等系统。MariaDB 单实例也不等于分布式数据库:异步副本主要扩读和容灾,Galera 仍不能消除跨节点写冲突与网络代价。
2. 官方资源、发布线与许可
常用一手入口包括官网、产品文档、下载页和全部发行记录。源码与问题可以从 Server 仓库和问题跟踪交叉确认;生产规划还要查看维护政策与安全公告。专项操作分别以 MariaDB Backup、Galera Cluster和Connector/J文档为准。
官方发行表和维护政策给出的关键信息如下:
| 发行线 | 精确版本与状态 | 使用结论 |
|---|---|---|
| 12.3 LTS | 12.3.3,Stable;Community LTS 二进制按 GA 起三年维护 | 新部署可作为候选基线,仍需固定补丁并完成应用、备份和回退测试 |
| 13.0 | 13.0.1,RC | 只用于兼容和预发布测试,不承载生产数据 |
| 13.1 | 13.1.0,Preview | 用于提前发现变化,环境应可随时重建 |
| 11.8、11.4、10.11、10.6 | 仍有各自维护窗口 | 已运行系统按维护期和业务风险升级,不因新版本出现就直接跨版本替换 |
“LTS”描述发行线维护周期,“Stable”描述某个补丁成熟度,两者不能互相替代。安装前再次查看发行说明、已知问题和仓库里的精确包版本:
mariadb --version
mariadb -Nse "SELECT VERSION(), @@version_comment;"预期同时看到 12.3.3-MariaDB 和 MariaDB 发行标识。若只看到 3306 端口可连接、MySQL 字样或代理伪装版本,应先确认 DNS、代理后端和实际进程,不执行结构变更。
MariaDB Community Server 使用 GPL v2;官方 Connector/C、Connector/J、Connector/ODBC 使用 LGPL v2.1 或更高版本。内网使用、通过网络连接和向客户分发修改后的程序涉及的义务不同,实际交付以所用版本内的 COPYING、组件许可证和法务结论为准。MaxScale 的许可随版本变化,采用前必须按精确版本核对订阅与许可,不把 Server 的 GPL 结论套到 MaxScale。
3. 总体架构与核心组件
一次请求会经过以下层次:
应用 / CLI / BI
|
MySQL 协议、TLS、认证
|
连接线程 -> SQL 解析 -> 权限检查 -> 优化器 -> 执行器
|
存储引擎接口
|
InnoDB / Aria 等
|
数据页、undo、redo、doublewrite
事务提交同时可能写入 binlog -> 异步副本 / CDC / PITR核心组件及边界:
| 组件 | 作用 | 关键边界 |
|---|---|---|
| 连接与权限层 | 协议握手、TLS、认证、账号与授权 | 连接成功不代表权限最小,也不代表连到正确实例 |
| 解析器与优化器 | 解析 SQL、改写查询、估算成本、选择连接顺序和索引 | 统计信息或数据分布变化会改变执行计划 |
| InnoDB | 事务、MVCC、行锁、崩溃恢复、聚簇和二级索引 | 长事务会阻挡 purge;二级索引叶子保存主键 |
| redo | 记录页修改,保障崩溃恢复 | redo 不是备份,不能恢复误删或磁盘整体损坏 |
| undo | 保存旧版本,支持回滚和一致性读 | 长快照使历史版本无法及时清理 |
| binlog | 记录逻辑变更,供复制、CDC 和时间点恢复使用 | 必须和基础备份、保留期及恢复测试配套 |
| 异步复制 | 把主节点 binlog 传到副本重放 | 默认存在延迟,故障切换可能丢失尚未复制的数据 |
| Galera | 多节点同步提交、成员关系和仲裁 | 需要多数派,写吞吐受最慢节点与网络影响 |
| MaxScale | 可选的代理、路由和监控层 | 不是数据库副本;误判或配置错误会把流量送错节点 |
4. 请求路径与事务提交
连接建立后,Server 校验账号、来源主机、认证插件和 TLS;SQL 经解析、权限检查和优化后,由执行器调用 InnoDB。读取先查 buffer pool,未命中才读磁盘;修改在内存页上完成,同时生成 undo 和 redo。
典型事务先为旧版本写 undo,再修改 buffer pool 中的数据页并产生 redo。开启 binlog 时,提交过程还要协调 redo 与 binlog,避免只提交一边。返回成功后脏页可以稍后刷回数据文件,崩溃重启则用 redo 恢复。副本收到 binlog 后异步重放,因此主库提交成功不等于副本立刻可见。
关键持久化变量:
SHOW VARIABLES WHERE Variable_name IN
('innodb_flush_log_at_trx_commit','sync_binlog','log_bin','binlog_format');生产常见基线是 innodb_flush_log_at_trx_commit=1、sync_binlog=1、binlog_format=ROW。降低刷盘强度可能提高吞吐,但会扩大主机故障时的数据丢失窗口;必须用明确的 RPO 决策,不能把压测结果当持久性结论。
5. InnoDB 的核心内部模型
5.1 聚簇索引和二级索引
InnoDB 按主键组织表数据。二级索引叶子保存索引列和主键,所以长主键会放大所有二级索引。没有合适主键时,InnoDB 仍需生成内部行标识,但应用难以稳定分页、复制校验和定位单行。
SHOW CREATE TABLE appdb.orders\G
SHOW INDEX FROM appdb.orders;判断时同时看主键宽度、索引顺序、基数和是否存在重复索引。联合索引 (tenant_id, status, created_at) 能否使用,取决于查询是否从最左列开始形成可选范围,而不是“字段都在索引里”就一定高效。
5.2 MVCC、隔离级别和锁
InnoDB 通过 undo 保存旧版本,通过 read view 提供一致性读。普通 SELECT 通常不阻塞写;SELECT ... FOR UPDATE、UPDATE、DELETE 会加锁。默认隔离级别通常是 REPEATABLE-READ,但应以实例输出为准:
SELECT @@transaction_isolation, @@autocommit;
SELECT @@global.transaction_isolation;READ-COMMITTED 减少部分间隙锁影响,但会改变同一事务两次读取的可见性。切换隔离级别前要回归库存扣减、分页扫描、批处理和重试逻辑。
5.3 Buffer Pool、redo、undo 和 purge
Buffer Pool 缓存数据页和索引页,是 InnoDB 性能的核心。命中率高不代表没有问题:全表扫描也可能污染缓存,脏页过多会造成后续刷盘尖峰。
SHOW GLOBAL STATUS WHERE Variable_name IN
('Innodb_buffer_pool_read_requests','Innodb_buffer_pool_reads',
'Innodb_buffer_pool_pages_dirty','Innodb_history_list_length');Innodb_buffer_pool_reads 持续快速增长表示物理读压力;脏页比例长期偏高表示刷盘跟不上;history list 持续增长通常要查长事务、未结束快照和 purge 能力。
6. SQL 优化器与执行计划
优化器依据索引统计、数据分布和成本模型选择访问路径。相同 SQL 在 MariaDB 与 MySQL、不同补丁或不同数据规模下可能选择不同计划,因此升级和迁移必须保存真实 SQL 的执行计划与时延分布。
EXPLAIN FORMAT=JSON
SELECT id, total_amount
FROM orders
WHERE tenant_id=1001 AND status='PAID'
ORDER BY created_at DESC
LIMIT 20;MariaDB 可用 ANALYZE FORMAT=JSON 执行查询并返回估算与实际统计,只在可控查询和测试数据上使用:
ANALYZE FORMAT=JSON
SELECT COUNT(*) FROM orders WHERE tenant_id=1001 AND status='PAID';重点比较访问类型、选择的 key、估算行数、实际循环与行数、临时表和排序。type=ALL 不总是错误,小表全扫可能比索引回表更便宜;大表高频查询出现全扫、估算与实际差距很大时,先更新统计并检查谓词与索引,而不是强行加 hint。
7. 复制、Galera 与 MaxScale 的关系
三者解决的问题不同。异步复制负责复制数据,常用于只读副本、报表、备份和跨机房灾备。Galera 让多个节点组成同步复制集群,通过多数派维持 Primary Component。MaxScale 位于客户端和数据库之间,负责连接、路由、监控或读写分离,本身不保存业务数据。
高可用链路至少包含故障发现、旧主隔离、新主选定、客户端重连、数据缺口判断和旧主回归。只搭一个副本或代理,不等于完成高可用。
8. 与 MySQL、PostgreSQL 的本质差异
| 维度 | MariaDB | MySQL | PostgreSQL |
|---|---|---|---|
| 兼容入口 | MySQL 协议和大量语法 | 自身协议与生态基准 | 独立协议和 SQL 方言 |
| JSON | JSON 主要是带校验语义的 LONGTEXT 别名 | binary JSON 与相应索引/函数体系 | json、jsonb 与丰富操作符 |
| 高可用 | 异步复制、Galera、MaxScale | 异步复制、Group Replication、InnoDB Cluster | 流复制及 Patroni 等外部编排 |
| 扩展方式 | 存储引擎、插件、代理 | 插件与官方生态 | 扩展、类型、函数和索引能力很强 |
| 迁移风险 | 与 MySQL 接近但不等价 | MariaDB 方言、GTID、认证不等价 | 协议、类型、SQL、序列全面不同 |
从 MySQL 迁到 MariaDB,不能只验证“SQL 能导入”。至少核对 JSON、collation、sql_mode、生成列、分区、函数、事件、DEFINER、认证插件、GTID、驱动 URL 和执行计划。协议兼容降低接入成本,但不会自动消除数据语义和运维差异。
二、为什么
1. 适用与不适用
当数据有稳定结构、主外键关系、唯一约束和事务边界,团队已有 SQL、InnoDB、MySQL 协议客户端和备份恢复经验,并希望自主控制版本、数据位置、插件和升级窗口时,MariaDB 很合适。单节点写入能力应能满足需求,读流量可以通过索引、缓存或副本扩展;团队还要能承担主从延迟、切换演练、备份验证和容量规划。
要求跨地域多主低延迟写入且冲突自动合并的业务,不适合直接采用 MariaDB。数据主要是大文件、搜索倒排、时序遥测或离线扫描时,应选对应的专用系统。没有数据库运维能力却要求自建达到托管服务同等可用性,依赖 MySQL binary JSON、GTID、认证插件或 InnoDB Cluster 完全等价,或者写入已超过单分片上限但没有拆分计划,也都应换一种方案。
2. 常见业务场景
2.1 核心业务 OLTP
订单、账户、支付状态、工单和权限关系需要事务、唯一约束和可追溯变更。MariaDB 的优势在于成熟 SQL 与 InnoDB;前提是事务短、索引匹配、热点行可控,并且备份和复制不是事后补充。
2.2 内容、配置和中后台
内容元数据、租户配置、审批流和管理后台通常读多写少、关系明确。单实例加副本即可覆盖相当长的增长阶段,复杂度低于过早拆分为多个专用存储。
2.3 MySQL 协议生态替代
现有应用使用 MySQL 协议、SQL 和工具,希望获得 MariaDB 的发行、引擎或社区路线时,可做兼容迁移。收益来自接入成本较低,不来自“零差异”;迁移前必须用真实 schema、数据和查询做验证。
2.4 读扩展与灾备
异步副本可承接报表、备份或允许旧读的查询,也可作为灾备候选。强一致读仍应走主节点或使用明确的一致性等待机制;不能把未知延迟的副本放到余额、库存确认等强一致链路。
3. 优势成立的条件
| 优势 | 成立条件 |
|---|---|
| SQL 和事务成熟 | 模型、约束和事务边界清晰,应用正确处理回滚与重试 |
| MySQL 生态接入成本低 | 逐项验证 MariaDB 方言、驱动、认证和工具,不以端口连通代替兼容测试 |
| 单机性能高 | 热数据能进入内存,索引选择性足够,慢 SQL 和长事务受治理 |
| 读扩展容易 | 查询允许副本延迟,连接层能区分强一致与可旧读流量 |
| 自主可控 | 团队真正承担监控、补丁、备份、恢复、切换和容量工作 |
| Galera 可提高节点可用性 | 低延迟网络、多数派故障域、写冲突受控且做过故障演练 |
4. 代价与边界
MariaDB 的主要成本不在安装,而在长期运行。单实例写能力有上限,增加异步副本不能扩主库写吞吐;索引加速读取,却增加写放大、缓存占用和 DDL 成本;强刷盘提高持久性,同时需要更低延迟、更稳定的存储。
异步复制简单且吞吐好,但存在延迟和故障切换数据窗口。Galera 降低单节点故障影响,提交却受网络和最慢节点约束,冲突事务会重试或失败。自建节省部分服务费,同时增加值守、演练、备份介质、跨区流量和专家成本;MaxScale 能统一路由,也会新增代理容量、配置、许可和故障面。
5. 部署形态怎么选
| 形态 | 适用场景 | 可用性与一致性 | 复杂度 |
|---|---|---|---|
| 单实例 | 开发、测试、可恢复的内部系统 | 节点故障即中断;事务只在本机 | 低 |
| 主库 + 异步副本 | 常规生产、读扩展、备份、灾备 | 主库强一致;副本可能旧读;提升可能有数据缺口 | 中 |
| 三节点 Galera | 需要节点级自动成员管理、网络低延迟 | 多数派可服务;同步提交;冲突与流控影响写入 | 高 |
| 数据库 + MaxScale | 需要统一入口、读写路由或监控联动 | 取决于后端拓扑与隔离是否正确 | 中到高 |
| 托管 MariaDB/MySQL 兼容服务 | 团队不想维护主机与备份设施 | 由服务等级、地域与产品兼容性决定 | 中,成本转为服务费 |
默认路径是:开发单实例;常规生产一主一到两个副本,备份独立保存;只有明确需要同步多节点且能承担冲突、流控和仲裁时才选 Galera;只有统一入口收益大于新增故障面时才加 MaxScale。
6. 一致性、可用性、性能和成本取舍
6.1 一致性与性能
强刷盘、同步确认和强一致读会增加延迟;降低刷盘、读副本或异步跨区能提高吞吐,却扩大数据丢失或旧读窗口。先为业务定义 RPO、RTO 和哪些读取必须读到刚写入数据,再选参数和路由。
6.2 可用性与复杂度
副本数量增加不自动提高可用性。没有旧主隔离、提升规则、客户端刷新、数据差异判断和演练,更多节点只会增加误切换概率。高可用是完整流程,不是某个组件开关。
6.3 性能与成本
大内存能提高缓存命中,NVMe 能降低刷盘和随机读延迟,但坏 SQL、无界分页和热点行不会被硬件永久掩盖。先优化模型与访问路径,再按峰值工作集、写入量、复制和备份窗口购买资源。
7. 与竞品的选型结论
选择 MariaDB:已有 MySQL 协议生态,需要成熟 OLTP、自主部署和可控发行路线,并接受对差异做验证。
选择 MySQL:依赖 Oracle MySQL 的 binary JSON、认证、Group Replication、InnoDB Cluster 或厂商生态,且不希望承担 MariaDB 分叉差异。
选择 PostgreSQL:复杂类型、扩展、标准 SQL 能力、地理空间或高级查询比 MySQL 协议兼容更重要。
选择托管服务:团队更愿意为自动备份、补丁、故障转移和支持付费,但仍要验证兼容边界、出口成本和恢复能力。
不要按“谁的跑分更高”单点决策。最终用代表性 schema、数据量、事务比例、P99、恢复时间、故障切换、三年总成本和团队能力做结论。
三、怎么做
1. 准备环境和版本
下面以 MariaDB 12.3.3 为示例。生产环境先准备独立主机或虚拟机;不要把数据库与高 IO 的构建、日志、对象存储进程混放。最低检查项:
uname -a
lscpu | sed -n '1,20p'
free -h
lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINTS
df -hT
timedatectl status判断标准:系统时间同步;数据盘和备份盘不是同一故障点;磁盘有持续 IOPS 和 fsync 能力;内存除 buffer pool 外还为连接、排序、临时表、操作系统页缓存和备份留有余量。df 只表示容量,不表示延迟,生产前用接近数据库块大小和队列深度的 fio 测试独立空盘,禁止破坏已有数据盘。
确认端口没有被占用:
ss -lntp | grep ':3306 ' || true
getent hosts db.internal.example如果已有进程监听 3306,先辨认进程与数据目录,不直接结束。DNS 应解析到计划地址;若使用代理,服务端和代理端口要分别记录。
2. 用系统包安装
2.1 Debian 或 Ubuntu
发行版仓库适合快速开始:
sudo apt update
sudo apt install -y mariadb-server mariadb-client mariadb-backup
apt-cache policy mariadb-server预期 Installed 显示已装版本。若不是计划发行线,不要先建生产数据再换仓库。
需要官方 12.3 仓库时,先把官方设置脚本下载为文件并人工查看,再执行:
curl -fL https://r.mariadb.com/downloads/mariadb_repo_setup -o /tmp/mariadb_repo_setup
less /tmp/mariadb_repo_setup
sudo bash /tmp/mariadb_repo_setup --mariadb-server-version=mariadb-12.3
sudo apt update
apt-cache policy mariadb-server
sudo apt install -y mariadb-server mariadb-client mariadb-backup
rm -f /tmp/mariadb_repo_setupcurl 非零、仓库签名失败或候选版本不是 12.3 时停止。不要使用 curl | sudo bash,也不要通过关闭签名校验绕过仓库错误。
2.2 RHEL、Rocky Linux 或 AlmaLinux
配置官方仓库后安装:
curl -fL https://r.mariadb.com/downloads/mariadb_repo_setup -o /tmp/mariadb_repo_setup
sudo bash /tmp/mariadb_repo_setup --mariadb-server-version=mariadb-12.3
sudo dnf makecache
sudo dnf install -y MariaDB-server MariaDB-client MariaDB-backup
dnf list installed MariaDB-server
rm -f /tmp/mariadb_repo_setup预期包名和版本来自 MariaDB 官方仓库。若系统已有发行版自带 mariadb-libs 并发生冲突,先用 dnf repoquery --installed 查依赖,不使用 --allowerasing 盲删业务依赖。
3. 用容器运行最小实例
容器适合本地开发、CI 和可重建环境。生产仍需明确持久卷、备份、资源限制、升级与节点故障恢复。
mkdir -p mariadb-lab/data mariadb-lab/secrets
chmod 700 mariadb-lab/secrets
umask 077
openssl rand -base64 32 > mariadb-lab/secrets/root-password
{ printf '[client]\nuser=root\npassword='; \
cat mariadb-lab/secrets/root-password; } \
> mariadb-lab/secrets/root-client.cnf先拉取 12.3 发行线镜像并记录实际 digest;Docker Hub 尚未提供计划补丁的精确 tag 时,不能虚构 tag:
docker pull mariadb:12.3
docker image inspect mariadb:12.3 --format '{{index .RepoDigests 0}}'将输出的 digest 写入环境清单,正式环境以 mariadb:12.3@sha256:... 固定。下面的实验只向本机暴露端口:
docker run -d --name mariadb-lab \
-p 127.0.0.1:3306:3306 \
-v "$PWD/mariadb-lab/data:/var/lib/mysql" \
-v "$PWD/mariadb-lab/secrets/root-password:/run/secrets/root-password:ro" \
-v "$PWD/mariadb-lab/secrets/root-client.cnf:/run/secrets/root-client.cnf:ro" \
-e MARIADB_ROOT_PASSWORD_FILE=/run/secrets/root-password \
mariadb:12.3检查初始化:
docker ps --filter name=mariadb-lab
docker logs --tail 100 mariadb-lab
docker exec mariadb-lab mariadb-admin \
--defaults-extra-file=/run/secrets/root-client.cnf ping
docker exec mariadb-lab mariadb \
--defaults-extra-file=/run/secrets/root-client.cnf \
-Nse "SELECT VERSION(),@@version_comment"预期容器状态为 Up,日志出现可接收连接的信息,mariadb-admin 返回 mysqld is alive,版本查询显示实际 MariaDB 补丁。补丁不是批准版本时停止,不把浮动 tag 直接用于生产。若旧数据卷已初始化,MARIADB_* 初始化变量不会重新创建账号;必须使用原凭据或明确清空可丢弃的实验卷,不能反复改环境变量猜密码。
停止与再次启动:
docker stop --time 30 mariadb-lab
docker start mariadb-lab
docker exec mariadb-lab mariadb-admin \
--defaults-extra-file=/run/secrets/root-client.cnf ping重启后业务表仍在,才证明数据确实落在持久卷。
4. 从源码构建
源码构建适合开发 Server、验证补丁或需要发行包未提供的构建选项,不是常规生产的首选。先安装编译器、CMake、Ninja、OpenSSL 和开发库,再从官方归档下载精确版本:
curl -fLO https://archive.mariadb.org/mariadb-12.3.3/source/mariadb-12.3.3.tar.gz
curl -fLO https://archive.mariadb.org/mariadb-12.3.3/source/sha256sums.txt
sha256sum -c --ignore-missing sha256sums.txt
tar -xzf mariadb-12.3.3.tar.gz校验必须返回 OK;归档和摘要下载失败时不继续。构建到独立目录:
cmake -S mariadb-12.3.3 -B build -G Ninja \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DCMAKE_INSTALL_PREFIX=/opt/mariadb-12.3.3 \
-DWITH_SSL=system
cmake --build build --parallel
ctest --test-dir build --output-on-failure
sudo cmake --install build任何测试失败都应先定位,不把未通过测试的二进制装入生产。安装后检查:
/opt/mariadb-12.3.3/bin/mariadbd --version
/opt/mariadb-12.3.3/bin/mariadb --version源码安装不会自动提供发行版的 systemd unit、用户、日志轮转和升级脚本,需要自行维护;若没有这一需求,回到系统包或容器方案。
5. 启动最小实例
系统包通常已创建 mysql 用户、数据目录和 mariadb.service:
sudo systemctl enable --now mariadb
sudo systemctl status mariadb --no-pager
sudo mariadb-admin ping预期 unit 为 active (running),ping 返回 mysqld is alive。启动失败时先看日志:
sudo journalctl -u mariadb -n 100 --no-pager
sudo mariadbd --verbose --help >/tmp/mariadbd-help.txt不要在已有数据目录上重复执行初始化。新建独立实例才使用:
sudo install -d -o mysql -g mysql -m 750 /srv/mariadb/data
sudo mariadb-install-db --user=mysql --datadir=/srv/mariadb/data初始化成功应创建系统表;若目录非空、属主错误或路径指向旧数据,停止并确认数据归属。
运行交互式安全初始化:
sudo mariadb-secure-installation根据实际认证设计设置 root 认证、删除匿名用户和 test 库。脚本是起点,不会替代应用账号最小授权、TLS、防火墙和备份。
6. 使用 CLI 和基础 SQL
本机发行包常让操作系统 root 通过 unix_socket 登录:
sudo mariadb远程或密码账号用受控 option file,避免密码出现在历史和进程列表:
[client]
host=127.0.0.1
port=3306
user=app_user
password=replace-with-secret
ssl-verify-server-certchmod 600 ~/.my-mariadb.cnf
mariadb --defaults-extra-file="$HOME/.my-mariadb.cnf"登录后先确认身份和会话:
SELECT VERSION(), @@version_comment;
SELECT CURRENT_USER(), USER();
SELECT @@transaction_isolation, @@sql_mode, @@time_zone;
STATUS;CURRENT_USER() 是授权匹配到的账户,USER() 是客户端声明的用户和来源;二者不同可能是匿名账户或 host 匹配造成的,应先修正再继续。
创建实验库和表:
CREATE DATABASE appdb
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE appdb;
CREATE TABLE users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uk_users_email (email)
) ENGINE=InnoDB;完成一轮增删改查:
INSERT INTO users(email, display_name)
VALUES ('alice@example.com','Alice');
SELECT id,email,display_name,created_at
FROM users WHERE email='alice@example.com';
UPDATE users SET display_name='Alice Zhang'
WHERE email='alice@example.com';
DELETE FROM users WHERE email='alice@example.com';每个写操作检查受影响行数。UPDATE 或 DELETE 条件不唯一时先在事务中 SELECT 确认范围,生产工具默认拒绝无 WHERE 的批量修改。
7. 配置一台生产单机
发行包主配置通常在 /etc/mysql/mariadb.conf.d/ 或 /etc/my.cnf.d/。先找实际读取顺序:
mariadbd --help --verbose 2>/dev/null | sed -n '/Default options/,/Variables and options/p'建立独立覆盖文件,例如 60-production.cnf:
[mariadbd]
bind-address=10.20.0.10
port=3306
skip-name-resolve=ON
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
default-time-zone=+00:00
max_connections=300
connect_timeout=5
wait_timeout=600
max_allowed_packet=64M
innodb_buffer_pool_size=12G
innodb_flush_method=O_DIRECT
innodb_flush_log_at_trx_commit=1
sync_binlog=1
log_bin=mariadb-bin
binlog_format=ROW
binlog_expire_logs_seconds=604800
slow_query_log=ON
long_query_time=0.5
log_slow_verbosity=query_plan,explain参数必须按内存、并发、磁盘和恢复目标调整;12G 不是通用答案。配置前保存当前值:
mariadb -Nse "SHOW VARIABLES" > before-variables.tsv
sudo mariadbd --help --verbose >/tmp/mariadbd-validate.txt
sudo systemctl restart mariadb重启后核对:
SHOW VARIABLES WHERE Variable_name IN
('bind_address','character_set_server','collation_server',
'max_connections','innodb_buffer_pool_size','log_bin','binlog_format');值不符合时先检查配置文件位置、组名和重复定义。服务无法启动则恢复刚修改的覆盖文件并查看 journal,不连续尝试多个参数。
8. 配置网络、账号和 TLS
8.1 只开放需要的来源
监听明确内网地址,不直接绑定公网。以 firewalld 为例,只允许应用网段:
sudo firewall-cmd --permanent --add-rich-rule='rule family=ipv4 source address=10.20.10.0/24 port port=3306 protocol=tcp accept'
sudo firewall-cmd --reload
sudo firewall-cmd --list-all从允许和不允许的网络各测试一次。允许网段应能建立 TCP,不允许网段应超时或拒绝;云环境还需同步限制安全组。
8.2 建立最小权限账号
先建库,再按应用真实 SQL 授权:
CREATE USER 'app_user'@'10.20.10.%'
IDENTIFIED BY 'replace-with-generated-secret'
REQUIRE SSL;
GRANT SELECT, INSERT, UPDATE, DELETE
ON appdb.* TO 'app_user'@'10.20.10.%';
SHOW GRANTS FOR 'app_user'@'10.20.10.%';应用账号不应拥有 SUPER、FILE、PROCESS、SHUTDOWN 或全局 ALL PRIVILEGES。若应用确需执行迁移,使用单独的短期部署账号,不把 DDL 权限永久放在运行账号上。
验证正向和负向权限:
SELECT COUNT(*) FROM appdb.users;
CREATE DATABASE should_be_denied;第一条应成功,第二条应报权限不足。若第二条成功,先撤销多余授权再接入业务。
8.3 启用并验证 TLS
证书和私钥应由组织 CA 签发并限制私钥权限,配置示例:
[mariadbd]
ssl_ca=/etc/mysql/tls/ca.pem
ssl_cert=/etc/mysql/tls/server-cert.pem
ssl_key=/etc/mysql/tls/server-key.pem
require_secure_transport=ON重启后检查:
SHOW VARIABLES LIKE 'have_ssl';
SHOW VARIABLES LIKE 'require_secure_transport';
SHOW SESSION STATUS LIKE 'Ssl_cipher';have_ssl=YES、require_secure_transport=ON,且 TLS 会话的 Ssl_cipher 非空才算生效。再从客户端使用错误 CA 连接,必须失败;只看到端口通并不能证明证书校验有效。
9. 数据建模、索引与事务
9.1 明确类型和约束
金额用 DECIMAL 或最小货币单位整数,不用二进制浮点;时间统一保存 UTC,并明确是否需要时区语义;状态列用受控值和业务约束;大文本与 BLOB 避免出现在高频覆盖索引中。
CREATE TABLE inventory (
sku_id BIGINT UNSIGNED NOT NULL,
available INT UNSIGNED NOT NULL,
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (sku_id),
CONSTRAINT chk_inventory_available CHECK (available >= 0)
) ENGINE=InnoDB;
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL,
sku_id BIGINT UNSIGNED NOT NULL,
status VARCHAR(20) NOT NULL,
total_amount DECIMAL(18,2) NOT NULL,
version INT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
KEY idx_orders_tenant_status_time
(tenant_id, status, created_at, id),
KEY idx_orders_customer (customer_id, created_at),
CONSTRAINT fk_orders_inventory FOREIGN KEY (sku_id)
REFERENCES inventory(sku_id),
CONSTRAINT chk_orders_amount CHECK (total_amount >= 0)
) ENGINE=InnoDB;
INSERT INTO inventory(sku_id,available) VALUES (1001,10);检查实际 DDL,确认字符集、collation、默认值和约束没有被工具改写:
SHOW CREATE TABLE orders\G
SHOW INDEX FROM orders;9.2 用 keyset 分页
大偏移量分页需要扫描并丢弃前面的行。稳定排序使用时间和唯一主键组成游标:
SELECT id,status,total_amount,created_at
FROM orders
WHERE tenant_id=1001
AND (created_at,id) < (?,?)
ORDER BY created_at DESC,id DESC
LIMIT 100;索引顺序要与过滤和排序一致;游标字段不可随意变更,否则会重复或漏行。
9.3 保持事务短小
库存扣减示例:
START TRANSACTION;
SELECT available
FROM inventory
WHERE sku_id=1001
FOR UPDATE;
UPDATE inventory
SET available=available-1
WHERE sku_id=1001 AND available>0;
COMMIT;应用必须检查 UPDATE 受影响行数:为 1 表示成功,为 0 表示库存不足或并发已改变状态。事务内不要调用远程 HTTP、等待用户输入或批量处理无界数据;异常时显式 ROLLBACK。
9.4 处理死锁和乐观并发
死锁是并发控制的正常结果之一。统一锁顺序、缩小事务和建立索引可降低概率;应用只对明确的死锁或短暂锁等待做有上限、带抖动的整事务重试。
乐观更新使用版本号:
UPDATE orders
SET status='PAID', version=version+1, updated_at=UTC_TIMESTAMP(6)
WHERE id=1001 AND version=7 AND status='PENDING';受影响行数为 0 时重新读取并按业务决定,不盲目覆盖。
10. 用 Java 完整接入
10.1 依赖和连接池
MariaDB Connector/J 3.5.10 是这里采用的精确版本,运行时固定为 Java 21。最小项目只有一个 pom.xml 和 src/main/java/example/ 下的三个类;升级依赖或 JDK 前重新做连接、TLS、事务与异常测试。完整 pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>example</groupId>
<artifactId>mariadb-orders</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>3.5.10</version>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>7.1.0</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.17</version>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
<configuration>
<mainClass>example.App</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>账号密码从 secret manager 或进程环境注入,不写入 Git。连接池示例:
package example;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
public final class DataSources {
private DataSources() {}
public static HikariDataSource create() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(requiredEnv("DB_URL"));
config.setUsername(requiredEnv("DB_USER"));
config.setPassword(requiredEnv("DB_PASSWORD"));
config.setMaximumPoolSize(24);
config.setMinimumIdle(4);
config.setConnectionTimeout(1000);
config.setValidationTimeout(500);
config.setIdleTimeout(300_000);
config.setMaxLifetime(1_500_000);
config.setAutoCommit(true);
config.setPoolName("orders-db");
return new HikariDataSource(config);
}
private static String requiredEnv(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException(name + " is required");
}
return value;
}
}connectTimeout 控制建连,socketTimeout 控制网络读写,connectionTimeout 控制从池中等待连接。它们解决的不是同一问题。连接数估算:
应用实例数 × 每实例最大连接 + 管理/复制/备份余量 < max_connections例如 10 个实例各 24 条连接就是 240,不应把服务端上限也设为 240。留出发布重叠、运维和短时波动余量,同时用应用线程数和数据库 CPU 验证,而不是无限放大连接池。
10.2 DAO、事务与超时
下面完成“创建订单并扣减库存”的单事务操作:
package example;
import javax.sql.DataSource;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.SQLTimeoutException;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
public final class OrderService {
private static final int MAX_ATTEMPTS = 3;
private static final long TOTAL_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(3);
private final DataSource dataSource;
public OrderService(DataSource dataSource) {
this.dataSource = dataSource;
}
public void createOrder(long orderId, long customerId,
long skuId, BigDecimal amount)
throws SQLException, InterruptedException {
long deadline = System.nanoTime() + TOTAL_TIMEOUT_NANOS;
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
remainingMillis(deadline);
createOrderOnce(orderId, customerId, skuId, amount, deadline);
return;
} catch (CommitOutcomeUnknownException error) {
throw error;
} catch (SQLException error) {
if (!retryable(error) || attempt == MAX_ATTEMPTS) {
throw error;
}
long remaining = deadline - System.nanoTime();
long jitterMillis = ThreadLocalRandom.current().nextLong(40, 121);
long sleepMillis = Math.min(
jitterMillis, TimeUnit.NANOSECONDS.toMillis(remaining));
if (sleepMillis <= 0) {
throw error;
}
System.err.printf(
"transaction-retry attempt=%d sqlState=%s errorCode=%d%n",
attempt, error.getSQLState(), error.getErrorCode());
Thread.sleep(sleepMillis);
}
}
}
private void createOrderOnce(long orderId, long customerId,
long skuId, BigDecimal amount, long deadline)
throws SQLException {
try (Connection connection = dataSource.getConnection()) {
connection.setNetworkTimeout(Runnable::run, remainingMillis(deadline));
connection.setAutoCommit(false);
connection.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
boolean commitStarted = false;
try {
int changed = decreaseInventory(connection, skuId, deadline);
if (changed != 1) {
throw new InsufficientInventoryException(skuId);
}
insertOrder(
connection, orderId, customerId, skuId, amount, deadline);
commitStarted = true;
connection.commit();
} catch (SQLException error) {
if (commitStarted) {
throw new CommitOutcomeUnknownException(orderId, error);
}
rollback(connection, error);
throw error;
}
}
}
private int decreaseInventory(Connection connection, long skuId,
long deadline)
throws SQLException {
String sql = """
UPDATE inventory
SET available=available-1
WHERE sku_id=? AND available>0
""";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, skuId);
statement.setQueryTimeout(remainingSeconds(deadline));
return statement.executeUpdate();
}
}
private void insertOrder(Connection connection, long orderId,
long customerId, long skuId, BigDecimal amount,
long deadline)
throws SQLException {
String sql = """
INSERT INTO orders
(id,tenant_id,customer_id,sku_id,status,total_amount,
version,created_at,updated_at)
VALUES (?,1001,?,?,'PENDING',?,0,UTC_TIMESTAMP(6),UTC_TIMESTAMP(6))
""";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, orderId);
statement.setLong(2, customerId);
statement.setLong(3, skuId);
statement.setBigDecimal(4, amount);
statement.setQueryTimeout(remainingSeconds(deadline));
statement.executeUpdate();
}
}
private static boolean retryable(SQLException error) {
return "40001".equals(error.getSQLState())
|| error.getErrorCode() == 1213
|| error.getErrorCode() == 1205;
}
private static int remainingMillis(long deadline) throws SQLTimeoutException {
long nanos = deadline - System.nanoTime();
if (nanos <= 0) {
throw new SQLTimeoutException("transaction deadline exceeded", "HYT00");
}
return (int) Math.min(Integer.MAX_VALUE,
Math.max(1, TimeUnit.NANOSECONDS.toMillis(nanos)));
}
private static int remainingSeconds(long deadline)
throws SQLTimeoutException {
int millis = remainingMillis(deadline);
return Math.max(1, (millis + 999) / 1000);
}
private static void rollback(Connection connection, SQLException original) {
try {
connection.rollback();
} catch (SQLException rollbackError) {
original.addSuppressed(rollbackError);
}
}
public static final class InsufficientInventoryException
extends SQLException {
public InsufficientInventoryException(long skuId) {
super("insufficient inventory for sku=" + skuId, "45000");
}
}
public static final class CommitOutcomeUnknownException
extends SQLException {
private final long orderId;
public CommitOutcomeUnknownException(long orderId, SQLException cause) {
super("commit outcome unknown for order=" + orderId,
cause.getSQLState(), cause.getErrorCode(), cause);
this.orderId = orderId;
}
public long orderId() {
return orderId;
}
}
}预期库存和订单同时提交;库存不足、唯一键冲突以及提交前的 SQL 失败都会回滚。错误码 1213、1205 或 SQLState 40001 只会重试整个事务,最多三次且总时限三秒。commit() 已开始后收到异常时,结果标为未知并停止重试。
10.3 可运行入口与结果判断
src/main/java/example/App.java 会真正调用 DataSources.create() 和 OrderService.createOrder()。verify 模式只查询数据库身份,供升级后验证使用:
package example;
import com.zaxxer.hikari.HikariDataSource;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public final class App {
private App() {}
public static void main(String[] args) throws Exception {
try (HikariDataSource dataSource = DataSources.create()) {
if (args.length == 1 && "verify".equals(args[0])) {
verifyServer(dataSource);
return;
}
long orderId = requiredLong("ORDER_ID");
long customerId = requiredLong("CUSTOMER_ID");
long skuId = requiredLong("SKU_ID");
BigDecimal amount = new BigDecimal(requiredEnv("ORDER_AMOUNT"));
OrderService service = new OrderService(dataSource);
try {
service.createOrder(orderId, customerId, skuId, amount);
printCommittedOrder(dataSource, orderId);
} catch (OrderService.InsufficientInventoryException error) {
System.err.printf("inventory-insufficient sku=%d%n", skuId);
throw error;
} catch (OrderService.CommitOutcomeUnknownException error) {
String lookup = lookupOutcome(dataSource, error.orderId());
System.err.printf(
"commit-outcome-unknown order=%d lookup=%s%n",
error.orderId(), lookup);
throw error;
} catch (SQLException error) {
System.err.printf("sql-failed state=%s code=%d%n",
error.getSQLState(), error.getErrorCode());
throw error;
}
}
}
private static void verifyServer(HikariDataSource dataSource)
throws SQLException {
String sql = "SELECT VERSION(),CURRENT_USER(),@@server_id,@@read_only";
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet result = statement.executeQuery()) {
result.next();
System.out.printf(
"database-ok version=%s user=%s serverId=%d readOnly=%d%n",
result.getString(1), result.getString(2),
result.getLong(3), result.getInt(4));
}
}
private static void printCommittedOrder(HikariDataSource dataSource,
long orderId)
throws SQLException {
String sql = """
SELECT o.customer_id,o.sku_id,i.available
FROM orders o JOIN inventory i ON i.sku_id=o.sku_id
WHERE o.id=?
""";
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, orderId);
try (ResultSet result = statement.executeQuery()) {
if (!result.next()) {
throw new SQLException("committed order not found", "02000");
}
System.out.printf(
"order-created id=%d customer=%d sku=%d inventory=%d%n",
orderId, result.getLong(1), result.getLong(2),
result.getInt(3));
}
}
}
private static String lookupOutcome(HikariDataSource dataSource,
long orderId) {
String sql = "SELECT 1 FROM orders WHERE id=?";
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, orderId);
try (ResultSet result = statement.executeQuery()) {
return result.next() ? "COMMITTED" : "NOT_FOUND";
}
} catch (SQLException lookupError) {
return "LOOKUP_FAILED";
}
}
private static long requiredLong(String name) {
return Long.parseLong(requiredEnv(name));
}
private static String requiredEnv(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException(name + " is required");
}
return value;
}
}10.4 构建、运行与失败分型
前提是 JDK 21、Maven、inventory.sku_id=1001 且 app_user 已有前文权限。先确认单一 Java 版本并构建:
JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
export JAVA_HOME
PATH="$JAVA_HOME/bin:$PATH"
export PATH
java -version
mvn -version
mvn -q clean packagejava -version 和 mvn -version 都必须显示 Java 21,构建退出码必须为 0。再定义连接和业务参数;密码由终端静默读取,不写入命令参数:
DB_URL='jdbc:mariadb://db.internal.example:3306/appdb?sslMode=verify-full&serverSslCert=/etc/ssl/certs/db-ca.pem&connectTimeout=1000&socketTimeout=2000&useServerPrepStmts=true'
DB_USER=app_user
ORDER_ID=2001
CUSTOMER_ID=3001
SKU_ID=1001
ORDER_AMOUNT=99.90
read -rsp 'DB password: ' DB_PASSWORD
export DB_URL DB_USER DB_PASSWORD ORDER_ID CUSTOMER_ID SKU_ID ORDER_AMOUNT
mvn -q exec:java
unset DB_PASSWORD成功输出类似 order-created id=2001 customer=3001 sku=1001 inventory=9,随后查询应只有一张订单且库存只减一。重复使用相同 ORDER_ID 时应输出 sql-failed state=23000 code=1062 且不重试;库存为零时输出 inventory-insufficient 且不插入订单。错误码 1213、1205 或 SQLState 40001 会输出至多两条 transaction-retry,总尝试不超过三次且受三秒总时限约束。
若输出 commit-outcome-unknown,程序不会重试。lookup=COMMITTED 表示主库重新查询已看到订单,lookup=NOT_FOUND 表示查询成功但未看到该主键,lookup=LOOKUP_FAILED 表示仍无法判断;后两种都先保持业务幂等键不变并人工核对,不能换新订单号重放。
应用运行时同时检查连接池和数据库:
SHOW GLOBAL STATUS WHERE Variable_name IN
('Threads_connected','Threads_running','Aborted_connects','Max_used_connections');
SHOW FULL PROCESSLIST;压测中连接数应稳定,Threads_running 不长期接近 CPU 线程数,Aborted_connects 不持续增加。数据库端正常而应用仍超时时,继续看连接池等待、线程池、DNS、TLS 和网络,不先调大 max_connections。
11. 建立异步复制
以下示例为一主一从:主库地址为 db-primary.internal.example(10.20.0.10),副本为 10.20.0.11。主库证书 SAN 必须包含这个 FQDN;两端版本和关键参数应兼容,server_id 必须唯一,网络只开放给复制节点。
11.1 主库配置
[mariadbd]
server_id=101
log_bin=mariadb-bin
binlog_format=ROW
gtid_strict_mode=ON
log_slave_updates=ON重启并确认:
SHOW VARIABLES WHERE Variable_name IN
('server_id','log_bin','binlog_format','gtid_strict_mode','log_slave_updates');
SHOW MASTER STATUS;server_id=101、binlog 与 strict mode 开启才继续。创建只允许副本网段且要求 TLS 的账号:
CREATE USER 'repl'@'10.20.0.11'
IDENTIFIED BY 'replace-with-replication-secret'
REQUIRE SSL;
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'10.20.0.11';
SHOW GRANTS FOR 'repl'@'10.20.0.11';副本上的 CA 和主库公开证书使用绝对路径。先验证文件权限、证书链和 SAN,正常输出分别为文件信息、OK 和 Hostname ... does match certificate:
PRIMARY_FQDN=db-primary.internal.example
CA_FILE=/etc/mysql/tls/replication-ca.pem
PRIMARY_CERT=/etc/mysql/tls/db-primary-cert.pem
sudo test -r "$CA_FILE"
sudo test -r "$PRIMARY_CERT"
openssl verify -CAfile "$CA_FILE" "$PRIMARY_CERT"
openssl x509 -in "$PRIMARY_CERT" -noout -checkhost "$PRIMARY_FQDN"任一命令非零都停止复制配置:重新分发正确 CA,或重新签发包含 db-primary.internal.example SAN 的主库证书,不能关闭主机名校验绕过。
11.2 取得一致的初始数据
小库可用逻辑备份,大库优先用 MariaDB Backup。物理备份示例:
sudo mariadb-backup --backup \
--target-dir=/backup/replica-seed \
--user=backup --password
sudo mariadb-backup --prepare \
--target-dir=/backup/replica-seed命令成功后检查 xtrabackup_checkpoints 和 xtrabackup_binlog_info。--password 会交互读取,避免凭据出现在命令行。把已准备的副本复制到目标机,在空数据目录恢复;详细恢复步骤见后文。
11.3 副本配置与启动
[mariadbd]
server_id=102
relay_log=relay-bin
read_only=ON
log_bin=mariadb-bin
log_slave_updates=ON
gtid_strict_mode=ON在副本启动一个不写客户端历史的本机管理会话,再输入后续 SQL。复制密码只在该交互会话输入,不放进 shell 参数或脚本:
sudo env MYSQL_HISTFILE=/dev/null mariadbCHANGE MASTER TO
MASTER_HOST='db-primary.internal.example',
MASTER_PORT=3306,
MASTER_USER='repl',
MASTER_PASSWORD='replace-with-replication-secret',
MASTER_USE_GTID=slave_pos,
MASTER_SSL=1,
MASTER_SSL_CA='/etc/mysql/tls/replication-ca.pem',
MASTER_SSL_VERIFY_SERVER_CERT=1;
START SLAVE;
SHOW ALL SLAVES STATUS\G成功标准:Slave_IO_Running=Yes、Slave_SQL_Running=Yes、Master_SSL_Allowed=Yes、Master_SSL_CA_File=/etc/mysql/tls/replication-ca.pem、Master_SSL_Verify_Server_Cert=Yes,并且 Last_IO_Error、Last_SQL_Error 为空。Gtid_IO_Pos 与 @@gtid_slave_pos 应随主库事务持续推进;Seconds_Behind_Master 只能作为参考。
复制启动前还要从副本直接验证登录。下面三组命令都会交互读取密码,密码不会进入 argv。正确 CA 与 FQDN 应连接成功并在 STATUS 中显示 TLS cipher:
PRIMARY_FQDN=db-primary.internal.example
CA_FILE=/etc/mysql/tls/replication-ca.pem
mariadb --host="$PRIMARY_FQDN" --user=repl --password \
--ssl --ssl-ca="$CA_FILE" --ssl-verify-server-cert \
--execute='STATUS'错误 CA 必须非零退出并报告证书验证失败:
PRIMARY_FQDN=db-primary.internal.example
WRONG_CA=/tmp/wrong-ca.pem
WRONG_KEY=/tmp/wrong-ca-key.pem
umask 077
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-subj '/CN=untrusted-test-ca' -keyout "$WRONG_KEY" -out "$WRONG_CA"
mariadb --host="$PRIMARY_FQDN" --user=repl --password \
--ssl --ssl-ca="$WRONG_CA" --ssl-verify-server-cert \
--execute='STATUS'
echo $?
rm -f "$WRONG_CA" "$WRONG_KEY"当主库证书只有 FQDN SAN 时,改用 IP 连接也必须非零退出并报告主机名不匹配:
PRIMARY_IP=10.20.0.10
CA_FILE=/etc/mysql/tls/replication-ca.pem
mariadb --host="$PRIMARY_IP" --user=repl --password \
--ssl --ssl-ca="$CA_FILE" --ssl-verify-server-cert \
--execute='STATUS'
echo $?11.4 验证读写与故障边界
在主库写入 canary:
CREATE TABLE IF NOT EXISTS appdb.replication_probe (
id BIGINT PRIMARY KEY,
created_at DATETIME(6) NOT NULL
);
INSERT INTO appdb.replication_probe VALUES (1001,UTC_TIMESTAMP(6));在副本读取:
SELECT * FROM appdb.replication_probe WHERE id=1001;
SELECT @@gtid_slave_pos;
SHOW ALL SLAVES STATUS\G记录写入前后的 Gtid_IO_Pos 和 @@gtid_slave_pos;二者推进、探针可见且 TLS 字段保持正确,才说明加密复制链工作。然后验证副本上的应用账号不能写。复制不是备份:主库误删会被同步到副本;必须另有离线备份和 binlog 保留。
12. 复制切换与回归
计划切换先停止或围栏旧主写入口,并记录当前主库身份和 GTID。候选副本的 IO、SQL 线程必须正常并追到约定位置,随后停止副本线程,确认没有复制错误和数据缺口。将候选节点设为可写并更新代理或应用地址后,用唯一业务标识完成一次写后读,确认只在新主成功。旧主以副本身份重建后,才能解除读流量限制。
查看位置:
SELECT @@server_id, @@read_only, @@gtid_binlog_pos, @@gtid_slave_pos;
SHOW MASTER STATUS;
SHOW ALL SLAVES STATUS\G提升候选节点前必须证明旧主无法接收写入。旧主状态未知、复制未追平或 GTID 分叉时保持停写并人工判断,不能同时放开两个主库。
计划提升命令:
STOP SLAVE;
RESET SLAVE ALL;
SET GLOBAL read_only=OFF;切换后写入带唯一 ID 的探针,再从真实应用入口读取。若路由仍指向旧节点、写入节点不唯一或结果不一致,立即关闭入口并恢复只读,不继续放量。
故障切换无法保证零数据丢失。候选副本落后时,应按 RPO 决定接受缺口、从其他副本补齐或保持停写。回切不是把 DNS 改回去;旧主必须从新主重建或确认 GTID 连续后才能重新加入。
13. 部署 Galera Cluster
Galera 推荐奇数个投票节点,常见为三个,分布在不同主机或故障域且网络低延迟。所有节点版本、字符集、sql_mode 和 wsrep provider 要一致。
下面固定 MariaDB Server 12.3.3,并在三个 Debian/Ubuntu 节点安装官方 galera-4 provider 和同发行线的 MariaDB Backup。先配置前文 12.3 官方仓库,再检查候选包:
MARIADB_RELEASE=12.3.3
sudo apt update
apt-cache policy mariadb-server mariadb-backup galera-4
SERVER_PKG=$(apt-cache madison mariadb-server | awk -v v="$MARIADB_RELEASE" '$3 ~ v {print $3; exit}')
BACKUP_PKG=$(apt-cache madison mariadb-backup | awk -v v="$MARIADB_RELEASE" '$3 ~ v {print $3; exit}')
GALERA_PKG=$(apt-cache policy galera-4 | awk '/Candidate:/ {print $2}')
test -n "$SERVER_PKG"
test -n "$BACKUP_PKG"
test -n "$GALERA_PKG"任一变量为空都表示仓库没有计划版本,停止安装并核对仓库发行线。候选正确后安装精确包:
MARIADB_RELEASE=12.3.3
SERVER_PKG=$(apt-cache madison mariadb-server | awk -v v="$MARIADB_RELEASE" '$3 ~ v {print $3; exit}')
BACKUP_PKG=$(apt-cache madison mariadb-backup | awk -v v="$MARIADB_RELEASE" '$3 ~ v {print $3; exit}')
GALERA_PKG=$(apt-cache policy galera-4 | awk '/Candidate:/ {print $2}')
sudo apt install -y socat \
"mariadb-server=$SERVER_PKG" \
"mariadb-backup=$BACKUP_PKG" \
"galera-4=$GALERA_PKG"启动前检查包版本、provider 和 SST 工具。正常结果应显示 Server 12.3.3、Galera 4 包版本、provider 文件可读以及 MariaDB Backup 12.3.3:
PROVIDER=/usr/lib/galera/libgalera_smm.so
dpkg-query -W mariadb-server mariadb-backup galera-4 socat
sudo -u mysql test -r "$PROVIDER"
mariadbd --version
mariadb-backup --version
socat -V三台节点都必须显示相同的 Server 12.3.3、Galera 4、MariaDB Backup 12.3.3 和可执行的 socat。任一节点缺包、provider 不可读或版本线不同都必须先修复,不能把 wsrep_provider 指向手工下载的未知库。
为 mariabackup SST 创建节点本地账号:
CREATE USER 'sst'@'localhost'
IDENTIFIED BY 'replace-with-sst-secret';
GRANT RELOAD, PROCESS, LOCK TABLES, BINLOG MONITOR, REPLICA MONITOR
ON *.* TO 'sst'@'localhost';
SHOW GRANTS FOR 'sst'@'localhost';把凭据放在独立的节点本地 include 文件,并限制为 root 和 mysql 组可读:
[mariadbd]
wsrep_sst_auth=sst:replace-with-sst-secretsudo chown root:mysql /etc/mysql/mariadb.conf.d/70-sst-secret.cnf
sudo chmod 640 /etc/mysql/mariadb.conf.d/70-sst-secret.cnfSST 启动前再确认 mysql 用户能读取凭据、mariadb-backup 可执行且账号授权完整:
SST_SECRET=/etc/mysql/mariadb.conf.d/70-sst-secret.cnf
sudo -u mysql test -r "$SST_SECRET"
sudo -u mysql test -x /usr/bin/mariadb-backup
sudo env MYSQL_HISTFILE=/dev/null mariadb \
-e "SHOW GRANTS FOR 'sst'@'localhost'"输出必须包含 RELOAD、PROCESS、LOCK TABLES、BINLOG MONITOR、REPLICA MONITOR;缺一项就只补缺失权限并重新验证。
每个节点配置:
[mariadbd]
binlog_format=ROW
default_storage_engine=InnoDB
innodb_autoinc_lock_mode=2
wsrep_on=ON
wsrep_provider=/usr/lib/galera/libgalera_smm.so
wsrep_cluster_name=orders-galera
wsrep_cluster_address=gcomm://10.20.0.21,10.20.0.22,10.20.0.23
wsrep_node_name=db1
wsrep_node_address=10.20.0.21
wsrep_sst_method=mariabackup每个节点分别修改 wsrep_node_name 和 wsrep_node_address。包安装可能已启动普通 MariaDB;三台节点先停止服务并确认 Galera 与客户端端口没有常驻监听:
sudo systemctl stop mariadb
sudo systemctl is-active mariadb
sudo ss -lntup \
| grep -E ':(3306|4444|4567|4568)\b'is-active 应返回 inactive,最后一条应无输出;仍有监听时先定位进程,不能启动临时探针或 bootstrap。三个节点的 internal zone 只绑定 Galera 内网接口;在每个节点直接放行 4567 TCP/UDP、4568 TCP、4444 TCP 和 3306 TCP:
FIREWALL_ZONE=internal
sudo firewall-cmd --permanent --zone="$FIREWALL_ZONE" --add-port=4567/tcp
sudo firewall-cmd --permanent --zone="$FIREWALL_ZONE" --add-port=4567/udp
sudo firewall-cmd --permanent --zone="$FIREWALL_ZONE" --add-port=4568/tcp
sudo firewall-cmd --permanent --zone="$FIREWALL_ZONE" --add-port=4444/tcp
sudo firewall-cmd --permanent --zone="$FIREWALL_ZONE" --add-port=3306/tcp
sudo firewall-cmd --reload
sudo firewall-cmd --zone="$FIREWALL_ZONE" --list-ports正常输出应包含五条端口规则。此时 Galera 尚未启动,4568 和 4444 没有常驻监听是正常现象,不能把 nc 连接失败误判为网络不通。bootstrap 前用 socat 做一次临时监听;先在接收节点为一个 TCP 端口启动监听:
LOCAL_IP=10.20.0.22
PORT=4568
timeout 15s socat -v TCP-LISTEN:"$PORT",bind="$LOCAL_IP",reuseaddr -再从发送节点连接,接收端应打印 galera-port-probe,两端退出码都应为 0:
PEER=10.20.0.22
PORT=4568
printf 'galera-port-probe\n' \
| timeout 5s socat - TCP:"$PEER":"$PORT"把 PORT 依次改为 4567、4568、4444、3306,并交换两台节点的发送与接收角色;db1↔db2、db1↔db3、db2↔db3 都要成功。UDP 4567 单独在接收节点启动临时监听:
LOCAL_IP=10.20.0.22
PORT=4567
timeout 15s socat -v UDP-RECVFROM:"$PORT",bind="$LOCAL_IP",reuseaddr -发送节点执行一次 UDP 探针并交换方向;接收端必须看到完整字符串:
PEER=10.20.0.22
PORT=4567
printf 'galera-udp-probe\n' \
| timeout 5s socat - UDP:"$PEER":"$PORT"临时监听会在超时后退出,不要求未启动的 Galera 提前监听端口。任一方向收不到字符串时先修路由、防火墙或 internal zone 绑定,不 bootstrap。
只在确认所有旧节点停止、没有仍存活的 Primary Component 时,在 db1 执行一次 bootstrap:
sudo galera_new_cluster
sudo systemctl status mariadb --no-pager其他节点正常启动加入:
sudo systemctl start mariadb检查集群:
SHOW STATUS WHERE Variable_name IN
('wsrep_cluster_status','wsrep_cluster_size','wsrep_local_state_comment',
'wsrep_ready','wsrep_connected','wsrep_local_recv_queue_avg',
'wsrep_flow_control_paused');三节点成功标准:Primary、size 为 3、local state 为 Synced、ready/connected 为 ON。任一节点长时间 Donor、Joining 或 flow control 比例上升,都先解决网络、磁盘或 SST,不继续加流量。
先验证一次真实 IST。在 db3 已为 Synced 时正常停止服务,在 db1 写入一个唯一探针,再启动 db3;停机期间的写集必须仍在 donor 的 gcache 内:
sudo systemctl stop mariadbCREATE TABLE IF NOT EXISTS appdb.galera_transfer_probe(
id BIGINT PRIMARY KEY,
created_at DATETIME(6) NOT NULL
) ENGINE=InnoDB;
INSERT INTO appdb.galera_transfer_probe VALUES (13001,UTC_TIMESTAMP(6));sudo systemctl start mariadb
sudo journalctl -u mariadb -n 400 --no-pager \
| grep -E 'IST|Incremental State Transfer'db3 日志必须明确出现 IST 完成,且 db3 能查到 id=13001;若出现 SST,说明 gcache 未覆盖缺口,这次 IST 演练不通过。再次查询集群状态,确认 size=3、Primary、三节点均 Synced 且 ready/connected=ON。
再强制一次完整 SST,只操作无业务流量的 db3。先确认 db3 已 Synced、其他两节点仍组成 Primary,然后停止 db3,把原 datadir 可恢复地移开并创建空目录:
DATADIR=/var/lib/mysql
OLD_DATADIR=/var/lib/mysql.before-forced-sst
sudo systemctl stop mariadb
sudo systemctl is-active mariadb
sudo test ! -e "$OLD_DATADIR"
sudo mv "$DATADIR" "$OLD_DATADIR"
sudo install -d -o mysql -g mysql -m 750 "$DATADIR"
sudo systemctl start mariadbis-active 必须返回 inactive,旧目录冲突时不得覆盖。空 datadir 会让 db3 无法执行 IST,只能请求完整 SST。检查 joiner 和 donor 日志:
sudo journalctl -u mariadb -n 800 --no-pager \
| grep -E 'SST|State Snapshot Transfer|mariadb-backup'
sudo test -s /var/lib/mysql/mariadb-backup.prepare.log日志必须明确显示 mariabackup SST 开始并完成,不能只有 IST;失败时停止 db3,保留新旧目录和日志定位,不再次 bootstrap。SST 完成后在 db3 查询 id=13001,再从三节点分别查询状态,只有 size=3、Primary、Synced、ready/connected=ON 全部成立才结束演练。原目录保留到业务数据、账号、事件和触发器抽查完成后再按变更流程归档或删除。
Galera 仍需单一写入口或业务冲突控制。跨节点并发修改同一行可能认证失败;长事务和大批量 DML 会放大复制、流控和回滚成本。
14. 用 MaxScale 提供统一入口
MaxScale 是可选代理。下面固定官方 MaxScale 25.10.3,与本文 MariaDB 12.3.3 组合测试;25.10 支持用 service credentials 登录 MariaDB 12,再以 SET SESSION AUTHORIZATION 切换到客户端身份。MaxScale 25.01 及后续版本使用专有商业许可,生产使用前必须取得有效 MariaDB Enterprise 订阅。Debian/Ubuntu 用订阅 token 配置官方 25.10 仓库:
MAXSCALE_SERIES=25.10
REPO_SCRIPT=/tmp/mariadb_es_repo_setup
curl -LsS https://dlm.mariadb.com/enterprise-release-helpers/mariadb_es_repo_setup \
-o "$REPO_SCRIPT"
chmod 0700 "$REPO_SCRIPT"
read -rsp 'MariaDB customer token: ' CUSTOMER_TOKEN
sudo "$REPO_SCRIPT" --token="$CUSTOMER_TOKEN" --apply \
--skip-server --skip-tools \
--mariadb-maxscale-version="$MAXSCALE_SERIES"
unset CUSTOMER_TOKEN
sudo apt update
rm -f "$REPO_SCRIPT"仓库脚本和 apt update 都必须返回 0。再选择并安装精确 25.10.3 包;找不到候选时停止,不改用浮动 latest:
MAXSCALE_RELEASE=25.10.3
MAXSCALE_PKG=$(apt-cache madison maxscale | awk -v v="$MAXSCALE_RELEASE" '$3 ~ v {print $3; exit}')
test -n "$MAXSCALE_PKG"
sudo apt install -y "maxscale=$MAXSCALE_PKG"
maxscale --version
maxctrl --version两个版本命令都必须显示 25.10.3。主配置固定为 /etc/maxscale.cnf,TLS 文件放 /etc/maxscale/tls/ 并由 root:maxscale 持有、目录权限 750、私钥权限 640。
先创建两个用途分离的账号。这个示例只做监控和路由,不开启自动 failover;自动切换所需额外管理权限必须按精确 MaxScale 版本单独评估。
CREATE USER 'maxscale_monitor'@'10.20.0.30'
IDENTIFIED BY 'replace-with-monitor-secret' REQUIRE SSL;
GRANT REPLICA MONITOR ON *.*
TO 'maxscale_monitor'@'10.20.0.30';
CREATE USER 'maxscale_router'@'10.20.0.30'
IDENTIFIED BY 'replace-with-router-secret' REQUIRE SSL;
GRANT SELECT ON mysql.global_priv
TO 'maxscale_router'@'10.20.0.30';
GRANT SELECT ON mysql.user
TO 'maxscale_router'@'10.20.0.30';
GRANT SELECT ON mysql.db
TO 'maxscale_router'@'10.20.0.30';
GRANT SELECT ON mysql.tables_priv
TO 'maxscale_router'@'10.20.0.30';
GRANT SELECT ON mysql.columns_priv
TO 'maxscale_router'@'10.20.0.30';
GRANT SELECT ON mysql.procs_priv
TO 'maxscale_router'@'10.20.0.30';
GRANT SELECT ON mysql.proxies_priv
TO 'maxscale_router'@'10.20.0.30';
GRANT SELECT ON mysql.roles_mapping
TO 'maxscale_router'@'10.20.0.30';
GRANT SHOW DATABASES ON *.*
TO 'maxscale_router'@'10.20.0.30';
GRANT SET USER ON *.*
TO 'maxscale_router'@'10.20.0.30';MariaDB 12 的 UAM 需要读取 mysql.global_priv 并以 SET USER 校验被代理用户;SHOW GRANTS 应只出现这些读取/监控权限。来源不是 MaxScale 主机或没有 TLS 的登录必须失败,不授予全局 ALL。
[db1]
type=server
address=db-primary.internal.example
port=3306
protocol=MariaDBBackend
ssl=true
ssl_ca=/etc/maxscale/tls/backend-ca.pem
ssl_cert=/etc/maxscale/tls/backend-client-cert.pem
ssl_key=/etc/maxscale/tls/backend-client-key.pem
ssl_verify_peer_certificate=true
ssl_verify_peer_host=true
use_service_credentials=true
[db2]
type=server
address=db-replica.internal.example
port=3306
protocol=MariaDBBackend
ssl=true
ssl_ca=/etc/maxscale/tls/backend-ca.pem
ssl_cert=/etc/maxscale/tls/backend-client-cert.pem
ssl_key=/etc/maxscale/tls/backend-client-key.pem
ssl_verify_peer_certificate=true
ssl_verify_peer_host=true
use_service_credentials=true
[MariaDB-Monitor]
type=monitor
module=mariadbmon
servers=db1,db2
user=maxscale_monitor
password=ENCRYPTED_MONITOR_PASSWORD
monitor_interval=2s
[RW-Service]
type=service
router=readwritesplit
servers=db1,db2
user=maxscale_router
password=ENCRYPTED_ROUTER_PASSWORD
causal_reads=local
[RW-Listener]
type=listener
service=RW-Service
protocol=MariaDBClient
port=4006
ssl=true
ssl_ca=/etc/maxscale/tls/client-ca.pem
ssl_cert=/etc/maxscale/tls/listener-cert.pem
ssl_key=/etc/maxscale/tls/listener-key.pem
ssl_verify_peer_certificate=true后端证书 SAN 分别包含两个 server FQDN;listener 证书 SAN 包含 maxscale.db.internal.example,客户端必须使用这个 FQDN 做主机校验。use_service_credentials 是 server 参数,两台后端都显式开启;不能放进 [RW-Service]。先创建或复用 MaxScale 密钥,再把两个账号密码转换为配置中的十六进制密文:
KEY_DIR=/var/lib/maxscale
sudo -u maxscale test -r "$KEY_DIR/.secrets" \
|| sudo -u maxscale maxkeys "$KEY_DIR"
read -rsp 'Monitor password: ' MONITOR_SECRET
MONITOR_ENCRYPTED=$(sudo -u maxscale maxpasswd "$KEY_DIR" "$MONITOR_SECRET")
read -rsp 'Router password: ' ROUTER_SECRET
ROUTER_ENCRYPTED=$(sudo -u maxscale maxpasswd "$KEY_DIR" "$ROUTER_SECRET")
printf 'monitor=%s\nrouter=%s\n' "$MONITOR_ENCRYPTED" "$ROUTER_ENCRYPTED"
unset MONITOR_SECRET ROUTER_SECRET MONITOR_ENCRYPTED ROUTER_ENCRYPTED把两行输出分别填入配置的 ENCRYPTED_MONITOR_PASSWORD 和 ENCRYPTED_ROUTER_PASSWORD;.secrets 必须仍由 maxscale 用户独占读取,重新生成它会让既有密文失效。明文只在受控管理终端短暂存在,不写入配置或 shell 历史。
启动前验证证书链、后端 SAN、配置文件和服务身份:
BACKEND_CA=/etc/maxscale/tls/backend-ca.pem
DB1_CERT=/etc/maxscale/tls/db-primary-cert.pem
DB1_HOST=db-primary.internal.example
sudo -u maxscale test -r "$BACKEND_CA"
sudo -u maxscale test -r /etc/maxscale/tls/backend-client-key.pem
openssl verify -CAfile "$BACKEND_CA" "$DB1_CERT"
openssl x509 -in "$DB1_CERT" -noout -checkhost "$DB1_HOST"
sudo maxscale --config=/etc/maxscale.cnf --config-check正确链和 SAN 应返回 OK 与 host match,config-check 退出码为 0。用错误主机名检查必须非零,证明不能把任意同 CA 证书当作 db1:
DB1_CERT=/etc/maxscale/tls/db-primary-cert.pem
WRONG_HOST=wrong-db.internal.example
openssl x509 -in "$DB1_CERT" -noout -checkhost "$WRONG_HOST"
echo $?验证通过后启动并查看状态:
sudo systemctl enable --now maxscale
sudo systemctl restart maxscale
maxctrl list servers
maxctrl list services
maxctrl list listeners成功标准:只有 db1 显示 Master,db2 显示 Slave,service 为 Started,TLS listener 监听 4006。正确 listener CA、证书 SAN 和客户端证书应连接成功:
MAXSCALE_HOST=maxscale.db.internal.example
CLIENT_CA=/etc/app/tls/maxscale-ca.pem
CLIENT_CERT=/etc/app/tls/app-cert.pem
CLIENT_KEY=/etc/app/tls/app-key.pem
mariadb --host="$MAXSCALE_HOST" --port=4006 --user=app_user --password \
--ssl --ssl-ca="$CLIENT_CA" --ssl-cert="$CLIENT_CERT" \
--ssl-key="$CLIENT_KEY" --ssl-verify-server-cert \
--execute='SELECT @@server_id,@@read_only'错误 CA 必须非零退出;密码仍由客户端交互读取:
MAXSCALE_HOST=maxscale.db.internal.example
WRONG_CA=/tmp/wrong-ca.pem
WRONG_KEY=/tmp/wrong-ca-key.pem
CLIENT_CERT=/etc/app/tls/app-cert.pem
CLIENT_KEY=/etc/app/tls/app-key.pem
umask 077
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-subj '/CN=untrusted-test-ca' -keyout "$WRONG_KEY" -out "$WRONG_CA"
mariadb --host="$MAXSCALE_HOST" --port=4006 --user=app_user --password \
--ssl --ssl-ca="$WRONG_CA" --ssl-cert="$CLIENT_CERT" \
--ssl-key="$CLIENT_KEY" --ssl-verify-server-cert \
--execute='SELECT 1'
echo $?
rm -f "$WRONG_CA" "$WRONG_KEY"正确 CA 下直接改用 IP 连接必须因 listener 证书 SAN 不匹配而非零退出:
MAXSCALE_IP=10.20.0.30
CLIENT_CA=/etc/app/tls/maxscale-ca.pem
CLIENT_CERT=/etc/app/tls/app-cert.pem
CLIENT_KEY=/etc/app/tls/app-key.pem
mariadb --host="$MAXSCALE_IP" --port=4006 --user=app_user --password \
--ssl --ssl-ca="$CLIENT_CA" --ssl-cert="$CLIENT_CERT" \
--ssl-key="$CLIENT_KEY" --ssl-verify-server-cert \
--execute='SELECT 1'
echo $?先在主库创建探针表并保存写入节点身份:
SELECT @@hostname, @@server_id, @@read_only;
CREATE TABLE appdb.route_probe(
id BIGINT PRIMARY KEY,
writer_server_id BIGINT NOT NULL
);通过 MaxScale 在同一个客户端会话执行写后读。causal_reads=local 只保证当前会话自己的写后读,不能承诺其他会话立即可见:
INSERT INTO appdb.route_probe VALUES (1001,@@server_id);
SELECT id,writer_server_id,@@server_id AS reader_server_id
FROM appdb.route_probe WHERE id=1001;writer_server_id 必须等于 db1 的 server_id,SELECT 必须立即返回该行。再直连 db2,用普通应用账号执行一条探针写入,必须因 read_only=ON 失败;最后在主库清理:
DROP TABLE appdb.route_probe;若 MaxScale 显示多个 Master、db2 可以写、TLS 反例成功或当前会话读不到刚写行,立即停止 listener 并修正后端角色、证书或 causal reads,不接入应用。故障演练前先阻止新连接并排空活动会话,再停服务、切换后端、验证路由,最后恢复入口。
15. 扩容和缩容
15.1 增加异步副本
增加副本前先检查主库 CPU、磁盘、网络和 binlog 保留能覆盖建副本时间。用已验证的物理备份初始化新节点,配置唯一 server_id、TLS 和复制账号,然后启动复制并等待追平。探针和真实只读 SQL 都通过后,才逐步加入读流量。
SHOW ALL SLAVES STATUS\G
SELECT @@server_id, @@read_only, @@gtid_slave_pos;新副本反复全量同步、SQL 线程报错或延迟持续扩大时,不加入流量。
15.2 下线副本
先从代理和应用发现列表移除,等待连接归零:
SHOW GLOBAL STATUS LIKE 'Threads_connected';
SHOW FULL PROCESSLIST;确认没有报表、备份、CDC 或灾备依赖后停止复制并保留最后 GTID:
STOP SLAVE;
SHOW ALL SLAVES STATUS\G先停止服务,再归档配置和数据;不要在连接仍存在时直接删除主机。
15.3 Galera 扩缩容
一次只加入或移除一个节点,等待 wsrep_cluster_size 和所有节点 Synced 后再继续。SST 会消耗 donor 的磁盘与网络;数据量大时选择低峰并监控 flow control。缩容必须保证剩余节点仍有多数派,不能同时停止两台三节点集群。
16. 备份与恢复
备份必须先定义目标:RPO 决定最多允许丢多少数据,RTO 决定多久恢复服务。常见组合是定期物理全备、持续保留 binlog、每日逻辑 schema 备份和异机或对象存储副本。
16.1 建立备份账号
备份账号与恢复账号分离。生产备份账号只执行读取和 MariaDB Backup 所需操作,不授予全局 ALL;恢复在隔离实例使用本机管理身份完成。
CREATE USER 'backup'@'localhost'
IDENTIFIED BY 'replace-with-backup-secret';
GRANT RELOAD, PROCESS, LOCK TABLES, BINLOG MONITOR
ON *.* TO 'backup'@'localhost';
SHOW GRANTS FOR 'backup'@'localhost';不同 MariaDB Backup 版本所需权限可能变化,按该版本官方文档和实际命令补最小权限。操作系统 mysql 或专用备份进程还需读取数据、redo、undo 和加密密钥路径;先用测试备份验证,不把数据库超级权限当文件权限替代品。
16.2 逻辑备份
小到中型 InnoDB 数据库可用一致性逻辑导出:
umask 077
mkdir -p /backup/logical/T0
mariadb-dump --single-transaction --quick \
--routines --events --triggers --hex-blob \
--databases appdb \
> /backup/logical/T0/appdb.sql
sha256sum /backup/logical/T0/appdb.sql \
> /backup/logical/T0/appdb.sql.sha256--single-transaction 只为事务表提供一致快照;MyISAM、Aria 非事务表或导出期间的 DDL 可能破坏一致性。命令非零、输出为空、日志出现对象权限错误都算失败。
检查文件:
sha256sum -c /backup/logical/T0/appdb.sql.sha256
grep -nE 'CREATE DATABASE|CREATE TABLE' \
/backup/logical/T0/appdb.sql | head16.3 物理备份
MariaDB Backup 必须与 Server 发行线兼容:
umask 077
sudo install -d -o mysql -g mysql -m 750 /backup/full/T0
sudo mariadb-backup --backup \
--target-dir=/backup/full/T0 \
--user=backup --password输入密码后,预期结束信息表明备份完成且退出码为 0。检查关键文件和容量:
sudo test -s /backup/full/T0/xtrabackup_checkpoints
sudo test -s /backup/full/T0/xtrabackup_binlog_info
sudo du -sh /backup/full/T0准备备份会重放 redo,使其可恢复:
sudo mariadb-backup --prepare \
--target-dir=/backup/full/T0--prepare 失败时保留原目录和日志,不在唯一副本上反复覆盖。备份目录再做校验、加密并复制到另一故障域。
16.4 在隔离实例恢复
恢复实例固定使用独立 datadir、socket、端口、pid、日志和 server_id,只监听回环并保持只读。它不加入生产 DNS、MaxScale、监控发现或复制拓扑。将以下内容保存为 /etc/mysql/mariadb-restore.cnf:
[mariadbd]
datadir=/srv/mariadb-restore/data
socket=/run/mariadb-restore/mariadb.sock
port=13306
bind-address=127.0.0.1
pid-file=/run/mariadb-restore/mariadb.pid
log-error=/var/log/mysql/mariadb-restore.log
server_id=9901
read_only=ON
skip-slave-start
wsrep_on=OFF
skip-name-resolve=ON使用独立 unit /etc/systemd/system/mariadb-restore.service,不要复用生产 mariadb.service:
[Unit]
Description=Isolated MariaDB restore instance
After=network.target
[Service]
Type=notify
User=mysql
Group=mysql
RuntimeDirectory=mariadb-restore
RuntimeDirectoryMode=0750
ExecStart=/usr/sbin/mariadbd --defaults-file=/etc/mysql/mariadb-restore.cnf
PIDFile=/run/mariadb-restore/mariadb.pid
Restart=no
TimeoutStartSec=300
TimeoutStopSec=60
[Install]
WantedBy=multi-user.target先创建目录、检查属主,并直接确认 datadir 为空。find 正常时不输出任何路径;出现任意条目都停止,不删除未知文件:
RESTORE_DATADIR=/srv/mariadb-restore/data
sudo install -d -o mysql -g mysql -m 750 "$RESTORE_DATADIR"
sudo install -d -o mysql -g mysql -m 750 /var/log/mysql
sudo find "$RESTORE_DATADIR" -mindepth 1 -maxdepth 1 -print
sudo test -z "$(sudo find "$RESTORE_DATADIR" -mindepth 1 -maxdepth 1 -print -quit)"
sudo stat -c '%U:%G %a %n' "$RESTORE_DATADIR"让 systemd 重新读取 unit,并确认 ExecStart 绑定隔离配置:
RESTORE_UNIT=mariadb-restore.service
sudo systemctl daemon-reload
sudo systemctl cat "$RESTORE_UNIT"
sudo systemctl show "$RESTORE_UNIT" -p ExecStart -p User -p Group
my_print_defaults \
--defaults-file=/etc/mysql/mariadb-restore.cnf mariadbd输出必须包含 --defaults-file=/etc/mysql/mariadb-restore.cnf、User=mysql、Group=mysql、--skip-slave-start 和 --wsrep_on=OFF。--defaults-file 必须是 mariadbd 的第一个参数,使进程只读取隔离配置;输出中出现生产 wsrep_cluster_address、复制源地址或生产 datadir 时修正 unit 和配置,不执行 copy-back。
恢复会覆盖目标数据目录,只能使用已确认的空目录。先 prepare 备份并检查退出码,再停止隔离 unit:
RESTORE_BACKUP=/backup/full/T0
sudo mariadb-backup --prepare --target-dir="$RESTORE_BACKUP"
echo $?
sudo systemctl stop mariadb-restore.serviceprepare 必须返回 0。copy-back 后修正属主:
RESTORE_BACKUP=/backup/full/T0
RESTORE_DATADIR=/srv/mariadb-restore/data
sudo mariadb-backup --copy-back \
--target-dir="$RESTORE_BACKUP" \
--datadir="$RESTORE_DATADIR"
sudo chown -R mysql:mysql "$RESTORE_DATADIR"
sudo find "$RESTORE_DATADIR" -maxdepth 1 -type f -printf '%u:%g %m %f\n' \
| headcopy-back 必须返回 0,抽查文件应为 mysql:mysql。启动隔离实例并确认所有身份字段:
RESTORE_SOCKET=/run/mariadb-restore/mariadb.sock
sudo systemctl start mariadb-restore.service
sudo journalctl -u mariadb-restore.service -n 100 --no-pager
mariadb --socket="$RESTORE_SOCKET" -Nse \
"SELECT VERSION(),@@datadir,@@socket,@@port,@@server_id,@@read_only"正常结果必须是计划版本、/srv/mariadb-restore/data/、隔离 socket、端口 13306、server_id=9901、read_only=1。任一字段不符就停止 unit;恢复实例不得开放生产入口。
在读取任何业务数据前,先确认恢复出来的复制元数据没有启动线程:
SHOW ALL SLAVES STATUS\G
SHOW GLOBAL STATUS
WHERE Variable_name IN ('Slave_running','Slaves_running');
SELECT THREAD_ID,NAME,PROCESSLIST_ID,PROCESSLIST_STATE
FROM performance_schema.threads
WHERE NAME LIKE 'thread/sql/slave%';
SELECT ID,USER,HOST,COMMAND,STATE
FROM information_schema.PROCESSLIST
WHERE USER='system user' OR COMMAND='Binlog Dump';没有 channel 时第一条为空;有旧 channel 时每个 channel 的 Slave_IO_Running 和 Slave_SQL_Running 都必须为 No。Slaves_running=0,默认 channel 不存在时 Slave_running 可以为空,否则必须为 OFF;线程与进程查询都应为空。任一复制线程存在就立即停止隔离 unit,不能开始数据验证。
再确认只监听回环,并且该进程没有连接生产主库:
RESTORE_PID=$(systemctl show mariadb-restore.service -p MainPID --value)
test "$RESTORE_PID" -gt 1
sudo ss -lntp '( sport = :13306 )'
if sudo ss -ntp | grep "pid=$RESTORE_PID," \
| grep -E '10\.20\.0\.(10|11|21|22|23):(3306|4444|4567|4568)'; then
exit 1
fi监听结果只能是 127.0.0.1:13306,条件块必须不输出连接并返回 0。随后连续读取两次恢复基线,未执行 PITR 时 GTID、行数、最新更新时间和摘要必须完全相同:
SELECT @@gtid_current_pos;
SELECT COUNT(*) AS rows_count,MAX(updated_at) AS newest,
BIT_XOR(CRC32(CONCAT_WS('#',id,status,total_amount))) AS digest
FROM appdb.orders;
DO SLEEP(2);
SELECT @@gtid_current_pos;
SELECT COUNT(*) AS rows_count,MAX(updated_at) AS newest,
BIT_XOR(CRC32(CONCAT_WS('#',id,status,total_amount))) AS digest
FROM appdb.orders;任一值自行推进都说明仍有写入或复制活动,必须停止 unit 定位。基线稳定后,才验证库表数、关键表行数、业务关系、事件、触发器、视图、账号权限、字符集和应用只读查询。
逻辑恢复示例:
mariadb --socket=/run/mariadb-restore/mariadb.sock \
< /backup/logical/T0/appdb.sql
mariadb --socket=/run/mariadb-restore/mariadb.sock \
-e "CHECK TABLE appdb.users,appdb.orders"16.5 时间点恢复
先恢复最后一次已验证全备,再重放备份结束位置之后、误操作之前的 binlog。查看事件边界:
mariadb-binlog --base64-output=DECODE-ROWS -vv \
/archive/binlog/mariadb-bin.000123 | less确认停止点后在隔离实例重放:
set -o pipefail
RESTORE_SOCKET=/run/mariadb-restore/mariadb.sock
BINLOG_FILE=/archive/binlog/mariadb-bin.000123
LAST_INCLUDED_ORDER=2001
FIRST_EXCLUDED_ORDER=2002
mariadb-binlog \
--start-position=154 \
--stop-position=982734 \
"$BINLOG_FILE" \
| mariadb --socket="$RESTORE_SOCKET"
echo $?LAST_INCLUDED_ORDER 和 FIRST_EXCLUDED_ORDER 必须先从 -vv 解码结果中对应到停止位置两侧的真实业务事务。pipeline 只有在 binlog 解析和 MariaDB 导入都成功时才返回 0;非零时保留日志并停止重放。成功后立即验证业务边界、GTID 和复制线程:
RESTORE_SOCKET=/run/mariadb-restore/mariadb.sock
LAST_INCLUDED_ORDER=2001
FIRST_EXCLUDED_ORDER=2002
mariadb --socket="$RESTORE_SOCKET" -Nse \
"SELECT SUM(id=$LAST_INCLUDED_ORDER),SUM(id=$FIRST_EXCLUDED_ORDER) FROM appdb.orders"
mariadb --socket="$RESTORE_SOCKET" -Nse "SELECT @@gtid_current_pos"
mariadb --socket="$RESTORE_SOCKET" -e 'SHOW ALL SLAVES STATUS\G'
mariadb --socket="$RESTORE_SOCKET" -Nse \
"SHOW GLOBAL STATUS WHERE Variable_name IN ('Slave_running','Slaves_running')"边界查询必须返回 1 0,即最后应纳入的订单存在、下一笔订单不存在;所有 channel 的 IO/SQL 线程仍须为 No,Slaves_running 仍为 0。再用前面的 performance_schema、PROCESSLIST 和 ss 命令确认复制线程为空、没有生产 3306 连接。任一条件不满足就停止恢复 unit,不把结果用于生产。确认解析器确实拒绝截断文件:
BINLOG_FILE=/archive/binlog/mariadb-bin.000123
TRUNCATED_BINLOG=/tmp/mariadb-bin.truncated
cp "$BINLOG_FILE" "$TRUNCATED_BINLOG"
truncate -s -1 "$TRUNCATED_BINLOG"
mariadb-binlog "$TRUNCATED_BINLOG" >/dev/null
echo $?
rm -f "$TRUNCATED_BINLOG"截断文件的解析退出码必须非零;返回 0 表示负向测试无效,不能继续 PITR。时间受数据库会话和操作系统时区影响,位置边界更精确。重放后用业务时间、行数和唯一标识确认误操作尚未发生,不直接向生产主库试错式重放。
17. 监控与告警
17.1 服务和连接
SHOW GLOBAL STATUS WHERE Variable_name IN
('Uptime','Threads_connected','Threads_running','Max_used_connections',
'Aborted_connects','Connections');告警看趋势和业务影响:连接占用持续超过安全线、Threads_running 长期升高、拒绝连接增加、应用获取连接超时都需要联动判断。
17.2 InnoDB 和事务
SHOW ENGINE INNODB STATUS\G
SELECT trx_id,trx_mysql_thread_id,trx_started,trx_state,trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;重点监控死锁、锁等待、history list、脏页、redo 等待、buffer pool 物理读和长事务。performance_schema.threads.PROCESSLIST_TIME 表示线程处于当前状态的持续秒数,状态切换会重置,不能当连接建立时间使用。
17.3 查询和临时表
SHOW GLOBAL STATUS WHERE Variable_name IN
('Questions','Slow_queries','Created_tmp_tables',
'Created_tmp_disk_tables','Sort_merge_passes');慢日志应按总耗时、P95、调用次数和扫描行数聚合。单条偶发慢 SQL 与每秒执行上千次的小慢 SQL,治理优先级不同。
17.4 复制和 Galera
SHOW ALL SLAVES STATUS\G
SHOW STATUS LIKE 'wsrep_%';复制告警覆盖 IO/SQL 线程停止、GTID 不推进、延迟和 relay 增长;Galera 覆盖非 Primary、节点非 Synced、ready 关闭、接收队列增长和 flow control。
17.5 主机和磁盘
pidstat -p "$(pidof mariadbd)" 1 5
iostat -x 1 5
vmstat 1 5
df -hT数据库指标要与 CPU、run queue、IO 延迟、fsync、磁盘容量、网络丢包和应用 P99 同时看。只监控进程存活会漏掉“进程活着但无法提交”的故障。
18. 性能与容量
18.1 先建立工作负载基线
记录峰值和日常值:QPS、事务比例、P50/P95/P99、活跃连接、读写字节、buffer pool 工作集、redo/binlog 生成速率、临时表、备份耗时、复制延迟和三个月增长率。
SHOW GLOBAL STATUS;
SHOW GLOBAL VARIABLES;相隔固定时间采两次,用差值计算速率;状态变量自启动累计,不能直接把总数当每秒值。
18.2 调优顺序
调优先找到业务慢接口和对应 SQL,再用 EXPLAIN FORMAT=JSON 或可控的 ANALYZE FORMAT=JSON 确认访问路径。先减少返回列和扫描行,修正谓词、分页、N+1 和事务范围,再调整索引并回归写入、存储和 DDL 成本。最后检查连接池、内存、临时表、redo、磁盘和复制,用相同负载对比 P99、吞吐和资源,不能只看平均耗时。
更新统计:
ANALYZE TABLE appdb.orders;
SHOW INDEX FROM appdb.orders;不要在高峰对大量大表同时执行维护命令;先在副本或测试环境测量耗时和 IO。
18.3 内存估算
总内存需求 ≈ InnoDB Buffer Pool
+ 连接线程与每连接缓冲峰值
+ 临时表、排序和 join 缓冲
+ Performance Schema、Galera/复制缓存
+ 操作系统与备份/DDL 余量max_connections × 所有 per-thread buffer 最大值 是风险上界,不是日常实际值。连接池限额、语句并发和查询模型比单独调大 max_connections 更重要。
18.4 磁盘和保留期
容量至少包含数据与索引、临时空间、redo/undo、binlog 保留、relay log、在线 DDL 临时副本、备份和增长余量。
SELECT table_schema,
ROUND(SUM(data_length+index_length)/1024/1024/1024,2) AS gib
FROM information_schema.tables
GROUP BY table_schema
ORDER BY gib DESC;du -sh /var/lib/mysql
find /var/lib/mysql -maxdepth 1 -name 'mariadb-bin.*' -printf '%s %p\n' \
| sort -n | tail磁盘告警应早于写满,给 purge、备份、DDL 和人工处置留出时间。binlog 只通过配置的过期策略和 PURGE BINARY LOGS 管理,不直接删除文件。
19. 升级、迁移与回退
19.1 同产品升级
下面固定一条 Debian/Ubuntu 主路径:把仍维护的 MariaDB 11.8 LTS 副本升级到 12.3.3。先阅读 12.3 的升级说明,检查废弃变量、认证、插件、Galera 和 Connector/J;不要跳过副本先行。候选机必须仍是 11.8、复制正常,并且已有一份在 11.8 隔离实例恢复成功的备份。
SOURCE_SERIES='11.8'
TARGET_RELEASE='12.3.3'
mariadb --version
sudo mariadb -Nse "SELECT VERSION(),@@server_id,@@read_only"
sudo mariadb -e "SHOW ALL SLAVES STATUS\G"正常输出应以 11.8. 开头、read_only=1,复制 IO/SQL 线程均运行且延迟已收敛;任一条件不满足就不升级。先把候选副本移出 MaxScale 读流量并排空连接:
SERVER_NAME='db3'
sudo maxctrl set server "$SERVER_NAME" maintenance
sudo maxctrl show server "$SERVER_NAME"
sudo maxctrl list sessions
sudo mariadb -e "SHOW FULL PROCESSLIST"State 应包含 Maintenance,候选机的进程列表中不再有业务账号会话;仍有会话时先让应用连接过期或主动下线,不得直接停止数据库。随后保存升级前状态并停止服务:
STATE_DIR='/var/lib/mariadb-upgrade/11.8-to-12.3.3'
sudo install -d -m 0700 "$STATE_DIR"
sudo mariadb -Nse "SHOW VARIABLES" > /tmp/before-upgrade-variables.tsv
sudo mariadb -Nse "SHOW GLOBAL STATUS" > /tmp/before-upgrade-status.tsv
sudo install -m 0600 /tmp/before-upgrade-*.tsv "$STATE_DIR"/
sudo rm -f /tmp/before-upgrade-*.tsv
sudo systemctl stop mariadb
sudo systemctl is-active mariadb最后一条预期返回 inactive;不是该状态就停止。配置官方 12.3 仓库并只安装候选值中精确对应 12.3.3 的包:
REPO_SCRIPT='/tmp/mariadb_repo_setup'
curl -LsS https://r.mariadb.com/downloads/mariadb_repo_setup -o "$REPO_SCRIPT"
chmod 0700 "$REPO_SCRIPT"
sudo "$REPO_SCRIPT" --mariadb-server-version='mariadb-12.3' --skip-maxscale
sudo apt-get updateTARGET_RELEASE='12.3.3'
SERVER_PKG=$(apt-cache madison mariadb-server | awk -v v="$TARGET_RELEASE" '$3 ~ v {print $3; exit}')
CLIENT_PKG=$(apt-cache madison mariadb-client | awk -v v="$TARGET_RELEASE" '$3 ~ v {print $3; exit}')
BACKUP_PKG=$(apt-cache madison mariadb-backup | awk -v v="$TARGET_RELEASE" '$3 ~ v {print $3; exit}')
printf '%s\n' "$SERVER_PKG" "$CLIENT_PKG" "$BACKUP_PKG"
test -n "$SERVER_PKG" && test -n "$CLIENT_PKG" && test -n "$BACKUP_PKG"
sudo apt-get install -y "mariadb-server=$SERVER_PKG" \
"mariadb-client=$CLIENT_PKG" "mariadb-backup=$BACKUP_PKG"三个候选值都必须显示 12.3.3 对应的发行包版本(包字符串可能带 epoch 和发行版后缀);空值、重复仓库导致的非目标候选或安装非零都要保持该节点离线。安装完成后启动并升级系统表:
TARGET_RELEASE='12.3.3'
sudo systemctl start mariadb
sudo systemctl is-active mariadb
sudo mariadb-upgrade
mariadb --version预期服务为 active、客户端和服务端均为 12.3.3,mariadb-upgrade 返回 0;启动或升级失败时立即停止服务,不再升级其他节点。检查系统表、复制和 Java 查询:
APP_DIR='/opt/mariadb-java-demo'
DB_URL='jdbc:mariadb://db3.internal.example:3306/appdb?sslMode=verify-full&serverSslCert=/etc/ssl/certs/db-ca.pem&connectTimeout=1000&socketTimeout=2000'
DB_USER=app_user
read -rsp 'DB password: ' DB_PASSWORD
export DB_URL DB_USER DB_PASSWORD
sudo mariadb-check --all-databases --check-upgrade
sudo mariadb -Nse "SELECT VERSION(),@@server_id,@@read_only"
sudo mariadb -e "SHOW ALL SLAVES STATUS\G"
cd "$APP_DIR"
mvn -q exec:java -Dexec.args=verify
unset DB_PASSWORD正常输出应无表错误,版本为 12.3.3,候选仍只读,复制线程运行且 GTID 继续推进,Java 输出以 database-ok version=12.3.3 开头。再按 16.3 创建一份 12.3.3 物理备份,并按 16.4 在隔离实例完成 prepare、copy-back、启动、身份和业务数据查询;恢复未通过前不解除 Maintenance,也不升级下一节点。
若候选失败,保持它离线,业务继续使用未升级的 11.8 节点;清空候选数据目录后,从已验证的 11.8 备份重建或重新配置副本。禁止在已经由 12.3.3 启动或写入过的数据目录上直接换回 11.8 二进制。只有候选的复制、Java 查询和新备份恢复全部通过,才按同样流程逐节点推进。
19.2 MySQL 迁到 MariaDB
先在只读副本或测试快照导出对象清单:
mysqldump --no-data --routines --events --triggers \
--databases appdb > mysql-schema.sql
mysqldump --single-transaction --quick --hex-blob \
--no-create-info --databases appdb > mysql-data.sql审查并转换不兼容项:MySQL 专属 collation、binary JSON 行为、生成列、函数、分区、sql_mode、DEFINER、事件、认证插件和 GTID。把转换后的 schema 作为单独文件评审,不边导入边临时替换。
在隔离 MariaDB 导入:
mariadb < approved-mariadb-schema.sql
mariadb < mysql-data.sql导入后比较对象、行数、关键聚合、抽样哈希、字符排序、JSON 查询、时间和金额,再回放真实 SQL:
SELECT COUNT(*) FROM appdb.orders;
CHECK TABLE appdb.orders;
SHOW CREATE TABLE appdb.orders\G
EXPLAIN FORMAT=JSON SELECT * FROM appdb.orders WHERE id=1001;MySQL GTID 与 MariaDB GTID 不是同一体系。需要低停机复制时,只采用官方明确支持的精确版本与方向,并单独验证 DDL、ROW 事件、字符集和故障恢复;否则使用停写、最终增量导出、校验和切换。
19.3 MariaDB 迁到 MySQL
反向迁移同样不是把客户端命令名换掉。先导出 MariaDB schema,转换 MariaDB 专属语法、引擎、sequence、动态列、JSON、collation、DEFINER、角色和事件,再导入隔离 MySQL:
mariadb-dump --no-data --routines --events --triggers \
--databases appdb > mariadb-schema.sql
mariadb-dump --single-transaction --quick --hex-blob \
--no-create-info --databases appdb > mariadb-data.sql
mysql < approved-mysql-schema.sql
mysql < mariadb-data.sql源端与目标端分别和批准的目标形态比较,不要求两个产品的原始 DDL 字节相同。验证 MySQL 驱动、认证、时区、JSON、排序、执行计划和备份工具后才允许写入。
19.4 切换和回退边界
切换前完成源端备份、目标端恢复点、最终数据对账、应用只读演练、连接串回退、旧主隔离和负责人确认。执行时先停止源端写入并等待在途事务结束,再应用最终增量并再次对账。目标端保持只读,先做真实入口读验证;随后切换连接、放开目标写入,用唯一业务 ID 做写后读,并持续观察错误率、P99、锁、复制和业务数量。
目标首次写入前,可以在确认源端仍完整且连接配置可恢复时回到源端。目标已经产生写入后,直接把流量切回旧源会丢失新写;必须保持入口关闭,先建立受控反向同步、导出增量或按业务补偿。没有可验证的回传路径,就不宣称存在无损回退窗口。
20. 清理实验环境
先确认没有应用、备份或复制依赖,再删除实验对象:
DROP DATABASE IF EXISTS appdb;
DROP USER IF EXISTS 'app_user'@'10.20.10.%';
DROP USER IF EXISTS 'repl'@'10.20.0.11';
DROP USER IF EXISTS 'backup'@'localhost';容器实验环境先停容器,再明确选择是否保留数据:
docker stop --time 30 mariadb-lab
docker rm mariadb-lab需要保留数据时归档 mariadb-lab/data 和配置;确认是可丢弃实验数据后再删除目录。生产数据目录、备份和 binlog 不参与这组清理命令。
系统包卸载不等于删除数据。只有完成备份、依赖确认和数据归属确认后,才按发行版包管理器卸载;不要使用递归删除命令处理未知数据目录。
四、问题处理
1. 服务启动失败或反复退出
现象
mariadb.service 为 failed,3306 没有监听,或启动几秒后进程退出。
影响
新连接全部失败;若是唯一主库,业务读写中断;反复启动还可能覆盖最有价值的首轮错误日志。
常见根因
配置项拼错或版本不支持;数据目录属主错误;磁盘写满;端口冲突;InnoDB 文件来自不兼容版本;加密密钥、插件或证书文件不可读;异常关机后恢复失败。
定位顺序
先确认 unit 和进程,再看第一条启动错误;随后检查端口、容量、配置读取路径和数据目录;最后才判断 InnoDB 恢复或版本兼容。
定位命令
systemctl status mariadb --no-pager
journalctl -u mariadb -b -n 200 --no-pager
ss -lntp | grep ':3306 ' || true
df -hT
sudo ls -ld /var/lib/mysql
mariadbd --version输出判断
unknown variable 指向配置兼容;Permission denied 指向路径或安全策略;No space left 指向容量;Address already in use 指向端口冲突;InnoDB 提示 redo、表空间或升级不兼容时禁止删除日志文件试错。
解决步骤
停止反复重启;保存 journal 和最近配置变更;修正单一根因。配置错误就回退覆盖文件,权限错误只修正已确认的数据路径,磁盘满先停止非必要写入并安全清理过期日志。涉及 InnoDB 文件损坏或跨版本时,复制现场,在同版本隔离实例恢复或从已验证备份重建。
验证
systemctl restart mariadb
systemctl is-active mariadb
mariadb-admin ping
mariadb -Nse "SELECT VERSION(),@@read_only"服务持续 active、ping 成功、身份与读写角色正确,并且日志不再新增错误,才恢复流量。
预防
配置纳入版本控制;升级前检查废弃变量;监控磁盘与证书有效期;定期做崩溃恢复和备份恢复演练;数据目录与二进制版本一一对应。
2. 本机能连,应用认证或 TLS 失败
现象
应用收到 connection refused、access denied、TLS handshake 或 certificate verify failed;本机 sudo mariadb 却能登录。
影响
应用连接池耗尽,发布失败;如果临时关闭 TLS 或扩大 host 授权,会进一步形成安全风险。
常见根因
bind-address 只监听回环;防火墙或安全组未放行;账号 host 不匹配;密码或认证插件错误;应用没带用户名;服务器证书域名、CA 或有效期错误;连接到了旧代理地址。
定位顺序
先从应用主机验证 DNS 和 TCP,再核对服务监听;随后检查账号匹配和授权;最后用证书校验模式复现 TLS。
定位命令
getent hosts db.internal.example
nc -vz db.internal.example 3306
ss -lntp | grep ':3306 '
openssl s_client -connect db.internal.example:3306 -starttls mysql \
-CAfile /etc/ssl/certs/db-ca.pemSELECT User,Host,plugin FROM mysql.user WHERE User='app_user';
SHOW GRANTS FOR 'app_user'@'10.20.10.%';
SHOW SESSION STATUS LIKE 'Ssl_cipher';输出判断
DNS 错误先修服务发现;TCP 拒绝查监听和防火墙;Access denied 中的 user/host 是实际匹配线索;证书名称或 CA 校验失败不能用 trust 或关闭验证长期绕过;Ssl_cipher 为空表示当前会话未加密。
解决步骤
校正内网 DNS 与监听;只放行应用网段;创建与真实来源匹配的最小权限账号;从 secret manager 更新正确凭据;重新签发包含连接域名 SAN 的证书,并让客户端信任组织 CA。密码轮换先让客户端支持新凭据,验证后再撤旧凭据。
验证
允许来源使用 sslMode=verify-full 登录并执行业务 SQL;错误 CA、错误密码、禁止网段和管理 SQL 均失败;Aborted_connects 不再增长。
预防
监控证书到期;账号按应用和环境拆分;禁止公网开放 3306;发布前做 DNS、TCP、TLS、认证和授权五层探测。
3. 连接数耗尽或连接风暴
现象
应用获取连接超时,数据库返回 too many connections,Threads_connected 或 Max_used_connections 接近上限。
影响
请求在线程池排队,重试进一步放大连接,管理连接也可能进不去,最终形成全站级联超时。
常见根因
连接泄漏;每次请求新建连接;应用扩容后总池容量超过服务端;慢 SQL 或锁让连接长期占用;连接生命周期同时到期;DNS、TLS 或数据库抖动触发无界重连。
定位顺序
先保留管理入口并看当前/运行连接,再按用户、主机、命令和时长分组;关联应用池等待和请求超时;最后检查 OS 文件描述符和网络。
定位命令
SHOW GLOBAL STATUS WHERE Variable_name IN
('Threads_connected','Threads_running','Max_used_connections',
'Aborted_connects','Connections');
SHOW FULL PROCESSLIST;pid=$(pidof mariadbd)
test -n "$pid" && ls "/proc/$pid/fd" | wc -l输出判断
大量 Sleep 且来源集中通常是池过大或泄漏;大量相同长查询表示 SQL/锁问题;Threads_running 高且 CPU/IO 饱和表示数据库过载;Aborted_connects 上升说明握手或认证失败,不应仅调上限。
解决步骤
先在入口限流并关闭无界重试;修复泄漏和慢事务;降低每实例池上限并错开 maxLifetime;为获取连接、建连和读写分别设超时。只有计算应用总连接和资源后,才适度增加 max_connections 与文件描述符。
验证
峰值下连接数稳定、池等待回落、管理连接可用、拒绝连接不再增加;故障时应用能快速失败或降级,不形成第二轮重试洪峰。
预防
连接池指标按应用实例汇总;发布扩容时计算总连接;保留运维账号和连接余量;压测包含数据库慢、断连和证书握手场景。
4. 慢 SQL 或执行计划退化
现象
接口 P99 上升,慢日志出现新 SQL,扫描行数或磁盘临时表激增;升级、数据增长后原 SQL 变慢。
影响
CPU、IO 和连接同时被占用,复制重放变慢,最终拖累无关请求。
常见根因
缺索引或索引顺序错误;隐式类型转换;大偏移分页;统计信息失真;数据倾斜;返回列过多;N+1;排序/分组落盘;版本变化导致计划改变。
定位顺序
从业务 trace 找 SQL 和参数;确认调用次数与总耗时;查看计划和表结构;比较估算与实际行数;再看临时表、磁盘、缓存和锁。
定位命令
EXPLAIN FORMAT=JSON
SELECT id,total_amount FROM appdb.orders
WHERE tenant_id=1001 AND status='PAID'
ORDER BY created_at DESC LIMIT 20;
SHOW INDEX FROM appdb.orders;
SHOW GLOBAL STATUS WHERE Variable_name IN
('Slow_queries','Created_tmp_tables','Created_tmp_disk_tables');输出判断
大表 ALL、估算与实际差距大、排序或临时表数据量无界是重点;possible_keys 有值但未选择不等于优化器错误,可能是选择性差或回表成本高。
解决步骤
先限制返回行和列,改 keyset 分页,消除函数包裹索引列和类型不一致;更新统计;设计与过滤、排序相符的联合索引;在影子流量验证写放大和计划。必要时回退最近版本或查询变更,不在线上连续试多个索引。
验证
相同数据与参数下扫描行数、总耗时、P99 和临时磁盘表下降;写入延迟和索引空间仍在预算内;复制延迟没有恶化。
预防
保存核心 SQL 计划基线;升级前回放真实参数;对无界查询、全表扫描和大偏移建立静态与运行告警;定期检查重复和未使用索引。
5. 锁等待和死锁放大
现象
请求卡在更新,出现 lock wait timeout 或 deadlock;应用持续重试后 QPS 下降、连接上升。
影响
热点事务排队,连接池耗尽;错误的单语句重试可能造成业务部分更新或重复写。
常见根因
事务过长;不同代码路径锁顺序相反;更新条件无索引扩大锁范围;热点单行;事务内远程调用;批量 DML 一次处理过多行。
定位顺序
先看当前事务和 InnoDB 最新死锁;找阻塞者、等待者和 SQL;确认索引与锁范围;再回到业务事务边界和重试代码。
定位命令
SHOW ENGINE INNODB STATUS\G
SELECT trx_id,trx_mysql_thread_id,trx_started,trx_state,trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
SHOW FULL PROCESSLIST;输出判断
最老事务往往是阻塞源;状态持续时间不能等同连接年龄;死锁段给出的 SQL、持有锁和等待锁用于还原锁顺序。查询条件无索引时,修索引优先于增大超时。
解决步骤
入口限流;确认业务影响后让阻塞事务提交、回滚或由负责人终止对应连接;统一资源锁顺序;缩短事务;补索引;拆小批次。应用对死锁只重试整个幂等事务且次数有上限,连接异常的提交结果先查业务键。
验证
并发夹压下死锁率和等待时长回落,连接池稳定,库存/余额等业务约束仍成立,重试没有产生重复记录。
预防
监控长事务与锁等待;代码评审检查事务内远程调用;高竞争模型使用分段、队列或乐观锁;压测覆盖相反顺序并发。
6. 磁盘被数据、binlog 或临时文件写满
现象
磁盘告警,写 SQL 失败,服务进入只读或崩溃;binlog、relay、slow log、临时表或在线 DDL 文件快速增长。
影响
事务无法提交,复制中断,崩溃恢复和备份也可能没有工作空间;直接删数据库文件会造成不可恢复损坏。
常见根因
binlog 保留配置失效;副本长期断开阻止日志处置;慢查询产生大临时表;日志轮转失效;大 DDL 复制表;业务增长或备份写到数据盘。
定位顺序
先确认哪个挂载点和目录增长;再区分数据、binlog、relay、临时和普通日志;查询数据库认可的 binlog 清单;检查复制与备份依赖后处置。
定位命令
df -hT
du -xhd1 /var/lib/mysql | sort -h
du -xhd1 /var/log/mysql | sort -h
lsof +L1 | grep -E 'mariadbd|mysql' || trueSHOW BINARY LOGS;
SHOW ALL SLAVES STATUS\G
SHOW VARIABLES LIKE 'binlog_expire_logs_seconds';输出判断
删除后空间不释放且 lsof +L1 有大文件,说明进程仍持有;binlog 必须与 SHOW BINARY LOGS 一致;副本仍需要的日志、唯一备份和 InnoDB 数据文件都不能删。
解决步骤
先限写和暂停大查询/DDL;把普通日志安全轮转或迁出;确认所有副本和 PITR 保留点后用 PURGE BINARY LOGS BEFORE 清理;扩大磁盘或迁移备份。磁盘已满时为数据库恢复和 checkpoint 留出空间,不直接 rm binlog、redo、undo 或表空间。
验证
文件系统回到安全水位,写事务、checkpoint、复制和备份恢复;错误日志不再出现 ENOSPC;binlog 清单与文件系统一致。
预防
按生成速率计算保留空间;数据、日志和备份分盘;容量多级告警;DDL 前估算临时空间;定期验证 binlog 过期策略。
7. 复制延迟扩大或线程停止
现象
Slave_IO_Running 或 Slave_SQL_Running 为 No,GTID 长时间不推进,副本读到旧数据,relay log 持续增长。
影响
只读业务不一致,备份与灾备点变旧;故障时没有满足 RPO 的候选节点。
常见根因
主副网络或 TLS 失败;账号/密码轮换不同步;副本磁盘或 CPU 较慢;主库大事务、DDL 或高并发写入;行不存在、重复键等数据分叉;binlog 已过期;并行复制配置不匹配。
定位顺序
先看 IO/SQL 线程和最后错误;比较 GTID 与延迟趋势;随后检查网络、证书、资源和 relay;SQL 错误再定位具体事件与数据差异。
定位命令
SHOW ALL SLAVES STATUS\G
SELECT @@server_id,@@gtid_binlog_pos,@@gtid_slave_pos,@@read_only;
SHOW PROCESSLIST;nc -vz 10.20.0.10 3306
iostat -x 1 5输出判断
Last_IO_Error 指向连接、TLS、账号或缺失 binlog;Last_SQL_Error 指向重放事件;两个线程为 Yes 但 GTID 差距扩大,说明副本处理能力不足。Seconds_Behind_Master=0 不能单独证明完全追平。
解决步骤
IO 故障修网络、证书或凭据并验证旧凭据失效;资源不足降低副本查询、改善磁盘或调整并行重放;大事务从源头拆分。数据冲突先停 SQL 线程、确认事件与业务事实,通过受审查的数据修复或从新备份重建;禁止直接 SQL_SLAVE_SKIP_COUNTER 掩盖未知差异。
验证
IO/SQL 线程均为 Yes,错误为空,GTID 持续推进并追到目标位置;主库写入唯一探针后副本可见;业务延迟和 relay 占用回落。
预防
监控线程、GTID、relay、网络和副本资源;binlog 保留覆盖最长修复时间;复制账号轮换做双端演练;大事务和 DDL 在副本评估后执行。
8. GTID 分叉或误提升风险
现象
两台节点都出现独立写入,GTID 集合不再是包含关系;重建复制时报重复事务、缺失事务或严格模式错误。
影响
形成双主和冲突事实,自动切回可能覆盖订单、余额或状态;恢复时间通常远高于普通复制延迟。
常见根因
旧主未隔离就提升副本;代理和 DNS 同时指向两端;人工在只读副本写入;错误修改 gtid_slave_pos;把 MariaDB GTID 当成 MySQL GTID 互换。
定位顺序
立即关闭写入口;分别记录实例身份、只读状态、GTID、binlog 和业务最后写入;确认各客户端实际连接;最后按业务键比较独有事务。
定位命令
SELECT @@hostname,@@server_id,@@read_only,
@@gtid_binlog_pos,@@gtid_slave_pos;
SHOW MASTER STATUS;
SHOW ALL SLAVES STATUS\Gmaxctrl list servers输出判断
两端都可写或都有对方不包含的 GTID 即分叉;此时不能靠选择“更大”的序号决定真相。MariaDB GTID 是 domain-server-sequence,跨产品 GTID 字符串不能直接转换。
解决步骤
保持入口关闭并把两端设为只读;导出各自独有业务变更,按订单、账户等事实规则合并;选定权威节点,从其一致备份重建其他节点;恢复单写路由。任何业务冲突未解决前不自动合并 binlog。
验证
只有一个节点可写;所有副本从同一权威 GTID 连续复制;唯一写探针只出现一次;代理、DNS 和应用连接均指向预期角色。
预防
提升前强制隔离旧主;应用账号在副本无写权;切换演练包含代理与 DNS;监控多个可写节点;禁止手工修改 GTID 位置绕过错误。
9. Galera 进入 Non-Primary 或不同步
现象
wsrep_cluster_status=Non-Primary、wsrep_ready=OFF,节点停在 Joining/Donor,写入报错或集群 flow control 很高。
影响
多数派丢失时集群停止写入以避免脑裂;慢节点可通过流控拖慢整个集群;错误 bootstrap 会形成两个 Primary Component。
常见根因
节点间网络分区;同时停止多数节点;错误地址或端口;磁盘慢导致接收队列增长;gcache 不足使 IST 退化为 SST;SST 账号、权限或空间失败。
定位顺序
先看每个节点的集群状态和大小;确认网络与故障域;再看节点状态、队列、flow control、gcache 和 SST 日志;不要先 bootstrap。
定位命令
SHOW STATUS WHERE Variable_name IN
('wsrep_cluster_status','wsrep_cluster_size','wsrep_local_state_comment',
'wsrep_ready','wsrep_connected','wsrep_local_recv_queue_avg',
'wsrep_flow_control_paused','wsrep_last_committed');journalctl -u mariadb -n 200 --no-pager
nc -vz 10.20.0.22 4567
df -hT输出判断
没有多数派且 Non-Primary 时保持停写;wsrep_local_state_comment 非 Synced 表示节点不能承载正常流量;接收队列和 flow control 持续升高说明节点跟不上;SST 错误要查账号、路径和空间。
解决步骤
修复网络和慢节点,恢复原 Primary Component;IST/SST 失败则清理目标节点的可重建数据后按已知健康 donor 重加。只有确认所有旧 Primary 节点停止,并根据 grastate.dat、恢复位置和各节点数据确定最新安全节点后,才 bootstrap 一次。
验证
所有计划节点 Primary、size 正确、Synced、ready/connected 为 ON;真实入口完成写后读;flow control 回到基线;重启一个节点能通过预期 IST 或 SST 回归。
预防
三节点跨故障域;监控仲裁、队列、流控和 SST;按写入速率配置 gcache;限制大事务;定期演练单节点和单故障域中断。
10. MaxScale 路由错误或切换后仍读旧数据
现象
写请求到只读副本,读请求长期命中落后副本,maxctrl 角色与数据库实际角色不一致,切换后应用仍连旧后端。
影响
写失败、读到旧数据,严重时两个节点同时接收写入;代理本身正常监听会掩盖后端错误。
常见根因
monitor 账号权限或密码错误;后端 read_only、复制状态和 MaxScale 标签不一致;服务/监听器使用旧配置;会话没有排空;旧主未隔离;版本不兼容。
定位顺序
先阻止新入口连接;查看 service、listener、monitor 和 server;直接登录每个后端确认身份、只读和复制;最后通过代理会话看真实路由。
定位命令
maxctrl list services
maxctrl list listeners
maxctrl list monitors
maxctrl list servers
maxctrl show server db1SELECT @@hostname,@@server_id,@@read_only;
SHOW ALL SLAVES STATUS\G输出判断
service Started 只说明代理服务启动,不证明路由正确;多个 Master、未知角色、monitor 认证失败、活动会话仍连旧主都应保持入口关闭。读副本延迟超业务阈值时不能承接强一致读。
解决步骤
围栏应用入口并排空会话;停止受影响 service;修复 monitor 凭据、服务器地址和角色;隔离旧主,确认唯一可写节点;启动 service 后先执行唯一写后读探针,确认路由再解除入口。验证失败则重新停止 service,不边接流量边改配置。
验证
MaxScale 与后端身份一致,只有一个可写主库;新会话写到主库,允许旧读的查询才到健康副本;错误率、延迟和复制均稳定。
预防
固定 MaxScale 精确版本并测试配置;监控真实后端身份而非标签;切换演练包含会话排空和路由探针;强一致查询使用主路由。
11. 备份失败或恢复后数据边界错误
现象
mariadb-backup 非零退出、prepare 失败、逻辑导出缺对象;恢复实例能启动但缺表、最新数据时间错误、账号或事件不完整。
影响
灾难发生时无法满足 RPO/RTO;“有文件”会造成错误安全感,生产切换后才发现不可用。
常见根因
Backup 与 Server 版本不兼容;备份账号权限或文件权限不足;磁盘/内存不足;复制中的备份位置未记录;只备了数据未备 routines/events/triggers;备份损坏或加密密钥缺失;恢复后未重放正确 binlog 边界。
定位顺序
先保存工具退出码和完整日志;确认 Server/Backup 版本;检查空间、权限和关键文件;在隔离实例 prepare、启动并按业务边界验证。
定位命令
mariadb --version
mariadb-backup --version
df -hT
test -s /backup/full/T0/xtrabackup_checkpoints
test -s /backup/full/T0/xtrabackup_binlog_info
sha256sum -c /backup/logical/T0/appdb.sql.sha256输出判断
版本线不兼容、关键文件缺失、校验失败或 prepare 非零都表示备份不可恢复;恢复实例启动不代表完整,仍要比较对象、行数、业务时间、权限和事件。
解决步骤
保留失败目录和日志;安装与 Server 兼容的 Backup;补最小数据库与文件读取权限;扩充独立备份空间后重新全备。恢复在空隔离目录进行,先 prepare,再 copy-back,再按记录位置重放 binlog;缺密钥时从受控密钥备份恢复,不生成新密钥假装可读。
验证
隔离实例通过启动、表检查、关键查询、对象清单、抽样业务关系和应用只读测试;测得的恢复时间在 RTO 内,恢复点在 RPO 内。
预防
备份任务检查退出码、文件和大小;备份异机保存并校验;每个版本升级都做恢复演练;保存配置、插件、证书、密钥和 binlog 位置。
12. 字符集、collation 或 JSON 差异阻断迁移
现象
导入时报 unknown collation、invalid JSON、重复唯一键;迁移后排序、大小写比较、索引或 JSON 查询结果变化。
影响
DDL 无法导入,或更危险地在导入成功后产生静默语义差异和重复业务键。
常见根因
MySQL 专属 utf8mb4_0900_*;源目标默认字符集不同;非法字节;排序规则改变等价关系;MariaDB JSON 为文本语义而 MySQL 使用 binary JSON;函数和生成列行为不同。
定位顺序
导出源端 schema、默认字符集和所有列级 collation;统计不可用规则;在只读副本检查冲突值和 JSON;生成目标专用 DDL 后在隔离库导入。
定位命令
SHOW VARIABLES WHERE Variable_name IN
('character_set_server','collation_server','sql_mode');
SHOW CREATE TABLE appdb.orders\G
SELECT table_name,column_name,character_set_name,collation_name
FROM information_schema.columns
WHERE table_schema='appdb' AND character_set_name IS NOT NULL;输出判断
目标不存在的 collation 必须显式映射;映射前后 DISTINCT 或唯一键数量变化表示存在等价冲突;JSON 校验失败或数值/字符串比较变化必须由业务确认,不能只做文本替换。
解决步骤
固定目标字符集和 collation;离线生成并审查转换 DDL;迁移前清理非法字节和唯一冲突;为 JSON 建兼容查询与索引方案;先导 schema 再导数据,不在生产导入流中即席 sed 替换。
验证
比较源/目标对象、唯一键、排序样例、多语言文本、JSON 查询、生成列和关键聚合;应用使用目标驱动回放真实读写。
预防
schema 显式写字符集和 collation;升级与迁移测试包含真实多语言和 JSON 数据;禁止依赖数据库默认值;对跨产品 DDL 保留各自批准形态。
13. 升级或迁移切换后异常
现象
新版本启动正常但应用报 SQL、认证或驱动错误;P99 上升;事件、视图或 DEFINER 失效;切换后发现漏数据或回退路径不可用。
影响
新旧系统可能同时产生写入;盲目降级会损坏数据目录;长时间观察会扩大新写数据与旧源的差距。
常见根因
跳过支持的升级路径;废弃变量和系统表未升级;驱动、认证或 TLS 不兼容;DDL 转换遗漏;执行计划变化;最终增量未追平;首次目标写入后仍把旧源当作可直接回退点。
定位顺序
先关闭或限流写入口;确认当前流量和唯一写节点;比较版本、变量、对象和关键数据;查看错误日志、慢 SQL、复制与应用错误;判断目标是否已经接受新写。
定位命令
mariadb --version
journalctl -u mariadb -n 200 --no-pagerSELECT @@hostname,@@server_id,@@read_only,VERSION();
SHOW WARNINGS;
SHOW ALL SLAVES STATUS\G
SHOW FULL PROCESSLIST;输出判断
多个节点可写、对象或数据不一致、复制线程停止时先保持入口关闭;废弃变量看启动日志;计划退化用升级前后的同 SQL 比较。目标已有独有写入时,旧源不再是无损回退点。
解决步骤
若目标尚无写入,恢复只读并按计划切回完整旧源;若目标已有写入,先把目标设只读并保存新写,通过反向复制、增量导出或业务补偿合并后再决定方向。二进制升级异常优先切换到未升级副本或从旧版本备份重建,不在同一数据目录直接降大版本。
验证
唯一写节点明确;对象、数据、权限和事件符合目标;关键 SQL、Java 连接、备份、复制、路由和业务写后读全部通过;错误率与 P99 回到基线。
预防
遵循官方升级路径;副本先行和灰度切换;保存升级前计划与变量;迁移做最终对账;首次目标写入前明确回退点,之后必须有真实增量回传方案。
