SQL Server数据迁移这件事,远比你想的复杂——但也远比你想的简单(下)



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


接着上篇聊。上篇说到我实测了KES V9R4C019,100并发场景下复杂查询TPS提升60%、响应时间压缩到原来的1/10这个事。这篇我想把这事拆开了揉碎了好好说说,因为光给个数字没说服力,你得知道这个数字背后到底发生了什么,才能判断它到底是真功夫还是花架子。

那个让我震撼的BI报表场景

先说说BI报表这个场景吧,因为这个是最直观的、业务方最能感知到的性能变化。

做过的BI系统的同学都知道,BI报表有个特别让人头疼的特点:SQL特别复杂,但用户对响应时间的期望又特别高。你做交易系统,接口响应3秒可能用户还能接受;但你做BI报表,用户点一下查询,等30秒还没出结果,他就开始焦虑了,等1分钟就开始刷新页面,等3分钟就直接提工单了。更搞人的是老板看的那些大屏报表,老板可没什么耐心等你跑查询,他要的就是即时呈现,你让他等一分钟他能记你半年。

我之前负责的那个BI平台迁移项目,问题最严重的就是月度经营分析报表。这个报表的数据来源涉及七八张表,有的表几千万行数据,查询SQL大概长这样:

sql 复制代码
-- 月度经营分析报表核心查询(简化版,实际比这复杂得多)
SELECT 
    r.region_name,
    p.product_line,
    c.channel_name,
    (
        SELECT SUM(s.gross_amount) 
        FROM sales_fact s 
        WHERE s.region_id = r.region_id
          AND s.product_id IN (
              SELECT product_id FROM dim_product 
              WHERE product_line = p.product_line
          )
          AND s.sale_date BETWEEN '2025-06-01' AND '2025-06-30'
    ) AS gross_sales,
    (
        SELECT SUM(s.discount_amount)
        FROM sales_fact s
        WHERE s.region_id = r.region_id
          AND s.channel_id = c.channel_id
          AND s.sale_date BETWEEN '2025-06-01' AND '2025-06-30'
    ) AS total_discount,
    (
        SELECT COUNT(DISTINCT s.customer_id)
        FROM sales_fact s
        WHERE s.region_id = r.region_id
          AND s.sale_date BETWEEN '2025-06-01' AND '2025-06-30'
    ) AS unique_customers,
    SUM(f.net_amount) AS net_revenue,
    AVG(f.unit_price) AS avg_price
FROM dim_region r
CROSS JOIN dim_product p
CROSS JOIN dim_channel c
INNER JOIN sales_fact f ON r.region_id = f.region_id 
    AND p.product_id = f.product_id 
    AND c.channel_id = f.channel_id
WHERE f.sale_date BETWEEN '2025-06-01' AND '2025-06-30'
GROUP BY r.region_name, r.region_id, p.product_line, c.channel_name, c.channel_id
ORDER BY gross_sales DESC;

你看看这SQL,三个标量子查询嵌在SELECT列表里,每个子查询还都关联了外层查询的字段,其中一个子查询里还嵌套了个IN子查询。CROSS JOIN做维度组合,再跟事实表做内连接。这种SQL在SQL Server上能跑,因为它查询优化器比较猛,能把标量子查询自动转换成 LEFT JOIN 或者其他更高效的执行方式。

在SQL Server上这段查询的响应时间大概是4到5秒。迁到旧版本的国产库上之后,变成了40多秒。你想想这个体验差距:原来5秒出结果,用户觉得"还行,能接受";现在45秒出结果,用户觉得"这系统是不是挂了"。

这就是"能用"和"好用"之间的鸿沟最真实的写照。SQL能跑、结果正确,这是"能用";但45秒的响应时间让这个查询在实际业务中几乎不可用,这就是"不好用"。

升级到V9R4C019之后呢?同一段SQL,响应时间降到了4秒左右。从45秒到4秒,这个变化是实实在在的,不是什么实验室数据。业务方的反馈是"终于不用等着喝完一杯咖啡才出报表了"。

我后来又跑了几个类似的BI报表查询,结果都差不多:

sql 复制代码
-- 另一个典型的BI分析查询,带标量子查询的环比同比计算
SELECT 
    t1.year_month,
    t1.region,
    t1.monthly_sales,
    (
        SELECT SUM(monthly_sales) 
        FROM monthly_summary t2 
        WHERE t2.region = t1.region 
          AND t2.year_month = 
              FORMAT(DATEADD(MONTH, -1, 
                  CAST(t1.year_month + '01' AS DATE)), 'yyyyMM')
    ) AS last_month_sales,
    (
        SELECT SUM(monthly_sales) 
        FROM monthly_summary t3 
        WHERE t3.region = t1.region 
          AND t3.year_month = 
              FORMAT(DATEADD(MONTH, -12, 
                  CAST(t1.year_month + '01' AS DATE)), 'yyyyMM')
    ) AS last_year_sales,
    CASE 
        WHEN t1.monthly_sales > 0 THEN 
            ROUND((t1.monthly_sales - (
                SELECT SUM(monthly_sales) 
                FROM monthly_summary t2 
                WHERE t2.region = t1.region 
                  AND t2.year_month = 
                      FORMAT(DATEADD(MONTH, -1, 
                          CAST(t1.year_month + '01' AS DATE)), 'yyyyMM')
            )) / (
                SELECT SUM(monthly_sales) 
                FROM monthly_summary t2 
                WHERE t2.region = t1.region 
                  AND t2.year_month = 
                      FORMAT(DATEADD(MONTH, -1, 
                          CAST(t1.year_month + '01' AS DATE)), 'yyyyMM')
            ) * 100, 2)
        ELSE NULL 
    END AS mom_growth_rate
FROM monthly_summary t1
WHERE t1.year_month IN ('202505', '202506')
ORDER BY t1.region, t1.year_month;

这种SQL就更搞人了,CASE WHEN里面嵌着标量子查询做环比增长率计算,同一个子查询还出现了两次。在旧版本上这个查询跑了将近1分钟,升级后大概5秒。虽然比SQL Server的3秒还慢了一点点,但已经是"可接受"的范畴了。

标量子查询到底为什么慢,又是怎么快的

这一段我想稍微深入一点聊聊技术原理,因为不搞清楚这个,你心里总是不踏实。

标量子查询为什么容易成为性能杀手?说到底是因为它打破了数据库"集合操作"的美感。数据库最擅长的是批量处理------给我一个集合,我对这个集合做操作,返回结果。但标量子查询本质上是一个"逐行操作"的模式:对外层结果的每一行,去执行一次子查询。这跟在应用代码里写for循环然后每次循环都查一次数据库是一个道理,只不过它发生在数据库内核里。

用伪代码来理解就是这样的区别:

python 复制代码
# 旧版本的执行方式(概念性类比)
# 相当于N+1查询问题
outer_results = query("SELECT * FROM dim_region")  # 假设返回10000行
for row in outer_results:
    # 对每一行执行一次子查询
    row.total_sales = query(
        "SELECT SUM(amount) FROM sales WHERE region_id = ?", 
        row.region_id
    )
    # 再执行一次另一个子查询
    row.avg_discount = query(
        "SELECT AVG(discount_rate) FROM sales WHERE region_id = ? AND product_category = ?",
        row.region_id, row.product_category
    )
# 总共执行了 1 + 10000*2 = 20001 次查询

# 新版本的执行方式(概念性类比)
# 相当于批量查询,用JOIN替代N+1
outer_results = query("""
    SELECT d.*, s1.total_sales, s2.avg_discount
    FROM dim_region d
    LEFT JOIN (
        SELECT region_id, SUM(amount) as total_sales
        FROM sales WHERE sale_date >= '2025-01-01'
        GROUP BY region_id
    ) s1 ON d.region_id = s1.region_id
    LEFT JOIN (
        SELECT region_id, product_category, AVG(discount_rate) as avg_discount
        FROM sales
        GROUP BY region_id, product_category
    ) s2 ON d.region_id = s2.region_id 
        AND d.product_category = s2.product_category
""")
# 总共执行了 1 次查询(内部被优化成Hash Join)

当然数据库内核里实际做的事情比这个伪代码复杂得多。它不是真的把子查询"改写成JOIN",而是在查询优化阶段识别出标量子查询可以被展开(flatten),然后选择更高效的执行策略。具体来说可能涉及子查询解关联、物化、连接顺序优化、并行执行等一系列优化器的内部机制。

从V9R2C14的资料来看,当时的优化主要针对的是"目标列中包含相关标量子查询和包含等价性谓词及传递谓词条件的场景"。说白了就是:如果你的标量子查询里用到了外层查询的某个字段(这就是"相关"的意思),并且这个字段上有等值条件(等价性谓词)或者可以通过传递关系推导出等值条件,那优化器就可以对这个子查询做特殊处理,把它从逐行执行转成批量执行。

V9R4C019大概率是在这个基础上进一步扩展了优化的覆盖范围。因为从我实测的结果来看,一些V9R2C14优化不了的复杂标量子查询场景,在V9R4C019上也变快了。具体扩展了哪些场景,官方资料里没有详细说,但从效果来看,至少含标量子查询的多表关联分析场景是明显改善了的。

我特别想指出的一点是:这种优化不是靠加索引、调参数能做到的。索引和参数调优解决的是"已有执行计划不够高效"的问题,但执行计划本身的选择------是用Nested Loop还是Hash Join,是把子查询保持为子查询还是展开成连接------这是查询优化器的决策。如果优化器做了一个错误的决策,你加再多的索引也没用。

这也就是为什么我说从SQL Server迁移过来的性能问题,本质上是数据库内核的问题。你不可能指望通过外部调优来解决查询优化器的内在缺陷。V9R4C019的这次升级,真正有价值的地方在于它在优化器层面做了改进,而不是在兼容层做补丁。

100并发下的压力测试细节

光说单个查询的响应时间可能还不够直观,我再聊聊并发场景下的测试情况。

做迁移性能评估的时候,单查询响应时间只是一方面,并发能力同样关键。因为真实业务环境下不可能只有一个用户在查询,通常都是几十甚至上百个请求同时打过来。并发场景下的性能表现取决于很多因素:锁竞争、CPU调度、内存分配、I/O吞吐、连接管理等等。

我们的测试方案大概是这样的:用JMeter模拟100个并发用户,每个用户循环执行10个预定义的复杂查询SQL,持续30分钟,统计TPS(每秒事务数)和P95响应时间。

先在旧版本上跑:

yaml 复制代码
# 旧版本压力测试结果(概要)
Duration: 30 minutes
Concurrency: 100
Total Requests: 12,847
Success Rate: 99.2%
TPS: ~7.1
Average Response Time: 13.8s
P95 Response Time: 28.4s
P99 Response Time: 41.2s

TPS只有7.1,也就是说每秒只能完成7个复杂查询。平均响应时间13.8秒,P95到了28秒。这个数据说实话很不好看,业务方看到这个肯定要炸。

然后升级到V9R4C019,同样的测试条件:

yaml 复制代码
# V9R4C019压力测试结果(概要)
Duration: 30 minutes
Concurrency: 100
Total Requests: 20,523
Success Rate: 99.8%
TPS: ~11.4
Average Response Time: 8.6s
P95 Response Time: 12.1s
P99 Response Time: 18.7s

TPS从7.1提升到11.4,提升幅度大概60%。平均响应时间从13.8秒降到8.6秒,P95从28.4秒降到12.1秒。

等等,这里有个有意思的地方。TPS提升60%是没错的,但响应时间的改善幅度比TPS的提升幅度更大------平均响应时间降了37%,P95降了57%。这说明什么?说明在高并发场景下,性能改善不仅仅是单个查询变快了,还可能是并发处理能力本身也提升了,比如减少了锁等待、改善了内存管理之类的。

不过我也要说句公道话,这个测试结果跟SQL Server的基准对比,还有差距。SQL Server在同样的测试条件下TPS大概在13到14左右,平均响应时间7秒左右。也就是说V9R4C019已经非常接近SQL Server的水平了,但还没有完全追平。

但是------注意这个但是------对于从SQL Server迁移过来的场景来说,这个性能水平已经完全可用了。从"40秒等结果"到"4到8秒出结果",从"TPS只有7"到"TPS超过11",这个跨越让系统从"业务不可用"变成了"业务可用"。这才是最重要的。

我后来还做了一个更极端的测试,200并发:

sql 复制代码
-- 200并发下执行的典型查询之一
-- 这个查询有3个标量子查询,涉及4张表的关联
SELECT 
    o.order_id,
    o.customer_id,
    o.order_date,
    o.total_amount,
    (
        SELECT c.customer_name 
        FROM customers c 
        WHERE c.customer_id = o.customer_id
    ) AS customer_name,
    (
        SELECT SUM(d.quantity) 
        FROM order_details d 
        WHERE d.order_id = o.order_id
    ) AS total_quantity,
    (
        SELECT p.payment_status
        FROM payments p 
        WHERE p.order_id = o.order_id
          AND p.payment_type = 'PRIMARY'
    ) AS payment_status,
    (
        SELECT AVG(rating) 
        FROM reviews r 
        WHERE r.customer_id = o.customer_id
          AND r.create_date >= o.order_date
    ) AS avg_rating_after_order
FROM orders o
WHERE o.order_date BETWEEN '2025-06-01' AND '2025-06-30'
    AND o.status = 'COMPLETED'
ORDER BY o.total_amount DESC
OFFSET 0 ROWS FETCH NEXT 50 ROWS ONLY;

200并发下旧版本直接就扛不住了,TPS掉到3以下,响应时间P99超过了60秒,超时率飙升。V9R4C019虽然也有性能下降(这很正常,并发翻倍嘛),但TPS还能维持在7到8左右,P95响应时间在20秒以内。作为对比,SQL Server在200并发下TPS大概是9到10,P95在15秒左右。

你看这个数据,差距在缩小。在100并发时V9R4C019跟SQL Server的TPS差距大约是18%(11.4 vs 13.5),在200并发时差距缩小到了约20%到30%(7.5 vs 9.5)。考虑误差和不同并发下的负载特性,基本可以说在同一个量级上了。

兼容性:不只是"能跑"

聊完了性能,我想再说说兼容性。因为迁移这件事,性能和兼容是两条腿,缺一不可。性能再好,如果SQL跑不了那也白搭。

V9R4C019在兼容性方面补齐了几个很重要的能力。之前我提到过SQL Server特有的MERGE、并行DML、OUTPUT子句,这次都原生支持了。这意味着很多原来需要手工改写的T-SQL代码,现在直接连上去就能跑。

拿OUTPUT子句来说,在SQL Server里这玩意儿用得特别多,特别是在审计日志场景:

sql 复制代码
-- SQL Server里的审计日志写入
-- 用OUTPUT子句把删除的数据自动写入审计表
DELETE FROM user_sessions
OUTPUT deleted.session_id, deleted.user_id, deleted.login_time, SYSDATETIME()
INTO session_audit_log (session_id, user_id, login_time, delete_time)
WHERE last_activity_time < DATEADD(MINUTE, -30, SYSDATETIME());

-- 更新操作也可以用OUTPUT
UPDATE inventory 
SET stock_count = stock_count - o.order_quantity,
    update_time = SYSDATETIME()
OUTPUT inserted.product_id, inserted.stock_count, deleted.stock_count, 
       inserted.update_time
INTO inventory_change_log (product_id, new_stock, old_stock, change_time)
FROM orders o
WHERE inventory.product_id = o.product_id 
  AND o.status = 'CONFIRMED';

这种SQL在以前的国产数据库上基本跑不了,你得拆成两步:先查出要删除/更新的数据,执行操作,再把数据写入审计表。代码量翻倍,而且不是原子操作了,中间出问题可能导致审计数据丢失。

现在直接支持OUTPUT子句了,迁移的时候就不用改这部分代码。这就叫"0改造"------不是说所有SQL都不用改,而是大部分常见的SQL Server特有写法都能直接跑了。

窗口函数方面也有改进。SQL Server的窗口函数支持得比较全面,特别是RANGE类型的窗口框架:

sql 复制代码
-- SQL Server窗口函数,RANGE类型的窗口框架
SELECT 
    employee_id,
    department_id,
    salary,
    hire_date,
    -- 累计薪资
    SUM(salary) OVER (
        PARTITION BY department_id 
        ORDER BY hire_date 
        RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS cumulative_salary,
    -- 部门平均薪资(同一入职日期的)
    AVG(salary) OVER (
        PARTITION BY department_id 
        ORDER BY hire_date 
        RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) AS dept_avg_salary,
    -- 前后记录对比
    LAG(salary, 1) OVER (
        PARTITION BY department_id 
        ORDER BY hire_date
    ) AS prev_salary,
    LEAD(salary, 1) OVER (
        PARTITION BY department_id 
        ORDER BY hire_date
    ) AS next_salary
FROM employees
WHERE department_id IN (1, 2, 3, 4, 5);

PIVOT/UNPIVOT也是SQL Server里的高频操作,做行列转换特别方便:

sql 复制代码
-- PIVOT:行转列
SELECT product_id, [Q1], [Q2], [Q3], [Q4]
FROM (
    SELECT product_id, quarter, sales_amount
    FROM quarterly_sales
) AS src
PIVOT (
    SUM(sales_amount)
    FOR quarter IN ([Q1], [Q2], [Q3], [Q4])
) AS piv;

-- UNPIVOT:列转行
SELECT product_id, quarter, sales_amount
FROM quarterly_sales_wide
UNPIVOT (
    sales_amount
    FOR quarter IN ([Q1], [Q2], [Q3], [Q4])
) AS unpiv;

这些在V9R4C019里都支持了。另外LIKE通配符的全面支持也很实用,SQL Server里 LIKE 'a-c%' 这种字符范围匹配终于不用改了。

还有一个我觉得比较有意思的特性是跨库访问系统视图。以前在不同数据库之间做数据交互基本要靠ETL或者dblink,现在Oracle、MySQL、KingbaseES之间可以直接通过系统视图互访。这个在迁移过渡期特别有用,因为迁移通常不是一刀切的,可能有段时间需要新旧库并存。

这个案例还有一个细节我觉得值得注意:客户说"查询效率、并发能力、运维成本均优于原有架构"。这说明迁移之后不只是"能用",而且是"更好用"了。当然客户的评价可能有滤镜,但从技术角度来说,如果原来用的是老旧版本的SQL Server或者其他商业数据库,迁到新一代的国产数据库上,性能提升也不是不可能的------毕竟数据库技术也在发展,新的优化器、新的存储引擎、新的缓存机制都可能带来性能红利。

不过我也要提一句,这个案例里迁的主要是从Oracle和MySQL过来的系统,不是SQL Server。SQL Server迁移的技术难度跟Oracle/MySQL不是一个量级的,因为SQL Server的T-SQL语法体系跟其他数据库差异更大。所以这个案例的"极简"体验,不能直接推导到SQL Server迁移场景上。SQL Server迁移要达到同样的"极简"程度,还需要更多的语法兼容和优化器改进。

运维侧的体验变化

做迁移不能只看迁移过程和性能,运维侧的体验也很重要。因为迁移完之后,系统就交给运维团队来管了,如果运维工具跟不上,出了问题排查不了,那也是个坑。

V9R4C019在运维方面加了不少东西。首先是内置的故障收集分析工具,能自动抓取日志、生成诊断报告。这个功能对于运维来说太重要了------以前出了问题,DBA要手动去翻日志文件、拼凑各种信息来判断根因,这个过程既耗时又容易遗漏。有了自动化的故障收集分析工具,从"人工拼图"变成了"工具出结论",效率完全不一样了。

官方说"出了问题5分钟找到根因",这个时间我觉得还算合理。当然前提是故障类型在工具的识别范围内,太复杂的故障可能还是需要人工介入。但至少常见的故障------比如锁等待、死锁、连接超时、空间不足这些------应该能自动识别。

HA组件也做了改进,支持独立运维,企业可以自主管理切换逻辑。以前很多国产数据库的HA组件是个黑盒子,出问题只能找厂商支持。现在能自己管了,运维团队的主控权更强了。

备份恢复方面也有升级。块级增量备份变成了默认的归档模式,全量+增量+归档日志三位一体。PITR(Point-in-Time Recovery)支持最优恢复路径计算,能自动选最快的恢复路径。备份窗口从小时级压缩到分钟级。

这些变化对运维的实际影响是什么呢?我举个具体场景:

bash 复制代码
# 以前的备份方式(概念性示意)
# 全量备份:每周日凌晨2点,耗时约3小时
pg_basebackup -D /backup/full -Fp -Xs -P

# 归档日志:持续WAL归档
archive_command = 'cp %p /archive/%f'

# 恢复时需要手动定位时间点,手动选择恢复路径
# 恢复耗时:全量恢复(3h) + 日志回放(看日志量)

# V9R4C019的备份方式
# 块级增量备份,默认开启
# 全量:周日,耗时约40分钟(块级备份比文件级快得多)
# 增量:每天,耗时约5-10分钟
# 归档日志:持续

# PITR恢复,自动选最优路径
# 如果时间点在最近一次增量之后,只需要回放少量日志
# 恢复耗时可能只有几分钟

从3小时备份到40分钟备份,从手动选恢复路径到自动选最优路径,这些变化在运维日常中是真金白银的时间节约。

智能调优:不改应用也能优化

还有一个功能我觉得值得一提:QueryMapping模糊匹配和智能SQL调优建议。

迁移之后经常会遇到一个尴尬的问题:某些SQL在原来的数据库上跑得很快,但在新库上很慢。原因可能是优化器选择了不同的执行计划,或者索引策略不同。这时候如果改SQL,就要动应用代码,走开发流程、测试流程、发布流程,周期很长。不改SQL吧,性能又不行。

QueryMapping的模糊匹配能自动发现相近SQL的执行差异。什么意思呢?就是它会把你的SQL跟数据库里执行过的其他SQL做比对,找到那些结构相似但执行计划差异很大的SQL。

写在最后的一些思考

写到这里我这个测试毕竟只覆盖了一个特定场景。不同业务系统的SQL写法千差万别,有些场景可能表现好,有些可能还有差距。我不能因为一个场景测出来不错就拍胸脯说所有场景都没问题------这不严谨。真正要做迁移决策的时候,还是得拿你自己的业务SQL去测,拿你自己的数据量去压,别人测出来的数据只能做参考。

最后我想说,数据库迁移从来不是终点,而是一个新的起点。迁过去之后还有很长的路要走------持续调优、版本升级、架构演进。但至少,V9R4C019让这条路的起点不那么坎坷了。至于后面能走多远,取决于厂商的持续投入和用户的深度使用。我们拭目以待吧。

相关推荐
Zane19941 小时前
JRE 去哪了?一次讲清楚 JDK、JRE、JVM 到底是什么关系?
java·后端
JavaPub1 小时前
Ontology 本体论是什么?从哲学概念到 AI、知识图谱与软件工程
后端
liuxiaocheng1 小时前
聊聊 Vercel AI SDK 的流式协议:前后端到底是怎么"边想边说"的
前端·后端
得物技术1 小时前
得物知识问答:复合检索 Agent 的系统设计实践
人工智能·后端·ai编程
andongni2031 小时前
SpringBoot 入门实验报告
java·spring boot·后端
李广坤2 小时前
Agent 记忆系统详解:从"金鱼脑"到"过目不忘"的进化之路
后端
zerozrc02 小时前
Java 方法调用的底层原理
java·后端
2601_953824612 小时前
【计算机毕业设计】基于Spring Boot+Vue的家电购物系统的设计与实现
spring boot·后端·课程设计
睡觉时不困4422 小时前
9.命令行通用规律:所有命令的底层语法
后端