29 BRIN
PostgreSQL 14 · Translated from Russian by Liudmila Mantrova · Chinese translation by Xiong Cancan
29.1 Overview
Unlike other indexes that are optimized to quickly find the required rows, BRIN1 is designed to filter out unnecessary rows. This access method was created primarily for large tables of several terabytes and up, so a smaller index size takes priority over search accuracy.
To speed up search, the whole table is split into ranges, hence the name: Block Range Index. Each range comprises several pages. The index does not store TIDs, keeping only a summary on the data of each range. For ordinal data types, it is the minimal and the maximal values in the simplest case, but different operator classes may collect different information on values in a range.
The number of pages in a range is defined at the time of the index creation based on the value of the pages_per_range storage parameter. (default: 128)
If a query condition references an indexed column, all the ranges that are guaranteed to have no matches can be skipped. The pages of all the other ranges are returned by the index as a lossy bitmap; all the rows of these pages have to be rechecked. (p. 342)
Thus, BRIN works well for columns with localized values (that is, for columns in which values stored close to each other have similar summary information properties). For ordinal data types, it means that values must be stored in ascending or descending order, that is, have high correlation between their physical location and the logical order defined by the greater than and less than operations. For other types of summary information, “similar properties” may vary. (p. 284)
It will not be wrong to think of BRIN as an accelerator of sequential heap scans rather than an index in the conventional sense of the word. It can be regarded as an alternative to partitioning, with each range representing a virtual partition.
29.2 Example
Our demo database contains no tables that are large enough for BRIN, but we can imagine that analytical reports demand that we have a denormalized table containing summary information on all the departed and arrived flights of a particular airport, down to the occupied seats. The data for each airport is updated daily, as soon as it is midnight in the corresponding timezone. The added data is neither updated nor deleted.
The table looks as follows:
Data loading can be emulated using nested loops:2 the outer loop will correspond to days (the demo database stores annual data), while the inner loop will be based on timezones. As a result, the loaded data will be more or less ordered at least by time and airports, even though it is not explicitly sorted within the loop.
I will load an existing copy of the database that takes roughly 4 GB and contains about 30 million rows:3
We can hardly call it a large table, but this data volume will be enough to demonstrate how BRIN works. I will create an index in advance:
It takes very little space with the default settings.
A B-tree index is a thousand times bigger, even if data deduplication is enabled. True, its efficiency is also much higher, but an additional volume can turn out to be unaffordable luxury for really large tables. (v. 13)
29.3 Page Layout
The zero page of a BRIN index is called the metapage; it keeps information on the index structure.
At a certain offset from the metadata, there are pages with summary information. Each index entry in such a page contains a summary of a particular block range.
The space between the metapage and the summary information is taken by the range map, which is sometimes also referred to as a reverse map (hence the common revmap abbreviation). It is effectively an array of pointers to the corresponding index rows; the index number in this array corresponds to the range number.
As the table is expanding, the size of the range map also grows. If the map does not fit the allotted pages, it overtakes the next page, and all the index entries previously located in this page are transferred to other pages. Since a page can accommodate many pointers, such transfers are quite rare.
BRIN index pages can be displayed by the pageinspect extension, as usual. The metadata includes the size of the range and the number of pages reserved for the range map:
Here the range map takes four pages, from the first to the fourth one. We can take a look at the pointers to index entries containing summarized data:
If the range is not summarized yet, the pointer in the range map is NULL.
And here are the summaries for several ranges:
29.4 Search
If a query condition is supported by the BRIN index,4 the executor scans the range map and the summary information for each range. If the data in a range may match the search key, all the pages that belong to this range are added to the bitmap. Since BRIN does not keep IDs of separate tuples, the bitmap is always lossy.
Matching the data against the search key is performed by the consistency function, which interprets range summary information. Non-summarized ranges are always added to the bitmap.
The received bitmap is used to scan the table in the usual manner. It is important to mention that heap page reads happen sequentially, block range by block range, and prefetching is employed. (p. 340)
29.5 Summary Information Updates
Value Insertion
As a new tuple is added into a heap page, the summary information in the corresponding index range gets updated.5 The range number is calculated based on the page number using simple arithmetic operations, and the summary information is then located by the range map.
To determine whether the current summary information has to be expanded, the addition function is employed. If an expansion is required and the page has enough free space, it is done in-place (without adding a new index entry).
Suppose we have added a tuple with value 42 to page 13. The range number is calculated by integer division of page number by the size of the range. Assuming that the range size equals four pages, we get range number 3; since range numbering is zero-based, we take the fourth pointer in the range map. The minimal value in this range is 31, the maximal one is 40. The added value falls outside these limits, so the maximal value is increased:
If an in-place update is impossible, a new entry is added, and the range map is modified.
Range Summarization
Everything said above applies to scenarios when a new tuple appears in an already summarized range. When an index is being built, all the existing ranges are summarized, but as the table grows, new pages may fall outside these ranges.
If an index is created with the autosummarize storage parameter enabled, the new range will be summarized at once. But in data warehouses, where rows are usually added in large batches rather than one by one, this mode can seriously slow down insertion. (default: off)
By default, new ranges are not summarized right away. It does not affect index correctness because ranges with no summary information are always scanned. Summarization is performed asynchronously, either during table vacuuming or when initiated manually by calling the brin_summarize_new_values function (or the brin_summarize_range function that processes a single range). (p. 112)
Range summarization6 does not lock the table for updates. At the beginning of this process, a placeholder entry is inserted into the index for this range. If the data in the range is changed while this range is being scanned, the placeholder will be updated with the summary information on these changes. Then the union function will unite this data with the summary information on the corresponding range.
In theory, summary information could sometimes shrink after some rows are deleted. But while GiST indexes can redistribute data after a page split, summary information of BRIN indexes never shrinks and can only get wider. Shrinking is usually not required here because a data storage is typically used only for appending new data. You can manually delete summary information by calling the brin_desummarize_range function for this range to be summarized again, but there is no clue as to which ranges might benefit from it. (p. 445)
Thus, BRIN is primarily targeted at tables of very large size, which either have minimal updates that add new rows mostly to the end of the file, or are not updated at all. It is mainly used in data warehouses for building analytical reports.
29.6 Minmax Classes
For data types that allow comparing values, summary information includes at least the maximal and minimal values. The corresponding operator classes contain the word minmax in their names:7
Here are the support functions of these operator classes:
The first function returns the operator class metadata, and all the other functions have already been described: they insert new values, check consistency, and perform union operations.
The minmax class includes the same comparison operators that we have seen for B-trees: (p. 432)
Choosing Columns to be Indexed
Which columns does it make sense to index using this operator class? As mentioned earlier, such indexes work well if the physical location of rows correlates with the logical order of values.
Let’s check it for the above example.
The data is ordered by time (both scheduled and actual time; there is little difference, if any): new entries are added in chronological order, and as the data is neither updated nor deleted, all the rows get into the main fork of the table sequentially, one after another. (p. 25)
Columns fare_conditions, flight_type, and airport_utc_offset have relatively high correlation, but they store too few distinct values.
The correlation in other columns is too low for their indexing with the minmax operator class to be of any interest.
Range Size and Search Efficiency
An appropriate range size can be determined based on the number of pages used to store particular values.
Let’s take a look at the scheduled_time column and get the information on all the flights performed in 24 hours. We first have to find out how many table pages are taken by the data related to this time interval.
To get this number, we can use the fact that a TID consists of a page number and an offset. Unfortunately, there is no built-in function to break down a TID into these two components, so we will have to write our own clumsy function to perform type casting via a text representation:
Now we can see how days are distributed through the table:
As we can notice, the data distribution is not quite uniform. With a standard range size of 128 pages, each day will take from 9 to 12 ranges. While fetching the data for a particular day, the index scan will return both the rows that are really needed and some rows related to other days that got into the same ranges. The bigger the range size, the more extra boundary values will be read; we can change their number by reducing or increasing the range size.
Let’s try out a query for some particular day (I have already created an index with the default settings). For simplicity, I will forbid parallel execution:
We can define an efficiency factor of a BRIN index for a particular query as a ratio between the number of pages skipped in an index scan and the total number of pages in the table. If the efficiency factor is zero, the index access degrades to sequential scanning (without taking overhead costs into account). The higher the efficiency factor, the fewer pages have to be read. But as some pages contain the data to be returned and cannot be skipped, the efficiency factor is always smaller than one. (p. 348)
In this particular case, the efficiency factor is ≈ 0.997, where 528,417 is the number of pages in the table.
However, we cannot draw any meaningful conclusions based on a single value. Even if we had uniform data and ideal correlation, the efficiency would still vary because, at the very least, range boundaries will not match page boundaries. We can get the full picture only if we treat the efficiency factor as a random value and analyze its distribution.
For our example, we can select all the different days of the year, check the execution plan for each value, and calculate statistics based on this selection. We can easily automate this process because the EXPLAIN command can return the results in the JSON format, which is convenient to parse. I will not provide all the code here, but the following snippet contains all the important details:
The results can be visually displayed as a box plot, also known as a “box-and-whiskers.” The whiskers here denote the first and fourth quartiles (that is, the right whisker gets 25 % of the largest values, while the left one gets 25 % of the smallest values). The box itself holds the remaining 50 % of values and has the median value marked. What is more important, this compact representation enables us to visually compare different results. The following illustration shows the efficiency factor distribution for the default range size and for two other sizes that are four times larger and smaller.
As we could have expected, the search accuracy and efficiency are high even for rather large ranges.
The dashed line here marks the average value of the maximal efficiency factor possible for this query, assuming that one day takes roughly of the table.
Note that the rise in efficiency comes at the expense of the index size increase. BRIN is quite flexible in letting you find the balance between the two.
Properties
BRIN properties are hardwired and do not depend on operator classes.
Access Method Properties
Obviously, neither sorting nor uniqueness properties are supported. Since a BRIN index always returns a bitmap, exclusion constraints are not supported either. Neither do additional INCLUDE columns make any sense, as even indexing keys are not stored in BRIN indexes.
However, we can create a multicolumn BRIN index. In this case, summary information for each column is collected and stored in a separate index entry, but they still have a common range mapping. Such an index is useful if the same range size is applicable to all the indexed columns.
Alternatively, we can create separate BRIN indexes for several columns and take advantage of the fact that bitmaps can be merged together. For example: (p. 343)
Index-Level Properties
Obviously, bitmap scanning is the only supported access type.
Lack of clusterization may seem puzzling. Since BRIN is sensitive to the physical order of rows, it is quite logical to assume that it should support reordering, which would maximize its efficiency. But clusterization of large tables is anyway a luxury, taking into account all the processing and extra disk space required to rebuild a table. Besides, as the example of the flights_bi table shows, some ordering in data storages can occur naturally.
Column-Level Properties
The only available column-level property is NULL support. To track NULL values in a range, summary information provides a separate attribute:
29.7 Minmax-Multi Classes
The established correlation can be easily disrupted by data updates. The reason is not an actual modification of a particular value but rather the MVCC design itself: an old version of a row may be deleted in one page, while its new version may be inserted into any location that is currently free, so the original row order cannot be preserved. (v. 14) (p. 66)
To minimize this effect to some extent, we can reduce the value of the fillfactor storage parameter to leave more space in the page for future updates. But is it really worth increasing the size of an already huge table? Besides, deletions will anyway free some space in existing pages, thus preparing traps for new tuples that would otherwise get to the end of the file.
Such a situation can be easily emulated. Let’s delete 0.1 % of randomly chosen rows and vacuum the table to clean up some space for new tuples:
Now let’s add some data for a new day in one of the timezones. I will simply copy the data of the previous day:
The performed deletion was enough to free some space in all or almost all the ranges. Getting into pages located somewhere in the middle of the file, new tuples have automatically expanded the ranges. For example, the summary information related to the first range used to cover less than a day, but now it comprises the whole year:
The smaller the date specified in the query, the more ranges have to be scanned. The graph shows the magnitude of the disaster:
To address this issue, we have to make the summary information a bit more sophisticated: instead of a single continuous range, we have to store several smaller ones that cover all the values when taken together. Then one of the ranges can cover the main set of data, while the rest will handle occasional outliers.
Such functionality is provided by minmax-multi operator classes:8
As compared to minmax operator classes, minmax-multi classes have one more support function that computes the distance between values; it is used to determine the range length, which the operator class strives to reduce:
The operators of such classes are absolutely the same as those of the minmax classes.
Minmax-multi classes can take the values_per_range parameter, which defines the maximal allowed number of summarized values per range. A summarized value is represented by two numbers (an interval),while a separate point requires just one. If there are not enough values, some of the intervals are reduced.9 (default: 32)
Let’s build a minmax-multi index instead of the existing one. We will limit the number of allowed values per range to 16:
The graph shows that the new index brings the efficiency back to the original level. Quite expectedly, it leads to an increase in the index size:
29.8 Inclusion Classes
The difference between minmax and inclusion operator classes is roughly the same as the difference between B-trees and GiST indexes: the latter are designed for data types that do not support comparison operations, although mutual alignment of values still makes sense for them. Summary information for a particular range provided by inclusion operator classes is represented by the bounding box of the values in this range.
Here are these operator classes; they are not numerous:
The list of support functions is extended by one more mandatory function that merges two values, and by a bunch of optional ones:
When dealing with values that can be compared, we relied on their correlation; but for other data types, no such statistic is collected,10 so it is hard to predict the efficiency of an inclusion-based BRIN index.
What is worse, correlation greatly affects cost estimation of an index scan. If such statistic is unavailable, it is taken as zero.11 Thus, the planner has no way to tell between exact and fuzzy inclusion indexes, so it typically avoids using them altogether. (v. 3.1.1)
PostGIS collects statistics on correlation of spatial data.
In this particular case, we can presume that it makes sense to build an index over airport coordinates, as longitude must correlate with the timezone.
Unlike GiST predicates, BRIN summary information has the same type as the indexed data; therefore, it is not so easy to build an index for points. But we can create an expression index by converting points into dummy rectangles:
An index built over timezones with the same range size takes approximately the same volume (3288 kB).
The operators included into this class are similar to GiST operators. For example, a BRIN index can be used to speed up search for points in a certain area:
But as mentioned earlier, the planner refuses to use an index scan unless we turn off sequential scanning:
29.9 Bloom Classes
Operator classes based on the Bloom filter enable BRIN usage for any data types that support the equal to operation and have a hash function defined. They can also be applied to regular ordinal types if values are localized in separate ranges but their physical location has no correlation with the logical order. (v. 14)
The names of such operator classes contain the word bloom:12
The classic Bloom filter is a data structure that enables you to quickly check whether an element belongs to a set. This filter is very compact, but it allows false positives: a set may be assumed to contain more elements than it actually does. But what is more important, false negatives are ruled out: the filter cannot decide that an element is not present in the set if it is actually there.
The filter is an array of m bits (also called a signature), which is originally filled with zeros. We select k different hash functions to map any element of the set to k bits of the signature. When an element is added to the set, each of the bits in the signature is set to one. Consequently, if all the bits that correspond to an element are set to one, the element may be present in the set; if there is at least one zero bit, the element is guaranteed to be absent.
In the case of BRIN indexes, the filter processes a set of values of an indexed column that belong to a particular range; the summary information for this range is represented by the built Bloom filter.
The bloom extension13 provides its own index access method based on the Bloom filter. It builds a filter for each table row and deals with a set of column values of each row. Such an index is designed for indexing several columns at a time and can be used in adhoc queries, when the columns to be referenced in filter conditions are not known in advance. A BRIN index can also be built on several columns, but its summary information will contain several independent Bloom filters for each of these columns.
The accuracy of the Bloom filter depends on the signature length. In theoretical terms, the optimal number of signature bits can be estimated at m = , where n is the number of elements in the set and p is the probability of false positives.
These two settings can be adjusted using the corresponding operator class parameters:
n_distinct_per_range defines the number of elements in a set; in this case, it is the number of distinct values in one range of an indexed column. This parameter value is interpreted just like statistics on distinct values: negative values indicate the fraction of rows in the range, not their absolute number. (default:
-0.1) (p. 276)false_positive_rate defines the probability of false positives. (default:
0.01)
A near-zero value means that an index scan will almost certainly skip the ranges that have no searched values. But it does not guarantee exact search, as the scanned ranges will also contain extra rows that do not match the query. Such behavior is due to range width and physical data location rather than to the actual filter properties.
The list of support functions is extended by a hash function:
Since the Bloom filter is based on hashing, only the equality operator is supported:
Let’s take the flight_no column that stores flight numbers; it has near-zero correlation, so it is useless for a regular range operator class. We will keep the default false-positive setting; as for the number of distinct values in a range, it can be easily calculated. For example, for an eight-page range we will get the following value:
For smaller ranges, this number will be even lower (but in any case, the operator class does not allow values smaller than 16).
We just have to create an index and check the execution plan:
The graph shows that for some flight numbers (represented by separate points that do not belong to any whiskers) the index does not work very well, but its overall efficiency is quite high:
postgresql.org/docs/14/brin.html backend/access/brin/README ↩︎
backend/access/brin/brin.c, bringetbitmap function ↩︎
backend/access/brin/brin.c, brininsert function ↩︎
backend/access/brin/brin.c, summarize_range function ↩︎
backend/access/brin/brin_minmax_multi.c, reduce_expanded_ranges function ↩︎
backend/commands/analyze.c, compute_scalar_stats function ↩︎
backend/utils/adt/selfuncs.c, brincostestimate function ↩︎