说实话,在接触国产数据库之前,我一直没太在意过行标识符这回事。不就是主键嘛,建个ID字段不就完事了?结果后来有次做Oracle迁移项目,才被现实狠狠打脸。那个项目里大量存储过程都在用ROWID做游标定位,一查才知道Oracle的ROWID是物理地址,性能贼快,但迁移到其他数据库就懵了——完全不是一回事儿。后来接触KingbaseES(后面就简称KES吧),发现它搞了个OID加ROWID双轨制,挺有意思的,就花时间研究了一下。这篇文章主要聊聊OID,下篇再说ROWID。
从对象标识说起——OID的前世今生
要说OID这东西,得先从数据库对象管理说起。我记得刚入行那会儿,看到系统表里各种OID字段,还以为是什么高大上的东西。后来才知道,这玩意儿就是个4字节的无符号整数,PostgreSQL很早就引入了,专门给系统表用的。KES继承了这套东西,但在实现上又做了些调整,导致好多从PG转过来的人都会掉坑里——我也是其中一个。
OID到底是全局的还是局部的?
这个问题争议挺大的,我记得在某个技术群里还吵过一架。早期的PG文档里说OID是全局唯一的,在整个数据库实例里不会重复。但KES的R6版本改了逻辑,系统表的OID确实全局唯一,但普通表的OID变成了局部计数,每个表从1开始自己数。这个改动当时可把我坑惨了。
看段代码就明白了:
-- 先看系统表的OID,明显不是从1开始的
test=# select oid,typname from sys_type where oid > 33990;
oid | typname
-------+------------
33992 | aatyp
33993 | func_test05
33995 | ...
(3 rows)
-- 新建个类型和函数,OID连续递增
cpbd_test=> create type aatyp is table of int;
CREATE TYPE
cpbd_test=> select oid,typname from sys_type where typname = 'aatyp';
oid | typname
-------+---------
55136 | aatyp
(1 row)
cpbd_test=> create or replace function func_test05(i int) return int as
begin return 1; end;
CREATE FUNCTION
cpbd_test=> select oid,proname from sys_proc where proname = 'func_test05';
oid | proname
-------+-------------
55137 | func_test05
(1 row)
看到了吧,系统对象的OID是全局递增的,55136后面紧跟着55137。这个还算正常。但普通表就不一样了:
-- 默认情况下普通表压根没有OID
test=# create table tt5(id int);
CREATE TABLE
test=# insert into tt5 values(10);
INSERT 0 1
test=# select oid,id from tt5;
ERROR: column "oid" does not exist
-- 开启参数后才有,而且每个表从1开始
test=# set default_with_oids to true;
SET
test=# create table tt6(id int);
CREATE TABLE
test=# insert into tt6 values(10);
INSERT 0 1
test=# select oid,id from tt6;
oid | id
-----+----
1 | 10
(1 row)
test=# create table tt7(id int) with oids;
CREATE TABLE
test=# insert into tt7 values(10);
INSERT 0 1
test=# select oid,id from tt7;
oid | id
-----+----
1 | 10
(1 row)
tt6和tt7的OID都从1开始,这就证明了普通表的OID是局部的。我当时看到这个结果,第一反应是"这也行?"。这个设计改动很多人不理解,觉得为什么不跟PG保持一致?其实仔细想想也有道理,全局OID意味着要维护一个全局计数器,高并发下会有性能瓶颈——这个应该算是权衡后的选择吧。改成局部计数,每个表自己管自己,并发性能好多了,就是失去了全局唯一性。说实话,我个人觉得这个改动有点可惜,毕竟全局唯一性在某些场景下还挺有用的。
OID的历史演进——从默认到逐步弃用
说起来,OID这个东西在PG的发展历程里也经历了不少变化。早期版本(8.0之前)所有用户表默认都带OID列,每行数据都有一个,可以直接用SELECT * FROM table WHERE oid = 12345这种写法。那时候设计初衷是用OID来标识实例中所有数据库对象,包括用户数据表中的元组。
但到了PG 8.0(2005年),情况就变了。官方默认不再给用户表添加oid列,想用的话得显式指定WITH OIDS。为啥要改呢?主要是几个问题:
安全性问题——OID单调递增,攻击者可以通过观察OID值来推测数据量、插入时间这些敏感信息。我之前有个项目,用户就是通过观察OID发现了我们系统的真实数据量,搞得挺被动。
存储开销——每行多4字节,看着不多,但对于宽表或者大数据量的表来说,这个开销就有点可观了。
复制兼容性——在逻辑复制或者主从切换的时候,OID可能会出现重复的情况,这个坑我踩过好几次。
再到后来,PG 12(2019年)干脆把WITH OIDS语法都给废弃了,用户表彻底不能用OID了。官方的考虑很明确:OID应该只用在系统表上,用户表还是用显式的主键或者SERIAL更靠谱。
KES呢,为了兼容旧版本和Oracle迁移需求,保留了OID支持,还搞了个ROWID。这个双轨制的设计挺有意思的,但也带来了一些认知上的混乱——到底是该用OID还是ROWID?这个问题我们下篇再细说。
regclass——OID的语法糖
不过OID有个好处,就是可以用regclass类型简化查询。啥意思呢?看这个例子:
-- 想查teachers表的字段信息,传统写法要关联sys_class
test=# SELECT attrelid,attname,atttypid
FROM sys_attribute
WHERE attrelid = (SELECT oid FROM sys_class WHERE relname = 'teachers');
-- 用regclass就简单多了
test=# SELECT attrelid,attname,atttypid
FROM sys_attribute
WHERE attrelid = 'teachers'::regclass;
'teachers'::regclass这写法本质上就是select oid from sys_class where relname='teachers',但代码简洁多了,看着也舒服。KES里这种类型转换还挺常用的,特别是查系统表的时候。我之前写过一个元数据查询工具,用了不少这个语法糖,省了很多代码。
实际上,regclass这类OID别名类型还有一些有趣的特性。比如当你在一个存储表达式(像列默认值或视图定义)中使用'my_seq'::regclass这样的常量时,系统会自动创建对该序列对象的依赖关系。这意味着你不能直接删除被引用的序列,除非先去掉这个依赖。这个机制挺智能的,避免了因为误删对象导致的一堆报错。
OID在系统表中的应用细节
既然OID主要是给系统表用的,那我们就来深入看看它在系统表里是怎么发挥作用的。
sys_class——数据库对象的"户口本"
sys_class是KES里最核心的系统表之一,记录了几乎所有的数据库对象(表、索引、视图、序列等)的元数据。它的oid字段就是这些对象的唯一标识。
-- 查看所有表对象的OID和名称
test=# select oid,relname,relkind from sys_class
where relkind = 'r' and relname like 't%' limit 5;
oid | relname | relkind
-------+---------+---------
24903 | tt1 | r
33139 | tt5 | r
33346 | table_t1| r
33664 | tbl_time| r
33726 | t1 | r
(5 rows)
这里有个小技巧:oid本身是数字,不太好记,但通过::regclass转换就能看到对象名称:
test=# select 16700::regclass;
regclass
----------
teachers
(1 row)
sys_type和sys_proc——类型和函数的OID管理
类型系统和函数系统也重度依赖OID。每个数据类型、自定义类型、函数(包括重载版本)都有唯一的OID:
-- 查看内置类型的OID
test=# select oid,typname from sys_type where typname in ('int4','text','varchar');
oid | typname
------+----------
23 | int4
25 | text
1043 | varchar
(3 rows)
-- 函数重载,OID不同
test=# create function my_func(int) returns int as 'select 1' language sql;
CREATE FUNCTION
test=# create function my_func(text) returns text as 'select ''a''' language sql;
CREATE FUNCTION
test=# select oid,proname from sys_proc where proname = 'my_func';
oid | proname
-------+---------
55140 | my_func
55141 | my_func
(2 rows)
这个OID机制支撑了PG/KES强大的扩展性和函数重载能力。函数执行计划缓存、权限控制、类型匹配等都依赖OID来实现。
OID与ctid的关系和区别
说到行标识符,除了OID,还有一个ctid(元组标识符)也很重要。ctid表示的是行的物理位置,格式是(块号,行号),比如(0,1)表示第0个数据块的第1行。
OID和ctid有本质区别:
- OID是逻辑标识,系统对象全局唯一,普通表局部唯一
- ctid是物理地址,会随着VACUUM、UPDATE等操作改变
test=# select oid,ctid,* from tt6;
oid | ctid | id
-----+-------+----
1 | (0,1) | 10
(1 row)
-- 更新后ctid会变
test=# update tt6 set id = 20 where id = 10;
UPDATE 1
test=# select oid,ctid,* from tt6;
oid | ctid | id
-----+-------+----
1 | (0,2) | 20
(1 row)
可以看到,UPDATE后OID没变(还是1),但ctid从(0,1)变成了(0,2),因为PG/KES的UPDATE本质是标记旧行删除、插入新行。所以ctid不能作为长期引用的标识,只能在当前事务或短时间内使用。
OID与其他数据库主键机制对比
了解了OID的特点后,我们再看看其他数据库是怎么处理行标识的。
Oracle的ROWID
Oracle的ROWID是物理地址,包含文件号、块号、行号等信息,定位速度极快,可以直接跳过索引查找。但问题是:
- 物理依赖性强——ROWID会在表移动、导出导入、分区重组等操作后改变
- 不可跨数据库——只有Oracle有这个概念,其他数据库都没有
- 索引组织表特殊——IOT表使用的是逻辑ROWID,基于主键编码
我之前那个Oracle迁移项目,大量存储过程用ROWID做游标定位,迁移到KES后这些逻辑都得重写,因为ROWID机制完全不同。
MySQL的自增主键
MySQL(InnoDB)用的是聚簇索引,主键就是行的物理存储位置,没有单独的行标识符概念。这个设计其实挺简洁的:
- 主键查询效率高(直接定位到数据页)
- 节省存储空间(不需要额外的行ID字段)
- 但必须显式定义主键,否则会用隐藏的6字节ROW_ID
KES的双轨制设计
KES比较特殊,既有OID(继承自PG),又引入了ROWID(兼容Oracle):
- OID:系统表专用,4字节,会回卷,不推荐业务表使用
- ROWID:逻辑标识,23字节字符串,单调递增,更适合业务场景
这个设计既保持了PG兼容性,又照顾了Oracle迁移需求,但也增加了理解成本。
OID的局限性——坑在哪里
但OID也有坑,最大的问题就是会回卷。4字节整数最大值42亿,超过了就从头开始。虽然官方说建个唯一索引能防止重复,但你想啊,表数据要是上了十亿级别,每次插入都要检查OID是否重复,性能得多差?我之前有个项目,表里大概有5亿条数据,用OID的时候插入速度慢得要命,后来改成显式主键才解决。
-- 官方建议:超过20亿条数据别用OID
-- 要用bigserial或者显式主键
test=# create table big_table(
id bigserial primary key,
data text
);
所以OID这东西,系统表里用用还行,业务表就别指望了。我记得有次跟同事讨论这个问题,他说"那我用OID做业务主键不就行了?"我说"行是行,但你确定你的表永远不超过20亿条数据?"他愣了一下,然后就默默改设计方案了。
还有一个坑,就是OID在某些情况下会被重用。比如你删了一行数据,OID可能会被新的数据占用。这个在需要长期保存引用的场景下是个大问题,虽然可以通过加约束来避免,但总觉得不太靠谱。
高并发场景下的性能问题
全局OID虽然好理解,但在高并发场景下确实有性能瓶颈。我记得有个电商项目,日均插入量在千万级别,一开始用了OID(Oracle迁移过来的老代码),结果发现插入性能越来越差。
排查后发现,问题出在OID分配上:
- 全局OID计数器需要加锁
- 高并发插入时,锁竞争严重
- 虽然PG/KES有预分配机制(每次分配8192个OID),但仍然不够用
后来改成了局部OID(每个表独立计数),性能好了不少,但也就失去了全局唯一性的优势。这就是个权衡取舍的问题。
从技术演进角度的思考
回顾OID的发展历程,其实能看出数据库设计理念的变化:
早期(90年代):面向对象思潮盛行,OID被设计成所有对象的统一标识,寄希望于实现真正的对象数据库。
中期(2000年代):实践发现OID在用户表上有诸多问题(安全性、性能、可移植性),开始逐步弱化,只保留在系统表层面。
现代(2010年代后):随着分布式系统的发展,全局唯一ID的需求更加突出,但用的是UUID、Snowflake等更现代的方案,而不是4字节的OID。
KES在这个演进过程中处于一个有趣的位置:既要保持PG兼容性(保留OID),又要支持Oracle迁移(引入ROWID),还要适应现代业务需求。这个平衡其实挺难把握的。
我个人觉得,KES R6把普通表的OID改成局部计数是个务实的选择。虽然失去了全局唯一性,但在高并发场景下性能更好,而且现代应用本来就不太依赖OID做业务标识。系统表保留全局OID则保证了元数据管理的一致性,这个设计还算合理。
下篇预告:既然OID有这么大的局限性,KES又是如何解决行标识符问题的呢?下一篇文章我们会深入探讨ROWID机制——这是KES为了兼容Oracle而设计的另一套行标识方案。说实话,ROWID的设计思路挺有意思的,跟Oracle的实现完全不一样,而且还有不少实战技巧。此外,还会介绍元数据访问、参数治理的坑,以及几个我实际遇到过的场景。敬请期待!