經過4次優化我把python程式碼耗時減少95%

金色旭光發表於2021-11-12

背景交代

團隊做大學英語四六級考試相關服務。業務中有一個care服務,購買了care服務考試不過可以全額退款,不過有一個前提是要完成care服務的任務,比如堅持背單詞N天,完成指定的試卷。

在這個背景下,當2021年6月的四六級考試完成之後,要統計出兩種使用者資料:

  1. 完成care服務的使用者
  2. 沒有完成care的使用者

所以簡化的邏輯就是要在所有的使用者中區分出care完成使用者和care未完成使用者。

  • 目標1:完成care服務
  • 目標2:未完成care服務

所有目標使用者的數量在2.7w左右,care完成使用者在0.4w左右。所以我需要做的是在從資料庫中查詢出的 2.7w 所有使用者,去另一個表區分出care完成使用者和care未完成使用者。

第一版

第一版:純粹使用資料庫查詢。首先查詢出所有目標,然後遍歷所有使用者,在遍歷中使用user id從另一張表中查詢出care完成使用者。
總量在2.7w 左右,所以資料庫就查詢了2.7w次。

耗時統計:144.7 s

def remind_repurchase():
    # 查詢出所有使用者
    all_users = cm.UserPlan.select().where(cm.UserPlan.plan_id.in_([15, 16]))
    plan_15_16_not_refund_users = []
    plan_15_16_can_refund_users = []
    for user in all_users:
        # 從另一張表查詢使用者是否完成care
        user_insurance = cm.UserInsurance.select().where(
            cm.UserInsurance.user_id == user.user_id,
            cm.UserInsurance.plan_id == user.plan_id,
            cm.UserInsurance.status == cm.UserInsurance.STATUS_SUCCESS,
        )

        # care 完成
        if len(user_insurance) == 1:
            # 其他邏輯
            plan_15_16_can_refund_users.append(user.user_id)
        else:
            # care未完成使用者
            plan_15_16_not_refund_users.append(user.user_id)
            

主要的耗時操作就在for迴圈查詢資料庫。這種耗時肯定是不被允許的,需要提高效率。

第二版

優化點:增加事務
第二版優化思路:對於 2.7w 次的資料庫庫查詢肯定會有 2.7w 次建立連線、事務、查詢語句轉SQL等。2.7w次的開銷也是一個極大的數字。理所當然的想到了減少事務的開銷。將所有的資料庫查詢都放在一個事務中完成,就能夠有效減少查詢帶來的耗時。

耗時統計:100.6 s


def remind_repurchase():

    # 查詢出所有使用者
    all_users = cm.UserPlan.select().where(cm.UserPlan.plan_id.in_([15, 16]))
    plan_15_16_not_refund_users = []
    plan_15_16_can_refund_users = []

    # 增加事務
    with pwdb.database.atomic():
        for user in all_users:
            # 從另一張表查詢使用者是否完成care
            user_insurance = cm.UserInsurance.select().where(
                cm.UserInsurance.user_id == user.user_id,
                cm.UserInsurance.plan_id == user.plan_id,
                cm.UserInsurance.status == cm.UserInsurance.STATUS_SUCCESS,
            )
            
            # care 完成
            if len(user_insurance) == 1:
                # 其他邏輯
                plan_15_16_can_refund_users.append(user.user_id)
            else:
                # care未完成使用者
                plan_15_16_not_refund_users.append(user.user_id)

增加事務之後減少了44s,相當於縮短了時間30%的時間,由此可以看出事務在資料庫中查詢是一個比較耗時的操作。

第三版

優化點:將2.7w次的資料庫查詢轉變成對列表的in操作。
第三版提出改進方案:原來的邏輯是迴圈 2.7w 次,在資料庫中查詢使用者是否完成care服務。2.7w 次的資料庫查詢是耗時最長的原因,而可以改進的方法是將所有完成care服務的使用者先一次性查詢出來,放到一個列表中。遍歷所有使用者時不去查資料庫,而是直接使用in操作在列表中查詢。這種方法直接將 2.7w 次資料庫遍歷減少到1次,極大縮短了資料庫查詢耗時。

耗時統計:11.5 s


def remind_repurchase():
   
    all_users = cm.UserPlan.select().where(cm.UserPlan.plan_id.in_([15, 16]))
    plan_15_16_not_refund_users = []
    plan_15_16_can_refund_users = []
    
    # 所有care完成使用者,先將所有使用者查詢出來放在一個列表中
    user_insurance = cm.UserInsurance.select().where(
        cm.UserInsurance.plan_id.in_([15, 16]),
        cm.UserInsurance.status == cm.UserInsurance.STATUS_SUCCESS,
    )
    user_insurance_list = [user.user_id for user in user_insurance]

   
    for user in all_users:
        user_id = user.user_id
        # care 完成
        if user.user_id in user_insurance_list:
            # 查分數
            plan_15_16_can_refund_users.append(user_id)
        else:
            # care未完成使用者 + 非care使用者
            plan_15_16_not_refund_users.append(user_id)

這一次優化的效果是非常顯著的,可以看出想要提高程式碼效率要儘量減少資料庫查詢次數。

第四版

優化點:2.7w 次對列表的in操作變成對字典的in操作
在第三版中已經極大的優化了效率,但是仔細琢磨之後發現還是有提升的空間的。在第三版中 2.7w 次for迴圈,然後用in操作在列表中查詢。眾所周知python中對列表的in操作是遍歷的,時間複雜度為0(n),所以效率不高,而對字典的in操作時間複雜度為常數級別0(1)。所以在第四版優化中先查詢出的資料不儲存為列表,而是儲存為字典。key就是原來列表中的值,value可自定義。

耗時統計:11.42 s

def remind_repurchase():
   
    all_users = cm.UserPlan.select().where(cm.UserPlan.plan_id.in_([15, 16]))
    plan_15_16_not_refund_users = []
    plan_15_16_can_refund_users = []
    
    # 所有care完成使用者,先將所有使用者查詢出來放在一個列表中
    user_insurance = cm.UserInsurance.select().where(
        cm.UserInsurance.plan_id.in_([15, 16]),
        cm.UserInsurance.status == cm.UserInsurance.STATUS_SUCCESS,
    )

    user_insurance_dict = {user.user_id:True for user in user_insurance}

   
    for user in all_users:
        user_id = user.user_id
        # care 完成
        if user.user_id in user_insurance_dict:
            # 查分數
            plan_15_16_can_refund_users.append(user_id)
        else:
            # care未完成使用者 + 非care使用者
            plan_15_16_not_refund_users.append(user_id)

由於2.7w次的in運算元據量並不是很大,並且列表的in操作在python中優化的效率也很好,所以這裡的對字典的in操作並沒有減少時間消耗。

第五版

優化點:將in操作轉變成集合操作。
在前四版的優化下已經將耗時縮短了 133s,減少了近 92.1% 的耗時,想著這個資料看起來還不錯了。隔天早上在刷牙時腦子裡思緒紛飛就想到這個事情了。這時忽然想到既然我能查詢全部使用者,又將完成care使用者的使用者查詢到一個列表中,這時不就是相當於兩個集合嗎?既然是集合,那麼使用集合之間的交集和差集是不是比迴圈 2.7w 次要快呢?上班之後馬上動手來驗證這個想法。果然,還能夠減少時間消耗,將第四版中的11.42 直接減少了一半,縮短到5.78,縮短近50%。

耗時統計: 5.78 s

def remind_repurchase():
    
    all_users = cm.UserPlan.select().where(cm.UserPlan.plan_id.in_([15, 16]))
    all_users_set = set([user.user_id for user in all_users])

    plan_15_16_can_refund_users = []
    received_user_count = 0

    # 所有care完成使用者
    user_insurance = cm.UserInsurance.select().where(
        cm.UserInsurance.plan_id.in_([15, 16]),
        cm.UserInsurance.status == cm.UserInsurance.STATUS_SUCCESS,
    )
    user_insurance_set = set([user.user_id for user in user_insurance])

    temp_can_refund_users = all_users_set.intersection(user_insurance_set)

總結

最終優化的結果:
第一版耗時: 144.7 s
最後一版耗時: 5.7 s
優化時間:109 s
優化百分比:95.0%

在各個版本中的優化詳細細節如下:

由此可以得出幾個結論,幫助減少程式耗時:
結論一:事務不僅能夠保證資料原子性,合理使用還能有效減少資料庫查詢耗時
結論二:集合操作的效率非常高,要善於使用集合減少迴圈
結論三:字典的查詢效率高於列表,但是萬次級別的操作無法體驗優勢

最後的還有一個結論:程式設計師的靈感似乎在刷牙、上廁所、洗澡、喝水時特別活躍,所以寫不出來程式碼就該去摸摸魚了。

相關文章