সরাসরি পর্বে যান
পর্ব ১২ · ৬৬-এর মধ্যে · মডিউল ২

Data format আর serialization — JSON, Protobuf, Avro আর schema evolution

Data Formats & Serialization — JSON, Protocol Buffers, Avro & Schema Evolution

Taught in Bangla, this lesson treats serialization as a design decision affecting bytes, CPU and mixed-version deployments. It compares JSON, XML and CSV, shows JSON pitfalls with big integers, Bangla UTF-8 text and Base64, hand-encodes Protobuf varints and field keys, resolves Avro writer and reader schemas, sizes one record in five encodings, and explains backward, forward and full compatibility, schema registries, API versions and database migrations, with a runnable schema checker.

এই পর্বে যা শিখবেন

  • Serialization কেন নকশার সিদ্ধান্ত — network-এর byte, CPU, storage আর একসাথে চলা পুরোনো-নতুন কোড
  • JSON, XML আর CSV-এর শক্তি-দুর্বলতা; বড় সংখ্যা, বাংলা লেখা আর Base64-এর হিসাব
  • Protobuf-এর wire format হাতে encode করা, আর Avro-র writer-reader schema মেলানো
  • Backward, forward আর full compatibility; কোন পরিবর্তন নিরাপদ, কোনটি ভাঙে
  • Schema registry, API version আর database migration-এ এই নিয়মগুলোর ব্যবহার

১ · Serialization কী, আর নকশায় এর দাম কোথায়

একটি mobile banking সেবার (কাল্পনিক) গল্প দিয়ে শুরু করি। লেনদেন হলেই payment service একটি বার্তা পাঠায় notification service-কে, আর সেটি গ্রাহককে জানায় “আপনার অ্যাকাউন্টে টাকা জমা হয়েছে”। এক বিকেলে বার্তার amount field-টি টাকা থেকে পয়সায় বদলে নতুন সংস্করণ চালু হলো; deploy চলল দশ মিনিট ধরে, সার্ভার একটি একটি করে বদলাল। ওই দশ মিনিটে কিছু গ্রাহক পেলেন একশো গুণ বড় অঙ্কের বার্তা। Network ঠিক, database-ও ঠিক — গোলমাল একটিই: দুই সংস্করণের কোড একই byte-এর দুই রকম অর্থ করছিল। এই পর্বের বিষয় ঠিক এটাই।

প্রোগ্রাম চলার সময় ডেটা থাকে memory-তে, object আর pointer হয়ে। একটি Customer object-এর নাম হয়তো memory-র অন্য কোনো ঠিকানায় রাখা — সেই ঠিকানার অর্থ শুধু ওই process-এর ভেতরে। অন্য মেশিনে পাঠাতে বা disk-এ রাখতে হলে ডেটাকে একটি স্বয়ংসম্পূর্ণ byte-এর সারিতে বদলাতে হয়। একে বলে serialization (encoding বা marshalling নামেও চলে); উল্টো কাজটির নাম deserialization, যাকে decoding বা parsing-ও বলা হয়। Byte-গুলো কোন ক্রমে, কোন নিয়মে সাজানো — সেটাই data format, আর ডেটার কাঠামোর লিখিত চুক্তিকে বলে schema.

ফার্নিচারের দোকান থেকে একটি খাট অন্য শহরে পাঠানোর কথা ভাবুন। জোড়া লাগানো খাট ট্রাকে জায়গা নষ্ট করে, তাই খুলে প্রতিটি টুকরোয় নম্বর লেখা হয় — ১ নম্বর পায়া, ২ নম্বর তক্তা — আর সাথে যায় জোড়া লাগানোর নির্দেশিকা। খোলা আর বাঁধা হলো serialization, টুকরোর নম্বর field number, নির্দেশিকা schema. নতুন মডেলে ৭ নম্বর একটি বাড়তি তাক এলো, কিন্তু মিস্ত্রির হাতে পুরোনো নির্দেশিকা — বুদ্ধিমান মিস্ত্রি অচেনা টুকরোটি ফেলে না দিয়ে পাশে সরিয়ে রাখবেন। কিন্তু কারখানা যদি পুরোনো ৩ নম্বরটি এবার অন্য টুকরোকে দেয়, মিস্ত্রি সেটিকেই পায়া ভেবে লাগাবেন, আর খাট বাঁকা হবে। এই পর্বের প্রায় সব নিয়ম এই দুই দৃশ্যেই আছে।

System design-এ serialization প্রায় প্রতিটি তীরের ভেতরে লুকিয়ে থাকে: API-র request ও response (পর্ব ৮, পর্ব ৯), message queue-র বার্তা (পর্ব ৩১), database-এর ভেতরের সারি (পর্ব ১৯), cache-এ রাখা মান (পর্ব ২৮), backup আর log ফাইল। Format বাছাইয়ের চারটি দাম:

Network-এর byte

বড় payload মানে বেশি bandwidth আর ধীর mobile network-এ বেশি latency; লাখো বার্তায় কয়েক byte-ও বড় অঙ্ক।

পর্ব ৩-এর হিসাব

CPU

প্রতিটি বার্তা একবার encode, একবার বা বহুবার decode হয়। অনেক সার্ভিসে CPU-র উল্লেখযোগ্য অংশ যায় শুধু parsing-এ।

encode + decode

Storage

Event আর log বছরের পর বছর জমে। Record-প্রতি আকার × কোটি record × কপির সংখ্যা = disk-এর বিল।

আকার × সময়

Compatibility

কোড বদলায় প্রতি সপ্তাহে, ডেটা থাকে বছরের পর বছর; deploy-এর সময় পুরোনো আর নতুন কোডকে একে অপরের byte বুঝতে হয়।

সবচেয়ে বড় দাম
মূল ধারণা

Format বাছাইয়ের আসল প্রশ্ন “কোনটি সবচেয়ে ছোট” নয়, বরং “কাল যখন কাঠামো বদলাব, তখন আজকের ডেটা আর আজকের কোড কী হবে?” একটি পরিচিত কথা আছে — data outlives code: আজ লেখা ডেটা আজকের কোডের চেয়ে অনেক বেশি দিন বাঁচে।

ভাষার নিজস্ব serialization থেকে সাবধান। Java-র Serializable বা Python-এর pickle এক ভাষার ভেতরে আরামদায়ক, কিন্তু অন্য ভাষা তা পড়তে পারে না, আর অবিশ্বস্ত উৎসের এমন ডেটা পড়া গুরুতর নিরাপত্তা-ঝুঁকি (ভাবনার প্রশ্ন ৪ দেখুন)। সার্ভিসের সীমানায় ভাষা-নিরপেক্ষ, শুধু-ডেটার format ব্যবহার করুন।

২ · Text format — JSON, XML আর CSV

JSON (JavaScript Object Notation) আজকের web-এর সাধারণ ভাষা। এতে মাত্র ছয় রকম মান: object {}, array [], string, number, true/false আর null. একটি payment event দেখতে এমন:

{
  "eventId": "9007199254740993",
  "amountPaisa": 125050,
  "currency": "BDT",
  "note": "ঈদের বোনাস",
  "createdAt": "2026-10-28T14:05:00+06:00"
}

JSON-এর শক্তি তিনটি: মানুষ পড়তে পারে, প্রায় সব ভাষায় parser আছে, আর এটি self-describing — প্রতিটি মানের সাথে তার নাম (key) যায়, তাই schema ছাড়াও মোটামুটি বোঝা যায়। দুর্বলতাও স্পষ্ট: প্রতিটি record-এ একই key বারবার লেখা হয়, integer আর দশমিকের আলাদা type নেই, binary ডেটা বা তারিখের type নেই (তাই তারিখ যায় ISO 8601 string হিসেবে, সময়ের অঞ্চলসহ — বাংলাদেশে +06:00), comment লেখা যায় না, আর ডিফল্টভাবে কোনো schema নেই — কোন field বাধ্যতামূলক, কোনটির type কী, তা কোথাও লেখা থাকে না।

JSON-কে জনপ্রিয় করেন Douglas Crockford, ২০০১–২০০২ সালের দিকে json.org ওয়েবসাইটের মাধ্যমে; তিনি নিজে বলতেন, তিনি এটি “আবিষ্কার” করেছেন, তৈরি নয় — কারণ এটি JavaScript-এর object লেখার নিয়মেরই একটি ছোট অংশ। পরে এটি RFC 4627 (২০০৬), ECMA-404 (২০১৩) আর RFC 8259 (২০১৭) হিসেবে মান পায়। RFC 8259 বলে, বন্ধ পরিবেশের বাইরে আলাদা সিস্টেমের মধ্যে আদান-প্রদানের JSON অবশ্যই UTF-8-এ হবে। ২০০০-এর দশকের শুরুতে বড় প্রতিষ্ঠানের web service-এ রাজত্ব ছিল XML আর SOAP-এর; Ajax-ভিত্তিক web অ্যাপ জনপ্রিয় হলে সরলতার জোরে JSON জায়গা নিয়ে নেয়।

Schema যোগ করা — JSON Schema. JSON-এর schema-হীনতার একটি জনপ্রিয় সমাধান JSON Schema: আলাদা একটি JSON নথিতে লেখা থাকে কোন field আছে, type কী, কোনটি বাধ্যতামূলক। OpenAPI (পর্ব ৮) API-র request-response বর্ণনায় এরই একটি রূপ ব্যবহার করে। লেখার সময়ে সর্বশেষ বড় সংস্করণটি 2020-12 নামে পরিচিত:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["eventId", "amountPaisa"],
  "properties": {
    "eventId":     { "type": "string" },
    "amountPaisa": { "type": "integer", "minimum": 1 },
    "currency":    { "type": "string", "enum": ["BDT"] },
    "note":        { "type": "string", "maxLength": 140 }
  },
  "additionalProperties": true
}

শেষ লাইনটি খেয়াল করুন। additionalProperties false করলে schema-য় নেই এমন field এলেই যাচাই ব্যর্থ হয় — তখন নতুন সংস্করণ একটি field যোগ করলেই পুরোনো যাচাইকারী সব বার্তা প্রত্যাখ্যান করবে। কড়াকড়ি দেখতে নিরাপদ, কিন্তু অংশ ৭-এ দেখবেন এটি forward compatibility ভেঙে দেয়।

XML ট্যাগ দিয়ে ডেটা ঘিরে রাখে: <amount currency="BDT">125050</amount>. এতে attribute, namespace আর XSD নামের শক্তিশালী schema ভাষা আছে, কিন্তু দুটি ট্যাগে আকার বাড়ে, parsing-ও ভারী। নতুন web API-তে XML কম, তবে প্রতিষ্ঠানের তথ্য-বিনিময় আর আর্থিক বার্তায় এখনো চালু — আন্তর্জাতিক পেমেন্ট বার্তার ISO 20022 মান মূলত XML-ভিত্তিক; আপনার .docx ফাইলটিও আসলে zip করা কয়েকটি XML ফাইল।

CSV (comma-separated values) সবচেয়ে সরল: প্রথম লাইনে কলামের নাম, তারপর প্রতি লাইনে একটি সারি। লাখো সারির bulk export-import, spreadsheet আর data warehouse-এ তোলার জন্য চমৎকার। কিন্তু নিয়মের ঘাটতি অনেক:

account_no,name,city,amount_paisa
0012345678,Rahim,Dhaka,125050
0012345679,"Karim, Jr.",Khulna,99900
  • মানের ভেতরে কমা থাকলে উদ্ধৃতিচিহ্ন লাগে ("Karim, Jr."); নিয়মটি RFC 4180 (২০০৫)-এ লেখা, কিন্তু বাস্তবে নানা রকম “dialect” চলে।
  • কোনো type নেই — spreadsheet 0012345678-কে সংখ্যা ভেবে শুরুর শূন্যগুলো মুছে দেয়; শূন্য দিয়ে শুরু হওয়া মোবাইল নম্বরেরও একই দশা হয়।
  • Encoding অস্পষ্ট — বাংলা লেখা থাকা UTF-8 CSV কিছু spreadsheet প্রোগ্রামে (অনেক সংস্করণে) ফাইলের শুরুতে একটি বিশেষ চিহ্ন, BOM, না থাকলে ভাঙা অক্ষরে দেখায়।
  • নেস্টেড ডেটা (যেমন একাধিক tag) রাখার স্বাভাবিক উপায় নেই।

৩ · JSON-এর তিন ফাঁদ — বড় সংখ্যা, বাংলা লেখা আর binary ডেটা

ফাঁদ ১ — ২৫৩-এর চেয়ে বড় integer. JSON-এর নিয়ম সংখ্যার আকার সীমিত করে না, কিন্তু JavaScript (আর আরও অনেক parser) প্রতিটি সংখ্যা রাখে IEEE 754-এর ৬৪-bit double হিসেবে। Double-এ আছে ১টি sign bit, ১১টি exponent bit আর ৫২টি fraction bit; সামনে একটি লুকানো ১ ধরে মোট ৫৩ bit-এর নির্ভুলতা। তাই ২৫৩ পর্যন্ত প্রতিটি পূর্ণসংখ্যা হুবহু রাখা যায়। কিন্তু ২৫৩ থেকে ২৫৪-এর মধ্যে পাশাপাশি দুটি double-এর ফারাক ২ — বিজোড় সংখ্যাগুলোর জায়গাই নেই। তাই ২৫৩ + ১ গোল হয়ে ২৫৩ হয়ে যায়, আর কোনো error-ও আসে না। নিরাপদ সীমা Number.MAX_SAFE_INTEGER = ৯,০০৭,১৯৯,২৫৪,৭৪০,৯৯১ = ২৫৩ − ১. বিস্তারিত দেখুন Computer Architecture-এর floating point পর্বে আর JavaScript-এর number পর্বে।

বড় সিস্টেমের ID প্রায়ই ৬৪-bit — যেমন সময়ের ক্রমে সাজানো, ১৯ অঙ্কের Snowflake-ধাঁচের ID (পর্ব ২৩)। Browser-এর JavaScript পড়ার সময় শেষের অঙ্কগুলো বদলে ফেলে, তারপর ভুল ID দিয়ে পরের অনুরোধ যায় — অন্য কারও অর্ডার খোলে বা “পাওয়া যায়নি” আসে। সমাধান সরল: বড় ID সবসময় string হিসেবে পাঠান। Protobuf-এর অফিশিয়াল JSON রূপও int64-কে string হিসেবে লেখে, আর Twitter (এখন X)-এর প্রকাশিত API ডকুমেন্টেশনে ঠিক এই কারণেই সংখ্যার পাশাপাশি id_str নামে string রূপ দেওয়া হতো। টাকার অঙ্কে আরেকটি নিয়ম: কখনো দশমিক double নয় — ৳১,২৫০.৫০-কে পূর্ণসংখ্যা পয়সায় (125050) বা দশমিক string-এ রাখুন, কারণ double-এ 0.1 + 0.2 হয় 0.30000000000000004.

ফাঁদ ২ — বাংলা লেখার byte. UTF-8 প্রতিটি Unicode code point-কে ১ থেকে ৪ byte-এ লেখে: ASCII (ইংরেজি অক্ষর, অঙ্ক) ১ byte, U+0080–U+07FF ২ byte, U+0800–U+FFFF ৩ byte, তার ওপরে ৪ byte. বাংলা লিপির Unicode block U+0980–U+09FF, অর্থাৎ প্রতিটি বাংলা code point UTF-8-এ ৩ byte নেয়। “Dhaka” ৫ byte, কিন্তু “ঢাকা” (ঢ, া, ক, া — চারটি code point) ১২ byte. আবার চোখে একটি অক্ষর মানেই একটি code point নয়: “ক্ষ” = ক + ্ + ষ, তিনটি code point, ৯ byte. চোখে দেখা এই এককটিকে বলে grapheme cluster. তাই “নাম সর্বোচ্চ ৫০ অক্ষর” লেখার আগে ঠিক করুন — byte, code point, JavaScript-এর UTF-16 একক, নাকি চোখে দেখা অক্ষর? Database, index আর SMS-এর সীমা আলাদা এককে মাপা হতে পারে; যেমন SMS-এ বাংলা সাধারণত UCS-2-এ যায়, এক বার্তায় ৭০টি code point (ইংরেজিতে ১৬০টি অক্ষর)।

আরেকটি লুকানো খরচ: কিছু JSON লাইব্রেরি ডিফল্টভাবে ASCII-র বাইরের প্রতিটি অক্ষরকে \u09a2 ধাঁচের escape-এ লেখে (যেমন Python-এর json.dumps, যার ensure_ascii ডিফল্টভাবে চালু)। তখন প্রতিটি বাংলা code point ৩-এর বদলে ৬ byte নেয়: {"city": "ঢাকা"} UTF-8-এ ২৪ byte, escape-সহ ৩৬ byte. ভুল encoding ধরে পড়লে আরেক বিপদ — “ঢাকা”-র UTF-8 byte-কে Windows-1252 ভেবে পড়লে দেখায় ঢাকা; এই ভাঙা লেখাকে বলে mojibake. Unicode-এর গভীরে যেতে দেখুন ডেটার বিট-রূপ পর্ব আর বাংলা টাইপিং আর Unicode পর্ব।

ফাঁদ ৩ — binary ডেটা। JSON-এ byte রাখার type নেই, তাই ছবি বা ফাইল পাঠাতে হয় Base64-এ: প্রতি ৩ byte (২৪ bit) ভেঙে ৪টি ৬-bit টুকরো, প্রতিটি টুকরো ৬৪টি ছাপার যোগ্য অক্ষরের একটি (A–Z, a–z, 0–9, +, /)। ফলে n byte-এর Base64 দৈর্ঘ্য ৪⌈n/৩⌉ — শেষে দরকারমতো = দিয়ে ভরাট করা হয় (RFC 4648 অনুযায়ী)। বড় ডেটায় অনুপাত ৪/৩, অর্থাৎ প্রায় ৩৩.৩% বাড়তি:

মূল ডেটাBase64 দৈর্ঘ্য = ৪⌈n/৩⌉অনুপাত
১ byte৪ অক্ষর (দুটি = ভরাট)৪ গুণ
৩০ byte৪০ অক্ষর১.৩৩৩
৩০ KiB ছবি (৩০,৭২০ byte)৪০,৯৬০ অক্ষর১.৩৩৩
৩০ লাখ byte-এর ফাইল৪০ লাখ অক্ষর১.৩৩৩

Base64 কোনো encryption নয় — যে কেউ মুহূর্তে উল্টে পড়তে পারেন; গোপন তথ্য Base64 করে “লুকানো” যায় না। বড় ফাইল JSON-এ না ভরে object storage-এ (পর্ব ২৬) তুলে শুধু তার ঠিকানা পাঠানোই সাধারণত ভালো নকশা। নিচের কোড প্রথম দুটি ফাঁদ নিজে দেখায়:

// (1) How many bytes does text take in UTF-8?  (2) What does JSON do to a big integer ID?
const utf8 = new TextEncoder();
const esc = s => s.replace(/[^\x00-\x7f]/g, c => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
console.log("code points | UTF-8 bytes | bytes as \\uXXXX | text");
for (const s of ["Dhaka", "ঢাকা", "আমার সোনার বাংলা", "ক্ষ"]) {
  const cp = [...s].length, bytes = utf8.encode(s).length;
  console.log(String(cp).padStart(11) + " | " + String(bytes).padStart(11) + " | " + String(esc(s).length).padStart(15) + " | " + s);
}
console.log("ঢাকা =", [...("ঢাকা")].map(c => "U+" + c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")).join(" "));
console.log("ঢ in UTF-8 =", [...utf8.encode("ঢ")].map(b => b.toString(16)).join(" "));

console.log("\nNumber.MAX_SAFE_INTEGER =", Number.MAX_SAFE_INTEGER, "= 2^53 - 1");
console.log("2^53 + 1 === 2^53 ?", 2 ** 53 + 1 === 2 ** 53);
const wire = '{"orderId": 9007199254740993, "orderIdStr": "9007199254740993"}';
const o = JSON.parse(wire);
console.log("sent 9007199254740993 as number ->", o.orderId);
console.log("sent it as a string             ->", o.orderIdStr, "| next id:", BigInt(o.orderIdStr) + 1n);
const snowflake = '{"id": 1541815603606036481}';      // a 64-bit, Snowflake-style ID
console.log("64-bit ID as number ->", JSON.parse(snowflake).id);

আউটপুট:

code points | UTF-8 bytes | bytes as \uXXXX | text
          5 |           5 |               5 | Dhaka
          4 |          12 |              24 | ঢাকা
         16 |          44 |              86 | আমার সোনার বাংলা
          3 |           9 |              18 | ক্ষ
ঢাকা = U+09A2 U+09BE U+0995 U+09BE
ঢ in UTF-8 = e0 a6 a2

Number.MAX_SAFE_INTEGER = 9007199254740991 = 2^53 - 1
2^53 + 1 === 2^53 ? true
sent 9007199254740993 as number -> 9007199254740992
sent it as a string             -> 9007199254740993 | next id: 9007199254740994n
64-bit ID as number -> 1541815603606036500

“আমার সোনার বাংলা”-য় ১৪টি বাংলা code point আর ২টি space: ১৪ × ৩ + ২ = ৪৪ byte, গড়ে code point-প্রতি ২.৭৫ byte. Escape করলে প্রায় দ্বিগুণ — ৮৬ byte. “ঢ” (U+09A2) UTF-8-এ তিন byte e0 a6 a2. নিচের অংশে 9007199254740993 সংখ্যা হিসেবে পাঠালে আসে …992 — কোনো error ছাড়াই। String হিসেবে পাঠালে মান অক্ষত থাকে, আর হিসাব দরকার হলে BigInt-এ নেওয়া যায়। ১৯ অঙ্কের ID-র শেষ তিনটি অঙ্ক বদলে 500 হয়ে গেছে — ঠিক এই ভুলই production-এ অন্য গ্রাহকের ডেটা খুলে দিতে পারে।

৪ · Protocol Buffers — field number, wire type আর varint

Protocol Buffers (সংক্ষেপে Protobuf) একটি binary format, যার schema লেখা হয় একটি IDL-এ (interface definition language, অর্থাৎ interface বর্ণনার ভাষা)। Google-এর ভেতরে বহু বছর ব্যবহারের পর ২০০৮ সালে এটি open source হয়; বর্তমান রূপ proto3 আসে ২০১৬ সালে। Schema থেকে protoc compiler নানা ভাষার কোড বানায়। gRPC-তে Protobuf কীভাবে API-র চুক্তি হয় — service, method, streaming — তা দেখেছেন পর্ব ৯-এ; এখানে আমরা নামব byte-এর স্তরে:

syntax = "proto3";

message Customer {
  int64  id            = 1;
  string name          = 2;
  repeated string tags = 3;
  optional string city = 4;   // v2-তে যোগ হয়েছে
}

প্রতিটি field-এর ডান পাশের সংখ্যাটি তার field number. Byte-এ নাম যায় না, যায় এই সংখ্যা — তাই নাম বদলালেও binary ডেটা ভাঙে না, কিন্তু সংখ্যা বদলালে ভাঙে। প্রতিটি field-এর আগে বসে একটি key (tag):

key = field number × ৮ + wire type
(field_number << 3) | wire_type

Wire type জানায় পরের মানটি কত লম্বা, যাতে schema না জানলেও parser field-টি ডিঙিয়ে যেতে পারে:

Wire typeনামকোন fieldদৈর্ঘ্য কীভাবে জানা যায়
0VARINTint32, int64, uint32, uint64, sint32, sint64, bool, enumশেষ byte-এর উঁচু bit ০
1I64fixed64, sfixed64, doubleসবসময় ৮ byte
2LENstring, bytes, nested message, packed repeatedসামনে একটি varint-এ দৈর্ঘ্য
5I32fixed32, sfixed32, floatসবসময় ৪ byte

৩ আর ৪ নম্বর wire type পুরোনো “group”-এর জন্য ছিল, এখন সেগুলো বাতিল (deprecated) বলে গণ্য। I64 আর I32-এর মান little-endian ক্রমে বসে — কম গুরুত্বপূর্ণ byte আগে (byte-ক্রমের বিস্তারিত ডেটার বিট-রূপ পর্বে)।

Varint হলো ছোট সংখ্যাকে কম byte-এ লেখার কৌশল: প্রতি byte-এ ৭ bit মান, আর সবচেয়ে উঁচু bit-টি “continuation bit” — ১ মানে “আরও byte আসছে”, ০ মানে “শেষ”। কম গুরুত্বপূর্ণ ৭-bit দল আগে যায়। ফলে ০–১২৭ এক byte, ১২৮–১৬,৩৮৩ দুই byte, আর ৩২-bit-এর সবচেয়ে বড় মান পাঁচ byte. Key-ও varint, তাই field number ১–১৫ এক byte-এর key পায় — ঘনঘন ব্যবহৃত field-এর জন্য এগুলো রেখে দেওয়া ভালো অভ্যাস। এবার একটি field হাতে encode করি:

  1. 08keyfield 1 · VARINT
  2. 961·0010110নিচের ৭ bit, আরও আছে
  3. 010·0000001উঁচু ৭ bit, শেষ
হিসাবের খাতা
কাজ
হিসাব
byte
  1. ধাপ ১ — কী পাঠাব। Schema-য় int64 id = 1, মান ১৫০. Field number ১, আর int64 যায় VARINT-এ, তাই wire type ০। প্রথমে key, তারপর মান।
  2. ধাপ ২ — key. ১ × ৮ + ০ = ৮, binary-তে 0000 1000. ১২৭-এর কম, তাই এক byte-ই যথেষ্ট: 08. পড়ার সময় ৮ ÷ ৮ = ১ থেকে field আর ৮ mod ৮ = ০ থেকে wire type পাওয়া যায়।
  3. ধাপ ৩ — মানটি binary-তে। ১৫০ = ১২৮ + ১৬ + ৪ + ২ = 1001 0110, মোট ৮ bit. এক byte-এ মান রাখার জায়গা মাত্র ৭ bit, তাই দুই byte লাগবে।
  4. ধাপ ৪ — ৭ bit করে ভাগ। ডান দিক, অর্থাৎ কম গুরুত্বপূর্ণ দিক থেকে: নিচের দল 0010110 (= ২২), উঁচু দল 0000001 (= ১)। Varint-এ নিচের দলটি আগে যায়।
  5. ধাপ ৫ — continuation bit. প্রথম byte-এর পরে আরও আছে, তাই সামনে ১: 1·0010110 = 0x96. শেষ byte-এর সামনে ০: 0·0000001 = 0x01. পুরো field: 08 96 01 — তিন byte. JSON-এ "id":150 লিখতে লাগে ৮ byte.
  6. ধাপ ৬ — উল্টো পথে। Decoder 08 দেখে বোঝে field ১, VARINT. 96-এর উঁচু bit ১, তাই নিচের ৭ bit (২২) রেখে এগোয়; 01-এর উঁচু bit ০ — এটিই শেষ, মান ১ × ১২৮. যোগফল ২২ + ১২৮ = ১৫০.

ঋণাত্মক সংখ্যা আর ZigZag. int32 বা int64 field-এ −১ লিখলে two's complement-এ সব bit ১ হয়, তাই varint-এ লাগে পুরো ১০ byte. ঋণাত্মক মান বেশি থাকলে sint32/sint64 ব্যবহার করুন — এরা ZigZag encoding করে: ০ → ০, −১ → ১, ১ → ২, −২ → ৩, অর্থাৎ ছোট মাত্রার ঋণাত্মক সংখ্যাও ছোট varint পায়; −১ লিখতে লাগে মাত্র এক byte, 01. Avro সব integer-এ এই কৌশলই ব্যবহার করে।

String আর repeated. String, bytes আর nested message যায় LEN হিসেবে: key, দৈর্ঘ্যের varint, তারপর byte. name = "Rahim" → 12 05 52 61 68 69 6d (key ২ × ৮ + ২ = ১৮ = 0x12, দৈর্ঘ্য ৫)। Repeated string-এর প্রতিটি উপাদান নিজের key নিয়ে যায়; repeated সংখ্যা proto3-এ ডিফল্টভাবে “packed” — একটি key, মোট দৈর্ঘ্য, তারপর পরপর varint.

Optional আর default. proto3-এ শূন্য মান (০, খালি string, false) byte-এ লেখাই হয় না, আর অনুপস্থিত field শূন্য ধরা হয় — তাই “ব্যালান্স ০” আর “ব্যালান্স জানানো হয়নি” আলাদা করা যায় না। পার্থক্য দরকার হলে optional লিখুন; proto3-এ এটি ২০২০–২১ সালের দিকে আবার যোগ হয়েছে।

Unknown field. পুরোনো reader অচেনা field number পেলে wire type দেখে বোঝে কত byte ডিঙাতে হবে, আর পরের field-এ চলে যায়। proto3 প্রথমে এমন field ফেলে দিত; সংস্করণ ৩.৫ (২০১৭ সালের দিকে) থেকে আবার proto2-র মতো রেখে দেয়, যাতে message আবার লেখার সময় অচেনা field-টিও অক্ষত থাকে। নিচের ছোট encoder-decoder এর সবটা দেখায়:

// A minimal Protobuf wire-format encoder/decoder: varints, field keys, strings.
function varint(n) {                        // non-negative integers only
  const out = [];
  do { let b = n % 128; n = Math.floor(n / 128); if (n > 0) b += 128; out.push(b); } while (n > 0);
  return out;
}
const utf8 = new TextEncoder(), text = new TextDecoder();
const key = (field, wire) => varint(field * 8 + wire);       // tag = (field << 3) | wire type
const vInt = (f, n) => [...key(f, 0), ...varint(n)];         // wire type 0 = VARINT
const vStr = (f, s) => { const b = [...utf8.encode(s)]; return [...key(f, 2), ...varint(b.length), ...b]; }; // 2 = LEN
const hex = a => a.map(b => b.toString(16).padStart(2, "0")).join(" ");

function decode(bytes, known) {             // known: field number -> name, from the READER's schema
  let i = 0; const msg = {}, unknown = [];
  const rv = () => { let n = 0, mul = 1, b; do { b = bytes[i++]; n += (b % 128) * mul; mul *= 128; } while (b >= 128); return n; };
  while (i < bytes.length) {
    const start = i, k = rv(), field = Math.floor(k / 8), wire = k % 8; let val;
    if (wire === 0) val = rv();
    else if (wire === 2) { const len = rv(); val = text.decode(new Uint8Array(bytes.slice(i, i + len))); i += len; }
    else throw new Error("wire type " + wire + " is not handled in this demo");
    const name = known[field];                                        // unknown field: skip, keep raw bytes
    if (!name) { unknown.push(hex(bytes.slice(start, i))); continue; }
    if (name === "tags") (msg.tags = msg.tags || []).push(val); else msg[name] = val;
  }
  return { msg, unknown };
}
// v1 schema: 1 = id (int64), 2 = name (string), 3 = tags (repeated string)
const v1 = [...vInt(1, 150), ...vStr(2, "Rahim"), ...vStr(3, "vip"), ...vStr(3, "new")];
console.log("field 1 = 150 ->", hex(vInt(1, 150)));
console.log("v1 message (" + v1.length + " bytes):", hex(v1));
const v1Fields = { 1: "id", 2: "name", 3: "tags" }; console.log("v1 reader, v1 data:", JSON.stringify(decode(v1, v1Fields).msg));
const v2 = [...v1, ...vStr(4, "Dhaka")];    // v2 adds 4 = city (string)
const r = decode(v2, v1Fields);
console.log("v2 message (" + v2.length + " bytes) ends with:", hex(v2.slice(-7)));
console.log("v1 reader, v2 data:", JSON.stringify(r.msg), "| unknown kept:", r.unknown.join(", "));

আউটপুট:

field 1 = 150 -> 08 96 01
v1 message (20 bytes): 08 96 01 12 05 52 61 68 69 6d 1a 03 76 69 70 1a 03 6e 65 77
v1 reader, v1 data: {"id":150,"name":"Rahim","tags":["vip","new"]}
v2 message (27 bytes) ends with: 22 05 44 68 61 6b 61
v1 reader, v2 data: {"id":150,"name":"Rahim","tags":["vip","new"]} | unknown kept: 22 05 44 68 61 6b 61

প্রথম লাইনে স্টেপারের সেই তিন byte. পুরো v1 message ২০ byte: 08 96 01 (id), 12 05 … (name, ৫ byte), আর দুটি tag, প্রতিটি নিজের key 1a নিয়ে, কারণ ৩ × ৮ + ২ = ২৬ = 0x1a. v2 শেষে যোগ করল 22 05 44 68 61 6b 61 — key 0x22 = ৩৪ = ৪ × ৮ + ২, অর্থাৎ field ৪, LEN, ৫ byte “Dhaka”. v1 reader field ৪ চেনে না, তবু ভাঙেনি: wire type ২ থেকে দৈর্ঘ্য পড়ে পাঁচ byte ডিঙিয়ে গেছে, আর কাঁচা byte-গুলো unknown-এ রেখে দিয়েছে। এটাই forward compatibility-র যন্ত্র। আসল Protobuf লাইব্রেরি আরও অনেক কিছু সামলায় (ঋণাত্মক সংখ্যা, nested message, packed field); এই demo শুধু মূল নিয়মটুকু দেখায়।

৫ · Avro — tag ছাড়া byte, writer আর reader-এর schema

Apache Avro এসেছে ২০০৯ সালের দিকে Hadoop-এর জগত থেকে (Doug Cutting-এর হাতে)। এর schema লেখা হয় JSON-এ, আর binary byte-এ কোনো field number বা নাম থাকে না — field-গুলোর মান schema-র ক্রমে পরপর বসে যায়:

{"type": "record", "name": "Customer", "fields": [
  {"name": "id",   "type": "int"},
  {"name": "name", "type": "string"},
  {"name": "city", "type": "string"}
]}

Record {id: 150, name: "Rahim", city: "Dhaka"} Avro-তে হয়: id-র ZigZag মান ৩০০ → ac 02; name-এর দৈর্ঘ্য ৫, ZigZag-এ ১০ → 0a, তারপর ৫ byte; city-ও তেমনি 0a + ৫ byte. মোট ১৪ byte; একই তিনটি field Protobuf-এ ১৭ byte (৩ + ৭ + ৭)। কিন্তু দাম আছে: ac 02 0a 52 … দেখে বোঝার উপায় নেই কোথায় একটি field শেষ, কোথায় আরেকটি শুরু। ঠিক যে schema দিয়ে লেখা হয়েছিল, সেই writer schema ছাড়া Avro byte পড়া যায় না। Writer schema আসে তিন জায়গা থেকে: বড় ফাইলের (Avro object container file) শুরুতে একবার লেখা থাকে, তারপর লাখো record; message system-এ প্রতিটি বার্তায় শুধু একটি ছোট schema ID, আর schema থাকে registry-তে (অংশ ৮); আর RPC-তে সংযোগের শুরুতে দুই পক্ষ schema বিনিময় করে।

Avro-র আসল জাদু schema resolution: reader নিজের schema (reader schema) আর লেখকের schema (writer schema) পাশাপাশি রেখে field নাম ধরে মেলায়। নিয়মগুলো:

  • দুই পাশে একই নামের field — মান পড়া হয়; type আলাদা হলে শুধু অনুমোদিত “promotion” চলে: int → long → float → double, আর string ↔ bytes.
  • Writer-এ আছে, reader-এ নেই — byte পড়ে বাদ দেওয়া হয় (পড়তেই হয়, নইলে পরের field কোথায় শুরু জানা যায় না)।
  • Reader-এ আছে, writer-এ নেই — reader schema-র default বসে; default না থাকলে error.
  • নাম বদল — reader schema-য় aliases দিলে পুরোনো নামের সাথে মেলে; alias ছাড়া নাম বদল মানে “একটি field মুছে আরেকটি যোগ”।
  • Optional field — union দিয়ে: {"name": "phone", "type": ["null", "string"], "default": null}; default-কে union-এর প্রথম type-এর হতে হয়।
দুই schema পাশাপাশি
Writer (v1)id: int · name: string · city: string
Reader (v2)id: long · fullName: string (alias name) · phone: string, default ""
এখন পড়া byte
নিয়ম
ফল
  1. ধাপ ১ — writer লিখল। v1 কোড {id: 150, name: "Rahim", city: "Dhaka"}-কে ১৪ byte-এ লিখল। Byte-এ কোনো নাম বা field number নেই — শুধু মানগুলো, schema-র ক্রমে।
  2. ধাপ ২ — writer schema লাগবে। v2 reader-এর হাতে নিজের schema আছে, কিন্তু ac 02 একটি int, নাকি দুটি ছোট int — writer schema ছাড়া বলা যায় না। তাই আগে writer schema আনা হয়: ফাইলের header থেকে, বা বার্তার schema ID দিয়ে registry থেকে।
  3. ধাপ ৩ — id. Writer-এর প্রথম field id: int: varint ac 02 = ৩০০, ZigZag উল্টে ১৫০. Reader-এ id আছে long হিসেবে — int থেকে long অনুমোদিত promotion, তাই id = ১৫০.
  4. ধাপ ৪ — name থেকে fullName. 0a = দৈর্ঘ্য ৫, তারপর “Rahim”. Reader-এ name নামে field নেই, কিন্তু fullName-এর alias “name” — তাই মান যায় fullName-এ। Alias না থাকলে নামটি অচেনা ধরা হতো, আর fullName-এর জন্য default লাগত।
  5. ধাপ ৫ — city বাদ। Writer-এ city আছে, reader-এ নেই। তবু byte-গুলো পড়তে হয় — 0a দেখে ৫ byte এগোনো — নইলে পরের field-এর শুরু খুঁজে পাওয়া যেত না। মানটি ফেলে দেওয়া হয়।
  6. ধাপ ৬ — phone-এর default. Reader-এ phone আছে, writer-এ নেই; reader schema-র default (খালি string) বসে। চূড়ান্ত record: {id: 150, fullName: "Rahim", phone: ""}. Phone-এর default না থাকলে এখানেই error হতো — তাই Avro-তে নতুন field সবসময় default-সহ যোগ করতে হয়।

কখন Avro, কখন Protobuf? Avro-তে field number সামলাতে হয় না, তাই schema স্বয়ংক্রিয়ভাবে বানানো সহজ — যেমন database টেবিলের প্রতিদিনের dump থেকে; কলাম যোগ হলে নতুন schema, আর নাম ধরে resolution বাকিটা সামলায়। বড় ফাইলে schema একবারই থাকে, তাই data pipeline আর data lake-এ Avro জনপ্রিয়। হাতে লেখা, দীর্ঘস্থায়ী service API-তে Protobuf বেশি দেখা যায়। দুটির চিন্তা এক: schema আছে বলেই byte ছোট, আর schema-র নিয়ম মানলেই বিবর্তন নিরাপদ।

৬ · আরও কয়েকটি format, একই record-এর পাঁচ রকম আকার, আর compression

MessagePack (২০০৮ সালের দিকে) আর CBOR (RFC 7049, ২০১৩; হালনাগাদ RFC 8949, ২০২০) হলো “binary JSON” — একই কাঠামো, কিন্তু ছোট সংখ্যা এক byte-এ, দৈর্ঘ্য সামনে লেখা, আর আলাদা bytes type. Schema লাগে না, তাই key-এর নাম প্রতিটি record-এ যায়। CBOR ছোট IoT যন্ত্রে আর WebAuthn-এর মতো নিরাপত্তা-মানে ব্যবহৃত হয়। Apache Thrift Facebook-এ তৈরি হয়ে ২০০৭ সালে open source হয়; এটিও IDL-ভিত্তিক, field-এ সংখ্যাযুক্ত ID, আর কাজের ধরনে Protobuf-এর কাছাকাছি — সাথে নিজস্ব RPC কাঠামোও আছে।

FormatধরনSchemaField চেনা যায়সাধারণ ব্যবহার
JSONtextঐচ্ছিক (JSON Schema)প্রতিটি record-এ key-এর নামweb API, config
XMLtextঐচ্ছিক (XSD)ট্যাগের নামপ্রতিষ্ঠানের বার্তা, নথি
CSVtextশুধু header লাইনকলামের অবস্থানbulk export
MessagePack / CBORbinaryনেইkey-এর নামছোট JSON, IoT
Protobuf / Thriftbinaryবাধ্যতামূলক (IDL)field numbergRPC, ভেতরের সার্ভিস
Avrobinaryবাধ্যতামূলক, পড়তে writer schema লাগেschema-র ক্রম; মেলানো হয় নামেdata pipeline, log stream

এবার মাপজোখ। একটি গ্রাহকের record — id, নাম, শহর, দুটি tag, loyalty point — পাঁচভাবে লিখে byte গুনি। Encoder-গুলো আমাদের নিজের ছোট কোড, তাই আকার নির্ভরযোগ্য; গতি এখানে মাপা হচ্ছে না।

// One record, five encodings: how many bytes does each one put on the wire?
const utf8 = new TextEncoder();
const varint = n => { const o = []; do { let b = n % 128; n = Math.floor(n / 128); if (n) b += 128; o.push(b); } while (n); return o; };
const zigzag = n => (n >= 0 ? 2 * n : -2 * n - 1);           // Avro writes ints as zigzag varints
const str = s => { const b = [...utf8.encode(s)]; return [b.length, b]; };

const rec = { customerId: 150, customerName: "Rahim", homeCity: "Dhaka", tags: ["vip", "new"], loyaltyPoints: 2500 };

const jsonLong = JSON.stringify(rec);
const jsonShort = JSON.stringify({ i: rec.customerId, n: rec.customerName, c: rec.homeCity, t: rec.tags, p: rec.loyaltyPoints });

// Protobuf-style: every field carries a key (field number + wire type)
const pInt = (f, n) => [...varint(f * 8 + 0), ...varint(n)];
const pStr = (f, s) => { const [len, b] = str(s); return [...varint(f * 8 + 2), ...varint(len), ...b]; };
const proto = [...pInt(1, rec.customerId), ...pStr(2, rec.customerName), ...pStr(3, rec.homeCity),
               ...rec.tags.flatMap(t => pStr(4, t)), ...pInt(5, rec.loyaltyPoints)];

// Avro-style: no keys at all; fields in schema order, the reader must have the schema
const aStr = s => { const [len, b] = str(s); return [...varint(zigzag(len)), ...b]; };
const avro = [...varint(zigzag(rec.customerId)), ...aStr(rec.customerName), ...aStr(rec.homeCity),
              ...varint(zigzag(rec.tags.length)), ...rec.tags.flatMap(aStr), 0, ...varint(zigzag(rec.loyaltyPoints))];

const b64 = btoa(String.fromCharCode(...proto));            // binary inside a JSON/text field
const rows = [["JSON, long keys", utf8.encode(jsonLong).length], ["JSON, short keys", utf8.encode(jsonShort).length],
              ["Protobuf-style", proto.length], ["Avro-style", avro.length], ["Base64(Protobuf)", b64.length]];
const base = rows[0][1];
console.log("format".padEnd(18) + "bytes  vs JSON");
for (const [name, n] of rows) console.log(name.padEnd(18) + String(n).padStart(5) + "  " + (n / base * 100).toFixed(0).padStart(4) + "%");
console.log("Base64 / binary = " + b64.length + " / " + proto.length + " = " + (b64.length / proto.length).toFixed(3));
const perDay = 1e8;                                          // assume 10 crore events a day
console.log("10^8 events/day: JSON " + (base * perDay / 1e9).toFixed(1) + " GB, Protobuf " + (proto.length * perDay / 1e9).toFixed(1) + " GB");

আউটপুট:

format            bytes  vs JSON
JSON, long keys     102   100%
JSON, short keys     60    59%
Protobuf-style       30    29%
Avro-style           26    25%
Base64(Protobuf)     40    39%
Base64 / binary = 40 / 30 = 1.333
10^8 events/day: JSON 10.2 GB, Protobuf 3.0 GB

লম্বা key-এর JSON ১০২ byte; key ছোট করলেই ৬০ — JSON-এর প্রায় ৪০% ছিল key-এর নাম। Protobuf-ধাঁচে ৩০ byte (নামের বদলে এক byte-এর key আর ছোট varint), Avro-ধাঁচে ২৬ (key-ই নেই)। Binary-কে আবার JSON-এ ভরতে Base64 করলে ৩০ থেকে ৪০ — ঠিক ৪/৩ গুণ। ধরা যাক দিনে ১০ কোটি event: JSON-এ দিনে প্রায় ১০.২ GB, Protobuf-এ ৩.০ GB — বছরে, replication-সহ, এই পার্থক্য কয়েক TB. (একই record MessagePack-এ, আমাদের আলাদা ছোট হিসাবে, লম্বা key-সহ ৭৯ byte — key-এর নাম তখনো যাচ্ছে।)

গতির কথা সাবধানে। Binary format সাধারণত দ্রুত decode হয় — text থেকে সংখ্যা বানানো বা key-এর নাম মেলানো লাগে না। কিন্তু গতি নির্ভর করে লাইব্রেরি, ভাষা আর ডেটার ধরনের ওপর; ভালো JSON parser অনেক binary লাইব্রেরির চেয়েও দ্রুত হতে পারে। লাইব্রেরির ওয়েবসাইটের benchmark আপনার ডেটা নয় — নিজের আসল payload দিয়ে মাপুন, আর throughput-এর সাথে p99 latency-ও দেখুন।

Compression — কখন লাভ, কখন ক্ষতি

Compression পুনরাবৃত্তি খুঁজে ছোট করে। পরিচিত তিনটি: gzip (DEFLATE-ভিত্তিক, RFC 1952, ১৯৯৬), Google-এর brotli (২০১৫; RFC 7932) আর zstd (২০১৬; RFC 8878), যা Facebook-এ Yann Collet-এর তৈরি। HTTP-তে browser Accept-Encoding header-এ জানায় কোনটি বোঝে, আর সার্ভার Content-Encoding-এ জানায় কোনটি দিল (HTTP পর্ব দেখুন)। আমাদের Node.js-এ (ডিফল্ট level) করা একটি ছোট পরীক্ষা:

  • উপরের একটিমাত্র ১০২ byte-এর JSON record gzip করলে হলো ১১২ byte — আকার বাড়ল! gzip-এর নিজস্ব header আর trailer-ই ১৮ byte, আর এত ছোট লেখায় পুনরাবৃত্তি প্রায় নেই।
  • একই ধাঁচের ১,০০০টি record-এর JSON array (১,০৩,১৫১ byte) gzip-এ ৫,৮২৮ আর brotli-তে ২,৫২৫ byte. তবে এই কৃত্রিম ডেটা খুবই একঘেয়ে; আসল ডেটায় অনুপাত সাধারণত এত ভালো হয় না।

তাই নিয়ম: বড়, পুনরাবৃত্তিময় payload-এ (key-ভরা JSON array, log) compression দারুণ; এক-একটি ছোট বার্তায় নয় — Kafka-র মতো সিস্টেম তাই পুরো batch একসাথে compress করে। আগেই compress করা (JPEG, MP4) বা encrypted ডেটা প্রায় ছোট হয় না। খরচ CPU-তে, দুই পাশেই, উঁচু level-এ আরও বেশি; সাধারণত decompress অনেক দ্রুত। ধীর mobile network-এ CPU খরচ করে byte বাঁচানো লাভজনক, data center-এর দ্রুত network-এ সবসময় নয় — মেপে দেখুন।

আরও গভীরে: Geoff Langdale ও Daniel Lemire-এর “Parsing Gigabytes of JSON per Second” (VLDB Journal, ২০১৯) দেখায়, CPU-র SIMD নির্দেশ দিয়ে JSON parsing কতটা দ্রুত করা যায় (simdjson লাইব্রেরি)। উল্টো দিকে FlatBuffers (Google, ২০১৪) আর Cap'n Proto (Kenton Varda, ২০১৩) এমনভাবে byte সাজায় যে decode ছাড়াই সরাসরি buffer থেকে field পড়া যায় — “zero-copy”. বিশ্লেষণের বিশাল ডেটায় আবার row ধরে নয়, কলাম ধরে রাখা Parquet বা ORC-এর মতো format চলে; row বনাম column store আসবে পর্ব ১৯-এ। পুরো বিষয়ের ক্লাসিক আলোচনা Martin Kleppmann-এর Designing Data-Intensive Applications (২০১৭) বইয়ের “Encoding and Evolution” অধ্যায়।

৭ · Schema evolution — backward, forward আর full compatibility

কাঠামো বদলাবেই — নতুন field আসবে, পুরোনো field অপ্রয়োজনীয় হবে। নিরাপদে বদলানোর বিদ্যাকে বলে schema evolution. এর দুটি দিক, আর নাম দুটি গুলিয়ে ফেলা খুব সাধারণ ভুল:

Backward compatibility: নতুন কোড পুরোনো ডেটা পড়তে পারে
Forward compatibility: পুরোনো কোড নতুন ডেটা পড়তে পারে
Full compatibility: দুটোই
পাঠক ↓ / ডেটা →পুরোনো ডেটা (v1-এর লেখা)নতুন ডেটা (v2-এর লেখা)
পুরোনো reader (v1)স্বাভাবিকforward compatibility লাগে
নতুন reader (v2)backward compatibility লাগেস্বাভাবিক

Backward তুলনামূলক সহজ — নতুন কোড লেখার সময় আপনি পুরোনো format জানেন। Forward কঠিন — পুরোনো কোডকে আগে থেকেই ভবিষ্যতের অচেনা ডেটা সহ্য করার মতো করে লিখতে হয় (অচেনা field ডিঙানো, রেখে দেওয়া)। একটি API-তে দুটোই একসাথে লাগে: নতুন সার্ভার পুরোনো app-এর request পড়ে — এটি backward; আর পুরোনো app নতুন সার্ভারের response পড়ে — এটি forward.

কেন দুই দিকই দরকার, তা সবচেয়ে ভালো বোঝা যায় rolling deployment-এ: সব সার্ভার একসাথে বন্ধ না করে একটি একটি করে নতুন সংস্করণে বদলানো, যাতে সেবা চালু থাকে (canary release-এও একই অবস্থা — কিছু সার্ভারে নতুন সংস্করণ; বিস্তারিত পর্ব ৫৩-এ)। নিচে চারটি সার্ভার, একটি shared database, আর v2-তে যোগ হলো field ৪ city:

চারটি সার্ভার (S1–S4) আর একটি database
সার্ভারের সংস্করণ
লেখক → পাঠক
যা লাগে
অবস্থা
  1. ধাপ ১ — শুরু। চারটি সার্ভারই v1; database-এর সব record v1-এর লেখা (field ১–৩)। Deploy শুরু হবে S1 দিয়ে, আর পুরো কাজ শেষ হতে কয়েক মিনিট লাগবে — এই সময়টুকুতেই সব ঝুঁকি।
  2. ধাপ ২ — v2 লিখল, v1 পড়ল। S1 এখন v2; একটি নতুন গ্রাহকের record লিখল city-সহ। পরের অনুরোধ load balancer পাঠাল S3-এ, যা এখনো v1. v1 field ৪ চেনে না, wire type দেখে ডিঙিয়ে যায় — এটাই forward compatibility.
  3. ধাপ ৩ — v1-এর ডেটা, v2 পড়ল। S1 (v2) একটি পুরোনো record পড়ল, যাতে city নেই। নতুন কোড ধরে নেয় default (খালি string) — এটাই backward compatibility. Default ছাড়া required field হলে এখানেই error হতো।
  4. ধাপ ৪ — নিঃশব্দ ডেটা-ক্ষয়। S3 (v1) city-সহ একটি record পড়ল, গ্রাহকের নাম বদলাল, আবার লিখল। v1 অচেনা field ফেলে দিয়ে লিখলে city চিরতরে হারায় — কোনো error ছাড়াই। Protobuf (৩.৫ থেকে) unknown field রেখে দেয়, কিন্তু JSON-কে নির্দিষ্ট class-এ বদলানো অনেক কোড অচেনা key ফেলে দেয়। পড়া-বদলানো-লেখার পথ আলাদা করে পরীক্ষা করুন।
  5. ধাপ ৫ — অর্ধেক অর্ধেক। S1, S2 v2; S3, S4 v1. একই ব্যবহারকারীর একটি অনুরোধ S1-এ গিয়ে city দেখাল, পরেরটি S4-এ গিয়ে দেখাল না। ডেটা ঠিক, অভিজ্ঞতা বিভ্রান্তিকর। প্রতিকার: field লেখা চলুক, কিন্তু পর্দায় দেখানো feature flag দিয়ে বন্ধ থাকুক, যতক্ষণ না সব সার্ভার v2.
  6. ধাপ ৬ — rollback. v2-তে bug; সব সার্ভার আবার v1. কিন্তু v2-এর লেখা record database-এ রয়ে গেছে! v1 সেগুলো পড়তে না পারলে rollback নিজেই নতুন বিভ্রাট। তাই forward compatibility ফিরে আসার পথও খোলা রাখে।
  7. ধাপ ৭ — সব v2. সব সার্ভার v2, rollback-এর সময়ও পার হলো। কেবল এখন, পরের release-এ, নতুন field-নির্ভর logic চালু করা নিরাপদ; পুরোনো কোনো field বাদ দেওয়া আরও পরের, আলাদা ধাপ।

কোন পরিবর্তন নিরাপদ, কোনটি ভাঙে

সাধারণত নিরাপদ

নতুন optional field যোগ, default-সহ, নতুন field number-এ (Avro-তে default বাধ্যতামূলক)। Optional field মুছে ফেলা, আর Protobuf-এ তার number reserved করা। Protobuf-এ field-এর নাম বদল — binary-তে নিরাপদ, কারণ নাম তারে যায় না (তবে JSON রূপ আর কোড ভাঙে)। Avro-তে alias দিয়ে নাম বদল। কিছু type বদল: Protobuf-এ int32 ↔ int64 (বড় মান কেটে যাওয়ার ঝুঁকিসহ), Avro-তে int → long.

যোগ করুন, মুছুন, কিন্তু পুনর্ব্যবহার নয়

বিপজ্জনক

মুছে ফেলা field-এর number নতুন অর্থে আবার ব্যবহার। বেমানান type বদল (string → int, repeated → একক)। Avro-তে alias ছাড়া নাম বদল। Optional-কে required করা, বা নতুন required field যোগ। Type না বদলে অর্থ বদল — টাকা থেকে পয়সা; কোনো যন্ত্র এটা ধরে না, তাই এককসহ নতুন নাম দিন, যেমন amount_paisa. Enum-এর মান মুছে বা নম্বর বদলে ফেলা।

পুরোনো ডেটা ভুল অর্থে পড়া হয়

reserved শব্দটি Protobuf-এর পাহারাদার: মুছে ফেলা field-এর number আর নাম তালিকায় রাখলে কেউ ভুল করে আবার ব্যবহার করতে চাইলে protoc compile করতেই রাজি হয় না।

message Customer {
  reserved 3, 9 to 11;   // এই number-গুলো আর কাউকে দেওয়া যাবে না
  reserved "tags";       // নামটিও না, যাতে JSON রূপে বিভ্রান্তি না হয়
  int64  id   = 1;
  string name = 2;
}

নিয়মগুলো যন্ত্রে বসানো যায়। নিচের checker পুরোনো আর নতুন field-তালিকা (number, নাম, type, required কি না, default) নিয়ে দুই দিকে পড়ার চেষ্টা করে, আর কারণসহ রায় দেয়। নিয়মগুলো সরলীকৃত, Protobuf-ধাঁচের — field মেলানো হয় number ধরে:

// Schema-evolution checker (simplified Protobuf-style rules: fields are matched by NUMBER).
// BACKWARD = new code reads OLD data.   FORWARD = old code reads NEW data.
const F = (num, name, type, req = false, def = null) => ({ num, name, type, req, def });
function canRead(reader, writer) {            // can code with schema `reader` read bytes written with `writer`?
  const bad = [], ok = [];
  for (const r of reader) {
    const w = writer.find(x => x.num === r.num);
    if (!w) { if (r.req) bad.push(`#${r.num} ${r.name} is required but absent`); else ok.push(`#${r.num} absent -> default ${JSON.stringify(r.def)}`); continue; }
    if (w.type !== r.type) bad.push(`#${r.num} written as ${w.type}, read as ${r.type}`);
    else if (r.req && !w.req) bad.push(`#${r.num} ${r.name} is required here but was optional for the writer`);
    else if (w.name !== r.name) ok.push(`#${r.num} renamed ${w.name} -> ${r.name} (binary fine, JSON breaks)`);
  }
  for (const w of writer) if (!reader.some(r => r.num === w.num)) ok.push(`unknown #${w.num} skipped`);
  return { bad, ok };
}
function check(title, oldS, newS) {
  const b = canRead(newS, oldS), f = canRead(oldS, newS);
  const line = x => (x.bad.length ? "NO  " + x.bad.join("; ") : "yes " + (x.ok.join("; ") || "no conflict"));
  const verdict = b.bad.length ? (f.bad.length ? "BREAKING" : "FORWARD only") : (f.bad.length ? "BACKWARD only" : "FULL");
  console.log(title + "\n  backward: " + line(b) + "\n  forward:  " + line(f) + "\n  => " + verdict);
}
const v1 = [F(1, "id", "int64", true), F(2, "name", "string", false, ""), F(3, "tags", "string[]", false, [])];
check("A. add optional #4 city", v1, [...v1, F(4, "city", "string", false, "")]);
check("B. add required #5 phone", v1, [...v1, F(5, "phone", "string", true)]);
check("C. delete optional #3 tags", v1, v1.slice(0, 2));
check("D. delete required #1 id", v1, v1.slice(1));
check("E. reuse #3 for phone:string", v1, [...v1.slice(0, 2), F(3, "phone", "string")]);
check("F. rename #2 name -> fullName", v1, [v1[0], F(2, "fullName", "string", false, ""), v1[2]]);

আউটপুট:

A. add optional #4 city
  backward: yes #4 absent -> default ""
  forward:  yes unknown #4 skipped
  => FULL
B. add required #5 phone
  backward: NO  #5 phone is required but absent
  forward:  yes unknown #5 skipped
  => FORWARD only
C. delete optional #3 tags
  backward: yes unknown #3 skipped
  forward:  yes #3 absent -> default []
  => FULL
D. delete required #1 id
  backward: yes unknown #1 skipped
  forward:  NO  #1 id is required but absent
  => BACKWARD only
E. reuse #3 for phone:string
  backward: NO  #3 written as string[], read as string
  forward:  NO  #3 written as string, read as string[]
  => BREAKING
F. rename #2 name -> fullName
  backward: yes #2 renamed name -> fullName (binary fine, JSON breaks)
  forward:  yes #2 renamed fullName -> name (binary fine, JSON breaks)
  => FULL

A আদর্শ: নতুন কোড পুরোনো ডেটায় default বসায়, পুরোনো কোড নতুন field ডিঙায় — FULL. B-তে নতুন required field পুরোনো ডেটায় নেই, তাই backward ভাঙে, কিন্তু forward টেকে। D ঠিক উল্টো: required field মুছলে পুরোনো কোড সেটি খুঁজে পায় না। E সবচেয়ে বিপজ্জনক — একই number ৩-এ এখন অন্য জিনিস। বাস্তবে আরও খারাপ: repeated string আর একক string দুটোই byte-এ LEN, তাই আসল parser হয়তো error-ই দেবে না; পুরোনো কোড ফোন নম্বরকে চুপচাপ tag বানাবে, নতুন কোড পুরোনো ডেটার শেষ tag-কে ফোন নম্বর ভাববে। F binary-তে নিরাপদ, কিন্তু JSON রূপে key বদলায়। Checker-টি সরলীকৃত: আসল Protobuf কিছু type বদল (int32 ↔ int64) মেনে নেয়, আর Avro মেলায় নাম ধরে।

৮ · বাস্তবে compatibility — schema registry, API version আর database migration

বড় প্রতিষ্ঠানে শত শত সার্ভিস একে অপরকে বার্তা পাঠায়। “সবাই নিয়ম মেনে চলবে” আশা করে বসে থাকলে একদিন কেউ ভুলবেই। তাই নিয়মটি পাহারা দেয় একটি কেন্দ্রীয় schema registry. নিচের ছবিতে একটি producer (v2) বার্তা পাঠাচ্ছে, আর পড়ছে এখনো v1-এ থাকা একটি consumer:

Schema registry — producer v2 থেকে consumer v1

Register & check → small schema ID in every message → consumer fetches the writer schema

Schema registry: producer v2, message bytes, consumer v1 Schema registry orders: v1 → id 41 · v2 → id 42 নিয়ম BACKWARD · না মিললে বাতিল ① schema v2 ② id 42 ⑤ id 42? ⑥ schema Producer · v2 order service writer schema v2 Consumer · v1 notification reader schema v1 ③ পাঠানো ④ পড়া Queue বা log-এ একটি বার্তা 00 00 00 00 2a ac 02 0a … magic schema id = 42 শুধু ডেটা Consumer: writer v2 + নিজের reader v1 মিলিয়ে পড়ে; অচেনা field বাদ যায় একবার আনা schema cache-এ থাকে
ছবি ১২.১ — নিবন্ধনের সময়ই compatibility যাচাই হয়, তাই বেমানান schema production-এ পৌঁছানোর আগেই আটকে যায়। দাগকাটা তীর উত্তর।

প্রবাহটি ধাপে ধাপে: (১) producer নতুন schema নিবন্ধন করতে চায়; registry একটি “subject”-এর (যেমন orders) আগের সংস্করণের সাথে নির্ধারিত নিয়মে মিলিয়ে দেখে, না মিললে প্রত্যাখ্যান করে। (২) মিললে একটি ছোট সংখ্যার ID দেয়। (৩) প্রতিটি বার্তায় পুরো schema নয়, শুধু এই ID যায় — Confluent-এর ডকুমেন্টেশনে বর্ণিত format অনুযায়ী শুরুতে একটি magic byte (০) আর চার byte-এর ID — big-endian ক্রমে, তাই ৪২ দেখায় 00 00 00 2a — অর্থাৎ বার্তাপ্রতি মাত্র ৫ byte বাড়তি। (৪–৬) consumer ID দিয়ে writer schema এনে cache করে, আর নিজের reader schema-র সাথে মিলিয়ে পড়ে — ঠিক অংশ ৫-এর resolution. পরিচিত উদাহরণ Confluent-এর Schema Registry, যা সংস্করণভেদে Avro ছাড়াও Protobuf আর JSON Schema সমর্থন করে।

Compatibility mode আর deploy-এর ক্রম। Confluent-ধাঁচের registry-তে সাধারণত BACKWARD, FORWARD, FULL আর NONE mode থাকে (ডিফল্ট সাধারণত BACKWARD — নিজের সংস্করণের ডকুমেন্টেশন দেখুন), আর প্রতিটির সাথে একটি ক্রম জড়িত: BACKWARD-এ নতুন schema পুরোনো ডেটা পড়তে পারে, তাই আগে সব consumer বদলান, পরে producer; FORWARD-এ পুরোনো schema নতুন ডেটা পড়তে পারে, তাই producer আগে বদলানো যায়; FULL-এ যেকোনো ক্রম। “_TRANSITIVE” রূপগুলো শুধু ঠিক আগের সংস্করণ নয়, আগের সব সংস্করণের সাথে মেলায় — log-এ মাসের পর মাস পুরোনো বার্তা জমা থাকলে (পর্ব ৩৩, log-ভিত্তিক streaming) এটিই নিরাপদ। Protobuf-এ একই কাজ হয় CI-তে: প্রতিটি pull request-এ পুরোনো আর নতুন .proto তুলনা করে বেমানান পরিবর্তন আটকানো।

API-র payload আর version. REST বা gRPC API-তেও একই নিয়ম (পর্ব ৮-এর versioning অংশ)। v1-এর ভেতরে শুধু যোগ করুন — মুছবেন না, অর্থ বদলাবেন না। Client হোক tolerant reader: অচেনা field উপেক্ষা করে, field-এর ক্রমে নির্ভর করে না। ভাঙা পরিবর্তন অনিবার্য হলে নতুন version (/v2 বা নতুন message type) পাশাপাশি চালান, আর পুরোনোটি বন্ধের সময়সূচি জানান। Mobile app-এ এটি বিশেষ জরুরি, কারণ অনেকে মাসের পর মাস app হালনাগাদ করেন না।

Database migration — expand–contract. Database-এর কলামও একটি schema, আর সেখানে ডেটা থাকে বছরের পর বছর। amount (টাকা, দশমিক) থেকে amount_paisa (পূর্ণসংখ্যা)-তে যাওয়ার নিরাপদ পথ হলো expand–contract: (১) নতুন কলাম nullable হিসেবে যোগ (expand); (২) কোড দুই কলামেই লেখে; (৩) পুরোনো সারিগুলো backfill; (৪) পড়া শুরু নতুন কলাম থেকে; (৫) পুরোনো কলামে লেখা বন্ধ; (৬) সব পুরোনো কোড বিদায় নিলে পুরোনো কলাম মুছে ফেলা — এটাই contract ধাপ। প্রতিটি ধাপ আগের ধাপের কোডের সাথে মানানসই, তাই rolling deploy-এর মাঝেও কিছু ভাঙে না। Zero-downtime migration-এর পুরো কৌশল আসবে পর্ব ৫৩-এ (deployment আর release); ORM-এর migration টুল দেখুন DBMS ও SQL-এর ORM আর migration পর্বে। Event sourcing-এ (পর্ব ৩৫) event কখনো মোছা হয় না, তাই দশ বছর আগের event-ও আজকের কোডকে পড়তে হয় — সেখানে পরীক্ষা আরও কঠিন।

পরীক্ষা ও ইন্টারভিউতে: বিশ্ববিদ্যালয়, চাকরি বা ইন্টারভিউর পরীক্ষায় এ ধরনের প্রশ্ন প্রায়ই আসে —

  • Protobuf-এ field ২-এ মান ৩০০ কোন byte-এ যায়? — key 10, মান ac 02: মোট 10 ac 02.
  • Backward আর forward compatibility-র পার্থক্য? — backward: নতুন কোড পুরোনো ডেটা পড়ে; forward: পুরোনো কোড নতুন ডেটা পড়ে।
  • মুছে ফেলা field-এর number কেন আবার ব্যবহার করা যায় না? — পুরোনো ডেটা আর পুরোনো কোড সেটিকে আগের অর্থে পড়বে; reserved দিয়ে আটকান।
  • Kafka-ধাঁচের log-এ Avro ব্যবহারে schema registry কেন লাগে? — Avro byte-এ field-এর পরিচয় নেই; পড়তে writer schema লাগে, আর বার্তায় থাকে শুধু তার ID.
  • JSON API-তে ৬৪-bit ID কেন string? — JavaScript-এর double ২৫৩-এর ওপরে সব পূর্ণসংখ্যা হুবহু রাখতে পারে না।

৯ · AI-এর যুগে serialization আর schema

AI-কে “কোন format ভালো?” জিজ্ঞেস করলে সুন্দর তুলনা আসে, কিন্তু কিছু ভুল বারবার দেখা যায় — আর সেগুলো ধরা পড়ে সাধারণত production-এ, দেরিতে:

AI-এর ভুলকেমন দেখায়কীভাবে যাচাই করবেন
Backward আর forward উল্টে বলা“পুরোনো client নতুন field পড়তে পারলে সেটা backward”সংজ্ঞা মেলান: কে পড়ছে (নতুন না পুরোনো কোড), কার লেখা ডেটা
উৎসহীন গতির দাবি“Protobuf সবসময় JSON-এর চেয়ে দশ গুণ দ্রুত”নিজের payload আর লাইব্রেরিতে benchmark; আকার নিজে গুনুন
Field number-এর বিপদ ভুলে যাওয়াপুরোনো field মুছে একই number নতুন field-কে দেওয়াreserved আছে কি না; CI-তে breaking-change যাচাই
২৫৩-এর ফাঁদ এড়িয়ে যাওয়া৬৪-bit ID JSON-এ সংখ্যা হিসেবে9007199254740993 দিয়ে আসল client-এ পরীক্ষা
Avro নিয়ে ভুল ধারণা“Avro byte নিজেই নিজেকে বোঝায়”Writer schema ছাড়া decode করে দেখুন — পারবেন না
পণ্যের সীমা বা ডিফল্ট বানিয়ে বলাRegistry-র ডিফল্ট mode, বার্তার সর্বোচ্চ আকার বা managed সেবার দাম নিয়ে আত্মবিশ্বাসী সংখ্যানিজের সংস্করণের অফিশিয়াল ডকুমেন্টেশন
সব জায়গায় একই প্রেসক্রিপশন“Kafka, Avro, schema registry আর microservices যোগ করুন” — দিনে কয়েক হাজার বার্তার জন্যহিসাব করুন; সরল JSON আর ভালো versioning হয়তো যথেষ্ট (over-engineering এড়ান)
হিসাবের ভুল আর failure বাদBase64-এর ৩৩% বা UTF-8-এর ৩ byte ভুলে storage হিসাব; rollback-এর কথা নেইনিজে আবার হিসাব; “rollback হলে পুরোনো কোড কী পড়বে?” প্রশ্ন

আরেকটি পরিচিত গোলমাল: AI প্রায়ই ACID-এর consistency (নিয়ম ও constraint রক্ষা), CAP-এর consistency (সব কপি থেকে সর্বশেষ মান) আর schema-র compatibility গুলিয়ে ফেলে — তিনটি আলাদা ধারণা (পর্ব ২৪, ৪০)।

AI-কে schema-র কড়া reviewer বানান — খসড়া আপনার, প্রশ্ন AI-র:

তুমি একজন অভিজ্ঞ backend প্রকৌশলী আর কড়া schema reviewer. নিচে আমার Protobuf (বা Avro) schema-র পুরোনো আর নতুন সংস্করণ: [পুরোনো schema] [নতুন schema] ১. প্রতিটি পরিবর্তনের জন্য বলো: backward compatible কি না, forward compatible কি না। সংজ্ঞা স্পষ্ট লিখে নাও — backward মানে নতুন কোড পুরোনো ডেটা পড়ে। ২. কোনো field number আবার ব্যবহার হয়েছে কি না, type বা অর্থ বদলেছে কি না, reserved দরকার কি না দেখাও। ৩. Rolling deploy আর rollback-এর সময় কোন সংস্করণ কোন ডেটা পড়বে, ধাপে ধাপে লেখো; কোথায় ডেটা নিঃশব্দে হারাতে পারে বলো। ৪. JSON রূপে কোনো ৬৪-bit সংখ্যা থাকলে সতর্ক করো। ৫. গতি বা আকারের কোনো দাবি করলে সেটা কীভাবে মাপব বলো; কোনো টুলের ডিফল্ট বা সীমা বললে উৎস দাও, নিশ্চিত না হলে স্পষ্ট লেখো।

Schema বদলের আগে — চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১Protobuf-এর নির্মাতারা proto3 থেকে “required” কেন বাদ দিলেন? “Required is forever” কথাটির অর্থ কী?

proto2-তে field-কে required বলা যেত — বার্তায় না থাকলে parser সেটিকে অসম্পূর্ণ ধরে প্রত্যাখ্যান করত। শুনতে নিরাপদ: “id ছাড়া অর্ডার আসবেই না।” কিন্তু বছরের পর বছর ব্যবহারের পর Google-এর প্রকৌশলীরা দেখলেন, required একটি এক-দিকের দরজা। Protobuf-এর proto2 গাইডে স্পষ্ট সতর্কবার্তা আছে — “Required Is Forever”: একবার required করলে পরে আর সেটি পাঠানো বন্ধ করা যায় না, কারণ পুরোনো reader সেই field ছাড়া বার্তা পেলেই ফেলে দেবে বা ব্যর্থ হবে।

অংশ ৭-এর ভাষায়: required field মুছলে forward compatibility ভাঙে, যেমন checker-এর D-তে; আর নতুন required field যোগ করলে backward ভাঙে, যেমন B-তে। Rolling deploy-এর মাঝখানে, অথবা কোনো মধ্যবর্তী সার্ভিস যে শুধু বার্তা এগিয়ে দেয়, সেখানে একটি অনুপস্থিত required field পুরো পাইপলাইন আটকে দিতে পারে। তাই ২০১৬ সালে proto3 required পুরোপুরি বাদ দেয়। Google-এ Protobuf-এর দ্বিতীয় সংস্করণের (proto2) প্রধান লেখক Kenton Varda পরে Cap'n Proto বানানোর সময়ও একই সিদ্ধান্ত নেন, আর তার ডকুমেন্টেশনে required-কে ক্ষতিকর বলেই উল্লেখ করেন।

তাহলে “এই field অবশ্যই থাকতে হবে” নিয়মটি কোথায় যাবে? উত্তর: format-এর স্তরে নয়, application-এর যাচাইয়ে — যেখানে নিয়মটি সংস্করণভেদে বদলানো যায়, আর ব্যর্থ হলে পরিষ্কার error দেওয়া যায়। মজার ব্যাপার, পরে proto3-এ optional আবার যোগ হয় (২০২০–২১ সালের দিকে), কারণ “শূন্য” আর “পাঠানো হয়নি” আলাদা করার প্রয়োজন বাস্তব ছিল; আর সাম্প্রতিক “Editions” ব্যবস্থায় field presence আরও নমনীয়ভাবে ঠিক করা যায়। Avro-তেও একই দর্শন: default ছাড়া field কার্যত required, তাই নতুন field সবসময় default-সহ।

মূল উপলব্ধি: Format-এর কড়াকড়ি স্থায়ী, ব্যবসার নিয়ম অস্থায়ী। যে নিয়ম কাল বদলাতে পারে, তাকে wire format-এ খোদাই করবেন না।

প্র ০২১৯৯৬ সালে Ariane 5 রকেট একটি সংখ্যার type রূপান্তরে ধ্বংস হয়েছিল। Schema-র type বদলের সাথে এর মিল কোথায়?

১৯৯৬ সালের ৪ জুন ইউরোপের নতুন Ariane 5 রকেট প্রথম উড্ডয়নের প্রায় ৪০ সেকেন্ড পরে পথ হারিয়ে ধ্বংস হয়। তদন্ত বোর্ডের (Jacques-Louis Lions-এর নেতৃত্বে) প্রতিবেদনে কারণটি ছিল প্রায় অবিশ্বাস্য রকম ছোট: inertial reference system-এর সফটওয়্যার একটি ৬৪-bit floating-point মানকে ১৬-bit signed integer-এ রূপান্তর করছিল। Ariane 5-এর শুরুর পথে অনুভূমিক গতি আগের Ariane 4-এর চেয়ে অনেক বেশি, তাই সেই মান ১৬-bit-এর সীমা (৩২,৭৬৭) ছাড়িয়ে যায়; রূপান্তরে exception হয়, আর computer বন্ধ হয়ে যায়।

দুটি তথ্য গল্পটিকে আরও শিক্ষণীয় করে। প্রথমত, কোডটি Ariane 4 থেকে হুবহু আনা — সেখানে বছরের পর বছর নির্ভুল চলেছে, কারণ সেই রকেটের পথে মানটি কখনো এত বড় হতো না। কোড বদলায়নি, বদলেছিল ডেটার পরিসর। দ্বিতীয়ত, backup system-এ একই সফটওয়্যার ছিল, তাই সেটিও একই মুহূর্তে একই কারণে ব্যর্থ হয় — redundancy একই ভুলের দুটি কপি হয়ে দাঁড়ায় — পর্ব ৫-এ যাকে correlated failure বলা হয়েছে। আরও বিদ্রূপের কথা, ওই হিসাবটি উড্ডয়নের পরে দরকারই ছিল না।

Schema evolution-এ ঠিক এই ভয়টাই “type বদল”। একটি field int64 থেকে int32 করলে Protobuf byte-এর স্তরে আপত্তি করে না — দুটোই varint. কিন্তু পুরোনো ডেটায় ২৩১-এর বড় মান থাকলে নতুন কোড সেটিকে কেটে ছোট করবে, কোনো error ছাড়াই। পয়সার অঙ্কে এর অর্থ হতে পারে কোটি টাকার লেনদেন হঠাৎ ঋণাত্মক। JSON-এর ২৫৩ সমস্যাও একই পরিবারের — একটি পাত্রের চেয়ে বড় মান ছোট পাত্রে ঢালা।

মূল উপলব্ধি: “এই কোড তো বছরের পর বছর ঠিক চলছে” — কথাটি শুধু পুরোনো ডেটার পরিসরের জন্য সত্য। Type সংকুচিত করা বা পুরোনো কোড নতুন প্রেক্ষাপটে বসানোর আগে আসল ডেটার সর্বোচ্চ মান মেপে দেখুন।

প্র ০৩Y2K আর ২০৩৮ সমস্যা — একটি field কত চওড়া হবে, সেই সিদ্ধান্ত কেন দশক পরে বিল পাঠায়?

১৯৬০–৭০-এর দশকে memory আর storage ছিল খুব দামি। তাই অনেক সিস্টেমে বছর রাখা হতো দুই অঙ্কে — “৭৫” মানে ১৯৭৫. এটি একটি schema-সিদ্ধান্ত, আর তখন যুক্তিসংগতও। সমস্যা হলো ডেটা আর কোড দুটোই প্রত্যাশার চেয়ে অনেক বেশি দিন বাঁচল। ২০০০ সাল এগিয়ে এলে “০০” কি ১৯০০ না ২০০০ — এই প্রশ্নে সুদের হিসাব, বয়স, মেয়াদ, সবকিছুতে ভুলের আশঙ্কা দেখা দিল। বিশ্বজুড়ে বছরের পর বছর ধরে পুরোনো কোড খুঁজে খুঁজে সংশোধনে বিপুল অর্থ আর শ্রম খরচ হয়।

সমাধানের দুটি পথ ছিল, আর দুটিই আজকের schema evolution-এর ভাষায় চেনা। একটি “field expansion” — বছরকে চার অঙ্ক করা; এতে সব ডেটা নতুন format-এ রূপান্তর আর ওই field পড়া প্রতিটি প্রোগ্রাম বদলাতে হয় — expand–contract-এর মতো, আর ধীর। আরেকটি “windowing” — ধরে নেওয়া যে ০০–৪৯ মানে ২০০০-এর দশক, ৫০–৯৯ মানে ১৯০০-এর; format না বদলে অর্থের নিয়ম বদলানো, যা দ্রুত কিন্তু সমস্যাকে ভবিষ্যতে ঠেলে দেয়।

পরের ঘণ্টা ইতিমধ্যে বাজছে: অনেক সিস্টেম সময় রাখে ১৯৭০ সালের ১ জানুয়ারি থেকে গোনা সেকেন্ডে, ৩২-bit signed integer-এ। সেই সংখ্যা ২০৩৮ সালের ১৯ জানুয়ারি ০৩:১৪:০৭ UTC-তে সর্বোচ্চ মান ছাড়িয়ে যাবে। আধুনিক অপারেটিং সিস্টেম ৬৪-bit সময়ে চলে গেছে, কিন্তু file format, database কলাম, embedded যন্ত্র আর network protocol-এর ভেতরে ৩২-bit সময় লুকিয়ে থাকতে পারে — আর সেগুলোই কঠিন, কারণ সেখানে ডেটা আর অন্য সিস্টেম জড়িত।

মূল উপলব্ধি: Field-এর প্রস্থ, একক আর অর্থ — এগুলো সস্তায় ঠিক করা যায় শুধু প্রথম দিনে। পরে বদলাতে হলে লাগে সব ডেটা আর সব পাঠকের সমন্বিত migration. তাই schema-য় একটু বাড়তি জায়গা আর স্পষ্ট একক রাখাই সস্তা বিমা।

প্র ০৪Java-র Serializable বা Python-এর pickle এত সুবিধাজনক — তাহলে সার্ভিসের সীমানায় এগুলো এড়িয়ে চলার পরামর্শ কেন?

সুবিধাটা সত্যি: এক লাইনে যেকোনো object — ভেতরের list, map, অন্য object-এর reference — byte হয়ে যায়, আরেক লাইনে ফিরে আসে, schema লাগে না। সমস্যা তিনটি, আর তৃতীয়টি গুরুতর।

প্রথমত, ভাষার বন্দিত্ব: Java-র serialized byte Python পড়তে পারে না; একটি সার্ভিস অন্য ভাষায় লিখতে চাইলেই আটকে যাবেন। দ্বিতীয়ত, ভঙ্গুর বিবর্তন: class-এ একটি field যোগ বা class-এর নাম বদলালেই পুরোনো byte পড়া কঠিন হয়ে পড়ে। তৃতীয়ত, নিরাপত্তা: এসব format শুধু ডেটা নয়, “কোন class-এর object বানাতে হবে” সেই নির্দেশও বহন করে। অবিশ্বস্ত উৎসের byte পড়া মানে বাইরের কাউকে আপনার প্রোগ্রামের ভেতরে object তৈরির নির্দেশ দিতে দেওয়া — আর কিছু লাইব্রেরির class সেই তৈরির সময়ই এমন কাজ করে, যা ঘটা উচিত নয়।

২০১৫ সালে Chris Frohoff ও Gabriel Lawrence-এর একটি নিরাপত্তা-উপস্থাপনার পর Java-র জনপ্রিয় বহু server সফটওয়্যারে এ ধরনের দুর্বলতা ধরা পড়ে, আর বছরজুড়ে patch আসতে থাকে। OWASP Top 10-এর ২০১৭ সালের তালিকায় “Insecure Deserialization” আলাদা ঝুঁকি হিসেবে জায়গা পায় (২০২১-এ এটি একটি বড় শ্রেণির অংশ হয়)। প্রতিরক্ষা হিসেবে Java-তে JEP 290 নামে deserialization filter আসে — কোন class পড়া যাবে তার অনুমোদিত তালিকা। Python-এর ডকুমেন্টেশন সরাসরি লেখে, pickle নিরাপদ নয়, শুধু বিশ্বস্ত ডেটা unpickle করুন; YAML লাইব্রেরিতেও এজন্য আলাদা “safe” লোড ফাংশন থাকে।

JSON, Protobuf বা Avro-র মতো শুধু-ডেটার format-এ parser কেবল সংখ্যা, লেখা আর তালিকা বানায়; কোন class তৈরি হবে তা ঠিক করে আপনার কোড, বাইরের byte নয়। এর পরেও প্রতিটি input-এর আকার সীমিত রাখা আর schema দিয়ে যাচাই করা দরকার।

মূল উপলব্ধি: সার্ভিসের সীমানায় ডেটা শুধু ডেটাই থাকুক। যে format বাইরের byte-কে “কী বানাতে হবে” বলার ক্ষমতা দেয়, তা শুধু পুরোপুরি বিশ্বস্ত সীমার ভেতরে ব্যবহার করুন।

প্র ০৫“যা পাঠাবেন তাতে কড়া, যা নেবেন তাতে উদার” — Postel-এর নীতি, tolerant reader আর Hyrum-এর সূত্র: উদারতার সীমা কোথায়?

১৯৮০ সালে TCP-র নির্দিষ্টকরণে (RFC 761) Jon Postel লেখেন: নিজে যা করবেন তাতে রক্ষণশীল হোন, অন্যদের কাছ থেকে যা নেবেন তাতে উদার। এটি “robustness principle” নামে পরিচিত, আর ইন্টারনেটের শুরুর দিনে ভিন্ন ভিন্ন implementation-কে একসাথে চালাতে বড় ভূমিকা রাখে। Schema evolution-এ এর ভালো দিকটি হলো tolerant reader — Martin Fowler ২০১১ সালে নামটি জনপ্রিয় করেন: অচেনা field উপেক্ষা করুন, শুধু দরকারি অংশ পড়ুন, field-এর ক্রমে নির্ভর করবেন না। Protobuf-এর unknown field ডিঙানো ঠিক এই নীতি।

কিন্তু উদারতার একটি অন্ধকার দিক আছে। কোনো parser যদি ভাঙা বা অস্পষ্ট input-ও “বুঝে নেয়” — অনুপস্থিত উদ্ধৃতিচিহ্ন, একই key দুবার — তাহলে প্রেরকেরা নিজেদের ভুল আর শোধরায় না, আর অন্য implementation-গুলোকেও সেই ভুল মেনে নিতে বাধ্য হতে হয়। দুই parser একই অস্পষ্ট input-কে দুই রকম অর্থ করলে নিরাপত্তা-ছিদ্রও তৈরি হয় — যেমন JSON-এ একই key দুবার থাকলে কোনটি নেওয়া হবে, নিয়মে বাধ্যতামূলকভাবে ঠিক করা নেই। ২০২৩ সালে প্রকাশিত RFC 9413 (“Maintaining Robust Protocols”) এই অভিজ্ঞতা থেকে যুক্তি দেয়, অন্ধ উদারতা দীর্ঘমেয়াদে protocol-কে দুর্বল আর অনমনীয় করে।

অন্য দিক থেকে আসে Hyrum-এর সূত্র (Google-এর প্রকৌশলী Hyrum Wright-এর নামে প্রচলিত): API-র ব্যবহারকারী যথেষ্ট বেশি হলে চুক্তিতে যা-ই লিখুন, প্রতিটি দৃশ্যমান আচরণের ওপর কেউ না কেউ নির্ভর করবেন। আপনি হয়তো কখনো বলেননি JSON-এর field নির্দিষ্ট ক্রমে আসবে, কিন্তু কোনো client সেই ক্রম ধরে নিয়েছে — ক্রম বদলালেই তার কোড ভাঙবে।

তাহলে ভারসাম্য কোথায়? অচেনা কিন্তু সুগঠিত জিনিসে উদার হোন — নতুন field, নতুন enum মান সহ্য করুন। ভাঙা বা অস্পষ্ট জিনিসে কড়া হোন — ভুল syntax, সীমার বাইরে মান, দ্বৈত key প্রত্যাখ্যান করুন। আর চুক্তির বাইরের আচরণ মাঝে মাঝে ইচ্ছা করে বদলে দিন, যাতে কেউ তার ওপর নির্ভর করতে না শেখে।

মূল উপলব্ধি: নতুনের প্রতি উদার, ভুলের প্রতি কড়া। Compatibility মানে সব কিছু মেনে নেওয়া নয় — স্পষ্ট চুক্তির ভেতরে বিবর্তনের জায়গা রাখা।

অনুশীলন

আগে খাতায় নিজে করুন, তারপর উত্তরের সাথে মেলান।

  1. হাতে varint: (ক) Protobuf-এ uint32 count = 2; field-এ মান ৩০০ কোন byte-এ যাবে? (খ) একটি বার্তায় পেলেন 18 ff 01 — কোন field, কোন wire type, মান কত? (গ) sint32 delta = 1;-এ মান −৩ হলে byte কী?

    (ক) Key = ২ × ৮ + ০ = ১৬ = 0x10. ৩০০ = ২৫৬ + ৩২ + ৮ + ৪ = 1 0010 1100, অর্থাৎ ৯ bit-এর সংখ্যা। ৭ bit করে, কম গুরুত্বপূর্ণ দিক থেকে: নিচের দল 0101100 (= ৪৪ = 0x2c), উঁচু দল 0000010 (= ২)। প্রথম byte-এ continuation bit: 1·0101100 = 0xac; শেষ byte 0·0000010 = 0x02. উত্তর: 10 ac 02.

    (খ) 0x18 = ২৪ → field ২৪ ÷ ৮ = ৩, wire type ২৪ mod ৮ = ০, অর্থাৎ VARINT. ff-এর উঁচু bit ১, নিচের ৭ bit = ১২৭; 01-এর উঁচু bit ০, মান ১ × ১২৮. যোগফল ১২৭ + ১২৮ = ২৫৫. অর্থাৎ field ৩ = ২৫৫.

    (গ) ZigZag: ঋণাত্মক n-এর জন্য ২|n| − ১ = ২ × ৩ − ১ = ৫. Key = ১ × ৮ + ০ = 08, মান 05: উত্তর 08 05 — দুই byte; সাধারণ int32-এ লাগত key-সহ ১১ byte.

  2. পরিবর্তন যাচাই — নিরাপদ না বিপজ্জনক: নিচের proto3 message-এ ছয়টি প্রস্তাব এসেছে। প্রতিটি বিচার করুন, কারণসহ।
    message Payment {
      string payment_id   = 1;
      int64  amount_paisa = 2;
      string currency     = 3;
      string note         = 4;
    }
    (ক) string channel = 5; যোগ; (খ) note মুছে ৪ নম্বর দেওয়া int64 fee_paisa-কে; (গ) amount_paisa-এর নাম বদলে amount; (ঘ) currency-কে int32 currency_code = 3 করা; (ঙ) note মুছে reserved 4; reserved "note";; (চ) amount_paisa-কে int64 থেকে int32 করা।
    • (ক) নিরাপদ। নতুন number, proto3-এ অনুপস্থিত মানে খালি string — পুরোনো কোড ডিঙায়, নতুন কোড পুরোনো ডেটায় খালি পায়। Full compatible.
    • (খ) বিপজ্জনক। পুরোনো ডেটায় field ৪ হলো LEN (string), নতুন কোড আশা করে VARINT — fee-র মান পাবে না (অনেক parser এমন বেমানান field-কে অচেনা ধরে সরিয়ে রাখে)। উল্টো দিকে পুরোনো কোড ৪ নম্বরে string আশা করে VARINT পাবে, note খালি দেখাবে। নতুন field-টিও string হলে আরও খারাপ — কোনো parser আপত্তিই করত না। নতুন number নিন, আর ৪ reserved করুন।
    • (গ) শর্তসাপেক্ষে নিরাপদ। Binary-তে নাম যায় না, তাই byte ঠিক থাকে। কিন্তু JSON রূপে key বদলায় (amountPaisa থেকে amount), আর কোডের নামও বদলায়। আরও বড় সমস্যা: এককটি নাম থেকে হারিয়ে গেল — কেউ টাকা ভেবে পড়বে। এই নাম বদল না করাই ভালো।
    • (ঘ) বিপজ্জনক। string (LEN) থেকে int32 (VARINT) — বেমানান type; পুরোনো ডেটার "BDT" নতুন কোড পড়তে পারবে না। নতুন field (যেমন ৬) যোগ করে কিছুদিন দুটোই লিখুন — expand–contract.
    • (ঙ) নিরাপদ। Optional field মুছে ফেলা, আর number ও নাম reserved — ঠিক এটাই নিয়ম। পুরোনো কোড note না পেলে খালি string ধরে।
    • (চ) byte-এ চলে, অর্থে বিপজ্জনক। int64 আর int32 দুটোই varint, তাই parse হবে; কিন্তু ২৩১ − ১ পয়সার (প্রায় ২ কোটি ১৪ লাখ টাকা) চেয়ে বড় পুরোনো মান নতুন কোডে কেটে ভুল সংখ্যা হবে, এমনকি ঋণাত্মকও — কোনো error ছাড়াই।
  3. হিসাব — বাংলা লেখা আর Base64: একটি chat অ্যাপে (কাল্পনিক) দিনে ২ কোটি বার্তা যায়। ধরা যাক প্রতিটি বার্তায় গড়ে ৬০টি বাংলা code point, আর ১০% বার্তায় ২৪ KiB-এর (২৪,৫৭৬ byte) একটি sticker ছবি, যা JSON-এর ভেতরে Base64 করে পাঠানো হয়। (ক) লেখার অংশ UTF-8-এ দিনে কত, আর \uXXXX escape-সহ কত? (খ) Sticker-এর Base64 দিনে কত বাড়তি byte যোগ করে?

    (ক) UTF-8-এ বার্তাপ্রতি ৬০ × ৩ = ১৮০ byte; দিনে ২ × ১০৭ × ১৮০ = ৩.৬ × ১০৯ byte = ৩.৬ GB. Escape-সহ বার্তাপ্রতি ৬০ × ৬ = ৩৬০ byte, দিনে ৭.২ GB. বছরে প্রায় ১.৩১ TB বনাম ২.৬৩ TB — একটি সেটিংয়ে দ্বিগুণ, replication-এর আগেই।

    (খ) Sticker-সহ বার্তা দিনে ২ কোটির ১০% = ২০ লাখ। একটির Base64 দৈর্ঘ্য ৪ × ⌈২৪,৫৭৬ ÷ ৩⌉ = ৪ × ৮,১৯২ = ৩২,৭৬৮ byte, অর্থাৎ বাড়তি ৮,১৯২ byte. দিনে মূল ছবি ২০ লাখ × ২৪,৫৭৬ ≈ ৪৯.১৫ GB, Base64-এ ≈ ৬৫.৫৪ GB — বাড়তি প্রায় ১৬.৩৮ GB প্রতিদিন. ভালো নকশা: sticker একবার object storage বা CDN-এ রাখুন, বার্তায় শুধু তার ID পাঠান — তখন বার্তাপ্রতি খরচ কয়েক byte.

  4. আউটপুট অনুমান করুন: অংশ ৭-এর schema_checker.js-এর শেষে নিচের লাইনটি যোগ করে চালালে ঠিক কোন চারটি লাইন ছাপা হবে? আগে লিখে ফেলুন, তারপর চালিয়ে মেলান।
    check("G. make #2 name required", v1, [v1[0], F(2, "name", "string", true), v1[2]]);
    G. make #2 name required
      backward: NO  #2 name is required here but was optional for the writer
      forward:  yes no conflict
      => FORWARD only

    Backward-এ নতুন schema reader: field ২ এখন required, কিন্তু পুরোনো writer-এর কাছে সেটি optional ছিল — পুরোনো ডেটায় না-ও থাকতে পারে, তাই NO. Forward-এ পুরোনো schema reader: তার কাছে field ২ optional, আর নতুন writer সবসময় পাঠায় — কোনো সংঘাত নেই, তাই checker-এর নোটের তালিকা খালি, আর ছাপে no conflict. রায়: FORWARD only — optional-কে required করা পুরোনো ডেটাকে অপাঠ্য করে তোলে।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি database কলাম amount থেকে amount_paisa-তে নিরাপদে নেওয়ার (expand–contract) ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. নতুন কলাম amount_paisa nullable হিসেবে যোগ
  2. নতুন কোড দুই কলামেই লেখে
  3. পুরোনো সারিগুলোতে amount_paisa backfill
  4. পড়া শুরু হয় নতুন কলাম থেকে
  5. পুরোনো কলামে লেখা বন্ধ
  6. সব পুরোনো কোড বিদায় নিলে পুরোনো কলাম মুছে ফেলা

Protobuf-এ int64 id = 1; field-এর মান ১৫০ হলে byte-এ কী লেখা হয়?

  • 96 01
  • 08 96 01
  • 08 01 96
  • 01 96 08

Rolling deploy চলছে: কিছু সার্ভার v1, কিছু v2. v1 সার্ভারগুলো v2-এর লেখা record পড়ছে। এখানে কোন বৈশিষ্ট্যটি দরকার?

  • Backward compatibility
  • ACID consistency
  • Forward compatibility
  • Linearizability

একটি browser-এর JavaScript JSON.parse('{"id": 9007199254740993}') চালাল। id-এর মান কী হবে?

  • 9007199254740993
  • 9007199254740992
  • SyntaxError
  • "9007199254740993" (string)

৩০,০০,০০০ byte-এর একটি ফাইল Base64-এ বদলালে কতটি অক্ষর হবে?

  • ২২,৫০,০০০
  • ৩০,০০,০০০
  • ৪০,০০,০০০
  • ৪৫,০০,০০০

Avro-তে reader schema-য় phone নামে একটি field আছে, যার কোনো default নেই; writer schema-য় এই field নেই। পড়ার সময় কী হয়?

  • phone-এ নিঃশব্দে null বসে
  • Field-টি বাদ দিয়ে বাকি record পড়া হয়
  • phone-এ খালি string বসে
  • Schema resolution error হয়

একটি Protobuf message থেকে string note = 4; মুছে ফেলা হয়েছে। ভবিষ্যতে কেউ যাতে ৪ নম্বরটি অন্য অর্থে ব্যবহার করতে না পারে, তার সবচেয়ে ভালো উপায় কোনটি?

  • reserved 4; reserved "note"; লিখে রাখা
  • Schema-র ওপরে একটি comment লিখে রাখা
  • নতুন field-কে optional দিয়ে ৪ নম্বরে বসানো
  • শুধু field-এর নাম বদলে note_old রাখা

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Format-এর চার দাম: network-এর byte, CPU, storage আর compatibility — শেষেরটিই সবচেয়ে বড়, কারণ ডেটা কোডের চেয়ে বেশি দিন বাঁচে।
  2. JSON-এ বড় ID string হিসেবে, টাকা পূর্ণসংখ্যা পয়সায়; Base64 মানে ৪⌈n/৩⌉, প্রায় ৩৩% বাড়তি; বাংলা code point UTF-8-এ ৩ byte.
  3. Protobuf: key = field number × ৮ + wire type, সংখ্যা varint-এ, অচেনা field ডিঙানো যায়। Avro: tag নেই, writer schema ছাড়া পড়া যায় না, মেলানো হয় নাম ধরে।
  4. Backward = নতুন কোড, পুরোনো ডেটা; forward = পুরোনো কোড, নতুন ডেটা। Rolling deploy আর rollback-এ দুটোই লাগে।
  5. যোগ করুন default-সহ, মুছুন reserved করে, কখনো number আবার ব্যবহার নয়; registry, CI-যাচাই আর expand–contract দিয়ে নিয়মকে যন্ত্রের পাহারায় রাখুন।

পরের পর্বে: মডিউল ২ শেষ — সার্ভিসগুলো কীভাবে কথা বলে, কোন ভাষায় বলে, আর সেই ভাষা কীভাবে নিরাপদে বদলায়, এখন আপনার জানা। মডিউল ৩ শুরু হবে পর্ব ১৩ দিয়ে: vertical বনাম horizontal scaling — stateless সার্ভিস আর autoscaling. একটি সার্ভার থেকে হাজার সার্ভারে যাওয়ার পথ।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান