Contents
Course · 11
Where do files live, and how do we find things fast?
This page covers three jobs. Keeping big files safe and cheap. Finding words inside millions of documents. Answering big "sum up everything" questions. Every number here was checked against official docs on 2026-10-25.
Put bytes in object storage, put facts about them in a database, and copy text into a search index that you rebuild from the database.
- Object storage (S3) is a giant key → file map over HTTPS. Clients upload straight to it with a pre-signed URL; your server never touches the bytes.
- Big files go up in parts: S3 takes up to 10,000 parts of 5 MiB–5 GiB. Dropbox cuts files into 4 MB blocks named by their SHA-256 hash, so a block already stored is never sent again.
LIKE '%cat%'reads every row. Search engines flip the data around: an inverted index maps each word to the documents that hold it.- Elasticsearch shows new documents after a refresh, every 1 s by default, but only on indices searched in the last 30 s.
- Keep search in sync by reading the database's change log (CDC), not by writing to two places from your app.
PUT—Words you need
Storing files
01Block, file and object storage
Block storage is a raw disk. It only knows numbered blocks. Your operating system puts a file system on top. It is fast and low-level. Databases love it.
File storage is a shared folder. Many servers mount it at once over the network and see the same files and folders.
Object storage stores whole files by name. You talk to it with web calls (PUT, GET, DELETE). There are no real folders and no "edit byte 500".
| Kind | AWS example | How you use it | Shared by many servers? | Best for |
|---|---|---|---|---|
| Block | EBS | Mounted as a disk on one EC2 server | No (except io1/io2 Multi-Attach, up to 16 Nitro servers in one AZ) | Database files, boot disks |
| File | EFS | Mounted over NFSv4.0/4.1 | Yes | Shared home folders, CMS uploads, legacy apps that need a path |
| Object | S3 | HTTPS API by key | Yes, from anywhere | Photos, videos, backups, logs, data lakes |
io2 Block Express is designed for 99.999% durability; other types 99.8–99.9%. S3 Standard is designed for 99.999999999% (11 nines) and keeps data in 3 or more AZs. That is why your user files go to S3, not to a server's disk. Source: EBS user guide, S3 storage classes.Database files → block. Files many servers must open by path → file. Everything users upload → object.
Sources: What is Amazon EBS · EBS Multi-Attach · What is Amazon EFS · checked 2026-10-25
02The object storage model
A bucket is the top container. Inside it, each object has a key, which is its full name, for example photos/puppy.jpg. The slash is just a character. "Folders" are only name prefixes.
An object is the bytes plus metadata: name-value pairs like Content-Type or your own x-amz-meta-* fields (user metadata is limited to 2 KB).
Objects are replaced, not edited. To change one byte you upload the whole object again. Turn on versioning to keep old copies.
PUT and DELETE in all Regions. A read that starts after a successful write sees the new data. Updates to one key are atomic: a reader gets the old object or the new one, never half. But there is no locking: two writers at once → "latest timestamp wins". There are no transactions across keys. Source: S3 consistency model.Lifecycle tiers: pay less for cold data
A lifecycle rule moves objects to cheaper classes as they age, or deletes them. Cheaper classes have a catch: a minimum storage time you pay for even if you delete early, plus retrieval fees.
Minimum billed storage time by S3 class
Days you pay for even if you delete sooner. Hover a bar for details. From the S3 storage-class comparison table, checked 2026-10-25.
Show the full table
| Class | Use when data is read… | Access time | AZs | Min days | Designed availability |
|---|---|---|---|---|---|
| S3 Standard | more than once a month | milliseconds | ≥3 | 0 | 99.99% |
| Standard-IA | about once a month | milliseconds | ≥3 | 30 | 99.9% |
| One Zone-IA | about once a month, can be re-created | milliseconds | 1 | 30 | 99.5% |
| Glacier Instant Retrieval | once a quarter | milliseconds | ≥3 | 90 | 99.9% |
| Glacier Flexible Retrieval | once a year | minutes to hours (restore first) | ≥3 | 90 | 99.99% after restore |
| Glacier Deep Archive | less than once a year | hours (restore first) | ≥3 | 180 | 99.99% after restore |
{ "Rules": [{
"ID": "logs-tiering", "Status": "Enabled",
"Filter": { "Prefix": "logs/" },
"Transitions": [
{ "Days": 30, "StorageClass": "STANDARD_IA" },
{ "Days": 90, "StorageClass": "GLACIER" } ],
"Expiration": { "Days": 365 } }] }
Object stores are simple on purpose: whole-object writes, one key at a time. Use lifecycle rules, but check minimum days before moving small or short-lived files.
Sources: What is Amazon S3 · S3 storage classes · S3 Lifecycle · Uploading objects (2 KB user metadata) · checked 2026-10-25
03Big uploads: multipart and pre-signed URLs
Why not upload through your API? Every byte would pass through your servers. That costs bandwidth, memory and time, and it ties up a server for minutes.
A pre-signed URL fixes this. Your API signs a URL with its own AWS credentials. The URL says "PUT this one key, until this time". The browser uploads straight to S3. Then the client, or an S3 event, tells your API the upload is done, and the API saves the row (step 4).
Multipart upload splits one big file into parts. Parts go up in parallel and in any order. A failed part is retried alone. S3 joins the parts at the end.
| Limit (S3) | Value |
|---|---|
Biggest single PUT | 5 GB |
| When to start using multipart | around 100 MB (AWS advice) |
| Part size | 5 MiB to 5 GiB (last part can be smaller) |
| Parts per upload | 10,000 |
| Biggest object | 48.8 TiB (the upload page says "50 TB"; older guides say 5 TB, now out of date) |
| Pre-signed URL life | up to 7 days with SigV4 and IAM user keys; console: 1 min–12 h; never longer than temporary credentials |
url = s3.generate_presigned_url(
"put_object",
Params={"Bucket": "media", "Key": f"u/{user_id}/{uuid4()}.mp4",
"ContentType": "video/mp4"},
ExpiresIn=900) # seconds; keep it short
Your API grants permission; storage moves bytes. Use multipart for anything above about 100 MB, and set a lifecycle rule to clean up unfinished multipart uploads.
Sources: S3 multipart limits · Uploading objects · Multipart upload overview · Pre-signed URLs · checked 2026-10-25
04Storing large files: chunks, dedup, and the metadata split
Chunking means cutting a file into blocks. If you edit one page of a 1 GB file, only the changed blocks need to go up again.
Deduplication means storing each unique block once. The block's name is its content hash. The same bytes always give the same hash, so the server can say "I already have that one".
A file then becomes a list of block hashes. That list lives in a database. The bytes live in the blob store. This is the classic metadata + blob split.
| Where | What it holds | Why there |
|---|---|---|
| Metadata DB (SQL) | files(id, owner_id, path, size, version), file_blocks(file_id, seq, block_hash), sharing rules | Small rows, transactions, joins, "list my folder" queries |
| Blob store (S3) | Block bytes, keyed by block_hash | Cheap, durable, endless space, served by CDN |
Name blocks by their hash, keep a block list per file in SQL, and keep the bytes in object storage.
Sources: Dropbox tech blog · Muthitacharoen et al., A Low-bandwidth Network File System, SOSP 2001 · Harnik, Pinkas, Shulman-Peleg, Side Channels in Cloud Services, IEEE S&P 2010
Search
05From LIKE to the inverted index
WHERE body LIKE '%cat%' has a wildcard at the front. A normal B-tree index is sorted from the first letter, so it cannot jump to "anything containing cat". The database reads every row. With 100 million rows, that is every query, every time.
It also gets the meaning wrong. It matches "concatenate". It misses "Cats". It cannot rank the best result first.
Search for cat: look up one entry, get docs 1 and 2. Search cat sat: take both lists and keep the docs in both (an intersection). Each list is sorted by doc id, so the merge is fast.
Analyzers: the same cleaning at write time and at query time
An analyzer has three parts, run in order: character filters (for example, strip HTML), exactly one tokenizer (split text into words), and token filters (lowercase, drop stop words like "the", add synonyms, cut words to their stem so "running" matches "run").
POST _analyze
{ "tokenizer": "standard", "filter": ["lowercase", "stop"],
"text": "The Cat sat" }
→ tokens: "cat", "sat" ("the" removed by the stop filter)
LIKE '%foo%' with a pg_trgm GIN or GiST index that breaks strings into 3-letter pieces; its docs say "unlike B-tree based searches, the search string need not be left-anchored". That fixes speed, not ranking or word meaning. Source: Anatomy of an analyzer, PostgreSQL pg_trgm.Use the database for exact lookups. Use an inverted index when users type words and expect ranked results.
Sources: Elastic: analyzer anatomy · Elastic: near real-time search · PostgreSQL: pg_trgm
06Ranking: TF-IDF and BM25
Finding matches is half the job. Ranking them is the other half. Two ideas drive it:
- Term frequency (TF): a word that appears often in a document makes it more relevant.
- Inverse document frequency (IDF): a rare word ("kubernetes") matters more than a common one ("the").
TF-IDF multiplies the two. BM25 fixes two of its flaws. First, TF saturates: the 50th "cat" adds almost nothing. Second, it normalises length: a long document is not rewarded just for having more words.
score(D, Q) = Σ over q in Q of IDF(q) · f(q,D)·(k1 + 1)
─────────────────────────────────
f(q,D) + k1·(1 − b + b·|D| / avgdl)
f = times q appears in D · |D| = length of D · avgdl = average length
k1 = how fast TF saturates (default 1.2) · b = how much length matters (default 0.75)
k1 = 1.2 and b = 0.75. The formula comes from the probabilistic relevance framework of Robertson and Zaragoza. Source: Elastic: similarity module, Robertson & Zaragoza, The Probabilistic Relevance Framework: BM25 and Beyond (2009).In an interview, say: "BM25 = TF-IDF with saturation and length normalisation; it is the default in Lucene-based engines."
Sources: Elastic similarity docs · Robertson & Zaragoza 2009
07Inside Elasticsearch / OpenSearch
An index is like a table. It is split into primary shards; each shard is a full Lucene index. Each primary has replica copies on other nodes. Replicas give safety and extra read capacity.
A search goes to every shard (scatter). Each shard returns its top hits. The coordinating node merges them (gather). More shards → more parallel work, but also more overhead per query.
| Setting | Default | Can change later? |
|---|---|---|
index.number_of_shards | 1 | No, fixed at creation (use split/shrink or reindex) |
index.number_of_replicas | 1 | Yes, any time |
index.refresh_interval | 1s (Serverless: 5s) | Yes; -1 turns it off during bulk loads |
| Shard size advice | 10–50 GB, under 200 million docs | Guideline, not a hard limit |
Near-real-time: why a new document is not instantly searchable
A write goes to a memory buffer and the translog (a safety log on disk). It becomes searchable only after a refresh writes a new segment and opens it.
index.search.idle.after). OpenSearch documents the same 1s default and the same idle rule. If a test must see its write, use ?refresh=wait_for on that request, not a forced refresh on every write. Source: Elastic: near real-time search, OpenSearch index settings.Search is "near real-time": about one second behind. Pick primary shard count early, by expected data size.
Sources: Elastic: index settings · Elastic: size your shards · OpenSearch index settings · checked 2026-10-25
08Keeping search in sync with the database
Dual write means your app writes to the database and then to the search index. It looks simple. It breaks in quiet ways:
- The DB write succeeds, then the app crashes before the search write. Search is now missing data, forever.
- Two updates race. The DB ends with version B, but search ends with version A.
- Search is down. Do you fail the user's save? Retry? Where do you keep the retry?
Change data capture (CDC) avoids this. The app writes only to the database. A reader tails the database's own log of committed changes and turns each change into an event. A consumer applies the events to search, in commit order.
The transactional outbox is the app-level cousin: write the event into an outbox table in the same transaction as the business change, then a relay publishes it.
pgoutput) to read row changes from the write-ahead log, then writes them to Kafka topics. microservices.io states the outbox guarantee: "messages are guaranteed to be sent if and only if the database transaction commits". Consumers must be idempotent (safe to apply twice), because delivery can repeat. Source: Debezium PostgreSQL connector, Transactional outbox.Never make search the source of truth. Feed it from the database's log, and keep a "rebuild index from DB" job ready.
Sources: Debezium docs · microservices.io: Transactional outbox
09Typeahead and autocomplete
A trie (say "try") is a tree of letters. Walking down c → a takes as many steps as the prefix is long, no matter how many words exist.
The naive way then walks every word under "ca" and sorts by popularity. That is slow for short prefixes like "a". The fix: store the top-k completions inside each node. A lookup is then "walk the prefix, return the list".
- Build the trie offline (for example, hourly) from query logs. Swap in the new one. Writes never block reads.
- Cache hot prefixes at the edge or in Redis. Short prefixes are few and very hot.
- Shard by prefix when it outgrows one machine.
- On the client: wait about 100–300 ms after typing stops (debounce) before calling. This range is common practice, not from a spec.
Typeahead = trie (or FST) + top-k per node + offline rebuild + heavy caching.
Sources: Elastic blog · Elastic suggesters docs
Analytics and special stores
10Warehouses, column storage and data lakes
OLTP (online transaction processing) is your app's database: many tiny reads and writes of whole rows. "Get order 42". "Insert one payment".
OLAP (online analytical processing) is reporting: few huge queries that read millions of rows but only a few columns. "Total revenue by country by month".
| Row store (OLTP) | Column store (OLAP) |
|---|---|
A disk block holds whole rows: [id, name, country, amount] | A disk block holds one column for many rows: [amount, amount, amount…] |
| Great for "fetch one order with all fields" | Great for "sum one field over a billion rows" |
| Mixed types per block → weaker compression | Same type per block → strong compression |
| Postgres, MySQL | Redshift, BigQuery, Snowflake, ClickHouse |
Data lake vs data warehouse
A data warehouse holds cleaned data in fixed tables. The shape is set before loading (schema-on-write). A data lake keeps raw files of any kind, often in S3, and applies a shape only when you read (schema-on-read). Many teams now use both: raw lake → cleaned warehouse tables.
Never run heavy reports on the OLTP primary. Copy data (often with CDC) into a column store.
Sources: Redshift columnar storage · AWS: What is a data lake
11Time-series databases
Time-series data is a stream of (timestamp, value, labels) points. New points arrive all the time. Old points never change. Queries ask for ranges: "CPU of host-7, last 6 hours, per minute".
A time-series database (Prometheus, InfluxDB, TimescaleDB) is built for that shape:
- Compression: store the change between neighbour points, not the full value. Close numbers compress very well.
- Time blocks: data is cut into blocks by time, so "drop data older than 15 days" means deleting whole blocks.
- Downsampling: keep per-second data for days, per-hour data for years.
Metrics in a normal SQL table work at small scale. At millions of points per second, use a time-series store.
Sources: Prometheus storage docs · Pelkonen et al., Gorilla, VLDB 2015
12Bloom filters
A Bloom filter is a row of bits, all 0 at the start. To add an item, hash it with k different hash functions and set those k bits to 1.
To check an item, look at its k bits. Any bit is 0 → the item is surely not in the set. All bits are 1 → it is probably in the set.
Why "probably"? Other items may have set those same bits by chance. That is a false positive. There are no false negatives. You also cannot delete from a plain Bloom filter, because a bit may be shared.
false-positive rate p ≈ (1 − e^(−k·n/m))^k
best number of hashes k = (m/n)·ln 2
n = items added · m = bits · about 9.6 bits per item gives p ≈ 1%
bloom_filter_fp_chance is 0.01 (0.1 for LeveledCompactionStrategy tables); the docs say 0.01 needs about three times the memory of 0.1. Source: Cassandra: Bloom filters, Bloom filter (formulas), original paper: B. H. Bloom, CACM 1970.Use a Bloom filter in front of a slow lookup when most answers are "no": disk files, caches, "is this username taken?" pre-checks.
Sources: Cassandra docs · Wikipedia: Bloom filter · Bloom 1970, Space/time trade-offs in hash coding
Practice
—When to use what
| Situation | Pick | Why |
|---|---|---|
| Users upload photos and videos | S3 + pre-signed URL + metadata row in SQL | Bytes skip your servers; SQL handles listing and permissions |
| Files over about 100 MB, flaky network | Multipart upload, parallel parts, retry per part | A failed part costs one part, not the whole file |
| Same files uploaded by many users | Content-hash chunks + dedup | Store and send each block once |
| Legacy app needs a shared folder path | EFS (NFS) | Many servers mount the same files |
| Database data files | EBS (block) | Low-latency random reads and writes |
| Logs kept 1 year, rarely read | S3 + lifecycle to IA, then Glacier | Pay less as data cools; watch min days |
| Free-text search with ranking | Elasticsearch / OpenSearch fed by CDC | Inverted index + BM25; DB stays the truth |
| Small app, simple "contains" search | Postgres pg_trgm or full-text search | No second system to sync |
| Search box suggestions | Trie/FST with top-k per prefix + cache | Answer in one walk, no sorting at query time |
| Dashboards over billions of rows | Column-store warehouse | Reads only the needed columns, compresses well |
| Metrics at high rate | Time-series DB (Prometheus etc.) | Compression, time blocks, cheap retention |
| Skip disk reads for missing keys | Bloom filter | Few bits per key, no false negatives |
—Common mistakes
—Interview questions
Show answer
io1/io2 with Multi-Attach, up to 16 Nitro servers in one AZ, and you need a cluster-aware file system.Show answer
u/123/abc.jpg; the database row holds the key, size, type, owner and permissions. Reads then go through a CDN or a pre-signed GET URL. Small blobs (a few KB, like avatars in a tiny app) in the DB can be fine; the rule is about scale. Follow-up: "How do you delete safely?" Delete the row first (or mark it), then let a background job delete the object, so you never point at missing bytes.Show answer
PUT of one key) until an expiry time. The client uploads straight to S3, so your servers never carry the bytes; that saves bandwidth, memory and connection time. S3 checks the signature and expiry when the request starts. Limits: up to 7 days with SigV4 and IAM user keys, but never longer than the temporary credentials used to sign it; the console allows 1 minute to 12 hours. Risks: it is a bearer token, so keep expiry short (minutes), scope it to one key, and set Content-Type when signing. After upload, confirm with an S3 event or a client "done" call before marking the file ready. Follow-up: "How do you limit file size?" Use a pre-signed POST policy with a content-length range.Show answer
PUT caps at 5 GB. The maximum object is now 48.8 TiB in the limits table. Catch: parts of an unfinished upload are stored and billed, so add a lifecycle rule to abort incomplete uploads after some days. Follow-up: "How big should parts be for a 1 TB file?" At least 1 TB / 10,000 ≈ 100 MB per part.Show answer
uploads(id, user, s3_upload_id, status=pending), and returns pre-signed URLs for each part, say 100 MB parts → 100 parts. The phone uploads 3–4 parts at a time, records finished parts locally, and retries failed parts with backoff. If the app is killed, it asks the API which parts S3 already has (ListParts) and continues. When all parts are done, the API completes the upload; an S3 event then triggers transcoding. Pre-signed URLs expire in minutes, so the app asks for fresh ones as needed. A lifecycle rule aborts uploads stuck for a week. Follow-up: "What about checksums?" Send a per-part checksum header so S3 rejects corrupted parts.Show answer
LIKE '%term%' slow, and what are the options? easyShow answer
'term%'. A leading wildcard forces a full scan of every row. It is also semantically weak: no word boundaries, no stemming, no ranking, and case issues. Options in order of effort: (1) Postgres pg_trgm GIN index, which splits strings into 3-letter pieces and supports LIKE '%x%' without left-anchoring. (2) Postgres full-text search (tsvector) for word-based search with basic ranking. (3) A dedicated search engine like Elasticsearch/OpenSearch, with analyzers, BM25 ranking, facets and fuzzy matching, kept in sync with CDC. I pick the simplest option that meets the ranking and scale needs, because every extra system adds sync work.Show answer
Show answer
k1 (Elasticsearch default 1.2): the 10th occurrence adds far less than the 1st. It also normalises by document length relative to the average, controlled by b (default 0.75): b = 0 ignores length, b = 1 fully normalises. BM25 is the default similarity in Elasticsearch. In practice you add business signals on top, such as recency, popularity or personalisation, via function scores or a learning-to-rank step. Follow-up: "When would you lower b?" For short fields like titles, where length differences carry little meaning.Show answer
refresh=wait_for on that one write if the UX needs it, and monitor consumer lag. Do not force a refresh on every write; it creates tiny segments and hurts indexing speed. Follow-up: "How would you measure the lag?" Write a timestamp, then alert on now minus last indexed timestamp.Show answer
Show answer
Show answer
Show answer
Show answer
Show answer
rate(). At small scale, Postgres with TimescaleDB or a simple partitioned table is fine. Watch out for high cardinality: each unique label set is a new series, so never put user IDs in labels. Follow-up: "How long do you keep raw data?" Days raw, months or years downsampled.Show answer
Show answer
Show answer
files, file_versions, blocks(hash, size, refcount), version_blocks(version_id, seq, hash), sharing ACLs. Bytes in S3, one object per block keyed by its SHA-256 hash (Dropbox uses 4 MB blocks). Upload: the client chunks and hashes, asks which hashes are missing, uploads those with pre-signed URLs, then commits a new version (a list of hashes) in one DB transaction. Download: fetch the block list, get blocks via CDN or pre-signed GETs. Sync: each change bumps a per-user cursor; clients long-poll or use a notification service and pull changes since their cursor. Conflicts: keep both copies ("conflicted copy"). Old versions and blocks with refcount 0 go to cheaper tiers or garbage collection. Follow-up: "Search inside documents?" Extract text asynchronously and feed an index through CDC.Show answer
—Cheat sheet
| Fact | Value | Source |
|---|---|---|
S3 single PUT max | 5 GB | S3 Uploading objects |
| S3 multipart parts | 10,000 parts, 5 MiB–5 GiB each | S3 multipart limits |
| S3 max object | 48.8 TiB ("50 TB") | S3 multipart limits |
| S3 Standard durability | 99.999999999%, ≥3 AZs | S3 storage classes |
| S3 consistency | Strong read-after-write for PUT/DELETE | S3 user guide |
| Pre-signed URL max | 7 days (SigV4, IAM user) | S3 pre-signed URLs |
| Min days: IA / Glacier IR, Flexible / Deep Archive | 30 / 90 / 90 / 180 | S3 storage classes |
| Dropbox block | 4 MB, named by SHA-256 | Dropbox tech blog |
| ES refresh | 1 s, only if searched in last 30 s | Elastic near real-time |
| ES defaults | 1 primary shard, 1 replica | Elastic index settings |
| Shard size advice | 10–50 GB, under 200M docs | Elastic: size your shards |
| BM25 defaults | k1 1.2, b 0.75 | Elastic similarity |
| Bloom filter for 1% FP | about 9.6 bits per item | Bloom filter (Wikipedia) |
| Cassandra Bloom FP default | 0.01 (0.1 for LCS) | Cassandra docs |
| Prometheus | 1–2 bytes/sample, 2 h blocks, 15 d retention | Prometheus storage |
Reference
—Sources
Course