Go 语言玩转Redis (二):数据结构与应用–字符串

第二到十一章节介绍Redis的数据结构与用法,包括上节说的字符串、散列、列表、集合、有序集合、HyperLogLog、位图、流、地理坐标等,我都会介绍其本身的基本命令及如何通过Go语言来调用。在详细介绍完后,我会带入一些使用场景并使用Go语言来实现,帮助大家和我更好的理解该数据结构。

其中Go语言的部分都会对应我github的链接,Go语言客户端的建立在(一)中有解释。

字符串

字符串(string)键是Redis最基本的键值对类型,这种类型的键值对会在数据库中把单独的一个键和单独的一个值关联起来,被关联的键和值既可以是普通的文字数据,也可以是图片、视频、音频、压缩文件等更为复杂的二进制数据。

Redis为字符串键提供了一系列操作命令,通过使用这些命令可以:

  • 为字符串键设置值。

  • 获取字符串键的值。

  • 在获取旧值的同时为字符串键设置新值。

  • 同时为多个字符串键设置值,或者同时获取多个字符串键的值。

  • 获取字符串值的长度。

  • 获取字符串值指定索引范围内的内容,或者对字符串值指定索引范围内的内容进行修改。

  • 将一些内容追加到字符串值的末尾。

  • 对字符串键存储的整数值或者浮点数值执行加法操作或减法操作。

接下来将对以上提到的字符串键命令进行介绍,并演示如何使用这些命令去解决各种实际问题。

SET: 为字符串键设置值

Redis的命令方式:

1
2
3
# SET key value
redis> SET number "10086"
OK

以上命令 Redis 将会在成功创建字符串键之后将返回OK作为结果。我们可以创建出一个字符串键,它的键为”number”,值为”10086”

Golang 命令方式代码

1
2
3
4
5
6
7
8
9
10
// SetStr doc
func SetStr(key, val string) error {
res, err := client.LocalRedis.Set(key, val, 0).Result()
if err != nil {
return err
}

fmt.Println("set key result : ", res)
return nil
}

这个设置的方法非常简单,三个参数key,value,expiration

expiration 参数

需要注意的是这里客户端的设置方法直接把设置键的存在时间放入了设置方法,如果要设置永久键应令expiration为0(如例子中所示)我们看下redis.v5的Set方法源码就能理解这点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Redis `SET key value [expiration]` command.
//
// Use expiration for `SETEX`-like behavior.
// Zero expiration means the key has no expiration time.
func (c *cmdable) Set(key string, value interface{}, expiration time.Duration) *StatusCmd {
args := make([]interface{}, 3, 4)
args[0] = "set"
args[1] = key
args[2] = value
if expiration > 0 {
if usePrecise(expiration) {
args = append(args, "px", formatMs(expiration))
} else {
args = append(args, "ex", formatSec(expiration))
}
}
cmd := NewStatusCmd(args...)
c.process(cmd)
return cmd
}

Redis中set 方法有px和ex 两个参数前者是设置超市时间单位为millisecond,后者是设置超时时间单位为second。

看一个单元测试用例,设置键值分别为”mark”,”123”,成功设置后使用Redis GET 方法去获取”mark” 的对应值并打印:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func TestSetStr(t *testing.T) {
client.Init()
err := SetStr("mark", "123")
if err != nil {
t.Log("set err : ", err)
return
}

res, err := GetStr("mark")
if err != nil {
t.Log("get err : ", err)
return
}

t.Log("success get result : ", res)
}

结果:

1
2
set key result :  OK
str_test.go:22: success get result : 123

成功设置key “mark”,并获取到值 “123”

复杂度

复杂度:O(1)。

SET(NX/XX):改变覆盖规则设置

在默认情况下,对一个已经设置了值的字符串键执行SET命令将导致键的旧值被新值覆盖。举个例子,如果我们连续执行以下两条SET命令,那么第一条SET命令设置的值将被第二条SET命令设置的值所覆盖

但你可以通过向SET命令提供可选的NX选项或者XX选项来指示SET命令是否要覆盖一个已经存在的值。

Redis的命令方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# SET key value [NX|XX]
redis> SET password "1234" NX
OK
# 对尚未有的值设置,成功
redis> SET password "6789" NX
(nil)
# 再次设置(已有值)失败,返回 nil
# 因为第二条SET命令没有改变number键的值,所以# password键的值仍然是刚开始时设置的"1234"。

# 对尚未有的值设置,失败,返回 nil
redis> SET passwordXX "1234" XX
(nil)
## 对已经有的值进行更新,成功
redis> SET passwordXX "1234"
OK
redis> SET passwordXX "5678" XX
OK
## 在第二条SET命令执行之后,passwordXX键的值将从原来的"1234"更新为"5678"。

Golang 命令方式代码

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
// SetStrNX redis set NX=true 
func SetStrNX(key, val string) error {
sec, err := client.LocalRedis.SetNX(key, val, 0).Result()
if err != nil {
return err
}

if !sec {
return fmt.Errorf("the key : %s has been setted", key)
}

return nil
}

// SetStrXX redis set XX=true
func SetStrXX(key, val string) error {
sec, err := client.LocalRedis.SetXX(key, val, 0).Result()
if err != nil {
return err
}

if !sec {
return fmt.Errorf("the key : %s is not find", key)
}

return nil
}

这个两个设置方法参数和Set相同不做赘述。

可以看到 Go 语言客户端实例的方法返回的结果为一个bool值:代表是否成功执行了设置命令和一个错误。

我们看单元测试用例:

  • 测试 SetNX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func TestSetStrNX(t *testing.T) {
client.Init()
err := SetStrNX("name", "mark")
if err != nil {
t.Log("set nx 1 err : ", err)
return
}

res, err := GetStr("name")
if err != nil {
t.Log("get err : ", err)
return
}
t.Log("success get result : ", res)

err = SetStrNX("name", "mark")
if err != nil {
t.Log("set nx 2 err : ", err)
return
}

t.Log("success get result : ", res)
}

结果:

1
2
str_test.go:38: success get result :  mark
str_test.go:42: set nx 2 err : the key : name has been setted

​ 通过结果可以看到第一次设置 key name 的值为mark,成功而第二次由于key name 存在值,所以设置失败。

  • 测试SetXX
1
2
3
4
5
6
7
8
func TestSetStrXX(t *testing.T) {
client.Init()
err := SetStrXX("num", "1")
if err != nil {
t.Log("set xx 1 err : ", err)
return
}
}

我们先单独设置一次,结果:

1
str_test.go:53: set xx 1 err :  the key : num is not find

结果显而易见是失败的因为SetXX 只能更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func TestSetStrXX(t *testing.T) {
client.Init()
err := SetStr("num", "1")
if err != nil {
t.Log("set err : ", err)
return
}

err = SetStrXX("num", "2")
if err != nil {
t.Log("set xx 1 err : ", err)
return
}

res, err := GetStr("num")
if err != nil {
t.Log("get err : ", err)
return
}
t.Log("success get result : ", res)
}

我们先设置一个值然后更新,结果:

1
2
set key result :  OK
str_test.go:68: success get result : 2

成功更新了 !

复杂度

复杂度:O(1)。

GET: 获取字符串键的值

用户可以使用GET命令从数据库中获取指定字符串键的值:

Redis的命令方式:

1
GET key value

Golang 命令方式代码

1
2
3
4
// GetStr doc
func GetStr(key string) (string, error) {
return client.LocalRedis.Get(key).Result()
}

这个就不过多解释了,需要注意的是如果去获取一个不存在的值会报错:”redis: nil” Redis 客户端会返回一个nil

1
str_test.go:65: redis: nil

复杂度

复杂度:O(1)。

GETSET:获取旧值并设置新值

GETSET命令就像GET命令和SET命令的组合版本,GETSET首先获取字符串键目前已有的值,接着为键设置新值,最后把之前获取到的旧值返回给用户:

Redis的命令方式:

1
GETSET key new_value

Golang 命令方式代码

1
2
3
4
// GetSetStr  doc
func GetSetStr(key, val string) (string, error) {
return client.LocalRedis.GetSet(key, val).Result()
}

这个方法调用的时候是需要注意一个点的:

如果GetSet的key不存在,那么Redis会返回旧值为nil,但Go调用是会返回错误:redis: nil

单元测试:

1
2
3
4
5
6
7
8
9
10
func TestGetSetStr(t *testing.T) {
client.Init()
res, err := GetSetStr("pass", "123456")
if err != nil {
t.Log("getset err : ", err)
return
}

t.Log("success getset result : ", res)
}

结果:

1
str_test.go:85: getset err :  redis: nil

虽然返回了错误但其实是设置成功了的,我们在执行一次看结果:

1
str_test.go:89: success getset result :  123456

复杂度

复杂度:O(1)。

MSET:一次为多个字符串键设置值

除了SET命令和GETSET命令之外,Redis还提供了MSET命令用于对字符串键进行设置。与SET命令和GETSET命令只能设置单个字符串键不同,MSET命令可以一次为多个字符串键设置值:

Redis的命令方式:

1
MSET key value [key value ...]

Golang 命令方式代码

1
2
3
4
// GetSetStr  doc
func GetSetStr(key, val string) (string, error) {
return client.LocalRedis.GetSet(key, val).Result()
}

与SET命令一样,MSET命令也会在执行设置操作之后返回OK表示设置成功。此外,如果给定的字符串键已经有相关联的值,那么MSET命令也会直接使用新值去覆盖已有的旧值。

单元测试:

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
func TestMSetStr(t *testing.T) {
client.Init()
res, err := MSetStr()
if err != nil {
t.Log("mset err : ", err)
return
}

key1, err := GetStr("key1")
if err != nil {
t.Log("mset err : ", err)
return
}

key2, err := GetStr("key2")
if err != nil {
t.Log("mset err : ", err)
return
}

key3, err := GetStr("key3")
if err != nil {
t.Log("mset err : ", err)
return
}

t.Log("success mset result : ", res, key1, key2, key3)
}

结果:

1
str_test.go:118: success mset result :  OK 1 2 3

MSET命令除了可以让用户更为方便地执行多个设置操作之外,还能有效地提高程序的效率:执行多条SET命令需要客户端和服务器之间进行多次网络通信,并因此耗费大量的时间;而使用一条MSET命令去代替多条SET命令只需要一次网络通信,从而有效地减少程序执行多个设置操作时的时间。

复杂度

复杂度:O(N),其中N为用户给定的字符串键数量。

MGET:一次获取多个字符串键的值

MGET命令就是一个多键版本的GET命令,MGET接受一个或多个字符串键作为参数,并返回这些字符串键的值:

Redis的命令方式:

1
MGET key [key ...]

Golang 命令方式代码

1
2
3
4
// MGetStr doc
func MGetStr() ([]interface{}, error) {
return client.LocalRedis.MGet("key1", "key2", "key3").Result()
}

MGET命令返回一个列表作为结果,这个列表按照用户执行命令时给定键的顺序排列各个键的值。比如,列表的第一个元素就是第一个给定键的值,第二个元素是第二个给定键的值,以此类推。

结果:

1
str_test.go:129: success mget result :  [1 2 3]

与GET命令一样,MGET命令在碰到不存在的键时也会返回空值。

与MSET命令类似,MGET命令也可以将执行多个获取操作所需的网络通信次数从原来的N次降低至只需一次,从而有效地提高程序的运行效率。

复杂度

复杂度:O(N),其中N为用户给定的字符串键数量。

Golang SET 方法设置不同类型的值

Golang的各种Set方法值的入参类型都是interface,那么是否意味着我们可以使用改方法设置不同类型的值呢?对的是可以的,不过不同类型的值打开方式不太一样。int和float类型是可以直接存储的,但象struct,[]string,map 等都要单独处理,我们直接看代码:

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
type Student struct {
Name string
Age int
}

func (s Student) MarshalBinary() ([]byte, error) {
return json.Marshal(s)
}

type MySlice []string

func (ms MySlice) MarshalBinary() ([]byte, error) {
return json.Marshal(ms)
}

func TestSetInterface(t *testing.T) {
client.Init()
s := Student{"abc", 123}
res, err := client.LocalRedis.Set("student", s, 0).Result()
if err != nil {
t.Log("redis set err : ", err)
return
}

t.Log("set success result : ", res)

msetRes, err := client.LocalRedis.MSet("int", 0, "float", 3.14, "myslice", MySlice{"a", "b", "c"}).Result()
if err != nil {
t.Log("redis Mset err : ", err)
return
}

t.Log("Mset success result : ", msetRes)

}

上面这个例子,我存储了int,float 以及自定义类型Student 底层为 struct 和 自定义类型 Myslice 低层为[]string。可以看出如果要进行非string,int,float 的类型需要我们去自定义并实现 MarshalBinary 方法从而实现 encoding.BinaryMarshaler 接口。相信大家已经能看明白这个redis.v5的源码做了些什么操作。它其实就是对不同类型的值调用 MarshalBinary 将值序列化为[]byte,然后在转成string再存入redis。(当然你也可以自己进行序列化并转成string存入redis,我们现在就介绍这种比较清爽的写法)

结果:

1
2
str_test.go:158: set success result :  OK
str_test.go:166: Mset success result : OK

那取出我们怎么取?下面我用 MGet 来看看他的正确打开方式。

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
44
45
46
47
48
49
50
51
func TestGetInterface(t *testing.T) {
client.Init()
mgetRes, err := client.LocalRedis.MGet("int", "float", "student", "myslice").Result()

if err != nil {
t.Log("redis Mset err : ", err)
return
}

for i, mr := range mgetRes {
fmt.Printf("%T \n", mr)
if i == 0 {
mrInt, err := strconv.Atoi(mr.(string))
if err != nil {
t.Log("atoi err :", err)
return
}
fmt.Printf("int : %T,%d \n", mrInt, mrInt)
}

if i == 1 {
mrfloat, err := strconv.ParseFloat(mr.(string), 64)
if err != nil {
t.Log("ParseFloat err :", err)
return
}
fmt.Printf("float : %T,%f \n", mrfloat, mrfloat)
}

if i == 2 {
ms := Student{}
err := json.Unmarshal([]byte(mr.(string)), &ms)
if err != nil {
t.Log("json unmarshal student err :", err)
return
}
fmt.Println("student", ms)
}
if i == 3 {
ms := MySlice{}
err := json.Unmarshal([]byte(mr.(string)), &ms)
if err != nil {
t.Log("json unmarshal myslice err :", err)
return
}
fmt.Println("myslice", ms)
}

}
}

结果:

1
2
3
4
5
6
7
8
string 
int : int,0
string
float : float64,3.140000
string
student {abc 123}
string
myslice [a b c]

从结果可以看出,使用MGet取出的依旧全是string类型,所以你要根据不同类型的值进行不同的处理,代码很简单大家自己看吧,不再过多解释。

MSETNX:只在键不存在的情况下,一次为多个字符串键设置值

MSETNX命令与MSET命令一样,都可以对多个字符串键进行设置:

Redis的命令方式:

1
MSETNX key value [key value ...]

Golang 命令方式代码

1
2
3
4
5
ok, err := client.LocalRedis.MSetNX("a", "a", "b", "b").Result()
if err != nil {
t.Log("redis MSetNX err : ", err)
return
}

MSETNX与MSET的主要区别在于,MSETNX只会在所有给定键都不存在的情况下对键进行设置,而不会像MSET那样直接覆盖键已有的值:如果在给定键当中,即使有一个键已经有值了,那么MSETNX命令也会放弃对所有给定键的设置操作。MSETNX命令在成功执行设置操作时返回1,在放弃执行设置操作时则返回0。

Golang 的方法则是返回true / false 来判别执行是否成功

单元测试

1
2
3
4
5
6
7
8
9
10
func TestMSetNXStr(t *testing.T) {
client.Init()
ok, err := client.LocalRedis.MSetNX("a", "a", "b", "b").Result()
if err != nil {
t.Log("redis MSetNX err : ", err)
return
}
t.Log("set nx ", ok)

}

结果:

1
str_test.go:228: set nx  true

可以看出对两个本身存在的值进行设置是成功的。那如果其中有一个是已存在的呢:

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
func TestMSetNXStr(t *testing.T) {
client.Init()
ok, err := client.LocalRedis.MSetNX("a", "a", "f", "f").Result()
if err != nil {
t.Log("redis MSetNX err : ", err)
return
}
t.Log("set nx ", ok)

a, err := GetStr("a")
if err != nil {
t.Log("mset err : ", err)
return
}
t.Log("a", a)

f, err := GetStr("f")
if err != nil {
t.Log("mset err : ", err)
return
}

t.Log("f", f)

}

结果:

1
2
3
str_test.go:228: set nx  false
str_test.go:235: a a
str_test.go:239: mset err : redis: nil

可以看出有一个值设置失败那么所有值的操作都会丢弃。

复杂度

复杂度:O(N),其中N为用户给定的字符串键数量。

STRLEN:获取字符串值的字节长度

通过对字符串键执行STRLEN命令,用户可以取得字符串键存储的值的字节长度:

Redis的命令方式:

1
STRLEN key 

Golang 命令方式代码

1
2
3
4
// GetStrLen doc
func GetStrLen(key string) (int64, error) {
return client.LocalRedis.StrLen(key).Result()
}

单元测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func TestGetStrLen(t *testing.T) {
client.Init()
key := "strLen"
val := "abc"
res, err := client.LocalRedis.Set(key, val, 0).Result()
if err != nil {
t.Log("redis set err : ", err)
return
}
t.Log("set success result : ", res)
l, err := GetStrLen(key)
if err != nil {
t.Log("redis getLen err : ", err)
return
}

t.Log("length : ", l)

}

结果:

1
2
str_test.go:255: set success result :  OK
str_test.go:262: length : 3

需要注意的是:对于不存在的键,STRLEN命令将返回0

1
2
3
4
5
6
7
8
9
10
11
12
13
func TestGetStrLen(t *testing.T) {
client.Init()
key := "strLen_not_find"
l, err := GetStrLen(key)
if err != nil {
t.Log("redis getLen err : ", err)
return
}

t.Log("length : ", l)

}

结果:

1
str_test.go:262: length :   0

在去获取一个不存在键时返回的长度是 0 而不是返回error。

复杂度

复杂度:O(1)。

字符串值的索引

因为每个字符串都是由一系列连续的字节组成的,所以字符串中的每个字节实际上都拥有与之相对应的索引。Redis为字符串键提供了一系列索引操作命令,这些命令允许用户通过正数索引或者负数索引,对字符串值的某个字节或者某个部分进行处理,其中:

  • 字符串值的正数索引以0为开始,从字符串的开头向结尾不断递增。

  • 字符串值的负数索引以-1为开始,从字符串的结尾向开头不断递减。

接下来将对GETRANGE和SETRANGE这两个字符串键的索引操作命令进行介绍。

GETRANGE:获取字符串值指定索引范围上的内容

通过使用GETRANGE命令,用户可以获取字符串值从start索引开始,直到end索引为止的所有内容:

Redis的命令方式:

1
GETRANGE key start end

Golang 命令方式代码

1
2
3
4
// GetStrRange doc
func GetStrRange(key string, start, end int64) (string, error) {
return client.LocalRedis.GetRange(key, start, end).Result()
}

单元测试:

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
44
45
46
47
48
49
50
51
52
53

func TestGetStrRange(t *testing.T) {
client.Init()
key := "strRange"
val := "0123456789"
res, err := client.LocalRedis.Set(key, val, 0).Result()
if err != nil {
t.Log("redis set err : ", err)
return
}
t.Log("set success result : ", res)
// 从头部开始
res, err = GetStrRange(key, 0, 3)
if err != nil {
t.Log("redis get range 1 err : ", err)
return
}
t.Log("get range 1 success result : ", res)

// 头尾混取
res, err = GetStrRange(key, 1, -2)
if err != nil {
t.Log("redis get range 2 err : ", err)
return
}
t.Log("get range 2 success result : ", res)

// 从尾部开始取
res, err = GetStrRange(key, -3, -1)
if err != nil {
t.Log("redis get range 3 err : ", err)
return
}
t.Log("get range 3 success result : ", res)

// 取所有
res, err = GetStrRange(key, 0, -1)
if err != nil {
t.Log("redis get range 4 err : ", err)
return
}
t.Log("get range 4 success result : ", res)

// start 如果比 end 的坐标位置大
res, err = GetStrRange(key, -1, 0)
if err != nil {
t.Log("redis get range 5 err : ", err)
return
}
t.Log("get range 5 success result : ", res)

}

以上我们做了五个测试用例,看下是什么结果:

1
2
3
4
5
6
str_test.go:275: set success result :  OK
str_test.go:282: get range 1 success result : 0123
str_test.go:290: get range 2 success result : 12345678
str_test.go:298: get range 3 success result : 789
str_test.go:306: get range 4 success result : 0123456789
str_test.go:314: get range 5 success result :

从结果中我总结了几个需要注意的点:

  • 闭区间索引:GETRANGE命令接受的是闭区间索引范围,也就是说,位于start索引和end索引上的值也会被包含在命令返回的内容当中。比如0-3的索引范围,返出的是0123。这个其实和Go语言中的切片截取的左闭右开不太一样,在使用的时候要非常注意。

  • 负下标的使用:负下标是从尾部开始向头部递减的,尾部的下标方便我们直接 从尾部截取数据。但开始下标的绝对值要大于结束下标才对比如-3 ~ -1。

  • 截取全部: redis 的双下标索引可以让我们在不知道长度的情况下截取所有数据,使用下标0~1即可。

  • 开始下标大于结束下标:此时并不会报错而是返回空字符串。

复杂度

复杂度:O(N),其中N为被返回内容的长度。

SETRANGE:对字符串值的指定索引范围进行设置

通过使用SETRANGE命令,用户可以将字符串键的值从索引index开始的部分替换为指定的新内容,被替换内容的长度取决于新内容的长度:

Redis的命令方式:

1
2
3
4
5
6
7
8
SETRANGE key index substitute

redis> GET message
"hello world"
redis> SETRANGE message 6 "Redis"
(integer) 11 #当前的字符串长度为11字节
redis> GET message
"hello Redis"

SETRANGE命令在执行完设置操作之后,会返回字符串值当前的长度作为结果。

这个例子中的SETRANGE命令会将message键的值从索引6开始的内容替换为”Redis”。

Golang 命令方式代码

1
2
3
4
// SetStrRange doc
func SetStrRange(key, val string, offset int64) (int64, error) {
return client.LocalRedis.SetRange(key, offset, val).Result()
}

单元测试:

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
func TestSetStrRange(t *testing.T) {
client.Init()
key := "strRange"
val := "0123456789"
result, err := client.LocalRedis.Set(key, val, 0).Result()
if err != nil {
t.Log("redis set err : ", err)
return
}
t.Log("set success result : ", result)

// 正下标替换
res, err := SetStrRange(key, "abc", 0)
if err != nil {
t.Log("redis get range 1 err : ", err)
return
}
t.Log("get range 1 success result : ", res)

getRes, err := GetStr(key)
if err != nil {
t.Log("get err 1: ", err)
return
}
t.Log("get result 1", getRes)

// 负下标替换
res, err = SetStrRange(key, "abc", -4)
if err != nil {
t.Log("redis set range 2 err : ", err)
return
}
t.Log("set range 2 success result : ", res)

getRes, err = GetStr(key)
if err != nil {
t.Log("get err 2: ", err)

return
}
t.Log("get result 2", getRes)

// 替换值过长超出最大下标
res, err = SetStrRange(key, "abc", 9)
if err != nil {
t.Log("redis set range 3 err : ", err)
return
}
t.Log("set range 3 success result : ", res)
getRes, err = GetStr(key)
if err != nil {
t.Log("get err 3: ", err)

return
}
t.Log("get result 3", getRes)

// 替换下标超出最大下标
res, err = SetStrRange(key, "abc", 15)
if err != nil {
t.Log("redis set range 4 err : ", err)
return
}
t.Log("set range 4 success result : ", res)
getRes, err = GetStr(key)
if err != nil {
t.Log("get err 4: ", err)

return
}
t.Logf("get result 4 %#v", getRes)

}

结果:

1
2
3
4
str_test.go:327: set success result :  OK
str_test.go:335: get range 1 success result : 10
str_test.go:342: get result 1 abc3456789
str_test.go:347: redis set range 2 err : ERR offset is out of range

发现在执行第二个例子时出错了。这里需要注意的是不可以使用负下标。

好我们把负下标测试注释掉再次试一下:

结果:

1
2
3
4
5
6
7
8
str_test.go:327: set success result :  OK
str_test.go:335: get range 1 success result : 10
str_test.go:342: get result 1 abc3456789
str_test.go:358: get result 2 abc3456789
str_test.go:366: set range 3 success result : 12
str_test.go:373: get result 3 abc345678abc
str_test.go:381: set range 4 success result : 18
str_test.go:388: get result 4 "abc345678abc\x00\x00\x00abc"

通过测试结果,我总结了以下几个需要注意的点:

如果新内容长于被替换内容,自动扩展被修改的字符串

当用户给定的新内容比被替换的内容更长时,SETRANGE命令就会自动扩展被修改的字符串值,从而确保新内容可以顺利写入。

如上面第三个测试用例,其字符串的长度从10扩展到了12。即原下标9的9替换为了a,然后拓展10,11下标并填充了bc。

如果替换起始下标超出原字符串长度,在值里面填充空字节

SETRANGE命令除了会根据用户给定的新内容自动扩展字符串值之外,还会根据用户给定的index索引扩展字符串。

当用户给定的index索引超出字符串值的长度时,字符串值末尾直到索引index-1之间的部分将使用空字节进行填充,换句话说,这些字节的所有二进制位都会被设置为0。

如上面单元测试最后一个例子,SETRANGE命令会先将字符串值扩展为18个字节长,然后将”abc”末尾直到索引18之间 (即13-18) 的所有字节都填充为空字节,最后再将索引16到索引18的内容设置为”abc”。

可以看到,strRange键的值现在包含了多个\x00符号,每个\x00符号代表一个空字节。

复杂度

复杂度:O(N),其中N为被返回内容的长度。

APPEND:追加新内容到值的末尾

通过调用APPEND命令,用户可以将给定的内容追加到字符串键已有值的末尾:

Redis的命令方式:

1
2
3
4
5
6
7
8
SETRANGE key suffix

redis> GET message
"hello world"
redis> APPEND message " my name is mark"
(integer) 27 #当前的字符串长度为27字节
redis> GET message
"hello world my name is mark"

SETRANGE命令在执行完设置操作之后,会返回字符串值当前的长度作为结果。

这个例子中的SETRANGE命令会将message键的值从索引6开始的内容替换为”Redis”。

Golang 命令方式代码

1
2
3
4
// StrAppend doc
func StrAppend(key, val string) (int64, error) {
return client.LocalRedis.Append(key, val).Result()
}

单元测试:

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
func TestStrAppend(t *testing.T) {
client.Init()
key := "strAppend"
val := "0123456789"
result, err := client.LocalRedis.Set(key, val, 0).Result()
if err != nil {
t.Log("redis set err : ", err)
return
}
t.Log("set success result : ", result)

// 对某key直接追加
res, err := StrAppend(key, "abc")
if err != nil {
t.Log("redis append 1 err : ", err)
return
}
t.Log("append 1 success result : ", res)

getRes, err := GetStr(key)
if err != nil {
t.Log("get err 1: ", err)
return
}
t.Log("get result 1", getRes)

// 处理不存在的键
res, err = StrAppend("new_key", "abc")
if err != nil {
t.Log("redis append 2 err : ", err)
return
}
t.Log("append 2 success result : ", res)

getRes, err = GetStr("new_key")
if err != nil {
t.Log("get err 2: ", err)
return
}
t.Log("get result 2", getRes)
}

结果:

1
2
3
4
5
str_test.go:401: set success result :  OK
str_test.go:409: append 1 success result : 13
str_test.go:416: get result 1 0123456789abc
str_test.go:424: append 2 success result : 3
str_test.go:431: get result 2 abc

通过测试结果,需要注意的是:

如果用户给定的键并不存在,那么APPEND命令会先将键的值初始化为空字符串””,然后再执行追加操作,最终效果与使用SET命令为键设置值的情况类似

当键有了值之后,APPEND又会像平时一样,将用户给定的值追加到已有值的末尾

复杂度

复杂度:O(N),其中N为新追加内容的长度。

INCRBY、DECRBY:对整数值执行加法操作和减法操作

当字符串键存储的值能够被Redis解释为整数时,用户就可以通过INCRBY命令和DECRBY命令对被存储的整数值执行加法或减法操作。INCRBY命令用于为整数值加上指定的整数增量,并返回键在执行加法操作之后的值:

Redis的命令方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
INCRBY key increment

redis> SET num 100
OK
redis> INCRBY num 100
(integer) 200
redis> GET num
"200"

DECRBY key increment
redis> DECRBY num 100
(integer) 100
redis> GET num
"100"

Golang 命令方式代码

1
2
3
4
5
6
7
8
9
10
// StrIncrBy doc
func StrIncrBy(key string, val int64) (int64, error) {
return client.LocalRedis.IncrBy(key, val).Result()
}

// StrDecrBy doc
func StrDecrBy(key string, val int64) (int64, error) {
return client.LocalRedis.DecrBy(key, val).Result()
}

单元测试:

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
func TestStrIncrByAndDecryBy(t *testing.T) {
client.Init()
key := "strNum"
val := 100
result, err := client.LocalRedis.Set(key, val, 0).Result()
if err != nil {
t.Log("redis set err : ", err)
return
}
t.Log("set success result : ", result)


res ,err := StrIncrBy(key,100)
if err != nil {
t.Log("redis incrby err : ", err)
return
}
t.Log("incrby success result : ", res)

getRes, err := GetStr(key)
if err != nil {
t.Log("get err 1: ", err)
return
}
t.Log("get result 1", getRes)

res ,err = StrDecrBy(key,100)
if err != nil {
t.Log("redis decrby err : ", err)
return
}
t.Log("decrby success result : ", res)

getRes, err = GetStr(key)
if err != nil {
t.Log("get err 2: ", err)
return
}
t.Log("get result 2", getRes)

}

结果:

1
2
3
4
5
str_test.go:443: set success result :  OK
str_test.go:450: incrby success result : 200
str_test.go:457: get result 1 200
str_test.go:464: decrby success result : 100
str_test.go:471: get result 2 100

需要注意的是

  • 类型限制,当字符串键的值不能被Redis解释为整数时,对键执行INCRBY命令或是DECRBY命令将返回一个错误。

  • INCRBY和DECRBY的增量和减量也必须能够被Redis解释为整数,使用其他类型的值作为增量或减量将返回一个错误

  • 当INCRBY命令或DECRBY命令遇到不存在的键时,命令会先将键的值初始化为0,然后再执行相应的加法操作或减法操作。

  • INCRBY 可以使用负数来形成减法但不推荐

复杂度

复杂度:O(1)。

INCR、DECR:对整数值执行加1操作和减1操作

因为对整数值执行加1操作或减1操作的场景经常会出现,所以为了能够更方便地执行这两个操作,Redis分别提供了用于执行加1操作的INCR命令以及用于执行减1操作的DECR命令。INCR命令的作用就是将字符串键存储的整数值加上1,效果相当于执行INCRBY key 1:

Redis的命令方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
INCR key 

redis> SET num 100
OK
redis> INCR num
(integer) 101
redis> GET num
"101"

DECRBY key increment
redis> DECRBY num
(integer) 100
redis> GET num
"100"

Golang 命令方式代码

1
2
3
4
5
6
7
8
9
10

// StrIncr doc
func StrIncr(key string) (int64, error) {
return client.LocalRedis.Incr(key).Result()
}

// StrDecr doc
func StrDecr(key string) (int64, error) {
return client.LocalRedis.Decr(key).Result()
}

复杂度

复杂度:O(1)。

INCRBYFLOAT:对数字值执行浮点数加法操作

除了用于执行整数加法操作的INCR命令以及INCRBY命令之外,Redis还提供了用于执行浮点数加法操作的INCRBYFLOAT命令:

Redis的命令方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
INCRBYFLOAT key increment

redis> SET num 100
OK
redis> INCRBYFLOAT num 3.14
"103.14"
redis> GET num
"103.14"

# 可以使用负数做减法
redis> INCRBYFLOAT num -3.14
"100"
redis> GET num
"100"

INCRBYFLOAT命令可以把一个浮点数增量加到字符串键存储的数字值上面,并返回键在执行加法操作之后的数字值作为命令的返回值。

Golang 命令方式代码

1
2
3
4
// StrIncrByFloat doc
func StrIncrByFloat(key string, val float64) (float64, error) {
return client.LocalRedis.IncrByFloat(key, val).Result()
}

与之前的INCRBTY相似,不做测试了就。

需要注意的点总结一下:

  • 处理不存在的键:INCRBYFLOAT命令在遇到不存在的键时,会先将键的值初始化为0,然后再执行相应的加法操作。在以下代码中,INCRBYFLOAT命令就是先把x-point键的值初始化为0,然后再执行加法操作的

  • 使用INCRBYFLOAT执行浮点数减法操作Redis为INCR命令提供了相应的减法版本DECR命令,也为INCRBY命令提供了相应的减法版本DECRBY命令,但是并没有为INCRBYFLOAT命令提供相应的减法版本,因此用户只能通过给INCRBYFLOAT命令传入负数增量来执行浮点数减法操作。

  • INCRBYFLOAT与整数值INCRBYFLOAT命令对于类型限制的要求比INCRBY命令和INCR命令要宽松得多:1.INCRBYFLOAT命令既可用于浮点数值,也可以用于整数值。

    2.INCRBYFLOAT命令的增量既可以是浮点数,也可以是整数。

    3.当INCRBYFLOAT命令的执行结果可以表示为整数时,命令的执行结果将以整数形式存储。

  • 小数位长度限制:虽然Redis并不限制字符串键存储的浮点数的小数位长度,但是在使用INCRBYFLOAT命令处理浮点数的时候,命令最多只会保留计算结果小数点后的17位数字,超过这个范围的小数将被截断

复杂度

复杂度:O(1)。

用例

锁是一种同步机制,用于保证一项资源在任何时候只能被一个进程使用,如果有其他进程想要使用相同的资源,那么就必须等待,直到正在使用资源的进程放弃使用权为止。
一个锁的实现通常会有获取(acquire)和释放(release)这两种操作:

  • 获取操作用于取得资源的独占使用权。在任何时候,最多只能有一个进程取得锁,我们把成功取得锁的这个进程称为锁的持有者。在锁已经被持有的情况下,所有尝试再次获取锁的操作都会失败。
  • 释放操作用于放弃资源的独占使用权,一般由锁的持有者调用。在锁被释放之后,其他进程就可以再次尝试获取这个锁了。

以下代码 展示了一个使用字符串键实现的锁程序,这个程序会根据给定的字符串键是否有值来判断锁是否已经被获取,而针对锁的获取操作和释放操作则是分别通过设置字符串键和删除字符串键来完成的。

Golang 代码 :

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
// Locker 锁对象
type Locker struct {
Rc *redis.Client
}

// NewLocker 新建锁对象
func NewLocker(rc *redis.Client) *Locker {
lc := &Locker{
Rc: client.LocalRedis,
}
if rc != nil {
lc.Rc = rc
}
return lc
}

// Acquire 获取锁
func (l *Locker) Acquire(key string) bool {
defaultVal := "lock"
res, err := l.Rc.SetNX(key, defaultVal, 0).Result()
if err != nil || !res {
return false
}

return true
}

// Release 释放锁
func (l *Locker) Release(key string) error {
_, err := l.Rc.Del(key).Result()
if err != nil {
return err
}

return nil
}

NX选项的值确保了代表锁的字符串键只会在没有值的情况下被设置:

  • 如果给定的字符串键没有值,那么说明锁尚未被获取,SET命令将执行设置操作,并将result变量的值设置为True。
  • 如果给定的字符串键已经有值了,那么说明锁已经被获取,SET命令将放弃执行设置操作,并将result变量的值设置为None。
  • Acquire()方法最后会通过检查result变量的值是否为True来判断自己是否成功取得了锁。因为Redis的DEL命令和Python的del关键字重名,所以在redis-py客户端中,执行DEL命令实际上是通过调用delete()方法来完成的:
1
DEL key [key ...]

因为Redis的DEL命令和Python的del关键字重名,所以在redis-py客户端中,执行DEL命令实际上是通过调用delete()方法来完成的:

测试:

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
func TestLocker_Acquire(t *testing.T) {
client.Init()
locker := NewLocker(nil)
if !locker.Acquire("lock") {
locker.Release("lock")
}
for i := 0; i < 10; i++ {
time.Sleep(500 * time.Millisecond)
go DoTask(locker)
}
}

func DoTask(locker *Locker) {
if !locker.Acquire("lock") {
fmt.Println("该锁被其他程序占有,无法执行任务!")
return
}
ExecTask(locker)
}

func ExecTask(locker *Locker) {
var err error
defer func() {
err = locker.Release("lock")
if err != nil {
fmt.Println("release lock err :", err)
}
}()
fmt.Println("成功取锁,开始执行任务....")
time.Sleep(1 * time.Second)
}

结果:

1
2
3
4
5
6
7
8
9
成功取锁,开始执行任务....
该锁被其他程序占有,无法执行任务!
成功取锁,开始执行任务....
该锁被其他程序占有,无法执行任务!
该锁被其他程序占有,无法执行任务!
成功取锁,开始执行任务....
该锁被其他程序占有,无法执行任务!
成功取锁,开始执行任务....
该锁被其他程序占有,无法执行任务!

可以看到并发的几个goroutine在抢锁并执行任务

这个锁只是简单的保证同步机制,其实是由很多问题的比如这个锁如果锁上后执行程序执行释放时挂了那该资源就会一直被锁。可以设置一个key过期时间,但是设置时间这个操作又会造成新的问题比如上锁成功设置过期时间失败。这个 在以后的介绍中会介绍更优的分布式锁。

限速器

为了保障系统的安全性和性能,并保证系统的重要资源不被滥用,应用程序常常会对用户的某些行为进行限制。比如:

  • 为了防止网站内容被网络爬虫抓取,网站管理者通常会限制每个IP地址在固定时间段内能够访问的页面数量,比如1min之内最多只能访问30个页面,超过这一限制的用户将被要求进行身份验证,确认本人并非网络爬虫,或者等到限制解除之后再进行访问。

  • 为了防止用户的账号遭到暴力破解,网上银行通常会对访客的密码试错次数进行限制,如果一个访客在尝试登录某个账号的过程中,连续好几次输入了错误的密码,那么这个账号将被冻结,只能等到第二天再尝试登录,有的银行还会向账号持有者的手机发送通知来汇报这一情况。

实现这些限制机制的其中一种方法是使用限速器,它可以限制用户在指定时间段之内能够执行某项操作的次数。

以下代码展示了一个使用字符串键实现的限速器,这个限速器程序会把操作的最大可执行次数存储在一个字符串键里面,然后在用户每次尝试执行被限制的操作之前,使用DECR命令将操作的可执行次数减1,最后通过检查可执行次数的值来判断是否执行该操作。

Golang 代码 :

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
44
45
46
47
48

// Limiter 限速器
type Limiter struct {
RClient *redis.Client
Key string
LimitTimes int
Expr time.Duration
}

// NewLimiter 新建一个限速器
func NewLimiter(rc *redis.Client, key string, times int, exp time.Duration) *Limiter {
return &Limiter{
RClient: rc,
Key: key,
LimitTimes: times,
Expr: exp,
}
}

// SetMaxExecuteTimes doc
func (l *Limiter) SetMaxExecuteTimes(times int) error {
ts := l.LimitTimes
if times > 0 {
ts = times
}
return l.RClient.Set(l.Key, ts, l.Expr).Err()
}

// StillValidToExecute 是否仍可执行
func (l *Limiter) StillValidToExecute() bool {
num, err := l.RClient.Decr(l.Key).Result()
if err != nil {
return false
}

return num >= 0
}

// GetRemainExecuteTimes 获取剩余的执行次数
func (l *Limiter) GetRemainExecuteTimes() (int, error) {
numStr, err := l.RClient.Get(l.Key).Result()
if err != nil {
return 0, err
}

return strconv.Atoi(numStr)
}

测试:

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
// 模拟一个爬虫频率限制程序
func TestLimitProgress(t *testing.T) {
client.Init()
crewLerLimit := NewLimiter(client.LocalRedis, "CrawlerLimit", 1, 30*time.Second)
err := crewLerLimit.SetMaxExecuteTimes(3)
if err != nil {
t.Log("err : ", err)
return
}

for {

time.Sleep(5 * time.Second)
remain, err := crewLerLimit.GetRemainExecuteTimes()
if err != nil {
if strings.Contains(err.Error(), "redis: nil") {
err := crewLerLimit.SetMaxExecuteTimes(3)
if err != nil {
t.Log("err : ", err)
return
}

} else {
t.Log("err : ", err)
return
}
}
t.Log("Remain times : ", remain)

if !crewLerLimit.StillValidToExecute() {
t.Log("爬去次数受限请稍后再试。。。")
} else {
t.Log("爬取数据。。。")
}
}

}

模拟一个限制请求次数的限速器,30秒内只能访问三次。30秒后更新新的Key。

结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
limit_test.go:37: Remain times :  3
limit_test.go:42: 爬取数据。。。
limit_test.go:37: Remain times : 2
limit_test.go:42: 爬取数据。。。
limit_test.go:37: Remain times : 1
limit_test.go:42: 爬取数据。。。
limit_test.go:37: Remain times : 0
limit_test.go:40: 爬去次数受限请稍后再试。。。
limit_test.go:37: Remain times : -1
limit_test.go:40: 爬去次数受限请稍后再试。。。
limit_test.go:37: Remain times : 0
limit_test.go:42: 爬取数据。。。
limit_test.go:37: Remain times : 2

其他用例

还有很多用例 可以使用redis的字符串类型,比如:

  • 存储日志:我们可以使用APPEND命令将日志的内容存储在一个key中每条不同的日志用\n进行分割,取出时在做拆分
  • 给文章存储程序加上文章长度计数功能和文章预览功能:使用STRLEN命令和GETRANGE命令,我们可以给文章存储程序加上两个功能,其中一个是文章长度计数功能,另一个则是文章预览功能。1.STRLEN文章长度计数功能用于显示文章内容的长度,读者可以通过这个长度值来了解一篇文章大概有多长,从而决定是否继续阅读。2.GETRANGE 文章预览功能则用于显示文章开头的一部分内容,这些内容可以帮助读者快速地了解文章大意,并吸引读者进一步阅读整篇文章。
  • 全局唯一ID生成器:通过执行INCR命令来产生新的ID
  • 计数器:计数器是构建应用程序时必不可少的组件之一,如对于网站的访客数量、用户执行某个操作的次数、某首歌或者某个视频的播放量、论坛帖子的回复数量等,记录这些信息都需要用到计数器。把计数器的值存储在一个字符串键里面,并通过INCRBY命令和DECRBY命令对计数器的值执行加法操作和减法操作,在需要时,还可以通过调用GETSET方法来清零计数器并取得清零之前的旧值。