一次数据迁移工具的性能优化及其原理

文摘   科技   2024-01-09 11:12   浙江  

问题背景


假设有一个数据库Migration工具, 其主要承担两个工作:

  1. 将数据库中特定的表导出为CSV

  2. 将CSV中的数据导入到数据库

已知数据库的类型为 Postgresql,数据库的表行数为千万级,数据量约为7GB。想要完成对应的工作,我们可以使用 postgresql 的 JDBC driver,并依靠它提供的 CopyManager 来实现导出到 CSV 和从 CSV 导入。


在实际的测试场景中发现, 对于一张3700万行的表, 导出到CSV耗时约为6分钟,导入本地的数据库耗时约为5小时


如何能提升导入性能?


一次导入耗时5小时,性能上有些不可接受。作为一个 Migration 工具,我们不能对客户的表结构做出任何的修改,在这个大前提下,该怎么提升性能呢?


一个简单的方案为:在导入数据库之前,删除数据库表中的索引,在导入数据库之后,重建索引。


经测试,采用这个方案以后,对于相同表,导入到相同的本地数据库的耗时从5小时缩短到了15分钟


为什么可以这么做?


程序员三问:这个程序编译挂了,但是为什么呢?这个程序跑失败了,但是为什么呢?这个程序跑成功了,但是为什么呢?


重新整理一下问题:

  • 已知 Postgresql 的默认索引类型为 B-Tree,一种平衡搜索树

  • 对于未进行优化的方案(下文称为“方案一”),相当于我们需要向一棵平衡搜索树中添加N个节点

  • 对于优化后的方案(下文称为“方案二”),相当于我们先获取了所有的节点,然后用这些节点生成一棵平衡搜索树

对于方案一,我们称之为在线算法(online algorithm),而方案二则称为离线算法(offline algorithm)。


在计算机科学中,在线算法是一种处理输入资料的独特形式,其演算过程中并不要求所有输入资料在算法开始运始之一刻即完备,反而可对逐步输入的资料加以处理并在输入完最后一项资料之后输出运算结果。与之相对的称为离线算法,则假设输入资料在运算开始前已完备。


对于很多问题,在线算法的效率是弱于离线算法的,因为在线算法的要求是在每一次的输入处理完毕之后,数据都必须处于一个合格的状态.


而对于我们这个问题场景,我们并不需要在数据插入的过程中一直保持索引可用,只需要在数据插入完毕之后保持索引可用就行。


对于在线算法,要求每次插入节点之后,平衡树依旧保持平衡,因此每次的插入都有可能会涉及到树的平衡旋转并重新计算节点高度,这会需要额外的操作开销。



因此生成一棵树的步骤如下:

  1. 对于当前输入节点,找到合适的叶子节点并插入

  2. 从叶子节点反向回溯至根节点,判断每个节点是否保持平衡,如果不平衡则进行旋转

  3. 重复步骤1和步骤2直到所有的节点都插入完毕

以下代码为AVL树的一个简易实现,AVL树为二叉平衡搜索树:


Node* Insert(Node* node,int val){    if(node == NULL)    {        node = new Node();        node->val = val;    }    else if(val > node->val)    {        node->right = Insert(node->right,val);        int disH = node->DisHeight();        if(disH == -2)  // 如果树失去平衡则需要进行旋转        {            if(val > node->right->val) node = RR(node);            else node = RL(node);        }    }    else if(val < node->val)    {        node->left = Insert(node->left,val);        int disH = node->DisHeight();        if(disH == 2)  // 如果树失去平衡则需要进行旋转        {            if(val < node->left->val) node = LL(node);             else node = LR(node);        }    }    node->UpdateHeight();  // 更新节点高度    return node;}
void online_algorithm(vector<int>& v){    Node* root = NULL,    for(int i=0;i<v.size();i++) root=Insert(root, v[i]);    return root;}


可以看出,时间复杂度为O(N*log(N)), 但是拥有较大的常数开销(每次插入新节点都需要访问从根节点到叶子节点的路径上的所有节点,可能还需要执行旋转操作)。


而对于离线算法,一个简单的从随机数组生成一棵平衡搜索树的方法如下:

  1. 对数组进行排序

  2. 从一个有序数组生成一棵平衡搜索树

以下代码同样为AVL树的一个简易实现:


Node* Build(const vector<int>& v, int l, int r){    if (l<=r)    {        int mid=(l+r)/2;    // 取中点作为当前子树的根节点        Node* x=new Node();        x->val=v[mid];        x->left=Build(v,l,mid-1);    // 递归处理左子树        x->right=Build(v,mid+1,r);   // 递归处理右子树        x->UpdateHeight();  // 更新节点高度        return x;    }    return NULL;}void online_algorithm(vector<int>& v){    sort(v.begin(), v.end());    return Build(v, 0, v.size()-1);}


可以看出, 从一个有序数组生成一棵AVL树的时间效率为O(N), 其中N为节点个数,主要的时间开销为给数组进行排序,而给数组排序的时间效率为O(N*log(N))。


因此总的时间复杂度为O(N*log(N)), 但是拥有较小的常数开销(现代c++ std库中的sort)。


在数据量较小的情况下,在线算法和离线算法的差距可能不算大,但是随着数据量的增加,差距将逐渐显现,以下是一个测试结果,单位为毫秒:


总节点数量

在线算法耗时

离线算法耗时

10k

4

3

100k

80

44

1m

2737

488

10m

55908

5813


回到最初的场景


回到我们最初的问题, 将CSV中的数据导入到数据库中,提前删除索引,再导入数据,最后重建索引,能够获得多少的性能提升呢?


以下是测试的结果,耗时单位为秒:


CSV行数

CSV大小

耗时(未进行优化)

耗时(重建检索)

1M

181M

33

22

2M

376M

73

48

5M

1G

279

124

10M

2G

968

(16.1

mins)

258

(4.3

mins)

37M

7G

18956

(5

hours+)

947

(15.7

mins)


可以看到, 在数据到达一定程度的时候,性能提升非常显著。


除了在在线离线算法上的不同之外,还有一个原因是,索引的大小达到了上限,由于是重新导入整张数据表的场景,数据无法拥有显著的冷热区分,因此必定会存在一部分索引存在于磁盘上而非内存中,因此会涉及到较多的IO操作。


而在重建索引的过程中,已经创建好的索引不再需要修改,也会拥有较少的IO操作开销。而这一点在数据量较少的时候无法体现。


总结


对于一个数据库的大批量写入程序,如果在写入的过程中不需要保证数据库的可用性,那么在写入之前尝试先移除数据库对应表的索引可能是个好主意(别忘了做性能测试哦)。



最后,来自 SQLite 的曾经的优化公告,与君共勉:


The latest SQLite 3.8.7 alpha version is 50% faster than the 3.7.17 release from 16 months ago. That is to say, it does 50% more work using the same number of CPU cycles.

The 50% faster number above is not about better query plans. This is 50% faster at the low-level grunt work of moving bits on and off disk and search b-trees. We have achieved this by incorporating hundreds of micro-optimizations. Each micro-optimization might improve the performance by as little as 0.05%. If we get one that improves performance by 0.25%, that is considered a huge win. Each of these optimizations is unmeasurable on a real-world system but if you do enough of them, they add up.


最新的 SQLite 3.8.7 alpha版本比16个月前发布的3.7.17版本快50%。也就是说,在相同的CPU周期内,它完成了1.5倍的工作。


上面的50%更快的数字并不是指更好的查询算法。这是指在移动位于磁盘上的数据和搜索B树等底层任务上,速度提高了50%。我们通过引入数百个微小优化来实现这一目标。每个微小优化可能只能提高0.05%的性能。如果我们找到一个可以提高0.25%性能的优化,那就被认为是巨大的胜利。在真实系统上,无法测量这些优化的影响,但如果你做足够多的优化,它们就会累积起来。


微策略 商业智能
微策略 MicroStrategy (Nasdaq: MSTR) 是企业级分析和移动应用软件行业的佼佼者。关注我们了解行业资讯、技术干货和程序员日常。
 最新文章