Conflict-free Replicated Data Types v3.7
Conflict-free replicated data types (CRDT) support merging values from concurrently modified rows, instead of discarding one of the rows (which is what traditional conflict resolution does).
Each CRDT type is implemented as a separate PostgreSQL data type, with
an extra callback added to the bdr.crdt_handlers
catalog. The merge
process happens within pglogical on the apply side; no additional user
action is required.
CRDTs require the table to have column-level conflict resolution enabled as documented in the CLCD chapter.
The only action taken by the user is the use of a particular data type in CREATE/ALTER TABLE, rather than standard built-in data types such as integer; e.g. consider the following table with one regular integer counter and a single row:
If we issue the following SQL on two nodes at same time:
... the resulting values can be seen using this query, after both updates are applied:
...showing that we've lost one of the increments, due to the update_if_newer conflict resolver. If you use the CRDT counter data type instead, you should observe something like this:
Again we issue the following SQL on two nodes at same time, then wait for the changes to be applied:
This shows that CRDTs correctly allow accumulator columns to work, even in the face of asynchronous concurrent updates that otherwise conflict.
The crdt_gcounter
type is an example of state-based CRDT types, that
work only with reflexive UPDATE SQL, such as x = x + 1
, as shown above.
The bdr.crdt_raw_value
configuration option determines whether queries
return the current value or the full internal state of the CRDT type. By
default only the current numeric value is returned. When set to true
,
queries return representation of the full state - the special hash operator
(#
) may be used to request only the current numeric value without using the
special operator (this is the default behavior). If the full state is
dumped using bdr.crdt_raw_value = on
then the value would only be able
to be reloaed with bdr.crdt_raw_value = on
.
Note: The bdr.crdt_raw_value
applies only formatting of data returned
to clients; i.e. simple column references in the select list. Any column
references in other parts of the query (e.g. WHERE
clause or even
expressions in the select list) may still require use of the #
operator.
Another class of CRDT data types exists, which we refer to as "delta CRDT" types (and are a special subclass of operation-based CRDTs, as explained later).
With delta CRDTs, any update to a value is automatically compared to the previous value on the same node and then a change is applied as a delta on all other nodes.
If we issue the following SQL on two nodes at same time:
The resulting values can be seen using this query, after both updates are applied:
With a regular integer
column the result would be 2
, of course. But
when we UPDATE the row with a delta CRDT counter, we start with the OLD
row version, make a NEW row version and send both to the remote node,
where we compare them with the version we find there (let's call that
the LOCAL version). Standard CRDTs merge the NEW and the LOCAL version,
while delta CRDTs compare the OLD and NEW versions and apply the delta
to the LOCAL version.
The CRDT types are installed as part of bdr
into the bdr
schema.
For convenience, the basic operators ( +
, #
and !
) and a number
of common aggregate functions (min
, max
, sum
and avg
) are
created in pg_catalog
, to make them available without having to tweak
search_path
.
An important question is how query planning and optimization works with these
new data types. CRDT types are handled transparently - both ANALYZE
and
the optimizer work, so estimation and query planning works fine, without
having to do anything else.
Note
This feature is currently only available on EDB Postgres Extended and EDB Postgres Advanced.
State-based and operation-based CRDTs
Following the notation from [1], we do implement both operation-based and state-based CRDTs.
Operation-based CRDT Types (CmCRDT)
The implementation of operation-based types is quite trivial, because the operation is not transferred explicitly but computed from the old and new row received from the remote node.
Currently, we implement these operation-based CRDTs:
crdt_delta_counter
-bigint
counter (increments/decrements)crdt_delta_sum
-numeric
sum (increments/decrements)
These types leverage existing data types (e.g. crdt_delta_counter
is
a domain on a bigint
), with a little bit of code to compute the delta.
This approach is possible only for types where we know how to compute the delta, but the result is very simple and cheap (both in terms of space and CPU), and has a couple of additional benefits (e.g. we can leverage operators / syntax for the under-lying data type).
The main disadvantage is that it's not possible to reset this value reliably in an asynchronous and concurrent environment.
Note: We could also implement more complicated operation-based types by creating custom data types, storing the state and the last operation (we decode and transfer every individual change, so we don't need multiple operations). But at that point we lose the main benefits (simplicity, reuse of existing data types) without gaining any advantage compared to state-based types (still no capability to reset, ...), except for the space requirements (we don't need a per-node state).
State-based CRDT Types (CvCRDT)
State-based types require a more complex internal state, and so can't use the regular data types directly the way operation-based types do.
Currently, we implement four state-based CRDTs:
crdt_gcounter
-bigint
counter (increment-only)crdt_gsum
-numeric
sum/counter (increment-only)crdt_pncounter
-bigint
counter (increments/decrements)crdt_pnsum
-numeric
sum/counter (increments/decrements)
The internal state typically includes per-node information, increasing the on-disk size but allowing additional benefits. The need to implement custom data types implies more code (in/out functions and operators).
The advantage is the ability to reliably reset the values, a somewhat self-healing nature in the presence of lost changes (which should not happen in properly-operated cluster), and the ability to receive changes from other than source nodes.
Consider for example that a value is modified on node A, and the change gets replicated to B, but not C (due to network issue between A and C). If B modifies the value, and this change gets replicated to C, it will include even the original change from A. With operation-based CRDTs the node C would not receive the change until the A-C network connection starts working again.
The main disadvantages of CvCRDTs are higher costs, both in terms of disk space - we need a bit of information for each node, including nodes that have been already removed from the cluster). The complex nature of the state (serialized into varlena types) means increased CPU usage.
Disk-Space Requirements
An important consideration is the overhead associated with CRDT types, particularly the on-disk size.
For operation-based types this is rather trivial, because the types are merely domains on top of other types, and so have the same disk space requirements (no matter how many nodes are there).
crdt_delta_counter
- same asbigint
(8 bytes)crdt_delta_sum
- same asnumeric
(variable, depending on precision and scale)
There is no dependency on the number of nodes, because operation-based CRDT types do not store any per-node information.
For state-based types the situation is more complicated. All the types
are variable-length (stored essentially as a bytea
column), and consist
of a header and a certain amount of per-node information for each node
that modified the value.
For the bigint
variants, formulas computing approximate size are ( N
denotes the number of nodes that modified this value):
crdt_gcounter
-32B (header) + N * 12B (per-node)
crdt_pncounter
-48B (header) + N * 20B (per-node)
For the numeric
variants there is no exact formula, because both the
header and per-node parts include numeric
variable-length values. To
give you an idea of how many such values we need to keep:
crdt_gsum
- fixed:
20B (header) + N * 4B (per-node)
- variable:
(2 + N)
numeric
values
- fixed:
crdt_pnsum
- fixed:
20B (header) + N * 4B (per-node)
- variable:
(4 + 2 * N)
numeric
values
- fixed:
Note: It does not matter how many nodes are in the cluster, if the values are never updated on multiple nodes. It also does not matter if the updates were concurrent (causing a conflict) or not.
Note: It also does not matter how many of those nodes were already removed from the cluster. There is no way to compact the state yet.
CRDT Types vs Conflicts Handling
As tables may contain both CRDT and non-CRDT columns (in fact, most columns are expected to be non-CRDT), we need to do both the regular conflict resolution and CRDT merge.
The conflict resolution happens first, and is responsible for deciding which tuple to keep (applytuple) and which one to discard. The merge phase happens next, merging data for CRDT columns from the discarded tuple into the applytuple.
Note: This makes CRDT types somewhat more expensive compared to plain conflict resolution, because the merge needs to happen every time, even when the conflict resolution can use one of the fast-paths (modified in the current transaction, etc.).
CRDT Types vs. Conflict Reporting
By default, detected conflicts are written into the server log. Without CRDT types this makes perfect sense, because the conflict resolution essentially throws away one half of the available information (local or remote row, depending on configuration). This presents a data loss.
CRDT types allow both parts of the information to be combined without throwing anything away, eliminating the data loss issue. This makes the conflict reporting unnecessary.
For this reason, we skip the conflict reporting when the conflict can be fully-resolved by CRDT merge, that is if each column meets at least one of these two conditions:
1) the values in local and remote tuple are the same (NULL or equal)
2) it uses a CRDT data type (and so can be merged)
Note: This means we also skip the conflict reporting when there are no CRDT columns, but all values in local/remote tuples are equal.
Resetting CRDT Values
Resetting CRDT values is possible but requires special handling. The asynchronous nature of the cluster means that different nodes may see the reset operation (no matter how it's implemented) at different places in the change stream. Different nodes may also initiate a reset concurrently; i.e. before observing the reset from the other node.
In other words, to make the reset operation behave correctly, it needs to be commutative with respect to the regular operations. Many naive ways to reset a value (which may work perfectly well on a single-node) fail for exactly this reason.
For example, the simplest approach to resetting a value might be:
With state-based CRDTs this does not work - it throws away the state for the other nodes, but only locally. It will be added back by merge functions on remote nodes, causing diverging values, and eventually receiving it back due to changes on the other nodes.
With operation-based CRDTs, this may seem to work because the
update is interpreted as a subtraction of -cnt
. But it only works in the
absence of concurrent resets. Once two nodes attempt to do a reset at
the same time, we'll end up applying the delta twice, getting a negative
value (which is not what we expected from a reset).
It might also seem that DELETE + INSERT
can be used as a reset, but this
has a couple of weaknesses too. If the row is reinserted with the same
key, it's not guaranteed that all nodes will see it at the same position in
the stream of operations (with respect to changes from other nodes).
BDR specifically discourages re-using the same Primary Key value since
it can lead to data anomalies in concurrent cases.
State-based CRDT types can reliably handle resets,
using a special !
operator like this:
By "reliably" we mean the values do not have the two issues illustrated above - multiple concurrent resets and divergence.
Operation-based CRDT types can only be reset reliably using Eager Replication, since this avoids multiple concurrent resets. Eager Replication can also be used to set either kind of CRDT to a specific value.
Implemented CRDT data types
Currently there are six CRDT data types implemented - grow-only counter
and sum, positive-negative counter and sum, and delta counter and sum.
The counters and sums behave mostly the same, except that the "counter" types
are integer-based (bigint
), while the "sum" types are decimal-based
(numeric
).
Additional CRDT types, described at [1], may be implemented later.
The currently implemented CRDT data types can be listed with the following query:
grow-only counter (crdt_gcounter
)
supports only increments with non-negative values (
value + int
andcounter + bigint
operators)current value of the counter can be obtained either using
#
operator or by casting it tobigint
is not compatible with simple assignments like
counter = value
(which is common pattern when the new value is computed somewhere in the application)allows simple reset of the counter, using the
!
operator (counter = !counter
)internal state can be inspected using
crdt_gcounter_to_text
grow-only sum (crdt_gsum
)
supports only increments with non-negative values (
sum + numeric
)current value of the sum can be obtained either by using
#
operator or by casting it tonumeric
is not compatible with simple assignments like
sum = value
(which is the common pattern when the new value is computed somewhere in the application)allows simple reset of the sum, using the
!
operator (sum = !sum
)internal state can be inspected using
crdt_gsum_to_text
positive-negative counter (crdt_pncounter
)
supports increments with both positive and negative values (through
counter + int
andcounter + bigint
operators)current value of the counter can be obtained either by using
#
operator or by casting tobigint
is not compatible with simple assignments like
counter = value
(which is the common pattern when the new value is computed somewhere in the application)allows simple reset of the counter, using the
!
operator (counter = !counter
)internal state can be inspected using
crdt_pncounter_to_text
positive-negative sum (crdt_pnsum
)
supports increments with both positive and negative values (through
sum + numeric
)current value of the sum can be obtained either by using
#
operator or by casting tonumeric
is not compatible with simple assignments like
sum = value
(which is the common pattern when the new value is computed somewhere in the application)allows simple reset of the sum, using the
!
operator (sum = !sum
)internal state can be inspected using
crdt_pnsum_to_text
delta counter (crdt_delta_counter
)
is defined a
bigint
domain, so works exactly like abigint
columnsupports increments with both positive and negative values
is compatible with simple assignments like
counter = value
(common when the new value is computed somewhere in the application)no simple way to reset the value (reliably)
delta sum (crdt_delta_sum
)
is defined as a
numeric
domain, so works exactly like anumeric
columnsupports increments with both positive and negative values
is compatible with simple assignments like
sum = value
(common when the new value is computed somewhere in the application)no simple way to reset the value (reliably)
[1] https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type