Zum Inhalt springen
DasPeTe Tutorials

Eine schnelle GraphQL-API mit Fastify und Yoga · Teil 7 von 8 der Serie

Teil 7: Ganze Antworten in Valkey cachen

Cache komplette Query-Ergebnisse mit dem Response-Cache von Yoga in Valkey, räum sie ab, wenn Mutations die Daten ändern, beantworte wiederholte GET-Requests mit 304 und halt die API am Laufen, wenn der Cache ausfällt.

Quest-Ziele

  • Query-Ergebnisse für alle Instanzen gleichzeitig cachen
  • Verstehen, wie der Cache Einträge über Entitäten abräumt
  • Listen abräumen, die eine Mutation nicht sehen kann
  • Wiederholte GET-Requests mit ETags beantworten
  • Aus einem Ausfall des Caches Cache-Misses statt Fehler machen

DataLoader hat jede Query billiger gemacht. Die schnellste Query ist aber die, die nie läuft. Das meiste, wonach das Gildenbrett gefragt wird, hat gerade eben schon jemand anderes gefragt, also behalt die Antwort und gib sie noch einmal aus.

Wie der Response-Cache arbeitet

Der Response-Cache von Yoga bildet aus der Operation und ihren Variablen einen Schlüssel und speichert darunter das fertige Ergebnis. Er merkt sich außerdem jede Entität im Ergebnis, jedes Objekt mit seinem Typ und seiner ID, etwa Quest:3. id und __typename ergänzt er selbst in den Auswahlen, das funktioniert also auch, wenn der Client nicht danach gefragt hat. Wenn eine Mutation Quest:3 zurückgibt, fliegt jedes gecachte Ergebnis raus, das Quest:3 enthält.

Der Cache lebt in Valkey, jede Instanz liest also, was irgendeine von ihnen gespeichert hat, und eine Mutation auf einer Instanz räumt ihn für alle ab.

~/projects/guild-api
$ pnpm add @graphql-yoga/plugin-response-cache @envelop/response-cache-redisPackages: +5dependencies:+ @envelop/response-cache-redis 5.0.0+ @graphql-yoga/plugin-response-cache 4.0.1Done in 519ms using pnpm v12.16.1

Leg den Cache in src/cache.ts an:

src/cache.ts
import { createRedisCache } from '@envelop/response-cache-redis';
import { Redis } from 'ioredis';

export const cache = createRedisCache({ redis: new Redis(process.env.REDIS_URL!) });

Und ergänz das Plugin in src/server.ts:

src/server.ts
import Fastify, { LogController } from 'fastify';
import { createYoga } from 'graphql-yoga';
import { useExtendContextValuePerExecuteSubscriptionEvent } from '@envelop/execute-subscription-event';
import { useResponseCache } from '@graphql-yoga/plugin-response-cache';
import { Logger } from '@graphql-hive/logger';
import { PinoLogWriter } from '@graphql-hive/logger/writers/pino';
import { cache } from './cache.ts';
import { createContext, type Context, type ServerContext } from './context.ts';
import { db } from './db/index.ts';
import { createLoaders } from './loaders.ts';
import { schema } from './schema.ts';

const app = Fastify({
  logger: true,
  logController: new LogController({ disableRequestLogging: true }),
});

const yoga = createYoga<ServerContext>({
  schema,
  context: createContext,
  plugins: [
    // A subscription keeps its context for every event: give each event fresh loaders.
    useExtendContextValuePerExecuteSubscriptionEvent<Context>(() => ({
      contextPartial: { loaders: createLoaders(db) },
    })),
    useResponseCache({
      session: () => null,
      cache,
      ttl: 60_000,
      includeExtensionMetadata: process.env.NODE_ENV !== 'production',
    }),
  ],
  logging: new Logger({ writers: [new PinoLogWriter(app.log)] }),
});

app.route({
  url: yoga.graphqlEndpoint,
  method: ['GET', 'POST', 'OPTIONS'],
  handler: (req, reply) => yoga.handleNodeRequestAndResponse(req, reply, { req, reply }),
});

await app.listen({ port: Number(process.env.PORT) });
  • session sagt, wem ein Ergebnis gehört. null heißt allen: Das Gildenbrett sieht für jeden Besucher gleich aus. Sobald Ergebnisse davon abhängen, wer fragt, gib hier die ID des Nutzers zurück, sonst bekommt jeder Besucher die Antwort des ersten.

  • ttl ist, wie lange ein Ergebnis höchstens bleibt, hier eine Minute.

  • includeExtensionMetadata schreibt in jede Antwort, ob sie aus dem Cache kam. Das hilft beim Entwickeln, und die Produktion kommt ohne aus.

Treffer und Fehlschläge

Frag zweimal nach denselben Quests:

~/projects/guild-api
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests(first: 3) { title status assignee { name } } }"}'{"data":{"quests":[{"title":"Slay the cellar rats","status":"DONE","assignee":{"name":"Brakka"}},{"title":"Escort the spice caravan","status":"ACCEPTED","assignee":{"name":"Ilse"}},{"title":"Find the lost forge hammer","status":"OPEN","assignee":null}]},"extensions":{"responseCache":{"hit":false,"didCache":true,"ttl":60000}}}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests(first: 3) { title status assignee { name } } }"}'{"data":{"quests":[{"title":"Slay the cellar rats","status":"DONE","assignee":{"name":"Brakka"}},{"title":"Escort the spice caravan","status":"ACCEPTED","assignee":{"name":"Ilse"}},{"title":"Find the lost forge hammer","status":"OPEN","assignee":null}]},"extensions":{"responseCache":{"hit":true}}}

Die erste Antwort wurde gespeichert, die zweite kam aus dem Cache. Das Log zeigt den Unterschied:

Terminal
Query: select "id", "title", "reward", "status", "guild_id", "assignee_id" from "quests" order by "quests"."id" limit $1 -- params: [3]Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."id" in ($1, $2) -- params: [1, 2][15:27:05.344] INFO (189): Request processed    requestId: "req-1"    method: "POST"    path: "/graphql"    operationType: "query"    status: 200    errorCount: 0    durationMs: 25[15:27:05.582] INFO (189): Request processed    requestId: "req-2"    method: "POST"    path: "/graphql"    status: 200    errorCount: 0    durationMs: 1

Zwei SQL-Abfragen und 25 Millisekunden für den ersten Request, kein SQL und eine Millisekunde für den zweiten. Der zweiten Zeile fehlt operationType, weil der Cache geantwortet hat, bevor irgendeine Operation lief.

Mutations räumen den Cache ab

Nimm die dritte Quest an und frag noch einmal:

~/projects/guild-api
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"mutation { acceptQuest(questId: 3, adventurerId: 3) { status } }"}'{"data":{"acceptQuest":{"status":"ACCEPTED"}},"extensions":{"responseCache":{"invalidatedEntities":[{"typename":"Quest","id":"3"}]}}}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests(first: 3) { title status assignee { name } } }"}'{"data":{"quests":[{"title":"Slay the cellar rats","status":"DONE","assignee":{"name":"Brakka"}},{"title":"Escort the spice caravan","status":"ACCEPTED","assignee":{"name":"Ilse"}},{"title":"Find the lost forge hammer","status":"ACCEPTED","assignee":{"name":"Corin"}}]},"extensions":{"responseCache":{"hit":false,"didCache":true,"ttl":60000}}}

Die Mutation hat Quest:3 zurückgegeben, also flog die gecachte Liste, die sie enthielt, raus, und der nächste Request hat den neuen Status aus der Datenbank gelesen.

Was eine Mutation nicht sehen kann

Jetzt die offenen Quests, eine neue Quest und noch einmal die offenen Quests:

~/projects/guild-api
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests(status: OPEN) { title } }"}'{"data":{"quests":[{"title":"Tame the volcano drake"},{"title":"Recover the pearl crown"},{"title":"Clear the harbour of krakenlings"},{"title":"Wake the old treant"}]},"extensions":{"responseCache":{"hit":false,"didCache":true,"ttl":60000}}}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"mutation { postQuest(input: { guildId: 1, title: \"Bake the ember bread\", reward: 10 }) { id } }"}'{"data":{"postQuest":{"id":"11"}},"extensions":{"responseCache":{"invalidatedEntities":[{"typename":"Quest","id":"11"}]}}}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests(status: OPEN) { title } }"}'{"data":{"quests":[{"title":"Tame the volcano drake"},{"title":"Recover the pearl crown"},{"title":"Clear the harbour of krakenlings"},{"title":"Wake the old treant"}]},"extensions":{"responseCache":{"hit":true}}}

Die neue Quest fehlt, und die Liste kommt aus dem Cache. Die Mutation hat Quest:11 zurückgegeben, aber die gecachte Liste wurde gespeichert, bevor es diese Quest gab, sie enthält sie also nicht, und nichts verbindet die beiden. Dasselbe passiert mit der Questliste eines Abenteurers, der eine Quest annimmt, außer der Client wählt den Abenteurer zufällig in der Mutation aus.

Die Resolver wissen, was sich geändert hat, also räumen sie es selbst ab. Importier den Cache in src/resolvers.ts:

src/resolvers.ts
import { cache } from './cache.ts';

Räum in postQuest jedes Ergebnis ab, in dem eine Quest steckt:

src/resolvers.ts
postQuest: async (_parent, { input }, { db, loaders, pubsub }) => {
      const title = input.title.trim();
      if (!title) throw badInput('A quest needs a title');
      if (input.reward < 1) throw badInput('The reward must be at least 1 gold');

      const guild = await loaders.guild.load(Number(input.guildId)).catch(() => null);
      if (!guild) throw badInput(`There is no guild with the id ${input.guildId}`);

      const [quest] = await db
        .insert(quests)
        .values({ title, reward: input.reward, guildId: guild.id })
        .returning();
      // A new quest belongs in cached lists that cannot know about it yet.
      await cache.invalidate([{ typename: 'Quest' }, { typename: 'Guild', id: guild.id }]);
      pubsub.publish('quest:posted', quest);
      return quest;
    },

Eine Entität ohne ID, { typename: 'Quest' }, steht für jede Quest. Guild mit seiner ID räumt auch Ergebnisse ab, die die Gilde ohne eine ihrer Quests zeigen. Räum in acceptQuest den Abenteurer ab:

src/resolvers.ts
acceptQuest: async (_parent, { questId, adventurerId }, { db, loaders, pubsub }) => {
      const adventurer = await loaders.adventurer.load(Number(adventurerId)).catch(() => null);
      if (!adventurer) throw badInput(`There is no adventurer with the id ${adventurerId}`);

      const [quest] = await db
        .update(quests)
        .set({ status: 'ACCEPTED', assigneeId: adventurer.id })
        .where(and(eq(quests.id, Number(questId)), eq(quests.status, 'OPEN')))
        .returning();
      if (!quest) throw badInput(`Quest ${questId} is not open`);

      // The adventurer's own quest list changed too.
      await cache.invalidate([{ typename: 'Adventurer', id: adventurer.id }]);

      pubsub.publish('quest:updated', quest.id, quest);
      return quest;
    },

Schreib noch eine Quest aus:

~/projects/guild-api
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"mutation { postQuest(input: { guildId: 1, title: \"Sweep the ash\", reward: 10 }) { id } }"}'{"data":{"postQuest":{"id":"12"}},"extensions":{"responseCache":{"invalidatedEntities":[{"typename":"Quest","id":"12"}]}}}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests(status: OPEN) { title } }"}'{"data":{"quests":[{"title":"Tame the volcano drake"},{"title":"Recover the pearl crown"},{"title":"Clear the harbour of krakenlings"},{"title":"Wake the old treant"},{"title":"Bake the ember bread"},{"title":"Sweep the ash"}]},"extensions":{"responseCache":{"hit":false,"didCache":true,"ttl":60000}}}

Beide neuen Quests sind auf dem Brett.

ETags

Der Cache hilft auch Browsern und CDNs. Für ein gecachtes Ergebnis schickt Yoga ein etag mit, einen Fingerabdruck der Antwort. Ein Client, der ihn in if-none-match zurückschickt, bekommt 304 Not Modified ohne Inhalt, solange die Antwort noch dieselbe ist:

~/projects/guild-api
$ curl -si 'localhost:4000/graphql?query=%7Bguilds%7Bname%7D%7D' | grep -iE '^(HTTP|etag|last-modified)'HTTP/1.1 200 OKetag: e90f0282165db81fff145699757eaedf0c13003ce5100e8a4132b0c0b0fc77a5last-modified: Tue, 03 Nov 2026 15:27:26 GMT$ curl -si 'localhost:4000/graphql?query=%7Bguilds%7Bname%7D%7D' -H 'if-none-match: e90f0282165db81fff145699757eaedf0c13003ce5100e8a4132b0c0b0fc77a5' | head -1HTTP/1.1 304 Not Modified

Das funktioniert nur bei GET-Requests, die Browser und CDNs cachen, und eine ganze Query in der Adresse wird lang. Der nächste Teil macht diese Adressen kurz.

Wenn der Cache ausfällt

Stopp Valkey und frag nach einer Gilde:

~/projects/guild-api
$ docker compose stop valkey Container guild-api-valkey-1 Stopping Container guild-api-valkey-1 Stopped$ curl -s -w '\n%{time_total}s\n' localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ guild(id: 2) { name } }"}'{"errors":[{"message":"Unexpected error.","extensions":{"code":"INTERNAL_SERVER_ERROR"}}]}7.452852s

Siebeneinhalb Sekunden, dann ein Fehler. ioredis stellt die Cache-Abfrage in die Warteschlange und versucht 20-mal, sich neu zu verbinden, bevor es aufgibt, und der Request wartet darauf. Ein Cache soll die API schneller machen. Fällt Valkey aus, reißt er die API mit.

Pack den Cache ein, damit ein ausgefallenes Valkey einen Cache-Miss bedeutet. Ersetz src/cache.ts:

src/cache.ts
import type { Cache } from '@graphql-yoga/plugin-response-cache';
import { createRedisCache } from '@envelop/response-cache-redis';
import { Redis } from 'ioredis';

// While Valkey is away, fail cache commands at once instead of queueing them.
const redis = new Redis(process.env.REDIS_URL!, { enableOfflineQueue: false });
redis.on('error', (error: NodeJS.ErrnoException) => {
  console.warn(`Valkey cache: ${error.code ?? error.message}`);
});

const valkeyCache = createRedisCache({ redis });

/** The Valkey cache, but an outage means cache misses instead of failed requests. */
export const cache: Cache = {
  async get(key) {
    try {
      return await valkeyCache.get(key);
    } catch {
      return undefined;
    }
  },
  async set(key, result, entities, ttl) {
    try {
      await valkeyCache.set(key, result, entities, ttl);
    } catch {
      // Not cached this time, the next request tries again.
    }
  },
  async invalidate(entities) {
    try {
      await valkeyCache.invalidate(entities);
    } catch {
      // What was cached before the outage expires with its TTL.
    }
  },
};

Mit enableOfflineQueue: false lehnt ioredis Befehle sofort ab, solange die Verbindung weg ist, statt sie in die Warteschlange zu stellen. Die Hülle fängt das ab: Ein fehlgeschlagenes get ist ein Miss, ein fehlgeschlagenes set wird übersprungen. Das ist das Gegenteil des PubSub aus dem letzten Teil, der auf Valkey wartet, denn ein verlorenes Event ist ein Fehler, und ein verlorener Cache-Eintrag ist nur eine langsamere Antwort. Stopp Valkey noch einmal:

~/projects/guild-api
$ docker compose stop valkey Container guild-api-valkey-1 Stopping Container guild-api-valkey-1 Stopped$ curl -s -w '\n%{time_total}s\n' localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ guild(id: 2) { name } }"}'{"data":{"guild":{"name":"Tidecallers"}},"extensions":{"responseCache":{"hit":false,"didCache":true,"ttl":60000}}}0.034335s

34 Millisekunden, direkt aus PostgreSQL. Wenn Valkey zurück ist, füllt sich der Cache von selbst wieder.

Lesen ist jetzt billig, gecacht und geteilt. Im letzten Teil schützt du die API vor den Queries, die ohnehin teuer wären: zu tief, zu breit oder einfach nicht von dir.

Kommentare

Die Kommentare sind GitHub Discussions: Zum Schreiben mit GitHub anmelden.

Die Kommentare kommen über giscus von GitHub. Beim Laden gehen deine IP-Adresse an giscus.app und GitHub.