paper.archive/Opensource
Opensource

redis data types

Contents

Redis Data Type Summary

There are plenty of good resources on the internet, but I’m writing this up as study

Testing is done with redis-cli, but for real use I’d probably use an api library for the language

Note. Coming back to this about ten years later, a lot was missing. I re-ran every example and filled in the gaps. The output below is from Redis 7.0, so it may differ slightly from older versions.

The official docs to read are these two:


Before the types themselves

A few things apply to every key regardless of type. Not knowing these causes confusion later.

One key, one type

A key holds exactly one type. Create it as a string and run a list command on it and you get an error right away.

1
2
3
4
5
6
127.0.0.1:6379> set k hello
OK
127.0.0.1:6379> lpush k world
(error) WRONGTYPE Operation against a key holding the wrong kind of value
127.0.0.1:6379> hget k f
(error) WRONGTYPE Operation against a key holding the wrong kind of value

WRONGTYPE almost always means key names collided. That’s why the convention is to namespace key names with colons, like user:1000:name.

type / exists / del

When you don’t know what type a key holds, use type.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
127.0.0.1:6379> set mykey hello
OK
127.0.0.1:6379> type mykey
string
127.0.0.1:6379> exists mykey
(integer) 1
127.0.0.1:6379> del mykey
(integer) 1
127.0.0.1:6379> exists mykey
(integer) 0

Expiry (expire / ttl)

The most important one if you’re using Redis as a cache. expire sets seconds, ttl reads it back.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
127.0.0.1:6379> set mykey hello
OK
127.0.0.1:6379> expire mykey 60
(integer) 1
127.0.0.1:6379> ttl mykey
(integer) 60
127.0.0.1:6379> persist mykey
(integer) 1
127.0.0.1:6379> ttl mykey
(integer) -1

ttl has three kinds of return value, which trips people up:

  • positive: seconds remaining
  • -1: the key exists but has no expiry
  • -2: the key doesn’t exist at all
1
2
127.0.0.1:6379> ttl nokey
(integer) -2

Also, expiry is per key, not per field. There’s no way to expire a single field of a hash 30 seconds from now. Deleting a field leaves the key’s TTL alone.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
127.0.0.1:6379> hset cart:1 item1 2 item2 3
(integer) 2
127.0.0.1:6379> expire cart:1 100
(integer) 1
127.0.0.1:6379> ttl cart:1
(integer) 100
127.0.0.1:6379> hdel cart:1 item1
(integer) 1
127.0.0.1:6379> ttl cart:1
(integer) 100

scan, not keys

keys * is convenient, but Redis is single threaded, so with many keys the whole server stalls while it runs. In production use scan and walk a cursor.

1
2
3
4
5
127.0.0.1:6379> scan 0 match "user:*" count 100
1) "0"
2) 1) "user:1"
   2) "user:3"
   3) "user:2"

The first value is the next cursor; 0 means you’ve been all the way around. count is a hint rather than an exact size, so you can’t predict how many come back.


Strings

  • Stores characters, numbers, etc. as values
  • No separate type on save (no distinction between numbers and strings)
  • Numbers can be stored too, and atomic counter operations like incr, incrby, decr, decrby are possible on them
  • incrby, decrby add or subtract a specific number, used like incrby "test_strings" 10
1
2
3
4
5
6
7
8
9
# redis-cli
127.0.0.1:6379> set "test_strings" 1
OK
127.0.0.1:6379> get "test_strings"
"1"
127.0.0.1:6379> incr "test_strings"
(integer) 2
127.0.0.1:6379> get "test_strings"
"2"

Counters

Good enough for things like view counts. The incr family is atomic, so nothing is lost when several processes hit it at once. The point is not having to read, add one, and write back.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
127.0.0.1:6379> set page:view:home 0
OK
127.0.0.1:6379> incr page:view:home
(integer) 1
127.0.0.1:6379> incrby page:view:home 10
(integer) 11
127.0.0.1:6379> decrby page:view:home 3
(integer) 8
127.0.0.1:6379> get page:view:home
"8"

For decimals there’s incrbyfloat. Trailing zeros get dropped.

1
2
3
4
127.0.0.1:6379> incrbyfloat price 19.99
"19.99"
127.0.0.1:6379> incrbyfloat price 0.01
"20"

A non-numeric value is an error, naturally.

1
2
3
4
127.0.0.1:6379> set name paper
OK
127.0.0.1:6379> incr name
(error) ERR value is not an integer or out of range

Storing with expiry (sessions)

setex does set + expire in one shot. Useful for sessions or verification codes.

1
2
3
4
127.0.0.1:6379> setex session:abc 30 "user_1"
OK
127.0.0.1:6379> ttl session:abc
(integer) 30

nx as a rough lock

set key value nx ex 10 writes only if the key doesn’t exist and sets a 10 second expiry. If it’s already there you get (nil). Since that’s atomic it works as a simple distributed lock.

1
2
3
4
5
6
127.0.0.1:6379> set lock:job1 worker-a nx ex 10
OK
127.0.0.1:6379> set lock:job1 worker-b nx ex 10
(nil)
127.0.0.1:6379> ttl lock:job1
(integer) 10

Releasing it with a plain del is wrong: your lock may have already expired and you’d delete someone else’s. The usual approach is to store an identifier in the value and compare-then-delete in Lua. For a proper implementation you want the Redlock document.

Several at once

To cut round trips, mset / mget. Missing keys come back as (nil) in place.

1
2
3
4
5
6
127.0.0.1:6379> mset user:1:name paper user:1:city seoul
OK
127.0.0.1:6379> mget user:1:name user:1:city user:1:none
1) "paper"
2) "seoul"
3) (nil)

String manipulation

You can append to a value or read a slice of it. Handy for accumulating logs.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
127.0.0.1:6379> append log "hello"
(integer) 5
127.0.0.1:6379> append log " world"
(integer) 11
127.0.0.1:6379> get log
"hello world"
127.0.0.1:6379> strlen log
(integer) 11
127.0.0.1:6379> getrange log 0 4
"hello"

Lists

  • Stores a list as the value
  • lrange: reads values, where -1 means fetch everything
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
127.0.0.1:6379> lpush "test_lists" 1
(integer) 1
127.0.0.1:6379> lpush test_lists 2
(integer) 2
127.0.0.1:6379> lrange test_lists 0 -1
1) "2"
2) "1"
127.0.0.1:6379> rpush test_lists 3
(integer) 3
127.0.0.1:6379> lrange test_lists 0 -1
1) "2"
2) "1"
3) "3"

lpush pushes on the left (head), rpush on the right (tail). That’s why pushing 1 then 2 with lpush puts 2 in front.

Length and positional access are available too. Negative indexes count from the end.

1
2
3
4
5
6
127.0.0.1:6379> llen test_lists
(integer) 3
127.0.0.1:6379> lindex test_lists 0
"2"
127.0.0.1:6379> lindex test_lists -1
"3"

Queues

  • Seems like you could implement a queue with rpop
1
2
3
4
5
127.0.0.1:6379> rpop test_lists
"3"
127.0.0.1:6379> lrange test_lists 0 -1
1) "2"
2) "1"

lpush in and rpop out gives a FIFO queue; lpush / lpop gives a stack.

Blocking with brpop

With brpop it seems you could even implement sequential distributed work. Similar to rpop but if there’s no data it waits in a block state until data comes in, so you don’t have to poll an empty list.

Run this in one terminal and it just sits there:

1
127.0.0.1:6379> brpop test_lists 0

Push from another terminal and it returns. You get the key it came from as well:

1
2
127.0.0.1:6379> rpush test_lists hello
(integer) 1
1
2
3
# the waiting side
1) "test_lists"
2) "hello"

The last argument is a timeout in seconds; 0 waits forever. On timeout you get (nil).

1
2
127.0.0.1:6379> brpop empty_list 1
(nil)

ltrim to keep only the last N

For things like “3 recently viewed items”. lpush then ltrim so the list doesn’t grow without bound.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
127.0.0.1:6379> lpush recent:user:1 page1 page2 page3 page4 page5
(integer) 5
127.0.0.1:6379> ltrim recent:user:1 0 2
OK
127.0.0.1:6379> lrange recent:user:1 0 -1
1) "page5"
2) "page4"
3) "page3"
127.0.0.1:6379> lpush recent:user:1 page6
(integer) 4
127.0.0.1:6379> ltrim recent:user:1 0 2
OK
127.0.0.1:6379> lrange recent:user:1 0 -1
1) "page6"
2) "page5"
3) "page4"

ltrim takes the range to keep, not the range to remove. Easy to get backwards.

Removing values, inserting in the middle

lrem key count value removes up to count occurrences from the front. Zero means all.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
127.0.0.1:6379> rpush q a b a c a
(integer) 5
127.0.0.1:6379> lrem q 2 a
(integer) 2
127.0.0.1:6379> lrange q 0 -1
1) "b"
2) "c"
3) "a"
127.0.0.1:6379> linsert q before c x
(integer) 4
127.0.0.1:6379> lrange q 0 -1
1) "b"
2) "x"
3) "c"
4) "a"

lmove for a queue that doesn’t drop work

If a worker dies after rpop, that job is simply gone. lmove pops and pushes onto another list atomically, so the job stays in a processing list and can be recovered.

1
2
3
4
5
6
7
8
127.0.0.1:6379> rpush jobs job1 job2
(integer) 2
127.0.0.1:6379> lmove jobs processing left right
"job1"
127.0.0.1:6379> lrange jobs 0 -1
1) "job2"
127.0.0.1:6379> lrange processing 0 -1
1) "job1"

When the job finishes, lrem it from processing. The old command was rpoplpush, whose direction was fixed, which is why lmove was added (6.2+). The blocking version is blmove.


Sets

  • Holds the value as a set
  • Lists allow duplicates but sets don’t
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
127.0.0.1:6379> sadd test_sets 1
(integer) 1
127.0.0.1:6379> smembers test_sets
1) "1"
127.0.0.1:6379> sadd test_sets 1 2 3 4
(integer) 3
127.0.0.1:6379> smembers test_sets
1) "1"
2) "2"
3) "3"
4) "4"

Note that sadd returns 3, not 4. It counts only the members actually added; 1 was already there.

Order isn’t guaranteed. It looking sorted above is a coincidence.

Cardinality, membership, removal

Don’t pull everything with smembers to count or search. There are dedicated commands.

1
2
3
4
5
6
7
8
127.0.0.1:6379> scard test_sets
(integer) 4
127.0.0.1:6379> sismember test_sets 3
(integer) 1
127.0.0.1:6379> sismember test_sets 99
(integer) 0
127.0.0.1:6379> srem test_sets 4
(integer) 1

sismember is O(1), which makes it a good fit for checks like “has this user seen this”.

Set operations

This is the real reason to use a set. Intersection, union and difference are done server side.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
127.0.0.1:6379> sadd tag:redis post1 post2 post3
(integer) 3
127.0.0.1:6379> sadd tag:java post2 post3 post4
(integer) 3
127.0.0.1:6379> sinter tag:redis tag:java
1) "post3"
2) "post2"
127.0.0.1:6379> sunion tag:redis tag:java
1) "post2"
2) "post3"
3) "post1"
4) "post4"
127.0.0.1:6379> sdiff tag:redis tag:java
1) "post1"

To store the result under a key, use sinterstore. It returns the number stored.

1
2
3
4
5
127.0.0.1:6379> sinterstore tag:both tag:redis tag:java
(integer) 2
127.0.0.1:6379> smembers tag:both
1) "post2"
2) "post3"

If you only need the count, sintercard (7.0+) is cheaper because it never materializes the result. With limit it stops counting there.

1
2
3
4
127.0.0.1:6379> sintercard 2 A B
(integer) 2
127.0.0.1:6379> sintercard 2 A B limit 1
(integer) 1

Set operations get heavy with many members, so running them live across large sets is best avoided.

Random members

srandmember only reads; spop removes what it returns. Useful for draws.

1
2
3
4
5
6
7
127.0.0.1:6379> srandmember tag:redis
"post1"
127.0.0.1:6379> srandmember tag:redis 2
1) "post3"
2) "post1"
127.0.0.1:6379> spop tag:java
"post3"

Hashes

  • Hashes hold a key/value list as the value
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
127.0.0.1:6379> hset htest username hi
(integer) 1
127.0.0.1:6379> hset htest userpwd asdf
(integer) 1
127.0.0.1:6379> hget htest username
"hi"
127.0.0.1:6379> hgetall htest
1) "username"
2) "hi"
3) "userpwd"
4) "asdf"

hgetall returns fields and values alternating. Client libraries fold that into a map for you.

  • hget returns (nil) if there’s no value
1
2
127.0.0.1:6379> hget htest temp
(nil)
  • Changing the value for a hashkey
1
2
3
4
5
6
127.0.0.1:6379> hget htest userpwd
"asdf"
127.0.0.1:6379> hset htest userpwd 1234
(integer) 0
127.0.0.1:6379> hget htest userpwd
"1234"

That 0 isn’t a failure — it means the field already existed and was overwritten rather than newly created.

Multiple fields at once

hset takes any number of field/value pairs (4.0+). The old hmset is deprecated.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
127.0.0.1:6379> hset user:1000 name paper city seoul age 20
(integer) 3
127.0.0.1:6379> hmget user:1000 name city
1) "paper"
2) "seoul"
127.0.0.1:6379> hkeys user:1000
1) "name"
2) "city"
3) "age"
127.0.0.1:6379> hvals user:1000
1) "paper"
2) "seoul"
3) "20"
127.0.0.1:6379> hlen user:1000
(integer) 3
127.0.0.1:6379> hexists user:1000 email
(integer) 0
127.0.0.1:6379> hdel user:1000 age
(integer) 1

Per-field counters

hincrby increments a single field. Good for things like a login count.

1
2
3
4
127.0.0.1:6379> hincrby user:1000 login_count 1
(integer) 1
127.0.0.1:6379> hincrby user:1000 login_count 5
(integer) 6

A missing field starts from 0, so there’s no need to initialize it.

hsetnx writes only when the field is absent.

1
2
3
4
127.0.0.1:6379> hsetnx user:1000 name other
(integer) 0
127.0.0.1:6379> hsetnx user:1000 email a@b.c
(integer) 1

Why a hash instead of several strings

Instead of scattering user:1:name, user:1:city and so on as separate strings, grouping them into one hash saves a fair amount of memory. Measuring five fields directly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# five strings
127.0.0.1:6379> memory usage s:user:1:name
(integer) 72
127.0.0.1:6379> memory usage s:user:1:age
(integer) 56
# 344 bytes for all five

# one hash
127.0.0.1:6379> memory usage h:user:1
(integer) 120

Same data, 344 vs 120. The per-key overhead disappears. At a million users that difference adds up.

Only cheap while small

With few fields a hash is stored as a listpack, a flat array. Past a threshold it converts to a real hash table and memory jumps.

1
2
3
127.0.0.1:6379> config get hash-max-listpack-entries
1) "hash-max-listpack-entries"
2) "512"

Measuring 512 fields against 600:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 512 fields
127.0.0.1:6379> object encoding big
"listpack"
127.0.0.1:6379> memory usage big
(integer) 6192

# 600 fields
127.0.0.1:6379> object encoding big
"hashtable"
127.0.0.1:6379> memory usage big
(integer) 32296

17% more fields, 5x the memory. If you’re thinking of packing thousands of fields into one hash, splitting keys is probably better. (Older versions called this encoding ziplist, with the setting hash-max-ziplist-entries.)


Sorted sets

  • Holds the value as a set
  • Like Sets, no duplicates
  • Stored together with a score, and sorted by score
  • Seems usable like a list, with the advantage of being sorted
1
2
3
4
5
6
7
127.0.0.1:6379> zadd test_ssets 1 1
(integer) 1
127.0.0.1:6379> zadd test_ssets 2 2
(integer) 1
127.0.0.1:6379> zrange test_ssets 0 -1
1) "1"
2) "2"
  • Don’t use a string for the score
1
2
127.0.0.1:6379> zadd test_ssets "a" 2
(error) ERR value is not a valid float
  • Since duplicates aren’t allowed, inserting the same value overwrites the existing data’s score and the order changes
1
2
3
4
5
127.0.0.1:6379> zadd test_ssets "0" 2
(integer) 0
127.0.0.1:6379> zrange test_ssets 0 -1
1) "2"
2) "1"

Add withscores to see the scores too, which you need when checking how things got ordered.

1
2
3
4
5
127.0.0.1:6379> zrange test_ssets 0 -1 withscores
1) "2"
2) "0"
3) "1"
4) "1"

The argument order is zadd key score member, score first. Coming from sadd you’ll keep writing it backwards.

Leaderboards

The obvious use. Add scores, slice in reverse.

1
2
3
4
5
6
7
8
9
127.0.0.1:6379> zadd rank 100 paper 250 kim 175 lee 90 park
(integer) 4
127.0.0.1:6379> zrevrange rank 0 2 withscores
1) "kim"
2) "250"
3) "lee"
4) "175"
5) "paper"
6) "100"

zrange goes low to high, zrevrange high to low. For rank and score on their own:

1
2
3
4
5
6
7
8
127.0.0.1:6379> zscore rank lee
"175"
127.0.0.1:6379> zrank rank lee
(integer) 2
127.0.0.1:6379> zrevrank rank lee
(integer) 1
127.0.0.1:6379> zcard rank
(integer) 4

zrank is zero based, so add one before displaying it.

To add to a score, zincrby. Same idea as incrby on a string, except the ordering follows automatically.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
127.0.0.1:6379> zincrby rank 200 park
"290"
127.0.0.1:6379> zrevrange rank 0 -1 withscores
1) "park"
2) "290"
3) "kim"
4) "250"
5) "lee"
6) "175"
7) "paper"
8) "100"

Selecting by score range

Where zrange works on rank (index), zrangebyscore works on score. A leading ( makes the bound exclusive.

1
2
3
4
5
6
7
8
9
127.0.0.1:6379> zrangebyscore rank 100 250
1) "paper"
2) "lee"
3) "kim"
127.0.0.1:6379> zrangebyscore rank "(100" 250
1) "lee"
2) "kim"
127.0.0.1:6379> zcount rank 100 250
(integer) 3

Open the range with -inf / +inf, and page with limit.

1
2
3
4
5
6
127.0.0.1:6379> zrangebyscore rank -inf +inf limit 0 2
1) "park"
2) "paper"
127.0.0.1:6379> zrangebyscore rank -inf +inf limit 2 2
1) "lee"
2) "kim"

Deletion comes in both flavors: by score (zremrangebyscore) and by rank (zremrangebyrank).

A timestamp as the score gives you a delayed queue

Use the score as a timestamp instead of a score and you can pull out “jobs due now”. Sorted sets get used for this as much as for rankings.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
127.0.0.1:6379> zadd delayed 1700000000 job:a 1700000060 job:b 1700003600 job:c
(integer) 3
127.0.0.1:6379> zrangebyscore delayed -inf 1700000100
1) "job:a"
2) "job:b"
127.0.0.1:6379> zremrangebyrank delayed 0 1
(integer) 2
127.0.0.1:6379> zrange delayed 0 -1 withscores
1) "job:c"
2) "1700003600"

Reading and removing are separate steps, so multiple workers can collide. Wrap it in Lua or use zpopmin.

Keeping only the best score

A plain zadd always overwrites, which can lower a score. gt updates only when the new score is greater (6.2+). ch makes the return value count changes rather than insertions.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
127.0.0.1:6379> zadd best 100 paper
(integer) 1
127.0.0.1:6379> zadd best gt ch 50 paper
(integer) 0
127.0.0.1:6379> zscore best paper
"100"
127.0.0.1:6379> zadd best gt ch 300 paper
(integer) 1
127.0.0.1:6379> zscore best paper
"300"

nx writes only when absent, xx only when present.

1
2
3
4
127.0.0.1:6379> zadd best nx 999 paper
(integer) 0
127.0.0.1:6379> zscore best paper
"300"

Bitmaps

  • Stores bit values
  • Can store 2^32 (4.2 billion) bit values in 512MB
  • Seems good for storing boolean option values (like whether each member has viewed a notice)
1
2
3
4
5
6
7
8
127.0.0.1:6379> setbit test_bits 0 1
(integer) 0
127.0.0.1:6379> getbit test_bits 0
(integer) 1
127.0.0.1:6379> setbit test_bits 0 0
(integer) 1
127.0.0.1:6379> getbit test_bits 0
(integer) 0

setbit returns the previous value. It isn’t really a separate type — it’s a string viewed bit by bit.

Daily visits

Make a key per day and use the user id as the bit offset. bitcount gives that day’s visitor count directly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
127.0.0.1:6379> setbit visit:20160727 100 1
(integer) 0
127.0.0.1:6379> setbit visit:20160727 200 1
(integer) 0
127.0.0.1:6379> setbit visit:20160727 300 1
(integer) 0
127.0.0.1:6379> setbit visit:20160728 200 1
(integer) 0
127.0.0.1:6379> setbit visit:20160728 400 1
(integer) 0
127.0.0.1:6379> bitcount visit:20160727
(integer) 3
127.0.0.1:6379> bitcount visit:20160728
(integer) 2

Visitors on both days is bitop and; on either day is bitop or.

1
2
3
4
5
6
7
8
127.0.0.1:6379> bitop and visit:both visit:20160727 visit:20160728
(integer) 51
127.0.0.1:6379> bitcount visit:both
(integer) 1
127.0.0.1:6379> bitop or visit:any visit:20160727 visit:20160728
(integer) 51
127.0.0.1:6379> bitcount visit:any
(integer) 4

The 51 that bitop returns is the byte length of the result string, not a count of people. Count with bitcount separately.

The first set bit is bitpos.

1
2
127.0.0.1:6379> bitpos visit:20160727 1
(integer) 100

How much it actually saves

One day of visit flags for a million users:

1
2
3
4
5
6
127.0.0.1:6379> setbit big 999999 1
(integer) 0
127.0.0.1:6379> strlen big
(integer) 125000
127.0.0.1:6379> memory usage big
(integer) 131120

122KB. The same thing as a Set has to hold a million members, which runs to tens of MB. The tradeoff is that you can’t list who visited without walking the bits, and ids have to be dense integers for this to pay off. With UUIDs it doesn’t work.


HyperLogLog

The rest wasn’t in the original post, but it belongs here.

Use it when you only need a distinct count (cardinality). It doesn’t store the members; it estimates probabilistically and uses almost no memory. In exchange the number isn’t exact and you can’t read members back out.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
127.0.0.1:6379> pfadd uv:20160727 user1 user2 user3
(integer) 1
127.0.0.1:6379> pfadd uv:20160727 user1
(integer) 0
127.0.0.1:6379> pfcount uv:20160727
(integer) 3
127.0.0.1:6379> pfadd uv:20160728 user3 user4
(integer) 1
127.0.0.1:6379> pfmerge uv:total uv:20160727 uv:20160728
OK
127.0.0.1:6379> pfcount uv:total
(integer) 4

pfmerge combining daily counters into weekly or monthly UV is the nice part. With a Set you’d have to keep all the originals around.

I put 100,000 members in and compared against a Set:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# HyperLogLog
127.0.0.1:6379> pfcount uv:big
(integer) 100420
127.0.0.1:6379> memory usage uv:big
(integer) 14384

# same data as a Set
127.0.0.1:6379> scard uv:set
(integer) 100000
127.0.0.1:6379> memory usage uv:set
(integer) 4772968

The real count was 100,000 and it reported 100,420 — off by 0.42%. In return, 14KB against 4.6MB, a 330x memory difference. The documented standard error is 0.81%, so that’s about right.

Good for metrics like UV where “roughly how many” is enough. Don’t use it for billing or settlement.


Geo

Store coordinates and query distance or radius. Added in 3.2.

1
2
3
4
5
6
7
127.0.0.1:6379> geoadd stores 126.9784 37.5665 cityhall 127.0276 37.4979 gangnam 126.9245 37.5563 hongdae
(integer) 3
127.0.0.1:6379> geodist stores cityhall gangnam km
"8.7777"
127.0.0.1:6379> geopos stores gangnam
1) 1) "127.02759772539138794"
   2) "37.4979006128308967"

Longitude comes first, latitude second. Map APIs usually do the reverse, which is confusing.

Radius search is geosearch (6.2+). The older georadius is deprecated.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
127.0.0.1:6379> geosearch stores frommember cityhall byradius 6 km asc withdist
1) 1) "cityhall"
   2) "0.0000"
2) 1) "hongdae"
   2) "4.8860"
127.0.0.1:6379> geosearch stores fromlonlat 127.0276 37.4979 byradius 20 km asc withdist withcoord
1) 1) "gangnam"
   2) "0.0002"
   3) 1) "127.02759772539138794"
      2) "37.4979006128308967"
2) 1) "cityhall"
   2) "8.7779"
   3) 1) "126.97840064764022827"
      2) "37.56650030628724579"
3) 1) "hongdae"
   2) "11.1760"
   3) 1) "126.92449897527694702"
      2) "37.55630058834207574"

Notice the coordinates that come back differ slightly from what went in — geohash encoding loses precision. Not a fit if you need meter-level accuracy.

It isn’t a new type; underneath it’s a sorted set with a geohash as the score.

1
2
3
4
5
6
7
8
9
127.0.0.1:6379> type stores
zset
127.0.0.1:6379> zrange stores 0 -1 withscores
1) "gangnam"
2) "4077553489665188"
3) "hongdae"
4) "4077564662109830"
5) "cityhall"
6) "4077564854920134"

Which is why you remove entries with zrem — there’s no geo-specific delete command.

Non-ASCII members are displayed escaped by redis-cli, like "\xed\x99\x8d...". The data isn’t corrupted, only the display; redis-cli --raw shows it properly.


Streams

A log-shaped type added in 5.0, much later than the six above. A List used as a queue loses the item the moment you pop it; a Stream keeps entries like a log and lets several consumers each track their own position. Roughly Kafka shaped.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
127.0.0.1:6379> xadd events "*" type login user 1000
"1788327172134-0"
127.0.0.1:6379> xadd events "*" type logout user 1000
"1788327172139-0"
127.0.0.1:6379> xlen events
(integer) 2
127.0.0.1:6379> xrange events - +
1) 1) "1788327172134-0"
   2) 1) "type"
      2) "login"
      3) "user"
      4) "1000"
2) 1) "1788327172139-0"
   2) 1) "type"
      2) "logout"
      3) "user"
      4) "1000"

* auto-generates the id. The milliseconds-sequence format means several entries in the same millisecond still don’t collide. Each entry holds field/value pairs like a hash.

Consumer groups

Workers in the same group split the messages. What a worker reads stays “pending” until it calls xack. If a worker dies the entry is still pending, so another worker can claim it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
127.0.0.1:6379> xgroup create events workers 0
OK
127.0.0.1:6379> xreadgroup group workers worker-1 count 1 streams events ">"
1) 1) "events"
   2) 1) 1) "1788327172134-0"
         2) 1) "type"
            2) "login"
            3) "user"
            4) "1000"
127.0.0.1:6379> xpending events workers
1) (integer) 1
2) "1788327172134-0"
3) "1788327172134-0"
4) 1) 1) "worker-1"
      2) "1"

> means “entries nobody has claimed yet”. After xack the pending list empties.

1
2
3
4
5
6
7
127.0.0.1:6379> xack events workers 1788327172134-0
(integer) 1
127.0.0.1:6379> xpending events workers
1) (integer) 0
2) (nil)
3) (nil)
4) (nil)

This is the main difference from a List queue: entries don’t vanish on read, they require an ack.

Capping growth

Being a log, it grows forever if you let it. maxlen sets an upper bound. With ~ it trims to approximately that many rather than exactly, which is much cheaper.

1
2
127.0.0.1:6379> xadd events maxlen "~" 1000 "*" type ping
"1788327172184-0"

Which type for what

TypeGood forWatch out
StringsCache, counters, sessions, simple locksLarge values move whole
ListsQueues, recent-item listsMiddle access is O(N)
SetsTags, dedup, intersectionsSet ops get heavy with many members
HashesGrouping one objectMemory jumps past thousands of fields
Sorted setsRankings, delayed queues, range queriesScores are doubles, large integers lose precision
BitmapsBulk booleans (visits, seen flags)Only pays off with dense integer ids
HyperLogLogApproximate distinct counts like UV0.81% error, members not retrievable
GeoRadius searchCoordinate precision loss, really a zset
StreamsEvent logs, queues needing ackGrows forever without maxlen

Roughly, reason about it in this order:

  • One value: String
  • Several fields as one unit: Hash
  • Order matters: List. Rank or range matters: Sorted set
  • Deduplication is the point: Set. Only the count, and approximate is fine: HyperLogLog
  • One on/off flag per user: Bitmap
  • Failures have to be recoverable: Stream, not List

References