行标识符机制的技术演进与实践(上)——从OID说起

0 阅读13分钟

兼容 是对前人努力的尊重 是确保业务平稳过渡的基石 然而 这仅仅是故事的起点

说实话,在接触国产数据库之前,我一直没太在意过行标识符这回事。不就是主键嘛,建个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是物理地址,包含文件号、块号、行号等信息,定位速度极快,可以直接跳过索引查找。但问题是:

  1. 物理依赖性强——ROWID会在表移动、导出导入、分区重组等操作后改变
  2. 不可跨数据库——只有Oracle有这个概念,其他数据库都没有
  3. 索引组织表特殊——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分配上:

  1. 全局OID计数器需要加锁
  2. 高并发插入时,锁竞争严重
  3. 虽然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的实现完全不一样,而且还有不少实战技巧。此外,还会介绍元数据访问、参数治理的坑,以及几个我实际遇到过的场景。敬请期待!