Trong bài viết này
1. SBE là gì, sự khác biệt so với các cơ chế encoding khác.
1.1. Giới thiệu qua SBE và một số cơ chế cùng mục đích phổ biến
SBE (Simple Binary Encoding) là một chuẩn mã hoá thông điệp nhị phân tốc độ cao, thiết kế ban đầu bởi Fix Trading Community để phục vụ thị trường tài chính. Không phải với mục đích lưu trữ, mà SBE sinh ra để truyền tải dữ liệu trên đường truyền mạng với độ trễ thấp nhất có thể.
Một số định dạng khác thường thấy, phổ biến hơn trong các hệ thống:
- JSON/XML: Định dạng text, dễ đọc cho con người nhưng khó khăn hơn cho các hệ thống tần suất cao vì quá trình parse từ text sang object tốn nhiều chu kỳ CPU và sinh nhiều rác bộ nhớ.
- Kryo/Java Serialization: Tốt hơn JSON vì là định dạng nhị phân, nhưng cũng chứa nhiều metadata thừa thãi (tên class, cấu trúc phân cấp) và bắt buộc phải khởi tạo các Object thực sự trong bộ nhớ khi giải mã (deserialization).
- Protocol Buffers (Protobuf): Phổ biến ở các hệ thống microservices (dùng gRPC). Cơ chế của thằng này có kỹ mã hoá khá gọn gàng (variant), nhưng việc tính toán để phục vụ cơ chế đó cũng khiến CPU phải làm việc nhiều hơn. Hơn nữa, quá trình decode Protobuf vẫn tạo ra các đối tượng mới trong bộ nhớ.
- FlatBuffers: Đây là đối thủ gần nhất của SBE. Cả 2 đều hỗ trợ đọc trực tiếp mà không cần giải mã (Zero-Copy). Tuy nhiên FlatBuffers hướng tới sự linh hoạt với các cấu trúc cây phức tạp, trong khi SBE ép mình vào một luồng đọc các byte tuần tự. Chính sự cứng nhắc sẽ là chìa khoá để SBE đạt được tốc độ tối đa nhờ tối ưu bộ nhớ đệm của phần cứng.
1.2. Triết lý Mechanical Sympathy - thấu hiểu phần cứng
Khi muốn viết code chạm tới một tốc độ xử lý nhất định, chúng ta sẽ phải hiểu CPU hoạt động thế nào. Martin Thompson (cha để của Lmax Disruptor mà chúng ta vừa tìm hiểu ở bài trước, Aeron, và cũng là người đóng góp nhiều cho SBE) đã phổ biến thuật ngữ Mechanical Sympathy - viết phần mềm đồng điệu với cách hoạt động của phần cứng.
CPU không phải lúc nào cũng sẽ chọc xuống RAM lấy dữ liệu. Ở mức độ này, đây được coi là một thao tác cực kỳ chậm. Để giải quyết, CPU có các tầng cache L1, L2, l3. Thiết kế của phần cứng khiến nó giỏi trong việc đọc dữ liệu một cách tuần tự. Và khi đọc byte đầu tiên, phần cứng sẽ tự động đoán và kéo luôn 1 vùng byte nằm gần nó (cache line) từ RAM lên cache. Và SBE sẽ tận dụng cơ chế này để thiết kế thân thiện với CPU cache bằng mấy cách:
- Đóng gói toàn bộ message thành một dải byte liên tục duy nhất trong memory
- Buộc mình phải đọc và ghi thông điệp theo một trình tự tiến lên duy nhất, từ đầu đến cuối
- Không có các con trỏ nhảy lung tung trong vùng nhớ - thứ vốn gây ra hiện tượng Cache miss cho CPU.
1.3. Đặc điểm Zero-Copy và Zero-Allocation (GC-Free)
Một kẻ thù ẩn của latency trong các hệ thống high frequency như hệ thống chứng khoán viết bằng Java hay C#, là Garbage Collector Pauses (GC-Pause) - những khoảnh khắc hệ thống phải khựng lại để dọn dẹp bộ nhớ.
Zero-Allocation (Không cấp phát mới): SBE không sử dụng các POJO class (kiểu Order.java xong khởi tạo với new Order()). Thay vào đó dùng Flyweight Pattern. Này có thể ví như 1 cái máy quét mã vạch, SBE Decoder Object được tạo ra 1 lần duy nhất khi hệ thống khởi động. Và khi có 1 bản tin mới nhận được qua mạng, chúng ta chỉ cần đặt cái máy quét này lên vị trí bắt đầu của mảng byte, nó sẽ tự động đọc dữ liệu bên dưới mà không hề tạo ra 1 Object mới nào.
Zero-Copy (Không sao chép): Đọc thẳng các con số nguyên, sô thực từ mạng (network buffer) của hệ điều hành mà không copy các byte đó ra một vùng nhớ trung gian nào khác.
Ghi nhớ: Không tạo Object -> Không có rác -> Garbage Collector không bao giờ phải chạy -> Độ trễ của hệ thống luôn ổn định ở mức thấp.
1.4. Một số thuật ngữ cần nhớ trước khi đi tiếp
Để ép buộc việc đọc dữ liệu tuần tự và Zero-Copy, SBE quy định cấu trúc bản tin cực kỳ nghiêm ngặt, và luôn đi theo 1 chiều duy nhất.
- Message header (tiêu đề): luôn nằm ở đầu mảng byte, là thứ để hệ thống biết chuẩn bị đọc cái gì.
- Block length: Kích thước của phần dữ liệu cố định ngay phía sau.
- TemplateId: Định danh loại bản tin. Ví dụ ID 119 là lệnh Mua, ID 120 là huỷ lệnh,...
- SchemaID và Version: Giúp hệ thống biết bản tin này đang dùng schema nào, phiên bản nào. Điều này rất quan trọng khi nâng cấp hệ thống, ví dụ kiểu phiên bản mới thêm trường dữ liệu.
- Root fields: Phần dữ liệu cố định, chứa các dữ liệu có độ dài không đổi (như long chưa Price, int chứa Quantity, enum chứa OrderType,...). Do độ dài cố định, SBE có thể tính toán vị trí chính xác (offset) của các trường này để truy xuất tức thì ngay khi đặt Flyweight lên.
- Repeating Groups (Dữ liệu lặp): Tương đương với mảng (arrays) hoặc danh sách (lists). Ví dụ một bản tin cập nhật lên bảng giá có 5 mức giá cho 5 mã khác nhau. Độ dài nhóm này thay đổi tuỳ số lượng phần tử, và phải nằm sau phần dữ liệu cố định. Mỗi phần tử trong group cũng có cấu trúc cứng như Block Length.
- Variable Data (Dữ liệu có độ dài thay đổi): Thường là chuỗi ký tự (String) hoặc mảng byte tự do. Ví dụ như ghi chú của lệnh. Vì kích thước của nó hoàn toàn bất định, SBE buộc phải để phần này ở cuối cùng của bản tin. Nếu để ở đầu, toàn bộ việc tính offset cho các trường khác chắc chắn sẽ khó khăn và nhiều nguy cơ bị lệch.
Việc sắp xếp này là một biểu hiện rõ ràng của Mechanical Sympathy: đọc header -> đọc các block -> đọc các group -> đọc các chuỗi variable. Hướng đi tiến lên liên tục, để tận dụng sức mạnh của CPU cache.
2. Thiết kế Schema (SBE XML Configuration)
2.1. Cấu trúc một file Schema hợp lệ
Trong hệ sinh thái SBE, toàn bộ cấu trúc dữ liệu truyền tải được định nghĩa trong một file XML duy nhất (schema). Nút gốc luôn là thẻ <messageSchema>
Một số thuộc tính cốt lõi của messageSchema:
- package: namespace dùng khi sinh code
- id: định danh duy nhất cho toàn bộ schema, đã nhắc đến ở trên.
- version: phiên bản của schema, hỗ trợ việc tương thích khi hệ thống nâng cấp version
- byteOrder: cấu hình quan trọg nhất liên quan đến hiệu suất phần cứng, nhận giá trị littleEndian hoặc bigEndian.
Littel Endian với Big Endian
Kiến trúc CPU dùng trong các hệ thống giao dịch tần suất cao (intel x86, x64, arm, amd64) lưu trữ số nguyên trong bộ nhớ theo chuẩn little endian (byte ít quan trọng nhất nằm ở địa chỉ ô nhớ thấp nhất) => việc sử dụng cấu hình này đảm bảo cho SBE không cần mất thêm thao tác đảo ngược byte ở cấp độ CPU khi thực hiện việc đọc dữ liệu từ card mạng lên RAM. Dữ liệu trên đường truyền hoàn toàn khớp với dữ liệu lưu trữ trên RAM.
Ví dụ khai báo thẻ messageSchema:
<messageSchema package="com.trading.sbe"
id="1"
version="1"
semanticVersion="5.2"
description="Core Trading Schema"
byteOrder="littleEndian">
<!-- Định nghĩa các kiểu dữ liệu và message tại đây -->
</messageSchema>2.2. Các data types
SBE yêu cầu khai báo kiểu dữ liệu đến mức bộ nhớ ở từng byte, cụ thể có một số kiểu sau:
Types (Kiểu nguyên thuỷ)
Các kiểu dữ liệu nền tảng phản ánh chính xác các kiểu số học của phần cứng. Khai báo thông qua thẻ <type>
Ví dụ: uint8, int8, uint16, int16, uint32, int32, uint64, int64, float, double, char.
<type name="Price" primitiveType="uint64" description="Mức giá nhân với hệ số"/>
<type name="Quantity" primitiveType="uint32" />Composites (kiểu phức hợp)
Đóng gói nhiều <type> nguyên thuỷ thành một khối. Cấu trúc vật lý của nó sẽ là một chuỗi các byte nối tiếp nhau.
<composite name="MessageHeader">
<type name="blockLength" primitiveType="uint16"/>
<type name="templateId" primitiveType="uint16"/>
<type name="schemaId" primitiveType="uint16"/>
<type name="version" primitiveType="uint16"/>
</composite>
<composite name="GroupSizeEncoding">
<type name="blockLength" primitiveType="uint16" description="Kích thước 1 phần tử"/>
<type name="numInGroup" primitiveType="uint8" description="Số lượng phần tử"/>
</composite>Enums (các hằng số)
Dùng truyền tải các hằng số với kích thước byte tối thiểu. Thay vì gửi một chuỗi các văn bản như RED, GREEN, BLUE, enum mã hoá chúng thành các số nguyên nhỏ (uint8 hoặc char).
<enum name="Side" encodingType="uint8">
<validValue name="Buy">1</validValue>
<validValue name="Sell">2</validValue>
</enum>Sets (Bitsets)
Tối ưu hoá các boolean flag, thay vì mỗi cờ tốn 1 byte, hoặc 4 byte như trong Java, nhiều cờ được nhồi vào các bit cụ thể của 1 số nguyên đơn lẻ (ví dụ 8 cờ trong 1 biến uint8). Hệ thống giải mã sẽ sử dụng bit-masking để đọc cờ.
<set name="OrderFlags" encodingType="uint8">
<choice name="IsAllOrNone">0</choice> <!-- Bit 0 -->
<choice name="IsPostOnly">1</choice> <!-- Bit 1 -->
<choice name="IsHidden">2</choice> <!-- Bit 2 -->
</set>
2.3. Thiết kế message
Mỗi loại bản tin được khai báo bằng thẻ message. Cấu trúc bên trong phải tuân thủ nghiêm ngặt theo luồng đọc: root field (khối cố định) -> repeating field (khối chứa dữ liệu lặp) -> variable data (dữ liệu có độ dài có thể thay đổi).
Các field cố định, các dữ liệu cơ bản luôn được đặt ở đầu khối:
<message name="NewOrderSingle" id="99" description="Lệnh đặt mới">
<field name="OrderId" id="1" type="uint64"/>
<field name="Price" id="2" type="Price"/>
<field name="Quantity" id="3" type="Quantity"/>
<field name="Side" id="4" type="Side"/>
<field name="Flags" id="5" type="OrderFlags"/>
Sau đó đến khai báo phần dữ liệu lặp (repeating group). Yêu cầu bắt buộc là phải có dimensionType trỏ đến một Composite định nghĩa sẵn kích thước và số lượng, chẳng hạn như GroupSizeEncoding ở trên.
<group name="MarketDataEntries" id="6" dimensionType="GroupSizeEncoding">
<field name="LevelPrice" id="7" type="Price"/>
<field name="LevelSize" id="8" type="Quantity"/>
</group>Cuối cùng là sẽ đến khai báo variable length data, xử lý chuỗi văn bản (String) hoặc các mảng byte tự do (Raw data). Luôn phải đặt ở cuối các message. Cần có một composite định nghĩa độ dài chuỗi trước khi lưu các byte thực tế.
<composite name="VarDataEncoding">
<type name="length" primitiveType="uint16"/>
<type name="varData" primitiveType="uint8" length="0" characterEncoding="UTF-8"/>
</composite>
<data name="AccountName" id="9" type="VarDataEncoding"/>2.4. Alignment Padding và nguyên lý tối ưu bộ nhớ
Việc sắp xếp các thẻ <field> bên trong một message không được thực hiện ngẫu nhiên. Nó quyết định trực tiếp đến băng thông mạng bị lãng phí.
CPU đọc dữ liệu hiệu quả nhất khi các biến nằm vừa vặn trên các đường ranh giới word (ví dụ 8 byte cho hệ 64 bit). Vì thế, nếu một cấu trúc dữ liệu có kích thước hỗn loạn, trình biên dịch (hoặc SBE) sẽ tự động thêm các byte rỗng (padding) vào giữa các trường để đảm bảo trường tiếp theo nằm đúng ranh giới. Điều này làm phình to kích thước của bản tin một cách vô lý.
Nguyên tắc sắp xếp tối ưu để Zero Padding (không có padding rác): sắp xếp tất cả các field theo thứ tự kích thước byte giảm dần: 8 byte -> 4 byte -> 2 bytes -> 1 byte.
Ví dụ của việc sắp xếp các trường một cách tối ưu (không có padding rác):
<field name="FieldA" type="uint64"/> <!-- 8 bytes -->
<field name="FieldB" type="uint32"/> <!-- 4 bytes -->
<field name="FieldC" type="uint16"/> <!-- 2 bytes -->
<field name="FieldD" type="uint8"/> <!-- 1 byte -->
<field name="FieldE" type="uint8"/> <!-- 1 byte -->
<!-- Tổng: 16 bytes. Dữ liệu liền mạch. -->Ví dụ của việc sắp xếp gây ra tồn tại thêm padding một cách lãng phí:
<field name="FieldD" type="uint8"/> <!-- 1 byte -->
<!-- Mất 7 byte padding ở đây để chuẩn bị cho uint64 -->
<field name="FieldA" type="uint64"/> <!-- 8 bytes -->
<field name="FieldC" type="uint16"/> <!-- 2 bytes -->
<!-- Mất 2 byte padding ở đây để chuẩn bị cho uint32 -->
<field name="FieldB" type="uint32"/> <!-- 4 bytes -->
<!-- Tổng: 24 bytes (Dư thừa 8 bytes rác truyền qua mạng) -->3. SBE tooling và code generation
3.1. SBETool: Trình biên dịch ở thời điểm compile code
Một nguyên tắc cốt lõi của SBE: Không làm việc gì ở runtime nếu đã có thể làm ở compile time.
SbeTool là một chương trình độc lập viết bằng java, đóng vai trò như 1 trình biên dịch (compiler). Thay vì đọc file XML mỗi khi ứng dụng đang chạy như cách Spring thường làm, SbeTool được gọi trong quá trình build dự án (maven, gradle, cmake). Nó sẽ đọc các file xml, kiểm tra tính hợp lệ của việc sắp xếp bộ nhớ (padding), rồi tự động sinh các file code decoder, encoder,.. tương ứng (Java, C++, ...) và chèn trực tiếp vào thư mục dự án.
3.2. Sự phụ thuộc vào thư viện Argona
Những file code ở trên, mà SBETool sinh ra cho Java không thể chạy độc lập, mà phụ thuộc vào một thư viện thao tác bộ nhớ tên là Argona. Thư viện này cung cấp các interface cốt lõi để thao tác với các vùng nhớ ngoài (Off-heap memory).
DirectBuffer: Interface dùng để đọc dữ liệu (dùng để đọc cho Decoder)
MutableDirectBuffer: Interface dùng để ghi/sửa dữ liệu (dùng cho Encoder)
Khi SBE sinh ra các hàm nhận các tham số truyền vào, nó sẽ luôn yêu cầu truyền vào các object của Argona thay vì mảng byte thông thường của Java.
4. Kỹ thuật lập trình với Flyweight Pattern.
Có thể tóm tắt lại để hình dung được liền mạch: Phần 3 giải thích SBE dùng tool sinh ra các class gì ở compile time, phần 4 sẽ nói về cách ứng dụng thực sự dùng các class đó để xử lý byte.
4.1. Flyweight Pattern trong SBE
Trong lập trình hướng đối tượng truyền thống, khi nhận dữ liệu, chúng ta thường khởi tạo một object mới (new Order()), sau đó parse byte và set các thuộc tính vào (order.setQuantity()). Khi xử lý xong, các object này sẽ trở thành rác và chờ GC dọn dẹp. Tuy nhiên, như đã đề cập, trong các hệ thống trading hiệu năng cao, GC là một điểm yếu.
SBE giải quyết bài toán đó bằng Flyweight Pattern. Các class encoder và decoder mà SBE Tool sinh ra không có bất kỳ trường dữ liệu của bản tin. Nó sẽ như các khung kính đánh dấu các toạ độ ở trên, thay vì đổ dữ liệu vào 1 object mới, chỉ cần cầm cái khung kính này, tính toán toạ độ và thao tác trực tiếp với các byte bên dưới.
Để làm được điều này, thì Argona, thư viện mà chúng ta vừa nhắc đến ở phần trên, sử dụng một class là UnsafeBuffer. Nó cho phép đọc ghi các byte ở cấp độ phần cứng (thông qua sun.misc.Unsafe trong java), vượt qua nhiều rào cản ranh giới thông thường để đạt tốc độ tối đa.
4.2. Quản lý bộ nhớ đệm Buffer - chìa khoá của Zero-Allocation
Vì Encoder và Decoder không giữ các dữ liệu, bài toán quản lý vòng đời chuyển qua buffer.
Trong các hệ thống thực tế, thường ưu tiên sử dụng bộ nhớ ngoài heap (Off-heap) thông qua ByteBuffer.allocateDirect(...). Buffer này tương tác trực tiếp với I/O của hệ điều (ví dụ như card mạng) mà không cần copy ngược data vào không gian của JVM. Và tất nhiên việc khởi tạo Buffer là thao tác rất tốn kém, do đó, nguyên tắc vẫn là cấp phát sẵn một lần duy nhất lúc khởi động hệ thống.
Khi ứng dụng chạy, chúng ta tạo sẵn một mảng các DirectByteBuffer, hoặc lấy từ RingBuffer chẳng hạn. Sau đó tạo sẵn duy nhất một instance của MessageHeaderEncoder, MessageEncoder, và các Decoder tương ứng cho mỗi thread. Sau đó, khi có luồng dữ liệu mới, chỉ cần gán bộ đệm (wrap buffer) vào các instance Encoder/Decoder đã có sẵn. Hàng triệu bản tin sẽ chỉ đi qua chung ngần nấy object => gần như không có rác sinh ra.
4.3. Kỹ thuật encoding ghi dữ liệu
Việc ghi dữ liệu trong SBE có thể hiểu nôm na là phải liên tục tiến lên phía trước, không được phép quay đầu :v
Code Java ví dụ các bước khởi tạo:
// Lấy một buffer trống từ pool
UnsafeBuffer buffer = new UnsafeBuffer(ByteBuffer.allocateDirect(1024));
int startOffset = 0;
MessageHeaderEncoder headerEncoder = new MessageHeaderEncoder();
NewOrderSingleEncoder orderEncoder = new NewOrderSingleEncoder();
// 1. Wrap và ghi Header trước
headerEncoder.wrap(buffer, startOffset)
.blockLength(orderEncoder.sbeBlockLength())
.templateId(orderEncoder.sbeTemplateId())
.schemaId(orderEncoder.sbeSchemaId())
.version(orderEncoder.sbeSchemaVersion());
// 2. Wrap Encoder vào buffer, nằm ngay sau Header
orderEncoder.wrap(buffer, startOffset + headerEncoder.encodedLength());Quy tắc bất di bất dịch: Chúng ta buộc phải gọi các hàm ghi theo đúng trình tự khai báo trong file XML: Root Fields -> Repeating Group -> Variable length data. Lý do là vì SBE duy trì một con trỏ nội bộ gọi là limit, mỗi khi ghi thêm một trường, limit sẽ tiến lên. Chẳng hạn nếu chúng ta ghi variable data, là cái thằng có độ dài động lên trước, limit sẽ nhảy 1 đoạn không thể xác định -> sau đó quay lại ghi root fields, sẽ ghi đè lên những byte sai lệch -> hỏng dữ liệu.
4.4. Kỹ thuật decoding đọc dữ liệu
Khi nhận một byte từ mạng, chúng ta sẽ chưa thể biết nội dung bên trong là gì. Header chính là thứ giúp để phân luồng.
Lưu ý quan trọng là decoding là chúng ta vẫn cần phải đọc dữ liệu tuần tự. Không thể nhảy cóc đọc String ở cuối, rồi lại vòn lên đọc Root Field ở đầu. Bởi cơ chế tính toán offset tự động của Repeating Groups và Variable Data ép buộc con trỏ limit chỉ được phép tiến lên.
// Nhận buffer từ mạng
UnsafeBuffer buffer = receiveFromNetwork();
int offset = 0;
MessageHeaderDecoder headerDecoder = new MessageHeaderDecoder();
headerDecoder.wrap(buffer, offset);
// 1. Đọc Header để biết đây là loại bản tin gì
int templateId = headerDecoder.templateId();
int actingBlockLength = headerDecoder.blockLength();
int actingVersion = headerDecoder.version();
offset += headerDecoder.encodedLength(); // Trượt con trỏ qua phần Header
// 2. Rẽ nhánh xử lý dựa trên templateId
switch (templateId) {
case NewOrderSingleDecoder.TEMPLATE_ID:
NewOrderSingleDecoder orderDecoder = new NewOrderSingleDecoder();
// Cung cấp actingBlockLength và version để hỗ trợ tương thích ngược (Schema Evolution)
orderDecoder.wrap(buffer, offset, actingBlockLength, actingVersion);
// Bắt buộc đọc theo thứ tự: Root -> Group -> VarData
long price = orderDecoder.price();
int qty = orderDecoder.quantity();
String account = orderDecoder.accountName();
break;
case CancelOrderDecoder.TEMPLATE_ID:
// Xử lý bản tin hủy lệnh...
break;
}
5. Quản lý phiên bản và nâng cấp Schema
Mục tiêu là đảm bảo khi Schema thay đổi, các phiên bản phần mềm cũ và mới vẫn phải giao tiếp được với nhau một cách an toàn mà không bị crash hay đọc lệch dữ liệu.
5.1. Sự khác biệt giữa Schema Version và Acting Version
Trong các hệ thống phân tán, microservices,... thường khó để lúc nào cũng có thể cập nhật tất cả thành phần lên cùng một phiên bản thư viện trong một thời gian ngắn. Vì thế SBE có một cơ chế quản lý phiên bản khá chặt chẽ ngay tại thời điểm runtime thông qua hai khái niệm cốt lõi.
- Schema Version (phiên bản tĩnh) là phiên bản của file XML tại thời điểm dùng SBE tool sinh ra mã nguồn. Các class Encoder/Decoder được biên dịch sẽ hardcode version này bên trong (thường là sbeSchemaVersion)
- Acting Version (phiên bản động) là phiên bản thực tế của luồng byte (bản tin) đang đi trên đường truyền mạng. Khi decoder đọc MessageHeader, nó sẽ trích xuất ra trường version và sử dụng con số này làm version cho toàn bộ quá trình giải mã phía sau.
Việc só sánh Schema Version và Acting Version (so sánh phiên bản của code hiện tại với phiên bản thực tế dữ liệu đang nhận được) chính là chìa khoá để tiến hoá schema.
5.2. Cách nâng cấp version an toàn
Quy tắc quan trọng trong việc mở rộng bản tin SBE là chỉ được phép thêm trường mới vào cuối một block length.
Khi khai báo trong XML, các trường mới bắt buộc phải đi kèm thuộc tính sinceVersion để đánh dấu phiên bản mà chúng bắt đầu xuất hiện.
<message name="NewOrderSingle" id="99" description="Lệnh đặt mới">
<!-- Block Length ban đầu (Version 1): 16 bytes -->
<field name="OrderId" id="1" type="uint64"/> <!-- 8 bytes -->
<field name="Price" id="2" type="uint64"/> <!-- 8 bytes -->
<!-- Trường mới thêm vào ở Version 2 -->
<field name="StopPrice" id="3" type="uint64" sinceVersion="2"/> <!-- 8 bytes -->
</message>
Nhờ việc luôn thêm vào cuối, offset của các trường cũ sẽ không bao giờ bị thay đổi, cấu trúc bộ nhớ cũ luôn được đảm bảo tuyệt đối. Từ nguyên tắc này, SBE có các cách xử lý các kịch bản lệch phiên bản như sau:
Decoder cũ đọc dữ liệu của Encoder mới (Forward Compatibility)
Ví dụ: Server bắn event đã nâng cấp lên version mới (như cái trên là ver 2, thêm cái StopPrice), nhưng client vẫn đang chạy code version cũ.
Khi Client đọc MessageHeader, nó sẽ nhận thấy blockLength thực tế từ mạng (24 bytes) lớn hơn sbeBlockLength mà nó biết lúc compile. Nhưng client vẫn tự tin dùng offset tĩnh để đọc OrderId và Price một cách chính xác trong 16 bytes đầu.
Sau khi đọc xong phần Root Fields, Client không biết StopPrice là gì. tuy nhiên nó sẽ dùng chính blockLength (24 bytes) đọc từ Header để đẩy con trỏ limit nhảy vọt qua 8 bytes thừa đó. Nhờ vào việc nhảy vọt này, con trỏ sẽ dừng ở ngay vị trí bắt đầu của Repeating Groups hoặc Variable Data phía sau. Quá trình đọc tiếp tục diễn ra trơn tru mà không văng lỗi.
Decoder mới đọc dữ liệu của Encoder cũ (Backward Compatibility)
Ví dụ: Client đã thêm mới đọc thêm trường StopPrice (ver 2), nhưng Server vẫn đang dùng cơ chế cũ gửi bản tin phiên bản trước, không có StopPrice
Khi client đọc messageHeader và nhận thấy actingVersion bé hơn version hiện tại của nó, clien vẫn sẽ đọc OrderId và Price bình thường.
Khi logic code ở decoder client gọi hàm get StopPrice(), class decoder này sẽ tự động kiểm tra kiểm tra ngầm actingVersion < version hiện tại của nó -> sẽ không cố tiếp tục đọc bộ nhớ để tránh OutOfbounds hoặc đọc dữ liệu rác. Thay vào đó nó sẽ trực tiếp gán null hoặc giá trị mặc định của kiểu dữ liệu tương ứng luôn.
5.3. Những thao tác nên tránh trong SBE.
Ví SBE đánh đổi sự linh hoạt như của JSON, XML để lấy hiệu năng bộ nhớ một cách tuyệt đối, bằng cách các offset được tính toán tĩnh ngay lúc compile, do đó bất kỳ sự thay đổi nào làm xô lệch offset đều dẫn đến hậu quả nghiêm trọng - memory corruption (đọc ghi lệch dữ liệu). Vì thế, có những nguyên tắc chúng ta cần ghi nhớ:
- Không xoá một trường đã tồn tại. Sẽ khiến offset của tất cả các trường đằng sau bị giật lùi lên trên. Decoder phiên bản cũ trước đó sẽ đọc lệch tất cả dữ liệu phía sau. Nếu một trường không dùng nữa, có thể đánh deprecated.
- Không chèn trường mới vào giữa block length. Tương tự như việc xoá một trường đã tồn tại, cũng sẽ phá vỡ cấu trúc offset vốn có của nó.
- Thay đổi kiểu dữ liệu (data type). Ví dụ đổi uint32 (4 bytes) thành uint64 (8bytes) không chỉ làm phình kích thước trường đó mà còn đẩy offset của trường phía sau đi 4 bytes.
- Thay đổi ID của thẻ <field> hoặc <message>. Vì SBE sử dụng id để phân tích cú pháp và nhận diện loại bản tin. Việc tái sử dụng ID cũ cho một trường mới sẽ khiến các hệ thống cũ route sai hoặc đọc sai ngữ nghĩa của trường.
6. Tổng kết lại vài điều cần nhớ, vì sao SBE lại tối ưu được như vậy.
6.1. Tối ưu hoá bằng cách đẩy các bước tốn cost về compile time, thay vì thực hiện tại thời điểm run time.
SBE chuyển toàn bộ gánh nặng của việc parse object về giai đoạn build. File schema dưới định dạng XML, với cấu trúc alignment khắt khe để tránh padding rác được SBE chuyển thành các class encoder, decoder chứa sẵn các toạ độ - offset. Và khi ứng dụng chạy, CPU không phải tự đoán xem dữ liệu nằm ở đâu, mà chỉ việc chọc vào memory bằng các lệnh máy đơn giản.
6.2. Flyweight pattern, khung kính trượt trên mảng byte để đọc dữ liệu
SBE thay đổi cách nhìn của lập trình hướng đối tượng truyền thống. Không khởi tạo new Object() mới, nhờ thế không có rác bộ nhớ, không nặng việc cho Garbage Collection. Hệ thống chỉ dùng object Encoder, Decoder duy nhất để chọc thẳng vào buffer và đọc ghi trực tiếp (zero copy, zero allocation)
6.3. Kỷ luật trong việc đọc dữ liệu tuần tự, không quay đầu
Hiệu năng của SBE cũng được đóng góp phần lớn từ việc ép luồng xử lý phải đi theo một chiều duy nhất: Message Header -> Root Fields (vùng dữ liệu cố định) -> Repeating Group (vùng dữ liệu lặp) -> Variable lenght data (vùng dữ liệu tuỳ biến). Thiết kế này giúp CPU cache hoạt động tối ưu. Và đương nhiên, bất kỳ thao tác nhảy cóc hay ghi sai thứ tự nào cũng sẽ dẫn đến việc hỏng sự đúng đắn của vùng nhớ.
6.4. Tiến hoá schema bằng cách mở rộng nhưng không được phá vỡ
SBE giải quyết bài toán lệch version giữa thằng gửi và thằng nhận bằng cơ chế đối chiếu giữa version của dữ liệu đang được gửi và version ghi nhận ở thời điểm compile. Và nguyên tắc quan trọng là chỉ thêm vào, không sửa, xoá các trường cũ!!!
.
.
.
.
.
.
.
Type xong bài này, tất nhiên là có sự giúp đỡ của GPT 5.6 SOL, nhưng cũng mệt thật =Đ
Mình là Đức. Vẫn đang học, đang đi và đang ghi chép.
Đang tải lượt xem…
