redis 和 memcached 对比
- 存储方式 Memecache 把数据全部存在内存之中,断电后会挂掉,数据不能超过内存大小。 Redis 有部份存在硬盘上,redis 可以持久化其数据
- 数据支持类型 memcached 所有的值均是简单的字符串,redis 作为其替代者,支持更为丰富的数据类型 ,提供 list,set,zset,hash 等数据结构的存储
- 使用底层模型不同 它们之间底层实现方式 以及与客户端之间通信的应用协议不一样。 Redis 直接自己构建了 VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求。
- value 值大小不同:Redis 最大可以达到 512M;memcache 只有 1mb。
- redis 的速度比 memcached 快很多
- Redis 支持数据的备份,即 master-slave 模式的数据备份。
Redis下载安装
- 官网下载源码;
- 解压文件包 tar -xzf redis.tar.gz
- cd redis;make
- 安装成功(安装过程中可能出现如下错误)
解决方法:
# 查看gcc的版本是否在 5.3以上,centos7默认是4.8.5.我这里的就是4.8.5
gcc -v
# 解决方法
# 升级到 5.3及以上版本
yum -y install centos-release-scl
yum -y install devtoolset-9-gcc devtoolset-9-gcc-c++ devtoolset-9-binutils
scl enable devtoolset-9 bash
# 注意:scl命令启用只是临时的,推出xshell或者重启就会恢复到原来的gcc版本。
# 如果要长期生效的话,执行如下:
echo "source /opt/rh/devtoolset-9/enable" >>/etc/profile
gcc -v # 检查版本版本是否升级
# 重新 make
应用模块
指定配置文件启动应用
./bin/redis-server ./redis.conf
配置文件说明
daemonize no 是否以后台进程启动
databases 16 创建database的数量(默认选中的是database 0)
save 900 1 #刷新快照到硬盘中,必须满足两者要求才会触发,即900秒之后至少1个关键字发生变化。
save 300 10 #必须是300秒之后至少10个关键字发生变化。
save 60 10000 #必须是60秒之后至少10000个关键字发生变化。
stop-writes-on-bgsave-error yes #后台存储错误停止写。
rdbcompression yes #使用LZF压缩rdb文件。
rdbchecksum yes #存储和加载rdb文件时校验。
dbfilename dump.rdb #设置rdb文件名。
dir ./ #设置工作目录,rdb文件会写入该目录。
slaveof #设为某台机器的从服务器
masterauth #连接主服务器的密码
slave-serve-stale-data yes #当主从断开或正在复制中,从服务器是否应答
slave-read-only yes #从服务器只读
repl-ping-slave-period 10 #从ping主的时间间隔,秒为单位
repl-timeout 60 #主从超时时间(超时认为断线了),要比period大
slave-priority 100 #如果master不能再正常工作,那么会在多个slave中,选择优先值最小的一个slave提升为master,优先值为0表示不能提升为master。
repl-disable-tcp-nodelay no #主端是否合并数据,大块发送给slave
slave-priority 100 #从服务器的优先级,当主服挂了,会自动挑slave priority最小的为主服
requirepass foobared # 需要密码
rename-command CONFIG b840fc02d524045429941cc15f59e41cb7be6c52 #如果公共环境,可以重命名部分敏感命令 如config
maxclients 10000 #最大连接数
maxmemory #最大使用内存
maxmemory-policy volatile-lru #内存到极限后的处理
volatile-lru #LRU算法删除过期key
allkeys-lru #LRU算法删除key(不区分过不过期)
volatile-random #随机删除过期key
allkeys-random #随机删除key(不区分过不过期)
volatile-ttl #删除快过期的key
noeviction #不删除,返回错误信息
#解释 LRU ttl都是近似算法,可以选N个,再比较最适宜T踢出的数据
maxmemory-samples 3
appendonly no #是否仅要日志
appendfsync no #系统缓冲,统一写,速度快
appendfsync always #系统不缓冲,直接写,慢,丢失数据少
appendfsync everysec #折衷,每秒写1次
no-appendfsync-on-rewrite no #为yes,则其他线程的数据放内存里,合并写入(速度快,容易丢失的多)
auto-AOF-rewrite-percentage 100 #当前aof文件是上次重写是大N%时重写
auto-AOF-rewrite-min-size 64mb aof #重写至少要达到的大小
slowlog-log-slower-than 10000 #记录响应时间大于10000微秒的慢查询
slowlog-max-len 128 #最多记录128条
常用命令
lastsave #上一次保存的时间
slaveof host port #做host port的从服务器(数据清空,复制新主内容)
slaveof no one #变成主服务器(原数据不丢失,一般用于主服失败后)
flushdb #清空当前数据库的所有数据
flushall #清空所有数据库的所有数据(误用了怎么办?)
shutdown [save/nosave] #关闭服务器,保存数据,修改AOF(如果设置)
slowlog get #获取慢查询日志
slowlog len #获取慢查询日志条数
slowlog reset #清空慢查询
config get #选项(获取配置文件参数,支持*通配)
config set #选项 值
config rewrite #把值写到配置文件
config restart #更新info命令的信息
debug object key #调试选项,看一个key的情况
debug segfault #模拟段错误,让服务器崩溃
object key (refcount|encoding|idletime)
monitor #打开控制台,观察命令(调试用)
client list #列出所有连接
client kill #杀死某个连接 CLIENT KILL 127.0.0.1:43501
client getname #获取连接的名称 默认nil
client setname "名称" #设置连接名称,便于调试
====连接命令===
auth 密码 #密码登陆(如果有密码)
ping #测试服务器是否可用
echo "some content" #测试服务器是否正常交互
select 0/1/2... #选择数据库
quit #退出连接
命令 | 说明 | 备注 |
---|---|---|
del key1 key2 … Keyn | 删除1个或多个键 | 不存在的key忽略掉,返回真正删除的key的数量 |
rename key newkey | 给key赋一个新的key名 | 如果newkey已存在,则newkey的原值被覆盖 |
renamenx key newkey | 把key改名为newkey | 发生修改返回1, 未发生修改返回0 注: nx–> not exists, 即, newkey不存在时,作改名动作 |
move key db | 将当前数据库的 key 移动到给定的数据库 db 当中 | 移动成功返回 1 ,失败则返回 0 |
keys pattern | 查询符合 pattern 模式的 key | 返回符合该模式的 key |
randomkey | 从当前数据库中随机返回一个 key | 当数据库不为空时,返回一个 key 。 当数据库为空时,返回 nil |
exists key | 判断key是否存在,返回1/0 | |
type key | 返回key存储的值的类型 | |
ttl key | 查询key的生命周期 | |
expire key 整型值 | 设置key的生命周期,以秒为单位 | pexpire key 毫秒数, 设置生命周期 pttl key, 以毫秒返回生命周期 |
persist key | 把指定key置为永久有效 |
命令 | 说明 | 备注 |
---|---|---|
set key value [ex 秒数] / [px 毫秒数] [nx] /[xx] | 设置key有效期 | 如: set a 1 ex 10 , 10秒有效 Set a 1 px 9000 , 9秒有效 注: 如果ex,px同时写,以后面的有效期为准 如 set a 1 ex 100 px 9000, 实际有效期是9000毫秒 nx: 表示key不存在时,执行操作 xx: 表示key存在时,执行操作 |
mset key1 v1 key2 v2 | 一次性设置多个键值 | |
get key | 获取key的值 | |
mget key1 key2 …keyn | 获取多个key的值 | |
setrange key offset value | 把字符串的offset偏移字节,改成value | 如果偏移量>字符长度, 该字符自动补0x00 |
append key value | 把value追加到key的原值上 | |
getrange key start stop | 是获取字符串中 [start, stop]范围的值 | 注意: 对于字符串的下标,左数从0开始,右数从-1开始 1: start>=length, 则返回空字符串 2: stop>=length,则截取至字符结尾 3: 如果start 所处位置在stop右边, 返回空字符串 |
getset key newvalue | 获取并返回旧值,设置新值 | |
incr/decr key | 指定的key的值加/减1,并返回加1后的值 | 注意: 1:不存在的key当成0,再incr操作 2: 范围为64有符号 |
incrby/decrby key number | 增长/减少指定整数 | |
incrbyfloat key floatnumber | 增长指定浮点数 |
命 令 | 说 明 | 备 注 |
---|---|---|
lpush key node1 [node2.]… | 把节点 node1 加入到链表最左边 | 如果是 node1、node2 …noden 这样加入, 那么链表开头从左到右的顺序是 noden…node2、node1 |
rpush key node1[node2]… | 把节点 node1 加入到链表的最右边 | 如果是 node1、node2…noden 这样加 入,那么链表结尾从左到右的顺序是 node1、node2,node3…noden |
lindex key index | 读取下标为 index 的节点 | 返回节点字符串,从 0 开始算 |
llen key | 求链表的长度 | 返回链表节点数 |
lpop key | 删除左边第一个节点,并将其返回 | —— |
rpop key | 删除右边第一个节点,并将其返回 | —— |
linsert key before | after pivot node | 插入一个节点 node,并且可以指定在值为pivot 的节点的前面(before)或者后面(after)) |
lpushx list node | 如果存在 key 为 list 的链表,则插入节点 node, 并且作为从左到右的第一个节点 | 如果 list 不存在,则失败 |
rpushx list node | 如果存在 key 为 list 的链表,则插入节点 node,并且作为从左到右的最后个节点 | 如果 list 不存在,则失败 |
lrange list start end | 获取链表 list 从 start 下标到 end 下标的节点值 | 包含 start 和 end 下标的值 |
lrem list count value | 如果 count 为 0,则删除所有值等于 value 的节 点:如果 count 不是 0,则先对 count 取绝对值,假设记为 abs,然后从左到右删除不大于 abs 个等于 value 的节点 | 注意,count 为整数,如果是负数,则 Redis 会先求取其绝对值,然后传递到后台操作 |
lset key index node | 设置列表下标为 index 的节点的值为 node | —— |
ltrim key start stop | 修剪链表,只保留从 start 到 stop 的区间的节点,其余的都删除掉 | 包含 start 和 end 的下标的节点会保留 |
这些操作链表的命令都是进程不安全的,因为当我们操作这些命令的时候,其他 Redis 的客户端也可能操作同一个链表,这样就会造成并发数据安全和一致性的问题,尤其是当你操作一个数据量不小的链表结构时,常常会遇到这样的问题。
为了克服这些问题,Redis 提供了链表的阻塞命令,它们在运行的时候,会给链表加锁,以保证操作链表的命令安全性,如下图所示。
列表阻塞命令
命 令 | 说 明 | 备 注 |
---|---|---|
blpop key timeout | 移出并获取列表的第一个元索,如果列表没有元素会阻塞列表直到等待超时或发现可弹出元索为止 | 相对于 lpop 命令,它的操作是进程安全的 |
brpop key timeout | 移出并获取列表的最后一个元素,如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止 | 相对于 rpop 命令,它的操作是进程安全的 |
rpoplpush key sre dest | 按从左到右的顺序,将一个链表的最后一个元素移除,并插入到目标链表最左边 | 不能设置超时时间 |
brpoplpush key src dest timeout | 按从左到右的顺序,将一个链表的最后一个元素移除,并插入到目标链表最左边,并可以设置超时时间 | 可设置超时时间 |
哈希
Redis 中哈希结构就如同 Java 的 map 一样,一个对象里面有许多键值对,它是特别适合存储对象的,如果内存足够大,那么一个 Redis 的 hash 结构可以存储 2 的 32 次方减 1 个键值对(40 多亿)
Redis hash结构命令
命 令 | 说 明 | 备 注 |
---|---|---|
hdel key field1[field2…] | 删除 hash 结构中的某个(些)字段 | 可以进行多个字段的删除 |
hexists key field | 判断 hash 结构中是否存在 field 字段 | 存在返回 1,否则返回 0 |
hgetall key | 获取所有 hash 结构中的键值 | 返回键和值 |
hincrby key field increment | 指定给 hash 结构中的某一字段加上一个整数 | 要求该字段也是整数字符串 |
hincrbyfloat key field increment | 指定给 hash 结构中的某一字段加上一个浮点数 | 要求该字段是数字型字符串 |
hkeys key | 返回 hash 中所有的键 | —— |
hlen key | 返回 hash 中键值对的数量 | —— |
hmget key field1[field2…] | 返回 hash 中指定的键的值,可以是多个 | 依次返回值 |
hmset key field1 value1 [field2 field2…] | hash 结构设置多个键值对 | —— |
hset key filed value | 在 hash 结构中设置键值对 | 单个设值 |
hsetnx key field value | 当 hash 结构中不存在对应的键,才设置值 | —— |
hvals key | 获取 hash 结构中所有的值 | —— |
集合
- Redis 集合不是线性结构,而是一个哈希表结构,它的内部会根据 hash 分子来存储和查找数据。
- Redis 集合的插入、删除和查找的复杂度都是 0(1)。
- 对于集合而言,它的每一个元素都是不能重复的,当插入相同记录的时候都会失败。
- 集合是无序的。
- 集合的每一个元素都是 String 数据结构类型。
集合命令
命 令 | 说 明 | 备 注 |
---|---|---|
sadd key member1 [member2 member3…] | 给键为 key 的集合増加成员 | 可以同时増加多个 |
scard key | 统计键为 key 的集合成员数 | — |
sdiffkey1 [key2] | 找出两个集合的差集 | 参数如果是单key,那么 Redis 就返回这个 key 的所有元素 |
sdiftstore des key1 [key2] | 先按 sdiff 命令的规则,找出 key1 和 key2 两 个集合的差集,然后将其保存到 des 集合中。 | — |
sinter key1 [key2] | 求 key1 和 key2 两个集合的交集。 | 参数如果是单 key,那么 Redis 就返冋这个 key 的所有元素 |
sinterstore des key1 key2 | 先按 sinter 命令的规则,找出 key1 和 key2 两个集合的交集,然后保存到 des 中 | — |
sismember key member | 判断 member 是否键为 key 的集合的成员 | 如果是返回 1,否则返回 0 |
smembers key | 返回集合所有成员 | 如果数据量大,需要考虑迭代遍历的问题 |
smove src des member | 将成员 member 从集合 src 迁移到集合 des 中 | — |
spop key | 随机弹出集合的一个元素 | 注意其随机性,因为集合是无序的 |
srandmember key [count] | 随机返回集合中一个或者多个元素,count 为限制返回总数,如果 count 为负数,则先求其绝对值 | count 为整数,如果不填默认为 1,如果 count 大于等于集合总数,则返回整个集合 |
srem key member1[member2…] | 移除集合中的元素,可以是多个元素 | 对于很大的集合可以通过它删除部分元素,避免删除大量数据引发 Redis 停顿 |
sunion key1 [key2] | 求两个集合的并集 | 参数如果是单 key,那么 Redis 就返回这个 key 的所有元素 |
sunionstore des key1 key2 | 先执行 sunion 命令求出并集,然后保存到键为 des 的集合中 | — |
有序集合
有序集合和集合类似,只是说它是有序的,和无序集合的主要区别在于每一个元素除了值之外,它还会多一个分数。分数是一个浮点数,在 Java 中是使用双精度表示的,根据分数,Redis 就可以支持对分数从小到大或者从大到小的排序。
有序集合命令
命 令 | 说 明 | 备 注 |
---|---|---|
zadd key score1 value1 [score2 value2…] | 向有序集合的 key,增加一个或者多个成员 | 如果不存在对应的 key,则创建键为 key 的有序集合 |
zcard key | 获取有序集合的成员数 | — |
zcount key min max | 根据分数返回对应的成员列表 | min 为最小值,max 为最大值,默认为包含 min 和 max 值,采用数学区间表示的方法,如果需要不包含,则在分数前面加入“(”,注意不支持“[”表示 |
zincrby key increment member | 给有序集合成员值为 member 的分数增加 increment | — |
zinterstore desKey numkeys key1 [key2 key3…] | 求多个有序集合的交集,并且将结果保存到 desKey 中 | numkeys 是一个整数,表示多少个有序集合 |
zlexcount key min max | 求有序集合 key 成员值在 min 和 max 的范围 | 这里范围为 key 的成员值,Redis 借助数据区间的表示方法,“[”表示包含该值,“(”表示不包含该值 |
zrange key start stop [withscores] | 按照分值的大小(从小到大)返回成员,加入 start 和 stop 参数可以截取某一段返回。如果输入可选项 withscores,则连同分数一起返回 | 这里记集合最人长度为 len,则 Redis 会将集合排序后,形成一个从 0 到 len-1 的下标,然后根据 start 和 stop 控制的下标(包含 start 和 stop)返回 |
zrank key member | 按从小到大求有序集合的排行 | 排名第一的为 0,第二的为 1…… |
zrangebylex key min max [limit offset count] | 根据值的大小,从小到大排序,min 为最小值,max 为最大值;limit 选项可选,当 Redis 求出范围集合后,会生产下标 0 到 n,然后根据偏移量 offset 和限定返回数 count,返回对应的成员 | 这里范围为 key 的成员值,Redis 借助数学区间的表示方法,“[”表示包含该值,“(”表示不包含该值 |
zrangebyscore key min max [withscores] [limit offset count] | 根据分数大小,从小到大求取范围,选项 withscores 和 limit 请参考 zrange 命令和 zrangebylex 说明 | 根据分析求取集合的范围。这里默认包含 min 和 max,如果不想包含,则在参数前加入“(”, 注意不支持“[”表示 |
zremrangebyscore key start stop | 根据分数区间进行删除 | 按照 socre 进行排序,然后排除 0 到 len-1 的下标,然后根据 start 和 stop 进行删除,Redis 借助数学区间的表示方法,“[”表示包含该值,“(” 表示不包含该值 |
zremrangebyrank key start stop | 按照分数排行从小到大的排序删除,从 0 开始计算 | — |
zremrangebylex key min max | 按照值的分布进行删除 | — |
zrevrange key start stop [withscores] | 从大到小的按分数排序,参数请参见 zrange | 与 zrange 相同,只是排序是从大到小 |
zrevrangebyscore key max min [withscores] | 从大到小的按分数排序,参数请参见 zrangebyscore | 与 zrangebyscore 相同,只是排序是从大到小 |
zrevrank key member | 按从大到小的顺序,求元素的排行 | 排名第一位 0,第二位 1… |
zscore key member | 返回成员的分数值 | 返回成员的分数 |
zunionstore desKey numKeys key1 [key2 key3 key4…] | 求多个有序集合的并集,其中 numKeys 是有序集合的个数 | —— |
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ETnGKozn-1590766454019)(redis 学习笔记.assets/image-20200529173614761.png)]
在 Redis 中,也存在多个客户端同时向 Redis 系统发送命令的并发可能性,因此同一个数据,可能在不同的时刻被不同的线程所操纵,这样就出现了并发下的数据一致的问题。为了保证异性数据的安全性,Redis 为提供了事务方案。而 Redis 的事务是使用 MULTI-EXEC 的命令组合,使用它可以提供两个重要的保证:
- 事务是一个被隔离的操作,事务中的方法都会被 Redis 进行序列化并按顺序执行,事务在执行的过程中不会被其他客户端发生的命令所打断。
- 事务是一个原子性的操作,它要么全部执行,要么就什么都不执行。
执行过程:
开启事务-》命令进入队列-》执行事务。
事务命令
命 令 | 说 明 | 备 注 |
---|---|---|
multi | 开启事务命令,之后的命令就进入队列,而不会马上被执行 | 在事务生存期间,所有的 Redis 关于数据结构的命令都会入队 |
watch key1 [key2…] | 监听某些键,当被监听的键在事务执行前被修改,则事务会被回滚 | 使用乐观锁 |
unwatch key1 [key2…] | 取消监听某些键 | —— |
exec | 执行事务,如果被监听的键没有被修改,则采用执行命令,否则就回滚命令 | 在执行事务队列存储的命令前,Redis 会检测被监听的键值对有没有发生变化,如果没有则执行命令, 否则就回滚事务 |
discard | 回滚事务 | 回滚进入队列的事务命令,之后就不能再用 exec 命令提交了 |
注意:通过上面两个例子,可以看出在执行事务命令的时候,在命令入队的时候,Redis 就会检测事务的命令是否正确,如果不正确则会产生错误。无论之前和之后的命令都会被事务所回滚,就变为什么都没有执行。当命令格式正确,而因为操作数据结构引起的错误,则该命令执行出现错误,而其之前和之后的命令都会被正常执行。这点和数据库很不一样,这是需要读者注意的地方
监控事务(watch)
当 Redis 使用 exec 命令执行事务的时候,它首先会去比对被 watch 命令所监控的键值对,如果没有发生变化,那么它会执行事务队列中的命令,提交事务;如果发生变化,那么它不会执行任何事务中的命令,而去事务回滚。无论事务是否回滚,Redis 都会去取消执行事务前的 watch 命令,这个过程如图 1 所示
ABA 问题
使用流水线(pipelined)提高 Redis 的命令性能
Redis 发布订阅模式
#客户端1订阅消息
SUBSCRIBE chat
#客户端2发布消息
publish chat "hello redis"
Redis的超时命令和垃圾回收策略
- 定时回收是指在确定的某个时间触发一段代码,回收超时的键值对。
- 惰性回收则是当一个超时的键,被再次用 get 命令访问时,将触发 Redis 将其从内存中清空。
定时回收可以完全回收那些超时的键值对,但是缺点也很明显,如果这些键值对比较多,则 Redis 需要运行较长的时间,从而导致停顿。所以系统设计者一般会选择在没有业务发生的时刻触发 Redis 的定时回收,以便清理超时的键值对。
对于惰性回收而言,它的优势是可以指定回收超时的键值对,它的缺点是要执行一个莫名其妙的 get 操作,或者在某些时候,我们也难以判断哪些键值对已经超时
数据持久化备份方式
- 快照恢复(RDB),通过快照(snapshotting)实现的,它是备份当前瞬间 Redis 在内存中的数据记录。
- 追加文件(Append-Only File,AOF),其作用就是当 Redis 执行写命令后,在一定的条件下将执行过的写命令依次保存在 Redis 的文件中,将来就可以依次执行那些保存的命令恢复 Redis 的数据了。
- Redis 中允许使用其中一种、两种或者两种都不用。
- 对于快照备份而言,如果当前 Redis 的数据量大,备份可能造成 Redis 卡顿,但是恢复重启是比较快速的。
- 对于 AOF 备份而言,它只是追加写入命令,所以备份一般不会造成 Redis 卡顿,但是恢复重启要执行更多的命令,备份文件可能也很大,使用者使用的时候要注意。
RDB 备份
命令 | 备注 |
---|---|
save 900 1 | 当 900 秒执行 1 个写命令时,启用快照备份(其他类似) |
stop-writes-on-bgsave-error yes | 执行 save 命令的时候,将禁止写入命令 |
rdbcompression yes | 使用LZF压缩算法压缩文件 |
dbfilename dump.rdb | rdb数据文件 |
AOF 备份
命令 | 备注 |
---|---|
appendonly no | 开启 AOF 持久化策略 |
appendfilename “appendonly.aof” | AOF 持久化写入文件 |
appendfsync everysec | 同步频率 |
no-appendfsync-on-rewrite no | 是否在后台 AOF 文件 rewrite(重写)期间调用 fsync |
auto-aof-rewrite-percentage 100 | 指定 Redis 重写 AOF 文件的条件,默认为 100 |
auto-aof-rewrite-min-size 64mb | 指定触发 rewrite 的AOF文件大小 |
aof-load-truncated yes | 恢复时会忽略最后一条可能存在问题的指令 |
表 1 maxmemory-policy 说明
名称 | 说明 |
---|---|
volatile-lru | 采用==最近使用最少的淘汰策略,Redis 将回收那些超时的(仅仅是超时的)键值对==,也就是它只淘汰那些超时的键值对。 |
allkeys-lru | 采用淘汰==最少使用的策略,Redis 将对所有的(不仅仅是超时的)键值对==采用最近使用最少的淘汰策略。 |
volatile-random | 采用==随机淘汰策略删除超时的(仅仅是超时的)键值对==。 |
allkeys-random | 采用==随机淘汰策略删除所有的(不仅仅是超时的)键值对==,这个策略不常用。 |
volatile-ttl | 采用删除存活时间最短的键值对策略。 |
noeviction | 根本就不淘汰任何键值对,当内存已满时,如果做读操作,例如 get 命令,它将正常工作,而做写操作,它将返回错误。也就是说,当 Redis 采用这个策略内存达到最大的时候,它就==只能读而不能写==了。 |
注:Redis 在默认情况下会采用 noeviction 策略
主从同步基础概念
- 在多台数据服务器中,只有一台主服务器,而主服务器只负责写入数据,不负责让外部程序读取数据。
- 存在多台从服务器,从服务器不写入数据,只负责同步主服务器的数据,并让外部程序读取数据。
- 主服务器在写入数据后,即刻将写入数据的命令发送给从服务器,从而使得主从数据同步。
- 应用程序可以随机读取某一台从服务器的数据,这样就分摊了读数据的压力。
- 当从服务器不能工作的时候,整个系统将不受影响;当主服务器不能工作的时候,可以方便地从从服务器中选举一台来当主服务器
Redis 主从同步配置(不推荐)
# 主节点配置参数
dir # 文件路径
filename # 同步的文件名称
# 从节点配置参数
replicaof masterIp port
# 当不想让从节点赋值主节点的数据时,从节点客户端执行 slave no one 命令即可
# 当主节点故障,执行 slaveof server of 可以让从节点复制另外一台主机的数据
# redis.conf 中还有一个 bind 的配置,默认为 127.0.0.1,也就是只允许本机访问,把它修改为 bind 0.0.0.0,其他的服务器就能够访问了
使用哨兵模式,自动监视 Master 节点,当前挂掉后,自动将 Slaver 节点变为 Master 节点(推荐)
定义:
悲观锁(Pessimistic Lock):
每次获取数据的时候,都会担心数据被修改,所以每次获取数据的时候都会进行加锁,确保在自己使用的过程中数据不会被别人修改,使用完成后进行数据解锁。由于数据进行加锁,期间对该数据进行读写的其他线程都会进行等待。
乐观锁(Optimistic Lock):
每次获取数据的时候,都不会担心数据被修改,所以每次获取数据的时候都不会进行加锁,但是在更新数据的时候需要判断该数据是否被别人修改过。如果数据被其他线程修改,则不进行数据更新,如果数据没有被其他线程修改,则进行数据更新。由于数据没有进行加锁,期间该数据可以被其他线程进行读写操作。
适用场景:
悲观锁:比较适合写入操作比较频繁的场景,如果出现大量的读取操作,每次读取的时候都会进行加锁,这样会增加大量的锁的开销,降低了系统的吞吐量。
乐观锁:比较适合读取操作比较频繁的场景,如果出现大量的写入操作,数据发生冲突的可能性就会增大,为了保证数据的一致性,应用层需要不断的重新获取数据,这样会增加大量的查询操作,降低了系统的吞吐量。
总结:两种所各有优缺点,读取频繁使用乐观锁,写入频繁使用悲观锁。
像乐观锁适用于写比较少的情况下,即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果经常产生冲突,上层应用会不断的进行retry,这样反倒是降低了性能,所以这种情况下用悲观锁就比较合适,之所以用悲观锁就是因为两个用户更新同一条数据的概率高,也就是冲突比较严重的情况下,所以才用悲观锁.
悲观锁比较适合强一致性的场景,但效率比较低,特别是读的并发低。乐观锁则适用于读多写少,并发冲突少的场景。
分布式锁
很久之前有讲过并发编程中的锁并发编程的锁机制:synchronized和lock。在单进程的系统中,当存在多个线程可以同时改变某个变量时,就需要对变量或代码块做同步,使其在修改这种变量时能够线性执行消除并发修改变量。而同步的本质是通过锁来实现的。为了实现多个线程在一个时刻同一个代码块只能有一个线程可执行,那么需要在某个地方做个标记,这个标记必须每个线程都能看到,当标记不存在时可以设置该标记,其余后续线程发现已经有标记了则等待拥有标记的线程结束同步代码块取消标记后再去尝试设置标记。
分布式环境下,数据一致性问题一直是一个比较重要的话题,而又不同于单进程的情况。分布式与单机情况下最大的不同在于其不是多线程而是多进程。多线程由于可以共享堆内存,因此可以简单的采取内存作为标记存储位置。而进程之间甚至可能都不在同一台物理机上,因此需要将标记存储在一个所有进程都能看到的地方。
常见的是秒杀场景,订单服务部署了多个实例。如秒杀商品有4个,第一个用户购买3个,第二个用户购买2个,理想状态下第一个用户能购买成功,第二个用户提示购买失败,反之亦可。而实际可能出现的情况是,两个用户都得到库存为4,第一个用户买到了3个,更新库存之前,第二个用户下了2个商品的订单,更新库存为2,导致出错。
在上面的场景中,商品的库存是共享变量,面对高并发情形,需要保证对资源的访问互斥。在单机环境中,Java中其实提供了很多并发处理相关的API,但是这些API在分布式场景中就无能为力了。也就是说单纯的Java Api并不能提供分布式锁的能力。分布式系统中,由于分布式系统的分布性,即多线程和多进程并且分布在不同机器中,synchronized和lock这两种锁将失去原有锁的效果,需要我们自己实现分布式锁。
常见的锁方案如下:
- 基于数据库实现分布式锁
- 基于缓存,实现分布式锁,如redis
- 基于Zookeeper实现分布式锁
下面我们简单介绍下这几种锁的实现。
基于数据库
基于数据库的锁实现也有两种方式,一是基于数据库表,另一种是基于数据库排他锁。
基于数据库表的增删
基于数据库表增删是最简单的方式,首先创建一张锁的表主要包含下列字段:方法名,时间戳等字段。
具体使用的方法,当需要锁住某个方法时,往该表中插入一条相关的记录。这边需要注意,方法名是有唯一性约束的,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。
执行完毕,需要delete该记录。
当然,笔者这边只是简单介绍一下。对于上述方案可以进行优化,如应用主从数据库,数据之间双向同步。一旦挂掉快速切换到备库上;做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍;使用while循环,直到insert成功再返回成功,虽然并不推荐这样做;还可以记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了,实现可重入锁。
基于数据库排他锁
我们还可以通过数据库的排他锁来实现分布式锁。基于MySql的InnoDB引擎,可以使用以下方法来实现加锁操作:
public void lock(){
connection.setAutoCommit(false)
int count = 0;
while(count < 4){
try{
select * from lock where lock_name=xxx for update;
if(结果不为空){
//代表获取到锁
return;
}
}catch(Exception e){
}
//为空或者抛异常的话都表示没有获取到锁
sleep(1000);
count++;
}
throw new LockException();
}
在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。其他没有获取到锁的就会阻塞在上述select语句上,可能的结果有2种,在超时之前获取到了锁,在超时之前仍未获取到锁。
获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,释放锁connection.commit()
。
存在的问题主要是性能不高和sql超时的异常。
基于数据库锁的优缺点
上面两种方式都是依赖数据库的一张表,一种是通过表中的记录的存在情况确定当前是否有锁存在,另外一种是通过数据库的排他锁来实现分布式锁。
- 优点是直接借助数据库,简单容易理解。
- 缺点是操作数据库需要一定的开销,性能问题需要考虑。
基于缓存
相对于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点,存取速度快很多。而且很多缓存是可以集群部署的,可以解决单点问题。基于缓存的锁有好几种,如memcached、redis、本文下面主要讲解基于redis的分布式实现。
基于redis的分布式锁实现
SETNX
使用redis的SETNX实现分布式锁,多个进程执行以下Redis命令:
SETNX lock.id
SETNX是将 key 的值设为 value,当且仅当 key 不存在。若给定的 key 已经存在,则 SETNX 不做任何动作。
- 返回1,说明该进程获得锁,SETNX将键 lock.id 的值设置为锁的超时时间,当前时间 +加上锁的有效时间。
- 返回0,说明其他进程已经获得了锁,进程不能进入临界区。进程可以在一个循环中不断地尝试 SETNX 操作,以获得锁。
存在死锁的问题
SETNX实现分布式锁,可能会存在死锁的情况。与单机模式下的锁相比,分布式环境下不仅需要保证进程可见,还需要考虑进程与锁之间的网络问题。某个线程获取了锁之后,断开了与Redis 的连接,锁没有及时释放,竞争该锁的其他线程都会hung,产生死锁的情况。
在使用 SETNX 获得锁时,我们将键 lock.id 的值设置为锁的有效时间,线程获得锁后,其他线程还会不断的检测锁是否已超时,如果超时,等待的线程也将有机会获得锁。然而,锁超时,我们不能简单地使用 DEL 命令删除键 lock.id 以释放锁。
考虑以下情况:
- A已经首先获得了锁 lock.id,然后线A断线。B,C都在等待竞争该锁;
- B,C读取lock.id的值,比较当前时间和键 lock.id 的值来判断是否超时,发现超时;
- B执行 DEL lock.id命令,并执行 SETNX lock.id 命令,并返回1,B获得锁;
- C由于各刚刚检测到锁已超时,执行 DEL lock.id命令,将B刚刚设置的键 lock.id 删除,执行 SETNX lock.id命令,并返回1,即C获得锁。
上面的步骤很明显出现了问题,导致B,C同时获取了锁。在检测到锁超时后,线程不能直接简单地执行 DEL 删除键的操作以获得锁。
对于上面的步骤进行改进,问题是出在删除键的操作上面,那么获取锁之后应该怎么改进呢?
首先看一下redis的GETSET这个操作,GETSET key value
,将给定 key 的值设为 value ,并返回 key 的旧值(old value)。利用这个操作指令,我们改进一下上述的步骤。
- A已经首先获得了锁 lock.id,然后线A断线。B,C都在等待竞争该锁;
- B,C读取lock.id的值,比较当前时间和键 lock.id 的值来判断是否超时,发现超时;
- B检测到锁已超时,即当前的时间大于键 lock.id 的值,B会执行
GETSET lock.id
设置时间戳,通过比较键 lock.id 的旧值是否小于当前时间,判断进程是否已获得锁;- B发现GETSET返回的值小于当前时间,则执行 DEL lock.id命令,并执行 SETNX lock.id 命令,并返回1,B获得锁;
- C执行GETSET得到的时间大于当前时间,则继续等待。
在线程释放锁,即执行 DEL lock.id 操作前,需要先判断锁是否已超时。如果锁已超时,那么锁可能已由其他线程获得,这时直接执行 DEL lock.id 操作会导致把其他线程已获得的锁释放掉。
一种实现方式
获取锁
public boolean lock(long acquireTimeout, TimeUnit timeUnit) throws InterruptedException {
acquireTimeout = timeUnit.toMillis(acquireTimeout);
long acquireTime = acquireTimeout + System.currentTimeMillis();
//使用J.U.C的ReentrantLock
threadLock.tryLock(acquireTimeout, timeUnit);
try {
//循环尝试
while (true) {
//调用tryLock
boolean hasLock = tryLock();
if (hasLock) {
//获取锁成功
return true;
} else if (acquireTime < System.currentTimeMillis()) {
break;
}
Thread.sleep(sleepTime);
}
} finally {
if (threadLock.isHeldByCurrentThread()) {
threadLock.unlock();
}
}
return false;
}
public boolean tryLock() {
long currentTime = System.currentTimeMillis();
String expires = String.valueOf(timeout + currentTime);
//设置互斥量
if (redisHelper.setNx(mutex, expires) > 0) {
//获取锁,设置超时时间
setLockStatus(expires);
return true;
} else {
String currentLockTime = redisUtil.get(mutex);
//检查锁是否超时
if (Objects.nonNull(currentLockTime) && Long.parseLong(currentLockTime) < currentTime) {
//获取旧的锁时间并设置互斥量
String oldLockTime = redisHelper.getSet(mutex, expires);
//旧值与当前时间比较
if (Objects.nonNull(oldLockTime) && Objects.equals(oldLockTime, currentLockTime)) {
//获取锁,设置超时时间
setLockStatus(expires);
return true;
}
}
return false;
}
}
lock调用tryLock方法,参数为获取的超时时间与单位,线程在超时时间内,获取锁操作将自旋在那里,直到该自旋锁的保持者释放了锁。
tryLock方法中,主要逻辑如下:
- setnx(lockkey, 当前时间+过期超时时间) ,如果返回1,则获取锁成功;如果返回0则没有获取到锁
- get(lockkey)获取值oldExpireTime ,并将这个value值与当前的系统时间进行比较,如果小于当前系统时间,则认为这个锁已经超时,可以允许别的请求重新获取
- 计算newExpireTime=当前时间+过期超时时间,然后getset(lockkey, newExpireTime) 会返回当前lockkey的值currentExpireTime
- 判断currentExpireTime与oldExpireTime 是否相等,如果相等,说明当前getset设置成功,获取到了锁。如果不相等,说明这个锁又被别的请求获取走了,那么当前请求可以直接返回失败,或者继续重试
释放锁
public boolean unlock() {
//只有锁的持有线程才能解锁
if (lockHolder == Thread.currentThread()) {
//判断锁是否超时,没有超时才将互斥量删除
if (lockExpiresTime > System.currentTimeMillis()) {
redisHelper.del(mutex);
logger.info("删除互斥量[{}]", mutex);
}
lockHolder = null;
logger.info("释放[{}]锁成功", mutex);
return true;
} else {
throw new IllegalMonitorStateException("没有获取到锁的线程无法执行解锁操作");
}
}
在上面获取锁的实现下,其实此处的释放锁函数可以不需要了,有兴趣的读者可以结合上面的代码看下为什么?有想法可以留言哦!
总结
本文主要讲解了基于redis分布式锁的实现,在分布式环境下,数据一致性问题一直是一个比较重要的话题,而 synchronize 和 lock 锁在分布式环境已经失去了作用。常见的锁的方案有基于数据库实现分布式锁、基于缓存实现分布式锁、基于Zookeeper实现分布式锁,简单介绍了每种锁的实现特点;然后,文中探索了一下redis锁的实现方案;最后,本文给出了基于Java实现的redis分布式锁,读者可以自行验证一下。
参考
分布式锁的一点理解
分布式锁1 Java常用技术方案
分布式锁的几种实现方式