Revise GIN README

We find GIN concurrency bugs from time to time.  One of the problems here is
that concurrency of GIN isn't well-documented in README.  So, it might be even
hard to distinguish design bugs from implementation bugs.

This commit revised concurrency section in GIN README providing more details.
Some examples are illustrated in ASCII art.

Also, this commit add the explanation of how is tuple layout in internal GIN
B-tree page different in comparison with nbtree.

Discussion: https://postgr.es/m/CAPpHfduXR_ywyaVN4%2BOYEGaw%3DcPLzWX6RxYLBncKw8de9vOkqw%40mail.gmail.com
Author: Alexander Korotkov
Reviewed-by: Peter Geoghegan
Backpatch-through: 9.4
This commit is contained in:
Alexander Korotkov 2019-11-19 23:11:24 +03:00
parent c0bf354215
commit 287192bda2

View File

@ -215,6 +215,35 @@ fit on one pending-list page must have those pages to itself, even if this
results in wasting much of the space on the preceding page and the last
page for the tuple.)
GIN packs downlinks and pivot keys into internal page tuples in a different way
than nbtree does. Lehman & Yao defines it as following.
P_0, K_1, P_1, K_2, P_2, ... , K_n, P_n, K_{n+1}
There P_i is a downlink and K_i is a key. K_i splits key space between P_{i-1}
and P_i (0 <= i <= n). K_{n+1} is high key.
In internal page tuple is key and downlink grouped together. nbtree packs
keys and downlinks into tuples as following.
(K_{n+1}, None), (-Inf, P_0), (K_1, P_1), ... , (K_n, P_n)
There tuples are shown in parentheses. So, highkey is stored separately. P_i
is grouped with K_i. P_0 is grouped with -Inf key.
GIN packs keys and downlinks into tuples in a different way.
(P_0, K_1), (P_1, K_2), ... , (P_n, K_{n+1})
P_i is grouped with K_{i+1}. -Inf key is not needed.
There are couple of additional notes regarding K_{n+1} key.
1) In entry tree rightmost page, a key coupled with P_n doesn't really matter.
Highkey is assumed to be infinity.
2) In posting tree, a key coupled with P_n always doesn't matter. Highkey for
non-rightmost pages is stored separately and accessed via
GinDataPageGetRightBound().
Posting tree
------------
@ -278,21 +307,86 @@ Concurrency
-----------
The entry tree and each posting tree are B-trees, with right-links connecting
sibling pages at the same level. This is the same structure that is used in
sibling pages at the same level. This is the same structure that is used in
the regular B-tree indexam (invented by Lehman & Yao), but we don't support
scanning a GIN trees backwards, so we don't need left-links.
scanning a GIN trees backwards, so we don't need left-links. The entry tree
leaves don't have dedicated high keys, instead greatest leaf tuple serves as
high key. That works because tuples are never deleted from the entry tree.
To avoid deadlocks, B-tree pages must always be locked in the same order:
left to right, and bottom to top. The exception is page deletion during vacuum,
which would be considered separately. When searching, the tree is traversed
from top to bottom, so the lock on the parent page must be released before
descending to the next level. Concurrent page splits move the keyspace to
right, so after following a downlink, the page actually containing the key
we're looking for might be somewhere to the right of the page we landed on.
In that case, we follow the right-links until we find the page we're looking
for. In spite of searches, insertions keeps pins on all pages in path from
root to the current page. These pages potentially requies downlink insertion,
while pins prevent them from being deleted.
The algorithms used to operate entry and posting trees are considered below.
### Locating the leaf page
When we search for leaf page in GIN btree to perform a read, we descend from
the root page to the leaf through using downlinks taking pin and shared lock on
one page at once. So, we release pin and shared lock on previous page before
getting them on the next page.
The picture below shows tree state after finding the leaf page. Lower case
letters depicts tree pages. 'S' depicts shared lock on the page.
a
/ | \
b c d
/ | \ | \ | \
eS f g h i j k
### Steping right
Concurrent page splits move the keyspace to right, so after following a
downlink, the page actually containing the key we're looking for might be
somewhere to the right of the page we landed on. In that case, we follow the
right-links until we find the page we're looking for.
During stepping right we take pin and shared lock on the right sibling before
releasing them from the current page. This mechanism was designed to protect
from stepping to delete page. We step to the right sibling while hold lock on
the rightlink pointing there. So, it's guaranteed that nobody updates rightlink
concurrently and doesn't delete right sibling accordingly.
The picture below shows two pages locked at once during stepping right.
a
/ | \
b c d
/ | \ | \ | \
eS fS g h i j k
### Insert
While finding appropriate leaf for insertion we also descend from the root to
leaf, while shared locking one page at once in. But during insertion we don't
release pins from root and internal pages. That could save us some lookups to
the buffers hash table for downlinks insertion assuming parents are not changed
due to concurrent splits. Once we reach leaf we re-lock the page in exclusive
mode.
The picture below shows leaf page locked in exclusive mode and ready for
insertion. 'P' and 'E' depict pin and exclusive lock correspondingly.
aP
/ | \
b cP d
/ | \ | \ | \
e f g hE i j k
If insert causes a page split, the parent is locked in exclusive mode before
unlocking the left child. So, insertion algorithm can exclusively lock both
parent and child pages at once starting from child.
The picture below shows tree state after leaf page split. 'q' is new page
produced by split. Parent 'c' is about to have downlink inserted.
aP
/ | \
b cE d
/ | \ / | \ | \
e f g hE q i j k
### Page deletion
Vacuum never deletes tuples or pages from the entry tree. It traverses entry
tree leafs in logical order by rightlinks and removes deletable TIDs from
@ -301,39 +395,83 @@ are vacuumed in two stages. At first stage, deletable TIDs are removed from
leafs. If first stage detects at least one empty page, then at the second stage
ginScanToDelete() deletes empty pages.
ginScanToDelete() scans posting tree to delete empty pages, while vacuum holds
cleanup lock on the posting tree root. This lock prevent concurrent inserts,
because inserters hold a pin on the root page. In spite of inserters searches
don't hold pin on root page. So, while new searches cannot begin while root page
is locked, any already-in-progress scans can continue concurrently with vacuum.
ginScanToDelete() traverses the whole tree in depth-first manner. It starts
from the super-exclusive lock on the tree root. This lock prevents all the
concurrent insertions into this tree while we're deleting pages. However,
there are still might be some in-progress readers, who traversed root before
we locked it.
ginScanToDelete() does depth-first tree scanning while keeping each page in path
from root to current page exclusively locked. It also keeps left sibling of
each page in the path locked. Thus, if current page is to be removed, all
required pages to remove both downlink and rightlink are already locked.
Therefore, page deletion locks pages top to bottom and left to right breaking
our general rule. But assuming there is no concurrent insertions, this can't
cause a deadlock.
The picture below shows tree state after page deletion algorithm traversed to
leftmost leaf of the tree.
During replay od page deletion at standby, the page's left sibling, the target
page, and its parent, are locked in that order. So it follows bottom to top and
left to right rule.
aE
/ | \
bE c d
/ | \ | \ | \
eE f g h i j k
Deletion algorithm keeps exclusive locks on left siblings of pages comprising
currently investigated path. Thus, if current page is to be removed, all
required pages to remove both downlink and rightlink are already locked. That
evades potential right to left page locking order, which could deadlock with
concurrent stepping right.
A search concurrent to page deletion might already have read a pointer to the
page to be deleted, and might be just about to follow it. A page can be reached
page to be deleted, and might be just about to follow it. A page can be reached
via the right-link of its left sibling, or via its downlink in the parent.
To prevent a backend from reaching a deleted page via a right-link, when
following a right-link the lock on the previous page is not released until the
lock on next page has been acquired.
To prevent a backend from reaching a deleted page via a right-link, stepping
right algorithm doesn't release lock on the current page until lock of the
right page is acquired.
The downlink is more tricky. A search descending the tree must release the lock
The downlink is more tricky. A search descending the tree must release the lock
on the parent page before locking the child, or it could deadlock with a
concurrent split of the child page; a page split locks the parent, while already
holding a lock on the child page. So, deleted page cannot be reclaimed
immediately. Instead, we have to wait for every transaction, which might wait to
reference this page, to finish. Corresponding processes must observe that the
page is marked deleted and recover accordingly.
holding a lock on the child page. So, deleted page cannot be reclaimed
immediately. Instead, we have to wait for every transaction, which might wait
to reference this page, to finish. Corresponding processes must observe that
the page is marked deleted and recover accordingly.
The picture below shows tree state after page deletion algorithm further
traversed the tree. Currently investigated path is 'a-c-h'. Left siblings 'b'
and 'g' of 'c' and 'h' correspondingly are also exclusively locked.
aE
/ | \
bE cE d
/ | \ | \ | \
e f gE hE i j k
The next picture shows tree state after page 'h' was deleted. It's marked with
'deleted' flag and newest xid, which might visit it. Downlink from 'c' to 'h'
is also deleted.
aE
/ | \
bE cE d
/ | \ \ | \
e f gE hD iE j k
However, it's still possible that concurrent reader has seen downlink from 'c'
to 'h' before we deleted it. In that case this reader will step right from 'h'
to till find non-deleted page. Xid-marking of page 'h' guarantees that this
page wouldn't be reused till all such readers gone. Next leaf page under
investigation is 'i'. 'g' remains locked as it becomes left sibling of 'i'.
The next picture shows tree state after 'i' and 'c' was deleted. Internal page
'c' was deleted because it appeared to have no downlinks. The path under
investigation is 'a-d-j'. Pages 'b' and 'g' are locked as self siblings of 'd'
and 'j'.
aE
/ \
bE cD dE
/ | \ | \
e f gE hD iD jE k
During the replay of page deletion at standby, the page's left sibling, the
target page, and its parent, are locked in that order. This order guarantees
no deadlock with concurrent reads.
Predicate Locking
-----------------