pingcap / pingcap/tidb

transaction support for lossy column change optimization

Open
#63,473 0 comments 0 reactions 0 assignees View on GitHub
type/feature-request
Dominant language
Go
Stars
40.5k
Forks
6.2k
PR merge metrics
PR metrics pending

Description

## Background

When DDL performs lossy column changes, the backfill phase needs to update the values of the columns. An optimization that DDL is currently working on is the reasonable key range partitioning, which allows backfill tasks to use 1PC as much as possible to improve performance.

Another potential optimization point in this scenario is to reduce task retries of DDL backfill transactions caused by conflicts with normal business writes. This is because backfill transactions have their own special semantics: if a backfill transaction finds that a column of a record has already been updated by another transaction while writing to that column, the backfill can skip that record.

Under normal circumstances, when a transaction attempts to modify a record and encounters modifications made by an updating transaction, this is a transaction conflict. However, for DDL backfill transactions, this situation may not be treated as a conflict. Its logic is:

1. If backfill encounters a transaction where the key has already been modified by an updated transaction, there is no need to backfill the new column value; instead, the value of the new transaction should be retained
2. If backfill encounters a key that has not been modified yet, it fills in the new column value
3. If the key encountered by backfill is currently being written, it is necessary to wait for the state to become definite, and then execute 1 or 2 depending on the situation

## Objective

The transaction layer needs to provide new capabilities to support the use case of DDL backfill transactions and lossy column changes. The expected outcome:
- Performance should be improved by 1PC
- The majority of transaction conflicts should be avoided by the new API
- Keep the code clean as a bonus for long term maintainability

## Design

Plan to add a transaction option skip_new_change to support the transaction requirements of DDL backfill

### How to Use

```
var collector interface { CollectLockKV(kvpair *kvrpcpb.KvPair) }
snap := store.GetSnapshot(ver)
// For snapshot with this option, if it meets a KV record with newer change than `ver` during scanning, that KV record is skipped
// When snap scanning meet locks, locks are collected to the caller, the caller should handle them later.
snap.SetOption(kv.SkipNewChange, collector)
```

```
txn := store.Begin()
// For txn with this option, if the txn's mutations find newer change than that KV record, the mutation is ignored
// When txn commit meet locks, locks are resolved just as usual, the caller does not need to take extra care
txn.SetOption(kv.SkipNewChange, nil)
txn.Commit()
```

### Kvproto

```
diff --git a/proto/kvrpcpb.proto b/proto/kvrpcpb.proto
index a20e0c9..a65b1db 100644
--- a/proto/kvrpcpb.proto
+++ b/proto/kvrpcpb.proto
@@ -65,6 +65,8 @@ message ScanRequest {
// If sample_step > 0, skips 'sample_step - 1' number of keys after each returned key.
// locks are not checked.
uint32 sample_step = 8;
+ // If this flag is set, the scan operation will not return the kv pair when newer version exists.
+ bool skip_newer_change = 9;
}

message ScanResponse {
@@ -136,6 +138,9 @@ message PrewriteRequest {
// for_update_ts constriants that should be checked when prewriting a pessimistic transaction.
// See https://github.com/tikv/tikv/issues/14311
repeated ForUpdateTSConstraint for_update_ts_constraints = 16;
+ // If this flag is set, when a mutation meets newer version change, the mutation is discard silently, and that
+ // case is not consider as conflict.
+ bool skip_newer_change = 17;

// Reserved for file based transaction.
repeated uint64 txn_file_chunks = 100;
```

### TiDB Code Changes

SetOption() passes the flag all the way down from the transaction until it reaches the request

`txn.SetOption(kv.SkipNewerChange)` is a not a safe operation. Its usage should be limited to DDL backfill.

After setting that option, the integrality of transaction is lost. It could succeess with part of the data. Imagine that we have a transaction with data records and index records, and the data records success with no conflict, while the index records meet conflicts and are discarded. After that transaction commit, data and index are in-consistency!

So in the implementation side, a different API is used. Normal transaction uses `txn.Commit()` while DDL backfill txn uses `txn.DDLBackfillTxnCommit()`.

### TiKV Changes

Process this flag and execute according to the semantics required by this flag.

When encountering this situation, it would originally return a `KeyError` of type `Conflict`, and originally would directly exit upon first encountering such a conflict.

Now, instead of exiting directly , we collect all `KeyErrors` of type `Conflict`. In `PrewriteResponse`, all the conflicts are collected to`[]KeyError` field.

### Client-go Changes

When `skip_newer_change` is set, after returning from prewrite:

1. No lock encountered, normal processing
2. When encountering a lock, follow the normal resolve lock process
- After resolving, re-send the request, then it's either case 1 (rollback) or case 3
- Code for resolve lock logic does not need any changes
3. Encountered write conflict
- If it is 1PC, it means the transaction is completed
- If it is 2PC, a commit operation is still required. At this time, the commit operation needs to ignore the keys with write conflicts returned by prewrite, and only commit the remaining part

### Corner case

A transaction must have a primary key, and all the other mutations are secondaries. The primary key decides the final states of a transaction, like commit/ongoing/rollback etc. If transaction meet lock on secondaries, it checks the primary key to get the state of the transaction.

`skip_newer_change` skips conflict on keys, so how about conflict on the primary key? That's a corner case. We should not skip conflict on the primary key. Because if we write secondaries but skip the primary key, there is no way to get the state of the transaction. This is fundamental constraint broken.

## Compatibility

Since `skip_newer_change` is just optional flag added to the KVProto, BR & CDC and other ecosystem tools are not affected.

If TiDB support `skip_newer_change` but TiKV does not support it, SetOption is a dummy operation.
If TiKV support `skip_newer_change` but TiDB does not support it, the upper layer will not pass the flag to TiKV, so that's OK.
To avoid any protocol mismatch, we still need to guarantee TiDB's version and TiKV's version match.

For client-go, if `skip_newer_change` is not set, it just works as if this option does not exist. But if it is set and client-go does not support it, that could be a compatibility breaker.

## Alternate

Provide separate API and kvproto `DDLBackfillScan` and `DDLBackfillCommit`

That requires much more work for both transaction team and DDL team.
The API will expose low-level control to DDL, and the caller need to handle all the lock and conflict retry and 1PC themselves, that's a lot of dirty work and the API is not easy to use.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.