redis学习记录

说明

这是我的redis笔记, 便于回忆。正在持续施工当中…

关于命令的原子性:

总的来说, redis保证来自某个客户端的一条命令是原子的, 如果有必要, 可以使用事务或lua脚本来保证来个某个客户端的多条指令原子, 不发生interwaving(交织)。

redis的事务并不具有回滚的特性, 它只是保证多条指令原子不交织, 因此可以不叫它事务, 可以叫它原子指令集合。

关于键值对与键的命名:

redis储存的键值对, 键是一个字符串类型, 不应该太长或太短。为了保证键的意义逻辑清晰, 提倡用”分级”的命名规则, 比如”user:1000:followers”。键的最大长度是512M(与能存储的字符串长度一致), 但是别在命名上找茬, 简洁且易读就行了。

列举所有的数据类型:

  • Strings: 字节集合, 最大512M
  • Lists: 链表(Linked List)
  • Sets: Hash Set, 无序String集合
  • Hashes: 哈希表
  • Sorted sets: 就像数值与字符串的双向映射, 可以支持两个排序方式(字典序和根据分数)
  • Streams: TODO
  • Grospatial indexes: TODO
  • Bitmaps: String的字节操作支持
  • Bitfields: 多个无符号整数的集合, 内部用一系列位维护
  • HyperLogLog: 用于统计字符串种类的数据类型, 相比Sets, 它占据恒定的内存

数据类型Strings:

不是utf8字符的集合, 它只是字节的集合, 类似c++的vector<char>或者go的[]uint8。可以在上面存储图片等二进制数据, 大小不超过512M。值得一提的是redis的键也是这种类型。

Strings可以被解析成整数, 并且能够使用INCR/DECR/INCRBY/DECRBY等操作进行增减:

1
2
3
4
5
6
> set counter 100
OK
> incr counter
(interget) 101
> incrby counter 50
(interget) 152

此处虽然redis内部存储的是string, 但是使用上述几个命令能解析这个字符串为整数, 进行加减操作。此外, 对不能解析成interget的字符串使用上述命令, 那么报错。

1
2
3
4
> set counter " 100"
OK
> incr counter
(error) ERR value is not an integer or out of range

get/set/del/exists/type/ttl/pttl命令:

set命令很多参数, 下面是它的总的用法:

1
set key value [NX|XX] [GET] [EX seconds|PX milliseconds]
  • NX(Not eXists): 要求key此刻不存在
  • XX: 要求key此刻存在
  • GET: 获得键之前对应的值, 可以用这个操作实现互斥锁, 但这么做显然性能上得不偿失
  • EX(EXpire): 在多少秒后自动删除
  • PX: 在多少微秒后自动删除

下面列举了其他的一些命令的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
> set key value
OK
> mset a 10 b 20 c 30
OK
> mget key a b c
1) "value"
2) "10"
3) "20"
4) "30"
> del c
(interget) 1
> exists c
(interget) 0
> type b
string
> type mykey
none

关于过期事件的查询:

set可以指定过期时间, ttl可以查询某个键值对的过期时间, 但是这个指令只能精确到秒, pttl可以精确到微秒:

1
2
3
4
5
6
> set key 100 ex 10
OK
> ttl key
(interget) 8
> set key 100 px 1000
OK

keys查询键

1
keys pattern

可以查询所有满足条件的键(当然必须存在):

1
2
3
keys *				# 查询所有键
keys abc* # 查询abc开头的
keys *abc* # 查询包含abc的

Lists

指的是Linked List, 就像内核链表一样的东西, 向插入收尾的性能不会随着链表长度的增加而递增。然而随之带来的是index访问速度的下滑。

应用场景:

  • List可以用来实现生产-消费队列
  • 推特用它保存用户最新的几条推送, 这样响应会很快, 这个其实就是个缓存

一般操作:

LPUSH命令将新的元素插入到List头部, RPUSH将新的元素插入List尾部, 这两个命令返回值是List剩余元素的个数。LPOP, RPOP移除首尾元素, 返回的是弹出的元素。这四个命令的时间复杂度都是O(1)。LLEN用来查询此时List的长度。

LRANGE取两个索引, 第一个是开始索引, 第二个是结束索引, 支持负数代表”倒数”含义:

1
2
3
4
5
6
7
8
9
10
11
12
13
> rpush mylist A
(integer) 1
> rpush mylist B
(integer) 2
> lpush mylist second first
(integer) 3
> lrange mylist 0 -1
1) "first"
2) "second"
3) "A"
4) "B"
> lpop mylist
"first"

注意lpush mylist second first相当于lpush mylist second + lpush mylist first的原子操作, 最后”first”在最前面。

如果LRANGE取到”意料之外”(不符合逻辑的, 比如第二个索引小于第一个的, 或第一个索引大于第二个的, 或超界的)的索引, 那么它简单地返回empty array

1
2
3
4
5
6
7
8
> rpush mylist A
(integer) 1
> lrange A 1 2
(empty array)
> lrange A 10 5
(empty array)
> lrange A -2 -5
(empty array)

如果想要得到列表所有元素, 使用lrange 0 -1即可, 由于是用链表实现的, 它的时间复杂度为O(n)。

LTRIM用于”修剪”List:

1
2
3
4
5
6
7
8
> rpush mylist 1 2 3 4 5
(integer) 5
> ltrim mylist 0 2
OK
> lrange mylist 0 -1
1) "1"
2) "2"
3) "3"

List的阻塞操作:

对于生产-消费者的场景, 如果简单地用生产者LPUSH, 消费者RPOP, 在消费队列中没有元素时返回nil, 此时最好让消费者阻塞, 避免没有用的自旋。redis提供了BRPOPBLPOP, 如果没有元素, 那么阻塞等待。它接收多个参数, 键和时间。返回值有两个, 一个是键(这是因为可以阻塞等待多个键), 一个是值。

1
2
3
BRPOP mylist 2
(nil)
(2.02s)

阻塞操作的一些技巧:

1.如果指定0为时间, 那么将会永久阻塞
2.可以指定多个键, 当其中一个有值时就返回, 返回谁是不确定的
3.如果有多个客户端都阻塞等待一个键, 那么redis将保证公平性, 先来的优先拿到

1
2
3
4
5
6
7
> lpush mylist1 x
(integer) 1
> lpush mylist2 x
(integer) 1
> brpop mylist1 mylist2 1
1) "mylist1"
2) "x"

LMOVE/BLMOVE操作:

1
lmove source destination <LEFT | RIGHT> <LEFT | RIGHT>

原子地将目标List的first/last元素移动到目标的head/tail处。

这种操作常常用来实现(FIXME)

键的存亡

有如下规则:

  1. When we add an element to an aggregate data type, if the target key does not exist, an empty aggregate data type is created before adding the element.

翻译: 当我们使用操作聚合数据类型的插入元素操作时, 如果目标键不存在, 一个空的聚合数据类型就会在插入前被创建。

1
2
3
4
> del mylist
(integer) 1
> lpush mylist 1 2 3
(integer) 3
  1. When we remove elements from an aggregate data type, if the value remains empty, the key is automatically destroyed. The Stream data type is the only exception to this rule.

翻译: 当我们从聚合数据类型删除元素时, 如果删除后它什么元素也不剩了, 那么这个键就被删除了。但是Stream类型是唯一的例外(TODO)。

1
2
3
4
5
6
> lpush mylist 1
(integer)
> rpop mylist
"1"
> exists mylist
(integer) 0
  1. Calling a read-only command such as LLEN (which returns the length of the list), or a write command removing elements, with an empty key, always produces the same result as if the key is holding an empty aggregate type of the type the command expects to find.

翻译: 对不存在的key使用”只读”的命令例如LLEN(它会返回List此刻的长度), 或者使用删除元素的操作像LPOP, 总是产生就像该键对应的是一个空的聚合数据类型的结果。

比如, del mylist后, LLEN mylist返回0, RPOP mylist返回nil。就好像我们在操作一个空的List一样。

哈希

哈希就是键值对, 对它的读写时间复杂读是O(1), 基本的命令命令包括HSET, HGET, HMGET, HDEL, HEXISTS, HKEYS, HLEN:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
> hset user:1000 username antirez birthyear 1977 verified 1
(integer) 3
> hget user:1000 username
"antirez"
> hget user:1000 birthyear
"1977"
> hgetall user:1000
1) "username"
2) "antirez"
3) "birthyear"
4) "1977"
5) "verified"
6) "1"
> hmget user:1000 username birthyear
1) "antirez"
2) "1977"

此外还包括对键对应的值进行数值操作:

1
2
3
4
> hincrby user:1000 birthyear 10
(integer) 1987
> hincrby user:1000 birthyear 10
(integer) 1997

值得一提的是, 如果hash的键不存在, 那么数值操作将在执行前提前将”0”写入:

1
2
3
4
> hincrby user:1000 not_exists 10
(integer) 10
> hget user:1000 not_exists
"10"

上述操作和对键直接使用数值操作如出一辙。

hash的键排序:

HKEYS获得的hash键, 将根据键进行排序, 这与之后的Sorted Sets密切相关。

Sets

Sets是无序字符串集合, SADD添加新元素进去, SREM删除某个元素, SMEMBERS得到所有的元素(字符串), SISMEMBER测试是否存在这个元素(字符串)。SCARD用来查询现在的Set有多少个元素。

1
> sadd myset 1 2 3

它很适合做元素唯一的列表, 比如保存用户关注的文章ID。

SINTER命令求多个Set的交集, 它会返回多个Set共有的字符串。SUNION用来求多个Set的并集。SDIFF用来求差集(并集去除补集的那部分)。这苍耳命令还有...STORE destination key1 key2...能把结果存储到目标键中。

SUNIONSTORE用来求并集, 并将结果存储到一个目标键里面。但是这种操作也可以用来做Set的拷贝。

SPOP从Set随即弹出一个元素, 此命令将删除元素。SRANDMEMBER能不删除地获取一个元素, 可选的count参数, 表示获取至多多少个元素。

1
2
3
4
5
6
> smembers A
1) "1"
2) "3"
> srandmember A 1000
1) "1"
2) "3"

由于上面A只有两个元素, 所以SRANDMEMBER只返回了两个。

Sorted sets

它和Sets一样, 不允许多个重复的元素。不同点是它是有序的, 通过score(分数)从小到大进行排序, 其中分数是double类型(这点很特殊, 因为redis没有double这种数据类型, 但是在Sorted sets这里却支持了这种浮点类型进行排序)

有两套排序机制, 分数(score)和字典序(lex)。默认用分数排序, 当分数一致时用字典序进行排序。其中成员是唯一的, 但是分数可以重复。当zadd尝试插入一个已存在的元素时, 默认执行覆盖, 但显示指定XX|NX。

1
ZADD key [NX|XX] [GT|LT] [CH] [INCR] score member [score member ...]
  • NX: 要求不存在元素
  • XX: 要求存在元素
  • INCR: 进行增加, 常用于更新已存在的元素的分数
  • CH: 返回成功新增元素的个数(因为zadd已存在的元素会进行覆盖, 它们不算新增)
  • GT(greater than): 修改的分数必须大于原来的分数才能修改成功, 不与[NX|XX]共用
  • LG(less than): 同理, 但是是小于

默认进行覆盖的小例子:

1
2
3
4
5
6
7
8
9
10
> zadd testkey 1.1 redis
(integer) 1
> zrange testkey 0 10 WITHSCORES
1) "redis"
2) "1.1000000000000001"
> zadd testkey 2.2 redis
(integer) 0
> zrange test 0 -1 withscores
1) "redis"
2) "2.2000000000000002"

因为它有两个排序原则, 所以它可以用来做排行榜, 也可以维护字典序排序的字符串集合。redis文档指出它可以做请求的滑动窗口, 避免过量的网络请求, 防止流量攻击。

遍历Sorted sets的方式:

1
zrange key start stop [byscore | bylex] [rev] [limit offset count] [withscores]
  • bysocre: 依据分数排序
  • bylex: 依据字典序排序, 用它时第一个字符指示是否是闭合区间, 比如start=[B, stop=[C是左闭右开的
  • rev: 是否倒序
  • limit: 类似SQL的SELECT LIMIT offset, count
  • withscores: 是否带有分数

此外还有count系列的命令:

1
2
3
zcount key min max: 按分数计数
zlexcount key min max: 按字典序计数
zcard key: 相当于zcount key 0 -1

删除命令:

1
2
3
4
5
zrem key element
zremrange key start end
zremrangebylex key min max
zremrangebyrank key start end
zremrangebyscore key min max

查询成员的分数:

1
zscore key element

技巧, 如果将所有元素的分数都指定成一样的, 那么就可以维护一个字典序的字符串集合。

1
2
3
4
5
6
> zadd hackers 0 "Alan Kay" 0 "Sophie Wilson" 0 "Richard Stallman"
(integer) 3
> zrange hackers 0 -1
1) "Alan Kay"
2) "Richard Stallman"
3) "Sophie Wilson"

Bitmaps

事实上Bitmaps不是数据类型, 它只是用在字符串类型上的一系列比特操作, 其中操作单个原子的操作是O(1)的。

1
2
getbit key offset: 如果超界, 那么返回0
setbit key offset value(0或1): 如果超界限, 那么返回0

还有一些操作:

1
2
3
bitcount key [start end [byte|bit]]: 查找被置为1的位
bitpos key bit [start [end [byte|bit]]]: 查找第一个bit的位置
bitop operation destkey key [key ...]: 可以进行XOR/AND/OR/NOT位操作

虽然我想不到redis搞这个有什么用, 但是官网却给出了一个应用场景, 我不懂但我大受震撼:

1
2
3
4
5
For example imagine you want to know the longest streak of daily visits of your web site users. You start counting days starting from zero, that is the day you made your web site public, and set a bit with SETBIT every time the user visits the web site. As a bit index you simply take the current unix time, subtract the initial offset, and divide by the number of seconds in a day (normally, 3600*24).

This way for each user you have a small string containing the visit information for each day. With BITCOUNT it is possible to easily get the number of days a given user visited the web site, while with a few BITPOS calls, or simply fetching and analyzing the bitmap client-side, it is possible to easily compute the longest streak.

Bitmaps are trivial to split into multiple keys, for example for the sake of sharding the data set and because in general it is better to avoid working with huge keys. To split a bitmap across different keys instead of setting all the bits into a key, a trivial strategy is just to store M bits per key and obtain the key name with bit-number/M and the Nth bit to address inside the key with bit-number MOD M.

HyperLogLogs

这种数据类型是专门用来统计元素的种类的, 就像Sets一样, 但是允许占用恒定的内存, 不存储元素而只是统计种类。想想如果用Sets做种类统计的话, 需要存在String, 这样它的空间就是O(N)递增的, 但是HLL不会, 它不存储元素, 占用的内存是恒定的。

1
2
3
4
5
6
> pfadd test 1 2 3 4
(integer) 1
> pfadd test 1
(integer) 0
> pfcount test
(integer) 4

文档提到它可以用来统计用户每天搜索内容(即有多少种不同的字符串)。

可以用PFMERGE用于合并多个HLL。

Streams TODO

Streams是redis最复杂的一个数据类型, 文档就好大一串。先搁置了。

Grospatial indexes: TODO

搁置先。

Bitfields

对于一个玩家, 我们需要保存他的两个信息, 获得的金币数量以及杀死的怪物数量。我们希望能原子的改变这两个信息(不发生interwaving), 当然我们可以使用lua脚本, 但是我们这里选择用bitfields。bitfields维护的是一些位, 其实我们可以认为bitfields就是多个整数变量的集合。

1
2
3
4
5
> bitfield player:1:stats set u32 #0 1000
(integer) 0
> bitfield player:1:stats incrby u32 #0 50 incrby u32 #1 1
1) (integer) 1050
2) (integer) 1

支持的操作有SET/INCRBY/GET

高级特性: Pub/Sub

关注/订阅模式可以用来做消息通知, 见后面的Go例子。

lua脚本:

使用eval命令可以跑脚本:

要原子地执行操作, lua脚本和事务是很好的选择, 这里介绍lua脚本, 下面是个hello world程序:

1
2
3
4
5
> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"

eval是用来执行lua脚本的, 下面是命令的模式, 将输入分为键和值(其实它们都是字符串), 这样做是为了逻辑清晰, 因为redis是键值对数据库。

1
eval script numkeys [key [key ...]] [arg [arg ...]

原子地设置两个变量, 这里用到了mset调用, 其实等价于mset, 只是用了redis.call:

1
eval "return redis.call('mset', KEYS[1], ARGV[1], KEYS[2], ARGV[2])" 2 key1 key2 value1 value2

为了方便起见, 下面用go-redis跑lua脚本。

Golang使用redis的示例

获取包:

1
go get -u "github.com/redis/go-redis/v9"

Hello World:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package main

import (
"context"
"github.com/redis/go-redis/v9"
)

func main() {
// 支持了Go的context, 能够支持任务取消
ctx := context.Background()

// 创建客户端
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // 没密码
DB: 0, // 0号库是默认库
})

// set key val time, time为0代表没过期时间
err := rdb.Set(ctx, "key", "value", 0).Err()
if err != nil {
panic(err)
}

// get key
val, err := rdb.Get(ctx, "key").Result()
if err != nil {
panic(err)
}
fmt.Println("key", val)

// 如果不存在返回的是redis.Nil
val2, err := rdb.Get(ctx, "key2").Result()
if err == redis.Nil {
fmt.Println("key2 does not exist")
} else if err != nil {
panic(err)
} else {
fmt.Println("key2", val2)
}
// Output: key value
// key2 does not exist
}

go-redis调用lua: TODO

发布订阅demo

publisher:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
"context"
"time"

"github.com/redis/go-redis/v9"
)

func main() {
ctx := context.Background()

rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // no password set
DB: 0, // use default DB
})

for {
time.Sleep(time.Second)
if err := rdb.Publish(ctx, "mychannel", "Hello World").Err(); err != nil {
panic(err)
}
}
}

subscriber:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import (
"context"
"fmt"

"github.com/redis/go-redis/v9"
)

func main() {
ctx := context.Background()

rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // no password set
DB: 0, // use default DB
})

sub := rdb.Subscribe(ctx, "mychannel")

for {
m, err := sub.ReceiveMessage(ctx)
if err != nil {
panic(err)
}
fmt.Println(m.Channel, m.Payload)

}
}