Column stores and the Cassandra data model

After this lesson you will explain what a column store gains and what it pays, decode a run-length-encoded column, draw Cassandra's data the way the slides do — keyspace, column family, row key, columns of name, value and timestamp, super columns — and say why Cassandra's tables start from the queries.

The lecture of this lesson is called "Columnar Databases – Cassandra" (p. 396). Its first half defines the family: "The Column Oriented Database stores data in columns", "mainly used in OLAP" (p. 401). A chart on p. 405 shows why: to read 4 bytes of each 148-byte record, a row store needs about 51 seconds — and about 51 seconds to read all 148. A column store reads the 4 bytes in about one second.

Then the deck turns to Cassandra, the database of its title, and p. 417 says in large grey letters: "think of cassandra as row-oriented". Page 412 adds "Often write-heavy workloads" — the opposite of analytics.

So is Cassandra a column store or not? Both slides are right. By the end of the lesson you will know in which sense each one is meant, and how to say it at the exam.

Wide-column stores and the workload behind them

Picture a messaging service: many users writing and reading small messages all day, in no particular order, and no two mailboxes alike. Page 399, "Issues with today's workloads", describes exactly this:

Its "Actual Needs": "Incremental Scalability", "Speed", "No Single point of failure", "Low cost (TCO) and admin" (TCO: total cost of ownership) and "Scale out, not up" — add ordinary machines instead of buying a bigger one (lesson 3).

Page 397 places the systems built for this among the NoSQL families of lesson 5: "Data stored in 4 types: Document, Graph, Key-value, Wide-column". Page 398, the title page of this part of the deck, reads "Big Column Stores"; the course notes explain the name: a single row can have "a huge number of different columns and that is why columnar db are also often called Big Column stores".

"Overview – NoSQL Family" (p. 397): the logo grid as a table, with the lessons of this notebook.
Family on the slideSystems on the slideLessons
Document DatabaseCouchDB, Couchbase, MarkLogic, mongoDB13–16
Graph DatabasesNeo4j, InfiniteGraph10–12
Key-value Databasesredis, Amazon DynamoDB, Aerospike, riak17–19
Wide Column StoresAccumulo, Hypertable, Cassandra, Apache HBase, Amazon SimpleDB20–22

Page 400 recalls the CAP theorem of lesson 5 — "Consistency: all nodes have same data at any time", "Availability: the system allows operations all the time", "Partition-tolerance: the system continues to work in spite of network partitions" — and places Cassandra: "Eventual (weak) consistency, Availability, Partition-tolerance". For a mailbox that is right: a message a second late hurts nobody, a mailbox that will not open does.

"Eventual" is only the default: each read or write says how many copies must answer (the CL of section 4), so one operation can buy consistency with waiting — p. 438's "tuneably consistent" (lesson 22).

Column-oriented storage: read less, write more

Before Cassandra, pp. 401–408 describe a different design that shares the word "column". Take a chain of shops that records every sale with the five columns of p. 402: date, store, product, customer, price. The cashier adds a sale every few seconds. The analyst asks, once a night, for the revenue of each store — two of the five columns, over every sale ever made.

Page 401: "The Column Oriented Database stores data in columns", "mainly used in OLAP(online Analytical Processing), Data Mining operations". OLAP is the analyst's question: few columns, very many rows. Its opposite, OLTP (online transaction processing), is the cashier's: whole records, one at a time.

Page 402 sets the two layouts side by side. A row store keeps each record in one piece: "+ easy to add/modify a record", "- might read in unnecessary data". A column store keeps each column in a piece of its own: "+ only need to read in relevant data", "- tuple writes require multiple accesses" (a tuple is a record). Verdict: "suitable for read-mostly, read-intensive, large data repositories".

One table, two ways to put it on disk

Sixteen sales with eight columns (quarter, date, store, product, customer, quantity, price, channel), four bytes per value. Above, the row store: the sales one after another, two per line. Below, the column store: one file per column. Only the quarter and the price are printed in the cells. Choose a query; dashed outlines show what each layout reads from disk, washed cells what the query needs. The two switches compress the quarter column and change the order of the rows.

  1. Keep the query "sum of price per quarter". Before you read the readouts, predict: of the bytes the row store reads, what share does the query need? Then compare the dashed outlines above and below.
    What you should see

    A quarter: 128 of the 512 bytes. The row store must read all sixteen records whole; the column store reads two files, 128 bytes, and nothing else. This is the insight the widget is built for: a column store reads only the columns a query names — here 2 of 8.

  2. Switch to "the whole record of sale 7", then to "insert a new sale". For each, predict how many separate places on disk each layout touches.
    What you should see

    1 against 8, both times. A record is one piece in the row store and eight pieces in the column store: eight seeks and a rebuild by position to read it, eight writes to add one. These are the cons of p. 404 — seek time, cost of inserts, tuple reconstruction — and the reason for "read-mostly".

  3. Go back to "sum of price per quarter" and switch on run-length encoding. Predict how many runs the quarter column becomes, then read the 1,750-row readout. Finally sort the rows by customer and predict again.
    What you should see

    Four runs, (Q1, 1, 3) (Q2, 4, 3) (Q3, 7, 5) (Q4, 12, 5): 48 bytes instead of 64. At 1,750 rows they become the triples of p. 407 and 7,000 bytes shrink to 48. Sorted by customer, neighbouring quarters always differ: 16 runs of length 1 would take 192 bytes, three times the raw column, so it stays raw (the 1,750 quarters of p. 407 in a simulated random order: 1,306 runs, 15,672 bytes). Run-length encoding pays only on sorted or clustered columns.

In numbers: with \(N\) records of \(m\) columns of \(w\) bytes, a query that needs \(k\) columns reads

\[ \begin{aligned} \text{row store:}\quad & N \cdot m \cdot w \\ \text{column store:}\quad & N \cdot k \cdot w \end{aligned} \]

so the column store reads the fraction \(k/m\): 2 of 8 columns, a quarter. Page 403 lists four "Pros" by name only; their mechanisms (background):

The "Cons" of p. 404 are the price of cutting records apart, and the widget showed each one: "Increased Disk Seek Time" (a record lives in \(m\) files), "Increased cost of Inserts" (one record, \(m\) writes), "Increases tuple reconstruction costs" (whole records must be glued back together).

Page 405, "Tuple Reconstruction", measures the trade (Fig. 1). The row store takes the same time whatever the query selects, because it always reads whole tuples; the column store's time grows with the bytes selected and catches up only near 125 of 148. The bullet "Large prefetch hides disk seeks in columns" answers the first con: reading each column file in large chunks turns many small jumps into a few long reads.

time (s) 0 20 40 60 4 36 68 100 132 148 selected bytes per tuple (a tuple is 148 bytes) row store crossover near 125 bytes column store column CPU row CPU
Fig. 1. The chart of p. 405, redrawn; the values are read off the slide's chart, not printed on it. The row store (teal, diamonds) needs about 51 s whatever the query selects. The column store (magenta, triangles) needs time in proportion to the bytes it selects, from about 1 s for 4 bytes, and loses only near 125 of the 148 bytes. The dotted lines are the CPU part of each time: under 13 s even for the column store, so rebuilding tuples is cheap next to the reading it saves.

On the right of the same slide, a box labelled Construct rebuilds the records. Position \(i\) of every column belongs to record \(i\): reconstruction is a zip by position, with no keys. And prodID is stored as the single box (4,1,4) — the value 4, from position 1, four times: the compression of the next two slides.

Construct (p. 405): four stored columns, zipped row by row (positions 1 to 4) into four tuples. prodID is stored as one run, (4,1,4).
prodIDstoreIDcustIDpricetuple
4227(4, 2, 2, 7)
41313(4, 1, 3, 13)
43342(4, 3, 3, 42)
41380(4, 1, 3, 80)

Page 406: compression "Trades I/O for CPU" — fewer bytes to read, a little decoding — and a scan waits for the disk, so the trade pays. Column stores profit most: "Higher data value locality in column stores" (neighbours belong to one attribute), "Techniques such as run length encoding far more useful", and the space saved "Can use extra space to store multiple copies of data in different sort orders", one per kind of query.

Run-length encoding (RLE) — a run of equal neighbouring values is stored as one triple (value, start_pos, run_length): the value, its first position, the number of rows.

Page 407 encodes a sales table sorted by quarter (Fig. 2): Quarter collapses to four triples, Product ID to short runs, and Price stays raw — neighbouring prices rarely repeat, and the encoding is chosen per column.

Quarter Product ID Price 1Q115 2Q117 3Q112 4Q119 5Q116 6Q128 7Q125 ……… 301Q213 302Q218 303Q211 304Q224 ………

Before: the columns as stored, position by position

Quarter Product ID Price (Q1, 1, 300) (Q2, 301, 350) (Q3, 651, 500) (Q4, 1151, 600) … … (1, 1, 5) (2, 6, 2) (1, 301, 3) (2, 304, 1) 57 29 68 5… 38 14 … each box: (value, start_pos, run_length) 1,750 rows, 4 boxes

After: Quarter and Product ID as runs, Price unchanged

Fig. 2. Run-length encoding on p. 407. The table is sorted by quarter, so Quarter is four runs: (Q1, 1, 300) reads "Q1, from position 1, 300 times", and 1,750 rows fit in four boxes. Product ID has short runs inside each quarter. Price has no runs worth encoding and stays raw.

Read one row of p. 407's table without decoding anything else:

  1. A triple \((v, s, l)\) stands for \(l\) copies of \(v\) from position \(s\):

    \[ c[i] = v \quad \text{for } s \le i \le s + l - 1 \]

    (Q1, 1, 300) covers 1–300, (Q2, 301, 350) covers 301–650.

  2. Each run starts where the last one ended, \(s_{j+1} = s_j + l_j\): 1 + 300 = 301, 301 + 350 = 651, 651 + 500 = 1151. The last ends at 1151 + 600 − 1 = 1750: the table has 1,750 rows.

  3. Ask for row 304. It lies in 301–650: the quarter is Q2.

  4. Product ID: (2, 304, 1) covers exactly 304, so the product is 2.

  5. Price is raw: its 304th value. The slide's Q2 block starts at 301 with 3, 8, 1, 4, so the price is 4.

  6. Zip by position: row 304 is (Q2, 2, 4) — tuple reconstruction again.

Run-length encoding needs runs. Sort the widget's sixteen sales by customer and no two neighbouring quarters are alike: sixteen runs of length 1, three times the raw column — the widget's last task.

A column store reads only the columns a query names, and pays one access per column whenever a whole record is read or written.

Page 408 closes with a "List of Databases": Cassandra, Vertica, SybaseIQ, C-Store, BigTable, MonetDB, LucidDB. Five are column stores in the sense of pp. 401–407 (C-Store, a university prototype, lives on as Vertica — background), like Snowflake (lesson 23) and Spark's columnar files (lesson 40). Cassandra and BigTable are something else.

Cassandra: Bigtable's data model on Dynamo's ring

Page 410: Cassandra was "Originally designed at Facebook" and is "Open-sourced and now within Apache foundation".

Avinash Lakshman — a co-author of Amazon's Dynamo paper — and Prashant Malik wrote Cassandra at Facebook for the inbox search: the random reads and writes of p. 399. It was open-sourced in July 2008 and became a top-level Apache project in February 2010. The name is the Trojan prophetess nobody believed. (Background: Wikipedia, "Apache Cassandra".)

Page 411, "innovation at scale", gives the family tree. "google bigtable (2006)": "consistency model: strong", "data model: sparse map", "clones: hbase, hypertable". "amazon dynamo (2007)": "O(1) dht", "consistency model: client tune-able", "clones: riak, voldemort". In large type: "cassandra ~= bigtable + dynamo".

The data model comes from Bigtable, the distribution from Dynamo. A sparse map is a table whose rows fill only the cells they have; empty cells cost nothing. A DHT (distributed hash table) spreads keys over machines by hashing them; O(1) means any node reaches a key's owner in one hop, because every node knows the whole ring (lesson 22). The "~=" is honest: unlike Bigtable, Cassandra has no master at all.

Page 412 draws the model as the "blog keyspace" (Fig. 3): "Column Families: Like SQL tables; but may be unstructured (client-specified); Can have index tables", "No schemas", "Some columns missing from some entries", "Supports get(key) and put(key, value) operations", "Often write-heavy workloads".

users jbellis namejonathan stateTX dhutch namedaria stateCA egilmore nameeric no state time_ordered_blogs_by_user jbellis 128984784061592dbeb5 dhutch 1289847840615d418a66 egilmore 12898478442756a0b483 an index table blog entries 92dbeb5 bodyToday I … user*jbellis category*tech d418a66 bodyI am … userdhutch categoryfashion 6a0b483 bodyThis is … useregilmore categorysports * = secondary indexes
Fig. 3. Three of the five column families of the "blog keyspace" (p. 412), redrawn. Each row is a row key (on the highlighter) followed by its own columns, and every column is a name (violet strip) above a value. egilmore has no state, and nothing at all is stored for it. time_ordered_blogs_by_user is an index table: its column names are timestamps in milliseconds and its values the ids of blog entries. Left out: subscribes_to and subscribers_of, whose column names are user names with empty values.

Rows are found by a key — here user names. time_ordered_blogs_by_user is not an entity but an answer prepared in advance: "the entries of jbellis, in time order" is one row read. The asterisks mark secondary indexes on two columns: keep them in mind for section 4.

"Page 412 says “column-oriented databases”, so Cassandra keeps each column in a file of its own, like Vertica." No: the label comes from the column families. A Cassandra row keeps all its columns together under its row key — "think of cassandra as row-oriented" (p. 417). Kleppmann, the course textbook, calls the label "very misleading" for this family (ch. 3, background). Keep the course's word, and say which sense you mean.

Pages 413–417 give the vocabulary, from the outside in:

Keyspace — "~= database", "typically one per application", "some settings are configurable only per keyspace" (p. 415): above all the replication, how many copies of each row are kept and where (lesson 21).

Column family — "group records of similar kind", "not same kind, because CFs are sparse tables" (p. 416); settings: the comparator and the type, standard or super (p. 413). Examples: User, Address, Tweet, PointOfInterest, HotelRoom.

Row — "each row is uniquely identifiable by key"; "rows group columns and super columns" (p. 417).

Column — name, value and clock (p. 413): section 4.

Page 417 is the slide of the hook: "think of cassandra as row-oriented" — one key opens a row, and its columns come back together. Page 414 draws a "Row Key" pointing to pairs of "Column Keys/Names" and "Column Values"; p. 418 shows two rows of one family with different columns; p. 419 writes them as nested maps:

// p. 419: User as nested maps
User {
  123 : { email: alison@foo.com,
          icon:  [the Cassandra eye] },
  456 : { email: eben@bar.com,
          location: The Danger Zone }
}

Think of a column family as a map of maps: row key → that row's columns, sorted by name, each one a name, a value and a timestamp.

A column has three parts, a super column has columns

Zoom into one cell. Page 420, "a column has 3 parts":

name — "byte[]", "determines sort order", "used in queries", "indexed".

value — "byte[]", "you don't query on column values".

timestamp — "long (clock)", "last write wins conflict resolution".

The name is the key of the row's inner map: sorted, so a range of names is one contiguous read, and indexed inside the row, so a name is found without scanning. The value is opaque bytes — a string, a number, the eye picture of p. 418 — stored as it comes and never sorted.

The timestamp settles disagreements between copies. One replica of row 456 holds email = eben@bar.com with timestamp 1004, another email = eben@new.com with 1010: when they are compared, 1010 wins on both. The client supplies the clock with every write (p. 428); a delete carries one too, so it can beat an older value. Simple and fast, at a price: of two concurrent writes, one is silently lost.

The sort order is set by the column family's comparator (p. 421): "byte", "utf8", "long", "timeuuid", "lexicaluuid", "<pluggable>", "ex: lat/long" — the type of the column names. For the names 2 and 10, utf8 compares characters and puts "10" first, long compares numbers and puts 2 first; timeuuid orders time-based identifiers by time, the natural choice for a timeline.

Page 422 adds a level: "super columns group columns under a common name". A super column has a name, "name: byte[]", and instead of a value a map of columns, "cols: Map<byte[], IColumn>". A column family is standard or super (the "type" of p. 413); in a super column family every row holds super columns.

Pages 423–424 fill one: PointOfInterest, keyed by ZIP code. Row 85255 holds the super columns Phoenix Zoo and Spring Training, row 10019 Central Park and Empire State Building; each holds phone and desc — or only desc, for Central Park: "flexible schema".

// p. 424: a super column family as nested maps
PointOfInterest {
  key: 85255 {
    Phoenix Zoo { phone: 480-555-5555,
      desc: They have animals here. },
    Spring Training { phone: 623-333-3333,
      desc: Fun for baseball fans. },
  }, //end phx
  key: 10019 {
    Central Park { desc: Walk around. It's pretty.} ,
    Empire State Building { phone: 212-777-7777,
      desc: Great view from 102nd floor. }
  } //end nyc
}

Page 425: "sub-column names in a SCF are not indexed", while "top level columns (SCF Name) are always indexed" — read the parenthesis as the super column names. So one sub-column costs a read of its whole super column. Super column families are "often used for denormalizing data from standard CFs": copying data to where a query reads it.

To ask for some of a row's columns you send a slice predicate, a "data structure describing columns to return" (p. 426). Its SliceRange: "start column name", "finish column name (can be empty to stop on count)", "reverse", "count (like LIMIT)". The names are sorted, so a slice is one stretch of the row:

The nested map: from the keyspace down to one column

The keyspace (called app here) holds three column families from the slides. Pick a row: you see its row key and its columns in the comparator's order, each with its name, clock and value. Then set the slice predicate: the columns it returns are washed and numbered in the order they come back. The User rows merge pp. 418–419; the clocks are made up, and two of the three timeline entries are added so that a slice has something to cut.

  1. Pick User › row 123 and leave start and finish empty, count 4. Predict the order in which the four columns come back.
    What you should see

    email, icon, n, user: alphabetical, because the comparator is utf8. The order of the names decides — not the clocks, not the values. This is the insight of the widget: a read names a row key and a stretch of sorted column names, never a value.

  2. Set start to icon and count to 2. Then switch reverse on, keeping start at icon. Predict both answers.
    What you should see

    Forward: icon, n. Reverse: icon, email — with reverse on, start becomes the upper end of the stretch and the names are walked downwards.

  3. Pick time_ordered_blogs_by_user › row jbellis, empty start and finish, reverse on, count 1. What comes back, and why is that the read a timeline needs?
    What you should see

    1290020640615 = e03a9d7, the newest entry. The names are timestamps compared as numbers (comparator long), so reverse with a count reads "the latest N" — what SQL writes as ORDER BY … DESC LIMIT.

  4. Pick PointOfInterest › row 10019, set start to Empire State Building and count to 1. Predict what comes back.
    What you should see

    The whole super column Empire State Building, desc and phone together. The slice runs over the super column names, which are indexed; the sub-columns are not, so a super column comes back whole (p. 425).

Pages 427–428 list the calls of the API of the time, Thrift RPC. Every call on these slides names its rows by key — one key, a list of keys or a range — never by a value, and ends with CL, the consistency level: how many replicas must answer (lesson 22). That is "tuneably consistent" in practice.

The read and write API of pp. 427–428 (Thrift, Cassandra of 2010).
CallWhat it does
get(key, path, CL)one column or super column of one row, at its column path ("get the Col or SC at given ColPath"); it returns a ColumnOrSuperColumn
get_slice(key, parent, predicate, CL)the columns of one row chosen by a slice predicate ("get Cols in one row")
multiget_slice(rowKeys, parent, predicate, CL)the same slice for a list of keys: a map key → columns
get_range_slices(parent, predicate, keyRange, CL)a slice of every row in a range: "range is startkey, endkey, starttoken, endtoken"
insert(key, parent, Column, CL)writes one column: name, value, clock
batch_mutate(map<byte[], map<String, List<Mutation>>>, CL)many inserts and deletions — per row key, per column family — in one call
remove(byte[], ColumnPath column_path, Clock, CL)deletes a column, a super column or a row, with a clock: the deletion competes with writes by timestamp
// p. 428: write one column (name, value, clock)
client.insert(userKeyBytes, parent,
    new Column("band".getBytes(UTF8),
    "Funkadelic".getBytes(), clock), CL);

The insert is p. 420 in code: name, value, clock. There is no separate update: writing an existing column stores a new value with a newer clock, and the newer clock wins.

All this is the Cassandra of 2010. Current Cassandra offers the same model through CQL tables, the language of the exercise sessions and the past papers (lesson 21); Thrift was removed in version 4.0 (2021), and super columns no longer exist. Theory questions use the slides' words, exercises the CQL ones:

The slides' vocabulary and its CQL counterpart.
On the slides (2010)In CQL (lesson 21)
keyspacekeyspace, CREATE KEYSPACE … WITH replication = {…}
column familytable — the papers write "tables (column families)"
row keypartition key: decides on which nodes the row lives
column names sorted by the comparatorclustering columns, sorted by CLUSTERING ORDER BY
column value, bytesa regular column with a type (text, int, …)
timestamp (clock)the write time of every cell: still last write wins
super columngone: one more clustering column, or a user-defined type
slice predicate: start, finish, reverse, counta range on a clustering column, ORDER BY … DESC, LIMIT
get, insert, remove … with a CLSELECT, INSERT, DELETE in cqlsh, with a consistency level

No SELECT … WHERE: start from the queries

Page 429 asks "what about… SQL?" and lists five habits: SELECT, WHERE, ORDER BY, JOIN ON, GROUP. The answer is a way of designing, not a clause.

Page 430: "rdbms: domain-based model — what answers do I have?"; "cassandra: query-based model — what questions do I have?"; "Start from queries, then design the data model". A relational designer models the domain — entities, relationships, normal forms (lessons 8–9) — and answers later questions with joins. Cassandra finds rows only by key and does not join, so a question with no structure built for it has no efficient answer: you list the questions first.

Page 431, "cassandra is an index factory": the column family USER has the key UserID and the columns username, email, birth date, city, state. It answers "give me user 123" in one read, and nothing else. "How to support this query?"

-- p. 431: the query that USER, keyed by UserID, cannot answer
SELECT * FROM User WHERE city = 'Scottsdale'

The slide's answer: "Create a new CF called UserCity", keyed by city, whose columns are the "IDs of the users in that city". The query becomes one row read, plus a multiget_slice on USER for the users. UserCity "uses the Valueless Column pattern": the information is in the column names, the values are empty. What an RDBMS does with an index, you do by hand, one column family per question, written together with USER — an index factory.

In CQL (lesson 21) UserCity is a table whose partition key is the city, with the user ids as clustering values — the valueless columns:

-- UserCity of p. 431 in CQL: one partition per city
CREATE TABLE user_city (
  city text,
  user_id text,
  PRIMARY KEY ((city), user_id)
);
-- all the users of Scottsdale: one partition read
SELECT user_id FROM user_city WHERE city = 'Scottsdale';

Two conditions at once? Page 432: "Use an aggregate key" — state:city: { user1, user2} — then "Get rows between AZ: & AZ; for all Arizona users" and "Get rows between AZ:Scottsdale & AZ:Scottsdale1 for all Scottsdale users". Why do those bounds work?

  1. Four rows — AZ:Phoenix, AZ:Scottsdale, AZ:Tucson, CA:Fresno — each with its users as valueless columns. Keys compare as strings, character by character, and a prefix sorts first: AZ: comes before AZ:Phoenix.

  2. In ASCII the colon is 58 and the semicolon 59. Every Arizona key has a colon in third place, so it sorts before AZ;. CA:Fresno sorts after AZ; because C comes after A. So AZ: to AZ; holds exactly the Arizona rows.

  3. Likewise AZ:Scottsdale1 sorts right after AZ:Scottsdale: that range holds the one Scottsdale row.

  4. This needs rows in key order: the order-preserving partitioner. The random one keeps rows in the order of the MD5 of their keys — AZ:Phoenix (ab66…), AZ:Tucson (f163…), CA:Fresno (f3ca…), AZ:Scottsdale (f412…) — so a Californian row sits between Arizona rows, and "the rows between AZ: and AZ;" no longer means the Arizona users.

Page 433, "ORDER BY": "Columns are sorted according to CompareWith or CompareSubcolumnsWith" (the comparators of names and sub-column names); "Rows are placed according to their Partitioner: Random: MD5 of key; Order-Preserving: actual key". No ORDER BY clause: to get entries by time, make the time the column name. The last line needs a correction:

No slide answers JOIN ON and GROUP in words; the hotel example of pp. 434–435 (Fig. 4) answers them by design. Eight relational tables tied by foreign keys, where a hotel page needs several joins, become seven column families with no foreign keys, each shaped for the reads it serves.

Hotel Room Reservation HotelToPOI RoomToAmenity Guest PointOf Interest Amenity arrows: foreign keys

Before (p. 434): eight tables, seven foreign keys

<<CF>> Hotel · key hotelID <<CF>> HotelByCity · city:state → hotel IDs <<SCF>> PointOfInterest · HotelID, poiName <<CF>> Guest · key phone <<SCF>> RoomAvailability · hotelID, date <<SCF>> Room · hotelID, roomID, amenities <<CF>> Reservation · key resID, with copies

After (p. 435): seven column families, no foreign keys

Fig. 4. The hotel example, before and after (pp. 434–435). Before: a normalised relational schema, where reading a hotel together with its rooms, amenities and nearby points of interest means joining along the arrows. After: one column family per way of reading the data, with copies instead of links; a double outline marks a super column family (<<SCF>>), whose two keys are its super column name and its row key, in the order the slide prints them.
What happened to each table of the hotel (p. 434 → p. 435).
Relational (p. 434)Cassandra (p. 435)What changed
Hotel<<CF>> Hotel, row key hotelIDthe same entity, found by its key
"hotels in a city": a WHERE on City<<CF>> HotelByCity, key city:state (printed #city:state:hotelID), columns hotel1, hotel2, …an index column family with an aggregate key and valueless columns (pp. 431–432)
PointOfInterest + HotelToPOI<<SCF>> PointOfInterest, keys HotelID and poiName, columns desc, phonethe bridge table disappears: the points of interest are copied next to each hotel
Room + RoomToAmenity + Amenity<<SCF>> Room, keys hotelID and roomID, columns num, type, rate, coffee, tv, hottub, …amenities become columns a room has or lacks: a sparse table
— (counted from reservations)<<SCF>> RoomAvailability, keys hotelID and date, kk = 22, qq = 14counts kept up to date at write time (the slide does not expand kk and qq): our reading of how GROUP is answered
Guest (GuestID, Name, Email)<<CF>> Guest, row key phonethe key is what the application looks guests up by
Reservation (GuestID, RoomID, StartDate, EndDate)<<CF>> Reservation, row key resID: hotelID, roomID, phone, name, arrive, depart, rate, ccNumthe guest's phone and name and the room's rate are copied in: no join

The price: names, phones and rates are stored twice, and the application keeps the copies in step — affordable because writes are cheap ("very fast writes", p. 438). Pages 436–437 compare Cassandra with an RDBMS:

"Properties wrt RDBMS (e.g., Oracle)", pp. 436–437, as printed.
PropertyCassandraRDBMS
Core ArchitectureMasterless (no single point of failure)Master-slave (single points of failure)
High AvailabilityAlways-on continuous availabilityGeneral replication with master-slave
Data ModelDynamic; structured and unstructured dataLegacy RDBMS; Structured data
Scalability ModelBig data/Linear scale performanceOracle RAC or Exadata
Multi-Data Center SupportMulti-directional, multi-cloud availabilityNothing specific
Enterprise SearchIntegrated search on Cassandra data.Handled via Oracle search
In-Memory Database OptionBuilt-in in-memory optionColumnar in-memory option
JoiningDoesn't support joiningSupports joining
Referential IntegrityCassandra has no concept of referential integrity across tables. No cascading deletes.Supports foreign keys in a table to reference the primary key of a another table. Supports cascading delete.
NormalizationTables contain duplicate denormalize data.Tables are normalized to avoid redundancy.

The rows of p. 437 are the data model in one breath: no joins, no foreign keys, duplicated data — joins and integrity checks would make nodes coordinate on every query. "Enterprise Search" and "In-Memory Database Option" describe DataStax's commercial edition, the table's source (background).

Page 438 closes with the checklist: "tuneably consistent", "very fast writes", "highly available", "fault tolerant", "linear, elastic scalability", "decentralized/symmetric", "~12 client languages", "Thrift RPC API", "~automatic provisioning of new nodes", "0(1) dht" (read O(1), as on p. 411), "big data" — the mechanisms of lesson 22.

When the exam's one-point table asks which DB technology fits which entities (lesson 5), motivate a columnar row with these slides: a large, continuously growing volume of records, written often and read by key along queries known in advance — "Often write-heavy workloads" (p. 412), "very fast writes" and "linear, elastic scalability" (p. 438).

The course notes say Cassandra "is almost exclusively used in OLAP systems with multiple Terabytes sized data" and list "Increased tuple reconstruction costs" among the ways it differs from relational tables: column-store facts carried over by mistake. The slides say "Often write-heavy workloads" (p. 412), and a Cassandra row is stored whole.

Start from the queries: one column family per question, the data copied to where it is read, and no joins.

Back to the puzzle. "The Column Oriented Database stores data in columns" (p. 401) and "think of cassandra as row-oriented" (p. 417) are both right, because "column" means two things. In Vertica or C-Store a column is a file: one attribute of every record, stored together, compressed and scanned — OLAP. In Cassandra a column is a name in a row's sorted map, stored with the rest of its row; a row can hold a huge number of them, hence "Big Column Stores". The deck and the papers ("you decide to store it in a columnar database", exam of 12 Sep 2025) call both "columnar": keep the label, and say which one you mean.

Exam-style questions

Theory questions in the forms of the theory part — true/false, multiple choice and short open answers, where a wrong answer may cost points — and one practical exercise in the wording of the past papers, built on the hotel of pp. 434–435.

True or false: Cassandra stores the values of each column of a column family together, separately from the other columns, as a column-oriented database such as Vertica does.

  • True That is the column storage of pp. 401–407. Cassandra keeps the columns of a row together under its row key: "think of cassandra as row-oriented" (p. 417).
  • False Cassandra is a wide-column store: a row, found by its key, holds all its columns together (p. 417). Storing each column apart is the analytic column store of pp. 401–407.
Show model answer

False. Cassandra is a wide-column (column-family) store: each row is uniquely identified by its key and keeps all its columns, sorted by name, together, so it should be thought of as row-oriented. Storing each column separately is the design of column-oriented databases such as Vertica or C-Store, built for OLAP: a query reads only the columns it needs, while inserts and whole-record reads touch every column. Cassandra is called columnar because of its column families and its very wide rows, and it is built for write-heavy workloads.

The trap is the word "column", shared by two different designs (section 2 and section 3). Wrong answers may carry a penalty, announced just before the exam: answer only what you can justify.

Explain how a column-oriented database stores a table and compare it with a row-oriented database: advantages, disadvantages, and the workloads each one suits.

Show model answer

A row-oriented database stores each record contiguously; a column-oriented database stores each column contiguously, in its own storage. A query that needs k of the m columns therefore reads only those k columns in a column store, while a row store reads whole records and may read unnecessary data. The column layout also compresses better (the values of a column share a type and repeat, so techniques such as run-length encoding work well) and improves bandwidth utilisation, code pipelining and cache locality. Its disadvantages are increased disk seek time (a record is spread over many files), an increased cost of inserts (one write per column) and increased tuple reconstruction costs (records must be rebuilt by position). Column stores therefore suit read-mostly, read-intensive, large repositories — OLAP and data mining — while row stores suit OLTP, with frequent inserts and updates of whole records.

A complete answer has four parts: the two layouts, the read argument (k of m columns), the pros and cons in the words of pp. 403–404, and the workloads. A strong extra is the chart of p. 405: the column store loses only when a query selects nearly the whole tuple.

The Quarter column of a sales table, sorted by quarter, is stored with run-length encoding as (Q1, 1, 300), (Q2, 301, 350), (Q3, 651, 500), (Q4, 1151, 600). Which statements are correct? Select all that apply.

  • (Q2, 301, 350) means that Q2 is stored at the positions from 301 to 350. The third number is a length, not an end: Q2 fills the 350 positions from 301 to 650.
  • The table has 1,750 rows. The last run ends at 1151 + 600 − 1 = 1750, the same as 300 + 350 + 500 + 600.
  • Position 700 holds Q3. Q3 starts at 651 and covers 500 positions, up to 1150.
  • The Price column of the same table would shrink just as much with the same encoding. Neighbouring prices rarely repeat: each price would become a triple, three times its raw size, which is why p. 407 leaves Price raw.
  • Sorted by customer instead, the same Quarter column would usually compress far less. Runs need equal values next to each other; another sort order breaks them up.
Show model answer

The second, third and fifth statements. Each triple is (value, start_pos, run_length), so (Q2, 301, 350) covers the positions 301 to 650; the last run ends at 1151 + 600 − 1 = 1750; position 700 lies in the run of Q3, from 651 to 1150. Run-length encoding pays only when equal values are adjacent: the Quarter column is sorted and the prices are not, so the encoding is chosen column by column and Price stays raw.

The numbers are those of p. 407, so the answer can be checked against the slide. The classic slip is reading run_length as an end position. Wrong ticks may carry a penalty: tick only what you can justify.

Describe the data model of Cassandra as presented in the lecture — keyspace, column family, row, column and super column. Explain why a column family is called a sparse table and what the timestamp of a column is used for.

Show model answer

A keyspace is the outermost container, roughly a database and typically one per application; some settings, such as the replication, are configured per keyspace. It contains column families, the analogue of tables, which group records of a similar but not identical kind; their settings include the comparator and the type, standard or super. A column family holds rows, each uniquely identified by its row key; Cassandra should be thought of as row-oriented, because a row's columns are stored and returned together. A column has three parts: a name (bytes; it determines the sort order, is used in queries and is indexed), a value (bytes; values are not queried) and a timestamp. A super column groups columns under a common name: it has a name and a map of columns instead of a value, and its sub-column names are not indexed. A column family is a sparse table because each row stores only the columns it has: a missing column costs nothing and is not a NULL. The timestamp (clock) is supplied with every write and resolves conflicts between replicas: the last write wins.

Give the containment chain in order and the three parts of a column with their properties (p. 420). "Sparse" and "last write wins" are the two whys the question asks for. If you quote the slide's example of a keyspace setting, the partitioner, add that it is in fact set for the whole cluster (red pen on p. 413).

Explain the difference between the domain-based model of an RDBMS and the query-based model of Cassandra. How would you support the query SELECT * FROM User WHERE city = 'Scottsdale' on a column family User keyed by UserID, and what does the solution cost?

Show model answer

An RDBMS follows a domain-based model: you model the entities and relationships of the domain, normalise them, and answer any later question by combining tables with joins ("what answers do I have?"). Cassandra follows a query-based model: rows are found by key, column values are not queried and there are no joins, so you list the questions first and build a structure for each one ("what questions do I have?" — start from queries, then design the data model). For the query, create a second column family, UserCity, keyed by city, whose column names are the IDs of the users living there, with empty values (the Valueless Column pattern): the query becomes one row read, followed by a multiget of the users from User. This is why Cassandra is called an index factory. The cost is duplication: every user is written to both column families, and the application must keep the copies consistent, with no joins and no referential integrity to help.

Two things make the answer: the why (rows found only by key, no joins) and the price (duplication, copies kept in step by the application). In CQL the same idea is a table with PRIMARY KEY ((city), user_id). The exercise session's other tool is a secondary index, CREATE INDEX; the design question of the paper of 30 Jan 2026 rules it out with "Do not use indexes".

Our exercise, in the wording of the past papers: the hotel of pp. 434–435. Consider the following ER diagram. Hotel (Hotel_ID, Name, Phone, Address, City, State, Zip); Room (Room_ID, Room Number, Rate); Guest (Guest_ID, Name, Phone, Email). Relationships: Has, Hotel (1, N) – Room (1, 1); Reserve, Guest (0, N) – Room (0, N), with the attributes Start Date and End Date.

Suppose you expect to record a large-scale, continuously increasing number of reservations, and therefore you decide to store them in a columnar database. Write the specification of the tables (column families) of a Cassandra deployment so that one can query the hotels of a state, the hotels of a city of a given state, and the reservations of a guest by phone number, the most recent first. Do not use indexes.

Show model answer

Two tables, one per access path: the hotels by location, partitioned by state and clustered by city, and the reservations by guest phone, partitioned by phone and clustered by start date, the most recent first. Each reservation carries copies of the guest, hotel and room data it shows, because Cassandra has no joins.

-- the keyspace (one per application)
CREATE KEYSPACE hotels
  WITH replication = {'class': 'SimpleStrategy',
                      'replication_factor': 3};
-- queries 1 and 2: the hotels of a state, or of a city in it
CREATE TABLE hotels_by_location (
  state text,
  city text,
  hotel_id text,
  name text,
  phone text,
  address text,
  zip text,
  PRIMARY KEY ((state), city, hotel_id)
);
-- query 3: a guest's reservations, with copies, newest first
CREATE TABLE reservations_by_guest_phone (
  guest_phone text,
  start_date date,
  reservation_id uuid,
  end_date date,
  guest_name text,
  hotel_id text,
  hotel_name text,
  room_number int,
  rate float,
  PRIMARY KEY ((guest_phone), start_date, reservation_id)
) WITH CLUSTERING ORDER BY (start_date DESC, reservation_id ASC);
-- all the hotels of Arizona: one partition
SELECT * FROM hotels_by_location WHERE state = 'AZ';
-- the hotels of Scottsdale: the first clustering column fixed
SELECT * FROM hotels_by_location
  WHERE state = 'AZ' AND city = 'Scottsdale';
-- a guest's reservations, the most recent first
SELECT * FROM reservations_by_guest_phone
  WHERE guest_phone = '+1 480 555 0101';

Every reservation is one INSERT into reservations_by_guest_phone, with reservation_id from uuid() and the guest's name, the hotel's name and the room's number and rate copied in.

This is pp. 431–435 in CQL. hotels_by_location is the aggregate key state:city of p. 432: the state is the partition key and the city the first clustering column, so one table answers both location questions with no key range and no order-preserving partitioner. reservations_by_guest_phone is the Reservation of p. 435: keyed by what the application searches, with copies instead of joins; reservation_id keeps two reservations of one guest on the same day apart, since an INSERT with an existing primary key overwrites. Past papers ask the same kind of question — the farmers' paper of 12 Sep 2025, exercise 1, is solved in lesson 21, together with the CQL rules behind every line.

Check yourself

A sales table has 20 columns of equal width and ten million rows. A query computes the average of one column. About what share of the row store's bytes does a column store read?

  • The same: both must read ten million rows Both visit ten million rows, but the column store reads one value per row, the row store twenty.
  • One twentieth \(k/m = 1/20\): only the one column the query names travels from disk.
  • One half Nothing here halves: the share is the fraction of columns needed, 1 of 20.
  • Twenty times more, because the column store must rebuild the tuples An average needs no tuples, so nothing is rebuilt. Reconstruction costs only when whole records are returned.

Sensor readings are stored sorted by sensor id, with 50,000 readings per sensor. Which column gains most from run-length encoding?

  • the temperature, because it is the column queried most How often a column is queried does not create runs; temperatures change from reading to reading.
  • the reading time, because it always increases Values that always increase never repeat: every run would have length 1.
  • the sensor id The table is sorted by it, so each sensor's 50,000 equal neighbours become one triple.
  • all columns equally: run-length encoding does not depend on the data It depends entirely on the data: it needs equal neighbouring values.

Two replicas of row 456 disagree: one holds email = eben@bar.com with timestamp 1004, the other email = eben@new.com with timestamp 1010. What do they hold after they are compared?

  • eben@new.com on both, because the larger timestamp wins "last write wins conflict resolution" (p. 420): the clock decides, and the loser is dropped.
  • eben@bar.com on both, because it was written first The rule is the last write, not the first.
  • both values, as two versions of the column A column has one value; the losing write is silently discarded — the price of last write wins.
  • whichever value the faster replica holds Speed decides nothing; the timestamps do.

In time_ordered_blogs_by_user the column names are millisecond timestamps (comparator long). Which slice predicate returns a user's ten latest entries?

  • start and finish empty, reverse off, count 10 That walks the names upwards: the ten oldest entries.
  • start and finish empty, reverse on, count 10 Reverse walks the sorted names from the newest end; count stops after ten, like LIMIT.
  • start = the newest timestamp, reverse off, count 10 From the newest name upwards there is only the newest entry: one column, not ten.
  • none: Cassandra has no ORDER BY, so the entries cannot be returned in time order There is no ORDER BY clause, but the names are sorted by the comparator — making time the column name is exactly how you get order (p. 433).

You must answer "the users in Scottsdale" and "the users in Arizona" without reading the whole User column family. Which designs from the lecture work? Select all that apply.

  • two column families, one keyed by city and one keyed by state, whose column names are the IDs of the users who live there UserCity of p. 431, built twice: each question is one row read, and every user is written to both families.
  • SELECT * FROM User WHERE city = 'Scottsdale' on the User column family keyed by UserID Rows are found by key; without an index this means reading everything — what p. 431 sets out to avoid.
  • a column family keyed by state:city, read with the key range from AZ: to AZ; under the order-preserving partitioner Page 432: one family answers both questions, as long as the rows are kept in key order.
  • the same state:city family under the random partitioner Rows then follow the MD5 of the key: a Californian row sits between Arizona rows, and the key range no longer means Arizona (red pen on p. 433).
  • a join between User and a City column family "Doesn't support joining" (p. 437).

Both working designs are index factories: the data is copied into a family keyed by the value you search.

Why does Cassandra give up joins (p. 437)?

  • Because column values are opaque bytes that cannot be compared A join compares keys, and keys can be compared; the obstacle is elsewhere.
  • Because a column family cannot store another row's key It can: Reservation stores hotelID and roomID (p. 435). Nothing checks or follows them, that is all.
  • Because the CAP theorem forbids joins in AP systems CAP says nothing about joins; it is about replicas during a partition.
  • Because the rows to combine would live on different nodes, and coordinating them on every query would cost the linear scalability and availability Cassandra is built for So data that is read together is stored together, duplicated if needed: "Tables contain duplicate denormalize data" (p. 437).

In the super column family PointOfInterest you need only the phone of the Empire State Building (row 10019). What does Cassandra read?

  • only the phone column, found through an index "sub-column names in a SCF are not indexed" (p. 425).
  • the whole row 10019, Central Park included Super column names are indexed, so it can go straight to Empire State Building.
  • the whole super column Empire State Building, desc and phone, and then picks the phone The top level is indexed, the sub-columns are not: a super column is read whole.
  • nothing: a super column cannot be read by name A column path can name the super column and even the sub-column (get, p. 427); it is the cost that differs.

Key take-aways

What to watch and read