Data Modeling

Model data from the access patterns: list the queries first, choose keys and partition keys that answer each in one place, and denormalize on purpose.

17 minLesson 6 of 6
On this page
  1. Access patterns first
  2. A worked schema
  3. Normalize, then copy on purpose
  4. Choosing the partition key
  5. How a booking flows through the model
  6. In the interview
  7. Common mistakes
  8. What to practice next

A team builds the reservation system from requirements and scope. They split the reservations table across 8 database partitions by a hash of the reservation id, so rows spread perfectly evenly. Then two diners book table 7 at 19:30 at the same moment. Their two new reservations hash to different partitions, each partition checks only its own rows, both find no conflict, and both commit. The table is double-booked, the one thing the requirements forbade.

The partitioning was wrong for this system because nobody first asked which queries run, and which of them must see each other's writes.

Access patterns first

Entities are the easy part: restaurants, tables, time slots, reservations, diners. Draw them in a minute. Then list every way the system reads and writes them, with a rough rate, before choosing a single key. The rates come from the estimates (20 million searches and 1 million bookings a day, so about 1,000 searches and 50 bookings a second at peak).

Access patternPeak rateLooked up by
Open slots at one restaurant on one date, for a party size1,000/srestaurant, date
Book a slot, never twice50/srestaurant, table, time
A restaurant's bookings for tonightlowrestaurant, date
A diner's upcoming reservationsmoderatediner, time
Restaurants by city and cuisinewith searchtext and filters

Read the right-hand column: almost everything starts from a restaurant, one pattern starts from a diner, and the last belongs in a search index, not this database.

A worked schema

Reservation tables
restaurants (restaurant_id PK, name, city, cuisine, timezone)
 
tables      (restaurant_id, table_id, seats,
             PRIMARY KEY (restaurant_id, table_id))
 
slots       (restaurant_id, slot_date, slot_time, table_id, seats,
             reservation_id NULL,          -- NULL means free
             PRIMARY KEY (restaurant_id, slot_date, slot_time, table_id))
 
reservations (restaurant_id, reservation_id, table_id, slot_date, slot_time,
              user_id, party_size, status, created_at,
              PRIMARY KEY (restaurant_id, reservation_id))
 
user_reservations (user_id, slot_date, slot_time, reservation_id,
                   restaurant_name,        -- copied on purpose
                   PRIMARY KEY (user_id, slot_date, slot_time, reservation_id))

Why each piece is there:

  • A row per slot. "Is table 7 free at 19:30?" becomes one row, and booking becomes a conditional update: set reservation_id only where it is still NULL. If zero rows change, someone else won, and the API returns 409. The database does the check and the write as one step, so there is no race.
  • Every restaurant table starts with restaurant_id. The slot update and the reservation insert share a partition key, so they commit in one local transaction, and an open-slots query reads one contiguous range.
  • user_reservations repeats data. The diner's page needs reservations sorted by time with the restaurant's name. Keyed by user_id, that is one range read with no join.

Size it. Slots for 50,000 restaurants × 20 tables × 8 sittings a day × 60 days ahead = 480 million rows; at about 50 bytes each, 24 GB. Reservations at 1 million a day × 365 × 200 bytes = 73 GB a year. Both fit one server, and about 1,050 operations a second at peak suits one primary with read replicas. You may never shard. Pick the partition key anyway: if you ever do, it decides which transactions stay on one machine.

Normalize, then copy on purpose

Normalized means each fact lives in one place: the restaurant's name only in restaurants. Updates are simple and nothing disagrees, but reads join. Denormalized means you copy facts next to where they are read, so reads are one lookup, and every change must reach every copy.

Where the restaurant name lives
OptionGood atBad atPick it when
Normalized onlyOne copy, always correct. Easy to change.The diner's page joins reservations to restaurants, and across partitions that is many lookups.One database node, or data that changes often.
Copy into the read view Usual defaultThe diner's page is one partition read.A renamed restaurant shows the old name until the copies are updated.Data read far more than it changes, where brief staleness is harmless. This case.

The rule: start normalized and denormalize one access pattern at a time, each with a named reason and a named way to keep the copy in step.

Choosing the partition key

When data outgrows one machine, the partition key decides which machine holds each row (the mechanics are in sharding and partitioning). A good key puts everything a frequent query or a transaction needs on one partition, and spreads load evenly. Those two goals pull against each other.

Partition key for slots and reservations
OptionGood atBad atPick it when
restaurant_id Usual defaultBooking checks, open-slot reads and tonight's list each touch one partition.A very popular restaurant concentrates its traffic on one partition.Most queries and every transaction start from a restaurant. This case.
Hash of reservation_idPerfectly even spread.No query starts from a reservation id except "get by id". Booking checks span partitions: the hook's double booking.Reads are almost always by the item's own id.
user_idA diner's list is local.Two diners booking the same table land on different partitions.As the key of the separate per-diner view only.
cityCity searches are local.A few big cities hold most of the load: badly uneven partitions.Rarely. Low-cardinality keys make hot spots.

What would change the choice: if one restaurant could take thousands of bookings a second (a stadium, say), its partition would run hot and you would split its slots by date or section. At 50 bookings a second for the whole system, that is not this problem.

How a booking flows through the model

One booking, two partitions, one lagging viewUse the arrows or ← → keys
  1. One booking, two partitions, one lagging view, step 1Diner to Reservation API; Reservation API to By restaurant_id (book); Reservation API to Booking events, asynchronous; Booking events to View updater, asynchronous; View updater to By user_id (upsert); Reservation API to By user_id (my list).DinerReservation APIBy restaurant_idBy user_idBooking eventsView updaterbookupsertmy list

    1A diner books table 7 at 19:30 at restaurant 42. The API routes by restaurant_id 42 to one partition.

  2. One booking, two partitions, one lagging view, step 2Diner to Reservation API; Reservation API to By restaurant_id (book); Reservation API to Booking events, asynchronous; Booking events to View updater, asynchronous; View updater to By user_id (upsert); Reservation API to By user_id (my list).DinerReservation APIBy restaurant_idslot → r-5521By user_idBooking eventsView updaterbookupsertmy list

    2On that partition, one transaction: the slot update (only if still free) and the reservation insert. Same partition key, so no cross-machine coordination.

  3. One booking, two partitions, one lagging view, step 3Diner to Reservation API; Reservation API to By restaurant_id (book); Reservation API to Booking events, asynchronous; Booking events to View updater, asynchronous; View updater to By user_id (upsert); Reservation API to By user_id (my list).DinerReservation APIBy restaurant_idBy user_idBooking eventsView updaterbookupsertmy list

    3It commits and the diner gets 201. The booking is now final, whatever happens next.

  4. One booking, two partitions, one lagging view, step 4Diner to Reservation API; Reservation API to By restaurant_id (book); Reservation API to Booking events, asynchronous; Booking events to View updater, asynchronous; View updater to By user_id (upsert); Reservation API to By user_id (my list).DinerReservation APIBy restaurant_idBy user_idBooking eventsView updaterbookupsertmy list

    4A booked event goes on the queue. (In production, write the event to an outbox table in the booking transaction and let a relay publish it, so a crash cannot lose it.)

  5. One booking, two partitions, one lagging view, step 5Diner to Reservation API; Reservation API to By restaurant_id (book); Reservation API to Booking events, asynchronous; Booking events to View updater, asynchronous; View updater to By user_id (upsert); Reservation API to By user_id (my list).DinerReservation APIBy restaurant_idBy user_idu-9: r-5521Booking eventsView updaterbookupsertmy list

    5The view updater writes a row into user_reservations on the diner's partition, with the restaurant name copied in. It upserts, so an event delivered twice is harmless.

  6. One booking, two partitions, one lagging view, step 6Diner to Reservation API; Reservation API to By restaurant_id (book); Reservation API to Booking events, asynchronous; Booking events to View updater, asynchronous; View updater to By user_id (upsert); Reservation API to By user_id (my list).DinerReservation APIBy restaurant_idBy user_idBooking eventsView updaterbookupsertmy list

    6The diner opens "My reservations": one range read on one partition by user_id, already sorted by date and time.

  7. One booking, two partitions, one lagging view, step 7Diner to Reservation API; Reservation API to By restaurant_id (book); Reservation API to Booking events, asynchronous; Booking events to View updater, asynchronous; View updater to By user_id (upsert); Reservation API to By user_id (my list).DinerReservation APIBy restaurant_idBy user_idBooking eventsevents waitingView updaterdownbookupsertmy list

    7Now the updater is down. New bookings still succeed and stay correct; only the diner's list lags. The app shows the booking from the 201 reply until the view catches up.

1 / 7

In the interview

Spend about 3 minutes here, as in the framework, and let the access patterns pick the store: single-key lookups suggest a key-value store, transactions and constraints suggest a relational database (see choosing a database). Here that is a relational database, because a booking is a conditional update plus an insert in one transaction. Phrases that show judgment:

  • "Before picking keys, here are the five queries and how often each runs."
  • "Booking must be atomic with the availability check, so both live under the same partition key."
  • "The diner's list is a denormalized view, updated from events. It can lag a few seconds; the booking itself cannot."
Choosing the partition keyStrong answer
Interviewer

How would you partition the reservations?

You

By restaurant. The booking check, the open-slot query and the restaurant's own view all start from a restaurant, so each is one partition. The cost is the diner's list, which starts from the user, so I keep a separate copy keyed by user id.

Interviewer

What about a hugely popular restaurant?

You

It's at most a few hundred slots a night, so the volume is small; the issue is contention on the same rows when bookings open. The conditional update keeps it correct, and a queue in front of that restaurant's booking page would smooth the burst.

Follow-ups you should expect:

  • "Why not a secondary index on user_id?" On one node, that is simpler and fine. On a partitioned store, a global secondary index is a separately partitioned copy. Depending on the store it updates asynchronously (the same lag, with less control) or in a cross-partition transaction (slower bookings).
  • "How do you handle time zones?" Store the slot in the restaurant's local date and time plus its time zone, since that is how diners and staff think about it.

Common mistakes

Quick check

Slots are stored as one row per table per sitting: 50,000 restaurants, 20 tables each, 8 sittings a day, 60 days ahead, about 50 bytes a row. About how big is the table?

Why is user_id a poor partition key for the slots table?

What does copying restaurant_name into user_reservations cost?

What to practice next

See how indexes make each access pattern fast in Indexing, then practice the whole module on Design Ticket Booking, where the same no-double-booking rule shapes the data.