为什么把 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 分布的做法。

应用这边的改动分三步:

  1. 改 Liquibase 脚本:建表 changeSet 里去掉 PARTITION BY,主键不变,仍是 PRIMARY KEY (ticket_id, occurred_at)。已执行过的 changeSet 内容变了,在 changelog 里给它加上 validCheckSum,写入旧的 checksum,启动校验才能通过。
  2. 删掉服务里拆 pmax 的定时任务和相关配置。
  3. 重建测试环境里已有的分区表: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 分区。

相关文章#

参考资料#

OceanBase 官方文档分支(V4.2.5 / V4.3.5):

其他: