欢迎访问个人博客http://www.jkraise.top
本案例项目根据黑马头条的MySQL,Redis
数据库优化
分片
作用
- 分片也称为数据拆分 (Shareding), 其主要工作就是对单库单表进行拆分, 多库多表共同组成完整的数据集合
- 分片可以提高吞吐量, 同一时间数据的读写完成量更多, 扩充单机存储的容量/读写速度上限
分类
- 分片主要分为两种:
- 垂直拆分
- 水平拆分
注意点
- 不要轻易分库分表,因为分片会带来 诸多分布式问题, 让应用的复杂度大量增加
- 应避免"过度设计"和"过早优化", 先尽力去做其他优化,例如:升级硬件、升级网络、读写分离、索引优化、缓存设计等等。
- 当数据量达到单表瓶颈时候(参考值: 单表记录1000W+/硬盘100G+),再考虑分库分表
- 如果需要进行分库分表, 优先考虑垂直拆分
垂直分表&&拆分规则
-
相关性
- 可以将字段根据 业务逻辑 和 使用的相关性 进行分表划分
- 如: 用户名和密码经常配合使用, 将其分到用户认证表, 生日和邮箱等个人信息经常一起访问, 将其分到用户信息表
-
使用频率
-
可以将字段根据 常用 和 不常用 进行划分, 并进行分表处理
-
如: 原始用户表中包含了多个字段, 其中有常用的昵称、手机号等字段, 也包含不常用的邮箱、生日等字段, 可以根据使用频率将其分为两张表: 用户基础信息表 和 用户其他信息表
-
垂直分库
-
将一个数据库中的多张表拆分到多个数据库(服务器节点)中
-
注意点:
-
由于 本地事务不支持跨库操作, 所以应该将 有相关联性的表放在同一个库中
-
# 默认 数据库 t_user t_article # 垂直分表 数据库 t_user_basic t_user_detail t_article_basic t_article_detail # 垂直分库 数据库1 t_user_basic t_user_detail 数据库2 t_article_detail t_article_basic
-
分库访问
- 其实在前边读写分离的课程中已经介绍过,
flask-sqlalchemy
通过配置SQLALCHEMY_BINDS
允许设置多个数据库URI, 并且每个模型类可以__bind_key__
属性 设置自己对应访问的数据库
水平拆分
水平分表
- 将 一张表的记录 拆分到多张表中
- 对于记录较多的表, 会出现索引膨胀, 查询超时等问题, 影响用户体验
水平分库
- 水平分表后, 将分表分散放在多个数据库节点中
拆分规则
- 时间
- 按照时间切分,就是将6个月前,甚至一年前的数据切出去放到另外的一张表,因为随着时间流逝,这些表的数据 被查询的概率变小,所以没必要和“热数据”放在一起,这个也是“冷热数据分离”。
- 业务
- 按照业务将数据进行分类并拆分, 如文章包含金融、科技等多个分类, 可以每个分类的数据拆分到一张表中。
- ID范围
- 从 0 到 100W 一个表,100W+1 到 200W 一个表。
- HASH取模 离散化
- 取用户id,然后hash取模,分配到不同的数据库上。这样可以同时向多个表中插入数据, 提高并发能力, 同时由于用户id进行了离散处理, 不会出现ID冲突的问题
- 地理区域
- 比如按照华东,华南,华北这样来区分业务,部分云服务应该就是如此。
数据定向查询
- 如果进行了水平拆分, 在没有精确过滤条件的情况下, 可能需要到多个数据库中依次查询目标数据
- 可以对
RoutingSession
进行二次开发, 提供方法进行 数据库定向查询 - 示例场景如下: 对用户表进行水平分库分表, 用户数据分别保存在
db1.t_user
和db2.t_user
中, 项目的其他数据保存在数据库test
中
import random
from flask import Flask
from flask_sqlalchemy import SQLAlchemy, SignallingSession, get_state
from sqlalchemy import orm
app = Flask(__name__)
# 设置单个数据库URI (用于建表并添加测试数据)
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://root:mysql@192.168.105.140:3306/db1'
# 设置多个数据库的URI (用于数据操作)
app.config['SQLALCHEMY_BINDS'] = {
'db1': 'mysql://root:mysql@192.168.105.140:3306/db1',
'db2': 'mysql://root:mysql@192.168.105.140:3306/db2',
'master': 'mysql://root:mysql@192.168.105.140:3306/test',
'slave': 'mysql://root:mysql@192.168.105.140:3306/test'
}
# 其他配置
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SQLALCHEMY_ECHO'] = True
# 1. 自定义Session类, 继承SignallingSession, 并重写get_bind方法
class RoutingSession(SignallingSession):
def get_bind(self, mapper=None, clause=None):
"""每次数据库操作(增删改查及事务操作)都会调用该方法, 来获取对应的数据库引擎(访问的数据库)"""
state = get_state(self.app)
if self._bind: # 如果查询指定了访问的数据库, 则使用指定的数据库
print('查询数据库:', self._bind)
return state.db.get_engine(self.app, bind=self._bind)
elif mapper is not None: # 如果模型类已指定数据库, 使用指定的数据库
info = getattr(mapper.mapped_table, 'info', {})
bind_key = info.get('bind_key')
if bind_key is not None:
return state.db.get_engine(self.app, bind=bind_key)
if self._flushing: # 如果模型类未指定数据库, 判断是否为写操作
print('写操作')
return state.db.get_engine(self.app, bind='master')
else:
print('读操作')
return state.db.get_engine(self.app, bind='slave')
_bind = None # 定义类属性记录要访问的数据库
def using_bind(self, bind):
"""指定要访问的数据库"""
self._bind = bind
return self
# 2. 自定义SQLALchemy类, 重写create_session方法
class RoutingSQLAlchemy(SQLAlchemy):
def create_session(self, options):
return orm.sessionmaker(class_=RoutingSession, db=self, **options)
# 创建组件对象
db = RoutingSQLAlchemy(app)
# 构建模型类
class User(db.Model):
__tablename__ = 't_user'
id = db.Column(db.Integer, primary_key=True)
name = db.Column('username', db.String(20), unique=True)
age = db.Column(db.Integer, default=0, index=True)
@app.route('/')
def index():
for db_bind in ['db1', 'db2']: # 遍历各数据库节点, 查询用户数据
user = db.session().using_bind(db_bind).query(User).filter(User.name == 'zs').first()
print(user)
if user:
print(user.id, user.name, user.age)
return "index"
if __name__ == '__main__':
# 重置所有继承自db.Model的表
db.drop_all()
db.create_all()
# 添加测试数据 需要分别往db1和db2中添加一条数据
user1 = User(name='zs', age=20)
db.session.add(user1)
db.session.commit()
app.run(debug=True)
分布式问题
-
一旦在数据库设计中引入了分布式, 则会带来诸多分布式问题, 这里介绍两个主要的问题:
- 分布式事务问题
- 跨节点 Join/排序/分页 的问题
1.分布式事务问题
- 本地事务不支持跨库操作
- 解决办法从简单到复杂有三种
方案1
- 将有关联的表放在一个数据库中
- 同库操作可以使用一个事务
- 如用户表&用户频道表, 文章基本信息表&文章内容表放在一起
方案2
-
Mysql从5.6开始支持分布式事务
-
核心是二阶段提交协议(简称 2PC协议 / XA协议)
-
分布式事务会提供一个 事务管理器 来对 各数据库的本地事务进行统一管理, 只有各本地事务都向管理器 预提交 成功后, 事务管理器才会统一执行提交处理, 否则统一进行回滚处理
-
sqlalchemy
也支持分布式事务- 只需要在创建
SQLAlchemy
对象时, 设置参数session_options={'twophase': True}
即可
- 只需要在创建
-
设置后, 整个session的所有操作会被放入到一个分布式事务中, 并在整个分布式事务范围内保证原子性
注意点:
- 分布式事务要在所有事务都"提交成功"的情况下才会正式提交, 如果参与的部分节点卡顿, 会影响整个事务的性能
方案3
-
基于状态/消息的最终一致性方案
- 对于 包含多个子系统的大型项目, 需要保证子系统之间的数据一致性
- 单个子系统往往不会操作所有数据库, 但是 每个子系统可以通过定义字段来记录操作的状态, 每完成一个阶段则更新相应的状态
- 如下单-付款流程中, 应用A的下单事务完成后更新订单状态为已下单, 应用B付款事务完成后, 再通过 支付回调接口 通知应用A 更新订单状态
- 应用B还需要提供一个 支付查询接口, 以便在用户查询或者订单超时的情况下, 让应用A可以查询订单的支付情况
2.跨节点 Join/排序/分页
- 不支持的跨库操作包括join/分组/聚合/排序
- 解决办法有两个
方案1
- 分两次查询进行, 在应用端合并
方案2
- 使用一些第三方方案(数据库中间件)
- 开源框架除了Mycat, 功能较少
- 需要一定学习成本, 二次开发需要公司具有一定技术实力
redis 单机
1. 常用命令
String
- 记录字符串/整数/浮点数
- 命令
- set 添加/修改数据
- get 获取数据
- mset 添加多个数据
- mget 获取多个数据
- incr 计数加1
- decr 计数减1
- incrby 计数加n
键命令
- 适用于所有的类型
- 命令
- del 删除数据
- exists 判断数据是否存在
- expire 设置过期时间
- ttl 获取剩余时间
- keys 查询满足条件的键
hash
- 类似 字典 的结构
- 命令
- hset 添加字段
- hget 获取字段
- hmset 添加多个字段
- hmget 获取多个字段
- hdel 删除字段
list
- 是一个
双向链表
- 命令
- lpush 从左侧追加元素
- lrange 从左侧遍历元素
- rpush 从右侧追加元素
- lset 从左侧修改元素
- lpop 从左侧删除元素
- rpop 从右侧删除元素
zset
有序
集合, 按照分数(score)进行排序- 命令
- zadd 添加/修改元素
- zrange 遍历元素(按分数从小到大)
- zrevrange 反向遍历元素(从大到小)
- zrangebyscore 遍历指定分数范围的元素
- zscore 查询元素的分数
- zrem 删除元素
- zincrby 元素的分数计数加n
set
无序
集合 无序+去重- 命令
- sadd 添加元素
- smembers 遍历元素
- sismember 判断是否包含
- srem 删除元素
Redis事务
基本语法
- MULTI
- 开启事务, 后续的命令会被加入到同一个事务中
- 事务中的操作会发给服务端, 但是不会立即执行, 而是放到了该事务的对应的一个队列中, 服务端返回QUEUED
- EXEC
- 执行EXEC后, 事务中的命令才会被执行
- 事务中的命令出现错误时, 不会回滚也不会停止事务, 而是继续执行
- DISCARD
- 取消事务, 事务队列会清空, 客户端退出事务状态
$ redis-cli
127.0.0.1:6379> set user1 zs # 设置string类型的键 user1
OK
127.0.0.1:6379> type user1 # 查看user1类型
string
127.0.0.1:6379> multi # 开启事务
OK
127.0.0.1:6379> set age 20 # 设置string类型的键 age, 事务中的操作不会立即执行, 只是入列
QUEUED
127.0.0.1:6379> hset user1 name zs # 设置hash类型的键 user1, 由于user1已存在, 且为string类型, 所以在该命令真正执行时会报错, 此处仅为入列
QUEUED
127.0.0.1:6379> set height 1.8 # 设置string类型的键 height
OK
127.0.0.1:6379> exec # 提交事务, 即使部分操作失败, 不回滚且继续执行
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
3) OK
ACID
- 原子性
- 不支持
- 不会回滚并且继续执行
- 隔离性
- 支持
- 事务中命令顺序执行, 并且不会被其他客户端打断 (先EXEC的先执行)
- 单机redis读写操作使用 单进程单线程
- 持久性
- 支持, 但相比Mysql, redis数据易丢失
- 一致性
- 不支持
from redis import StrictRedis
# 创建redis客户端
redis_client = StrictRedis()
# 创建管道对象 默认会开启事务
pipe = redis_client.pipeline()
# pipe的后续操作会被放入事务中 不会立即执行
a = pipe.set('name', 'zhangsan')
b = pipe.get('name')
# 提交事务 提交才会执行事务中的命令
c = pipe.execute()
print(a)
print(b)
print(c)
注意点:
- 创建管道后, 默认会开启事务
- 放入事务中的命令在执行
execute
方法后才会执行
Redis乐观锁
基本语法
- watch
- redis实现的乐观锁
- 机制
- 事务开启前, 设置对数据的监听, EXEC时, 如果发现数据发生过修改, 事务会自动取消(DISCARD)
- 事务EXEC后, 无论成败, 监听会被移除
WATCH mykey # 监视mykey的值
MULTI # 开启事务
SET mykey 10
EXEC # 如果mykey的值在执行exec之前发生过改变, 则该事务会取消(客户端可以在发生碰撞后不断重试)
- 应用场景
- 避免并发引起的资源抢夺问题
# 需求: 使用redis实现秒杀功能 (防止超卖)
from redis import StrictRedis, WatchError
# 1.创建客户端
redis_client = StrictRedis()
# 2.创建管道对象
pipe = redis_client.pipeline()
while True:
try:
# 3.监听数据 如果开启监听, 则不会开启默认的事务, 后续的pipe操作会立即执行
pipe.watch('reserve_count')
# 4.读取库存数量
count = pipe.get('reserve_count')
# 判断库存数量
if int(count) > 0: # 有库存, 库存-1
# 手动开启事务
pipe.multi()
# 库存减一
pipe.decr('reserve_count')
# 提交事务
pipe.execute()
print('下单成功')
else: # 无库存
print('商品已售罄')
# 将监听移除
pipe.reset()
break
except WatchError: # 捕获到该异常, 说明监听的数据被其他客户端修改, 此时应该重试/取消操作
print('数据被修改, 重试')
continue
注意点:
- 如果开启监听, 则不会开启默认的事务, 后续的pipe操作会立即执行
- 如果没有执行
pipe.execute()
操作, 则需要手动移除监听pipe.reset()
Redis悲观锁
基本语法
- SETNX命令
- 键不存在才会设置成功
- 多个客户端抢夺, 只有一个可以设置成功(获取锁, 获取操作数据的权限)
SETNX lock1 1 # 键不存在,才会设置成功
$ 1
SETNX lock1 1 # 键存在, 无法设置, 返回0
$ 0
from redis import StrictRedis
# 创建redis连接
redis_client = StrictRedis(decode_responses=True)
# 设计redis悲观锁 处理秒杀超卖问题
# 先获取锁
while True:
order_lock = redis_client.setnx('lock:order', 1)
if order_lock:
redis_client.expire('lock:order', 5) # 给锁设置过期时间, 超出5秒, 自动删除锁
reserve_count = redis_client.get('count:reserve')
if int(reserve_count) > 0:
redis_client.decr('count:reserve')
print("生成订单")
else:
print("已售罄")
# 完成处理, 移除锁
redis_client.delete('lock:order')
break
注意点:
- 给锁设置过期时间, 超出5秒, 自动删除锁, 避免出现死锁现象
非事务型管道
-
redis的事务和管道可以分离, 可以 不使用事务的情况下单独使用管道
-
管道可以实现 一次发送多条命令给redis服务器, 提高传输效率
-
创建管道对象 设置transaction参数为False, 则会创建非事务型管道(只开管道, 不开事务) pipe = redis_client.pipeline(transaction=False)
from redis import StrictRedis
# 创建redis客户端
redis_client = StrictRedis()
# 创建管道对象 设置transaction参数为False, 则会创建非事务型管道(只开管道, 不开事务)
pipe = redis_client.pipeline(transaction=False)
# pipe的后续操作会被管道中
a = pipe.set('name', 'zhangsan')
b = pipe.get('name')
# 执行管道 会让管道将命令打包发给redis服务器
c = pipe.execute()
print(a)
print(b)
print(c)
Redis分布式
主要内容
数据库主从
基本介绍
- 作用
- 数据备份
- 读写分离
- 特点
- 只能一主多从 (mysql可以多主多从)
- 从数据库不能写入 (mysql可以写)
相关配置
# 主从数据库分别配置ip/端口 如果不设置, 则接受来自任意IP的请求(包括外网、局域网、本机)
# bind 127.0.0.1
port 6379
# 从数据库配置slaveof参数: 主数据库ip 主数据库端口
slaveof 192.168.105.140 6378
# 以下两条连起来: 当至少有2个从数据库可以进行复制并且响应延迟都在10秒之内时, 主数据库才允许写操作
min-slaves-to-write 2
min-slaves-max-lag 10
# ubuntu的redis安装目录, 其中包含了redis和sentinal的配置模板
/usr/local/redis/
# ubuntu中 启动/重启/停止 默认的redis服务
/etc/init.d/redis-server start/restart/stop
哨兵模式
1. 基本介绍
- 作用
- 监控redis服务器的运行状态, 可以进行 自动故障转移(failover), 实现高可用
- 与 数据库主从 配合使用的机制
- 特点
- 独立的进程, 每台redis服务器应该至少配置一个哨兵程序
- 监控redis主服务器的运行状态
- 出现故障后可以向管理员/其他程序发出通知
- 针对故障,可以进行自动转移, 并向客户端提供新的访问地址
内部机制 (了解)
- 流言协议
- 当某个哨兵程序ping 发现监视的主服务器下线后(心跳检测), 会向监听该服务器的其他哨兵询问, 是否确认主服务器下线, 当 确认的哨兵数量 达到要求(配置文件中设置)后, 会确认主服务器下线(客观下线), 然后进入投票环节
- 投票协议
- 当确认主服务器客观下线后, 哨兵会通过 投票的方式 来授权其中一个哨兵主导故障转移处理
- 只有在 大多数哨兵都参加投票 的前提下, 才会进行授权, 比如有5个哨兵, 则需要至少3个哨兵投票才可能授权
- 目的是避免出现错误的故障迁移
- 建议最低配置
- 至少在3台服务器上分别启动至少一个哨兵
- 如果只有一台, 则服务器宕机后, 将无法进行故障迁移
- 如果只有两台, 一旦一个哨兵挂掉了, 则投票会失败
2. 相关配置
- 哨兵模式是Redis官方自带的工具, 默认安装
- 哨兵模式的配置模板在Redis的安装包中, 默认名为
sentinel.conf
# bind 127.0.0.1 # 哨兵绑定的ip, 如果注释则表示接受任意IP发来的请求
port 26381 # 哨兵监听的端口号, redis客户端需要访问哨兵的ip和端口号
sentinel monitor mymaster 192.168.105.140 6381 2 # 设置哨兵 (主数据库别名 主数据库ip 主数据库端口 确认下线的最小哨兵数量)
sentinel down-after-milliseconds mymaster 60000 # 服务器断线超时时长
daemonize yes # 设置后台服务
logfile "/var/log/redis-sentinel-26379.log" # 哨兵生成的日志文件路径
- 启动哨兵模式
sudo redis-sentinel sentinel.conf
配置虚拟机的哨兵模式
- CentOS虚拟机中已经配置好了3个哨兵的配置文件, 存放在
/etc/redis
目录下, 分别是sentinel_26380.conf
到sentinel_26382.conf
3个文件 - 但是 需要同学们根据 自己虚拟机的ip地址 修改绑定的主数据库ip, 否则后续无法通过哨兵局域网访问Redis主从
- 虚拟机中还配置好了主从+哨兵的启动脚本, 存放在
redis-replication-start.sh
- 具体操作如下:
# 先使用 主从+哨兵停止脚本 关闭Redis主从和哨兵
$ cd /opt
$ sudo ./redis-replication-stop.sh
# 修改哨兵配置文件
$ sudo vi /etc/redis/sentinel_26380.conf
# 将 "sentinel monitor mymaster 192.168.105.140 6381 2" 中的ip部分替换为自己的局域网ip
$ sudo vi /etc/redis/sentinel_26381.conf
# 将 "sentinel monitor mymaster 192.168.105.140 6381 2" 中的ip部分替换为自己的局域网ip
$ sudo vi /etc/redis/sentinel_26382.conf
# 将 "sentinel monitor mymaster 192.168.105.140 6381 2" 中的ip部分替换为自己的局域网ip
# 使用 主从+哨兵启动脚本 启动哨兵
$ sudo ./redis-replication-start.sh
# 访问Redis主/从
redis-cli -p 6381
3. python通过哨兵访问Redis
redis-py
包中就集成了哨兵功能
代码示例
from redis.sentinel import Sentinel
sentinels = [ # 设置哨兵的IP和端口
('192.168.105.140', 26380),
('192.168.105.140', 26381),
('192.168.105.140', 26382),
]
# 创建哨兵客户端
sentinel_client = Sentinel(sentinels)
# 主数据库别名
service_name = 'mymaster'
# 获取主数据库
redis_master = sentinel_client.master_for(service_name, decode_responses=True)
# 获取从数据库
redis_slave = sentinel_client.slave_for(service_name, decode_responses=True)
# print(type(redis_master))
redis_master.zadd('movies', 8, 'dahuaxiyou')
redis_master.zincrby('movies', 'dahuaxiyou', 2)
print(redis_master.zscore('movies', 'dahuaxiyou'))
# 主数据库进行数据操作
# redis_master.set('name', 'zhangsan123')
# print(redis_master.get('name'))
# 从数据库进行数据操作
# print(redis_slave.get('name'))
# redis_slave.set('name', 'lisi') # 不能写
# 写操作, 直接使用主
# 读操作, 直接使用从
# 有读有写, 建议使用主 可以使用 管道 / 乐观锁
4. 项目集成
- Redis主从在后续 Redis持久化 章节中进行使用, 目前阶段只完成集成即可
- 在
app/settings/config.py
文件中 设置哨兵配置
# app/settings/config.py
class DefaultConfig:
"""默认配置"""
# mysql配置
...
# 设置哨兵的ip和端口
SENTINEL_LIST = [
('192.168.105.140', 26380),
('192.168.105.140', 26381),
('192.168.105.140', 26382),
]
SERVICE_NAME = 'mymaster' # 哨兵配置的主数据库别名
- 在
app/__init__.py
文件中, 创建哨兵和主从客户端对象
# app/__init__.py
...
from redis.sentinel import Sentinel
...
# redis主从数据库
redis_master = None # type: StrictRedis
redis_slave = None # type: StrictRedis
...
def register_extensions(app):
"""组件初始化"""
...
# 哨兵客户端
global redis_master, redis_slave
sentinel = Sentinel(app.config['SENTINEL_LIST'])
redis_master = sentinel.master_for(app.config['SERVICE_NAME'], decode_responses=True)
redis_slave = sentinel.slave_for(app.config['SERVICE_NAME'], decode_responses=True)
集群
1. 基本介绍
- 多个节点共同保存数据
- 作用
- 扩展存储空间
- 提高吞吐量, 提高写的性能
- 和单机的不同点
- 不再区分数据库, 只有0号库, 单机默认0-15
- 不支持事务/管道/多值操作
- 特点
- 要求至少 三主三从
- 要求必须开启 AOF持久化
- 自动选择集群节点进行存储
- 默认集成哨兵, 自动故障转移
2. 相关配置
- 在前边课程中已经讲解过Redis集群的配置, 此处以回顾为主
# 每个节点分别配置ip/端口, 注释表示接受所有请求
# bind 127.0.0.1
port 6379
# 集群配置
cluster-enabled yes # 开启集群
cluster-config-file nodes-7000.conf # 节点日志文件
cluster-node-timeout 15000 # 节点超时时长 15秒
# 开启AOF 及相关配置
appendonly yes
创建集群
- CentOS虚拟机中已经配置好了6个集群节点(三主三从)的配置文件, 存放在
/etc/redis
目录下, 分别是7000.conf
到7005.conf
6个文件 - 虚拟机中还配置好了集群启动脚本, 存放在
/opt/redis-cluster-start.sh
- redis的安装包中包含了
redis-trib.rb
命令,⽤于创建集群 - 具体操作如下:
# 使用 集群启动脚本 启动集群节点
$ cd /opt
$ sudo ./redis-cluster-start.sh
# 创建集群
$ cd ~/redis-4.0.13/src/
$ redis-trib.rb create --replicas 1 192.168.105.140:7000 192.168.105.140:7001 192.168.105.140:7002 192.168.105.140:7003 192.168.105.140:7004 192.168.105.140:7005
# 将启动脚本路径 添加到 linux自启动文件中 (即使重启系统, 集群也会自启动)
$ sudo vi /etc/rc.d/rc.local
# 将 /opt/redis-cluster-start.sh 添加到文件末尾
# 访问集群 访问集群必须加-c选项, 否则无法进行读写操作
redis-cli -p 7000 -c
3. python操作Redis集群
- 想要通过python访问redis集群, 需要安装依赖包
redis-py-cluster
- 安装依赖包
pip install redis-py-cluster
代码示例
from rediscluster import RedisCluster
master_nodes = [ # 设置主数据库的ip和端口
{'host': '192.168.105.140', 'port': 7000},
{'host': '192.168.105.140', 'port': 7001},
{'host': '192.168.105.140', 'port': 7002},
]
# 创建集群客户端
cluster_client = RedisCluster(startup_nodes=master_nodes)
# 访问redis
cluster_client.set('name', 'lisi')
print(cluster_client.get('name'))
# 集群不能使用 管道/事务/乐观锁/多值操作
# 集群仍然可以使用悲观锁
- 注意点:
- redis集群不能支持事务和WATCH, 并发控制可以通过自己设计悲观锁(setnx)来实现
4. 项目集成
- 在头条项目中集成Redis集群客户端, 用于代替Redis单机 存储短信验证码, 并且在后续 缓存设计 章节中使用
- 在
app/settings/config.py
文件中 设置集群配置
# app/settings/config.py
class DefaultConfig:
"""默认配置"""
...
# redis配置
# REDIS_HOST = '192.168.105.140' # ip
# REDIS_PORT = 6381 # 端口
# redis集群配置
CLUSTER_NODES = [ # 集群中主数据库的ip和端口号
{'host': '192.168.105.140', 'port': 7000},
{'host': '192.168.105.140', 'port': 7001},
{'host': '192.168.105.140', 'port': 7002},
]
- 在
app/__init__.py
文件中, 创建集群客户端对象
# app/__init__.py
...
from rediscluster import RedisCluster
...
# Redis数据库操作对象
# redis_client = None # type: StrictRedis
# 创建集群客户端对象
redis_cluster = None # type: RedisCluster
...
def register_extensions(app):
"""组件初始化"""
...
# Redis组件初始化
# global redis_client
# redis_client = StrictRedis(host=app.config['REDIS_HOST'], port=app.config['REDIS_PORT'], decode_responses=True)
...
# redis集群组件初始化
global redis_cluster
redis_cluster = RedisCluster(startup_nodes=app.config['CLUSTER_NODES'], decode_responses=True)
- 修改
app/resources/user/passport.py
文件中 获取短信验证码 & 注册登录 视图函数, 以集群方式访问Redis
# app/resources/user/passport.py
...
# from app import redis_client
from app import redis_cluster
...
class SMSCodeResource(Resource):
"""获取短信验证码"""
def get(self, mobile):
...
# 保存验证码(redis) app:code:18912341234 123456
key = 'app:code:{}'.format(mobile)
redis_cluster.set(key, rand_num, ex=SMS_CODE_EXPIRE)
...
class LoginResource(Resource):
"""注册登录"""
def post(self):
...
# 校验短信验证码
key = 'app:code:{}'.format(mobile)
real_code = redis_cluster.get(key)
if not real_code or real_code != code:
return {'message': 'Invalid Code', 'data': None}, 400
# 删除验证码 正常验证码只能使用一次
# redis_cluster.delete(key)
...
过期与淘汰
1. 缓存过期
-
只要是缓存, 都应该设置过期时间, 设置有效期的优点:
- 节省空间
- 做到数据弱一致性,有效期失效后,可以保证数据的一致性
-
常规的过期策略通常有以下三种:
-
定时过期
每个设置过期时间的key都创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源进行计时和处理过期数据,从而影响缓存的响应时间和吞吐量。
-
惰性过期
只有当访问一个key时,才会判断该key是否已过期,过期则清除(返回nil)。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。
-
定期过期
每隔一定的时间,扫描数据库中一部分设置了有效期的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。
Redis的过期策略
Redis中同时使用了惰性过期和定期过期两种过期策略。
- 定期过期: 默认是每100ms检测一次,遇到过期的key则进行删除,这里的检测并不是顺序检测,而是随机检测。
- 惰性过期: 当我们去读/写一个key时,会触发Redis的惰性过期策略,直接删除过期的key
2. 缓存淘汰
- 假定某个key逃过了定期过期, 且长期没有使用(即逃过惰性过期), 那么redis的内存会越来越高。当redis占用的内存达到系统上限时, 就会触发 内存淘汰机制。
- 所谓内存淘汰机制, 是指 在Redis允许使用的内存达到上限时,如何淘汰已有数据及处理新的写入需求。
- Redis自身提供了多种缓存淘汰策略, 最常用的是 LRU 和 LFU
2.1 LRU (Least recently used 最后使用时间策略)
- LRU算法根据数据的历史访问记录来进行淘汰数据,优先淘汰最近没有使用过的数据。
- 基本思路
- 新数据插入到列表头部;
- 每当缓存命中(即缓存数据被访问),则将数据移到列表头部;
- 当列表满的时候,将列表尾部的数据丢弃。
- 存在的问题
- 单独按照最后使用时间来进行数据淘汰, 可能会将一些使用频繁的数据删除, 如下例中数据A虽然最后使用时间比数据B早, 但是其使用次数较多, 后续再次使用的可能性也更大
数据 最后使用时间 使用次数
数据A 2020-03-15 100
数据B 2020-03-16 2
2.2 LFU (Least Frequently Used 最少使用次数策略)
- redis 4.x 后支持LFU策略
- 它是基于“如果一个数据在最近一段时间内使用次数很少,那么在将来一段时间内被使用的可能性也很小”的思路, 优先淘汰使用率最低的数据。
- 考虑到新添加的数据往往使用次数要低于旧数据, LFU还实现了 定期衰减机制
- LFU的缺点
- 需要每条数据维护一个使用计数
- 还需要定期衰减
Redis的淘汰策略
- allkeys-lfu: 当内存不足以容纳新写入数据时,在键空间中,优先移除使用次数最少的key。
- volatile-lfu: 当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,优先移除使用次数最少的key。
- allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,优先移除最近没有使用过的key。
- volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,优先移除最近没有使用过的key。
- allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个key。
- volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个key。
- volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的key优先移除。
- noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。
思考题
问题: mySQL里有2000w数据,redis中只存20w的数据,如何保证redis中的数据都是热点数据?
头条项目方案
- 缓存数据都设置有效期
- 配置redis,使用volatile-lfu
缓存问题
1. 缓存更新
-
mysql和redis是两个独立的系统, 在并发环境下, 无法保证更新的一致性
-
如下图(以Redis和Mysql为例),两个并发更新操作,数据库先更新的反而后更新缓存,数据库后更新的反而先更新缓存。这样就会造成数据库和缓存中的数据不一致,应用程序中读取的都是脏数据。
解决方案
- 方案1: 设计分布式锁(redis-setnx)/使用消息队列顺序执行
- 缺点: 并发能力差
- 方案2: 更新数据时, 先写入mysql, 再删除缓存
- 主要用于数据对象 (更新少)
- 数据集合可以考虑更新缓存 (集合的查询成本高, 频繁更新缓存效率太低)
- 广泛使用, 如: facebook
2. 缓存穿透
- 缓存只是为了缓解数据库压力而添加的一层保护层,当从缓存中查询不到我们需要的数据就要去数据库中查询了。如果被黑客利用,频繁去访问缓存中没有的数据,那么缓存就失去了存在的意义,瞬间所有请求的压力都落在了数据库上,这样会导致数据库连接异常。
解决方案
- 方案1: 对于数据库中不存在的数据, 也对其在缓存中设置默认值Null
- 为避免占用资源, 一般过期时间会比较短
-
方案2: 可以设置一些过滤规则
- 如布隆过滤器(一种算法, 用于判断数据是否包含在集合中), 将所有可能的值录入过滤器, 如果不包含直接返回None, 有误杀概率·
布隆过滤器 (拓展)
- 安装包
pip install pybloomfiltermmap3
import pybloomfilter
# 创建过滤器
filter = pybloomfilter.BloomFilter(1000000, 0.01, 'words.bloom')
# 添加数据
filter.update(('bj', 'sh', 'gz'))
# 判断是否包含
if 'bj' in filter:
print('包含')
else:
print('不包含')
3.缓存雪崩
- 如果大量缓存数据都在同一个时间过期, 那么很可能出现 缓存集体失效, 会导致所有的请求都直接访问数据库, 导致数据库压力过大
解决方案
- 方案1: 设置过期时间时添加随机值, 让过期时间进行一定程度分散,避免同一时间集体失效。
- 比如以前是设置10分钟的超时时间,那每个Key都可以随机8-13分钟过期,尽量让不同Key的过期时间不同。
- 方案2: 采用多级缓存,不同级别缓存设置的超时时间不同,即使某个级别缓存都过期,也有其他级别缓存兜底。
缓存模式
- 缓存设计的核心思路为 先读取缓存中的数据, 没有才会读取数据库中的数据, 以便解决数据库读取压力
- 具体的缓存设计模式 可以主要分为以下两种:
- Cache Aside
- Read-through 通读
1. Cache Aside
- 特点:
- 具体读写操作交给应用完成
- 缺点:
- 业务和数据操作耦合度高, 不利用技术升级
2. Read-through 通读
- 特点:
- 具体读写操作交给缓存层完成, 即使后期修改存储方案, 业务代码不需要修改,
- 优点:
- 有利于项目的重构和架构升级
头条项目方案
-
使用
Read-through
- 构建一层抽象出来的缓存操作层,负责数据库查询和Redis缓存存取,在Flask的视图逻辑中直接操作缓存层工具。
-
更新数据对象, 采用先更新数据库,再删除缓存; 更新数据集合时, 采用先更新数据库, 再更新缓存