3 Pages and Tuples
PostgreSQL 14 · Translated from Russian by Liudmila Mantrova · Chinese translation by Xiong Cancan
3.1 Page Structure
Each page has a certain inner layout that usually consists of the following parts:1
page header
an array of item pointers
free space
items (row versions)
special space
Page Header
The page header is located in the lowest addresses and has a fixed size. It stores various information about the page, such as its checksum and the sizes of all the other parts of the page. (p. 104)
These sizes can be easily displayed using the pageinspect extension.2 Let’s take a look at the first page of the table (page numbering is zero-based):
Special Space
The special space is located in the opposite part of the page, taking its highest addresses. It is used by some indexes to store auxiliary information; in other indexes and table pages this space is zero-sized.
In general, the layout of index pages is quite diverse; their content largely depends on a particular index type. Even the same index can have different kinds of pages: for example, B-trees have a metadata page of a special structure (page zero) and regular pages that are very similar to table pages.
Tuples
Rows contain the actual data stored in the database, together with some additional information. They are located just before the special space.
In the case of tables, we have to deal with row versions rather than rows because multiversion concurrency control implies having several versions of the same row. Indexes do not use this MVCC mechanism; instead, they have to reference all the available row versions, falling back on visibility rules to select the appropriate ones.
Both table row versions and index entries are often referred to as tuples. This term is borrowed from the relational theory—it is yet another legacy of PostgreSQL’s academic past.
Item Pointers
The array of pointers to tuples serves as the page’s table of contents. It is located right after the header.
Index entries have to refer to particular heap tuples somehow. PostgreSQL employs six-byte tuple identifiers (TIDs) for this purpose. Each TID consists of the page number of the main fork and a reference to a particular row version located in this page. (p. 25)
In theory, tuples could be referred to by their offset from the start of the page. But then it would be impossible to move tuples within pages without breaking these references, which in turn would lead to page fragmentation and other unpleasant consequences.
For this reason, PostgreSQL uses indirect addressing: a tuple identifier refers to the corresponding pointer number, and this pointer specifies the current offset of the tuple. If the tuple is moved within the page, its TID still remains the same; it is enough to modify the pointer, which is also located in this page.
Each pointer takes exactly four bytes and contains the following data:
tuple offset from the start of the page
tuple length
several bits defining the tuple status
Free Space
Pages can have some free space left between pointers and tuples (which is reflected in the free space map). There is no page fragmentation: all the free space available is always aggregated into one chunk.3 (p. 26)
3.2 Row Version Layout
Each row version contains a header followed by actual data. The header consists of multiple fields, including the following:
xmin, xmax represent transaction IDs; they are used to differentiate between this and other versions of the same row.
infomask provides a set of information bits that define version properties.
ctid is a pointer to the next updated version of the same row.
null bitmap is an array of bits marking the columns that can contain NULL values.
As a result, the header turns out quite big: it requires at least 23 bytes for each tuple, and this value is often exceeded because of the null bitmap and the mandatory padding used for data alignment. In a “narrow” table, the size of various metadata can easily beat the size of the actual data stored.
Data layout on disk fully coincides with data representation in RAM. The page along with its tuples is read into the buffer cache as is, without any transformations. That’s why data files are incompatible between different platforms.4
One of the sources of incompatibility is the byte order. For example, the x86 architecture is little-endian, z/Architecture is big-endian, and ARM has configurable byte order.
Another reason is data alignment by machine word boundaries, which is required by many architectures. For example, in a 32-bit x86 system, integer numbers (the integer type, takes four bytes) are aligned by the boundary of four-byte words, just like double-precision floating-point numbers (the double precision type, eight bytes). But in a 64-bit system, double values are aligned by the boundary of eight-byte words.
Data alignment makes the size of a tuple dependent on the order of fields in the table. This effect is usually negligible, but in some cases it can lead to a significant size increase. Here is an example:
I have used the heap_page_items function of the pageinspect extension to display some details about pointers and tuples.
In PostgreSQL, tables are often referred to as heap. This is yet another obscure term that hints at the similarity between space allocation for tuples and dynamic memory allocation. Some analogy can certainly be seen, but tables are managed by completely different algorithms. We can interpret this term in the sense that “everything is piled up into a heap,” by contrast with ordered indexes.
The size of the row is 40 bytes. Its header takes 24 bytes, a column of the integer type takes 4 bytes, and boolean columns take 1 byte each. It makes 34 bytes, and 6 bytes are wasted on four-byte alignment of integer columns.
If we rebuild the table, the space will be used more efficiently:
Another possible micro-optimization is to start the table with the fixed-length columns that cannot contain NULL values. Access to such columns will be more efficient because it is possible to cache their offset within the tuple.5
3.3 Operations on Tuples
To identify different versions of the same row, PostgreSQL marks each of them with two values: xmin and xmax. These values define “validity time” of each row version, but instead of the actual time, they rely on ever-increasing transaction IDs. (p. 123)
When a row is created, its xmin value is set to the transaction ID of the INSERT command.
When a row is deleted, the xmax value of its current version is set to the transaction ID of the DELETE command.
With a certain degree of abstraction, the UPDATE command can be regarded as two separate operations: DELETE and INSERT. First, the xmax value of the current row version is set to the transaction ID of the UPDATE command. Then a new version of this row is created; its xmin value will be the same as the xmax value of the previous version.
Now let’s get down to some low-level details of different operations on tuples.6
For these experiments, we will need a two-column table with an index created on one of the columns:
Insert
Start a transaction and insert one row:
Here is the current transaction ID:
To denote the concept of a transaction, PostgreSQL uses the term xact, which can be found both in SQL function names and in the source code. Consequently, a transaction ID can be called xact ID, TXID, or simply XID. We are going to come across these abbreviations over and over again.
Let’s take a look at the page contents. The heap_page_items function can give us all the required information, but it shows the data “as is,” so the output format is a bit hard to comprehend:
To make it more readable, we can leave out some information and expand a few columns:
This is what has been done here:
The
lppointer is converted to the standard format of a tuple ID:(page number, pointer number).The
lp_flagsstate is spelled out. Here it is set to thenormalvalue, which means that it really points to a tuple.Of all the information bits, we have singled out just two pairs so far. The
xmin_committedandxmin_abortedbits show whether thexmintransaction is committed or aborted. Thexmax_committedandxmax_abortedbits give similar information about thexmaxtransaction. (v. 13)
The pageinspect extension provides the heap_tuple_infomask_flags function that explains all the information bits, but I am going to retrieve only those that are required at the moment, showing them in a more concise form.
Let’s get back to our experiment. The INSERT command has added pointer 1 to the heap page; it refers to the first tuple, which is currently the only one.
The xmin field of the tuple is set to the current transaction ID. This transaction is still active, so the xmin_committed and xmin_aborted bits are not set yet.
The xmax field contains 0, which is a dummy number showing that this tuple has not been deleted and represents the current version of the row. Transactions will ignore this number because the xmax_aborted bit is set.
It may seem strange that the bit corresponding to an aborted transaction is set for the transaction that has not happened yet. But there is no difference between such transactions from the isolation standpoint: an aborted transaction leaves no trace, hence it has never existed.
We will use this query more than once, so I am going to wrap it into a function. And while being at it, I will also make the output more concise by hiding the information bit columns and displaying the status of transactions together with their IDs.
Now it is much clearer what is happening in the tuple header:
You can get similar but less detailed information from the table itself by querying the xmin and xmax pseudocolumns:
Commit
Once a transaction has been completed successfully, its status has to be stored somehow— it must be registered that the transaction is committed. For this purpose, PostgreSQL employs a special CLOG (commit log) structure.7 It is stored as files in the PGDATA/pg_xact directory rather than as a system catalog table.
Previously, these files were located in PGDATA/pg_clog, but in version 10 this directory got renamed:8 it was not uncommon for database administrators unfamiliar with PostgreSQL to delete it in search of free disk space, thinking that a “log” is something unnecessary.
CLOG is split into several files solely for convenience. These files are accessed page by page via buffers in the server’s shared memory.9 (p. 132)
Just like a tuple header, CLOG contains two bits for each transaction: committed and aborted.
Once committed, a transaction is marked in CLOG with the committed bit. When any other transaction accesses a heap page, it has to answer the question: has the xmin transaction already finished?
- If not, then the created tuple must not be visible.
To check whether the transaction is still active, PostgreSQL uses yet another structure located in the shared memory of the instance; it is called ProcArray. This structure contains the list of all the active processes, with the corresponding current (active) transaction specified for each process.
- If yes, was it committed or aborted? In the latter case, the corresponding tuple cannot be visible either.
It is this check that requires CLOG. But even though the most recent CLOG pages are stored in memory buffers, it is still expensive to perform this check every time. Once determined, the transaction status is written into the tuple header—more specifically, into xmin_committed and xmin_aborted information bits, which are also called hint bits. If one of these bits is set, then the xmin transaction status is considered to be already known, and the next transaction will have to access neither CLOG nor ProcArray.
Why aren’t these bits set by the transaction that performs row insertion? The problem is that it is not known yet at that time whether this transaction will complete successfully. And when it is committed, it is already unclear which tuples and pages have been changed. If a transaction affects many pages, it may be too expensive to track them. Besides, some of these pages may be not in the cache anymore; reading them again to simply update the hint bits would seriously slow down the commit.
The flip side of this cost reduction is that any transaction (even a read-only SELECT command) can start setting hint bits, thus leaving a trail of dirtied pages in the buffer cache.
Finally, let’s commit the transaction started with the INSERT statement:
Nothing has changed in the page (but we know that the transaction status has already been written into CLOG):
Now the first transaction that accesses the page (in a “standard” way, without using pageinspect) has to determine the status of the xmin transaction and update the hint bits:
Delete
When a row is deleted, the xmax field of its current version is set to the transaction ID that performs the deletion, and the xmax_aborted bit is unset. (p. 210)
While this transaction is active, the xmax value serves as a row lock. If another transaction is going to update or delete this row, it will have to wait until the xmax transaction is complete.
Let’s delete a row:
The transaction ID has already been written into the xmax field, but the information bits have not been set yet:
Abort
The mechanism of aborting a transaction is similar to that of commit and happens just as fast, but instead of committed it sets the aborted bit in CLOG. Although the corresponding command is called ROLLBACK, no actual data rollback is happening: all the changes made by the aborted transaction in data pages remain in place.
When the page is accessed, the transaction status is checked, and the tuple receives the xmax_aborted hint bit. The xmax number itself still remains in the page, but no one is going to pay attention to it anymore:
Update
An update is performed in such a way as if the current tuple is deleted, and then a new one is inserted:
The query returns a single row (its new version):
But the page keeps both versions:
The xmax field of the previously deleted version contains the current transaction ID. This value is written on top of the old one because the previous transaction was aborted. The xmax_aborted bit is unset since the status of the current transaction is still unknown.
To complete this experiment, let’s commit the transaction.
3.4 Indexes
Regardless of their type, indexes do not use row versioning; each row is represented by exactly one tuple. In other words, index row headers do not contain xmin and xmax fields. Index entries point to all the versions of the corresponding table row. To figure out which row version is visible, transactions have to access the table (unless the required page appears in the visibility map). (p. 95)
For convenience, let’s create a simple function that will use pageinspect to display all the index entries in the page (B-tree index pages store them as a flat list):
The page references both heap tuples, the current and the previous one:
Since BAR < FOO, the pointer to the second tuple comes first in the index.
3.5 TOAST
A TOAST table is virtually a regular table, and it has its own versioning that does not depend on row versions of the main table. However, rows of TOAST tables are handled in such a way that they are never updated; they can be either added or deleted, so their versioning is somewhat artificial. (p. 28)
Each data modification results in creation of a new tuple in the main table. But if an update does not affect any long values stored in TOAST, the new tuple will reference an existing toasted value. Only when a long value gets updated will PostgreSQL create both a new tuple in the main table and new “toasts.”
3.6 Virtual Transactions
To consume transaction IDs sparingly, PostgreSQL offers a special optimization.
If a transaction is read-only, it does not affect row visibility in any way. That’s why such a transaction is at first given a virtual XID10, which consists of the backend process ID and a sequential number. Assigning a virtual XID does not require any synchronization between different processes, so it happens very fast. At this point, the transaction does not have a real ID yet: (p. 202)
At different points in time, the system can contain some virtual XIDs that have already been used. And it is perfectly normal: virtual XIDs exist only in RAM, and only while the corresponding transactions are active; they are never written into data pages and never get to disk.
Once the transaction starts modifying data, it receives an actual unique ID:
3.7 Subtransactions
Savepoints
SQL supports savepoints, which enable canceling some of the operations within a transaction without aborting this transaction as a whole. But such a scenario does not fit the course of action described above: the status of a transaction applies to all its operations, and no physical data rollback is performed.
To implement this functionality, a transaction containing a savepoint is split into several subtransactions,11 so their status can be managed separately.
Subtransactions have their own IDs (which are bigger than the ID of the main transaction). The status of a subtransaction is written into CLOG in the usual manner; however, committed subtransactions receive both the committed and the aborted bits at once. The final decision depends on the status of the main transaction: if it is aborted, all its subtransactions will be considered aborted too.
The information about subtransactions is stored under the PGDATA/pg_subtrans directory. File access is arranged via buffers that are located in the instance’s shared memory and have the same structure as CLOG buffers.12
Do not confuse subtransactions with autonomous ones. Unlike subtransactions, the latter do not depend on each other in any way. Vanilla PostgreSQL does not support autonomous transactions, and it is probably for the best: they are required in very rare cases, but their availability in other database systems often provokes misuse, which can cause a lot of trouble.
Let’s truncate the table, start a new transaction, and insert a row:
Now create a savepoint and insert another row:
Note that the pg_current_xact_id function returns the ID of the main transaction, not that of a subtransaction.
Let’s roll back to the savepoint and insert the third row:
The page still contains the row added by the aborted subtransaction.
Commit the changes:
Now we can clearly see that each subtransaction has its own status.
SQL does not allow using subtransactions directly, that is, you cannot start a new transaction before completing the current one:
Subtransactions are employed implicitly: to implement savepoints, handle exceptions in PL/pgSQL, and in some other, more exotic cases.
Errors and Atomicity
What happens if an error occurs during execution of a statement?
After a failure, the whole transaction is considered aborted and cannot perform any further operations:
And even if you try to commit the changes, PostgreSQL will report that the transaction is rolled back:
Why is it forbidden to continue transaction execution after a failure? Since the already executed operations are never rolled back, we would get access to some changes made before the error—it would break the atomicity of the statement, and hence that of the transaction itself.
For example, in our experiment the operator has managed to update one of the two rows before the failure:
On a side note, psql provides a special mode that allows you to continue a transaction after a failure as if the erroneous statement were rolled back:
As you can guess, psql simply adds an implicit savepoint before each command when run in this mode; in case of a failure, a rollback is initiated. This mode is not used by default because issuing savepoints (even if they are not rolled back to) incurs significant overhead.
postgresql.org/docs/14/storage-page-layout.html include/storage/bufpage.h ↩︎
backend/storage/page/bufpage.c, PageRepairFragmentation function ↩︎
backend/access/common/heaptuple.c, heap_deform_tuple function ↩︎