Crea il tuo Redis in Java
Redis è uno dei data store in-memory più diffusi al mondo. La sua reputazione di sistema estremamente veloce nasconde, sotto il cofano, un'architettura sorprendentemente comprensibile: un server TCP che parla un protocollo testuale semplice e che mantiene i dati in strutture in memoria. In questo articolo costruiremo da zero una versione minimale ma funzionante di Redis in Java, in grado di rispondere ai comandi fondamentali (PING, ECHO, SET, GET, DEL, EXPIRE, TTL) e di interoperare con i client Redis reali come redis-cli.
L'obiettivo non è sostituire Redis, ma capire come funziona realmente: il parsing del protocollo RESP, la gestione delle connessioni concorrenti, lo storage in-memory thread-safe e la scadenza delle chiavi. Al termine avremo un server che potremo interrogare con i tool ufficiali, ottenendo le stesse risposte che daremmo a un'istanza Redis autentica.
Il protocollo RESP
Tutta la comunicazione con Redis avviene tramite RESP (REdis Serialization Protocol). Si tratta di un protocollo testuale binary-safe in cui il primo byte di ogni messaggio determina il tipo di dato. I tipi fondamentali sono cinque, ciascuno identificato da un prefisso:
- Simple String: inizia con
+, ad esempio+OK\r\n. - Error: inizia con
-, ad esempio-ERR unknown command\r\n. - Integer: inizia con
:, ad esempio:1000\r\n. - Bulk String: inizia con
$seguito dalla lunghezza, ad esempio$5\r\nhello\r\n. La lunghezza-1rappresenta il valore nullo ($-1\r\n). - Array: inizia con
*seguito dal numero di elementi, ad esempio*2\r\n$4\r\nECHO\r\n$3\r\nhey\r\n.
Ogni elemento è terminato dalla sequenza CRLF (\r\n). I comandi inviati dai client sono sempre codificati come array di bulk string: il primo elemento è il nome del comando, i successivi sono gli argomenti. Quando digitiamo SET name Gabriele in redis-cli, ciò che viaggia sul socket è in realtà questo:
*3\r\n
$3\r\nSET\r\n
$4\r\nname\r\n
$8\r\nGabriele\r\n
Comprendere RESP è la chiave dell'intero progetto: il nostro lavoro consiste essenzialmente nel leggere array di bulk string dal socket e nello scrivere risposte conformi a questi cinque tipi.
Struttura del progetto
Useremo un progetto Maven standard, senza dipendenze esterne: tutto ciò che ci serve è disponibile nella libreria standard di Java. Questa è la struttura dei file che andremo a creare:
redis-java/
├── pom.xml
└── src/
└── main/
└── java/
└── com/
└── example/
└── redis/
├── RedisServer.java
├── ClientHandler.java
├── RespParser.java
├── RespWriter.java
├── DataStore.java
└── CommandExecutor.java
Il file pom.xml è minimale e si limita a fissare la versione di Java e a configurare la classe main per la creazione di un JAR eseguibile:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>redis-java</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.1</version>
<configuration>
<archive>
<manifest>
<mainClass>com.example.redis.RedisServer</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
Il server TCP
Il cuore del server è un ServerSocket in ascolto sulla porta 6379, la porta di default di Redis. Ogni volta che un client si connette, deleghiamo la gestione della connessione a un thread separato preso da un thread pool. Questo approccio thread-per-connection è meno scalabile di un modello a I/O non bloccante, ma è perfetto per i nostri scopi didattici e rimane sufficientemente robusto per un numero moderato di client.
package com.example.redis;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class RedisServer {
// Porta standard di Redis
private static final int DEFAULT_PORT = 6379;
private final int port;
private final DataStore dataStore;
private final ExecutorService threadPool;
public RedisServer(int port) {
this.port = port;
this.dataStore = new DataStore();
// Pool di thread virtuali: ogni connessione ottiene il proprio thread
this.threadPool = Executors.newVirtualThreadPerTaskExecutor();
}
public void start() {
try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("Server in ascolto sulla porta " + port);
// Ciclo di accettazione: blocca finché non arriva un client
while (true) {
Socket clientSocket = serverSocket.accept();
System.out.println("Nuova connessione da " + clientSocket.getRemoteSocketAddress());
// Deleghiamo la gestione del client a un task separato
ClientHandler handler = new ClientHandler(clientSocket, dataStore);
threadPool.submit(handler);
}
} catch (IOException e) {
System.err.println("Errore del server: " + e.getMessage());
} finally {
threadPool.shutdown();
}
}
public static void main(String[] args) {
// Permettiamo di sovrascrivere la porta da riga di comando
int port = args.length > 0 ? Integer.parseInt(args[0]) : DEFAULT_PORT;
new RedisServer(port).start();
}
}
Da notare l'uso di Executors.newVirtualThreadPerTaskExecutor(), disponibile a partire da Java 21. I thread virtuali rendono il modello thread-per-connection estremamente economico: possiamo gestire migliaia di connessioni simultanee senza esaurire le risorse del sistema operativo, perché ogni connessione non occupa più un thread nativo del kernel ma un thread leggero gestito dalla JVM.
Lettura del protocollo: il parser RESP
Il parser ha il compito di leggere dal flusso in ingresso un comando completo e restituirlo come lista di stringhe. Poiché i comandi sono sempre array di bulk string, il parser deve riconoscere il prefisso *, leggere il numero di elementi e poi, per ciascun elemento, leggere il prefisso $, la lunghezza e infine i byte effettivi.
Lavoriamo direttamente sui byte tramite un InputStream bufferizzato, perché RESP è binary-safe e un valore potrebbe contenere byte arbitrari. La lettura della lunghezza avviene leggendo una riga di testo terminata da CRLF, mentre la lettura del payload avviene leggendo esattamente il numero di byte dichiarato.
package com.example.redis;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
public class RespParser {
private final InputStream input;
public RespParser(InputStream input) {
this.input = input;
}
// Legge un comando completo dal flusso e lo restituisce come lista di stringhe.
// Restituisce null quando il client chiude la connessione.
public List<String> parseCommand() throws IOException {
int prefix = input.read();
// Fine dello stream: il client si è disconnesso
if (prefix == -1) {
return null;
}
// Ci aspettiamo sempre un array per i comandi
if (prefix != '*') {
throw new IOException("Atteso un array RESP, trovato: " + (char) prefix);
}
// Numero di elementi dell'array
int elementCount = Integer.parseInt(readLine());
List<String> arguments = new ArrayList<>(elementCount);
for (int i = 0; i < elementCount; i++) {
int elementPrefix = input.read();
// Ogni elemento di un comando è una bulk string
if (elementPrefix != '$') {
throw new IOException("Attesa una bulk string, trovato: " + (char) elementPrefix);
}
int length = Integer.parseInt(readLine());
byte[] payload = readBytes(length);
// Consumiamo il CRLF finale che segue il payload
readLine();
arguments.add(new String(payload, StandardCharsets.UTF_8));
}
return arguments;
}
// Legge una riga terminata da CRLF e restituisce il contenuto senza terminatore.
private String readLine() throws IOException {
StringBuilder builder = new StringBuilder();
int current;
while ((current = input.read()) != -1) {
if (current == '\r') {
// Il carattere successivo deve essere \n: lo scartiamo
input.read();
break;
}
builder.append((char) current);
}
return builder.toString();
}
// Legge esattamente "length" byte dal flusso.
private byte[] readBytes(int length) throws IOException {
byte[] buffer = new byte[length];
int totalRead = 0;
// read() può restituire meno byte del richiesto: ripetiamo finché serve
while (totalRead < length) {
int read = input.read(buffer, totalRead, length - totalRead);
if (read == -1) {
throw new IOException("Flusso terminato in modo inatteso");
}
totalRead += read;
}
return buffer;
}
}
Il punto delicato è il metodo readBytes: un InputStream non garantisce di restituire in una sola chiamata tutti i byte richiesti, quindi dobbiamo ripetere la lettura finché non abbiamo accumulato l'intera bulk string. Trascurare questo dettaglio è una fonte classica di bug intermittenti che si manifestano solo con payload grandi o connessioni lente.
Scrittura del protocollo: il writer RESP
Speculare al parser, il writer si occupa di serializzare le risposte nei tipi RESP corretti. Esponiamo un metodo per ciascun tipo di risposta che ci serve: simple string, error, integer, bulk string e null bulk string.
package com.example.redis;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
public class RespWriter {
private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8);
private final OutputStream output;
public RespWriter(OutputStream output) {
this.output = output;
}
// Simple string: +OK\r\n
public void writeSimpleString(String value) throws IOException {
output.write('+');
output.write(value.getBytes(StandardCharsets.UTF_8));
output.write(CRLF);
output.flush();
}
// Error: -ERR messaggio\r\n
public void writeError(String message) throws IOException {
output.write('-');
output.write(message.getBytes(StandardCharsets.UTF_8));
output.write(CRLF);
output.flush();
}
// Integer: :123\r\n
public void writeInteger(long value) throws IOException {
output.write(':');
output.write(Long.toString(value).getBytes(StandardCharsets.UTF_8));
output.write(CRLF);
output.flush();
}
// Bulk string: $5\r\nhello\r\n
public void writeBulkString(String value) throws IOException {
byte[] payload = value.getBytes(StandardCharsets.UTF_8);
output.write('$');
output.write(Integer.toString(payload.length).getBytes(StandardCharsets.UTF_8));
output.write(CRLF);
output.write(payload);
output.write(CRLF);
output.flush();
}
// Null bulk string: $-1\r\n, indica l'assenza di un valore
public void writeNull() throws IOException {
output.write('$');
output.write("-1".getBytes(StandardCharsets.UTF_8));
output.write(CRLF);
output.flush();
}
}
La chiamata a flush() al termine di ogni risposta garantisce che i dati vengano effettivamente inviati al client e non rimangano bloccati in un buffer interno. In un server di produzione si potrebbe accumulare l'output e svuotarlo strategicamente per migliorare il throughput, ma per chiarezza preferiamo inviare ogni risposta immediatamente.
Lo storage in-memory
I dati vivono in una ConcurrentHashMap, che ci offre accesso thread-safe senza dover gestire manualmente i lock. Poiché più client possono leggere e scrivere contemporaneamente, l'uso di una struttura concorrente è essenziale per evitare corruzione dello stato.
Oltre al valore, ogni chiave può avere un tempo di scadenza. Modelliamo questo aspetto con una classe interna StoredValue che incapsula il valore e l'istante di scadenza espresso in millisecondi epoch. Una scadenza pari a zero indica una chiave senza tempo di vita.
package com.example.redis;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
public class DataStore {
// Rappresenta un valore con un'eventuale scadenza
private static final class StoredValue {
final String value;
final long expiresAt; // millisecondi epoch, 0 = nessuna scadenza
StoredValue(String value, long expiresAt) {
this.value = value;
this.expiresAt = expiresAt;
}
boolean isExpired() {
return expiresAt != 0 && System.currentTimeMillis() >= expiresAt;
}
}
private final ConcurrentMap<String, StoredValue> store = new ConcurrentHashMap<>();
// Imposta un valore senza scadenza
public void set(String key, String value) {
store.put(key, new StoredValue(value, 0));
}
// Imposta un valore con scadenza espressa in secondi a partire da ora
public void setWithExpiry(String key, String value, long seconds) {
long expiresAt = System.currentTimeMillis() + seconds * 1000L;
store.put(key, new StoredValue(value, expiresAt));
}
// Restituisce il valore, oppure null se assente o scaduto
public String get(String key) {
StoredValue stored = store.get(key);
if (stored == null) {
return null;
}
// Scadenza pigra: la chiave scaduta viene rimossa al primo accesso
if (stored.isExpired()) {
store.remove(key, stored);
return null;
}
return stored.value;
}
// Rimuove una chiave e segnala se esisteva ancora
public boolean delete(String key) {
StoredValue stored = store.remove(key);
return stored != null && !stored.isExpired();
}
// Imposta una scadenza su una chiave esistente. Restituisce true in caso di successo.
public boolean expire(String key, long seconds) {
StoredValue stored = store.get(key);
if (stored == null || stored.isExpired()) {
return false;
}
long expiresAt = System.currentTimeMillis() + seconds * 1000L;
// Aggiorniamo in modo atomico solo se il valore non è cambiato nel frattempo
return store.replace(key, stored, new StoredValue(stored.value, expiresAt));
}
// Restituisce i secondi residui: -2 se assente, -1 se senza scadenza
public long ttl(String key) {
StoredValue stored = store.get(key);
if (stored == null || stored.isExpired()) {
return -2;
}
if (stored.expiresAt == 0) {
return -1;
}
long remaining = stored.expiresAt - System.currentTimeMillis();
return Math.max(0, remaining / 1000L);
}
}
Abbiamo adottato la strategia della scadenza pigra (lazy expiration): una chiave scaduta non viene cancellata da un thread di pulizia in background, ma solo quando viene effettivamente acceduta. Questo è esattamente uno dei meccanismi che Redis utilizza. Il vantaggio è la semplicità; lo svantaggio è che le chiavi scadute mai più accedute restano in memoria. Redis affianca alla scadenza pigra un campionamento periodico attivo, che potremmo aggiungere in un secondo momento.
I valori di ritorno di ttl seguono la convenzione di Redis: -2 significa che la chiave non esiste, -1 che la chiave esiste ma non ha una scadenza impostata.
L'esecutore dei comandi
L'esecutore riceve la lista di argomenti prodotta dal parser, individua il comando dal primo elemento e produce la risposta appropriata scrivendola tramite il writer. È il punto in cui la logica applicativa incontra il protocollo. Normalizziamo il nome del comando in maiuscolo per accettare sia SET sia set, come fa Redis.
package com.example.redis;
import java.io.IOException;
import java.util.List;
public class CommandExecutor {
private final DataStore dataStore;
private final RespWriter writer;
public CommandExecutor(DataStore dataStore, RespWriter writer) {
this.dataStore = dataStore;
this.writer = writer;
}
// Smista il comando e produce la risposta
public void execute(List<String> arguments) throws IOException {
if (arguments.isEmpty()) {
writer.writeError("ERR empty command");
return;
}
// Il nome del comando è case-insensitive in Redis
String command = arguments.get(0).toUpperCase();
switch (command) {
case "PING" -> handlePing(arguments);
case "ECHO" -> handleEcho(arguments);
case "SET" -> handleSet(arguments);
case "GET" -> handleGet(arguments);
case "DEL" -> handleDel(arguments);
case "EXPIRE" -> handleExpire(arguments);
case "TTL" -> handleTtl(arguments);
default -> writer.writeError("ERR unknown command '" + command + "'");
}
}
// PING risponde PONG, oppure restituisce il messaggio passato come argomento
private void handlePing(List<String> arguments) throws IOException {
if (arguments.size() > 1) {
writer.writeBulkString(arguments.get(1));
} else {
writer.writeSimpleString("PONG");
}
}
// ECHO restituisce esattamente il messaggio ricevuto
private void handleEcho(List<String> arguments) throws IOException {
if (arguments.size() != 2) {
writer.writeError("ERR wrong number of arguments for 'echo'");
return;
}
writer.writeBulkString(arguments.get(1));
}
// SET key value [EX seconds]
private void handleSet(List<String> arguments) throws IOException {
if (arguments.size() < 3) {
writer.writeError("ERR wrong number of arguments for 'set'");
return;
}
String key = arguments.get(1);
String value = arguments.get(2);
// Supporto opzionale alla scadenza inline tramite EX
if (arguments.size() >= 5 && arguments.get(3).equalsIgnoreCase("EX")) {
try {
long seconds = Long.parseLong(arguments.get(4));
dataStore.setWithExpiry(key, value, seconds);
} catch (NumberFormatException e) {
writer.writeError("ERR value is not an integer or out of range");
return;
}
} else {
dataStore.set(key, value);
}
writer.writeSimpleString("OK");
}
// GET key
private void handleGet(List<String> arguments) throws IOException {
if (arguments.size() != 2) {
writer.writeError("ERR wrong number of arguments for 'get'");
return;
}
String value = dataStore.get(arguments.get(1));
if (value == null) {
writer.writeNull();
} else {
writer.writeBulkString(value);
}
}
// DEL key [key ...] restituisce il numero di chiavi effettivamente rimosse
private void handleDel(List<String> arguments) throws IOException {
if (arguments.size() < 2) {
writer.writeError("ERR wrong number of arguments for 'del'");
return;
}
long removed = 0;
for (int i = 1; i < arguments.size(); i++) {
if (dataStore.delete(arguments.get(i))) {
removed++;
}
}
writer.writeInteger(removed);
}
// EXPIRE key seconds restituisce 1 se applicata, 0 altrimenti
private void handleExpire(List<String> arguments) throws IOException {
if (arguments.size() != 3) {
writer.writeError("ERR wrong number of arguments for 'expire'");
return;
}
try {
long seconds = Long.parseLong(arguments.get(2));
boolean applied = dataStore.expire(arguments.get(1), seconds);
writer.writeInteger(applied ? 1 : 0);
} catch (NumberFormatException e) {
writer.writeError("ERR value is not an integer or out of range");
}
}
// TTL key
private void handleTtl(List<String> arguments) throws IOException {
if (arguments.size() != 2) {
writer.writeError("ERR wrong number of arguments for 'ttl'");
return;
}
writer.writeInteger(dataStore.ttl(arguments.get(1)));
}
}
L'uso dello switch con sintassi a frecce mantiene il dispatcher leggibile e facilmente estendibile: aggiungere un nuovo comando significa aggiungere un ramo allo switch e un metodo dedicato. Ogni handler valida il numero di argomenti e restituisce un errore conforme alla convenzione di Redis quando il comando è malformato.
Il gestore della connessione
Manca l'anello che collega tutto: il ClientHandler. Implementa Runnable ed è il task eseguito dal thread pool per ciascun client. Il suo compito è creare un parser, un writer e un esecutore sulla connessione, quindi entrare in un ciclo che legge un comando alla volta e lo esegue, finché il client non chiude la connessione.
package com.example.redis;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.util.List;
public class ClientHandler implements Runnable {
private final Socket socket;
private final DataStore dataStore;
public ClientHandler(Socket socket, DataStore dataStore) {
this.socket = socket;
this.dataStore = dataStore;
}
@Override
public void run() {
// try-with-resources garantisce la chiusura del socket a fine connessione
try (Socket clientSocket = this.socket;
InputStream rawInput = clientSocket.getInputStream();
OutputStream rawOutput = clientSocket.getOutputStream()) {
// Bufferizziamo i flussi per ridurre le chiamate di sistema
InputStream input = new BufferedInputStream(rawInput);
OutputStream output = new BufferedOutputStream(rawOutput);
RespParser parser = new RespParser(input);
RespWriter writer = new RespWriter(output);
CommandExecutor executor = new CommandExecutor(dataStore, writer);
// Ciclo principale: leggiamo ed eseguiamo un comando per iterazione
while (true) {
List<String> command = parser.parseCommand();
// null indica che il client ha chiuso la connessione
if (command == null) {
break;
}
executor.execute(command);
}
} catch (IOException e) {
// Disconnessioni improvvise sono normali: le registriamo soltanto
System.out.println("Connessione terminata: " + e.getMessage());
}
}
}
Il try-with-resources assicura che socket e flussi vengano sempre chiusi, anche in caso di eccezione. La bufferizzazione dei flussi è una piccola ottimizzazione importante: senza di essa, ogni byte letto o scritto comporterebbe una chiamata di sistema, penalizzando pesantemente le prestazioni.
Compilazione ed esecuzione
Con tutti i file al loro posto, compiliamo e avviamo il server tramite Maven:
# Compila il progetto e crea il JAR eseguibile
mvn clean package
# Avvia il server sulla porta di default 6379
java -jar target/redis-java-1.0.0.jar
Il server stampa il messaggio di avvio e resta in ascolto. A questo punto possiamo collegarci con il client ufficiale di Redis, se installato sul sistema, oppure con un qualsiasi client compatibile:
redis-cli -p 6379
Ecco una sessione di esempio che esercita tutti i comandi implementati:
127.0.0.1:6379> PING
PONG
127.0.0.1:6379> ECHO "ciao mondo"
"ciao mondo"
127.0.0.1:6379> SET name Gabriele
OK
127.0.0.1:6379> GET name
"Gabriele"
127.0.0.1:6379> SET session abc123 EX 10
OK
127.0.0.1:6379> TTL session
(integer) 10
127.0.0.1:6379> EXPIRE name 30
(integer) 1
127.0.0.1:6379> TTL name
(integer) 30
127.0.0.1:6379> DEL name
(integer) 1
127.0.0.1:6379> GET name
(nil)
Il fatto che redis-cli interpreti correttamente le nostre risposte è la prova che la nostra implementazione di RESP è conforme. Possiamo anche verificare il comportamento senza il client ufficiale, usando direttamente nc e codificando il protocollo a mano, anche se è meno comodo.
Verifica della concorrenza
Per accertarci che il server gestisca correttamente più client simultanei, possiamo aprire diverse sessioni di redis-cli e operare sulle stesse chiavi. Grazie alla ConcurrentHashMap e all'aggiornamento atomico tramite replace nel metodo expire, non si verificano corruzioni dello stato anche sotto contesa. Un test più rigoroso consiste nel lanciare il benchmark integrato di Redis:
# Esegue una raffica di comandi SET e GET con 50 client paralleli
redis-benchmark -p 6379 -t set,get -n 10000 -c 50 -q
Il nostro server, pur non raggiungendo le prestazioni di Redis scritto in C, regge senza problemi questo carico, dimostrando che il modello a thread virtuali e lo storage concorrente sono adeguati.
Limiti e possibili estensioni
Quello che abbiamo costruito è un nucleo funzionante, ma volutamente parziale. Redis offre decine di tipi di dato e centinaia di comandi che qui non abbiamo toccato. Le direzioni più naturali per estendere il progetto sono diverse e ciascuna introduce concetti interessanti.
Si potrebbe aggiungere il supporto alle strutture dati complesse: liste con LPUSH e RPUSH, hash con HSET e HGET, insiemi con SADD e SMEMBERS. Ognuna richiede una nuova rappresentazione interna nello storage e nuovi handler nell'esecutore. Un'altra estensione importante è la persistenza: Redis salva i dati su disco tramite snapshot RDB o append-only file (AOF), e replicare anche solo la modalità AOF, che registra ogni comando di scrittura su un file di log, è un esercizio formativo.
La scadenza attiva è un altro miglioramento concreto. Attualmente cancelliamo le chiavi scadute solo quando vengono accedute; un thread in background che campiona periodicamente le chiavi e rimuove quelle scadute libererebbe memoria in modo proattivo, avvicinandoci al comportamento reale di Redis. Infine, comandi come INCR e DECR per i contatori atomici, le transazioni con MULTI ed EXEC, e il meccanismo di pubblicazione e sottoscrizione (PUBLISH e SUBSCRIBE) sono tutte aggiunte che arricchirebbero notevolmente il progetto.
Conclusione
Costruire un Redis minimale in Java mostra quanto sia accessibile l'architettura che sta dietro a uno strumento apparentemente complesso. Il protocollo RESP, una volta compreso, si rivela elegante e diretto; lo storage in-memory è poco più di una mappa concorrente; la gestione delle connessioni, con i thread virtuali di Java moderno, diventa quasi banale. Il valore di questo esercizio non sta nel risultato finale, ma nella comprensione che se ne ricava: la prossima volta che useremo Redis in produzione, sapremo esattamente cosa accade quando inviamo un comando, come viene serializzata la risposta e perché certe scelte progettuali, come la scadenza pigra, sono state fatte. È questa la lezione più preziosa del reimplementare da zero ciò che usiamo ogni giorno.