System designDeep dive10 min

Secondary indexes on sharded data

Sharding by one key makes the queries on that key fast and leaves every other query asking every shard. Secondary indexes are how the other queries find their rows, and on sharded data each kind of index makes a different trade: cheap writes and expensive reads, cheap reads and lagging results, or consistent results and a distributed transaction on every write.

1 · The problem

The shard key serves one access path, and every other query needs an index

A used-car marketplace holds 40 million listings from 60,000 dealers, sharded by dealer ID across 32 shards, about 1.25 million listings each. Dealers manage their stock all day, and every one of those queries carries the dealer ID, so each touches one shard. Buyers don't know or care which dealer has the car:

Query

Has the shard key?

Without an index

A dealer's stock, sorted by price

Yes, dealer ID

One shard

Toyota RAV4s, cheapest first, 20 a page

No

All 32 shards

Is this VIN already listed?

No

All 32 shards

Every VIN must be listed only once

No

Can't be enforced on one shard

Sharding & partitioning introduces the two basic answers: a local index that each shard keeps for its own rows, or a global index split by the indexed value. This page follows each through its reads, its writes, its consistency and its limits, then adds the two cases they don't cover: indexes that stay on one shard by design, and uniqueness.

2 · Local indexes

A local index keeps writes on one shard and sends every read to all of them

With local indexes, each shard indexes (make, model, price) for its own 1.25 million listings. A new listing updates its row and the index entry on the same shard, in the same local transaction, so the index is never stale. The cost moves to reads. MongoDB's router broadcasts any query without the shard key to every shard, and Vitess does the same unless it has a cross-shard index to consult:

Each shard uses its index well, but all 32 must answer. The router merges 640 rows by price and returns the first 20, and the page waits for the slowest shard.

The router can't know which shard holds the 20 cheapest, so each shard sends its own top 20; MongoDB passes the limit to every shard and applies it again after merging. Skipping pages is worse: the router can't pass a skip down, so for page 50 every shard returns its first 1,000 rows:

Page 1, 20 shown
640 rows
Page 50, 20 shown
32,000 rows

Rows sent to the router for one page: 32 shards × (skip + limit). A cursor that starts after the last price seen keeps every page as cheap as the first.

Local indexes suit queries that are rare, or that need every shard anyway, like analytics. For the marketplace's busiest buyer query, every search costs 32 shards' work, and adding shards adds work to each search instead of capacity (Latency covers why waiting on many servers makes the slow tail common).

3 · Index by parent

An index that starts with the shard key never leaves its shard

Not every secondary index needs to be global. The dealer's stock page searches within one dealer: by price, by age in stock, by model. An index whose key starts with the shard key, (dealer_id, price), keeps every entry on the same shard as its rows, so both the write and the read stay local and consistent.

Databases name this pattern differently:

  • Spanner calls it an interleaved index. It's the better choice whenever queries filter by the parent's key, because the index entry lands in the same split as the row and writes stay local.
  • DynamoDB calls it a local secondary index: same partition key, different sort key, and it allows strongly consistent reads.

Either way, the index answers only questions about one parent, which is exactly what the dealer's pages ask.

Aspect

Index starting with the shard key

Index on another column

Example

(dealer_id, price)

(make, model, price)

Read

One shard

Every shard if local, one if global

Write

One shard, same transaction

Local: one shard. Global: two places

Limit

Only answers per-dealer questions

Pays in reads or in writes

The catch is size per parent. DynamoDB caps everything that shares a partition key, the items plus their local index entries, at 10 GB on tables with local secondary indexes. A marketplace with one dealer far larger than the rest should check that before relying on it (Hot partitions & skew).

4 · Global indexes

A global index is sharded by the indexed value, so reads ask one place and writes touch two

A global index is a second sharded table: entries keyed by (make, model, price), partitioned by make and model. All RAV4 entries live together, sorted by price, so the buyer's search reads one index partition and stops after 20 entries. The price is on the write side: a new listing goes to its dealer's shard, and its index entry goes to whichever partition owns "Toyota RAV4":

New listingdealer 812, RAV4Listings shard 7by dealer IDIndex partition 19by make and modelwrite the rowupdate index entryNew listingdealer 812, RAV4Listings shard 7by dealer IDIndex partition 19by make and modelwrite the rowupdate index entry

Two writes on two machines for one listing. Whether the second happens later or in the same transaction is the defining choice of a global index.

DynamoDB's global secondary indexes do it later. The table write succeeds first, and DynamoDB updates the index asynchronously, normally within a fraction of a second, so a query on the index can briefly miss a listing the table already has. A dealer who lists a car and searches for it straight away may not see it. Reads that must be current go to the table by its key.

The index is also a separate set of partitions with its own capacity. If a global index can't absorb the write rate, DynamoDB throttles writes to the table itself. And its key decides its hot spots: every RAV4 listing, price change and sale lands on the one partition holding RAV4s. A higher-cardinality key spreads that load, such as make, model and region together (Hot partitions & skew).

5 · Consistent globals

A consistent global index makes every write a distributed transaction

Distributed SQL databases update global indexes in the same transaction as the row, so a query on the index never misses a committed write. The price: the index entry usually lives in a different split from the row, so the write needs a two-phase commit across splits, which adds latency and CPU:

Each global index adds a participant to every write that changes its columns. A listing with three global indexes can involve up to four splits.

So the choice between asynchronous and synchronous global indexes is a choice between a moment of stale search results and slower writes everywhere (Distributed transactions: 2PC & sagas covers the protocol; Distributed SQL covers how the ranges commit). For a marketplace, search results a second old are fine; for "has this coupon code been used?", they are not.

6 · Uniqueness

A unique constraint across shards needs one place that owns each value

Every VIN must appear in at most one active listing. A local unique index can't promise that: two dealers on different shards can each insert the same VIN, and neither shard sees the other. MongoDB states the rule directly: a unique index on a sharded collection works only if it begins with the full shard key, which makes (dealer_id, vin) unique, not vin.

The fix is to give each value a single owner, a record keyed by the value itself, so the claim lands on exactly one shard and that shard can refuse a second one. MongoDB's answer is a separate proxy collection sharded on the unique field. DynamoDB's documented pattern writes an extra item whose key is the value, in the same transaction as the real item, with a condition that it doesn't exist yet:

Claiming a VIN in the same DynamoDB transaction as the listing

TransactWriteItems:
  Put  pk = "listing#9f3c…"         if attribute_not_exists(pk)
       dealer = 812, vin = "1HGCM82633A004352", price = 21400
  Put  pk = "vin#1HGCM82633A004352"  if attribute_not_exists(pk)

# a second dealer listing the same VIN:
#   TransactionCanceledException: [None, ConditionalCheckFailed]

Either both items are written or neither is, so there is never a listing without its claim or a claim without its listing. Selling or deleting the car must remove the claim in the same transaction. Vitess offers the same thing built in, a unique lookup index kept consistent with the table without two-phase commit.

7 · In practice

Choose an index per query, by how often it runs and how fresh it must be

The marketplace ends up with four approaches, each matched to a query. The dealer's stock uses an index that starts with the dealer ID. The model search uses an asynchronous global index, since a second of lag is invisible to buyers. VIN uniqueness uses a claim item written in the same transaction. Free-text search across many filters goes to a search engine fed by a change stream (Search & inverted indexes, Change data capture).

Approach

Reads

Writes

Fresh?

Starts with the shard key

One shard

One shard

Always

Local, other column

Every shard

One shard

Always

Global, asynchronous

One partition

Two places, the index later

Lags briefly

Global, synchronous

One partition

Distributed transaction

Always

  1. List queries without the shard key

    With their rate, page size and how fresh results must be.

  2. Prefer indexes that start with the shard key

    Most per-owner queries never need to leave the shard.

  3. Global indexes for hot cross-shard reads

    Asynchronous if a moment of lag is fine; synchronous if not.

  4. Check index keys for skew

    One popular value can make one index partition hot.

  5. Give unique values an owner

    A claim record keyed by the value, in the same transaction.

  6. Page with cursors

    Skip-based pages multiply rows read by the shard count.

Check yourself

4 questions

1. Listings are sharded by dealer ID with a local index on (make, model, price). How many shards does "cheapest RAV4s, 20 per page" query?
2. A dealer lists a car and searches the DynamoDB global index for it a moment later, but it's missing. Why?
3. Why does Spanner prefer an interleaved index on (dealer_id, price) for a dealer's stock page?
4. Listings are sharded by dealer. How can every VIN be listed only once?

Takeaways

Remember this

  • Queries without the shard key need a secondary index, and each kind trades read cost, write cost and freshness differently.
  • Local indexes keep writes on one shard but send every read to every shard, and skip-based paging multiplies the rows read.
  • Indexes that start with the shard key stay on one shard for reads and writes; use them for per-owner queries.
  • Global indexes read one partition but write to two places: asynchronously with brief lag, or synchronously with a distributed transaction.
  • Uniqueness across shards needs a record keyed by the value itself, written atomically with the row.