一、为什么需要一套开发规范

很多朋友第一次接触OceanBase,脑子里第一反应是:"这不就是个MySQL吗?" 确实,OceanBase兼容MySQL协议,大部分SQL写起来跟MySQL没啥两样。但等你真的把业务跑上去,就会发现事情没那么简单——一条看起来很普通的SQL,在单机数据库上秒回,到了OceanBase上可能慢得让你怀疑人生。反过来,一些被MySQL折腾得死去活来的问题,在OceanBase上反而轻松解决。

原因在于OceanBase是个分布式数据库,它把数据打散到多台机器上,再用Paxos协议保证一致性和高可用。这种架构带来了强大的扩展性和容灾能力,但也意味着SQL的执行路径、索引的选择、事务的处理方式都和传统单机数据库有本质区别。如果我们还是拿着写MySQL的惯性思维去写OceanBase的SQL,就会掉进各种坑里。

所以我们需要一套开发规范。规范不是为了限制大家的自由,而是帮大家绕过那些已经被踩烂的坑,让代码在OceanBase上跑得更稳、更快、更省。

这套规范覆盖三个层面:一是SQL本身怎么书写,二是表结构怎么设计,三是应用层怎么适配。每个层面都会配合具体的示例来讲,保证你照着就能用。

二、OceanBase核心特性速览

2.1 分布式但兼容MySQL

OceanBase有一个"租户"概念,一个集群里可以划分多个租户,每个租户可以看成是一个独立的数据库实例。租户有两种模式:MySQL模式和Oracle模式。我们这里默认讨论MySQL模式,因为大部分中小团队更习惯这个模式。

在MySQL模式下,你可以直接用熟悉的客户端工具连接,执行CREATE TABLE、SELECT、INSERT等语句,甚至很多MySQL特有的函数和语法也支持。这大大降低了迁移成本。但注意,语法兼容不代表行为完全一致。比如,OceanBase的默认隔离级别是读已提交,而不是MySQL的重复读。再比如,某些MySQL的存储引擎特性在OceanBase里不存在,因为OceanBase使用自己的存储引擎。

2.2 存储引擎与事务

OceanBase存储引擎采用LSM-Tree结构,写入是追加式的,内存里先写memtable,达到阈值后转储到磁盘。这种机制让写入性能非常强悍,即使大量随机插入也不会像B+树那样产生随机IO。但代价是读取时需要合并内存和磁盘的数据,如果SQL没有合适的索引,扫全表会非常吃力。

事务方面,OceanBase支持完整的ACID,而且跨行、跨表、跨节点的分布式事务是内置的,不需要应用层手动协调。但这不等于你可以滥用长事务。分布式事务的提交需要多节点协调,长事务会持有锁资源更久,容易引发冲突和性能毛刺。

了解这些特性后,我们再来看具体的SQL编写规范,就更有针对性了。

三、SQL编写规范实战

3.1 建表规范

建表是地基。地基没打好,后面怎么写SQL都是事倍功半。在OceanBase里,建表尤其要注意主键、字段类型和注释。

规范一:显式指定主键,而且主键要尽量简单。

OceanBase的分布式架构里,表数据会按照主键做范围分区,均匀分布到各个节点。没有主键的表,OceanBase会内部生成一个隐藏主键,但那玩意儿对业务没有任何意义,查询时还得全表扫描。所以我们一定要显式定义主键。

主键的类型最好使用整数类型,比如BIGINT。字符串主键比如VARCHAR也能用,但整数主键在存储和比较上更高效。如果是分布式场景,尽量避免使用UUID这种随机散列的主键,因为会导致数据插入时跨节点分裂,性能受损。更推荐雪花ID或自增ID。

看一个例子:

-- 技术栈:OceanBase(MySQL模式)SQL

-- 创建用户表
CREATE TABLE `t_user` (
    `id` BIGINT NOT NULL COMMENT '自增主键,由应用生成雪花ID',
    `nickname` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '用户昵称',
    `age` INT NOT NULL DEFAULT 0 COMMENT '年龄',
    `email` VARCHAR(120) NOT NULL DEFAULT '' COMMENT '邮箱',
    `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用',
    `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    PRIMARY KEY (`id`),
    KEY `idx_status_create` (`status`, `create_time`)  -- 联合索引,用于状态+时间过滤
) COMMENT '用户表';

在这个例子中,我们选了BIGINT作主键,并且明确它是业务生成的雪花ID,不是数据库自增。为什么不用AUTO_INCREMENT?因为OceanBase的全局自增在高并发下会有性能瓶颈,而且分布式环境下很难保证绝对递增。如果你非要用自增,OceanBase也支持,但建议在低并发场景使用。

规范二:字段类型宁小勿大。

很多MySQL习惯用VARCHAR(255)存所有短文本,用DATETIME存日期,用TEXT存大字段。在OceanBase里,过大的字段类型会让每一行的存储变大,读取一行的成本也随之上升。尤其是TEXT这种,尽量拆到单独的表,或者用对象存储保存文件内容,数据库只存URL。

规范三:所有字段都要有注释,表也要有注释。

这听着像老生常谈,但分布式数据库表多、字段多,没有注释,三个月后没人看得懂。上面例子已经体现了注释的规范。

3.2 索引设计

索引是OceanBase查询性能的关键。因为LSM-Tree对点查和范围查比较友好,但前提是有正确的索引。这里有几个和MySQL明显不同的地方。

第一,OceanBase的索引分为局部索引和全局索引。

  • 局部索引:索引数据和主表在同一个节点上,创建和写入成本低,但查询时只能路由到节点内部查找,不能跨节点聚合。
  • 全局索引:索引数据独立分发,可能和主表不在一个节点,查询时通过索引能快速定位到数据所在节点,代价是写入时需要额外维护索引。

所以,在OceanBase里,一个针对查询优化的辅助索引,很多时候必须是全局索引。因为主表按主键分散在多个节点,如果辅助索引是局部的,你按一个非主键字段查,就得所有节点都扫一遍局部索引,然后汇总结果,性能极差。

第二,创建索引时要指定GLOBAL关键字。

MySQL里没有这个概念,但OceanBase里有。我们看示例:

-- 技术栈:OceanBase(MySQL模式)SQL

-- 创建一个全局索引,覆盖按email查询的场景
CREATE INDEX `idx_email` ON `t_user` (`email`) GLOBAL;

-- 创建一个局部索引,用于分区内部的排序
CREATE INDEX `idx_create_time` ON `t_user` (`create_time`) LOCAL;

注意,这里的注释说明了为什么用全局索引和局部索引。规范是:只要满足高并发点查或多表关联的辅助索引,一律用GLOBAL。而LOCAL索引更适合那些跟主键强相关的查询场景,比如按主键范围查之后还要按时间排序。

第三,联合索引的列顺序有讲究。

在OceanBase里,联合索引的左前缀规则依然有效。但要注意,全局联合索引第一个字段如果选择性不高,很容易让索引没有意义。比如我们常用status(状态)作为第一个字段,如果status只有0和1两个值,那么索引的区分度就很低。这种情况下,可以考虑把区分度高的字段放前面。

看一个优化示例:

-- 技术栈:OceanBase(MySQL模式)SQL

-- 这是低效索引:区分度低
CREATE INDEX `idx_status` ON `t_order` (`status`) GLOBAL;

-- 这是相对高效索引:先按order_no精确定位,再过滤status
CREATE INDEX `idx_order_status` ON `t_order` (`order_no`, `status`) GLOBAL;

当然,最终选择要看实际查询模式。规范是:让索引能帮你过滤掉90%以上的无效数据,否则就别建。

3.3 分页查询

分页是每个业务都会碰到的功能。在MySQL里,很多同学习惯用LIMIT offset, size。可是offset特别大的时候,MySQL会扫描前面所有行再丢弃,效率很低。OceanBase也一样,而且因为分布式,这个低效会被放大。

推荐的做法是:用"键集分页"(keyset pagination),也叫"游标分页"。不传OFFSET,而是传一个上一页最后一条记录的排序字段值,然后通过WHERE条件挑出下一页。

看示例:

-- 技术栈:OceanBase(MySQL模式)SQL

-- 传统分页实现(不推荐,offset过大时性能差)
SELECT id, nickname, create_time
FROM t_user
ORDER BY id
LIMIT 10000, 20;

-- 键集分页实现(推荐)
-- 假设上一页最后一条记录的id为10000
SELECT id, nickname, create_time
FROM t_user
WHERE id > 10000
ORDER BY id
LIMIT 20;

第二种写法利用主键索引直接跳到id大于10000的位置,只取20条,无论翻到第几页,耗时都很稳定。规范就是:任何分页查询,只要排序字段唯一(比如id),就一定要用键集分页。

如果排序字段不唯一,比如按create_time排序,那就在ORDER BY里带上主键作为第二排序条件,这样游标才稳定:

-- 技术栈:OceanBase(MySQL模式)SQL

-- 游标分页,以create_time + id作为联合游标
SELECT id, nickname, create_time
FROM t_user
WHERE (create_time, id) > ('2024-05-01 00:00:00', 10086)
ORDER BY create_time, id
LIMIT 20;

注意这里使用了元组比较,OceanBase支持这种写法。如果数据库不支持,也可以改成:

WHERE create_time > '2024-05-01 00:00:00'
   OR (create_time = '2024-05-01 00:00:00' AND id > 10086)

但第一种写法更简洁,我们推荐优先使用。

3.4 事务控制

OceanBase天生支持分布式事务,但应用层不能乱来。规范上有几个铁律:

铁律一:事务要短,不要包含远程调用。

一个事务里如果出现了HTTP请求、消息队列发送、甚至循环中的网络调用,都会让事务持有事务的锁时间过长,导致冲突概率飙升,性能直线下降。正确做法是,先把远程调用的数据准备好,再开启事务执行数据库操作。

铁律二:UPDATE、DELETE一定要带WHERE,并且WHERE要能走索引。

这个在MySQL里也是常识,但OceanBase的分布式事务里,如果UPDATE扫描了太多行,会把很多行加上锁,其他事务就会被阻塞。我们看一个危险示例和规范示例:

-- 技术栈:OceanBase(MySQL模式)SQL

-- 危险!全表更新,会导致大量行锁
UPDATE t_user SET status = 0;

-- 正确做法:按明确条件更新,且条件走索引
UPDATE t_user SET status = 0 WHERE id IN (1001, 1002, 1003);

如果确实需要批量更新少量数据,使用IN列表没毛病。如果数据多,可以分批次提交事务。

铁律三:使用乐观锁替代悲观锁。

在单机数据库上,很多人习惯用SELECT ... FOR UPDATE来锁行。在OceanBase里,这种锁一样有效,但在高并发下,锁等待和死锁的概率会更高。推荐用版本号方式实现乐观锁。

看示例:

-- 技术栈:OceanBase(MySQL模式)SQL

-- 假设t_user表有version字段
UPDATE t_user
SET nickname = '新昵称', version = version + 1
WHERE id = 10086 AND version = 12;
-- 如果影响行数为0,说明version不是12,说明数据已被别人改过,需要重试

这个方法利用version字段避免显式锁,遇到冲突就重试,在高并发下性能好很多。

3.5 避免分布式陷阱

OceanBase的SQL优化器和执行器都经过了深度改造,但分布式场景下有些天然陷阱,我们必须在编写时主动避开。

陷阱一:跨节点JOIN,特别是笛卡尔积。

如果两张表的数据都很大,且分布方式不同,JOIN时需要把一方的数据广播到另一方的所有节点,代价非常大。规范是:尽量在同一个租户下,通过主外键关联,并确保JOIN的字段是分布键(即主键),这样JOIN可以在本地完成。

看一个设计示例:

-- 技术栈:OceanBase(MySQL模式)SQL

-- 订单表以user_id作为主键和分布键
CREATE TABLE `t_order` (
    `user_id` BIGINT NOT NULL COMMENT '用户ID,同时也是分布键',
    `order_id` BIGINT NOT NULL COMMENT '订单ID',
    `amount` DECIMAL(10,2) NOT NULL COMMENT '金额',
    PRIMARY KEY (`user_id`, `order_id`)
) COMMENT '订单表,按user_id分布';

-- 订单明细表同样以user_id作为分布键,这样JOIN时数据就在本地
CREATE TABLE `t_order_item` (
    `user_id` BIGINT NOT NULL COMMENT '用户ID',
    `order_id` BIGINT NOT NULL COMMENT '订单ID',
    `item_id` BIGINT NOT NULL COMMENT '商品ID',
    `quantity` INT NOT NULL COMMENT '数量',
    PRIMARY KEY (`user_id`, `order_id`, `item_id`)
) COMMENT '订单明细表,与订单表同分布';

实际查询时,JOIN条件里带上user_id,就能利用分布键执行本地JOIN:

-- 技术栈:OceanBase(MySQL模式)SQL

SELECT o.order_id, i.item_id, i.quantity
FROM t_order o
JOIN t_order_item i ON i.user_id = o.user_id AND i.order_id = o.order_id
WHERE o.user_id = 10086;

陷阱二:大表全量扫描做聚合。

很多报表查询经常对全表做SUM、COUNT。在OceanBase里,全表扫描会拆成多个子任务并行执行,性能其实不差,但数据量到了几十亿行,全表扫描依然很费资源。规范是:利用预聚合表或周期汇总表,避免实时扫描大表。或者使用OceanBase的物化视图功能。但物化视图有个刷新周期,需要根据业务容忍度来选择。

陷阱三:不恰当的SQL函数写在WHERE条件里。

比如WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2024-05-01',这会让索引失效,OceanBase只能全表扫。更好的写法是范围查询:

-- 技术栈:OceanBase(MySQL模式)SQL

-- 低效写法
SELECT * FROM t_order WHERE DATE(create_time) = '2024-05-01';

-- 高效写法
SELECT * FROM t_order
WHERE create_time >= '2024-05-01 00:00:00'
  AND create_time < '2024-05-02 00:00:00';

四、应用适配技巧

4.1 连接管理

OceanBase是多租户的,应用连数据库时,连接串里需要指定租户名。比如:

-- 技术栈:OceanBase(MySQL模式)SQL

-- 这是连接OceanBase的JDBC URL示例,不是SQL语句,但作为配置参考
-- jdbc:oceanbase://10.0.0.1:2881/my_db?tenant=my_tenant&user=app_user&password=xxxx

实际上,OceanBase提供了两种连接方式:一种是直连某个节点的地址,另一种是通过负载均衡地址。如果是云上环境,一般给一个SLB地址。规范是:应用层必须使用连接池,而且连接池的最小空闲连接数不要设太大,因为OceanBase的Proxy(如果使用OBProxy)对长连接有管理机制,空闲连接太多会占用资源。

还要注意,连接串中的版本号要设置正确,不然OceanBase可能以兼容模式处理一些语句,导致非预期行为。

4.2 批量写入

前面说了OceanBase的LSM-Tree对写入很友好,但应用层的写法也有讲究。千万不要在循环里一条一条INSERT,那样会产生大量小事务,每个事务都要走提交日志,性能很差。正确做法是使用批量INSERT语句。

看一个批量插入的示例:

-- 技术栈:OceanBase(MySQL模式)SQL

-- 一条INSERT插入多行,值用逗号分隔
INSERT INTO t_user (id, nickname, age, email, status, create_time)
VALUES
(1, 'Alice', 20, 'alice@example.com', 1, NOW()),
(2, 'Bob', 25, 'bob@example.com', 1, NOW()),
(3, 'Carol', 30, 'carol@example.com', 1, NOW());
-- 每批建议不超过1000行,避免事务日志过大

如果你用的编程语言,比如Java的MyBatis,也可以使用<foreach>标签拼批量插入,但要注意MySQL连接默认的rewriteBatchedStatements=true参数,这个参数在OceanBase JDBC驱动里也支持。开启后,JDBC会把一批addBatch()合成的多条INSERT重写为一条多VALUES语句,性能成倍提升。

4.3 分区表的使用

OceanBase对分区表的支持非常灵活。使用分区表可以提高大表的查询和管理效率。分区方式有RANGE、LIST、HASH等。规范是:如果表的数据量级超过千万行,建议使用分区表,并且按业务访问模式选择分区键。

比如,订单表按创建时间做RANGE分区,查询最近一个月的数据时,OceanBase可以精准定位到某个分区,大大减少扫描量。

看示例:

-- 技术栈:OceanBase(MySQL模式)SQL

CREATE TABLE `t_order_hist` (
    `order_id` BIGINT NOT NULL,
    `user_id` BIGINT NOT NULL,
    `status` TINYINT NOT NULL,
    `create_time` DATETIME NOT NULL,
    PRIMARY KEY (`order_id`, `create_time`)  -- 注意分区键必须包含在主键里
) COMMENT '订单历史表,按月分区'
PARTITION BY RANGE (TO_DAYS(`create_time`)) (
    PARTITION p202401 VALUES LESS THAN (TO_DAYS('2024-02-01')),
    PARTITION p202402 VALUES LESS THAN (TO_DAYS('2024-03-01')),
    PARTITION p202403 VALUES LESS THAN (TO_DAYS('2024-04-01')),
    PARTITION pMax VALUES LESS THAN MAXVALUE
);

这里有个重要规范:分区键必须包含在主键或唯一键里,否则OceanBase无法保证数据的全局唯一性。这是很多从MySQL迁移过来的同学最容易犯的错。MySQL允许分区键不包含在主键里,但OceanBase不允许。

应用查询时,如果你的WHERE条件带上了分区键,OceanBase会自动进行分区裁剪,只扫描对应的分区。所以,所有按分区键的查询都应该强制带上分区键字段。比如:

-- 技术栈:OceanBase(MySQL模式)SQL

-- 按分区键查询,直接裁剪到p202404
SELECT * FROM t_order_hist
WHERE create_time >= '2024-04-01'
  AND create_time < '2024-05-01'
  AND user_id = 10086;

五、注意事项与踩坑记录

这一节专门整理一些我们在实际开发中遇到过的坑,希望帮大家避雷。

第一坑:AUTO_INCREMENT与分布式高并发不匹配。

OceanBase的AUTO_INCREMENT在单机租户里可以当普通自增用,但在分布式模式下,它是通过一个全局序列来实现的,多节点并发插入时会产生大量的跨节点通信。如果业务早期数据量不大,问题不明显;一旦流量上来,自增ID很可能成为写入瓶颈。我们建议所有业务表的主键改用应用层生成的雪花ID或底层使用UUID转BIGINT。如果一定要用数据库自增,那么要控制并发量,并且明确知道扩展性会受限。

第二坑:ODBC/JDBC驱动版本太老。

OceanBase的JDBC驱动一直在演进,早期版本对批量INSERT、prepareStatement的优化支持不完善。如果你在生产环境遇到性能异常,不妨先检查驱动版本。规范是:统一使用官方推荐的最新稳定版本驱动,并在POM中固定版本,不要使用依赖传递的版本。

第三坑:不指定SHOW VARIABLES里的ob_trx_idle_timeout。

这个参数控制事务空闲超时时间,默认可能只有60秒。如果应用开启了一个事务后,迟迟没有提交,比如在事务里做了很慢的外部HTTP调用,事务会被OceanBase在一段时间后自动回滚。你的代码可能还没意识到,继续执行下一步操作,结果发现数据丢了。规范是:把事务超时参数调大(根据业务情况调整),更重要的是,不要在事务里做任何非数据库操作。

第四坑:全局索引和主键的分布策略不一致。

创建全局索引后,索引数据会按照索引键单独分布,所以索引查询很快,但更新主表时,需要同步更新全局索引,这会增加写放大。如果一个表写入频繁,且查询不是主要负载,可以牺牲全局索引,改用局部索引。规范是:写入多、查询少的表,尽量用局部索引;查询多、写入少的表,才用全局索引。

第五坑:内存引擎的LIMIT与OFFSET。

有人觉得OceanBase基于LSM,OFFSET分页应该比MySQL好点。其实并不然。OFFSET的语义决定了它必须扫描并丢弃前N行,无论存储引擎如何优化,这个开销都省不掉。所以再次强调:分页必须用游标方式。

第六坑:大事务导致的全局快照过期。

OceanBase的多版本并发控制依赖于全局递增版本号。如果一个事务执行时间过长,超过查询的版本保留时间,会导致快照过旧无法读取,直接报错。规范上要求单事务执行时间尽量控制在几百毫秒内,不要超过1秒。如果业务上有批量操作,拆成多个小事务,每个小事务处理几百行,中间可以做些休息,让系统喘口气。

第七坑:字符集和排序规则不统一。

MySQL模式下的OceanBase默认字符集可能是utf8mb4,但有些租户可能被创建成utf8mb3。如果多张表之间字符集不一致,JOIN时索引可能失效。规范是:对每个库、表、字段显式指定相同的字符集和排序规则。比如:

-- 技术栈:OceanBase(MySQL模式)SQL

CREATE TABLE `t_user` (
    `nickname` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT ''
) COMMENT '用户表,强制使用utf8mb4_bin排序规则';

使用utf8mb4_bin会使用二进制比较,速度比utf8mb4_general_ci更快,而且区分大小写,适合用于用户名、邮箱等字段。如果你需要忽略大小写,就统一用utf8mb4_general_ci,但不要混用。

六、文章总结

OceanBase不是简单的"MySQL换壳",它的分布式架构带来了扩展性和高可靠性,同时也对SQL编写提出了新的要求。本文提到的规范可以浓缩成四个关键词:

  • 主键优先:显式设计简单的主键,尽量用雪花ID。
  • 索引全局:辅助索引优先使用GLOBAL,避免跨节点扫描。
  • 事务短小:保持事务简短,避免远程调用和大范围更新。
  • 游标分页:用键集分页替代OFFSET分页。

这些规范不是死教条,具体场景还要结合执行计划来调整。OceanBase提供了EXPLAIN命令,你可以用它观察SQL的执行计划,看看是不是走了全局索引,是不是发生了分区裁剪,是不是有跨节点广播。把EXPLAIN养成习惯,比死记规范有用一百倍。

应用适配方面,连接管理、批量写、分区表都需要在日常开发中持续打磨。如果团队刚开始从MySQL迁移,建议把本文中的规范做成Checklist,嵌入到代码评审流程里。每一条踩坑经验都是用线上事故换来的,希望你能少走弯路。

最后,记住一点:数据库规范是为业务服务的,不是业务为规范服务。当规范与业务产生冲突时,拿出EXPLAIN和监控数据,用事实说话。