Chapter 3 was about how one process stores data. Chapter 4 is about what happens when two versions
of your code exist at the same time — because in a large application, code changes cannot happen
instantaneously. That single fact generates the whole chapter: two directions of compatibility, a
handful of encoding formats judged by how well they hold those directions, and three modes of
dataflow in which the question keeps recurring.
The premise§ Chapter opening
Old and new may all coexist at the same time
Applications inevitably change: features are added or modified as new products launch,
requirements become better understood, or business circumstances change. Chapter 1 called the goal
evolvability. And in most cases a change to an application’s features also requires a change
to the data it stores — a new field or record type to capture, or existing data to present in a
new way.
The data models of chapter 2 cope with that differently. Relational databases generally assume all
data conforms to one schema: it can be changed through migrations, ALTER statements, but there
is exactly one schema in force at any point in time. Schema-on-read databases don’t enforce one, so
the database can contain a mixture of older and newer data formats written at different times.
When the format changes, application code usually has to change too. But code changes can’t happen
instantaneously either.
Server-side
You may want a rolling upgrade, also known as a staged rollout: deploy the new version to a
few nodes at a time, check it’s running smoothly, and gradually work through all the nodes. That
allows deployment without service downtime, which encourages more frequent releases and
better evolvability.
Client-side
You’re at the mercy of the user, who may not install the update for some time. There is no
equivalent of the deploy button, and no upper bound on how long the old version stays in
service.
A rolling upgrade, and the window it opens
one service, 6 nodes, upgraded a few at a time
v1v2v1v2v1v2v1v2v1v2v1v2
the coexistence window
new code writesrecordold code readsforward compatibility
old code writesrecordnew code readsbackward compatibility
Neither lane is hypothetical while the wave is crossing. Both are running, over the same data,
in the same cluster.
Backward compatibility
Newer code can read data that was written by older code. Normally not hard to achieve: as
author of the newer code you know the format written by the older code, so you can
explicitly handle it — if necessary by simply keeping the old code around to read the old data.
Forward compatibility
Older code can read data that was written by newer code. Trickier, because it requires
older code to ignore additions made by a newer version of the code. The old code has no way
of knowing what’s coming.
Both definitions name an old thing and a new thing, which is exactly why they are so easy to get the
wrong way round. Choosing the two ends is more reliable than parsing the sentence.
Which one do you need? Pick a writer and a reader
read by old coderead by new codewritten by old codewritten by new code
Backward compatibility
old codea recordnew code
Newer code can read data that was written by older code. This is normally not hard to achieve: as author of the newer code you know the format written by the older code, so you can explicitly handle it — if necessary by simply keeping the old code around to read the old data.
In a database it is unavoidable even with a single process, because the reader is just a later version of the same process: storing something is sending a message to your future self. For RPC this is the request direction, given the assumption that all servers are updated before any client.
Forward compatibility
new codea recordold code
Older code can read data that was written by newer code. Trickier, because it requires older code to ignore additions made by a newer version of the code — and the old code has no way of knowing what those additions will be. The format has to carry enough information for a reader to skip what it does not recognise.
In a database this is often required, because a rolling upgrade means some instances are still running the old code. For RPC it is the response direction.
Neither — and this is the state you are leaving
old codea recordold code
The pre-upgrade world: one code version, one data format, no compatibility question. The whole chapter is about the fact that you cannot stay here, because applications inevitably change and code changes cannot happen instantaneously.
Neither — but this is not where your data is
new codea recordnew code
The post-upgrade world, for code. Note the trap: even when every node runs the new version, the five-year-old records are still there in their original encoding unless you explicitly rewrote them. Data outlives code. The backward-compatibility requirement does not retire when the rollout finishes.
Half one§ Formats for Encoding Data
Two representations, and the translation between them
Programs usually work with data in at least two different representations. In memory it is
objects, structs, lists, arrays, hash tables and trees — structures optimized for efficient access
and manipulation by the CPU, typically using pointers. On disk or on the wire it has to be a
self-contained sequence of bytes, and since a pointer wouldn’t make sense to any other process,
that representation looks quite different.
Encoding is also called serialization or marshalling; decoding is parsing, deserialization,
unmarshalling. Neither has anything to do with encryption.
Encoding is the translation from the in-memory representation to a byte sequence — also known as
serialization or marshalling. Decoding is the reverse: parsing, deserialization,
unmarshalling.
Terminology clash.Serialization is also used in the context of transactions, with a
completely different meaning, so the book sticks with encoding even though serialization is
perhaps the more common term. And a separate warning worth keeping: encoding has nothing to do
with encryption. The in-memory/byte-sequence split has exceptions too, such as certain
memory-mapped files, or operating directly on compressed data as in column compression.
Language-specific formats, convenient and a bad idea
Many languages ship built-in support for encoding in-memory objects: Java has
java.io.Serializable, Ruby has Marshal, Python has pickle. Third-party libraries exist too,
such as Kryo for Java. They’re very convenient — in-memory objects saved and restored with minimal
extra code — and they have a number of deep problems.
Language lock-inThe encoding is often tied to a particular programming language, and reading the data in another language is very difficult. Store or transmit data in such an encoding and you are committing yourself to your current language for potentially a very long time, precluding integrating your systems with those of other organizations, which may use different languages.
SecurityTo restore data in the same object types, the decoding process needs to be able to instantiate arbitrary classes. This is frequently a source of security problems: if an attacker can get your application to decode an arbitrary byte sequence, they can instantiate arbitrary classes, which often allows them to do terrible things such as remotely executing arbitrary code.
Versioning as an afterthoughtVersioning data is often an afterthought in these libraries. Being intended for quick and easy encoding, they often neglect the inconvenient problems of forward and backward compatibility.
Efficiency as an afterthoughtBoth the CPU time taken to encode or decode and the size of the encoded structure are often afterthoughts. Java’s built-in serialization is notorious for its bad performance and bloated encoding.
Conclusion: it’s generally a bad idea to use your language’s built-in encoding for anything
other than very transient purposes.
The lowest common denominator§ JSON, XML, and Binary Variants
Widely known, widely supported, almost as widely disliked
Moving to standardized encodings readable by many languages, JSON and XML are the obvious
contenders. XML is often criticized for being too verbose and unnecessarily complicated.
JSON’s popularity is mainly due to built-in support in web browsers, by virtue of being a subset
of JavaScript, and simplicity relative to XML. CSV is another popular language-independent format,
albeit less powerful.
All three are textual and thus somewhat human-readable, though the syntax is a popular topic of
debate. Beyond the superficial syntactic issues, they have subtle problems.
Number ambiguityIn XML and CSV you cannot distinguish a number from a string that happens to consist of digits, except by referring to an external schema. JSON does distinguish strings and numbers, but it does not distinguish integers from floating-point numbers, and it specifies no precision.
That bites with large numbers. Integers greater than 253 cannot be exactly represented in an IEEE 754 double, so they become inaccurate when parsed in a language that uses floating point — such as JavaScript. Twitter uses a 64-bit number to identify each tweet, and the JSON returned by Twitter’s API includes tweet IDs twice, once as a JSON number and once as a decimal string, to work around the fact that the numbers are not correctly parsed by JavaScript applications.
No binary stringsJSON and XML have good support for Unicode character strings — human-readable text — but they do not support binary strings: sequences of bytes without a character encoding. Binary strings are a useful feature, so people get around this by encoding binary data as text using Base64, with the schema indicating that the value should be interpreted that way. It works, but it is somewhat hacky and increases the data size by 33%.
Optional schemasThere is optional schema support for both XML and JSON. These schema languages are quite powerful, and thus quite complicated to learn and implement. Use of XML schemas is fairly widespread; many JSON-based tools do not bother with schemas. Since the correct interpretation of data — numbers, binary strings — depends on information in the schema, applications that do not use them need to potentially hardcode the appropriate encoding and decoding logic instead.
CSV is worseCSV has no schema at all, so it is up to the application to define the meaning of each row and column. If an application change adds a new row or column, you have to handle that change manually. CSV is also a quite vague format — what happens if a value contains a comma or a newline? Its escaping rules have been formally specified, but not all parsers implement them correctly.
The 2⁵³ problem, performed rather than described
Integers greater than 253 cannot be exactly represented in an IEEE 754 double-precision
floating-point number, so they become inaccurate when parsed in a language that uses floating-point
numbers — such as JavaScript. This page is running JavaScript, so the demonstration below is not an
illustration: the digits really do go through JSON.parse, and what comes back is what came back.
as a JSON string
parsed as a JSON number
equal?
"9007199254740991"
9007199254740991
yes
Survived16 digits, and this value is inside the exactly-representable range, so the round trip through JSON.parse is lossless. Push it one higher.
Despite these flaws, JSON, XML and CSV are good enough for many purposes, and will likely remain
popular, especially as data interchange formats — sending data from one organization to another.
In those situations, as long as people agree on what the format is, it often doesn’t matter how
pretty or efficient it is: the difficulty of getting different organizations to agree on anything
outweighs most other concerns.
Binary encodings of JSON, and why they barely help
For data used only internally there’s less pressure to use a lowest-common-denominator format, so you
could pick something more compact or faster to parse. For a small dataset the gains are negligible,
but once you get into the terabytes, the choice of data format can have a big impact. That
observation led to a profusion of binary encodings for JSON — MessagePack, BSON, BJSON, UBJSON,
BISON, Smile — and for XML, such as WBXML and Fast Infoset. None is as widely adopted as the textual
versions.
Some extend the set of datatypes, for instance distinguishing integers from floats or adding binary
strings, but they otherwise keep the JSON data model unchanged. Crucially, since they don’t
prescribe a schema, they need to include all the object field names within the encoded data.
The exhibit the chapter turns on§ one record, six encodings
Where the bytes actually go
The rest of the first half is a table of six byte counts, and a table is the one form in which
those numbers have to be taken on trust. So the six encodings below are real: one small record,
six encoders, every byte on screen, and every total summed from the bytes above it. Click a group
to find out what it is for, and switch encoding to watch the same record lose its field names.
The object opensOne byte of punctuation. Textual formats pay for structure in characters, and every one of them is a byte on the wire.
The field name, spelled outAll 8 characters of userName are physically in the record, plus two quotes and a colon. JSON has no schema, so it has no choice — a reader with no external description of the data can only find a field by reading its name.
The value6 bytes of payload wrapped in two bytes of quoting. Hold on to the payload number: every one of the six encodings spends almost exactly the same bytes here, and differs everywhere else.
A separatorOne more byte of punctuation between fields.
The field name, spelled outAll 14 characters of favoriteNumber are physically in the record, plus two quotes and a colon. JSON has no schema, so it has no choice — a reader with no external description of the data can only find a field by reading its name.
A number, written out in decimalFour characters. JSON distinguishes strings from numbers but not integers from floating-point numbers, and it specifies no precision — which is the flaw the 253 exhibit above is about. The binary formats spend two bytes here.
A separatorOne more byte of punctuation between fields.
The field name, spelled outAll 9 characters of interests are physically in the record, plus two quotes and a colon. JSON has no schema, so it has no choice — a reader with no external description of the data can only find a field by reading its name.
The listBrackets, quotes and a comma around two string values. Readable by anything, at a price in bytes that nothing else here pays.
The object closesAnd that is the baseline.
MessagePack · 66 bytes · self-describing, no schema
The object header0x83 says what follows is an object (top four bits 0x80) with 3 fields (bottom four bits 0x03). If an object had more than 15 fields the count would not fit in four bits, so it gets a different type indicator and a count in two or four bytes.
A field name, in binary but still spelled out0xa8 marks a string (top four bits 0xa0) of 8 bytes (bottom four), then the 8 bytes themselves. Because the length came first there is no terminator and no escaping. And here is the point that decides the rest of the chapter: the field name is still physically present in the record.
A string value0xa6 marks a string (top four bits 0xa0) of 6 bytes (bottom four), then the 6 bytes themselves. Because the length came first there is no terminator and no escaping. Identical payload to the JSON encoding, two bytes of quoting cheaper.
A field name, in binary but still spelled out0xae marks a string (top four bits 0xa0) of 14 bytes (bottom four), then the 14 bytes themselves. Because the length came first there is no terminator and no escaping. Fourteen bytes to say which field this is, in a format advertised as compact.
The number, as a 16-bit integer0xcd announces a two-byte unsigned integer, then 0x05 0x39. Two bytes of payload where the textual encoding spent four — and unlike JSON, the type is unambiguous.
A field name, in binary but still spelled out0xa9 marks a string (top four bits 0xa0) of 9 bytes (bottom four), then the 9 bytes themselves. Because the length came first there is no terminator and no escaping. Nine more.
The list header0x92: an array of 2 elements. Same trick as the object header.
A string value0xab marks a string (top four bits 0xa0) of 11 bytes (bottom four), then the 11 bytes themselves. Because the length came first there is no terminator and no escaping. Payload, again unchanged.
A string value0xa7 marks a string (top four bits 0xa0) of 7 bytes (bottom four), then the 7 bytes themselves. Because the length came first there is no terminator and no escaping. Payload, again unchanged.
Thrift BinaryProtocol · 59 bytes · schema required to decode
A type annotation and a field tagOne byte of type (0x0b, string) and two bytes of tag (1). The field name is gone. The tag is an alias for userName — a compact way of saying which field we mean without spelling out the name — and the type annotation is what lets a reader that does not recognise the tag work out how many bytes to skip.
A four-byte lengthBinaryProtocol writes every length as a full 32-bit integer, even to say six. CompactProtocol is the same information with this generosity removed.
The valueUTF-8 bytes, exactly as in every other encoding here.
A type annotation and a field tagOne byte of type (0x0a, i64) and two bytes of tag (2). The field name is gone. The tag is an alias for favoriteNumber — a compact way of saying which field we mean without spelling out the name — and the type annotation is what lets a reader that does not recognise the tag work out how many bytes to skip.
The number, in eight bytesThe schema said i64, so an i64 is what goes on the wire: fifty-three leading zero bits to carry 1337. Variable-length integers exist to fix precisely this.
A type annotation and a field tagOne byte of type (0x0f, list) and two bytes of tag (3). The field name is gone. The tag is an alias for interests — a compact way of saying which field we mean without spelling out the name — and the type annotation is what lets a reader that does not recognise the tag work out how many bytes to skip.
The element type and the element countThrift has a dedicated list datatype, parameterised with the type of its elements — which is why it cannot make the single-to-multi-valued change Protocol Buffers can, and why it can nest lists, which Protocol Buffers cannot.
An elementFour bytes of length for 11 bytes of string.
An elementFour bytes of length for 7 bytes of string.
The stop byteThe struct ends. An encoded record is just the concatenation of its encoded fields, and if a field value is not set it is simply omitted — so something has to say where the record stops.
Thrift CompactProtocol · 34 bytes · schema required to decode
Tag and type, in one byte0x18: the top four bits are the tag as a delta from the previous field (+1, so tag 1, for userName) and the bottom four are the type (string). BinaryProtocol spent three bytes on the same thing.
A one-byte lengthA varint, so a length of six costs one byte rather than four.
The valueUnchanged.
Tag and type, in one byte0x16: the top four bits are the tag as a delta from the previous field (+1, so tag 2, for favoriteNumber) and the bottom four are the type (i64). BinaryProtocol spent three bytes on the same thing.
The number, as a variable-length integerTwo bytes rather than a full eight.0xf2 0x14 — the top bit of each byte indicates whether there are still more bytes to come. Values from −64 to 63 fit in one byte, −8192 to 8191 in two, bigger numbers in more.
Tag and type, in one byte0x19: the top four bits are the tag as a delta from the previous field (+1, so tag 3, for interests) and the bottom four are the type (list). BinaryProtocol spent three bytes on the same thing.
Count and element type, in one byteThe same packing trick applied to the list header.
An elementOne byte of length, then the string.
An elementOne byte of length, then the string.
The stop byteAnd the struct ends.
Protocol Buffers · 33 bytes · schema required to decode
Tag and wire type, in one byte0x0a is the tag (1, for userName) shifted left three bits, with the wire type (length-delimited) in the bottom three. Protocol Buffers does the bit packing slightly differently from Thrift's CompactProtocol but is otherwise very similar.
Length, then the valueOne byte of length for six bytes of string.
Tag and wire type, in one byte0x10 is the tag (2, for favoriteNumber) shifted left three bits, with the wire type (varint) in the bottom three. Protocol Buffers does the bit packing slightly differently from Thrift's CompactProtocol but is otherwise very similar.
The number, as a varint0xb9 0x0a. Two bytes, and no length or type byte of its own — the wire type in the key was enough.
Tag and wire type, in one byte0x1a is the tag (3, for interests) shifted left three bits, with the wire type (length-delimited) in the bottom three. Protocol Buffers does the bit packing slightly differently from Thrift's CompactProtocol but is otherwise very similar. Protocol Buffers has no list datatype.interests is marked repeated, and the encoding of a repeated field is exactly what it says: the same field tag simply appears multiple times in the record.
Length, then the elementWhich is why an optional field can safely become a repeated one: old code reading new data simply sees the last occurrence.
Tag and wire type, in one byte0x1a is the tag (3, for interests) shifted left three bits, with the wire type (length-delimited) in the bottom three. Protocol Buffers does the bit packing slightly differently from Thrift's CompactProtocol but is otherwise very similar. The same tag again — this is the second element, and nothing but its repetition says so.
Length, then the elementWhich is why an optional field can safely become a repeated one: old code reading new data simply sees the last occurrence.
Avro · 32 bytes · schema required to decode
A length prefix and some bytes0x0c is 6, ZigZag-encoded, then 6 bytes of UTF-8. Nothing here says it is a string. It could just as well be an integer, or something else entirely. Only the schema says otherwise, and the reader has to have it.
Which branch of the unionfavoriteNumber is declared union { null, long }, so one byte says which branch this value is: index 1, the long. That byte is how Avro allows null without an optional marker — and you may only default a field to null if null is one of the branches.
The numberVariable-length, the same encoding as Thrift’s CompactProtocol.
A block count2 items follow. Avro writes an array as one or more counted blocks.
An elementLength, then bytes. No field name, no tag, no type.
An elementLength, then bytes. No field name, no tag, no type.
A zero block, meaning the array is overAnd the record is finished — in fewer bytes than any other encoding in the chapter, having identified nothing whatsoever about its own contents.
Three colours, and they are the whole argument of this half of the chapter. Amber is what the
encoding spends saying which field this is — the name spelled out, or the tag that stands in for
it. Accent is the values, the payload you actually wanted. Grey is framing: type markers,
lengths, delimiters, union branches, stop bytes.
Read the scoreboard by those bands rather than by the totals, and the chapter’s central claim stops
being an assertion. Every encoding here carries the same record, and four of the six spend exactly
26 bytes on its values.
The same record, six ways, split by what the bytes are spent on
JSON · 81 bytesThe baseline. Human-readable, universally supported, and every field name spelled out in full: 31 of the 81 bytes are field names and another 22 are quotes, colons, commas and brackets. Only 28 bytes are the values. JSON is less verbose than XML, but both use a lot of space compared with the binary formats.
MessagePack · 66 bytesA binary encoding for JSON, and it saves only 15 bytes on 81. The split says exactly why: framing falls from 22 bytes to 9, but field names cost 31 bytes — precisely what they cost in the textual encoding. Since MessagePack doesn't prescribe a schema, it has to include all the object's field names in the encoded data. It's not clear whether such a small space reduction is worth the loss of human-readability.
Thrift BinaryProtocol · 59 bytesThe first schema-driven encoding here, and the field names are gone — replaced by 6 bytes of field tags, the numbers 1, 2, 3 from the schema. But it is not yet compact: 21 bytes of framing, because every length is a full 32-bit integer, and 32 bytes of values, because the schema said i64 and 1337 duly arrives in eight bytes.
Thrift CompactProtocol · 34 bytesSemantically equivalent to BinaryProtocol, 25 bytes smaller, and both savings are visible in the split: field identification drops from 6 bytes to 3 by packing the field type and tag number into a single byte, and framing plus values drop from 53 to 31 by using variable-length integers — 1337 in two bytes rather than eight.
Protocol Buffers · 33 bytesOnly one binary encoding format, and it does the bit packing slightly differently from Thrift's CompactProtocol but is otherwise very similar. The one structural difference is in the list: there is no list datatype, so interests is repeated and its tag appears once per element — which is why 4 bytes go on tags for three fields.
Avro · 32 bytesThe most compact of all the encodings in the chapter, and it gets there by a different route: 0 bytes of field identification. There are no tag numbers at all. Examine the bytes and there is nothing to identify fields or their datatypes — the encoding is simply values concatenated together, 26 bytes of them, with 6 bytes of lengths, one union branch and one block count holding it up. Which is exactly why the reader must have a compatible schema.
The one comparison worth memorising
MessagePack spends 31 bytes on field names. Textual JSON spends
31. They are the same bytes, and that is the entire reason a binary encoding of
JSON saves only 15 bytes on 81: it changed the framing and left
the names alone, because a format that doesn’t prescribe a schema has no way to refer to a field
except by naming it. It’s not clear whether such a small space reduction — and perhaps a speedup in
parsing — is worth the loss of human-readability.
Avro spends 0. Everything between here and the end of the first half is the
story of how that number reaches zero.
Schema-driven, part one§ Thrift and Protocol Buffers
Replace field names with field tags
Apache Thrift and Protocol Buffers are binary encoding libraries based on the same principle.
Protocol Buffers was originally developed at Google, Thrift at Facebook, and both were made open
source in 2007–08. Both require a schema for any data that is encoded, and both come with a
code generation tool that turns a schema definition into classes in various programming
languages, which your application calls to encode or decode records.
Field tags, and two markers that change nothingThe numbers 1, 2, 3 are what goes on the wire in place of the names. required and optional make no difference at all to how a field is encoded — nothing in the binary data indicates whether a field was required. The difference is that requiredenables a runtime check that fails if the field is not set, which is useful for catching bugs.
Three markers, not tworepeated stands in for a list datatype, which Protocol Buffers does not have. The encoding of a repeated field is exactly what the word says: the same field tag simply appears multiple times in the record.
record Person {
string userName;
union { null, long } favoriteNumber = null;
array<string> interests;
}
// note: no tag numbers anywhere
No tags, and no optional eitherAvro has two schema languages: this one, intended for human editing, and one based on JSON that is more easily machine-readable. It has no optional or required markers — it has union types and default values instead.
Confusingly, Thrift has two different binary encoding formats — BinaryProtocol and
CompactProtocol. Actually three: DenseProtocol exists but is only supported by the C++
implementation, so it doesn’t count as cross-language. Plus two different JSON-based encoding
formats. What fun. Protocol Buffers has only one binary encoding format, and it does the bit packing
slightly differently from Thrift’s CompactProtocol but is otherwise very similar.
Both savings between BinaryProtocol’s 59 bytes and CompactProtocol’s 34 are
visible in the strips above: the field header becomes one byte carrying tag and type together, and
every integer becomes variable-length. The second is worth a dial of its own, because the chapter
states its boundaries as a table and a table makes them look arbitrary.
A variable-length integer, and where its boundaries come from
111100100xf2 · more follows000101000x14 · last
1,337 · 2 bytesZigZag first, so that small negative numbers stay small: 1,337 becomes 2,674. Then seven bits of it per byte, with the top bit of each byte indicating whether there are still more bytes to come — the flag is marked above, and it is the only reason a reader knows where the number ends.
The last value that fits in 2 bytes is 8,191; one more and it takes 3. A fixed 64-bit integer would spend 8 bytes on this value whatever it is, which is what Thrift's BinaryProtocol does — the varint spends 2, a saving of 6.
value range
bytes used
−64 to 63
1
−8192 to 8191
2
bigger numbers
more
One detail that surprises people
Each field in those schemas was marked required or optional, but this makes no difference to
how the field is encoded — nothing in the binary data indicates whether a field was required.
The difference is simply that requiredenables a runtime check that fails if the field is not
set, which can be useful for catching bugs.
Field tags and schema evolution
An encoded record is just the concatenation of its encoded fields. Each field is identified by
its tag number and annotated with a datatype, and if a field value is not set it is simply omitted
from the encoded record. From which it follows that field tags are critical to the meaning of the
encoded data — and that a reader meeting a tag it does not recognise has everything it needs to carry
on anyway.
An old reader taking a record a newer version wrote
47 bytes, written by the new codeA newer version of the schema added a fourth field, photoUrl, with tag 4. The record is now 47 bytes rather than 33. The reader about to walk it has never heard of that field.
Tag 1 · recognisedThe key byte gives tag 1 and wire type 2, length-delimited. The reader knows tag 1: it is userName. Read the length, take that many bytes.
Tag 2 · recognisedWire type 0, a varint. No length needed — the continuation bits say where the number ends.
Tag 3, twice · recognisedThe same tag appearing twice is how Protocol Buffers writes a list. Two elements, two keys, and the reader is now at the byte where the old schema ended.
Tag 4 · unknownHere is the moment the whole chapter turns on. The reader has a tag it cannot name. It does not fail, and it does not guess: it reads the wire type packed into the same byte, which says length-delimited.
Skip 12 bytesThe next byte is a length — 12 — so the reader steps over exactly 12 bytes and carries on. That is forward compatibility, and it is the entire mechanism. The old code did not need to know what the new field was; it only needed the format to tell it how far to jump.
Done · and now the dangerThe old code has a complete Person minus one field it never knew about. The unknown bytes survived the decoder. Whether they survive the next write back is a different question, and it is the one the database exhibit below is about.
Rename a field · safeYou can change the name of a field in the schema, since the encoded data never refers to field names — only to tag numbers. Both directions unaffected.
Change a field’s tag · neverYou cannot change a field’s tag, since that would make all existing encoded data invalid. Field tags are critical to the meaning of the encoded data; change one and every record ever written is silently reinterpreted.
Add a new field · safe if optional or defaultedForward compatibility comes free. Old code, which does not know the new tag number, encounters it and can simply ignore that field — the datatype annotation lets the parser determine how many bytes to skip. That is the walk above, byte by byte.
Backward compatibility is what imposes the constraint. As long as each field has a unique tag number, new code can always read old data, because the tags still mean the same thing. The catch: you cannot make a new field required, because the required check would fail when new code read data written by old code, which never wrote that field. Every field added after the initial deployment must be optional or have a default value.
Remove a field · only if it was optionalRemoving a field is just like adding one, with the two directions reversed. So: you can only remove a field that is optional — a required field can never be removed — and you can never use the same tag number again, because data written somewhere may still include the old tag, and that field must be ignored by new code.
Widen int32 to int64 · asymmetricChanging a datatype may be possible — check the documentation — but there is a risk that values will lose precision or get truncated. Widening a 32-bit integer to 64-bit: new code easily reads old data, because the parser can fill in the missing bits with zeros. But old code reading new data is still holding the value in a 32-bit variable, so a decoded 64-bit value that does not fit will be truncated.
optional → repeated · safe, and neatBecause a repeated field is just the same tag appearing several times, it is fine to change an optional single-valued field into a repeated multi-valued one. New code reading old data sees a list with zero or one elements; old code reading new data sees only the last element.
Thrift cannot do this — it has a dedicated list datatype, parameterised with its element type — but Thrift’s approach has the advantage of supporting nested lists.
A last asymmetry between the two libraries. Protocol Buffers has no list or array datatype —
instead it has a repeated marker, a third option alongside required and optional, and the
encoding of a repeated field is exactly what it says: the same field tag simply appears multiple
times in the record, which you can see twice over in its strip above. Thrift has a dedicated
list datatype, parameterized with the type of the elements. That doesn’t allow the
single-valued-to-multi-valued evolution Protocol Buffers permits, but it does have the advantage
of supporting nested lists.
Schema-driven, part two§ Avro
No tag numbers, no field names, just values
Apache Avro is another binary encoding format, interestingly different from Protocol Buffers
and Thrift. It was started in 2009 as a subproject of Hadoop, as a result of Thrift not being a
good fit for Hadoop’s use cases. It also uses a schema, and has two schema languages: Avro
IDL, intended for human editing, and one based on JSON that is more easily machine-readable.
Avro IDL, intended for human editing
record Person {
string userName;
union { null, long } favoriteNumber = null;
array<string> interests;
}
First of all, notice there are no tag numbers. Encoding the record with this schema gives just
32 bytes — the most compact of all the encodings in the chapter. Examine that byte
sequence in the inspector above and you find nothing to identify fields or their datatypes. The
encoding simply consists of values concatenated together. A string is just a length prefix
followed by UTF-8 bytes, but nothing in the encoded data tells you it is a string; it could just as
well be an integer, or something else entirely.
To parse it you go through the fields in the order they appear in the schema and use the schema to
tell you each field’s datatype. Which means the binary data can only be decoded correctly if the
code reading it is using the exact same schema as the code that wrote it. Any mismatch would mean
incorrectly decoded data.
So how does Avro support schema evolution at all?
The writer’s schema and the reader’s schema
When an application encodes data it uses whatever version of the schema it knows about, perhaps
compiled into the application. That is the writer’s schema. When an application decodes data it
expects some schema, which is the reader’s schema — the one the application code relies on,
possibly the one its code was generated from at build time.
The key idea: the writer’s schema and the reader’s schema don’t have to be the same. They only need
to be compatible. When data is decoded, the Avro library resolves the differences by looking at
the two schemas side by side and translating the data from the writer’s schema into the reader’s
schema. The Avro specification defines exactly how this resolution works.
Three mismatches, and what resolution does with each
No problem at allIt is no problem if the writer’s schema and the reader’s schema have their fields in a different order, because the resolution matches up the fields by field name. The reader walks its own schema and pulls each field from wherever the writer put it. Unrecognised field names are the thing it cannot cope with — not unfamiliar positions.
IgnoredIf the code reading the data encounters a field that appears in the writer’s schema but not in the reader’s schema, it is ignored. This is the forward-compatibility case: an old reader coping with a new writer.
Filled with a defaultIf the code reading the data expects some field but the writer’s schema does not contain a field of that name, it is filled in with a default value declared in the reader’s schema. This is the backward-compatibility case — and it is exactly why Avro’s evolution rule is about default values.
Schema evolution rules
Forward compatibility here means
A new version of the schema as writer, and an old version as reader.
Backward compatibility here means
A new version of the schema as reader, and an old version as writer.
The rule itselfTo maintain compatibility, you may only add or remove a field that has a default value. In the schema above, favoriteNumber has a default of null.
Add a field with a default, so it exists in the new schema but not the old. When a reader using the new schema reads a record written with the old schema, the default value is filled in for the missing field.
Add a field with no defaultBreaks backward compatibility. New readers cannot read data written by old writers: there is no field of that name in the writer’s schema and no default to substitute.
Remove a field with no defaultBreaks forward compatibility. Old readers cannot read data written by new writers — the old reader still expects the field, the new writer no longer produces it, and there is no default to fall back on.
Change a field’s datatypePossible, provided that Avro can convert the type.
Rename a fieldPossible but a little tricky. The reader’s schema can contain aliases for field names, so it can match an old writer’s field names against the aliases. Which makes a rename backward compatible but not forward compatible — and is the price of matching by name rather than by tag.
Add a branch to a union typeBackward compatible but not forward compatible — the same asymmetry as renaming.
Null is not a default for free
In some languages null is an acceptable default for any variable. Not in Avro. To allow a field
to be null you have to use a union type — for example union { null, long, string } field; means
the field can be a number, a string, or null. You can only use null as a default value if it is one
of the branches of the union. Strictly, the default value must be of the type of the first branch
of the union, which is a specific limitation of Avro rather than a general feature of union types.
That’s a little more verbose than making everything nullable by default, but it helps prevent bugs
by being explicit about what can and cannot be null. Consequently Avro doesn’t have optional
and required markers the way Protocol Buffers and Thrift do.
But what is the writer’s schema?
How does the reader know which schema a particular piece of data was encoded with? You can’t include
the entire schema with every record — the schema would likely be much bigger than the encoded data,
making all the space savings from the binary encoding futile. Look at the Avro strip above: the
record is 32 bytes and the JSON form of its schema is several hundred. The answer depends
on the context.
A large file with lots of recordsA common use for Avro, especially with Hadoop: a file containing millions of records, all encoded with the same schema. The writer includes the writer’s schema once at the beginning of the file. Avro specifies a file format — object container files — that does exactly this, which is also what makes such a file self-describing.
A database with individually written recordsDifferent records may be written at different times with different writer’s schemas, so you cannot assume one schema for the file. The simplest solution: include a version number at the beginning of every encoded record and keep a list of schema versions in your database. A reader fetches a record, extracts the version number, fetches that writer’s schema, and uses it to decode the rest. Espresso works this way.
Records over a network connectionTwo processes communicating over a bidirectional network connection can negotiate the schema version on connection setup and use it for the lifetime of the connection. The Avro RPC protocol works like this.
A database of schema versions is a useful thing to have in any case, since it acts as
documentation and gives you a chance to check schema compatibility. As the version number you
could use a simple incrementing integer, or a hash of the schema.
Dynamically generated schemas
Why does it matter that Avro’s schema has no tag numbers? Because Avro is friendlier to dynamically
generated schemas. Say you want to dump a relational database’s contents to a file in a binary
format, avoiding the problems with textual formats. With Avro you can fairly easily generate a schema
from the relational schema — a record schema per table, each column becoming a field, the column
name mapping to the field name — and dump it all into an Avro object container file.
With Avro
The database schema changes: a column added, one removed. Just generate a new Avro schema from
the updated database schema and export in it. The export process doesn’t need to pay any
attention to the schema change; it simply does the conversion every time it runs. Readers see
that the record’s fields have changed, but since fields are identified by name, the updated
writer’s schema still matches up with the old reader’s schema.
With Thrift or Protocol Buffers
The field tags would likely have to be assigned by hand: every time the database schema
changes, an administrator has to manually update the mapping from database column names to field
tags. It might be possible to automate, but the generator would have to be very careful not to
assign previously used field tags. This kind of dynamically generated schema simply wasn’t a
design goal of Thrift or Protocol Buffers, whereas it was for Avro.
Code generation and dynamically typed languages
Thrift and Protocol Buffers rely on code generation. That’s useful in statically typed languages
such as Java, C++ or C#, because it allows efficient in-memory structures for decoded data, plus type
checking and autocompletion in IDEs. In dynamically typed languages such as JavaScript, Ruby or
Python there’s not much point, since there is no compile-time type checker to satisfy — and code
generation is often frowned upon in those languages, which otherwise avoid an explicit compilation
step. Worse, with a dynamically generated schema, code generation is an unnecessary obstacle to
getting to the data.
Avro provides optional code generation for statically typed languages, but works just as well
without it. Given an object container file, which embeds the writer’s schema, you can open it
with the Avro library and look at the data much as you would a JSON file: the file is
self-describing because it includes all the necessary metadata. That’s especially useful with
dynamically typed data processing languages like Apache Pig, where you can just open some Avro
files, start analyzing, and write derived datasets back out in Avro without even thinking about
schemas.
The verdict on half one§ The Merits of Schemas
Four properties worth the loss of human-readability
Protocol Buffers, Thrift and Avro all use a schema to describe a binary encoding format, and their
schema languages are much simpler than XML Schema or JSON Schema, which support far more
detailed validation rules — “the string value of this field must match this regular expression”,
“the integer value must be between 0 and 100”. Being simpler to implement and to use, they have
grown to support a fairly wide range of programming languages.
Compactness
Much more compact than the various binary JSON variants, since they can omit field names
from the encoded data — the 31 bytes at the top of the scoreboard that never
had to be there.
Living documentation
The schema is a valuable form of documentation, and because it’s required for decoding you can
be sure it’s up to date — whereas manually maintained documentation easily diverges from
reality.
Pre-deployment checks
Keeping a database of schemas lets you check forward and backward compatibility of a schema
change before anything is deployed.
Compile-time type checking
For users of statically typed languages, generating code from the schema enables type checking
at compile time.
None of this is new
These ideas have a lot in common with ASN.1, a schema definition language first standardized in
1984. It was used to define various network protocols, and its binary encoding DER is still used
to encode SSL certificates (X.509). ASN.1 supports schema evolution using tag numbers, similar to
Protocol Buffers and Thrift. However, it’s also very complex and badly documented, so it’s
probably not a good choice for new applications.
Many data systems also implement proprietary binary encodings. Most relational databases have a
network protocol for sending queries and receiving responses; those protocols are generally specific
to a particular database, and the vendor provides a driver — using ODBC or JDBC — that decodes
responses from the network protocol into in-memory data structures.
The summary sentence worth memorising: schema evolution allows the same kind of flexibility as
schemaless, schema-on-read JSON databases provide, while also providing better guarantees about
your data and better tooling.
Half two, mode one§ Dataflow Through Databases
Storing something is sending a message to your future self
Compatibility is a relationship between one process that encodes the data and another process
that decodes it — a fairly abstract idea, since there are many ways data can flow from one
process to another. The chapter walks three: via databases, via service calls, via asynchronous
message passing.
In a database, the writer encodes and the reader decodes. There may be just a single process, in
which case the reader is simply a later version of the same process — storing something in the
database is like sending a message to your future self. Backward compatibility is clearly necessary
there, or your future self won’t be able to decode what you previously wrote.
But it’s common for several processes to access a database at the same time: different applications
or services, or several instances of the same service running in parallel for scalability or fault
tolerance. Either way, in a changing application it’s likely that some processes are running newer
code and some older — for instance because a new version is mid-rolling-upgrade. So a value may be
written by a newer version and subsequently read by an older version that is still running:
forward compatibility is also often required for databases.
That much the byte walk above already showed. Here is the part it didn’t.
The additional snag
1 / 5
one record, in the database
field
value
userName
Martin
favoriteNumber
1337
one record, in the database
field
value
userName
Martin
favoriteNumber
1337
photoUrl
/p/7/000.jpg
one record, in the database
field
value
userName
Martin
favoriteNumber
1337
photoUrl
/p/7/000.jpg
one record, in the database
field
value
userName
Martin
favoriteNumber
42
photoUrl
/p/7/000.jpg
one record, in the database
field
value
userName
Martin
favoriteNumber
42
photoUrl
— gone —
BeforeA record written before the new field existed. Two fields, both understood by every version of the code.
Step 1 · new code writesYou added a field, and the newer version of the code writes a value for it — photoUrl — to the database.
Step 2 · old code readsAn older version of the code, which does not know about the new field, reads the record. Forward compatibility means it can: it just cannot interpret photoUrl. Byte for byte, this is the skip you walked through above.
Step 3 · old code updates and writes backThe old code changes something it does understand and writes the record back. The desirable behaviour is for it to keep the new field intact, even though it could not be interpreted.
Step 4 · the snagBut look what can happen. The encoding formats in this chapter do support preserving unknown fields — sometimes you need to take care at the application level. Decode a database value into model objects and later re-encode those objects, and the unknown field can be lost in the translation. It survived the decoder and died in the object mapper. Solving this is not a hard problem; you just need to be aware of it.
Different values written at different times
A database generally allows any value to be updated at any time, so within a single database you may
have values written five milliseconds ago and values written five years ago. When you deploy a new
version of a server-side application you may replace the old version entirely within a few minutes.
The same is not true of database contents: the five-year-old data will still be there, in the
original encoding, unless you have explicitly rewritten it.
The observation is sometimes summed up as data outlives code.
Rewriting — migrating — data into a new schema is certainly possible, but it’s expensive on a large
dataset, so most databases avoid it. Most relational databases allow simple schema changes such as
adding a new column with a null default value without rewriting existing data; when an old row is
read, the database fills in nulls for the columns missing from the encoded data on disk. MySQL is the
exception, often rewriting an entire table even though it isn’t strictly necessary. LinkedIn’s
document database Espresso uses Avro for storage, so it gets Avro’s schema evolution rules.
Which yields a nice way to state the payoff: schema evolution allows the entire database to appear
as if it was encoded with a single schema, even though the underlying storage may contain records
encoded with various historical versions of the schema.
Archival storage
Suppose you snapshot the database periodically, for backups or for loading into a data warehouse.
The dump will typically be encoded using the latest schema, even if the original storage
contained a mixture of schema versions from different eras — since you’re copying the data anyway,
you might as well encode the copy consistently. Because the dump is written in one go and
thereafter immutable, formats like Avro object container files are a good fit. It’s also a
good opportunity to encode the data in an analytics-friendly column-oriented format such as
Parquet, which is where chapter 3 ended.
Half two, mode two§ Dataflow Through Services: REST and RPC
Clients, servers, and a bad abstraction
The most common arrangement for processes communicating over a network is two roles: clients and
servers. Servers expose an API over the network and clients connect to make requests to it.
The API exposed by the server is known as a service.
The web works this way: browsers make GET requests to download HTML, CSS, JavaScript and images,
and POST requests to submit data. That API is a standardized set of protocols and data formats —
HTTP, URLs, SSL/TLS, HTML — and because browsers, servers and website authors mostly agree on these
standards, you can use any browser to access any website. At least in theory.
Browsers aren’t the only clients. A native app on a mobile device or desktop can make requests, and a
client-side JavaScript application in a browser can use XMLHttpRequest to become an HTTP client, a
technique known as Ajax. Then the server’s response is typically not HTML for a human but
data in an encoding convenient for further processing by client-side code, such as JSON. HTTP may be
the transport, but the API on top is application-specific, and client and server must agree on
its details.
A server can itself be a client to another service — a typical web app server acts as client to a
database. That’s how a large application gets decomposed into smaller services by area of
functionality, an approach traditionally called service-oriented architecture (SOA), more
recently refined and rebranded as microservices architecture.
How services resemble databases
They typically allow clients to submit and query data.
How they differ
Databases allow arbitrary queries using query languages. Services expose an
application-specific API that only allows inputs and outputs predetermined by the business
logic of the service. That restriction provides encapsulation: services can impose
fine-grained restrictions on what clients can and cannot do.
And the design goal that makes this chapter relevant: SOA and microservices aim to make the
application easier to change and maintain by making services independently deployable and
evolvable. Each service should be owned by one team, and that team should be able to release new
versions frequently without having to coordinate with other teams. In other words, we should
expect old and new versions of servers and clients to be running at the same time, so the encoding
used must be compatible across versions of the service API.
Web services, three contexts and two philosophies
When HTTP is the underlying protocol for talking to a service, it’s called a web service — a
slight misnomer, since web services aren’t only used on the web.
Context 1
A client application on a user’s device — a native mobile app, or a JavaScript web app using
Ajax — making requests over HTTP, typically over the public internet.
Context 2
One service requesting another owned by the same organization, often in the same datacenter,
as part of a SOA or microservices architecture. Software supporting this is sometimes called
middleware.
Context 3
One service requesting a service owned by a different organization, usually via the
internet. Includes public APIs such as credit card processing, or OAuth for shared access to
user data.
REST and SOAP, almost diametrically opposed in philosophy
RESTNot a protocol, but a design philosophy that builds upon the principles of HTTP. It emphasises simple data formats, using URLs for identifying resources and HTTP features for cache control, authentication and content type negotiation. An API designed according to its principles is called RESTful.
REST has been gaining popularity compared with SOAP, at least for cross-organizational service integration, and is often associated with microservices. RESTful APIs tend to favour simpler approaches, typically less code generation and automated tooling. A definition format such as OpenAPI, also known as Swagger, can describe a RESTful API and produce documentation.
SOAPAn XML-based protocol for making network API requests. Most commonly used over HTTP, it aims to be independent from HTTP and avoids using most HTTP features. Instead it comes with a sprawling and complex multitude of related standards — the web service framework known as WS-*.
Its API is described in WSDL, which enables code generation so a client can call a remote service through local classes and methods. Because WSDL is not designed to be human-readable and SOAP messages are often too complex to construct by hand, users rely heavily on tool support, code generation and IDEs; for languages not supported by SOAP vendors, integration is difficult. And although SOAP and its extensions are ostensibly standardized, interoperability between vendors’ implementations often causes problems. Still used in many large enterprises, it has fallen out of favour in most smaller companies.
Despite the acronyms, SOAP is not a requirement for SOA: SOAP is a particular technology, SOA a general approach to building systems.
The problems with remote procedure calls
Web services are merely the latest incarnation of a long line of technologies for making API requests
over a network, many of which received a lot of hype but have serious problems. Enterprise
JavaBeans (EJB) and Java’s Remote Method Invocation (RMI) are limited to Java. The
Distributed Component Object Model (DCOM) is limited to Microsoft platforms. The Common Object
Request Broker Architecture (CORBA) is excessively complex, and does not provide backward or
forward compatibility.
All are based on the idea of a remote procedure call (RPC), around since the 1970s. The RPC model
tries to make a request to a remote network service look the same as calling a function or method
in your programming language, within the same process — an abstraction called location
transparency. It seems convenient at first, but the approach is fundamentally flawed, because a
network request is very different from a local function call.
PredictabilityA local function call is predictable and either succeeds or fails, depending only on parameters that are under your control. A network request is unpredictable: the request or the response may be lost to a network problem, or the remote machine may be slow or unavailable — problems entirely outside your control. Network problems are common, so you have to anticipate them, for example by retrying a failed request.
A third possible outcomeA local function call returns a result, throws an exception, or never returns. A network request has another possible outcome: it may return without a result, due to a timeout. In that case you simply do not know what happened — with no response, you have no way of knowing whether the request got through.
Retries duplicate workIf you retry a failed request, it may be that the requests are getting through and only the responses are being lost. Then retrying causes the action to be performed multiple times, unless you build deduplication (idempotence) into the protocol. Local function calls do not have this problem.
Wildly variable latencyA local function normally takes about the same time on every call. A network request is much slower, and its latency is also wildly variable: sometimes under a millisecond, and when the network is congested or the remote service overloaded, many seconds to do exactly the same thing.
You cannot pass pointersA local call can efficiently pass references to objects in local memory. A network request has to encode all its parameters into a sequence of bytes — fine for primitives, and quickly problematic with larger objects. Which is the same fact the encoding figure at the top of this note is about.
Cross-language type translationClient and service may be written in different languages, so the framework must translate datatypes from one into the other. This can end up ugly, since not all languages have the same types — recall JavaScript’s trouble with numbers greater than 253. The problem does not exist in a single process written in a single language.
The conclusion: there’s no point trying to make a remote service look too much like a local object
in your programming language, because it’s a fundamentally different thing. Part of the appeal of
REST is that it doesn’t try to hide the fact that it’s a network protocol — although that doesn’t
seem to stop people building RPC libraries on top of REST.
Current directions for RPC
Despite the problems, RPC isn’t going away. Frameworks have been built on top of all the encodings in
this chapter: Thrift and Avro come with RPC support included, gRPC is an RPC implementation using
Protocol Buffers, Finagle also uses Thrift, and Rest.li uses JSON over HTTP.
This new generation is more explicit about the fact that a remote request is different from a local
function call. Finagle and Rest.li use futures, or promises, to encapsulate asynchronous actions
that may fail — futures also simplify making requests to multiple services in parallel and combining
their results. gRPC supports streams, where a call consists not of one request and one response
but a series of both over time. Some frameworks also provide service discovery, letting a client
find out at which IP address and port a particular service can be found.
What custom binary RPC buys you
Better performance than something generic like JSON over REST.
What a RESTful API buys you
It’s good for experimentation and debugging — you can make requests with a browser or
curl, with no code generation or software installation; it’s supported by all mainstream
languages and platforms; and there’s a vast ecosystem of tools: servers, caches, load
balancers, proxies, firewalls, monitoring, debugging, testing.
Hence the split in practice: REST seems to be the predominant style for public APIs, while the main
focus of RPC frameworks is on requests between services owned by the same organization, typically
within the same datacenter.
Data encoding and evolution for RPC
The simplifying assumption
Compared with dataflow through databases, services allow one simplification: it is reasonable to
assume that all the servers will be updated first, and all the clients second. Therefore you only
need backward compatibility on requests — the newer server must read requests written by older
clients — and forward compatibility on responses — the older client must read responses written
by the newer server.
The compatibility properties of an RPC scheme are inherited from whatever encoding it uses:
Thrift, gRPC over Protocol Buffers, and Avro RPC all evolve according to the rules of their
respective formats. In SOAP, requests and responses are specified with XML schemas, which can be
evolved, but there are some subtle pitfalls. RESTful APIs most commonly use JSON without a
formally specified schema for responses, and JSON or URI-encoded or form-encoded parameters for
requests; adding optional request parameters and adding new fields to response objects are usually
considered changes that maintain compatibility.
Service compatibility is made harder by the fact that RPC is often used across organizational
boundaries: the provider of a service often has no control over its clients and cannot force them
to upgrade. So compatibility must be maintained for a long time, perhaps indefinitely. If a
compatibility-breaking change is required, the provider often ends up maintaining multiple versions
of the service API side by side.
And there is no agreement on how API versioning should work — how a client indicates which
version it wants. For RESTful APIs, common approaches are a version number in the URL or in the
HTTP Accept header. For services using API keys to identify a client, another option is to
store the client’s requested API version on the server and allow that selection to be updated
through a separate administrative interface.
Half two, mode three§ Message-Passing Dataflow
Somewhere between RPC and databases
Asynchronous message-passing systems are similar to RPC in that a client’s request — usually
called a message — is delivered to another process with low latency. They’re similar to
databases in that the message is not sent via a direct network connection but goes via an
intermediary called a message broker, also called a message queue or message-oriented
middleware, which stores the message temporarily.
Five advantages of a broker over direct RPC
It can act as a buffer if the recipient is unavailable or overloaded, and so improve system
reliability.
It can automatically redeliver messages to a process that has crashed, and so prevent messages
from being lost.
It avoids the sender needing to know the IP address and port number of the recipient, which is
particularly useful in a cloud deployment where virtual machines often come and go.
It allows one message to be sent to several recipients.
It logically decouples the sender from the recipient: the sender just publishes messages and
does not care who consumes them.
The difference from RPC is that message-passing communication is usually one-way: a sender
normally doesn’t expect a reply to its messages. A process can send a response, but that would
usually be done on a separate channel. The pattern is asynchronous: the sender doesn’t wait for
the message to be delivered, but simply sends it and then forgets about it.
Message brokers
The landscape used to be dominated by commercial enterprise software from companies such as TIBCO,
IBM WebSphere and webMethods. More recently open source implementations have become popular:
RabbitMQ, ActiveMQ, HornetQ, NATS and Apache Kafka.
Delivery semantics vary by implementation and configuration, but in general: one process sends a
message to a named queue or topic, and the broker ensures the message is delivered to one or more
consumers of or subscribers to that queue or topic. There can be many producers and many consumers
on the same topic.
A topic provides only one-way dataflow. But a consumer may itself publish to another topic — so
you can chain them together — or to a reply queue consumed by the sender of the original message,
which allows a request/response dataflow similar to RPC.
Brokers typically don’t enforce any particular data model: a message is just a sequence of bytes
with some metadata, so you can use any encoding format. And here’s the payoff: if the encoding is
backward and forward compatible, you have the greatest flexibility to change publishers and consumers
independently and deploy them in any order.
One caution: if a consumer republishes messages to another topic, you may need to be careful to
preserve unknown fields — the same hazard as the database case above, and the same fix.
The three modes, on one clock
via databases
writerencoded recordthe database · data outlives codethe same bytesreader, five years later
backward compatibility, unavoidably · forward compatibility, usually
via services · REST and RPC
clientrequestserverresponseclient again
backward on requests · forward on responses · servers updated first
via asynchronous message passing
producermessagebroker · one named topicto consumer 1to consumer 2consumers
both directions · and then you can deploy publishers and consumers in any order
One question, three timings. The broker's job is that the middle box can hold the message while
nobody is listening.
Distributed actor frameworks
The actor model is a programming model for concurrency in a single process. Rather than
dealing directly with threads, and the associated problems of race conditions, locking and deadlock,
logic is encapsulated in actors. Each actor typically represents one client or entity, may have
some local state that is not shared with any other actor, and communicates with other actors by
sending and receiving asynchronous messages. Message delivery is not guaranteed: in certain error
scenarios, messages will be lost. Since each actor processes only one message at a time it needn’t
worry about threads, and each can be scheduled independently by the framework.
In distributed actor frameworks the model is used to scale an application across multiple nodes.
The same message-passing mechanism is used whether sender and recipient are on the same node or
different nodes; if different, the message is transparently encoded into a byte sequence, sent over
the network, and decoded on the other side.
Why location transparency works better here than in RPC
Because the actor model already assumes that messages may be lost, even within a single
process. Latency over the network is likely higher than within the same process, but there is
less of a fundamental mismatch between local and remote communication when using the actor
model. A distributed actor framework essentially integrates a message broker and the actor
programming model into a single framework — but if you want rolling upgrades of an actor-based
application, you still have to worry about forward and backward compatibility, since messages may
be sent from a node running the new version to one running the old version, and vice versa.
AkkaUses Java’s built-in serialization by default, which provides neither forward nor backward compatibility. You can replace it with something like Protocol Buffers, and thereby gain the ability to do rolling upgrades.
OrleansBy default uses a custom data encoding format that does not support rolling upgrade deployments. To deploy a new version you set up a new cluster, move traffic across, and shut the old one down. As with Akka, custom serialization plug-ins can be used.
Erlang OTPSurprisingly hard to make changes to record schemas, despite the system having many features designed for high availability. Rolling upgrades are possible but need to be planned carefully. An experimental maps datatype — a JSON-like structure introduced in Erlang R17 in 2014 — may make this easier.
Recall check12 questions
Did it stick?
One attempt per question; the explanation appears either way.
1. Define backward and forward compatibility, and say which is usually harder.
Correct Backward compatibility is normally not hard, because as author of the newer code you know the format written by older code and can handle it explicitly — if necessary by keeping the old code around to read the old data.
2. Why is a rolling upgrade the reason this chapter exists?
Correct Rolling upgrades allow deployment without downtime, which encourages frequent releases and makes deployments less risky, since a faulty release can be detected and rolled back before it affects many users. The price is a window in which both directions of compatibility are live at once. Client-side applications are worse still — you are at the mercy of the user, who may not install the update for some time.
3. What is the chief drawback of using your language's built-in encoding (pickle, Marshal, java.io.Serializable)?
Correct Four deep problems: language lock-in, security (instantiating arbitrary classes can allow remote code execution), versioning as an afterthought, and efficiency as an afterthought — Java’s built-in serialization being notorious for bad performance and bloated encoding. Use them only for very transient purposes.
4. Why does Twitter's API return each tweet ID twice, once as a number and once as a string?
Correct JSON distinguishes strings from numbers but not integers from floats, and specifies no precision. Twitter uses a 64-bit number per tweet, so the JSON number form is silently corrupted in JavaScript clients and the decimal string is the workaround.
5. MessagePack encodes the example record in 66 bytes versus JSON's 81. Why so little saving?
Correct All the binary encodings of JSON are similar in this regard, and it is not clear whether such a small space reduction, plus perhaps a parsing speedup, is worth the loss of human-readability. The schema-driven formats reach 32–34 bytes precisely by omitting field names.
6. What role do field tags play in Thrift and Protocol Buffers?
Correct An encoded record is just the concatenation of its encoded fields, each identified by tag and annotated with a datatype; unset fields are simply omitted. Changing a tag would make all existing encoded data invalid. The datatype annotation is a separate byte — and it is what lets old code skip the right number of bytes for a tag it does not recognise.
7. You're adding a field to a Protocol Buffers schema after initial deployment. What constraint applies?
Correct Forward compatibility comes free — old code ignores the unrecognised tag, using the wire type to know how far to skip. Backward compatibility is what imposes the constraint. Conversely, only optional fields can be removed, and a retired tag number can never be reused.
8. In Avro, what does the reader do when the writer's schema contains a field the reader's schema doesn't have?
Correct Resolution matches fields by name, so field order does not matter. The mirror case is the other rule: if the reader expects a field the writer’s schema lacks, it is filled in with a default value declared in the reader’s schema — which is why the evolution rule is that you may only add or remove a field that has a default value.
9. How can a reader know the writer's schema without shipping the schema in every record?
Correct You cannot include the whole schema per record — it would likely be much bigger than the encoded data, wiping out the space saving. Espresso uses the per-record version number; the Avro RPC protocol negotiates on connection setup. A database of schema versions is useful in any case, as documentation and for checking compatibility.
10. Why is Avro friendlier than Thrift or Protocol Buffers to dynamically generated schemas?
Correct Dumping a relational database to Avro means a record schema per table and a field per column, with column names mapping to field names — so a column added or removed just means regenerating the schema. With Thrift or protobuf an administrator would have to hand-assign field tags each time, being very careful never to reuse a previously used one. This was a design goal for Avro and not for the others.
11. Why is the RPC ideal of location transparency fundamentally flawed?
Correct A local call is predictable and depends only on parameters under your control; a network request is not. Part of REST’s appeal is that it does not try to hide the fact that it is a network protocol. Newer frameworks are more explicit about the difference — Finagle and Rest.li use futures, gRPC supports streams.
12. What simplifying assumption can you make about RPC compatibility that you can't make about databases?
Correct The last option is precisely what you cannot assume: RPC is often used across organizational boundaries, so the provider often has no control over its clients, compatibility must be maintained for a long time — perhaps indefinitely — and a breaking change often means maintaining several API versions side by side. There is also no agreement on how API versioning should work.
Answered 0 of 12 · 0 correct
VocabularyClick to flip
The terms this chapter installs
Chapter 4 in one breath. Encoding details affect not only efficiency but, more importantly, the
architecture of applications and your options for deploying them. Many services need rolling
upgrades, where a new version is gradually deployed to a few nodes at a time rather than everywhere
at once — allowing releases without downtime, which encourages frequent small releases over rare big
ones, and making deployments less risky by letting faulty releases be detected and rolled back before
they affect many users. Hugely beneficial for evolvability. During a rolling upgrade we must
assume different nodes run different versions of the code, so all data flowing around the system must
provide backward compatibility, where new code can read old data, and forward compatibility,
where old code can read new data.
The formats: language-specific encodings are restricted to one language and often fail to provide
either direction. Textual formats — JSON, XML, CSV — are widespread, their compatibility depends
on how you use them, their optional schema languages are sometimes helpful and sometimes a hindrance,
and they’re vague about datatypes, so be careful with numbers and binary strings. Binary
schema-driven formats — Thrift, Protocol Buffers, Avro — allow compact, efficient encoding with
clearly defined compatibility semantics, and their schemas are useful for documentation and code
generation, at the cost that data needs to be decoded before it is human-readable. The
81 bytes at the top of the inspector and the 32 at the bottom are the same record;
the 49 bytes of difference are almost entirely names and punctuation.
The modes: databases, where the writing process encodes and the reading process decodes; RPC
and REST APIs, where the client encodes a request, the server decodes it and encodes a response,
and the client finally decodes the response; and asynchronous message passing via brokers or
actors, where nodes communicate by messages encoded by the sender and decoded by the recipient. The
conclusion: with a bit of care, backward and forward compatibility and rolling upgrades are quite
achievable. May your application’s evolution be rapid and your deployments be frequent.
Study aid built for recall practice. Concepts, code, schemas and named systems are Martin
Kleppmann’s, from Designing Data-Intensive Applications, chapter 4 — go back to the book for his
wording and the full reference list. The byte counts here are not quoted from it: the six encoders
in src/lib/ddia4.ts encode one record and the exhibits count the result, which happens to agree
with the book’s figures on all six.