8 · The Ticket Rail: Kafka

On the Tuesday of her second week, Mia’s notebook was already open when Theo arrived.

In Chapter 7, she had matched every order to its order_completed event. Some orders had none, and one of them was her own: A1024. The dashboard counts these events, so each missing event is an order the dashboard never sees.

“Good morning,” said Theo. He looked at the notebook. “That is your question face.”

“My order is in the database,” said Mia. “The app should have sent an order_completed event for it. That event is not in the warehouse. You told me that app events travel through something called Kafka. So my question is this. Did Kafka lose my event?”

Theo took a paper napkin from his desk drawer. He kept a stack there for moments like this.

“Have you ever worked in a kitchen?”

“No. But I have stood in many queues for tea.”

“Then you have seen a ticket rail.” He drew a long line across the napkin and a row of small squares hanging under it. “The cashier clips each new order to the rail. The cooks take their orders from it. The rail sits between them, so the cashier never waits for a cook. That is Kafka. A very long, very tidy ticket rail.”

“And if a ticket falls off the rail?”

“Every ticket has a number,” said Theo. “Kafka keeps tickets for about seven days, so your Monday is already gone from the real rail. But I saved a copy of that day before it was deleted. We call it Monday’s dump. In that copy, a ticket that fell off would leave a hole in the numbers. Let me show you how the rail works. Then we go and look for your ticket.”

Mia wrote at the top of a new page: Did Kafka lose A1024? Find the hole, or prove there is none.

ImportantThe big idea

Kafka is a shared ticket rail. Writers add numbered tickets to the end, every reader keeps its own bookmark, and reading a ticket never removes it.

Six long horizontal ticket rails on a cream wall, each holding a row of blank paper tickets. On every rail sit three round clips, one teal, one tomato and one mustard, at different places along the rail. Below the rails stand a teal teapot, a tomato delivery bag, and a mustard ledger book.

Look at the picture above: six rails of tickets, three coloured clips on each rail, and three readers below (a teapot, a delivery bag and a ledger book). By the end of this chapter, you will know what each part means.

Life before the rail

Before Kafka, Steep’s order service, the program that takes orders and saves them, did everything itself. After each order, it called five other systems, one after another: the kitchen printer in the store, delivery dispatch, the loyalty-points system, the fraud check, and analytics. It told the customer “Done” only after all five had answered.

One evening, the loyalty system became very slow. Every checkout waited for it, and then gave up. For the rest of that evening, customers could not buy tea, because a points counter was broken.

Systems break. The real problem was tight coupling: checkout could only work if all five systems worked at the same moment.

The fix was a rail in the middle. Now the order service clips one ticket per order to the rail and tells the customer “Done” at once. The other five systems read the ticket when they are ready. If loyalty is down for an hour, its tickets wait, and it catches up later. No customer notices.

Tools that pass messages between systems like this are often called message queues. Kafka is one of them, with one big difference.

Tickets, writers and readers

Events. In the tea shop, the ticket the cashier writes when a customer pays is an event. You met app events in Chapter 7: small records that say who did what, and when. Kafka also calls them messages or records. Mia’s first tap on that Monday morning became an event: user u000001 did app_open, and the event reached Kafka at 08:43:49.

Producers and consumers. The one who writes tickets is the producer. The one who reads them is the consumer. At Steep, the order service is a producer. So is the app: every tap goes to Steep’s servers, and a small service there writes it to Kafka. The kitchen printer, the loyalty system and the data warehouse are consumers. Producers and consumers never talk to each other, only to the rail. That is the whole trick.

Topics. A topic is a named rail for one kind of ticket. Steep has an orders topic for orders and an app_events topic for taps in the app. Mia’s missing event belongs on app_events.

The log. Here Kafka is different from a kitchen. In a kitchen, the cook takes the ticket off the rail, and when the drink is made, the ticket goes in the bin. A list where reading an item removes it is a to-do queue.

Kafka never removes a ticket because someone has read it. New tickets are added at the end, and old ones are never changed. Readers look; they do not take. A list that you can only add to, and that keeps its order, is a log. Engineers often say append-only log, because “append” means “add to the end”.

This small difference is the most important idea in this chapter. Because nothing is removed, many readers can read the same tickets, each at its own speed, and a reader that made a mistake can read them again.

Offsets. Every ticket on a rail gets a number when it is clipped on: 0 for the first ticket, 1 for the next, and so on. Once a ticket is safely stored, its number never changes. This position is the ticket’s offset.

“Pre-numbered documents,” said Mia. “In audit, we test those for gaps.”

“That is exactly what we are going to do,” said Theo. “In our dump, the test works. Real Kafka has one exception. Kafka copies each ticket to other machines. If a machine breaks before the copy exists, the ticket is lost and the next ticket takes its number. Then the loss leaves no hole.”

Mia wrote that down too. An exception is something to test, not something to ignore.

Six rails, not one

On that Monday alone, 51,927 app events arrived. One rail for all of them would become a traffic jam.

So Kafka splits each topic into several rails that work side by side. Each one is called a partition. A partition is a complete log of its own, with its own offsets. Steep’s app_events topic has 6 partitions: the rails in the picture. Because each partition counts on its own, the address of a ticket is a pair: partition and offset.

Splitting has a price. Inside one partition, tickets stay in the order they arrived. Across partitions, there is no order at all. Offsets cannot tell you whether ticket 500 on one rail came before or after ticket 300 on another rail. Kafka promises order only within a partition.

So how does Kafka choose a rail for each ticket? The producer can give every ticket a key, and turns the key into a partition number with a fixed formula. The same key always leads to the same partition, as long as the number of partitions does not change. Same word, different thing: in Chapter 1, a key was a unique ID, like order_id. A Kafka key is not unique. Every ticket from one user carries the same key; it only chooses the rail. Steep uses the user ID as the key. All of one user’s taps land on the same rail, so they stay in order: app_open, then view_menu, then add_to_cart. The rail keeps the order in which tickets arrive. If they arrive in the wrong order, the rail keeps that order too.

This key also spreads the work. Monday’s events came from 14,259 different users. The formula scatters users almost at random, so with thousands of users each rail gets about the same number. Each partition held between 16.2% and 17.3% of the day’s events, close to the fair share of 16.7%.

Now imagine that Steep had used the city as the key. With only 4 cities, at most 4 rails could get tickets; the rest would sit empty. Worse, Harbor is the largest city: on Monday 14 September, 38.9% of the day’s events came from Harbor users. Every one of them would land on the same rail. A partition that gets far more than its fair share is a hot partition. As you will see below, a team gives each rail to only one reader. That one reader would have far more work than the others. The same Harbor problem returns in Chapter 10, under the name data skew.

Show the code
bk.setup()
fig, (left, right) = plt.subplots(1, 2, figsize=(8, 3.8), sharey=True, layout="constrained")
fair = 1 / n_parts
labels = [f"P{p}" for p in range(n_parts)]

left.bar(labels, rails.events / n_events, color=bk.TEAL, width=0.7)

n_city = len(by_city)
city_rails = list(by_city.share) + [0.0] * (n_parts - n_city)
colors = [bk.TOMATO] + [bk.MUSTARD] * (n_city - 1) + [bk.GRID] * (n_parts - n_city)
right.bar(labels, city_rails, color=colors, width=0.7)
for p, (city, share) in enumerate(zip(by_city.city, by_city.share)):
    right.text(p, share + 0.01, f"{city.title()}\n{bk.fmt_pct(share, 0)}",
               ha="center", va="bottom", fontsize=8.5, color=bk.INK,
               bbox=dict(facecolor=bk.PAPER, edgecolor="none", pad=1))
for p in range(n_city, n_parts):
    right.text(p, 0.01, "empty", ha="center", va="bottom", fontsize=8.5, color=bk.MUTED)

for ax, title in ((left, "Key = user_id"), (right, "Key = city")):
    ax.set_axisbelow(True)
    ax.axhline(fair, color=bk.INK, linestyle="--", linewidth=1, zorder=1)
    ax.set_title(title, fontsize=11)
    ax.yaxis.set_major_formatter(lambda v, _: f"{v:.0%}")
left.text(-0.4, fair + 0.012, "fair share", fontsize=9, color=bk.INK)
left.set_ylabel("Share of Monday's tickets")
right.set_ylim(0, max(city_rails) * 1.3)
fig.suptitle(f"Keyed by user, the rails share the work; keyed by city, "
             f"one rail gets {bk.fmt_pct(harbor_share, 0)}",
             x=0.01, ha="left", fontsize=13, fontweight="semibold", color=bk.INK)
plt.show()
Two bar charts side by side. On the left, six bars of almost equal height, each close to a dashed line marking a fair share of one sixth. On the right, four bars of unequal height and two empty partitions; the Harbor bar is more than twice the fair share.
Figure 1: Monday’s app_events tickets on each partition. Left: the real topic, keyed by user ID. Right: the same tickets if the key were the city, in the best case where each city gets a rail of its own.

Finding Mia’s tickets

Theo opened Monday’s dump of the app_events topic: every ticket that arrived on Mia’s first day, with its partition, offset, key and contents. Mia’s key is u000001. All of her tickets were on partition 2, as the key promised.

Table 1: Every ticket with key u000001 on Monday’s app_events rail, in offset order. Times are Harbor time.
Partition Offset Event Arrived
2 1019041 app_open 08:43:49
2 1019042 view_menu 08:43:57
2 1019046 add_to_cart 08:45:42
2 1019047 checkout_start 08:46:29

Mia read the four lines twice. Open the app. Look at the menu. Add a drink to the cart. Start the checkout. Then nothing. The fifth ticket, order_completed, was not there.

“So Kafka lost it,” she said.

“Maybe,” said Theo. “What does an auditor do now?”

Mia knew the answer. With pre-numbered documents, you look for a gap. In Steep’s dump, a ticket that was clipped on and then lost would leave a hole on partition 2. Her order was placed at 08:47:21. So the missing ticket should have arrived soon after that moment. Theo listed every ticket on her rail from a moment before her first tap to shortly after her order.

Table 2: Mia’s partition around the moment of her order. Her own tickets are in bold. The offsets run on without a gap.
Offset Key Event Arrived
1019040 u391520 checkout_start 08:43:47
1019041 u000001 app_open 08:43:49
1019042 u000001 view_menu 08:43:57
1019043 u711784 app_open 08:44:37
1019044 u711784 view_menu 08:44:41
1019045 u876392 checkout_start 08:44:49
1019046 u000001 add_to_cart 08:45:42
1019047 u000001 checkout_start 08:46:29
1019048 u557960 app_open 08:47:13
1019049 u557960 view_menu 08:47:18
1019050 u557960 add_to_cart 08:47:39
1019051 u430772 app_open 08:47:46
1019052 u557960 checkout_start 08:47:46
1019053 u804912 app_open 08:47:48
1019054 u430772 view_menu 08:47:48

The numbers ran on without a break, from 1019040 to 1019054. Offset 1019049 was the last ticket before her order, and 1019050 was the first ticket after it. Nothing is missing between them.

“That is one small stretch,” said Mia. “What about the rest of the day?”

Theo checked every partition. On each rail, he took the first and last offset of the day and the number of tickets in between. If no ticket is missing, the count equals last minus first, plus one. For example, offsets 10 to 14 hold 14 − 10 + 1 = 5 tickets. On all 6 partitions, the count matched: 0 missing offsets.

Mia’s order was not the only one without a ticket. Of the 5,711 orders placed on Monday, 843 had no order_completed ticket on Monday’s rail. Tickets for late-evening orders can arrive after midnight, so Theo also checked the warehouse, which keeps its own copy of every event. That left 839 orders, about one in 7, with no ticket anywhere. Hundreds of missing tickets, and not one hole in the rail.

“One Monday is a small sample,” said Mia. “Does it speak for the week of the drop, 7 to 13 September?”

Theo thought it did. Mia’s phone ran app version 3.2.0, which came out on 7 September and was still the newest version. And her own reconciliation in Chapter 7 had found the same kind of gap on every day of that week.

Mia remembered the exception. “Could a broken Kafka machine have lost these tickets without a hole?”

“Look at the pattern,” said Theo. The missing orders came from users on all 6 rails, spread almost evenly. And for 836 of the 839 orders, a checkout_start ticket from the same user did arrive. A broken machine would lose all kinds of tickets, and only on some rails. This loss hit one kind of ticket, on every rail.

“So the tickets were never clipped on,” said Mia slowly.

“Never written,” said Theo. “Look at your phone’s tickets. Four of them arrived in order, over 2 minutes and 40 seconds. Your checkout_start arrived 52 seconds before the database recorded your order. So the phone could reach Kafka less than a minute before the order. It was not a bad signal. The fifth ticket was not lost inside Kafka. It never reached the rail. It was lost before that: in the app, or in the service that writes to Kafka.”

Which of the two? In Chapter 7, 98.7% of the orders with no event were iOS orders. The writing service carries events from every platform. If it were losing them, Android and web orders would go missing too. So the app is the main suspect, and the service is a smaller one. The last proof needs the app’s own error logs, and those live on customers’ phones. Only the iOS team can collect them.

Mia wrote in her notebook: Kafka: innocent. Suspects: the iOS app (main), the service that writes to Kafka (smaller). She wrote “suspects”, not “guilty”. She had not seen the code of either one yet.

Many readers, one rail

Now look at the clips in the picture again. Each colour is one team of readers. The teal clips belong to the teapot: the kitchen. The tomato clips belong to the delivery bag: dispatch. The mustard clips belong to the ledger book: the data warehouse.

A team like this is a consumer group: a set of consumers that share the work of reading a topic. Kafka has two rules for groups.

  1. Inside one group, each partition has exactly one reader. One member may read several partitions, but two members never read the same one. This keeps each partition’s order: two readers on one rail could handle ticket 6 before ticket 5. If a group has more members than the topic has partitions, the extra members sit idle, as spare readers.
  2. Different groups do not share. Each group reads every ticket, at its own speed. The kitchen reading a ticket does not stop the warehouse from reading it too. That is why every rail in the picture has three clips.

Committed offset. Each clip marks how far its group has read. Kafka stores this bookmark for every group and every partition. Here, commit means saving the bookmark, so the bookmark is called the committed offset. To be exact, it is the offset of the next ticket the group will read. When a reader crashes, another member of the group takes over its partitions and starts from this bookmark.

Lag. The distance from a group’s bookmark to the newest ticket on the rail is the group’s lag: tickets that have been written but not yet read. Some lag is normal. Lag that keeps growing means that the readers cannot keep up.

Retention and replay. Tickets do not stay on the rail forever. Each topic has a retention period. Kafka keeps each ticket for at least that long, then deletes old tickets in batches. In Apache Kafka, the default is seven days. That is why Theo had to save Monday’s dump. Within that window, a group can move its bookmark backwards and read the same tickets again. This is a replay, like replaying the binlog in Chapter 7. If loyalty gave wrong points for three days, the team can fix the bug and replay those days. A to-do queue cannot do this.

Retention also sets a deadline. If a group reads too slowly, old tickets may be deleted first. That group never sees them. (The box “Under the hood” below shows what the reader does next.)

Once, twice or never

Things fail on the way to the rail and on the way from it. A producer sends a ticket, and the network breaks before the answer comes back. Did the ticket arrive? The producer cannot know. There are three possible promises. The first two are simple. Readers have the same two choices.

  • At-most-once. Never send again. A ticket is never duplicated, but some tickets may be lost. A reader works this way if it saves its bookmark before it handles the ticket. If it crashes in between, its replacement skips that ticket.
  • At-least-once. Send again until you hear “got it”. No ticket is lost, but some may arrive twice. A reader works this way if it saves its bookmark after it handles the ticket. If it crashes in between, its replacement handles that ticket a second time.
  • Exactly-once. Every ticket has its effect once and only once. This is what everybody wants, and it is the hardest to get.

Kafka offers two tools for exactly-once. The first is the idempotent producer. Idempotent means that doing something twice has the same effect as doing it once. Kafka gives each producer an ID and numbers its tickets, so when the producer resends a ticket, Kafka sees the copy and does not write it again. The second tool is transactions. A transaction groups several writes. Either all of them happen, or none do. Together, these give exactly-once processing for programs that read from Kafka and write back to Kafka.

These tools have limits. The idempotent producer only catches its own copies, while it runs without restarting. If a phone sends the same tap twice, Kafka sees two different tickets. And when data leaves Kafka for another system, such as a warehouse table, exactly-once needs that system’s help. Kafka’s own default is at-least-once. The safe habit: give every event a unique ID at the source, and remove copies by that ID downstream (later, in the systems that read the data).

This is not theory. On Monday’s rail, 148 event IDs appear twice. That is 0.3% of all tickets. For example, one view_menu ticket sits at offset 1036294 on partition 0, and again at offset 1036321, 139 seconds later. This is at-least-once delivery in real data: somewhere between the phone and the rail, a sender did not hear “got it” in time and sent the ticket again.

Copies matter when you count. Monday’s rail holds 4,885 order_completed tickets, but they describe only 4,869 different orders. Count raw tickets, and 16 orders are counted twice. The table dwd_event_detail, a cleaned table in the data warehouse, removes the copies by event_id before anyone counts.

Why a crash does not lose tickets

Kafka keeps copies of each partition on several machines, so one broken machine loses nothing, as long as the team sets Kafka up with care. The box below explains the copies and the settings.

Choosing a partition. For a ticket with a key, Kafka’s standard producer computes (slightly simplified)

\[\text{partition} = \text{murmur2}(\text{key}) \bmod N,\]

where murmur2 is a hash function (a formula that turns any text into a number), \(N\) is the number of partitions, and “mod” means the remainder after dividing by \(N\). Here, a client is the code library a program uses to talk to Kafka, not the customer’s phone of Chapter 7. This formula is the Java client’s default. Clients built on librdkafka use a different hash, so producers written with different clients can send the same key to different partitions. Change \(N\), and most keys move to a different partition. That is why teams pick the number of partitions with care. Kafka lets you add partitions to a topic, but never remove them.

Lag. For one group and one partition \(p\):

\[\text{lag}_p = \text{log-end offset}_p - \text{committed offset}_p,\]

where the log-end offset is the offset the next new ticket will get. The group’s total lag is the sum over its partitions.

When the next ticket is already deleted. A reader’s settings decide what happens next. The setting auto.offset.reset is latest by default: the reader jumps to the newest ticket and skips everything in between. Bookmarks expire too. If a group has no members for seven days (the default), Kafka forgets where it was.

Copies and confirmations. Kafka runs on several servers, called brokers. In production (in the real, running system), each partition is usually copied to more than one broker; the default is one copy. The number of copies is the replication factor, and three is a common choice. One copy, the leader, takes the new tickets; the others copy them. The copies that are caught up, the leader included, are the in-sync replicas. The producer setting acks says how many copies must say “got it” before a write counts. With acks=all, the leader answers only after all in-sync replicas have the ticket. If the leader’s machine then dies, a caught-up follower takes over, and the ticket is still there. The topic setting min.insync.replicas sets a minimum. If fewer copies than this are in sync, Kafka refuses the write and the producer gets an error, instead of a promise that it cannot keep. In short, with acks=all, a ticket that Kafka has confirmed will not be lost as long as at least one in-sync copy survives. Two warnings. With acks=1, the leader confirms before any copy exists. And min.insync.replicas is 1 by default, so a team has to raise it.

Gaps in real Kafka. In this book’s dump, a gap would mean a lost ticket. Real Kafka is different. If a broker loses a ticket before copying it (with acks=1, or after an “unclean” leader election), the next ticket can take the same offset, so no hole appears. Some gaps are harmless: transaction markers use up offsets, and in “compacted” topics Kafka later, in the background, removes an old ticket once a newer ticket with the same key exists. So in a real cluster (a group of machines working as one), offsets alone cannot prove that nothing was lost. Engineers also check the producer’s error logs and the acks settings.

Other kinds of group. This chapter describes classic consumer groups. Newer versions of Kafka also offer share groups, where several members can read from the same partition, a little like a to-do queue. They trade the per-partition order for more flexible sharing.

Try it

The first playground is a toy rail with made-up orders, so you can break things safely.

The ticket-rail simulator. Each row is one partition. Tickets show their offset and their key. The teal clip is the group’s bookmark (its committed offset). A reader saves it after every three tickets and whenever it has caught up. A rail keeps only its newest 30 tickets, a stand-in for “seven days”.

Things to try:

  • Set the consumers to 6 and the partitions to 4. Two consumers sit idle.
  • Switch the key to city and send orders a few times. The busiest rails are marked “hot”, some rails stay empty, and Oldtown and Riverside even share a rail (P4).
  • Send orders three times, then crash a consumer while the readers are busy. A few tickets are often read twice: at-least-once delivery.
  • Press “Replay from start”. Every ticket is read again, because reading never removed it.
  • Set the partitions to 1, switch the consumers off, and send orders four times. The oldest tickets are deleted before anyone reads them.

This toy is simpler than Kafka in two ways. It notices a crash at once, where real Kafka waits about 45 seconds by default. And when unread tickets are deleted, its reader restarts from the oldest ticket left; real Kafka, by default, jumps to the newest one.

The second playground is real: Monday’s dump, in a small database inside your browser. Each row is one ticket. Its value holds the event itself, and value.event_name reaches inside it.

Monday’s rail, in your browser. Pick a starting query, change it if you like, and press “Run query”.

The first query should show the same four tickets as Table 1. Try the other starting points too: look for gaps, then look for copies.

Common traps

  • Treating Kafka as a database. Kafka finds tickets by partition and offset, not by what is written on them, so finding order A1024 means scanning the whole rail. And A1024 is a label, not a key in the Chapter 1 sense (a unique ID): by 22 September, it appears on 456 different orders.
  • Expecting one global order. Offsets count per partition, so sorting tickets from different partitions by offset means nothing.
  • Choosing a key with few values. City or “true/false” keys create hot partitions.
  • Ignoring copies. Count distinct event_ids, not raw rows.
  • Not watching lag. A reader that falls behind makes its reports late and can lose tickets to retention. Put lag on someone’s alert list.
TipAudit Instinct · The journal and the duplicate payment test

The log is a journal. In accounting, you never erase a line in the journal. If an entry is wrong, you post a new entry that reverses it, and both stay on the record. Kafka works in a similar way. Nobody edits a ticket in place. A correction is a new ticket. But Kafka is not a vault: retention deletes old tickets, and an administrator can delete records. For a lasting audit trail, copy the tickets to storage you control.

Copies need the duplicate payment test. Auditors test payments for duplicates: the same invoice number, paid twice. At-least-once delivery creates the same risk in data. The test is the same too. Group by the ID that should be unique, and look for any ID that appears more than once. On Monday’s rail, that test finds 148 event IDs.

NoteInterview Corner

Q1. How does Kafka keep messages in order?

Only within a partition, which consumers read in offset order. There is no order across partitions. Give related messages the same key, so that they land in the same partition. One more trap: with retries on and several batches (groups of tickets sent together) on the way at once, a batch that fails and is resent can land after a later batch. The idempotent producer numbers its batches for each partition, so the broker refuses one that arrives out of turn, and the order holds (with up to 5 batches on the way per connection).

Q2. What happens when a consumer in a group crashes?

The member stops sending heartbeats, the small “I am alive” messages. The group waits for the session timeout (session.timeout.ms, 45 seconds by default since Kafka 3.0); until then, nobody reads its partitions. Then the group runs a rebalance: it shares out the partitions again among the members that are still alive. They start from the last committed offset, so messages processed after that commit are processed again. Processing should be idempotent, or copies removed later by a unique ID.

Q3. How do you avoid losing messages?

The idea: keep several copies of every ticket, make the writer wait until the copies exist, and let the reader save its place only after its work is done. In settings: on the producer side, use acks=all, keep retries on, enable the idempotent producer, and check the result of every send. On the topic, use a replication factor of at least 3 and min.insync.replicas of 2. Then a write succeeds only if at least two copies have it. Keep unclean.leader.election.enable=false (the default). On the consumer side, commit offsets after processing, not before, and monitor lag. The Java client turns on acks=all and idempotence by default from version 3.0 (a bug kept idempotence off in 3.0.0 and 3.1.0, fixed in 3.0.1, 3.1.1 and 3.2.0), unless another setting conflicts with it; clients built on librdkafka, such as Python’s confluent-kafka, do not turn on idempotence by default. Check rather than assume.

A note to the iOS team

Before lunch, Theo helped Mia write to the iOS app team. She kept it short, the way she used to write audit findings.

Orders placed from 7 September to 21 September with no order_completed event in the warehouse: 12,828. Of these, 98.3% came from iOS version 3.2.0, released on 7 September. Example: receipt A1024, order 4158971, placed at 08:47:21 on 14 September. Its app_open, view_menu, add_to_cart and checkout_start events reached Kafka. No order_completed did, and Kafka has no missing tickets for that day. Could you check what this version does after a customer pays?

The answer came within the hour. The iOS team would look at the checkout code first, and collect error logs from test phones.

Mia added one line to her notebook: Reported to the iOS team, 22 September.

Reported change: −12.0% orders, the week of 7 September compared with the week before (CEO dashboard).

Explained so far: 0 of the 12 points. About 7 of them sit between the orders database and the dashboard (Clue 1, opened in Chapter 1).

Suspects: the iOS app, version 3.2.0 (Chapters 6 and 7): the main suspect, not proved. The service that writes app events to Kafka: a smaller suspect, not proved.

Ruled out: the matcha menu (Chapter 4). Kafka (this chapter): Monday’s dump has 0 missing offsets on all 6 partitions, and the missing tickets never reached the rail.

Open questions: Is anything lost after Kafka, on the way to the dashboard? (Chapters 9 to 14.) Why does iOS 3.2.0 lose order_completed events, and how many points does that explain? (Chapter 15.)

New evidence: on Mia’s first day, 839 of 5,711 orders had no ticket anywhere. By 22 September, about 0.3% of events had arrived twice: count distinct event_ids, never raw rows.

Recap

  • Kafka is an append-only log split into partitions. Each ticket has a fixed address: partition and offset. Reading never removes a ticket.
  • Order holds only within a partition. The key chooses the partition, so a good key keeps related events in order and spreads the work evenly.
  • Kafka’s default delivery is at-least-once, so copies happen. Count by a unique event ID. When a ticket is missing, check the offsets. In Steep’s dump, no hole means the ticket never reached the rail. In a real cluster (a group of machines working as one), offsets alone cannot prove that nothing was lost.
English 中文
event / message 事件 / 消息
producer 生产者
consumer 消费者
message queue 消息队列
topic 主题
append-only log 仅追加日志
partition 分区
offset 偏移量
key (message key) 键(消息键)
hot partition 热点分区
consumer group 消费者组
committed offset 已提交偏移量
lag 消费积压 / 延迟
retention (log retention) 保留期
replay 重放 / 回溯
at-most-once 至多一次
at-least-once 至少一次
exactly-once 精确一次
idempotent 幂等
replication factor 副本因子

Further reading