redis

Redis 与 Pika scan 性能对比

Redis 是后端常用的键值数据库。Pika 是 360 出品的一款与 Redis 协议几乎兼容的数据库。与 Redis 不同的是,Pika 基于硬盘,使用 RocksDB 作为引擎,从容量上来说,比基于内存的 Redis 大了不少,而且在性能上也能满足一般需求。

我们知道,在 Redis 中,keys * 这个操作仅限于在本地调试使用,千万不能用于线上,因为这会遍历整个数据库,可能引起数据库长时间无响应,甚至崩溃。在线上服务器,如果想要查找某个模式的键,可以使用 scan 命令。比如说要查找 user: 前缀的所有键,可以使用 scan 0 user:* 命令。然而如果服务器上的键非常多的话,虽然不会卡死服务器了,但是这个过程依然会很漫长。

Redis 是使用 hash table 实现的,所以 scan 命令其实也是遍历所有键,拿到每个键再做过滤,而不能直接读取符合对应 pattern 的键。我们使用下面的代码来验证一下 redis scan 的性能。

from redis import Redis
from uuid import uuid4
import time


def gen(r):
    for i in range(10000000):
        r.set(str(uuid4()), 1)
    r.set("user:1", "bar")


def scan(r):
    start = time.time()
    for key in r.scan_iter("user:*"):
        print("user=%s" % r.get(key).decode())
        duration = time.time() - start
        print("duration for finding user is %.3f" % duration)
    duration = time.time() - start
    print("duration for full scan is %.3f" % duration)


if __name__ == "__main__":
    import sys
    port = int(sys.argv[1])
    r = Redis(port=port)
    gen(r)
    scan(r)

首先插入一千万个随机数据,然后从中查找我们的目标数据。结果如下:

-> % python3 rb.py 6379
user=bar
duration for finding user is 80.145
duration for full scan is 180.936

和我们的预期基本是相符的, 也就是说 Redis 是首先遍历然后再做过滤的。

接下来我们对 Pika 做相同的实验,Pika 默认使用 9221 端口,我们只需要把端口换一下就好了:

-> % python3 rb.py 9221
user=bar
duration for finding user is 0.002
duration for full scan is 0.003

结果是令人震惊的!Pika 几乎在瞬间就完成了遍历。原因在于 Pika 使用了 RocksDB,而 RocksDB 支持 Range 操作。RocksDB 中的数据都是有序的,所以查找起来就不需要 O(n) 了,只需要二分查找,也就是 O(logN) 即可。

redis 常见问题

主要参考这篇文章:https://mp.weixin.qq.com/s/vS8IMgBIrfGpZYNUwtXrPQ

1. 集合操作避免范围过大

使用 sortedset、set、list、hash等集合类的O(N)操作时要评估当前元素个数的规模以及将来的增长规模,对于短期就可能变为大集合的key,要预估O(N)操作的元素数量,避免全量操作,可以使用HSCAN、SSCAN、ZSCAN进行渐进操作。集合元素数量过大在使用过程中会影响redis的实际性能,元素个数建议尽量不要超过5000,元素数量过大可考虑拆分成多个key进行处理。

2. 合理使用过期时间

如果key没有设置超时时间,会导致一直占用内存。对于可以预估使用生命周期的key应当设置合理的过期时间或在最后一次操作时进行清理,避免垃圾数据残留redis。redis 不是垃圾桶。

3. 利用批量操作命令

假设要给一个集合导入 5000 个元素:

方案1:直接使用redis的HSET逐个设置

for _ in 0..5000
    HSET hash, k,v

结果:失败。redis ops飙升,同时接口响应超时

方案2:改用redis的 HMSET一次将所有元素设置到hash中

map<k, v> = 50000个元素
HMSET hash map

结果:失败。出现redis慢日志

方案3:依然使用 HMSET,只是每次设置500个,循环100次

map_chunk<k, v> = 500个元素
for i in 0..100
    HMSET hash map_chunk[i]

结果:成功

MSET/HMSET等都支持一次输入多个key,LPUSH/RPUSH/SADD等命令都支持一次输入多个value,也要注意每次操作数量不要过多,建议控制在500个以内

4. 合理设置值的大小

String类型尽量控制在10KB以内。虽然redis对单个key可以缓存的对象长度能够支持的很大,但是实际使用场合一定要合理拆分过大的缓存项,1k 基本是redis性能的一个拐点。当缓存项超过10k、100k、1m性能下降会特别明显。关于吞吐量与数据大小的关系可见下面官方网站提供的示意图。

在局域网环境下只要传输的包不超过一个 MTU(以太网下大约 1500 bytes),那么对于 10、100、1000 bytes不同包大小的处理吞吐能力实际结果差不多。

5. 禁用一些命令

keys、monitor、flushall、flushdb应当通过redis的rename机制禁掉命令,若没有禁用,开发人员要谨慎使用。其中flushall、flushdb会清空redis数据;keys命令可能会引起慢日志;monitor命令在开启的情况下会降低redis的吞吐量,根据压测结果大概会降低redis50%的吞吐量,越多客户端开启该命令,吞吐量下降会越多。

keys和monitor在一些必要的情况下还是有助于排查线上问题的,建议可在重命名后在必要情况下由redis相关负责人员在redis备机使用,monitor命令可借助redis-faina等脚本工具进行辅助分析,能更快排查线上ops飙升等问题。

6. 避免大量 key 同时过期

如果大量的 key 过期时间设置得过于集中,到过期的时间点,redis 可能会出现短暂的卡顿现象。一般需要在时间上加一个随机值,使得过期时间分散一些。

7. Redis 如何做持久化

bgsave 做镜像全量持久化,aof 做增量持久化。因为 bgsave 会耗费较长时间,不够实时,在停机的时候回导致大量丢失数据,所以需要 aof 来配合使用。在 redis 实例重启时,优先使用 aof 来回复内存状态,如果没有 aof 日志,就会使用 rdb 来恢复。

如果 aof 文件过大会导致恢复时间过长,不过 redis 会定期做 aof 重写,压缩 aof 文件日志大小。在 redis 4.0 之后还有了混合持久化的功能,将 bgsave 的全量和 aof 的增量做了融合处理,这样既保证了回复的效率有兼容了数据的安全性。

为了避免断电时后丢失数据,还可以设置 aof 日志的 sync 属性,极端情况下,可以每次写入都执行,不过会对性能有影响,一般每秒一次就可以。

8. 保存失败

redis 报错:Can’t save in background: fork: Cannot allocate memory。

原因是 redis 在后台保存的时候会直接 fork 一下,然后保存。由于数据库过大,就会 fork 失败,但是实际上由于 copy-on-write 机制的存在,并不会产生问题。所以可以直接更改系统的配置,允许 fork。

/etc/sysctl.conf 文件修改如下:

vm.overcommit_memory=1

然后重新加载:

sysctl -p /etc/sysctl.conf

参考资料:

  1. https://stackoverflow.com/questions/11752544/redis-bgsave-failed-because-fork-cannot-allocate-memory

redis 中如何给集合中的元素设置 TTL

我们知道在 redis 中可以给每个 key 设置过期时间(TTL),但是没法为每个集合中的每一个元素设置过期时间,可以使用zset来实现这个效果。

直接上代码吧,Python 版的。

class RedisSet:

    def __init__(self, key):
        self.client = redis.StrictRedis()
        self.key = key

    def add(self, val, ttl=60):
        now = time.time()
        # 把过期时间作为 score 添加到 zset 中
        self.client.zadd(self.key, now + ttl, val)
        # 删除已经过期的元素
        self.client.zremrangebyscore(self, now, "+inf")

    def getall(self):
        # 只读取还没有过期的元素
        return self.client.zrangebyscore(self.key, "-inf", time.time())

参考:https://github.com/antirez/redis/issues/135

阅读 redis 源码

字符串

redis 内部使用Simple Data String 代表字符串。结构如下:

sds的内存分配策略:

  1. 如果当前内存不能够放得下需要的字符串,长度翻倍。放得下则直接放。当超过30M时,则每次增长1M
  2. 如果释放内存时,不释放空间
  3. redis的字符串是二进制安全的

链表

redis 的 list 是使用链表实现的。

typedef struct listNode {
    struct listNode * prev;
    struct listNode * next;
    void * value;
} listNode;

typedef struct list {
    listNode * head;
    listNode * tail;"
    unsigned long len;
    void *(*dup) (void *ptr);
    void (*free)(void *ptr);
    int (*match) (void * ptr, void * key);
} list;
  • https://zhengqm.github.io/code/2015/06/20/Learn-by-hacking-redis-source-code/
  • https://github.com/huangz1990/blog/blob/master/diary/2014/how-to-read-redis-source-code.rst

学习 redis 的基础命令

basically, redis is a data structure server

string list set sorted set hash

key related

keys <pattern>  list all keys share the pattern
exists key  
del key 
expire key expiration   
expireat key timestamp  
ttl key 
rename key  
type key    

string related

set key value   set mystr "hello world"
setex key timeout value set key with expiration
setnx key value set only not exist
get key -> value    get mystr
getset key new -> old   get old and set new
setrange key offset value   
getrange key start end  returns the value, inclusive
mget key... returns a list of values
incr key    mynum
decr key    mynum
incrby key value    mynum
decrby key value    mynum
getset  

hash

hmset key f v ...   store kv pair in hash
hgetall key 
hdel key f  
hexists key f   
hkeys key   
hlen key    
hvals key   

lists

list is implemented as a double-linked list

lpush key value1 value2 value3   lpushx only pushes if not exist
rpush
lpop key    
rpop
rpoplpush src dst
blpop key... timeout    block until one value is avaliable
lindex key index
llen key
lrange key start end    inclusive
linsert key 
lrem key count value    
lset key index value    
ltrim key start end 

tricks

to get all elements with lrange: use lrange KYE 0 -1

Persistense

RDB
AOF

Transaction

MULTI用来组装一个事务;
EXEC用来执行一个事务;
DISCARD用来取消一个事务;
WATCH用来监视一些key,一旦这些key在事务执行之前被改变,则取消事务的执行。

zset

rank is which place the value ranked by score in the zset.

add and remove cookies

zadd KEY SCORE MEMBER           # add a value to a zset
zincrby KEY SCORE MEMBER        # increment the member"s score NOTE redis-py implements wrongly
zrem KEY MEMBER...              # remove a value from zset
zremrangebyrank KEY START STOP  # removes all values in the set within the give index
zremrangebyscore KEY MIN MAX    # removes all values in the set within the given scores

get zset stats

this is zismember command, just use zscore KEY MEMBER is None to check

zcard KEY                       # get the number of elements in a zset
zcount KEY MIN MAX              # count the members in a sorted set with scores within the given scores
zrank KEY MEMBER                # get the index of member in zset
zrevrank KEY MEMBER             # the reverse index of member
zscore KEY MEMBER               # get the score of member in zset

read member(s)

zrange KEY START END            # a range of members by index
zrevrange KEY START END         # a range of memvers by index, sorted from high to low
zrangebyscore KEY MIN MAX       # a range of members within given scores
zrevrangebyscore KEY MAX MIN    # a range of members within given scores, from max to min

set manipulation

redis 实战总结

redis是做什么的

一个数据结构存储器,数据驻留在内存里,可以在程序的两次之间保存数据

一些实现细节和比较好的地方

redis 的 string 是 binary-safe 的,可以存储任意的二进制数据(bytes),甚至可以把图片存储在 redis 中

经常用到的场合

  1. 用作缓存

    1. 最基础的,最经典的应用场合,当查询数据库或者ES等存储代价比较高的时候,直接用查询的语句做 key,查询结果用作缓存
  2. 用做队列. Redis 5.0 之后最好用 Redis Stream,不要用 list。

  3. 用做集合,也就是存储一批数据的池子。用作有序集合

经常遇到的问题

过期之间只能指定到键级别,而不能指定到集合的键级别

pipeline

imporve performance by combining multi command into one and reduce TCP times

>>> p = r.pipeline()        # 创建一个管道
>>> p.set("hello","redis")
>>> p.sadd("faz","baz")
>>> p.incr("num")
>>> p.execute()
[True, 1, 1]
>>> r.get("hello")
"redis"

or 

>>> p.set("hello","redis").sadd("faz","baz").incr("num").execute()

默认的情况下,管道里执行的命令可以保证执行的原子性,执行pipe = r.pipeline(transaction=False)可以禁用这一特性。

key 的命名

colon sign : is a convention when naming keys. Try to stick with a schema. For instance “object-type:id:field” can be a nice idea, like in “user:1000:password”. I like to use dots for multi-words fields, like in “comment:1234:reply.to”.

使用方法

Redis 是个好东西,提供了很多好用的功能,而且大部分实现的都还既可靠又高效(主从复制除外)。所以一开始我们犯了一个天真的用法错误:把所有不同类型的数据都放在了一组 Redis 集群中。

  • 长生命周期的用户状态数据
  • 临时缓存数据
  • 后台统计用的流水数据

导致的问题就是当你想扩分片的时候,客户端 Hash 映射就变了,这是要迁移数据的。而所有数据放在一组 Redis 里,要把它们分开就麻烦了,每个 Redis 实例里面都是千万级的 key。

根据数据性质把 Redis 集群分类;我的经验是分三类:cache、buffer 和 db

  • cache:临时缓存数据,加分片扩容容易,一般无持久化需要。
  • buffer:用作缓冲区,平滑后端数据库的写操作,根据数据重要性可能有持久化需求。
  • db:替代数据库的用法,有持久化需求。

规避在单实例上放热点 key。

同一系统下的不同子应用或服务使用的 Redis 也要隔离开