Skip to content

Commit 2334fd4

Browse files
authored
RFC: In-memory Pessimistic Locks (#77)
* RFC: In-memory Pessimistic Locks Signed-off-by: Yilin Chen <[email protected]> * clarify where to delete memory locks after writing a lock CF KV Signed-off-by: Yilin Chen <[email protected]> * Elaborate transfer leader handlings and add correctness section Signed-off-by: Yilin Chen <[email protected]> * add an addition step of proposing pessimistic locks before transferring leader Signed-off-by: Yilin Chen <[email protected]> * clarify about new leaders of region split Signed-off-by: Yilin Chen <[email protected]> * Add tracking issue link Signed-off-by: Yilin Chen <[email protected]> * update design and correctness analysis of lock migration Signed-off-by: Yilin Chen <[email protected]> * add configurations Signed-off-by: Yilin Chen <[email protected]>
1 parent 8bd15f2 commit 2334fd4

File tree

1 file changed

+267
-0
lines changed

1 file changed

+267
-0
lines changed
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
# RFC: In-memory Pessimistic Locks
2+
3+
- RFC PR: https://github.com/tikv/rfcs/pull/77
4+
- Tracking Issue: https://github.com/tikv/tikv/issues/11452
5+
6+
## Motivation
7+
8+
Pessimistic locks are replicated via the Raft protocol and then applied to the Lock CF in RocksDB. Now, TiKV implements an optimization called "pipelined pessimistic lock", which returns the result to the user immediately after proposing the locking request successfully.
9+
10+
"Pipelined pessimistic lock" saves most of the latency spent on Raft replication. This optimization is mainly based on the fact that it is still safe if some pessimistic locks are lost at the time of committing the transaction.
11+
12+
We can take it a step further. It is feasible to only keep pessimistic locks in the memory of the leader and not replicate the locks via Raft. With appropriate handlings on region changes, the failure rate of transactions will not increase compared to "pipelined pessimistic lock".
13+
14+
This change expects to reduce disk write bandwidth by 20% and reduce the latency of pessimistic locking by 50% according to preliminary tests on the TPC-C workload.
15+
16+
## Detailed design
17+
18+
Here is the general idea:
19+
20+
- Pessimistic locks are written into a region-level lock table.
21+
- Pessimistic locks are sent to other peers before a voluntary leader transfer.
22+
- Pessimistic locks in the source region are sent to the target region before a region merge.
23+
- On splitting a region, pessimistic locks are moved to the corresponding new regions.
24+
- Each region has limited space for in-memory pessimistic locks.
25+
26+
Next, we will talk about them in detail.
27+
28+
### Lock table
29+
30+
The lock table is region-level and is backed by a simple hash table.
31+
32+
The table is region-level because on region changes, we usually need to scan all pessimistic locks in a single region.
33+
34+
We don't have the requirement to scan pessimistic locks in a range ordered by key, so a hash table is sufficient instead of an ordered map.
35+
36+
We want to put transaction-related data together in `TxnExt`. Now, it contains `max_ts_sync_status` which is useful for async commit, and the newly added `PeerPessimisticLocks` protected by a `RwLock`.
37+
38+
```rust
39+
pub struct Peer<EK, ER> {
40+
// ...
41+
txn_ext: Arc<TxnExt>,
42+
}
43+
44+
pub struct TxnExt {
45+
max_ts_sync_status: AtomicU64,
46+
pessimistic_locks: RwLock<PeerPessimisticLocks>,
47+
}
48+
```
49+
50+
`PeerPessimisticLocks` contains a simple `HashMap` for the memory locks. And it contains `is_valid` marking its status and `term` and `version` indicating the metadata of the corresponding region. `size` is used to control the used memory. They will be explained later.
51+
52+
```rust
53+
pub struct PeerPessimisticLocks {
54+
/// The table that stores pessimistic locks.
55+
///
56+
/// The bool marks an ongoing write request (which has been sent to the raftstore while not
57+
/// applied yet) will delete this lock. The lock will be really deleted after applying the
58+
/// write request.
59+
map: HashMap<Key, (PessimisticLock, bool)>,
60+
size: usize,
61+
/// Whether the pessimistic lock map is valid to read or write. If it is invalid,
62+
/// the in-memory pessimistic lock feature cannot be used at the moment.
63+
pub is_valid: bool,
64+
/// Refers to the Raft term in which the pessimistic lock table is valid.
65+
pub term: u64,
66+
/// Refers to the region version in which the pessimistic lock table is valid.
67+
pub version: u64,
68+
}
69+
70+
pub struct PessimisticLock {
71+
pub primary: Box<[u8]>,
72+
pub start_ts: TimeStamp,
73+
pub lock_ttl: u64,
74+
pub for_update_ts: TimeStamp,
75+
pub min_commit_ts: TimeStamp,
76+
}
77+
```
78+
79+
The transaction layer gets the lock table through the snapshot.
80+
81+
Here, we want to change a bit of the snapshot interface. Raftstore-specific data are provided by extensions. This unifies the way how the raftstore passed out extra information of a region.
82+
83+
```rust
84+
pub trait Snapshot: Sync + Send + Clone {
85+
type Iter: Iterator;
86+
type Ext<'a>: SnapshotExt;
87+
88+
fn ext(&self) -> Self::Ext<'_>;
89+
}
90+
91+
pub trait SnapshotExt {
92+
fn get_data_version(&self) -> Option<u64>;
93+
94+
fn is_max_ts_synced(&self) -> bool;
95+
96+
fn get_term(&self) -> Option<NonZeroU64>;
97+
98+
fn get_txn_extra_op(&self) -> ExtraOp;
99+
100+
fn get_txn_ext(&self) -> Option<&Arc<TxnExt>>;
101+
}
102+
103+
pub struct RegionSnapshot<S: Snapshot> {
104+
snap: Arc<S>,
105+
region: Arc<Region>,
106+
apply_index: Arc<AtomicU64>,
107+
term: Option<NonZeroU64>, // term is no longer returned via CbContext
108+
txn_extra_op: TxnExtraOp,
109+
txn_ext: Option<Arc<TxnExt>>,
110+
}
111+
112+
pub struct RegionSnapshotExt<'a, S: Snapshot> {
113+
snapshot: &'a RegionSnapshot<S>,
114+
}
115+
116+
impl<'a, S: Snapshot> SnapshotExt for RegionSnapshotExt<'a, S> {
117+
// ...
118+
}
119+
```
120+
121+
### Read and write pessimistic locks
122+
123+
After the lock table is retrieved, we can read and write pessimistic locks in the transaction layer.
124+
125+
Before processing an `AcquirePessimisticLock` command in the scheduler, we save the current `term` and `version` of the region. This will be checked later when writing locks to the table.
126+
127+
When loading a lock, we can read the in-memory lock table first. If no lock is found, then read the underlying RocksDB. When reading the lock table, we should check the `term` and `version` of the region. If `version` or `term` has changed, We should return `EpochNotMatch` or `StaleCommand` respectively.
128+
129+
130+
And a new `Modify` variant is added. The `AcquirePessimisticLock` now adds `Modify::PessimisticLock` instead of a normal `Modify::Put`.
131+
132+
```rust
133+
pub enum Modify {
134+
// ...
135+
PessimisticLock(Key, PessimisticLock),
136+
}
137+
```
138+
139+
140+
With this policy, we need to check the `PeerPessimisticLocks` first. If `is_valid` is `false`, the feature is not usable probably because lock migration is in progress. And if the `term` or `version` is different from those before processing the command, the region must have changed and the lock table we fetched before processing is not the one we should write to. For simplicity, we can just continue sending the command to the raftstore to get the error and let the client retry when any check fails.
141+
142+
If the check passes, it is safe for us to write the pessimistic locks into the lock table. After this, we can return the response to the client without proposing anything at all.
143+
144+
For all writes involving the lock CF, the lock in the lock table should be cleared. For example, when a `Prewrite` command replaces a pessimistic lock with a 2PC lock, of course, this is a replicated write, we need to remove the pessimistic lock from the lock table after the write succeeds. It needs two steps to remove the lock. First, we mark the lock as `deleted` by changing the bool field in the lock table, and atomically send the write command to the raftstore. After the write command is finally applied, the lock is truly removed from the table, executing in the apply thread in the raftstore.
145+
146+
### Region leader transfer
147+
148+
If it is a voluntary leader transfer triggered by PD, we have the chance to transfer the pessimistic locks to the new leader to avoid unexpected transaction failures.
149+
150+
When a peer is going to transfer its leadership, the leader serializes current pessimistic locks **with no deleted marks** into bytes and modifies `is_valid` to `false`. Then, later `AcquirePessimisticLock` commands will fall back to proposing locks. In this way, we can guarantee the pessimistic locks either exist in the serialized bytes, or are replicated through Raft.
151+
152+
The serialized pessimistic locks will be sent to other peers through a Raft proposal. After this lock migration proposal is committed, the leader will continue the logic of in the current `pre_transfer_leader` method.
153+
154+
By default, a Raft message has a size limit of 1 MiB. We will guarantee that the total size of in-memory pessimistic locks in a single region will not exceed the limit. This will be discussed later.
155+
156+
If the transfer leader message is lost or rejected, we need to revert `is_valid` to `true`. But it is not possible for the leader to know. So, we have to use a timeout to implement it. That means, we can revert `is_valid` to `true` if the leader is still not transferred after some ticks. And if the leader receives the `MsgTransferLeader` response from the follower after the timeout, it should ignore the message and not trigger a leader transfer.
157+
158+
### Region merge
159+
160+
Before region merge, we should first set the `is_valid` to `false` to prevent future writings to the lock table. Then, record the current `proposed_index` and set a flag in the raftstore to forbid new write commands. After it applies to the recorded index, we can propose all the locks **including those with deleted marks** in the lock table at this time.
161+
162+
After proposing the locks, we can continue the original merge procedure, proposing `PrepareMerge`.
163+
164+
If the merge is rolled back, we can set `is_valid` of the source region back to `true`.
165+
166+
### Region split
167+
168+
After a region splits, if the parent peer is a leader, the newly split peer will campaign first to become a leader. So, the leader of all new regions are still located in the same TiKV unless there are network issues or the raftstore is too busy, in which case locks can be lost. Therefore, we only need memory operations to handle the case.
169+
170+
In `on_ready_split_region`, we first set `is_valid` to `false` and update the `version` of the lock table. Later pessimistic locks will either make a proposal or just fail, so we will not miss any lock.
171+
172+
Then, we iterate all locks **including those with deleted marks** and group them into new regions. After the locks are processed, we can increase `epoch` and set `is_valid` to `true`.
173+
174+
### Memory limit
175+
176+
To simplify handlings in region changes, we don't allow the total size of the pessimistic locks in a single region to be too large. The limit for each region is 512 KiB, matching the 1 MiB Raft message limit.
177+
178+
There should also be a global limit. The default size is the minimum of 1 GiB and 5% of the system memory.
179+
180+
It is easy for the lock writer to maintain the total size of pessimistic locks in a single region. If the memory limit is exceeded, we have to fall back to propose pessimistic locks.
181+
182+
### Compatibility
183+
184+
Pessimistic locks are sent via new fields in Raft messages. Only the upgraded TiKV instances can handle them while the old TiKV instances will ignore them. If the feature is unconditionally enabled, the pessimistic locks will be lost and the success rate of pessimistic transactions will drop during the rolling upgrade.
185+
186+
So this feature needs to be enabled after TiKV is fully upgraded. A possible approach is that TiKV gets the cluster version from PD and automatically turns it on through feature gate control.
187+
188+
The storage structure is not changed, so downgrading is feasible. However, before the downgrade, we must disable this feature first to avoid affecting the success rate of pessimistic transactions.
189+
190+
Ecosystem tools should not be affected by this optimization.
191+
192+
### Configurations
193+
194+
The feature can be enabled by setting `pessimistic-txn.in-memory` to `true` in the TiKV configuration file.
195+
196+
```toml
197+
[pessimistic-txn]
198+
in-memory = true
199+
```
200+
201+
Another two tick configurations are added to control when the feature will be reactivated again after transferring leader:
202+
203+
```toml
204+
[raftstore]
205+
reactive-memory-lock-tick-interval = "2s"
206+
reactive-memory-lock-timeout_tick = 5
207+
```
208+
209+
By default, the transferring leader timeout is 10 seconds before reactivating the feature. It is unlikely that the users need to change these two configurations.
210+
211+
## Correctness
212+
213+
There are two main changes compared to "pipelined pessimistic lock": loss of pessimistic locks and lock migration.
214+
215+
### Loss of pessimistic locks
216+
217+
With "pipelined pessimistic lock", if a pessimistic lock is read, it will either be rolled back or turned into a 2PC lock. But this is different if pessimistic locks only exist in the memory. The pessimistic lock in the memory is readable, but it can be lost later due to various reasons like TiKV crash.
218+
219+
Luckily, this does not have much impact.
220+
221+
- Reading exactly the key of which pessimistic lock is lost is not affected, because the pessimistic lock is totally invisible to the reader.
222+
- If a secondary 2PC lock is read while the primary lock is still in the pessimistic stage, the reader will call `CheckTxnStatus` to the primary lock:
223+
- If the primary lock exists, `min_commit_ts` of the lock is advanced, so the reader will not be blocked. **This operation must be replicated through Raft.** Otherwise, if the primary lock is lost, we may allow a smaller commit TS, breaking snapshot isolation.
224+
- If the primary lock is lost, `CheckTxnStatus` will do nothing until a lock is prewritten or the TTL is expired. The behavior is no different from before. There can be optimizations that avoid waiting for lock expiration but that's out of the range of this RFC.
225+
- A different transaction can resolve the pessimistic lock when it encounters the pessimistic lock in `AcquirePessimisticLock` or `Prewrite`. So, if the lock is lost, `PessimisticRollback` will find no lock and do nothing. No change is needed.
226+
- `TxnHeartBeat` will fail after the loss of pessimistic locks. But it will not affect correctness.
227+
228+
### Lock migration
229+
230+
Before lock migration, we need to scan all the memory locks in the region. To reduce unavailability time, the region will still be available to write between scanning and the region change. So the migrated locks are a stale and partial snapshot of pessimistic locks of the region. We must make sure that everything should work well after these locks are ingested.
231+
232+
#### Leader transfer
233+
234+
After `is_valid` is set to `false`, later pessimistic locks are replicated through Raft proposals. So, these new pessimistic locks won't be missing.
235+
236+
For the just migrated locks, we should guarantee no lock will be missing and no deleted lock will appear again on the new leader. Considering following cases with different order of operations when there is a concurrent write command that will remove an existing pessimistic lock:
237+
238+
1. Propose write -> propose locks -> apply write -> apply locks -> transfer leader
239+
Because the locks marked as deleted will not be proposed. The lock will be deleted when applying the write while not showing up again after applying the locks. On the new leader, the write command is successfully applied, so the lock information is correct.
240+
241+
2. Propose locks -> propose write -> transfer leader
242+
No lock will be lost in normal cases because the write request has been sent to the raftstore, it is likely to be proposed successfully, while the leader will need at least another round to receive the transfer leader message from the transferree.
243+
244+
#### Region merge
245+
246+
We reject all writings before proposing `PrepareMerge` and wait until the latest proposed command is applied. After that, we can make sure that no lock with a deleted mark will be deleted successfully. Either it should have been deleted because it is applied, or the write command will be rejected.
247+
248+
So, considering the different cases like leader transfer:
249+
250+
1. Propose write -> reject write -> apply write -> propose locks -> propose prepare merge
251+
The proposed write command will be applied successfully before proposing the existing pessimistic locks. This means the proposed locks will not include the locks that are deleted by the write command. It is correct.
252+
253+
2. Reject write -> propose write -> propose locks -> propose prepare merge
254+
The write command will be rejected and will not be applied. So, we need to propose the pessimistic locks marked as deleted, because they will not be deleted by the source region leader and should be moved to the new region.
255+
256+
#### Region split
257+
258+
Region split happens on the same node. Considering the different orders as always:
259+
260+
1. Propose write -> propose split -> apply write -> execute split
261+
The write will be applied earlier than split. So, the lock will be deleted earlier than moving locks to new regions.
262+
263+
2. Propose split -> propose write -> ready split -> apply write
264+
The write will be skipped because its version is lower than the new region. So, no lock should be deleted in this case. It is correct for us to transfer the locks with deleted marks.
265+
266+
3. Propose split -> ready split -> propose write
267+
The write proposal will be rejected because of version mismatch. So, it is correct to include the locks with deleted marks.

0 commit comments

Comments
 (0)