行标识符机制的技术演进与实践(下)——ROWID与实战应用

0 阅读13分钟

@[toc]


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

在上一篇文章中,我们深入探讨了OID机制,了解到KES的OID有全局和局部之分,还知道了它最大的坑就是回卷问题。说实话,OID这东西在系统表里挺好用的,但到了业务表就捉襟见肘了。我之前有个项目,就是因为用OID做业务主键,结果表数据一上来各种问题。那么问题来了,既然OID有这么多局限,KES又是如何解决行标识符问题的呢?答案就是ROWID——KES为了兼容Oracle而设计的另一套方案。

ROWID——兼容性与性能的平衡

聊完OID,再说说ROWID。这玩意儿是KES为了兼容Oracle搞出来的,但实现思路跟Oracle完全不一样。我记得第一次看到KES的ROWID实现时,还愣了一下——怎么跟Oracle差这么多?

Oracle的ROWID vs KES的ROWID

Oracle的ROWID是实打实的物理地址,包含文件号、块号、行号这些信息,直接能定位到磁盘上的位置,性能极其快。我之前做Oracle项目的时候,用ROWID做查询,那速度确实没得说。但有个问题,物理地址会变——表重建了、行迁移了,ROWID就变了,不能当长期标识用。这个坑我也踩过,当时以为ROWID是稳定的,结果做数据同步的时候各种对不上。

KES的ROWID不一样,它是逻辑标识,基于事务ID生成的:

-- 开启ROWID支持
test=# set default_with_rowid = true;
SET
test=# create table student(
  sno int, 
  name varchar(10), 
  birthday date
);
CREATE TABLE
test=# insert into student values(1, 'li', '2018-1-1');
INSERT 0 1
test=# select rowid, * from student;
          rowid          | sno | name |      birthday
-------------------------+-----+------+---------------------
 AAAAAAAAADQCAAAAAAAAAAA |   1 | li   | 2018-01-01 00:00:00
(1 row)

这个ROWID是23个字符的Base64编码,看起来挺长,实际存储只有4-18字节。它的组成是这样的:

  • 前6位:事务回卷次数(0-5)
  • 中间6位:事务XID
  • 后11位:事务内插入的行号

说实话,刚看到这个设计的时候我觉得挺有意思的,居然是用事务ID来生成。

-- 同一事务里插入多行,ROWID递增
test=# insert into student values
  (2, 'wang', '2018-1-2'),
  (3, 'zhang', '2018-1-3');
INSERT 0 2
test=# select rowid, sno, name from student;
          rowid          | sno | name
-------------------------+-----+-------
 AAAAAAAAADQCAAAAAAAAAAA |   1 | li
 AAAAAAAAADQCAAAAAAAAAAB |   2 | wang
 AAAAAAAAADQCAAAAAAAAAAC |   3 | zhang
(3 rows)

看到了吧,最后几个字符从AAA变成AAB、AAC,说明是同一个事务里的第二、三行。这个设计有个好处,ROWID单调递增,能用来排序。Oracle的ROWID就没这个特性,因为它是物理地址,没有顺序概念。不过话说回来,实际业务中用ROWID排序的场景好像也不多,主要是用来做唯一标识。

参数互斥——一个容易踩的坑

KES里default_with_rowid和default_with_oids这两个参数是互斥的,而且ROWID优先级更高。这个设计我当时研究了好一会儿才搞明白:

-- 两个参数都开着
test=# show default_with_oids;
 default_with_oids
-------------------
 on
(1 row)

test=# show default_with_rowid;
 default_with_rowid
--------------------
 on
(1 row)

-- 实际生效的是ROWID
test=# create table tt11(id int);
CREATE TABLE
test=# insert into tt11 values(10);
INSERT 0 1
test=# select rowid, id from tt11;
          rowid          | id
-------------------------+----
 AAAAAAAAADQCAAAAAAAAAAA | 10
(1 row)

test=# select oid, id from tt11;
ERROR:  column "oid" does not exist

但有个反直觉的情况,如果default_with_oids开着、default_with_rowid关着,想在DDL里强制建ROWID表会报错:

test=# set default_with_rowid to false;
SET
test=# set default_with_oids to true;
SET
test=# create table tt16(id int) with rowid;
ERROR:  can not create table with rowid (default_with_rowid is false, and default_with_oids is true)!

这个报错信息挺让人困惑的,为啥全局参数开着就不让建?我后来想想,KES的设计思路大概是:要么全局用OID,要么全局用ROWID,两种机制混用容易出问题。虽然可以理解,但迁移的时候确实得小心,特别是那些只用部分表需要ROWID的场景。我当时就遇到过一个项目,大部分表用OID,个别表要迁移Oracle的ROWID逻辑,折腾了好久。

ROWID类型——还能当列来用

KES还支持把ROWID当数据类型用,这功能挺有意思的,我之前都没注意到:

test=# create table rowid_tt1(id rowid);
CREATE TABLE
test=# insert into rowid_tt1 values('AAAAAAAAAAABAAAAAAAAAAA');
INSERT 0 1
test=# select * from rowid_tt1;
           id
-------------------------
 AAAAAAAAAAABAAAAAAAAAAA
(1 row)

-- 可以比较大小
test=# insert into rowid_tt1 values('AAAAAAAAAAABAAAAAAAAAAB');
INSERT 0 1
test=# insert into rowid_tt1 values('AAAAAAAAAAABAAAAAAAAAAC');
INSERT 0 1
test=# select * from rowid_tt1 where id >= 'AAAAAAAAAAABAAAAAAAAAAB';
           id
-------------------------
 AAAAAAAAAAABAAAAAAAAAAB
 AAAAAAAAAAABAAAAAAAAAAC
(2 rows)

不过要注意,ROWID类型只支持23个字符,多了少了都不行:

test=# insert into rowid_tt1 values('AAAAAAAAAAABAAAAAAAAAAA44444444');
ERROR:  invalid input syntax for type rowid: "AAAAAAAAAAABAAAAAAAAAAA44444444"

test=# insert into rowid_tt1 values('AAAAAAAAAAABAAAAA');
ERROR:  invalid input syntax for type rowid: "AAAAAAAAAAABAAAAA"

这个特性在数据迁移时挺有用的,可以把Oracle导出的ROWID直接存进来,虽然逻辑意义不一样,但至少不会报错。我之前有个迁移项目,Oracle那边大量使用ROWID做关联,迁移到KES的时候这个功能帮了大忙。

元数据访问——系统表的那些事儿

说到行标识符,就不得不提系统表。KES的系统表设计有些细节不一样。我记得刚接触KES的时候,还专门花时间研究了这些系统表,毕竟要做一些数据库管理工具。

sys_class——对象的大管家

所有数据库对象(表、索引、视图、序列)的信息都在sys_class里,OID就是它的主键:

test=# select oid,relname,relkind from sys_class 
        where relname like 't%' limit 10;
  oid  |    relname     | relkind
-------+----------------+---------
 16700 | teachers       | r
 16706 | teachers_pkey  | i
 16708 | teachers_copy  | r
 24903 | tt1            | r
 16733 | ttab           | r
(10 rows)

relkind字段表示对象类型:r是普通表,i是索引,v是视图,S是序列。通过OID关联,能查到对象的详细信息:

-- 查表的字段
test=# select attname,atttypid,attnum 
        from sys_attribute 
        where attrelid = 16700;
   attname    | atttypid | attnum
--------------+----------+--------
 tableoid     |       26 |     -6
 cmax         |       29 |     -5
 xmax         |       28 |     -4
 cmin         |       29 |     -3
 xmin         |       28 |     -2
 ctid         |       27 |     -1
 teacher_id   |     1700 |      1
 teacher_name |     1043 |      2
(8 rows)

注意前面几个负数字段,tableoid、cmax、xmax这些,都是系统字段,平时查select *看不到,但确实存在。ctid就是物理位置标识,格式是(块号,行号),比如(0,1)表示第0块第1行。这个ctid在某些优化场景下挺有用的,比如避免全表扫描。

sys_type——类型系统

KES的类型信息在sys_type里,包括基本类型、复合类型、域类型等:

test=# select oid,typname,typtype from sys_type 
        where typname in ('int4','varchar','rowid');
  oid  | typname | typtype
-------+---------+---------
    23 | int4    | b
   1043 | varchar | b
  34010 | rowid   | b
(3 rows)

typtype='b'表示基本类型,'c'是复合类型,'d'是域类型。创建自定义类型也会在sys_type里登记:

test=# create type address_type as (
  city varchar(50),
  street varchar(100)
);
CREATE TYPE
test=# select oid,typname,typtype from sys_type 
        where typname = 'address_type';
  oid  |    typname    | typtype
-------+---------------+---------
 34567 | address_type  | c
(1 row)

这些元数据在数据库逆向工程、迁移工具开发时特别有用,但要注意,系统表结构可能随版本变化,别写死了。我之前有个工具就是写死了系统表字段,结果KES升级后全挂了,后来改成动态查询才解决。

兼容性治理——参数配置的艺术

KES在兼容Oracle方面做了很多工作,但也引入了复杂的参数体系。有些参数必须初始化时设置,有些可以运行时修改,搞混了会很麻烦。说实话,我第一次看到这么多参数的时候头都大了。

关键GUC参数一览

-- 大小写敏感,只能initdb时设置
test=# show enable_ci;
 enable_ci
-----------
 off
(1 row)

-- ROWID支持,可以运行时改
test=# show default_with_rowid;
 default_with_rowid
--------------------
 off
(1 row)

-- OID支持,可以运行时改
test=# show default_with_oids;
 default_with_oids
-------------------
 off
(1 row)

enable_ci这个参数要特别注意,只能在initdb时设置。如果应用对大小写敏感,必须初始化时关掉,否则后期没法改。我之前有个项目就因为这个参数设置错误,导致后面各种问题,最后不得不重建数据库。

还有个坑是enable_func_colname,开了之后查询结果的列名会包含函数参数:

test=# set enable_func_colname=on;
SET
test=# select sum(1+2);
 sum(1+2)
----------
        3
(1 row)

test=# set enable_func_colname=off;
SET
test=# select sum(1+2);
 sum
-----
   3
(1 row)

这个参数在迁移Oracle应用时可能有用,但也会导致某些ORM框架识别不了列名,得权衡利弊。我记得有个Spring项目就因为这个参数,MyBatis映射全乱了。

参数冲突处理

前面说过ROWID和OID互斥,实际处理逻辑是这样:

  1. 如果default_with_rowid=true,优先创建ROWID隐含列
  2. 如果default_with_rowid=false但default_with_oids=true,创建OID隐含列
  3. DDL中with rowid的优先级高于全局参数
-- 全局关着,但建表时指定
test=# set default_with_rowid to false;
SET
test=# set default_with_oids to false;
SET
test=# create table t1(id int) with rowid;
CREATE TABLE
test=# \d t1
            Table "public.t1"
 Column |  Type   | Collation | Nullable | Default
--------+---------+-----------+----------+---------
 id     | integer |           |          |
Indexes:
    "t1_rowid_key" UNIQUE CONSTRAINT, btree ("rowid")

-- 全局开着,建表时指定OID会被忽略
test=# set default_with_rowid to true;
SET
test=# create table t2(id int) with oids;
CREATE TABLE
test=# select rowid, id from t2 limit 1;
          rowid          | id
-------------------------+----
 AAAAAAAAADQCAAAAAAAAAAA |  1
(1 row)

这种参数优先级设计虽然合理,但容易让人误以为with oids会生效,实际测试验证才是正道。我之前就犯过这个错,以为建表时指定with oids就能用OID,结果运行时发现用的是ROWID,还好及时测试发现了。

实战场景——ROWID能干啥

理论说了半天,实际项目中ROWID能干嘛?说几个我遇到的场景,应该能给大家点启发。

场景一:批量删除优化

大表删除是个头疼事,直接delete where status='inactive'可能锁表锁半天。用ROWID分批处理就好多了:

-- 开启ROWID
set default_with_rowid = true;

-- 建测试表
create table log_table(
  id serial,
  log_time timestamp,
  status varchar(20)
) with rowid;

-- 插入100万测试数据
insert into log_table(log_time, status)
select now() - (random() * 365 || ' days')::interval,
       case when random() > 0.3 then 'ACTIVE' else 'INACTIVE' end
from generate_series(1, 1000000);

-- 分批删除
do $$
declare
  batch_size int := 10000;
  deleted_count int;
begin
  loop
    delete from log_table
    where rowid in (
      select rowid from log_table
      where status = 'INACTIVE'
      limit batch_size
    );
    
    get diagnostics deleted_count = row_count;
    exit when deleted_count = 0;
    
    commit;
  end loop;
end $$;

这个方法的好处是每次只删1万条,锁表时间短,不会影响其他业务。ROWID有索引,查起来也快。我之前有个日志表,累积了几千万条数据要清理,用这个方法跑了一晚上就搞定了。

场景二:游标更新

Oracle里常用的CURRENT OF游标更新,KES可以用ROWID替代:

-- Oracle写法
/*
declare
  cursor c1 is select * from emp for update;
begin
  for rec in c1 loop
    update emp set sal = sal * 1.1 
    where current of c1;
  end loop;
end;
*/

-- KES写法
declare
  cursor c1 is select rowid, * from emp for update;
  v_rowid rowid;
begin
  for rec in c1 loop
    v_rowid := rec.rowid;
    update emp set sal = sal * 1.1 
    where rowid = v_rowid;
  end loop;
end;

虽然多查了个rowid字段,但逻辑清晰,也避免了CURRENT OF在某些场景下失效的问题。这个我在迁移存储过程的时候用过,效果还不错。

场景三:并发控制

用ROWID做乐观锁,比版本号字段简洁:

-- 查询时缓存ROWID
select id, name, rowid from user_profile where user_id = 1001;

-- 更新时验证
update user_profile
set score = 90
where user_id = 1001 and rowid = 'AAAAAAAAADQCAAAAAAAAAAA';

-- 如果影响行数为0,说明数据已被他人修改
-- 应用层可以提示用户刷新

这种写法不用额外加version字段,利用ROWID自带的唯一性做校验,适合简单场景。但要注意,ROWID在DELETE后可能被重用,高并发场景还是要用version字段更稳妥。我之前有个低频更新的配置表,就用这个方法,简化了不少代码。

一些思考

研究完这些机制,感觉KES的设计团队是在做一道平衡题。一方面要兼容Oracle生态,让迁移成本尽量低;另一方面又要保持技术独立性,不能完全照搬Oracle的实现。说实话,这个平衡挺难找的。

OID这套东西,从PG继承过来后做了本地化改造,全局变局部,解决了并发性能问题,但也牺牲了全局唯一性。这个取舍见仁见智,看具体应用场景。我个人觉得,如果KES能提供一个开关,让用户选择是用全局还是局部OID,可能会更好一点——当然,这会增加实现复杂度。

ROWID的实现更典型,Oracle用物理地址,KES用逻辑标识。物理地址快但不稳定,逻辑标识慢点但可排序、可持久化。哪种更好?没有标准答案,取决于你的业务需求。不过从我个人的经验来看,逻辑标识在实际应用中更容易处理一些,至少不用担心表重建后ROWID变的问题。

参数体系也是个双刃剑,兼容性参数越多,迁移越容易,但配置复杂度也越高。default_with_rowid和default_with_oids互斥这个设计,我能理解背后的逻辑,但确实让初学者容易踩坑。我记得刚开始用KES的时候,被这些参数折腾了好久,后来才慢慢搞清楚优先级关系。

从技术演进的角度看,早期数据库追求标准化,希望所有数据库都遵循同一套SQL规范。后来发现不现实,Oracle、MySQL、SQL Server各有各的特色,用户选型后就被绑定死了。国产数据库要突围,兼容性是必经之路,但兼容到什么程度、如何保持自身特色,是门艺术。

KES在OID和ROWID上的实践,算是找到了一个相对平衡的点。既提供了Oracle兼容性,又有自己的技术实现,虽然细节上还有些粗糙,但方向是对的。未来如果能进一步简化参数配置,提供更清晰的迁移指南,相信会有更多人愿意尝试。

结语

行标识符这东西,平时开发可能不太注意,但深入进去会发现涉及很多底层机制。OID的系统表全局性、ROWID的逻辑标识设计、参数互斥的处理逻辑,每一块都体现了数据库设计者的思考。

技术选型时,别被表面功能迷惑,要深入理解实现原理。Oracle的ROWID快,但那是物理地址换来的;KES的ROWID兼容,但实现逻辑完全不同。迁移时如果不搞清楚这些细节,很容易掉坑。我之前就犯过这个错,以为ROWID都是一样的,结果各种问题。

最后说一句,国产数据库这几年进步很快,从单纯模仿到自主创新,路还很长。KES作为老牌国产数据库,在兼容性和自主性上的探索,值得学习。希望未来能看到更多技术突破,真正在关键领域替代国外产品。当然,这需要我们这些技术人员的共同努力,毕竟国产数据库的发展离不开用户的支持和反馈。