How I Split Responsibilities Between Postgres and Redis

Records and audit trails stay durable, while rebuildable responses live in cache

When remaining parking spaces appear on screen, users move based on that number. A phrase like “20 spaces left” is not merely data. It becomes a signal that changes whether someone goes to the Han River.

That is why the split between Postgres and Redis in Hangangjari was not only about speed. I first had to decide where to keep the evidence behind numbers users see, and how far rebuildable query results should be treated as cache.

In personal projects, it is easy to swing between “why not read directly from the DB?” and “wouldn’t everything be faster in Redis?” For Hangangjari, both extremes were risky. If every request hits only Postgres, frequently read first screens and forecast responses become heavier. If too much meaning is placed in Redis, cache failure starts looking like data loss.

What Postgres and Redis own

The difference becomes visible by looking at how both stores are used in the request path. Postgres keeps records that may need explanation later, while Redis serves rebuildable query results quickly.

flowchart LR
  Workers["Collection · forecast · notification workers"] --> PG[("PostgreSQL / PostGIS<br/>Reference data · history · location · audit records")]
  Workers --> Redis[("Redis<br/>Current state · response cache · supporting indexes")]
  API["FastAPI read path"] --> Redis
  Redis -->|Hit| API
  Redis -->|Miss · malformed · stale| PG
  PG --> API

Postgres owns values that must be explained later: when a source value came in, which forecast run produced which result, and why a notification was sent or suppressed.

Redis is used to shorten user request time. It holds values that can be rebuilt, such as latest parking status, forecast responses, home-summary hot/stale responses, source status summaries, and indexes for push candidate lookup.

With this split, incident language becomes more precise. If Redis is empty, screen-ready responses can be rebuilt. If Postgres reference data or history is damaged, recovery is entirely different. Treating the two stores with the same weight slows response.

Data groups seen by app feature

Rather than expanding the entire DB schema, it is more useful to start with the data groups each app feature depends on. Parking, outing sources, forecasts, push, transit, and translation share one DB but move at different speeds and fail in different ways.

erDiagram
  PARK ||--o{ PARKING_LOT : owns
  PARKING_LOT ||--o{ PARKING_STATUS : records
  PARKING_STATUS ||--o{ FORECAST_INPUT : becomes
  FORECAST_RUN ||--o{ FORECAST_RESULT : creates
  DATA_SOURCE ||--o{ INGESTION_RUN : reports
  DATA_SOURCE ||--o{ OUTING_SIGNAL : publishes
  PUSH_SUBSCRIPTION ||--o{ DELIVERY_DECISION : evaluates
  DELIVERY_DECISION ||--o{ DELIVERY_ATTEMPT : audits
  TRANSIT_DATASET ||--o{ TRANSIT_ROUTE : contains
  TRANSLATION_SOURCE ||--o{ TRANSLATION_CACHE : renders

This ERD is not a copy of the whole schema. It is closer to an operator’s first map. Even within the same DB, knowing which feature owns which reference data and history makes incident response more accurate.

The real schema has more helper tables and indexes. This diagram keeps only how user-facing features and operator-facing records connect.

Notification records stay in Postgres

The difference is clearest in push. What needs to be retained is not only “sent.” The system needs to know why it sent, why it did not send, whether delivery was attempted, and how the external sender responded.

If this is kept only as one log string, it becomes hard to tune how quietly notifications should behave later. A notification suppressed because of quiet hours and a notification that failed to deliver both look like “no notification arrived” to the user. But the operator’s next action is different.

So the decision to send and the delivery audit record live in Postgres. Redis can help find candidates quickly, but it is not the final reason. That is what lets “not sent” remain an explainable product choice.

Values carry the time they were checked

In a public-data app, storing only values is not enough.

Even if the value says “20 spaces available at Yeouido,” the next questions remain.

  • When was this value observed?
  • When did the server fetch it?
  • Which source produced it?
  • Is the source healthy now?
  • Is this a current value or a forecast?

That is why status, forecast, outing, and home summary records carry time and refresh state together. The app uses that information to speak differently about “fresh,” “delayed,” and “unavailable.”

This distinction changes screen copy. If stale values look current, the cache may have succeeded but the app has misled the user. If the app clearly says a value is old, users can treat it as a reference signal.

Cache is deleted with rebuild in mind

Redis cache is fast, but it is not the source of truth. I set these rules.

  • Redis values must be rebuildable.
  • A Redis miss should be a normal path, not an incident by itself.
  • Malformed cache entries are ignored and fall back to Postgres records.
  • When a new forecast run is created, related forecast and home caches are cleared.
  • home-summary separates hot cache and stale backup cache so incidents can show the last trusted value.
  • Push candidate lookup can be Redis-first, but final delivery and audit follow Postgres.

Operations made the reason for these choices clearer. Redis improves latency. But if the app cannot say whether the information is stale, which source it came from, or whether the value can be rebuilt, a fast response alone is hard to trust.

The judgment left on screen

After splitting Postgres and Redis, the UI changed in a simple way. The app no longer receives only values. It receives whether each value is fresh, a last successful response, or unavailable.

That prevents “no information” and “empty” from becoming the same screen. Timestamps are not just helper copy; they are part of explaining how far a value can be trusted. This distinction matters even more in widgets, where stale values may be shown.

In the end, the reason for splitting Postgres and Redis was not infrastructure preference. It was a question of how far users should trust the numbers they see. Keeping evidence in Postgres and making cache rebuildable gave the whole system a simple line to follow.

Share

Share

Image preview