MySQL 8 迁到 OceanBase 4.2 的兼容差异与分区取舍
目录
为什么把 MySQL 迁到 OceanBase#
prize-service 是奖池活动的后端服务,在整体架构里的位置见H5 应用容器与奖池的架构。它负责抽奖券扣款、奖池入账、派奖结算和事件发布,核心存储原先跑在 MySQL 8 上。
迁移的出发点在写入扩展:系统按每天 3 亿笔交易设计(平均每秒约 3500 笔),抽奖券流水和奖池流水跟着交易写入,单机 MySQL 的写入能力就是上限;在应用层上分库分表中间件(比如 ShardingSphere)又要自己维护分片键,跨分片的对账查询和事务都得改。OceanBase 的 MySQL 模式兼容 MySQL 协议和大部分语法,多节点扩展和多副本高可用由数据库自己提供,应用层不用再加分片这一层。
服务的开发和测试基线原先基于 MySQL 8:Testcontainers 拉取 mysql:8.0,表结构由 Liquibase 管理,Hibernate 启动时按实体校验表结构。测试环境接入的是 OceanBase 云服务上的 MySQL 模式租户,SELECT VERSION() 返回 5.7.25-OceanBase-v4.2.5.7。
为了逐项摸清两边的行为差异,本地对照了两个容器:MySQL 8.0.46(官方镜像 mysql:8.0)和 OceanBase CE 4.2.5.5(镜像 oceanbase/oceanbase-ce:4.2.5-lts,MODE=mini 单节点),两边均使用默认参数。
迁移过程中的兼容性注意点#
迁移中遇到的差异有六处:会话变量的默认值、Hibernate 生成的锁语句、三处事务并发行为,以及一次性运维脚本里的几个参数。
会话变量的默认值#
连上数据库后首先对照会话变量,下表为本地两个容器中 SHOW VARIABLES 的输出:
| 变量 | MySQL 8.0.46 | OceanBase CE 4.2.5.5 |
|---|---|---|
transaction_isolation |
REPEATABLE-READ |
READ-COMMITTED |
lower_case_table_names |
0 |
1 |
collation_server |
utf8mb4_0900_ai_ci |
utf8mb4_general_ci |
sql_mode |
ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION |
STRICT_ALL_TABLES,NO_ZERO_IN_DATE,NO_AUTO_CREATE_USER |
time_zone |
SYSTEM |
+00:00 |
innodb_lock_wait_timeout |
50 |
50 |
ob_trx_lock_timeout |
无此变量 | -1 |
ob_query_timeout |
无此变量 | 10000000(微秒) |
前五行各自影响的地方:
- 隔离级别:OceanBase 默认事务隔离级别是读已提交(RC),MySQL 8 默认是可重复读(RR)。业务代码里没显式声明隔离级别的事务,换库后隔离级别跟着变,并发下的加锁行为也就变了。
- 表名大小写:
lower_case_table_names在 OceanBase 默认为 1(表名小写存储且比较时不区分大小写),且只能在创建租户时指定。测试环境中 Liquibase 的databasechangelog和databasechangeloglock表在两边的大小写表现一致,但若应用依赖大小写敏感区分不同表名,迁移后不成立。 - 排序规则:4.2.5 支持
utf8mb4_0900_ai_ci,但服务端默认排序规则切回了utf8mb4_general_ci。未显式指定 collation 的新建表会拿到不同默认值。prize-service 建表均显式声明了utf8mb4_unicode_ci,未受影响。 sql_mode:OceanBase 默认没有启用ONLY_FULL_GROUP_BY,但开启了STRICT_ALL_TABLES。prize-service 的查询均遵循严格聚合分组规则,且不存零值日期,两边执行表现一致。- 时区:本地容器默认是
+00:00,云上实例按地域配置。prize-service 时间列统一采用BIGINT记录 Unix 毫秒时间戳,不受数据库会话时区转换干扰。
最后三行涉及等锁超时与大查询超时,在后文展开说明。
Hibernate 生成的 FOR UPDATE OF#
下表为两边在同一张测试表上逐条执行不同锁语句的结果:
| 语句结尾 | MySQL 8.0.46 | OceanBase CE 4.2.5.5 |
|---|---|---|
FOR UPDATE |
通过 | 通过 |
FOR UPDATE OF a |
通过 | ERROR 1064 |
FOR UPDATE SKIP LOCKED |
通过 | 通过 |
FOR UPDATE NOWAIT |
通过 | 通过 |
FOR SHARE |
通过 | ERROR 1064 |
LOCK IN SHARE MODE |
通过 | 通过 |
OceanBase 文档明确指出不支持 SELECT ... FOR SHARE ... 语法;而带别名的 FOR UPDATE OF <alias> 在本地测试中同样会触发 1064 语法错误。
Spring Boot 4.1.1 依赖的 Hibernate 7.4.5 中,MySQLDialect 默认会给悲观锁语句加上别名:PESSIMISTIC_WRITE 会渲染为 for update of <别名>,带跳过锁定的抢占查询会渲染为 for update of <别名> skip locked。由于 supportsAliasLocks() 和 supportsForShare() 在 Hibernate 源码中默认无条件返回 true,导致生成的 SQL 在 OceanBase 上直接报错。
解决方式是在项目中扩展方言,显式关闭别名锁和 share 锁:
public class OceanBaseMySQLDialect extends MySQLDialect {
// 构造方法与 MySQLDialect 对应,此处省略
@Override
protected boolean supportsAliasLocks() {
return false;
}
@Override
protected boolean supportsForShare() {
return false;
}
}
关闭别名锁后,Hibernate 将锁语句退化为标准的 for update 或 for update skip locked。对于单表查询,锁定的行范围与之前完全相同;但对于多表关联查询,该设置会锁定关联表涉及的行。
由于 MySQL 对两种写法均支持,在 MySQL 容器上跑集成测试无法暴露该问题。工程中通过配置 Hibernate 的 StatementInspector 拦截发出的实际 SQL,并使用正则表达式断言拦截不兼容语法:
private static final Pattern REFUSED = Pattern.compile("(?i)\\bfor\\s+(update|share)\\s+of\\b|\\bfor\\s+share\\b");
RR 下没有 gap lock#
在空区间上加排他锁,同时在另一个会话里往这个区间插入。表 g 初始有 10、20、30、40 四行:
-- 会话 A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT * FROM g WHERE id BETWEEN 11 AND 19 FOR UPDATE;
SELECT SLEEP(5);
ROLLBACK;
-- 会话 B(在 A 持有锁期间执行)
INSERT INTO g VALUES (15, 15);
DELETE FROM g WHERE id = 15;
会话 B 的耗时:
| 会话 A 的隔离级别 | MySQL 8.0.46 | OceanBase CE 4.2.5.5 |
|---|---|---|
REPEATABLE READ |
4106 ms(等待 A 事务回滚后才插入成功) | 179 ms(直接插入成功) |
READ COMMITTED |
180 ms(无等待直接插入) | 124 ms(无等待直接插入) |
MySQL 在 RR 下用 InnoDB 的 gap lock 锁住了 10 和 20 之间的间隙,会话 B 插入 15 要等 A 回滚;OceanBase 在 RR 下没有拦住这次插入。OceanBase 的文档里没找到关于 gap lock 的明确说明,这里以实测为准。靠「先查无记录再插入」防重的逻辑,迁过去以后挡不住并发的重复插入,防重要交给唯一键约束。
此外,OceanBase 在 RR 级别下发生写写冲突时,若先写的事务提交,后写的事务会回滚并返回错误;而 MySQL 会让后写的事务在新提交版本上继续更新。
等锁超时从 50 秒变成 10 秒#
会话 A 锁住目标行持锁不放,会话 B 尝试更新该行:
| 表现 | MySQL 8.0.46 | OceanBase CE 4.2.5.5 |
|---|---|---|
| 等待放弃耗时 | 约 50 秒 | 约 10 秒 |
| 报错信息 | ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction |
同左 |
两边的 innodb_lock_wait_timeout 都是 50,OceanBase 却等了 10 秒就放弃。它的等锁时间看 ob_trx_lock_timeout,这个变量默认 -1,表示不生效;锁机制文档的说法是锁超时「默认为语句超时时间」,也就是 ob_query_timeout 的默认值 10000000 微秒(10 秒)。两边错误码一样,应用层收到超时却早了 40 秒。
SKIP LOCKED 加 LIMIT 拿到空结果#
会话 A 锁住 id 为 10 和 20 的行,会话 B 执行抢占查询:
SELECT GROUP_CONCAT(id ORDER BY id)
FROM (SELECT id FROM g ORDER BY id LIMIT 2 FOR UPDATE SKIP LOCKED) x;
MySQL 返回 30,40,OceanBase 返回 NULL:跳过被锁的两行之后,OceanBase 没有接着往下取。拿这条语句做任务队列抢占的话,排在前面的行被别的 worker 锁住时,这一轮什么都抢不到。
运维脚本里的超时、字符集、自增列和 mysqldump#
数据初始化和一次性脚本里有四处要调整。
ob_query_timeout 默认 10 秒,批量迁移、历史数据回填、大表 INSERT ... SELECT 很容易被它中断。一次性运维脚本连上以后先把窗口调大:
SET SESSION ob_query_timeout = 3600000000;
SET SESSION ob_trx_timeout = 3600000000;
用 obclient 连接且不指定参数时,character_set_client 和 character_set_connection 默认是 latin1。在这样的连接上执行带中文注释的 DDL:
CREATE TABLE cs (id INT PRIMARY KEY) COMMENT='中文注释';
再用 utf8mb4 连接读出来,HEX(TABLE_COMMENT) 以 C3A4C2B8C2AD 开头,正确的 UTF-8 应该以 E4B8AD 开头:UTF-8 字节被当成 latin1 又编码了一次,存进去的数据已经坏了。运维脚本的连接参数要显式写 --default-character-set=utf8mb4,脚本第一行执行 SET NAMES utf8mb4;。
OceanBase 的自增列有两种模式:ORDER 保证全局递增,NOORDER 只保证全局唯一。租户级配置项 default_auto_increment_mode 默认是 ORDER。靠递增 id 区分同一毫秒内事件先后的表,要确认这个配置是 ORDER。另外,OceanBase 的 information_schema.TABLES 里 AUTO_INCREMENT 列返回 NULL,查自增当前值要看 SHOW CREATE TABLE。
用 mysqldump 导出表结构和数据时,可以用这组参数:
mysqldump --default-character-set=utf8mb4 --single-transaction --skip-lock-tables \
--set-gtid-purged=OFF --column-statistics=0 --no-tablespaces --hex-blob \
-h <host> -P <port> -u <user> <database> <table> ...
按 mysqldump 文档:--set-gtid-purged=OFF 不在导出文件里写 SET @@GLOBAL.gtid_purged;--column-statistics=0 不写生成直方图的 ANALYZE TABLE;--no-tablespaces 不写 CREATE TABLESPACE,MySQL 8.0.21 起不加它的话,导出账号还要有 PROCESS 权限;--hex-blob 把二进制列按十六进制写出。
时间分区在 OceanBase 上的代价#
prize-service 的流水表在 MySQL 上按 occurred_at 做 RANGE 分区,留一个 pmax 兜底,由服务里的定时任务把 pmax 往后拆。这套做法搬到 OceanBase 上有三处不合适:两边分区的角色不同,时间分区把写入压在一个 Leader 上,4.2.5 上 pmax 又拆不开。
分区在 MySQL 和 OceanBase 里的角色#
InnoDB 的分区是单机内部的数据组织方式:每个分区一个 .ibd 文件,用处是分区裁剪,以及用 DROP PARTITION 快速清掉历史数据。分区再多,所有分区还在同一台机器上,由同一个事务引擎处理,写入上限就是这台机器。
OceanBase 4.x 里,表的每个分区对应一个 Tablet,Paxos 复制和 Leader 角色在日志流(log stream)这一层。按数据分布文档,一个日志流里有多个 Tablet,「日志流内所有数据分区继承其属性」;副本介绍说得更直接:「数据分区不再独立拥有角色信息,而是由其归属于的日志流角色决定」。一个分区的写入落在哪台 OBServer,取决于它所在日志流的 Leader 在哪。Leader 没摊开会怎样,OceanBase 与 TiDB 容量压测那篇遇到过:32 个哈希分区的 Leader 全落在一台 observer 上。
时间分区把写入压在一个 Leader 上#
按时间分区时,任一时刻的写入几乎都落进最新的那个分区。MySQL 本来就是单机,这不影响什么;在 OceanBase 上,最新分区的 Tablet 属于某一个日志流,这个日志流的 Leader 只在一台 OBServer 上,集群里其他机器分不到流水表的写入。
要把写入摊开,一般做法是在时间分区下面再按 ticket_id 这类键切 KEY 子分区。代价在两处:
- Tablet 数量成倍增长。集群架构文档说索引表的每个分区也对应一个 Tablet,局部索引的 Tablet 和主表 Tablet 强制绑定。一张表带 3 个本地索引、每个时间分区切 8 个子分区,每新增一个时间分区就多出至少
(1 + 3) × 8 = 32个 Tablet,有 LOB 列的表还要加上 LOB 辅助表的 Tablet。保留的分区越多,要管理的 Tablet 越多。 - 唯一约束变贵。本地唯一索引必须包含全部分区列,要在非分区列上保证唯一,只能建全局索引。全局索引的分区独立分布,写一行数据可能同时写到两个日志流;同一篇集群架构文档写明,事务修改在单个日志流内完成时才能一阶段提交,跨日志流就要走两阶段提交。
pmax 在 4.2.5 上拆不开#
MySQL 上滚动时间分区的常见做法是留一个 VALUES LESS THAN MAXVALUE 的 pmax 分区兜底,防止写入越界报错,再由定时任务执行 REORGANIZE PARTITION pmax INTO (PARTITION p_today VALUES LESS THAN (...), PARTITION pmax VALUES LESS THAN MAXVALUE) 把 pmax 往后拆。在 OceanBase CE 4.2.5.5 上把这套操作和常见的分区 DDL 试了一遍:
| 操作 | 结果 |
|---|---|
REORGANIZE PARTITION pmax INTO (...),pmax 为空 |
报 reorganize partition not allowed |
同上,pmax 中已存在数据 |
报 reorganize partition not allowed |
表含 MAXVALUE 分区时执行 ADD PARTITION |
报 VALUES LESS THAN value must be strictly increasing for each partition |
表不含 MAXVALUE 分区时在末尾执行 ADD PARTITION |
通过 |
| 写入超出最后一个分区上界的值 | 报 Table has no partition for value |
TRUNCATE PARTITION、DROP PARTITION |
通过 |
ALTER TABLE ... REMOVE PARTITIONING |
语法错误(不支持) |
ALTER TABLE ... PARTITION BY RANGE (...) 原地改规则 |
通过(为 Offline DDL,需重整数据) |
pmax 拆不开,有 MAXVALUE 分区时也加不了新分区;分区表也退不回普通表,修改分区规则的转换表里,一级分区转非分区是「不支持」。要继续用时间分区,只能不留 MAXVALUE,提前 ADD PARTITION 预建未来的分区,旧分区用 DROP PARTITION 删。按 Online / Offline DDL 列表,添加分区是 Online DDL,只改元数据;删除分区是 Offline DDL。阿里云 ODC 的分区计划可以定时做预建和删除,但那页文档已经标成 Deprecated,而且只支持 RANGE 分区,末尾是 MAXVALUE 的表配不了。
建表脚本不带分区,分区交给 DBA#
这里的约定是:Liquibase 里归档的 DDL 不带分区,分区由系统管理员手工或另外的脚本维护。按每天 3 亿笔交易的设计量,流水表还是要分区,只是不再按时间分,改成按业务键做 HASH/KEY 分区:分区数固定,没有 pmax 要拆,也不会随时间不断多出新的 Tablet,写入按键分散到各个分区。这些分区的 Leader 是否真的摊在多台 OBServer 上,还要看租户的 primary_zone,前面提到的压测那篇里有检查 Leader 分布的做法。
应用这边的改动分三步:
- 改 Liquibase 脚本:建表 changeSet 里去掉
PARTITION BY,主键不变,仍是PRIMARY KEY (ticket_id, occurred_at)。已执行过的 changeSet 内容变了,在 changelog 里给它加上validCheckSum,写入旧的 checksum,启动校验才能通过。 - 删掉服务里拆
pmax的定时任务和相关配置。 - 重建测试环境里已有的分区表:4.2.5 转不回普通表,所以新建一张同结构的非分区表,
INSERT ... SELECT导入存量数据,在低峰期用RENAME TABLE原子交换表名,旧表改名留作回滚备份。操作前用运维脚本那一节的mysqldump参数做冷备,导完核对行数和哈希。
V4.3.5 BP2 起 OceanBase 有了内核自带的动态分区:按 TIME_UNIT、PRECREATE_TIME、EXPIRE_TIME 自动预建和清理分区,BIGINT_PRECISION 支持毫秒时间戳做分区键。升级到这个版本以后,时间分区可以再评估一次。
本地用单节点容器就能把这些改动跑一遍:
docker run -d --name ob-local -p 127.0.0.1:12881:2881 -e MODE=mini oceanbase/oceanbase-ce:4.2.5-lts
docker logs -f ob-local 2>&1 | grep -m1 'boot success'
docker exec -it ob-local obclient -h127.0.0.1 -P2881 -uroot@test
在这个容器上,Liquibase 从空库跑完了 22 个 changeSet,演练了一次分区表到普通表的重建,StatementInspector 的断言也没再拦到不兼容的锁语句。集成测试目前还跑在 mysql:8.0 上,Testcontainers 有 OceanBase 模块(OceanBaseCEContainer),后续可以换成和测试环境同一个大版本的镜像。
小结#
这次遇到的 OceanBase 4.2.5 MySQL 模式与 MySQL 8 的差异,集中在默认值和锁上:默认隔离级别是 RC,RR 下没有 gap lock 拦插入,等锁 10 秒就超时,Hibernate 7.4.5 的 MySQLDialect 会生成 OceanBase 不认的 FOR UPDATE OF,要用子类关掉 supportsAliasLocks()。运维脚本要调大 ob_query_timeout,连接显式用 utf8mb4。
分区上,OceanBase 的分区是日志流里的一个 Tablet,时间分区让写入集中在一个 Leader 上,4.2.5 又拆不开 pmax。prize-service 的 Liquibase 建表脚本不再带分区;按每天 3 亿笔交易的设计量,流水表由 DBA 在库上做不带时间维度的 HASH/KEY 分区。
相关文章#
- H5 应用容器与奖池的架构:prize-service 在整体里的位置
- OceanBase 与 TiDB 容量压测:瓶颈定位:分区 Leader 倾斜和
primary_zone的写法 - Liquibase (XML) 在微服务里的 14 个问题:schema 归属、changeSet ID、大表 DDL 与回滚:多环境下的 changelog 结构与 checksum 管理
参考资料#
OceanBase 官方文档分支(V4.2.5 / V4.3.5):
- 与 MySQL 兼容性对比(V4.2.5)
- MySQL 模式的事务隔离级别(V4.2.5)、锁机制(V4.2.5)
- 锁定查询结果 SELECT FOR UPDATE(V4.2.5)
- ob_query_timeout(V4.2.5)、ob_trx_lock_timeout(V4.2.5)、lower_case_table_names(V4.2.5)
- 副本介绍(V4.2.5)、数据分布(V4.2.5)、集群架构(V4.2.5)
- Online DDL 和 Offline DDL 操作(V4.2.5)
- 修改分区规则(V4.2.5)、动态分区概述(V4.3.5)
- 定义自增列(V4.2.5)
其他:
- MySQL 8.0 Reference Manual: InnoDB Locking、mysqldump
- Hibernate ORM 7.4.5 源码:MySQLDialect.java、MySQLLockingSupport.java
- Testcontainers for Java: OceanBase Module
- 阿里云 ODC:Manage partitioning plans(页面已标为 Deprecated)