Xử lý hàng triệu bản ghi Python: Đừng để MemoryError đánh sập Server

Python tutorial - IT technology blog
Python tutorial - IT technology blog

Cơn ác mộng mang tên MemoryError

Hồi mới tập tành viết script Python để parse log, mình thường có thói quen dùng list để chứa mọi thứ cho tiện. Mọi chuyện vẫn êm đềm cho đến một ngày, file log hệ thống vọt lên hơn 5GB. Kết quả là script chạy được vài giây rồi lăn đùng ra chết với dòng thông báo ngắn gọn nhưng đầy ám ảnh: MemoryError.

Vấn đề nằm ở chỗ mình cố nạp toàn bộ nội dung file vào RAM để xử lý một lượt. Với các tập dữ liệu nhỏ, cách này cực kỳ nhanh. Nhưng khi đối mặt với log server hàng chục triệu dòng, RAM bao nhiêu cũng không đủ. Đó là lúc mình nhận ra giá trị của việc xử lý dữ liệu theo kiểu “cuốn chiếu” thông qua Generator và Iterator.

Tại sao RAM của bạn lại “bốc hơi” nhanh như vậy?

Thực tế, một cái list trong Python sẽ lưu trữ mọi phần tử của nó trong bộ nhớ cùng một lúc. Hãy thử tạo một danh sách 10 triệu số nguyên:

# Cách làm ngốn RAM
my_list = [i for i in range(10000000)] # Chiếm khoảng 80MB-400MB RAM tùy hệ thống

Python yêu cầu hệ điều hành cấp phát một khoảng không gian liên tục đủ lớn để chứa toàn bộ 10 triệu con số đó. Nếu server đang gánh nhiều service khác, việc chiếm dụng đột ngột này rất dễ gây treo máy. Đây chính là cơ chế “Eager Evaluation” (tính toán ngay lập tức), cái gì cũng muốn có sẵn ngay dù chưa dùng tới.

Iterator – Cơ chế “hỏi đến đâu, trả lời đến đó”

Thay vì bê nguyên một rổ cam về nhà rồi mới ăn, Iterator giống như việc bạn ra vườn, cứ mỗi lần muốn ăn thì hái đúng một quả. Bạn không cần kho chứa lớn, cũng chẳng lo cam bị hỏng vì để lâu.

Một đối tượng được gọi là Iterator nếu nó thực hiện đúng giao thức (protocol) gồm hai phương thức: __iter__()__next__(). Khi bạn gọi next(), nó mới tính toán để trả về giá trị tiếp theo. Trạng thái của nó được giữ lại để biết lần sau sẽ bắt đầu từ đâu.

class MyCounter:
    def __init__(self, low, high):
        self.current = low
        self.high = high

    def __iter__(self):
        return self

    def __next__ (self):
        if self.current > self.high:
            raise StopIteration
        self.current += 1
        return self.current - 1

# Sử dụng
counter = MyCounter(1, 10000000)
for num in counter:
    # Xử lý num ở đây, RAM vẫn cực kỳ nhẹ nhàng
    pass

Dù bạn có đếm đến 1 tỷ, script vẫn chỉ tiêu tốn một lượng RAM siêu nhỏ để lưu biến self.current. Máy chủ của bạn sẽ thở phào nhẹ nhõm.

Generator: Tuyệt chiêu với từ khóa yield

Viết cả một class chỉ để làm Iterator thì hơi rườm rà. Python cung cấp một cách ngắn gọn hơn nhiều: Generator. Thay vì dùng return để kết thúc hàm, bạn chỉ cần dùng yield.

Khi gặp yield, hàm sẽ tạm dừng và “đóng băng” toàn bộ trạng thái tại đó. Khi được gọi tiếp, nó lại rã đông và chạy từ dòng code ngay sau yield. Cực kỳ thông minh!

def my_generator(n):
    i = 0
    while i < n:
        yield i
        i += 1

# Chỉ mất vài KB RAM dù n lớn thế nào đi nữa
gen = my_generator(10000000)

So sánh thực tế về độ “nhẹ”

Thử dùng module sys để kiểm tra kích thước đối tượng thực tế. Kết quả sẽ khiến bạn bất ngờ:

import sys

n = 1000000
list_data = [i for i in range(n)]
gen_data = (i for i in range(n))

print(f"List ngốn: {sys.getsizeof(list_data)} bytes (~8MB)")
print(f"Generator ngốn: {sys.getsizeof(gen_data)} bytes (112 bytes)")

List ngốn gấp hơn 70.000 lần bộ nhớ so với Generator. Nếu dữ liệu lên tới hàng tỷ bản ghi, Generator chính là ranh giới giữa việc script chạy mượt và việc server bị OOM (Out Of Memory) vào lúc nửa đêm.

Ứng dụng thực tế: Xử lý file log 10GB

Trong công việc DevOps, mình thường xuyên phải lọc log. Nếu dùng f.readlines(), bạn đang tự làm khó mình nếu file nặng vài GB. Cách đúng đắn là tận dụng việc bản thân đối tượng file trong Python đã là một Iterator.

def filter_error_logs(file_path):
    with open(file_path, "r") as f:
        for line in f: # Đọc từng dòng một, không nạp cả file vào RAM
            if "ERROR" in line:
                yield line.strip()

# Xử lý pipeline dữ liệu
logs = filter_error_logs("huge_production.log")
for error in logs:
    # Đẩy error này lên Telegram hoặc lưu vào DB
    print(f"Phát hiện lỗi: {error}")

Dữ liệu chỉ được đọc và xử lý khi thực sự cần thiết. Đây gọi là cơ chế Lazy Evaluation (tính toán lười biếng) – một kiểu lười biếng cực kỳ có lợi cho hiệu suất.

Generator Expression: Viết code vừa ngắn vừa sang

Nếu đã quen với List Comprehension kiểu [x for x in data], bạn chỉ cần thay ngoặc vuông bằng ngoặc tròn (x for x in data) để có ngay một Generator Expression.

# Tính tổng bình phương của 10 triệu số mà không tốn RAM
total = sum(x*x for x in range(10000000))

Hàm sum() sẽ lấy từng số, tính bình phương rồi cộng dồn luôn. Không có danh sách trung gian nào được tạo ra gây lãng phí tài nguyên.

Xâu chuỗi Generator (Pipeline Pattern)

Kỹ thuật mình thích nhất khi xử lý ETL là xâu chuỗi nhiều generator. Nó giống như một dây chuyền sản xuất, mỗi công đoạn chỉ xử lý đúng một sản phẩm tại một thời điểm.

def get_lines(file_obj): yield from file_obj

def clean_lines(lines): 
    for line in lines: yield line.strip()

def find_critical(lines):
    for line in lines:
        if "CRITICAL" in line: yield line

# Kết nối các công đoạn
with open("app.log") as f:
    critical_issues = find_critical(clean_lines(get_lines(f)))
    for issue in critical_issues:
        print(f"Nguy cấp: {issue}")

Cách viết này cực kỳ tường minh. Bạn có thể dễ dàng bảo trì hoặc thêm bước xử lý mà không lo ảnh hưởng đến RAM.

Lời kết từ kinh nghiệm thực chiến

Sau nhiều lần phải dậy lúc 2 giờ sáng để khởi động lại server, mình rút ra quy tắc: Bất cứ khi nào làm việc với chuỗi dữ liệu chưa rõ kích thước, hãy dùng Generator.

  • Dùng List khi: Dữ liệu nhỏ, cần truy cập ngẫu nhiên (như lấy phần tử thứ 10 rồi quay lại phần tử thứ 2) hoặc cần sắp xếp nhiều lần.
  • Dùng Generator/Iterator khi: Xử lý dữ liệu lớn, đọc file, stream dữ liệu từ database, hoặc chỉ cần duyệt qua dữ liệu một lần.

Tối ưu bộ nhớ không phải là điều gì quá cao siêu. Đôi khi nó chỉ bắt đầu từ việc thay đổi cặp dấu [] thành (). Hy vọng những chia sẻ này giúp anh em né được lỗi MemoryError trong các dự án sắp tới.

Share: