助记提要
- 快照持久化的作用和缺点
- Redis创建快照的时机
- AOF文件同步的三种配置
- AOF文件重写的方式
- Redis复制的配置项和控制命令
- Redis复制过程 5步
- Redis主从链
- 确认数据写入从服务器硬盘
- 故障处理的两步
- Redis事务命令 5个
- Redis事务的特点 3点
- 非事务型流水线
- 使用性能测试工具评估客户端的性能
4章 数据安全与性能保障
持久化和复制 故障恢复 事务和流水线
4.1 快照持久化
快照持久化是将某一时刻存储在内存里的所有数据写入硬盘。
快照持久化适合丢失数据不影响运行的程序,或者丢失的数据容易恢复的情况。
Redis的持久化配置项
# 快照持久化配置
save 60 1000
stop-writes-on-bgsave-error no
rdbcompression yes
dbfilename dump.rdb
# AOF持久化配置
appendonly no
appendfsync everysec
no-appendfsync-on-rewrite no
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
# 共享配置(快照文件和AOF文件的保存位置)
dir ./
快照的作用
- 进行数据备份
- 创建具有相同数据的副本
快照持久化的缺点
在新的快照文件创建完毕之前,如果系统发生崩溃会丢失最近一次创建快照之后更改的所有数据。
在Redis数据量大的时候,快照持久化需要的时间和内存占用会变大,可能造成性能问题或丢失数据。
Redis创建快照的时机
- 客户端向Redis发送BGSAVE命令。Redis会创建子进程将快照写入硬盘,主进程继续响应命令。
- 客户端向Redis发送SAVE命令。Redis主进程暂停响应命令,创建快照。
- 配置中设置save选项。
save 60 10000
配置表示从Redis创建上一个快照开始,60秒内有10000次写入的话就自动触发BGSAVE命令。设置多个save配置时,只要满足其中一个,就会创建快照。 - Redis收到SHUTDOWN命令或收到标准TREM信号时,会执行SAVE命令,完毕后关闭服务器。
- Redis从服务器向Redis主服务器发送SYNC命令来执行一次复制操作时,如果主服务器最近没有执行过BGSAVE命令,那么主服务器就会执行一次BGSAVE命令。
SAVE命令不常用。一般只会在内存不够执行BGSAVE命令,或者允许等待持久化操作完毕的情况下才会使用。
快照的使用实例
-
个人开发
为了降低快照持久化的资源消耗,设置save 900 1
。
Redis在上次成功生成快照的15分钟内有写操作,就开始BGSAVE操作。 -
日志聚合计算
需要考虑可承受丢失多长时间内产生的新数据,和如何恢复中断的日志处理操作。
def process_logs(conn, path, callback):
"""
:param conn: Redis连接;
:param path: 日志文件路径;
:param callback: 具体处理日志的回调函数;
"""
# 取当前的处理进度。
current_file, offset = conn.mget(
'progress:file', 'progress:position'
)
# 使用事务流水线
pipe = conn.pipeline()
def update_progress():
# 更新当前处理的文件和位置
pipe.mset({
'progress:file': fname,
'progress:position': offset
})
pipe.execute()
for fname in sorted(os.listdir(path)):
if fname < current_file:
continue
inp = open(os.path.join(path, fname), 'rb')
# 对于之前未完全处理的文件,忽略已处理部分
if fname == current_file:
inp.seek(int(offset, 10))
else:
offset = 0
for lno, line in enumerate(inp):
# 按行处理日志
callback(pipe, line)
offset += int(offset) + len(line)
# 处理完1000行或一个完整文件的时候更新进度
if not (lno + 1) % 1000:
update_progress()
update_progress()
inp.close()
- 大数据
Redis中的数据量达到数十个GB,且系统剩余内存不足时,执行BGSAVE可能导致长时间的停顿。
Redis所在系统 | Redis每占用1GB内存,BGSAVE创建子进程耗费的时间 |
---|---|
真实硬件、VMWare、KVM虚拟机 | 10~20ms |
Xen云虚拟机 | 200~300ms |
为了避免Redis由于创建子进程出现停顿,可以手动发送BGSAVE或者SAVE来持久化。
手动发送BGSAVE也会停顿,但是可以控制停顿出现的时间。
SAVE虽然会阻塞Redis,但是它不需要创建子进程,且创建快照的速度比BGSAVE快。
4.2 只追加文件AOF
执行写命令时将被执行的写命令写到AOF文件末尾,记录数据发生的变化。
只要Redis从头到尾执行一次AOF文件包含的所有写命令,就能恢复AOF文件记录的数据集。
AOF文件同步
文件同步:
往硬盘写文件时,写入的内容会先存到缓冲区,再由操作系统在某个时候把缓冲区的内容写入硬盘。用户可以让操作系统将文件同步到硬盘,同步操作会一直阻塞直到指定的文件被写入硬盘为止。
不同appendfsync参数配置的同步频率
- always
每个Redis写命令时都写入硬盘。这样会使系统发生故障时造成的数据丢失捡到最少。但是会使Redis处理命令的速度受限于硬盘性能。
固态硬盘使用“appendfsync always”配置时,每次只写入一条命令,这种做法会引发严重的“写入放大”问题,降低固态硬盘的寿命。
-
everysec
每秒执行一次同步,显式地把多个写命令同步到硬盘。
这个配置下Redis的性能几乎不受影响。即使系统崩溃,也只会损失1秒内的数据。 -
no
Redis不对AOF文件执行任何显式地同步操作,而是由操作系统决定何时对AOF文件进行同步。
不影响Redis性能,但是系统崩溃时会丢失不定数量的数据。
同时,如果硬盘的写入速度不够快,在缓冲区被等待写入硬盘的数据填满时,Redis写入操作会被阻塞,使Redis处理命令的速度变慢。
AOF文件重写
Redis不断运行,AOF文件体积会越来越大。
Redis重启后需要重新执行AOF文件记录的写命令来还原数据集,如果AOF文件很大,还原操作的时间会很长。
-
重写命令BGREWRITEAOF
使用BGREWRITEAOF命令会让Redis通过移除AOF文件中的冗余命令来重写AOF文件,使其体积变小。Redis会创建一个子进程来进行重写操作。创建子进程也会导致性能和内存占用问题。而且,删除过大的AOF文件可能导致系统挂起数秒。 -
重写配置项
auto-aof-rewrite-percentage,AOF文件体积超出上一次重写时的体积的百分比;
auto-aof-rewrite-min-size,AOF体积大于改配置设定;
同时满足上述两个配置的条件时,Redis自动执行BGREWRITEAOF操作。
4.3 复制
复制指让其他的服务器拥有一个不断更新的数据副本,拥有副本的服务器可以用来处理客户端的读请求。
在扩展平台以适应更高负载时,经常需要复制。
通常会使用一个主服务器向多个从服务器发送更新。从服务器接收到主服务器的数据初始副本后,客户端每次向主服务器写入时,从服务器都会实时更新。客户端就能向任何一个从服务器发送读请求了。
复制配置
- 主服务器的配置中需要设置
dir
和dbfilename
两个选项。 - 启动服务器时,指定一个包含
slaveof host port
选项的配置文件,Redis就会根据这个选项连接主服务器。
使用配置创建从服务器,从服务器启动时会先载入当下可用的任何快照或AOF文件,然后连接主服务器开启复制。
用户可以发送“SLAVEOF no one”的命令是服务器停止复制操作,且不再接受主服务器的更新。
用户能通过发送“SLAVEOF host port”命令让服务器开始复制一个新的主服务器。Redis会立即尝试连接主服务器,连接成功后开始复制。
Redis复制的过程
初始连接主服务器时,从服务器上的原有数据会全部丢失。
-
复制对主服务器的内存要求
Redis在复制期间会尽可能地处理接收到的命令请求。此时如果主从服务器网络带宽不够,或是主服务器内存不足够创建子进程和记录命令的缓冲区,Redis的效率就会受到影响。
因此最好让主服务器只使用50%-65%的内存,剩下的用于执行BGSAVE和创建缓冲区。 -
Redis不支持主主复制和多主复制
互相设置为主服务器的两个Redis实例只会持续占用大量处理器资源并不断尝试与对方通信。客户端连接不同的服务器会得到不一致的数据或得不到数据。 -
从服务器连接主服务器的时机
多个从服务器同时连接同一个主服务器时,同步占用的带宽可能会使命令请求难以传给主服务器。
在复制过程中有新的从服务器连接时,如果此时BGSAVE正在执行或已经执行完毕,主服务器会先和之前连接的从服务器完成复制的5步,然后和新连接的从服务器再执行一遍复制过程。
主从链
Redis的从服务器也可以拥有下一级的从服务器,形成主从链。
在读请求很多,需要更多从服务器来处理时,主从链可以避免多个从服务器同时从一个主服务器复制时造成的网络拥堵。
从服务器在与主服务器执行复制时,将断开和下一级从服务器的连接,导致下一级从服务器需要重连并重新同步。
检验硬盘写入
数据同步到多台从服务器上后,后续的读操作才能取到正确的数据。
为了确保这一点,除了在各个从服务器上配置appendonly yes
和appendfsync everysec
选项外,最好在写操作后,主动确认数据确实写到了从服务器的硬盘上。
- 确认数据已发给从服务器
用户可在主服务器写入完后,再往主服务器写入一个唯一的虚构值,然后检查该值是否存在于从服务器上。 - 确认数据已存在从服务器硬盘
对于每秒同步一次AOF的Redis服务器来说,用户可以等待1秒来确保对数据的改动已保存到硬盘。
更快的做法是,检查INFO命令的“aof_pending_bio_fsync”属性的值是否为0,为0则表示服务器已经把已知的所有数据都存到硬盘了。
以下函数可以在向主服务器写入数据后,检查数据是否存入从服务器硬盘。
def wait_for_sync(mconn, sconn):
# 主服务器添加令牌
identifier = str(uuid.uuid4())
mconn.zadd('sync:wait', identifier, time.time())
# 确认从服务器已连接主服务器,必要的话可以等待同步完成
while not sconn.info()['master_link_status'] != 'up':
time.sleep(0.001)
# 检查从服务器是否有更新数据
while not sconn.zscore('sync:wait', identifier):
time.sleep(0.001)
# 最多等待1秒
deadline = time.time() + 1.01
while time.time() < deadline:
# 检查缓冲区的数据是否写入到硬盘
if sconn.info()['aof_pending_bio_fsync'] == 0:
break
time.sleep(0.001)
# 清理刚刚创建的令牌和之前遗留的令牌
mconn.zrem('sync:wait', identifier)
mconn.zremrangebyscore('sync:wait', 0, time.time()-900)
INFO命令提供了大量与Redis服务器当前状态有关的信息。如内存占用、客户端连接数、每个数据库包含的键数、上一次快照后执行的写次数等。
4.4 处理系统故障
验证快照文件和AOF文件
通过命令行程序检查AOF文件和快照文件的状态
# 检查AOF文件
redis-check-aof [--fix] <file.aof>
# 检查快照文件
redis-check-dump <dump.rdb>
redis-check-aof命令的–fix参数可以对AOF文件修复。它扫描AOF文件,找到不正确的命令后,会删除错误命令及其之后的所有命令。一般删除AOF文件末尾的不完整的命令。
快照文件出错时无法修复的,因此最好为它保留多个备份,并在数据恢复时,运计算快照的SHA1散列值和SHA256散列值验证内容。
Redis会在快照文件中包含快照文件自身的CRC64校验和。CRC校验可以发现网络传输错误和硬盘损坏。用户翻转文件中任意数量的二进制位,然后通过翻转最后64个二进制位的一个子集来产生与源文件相同的CRC64校验和。
更换故障主服务器
-
新服务器做主服务器
向从服务器发送一个SAVE命令,让它创建快照文件。然后把快照文件发送给新的服务器,在新服务器上启动Redis,让原先的从服务器成为新服务器的从服务器。 -
新服务器做从服务器
原先的从服务器升级为主服务器,然后为它创建从服务器。
4.5 Redis事务
Redis事务命令
MULTI
,开始事务;
EXEC
,结束事务。该命令调用之后,才会执行从MULTI开始输入的各个命令。
WATCH
,对某个键进行监视,直到执行EXEC命令之前,如果有其他客户端抢先对被监视的键进行替换、更新或删除操作,执行EXEC命令时,事务会失败并返回错误。
UNWATCH
,可以在WATCH命令之后、MULTI命令之前对连接进行重置。
DSICARD
,可以在MULTI执行之后、EXEC执行之前对连接进行重置。
Redis事务特点
-
事务延迟执行
Redis执行事务时,会延迟执行已入队的命令,直到客户端发送EXEC命令为止。
这种一次性发送多个命令,然后等待所有回复出现的做法通常称为流水线。
可以减少客户端与Redis的网络通信次数,提升Redis的性能。 -
无法以一致的形式读取数据
由于Redis事务在EXEC执行前不会执行任何操作,因此用户无法根据读取到的数据来做决定。
多个事务同时处理一个对象时通常需要用到二阶提交,事务不能以一致的形式读取数据,所以二阶提交无法实现。 -
Redis只有乐观锁没有悲观锁
对数据加锁后,访问该数据的请求会被阻塞,直到事务完成。缺点是持有锁的客户端越慢,阻塞时间就越长。这种操作叫悲观锁。
Redis为了减少等待时间,不会在WATCH的同时对数据加锁,只会在数据被修改的情况下,通知执行了WATCH命令的客户端。这种方式叫乐观锁。此时只需要事务失败后重试就行。
实现在市场里购买一件商品
需求:卖家可以把自己商品指定价格放到市场上;买家购买时,卖家会收到钱。
- 数据结构
| 说明 | 数据结构 | 名称 | 内容 |
| ---- | ---- | ---- | ---- |
| 用户信息 | 散列 | users:用户编号 | name, 用户名
funds, 用户钱数 |
| 用户包裹 | 集合 | inventory:用户编号 | 商品编号 |
| 市场 | 有序集合 | market: | 成员为“商品编号.用户编号”,值为商品价格 |
将商品放在市场销售
def list_item(conn, itemid, sellerid, price):
inventory = "inventory:%s" % sellerid
item = "%s.%s" % (itemid, sellerid)
end = time.time() + 5
pipe = conn.pipeline()
while time.time() < end:
try:
# 监视用户包裹的变化
pipe.watch(inventory)
# 检查用户是否仍然持有将售的商品
if not pipe.sismember(inventory, itemid):
pipe.unwacth()
return None
# 把将售商品加到市场
pipe.multi()
pipe.zadd("market:", item, price)
pipe.srem(inventory, itemid)
pipe.execute()
# execute执行成功,对包裹的监视结束
return True
except redis.exceptions.WatchError:
# 用户包裹发生变化,重试
pass
return False
购买商品
def purchase_item(conn, buyerid, itemid, sellerid, lprice):
buyer = "user:%s" % buyerid
seller = "user:%s" % sellerid
inventory = "inventory:%s" % sellerid
item = "%s.%s" % (itemid, sellerid)
end = time.time() + 10
pipe = conn.pipeline()
while time.time() < end:
try:
# 监控市场和买家
pipe.watch("market:", buyer)
price = pipe.zscore("market:", item)
funds = int(pipe.hget(buyer, "funds"))
# 检查买家想买的商品价格是否变化,买家是否钱够
if price != lprice or price > funds:
pipe.unwatch()
return None
# 买家给钱拿商品
pipe.multi()
pipe.hincrby(seller, "funds", int(price))
pipe.hincrby(buyer, "funds", int(-price))
pipe.sadd(inventory, itemid)
pipe.zrem("market:", item)
pipe.execute()
return True
except redis.exceptions.WatchError:
pass
return False
4.6 非事务型流水线
使用事务的一个好处是可以通过流水线来提高事务执行时的性能。
在需要执行大量命令的情况下,为了一次性发送所有命令来减少通信次数,可以在不使用MULTI和EXEC的情况下,使用流水线。
使用MULTI和EXEC也会消耗资源,且可能导致其他命令被延迟执行。
# 默认使用MULTI和EXEC
pipe = conn.pipeline()
# 一次性发送执行的命令,但不使用MULTI和EXEC
pipe = conn.pipeline(False)
修改之前的创建令牌的函数,将标准的Redis连接换成流水线连接
def update_token_pipeline(conn, token, user, item=None):
timestamp = time.time()
# 设置流水线
pipe = conn.pipeline()
pipe.hset('login:', token, user)
pipe.zadd('recent:', token, timestamp)
if item:
pipe.zadd('viewed:' + token, item, timestamp)
pipe.zremrangebyrank('viewed:' + token, 0, -26)
pipe.zincrby('viewed:', item, -1)
# 执行被流水线包裹的命令
pipe.execute()
4.7 Redis性能
要优化Redis的性能,需要先了解各个Redis命令能跑多快。
Redis的性能测试工具redis-benchmark
可以通过调用Redis附带的性能测试程序redis-benchmark来看。redis-benchmark可以展示一些常用命令在1秒内内能够执行的次数。默认情况下,它会使用50个客户端进行测试,但是为了和自己的客户端做对比,一般会用-c
选项指定它只使用一个客户端。
redis-benchmark不会处理执行命令得到的回复,所以节约了对回复进行语法分析的时间。通常情况下,对于只使用单客户端的redis-benchmark来说,不使用流水线的python客户单的性能大概只有redis-benckmark展示的50%-60%。
性能问题的处理
如果自己的客户端性能只有redis-benchmark展示的25%-30%,或者客户端返回错误“Cannot assign requested address”,可能是每次发送命令时都创建了新的连接,也可能是以不正确的方式使用Redis的数据结构。
大部分Redis客户端都提供了内置的连接池。python的Redis客户端,对于每个Redis服务器,用户只需要创建一个redis.Redis()对象,它就会按需创建连接、重用已有连接并关闭超时的连接。