Python 大資料量文字檔案高效解析方案程式碼實現

授客發表於2022-12-18

大資料量文字檔案高效解析方案程式碼實現

測試環境

Python 3.6.2

Win 10 記憶體 8G,CPU I5 1.6 GHz

背景描述

這個作品來源於一個日誌解析工具的開發,這個開發過程中遇到的一個痛點,就是日誌檔案多,日誌資料量大,解析耗時長。在這種情況下,尋思一種高效解析資料解析方案。

解決方案描述

1、採用多執行緒讀取檔案

2、採用按塊讀取檔案替代按行讀取檔案

由於日誌檔案都是文字檔案,需要讀取其中每一行進行解析,所以一開始會很自然想到採用按行讀取,後面發現合理配置下,按塊讀取,會比按行讀取更高效。

按塊讀取來的問題就是,可能導致完整的資料行分散在不同資料塊中,那怎麼解決這個問題呢?解答如下:

將資料塊按換行符\n切分得到日誌行列表,列表第一個元素可能是一個完整的日誌行,也可能是上一個資料塊末尾日誌行的組成部分,列表最後一個元素可能是不完整的日誌行(即下一個資料塊開頭日誌行的組成部分),也可能是空字串(日誌塊中的日誌行資料全部是完整的),根據這個規律,得出以下公式,透過該公式,可以得到一個新的資料塊,對該資料塊二次切分,可以得到資料完整的日誌行

上一個日誌塊首部日誌行 +\n + 尾部日誌行 + 下一個資料塊首部日誌行 + \n + 尾部日誌行 + ...

3、將資料解析操作拆分為可並行解析部分和不可並行解析部分

資料解析往往涉及一些不可並行的操作,比如資料求和,最值統計等,如果不進行拆分,並行解析時勢必需要新增互斥鎖,避免資料覆蓋,這樣就會大大降低執行的效率,特別是不可並行操作佔比較大的情況下。

對資料解析操作進行拆分後,可並行解析操作部分不用加鎖。考慮到Python GIL的問題,不可並行解析部分替換為單程式解析。

4、採用多程式解析替代多執行緒解析

採用多程式解析替代多執行緒解析,可以避開Python GIL全域性解釋鎖帶來的執行效率問題,從而提高解析效率。

5、採用佇列實現“協同”效果

引入佇列機制,實現一邊讀取日誌,一邊進行資料解析:

  1. 日誌讀取執行緒將日誌塊儲存到佇列,解析程式從佇列獲取已讀取日誌塊,執行可並行解析操作
  2. 並行解析操作程式將解析後的結果儲存到另一個佇列,另一個解析程式從佇列獲取資料,執行不可並行解析操作。

程式碼實現

#!/usr/bin/env python
# -*- coding:utf-8 -*-
import re
import time
from datetime import datetime
from joblib import Parallel, delayed, parallel_backend
from collections import deque
from multiprocessing import cpu_count
import threading


class LogParser(object):
    def __init__(self, chunk_size=1024*1024*10, process_num_for_log_parsing=cpu_count()):
        self.log_unparsed_queue = deque() # 用於儲存未解析日誌
        self.log_line_parsed_queue = deque()  # 用於儲存已解析日誌行
        self.is_all_files_read = False  # 標識是否已讀取所有日誌檔案
        self.process_num_for_log_parsing = process_num_for_log_parsing # 併發解析日誌檔案程式數
        self.chunk_size = chunk_size # 每次讀取日誌的日誌塊大小
        self.files_read_list = [] # 存放已讀取日誌檔案
        self.log_parsing_finished = False # 標識是否完成日誌解析


    def read_in_chunks(self, filePath, chunk_size=1024*1024):
        """
        惰性函式(生成器),用於逐塊讀取檔案。
        預設區塊大小:1M
        """

        with open(filePath, 'r', encoding='utf-8') as f:            
            while True:
                chunk_data = f.read(chunk_size)
                if not chunk_data:
                    break
                yield chunk_data


    def read_log_file(self, logfile_path):
        '''
        讀取日誌檔案
        這裡假設日誌檔案都是文字檔案,按塊讀取後,可按換行符進行二次切分,以便獲取行日誌
        '''

        temp_list = []  # 二次切分後,頭,尾行日誌可能是不完整的,所以需要將日誌塊頭尾行日誌相連線,進行拼接
        for chunk in self.read_in_chunks(logfile_path, self.chunk_size):
            log_chunk = chunk.split('\n')
            temp_list.extend([log_chunk[0], '\n'])
            temp_list.append(log_chunk[-1])
            self.log_unparsed_queue.append(log_chunk[1:-1])
        self.log_unparsed_queue.append(''.join(temp_list).split('\n'))
        self.files_read_list.remove(logfile_path)


    def start_processes_for_log_parsing(self):
        '''啟動日誌解析程式'''

        with parallel_backend("multiprocessing", n_jobs=self.process_num_for_log_parsing):
            Parallel(require='sharedmem')(delayed(self.parse_logs)() for i in range(self.process_num_for_log_parsing))

        self.log_parsing_finished = True

    def parse_logs(self):
        '''解析日誌'''

        method_url_re_pattern = re.compile('(HEAD|POST|GET)\s+([^\s]+?)\s+',re.DOTALL)
        url_time_taken_extractor = re.compile('HTTP/1\.1.+\|(.+)\|\d+\|', re.DOTALL)

        while self.log_unparsed_queue or self.files_read_list:
            if not self.log_unparsed_queue:
                continue
            log_line_list = self.log_unparsed_queue.popleft()
            for log_line in log_line_list:
                #### do something with log_line
                if not log_line.strip():
                    continue

                res = method_url_re_pattern.findall(log_line)
                if not res:
                    print('日誌未匹配到請求URL,已忽略:\n%s' % log_line)
                    continue
                method = res[0][0]
                url = res[0][1].split('?')[0]  # 去掉了 ?及後面的url引數

                # 提取耗時
                res = url_time_taken_extractor.findall(log_line)
                if res:
                    time_taken = float(res[0])
                else:
                    print('未從日誌提取到請求耗時,已忽略日誌:\n%s' % log_line)
                    continue

                # 儲存解析後的日誌資訊
                self.log_line_parsed_queue.append({'method': method,
                                                   'url': url,
                                                   'time_taken': time_taken,
                                                   })


    def collect_statistics(self):
        '''收集統計資料'''

        def _collect_statistics():
            while self.log_line_parsed_queue or not self.log_parsing_finished:
                if not self.log_line_parsed_queue:
                    continue
                log_info = self.log_line_parsed_queue.popleft()
                # do something with log_info
       
        with parallel_backend("multiprocessing", n_jobs=1):
            Parallel()(delayed(_collect_statistics)() for i in range(1))

    def run(self, file_path_list):
        # 多執行緒讀取日誌檔案
        for file_path in file_path_list:
            thread = threading.Thread(target=self.read_log_file,
                                      name="read_log_file",
                                      args=(file_path,))
            thread.start()
            self.files_read_list.append(file_path)

        # 啟動日誌解析程式
        thread = threading.Thread(target=self.start_processes_for_log_parsing, name="start_processes_for_log_parsing")
        thread.start()

        # 啟動日誌統計資料收集程式
        thread = threading.Thread(target=self.collect_statistics, name="collect_statistics")
        thread.start()

        start = datetime.now()
        while threading.active_count() > 1:
            print('程式正在努力解析日誌...')
            time.sleep(0.5)

        end = datetime.now()
        print('解析完成', 'start', start, 'end', end, '耗時', end - start)



if __name__ == "__main__":
    log_parser = LogParser()
    log_parser.run(['access.log', 'access2.log'])

注意:

需要合理的配置單次讀取檔案資料塊的大小,不能過大,或者過小,否則都可能會導致資料讀取速度變慢。筆者實踐環境下,發現10M~15M每次是一個比較高效的配置。

相關文章