기록 저장소/Opensource
Opensource

redis data type 종류

목차

Redis Data Type 요약

인터넷상에 수많은 좋은 자료가 있음에도 공부 차원에서 정리를 한다

테스트는 redis-cli를 이용해서 하지만 실제 사용은 언어에 맞는 api library를 사용 할듯

덧. 10년쯤 지나서 다시 보니 빠진게 너무 많다. 예제를 전부 다시 돌려서 보강했다. 아래 출력은 redis 7.0 에서 직접 찍은 거라 옛날 버전이랑 조금 다를 수 있다. 그때는 타입 여섯개면 다 아는줄 알았지

공식 문서는 이 두 개를 보면 된다


타입 얘기 전에 알아둘 것

타입별로 들어가기 전에 key 에 공통으로 걸리는 것들이 있다. 이걸 모르고 시작하면 나중에 헷갈린다

key 하나에 타입 하나

key 는 타입을 하나만 가진다. string 으로 만들어놓고 list 명령을 쓰면 바로 에러가 난다

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 이 뜨면 십중팔구 key 이름이 겹친 거다. 그래서 key 이름을 user:1000:name 처럼 콜론으로 네임스페이스를 나눠 쓰는 관례가 있는듯

type / exists / del

지금 이 key 가 무슨 타입인지 모르겠으면 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

만료 (expire / ttl)

캐시로 쓸꺼면 이게 제일 중요하다. expire 로 초를 걸고 ttl 로 확인한다

 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 반환값이 세 종류라 헷갈린다

  • 양수: 남은 초
  • -1: key 는 있는데 만료가 안 걸려있음
  • -2: key 자체가 없음
1
2
127.0.0.1:6379> ttl nokey
(integer) -2

그리고 만료는 key 단위지 필드 단위가 아니다. hash 안의 필드 하나만 30초 뒤에 지우고 싶어도 그런건 없다. 지워도 key 의 ttl 은 그대로다

 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

keys 말고 scan

keys * 는 편한데 redis 가 싱글 스레드라 key 가 많으면 그동안 서버가 멈춘다. 운영에서는 scan 으로 커서를 돌려가며 조금씩 가져와야 한다

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"

첫번째 값이 다음 커서다. 0 이 나오면 한 바퀴 다 돈 것. count 는 정확한 개수가 아니고 힌트 정도라 실제로 몇개가 올지는 모른다


Strings

  • value에 문자 숫자 등을 저장한다
  • 저장시 별도로 형이 없다 (숫자 문자 구분이 없음)
  • 숫자도 저장가능 하다 그리고 숫자에 incr, incrby, decr, decrby 같은 atomic counter 연산이 가능
  • incrby, decrby 는 특정 수를 더하거나 뺄때 사용 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"

카운터

조회수 같은거 세는데 그냥 쓰면 된다. incr 계열은 atomic 이라 여러 프로세스가 동시에 때려도 안 샌다. 읽어서 +1 하고 다시 쓰는 짓을 안 해도 되는게 핵심

 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"

소수는 incrbyfloat. 딱 떨어지면 뒤가 잘려서 나온다

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"

값이 숫자가 아니면 당연히 에러

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

만료 붙여서 저장 (세션)

setexset + expire 를 한 방에 한다. 세션이나 인증 코드 같은데 쓰면 된다

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 로 락 비슷한거

set key value nx ex 10key 가 없을 때만 쓰고 10초 만료를 건다. 이미 있으면 (nil) 이 온다. 이게 atomic 이라 간단한 분산 락으로 쓸 수 있을듯

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

풀 때는 그냥 del 하면 안된다. 내가 건 락이 만료된 뒤 남이 잡은 락을 지워버릴 수 있어서.. 보통 value 에 자기 식별자를 넣고 lua 로 비교 후 삭제한다. 제대로 하려면 Redlock 문서를 봐야 할꺼 같다

한번에 여러개

왕복 횟수를 줄이려면 mset / mget. 없는 key 는 (nil) 자리로 온다

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)

문자열 조작

값 뒤에 붙이거나 잘라서 볼 수도 있다. 로그 같은거 이어붙일 때

 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

  • value에 list를 저장한다
  • lrange: 값을 조회 이때 -1은 모두 가져오라는 뜻
 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 는 왼쪽(머리), rpush 는 오른쪽(꼬리)에 넣는다. 그래서 위에서 1, 2 를 lpush 했더니 2가 앞으로 온 것

길이랑 특정 위치 값도 볼 수 있다. 인덱스에 음수를 쓰면 뒤에서부터

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"

  • rpop를 이용하여 queue 구현이 가능할꺼 같다
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 로 넣고 rpop 으로 빼면 FIFO 큐, lpush / lpop 이면 스택이 된다

brpop 으로 블로킹

brpop 을 쓰면 순차적인 분산 작업도 구현 가능할꺼 같다. rpop과 비슷하나 데이타가 없다면 데이타가 들어올때까지 block 상태로 대기한다. 빈 리스트에 rpop 을 계속 때리는 폴링을 안 해도 된다

터미널 하나에서 이걸 실행하면 그냥 멈춰있는다

1
127.0.0.1:6379> brpop test_lists 0

다른 터미널에서 넣어주면 그제서야 반환된다. 어느 key 에서 나왔는지도 같이 온다

1
2
127.0.0.1:6379> rpush test_lists hello
(integer) 1
1
2
3
# 대기중이던 쪽
1) "test_lists"
2) "hello"

마지막 인자가 타임아웃(초)이고 0 이면 무한 대기다. 시간이 지나면 (nil)

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

ltrim 으로 최근 N개만 유지

“최근 본 상품 3개” 같은거. lpush 하고 ltrim 으로 잘라내면 리스트가 계속 커지지 않는다

 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 은 남길 범위를 주는거지 지울 범위를 주는게 아니다. 이거 자주 헷갈린다

값 지우기 / 중간에 넣기

lrem key count value 는 앞에서부터 count 개만큼 지운다. count 가 0 이면 전부

 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 로 안 잃어버리는 큐

rpop 으로 꺼낸 다음 워커가 죽으면 그 작업은 그냥 사라진다. lmove 는 꺼내면서 동시에 다른 리스트에 넣는걸 atomic 하게 한다. 처리중 리스트에 남아있으니 죽어도 복구가 된다

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"

작업이 끝나면 processing 에서 lrem 으로 지우면 된다. 옛날엔 rpoplpush 를 썼는데 방향이 고정이라 lmove 가 나왔다 (6.2+). 블로킹 버전은 blmove


Sets

  • value을 set형태로 가지고 있음
  • list는 중복이 되나 set은 중복이 안됨
 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"

sadd 반환값이 4가 아니고 3인게 포인트다. 실제로 새로 들어간 개수만 센다. 1은 이미 있어서 안 세짐

순서는 보장 안된다. 위에서 정렬돼 보이는건 우연이다

개수 / 있는지 / 지우기

smembers 로 다 가져와서 세거나 찾으면 안된다. 전용 명령이 있다

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 가 O(1) 이라 “이 유저가 이거 봤나” 같은 체크에 딱이다

집합 연산

이게 set 을 쓰는 진짜 이유인듯. 교집합 합집합 차집합을 서버가 해준다

 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"

결과를 다시 key 로 저장하려면 sinterstore. 반환값은 저장된 개수

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"

개수만 필요하면 sintercard (7.0+). 결과를 다 만들지 않아서 더 싸다. limit 을 주면 거기까지만 세고 멈춘다

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 끼리 실시간으로 돌리는건 피하는게 좋을듯

랜덤 뽑기

srandmember 는 보고만 오고 spop 은 뽑으면서 지운다. 추첨 같은데 쓰면 된다

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

  • Hashs key/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 은 필드와 값이 번갈아 나온다. 라이브러리 쓰면 알아서 map 으로 묶어준다

  • hget시에 값이 없다면 (nil)을 반환
1
2
127.0.0.1:6379> hget htest temp
(nil)
  • 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"

반환값 0 은 실패가 아니고 새 필드가 아니라 덮어쓴 것이라는 뜻이다

여러 필드 한번에

hset 에 필드/값 쌍을 계속 붙여쓸 수 있다 (4.0+). 예전에 쓰던 hmset 은 폐기됨

 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

필드 단위 카운터

hincrby 로 필드 하나만 올릴 수 있다. 로그인 횟수 같은거

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

없던 필드면 0에서 시작한다. 미리 만들어둘 필요가 없다

hsetnx 는 필드가 없을 때만 쓴다

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

왜 string 여러개 말고 hash 인가

user:1:name, user:1:city … 이렇게 string 을 흩뿌리는 대신 hash 하나로 묶으면 메모리가 꽤 줄어든다. 필드 5개를 직접 재보면

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# string 5개
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
# 5개 합쳐서 344 bytes

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

같은 데이터인데 344 vs 120. key 하나마다 붙는 오버헤드가 없어져서 그렇다. 유저가 백만이면 이 차이가 꽤 커진다

작을 때만 싸다

hash 는 필드가 적으면 listpack 이라는 납작한 배열로 저장한다. 개수가 임계치를 넘으면 진짜 해시테이블로 바뀌고 메모리가 확 뛴다

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

512개랑 600개를 각각 넣고 재보면

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

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

필드는 17% 늘었는데 메모리는 5배가 됐다. 한 hash 에 수천개씩 몰아넣을 생각이면 차라리 key 를 쪼개는게 나을꺼 같다. (옛날 버전은 이 인코딩 이름이 ziplist 였고 설정도 hash-max-ziplist-entries 였다)


Sorted sets

  • value를 set형태로 가지고 있음
  • Sets과 마찬가지로 중복은 안됨
  • score와 함께 저장되며 score를 기준으로 정렬됨
  • list처럼 사용이 될꺼 같으나 정렬된다는 장점이 있는 것 같다
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"
  • score를 문자형으로 쓰면 안된다
1
2
127.0.0.1:6379> zadd test_ssets "a" 2
(error) ERR value is not a valid float
  • 중복이 안되면 동일한 value를 넣으면 기존 데이타의 score를 덮어서 데이타 순서가 바뀐다
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"

withscores 를 붙이면 score 도 같이 온다. 뭐가 어떻게 정렬됐는지 볼 때 필요하다

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

인자 순서가 zadd key score member 라 score 가 먼저다. sadd 하다가 넘어오면 자꾸 반대로 쓰게 된다..

랭킹보드

sorted set 하면 이게 제일 먼저 떠오른다. 점수 넣고 역순으로 자르면 끝

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 는 낮은 점수부터, zrevrange 는 높은 점수부터다. 등수랑 점수만 따로 보려면

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 는 0부터 시작하니 화면에 뿌릴 때 1을 더해야 한다

점수를 더할 때는 zincrby. string 의 incrby 랑 같은 느낌인데 순위가 자동으로 따라온다

 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"

점수 범위로 뽑기

zrange 가 순위(인덱스) 기준이면 zrangebyscore 는 점수 기준이다. ( 를 붙이면 그 값은 뺀다

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

전체는 -inf / +inf 로 열고, 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"

지우는 것도 점수 범위(zremrangebyscore)와 순위 범위(zremrangebyrank) 두 가지가 있다

score 에 시간을 넣으면 지연 큐

score 를 점수가 아니라 timestamp 로 쓰면 “지금 실행할 작업” 을 뽑아낼 수 있다. sorted set 이 랭킹 말고 이런 데도 잘 쓰인다

 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"

뽑기랑 지우기가 따로라 워커가 여럿이면 겹칠 수 있다. lua 로 묶거나 zpopmin 을 쓰는게 안전할듯

최고점만 남기기

그냥 zadd 하면 무조건 덮어써서 점수가 내려갈 수도 있다. gt 를 주면 더 클 때만 갱신한다 (6.2+). ch 는 반환값을 “바뀐 개수” 로 바꿔준다

 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 는 없을 때만, xx 는 있을 때만 쓴다

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

  • bit값을 저장해준다
  • 512MB 용량으로 2^32(42억)개의 bit값들을 저장할 수 있다
  • boolean 옵션값을 저장하는 용도로 사용하면 좋을거 같다 (회원마다 공지 조회여부 등)
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 반환값은 바꾸기 전 값이다. 새 타입은 아니고 사실 string 을 비트 단위로 보는 것

출석 체크

날짜별 key 를 만들고 유저 id 를 비트 위치로 쓴다. bitcount 로 그날 방문자 수가 바로 나온다

 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

이틀 다 온 사람은 bitop and, 하루라도 온 사람은 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

bitop 의 반환값 51은 사람 수가 아니라 결과 문자열의 바이트 길이다. 사람 수는 bitcount 로 따로 세야 한다

처음 1이 나오는 위치는 bitpos

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

얼마나 아끼나

유저 백만명 방문 여부를 하루치 담아보면

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. 같은걸 Set 으로 하면 백만개 원소를 다 들고 있어야 하니 수십 MB 는 간다. 대신 “누가 왔는지” 목록은 못 뽑고 비트를 훑어야 한다. id 가 연속된 정수여야 효율이 나오는 것도 제약이다. uuid 쓰면 못 쓴다


HyperLogLog

여기부터는 원래 글에 없던 건데 같이 정리해둔다

중복 없는 개수(cardinality)만 알고 싶을 때 쓴다. 원소를 저장하지 않고 확률적으로 추정해서 메모리를 거의 안 쓴다. 대신 값이 정확하지 않고 멤버를 다시 꺼낼 수도 없다

 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 로 일별을 합쳐서 주간/월간 UV 를 만들 수 있는게 좋다. Set 으로 하면 원본을 다 들고 있어야 하는 일이라..

10만개를 넣고 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

# 같은 데이터를 Set 으로
127.0.0.1:6379> scard uv:set
(integer) 100000
127.0.0.1:6379> memory usage uv:set
(integer) 4772968

실제 10만인데 100420 이 나왔다. 0.42% 틀렸다. 대신 14KB vs 4.6MB 로 메모리는 330배 차이다. 표준오차가 0.81% 라고 하니 대체로 저 정도인듯

UV 처럼 “대충 몇명” 이면 되는데 정확히는 필요 없는 지표에 쓰면 좋을꺼 같다. 과금이나 정산에 쓰면 안된다


Geo

좌표를 넣고 거리나 반경 검색을 한다. 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"

경도가 먼저고 위도가 나중이다. 지도 API 는 보통 반대라 헷갈린다

반경 검색은 geosearch (6.2+). 예전 georadius 는 폐기 예정이다

 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"

넣은 좌표랑 꺼낸 좌표가 미묘하게 다른게 보인다. geohash 로 인코딩하면서 정밀도가 깎여서 그렇다. 미터 단위로 정확해야 하는 용도면 안 맞을듯

새 타입이 아니고 실체는 sorted set 이다. score 에 geohash 를 넣은 것

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"

그래서 zrem 으로 지우면 된다. geo 전용 삭제 명령이 없는 이유가 이거다

한글 멤버를 넣으면 redis-cli"\xed\x99\x8d..." 처럼 이스케이프해서 보여준다. 데이터가 깨진게 아니라 표시만 그런거고 redis-cli --raw 로 보면 제대로 나온다


Streams

5.0 에 들어온 로그형 타입. 위 여섯개보다 한참 나중이다. List 로 큐를 만들면 꺼내는 순간 사라지는데, Stream 은 로그처럼 쌓아두고 여러 소비자가 각자 위치를 들고 읽는다. 카프카 비슷한 모양

 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"

* 를 주면 id 를 자동 생성한다. 밀리초-순번 형식이라 같은 ms 에 여러개가 들어와도 안 겹친다. 각 항목은 hash 처럼 필드/값 쌍을 가진다

consumer group

같은 그룹의 워커들이 메시지를 나눠 갖는다. 읽어간 건 “처리중(pending)” 으로 남아있다가 xack 을 해야 사라진다. 워커가 죽으면 pending 에 남아있으니 다른 워커가 가져갈 수 있다

 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"

> 는 “아직 아무도 안 가져간 것” 이라는 뜻이다. xack 하면 pending 이 빈다

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)

List 큐랑 제일 다른 점이 이거다. 꺼내자마자 사라지는게 아니라 ack 를 받아야 한다

무한정 쌓이는거 막기

로그라 놔두면 계속 큰다. maxlen 으로 상한을 건다. ~ 를 붙이면 정확히 그 개수가 아니라 대략 그쯤에서 자르는데 훨씬 싸다

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

어디에 뭘 쓸까

타입쓸 만한 곳주의
Strings캐시, 카운터, 세션, 간단한 락값이 크면 통째로 오간다
Lists큐, 최근 목록중간 접근이 O(N)
Sets태그, 중복 제거, 교집합원소 많으면 집합 연산이 무겁다
Hashes객체 하나 묶기필드가 수천개면 메모리가 뛴다
Sorted sets랭킹, 지연 큐, 범위 조회score 는 double 이라 큰 정수는 정밀도 손실
Bitmaps대량 boolean (출석, 조회 여부)id 가 연속 정수여야 이득
HyperLogLogUV 처럼 대략의 유니크 수오차 0.81%, 멤버 못 꺼냄
Geo반경 검색좌표 정밀도 손실, 실체는 zset
Streams이벤트 로그, ack 필요한 큐maxlen 안 걸면 계속 큰다

고민되면 대충 이 순서로 생각하면 될듯

  • 값 하나면 String
  • 필드 여러개를 한 덩어리로 보면 Hash
  • 순서가 중요하면 List, 순위나 범위가 중요하면 Sorted set
  • 중복 제거가 목적이면 Set, 개수만 알면 되고 좀 틀려도 되면 HyperLogLog
  • 유저 수만큼의 on/off 면 Bitmap
  • 처리 실패를 복구해야 하면 List 말고 Stream

참고