记一次结算程序的性能优化过程

背景:某项目结算程序,业务系统每日生成当天全量交易数据并上传至FTP,该结算程序从FTP中获取文件后解析交易数据,执行余额变更操作及登记资金流水。

第一轮压测结果:TPS=3

分析源码后发现,开发童鞋是串行单笔处理的,简化后的核心代码如下:

//遍历每条交易数据
foreach(){
    insertLogAndUpdateBalance();
}

//启用事务
insertLogAndUpdateBalance(){
    //插入资金流水
    insertLog();
    //更新余额
    updateBalance();
}

第一轮改进方法:
1、改串行单笔处理为串行多笔处理
2、余额变更update转insert,即先记入余额流水表,再异步刷新回余额表
3、这里引入了一个新问题:如何保证余额不被扣成负数?该问题后面另起专题介绍。
简化后的核心代码

//遍历每批交易数据,每500条一批
foreachBatch(){
    insertLogAndUpdateBalanceBatch();
}

//启用事务
insertLogAndUpdateBalance(){
    //插入资金流水
    insertLogBatch();
    //更新余额
    updateBalanceBatch();
}

第二轮压测结果:TPS很不稳定,15 - 45之间

第二轮改进方法:
经过排除后发现开发人员使用定时任务的方式为,(SpringBoot项目)

@EnableScheduling
public class ExceptionStatusChangeNotifyScheduler {
 @Scheduled(cron = "0 */1 * * * ?")
    public void doUpdateStatus() {
        //业务逻辑处理
    }
}

翻看EnableScheduling 源码可以看到类说明里面已经很清晰的描述了,默认情况下是使用单线程方式,而我们项目同时有多个定时任务,所以这也解释了为什么TPS会很不稳定的现象。

 * In all of the above scenarios, a default single-threaded task executor is used.
 * When more control is desired, a {@code @Configuration} class may implement
 * {@link SchedulingConfigurer}. This allows access to the underlying
 * {@link ScheduledTaskRegistrar} instance. For example, the following example
 * demonstrates how to customize the {@link Executor} used to execute scheduled
 * tasks:

对于这个问题,我特意百度了一下也看了某些介绍SpringBoot定时任务的书及文章,都是一样的demo,但都没有提到这种方式是单线程的......
所以这里我想说网络及书籍上的代码可以参考,但要有相对细致的了解,不致于出现问题时束手无策。
解决方案很简单,就是实现SchedulingConfigurer接口,示例如下:

public class XXX implements SchedulingConfigurer{
    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        taskRegistrar.setScheduler(taskExecutor());
    }    
    @Bean(destroyMethod="shutdown")
    public Executor taskExecutor() {
        return Executors.newScheduledThreadPool(30);
    }
}

第三轮压测结果:TPS=30
第三轮改进方法:
已经有所进步了,不是嘛,至少比起第一轮结果,我们已经提高10倍了,但是,这合理吗?我们都是insert操作,经过排查加日志,最终定位批量插入4500条记录需要10S以上,我们的插入语句是这样的。(使用MyBatis 3.1.1)

<insert >
       insert into xxx (msg_id, balance_type,
           amount, account_id, user_id,
           is_handle, create_time
           ) values
     <foreach collection="list" item="item" index="index" separator=",">
       (#{item.msgId,jdbcType=VARCHAR}, #{item.balanceType,jdbcType=VARCHAR},
           #{item.amount,jdbcType=BIGINT}, #{item.accountId,jdbcType=BIGINT}, #{item.userId,jdbcType=VARCHAR},
           #{item.isHandle,jdbcType=INTEGER}, #{item.createTime,jdbcType=TIMESTAMP}
           )
     </foreach>
     </insert>

使用的了MyBatis foreach语法,生成insert into xxx values (aa,bb), (cc, dd)这种sql语句,经测试,每条sql长度在500K,网络也基本排除延迟的情况下,不禁开始怀疑MyBatis的foreach语法问题,于是将以上SQL由代码生成,再经由MyBatis执行,示例代码如下:

<insert  >
    ${sql}
 </insert>

第四轮压测结果:TPS=300,然后第二批开始下降到100左右
第四轮改进方法:排除第一批数据与后续批次数据量有明显差距的情况,将问题定位到Mysql,经过定位,原来字段msg_id(值为UUID)存在惟一索引,使用UUID作为惟一索引存在两个问题:
1、太长
2、无序,每次insert操作时,索引重建效率不高
这个字段是由于历史原因存在的,事实上已经用不上了,故删除之。

第五轮压测结果:TPS=300+

总结:

本次性能优化的过程,首先从串行单笔操作转为串行批量操作,增加每次跨进程(服务到DB)交互的数据量;然后通过TPS不稳定发现了SpringBoot中EnableScheduling存在单线程问题;
最后根据历史经验及测试对比又定位出使用Mybatis的foreache存在的性能问题; 最后根据TPS忽然下降定位出UUID作为惟一索引存在的问题,从而最终将单机串行处理TPS提高到300以上。至此只是了单机性能。如何利用多机进一步提升TPS?请读者先考虑伸缩性设计。后续作专题介绍。