My implementation of a Redis-like in-memory cache server. Client is out of scope due to time constraints, but any redis client can be used.
Basically, a subset of Redis functionality. API-wise.
Supports values of types string, list, hash.
Supports Per-key TTL.
Supports basic set of CRUD commands for each type.
Running server
./memcache
Interaction via redis-cli
./redis-cli -p 9876
127.0.0.1:9876> SET X 42
OK
127.0.0.1:9876> GET X
"42"
127.0.0.1:9876> EXPIRE X 30
(integer) 1
127.0.0.1:9876> TTL X
(integer) 23
127.0.0.1:9876> TTL X
(integer) 8
127.0.0.1:9876> TTL X
(integer) -2
127.0.0.1:9876> HMSET dict a 1 b 2 c 3
OK
127.0.0.1:9876> HGETALL dict
1) "b"
2) "2"
3) "c"
4) "3"
5) "a"
6) "1"
127.0.0.1:9876> LPUSH list 1 2 3 4 5 6
(integer) 6
127.0.0.1:9876> LRANGE list 0 -1
1) "6"
2) "5"
3) "4"
4) "3"
5) "2"
6) "1"
127.0.0.1:9876> RPOP list
"1"
127.0.0.1:9876> RPOP list
"2"
127.0.0.1:9876> RPOP list
"3"
127.0.0.1:9876> RPOP no-list
(nil)
127.0.0.1:9876> RPOP list another args
(error) ARGS_NUM RPOP requires 1 args exactly, got 3
RESP was chosen as a protocol for communicating with clients.
It's a simple, solid, low-overhead protocol. Another benefit is that redis-cli or any other Redis client
can be used to communicate with the server.
It will fail in some error cases, for example because COMMAND command is not implemented.
Server is configured via environment variables.
SERVER_PORTsets server port. Default is9876. Example:SERVER_PORT=7000LOG_LEVELsets log level. Default isinfoPossible values are logrus log levels. Example:LOG_LEVEL=warnLOG_FORMATsets log format. Default istextPossible values arejson,text. Example:LOG_FORMAT=json
build-local directory contains simple scripts to
build and run the server locally with goreleaser.
./build-local/build.sh builds the project.
./build-local/run.sh runs the project in default configuration.
Releases are available in github releases.
Just run the binary.
- Active cleanup of expired keys. Strategy similar to Redis strategy can be used.
KEYS patterncommand. Performance must be considered carefully when implementing.- Disk storage.
Due to time constraints it sucks currently :) So there's a lot to improve.
Always measure! Check which type of interaction you are optimizing! Keep in mind, that server is accessed over network, which might be orders of magnitude slower than actual request processing for simple requests. Mem-profile methods to see if any memory allocations can be avoided.
- Concurrent reads/writes of single key.
- Consider using sync.RWMutex. Current issue with it is that expired key can be deleted on access.
- Concurrent reads/writes of different keys.
- Create map of per-key locks and avoid locking entire map.
- With per-key locks, consider limiting the number of goroutines to GOMAXPROCS or so. The bottleneck is most probably CPU-bound.
string,list,mapspecific operations- Marshaling empty/short lists.
- Using linked-list instead of slices for lists, as growing and copying slices is expensive. Memory allocated by list entries is not cleaned up until the list key is deleted.
- Wrap logrus.Logger as implementation detail
- Move resp.types to handlers level, leave the marshaling logic next to server
- Add panic handling
- Add graceful server shutdown
- Refine handling of connection read/write errors: which are fine skip and close connection?
The list of commands is a subset of Redis commands, with an attempt to follow the redis commands behavior as close as possible. See RESP for the definition of types.
Errors returned from every command:
- Number of arguments is different from the expected by the command.
- The request is malformed in some way.
Errors returned from every command except Keys API:
- Type of the
valuestored inkeyis different from command type.
Some commands return errors specific to their arguments.
Gets the string value of the key.
Bulk String reply: value of key, or (nil) if key does not exist.
Sets the key to hold the string value.
Simple String reply: "OK".
Pops the first items from key.
Bulk String reply: string value of the first item at key, or (nil) if list is empty or key does not exist.
Pops the last items from key.
Bulk String reply: string value of the last item in key, or (nil) if list is empty or key does not exist.
Pushes one to many items to the beginning of the list at key.
Items are pushed left-to-right, that is LPUSH mylist 1 2 3 will result in [3, 2, 1] list.
Creates new list if does not exist yet.
Int reply: list size after push operation.
Pushes one to many items to the end of the list at key.
Items are pushed left-to-right, that is RPUSH mylist 1 2 3 will result in [1, 2, 3] list.
Creates new list if does not exist yet.
Int reply: list size after push operation.
Returns elements from list key.
start and stop are 0-based and are both, inclusive, that is
LRANGE mylist 0 0 returns list of [0th] element, and
LRANGE mylist 0 1 returns list of [0th, 1st] elements.
If start index is after the list end, empty list is returned.
If stop index is after the list end, it is treated as the last element of the list.
If start or stop indices are negative, they are treated as counting backwards from the list end, that is LRANGE mylist 0 -1 returns all list elements.
Array reply: list of elements in the specified range.
Returns value stored in key map in the field field.
Bulk String reply: value stored in map, or '(nil)' if field is not set or key does not exist.
Returns values stored in key map in the fields listed.
For every non-existing field a (nil) value is returned.
Running the command against a non-existing key results in a list of (nil) values.
Array reply: values stored in map.
Returns all values stored in key map.
Array reply: all values stored in map.
Sets the field in the key map to hold the value.
Creates new hash if does not exist yet.
Int reply: 1 if field is a new field, 0 if field was updated.
Sets the every field in the key map to hold the corresponding value.
Creates new hash if does not exist yet.
Simple String reply: "OK".
Clears all fields in the key map.
Int reply: number of fields removed.
Deletes the key. No-op if key does not exist.
Int reply: number of keys removed.
Sets the expiration to the key, in seconds.
If key already had an expiration, it is replaced with the new value.
Expiration can be removed by a command that overwrites the key itself, like DEL, SET.
Commands that alter the key content (like LPOP, HMSET, etc) do not affect the expiration.
Expiration is timestamp-based, so it is sensitive to system time changes. Expired keys are cleared from memory in a passive way currently, that is on-access.
Int reply: 1 if timeout was set, 0 if key does not exist.
Gets the remaining time to live of the key, in seconds.
Int reply: ttl in seconds, or -1 if the key has no ttl set, -2 if the key does not exist.