TiDB
Paper: Huang et al., TiDB: A Raft-based HTAP Database (PVLDB 2020, PingCAP).
Last time: Postgres — one machine, heap + indexes, extend the type system. Today: how do you get SQL + ACID when the data no longer fits on one box? The full Raft paper is Wednesday; below is only the background TiDB assumes.
A little history
The problem they were selling
- LAMP in 2000s: Linux, Apache, MySQL, PHP
- e.g. Facebook
- Mid-2010s “NewSQL”: people had outgrown one MySQL / one Postgres
- writes do not scale on a single primary
- the industry answer was shard in the app (proxy,
user_id % N)
- Bigtable / Spanner showed a prettier picture: range shards + consensus + SQL
- not something you could download and point JDBC at
- PingCAP (~2015, China): a Spanner-shaped system that existing apps can speak to
Why the MySQL wire, not Postgres
- Who they were selling to.
- China internet was LAMP: MySQL + sharding proxies, not Postgres
- Huang (CTO): MySQL
- CockroachDB / Yugabyte later took the Postgres wire — different home market
Why not MySQL + a distributed storage engine?
- first week they tried “MySQL + a distributed storage engine”
- dropped it: MySQL’s optimizer / executor are single-node
- So: new SQL layer (Go) + TiKV (Rust) + MySQL on the wire
- pitch: keep the client; hide the shards
- reuse drivers, ORMs,
mysqldump, DBA tools.
Takeaway: they cloned Spanner’s architecture, not MySQL’s storage. MySQL is the front door.
Architecture
Three processes. You deploy all of them. A lone TiKV is not a database.
app --MySQL proto--> TiDB SQL (stateless) --> TiKV (rows)
| ^
+-----> PD --------+
- TiDB SQL: parse, optimize, execute. No durable rows. Scale by adding nodes
- PD (Placement Driver): control plane — where is this key, who should move, timestamps
- TiKV: data plane — the shards, Raft, RocksDB
From a table to a key-value map
No Postgres heap file. The whole cluster is one ordered KV map:
key = {tableID, rowID} (rowID often the PK)
value = {col0, col1, …}
Secondary indexes are more KV pairs (index key → primary key).
The SQL engine is a translator from SQL to KV reads/writes.
Raft background (for this lecture)
- The network is asynchronous
- messages can be delayed, dropped, reordered; the network can partition
- no shared clock; a node cannot tell crash from “slow”
- Split brain: two servers both think they are primary and accept writes
- replicas diverge; no single copy is truth
- Consensus (Raft): agree on a log of commands so everyone applies the same sequence
- leader + majority (quorum)
- any two majorities overlap → two leaders cannot both commit
- a minority can be down or partitioned; they just do not get to decide
- Log: an array of log entries
- each entry has a command or a group of commands
- committed = a majority has the entry, and it will not be forgotten
- Wednesday: the full details of Raft
Region = range + Raft group
Cut the map into Regions: contiguous key ranges.
- paper default: ~96 MB.
- one Region = one Raft group (usually 3 peers; one leader serves reads/writes)
- many groups on one machine = multi-Raft
- PD places groups so replicas sit on different TiKV nodes (and AZs)
Why many Raft groups, not one group per machine?
- Raft group = unit of placement and failover
- Region A’s copies can live on
{1,2,3}, Region B’s on{1,4,5} - move / split a hot range without shipping the rest of the node
- many leaders ⇒ write parallelism
Split / merge are themselves Raft commands (metadata). Idle groups heartbeat more slowly.
PD vs TiKV:
- PD has the cluster-wide Region map + TSO.
- TiKV has only local peers. It does not answer “which node has this key?” for the whole cluster
TiKV write / read path (OLTP)
Naive Raft is one request at a time (append, ship, wait majority, apply, reply). Leader is the bottleneck. Tricks in the paper:
- append locally in parallel with shipping to followers
- batch log RPCs
- pipeline: don’t wait for ack before the next batch
- async apply on another thread once committed
Linearizable reads without a dummy log entry per read:
- Read index: record commit index, heartbeat to prove you are still leader, wait until applied
- Lease read: during a lease, no extra RPCs (clocks must be close enough)
- Follower read: follower asks the leader for a read index, then waits — spreads hot Region reads
Transactions
- A transaction: a group of reads/writes that should commit all-or-nothing and look isolated from other txns
- One SQL txn can touch many Regions — i.e. many Raft groups
- Raft only orders the log inside one Region. It does not run the SQL txn
- So you still need:
- timestamps to order txns (PD’s TSO = timestamp oracle)
- a commit protocol across Regions (two-phase commit; the paper uses Google Percolator)
- Locks live next to the keys in TiKV, not in a central lock manager
- MVCC: keep old versions so a reader can see a consistent snapshot
HTAP: OLTP + OLAP
OLTP and OLAP want different layouts and different machines.
- OLTP (online transaction processing): many small reads/writes, indexes, latest row
- “debit this account,”
SELECT … WHERE pk = ?
- “debit this account,”
- OLAP (online analytic processing): scan lots of rows, few columns, aggregations / joins
SUM(bal) GROUP BY regionover the whole table
- NewSQL (Spanner, CRDB, TiKV) is the first, not the second
- Stonebraker: “one size does not fit all” — two systems are expensive, and the warehouse is stale
HTAP extra requirements (on top of NewSQL):
- Freshness: how recent is the analytic replica?
- Isolation: OLAP must not crush OLTP (and vice versa)
Why the obvious designs fail
- ETL (extract / transform / load) every few hours: dump OLTP into a warehouse; consistent-ish, not fresh
- Stream the log into a second system: fresher, no global txn story
- One in-memory engine (HyPer, HANA, MemSQL): fresh, same cores/RAM
- why it fails: analytics steal CPU / cache / memory bandwidth from txns (no resource isolation)
- CH-benCHmark: HANA OLTP ~3× slower with analytics; HyPer ~5×
- More Raft followers: still in the quorum
- leader waits on a larger majority → OLTP slower
- a follower can still become leader → not a dedicated OLAP box
Need: a replica that is up to date, columnar, and not in the write quorum.
Idea: Raft learners + a column store
- Standard group: leader + followers (row, OLTP) on TiKV
- Add a learner (TiFlash) on other machines:
- asynchronously tails the leader’s log
- not in the election / quorum → leader does not wait on it at commit
- at read time, catch up to a snapshot timestamp, then scan
- Apply the log into a column store (not another row store)
- drop aborted txns, decode rows, transpose to columns
- cannot rewrite huge compressed column files on every small write; a plain LSM makes analytic reads merge many files
- so: stable columnar chunks (good for
SUM/COUNT) plus a small delta of recent updates (paper: DeltaTree)
- Freshness = log lag (paper: often < 1 s). Isolation = other machines.
Row store vs column store
A row store keeps a tuple together. A column store keeps a column together.
Row store (Postgres heap, InnoDB, TiKV): on disk you see
[id=1, name=Ann, bal=10] [id=2, name=Bob, bal=20] …
- one I/O brings the whole row
- point lookup / UPDATE of a few rows is cheap
SUM(bal)still readsname(and everything else) off the page
Column store (MonetDB, C-Store/Vertica, Parquet, TiFlash): on disk you see
id: [1, 2, 3, …]
name: [Ann, Bob, …]
bal: [10, 20, …]
SUM(bal)reads only thebalfile- values in one column are similar → compress well
- engines run vectorized loops on arrays of one type
- reconstructing “row 17” gathers from many files; an UPDATE may touch every column file
So OLTP wants rows; analytics (few columns, many rows) wants columns. TiDB’s bet: same logical table, two physical layouts, glued by the Raft log.
Query processing: SQL vs logical plan vs physical plan
- SQL: syntax and sugar (
IN/EXISTS, views,SELECT *, join written as comma-FROM) - Optimizer pipeline: SQL → (many) logical plans → (many) physical plans → run one.
- Logical plan: a tree of relational algebra (
σ,⋈,π,γ). What to compute, not how- binding has happened:
balis nowAccount.bal(table id, type) - one SQL can rewrite to many equivalent logical trees (join order, unnest a subquery)
- different SQL can become the same logical join
- binding has happened:
- Physical plan: algorithms + access paths. How to compute it
- heap scan vs index vs column store
- hash join vs index nested-loop vs sort-merge
- sort vs hash aggregate
- Optimizer:
- RBO (rule-based optimizer): rewrite with fixed algebraic rules. No “is this table big?”
- Push a filter below a join, fold constants, unnest a subquery. Produces a nicer logical plan.
- CBO (cost-based optimizer): estimate cost from stats (row counts, tuple/column size, seeks vs scans, Region count) and pick the cheapest physical plan.
- TiDB: RBO first (logical), then CBO (physical). Three scan shapes: TiKV row, TiKV index, TiFlash column. A plan can mix stores (index into T on TiKV, hash-join S from TiFlash).
- RBO (rule-based optimizer): rewrite with fixed algebraic rules. No “is this table big?”
Evaluation (what to remember)
- TPC-C: OLTP — many small txns (new order, payment, …) on a wholesale warehouse schema; indexed lookups and updates
- TPC-H: OLAP — ~22 long queries (scans, joins, aggregations) on a different sales/supply-chain schema (parts, suppliers, orders)
- a manager asking e.g. revenue by nation, not a clerk ringing up an order (“decision support”)
- CH-benCHmark = TPC-C txns + TPC-H-style queries rewritten onto the TPC-C schema (same data, both workloads)
- OLTP: often beats CRDB in their setup
- OLAP: TiKV + TiFlash together often beats either store alone (joins)
- HTAP: extra AP clients cut TP by ≤ ~10%; MemSQL TP drops >5×
- Learner lag: tens–hundreds of ms; ~1 s “fresh enough” on 100 warehouses
Questions to review
- Why MySQL on the wire if they threw away MySQL’s engine?
- PD vs TiKV: who knows the cluster-wide key → node map?
- Why many Raft groups but one RocksDB per node?
- Region 96 MB: too big? too small? who splits?
- Why encode a row as
{tableID,rowID} → columnsinstead of a heap file like Postgres? - SQL vs logical plan: same query, two join orders — which object changed?
- Row store vs column store: why is
SUM(bal)cheaper on TiFlash? why isUPDATEcheaper on TiKV? - Percolator vs “one Raft group for the whole txn”?
- Why not ETL? Why not “just add Raft followers”?
- Learner vs follower: who waits at write time? at read time?
- Will the analytic replica always match the leader before the client’s OLTP commit returns?
- Percolator logs on the learner: why compact rollbacks before building columns?
- DeltaTree vs feeding TiFlash from RocksDB: what hurts analytic reads?
- When is an index join on TiKV cheaper than a hash join on TiFlash?
- PD as timestamp oracle: what breaks if PD is partitioned from TiKV?
- “One size fits all is dead” vs TiDB: one system, or two stores glued by a log?
Extra details
One RocksDB, many Regions
Default TiKV (the paper, and still Dedicated / self-managed):
- one KV RocksDB per node — all Regions on that machine share it
- one log store per node (paper: another RocksDB; today usually Raft Engine)
- Region id is in the key prefix; the LSM does not know about Raft
Why not one RocksDB per Region? A node has thousands of Regions. RocksDB is heavy (memtables, WAL, open SSTs, compaction). A 96–256 MB tree is a lot of overhead per byte. One shared LSM batches puts into one WAL and one compaction pipeline.
Cost they accepted: compacting or moving one range rewrites mixed SSTs.
(Later they tried “one RocksDB per Region” — still experimental. Cloud elastic tiers use a new per-Region LSM on S3, TiDB X. Not this paper.)