
Chao xìn mọi người lại là mình đây Nhabachoc KAD.
Vì mình không có nhiều thời gian để có thể làm được một Blog hoàn chỉnh nên các bạn thông cảm nha. Hãy vào link sau để đọc được chi tiết bài viết vì ở dưới bị lỗi hình ảnh:
https://kadpkt.notion.site/C-u-tr-c-d-li-u-v-m-ho-ba7adb3fecc142b88e423cc7226bb1c2Bài viết này có dành cho bạn?
Bạn đọc các bài viết trên mạng nhưng không ai giải thích một cách rõ ràng về những cấu trúc và mã hoá dùng trong Ethereum
Hành trang cần chuẩn bị:
Đi qua chuỗi bài “Mã hoá cơ bản”
Cùng tắt đèn bật ý tưởng, gét go 🪄
RLP encoding
Trình tự hoá dữ liệu giúp biến dạng dữ liệu phức tap thành dạng byte để lưu trữ hoặc truyền đi. RLP là thuật toán mã hoá / giải mã giúp Ethereum serialize dữ liệu và đảo ngược lại chúng.
Định nghĩa:
Nếu đầu vào là một byte đơn trong phạm vi
[0x00, 0x7f]
, thì chính nó là RLP.Nếu đầu vào không phải là giá trị (uint(0), []byte{}, chuỗi(“”), con trỏ trống …), RLP là
0x80
.Nếu đầu vào là một byte đặc biệt trong phạm vi
[0x80, 0xff]
, RLP sẽ nối0x81
với byte[0x81, byte]
.Nếu đầu vào là một chuỗi có độ dài từ 2–55 byte, RLP bao gồm một byte đơn có giá trị
0x80
cộng với độ dài của chuỗi tính bằng byte và mảng giá trị hex của chuỗi. Dễ dàng thấy rằng byte đầu tiên nằm trong phạm vi[0x82, 0xb7]
VD: **“hello world” =
[**0x8b**, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x77, 0x6f, 0x72, 0x6c, 0x64]
“hello world” có độ dài 11 tính ra byte
0x0b
Byte đầu tiên =
0x80 + 0x0b = **0x8b**
Nếu đầu vào là một chuỗi dài hơn 55 byte, RLP bao gồm 3 phần từ trái sang phải:
Phần đầu tiên là một byte đơn có giá trị
0xb7
cộng với độ dài tính bằng byte của phần thứ hai. Dễ dàng nhận ra phạm vi của byte đầu[0xb8, 0xbf]
Phần thứ hai là giá trị hex của độ dài của chuỗi.
Phần cuối cùng là chuỗi tính bằng byte.
VD: **Một chuỗi có 1024 ký tự “a” ⇒ “aaa…” =
[**0xb9**, **0x04**, **0x00**, 0x61, 0x61, …]
Phần đầu**
0xb9 =** 0xb7 + 0x02
vì phần 2 có độ dài là 2Phần hai
0x04, 0x00
= ****0x0400
****là độ dài của “1024” - độ dài chuỗi ****Phần cuối
0x61
là “a”
Nếu đầu vào là một mảng trống, mã hóa RLP là một byte đơn
0xc0
.Nếu đầu vào là một mảng có độ dài phần tử từ 0–55 byte, RLP bao gồm một byte đơn có giá trị
0xc0
cộng với độ dài toàn bộ phần tử sau khi chuyển sang dạng hex và nối tất cả phần tử sau khi mã hoá RLP. Phạm vi của byte đầu tiên là[0xc1, 0xf7]
VD: **[“hello”, “world”] =
[0xcc, 0x85, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x85, 0x77, 0x6f, 0x72, 0x6c, 0x64]
.[0x85, 0x68, 0x65, 0x6c, 0x6c, 0x6f]
là mã hóa RLP của“hello”
[0x85, 0x77, 0x6f, 0x72, 0x6c, 0x64]
là mã hóa RLP của“world”
0xcc = 0xc0 + 0x0c
và0x0c = 0x06 + 0x06
độ dài các phần tử sau chuyển sang dạng hex.
Nếu đầu vào là một mảng có độ dài phần tử lớn hơn 55 byte, RLP bao gồm 3 phần:
Phần đầu là một byte đơn có giá trị
0xf7
cộng với độ dài tính bằng byte của phần thứ hai. Phạm vi của byte đầu tiên là[0xf8, 0xff]
Phần thứ hai là chiều dài của phần tử sau khi chuyển sang dạng hex.
Phần cuối cùng là nối mã hóa RLP của các mục trong danh sách.
Một điều nữa, nó không được đề cập trong wiki Ethereum nhưng trong mã nguồn Golang. Với kiểu boolean
true = 0x01
vàfalse = 0x80
.
Compact encoding - Hex Prefix encoding
Bạn nên đọc về Patricia trie xong và quay lại đây đọc hoặc mình sẽ link đến đây lúc cần kiến thức này.
"nibble" (tiếng Việt là "nửa byte") được sử dụng để mô tả mỗi ký tự của một địa chỉ Ethereum. Một địa chỉ Ethereum được biểu diễn bằng 40 ký tự hex, tương đương với 20 byte. Mỗi byte bao gồm 2 nibble.
⇒ 1 ký tự = 1 nibble
câu chuyện về mã HP này khá dài nên các bạn hứng thú thì có thể đọc, không muốn hiểu sâu chỉ cần biết định nghĩa sau là được. Ta nói rằng để phân biệt node leaf
và node extension
ta cần thêm vào trong 1 tiền tố như bảng sau:
loại node độ dài path | prefix hexchar
--------------------------------------------------
extension chẵn | 0000 0x0
extension lẻ | 0001 0x1
leaf chẵn | 0010 0x2
leaf lẻ | 0011 0x3
và thêm 1 nibble 0 vào các đường dẫn chẵn.
loại node độ dài path | prefix nibble hexchar
------------------------------------------------------------
extension chẵn | 0000 0000 0x00
extension lẻ | 0001 0x1
leaf chẵn | 0010 0000 0x20
leaf lẻ | 0011 0x3
Ok vậy là xong định nghĩa làm theo là được nhưng nếu bạn hỏi:
Vì sao lại phải phân chia chẵn lẻ trong các node ?
Vì sao phải thêm 1 nibble 0 vào node có đường dẫn chẵn ?
Các bạn hỏi rất đúng và mình cũng trằn trọc không ngủ mấy đêm để tìm được ra câu trả lời mình thấy hợp lý:
Giai đoạn 1: Ban đầu bạn sẽ chỉ đơn giản là muốn phân biệt 2 loại node và khi làm việc với máy tính ta tương với bit ta có 2 bit
0
và1
. Khi làm việc với trie với ethereum thì ta có đơn vịnibble = 1hex = 4bit
⇒ khi đó ta có bảng sau:loại node | prefix hexchar -------------------------------------------------- extension | 0000 0x0 leaf | 0001 0x1
Xét cụ thể
leaf
có 2 trường hợp:Đường dẫn chẵn:
36 fa
thì theo bảng trên ta thêm tiền tố1 36 fa
(mình viết cách ra vì cứ 2hex = 1byte và máy tính đọc theo byte nên cách ra cho dễ nhìn)Đường dẫn chẵn:
3 6f
thì theo bảng trên ta thêm tiền tố13 6f
Byte là đơn vị dữ liệu tiêu chuẩn được sử dụng để truyền dữ liệu giữa các thiết bị và mạng máy tính vì vậy khi bạn có 1 nibble
1
và 2 nibble01
nhưng khi được lưu trữ sẽ cùng lưu thành byte01
Chính xác là thế khi chúng ta lưu 2 đường dẫn bên trên vào DB ta có
01 36 fa
và13 6f
. Đây là giai đoạn mã hoá oki ổn nha nhưng giải mã 😀 ủa tui chịu lun á không giải mã được. Theo quy luật ta có: mã hoá thêm 1 nibble ⇒ giải mã bỏ 1 nibble nhưng vì lưu vào DB nó lưu thành byte nên nibble đầu tiên bây giờ lại là0
⇒ đường dẫn là1 36 fa
💥 toang luôn vì đường dẫn đùng là36 fa
.Nếu bạn bảo thêm
if(ký tự đầu = 01) { bỏ luôn 2nibble 01 } else { tiếp tục như bình thường }
vào đi để check trường hợp này vậy thì nếu mình có 1 node
extension
có đường dẫn1 36 fa
⇒ mã hoá HP xong sẽ được01 36 fa
. Nếuif else
xử lý được phần ở trên thì đến đây lại toang 💥 vì ở đây thực sự chỉ cần bỏ đi nibble đầu tiên0
thì sẽ thu được đường dẫn ban đầu.Giai đoạn 2: Hừm thực sự ta cần thêm 1 cái gì đó rồi. Ta cần thêm cờ để đánh dấu chẵn lẻ cho đường dẫn, trong nibble bít cuối thể hiện cho tính chẵn lẻ, bit tiếp theo thể hiện cho loại node:
loại node độ dài path | prefix hexchar -------------------------------------------------- extension chẵn | 0000 0x0 extension lẻ | 0001 0x1 leaf chẵn | 0010 0x2 leaf lẻ | 0011 0x3
và các bạn thử áp dụng lại vào bài toán ở trên thì oki, ngon lành cành đào, không trùng nhau phân biệt rõ ràng NHƯNG đấy là trong trường hợp mã hoá, đừng quên bài toán đẩy vào DB sẽ làm tròn lên byte ảnh hưởng đến phần giải mã. VD:
02 36 fa
sẽ được giải mã ra sao: nibble0
đầu tiên có thể là prefix của nodeextension
hoặc cùng có thể là do máy làm tròn lên byte của nodeleaf
. Nhờ vào tính độ dài sau khi thu được ta vẫn có thể biết chính xác nó là node leaf chẵn nhưng không phù hợp để làm thuật toán giải mã khi quá cồng kềnh cần nhiều phép tính.Giai đoạn 3: Ta cần thêm 1 chút thôi, chút gì đó … 💭. Thay vì để mặc số phận đời trôi nổi ta sẽ tự kiểm soát nó, thay vì để nó auto làm tròn byte ta sẽ thêm 1 nibble 0 vào sau nibble prefix vào trường hợp chẵn khi giải mã ta thu được mảng byte tròn trịa do ta kiểm soát và nhìn vào hex bạn có thể thấy ngay không còn sự cồng kềnh do cần bị trùng lặp nữa:
loại node độ dài path | prefix nibble hexchar ------------------------------------------------------------ extension chẵn | 0000 0000 0x00 extension lẻ | 0001 0x1 leaf chẵn | 0010 0000 0x20 leaf lẻ | 0011 0x3
Radix Tree - Trie
Là một cấu trúc dữ liệu còn được gọi là Patricia Tree - Radix Tree - Trie. Trie sử dụng một khóa làm đường dẫn để các nút có chung tiền tố cũng sẽ nằm chung trên một đường dẫn. Cấu trúc này nhanh nhất trong việc tìm, dễ thực hiện và yêu cầu bộ nhớ nhỏ. Do đó, nó thường được sử dụng để triển khai các bảng định tuyến, các hệ thống được sử dụng trong các máy có thông số kỹ thuật thấp như bộ định tuyến.
Không gì tốt hơn nếu có gì đấy để nhìn bằng mắt, chúng ta có ví dụ về các cặp key-value:
TOKEN: 1
TOW: 12
TRAN: 90
TRIE: 6
Ta sẽ vẽ lại theo cấu trúc Trie:
Đầu tiên ta thấy tất cả các cặp key đều có chung tiền tố T nên ta để T là nút cha
Sau đó ta thấy có 2 trường hợp O, R nên ta đặt làm 2 nút con của 2 nhánh OKEN, OW và RAN, RIE
Ta thấy O lại là tiền tố chung của OKEN trong TOKEN và OW trong TOW nên ta để O là nút cha
Tiếp tục thế cho đến hết
Hay theo dạng Radix Tree:
Chúng ta có một cấu trúc nhìn gọn gàng hơn, giảm được các node trung gian không chứa giá trị gì.
Nhưng trong lập trình sẽ phải lập trình theo mảng: [vt0, vt1, vt3, …, {giá trị}]. vt0 là vị trí 0 mà vì sao lại là vị trí thì các bạn theo dõi sau đây:
Các bạn chuyển key từ tring sang hex (giảm chiều dài mảng chứa key):
TOKEN ⇒ 746F6B656E
TOW ⇒ 746F77
TRAN ⇒ 7472616E
TRIE ⇒ 74726965
Các bước thực hiện:
Ta áp dụng Trie đầu tiên thấy 7 chung ta đặt làm node cha nhưng hay suy nghĩ lại tý. Trong lập trình nếu ta đơn giản là ghi 7 → 4 ai biết được trỏ là gì vậy nên 7 hay 4 hay các ký tự từ 0→9 và a→f phải là vị trí, vị trí đấy chứa con trỏ hay chứa địa chỉ của ô nhớ kế tiếp. 😐 hơi rắc rối nhỉ, cố ngẫm nhá hoặc do mình nói ngu 😅. Chốt lại node cha đầu tiên ở vị trí thứ 7 sẽ chứa con trỏ đến node tiếp theo.
Tiếp tục 4 chung ở mọi chuỗi nên trong node này chọn vị trí thứ 4 chứa con trỏ node tiếp theo
Cứ tiếp tục như vậy ta được như hình vẽ
Lưu ý:
Dãy mình ghi số 0-9, a-f để cho các bạn dễ nhìn đc vị trí nhưng thực tế thì:
[null, null, null, null, null, null, null, <Con trỏ đến node tiếp>, null, null, null, null, null, null, null, null, {giá trị}] nếu không có giá trị thì = null
Ưu điểm:
Sau đây là những lợi thế chính của trie so với cây nhị phân tìm kiếm:
Thời gian tìm kiếm nhỏ hơn. Thao tác tìm kiếm một khóa độ dài m đòi hỏi O(m) phép so sánh ký tự. Một cây nhị phân tìm kiếm sử dụng O(log_n) phép so sánh xâu (n là số lượng khóa). Trong trường hợp xấu nhất, cây nhị phân tìm kiếm cần dùng O(m*log_n) phép so sánh ký tự.
Trie sử dụng ít bộ nhớ hơn bởi các tiền tố chung chỉ cần được lưu trữ một lần
Số lượng nút từ gốc tới lá đúng bằng độ dài của khóa.
Tóm lại là tím kiếm nhanh hơn và sử dụng ít bộ nhớ hơn với xâu ký tự
Merkle Tree
Cây Merkle là một cây băm. Các nút lá lưu trữ dữ liệu. Các nút cha chứa giá trị băm của con chúng và quá trình này được lặp lại đến khi chỉ còn lại một giá trị băm duy nhất, ta gọi đó là RootHash. RootHash tóm tắt tất cả dữ liệu trong các giao dịch liên quan, nó duy trì tính toàn vẹn của dữ liệu. Nếu một chi tiết duy nhất trong bất kỳ giao dịch hoặc thứ tự giao dịch nào thay đổi, thì MerkleRoot hay RootHash cũng sẽ thay đổi. Sử dụng cây Merkle cho phép kiểm tra nhanh chóng và đơn giản xem liệu một giao dịch cụ thể có được bao gồm trong tập hợp hay không:
Transaction A <TxA> có Hash A
…
Transaction D <TxD> có Hash D
⇒ Bắt đầu gộp các 2 node lại thành 1 node mới ta được:
Hash AB = hash(Hash A + Hash B)
Hash CD = hash(Hash C + Hash D)
Và khi còn lại 2 node cuối cùng ta được:
HashRoot = hash(Hash AB + hash CD)
Ưu điểm:
Cây Merkle cung cấp một phương tiện để chứng minh tính toàn vẹn và hợp lệ của dữ liệu
Cây Merkle đòi hỏi ít bộ nhớ hoặc dung lượng ổ đĩa vì các bằng chứng được tính toán dễ dàng và nhanh chóng
Bằng chứng và quản lý của nó chỉ yêu cầu một lượng nhỏ thông tin được truyền qua mạng
Merkle Patricia Trie
Patricia trie là trie chính được sử dụng trong Ethereum để lưu trữ dữ liệu. Nó là sự kết hợp của Radix trie và Merkle tree.
// đây sẽ dữ liệu muốn lưu theo dạng key-value
{
{ '546F' : '1' },
{ '556' : '12' },
{ '9AA' : '90' },
{ '8FA2' : '6' }
}
Đây là cấu trúc của node - đây sẽ là thứ được lưu vào DB cũng theo dạng key-value:
Một node trong Ethereum được lưu trữ dưới dạng key-value với key là hàm băm của nút. Value là một mảng có 17 phần tử: 16 phần tử đầu tiên là số hex từ 0
đến f
và phần tử cuối cùng là dữ liệu chứa trong node đó.
Key của node để “tra cứu trong cơ sở dữ liệu” và Path <key của dữ liệu muốn lưu. Vd: address> được sử dụng để “tra cứu trong trie”.
Patricia Trie cơ bản
Đây là sẽ là thứ được lưu trên DB và nếu bạn muốn tìm kiếm dữ liệu 546F
thì mình sẽ nói từng bước:
Nhìn vào dữ liệu cần tìm
546F
ta tính được Path là5
,4
,6
,F
Tìm kiếm trong node đầu tiên <root Hash> vị trí đầu tiên trong Path là
5
mang giá trị HashATìm đến Node có key = HashA rồi tìm tiếp vị trí tiếp theo trong Path là
4
mang giá trị HashBTìm đến Node có key = HashB rồi tìm tiếp vị trí tiếp theo trong Path là
6
mang giá trị HashCTìm đến Node có key = HashC rồi tìm tiếp vị trí tiếp theo trong Path là
F
mang giá trị HashDTìm đến Node có key = HashD và ta cũng đã tìm hết các vị trí trong Path, nhìn vào data ta thấy kết quả = 1. Vậy đây chính là giá trị tương ứng cần tìm
Vậy các bạn đã hiểu câu trên
Key của node để “tra cứu trong cơ sở dữ liệu” và Path được sử dụng để “tra cứu trong trie”.
Bạn có thể thấy rằng, mình đã sử dụng Path để tìm giá trị (một thuộc tính của Radix trie) và nếu một giá trị trong trie bị thay đổi, nó sẽ khiến rootHash
thay đổi (một thuộc tính của Merkle tree). Hơn nữa, trie có rất nhiều node có giá trị null trong phần tử dữ liệu và mình phải cải thiện nó để hiệu quả hơn.
Cấu trúc đầu tiên mình sẽ để các đường dẫn cho dễ hình dung nhưng ở dưới các bạn tự hiểu ra các đường dẫn nhé vì nhìn khá là rối.
Patricia Trie cải tiến
Cây trên có 2 vấn đề chúng ta cần xem xét:
8FA2
để tìm được giá trị6
không bị phân tách mà thẳng một đườngHay ở 1 trường hợp khác
{caption, capte}
ta thấy có đoạncap
chung và đoạncap
cũng không bị phân tách ở giữa
⇒ lãng phí bộ nhớ cho các node và các giá trị null
👉 Để giải quyết vấn đề đầu tiên Ethereum đã giới thiệu leaf
- nút lá và vấn đề thứ hai dùng extension
- node mở rộng. leaf
và extension
đều là các node dạng mảng có 2 phần tử:
Phần tử đầu là
partialPath
giúp giảm giá trị rỗngPhần tử thứ 2:
Nếu là
leaf
: chứa giá trị dataNếu là
extension
: chứa giá trị merkleHash
Sau khi áp dụng đã gọn hơn rất nhiều rồi phải không. À thì thực ra ví dụ này của mình hơi tệ nhỉ vì nó không có đủ trường hợp, để mình lấy 1 ví dụ khác:
// đây sẽ dữ liệu muốn lưu theo dạng key-value
{
{ 'cab8' : '1' },
{ 'cab' : '12' },
{ '39' : '90' },
{ '395' : '6' },
{ '56f0' : '15' }
}
Ta được cấu trúc chưa tối ưu:
Sau khi tối ưu:
Đây cũng chưa phải bước hoàn thiện. Mình và các bạn sẽ đi đến bước cuối cùng để hoàn thiện và nhận về Patricia trie mà Ethereum sử dụng:
Một số quy tắc bổ sung:
Các node
leaf
hoặcextension
thìpartialPath
được mã hóa HPMọi phần tử trong một node sẽ được mã hóa RLP
Value sẽ được mã hóa RLP.
Giờ thì quay lại phần Hex Prefix encoding để hiểu được hết ý nghĩa. Thành quả là: