尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

数据库行定位实战:ROWID与唯一键的选型与优化

数据库行定位实战:ROWID与唯一键的选型与优化 1. 定位机制的本质ROWID为什么能“快准狠”做数据库开发的人迟早会遇到这么一个问题明明表里就几万行数据一条 UPDATE 或者 DELETE 却慢得让人怀疑人生或者干脆报错说“影响了多行”把我吓出一身冷汗。这个时候大部分人的第一反应是“我是不是索引没用上”但很少有人会去想一个更底层的问题数据库到底是怎么找到我要改的哪一行的答案其实就两个要么走 ROWID要么走唯一键。先说 ROWID。它是一个物理地址你可以把它理解为“这行数据在磁盘上的门牌号”。Oracle 里每行数据天生就有一个 ROWID它由数据对象编号、数据文件编号、数据块编号和行编号拼出来的只要行不迁移、表不被重建这个东西基本是稳定的。MySQL 的 InnoDB 也有类似的概念只不过它用的是主键聚簇结构每行数据的“物理身份”是靠主键决定的而不是给你暴露一个可以直接查的伪列。PostgreSQL 里的ctid也和 ROWID 类似代表行在页面里的物理位置。而唯一键呢它走的是逻辑查找。你得告诉数据库“我要找的这行它的业务标识是什么”数据库再去索引里比对 B 树最后定位到具体行。两者的核心区别就是一个是“直接给地址”一个是“按条件去搜索”。这里要注意一个很多人容易搞混的点主键是唯一键的一种但唯一键不一定非得是主键。一个表只能有一个主键但可以有多个唯一键。主键自带 NOT NULL 约束而唯一键允许 NULL 值而且 MySQL 里一个唯一索引可以有多条 NULL 记录。这个差异在实际定位数据时非常重要我在后面的实操部分会专门说到。那什么时候必须用 ROWID什么时候必须用唯一键我说几个典型场景。第一个场景是“绝对精确且近乎零开销的定位”。比如你在做批处理脚本前面刚插入一批数据后面对新插入的这几行做 UPDATE你心里很清楚自己刚插了哪些行但这批数据没有唯一的业务编号。这时候如果你用的是 Oracle直接在 INSERT 语句里加RETURNING ROWID INTO把新行的 ROWID 拿回来存进集合变量里后续的更新操作就直接拿这个 ROWID 来定位。性能好到你几乎感觉不到延迟因为这根本不做索引查找拿到物理地址直接就定位了。第二个场景是“跨表比对时需要区分重复行”。比如 FROM 子句里有两张结构完全一样的表里面甚至可能出现一模一样的记录唯一键根本分不清它们谁是谁这时候唯一能区分它们的就只有物理身份。Oracle 的 ROWID、PostgreSQL 的 ctid就派得上用场了。第三个场景是“业务上是唯一定位的但暂时做不了主键”。比如一张日志表你想用(user_id, event_time)做唯一键来定位一条记录但主键是自增 id。你当然可以直接用主键来定位但业务上你拿到的参数就是 user_id 和 event_time这时候就该用联合唯一键建立索引然后按这个唯一键去查询。反过来ROWID 也有它的致命伤。它不是 SQL 标准里的东西Oracle、PostgreSQL、MySQL 的实现各不相同。最要命的是它不稳定行迁移之后 ROWID 会变表被ALTER TABLE ... MOVE或者OPTIMIZE TABLE之后会变数据泵导入导出之后也会变。所以 ROWID 只适合在会话级别、或者短期内用于定位绝不应该被当作长期、跨会话的“主键替代品”存到别的表里。所以选 ROWID 还是唯一键本质上是在“物理身份的稳定性和速度”与“逻辑上的业务可读性”之间做权衡。你要是只想在当前会话里高效处理刚产生的那几行或者想对物理存储层面做处理那就用 ROWID要是你的操作需要跨会话、跨任务、可审计那必须用唯一键。2. ROWID的获取方式与使用边界2.1 Oracle中如何拿到ROWIDOracle 里所有表默认都有 ROWID 伪列你直接 SELECT 就能查出来SELECT rowid, t.* FROM users t WHERE user_id 100;这里要注意一个语法小细节因为 ROWID 是伪列如果你用了SELECT t.*它并不会包含 rowid必须显式地在 SELECT 列表里写上rowid或者t.rowid。实际业务中获取 ROWID 更常用的方式是RETURNING子句特别是在写 PL/SQL 批处理的时候。比如DECLARE TYPE rid_list IS TABLE OF ROWID; v_rids rid_list; BEGIN DELETE FROM temp_order_log WHERE created_date SYSDATE - 30 RETURNING rowid BULK COLLECT INTO v_rids; FORALL i IN 1..v_rids.COUNT INSERT INTO order_log_archive SELECT * FROM temp_order_log WHERE rowid v_rids(i); FORALL i IN 1..v_rids.COUNT DELETE FROM temp_order_log WHERE rowid v_rids(i); END;有人可能会问直接一个 INSERT INTO ... SELECT 不就行了我说这个例子的目的是想展示一个经典的数据归档思路先定位要处理的行再用 ROWID 去精准操作。在数据量大的时候你如果用业务字段再查一遍可能又会触发一次全表扫描而用 ROWID 集合直接定位速度完全不是一个量级。2.2 MySQL和PostgreSQL的“类ROWID”怎么用MySQL 的 InnoDB 并没有直接暴露 ROWID但有一个替代思路可以拿到“行的物理身份”。如果没有主键InnoDB 会生成一个隐藏主键但你没有权限直接查它。实际开发中我一般用两种方式间接实现类似效果一种是通过SELECT ... FOR UPDATE锁住行再结合主键或唯一键去更新虽然没有物理 ROWID但在并发环境下能达到“精准定位并独占该行”的效果。另一种是用 MySQL 8.0 的窗口函数ROW_NUMBER()来定位“第几行”这其实也是逻辑定位只是给行加了编号。WITH numbered AS ( SELECT *, ROW_NUMBER() OVER (ORDER BY id) AS rn FROM users ) SELECT * FROM numbered WHERE rn 5;PostgreSQL 则提供了ctid可以直接查出来SELECT ctid, * FROM users WHERE id 100;ctid 的格式是(块号, 行偏移号)比如(0, 15)就表示第 0 个数据块的偏移量 15 的行。但要注意UPDATE 之后这个值一定会变因为 PG 的更新是“先标记旧行删除再插入新行”新行的 ctid 就变了。2.3 什么情况下绝对不能依赖ROWID这是我的血泪教训当年在做数据清洗项目时踩过坑。任务是这样的把 A 表里的脏数据找出来更新到另一张有外键关联的表里。我图省事直接用 ROWID 做关联条件本地测试全通过结果上生产环境隔天跑批就出了问题——因为中间有一步ALTER TABLE ... SHRINK SPACE把表的物理存储重排了所有行的 ROWID 全变了导致第二天再次跑批时关联全乱了。所以以下情况千万不要存 ROWID 长期使用表会被TRUNCATE、ALTER TABLE ... MOVE、SHRINK SPACE或OPTIMIZE TABLE处理。数据会通过数据泵、导入导出工具做迁移迁移之后 ROWID 必然变化。你需要在应用层跨会话、跨请求传递定位标识。表的并发写入很高经常发生行迁移。在这些场景下老老实实用业务唯一键。如果业务上确实没有唯一键那就建一个自增主键不要偷懒。毕竟主键存在的意义就是给每一行一个稳定、可复现的逻辑身份。3. 唯一键的定位价值与索引落地3.1 唯一键和主键在定位时的差别主键和唯一键在查询优化器眼里差别并不大它们都会自动生成唯一索引等值查询时都能把复杂度降到 O(log n) 甚至更优。但有几个细节会让你在实操中踩坑。第一个坑是 NULL 值。唯一索引允许多个 NULL 值MySQL 的 InnoDB 和 Oracle 都是这样系统没办法用唯一性来约束“NULL 等于 NULL”所以在做定位时WHERE unique_col NULL永远查不到东西必须写成IS NULL。你要查的业务数据如果包含了空值就复杂了因为可能命中多行。第二个坑是复合唯一索引的列顺序。比如一张订单表有(order_no, item_no)唯一索引你要是只用WHERE item_no A001去查这个唯一索引大概率用不上因为最左前缀原则被违反了。很多人在定位数据时报“索引失效”其实不是数据库的问题而是列顺序没设计好。第三个坑是 SQL Server 里聚集索引和非聚集索引对唯一键的存储结构影响。SQL Server 的表如果没有显式主键它可能是个堆表Heap这时候唯一键索引里存的是行定位器RID性能会略差如果有主键聚簇索引唯一键索引的叶子节点存的是主键值。同样是按唯一键定位底层访问路径完全不同。在 SQL Server 里你要特别注意sys.indexes的type_descCLUSTERED 和 NONCLUSTERED 的定位效率差距挺明显。3.2 为什么推荐用唯一键做“可存档的定位凭证”做过数据修复、数据对账、审计类需求的朋友应该深有体会你把一条记录的处理结果发给下游或者记进日志总不能写“我处理的是第 3 个数据块里的第 7 行”因为明天这个信息就失效了。你写的应该是订单号、身份证号这类业务标识对方才能根据这个标识复现你的处理过程。所以我的建议是会话级别的临时操作优先用 ROWID 提升性能。跨会话、跨系统的数据修复、对账、日志记录用唯一键。一张表如果连唯一键都没有设计上就有缺陷。就算业务字段实在找不出唯一性也建一个自增主键哪怕只是“为了有个主键”。主键不只是为了让 ORM 好用更是给每一行一个稳定的逻辑身份锚点。没有这个锚点任何定位方案都是空中楼阁。4. 多种真实场景下的ROWID与唯一键实操方案4.1 场景一批处理中“先插入再更新”这是我在做订单数据清洗时候最常用的一招。假设你要把一堆明细数据灌进临时表然后根据某些规则更新其中的部分列。如果用业务条件去更新可能会误伤之前已经存在的数据。更稳妥的做法是把新插入行的 ROWID 留好只更新这些行。Oracle 写法DECLARE v_rids DBMS_SQL.VARCHAR2S; -- 实际用TABLE OF ROWID更优雅 BEGIN INSERT INTO temp_orders (order_no, amount, status) VALUES (A001, 100, NEW) RETURNING rowid INTO v_rids(1); -- 后续根据 v_rids 精准更新 UPDATE temp_orders SET status PROCESSED WHERE rowid v_rids(1); END;MySQL 没有 RETURNING ROWID但你可以用LAST_INSERT_ID()配合自增主键完成同样的事INSERT INTO temp_orders (order_no, amount, status) VALUES (A001, 100, NEW); SET new_id LAST_INSERT_ID(); UPDATE temp_orders SET status PROCESSED WHERE id new_id;这两种方式的核心逻辑都是“先拿到本次操作产出的行的精确身份再基于这个身份做后续操作”避免了重复去查业务条件。4.2 场景二大表分页与去重定位在大表上做分页最经典的低效写法是LIMIT 100000, 20。数据库会先把前 100020 行都查出来再丢掉前 100000 行浪费极大。用 ROWID 或唯一键做“键集分页”Keyset Pagination就好很多。MySQL 里基于唯一键的分页-- 首页 SELECT id, user_id, amount FROM orders WHERE id 0 ORDER BY id LIMIT 20; -- 下一页传入上一页最后一条记录的 id SELECT id, user_id, amount FROM orders WHERE id 10086 ORDER BY id LIMIT 20;Oracle 里也可以用 ROWID 实现类似效果但实践中我更推荐用主键来翻页原因前面说过了ROWID 不稳定翻到后面几页时如果数据发生了迁移结果就可能重复或遗漏。唯一键去重是另一个高频场景。假设业务表里根本没有主键我也见过不少这种设计失误你需要删掉完全重复的行、只保留一条。此时唯一键用不上物理身份反而成了救命稻草。以 Oracle 为例DELETE FROM users WHERE rowid NOT IN ( SELECT MIN(rowid) FROM users GROUP BY user_name, id_card );这条 SQL 的逻辑就是按业务字段分组在每个分组里保留物理位置最靠前的那条把其他重复行删掉。没有 ROWID 你根本写不出这么简洁的去重语句。PostgreSQL 可以换成ctidMySQL 因没暴露物理行号只能借助临时表或窗口函数比如DELETE FROM users WHERE id NOT IN ( SELECT MIN(id) FROM users GROUP BY user_name, id_card );这里有个前提users 表得有 id 列且值唯一。所以你看不管怎么绕最终的稳定锚点还是唯一键或主键。4.3 场景三用唯一键做数据修复的追踪记录有一次处理线上数据订正影响范围是一张 2000 多万行的用户积分表。我把要修正的 user_id 列表放在一张临时表里然后做了三步第一步备份。把涉及的用户完整行记录插入备份表避免修错无法回滚。CREATE TABLE user_points_bak_20250101 AS SELECT u.* FROM user_points u INNER JOIN tmp_fix_list t ON u.user_id t.user_id;第二步更新。以唯一键 user_id 作为关联条件执行更新UPDATE user_points u INNER JOIN tmp_fix_list t ON u.user_id t.user_id SET u.points t.new_points, u.update_time NOW(), u.update_reason MANUAL_FIX;第三步核对差异并记录修复日志。日志里写的就是 user_id 和修复前后的积分值方便追溯。这套流程里唯一键从头到尾都是“可存档定位凭证”而 ROWID 只是内部执行计划的一种加速手段。如果当时自作聪明把 ROWID 存进修复日志那这份日志一过数据迁移就彻底变成废纸了。4.4 场景四通过EXPLAIN判断定位方式是否生效不管是 ROWID 还是唯一键最终能不能快还得看执行计划。很多人在定位慢 SQL 时第一步就去看 EXPLAIN这方向是对的但信息没看全。以 MySQL 为例你要重点看这几列type如果是const或eq_ref说明按唯一键等值定位这是最优情况ref也可以但不保证唯一ALL就是全表扫描需要警惕。key实际用到的索引。如果这一列是 NULL说明索引根本没生效。rows预估扫描行数。如果预估几百万可是表只有几万优先考虑是不是统计信息过期了。Extra重点关注有没有Using filesort或Using temporary这两个一旦出现SQL 多半需要优化。假设我们有这样一条查询SELECT * FROM orders WHERE order_no 20250101001;如果order_no上有唯一索引type 会显示constrows 只有 1。要是发现 type 是ALL那就要检查order_no上是不是真的建了唯一索引或者有没有隐式类型转换让索引失效。比如 order_no 是 VARCHAR你查的时候写的是数字20250101001MySQL 就可能把索引列转成数字去比较导致索引失效。Oracle 的做法类似看执行计划的TABLE ACCESS BY INDEX ROWID和INDEX UNIQUE SCAN。如果出现TABLE ACCESS FULL且表很大那就是定位方式没选好。5. 常见问题与排查技巧实录5.1 ROWID相关的经典报错和坑ORA-01410: invalid ROWID这个报错核心意思是 ROWID 已经不是有效的 ROWID 了。常见原因是之前存的 ROWID 字符串对应行已经被删除、块被重新组织甚至 ROWID 在跨库传递时被当普通字符串截断或编码转义。解决办法很简单别把 ROWID 当长期标识存只在当前会话内使用如果需要从外部传入 ROWID 字符串一定要校验长度和格式Orace ROWID 标准长度是 18 位但扩展格式可能是更长。行迁移导致 ROWID 变化行迁移发生在 UPDATE 之后新行的数据量超过了原数据块的剩余空间Oracle 会把整行搬到另一个块原位置只留一个“转发地址”。更麻烦的是迁移后通过旧 ROWID 去查Oracle 内部会自动转向新位置所以查询结果没错但性能变差了。你会感觉“访问路径明明走 ROWID为什么还是很慢”这就是原因之一。排查方法是查USER_TABLES.CHAIN_CNT字段如果这个值持续增长说明行迁移比较多。解决方案是调整 PCTFREE 参数给 UPDATE 留足空间或者定期ALTER TABLE ... MOVE重组一次。但这属于表维护范畴执行前务必评估停服窗口。PostgreSQL ctid 和 Oracle ROWID 的区别PG 的 ctid 也有类似的行迁移问题甚至更严重因为 PG 的 UPDATE 本质是插入新行旧行被标记删除所以任何一次 UPDATE 都会让 ctid 变化。在 PG 里基于 ctid 做跨语句定位风险极高我是强烈不推荐的。5.2 唯一键定位时经常踩的索引失效坑隐式类型转换这是最常见的问题。user_id是 VARCHAR但查询条件是数字SELECT * FROM users WHERE user_id 123;MySQL 会先把 user_id 转成数字再和 123 比较。结果就是索引列上发生了函数操作索引失效全表扫描。正确写法是SELECT * FROM users WHERE user_id 123;字符集和排序规则不一致两张表做 JOIN 定位时如果关联字段的字符集或排序规则不一样也可能导致索引失效。比如一张表是 utf8mb4_general_ci另一张是 utf8mb4_unicode_ciMySQL 可能就得先把字符集统一了才能比较索引大概率用不上。解决办法是统一库表字段的字符集对比information_schema.columns找出不一致的列。复合唯一索引的最左前缀问题假设唯一索引是(order_no, item_no)但你的查询条件是WHERE item_no A001这个索引就帮不上忙因为优化器没法从第二个字段开始匹配。解决办法要么把 item_no 挪到第一列要么单独给 item_no 建索引。具体怎么取舍取决于你的高频查询模式。5.3 定位慢SQL的排查顺序如果你现在正被一条“看起来有索引但还是慢”的查询折磨我建议你按这个顺序来排查先跑EXPLAIN看执行计划确认优化器到底用了哪个索引。检查 WHERE 条件列的类型确认没有隐式转换。检查索引列的可选择性。如果唯一键和普通索引效果一样都是扫描大量数据说明数据分布有问题例如状态字段只有 0 和 1 两个值给它建索引基本没用。用SHOW INDEX FROM确认索引状态看看 Cardinality 是否正常。如果这个值异常低可能有大批重复值或者统计信息太久没更新。如果以上都正常那优先怀疑索引选择错误可以用FORCE INDEX或USE INDEX先做临时验证确认是不是优化器选错索引再考虑是更新统计信息还是调整 SQL 写法。5.4 一个完整的踩坑案例复盘去年做日报表统计有一张 8000 万行的 T0 流水表。业务方要求按“用户最近一笔交易”来统计活跃用户我开始写的是SELECT user_id, MAX(trans_time) FROM transaction_flow GROUP BY user_id;这 SQL 没毛病但跑了 7 分钟业务方等不了。我本来想用 ROWID 思路——先查出每个用户最新一笔交易对应的 rowid再回表查完整记录但 Oracle 的MAX(...) KEEP (DENSE_RANK ...)其实更合适。于是改成SELECT user_id, MAX(trans_time) KEEP (DENSE_RANK FIRST ORDER BY trans_time DESC, trans_id DESC) AS latest_trans_id FROM transaction_flow GROUP BY user_id;这条 SQL 依然走了全表扫描但只扫一遍不用额外回表跑完只要 40 秒。虽然还是要全表扫但对这种没有合理筛选条件的统计需求已经是逻辑上的最优解。这个案例的启示是定位数据不一定非要用 ROWID也不一定非要建索引有时候改写逻辑减少回表次数比追求“某种定位技巧”更有效。6. 从ROWID到唯一键选型总结与个人经验回到最初的问题快速定位 SQL 表中的特定行到底该用 ROWID 还是唯一键我的经验可以浓缩成下面这张表维度ROWID唯一键定位原理物理地址直达数据块逻辑标识走索引查找速度极快开销极小快取决于索引结构稳定性行迁移、表重组、导入导出后都会变只要业务不变永久稳定跨会话可用性差只适合当前会话好可归档、可审计可读性差一串无意义字符好直接对应业务含义依赖前提数据库厂商伪列非SQL标准必须有唯一约束或主键典型场景批处理内临时定位、去重数据修复、日志记录、跨系统同步如果你对自己表结构有足够的控制力我的建议是“不要二选一”。正确姿势是表设计时一定要有主键和必要的唯一键这是长期稳定定位的基石在执行批处理、会话级数据操作时灵活用 ROWID 做加速手段但用完即弃不要把它当成业务标识存入日志或传给下游。还有一条比较容易被忽略的心得任何定位手段都救不了一个设计有问题的表。如果表没有主键、没有唯一键你就算临时用 ROWID 把功能跑通了后续的维护成本也会像滚雪球一样越滚越大。先花时间把表结构梳理清楚该补的主键补上该清洗的重复数据清洗掉再谈定位优化才有意义。最后再分享一个小技巧。如果你要在 Oracle 里快速验证一个表有没有主键、有几个唯一键可以查这张视图SELECT table_name, index_name, column_name, column_position FROM user_ind_columns WHERE table_name YOUR_TABLE ORDER BY index_name, column_position;MySQL 则用SHOW INDEX FROM your_table;拿到这些信息你才能准确判断手头这张表到底有没有可靠的逻辑定位凭证如果没有那下一步需要补什么样的唯一键而不是两眼一抹黑地去写各种看起来好像能跑的定位 SQL。
返回列表