性能优化实战-1

系统优化大致可以分为:业务优化、设计和程序优化、DB和SYSTEM优化、SQL优化四个方面,这四个方面按优先级进行排列,并且越往后越不容易优化,他们之间又相辅相成、互相关联,我们优化的时候可以交替着去实施。下面着重分享一下我们在SQL优化和程序优化等方面的一些经验。

 

一、SQL优化

慢查询是大多数数据库问题的罪魁祸首,我们可以通过改善表结构设计、优化SQL语句、合理的使用索引等方面来最大限度的解决这个问题,下面我们一一进行讲解。

  1、 改善表结构设计

我们可以使用 PROCEDURE ANALYSE()对当前已有应用的表类型的判断,该函数可以对数据表中的列的数据类型提出优化建议, 可以根据应用的实际情况酌情考虑是否实施优化。

SELECT * FROM tbl_name PROCEDURE ANALYSE();

性能优化实战-1PROCEDURE ANALYSE()是动态分析表数据得出的结果,分析需要一定的时间。分析结果展示了列名称、列的最小值、列的最大值、列最小值的长度、列最大值的长度、列中empty或0的行数,列中null的行数,平均值或者平均长度,给出的列的类型优化建议,从中我们可以看出哪些列适合哪种类型,以减少存储空间、提高查询效率。

 

    2、 explain查询语句

(product)ifaes_test@localhost [ifaes_test]> explain select * from project where uuid='000002a1155c48f389ce85a64f0e2754';

+----+-------------+---------+-------+---------------+---------+---------+-------+------+-------+

| id | select_type | table   | type  | possible_keys | key     | key_len | ref   | rows | Extra |

+----+-------------+---------+-------+---------------+---------+---------+-------+------+-------+

| 1 | SIMPLE     | project | const  | PRIMARY    | PRIMARY | 98    | const |    1 | NULL  |

+----+-------------+---------+-------+---------------+---------+---------+-------+------+-------+

1 row in set (0.00 sec)

          在日常工作中,我们会有时会开慢查询去记录一些执行时间比较久的SQL语句,找出这些SQL语句并不意味着完事了,这时我们常常用到explain这个命令来查看一个这些SQL语句的执行计划,查看该SQL语句有没有使用上了索引,有没有做全表扫描,这都可以通过explain命令来查看。

  id SQL执行顺序的标识,相同的id表示为同一组。SQL执行顺序和ID有关,id值越大越先执行,相同的id按照从上往下的顺序执行。

select_type  select子句的类型,用于标识和其他子句的关联关系,常见的如SIMPLE/PRIMARY/UNION等类型。

(1) SIMPLE(简单SELECT,不使用UNION或子查询等)

(2) PRIMARY(查询中若包含任何复杂的子部分,最外层的select被标记为PRIMARY)

(3) UNION(UNION中的第二个或后面的SELECT语句)

(4) DEPENDENT UNION(UNION中的第二个或后面的SELECT语句,取决于外面的查询)

(5) UNION RESULT(UNION的结果)

(6) SUBQUERY(子查询中的第一个SELECT)

(7) DEPENDENT SUBQUERY(子查询中的第一个SELECT,取决于外面的查询)

(8) DERIVED(派生表的SELECT, FROM子句的子查询)

table select子句是关于哪张表的,有时会有临时表出现。

性能优化实战-1

type表示MySQL在表中找到所需行的方式,又称为“访问类型”。常用的类型有:const/eq_reg/ref/ range/ index / all。

const表示该表中最多有一条匹配的记录,出现在主键、唯一索引等查询语句中。

eq_reg对于每个来自前面的表的行组合,从该表中读取一行。即前表的行组合和当前SQL是一一对应的关系。

ref 前表的行组合和当前SQL是一对多的关系。

range使用索引来检索给定范围的行,key表示使用的索引名称。(<>、>、>=、<、<、<=、IS NULL、between、in等等)。

index 使用索引进行检索。

ALL 进行完整的表扫描,即全表扫描。

 possible_keys 列指出MySQL能使用哪个索引在该表中找到行,方便我们合理利用索引来优化SQL语句。

 key 列显示MySQL实际决定使用的键(索引)。如果没有选择索引,键是NULL。要想强制MySQL使用或忽视possible_keys列中的索引,在查询中使用FORCE INDEX、USE INDEX或者IGNORE INDEX。

 key_len 列显示MySQL决定使用的键长度。如果键是NULL,则长度为NULL。使用的索引的长度。在不损失精确性的情况下,长度越短越好 。

 ref 表示上述表的连接匹配条件,即哪些列或常量被用于查找索引列上的值

 rows 列显示MySQL认为它执行查询时必须检查的行数。

 

   3、  合理的使用索引

首先要说明一点,合理的使用索引可以提高检索(查询)的效率,但是插入/更新/删除等操作时都需要对索引进行维护,所以索引并不是越多越好。

在用户红包表、用户加息券表中

性能优化实战-1

user_redenvelope表有主键索引,tender_id的单列普通索引,user_id和status的联合索引,use_expire_time_status和status的联合索引。下面分别说说各索引的用处,主键索引这里就不详述了。

idx_user_redenvelope_tender_id          查询投资记录所使用的红包时使用;

idx_user_redenvelope_user_id_status             查询指定用户可用红包/不可用红包时使用

idx_user_redenvelope_use_expire_time_status     处理过期红包时使用,后面会讲到如何使用索引。

用户加息券表的索引和和用户红包表的索引差不多,这里就不赘述。

和用户一一对应的记录表:user_base_info、user_cache、user_identify、user_company_info、user_score、user_vip等添加user_id唯一索引;

投资记录表project_invest、待收记录表project_collection、待还记录表project_repayment对project_id列建立普通索引,产品表product、借贷表borrow对project_id列建立唯一索引。债权相关的表和投资相关表类似。

    下面举一个索引使用不当的例子:

性能优化实战-1

在user_cache表总创建了两个索引:uq_user_cache_user_id针对用户id的唯一索引和idx_user_cache_user_id_user_nature是将user_id和user_nature组合在一起的联合索引。上面的查询语句中,两个索引一个也没用到。

我们来考虑一下索引的有效性,uq_user_cache_user_id索引可以用来防重并且在多数查询时可以通过user_cache.user_id=xxx来使用唯一索引,提高查询效率。另一个就是idx_user_cache_user_id_user_nature,我们的期望是想通过和user的关联来查询某一类型的用户,但是事与愿违,所以该索引是无效的。那么我们怎么做?

         1、删除idx_user_cache_user_id_user_nature索引;

         2、在user表添加冗余字段user_nature,或者将user_nature列直接移至user表;

 

  4、  其他优化建议

a)       表关联查询时,能使用内连接(逗号分隔或者inner join)的尽量不使用外连接(left join or right join),能使用join的尽量不使用子查询;

查询投资记录,关联项目记录时使用内联

select * from project_invest i inner join project p on i.project_id= p.uuid ;

select * from project_invest i left join project p on i.project_id= p.uuid ;

查询没有进行过投资的注册用户,使用外联代替子查询:

select * from user where uuid not in (select user_id from project_invest );

select u.* from user u left join project_invest i on i.user_id = u.uuid and i.user_id is null;

 

b)       对于排重(或分组)的SQL,可以使用下面两种,推荐使用方案1

方案1:select distinct min,max from tbl;

方案2:select min, max from tbl group by min,max;

c)       索引使用最左匹配原则,在条件查询时,如果组合索引中的首列没有出现,mysql优化器将放弃使用索引。

如下的第三条语句MySQL优化器将放弃使用索引

select * from user_redenvelope where use_expire_time>='2017-01-04 23:59:59' and use_expire_time<='2017-01-13 00:00:00' and status='0';

select * from user_redenvelope where use_expire_time>='2017-01-04 23:59:59' and use_expire_time<='2017-01-13 00:00:00';

select * from user_redenvelope where status='0';

 

d)       散列分布不均的列不推荐使用索引,比如状态列、枚举类型的列等等;

e)       尽量避免select * 操作,从库里读取的数据越多查询越慢。

f)       固定长度的效率更高,尽可能的使用非空约束(通过表结构优化来进行处理)。

 

二、代码优化

1、  缓存服务

对于访问频率比较高的或者不常更新的数据,我们可以考虑使用缓存服务,来提高系统的响应效率,减小数据库服务器的压力。比如前台首页、理财频道、投资详情页等页面。

首页的新手专享和精选投资列表缓存2s

    /**

     *

     * 首页--新手专享投资列表

     * @return

     * @throws Exception

     */

    @RequestMapping(value="/index/getNoviceProject")

    @ResponseBody

    @Cacheable(expire=ExpireTime.TWO_SEC)

    public Object getNoviceProject() throws Exception {

        …

    }

    /**

     *

     * 首页--精选投资列表

     * @return

     * @throws Exception

     */

    @RequestMapping(value="/index/getChoiceProject")

    @ResponseBody

    @Cacheable(expire=ExpireTime.TWO_SEC)

public Object getChoiceProject() throws Exception {

    ….

}

理财频道缓存5s

    /**

     *  产品列表查询

     * @author  FangJun

     */

    @Cacheable(key = CacheConstant.KEY_PROJECT_LIST, expire = ExpireTime.FIVE_SEC)

    public Page<ProjectRecord> findProjectPage(ProjectRecord model) {

       

    }

 

2、  队列服务

对于优先级不是特别高的操作,可以考虑将其加入队列的方式进行处理。比如前台用户注册,注册成功以后不仅需要向user、user_cache、user_company_info(user_base_info)、user_identity、user_vip、user_score、user_invite等用户相关的表中添加记录,还需要在账户中心进行注册,根据活动方案的规则发放相应的红包、加息券等操作。活动方案相对用户信息和账户信息来说显得不是那么迫切,不需要即时入库,可以考虑将活动方案加入队列服务。活动方案加入队列服务之前,用户注册的并发量一直上不去。活动方案加入队列之后,用户注册的并发可以达到30TPS,用户注册的并发量有了显著的提升。

活动方案加入队列后带来了一个新的问题,就是假如后台配置的活动方案太多,队列服务又需要一个一个的进行处理,在高并发的情况下会出现大量的消息堆积。有时甚至两个小时的用户并发注册,队列服务跑一天都处理不完。

接着怎么办?拆服务

梳理活动方案的类别,将活动方案拆分成:注册送好礼--送红包、注册送好礼--送加息券、注册好友送好礼--送红包、注册好友送好礼--送加息券、注册 --送积分五个小类。然后分别如队列进行处理。

类似的,我们对调度任务的队列服务按放款、还款、项目撤销的类别进行了服务拆分。

private User addUserForRegister(final UserModel model){

        //用户相关表持久化操作…

        //活动方案入队列    注册的时候拆分队列

        MqActPlanModel redActModel = new MqActPlanModel(MqConstant.OPERATE_ACTPLAN_REGISTER_GIFT_REDPACKET, user,null,null);

        RabbitUtils.actPlan(redActModel);

        MqActPlanModel rateActModel = new MqActPlanModel(MqConstant.OPERATE_ACTPLAN_REGISTER_GIFT_RATECOUPON, user,null,null);

        RabbitUtils.actPlan(rateActModel);

        MqActPlanModel friendRedActModel = new MqActPlanModel(MqConstant.OPERATE_ACTPLAN_REGISTER_FRIEND_GIFT_REDPACKET, user,null,null);

        RabbitUtils.actPlan(friendRedActModel);

        MqActPlanModel friendRateActModel = new MqActPlanModel(MqConstant.OPERATE_ACTPLAN_REGISTER_FRIEND_GIFT_RATECOUPON, user,null,null);

        RabbitUtils.actPlan(friendRateActModel);

        MqActPlanModel scoreActModel = new MqActPlanModel(MqConstant.OPERATE_ACTPLAN_REGISTER_SCORE, user,null,null);

        RabbitUtils.actPlan(scoreActModel);

        returnuser;

    }

 

3、  单项持久化改为批量持久化

在前期的活动方案中,红包、加息券的发放是按用户和活动规则是一条条进行的,活动方案匹配成功后将红包(加息券)发放给用户,生成一条用户红包(加息券)记录和发放日志记录。跟踪队列服务发现在活动规则较多、用户注册并发量大的时候,队列的处理效率低的吓人。根据业务场景,我们将同一个用户的多个活动合并,批量持久化到数据库。在经过将队列服务拆分和单项操作改为批量操作后,活动方案的处理能力从0.2TPS提升到10-30TPS。

类似的,调度任务、账户中心等地也从原来的单条持久化改为批量持久化。

性能优化实战-1

4、  定时任务

程序员在处理定时任务的时候喜欢简单粗暴的方式,就像这样的:

#将红包标记为已过期

update user_redenvelope set status=#{status} where use_expire_time < now() and status=#{preStatus}

这个SQL语句在数据量不大的时候执行没有什么问题,一旦数据量上去之后就会卡死,甚至产生死锁。

那么怎么优化?我们先看看定时器里的方法

    @Scheduled(cron = "0 */5 * * * ?")

    publicvoid doCouponExpiredHandle() {    

        userRedenvelopeService.expiredHandle();// 红包过期处理

        userRateCouponService.expiredHandle();// 加息券过期处理

    }

上面的@Scheduled注解表示这是一个定时操作,cron表达式表明每五分钟执行一次。也就是说该方法会每五分钟执行一次,发现有过期的红包或者加息券就标记为已过期。

既然是每五分钟执行一次,那么我们查询的时间范围应该是可以继续缩小的,比如查询10分钟以内已经过了有效期但状态为未过期的记录。然后将状态标记为已过期并将结果分批次批量入库。

    /**

     * 红包过期处理

     */

    @Override

    publicint expiredHandle() {

        Date now = DateUtils.getNow();       

        List<String> ids = dao.findExpireList(DateUtils.rollMinute(now, -10), now, OperateEnum.STATUS_NO_USE.getValue());

        List<UserRedenvelope> list = new ArrayList<>(ids.size());

        if(CollectionUtils.isNotEmpty(ids)){

            for(String id:ids){

                UserRedenvelope url = new UserRedenvelope();

                url.setUuid(id);

                url.setStatus(OperateEnum.STATUS_EXPIRED.getValue());

                list.add(url);

            }

            //批量更新

            List<List<UserRedenvelope>> splitList = ObjectUtils.splitList(list, 200);

            for (List<UserRedenvelope> ls : splitList) {

                dao.updateBatchStatus(ls);

            }

        }

        return Constant.INT_ONE;

    }

Mapper对应的SQL如下:

<!-- 查询过期红包 -->

    <select id="findExpireList" resultType="String">

        select uuid

        from user_redenvelope

        where use_expire_time between #{startTime} and #{endTime} and status=#{preStatus}

    </select>

    <!-- 批量更新过期红包的状态-->

    <update id="updateBatchStatus" parameterType="java.util.List">

        <foreach collection="list" item="item" open="" separator=";" close=""

            update user_redenvelope  set STATUS = #{item.status}  where uuid = #{item.uuid}

        </foreach>

    </update>

我们总结一下:

     查询一定时间范围内状态为未过期的红包记录;

     将记录标记为已过期;

     将红包记录分批次批量入库;

这里为了提高查询的效率,我们对use_expire_time和status创建了联合索引并且将范围查询由>=、<=调整为between and。

那么我们能不能直接将上面的查询语句调整为update语句呢?答案是:不推荐。对于数据量较大的平台,不建议直接批量更新,因为没办法确定更新记录的数量。数据量太大依然会卡死,甚至产生死锁。其他的定时操作也可以按照这样的步骤来进行优化。

另外一个优化点就是缩小事务的范围,定时任务多是批量操作。简单的定时操作通过一个update语句就能搞定,还有一些定时操作相对就复杂的多,涉及很多的业务处理,逻辑判断,而这些批量操作的数据之间又是相互隔离,互不影响的,比如订单超时处理、自动审核、自动下架等等。我们没有必要在这些定时任务的入口加上事务,只需要在处理单笔交易时加上事务支持即可。

    //@Transactional(readOnly = false)//订单超时处理入口取消事务支持

    publicvoid investTimeoutHandle() {

        …

    }

 

    /**

     * 投资超时处理,在单笔交易中添加事务支持

     * @param invest

     */

    privatevoid itemInvestTimeoutHandle(OutTimeProjectInvest invest){

        TransactionDefinition definition = new DefaultTransactionDefinition();

        TransactionStatus ts = transactionManager.getTransaction(definition);

        try {

             //处理超时相关业务…

            transactionManager.commit(ts);

        } catch (Exception e) {

            transactionManager.rollback(ts);

            LOGGER.error(e.getMessage(),e);

            throwe;

        }  

    }

 

5、  后台查询

根据多年的从业经验在关联查询这方面定了一个规则:后台的关联查询不得超过3张表。起初大家都不是很理解,总觉得规定这么死很难去实现产品经理或者客户的需求。众所周知,后台查询通常都会进行一些跨表的关联查询,有的时候为了满足业务需要甚至需要关联5、6张表进行查询,SQL语句写的那叫一个漂亮,能整几十甚至上百行。但是数据量一旦上去,就卡的不行不行的。那么我们怎么去做既能保证SQL高效,还能保证能够实现产品经理或者客户的需求呢?

经验一:尽量将多表改为单表查询;

拿菜单【用户积分】举例,以下为【用户积分】列表页面的截图:

性能优化实战-1

我们可以看到用户积分列表页展示了用户名、总积分、有效积分、冻结积分和消费积分等信息。列表中除了用户名是user表的属性外,其他列的信息都来自user_vip表。假设我们通过将user和user_vip的关联调整为单表查询,我们该以哪张表为主进行查询呢?

方案一:分页查询以user_vip为主表,在service层通过user_vip的user_id查询出user表的用户名;

方案二:分页查询以user为主表,在service层通过user的uuid查询user_vip的相关信息;

貌似这两种方案都行的通,但是细看页面我们发现搜索框是支持根据用户名进行模糊查询的,所以方案一被否决,方案二胜出。

这个问题貌似难度不够高,假设用户提出要求,我们需要按照用户名、总积分、有效积分、冻结积分和消费积分等信息进行检索或者排序,那方案二显然不能满足需求,怎么办?

经验二:适当的时候可以考虑添加冗余字段;

接着上面的问题,测试环境的用户量已经超过一百万,关联查询显然已经不合适,那么我们就可以考虑在user_vip表中将user表的用户名列冗余添加到user_vip表中。这个例子可能不够好,我们举个相对比较恰当的例子吧。

对project(产品+借贷)做了分类,我们在前台的理财频道中需要用到项目的类别名称,通过关联查询project_type表确实是一个方案,但是不够好。我们可以考虑在project表中添加类别名称列的冗余来取消对project_type表的关联查询。

经验三:通过添加索引提高查询效率;

表的数据量过大时,我们还可以采取通过添加索引的方式来提高查询效率,这里我就不展开讲解了。当然其他的优化方案在这里也可以派上用场。

 

6、  Excel导出

Excel大批量数据导出一直是我们系统的弱项,2.0里导出操作的数据量超过一万就会卡死。我们先看看excel导出经过了哪几个步骤:

1)       从数据库中查询出符合条件的记录;

2)       对记录中的数据进行加工处理,如将状态、枚举等类型转为可描述的信息等;

3)       将记录写入到文件或输出流中;

我们看到纵向操作很难去优化,那么我们分析一下数据量超过一万条就卡死的原因是什么,是在那一步产生的?

通过跟踪服务器的相关指标(线程、CPU、内存、相关服务),我们发现在做导出操作的时候,服务器的CPU和内存都飙升的很高。那么我们能不能通过调节JVM的相关参数来避免导出的时候出现卡死的现象呢?答案是:不推荐。

我们通过excel导出的步骤可以推测:导出卡死主要是因为数据量太大,导致jvm的内存爆表。那么如何优化呢?

我们在查询的时候模仿后台列表页面的分页操作,在导出的第一步将一次查询改为分页查询,每次最多读取5000条记录(每页导出的记录数根据实际情况来定)。目前我们已经做到可以支持百万级的数据导出,而不会出现卡死的现象。

除此之外,我们还做了什么?

我们发现,一般而言,导出的列都是和页面展示的列对应的,在后台通过硬编码来决定导出的列确实是一件吃力不讨好的事情,我们对需要导出的列做了封装,页面展示哪些列,导出就是哪些列,不需要在后台进行硬编码。

另外一个问题是让人头疼的状态、枚举等列在导出时的转换问题,我们定义了@DictType的注解,在代码里将繁琐的状态、枚举等类型的信息转换全部去除。

 

相关推荐