Inside a Database
Half the hard problems in computing are database problems wearing a disguise. The page that takes nine seconds is a missing index. The site that froze at lunchtime is a lock. The order that vanished is a transaction that only half happened. So build the engine room yourself, starting from an empty file, and then pull its power out in the middle of a sentence and watch it put itself back together.
There is a working storage engine in this page. It has pages, a tree you can watch split, a log, and a table of locks. Every number you meet is counted while the engine runs, not copied out of a book. When you press Pull the plug it really does throw away the work that was not finished, and Recover really does read the log back and redo the work that was.
Nothing. No programming, no maths past counting and halving, no idea what a database is. Words like byte, index, page, buffer and transaction are all explained at the point where they first turn up. If you already write software, the same pages work as a refresher, and the engine underneath is real enough to argue with.
The steps
What a table really is
A database begins as something disappointing: a file with rows in it. A table is a list of things that all have the same shape. One shop's orders. One school's pupils. One library's books. Each single thing is a row, and each named slot inside a row is a column.
The shop below has four columns: id, customer, city and
total. One row is one order. None of that needs a computer, and you could keep it in a
notebook. The question that turns it into engineering is where those rows actually sit, because a disk
cannot store a row. A disk stores bytes, in one long line, from the start of a file to the end.
What a bit and a byte are
A bit is the smallest thing a computer can hold: one yes or no, written as 1 or 0. Eight bits standing together are called a byte. Eight yes-or-no answers can be arranged in 256 different patterns, so one byte can stand for a whole number from 0 to 255, or for a single letter of English text.
A file is a long line of bytes with a length. There is byte number 0, then byte number 1, and so on to the end. That position, counted from the start, is called an offset. Almost everything in this course comes back to one question: which offset do I jump to.
So the file has to turn rows into a line of bytes, and there are two honest ways to do it. Give every column a fixed width and pad the leftovers, or stick a separator between the values and let each row be as long as it needs. The first wastes space. The second, as you are about to see, costs something worse.
Why the shop stores the total as a number and the city as text
Every column is declared with a type: the kind of value it is allowed to hold. A whole number, a piece of text of at most so many letters, a date. The type is what lets the engine decide how many bytes the column needs and how to compare two of them.
The type matters for sorting more than for storage. The text"100" comes before the text"99", because "1" comes before"9" in the alphabet of characters. The number 100 comes after the number 99. If the engine did not know which one it had, it could not put a column in order, and the whole of Part 2 depends on being able to do exactly that.
Twelve bytes for a name sounds tiny. Why care?
It is tiny once. Ten million orders is ten million times tiny. Nine wasted bytes per row becomes 90 megabytes of nothing, and that 90 megabytes has to be read off the disk along with everything else, because the wasted space sits in between the parts you wanted.
Real engines do not choose one layout for everything. They usually store short fixed-size columns inline and put long text somewhere else with a pointer to it, which is a way of having both. The trade-off in the lab is real even when the compromise is more clever.
Finding one row without reading them all
Step 1 gave you a file of rows and a way to jump to row number 5. Now the real question. The table has
50,000 orders. Somebody asks for the one with id 34,981. Not row number 34,981: the row whose
id column happens to hold that value, which could be anywhere.
With only what Step 1 built, there is one honest answer. Look at row 0. Not it. Look at row 1. Not it. Keep going until you find it or run out of table. That is a full scan, and its cost tracks the size of the table exactly. Double the rows, double the work.
What exactly is being counted here, and why not seconds
Seconds depend on which machine you are sitting at, what else it is doing, and how warm the disk is. A count of work does not. So the widgets here count the thing the engine actually does: rows compared, and later pages read. Those numbers are the same on your laptop and on a server in a rack.
This kind of counting has a name. When people say one approach is faster than another, what they usually mean is that the count grows more slowly as the data grows. A full scan of a table twice the size does twice the work, every time, on every machine.
Sorting changes everything. If the rows are in order by id, you can open the table in the
middle and ask one question: is 34,981 above or below the value sitting there? Whichever the answer, half the
table is gone. Ask again, half of what is left is gone. This is binary search, and it is the reason
databases care so much about keeping things in order.
The word "index" is about to mean two different things
In Step 1,"row number 5" was a position counted from the start. Programmers call that position an index too, and you will hear "the index of that item" all the time. That meaning is not going away.
From here on, though, index mostly means the other thing: the sort of index at the back of a book. A separate, small, sorted list that says "this value is at that place", kept beside the real data so you can find a page without reading the book. When this course says "add an index on customer", it means build one of those. Both meanings are standard, and the sentence around the word always tells you which one is meant.
If sorted is so good, why is anything ever unsorted?
Because a table can only be kept in one order at a time, and because keeping it in that order costs
something on every insert. A new order with id 34,982 belongs after 34,981. If the file is
sorted, everything after it has to shift along to make room, and shifting 20,000 rows to insert one is a
poor trade.
Steps 3 and 4 are about the structure that gets around this. It keeps values in order without ever shifting a whole file, and it charges a small, fixed fee per insert instead. You can safely skip this note: the next step arrives at the same place by a different road.
Fat, shallow trees
Binary search sounds like the end of the story. It is not, because of one awkward fact about storage: you cannot read a byte. You read a page.
A disk, and the operating system above it, hand over a fixed-size block whatever you asked for. Four thousand and ninety-six bytes is the usual size, sometimes eight thousand. Ask for one byte and you still pay for the whole page. So the number that decides how fast a lookup is turns out not to be comparisons at all. It is page reads. And binary search over a sorted file is cruel here, because every halving lands somewhere else entirely, and each landing is a fresh page.
Why on earth does the hardware work in pages?
Because finding the data is the expensive part and copying it is not. A spinning disk has to move an arm and wait for the platter to come round, which takes milliseconds. Once the head is there, streaming the next few thousand bytes costs almost nothing extra. A solid state drive has no arm, but it is built from cells that are erased and written in blocks, so it has the same shape of cost for its own reasons.
The operating system settled on a page as the unit for the same reason, and keeps recently used pages in memory in case you want them again. Everything above the hardware, including this course, therefore counts in pages. A design that reads three pages beats a design that reads thirty, even if the second one does fewer comparisons.
The fix is a shape called a B-tree, and nearly every database in the world uses one. The idea is to stop halving and start dividing by a hundred. Fill one page with as many keys as it will hold, say 100, and keep beside each one a pointer to the page that holds the values in between. One page read now narrows the search 101 ways instead of two.
Tree, node, root, leaf, depth: the words
A tree here is a set of boxes joined by arrows that always point downward and never loop back. Each box is a node. The one at the top with nothing above it is the root. The boxes at the bottom with nothing below them are leaves. A box directly below another is its child.
Depth is how many boxes you pass through going from the root to a leaf. In a B-tree one node is one page, so the depth is exactly the number of page reads a lookup costs. That single sentence is why the rest of this step is about making trees shorter.
Where does the row itself live?
In the design shown here, and in most real ones, the actual rows sit in the bottom row of nodes and nowhere else. The nodes above hold only keys and arrows: signposts, not data. That is why the upper nodes can pack in so many keys, and why the tree stays short.
A separate index works the same way, except the bottom nodes hold the key and the place to find the row, instead of the row. The lookup then costs the depth of the index, plus one more page read to go and fetch the row itself. Step 6 gets a lot of use out of that extra read.
What an index costs you
Indexes look like free money in Step 3. They are not, and the bill arrives somewhere the reader was not looking: on writes. An index is a second copy of one column, kept in order. Copies have to be kept true. Put a new order in the table and it has to go into every index on that table as well, in the right place.
Usually that is cheap. The engine walks down the tree, finds the leaf page the key belongs in, slots it in, writes that one page back. But pages are a fixed size, so eventually the leaf is full, and then something more interesting happens.
What happens when a page has no room left
The engine performs a split. It takes the full page, hands out roughly half the keys to a brand new page, and then tells the parent about the new page by inserting one separator key. So one insert has turned into three page writes rather than one: the old page, the new page and the parent.
The catch is that the parent can now be full too, which splits it, which tells its parent. In the worst case a single insert splits every level up to the root, and the root splitting is the only way a B-tree ever gets deeper. It grows from the top, not the bottom, which is exactly why every leaf stays the same distance from the root.
Is a split a disaster? It sounds like one.
No, and the arithmetic says why. A page holding 100 keys splits about once every 50 inserts, because after a split each half has room for 50 more. So the extra writes are spread across 50 cheap inserts, which works out at well under one extra write each on average.
Splits are only a problem when they are constant, and there is one shape that causes exactly that: keys arriving in increasing order, which is what an auto-numbered id column does. Every insert lands in the last page, so that one page splits over and over while the others sit half empty. Real engines special-case this by cutting the last page unevenly instead of down the middle.
How do real teams decide which indexes to keep?
They measure. Every serious database can report how many times each index has actually been used since it was last restarted, and dropping the ones with a count of zero is one of the quickest improvements available. It is common to find indexes added years ago for a screen that no longer exists.
The other half of the job is looking at the slow queries and asking which index would have helped. Step 13 is that job, in miniature, with the costs counted for you. Safe to skip until then.
Putting two tables together
Real data is never in one table. The shop keeps orders in one table and customers in another, and the
order table does not repeat the customer's name and address on every line. It stores the customer's
id and nothing else. That small stored id, pointing at a row in another table, is called a
foreign key.
Which means the obvious question,"show me every order with the customer's name next to it", cannot be answered from one table. The engine has to pair each order with the matching customer row. That pairing is a join, and it is the operation databases spend most of their effort on.
Why split it into two tables in the first place?
Because a customer who moves house should move house once. If the address is copied onto all 300 of their past orders, then changing it means finding and rewriting 300 rows, and if you miss one the database now holds two different truths. Storing each fact in exactly one place is the single most useful habit in database design.
The price is that answering a question now needs two tables instead of one. That price is the join, and the rest of this step is about how much it costs and how to make it cost less.
There are three standard ways to do it, and none of them is best. The engine picks one each time it runs a query, based on how big the two sides are and what indexes exist.
What a hash table is, since one of the three uses it
Imagine a row of numbered buckets. To file a customer, run their id through a small fixed sum that turns any value into a bucket number, and drop them in that bucket. That sum is called a hash. To find them again, run the same sum, go to that one bucket, and look through the handful of items there. You never look in any other bucket.
So a hash table finds things in a roughly fixed number of steps no matter how many items it holds, which is better than a tree. What it cannot do is answer "give me everything between 100 and 200", since the hash scatters neighbouring values into unrelated buckets on purpose. Trees keep order, hashes throw it away for speed. Both exist because both trades are sometimes right.
If the nested loop is so bad, why does any engine use it?
Because when one side is tiny, the multiplication is tiny too. Joining 5 rows to 5 million with a hash join means building a table of 5 million buckets first. The nested loop skips that entirely and does 5 lookups. Engines choose it constantly for small inputs.
And there is a second reason, which Lab 10 is about. If the inner table has an index on the joining column, the nested loop stops scanning the inner table and starts doing a tree lookup instead. That turns the worst plan into one of the best ones without changing the plan's name.
Questions an index cannot answer quickly
An index is a sorted list. Everything it can do, and everything it cannot, comes from that one fact. Sorted means you can jump to a value, and read forward from there in order. It does not mean anything else.
So "find the customer named Perera" is easy: jump to P, take what is there. And "find every customer whose
name ends in era" is impossible for the same index to help with, because the sorted order is by
first letter, and names ending in era are scattered from Alwis to Zoysa. The engine has no choice but to read
the whole table. The query looks almost identical to the fast one. It is not.
Why a sorted list only helps from the left
Think of a paper phone book. Finding Perera takes seconds, because the book is ordered by the first letter, then the second, then the third. Now find everyone whose name ends in "era". The book gives you nothing. You read all of it.
Nothing about a computer changes this. A database index is that same book. Any condition that pins down the start of the value can use it. Any condition that only describes the middle or the end cannot, and no amount of hardware fixes that, because the ordering it needs was never built.
total × 2 is over 1,000, blinds the index
completely: the index holds total, not total × 2, so it cannot jump anywhere.
Rewriting the same condition as total > 500 gets the index back. That rewrite is one of
the most common real fixes there is.What "selectivity" means
Selectivity is just the share of the table a condition keeps. Asking for one exact id out of 10,000 rows is very selective: it keeps 0.01 percent. Asking for orders whose status is not cancelled might keep 95 percent, which is barely selective at all.
This one number decides more query plans than anything else, and it is why the engine keeps rough statistics about your data. It has to guess how many rows a condition will keep before it runs it. When those statistics go stale, the engine guesses badly and a query that ran in a blink starts taking a minute, with no code having changed.
Is there any way to answer "ends with era" quickly?
Yes, by storing something else. Build a second index on the name spelled backwards, and "ends with era" becomes "starts with are" on that index, which is a jump. Some engines will do this for you if you ask for an index on a calculation rather than on a column.
The general lesson is worth more than the trick. When a question cannot be answered quickly, the fix is almost never a faster machine. It is storing a different arrangement of the same data, chosen so that the question you keep asking becomes a jump. That is what every index in this course has been.
city and there is an index on city. The table
has 100,000 rows and 60,000 of them are in Colombo. Somebody searches for Colombo and it is slow. What is
the most likely reason?Pulling the plug
Everything so far has been about speed. This part is about something else entirely: whether the answer is true. Move 500 rupees from Amali's account to Nuwan's. That is two writes. Take 500 off one row, add 500 to the other.
Now cut the power in between. Not a bug, not bad code: a substation trips, or somebody kicks the cable. The first write is on the disk. The second one never happened. Five hundred rupees have left the world.
What actually survives a power cut, and what does not
A machine has two kinds of storage. Memory is fast and forgets everything the instant the power goes. Disk is slow and remembers. A running program keeps almost everything in memory, and writes to disk only when it decides to.
So "the power went off" means precisely this: everything in memory is gone, and whatever had already reached the disk is still there. The trouble is that the program does not get to choose the moment. The cut lands wherever it lands, including exactly halfway through a job that only makes sense whole.
Could you not just do both writes at the same instant?
Not really. The two rows are in different places on the disk, so they are two separate operations down at the hardware, and something can always happen between them. Even if they landed in the same page, a page write can be interrupted partway and leave half-old, half-new bytes behind, which is worse than either outcome.
So the answer cannot be "make the gap small". It has to be "make the gap survivable". That means writing down what you intend to do, somewhere that survives, before you start doing it, which is Step 8 in one sentence.
What is needed is a way to say: these writes are one thing. Either all of them happen or none of them do, and there is no state of the world in which half of them are visible. A group of writes with that promise attached is a transaction. You mark the start, do the writes, and then either commit, which means make it all real, or roll back, which means pretend none of it happened.
You will see the word ACID. What are the four letters?
They are the four promises a transaction makes, and each one has a step of this course behind it. A is atomic: all of it happens or none of it does, which is this step. C is consistent: the invariants that held before still hold after. I is isolated: two transactions running at once do not see each other half done, which is Steps 9 to 11.
D is durable: once you are told it committed, it survives a power cut, which is Steps 8 and 12. The word is used loosely in marketing, so it is worth being able to ask which of the four letters somebody actually means.
Writing it down before you do it
Here is the trick that solves Step 7, and it is not clever, which is why it works. Before touching any row, write a note on the disk saying what you are about to do. Only then do it. If the power goes, the note is still there when you come back. You can finish the job or undo it.
That file of notes is the write-ahead log, and "write-ahead" is the entire rule: the note goes to disk ahead of the change it describes. The log is a single file that only ever grows at the end, which matters more than it sounds, because appending to the end of one file is the cheapest thing a disk does.
Why appending is so much cheaper than updating rows
Ten rows changed by a transaction may live in ten different pages, scattered across the disk. Writing them means ten trips. Writing ten log records means one trip, because they all go to the same place: the end of the log, which the disk head is already sitting on.
So the engine gets to be safe immediately and tidy later. The moment the log record is on disk the transaction can be declared committed, and the real pages can be written whenever it suits, in a batch, in a sensible order. Being allowed to be late is what makes it fast.
Recovery then has one job. Read the log from the start. Note every transaction that has a
commit record. Redo the writes belonging to those, and ignore the writes belonging to
transactions that never committed. Amali's 500 either has a commit record after it or it does not, and there
is no third case.
Does the log grow forever?
It would, so the engine takes a checkpoint now and then: it writes all the changed pages out to the data file properly, then records in the log that it has done so. Everything before that point is now safely in the data file, so those log records can be thrown away.
Checkpoints are also what keeps recovery short. Without one, coming back from a crash would mean replaying the log from the day the database was created. With one, it means replaying from the last checkpoint, which is usually seconds of work. The cost is a burst of disk writes each time, which is why a database can feel briefly slower every few minutes.
What is written in one log record?
Five small things: which transaction, which row, which column, the value before, and the value after. The value after is what recovery uses to redo committed work. The value before is what a rollback uses to undo work that was abandoned, whether the abandoning was your choice or a crash's.
That is why a log is much smaller than the data it protects, and why it can be written in one place while the rows it describes are scattered. A record is a sentence about a change, not a copy of the page.
When an update disappears
Crashes are not the only way to break an invariant. Two people at once will do it on a machine that never fails at all. Amali's account holds 500. A shop charges her 50 and, in the same second, a refund of 30 arrives. Both are ordinary, correct pieces of work.
Each one does the same three things: read the balance, work out the new balance, write it back. That shape, read then change then write, is where almost all concurrency bugs live, because there is a gap in the middle where the value you are holding can go stale.
What "at the same time" actually means here
Usually it does not mean two things happening in the same instant. It means the machine is switching between them, running a bit of one, then a bit of the other, over and over, far too fast to see. That switching back and forth is called interleaving, and the order it produces is different every time.
Which is why this class of bug is so unpleasant. In the tiny six-step model below, nearly every overlapping order is wrong because the read-to-write gap takes up most of each transaction. A real transaction may do thousands of unrelated steps around a much smaller dangerous gap, so the overlap can be rare. Rare does not mean safe: enough traffic eventually finds every possible order.
What would "correct" even mean when two things run at once?
There is a clean definition and it is worth learning. An outcome is correct if it matches what you would have got by running the transactions one after another, in some order, with no overlap at all. That is called a serial order.
Notice it does not say which order. Charge then refund gives 480, and refund then charge gives 480 as well. Either is acceptable. What is not acceptable is 530 or 450, because no ordering of the two whole transactions produces those. A schedule that gives the same answer as some serial order is called serializable, and that word is the goal of the next two steps.
The same bug without any money in it
A web page keeps a counter of how many times it has been viewed. Each visit reads the counter, adds one, writes it back. Exactly the shape from this step. Two visits landing together lose a count, and nobody notices, because nobody knows what the true number was.
This is worth holding on to, because it shows why the bug survives. With money somebody eventually complains. With counters, likes, stock levels and "number of retries so far", the number is simply a little bit wrong forever, and the wrongness grows with traffic.
Locks, and the jam they cause
The fix for a lost update is blunt. Before a transaction reads a row it means to change, it takes a lock on that row. While it holds the lock, nobody else may touch that row. Anybody who tries is made to wait until the lock is released, which happens at commit.
That closes the gap from Step 9 completely. T2 cannot read the balance while T1 is in the middle of changing it, so T2 cannot be holding a stale copy. It simply waits, and then reads the value T1 wrote.
Two kinds of lock, because readers can share
Locking every row against everybody would be far too strict. Two transactions that only want to read the same row cannot possibly upset each other, so they are allowed to hold the row together. That is a shared lock, and any number of transactions can hold one at once.
A transaction that means to write needs an exclusive lock, which cannot be held alongside anything else, shared or exclusive. So the rule is short: readers do not block readers, and everything else waits. Most database tuning arguments are about how long an exclusive lock is held.
Locks fix the lost update and immediately introduce a new failure of their own. If T1 holds row A and wants row B, while T2 holds row B and wants row A, then both wait forever. Neither can give way, because giving way means releasing a lock, and neither is allowed to release before it commits. That is a deadlock.
How the engine notices and what it does about it
It keeps a small picture called a wait-for graph: an arrow from each waiting transaction to the one it is waiting on. A deadlock is exactly a loop in that picture, and looking for a loop is quick, so the engine checks every second or so.
When it finds one, there is no polite option. It picks one transaction, calls it the victim, rolls it back completely and releases its locks, which frees everybody else. The victim gets an error saying it was chosen. Well-written software catches that error and simply tries the whole transaction again, and the second attempt almost always succeeds.
Why not lock the whole table and avoid all this?
You can, and it is correct. It is what some small engines do. The cost is that only one transaction can work on the table at a time, so a shop with a hundred cashiers becomes a shop with one. Locking a single row instead lets the other ninety-nine carry on.
The choice of how big a thing to lock is called granularity, and it is a trade like every other one here. Fine locks allow more work at once but need a bigger lock table and more checking. Engines usually lock rows, and quietly promote to a table lock when one transaction has taken so many row locks that tracking them costs more than the parallel work is worth. Safe to skip.
How much mess you agree to live with
Locking everything until commit is correct and slow. Every reader waits for every writer, so a single long-running report can bring a busy system to a halt. So databases offer a dial. You can ask for less protection in exchange for less waiting, and the dial has four standard settings called isolation levels.
What matters is not the names but the honest bit: each setting tells you exactly which kinds of wrong answer it still permits. Choosing a level is choosing which anomalies you are prepared to see.
The three anomalies, in plain words
A dirty read is reading a value another transaction has written but not committed. If that transaction then rolls back, you acted on a number that never existed. A non-repeatable read is reading the same row twice in one transaction and getting two different values, because somebody committed a change in between.
A phantom is the same problem for a group rather than a row. You count the orders from Kandy and get 12. Somebody inserts a new Kandy order and commits. You count again and get 13, inside the same transaction. No row you read has changed. A new one appeared.
Why would anyone choose a level that allows wrong answers?
Because for a lot of questions the wrong answer does not matter. A dashboard showing roughly how many orders came in today does not need to be exact, and making it exact might mean blocking the checkout for everybody while it counts. Read committed, which most databases use by default, is the common compromise.
The mistake is not choosing a weak level. The mistake is choosing one without knowing which anomalies it lets through, and then being surprised. Money movement, stock counts and anything with a uniqueness rule usually deserve the strictest setting, which is why Lab 22 asks you to learn the table rather than look it up.
Snapshots: the other way to get isolation
Locks are not the only mechanism. Most modern engines keep several versions of each row, each stamped with the transaction that made it. A reader is given the version that was current when its transaction began, and it keeps reading that version to the end. Writers make new versions rather than overwriting, so a reader never has to wait for a writer.
This is why a report can run for ten minutes on a busy database without freezing anybody, and why old versions have to be cleaned up afterwards by a background job. It changes how the levels are implemented, not what they permit, so the table in Lab 22 still holds.
What "saved" actually promises
Step 8 said a transaction is safe once its commit record is on the disk. That sentence hides a stack of four places the bytes have to travel through, and every one of them can be holding your commit record when the power goes.
The word for a waiting area like that is a buffer: a patch of memory holding data on its way somewhere, so the slow thing at the far end can be dealt with in large batches instead of one item at a time. Buffers are why computers are fast. They are also why "saved" is a slippery word.
The four places your bytes sit on the way down
First the program's own buffer, in its own memory. Then the operating system's buffer, still in memory, because writing to a file usually just hands the bytes to the system and returns immediately. Then the drive's own small buffer, which is on the drive but still electric. Only then the actual storage that survives a power cut.
Each handover has its own instruction. Emptying the program's buffer into the system is a flush. Telling the system to push everything down to the drive and not return until the drive says it is safe is called fsync. A commit that has not run fsync has not really committed, whatever it reported to the user.
Why is fsync so slow?
Because it is the one operation that cannot be batched away or answered from memory. It has to wait for physics: for a platter to come round, or for a flash cell to be programmed. On a spinning disk that is several milliseconds, which sounds small until you notice a busy shop wants to commit a thousand transactions a second.
That single number sets the ceiling on how many transactions a database can commit per second, and no amount of processor speed moves it. Which is why the trick in Lab 24 exists, and why it is one of the most valuable ideas in the whole field.
What if the whole drive dies?
Then durability, as defined here, has nothing to say. Everything in this step is about surviving a power cut on a working drive. A drive that stops existing takes its log and its data file with it, and no amount of fsync helps.
That is a different promise, and it is bought differently: by keeping copies somewhere else. A backup taken nightly, or a second machine receiving the log records as they are written and applying them as they arrive. The second one is a whole course of its own, because the network between the two machines is allowed to drop things. Safe to skip for now.
How the buffer pool works
A database does not read a page from storage every time a query asks for it. It keeps recently used pages in a reserved area of memory called the buffer pool. A lookup first asks the pool. A hit means the page is already there. A miss means the engine must fetch it from storage.
Each frame in the pool holds one page. A page being used is pinned, so the eviction code may not replace it. A changed page is dirty. It can be evicted only after the matching WAL record is durable and the page has been written back. The pool therefore joins query speed, WAL ordering and checkpoint work.
Why the operating system cache is not enough
The operating system also caches files, but it does not know which database pages are pinned or which ones are expensive to rebuild. A database pool can keep an index root hot, schedule dirty writes and report its own hit rate. Some engines use direct I/O; others cooperate with the operating-system cache.
Two caches can mean the same page occupies memory twice. One cache can mean less control. There is no universal choice. Engineers measure the workload and the storage system before changing it.
Choose the structure for the question
A B-tree is a good default, not the only possible index. Its sorted keys handle equality, ranges and ordering. A hash index is good at equality but discards order. A bitmap or inverted index records which rows contain each value or word. A spatial tree groups nearby regions. A BRIN-style index stores a small summary for a run of neighbouring pages.
Tables also come in different physical shapes. A row store keeps one whole record together and suits transactions that touch a few rows. A column store keeps one column together and suits analytics that scan a few columns across millions of rows. An LSM tree buffers writes and merges sorted runs later, trading cheap writes for background compaction and more complicated reads.
Composite, partial and covering indexes
A composite B-tree on (customer, created_at) is ordered by customer first, then time
within one customer. It can jump to one customer's recent orders. Reversing the columns answers a
different question. This is the left-prefix rule from Step 6 applied to more than one column.
A partial index stores only rows matching a condition, such as unpaid orders. A covering index includes the columns a query returns, so the engine may answer from the index without visiting the heap. Both save reads, but both add write work and storage.
Similarity search in 2026
Text, images and audio can be converted by a model into a list of numbers called an embedding. Nearby lists usually represent similar meaning. A vector query asks for the nearest stored vectors, rather than an exact key or a numeric range.
Exact search compares the query with every vector. That is correct and expensive. Approximate nearest neighbour indexes such as HNSW and IVFFlat examine a smaller candidate set. They trade some recall, the share of true nearest items found, for lower latency and memory or build cost.
An embedding is not a fact and distance is not truth
An embedding records patterns learned from training data. Two passages can be nearby and still disagree. A retrieved passage can be outdated or malicious. Similarity search finds candidates; it does not verify their claims.
Production retrieval usually combines vector similarity with ordinary filters, keyword ranking, access control and citations. Keep the original source text and its permissions beside the vector.
Several versions of one row
Step 11 introduced snapshots. Now build one. Under multiversion concurrency control, an update creates a new row version and marks the old version as finished. A snapshot contains the transaction ids it is allowed to see. Visibility is a rule over those ids, not simply “pick the newest bytes”.
Old versions let readers and writers continue together. They also occupy space. A vacuum or garbage collector can remove a version only when no active snapshot could still need it. One forgotten transaction can therefore hold back cleanup and make a table and its indexes grow.
Transaction ids also need maintenance
A fixed-size transaction id eventually wraps around. An engine must mark sufficiently old committed versions as permanently visible before that happens. PostgreSQL calls this freezing and performs it as part of vacuum work.
This is why cleanup is part of correctness, not just disk housekeeping. Monitor age limits and vacuum progress before they become an emergency.
An invariant no row lock can see
Two doctors are on call. The rule says at least one must remain on call. Each doctor starts a transaction, sees the other is on call, and takes themself off. They update different rows, so no row-level write conflict occurs. Both can commit under snapshot isolation, leaving nobody on call.
This is write skew. The broken invariant spans several rows. Serializable isolation prevents the outcome by tracking read/write dependencies or by locking the predicate that represents “doctors on call”. An explicit constraint or a single locked summary row can also move the invariant into one conflict point.
Prefer a database constraint when one can express the rule
A unique, foreign-key or check constraint is enforced for every writer, including scripts and future services. A check in application code protects only paths that remember to call it.
Cross-row rules are harder to express as simple constraints. For those, document the chosen lock or serializable pattern and test concurrent schedules rather than only single requests.
Recovery is three separate jobs
Step 8 replayed a small log from the beginning. A production engine needs a tighter method. It starts at a checkpoint and performs three jobs: analysis finds active transactions and dirty pages; redo repeats updates that may not be present on disk; undo reverses transactions that never committed.
Every log record has a log sequence number, or LSN. Each data page records the LSN of its newest applied change. During redo, a record can be skipped when the page LSN is already as new. This makes replay idempotent: running it twice gives the same result as running it once.
Why undo writes more log records
Recovery itself can crash. When undo reverses a change, it records that action in a compensation log record. A second recovery can see which undo work was already completed and continue safely.
This analysis-redo-undo shape is associated with ARIES. Engines differ in record format and detail, but the questions stay useful: where does recovery start, what is safe to repeat, and how is interrupted recovery resumed?
Detecting damaged pages
A correct WAL order cannot stop a storage device from returning damaged bytes. A page checksum turns the page into a number before it is written. The engine recomputes that number when reading. A mismatch detects accidental change; it does not repair the page or protect against a person deliberately changing both data and checksum.
A power cut can also produce a torn page: some sectors are new and the rest are old. Engines may log a full page image after a checkpoint, use atomic-write storage, or keep a second safe copy. Recovery can restore the complete image before replaying later changes.
Checksums, encryption and error-correcting memory solve different problems
A checksum detects accidental byte changes. Encryption limits who can understand stored bytes. Authentication detects deliberate modification by someone without the key. ECC memory can correct some bit errors before the database sees them.
A careful system may use all four. Naming the failure first prevents one mechanism being credited with a guarantee it does not provide.
A backup is only real after a restore
A backup protects against deletion, corruption and loss of the whole machine. A replica is useful for
availability, but it quickly copies an accidental DELETE. A backup kept in another failure
domain can recover older state. Keep more than one generation, restrict deletion and test restoration.
Point-in-time recovery combines a base backup with archived WAL. Restore the base, then replay WAL until just before the bad event. The recovery point objective says how much recent data may be lost. The recovery time objective says how long service may remain unavailable.
What a useful restore drill records
Record the backup chosen, every WAL segment used, checksum results, start and finish times, and the application checks run afterwards. Restore into an isolated destination so the drill cannot overwrite the source.
Test operator access too. A backup that only one unavailable person can decrypt or locate does not meet a recovery objective.
Change a live database safely
A schema change is a program change applied to stored data. Adding a nullable column is usually cheap. Rewriting every row, checking a new constraint or building an index can run for hours and hold locks. Safe migrations separate compatible steps: expand, backfill in small batches, verify, switch readers, then remove the old shape later.
Diagnosis is also a sequence. Start with the symptom and a time window. Inspect query plans and actual rows, buffer reads, lock waits, WAL rate, checkpoint time, vacuum lag and storage latency. Change one thing, measure again and keep a rollback. An AI assistant can propose hypotheses or explain a plan, but it must not receive secrets or execute unreviewed production changes.
Use the neighbouring courses instead of stretching this one
Build a Query Engine covers parsing, relational algebra, cardinality estimates and plan search. Distributed Consistency covers histories, replicas and clocks. Raft and Consensus covers an ordered replicated log and membership changes.
Security engineering adds threat models, least privilege, secret handling and incident response. Database permissions, encrypted connections and audit logs should be designed with those tools rather than treated as a final checkbox.
Tune and diagnose it yourself
Everything in this course now exists in one place: a heap of rows in pages, B-trees over them, a log, and a lock table. The last job is the one an engineer is actually paid for. Here is a day of a shop's work. Choose which indexes to keep, and make the total cost fit the budget.
Nothing is hidden. The workload runs against the real structures, every page read and page write is counted as it happens, and the tempting answer of indexing everything will fail the budget for a reason you can read off the bars.
What the budget number actually counts
One unit is one page touched: read into memory, or written back out. It is the standard way engines compare plans, because a page is the unit the hardware works in and because the count is the same on every machine.
Real planners weight the two differently, since a write is dearer than a read, and they add a small charge for the processor work of comparing rows. Those refinements change the numbers by tens of percent and change the answers hardly at all, so this lab counts pages plainly and says so.
What these choices look like typed out in a real database
The language almost every database speaks is called SQL, and the parts this course has been doing by hand are four short commands. Nothing below is needed to finish the lab, but you will meet it within a day of touching a real system.
SELECT id, total FROM orders WHERE customer = 412; CREATE INDEX orders_customer ON orders (customer); EXPLAIN SELECT id, total FROM orders WHERE customer = 412; DROP INDEX orders_customer;
The third one is the interesting command. EXPLAIN does not run the query. It asks the
planner to print the plan it would use, including whether it intends to use an index or scan the table,
and it is how every one of the problems in Step 6 gets diagnosed in real life.
How does a real planner choose, given it cannot try every plan?
It estimates. For each way of running the query it works out a cost from statistics it keeps about your tables: roughly how many rows there are, how many distinct values a column holds, how the values are spread. Then it picks the cheapest estimate and runs that. It never measures the real thing first, because measuring would cost more than the query.
Which explains the most confusing kind of database problem there is. The statistics go stale, the estimate becomes wrong, the planner picks a bad plan, and a query that ran in a blink for a year starts taking a minute with nothing in your code having changed. The command to refresh those statistics is usually the first thing to reach for.
help and press Run to
see the words it knows. Then try begin, set amali 400, crash,
recover, get amali, one line at a time, and see what the balance is.log to read what recovery would
see.id only. Where would you look first?What you built
- A table as bytes in a file, and the arithmetic that jumps to row n.
- A B-tree over pages, splitting as it grows, with lookups costing its depth.
- The read and write sides of an index, and the judgement to weigh one against the other.
- Three join strategies, and the reason the choice between them is really an index question.
- A write-ahead log, a crash, and a recovery that redoes committed work and discards the rest.
- A lock table, a lost update it prevents, a deadlock it causes, and the ordering rule that avoids it.
- The four isolation levels and the anomaly each one still lets through.
- The four buffers between a commit and a promise you can keep.
- A buffer pool with hits, misses, eviction and the WAL rule for dirty pages.
- Row, column and LSM storage, plus composite, covering and vector indexes.
- MVCC visibility, cleanup, write skew and whole-transaction retries.
- Analysis, redo and undo recovery, page checksums, backups and point-in-time restore.
- An expand-and-contract migration and an evidence-led incident diagnosis.
Where this goes
- Build a Query Engine. You have been choosing plans by hand. Next, parse a written query, work out every plan that would answer it, cost each one, and let the machine choose.
- Reliable Data Transfer. The log in Step 8 makes one machine survive a crash. Sending it to a second machine over a network that drops things is a harder and different problem.
- Distributed Consistency. Two copies of this database, on two machines, both accepting writes. Everything in Part 5 comes back, larger, and some of it has no clean answer.
- Source manuals. PostgreSQL's current documentation explains MVCC, write-ahead logging, index types and point-in-time recovery. Use the manual for the engine and version you are operating.