AWS DynamoDB vs RDS in Serverless Umgebungen - eine DynamoDB Case Study

Von hohen RDS-Kosten zu nahezu null mit DynamoDB: Eine detaillierte Fallstudie am Beispiel einer serverlosen LLM-Chat-Anwendung.

Author Eugen Kochtyrew

Eugen Kochtyrew

Freelance Consultant, CISSP, CTO @ Shortcats

15 min read · 14.04.2025

Datenbanken bilden oft einen erheblichen Kostenblock beim Aufbau von Anwendungen – das war auch bei unserem SaaS-Startup Shortcats nicht anders. Unser zunächst gewähltes RDS PostgreSQL wurde in Kombination mit AWS Lambda und mehreren Umgebungen (Dev, Staging, Prod) schnell zu einem echten Kostentreiber.

Die Suche nach einer skalierbaren und kosteneffizienten Alternative führte uns zu Amazon DynamoDB. Aber bevor ich lange Reden schwinge, folgendes geschah:

DynamoDB vs RDS Cost Optimization

Shortcats AWS-Kosten nach der Migration zu DynamoDB (VOR der DynamoDB-Preisminderung vom November 2024)

Als Teil der Serie "Building a serverless LLM-based Chat-Service on AWS" widmet sich dieser Beitrag der Implementierung der Datenschicht mittels DynamoDB für unseren Chat-Service. Wir gehen dabei auf unsere Methodik ein, inklusive Single-Table Design, der Nutzung von ElectroDB als typsicherem Mapper und der Architektur unseres Data Access Layers.

Den vollständigen Code für diesen Teil findet ihr in diesem GitHub-Repository.


DynamoDB: Die Serverless-Datenbank von AWS

Was ist DynamoDB?

DynamoDB ist AWS "fully-managed" NoSQL-Datenbankdienst. "Fully-managed" heißt: AWS kümmert sich um Betrieb, Wartung und Skalierung – keine Serveradministration für dich, oder deine DevOps Mitarbeiter, die du nun (fast 😉) nicht mehr brauchst. Im Gegensatz zu relationalen Datenbanken (wie PostgreSQL) bietet es ein flexibles Schema.

Warum ideal für Serverless (besonders mit Lambda)?

DynamoDB glänzt in Serverless-Architekturen wegen:

  • Serverless by Design: Fügt sich nahtlos in die Philosophie und Architektur von serverlosen Anwendungen ein.
  • Nahtloser Skalierbarkeit: Passt sich automatisch an jede Last an, von null bis Milliarden Anfragen.
  • Echtem Pay-per-Use (On-Demand): Kosten fallen (fast) nur bei tatsächlicher Nutzung an (Lese-/Schreibvorgänge, Speicher). Perfekt für variable Lasten und Kostenkontrolle – im Leerlauf nahezu kostenlos.
  • Schnellem Setup & Performance: Tabellen sind in Minuten einsatzbereit, Zugriffe erfolgen mit Latenzen im einstelligen Millisekundenbereich.

Knackpunkt: DynamoDB vs. Relationale Datenbanken (RDS/Aurora) mit Lambda

Gerade im Lambda-Kontext löst DynamoDB typische Probleme relationaler Datenbanken:

  • Verbindungsmanagement: Lambdas können leicht das Verbindungslimit relationaler Datenbanken sprengen. Die Lösung (RDS Proxy) kostet extra und erhöht die Komplexität. Bei DynamoDB entsteht dieses Problem erst gar nicht.
  • Cold-Start-Latenz: Der Verbindungsaufbau von "kalten" Lambdas zu RDS kann spürbar langsam sein (meist mehrere Sekunden). DynamoDB-Anfragen sind konstant schnell.
  • Grundkosten & Umgebungen: RDS hat fixe monatliche Kosten pro Instanz, auch bei Nichtnutzung. Das multipliziert sich bei Dev/Staging/Prod-Umgebungen. DynamoDB On-Demand verursacht nur bei Nutzung nennenswerte Kosten, ideal für wenig genutzte Testumgebungen.

DynamoDB: Die wichtigsten Vor- und Nachteile

Die Wahl von DynamoDB bringt spezifische Vorteile, besonders im Serverless-Kontext, aber auch Herausforderungen mit sich. Die folgende Tabelle stellt die wichtigsten Punkte gegenüber:

Vorteile (Stärken)

✅ Enorme Skalierbarkeit & Performance

✅ Fully Managed

✅ Ideal für Serverless (Pay-per-Use)

✅ Schnelles Setup & Entwicklung

✅ Tiefe AWS-Ökosystem-Integration

✅ Flexibles NoSQL-Datenmodell

✅ Optionale Globale Tabellen

Nachteile (Herausforderungen)

❌ Steile Lernkurve: Anderes Denkmodell (Single-Table, Access Patterns)

❌ Eingeschränkte Abfrageflexibilität: Kein SQL, keine JOINs, Indizes nötig

❌ Max. Item-Größe: 400 KB Limit pro Eintrag

❌ Eventual Consistency als Standard (Strong Consistency kostet mehr)

❌ Preisdetails können komplex werden (WCU/RCU-Kalkulation)

☝️

Wichtige Einordnung der "Nachteile":

Viele der genannten "Nachteile", insbesondere die eingeschränkten Abfragemöglichkeiten und das Fehlen von JOINs, sind bewusste Designentscheidungen. Sie sind der Schlüssel zur extremen Skalierbarkeit und Performance vonDynamoDB.

Der Ansatz erfordert eine vorausschauende Datenmodellierung (z.B. Denormalisierung, Single-Table Design), um Abfragen so zu optimieren, dass Daten mit wenigen, schnellen Zugriffen geholt werden können. Man tauscht bewusst die von SQL gewohnte Abfrageflexibilität zur Laufzeit gegen eine massive Steigerung von Performance und Skalierbarkeit – ein oft gewünschter Kompromiss für moderne, hochskalierende Anwendungen


Kosten im Detail: DynamoDB vs. RDS/Aurora

Ein entscheidender Faktor bei der Datenbankwahl sind die Kosten, besonders in serverlosen Architekturen mit AWS Lambda. Wie schneidet DynamoDB verglichen mit RDS (z.B. PostgreSQL) und Aurora Serverless v2 ab? Hier betrachten wir die Kosten im Detail, insbesondere unter Berücksichtigung der Notwendigkeit eines RDS Proxy für relationale Datenbanken im Lambda-Einsatz.

Grundlagen der Preismodelle (Kurzfassung):

  • DynamoDB (On-Demand): Kosten pro Lese-/Schreibanfrage + Speicher. Fast keine Kosten bei Nichtnutzung. Inkl. Free Tier.
  • RDS PostgreSQL: Kosten pro Instanzstunde + Speicher + ggf. I/O. Benötigt RDS Proxy für Lambda.
  • Aurora Serverless v2: Kosten pro ACU-Stunde (min. 0,5 ACUs) + Speicher + I/O. Benötigt oft RDS Proxy für Lambda.

Der RDS Proxy: Dieser ist bei Lambda-Nutzung mit RDS/Aurora oft essenziell, um Verbindungslimits zu managen, verursacht aber zusätzliche, nicht unerhebliche Kosten (basierend auf vCPUs/ACUs), die auch bei geringer Last anfallen.

Szenarien im Vergleich (Schätzungen, Stand 2025):

Wir betrachten die Production-Umgebung. Bei RDS/Aurora kommen Kosten für Dev/Staging hinzu, bei DynamoDB On-Demand sind diese minimal. Annahmen: 4KB Item-Größe, 50/50 Lese-/Schreibverteilung. Backup-Kosten nicht inkludiert.

Szenario 1: Startup / Hobby Projekt

  • 5 GB Datenspeicher, 300k Anfragen pro Monat
  • DynamoDB On-Demand, 0.5 ACU Aurora Serverless v2, RDS t3.micro

Vergleich der geschätzten monatlichen Kosten für Szenario 1

KostenartDynamoDBAuroraRDS
Datenspeicher$0,00 (FT)$0,50$0,58
Instanz-/Kapazitätskostenn.a.$43,80 (min. 0.5 ACU)$12,41
Durchsatzkosten$0,79$0,00$0,00
RDS Proxy Kostenn.a.$87,60 (min. 8 ACUs)$21,90 (min. 2 vCPUs)
Gesamtkosten (Monat)~$0,79~$131,90~$34,89

Erkenntnis 1: DynamoDB ist dank Free Tier praktisch kostenlos. Die Fixkosten für Instanz/ACU und insbesondere der RDS Proxy machen RDS/Aurora deutlich teurer bei geringer Last.

Szenario 2: Startup im Wachstum (Scaleup)

  • 500 GB Datenspeicher, ~43 Mio Anfragen pro Monat
  • DynamoDB On-Demand, Skalierte Aurora Serverless v2, RDS t4g.medium

Vergleich der geschätzten monatlichen Kosten für Szenario 2

KostenartDynamoDBAuroraRDS
Datenspeicher$118,75$50,00$57,50
Instanz-/Kapazitätskostenn.a.$350,40 (skaliert)$49,64
Durchsatzkosten$113,40$0,00$0,00
RDS Proxy Kostenn.a.$87,60 (min. 8 ACUs)$21,90 (min. 2 vCPUs)
Gesamtkosten (Monat)~$232,15~$488,00~$129,04

Erkenntnis 2: Hier wird RDS PostgreSQL zur günstigsten Option. Die Instanz- und Proxy-Kosten sind geringer als die nun signifikanten Durchsatzkosten von DynamoDB On-Demand.

Szenario 3: Etabliertes Scaleup

  • 5 TB Datenspeicher, ~432 Mio Anfragen pro Monat
  • DynamoDB On-Demand, Skalierte Aurora Serverless v2, RDS r5.2xlarge

Vergleich der geschätzten monatlichen Kosten für Szenario 3

KostenartDynamoDBAuroraRDS
Datenspeicher$1.243,75$500,00$575,00
Instanz-/Kapazitätskostenn.a.$1.401,60 (skaliert)$730,00
Durchsatzkosten$1.134,00$0,00$0,00
RDS Proxy Kostenn.a.$87,60 (min. 8 ACUs)$87,60 (passend)
Gesamtkosten (Monat)~$2.377,75~$1.989,20~$1.392,60

Erkenntnis 3: RDS PostgreSQL bleibt im Pay-as-you-go Vergleich bei hoher Last am günstigsten. Die reinen Durchsatzkosten machen DynamoDB On-Demand zur teuersten Alternative ohne Optimierung.

Optimierungspotenzial bei hoher Last (Szenario 3 optimiert)

Bei konstant hohen Workloads lässt sich durch Bindung (Reserved Instances/Capacity) und ggf. Caching (DAX) erheblich sparen:

*Beispiele für 1-Jahres-Bindung, tatsächliche Ersparnis variiert.

KostenkomponenteDynamoDB (Optimiert*)RDS PostgreSQL (Optimiert*)
Datenspeicher~$868,75 (ggf. weniger)~$575,00
Kapazität/Instanz~$9,54 (Reserved Capacity)~$292,00 (Reserved Inst.)
Durchsatz (Rest)/I/OIncluded in RC / geringe $Geringe $
Caching/Proxy~$99,28 (DAX, optional)~$35,04 (RDS Proxy RI)
Gesamtkosten (Monat)~$977,57 (mit DAX)~$902,04

Erkenntnis Optimierung: Mit Optimierung (v.a. Reservierung) sinken die Kosten massiv. RDS bleibt oft knapp günstiger, aber DynamoDB rückt sehr nah heran und eliminiert den Großteil des operativen Aufwands für DB-Management (Patches, Skalierung etc.), was Personalkosten spart.

Fazit zum Preis:

  1. Niedrige/Variable Last: DynamoDB On-Demand ist dank Free Tier und Pay-per-Use fast unschlagbar günstig, da keine Fixkosten für Instanz/Proxy anfallen.
  2. Hohe/Konstante Last: Optimiertes RDS ist oft die rechnerisch günstigste Option, aber optimiertes DynamoDB ist sehr wettbewerbsfähig und reduziert den Management-Overhead erheblich.
  3. Multi-Environment: DynamoDB On-Demand ist klar im Vorteil, da Dev/Staging-Umgebungen kaum Kosten verursachen.

Die reinen Kosten sind nur ein Teil des Bildes. Technische Passung, Skalierbarkeit, Entwicklungsgeschwindigkeit und Managementaufwand müssen ebenfalls in die Entscheidung einfließen.


DynamoDB in der Praxis

Abschied von JOINs, willkommen Single-Table Design

Wer von relationalen Datenbanken kommt, ist versucht, für jede Entität (z.B. Chats, Nachrichten) eine eigene Tabelle anzulegen. Für DynamoDB ist jedoch oft das Single-Table Design (STD) der Schlüssel zu Performance und Kosteneffizienz:  Alle Entitätstypen landen in einer einzigen Tabelle.

Warum Single-Table? Weil DynamoDB keine JOINs hat (und das ist gut so!)

  • Performance: DynamoDB wurde für extrem schnelle Key-Value-Zugriffe optimiert. JOINs, wie man sie aus SQL kennt, gibt es bewusst nicht, da sie zur Laufzeit teuer sind und die Skalierbarkeit einschränken.
  • Access Pattern-Fokus: Beim STD modelliert man nicht primär die Entitäten, sondern die Zugriffsmuster (Access Patterns) : Wie  wird die Anwendung Daten lesen und schreiben? Man gestaltet Primärschlüssel (PK, SK) und sekundäre Indizes (GSIs) so, dass zusammengehörige Daten, die oft gemeinsam benötigt werden (z.B.ein Chat und seine Nachrichten), mit einer einzigen, effizienten Query abgerufen werden können. Man "pre-joint" die Daten quasi beim Speichern.
  • Kosten: Weniger Queries bedeuten oft geringere Kosten.

Der Trade-Off:

Dieser Ansatz erfordert mehr Planungsaufwand im Voraus und ein Umdenken. Man muss seine Access Patterns genau kennen, da spätere Änderungen aufwendiger sein können als bei relationalen Modellen. Im Gegenzug erhält man die Performance und Skalierbarkeit, für die DynamoDB bekannt ist.

Im nächsten Schritt sehen wir uns an, wie wir dieses STD-Konzept für unseren Chat-Service mit TypeScript und ElectroDB konkret umsetzen können.

Praktische Umsetzung: Single-Table Design mit TypeScript & ElectroDB

Theorie ist gut, Praxis besser. Für die Umsetzung unseres Single-Table Designs in TypeScript nutzen wir ElectroDB. Diese Bibliothek vereinfacht die Arbeit mit DynamoDB erheblich:

  • Abstraktion: Generiert komplexe PK/SK/GSI-Strukturen automatisch aus deinem Modell.
  • Typsicherheit: Starke Typisierung für Entitäten und Abfragen verhindert Fehler.
  • Schema & Validierung: Definiert Struktur, Regeln und Standardwerte für deine Daten.
  • Einfache Abfragen: Bietet eine klare API für CRUD-Operationen.
  • Collections: Gruppiert verwandte Entitäten (wie Chat & Nachrichten) für gemeinsame Abfragen.

Im Folgenden beleuchten wir die wesentlichen Teile der Implementierung; den vollständigen Code, inklusive E2E-Tests und des kompletten Data Access Layers (DAL), findet ihr in folgendem GitHub-Repository.

Das Datenmodell für Chat & Message (mit ElectroDB)

Wir definieren zwei Entitäten (Chat,ChatMessage), die beide in derselben DynamoDB-Tabelle (ChatsTable) gespeichert werden. Der Cloud liegt in der Definition der Indizes:


// ChatModel.ts
// DDB Client Setup (Conceptual)
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
const client = new DynamoDBClient({}); // Configure as needed

// ElectroDB Setup
import { Entity, Service, type EntityItem } from "electrodb";

export const TABLE_NAME = "ChatsTable";

// -------- Chat Entity Definition --------
const ChatEntity = new Entity(
  {
    model: {
      entity: "chat",
      version: "1",
      service: "chatservice",
    },
    attributes: {
      chatId: {
        type: "string",
        required: true,
        default: () => crypto.randomUUID(),
      },
      userId: { type: "string", required: true },
      name: {
        type: "string",
        required: true,
        validate: (v) => v.length > 0 && v.length <= 100,
      },
      createdAt: {
        type: "string",
        readOnly: true,
        required: true,
        default: () => new Date().toISOString(),
      },
      updatedAt: {
        type: "string",
        required: true,
        default: () => new Date().toISOString(),
        // update the updatedAt with every write
        set: () => new Date().toISOString(),
      },
    },
    indexes: {
      // Primary Key: Access Chat by chatId
      primary: {
        pk: { field: "pk", composite: ["chatId"], template: "CHAT#${chatId}" },
        sk: { field: "sk", composite: [], template: "METADATA" },
        collection: "chatAndMessages", 
      },
      // GSI1: Access Chats by userId, sorted by updatedAt
      byUser: {
        index: "gsi1",
        pk: {
          field: "gsi1pk",
          composite: ["userId"],
          template: "USER#${userId}",
        },
        sk: {
          field: "gsi1sk",
          composite: ["updatedAt"],
          template: "CHAT#${updatedAt}",
        },
      },
    },
  },
  { table: TABLE_NAME, client }
);

// -------- ChatMessage Entity Definition --------
const ChatMessageEntity = new Entity(
  {
    model: {
      entity: "chatmessage",
      version: "1",
      service: "chatservice",
    },
    attributes: {
      chatId: { type: "string", required: true }, // Used for PK
      messageId: {
        type: "string",
        required: true,
        default: () => crypto.randomUUID(),
      }, // Used for SK
      userId: { type: "string", required: true }, // User who wrote the message
      createdAt: {
        type: "string",
        readOnly: true,
        required: true,
        default: () => new Date().toISOString(),
      }, // Used for SK
      updatedAt: {
        type: "string",
        required: true,
        default: () => new Date().toISOString(),
        set: () => new Date().toISOString(),
      },
      content: { type: "string", required: true },
      reasoningContent: { type: "string", required: false }, 
      role: { type: ["user", "assistant"] as const, required: true },
    },
    indexes: {
      // Primary Key: Access Messages by chatId, sorted by createdAt
      primary: {
        pk: { field: "pk", composite: ["chatId"], template: "CHAT#${chatId}" },
        sk: {
          field: "sk",
          composite: ["createdAt", "messageId"],
          template: "MSG#${createdAt}#${messageId}",
        },
        collection: "chatAndMessages",
      },
    },
  },
  { table: TABLE_NAME, client }
);

// -------- Service Definition  --------
const ChatService = new Service(
  {
    // Expose entities through the service
    chat: ChatEntity,
    chatMessage: ChatMessageEntity,
  },
  { client, table: TABLE_NAME }
);

// -------- Types for usage elsewhere --------
export type ChatEntityType = typeof ChatService.entities.chat;
export type ChatItem = EntityItem<ChatEntityType>;
export type MessageEntityType = typeof ChatService.entities.chatMessage;
export type MessageItem = EntityItem<MessageEntityType>;

export default ChatService;

Schlüsselkonzepte:

  • PK/SK Templates: Definieren, wie Primärschlüsselaus Attributen gebaut werden. Wichtig: Chat und ChatMessage teilen sich den pk (CHAT#{"<chatId>"}), was sie gruppiert. Sie unterscheiden sich im sk (METADATA vs. MSG#...).
  • GSI (byUser-Index): Ermöglicht einen alternativen Zugriffspfad (alle Chats eines Users).
  • collection chatAndMessages: Erlaubt das Abrufen eines Chats und seiner Nachrichten mit einer einzigen Query, die auf den pk (CHAT#{"<chatId>"}) abzielt.

Beispieloperationen (DAL Nutzung)

Mit dem ChatService können wir nun typsicher auf unsere Daten zugreifen:


// services/chats.ts
import { EntityItem } from "electrodb";
import ChatService, {
  type ChatItem,
  type MessageItem,
} from "./models/ChatModel";

export class Chats {
  private readonly chatEntity = ChatService.entities.chat;
  private readonly messageEntity = ChatService.entities.chatMessage;

  public async createChat(userId: string, name: string): Promise<ChatItem> {
    try {
      if (!userId)
        throw new Error("userId is required");

      const chat = await this.chatEntity
        .create({
          userId,
          name,
        })
        .go();

      return chat.data;
    } catch (error) {
      // ...
    }
  }

  public async createMessage(
    chatId: string,
    userId: string,
    content: string,
    role: "user" | "assistant",
    reasoningContent?: string
  ): Promise<MessageItem> {
    try {
      if (!userId)
        throw new Error("userId is required");

      // First check if the chat exists and belongs to the user
      const chat = await this.getChatForUser(chatId, userId);
      if (!chat)
        throw new Error(`Chat with ID ${chatId} not found or access denied`);

      // Create the message
      const { data: message } = await this.messageEntity
        .create({
          chatId,
          userId,
          content,
          role,
          reasoningContent,
        })
        .go();

      // Update the chat's updatedAt timestamp
      await this.chatEntity.update({ chatId }).set({}).go();

      return message;
    } catch (error) {
      // ...
    }
  }
  
  // ... implement more
}

Das Ergebnis in DynamoDB:

ElectroDB erstellt Items, bei denen z.B. ein Chat-Item den PK CHAT#123 und SK METADATA hat, während eine zugehörige Nachricht den PK CHAT#123 und SK MSG#2024-01-01T10:00:00Z#abc hat. Das GSI-Chat-Item hätte z.B. gsi1pk: USER#abc und gsi1sk: CHAT#2024-01-01T10:00:00Z. Alle Attribute (userId, content, etc.) sind ebenfalls im Item gespeichert.

pksk__edb_e__edb_vchatIdcontentcreatedAtgsi1pkgsi1skmessageIdnameroleupdatedAtuserId
CHAT#07bcce62-...METADATAchat107bcce62-...

null

2025-04-10T15:49:09ZUSER#c364d8e2-...CHAT#2025-04-10T15:49:09Z

null

New Chat

null

2025-04-10T15:49:09Zc364d8e2-...
CHAT#07bcce62-...MSG#2025-04-10T15:49:09Z#e314636c-...chatmessage107bcce62-..."Hey Mister"2025-04-10T15:49:09Z

null

null

e314636c-...

null

user2025-04-10T15:49:09Zc364d8e2-...
CHAT#07bcce62-...MSG#2025-04-10T15:50:15Z#a9f8e7d6-...chatmessage107bcce62-..."What is uuup?"2025-04-10T15:50:15Z

null

null

a9f8e7d6-...

null

assistant2025-04-10T15:50:15Zc364d8e2-...

Vollständige Chats Tabelle mit 1 Chat und 2 Chat Nachrichten.

Mit diesem Setup nutzen wir die Stärken des Single-Table Designs, ohne die Komplexität der Schlüsselgenerierung manuell verwalten zu müssen.


Fazit: DynamoDB – Mehr als nur eine Datenbank

Amazon DynamoDB ist eine hochskalierbare, managed NoSQL-Datenbank, die besonders in serverlosen AWS-Architekturen überzeugt. Die Vorteile sind klar: schnelles Setup, automatische Skalierung, Performance mit niedriger Latenz und, im On-Demand-Modus, extrem geringe Startkosten ohne die Notwendigkeit eines teuren RDS Proxys für Lambda. Das macht sie ideal für Hobbyprojekte, Startups und moderne Anwendungen.

Der Preis dafür ist ein Paradigmenwechsel: Die Lernkurve ist steiler als bei relationalen Datenbanken. Man muss sich vom Konzept der JOINs verabschieden und stattdessen  Access Pattern-getrieben modellieren, oft mittels  Single-Table Design (STD) . Dies erfordert Planung, wird aber durch die resultierende Performance und Skalierbarkeit belohnt. Werkzeuge wie  ElectroDB helfen dabei, die Komplexität des STD zu meistern und Typsicherheit zu gewährleisten.

DynamoDB ist kein Allheilmittel, aber eine exzellente Wahl, wenn Skalierbarkeit, geringe Betriebskosten und nahtlose Serverless-Integration im Vordergrund stehen. Wer bereit ist, die Designphilosophie zu akzeptieren und die Datenmodellierung darauf auszurichten, findet in DynamoDB eine mächtige und oft kosteneffiziente Lösung für viele moderne Anwendungsfälle auf AWS.