A new Protocol Buffers generator for Go

Bài tóm tắt Vitess 2021 4. Điều chỉnh Độ khó: Nâng cao

Vitess (PlanetScale/CNCF) · 2021 · 4. Điều chỉnh · Độ khó: Nâng cao — bài gốc

Bối cảnh

  • Mọi giao tiếp giữa các thành phần của Vitess đi qua gRPC, mọi message là Protobuf. Năm 2020 Google ra Go Protobuf APIv2, không tương thích APIv1. Vitess không vội nâng: việc lớn, không có lợi ích trực tiếp (không dùng reflection), và nhất là hồi quy hiệu năng.
  • Lý do hồi quy: đầu 2021 Vitess đã chuyển sang Gogo ProtoBuf (fork của trình sinh APIv1) để sinh code marshal/unmarshal trải phẳng, nhanh hơn nhiều so với codec dựa trên reflection. Nhưng Gogo không còn được bảo trì, vì lên APIv2 đồng nghĩa viết lại toàn bộ. Đội biết đây là tối ưu ngắn hạn và cố dùng ít tính năng riêng của Gogo nhất có thể.
  • Thử nâng lên APIv2 (vài tuần, test xanh): benchmark nightly AreWeFastYet báo CPU tăng tới 19%, throughput toàn hệ giảm khoảng 3%. Vitess cố không để hiệu năng tụt giữa các bản phát hành.

Thay đổi chính

  • Không nhận nuôi Gogo (quá lớn để nâng và duy trì mãi). Thay vào đó viết vtprotobuf: không phải fork mà là plugin chạy cạnh trình sinh chính thức của APIv2, chỉ sinh các hàm helper tối ưu (marshal, unmarshal, size) dưới dạng tuỳ chọn, phụ thuộc duy nhất vào gói protogen công khai và ổn định. Đổi lại không làm được vài tối ưu của Gogo (field không nullable) vì không sửa struct message.
  • Memory pool cho message được đánh dấu: unmarshal lấy object từ pool, có hàm trả lại pool và Reset giữ bộ nhớ đã cấp. Go không có arena và hệ kiểu không bảo vệ object trong pool, nên dùng sai dễ gây data race.
  • Đo trên VReplication (sao chép bảng lớn giữa hai keyspace, đường dùng RPC nhiều nhất), thời gian CPU cho unmarshal: APIv1 mặc định 7,47 s; Gogo 4,24 s; APIv2 mặc định 7,17 s; APIv2 + vtprotobuf 4,15 s; thêm pooling 0,63 s (2021, theo Vitess).

Vì sao

  • Phụ thuộc vào một fork bị bỏ rơi khoá dự án vào API cũ; tối ưu phải tách khỏi phần sinh message chính thức để đi cùng upstream lâu dài.

Bài học cho SWE

  • Khi buộc phải tối ưu bằng công cụ ngoài luồng chính, thiết kế nó như plugin chạy cạnh upstream thay vì fork; mất vài tối ưu nhưng không bị kẹt khi upstream đổi.
  • Benchmark chạy hằng đêm biến "nâng phụ thuộc" thành quyết định có số liệu, phát hiện hồi quy trước khi phát hành.
  • Phần lớn chi phí của serialization trong Go là cấp phát bộ nhớ, không phải parse; pool đúng chỗ (đường stream nóng) có hiệu quả hơn tối ưu code sinh ra.

Liên quan