Raydash is a self-deployable mini cache built in Go, featuring Spring Boot support via a custom written java client: raydash-client-java.
I built this project for a couple of reasons, but the biggest one is that Redis kept threatening to delete the database I created for my other project, staycultured.app. It's an all-in-one media tracker for games, movies, tv shows & anime. Because it is currently a work in progress and I can't work on it full-time, the cache occasionally sits inactive. Whenever that happens Redis sends me a threat and has wiped my database a couple of times which is quite frustrating honestly. Now the "quick fix" for this problem was simple, whenever an email arrived I manually hit an endpoint via the Swagger OpenAPI docs to keep it alive but man I couldn't be bothered with this shit.
On top of that, the HUGE free 30MB limit on Redis free tier was pretty restrictive for my use case. My average API responses hover on the very least at around 30-50KB, which only grants me room for about ~1,000 JSON objects. If an API like Jikan (for anime) or TMDB returns a massive payload, that limit is gone with realistically 5-10 users using my app! (no one is using it right now I am just saying in hypothetical scenarios...). Upgrading means paying monthly fees of 5ish$ dollars or something for slightly bigger limits.
Since I am already paying a cloud provider for deployments anyways, I figured why not host my own cache? or even better why not build my own cache? I get full control over my data and save some cash too! Plus I get a fantastic excuse to learn Go and build a cool project with it!
What you see here is a bare-minimum, self deployable mini-cache. While it doesn't have advanced enterprise features (YET), it gets the job done perfectly for simple use cases AND it's a really great project for diving into advanced backend concepts~!
At a glance,
- Binary safe protocol
- Optional AUTH
- Graceful shutdown
- Ready to use client support (Spring Boot)
- Optional Postgres persistence
| Index | Sections |
|---|---|
| 1 | Running locally |
| 2 | Configuration |
| 3 | Architecture |
| 4 | Protocol |
| 5 | Persistence |
| 6 | Commands |
| 7 | Benchmark |
| 8 | Clients |
| 9 | Demos |
| 10 | Self hosting / Deployment |
| 11 | Limitations |
| 12 | Test Suite |
| 13 | Contributions |
| 14 | License |
| 15 | Links |
docker compose upFor cli tool
go run cli/main.go -server <any-valid-server-address> -auth <auth-token>docker compose up will spin up both the services together in a coupled container
but alternatively you can run the server and postgres db separately for testing
and benchmarking without the docker network overhead.
go run ./internal/main.goMake sure to have a .env file present in the root of this project (find more at
Configuration below). This might be a good time to remind that
persistence and authentication (via an auth token) are optional.
Right now variables under environment section of raydash & postgres services
in docker-compose.yml are hardcoded but alternatively you can make a .env
file (make sure to .gitignore it) and docker automatically looks for the file
and loads the variables.
Example of .env file content,
# postgres
POSTGRES_DB=raydash
POSTGRES_USER=raydash
POSTGRES_PASSWORD=raydash
# raydash
RAYDASH_PORT=13203
RAYDASH_EXPIRY_INTERVAL_MS=100
RAYDASH_POSTGRES_DSN=postgres://raydash:raydash@postgres:5432/raydash?sslmode=disable
RAYDASH_SNAPSHOT_INTERVAL_SECONDS=30
RAYDASH_MAX_KEYS=0
RAYDASH_AUTH_TOKEN=changemeUsage in docker-compose.yml,
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
# Similarly the restFor local setup without docker we do the same thing, make a .env file in the
root, write the variables and with the help of godotenv package in internal/main.go
it will load those variables automatically.
Flags for the loadtest are hardcoded in case none are provided but you can look at the source code and modify accordingly.
How does this whole project work?
Persistence is optional. To enable it you must pass a valid postgres DSN. In case
of no valid DSN Raydash will run pure in memory. Running locally without docker
meaning leaving the RAYDASH_POSTGRES_DSN blank or absent in .env file.
Making persistence optional using docker is a bit of work. Since compose spins up
both raydash and postgres service together and raydash service heavily refers
to it do these things to run without a database:
- Remove the
postgresservice completely. Delete thepostgresblock in under services. - Remove the
RAYDASH_POSTGRES_DSNline fromraydashservice or leave it blank. - Remove the
depends_onblock fromraydashservice. If you don't remove it Docker will throw an error and refuse to start. - Lastly delete the
raydash-pgdatafrom thevolumes:block at the very bottom of thedocker-compose.ymlso you don't leave unused storage definition behind.
Now let's look at how persistence works,
In case you want to have persistence for a native run for whatever weird reason then you have 2 options:
-
Run
docker compose up postgresand run only thepostgresservice. Then add DSN for the exposed db to our.envvariable and you have a docker running db acting as persistence. -
Other not so straightforward method is creating a database in the default postgres server (instructions listed down below) and enter DSN for that database which will also make it persist.
- You can check if you have EDB Postgres (Community Version) installed or not
by running
Get-Service *postgres*in Powershell and if its present it will give usStoppedorRunningdepending on the current status. In case its not installed at all it will throw aServiceCommandExceptionor something like that. - If you want a GUI approach then
Windows + R, typeservices.mscfind the process,postgresql-x64-16 - PostgreSQL Server 16then you canStartit if its not inRunningstate and if its not present at all then you have to install EDB Postgres first.
- You can check if you have EDB Postgres (Community Version) installed or not
by running
Try
INSANEandHATEfor yourself 😃!
INFOGet a key.
GET <key>Important
SET command is different from others since its server syntax requires 2 lines:
SET <key> <byte-length>\r\n
<exact-byte-length-of-characters>\r\nSince this cache is specifically built to be used with a client like our Java
one you never use SET manually instead the client handles it by itself. BUT
there is an exception! when you use the cli you HAVE to send the command in
a single line there is simply no other option or at least not one which does not
cause me a headache to build so what I do is I take the SET command using the
format given down below and internally transform it to what the server expects.
This way for the rare use case of cli tool it remains simple while following server
protocol.
Binary safe format for setting a key-value pair.
SET <key> <value>Remove a key.
DEL <key>Sets expire time for a key (in seconds).
EXPIRE <key> <time-in-seconds>Return remaining time-to-live given a valid key.
TTL <key>Return :0 if no key is present & :1 if key is present.
EXISTS <key>Clears the whole store use carefully! No arguments needed.
FLUSHALLTo authenticate yourself with the Server. I doubt you will ever use this as a standalone command this is usually configured if present and auto sent before any other command but still good to know.
AUTH <auth-token>These metrics represent the application running directly on the host machine (without Docker or containerization overhead).
| Metric | Value |
|---|---|
| Throughput | 49,026 ops/sec |
| Total Operations | 491,106 (245,553 SET / 245,553 GET) |
| Errors | 0 (0.00%) |
Avg/p50 Latency (SET) |
1.02 ms |
Avg/p50 Latency (GET) |
939.5 µs |
| p99 Latency (Combined) | 3.45 ms |
| Metric | Value |
|---|---|
| Throughput | 1,990 ops/sec |
| Total Operations | 20,040 (10,020 SET / 10,020 GET) |
| Errors | 0 (0.00%) |
p50 Latency (SET) |
46.10 ms |
p50 Latency (GET) |
2.33 ms |
| p99 Latency (Combined) | 51.75 ms |
These metrics represent the application deployed on a remote server (located in Southeast Asia/Singapore).
| Metric | Value |
|---|---|
| Throughput | 805 ops/sec |
| Total Operations | 8,136 (4,068 SET / 4,068 GET) |
| Errors | 0 (0.00%) |
p50 Latency (SET) |
58.17 ms |
p50 Latency (GET) |
57.86 ms |
| p99 Latency (Combined) | 84.15 ms |
A side-by-side breakdown of application performance across native execution, local containerization (Docker Compose), and a remote cloud deployment.
| Metric | Native Performance | Docker Compose (Local) | Deployed Server (Railway SEA) |
|---|---|---|---|
| Throughput | 49,026 ops/sec | 1,990 ops/sec | 805 ops/sec |
| Total Operations | 491,106 | 20,040 | 8,136 |
| Workload Split | 245,553 (SET / GET) |
10,020 (SET / GET) |
4,068 (SET / GET) |
| Errors | 0 (0.00%) | 0 (0.00%) | 0 (0.00%) |
p50 Latency (SET) |
1.02 ms | 46.10 ms | 58.17 ms |
p50 Latency (GET) |
939.5 µs | 2.33 ms | 57.86 ms |
| p99 Latency (Combined) | 3.45 ms | 51.75 ms | 84.15 ms |
Quick obvious takeaways:
- Bare Metal Dominance 🥵: Running natively delivers maximum results with sub-milliseconds to low-milliseconds latencies, this is the TRUE baseline capacity of this application btw. Every other stat simply DOES NOT matter. (the last statement is more absolute than true, the difference in stats is more like "how fast and optimized my code is" vs "what does a real deployed user experience").
- Docker Network Bottleneck: Local Docker Compose shows a massive
96% drop in throughput and a huge spike in
SETlatency. This is directly due to Docker Desktop's VM-based network proxy (the port-forwarding layer on Windows/Mac) adding a flat per-connection tax, nothing to do with file I/O or volume mounts. - Network-Bound Cloud Deployment: The Railway SEA instance throughput drops even further, but this is completely normal given the nature of cross-border network round-trip-time (RTT) from India to Singapore, meaning bottleneck here turns out to be the physical transit distance and NOT related to the application whatesover.
- Start the raydash server using
docker-compose.yml - Run the Spring Boot application from
demos/springboot/app/src/main/java/raydash/demo/app/AppApplication.java application.propertiesis loaded with basic server configuration already- In the root of this
appyou can find abrunofolder containing a basicGETendpoint for fetching user by id, double hit that endpoint (you'll need thebrunodesktop client but if you prefer a straightforward way you can call it from curl or any other API client of your choice). - Use pgadmin or any other database GUI to connect to our docker database using
the DSN,
postgres://raydash:raydash@localhost:5432/raydash?sslmode=disableand look for table,raydash_snapshotwhich will confirm cache hit.
Deploying could be pretty straightforward or might have some extra steps involved
depending on which provider you choose. My own deployment on railway.app is pretty
straightforward I link my github repository to a new service, create another one
for the database link the DSN, environment variables gets hardcoded from my .env
file and it automatically picks up the Dockerfile.
So in simple words fork or clone this repo, add that as a service it will take
care of the code compilation and build on itself (you might need to add instructions
for compilation yourself) then if you want persistence you have to add a postgres
database and connect them and that's it!
Most of the PaaS providers like railway.app have quick and easy steps for a repo
deployment ocassionally using the Dockerfile. But in case its more manual work
the one extra step you need for this cache is having a database connected to your
repository in case you want persistence and thats the part you have to figure yourself.
Known limitations worth pointing out:
- Snapshot persistence only starts working up to one
RAYDASH_SNAPSHOT_INTERVAL_SECONDS. Any hard crash upto that point on in between snapshot intervals will lead to data loss. - The ENTIRE CONNECTION and everything that travels over the wire is unencrypted! every single key and value (difference between unauthenticated vs unencrypted).
RAYDASH_MAX_KEYScaps the key count, there is no provision to cap the byte size.- No GUI present for cache either you have to use cli or connect database to a GUI for database and go over the contents manually using SQL.
To run individual functions you can do that from you IDE itself (I can do that at least in vscode)
Test suite for server.go & store.go contains tests for core functionality like
concurrenct read/write, binary safe protocol, authentication, CRUD operations, server
related stuff etc.
server_test.go: Tests for concurrent read/write/delete operations with 50 workers.server_protocol_test.go: Probably the most important test file out of all. Tests forAUTH,INFO&FLUSHALLcommands under multiple scenarios, validates our rewritten binary safe protocol with trick values.
Navigate from root to server directory,
cd internal/server
go test .Run with race condition,
go test -race .Run multiple times with race condition,
go test -race -count=10 .store_bench_test.go: Rough benchmarks under a simple environment for read/writes.store_concurrency_test.go: 100 writers and 100 readers running genuine concurrent operations against the same keys.store_crud_test.go: CRUD operations.store_key_value_test.go:TTLcheck along with lazy and active expiration of keys.store_eviction_test.go: Tests ourmaxKeysproperty under multiple conditions.
Navigate from root to store directory,
cd internal/store
go test .Run with race condition,
go test -race .Run multiple times with race condition,
go test -race -count=10 .You are welcome to add suitable new features or enhance existing ones 😃
This project is licensed under the MIT License. See the License file for more details.


