暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

PolarDB-X最佳实践系列:规避Batch写入的那些“坑”

PolarDB 2024-11-11
195
Batch操作(包括Batch Insert、Batch Update、Batch Delete)是数据库中常用的技巧,主要几个作用:
  1. 可以有效减少单条记录的处理时间(通常可以由毫秒级降到0.1毫秒级),在无法通过增加并发数提升吞吐的应用中(例如有些业务需要单线程消费MQ),可以通过Batch操作有效的提升吞吐;
  2. 合并事务日志的提交等,降低数据库的负载。
但由于种种原因(主要是MySQL官方驱动的问题),在使用PolarDB-X、MySQL时,经常出现“我以为走了Batch,实际上还是单条执行”的情况。
本文将介绍在PolarDB-X(当然,对MySQL也适用)中,各种操作如何做到Batch化

多语句与Batch

PolarDB-X(MySQL)中有两种形式都可以将一堆数据一次性提交到数据库,写在多条SQL中的叫“多语句”,写在单条SQL中的叫“Batch”。
例如,这是多语句(本质上是多条SQL):
    insert into t1 (c1,c2) values (1,1);insert into t1 (c1,c2) values (2,2);
    这叫Batch(本质上还是一条SQL):
      insert into t1 (c1,c2) values (1,1),(2,2);
      区别在于:
      1. 多语句的各个子句之间,是要串行执行的(标准就是这样定义的,各个子句之间是允许存在依赖关系的),一条执行完才能执行下一条。因此,多语句只能节省客户端到数据库之间的网络RTT(1次RTT即可),无法节省数据库内部之间的一些开销(比如数据库主备之间,写入操作每个子句都需要至少一次RTT)。
      2. Batch,各个记录之间,数据库是可以并行去写入的(对于PolarDB-X来说,各记录是并行写入的;对于MySQL来说,各个记录还是串行的写入的);主备之间(PolarDB-X的leader和follower之间),整个Batch只有一次RTT。


      以一个典型的三可用区部署的PolarDB-X为例,同样要写入1000条记录:
      1. 如果是多语句,只考虑网络上的延迟,理论上的耗时至少是(0.1ms+(0.1ms+0.8ms+0.8ms+0.1ms)*1000+0.1ms)= 1800.2ms;
      2. 如果是Batch,只考虑网络上的延迟,理论上的好是至少是(0.1ms+(0.1ms+0.8ms+0.8ms+0.1ms)+0.1ms)=2ms(当然,实际上肯定会比2ms大,但会远远小于1800ms)。
      可以看出来,两种模式看起来都是一次性把数据提交到了数据库,但实际上效果是有很大差别的。我们应该尽可能使用Batch,而不是多语句。
      但是,MySQL驱动(mysql-connector-java)、ORM框架(比如mybatis)等,这些东西稍不留神,就可能把你认为是Batch的操作,转成了多语句执行(甚至单语句执行)。下面将介绍各种Batch操作的正确姿势。

      Batch Insert

      INSERT的Batch很简单,有且仅有这一种形式:
        INSERT INTO t1 (c1,c2) VALUES (1,1),(2,2);
        在Java中实现它有几种方式:
        1. 使用PreparedStatement的addBatch()、executeBatch()接口(注意,不是addBatch(String sql),这是多语句):

            Connection conn = xxx;
            PreparedStatement pstmt = conn.prepareStatement("INSERT INTO t1 (c1,c2) VALUES (?,?)");


            pstmt.setInt(1, 1);
            pstmt.setInt(2, 1);
            pstmt.addBatch();


            pstmt.setInt(1, 2);
            pstmt.setInt(2, 2);
            pstmt.addBatch();


            pstmt.executeBatch()
          看起来很简单?但坑在于,这种方式是依赖mysql-connector-java将SQL和参数拼接成Batch的形式,只有当JDBC url中,设置了rewriteBatchedStatements=true(默认是false),mysql-connector-java才会做这个转换,否则,mysql-connector-java是以多语句的方式执行两条insert SQL。
          同理,如果使用mybatis等框架,如果mapper中的定义的SQL模板也是类似形式,那它能否走到Batch也受该参数控制。为了避免踩坑,更推荐第二种形式
          1. 应用(包括mybatis)直接将语句拼成Batch的形式,这样mysql-connector-java接收到的语句就是Batch的形式,就不依赖rewriteBatchedStatements的值了。以mybatis为例:
              <!-- 批处理插入 -->
              <insert id="batchInsert">
              INSERT INTO t1 (c1, c2) VALUES
              <foreach collection="list" item="item" separator=",">
              (#{item.c1}, #{item.c2})
              </foreach>
              </insert>
            Batch Update

            最常见的错误形式是,使用PreparedStatement的addBatch()、executeBatch()接口,例如:

              Connection conn = xxx;
              PreparedStatement pstmt = conn.prepareStatement("UPDATE t1 SET c1 = ? WHERE id = ?");


              pstmt.setInt(1, 1);
              pstmt.setInt(2, 1);
              pstmt.addBatch();


              pstmt.setInt(1, 2);
              pstmt.setInt(2, 2);
              pstmt.addBatch();


              pstmt.executeBatch()
              mysql-connector-java并不支持update语句的该写,所以该形式一定是走多语句(甚至是单语句:JDBC url,如果allowMultiQueries=true,则走多语句,如果allowMultiQueries=false(默认为false),则走单语句)的形式,并非Batch操作。
              Batch Update有两种正确的形式:
              1. 使用CASE WHEN,例如:

                  UPDATE t1
                  SET
                  c1 = CASE id
                  WHEN 1 THEN 1
                  WHEN 2 THEN 2
                  END,
                  c2 = CASE id
                  WHEN 1 THEN 1
                  WHEN 2 THEN 2
                  END
                  WHERE id IN (1,2)
                  如果用mybatis,例如:
                    <!-- 批处理更新 -->
                    <update id="batchUpdate">
                    UPDATE t1
                    SET
                    c1 = CASE id
                    <foreach collection="list" item="item" separator=" ">
                    WHEN #{item.id} THEN #{item.c1}
                    </foreach>
                    END,
                    c2 = CASE id
                    <foreach collection="list" item="item" separator=" ">
                    WHEN #{item.id} THEN #{item.c2}
                    </foreach>
                    END
                    WHERE id IN
                    <foreach collection="list" item="item" separator="," open="(" close=")">
                    #{item.id}
                    </foreach>
                    </update>
                  该方式的问题是:
                    • 语句冗长不好看;
                    • WHERE中使用主键的in操作,容易导致全表扫描等;
                  1. 使用INSERT ... ON DUPLICATE UPDATE(简称upsert),例如:

                      INSERT INTO t1 (id,c1) VALUES (1,1), (2,2ON DUPLICATE KEY UPDATE c1 = VALUES(c1)
                      如果用mybatis,例如:
                        <!-- 使用upsert批量更新 -->
                        <update id="batchUpdate">
                        INSERT INTO t1 (c1, c2) VALUES
                        <foreach collection="list" item="item" separator=",">
                        (#{item.c1}, #{item.c2})
                        </foreach>
                        ON DUPLICATE KEY UPDATE c1 = VALUES(c1)
                        </update>

                      该形式与Batch Insert类似,注意:

                        • 不更新的列无需写在values和update中(即使它不允许为null);

                        • 如果是分区表,记得把分区键也写在values中(但不必写到update中);

                        • 该方法有一定的限制,它要求更新的数据必须是存在的(如果不存在它就会给你插进去一条,可能与业务语义不符);

                        • 该形式也可以使用Batch Insert中的rewriteBatchedStatements=true这种形式去实现,但不推荐。

                      该形式SQL比较精炼,是最高效的形式。

                      Batch Delete

                      Batch Delete相对比较简单,只有一种方式:

                        DELETE a FROM t1 a FORCE INDEX (PRIMARY) WHERE id IN (1,2)

                        如果是分区表,建议带上分区键:

                          DELETE a FROM t1 a FORCE INDEX (PRIMARY) WHERE (id,c2) IN ((1,1),(2,2))

                          注意几点:

                          1. 控制一下in的数目,多了容易走全表扫描导致锁表,建议不超过500;

                          2. 加上FORCE INDEX (PRIMARY);

                          3. 为了第二点,还需要给t1增加一个别名,这个来源于MySQL一个很奇怪的限制:

                            1. “single-table delete”不支持force index,“mult-table delete”支持force index;

                            2. 只要给表加个别名,这个delete就属于“mult-table delete”了。

                          如何判断是否走了Batch

                          无论使用了何种形式,建议通过以下方式判断一下是否真的走了Batch:

                          1. 查看SQL审计;

                          2. 如果没开SQL审计,可以在有业务流量的时候,查看PROCESSLIST(PolarDB-X中可以执行:SHOW FULL PROCESSLIST WHERE INFO IS NOT NULL),观察执行的SQL是否是Batch的;

                          3. 有经验的话,看响应时间即可,一个1000条的Batch执行好几秒,那大概率是没走Batch的。

                          文章转载自PolarDB,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                          评论