初识Redis

Redis是一个远程内存数据库。它是一个速度非常快的非关系型内存数据库。它可以存储键到五种类型的映射。并且可以将存储到内存中的数据持久化到磁盘。并且可以使用复制特性来提高读性能,使用客户端分片来提高写性能/

Redis的独到之处

Redis不同于我们常用的MySQL数据库的一个特性是它是非关系型数据库。简单的讲就是不会使用表。

它和memcached的一大不同之处在于它支持多种不同的数据类型,而memcached只支持字符串键。

Redis还有一个独特之处在于它是内存数据库。因为内存是易失性存储,所以不得不考虑持久化的问题。Redis的持久化提供了两种方案,一种是时间点转储,第二种方案就是将所有修改了数据库的命令写入一个只追加的文件中,根据用户的设置可以选择从不同步、每秒同步一次或者没写入一个命令就同步一次。

KpabTA.png

Redis的用法

Redis的数据结构
KkbVAK.png

  1. string
    Kkb4D1.png

  2. list
    一个列表可以有序的存储多个字符串。
    KkXbdI.png

  3. 集合 set
    集合通过散列表来确保自己存储的每个字符串都是各不相同的。Redis的集合使用无序方式存储元素。
    KkvjKg.png

  4. hash
    Redis的散列可以存储多个键值对之间的映射。和字符串一样散列存储的既可以是字符串又可以是数字值,并且用户同样可以对散列存储的数字执行自增或自减操作。
    Kkx1xO.png

  5. Redis有序结合 zset
    有序集合的键被称为成员,每个成员都是不同的。有序集合的值被称为分值。
    KkxrQS.png

使用Redis构建web应用

登录和cookie缓存

在我们登录互联网服务的时候,这些服务都会使用cookie来记录我们的身份,cookie由少量数据组成,网站会要求我们在之后的每次请求中携带cookie数据。对于用来登录的cookie,有两种常用的方法可以将登录信息存储在cookie中,一种是签名(signed)cookie,另一种是(token)cookie。

我们可以使用Redis来重新实现登录cookie功能。我们使用一个散列表来存储登录cookie令牌与已登录用户之间的映射。要检查一个用户是否已经登录,需要根据给定的令牌来查找与之对应的用户,并在用户已经登陆的情况下,返回该用户的ID。

实战:

案例一:
需求
现在有一个大型的网上商城,每天用户会有大概一亿次点击,并从网站上购买10万多件商品。我们要管理用户的登录会话,并记录用户最近访问的商品。
解决方案:
首先使用一个hash来存储用户的cookie和已登录用户之间的映射,实现那对登录会话的管理。至于记录用户最近浏览的商品,我们可以先计算一下负载,平均每秒需要写入1200次,高峰时期需要写入6000次,如果使用关系型数据库来记录是非常的困难的,因为一台关系型数据库每秒大概能处理200-2000个插入请求。所以我们可以考虑使用吞吐率极大的Redis来存储这些浏览记录。

用到了哪些数据结构:

  1. 一个名为login:的哈希表,其中键为用户的令牌,值为用户信息
  2. 一个名为recent:的有序集合,其中成员为用户的token,分数为用户最后一次登录的时间戳
  3. 一组名为viewed:+token的有序集合,其中成员为该用户浏览过的商品信息,分数为时间戳
1
2
3
4
5
6
7
8
9
10
11
12
13
# 用于检查登录cookie的方法,尝试获取比并返回令牌对应的用户
def check_token(conn,token):
return conn.hget('login:',token) # 尝试获取比并返回令牌对应的用户

# 用于记录用户的浏览记录,维持登录用户与令牌的关系
def update_token(conn,token,user,item=None):
timestamp=time.time() # 获取当前的时间戳
conn.hset('login:',token,user) # 维持令牌与已登录用户之间的映射(哈希)
conn.zadd('recent:',token,timestamp) # 记录令牌最后一次出现的时间 (有序集合)
if item:
conn.zadd('viewed:'+token,item,timestamp) # 记录用户浏览过的商品
conn.zremrangebyrank('viewed:'+token,0,-26) # 移除旧的记录,只保留26各记录

因为存储的会话数据会随着时间的推移而不断地增多,所以我需要定时地清理一些会话数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
QUIT = False
LIMIT = 10000000
def clean_session(conn):
while not QUIT:
size=conn.zcard('recent:')
if size <= LIMIT: # 在令牌数量未达到限制之前一直休眠
time.sleep(1)
continue
end_index=min(size-LIMIT,100)
tokens=conn.zrange('recent:,0,end_index-1) # 获取需要移除地令牌的ID
session_key=[]
for token in tokens:
session_keys.append('view:'+token) # 构建待删除的列表
conn.delete(*session_keys)
conn.hdel('login:',*tokens) # 移除那些最旧的令牌
conn.zrem('recent:',*tokens)

其实clean_session这个行数存在竞争条件的,如果正在清理用户信息的同时,用户登录了,那么这个用户就得重新登录了,因为它刚刚记录的登录信息会被清除掉。


案例2
需求
使用Redis实现购物车
解决方案
每个用户的购物车都是一个散列,这个散列存储了商品ID与商品订购数量之间的映射。我们还需要对商品的数量变动进行维护。

1
2
3
4
5
def add_to_cart(conn,session,item,count):
if count <= 0:
conn.hrem('cart:'+session,item) # 如果数量商品小于0了就移除
else:
conn.hset('cart:+session,item,count) # 将指定的商品添加到购物车中

我们需要改造之前的会话清理函数,在清理用户会话的时候,将其购物车也一并清理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def clean_session(conn):
while not QUIT:
size=conn.zcard('recent:')
if size <= LIMIT: # 在令牌数量未达到限制之前一直休眠
time.sleep(1)
continue
end_index=min(size-LIMIT,100)
sessions=conn.zrange('recent:,0,end_index-1) # 获取需要移除地令牌的ID
session_key=[]
for session in sessions:
session_keys.append('view:'+session) # 构建待删除的列表
seesion_keys.append('cart:'+session)
conn.delete(*session_keys)
conn.hdel('login:',*sessions) # 移除那些最旧的令牌
conn.zrem('recent:',*sessions)

网页缓存

在动态生成网页的时候,通常会使用模板语言来简化网页的生成,其实网页不必每次都重新生成,因为变化的网页只是极少数。我们可以将这些不变的网页缓存起来,下次需要使用的时候直接使用即可。

案例一
需求:
将可悲缓存的网页缓存起来,避免没有必要的重复动态生成。从而减少载入不常改变的页面所需的时间。
解决方案

1
2
3
4
5
6
7
8
9
def cache_request(conn.request,callback):
if not can_cache(conn,request):
return callback(request) # 对于不能缓存的请求,直接调用回调函数
page_key='cache:'+hash_request(request)
content=conn.get(page_key) # 尝试查找被缓存的页面
if not content:
content=callback(request) # 没有被缓存就生成页面
conn.setex(page_key,content,300) # 缓存生成的页面
return content # 返回页面

缓存行数据

一些页面也被我们缓存起来了,比如商品界面每次需要从数据库中载入一两行数据。程序可以通过缓存页面载入时所需的数据库行来减少载入页面所需的时间。

案例一
需求
假设一个商城为了清空库存和吸引消费者决定开始新一轮的促销活动,每天都会提供一些特价商品,所以商品的数量都是有限的,售完即止。
解决方案
在这种情况下,我们是不能对整个页面进行缓存的,否则用户可能看到错误的剩余数量。但是如果,每次载入数据都从数据库中取得话,又会给数据库带来巨大得压力。
我们可以使用一个守护进程函数,将指定得数据行缓存到Redis里面,并不定期得对这些缓存进行更新。缓存得数据以JSON格式进行编码。

使用的数据结构;

  1. 一个名为schedule的有序集合,其中成员为数据行的行ID,分值为缓存该数据行的时间戳。
  2. 一个名为delay的有序集合,其中成员为数据行的行ID,分值为所缓存的数据行,需要多久更新一次。
  3. 一组名为inv:+row_id的集合,其中存储的是以JSON格式保存的行数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def schedule_row_cache(conn,row_id,delay):
conn.zadd('delay:',row_id,delay)
conn.zadd('schedule:',row_id,time.time())

def cache_rows(conn):
while not QUIT:
next =conn.zrange('schedule:',0,0,withscores=Ture)
now=time.time()
if not next or next[0][1] >now
time.sleep(0.05)
continue
row_id =next[0][0]
delay=conn.zscore('delay:',row_id)
if delay<=0 # 如果延迟时间为小于等于0则不必再缓存了
conn.zrem('delay:',row_id)
conn.zrem('schedule',row_id)
conn.delete('inv:'+row_id)
continue
row =Inventory.get(row_id) # 读取数据行
conn.zadd('schedule:',row_id,now+delay)
conn.set('inv:'+row_id,json.dumps(row.to_dict())) #缓存行数据

网页分析

我们是不可能将所有的页面都缓存起来的,所以我们需要挑选用户经常访问的页面。为此,我们可以修改之前记录用户浏览记录的函数,来统计最经常被浏览的一批商品,然后我们再缓存它。

1
2
3
4
5
6
7
8
9
# 用于记录用户的浏览记录,维持登录用户与令牌的关系
def update_token(conn,token,user,item=None):
timestamp=time.time() # 获取当前的时间戳
conn.hset('login:',token,user) # 维持令牌与已登录用户之间的映射(哈希)
conn.zadd('recent:',token,timestamp) # 记录令牌最后一次出现的时间 (有序集合)
if item:
conn.zadd('viewed:'+token,item,timestamp) # 记录用户浏览过的商品
conn.zremrangebyrank('viewed:'+token,0,-26) # 移除旧的记录,只保留26各记录
conn.zincrby('viewed:',item,-1) # 新添加的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 定期维护浏览次数排行榜
def rescake_viewed(conn):
while not QUIT:
conn.zremrangebyrank('viewed:',0,-20001) # 删除排名在20000名之后的商品
conn.zinterstore('viewd:',{'viewed:':.5}) # 将浏览次数降低为原来的一半
time.sleep(300) # 5分钟之后再次执行这个操作

def can_cache():
item_id=extract_item_id(request)
if not item_id or is_dynamic(request):
return False
rank=conn.zrank('viewed:',item_id)
return rank is not None and rank <10000 # 只有排名在10000名之前的才能被缓存

Redis核心概念

Redis命令

字符串

Redis的字符串就是一个由字节组成的序列,它可以存储一下3中类型的值:

  1. 字符串
  2. 整数
  3. 浮点数
    Redis可以对存储的整数或浮点数进行自增或自减操作。
1
2
3
4
5
incr key-name //将键存储的值加一
decr key-name //将键存储的值减一
incrby key-name amount //将键存储的值加上整数amount
decrby key-name amount //将键存储的值减去整数amount
incrbyfloat key-name amount //键键存储的值加上浮点数

如果对一个不存在的键或者空串进行自增,自减操作的话,会按这个键值为0进行处理;如果进行操作的键的值不能被解释为整数或者浮点数的时候会报错

1
2
3
4
5
6
7
append key-name value //将值追加到给定键key-name当前存储的值的末尾
getrange key-name start end //获取一个由偏移量start至end范围内所有字符组成的子串
setrange key-name offset value //键从start偏移量开始的子串设置为给定值
getbit key-name offset //将字节串看作是二进制位串,并返回位串中便宜量位offset的二进制位的值
setbit setbit key-name offset value //将字符串看作是二进制位串,并将位串中偏移量位offset的二进制位的值设置位value
bitcount key-name [start end] // 统计二进制位串中值位1的二进制位的数量
bittop operation dest-key key-name [key-name ...] //对一个或多个二进制位串执行包括并,或、异或、非在内的任意一种按位运算操作,并将计算的得出的结果保存到dest-key键里面。

列表

Redis的列表允许用户从序列的两端推入或弹出元素,获取列表元素,以及执行各种常见额列表操作。
Knf2wt.png

阻塞执行的命令:
KnhepD.png
列表的一个主要优点在于它可以包含多个字符串值,这使得用户可以键数据集中的同一个地方。

集合

Redis集合以无序的方式来存储多个各部相同的元素,用户可以快速的对集合执行添加元素操作,移除元素操作以及检查一个元素是否存在于集合里。

KMwNS1.png

散列

Redis的散列可以让用户将多个键值对存储到一个Redis键里面。

KMBSDf.png

KMDv0f.png

有序集合

有序集合也存储着成员与分值之间的映射,并且提供了分值处理命令,以及根据分值大小有序地获取或扫描成员和分值地命令。

KMrf3j.png

KMsozd.png

发布与订阅

发布与订阅地特定是订阅者负责订阅频道,发送者负责向频道发送二进制字符串消息。每当有消息被发送至给定频道时,频道地所有订阅者都会收到消息。

1
2
3
4
5
subscribe channel [channel...] //订阅给定的一个或多个频道
unsubscribe [channle [channel...] ] //退订给定的一个或多个频道
publish channel message //向给定频道发送消息
psubscribe pattern [pattern ...] //订阅与给定模式相匹配的所有频道
punsubscribh [pattern [pattern ...]] //退订给定的模式,如果执行时没有给定的任何模式,那么会退订所有模式

Redis事务

Redis的基本事务需要用到MULTI命令和EXEC事务,这种事务可以让一个客户端在不被其它客户端打断的情况执行多个命令。被MULTI和EXEC包裹的命令会一条接着一条的执行,不会被其它命令打断

键的过期时间

可以通过Redis的过期时间特性来让一个键在给定的时限之后自动被删除。
KM2BvQ.png

数据安全与性能保障

持久化选项

Redis提供了两种方案来实现数据的持久化,一种是快照,它可以将存在某一时刻的所有数据都写入磁盘。另一种方法叫做只追加文件*,他会在执行写命令时,键被执行的写命令复制到硬盘里面。

快照持久化

Redis可以通过创造快照来获得存储在内存里面的数据子啊某个时间点上的副本。可以利用创建的快照进行数据的备份和创建具有相同数据的服务器。
创建快照的方法:

  1. 通过向Redis发送BGSAVE命令,Redis会调用fork来创建一个子进程,然后子进程负责将快照写入硬盘,而父进程继续处理请求。
  2. 通过向Redis发送SAVE命令,在创建完快照之前将不再响应任何其它命令。
  3. 用户设置的save配置选项,如save 60 10000 ,从最近一次创建快照算起,达到60秒内插入10000次写入,这个条件被满足时,Redis会自动触发BGSAVE命令。
  4. 当Redis接收到SHUTDOWN命令接收到关闭服务器的请求时,或收到标准TERM信号时,会执行一个SAVE命令,阻塞所有客户端。
  5. 当一个Redis服务器连接到另一个Redis服务器的时候,并向对方发送SYNC命令来开始一次复制的时候,那么主服务器会执行BGSAVE命令。

随着Redis所占用内存的大小的增加,其创建用于生成的快照的子进程的时间也会增加。但总的来所,Redis创建快照是非常的快的,一般以毫秒为单位。

AOF持久化

AOF持久化就是将执行过的命令追加到AOF文件末尾,以此来实现数据变化的记录。因此Redis只需要从头到尾的执行以便AOF文件中的所有写命令,就能恢复,AOF所记录的数据集。

文件同步:写文件的时候会调用操作系统的write()方法,写入的内容首先会被存放在缓冲区内,然后在之后的某个时刻,操作系统会把缓冲区内的数据写入到磁盘文件中,只有当数据写入到磁盘文件才,完成了数据的真正持久化,我们可以使用flush()方法来强制将缓冲区中的内容写入磁盘。

同步选项

1
2
3
always //每个Redis命令写入都要同步磁盘
ererysec //每秒执行一次同步,显示的将多个命令同步到硬盘
no //让操作系统来决定什么时候同步

同步的越频繁,写入性能越低,数据安全性越高,系统发生崩溃时丢失的数据越少。凡之亦然。

AOF持久化有一个非常致命的缺陷,那就是AOF文件的体积。

因为Redis会不断的向AOF文件中写入命令,这会导致文件的体积不断地增大。为了解决这个问题可以这样:

  1. 使用BGREWRIEAOF命令可以通过移除APF中地冗余命令来重写AOF文件。它地工作原理和BGSAVE命令类似,也是使用子进程来完成。AOF持久化也可以通过哦设置auto-aof-rewrite-percentage选项和auto-aof-rewrite-min-size选项来自动执行BGREWRITEAOF.

复制

复制可以让其它服务器拥有一个不断更新地数据副本,可以使得拥有数据副本地服务器可以处理客户端发送地读请求。Redis可以采用主从复制的策略。

当设置好主从复制之后,当客户端向主服务器进行写入时,从服务器都会实时的得到更新,这样客户端就可以向任意一各服务器发送读请求了。

Redis复制的启动过程

因为redis服务器在连接之前需要执行BGSAVE命令,所以要提前设置好dir选项和dbfilename选项。对于从服务器必要要使用SLAVEOF host port 命令来让服务器开始复制一个新的主服务器。


Redis在复制期间也会尽可能地处理接收到地命令请求,但是如果主从服务器之间地带宽不足,或主服务器没有足够地内容来创建子进程和记录写命令地缓冲区,那么Reids处理命令地请求效率就会受到影响。

从服务器在进行同步时,会丢弃自己之前地数据
Redis不支持主主复制

当一个新地从服务器连接到已有地主服务器时,有时可以重用已有地快照文件。

因为从服务器和主服务器并没有什么特别地差距,所以从服务器也可以拥有自己地从服务器,并由此形成主从链。

从服务器从从服务器进行复制和从服务器从主服务器进行复制地区别在于:
假如从服务器X有从服务器Y
从服务器X在执行对快照文件的解释的时候,会断开它与从服务器Y之间的连接。

Redis事务

Redis中可以简单的使用MULTI和EXEC命令来处理一段互相一致的读操作和写操作。
在执行EXEC命令之前系统是不会执行任何操作的,这使得用户无法根据读取到的数据来做决定。而且在多个事务同时处理一个对象的时候通常需要二阶提交,所以如果事务不能以一致的形式读取数据,那么二阶提交将无法实现。

因为对数据进行加锁,会造成长时间的等待,所以Redis会尽可能地减少客户端地等待时间,并不会在执行WATCH命令时进行加锁,相反地,Redis会在数据被其它客户端抢先修改了的情况下,通知执行WATCH操作的客户端,这种被称为乐观锁,而关系数据库实际执行的加锁操作被称为被guan