a curated list of database news from authoritative sources

September 15, 2026

Performance improvements in Percona Server 8.4.11-11

Focusing on Percona Server 8.4.11-11 My previous post (Performance Progression of Percona Server for MySQL 8.4) did a brief review of the performance changes in Percona Server for MySQL 8.4 released in 2026. I recommend reading it first to better understand the material in this post. Version 8.4.11-11 includes patches that deliver significant improvements in … Continued

The post Performance improvements in Percona Server 8.4.11-11 appeared first on Percona.

September 14, 2026

Percona and HexaCluster: Faster, Safer Oracle Migration

Percona and HexaCluster have partnered to remove the hardest part of an open source database migration: getting off Oracle, SQL Server, DB2 or Sybase ASE with confidence, on a predictable timeline, without a multi-year consulting program. Percona brings open source expertise, its own distributions, operators and enterprise support. HexaCluster brings the assessment and migration engineering … Continued

The post Percona and HexaCluster: Faster, Safer Oracle Migration appeared first on Percona.

Resolve Amazon Aurora PostgreSQL lock contention with Database Insights: Part 2

Part 1 showed how row lock contention degrades Amazon Aurora PostgreSQL throughput. In Part 2, use Amazon CloudWatch Database Insights and its Lock Tree to pinpoint blocking sessions, then resolve contention with query termination, timeout parameters, and architectural patterns such as SKIP LOCKED and row splitting that restore throughput.

Troubleshooting row lock contention in Amazon Aurora PostgreSQL: Part 1 – Understanding row lock contention in PostgreSQL

Row lock contention can collapse database throughput during a flash sale even when CPU and I/O look healthy. In Part 1 of this series, learn how PostgreSQL row locking works and how to monitor lock contention in Amazon Aurora PostgreSQL and Amazon RDS for PostgreSQL using system views, the pgrowlocks extension, and the log_lock_waits parameter.

September 12, 2026

Jetpack: Consensus Made Generally Fast (OSDI '26)

Aleksey and I are back to reading papers live. This paper, Jetpack(OSDI '26), attempts building a universal 1-RTT fast-path framework that bolts onto existing leader-based consensus protocols with minimal modification.

Why would we want this? Classic consensus protocols like Raft, Paxos, or Zab require two round-trip times (2 RTT) to commit a command: one RTT from client to leader, and another to replicate across followers. The extra RTT matters a lot for WAN deployments, so fast-path protocols (such as Fast Paxos, EPaxos, or SwiftPaxos) reduce this to 1 RTT by bypassing leader serialization, but unfortunately they tightly couple the fast path to the core protocol design. Production systems cannot easily swap out their battle-tested bespoke consensus engines, but if there was an add on that helped with latency especially in WAN deployments, that would be useful.

The good news is that Jetpack is truly an add-on portable deal. It provides a shim layer that runs two execution paths in parallel: a 1-RTT fast path and the original 2-RTT consensus path. When a client issues a command, it broadcasts the request concurrently to both paths. The fast path checks for key conflicts, and if none exist and a supermajority quorum ($\sim 3/4$ of nodes) issues a promise, this enables the command to fast-commit in 1 RTT. To guarantee agreement, original path proposers promise not to propose conflicting commands ahead of fast-committed ones.

The bad news is that this design gets wasteful due to keeping two distinct logs (the fast-path log and the original-path log). The original consensus engine runs its full replication cycle in the background, ignoring the fast path replication of commands (because it is completely oblivious to the fast path replication in the name of bolt-on portability). So these commands travel in the network twice, and replicas process commands twice, introducing redundant work and extra CPU/network overhead.

This dual-log architecture also creates a bigger gap between commitment and execution. Jetpack fast-commits in 1 RTT, but actual state-machine execution is driven strictly by the underlying original 2RTT path log ordering. Then, what good is a fast commit in practice? If you are running write-heavy, asynchronous pipelines or "fire-and-forget" ingestion where a client issues a PUT(key, val) and immediately moves on to the next task, a 1-RTT durable commit confirmation is a win. However, the fast path only gives you a fast commit, but it does not accelerate state-machine execution. The moment you run an interactive workload, say a client that issues a PUT(key, val) and immediately follows up with a GET(key) expecting read-your-own-writes or linearizability, the fast-path illusion breaks down. Jetpack's shim detects the unexecuted PUT sitting in its in-flight conflict pool and immediately demotes the GET right back to the slow 2-RTT original path to preserve correctness. So, yes, Jetpack stays linearizable, but you pay the full 2-RTT latency tax and wait for the original consensus engine to catch up to answer the GET.

This connects directly to the principle of nil-externality formalized in Exploiting Nil-Externality for Fast Replicated Storage (SOSP '21). Because write commands like PUT return no system state back to the client (they have "nil externality"), Jetpack can safely grant a 1-RTT fast commit before state-machine execution. But, the moment an operation (like a GET) needs to externalize state, that nil-externality optimization breaks down and forces the system to wait for full state-machine execution.

Despite this execution lag and redundant message overhead, the evaluation section shows gains for write-heavy pipelines (I think due to nil-eternality optimization) by benchmarking Jetpack across six consensus systems deployed on 10 AWS datacenters using YCSB workloads and Facebook's Akkio production traces. Because cross-datacenter write requests (such as remote shard updates in Akkio) primarily wait on durable commit confirmation before responding, Jetpack slashes client-observed end-to-end latency by up to 60%.


The paper's biggest safety contribution is to show how the fast-path protocols may break during leader elections. As I noted in my 2020 blog post review of CURP, mixing witness state with backup replicas makes view changes inherently risky. Jetpack proves this by uncovering a concrete bug in CURP’s Raft extension (used in production by Xline), where a lagging witness ACKs a fast-path command, only for a delayed cleanup message from a new leader to erase it, causing permanent data loss (or reordering) after a crash. This happens because promises made in stable views live in local replica states that a new leader never saw. Jetpack fixes this "view change hazard" with two strict principles: keeping fast-path views independent so ACKs cannot straddle terms (Principle 1), and forcing new leaders to write a "stability marker" that recovers all prior fast-committed commands before accepting new proposals (Principle 2).

As Aleksey and I experienced live during our reading session, untangling Jetpack’s three-phase recovery procedure was the trickiest part of the paper. We  struggled to follow why recovery requires only a standard majority rather than a superquorum. But refreshing our understanding of Fast Paxos later showed that Jetpack's recovery mechanism is similar to that of Fast Paxos. Fast Paxos explicitly requires a supermajority quorum ($Q_2 \approx 3/4$ of nodes) for fast-path commits precisely so that leader election and recovery ($Q_1$) can run on a simple majority. Because any supermajority $Q_2$ is mathematically guaranteed to overlap with any standard majority $Q_1$ by at least one node, a newly elected leader polling a simple majority during recovery will always discover any fast-committed command. Still, the quorum math aside, I'll be damned if anyone can call fast-path recovery simple (and dependable).

September 11, 2026

118 million queries per second on Neki

We ran a massive, sharded Postgres database at 118.5 million queries per second, with 200k queries per second on each shard across 512 shards.

September 10, 2026

PostgreSQL MVCC: Why Bloat Doesn't Automatically Mean Expensive Reads

One of the most persistent misconceptions about PostgreSQL MVCC is that old row versions accumulate in a chain until VACUUM removes them, making reads increasingly expensive as updates pile up.

That's not how PostgreSQL works. Space amplification is common in MVCC databases because they need to access multiple versions over time, but this doesn't necessarily lead to read amplification. Databases are built to read only the data they need from a larger dataset.

In this article, I'll demonstrate three important facts:

  • Scans don't walk version chains across pages — Seq Scans examine heap tuples directly and skip invisible ones; Index Scans follow the HOT chain only within a single page; Bitmap Scans behave like one or the other depending on bitmap losiness.
  • Making space reusable in heap and indexes doesn't wait for vacuum — normal reads perform maintenance with hint bits and opportunistic heap pruning, even with autovacuum disabled.
  • Index scans pay the visibility cost once, and mark dead entries LP_DEAD to skip future heap visits.

As a result, PostgreSQL can build up dead tuples and index entries, but read amplification doesn't increase proportionally, and space can be reused over time.

Setup

PostgreSQL MVCC is known for space amplification (called bloat), but it doesn't accumulate old versions forever. Some garbage collection (called vacuum) happens in the background. However, this article focuses on read amplification before vacuum. For the purpose of the demo, I created a table and disabled auto-vacuum:

drop table if exists mvcc_demo;

create table mvcc_demo (
 id int,
 a int,
 b int,
 filler text default repeat('x',1000)
);

alter table mvcc_demo set (autovacuum_enabled = off);

create index on mvcc_demo ( a );
create index on mvcc_demo ( b );

insert into mvcc_demo
select n,n,n
from generate_series(1,8) n;

vacuum analyze;

To inspect heap pages, I prepare a helper query that lists all line pointers in a page except those with length zero, which are only small stubs:

create extension if not exists pageinspect;

prepare show_tuples(int,int) as
select page, lp, t_xmin, t_xmax, t_ctid,
  regexp_replace(t_data::text,'^(\\x)(.{8})(.{8})(.{8})(.{8}).*$','id=\\x\2 a=\\x\3 b=\\x\4 filler=\\x\5...'),
  t_infomask, t_infomask2
 from generate_series($1,$2) page, lateral (
  select * from heap_page_items(get_raw_page('mvcc_demo', page)) where lp_len>0
) order by page, lp
;

execute show_tuples(0,1)
;

The initial state shows eight tuples:

 page | lp | t_xmin | t_xmax | t_ctid | t_infomask |                        regexp_replace
------+----+--------+--------+--------+------------+--------------------------------------------------------------
    0 |  1 |    697 |      0 | (0,1)  |       2306 | id=\x01000000 a=\x01000000 b=\x01000000 filler=\xb00f0000...
    0 |  2 |    697 |      0 | (0,2)  |       2306 | id=\x02000000 a=\x02000000 b=\x02000000 filler=\xb00f0000...
    0 |  3 |    697 |      0 | (0,3)  |       2306 | id=\x03000000 a=\x03000000 b=\x03000000 filler=\xb00f0000...
    0 |  4 |    697 |      0 | (0,4)  |       2306 | id=\x04000000 a=\x04000000 b=\x04000000 filler=\xb00f0000...
    0 |  5 |    697 |      0 | (0,5)  |       2306 | id=\x05000000 a=\x05000000 b=\x05000000 filler=\xb00f0000...
    0 |  6 |    697 |      0 | (0,6)  |       2306 | id=\x06000000 a=\x06000000 b=\x06000000 filler=\xb00f0000...
    0 |  7 |    697 |      0 | (0,7)  |       2306 | id=\x07000000 a=\x07000000 b=\x07000000 filler=\xb00f0000...
    1 |  1 |    697 |      0 | (1,1)  |       2306 | id=\x08000000 a=\x08000000 b=\x08000000 filler=\xb00f0000...
(8 rows)

The value 0 of t_xmax and the t_ctid pointing to itself indicate that the tuple is the current version of the row.

Creating a version chain

I'll update one row several times with the following statement:

postgres=# update mvcc_demo
           set a=a+1
           where id=1
;
UPDATE 1

The first update created a new row version on another page because there was no space on the same page. The original tuple is updated with t_ctid storing the address of the next version, and receives its end of visibility in xmax:

 page | lp | t_xmin | t_xmax | t_ctid | t_infomask |                        regexp_replace
------+----+--------+--------+--------+------------+--------------------------------------------------------------
    0 |  1 |    697 |    740 | (1,2)  |        258 | id=\x01000000 a=\x01000000 b=\x01000000 filler=\xb00f0000...
    0 |  2 |    697 |      0 | (0,2)  |       2306 | id=\x02000000 a=\x02000000 b=\x02000000 filler=\xb00f0000...
    0 |  3 |    697 |      0 | (0,3)  |       2306 | id=\x03000000 a=\x03000000 b=\x03000000 filler=\xb00f0000...
    0 |  4 |    697 |      0 | (0,4)  |       2306 | id=\x04000000 a=\x04000000 b=\x04000000 filler=\xb00f0000...
    0 |  5 |    697 |      0 | (0,5)  |       2306 | id=\x05000000 a=\x05000000 b=\x05000000 filler=\xb00f0000...
    0 |  6 |    697 |      0 | (0,6)  |       2306 | id=\x06000000 a=\x06000000 b=\x06000000 filler=\xb00f0000...
    0 |  7 |    697 |      0 | (0,7)  |       2306 | id=\x07000000 a=\x07000000 b=\x07000000 filler=\xb00f0000...
    1 |  1 |    697 |      0 | (1,1)  |       2306 | id=\x08000000 a=\x08000000 b=\x08000000 filler=\xb00f0000...
    1 |  2 |    740 |      0 | (1,2)  |      10242 | id=\x01000000 a=\x02000000 b=\x01000000 filler=\xb00f0000...
(9 rows)

This t_ctid is what makes people think every read must follow a growing chain of versions. Ordinary visibility checks don't work that way because:

  • if the query's read snapshot is between xmin and xmax, this is the right row, and there's no need to get another one
  • if the tuple is not visible to the snapshot, it is skipped. The scan continues normally and may encounter another version of the same logical row elsewhere in the heap.

Here, the row with id=1 (\x01000000) has two versions in two pages - it's not a HOT (heap-only tuple) update. The new version was inserted on a page with free space.

After a second update, the same happens, but there is free space on the same page, so the new version is inserted there, with two versions of id=1 (\x01000000) on page 1:

 page | lp | t_xmin | t_xmax | t_ctid | t_infomask |                        regexp_replace
------+----+--------+--------+--------+------------+--------------------------------------------------------------
    0 |  2 |    697 |      0 | (0,2)  |       2306 | id=\x02000000 a=\x02000000 b=\x02000000 filler=\xb00f0000...
    0 |  3 |    697 |      0 | (0,3)  |       2306 | id=\x03000000 a=\x03000000 b=\x03000000 filler=\xb00f0000...
    0 |  4 |    697 |      0 | (0,4)  |       2306 | id=\x04000000 a=\x04000000 b=\x04000000 filler=\xb00f0000...
    0 |  5 |    697 |      0 | (0,5)  |       2306 | id=\x05000000 a=\x05000000 b=\x05000000 filler=\xb00f0000...
    0 |  6 |    697 |      0 | (0,6)  |       2306 | id=\x06000000 a=\x06000000 b=\x06000000 filler=\xb00f0000...
    0 |  7 |    697 |      0 | (0,7)  |       2306 | id=\x07000000 a=\x07000000 b=\x07000000 filler=\xb00f0000...
    1 |  1 |    697 |      0 | (1,1)  |       2306 | id=\x08000000 a=\x08000000 b=\x08000000 filler=\xb00f0000...
    1 |  2 |    740 |    741 | (1,3)  |       8450 | id=\x01000000 a=\x02000000 b=\x01000000 filler=\xb00f0000...
    1 |  3 |    741 |      0 | (1,3)  |      10242 | id=\x01000000 a=\x03000000 b=\x01000000 filler=\xb00f0000...
(9 rows)

Actually, all versions of id=1 (\x01000000) are on the same page because the initial version has disappeared from the first page even without vacuum, proof that free space is released even before vacuum runs. What happened is that the update has read the first page and did some cleanup while the buffer was pinned.

This is proof that garbage collection can happen without vacuum, simply when UPDATE, DELETE, or SELECT reads after the update. It is called opportunistic pruning: pruning is attempted whenever a page's free space heuristically looks low, or a page previously failed to fit an updated tuple. Space reclamation happens during tuple retrieval when the page is full or nearly full (<10% free or fillfactor target) and a buffer cleanup lock can be acquired.

Additionally, when the UPDATE has to move a new version to a different page because there isn't room, it flags the old page as full. Here, there was space to place the new version on the same page.

Here is a third update that adds another version:

 page | lp | t_xmin | t_xmax | t_ctid | t_infomask |                        regexp_replace
------+----+--------+--------+--------+------------+--------------------------------------------------------------
    0 |  2 |    697 |      0 | (0,2)  |       2306 | id=\x02000000 a=\x02000000 b=\x02000000 filler=\xb00f0000...
    0 |  3 |    697 |      0 | (0,3)  |       2306 | id=\x03000000 a=\x03000000 b=\x03000000 filler=\xb00f0000...
    0 |  4 |    697 |      0 | (0,4)  |       2306 | id=\x04000000 a=\x04000000 b=\x04000000 filler=\xb00f0000...
    0 |  5 |    697 |      0 | (0,5)  |       2306 | id=\x05000000 a=\x05000000 b=\x05000000 filler=\xb00f0000...
    0 |  6 |    697 |      0 | (0,6)  |       2306 | id=\x06000000 a=\x06000000 b=\x06000000 filler=\xb00f0000...
    0 |  7 |    697 |      0 | (0,7)  |       2306 | id=\x07000000 a=\x07000000 b=\x07000000 filler=\xb00f0000...
    1 |  1 |    697 |      0 | (1,1)  |       2306 | id=\x08000000 a=\x08000000 b=\x08000000 filler=\xb00f0000...
    1 |  2 |    740 |    741 | (1,3)  |       9474 | id=\x01000000 a=\x02000000 b=\x01000000 filler=\xb00f0000...
    1 |  3 |    741 |    742 | (1,4)  |       8450 | id=\x01000000 a=\x03000000 b=\x01000000 filler=\xb00f0000...
    1 |  4 |    742 |      0 | (1,4)  |      10242 | id=\x01000000 a=\x04000000 b=\x01000000 filler=\xb00f0000...
(10 rows)

The row id=1 (\x01000000) started with a=1 (\x01000000), then updated to a=2 (\x02000000), a=3 (\x03000000), and a=4 (\x04000000). Because no open transactions need to read those old values, Postgres cleans them up when possible to free space on the page.

In this example, I update an indexed column, so even if the new version lands on the same page, this isn't a HOT update—a separate index entry is created. Because the old version might still be referenced by an index entry, ordinary read-triggered pruning cannot fully discard its line pointer. It is still there with a length of zero, which I filter out with lp_len>0 - so that id=1 (\x01000000), a=1 (\x01000000) disappeared.

However, page defragmentation reclaimed the tuple storage even though the line pointer is retained as a stub, and this space can be reused before any VACUUM runs and removes the line pointer. I update another row, id=2 (\x02000000) in the first page, and the new version fits there in a new line pointer of the same page:

postgres=# update mvcc_demo
           set a=a+1
           where id=2
;
UPDATE 1
postgres=# execute show_tuples(0,1)
;
 page | lp | t_xmin | t_xmax | t_ctid | t_infomask |                        regexp_replace
------+----+--------+--------+--------+------------+--------------------------------------------------------------
    0 |  2 |    697 |    743 | (0,8)  |        258 | id=\x02000000 a=\x02000000 b=\x02000000 filler=\xb00f0000...
    0 |  3 |    697 |      0 | (0,3)  |       2306 | id=\x03000000 a=\x03000000 b=\x03000000 filler=\xb00f0000...
    0 |  4 |    697 |      0 | (0,4)  |       2306 | id=\x04000000 a=\x04000000 b=\x04000000 filler=\xb00f0000...
    0 |  5 |    697 |      0 | (0,5)  |       2306 | id=\x05000000 a=\x05000000 b=\x05000000 filler=\xb00f0000...
    0 |  6 |    697 |      0 | (0,6)  |       2306 | id=\x06000000 a=\x06000000 b=\x06000000 filler=\xb00f0000...
    0 |  7 |    697 |      0 | (0,7)  |       2306 | id=\x07000000 a=\x07000000 b=\x07000000 filler=\xb00f0000...
    0 |  8 |    743 |      0 | (0,8)  |      10242 | id=\x02000000 a=\x03000000 b=\x02000000 filler=\xb00f0000...
    1 |  1 |    697 |      0 | (1,1)  |       2306 | id=\x08000000 a=\x08000000 b=\x08000000 filler=\xb00f0000...
    1 |  2 |    740 |    741 | (1,3)  |       9474 | id=\x01000000 a=\x02000000 b=\x01000000 filler=\xb00f0000...
    1 |  3 |    741 |    742 | (1,4)  |       9474 | id=\x01000000 a=\x03000000 b=\x01000000 filler=\xb00f0000...
    1 |  4 |    742 |      0 | (1,4)  |      10498 | id=\x01000000 a=\x04000000 b=\x01000000 filler=\xb00f0000...
(11 rows)

The two versions of id=2 (\x02000000) are on the same page. This page is now full again, and another update, on id =3 (\ x03000000), will need to insert its new version on another page:

postgres=# update mvcc_demo
           set a=a+1
           where id=3
;
UPDATE 1
postgres=# execute show_tuples(0,1)
;
 page | lp | t_xmin | t_xmax | t_ctid | t_infomask |                        regexp_replace
------+----+--------+--------+--------+------------+--------------------------------------------------------------
    0 |  2 |    697 |    743 | (0,8)  |       1282 | id=\x02000000 a=\x02000000 b=\x02000000 filler=\xb00f0000...
    0 |  3 |    697 |    744 | (1,
                                        by Franck Pachot
                                    

Introducing Neki

Neki, sharded Postgres by PlanetScale, is now available in platform preview.

September 09, 2026

Enabling TLS in PXC without Downtime

Starting with Percona XtraDB Cluster (PXC) 8.0, replication traffic encryption is enabled by default. That said, it’s common to find clusters running without TLS that suddenly need it: a new compliance requirement, an audit finding, a network segment that is no longer considered trusted. PXC has a variable for exactly that case, pxc-encrypt-cluster-traffic, which handles … Continued

The post Enabling TLS in PXC without Downtime appeared first on Percona.

Percona Operator for PostgreSQL 3.1.0: Transparent Data Encryption, Logical Replicas, and Persistent Logging

Percona Operator for PostgreSQL 3.1.0 takes on three things that decide whether a PostgreSQL platform passes review: is the data encrypted at rest, can it serve reads without straining the primary, and are the logs there when you need them. This release answers all three inside the custom resource, so none of them is a … Continued

The post Percona Operator for PostgreSQL 3.1.0: Transparent Data Encryption, Logical Replicas, and Persistent Logging appeared first on Percona.

Meet pgstef: Why Stefan Fercot Joined Percona, and Why You Should Find Him at Percona Live Amsterdam

If you have spent any time in the Postgres community, you already know the name pgstef. Stefan Fercot has spent years as one of the most visible advocates for pgBackRest, a familiar face at European Postgres conferences, and countless hallway conversations about backups, high availability, and everything in between. Now he is doing that work … Continued

The post Meet pgstef: Why Stefan Fercot Joined Percona, and Why You Should Find Him at Percona Live Amsterdam appeared first on Percona.

Building async Python applications with Tortoise ORM and Amazon Aurora DSQL

Build a high-concurrency async Python rideshare application with Tortoise ORM and Amazon Aurora DSQL. This post walks through the key adaptations: UUID primary keys, IAM-authenticated asyncpg connections with a connection-pool patch, individual DDL execution, and optimistic concurrency control (OCC) retry logic.

September 08, 2026