Von hohen RDS-Kosten zu nahezu null mit DynamoDB: Eine detaillierte Fallstudie am Beispiel einer serverlosen LLM-Chat-Anwendung.
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:
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 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.
DynamoDB glänzt in Serverless-Architekturen wegen:
Gerade im Lambda-Kontext löst DynamoDB typische Probleme relationaler Datenbanken:
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:
✅ 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
❌ 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)
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
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.
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.
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.
Kostenart | DynamoDB | Aurora | RDS |
---|---|---|---|
Datenspeicher | $0,00 (FT) | $0,50 | $0,58 |
Instanz-/Kapazitätskosten | n.a. | $43,80 (min. 0.5 ACU) | $12,41 |
Durchsatzkosten | $0,79 | $0,00 | $0,00 |
RDS Proxy Kosten | n.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.
Kostenart | DynamoDB | Aurora | RDS |
---|---|---|---|
Datenspeicher | $118,75 | $50,00 | $57,50 |
Instanz-/Kapazitätskosten | n.a. | $350,40 (skaliert) | $49,64 |
Durchsatzkosten | $113,40 | $0,00 | $0,00 |
RDS Proxy Kosten | n.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.
Kostenart | DynamoDB | Aurora | RDS |
---|---|---|---|
Datenspeicher | $1.243,75 | $500,00 | $575,00 |
Instanz-/Kapazitätskosten | n.a. | $1.401,60 (skaliert) | $730,00 |
Durchsatzkosten | $1.134,00 | $0,00 | $0,00 |
RDS Proxy Kosten | n.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.
Bei konstant hohen Workloads lässt sich durch Bindung (Reserved Instances/Capacity) und ggf. Caching (DAX) erheblich sparen:
Kostenkomponente | DynamoDB (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/O | Included 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.
Die reinen Kosten sind nur ein Teil des Bildes. Technische Passung, Skalierbarkeit, Entwicklungsgeschwindigkeit und Managementaufwand müssen ebenfalls in die Entscheidung einfließen.
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.
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.
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:
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.
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;
Chat
und ChatMessage
teilen sich den pk
(CHAT#{"<chatId>"}
), was sie gruppiert.
Sie unterscheiden sich im sk
(METADATA
vs. MSG#...
).byUser-Index
): Ermöglicht einen alternativen Zugriffspfad (alle Chats eines Users).chatAndMessages
: Erlaubt das Abrufen eines Chats und seiner Nachrichten mit einer
einzigen Query, die auf den pk
(CHAT#{"<chatId>"}
) abzielt.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
}
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.
pk | sk | __edb_e | __edb_v | chatId | content | createdAt | gsi1pk | gsi1sk | messageId | name | role | updatedAt | userId |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
CHAT#07bcce62-... | METADATA | chat | 1 | 07bcce62-... | null | 2025-04-10T15:49:09Z | USER#c364d8e2-... | CHAT#2025-04-10T15:49:09Z | null | New Chat | null | 2025-04-10T15:49:09Z | c364d8e2-... |
CHAT#07bcce62-... | MSG#2025-04-10T15:49:09Z#e314636c-... | chatmessage | 1 | 07bcce62-... | "Hey Mister" | 2025-04-10T15:49:09Z | null | null | e314636c-... | null | user | 2025-04-10T15:49:09Z | c364d8e2-... |
CHAT#07bcce62-... | MSG#2025-04-10T15:50:15Z#a9f8e7d6-... | chatmessage | 1 | 07bcce62-... | "What is uuup?" | 2025-04-10T15:50:15Z | null | null | a9f8e7d6-... | null | assistant | 2025-04-10T15:50:15Z | c364d8e2-... |
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.
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.