
最近在帮一个老项目做国产化适配核心库要从Oracle迁到达梦数据库。本来以为最大的工作量在存储过程和分页SQL改造上结果真正卡住我两天的居然是一个看起来特别不起眼的需求——分组取最新值。比如每个客户最近一笔订单金额、每台设备的最近一条告警记录、每个商品的最新价格。这种需求在Oracle里我习惯用KEEP语法一把梭一行SQL就完事。但换到达梦之后心里确实打了个问号KEEP这种Oracle专属语法达梦认吗实测下来的结论让我挺意外达梦DM8不仅认而且在执行计划上表现相当干净。我翻了官方文档发现它对Oracle语法的兼容比想象中要到位只是很多DBA和开发在入门时根本没注意到这个能力。这篇文章我就把KEEP语法从原理到实战完整拆一遍结合我这次适配过程中的真实案例和踩坑记录聊聊怎么在达梦数据库里优雅地分组取最新值。1. 分组取最新值一个比想象中更常见的高频需求1.1 哪些业务场景每天都在“分组取最新”我做了这么多年数据开发和数据库运维说句实话“分组取最新值”可能是报表里出现频率最高的需求之一只不过大家平时叫法不一样。它本质上是某张流水表或者历史表里每个分组都有多条记录我们需要拿到每组当中符合某种排序规则的“第一条”或“最后一条”。典型的场景我随手就能列出一堆客户订单表里取每个客户最近一次下单金额会员积分流水里取每个会员当前最新积分余额设备状态历史表里取每台设备最近一次上报状态商品价格表里取每个商品当前执行的最新价格还有审批流表里取每个工单当前所处节点。这些场景有个共同特征数据量往往不小而且表结构是“一主多从”的流水型结构分组键加上时间字段就能定位到目标记录。我这次适配的项目更典型是一套报表系统。底层几张事实表每天几百万行往上涨报表需求却都是“看每个维度当前最新状态”。比如按门店维度看每个门店最近一次盘点结果按仓库维度看每个库位最近一次库存快照。这种需求如果写不好报表页面能从秒级响应直接劣化到几十秒。1.2 常见的三种写法以及它们各自的痛点既然是高频需求大家自然摸索出了各种写法。我见过最多的有三种自连接、窗口函数、还有KEEP。先说自连接思路很简单先用子查询求出每个分组里的最大时间再把这个时间关联回原表拿到整行数据。SQL写出来大概长这样SELECT t1.customer_id, t1.order_amount FROM orders t1 JOIN ( SELECT customer_id, MAX(order_date) AS max_date FROM orders GROUP BY customer_id ) t2 ON t1.customer_id t2.customer_id AND t1.order_date t2.max_date;这段SQL看着不难但实际跑起来问题不少。首先它要把订单表扫描一遍求最大值然后还要再用最大值回表关联一次等于整张大表跑了两次甚至多次。如果最大值对应的记录有多条同一个客户同一天下了好几单关联出来还会产生重复数据需要额外加去重逻辑。更麻烦的是如果需求变成“取每个客户最近一笔订单的收货地址、支付方式、优惠金额”等七八个字段虽然也能关联但SQL膨胀得很厉害。第二种是窗口函数ROW_NUMBER()。这是很多开发偏爱的方式因为逻辑直白先按客户分组、按时间倒序编号然后外面包一层查询过滤编号等于1的记录。SELECT customer_id, order_amount FROM ( SELECT customer_id, order_amount, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn FROM orders ) t WHERE rn 1;窗口函数的可读性比自连接好不少而且性能通常也能接受。但缺点是要包一层子查询扫描完整张表生成编号之后外层才能过滤。对于几百万行的大表这等于把所有行的编号都算了一遍其实有不少算出来的编号根本用不上白白浪费了排序资源。第三种就是我这次要重点讲的KEEP语法。它在Oracle里属于FIRST/LAST聚合函数家族写法极其紧凑一条语句直接出结果不需要子查询也不存在重复关联。到我这次在达梦上实际验证之前我对它的迁移兼容性还是有顾虑的毕竟它不是标准SQL。为了让大家有直观对比我把三种方式的特点整理成了下面这个表格方案写法复杂度是否多扫表是否易产生重复达梦兼容性自连接较复杂取多字段时SQL膨胀是至少两遍容易完全兼容窗口函数中等需子查询包裹否但全量排序不会完全兼容KEEP简洁一行聚合否分组内排序取决于并列情况需要Oracle兼容模式2. KEEP语法到底在干什么一个被低估的聚合利器2.1 语法结构逐段拆解KEEP语法刚接触时确实不太直观因为它把两个看似矛盾的动作组合在了一起外层是MAX/MIN这种聚合函数括号里却塞了个ORDER BY。我第一次在Oracle里看到这个写法也觉得奇怪聚合函数不是不能跟ORDER BY混用吗后来才明白这里的ORDER BY不是给聚合函数用的而是给KEEP关键字后面的DENSE_RANK用的。一个完整的KEEP语法长这样MAX(order_amount) KEEP (DENSE_RANK LAST ORDER BY order_date, order_id)我拆给你看。最外层的MAX(order_amount)是真正的聚合操作决定我们要在选出来的行里取哪个列的什么极值。紧跟其后的KEEP关键字是核心它告诉数据库把当前分组内的行按照后面ORDER BY指定的规则排序然后从DENSE_RANK LAST或FIRST选出来的行集合中再对这个列执行外层聚合。ORDER BY部分负责定义“最新”或者“最先”的排序标准可以跟多个字段组合比如先按日期排序、日期一样再按订单号倒序。把它应用到完整的分组查询里就是这样SELECT customer_id, MAX(order_amount) KEEP (DENSE_RANK LAST ORDER BY order_date) AS latest_amount FROM orders GROUP BY customer_id;这段SQL的意思是所有订单按customer_id分组在每组内部先按order_date倒序排好DENSE_RANK LAST就把排在最末尾的那一行或几行圈出来然后MAX(order_amount)在圈出来的这些行里取订单金额的最大值。当每组里“最晚”的记录只有一条时取到的自然就是这一条的金额。2.2 一句大白话理解KEEP如果觉得语法结构太抽象我用生活场景打个比方。想象你在整理每个班级的学生档案想找出每个班里年龄最小的那个学生的姓名。KEEP的做法是每个班先按生日从晚到早排个队然后只看排到最末尾的那一队人让MAX聚合函数在这一队里挑一个名字按字典序最大。这个类比里班级就是GROUP BY的分组键生日排序就是ORDER BYDENSE_RANK LAST负责框定“排最末尾的一队人”MAX(姓名)负责从这队人中取一个值。你会发现KEEP并不是真的为了取“一整条记录”设计的它是为了取“某个值”设计的。这点特别重要很多人用不好KEEP就是因为总想着拿它返回整行数据实际它的定位就是一个花式聚合函数。2.3 为什么是DENSE_RANK而不是ROW_NUMBER这个问题我当年学KEEP时困惑了很久为什么Oracle设计这个语法时偏偏选了DENSE_RANK而不是更自然的ROW_NUMBER后来在实战中想明白了KEEP要的不是“唯一一条”记录而是“排名最前或最后的一组行”。DENSE_RANK允许并列排名当排序字段完全相同的行有多条时它们会全部被纳入外层聚合函数的计算范围。这个特性有优势也有坑。优势在于你可以对排序并列的一组行再做强一点的聚合。比如“请列出每个部门里工资最高的所有员工中工资数额最大是多少”——这在人事薪资系统里挺常见。坑在于如果你以为取到的是“最新的一条完整记录”实际取到的却是并列行里某个字段的极值就会产生结果偏差。比如两个人同一时刻下了一单你要是用KEEP取客户姓名得到的会是按字典序排出来最大的那个名字而不是随机的一条。这些都是后面实操里最容易踩的地方我先在这里埋个伏笔第5部分会专门展开讲。3. 实操达梦数据库里用KEEP分组取最新值全程走一遍3.1 建表与测试数据准备理论说完了咱们直接在达梦数据库里把KEEP跑起来。我先建一张最典型的订单表来模拟真实场景CREATE TABLE customer_order ( customer_id INT, order_id INT, order_amount NUMERIC(10,2), order_date DATE, order_status VARCHAR(20) );然后插入一批测试数据故意让每个客户有多个订单而且让其中一个客户在同一时间下两个单方便后面验证并列排序的行为INSERT INTO customer_order VALUES (1001, 100001, 299.00, 2025-01-05, 已完成); INSERT INTO customer_order VALUES (1001, 100002, 59.90, 2025-02-10, 已完成); INSERT INTO customer_order VALUES (1001, 100003, 159.00, 2025-03-01, 配送中); INSERT INTO customer_order VALUES (1002, 100004, 799.00, 2025-01-12, 已完成); INSERT INTO customer_order VALUES (1002, 100005, 1299.00, 2025-02-14, 已完成); INSERT INTO customer_order VALUES (1002, 100006, 399.00, 2025-03-05, 待付款); INSERT INTO customer_order VALUES (1003, 100007, 59.00, 2025-02-20, 已完成); INSERT INTO customer_order VALUES (1003, 100008, 89.00, 2025-02-20, 已完成); COMMIT;这里我故意让客户1003在2025年2月20日下了两单后面可以清晰演示DENSE_RANK并列时KEEP的行为。建好表插好数据之后确认一下达梦的兼容模式。这一步很关键我用的达梦版本是DM8在初始化实例时选择的就是Oracle兼容模式。如果实例是在MySQL兼容模式下创建的KEEP语法可能会报错这个分支我们在常见问题里再详细说。3.2 案例一每个客户最近一笔订单金额第一个需求很直观取每个客户最近一单的订单金额。SQL写出来极其精简SELECT customer_id, MAX(order_amount) KEEP (DENSE_RANK LAST ORDER BY order_date) AS latest_amount FROM customer_order GROUP BY customer_id;执行结果如下customer_idlatest_amount1001159.001002399.00100389.00客户1001最近一笔订单是3月1日的159元客户1002最近一笔是3月5日的399元这两条都符合预期。重点看客户1003他有两单都是2月20日下的金额分别59元和89元DENSE_RANK LAST把这两个并列最晚的行都框进来了MAX再在59和89之间取最大值最后返回了89而不是这两条里的某一条整行。这个结果恰恰说明了第2章讲到的语义。如果业务上想从两个并列单里取特定的一条就不能只按order_date排序了得追加一个能让排序唯一化的字段。比如达梦里每张表的order_id是唯一的那把排序改成ORDER BY order_date, order_id DESC这样即使日期相同也能通过订单号决定严格顺序SELECT customer_id, MAX(order_amount) KEEP (DENSE_RANK LAST ORDER BY order_date, order_id DESC) AS latest_amount FROM customer_order GROUP BY customer_id;客户1003在2月20日的两个订单order_id分别是100007和100008加了order_id DESC之后排序唯一最后取到的因为用了MAX语义是在并列组内对金额取最大值但是一旦排序不再并列它取到的就是唯一那条最新记录也就不存在模棱两可了。3.3 案例二每个客户最新订单的订单号很多场景下除了金额还要拿订单号等字段这时候两个字段同时取就行SELECT customer_id, MAX(order_id) KEEP (DENSE_RANK LAST ORDER BY order_date) AS latest_order_id, MAX(order_amount) KEEP (DENSE_RANK LAST ORDER BY order_date) AS latest_amount FROM customer_order GROUP BY customer_id;这里外层聚合字段换成了order_id语义就变成了在DENSE_RANK LAST圈出来的那一组行里取order_id最大的那个值。对于同一组并列行MAX取极值的效果等同于“在并列记录里挑最后产生的那条”。这在很多明细查询场景里相当实用因为订单号、流水号这类字段往往是递增的天生适合作为并列时的辅助排序依据。需要注意的是这条SQL里的order_id和order_amount都是外层聚合的列它们的极值可能来自不同的原始记录。比如并列里有两条金额分别是10和20的订单KEEP取order_id最大值可能落在金额为10的那条上同时取order_amount最大值则落在金额为20的那条上。如果你需要同一个字段组合完整对应同一条记录最好用窗口函数或者把排序字段做到唯一化。3.4 案例三每个客户最早一笔订单的订单状态取最新用得最多但取最早同样有需求。比如业务方想知道每个客户第一次下单时的订单状态用于分析新客行为。这时候把DENSE_RANK LAST换成DENSE_RANK FIRST就行排序方向也可以反着来SELECT customer_id, MAX(order_status) KEEP (DENSE_RANK FIRST ORDER BY order_date) AS first_status FROM customer_order GROUP BY customer_id;这个SQL里外层聚合的order_status是字符串类型KEEP会对并列行里所有订单状态做MAX运算也就是按字典序取最大值。客户1001最早订单是1月5日只有一条直接返回“已完成”。这个场景想特别说明的是KEEP的聚合目标列可以是任意支持MIN/MAX运算的数据类型字符串完全可以只要语义符合需求。4. 性能与写法演进KEEP真的更快吗4.1 三种写法放一起对比纸上谈兵没有用我直接把三种常见写法放到达梦数据库上跑了一遍。测试表一共有200万行订单数据按10000个客户分组每个客户约200条订单。服务器是普通的8核16G虚拟机达梦版本DM8。三条SQL目标一致取每个客户最近一笔订单金额。窗口函数的写法SELECT customer_id, order_amount FROM ( SELECT customer_id, order_amount, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn FROM customer_order ) WHERE rn 1;自连接的写法SELECT t1.customer_id, t1.order_amount FROM customer_order t1 JOIN ( SELECT customer_id, MAX(order_date) AS max_date FROM customer_order GROUP BY customer_id ) t2 ON t1.customer_id t2.customer_id AND t1.order_date t2.max_date;KEEP的写法SELECT customer_id, MAX(order_amount) KEEP (DENSE_RANK LAST ORDER BY order_date) AS latest_amount FROM customer_order GROUP BY customer_id;三条SQL最后都执行成功结果集完全一致。性能方面虽然会受数据分布和服务器负载影响但我这次在达梦上跑出来的趋势非常明显KEEP的耗时大约是窗口函数的60%到70%自连接在数据量大时最慢。加不加索引也会影响排名但KEEP即使没走索引单次扫描的优势依然在那里。4.2 从执行计划看KEEP的扫描方式为什么KEEP能快这么多我用达梦的管理工具对三条SQL分别抓执行计划对比之后发现了关键差异。自连接的执行计划里有两处对customer_order的全表扫描一处是内层子查询求MAX(order_date)另一处是外层关联回原表取字段。也就是说同一张200万行的表被完整扫了两遍这种开销在自连接方案里几乎是免不了的。窗口函数版本的执行计划是一遍全表扫描加窗口排序相比自连接已经少了一次扫描。但它需要对分区内的200行数据全部做排序和编号即使每个分组最终只用到rn1那一行前面199行的编号工作也全干了这部分开销被白白浪费掉了。KEEP版本最有意思它同样只扫一遍表但在分组聚合的过程中数据库把“排序后取首行/尾行”这个动作内聚到了聚合算子内部。优化器知道每个分组只需要保留满足DENSE_RANK条件的行所以在内存/临时表里维护的东西比窗口函数全量排序要少得多。从执行计划上看它是标准的HASH GROUP BY SORT AGGREGATE的组合没有额外的表访问。4.3 实测数据与适用边界我把我这次实测的大致耗时整理成了表格数据分布是每个客户200条订单、订单日期随机未针对三个方案额外建索引方案执行耗时200万行扫描次数备注自连接约4.2秒2次全表数据量翻倍时耗时上升较快窗口函数约3.1秒1次全表全量排序分组内记录越多排序成本越高KEEP约2.0秒1次全表分组内聚合分组越少/组内越多优势越明显需要说明的是这个数据是我这台虚拟机上跑出来的不能当通用指标用。但它反映出的规律是通用的KEEP在“分组多、组内记录数中等、目标只是取最新值”的场景下性价比最高。如果业务需要返回整行记录KEEP就不合适这时候窗口函数依然是更好的选择。有一次我把KEEP用在一个分组数量特别大、每个分组只有一条记录的场景里结果KEEP和窗口函数的耗时几乎一样因为每个分组就一行不管什么写法都是平均值。这个边界让我意识到KEEP不是银弹它的优势集中在分组内有足够多条记录需要竞争排序的场景场景对了优势才明显。5. 踩坑总结KEEP语法的7个注意事项5.1 KEEP只能返回单个值不能返回整行记录这是最容易误解的一点我发现很多开发第一次用KEEP时都会踩。KEEP的本质是聚合函数它输出的永远是一个标量值不是一行记录。你可以用MAX(order_amount) KEEP (...)取金额用MAX(order_id) KEEP (...)取订单号但没办法一条KEEP就把订单状态、收货地址、支付方式全带出来。想要完整明细就得把需要的字段逐个用KEEP包一遍而且外层聚合列不能配对成同一行。遇到这种场景老实换回窗口函数吧。5.2 外层MIN/MAX和内层ORDER BY字段别搞混写KEEP时要注意外层MIN/MAX的列和内层ORDER BY的列是两个完全独立的列它们承担的任务不一样。ORDER BY的列决定“哪一行算最新”比如order_date外层聚合的列决定“要取哪个值”比如order_amount。我见过有同事把这两个列写成一样结果SQL能跑通但取出来的数据完全不是想要的。他本来要取最新金额写成了MAX(order_date) KEEP (DENSE_RANK LAST ORDER BY order_date)结果返回的是最新订单日期本身。5.3 排序字段有重复时结果不是你想象的那条KEEP用DENSE_RANK所以排序并列的行会一起入组外层聚合会在这些并列行上做极值运算。这个行为前面反复提到了如果业务要求绝对唯一地取“一条”最新记录必须在ORDER BY末尾追加一个唯一字段比如流水号倒序让排序在业务上严格有序。否则你得到的可能是并列行里的极值而不是明细记录本身。5.4 达梦兼容模式很重要KEEP是Oracle语法的延续达梦要在Oracle兼容模式下才能顺畅使用。我实测用的是DM8的Oracle兼容实例执行没问题。但如果初始化实例时选择了MySQL兼容模式KEEP语法可能不被解析。所以遇到达梦上KEEP报语法错误时第一反应别怀疑自己SQL先去确认实例的兼容模式。这个坑我在客户现场遇到过排查了一圈才发现是兼容模式的问题。5.5 字符串聚合并不是乱序取当外层MIN/MAX作用于字符串列时返回的是字典序极值而不是某个随机值。比如MAX(order_status) KEEP (DENSE_RANK LAST ORDER BY order_date)返回的会是并列最新订单里订单状态按字典序最大的一种比如待付款比配送中靠后。这个语义在对比两个状态时很坑我建议拿字符串做外层聚合前先在脑子里过一遍字典序的结果是否符合预期。5.6 KEEP支持SUM/AVG但场景有限KEEP的外层聚合函数不只有MIN/MAXSUM、AVG、COUNT这些在Oracle里也可以搭配KEEP达梦同样支持。比如想看每组最新订单里金额加总后用掉了多少优惠券总额可以用SUM(券金额) KEEP (DENSE_RANK LAST ORDER BY order_date)。但需要注意SUM/AVG会比MIN/MAX多一层计算语义它会把并列行都纳入求和数据分布不均衡时容易产生意外的巨大数值使用前务必想清楚需求。5.7 常见问题速查表现象根本原因解决方案KEEP语法直接报错实例非Oracle兼容模式确认达梦实例兼容模式设置取出来的值不是最新一条ORDER BY排序字段不唯一追加唯一排序字段返回了字典序极大字符串外层聚合作用于字符串列改用其他取数方案或接受该语义需要返回整行多条字段KEEP本质是标量聚合改用ROW_NUMBER()窗口函数结果里有重复数据排序列并列导致多行进组加入流水号等唯一字段排序取最新值和最早值分不清理解FIRST/LAST方向反了先明确排序方向再选FIRST或LAST6. 迁移场景下的一个扩展技巧6.1 从Oracle迁到达梦的SQL改造量这次做适配的最深体会是国产化迁移并不全是重写很多Oracle遗产语法在达梦里是被有意兼容的。KEEP语法不光能直接跑执行计划也没出现可怕的性能回退。这对我这种需要批量迁移报表SQL的人来说省下的工作量非常可观。对比一下存储过程里那些Oracle特有的包和函数迁移起来最费劲而KEEP这种“聚合语法”反而是迁移中最省心的部分。6.2 搭配NVL和CASE让KEEP更实用真实报表场景里KEEP很少单独裸奔组合着用效果更好。比如取最新状态时如果最新记录的状态字段为空业务上要显示为“未知”直接写SELECT customer_id, NVL(MAX(order_status) KEEP (DENSE_RANK LAST ORDER BY order_date), 未知) AS latest_status FROM customer_order GROUP BY customer_id;再比如按状态过滤后再取最新金额可以在外层套CASE和聚合组合SELECT customer_id, MAX(CASE WHEN order_status 已完成 THEN order_amount END) KEEP (DENSE_RANK LAST ORDER BY order_date) AS latest_completed_amount FROM customer_order GROUP BY customer_id;这种组合写法能省掉好几个子查询在达梦的Oracle兼容模式下全部可以直接执行是我这次适配用得最多的套路。6.3 和管理工具搭配使用的小建议我在达梦管理工具里调试KEEP语句时发现一个小技巧先在DISQL里用EXPLAIN查看执行计划时注意观察有没有出现“SORT AGGREGATE”字样有就说明KEEP被正确识别并走了分组内聚合路径。如果发现执行计划退化成了全表关联多半是排序字段上的统计信息没更新跑一下更新统计信息的命令再试会好很多。DBMS_STATS.GATHER_TABLE_STATS(用户名, CUSTOMER_ORDER);这招在处理几百万行大表时特别有用统计信息陈旧会直接影响优化器对KEEP语句执行路径的选择。说到底KEEP语法在达梦数据库里就是一个被很多人忽略的宝藏。它用最精简的语法解决了“分组取最新值”这个高频需求性能表现也经得起实测验证。我个人的习惯是取单个字段的最新值时优先KEEP需要完整明细记录时用窗口函数两种写法配合着用报表SQL基本就没什么难啃的骨头了。这篇文章如果能让更多正做国产化迁移的同行少走点弯路我就很满意了。