Cloudflare D1 a fondo: SQLite serverless con migraciones, réplicas de lectura y código probado

Qué es D1 y cómo funciona por dentro, el precio por filas leídas y escritas, migraciones con Wrangler, consultas preparadas, batch como transacción, índices con EXPLAIN, réplicas con la API de sesiones, Time Travel, tests y límites, con código probado.

Portada: Cloudflare D1 a fondo: SQLite serverless con migraciones, réplicas de lectura y código probado

Casi todas las aplicaciones acaban necesitando una base de datos relacional. En el mundo serverless eso siempre ha sido incómodo: o pagas una base de datos gestionada que está encendida aunque nadie la use, o te peleas con pools de conexiones desde funciones que se crean y destruyen a cada momento.

D1 es la base de datos SQL de Cloudflare: SQLite gestionado, accesible desde un Worker con un binding, sin conexiones que abrir, sin servidor que dimensionar y sin pagar por tenerla parada. Se cobra por filas leídas y escritas y por el almacenamiento, y la capa gratuita es generosa: 5 millones de filas leídas al día.

En este artículo vamos a construir una API de artículos con etiquetas sobre D1, con migraciones, índices comprobados con EXPLAIN, transacciones con batch(), la API de sesiones para réplicas de lectura y tests que aplican las migraciones solos, todo ejecutado en local.

🧪
Todo el código de este artículo se ha ejecutado de verdad el 2 de octubre de 2026 con Wrangler 4.147.0, workerd 1.20261001.1 (D1 en local con Miniflare), @cloudflare/vitest-plugin 1.3.6, Vitest 4.1.11 y Node.js 24.21.0. Lo que necesita cuenta (crear la base de datos remota, las réplicas de lectura y Time Travel) está marcado. Precios y límites comprobados en la documentación oficial ese mismo día.

Qué es D1 y cómo funciona por dentro

Una base de datos D1 es una base de datos SQLite que vive en un servidor de Cloudflare, en una ubicación concreta (la más cercana a quien la crea, o la que pidas con --location). Tu Worker se conecta a ella a través de un binding y le envía consultas SQL; D1 las ejecuta y devuelve las filas.

Dos consecuencias directas de que sea SQLite:

  • Es SQL de verdad: JOIN, índices, RETURNING, claves foráneas, JSON, búsqueda de texto completo con FTS5... Lo que sabes de SQLite vale aquí.
  • Cada base de datos ejecuta las consultas de una en una. Un solo hilo. Con consultas de 1 ms puede atender unas mil por segundo; con consultas de 100 ms, diez. El rendimiento depende mucho más de lo eficientes que sean tus consultas que de cualquier configuración.

Y una consecuencia de su diseño: una base de datos tiene un máximo de 10 GB (500 MB en el plan gratuito) y ese límite no se puede ampliar. D1 está pensado para escalar horizontalmente: muchas bases de datos pequeñas (una por cliente, por usuario o por tenant) en lugar de una gigante. En el plan de pago puedes tener 50.000.

Esquema de D1: los Workers de todo el mundo envían consultas a la base de datos primaria en una región; las réplicas de lectura opcionales en otras regiones atienden lecturas a través de la API de sesiones
La primaria recibe todas las escrituras. Con las réplicas activadas, las lecturas pueden ir a la réplica más cercana. Diagrama: devutilites.

Réplicas de lectura

Por defecto, todas las consultas viajan hasta la región de la base de datos. Si tus usuarios están repartidos por el mundo, puedes activar la replicación de lectura: D1 mantiene copias de solo lectura en otras regiones y envía las lecturas a la más cercana. Como una réplica puede ir ligeramente por detrás de la primaria, para usarla hay que pasar por la API de sesiones, que garantiza que dentro de una sesión siempre lees al menos lo que ya has escrito (consistencia secuencial). Lo veremos con código más abajo.

Lo que cuesta

D1 cobra por filas leídas, filas escritas y almacenamiento. No se cobra por consulta ni por tiempo de ejecución, y no hay coste de transferencia.

Workers FreeWorkers Paid
Filas leídas5 millones al día25.000 millones al mes incluidas, luego 0,001 $ por millón
Filas escritas100.000 al día50 millones al mes incluidas, luego 1,00 $ por millón
Almacenamiento5 GB en total5 GB incluidos, luego 0,75 $ por GB-mes

Los límites diarios del plan gratuito se reinician a las 00:00 UTC (las 02:00 en la España peninsular en horario de verano). Si los superas, las consultas devuelven error hasta el reinicio.

Lo importante es entender qué es una "fila leída": las filas que la consulta recorre, no las que devuelve. Un SELECT * FROM articulos WHERE autor = 'luis' sin índice en autor recorre la tabla entera: si tiene 100.000 filas, son 100.000 filas leídas aunque devuelva tres. Con un índice serían tres (más las del índice). Los índices tienen un coste en el otro lado: cada escritura que toca una columna indexada cuenta una fila escrita más.

💡
Cada respuesta te dice lo que has gastado. El objeto meta de cualquier consulta trae rows_read y rows_written. Devuélvelos en desarrollo o regístralos en los logs y verás enseguida qué consulta necesita un índice.

Crear la base de datos

# Con cuenta: crea la base de datos y añade el binding a wrangler.jsonc
npx wrangler d1 create blog-db --location weur

# Crear una migración (genera migrations/0001_crear_tablas.sql vacío)
npx wrangler d1 migrations create blog-db crear_tablas

La configuración del binding:

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "d1-lab",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "blog-db",
      "database_id": "00000000-0000-0000-0000-000000000000",
      "migrations_dir": "migrations"
    }
  ]
}

El database_id lo devuelve wrangler d1 create. Para trabajar solo en local, cualquier UUID sirve: aquí uso ceros. Como en el resto de bindings, puedes omitir el id y Wrangler creará la base de datos en el primer deploy.

Migraciones

Las migraciones son ficheros SQL numerados en migrations/. Wrangler registra en una tabla interna (d1_migrations) cuáles se han aplicado, así que solo aplica las nuevas. Las de este proyecto:

-- Migration number: 0001 	 Artículos y etiquetas
CREATE TABLE articulos (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  slug       TEXT NOT NULL UNIQUE,
  titulo     TEXT NOT NULL,
  cuerpo     TEXT NOT NULL DEFAULT '',
  visitas    INTEGER NOT NULL DEFAULT 0,
  creado_en  TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE etiquetas (
  articulo_id INTEGER NOT NULL REFERENCES articulos(id) ON DELETE CASCADE,
  etiqueta    TEXT NOT NULL,
  PRIMARY KEY (articulo_id, etiqueta)
);
-- Migration number: 0002 	 Buscar artículos por etiqueta sin recorrer la tabla entera
CREATE INDEX idx_etiquetas_etiqueta ON etiquetas (etiqueta);

Se aplican con migrations apply, en local con --local y en producción con --remote:

$ npx wrangler d1 migrations apply blog-db --local
Migrations to be applied:
┌──────────────────────────────┐
│ name                         │
├──────────────────────────────┤
│ 0001_crear_tablas.sql        │
├──────────────────────────────┤
│ 0002_indice_por_etiqueta.sql │
└──────────────────────────────┘
? About to apply 2 migration(s)
Your database may not be available to serve requests during the migration, continue?
🤖 Using fallback value in non-interactive context: yes
🌀 Executing on local database blog-db (00000000-0000-0000-0000-000000000000) from .wrangler/state/v3/d1:
🚣 3 commands executed successfully.
...
┌──────────────────────────────┬────────┐
│ name                         │ status │
├──────────────────────────────┼────────┤
│ 0001_crear_tablas.sql        │ ✅     │
├──────────────────────────────┼────────┤
│ 0002_indice_por_etiqueta.sql │ ✅     │
└──────────────────────────────┴────────┘

$ npx wrangler d1 execute blog-db --local --file seed.sql
🚣 2 commands executed successfully.

Si una migración falla, se deshace y las anteriores siguen aplicadas. Fíjate también en el aviso: durante una migración la base de datos puede no atender peticiones, así que en producción conviene que las migraciones sean pequeñas.

⚠️
wrangler d1 execute sin --local ni --remote usa la local. Es lo correcto, pero acostúmbrate a escribir siempre el que quieres. Un DROP TABLE lanzado con --remote por error no tiene "deshacer"... salvo Time Travel, que veremos al final.

La API del binding

El binding env.DB es un D1Database. Lo normal es preparar una sentencia, enlazar los parámetros y ejecutarla:

MétodoDevuelveÚsalo para
prepare(sql).bind(...).run(){ results, success, meta }Cualquier consulta; results trae las filas
.first() / .first("columna")La primera fila (o un valor), o nullBuscar por clave, RETURNING id, count(*)
.raw()Arrays de valores en lugar de objetosRespuestas grandes, menos memoria
DB.batch([s1, s2, ...])Un resultado por sentenciaVarias sentencias en un viaje y en una transacción
DB.exec(sql)Solo count y durationScripts de mantenimiento; nunca con datos del usuario
DB.withSession(...)Una sesión con prepare y getBookmark()Réplicas de lectura

Usa siempre parámetros (?1, ?2 o ?) en lugar de concatenar valores en el SQL: evitas la inyección de SQL y D1 puede reutilizar la sentencia. Los tipos se convierten así: null a NULL, number a INTEGER o REAL, string a TEXT, boolean a INTEGER (0 o 1) y ArrayBuffer a BLOB. undefined no se admite y lanza un error. Al leer, los booleanos vuelven como 0 o 1 y los BLOB como arrays de números; la documentación recomienda tablas STRICT para que SQLite no guarde un tipo distinto del que declaras.

Una API de artículos con etiquetas

type Articulo = { id: number; slug: string; titulo: string; visitas: number; creado_en: string };

export default {
  async fetch(request, env): Promise<Response> {
    const url = new URL(request.url);
    const partes = url.pathname.split("/").filter(Boolean); // ["articulos", "mi-slug"]

    // GET /articulos?etiqueta=cloudflare
    if (request.method === "GET" && url.pathname === "/articulos") {
      const etiqueta = url.searchParams.get("etiqueta");
      const stmt = etiqueta
        ? env.DB.prepare(
            `SELECT a.id, a.slug, a.titulo, a.visitas, a.creado_en
               FROM articulos a JOIN etiquetas e ON e.articulo_id = a.id
              WHERE e.etiqueta = ?1
              ORDER BY a.id DESC`,
          ).bind(etiqueta)
        : env.DB.prepare("SELECT id, slug, titulo, visitas, creado_en FROM articulos ORDER BY id DESC LIMIT 50");

      const { results, meta } = await stmt.run<Articulo>();
      return Response.json({ articulos: results, filasLeidas: meta.rows_read, ms: meta.duration });
    }

    // POST /articulos  { slug, titulo, cuerpo, etiquetas: [] }
    if (request.method === "POST" && url.pathname === "/articulos") {
      const datos = await request.json<{ slug: string; titulo: string; cuerpo?: string; etiquetas?: string[] }>();
      if (!datos.slug || !datos.titulo) return Response.json({ error: "slug y titulo son obligatorios" }, { status: 400 });

      try {
        // RETURNING nos da el id sin una segunda consulta
        const creado = await env.DB.prepare(
          "INSERT INTO articulos (slug, titulo, cuerpo) VALUES (?1, ?2, ?3) RETURNING id",
        )
          .bind(datos.slug, datos.titulo, datos.cuerpo ?? "")
          .first<{ id: number }>();

        // batch(): varias sentencias en un único viaje y en una transacción
        const etiquetas = datos.etiquetas ?? [];
        if (etiquetas.length) {
          const insertar = env.DB.prepare("INSERT INTO etiquetas (articulo_id, etiqueta) VALUES (?1, ?2)");
          await env.DB.batch(etiquetas.map((e) => insertar.bind(creado!.id, e)));
        }
        return Response.json({ id: creado!.id }, { status: 201 });
      } catch (err) {
        if (String(err).includes("UNIQUE constraint failed")) {
          return Response.json({ error: "Ese slug ya existe" }, { status: 409 });
        }
        throw err;
      }
    }

    // GET /articulos/:slug  (y suma una visita)
    if (request.method === "GET" && partes[0] === "articulos" && partes[1]) {
      const [visita, articulo] = await env.DB.batch([
        env.DB.prepare("UPDATE articulos SET visitas = visitas + 1 WHERE slug = ?1").bind(partes[1]),
        env.DB.prepare("SELECT id, slug, titulo, visitas, creado_en FROM articulos WHERE slug = ?1").bind(partes[1]),
      ]);
      const fila = (articulo.results as Articulo[])[0];
      if (!fila) return Response.json({ error: "No encontrado" }, { status: 404 });
      return Response.json({ ...fila, filasEscritas: visita.meta.rows_written });
    }

    return new Response("No existe", { status: 404 });
  },
} satisfies ExportedHandler<Env>;

Tres técnicas que merecen un comentario:

  • RETURNING id con .first() devuelve el id del artículo recién creado en la misma consulta, sin un SELECT last_insert_rowid() aparte.
  • batch() envía todas las inserciones de etiquetas en un solo viaje a la base de datos. Además, el lote es una transacción: si una sentencia falla, se deshacen todas. Es la forma de hacer transacciones en D1 desde un Worker.
  • La visita y la lectura en un lote: el UPDATE que suma la visita y el SELECT que devuelve el artículo van juntos, así que el contador que ves ya incluye tu visita.

Con npx wrangler dev --port 8789:

$ curl "localhost:8789/articulos?etiqueta=backend"
{"articulos":[{"id":2,"slug":"express-a-fondo","titulo":"Express a fondo","visitas":0,"creado_en":"2026-10-02 18:23:53"},
              {"id":1,"slug":"hono-a-fondo","titulo":"Hono a fondo","visitas":0,"creado_en":"2026-10-02 18:23:53"}],
 "filasLeidas":7,"ms":0}

$ curl -X POST localhost:8789/articulos -H "Content-Type: application/json" \
    -d '{"slug":"cloudflare-d1-a-fondo","titulo":"Cloudflare D1 a fondo","etiquetas":["cloudflare","sql"]}' -w " %{http_code}\n"
{"id":3} 201

$ curl -X POST localhost:8789/articulos -H "Content-Type: application/json" \
    -d '{"slug":"cloudflare-d1-a-fondo","titulo":"Otra vez"}' -w " %{http_code}\n"
{"error":"Ese slug ya existe"} 409

$ curl -X POST localhost:8789/articulos -H "Content-Type: application/json" -d '{"slug":"x"}' -w " %{http_code}\n"
{"error":"slug y titulo son obligatorios"} 400

$ curl localhost:8789/articulos/hono-a-fondo
{"id":1,"slug":"hono-a-fondo","titulo":"Hono a fondo","visitas":1,"creado_en":"2026-10-02 18:23:53","filasEscritas":1}
$ curl localhost:8789/articulos/hono-a-fondo
{"id":1,"slug":"hono-a-fondo","titulo":"Hono a fondo","visitas":2,"creado_en":"2026-10-02 18:23:53","filasEscritas":1}

$ curl localhost:8789/articulos/no-existe -w " %{http_code}\n"
{"error":"No encontrado"} 404
🕒
datetime('now') está en UTC. Las filas se crearon a las 20:23 en Madrid y creado_en dice 18:23:53. SQLite no sabe nada de zonas horarias: guarda en UTC (añadiendo la Z si la vas a mandar a un navegador, o como número con unixepoch()) y convierte al mostrar.

Comprobar que el índice se usa

La segunda migración crea un índice sobre etiquetas(etiqueta). EXPLAIN QUERY PLAN confirma que la consulta de filtrado lo usa en lugar de recorrer la tabla:

$ npx wrangler d1 execute blog-db --local --command "EXPLAIN QUERY PLAN SELECT a.slug FROM articulos a
    JOIN etiquetas e ON e.articulo_id = a.id WHERE e.etiqueta = 'backend'"
[
  {
    "results": [
      { "id": 4, "parent": 0, "notused": 63, "detail": "SEARCH e USING INDEX idx_etiquetas_etiqueta (etiqueta=?)" },
      { "id": 9, "parent": 0, "notused": 45, "detail": "SEARCH a USING INTEGER PRIMARY KEY (rowid=?)" }
    ],
    "success": true,
    "meta": { "duration": 0 }
  }
]

SEARCH ... USING INDEX es lo que quieres ver. Si ves SCAN articulos, la consulta recorre la tabla entera y pagarás cada fila. Hazlo con todas las consultas que se ejecuten en cada petición.

Réplicas de lectura y la API de sesiones

La replicación se activa por base de datos desde el panel (o con la API REST, poniendo read_replication.mode a auto). En el código, en lugar de env.DB.prepare(...) se abre una sesión:

export default {
  async fetch(request, env): Promise<Response> {
    // Si el cliente nos devuelve el bookmark de su última petición, seguimos desde ahí;
    // si no, la primera consulta puede ir a cualquier réplica.
    const bookmark = request.headers.get("x-d1-bookmark") ?? "first-unconstrained";
    const sesion = env.DB.withSession(bookmark);

    const { results } = await sesion.prepare("SELECT slug, titulo FROM articulos ORDER BY id DESC LIMIT 20").run();

    const respuesta = Response.json(results);
    // El bookmark marca hasta dónde ha leído esta sesión
    respuesta.headers.set("x-d1-bookmark", sesion.getBookmark() ?? "");
    return respuesta;
  },
} satisfies ExportedHandler<Env>;

Las opciones de withSession() son "first-unconstrained" (la primera consulta va a donde sea más rápido; es la predeterminada), "first-primary" (la primera va a la primaria, para leer lo último) o un bookmark devuelto antes. Si el cliente reenvía el bookmark en cada petición, nunca verá datos más antiguos que los que ya vio, aunque cada petición vaya a una réplica distinta.

⚠️
Esto solo lo he probado a medias. En local no hay réplicas: el test de abajo comprueba que la API funciona (que una sesión lee lo que escribe y que getBookmark() devuelve un texto), pero el enrutado real a réplicas solo se ve con una base de datos remota con la replicación activada.

Pruebas con las migraciones aplicadas

El plugin de Vitest lee las migraciones y un fichero de configuración las aplica antes de los tests:

import path from "node:path";
import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-plugin";
import { defineConfig } from "vitest/config";

export default defineConfig(async () => {
  // Lee las migraciones de ./migrations para aplicarlas en cada ejecución de tests
  const migraciones = await readD1Migrations(path.join(import.meta.dirname, "migrations"));
  return {
    plugins: [
      cloudflareTest({
        wrangler: { configPath: "./wrangler.jsonc" },
        miniflare: { bindings: { TEST_MIGRATIONS: migraciones } },
      }),
    ],
    test: { setupFiles: ["./test/aplicar-migraciones.ts"] },
  };
});
import { applyD1Migrations } from "cloudflare:test";
import { env } from "cloudflare:workers";

// Solo aplica las que falten, así que es seguro ejecutarlo varias veces
await applyD1Migrations(env.DB, env.TEST_MIGRATIONS);

Para que TypeScript conozca env.TEST_MIGRATIONS, se amplía el tipo Env en test/env.d.ts:

declare namespace Cloudflare {
  interface Env {
    TEST_MIGRATIONS: import("cloudflare:test").D1Migration[];
  }
}

Y los tests:

import { env, exports } from "cloudflare:workers";
import { describe, it, expect } from "vitest";

const crear = (cuerpo: object) =>
  exports.default.fetch("https://x/articulos", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(cuerpo),
  });

describe("API de artículos sobre D1", () => {
  it("crea un artículo con etiquetas y lo filtra", async () => {
    const res = await crear({ slug: "d1", titulo: "D1 a fondo", etiquetas: ["cloudflare", "sql"] });
    expect(res.status).toBe(201);

    const lista = await exports.default.fetch("https://x/articulos?etiqueta=sql");
    const datos = await lista.json<{ articulos: { slug: string }[] }>();
    expect(datos.articulos.map((a) => a.slug)).toEqual(["d1"]);
  });

  it("devuelve 409 si el slug se repite", async () => {
    await crear({ slug: "repe", titulo: "Uno" });
    const res = await crear({ slug: "repe", titulo: "Dos" });
    expect(res.status).toBe(409);
  });

  it("el almacenamiento se aísla por fichero, no por test", async () => {
    // Los artículos de los tests anteriores de ESTE fichero siguen ahí
    const { total } = (await env.DB.prepare("SELECT count(*) AS total FROM articulos").first<{ total: number }>())!;
    expect(total).toBe(2); // "d1" y "repe" (el segundo "repe" falló)
  });

  it("una sesión devuelve un bookmark", async () => {
    const sesion = env.DB.withSession("first-primary");
    await sesion.prepare("INSERT INTO articulos (slug, titulo) VALUES ('s', 'Sesión')").run();
    const fila = await sesion.prepare("SELECT titulo FROM articulos WHERE slug = 's'").first<{ titulo: string }>();
    expect(fila?.titulo).toBe("Sesión");
    expect(typeof sesion.getBookmark()).toBe("string");
  });
});
$ npx vitest run

 Test Files  1 passed (1)
      Tests  4 passed (4)
   Duration  582ms (transform 31ms, setup 53ms, import 10ms, tests 31ms, environment 0ms)
🐛
El almacenamiento se aísla por fichero de test, no por test. Mi primer intento suponía que cada it() empezaba con la base de datos vacía, y no: los datos de un test siguen ahí en el siguiente del mismo fichero. El tercer test lo demuestra contando las filas. Si necesitas tests independientes, usa datos distintos en cada uno (slugs únicos) o sepáralos en ficheros.

Copias de seguridad: Time Travel y export

D1 guarda un historial de cambios que permite restaurar la base de datos a cualquier minuto de los últimos 30 días (7 en el plan gratuito). No hay que activarlo:

npx wrangler d1 time-travel info blog-db                       # bookmark actual
npx wrangler d1 time-travel restore blog-db --timestamp=2026-10-02T18:00:00Z
npx wrangler d1 export blog-db --remote --output=copia.sql     # volcado SQL completo

La restauración sobrescribe la base de datos actual (Wrangler te devuelve un bookmark para deshacerla). Para tener una copia fuera de Cloudflare, d1 export genera un .sql que puedes subir a R2 desde un cron.

Límites que importan

LímiteWorkers PaidWorkers Free
Tamaño máximo de una base de datos10 GB500 MB
Almacenamiento por cuenta1 TB5 GB
Bases de datos por cuenta50.00010
Consultas por invocación del Worker1.00050
Time Travel30 días7 días
Duración máxima de una consulta30 segundos30 segundos
Parámetros por consulta100100
Columnas por tabla100100
Tamaño de una fila, cadena o BLOB2 MB2 MB
Longitud de una sentencia SQL100 KB100 KB

El de 100 parámetros sorprende a menudo: un INSERT de 50 filas con 3 columnas son 150 parámetros y falla. Divide en lotes o usa batch() con una sentencia por fila, como hace el ejemplo con las etiquetas.

Errores comunes (y cómo evitarlos)

  • Consultas sin índice en cada petición. Funcionan perfectamente en desarrollo y te cuestan una fortuna en filas leídas en producción. EXPLAIN QUERY PLAN y meta.rows_read son tus amigos.
  • Pasar undefined a bind(). Lanza un error. Usa ?? null en los campos opcionales.
  • Pensar en una base de datos enorme. El límite de 10 GB es duro. Si tu modelo encaja en "una base de datos por cliente", D1 brilla; si necesitas una única base de datos de 200 GB, mira Hyperdrive con tu PostgreSQL.
  • Muchas consultas pequeñas en serie. Cada una es un viaje a la región de la base de datos. Agrúpalas con batch() o con un JOIN.
  • Concatenar SQL. Además del riesgo de inyección, es más lento. Parámetros siempre.
  • Olvidar --remote al migrar producción (o, peor, usarlo sin querer). Mete la migración remota en tu pipeline de despliegue y no la lances a mano.
  • Fechas en hora local. datetime('now') es UTC; trátalo así de principio a fin.

Cuándo usar D1 (y cuándo no)

Usa D1 para el modelo de datos relacional de una aplicación en Workers: usuarios, contenidos, pedidos, configuración. Brilla con muchas bases de datos pequeñas (una por cliente) y con cargas de lectura, y su capa gratuita cubre de sobra un proyecto personal.

No es lo adecuado si necesitas una base de datos de más de 10 GB, muchas escrituras concurrentes sobre los mismos datos (un único hilo por base de datos) o PostgreSQL y sus extensiones: para eso, Hyperdrive delante de tu base de datos. Y para datos clave-valor que se leen muchísimo y cambian poco, KV es más rápido y sencillo.

Sigue leyendo