The reporting database that is not a replica

The read replica has served reports since 2023, which took the analytical load off the primary and left one problem it cannot solve: a replica has the primary’s schema, which is the point of a replica and means every reporting index has to exist on the tables that take four hundred writes a second.

The symptom

$ ./bin/index-usage --table=orders --by-reader
  index                      app reads   report reads
  PRIMARY                    4,128,840          412
  idx_customer_placed        1,204,882        1,208
  idx_status_created           888,204          904
  idx_report_customer_tier           0        1,204
  idx_report_placed_status           0          888
  idx_report_channel_month           0          412
  ... 8 more with zero application reads

Eleven indexes read only by reports, on tables the application writes constantly. Each one is a write cost on every insert to make a query fast that runs a few times a day, which is a trade nobody makes deliberately and everybody makes incrementally.

Why it happens

A replica exists to move load and cannot move schema. Any index a report needs must exist on the primary, be replicated, and be maintained on every write — which is the cost of using the same tables for two purposes.

The fix

The cost of a reporting index, measured

dropped one index on a staging copy under a synthetic
write load matching production:

  orders INSERT p50   1.9ms → 1.82ms
  orders INSERT p99   8.4ms → 7.6ms

one index is almost nothing. eleven:

  orders INSERT p50   1.9ms → 1.1ms
  orders INSERT p99   8.4ms → 3.2ms
  index storage       14.2 GB
  buffer pool         hit rate 99.1% → 99.6%

the buffer pool figure is the one that matters. 14 GB
of index pages were competing with the working set.

A schema shaped for the questions

CREATE TABLE rpt_order_facts (
  order_id        BIGINT UNSIGNED NOT NULL PRIMARY KEY,
  customer_id     BIGINT UNSIGNED NOT NULL,
  customer_name   VARCHAR(255) NOT NULL,   -- denormalised
  customer_tier   VARCHAR(32) NOT NULL,    -- denormalised
  channel         VARCHAR(32) NOT NULL,
  placed_date     DATE NOT NULL,
  placed_month    CHAR(7) NOT NULL,        -- '2026-05'
  line_count      SMALLINT UNSIGNED NOT NULL,
  net_minor       BIGINT NOT NULL,
  tax_minor       BIGINT NOT NULL,
  KEY idx_month_tier (placed_month, customer_tier),
  KEY idx_date_channel (placed_date, channel)
) ENGINE=InnoDB;

A pre-computed month column and a denormalised customer tier are both things that would be indefensible in the transactional schema and are exactly right here. The table answers eleven of fourteen reports with two indexes, where the same questions against the normalised schema needed eleven.

Denormalisation that is safe

the copy holds a customer's name and tier, which
change.

the reason that is safe: the table is TRUNCATED and
rebuilt nightly. there is no path by which the copy
can disagree with the source, because the copy is
either current or absent.

which is the property that distinguishes this from a
projection maintained by events — that arrangement can
diverge and this one cannot.

the cost: reports are up to 24 hours stale, and that
was agreed rather than assumed.

A full rebuild removes every class of synchronisation bug, which is the failure mode that made the 2023 projection expensive to maintain. It costs freshness, and asking the three people who read these reports produced an answer of “a day is fine” from all three — which had never been asked in 2023.

The rebuild

-- into a staging table, then swapped
CREATE TABLE rpt_order_facts_new LIKE rpt_order_facts;

INSERT INTO rpt_order_facts_new
SELECT o.id, o.customer_id, c.name, c.tier, o.channel,
       DATE(o.placed_at), DATE_FORMAT(o.placed_at, '%Y-%m'),
       COUNT(ol.id), SUM(ol.net_minor), SUM(ol.tax_minor)
FROM orders o
JOIN customers c ON c.id = o.customer_id
LEFT JOIN order_lines ol ON ol.order_id = o.id
GROUP BY o.id;

RENAME TABLE rpt_order_facts TO rpt_order_facts_old,
             rpt_order_facts_new TO rpt_order_facts;
DROP TABLE rpt_order_facts_old;

Building into a new table and swapping with a single RENAME is what makes the rebuild atomic from a reader’s perspective — a truncate-and-repopulate leaves the table partially filled for the duration, which is the failure that produced two days of plausible-looking wrong reports in April.

What happens when it fails

the April incident: the source query timed out, the
job exited non-zero, and the tables were half
populated.

reports were served from a partially populated database
for two days and looked plausible — revenue down 40%,
which finance noticed and assumed was real.

what was missing: a check on the OUTCOME rather than
on the exit code.

  row count within 5% of the source
  the newest row is from yesterday
  a checksum on one stable month

and the swap happens only if all three pass.

A half-completed rebuild is worse than a failed one because the output is plausible, and an exit code says nothing about whether the data is complete. The three assertions before the rename are the whole fix, and finance believing a forty per cent drop for two days is what made this an incident rather than a bug.

Dropping the eleven

one at a time, over three weeks, with the write
latency graphed:

  each drop is an ALTER on a large table. INPLACE and
  LOCK=NONE, and each took between 40 seconds and 4
  minutes.

  and the check before each: zero application reads in
  the last 30 days, from performance_schema, on a
  replica with 30 days of uptime.

one was kept — idx_report_placed_status turned out to
serve an admin screen that runs four times a day and
is not a report.

Verifying it worked

$ ./bin/insert-latency --table=orders --since=30d
  p50 1.1ms   p99 3.2ms      # was 1.9 / 8.4

$ mysql -e "SELECT ROUND(SUM(stat_value*@@innodb_page_size)
            /1024/1024/1024,1) FROM mysql.innodb_index_stats
            WHERE table_name='orders' AND stat_name='size'"
8.4                          # was 22.6

$ ./bin/report-timings --all
  14 reports, median 0.4s, max 2.1s   # was 11.4s max

$ ./bin/rebuild-check --last=30
  runs: 30   swaps: 30   assertion failures: 1
  # one run aborted before the swap. correctly.

The single aborted run is the check working — a source query that returned four per cent fewer rows than expected, caused by a long-running transaction on the replica, and the previous night’s data was served instead. That is the outcome the April incident should have had.

What this costs

A third copy of the data, after the primary and the replica, with a schema that must follow the first. Every schema change to orders is now a potential change to the rebuild query, and nothing connects the two — a column rename breaks the rebuild at 03:00 rather than at deploy.

The rebuild is also permanent infrastructure with all the ways a nightly job silently stops. The three assertions protect against a bad rebuild and not against no rebuild, and the guard for that is an alert on the age of the newest row — which exists and is the sort of thing that gets added after the second incident rather than the first.