314 56 12MB
English Pages [315] Year 2020
Contents Preface
i
Acknowledgements
I
iii
Fundamental Concepts
1
1 Introduction What is a distributed system and why we need it The fallacies of distributed computing . . . . . . Why distributed systems are hard . . . . . . . . Correctness in distributed systems . . . . . . . . System models . . . . . . . . . . . . . . . . . . . The tale of exactly-once semantics . . . . . . . . Failure in the world of distributed systems . . . . Stateful and Stateless systems . . . . . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
2 2 5 7 8 9 10 13 15
2 Basic concepts and theorems Partitioning . . . . . . . . . . . . . . . . Algorithms for horizontal partitioning . Replication . . . . . . . . . . . . . . . . Single-master replication . . . . . . . . . Multi-master replication . . . . . . . . . Quorums in distributed systems . . . . . Safety guarantees in distributed systems ACID transactions . . . . . . . . . The CAP Theorem . . . . . . . . . Consistency models . . . . . . . . . . . . Isolation levels . . . . . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
17 17 19 23 24 27 28 31 31 32 36 41
1
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
CONTENTS
2
Consistency and Isolation - Differences and Similarities . . . . . . . Why all the formalities . . . . . . . . . . . . . . . . . . . . . . . . .
II
Distributed Transactions & Consensus
47 50
53
3 Distributed Transactions What is a distributed transaction . . . . . . Achieving Isolation . . . . . . . . . . . . . . 2-phase locking (2PL) . . . . . . . . . . . . Optimistic concurrency control (OCC) . . . Multi-version concurrency control (MVCC) Achieving atomicity . . . . . . . . . . . . . 2-phase commit (2PC) . . . . . . . . . . . . 3-phase commit (3PC) . . . . . . . . . . . . A quorum-based commit protocol . . . . . . How it all fits together . . . . . . . . . . . . Long-lived transactions & Sagas . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
4 Consensus Defining consensus . . . . . . . . . . . . Some use-cases of consensus . . . . . . . FLP impossibility . . . . . . . . . . . . . The Paxos algorithm . . . . . . . . . . . Intricacies of Paxos . . . . . . . . . . . . Paxos in real-life . . . . . . . . . . . . . Replicated state machine via consensus Distributed transactions via consensus . Raft . . . . . . . . . . . . . . . . . . . . Standing on the shoulders of giants . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
84 . 84 . 85 . 86 . 86 . 90 . 93 . 96 . 98 . 100 . 105
III
. . . . . . . . . .
. . . . . . . . . .
Time & Order
5 Time What is different in a distributed system A practical perspective . . . . . . . . . . A theoretical perspective . . . . . . . . . Logical clocks . . . . . . . . . . . . . . . 6 Order
54 54 57 61 63 65 68 70 73 76 79 80
107 . . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
109 109 110 111 114 115
CONTENTS
3
Total and partial ordering . . . . . . . . . The concept of causality . . . . . . . . . . Lamport clocks . . . . . . . . . . . . . . . Vector clocks . . . . . . . . . . . . . . . . Version vectors & Dotted Version Vectors Distributed snapshots . . . . . . . . . . . Physical & Logical time: closing thoughts
IV
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
Network & Security
7 Networking The physical layer . The link layer . . . . The network layer . The transport layer . The application layer Taking a step back .
115 117 121 123 125 130 134
137
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
138 . 139 . 143 . 152 . 157 . 160 . 163
8 Security Authentication . . . . . Confidentiality . . . . . Integrity . . . . . . . . . A cryptography primer . Example protocols . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
V
. . . . . .
From theory to practice
9 Case studies Distributed file systems (HDFS/GFS) . . . . . . . . . . . . Distributed coordination service (Zookeeper/Chubby/etcd) Distributed datastores . . . . . . . . . . . . . . . . . . . . . BigTable/HBase . . . . . . . . . . . . . . . . . . . . . Cassandra . . . . . . . . . . . . . . . . . . . . . . . . . Spanner . . . . . . . . . . . . . . . . . . . . . . . . . . FaunaDB . . . . . . . . . . . . . . . . . . . . . . . . . Distributed messaging system (Kafka) . . . . . . . . . . . . Distributed cluster management (Kubernetes) . . . . . . . . Distributed ledger (Corda) . . . . . . . . . . . . . . . . . .
165 165 167 168 170 175
184 . . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
185 . 186 . 191 . 197 . 197 . 202 . 212 . 219 . 223 . 229 . 232
CONTENTS
4
Distributed data processing systems MapReduce . . . . . . . . . . . Apache Spark . . . . . . . . . . Apache Flink . . . . . . . . . . 10 Practices & Patterns Communication patterns . . . Coordination patterns . . . . Data synchronisation . . . . . Shared-nothing architectures Distributed locking . . . . . . Compatibility patterns . . . . Dealing with failure . . . . . Distributed tracing . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . .
. . . . . . . .
. . . .
. . . . . . . .
. . . .
. . . . . . . .
. . . .
. . . . . . . .
. . . .
. . . . . . . .
. . . .
. . . . . . . .
. . . .
. . . . . . . .
. . . .
. . . . . . . .
. . . .
. . . . . . . .
. . . .
. . . . . . . .
. . . .
. . . . . . . .
. . . .
. . . . . . . .
. . . .
. . . . . . . .
. . . .
. . . . . . . .
. . . .
. . . . . . . .
. . . .
. . . .
240 241 245 253
. . . . . . . .
260 . 260 . 267 . 268 . 272 . 277 . 281 . 284 . 291
11 Closing thoughts
293
References
298
Preface Distributed systems are becoming ubiquitous in our life nowadays: from how we communicate with our friends to how we make online shopping and many more things. It might be transparent to us sometimes, but many companies are making use of extremely complicated software systems under the hood to satisfy our needs. By using these kind of systems, companies are capable of significant achievements, such as sending our message to a friend who is thousand miles away in a matter of milliseconds, delivering our orders despite outages of whole datacenters or searching the whole Internet by processing more than a million terabytes of data in less than a second. Putting all of this into perspective, it’s easy to understand the value that distributed systems bring in the current world and why it’s useful for software engineers to be able to understand and make use of distributed systems.
However, as easy and fascinating as it might seem, the area of distributed systems is a rather complicated one with many different execution models and failure modes. As a result, in order for one to simply understand how to use a 3rd party library or verify the correctness of a distributed system under construction, one has to digest a vast amount of information first. Distributed systems have been a really hot academic topic for the last decades and tremendous progress has been achieved, albeit through a large number of papers with one building on top of the previous ones usually. This sets a rather high barrier to entry for newcomers and practitioners that just want to understand the basic building blocks, so that they can be confident in the systems they are building without any aspirations of inventing new algorithms or protocols.
The ultimate goal of this book is to help these people get started with i
PREFACE
ii
distributed systems in an easy and intuitive way. It was born out of my initiation to the topic, which included a lot of transitions between excitement, confusion and enlightenment.
Of course, it would be infeasible to tackle all the existing problems in the space of distributed computing. So, this book will focus on: • establishing the basic principles around distributed systems • explaining what is and what is not possible to achieve • explaining the basic algorithms and protocols, by giving easy-to-follow examples and diagrams • explaining the thinking behind some design decisions • expanding on how these can be used in practice and what are some of the issues that might arise when doing so • eliminating confusion around some terms (i.e. consistency) and foster thinking about trade-offs when designing distributed systems • providing plenty of additional resources for people that are willing to invest more time in order to get a deeper understanding of the theoretical parts
Who is this book for This book is aimed at software engineers that have some experience in building software systems and have no or some experience in distributed systems. We assume no knowledge around concepts and algorithms for distributed systems. This book attempts to gradually introduce the terms and explain the basic algorithms in the simplest way possible, providing many diagrams and examples. As a result, this book can also be useful to people that don’t develop software, but want to get an introduction to the field of distributed systems. However, this book does not aim to provide a full analysis or proof of every single algorithm. Instead, the book aims to help the reader get the intuition behind a concept or an algorithm, while also providing the necessary references to the original papers, so that the reader can study other parts of interest in more depth.
Acknowledgements As any other book, this book might have been written by a single person, but that would not have been possible without the contribution of many others. As a result, credits should be given to all my previous employers and colleagues that have given me the opportunity to work with large-scale, distributed systems and appreciate both their capabilities and complexities and the distributed systems community that was always open to answer any questions. I would also like to thank Richard Gendal Brown for reviewing the case study on Corda and giving feedback that was very useful in helping me to add clarity and remove ambiguity. Of course, this book would not have been possible without the understanding and support of my partner in life, Maria.
iii
Part I
Fundamental Concepts
1
Chapter 1
Introduction What is a distributed system and why we need it First of all, we need to define what a distributed system is. Multiple, different definitions can be found, but we will use the following: "A distributed system is a system whose components are located on different networked computers, which communicate and coordinate their actions by passing messages to one another."[1] As shown in Figure 1.1, this network can either consist of direct connections between the components of the distributed system or there could be more components that form the backbone of the network (if communication is done through the Internet for example). These components can take many forms; they could be servers, routers, web browsers or even mobile devices. In an effort to keep an abstract and generic view, in the context of this book we’ll refer to them as nodes, being agnostic to their real form. In some cases, such as when providing a concrete example, it might be useful to escape this generic view and see how things work in real-life. In these cases, we might explain in detail the role of each node in the system. As we will see later, the 2 parts that were highlighted in the definition above are central to how distributed systems function: • the various parts that compose a distributed system are located remotely, separated by a network.
2
CHAPTER 1. INTRODUCTION
3
Figure 1.1: A distributed system • the main mechanism of communication between them is by exchanging messages, using this network that separates them.
Now that we have defined what a distributed system is, let’s explore its value. Why do we really need distributed systems ? Looking at all the complexity that distributed systems introduce, as we will see during this book, that’s a valid question. The main benefits of distributed systems come mostly in the following 3 areas: • performance • scalability • availability Let’s explain each one separately. The performance of a single computer has certain limits imposed by physical constraints on the hardware. Not only that, but after a point, improving the hardware of a single computer in order to achieve better performance becomes extremely expensive. As
CHAPTER 1. INTRODUCTION
4
a result, one can achieve the same performance with 2 or more low-spec computers as with a single, high-end computer. So, distributed systems allow us to achieve better performance at a lower cost. Note that better performance can translate to different things depending on the context, such as lower latency per request, higher throughput etc. "Scalability is the capability of a system, network, or process to handle a growing amount of work, or its potential to be enlarged to accommodate that growth." [2] Most of the value derived from software systems in the real world comes from storing and processing data. As the customer base of a system grows, the system needs to handle larger amounts of traffic and store larger amounts of data. However, a system composed of a single computer can only scale up to a certain point, as explained previously. Building a distributed system allows us to split and store the data in multiple computers, while also distributing the processing work amongst them1 . As a result of this, we are capable of scaling our systems to sizes that would not even be imaginable with a single-computer system. In the context of software systems, availability is the probability that a system will work as required when required during the period of a mission. Note that nowadays most of the online services are required to operate all the time (known also as 24/7 service), which makes this a huge challenge. So, when a service states that it has 5 nines of availability, this means that it operates normally for 99.999% of the time. This implies that it’s allowed to be down for up to 5 minutes a year, to satisfy this guarantee. Thinking about how unreliable hardware can be, one can easily understand how big an undertaking this is. Of course, using a single computer, it would be infeasible to provide this kind of guarantees. One of the mechanisms that are widely used to achieve higher availability is redundancy, which means storing data into multiple, redundant computers. So, when one of them fails, we can easily and quickly switch to another one, preventing our customers from experiencing this failure. Given that data are stored now in multiple computers, we end up with a distributed system! Leveraging a distributed system we can get all of the above benefits. However, as we will see later on, there is a tension between them and several other 1 The approach of scaling a system by adding resources (memory, CPU, disk) to a single node is also referred to as vertical scaling, while the approach of scaling by adding more nodes to the system is referred to as horizontal scaling.
CHAPTER 1. INTRODUCTION
5
properties. So, in most of the cases we have to make a trade-off. To do this, we need to understand the basic constraints and limitations of distributed systems, which is the goal of the first part of this book.
The fallacies of distributed computing Distributed systems are subject to many more constraints, when compared to software systems that run in a single computer. As a result, developing software for distributed systems is also very different. However, people that are new to distributed systems make assumptions, based on their experience developing software for systems that run on a single computer. Of course, this creates a lot of problems down the road for the systems they build. In an effort to eliminate this confusion and help people build better systems, L Peter Deutsch and others at Sun Microsystems created a collection of these false assumptions, which is now known as the fallacies of distributed computing2 . These are the following:
1. 2. 3. 4. 5. 6. 7. 8.
The network is reliable.[3][4] Latency is zero. Bandwidth is infinite. The network is secure. Topology doesn’t change. There is one administrator. Transport cost is zero. The network is homogeneous.
As you progress through the book, you will get a deeper understanding of why these statements are fallacies. However, we will try and give you a sneak preview here by going quickly over them and explain where they fall short. The first fallacy is sometimes enforced by abstractions provided to developers from various technologies and protocols. As we will see in a later chapter networking protocols, such as TCP, can make us believe that the network is reliable and never fails, but this is just an illusion and can have significant repercussions. Network connections are also built on top of hardware that will also fail at some point and we should design our systems 2
See: https://en.wikipedia.org/wiki/Fallacies_of_distributed_computing
CHAPTER 1. INTRODUCTION
6
accordingly. The second assumption is also enforced nowadays by libraries, which attempt to model remote procedure calls as local calls, such as gRPC3 or Thrift4 . We should always keep in mind that there is a difference of several orders of magnitude in latency between a call to a remote system and a local memory access (from milliseconds to nanoseconds). This is getting even worse, when we are talking about calls between datacenters in different continents, so this is another thing to keep in mind when deciding about how we want to geo-distribute our system. The third one is getting weaker nowadays, since there have been significant improvements in the bandwidth that can be achieved during the last decades. Still, even though we can build high-bandwidth connections in our own datacenter, this does not mean that we will be able to use all of it, if our traffic needs to cross the Internet. This is an important consideration to keep in mind, when making decisions about the topology of our distributed system and when requests will have to travel through the Internet. The fourth fallacy illustrates the fact that the wider network that is used by two nodes in order to communicate is not necessarily under their control and thus should be considered insecure. The book contains a chapter dedicated on security that explains various techniques that can be used in order to make use of this insecure network in a secure way. This network is also composed of many different parts that might be managed by different organisations with potentially different hardware and failures in some parts of this network might mean its topology might have to change to remain functional. This is all highlighted by the fifth, sixth and eighth fallacies. Last but not least, transportation of data between two points incurs financial costs, which should be factored in when building a distributed system. There’s one more fallacy that’s not included in the above set, but it’s still very common amongst people new to distributed systems and can also create a lot of confusion. If we were to follow the same style as above, we would probably phrase it in the following way:
"Distributed systems have a global clock, which can be used to identify when events happen."
This assumption can be quite deceiving, since it’s somewhat intuitive and 3 4
See: https://grpc.io/ See: https://thrift.apache.org/
CHAPTER 1. INTRODUCTION
7
holds true when working in systems that are not distributed. For instance, an application that runs in a single computer can use the computer’s local clock in order to decide when events happen and what’s the order between them. Nonetheless, that’s not true in a distributed system, where every node in the system has its own local clock, which runs at a different rate from the other ones. There are ways to try and keep the clocks in sync, but some of them are very expensive and do not eliminate these differences completely. This limitation is again bound by physical laws5 . An example of such an approach is the TrueTime API that was built by Google [5], which exposes explicitly the clock uncertainty as a first-class citizen. However, as we will see in the next chapters of the book, when one is mainly interested in cause and effects, there are other ways to reason about time using logical clocks instead.
Why distributed systems are hard In general, distributed systems are hard to design, build and reason about, thus increasing the risk of error. This will become more evident later in the book while exploring some algorithms that solve fundamental problems that emerge in distributed systems. It’s worth questioning: why are distributed systems so hard? The answer to this question can help us understand what are the main properties that make distributed systems challenging, thus eliminating our blind spots and providing some guidance on what are some of the aspects we should be paying attention to. The main properties of distributed systems that make them challenging to reason about are the following: • network asynchrony • partial failures • concurrency Network asynchrony is a property of communication networks that cannot provide strong guarantees around delivery of events, e.g. a maximum amount of time required for a message to be delivered. This can create a lot of counter-intuitive behaviours that would not be present in non-distributed systems. This is in contrast to memory operations that can provide much stricter guarantees6 . For instance, in a distributed system messages might 5 6
See: https://en.wikipedia.org/wiki/Time_dilation See: https://en.wikipedia.org/wiki/CAS_latency
CHAPTER 1. INTRODUCTION
8
take extremely long to be delivered, they might be delivered out of order or not at all. Partial failures are cases where only some components of a distributed system fail. This behaviour can come in contrast to certain kinds of applications deployed in a single server that work under the assumption that either the whole server has crashed or everything is working fine. It introduces significant complexity when there is a requirement for atomicity across components in a distributed system, i.e. we need to ensure that an operation is either applied to all the nodes of a system or to none of them. The chapter about distributed transactions analyses this problem. Concurrency is execution of multiple computations happening at the same time and potentially on the same piece of data interleaved with each other. This introduces additional complexity, since these different computations can interfere with each other and create unexpected behaviours. This is again in contrast to simplistic applications with no concurrency, where the program is expected to run in the order defined by the sequence of commands in the source code. The various types of problematic behaviours that can arise from concurrency are explained in the chapter that talks about isolation later in the book. As explained, these 3 properties are the major contributors of complexity in the field of distributed systems. As a result, it will be useful to keep them in mind during the rest of the book and when building distributed systems in real life so that you can anticipate edge cases and handle them appropriately.
Correctness in distributed systems The correctness of a system can be defined in terms of the properties it must satisfy. These properties can be of the following types: • Safety properties • Liveness properties A safety property defines something that must never happen in a correct system, while a liveness property defines something that must eventually happen in a correct system. As an example, considering the correctness properties of an oven, we could say that the property of "the oven not exceeding a maximum temperature threshold" is a safety property. The property of "the oven eventually reaching the temperature we specified via
CHAPTER 1. INTRODUCTION
9
the button" is a liveness property. Similar to this example, in distributed systems, it’s usually more important to make sure that the system satisfies the safety properties than the liveness ones. Throughout this book, it will become clear that there is an inherent tension between safety and liveness properties. Actually, as we will see later in the book, there are some problems, where it’s physically impossible to satisfy both kinds of properties, so compromises are made for some liveness properties in order to maintain safety.
System models Real-life distributed systems can differ drastically in many dimensions, depending on the network where they are deployed, the hardware they are running on etc. Thus, we need a common framework so that we can solve problems in a generic way without having to repeat the reasoning for all the different variations of these systems. In order to do this, we can create a model of a distributed system by defining several properties that it must satisfy. Then, if we prove an algorithm is correct for this model, we can be sure that it will also be correct for all the systems that satisfy these properties. The main properties that are of interest in a distributed system have to do with: • how the various nodes of a distributed system interact with each other • how a node of a distributed system can fail Depending on the nature of communication, we have 2 main categories of systems: synchronous and asynchronous systems. A synchronous system is one, where each node has an accurate clock and there is a known upper bound on message transmission delay and processing time. As a result, the execution is split into rounds so that every node can send a message to another node, the messages are delivered and every node computes based on the messages just received, all nodes running in lock-step. An asynchronous system is one, where there is no fixed upper bound on how long it takes for a message to be delivered or how much time elapses between consecutive steps of a node. The nodes of the system do not have a common notion of time and thus run in independent rates. The challenges arising from network asynchrony have already been discussed previously. So, it should be clear by now that the first model is much easier to describe, program and reason about. However, the second model is closer to real-life distributed systems,
CHAPTER 1. INTRODUCTION
10
such as the Internet, where we cannot have control over all the components involved and there are very limited guarantees on the time it will take for a message to be sent between two places. As a result, most of the algorithms we will be looking at this book assume an asynchronous system model. There are also several different types of failure. The most basic categories are: • Fail-stop: A node halts and remains halted permanently. Other nodes can detect that the node has failed (i.e. by communicating with it). • Crash: A node halts and remains halted, but it halts in a silent way. So, other nodes may not be able to detect this state (i.e. they can only assume it has failed on the basis of not being able to communicate with it). • Omission: A node fails to respond to incoming requests. • Byzantine: A node exhibits arbitrary behavior: it may transmit arbitrary messages at arbitrary times, it may stop or take an incorrect step. Byzantine failures can be exhibited, when a node does not behave according to the specified protocol/algorithm, i.e. because the node has been compromised by a malicious actor or because of a software bug. Coping with these failures introduces significant complexity to the resulting solutions. At the same time, most distributed systems in companies are deployed in environments that are assumed to be private and secure. Fail-stop failures are the simplest and the most convenient ones from the perspective of someone that builds distributed systems. However, they are also not very realistic, since there are cases in real-life systems where it’s not easy to identify whether another node has crashed or not. As a result, most of the algorithms analysed in this book work under the assumption of crash failures.
The tale of exactly-once semantics As described in the beginning of the book, the various nodes of a distributed system communicate with each other by exchanging messages. Given that the network is not reliable, these messages might get lost. Of course, to cope with this, nodes can retry sending them hoping that the network will recover at some point and deliver the message. However, this means that messages might be delivered multiple times, as shown in Figure 1.2, since the sender can’t know what really happened.
CHAPTER 1. INTRODUCTION
Figure 1.2: Intricacies of a non-reliable network in distributed systems
11
CHAPTER 1. INTRODUCTION
12
This duplicate delivery of a message can create disastrous side-effects. For instance, think what would happen if that message is supposed to signal transfer of money between 2 bank accounts as part of a purchase; a customer might be charged twice for a product. To handle scenarios like this, there are multiple approaches to ensure that the processing of a message will only be done once, even though it might be delivered multiple times. One approach is using idempotent operations. Idempotent is an operation that can be applied multiple times without changing the result beyond the initial application. An example of an idempotent operation is adding a value in a set of values. Even if this operation is applied multiple times, the applications that follow the first one will have no effect, since the value will already have been added in the set. Of course, this is under the assumption that other operations cannot remove values from the set. Otherwise, the retried operation might add a value that had been removed in the meanwhile. On the contrary, an example of a non-idempotent operation would be increasing a counter by one, which has additional side-effects every time it’s applied. By making use of idempotent operations, we can have a guarantee that even if a message is delivered multiple times and the operation is repeated, the end result will be the same. However, as demonstrated previously idempotent operations commonly impose tight constraints on the system. So, in many cases we cannot build our system, so that all operations are idempotent by nature. In these cases, we can use a de-duplication approach, where we give every message a unique identifier and every retried message contains the same identifier as the original. In this way, the recipient can remember the set of identifiers it has received and executed already and avoid executing operations that have already been executed. It is important to note that in order to do this, one must have control on both sides of the system (sender and receiver). This is due to the fact that the ID generation is done on the sender side, but the deduplication process is done on the receiver side. As an example, imagine a scenario where an application is sending emails as part of an operation. Sending an e-mail is not an idempotent operation, so if the e-mail protocol does not support de-duplication on the receiver side, then we cannot be absolutely sure that every e-mail is shown exactly once to the recipient. When thinking about exactly-once semantics, it’s useful to distinguish between the notions of delivery and processing. In the context of this discussion, let’s consider delivery being the arrival of the message at the destination node at the hardware level. Then, we consider processing being the handling
CHAPTER 1. INTRODUCTION
13
of this message from the software application layer of the node. In most cases, what we really care about is how many times a message is processed, not how many times it has been delivered. For instance, in our previous e-mail example, we are mainly interested in whether the application will display the same e-mail twice, not whether it will receive it twice. As the previous examples demonstrated, it’s impossible to have exactly-once delivery in a distributed system. It’s still sometimes possible though to have exactly-once processing. With all that said, it’s important to understand the difference between these 2 notions and make clear what you are referring to, when you are talking about exactly-once semantics. Also, as a last note, it’s easy to see that at-most-once delivery semantics and at-least-once delivery semantics can be trivially implemented. The former can be achieved by sending every message only one time no matter what happens, while the latter one can be achieved by sending a message continuously, until we get an acknowledgement from the recipient.
Failure in the world of distributed systems It is also useful to understand that it is very difficult to identify failure because of all the characteristics of a distributed system described so far. The asynchronous nature of the network in a distributed system can make it very hard to differentiate between a node that has crashed and a node that is just really slow to respond to requests. The main mechanism used to detect failures in a distributed systems are timeouts. Since messages can get infinitely delayed in an asynchronous network, timeouts impose an artificial upper bound on these delays. As a result, when a node is slower than this bound, we can assume that the node has failed. This is useful, since otherwise the system might be blocked eternally waiting for nodes that have crashed under the assumption that they might just be extremely slow. However, this timeout does not represent an actual limit, so it creates the following trade-off. Selecting a smaller value for this timeout means that our system will waste less time waiting for nodes that have crashed. At the same time, the system might be declaring dead some nodes that have not crashed, but they are just being a bit slower than expected. On the other hand, selecting a larger value for this timeout means that the system will be more lenient with slow nodes. However, it also implies that the system will be slower in identifying crashed nodes, thus wasting time waiting for them
CHAPTER 1. INTRODUCTION
14
in some cases. This is illustrated in Figure 1.3.
Figure 1.3: Trade-offs in failure detection In fact, this is a very important problem in the field of distributed systems. The component of a node that is used to identify other nodes that have failed is called a failure detector. As we explained previously, this component is very important for various algorithms that need to make progress in the presence of failures. There has been extensive research about failure detectors [6]. The different categories of failure detectors are distinguished by 2 basic properties that reflect the aforementioned trade-off: completeness and accuracy. Completeness corresponds to the percentage of crashed nodes a failure detector succeeded in identifying in a certain period. Accuracy corresponds to the number of mistakes a failure detector made in a certain period. A perfect failure detector is one that is characterised by the strongest form of completeness and accuracy, namely one that can successfully detect every faulty process without ever thinking a node has crashed before it actually crashes. As expected, it is impossible to build a perfect failure detector in purely asynchronous systems. Still, even imperfect failure detectors can be
CHAPTER 1. INTRODUCTION
15
used to solve difficult problems, such as the problem of consensus which is described later.
Stateful and Stateless systems We could say that a system can belong in one of the 2 following categories: • stateless systems • stateful systems A stateless system is one that maintains no state of what has happened in the past and is capable of performing its capabilities, purely based on the inputs provided to it. For instance, a contrived stateless system is one that receives a set of numbers as input, calculates the maximum of them and returns it as the result. Note that these inputs can be direct or indirect. Direct inputs are those included in the request, while indirect inputs are those potentially received from other systems to fullfil the request. For instance, imagine a service that calculates the price for a specific product by retrieving the initial price for it and any currently available discounts from some other services and then performing the necessary calculations with this data. This service would still be stateless. On the other hand, stateful systems are responsible for maintaining and mutating some state and their results depend on this state. As an example, imagine a system that stores the age of all the employees of a company and can be asked for the employee with the maximum age. This system is stateful, since the result depends on the employees we’ve registered so far in the system. There are some interesting observations to be made about these 2 types of systems: • Stateful systems can be really useful in real-life, since computers are much more capable in storing and processing data than humans. • Maintaining state comes with additional complexity, such as deciding what’s the most efficient way to store it and process it, how to perform back-ups etc. • As a result, it’s usually wise to create an architecture that contains clear boundaries between stateless components (which are performing business capabilities) and stateful components (which are responsible for handling data).
CHAPTER 1. INTRODUCTION
16
• Last and most relevant to this book, it’s much easier to design, build and scale distributed systems that are stateless when compared to stateful ones. The main reason for this is that all the nodes (e.g. servers) of a stateless system are considered to be identical. This makes it a lot easier to balance traffic between them and scale by adding or removing servers. However, stateful systems present many more challenges, since different nodes can hold different pieces of data, thus requiring additional work to direct traffic to the right place and ensure each instance is in sync with the other ones. As a result, some of the book’s examples might include stateless systems, but the most challenging problems we will cover in this book are present mostly in stateful systems.
Chapter 2
Basic concepts and theorems Partitioning As we described previously, one of the major benefits of distributed systems is scalability, allowing us to store and process datasets much larger than what one could do with a single machine. One of the primary mechanisms of achieving scalability, is called partitioning. Partitioning is the process of splitting a dataset into multiple, smaller datasets and then assigning the responsibility of storing and processing them to different nodes of a distributed system. This allows us to increase the size of the data our system can handle, by adding more nodes to the system. There are 2 different variations of partitioning: vertical partitioning and horizontal partitioning (also called sharding). The terms vertical and horizontal originate from the era of relational databases, which established the notion of a tabular view of data.1 In this view, data consist of rows and columns, where a row is a different entry in the dataset and each column is a different attribute for every entry. Figure 2.1 contains a visual depiction of the difference between these 2 approaches. Vertical partitioning involves splitting a table into multiple tables with fewer columns and using additional tables to store columns that serve the purpose of relating rows across tables (commonly referred to as a join operation). These different tables can then be stored in different nodes. Normalization2 1 2
See: https://en.wikipedia.org/wiki/Relational_model See: https://en.wikipedia.org/wiki/Database_normalization
17
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
18
Figure 2.1: Vertical and Horizontal Partitioning is one way to perform vertical partitioning, but general vertical partitioning can go far beyond that, splitting columns, even when they are normalized. On the other hand, horizontal partitioning involves splitting a table into multiple, smaller tables, where each of those tables contain a percentage of the rows of the initial table. These different sub-tables can then be stored in different nodes. There are multiple strategies for performing this split, as we will see later on. A simplistic approach is an alphabetical split. For instance, in a table containing the students of a school, we could partition horizontally, using the surname of the students, as shown in Figure 2.2. Partitioning helps with allowing a system to handle larger datasets more efficiently, but it also introduces some limitations. In a vertically partitioned system, requests that need to combine data from different tables (i.e. join operations) become less efficient, because these requests might now have to access data from multiple nodes. In a horizontally partitioned system, this is usually avoided, because all the data for each row is located in the same node. However, it can happen for requests that are searching for a range of rows and these rows belong to multiple nodes. Another important implication of horizontal partitioning is the potential for loss of transactional semantics. When storing data in a single machine, it’s easy to perform multiple operations in an atomic way, so that either all of them succeed or
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
19
Figure 2.2: Horizontal Partitioning using alphabetical order none of them succeeds, but this is much harder to achieve in a distributed system (as we will see in the chapter about distributed transactions). As a result, when partitioning data horizontally, it’s much harder to perform atomic operations over data that reside in different nodes. This is a common theme in distributed systems; there’s no silver bullet, one has to make trade-offs in order to achieve a desired property. Vertical partitioning is mainly a data modelling practice, which can be performed by the engineers designing a system, sometimes independently of the storage systems that will be used. However, horizontal partitioning is commonly provided as a feature of distributed databases, so it’s important for engineers to know how it works under the hood in order to make proper use of these systems. As a result, we will focus mostly on horizontal partitioning in this book.
Algorithms for horizontal partitioning There are a lot of different algorithms for performing horizontal partitioning. In this section, we will study some of these algorithms, discussing the advantages and drawbacks of each one. Range partitioning is a technique, where a dataset is split into ranges, according to the value of a specific attribute. Each range is then stored in a separate node. The case we described previously with the alphabetical split is an example of range partitioning. Of course, the system should store and maintain a list of all these ranges, along with a mapping, indicating which
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
20
node stores a specific range. In this way, when the system is receiving a request for a specific value (or a range of values), it consults this mapping to identify to which node (or nodes, respectively) the request should be redirected. The advantages of this technique are: • its simplicity and ease of implementation. • the ability to perform range queries, using the value that is used as the partitioning key. • a good performance for range queries using the partitioning key, when the queried range is small and resides in a single node. • easy and efficient way to adjust the ranges (re-partition), since one range can be increased or decreased, exchanging data only between 2 nodes. Some of its disadvantages are: • the inability to perform range queries, using other keys than the partitioning key. • a bad performance for range queries using the partitioning key, when the queried range is big and resides in multiple nodes. • an uneven distribution of the traffic or the data, causing some nodes to be overloaded. For example, some letters are more frequent as initial letters in surnames,3 which means that some nodes might have to store more data and process more requests. Some systems that leverage a range partitioning technique are Google’s BigTable [7] and Apache HBase.4
Hash partitioning is a technique, where a hash function is applied to a specific attribute of each row, resulting in a number that determines which partition (and thus node) this row belongs to. For the sake of simplicity, let’s assume we have one partition per node (as in the previous example) and a hash function that returns an integer. If we have n number of nodes in our system and trying to identify which node a student record with a surname s is located at, then we could calculate it using the formula hash(s) mod n. This mapping process needs to be done both when writing a new record and when receiving a request to find a record for a specific value of this attribute. 3 4
See: http://surnamestudies.org.uk/teaching/micro.htm See: https://hbase.apache.org/
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
21
The advantages of this technique are: • the ability to calculate the partitioning mapping at runtime, without needing to store and maintain the mapping. This has benefits both in terms of data storage needs and performance (since no additional request is needed to find the mapping). • a bigger chance of the hash function distributing the data more uniformly across the nodes of our system, thus preventing some nodes from being overloaded. Some disadvantages of this technique are: • the inability to perform range queries at all (even for the attribute used as a partitioning key), without storing additional data or querying all the nodes. • adding/removing nodes from the systems causes re-partitioning, which results in significant movement of data across all nodes of the system.
Consistent hashing is a partitioning technique, having very similar characteristics to the previous one, but solving the problem of increased data movement during re-partitioning. The way it works is the following: each node in the system is randomly assigned an integer in a range [0, L], called ring (i.e. [0, 360]). Then, a record with a value s for the attribute used as partitioning key is located to the node that is the next one after the point hash(s) mod L in the ring. As a result, when a new node is added to the ring, it receives data only from the previous node in the ring, without any more data needed to be exchanged between any other nodes. In the same way, when a node is removed from the ring, its data just need to be transferred to the next node in the ring. For a visual representation of this behaviour and the difference between these 2 different algorithms, see Figure 2.3. Some disadvantages of this technique are: • the potential for non-uniform distribution of the data, because of the random assignment of the nodes in the ring. • the potential for creating more imbalanced data distribution as nodes are added or removed. For example, when a node is removed, its dataset is not distributed evenly across the system, but it’s transferred to a single node. Both of these issues can be mitigated by using the concept of "virtual nodes",
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
22
Figure 2.3: Re-partitioning, when a node (N3) is removed, in hash partitioning and consistent hashing
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
23
where each physical node is assigned multiple locations (virtual nodes) in the ring. For more discussion on this concept, feel free to read the Dynamo paper [8]. Another widely used system that makes use of consistent hashing is Apache Cassandra [9].
Replication As we discussed in the previous section, partitioning can improve the scalability and performance of a system, by distributing data and request load to multiple nodes. However, the introduction mentioned another dimension that benefits from the usage of a distributed system and that was availability. This property directly translates to the ability of the system to remain functional despite failures in parts of it. Replication is the main technique used in distributed systems in order to increase availability. It consists of storing the same piece of data in multiple nodes (called replicas), so that if one of them crashes, data is not lost and requests can be served from the other nodes in the meanwhile. However, the benefit of increased availability from replication comes with a set of additional complications. Replication implies that the system now has multiple copies of every piece of data, which must be maintained and kept in sync with each other on every update. Ideally, replication should function transparently to the end-user (or engineer), creating the illusion that there is only a single copy of every piece of data. This makes a distributed system look like a simple, centralised system of a single node, which is much easier to reason about and develop software around. Of course, this is not always possible; it might require significant hardware resources or giving up other desired properties to achieve this ideal. For instance, engineers are sometimes willing to accept a system that provides much higher performance, giving occasionally a non-consistent view of the data as long as this is done only under specific conditions and in a specific way they can account for, when designing the overall application. As a result of this, there are 2 main strategies for replication: • Pessimistic replication: this strategy tries to guarantee from the beginning that all of the replicas are identical to each other, as if there was only a single copy of the data all along. • Optimistic replication (also called lazy replication): this strategy allows the different replicas to diverge, guaranteeing that they will
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
24
converge again if the system does not receive any updates (also known as quiesced) for a period of time. Replication is a very active field in research, so there are many different algorithms. As an introduction, we will now discuss the 2 main techniques: single-master replication and multi-master replication.
Single-master replication Single-master replication is a technique, where a single node amongst the replicas is designated as master (or primary) and is responsible for receiving all the updates5 . The remaining replicas are commonly referred to as slaves (or secondaries) and they can only handle read requests. Every time the master receives an update, it’s responsible for propagating this update to the other nodes besides executing it locally, ensuring all the replicas will maintain a consistent view of the data. This propagation of the updates can be done in 2 ways: either synchronously or asynchronously. In synchronous replication, the node can reply to the client indicating the update has been completed, only after having received acknowledgements from the other replicas that they have also performed the update on their local storage. This guarantees that after an update has been acknowledged to a client, the client will be able to view this update in a subsequent read, no matter which replica it reads from. Furthermore, it provides increased durability, since the update will not be lost, even if the master crashes right after acknowledging the update. However, this technique can make write requests slower, since the master has to wait until responses have been received from all the replicas. In asynchronous replication, the node can reply to the client as soon as it has performed the update in its local storage, without waiting for responses from the other replicas. This increases performance significantly for write requests, since the client does not pay the penalty of the network requests to the other replicas anymore. However, this comes at a cost of reduced consistency and decreased durability. After a client has received a response for an update request, he might read older (stale) values in a subsequent read, if this operation happens in one of the replicas that has not performed the update yet. On top of that, if the master node crashes right 5
This technique is also known as primary-backup replication.
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
25
after acknowledging an update and the "propagation" requests to the other replicas are lost, then an update that has been acknowledged is eventually lost. The difference between these 2 techniques is visualised in Figure 2.4. The main advantages of single-master replication are: • it’s simple to understand and to implement. • concurrent operations are serialized in the master node, obviating the need for more complicated, distributed concurrency protocols. In general, this property also makes it easier to support transactional operations. • it’s quite scalable for workloads that are read-heavy, since capacity for read requests can be increased, by adding more read replicas. Its main disadvantages are: • it’s not very scalable for write-heavy workloads, since the capacity for writes is determined by the capacity of a single node (the master). • it imposes an obvious trade-off between performance, durability and consistency. • failing over to a slave node, when the master node crashes is not instant, it might create some downtime and it also introduces risk of errors. In general, there are two different approaches for performing the failover: manual or automated. In the manual approach, the operator selects the new master node and instructs all the nodes accordingly. This is the safest approach, but it can also incur a significant downtime. The alternative is an automated approach, where slave nodes detect that the master node has crashed (e.g. via periodic heartbeats) and attempt to elect a new master node. This can be faster, but it’s also quite risky, because there are many different ways in which the nodes can get confused and arrive to an incorrect state. The chapter about consensus will be covering in more detail this topic, called leader election. • even though read capacity can be scaled by adding more slave nodes, the network bandwidth of the master node can end up being a bottleneck, if there’s a big number of slaves listening for updates. An interesting variation of single-master replication that mitigates this problem is a technique, called chain replication, where nodes form a chain, propagating the updates linearly [10]. Most of the widely used databases, such as PostgreSQL6 or MySQL,7 use 6 7
See: https://www.postgresql.org/ See: https://www.mysql.com/
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
Figure 2.4: Synchronous vs asynchronous replication
26
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
27
a single-master replication technique, supporting both asynchronous and synchronous replication.
Multi-master replication As we’ve seen in the previous section, the single-master replication is a technique, which is easy to implement and operate, it can easily support transactions and can hide the distributed nature of the underlying system (i.e. when using synchronous replication). However, it has some limitations in terms of performance, scalability and availability. As we’ve already discussed, there are some kinds of applications, where availability and performance is much more important than data consistency or transactional semantics. A frequently cited example is that of an e-commerce shopping cart, where the most important thing is for the customers to be able to access their cart at all times and be able to add items in a quick and easy way. Compromising consistency to achieve this is acceptable, as long as there is data reconciliation at some point. For instance, if 2 replicas diverge because of intermittent failures, the customer can still resolve any conflicts, during the checkout process. Multi-master replication is an alternative replication technique that favors higher availability and performance over data consistency8 . In this technique, all replicas are considered to be equal and can accept write requests, being also responsible for propagating the data modifications to the rest of the group. There is a significant difference with the single-master replication; in multi-master replication, there is no single, master node that serializes the requests imposing a single order, since write requests are concurrently handled by all the nodes. This means that nodes might disagree on what the right order is for some requests. This is usually referred to as a conflict. In order for the system to remain operational when this happens, the nodes need to resolve this conflict, agreeing on a single order amongst the available ones. Figure 2.5 shows an example, where 2 write requests can potentially result in a conflict, depending on the latency of the propagation requests between the nodes of the system. In the first diagram, write requests are processed in the same order in all the nodes, so there is no conflict. In the second diagram, the write requests are processed in different order in the various nodes (because of network delays), which results in a conflict. In this 8
This technique is also known as multi-primary replication.
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
28
case, a subsequent read request could receive different results, depending on the node that handles the request, unless we resolve the conflict so that all the nodes converge again to a single value. There are many different ways to resolve conflicts, depending on the guarantees the system wants to provide. An important characteristic of different approaches to resolving conflicts is whether they resolve the conflict eagerly or lazily. In the first case, the conflict is resolved during the write operation. In the second case, the write operation proceeds maintaining multiple, alternative versions of the data record and these are eventually resolved to a single version later on, i.e. during a subsequent read operation. For instance, some common approaches for conflict resolution are: • exposing conflict resolution to the clients. In this approach, when there is a conflict, the multiple available versions are returned to the client, who selects the right version and returns it to the system, resolving the conflict. An example of this could be the shopping cart application, where the customer selects the correct version of his/her cart. • last-write-wins conflict resolution. In this approach, each node in the system tags each version with a timestamp, using a local clock. During a conflict, the version with the latest timestamp is selected. Since there can’t be a global notion of time, as we’ve discussed, this technique can lead to some unexpected behaviours, such as write A overriding write B, even though B happened "as a result" of A. • conflict resolution using causality tracking algorithms. In this approach, the system makes use of an algorithm that keeps track of causal relationships between different requests. When there is a conflict between 2 writes (A, B) and one is determined to be the cause of the other one (suppose A is the cause of B), then the resulting write (B) is retained. However, keep in mind that there can still be writes that are not causally related (requests that are actually concurrent), where the system cannot make an easy decision. We’ll elaborate more on some of these approaches later in the chapter about time and order.
Quorums in distributed systems The main pattern we’ve seen so far is writes being performed to all the replica nodes, while reads are performed to one of them. Ensuring writes
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
Figure 2.5: Conflicts in multi-master replication
29
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
30
are performed to all of them (synchronously) before replying to the client, we can guarantee that the subsequent reads will have seen all the previous writes regardless of the node that processes the read operation. However, this means that availability is quite low for write operations, since failure of a single node makes the system unable to process writes, until the node has recovered. Of course, the reverse strategy could be used; writing data only to the node that is responsible for processing a write operation, but processing read operations, by reading from all the nodes and returning the latest value. This would increase significantly the availability of writes, but it would decrease the availability of reads at the same time. A useful mechanism in achieving a balance in this trade-off is using quorums. For instance, in a system of 3 replicas, we could say that writes need to complete in 2 nodes (also known as a quorum of 2), while reads need to retrieve data from 2 nodes. In this way, we could be sure that reads will read the latest value, because at least one of the nodes in the read quorum will be included in the latest write quorum as well. This is based on the fact that in a set of 3 elements, 2 subsets of 2 elements must have at least 1 common element. This technique was introduced in a past paper [11] as a quorum-based voting protocol for replica control. In general, in a system that has a total of V replicas, every read operation should obtain a read quorum of Vr replicas, while a write operation should obtain a write quorum of Vw replicas, respectively. The values of these quorums should obey the following properties: • Vr + V w > V / 2 • Vw > V / 2 The first rule ensures that a data item is not read and written by 2 operations concurrently, as we just described. The second rule ensures that there is at least one node that will receive both 2 write operations and can impose an order on them. Essentially, this means 2 write operations from 2 different operations cannot occur concurrently on the same data item. Both of the rules together guarantee that the associated distributed database behaves as a centralized, one-replica database system. What this means exactly will become more clear in the sections that follow, which provide more formal definitions of various properties of distributed systems. The concept of a quorum is really useful in distributed systems that are composed of multiple nodes. As we will see later in the book, it has been
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
31
used extensively in other areas, like distributed transactions or consensus protocols. The concept is intentionally introduced early on in the book, so that it is easier to identify it as a pattern in the following chapters.
Safety guarantees in distributed systems Since distributed systems involve a lot of complexity, some safety guarantees are used to ensure that the system will behave in specific expected ways. This makes it easier for people to reason about a system and any potential anomalies that can occur, so that they can build proper safeguards to prevent these anomalies from happening. The main safety guarantees that systems provide are around the following two properties: • atomicity • isolation • consistency The concepts of atomicity and isolation originate from database research and ACID transactions, while by consistency in this book we will mostly refer to the notion of consistency made popular by the CAP theorem. Thus, before going any further it would be useful to have a look at these topics first. It is interesting to observe that each one of these safety guarantees is tightly related to one of the aforementioned reasons distributed systems are hard. Achieving atomicity is in a distributed system is challenging because of the possibility of partial failures. Achieving consistency is challenging because of the network asynchrony and achieving isolation is also challenging because of the inherent concurrency of distributed systems.
ACID transactions ACID is a set of properties of database transactions that are used to provide guarantees around the expected behaviour of transactions in the event of errors, power failures etc. More specifically, these properties are: • Atomicity (A): this property guarantees that a transaction composed of multiple operations is treated as a single unit. This means that either all operations of the transaction are executed or none of them is. This concept of atomicity translates to distributed systems, where the system might need to execute the same operation in multiple nodes of
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
32
the system in an atomic way, so that the operation is either executed to all the nodes or to none. This topic will be covered more extensively in the chapter about distributed transactions. • Consistency (C): this property guarantees that a transaction can only transition the database from one valid state to another valid state, maintaining any database invariants. However, these invariants are application-specific and defined by every application accordingly. For example, if an application has a table A with records that refer to records in a table B through a foreign key relationship9 , the database will prevent a transaction from deleting a record from table A, unless any records in table B referenced from this record have already been deleted. Note that this is not the concept of consistency we will be referring to in the context of distributed systems, that concept will be presented below. • Isolation (I): this property guarantees that even though transactions might be running concurrently and have data dependencies, the end result will be as if one of them was executing at a time, so that there was no interference between them. This prevents a large number of anomalies that will be discussed later. • Durability (D): this property guarantees that once a transaction has been committed, it will remain committed even in the case of failure. In the context of single-node, centralised systems, this usually means that completed transactions (and their effects) are recorded in nonvolatile storage. In the context of distributed systems, this might mean that transactions need to be durably stored in multiple nodes, so that recovery is possible even in the presence of total failures of a node along with its storage facilities.
The CAP Theorem The CAP Theorem [12] is one of the most fundamental theorems in the field of distributed systems, outlining an inherent trade-off in the design of distributed systems. It states that it’s impossible for a distributed data store to simultaneously provide more than 2 of the following properties: • Consistency10 : this means that every successful read request will 9
See: https://en.wikipedia.org/wiki/Foreign_key As implied earlier, the concept of consistency in the CAP theorem is completely different from the concept of consistency in ACID transactions. The notion of consistency 10
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
33
receive the result of the most recent write request. • Availability: this means that every request receives a non-error response, without any guarantees on whether that reflects the most recent write request. • Partition Tolerance: this means that the system can continue to operate despite an arbitrary number of messages being dropped by the network between nodes due to a network partition. It is very important to understand though that partition tolerance is not a property you can abandon. In a distributed system, there is always the risk of a network partition. If this happens, then the system needs to make a decision either to continue operating while compromising data consistency or stop operating, thus compromising availability. However, there is no such thing as trading off partition tolerance in order to maintain both consistency and availability. As a result, what this theorem really states is the following: "In the presence of a partition, a distributed system can be either consistent or available." Let’s attempt to schematically prove this theorem in a simplistic way. As shown in Figure 2.6, let’s imagine a distributed system consisting of 2 nodes. This distributed system can act as a plain register, holding the value of a variable, called X. Now, let’s assume that at some point there is a network failure between the 2 nodes of the system, resulting in a network partition between them. A user of the system is performing a write and then a read it could also be 2 different users performing the operations. We will examine the case where each operation is processed by a different node of the system. In that case, the system has 2 options: it can either fail one of the operations (breaking the availability property) or it can process both of the operations returning a stale value from the read (breaking the consistency property). It cannot process both of the operations successfully, while also ensuring that the read returns the latest value, which is the one written by the write operation. The reason is that the results of the write cannot be propagated from node A to node B due to the network partition. This theorem is really important, because it has helped establish this basic limitation that all distributed systems are imposed to. This forced designers of distributed systems to make explicit trade-offs between availability and consistency and engineers become aware about these properties and choose the right system appropriately. When looking at the literature or reading as presented in the CAP theorem is the one that is more important for distributed systems.
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
34
Figure 2.6: Handling a network partition in a distributed system documentation of distributed systems, you will notice systems are usually classified in 2 basic categories, CP and AP, depending on which property the system violates during a network partition. Sometimes, you might even find a third category, called CA. As explained previously, there is no such category for distributed systems and people usually refer either to one of the other two categories instead or to a non-distributed system, such as a single node database. There is another important thing to note about the CAP theorem: this choice between consistency and availability needs to be made only during a network partition. At all other times, both of these properties can be satisfied. However, even during normal operation when no network partition is present, there’s a different trade-off between latency and consistency. In order for the system to guarantee data consistency, it will have to essentially delay write operations until the data have been propagated across the system successfully, thus taking a latency hit. An example of this trade-off is the single-master replication scheme we previously described. In this setting, a synchronous replication approach would favor consistency over latency, while asynchronous replication would benefit from reduced latency at the cost of consistency.
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
35
Figure 2.7: Categories of distributed systems according to the CAP theorem There is actually an extension to the CAP theorem, called the PACELC theorem, captured in a separate article [13]. This theorem states that: • in the case of a network partition (P), the system has to choose between availability (A) and consistency (C) • but else (E) when the system is operating normally in the absence of network partitions, the system has to choose between latency (L) and consistency (C). As a result, each branch of this theorem creates 2 sub-categories of systems. The first part of the theorem defines the two categories we have already seen: AP and CP. The second part defines two new categories: EL and EC. These sub-categories are combined to form 4 categories in total: AP/EL, CP/EL, AP/EC, CP/EC. For instance, a system from the AP/EL category will prioritise availability during a network partition and it will prioritise latency during normal operation. In most of the cases, systems are designed with an overarching principle in mind, which is usually either performance and availability or data consistency. As a result, most of the systems tend to fall into the categories AP/EL or CP/EC. However, there are still systems that cannot be strictly classified in one of these categories, since they have various levers that can be used to tune the system differently when needed. Still, this theorem serves as a good indicator of the various forces at play in a distributed system. You can find a table with the categorisation of several distributed systems along these dimensions in the associated Wikipedia
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
36
page11 .
Consistency models In the previous section, we defined consistency as the property that every successful read request will return the result of the most recent write. In fact, this was an oversimplification, since there are many different forms of consistency. In this section, we will introduce the forms that are the most relevant to the topics of this book. As with many other things, in order to define what each of these forms really is, one needs to build a formal model. This is usually called a consistency model and it defines the set of execution histories12 that are valid in a system amongst all the possible ones. In layman’s terms, a model defines formally what behaviours are possible in a distributed system. Consistency models are extremely useful, because they help us formalise the behaviour of a system. Systems can then provide guarantees about their behaviour and software engineers can be confident that the way they use a distributed system (i.e. a distributed database) will not violate any safety properties they care about. In essence, software engineers can treat a distributed system as a black box that provides a set of properties, while remaining unaware of all the complexity that the system assumes internally in order to provide these. We say that a consistency model A is stronger than model B, when the first one allows fewer histories. Alternatively, we could say model A makes more assumptions or poses more restrictions on the possible behaviours of the system. Usually, the stronger the consistency model a system satisfies the easier it is to build an application on top of it, since the developer can rely on stricter guarantees.
There are many different consistency models in the literature. In the context of this book, we will focus on the most fundamental ones, which are the following: • Linearizability • Sequential Consistency 11
See: https://en.wikipedia.org/wiki/PACELC_theorem A history is a collection of operations, including their concurrent structure (i.e. the order they are interleaved during execution). 12
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
37
• Causal Consistency • Eventual Consistency A system that supports the consistency model of linearizability[14] is one, where operations appear to be instantaneous to the external client. This means that they happen at a specific point from the point the client invokes the operation to the point the client receives the acknowledgement by the system the operation has been completed. Furthermore, once an operation is complete and the acknowledgement has been delivered to the client, it is visible to all other clients. This implies that if a client C2 invokes a read operation after a client C1 has received the completion of its write operation, then C2 should see the result of this (or a subsequent) write operation. This property of operations being "instantaneous" and "visible" after they are completed seems obvious, right ? However, as we have discussed previously, there is no such thing as instantaneity in a distributed system. Figure 2.8 might help you understand why. When thinking about a distributed system as a single node, it seems obvious that every operation happens at a specific instant of time and it’s immediately visible to everyone. However, when thinking about the distributed system as a set of cooperating nodes, then it becomes clear that this should not be taken for granted. For instance, the system in the bottom diagram is not linearizable, since T4 > T3 , but still the second client won’t observe the read, because it hasn’t propagated to the node that processes the read operation yet. To relate this to some of the techniques and principles we’ve discussed previously, the non-linearizability comes from the use of asynchronous replication. By using a synchronous replication technique, we could make the system linearizable. However, that would mean that the first write operation would have to take longer, until the new value has propagated to the rest of the nodes (remember the latency-consistency trade-off from the PACELC theorem!). As a result, one can realise that linearizability is a very powerful consistency model, which can help us treat complex distributed systems as much simpler, single-node datastores and reason about our applications more efficiently. Moreover, leveraging atomic instructions provided by hardware (such as CAS operations13 ), one can build more sophisticated logic on top of distributed systems, such as mutexes, semaphores, counters etc., which would not be possible under weaker consistency models. Sequential Consistency is a weaker consistency model, where operations are allowed to take effect before their invocation or after their completion. 13
See: https://en.wikipedia.org/wiki/Compare-and-swap
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
Figure 2.8: Why linearizability is not obvious in a distributed system
38
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
39
As a result, it provides no real-time guarantees. However, operations from different clients have to be seen in the same order by all other clients and operations of every single client preserve the order specified by its program (in this "global" order). This allows many more histories than linearizability, but still poses some constraints that can be useful to real-life applications. For example, in a social networking application, one usually does not really care what’s the ordering of posts between some of his/her friends, but there’s still an expectation that posts from a single friend are displayed in the right order (the one he/she published them at). Following the same logic, one expects his/her comments in a post to appear in the order that he/she submitted them. These are all properties that are captured by this model. In some cases, we don’t even need to preserve this ordering specified by each client’s program, as long as causally related operations are displayed in the right order. In our previous example, one could accept comments from one of his/her friends being displayed in a different order than the one he/she originally submitted them, as long as every comment is displayed after the comment it replies to. This would be expected, since there is a cause-and-effect 14 relationship between a comment and the comments that constitute replies to it. This is the causal consistency model, which requires that only operations that are causally related need to be seen in the same order by all the nodes. Thus, unlike sequential consistency, the other operations that are not causally related can be seen in different orders in the various clients of the system, also without the need to maintain the order of each client’s program. Of course, in order to achieve that each operation needs to contain some information signalling whether it depends on other operations or not. This does not need to be related to time at all and it can be an application-specific property, as in the example we previously described. Causal consistency is one of the weaker forms of consistency, while still preventing a common class of unintuitive behaviours. There are still even simpler applications that do not have the notion of a cause-and-effect and they would benefit from an even simpler consistency model. For instance, it could be acceptable that the order of operations can be different between the multiple clients of the system and reads do not need to return the latest write, as long as the system eventually arrives at a stable state. In this state, if no more write operations are performed, read operations will return the same result. This is the model of eventual consistency. It is one of the weakest forms of consistency, since it does not 14
See: https://en.wikipedia.org/wiki/Causality
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
40
really provide any guarantees around the perceived order of operations or the final state the system converges to. It can still be a useful model for some applications, which do not require stronger assumptions or can detect and resolve inconsistencies at the application level. Note that there are many more consistency models, besides the ones we explained here.15
When explaining the CAP theorem, we encountered the term consistency, but which of all ? The C property in the CAP theorem refers to the linearizability model we previously described. This means it’s impossible to build a system that will be available during a network partition, while also being linearizable. In fact, there has been research that shows that even some weaker forms of consistency, such as sequential consistency, cannot be supported in tandem with availability under a network partition [15]. This vast number of different consistency models creates a significant amount of complexity. As we explained previously, modelling consistency is supposed to help us reason about these systems. However, the explosion of consistency models can have the opposite effect. The CAP theorem can conceptually draw a line between all these consistency models and separate them into 2 major categories: strong consistency models and weak consistency models. Strong consistency models correspond to the C in the CAP theorem and cannot be supported in systems that need to be available during network partitions. On the other hand, weak consistency models are the ones that can be supported, while also preserving availability during a network partition. Looking at the guarantees provided by several popular distributed systems nowadays (i.e. Apache Cassandra, DynamoDB etc.), there are 2 models that are commonly supported. The first one is strong consistency, specifically linearizability. The second one is weak consistency, specifically eventual consistency. Most probably, the reasons most of the systems converged to these 2 models are the following: • Linearizability was selected amongst the available strong consistency models, because in order to support a strong consistency model, a system needs to give up availability, as part of the CAP theorem. It then seems reasonable to provide the strongest model amongst the 15
See: https://en.wikipedia.org/wiki/Consistency_model
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
41
available ones, facilitating the work of the software engineers having to work with it. • Eventual Consistency was selected amongst the available weak consistency models thanks to its simplicity and performance. Thinking along the same lines, given the application relinquishes the strict guarantees of strong consistency for increased performance, it might as well accept the weakest guarantees possible to get the biggest performance boost it can. This makes it much easier for people designing and building applications on top of distributed systems to make a decision, when deciding which side of the CAP theorem they prefer to build their application on.
Isolation levels As mentioned already, the inherent concurrency in distributed systems creates the potential for anomalies and unexpected behaviours. Specifically, transactions that are composed of multiple operations and run concurrently can lead to different results depending on how their operations are interleaved. As a result, there is still a need for some formal models that define what is possible and what is not in the behaviour of a system. These are called isolation levels. We will study the most common ones here which are the following: • • • • •
Serializability Repeatable read Snapshot Isolation Read Committed Read Uncommitted
Unlike the consistency models presented in the previous section, some of these isolation levels do not define what is possible via some formal specification. Instead, they define what is not possible, i.e. which anomalies are prevented amongst the ones that are already known. Of course, stronger isolation levels prevent more anomalies at the cost of performance. Let’s first have a look at the possible anomalies before examining the various levels.
The origin of the isolation levels above and the associated anomalies was essentially the ANSI SQL-92 standard[16]. However, the definitions in this
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
42
standard were ambiguous and missed some possible anomalies. Subsequent research[17] examines more anomalies extensively and attempts a stricter definition of these levels. The basic parts will be covered in this section, but feel free to refer to it if you are looking for a deeper analysis. The anomalies covered here are the following: • • • • • • •
Dirty writes Dirty reads (Fuzzy) non-repeatable reads Phantom reads Lost updates Read skew Write skew
A dirty write occurs when a transaction overwrites a value that has previously been written by another transaction that is still in-flight and has not been committed yet. One reason dirty writes are problematic is they can violate integrity constraints. For example, imagine there are 2 transactions A and B, where transaction A is running the operations [x=1, y=1] and transaction B is running the operations [x=2, y=2]. Then, a serial execution of them would always result in a situation where x and y have the same value, but in a concurrent execution where dirty writes are possible this is not necessarily true. An example could be the following execution [x=1, x=2, y=2, commit B, y=1, commit A] that would result in x=2 and y=1. Another problem of dirty writes is they make it impossible for the system to automatically rollback to a previous image of the database. As a result, this is an anomaly that needs to be prevented in most cases. A dirty read occurs when a transaction reads a value that has been written by another transaction that has not been committed yet. This is problematic, since decisions might be made by the system depending on these values even though the associated transactions might be rolled back subsequently. Even in the case where these transactions eventually commit, this can still be problematic though. An example is the classic scenario of a bank transfer where the total amount of money should be observed to be the same at all times. However, if a transaction A is able to read the balance of two accounts that are involved in a transfer right in the middle of another transaction B that performs the transfer from account 1 to account 2, then it will look like as if some money have been lost from account 1. However, there are a few cases where allowing dirty reads can be useful, if done with care. One such case is to generate a big aggregate report on a full table, when one
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
43
can tolerate some inaccuracies on the numbers of the report. It can also be useful when troubleshooting an issue and one wants to inspect the state of the database in the middle of an ongoing transaction. A fuzzy or non-repeatable read occurs when a value is retrieved twice during a transaction (without it being updated in the same transaction) and the value is different. This can lead to problematic situations similar to the example presented above for dirty reads. Other cases where this can lead to problems is if the first read of the value is used for some conditional logic and the second is used in order to update data. In this case, the transaction might be acting on stale data. A phantom read occurs when a transaction does a predicate-based read and another transaction writes or removes a data item matched by that predicate while the first transaction is still in flight. If that happens, then the first transaction might be acting again on stale data or inconsistent data. For example, let’s say transaction A is running 2 queries to calculate the maximum and the average age of a specific set of employees. However, between the 2 queries transaction B is interleaved and inserts a lot of old employees in this set, thus making transaction A return an average that is larger than the maximum! Allowing phantom reads can be safe for an application that is not making use of predicate-based reads, i.e. performing only reads that select records using a primary key. A lost update occurs when two transactions read the same value and then try to update it to two different values. The end result is that one of the two updates survives, but the process executing the other update is not informed that its update did not take effect, thus called lost update. For instance, imagine a warehouse with various controllers that are used to update the database when new items arrive. The transactions are rather simple, reading the number of items currently in the warehouse, adding the number of new items to this number and then storing the result back to the database. This anomaly could lead to the following problem: transactions A and B read simultaneously the current inventory size (say 100 items), add the number of new items to this (say 5 and 10 respectively) and then store this back to the database. Let’s assume that transaction B was the last one to write, this means that the final inventory is 110, instead of 115. Thus, 5 new items were not recorded! See Figure 2.9 for a visualisation of this example. Depending on the application, it might be safe to allow lost updates in some cases. For example, consider an application that allows multiple administrators to update specific parts of an internal website used by employees of a company.
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
44
In this case, lost updates might not be that catastrophic, since employees can detect any inaccuracies and inform the administrators to correct them without any serious consequences.
Figure 2.9: Example of a lost update A read skew occurs when there are integrity constraints between two data items that seem to be violated because a transaction can only see partial results of another transaction. For example, let’s imagine an application that contains a table of persons, where each record represents a person and contains a list of all the friends of this person. The main integrity constraint is that friendships are mutual, so if person B is included in person A’s list of friends, then A must also be included in B’s list. Everytime someone (say P1) wants to unfriend someone else (say P2), a transaction is executed that removes P2 from P1’s list and also removes P1 from P2’s list at a single go. Now, let’s also assume that some other part of the application allows people to view friends of multiple people at the same time. This is done by a transaction that reads the friends list of these people. If the second transaction reads the friends list of P1 before the first transaction has started, but it reads the friends list of P2 after the second transaction has committed, then it will notice an integrity violation. P2 will be in P1’s list of friends, but P1 will not be in P2’s list of friends. Note that this case is not a dirty read,
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
45
since any values written by the first transaction are read only after it has been committed. See Figure 2.10 for a visualisation of this example. A strict requirement to prevent read skew is quite rare, as you might have guessed already. For example, a common application of this type might allow a user to view the profile of only one person at a time along with his or her friends, thus not having a requirement for the integrity constraint described above.
Figure 2.10: Example of a read skew A write skew occurs when two transactions read the same data, but then modify disjoint sets of data. For example, imagine of an application that maintains the on-call rota of doctors in a hospital. A table contains one record for every doctor with a field indicating whether they are oncall. The application allows a doctor to remove himself/herself from the on-call rota if another doctor is also registered. This is done via a transaction that reads the number of doctors that are on-call from this table and if the number is greater than one, then it updates the record corresponding to this doctor to not be on-call. Now, let’s look at the problems that can arise from write skew phenomena. Let’s say two doctors, Alice and Bob, are on-call currently and they both decide to see if they can remove themselves. Two transactions running concurrently might read the state of the database, seeing there are two doctors and removing the associated doctor from being on-call. In the end, the system ends with no doctors being on-call! See Figure 2.11 for a visualisation of this example.
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
46
Figure 2.11: Example of a write skew It must be obvious by now that there are so many different anomalies to consider. On top of that, different applications manipulate data in different ways, so one would have to analyse each case separately to see which of those anomalies could create problems. Of course, there is one isolation level that prevents all of these anomalies, the serializable one[18][19]. Similar to the consistency models presented previously, this level provides a more formal specification of what is possible, e.g. which execution histories are possible. More specifically, it guarantees that the result of the execution of concurrent transactions is the same as that produced by some serial execution of the same transactions. This means that one can only analyse serial executions for defects. If all the possible serial executions are safe, then any concurrent execution by a system at the serializable level will also be safe. However, serializability has performance costs, since it intentionally reduces concurrency to guarantee safety. The other less strict levels provide better performance via increased concurrency at the cost of decreased safety. These models allow some of the anomalies we described previously. Figure 2.12 contains a table with the most basic isolation levels along with the anomalies they prevent. As mentioned before, these isolation levels originated from
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
47
the early relational database systems that were not distributed, but they are applicable in distributed datastores too, as shown later in the book.
Figure 2.12: Isolation levels and prevented anomalies
Consistency and Isolation - Differences and Similarities It is interesting to observe that isolation levels are not that different from consistency models. Both of them are essentially constructs that allow us to express what executions are possible or not possible. In both cases, some of the models are stricter allowing less executions, thus providing increased safety at the cost of reduced performance and availability. For instance, linearizability allows a subset of the executions causal consistency allows, while serializability also allows a subset of the executions snapshot isolation does. This strictness relationship can be expressed in a different way, saying that one model implies another model. For example, the fact that a system provides linearizability automatically implies that the same system also provides causal consistency. Note that there are some models that are not directly comparable, which means neither of them is stricter than the other. At the same time, consistency models and isolation levels have some differences with regards to the characteristics of the allowed and disallowed
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
48
behaviours. A main difference between the consistency models and the isolation levels presented so far is that the consistency models applied to single-object operations (e.g. read/write to a single register), while isolation levels applied to multi-object operations (e.g. read & write from/to multiple rows in a table within a transaction). Looking at the strictest models in these two groups, linearizability and serializability, there is another important difference. Linearizability provides real-time guarantees, while serializability does not. This means that linearizability guarantees that the effects of an operation took place at some point between the time the client invoked the operation and the result of the operation was returned to the client. Serializability only guarantees that the effects of multiple transactions will be the same as if they run in a serial order, but it does not provide any guarantee on whether that serial order would be compatible with real-time order. Figure 2.13 contains the illustration of an example why real-time guarantees are actually important from an application perspective. Think of an automated teller machine that can support 2 transactions: getBalance() and withdraw(amount). The first transaction performs a single operation to read the balance of an account. The second operation reads the balance of an account, reduces it by the specified amount and then returns to the client the specified amount in cash. Let’s also assume this system is serializable. Now, let’s examine the following scenario: a customer with an initial balance of x reads his/her balance and then decides to withdraw 20 dollars by executing a withdraw(20) transaction. After the transaction has been completed and the money is returned, the customer performs a getBalance() operation to check the new balance. However, the machine still returns x as the current balance, instead of x-20. Note that this execution is serializable and the end result is as if the machine executed first the getBalance() transactions and then the withdraw(20) transaction in a completely serial order. This example shows why in some cases serializability is not sufficient in itself. In fact, there is another model that is a combination of linearizability and serializability, called strict serializability. This model guarantees that the result of multiple transactions is equivalent to the result of a serial execution of them that would also be compatible with the real-time ordering of these transactions. As a result, transactions appear to execute serially and the effects of each one of them takes place at some point between its invocation and its completion. As illustated before, strict serializability is often a more useful guarantee
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
Figure 2.13: Why serializability is not enough sometimes
49
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
50
than plain serializability. However, in centralized systems providing strict serializability is simple and as efficient as only providing serializability guarantees. As a result, sometimes systems, such as relational databases, advertise serializability guarantees, while they actually provide strict serializability. This is not necessarily true in a distributed database, where providing strict serializability can be more costly, since additional coordination is required. Therefore, it is important to understand the difference between these two guarantees in order to determine which one is needed depending on the application domain. All of the models presented so far - and many more that were not presented in this book for practical reasons - can be organised in a hierarchical tree according to their strictness and the guarantees they provide. This has actually been done in previous research [15]. Figure 2.14 contains such a tree containing only the models presented in this book.
Why all the formalities The previous chapters spent significant amount of time going through many different formal models. But why do we need all these complicated, formal, academic constructs? As explained before, these constructs help us define different types of properties in a more precise way. As a result, when designing a system it is easier to reason about what kind of properties the system needs to satisfy and which of these models are sufficient to provide the required guarantees. In many cases, applications are built on top of pre-existing datastores and they derive most of their properties from these datastores, since most of the data management is delegated to them. As a consequence, necessary research needs to be done to identify datastores that can provide the guarantees the application needs. Unfortunately, the terminology presented here and the associated models are not used consistently across the industry making decision making and comparison of systems a lot harder. For example, there are datastores that do not state precisely what kind of consistency guarantees their system can provide or at least these statements are well hidden, while they should be highlighted as one of the most important things in their documentation. In some other cases, this kind of documentation exists, but the various levels presented before are misused leading to a lot of confusion. As mentioned
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
Figure 2.14: Hierarchy of consistency
51
CHAPTER 2. BASIC CONCEPTS AND THEOREMS
52
before, one source of this confusion was the initial ANSI-SQL standard. For example, the SERIALIZABLE level provided by Oracle 11g, mySQL 5.7 and postgreSQL 9.0 was not truly serializable and was susceptible to some anomalies. Understanding the models presented here is a good first step in thinking more carefully when designing systems to reduce risk for errors. You should be willing to search the documentation of systems you consider using to understand what kind of guarantees they provide. Ideally, you should also be able to read between the lines and identify mistakes or incorrect usages of terms. This will help you make more informed decisions. Hopefully, it will also help raise awareness across the industry and encourage vendors of distributed systems to specify the guarantees their system can provide.
Part II
Distributed Transactions & Consensus
53
Chapter 3
Distributed Transactions One of the most common problems faced when moving from a centralised to a distributed system is performing some operations across multiple nodes in an atomic way, what is also known as a distributed transaction. In this chapter, we will explore all the complexities involved in performing a distributed transaction, examining several available solutions and the pitfalls of each one.
What is a distributed transaction Before diving on the available solutions, let’s first make a tour of transactions, their properties and what distinguishes a distributed transaction. A transaction is a unit of work performed in a database system, representing a change, which can be potentially composed of multiple operations. Database transactions are an abstraction that has been invented in order to simplify engineers’ work and relieve them from dealing with all the possible failures, introduced by the inherent unreliability of hardware. As described previously, the major guarantees provided by database transactions are usually summed up in the acronym ACID, which stands for: • • • •
Atomicity Consistency Isolation Durability 54
CHAPTER 3. DISTRIBUTED TRANSACTIONS
55
As we mentioned previously, each transaction TX can be composed of multiple operations (op1 , op2 , op3 , ...) and multiple transactions TX1 , TX2 etc. can be executed simultaneously in a database. Atomicity is the property that guarantees that either all of the operations of a transaction will complete successfully or none of them will take effect. In other words, there are no situations where the transaction "partially fails", performing only some of the operations. Consistency ensures that a transaction will transition the database from a valid state to another valid state, maintaining all the invariants defined by the application. As an example, a financial application could define an invariant that states that the balance of every account should always be positive. The database then ensures that this invariant is maintained at all times, while executing transactions. Isolation guarantees that transactions can be executed concurrently without interfering with each other. Durability guarantees that once a transaction has been committed, it will remain committed even in the case of a failure system (i.e. power outage). Each of those properties transfers a set of responsibilities from the application to the database, simplifying the development of applications and reducing the potential errors, because of software bugs or hardware failures. Atomicity implies that our application will not have to take care of all possible failures and have conditional logic in order to bring the database back to a consistent state in the case of a partial failure, rolling back operations that should not have taken effect. Consistency allows us to state the invariants of our application in a declarative way, removing redundant code from our application and allowing the database to perform these checks, when necessary. Isolation allows our applications to leverage concurrency, serving multiple requests by executing transactions in parallel, while being certain that the database will take care to prevent any bugs because of this concurrent execution. Last but not least, durability guarantees that when the database has declared a transaction committed, this will be a final declaration that can’t be reverted, relieving again our application from complicated logic. The aforementioned aspects of consistency and durability do not require special treatment in distributed systems and are relatively straightforward, so there will not be a separate analysis in this book. Consistency is maintained by many different mechanisms provided by databases, such as constraints, cascades, triggers etc. The application is responsible for defining any constraints through these mechanisms and the database is responsible for checking these conditions, while executing transactions, aborting any transactions that violate them. Durability is guaranteed by persisting transactions at non-volatile
CHAPTER 3. DISTRIBUTED TRANSACTIONS
56
storage when they commit. In distributed systems, this might be a bit more nuanced, since the system should ensure that results of a transaction are stored in more than one node, so that the system can keep functioning if a single node fails. In fact, this would be reasonable, since availability is one of the main benefits of a distributed system, as we described in the beginning of this book. This is achieved via replication as described previously. As we just explained, a database transaction is a quite powerful abstraction, which can simplify significantly how applications are built. Given the inherent complexity in distributed systems, one can easily deduce that transactional semantics can be even more useful in distributed systems. In this case, we are talking about a distributed transaction, which is a transaction that takes place in 2 or more different nodes. We could say that there are 2 slightly different variants of distributed transactions. The first variant is one, where the same piece of data needs to be updated in multiple replicas. This is the case where the whole database is essentially duplicated in multiple nodes and a transaction needs to update all of them in an atomic way. The second variant is one, where different pieces of data residing in different nodes need to be updated atomically. For instance, a financial application might be using a partitioned database for the accounts of customers, where the balance of user A resides in node n1, while the balance of user B resides in node n2 and we want to transfer some money from user A to user B. This needs to be done in an atomic way, so that data are not lost (i.e. removed from user A, but not added in user B, because the transaction failed midway). The second variant is the most common use of distributed transactions, since the first variant is mostly tackled via single-master synchronous replication. The aspects of atomicity and isolation are significantly more complex and require more things to be taken into consideration in the context of distributed transactions. For instance, partial failures make it much harder to guarantee atomicity, while the concurrency and the network asynchrony present in distributed systems make it quite challenging to preserve isolation between transactions running in different nodes. Furthermore, they can have farreaching implications for the performance and the availability of a distributed system, as we will see later in this chapter. For this reason, this book contains dedicated sections for these two aspects, analysing their characteristics and some techniques that can be used. Note that some of these techniques are used internally by database systems you might want to use from of your application to store and access data. This means that everything might be hidden from you behind a high-level interface
CHAPTER 3. DISTRIBUTED TRANSACTIONS
57
that allows you to build an application without having to know how the database provides all these capabilities under the hood. It is still useful to have a good understanding of these techniques, because you can make better decisions about which database systems to use. There can also be cases where you might have to use some of these techniques directly at the application level to achieve properties that might not be provided by the database system.
Achieving Isolation Previous sections have described some potential anomalies from concurrent transactions that are not properly isolated. Some examples were also provided that illustrated what the consequences can be in real life from these anomalies. As explained in that section, in order to be completely protected against any of these anomalies, the system should be strictly serializable.1 This section will cover some algorithms that can be used to achieve strict serializability. As explained in previous chapters, a system that provides serializability guarantees that the result of any allowed execution of transactions is the same as that produced by some serial execution2 of the same transactions, hence the name. You might ask: what does same mean in the previous sentence? There are two major types of serializability that establish two different notions of similarity: • view serializability: a schedule is view equivalent to a serial schedule with the same transactions, when all the operations from transactions in the two schedules read and write the same data values ("view" the same data). • conflict serializability: a schedule is conflict equivalent to a serial schedule with the same transactions, when every pair of conflicting operations between transactions are ordered in the same way in both schedules. 1 Note that this applies to transactions that are composed of multiple operations, as explained before. In cases where transactions are composed of a single operation, there is no potential for operations interleaving due to concurrency. In most of these cases, the system needs to satisfy linearizability or some of the weaker consistency models for single-object operations. 2 In the context of isolation, an execution of multiple transactions that corresponds to an ordering of the associated operations is also called a shedule.
CHAPTER 3. DISTRIBUTED TRANSACTIONS
58
It turns out calculating whether a schedule is view serializable is a computationally very hard problem3 , so we will not analyse view serializability further. However, determining whether a schedule is conflict serializable is a much easier problem to solve, which is one of the reasons conflict serializability is widely used. In order to understand conflict serializability, we first have to define what it means for two operations to be conflicting. Two operations are conflicting (or conflict) if: • they belong to different transactions. • they are on the same data item and at least one of them is a write operation, where a write operation inserts, modifies or deletes an object. As a result, we can have three different forms of conflicts: • a read-write conflict • a write-read conflict • a write-write conflict A trivial way to check if a schedule is conflict serializable would be to calculate all possible serial schedules, identify conflicting operations in them and check if their order is the same as in the schedule under examination. As you might have thought already, this would be computationally heavy, since we would need to compute all the possible permutations of transactions. A more practical way of determining whether a schedule is conflict serializable is through a precedence graph. A precedence graph is a directed graph, where nodes represent transactions in a schedule and edges represent conflicts between operations. The edges in the graph denote the order in which transactions must execute in the corresponding serial schedule. As a result, a schedule is conflict serializable if and only if its precedence graph of committed transactions is acyclic. Let’s look at an example to get some intuition about this rule. Figure 3.1 contains a schedule of three transactions T1 , T2 and T3 , where R(Ii ) and W(Ii ) represent a read or write operation on item Ii respectively. As we can see in the diagram, the conflicting operations in this schedule form a precedence graph with a cycle. This means that this schedule is not conflict serializable. The cycle between T1 and T2 means that there must be a serial schedule where T1 executes before T2 and vice-versa, which 3
More specifically, it is an NP-complete problem, which means the time required to solve the problem using any currently known algorithm increases rapidly as the size of the problem grows.
CHAPTER 3. DISTRIBUTED TRANSACTIONS
59
is impossible. Figure 3.2 contains a slightly different schedule of the same transactions that is now conflict serializable, since there are no cycles in the corresponding precedence graph. Looking at the precedence graph, we can see the edges only impose the constraints that T1 must be before T2 and T3 in the corresponding serial schedule. This means that this schedule is conflict equivalent to both serial schedules T1 , T2 , T3 and T1 , T3 , T2 .
Figure 3.1: Precedence graph of a non-conflict serializable schedule The method described above is one way to determine whether a schedule is serializable after the fact. However, what we really need is a way to generate a schedule that is serializable ahead of time. The notion of precedence graph is still very useful. All we need to do is ensure that as we execute operations in the schedule, no cycle is formed. This can be achieved in two basic ways. One way is to prevent transactions from making progress when there is a risk of introducing a conflict that can create a cycle. Another way is to let transactions execute all their operations and check if committing that transaction could introduce a cycle. In that case, the transaction can be aborted and restarted from scratch. These two approaches lead to the two main mechanisms for concurrency control: • Pessimistic concurrency control: this approach blocks a transaction if it’s expected to cause violation of serializability and resumes it when it is safe to do so. This is usually achieved by having transactions
CHAPTER 3. DISTRIBUTED TRANSACTIONS
60
Figure 3.2: Precedence graph of a conflict serializable schedule acquire locks on the data they process to prevent other transactions from processing the same data concurrently. The name pessimistic comes from the fact that this approach assumes that the majority of transactions are expected to conflict with each other, so appropriate measures are taken to prevent this from causing issues. • Optimistic concurrency control: this approach delays the checking of whether a transaction complies with the rules until the end of the transaction. The transaction is aborted if a commit would violate any serializability rules and then it can be restarted and re-executed from the beginning. The name optimistic comes from the fact that this approach assumes that the majority of transactions are expected to not have conflicts, so measures are taken only in the rare case that they do. The main trade-off between pessimistic and optimistic concurrency control is between the extra overhead from locking mechanisms and the wasted computation from aborted transactions. In general, optimistic methods are expected to perform well in cases where there are not many conflicts between transactions. This can be the case for workloads with many readonly transactions and only a few write transactions or in cases where most of the transactions touch different data. Pessimistic methods incur some overhead from the use of locks, but they can perform better in workloads
CHAPTER 3. DISTRIBUTED TRANSACTIONS
61
that contain a lot of transactions that conflict, since they reduce the number of aborts & restarts, thus reducing wasted effort. To understand better the mechanics and trade-offs of these two approaches, we will examine some algorithms from both of these categories in the following sections.
2-phase locking (2PL) 2-phase locking (2PL) is a pessimistic concurrency control protocol that makes use of locks to prevent concurrent transactions from interfering. These locks indicate that a record is being used by a transaction, so that other transactions can determine whether it is safe to use it or not. There are 2 basic types of locks used in this protocol: • Write (exclusive) locks: these locks are acquired when a record is going to be written (inserted/updated/deleted). • Read (shared) locks: these locks are acquired when a record is read. The interaction between these 2 types of locks is the following: • A read lock does not block a read from another transaction. This is the reason they are also called shared, because multiple read locks can be acquired at the same time. • A read lock blocks a write from another transaction. The other transaction will have to wait until the read operation is completed and the read lock released, then it will have to acquire a write lock and perform the write operation. • A write lock blocks both reads and writes from other transactions, which is also the reason it’s also called exclusive. The other transactions will have to wait for the write operation to complete and the write lock to be released, then they will attempt to acquire the proper lock and proceed. If a lock blocks another lock, they are called incompatible. Otherwise, they are called compatible. As a result, the relationships described above can be visualised in a compatibility matrix, as shown in Figure 3.3. The astute reader might notice a similarity between this matrix and the definition of conflicts in conflict serializability. This is not a coincidence, the two-phase locking protocol makes use of these locks to prevent cycles of these conflicts
CHAPTER 3. DISTRIBUTED TRANSACTIONS
62
from forming, as described before. Each type of conflict is represented by an incompatible entry in this matrix.
Figure 3.3: Compatibility of locks in 2PL In this protocol, transactions acquire and release locks in 2 distinct phases: • Expanding phase: in this phase, a transaction is allowed to only acquire locks, but not release any locks. • Shrinking phase: in this phase, a transaction is allowed to only release locks, but not acquire any locks. It’s been implied so far that locks are held per-record. However, it’s important to note that if the associated database supports operations based on predicates, there must also be a way to lock ranges of records (predicate locking), e.g. all the customers of age between 23 and 29. This is in order to prevent anomalies like phantom reads. This protocol is proven to only allow serializable executions to happen [20]. A schedule generated by two-phase locking will be conflict equivalent to a serial schedule, where transactions are serialized in the order they completed their expanding phase. There are some slight variations of the protocol that can provide some additional properties, such as strict two-phase locking (S2PL) or strong strict two-phase locking (SS2PL). The locking mechanism introduces the risk for deadlocks, where two transactions might be waiting on each other for the release of a lock thus never making progress. In general, there are two ways to deal with these deadlocks: • prevention: prevent the deadlocks from being formed in the first place.
CHAPTER 3. DISTRIBUTED TRANSACTIONS
63
For example, this can be done if transactions know all the locks they need in advance and they acquire them in an ordered way. This is typically done by the application, since many databases support interactive transactions and are thus unaware of all the data a transaction will access. • detection: detect deadlocks that occur and break them. For example, this can be achieved by keeping track of which transaction a transaction waits on, using this information to detect cycles that represent deadlocks and then forcing one of these transactions to abort. This is typically done by the database without the application having to do anything extra.
Optimistic concurrency control (OCC) Optimistic concurrency control (OCC) is a concurrency control method that was first proposed in 1981[21], where transactions can access data items without acquiring locks on them. In this method, transactions execute in the following three phases: • begin • read & modify • validate & commit/rollback In the first phase, transactions are assigned a unique timestamp that marks the beginning of the transaction. During the second phase, transaction execute their read and write operations tentatively. This means that when an item is modified, a copy of the item is written to a temporary, local storage location. A read operation first checks for a copy of the item in this location and returns this one, if it exists. Otherwise, it performs a regular read operation from the database. The transaction enters the last phase, when all operations have executed. During this phase, the transaction checks whether there are other transactions that have modified the data this transaction has accessed and have started after this transaction’s start time. If that’s true, then the transaction is aborted and restarted from the beginning, acquiring a new timestamp. Otherwise, the transaction can be committed. The commit of a transaction is performed by copying all the values from write operations from the local storage to the common database storage that other transactions access. It’s important to note that the validation checks and the associated commit operation need to be performed in a single atomic action
CHAPTER 3. DISTRIBUTED TRANSACTIONS
64
as part of a critical section4 . This requires essentially some form of locking mechanism, so there are various optimisations of this approach that attempt to reduce the duration of this phase in order to improve performance. There are different ways to implement the validation logic: • One way is via version checking, where every data item is marked with a version number. Every time a transaction accesses an item, it can keep track of the version number it had at that point. During the validation phase, the transaction can check the version number is the same, which would mean that no other transaction has accessed the item in the meanwhile. • Another way is by using timestamps assigned to transactions, a technique also known as timestamp ordering since the timestamp indicates the order in which a transaction must occur, relative to the other transaction. In this approach, each transaction keeps track of the items that are accessed by read or write operations, known as the read set and the write set. During validation, a transaction performs the following inside a critical section. It records a fresh timestamp, called the finish timestamp, and iterates over all the transactions that have been assigned a timestamp between the transaction’s start and finish timestamp. These are essentially all transactions that have started after the running transaction and have already committed. For each of those transactions, the running transaction checks if their write set intersect with its own read set. If that’s true for any of these transactions, this means that the transaction essentially read a value "from the future". As a result, the transaction is invalid and must be aborted and restarted from the beginning with a fresh timestamp. Otherwise, the transaction is committed and it’s assigned the next timestamp. If you are interested in more details or optimised versions of this protocol, reading the original paper would be a very good starting point[21]. 4
A critical section is some part of a program that can only be executed by only one process at a time, because it accesses shared resources for which concurrent access can lead to erroneous behaviour. In this case, this could happen if some other transaction commits in between the validation and commit of this transaction, which would make the validation results of the this transaction invalid.
CHAPTER 3. DISTRIBUTED TRANSACTIONS
65
Multi-version concurrency control (MVCC) Multiversion Concurrency Control (MVCC) is a technique where multiple physical versions are maintained for a single logical data item. As a result, update operations do not overwrite existing records, but they just write new version of these records. Read operations can then select a specific version of a record, possibly an older one. This is in contrast with the previous techniques, where updates are performed in-place and there is a single record for each data item that can be accessed by read operations. The original protocol was first proposed in a dissertation in 1978[22], but many different variations of the original idea have been proposed since then[23][24]. As the name implies, this technique is focused on the multi-version aspect of storage, so it can be used theoretically with both optimistic and pessimistic schemes. However, most variations use an optimistic concurrency control method in order to leverage the multiple versions of an item from transactions that are running concurrently. In practice, MVCC is commonly used to implement the snapshot isolation level. As explained before, Snapshot Isolation (SI) is an isolation level that essentially guarantees that all reads made in a transaction will see a consistent snapshot of the database from the point it started and the transaction will commit successfully if no other transaction has updated the same data since that snapshot. As a result, it is practically easier to achieve snapshot isolation using an MVCC technique. This works in the following way: • Each transaction is assigned a unique timestamp at the beginning. • Every entry for a data item contains a version that corresponds to the timestamp of the transaction that created this new version. • Every transaction records the following pieces of information during its beginning: the transaction with the highest timestamp that has committed so far (let’s call it Ts ) and the number of active transactions that have started but haven’t been commited yet. • When performing a read operation for an item, a transaction returns the entry with the latest version that is earlier than Ts and does not belong to one of the transactions that were active at the beginning of this transaction. This prevents dirty reads as only committed values from other transactions can be returned. There is an exception to this rule: if the transaction has already updated this item, then this value is returned instead. Fuzzy reads are also prevented, since all the reads
CHAPTER 3. DISTRIBUTED TRANSACTIONS
66
will return values from the same snapshot and will ignore values from transactions that committed after this transaction started. • When performing a write operation for an item, a transaction checks whether there is an entry for the same item that satisfies one of the following criteria: its version is higher than this transaction’s timestamp or its version is lower than this transaction’s timestamp, but this version belongs to one of the transactions that were active at the beginning of this transaction. In any of these cases, the transaction is aborted and can be restarted from scratch with a larger timestamp. In the first case, if the transaction commmitted, then we would have an entry with version Tj committed before an entry with version Ti even though Ti < Tj , which is wrong. In the second case, the transaction is aborted to prevent a lost update anomaly. As explained already, this prevents a lot of the anomalies, but it is still not serializable and some anomalies would still be possible. An example of such an anomaly that would not be prevented is write skew. Figure 3.4 contains an example that illustrates how this can happen. In the schedule shown by this example, none of the transactions sees the versions written by the other transaction. However, this would not be possible in a serial execution.
Figure 3.4: Write skew in MVCC
Research on this field has resulted in an improved algorithm, called Seri-
CHAPTER 3. DISTRIBUTED TRANSACTIONS
67
alizable Snapshot Isolation (SSI), which can provide full serializability [25] and has been integrated in commercial, widely used databases [26]. This algorithm is still optimistic and just adds some extensions on top of what has been described above. The mechanics of the solution are based on a key principle of previous research that showed that all the non-serializable executions under snapshot isolation share a common characteristic. This states that in the precedence graph of any non-serializable execution, there are two rw-dependency edges which form consecutive edges in a cycle and they involve two transactions that have been active concurrently, as shown in Figure 3.5. A rw-dependency is a data dependency between transactions T1 and T2 where T1 reads a version of an item x and T2 produces a version of item x that is later in the version order than the version read by T1 . This was the case in the example presented previously in Figure 3.4 too.
Figure 3.5: Dangerous structure in serialization graph for SSI This approach detects these cases and breaks the cycle when they are about to happen and it prevents them from being formed, by aborting one of the involved transactions. In order to do so, it keeps track of the incoming and outgoing rw-dependency edges of each transaction. If there is a transaction that has both incoming and outgoing edges, the algorithm aborts one of the transactions and retries it later.5 So, it is sufficient to maintain two boolean flags per transaction T.inConflict and T.outConflict denoting whether there is an incoming and outgoing rw-dependency edge. These flags can be 5
Note this can lead to aborts that are false positives, since the algorithm does not check whether there is a cycle. This is done intentionally to avoid the computational costs associated with tracking cycles.
CHAPTER 3. DISTRIBUTED TRANSACTIONS
68
maintained in the following way: • When a transaction T is performing a read, it is able to detect whether there is a version of the same item that was written after the transaction started, e.g. by another transaction U. This would imply a rw-dependency edge, so the algorithm can update T.outConflict and U.inConflict to true. • However, this will not detect cases where the write happens after the read. The algorithm uses a different mechanism to detect these cases too. Every transaction creates a read lock, called SIREAD lock, when performing a read. As a result, when a transaction performs a write it can read the existing SIREAD locks and detect concurrent transactions that have previously read the same item, thus updating accordingly the same boolean flags. Note that these are a softer form of locks, since they do not block other transactions from operating, but they exist mainly to signal data dependencies between them. This means the algorithm preserves its optimistic nature. Figure 3.6 shows how this approach would prevent the write skew anomaly in our previous example. When transaction T2 executes the write operation, it checks for existing SIREAD locks on item I1 . T1 holds such a lock, so transaction T2 updates its inConflict flag to true. Given both the inConflict and the outConflict flags for T2 are true at this moment, this transaction is aborted. For brevity, this explanation omitted some details of SSI. If you are interested, have a look at the related papers[25][26].
Achieving atomicity As explained before, the second challenging aspect of transactions, and especially distributed transactions, is atomicity. One of the benefits of grouping operations inside a transaction is the guarantee that either all of them will be performed or none of them will be performed. As a result, the application developer does not need to think about scenarios of partial failures, where the transaction has failed midway after some of the operations have been performed. Similar to the isolation guarantees, this makes it easier to develop applications and delegates some of the complexity around handling these situations to the persistence layer, e.g. to the datastore used by the application that provides atomicity guarantees.
CHAPTER 3. DISTRIBUTED TRANSACTIONS
69
Figure 3.6: How write skew is prevented in SSI
Guaranteeing atomicity is hard in general, not only in distributed systems. The reason is components can fail unexpectedly regardless of whether they are software or hardware components. Even the simple action of writing some bytes to a file requires extra work to ensure it will be performed in an atomic way and the file will not end up in a corrupted state if the disk fails while executing part of the operation [27]. One common way of achieving atomicity in this case is by using a technique called journalling or write-ahead logging, where metadata about the operation are first written to a separate file along with markers that denote whether an operation has been completed or not. Based on this data, the system is capable of identifying which operations were in-progress when a failure happened and drive them to completion either by undoing their effects and aborting them or by completing the remaining part and committing them. This approach is used extensively in file systems and databases. The issue of atomicity in a distributed system becomes even more complicated, because components (nodes in this context) are separated by the network that is slow and unreliable, as explained already. Furthermore, we do not only need to make sure that an operation is performed atomically in a node, but in most cases we need to ensure that an operation is performed atomically
CHAPTER 3. DISTRIBUTED TRANSACTIONS
70
across multiple nodes. This means that the operation needs to take effect either at all the nodes or at none of them. This problem is also known as atomic commit6 . In the next sections, we will be looking at how atomicity can be achieved in distributed settings. Algorithms are discussed in chronological order, so that the reader can understand what are the pitfalls of each algorithm and how they were addressed by subsequent ones.
2-phase commit (2PC) As we’ve described many times so far, in a distributed system with an unreliable network, just sending a message to the involved nodes would not be enough for executing a distributed transaction. The node initiating the transaction would be unable to know whether the other nodes committed successfully or aborted because of some failure in order to take a final decision about the result of the transaction. Thinking about that, the simplest idea is to add another round of messages, checking what was the result on each node. This is essentially the 2-phase commit protocol (2PC ) [28][29], which consists of 2 phases, as described previously, hence the name. The protocol contains 2 different roles: the coordinator and the participants. Their names reflect their actual responsibilities in the protocol, with the first being responsible for coordinating the different phases of the protocol and the second corresponding to all the nodes that participate in the transaction. Note that one of the participants could also play the role of the coordinator. As described, the protocol contains 2 phases: • The first phase is called the voting phase, where the coordinator sends the transaction to all the participants. The participants execute the transaction up to the point where they are supposed to commit.7 Then, they respond to the coordinator with a vote that shows if the 6
See: https://en.wikipedia.org/wiki/Atomic_commit In some cases, the operations of each transaction are executed separately and before the voting phase, which starts after all the operations of a transaction have been executed. Furthermore, agreement protocols like this usually involve some locking protocol as well, so that other concurrent transactions cannot make participants that have already voted change their mind on whether they can commit or not. For example, the 2-phase commit protocol can be combined with the 2-phase locking protocol. 7
CHAPTER 3. DISTRIBUTED TRANSACTIONS
71
transaction’s operations were executed successfully (Yes vote) or there was some error that means the transaction can’t be committed (No vote). • The second phase is called the commit phase, where the coordinator gathers all the votes from the participants. If all the votes were Yes, then the coordinator messages the participants again with an instruction to commit the transaction. Otherwise, if at least one vote is No, the coordinator instructs the participants to abort the transaction. Finally, the participants reply with an acknowledgement, closing this phase. The fact that a unanimous positive vote is needed for commit means that the transaction will either commit to all the participants or it will be aborted to all of them (atomicity property). The coordinator and the participants make use of a write-ahead-log, where they persist their decisions during the various steps, so that they can recover in case of a failure. The coordinator also uses a timeout, when waiting for the responses from the first phase. If the timeout expires, the coordinator interprets this timeout as a No vote, considering the node has failed. On the other hand, the participants do not apply any timeouts, while waiting for coordinator’s messages since that could lead to participants reaching different conclusions due to timing issues. Figure 3.7 shows what this flow looks like. Since the happy path is straightforward, let’s examine how the protocol handles various kinds of failures: • Failure of a participant in the first phase: As we described before, if a participant fails in the voting phase before replying to the coordinator, the coordinator will time out waiting and assume a No vote on behalf of this participant. This means that the protocol will end up aborting the transaction. • Failure of a participant in the second phase: In this scenario, a participant votes in the first phase, but then fails, before receiving the message from the coordinator and completing the transaction (either by commit or abort). In this case, the protocol will conclude without this node. If this node recovers later on, it will identify that pending transaction and will communicate with the coordinator to find out what was the result and conclude it in the same way. So, if the result of the transaction was successful, any crashed participant will eventually find out upon recovery and commit it, the protocol does not allow aborting it unilaterally.8 Thus, atomicity is maintained. 8
An astute reader will observe that there is a chance that the participants might fail
CHAPTER 3. DISTRIBUTED TRANSACTIONS
Figure 3.7: 2-phase commit flow
72
CHAPTER 3. DISTRIBUTED TRANSACTIONS
73
• Network failures in the same steps of the protocol have similar results to the ones described previously, since timeouts will make them equivalent to node failures. Even though 2-phase commit can handle gracefully all the aforementioned failures, there’s a single point of failure, the coordinator. Because of the blocking nature of the protocol, failures of the coordinator at specific stages of the protocol can bring the whole system to a halt. More specifically, if a coordinator fails after sending a prepare message to the participants, then the participants will block waiting for the coordinator to recover in order to find out what was the outcome of the transaction, so that they commit or abort it respectively. This means that failures of the coordinator can decrease availability of the system significantly. Moreover, if the data from the coordinator’s disk cannot be recovered (e.g. due to disk corruption), then the result of pending transactions cannot be discovered and manual intervention might be needed to unblock the protocol. Despite this, the 2-phase commit has been widely used and a specification for it has also been released, called the eXtended Architecture (XA)9 . In this specification, each of the participant nodes are referred to as resources and they must implement the interface of a resource manager. The specification also defines the concept of a transaction manager, which plays the role of the coordinator starting, coordinating and ending transactions. To conclude, the 2PC protocol satisfies the safety property that all participants will always arrive at the same decision (atomicity), but it does not satisfy the liveness property that it will always make progress.
3-phase commit (3PC) As we described previously, the main bottleneck of the 2-phase commit protocol was failures of the coordinator leading the system to a blocked state. Ideally, we would like the participants to be able to take the lead in some at the point they try to commit the transaction essentially breaking their promise, e.g. because they are out of disk space. Indeed, this is true, so participants have to make the minimum work possible as part of the commit phase to avoid this. For example, the participants can write all the necessary data on disk during the first phase, so that they can signal a transaction is committed by doing minimal work during the second phase (e.g. flipping a bit). 9 See: https://en.wikipedia.org/wiki/X/Open_XA
CHAPTER 3. DISTRIBUTED TRANSACTIONS
74
way and continue the execution of the protocol in this case, but this is not so easy. The main underlying reason is the fact that in the 2nd phase, the participants are not aware of the state of the other participants (only the coordinator is), so taking the lead without waiting for the coordinator can result in breaking the atomicity property. For instance, imagine the following scenario: in the second phase of the protocol, the coordinator manages to send a commit (or abort, respectively) message to one of the participants, but then fails and this participant also fails. If one of the other participants takes the lead, it will only be able to query the live participants, so it will be unable to make the right decision without waiting for the failed participant (or the coordinator) to recover. This problem could be tackled by splitting the first round into 2 sub-rounds, where the coordinator first communicates the votes result to the nodes, waiting for an acknowledgement and then proceeds with the commit/abort message. In this case, the participants would know what was the result from the votes and could complete the protocol in case of a coordinator failure independently. This is essentially the 3-phase commit protocol (3PC ) [30][31]. Wikipedia contains a nice, detailed description of the various stages of the protocol and a nice visual demonstration10 , so we won’t repeat it here, but feel free to refer to this resource for additional study on the protocol. The main benefit of this protocol is that the coordinator stops being a single point of failure. In case of a coordinator failure, the participants are now able to take over and complete the protocol. A participant taking over can commit the transaction if it has received a prepare-to-commit, knowing that all the participants have voted yes. If it has not received a prepare-to-commit, it can abort the transaction, knowing that no participant has committed, without all the participants having received a prepare-to-commit message first. As a result, it seems that the 3PC protocol increases availability, preventing the coordinator from being a single point of failure. However, this comes at the cost of correctness, since the protocol is vulnerable to some kind of failures, such as network partitions. An example of such a failure case is shown in Figure 3.8. In this example, a network partition occurs at a point, where the coordinator has managed to send a prepare-to-commit message only to some participants, while the coordinator fails right after this point, so the participants time out and have to complete the protocol on their own. In this case, the one side of the partition has participants that have received a 10
See: https://en.wikipedia.org/wiki/Three-phase_commit_protocol
CHAPTER 3. DISTRIBUTED TRANSACTIONS
75
prepare-to-commit and continue with committing the transaction. However, the other side of the partition has not received a prepare-to-commit message and thus unilaterally abort the transaction. This can seem like a failure case that is very unlikely to happen. However, the consequences are disastrous if it does happen, since after the network partition is fixed, the system is now at an inconsistent state, where the atomicity property of the transaction has been violated. To conclude, the 3PC protocol satisfies the liveness property that it will always make progress at the cost of violating the safety property of atomicity.
Figure 3.8: Network partition issues in 3PC
CHAPTER 3. DISTRIBUTED TRANSACTIONS
76
A quorum-based commit protocol As we observed in the previous section, the main issue with the 3PC protocol occurs in the end of the second phase, where a potential network partition can bring the system to an inconsistent state. This can happen due to the fact that participants attempt to unblock the protocol, by taking the lead without having a picture of the overall system, resulting in a split-brain situation11 . Ideally, we would like to be able to cope with this network partition, but without compromising on the safety of the protocol. This can be done using a concept we have already introduced in the book, a quorum. This approach is followed by the quorum-based commit protocol [32]. This protocol is significantly more complex, when compared to the other two protocols we described previously, so you should study the original paper carefully, if you want to examine all the possible edge cases. However, we will attempt to give a high-level overview of the protocol in this section. As we mentioned before, this protocol leverages the concept of a quorum to ensure that different sides of a partition do not arrive to conflicting results. The protocol establishes the concept of a commit quorum (VC ) and an abort quorum (VA ). A node can proceed with committing only if a commit quorum has been formed, while a node can proceed with aborting only if an abort quorum has been formed. The values of the abort and commit quorums have to be selected so that the property VA + VC > V holds, where V is the total number of participants of the transaction. Based on the fact that a node can be in only one of the two quorums, it’s impossible for both quorums to be formed in two different sides of the partition, leading in conflicting results. The protocol is composed of 3 different sub-protocols, used in different cases: • the commit protocol, which is used when a new transaction starts • the termination protocol, which is used when there is a network partition • the merge protocol, which is used when the system recovers from a network partition The commit protocol is very similar to the 3PC protocol. The only difference is that the coordinator is waiting for VC number of acknowledgements in the end of the third phase to proceed with committing the transaction. If there is a network partition at this stage, then the coordinator can be rendered unable to complete the transaction. In this case, the participants 11
See: https://en.wikipedia.org/wiki/Split-brain_(computing)
CHAPTER 3. DISTRIBUTED TRANSACTIONS
77
on each side of a partition will investigate whether they are able to complete the transaction, using the termination protocol. Initially, a (surrogate) coordinator will be selected amongst them via leader election. Note that which leader election algorithm is used is irrelevant and even if multiple leaders are elected, this does not violate the correctness of the protocol. The elected coordinator queries the nodes of the partition for their status. If there is at least one participant that has committed (or aborted), the coordinator commits (or aborts) the transaction, maintaining the atomicity property. If there is at least one participant at the prepare-to-commit state and at least VC participants waiting for the votes result, the coordinator sends prepare-to-commit to the participants and continues to the next step. Alternatively, if there’s no participant at the prepare-to-commit state and at least VA participants waiting for the results vote, the coordinator sends a prepare-to-abort message. Note that this message does not exist in the commit protocol, but only in the termination one. The last phase of the termination protocol waits for acknowledgements and attempts to complete the transaction in a similar fashion to the commit protocol. The merge protocol is simple, including a leader election amongst the leaders of the 2 partitions that are merged and then execution of the termination protocol we described. Let’s examine what would happen in the network partition example from the previous section (Figure 3.8). In this case, we had 3 participants (V = 3) and we will assume that the protocol would use quorums of size VA = 2 and VC = 2. As a result, during the network partition, the participant on the left side of the partition would be unable to form a commit quorum. On the other hand, the participants on the right side of the partition would be able to form an abort quorum and they would proceed with aborting the transaction, assuming no more partitions happen. Later on, when the network partition recovers, the merge protocol would execute, ensuring that the participant from the left side of the partition would also abort the transaction, since the new coordinator would identify at least one node that has aborted the transaction. Figure 3.9 contains a visualisation of this execution. An interesting property of the protocol is that one can tune the values of the quorums VA , VC , thus effectively adjusting the protocol’s tendency to complete a transaction via commit or abort in the presence of a partition. To conclude, the quorum-based commit protocol satisfies the safety property that all participants will always arrive at the same decision (atomicity). It does not satisfy the liveness property that it will always make progress, since
CHAPTER 3. DISTRIBUTED TRANSACTIONS
Figure 3.9: Network partition in quorum-based commit
78
CHAPTER 3. DISTRIBUTED TRANSACTIONS
79
there are always degenerate, extreme failure cases (e.g. multiple, continuous and small partitions). However, it’s much more resilient, when compared to 2PC and other protocols and can make progress in the most common types of failures.
How it all fits together As described already many times, transactions need to provide some guarantees if applications are to get some benefit out of using them. Distributed transactions need to provide similar guarantees. Some basic guarantees commonly used are contained in the ACID acronym that has been analysed previously. As explained before, consistency and durability do not require very different treatment in a distributed setting when compared to a centralised, single-node system. For durability, it’s enough for the data to be stored in non-volatile storage before being acknowledged to the client. In a distributed system, it might be better to do this in more than one replicas before acknowledging, so that the system can survive failures of a single node. To achieve consistency, the system can just introduce some additional read & write operations in the transaction’s context that exist to guarantee application consistency is preserved. These operations might be automatically generated, such as referential integrity contraints from foreign keys or cascades, or they might be defined by the application e.g. via triggers. The guarantees of atomicity and isolation are more challenging to preserve and some of the algorithms that can be used were analysed previously. The book examined some algorithms that can help preserve isolation across transactions and some algorithms that can be used to preserve atomicity in a distributed system. These algorithms must be combined in order to guarantee all of these properties. Some combinations of these algorithms might be easier to implement in practice because of their common characteristics. For example, two-phase locking has very similar characteristics with two-phase commit and it’s easier to understand how they can be combined. Spanner [5] is an example of a system that uses a combination of these two techniques to achieve atomicity and isolation, as explained later in the book. Looking at the previous algorithms presented, it is not very hard to realise that some of them introduce either brittleness (e.g. two-phase commit) or a lot of additional complexity to a system (e.g. quorum-based commit). The algorithms presented for isolation can be used both in centralised and
CHAPTER 3. DISTRIBUTED TRANSACTIONS
80
distributed systems. However, their use in a distributed system has several additional implications. For example, two-phase locking requires use of distributed locks, which is something that is not trivial to implement in a distributed system, as explained later in the book. Optimistic techniques, such as snapshot isolation, will require a lot of data transfer between different nodes in a distributed system, which will have adverse effects in terms of performance [33]. As a consequence, using transactions in a distributed system comes at a higher cost when compared to a centralised system and systems that do not have a strong need for them can be designed in such a way that makes it possible to operate safely without them. That is also one of the reasons why many distributed databases either do not provide full support for ACID transactions or force the user to explicitly opt in to use them.
Long-lived transactions & Sagas As explained previously, achieving complete isolation between transactions is relatively expensive. The system either has to maintain locks for each transaction potentially blocking other concurrent transactions from making progress or it might have to abort some transactions to maintain safety which leads to some wasted effort. Furthermore, the longer the duration of a transaction is the bigger the impact of these mechanisms is expected to be on the overall throughput. There is also a positive feedback cycle in that using these mechanisms can cause transactions to take longer, which can increase the impact of these mechanisms. In fact, there is a specific class of transactions, called long-lived transactions (LLT ). These are transactions that are by their nature transactions that have a longer duration in the order of hours or even days, instead of milliseconds. This can happen because this transaction is processing a large amount of data, requires human input to proceed or needs to communicate with 3rd party systems that are slow. Examples of LLTs are: • batch jobs that calculate reports over big datasets • claims at an insurance company, containing various stages that require human input • an online order of a product that spans several days from order to delivery As a result, running these transactions using the common concurrent mech-
CHAPTER 3. DISTRIBUTED TRANSACTIONS
81
anisms would degrade performance significantly, since they would need to hold resources for long periods of time, while not operating on them. What’s more, sometimes these transactions do not really require full isolation between each other, but they still need to be atomic, so that consistency is maintained under partial failures. Thus, researchers came up with a new concept, the saga[34]. The saga is essentially a sequence of transactions T1 , T2 , ..., TN that can be interleaved with other transactions. However, it’s guaranteed that either all of the transactions will succeed or none of them will, maintaining the atomicity guarantee. Each transaction Ti is associated with a so-called compensating transaction Ci , which is executed in case a rollback is needed. The concept of saga transactions can be really useful in distributed systems. As demonstrated in the previous sections, distributed transactions are generally hard and can only be achieved, by making compromises on performance and availability. There are cases, where a saga transaction can be used instead of a distributed transaction, satisfying all of our business requirements while also keeping our systems loosely coupled and achieving good availability and performance. As an example, let’s imagine we are building an e-commerce application, where every order of a customer is composed of several discrete steps, such as credit card authorization, checking warehouse inventory, item shipping, invoice creation & delivery etc. One approach could be to perform a distributed transaction across all these systems for every order. However, in this case, failure of a single component (i.e. the payment system) could potentially bring the whole system to a halt, as we explained previously. An alternative, leveraging the saga pattern, would be to model the order operation as a saga operation, consisting of all these sub-transactions, where each of them is associated with a compensating transaction. For example, debiting a customer’s bank account could have a compensating transaction that would give a refund. Then, we can build the order operation as a sequential execution of these transactions, as shown in Figure 3.10 . In case any of these transactions fails, then we rollback the transactions that have been executed, running their corresponding compensating transactions. There might still be cases where some form of isolation is needed. In the example above, orders from different customers about the same product might share some data, which can lead to interference between each other. For instance, think about the scenario of 2 concurrent orders A and B, where A has reserved the last item from the warehouse. As a result of this, order
CHAPTER 3. DISTRIBUTED TRANSACTIONS
Figure 3.10: Example of a saga transaction
82
CHAPTER 3. DISTRIBUTED TRANSACTIONS
83
B fails at the first step and it’s rejected because of zero inventory. Later on, order A also fails at the second step because the customer’s card does not have enough money and the associated compensating transaction is run returning the reserved item to the warehouse. This would mean that an order would have been rejected while it could have been processed normally. Of course, this violation of isolation does not have severe consequences, but in some cases the consequences might be more serious, e.g. leading to customers being charged without receiving a product. In order to prevent these scenarios, some form of isolation can be introduced at the application layer. This topic has been studied by previous research that proposed some concrete techniques [35], referred to as countermeasures to isolation anomalies. Some of these techniques are: • the use of a semantic lock, which essentially signals that some data items are currently in process and should be treated differently or not accessed at all. The final transaction of a saga takes care of releasing this lock and resetting the data to their normal state. • the use of commutative updates that have the same effect regardless of their order of execution. This can help mitigate cases that are otherwise susceptible to lost update phenomena. • re-ordering the structure of the saga, so that a transaction called as a pivot transaction delineates a boundary between transactions that can fail and those that can’t. In this way, transactions that can’t fail - but could lead to serious problems if being rolled-back due to failures of other transactions - can be moved after the pivot transaction. An example of this is a transaction that increases the balance of an account. This transaction could have serious consequences if another concurrent saga reads this increase in the balance, but then the previous transaction is rolled back. Moving this transaction after the pivot transaction means that it will never be rolled back, since all the transactions after the pivot transaction can only succeeed. These techniques can be applied selectively in cases where they are needed. However, they introduce significant complexity and move some of the burden back to the application developer that has to think again about all the possible failures and design accordingly. These trade-offs need to be taken into consideration when choosing between using saga transactions or leveraging transaction capabilities of the underlying datastore.
Chapter 4
Consensus Amongst all the problems encountered so far in the book, there is a common trait that characterizes most (if not all) of them. It’s the fact that the various nodes of a distributed systems try to reach an agreement on a specific thing. In the case of a distributed transaction, it’s whether a transaction has been committed or not. In case of a message delivery, it’s whether a message has been delivered or not. In fact, this underlying property is common in many more problems in the distributed systems space. As a result, researchers have formally defined this problem and researched possible solutions, since these can then be used as a building block for more complicated problems. This is known as the consensus problem and this chapter of the book is devoted to it.
Defining consensus First of all, we need to formally define the problem of consensus. We assume we have a distributed system, consisting of k nodes (n1 , n2 , ..., nk ), where each one can propose a different value vi . Consensus is the problem of making all these nodes agree on a single value v. The following properties must also be satisfied: • Termination: Every non-faulty node must eventually decide. • Agreement: The final decision of every non-faulty node must be identical.
84
CHAPTER 4. CONSENSUS
85
• Validity: The value that is agreed must have been proposed by one of the nodes.
Some use-cases of consensus As explained before, the problem of consensus lies beneath a lot of other common problems in the distributed systems space. We will now visit some of them and discuss how they relate to the consensus problem. A very common problem is leader election, where the nodes that are part of a distributed system need to elect one node amongst them to act as their leader, coordinating the operation of the whole system. An example of this is the single-master replication scheme that was presented previously in the book. This scheme is based on the fact that one node, designated as primary, will be responsible for performing operations that update data and the other nodes, designated as secondaries, will be following up with the same operations. However, in order to do that the system first needs to select the primary node, which is a process called leader election. Since, all the nodes are practically agreeing on a single value, the identity of the leader, this problem can easily be modelled as a consensus problem. One more common problem is distributed locking. Most distributed systems receive multiple concurrent requests and need to perform some concurrency control to prevent data inconsistencies, because of interference between these requests. One of these concurrency control methods is locking, but using locking in the context of a distributed system comes with a lot of edge-cases, adding a lot of risk. Of course, distributed locking can also be modelled as a consensus problem, where the nodes of the system agree on a single value, which is the node that holds the lock. Another commonly cited problem is atomic broadcast, which is concerned with allowing a set of nodes to concurrently broadcast messages while ensuring that all destinations consistently deliver them in the exact same sequence despite the possible presence of faulty nodes. This problem is also equivalent to consensus, as also demonstrated in previous research [6][36]. The reason we described these problems and demonstrated how they can be modelled as a consensus problem is so that you can appreciate the value of this abstraction and understand that solving the consensus problem can provide solutions to many more problems.
CHAPTER 4. CONSENSUS
86
FLP impossibility Researchers have found a lot of different solutions to this problem, but they have also found important constraints that impose some limitations on the possible solutions. We should note that it’s extremely useful to know the limits of the available solutions to a problem and the research community has benefited massively from this. As a result, this chapter will unfold in a counter-intuitive way, first explaining these limitations and then discussing one of the solutions to the problem. In this way, we hope the reader will be able to gain a better understanding of the problem and will be better equipped to reason about the solution presented later on. As explained previously in the book, there are several different system models with the asynchronous being the one that is really close to real-life distributed systems. So, it’s been proved that in asynchronous systems, where there can be at least one faulty node, any possible consensus algorithm will be unable to terminate, under some scenarios. In other words, there can be no consensus algorithm that will always satisfy all the aforementioned properties. This is referred to as the FLP impossibility after the last initials of the authors of the associated paper[37]. The proof in the paper is quite complicated, but it’s essentially based on the following 2 parts. First, the fact that it’s always possible that the initial state of the system is one, where nodes can reach different decisions depending on the ordering of messages (the so-called bivalent configuration), as long as there can be at least one faulty node. Second, from such a state it’s always possible to end up in another bivalent state, just by introducing delays in some messages. As a result, it’s impossible to develop a consensus algorithm that will always be able to terminate successfully in asynchronous systems, where at least one failure is possible. What we can do instead is develop algorithms that minimize this possibility of arriving at ambivalent situations.
The Paxos algorithm Some of the algorithms we have already studied in previous chapters could arguably be applied as solutions to the consensus problem. For instance, the 2-phase commit protocol could be used, where the coordinator would drive the voting process. However, as we have already seen, such a protocol would have very limited fault-tolerance, since the failure of a single node (the
CHAPTER 4. CONSENSUS
87
coordinator) could bring the whole system to a halt. The obvious next step is to allow multiple nodes to inherit the role of the coordinator in these failure cases. This would then mean that there might be multiple masters that might produce conflicting results. We have already demonstrated this phenomenon in the chapter about multi-master replication and when explaining the 3-phase commit. One of the first algorithms that could solve the consensus problem safely under these failures is called the Paxos algorithm. More specifically, this algorithm guarantees that the system will come to an agreement on a single value, tolerating the failure of any number of nodes (potentially all of them), as long as more than half the nodes are working properly at any time, which is a significant improvement. Funnily enough, this algorithm was invented by Leslie Lamport during his attempt to prove this is actually impossible! He decided to explain the algorithm in terms of a parliamentary procedure used in an ancient, fictional Greek island, called Paxos. Despite being elegant and highly entertaining, this first paper[38] was not well received by the academic community, who found it extremely complicated and could not discern its applicability in the field of distributed systems. A few years later and after several successful attempts to use the algorithm in real-life systems, Leslie decided to publish a second paper[39], explaining the algorithm in simpler terms and demonstrating how it can be used to build an actual, highly-available distributed system. A historical residue of all this is the fact that the Paxos algorithm is regarded as a rather complicated algorithm until today. Hopefully, this section will help dispel this myth. The Paxos algorithm defines 3 different roles: the proposers, the acceptors and the learners. Every node in the system can potentially play multiple roles. A proposer is responsible for proposing values (potentially received from clients’ requests) to the acceptors and trying to persuade them to accept their value in order to arrive at a common decision. An acceptor is responsible for receiving these proposals and replying with their decision on whether this value can be chosen or not. Last but not least, the learners are responsible for learning the outcome of the consensus, storing it (in a replicated way) and potentially acting on it, by either notifying clients about the result or performing actions. Figure 4.1 contains a visual overview of these roles and how they interact with the clients. The algorithm is split into 2 phases, each of which contains two parts: • Phase 1 (a): A proposer selects a number n and sends a prepare request with this number (prepare(n)) to at least a majority of the
CHAPTER 4. CONSENSUS
Figure 4.1: An overview of the paxos protocol
88
CHAPTER 4. CONSENSUS
89
acceptors. • Phase 1 (b): When receiving a prepare request, an acceptor has the following options: – If it has not already responded to another prepare request of a higher number n, it responds to the request with a promise not to accept any more proposals that are numbered less than n. It also returns the highest-numbered proposal it has accepted, if any (note: the definition of a proposal follows). – Otherwise, if it has already accepted a prepare request with a higher number, it rejects this prepare request, ideally giving a hint to the proposer about the number of that other prepare request it has already responded to. • Phase 2 (a): If the proposer receives a response to its prepare(n) requests from a majority of acceptors, then it sends an accept(n, v) request to these acceptors for a proposal numbered n with a value v. The value is selected according to the following logic: – If any of the acceptors had already accepted another proposal and included that in its response, then the proposer uses the value of the highest-numbered proposal among these responses. Essentially, this means that the proposer attempts to bring the latest proposal to conclusion. – Otherwise, if none of the acceptors had accepted any other proposal, then the proposer is free to select any desired value. This value is usually selected based on the clients’ requests. • Phase 2 (b): If the acceptor receives an accept(n, v) request for a proposal numbered n, it accepts the proposal, unless it has already responded to a prepare(k) request of a higher number (k > n). Furthermore, as the acceptors accept proposals, they also announce their acceptance to the learners. When a learner receives an acceptance from a majority of acceptors, it knows that a value has been chosen. This is the most basic version of the Paxos protocol. As we mentioned previously, nodes can play multiple roles for practical reasons and this is usually the case in real-life systems. As an example, one can observe that the proposers can play the role of learners as well, since they will be receiving some of these accept responses anyway, thus minimising traffic and improving the performance of the system. During Phase 1 (a) of the protocol, the proposers have to select a proposal number n. These numbers must be unique in order for the protocol to maintain its correctness properties. This is so that acceptors are always
CHAPTER 4. CONSENSUS
90
able to compare two prepare messages. This can be achieved in several ways, but the easiest one is to compose these numbers out of 2 parts, the one being an integer and the second one being a unique identifier of the proposer (i.e. the IP address of the node). In this way, proposers can draw numbers from the same set. As we have insinuated in the beginning of this section, multiple proposers can initiate concurrent prepare requests. The proposer that receives a response to its prepare request from a majority of acceptors is essentially elected as the current (but temporary) leader. As a result, it can proceed with making a proposal request. The value of this proposal will be the chosen one, unless a majority of acceptors have failed (and did not reply to the proposal) or another leader stepped up becoming the temporary leader in the meanwhile (in which case, the acceptors will reject this proposal). The basic ingredient of the Paxos protocol is a concept we have already seen, namely the quorum. More specifically, the Paxos protocol makes use of majority quorums. A majority quorum is one that consists of more than half of the nodes of the system, i.e. at least k+1 nodes in a system of 2k nodes. The fact that proposers require a majority quorum to reply to their prepare requests to proceed with a proposal guarantees that there can’t be 2 different proposers that complete both phases of the protocol concurrently. As a result, only a single value can be chosen, satisfying the agreement property of consensus.
Intricacies of Paxos As we mentioned previously, many people consider the Paxos protocol to be difficult to understand. One of the reasons for this is the inherent complexity of the consensus problem, which in turn originates from the increased concurrency and large state space of distributed systems. This section will cover some edge cases and how Paxos handles them. Of course, we will not be able to cover all the possible cases, since that would be a much bigger undertaking. But, we hope the examples presented in this section will help you understand the basic parts of the protocol and give you a starting point for exploring any other cases you might think of. For all of these examples, we will assume that the nodes play all the roles of the protocol, thus being proposers, acceptors and learners at the same time, in order to simplify our explanations. Keep in mind that this a realistic assumption, since many implementations of the Paxos protocol follow this approach.
CHAPTER 4. CONSENSUS
91
The beginning of this chapter outlined how Paxos can be used to solve the leader election problem. Nonetheless, Paxos itself needs to elect a leader in order to reach consensus, which seems like a catch-221 . The Paxos protocol resolves this paradox, by allowing multiple leaders to be elected, thus not needing to reach consensus for the leader itself. It still has to guarantee that there will be a single decision, even though multiple nodes might be proposing different values. Let’s examine how Paxos achieves that and what are some of the consequences. When a proposer receives a response to a prepare message from a majority of nodes, it considers itself the (temporary) leader and proceeds with a proposal. If no other proposer has attempted to become the leader in the meanwhile, its proposal will be accepted. However, if another proposer managed to become a leader, the accept requests of the initial node will be rejected. This prevents multiple values to be chosen by the proposals of both nodes. This can result in a situation, where proposers are continuously duelling each other, thus not making any progress, as you can see in Figure 4.2. There are many ways to avoid getting into this infinite loop. The most basic one is forcing the proposers to use random delays or exponential back-off every time they get their accept messages rejected and have to send a new prepare request. In this way, they give more time to the node that is currently leading to complete the protocol, by making a successful proposal, instead of competing.
Another interesting aspect of the Paxos protocol is how it handles partial failures gracefully, maintaining safety at all times. In this context, by partial failures we refer to cases, where a node sends a message to multiple nodes (i.e. accept messages as part of Phase 2.a) and only some of them are delivered either due to node failures or network issues. As an example, let’s examine an extreme case, where multiple proposers attempt to propose different values, but only one of their accept messages gets delivered to the acceptors of the majority quorum. Figure 4.3 provides a visualisation of the execution of the protocol to aid comprehension. Every row represents a different round of the protocol, while the dashed box shows which nodes were included in the majority quorum of Phase 1. The text inside every node displays any proposal that has already been accepted in the form (n, v), where n is the proposal number and v is the value of the proposal. The bold text represents the values that have been accepted in that round. As you can see, every proposer manages to deliver an accept message to only one acceptor 1
See: https://en.wikipedia.org/wiki/Catch-22_(logic)
CHAPTER 4. CONSENSUS
Figure 4.2: The situation of duelling proposers
92
CHAPTER 4. CONSENSUS
93
at every round. For the first 3 rounds, none of the nodes in the majority quorum have accepted any value, so proposers are free to propose their own value. In rounds 4 and 5, proposers have to propose the value of the highest-numbered proposal that has been accepted by the acceptors included in Phase’s 1 majority quorum. This is A for round 4 and B for round 5. As it’s demonstrated for round 6, at this point the behaviour depends partially on the quorum that will be used. For example, if the next proposer selects the yellow quorum, value C is going to be proposed, while value B will be proposed if the green quorum is used instead. However, there is one important thing to note: as soon as the system recovers from failures and a proposer manages to get a proposal accepted by a majority quorum, then this value is chosen and it cannot be changed. The reason is that any subsequent proposer will need to get a majority quorum for Phase 1 of the protocol. This majority will have to contain at least 1 node from the majority that has accepted the aforementioned proposal, which will thus transfer the accepted proposal to the prospective leader. Furthermore, it’s guaranteed this will be the highest-numbered proposal, which means any subsequent proposer can only propagate the chosen value to the acceptors that might not have it yet.
Paxos in real-life As we have seen so far, the Paxos protocol is a well specified protocol. However, there are some small details and optimisations that the original paper could not cover. Some of these topics have been covered in subsequent papers [40]. This section will cover some of these topics as briefly as possible. The basic Paxos protocol describes how a distributed system of multiple nodes can decide on a single value. However, just choosing a single value would have limited practical applications on its own. In order to be able to build more useful systems, we need to be able to continuously select values. This can be achieved by running multiple instances of Paxos, where an instance is an execution of the protocol that leads to a decision on a single value. These instances can run independently and in parallel, but they also have to be numbered. Depending on the functionality needed, there can be several rules applied, such as not returning the result of an instance to the client, unless all the previous instances have completed as well. We will elaborate more on this topic in the next section. Another common need is the ability to query the current state of the system.
CHAPTER 4. CONSENSUS
Figure 4.3: A Paxos execution with partial failures
94
CHAPTER 4. CONSENSUS
95
Of course, the clients of the system learn the chosen values, so they could keep track of the state on their side. But, there will always be cases, where some clients need to retrieve some of the values chosen in the past, i.e. because they are clients that have just been brought into operation. So, Paxos should also support read operations that return the decisions of previously completed instances alongside write operations that start new instances of the protocol. These read operations have to be routed to the current leader of the system, which is essentially the node that completed successfully the last proposal. It’s important to note that this node cannot reply to the client using its local copy. The reason for this is that another node might have done a proposal in the meanwhile (becoming the new leader), thus meaning that the reply will not reflect the latest state of the system.2 As a result, that node will have to perform a read from a majority of nodes, essentially seeing any potential new proposal from another node. You should be able to understand how a majority quorum can guarantee that by now. If not, it would probably be a good idea to revisit the section about quorums and their intersection properties. This means that reads can become quite slow, since they will have to execute in 2 phases. An alternative option that works as an optimisation is to make use of the so-called master leases [41]. Using this approach, a node can take a lease, by running a Paxos instance, establishing a point in time,3 until which it’s guaranteed to be considered the leader and no other node can challenge him. This means that this node can then serve read operations locally. However, one has to take clock skew into account in the implementation of this approach and keep in mind it will be safe only if there’s an upper bound in the clock skew. By the same logic, one could argue that electing a leader in every instance of the Paxos protocol is not as efficient as possible and degrades performance significantly under normal conditions without many failures. Indeed, that is true and there is a slightly adjusted implementation of Paxos, called MultiPaxos that mitigates this issue [42]. In this approach, the node that has performed the last successful proposal is considered the current distinguished proposer. This means that a node can perform a full instance of Paxos and then it can proceed straight to the second phase for the subsequent instances, using the same proposal number that has been accepted previously. 2 This would mean that the read/write consensus operations would not be linearizable. Note that in the context of consensus, operations such as proposals are considered singleobject operations. As a result, there is no need for isolation guarantees. 3 This point in time is essentially the time of the proposal (a timestamp that can be part of the proposal’s value) plus a pre-defined time period, which corresponds to the duration of the lease.
CHAPTER 4. CONSENSUS
96
The rest of the nodes know which node is currently the leader based on which node made the last successful proposal. They can perform periodic health checks and if they believe this node has crashed, they can initiate a prepare request in order to perform a successful proposal and become the distinguished proposer. Essentially, this means that the protocol is much more efficient under stable conditions, since it has only one phase. When failures occur, the protocol just falls back to plain Paxos. Another common need is a way to dynamically update the nodes that are members of the system. The answer to this requirement might sound familiar thanks to the elegance of the protocol; membership information can just be propagated as a new Paxos proposal! The nodes that are member of the system can have their own way of identifying failures of other nodes (i.e. periodic health checks) and the corresponding policies on when a node should be considered dead. When a node is considered dead, one of the nodes that has identified it can trigger a new Paxos instance, proposing a new membership list, which is the previous one minus the dead node. As soon as this proposal completes, all the subsequent instances of Paxos should make use of the updated membership list.
Replicated state machine via consensus In the beginning of this chapter, we briefly described how a consensus algorithm can be used to solve a wide variety of problems. This is not a coincidence, since all these problems share a common, fundamental characteristic. This is the fact that they can all be modelled as a state machine to some extent. This is also the reason why it’s easier to solve them in a centralised setting, but it gets much harder when we want to solve them in a distributed setting in order to increase availability. However, using a consensus algorithm we can build a replicated state machine. This is a set of nodes, where each of them is receiving commands and executing them transitioning between states. If all the nodes make use of the same state machine, then all we need is to make sure that all the nodes receive the same inputs in the same order and then we can guarantee that all the nodes will make exactly the same transitions. This would mean that the distributed system would look similar to a single server from the outside. As a result, one could achieve all the benefits of a distributed system, while maintaining a simple programming model. Figure 4.4 contains a high-level overview of
CHAPTER 4. CONSENSUS
97
such a system. The top layer is the one receiving requests from the clients and creating proposals for the consensus layer, which conducts the necessary coordination between the other nodes of the system and propagates the chosen values to the lower layer, which just receives these values as inputs and executes the necessary state transitions.
Figure 4.4: A replicated state machine using consensus Let’s elaborate a bit more on what that would entail, assuming Paxos is used as the consensus layer of the system. Essentially, the clients would be sending regular requests to the system, depending on the domain of the system. These requests could be either commands to the system or requests to inspect its internal system. These requests would be dispatched to the
CHAPTER 4. CONSENSUS
98
current leader of the system, which will be determined based on previous instances of the consensus. If a node that is not a leader receives a request, it can return the leader’s address so the client can re-route it. When the system is bootstrapped and no instances of consensus have been run yet, this leader can be determined from a configuration file or the nodes can compete with each other for the leader role. Every time the leader node receives a new command, it attempts to execute a new instance of consensus, increasing the instance number every time. In order to achieve satisfactory performance, multiple consensus instances can be run in parallel. However, the necessary serialization must be performed in some places to ensure correctness. For instance, the lower layer should process the decision of a consensus instance, only when it has processed all the previous instances to ensure that all state machines perform the same transitions. Similarly, the leader should wait after an instance is completed and reply to the associated client only after all the previous instances have completed. During situations where the current leader is unstable and other nodes start making proposals, there might be increased contention for an instance, creating significant delays for any subsequent instances that might have completed. A dummy value can be proposed by the nodes in these cases, which essentially represents a no-op, rejecting the client’s operation. This abstraction of a replicated state machine is quite powerful and could potentially be used to implement solutions for many common problems in the distributed systems area.
Distributed transactions via consensus The introduction of this chapter mentioned that the consensus problem is very similar to the problem of distributed transactions. However, after studying the Paxos algorithm, one might think there seems to be a fundamental conflict between distributed transactions and the way Paxos solves the consensus problem. The core characteristic of distributed transactions is atomicity, the fact that either the relevant update has to be performed in all the nodes or it should not be performed in any of them. However, the Paxos algorithm relies on just a majority quorum to decide on a value. Indeed, the problem of distributed transactions, known as atomic commit, and the consensus problem might be closely related, but they are not equivalent[43]. First of all, the consensus problem mandates that every non-faulty node must reach the same decision, while the atomic commit problem requires that all the
CHAPTER 4. CONSENSUS
99
nodes (faulty or not) must reach the same decision. Furthermore, the atomic commit problem imposes stricter relationships between votes or proposals and the final decision than the consensus problem. In consensus, the only requirement is that the value that is agreed must have been proposed by at least one of the nodes. In atomic commit, a decision can be positive, only if all the votes were positive. The decision is also required to be positive, if all votes are positive and there are no failures. As a result of this difference, one might think that the Paxos algorithm does not have anything to offer in the problem space of distributed transactions. This is not true and this section will try to illustrate what Paxos (and any other consensus algorithm) has to offer. The biggest contribution of a consensus algorithm would not be in the communication of the resource managers’ results back to the transaction manager, which requires successful communication for all of them and not just a majority. Its value would lie in storing and transmitting the transaction’s result back to the resource managers in a fault-tolerant way, so that the failure of a single node (the transaction manager) cannot block the system. Indeed, there is a very simple way to achieve that in the existing 2-phase commit (2PC) protocol leveraging a consensus algorithm. Assuming we make use of Paxos as a consensus algorithm, we could just have the transaction manager start a new Paxos instance, proposing a value for the result of the transaction, instead of just storing the result locally before sending it back to the resource managers. The proposal value would be either commit or abort, depending on the previous results of each one of the resource managers. This adjustment on its own would make the 2-phase commit protocol resilient against failures of the transaction manager, since another node could take the role of the transaction manager and complete the protocol. That node would have to read the result of the transaction from any existing Paxos instance. If there’s no decision, that node would be free to make an abort proposal. This is simple and elegant, but it would require adding one more messaging round to the 2-phase commit protocol. It’s actually possible to remove this additional round, trading off some simplicity for increased performance. This could be done by essentially "weaving" several instances of Paxos in the plain 2-phase commit protocol, practically obviating the need for a transaction manager completely. More specifically, the resource managers would have to send their response to the first phase to a set of acceptors, instead of sending it to the transaction manager, thus creating a separate Paxos instance for
CHAPTER 4. CONSENSUS
100
every resource manager involved in the transaction. Similarly, the acceptors could propagate the chosen values to the resource managers directly, instead of doing that indirectly via the transaction manager. The resource managers would be responsible for checking that all the Paxos instances from the other resource managers had a positive result (corresponding to the first phase of 2PC) in order to commit the transaction. A paper titled ’Consensus on transaction commit’ [44] examines this relationship between distributed transactions and consensus and also explains this approach in much greater detail, which is referred to as Paxos commit. This paper also demonstrates why 2-phase commit is essentially a special case of Paxos commit with zero tolerance of node failures (f = 0).
Raft Paxos has been the canonical solution to the consensus problem. However, the initial specification of the algorithm did not cover some aspects that were crucial in implementing the algorithm in practice. As explained previously, some of these aspects were covered in subsequent papers. The Paxos algorithm is also known to be hard to understand. As a response to these issues, researchers decided to create a new algorithm with the goals of improved understandability and ease of implementation. This algorithm is called Raft [45]. We will briefly examine this algorithm in this section, since it has provided a good foundation for many practical implementations and it nicely demonstrates how the various aspects described before can be consolidated in a single protocol. Raft establishes the concept of a replicated state machine and the associated replicated log of commands as first class citizens and supports by default multiple consecutive rounds of consensus. It requires a set of nodes that form the consensus group, which is referred to as the Raft cluster. Each of these nodes can be in one of 3 states: a leader, a follower or a candidate. One of the nodes is elected the leader and is responsible for receiving log entries from clients (proposals) and replicate them to the other follower nodes in order to reach consensus. The leader is responsible for sending heartbeats to the other nodes in order to maintain its leadership. Any node that hasn’t heard from the leader for a while will assume the leader has crashed, it will enter the candidate state and attempt to become leader by triggering a new election. On the other hand, if a previous leader identifies another node has
CHAPTER 4. CONSENSUS
101
gained leadership, it falls back to a follower state. Figure 4.5 illustrates the behaviour of the nodes depending on their state.
Figure 4.5: The states of nodes in Raft In order to prevent two leaders from operating concurrently, Raft has the temporal concept of terms. Time is divided into terms, which are numbered with consecutive integers and each term begins with an election where one or more candidates attempt to become leaders. In order to become a leader, a candidate needs to receive votes from a majority of nodes. Each node votes for at most one node per term on a first-come-first-served basis. Consequently, at most one node can win the election for a specific term, since 2 different majorities would conflict in at least one node. If a candidate wins the election, it serves as the leader for the rest of the term. Any leader from previous terms will not be able to replicate any new log entries across the group, since the voters of the new leader will be rejecting its requests and it will eventually discover it has been deposed. If none of the candidates manages to get a majority of votes in a term, then this term ends with no leader and a new term (with a new election) begins straight after. Nodes communicate via remote procedure calls (RPCs) and Raft has 2 basic RPC types: • RequestVote: sent by candidates during an election. • AppendEntries: sent by leaders to replicate log entries and also to provide a form of heartbeat. The commands are stored in a log replicated to all the nodes of the cluster. The entries of the log are numbered sequentially and they contain the term in which they were created and the associated command for the state machine, as shown in Figure 4.6. An entry is considered committed if it can be applied
CHAPTER 4. CONSENSUS
102
to the state machine of the nodes. Raft guarantees that committed entries are durable and will be eventually be executed by all of the available state machines, while also guaranteeing that no other entry will be committed for the same index. It also guarantees that all the preceding entries of a committed entry are also committed. This status essentially signals that consensus has been reached on this entry.
Figure 4.6: The structure of the replicated log As mentioned previously, leaders are responsible for receiving commands from clients and replicating them across the clusters. This happens in the following order: when a leader receives a new command, it appends the entry to its own log and then sends an AppendEntries request in parallel to the other nodes, retrying when it does not receive a timely response. When the leader receives a response from a majority of followers, the entry can be considered committed. The leader applies the command to its state machine and informs the followers they can do the same, by piggybacking the necessary information about committed entries in subsequent AppendEntries messages. Of course, this is mostly the happy path. During leader and follower failures, divergence might be observed between the various nodes. Figure 4.7 contains some examples of this phenomenon.
CHAPTER 4. CONSENSUS
103
For example, a follower might have crashed and thus missed missed some (committed) entries (a, b). It might have received some more (non committed) entries (c,d). Or both things might have happened (e,f). Specifically scenario (f) could happen if a node was elected leader in both terms 2 and 3 and replicated some entries, but it crashed before any of these entries were committed.
Figure 4.7: Temporary divergence of node logs Raft contains some additional elements to resolve these temporary divergences. The main overarching principle is that any elected leader should contain any entries that have been committed up to the term he becomes leader. The leader is then responsible for helping any followers with conflicting logs adjust them accordingly to converge again. It’s important to note that a leader only appends entries to its log and never updates it, only followers are allowed to update their log. These 2 aspects are satisfied in the following way: • During an election, every RequestVote RPC contains some information about the candidate’s log. A voter is allowed to vote for a candidate only if its log is not more up-to-date than the candidate’s log. Raft determines which of two logs is more up-to-date by comparing the index and term of the last entries in the logs. A candidate must receive votes from a majority of the cluster in order to be elected, which means that every committed entry must be present in at least one of those servers. If the candidate’s log is at least as up-to-date as any other log
CHAPTER 4. CONSENSUS
104
in that majority, then it’s guaranteed to hold all the committed entries. • When sending an AppendEntries RPC, a leader includes the index and term of the entry that immediately precedes the new entries in its log. The followers check against their own logs and reject the request if their log differs. If that happens, the leader discovers the first index where their logs disagree and starts sending all the entries after that point from its log. The follower discards its own entries and adds the leader’s entries to its log. As a result, their logs eventually converge again. We mentioned previously that a leader knows that an entry from its term can be considered committed when it has been successfully replicated to a majority of nodes and it can then be safely applied to the state machine. But, what happens when a leader crashes before committing an entry? If subsequent leaders have received this entry, they will attempt to finish replicating the entry. However, a subsequent leader cannot safely conclude that an entry from a previous term is commmitted once it is stored on a majority of nodes. The reason is there is an edge case where future leaders can still replace this entry even if it’s stored on a majority of nodes. Feel free to refer to the paper for a full description of how this can happen. As a result, leaders can safely conclude an entry from a previous term is committed by replicating it and then replicating a new entry from its term on top of it. If the new entry from its own term is replicated to a majority, the leader can safely consider it as committed and thus it can also consider all the previous entries as committed at this point. So, a leader is guaranteed to have all the committed entries at the start of its term, but it doesn’t know which those are. To find out, it needs to commit an entry from its own term. To expedite this in periods of idleness, the leader can just commit a no-op command in the beginning of its term. What has been described so far consists the main specification of the Raft protocol. The paper contains more information on some other implementation details that will be covered briefly here. Cluster membership changes can be performed using the same mechanisms by storing the members of the cluster in the same way regular data is stored. An important note is that transition from an old configuration Cold to a new configuration Cnew must be done via a transition to an intermediate configuration Cjoint that contains both the old and the new configuration. This is to prevent two different leaders from being elected for the same term. Figure 4.8 illustrates how that could happen if the cluster transitioned from Cold directly to Cnew . During this intermediate transition, log entries are replicated to the servers of both
CHAPTER 4. CONSENSUS
105
configurations, any node from both configurations can serve as a leader and consensus requires majority from both the old and the new configuration. After the Cjoint configuration has been committed, the cluster then switches to the new configuration Cnew . Since the log can grow infinitely, there also needs to be a mechanism to avoid running out of storage. Nodes can perform log compaction by writing a snapshot of the current state of the system on stable storage and removing old entries. When handling read requests from clients, a leader needs to first send heartbeats to ensure it’s still the current leader. That guarantees the linearizability of reads. Alternatively, leaders can rely on the heartbeat mechanism to provide some form of lease, but this would assume bounded clock skew in order to be safe. A leader might also fail after applying a committed entry to its state machine, but before replying to the client. In these cases, clients are supposed to retry the request to the new leader. If these requests are tagged with unique serial numbers, the Raft nodes can identify commands that have already been executed and reply to the clients without re-executing the same request twice.
Figure 4.8: Risk of switching directly from Cold to Cnew
Standing on the shoulders of giants At this point, we have spent enough time examining all the small details of the various consensus algorithms. This can prove to be very useful, when one thinks about how to design a system, what kinds of guarantees it would require or even when troubleshooting edge cases. Hopefully, you
CHAPTER 4. CONSENSUS
106
have realised by now that these problems are very complicated. As a result, creating an algorithm that solves these problems or even translating an existing algorithm to a concrete implementation is a really big undertaking. If there is an existing solution out there, you should first consider re-using this before rolling out your own, since it’s highly likely that the existing solution would be much more mature and battle-tested. This is true not only for consensus but other problems inherent to distributed systems as well. A later chapter contains some case studies of basic categories of such distributed systems that you can leverage to solve some common problems.
Part III
Time & Order
107
108 Time and order are some of the most challenging aspects of distributed systems. As you might have realised already by the examples presented, they are also very intertwined. For instance, time can be defined by the order of some events, such as the ticks of a clock. At the same time, the order of some events can also be defined based on the time each of these events happened. This relationship can feel natural when dealing with a system that is composed of a single node, but it gets a lot more complicated when dealing with distributed systems that consist of many nodes. As a result, people that have been building single-node, centralised applications sometimes get accustomed to operating under principles and assumptions that do not hold when working in a distributed setting. This part will study this relationship between time and order, the intricacies related to distributed systems and some of the techniques that can be used to tackle some of the problems inherent in distributed systems.
Chapter 5
Time What is different in a distributed system As mentioned previously, one of the main use cases of time in a software system is to determine the order between different events. In practice, this can mean very different things. For example, a system might want to impose an order to requests received concurrently by different clients in order to determine in which order the effects of each request should take place. A different example is one where a system administrator might be investigating an incident looking at the system logs and trying to infer relationships between different events, using the timestamp of the logs associated to these events. Both of these examples have a common, underlying goal, which is to determine the order between events. There is a main difference between a centralised system and a distributed, multi-node system with regards to time. In the first type of systems, there is only a single node and thus only a single clock. This means one can maintain the illusion of a single, universal time dimension, which can determine the order of the various events in the single node of the system. In a distributed system, each node has its own clock and each one of those clocks may run at a different rate or granularity, which means they will drift apart from each other. As a consequence of this, in a distributed system there is no global clock, which could have been used to order events happening on different nodes of the system.
109
CHAPTER 5. TIME
110
A practical perspective The clocks used in real systems are what we usually call physical clocks. A physical clock is a physical process coupled with a method of measuring that process to record the passage of time [46]. Most physical clocks are based on cyclic processes. Below are some examples of such devices: • Some of the most basic ones and easy to understand are devices like a sundial or an hourglass. The former tells the time of the day using a gnomon and tracking the shadow created by the sun. The latter measures time by the regulated flow of of sand through a bulb. • Another common clock device is a pendulum clock, which uses an oscillating weight as its timekeeping element. • An electronical version of the last type is used in software systems, called a quartz clock. This device makes use of a crystal, called quartz crystal, which vibrates or ticks with a specific frequency, when electricity is applied to it. • One of the most accurate timekeeping devices are atomic clocks, which use the frequency of eletronic transitions in certain atoms to measure time. As explained initially, all these devices rely on physical processes to measure time. Of course, there can be errors residing both in the measurement tools being used and the actual physical processes themselves. As a result, no matter how often we synchronize these clocks with each other or with other clocks that have more accurate measurement methods, there will always be a skew between the various clocks involved in a distributed system. When building a distributed system, this difference between clocks must be taken into account and the overall system should not operate under the assumption that all these clocks are the same and can act as a single, global clock. Figure 5.1 contains an example of what could happen otherwise. Let’s assume we have a distributed system composed of 3 different nodes A, B and C. Every time an event happens at a node, the node assigns a timestamp to the event, using its own clock, and then propagates this event to the other nodes. As the nodes receive events from the other nodes, they compare the timestamps associated with these events to determine the order in which the events happened. If all the clocks were completely accurate and reflecting exactly the same time, then that scheme would theoretically be capable of identifying the order. However, if there is a skew between the clocks of the various nodes, the correctness of the system is violated. More specifically, in
CHAPTER 5. TIME
111
our example, we assume that the clock of node A is running ahead of the clock of node B. In the same way, the clock of node C is running behind the clock of node B. As a result, even if the event in node A happened before the event in node C, node B will compare the associated timestamps and will believe the event from node C happened first.
Figure 5.1: Side-effects of assuming a global clock So, from a practical point of view, the best we could do is accept there will always be a difference between the clocks of different nodes in the system and expose this uncertainty in some way, so that the various nodes in the system can handle it appropriately. Spanner [5] is a system that follows this approach, using the TrueTime API that directly exposes this uncertainty by using time intervals (embedding an error) instead of plain timestamps.
A theoretical perspective What we have demonstrated so far is a rather practical and simplified version of the notion of time. The main hypothesis was that there exists an absolute, universal notion of time, but it’s practically impossible to measure it in a completely precise way. In reality, there is no such absolute and universal notion of time. According to the laws of physics, more specifically the special
CHAPTER 5. TIME
112
theory of relativity,1 it is impossible to say in an absolute sense that two distinct events occur at the same time if those events are separated in space. For example, a car crash in London and another in New York appearing to happen at the same time to an observer on Earth, will appear to have occurred at slightly different times to an observer on an airplane flying between London and New York. This can look like a paradox, but you can easily understand the intuition behind this theory via a thought experiment, known as the train experiment. It consists of a train with an observer located in the middle of the carriage and another observer standing on the platform as the train passes by. A flash of light is given in the middle of the carriage right at the point when the two observers meet each other. For the observer inside the carriage, both sides of the carriage have the same distance from the middle, so the light will reach both sides at the same time. For the observer standing on the platform, the rear side is moving towards the initial point of the flash, while the front side is moving away from it, so the light will reach the two sides at different points in time, since the speed of light is the same for all directions. As a result, whether two spatially separated events happen at the same time (simultaneously) is not absolute, but depends on the observer’s reference frame. Note that in practice relativity is not a problem for computing systems, since all of humanity’s current computing computing systems share a close enough frame of reference to make relativistic differences in the perception of time immaterial. In the context of this book though, it mainly serves to underline the fact that time is relative and there can be no absolute, universal time dimension in a distributed system. It also gives rise to another observation that forms the basis of distributed algorithms, which provide solutions to ordering problems without relying on a global clock. These algorithms will be covered in more detail in the next chapter. As illustrated from the previous example, information in the real world flows through light. If the sun stops shining, a human will realise that a bit later (around 8 minutes later, to be concrete). This is the time it takes sunlight to reach Earth from the sun. If you think about it, distributed systems are very similar. Events happen at different nodes of the system and it takes some time for the information that these events happened to propagate to the other nodes of the system. A difference worth pointing out is that the speed of light is constant, but the speed with which information flows in a distributed system is variable, since 1
See: https://en.wikipedia.org/wiki/Relativity_of_simultaneity
CHAPTER 5. TIME
Figure 5.2: The train experiment
113
CHAPTER 5. TIME
114
it depends on the underlying infrastructure and conditions of the network. In the worst-case scenario, information might not be able to flow between 2 parts of a system at all because of a network partition.
Logical clocks The focus of this section has been on physical clocks so far, explaining their main limitations when used in distributed systems. However, there is an alternative category of clocks, which is not subject to the same constraints, logical clocks. These are clocks that do not rely on physical processes to keep track of time. Instead, they make use of messages exchanged between the nodes of the system, which is the main mechanism information flows in a distributed system, as described previously. We can imagine a trivial form of such a clock in a system consisting of only a single node. Instead of using a physical clock, this node could instead make use of a logical clock, which would consist of a single method, say getTime(). When invoked, this method would return a counter, which would subsequently be incremented. For example, if the system started at 9:00 and events A, B and C happened at 9:01, 9:05 and 9:55 respectively, then they could be assigned the timestamps 1, 2 and 3. As a result, the system would still be able to order the events, but it would not be able to determine the temporal distance between any two events. Some more elaborate types of logical clocks are described in the next section.
Chapter 6
Order Total and partial ordering As explained before, determining the order of events is a common problem that needs to be solved in software systems. However, there are 2 different, possible types of ordering: total ordering and partial ordering. Total order is a binary relation that can be used to compare any 2 elements of a set with each other. As a direct consequence of this property, using this relation we can derive only a single order for all the elements in a set, which is why it’s called total order. For instance, the set of unique integers {7, 9, 3, 2, 6} can be totally totally ordered (using the relation less than f then e is also in the cut. Note that the Chandy-Lamport algorithm produces snapshots that are also consistent cuts.
CHAPTER 6. ORDER
132
• Ssnapshot is reachable from the state Sstart in which the algorithm was initiated. • the state Send in which the algorithm terminates is also reachable from Ssnapshot . Let’s look at an example to get some intuition about this. Let’s assume we have a very simple distributed system consisting of 2 nodes p, q and two channels c, c', as shown in Figure 6.9. The system contains one token that is passed between the nodes. Each node has two possible states s0 and s1 , where s0 is the state in which the node does not possess the token and s1 is the state in which it does. Figure 6.10 contains the possible global states of the systems and the associated transitions. As a result, a snapshot where the state is s0 for both of the nodes and the state of both channels is empty would not be consistent, since the token is lost! A snapshot where the states are s1 and s0 and channel c contains the token is also not consistent, since there are now two tokens in the system.
Figure 6.9: A simple distributed system consisting of 2 nodes and 2 communication channels The algorithm is based on the following main idea: a marker message is sent between nodes using the available communication channels that represents an instruction to a node to record a snapshot of the current state. The algorithm works as follows: • The node that initiates the protocol records its state and then sends a marker message to all the outbound channels. Importantly, the marker is sent after the node records its state and before any further messages are sent to the channels. • When a node receives a marker message, the behaviour depends on whether the node has already recorded its state (while emitting the
CHAPTER 6. ORDER
133
Figure 6.10: The possible global states and the corresponding transitions of the token system mark previously) or not. – If the node has not recorded its state, it records its state and then it records the state of the channel c the marker was received from as an empty sequence. It then sends the marker to all the outbound channels. – If the node has recorded its state, it records the state of the channel the marker was received from as the sequence of messages received from c after the node’s state was recorded and before the node received the marker from c. Figure 6.11 contains a sample execution of this algorithm on the simple system presented previously. The node p sends the token and right after initiates an execution of the protocol. As a result, it records its state s0 and sends the marker in channel c. The node q receives the token, transitions to state s1 . It then sends the token to channel c' and transitions to state s0 . Afterwards, it receives the marker message, records its state s0 and the state of the channel c as an empty sequence and sends the marker message to channel c'7 . In the meanwhile, node p has received the token, transitioned 7
Note that this is just one of the possible executions. In an alternative execution, the node q could have processed both the token and the marker, recording its state as s1 and potentially sending the marker across channel c' without sending the token yet. This would have led to a different, but still consistent snapshot.
CHAPTER 6. ORDER
134
to state s1 and buffered the token in the sequence of messages received while the snapshot protocol was executing. The node p then receives the marker and records the state of the channel c' as the sequence [token]. At this point, the protocol concludes, since the state of all nodes and channels has been recorded and the global snapshot state is the following: snapshot(p): s0 snapshot(q): s0 snapshot(c): [] snapshot(c'): [token]
Figure 6.11: An execution of Chandy-Lamport algorithm on the token system
Physical & Logical time: closing thoughts Hopefully, the two previous chapters helped you understand the difference between the concepts of physical and logical time. At the same time, some parts went into detail to explain the inner workings of some techniques and their benefits and pitfalls. This might have left you with more questions, so this section will contain an overview of what we have seen and some additional observations. The goal is to help you finish this chapter with a
CHAPTER 6. ORDER
135
clear understanding of the difference between these 2 concepts and what each one has to offer. Let’s start by reviewing the basic difference between physical and logical time. As explained previously, physical time is measured based on some physical phenomena. Depending on the phenomenon that is observed, the granularity of physical time can differ from days to seconds or nanoseconds. In all cases, the time flows continually between discrete values8 . In a non-distributed system, a computing node can use these measurements to associate occuring events and compare them with each other to determine which happened first. In a distributed system, the various nodes have separate clocks that might not be in sync, so they will have to do additional work to make sure the values from their clocks can be compared safely. This additional work will involve exchange of messages between nodes, which will contain the values of their clocks, so that nodes can adjust their clocks accordingly to synchronize them. On the other hand, logical time is not measured based on physical phenomena, but the node makes use of local events to measure logical time instead. For instance, the node can maintain a counter that is incremented everytime something happens. In this way, every event can still be associated with a discrete "moment in time", but the value of this time will be mostly relative and not absolute. Furthermore, logical time will not flow continuously, but it will only flow when events happen at a node. The node will still be able to compare local events and determine which one happened first. As mentioned before, these instances of time do not have any absolute semantics. As a result, in a distributed system, the various nodes will have to exchange their perception of logical time, so that they can compare the values of their logical clocks and be able to order events across different nodes (and clocks). After reading the previous paragraph, did you notice anything interesting? In the beginning, the concepts of physical and logical time do seem really different. However, closer examination reveals they are quite similar and actually share some basic properties. In both cases, time flows in discrete increments everytime something happens. In the case of physical time, what happens is the underlying physical phenomenon, while in the case of logical time it’s the actual logical event that happens in a node. In both cases, communication is required between nodes to synchronise their 8 Time can be considered discrete in the context of hardware or software systems. Whether this is true in other contexts (i.e. physics) is a much bigger question and is out of topic for this discussion.
CHAPTER 6. ORDER
136
clocks. In the case of physical time, this communication is performed in the background continuously, while in the case of logical time, this communication is performed on-demand, when messages are sent between nodes. Identifying that these 2 notions make use of these common, basic principles but in a slightly different way is useful to understand the advantages and disadvantages of each concept and where each one can be useful. By its nature, physical time attempts to perform all the necessary coordination and communication in the background, so that the various nodes can establish an order without additional coordination, when needed. In most cases, physical clocks are used in order to provide the illusion of a total order, which is not realistic in a distributed system, as explained before. Furthermore, physical clocks need to remain properly synchronised, which might not be temporarily possible under network partitions. If clock drift between nodes is larger than the acceptable error, then this might mean the correctness of the application is compromised. On the other hand, logical time is operating under the assumption that network partitions are a given and a partial order satisfying causality is the best kind of ordering one can achieve in a distributed system. During partitions, different nodes can keep operating, while essentially leaving their clocks drift. However, when partitions are healed or nodes communicate with each other, they can eventually detect things that happened concurrently and perform the necessary reconciliation. Logical clocks can also be adapted, so that only necessary causal relationships are captured (instead of everything), thus achieving both good performance and safety. However, as explained previously logical time does not flow on its own as physical time does, it only flows when events happen. As a consequence, logical time cannot be used for tasks that require a notion of wall clock time that flows even when no events happen. For instance, in order to identify whether another node is slow during communication or has potentially crashed, a node needs to make use of timeouts and physical time.
Part IV
Network & Security
137
Chapter 7
Networking So far, the book has focused on the end nodes of a distributed system treating the network that connects them as a black box. This chapter will examine more closely the various components of this network that help the various nodes that form a distributed system communicate. As we will see, applications typically rely on a set of well-specified protocols that allow these applications to perform their functions at a high-level without having to deal with low-level details. This means that in many cases people can build applications without having to know how these lower levels function. However, having this knowledge enables one to create scalable, efficient and fault-tolerant distributed systems and also troubleshoot more efficiently issues as they arise. As a result, this chapter contains a brief overview of the basic technologies and protocols used to interconnect multiple computing nodes. Going back to the fallacies of distributed computing, we can see that many of them are highly relevant in this chapter: • • • • • •
The network is reliable. Latency is zero. Bandwidth is infinite. Topology doesn’t change. Transport cost is zero. The network is homogeneous.
So, we would encourage the reader to keep them in the back of their mind as they go through the various parts of this chapter.
138
CHAPTER 7. NETWORKING
139
There are so many different networking protocols and technologies nowadays that covering them all in a single book could easily become a herculean task. As a result, this book will focus on covering the Internet protocol suite, which is the most dominant nowadays. However, the problems solved by these protocols are general and they rely on fundamental principles that manifest in other models as well, such as the OSI model. The Internet protocol suite follows a layered architecture, where the overall functionality is divided into separate layers that contain a set of distinct protocols. Each layer provides a service to the next layer above and makes use of the services provided by the layer below. Starting from the top, there are five layers: • • • • •
the the the the the
application layer transport layer network layer link layer physical layer
When receiving data from the next layer above, each layer wraps this data along with the necessary metadata for the corresponding protocol, as shown in Figure 7.1. This metadata is typically referred to as headers, while the actual data is referred to a payload. When data is passed from lower-level layers to higher-level layers, the reverse process is followed, where the payload is extracted and handed off to the next layer above. These processes are also known as encapsulation and decapsulation accordingly. This chapter will examine more closely each one of those layers and their protocols in order to understand the problems each layer is trying to tackle and how they are solved. We will follow a bottom-up approach starting from the layers at the bottom of the stack and moving up one step at a time.
The physical layer The physical layer is responsible for the transmission of data in the form of raw bits over a physical link between two nodes that are connected. This data is transmitted through a medium that can take many different forms, as we will see later on. Some services provided by this layer are the following: • line coding: this is the process of encoding digital data in the form of bits into a pattern of voltage, current or photons to be transmitted through a physical medium. Some examples of line coding are unipolar,
CHAPTER 7. NETWORKING
Figure 7.1: Layers of the Internet protocol suite
140
CHAPTER 7. NETWORKING
141
polar, bipolar and Machester code, as shown in Figure 7.2. Each encoding comes with pros and cons we will not analyse here for the sake of brevity. As a result, suitability of an encoding depends on other factors, such as the transmission medium. • modulation/demodulation: this is the process of mixing a signal that corresponds to the digital data we want to transmit (known as the data signal or the message signal) with a separate periodic signal (known as the carrier signal). This is done in order to convert the signal into a form that is more suitable for transmission. For example, being able to select the frequency of a signal makes it possible to transmit multiple signals concurrently over a single medium on different frequencies, a technique known as multiplexing. This also has practical benefits, since antennas need to be about 1/10th the length of the signal’s wavelength, so it’s economical to use higher frequencies. The 3 basic types of modulation are: amplitude modulation, frequency modulation and phase modulation. • carrier sense and collision detection: this is the process of identifying whether another device is transmitting data over the medium and whether another device transmitted data concurrently in the medium thus leading to a collision and potentiall loss/corruption of transmitted data. • auto-negotiation: this is the process that is used so that the two or more sides of a network connection can agree on a set of parameters that will be used for communication, such as speed and transmission mode (e.g. simplex or duplex). In practice, what happens is devices advertise what they can support and then the highest performance transmission mode that is supported in both sides is selected. There are many different physical media that can be classified into two main categories: guided and unguided media. Guided media use a physical path or conductor to transmit the signal in a specific direction. That’s why this is also called wired communication. The unguided media broadcast the signal through the air, thus not directing the signal. This is why this is also known as wireless communication. Some examples of wired media are: • twisted pair cabling, which is a type of wiring in which two conductors of a single circuit are twisted together for the purpose of improving electromagnetic compatibility by reducing electromagnetic radiation from the pair and external electromagnetic interference from other
CHAPTER 7. NETWORKING
Figure 7.2: Different line codes
142
CHAPTER 7. NETWORKING
143
sources. The cables are typically made of copper wires. • Coaxial cable, which is a type of electrical cable consisting of an inner conductor surrounded by a concentric conducting shield, with the two separated by a dielectric (insulating material). • Fiber-optic cable, which contains one or more optical fibers that are used to carry light. Typically, these fibers are individually coated with plastic layers and contained in a protective tube suitable for the environment where the cable will be deployed. Some examples of wireless media are radio waves, micro waves and infrared light, which are forms of electromagnetic radiation with different frequencies. There are many different technologies providing physical layer services, such as DSL, ISDN, Bluetooth and USB. Ethernet and Wi-fi are the basic underpinnings of the Internet, so we will focus on them a bit more. • Ethernet is part of the IEEE 802.3 standard that defines both the physical layer specifications and the associated link layer protocols, which we will examine later. It can be carried over a variety of physical media including coaxial cable, copper wire and optical fiber. As a result, it comes in many different flavours. Some examples are 10BASE-2, 10BASE-T, 10BASE-F where the first component indicates the speed of the standard (e.g. 10Mbit/s) and the last part indicates the medium (e.g. coaxial cable, twisted pair cable or fiber-optic cable). • Similarly, Wi-fi is part of the IEEE 802.11 standard that defines the physical layer specifications and the associated link layer protocols for achieving wireless communication. The physical layer services are provided to a computer through the use of a network interface card. This is a hardware component that connects a computer to other computers via a wired or wireless medium. Each network interface card is associated with an address that is used to uniquely identify this device in the immediate network of devices, called the MAC address.
The link layer While the physical layer is responsible for transmitting a good signal through a physical transmission medium, the link layer is responsible for moving a packet of information from one node to a neighbouring node. In this context, we consider a node to be a neighbouring node if it is connected through a
CHAPTER 7. NETWORKING
144
single communication link whether that’s wired or wireless. As a result, the link layer can provide some of the following services: • framing: this is the process of encapsulating payloads from higher layers of the stack into the link-layer packet, commonly referred to as a frame. • link access: a physical link might be shared between multiple nodes, which means there must be a way to mediate access to this link so that nodes can coordinate with each other to ensure only one of them transits data at a time. This is achieved through protocols known as medium access control (MAC) protocols. These can be grouped into 3 main categories: channel partitioning protocols, random access protocols and taking-turn protocols. Channel partitioning protocols work by partitioning the channel into parts that can be used simultaneously by more than one nodes. There are 3 main types of channel partitioning protocols: time-division multiple access where nodes are assigned separate time slots where they are allowed to transmit, frequencydivision multiple access where nodes are assigned separate frequencies in which they are allowed to transmit, and code-division multiple access where nodes can transmit concurrently, but they encode their data using a different encoding scheme. As the name implies, random access protocols work by allowing any node to transmit at the full rate of a channel whenever they want, whihch means transmissions from multiple nodes might collide and the nodes might have to retransmit this data. There are many random access protocols, but two well-known and widely adopted protocols are the ALOHA protocol and the CSMA protocol. The ALOHA protocol is quite simple: when nodes have data they just transmit them. If they receive data from other nodes while transmitting, they try transmitting the same data again later. The CSMA protocol, which stands for carrier sense multiple access, is slightly more elaborate since the nodes listen to the channel before transmitting and they start transmitting only if the channel appears to be idle. Taking-turn protocols allow nodes to coordinate with each other to acquire access to the medium. Two main taking-turn protocols are the polling protocol where a node is designated as the master node and is responsible for passing the access to the medium sequentially through the nodes, and the token-passing protocol where nodes perform this on their own without the use of a master node by passing a special message (the token) that gives access to the medium between each other.
CHAPTER 7. NETWORKING
145
• reliable delivery: this is the guarantee that data transmitted over the medium will be received by their destination successfully regardless of potential complications that might happen. As explained later on, this is also provided at some higher layers, so not all link-layer protocols provide this service. In general, for performance reasons reliable delivery is often provided in links that are prone to high error rates, such as wireless links. However, in order to avoid extra overhead it is typically not provided for low error-rate links, such as wired links. Reliable delivery is achieved through the transmission of acknowledgement messages from the recipient and retries by the sender. • error detection and error correction: error detection is the process of identifying cases where an error happened during transmission due to signal attenuation and electromagnetic noise and recovering from the error by identifying exactly the data that were altered and in which way. This is achieved by accompanying the data to be sent with an additional piece of redundant data that can be used to determine whether data have been received as originally transmitted and what changed if not. There are many techniques to do that with checksums and cyclic redundancy checks (CRC) being the most common ones. In checksumming techniques, the stream of data is treated as a sequence of k-bit integers, which are summed up to produce the checksum that is sent along with the data. When using cyclic redundancy checks, a list of bits is appended to the data to be sent so that the resulting number is exactly divisible by a pre-agreed number. It’s interesting to note here that the physical layer consists of the specifications of the hardware components that participate in the transmition of data on a physical medium. The link layer contains some parts that are implemented in hardware, i.e. in the network interface card, and other parts that are implemented in software, i.e. in device drivers. The higher layers are typically implemented exclusively in software, e.g. either coming as part of the kernel of the OS or in third-party libraries. As a result, the link layer essentially forms the bridge between hardware and software1 . As mentioned earlier, Ethernet and Wi-fi are the most widely used protocols to connect devices with each other in wired or wireless settings, so we will 1 Note that this is true in the general case, but there are always exceptions and there might be more in the future given the rapid progress in the evolution of hardware components. As an example, TCP offload engine is a technology that offloads processing of higher layers to the network controller in order to achieve higher performance.
CHAPTER 7. NETWORKING
146
provide a quick overview of their basic workings here. Ethernet evolved through time going through various phases and this meant changes in the specifications both at the hardware level and on the associated protocols. Initially, Ethernet was based on connecting multiple computers over a shared coaxial cable, which was acting as a broadcast transmission medium. This topology is also known as a bus topology, as shown in Figure 7.4. This meant that any data transmitted by a node would be received by all the other nodes connected to the cable. The network interface card was responsible for reading the metadata of each packet and passing through to the processor only the packets that were addressed to it. In order to achieve that, each network interface card has one or multiple unique addresses, which are known as MAC (media access control) addresses. These are essentially 48-bit numbers, which are allocated by the IEEE association. Every Ethernet frame contains a source and a destination MAC address with the latter being used by a network interface card to identify frames destined to it. There are also some special addresses that allow sending a frame to multiple network interface cards at a time. One such example is the broadcast address, which is all one bits (also represented as FF:FF:FF:FF:FF:FF) and is accepted by all nodes. The frame also contains other fields, as shown in Figure 7.3. The preamble is a static value that is used to synchronise the clocks of the sender and the receiver, so that the subsequent data are translated in the right way by the recipient. The EtherType field contains an identifier that indicates the next network-layer protocol the data should be passed to. The payload field carries the data of the next network-layer protocol and the CRC is used to detect bit errors, as explained earlier. Since the medium is shared, if two stations attempt to transmit at the same time, a collision can happen leading to corruption of the transmitted data. As a result, early versions of Ethernet were making use of CSMA/CD to recover from such collisions and manage to transmit data successfully. In order for the CSMA/CD protocol to operate efficiently, the standard specifies specific length limits for a cable segment. This keeps the propagation delay of a collision and signal degradation down to tolerable levels. Later on, the bus topology started being replaced by a star topology, where multiple nodes were connected to a device called repeater or hub with twisted-pair copper wire, as shown in Figure 7.4. A hub is a physical-layer device that acts on individual bits rather than frames. When a bit arrives from one interface the hub simply recreates the bit, boosts its signal strength, and transmits the bit onto all the other interfaces. As a result, Ethernet with a hub-based star topology remains a shared broadcast medium, which
CHAPTER 7. NETWORKING
147
Figure 7.3: The structure of an Ethernet frame means CSMA/CD was still used. The star topology has some benefits when compared to the bus topology. Some examples are easier installation into buildings that already had star topology cable plans for telephony and better fault-tolerance by being able to easily disconnect faulty peers and preventing them from affecting other devices on the network. The next and most recent evolution led to the replacement of hubs at the center of the star topology with devices that operated at the link layer, called switches. A switch receives link-layer frames and forwards them onto the specific outgoing links they are addressed to. In order to do this, the switch maintains a switch table that maps MAC addresses to the corresponding interfaces that lead to them. This is done automatically without the need for manual configuration or extra protocols. The switch table is initially empty and the switch adds entries to the table by inspecting the source addresses of incoming frames and associating them to the interface they arrived from. Everytime a switch receives an incoming frame, it searches for an entry containing the destination address in the table2 , it finds the corresponding interface and forwards the frame onto it. If there is no such entry, the frame is forwarded to all the interfaces. This is the reason switches are said to be self-learning or plug-and-play devices. In cases where nodes are directly connected to switches in a full-duplex mode, there is no possibility for a collision, which means there is no need for a medium access control protocol, such as CSMA/CD, and the link’s bandwitch is utilised more efficiently. The use of a switch instead of a hub can also lead to overall reduction of traffic thanks to reduced broadcast traffic, which can lead to increased performance in the network.
2
Note that recent advancements mean that switches might be able to perform forwarding based on more complicated logic using more information besides just the destination MAC address, but we intentionally focused on the original and simplest use here. One such example is the OpenFlow protocol[56].
CHAPTER 7. NETWORKING
148
Figure 7.4: Different network topologies Wi-Fi has two basic modes of operation: ad-hoc mode and infrastructure mode. In ad-hoc mode, each node transmits data directly to other nodes. In infrastructure mode, all communication needs to go through a central access point (AP), which connects wireless devices (stations) to a wired network. The infrastructure mode is used more widely and in most everyday scenarios, so we will focus on this one here. In this mode, a basic service set (BSS) is a group of stations that are all connected to the same access point. Each access point is associated with an identifier, known as the Service Set Identifier (SSID), which is typically configured by the administrator. Every wireless station needs to be associated with an access point before it can send or receive data to or from the rest of the network respectively. Association is performed in the following way: APs periodically transmit special messages, called beacon frames, which contain the access point’s SSID and MAC address. The wireless station listens for these messages and selects one of them for association or presents them to the user who then selects via a user interface. This process of listening to the medium for beacon frames is known as passive scanning. A wireless device can also perform active scanning by broadcasting a probe frame that is received by all access points within the wireless device’s range. Access points then respond to the probe request frame with a probe response frame. In order to associate with an AP, the device sends an association request frame to the acess point and the access point responds with an association response frame.
CHAPTER 7. NETWORKING
Figure 7.5: Structure of a Wi-fi network
149
CHAPTER 7. NETWORKING
150
Once association is completed, the device can start sending and receiving data frames to the network. Given there can be many wireless devices sharing the same medium, a multiple access protocol is needed to coordinate th transmissions. For this reason, 802.11 makes use of CSMA with collision avoidance (CSMA/CA). Collision detection is replaced with collision avoidance, because the received signal is typically very weak compared to the strength of the transmitted signal at th 802.11 adapter, so it is costly to build hardware that can detect a collision. Compared to wired links, wireless links suffer from higher signal attenuation and interference, which makes it more likely for transmitted data to be corrupted. For this reason, an acknowledgement protocol is used where a recipient replies to a data message with an acknowledgement message to let the sender know the message was received intact. If the message is not received intact or the acknowedgement message is lost, the sender will eventually retry sending the message. The stations introduce additional delays after sensing the channel is not idle to ensure another node is not trying to send a frame at the same time. This is the collision avoidance part and is introduced, because in this protocol the nodes do not stop transmitting when there is a collision (i.e. there is no collision detection). Wireless links are also subject to a problem, known as the hidden terminal problem. There are cases where two stations might be unable to detect each other’s transmissions, because they are too far from each other, while still being within the range of the access point as shown in Figure 7.6. The Wifi protocol contains an optional mode, where a station can transmit a specific message to the access point to get access to transmit, known as Request to Send (RTS). If no other device is transmitting, the access point replies with a message, known as Clear to Send (CTS). The CTS will be received by all the devices associated with the acess point, essentially reserving the medium for the corresponding station. In general, frames can belong to three basic types: data, control and management frames. Data frames are used to transmit data payloads between nodes. Control frames are used to facilitate the exchange of data frames, e.g. CTS/RTS and acknowedgement frames are control frames. Management frames are used for the maintenance or discontinuation of communication. Examples of management frames are beacon frames or probe requests. The frame structure shares many similarities with Ethernet. The payload contains the payload of the higher-level protocol that’s encapsulated and a CRC that protects against transmission errors. The frame contains 4 MAC addresses. Address 1 is the direct receiver, address 2 is the direct transmitter and ad-
CHAPTER 7. NETWORKING
151
Figure 7.6: The hidden terminal problem dress 3 is the indirect receiver/transmitter, as shown in Figure 7.7. So, these address correspond to the access point, the source station and the destination station respectively for frames sent by a station to the access point for them to be delivered to another station. Alternatively, they correspond to the destination station, the access point and the source station respectively for frames sent by an access point to the final destination station. The frame also contains sequence numbers for deduplication purposes, a duration field used for reserving the channel and a frame control field that contains various sub-fields, such as the protocol version, the type of the frame (e.g. data, control or management) etc.
Figure 7.7: Sending a packet through the access point
CHAPTER 7. NETWORKING
152
The network layer While the link layer is responsible for transferring packets between nodes that are directly linked, the network layer provides the means of transferring packets between nodes that are not directly linked and might belong to different networks. The functionalities that help the network layer provide this service can be divided conceptually into two main areas that are commonly known as the control plane and the data plane. The control plane is the part that gathers all the necessary meta-information that is needed in order to route data packets between nodes. The data plane is the part that routes data packets between nodes, typically leveraging information provided by the control plane. There are many different protocols in this layer and it is impractictal to cover them all here, so we will focus on the most central ones. We mentioned previously that every network interface comes with a MAC address, which is used to address frames at the link layer. However, this address is assigned statically and is not hierarchical, which makes it unsuitable for routing packets in a network. As a result, the network layer introduces a second form of address, called the IP (Internet Protocol) address. This can be assigned statically by an administrator or dynamically by the network. IP addresses are also hierarchical, which means each address is part of a network that can contain multiple addresses and where the network can also be part of a larger network. The IP protocol has evolved through time to address new needs and the structure of IP addresses has changed along with it. As a result, an IP v4 address is a 32-bit number and an IP v6 address is a 128-bit number3 . IP addresses are typically represented in a dot-decimal notation, consisting of four decimal numbers that represent 8 bits and range from 0 to 255, which are separated by a dot. An example IP address is 172.16.254.1. An IP address is structurally composed of two parts: the first part contains the higher-order bits that are common for all addresses in this network and is known as the network prefix, while the remaining parts form the second part that represents the identifier of the node in this network. Networks are connected with each other via special devices, called routers, which are capable of routing packets properly across networks so that they reach their destination successfully. Figure 7.8 shows an example of 3 different subnets connected to each other via 3 routers. The central protocol in the network layer is the IP protocol. IP has the task of 3
Adoption of IP v6 is still ongoing, so we will give examples using IP v4 for simplicity. We will simply refer to it as IP for the sake of brevity.
CHAPTER 7. NETWORKING
153
delivering packets, called datagrams, from a node to another node that can potentially reside in a different network. These datagrams are encapsulated into link-layer frames (e.g. Ethernet frames) in order to be sent across a link. They are also composed of a set of headers and a payload that corresponds to the packet of the next layer in the network stack. The main headers of interest here are the source and destination IP address, the protocol of the higher layer and the version of the IP protocol used. In order to understand how a packet is delivered between different networks, we will have to explore some protocols of the network layer a bit more. As implied so far, nodes are typically addressed globally in the Internet via their IP addresses. However, in order for a packet to eventually reach a node, its neighbouring node will need to know its MAC address in order to craft a link-layer packet addressed to it. This mapping from an IP address to a MAC address is performed by the ARP protocol4 . In order for a node to identify the MAC address of a node with a specific IP address, it can send an ARP request message to all the nodes on the local network (using the broadcast MAC address as destination address), including the IP address in the request. The ARP message will be encapsulated in a link-layer frame. In the case of Ethernet for instance, this would mean that the frame’s payload will be the ARP message and the type will correspond to the ARP type (0x0806). For the sake of brevity, we will not go into the details of the structure of an ARP message here, we leave this as an exercise for the curious reader. Each of the nodes that receive the ARP request will check if the IP address matches theirs and if that’s the case, they will reply with an ARP response message containing their MAC address. The nodes also store these mappings in a local ARP table that acts as a cache to avoid requesting the same information repeatedly. We have now answered the question how does the network layer deliver packets inside the same network, but the question remains on how it delivers packets across multiple networks. This is achieved through the use of the routers, which maintain special routing tables that contain information on how specific IP addresses can be reached. More specifically, the routing table maintains entries that contain the following fields amongst others: 4
Whether ARP is a link-layer or a network-layer protocol is a controversial topic. Some people claim it’s a network-link layer, because its packets are encapsulated in a link-layer protocol frame. Other people claim it’s a link-layer protocol, because it is not routable and is only exchanged inside the boundaries of a single network. In the context of this chapter, the ARP protocol was presented as part of the network layer mostly because we believe it is easier to understand when following a bottom-up approach.
CHAPTER 7. NETWORKING
154
Figure 7.8: An example of multiple subnets interconnected via routers
CHAPTER 7. NETWORKING
155
• network destination and network mask, which together form a network identifier. • gateway, which is the next node to which the packet is to be sent to reach its final dstination. This can be a specific IP address of a router if the network is accessible via some router. It can also have the value 0.0.0.0 if the network is accessible locally, which means there is a direct link to the destination IP address and the packet can be sent directly to it. • interface, which corresponds to the network interface through which the gateway can be reached. Routing tables are also maintained by regular nodes for the same purpose. Every time a node needs to send a network layer packet, it performs a lookup on its routing table using the destination IP of the packet to identify where this packet needs to be routed in order to reach this IP. The packet is then sent through the appropriate interface to the corresponding gateway. We can now look back at our previous example and see how these routing tables would look like and how the packets would flow across the networks. For instance, the routing table for the node with IP 240.1.1.1 could have the following form: Destination 0.0.0.0 240.1.1.0
Network mask
Gateway
Interface
0.0.0.0 255.255.255.0
240.1.1.3 0.0.0.0
if1 if1
The next question you might have at this point is how is this routing table populated in the first place? This is achieved by the routers exchanging information about the IP addresses that are reachable through them. The routers then use this information to form a map of the network in the form of a routing table. Of course, this is an intentionally simplified description. In practice, multiple nodes from one or more networks can be grouped in a so-called autonomous system (AS), which contains a group of routers that are under the same administrative control. Routers within the same AS run the same routing algorithm to share information about each other. As a result, the routing algorithm running within an autonomous system is called an intra-autonomous system routing protocol. An example of such a protocol is the open shortest path first protocol (OSPF)5 . However, an autonomous system needs to exchange some information with other autonomous systems 5
The layer at which OSPF belongs is also a controversial topic. It is encapsulated inside
CHAPTER 7. NETWORKING
156
in order to be able to route packets to them. This is the task of an interautonomous system routing protocol. An example of such a protocol is the border gateway protocol (BGP)6 , which consists of external BGP (eBGP) and internal BGP (iBGP). eBGP runs between two routers in different autonomous systems in order to exchange neighbouring information between them and iBGP runs between two routers in the same autonomous systems to propagate this information inside the autonomous system.
Figure 7.9: intra-AS and inter-AS routing Before we move to the next layer, it would be useful to reflect first on what are the limitations of the network layer, since the next layers are addressing some of them. First of all, the network layer does not provide any guarantees that the messages sent from a node will eventually be delivered to the destination node. This can be because of a router failure or because a router had to drop the packet due to network congestion7 . The network layer does not IP, but it is typically placed on the link layer, because OSPF packets flow only across a single link and do not traverse routers to travel more than one hops. 6 In fact, BGP is run by most Internet service providers (ISPs) to establish routing between themselves. 7 Routers are storing received packets in memory until they have transmitted them on the network. If packets arrive at a greater rate than the rate at which packets are transmitted, the router might have to drop some packets to avoid running out of memory. How packets are buffered in memory and dropped is governed by the queueing algorithm
CHAPTER 7. NETWORKING
157
provide any ordering guarantees either. If node A sends two messages 1 and 2 in sequence to the same node, this does not mean the other node will receive them in the same order. The packets might follow different paths through the network or some routers in the network might not necessarily preserve the order of the packets. As a result, the network layer does not provide reliable nor in-order delivery. The network layer is also unable to provide strict throughput or timing guarantees, since there is no way for applications to reserve resources of the network for a speficic period of time8 . Instead, they use any bandwidth that is available at the moment of transmitting data. This is an important thing to keep in mind when building a distributed system in order to make sure you are not making assumptions that are invalid.
The transport layer While the network layer provides the service of node-to-node communication, the transport layer provides the service of application-to-application communication. This is useful because there can be many different applications running in a node that want to communicate with one of the applications running in a different node. This is achieved via the concept of ports, where each node can have many different applications running and listening on different ports for data from the network. Some of the protocols in this layer can also provide more services on top of this fundamental one. We will briefly cover the transmission control protocol (TCP) and the user datagram protocol (UDP), which are the two most commonly used and well known protocols of the transport layer. UDP is an extremely simple protocol applications can use to send messages to each other. These messages are called datagrams. Similar to the IP protocol, it is a connectionless protocol, which means an application in a host can send a message to an application on a different host without having to setup a communication channel first. All it has to do is specify the IP address of the destination node and the port the corresponding application is listening to. This is also clear when looking at the structure of a datagram. It’s composed of a payload that corresponds to the application message and a used by the router, such as first-in-first-out, priority queueing, weighted fair queueing. 8 This comes down to the fact that the IP protocol is a packet-switched protocol. There are other circuit-switched protocols that can provide strict throughput guarantees, but these come with their own drawbacks of course.
CHAPTER 7. NETWORKING
158
header, which contains fields for the source and destination ports, the length of the datagram and an optional checksum. TCP is a significantly more complex protocol that can provide more services. TCP is a connection-oriented protocol, which means a connection between a client and a server needs to be established before application data can be sent. This is achieved via what’s known as the 3-way handshake, which is used to establish a connection and negotiate several parameters for it. For example, a crucial parameter that is selected during this phase is the size of in-memory buffers that will be used to temporarily hold received data before they can be delivered to the application. This process starts with the client sending a request to initiate a connection (SYN), the server then responds with a confirmation of the connection along with a request to initiate a connection on the reverse direction (ACK & SYN) and finally the client responds with a confirmation (ACK). After this is complete, a connection is established that the two sides can use to exchange data. Data are sent from one node to the other as a stream of bytes. As a result, the application needs to indicate somehow the beginning and end of an application message in this stream through delimiters or special headers that designate the message size. TCP provides reliable and in-order delivery. This means that any data sent from the sender are guaranteed to be delivered eventually9 to the recipient and in the order they were sent. This is achieved through the use of a sophisticated acknowledgement protocol. The sender tags the data sent with a sequence number that is aligned with the number of bytes contained in the payload. The recipient sends back acknowledgements indicating the sequence number up to which the recipient has received successfully. As a result, the sender is able to resend data that haven’t been acknowledged after a specific amount of time. The recipient is also able to deliver data to the application in the order they were sent using these sequence numbers. TCP also provides flow control and congestion control, which means the rate of transmission of the sender is managed carefully to avoid overloading the recipient or the overall network. Flow control is achieved by having the recipient include information in the acknowledgements about how much space is available in their receive buffer, so that the sender can reduce the transmission rate when the buffer starts getting full. Congestion control is achieved by adjusting the number of outstanding, unacknowledged packets 9
The word eventually is intentionally used here, because TCP cannot really do something if there is a permanent failure in the network that creates a network partition between the nodes. However, assuming that any temporary failures are eventually fixed and the sender does not close the connection, the packets that were sent will be delivered to the recipient.
CHAPTER 7. NETWORKING
159
on the sender side according to the perceived packet loss. Packet loss is usually hard to identify precisely at the two ends of a connection10 , so the nodes use heuristics to get an approximation, e.g. the sender assume a packet that is not acknowledged inside a specific time window should be considered lost. We avoid going into the full details of this acknowledgement protocol here intentionally, because it contains a lot of optimisations and different variations that are a full subject on their own[57]. As a result, if one looks at the structure of a TCP packet, it is more complex than that of UDP packets. The header contains a lot of additional fields for the aforementioned functionalities, such as a sequence number, an acknowledgement number, a window size etc. After the previous analysis, one might think TCP is superior to UDP, since it provides more services to the application layer. Of course, this is not true, since all these guarantees come with tradeoffs. The fact that TCP requires a connection to be established before any data exchange makes it more heavyweight and slow when compared to UDP. The same applies to the services of reliable and in-order delivery, which can introduce a lot of memory overhead and network traffic due to the need to maintain connection state and message buffers and perform retries for data that are considered lost. As described earlier, data in TCP are sent and received as a byte stream without any boundary indicators, which means the application layer needs to add boundary indicators along with the necessary parsing and buffering. Instead, data in UDP are sent and received as messages with explicit boundaries. UDP also provides better control over when a message will actually be transmitted on the network when compared to TCP that has a lot of parameters that control when data sent from an application should be transmitted in the network11 . So, it is useful to understand the pros and cons of each protocol in order to make the right decision when building an application on top of them. Below are some examples of applications and how they could benefit from the characteristics of each protocol: • A real-time video and audio application (e.g. video conference) might benefit from UDP, since it can tolerate occasional packet loss and 10 This is sometimes possible if routers can inject data in the packets to signal congestion in the network, but it requires special capabilities that are not always available. Explicit Congestion Notification (ECN) is an extension to the IP/TCP protocols that achieves this. 11 As an example, Nagle’s algorithm works by combining a number of small outgoing packets and sending them all at once to utilise the network more efficiently, which means data sent from an application might not be transmitted immediately.
CHAPTER 7. NETWORKING
160
cannot benefit much from packets that arrive extremely late. • An application that requires a fast exchange of a relatively small request followed by a single response might benefit from UDP, because it does not require a connection to be established first making this interaction very fast. DNS is an example of such an application that we will study in the next section. • An application, where the recipient needs to receive all the data from the sender and in the right order can benefit from TCP’s reliable and in-order delivery. There are many examples of such an application, such as e-mail transfer (SMTP), file transfer (FTP) or transfer of web content (HTTP).
The application layer The application layer relies on the transport layer for the data transfer and is responsible for defining the syntax and semantics of this data as well as the protocol of exchanges between the two sides. This is probably the most crowded layer with a vast number of protocols available. We will explore here two of those protocols that form the foundation of the world wide web, the Domain Name System (DNS) protocol and the Hypertext Transfer Protocol (HTTP). DNS is a system used to map human-friendly names, called domain names, to IP addresses. This is useful because these domain names are easier for humans to remember and use and they can also be agnostic to the actual network topology of an application making them a better addressing scheme for applications, unlike IP addresses. The users of the DNS system can be classified into two broad categories: clients that want to resolve a domain name into an IP address in order to send a data packet and servers that store and serve this information to clients. The DNS specifies a protocol that defines the data structures the servers can maintain and the way clients can request the data they need. For example, one type of record is the so-called A record, which contains the IP address for the associated domain name. In order for a client to find the IP address for a specific domain name, it needs to send a DNS query message to a DNS server that contains the necessary information, such as the domain name that needs to be resolved and the record type that is requested (A). The DNS servers form a hierarchy with 3 basic layers: the root servers, then the top-level domain servers and lastly the authoritative servers. Of course, the client needs to know the IP addresses
CHAPTER 7. NETWORKING
161
of at least the root servers, which are published by the Internet Corporation of Assigned Names and Numbers (ICANN) as the official "root zone file". Under normal conditions, DNS makes use of UDP as a transport protocol and a resolution of a domain name consists of a single UDP request from the client followed by a single UDP reply from the server.
Figure 7.10: Resolving a domain name in order to make an HTTP request HTTP is an application layer protocol for distributed, collaborative, hypermedia information systems. In other words, it can be used for distributing various pieces of information throughout the Internet, where each piece of information can link to a different piece of information. It also functions as a request-response protocol between clients and servers. For example, the client can be a browser requesting a web page and the server can be a computer hosting this webpage. It makes use of TCP as the underlying transport layer protocol12 and thus has to use its own headers to specify the 12
In fact, there are many versions of the HTTP protocol and HTTP/3 uses UDP instead of TCP mostly for performance reasons. However, when talking about HTTP here, we
CHAPTER 7. NETWORKING
162
size of a message. Figure 7.11 shows the structure of an HTTP request and the associated response. • The request message consists of the following parts that are separated by (a carriage return character followed by a line feed character): – a request line that contains the request method (e.g. GET) and the location of the resource that is requested (e.g. /index.html) – some header fields (e.g. Host) – an empty line – an optional message body, populated in case data need to be submitted to the server (e.g. when making a request to update data, instead of retrieving data) • The response message consists of the following parts (again separated by ): – a status line that includes the status code and a message indicating the reason of success of failure (e.g. 200 OK) – some header fields (e.g. Content-Type) – an empty line – an optional message body, populated when the servers needs to send data associated with a resource as part of the respone
Figure 7.11: Requesting a webpage via HTTP refer to the first version of HTTP for simplicity.
CHAPTER 7. NETWORKING
163
Taking a step back Now that we have examined each layer more closely, we should take a step back and look at the big picture. At this point, you have probably realised how much complexity underlies a seemingly simple request from your web browser to Google and how many moving parts are involved in this transfer. Of course, the same applies to most distributed systems nowadays, since they are supposed to work on top of the same or similar network infrastructure. Figure 7.12 shows an example of a big network like Internet and the layers that are involved at every step. When you see layers missing from a node, it means this node will be agnostic to this layer. For example, switches will forward Ethernet frames without inspecting the network packet encapsulated within. In the same way, routers will route IP packets without inspecting the TCP or UDP packets encapsulated within. We hope it’s become clear by now that each one of those layers provides a useful abstraction that we can leverage to build our applications without having to mess with all this complexity or reinvent the wheel. However, it can still be extremely beneficial to have a good understanding of what are the main mechanisms they use to do so. Knowing the services each protocol provides is useful for making the right choice when evaluating different protocols for your application. Knowing the mechanisms they use under the hood is useful for understanding what are the costs for providing these services and for troubleshooting issues when they arise. Of course, this chapter only scratched the surface in order to give you a general idea of the main parts that are involved and their role. For the interested reader that wants to get a deeper understanding of the various mechanims and techniques used by these protocols, there are a lot of good books to continue your journey[58].
CHAPTER 7. NETWORKING
Figure 7.12: The Internet from high above
164
Chapter 8
Security Security is yet another big problem in the field of distributed systems. Most of the chapters so far have examined what happens when thing go wrong, e.g. when hardware or software failures happen. However, we assumed that the nodes do not act maliciously, so we didn’t really examine what happens in this case. This is what this chapter will focus on. In the beginning of the book, we mentioned the following fallacy of distributed computing:
"The network is secure."
The previous chapter illustrated that the network that distributed systems make use of can be really big and have a lot of different components, each of which is under the control of a different organisation. So, it is crucial to ensure the system you are building will be able to function properly and securely even when third parties are acting against this purpose. There are three core problems that come up frequently when designing distributed systems: authentication, confidentiality and integrity. So, this chapter will focus on these three areas.
Authentication Authentication is the process of determining whether someone or something is, in fact, who or what it declares itself to be. The problem of authentication 165
CHAPTER 8. SECURITY
166
comes up in two main contexts when building systems: when a user tries to authenticate to a computing system (user-to-system) and when a system tries to authenticate to another system (system-to-system). When talking about user-to-system authentication, there are three main categories of approaches in which authentication can be performed, known as authentication factors. These are: • the knowledge factors, which correspond to something the authenticated entity knows. For example, this could be a password or the answer to a question. • the ownership factors, which correspond to something the authenticated entity has. For example, this could be an ID card or a hardware authentication device. • the inherence factors, which correspond to something the authenticated entity is or does. For example, this could be a biometric identifier, such as a fingerprint or DNA. In order to achieve the highest degree of security, a system should ideally use multiple authentication factors, a technique known as a multi-factor authentication. For example, many applications nowadays use two-factor authentication (2FA), where the user needs to provide a password first, then they receive a one-time code in a personal device that they have to provide back to the system. In this way, even if the password of the user gets compromised, a malicious actor will be unable to get access to the system on behalf of the user without also getting access to their personal device. There are also several good practices when doing password-based authentication. First of all, the system should avoid storing the password in plaintext, so that it is not readily accessible to system administrators and it cannot get leaked in case the database gets compromised. One way to mitigate this issue is to compute the hash of the password and store the value of the hash instead. Even in that case though, the attacker might be able to compute the original value of the password by precomputing a table of hashes for many possible values and then checking all the leaked hashes against this table. This is known as a rainbow attack. In order to make this kind of attack computationally harder, one can make use of a salt. This is a random piece of data that is generated for every password and appended to the password before calculating the hash value. It can be stored alongside the password hash, so that the same operation can be repeated when trying to authenticate an existing user. This makes rainbow attacks a lot harder to execute, because the attacker would have to calculate every possible combination of the salt
CHAPTER 8. SECURITY
167
with all the possible passwords and this cannot be done in advance, but only after discovering some salts, e.g. due to a data leak. These are only some of the many good practices you can follow to store passwords more securely. In system-to-system authentication, the knowledge and ownership factors are the main ones that are used. For example, a system A might create an account with credentials for a separate system B and these credentials might be injected at runtime on the system B, which will provide them to system A in order to authenticate before invoking any operations. Alternatively, system A might register public keys for other systems that can then use the corresponding private keys for authentication (e.g. in SSH).
Confidentiality Confidentiality refers to the process of protecting information from being accessed by unauthorised parties. The main technique to achieve this is encryption, which is the process of converting the original representation of some information, known as plaintext, into an alternative form, known as ciphertext, which has no relationship to the original plaintext and can be decrypted back into it only by specific authorised parties. For technical reasons, encryption is typically achieved through the use of a pseudo-random encryption key, where decryption is achieved through the use of the corresponding decryption key. There are two main encryption schemes: symmetric and asymmetric. Symmetric encryption is the simplest kind of encryption, where there is a single key that can be used both as the encryption and decryption key. Asymmetric encryption makes use of two separate keys, an encryption key and a decryption key. The encryption key is a public key that is shared with everyone that needs to send encrypted messages to an entity. The decryption key is kept private and is supposed to be used only by this entity to decrypt incoming messages. Symmetric encryption is generally faster than asymmetric encryption, but it suffers from a bootstrapping problem, because one needs to find a secure way to exchange the symmetric key in the first place. As a result, many protocols make use of asymmetric encryption initially in order to exchange a symmetric key, which is then used from that point on for symmetric encryption for the remaining communication. There are two main uses of encryption, known as encryption in transit and encryption at rest. Encryption in transit is used to encrypt data that are transferred through a network, which protects disclosure of this data to intermediaries in this transfer. Encryption at rest is used to encrypt data
CHAPTER 8. SECURITY
168
that is stored on computers and storage devices, which protects this data if other security measures fail and malicious actors gain access to the systems where the data is stored.
Figure 8.1: Asymmetric & symmetric encryption
Integrity Integrity refers to the process of preserving the accuracy and completeness of data over its entire lifecycle, so that they cannot be mofified in an unauthorized or undetected manner. This modification can happen maliciously (e.g. by an intermediary in the network) or inadvertently (e.g. because of a software or hardware failure). We have seen in the previous chapter some techniques that can protect against accidental modifications, such as checksums and cyclic redundancy checks, so here we will focus on malicious modification that is more relevant in the context of security. Another simple approach to protect the integrity of data is to calculate the hash value for a piece of data and send both the data and the hash. However, this suffers from a quite obvious problem: if the hash is sent alongside the data, any malicious actor that is able to modify the data might be able to figure out the hash function that is used and adjust the hash accordingly, which means we would be unable to detect the data has been modified. As a consequence,
CHAPTER 8. SECURITY
169
what we usually need is a combination of integrity and authentication, i.e. be certain that the hash corresponds to the data and that the hash has been calculated by the sender and not someone else. This can be achieved with similar techniques to the ones described previously. One such technique is message authentication codes, which make use of a shared secret key in order to generate a tag for a message that can then be verified against the data using the shared key. Of course, this technique suffers from similar problems as symmetric encryption. An alternative technique that can also provide both integrity and authentication is a digital signature. Digital signatures make use of asymmetric cryptography, where an algorithm generates a public and a private key. The private key is kept known only to the sender of the message, who can use it to sign a message producing a digital signature. The recipients can then make use of the public key that has been shared in order to verify that the digital signature is valid and has been generated by the sender. Similarly to asymmetric encryption, digital signatures can be used to protect the integrity and authenticity of data that are either transmitted through a network or stored in a system.
Figure 8.2: Digital signatures & message authentication codes
CHAPTER 8. SECURITY
170
A cryptography primer In this section, we will try to explain some of the basic mechanisms behind the techniques mentioned in the previous sections. However, keep in mind that cryptography is yet another vast field. So, we can only scratch the surface here to give some insight and hope the interested reader will spend more time to study the various topics in more depth. One of the most central pieces in cryptography is that of the cipher. A cipher is an algorithm for converting a piece of data, called plaintext, into a a piece of data, called ciphertext, which should appear to be random data and bear no relationship to the original data. Speaking more strictly, a cipher can be defined as a pair of efficient algorithms (E, D) where: • E: K x M -> C is an encryption function receiving a plaintext and a key as an input and providing a ciphertext as an output. • D: K x C -> E is a decryption function receiving a ciphertext and a key as an input and providing the original plaintext as an output. The one-time pad is the simplest example of a secure cipher and it is simply using an XOR operation: • E(k, m) = k XOR m • D(k, c) = k XOR c XOR is used in this case, because it has a very interesting property: if Y is a random variable with an unknown distribution in {0,1}ˆN and X is an independent variable with a uniform distribution in {0,1}ˆN, then Z = X XOR Y is a variable with a uniform distribution in {0,1}ˆN. This means that the underlying patterns in variable Y are removed when it’s XOR’ed with a uniformly distributed variable X. This makes the one-time pad a secure cipher under a very strong definition of security, known as semantic security, which defines that the ciphertext does not reveal any information at all about the plaintext. However, it is not easy to use in practice, because the key needs to be of the same size as the message to be encrypted. In fact, it’s been proven in general that perfect secrecy requires a key size at least equal or larger than the message size, which makes it quite impractical to have perfectly secure algorithms. For this reason, there are many different definitions of security that are used depending on the case. In general, there are two main categories of ciphers: stream ciphers and block ciphers.
CHAPTER 8. SECURITY
171
A stream cipher is a cipher that converts plaintext into ciphertext by encrypting one plaintext digit (e.g. bit) at a time with the corresponding digit of a pseudorandom keystream, which is generated based on a random key as shown in Figure 8.3. This cipher can be useful in situations where the full size of the plaintext is not known in advance. Stream ciphers that use an unpredictable pseudorandom generator for the underlying keystream are semantically secure, where semantic security is one of these alternative definitions. When using a cipher stream, one needs to be careful not to use the same key more than once in order to preserve its security.
Figure 8.3: Structure of a stream cipher Contrary to stream ciphers, block ciphers operate on fixed-length groups of bits, called blocks. Most block ciphers are classified as iterated block ciphers, which means they transform fixed-size blocks of plaintext into identically sized blocks of ciphertext by applying repeatedly an invertible transformation, known as a round function. Figure 8.4 shows an example of an iterated block cipher, known as substitution-permutation network, which performs several alternating rounds of substitutions (S-boxes) followed by permutations (Pboxes). In order to be secure, these operations must have specific properties, e.g. the S-boxes should not have any linearities. The key for every round is derived from the original encryption key. A block cipher by itself allows encryption only of a single data block of the plaintext. In order to encrypt multiple blocks (the whole plaintext), each block cipher can be used in different ways, which are called modes of operation. An example is the
CHAPTER 8. SECURITY
172
cipher-block chaining mode (CBC), which selects a random initialisation vector, which is added in an XOR manner to the first plaintext block before it is being encrypted. The resultant ciphertext block is then used as the new initialisation vector for the next plaintext block, as shown in Figure 8.5. Some of the ciphers mentioned so far provide confidentiality, but not necessarily integrity since a man in the middle could potentially alter the ciphertext in a way that would decrypt into a valid plaintext. A Message Authentication Code (MAC) is a mechanism that can provide integrity, but not confidentiality. It consists of 3 main pieces: a key-generator function, a signing function that can generate a tag for a plaintext and a verifying function that can verify whether a tag is valid for a specific plaintext. MACs can be constructed from other cryptographic primitives, such as block ciphers algorithms or cryptographic hash functions. By combining a cipher and a message authentication code, one can provide authenticated encryption, which is a form of encryption that provides simultaneously confidentiality and integrity of the messages. Only one combination of these techniques has been proven to be secure, where the plaintext is first encrypted and a MAC is then produced based on the resulting ciphertext. This technique is also known as encrypt-then-mac. As we explained so far, symmetric cryptography is mainly based on randomisation. On the other hand, asymmetric cryptography cannot rely only on randomisation, because there has to be a structure that allows an easy computation in the forward direction (e.g. generating the key pair), but makes the inverse direction extremely hard computationally (e.g. discovering the private key from the public key). The main building block for this are so-called intractable problems, which are problems for which there is no known efficient algorithm to solve them. There are 2 main types of such problems that are commonly leveraged in asymmetric cryptography: the discrete logarithm problem and the factoring problem. The former is about calculating the logarithm of a number, which is relatively easy for real numbers, but an extremely harder problem in modular arithmetic. The latter is the problem of distinguishing prime numbers from composite numbers and resolving the composite ones into their prime factors. There are two basic categories of public key encryption systems: those based on trapdoor functions and those based on Diffie-Helman key exchange. A trapdoor function is a function that is easy to calculate one way, but has a secret that is required to calculate in the opposite direction. RSA is one of the most widely used trapdoor functions and its security hardness relies on properties of modular arithmetic. To invert the RSA function, an attacker must factor a number,
CHAPTER 8. SECURITY
Figure 8.4: Structure of a substitution-permutation network
173
CHAPTER 8. SECURITY
174
Figure 8.5: CBC mode of encryption which is known to be an intractable problem. Having a trapdoor function, a symmetric authentication encryption scheme and a hash function, one can build a public key encryption system that provides authenticated encryption. Diffie-Helman is a protocol that can be used for key exchange based on the discrete logarithm problem. The two sides can use Diffie-Helman to exchange securely a private key that can then be used to perform symmetric encryption. Digital signatures can be used when we want to assure the integrity of a signature over a piece of data without any need for confidentiality. As we saw previously, message authentication codes solve the same problem, but they need a shared private key. Instead, when using digital signatures we want to generate signatures using a private key that can then be verified by anyone having our public key. This can be built on top of trapdoor functions in a similar way as described before, but the trapdoor function is used in the opposite way. For example, asymmetric encryption makes use of the trapdoor function when encrypting a message and the inverse trapdoor function when decrypting. On the other hand, digital signatures make use of the inverse trapdoor function when signing a message and the forward trapdoor function when validating a signature. At this point, you might think we papered over all the interesting details and you will be right! In order to examine all the details, we would probably need to write a whole other book. However, we hope we have given you some insight on the basic tools available and ideally triggered your curiosity to go and read more.
CHAPTER 8. SECURITY
175
Example protocols We will now look at some example protocols & standards used for security purposes and try to tie them back to the principles and techniques discussed previously. Transport Layer Security (TLS) The Transport Layer Security (TLS) protocol is a cryptographic protocol designed to provide secure communication over an insecure network. It can provide confidentiality, authentication and integrity. It typically runs above a reliable transport layer protocol, such as TCP1 . The sides participating in the communication are typically referred to as the client and the server, where the client is responsible for initiating the TLS connection. The protocol has two main parts: the handshake part where the two sides negotiate the parameters of the connection to be established and the data exchange part where the two sides can exchange data securely. TLS has different modes of operation depending on whether authentication needs to be performed and whether one or both sides need to be authenticated2 . As a result, the workflow of the protocol can differ slightly. Here we will look at the most common case, where only the server is authenticated. Figure 8.6 shows the workflow in this case, which works as follows: • The client sends a ClientHello message to initiate a TLS connection. This message contains a list of supported cipher suites supported by the client along with a client random number (nonce). • The server responds with a ServerHello message that contains a server random number (nonce) and the selected cipher suite. This cipher suite will contain a symmetric encryption algorithm (e.g. AES), a key exchange algorithm (e.g. RSA) and a MAC algorithm (e.g. HMAC). • The server will also send a ServerKeyExchange3 , a Certificate message that contains the server’s public key contained in a certificate and a ServerHelloDone message that indicates it is done with handshake negotiation. 1
However, there are equivalent protocols for unreliable transport layer protocols, such as DTLS that can work on top of UDP. 2 When both sides are authenticated, this is commonly known as mutual TLS. 3 This message is sent only for specific cipher suites.
CHAPTER 8. SECURITY
176
• The client will then verify the server’s certificate, extract its public key, generate a pre-master secret (PMS), encrypt it with the server’s public key and send it across through a ClientKeyExchange message. • At this point, the client and the server use the random numbers (nonces) that were exchanged previously along with the pre-master secret to compute a common secret, called the master secret, through the use of a key derivation function. This is subsequently used to generate all other key data (e.g. encryption keys, initialisation vectors etc.). • The client will then send a ChangeCipherSpec message that indicates everything will be encrypted from now. This message is followed by an encrypted Finished message that contains a hash and a MAC over all the previously exchanged handshake messages. The server will do the same thing in the other direction. This exchange of messages ensures no man-in-the-middle could have tampered with previous messages to degrade security, e.g. by modifying the list of supported cipher suites. • At this point, the handshake is complete and the two sides will exchange Application messages that will be authenticated and encrypted. One of the most common uses of TLS is in the HTTPS protocol, which is an extension of the HTTP protocol where data are exchanged securely between the server and the client over TLS. Public-key infrastructure (PKI) When using public key cryptography, one common problem is how a client can ensure the server is who they claim to be. In theory, a malicious party could generate a second keypair and present the public key to us, which we would use to encrypt the data to be sent. Even though the data would be encrypted, they would be sent to the wrong party who would be able to then decrypt them with their private key. In order to be able to ensure the client is talking to the server it expects, we can make use of certificates. Certificates are essentially digital documents that bind a public key to a specific entity and are then used by clients for authentication purposes. These certificates can have different formats, but X.509 is a common standard defining one such format for protocols, such as TLS. An X.509 certificate contains a public key and the identity this public key belongs to. This identity is represented by a so-called distinguished name, which uniquely identifies the entity and consists of various attributes, such as a common name, an organisation name, a locality name etc. The values for each of these fields can differ for each
CHAPTER 8. SECURITY
Figure 8.6: The TLS handshake
177
CHAPTER 8. SECURITY
178
use-case. In the case of TLS certificates used for websites, the common name is populated with the domain name of the website, so a certificate binds a public key to a specific domain name. Certificates are issued by specific organisations, called certificate authorities, which sign these certificates in order to attest to the ownership of this public key to the associated entity. Of course, before signing the certificates, the certificate authority first verifies the identity of the party that makes the request. In case of a web certificate, this could be done by asking them to prove they have control over the associated domain, e.g. by uploading specific content to a website served by this domain. These CAs also generate certificates for their own public keys, but these certificates are signed by their own private key and are known as self-signed certificates. As a result, they act as trusted third parties, which are trusted both by the owner of a certificate and by the users of the certificate. Sometimes, this can get more complicated with certificate authorities issuing certificates for the public keys of other certificate authorities that can then be used by them to issue other certificates. In this case, these certificates can form a certificate chain, as shown in Figure 8.7. The certificate at the bottom of the chain is known as the target certificate and the certificate at the top of the chain is knowns as the root certificate. A certificate chain is used to ensure that the public key contained in the target certificate and other metadata contained in it actually belong to its subject and this is true only when a certificate chain is valid. In order for a certificate chain to be valid, it must comply with several rules. First of all, the issuer of each certificate (except the root one) must match the subject of the next certificate in the list. Each of those certificates must also be signed by the secret key corresponding to the next certificate in the chain, i.e. the signature of the certificate must be verified successfully using the public key in the next certificate. The root certificate must be signed by an entity you inherently trust, that is why it is also known as trust anchor. For this purpose, applications are typically given a list of trusted root certificates. This can be done through various mechanisms, e.g. web browsers come with a predetermined set of root certificates pre-installed, so TLS certificates issued from major certificate authorities will work instantly. This list is curated accordingly to only include trustworthy organisations4 . There are also various mechanisms to revoke certificates that been already issued by certificate authorities, e.g. because the private key of the associated entity was compromised. Some of these mechanisms are the certificate revocation lists 4
For example, there have been cases, where certificate authorities have failed to operate safely and have been subsequently removed from these lists.
CHAPTER 8. SECURITY
179
(CRLs) and the online certificate status protocol (OCSP). This whole system for managing the creation, storage and distribution of digital certificates is also known as a public key infrastructure (PKI).
Figure 8.7: An example certificate chain
Web of trust (PGP) When using a public key infrastructure, one has to at trust at least the root certificate authorities, which means the overall system is subject to some form of centralisation. An alternative approach that aspired to solve the same problem in a more decentralised way is known as the web of trust.
CHAPTER 8. SECURITY
180
In the web of trust, there are no certificate authorities that are essentially trusted by everyone. Instead, the various parties sign each other’s public keys, thus endorsing the association of the public key with the person or entity listed in the corresponding certificate. This can be done in a key signing party, which is an event where people present their public keys to other people along with proofs of their identity (e.g. passports) who then sign digitally their public key. Each party can also assign a specific degree of trust to other parties, which can then be used to build a more elaborate voting scheme, as shown in Figure 8.8. For example, Alice can assign full trust to Charlie, but only marginal trust to Bob. This means that public keys from certificates that have been signed by Charlie will be automatically trusted. However, in order to trust public keys that have been signed by Bob, these keys must also be signed by other parties that are marginally trusted. This scheme is flexible and users can adjust these thresholds accordingly. In this way, all the parties form a web of trust. OpenPGP is a standard built on the concept of the web of trust. margina OAuth Many scenarios described in this section involve two main parties, a client and a server. In the case of the world wide web, the client can be a web browser and the server can be an application server. However, there are more complex scenarios that involve more parties. One such scenario is when a server needs to retrieve the client’s data from a different server, but it needs the client to authorise this data transfer. For example, this could happen when an e-mail application wants to retrieve the e-mail accounts of your friends in a separate social media application in order to add them in your contacts list. OAuth is a protocol that allows this to happen in a secure way5 . There are 4 main entities in this protocol: • the resource owner, which is the person who is giving access to some portion of their data. • the client, which is the application that is attempting to get access to some of the user’s data, e.g. the e-mail application in our previous example. • the resource server, which is the application that contains the user’s data to be accessed, e.g. the social media application in our previous 5 There are multiple versions of this protocol with significant changes, but anything described here will refer to OAuth 2.0
CHAPTER 8. SECURITY
Figure 8.8: An example of web of trust
181
CHAPTER 8. SECURITY
182
example. • the authorisation server, which is the server that presents the interface where the user approves or denies the request to their data. In simpler cases, this can be the same as the resource server, but in more complex scenarios they can be different. Figure 8.9 contains a visualisation of the main flow in the OAuth protocol, which has the following steps: • Initially, the user visits the website of the client application. The client applications redirects the user to a specific webpage of the authorisation server dedicated to OAuth. The request to the authorisation server contains some additional data, such as a redirect URI back to the client application, the category of user data that is requested, the ID of the client application etc. • The authorisation server returns a webpage to the user that presents a detailed message about the data that are requested to be accessed and the client application that is making the request. The user is given the option to authorise or reject this request6 . • When the user approves the request, the authorisation server will redirect the user back to the client application. Specifically, the user will be redirected back to the redirect URI contained in the original request, but this URI will be enriched with some additional data. One crucial piece of data is an authorisation code vended by the authorisation server representing the permission the user gave to the application code to access their data. • The client application will then make a request to the authorisation server providing this authorisation code in exchange for an access token. Apart from the authorisation code, the client application will also provide their client identifier along with a client secret. • The client application can then use this access token when making a request to the resource server requesting the user’s data. When encountering this workflow for the first time, one might question why there is a need for both the authorisation code and the access token. The main reason behind this is that the web browser is considered much more insecure when compared to the client application on the backend. So, only the authentication code is exposed to the web browser that cannot be used to retrieve the user’s data without the client’s secret that is securely stored 6 If the user isn’t logged in to the authorisation server, a login prompt will be shown first.
CHAPTER 8. SECURITY by the client application.
Figure 8.9: The authorisation flow in OAuth
183
Part V
From theory to practice
184
Chapter 9
Case studies This chapter examines in more detail some specific examples of distributed systems. Some of these systems are commercially available and widely used. Some of them have been developed and used internally in companies, but their design has been shared publicly via academic papers. The goal of this chapter is twofold: first to provide an overview of the basic categories of distributed systems and then to explain how these systems make use of the principles that have been described so far. However, keep in mind that some of the systems described in this chapter might have evolved since the time of writing. As a point of reference, the table below contains the versions of the associated systems at the time of writing, when applicable. System
Version
HDFS Zookeeper Hbase Cassandra FaunaDB Kafka Kubernetes Corda Spark Flink
3.1.2 3.5.5 2.0 3.11.4 2.7 2.3.1 1.13.12 4.1 2.4.4 1.8
185
CHAPTER 9. CASE STUDIES
186
Distributed file systems (HDFS/GFS) Google File System (GFS)[59] is a proprietary distributed file system developed by Google, which has been the inspiration for the Hadoop Distributed File System (HDFS)[60], a distributed file system that has been developed as an Apache project1 . As explained later, the basic design principles are similar for these two systems, which also have some small differences. The core requirements for these distributed file systems were the following: • fault tolerance: the ability to be operational even when some machines of the system have failed. • scalability: the ability to scale the system to significantly large sizes both in terms of number of files and file sizes. • optimised for batch operations: the system should be optimised for use-cases that involved batch operations, such as applications that performed processing and analysis of huge datasets. This implies that throughput is more important than latency and most of the files are expected to be mutated by appending data rather than overwriting existing data. Figure 9.1 displays a high-level overview of the GFS architecture. A GFS cluster consists of a single master node and multiple chunkserver nodes. Chunkserver nodes are responsible for storing and serving the data of the files, while the master node is responsible for maintaining the file system metadata, informing clients about which chunkservers store a specific part of a file and performing necessary administration tasks, such as garbage collection of orphaned chunks or data migration during failures. Note that the HDFS architecture is similar, but the master node is called Namenode and the chunkserver nodes are called Datanodes. Each file is divided into fixed-size chunks, which are identified by an immutable and globally unique 64-bit chunk handle, assigned by the master during chunk creation. Chunkservers store chunks on local disks as regular files. The system employs both partitioning and replication: it partitions files across different chunkservers and replicates each chunk on multiple chunkservers. The former improves performance and the latter improves availability and data reliability. The system takes into account the network topology of a datacenter, which usually consists of multiple racks of servers. This has several implications, e.g. bandwidth into or out of a rack 1
See: https://www.apache.org
CHAPTER 9. CASE STUDIES
187
Figure 9.1: GFS high-level architecture may be less than the aggregate bandwidth of all the machines within the rack and a failure of a single shared resource in a rack (a network switch or power circuit) can essentially bring all the machines of the rack down. When creating a new chunk and placing its initially empty replicas, a master tries to use chunkservers with below-average disk space utilisation. It also tries to use chunkservers that have a low number of recent creations, since that can reliably predict imminent heavy write traffic. In this way, the master attempts to balance disk and network bandwidth utilisation across the cluster. When deciding where to place the replicas, the master also follows a chunk replica placement policy that is configurable. By default, it will attempt to store two replicas at two different nodes that reside in the same rack, while storing the third replica at a node that resides in a separate rack. This is a trade-off between high network bandwidth and data reliability. The clients can create, read, write and delete files from the distributed file system by using a GFS client library linked in to the application that abstracts some implementation details. For example, the applications can operate based on byte offsets of files and the client library can translate these byte offsets to the associated chunk index, communicate with the master to
CHAPTER 9. CASE STUDIES
188
retrieve the chunk handle for the provided chunk index and the location of the associated chunkservers and finally contact the appropriate chunkserver (most likely the closest one) to retrieve the data. Figure 9.1 displays this workflow for a read operation. Clients cache the metadata for chunk locations locally, so they only have to contact master for new chunks or when the cache has expired. During migration of chunks due to failures, clients organically request fresh data from the master, when they realise the old chunkservers cannot serve the data for the specified chunk anymore. On the other hand, clients do not cache the actual chunk data, since they are expected to stream through huge files and have working sets that are too large to benefit from caching. The master stores the file and chunk namespaces, the mapping from files to chunks and the chunk locations. All metadata is stored in the master’s memory. The namespaces and the mappings are also kept persistent by logging mutating operations (e.g. file creation, renaming etc.) to an operation log that is stored on the master’s local disk and replicated on remote machines. The master node also checkpoints its memory state to disk when the log grows significantly. As a result, in case of the master’s failure the image of the filesystem can be reconstructed by loading the last checkpoint in memory and replaying the operation log from this point forward. File namespace mutations are atomic and linearizable. This is achieved by executing this operation in a single node, the master node. The operation log defines a global total order for these operations and the master node also makes use of read-write locks on the associated namespace nodes to perform proper serialization on any concurrent writes. GFS supports multiple concurrent writers for a single file. Figure 9.2 illustrates how this works. The client first communicates with the master node to identify the chunkservers that contain the relevant chunks. Afterwards, the clients starts pushing the data to all the replicas using some form of chain replication. The chunkservers are put in a chain depending on the network topology and data is pushed linearly along the chain. For instance, the client pushes the data to the first chunkserver in the chain, which pushes the data to the second chunkserver etc. This helps fully utilize each machine’s network bandwidth avoiding bottlenecks in a single node. The master grants a lease for each chunk to one of the chunkservers, which is nominated as the primary replica, which is responsible for serializing all the mutations on this chunk. After all the data is pushed to the chunkservers, the client sends a write request to the primary replica, which identifies the data pushed earlier. The primary assigns consecutive serial numbers to all the mutations,
CHAPTER 9. CASE STUDIES
189
applies them locally and then forwards the write request to all secondary replicas, which apply the mutations in the same serial number imposed by the primary. After the secondary replicas have acknowledged the write to the primary replica, then the primary replica can acknowledge the write to the client.
Figure 9.2: How writes work in GFS Of course, this flow is vulnerable to partial failures. For example, think about the scenario, where the primary replica crashes in the middle of performing a write. After the lease expires, a secondary replica can request the lease and start imposing a new serial number that might disagree with the writes of other replicas in the past. As a result, a write might be persisted only in some replicas or it might be persisted in different orders in different replicas. GFS provides a custom consistency model for write operations. The state of
CHAPTER 9. CASE STUDIES
190
a file region after a mutation depends on the type of mutation, whether it succeeds or fails and whether there are concurrent mutations. A file region is consistent if all clients will always see the same data, regardless of the replica they read from. A region is defined after a file data mutation if it is consistent and clients will see what the mutation writes in its entirety. When a mutation succeeds without interference from concurrent writes, the affected region is defined: all clients will always see what the mutation has written. Concurrent successful mutations leave the region undefined but consistent: all the clients see the same data, but it may not reflect what any one mutation has written. Typically, it consists of mingled fragments from multiple mutations. A failed mutation makes the region inconsistent: different clients may see different data at times. Besides regular writes, GFS also provides an extra mutation operation: record appends. A record append causes data to be appended atomically at least once even in the presence of concurrent mutations, but at an offset of GFS’s choosing, which is returned to the client. Clients are supposed to retry failed record appends and GFS guarantees that each replica will contain the data of the operation as an atomic unit at least once in the same offset. However, GFS may insert padding or record duplicates in between. As a result, successful record appends create defined regions interspersed with inconsistent regions. Table 9.3 contains a summary of the GFS consistency model.
Figure 9.3: How writes work in GFS Applications can accommodate this relaxed consistency model of GFS by applying a few simple techniques at the application layer: using appends rather than overwrites, checkpointing and writing self-validating, self-identifying records. Appending is far more efficient and more resilient to application failures than random writes. Each record prepared by a writer can contain
CHAPTER 9. CASE STUDIES
191
extra information like checksums so that its validity can be verified. A reader can then identify and discard extra padding and record fragments using these checksums. If occasional duplicates are not acceptable, e.g. if they could trigger non-idempotent operations, the reader can filter them out using unique record identifiers that are selected and persisted by the writer. HDFS has taken a slightly different path to simplify the semantics of mutating operations. Specifically, HDFS supports only a single writer at a time. It provides support only for append (and not overwrite) operations. It also does not provide a record append operation, since there are no concurrent writes and it handles partial failures in the replication pipeline a bit differently, removing failed nodes from the replica set completely in order to ensure file content is the same in all replicas. Both GFS and HDFS provide applications with the information where a region of a file is stored. This enables the applications to schedule processing jobs to be run in nodes that store the associated data, minimizing network congestion and improving the overall throughput of the system. This principle is also known as moving computation to the data.
Distributed coordination service (Zookeeper/Chubby/etcd) It must have become evident by now that coordination is a central aspect in distributed systems. Even though each component of a distributed system might function correctly in isolation, one needs to ensure that they will also function correctly when operating simultaneously. This can be achieved through some form of coordination between these components. As illustrated in the section about consensus, this coordination can end up being quite complicated with many edge cases. As a consequence, implementing these coordination algorithms on every new system from scratch would be inefficient and would also introduce a lot of risk for bugs. On the contrary, if there was a separate system that could provide this form of coordination as an API, it would be a lot easier for other systems to offload any coordination function to this system. Several different systems were born out of this need. Chubby [61] was such a system implemented internally in Google and used from several different systems for coordination purposes. Zookeeper [62] was a system that was partially inspired from Chubby, it was originally developed in Yahoo and later became an Apache project. It has been widely used by many companies
CHAPTER 9. CASE STUDIES
192
to perform coordination in distributed systems, including some systems that are part of the Hadoop ecosystem. etcd2 is another system that implements similar coordination primitives and formed the basis of Kubernetes’ control plane. As expected, these systems present a lot of similarities, but they also have some small differences. For the sake of brevity, this chapter will focus on Zookeeper providing an overview of its design, but it will also try to comment on the basic differences of the other two systems where relevant. Let’s start by looking at Zookeeper’s API, which is essentially a hierarchical namespace similar to a filesystem.3 Every name is a sequence of path elements separated by a slash (/). Every name represents a data node (called znode), which can contain a piece of metadata and children nodes. For instance, the node /a/b is considered a child of the node /a. The API contains basic operations that can be used to create nodes, delete nodes, check if a specific node exists, list the children of a node and read or set the data of a node. There are 2 types of znodes: regular nodes and ephemeral nodes. Regular nodes are created and deleted explicitly by clients. Ephemeral nodes can also be removed by the system when the session that created them expires (i.e. due to a failure). Additionally, when a client creates a new node, it can set a sequential flag. Nodes created with this flag have the value of a monotonically increasing counter appended to a provided prefix. Zookeeper also provides an API that allows clients to receive notifications for changes without polling, called watches. On read operations, clients can set a watch flag, so that they are notified by the system when the information returned has changed. A client connects to Zookeper initiating a session, which needs to be maintained open by sending heartbeats to the associated server. If a Zookeeper server does not receive anything from a client for more than a specified timeout, it considers the client faulty and terminates the session. This deletes the associated ephemeral nodes and unregisters any watches registered via this session. The update operations can take an expected version number, which enables the implementation of conditional updates resolving any conflicts arising from concurrent update requests. Zookeeper nodes form a cluster, which is called a Zookeepe ensemble. One of these nodes is designated as the leader and the rest of the nodes are followers. Zookeeper makes use of a custom atomic broadcast protocol, called Zab [63][64]. This protocol is used in order to elect the leader and 2
See: https://etcd.io Chubby also provides a hierarchical namespace, while etcd provides a key-value interface. 3
CHAPTER 9. CASE STUDIES
193
Figure 9.4: Hierarchical namespace in Zookeeper replicate the write operations to the followers4 . Each of those nodes has a copy of the Zookeper state in memory. Any changes are also recorded in a durable, write-ahead log which can be used for recovery. All the nodes can serve read requests using their local database. Followers have to forward any write requests to the leader node, wait until the request has been successfully replicated/broadcasted and then respond to the client. Reads can be served locally without any communication between nodes, so they are extremely fast. However, a follower node might be lagging behind the leader node, so client reads might not necessarily reflect the latest write that has been performed, thus not providing linearizability. For this reason, Zookeeper provides an additional operation called sync that clients can use to reduce the possibility of stale data. This operation will be directed to the current leader, who will put it at the tail of the queue that contains pending requests in order for it to be sent to the corresponding follower. The follower will wait for any pending sync operations to be completed before replying from subsequent read operations. It is important to note that the sync operation does not need to go through the broadcast protocol and thus reach a majority quorum, it it just placed at the end of the leader’s queue and forwarded only to the associated follower5 . As a result, read operations are not linearizable with 4
Chubby uses Paxos for this purpose, while etcd makes use of Raft. In contrast, in Chubby both read and write requests are directed to the master. This has the benefit of increased consistency, but the downside of decreased throughput. To mitigate this, Chubby clients cache extensively and the master is responsible for invalidating the caches before completing writes, thus making the system a bit more sensitive to client failures. 5
CHAPTER 9. CASE STUDIES
194
respect to write operations even if a sync operation is used first. The formal consistency model provided by Zookeeper lies between sequential consistency and linearizability and is called ordered sequential consistency[65].
Figure 9.5: Zookeeper architecture As a result, Zookeeper provides the following safety guarantees: • Linearizable writes: all requests that update the state of Zookeeper are linearizable and respect precedence. Of course, this means that each write operation will appear to take effect atomically at some point between when the client issues the request and receives the corresponding response. However, merely stating that write operations are linearizable is meaningless, since we also need to perform read operations to really notice their effects. • Sequentially consistent reads: read operations will appear to take effect in some sequential order that also respects the order of each client’s operations. As mentioned before, they are strictly speaking ordered sequentially consistent. • FIFO client order: all requests from a given client are executed in the order they were sent by the client.
CHAPTER 9. CASE STUDIES
195
There is one more important ordering guarantee: if a client is waiting for a change, the client will see the notification event before it sees the new state of the system after the change is made. As a result, when a client receives a notification and performs a read, the result will reflect all writes at least up to the one that triggered this notification. Zookeeper also provides the following 2 liveness and durability guarantees: • if a majority of servers are active and communicating, the service will be available • if the service responds successfully to a change request, that change persists across any number of failures as long as a quorum of servers is eventually able to recover.
As mentioned above, the Zookeeper atomic broadcast protocol (ZAB) is used in order to agree on a leader in the ensemble, synchronize the replicas, manage the broadcast of update transactions and recover from a crashed state to a valid state. This protocol shares a lot of characteristics with other consensus protocols, such as Paxos or Raft. In Zab, transactions are identified by a specific type of identifier, called zxid. This identifier consists of 2 parts , where e is the epoch number of the leader that generated the transaction and c is an integer acting as a counter for this epoch. The counter c is incremented every time a new transaction is introduced by the leader, while e is incremented when a new leader becomes active. The protocol consists of 4 basic phases: • Leader election: peers are initialised in this phase, having state election. This phase terminates when a quorum of peers have voted for a leader. This leader is prospective and will become an established leader only after the end of phase 3. • Discovery: in this phase, the leader communicates with the followers in order to discover the most up-to-date sequence of accepted transactions among a quorum and establish a new epoch so that previous leaders cannot commit new proposals. • Synchronisation: in this phase, the leader synchronises the replicas in the ensemble using the leader’s updated history from the previous phase. At the end of this phase, the leader is said to be established. • Broadcast: in this phase, the leader receives write requests and performs broadcasts of the associated transactions. This phase lasts until the leader loses its leadership, which is essentially maintained via
CHAPTER 9. CASE STUDIES
196
heartbeats to the followers. In practice, Zookeeper is using a leader election algorithm called Fast Leader Election (FLE), which employs an optimisation. It attempts to elect as leader the peer that has the most up-to-date history from a quorum of processes. This is done in order to minimize the data exchange between the leader and the followers in the Discovery phase.
The Zookeeper API can be used to build more powerful primitives. Some examples are the following: • Configuration management: This can be achieved simply by having the node that needs to publish some configuration information create a znode zc and write the configuration as the znode’s data. The znode’s path is provided to the other nodes of the system, which obtain the configuration by reading zc . They also register a watch, so that they are informed when this configuration changes. If that happens, they are notified and perform a new read to get the latest configuration. • Group membership: A node zg is designated to represent the group. When a node wants to join the group, it creates an ephemeral child node under zg . If each node has a unique name or identifier, this can be used as the name of the child node. Alternatively, the nodes can make use of the sequential flag to obtain a unique name assignment from Zookeeper. These nodes can also contain additional metadata for the members of the group, such as addresses and ports. Nodes can obtain the members of the group by listing the children of zg . If a node wants to also monitor changes in the group membership, it can register a watch. When nodes fail, their associated ephemeral nodes are automatically removed, which signals their removal from the group. • Simple locks: The simplest way to implement locks is by using a simple "lock file", which is represented by a znode. To acquire a lock, a client tries to create the designated znode with the ephemeral flag. If the create succeeds, the client holds the lock. Otherwise, the client can set a watch to the created node to be notified if the lock is released, so that it attempts to re-acquire it. The lock is released when the client explicitly deletes the znode or when it dies. • Locks without herd effect: The previous pattern suffers from the herd effect: if there are many clients waiting to acquire a lock, they will all be notified simultaneously and attempt to acquire the lock even though only one can acquire it, thus creating unnecessary contention.
CHAPTER 9. CASE STUDIES
197
There is a different way to implement locks to avoid this problem. All the clients competing for the lock attempt to create a sequential, ephemeral znode with the same prefix (i.e. /lock-). The client with the smallest sequence number acquires the lock. The rest of the clients register watches for the znode with the next lower sequence number. Once a node is notified, it can check if it’s now the lowest sequence number, which means it has acquired the lock. Otherwise, it registers a new watch for the next znode with the lower sequence number. In similar ways, many more primitives can be built, such as read/write locks, barriers etc. These patterns in Zookeeper are usually called recipes 6 .
Distributed datastores This section will examine some basic categories of distributed datastores. It is impossible to cover all available datastores here. Furthermore, each datastore can have some special characteristics that make it differ from the rest, so it is not easy to establish clear boundaries between them and classify them into well-defined categories. For this reason, datastores are grouped based on their most basic architectural characteristics and their historical origins. It is useful to draw comparisons against these systems and try to understand the strengths and weaknesses of each one.
BigTable/HBase BigTable [7] is a distributed storage system that was initially developed in Google and was the inspiration for HBase7 , a distributed datastore that is part of the Apache Hadoop project. As expected, the architecture of these two systems is very close, so this section will focus on HBase, which is an open-source system. HBase provides a sparse, multi-dimensional sorted map as a data model, as shown in Figure 9.6. The map is indexed by a row key, a column key and a timestamp, while each value in the map is an uninterpreted array of bytes. The columns are further grouped in column families. All members of a column family are physically stored together on the filesystem and 6 7
See: https://zookeeper.apache.org/doc/current/recipes.html See: https://hbase.apache.org
CHAPTER 9. CASE STUDIES
198
Figure 9.6: HBase data model & physical layout the user can specify tuning configurations for each column family, such as compression type or in-memory caching. Column families need to be declared upfront during schema definition, but columns can be created dynamically. Furthermore, the system supports a small number of column families, but an unlimited number of columns. The keys are also uninterpreted bytes and rows of the table are physically stored in lexicographical order of the keys. Each table is partitioned horizontally using range partitioning based on the row key into segments, called regions. The main goal of this data model and the architecture described later is to allow the user to control the physical layout of data, so that related data are stored near each other. Figure 9.7 shows the high-level architecture of HBase, which is also based on a master-slave architecture. The master is called HMaster and the slaves are called region servers. The HMaster is responsible for assigning regions to region servers, detecting the addition and expiration of region servers, balancing region server load and handling schema changes. Each region server manages a set of regions, handling read and write requests to the regions it has loaded and splitting regions that have grown too large. Similar to other single-master distributed systems, clients do not communicate with the master for data flow operations, but only for control flow operations in order to prevent it becoming the performance bottleneck of the system. Hbase uses Zookeeper to perform leader election of the master node, maintain group membership of region servers, store the bootstrap location of HBase data and also store schema information and access control lists. Each region server stores the data for the associated regions in HDFS, which provides the necessary redundancy. A region server can be collocated at the same
CHAPTER 9. CASE STUDIES
199
machine of an HDFS datanode to enable data locality and minimize network traffic.
Figure 9.7: HBase high-level architecture There is a special HBase table, called the META table, which contains the mapping between regions and region servers in the cluster. The location of this table is stored in Zookeeper. As a result, the first time a client needs to read/write to HBase, it first communicates with Zookeeper to retrieve the region server that hosts the META table, then contacts this region server to find the region server that contains the desired table and finally sends the read/write operation to that server. The client caches locally the location of the META table and the data already read from this table for future use. HMasters initiallly compete to create an ephemeral node in Zookeeper. The first one to do so becomes the active master, while the second one listens for notifications from Zookeeper of the active master failure. Similarly, region servers create ephemeral nodes in Zookeeper at a directory monitored by the HMaster. In this way, the HMaster is aware of region servers that join/leave the cluster, so that it can manage assignment of regions accordingly. Appends are more efficient than random writes, especially in a filesystem like HDFS. Region servers try to take advantage of this fact by employing the following components for storage and data retrieval:
CHAPTER 9. CASE STUDIES
200
• MemStore: this is used as a write cache. Writes are initially written in this data structure, which is stored in-memory and can be sorted efficiently before being written to disk. Writes are buffered in this data structure and periodically written to HDFS after being sorted. • HFile: this is the file in HDFS which stores sorted key-value entries on disk. • Write ahead log (WAL): this stores operations that have not been persisted to permanent storage and are only stored in the MemStore. This is also stored in HDFS and is used for recovery in the case of a region server failure. • BlockCache: this is the read cache. It stores frequently read data in memory and least recently used data is evicted when the cache is full. As a result, write operations go through WAL and MemStore first and eventually end up being stored in HFiles8 , as shown in Figure 9.8. Read operations have to read from both the MemStore, the BlockCache and the existing HFiles and merge the results. This can be quite inefficient, so there are several optimisations used. As mentioned previously, columns are grouped by their column family and stored separately. As a result, only the HFiles that contain the required column family need to be queried. All the entries in an HFile are stored in lexicographical order and they contain an index in the end of the file which can be kept in memory, so reads can find the required data without reading the whole file. Each HFile also contains the time range of the entries contained in it to avoid unnecessary reads of files that cannot contain the requested data. Bloom filters9 are also used to reduce the number of HFiles that need to be read; these are special data structures that make it easy to identify whether some data is not contained in a file using a very small amount of memory. There is also a background process, called compaction, which merges multiple HFiles into a single HFile removing older versions of data that are not needed anymore, thus reducing the number of HFiles that need to be inspected during read operations. Some of the guarantees provided by HBase are the following: Atomicity: • Operations that mutate multiple rows are atomic. – An operation that returns a success code has completely succeeded. 8 This pattern originates from a data structure, called a log-structured merge (LSM) tree[66] 9 See: https://en.wikipedia.org/wiki/Bloom_filter
CHAPTER 9. CASE STUDIES
201
Figure 9.8: read and write data flow in HBase – An operation that returns a failure code has completely failed. – An operation that times out may have succeeded or may have failed. However, it cannot have partially succeeded or failed. – This is true even if the mutation crosses multiple column families within a row.10 • Operations that mutate multiple rows will not be atomic. For example, a mutative operation on rows ’a’, ’b’ and ’c’ may return having mutated some but not all of the rows. In this case, the operation will return a list of codes, some of which may be successes, failures or timeouts. • Hbase provides a conditional operation, called checkAndPut, which happens atomically like the typical compareAndSet (CAS) operation found in many hardware architectures. Consistency & Isolation: • Single-row reads/writes are linearizable. – When a client receives a successful response for any mutation, this mutation is immediately visible to both that client and any client with whom it later communicates through side channels. • HBase provides a scan operation that provides efficient iteration over multiple rows. This operation does not provide a consistent view of 10
This is achieved by fine-grained, per-row locking. Note that HFiles are essentially immutable, so only the MemStore needs to participate in this which makes it very efficient.
CHAPTER 9. CASE STUDIES
202
the table and does not exhibit snapshot isolation. Instead: – Any row returned by the scan is a consistent view, i.e. that version of the complete row existed at some point in time. – A scan operation must reflect all mutations committed prior to the construction of the scanner and may reflect some mutations committed subsequent to the construction of the scanner. Durability: • All visible data is also durable. This means that a read will never return data that has not been made durable on disk. • Any mutative operation that returns a successful response has been made durable. • Any operation that has been made durable is stored in at least n different servers (Namenodes), where n is the configurable replication factor of HDFS. As mentioned earlier, HBase and Bigtable have a very similar architecture with slightly different naming for the various components and different dependencies. The table below contains a mapping between HBase concepts and the associated concepts in Bigtable. HBase region region server Zookeeper HDFS HFile MemStore
Bigtable tablet tablet server Chubby GFS SSTable Memtable
Cassandra Cassandra is a distributed datastore that combined ideas from the Dynamo[8]11 and the Bigtable[7] paper. It was originally developed by Facebook[9], but it was then open sourced and became an Apache project. 11
There is also a separate distributed system, which is called DynamoDB. This is commercially available, but details around its internal architecture have not been shared publicly yet. However, this system has a lot of similarities with Cassandra, such as the data model and tunable consistency.
CHAPTER 9. CASE STUDIES
203
During this period, it has evolved significantly from its original implementation12 . The main design goals of Cassandra are extremely high availability, performance (high throughput/low latency with emphasis on write-heavy workloads) with unbounded, incremental scalability. As explained later, in order to achieve these goals it trades off some other properties, such as strong consistency. The data model is relatively simple: it consists of keyspaces at the highest level, which can contain multiple, different tables. Each table stores data in sets of rows and is characterised by a schema. This schema defines the structure of each row, which consists of the various columns and their types. The schema also determines the primary key, which is a column or a set of columns that have unique values for each row. The primary key can have two components: the first component is the partition key and it’s mandatory, while the second component contains the clustering columns and is optional. If both of these components are present, then the primary key is called a compound primary key. Furthermore, if the partition key is composed of multiple columns, it’s called a composite partition key. Figure 9.9 contains an example of two tables, one having a simple primary key and one having a compound primary key.
Figure 9.9: Cassandra data model The primary key of a table is one of the most important parts of the schema, because it determines how data is distributed across the system and also how it is stored in every node. The first component of the primary key, the partition key determines the distribution of data. The rows of a table are conceptually split into different partitions, where each partition contains 12
The information presented in this section refers to the state of this project at the time of writing.
CHAPTER 9. CASE STUDIES
204
only rows with the same value for the defined partition key. All the rows corresponding to a single partition are guaranteed to be stored collocated in the same nodes, while rows belonging to different partitions can be distributed across different nodes. The second component of the primary key, the clustering columns, determine how rows of the same partition will be stored on disk. Specifically, rows of the same partition will be stored in ascending order of the clustering columns defined, unless specified otherwise. Figure 9.10 elaborates on the previous example, showing how data from the two tables would be split into partitions and stored in practice.
Figure 9.10: Cassandra partitioning Cassandra distributes the partitions of a table across the available nodes using consistent hashing, while also making use of virtual nodes to provide balanced, fine-grained partitioning. As a result, all the virtual nodes of a Cassandra cluster form a ring. Each virtual node corresponds to a specific value in the ring, called the token, which determines which partitions will belong to this virtual node. Specifically, each virtual node contains all the partitions whose partition key (when hashed) falls in the range between its token and the token of the previous virtual node in the ring13 . Every Cassandra node can be assigned multiple virtual nodes. Each partition is also replicated across N nodes, where N is a number that is configurable per keyspace and it’s called the replication factor. There are multiple, available replication strategies that determine how the additional N-1 nodes 13
Cassandra also supports some form of range partitioning, via the ByteOrderedPartitioner. However, this is available mostly for backwards compatibility reasons and it’s not recommended, since it can cause issues with hot spots and imbalanced data distribution.
CHAPTER 9. CASE STUDIES
205
are selected. The simplest strategy just selects the next nodes clockwise in the ring. More complicated strategies also take into account the network topology of the nodes for the selection. The storage engine for each node is inspired by Bigtable and is based on a commit log containing all the mutations and a memtable that is periodically flushed to SSTables, which are also periodically merged via compactions.
Figure 9.11: Cassandra partitioning The nodes of the cluster communicate with each other periodically via a gossip protocol, exchanging state and topology information about themselves and other nodes they know about. New information is gradually spread throughout the cluster via this process. In this way, nodes are able to keep track of which nodes are responsible for which token ranges, so that they can route requests accordingly. They can also determine which nodes are healthy and which are not, so that they can omit sending requests to nodes that are unreachable. Administrator tools are available that can be used by an operator to instruct a node of the cluster to remove another node that has crashed permanently from the ring. Any partitions belonging to that node will be replicated to a different node from the remaining replicas. There is need for a bootstrap process that will allow the first nodes to join the cluster. For this reason, a set of nodes are designated as seed nodes
CHAPTER 9. CASE STUDIES
206
and they can be specified to all the nodes of the cluster via a configuration file or a third-party system during startup. Cassandra has no notion of a leader or primary node. All replica nodes are considered equivalent. Every incoming request can be routed to any node in the cluster. This node is called the coordinator node and is responsible for managing the execution of the request on behalf of the client. This node identifies the nodes that contain the data for the requested partition and dispatches the requests. After successfully collecting the responses, it replies to the client. Given there is no leader and all replica nodes are equivalent, they can be handling writes concurrently. As a result, there is a need for a conflict resolution scheme and Cassandra makes use of a last-write-wins (LWW) scheme. Every row that is written comes with a timestamp. When a read is performed, the coordinator collects all the responses from the replica nodes and returns the one with the latest timestamp. The client can also specify policies that define how this coordinator node is selected. This policy might select coordinator nodes randomly in a roundrobin fashion, select the closest node or select one of the replica nodes to reduce subsequent network hops. Similar to the concept of seed nodes, the client driver is provided some configuration that contains a list of contact points, which are nodes of the cluster. The client will initially try and connect to one of these nodes in order to acquire a view of the whole cluster and be able to route requests everywhere. When communicating with Cassandra nodes, clients can specify different consistency levels, which allows them to optimise for consistency, availability or latency accordingly. The client can define the desired read consistency level and the desired write consistency level, where each consistency level provides different guarantees. Some of the available levels are the following: • ALL: A write must be written to all replica nodes in the cluster for the associated partition. A read returns the record only after all replicas have responded, while the operation fails if a single replica does not respond. This option provides the highest consistency and the lowest availability. • QUORUM: A write must be written on a quorum of replica nodes across all datacenters. A read returns the record after a quorum of replicas from all datacenters has replied. This option provides a balance between strong consistency and tolerance to a small number of failures. • ONE: A write must be written to at least one replica node. A read returns the record after the response of a single replica node. This
CHAPTER 9. CASE STUDIES
207
option provides the highest availability, but incurs a risk of reading stale data since the replica that replied might not have received the latest write. The two consistency levels are not independent, so one should consider the interactions between them when deciding the appropriate level. If we assume a keyspace with replication factor N and clients that read with read consistency R and write with write consistency W, then a read operation is guaranteed to reflect the latest successful write as long as R + W > N. For instance, this could be achieved by performing both reads and writes at QUORUM level. Alternatively, it could be achieved by performing reads at ONE level and writes at ALL level or vice versa. In all of these cases, at least one node from the read set will exist in the write set, thus having seen the latest write. However, each one of them provides different levels of availability, durability, latency and consistency for read and write operations. As illustrated so far, Cassandra favours high availability and performance over data consistency. As a result, it employs several mechanisms that ensure the cluster can keep processing operations even during node failures and partitions and the replicas can converge again as quickly as possible after recovery. Some of these mechanisms are the following: • hinted handoff • read repair • anti-entropy repair Hinted handoff happens during write operations: if the coordinator cannot contact the necessary number of replicas, then the coordinator can store locally the result of the operation and forward it to the failed node after it has recovered. Read repair happens during read operations: if the coordinator receives conflicting data from the contacted replicas, it resolves the conflict by selecting the latest record and forwards it synchronously to the stale replicas before responding to the read request. Anti-entropy repair happens in the background: replica nodes exchange the data for a specific range and if they find differences, they keep the latest data for each record, complying with the LWW strategy. However, this involves big datasets, so it’s important to minimise consumption of network bandwidth. For this reason, the nodes encode the data for a range in a Merkle tree and exchange parts of the tree gradually, so that they can discover the conflicting data that need to be exchanged. It is important to note that by default operations on a single row are not
CHAPTER 9. CASE STUDIES
208
linearizable even when using majority quorums. To understand why, it is useful to understand how partial failures and network delays are handled by Cassandra. Let’s examine two different scenarios. • First, let’s assume that read repair is not used. The system consists of 3 different replicas with a single row that contains a single column owner with value "none". Client A initially performs a write operation to set owner = A. While this operation is in progress, two different clients B and C perform a read operation for owner in sequence. The majority quorum of client B contains one replica that has already received the write operation, while client C contacts a quorum with nodes that haven’t received it yet. As a result, client B reads owner = A, while client C reads owner = none even though the operation from the latter started after the operation from the former had completed, which violates linearizability. Figure 9.12 contains a diagram illustrating this phenomenon.
Figure 9.12: Linearizability violations when read repair is not used • The violation of linearizability in the previous example would be eliminated if read repair was used, since the read from client B would propagate the value to the replica 2 and client C would also read owner = A. So, let’s assume that read repair is used and examine a different
CHAPTER 9. CASE STUDIES
209
scenario. Client A performs again a write operation to set owner = A. The write succeeds in one replica and fails in the other replica. As a result, the write is considered unsuccessful and the coordinator returns a failure response back to the client. Afterwards, client B performs a read operation that uses a quorum that contains the replica where the previous write succeeded. Cassandra performs a read repair using the LWW strategy, thus propagating the value to replica 2. As a consequence, a write operation that failed has affected the state of the database, thus violating linearizability. This example is shown in Figure 9.13.
Figure 9.13: Linearizability violations when read repair is used Cassandra provides another consistency level that provides linearizability guarantees. This level is called SERIAL and the read/write operations executed in this level are also referred to as lightweight transactions. This level is implemented using a 4-phase protocol based on Paxos, as shown in Figure 9.14. The first and third phases of the protocol are the exact phases of Paxos and satisfy the same needs: the first phase is called prepare and corresponds to the nodes trying to gather votes before proposing a value which is done in the third phase, called propose. When run under SERIAL level, the write operations are conditional using an IF clause, also known as compare-and-set (CAS). The second phase of the protocol is called read
CHAPTER 9. CASE STUDIES
210
and is used to retrieve the data in order to check whether the condition is satisfied before proceeding with the proposal. The last phase is called commit and it’s used to move the accepted value into Cassandra storage and allow a new consensus round, thus unblocking concurrent LWTs again. Read and write operations executed under SERIAL are guaranteed to be linearizable. Read operations will commit any accepted proposal that has not been committed yet as part of the read operation. Write operations under SERIAL are required to contain a conditional part. In Cassandra, performing a query that does not make use of the primary key is guaranteed to be inefficient, because it will need to perform a full table scan querying all the nodes of the cluster. There are two alternatives to this: secondary indexes and materialized views. A secondary index can be defined on some columns of a table. This means each node will index locally this table using the specified columns. A query based on these columns will still need to ask all the nodes of the system, but at least each node will have a more efficient way to retrieve the necessary data without scanning all the data. A materialised view can be defined as a query on an existing table with a newly defined partition key. This materialised view is maintained as a separate table and any changes on the original table are eventually propagated to it. As a result, these two approaches are subject to the following trade-off: • Secondary indexes are more suitable for high cardinality columns, while materialized views are suitable for low cardinality columns as they are stored as regular tables. • Materialised views are expected to be more efficient during read operations when compared to secondary indexes, since only the nodes that contain the corresponding partition are queried. • Secondary indexes are guaranteed to be strongly consistent, while materialised views are eventually consistent. Cassandra does not provide join operations, since they would be inefficient due to the distribution of data. As a result, users are encouraged to denormalise the data by potentially including the same data in multiple tables, so that they can be queried efficiently reading only from a minimum number of nodes. This means that any update operations on this data will need to update multiple tables, but this is expected to be quite efficient. Cassandra provides 2 flavours of batch operations that can update multiple partitions and tables: logged and unlogged batches. Logged batches provide the additional guarantee of atomicity, which means either all of the statements of the batch operation will take effect or none of them. This can help ensure
CHAPTER 9. CASE STUDIES
Figure 9.14: Phases of Cassandra LWT protocol
211
CHAPTER 9. CASE STUDIES
212
that all the tables that share this denormalised data will be consistent with each other. However, this is achieved by first logging the batch as a unit in a system table which is replicated and then performing the operations, which makes them less efficient than unlogged batches. Both logged and unlogged batches do not provide any isolation, so concurrent requests might observe the effects of some of the operations only temporarily.
Spanner Spanner is a distributed datastore that was initially developed internally by Google [5][67] and was subsequently released publicly as part of the Google platform14 . The data model of Spanner is very close to the data model of classical relational databases. A database in Spanner can contain one or more tables, which can contain multiple rows. Each row contains a value for each column that is defined and one or more columns are defined as the primary key of the table, which must be unique for each row. Each table contains a schema that defines the data types of each column. Spanner partitions the data of a table using horizontal range partitioning. The rows of a table are partitioned in multiple segments, called splits. A split is a range of contiguous rows, where the rows are ordered by the corresponding primary key. Spanner can perform dynamic load-based splitting, so any split that receives an extreme amount of traffic can be partitioned further and stored in servers that have less traffic. The user can also define parentchild relationships between tables, so that related rows from the tables are collocated making join operations much more efficient. A table C can be declared as a child table of A, using the INTERLEAVE keyword and ensuring the primary key of the parent table is a prefix of the primary key of the child table. An example is shown in Figure 9.15, where a parent table Singers is interleaved with a child table, called Albums . Spanner guarantees that the row of a parent table and the associated rows of the child table will never be assigned to a different split. A Spanner deployment is called a universe and it consists of a set of zones, which are the units of administrative deployment, physical isolation and replication (e.g. datacenters). Each zone has a zonemaster and hundreds to several thousands spanservers. The former is responsible for assigning 14
See: https://cloud.google.com/spanner
CHAPTER 9. CASE STUDIES
213
Figure 9.15: Definition of interleaved tables and data layout data to spanservers, while the latter process read/write requests from clients and store data. The per-zone location proxies are used by clients to locate the spanservers that serve a specific portion of data. The universe master displays status information about all the zones for troubleshooting and the placement driver handles automated movement of data across zones, e.g. for load balancing reasons. Each spanserver can manage multiple splits and each split is replicated across multiple zones for availability, durability and performance15 . All the replicas of a split form a Paxos group. One of these replicas is voted as the leader and is responsible for receiving incoming write requests and replicating them to the replicas of the group via a Paxos round. The rest of the replicas are followers and can serve some kinds of read requests. Spanner makes use of long-lived leaders with time-based leader leases, which are renewed by default every 10 seconds. Spanner makes use of pessimistic concurrency control to ensure proper isolation between concurrent transactions, specifically twophase locking. The leader of each replica group maintains a lock table that maps ranges of keys to lock states for this purpose16 . Spanner also provides support for distributed transactions that involve multiple splits that potentially belong to different replica groups. This is achieved via 15
In fact, each split is stored in a distributed filesystem, called Colossus that is the successor of GFS, which already provides byte-level replication. However, Spanner adds another level of replication to provide the additional benefits of data availability and geographic locality. 16 In practice, these locks are also replicated in the replicas of the group to cover against failures of the leader.
CHAPTER 9. CASE STUDIES
Figure 9.16: Spanner architecture
214
CHAPTER 9. CASE STUDIES
215
two-phase commit across the involved replica groups. As a result, the leader of each group also implements a transaction manager to take part in the two-phase commit. The leaders of each group that take part are referred to as participant leaders and the follower replicas of each one of those groups are referred to as participant slaves. More specifically, one of these groups is chosen as the coordinator for the two-phase commit protocol and the replicas of this group are referred to as coordinator leader and slaves respectively. Spanner makes use of a novel API to record time, called TrueTime [68], which was the key enabler for most of the consistency guarantees provided by Spanner. This API directly exposes clock uncertainty and nodes can wait out that uncertainty when comparing timestamps retrieved from different clocks. If the uncertainty gets large because of some failure, this will manifest as increased latency due to nodes having to wait longer periods. TrueTime represents time as a TTInterval, which is an interval [earliest, latest] with bounded time uncertainty. The API provides a method TT.now() that returns a TTInterval that is guaranteed to contain the absolute time during which the method was invoked17 . It also provides two convenience methods TT.after(t), TT.before(t) that specify whether t is definitely in the past or in the future. These are essentially just wrappers around TT.now(), since TT.after(t) = t < TT.now().earliest and TT.before(t) = t > TT.now().latest. As a result, Spanner can assign timestamps to transactions that have global meaning and can be compared by nodes having different clocks. TrueTime is implemented by a set of time master machines per datacenter and a timeslave daemon per machine. The masters can use one of two different forms of time reference, either GPS or atomic clocks, since they have different failure modes. The master servers compare their time references periodically and they also cross-check the rate at which their reference time advances against their local clock, evicting themselves from the cluster if there is a significant divergence. Daemons poll a variety of masters to synchronise their local clocks and advertise an uncertainty e which corresponds to half of the interval’s width (latest - earliest) / 2. This uncertainty depends on master-daemon communication latency and the uncertainty of the masters’ time. This uncertainty is a sawtooth function of time that is slowly increasing between synchronisations. In Google’s production environment, the average value of this uncertainty was reported to be 4 milliseconds. 17
As also explained in the chapter about time, this is assuming there’s an idealized absolute time that uses the Earth as a single frame of reference and is generated using multiple atomic clocks. See: https://en.wikipedia.org/wiki/International_Atomic_Time
CHAPTER 9. CASE STUDIES
216
Spanner supports the following types of operations: • standalone (strong or stale) reads • read-only transactions • read-write transactions A read-write transaction can contain both read and/or write operations. It provides full ACID properties for the operations of the transaction. More specifically, read-write transactions are not simply serializable, but they are strictly serializable18 . A read-write transaction executes a set of reads and write operations atomically at a single logical point in time. As explained before, Spanner achieves these properties with the use of two-phase locking for isolation and two-phase commit for atomicity across multiple splits. More specifically, the workflow is the following: • After opening a transaction, a client directs all the read operations to the leader of the replica group that manages the split with the required rows. This leader acquires read locks for the rows and columns involved before serving the read request. Every read also returns the timestamp of any data read. • Any write operations are buffered locally in the client until the point the transaction is committed. While the transaction is open, the client sends keepalive messages to prevent participant leaders from timing out a transaction. • When a client has completed all reads and buffered all writes, it starts the two-phase commit protocol19 . It chooses one of the participant leaders as the coordinator leader and sends a prepare request to all the participant leaders along with the identity of the coordinator leader. The participant leaders that are involved in write operations also receive the buffered writes at this stage. • Every participant leader acquires the necessary write locks, chooses a prepare timestamp that is larger than any timestamps of previous transactions and logs a prepare record in its replica group through Paxos. The leader also replicates the lock acquisition to the replicas to ensure they will be held even in the case of a leader failure. It then responds to the coordinator leader with the prepare timestamp. 18 In fact, Spanner documentation also refers to strict serializability with the name "external consistency", but both are essentially the same guarantees. 19 The two-phase commit is required only if the transaction accesses data from multiple replica groups. Otherwise, the leader of the single replica group can commit the transaction only through Paxos.
CHAPTER 9. CASE STUDIES
217
• The coordinator leader waits for the prepare response from all participant leaders. Afterwards, it acquires write locks and selects the commit timestamp of the transaction s. This must be greater or equal to all prepare timestamps from the participant leaders, greater than TT.now().latest at the time the coordinator received the commit request from the client and greater than any timestamps it has assigned to previous transactions. The coordinator leader then logs a commit record at its replica group through Paxos and then sends the commit timestamp to the client and all the participant leaders. In fact, the coordinator waits until TT.after(s) before doing that to ensure that clients cannot see any data committed by s until after TT.after(s) is true. • Each participant logs the transaction’s outcome through Paxos at its replica group, applies the transaction’s writes at the commit timestamp and then release any locks held from this transaction. Figure 9.17 contains a visualisation of this sequence. It is worth noting that the availability problems from two-phase commit are partially mitigated in this scheme by the fact that both the participants and the coordinator are essentially a Paxos group. So, if one of the leader nodes crashes, then another replica from that replica group will eventually detect that, take over and help the protocol make progress. Furthermore, the two-phase locking protocol can result in deadlocks. Spanner resolves these situations via a wound-wait scheme [69], where a transaction TX1 is allowed to abort a transaction TX2 that holds a desired lock only if TX1 is older than TX2 . Spanner needs a way to know if a replica is up-to-date to satisfy a read operation. For this reason, each replica tracks a value called safe time tsafe , which is the maximum timestamp at which the replica is up-to-date. Thus, a replica can satisfy a read at a timestamp t if t ≤ tsafe . This value is calculated as tsafe = min(tsafe Paxos , tsafe TM ). tsafe Paxos is the timestamp of the highest-applied Paxos write at a replica group and represents the highest watermark below which writes will no longer occur with respect to Paxos. tsafe TM is calculated as mini (si,g prepare ) over all transactions Ti prepared (but not committed yet) at replica group g. If there is no such transactions, then tsafe TM = + ∞. Read-only transactions allow a client to perform multiple reads at the same timestamp and these operations are also guaranteed to be strictly serializable. An interesting property of read-only transactions is they do not need to hold any locks and they don’t block other transactions. The reason for
CHAPTER 9. CASE STUDIES
218
Figure 9.17: Read-write transactions in Spanner this is that these transactions perform reads at a specific timestamp, which is selected in such a way as to guarantee that any concurrent/future write operations will update data at a later timestamp. The timestamp is selected at the beginning of the transaction as TT.now().latest and it’s used for all the read operations that are executed as part of this transaction. In general, the read operations at timestamp tread can be served by any replica g that is up to date, which means tread ≤ tsafe,g . More specifically: • In some cases, a replica can be certain via its internal state and TrueTime that it is up to date enough to serve the read and does so. • In some other cases, a replica might not be sure if it has seen the latest data. It can then ask the leader of its group for the timestamp of the last transaction it needs to apply in order to serve the read. • In the case the replica is the leader itself, it can proceed directly since it is always up to date. Spanner also supports standalone reads outside the context of transactions. These do not differ a lot from the read operations performed as part of read-only transactions. For instance, their execution follows the same logic
CHAPTER 9. CASE STUDIES
219
using a specific timestamp. These reads can be strong or stale. A strong read is a read at a current timestamp and is guaranteed to see all the data that has been committed up until the start of the read. A stale read is a read at a timestamp in the past, which can be provided by the application or calculated by Spanner based on a specified upper bound on staleness. A stale read is expected to have lower latency at the cost of stale data, since it’s less likely the replica will need to wait before serving the request. There is also another type of operations, called partitioned DML. This allows a client to specify an update/delete operation in a declarative form, which is then executed in parallel at each replica group. This parallelism and the associated data locality makes these operations very efficient. However, this comes with some tradeoffs. These operations need to be fully partitionable, which means they must be expressible as the union of a set of statements, where each statement accesses a single row of the table and each statement accesses no other tables. This ensures each replica group will be able to execute the operation locally without any coordination with other replica groups. Furthermore, these operations need to be idempotent, because Spanner might execute a statement multiple times against some groups due to network-level retries. Spanner does not provide atomicity guarantees for each statement across the entire table, but it provides atomicity guarantees per each group. This means that a statement might only run against some rows of the table, e.g. if the user cancels the operation midway or the execution fails in some splits due to constraint violations.
FaunaDB FaunaDB20 is a distributed datastore that drew inspiration from the Calvin protocol[70] for its core architecture. Calvin is based on the following central idea: by replicating inputs instead of effects to the various nodes of the system, it’s possible to have a system that is more deterministic where all the non-failing nodes go through the same states. This determinism can obviate the need for agreement protocols, such as two-phase commit, when performing distributed transactions, since the nodes involved in the transaction can rely on each other proceeding in exactly the same way. Abstractly, the architecture is composed of three layers: 20
See: https://fauna.com
CHAPTER 9. CASE STUDIES
220
• the sequencing layer: this is responsible for receiving inputs/commands and placing them in a global order, which is achieved via a consensus protocol. This is the sequence the operations will be executed by all the nodes. • the scheduling layer: this is responsible for orchestrating the execution of transactions using a deterministic locking scheme to guarantee equivalence to the serial order specified by the sequencing layer, while also allowing transactions to be executed concurrently. • the storage layer: this is responsible for the physical data layout. In practice, every node in FaunaDB performs three roles simultaneously: • query coordinator: this is responsible for receiving and processing a request. As explained later, the request might be processed locally or routed to other nodes, depending on its type. • data replica: this is responsible for storing data and serving them during read operations • log replica: this is responsible for reaching consensus on the order of inputs and adding them to the globally ordered log. A cluster is made up of three or more logical datacenters and data is partitioned inside a datacenter and replicated across datacenters for increased performance and availability. Multiple versions of each data item are preserved, similar to Spanner. FaunaDB uses a slightly customised version of Raft for consensus, which aggregates requests and replicates them in batches21 to improve throughput. When a request arrives to a query coordinator, it speculatively executes the transaction at the latest known log timestamp to discover the data accessed by the transaction, also referred to as read and write intents. The processing thereafter differs depending on the type of request: • If the request is a read-write transaction, it is forwarded to a log replica that makes sure it’s recorded as part of the next batch, as agreed via consensus with the other replicas. The request is then forwarded to each data replica that contains associated data. An interesting difference with other systems is that in FaunaDB data transfer at this stage is push-based, not pull-based. As an example, if during a transaction replica A needs to perform a write based on data owned by replica B, 21 These batches are called epochs and a typical window of batching is 10 milliseconds, so that the impact on latency is not significant. The ordering of requests is achieved by combining the epoch number and the index of the request in the batch.
CHAPTER 9. CASE STUDIES
Figure 9.18: Conceptual view of FaunaDB’s architecture
221
CHAPTER 9. CASE STUDIES
222
replica B is supposed to send the data to replica A, instead of replica A requesting them22 . As a result, each data replica blocks until it has received all the data needed from other replicas. Then, it resolves the transaction, applies any local writes and acknowledge the success to the query coordinator. It’s important to note that data might have changed since the speculative execution of the query coordinator. If that’s the case, the transaction will be aborted and can potentially be retried, but this will be a unanimous decision since all the nodes will execute the operations in the same order. As a consequence, there is no need for an agreement protocol, such as two-phase commit. • If the request is a read-only transaction, it is sent to the replica(s) that contain the associated data or served locally, if the query coordinator happens to contain all the data. The transaction is timestamped with the latest known log timestamp and all read operations are performed at this timestamp. The client library also maintains the timestamp of the highest log position seen so far, which is used to guarantee a monitonically advancing view of the transaction order. This guarantees causal consistency in cases where the client switches from node A to node B, where node B is lagging behind node A in transaction execution from the log. In terms of guarantees, read-write transactions are strictly serializable and read-only transactions are only serializable. However, read-only transactions can opt-in to be strictly serializable by using the so-called linearized endpoint. In that case, the read is combined with a no-op write and it’s executed as a regular read-write transaction going through consensus, thus taking a latency hit. To achieve these guarantees, read-write transactions make use of a pessimistic concurrency control scheme based on read/write locks. This protocol is deterministic, which means it guarantees that all nodes will acquire and release locks in the exact, same order23 . This order is defined by the order of the transactions in the log. Note that this does not prevent transactions from running concurrently, it just requires that locks for transaction ti can be acquired only after locks have been acquired (and potentially released) for all previous transactions tj (j < i). 22 A significant advantage of this is fewer messages that lead to reduced latency. A valid question is what happens if the node that is supposed to send the data fails. In this case, the data replica can fall back to requesting the data from other replicas of this partition. 23 An interesting benefit of this is that deadlocks are prevented. There is literature that examines in more detail the benefits of determinism in database systems[71].
CHAPTER 9. CASE STUDIES
223
This means that all the data accessed by read/write transactions need to be known in advance24 , which means FaunaDB cannot support interactive transactions. Interactive transactions are ones that a client can keep open and execute operations dynamically while potentially performing other operations not related to the database. In contrast to that, transactions in FaunaDB are declared at once and sent for processing. Interactive transactions could still be simulated via a combination of snapshot reads and compare-and-swap operations. Figure 9.18 contains a conceptual view of the architecture described so far. Each role is visualised separately in the figure to facilitate understanding of how the various functions interoperate. However, a single node can perform all these roles, as explained previously.
Distributed messaging system (Kafka) Apache Kafka is an open-source messaging system initially developed by Linkedin[72][73] and then donated to the Apache Software Foundation25 . The primary goal of Kafka was: • performance: the ability to exchange messages between systems with high throughput and low latency. • scalability: the ability to incrementally scale to bigger volumes of data by adding more nodes to the system. • durability & availability: the ability to provide durability and availability of data even in the presence of node failures. The central concept of Kafka is the topic. A topic is an ordered collection of messages. For each topic, there can be multiple producers that write messages to it. There can also be multiple consumers that read messages from it26 . To achieve performance and scalability, each topic is maintained as a partitioned log, which is stored across multiple nodes called brokers. Each partition is an ordered, immutable sequence of messages, where each 24
As described previously, this is not strictly required, since the query coordinator performs an initial reconaissance query and includes the results in the submitted transactions. So, all the replicas can perform again the reads during the execution of the transaction and identify whether read/write sets have changed, where the transaction can be aborted and retried. This technique is called Optimistic Lock Location Prediction (OLLP). 25 See: https://kafka.apache.org/ 26 This means Kafka can support both the point-to-point and the publish-subscribe model depending on the number of consumers used.
CHAPTER 9. CASE STUDIES
224
message is assigned a sequential id number called the offset, which uniquely identifies each message within the partition. Messages by producers are always appended to the end of the log. Consumers can consume records in any order they like providing an offset, but normally a consumer will advance its offset linearly as it reads records. This provides some useful flexibility, which allows consumers to do things like replaying data starting from an older offset or skipping messages and start consuming from the latest offset. The messages are stored durably by Kafka and retained for a configurable amount of period, called retention period, regardless of whether they have been consumed by some client.
Figure 9.19: Structure of a Kafka topic As explained before, every log is partitioned across multiple servers in a Kafka cluster. Messages written by producers are distributed across these partitions. This can be done in a round-robin fashion simply to balance
CHAPTER 9. CASE STUDIES
225
load or the partition can be selected by the producer according to some semantic partitioning function (e.g. based on some attribute in the message and a partitioning function), so that related messages are stored in the same partition. Each consumer of a topic can have multiple consumer instances for increased performance, which are all identified by a consumer group name. Consumption is implemented in such a way that partitions in a log are divided over the consumer instances, so that each instance is the exclusive consumer of a "fair share" of partitions. As a result, each message published to a topic is delivered to one consumer instance within each subscribing consumer group. Each partition is also replicated across a configurable number of nodes for fault tolerance. Each partition has one node which acts as the leader and zero or more servers that act as followers. The leader handles all read and write requests for the partition, while the followers passively replicate the leader. If the leader fails, one of the followers will automatically detect that and become the new leader.
Figure 9.20: Kafka architecture Kafka makes use of Zookeper for various functions, such as leader election between the replica brokers and group membership of brokers and consumers.
CHAPTER 9. CASE STUDIES
226
Interestingly, log replication is separated from the key elements of the consensus protocol, such as leader election and membership changes. The latter are implemented via Zookeper, while the former is using a single-master replication approach, where the leader waits for followers to persist each message before acknowledging it to the client. For this purpose, Kafka has the concept of in-sync replicas (ISR), which are replicas that have replicated committed records and are thus considered to be in-sync with the leader. In case of a leader failure, only a replica that is in the ISR set is allowed to be elected as a leader. This guarantees zero data loss, since any replica in the ISR set is guaranteed to have stored locally all the records acknowledged by the previous leader. If a follower in the ISR set is very slow and lags behind, the leader can evict that replica from the ISR set in order to make progress. In this case, it’s important to note that the ISR update is completed before proceeding, e.g. acknowledging records that have been persisted by the new, smaller ISR set. Otherwise, there would be a risk of data loss, if the leader failed after acknowledging these records but before updating the ISR set, so that the slow follower could be elected as the new leader even though it would be missing some acknowledged records. The leader maintains 2 offsets, the log end offset (LEO) and the high watermark (HW). The former indicates the last record stored locally, but not replicated or acknowledged yet. The latter indicates the last record that has been successfully replicated and can be acknowledged back to the client. Kafka provides a lot of levers to adjust the way it operates depending on the application’s needs. These levers should be tuned carefully depending on requirements around availability, durability and performance. For example, the user can control the replication factor of a topic, the minimum size of the ISR set (min.insync.replicas) and the number of replicas from the ISR set that need to acknowledge a record before it’s committed (acks). Let’s see some of the trade-offs one can make using these values: • Setting min.insync.replicas to a majority quorum (e.g. (replication factor / 2) + 1) and acks to all would allow one to enforce stricter durability guarantees, while also achieving good availability. Let’s assume replication factor = 5, so there are 5 replicas per partition and min.insync.replicas = 3. This would mean up to 2 node failures can be tolerated with zero data loss and the cluster still being available for writes and reads. • Setting min.insync.replicas equal to replication factor and acks to all would provide even stronger durability guarantees at the expense of lower availability. In our previous example of replication
CHAPTER 9. CASE STUDIES
227
factor = 5, this would mean that up to 4 node failures can now be tolerated with zero data loss. However, a single node failure makes the cluster unavailable for writes. • Setting acks to 1 can provide better performance at the expense of weaker durability and consistency guarantees. For example, records will be considered committed and acknowledged as soon as the leader has stored them locally without having to wait for any of the followers to catch up. However, in case of a leader failure and election of a new leader, records that had been acknowledged by the previous leader but had not made it to the new leader yet will be lost. Kafka can provide at-least-once, at-most-once and exactly-once messaging guarantees through various different configurations. Let’s see each one of them separately: • at-most-once semantics: this can be achieved on the producer side by disabling any retries. If the write fails (e.g. due to a TimeoutException), the producer will not retry the request, so the message might or might not be delivered depending on whether it had reached the broker. However, this guarantees that the message cannot be delivered more than once. In a similar vein, consumers commit message offsets before they process them. In that case, each message is processed once in the happy path. However, if the consumer fails after committing the offset but before processing the message, then the message will never be processed. • at-least-once semantics: this can be achieved by enabling retries for producers. Since failed requests will now be retried, a message might be delivered more than once to the broker leading to duplicates, but it’s guaranteed it will be delivered at least once27 . The consumer can process the message first and then commit the offset. This would mean that the message could be processed multiple times, if the consumer fails after processing it but before committing the offset. • exactly-once semantics: this can be achieved using the idempotent producer provided by Kafka. This producer is assigned a unique identifier (PID) and tags every message with a sequence number. In this way, the broker can keep track of the largest number per PID and reject duplicates. The consumers can store the committed offsets in Kafka or in an external datastore. If the offsets are stored in the same 27 Note that this is assuming infinite retries. In practice, a maximum threshold of retries is usually performed, in which case a message might not be delivered if this limit is exhausted.
CHAPTER 9. CASE STUDIES
228
datastore where the side-effects of the message processing are stored, then the offsets can be committed atomically with the side-effects, thus providing exactly-once guarantees. Kafka also provides a transactional client that allows producers to produce messages to multiple partitions of a topic atomically. It also makes it possible to commit consumer offsets from a source topic in Kafka and produce messages to a destination topic in Kafka atomically. This makes it possible to provide exactly-once guarantees for an end-to-end pipeline. This is achieved through the use of a two-phase commit protocol, where the brokers of the cluster play the role of the transaction coordinator in a highly available manner using the same underlying mechanisms for partitioning, leader election and fault-tolerant replication. The coordinator stores the status of a transaction in a separate log. The messages contained in a transaction are stored in their own partitions as usual. When a transaction is committed, the coordinator is responsible for writing a commit marker to the partitions containing messages of the transactions and the partitions storing the consumer offsets. Consumers can also specify the isolation level they want to read under, read_committed or read_uncommitted. In the former case, messages that are part of a transaction will be readable from a partition only after a commit marker has been produced for the associated transaction. This interaction is summarised in Figure 9.21.
Figure 9.21: High-level overview of Kafka transactions The physical storage layout of Kafka is pretty simple: every log partition is implemented as a set of segment files of approximately the same size
CHAPTER 9. CASE STUDIES
229
(e.g. 1 GB). Every time a producer publishes a message to a partition, the broker simply appends the message to the last segment file. For better performance, segment files are flushed to disk only after a configurable number of messages have been published or a configurable amount of time has elapsed28 . Each broker keeps in memory a sorted list of offsets, including the offset of the first message in every segment file. Kafka employs some more performance optimisations, such as using the sendfile API29 for sending data to consumers thus minimizing copying of data and system calls. Some of the guarantees provided by Kafka are the following: • Messages sent by a producer to a particular topic’s partition will be appended in the order they are sent. That is, if a message M1 is sent by the same producer as a message M2 , and M1 is sent first, then M1 will have a lower offset than M2 and appear earlier in the log30 . • As explained before, Kafka can provide at-least-once, at-most-once and exactly-once messaging semantics, depending on the configuration and the type of producers and consumers used. • The durability, availability and consistency guarantees provided by Kafka depend on the specific configuration of the cluster, as shown in the examples above. For example, a topic with replication factor of N, min.insync.replicas of N/2 + 1 and acks=all guarantees zero data loss and availability of the cluster for up to N/2 failures.
Distributed cluster management (Kubernetes) Kubernetes31 is a system that was originally designed by Google, inspired by a similar system called Borg[74][75], and now maintained by the Cloud Native Computing Foundation. It can be used to manage a cluster of nodes and other resources (e.g. disks) handling all the aspects of running software in the cluster, such as deployment, scaling and discovery. This behaviour is configurable through the values log.flush.interval.messages and log.flush.interval.ms. It is important to note that this behaviour has implications in the aforementioned durability guarantees, since some of the acknowledged records might be temporarily stored only in the memory of all in-sync replicas for some time until they are flushed to disk. 29 See https://developer.ibm.com/articles/j-zerocopy 30 Note that ordering guarantees are provided only per partition. Users of Kafka can control partitioning, as described before, to leverage the ordering guarantees. 31 See: https://kubernetes.io/ 28
CHAPTER 9. CASE STUDIES
Figure 9.22: Storage layout of a Kafka topic
230
CHAPTER 9. CASE STUDIES
231
A Kubernetes cluster contains a set of nodes that can have 2 distinct roles, they can either be a master node or a worker node. A worker node is responsible for running the user applications. A master node is responsible for managing and coordinating the worker nodes. Essentially, worker nodes make a set of resources available to the cluster and master nodes decide how these resources are allocated to the applications that need to be executed as specified by the user32 . For availability and durability, multiple master nodes can be run in parallel with one of them operating as the active leader and the rest acting as passive followers. Kubernetes uses etcd for various purposes, such as storing all the cluster data, performing leader election and transmitting change notifications between different parts of the cluster. Each node has several different components for the various functionalities that run independently, i.e. as different processes. The various objects of the cluster (e.g. nodes, services, jobs etc.) are called resources and they are represented in etcd as key-value entries under the right namespace. One of the most central resources in Kubernetes is the pod, which represents the smallest deployable unit of computing. In practice, it is a group of one or more containers33 with shared storage/network and a specification for how to run the containers. A persistent volume is a piece of storage in the cluster that has a lifecycle independent of any individual pod that uses it. A job creates one or more pods and ensures that a specified number of them successfully terminate. A service is an abstraction which defines a logical set of Pods and a policy by which to access them. Every resource is characterised by some desired state (e.g. number of replicas for a service) and the various components of Kubernetes cooperate to ensure the cluster’s current state matches the desired state34 . The main components of the master node are the API Server (kube-apiserver), the Scheduler (kube-scheduler) and the Controller Manager (kube-controller-manager). The API Server is essentially the front-end of the Kubernetes cluster allowing users to inspect the resources of the cluster and modify them or create new ones. The Scheduler is responsible 32
These applications can be divided in two main categories: long-running services that are supposed to be running constantly and typically respond to incoming requests and jobs that are supposed to run for a bounded amount of time typically doing some data processing. 33 A container is a lightweight and portable executable image that contains software and all of its dependencies. Kubernetes supports multiple container runtimes with Docker being the most popular. 34 The desired state is provided by the user when creating a resource (Spec), while the current state is supplied and updated by Kubernetes (Status).
CHAPTER 9. CASE STUDIES
232
for detecting newly created pods that have no node assigned and select a node for them to run. This selection is done based on multiple criteria, such as user-specified constraints, affinity specifications, data locality etc. The Controller Manager is responsible for running all the available controllers in the master node. A controller is essentially a control loop that watches the state of the cluster through the API server making changes in order to move the current state towards the desired state. Below are some examples of controllers: • Node Controller: responsible for noticing and responding to node failures • Replication Controller: responsible for maintaining the correct number pods according to the replication specified by the user. • Endpoints Controller: responsible for creating endpoints for services The main components of the worker nodes are the kubelet and the proxy (kube-proxy). The kubelet is an agent that runs on each node in the cluster, receives a set of pod specifications and makes sure the containers described in these specifications are running and are healthy. The proxy is a network proxy that maintains network rules that allow network communication to the pods from sessions inside and outside the cluster. The worker nodes also contain software of the container runtime that is used. As a result, Kubernetes operates under eventual consistency, recovering from potential failures and converging back to the desired state. Since there are multiple components reading and updating the current state of the cluster, there is a need for some concurrency control to prevent anomalies arising from reduced isolation. Kubernetes achieves this with the use of conditional updates. Every resource object has a resourceVersion field representing the version of the resource as stored in etcd. This version can be used to perform a compare-and-swap (CAS) operation, so that anomalies like lost updates are prevented.
Distributed ledger (Corda) Corda is a platform that allows multiple parties that do not fully trust each other to maintain a distributed ledger with shared facts amongst each other. By its nature, this means it is a distributed system similar to the systems analysed previously. However, a distinctive characteristic of this system is this lack of trust between the nodes that are part of the system, which also
CHAPTER 9. CASE STUDIES
Figure 9.23: Kubernetes architecture
233
CHAPTER 9. CASE STUDIES
234
gives it a decentralisation aspect. This distrust is managed through various cryptographic primitives, as explained later. This section will give a rather brief overview of Corda’s architecture, but you can refer to the available whitepapers for a more detailed analysis[76][77]. Each node in Corda is a JVM-runtime environment with a unique identity on the network. A Corda network is made up of many such nodes that want to transact with each other in order to maintain and evolve a set of shared facts. This network is permissioned, which means nodes need to acquire an X.509 certificate from the network operator in order to be part of the network. The component that issues these certificates is referred to as the doorman. In this context, the doorman operates as a certificate authority for the nodes that are part of the network. Each node maintains a public and a private key, where the private key is used to attest to facts by signing the associated data and the public key is used by other nodes to verify these signatures. This X.509 certificate creates an association between the public key of the node and a human-readable X.500 name (e.g. O=MegaCorp,L=London,C=GB). The network also contains a network map service, which provides some form of service discovery to the nodes that are part of the network. The nodes can query this service to discover other nodes that are part of the network in order to transact with them. Interestingly, the nodes do not fully trust the network operator for the distribution of this information, so each entry of this map that contains the identifying data of a node (i.e. IP address, port, X.500 name, public key, X.509 certificate etc.) is also signed by the corresponding node. In order to avoid censorship by the network operator, the nodes can even exchange the files that contain this information with each other out-of-band and install them locally. Let’s have a look at the data model of Corda now. The shared facts between Corda nodes are represented by states, which are immutable objects which can contain arbitrary data depending on the use case. Since states are immutable, they cannot be modified directly to reflect a change in the state of the world. Instead, the current state is marked as historic and is replaced by a new state, which creates a chain of states that gives us a full view of the evolution of a shared fact over time. This evolution is done by a Corda transaction, which specifies the states that are marked as historic (also known as the input states of the transaction) and the new states that supersede them (also known as the output states of the transaction). Of course, there are specific rules that specify what kind of states each state can be replaced by. These rules are specified by smart contracts and each state also contains a reference to the contract that governs its evolution. The
CHAPTER 9. CASE STUDIES
235
smart contract is essentially a pure function that takes a transaction as an input and determines whether this transaction is considered valid based on the contract’s rules. Transactions can also contain commands, which indicate the transaction’s intent in terms of how the data of the states are used. Each command is also associated with a list of public keys that need to sign the transaction in order to be valid. Figure 9.24 contains a very simple example of this data model for electronic money. In this case, each state represents an amount of money issued by a specific bank and owned by a specific party at some point in time. We can see that Alice combines two cash states in order to perform a payment and transfer 10 GBP to Bob. After that, Bob decides to redeem this money in order to get some cash from the bank. As shown in the diagram, there are two different commands for each case. We can also guess some of the rules of the associated contract for this cash state. For a Spend command, the contract will verify that the sum of all input states equals the sum of all output states, so that no money is lost or created out of thin air. Most likely, it will also check that the Spend command contains all the owners of the input states as signers, which need to attest to this transfer of money.
Figure 9.24: Corda data model The astute reader will notice that in this case nothing would prevent someone from spending a specific cash state to two different parties who would not be able to detect that. This is known as double spend and it’s prevented
CHAPTER 9. CASE STUDIES
236
in Corda via the concept of notaries. A notary is a Corda service that is responsible for attesting that a specific state has not been spent more than once. In practice, every state is associated with a specific notary and every transaction that wants to spend this state needs to acquire a signature from this notary that proves that the state has not been spent already by another transaction. This process is known as transaction finalisation in Corda. The notarisation services are not necessarily provided by a single node, it can also be a notary cluster of multiple nodes in order to provide better fault tolerance and availability. In that case, these nodes will form a consensus group. Corda allows the consensus algorithm used by the notary service to be pluggable depending on the requirements in terms of privacy, scalability, performance etc. For instance, a notary cluster might choose to use a crash fault tolerant (CFT) consensus algorithm (e.g. Raft) that provides high performance but also requires high trust between the nodes of the cluster. Alternatively, it might choose to use a byzantine fault tolerant (BFT) algorithm that provides lower performance but also requires less trust between the nodes of the cluster. At this point, it’s important to note that permissioning has different implications on regular Corda nodes and notaries. In the first case, it forms the foundation for authentication of communication between nodes, while in the second case it makes it easier to detect when a notary service deviates from a protocol (e.g. violating finality), identify the associated real-world entity and take the necessary actions. This means that finalised transactions are not reversible in Corda unless someone violates the protocol35 . As mentioned previously, in some cases even some limited amount of protocol violation can be tolerated, i.e. when using a byzantine consensus protocol. The size of the ledger of all Corda applications deployed in a single network can become pretty large. The various nodes of the network communicate on a peer-to-peer fashion only with the nodes they need to transact, but the notary service seems to be something that needs to be used by all the nodes and could potentially end up being a scalability and performance bottleneck. For this purpose, Corda supports both vertical and horizontal partitioning. Each network can contain multiple notary clusters, so that different applications can make use of different clusters (vertical partitioning). Even the same application can choose to distribute its states between multiple notary clusters for better performance and scalability (vertical partitioning). 35
This is in contrast to some other distributed ledger systems where nodes are anonymous and can thus collude in order to revert historic transactions, such as Bitcoin[78].
CHAPTER 9. CASE STUDIES
237
The only requirement is for all input states of a transaction to belong to the same notary. This is so that the operation of checking whether a state is spent and marking it as spent can be done atomically in a simple and efficient way without the use of distributed transaction protocols. Corda provides a special transaction type, called notary-change transaction, which allows one to change the notary associated with a state by essentially spending the state and creating a new one associated with the new notary. However, in some use cases datasets can be partitioned in a way that requires a minimal number of such transactions, because the majority of transactions are expected to access states from the same partition. An example of this is partitioning of states according to geographic regions if we know in advance that most of the transactions will be accessing data from the same region. This architecture also makes it possible to use states from different applications in a very easy way without the use of distributed transaction protocols36 . Corda applications are called CorDapps and contain several components of which the most important ones are the states, their contracts and the flows. The flows define the workflows between nodes used to perform an update to the ledger or simply exchange some messages. Corda provides a framework that allows the application to define the interaction between nodes as a set of blocking calls that send and receive messages and the framework is responsible for transforming this to an asynchronous, event-driven execution. Corda also provides a custom serialization framework that determines how application messages are serialised when sent across the wire and how they are deserialised when received. Messaging between nodes is performed with the use of message queues, using the Apache ActiveMQ Artemis message broker. Specifically, each node maintains an inbound queue for messages received by other nodes and outbound queues for messages sent to other nodes along with a bridge process responsible for forwarding messages from the node’s outbound queues to the corresponding inbound queues of the other nodes. Even though all of these moving parts can crash and restart in the middle of some operation, the platform provides the guarantee that every node will process each message exactly-once. This is achieved by resending messages until they are acknowledged and having nodes keeping track of messages processed already and discarding duplicates. Nodes also need to acknowledge a message, store its identifier and perform any related side-effects in an atomic way, which is achieved by doing all of this in a single database transaction. All the states from the ledger that are relevant to a 36 This is known as atomic swap and a real use case in the financial world is known as delivery-versus-payment (DvP).
CHAPTER 9. CASE STUDIES
238
node are stored in its database, this part of the database is called the vault. A node provides some more APIs that can be used for various purposes, such as starting flows or querying the node’s vault. These can be accessed remotely via a client, which provides a remote procedure call (RPC) interface that’s implemented on top of the existing messaging infrastructure and using the serialization protocol described before. Figure 9.25 contains a high-level overview of the architecture described so far.
Figure 9.25: High-level overview of Corda’s architecture Corda is a very interesting case study from the perspective of backwards compatibility. In a distributed system, the various nodes of the system might be running different versions of the software, since in many cases software has to be deployed incrementally to them and not in a single step. In a decentralised system, there is an additional challenge, because the various nodes of the systems are now controlled by different organisations, so these discrepancies might last longer. Corda provides a lot of different mechanisms to preserve backwards compability in different areas, so let’s explore some of them. First of all, Corda provides API & ABI37 backwards compatibility for all the public APIs available to CorDapps. This means that any CorDapp should be able to run in future versions of the platform without 37
See: https://en.wikipedia.org/wiki/Application_binary_interface
CHAPTER 9. CASE STUDIES
239
any change or re-compilation. Similar to other applications, CorDapps are expected to evolve, which might involve changing the structure of data exchanged between nodes and the structure of data stored in the ledger (e.g. states). The serialization framework provides some support for evolution for the first case. For instance, nullable properties can be added to a class and the framework will take care of the associated conversions. A node running an older version of the CorDapp will just ignore this new property if data is sent from a node running a newer version of the CorDapp. A node running a newer version of the CorDapp will populate the property with null when receiving data from a node running the older version of the CorDapp. Removing nullable properties and adding a non-nullable property is also possible by providing a default value. However, the serialization framework does not allow this form of data loss to happen for data that are persisted in the ledger, such as states and commands. Since states can evolve and the ledger might contain states from many earlier versions of a CorDapp, newer versions of a contract need to contain appropriate logic that is able to process states from earlier versions of the CorDapp. The contract logic for handling states from version vi of the CorDapp can be removed by a subsequent release of the CorDapp only after all unspent states in the ledger are from version vj of the CorDapp, where j > i. In some cases, the platform might introduce a new feature that is not backwards compatible, i.e. cannot be understood by older versions of the platform. This can be problematic for two reasons. First of all, two nodes running different versions of the platform might reach different conclusions with regards to the validity of a transaction. Furthermore, when validating a transaction nodes are supposed to also validate previous transactions that were involved in the chain of provenance of the states that are consumed in the current transaction. This means that a node running an older version of the platform might fail to validate a transaction that was deemed valid in the past, because it is using a feature introduced in a newer version of the platform. Corda solves this problem with the use of the network minimum platform version. Every network comes with a set of parameters that every node participating in the network needs to agree on and to use to correctly interoperate with each other. This set contains a parameter called minimumPlatformVersion, which determines the minimum platform version that the nodes must be running, any node which is below this will not be able to start. Any feature of the platform that is not backwards compatible and requires a minimum version of the platform can check this parameter and be enabled only when the network is over a specific platform version. In
CHAPTER 9. CASE STUDIES
240
this way, the nodes of a network can start using a feature only after they can be certain all other nodes will also be able to use it. This establishes a balance between nodes in a network that are keen on using a new feature and nodes that are risk averse and are not willing to upgrade to a new version of the platform. However, all of this applies only to features that have network-wide implications, e.g. ones that determine how data are stored on the ledger. There can also be features that do not affect the whole network. Examples of this are changes to the way two nodes interact during the execution of a flow or even the way a single node executes some part of a CorDapp locally. Corda provides multiple versioning levers for more fine-grained control. For this purpose, CorDapps provide two version numbers: minimumPlatformVersion and targetPlatformVersion. The former indicates the minimum platform version the node must have for the CorDapp to function properly, which is essentially determined based on the features that are necessary to the CorDapp and the platform version they were introduced in. The latter indicates the highest version the CorDapp has been tested against and helps the platform disable backwards compatibility codepaths that might make the CorDapp less efficient or secure. Note that these versions only have implications on the node running the CorDapp, instead of the whole network. Another example is the fact that flows in a CorDapp can be versioned in order to evolve while maintaining backwards compatibility. In this way, a flow can behave differently depending on the version of the CorDapp that is deployed on the counterparty node. This makes it possible to upgrade a CorDapp incrementally across various nodes, instead of all of them having to do it in lockstep.
Distributed data processing systems This section will examine distributed systems that are used to process large amounts of data that would be impossible or very inefficient to process using only a single machine. They can be classified in two main categories: • batch processing systems: these systems group individual data items into groups, called batches, which are processed one at a time. In many cases, these groups can be quite large (e.g. all items for a day), so the main goal for these system is usually to provide high throughput sometimes at the cost of increased latency.
CHAPTER 9. CASE STUDIES
241
• stream processing systems: these systems receive and process data continuously as a stream of data items. As a result, the main goal for these systems is providing very low latency sometimes at the cost of decreased throughput.
Figure 9.26: Batch and stream processing There is also a form of processing that is essentially a hybrid between these two categories, called micro-batch processing. This approach processes data in batches, but these batches are kept very small in order to achieve a balance between throughput and latency.
MapReduce MapReduce was a framework for batch data processing originally developed internally in Google[79] that was later incorporated in the wider Apache Hadoop framework. The framework draws inspiration from the field of functional programming and is based on the following main idea. Many real-word computations can be expressed with the use of two main primitive functions, map and reduce. The map function processes a set of key/value pairs and produces as output another set of intermediate key/value pairs. The reduce function receives all the values for each key and returns a single value, essentially merging all the values according to some logic. These primitive functions have a very important property, they can easily be parallelised and run across multiple machines for different parts of the dataset. As a result, the application code is responsible for defining these two methods
CHAPTER 9. CASE STUDIES
242
and the framework is responsible for partitioning the data, scheduling the program’s execution across multiple nodes, handling node failures and managing the required inter-machine communication. Let’s see a typical example to understand better how this programming model works in practice. We assume we have a huge collection of documents (e.g. webpages) and we need to count the number of occurrences for each word. To achieve that via MapReduce, we would use the following functions: // key: the document name // value: the document contents map(String key, String value) { for(word: value.split(" ")) { emit(word, 1) } } reduce(String key, Iterator values) { int count = 0; for(value: values) { result += value; } emit(key, result); } In this case, the map function would emit a single record for each word with the value 1, while the reduce function would just count all these entries and return the final sum for each word. This framework is also based on a master-worker architecture, where the master node is responsible for scheduling tasks at worker nodes and managing their execution, as shown in Figure 9.27. Apart from the definition of the map/reduce functions, the user can also specify the number M of map tasks, the number R of reduce tasks, the input/output files and a partitioning function that defines how key/value pairs from the map tasks are partitioned before being processed by the reduce tasks. By default, a hash partitioner is used that selects a reduce task using the formula hash(key) mod R. The execution of a MapReduce proceeds in the following way: • The framework divides the input files into M pieces, called input splits, which are typically between 16 and 64 MB per split. • It then starts an instance of a master node and multiple instances of
CHAPTER 9. CASE STUDIES
• •
•
•
243
worker nodes on an existing cluster of machines. The master selects idle worker nodes and assigns map tasks to them. A worker node that is assigned a map task reads the contents of the associated input split, it parses key/value pairs and passes them to the user-defined map function. The entries emitted by the map function are buffered in memory and periodically written to the local disk, partitioned into R regions using the partitioning function. When a worker node completes a map task, it sends the location of the local file to the master node. The master node assigns reduce tasks to worker nodes providing the location to the associated files. These worker nodes then perform remote procedure calls (RPCs) to read the data from the local disks of the map workers. The data is first sorted38 , so that all occurrences of the same key are grouped together and then passed into the reduce function. When all map and reduce tasks are completed, the master node returns the control to the user program. After successful completion, the output of the mapreduce job is available in the R output files that can either be merged or passed as input to a separate MapReduce job.
The master node communicates with every worker periodically in the background as a form of heartbeat. If no response is received for a specific amount of time, the master node considers the worker node as failed and re-schedules all its tasks for re-execution. More specifically, reduce tasks that had been completed do not need to be rerun, since the output files are stored in an external file system. However, map tasks are rerun regardless of whether they had completed, since their output is stored on the local disk and is therefore inaccessible to the reduce tasks that need it. This means that network partitions between the master node and worker nodes might lead to multiple executions of a single map or reduce task. Duplicate executions of map tasks are deduplicated at the master node, which ignores completion messages for already completed map tasks. Reduce tasks write their output to a temporary file, which is atomically renamed when the reduce task completes. This atomic rename operation provided by the underlying file system guarantees that the output files will contain just the data produced by a single execution of each reduce task. However, if the map/reduce functions defined by the application code have additional sideeffects (e.g. writing to external datastores) the framework does not provide 38
If the size is prohibitively large to fit in memory, external sorting is used.
CHAPTER 9. CASE STUDIES
Figure 9.27: Architecture of MapReduce
244
CHAPTER 9. CASE STUDIES
245
any guarantees and the application writer needs to make sure these sideeffects are atomic and idempotent, since the framework might trigger them more than once as part of a task re-execution. Input and output files are usually stored in a distributed filesystem, such as HDFS or GFS. MapReduce can take advantage of this to perform several optimisations, such as scheduling map tasks on worker nodes that contain a replica of the corresponding input to minimize network traffic or aligning the size of input splits to the block size of the file system. The framework provides the guarantee that within a given partition, the intermediate key/value pairs are processed in increasing key order. This ordering guarantees makes it easy to produce a sorted output file per partition, which is useful for use cases that need to support efficient random access lookups by key or need sorted data in general. Furthermore, some use-cases would benefit from some form of pre-aggregation at the map level to reduce the amount of data transferred between map and reduce tasks. This was evident in the example presented above, where a single map would emit multiple entries for each occurence of a word, instead of a single entry with the number of occurrences. For this reason, the framework allows the application code to also provide a combine function. This method has the same type as the reduce function and is run as part of the map task in order to pre-aggregate the data locally.
Apache Spark Apache Spark [80][81] is a data processing system that was initially developed at the University of California and then donated to the Apache Software Foundation. It was developed in response to some of the limitations of MapReduce. Specifically, the MapReduce model allowed developing and running embarrassingly parallel computations on a big cluster of machines, but every job had to read the input from disk and write the output to disk. As a result, there was a lower bound in the latency of a job execution, which was determined by disk speeds. This means MapReduce was not a good fit for iterative computations, where a single job was executed multiple times or data were passed through multiple jobs, and for interactive data analysis, where a user wants to run multiple ad-hoc queries on the same dataset. Spark tried to address these two use-cases. Spark is based on the concept of Resilient Distributed Datasets (RDD), which
CHAPTER 9. CASE STUDIES
246
is a distributed memory abstraction used to perform in-memory computations on large clusters of machines in a fault-tolerant way. More concretely, an RDD is a read-only, partitioned collection of records. RDDs can be created through operations on data in stable storage or other RDDS. The operations performed on an RDD can be one of the following two types: • transformations, which are lazy operations that define a new RDD. Some examples of transformations are map, filter, join and union. • actions, which trigger a computation to return a value to the program or write data to external storage. Some examples of actions are count, collect, reduce and save. A typical Spark application will create an RDD by reading some data from a distributed filesystem, it will then process the data by calculating new RDDs through transformations and will finally store the results in an output file. For example, an application used to read some log files from HDFS and count the number of lines that contain the word "sale completed" would look like the following: lines = spark.textFile("hdfs://...") completed_sales = lines.filter(_.contains("sale completed")) number_of_sales = completed_sales.count() This program can either be submitted to be run as an invividual application in the background or each one of the commands can be executed interactively in the Spark interpreter. A Spark program is executed from a coordinator process, called the driver. The Spark cluster contains a cluster manager node and a set of worker nodes. The responsibilities between these components are split in the following way: • The cluster manager is responsible for managing the resources of the cluster (i.e. the worker nodes) and allocating resources to clients that need to run applications. • The worker nodes are the nodes of the cluster waiting to receive applications/jobs to execute. • Spark also contains a master process that requests resources in the cluster and makes them available to the driver39 . 39
Note that Spark supports both a standalone clustering mode and some clustering modes using third-party cluster management systems, such as YARN, Mesos and Kubernetes. In the standalone mode, the master process also performs the functions of the cluster manager. In some of the other clustering modes, such as Mesos and YARN, they are separate processes.
CHAPTER 9. CASE STUDIES
247
• The driver is responsible for requesting the required resources from the master and starting a Spark agent process on each node that runs for the entire lifecycle of the application, called executor. The driver then analyses the user’s application code into a directed acyclic graph (DAG) of stages, partitions the associated RDDs and assigns the corresponding tasks to the executors available to compute them. The driver is also responsible for managing the overall execution of the application, e.g. receiving heartbeats from executors and restarting failed tasks.
Figure 9.28: Architecture of Spark Notably, in the previous example the second line is executed without any data being read or processed yet, since filter is a transformation. The data is being read from HDFS, filtered and then counted, when the third line is processed, which contains the count operation which is an action. To achieve that, the driver maintains the relationship between the various RDDs through a lineage graph, triggering calculation of an RDD and all its ancestors only when an action is performed. RDDs provide the following basic operations40 : • partitions(), which returns a list of partition objects. For example, an RDD representing an HDFS file has a partition for each block of the file by default. The user can specify a custom number of partitions for an RDD, if needed. 40
Note that these operations are mainly used by the framework to orchestrate the execution of Spark applications. The applications are not supposed to make use of these operations, they should be using the transformations and actions that were presented previously.
CHAPTER 9. CASE STUDIES
248
• partitioner(), which returns metadata determining whether the RDD is hash/range partitioned. This is relevant to transformations that join multiple key-value RDDs based on their keys, such as join or groupByKey. In these cases, hash partitioning is used by default on the keys, but the application can specify a custom range partitioning to be used, if needed. An example is provided in the paper [81], where execution of the PageRank algorithm on Spark can be optimised by providing a custom partitioner that groups all the URLs of a single domain in the same partition. • preferredLocations(p), which lists nodes where partition p can be accessed faster due to data locality. This might return nodes that contain the blocks of an HDFS file corresponding to that partition or a node that already contains in memory a partition that needs to be processed. • dependencies(), which returns a list of dependencies on parent RDDs. These dependencies can be classified into two major types: narrow and wide dependencies. A narrow dependency is one where a partition of the parent RDD is used by at most one partition of the child RDD, such as map, filter or union. A wide dependency is one where multiple child partitions may depend on a single parent partition, such as a join or groupByKey. Note that a join of two RDDs can lead to two narrow dependencies, if both of the RDDs are partitioned with the same partitioner, as shown in Figure 9.29. • iterator(p, parentIters), which computes the elements of a partition p given iterators for its parent partitions. As explained before, the driver examines the lineage graph of the application code and builds a DAG of stages to execute, where each stage contains as many pipelined transformations with narrow dependencies as possible. The boundaries of each stage correspond to operations with wide dependencies that require data shuffling between partitions or any already computed partitions that have been persisted and can short-circuit the computation of ancestor RDDs. The driver launches tasks to compute missing partitions from each stage until it has computed the target RDD. The tasks are assigned to executors based on data locality. If a task needs to process a partition that is available in memory on a node, it’s submitted to that node. If a task processes a partition for which the containing RDD provides preferred locations (e.g. an HDFS file), it’s submitted to these nodes. For wide dependencies that require data shuffling, nodes holding parent partitions materialize intermediate records locally that are later pulled by nodes from
CHAPTER 9. CASE STUDIES
249
Figure 9.29: Examples of narrow and wide dependencies. Each big rectangle is an RDD with the smaller grey rectangles representing partitions of the RDD
CHAPTER 9. CASE STUDIES
250
the next stage, similar to MapReduce.
Figure 9.30: Example of a DAG of stages computed from a Spark application This graph is the basic building block for efficient fault-tolerance. When an executor fails for some reason, any tasks running on it are re-scheduled on another executor. Along with this, tasks are scheduled for any parent RDDs required to calculate the RDD of this task. As a consequence of this, wide dependencies can be much more inefficient than narrow dependencies when recovering from failures, as shown in Figure 9.31. Long lineage graphs can also make recovery very slow, since many RDDs will need to be recomputed in a potential failure near the end of the graph. For this reason, Spark provides a checkpointing capability, which can be used to store RDDs from specified tasks to stable storage (e.g. a distributed filesystem). In this way, RDDs that have been checkpointed can be read from stable storage during recovery, thus only having to rerun smaller portions of the graph. Users can call a persist() method to indicate which RDDs need to be stored on disk. The persist() method provides different storage levels depending on the
CHAPTER 9. CASE STUDIES
251
Figure 9.31: The impact of wide dependencies on recovery from failures. The red cross indicates failure of a task calculating a specific partition of an RDD. The black rectangles represent the partitions of RDDs that need to be recomputed, if not persisted. needs of the application. As explained before, persistence on disk can be used to make recovery from failures faster. Users can also instruct nodes to store calculated RDDs in memory, so that they can be served from memory every time they are needed and they don’t need to be recalculated. This option is very useful for interactive applications, where a specific RDD is calculated and is then used in multiple different ways in order to explore a dataset without having to calculate the whole lineage each time. Spark also provides graceful degradation in cases where memory is not enough, so that the application does not fail but keeps running with decreased performance. For instance, Spark can either recalculate any partitions on demand when they don’t fit in memory or spill them to disk. Wide dependencies cause more data to be exchanged between nodes compared to narrow dependencies, so performance can be increased significantly by reducing wide dependencies or the amount of data that need to be shuffled. One way to do this is by pre-aggregating data, also known as map-side reduction 41 . As an example, the following code performs a word count in two different ways: the first one will send multiple records of value 1 for each word across the network, while the second one will send one record for each word containing the number of occurrences. // word count without pre-aggregation 41 As explained previously, this is a capability provided in the MapReduce framework too through the use of combiners.
CHAPTER 9. CASE STUDIES
252
sparkContext.textFile("hdfs://...") .flatMap(line => line.split(" ")) .map(word => (word,1)) .groupByKey() .map((x,y) => (x,sum(y))) // word count with pre-aggregation sparkContext.textFile("hdfs://...") .flatMap(line => line.split(" ")) .map(word => (word,1)) .reduceByKey((x,y)=> (x+y)) Spark can also be configured in a way that is resilient to failures of the master process. This is achieved via Zookeeper, where all the masters are performing leader election and one of them is elected as the leader with the rest remaining in standby mode. When a new worker node is added to the cluster, it registers with the master node. If failover occurs, the new master will contact all previously registered worker nodes to inform them of the change in leadership. So, only the scheduling of new applications is affected during a failover, applications that were already running are unaffected. When submitting a job to the Spark cluster, the user can specify through a --supervise option that the driver needs to be automatically restarted by the master if it fails with non-zero exit code. Spark supports multiple, different systems for data persistence. It has a commit protocol that aims to provide exactly-once guarantees on the job’s output under specific conditions. This means that no matter how many times worker nodes fail and tasks are rerun, if a job completes, there will be no duplicate or corrupt data in the final output file. This might not be the case for every supported storage system and it’s achieved differently depending on the available capabilities. For instance, when using HDFS, each task writes the output data to a unique, temporary file (e.g. targetDirectory/_temp/part-XXXX_attempt-YYYY) and when the write is complete, the file is moved to the final location (e.g. targetDirectory/part-XXXX) using an atomic rename operation provided by HDFS. As a result, even if a task is executed multiple times due to failures, the final file will contain its output exactly once. Furthermore, no matter which execution completed successfully, the output data will be the same as long as the transformations that were used were deterministic and idempotent. This is true for any transformations that act solely on the data provided by the previous RDDs using deterministic operations.
CHAPTER 9. CASE STUDIES
253
However, this is not the case if these transformations make use of data from other systems that might change between executions or if they use non-deterministic actions (e.g. mathematical calculations based on random number generation). Furthermore, if transformations perform side-effects on external systems that are not idempotent, no guarantee is provided since Spark might execute each side-effect more than once.
Apache Flink Apache Flink[82][83] is an open-source stream-processing framework developed by the Apache Software Foundation aiming to provide a highthroughput, low latency data processing engine42 . The basic constructs in Flink are streams and transformations. A stream is an unbounded43 flow of data records and a transformation is an operation that takes one or more streams as input and produces one or more output streams as a result. Flink provides many different APIs that can be used to define these transformations. For example, the ProcessFunction API is a low-level interface that allows the user to define imperatively what each transformation should do by providing the code that will process each record. The DataStream API is a high-level interface that allows the user to define declaratively the various transformations by re-using basic operations, such as map, flatMap, filter, union etc. Since streams can be unbounded, the application has to produce some intermediate, periodic results. For this purpose, Flink provides some additional constructs, such as windows, timers and local storage for stateful operators. Flink provides a set of high-level operators that specify the windows over which data from the stream will be aggregated. These windows can be time-driven (e.g. time intervals) or data-driven (e.g. number of elements). The timer API allows applications to register callbacks to be executed at specific points in time in the future. A data processing application in Flink can be represented as a directed acyclic 42
This is a main differentiator between Flink and Spark. Flink processes incoming data as they arrive, thus managing to provide sub-second latency that can go down to single-digit millisecond latency. Note that Spark also provides a streaming engine, called Spark Streaming[84]. However, that is running some form of micro-batch processing, where an input data stream is split into batches, which are then processed to generate the final results in batches with the associated latency trade-off. 43 Note that Flink can also execute batch processing applications, where data is treated as a bounded stream. As a result, the mechanisms described in this section are used in this case too with slight variations.
CHAPTER 9. CASE STUDIES
254
graph (DAG)44 , where nodes represent computing tasks and edges represent data subscriptions between tasks. Flink is responsible for translating the logical graph corresponding to the application code to the actual physical graph that will be executed. This includes logical dataflow optimisations, such as fusion of multiple operations to a single task (e.g. combination of two consecutive filters). It also includes partitioning each task into multiple instances that can be executed in parallel in different compute nodes. Figure 9.32 illustrates this process.
Figure 9.32: A Flink dataflow graph example The high-level architecture of Flink consists of 3 main components, as shown in Figure 9.33. The client receives the program code, transforms it into a dataflow graph and submits it to the Job Manager, which coordinates the distributed execution of the dataflow. The Job Manager is responsible for scheduling execution of tasks on Task Managers, tracking their progress and coordinating checkpoints and recovery from possible failures of tasks. Each Task Manager is responsible for executing one or more tasks that execute user-specified operators that can produce other streams and report their status to the Job Manager along with heartbeats that are used for failure detection. When processing unbounded streams, these tasks are supposed to be long-lived. If they fail, the Job Manager is responsible for restarting them. To avoid making the Job Manager a single point of failure, 44
Note that Flink also has support for cyclic dataflow graphs, which can be used for use-cases such as iterative algorithms. However, this detail is omitted here for the sake of simplicity.
CHAPTER 9. CASE STUDIES
255
multiple instances can be running in parallel. One of them will be elected leader via Zookeeper and will be responsible for coordinating the execution of applications, while the rest will be waiting to take over in case of a leader failure. As a result, the leader Job Manager stores some critical metadata about every application in Zookeeper, so that it’s accessible to newly elected leaders.
Figure 9.33: Apache Flink architecture As explained previously, time is a crucial element that is commonly used to define boundaries on unbounded streams. Similar to other stream processing systems[85][86], Flink supports two main notions of time: processing time and event time 45 . Processing time refers to the system time of the machine that is executing an operation. Event time is the time that each individual event occured on its producing device. Each one of them can be used with some trade-offs: • When a streaming program runs on processing time, all time-based operations (e.g. time windows) will use the system clock of the machines 45
In fact, Flink has a third notion of time, called the ingestion time. This corresponds to the time an event enters Flink. The discussion in this section will focus on event and processing time.
CHAPTER 9. CASE STUDIES
256
that run the respective operation. This is the simplest notion of time and requires no coordination between the nodes of the system. It also provides good performance and reliably low latency on the produced results. However, all this comes at the cost of consistency and nondeterminism. The system clocks of different machines will differ and the various nodes of the system will process data at different rates. As a consequence, different nodes might assign the same event to different windows depending on timing. • When a streaming program runs on event time, all time-based operations will use the event time embedded within the stream records to track progress of time, instead of system clocks. This brings consistency and determinism to the execution of the program, since nodes will now have a common mechanism to track progress of time and assign events to windows. However, it requires some coordination between the various nodes, as we will see below. It also introduces some additional latency, since nodes might have to wait for out-of-order or late events. The main mechanism to track progress in event time in Flink is watermarks. Watermarks are control records that flow as part of a data stream and carry a timestamp t. A Watermark(t) record indicates that event time has reached time t in that stream, which means there should be no more elements with a timestamp t' ≤ t. Once a watermark reaches an operator, the operator can advance its internal event time clock to the value of the watermark. Watermarks can be generated either directly in the data stream source or by a watermark generator in the beginning of a Flink pipeline. The operators later in the pipeline are supposed to use the watermarks for their processing (e.g. to trigger calculation of windows) and then emit them downstream to the next operators. There are many different strategies for generating watermarks. An example is the BoundedOutOfOrdernessGenerator, which assumes that the latest elements for a certain timestamp t will arrive at most n milliseconds after the earliest elements for timestamp t. Of course, there could be elements that do not satisfy this condition and arrive after the associated watermark has been emitted and the corresponding windows have been calculated. These are called late elements and Flink provides different ways to deal with them, such as discarding them or re-triggering the calculation of the associated window. Figure 9.34 contains an illustration of how watermarks flow in a Flink pipeline and how event time progresses. As mentioned previously, stream processing applications in Flink are supposed to be long-lived. As a result, there must be an efficient way to recover from failures without having to repeat a lot of work. For this purpose, Flink
CHAPTER 9. CASE STUDIES
257
Figure 9.34: Event time & watermarks in Flink periodically checkpoints the operators’ state along with the position of the stream that has been consumed to generate this state. In case of a failure, an application can be restarted from the latest checkpoint and continue processing from there. All this is achieved via an algorithm similar to the Chandy-Lamport algorithm for distributed snapshots, called Asynchronous Barrier Snapshotting (ABS)[87]. This algorithm operates slightly different for acyclic and cyclic graphs, so we will examine the first case here which is a bit simpler. The algorithms works in the following way: • The Job Manager periodically injects some control records in the stream, referred to as stage barriers. These records are supposed to divide the stream into stages, where the set of operator states at the end of a stage reflects the whole execution history up to the associated barrier and thus it can be used for a snapshot. • When a source task receives a barrier, it takes a snapshot of its current state and it then broadcasts the barrier to all its outputs. • When a non-source task receives a barrier from one of its inputs, it blocks that input until it has received a barrier from all the inputs. It then takes a snapshot of its current state and broadcasts the barrier to its outputs. Finally, it unblocks its inputs. This blocking is needed, so that the checkpoint is guaranteed to contain the state after processing all the elements before the barrier and no elements after the barrier. Note that the snapshot taken while the inputs are blocked
CHAPTER 9. CASE STUDIES
258
can be a logical one, where the actual, physical snapshot is happening asynchronously in the background46 . This is done in order to reduce the duration of this blocking phase, so that the application can start processing data again as quickly as possible. • Once the background copy process has completed, each task acknowledges the checkpoint back to the Job Manager. After the Job Manager has received an acknowledgement from all the tasks, the checkpoint is considered complete and can be used for recovery if a failure happens later on. At this point, the Job Manager notifies all the tasks that the checkpoint is complete, so that they can perform any cleanup or bookkeeping logic required. There are 2 subtle points in the checkpoint algorithm and the recovery process. During recovery, tasks will be reset back to the last checkpoint and start processing again from the first element after the checkpoint was taken. This implies that any state that might have been produced by elements after the last checkpoint will be essentially discarded, so that each element is processed exactly-once. However, this raises the following questions: • How is the state produced after the last checkpoint discarded in practice if it’s been persisted in the operator’s state? • What happens with sink tasks that interact with external systems and records that might have been emitted after the last checkpoint in case of a failure? • What happens with sources that do not support replay of past records? The answers to all these questions partially rely on a core characteristic of the checkpoint algorithm. As explained previously, the algorithm has the form of a two-phase commit protocol, where the first phase consists of the Job Manager instructing all the tasks to create a checkpoint and the second phase consists of the Job Manager informing them that all the tasks succcesfully managed to create a checkpoint. The state of an operator can be stored in different ways, such as in the operator’s memory, in an embedded key-value store or in an external datastore. If that datastore supports MVCC, then all the updates to the state can simply be stored under a version that corresponds to the next checkpoint. During recovery, updates that had been performed after the last checkpoint are automatically ignored, since reads will return the version corresponding to the last checkpoint. If the datastore does not support MVCC, all the state changes can be maintained temporarily in local 46 One way to achieve this is through copy-on-write techniques. See: https://en.wikipedia. org/wiki/Copy-on-write
CHAPTER 9. CASE STUDIES
259
storage in the form of a write-ahead-log (WAL), which will be committed to the datastore during the second phase of the checkpoint protocol. Flink can also integrate with various other systems that can be used to retrieve input data from (sources) or send output data to (sinks), such as Kafka, RabbitMQ etc. Each one of them provides different capabilities. For example, Kafka provides an offset-based interface, which makes it very easy to replay data records in case of recovery from a failure. A sink task just has to keep track of the offset of each checkpoint and start reading from that offset during recovery. However, message queues do not provide this interface, so Flink has to use alternative methods to provide the same guarantees. For instance, in the case of RabbitMQ, messages are acknowledged and removed from the queue only after the associated checkpoint is complete, again during the second phase of the protocol. Similarly, a sink needs to coordinate with the checkpoint protocol in order to be able to provide exactly-once guarantees. Kafka is a system that can support this through the use of its transactional client. When a checkpoint is created by the sink, the flush() operation is called as part of the checkpoint. After the notification that the checkpoint has been completed in all operators is received from the Job Manager, the sink calls Kafka’s commitTransaction method. Flink provides an abstract class called TwoPhaseCommitSinkFunction that provides the basic methods that need to be implemented by a sink that wants to provide these guarantees (i.e. beginTransaction, preCommit, commit, abort). To sum up, some of the guarantees provided by Flink are the following: • Depending on the types of sources and sinks used, Flink provides exactly-once processing semantics even across failures. • The user can also optionally downgrade to at-least-once processing semantics, which can provide increased performance. • It’s important to note that the exactly-once guarantees apply to stream records and local state produced using the Flink APIs. If an operator performs additional side-effects on systems external to Flink, then no guarantees are provided for them. • Flink does not provide ordering guarantees after any form of repartitioning or broadcasting and the responsibility of dealing with out-of-order records is left to the operator implementation.
Chapter 10
Practices & Patterns This chapter covers common practices and patterns used when building and operating distributed systems. These are not supposed to be exact prescriptions, but they can help you identify some of the basic approaches available to you and the associated trade-offs. It goes without saying that there are so many practices and patterns that we would never be able to cover them all. As a result, the goal is to cover some of the most fundamental and valuable ones.
Communication patterns One of the main differentiating characteristics of distributed systems is the fact that the various nodes need to exchange data across the network boundary. In this section, we will examine how that can be achieved and what are the trade-offs of each approach. First of all, every node needs a way to transform data that reside in memory into a format that can be transmitted over the network and it also needs a way to translate data received from the network back to the appropriate in-memory representation. These processes are called serialisation and deserialisation respectively1 . The various nodes of the system need to agree on a common way to serialise and deserialise data. Otherwise, they will not be able to translate data they 1
Note that serialisation can also be used to transform data in a format that’s suitable for storage, not only communication.
260
CHAPTER 10. PRACTICES & PATTERNS
261
Figure 10.1: Serialisation and deserialisation send to each other. There are various options available for this purpose, so let’s have a look at some of them: • Some languages provide native support for serialisation, such as Java and Python via its pickle module. The main benefit of this option is convenience, since there is very little extra code needed to serialise and deserialise an object. However, this comes at the cost of maintainability, security and interoperability. Given the transparent nature of how these serialisation methods work, it becomes hard to keep the format stable, since it can be affected even by small changes to an object that do not affect the data contained in it (e.g. implementing a new interface). Furthermore, some of these mechanisms are not very secure, since they indirectly allow a remote sender of data to initialise any objects they want, thus introducing the risk of remote code execution. Last but not least, most of these methods are available only in specific languages, which means systems developed in different programming languages will not be able to communicate. Note that there are some third-party libraries that operate in a similar way using reflection or bytecode manipulation, such as Kryo2 . These libraries tend to be subject to the same trade-offs. • Another option is a set of libraries that serialise an in-memory object 2
See: https://github.com/EsotericSoftware/kryo
CHAPTER 10. PRACTICES & PATTERNS
262
based on instructions provided by the application. These instructions can be either imperative or declarative, i.e. annotating the fields to be serialised instead of explicitly calling operations for every field. An example of such a library is Jackson3 , which supports a lot of different formats, such as JSON and XML. A main benefit of this approach is the ability to interoperate between different languages. Most of these libraries also have rather simple rules on what gets serialised, so it’s easier to preserve backwards compatibility when evolving some data, i.e. introducing new optional fields. They also tend to be a bit more secure, since they reduce the surface of exploitation by reducing the number of types that can be instantiated during deserialisation, i.e. only the ones that have been annotated for serialisation and thus implicitly whitelisted. However, sometimes they can create additional development effort, since the same serialisation mapping needs to be defined on every application. • The last option we will explore is interface definition languages (IDL). These are specification languages used to define the schema of a data type in a language-independent way. Typically, these definitions can then be used to dynamically generate code in different languages that will be able to perform serialisation and deserialisation, when included in the application. This can allow applications built in different programming languages to interoperate, depending on the languages supported by the IDL. Each IDL allows for different forms of evolution of the underlying data types. They also reduce duplicate development effort, but they require adjusting build processes so that they are able to integrate with the code generation mechanisms. Some examples of IDLs are Protocol Buffers4 , Avro5 and Thrift6 . Figure 10.2 provides an illustration of the difference between the second and the third approach, using Jackson and Protocol Buffers as examples. So far, we have seen what are the options for creating and parsing the data sent through the network. A question that remains unanswered is what are the main ways through which this data is sent and received and the associated trade-offs. This does not refer to the underlying transfer protocols that are used, such as Ethernet, IP, TCP, UDP etc. Instead, it refers to the two basic, high-level forms of communication, synchronous and asynchronous 3
See: See: 5 See: 6 See: 4
https://github.com/FasterXML/jackson https://github.com/protocolbuffers/protobuf https://avro.apache.org/ https://thrift.apache.org/
CHAPTER 10. PRACTICES & PATTERNS
263
Figure 10.2: Jackson and Protocol Buffers communication. If node A is communicating synchronously with node B, this means that node A will be waiting until it has received a response from node B before proceeding with subsequent tasks. If node A is communicating asynchronously instead, this means that it does not have to wait until that request is complete; it can proceed and perform other tasks at the same time. A simple illustration of this difference can be seen in Figure 10.3. Synchronous communication is typically used in cases, where node A needs to retrieve some data in order to execute the next task or it needs to be sure a side-effect has been performed successfully on node B’s system before proceeding. Asynchronous communication is preferred in cases, where the operation performed in node B can be executed independently without blocking other operations from making progress. For instance, an e-commerce system might need to know that a payment has been performed successfully before dispatching an item, thus using synchronous communication. However, after the order has been dispatched, the process that sends the appropriate notification e-mail to the customer can be triggered asynchronously. Synchronous communication is usually implemented on top of the existing protocols, such as using HTTP in order to transmit data in JSON or some other serialisation format (e.g. protocol buffers). In the case of asynchronous communication between two nodes, there is usually a need to persistently
CHAPTER 10. PRACTICES & PATTERNS
264
Figure 10.3: Synchronous and asynchronous communication store incoming requests until they are processed. This is needed so that requests are not lost even if the recipient node fails for some reason7 . These requests can be stored in an intermediate datastore in order to prevent loss in the face of failures. The datastores that are typically used for this purpose belong in two main categories: message queues and event logs. Some commonly used messages queues are ActiveMQ8 , RabbitMQ9 and Amazon SQS10 . A message queue usually operates with first-in, first-out (FIFO) semantics. This means that messages, in general, are added to the tail of the queue and removed from the head. Note that multiple producers can be sending messages to the queue and multiple consumers can be receiving messages and processing them concurrently. Depending on how a message 7 Note that this is not necessarily needed in the case of synchronous communication, because if the recipient crashes before processing a request, the sender will notice at some point and it can retry the request. But, even when using asynchronous communication, there can be cases where losing requests is not a big problem. A typical example of this is the autocomplete functionality of a search engine; this can be done asynchronously in the background and losing some requests will just lead to fewer suggestions to the user, so it might be acceptable. 8 See: http://activemq.apache.org 9 See: https://www.rabbitmq.com 10 See: https://aws.amazon.com/sqs
CHAPTER 10. PRACTICES & PATTERNS
265
queue is configured, messages can be deleted as soon as they are delivered to a consumer or only after a consumer has explicitly acknowledged it has succcessfully processed a message. The former essentially provides at-mostonce guarantees, since a consumer might fail after receiving a message, but before acknowledging it. The latter can provide at-least-once semantics, since at least a single consumer must have processed a message before it being removed from the queue. However, most message queues contain a timeout logic on unacknowledged messages to cope with failed consumers ensuring liveness 11 . This means that unacknowledged messages are being put back to the queue and redelivered to a new consumer. As a consequence of this, there are cases where a message might be delivered more than once to multiple consumers. The application is responsible for converting the at-least-once delivery semantics to exactly-once processing semantics. As we have seen already, a typical way to achieve this is by associating every operation with a unique identifier that is used to deduplicate operations originating from the same message. Figure 10.4 shows an example of how this would work in practice12 .
Figure 10.4: Example of exactly-once processing through deduplication An event log provides a slightly different abstraction than a message queue. Messages are still inserted by producers at the tail of the log and stored in an ordered fashion. However, the consumers are free to select the point of the log they want to consume messages from, which is not necessarily the head. Messages are typically associated with an index, which can be used by 11
For example, Amazon SQS achieves that using a per-message visibility timeout, while ActiveMQ and RabbitMQ rely on a connection to timeout in order to redeliver all the unacknowledged messages of this connection. 12 An important thing to note here is that the side-effect from the operation and the storage of the unique identifier must be done atomically to guarantee exactly-once processing. A simple way to do this is store both in the same datastore using a transaction.
CHAPTER 10. PRACTICES & PATTERNS
266
consumers to declare where they want to consume from. Another difference with message queues is that the log is typically immutable, so messages are not removed after they are processed. Instead, a garbage collection is run periodically that removes old messages from the head of the log. This means that consumers are responsible for keeping track of an offset indicating the part of the log they have already consumed in order to avoid processing the same message twice, thus achieving exactly-once processing semantics. This is done in a similar way as described previously with this offset playing the role of the unique identifier for each message. Some examples of event logs are Apache Kafka13 , Amazon Kinesis14 and Azure Event Hubs15 . Message queues and event logs can enable two slightly different forms of communication, known as point-to-point and publish-subscribe. The point-to-point model is used when we need to connect only 2 applications16 . The publish-subscribe model is used when more than one applications might be interested in the messages sent by a single application. For example, the fact that a customer made an order might be needed by an application sending recommendations for similar products, an application that sends the associated invoices and an application that calculates loyalty points for the customer. Using a publish-subscribe model, the application handling the order is capable of sending a message about this order to all the applications that need to know about it, sometimes without even knowing which these applications are. These two models of communication are implemented slightly different depending on whether an event log or a message queue is used. This difference is mostly due to the fact that consumption of messages behaves differently in each system: • Point-to-point communication is pretty straightforward when using a message queue. Both applications are connected to a single queue, where messages are produced and consumed. In the publish-subscribe model, one queue can be created and managed by the producer application and every consumer application can create its own queue. There also needs to be a background process that receives messages from the 13
See: https://kafka.apache.org See: https://aws.amazon.com/kinesis 15 See: https://azure.microsoft.com/services/event-hubs 16 Note that point-to-point refers to the number of applications, not the actual servers. Every application on each side might consist of multiple servers that produce and consume messages concurrently. 14
CHAPTER 10. PRACTICES & PATTERNS
267
producer’s queue and forwards them to the consumers’ queues17 . • When using an event log, both models of communication can be implemented in the same way. The producer application writes messages to the log and multiple consumer applications can be consuming from the log concurrently the same messages maintaining independent offsets. Figure 10.5 illustrates this difference.
Figure 10.5: Implementations of point-to-point and publish-subscribe models
Coordination patterns In many cases, a business function is performed by many different systems that cooperate with each other, so that each one of them performs some part of the overall function. For example, displaying a product page might require combining functionality from different systems, such as an advertising system, a recommendation system, a pricing system etc. The previous section examined the basic ways in which two different systems can communicate. This section will examine the two basic approaches that can be used to coordinate different systems in order to perform a common goal: orchestration and choreography. In orchestration, there is a single, central system that is responsible for coordinating the execution of the various other systems, thus resembling a star topology. That central system is usually referred to as the orchestrator and it needs to have knowledge about all the other systems and their capabilities; 17
Some systems might provide facilities that provide this functionality out of the box. For example, this is achieved in ActiveMQ via a bridge and in Amazon SQS via a separate AWS service, called Amazon Simple Notification Service (SNS).
CHAPTER 10. PRACTICES & PATTERNS
268
these systems can be unaware of each other. In choreography, these systems coordinate with each other without the need of an external coordinator. They are essentially placed in a chain, where each system is aware of the previous and the next system in the topology. A request is successively passed through the chain from each system to the next one. Figure 10.6 contains a basic diagram of these two patterns. Note that the communication link between two systems can be of any of the two forms described in the previous section. The systems can either communicate synchronously (i.e. using RPC calls) or asynchronously (i.e. via an intermediary message queue). Each option is subject to different trade-offs, some of which we will discuss in the next sections. These patterns will also behave slightly differently depending on whether the function performed has side-effects or not. For example, displaying a product page is most likely a function that does not have side-effects. This means that partial failures can be treated simply by retrying any failed requests, until they all succeed. Alternatively, if some requests are continuously failing, then they can be abandoned and the original request can be forced to fail. However, processing a customer order is most likely an operation with side-effects, which might need to be performed atomically. It’s probably undesirable to charge a customer for an order that cannot be processed or to send an order to a customer that cannot pay. Each of the aforementioned patterns need to ensure this atomicity. One way to achieve this would be via a protocol like two-phase commit. An alternative would be to make use of the concept of saga transactions, described previously in the book. The first approach would fit better in the orchestrator pattern where the orchestrator plays the role of the coordinator of the two-phase commit protocol, while the second approach could be used in both patterns.
Data synchronisation There are some cases, where the same data needs to be stored in multiple places and in potentially different forms18 . Below are some typical examples: • Data that reside in a persistent datastore also need to be cached in a separate in-memory store, so that read operations can be processed 18
These are also referred to as materialized views. See: https://en.wikipedia.org/wiki/ Materialized_view
CHAPTER 10. PRACTICES & PATTERNS
Figure 10.6: Orchestration and choreography
269
CHAPTER 10. PRACTICES & PATTERNS
270
from the cache with lower latency. Writes operations need to update both the persistent datastore and the in-memory store. • Data that are stored in a distributed key-value store need to also be stored in a separate datastore that provides efficient full-text search, such as ElasticSearch19 or Solr20 . Depending on the form of read operations, the appropriate datastore can be used for optimal performance. • Data that are stored in a relational database need to also be stored in a graph database, so that graph queries can be performed in a more efficient way. Given data reside in multiple places, there needs to be a mechanism that keeps them in sync. This section will examine some of the approaches available for this purpose and the associated trade-offs. One approach is to perform writes to all the associated datastores from a single application that receives update operations. This approach is sometimes referred to as dual writes. Typically, writes to the associated data stores are performed synchronously, so that data have been updated in all the locations before responding to the client with a confirmation that the update operation was successful. One drawback of this approach is the way partial failures are handled and their impact on atomicity. If the application manages to update the first datastore successfully, but the request to update the second datastore fails, then atomicity is violated and the overall system is left in an inconsistent state. It’s also unclear what the response to the client should be in this case, since data has been updated, but only in some places. However, even if we assume that there are not partial failures, there is another pitfall that has to do with how race conditions are handled between concurrent writers and their impact on isolation. Let’s assume two writers submit an update operation for the same item. The application receives them and attempts to update both datastores, but the associated requests are reordered, as shown in Figure 10.7. This means that the first datastore contains data from the first request, while the second datastore contains data from the second request. This also leaves the overall system at an inconsistent state. An obvious solution to mitigate these issues is to introduce a distributed transaction protocol that provides the necessary atomicity & isolation, such as a combination of two-phase commit and two-phase locking. In order to be able to do this, the underlying datastores need to provide support for this. Even in that case though, this protocol will have some performance 19 20
See: https://github.com/elastic/elasticsearch See: https://github.com/apache/lucene-solr
CHAPTER 10. PRACTICES & PATTERNS
271
and availability implications, as explained in the previous chapters.
Figure 10.7: Isolation issues with dual writes Another approach is writing any update operations to an append-only event log and having any interested applications consume events from this log and storing the associated data in their preferred datastore. This approach is called event sourcing. The current state of the system can be derived simply by consuming all the events from the beginning of the log. However, applications typically save periodical snapshots (also known as checkpoints) of the state to avoid having to reconsume the whole log in case of failures. In this case, an application that recovers from a failure only needs to replay the events of the log after the latest snapshot. This approach does not suffer from atomicity violations, which means there is no need for an atomic commit protocol. The reason is every application is consuming the log independently and they will eventually process all the events successfully, restarting from the last consumed event in case of a temporary failure. The isolation problem described in the first approach is also mitigated, since the applications will be consuming all the events in the same order. There is a small caveat: applications might be consuming the events from the log at different speeds, which means an event will not be reflected at the same instance on all the applications. This phenomenon can be handled at the application level. For example, if an item is not available in the cache, the application can query the
CHAPTER 10. PRACTICES & PATTERNS
272
other datastores. A different manifestation of this problem could be an item that has been indexed successfully has not been stored in the authoritative datastore yet, which could lead to a dangling pointer. This could also be mitigated at the application level, by identifying and discarding these items, instead of displaying broken links. If no such technique can be applied at the application level, a concurrency control protocol could be used, e.g. a locking protocol with the associated performance and availability costs. Some kind of applications need to perform update operations that need an up-to-date view of the data. The simplest example is a conditional update operation21 , create a user if no user with the same username exists already. This is not easily achievable when using event sourcing, because the applications consume the log asynchronously, so they are only eventually consistent. There is another approach that solves this problem, known as change data capture (CDC). When using this approach, a datastore is selected as the authoritative source of data, where all update operations are performed. An event log is then created from this datastore that is consumed by all the remaining operations the same way as in event sourcing. This primary datastore needs to provide the necessary transactional semantics and a way to monitor changes in the underlying data in order to produce the event log. Relational databases are usually a good fit for this, since most of them provide strong transactional guarantees and they internally use a writeahead-log (WAL) that imposes an order on the performed operations and can be used to feed an event log22 . As a result, clients are able to perform updates that are conditional on the current state and the remaining applications can apply these operations independently at their own datastores.
Shared-nothing architectures At this point, it must have become evident that sharing leads to coordination, which is one of the main factors that inhibit high availability, performance and scalability. For example, we have already explained how distributed databases can scale to larger datasets and in a more cost-efficient manner than centralised, single-node databases. At the same time, some form of sharing is sometimes necessary and even beneficial for the same characteristics. For instance, a system can increase its overall availability by reducing sharing 21 This is also known as a compare-and-set (CAS) operation. See: https://en.wikipedia. org/wiki/Compare-and-swap 22 An example of a tool that does this is Debezium. See: https://debezium.io
CHAPTER 10. PRACTICES & PATTERNS
Figure 10.8: Data synchronisation techniques
273
CHAPTER 10. PRACTICES & PATTERNS
274
through partitioning, since the various partitions can have independent failure modes. However, when looking at a single data item, availability can be increased by increasing sharing via replication. A key takeaway from all this is that reducing sharing can be very beneficial, when applied properly. There are some system architectures that follow this principle to the extreme in order to reduce coordination and contention, so that every request can be processed independently by a single node or a single group of nodes in the system. These are usually called shared-nothing architectures. This section will explain how this principle can be used in practice to build such architectures and what are some of the trade-offs. A basic technique to reduce sharing used widely is decomposing stateful and stateless parts of a system. The main benefit from this is that stateless parts of a system tend to be fully symmetric and homogeneous, which means that every instance of a stateless application is indistinguishable from the rest. Separating them makes scaling a lot easier. Since all the instances of an application are identical, one can balance the load across all of them in an easy way, since all of them should be capable of processing any incoming request23 . The system can be scaled out in order to handle more load by simply introducing more instances of the applications behind a load balancer. The instances could also send heartbeats to the load balancer, so that the load balancer is capable of identifying the ones that have potentially failed and stop sending requests to them. The same could also be achieved by the instances exposing an API, where requests can be sent periodically by the load balancer to identify healthy instances. Of course, in order to achieve high availability and be able to scale incrementally, the load balancer also needs to be composed of multiple, redundant nodes. There are different ways to achieve this in practice, but a typical implementation uses a single domain name (DNS) for the application that resolves to multiple IPs belonging to the various servers of the load balancer. The clients, such as web browsers or other applications, can rotate between these IPs. The DNS entry needs to specify a relatively small time-to-live (TTL), so that clients can identify new servers in the load balancer fleet relatively quickly. As seen already throughout the book, stateful systems are a bit harder to manage, since the various nodes of the system are not identical. Each node contains different pieces of data, so the appropriate routing must be 23
Note that in practice the load balancer might need to have some state that reflects the load of the various instances. But, this detail is omitted here on purpose for the sake of simplicity.
CHAPTER 10. PRACTICES & PATTERNS
275
performed in order to direct requests to the proper part of the system. As implied before, the presence of this state also creates a tension that prevents us from completely eliminating sharing if there is a need for high availability. A combination of partitioning and replication is typically used to strike a balance. Data are partitioned to reduce sharing and create some independence and fault isolation, but every partition is replicated across multiple nodes to make every partition fault tolerant. We have already examined several systems that follow this pattern, such as Cassandra and Kafka. Sharing is thus not a binary property of a system, but rather a spectrum. On the one end of the spectrum, there might be systems that have as little sharing as possible, i.e. not allowing any transactions across partitions in order to reduce the coordination needed. Some systems might fall in the middle of the spectrum, where transactions are supported, but performed in a way that introduces coordination only across the involved partitions instead of the whole system. Lastly, systems that store all the data in a single node fall somewhere on the other end of the spectrum. Figure 10.9 illustrates an example of such a shared-nothing architecture. As explained already, such an architecture provides a lot of benefits from a performance and fault-tolerance perspective. All layers of the applications can be incrementally scaled out or in depending on the load. Admittedly, this is easier and quicker to achieve in the stateless components, since it requires less data transfer. The system is resilient to single-node and multinode failures. More specifically, these two different forms of failure impact the stateless parts of the system in a similar way, the size of the impact is just different. For example, the remaining nodes might need to handle bigger load or more servers might need to be provisioned. For the stateful parts of the architecture, these two different forms of failure have slightly different behaviours. Single-node failures are a lot easier to handle, since each partition can use a consensus-based technique for replication which can remain fully functional as long as a majority of nodes is healthy. However, multi-node failures can affect a majority, thus making a partition unavailable. Even in this case, the good thing is only this partition will be unavailable and the rest of system’s data will still be available. This type of architecture tends to be a good fit for problems that are amenable to fine-grained partitioning. Some examples of problems in this space are managing user sessions or managing the products of a catalog. In both cases, data can easily be partitioned in a way, where data access operations will need to access a single data item. For example, sessions can be assigned a unique identifier and they can be partitioned by this attribute and products
CHAPTER 10. PRACTICES & PATTERNS
276
Figure 10.9: Example of a typical shared-nothing architecture can be partitioned according to a similar product identifier. If sessions and products are mostly retrieved and updated by their identifier, this can be done quite efficiently by querying only the nodes that have the associated data. There is also space for some form of limited concurrency control in the scope of a partition. For cases where single-item access is the norm, a common technique is using optimistic concurrency control in order to reduce overhead and contention. This can be achieved by including a version attribute on every data item. Every writer performs a read before a write in order to find the current version and then includes the current version in the update as a condition to be satisfied in order for it to be completed. Of course, this requires the corresponding datastore to provide support for conditional updates. If a concurrent writer has updated the same item in the meanwhile, the first writer will have to abort and restart by performing a new read to determine whether its initial write should still apply and if so, retry it. Of course, all of this does not mean this architecture does not have drawbacks. The main one is reduced flexibility. If the application needs access to new data access patterns in an efficient way, it might be hard to provide it given
CHAPTER 10. PRACTICES & PATTERNS
277
the system’s data have been partitioned in a specific way. For example, attempting to query by a secondary attribute that is not the partitioning key might require to access all the nodes of the system. This reduced flexibility might also manifest in lack of strong transactional semantics. Applications that need to perform reads of multiple items with strong isolation or write multiple items in a single atomic transaction might not be able to do this under this form of architecture or it might only be possible at the cost of excessive additional overhead.
Distributed locking As explained already in the introductory chapters of the book, concurrency is one of factors that contribute significantly to the complexity of distributed systems. A mechanism is needed to ensure that all the various components of a distributed system that are running concurrently do so in a way that is safe and does not bring the overall system to an inconsistent state. An example we have already seen is leader election, where the system needs to ensure only one node in the system is capable of performing the leader duties at any point in time. Amongst the available techniques, locking is the simplest solution and one that is used commonly. However, locking techniques are subject to different failure modes when applied in a distributed system. This section will cover some common pitfalls and how to address them to use locking safely in a distributed system. The main property derived from the use of locks is mutual exclusion: multiple concurrent actors can be sure that only one of them will be performing a critical operation at a time. Typically, all of the actors have to follow the same sequence of operations, which is first acquiring the lock, performing that critical operation and then releasing the lock, so that other workers can proceed. This is usually simple to implement in cases where all actors are running inside the same application sharing a single memory address space and the same lifecycle. However, doing the same in a distributed system is much more complicated, mostly due to the potential of partial failures. The main complication in a distributed system is that the various nodes of the system can fail independently. As a result, a node that is currently holding a lock might fail before being able to release the lock. This would bring the system to a halt until that lock is released via some other means (i.e. via an operator), thus reducing availability significantly. A timeout
CHAPTER 10. PRACTICES & PATTERNS
278
mechanism can be used to cope with this issue. A lease[88] is essentially a lock with an expiry timeout after which the lock is automatically released by the system that is responsible for managing the locks. By using leases instead of locks, the system can automatically recover from failures of nodes that have acquired locks by releasing these locks and giving the opportunity to other nodes to acquire them in order to make progress. However, this introduces new safety risks. There are now two different nodes in the system that can have different views about the state of the system, specifically which nodes holds a lock. This is not only due to the fact that these nodes have different clocks so the time of expiry can differ between them, but also because a failure detector cannot be perfect, as explained earlier in the book. The fact that part of the system considers a node to be failed does not mean this node is necessarily failed. It could be a network partition that prevents some messages from being exchanged between some nodes or that node might just be busy with processing something unrelated. As a result, that node might think it still holds the lock even though the lock has expired and it has been automatically released by the system. Figure 10.10 shows an example of this problem, where nodes A and B are trying to acquire a lease in order to perform some operations in a separate system. Node A manages to successfully acquire a lease first. However, there is a significant delay between acquiring the lease and performing the associated operation. This could be due to various reasons, such as a long garbage collection pause, scheduling delays or simply network delays. In the meanwhile, the lease has expired, it has been released by the system and acquired by node B, which has also managed to perform the operation that’s protected by the lock. After a while, the operation from node A also reaches the system and it’s executed even though the lease is not held anymore by that node violating the basic invariant that was supposed to be protected by the lease mechanism. Note that simply performing another check the lease is still held before initiating the operation in node A would not solve the problem, since the same delays can occur between this check and the initiation of the operation or even the delivery of the operation to the system. There is one simple technique that solves this problem and it’s called fencing. The main idea behind fencing is that the system can block some nodes from performing some operations they are trying to perform, when these nodes are malfunctioning. In our case, nodes are malfunctioning in the sense that they think they hold a lease, while they don’t. The locking subsystem can associate every lease with a monotonically increasing number. This number can then be used by all the other systems in order to keep track of the node
CHAPTER 10. PRACTICES & PATTERNS
Figure 10.10: Issues with distributed locking & leases
279
CHAPTER 10. PRACTICES & PATTERNS
280
that has performed an operation with the most recent lease. If a node with an older lease attempts to perform an operation, the system is able to detect that and reject the operation, while also notifying the node that it’s not the leader anymore. Figure 10.11 shows how that would work in practice. This essentially means that in a distributed system, lock management cannot be performed by a single part of the system, but it has to be done collectively by all the parts of the system that are protected by this lock. For this to be possible, the various components of the system need to provide some basic capabilities. The locking subsystem needs to provide a monotonically increasing identifier for every lock acquisition. Some examples of systems that provide this is Zookeeper via the zxid or the znode version number and Hazelcast as part of the fenced token provided via the FencedLock API. Any external systems protected by the locks needs to provide conditional updates with linearizability guarantees.
Figure 10.11: Mutual exclusion using fencing
CHAPTER 10. PRACTICES & PATTERNS
281
Compatibility patterns As explained early on in the book, a defining characteristic of distributed systems is they are composed of multiple nodes. In general, it is useful to allow the various nodes of such a system to operate independently for various reasons. A very typical requirement for some applications in real life is to be able to deploy new versions of the software with zero downtime. The simplest way to achieve that is to perform rolling deployments, instead of deploying in lockstep the software to all the servers at the same time. In some cases, this is not just a nice-to-have, but an inherent characteristic of the system. An example of this is mobile applications (e.g. Android applications), where user consent is required to perform an upgrade, which implies that users are deploying the new version of the software at their own pace. As a result, the various nodes of a distributed systems can be running different versions of the software at any time. This section will examine the implications of this and some techniques that can help manage this complication. The phenomenon described previously manifests in many different ways. One of the most common ones is when two different applications need to communicate with each other, while each one of them evolves independently by deploying new versions of its software. For example, one of the applications might want to expose more data at some point. If this is not done in a careful way, the other application might not be able to understand the new data making the whole interaction between the applications fail. Two very useful properties related to this are backward compatibility and forward compatibility: • Backward compatibility is a property of a system that provides interoperability with an earlier version of itself or other systems. • Forward compatibility is a property of system that provides interoperability with a later version of itself or other systems. These two properties essentially reflect a single characteristic viewed from different perspectives, those of the sender and the recipient of data. Let’s consider for a moment a contrived example of two systems S and R, where the former sends some data to the latter. We can say that a change on system S is backward compatible, if older versions of R will be able to communicate successfully with a new version of S. We can also say that the system R is designed in a forward compatible way, so that it will be able to understand new versions of S. Let’s look at some examples. Let’s assume system S needs to change the data type of a specific attribute. Changing the data type of
CHAPTER 10. PRACTICES & PATTERNS
282
an existing attribute is not a backward compatible change in general, since system R would be expecting a different type for this attribute and it would fail to understand the data. However, this change could be decomposed in the following sub-changes that preserve compatibility between the two systems. The system R can deploy a new version of the software that is is capable of reading that data either from the new attribute with the new data type or the old attribute with the old data type. The system S can then deploy a new version of the software that stops populating the old attribute and starts populating the new attribute24 . The previous example probably demonstrated that seemingly trivial changes to software are a lot more complicated when they need to be performed in a distributed system in a safe way. As a consequence, maintaining backward compatibility imposes a tradeoff between agility and safety. It’s usually beneficial to version the API of an application, since that makes it easier to compare versions of different nodes and applications of the system and determine which versions are compatible with each other or not. Semantic versioning is a very useful convention, where each version number consists of 3 digits x.y.z. The last one (z) is called the patch version and it’s incremented when a backward compatible bug fix is made to the software. The second one (y) is called the minor version and it’s incremented when new functionality is added in a backward compatible way. The first one (x) is called the major version and it’s incremented when a backward incompatible change is made. As a result, the clients of the software can easily quickly understand the compatibility characteristics of new software and the associated implications. When providing software as a binary artifact, the version is usually embedded in the artifact. The consumers of the artifact then need to take the necessary actions, if it includes a backward incompatible change, e.g. adjusting their application’s code. However, when applied to live applications, semantic versioning needs to be implemented slightly differently. The major version needs to be embedded in the address of the application’s endpoint, while the major and patch versions can be included in the application’s responses25 . This is needed so that clients can be automatically upgraded to newer versions of the software if desired, but only if these are backward compatible. 24 Note that whether a change is backward compatible or not can differ depending on the serialization protocol that is used. The following article explains this nicely: https://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocolbuffers-thrift.html 25 For an example of this, see: https://developers.facebook.com/docs/apps/versions
CHAPTER 10. PRACTICES & PATTERNS
283
Another technique for maintaining backward compatibility through the use of explicitly versioned software is protocol negotiation. Let’s assume a scenario as mentioned previously, where the client of an application is a mobile application. Every version of the application needs to be backward compatible with all the versions of the client application running on user phones currently. This means that the staged approach described previously cannot be used when making backward incompatible changes, since end users cannot be forced to upgrade to a newer version. Instead, the application can identify the version of the client and adjust its behaviour accordingly. For example, consider the case of a feature introduced on version 4.1.2 that is backward incompatible with versions < 4.x.x. If the application receives a request from a 3.0.1 client, it can disable that feature in order to maintain compatibility. If it receives a request from a 4.0.3 client, it can enable the feature. In some cases, an application might not be aware of the applications that will be consuming its data. An example of this is the publish-subscribe model, where the publisher does not necessarily need to know all the subscribers. It’s still very important to ensure consumers will be able to deserialise and process any produced data successfully as its format changes. One pattern used here is defining a schema for the data, which is used by both the producers and consumers. This schema can be embedded in the message itself. Otherwise, to avoid duplication of the schema data, a reference to the schema can be put inside the message and the schema can be stored in a separate store. For example, this is a pattern commonly used in Kafka via the Schema Registry26 . However, it’s important to remember that even in this case, producers and consumers are evolving independently, so consumers are not necessarily using the latest version of the schema used by the producer. So, producers need to preserve compatibility either by ensuring consumers can read data of the new schema using an older schema or by ensuring all consumers have started using the new schema before starting to produce messages with it. Note that similar considerations need to be made for the compatibility of the new schema with old data. For example, if consumers are not able to read old data with the new schema, the producers might have to make sure all the messages with the previous schema have been consumed by everyone first. Interestingly, the Schema Registry defines different categories of compatibility along these dimensions, which determine what changes are allowed in each category and what is the upgrade process, e.g. if producers or consumers need to upgrade first. It can also check two different versions of 26
See: https://docs.confluent.io/current/schema-registry
CHAPTER 10. PRACTICES & PATTERNS
284
a schema and confirm that they are compatible under one of these categories to prevent errors later on27 . Note that it’s not only changes in data that can break backward compatibility. Slight changes in behaviour or semantics of an API can also have serious consequences in a distributed system. For example, let’s consider a failure detector that makes use of heartbeats to identify failed nodes. Every node sends a heartbeat every 1 second and the failure detector considers a node failed if it hasn’t received a single heartbeat in the last 3 seconds. This causes a lot of network traffic that affects the performance of the application, so we decide to increase the interval of a heartbeat from 1 to 5 seconds and the threshold of the failure detector from 3 to 15 seconds. Note that if we start performing a rolling deployment of this change, all the servers with the old version of the software will start thinking all the servers with the new version have failed. This due to the fact that their failure detectors will still have the old deadline of 3 seconds, while the new servers will send a heartbeat every 5 seconds. One way to make this change backward compatible would be to perform an initial change that increases the failure detector threshold from 3 to 15 seconds and the follow this with a subsequent change that increases the heartbeat interval to 5 seconds, only after the first change has been deployed to all the nodes. This technique of splitting a change in two parts to make it backward compatible is commonly used and it’s also known as two-phase deployment.
Dealing with failure Failure is the norm in a distributed system, so building a system that is able to cope with failures is crucial. This section will cover some principles on how to deal with failures and present some basic patterns for building systems that are resilient to failures. As shown throughout this section, dealing with a failure consists of three main parts: identifying the failure, recovering from the failure and in some cases containing a failure to reduce its impact. Hardware failures can be the most damaging ones, since they can lead to data loss or corruption. On top of that, the probability of a hardware failure is significantly higher in a distributed system due to the bigger number of hardware components involved. Silent hardware failures are the ones with the 27
See: https://docs.confluent.io/current/schema-registry/avro.html#schema-evolutionand-compatibility
CHAPTER 10. PRACTICES & PATTERNS
285
biggest impact, since they can potentially affect the behaviour of a system without anyone noticing. An example of a silent failure would be a node in the network corrupting some part of a message, so that the recipient receives data that is different to what the sender originally sent without being able to detect that. Similarly, data written to a disk can be corrupted during the write operation or even a long time after that was completed. Below are some techniques that are commonly used to handle these kind of failures: • One way to detect these failures when sending a message to another node is to introduce some redundancy in the message using a checksum derived from the actual payload. If the message is corrupted, the checksum will not be valid. As a result, the recipient can ask the sender to send the message again. • When writing data to disk, this technique might not be useful, since the corruption will be detected a long time after a write operation has been performed by the client, which means it might not be feasible to rewrite the data. Instead, the application can make sure that data is written to multiple disks, so that corrupted data can be discarded later on and the right data can be read from another disk with a valid checksum. • Another technique used in cases where retransmitting or storing the data multiple times is impossible or costly is error correcting codes (ECC). These are similar to checksums and are stored alongside the actual payload, but they have the additional property that they can also be used to correct corruption errors calculating the original payload again. The downside is they are larger than checksums, thus having a higher overhead in terms of data stored or transmitted across the network. A distributed system consists of many different parts and these kind of failures can happen on any of them. This raises the question of where and how to apply these techniques. There is a design principle, known as the end-to-end argument, which suggests that some functions such as the fault tolerance techniques described above can be implemented completely and correctly only wih the knowledge and help of the application standing at the end points of the communication system. A canonical example to illustrate this point is the "careful file transfer" application, where a file needs to be moved from computer A’s storage to computer B’s storage without damage. As shown in Figure 10.12, hardware failures can happen in many places during this process, such as the disks of computers, the software of the file system, the hardware processors, their local memory or the communication
CHAPTER 10. PRACTICES & PATTERNS
286
system. Even if the various subsystems embed error recovery functionality, this can only cover lower levels of the system and it cannot protect from errors happening at a higher level of the system. For example, error detection and recovery implemented at the disk level or in the operating system won’t help, if the application has a defect that leads to writing the wrong data in the first place. This implies that complete correctness can only be achieved by implementing this function at the application level. Note that this function can be implemented redundantly at lower levels too, but this is done mostly as a performance optimisation28 . It’s also important to note that this redundant implementation at lower levels is not always beneficial, but it depends on the use case. There is existing literature that covers extensively this trade-off, so we’ll refer the reader to it instead of repeating the same analysis here[89][90].
Figure 10.12: Careful file transfer and possible failures It’s interesting to observe that this principle manifests in many different ways when dealing with a distributed system. The most relevant problem we have encountered repeatedly throughout this book is providing exactlyonce guarantees. Let’s consider an extremely simplified version of the problem, where applications A wants to trigger an operation on application B exactly once and each application consists of a single server. Note that the communication subsystem, specifically TCP, can provide reliable delivery 28
An example of this was already presented in the chapter about networking, where we saw that the link layer provides reliable data transfer for wireless links (e.g. Wi-fi) even though this can also be provided at the transport layer (e.g. TCP).
CHAPTER 10. PRACTICES & PATTERNS
287
of data via retries, acknowledgements and deduplication, which are the techniques already described in this book. However, this is still not sufficient for providing exactly-once guarantees at the application level. Let’s look at some of the things that can go wrong: • The TCP layer on the side of application B might receive a packet and acknowledge it back to the sender side, while buffering it locally to be delivered to the application. However, the application crashes before it manages to receive this packet from the TCP layer and process it. In this case, the application on the sender side will think the packet has been successfully processed, while it wasn’t. • The TCP layer on the side of the application B might receive a packet and deliver it successfully to the application, which processes it successfully. However, a failure happens at this point and the applications on both sides are forced to establish a new TCP connection. Application A had not received an application acknowledgement for the last message, so it attempts to resend it on the new connection. TCP provides reliable transfer only in the scope of a single connection, so it will not be able to detect this packet has been received and processed in a previous connection. As a result, a packet will be processed by the application more than once. The main takeaway is that any functionality needed for exactly-once semantics (e.g. retries, acknowledgements and deduplication) needs to be implemented at the application level in order to be correct and safe against all kind of failures29 . Another problem where the end-to-end principle manifests in a slightly different shade is the problem of mutual exclusion in a distributed system. The fencing technique presented previously essentially extends the function of mutual exclusion to all the involved ends of the application. The goal of this section is not to go through all the problems, where the end-to-end argument is applicable. Instead, the goal is to raise awareness and make the reader appreciate its value on system design, so that it’s taken into account if and when need be. The main technique to recover from failures is using retries. In the case of a stateless system, the application of retries is pretty simple, since all the nodes of the application are identical from the perspective of the client so it could retry a request on any node. In some cases, that can be done 29
It’s also worth reminding here that the side-effects from processing a request and storing the associated deduplication ID need to be done in an atomic way to avoid partial failures violating the exactly-once guarantees.
CHAPTER 10. PRACTICES & PATTERNS
288
in a fully transparent way to the client. For example, the application can be fronted by a load balancer that receives all the requests under a single domain and it’s responsible for forwarding the requests to the various nodes of the application. In this way, the client would only have to retry the request to the same endpoint and the load balancer would take care of balancing the requests across all the available nodes. In the case of stateful systems, this gets slightly more complicated, since nodes are not identical and retries need to be directed to the right one. For example, when using a system with master-slave replication, a failure of the master node must be followed by a failover to a slave node that is now the new master and new requests should be going there. There are different mechanisms to achieve this depending on the technology used. The same applies to consensus-based replication, where new leader election might need to happen and write operations need to be directed to the current leader. Most of the techniques described so far are used to identify failure and recover from it. It’s also useful to be able to contain the impact of a failure, so we will now discuss some techniques for this purpose. This can be done via technical means, such as fault isolation. One common way to achieve this is to deploy an application redundantly in multiple facilities that are physically isolated and have independent failure modes. So, when there is an incident that affects one of these facilities, the other facilities are not impacted and continue functioning as normal. Note that this introduces a trade-off between availability and latency, since physical isolation comes with increased network distance and latency. Most of the cloud providers provide multiple datacenters that are physically isolated and all located close to each other in a single region to strike a good balance in this trade-off. These are commonly known as availability zones. There are also cases where this can be achieved by a technique called graceful degradation, where an application reduces the quality of its service in order to avoid failing completely. For instance, let’s think about a service that provides the capabilities of a search engine by calling to downstream services. Let’s also assume one of these services that provides the advertisements to be shown for each query is having some issues. The top-level service can just render the results of a search term without any advertisements, instead of returning an error message or a completely empty response. Techniques to contain failure can be broadly categorised in two main groups: those performed at the client side and those performed at the server side. A very useful concept in the field of distributed systems is backpressure. Backpressure is essentially a resistance to the desired flow of data through
CHAPTER 10. PRACTICES & PATTERNS
289
a system. This resistance can manifest in different ways, such as increased latency of requests or failed requests. Backpressure can also be implicit or explicit. For example, implicit backpressure arises in a system that is overloaded by a traffic surge and becomes extremely slow. On the other hand, a system that rejects some requests during a traffic surge in order to maintain a good quality of service is essentially exerting explicit backpressure to its clients. Most of the techniques to contain failure reflect how applications exert backpressure and how their clients handle backpressure. Let’s look first at how applications can exert backpressure. It is useful for a system to know its limits and exert backpressure when they are reached, instead of relying on implicit backpressure. Otherwise, there can be many failure modes that are unexpected and harder to deal with when they happen. The main technique to exert backpressure is load shedding, where an application is aware of the maximum load it can handle and rejects any requests that cross this threshold in order to keep operating at the desired levels. A more special form of load shedding is selective client throttling, where an application assigns different quotas to each of its clients. This technique can also be used to prioritise traffic from some clients that are more important. Let’s consider a service that is responsible for serving the prices of products, which is used both by systems that are responsible to display product pages and by systems that are responsible to receive purchases and charge the customer. In case of a failure, that service could throttle the former type of systems more than the latter, since purchases are considered to be more important for a business and they also tend to constitute a smaller percentage of the overall traffic. In the case of asynchronous systems that make use of message queues, load shedding can be performed by imposing an upper bound on the size of the queue. There is a common misconception that message queues can help absorb any form of backpressure without any consequences. However, this comes at the cost of an increased backlog of messages, which can lead to increased processing latency or even failure of the messaging system in extreme cases. Clients of an application should also be able to react properly to backpressure emitted by an application. The most typical way to react to failures in a distributed systems is retries. This is done under the assumption that a failure is temporary, so retrying a request is expected to have a better outcome. However, retries can have adverse effects, such as overloading a service. There are some techniques to make sure retries are used properly and negative side-effects are avoided as much as possible. First of all, it is useful to think about the whole architecture of the systems and the
CHAPTER 10. PRACTICES & PATTERNS
290
Figure 10.13: Load amplification due to retries various applications involved to determine where retries will be performed. Performing retries at multiple levels can lead to significant amplification of the traffic coming from customers, which can overload services and cause issues. As an example, let’s assume we have 4 services A, B, C and D that call each other in order, as shown in Figure 10.13. If each service performs 3 retries for every failed request, then a temporary issue at service D will cause every request to be retried 27 times, thus creating a lot of additional load to service D during a period it’s already experiencing issues. A conventional approach to mitigate this issue is to retry failed requests at the highest level possible, which usually contains additional context around the business function of the request and whether it’s actually worth retrying or not. Another technique is using exponential backoff when retrying a request, so that the system waits a bit more every time before performing the next retry. This gives the downstream system a better opportunity to recover from any temporary issues. Ideally, exponential backoff is also combined with some jitter. This is so retries from various servers of a service are distributed evenly and they do not produce sudden spikes of traffic that can also cause overload issues. Clients of an application can also perform some form of load shedding to help downstream applications to recover with the use of circuit breaker. A circuit breaker essentially monitors the percentage of failed requests. When a specific threshold is crossed, this is interpreted as a permanent failure of the downstream application. As a result, the circuit breaker rejects all the requests locally without sending them to the downstream application. The circuit breaker allows just a few requests to be sent periodically and if a good percentage of them is successful, it starts sending load again. This technique is beneficial in two ways. First of all, it
CHAPTER 10. PRACTICES & PATTERNS
291
gives the downstream service a chance to recover from overload situations or other kinds of permanent failures. On top of that, it improves the customer experience by reducing request latency in cases where the response from a downstream is not absolutely necessary30 . Another useful thing clients can do to help downstream applications is embed timeout hints in their requests. These hints inform downstream applications about the time after which a response to a request is not useful anymore. In this way, downstream applications can simply discard requests that had been waiting for a long time in message queues or in memory buffers due to resource exhaustion, thus speeding up processing of accumulated backlogs.
Distributed tracing Tracing refers to the special use of logging to record information about a program’s execution, which can be used for troubleshooting or diagnostic purposes. In its simplest form, this can be achieved by associating every request to the program with a unique identifier and recording logs for the most important operations of the program alongside the request identifier. In this way, when trying to diagnose a specific customer issue, the logs can easily be filtered down to only include a chronologically ordered list of operation logs pertaining to the associated request identifier. These logs can provide a summary of the various operations the program executed and the steps it went through. In a distributed system, every client request is typically served through the use of multiple, different applications. As a result, one needs to collate traces from multiple programs in order to fully understand how a request was served and where something might have gone wrong. This is not as easy as it sounds, because every application might be using its own request identifiers and the applications are most likely processing multiple requests concurrently. This makes it harder to determine which requests correspond to a specific client request. This problem can be solved through the use of correlation identifiers. A correlation identifier is a unique identifier that corresponds to a top-level client request. This identifier might be automatically generated or it might be provided by some external system or manual process. It is then propagated through the various applications that are involved in 30 An example of this is the situation described previously, when explaining the concept of graceful degradation.
CHAPTER 10. PRACTICES & PATTERNS
292
Figure 10.14: Distributed tracing via correlation IDs serving this request. These applications can then include that correlation identifier in their logs along with their own request identifiers. In this way, it is easier to identify all the operations across all applications corresponding to a specific client request by filtering their logs based on this correlation identifier. By also incorporating timing data in this logging, one can use this technique to also retrieve performance data, such as the time spent on every operation. Figure 10.14 illustrates how correlation IDs can be used and an example of a trace that shows latency contribution of every application. There are several libraries and tools for implementing distributed tracing, such as OpenTracing31 or Zipkin32 .
31 32
See: https://opentracing.io See: https://zipkin.io
Chapter 11
Closing thoughts Hopefully, this book has helped you understand how distributed systems can be useful, what are some of the main challenges one might face when building distributed systems and how to overcome them. Ideally, it has also made you realise that building or using a distributed system is a serious undertaking that should be done only when necessary. If your requirements around performance, scalability or availability can be met without the use of a distributed system, then not using a distributed system might actually be a wise decision. This might feel like the end of a journey, but for some of you it might just be the beginning of it. For this reason, I think it would be useful to recap some key learnings from this book, while also highlighting topics that were left uncovered. In this way, those of you that are willing to dive deeper on some areas will have some starting points to do so. First of all, we introduced some of the basic areas where distributed systems can help: performance, scalability and availability. Throughout the book, we analysed basic mechanisms that can help in these areas, such as partitioning and replication. It also became evident that these mechanisms introduce some tension between the aforementioned characteristics and other properties, such as consistency. This tension is formalised by basic theorems, such as the CAP theorem and the FLP result. This tension manifests in various ways, as shown in the book. For example, the decision on whether replication operates synchronously or asynchronously can be a trade-off between performance and availability or durability. Early on, we explained the difference between liveness and safety properties and tried to provide an overview of the basic 293
CHAPTER 11. CLOSING THOUGHTS
294
consistency models that help formalise the behaviour of a distributed system and facilitate reasoning about its interaction with other systems. However, there are many more models we omitted in this book in the interest of time and simplicity, such as read-your-writes, monotonic reads and monotonic writes consistency models[91]. The first chapter also introduced the concept of failure detection and gave an example of a simplistic failure detector that makes use of heartbeats and timeouts. In reality, failure detectors have to deal with many more practical problems and need to be quite more complex. This might mean they have to avoid using timeouts in order to be applicable to quiescent algorithms[92], operate using a gossip protocol1 to improve scalability and fault-tolerance[94] or output a suspicion level on a continuous scale instead of a binary value[95]. In the second chapter, we explored several partitioning techniques. Again, there are many more algorithms to study further if you want to, such as shuffle sharding[96] for workload and fault isolation, jump consistent hashing[97] for reduced memory overhead and better load distribution, multi-probe consistent hashing[98] and rendez-vous hashing[99]. The fourth chapter introduces the problem of consensus and explains the two major algorithms that solve it, Paxos and Raft. The topic of consensus is very old, but there is still a lot of very useful research being conducted in this area. For example, the original Paxos algorithm operated under the assumption that all quorums need to be majority quorums in order to maintain the safety properties of the algorithm. However, recent research[100] has actually demonstrated that this is not absolutely necessary. Instead, the algorithm just needs to ensure quorums from the first phase of the algorithm overlap with quorums from the second phase. This allows one to size the quorums of each phase accordingly depending on the requirements around performance and fault-tolerance during the steady state or during recovery. There are also a lot of variations of the Paxos algorithm, such as egalitarian Paxos[101] that does not require a stable leader, vertical Paxos[102] that allows a reconfiguration in the middle of a consensus round and more[103]. All these algorithms solve the consensus problem in cases where nodes can crash or get arbitrarily slow, but assuming they do not exhibit byzantine failures. Solving consensus under the presence of byzantine failures is a much more challenging task[104][105] and is subject to different constraints. There is also another category of algorithms worth exploring that solve the problem of consensus probabilistically2 , such as the 1
Gossip protocols were also not covered in this book and they can be considered a whole topic on its own[93]. 2 This means that a value is agreed with a specific probability p, where 1-p is the
CHAPTER 11. CLOSING THOUGHTS
295
one used in the Bitcoin protocol and known as the Nakamoto consensus[78]. In the fifth and sixth chapter, we introduced the notions of time and order and their relationship. We explained the difference between total and partial order, which is quite important in the field of distributed systems. While consensus can be considered as the problem of establishing total order amongst all events of a system, there are also systems that do not have a need for such strict requirements and can also operate successfully under a partial order. Vector clocks is one mechanism outlined in the book that allows a system to keep track of such a partial order that preserves causality relationships between events. However, there are more techniques that were not presented in the book. An example is conflict-free replicated data types (CRDTs)[106][107], which are data structures that can be replicated across multiple nodes, where the replicas can be updated independently and concurrently without coordination between the replicas and it’s always possible to resolve inconsistencies that might result from this. Some examples of such data structures are a grow-only counter, a grow-only set or a linear sequence CRDT. The lack of need for coordination makes these data structures more efficient due to reduced contention and more tolerant to partitions, since the various replicas can keep operating independently. However, they require the underlying operations to have some specific characteristics (e.g. commutativity, associativity, idempotency etc.), which can limit their expressive power and practical application. There have also been many other efforts to explore how we can build applications without the need for coordination, such as invariant confluence[108] and the CALM (consistency as logical monotonicity) theorem[109]. The next two chapters covered the topics of networking and security. Networking is core to how the various nodes of a distributed system communicate. So, it is important to have a good understanding of the various networking protocols and technologies available when designing a distributed system. Breadth of knowledge can help you see the big picture and make the right design decision, but depth of knowledge is still valuable to get the small details right and troubleshoot issues efficiently when things go wrong3 . We attempted to give you a quick, but complete overview of the basic protocols and technologies in this book and we would urge you to study further the areas that didn’t feel familiar. Networks themselves can be designed following probability of this value being considered not agreed (what is also referred to as reversed) in the future. 3 The analysis of the following incident is a good example of how such knowledge can be useful in mitigating and preventing issues: https://www.usenix.org/node/195676
CHAPTER 11. CLOSING THOUGHTS
296
the same principles outlined in this book, so that distributed systems can run on top of them at scale[110]. Security is also a crucial aspect of distributed systems that is frequently neglected, unfortunately. This is one of the reasons we dedicated a chapter to cover the basic concepts and techniques you can use to build secure distributed systems. However, we only scratched the surface of the basic cryptographic primitives without going into too much detail, since it’s a vast field you would have to explore separately. Our last piece of advice would be to always stick with existing standards and libraries that have been cryptanalysed and battle tested, instead of rolling out your own. Reading about threat modelling would also be very useful, so that you can analyse the design of a distributed system properly and identify potential issues. We believe it is a lot easier for someone to understand theory, when it is put into context by demonstrating how it is used in practical systems. This is the reason we included a chapter dedicated to case studies about real systems and how they use algorithms and techniques presented in the book. We tried to cover systems from as many different categories as possible, but we have to admit there are many more systems that are very interesting and we would like to include in this chapter, but we couldn’t due to time constraints. The last chapter on practices and patterns was written under the same spirit and subject to similar time constraints. So, we call the reader to study more resources than those that were available in the book for a deeper understanding of how theory can be put in practice[111][112][113][114][115]. At the risk of being unfair to other systems and material out there, we would like to mention CockroachDB4 as one system that has a lot of public material demonstrating how they have used theoretical concepts in practice. Some concrete examples are how they implemented pipelined consensus5 and how they implemented a parallelised version of two-phase commit6 that required a single round-trip instead of two before acknowledging a commit. Some resources that also contain a lot of practical information on how to build and operate distributed systems are the Amazon Builders Library7 and papers with learnings of practitioners that have built large-scale systems[116][117]. The last chapter also contained discussion about how systems can deal with failure. There are two types of failure that are frequently neglected when building or operating distributed systems even though they are quite common: 4
See: See: 6 See: 7 See: 5
https://github.com/cockroachdb/cockroach https://www.cockroachlabs.com/blog/transaction-pipelining https://www.cockroachlabs.com/blog/parallel-commits https://aws.amazon.com/builders-library
CHAPTER 11. CLOSING THOUGHTS
297
gray failures[118] and partial failures[119]. Gray failures are those that do not manifest cleanly as a binary indication, but they are more subtle and they can be observed differently by different parts of a system. Partial failures are those in which only parts of a system fail in a way that has serious consequences equivalent to a full failure of the system sometimes due to a defect in the design. These types of failure can be very common in distributed systems due to many moving parts and they can have serious consequences, so it is very important for people that build and run distributed systems to internalise these concepts and look out for them in the systems they build and operate. Another important topic that we did not cover at all is formal verification of systems. There are many formal verification techniques and tools that can be used to prove safety and liveness properties of systems with TLA+[120] being one of the most commonly used across the software industry[121]. It is important to note that users of these formal verification methods have acknowledged publicly that they have not only helped them discover bugs in their designs, but they have also helped them significantly reason about the behaviour of their systems in a better way.
References [1] G. Coulouris, J. Dollimore, T. Kindberg, and G. Blair, Distributed Systems: Concepts and Design (5th Edition). 2011. [2] A. B. Bondi, “Characteristics of scalability and their impact on performance,” in Proceedings of the second international workshop on Software and performance – WOSP ’00. p. 195, 2000. [3] P. Bailis and K. Kingsbury, “The Network is Reliable,” ACM Queue, Volume 12, Issue 7, July 23, 2014, 2014. [4] A. Alquraan, H. Takruri, M. Alfatafta, and S. Al-Kiswany, “An Analysis of Network-Partitioning Failures in Cloud Systems,” Proceedings of the 12th USENIX Conference on Operating Systems Design and Implementation, 2018. [5] J. C. Corbett et al., “Spanner: Google’s Globally-Distributed Database,” in Proceedings of OSDI 2012, 2012. [6] T. D. Chandra and S. Toueg, “Unreliable failure detectors for reliable distributed systems,” Journal of the ACM. Volume 43 Issue 2, ACM. pp. 225–267, 1996. [7] F. Chang et al., “Bigtable: A Distributed Storage System for Structured Data,” in Proceedings of 7th {USENIX} Symposium on Operating Systems Design and Implementation (OSDI), 2006. [8] G. DeCandia et al., “Dynamo: Amazon’s Highly Available Key-value Store,” in Proceedings of twenty-first ACM SIGOPS symposium on Operating systems principles, 2007. [9] A. Lakshman and P. Malik, “Cassandra — A Decentralized Structured Storage System,” Operating Systems Review, 2010. [10] R. van Renesse and F. B. Schneider, “Chain Replication for Support298
CHAPTER 11. CLOSING THOUGHTS
299
ingHigh Throughput and Availability,” in Proceedings of the 6th conference on Symposium on Opearting Systems Design & Implementation, 2004. [11] D. K. Gifford, “Weighted voting for replicated data,” in Proceedings of the seventh ACM symposium on Operating systems principles, 1979. [12] N. Lynch and S. Gilbert, “Brewer’s conjecture and the feasibility of consistent, available, partition-tolerant web services,” SIGACT News, 2002. [13] D. Abadi, “Consistency Tradeoffs in Modern Distributed Database System Design: CAP is Only Part of the Story,” Computer, 2012. [14] M. P. Herlihy and J. M. Wing, “Linearizability: A Correctness Condition for Concurrent Objects,” ACM Transactions on Programming Languages and Systems, July 1990, 1990. [15] P. Bailis, A. Davidson, A. Fekete, A. Ghodsi, J. M. Hellerstein, and I. StoicaUC, “Highly Available Transactions: Virtues and Limitations (Extended Version),” Proceedings of the VLDB Endowment, 2013. [16] ANSI X3.135-1992, “American National Standard for Information Systems - Database Language - SQL,” 1992. [17] H. Berenson, P. Bernstein, J. Gray, J. Melton, E. O’Neil, and P. O’Neil, “A Critique of ANSI SQL Isolation Levels,” SIGMOD Rec., 1995. [18] C. H. Papadimitriou, “The Serializability of Concurrent Database Updates,” Journal of the ACM, October 1979, 1979. [19] P. A. Bernstein and N. Goodman, “Serializability theory for replicated databases,” Journal of Computer and System Sciences, 1983. [20] M. J. Franking, “Concurrency Control and Recovery,” SIGMOD ’92, 1992. [21] H. T. Kung and J. T. Robinson, “On optimistic methods for concurrency control,” ACM Transactions on Database Systems, Volumne 6, Issue 2, 1981. [22] D. P. Reed, “Naming and Synchronisation in a Decentralised Computer System,” Tech. Rep. MIT/LCS/TR-204, Dept. Electrical Engineering and Computer Science, Massachusetts Institute of Technology, 1978. [23] A. Silberschatz, “A multi-version concurrency scheme with no rollbacks,” Proceedings of the first ACM SIGACT-SIGOPS symposium on Principles of distributed computing, 1982.
CHAPTER 11. CLOSING THOUGHTS
300
[24] R. E. Stearns and D. J. Rosenkratz, “Distributed database concurrency controls using before-values,” Proceedings of the 1981 ACM SIGMOD international conference on Management of data, 1981. [25] M. J. Cahill, U. Rohm, and A. D. Fekete, “Serializable Isolation for Snapshot Databases,” Proceedings of the 2008 ACM SIGMOD International Conference on Management of Data, 2008. [26] D. R. K. Ports and K. Grittner, “Serializable Snapshot Isolation in PostgreSQL,” Proceedings of the VLDB Endowment, Volume 5 Issue 12, August 2012, 2012. [27] T. S. and C. Pillai, V. Chidambaram, R. Alagappan, S. Al-Kiswany, A. C. Arpaci-Dusseau, and R. H. Arpaci-Dusseau, “All File Systems Are Not Created Equal: On the Complexity of Crafting Crash-consistent Applications,” Proceedings of the 11th USENIX Conference on Operating Systems Design and Implementation, 2014. [28] J. N. Gray, “Notes on data base operating systems,” Operating Systems. Lecture Notes in Computer Science, vol 60. Springer, 1978. [29] B. Lampson and H. E. Sturgis, “Crash Recovery in a Distributed Data Storage System,” 1979. [30] H. Garcia-Molina, “Performance of Update Algorithms for Replicated Data in a Distributed Database.” 1979. [31] D. Skeen, “Nonblocking Commit Protocols,” Proceedings of the 1981 ACM SIGMOD International Conference on Management of Data, 1981. [32] D. Skeen, “A Quorum-Based Commit Protocol,” 1982. [33] C. Binnig, S. Hildenbrand, F. Farber, D. Kossmann, J. Lee, and N. May, “Distributed Snapshot Isolation: Global Transactions Pay Globally, Local Transactions Pay Locally,” The VLDB Journal, Volume 23 Issue 6, December 2014, 2014. [34] H. Garcia-Molina and K. Salem, “Sagas,” Proceedings of the 1987 ACM SIGMOD International Conference on Management of Data, 1987. [35] L. Frank and T. U. Zahle, “Semantic ACID Properties in Multidatabases Using Remote Procedure Calls and Update Propagations,” Software—Practice & Experience, Volume 28 Issue 1, Jan. 1998, 1998. [36] X. Defago, A. Schiper, and P. Urban, “Total order broadcast and multicast algorithms: Taxonomy and survey,” ACM Computing Surveys,
CHAPTER 11. CLOSING THOUGHTS
301
Volume 34, 2004. [37] M. J. Fischer, N. A. Lynch, and M. S. Paterson, “Impossibility of Distributed Consensus with One Faulty Process,” Journal of the ACM (JACM), 1985. [38] L. Lamport, “The Part-time Parliament,” ACM Transactions on Computer Systems (TOCS), 1998. [39] L. Lamport, “Paxos Made Simple,” ACM SIGACT News (Distributed Computing Column) 32, 4 (Whole Number 121, December 2001), 2001. [40] T. D. Chandra, R. Griesemer, and J. Redstone, “Paxos Made Live: An Engineering Perspective,” Proceedings of the Twenty-sixth Annual ACM Symposium on Principles of Distributed Computing, 2007. [41] B. W. Lampson, “How to Build a Highly Available System Using Consensus,” Proceedings of the 10th International Workshop on Distributed Algorithms, 1996. [42] H. Du, J. S. David, and Hilaire, “Multi-Paxos: An Implementation and Evaluation,” 2009. [43] V. Hadzilacos, “On the Relationship between the Atomic Commitment and Consensus Problems,” Fault-Tolerant Distributed Computing, November 1990, pages 201–208, 1990. [44] J. Gray and L. Lamport, “Consensus on Transaction Commit,” ACM Transactions on Database Systems (TODS), Volume 31 Issue 1, March 2006, 2006. [45] D. Ongaro and J. Ousterhout, “In Search of an Understandable Consensus Algorithm,” Proceedings of the 2014 USENIX Conference on USENIX Annual Technical Conference, 2014. [46] C. Dyreson, “Physical Clock,” Encyclopedia of Database Systems, 2009. [47] M. Raynal and M. Singhal, “Logical time: capturing causality in distributed systems,” Computer, Volume 29, Issue 2, Feb 1996, 1996. [48] L. Lamport, “Time, Clocks, and the Ordering of Events in a Distributed System,” Communications of the ACM 21, 7 July 1978, 1978. [49] S. Reinhard and M. Friedemann, “Detecting Causal Relationships in Distributed Computations: In Search of the Holy Grail,” Distributed Computing, Volume 7 Issue 3, March 1994, 1994.
CHAPTER 11. CLOSING THOUGHTS
302
[50] F. Colin J., “Timestamps in Message-Passing Systems That Preserve the Partial Ordering,” Proceedings of the 11th Australian Computer Science Conference (ACSC’88), pp. 56–66, 1988. [51] M. Friedemann, “Virtual Time and Global States of Distributed Systems,” Parallel and Distributed Algorithms, 1988. [52] B. Charron-Bost, “Concerning the Size of Logical Clocks in Distributed Systems,” Information Processing Letters, Volume 39 Issue 1, July 12, 1991, 1991. [53] D. S. Parker et al., “Detection of mutual inconsistency in distributed systems,” IEEE Transactions on Software Engineering, Volume, 9 Issue 3, pages 240-247, 1983. [54] N. M. Perguica, C. Baquero, P. S. Almeida, V. Fonte, and G. Ricardo, “Dotted Version Vectors: Logical Clocks for Optimistic Replication,” arXiv:1011.5808, 2010. [55] K. M. Chandy and L. Lamport, “Distributed Snapshots: Determining Global States of Distributed Systems,” ACM Transactions on Computer Systems (TOCS), Volume 3 Issue 1, Feb. 1985, 1985. [56] N. McKeown et al., “OpenFlow: Enabling Innovation in Campus Networks,” ACM SIGCOMM Computer Communication Review, March 2008, 2008. [57] S. W. Richard, “TCP/IP Illustrated (Vol. 1): The Protocols,” AddisonWesley Longman Publishing Co., Inc., 1993. [58] J. F. Kurose and K. W. Ross, “Computer Networking: A Top-Down Approach (6th Edition),” Pearson, 2012. [59] S. Ghemawat, H. Gobioff, and S.-T. Leung, “The Google File System,” Sumposium on Operating Systems Principles ’03, 2003. [60] K. Shvachko, H. Kuang, and R. Chansler, “The Hadoop Distributed File System,” IEEE 26th Symposium on Mass Storage Systems and Technologies (MSST), 2010. [61] M. Burrows, “The Chubby Lock Service for Loosely-coupled Distributed Systems,” Proceedings of the 7th Symposium on Operating Systems Design and Implementation, 2006. [62] P. Hunt, M. Konar, F. P. Junqueira, and B. Reed, “ZooKeeper: Wait-free Coordination for Internet-scale Systems,” Proceedings of the 2010 USENIX
CHAPTER 11. CLOSING THOUGHTS
303
Conference on USENIX Annual Technical Conference, 2010. [63] F. P. Junqueira, B. Reed, and M. Serafini, “Zab: High-performance Broadcast for Primary-backup Systems,” Proceedings of the 2011 IEEE/IFIP 41st International Conference on Dependable Systems & Networks, 2011. [64] A. Medeiros, “ZooKeeper’ s atomic broadcast protocol : Theory and practice,” 2012. [65] K. Lev-Ari, “Ordered Sequential Consistency: Composition and Correctness,” 2017. [66] P. O’Neil, E. Cheng, D. Gawlick, and E. O’Neil, “The log-structured merge-tree (LSM-tree),” Acta Informatica, Volume 33 Issue 4, 1996, 1996. [67] D. F. Bacon et al., “Spanner: Google’s Globally-Distributed Database,” in Proceedings of the 2017 ACM International Conference on Management of Data, 2017. [68] E. Brewer, “Spanner, TrueTime and the CAP Theorem,” 2017. [69] D. J. Rosenkrantz, R. E. Stearns, and P. M. Lewis,II, “System Level Concurrency Control for Distributed Database Systems,” ACM Transactions on Database Systems (TODS), volume 3, Issue 2, June 1978, 1978. [70] A. Thomson, T. Diamond, S.-C. Weng, K. Ren, P. Shao, and D. J. Abadi, “Calvin: Fast Distributed Transactions for Partitioned Database Systems,” Proceedings of the 2012 ACM SIGMOD International Conference on Management of Data, 2012. [71] D. J. Abadi and J. M. Faleiro, “An Overview of Deterministic Database Systems,” Communications of the ACM, Volume 61 Issue 9, September 2018, 2018. [72] J. Kreps, N. Narkhede, and J. Rao, “Kafka : a Distributed Messaging System for Log Processing,” NetDB’ 11, June 2012, 2011, 2011. [73] G. Wang et al., “Building a Replicated Logging System with Apache Kafka,” NetDB’ 11, June 2012, 2011, 2011. [74] A. Verma, L. Pedrosa, M. R. Korupolu, D. Oppenheimer, E. Tune, and J. Wilkes, “Large-scale cluster management at Google with Borg,” Proceedings of the European Conference on Computer Systems, Eurosys, 2015. [75] B. Burns, B. Grant, D. Oppenheimer, E. Brewer, and J. Wilkes, “Borg, Omega, and Kubernetes,” ACM Queue, volume 14, pages 70-93, 2016, 2016.
CHAPTER 11. CLOSING THOUGHTS
304
[76] R. G. Brown, “The Corda Platform: An Introduction,” 2018. [77] M. Hearn and R. G. Brown, “Corda: A distributed ledger,” August 2019, version 1.0, 2019. [78] S. Nakamoto, “Bitcoin: A Peer-to-Peer Electronic Cash System,” 2008. [79] J. Dean and S. Ghemawat, “MapReduce: Simplified Data Processing on Large Clusters,” Proceedings of the 6th Conference on Symposium on Operating Systems Design & Implementation, Volume 6, 2004. [80] M. Zaharia, M. Chowdhury, M. J. Franklin, S. Shenker, and I. Stoica, “Spark: Cluster Computing with Working Sets,” Proceedings of the 2Nd USENIX Conference on Hot Topics in Cloud Computing, 2010. [81] M. Zaharia et al., “Resilient Distributed Datasets: A Fault-tolerant Abstraction for In-memory Cluster Computing,” Proceedings of the 9th USENIX Conference on Networked Systems Design and Implementation, 2012. [82] P. Carbone, A. Katsifodimos, S. Ewen, V. Markl, S. Haridi, and K. Tzoumas, “Apache Flink™: Stream and Batch Processing in a Single Engine,” IEEE Data Engineering Bulletin 2015, 2015. [83] P. Carbone, S. Ewen, G. Fora, S. Haridi, S. Richter, and K. Tzoumas, “State Management in Apache Flink: Consistent Stateful Distributed Stream Processing,” Proceedings of the VLDB Endowment, Volume 10 Issue 12, August 2017, 2017. [84] M. Zaharia, T. Das, H. Li, T. Hunter, S. Shenker, and I. Stoica, “Discretized Streams: Fault-tolerant Streaming Computation at Scale,” Proceedings of the Twenty-Fourth ACM Symposium on Operating Systems Principles, 2013. [85] T. Akidau et al., “MillWheel: Fault-tolerant Stream Processing at Internet Scale,” Proceedings of the VLDB Endowment, Volume 6 Issue 11, August 2013, 2013. [86] T. Akidau et al., “The Dataflow Model: A Practical Approach to Balancing Correctness, Latency, and Cost in Massive-scale, Unbounded, Outof-order Data Processing,” Proceedings of the 41st International Conference on Very Large Data Bases, Volume 8 Issue 12, August 2015, 2015. [87] P. Carbone, G. Fora, S. Ewen, S. Haridi, and K. Tzoumas, “Lightweight Asynchronous Snapshots for Distributed Dataflows,” 2015.
CHAPTER 11. CLOSING THOUGHTS
305
[88] C. G. Gray and D. R. Cheriton, “Leases: An Efficient Fault-Tolerant Mechanism for Distributed File Cache Consistency,” Proceedings of the Twelfth ACM Symposium on Operating Systems Principles, 1989. [89] J. H. Saltzer, D. P. Reed, and D. D. Clark, “End-to-End Arguments in System Design,” ACM Transactions in Computer Systems 2, 4, November, 1984, pages 277-288, 1984. [90] T. Moors, “A critical review of End-to-end arguments in system design,” Communications, ICC 2002. IEEE International Conference on, Volume 2, 2002. [91] P. Viotti and M. Vukoliundefined, “Consistency in Non-Transactional Distributed Storage Systems,” ACM Computing Surveys, Volume 49, No. 1, 2016. [92] A. M. Kawazoe, W. Chen, and S. Toueg, “Heartbeat: A timeout-free failure detector for quiescent reliable communication,” 11th International Workshop on Distributed Algorithms, ’97, 1997. [93] K. Birman, “The Promise, and Limitations, of Gossip Protocols,” ACM SIGOPS Operating Systems Review, October 2007, 2007. [94] R. van Renesse, Y. Minsky, and M. Hayden, “A Gossip-Style Failure Detection Service,” Proceedings of the IFIP International Conference on Distributed Systems Platforms and Open Distributed Processing, ’98, 1998. [95] N. Hayashibara, X. Défago, R. Yared, and T. Katayama, “The phi accrual failure detector,” 23rd IEEE International Symposium on Reliable Distributed Systems, 2004. [96] C. MacCárthaigh, “Workload isolation using shuffle-sharding,” The Amazon Builders’ Library, 2019. [97] J. Lamping and E. Veach, “A Fast, Minimal Memory, Consistent Hash Algorithm,” arXiv:1406.2294, 2014. [98] B. Appleton and M. O’Reilly, “Multi-probe consistent hashing,” arXiv:1505.00062, 2015. [99] D. G. Thaler and C. V. Ravishankar, “A Name-Based Mapping Scheme for Rendezvous,” University of Michigan Technical Report CSE-TR-316-96, 2013. [100] H. Howard, D. Malkhi, and A. Spiegelman, “Flexible Paxos: Quorum intersection revisited,” arXiv:1608.06696, 2016.
CHAPTER 11. CLOSING THOUGHTS
306
[101] I. Moraru, D. G. Andersen, and M. Kaminsky, “There is More Consensus in Egalitarian Parliaments,” Proceedings of the 24th ACM Symposium on Operating Systems Principles, 2013. [102] L. Lamport, D. Malkhi, and L. Zhou, “Vertical Paxos and PrimaryBackup Replication,” Proceedings of the 28th Annual ACM Symposium on Principles of Distributed Computing, 2009. [103] R. Van Renesse and D. Altinbuken, “Paxos Made Moderately Complex,” ACM Computing Surveys, February 2015, Article No. 42, 2015. [104] M. Castro and B. Liskov, “Practical Byzantine Fault Tolerance,” Proceedings of the 3rd Symposium on Operating Systems Design and Implementation, 1999. [105] L. Lamport, “Byzantizing paxos by refinement,” Proceedings of the 25th International Conference on Distributed Computing, 2011. [106] M. Shapiro, N. Preguiça, C. Baquero, and M. Zawirski, “Conflict-free Replicated Data Types,” [Research Report] RR-7687, 2011, pp.18. inria00609399v1, 2011. [107] M. Shapiro, N. Preguiça, C. Baquero, and M. Zawirski, “A comprehensive study of Convergent and CommutativeReplicated Data Types,” Research Report] RR-7506, Inria – Centre Paris-Rocquencourt; INRIA. 2011, pp.50. inria-00555588, 2011. [108] P. Bailis, A. Fekete, M. J. Franklin, A. Ghodsi, J. M. Hellerstein, and I. Stoica, “Coordination Avoidance in Database Systems,” VLDB Endowment, vol. 8, November 2014, 2014. [109] J. M. Hellerstein and P. Alvaro, “Keeping CALM: When Distributed Consistency is Easy,” Communications of ACM, vol. 63, August 2020, 2020. [110] B. Lebiednik, A. Mangal, and N. Tiwari, “A Survey and Evaluation of Data Center Network Topologies,” arXiv:1605.01701, 2016. [111] R. Nishtala et al., “Scaling Memcache at Facebook,” Proceedings of the 10th USENIX Conference on Networked Systems Design and Implementation, 2001. [112] N. Bronson et al., “TAO: Facebook’s Distributed Data Store for the Social Graph,” Proceedings of the 2013 USENIX Conference on Annual Technical Conference, 2013.
CHAPTER 11. CLOSING THOUGHTS
307
[113] B. H. Sigelman et al., “TAO: Facebook’s Distributed Data Store for the Social Graph,” 2010. [114] A. Verbitski et al., “Amazon Aurora: On Avoiding Distributed Consensus for I/Os, Commits, and Membership Changes,” Proceedings of the 2018 International Conference on Management of Data, 2018. [115] A. Verbitski et al., “Amazon Aurora: Design Considerations for High Throughput Cloud-Native Relational Databases,” Proceedings of the 2017 ACM International Conference on Management of Data, 2017. [116] J. Hamilton, “On Designing and Deploying Internet-Scale Services,” Proceedings of the 21st Large Installation System Administration Conference (LISA ’07), 2007. [117] E. A. Brewer, “Lessons from Giant-Scale Services,” IEEE Internet Computing, Volume 5, No. 4, 2001. [118] P. Huang et al., “Gray Failure: The Achilles’ Heel of Cloud-Scale Systems,” Proceedings of the 16th Workshop on Hot Topics in Operating Systems, 2017. [119] C. Lou, P. Huang, and S. Smith, “Understanding, Detecting and Localizing Partial Failures in Large System Software,” 17th USENIX Symposium on Networked Systems Design and Implementation, 2020. [120] M. A. Kuppe, L. Lamport, and D. Ricketts, “The TLA+ Toolbox,” arXiv:1912.10633, 2019. [121] C. Newcombe, T. Rath, F. Zhang, B. Munteanu, M. Brooker, and M. Deardeuff, “How Amazon Web Services Uses Formal Methods,” Communications of the ACM, Volume 58, No. 4, 2015.