6.1.4 一种高效分页批处理方案

常规的分页更新 SQL 一般使用主键或者唯一索引进行排序,这样能避免相邻两页之间出现空隙或重叠;再配合 MySQL limit 语法中非常好用的 offset 功能按固定行数拆分页面,然后把页面包装进独立的事务中,从而实现灵活的分页更新。

  1. begin;
  2. update sbtest1 set pad='new_value' where id in (select id from sbtest1 order by id limit 0,10000);
  3. commit;
  4. begin;
  5. update sbtest1 set pad='new_value' where id in (select id from sbtest1 order by id limit 10000,10000);
  6. commit;
  7. begin;
  8. update sbtest1 set pad='new_value' where id in (select id from sbtest1 order by id limit 20000,10000);
  9. commit;

如上述 SQL 所示,该方案逻辑清晰,代码也易于编写。但是,劣势也很明显:由于需要对主键或者唯一索引进行排序,越靠后的页面参与排序的行数就会越多,相应地扫描数据过程中对 TiKV 的压力也会线性增长。这导致整体处理效率偏低,尤其当批量处理涉及的数据体量较大时,可能会占用过多计算资源,甚至引发性能问题,影响线上业务。

本节将介绍一种改进方案。

这里我们假定的业务需求是,要在一小时内完成 200 万行数据的并发处理。下面我们来初始化一张表 tmp_loan,表结构如下所示;该表初始状态即包含约 200 万行数据。

  1. MySQL [demo]> desc tmp_loan;
  2. +-------------+-------------+------+------+---------+-------+
  3. | Field | Type | Null | Key | Default | Extra |
  4. +-------------+-------------+------+------+---------+-------+
  5. | serialno | int(11) | NO | PRI | NULL | |
  6. | name | varchar(40) | NO | | | |
  7. | businesssum | int(10) | NO | | 0 | |
  8. +-------------+-------------+------+------+---------+-------+
  9. MySQL [demo]> select count(1) from tmp_loan;
  10. +----------+
  11. | count(1) |
  12. +----------+
  13. | 1998985 |
  14. +----------+
  15. MySQL [demo]> select * from tmp_loan limit 10;
  16. +-----------+-----------+-------------+
  17. | serialno | name | businesssum |
  18. +-----------+-----------+-------------+
  19. | 200000000 | 华碧波 | 10000 |
  20. | 200000001 | 陶南 | 10000 |
  21. | 200000002 | 何谷 | 10000 |
  22. | 200000003 | 曹念 | 10000 |
  23. | 200000004 | 潘旋千 | 10000 |
  24. | 200000005 | 魏柔 | 10000 |
  25. | 200000006 | 公羊 | 10000 |
  26. | 200000007 | 司马 | 10000 |
  27. | 200000008 | 陶之 | 10000 |
  28. | 200000009 | 严香 | 10000 |
  29. +-----------+-----------+-------------+

改进方案的基本思路是,首先将数据按照主键排序,然后调用窗口函数 row_number() 为每一行数据生成行号,接着调用聚合函数按照设置好的页面大小对行号进行分组,最终计算出每页的最小值和最大值。下面的代码演示了具体的做法:

  1. MySQL [demo]> selecct min(t.serialno) as start_key, max(t.serialno) as end_key, count(*) as page_size from ( select *, row_number () over (order by serialno) as row_num from tmp_loan ) t group by floor((t.row_num - 1) / 50000) order by start_key;
  2. +-----------+-----------+-----------+
  3. | start_key | end_key | page_size |
  4. +-----------+-----------+-----------+
  5. | 200000000 | 200050001 | 50000 |
  6. | 200050002 | 200100007 | 50000 |
  7. | 200100008 | 200150008 | 50000 |
  8. | 200150009 | 200200013 | 50000 |
  9. | 200200014 | 200250017 | 50000 |
  10. | ........ |.......... | ........ |
  11. | 201900019 | 201950018 | 50000 |
  12. | 201950019 | 201999003 | 48985 |
  13. +-----------+-----------+-----------+
  14. 40 rows in set (1.51 sec)

接下来,只需要使用 serialno between start_key and end_key 查询每个分片的数据即可。

  1. MySQL [demo]> select serialno from tmp_loan where serialno between 200050002 and 200100007;
  2. +-----------+
  3. | serialno |
  4. +-----------+
  5. | 200050002 |
  6. | 200050003 |
  7. | 200050004 |
  8. | 200050005 |
  9. | 200050006 |
  10. | ......... |
  11. +-----------+
  12. 50000 rows in set (0.070 sec)

当我们需要批量修改数据时,也可以借助上面计算好的分片信息,实现高效数据更新。

  1. MySQL [demo]> update tmp_loan set businesssum = 6666 where serialno between 200000000 and 200050001;
  2. Query OK, 50000 rows affected (0.89 sec)
  3. Rows matched: 50000 Changed: 50000 Warnings: 0
  4. MySQL [demo]> select * from tmp_loan order by serialno limit 10;
  5. +-----------+-----------+-------------+
  6. | serialno | name | businesssum |
  7. +-----------+-----------+-------------+
  8. | 200000000 | 华碧波 | 6666 |
  9. | 200000001 | 陶南 | 6666 |
  10. | 200000002 | 何谷 | 6666 |
  11. | 200000003 | 曹念 | 6666 |
  12. | 200000004 | 潘旋千 | 6666 |
  13. | 200000005 | 魏柔 | 6666 |
  14. | 200000006 | 公羊 | 6666 |
  15. | 200000007 | 司马 | 6666 |
  16. | 200000008 | 陶之 | 6666 |
  17. | 200000009 | 严香 | 6666 |
  18. +-----------+-----------+-------------+

总体而言,改进方案由于规避了频繁的数据排序操作造成的性能损耗,显著改善了批量处理的效率。