Mehdi Akiki
Published on

Group Commit: How Databases Make fsync Cheaper

Authors
  • Mehdi Akiki avatar
    Name
    Mehdi Akiki
    Twitter

Investigation · Part 16 of 20 · What survives a crash

In part 9 my store made about 1,960 puts per second durable, and each put needed only about 2 microseconds without the sync. The limit was the fdatasync after each put, around half a millisecond on this laptop (part 2). So what happens when 16 clients write at the same time? Do they get 16 times more, or do they wait in a line?

To answer it, I made a small Rust program. And the answer depends completely on one design decision.

The test

Records of 100 bytes go to one log file. There are 1, 2, 4, 8 or 16 writer threads, each one runs for one second, and the rule is the rule of the whole series since its first part: a writer may continue only when its record is durable. The program measures records per second, the latency of each commit (from the call to the return, p50 and p99), how many records each fdatasync covered on average, and the flush requests that the disk received. There are three strategies, and here is the first.

Every writer for itself

Each writer takes a lock, writes its record, calls fdatasync, and releases the lock:

== records of 100 bytes, each one durable before its writer continues, 1 second per line, machine load: load average: 3.57, 3.56, 3.65
  alone         1 writers:   1867 records/s, p50    485 us, p99   1177 us,  1.00 records per fdatasync, 100.21 disk flushes per 100 records
  alone         2 writers:   1607 records/s, p50    543 us, p99   3953 us,  1.00 records per fdatasync, 100.00 disk flushes per 100 records
  alone         4 writers:   2038 records/s, p50    449 us, p99   1011 us,  1.00 records per fdatasync, 100.00 disk flushes per 100 records
  alone         8 writers:   1969 records/s, p50    451 us, p99   3295 us,  1.00 records per fdatasync, 100.20 disk flushes per 100 records
  alone        16 writers:   1828 records/s, p50    456 us, p99 181372 us,  1.00 records per fdatasync, 100.00 disk flushes per 100 records

The throughput does not move: around 1,600 to 2,000 records per second, with one writer or with sixteen. Each record has its own fdatasync, so the disk received about one flush per record, and the disk is the line where everybody waits.

The p50 latency looks fine, about 450 microseconds: most calls get the lock quickly. But look at the p99 with 16 writers: 181,372 microseconds, 0.18 seconds. The documentation of Rust's Mutex promises that one thread at a time holds the lock, and it says nothing about which waiting thread gets it next. My reading is that some threads got it again and again, while one percent of the commits waited a very long time. Sixteen threads wait for one lock, and nothing promises them a turn.

One fsync for everybody

The second strategy is group commit. The writers put their records into a shared queue. If nobody is syncing, the writer becomes the leader: it takes everything in the queue, writes it with one write call, calls fdatasync once, marks all these records as durable, and wakes everybody. If a leader is already busy, the writer waits, and either its record is in the next batch, or it becomes the next leader itself:

    fn commit_grouped(&self, record: &[u8]) {
        let mut q = self.queue.lock().unwrap();
        let mine = q.next;
        q.next += 1;
        q.pending.extend_from_slice(record);
        while q.durable <= mine {
            if q.leader {
                q = self.done.wait(q).unwrap();
                continue;
            }
            q.leader = true;
            if !self.delay.is_zero() {
                drop(q);
                thread::sleep(self.delay);
                q = self.queue.lock().unwrap();
            }
            let batch = std::mem::take(&mut q.pending);
            let upto = q.next;
            drop(q);
            let mut file = self.file.lock().unwrap();
            file.write_all(&batch).expect("write");
            file.sync_data().expect("fdatasync");
            drop(file);
            q = self.queue.lock().unwrap();
            q.durable = upto;
            q.leader = false;
            q.syncs += 1;
            self.done.notify_all();
        }
    }

The important line for correctness is the while: a writer returns only when durable is past its own sequence number. Nobody returns because "somebody is syncing". It returns because the sync that contained its record has finished. (The delay part is the third strategy, below.)

  group         1 writers:   1539 records/s, p50    486 us, p99   4375 us,  1.00 records per fdatasync, 100.19 disk flushes per 100 records
  group         2 writers:   2968 records/s, p50    539 us, p99   1476 us,  1.50 records per fdatasync, 66.69 disk flushes per 100 records
  group         4 writers:   4752 records/s, p50    867 us, p99   2210 us,  2.49 records per fdatasync, 40.21 disk flushes per 100 records
  group         8 writers:   8554 records/s, p50    922 us, p99   1975 us,  4.39 records per fdatasync, 22.85 disk flushes per 100 records
  group        16 writers:  15822 records/s, p50    945 us, p99   4283 us,  8.31 records per fdatasync, 12.04 disk flushes per 100 records

With one writer there is nobody to share with, so nothing gets better (1,539 records per second here against 1,867; the alone lines already move between 1,607 and 2,038). But with 16 writers it is another story: 15,822 records per second, more than eight times the first strategy, with 8.31 records per fdatasync and 12 flushes per 100 records. The durability is the same, every record is synced before its writer continues. Only the cost is shared.

The price is latency. With 16 writers, the p50 went from 456 microseconds, every writer alone, to 945 with group commit. A writer that arrives while a sync is running must wait for that sync to finish, and then for the next one, which contains its record: about two syncs instead of one.

And why 8 records per sync with 16 writers, not 16? My reading of the numbers: the writers split into two groups. While the leader syncs a batch of about eight, the eight other writers put their records into the queue. When the sync ends, the first eight wake up and immediately add new records, but the next leader takes the queue at once, with the waiting eight. So the batches alternate between two halves.

Waiting on purpose

What if the leader waits a little before it takes the queue, so that more records arrive? The third strategy waits 200 microseconds:

  group+delay   1 writers:   1187 records/s, p50    793 us, p99   1453 us,  1.00 records per fdatasync, 100.00 disk flushes per 100 records
  group+delay   2 writers:   2447 records/s, p50    772 us, p99   1406 us,  2.00 records per fdatasync, 50.12 disk flushes per 100 records
  group+delay   4 writers:   4881 records/s, p50    791 us, p99   1209 us,  4.00 records per fdatasync, 25.00 disk flushes per 100 records
  group+delay   8 writers:  10170 records/s, p50    736 us, p99   1885 us,  7.99 records per fdatasync, 12.51 disk flushes per 100 records
  group+delay  16 writers:  17012 records/s, p50    834 us, p99   3953 us, 15.96 records per fdatasync,  6.28 disk flushes per 100 records

For one writer it is only a loss: 1,187 records per second instead of 1,539, because every commit now waits 200 microseconds for nobody. For many writers it works: the batches are full (15.96 records per sync with 16 writers), the throughput is the best of the three, 17,012 per second, and even the p50 latency is lower than without the delay, because the two alternating halves become one batch. This one surprised me: I expected the delay to make every commit slower.

PostgreSQL has this knob, and its documentation describes this result in advance. commit_delay "can improve group commit throughput by allowing a larger number of transactions to commit via a single WAL flush, if system load is high enough that additional transactions become ready to commit within the given interval. However, it also increases latency by up to the commit_delay for each WAL flush." And then the part that my one-writer line shows: "Because the delay is just wasted if no other transactions become ready to commit, a delay is only performed if at least commit_siblings other transactions are active when a flush is about to be initiated." The default commit_delay is zero, and commit_siblings is five. A good default: wait only when there is somebody to wait for.

Redis uses the same idea for its strictest mode: the persistence documentation says that appendfsync always "supports group commit, so if there are multiple parallel writes Redis will try to perform a single fsync operation."

What group commit does not do

First, one commit is not faster. A single writer still pays a full sync, and with a delay it pays even more.

The promise also stays the same, and this is the difference with the ack-before-fsync variant of part 9, which also made the writer continue earlier, but by lying. Here every ok still comes after the fdatasync that covers the record.

And group commit needs concurrency: with few writers there is nothing to group, and the code is more complex than a lock: a writer that returns one batch too early is a durability bug that no benchmark will show.

So my opinion: if a system must make many small things durable, a correct way to pay less is to share the sync, not to skip it. And the delay is a setting to tune with the real number of concurrent writers, not a constant to copy.

The program is groupcommit.rs in the fixture, and the full output is p16-groupcommit.txt.

Further reading