基于python的代码优化实践

内容纲要

本文将围绕日常遇到的性能和代码可读性的几个问题分别展开分析,给出优化实践。由于经验所限,本文涉及的优化均是在处理数据、数据组装过程中的问题,python提供接口服务的性能优化方面涉及较少。

性能问题实例分析

可变类型变量越来越大

现象:
带有成千上万次循环逻辑的代码,随着执行时间的增长,占用内存越来越大;打印可变类型变量(list, set, dict)的长度,发现长度越来越长。

代码控制不良

代码描述:在循环逻辑之外声明的可变变量,在循环过程中不断向里添加值。
代码示例:

class Test:
     one_list = list()
     def get_data(self):
         self.one_list.append(1)
    def process_data(self):
         # 使用self.one_list
         pass
     def pipline(self):
         i = 1
         while i < 100000:
             self.get_data()
             self.process_data()
             i += 1

调用pipline方法时,造成self.one_list越来越大。
解决办法:

  1. 声明局部变量,循环时向此变量添加值,循环后将此局部变量赋值给外部变量;
  2. 每次循环、使用该变量后清空该变量值(在本例中可增加clear函数,在调用process_data之后调用);

可变类型作为入参

代码描述:
函数的某个入参定义了可变类型默认值(以列表为例),若调用时没有给此入参传入值,则多次调用之间,函数内使用的是同一个列表;若此时函数内向列表添加值,则多次调用后,此函数列表越来越长。
代码示例:

def func(numbers=[]):
        numbers.append(1)

原理:
函数定义关键字def是一条可执行语句,Python 解释器执行 def 语句时,就会在内存中就创建了一个函数对象;函数对象生成之后,它的属性(名字和默认参数列表)都将初始化完成,属性 __default__ 中的第一个默认参数 numbers 指向一个空列表。不管该函数调用多少次,每次调用之间共享同一个列表对象(eg:func())。
如果调用函数时显式地指定 numbers 参数,则numbers被重新赋值,它不再指向原来初始化时的那个列表,而是指向了传递过去的那个列表对象,就不会出现多次调用之间列表变大的情况了(eg:func(numbers=[2,3]))。

解决办法:
不要用可变对象作为参数的默认值。可以用这样的方式实现:

def func(numbers=None):
        if not numbers:
                numbers = []
        numbers.append(1)

一次性获取大量数据和分批取获得大量数据

现象:
从数据源加载大量数据时,一次性获取数据导致内存飙升,修改为分批获取后则不再出现此问题。
代码示例:
doctor_patient索引代码中,根据变动的医生id获取医患关系id时,未分页获取,导致内存使用达到10G+,改为分批获取后,内存峰值为4G+。(即调用wy_data_basic.load_datawy_data_basic.batch_get_data的区别)
原理:
一次不加载那么多数据,故内存消耗变小。
但无法解释为何加载完数据,程序内存仍维持在峰值上(优化前的10G和优化后的4G)。
解决办法:
保险起见,加载数据均使用批量加载。

代码设计不完善导致方法误用造成性能问题

现象:
如题,因代码较复杂、具有隐含条件要求、逻辑判断欠缺,导致使用特定组合传入参数后,进入非正常逻辑。
代码示例:
wy_data_basic.batch_get_data方法接受入参sqlids,方法内部对ids切片,分批拼装到sql模板中,实现分批获取数据,这就要求sql入参传入的sql必须是带有'%s'的模板sql。
doctor_user索引代码中,使用此方法时,在外部用ids拼接好的where条件替换了sql模板中的%s,形成了完整的sql,和ids参数一起传入此方法,方法正常运行,结果无异。但实际上是对这个拼接了所有id的sql查询了多次(因为先对id分批,再替换sql入参的%s,若sql入参里没有%s,也没有报错)。这导致了线上执行此脚本内存超过警戒线触发报警。
解决办法:

  1. 像此例中的情况(多个参数之间有隐含制约条件),需进行完善的条件判断。
  2. 调用方法时,不阅读完整源码,也要阅读方法注释,避免误调用(此方法在注释中描述sql要带%s)。

多线程未指定等待队列长度导致队列占满时无法阻塞主流程

现象:
为了解决数据获取速度快而提交速度慢带来的效率问题,base_index中使用futures.ThreadPoolExecutor支持多线程提交,默认线程数为5。但在特定生产环境偶现了内存增大的问题,从日志看,缺少提交日志,只有获取数据的日志。
原因:
某些场景下,向solr提交数据的速度远小于数据组装的速度(persona_tag的sql非常简单,拿取数据的速度很快;new_anhao_user在构建全量索引时,人为reload导致recovery时,solr提交速度明显降低),这时多线程派上了用场。但因在代码中未指定线程占满时等待队列的长度(以为是0,阻塞主线程;但实际上是无限队列,直到把内存吃光),导致代码不断从库中拿数据组装,却无法及时提交。时间长了,拿取的数据占了大量内存。
解决办法:
使用多线程时注意等待队列长度的设置。

反复调用带有初始化对象功能的装饰器的函数

现象:
一个函数带有装饰器,此装饰器内会初始化相关对象(solr_search_router.build_route_dict),多次调用这个函数,导致不断初始化新对象,虚拟内存升高。
原理:
上述函数内调用了使用zkCli.ChildrenWatch装饰器的函数,从源码看多次调用会导致重复初始化新对象。但为何实际内存占用和线程数不升高,仅虚拟内存飙升还未能解释。
解决办法:
避免反复调用此类函数。

代码可读性问题实例分析

在我们的python代码库中,涉及到代码可读性问题的,一般都是过长代码导致。下面的前四点围绕过长代码和处理过长代码过程中会碰到的问题展开说明;第五点单独描述魔法数的处理。

过长函数与局部变量问题(doctor_search)

现象:
doctor_search的主体函数base_get有上千行,优化的第一步就是按逻辑块抽出小函数。但在抽出的过程中(以抽出sort处理逻辑为例),发现逻辑块里用到各种上文初始化的局部变量,直接抽取可能需要将新函数定义5个以上的入参,带来参数列表过长问题。
解决办法:

  1. 考虑将通用变量定义为类变量,如self.sortself.areaself.semantic_type等,这样抽出的类方法不需要再传入。
  2. 用简单查询替代局部变量(参考《重构》一书)。若一个局部变量值是通过调用一个函数获得的值,则可以用此调用替代局部变量的声明。这样抽出方法后可在方法内调用函数获取值,减少所需入参数目。

数据泥团(base_search)

现象:
数据泥团是指总是抱团一起出现的数据项(分页/深分页,分面相关参数),他们应该有自己的类。在许多search代码中,想要使用深分页/分面之类的功能,需要向词典中逐一维护相关参数,这一方面带来重复代码,另一方面功能结构不清晰。
解决办法:

  1. 在base_search中维护处理深分页/分面逻辑的公共方法
  2. 为深分页/分面维护各自的类,使结构清晰,功能模块化。目前分面已有自己的类wylib.search.facet_parse(书瑾)。

多返回值问题(doctor_patient)

现象:
doctor_patient_data.get_platform_doctor_patient方法很长,包含多个逻辑块,且返回值是6个元素组成的tuple。若不改变实现方式,随着业务的叠加,返回值还会变多。这个问题一方面影响函数本身和调用方的可读性,另一方面不方便添加新逻辑。
代码示例:

# 函数返回值复杂
def get_platform_doctor_patient():
        ...
        return (relation_dict, relation_disease_dict, patient_info, patient_event, liked_relation_set, new_sign_set)

# 调用代码也难理解
(doctor_patient, self.platform_patient_disease_dict,
 self.patient_info, self.event_info, self.liked, self.new_sign) =\
    doctor_patient_data.get_platform_doctor_patient(
    self.db, update_dict, patient_min_max_info['full_id_sql']
         if patient_min_max_info else '')

解决办法:

  1. 应考虑每个函数仅返回一个值。在本例中,获取后面几个数据集的行为可以抽出独立方法。
  2. 以前之所以写在一起,是因为在获取前面数据集时会遍历数据,遍历时维护id集,后面使用id集获取其他数据集。这种情况可以将此函数返回核心数据集和id集,在函数外使用id集继续后续逻辑(中间id集问题可以理解为一种局部变量问题,这里将它作为出参和入参来达到消除局部变量的效果)。

过大的类(base_index)

现象:
base_index为例,使用抽提函数的方法简化大段逻辑后,仍感觉代码复杂难懂。这是因为一个类中聚合了太多功能(分页准备,获取数据,提交数据,检查删除旧数据),成员变量和函数数目太多导致。
解决办法:
功能块抽为单独的类。

魔法数问题(base_index)

现象:
处理不同类型对应不同逻辑时,为了开发便捷,简单的使用数字或字符串区分。这导致判断此类型的各处代码都写死判断指定数值。这样很难寻找究竟哪些地方使用了此逻辑,也难以维护。有时这样的类型定义直接写在当前模块里,导致其他模块想使用这些类型时又定义一遍,难以维护。
代码示例:

# 索引类型
index_type_dict = {
    0: '全量',
    1: '增量',
    2: '实时',
    3: '秒级增量'
}
# 输出类型(xml/json, file/content)
output_type = {'xml_file', 'xml_content', 'json_file', 'json_content'}

# 不同索引类型时的处理
if self.index_type in (1, 3) and self.inc_sec:  # 增量限流
    self._incr_flow_limit()
# 不同输出类型时的处理
if self.output_type == 'xml_file':
    self.builder = formatter.SolrXMLBuilder(
    self.dest_dir, self.solr_operate, self.filename)
    filename = self.builder.filehandle.name
    self.filename = filename.split('/')[-1]
elif self.output_type == 'xml_content':
    self.builder = formatter.SolrXMLBuilder(output_type='xml_content')

解决办法:

  1. 使用枚举类替代魔法数

    @unique
    class CommitStrategy(BasicEnum):
    """索引提交策略
    不是指每次提交时怎样提交,而是从总体来定义
    """
    SOFT = 1  # 每一批都执行软提交
    SOFT_FINAL = 2  # 分批发送(不提交)后,最后执行一次软提交
    HARD_FINAL = 3  # 分批发送(不提交)后,最后执行一次硬提交
    HARD_EVERY = 4  # 每一批都执行硬提交
    # 调用
    class CommitTypeConfig(CommitConfigBase):
    """commit type 配置类"""
    
    def __init__(self, full=CommitStrategy.SOFT_FINAL.value,
                 incr=CommitStrategy.SOFT.value,
                 real_time=CommitStrategy.SOFT.value,
                 sec_incr=CommitStrategy.SOFT.value):
        """初始化各情况下的commitwithin值
        0-每一批软提交 1-分批发送后硬提交 2-每一批硬提交
        Args:
            full: 全量
            incr: 增量
            real_time: 实时
            sec_incr: 秒级增量
        """
        CommitConfigBase.__init__(self)
        self.full = CommitStrategy(full)
        self.incr = CommitStrategy(incr)
        self.real_time = CommitStrategy(real_time)
        self.sec_incr = CommitStrategy(sec_incr)
  2. 将常用魔法数(魔法字符串)抽提到常量模块,见wylib.util.constant
  3. 使用多态替代表示类型的魔法数,用多态替换类型判断(最清晰舒爽的做法,但python里实现起来,收益与损失相比没有很多)。

小节

本文从日常中遇到的性能和代码可读性问题出发,结合排查给出解释和处理办法。希望把自己踩过、看过的坑能分享给大家,少走弯路。但受限于个人经验,一些状况无法解释清楚,或解释有误,望大家多交流指点。

参考

  1. 为什么不能用可变对象作为函数的默认值 第41条
  2. 《重构——改善既有代码的设计》

发表评论

邮箱地址不会被公开。 必填项已用*标注