Redis --- 企业应用案例
解决集群的session共享问题
流程图
由于线上是集群环境,当nginx做负载均衡的时候,可能当前主机并没有缓存登录用户的session,导致用户登录失败,所以需要一个共享的session空间,并且由于是共享的空间,每个登录用户都需要有自己的存储空间,这样就表示存储在redis中时的key不允许相同,故可以用手机号作为key,验证码作为值来存储,并设置超时时间
缓存及问题解决方案
缓存就是数据交换的缓存区(Cache)是存贮数据的临时地方,一般读写性能较高
缓存的作用
降低后端负载
提高读写效率,降低响应时间
缓存的成本
数据一致性成本
代码维护成本
运维成本
缓存作用模型
Redis如何缓存
商铺信息缓存流程图
缓存更新策略
当数据的数据发生改变后,缓存中的数据还是以前的老数据,则会发生数据不一致的问题
内存淘汰
不用自己维护,利用redis的内存淘汰机制,当内存不足时自动淘汰部分数据,下次查询时更新缓存
一致性很差(自己无法控制淘汰哪些数据,还是会导致数据一致性的问题)
超时剔除
给缓存数据添加TTL时间,到期后自动删除缓存,下次查询时更新缓存
一致性一般(取决于自己设置的过期时间,当在未删除时,数据库发生变化,则无法保证真正的一致性)
主动更新
编写业务逻辑,在修改数据库的同时,更新缓存
一致性较好
如何选择更新策略?
低一致性需求:使用内存淘汰机制,对长久不发生变化的数据可以交给redis自己去淘汰,可以配合超时时间
高一致性需求:主动更新,并结合超时剔除作为兜底方案
主动更新策略的方式如何选择?
1. 由缓存的调用者,在更新数据库的同时更新缓存(都用这个)
2. 缓存与数据库整合为一个服务,由服务来维护一致性,调用者调用该服务,无需关心缓存一致性的问题
3. 调用者只操作缓存,由其他线程异步的将缓存数据持久化到数据库,保证最终一致性(一致性和可靠性都存在问题,如:操作了多次缓存,但是并未触发异步写入数据库的操作,在这段时间内的数据并不一致,而且缓存宕机的话所有的数据就会重归原来的状态,更改的数据无法找回)
操作缓存和数据库时的问题?
1.删除缓存还是更新缓存
更新缓存:每次更新数据库都更新缓存,无效写操作较多
删除缓存:更新数据库时让缓存失效,查询时再更新缓存 (选择删除缓存)
2.如何保证缓存与数据库的操作同时成功或失败
单体系统:将缓存与数据库操作放在一事务
分布式系统:利用TCC等分布式事务方案
3.先操作缓存还是先操作数据库
先操作数据库再删缓存要更靠谱一些(搭配写缓存时的过期时间)
先删缓存再操作数据库的线程安全问题
先操作数据库在删缓存的线程安全问题(但是可能性很低很低,因为数据库操作比缓存要慢很多,可以通过在写入缓存时设置超时时间来解决)
主动更新的最佳实践方案
缓存穿透
客户端请求的数据在缓存中和数据库中都不存在,这样的缓存永远不会生效,这些请求都会打到数据库
解决方案(被动)
缓存空对象(推荐使用)
如果缓存和数据库都没有就在缓存中缓存一个空的当前查询对象
优点:实现简单,维护方便
缺点:
1.额外的内存消耗(可以设置较短时间的TTL)
2.可能造成短期的不一致(当假用户查询时不存在,缓存到数据库中,当真实用户来创建时数据库有了,缓存还是null,只有等过期时间到了才能查到真实数据,可以通过数据库新增时,主动更新缓存中的数据,覆盖以前的数据)
布隆过滤
算法
在客户端和redis中间加入一层布隆过滤算法,将数据库中的数据算出hash值,再将hash值转成二进制位保存在布隆过滤器中,通过算出对应位置的二进制位是0或1来表示是否存在,并非百分百存在,有一定的穿透风险
有点:内存占用较少,没有多余的key,redis中已经实现了布隆过滤器
缺点:实现复杂,存在误判的可能性
解决方案(主动)
增加id的复杂度,避免被猜测id规律,做好客户端提交数据的基础格式校验
加强用户权限校验
做好热点参数的限流
缓存雪崩
在同一时间段大量的缓存key同时失效或redis服务宕机,导致大量请求到达数据库,带来巨大压力
解决方案(key失效)
1.给不同的key的TTL添加随机值(固定过期时间+随机过期时间)
解决方案(宕机)
1.利用redis集群提高服务的可用性(高可用)
利用哨兵机制,给redis做主从,保证一个宕机,哨兵会从中选出一个做主redis,并同步数据到主机中
2.给缓存业务添加降级限流策略(当redis集群全部宕机时对一些请求快速失败,拒绝服务)
3.给业务添加多级缓存(浏览器缓存+nginx缓存+本地缓存+...)
缓存击穿(热点key,临时添加,用完删除)
也成为热点key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会再瞬间给数据库带来巨大的冲击
解决方案
互斥锁(性能差)
redis中已经实现了类似的互斥锁,setnx命令, 当key存在时,其他人再setnx时不会执行,所以可以在redis中setnx一个互斥锁lock:1,当业务执行完毕后del lock,则释放锁,但是当没有人del这个锁时会造成死锁现象,所以需要通过加过期时间来解决死锁现象
逻辑过期
在value中创建一个字段来保存过期时间从而保证不被redis 真正删除
优缺点
秒杀
全局唯一ID
基于redis自增
订单ID如果使用自增,则ID的规律太明显并且受单表数据量的限制
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID 的工具,其特性:
1.唯一性
2.高可用
3.高性能
4.递增性
5.安全性(为了增加ID 的安全性,我们可以不直接用redis自增的数字,而是拼接一些其他信息)
0 0000000000000000000000000 - 0000000000000000000000000000
ID的组成部分8个字节:
符号位:1bit,永远为0
时间戳差值:32bit,以秒为单位,可以使用69年(当前时间戳 - 规定的固定时间时间戳)
序列号(自增):32bit,秒内的计数器,支持每秒产生2^32个不同的ID
为保证序列号不会超过规定的2^32,可以在key中以 标识:业务名:(当前年:月:日) 来表示这个key中存的是一天的订单数量,并方便将来做统计,以及将来业务暴增也可以保证订单号的统一长度,如果以天为单位满足不了需要可以以时分秒为标识
得到数字类型的
时间戳 << 序列号位数 | 序列号
(位运算,标识向左移32位)
python代码
import redis
from datetime import datetime
r = redis.StrictRedis(host='localhost', port=6379, db=0)
# 固定订单前缀(redis中key)
BEGINORDER = "order:incr:"
# 固定时间戳
BEGIN_TIME = 1676191086.050477
# 序列号长度
IDLENGTH = 32
def initOrderId(bus_name):
"""
在redis中初始化业务ID键值对
:param bus_name: 业务名
:return: 无
"""
redis_id_key = bus_name + ":" + BEGINORDER + datetime.strftime(datetime.now(), "%Y:%m:%d")
r.set(redis_id_key, 10000)
def redis_id_worker(bus_name):
"""
基于redis的唯一ID生成器
:param bus_name:业务名
:return:
"""
# 业务名 + 固定订单前缀 + 年月日
redis_id_key = bus_name + ":" + BEGINORDER + datetime.strftime(datetime.now(), "%Y:%m:%d")
# 序列号(自增1)
id_number = int(r.incr(redis_id_key))
print("序列号:",id_number)
# 时间差(当前时间戳 - 固定时间戳 (取小数点前))
difference_time = int(str(datetime.now().timestamp() - BEGIN_TIME).split(".")[0])
print("时间差:",difference_time)
# 完整订单编号
order_number = difference_time << IDLENGTH | id_number
print(order_number)
initOrderId("shangcheng")
redis_id_worker("shangcheng")
雪花算法
数据库自增
建一张自增表,只存自增订单ID,其他订单ID都从这张表中取
提前批量生成ID
缓存在业务缓存中,并持久化在数据库的单表中防止丢失
超卖问题
多线程并发的场景下经常会出现的问题,是由于多线程的查询速度较快,当线程1扣减库存前其他线程查询时都是有库存的,所以都可以扣除,可以通过加锁来解决
悲观锁
悲观锁是认为线程安全问题是一定会产生的,所以在查询数据库前就获取锁,确保所有线程串行执行,以保证数据安全,但是性能低下
乐观锁(更新数据的时候才可以使用)
乐观锁是认为线程安全问题不一定会产生,因此不加锁,只是在更新数据时判断有没有其他线程对数据做了修改,如果没有被修改,自己才更新数据,如果修改过说明发生了线程安全问题,此时可以重试或异常,性能会较高,
那么如何知道数据有没有被其他线程修改过?
1.版本号法(给数据加一个版本,每当数据做一次修改,数字加1)
就是在执行扣减动作前,判断库存和版本号和第一次查询到的版本号是否一致
2.CAS法(版本号简化版,建议使用)
判断条件是基于版本号这样一个可能发生变化的数据,那么就可以根据库存是否发生过变化来确实,其他线程有没有修改过数据了
# 注意:乐观锁会造成很多成交无效,可以通过判断库存大于0(只要库存足够就随便减),如果必须通过数据是否发生变化来判断是否发生安全问题,可以将100个库存分布在10张表中,可以在多张表中抢购(分段锁)
一人一单(单机)
通过优惠券ID 和用户ID 查询订单表中是否存在该用户是否购买优惠券来控制一人一单
# 同样会发生线程安全问题,需要加悲观锁(需要想办法锁同一个ID的用户而不是全部用户来了都要加锁)
#但是在集群模式下同样会发生线程-安全,原因是锁监视器的锁空间不同,也就是两个锁,只能锁单机空间,所以需要用到分布式锁
redis分布式锁
满足分布式系统或集群模式下多进程可见并且互斥的锁
需要满足:高可用,高性能,安全性
reids实现,需要保证在创建锁和设置过期时间这个操作保持原子性
set lock thread1 ex 10 nx
简单版代码
import redis
r = redis.StrictRedis(host='localhost', port=6379, db=0,decode_responses=True)
def get_lock(lock_name, timeout):
"""
获取分布锁
:param lock_name: 锁名称
:param timeout: 过期时间
:return: bool
"""
# 原子性操作
return r.set(lock_name, 1, ex=timeout, nx=True)
def unlock(lock_name):
"""
释放分布锁
:param lock_name: 锁名称
:return: None
"""
r.delete(lock_name)
print(get_lock("lock", 10))
改进版
# 简单版存在的问题:多线程下,如果在业务代码还没执行完毕,锁就过期了,会导致其他线程抢夺锁,从而造成多个线程同时工作,
# 改进思路:
1.在获取锁时,存入线程标识 +UUID来表示不同主机的不同线程
线程标识来区分本机的不同线程
uuid用来区分不同机器的不同线程
2.在释放锁时先获取锁中的线程标识,判断是否与当前线程标识一致
import threading
from uuid import uuid4
import redis
r = redis.StrictRedis(host='localhost', port=6379, db=0, decode_responses=True)
uuid_value = "".join(str(uuid4()).split("-"))
def get_lock(lock_name, timeout):
"""
获取分布锁
:param lock_name: 锁名称
:param timeout: 过期时间
:param value: 线程标识
:return: bool
"""
value = "thread-" + str(threading.get_ident()) + ":" + uuid_value
return r.set(lock_name, value, ex=timeout, nx=True)
def unlock(lock_name):
"""
释放分布锁
:param lock_name: 锁名称
:return: None
"""
# 获取线程标识
redis_value = r.get(lock_name)
value = "thread-" + str(threading.get_ident()) + ":" + uuid_value
print('redis_value=', redis_value)
print('value=', value, "type=")
if redis_value != value:
return "释放锁错误"
r.delete(lock_name)
return "释放锁成功"
def run(lockname, timeout):
get_lock(lockname, timeout)
print(unlock(lockname))
for i in range(10):
t = threading.Thread(target=run, args=("lock", 10))
t.start()
进一步改进
以上代码可能或在释放锁前发生阻塞,如果时间足够长,锁就会过期,其他线程就可以获取锁,从而 导致线程安全,解决办法:判断锁是否正确和删除锁必需保证原子性,可以通过redis的lua脚本,在一个脚本中编写多条redis命令,确保多条命令执行时的原子性
lua脚本
-- 锁的key
local key = KEYS[1]
-- 当前线程标识
locak threadID = ARGV[1]
-- 获取redis中存的线程标识
local lockID = redis.call('get',key)
-- 一致性比较
if(lockID == threadID) then
-- 释放锁
return redis.call('del',key)
end
return 0
python中执行lua脚本
import redis
r = redis.StrictRedis(host='localhost', port=6379, db=0, decode_responses=True)
# 必须return 才有返回值
# KEYS[1]:取下标(从1开始)
script = """
-- 锁的key
local key = KEYS[1]
-- 当前线程标识
locak threadID = ARGV[1]
-- 获取redis中存的线程标识
local lockID = redis.call('get',key)
-- 一致性比较
if(lockID == threadID) then
-- 释放锁
return redis.call('del',key)
end
return 0
"""
print(r.eval(script, 1, *['name', 'jack']))
# 第二种调用方式,元素个数和argv数组元素个数一致
reg = r.register_script(script)
print(reg(keys=['name'], args=['jack']))
总结
以上基于setnx实现的分布式锁存在以下极端问题:
1.不可重入:同一个线程无法多次获取同一把锁
2.不可重试:获取锁只尝试一次就返回,无重试机制
3.超时释放:锁超时释放虽然可以避免死锁,但是如果业务执行耗时较长会导致锁释放,存在安全隐患
4.主从一致性:如果redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从同步主中的锁数据,则会出现锁失效
使用Redis分布式锁的详细方案是什么?
一个很简单的答案就是去使用 Redission 客户端。Redission 中的锁方案就是 Redis 分布式锁的比较完美的详细方案。
那么,Redission 中的锁方案为什么会比较完美呢?
正好,我用 Redis 做分布式锁经验十分丰富,在实际工作中,也探索过许多种使用 Redis 做分布式锁的方案,经过了无数血泪教训。
所以,在谈及 Redission 锁为什么比较完美之前,先给大家看看我曾经使用 Redis 做分布式锁遇到过的问题。
我曾经用 Redis 做分布式锁想去解决一个用户抢优惠券的问题。这个业务需求是这样的:当用户领完一张优惠券后,优惠券的数量必须相应减一,如果优惠券抢光了,就不允许用户再抢了。
在实现时,先从数据库中先读出优惠券的数量进行判断,当优惠券大于 0,就进行允许领取优惠券,然后,再将优惠券数量减一后,写回数据库。
当时由于请求数量比较多,所以,我们使用了三台服务器去做分流。
这时候会出现一个问题:
如果其中一台服务器上的 A 应用获取到了优惠券的数量之后,由于处理相关业务逻辑,未及时更新数据库的优惠券数量;在 A 应用处理业务逻辑的时候,另一台服务器上的 B 应用更新了优惠券数量。那么,等 A 应用去更新数据库中优惠券数量时,就会把 B 应用更新的优惠券数量覆盖掉。
看到这里,可能有人比较奇怪,为什么这里不直接使用 SQL:
update 优惠券表 set 优惠券数量 = 优惠券数量 - 1 where 优惠券id = xxx
原因是这样做,在没有分布式锁协调下,优惠券数量可能直接会出现负数。因为当优惠券数量为 1 的时候,如果两个用户通过两台服务器同时发起抢优惠券的请求,都满足优惠券大于 0 的条件,然后都执行这条 SQL 语句,结果优惠券数量直接变成 -1 了。
还有人说可以用乐观锁,比如使用如下 SQL:
update 优惠券表 set 优惠券数量 = 优惠券数量 - 1 where 优惠券id = xxx and version = xx
这种方式就在一定几率下,很可能出现数据一直更新不上,导致长时间重试的情况。
所以,经过综合考虑,我们就采用了 Redis 分布式锁,通过互斥的方式,以防止多个客户端去同时更新优惠券数量的方案。
当时,我们首先想到的就是使用 Redis 的 setnx 命令,setnx 命令其实就是 set if not exists 的简写。
当 key 设置值成功后,则返回 1,否则就返回 0。所以,这里 setnx 设置成功可以表示成获取到锁,如果失败,则说明已经有锁,可以被视作获取锁失败。
setnx lock true
如果想要释放锁,执行 del 指令,把 key 删除即可。
del lock
利用这个特性,我们就可以让系统在执行优惠券逻辑之前,先去 Redis 中执行 setnx 指令。再根据指令执行结果,去判断是否获取到锁。如果获取到了,就继续执行业务,执行完再使用 del 指令去释放锁。如果没有获取到,就等待一定时间,重新再去获取锁。
乍一看,这一切没什么问题,使用 setnx 指令确实起到了想要的互斥效果。
但是,这是建立在所有运行环境都是正常的情况下的。一旦运行环境出现了异常,问题就出现了。
想一下,持有锁的应用突然崩溃了,或者所在的服务器宕机了,会出现什么情况?
这会造成死锁——持有锁的应用无法释放锁,其他应用根本也没有机会再去获取锁了。这会造成巨大的线上事故,我们要改进方案,解决这个问题。
怎么解决呢?咱们可以看到,造成死锁的根源是,一旦持有锁的应用出现问题,就不会去释放锁。从这个方向思考,可以在 Redis 上给 key 一个过期时间。
这样的话,即使出现问题,key 也会在一段时间后释放,是不是就解决了这个问题呢?实际上,大家也确实是这么做的。
不过,由于 setnx 这个指令本身无法设置超时时间,所以一般会采用两种办法来做这件事:
1、采用 lua 脚本,在使用 setnx 指令之后,再使用 expire 命令去给 key 设置过期时间。
if redis.call("SETNX", "lock", "true") == 1 then local expireResult = redis.call("expire", "lock", "10") if expireResult == 1 then return "success" else return "expire failed" endelse return "setnx not null"end
2、直接使用 set(key,value,NX,EX,timeout) 指令,同时设置锁和超时时间。
redis.call("SET", "lock", "true", "NX", "PX", "10000")
以上两种方法,使用哪种方式都可以。
释放锁的脚本两种方式都一样,直接调用 Redis 的 del 指令即可。
到目前为止,我们的锁既起到了互斥效果,又不会因为某些持有锁的系统出现问题,导致死锁了。这样就完美了吗?
假设有这样一种情况,如果一个持有锁的应用,其持有的时间超过了我们设定的超时时间会怎样呢?会出现两种情况:
- 发现系统在 Redis 中设置的 key 还存在
- 发现系统在 Redis 中设置的 key 不存在
出现第一种情况比较正常。因为你毕竟执行任务超时了,key 被正常清除也是符合逻辑的。
但是最可怕的是第二种情况,发现设置的 key 还存在。这说明什么?说明当前存在的 key,是另外的应用设置的。
这时候如果持有锁超时的应用调用 del 指令去删除锁时,就会把别人设置的锁误删除,这会直接导致系统业务出现问题。
所以,为了解决这个问题,我们需要继续对 Redis 脚本进行改动
首先,我们要让应用在获取锁的时候,去设置一个只有应用自己知道的独一无二的值。
通过这个唯一值,系统在释放锁的时候,就能识别出这锁是不是自己设置的。如果是自己设置的,就释放锁,也就是删除 key;如果不是,则什么都不做。
脚本如下:
if redis.call("SETNX", "lock", ARGV[1]) == 1 then local expireResult = redis.call("expire", "lock", "10") if expireResult == 1 then return "success" else return "expire failed" endelse return "setnx not null"end
或者
redis.call("SET", "lock", ARGV[1], "NX", "PX", "10000")
这里,ARGV[1] 是一个可传入的参数变量,可以传入唯一值。比如一个只有自己知道的 UUID 的值,或者通过雪球算法,生成只有自己持有的唯一 ID。
释放锁的脚本改成这样:
if redis.call("get", "lock") == ARGV[1] then return redis.call("del", "lock") else return 0 end
可以看到,从业务角度,无论如何,我们的分布式锁已经可以满足真正的业务需求了。能互斥,不死锁,不会误删除别人的锁,只有自己上的锁,自己可以释放。
一切都是那么美好!!!
可惜,还有个隐患,我们并未排除。这个隐患就是 Redis 自身。
要知道,lua 脚本都是用在 Redis 的单例上的。一旦 Redis 本身出现了问题,我们的分布式锁就没法用了,分布式锁没法用,对业务的正常运行会造成重大影响,这是我们无法接受的。
所以,我们需要把 Redis 搞成高可用的。一般来讲,解决 Redis 高可用的问题,都是使用主从集群。
但是搞主从集群,又会引入新的问题。主要问题在于,Redis 的主从数据同步有延迟。这种延迟会产生一个边界条件:当主机上的 Redis 已经被人建好了锁,但是锁数据还未同步到从机时,主机宕了。随后,从机提升为主机,此时从机上是没有以前主机设置好的锁数据的——锁丢了
到这里,终于可以介绍 Redission(开源 Redis 客户端)了,我们来看看它怎么是实现 Redis 分布式锁的。
Redission 实现分布式锁的思想很简单,无论是主从集群还是 Redis Cluster 集群,它会对集群中的每个 Redis,挨个去执行设置 Redis 锁的脚本,也就是集群中的每个 Redis 都会包含设置好的锁数据。
我们通过一个例子来介绍一下。
假设 Redis 集群有 5 台机器,同时根据评估,锁的超时时间设置成 10 秒比较合适。
第 1 步,咱们先算出集群总的等待时间,集群总的等待时间是 5 秒(锁的超时时间 10 秒 / 2)。
第 2 步,用 5 秒除以 5 台机器数量,结果是 1 秒。这个 1 秒是连接每台 Redis 可接受的等待时间。
第 3 步,依次连接 5 台 Redis,并执行 lua 脚本设置锁,然后再做判断:
- 如果在 5 秒之内,5 台机器都有执行结果,并且半数以上(也就是 3 台)机器设置锁成功,则认为设置锁成功;少于半数机器设置锁成功,则认为失败。
- 如果超过 5 秒,不管几台机器设置锁成功,都认为设置锁失败。比如,前 4 台设置成功一共花了 3 秒,但是最后 1 台机器用了 2 秒也没结果,总的等待时间已经超过了 5 秒,即使半数以上成功,这也算作失败。
再额外多说一句,在很多业务逻辑里,其实对锁的超时时间是没有需求的。
比如,凌晨批量执行处理的任务,可能需要分布式锁保证任务不会被重复执行。此时,任务要执行多长时间是不明确的。如果设置分布式锁的超时时间在这里,并没有太大意义。但是,不设置超时时间,又会引发死锁问题。
所以,解决这种问题的通用办法是,每个持有锁的客户端都启动一个后台线程,通过执行特定的 lua 脚本,去不断地刷新 Redis 中的 key 超时时间,使得在任务执行完成前,key 不会被清除掉。
脚本如下:
if redis.call("get", "lock") == ARGV[1] then return redis.call("expire", "lock", "10") else return 0 end
其中,ARGV[1] 是可传入的参数变量,表示持有锁的系统的唯一值,也就是只有持有锁的客户端才能刷新 key 的超时时间。
到此为止,一个完整的分布式锁才算实现完毕。总结实现方案如下:
- 使用 set 命令设置锁标记,必须有超时时间,以便客户端崩溃,也可以释放锁;
- 对于不需要超时时间的,需要自己实现一个能不断刷新锁超时时间的线程;
- 每个获取锁的客户端,在 Redis 中设置的 value 必须是独一无二的,以便识别出是由哪个客户端设置的锁;
- 分布式集群中,直接每台机器设置一样的超时时间和锁标记;
- 为了保证集群设置的锁不会因为网络问题导致某些已经设置的锁出现超时的情况,必须合理设置网络等待时间和锁超时时间。
这个分布式锁满足如下四个条件:
- 任意时刻只能有一个客户端持有锁;
- 不能发生死锁,有一个客户端持有锁期间出现了问题没有解锁,也能保证后面别的客户端继续去持有锁;
- 加锁和解锁必须是同一个客户端,客户端自己加的锁只能自己去解;
- 只要大多数 Redis 节点正常,客户端就能正常使用锁。
当然,在 Redission 中的脚本,为了保证锁的可重入,又对 lua 脚本做了一定的修改,现在把完整的 lua 脚本贴在下面。
获取锁的 lua 脚本:
if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil;end;if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil;end;return redis.call('pttl', KEYS[1]);
对应的刷新锁超时时间的脚本:
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('pexpire', KEYS[1], ARGV[1]); return 1; end; return 0;
对应的释放锁的脚本:
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil;end;local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]);return 0;else redis.call('del', KEYS[1]); redis.call('publish', KEYS[2], ARGV[1]);return 1;end;return nil;
到现在为止,使用 Redis 作为分布式锁的详细方案就写完了。
我既写了一步一坑的坎坷经历,也写明了各个问题和解决问题的细节,希望大家看完能有所收获。
最后再给大家提个醒,使用 Redis 集群做分布式锁,有一定的争议性,还需要大家在实际用的时候,根据现实情况,做出更好的选择和取舍。
Java的redisson模块可以很好地解决分布式锁的问题,暂未找到python版
RedLock分布式锁(python)
redisson分布式锁(Java)
可重入锁原理
第二次获取锁时,判断是否是自己摘获取锁,如果是则在获取次数上加1,并成功获取锁,当释放锁时减1,并判断是否为0,如果是0则删除,使用redis的hash实现,并用lua脚本实现
实现流程
lua脚本
获取锁
"""
--锁标识
local key = KEYS[1]
--线程唯一标识
local threadID = ARGV[1]
--锁自动释放时间
local releaseTime = ARGV[2]
if(redis.call('exists',key) == 0) then
--不存在,获取锁,并将计数器设置为1
redis.call('hset',key,threadID,'1')
--设置有效期
redis.call(‘expire’,key,releaseTime)
return 1
end
-- 锁已存在,判断线程是否是自己
if (redis.call('hexists',key,threadID) == 1) then
-- 不存在,获取锁,重入次数+1
redis.call('hincrby',key,threadID,'1')
-- 设置有效期
redis.call('exipre',key,releaseTime)
return 1
end
return 0
"""
释放锁
"""
--锁标识
local key = KEYS[1]
--线程唯一标识
local threadID = ARGV[1]
--锁自动释放时间
local releaseTime = ARGV[2]
-- 判断当前锁是否还是自己持有
if(redis.call('hexists',key) == 0) then
--如果不是说自己,直接返回
return nil
end
-- 是自己的锁,则重入次数-1
local count = redis.call('hincrby',key,threadID,-1)
-- 判断是否重入次数为0
if (count > 0) then
-- 大于0说明不能释放锁,充值有效期
redis.call('exipre',key,releaseTime)
return nil
else
--等于0则可以释放锁,直接删除
redis.call('del',key)
return nil
end
"""
可重试原理
利用信号量和PubSub功能实现等待,唤醒,获取 的锁失败重试机制
超时续约
超时续约:利用watchDog 做定时任务,隔一段时间(releaseTime/3)重置超时时间
主从一致性
主从同步时的这一段时间发生不一致
处理方法(联锁):
多节点机制,并且多个redis节点同时获取锁成功才算成功
多主节点机制,并且多个redis主节点同时获取锁成功才算成功
redis优化(异步秒杀)
流程分析
全部串行,并且数据库操作多
优化方案
业务分离,对于耗时较久的业务,开启独立线程异步处理
# lua脚本保证扣减库存和一人一单的原子性
库存判断:
用string结构存储,并提前减去库存
一人一单:
用set结构存储,多元素存储,并确保元素不重复
流程
lua脚本
-- 参数列表
-- 1.优惠券id
local voucherId = ARGV[1]
-- 2.用户id
local userId = ARGV[2]
-- 数据key
-- 1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.订单key
local stockKey = 'seckill:order' .. voucherId
-- 脚本业务
-- 1.判断库存是否充足
if (tonumber(redis.call('get',stockKey) <=0) then
-- 库存不足,返回1
return 1
end
-- 2.判断用户是否下过单
if (redis.call('sismember',stockKey,userId) == 1) then
-- 重复下单,返回2
return 2
end
-- 扣库存
redis.call('incrby',stockKey,-1)
-- 添加到订单成员集合中
redis.call('sismember',stockKey,userId)
-- 成功返回0
return 0
缺陷
基于阻塞队列的异步秒杀存在的问题:
1.阻塞队列创建时,需指定大小,如果订单存满了,则存不进去了
2.数据安全问题(服务宕机,订单数据丢失)
消息队列实现异步秒杀
消息队列模型
消息队列:存储和管理消息,也被成为消息代理(Message Broker)
生产者:发送消息到消息队列
消费者:从消息队列获取消息并处理消息
redis消息队列
基于list数据结构的消息队列
利用redis的list数据结构实现消息队列,LPUSH和RPOP或者RPUSH和LPOP,但是这两个命令是不阻塞的,并且删除原数据,取不到为nil,需要通过BLPUSH和BRPOP或者BRPUSH和BLPOP实现
优点:
1.利用Redis存储,不受限内存上限
2.基于Redis的持久化机制,数据安全得到保证
3.可以满足消息有序性
缺点:
1.无法避免消息丢失
2.只支持单消费者(无法实现一条消息被多消费者消费)
基于PubSub的消息队列
PubSub(发布订阅) 是redis 2.0引入的消息传递模型,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息
订阅频道可以指定或者可以通过正则匹配
# 订阅频道
subscribe 频道名
subscribe order.q1
subscribe order.*(匹配order.全部)
# 发布消息
publish 频道名 消息
publish order.q1 hello
优点:
采用发布订阅模型,支持多生产,多消费
缺点:
1.不支持持久化,无法避免消息丢失(发完消息没人接收直接丢弃)
2.消息堆积有上限,超出时数据丢失(消息缓存在客户端的缓冲区)
Stream消费队列(是一种数据类型)
消息永久存在,可以被一个或者多个消息费多次读取
向队列发送消息
# 命令
xadd 队列名
#参数说明:
nomaketream:是否创建队列名
maxlegth:消息最大数量
*|ID:消息的唯一标识,*表示redis自动内部维护,时间戳-递增数字,如17345646764356-0,自己指定也必须按照这个格式
filed:消息体的key
value:消息体的值
# 实例
# 返回值为消息的唯一标识
xadd users * name jack age 21
查看队列长度
# 命令
xlen 队列名
读取队列中的消息
xread
# 命令
xread
# 参数说明
count: 读取消息条数
block mill:当没有消息时是否阻塞,默认为false
streams key:队列名
id:起始消息id,0表示第一条消息开始,$ 表示从最新消息开始
# 实例
xread count 1 streams users 0
# 注意xread 或造成漏读消息,当我们指定起始id为$是,表示读最新消息,当我们处理这条最新消息的过程中,又有超过一条以上的消息添加到消息队列中,则小吃获取的也是最新的一条消息,
# 特点:
1.消息可回溯
2.一个消息可以被多个消费者读取
3.可以阻塞读取
4.有消息漏读风险
消费者组(Consumer Group)
# 消费者组解决上述问题
将多个消费者划分到一个组中,监听同一个队列
特点:
1.消息分流
队列中的消息会分流给组内的不同消费者,而不是重复消费者,从而加速消息处理的速度
2.消息提示
消费者组会维护一个标识,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标识之后读取消息,确保每一个消息都会被消费
3.消息确认
消费者读取消息后,消息处于pending状态,并存入一个pendinglist,当处理完成后需要通过xack来确认消息,标记消息为已处理,才会从pendinglist中移除
创建消费者组
# 命令
xgroup create key groupName ID [mkstream]
# 参数说明
mkstream:队列不存在时自动创建队列
ID:起始消息id,0表示第一条消息开始,$ 表示从最新消息开始
删除消费者组
xgroup destory key groupName
向消费者组中添加消费者
# 在消费者组中指定消费者并监听消息时,如果组中的消费者不存在则自动创建
xgroup createconsumer key groupName consumername
删除消费者注重的指定消费者
XGROUP DELCONSUMER key groupName consumername
读取消息(必须确认消息)
# 命令
XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]
XACK key group ID [ID ...]
# 参数说明
group:消费者组名称
consumer:消费者名称,如果该名称不存在则自动创建一个消费者到指定消费者组中
count: 本次查询的最大数量
block mill:当没有消息时是否阻塞,默认为false
NOACK:指定是否消息确认,一般不配置这个参数
STREAMS key:队列名
ID:起始消息id
1.">",从下一个未消费的消息开始
2.其他的参数,根据指定ID从pendinglist中获取已消费但未确认的消息,如0是从第一个开始
# 如何配置ID参数?
正常情况指定“>”,并确认消息,异常情况则指定0,如果消费成功则确认消息
读取消息流程
查看pending-list
# 命令
XPENDING key group [[IDLE min-idle-time] start end count [consumer]]
# 参数说明
IDLE min-idle-time:获取以后,确认消息之前的时间
start end:指定最大最小id范围,"-"表示最小,"+“ 表示最大
两种读取方式对比
XREADGROUP命令
1.消息可回溯
2.可以多消费者争抢消息,加快消费速度
3.可以阻塞读取
4.没有消息漏读风险
5.有消息确认机制,保证消息至少被消费一次
redis消费队列对比
代码流程图
点赞
用户点赞时,数据库相应字段增加,并在set中存储当前点赞用户的id,查询时,直接判断当前用户ID是否在集合中,如果不在则在数据库查询,数据库有就将数据缓存到redis,如果数据库没有则表示用户尚未点赞,问题是当查询时set是无序的,则选择sortedset
点赞排行榜
业务需求是按照点赞次数排序,可以用redis中的SortedSet数据结构来实现,判断是否是sortset中的成员可以通过zscore来查询,如果存在则返回对应分数,如果不存在则返回nill,分数可以用时间戳来表示,这会按照插入时间排序
# 数据库in查询会根据ID 排序返回,而不是代码给定的id顺序
比如
select * from user where id in (5,1)
# 结果会是先1后5,解决方式:
select * from user where id in (5,1) order by field(id,5,1)
共同关注
用redis的set求交集就可以找到两个人的共同关注
需要再查询共同关注前将两个人的关注列表存在redis中,即用户点关注的时候就应该在redis中存储我的关注列表
关注消息推送
关注推送也叫Feed流,直译为投喂,为用户持续的提供"沉浸式"的体验,通过无限下拉刷新获取最新的信息
Feed流实现模式
TimeLine
不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注,例如朋友圈
优点:信息全面,不会有缺失,并且实现也相对简单
缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
拉模式
也叫读扩散,比如博主在更新自己的博客,当登录用户需要看自己所关注的博主内容时,程序会将内容拉到自己的收件箱中,然后根据时间进行排序,当用户读完以后会将本地清空,下次点进来重新拉取
缺点:耗时时间太长,
推模式(使用频率高)
也叫写扩散,当用户写时,会直接推送到各个粉丝的收件箱中,并根据时间做排序,这样粉丝读取消息时,早就写好了,延迟低,节省时间
缺点:如果博主的粉丝很多(过千万),则需要写n多份,内存占用很高
代码实现思路
在Feed流中的数据会不断更新,所以数据的下标也在变化,因此不能使用传统的分页模式
当有最新的数据插入时,之前的数据的下标会发生变化,当查询第二页时,因为是按照下标查询,可能会导致第二页的数据不是想要查询到的数据
# 滚动分页
记录查询位置,从查询位置往后查询多少个,永远是按照查询位置往后数几个,就不会造成分页数据混乱
因为要做倒序查询
redis命令:
# 第一次查询时
ZREVRANGEBYSCORE z1 当前时间戳 0 WITHSCORES LIMIT 0 查的条数
(0表示小于等于查询条件需要偏移几个,第一次查询时需要包含第一条数据)
# 第一次查询结束后需要返回给前端,下次查询需要带的条件:
minTime:本次查询最小的时间戳
offset:有几条和最小时间戳相同的数据(包含本身)
-
# 第二次查询时
ZREVRANGEBYSCORE z1 上一次查询的最小分数 0 WITHSCORES LIMIT 1 查询条数
(1表示小于等于查询条件需要偏移几个,这个参数需要动态变化,如果分数没有相同的则为1,如果有跟查询条件相同分数的数据,则为相同分数数据的个数)
# 假如有两条数据的分数相同时,redis会先从第一条开始算,同样会找陈数据混乱,所以offset要根据有几个分数一样的数据来动态变化
(1表示小于等于1的查询条数个元素,第二次查询时不需要包含第一条数据,则为1)
用户写笔记时,将笔记的ID推送到redis中对应粉丝的收件箱中,用sortedset保存起来,key为feed:粉丝ID
推拉结合
如果是普通博主,则可以用推模式,写的次数会少,如果是大v(千万以上),则将粉丝分为两类,活跃粉丝用推模式,不活跃的粉丝用拉模式
大v发送消息时,发件箱一份,活跃粉丝的收件箱一份,不活跃粉丝想看直接自己拉就可以了
缺点:代码复杂度高
智能排序
利用只能算法屏蔽掉违规的,用户不感兴趣的内容,推送用户感兴趣信息来吸引用户
优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
缺点:如果算法不精准,可能起反作用
附近商户(Geo)
GEO数据结构及相关命令
存储地理坐标数据
GEOADD:添加一个地理空间信息,包含:经度,纬度,值(member)
GEODIST:计算指定的两个点之间的距离并返回
GEOHASH:将指定member的坐标转为hash字符串形式返回
GEOPOS:返回指定member的坐标
GEORADIUS:指定圆心,半径 找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回,6.2以后废弃
georadius zhan 116.397904 39.909005 10 km withdist
GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回,范围可以是圆形或矩形,6.2新功能
GEOSEARCHSTORE:与GEOSEARCH功能一直,不过可以把结果存储到一个指定的key,6.2新功能
附近商户搜索
# 查找指定坐标内的成员,按距离排序,并显示距离
georadius zhan 116.397904 39.909005 10 km withdist
# 缓存商铺时,根据商铺类型分别缓存商铺坐标,如:
shop:geo:meishi
shop:geo:KTV
用户签到(BitMap)
1110111101111011111110
把每一个bit为对应当月的每一天,形成映射关系,用0和1表示业务状态,这种思路成为位圈(BitMap)
redis中是利用string类型数据结构实现BitMap,因此最大上限为512M,转换为bit这是2^32个bit位
基础命令
SETBIT:向指定位置(offset) 存入一个0或1
GETBIT:获取指定位置(offset)的bit值(只能查一个位置)
BITCOUNT:统计BitMap中值为1的bit位的数量
BITFIELD:操作(查询,修改,自增)BitMap中bit数组中的指定位置(offset)的值(一般用于查询,可以查多个)
BITFIELD s1 GET u2 0
u表示无符号 i表示有符号,2表示读取两位,从1开始计数,0表示从0 开始读
BITFIELD_RO:获取BitMap中bit数组,并以十进制形式返回
BITOP:将多个BitMap的结果做位运算(与,或,异或)
BITPOS:查找bit数组中指定范围内第一个0或1出现的位置
用户签到
# 存入签到信息
以月为单位存储签到信息
sign:userid:2023:2 0101101111101011
今天是本月的第几天就存入第多少 -1 位(因为redis中下标是从0开始的)
签到统计
# 连续签到次数
从最后一次签到开始向前统计,知道遇到第一次未签到位置,计算总的签到次数,就是连续签到天数
import redis
from datetime import date, timedelta
import calendar
r = redis.StrictRedis(host='localhost', port=6379, db=0, decode_responses=True)
def count_day_of_mouth():
"""
计算几天是这个月的第几天
:return:
"""
date_list = list()
start_day = date.today().replace(day=1) # 这里是获取当前月份的第一天
end_day = start_day + timedelta(
days=calendar.monthrange(start_day.year, start_day.month)[1] - 1) # 这里是获取当前月份的最后一天
day = timedelta(days=1)
while start_day < end_day: # 循环判断开始的月份是否大于最后一天
date_list.append(start_day.strftime("%Y-%m-%d")) # 不大于就添加到列表里面
start_day += day # 然后当前时间在加一天
# date.today() 是获取当前时间
d = date_list.index(date.today().strftime("%Y-%m-%d")) # 这里是在列表内获取当前时间的索引
return d + 1 # 列表内索引是0开始,所以在这里需要+1
def sign():
"""
当天签到,下标签到
:return:
"""
count_day = count_day_of_mouth()
print("今天是本月的第%s天" % count_day)
return r.setbit("s1", count_day - 1, 1)
def get_sign_message():
"""
获取签到结果,十进制
:return:
"""
int_sign = r.bitfield("s1").get("u" + str(count_day_of_mouth()), 0).execute()[0]
bin_sign = bin(int_sign)[2:]
print("签到结果为:", bin_sign)
return int_sign
def count_sign():
"""
计算连续签到次数
:return:
"""
int_sign = get_sign_message()
bin_sign = bin(int_sign)[2:]
count = 0
for i in bin_sign[::-1]:
if i == "1":
count += 1
else:
break
print(count)
第二种方式
import redis
from datetime import date, timedelta
import calendar
r = redis.StrictRedis(host='localhost', port=6379, db=0, decode_responses=True)
def count_day_of_mouth():
"""
计算几天是这个月的第几天
:return:
"""
date_list = list()
start_day = date.today().replace(day=1) # 这里是获取当前月份的第一天
end_day = start_day + timedelta(
days=calendar.monthrange(start_day.year, start_day.month)[1] - 1) # 这里是获取当前月份的最后一天
day = timedelta(days=1)
while start_day < end_day: # 循环判断开始的月份是否大于最后一天
date_list.append(start_day.strftime("%Y-%m-%d")) # 不大于就添加到列表里面
start_day += day # 然后当前时间在加一天
# date.today() 是获取当前时间
d = date_list.index(date.today().strftime("%Y-%m-%d")) # 这里是在列表内获取当前时间的索引
return d + 1 # 列表内索引是0开始,所以在这里需要+1
def sign():
"""
当天签到,下标签到
:return:
"""
count_day = count_day_of_mouth()
print("今天是本月的第%s天" % count_day)
return r.setbit("s1", count_day - 1, 1)
def get_sign_message():
"""
获取签到结果,十进制
:return:
"""
int_sign = r.bitfield("s1").get("u" + str(count_day_of_mouth()), 0).execute()[0]
bin_sign = bin(int_sign)[2:]
print("签到结果为:", bin_sign)
return int_sign
def count_sign():
"""
计算连续签到次数,
:return:
"""
int_sign = get_sign_message()
sign_days = 0
while 1:
# 和1做与运算,得到最后一个bit位。判断你这个bit是否为0
if int_sign & 1 == 0:
# 从未签到,直接结束
break
else:
# 签到过,可以计算连续签到天数
sign_days += 1
# 整体右移一位,抛弃最后一个bit位,继续下一个bit位
int_sign >>= 1
return sign_days
print(count_sign())
uv统计(HyperLogLog)
UV:全程Unique Visitor 也叫独立访客量,是指通过互联网访问,浏览这个网页的自然人,一天内同一个用户多次访问该网站,只记录一次
PV:全程Page View,也叫页面访问量或点击量,用户没访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV,往往用来衡量网站的流量
HyperLogLog 是从LogLog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储所有值,redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用非常低,作为代价,其衡量结果是概率性的,有小于0.81%的误差,完全可以忽略
命令
PFADD: 添加
PFCOUNT:计数,只统计不同的值,重复值是不统计的
PFMERGE:合并,可以将每天的key合并在一起在PFCOUNT,来实现这种按月按年统计
测试
def Pfadd():
user_list = []
for i in range(1000000):
# 每千条插入一次
if i != 0 and i % 1000 == 0:
print("i==",i)
r.pfadd("p1",*user_list)
user_list.clear()
user_list.append("user_" + str(i))
print(r.pfcount("p1")) # 统计结果为:996591
Pfadd()
多级缓存(亿级流量的缓存方案)
传统缓存
传统缓存策略一般是请求到达Tomcat后,先查询Redis,如果未命中则查询数据库,存在以下问题
1.请求依然会先访问到Tomcat,但是Tomcat本身的性能成为了整个系统的并发瓶颈
2.Redis缓存失效时,会对数据库产生冲击
多级缓存
就是充分利用请求处理的每个环节,分别添加缓存,减轻Tomcat的压力,提升服务性能
用户可以通过浏览器或者手机客户端访问数据及渲染,
#一级缓存:浏览器客户端缓存
浏览器可以将服务器的静态资源缓存在本地,下次用户访问的时候,服务器检查一下静态资源是否发生变化,如果没有变化,服务器直接返回304状态码,表示客户端本地已经缓存了,直接渲染本地数据,这样可以减少数据的传输从而提高性能
# 二级缓存:nginx本地缓存
nginx也是可以编程的,对于用户的请求,nginx查询本地是否有缓存,如果有直接返回,nginx可以搭建nginx集群,用来做web服务器,当然也可以用单独的nginx做反向代理
# 三级缓存:redis缓存
如果nginx未命中,直接由nginx发送请求查询redis数据库,
# 四级缓存:TomCat 进程缓存
先读本地缓存,命中即返回
安装Mysql
准备目录
cd /tmp
mkdir mysql
cd mysql
执行docker命令
进入mysql目录后执行下面Docker命令:
docker run \
-p 3306:3306 \
--name mysql \
-v $PWD/conf:/etc/mysql/conf.d \
-v $PWD/logs:/logs \
-v $PWD/data:/var/lib/mysql \
-e MYSQL_ROOT_PASSWORD=123 \
--privileged \
-d \
mysql:5.7.25
修改配置
在 /tmp/mysql/conf目录添加一个my.cnf文件,作为mysql的配置文件
touch /tmp/mysql/conf/my.cnf
文件内容如下:
[mysqld]
skip-name-resolve
character_set_server=utf8
datadir=/vor/lib/mysql
server-id=1000
配置修改后,重新启动容器
docker restart mysql
nginx方向代理配置
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
# nginx(openresty集群)的业务集群
upstream nginx-cluster{
server 192.168.150.101:8081;
server 192.168.150.101:8082;
}
server {
listen 80;
server_name localhost;
# 监听 /api的所有请求,反向代理到nginx-cluster集群
location /api {
proxy_pass http://nginx-cluster;
}
location / {
root html;
index index.html index.html
}
error_page 500 502 503 504 /50x.html;
location = /50x.html{
root html;
}
}
}
JVM进程缓存
缓存再日常开发中启着至关重要的作用,由于是存储在内存中,数据的读取速度是非常快的,能大量减少数据库的访问,减少数据库的压力,我们把缓存分为两类:
- 分布式缓存,如Redis:
- 优点:存储容量更大,可靠性更好,可以在服务集群间共享
- 缺点:访问缓存有网络开销
- 场景:缓存数据量较大,可靠性要求较高,需要再集群间共享
- 进程本地缓存,例如Java中的HashMap,GuavaCache
- 优点:读取本地内存,没有网络开销,速度更快
- 缺点:存储容量有限,可靠性较低,无法共享
- 场景:性能要求较高,缓存数量较小
nginx编程
lua基础
-- 数据类型
nil
boolean
number: 双精度类型的实浮点数
string: 字符串,单引号或双引号,拼接用..
function: 由c或lua编写的函数
table: lua中的表(table),其实是一个"关联数组",数组的索引可以是数字,字符串或表类型,在lua中table的创建时通过"构造表达式"来完成,最简单的构造表达式是{},用来创建一个空表
-- 变量(local 声明局部变量,变量名=值 为 全局声明)
local 变量名 = 值
local arr = {1,2,3} -- 数组
local map = {name=1,age=2} -- 字典
-- 访问数组
arr[1] -- 下标从1开始
-- 访问table
map['name'] 或 map.name
-- 循环
-- 遍历数组
for index,value in ipairs(arr) do
print(index,value)
end
-- 遍历table
for key,value in pairs(map) do
print(key,value)
end
-- 函数
function 函数名(参数1,参数2)
return 返回值
end
-- 条件控制(and or not 与或非)
if (布尔表达式) then
-- 布尔表达式为true 执行的代码块
elseif 布尔表达式
-- 继续判断
else
-- 布尔表达式为false 执行的代码块
end
OpenResty
基于Nginx的高性能web平台,用于方便的搭建能够处理超高并发,扩容性极强的动态web应用web服务和动态网关:
1.具备Nginx的完整功能
2.基于Lua语言进行扩展,集成了大量尽量的Lua库,第三方模块
3.允许使用Lua自定义业务逻辑,自定义库
官网地址:http://openresty.org/cn/
安装
安装openResty的依赖开发库
yum install -y pcre-devel openssl-devel gcc --skip-broken
安装OpenResty仓库
可以在CentOS系统中添加openresty仓库,可以便于未来安装或更新软件包(yum-check-update命令),运行下面的命令可以添加仓库:
yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo
如果提示命令不存在,则运行
yum install -y yum-utils
然后再重复上面的命令
安装OpenResty
yum install -y openresty
安装opm工具
opm是OpenResty的一个管理工具,可以帮助我们安装一个第三方的lua模块
如果想安装命令行工具opm,那么可以像下面这样安装 openresty-opm包
yum install -y openresty-opm
openresty的安装目录在:/usr/local/openresty
配置nginx的环境变量
打开配置文件
vi /etc/profile
在最下面添加两行
export NGINX_HOME=/usr/local/openresty/nginx
export PATH=$(NBINX_HOME)/$bin:SPATH
NGINX_HOME:后面是OpenResty安装目录下的nginx的目录:
然后让配置生效
source /etc/profile
启动和运行
OpenResty底层是基于Nginx的,所以运行方式与Nginx基本一致:
# 启动nginx
nginx
# 重新加载配置
nginx -s reload
# 停止
nginx -s stop
nginx的默认配置文件注释太多,影响后续编辑,这里将只保留有效部分(最简化版的nginx原生配置)
修改/usr/local/openresty/nginx/conf/nginx.conf文件
worker_processes 1;
error_log logs/error.log;
events (
worker_connections 1024;
)
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 8081;
server_name localhost;
location / {
root html;
index index.html index.html
}
error_page 500 502 503 504 /50x.html;
location = /50x.html{
root html;
}
}
}
快速入门
nginx反向代理过来的请求
1.在nginx.conf的http下面,添加对OpenResty的Lua模块的加载:
# 加载Lua模块
lua_package_path "/usr/local/openresty/lualib/?.lua;;";
# 加载c模块
lua_package_cpath "/usr/local/openresty/lualib/?.so;;";
2.在nginx.conf的server下面,添加对/api/item 这个路径的监听
location /api/item{
# 相应类型,这里返回json
default_type application/json;
# 相应数据由 lua/item.lua 这个文件来决定
content_by_lua_file lua/item.lua;
}
3. 在nginx.conf的server下面,添加对8081端口监听的负载均衡
location /item{
proxy_pass http://192.168.150.1:8081;
}
在nginx目录下创建lua文件夹
cd /usr/local/openresty/nginx
mkdir lua
# 创建一个item.lua文件
touch lua/item.lua
lua脚本
-- 简单的返回假数据,就是写数据到Response中
ngx.say('{"id":10001,"name":"SALSA AIR"}')
重新加载配置
nginx -s reload
请求参数的获取
在nginx的配置文件中的server
-- 路径中携带的参数
如:/item/1001
--1.正则表达式匹配: ~ 表示后面是正则匹配
location ~ /item/(\d+) {
conten_by_lua_file lua/item.lua;
}
--2.匹配到的数据会存入ngx.var数组中,可以使用下标获取,在lua脚本中获取请求参数
local id = ngx.var[1]
-- 请求头中的参数
如: id:10001
-- 获取请求头,返回值是table类型
local headers = ngx.req.get_headers()
-- get请求参数
如: ?id=1001
--获取GET请求参数,返回值是table类型
local getParams = ngx.req.get_uri_args()
--post表单参数
如:id=10001
-- 需要先读取请求体
ngx.req.read_body()
-- 获取POST表单参数,返回值是table类型
local postParams = ngx.req.get_post_args()
--json参数
如:{"id":10001}
-- 需要先读取请求体
ngx.read_body()
-- 获取body中的json参数,返回值是string类型
local jsonBody = ngx.req.get_body_data()
发送http请求
local resp = ngx.location.captrue(
"/path",{ --请求路径
method = ngx.HTTP_GET, --请求方式
args = (a=1,b=2) -- get方式传参
body = "c=3&d=4" -- post方式传参
}
)
-- 相应内容包括:
1.resp_status:响应状态码
2.resp.header:相应头,是一个table类型
3.resp.body:相应体,就是相应数据
-- 请求路径
path这里表示请求路径,并不包含ip和端口,这个请求会被nginx内部的server监听并处理,可以通过配置nginx的反向代理,代理到对应目标服务器,即在nginx中配置如下配置:
location /path {
proxy_pass http://192.168.150.1:8081;
}
将发送http请求的业务封装成函数,放到OpenResty函数库中,后期直接调用就可以了
在/usr/local/openresty/lualib目录下创建common.lua文件
vi /usr/local/openresty/lualib/common.lua
在common.lu中封装http查询的函数
local function read_http(path,params)
local resp = ngx.location.capture(path,{
method = ngx.HTTP_GET,
args = params,
})
if not resp then
-- 记录错误信息,返回404
ngx.log(ngx.ERR,"http not found path",path,", args:",args)
ngx.exit(404)
end
return resp.body
end
--将方法导出
local _H = {
read_http = read_http
}
return _H
使用封装好的http请求函数
--导入common函数库
local common = require('common')
-- 导入cjson序列化模块
local cjson require('cjson')
--拿到函数
local read-http = common.read_http
-- 获取路径参数
local id = ngx.var[1]
-- 查询商品信息
local itemJson = read_http("/item/" .. id,nil)
-- 查询库存信息
local stockJson = read_http("/item/stock/" .. id,nil)
-- 将json转成table(cjson模块)
local item = cjson.decode(itemJson)
local stock = cjson.decode(stockJson)
-- 将库存和销量拼接到item table中
item.stock = stock.stock
item.sold = stock.sold
-- 将item序列化成Json
itemJson = cjson.encode(item)
-- 返回结果
ngx.say(itemJson)
添加Tomcat集群负载均衡
在openresty 的nginx.conf文件中添加
# 在nginx.conf的server下面,添加对/item 这个路径的监听
location /item {
proxy_pass http://tomcat-cluster;
}
# 在nginx.conf的http下面,添加对OpenResty的tomcat 集群配置
upstream tomcat-cluster{
server 192.168.150.1:8081;
server 192.168.150.1:8082;
}
但是tomcat是进程之间隔离的,第一次访问时,如果这台tomcat缓存了对应数据,那么当负载均衡的轮询算法,访问下一台机器的时候依然不会命中缓存,所以需要更改负载均衡算法,即request_uri hash算法,配置文件修改为:
# 1在nginx.conf的http下面,添加对OpenResty的tomcat 集群配置,修改负载均衡算法
upstream tomcat-cluster{
hash $request_uri;
server 192.168.150.1:8081;
server 192.168.150.1:8082;
}
# 负载均衡算法:会对访问路径计算出hash值,然后取余负载均衡的服务器个数,如果请求路径不变,那么计算出的hash值永远相同,则永远访问同一台tomcat
冷启动与缓存预热
冷启动:服务刚刚启动时,Redis中并没有缓存,如果所有商品数据都在第一次查询时添加缓存,可能会给数据库带来较大压力
缓存预热:在实际开发中,我们可以利用大数据统计用户访问的热点数据,在项目启动时将这些热点数据提前查询并保存到redis中
缓存预热
安装redis
docker run --name redis -p 6379:6379 -d redis redis-server --appendonly yes
就是需要再服务启动时,直接调用写好的程序用来查询并保存到redis中来预热,
openresty查询redis
引入Redis模块,并初始化redis配置
-- 导入redis模块
local redis = require('resty.redis')
-- 初始化Redis对象
local red = redis:new()
-- 设置Redis超时时间
red:set_timeouts(1000,1000,1000)
封装redis操作函数,用来释放Redis连接,就是放回连接池
-- 用来关闭redis连接的方法,其实就是就按连接返回连接池中
local function close_redis(red)
local pool_max_idle_time = 10000 -- 连接的空闲时间
local pool_size = 100 --连接池的大小
local ok, err = red:set_keppalive(pool_max_idle_time,pool_size)
if not ok then
ngx.log(ngx.ERR,"放入Redis连接池失败: ",err)
end
end
封装函数到common中,从Redis读数据并返回
-- 导入redis模块
local redis = require('resty.redis')
-- 初始化Redis对象
local red = redis:new()
-- 设置Redis超时时间
red:set_timeouts(1000,1000,1000)
local function read_redis(ip,port,key)
-- 获取一个redis连接
local ok, err = red:connect(ip,port)
if not ok then
ngx.log(ngx.ERR,"放入Redis连接池失败: ",err)
end
-- 查询redis
local resp, err = red:get(key)
if not resp then
ngx.log(ngx.ERR,"查询Redis失败: ",err)
end
-- 得到的数据为空处理
if resp == ngx.null then
resp = nil
ngx.log(ngx.ERR,"查询Redis数据为空: ",err)
end
-- 将连接放回连接池
close_redis(red)
return resp
end
-- 将方法导出
local _H = {
read_redis = read_redis
}
return _H
使用函数
-- 导包
local common = require("common")
local read_redis = common.read_redis
local function read_data(key,path,params)
-- 先查redis
local resp = read_redis("127.0.0.1",6379,key)
-- 如果redis未命中,则查tomcat
if not resp then
ngx.log("redis查询失败,尝试查询http!!! key:",key)
resp = read_http(path,params)
end
return resp
end
-- 获取路径参数
local id = ngx.var[1]
-- 查询商品信息
local itemJson = read_data("item:" .. id,"/item/" .. id,nil)
-- 查询库存信息
local stockJson = read_data("item:stock:" .. id,"/item/stock/" .. id,nil)
-- 将库存和销量拼接到item table中
item.stock = stock.stock
item.sold = stock.sold
-- 将item序列化成Json
itemJson = cjson.encode(item)
-- 返回结果
ngx.say(itemJson)
nginx 本地缓存
openresty 为Nginx提供了shared dict 功能,可以在nginx的多个worker之间共享数据,实现缓存功能,无法在openresty集群中共享
# 开启共享字典,在nginx.conf 的http下添加配置
# 共享字典,也就是本地缓存,名称叫做:item_cache,大小150m(可以指定)
lua_shared_dict item_cache 150m;
lua脚本操作共享字典
-- 获取本地缓存对象
local item_cache = ngx.shared.item_cache
-- 存储,指定key,value,过去时间,单位为s,默认为0,永不过期
item_cache:set('key',"value",1000)
-- 读取
local val = item_cache:get("key")
修改查询逻辑
-- 导包
local common = require("common")
local read_redis = common.read_redis
local read_http = common.read_http
local item_cache = ngx.shared.item_cache
local function read_data(key,expire,path,params)
-- 先查询本地缓存
local resp = item_cache:get("item:cache" .. key)
if not resp then
ngx.log(ngx.ERR,"本地缓存查询失败,尝试查询Redis!!! key:",key)
-- 先查redis
local resp = read_redis("127.0.0.1",6379,key)
-- 如果redis未命中,则查tomcat
if not resp then
ngx.log(ngx.ERR,"redis查询失败,尝试查询http!!! key:",key)
resp = read_http(path,params)
end
-- 查询成功,先写入本地缓存
item_cache:set("item:cache" .. key,resp,expire)
ngx.log(ngx.ERR,"本地缓存写入成功!! key:",key)
end
return resp
end
-- 获取路径参数
local id = ngx.var[1]
-- 查询商品信息
local itemJson = read_data("item:" .. id,30*60,"/item/" .. id,nil)
-- 查询库存信息
local stockJson = read_data("item:stock:" .. id,1*60,"/item/stock/" .. id,nil)
-- 将库存和销量拼接到item table中
item.stock = stock.stock
item.sold = stock.sold
-- 将item序列化成Json
itemJson = cjson.encode(item)
-- 返回结果
ngx.say(itemJson)
多级缓存的缓存同步策略
常见缓存同步策略
1.设置有效期:给缓存设置有效期,到期后自动删除,再次查询时更新
优点:简单,方便
缺点:时效性差,缓存过期之前可能不一致
场景:更新频率较低,时效性要求低的业务
2.同步双写:在修改数据库的同时,直接修改缓存
优点:时效性强,缓存与数据库强一致
缺点:由代码侵入,耦合度高
场景:对一致性,时效性要求较高的缓存数据
3.异步通知:修改数据库时发送事件通知,相关服务监听到通知后修改缓存数据(可以通过消息队列通知)
优点:低耦合,可以同时通知多个缓存服务
缺点:时效性一般,可能存在终极爱你不一致的状态
场景:时效性要求一般,有多个服务需要同步
# 可以对经常变化的数据,做高强度的一致性同步,对不经常变化的,可以做这种设置有效期的缓存同步
基于Cannal的异步通知
cannal是阿里巴巴旗下的一个开源项目,基于java开发,基于数据库增量日志解析,提供增量数据订阅&消费,github地址:https://github.com/alibaba/canal
# canal是基于mysql的主从同步来实现的,mysql的主从同步原理如下:
mysql的master 将数据变更写入二进制日志(binary log),其中记录的数据叫做binary log events,mysql的slave不断的将binary log events 拷贝到自己的中继日志(relay log)中,这样slave重放加载的relay log中的时间,将自己的数据做同步变更
# canal 就是把自己伪装成Mysql的一个slave节点,从而监听master的binary log 变化,再把得到的变化信息通知给Canal的客户端,进而完成对其他数据库的同步
开启mysql的主从同步
1.开启binlog
# mysql/conf,添加以下内容
# 设置binarylog文件的存放地址和文件名,这里叫做mysql.bin
log-bin=/var/lib/mysql/mysql-bin
# 指定对那个database记录binarylog events,这里记录xx这个库
binlog-do-db=kuming
2.给从节点设置权限
接下来添加一个仅用来数据同步的用户,处于安全考虑这里仅提供对xx这个库的操作权限
create user canal@'%' IDENTIFIED by 'canal';
GRANT SELECT,REPLICATION SLAVE,REPLICATION CLIENT,SUPER ON "." TO 'canal'@'%' identfied by 'canal';
FLUSH PRIVILEGES;
重启mysql
docker restart mysql
安装Canal
我们需要穿件一个网络将myusql canal mq放在一个docker网络中
docker network create wangluo
让mysql 加入这个网络
docker network connect wangluo mysql
安装Canal
需要先加载canal的镜像压缩包
还需要自己找
上传到虚拟机上
docker load canal.tar
然后运行命令创建Canal容器
docker run -p 11111:11111 --name canal \
-e canal.destinations=canaljiqunming \
-e canal.instance.master.address=mysql:3306 \
-e canal.instance.dbUsername=canal \
-e canal.instance.dbPassword=canal \
-e canal.instance.connectionCharset=UTF-8 \
-e canal.instance.tsdb.enable=true \
-e canal.instance.gtidon=false \
-e canal.instance.filter.regex=kuming\\..* \
--network wanglu \
-d canal/canal-server:v1.1.5
参数说明:
# 数据库地址和端口,如果不知道mysql容器地址,可以通过 docker_inspect 容器id 来查看
-e canal.instance.master.address=mysql:3306
# 要监听的库名
-e canal.instance.filter.regex=kuming\\..*
库名称监听支持的语法
mysql 数据解析关注的表,Perl正则表达式
多个正则之间以逗号分割,转义符要使用双斜杠(\\)
常见例子:
1.所有表: .* .*\\..*
2.canal schema下所有表:canal\\..*
3.canal下的以canal打头的表: canal\\.canal.*
4.canal schema下的一张表:canal.test1
5.多个规则组合使用然后以逗号隔开:
canal\\..*,mysql.test1,mysql.test2
canal客户端
canal提供了各种语言的客户端,当canal监听到binlog变化时,则会通知canal客户端,需要再服务中编写对应的canal接受逻辑
redis企业经验总结
Redis键值设计
# redis的key是自定义的,不过建议遵循下面几个约定:
1.遵循基本格式: 业务名:数据名:[id] # 以冒号隔开
优点:可读性强,避免key冲突,方便管理(:分割在图形化界面会以文件夹的形式存在)
2.长度不超过44字节
key是string类型,底层编码包括int,embstr和raw三种,embstr底层存储时小于44字节使用,采用连续空间,内存占用更小,在redis3或redis4以下是39字节限制
3.不包含特殊字符
Bigkey问题
# Bigkey通常以Key的大小和Key中成员你的数量来总和判定,如:
1.key本身的数据量过大:一个string类型的key,它的值为5MB
2.key中的成员数过多:一个ZSET类型的Key,它的成员数量为10000个
3.key中的成员数据量过大:一个Hash类型的Key,它的成员数量虽然只有1000个但是这些成员的Value值 总大小为100MB
# 推荐值
1.单个key的value小于10kb
2.对于集合类型的key,建议元素数量小于1000
# 查看值的整体存储空间的占用内存大小,得到结果单位为字节(对cpu的占用很大,不建议经常使用)
memory usage KEY
memory usage name
# 查看值所占字节
strlen KEY
Bigkey的危害
1.网络阻塞
对BigKey执行读请求时,少量的QPS就可能导致宽带使用率被占满,乃至所在物理机变慢
2.数据倾斜
BigKey所在的Redis实例内存使用率远超其他实例,无法使数据分片的内存资源达到均衡
3.Redis阻塞
对元素较多的hash,list,zset等做运算会耗时较久,使主线程被阻塞
4.CPU压力
对BigKey的数据序列化和发序列化会导致CPU的使用率飙升,影响Redis实例和本机其他应用
如何找到BigKey
# 命令
1.redis-cli --bigkeys
利用redis-cli提供的 --bigkeys参数,可以遍历分析所有key,并返回Key的整体统计信息与每个数据的Top1的bigkey,无法保证排名第二的是否是bigkey,所以此命令并不全面,
2.scan扫描
自己变成,利用scan命令扫描Redis中的所有key,利用strlen,hlen等命令判断key的长度(不建议使用memory usage)
scan 0 # 第一次扫描为0,默认扫描10个
scan 返回的游标 # 第二次扫描,当游标重新归零时,则表示扫描完成
# 可以通过循环判断每个key是否是bigkey
3.第三方工具(离线分析)
利用第三方工具,如:redis-rdb-tools 分析RDB快照文件,全面分析内存使用情况
4.网络监控
自定义工具,监控进出Redis的网络数据,超出预警值时主动告警
处理BigKey
# 如何删除BigKey?
bigkey内存占用较多,即使是删除这样的key 也需要耗费很长时间,导致Redis主线程阻塞,从而引发一系列问题
1.redis3.0以下版本
如果是集合类型,则用scan命令遍历BigKey的元素,先逐个删除子元素,然后删除BigKey,各个数据类型都有自己的scan方法,如HASH中的Hscan
2.redis4.0后
redis在4.0后提供了异步删除的命令:unlink
如何将BigKey重新构造
# 选择合适的数据类型存储
1.json字符串
#实现简单,但是耦合度太高,不够灵活,修改其中的某个字段,需要重新将整个json串覆盖
2.字段打散
例如:
login:user:1 中存了两个字段,一个name,一个age
可以打散成:
login:user:1:name
login:user:1:age
# 缺点,占用内存会增大,而且没办法统一控制,每个数据类型在存储的时候都会有很多源信息存储,
3.转成hash结构存储
login:user:1 name jack
age 21
# 底层会使用ziplist存储,空间占用小,并可以灵活访问对象的任意字段,缺点就是代码相对复杂,数据类型转换比较麻烦
案例
# 假如有一个hash类型的key,其中有100万对filed-v,filed是自增id,这个bigkey存在什么问题,如何优化呢?
#存在问题
内存占用较多,因为hash中的ziplist是有使用条件的,当hash中的entry(一组filed-value的键值对)数量超过500,会使用哈希表,而不再是ziplist,所有内存占用较多
当然entry上限是可以通过hash-max-ziplist-entries来配置的,但是如果entry过多就会导致BigKey问题(最好不要超过1000)
#修改entry数量
1.查询entry数量
config get hash-max-ziplist-entries
2.修改entry数量
config set hash-max-ziplist-entries 1000
# 优化方案
可以将bigkey分片存储,如1-100存储一个key,101-200存储一个key,可以将id/100作为key,将id % 100 作为field,这样每100个元素为一个hash
批处理的优化
一次性大批量的存储,而不是一次一次的存储
# string
mset
# hash
hmset
# set
sadd
但是redis提供的命令有局限性,例如:set往多个不同的key中存储的时候,无法做到,
Pipeline(单机)
# 可以通过管道来存储多条命令,然后发送到Redis服务端,由服务端一次性执行管道中的多条命令
# 在python中
import redis
sr = redis.StrictRedis.from_url('redis://192.168.124.49/1')
# 创建管道对象
pipe = sr.pipeline()
pipe.set("name", "dgw")
pipe.set("age", 27)
pipe.set("sex", "nan")
# 执行
ret = pipe.execute()
print(ret)
total = len([r for r in ret if r])
print(f"执行成功{total}条数据!")
集群模式的批处理
如MSET或Pipelin这样的批处理需要再一次请求中携带多条命令,而此时如果Redis是一个集群,那批处理命令的多个key必须落在一个插槽中,否则就会导致执行失败
最有效率的方案,即hash_tag 就是{xxx}这种,就是在集群情况下,插槽的分配是计算{}中的有效部分会分配到一个插槽,但是会导致某个插槽插入太多数据,导致数据倾斜,所以推荐并行slot
持久化配置
1.用来做缓存的Redis实例尽量不要开启持久化功能
2.建议关闭RDB持久化功能,使用AOF持久化
3.利用脚本定期在slave节点做RDB,实现数据备份
4.在使用AOF时,设置合理的rewrite阈值,避免频繁的bgrewrite
5.在配置no-appendfsync-on-rewrite=yes,禁止rewrite期间做AOF,避免因AOF引起的阻塞,但是在rewrite期间的这些数据无法保证安全,所以需要权衡
# 部署相关建议:
1.Redis实例的物理机要预留足够内存,应对fork和rewrite
2.单个Redis实例内存上限不要太大,例如4G或8G,可以加快fork的速度,减少主从同步,数据迁移的压力
3.不要和CPU密集型应用部署在一起,如ES数据库
4.不要和高硬盘负载应用一起部署,例如:数据库,消息队列等
慢查询
# 在redis执行时耗时超过某个阈值的命令,称为慢查询,不管是读写
慢查询的阈值可以通过配置指定:
slowlog-log-slower-than: 慢查询阈值,单位为微秒,默认是10000,建议1000 或更小
慢查询会被放入慢查询日志中,日志的长度有上限,可以通过配置指定:
slowlog-max-len: 慢查询日志(本质是一个队列)的长度,默认是128.建议1000
命令临时配置:config get slowlog-max-len
config set slowlog-max-len 1000
# 查看慢查询日志列表:
slowlog len:查询慢查询日志长度
slowlog get:读取n条慢查询日志
# 返回信息格式:
序列号(从0开始)
命令的时间戳
命令执行耗时
命令内容
客户端地址端口
客户端名称
slowlog reset:清空慢查询列表
命令及安全配置
redis如果绑在0.0.0.0:6379,这样将会就将redis服务暴露到公网上,而且Redis如果没有做身份认证,会出现严重的安全漏洞,
漏洞重新方式:https://cloud.tencent.com/developer/article/1039000
# 漏洞出现原因:
1.redis暴露到了公网
2.redis未设置密码
3.利用了Redis的config set 命令动态修改Redis配置
4.使用率root账号权限启动redis
为了避免以上漏洞,
1.redis一定要设置密码(密码一定要复杂)
2.禁止线上使用下面命令:keys,flushall,flushdb,config set等命令,可以利用rename-conmmand禁用
例如可以:
remame-command config ajsldfhioqtmlkasdg2154a65
#之后使用config 必须使用ajsldfhioqtmlkasdg2154a65来代替config命令
remame-command config "" # 表示永久禁用config命令
3.bind:限制网卡,禁止外网网卡访问
4.开启防火墙
5.不要使用Root账户启动Redis
6.尽量不使用默认的端口
内存配置
当redis内存不足时,可能导致Key频繁被删除,响应时间边长,QPS不稳定等问题,当内存使用率达到90%以上时就需要我们警惕,并快速定位到内存占用原因
# 内存碎片产生原因: 主要是redis的内存分配机制,比如10个字节的数据,redis中的内存分配原则为2,4,8,16,32 8字节不够存储就会分配16字节存储10字节的数据,这多出来的6字节称为内存碎片,当redis重启时,会自动回收这些内存碎片
# 主要是要看数据内存是否出现bigkey问题,内存碎片问题可以通过在保证redis正常使用的情况下,分批重启,另外一个关心的就是缓存区内存占用过大的问题
# redis提供了一些命令来查看redis目前的内存分配状态:
info memory
memory xxx
memory stats参数说明:
# peak.allcated:
redis进程自启动以来消耗内存的峰值
# total.allocated:
redis使用其分配器分配的总字节数,即当前的总内存使用量
# startup.allocated:
redis启动时消耗的初始内存量
# replication.backlog:
复制积压缓冲区的大小
# clients.slaves:
主从复制中所有从节点的读写缓冲区大小
# clients.normal:
除从节点外,所有其他客户端的读写缓冲区大小
# aof.buffer:
AOF持久化使用的缓存和AOF重写时产生的缓存
# db.0:
业务数据库
# overhead.hashtable.main:
当前数据库的hash链表开销内存总和,即元数据内存
# overhead.hashtable.expire:
用于存储key的过期时间所消耗的内存
# overhead.total:
数值=startup.allocated+replication.backlog+clients.slaves+clients.normal+aof.buffer+db.x
# keys.count
当前redis实例的key总数
# keys.bytes-per-key
当前redis的每个key的平均大小,计算公式(total.allocated - startup.allocated) / keys.count
# dataset.bytes
纯业务数据占用的内存大小
# dataset.percentage
纯业务数据所占的内存比(total.allocated - startup.allocated)
# peak.percentage
当前总内存与历史峰值的比例(total.allocated * 100 / peak.allcated)
# fragmentation
内存的碎片率
内存缓冲区配置
# 常见的有三种:
# 复制缓冲区:
主从复制的repl_backlog_buf,如果太小可能导致频繁的全量复制,影响性能,通过repl-backlog-size来设置,默认为1mb
# AOF缓冲区:
AOF刷盘之前的缓存区域,AOF执行rewrite的缓冲区,无法设置容量上限
# 客户端缓冲区:
分为输入缓冲区和输出缓冲区,输入缓冲区 最大1G且不能设置,输出缓冲区可以设置
client-output-buffer-limit <class> <hard limit><soft limit> <soft seconds>
# 参数说明
# class:客户端类型
normal:普通客户端,replica:主从复制客户端,pubsub:PubSub客户端
# hard limit:缓冲区上限,超过limit后断开客户端
# soft limit:缓冲区上限,在超过soft limit并且持续了soft seconds秒后断开客户端
# soft seconds:缓冲区满的持续时间
normal默认值是0 0 0 # 无上限
replica 256mb 64mb 60
pubsub 32mb 8mb 60
# 产生原因是普通客户端查询时bigkey或者redis物理机的网络带宽有限,很多bigkey来不及传输就被断开了导致很多数据遗留在缓冲区,解决bigkey的同时,找到对应业务并限流
info clients # 查看与redis建立连接的客户端占用情况
client list # 查看连接redis的所有客户端内存占用情况
集群还是主从,怎么选择
集群虽然具备高可用特征,能实现自动故障恢复,但是如果使用不当,也会出现一些问题:
# 集群完整性问题
在redis的默认配置中有一项配置:cluster-require-full-coverage yes 的配置,即发现任意一个插槽不可用,则整个集群会停止对外服务,为了保证高可用特性,这里建议改为no
# 集群带宽问题
集群的节点之间会不断地互相ping来确定集群中其他节点的状态,每次ping携带的信息至少包括插槽信息,和集群状态信息,集群中节点越多,集群状态信息数据量也越大,10个节点的相关信息可能达到1kb,此时每次集群互通需要的带宽会非常高,解决途径:
一:避免大集群,集群节点不要太多,最好少于1000,如果业务庞大,则建立多个集群
二:避免在单个物理机中运行太多Redis实例
三:配置合适的cluster-node-timeout值
# 数据倾斜问题
# 客户端性能能问题
# 命令的集群兼容性问题
# lua和事务问题
# 单体Redis(主从redis)已经能达到万级别的QPS,并且也具备很强的高可用特征,如果主从能满足业务的情况下,尽量不搭建Redis集群

浙公网安备 33010602011771号