面试题之数据库Mysql优化,Redis分布式优化

欢迎访问个人博客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_userdb2.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)

分布式问题

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.confsentinel_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.conf7005.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自身提供了多种缓存淘汰策略, 最常用的是 LRULFU

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('不包含')

拓展阅读: 使用Redis实现布隆过滤器

3.缓存雪崩

  • 如果大量缓存数据都在同一个时间过期, 那么很可能出现 缓存集体失效, 会导致所有的请求都直接访问数据库, 导致数据库压力过大

请添加图片描述

解决方案
  • 方案1: 设置过期时间时添加随机值, 让过期时间进行一定程度分散,避免同一时间集体失效。
    • 比如以前是设置10分钟的超时时间,那每个Key都可以随机8-13分钟过期,尽量让不同Key的过期时间不同。
  • 方案2: 采用多级缓存,不同级别缓存设置的超时时间不同,即使某个级别缓存都过期,也有其他级别缓存兜底。

缓存模式

  • 缓存设计的核心思路为 先读取缓存中的数据, 没有才会读取数据库中的数据, 以便解决数据库读取压力
  • 具体的缓存设计模式 可以主要分为以下两种:
    • Cache Aside
    • Read-through 通读
1. Cache Aside
  • 特点:
    • 具体读写操作交给应用完成
  • 缺点:
    • 业务和数据操作耦合度高, 不利用技术升级

请添加图片描述

2. Read-through 通读
  • 特点:
    • 具体读写操作交给缓存层完成, 即使后期修改存储方案, 业务代码不需要修改,
  • 优点:
    • 有利于项目的重构和架构升级

请添加图片描述

头条项目方案
  • 使用

    Read-through
    
    • 构建一层抽象出来的缓存操作层,负责数据库查询和Redis缓存存取,在Flask的视图逻辑中直接操作缓存层工具。
  • 更新数据对象, 采用先更新数据库,再删除缓存; 更新数据集合时, 采用先更新数据库, 再更新缓存

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值