Nửa đêm, khi một chức năng bỗng chạy chậm hoặc trả về kết quả sai, phản xạ quen thuộc của nhiều người là mở code và sửa ngay vào chỗ nghi ngờ nhất. Đôi khi cách đó may mắn có kết quả ngay. Nhưng theo kinh nghiệm của đội ngũ chúng tôi, lỗi kiểu này thường quay lại sau vài tuần, đúng lúc không ai còn nhớ bối cảnh ban đầu. Vì vậy, trước khi chạm vào code, chúng tôi luôn dành vài phút để hiểu rõ mình đang đối mặt với điều gì.
Bài viết này tổng hợp bảy lưu ý mà chúng tôi rút ra sau nhiều dự án .NET quy mô vừa. Mỗi lưu ý được chọn theo ba tiêu chí: bạn áp dụng được ngay trong ngày làm việc, có công cụ sẵn để đo kết quả, và đủ thực tế cho một dự án không quá lớn. Bạn không cần đọc theo thứ tự, cứ chọn mục nào đang gặp phải là được.
Vì sao debug và tối ưu mã hay bị làm sai ngay từ đầu
Lý do lớn nhất là chúng ta thường chữa triệu chứng thay vì tìm nguyên nhân gốc. Giống như đèn cảnh báo nhấp nháy mà bạn chỉ tắt đèn đi, trong khi dây điện bên trong vẫn đang chập. Lỗi không biến mất, nó chỉ chờ đến lần sau mới lộ diện.
Một sai lầm khác là tối ưu quá sớm khi chưa có số liệu. Đội của chúng tôi từng viết lại gần như toàn bộ một hàm tính toán chỉ vì cảm giác nó chậm. Sau khi đo, chúng tôi phát hiện điểm nghẽn thật nằm ở một câu truy vấn hoàn toàn khác, thuộc tầng dữ liệu chứ không phải logic tính toán. Công sức bỏ ra cho hàm kia coi như mất trắng.
Vì vậy, mỗi lưu ý bên dưới đều bắt đầu từ việc hiểu rõ vấn đề trước khi động vào code. Đây cũng là thói quen mà chúng tôi cố gắng truyền lại cho các bạn dev mới trong đội.
Lưu ý 1: Tái hiện lỗi ổn định trước khi debug
Lỗi mà bạn không tái hiện được thì gần như không thể sửa chắc chắn. Bạn có thể sửa xong, chạy thử thấy ổn, rồi vài ngày sau lỗi xuất hiện y nguyên mà không ai biết vì sao.
Trước khi đặt breakpoint, hãy ghi lại ba thứ: các bước thao tác chính xác, dữ liệu đầu vào và môi trường đang chạy. Môi trường ở đây gồm phiên bản .NET, file cấu hình và tài khoản nào đã thực hiện thao tác. Một ghi chú vài dòng như vậy giúp bạn lúc này, và cả đồng đội sau này, hiểu ngay chuyện đã xảy ra. Điều này đặc biệt có ích cho dev mới, vì họ dễ bị cuốn vào việc đoán mò và sửa theo cảm tính.
Tất nhiên, không phải lỗi nào cũng dễ tái hiện. Với lỗi phụ thuộc thời gian hoặc tải mạng, bạn sẽ cần thêm log hoặc công cụ ghi vết. Đừng cố thao tác tay mãi, vì khi đó dữ liệu bạn có thường không đủ để kết luận.
Lưu ý 2: Dùng breakpoint có điều kiện thay vì đặt bừa bãi
Breakpoint thông thường dừng chương trình mỗi lần chạy qua dòng đó. Nếu nó nằm trong vòng lặp xử lý vài nghìn bản ghi, bạn sẽ phải bấm tiếp hàng nghìn lần mới tới được trường hợp cần xem. Thời gian mất đi rất nhanh.
Visual Studio có Conditional Breakpoint, cho phép chỉ dừng khi một biểu thức đúng. Ví dụ, nếu bạn chỉ quan tâm đơn hàng có mã 10542, hãy đặt điều kiện order.Id == 10542. Tracepoint thì khác: nó không dừng chương trình mà chỉ in giá trị ra cửa sổ Output. Cách này hợp khi bạn muốn quan sát luồng dữ liệu mà không làm gián đoạn nhịp chạy, nhất là với các tác vụ có ràng buộc thời gian.
Hạn chế là nếu đặt quá nhiều tracepoint, cửa sổ Output sẽ rối đến mức không đọc nổi. Chỉ đặt ở những điểm thật sự cần quan sát, và xóa chúng khi đã có kết luận.
Lưu ý 3: Đo trước khi tối ưu
Đo là cách duy nhất để biết chắc chỗ nào thực sự chậm trước khi bạn bỏ công sửa. Với một đoạn code nhỏ, Stopwatch đủ để lấy con số thời gian. Khi cần so sánh nhiều cách viết khác nhau, BenchmarkDotNet cho kết quả đáng tin hơn, vì nó xử lý các yếu tố như JIT warm-up và số lần lặp để giảm nhiễu.
Khi chạy thử ứng dụng, cửa sổ Diagnostic Tools của Visual Studio cho thấy mức dùng bộ nhớ và số lần gọi database. Nhìn vào đó, bạn thường khoanh vùng được một chỗ đáng nghi khá nhanh.
Có một điều cần lưu ý: máy dev thường khác production về cấu hình phần cứng, lượng dữ liệu và mức tải đồng thời. Kết quả benchmark trên máy cá nhân chỉ nên xem là gợi ý. Hãy kiểm chứng lại trên môi trường gần thực tế nhất có thể trước khi kết luận.
Lưu ý 4: Cẩn trọng với truy vấn dữ liệu trong vòng lặp
Đây là lỗi kinh điển khi dùng Entity Framework. Bạn lấy danh sách đơn hàng, rồi trong vòng lặp gọi thêm truy vấn để lấy thông tin khách hàng của từng đơn. Kết quả là một truy vấn chính cộng với N truy vấn phụ, thường gọi là N+1. Với 500 đơn hàng, database có thể nhận gần 501 câu lệnh thay vì chỉ một hoặc hai.
Cách xử lý phổ biến là dùng Include để nạp dữ liệu liên quan trong cùng một lần truy vấn. Nếu chỉ cần vài cột, dùng Select để lấy đúng những thứ đó, không kéo cả entity về. Quan trọng hơn cả, hãy bật logging để xem câu SQL thực sự được sinh ra, vì điều bạn nghĩ mình viết và điều EF thực thi đôi khi khác nhau khá xa.
Hạn chế là tối ưu quá tay có thể làm code khó đọc, nhất là khi chuỗi Include dài và lồng nhau. Khi thấy vậy, hãy tách phần truy vấn thành một hàm riêng có tên rõ nghĩa để người đọc hiểu ý đồ ngay. Nếu bạn muốn hiểu sâu hơn các lỗi hay gặp khi làm việc với dữ liệu, có thể tham khảo các sai lầm thường gặp khi dùng Entity Framework.
Lưu ý 5: Xử lý async đúng cách khi debug
Gọi .Result hoặc .Wait() trên một Task trong ngữ cảnh có SynchronizationContext có thể gây deadlock. Ứng dụng đứng im mà không báo lỗi gì, nên loại lỗi này rất khó nhận ra nếu chỉ nhìn log thông thường.
Khi gặp tình huống treo như vậy, cửa sổ Parallel Stacks trong Visual Studio là công cụ đáng giá. Nó cho thấy các Task đang chờ nhau ở đâu, từ đó bạn tìm ra vòng khóa một cách trực quan. Nhiều người dùng Visual Studio lâu năm vẫn chưa mở nó lần nào.
Lỗi async thường không tái hiện ổn định, nên hãy ghi log đầy đủ thời điểm bắt đầu và kết thúc các tác vụ quan trọng. Khi lỗi xuất hiện trên môi trường thật, bạn sẽ có dữ liệu để đối chiếu thay vì phải đoán.
Lưu ý 6: Ghi log có cấu trúc để truy vết nguyên nhân
Log dạng chữ thuần như “Đã xử lý xong” gần như vô dụng khi hệ thống có nhiều dịch vụ chạy song song. Bạn thấy hàng nghìn dòng giống nhau và không biết dòng nào thuộc về yêu cầu đang gặp lỗi.
Dùng ILogger với các thuộc tính ngữ cảnh như mã đơn hàng, mã người dùng hoặc mã request giúp bạn lọc đúng dòng cần xem. Cách này cần thiết khi ứng dụng chạy trên nhiều máy chủ, nơi bạn không thể ngồi theo dõi trực tiếp từng tiến trình.
Log quá chi tiết cũng tốn dung lượng lưu trữ và chi phí. Hãy phân mức rõ ràng: Debug và Trace dùng cho môi trường phát triển, còn Information và Warning là đủ cho production. Đừng bật mọi thứ ở mọi nơi.
Lưu ý 7: Kiểm thử hồi quy sau mỗi lần tối ưu
Tối ưu xong mà làm hỏng chức năng cũ thì coi như chưa làm gì. Viết unit test cho đoạn code vừa sửa là cách đơn giản nhất để đảm bảo kết quả không thay đổi ngoài ý muốn.
Chúng tôi thấy cách này đặc biệt đáng giá khi dự án có nhiều người cùng chỉnh sửa. Người sau rất dễ vô tình phá logic của người trước vì không nắm hết ngữ cảnh. Một bộ test nhỏ nhưng đặt đúng chỗ sẽ bắt được những lỗi đó sớm hơn nhiều.
Chi phí viết test ban đầu khá cao, nhất là với code cũ không được thiết kế để test. Bạn không cần phủ test cho mọi thứ. Hãy ưu tiên các đoạn liên quan đến tiền bạc hoặc dữ liệu khách hàng, rồi mở rộng dần.
Một ví dụ thực tế trong đội nhỏ
Hãy hình dung một đội ba bốn dev nhận báo cáo rằng trang thống kê doanh thu trên hệ thống bán hàng chạy rất chậm vào cuối tháng. Ban đầu mọi người có ý kiến khác nhau: người nghĩ do server yếu, người nghi do chính code báo cáo.
Đội chúng tôi thường bắt đầu bằng việc tái hiện lỗi đúng với khoảng thời gian và bộ lọc khách hàng đang dùng. Sau đó bật logging SQL để xem câu truy vấn nào được gọi bao nhiêu lần. Trong trường hợp này, một vòng lặp gọi thêm truy vấn cho từng cửa hàng đã sinh ra hàng trăm câu SQL không cần thiết. Con số đó khiến cả đội bất ngờ.
Sau khi xác định nguyên nhân, người sửa viết một benchmark nhỏ để so sánh cách cũ và cách mới. Cách mới dùng một truy vấn gom nhóm duy nhất. Kết quả được kiểm tra bằng unit test để chắc chắn số liệu doanh thu không đổi so với trước. Nếu muốn tìm hiểu cách viết truy vấn gọn hơn trong C#, bạn có thể đọc so sánh LINQ method syntax và query syntax.
Đây là bài học quen thuộc: khoanh vùng trước, đo đạc sau, sửa cuối cùng rồi kiểm thử lại. Với phần quy trình review mã trong nhóm, bạn cũng có thể tham khảo best practices cho backend developer khi review code.
Tóm tắt và điểm bắt đầu phù hợp
Nếu bạn đang ở giai đoạn đầu với một lỗi chưa rõ nguyên nhân, hãy bắt đầu từ lưu ý 1 và 2 để có đường tái hiện ổn định và điểm dừng hợp lý. Khi đã có lỗi lặp lại được, lưu ý 3 và 4 giúp bạn xác định đúng chỗ nghẽn. Nếu hệ thống có nhiều dịch vụ hoặc nhiều tác vụ bất đồng bộ, lưu ý 5 và 6 thường mang lại giá trị nhanh nhất. Cuối cùng, lưu ý 7 là lớp bảo vệ để mọi thay đổi không làm hỏng những gì đã chạy tốt.
Các con số thời gian tiết kiệm được mà chúng tôi nêu chỉ mang tính tham khảo từ kinh nghiệm riêng, mỗi dự án sẽ khác nhau. Hãy tự kiểm chứng bằng số liệu của chính hệ thống bạn đang làm.
Câu hỏi thường gặp
Nên bắt đầu debug từ đâu khi chưa biết nguyên nhân lỗi?
Bắt đầu bằng việc tái hiện lỗi và ghi lại đầy đủ bước thao tác, dữ liệu và môi trường. Khi đã có đường tái hiện ổn định, đặt breakpoint có điều kiện ở đúng vùng nghi ngờ thay vì đọc toàn bộ code. Nếu lỗi không tái hiện được trên máy dev, hãy xem log từ môi trường thật trước khi đoán tiếp.
Có nên tối ưu mã ngay từ đầu dự án không?
Thường thì không. Giai đoạn đầu nên ưu tiên code rõ ràng và dễ sửa đổi. Khi có dữ liệu đo đạc cho thấy điểm nghẽn thật, hãy tối ưu đúng chỗ đó. Tối ưu sớm dễ làm code phức tạp hơn mà không mang lại lợi ích thực tế.
Công cụ nào đủ dùng cho dev mới bắt đầu?
Visual Studio cùng breakpoint có điều kiện, cửa sổ Diagnostic Tools và Stopwatch là đủ để bắt đầu. Khi đã quen quy trình, bạn có thể học thêm BenchmarkDotNet để so sánh hiệu năng và ILogger để ghi log có cấu trúc.
Làm sao biết một đoạn code cần tối ưu?
Đừng chỉ dựa vào cảm giác. Hãy đo thời gian thực thi, số lần gọi database và mức dùng bộ nhớ. Đoạn nào chiếm phần lớn thời gian và được gọi nhiều lần mới đáng để tối ưu trước.

