redis作为nosql数据库跟sql数据库有什么区别呢?
SQL | NoSQL | |
数据结构 | 结构化 | 非结构化 |
数据关联 | 关联 | 无关联 |
查询方式 | sql查询 | 非sql |
事务特征 | ACID | BASE |
存储方式 | 磁盘 | 内存 |
扩展性 | 垂直 | 水平 |
使用场景 | 1.数据结构固定 2.对数据安全性要求高 | 1.数据结构不固定 2.对数据安全性要求低 |
其中 非结构 :
键值类型
文档类型
列类型
Graph类型
特征:
键值 (key-value)型,value支持多种不同数据结构,功能丰富
单线程,每个命令具备原子性
低延迟,速度快(基于内存、IO多路复用、良好的编码)
支持数据持久化
支持主从集群、分片集群
支持多语言客户端
# 启动
systemctl start redis
# 停止
systemctl stop redis
# 重启
systemctl restart redis
# 查看状态
systemctl status redis
# 进入 Redis
redis-cli -p 6379
数据结构
Redis是一个key-value的数据库,key一般是String类型,不过value的类型多种多样
String | hello word |
Hash | {name : "jack" , age : 20} |
List | [a -> b->c->c] |
Set | {a , b , c} |
SortedSet | {a : 1 , b : 2, c : 3} |
GEO | {a : (120.3 , 30.5)} |
BitMap | 0110110101110101011 |
HyperLog | 0110110101110101011 |
前五种是基本类型,下面三种是特殊类型
通用命令
可以通过help [command] 查看一个命令的具体用法
keys 查看符合模板的所有key
del 根据key删除,可以指定多个key
exists 判断key是否存在
expire 给一个key设置有效期,有效期到期时该key会被自动删除,单位是秒
-1 永久有效
-2 过期
ttl 查看一个key的有效期
info 查看redis内存
info memory
eval 调用lua脚本
EVAL "return redis.call('set' , 'name' , 'jack')" 0
0 表示参数数量
set添加记录的时候,默认有效期是永久,
Stirng类型
String的常见命令
set 添加或者修改已经存在的一个String类型的键值对
get 根据key获取String类型的value
mset 批量添加多个String类型的键值对
mget 根据多个key获取多个String类型的value
incr 让一个整型的key自增1
incr num
incrby 让一个整型的key自增并指定步长
例如: 让num值自增2
incrby num 2
incrbyfloat 让一个浮点型数字自增并指定步长
incrbyfloat f 1
setnx 添加一个String类型的键值对,前提是这个key不存在,否则不执行
setnx num2 2 相同的效果: set k2 x nx
添加一个字符串并且设置ttl
set lock thread1 nx ex 10
setex 添加一个String类型的键值对,并且指定有效期
例如:添加name:j 有效期10s
setex name 10 j 相同的效果: set name j ex 10
一个问题
mysql当中可以分表,在user表中id=1的数据为name=user,在product表中id=1的数据为name=product
那在redis中,只有key value的形式,那id=1的数据怎么表示?
Redis的key允许有多个单词形成层级结构,多个单词之间用 ' : ‘ 隔开,格式如下
项目名:业务名:类型:id
那就应该是:
user相关的key:wzw:user:1
product相关的key:wzw:product:1
也可以存储java对象,通过json字符串存储
set wzw:userL:1 '{"id":1, "name":"Jack", "age": 21}'
set wzw:user:2 '{"id":2, "name":"Rose", "age": 18}'
set wzw:product:1 '{"id":1, "name":"小米11", "price": 4999}'
set wzw:product:2 '{"id":2, "name":"荣耀6", "price": 2999}'
Hash类型
Hash类型,也叫散列,其value是一个无序字典,类似于Java中的HashMap结构
为什么会出现Hash类型的数据类型?
String结构是将对象序列化为JSON字符串后存储,当需要修改对象某个字段时很不方便
Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD
Hash的常见命令
hset key field value : 添加或者修改hash类型key的field的值
hset wzw:user:3 name lucy hset wzw:user:3 age 17
hgetkey field :获取一个hash类型key的field的值
hmset :批量添加多个hash类型key的field的值
hmset wzw:user:3 name jazz age 3
hmget :批量获取多个hash类型key的field的值
hmget wzw:user:3 name age
hgetall :获取一个hash类型的key中的所有的field和value
hgetall wzw:user:3
1) "name"
2) "lucy"
3) "age"
4) "17"
hkeys :获取一个hash类型的key中的所有的field
hkeys wzw:user:3
1) "name"
2) "age"
hvals :获取一个hash类型的key中的所有的value
hvals wzw:user:3
1) "lucy"
2) "17"
hincrby:让一个hash类型key的字段值自增并指定步长
hincrby wzw:user:3 age 2
"19"
hsetnx :添加一个hash类型的key的field值,前提是这个field不存在,否则不执行
hsetnx wzw:user:3 sex man
List类型
Redis中的List类型与Java中的LinkedList类似,可以看做是一个双向链表结构。既可以支持正向检索和也可以支持反向检索。
特征:
有序
元素可以重复
插入和删除快
查询速度一般
List的常见命令
lpush key element... :向列表左侧插入一个或多个元素
lpush wzw:product:3 0 1 2
lpop key :移除并返回列表左侧的第一个元素,没有则返回nil
rpush key element ... :向列表右侧插入一个或多个元素
rpop key :移除并返回列表右侧的第一个元素
lrange key starend : 返回一段角标范围内的所有元素
lrange wzw:product:3 1 2
0 1
blpop和brpop :与LPOP和RPOP类似,只不过在没有元素时等待指定时间,而不是直接返回null
centos7-192.168.163.129:0>brpop L1 20
1) "L1"
2) "xx"
Set类型
Redis的Set结构与Java中的HashSet类似,可以看做是一个value为null的HashMap。因为也是一个hash表,因此具备与HashSet类似的特征:
无序
元素不可重复
查找快
支持交集、并集、差集等功能
Set的常见命令
sadd key member... :向set中添加一个或多个元素
srem key member. :移除set中的指定元素
scard key : 返set中元素的个数
sismember key member :判断一个元素是否存在于set中
smembers key : 获取set中所有元素
sinter key1 key2 : 求key1于key2的交集
sdiff key1 key2... : 求ey1与key2的差集
sunion key1 key2.. :求key1和key2的并集
SortedSet类型
Redis的SortedSet是一个可排序的set集合,与lava中的TreeSet有些类似,但底层数据结构却差别很大。
SortedSet中的每一个元素都带有一个score属性,可以基于score属性对元素排序,底层的实现是一个跳表
(SkipList)加 hash表SortedSet具备下列特性:
可排序
元素不重复
查询速度快
因为SortedSet的可排序特性,经常被用来实现排行榜这样的功能
SortedSet的常见命令
**zadd key score member ** : 添加一个或多个元素到sorted set ,如果已经存在则更新其score值
添加一个学生得分 85
zadd students 85 jack
zrem key member :删除sorted set中的一个指定元素
zscore key member :获取sorted set中的指定元素的score值
zrank key member :获取sorted set 中的指定元素的排名
zcard key : 获取sorted set中的元素个数
zcoun key min max :统计score值在给定范围内的所有元素的个数
zincrby key increment member : 让sorted set中的指定元素自增,步长为指定的increment值
zrange key min max :按照score排序后,获取指定排名范围内的元素
zrangebyscore key min max : 按照score排序后,获取指定score范围内的元素
zadd demo 1 k1 2 k2 3 k3 4 k4
zrevrangebyscore demo 1000 0 withscores limit 0 3
1) "k6"
2) "6"
3) "k5"
4) "5"
5) "k4"
6) "4"
zrevrangebyscore demo 4 0 withscores limit 1 3
1) "k3"
2) "3"
3) "k2"
4) "2"
5) "k1"
6) "1"
其中:按降序排序查找demo,最大值是1000,最小值是0,携带sorce,显示3个, 0代表忽略前一个结果,1代表顺后一位,上一次查到的是4,下一次查询的时候,最大值就是4,且忽略一个4,如果有两个4,就忽略2个4
zdiff、zinter、zunion :求差集、交集、并集
所有的排名默认都是升序,如果要降序则在命令的Z后面添加REV即可
案例
Jack 85, Lucy 89, Rose 82, Tom 95,Jerry 78 Amy 92, Miles 76
删除Tom同学
zrem students tom
获取Amy同学的分数
zscore students amy
获取Rose同学的排名
zrevrank students rose
查询80分以下有几个学生
zcount students 0 80
给Amy同学加2分
zincrby students 2 amy
查出成绩前3名的同学
zrevrange students 0 2
查出成绩80分以下的所有同学
zrangebyscore students 0 80
GEO数据结构
GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:
GEOADD: 添加一个地理空间信息,包含: 经度 (longitude)、纬度 (latitude)、值 (member)
GEODIST:计算指定的两个点之间的距离并返回
GEOHASH:将指定member的坐标转为hash字符串形式并返回
GEOPOS:返回指定member的坐标
GEORADIUS:指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。6.2以后已废弃
GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范用可以是圆形或矩形。6.2.新功能
GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。6.2.新功能
例子:
- 北京南站( 116.378248 39.865275)
- 北京站 ( 116.42803 39.903738)
- 北京西站(116.322287 39.893729)
添加以上信息
geoadd g1 116.378248 39.865275 bjn 116.42803 39.903738 bjz 116.322287 39.893729 bjx
"3"
计算北京西到北京站的距离
geodist g1 bjx bjz
"9091.5648"
geodist g1 bjx bjz km
"9.0916"
搜索天安门( )附近10km内的所有火车站,并按照距离升序排序
geosearch g1 fromlonlat 116.397904 39.909005 byradius 10 km withdist
1) 1) "bjz"
2) "2.6361"
2) 1) "bjn"
2) "5.1452"
3) 1) "bjx"
2) "6.6723"
BitMap
Redis中是利用string类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是2A32个bit位。
BitMap的操作命令有:
SETBIT:向指定位置 (offset)存入一个0或1
setbit bm1 0 1
10000000
setbit bm1 1 1
11000000
GETBIT:获取指定位置 (offset)的bit值
getbit bm1 1
1
BITCOUNT:统计BitMap中值为1的bit位的数量
bitcount bm1
2
BITFIELD:操作(查询、修改、自增),BitMap中bit数组中的指定位置 (offset)的值
◦ 0011111110000000
bitfield bm1 get u26 0
相当于:00111111100000000000000000
"16646144"
◦ bm1是bit数组名称
◦ get读取
◦ u不带符号
◦ 读取26位
◦ 0从第一位0开始,0123456,从第一位0开始
BITFIELD_RO:获取BtMap中bit数组,并以十进制形式返回
BITOP:将多个BitMap的结果做位运算 (与、或、异或)
BITPOS:查找bit数组中指定范围内第一个0或1出现的位置
bitpos bm1 1
HyperLogLog
UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖。
Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。
相关算法原理可以参考”https://juejin.cn/post/6844903785744056333#heading-0
Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。
pfadd
pfadd a bc b
pfcount
可以用来统计uv,他不会计算重复人数
pfcount key
pfmerge
SpringDataRedis的序列化方式
RedisTemplate可以接收任意0bject作为值写入Redis,只不过写入前会把Object序列化为字节形式,默认是采用JDK序列化,得到的结果是这样的:
@Test
void contextLoads() {
ValueOperations valueOperations = redisTemplate.opsForValue();
valueOperations.set("name","ls");
Object name = valueOperations.get("name");
System.out.println("name--->"+name);
}
解决办法:
通过查看RedisTemplate源码发现:当没有KeySerializer和ValueSerializer的时候,默认采用jdk的序列化,所以可以自己设置序列化
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory connectionFactory){
// 创建RedisTemplate对象
RedisTemplate<String, Object> template = new RedisTemplate<>();
// 设置连接工厂
template.setConnectionFactory(connectionFactory);
// 创建json序列化工具
GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
// 设置key的序列化
template.setKeySerializer(StringRedisSerializer.UTF_8);
template.setHashKeySerializer(StringRedisSerializer.UTF_8);
// 设置value的序列化
template.setValueSerializer(jsonRedisSerializer);
template.setHashValueSerializer(jsonRedisSerializer);
return template;
}
}
一个问题
为了在反序列化时知道对象的类型,JSON序列化器会将类的class类型写入json结果中,存入Redis,会带来额外的内存开销。
{
"@class": "com.wzw.springboot06redis.entity.User",
"name": "zs",
"sex": "男"
}
一共三个属性,序列化的内存比真实需要的内存还高,所以这样的不行的
为了节省内存空间,我们并不会使用SON序列化器来处理value,而是统一使用String序列化器,要求只能存储String类型的key和value。当需要存储Java对象时,手动完成对象的序列化和反序列化。
手动序列化
推荐!!!!!!
@Autowired
private StringRedisTemplate stringRedisTemplate;
// JSON工具,用来序列化
private static final ObjectMapper mapper = new ObjectMapper();
@Test
public void testStringTemplate() throws JsonProcessingException {
User user = new User();
user.setName("zs");
user.setSex("男");
ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
// 手动序列化
String json = mapper.writeValueAsString(user);
valueOperations.set("user2",json);
String user2 = valueOperations.get("user2");
System.out.println("user2--->"+user2);
// 反序列化
User readValue = mapper.readValue(user2, User.class);
System.out.println("readValue-->"+readValue);
}
集群的session共享问题
session共享问题: 多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务时导致数据丢失的问题
session的替代方案应该满足:
数据共享
内存存储
key、value结构
使用Redis
什么是缓存
缓存就是数据交换的缓冲区(称作Cache[kae1),是存赔数据的临时地方,一般读写性能较高
两个例子
这里有个地方要注意!关于String类型直接存储
往redis里存,要存json数据
从redis里取,取出来的也是json数据,需要转化成相应的java对象,也就是bean
通过id查数据
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询缓存
String shopJSON = stringRedisTemplate.opsForValue().get(key);
// 2.判断缓存中是否存在
if (StringUtils.isNotBlank(shopJSON)){
// 3.存在,返回缓存数据
Shop shop = JSONUtil.toBean(shopJSON, Shop.class);
return Result.ok(shop);
}
// 4.不存在,查询数据库
Shop shop = getById(id);
if (shop ==null){
// 5.不存在,返回错误
return Result.fail("数据不存在");
}
// 6.数据库中存在,返回数据到controller,存储数据到缓存
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
return Result.ok(shop);
}
查list数据
@Override
public Result listShopType() {
// 先看缓存中有没有
String JSONArray = stringRedisTemplate.opsForValue().get(CACHE_SHOPType_KEY);
// 如果缓存中有,直接返回
if (StringUtils.isNotBlank(JSONArray)) {
List<ShopType> strings = JSONUtil.toList(JSONArray, ShopType.class);
return Result.ok(strings);
}
// 如果缓存中没有,去数据库里查
List<ShopType> list = query().orderByAsc("sort").list();
// 如果数据库没有,返回错误
if (list ==null){
return Result.fail("数据错误");
}
// 如果数据库有,先存入缓存数据,后返回数据
stringRedisTemplate.opsForValue().set(CACHE_SHOPType_KEY, JSONUtil.toJsonStr(list));
return Result.ok(list);
}
缓存更新策略
业务场景
低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存
高一致性需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存
缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。如果有不怀好意的人多线程故意发送错误的请求过来,这样每次错误的请求都会经过redis最终打到数据库上,给数据库带来压力
注意!
这里是空对象指的不是null,是“”
例子
1.0版本
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询缓存
String shopJSON = stringRedisTemplate.opsForValue().get(key);
// 2.判断缓存中是否命中
// ""进不了这个if
if (StringUtils.isNotBlank(shopJSON)){
// 3.命中,返回缓存数据
Shop shop = JSONUtil.toBean(shopJSON, Shop.class);
return Result.ok(shop);
}
// 判断命中的是不是“”
if ("".equals(shopJSON)){
return Result.fail("该id不存在");
}
// 4.不存在,查询数据库
Shop shop = getById(id);
if (shop ==null){
// 空值缓存
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
// 5.不存在,返回错误
return Result.fail("数据不存在");
}
// 6.数据库中存在,返回数据到controller,存储数据到缓存
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
2.0版本,进行了封装
/**
* 解决缓存穿透,空字符对象返回
* @param prefix
* @param id
* @param type
* @param function
* @param time
* @param unit
* @return
* @param <R>
* @param <ID>
*/
public <R,ID> R queryWithPassThrough(String prefix,ID id, Class<R> type, Function<ID,R> function, Long time, TimeUnit unit){
String key = prefix + id;
String JSON = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isNotBlank(JSON)){
return JSONUtil.toBean(JSON, type);
}
if ("".equals(JSON)){
return null;
}
/*
数据库查询
*/
R r = function.apply(id);
if (r ==null){
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
this.set(prefix+id,r,time,unit);
return r;
}
缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力
缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
基于互斥锁的解决方式
原理:Redis有个命令:sexnx
centos7-192.168.163.129:0>setnx lock 1
"1"
centos7-192.168.163.129:0>setnx lock 2
"0"
centos7-192.168.163.129:0>setnx lock 3
"0"
centos7-192.168.163.129:0>get lock
"1"
centos7-192.168.163.129:0>del lock
"1"
centos7-192.168.163.129:0>get lock
null
centos7-192.168.163.129:0>setnx lock 2
"1"
setnx的时候,当key=lock,存在时,不能更新,只有不存在的时候才能set
1.0版本
/**
* 互斥锁解决缓存击穿(热点key)
* @param id
* @return
*/
public Shop queryWithMutex(Long id){
Shop shop =null;
String key =CACHE_SHOP_KEY + id;
String JSONStr = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isNotBlank(JSONStr)){
return JSONUtil.toBean(JSONStr, Shop.class);
}
if ("".equals(JSONStr)){
return null;
}
try {
boolean isLock = tryLock(LOCK_SHOP_KEY + id);
if (!isLock) {
Thread.sleep(50);
return queryWithMutex(id);
}
shop = getById(id);
Thread.sleep(200);
if (shop ==null){
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL,TimeUnit.MINUTES);
} catch (InterruptedException e) {
unLock(key);
}
return shop;
}
/**
* 加互斥锁
* @param key
* @return
*/
public boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
* @param key
* @return
*/
public void unLock(String key){
stringRedisTemplate.delete(key);
}
2.0版本,封装
/**
* 缓存击穿-->互斥锁解决
* @param prefix 查询的前缀
* @param id
* @param type
* @param function
* @param time
* @param unit
* @return
* @param <R>
* @param <ID>
*/
public <R,ID> R queryWithMutex(String prefix, ID id, Class<R> type, Function<ID,R> function, Long time, TimeUnit unit ){
String key = prefix + id;
String lockKey = LOCK_SHOP_KEY+id;
String JSON = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isNotBlank(JSON)){
return JSONUtil.toBean(JSON,type);
}
if ("".equals(JSON)){
return null;
}
R r = null;
try {
boolean isLock = tryLock(lockKey);
if (!isLock){
Thread.sleep(50);
return queryWithMutex(prefix, id, type, function, time, unit);
}
r = function.apply(id);
log.debug("r--->" + r);
// 代表数据库没有需要的数据
if (r==null){
set(key,"", time, unit);
return null;
}
set(key,JSONUtil.toJsonStr(r),time,unit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
unLock(lockKey);
}
return r;
}
/**
* 互斥锁
* @param key
* @return
*/
public boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
* @param key
*/
public void unLock(String key){
stringRedisTemplate.delete(key);
}
基于逻辑过期的解决方式
1.0版本
// 创建线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 逻辑过期解决缓存击穿
* @param id
* @return
*/
public Shop queryWithExpiration(Long id){
String JSONStr = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 未命中
if (StringUtils.isBlank(JSONStr)){
return null;
}
// 命中
LocalDateTime now = LocalDateTime.now();
RedisData redisData = JSONUtil.toBean(JSONStr, RedisData.class);
LocalDateTime expireTime = redisData.getExpireTime();
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
// 没过期,返回商铺信息
if (expireTime.isAfter(now)){
return shop;
}
// 过期
if (tryLock(LOCK_SHOP_KEY + id)){
// 获取到了锁,开启一个新线程,查询数据库,写入redies,重新设置过期时间
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
saveShopHot(id,30L);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
unLock(LOCK_SHOP_KEY + id);
}
});
}
return shop;
}
/**
* 逻辑过期处理 预热
* @param id
*/
public void saveShopHot(Long id,Long seconds){
RedisData redisData = new RedisData();
redisData.setData(getById(id));
redisData.setExpireTime(LocalDateTime.now().plusSeconds(seconds));
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}
2.0版本,封装
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 缓存击穿,逻辑过期解决
* @param prefix
* @param id
* @param type
* @param function
* @param time
* @param unit
* @return
* @param <R>
* @param <ID>
*/
public <R,ID> R queryWithLogicalExpire(String prefix, ID id, Class<R> type, Function<ID,R> function, Long time, TimeUnit unit){
String key = prefix + id;
String lockKey = LOCK_SHOP_KEY + id;
String JSON = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isBlank(JSON)){
return null;
}
RedisData redisData = JSONUtil.toBean(JSON, RedisData.class);
LocalDateTime expireTime = redisData.getExpireTime();
LocalDateTime now = LocalDateTime.now();
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
if (expireTime.isAfter(now)){
// 没过期
return r;
}
// 过期
try {
if (tryLock(lockKey)) {
CACHE_REBUILD_EXECUTOR.submit(()->{
log.debug("竞争到了");
R rDb = function.apply(id);
setWithLogicalExpire(key,rDb,time,unit);
});
}
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unLock(key);
}
return r;
}
/**
* 重载缓存
* @param key
* @param value
* @param time
* @param unit
*/
public void set(String key, Object value, Long time, TimeUnit unit){
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
}
/**
* 设置逻辑过期缓存
* @param key
* @param value
* @param time
* @param unit
*/
public void setWithLogicalExpire(String key,Object value, Long time, TimeUnit unit){
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
}
全局唯一ID
==数据库主键自增长有什么缺点?==
主键id的规律性太明显
比如下一单主键是1,第二天主键是100,就暴露了信息
受单表数据量的限制
全局ID生成器
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:
高可用
唯一性
高性能
递增性
安全性
@Component
public class RedisIdWorker {
private final static long BEGIN_TIMESTAMP = 1640995200;
private final static int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public long getId(String keyPrefix){
// id生成器是由时间戳跟序列化拼接而成的
// 1.生成时间戳
long nowSecond = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 2.生成序列化
Long count = stringRedisTemplate.opsForValue().increment("incr:" + keyPrefix + ":" + date);
// 3.拼接并返回
return timestamp << COUNT_BITS | count;
}
}
并发问题
超卖问题
版本号法
每次更新数据的时候,判断版本是否跟之前查询的版本version相同,如果相同,说明数据没有被修改过,可以修改
CAS
版本号法的修改版本,在秒杀中,秒杀一单库存少一单,可以用库存代替版本version
注意! 这里其实做了修改的,本来是每次请求分为:1.查优惠券信息 2.扣减库存,所以每次做更新操作的时候本来是要判断这两次操作之间有没有别的操作更新了库存,导致超卖情况。但是这样也会导致商品滞留的情况,一开始高并发线程都读到了100库存,但是只有其中一个线程能够成功更新,其他线程都不能更新。所以这里直接判断库存>0,而不是判断版本,这样就将更新跟查询放到了同一条语句,保证了原子性
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("error");
}
// 3.判断秒杀是否已经结束
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("error");
}
// 4.判断库存是否充足
if (seckillVoucher.getStock()<1){
return Result.fail("error");
}
// 5.扣减库存
boolean update = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
// update xx set stock = stock -1 where voucher_id = xxx and stock >0
if (!update){
return Result.fail("error");
}
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
Long id = UserHolder.getUser().getId();
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(id);
voucherOrder.setId(redisIdWorker.getId("order"));
save(voucherOrder);
// 7.返回订单id
return Result.ok(redisIdWorker.getId("order"));
}
}
一人一单问题
测试的时候是带有token的,所以并发量在后台看来是同一个人,如何保证一人只能下一单呢?
用锁
先查询该用户是否购买过,如果购买过了就不能继续购买了
那么这个购买如何加锁,乐观锁还是悲观锁,查询语句一般都是用悲观锁
锁什么好呢?可以锁用户id,每一个用户都是一个独立的id且不可重复
uid.toString().intern()
先获取锁,开启事务,提交事务,释放锁
这里涉及了一个锁的粒度问题,应该是锁最小的粒度,还涉及了spring事务的问题
spring事务是通过代理对象调用方法创建事务的,下面的例子通过一个封装方法返回值,这个方法需要用到事务,但是是在本类中调用的方法,是本类自己直接调用的,而不是代理对象调用,所以事务会失效
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀未开始");
}
// 3.判断秒杀是否已经结束
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀结束");
}
// 4.判断库存是否充足
if (seckillVoucher.getStock()<1){
return Result.fail("库存不足");
}
// 7.返回订单id
Long uid = UserHolder.getUser().getId();
// 先获取锁,开启事务,提交事务,释放锁
synchronized (uid.toString().intern()){
IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
return proxy.createVoucherOreder(voucherId, uid);
}
}
@Transactional
public Result createVoucherOreder(Long voucherId, Long uid) {
Integer count = query().eq("user_id", uid).eq("voucher_id", voucherId) .count();
if (count ==1){
return Result.fail("已经购买过了");
}
// 5.扣减库存
boolean update = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
// update xx set stock = stock -1 where voucher_id = xxx and stock >0
if (!update){
return Result.fail("error");
}
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(uid);
voucherOrder.setId(redisIdWorker.getId("order"));
save(voucherOrder);
return Result.ok(redisIdWorker.getId("order"));
}
Spring的事务处理机制
在类中使用@Transactional的时候,实际上是spring拿到了这个类的代理对象做的事务处理。
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
Long uid = UserHolder.getUser().getId();
// 先获取锁,开启事务,提交事务,释放锁
// 为什么要这样?
// 如果是-->先获取事务,获取锁,释放锁,提交事务
// 那就有可能造成:先获取事务,线程1获取锁,线程1释放锁,线程2获取锁,线程2释放锁,提交事务 造成超卖
synchronized (uid.toString().intern()){
IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
return proxy.createVoucherOreder(voucherId, uid);
}
}
@Transactional
public Result createVoucherOreder(Long voucherId, Long uid) {
demo
}
}
引入依赖
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
启动类添加注解@EnableAspectJAutoProxy(exposeProxy = true) 暴露代理对象,这样在类中自己new代理对象才能获取到
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}
一人一单并发问题
在有多个Tomcat,集群模式下, 刚刚上面的锁机制就不行了,因为每个tomcat都有自己的JVM,每个JVM里都有自己的常量池,所以锁不住,所以上面的锁机制只用于单体模式
解决办法——分布式锁
找一个外部锁————>
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁
MYSQL | Redis | ZooKeeper | |
互斥 | 利用mysql本身的互斥锁机制 | 利用setnx这样的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
高可用 | 好 | 好 | 好 |
高性能 | 一般 | 好 | 一般 |
安全性 | 断开连接,自动释放锁 | 利用锁超时时间,到期释放 | 临时节点,断开连接自动释放 |
基于Redis的分布式锁
获取锁失败有两种实现
阻塞:获取锁失败进入等待,然后重新获取锁
非阻塞:获取锁失败直接返回
分布式锁的简单实现
使用setnx+ttl
public class SimpleRedisLock implements ILock{
private String name;
private static final String Lock_Key_Prefix = "lock:";
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
Long currentID = Thread.currentThread().getId();
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(Lock_Key_Prefix+name,currentID + "" , timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(flag);
}
@Override
public void unLock() {
stringRedisTemplate.delete(Lock_Key_Prefix+name);
}
}
SimpleRedisLock lock = new SimpleRedisLock("order:" + uid, stringRedisTemplate);
boolean tryLock = lock.tryLock(1200);
if (!tryLock) {
return Result.fail("error");
}
try {
IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
return proxy.createVoucherOreder(voucherId, uid);
} finally {
lock.unLock();
}
分布式锁误删问题
并发问题1
线程1拿到锁,由于业务繁重,造成了在ttl里还没能释放锁,导致锁自动超时释放掉了,当线程1业务完成的时候,就会去删除锁,有可能造成删除了其他线程的锁,造成并发问题。
问题来了,什么场景下,会出现这种问题?
同一个人不同的客户端,可以是多线程的,比如手机是线程1,电脑是线程2,两边同时秒杀,手机由于网络延迟导致超时释放锁,电脑此时抢到了锁,判断还没有购买成功,可以购买,开始购买的业务实现,此时手机网好了,手机购买成功,把电脑的锁释放了,此时可以再来一个平板继续秒杀,也是可以的。到此至少超卖了一件商品,手机可以买到,电脑没有锁控制也可以买到
解决办法
需求:修改之前的分布式锁实现,满足
在获取锁时存入线程标示(可以用UUID表示) ( value )
在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致
如果一致则释放锁
如果不一致则不释放锁
// 分布式锁
SimpleRedisLock lock = new SimpleRedisLock("order:" + uid, stringRedisTemplate);
boolean tryLock = lock.tryLock(1200);
if (!tryLock) {
return Result.fail("error");
}
try {
IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
return proxy.createVoucherOreder(voucherId, uid);
} finally {
lock.unLock();
}
public class SimpleRedisLock implements ILock{
private String name;
private static final String Lock_Key_Prefix = "lock:";
private static String ID_PREFIX = UUID.randomUUID().toString().replace("-","")+"-";
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
String threadID = ID_PREFIX + Thread.currentThread().getId();
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(Lock_Key_Prefix+name, threadID, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(flag);
}
@Override
public void unLock() {
String threadID = stringRedisTemplate.opsForValue().get(Lock_Key_Prefix + name);
String id = ID_PREFIX + Thread.currentThread().getId();
if (id.equals(threadID)) {
Boolean delete = stringRedisTemplate.delete(Lock_Key_Prefix + name);
}
}
}
并发问题2
上面的解决还是有点小问题,在释放锁的if中,如果已经做完了判断,true,知道是自己的锁的时候,刚准备删,出现了阻塞(GC),有可能导致超时自动释放,其他线程来的时候,线程1醒了又把其他线程的锁释放掉了,这一次释放是不用判断的因为已经判断过线程标识了(value),所以根据key直接就将同一个用户的锁删了,又造成了超卖
产生原因:判断锁跟释放锁是两个动作,并没有保持原子性
解决思路:将判断锁跟释放锁放到同一个动作,保持原子性
解决办法
使用lua脚本,在一个脚本里编写多条Redis命令,确保多条命令执行时的原子性
-- 获取锁中的线程标识
-- 比较线程标识与锁中的线程标识是否一致
if(redis.call("get", KEYS[1]) ==ARGV[1]) then
-- 释放锁
return redis.call("del",KEYS[1])
end
return 0
public class SimpleRedisLock implements ILock{
private String name;
private static final String Lock_Key_Prefix = "lock:";
private static String ID_PREFIX = UUID.randomUUID().toString().replace("-","")+"-";
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
String threadID = ID_PREFIX + Thread.currentThread().getId();
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(Lock_Key_Prefix+name, threadID, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(flag);
}
@Override
public void unLock() {
Long flag = stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(Lock_Key_Prefix + name),
ID_PREFIX + Thread.currentThread().getId());
}
}
基于Redis的分布式锁实现思路
利用setnxex获取锁,并设置过期时间,保存线程标示
释放锁时先判断线程标示是否与自己一致,一致则删除锁特性:
利用setnx满足互斥性
利用set ex保证故障时锁依然能释放,避免死锁,提高安全性利用Redis集群保证高可用和高并发特性
基于Redis的分布式锁的优化
可以借助第三方来实现上面的需求,比如:Redisson
Redisson
Redisson是一个在Redis的基础上实现的lava驻内存数据网格 (In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
引入pom
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.19.3</version>
</dependency>
配置类
@Configuration
public class RedisConfig {
@Bean
public RedissonClient redissonClient(){
// redisson的工具类
Config config = new Config();
// 添加redis地址,这里添加的是单点地址,也可以使用config.useClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://192.168.163.129:6379").setPassword("123456");
// 创建客户端
return Redisson.create(config);
}
}
Redisson可重入锁原理
为什么我们自己写的redis锁不能实现同一线程内获取同一锁呢?
我们自己写的redis锁是根据setnx来实现的,只有一个能获取成功
Redisson的可重入锁采用的是Hash结构,如果获取锁成功value+1,释放锁value-1,当最外层锁释放的时候,value=0,执行删除操作,在这些操作中,必须要保持原子性才能实现,所以都是采用Lua脚本实现的
获取锁的Lua脚本
@Override
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
"local mode = redis.call('hget', KEYS[1], 'mode'); " +
"if (mode == false) then " +
"redis.call('hset', KEYS[1], 'mode', 'write'); " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (mode == 'write') then " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"local currentExpire = redis.call('pttl', KEYS[1]); " +
"redis.call('pexpire', KEYS[1], currentExpire + ARGV[1]); " +
"return nil; " +
"end; " +
"end;" +
"return redis.call('pttl', KEYS[1]);",
Arrays.<Object>asList(getRawName()),
unit.toMillis(leaseTime), getLockName(threadId));
}
释放锁的Lua脚本
释放过程中会发布消息通知:redis.call('publish', KEYS[2], ARGV[1])
@Override
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"local mode = redis.call('hget', KEYS[1], 'mode'); " +
"if (mode == false) then " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end;" +
"if (mode == 'write') then " +
"local lockExists = redis.call('hexists', KEYS[1], ARGV[3]); " +
"if (lockExists == 0) then " +
"return nil;" +
"else " +
"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('hdel', KEYS[1], ARGV[3]); " +
"if (redis.call('hlen', KEYS[1]) == 1) then " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"else " +
// has unlocked read-locks
"redis.call('hset', KEYS[1], 'mode', 'read'); " +
"end; " +
"return 1; "+
"end; " +
"end; " +
"end; "
+ "return nil;",
Arrays.<Object>asList(getRawName(), getChannelName()),
LockPubSub.READ_UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
Redisson的锁重试机制
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit)
在获取锁失败的时候:会有一个订阅,当释放锁的时候会发布消息通知被这个订阅获取,判断最大剩余等待时间是否大于0,大于0才重新开始获取锁。
CompletableFuture<RedissonLockEntry> future = subscribe(threadId);
超时则取消订阅
unsubscribe(entry, threadId);
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return;
}
CompletableFuture<RedissonLockEntry> future = subscribe(threadId);
pubSub.timeout(future);
RedissonLockEntry entry;
if (interruptibly) {
entry = commandExecutor.getInterrupted(future);
} else {
entry = commandExecutor.get(future);
}
try {
while (true) {
ttl = tryAcquire(-1, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
break;
}
// waiting for message
if (ttl >= 0) {
try {
entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
if (interruptibly) {
throw e;
}
entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
}
} else {
if (interruptibly) {
entry.getLatch().acquire();
} else {
entry.getLatch().acquireUninterruptibly();
}
}
}
} finally {
unsubscribe(entry, threadId);
}
// get(lockAsync(leaseTime, unit));
}
其中,这一段是指当获取到了锁,发现时间小于等于0,到期续订scheduleExpirationRenewal
if (acquired) {
if (leaseTime > 0) {
internalLockLeaseTime = unit.toMillis(leaseTime);
} else {
scheduleExpirationRenewal(threadId);
}
}
看门狗机制
在到期续订的内部封装了一个死循环的定时任务,在释放锁之前保证不会过期
释放锁的时候,取消更新任务
具体代码
private void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
CompletionStage<Boolean> future = renewExpirationAsync(threadId);
future.whenComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock {} expiration", getRawName(), e);
EXPIRATION_RENEWAL_MAP.remove(getEntryName());
return;
}
if (res) {
// reschedule itself
renewExpiration();
} else {
cancelExpirationRenewal(null);
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
protected void cancelExpirationRenewal(Long threadId) {
ExpirationEntry task = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (task == null) {
return;
}
if (threadId != null) {
task.removeThreadId(threadId);
}
if (threadId == null || task.hasNoThreads()) {
Timeout timeout = task.getTimeout();
if (timeout != null) {
timeout.cancel();
}
EXPIRATION_RENEWAL_MAP.remove(getEntryName());
}
}
总结
可重入:利用hash结构记录线程id和重入次数
可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制
超时续约:利用watchDog,每隔一段时间(releaseTime/3),重置超时时间
Redisson分布式锁主从一致性问题
获取锁的时候进行setnx操作,这个setnx操作会向所有的redis节点操作,即:向所有节点获取锁,只有所有节点都setnx成功,才会获取锁成功。
如果真的有一个redis宕机了,且这个reids节点没有完成主从同步,没有锁标识,只有在每一个节点都获取到锁才算获取成功,这个例子中只有那个宕机的节点能获取到锁,其他两个节点都获取不到。
总结
不可重入Redis分布式锁
原理:利用setnx的互斥性;利用ex避免死锁;释放锁时判断线程标示
缺陷:不可重入、无法重试、锁超时失效
可重入的Redis分布式锁
原理:利用hash结构,记录线程标示和重入次数;利用watchDog延续锁时间;利用信号量(value)控制锁重试等待
缺陷:redis宕机引起锁失效问题
Redisson的multiLock
* 原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功
* 缺陷:运维成本高、实现复杂
Lua脚本
参考文档:https://www.runoob.com/lua/lua-tutorial.html
Redis调用Lua脚本的命令:EVAL
Lua中数字从1开始,而不是0
无参形式
EVAL "return redis.call('set' , 'name' , 'jack')" 0
0 表示参数数量
有参形式
eval "return redis.call('set' , KEYS[1] , ARGV[1])" 1 name rose
调用Lua的api
RedisTemplate.execute()
/*
* (non-Javadoc)
* @see org.springframework.data.redis.core.RedisOperations#execute(org.springframework.data.redis.core.script.RedisScript, org.springframework.data.redis.serializer.RedisSerializer, org.springframework.data.redis.serializer.RedisSerializer, java.util.List, java.lang.Object[])
*/
@Override
public <T> T execute(RedisScript<T> script, RedisSerializer<?> argsSerializer, RedisSerializer<T> resultSerializer,
List<K> keys, Object... args) {
return scriptExecutor.execute(script, argsSerializer, resultSerializer, keys, args);
}
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<Long>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
Redis优化秒杀
在原先的系统中,中间是没有redis这一层的,直接就是请求通过nginx请求分发到服务器,tomcat串行执行一系列流程,最终返回结果,这其中效率是很低的,因为所有的流程都给tomcat去做,耗时就是整个流程的时间,一个请求进来就要花费这么多时间,下一个请求才能进来,单位时间内的处理业务能力就下降了。
解决办法:
加一层redis,请求进来之后先在redis中判断,库存是否充足,有没有下单资格,如果有,保存下单的信息,直接将结果返回用户,此时下单流程还没有开始,只是提前将结果告诉了用户,因为条件满足,所以这个用户肯定能下单成功,下单的流程就交给后面的服务器去做,增加了整个系统的吞吐量
解决思路
创建异步线程:
先利用Redis完成库存余量,一人一单判断,完成抢单业务
再将下单业务放入阻塞队列,利用独立线程异步下单
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private RedissonClient redissonClient;
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
// 开启一个线程池
private ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
private IVoucherOrderService proxy ;
static {
SECKILL_SCRIPT = new DefaultRedisScript<Long>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
// @PostConstruct代表着,当前类VoucherOrderServiceImpl初始化完毕后,开始执行
// 一旦这个类创建成功,就提交线程任务VoucherOrderHandle
@PostConstruct
private void init(){
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandle());
}
private class VoucherOrderHandle implements Runnable{
@Override
public void run() {
while (true){
// 不停的从阻塞队列中获取第一个元素
// take:获取队列中的第一个元素,如果元素不存在,则阻塞,不占用cpu,所以这个死循环也不会继续循环下去
// 获取订单
try {
VoucherOrder order = orderTasks.take();
// 提交订单
handleVoucherOrder(order);
} catch (Exception e) {
log.error("线程任务异常:-->" + e);
}
}
}
}
private void handleVoucherOrder(VoucherOrder order) {
// 这里为什么不用thread工具类的getUserId呢?
// 因为用ThreadLocal获取不到userid,现在这个线程任务不是主线程,而是子线程
Long uid = order.getUserId();
RLock lock = redissonClient.getLock("lock:order:" + uid);
boolean tryLock = lock.tryLock();
if (!tryLock) {
log.error("error");
return;
}
try {
// 这里也不能用AopContext.currentProxy(),点进去发现这个也是用ThreadLocal获取的代理对象
// 而现在子线程获取不到代理对象,除非是在主线程调用这个方法,传递给子线程,子线程才能获取到
// IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
proxy.createVoucherOreder(order);
} finally {
lock.forceUnlock();
}
}
public Result seckillVoucher(Long voucherId) {
Long uid = UserHolder.getUser().getId();
Long res = stringRedisTemplate.execute(SECKILL_SCRIPT, Collections.EMPTY_LIST, voucherId.toString(), uid.toString());
int i = res.intValue();
if (i ==1){
return Result.fail("库存不足");
}
if (i ==2){
return Result.fail("不可重复下单");
}
// 返回订单id
long orderId = redisIdWorker.getId("order");
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(uid);
voucherOrder.setId(orderId);
// 将下单信息放到阻塞队列里
// 当一个线程从队列中获取元素,没有元素,线程就会阻塞,直到队列中有元素,线程才会被唤醒
orderTasks.add(voucherOrder);
proxy = (IVoucherOrderService)AopContext.currentProxy();
// TODO: 2023/2/25 将下单信息保存到阻塞队列
return Result.ok(orderId);
}
@Transactional
public void createVoucherOreder(VoucherOrder order) {
Long uid = order.getUserId();
Long voucherId = order.getVoucherId();
Integer count = query().eq("user_id", uid).eq("voucher_id", voucherId) .count();
if (count ==1){
return;
}
boolean update = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!update){
return;
}
save(order);
}
}
当前异步任务有什么问题?
内存限制问题:当前的阻塞队列设置了1024 * 1024
数据安全问题:当前数据放在内存里的,如果服务宕机了,会造成数据丢失
消息队列
消息队列 (Message Queue),字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色:
消息队列:存储和管理消息,也被称为消息代理(Message Broker)
生产者:发送消息到消息队列
消费者:从消息队列获取消息并处理消息
消息队列跟上面的阻塞队列有什么区别呢?
区别:
阻塞队列在JVM里,受到JVM的内存限制。
消息队列独立于JVM,是一个单独的服务,不受内存限制
阻塞队列不做持久化操作,容易造成数据安全问题。
消息队列有持久化操作,且每次投递消息的时候,消费者都有确认消息的操作,如果消费者不确认,就会一直投递,确保消息至少一次消费成功
市面上有很多成熟的消息队列的服务,且搭建消息队列是有成本的,所以小的服务可以使用redis自身提供的方式实现消息队列
Redis提供了三种不同的方式来实现消息队列:
list结构:基于List结构模拟消息队列
PubSub:基本的点对点消息模型
stream:比较完善的消息队列模型
List结构模拟消息队列
基于List的消息队列有哪些优缺点?
优点:
利用Redis存储,不受限于JVM内存上限
基于Redis的持久化机制,数据安全性有保证
可以满足消息有序性
缺点:
无法避免消息丢失
只支持单消费者
基于PubSub的消息队列
PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息
SUBSCRIBE channel[channel] : 订阅一个或多个频道
PUBLISH channel msg : 向一个频道发送消息
PSUBSCRIBE pattern[pattern] : 订阅与pattern格式匹配的所有频道
基于Pubsub的消息队列有哪些优缺点?
优点:
采用发布订阅模型,支持多生产、多消费
缺点:
不支持数据持久化
无法避免消息丢失
消息堆积有上限,超出时数据丢失
基于Stream的消息队列
其中:
读取消息
xread count 1 block 0 streams s1 $
count 1 代表只读取1条数据
block 0 代表永久阻塞,直到读取到数据
如果是 block 1000 代表阻塞1000毫秒,超时返回nil
streams s1 代表读取s1消息
$ 代表读取最新数据,0 代表读取第一条数据
发送消息
xadd s1 * k1 v1 ...
xadd 代表插入
s1 代表队列名称
*代表id,不确定,让redis生成
创建队列
xgroup create groupName key 0 mkstream
注意!
当我们指定起始ID为$时,代表读取最新的消息,如果我们处理一条消息的过程中,又有超过1条以上的消息到达队列,则下次获取时也只能获取到最新的一条,会出现漏读消息的问题。
STREAM类型消息队列的XREAD命令特点:
优点
消息可回溯
一个消息可以被多个消费者读取
可以阻塞读取
缺点
有消息漏读的风险
基于Stream的消息队列——消费者组
创建消费者组:
XGROUP CREATE key groupName ID [MKSTREAM]
key :队列名称
groupName : 消费者组名称
ID : 起始ID标识,$代表队列中最后一个消息,0代表队列中第一个消息
MKSTREAM : 队列不存在时自动创建队列
xgroup create s1 g1 0
创建一个队列名称为s1,消费者组名称为g1,,
删除指定的消费者组:
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...]
group : 消费组名称
consumer : 消费者名称,如果消费者不存在,会自动创建一个消费者
count : 本次查询的最大数量
BLOCK milliseconds : 当没有消息时最长等待时间
NOACK : 无需手动ACK,获取到消息后自动确认
STREAMS key : 指定队列名称
ID : 获取消息的起始ID:
">" : 从下一个未消费的消息开始
其它 : 根据指定id从pending-list中获取已消费但未确认的消息,例如0,是从pending-list中的第一个消息开始
在java代码中如何实现呢?
首先监听队列——获取消息( > )——处理消息——确认消息——如果有异常:while true [ 重新获取消息( 0 ) ——处理消息确认消息 ]
STREAM类型消息队列的XREADGROUP命令特点
消息可回溯
可以多消费者争抢消息,加快消费速度
可以阻塞读取
没有消息漏读的风险
有消息确认机制,保证消息至少被消费一次
总结——基于Reids的消息队列
LIist | Pubsub | Stream | |
消息持久化 | 支持 | 不支持 | 支持 |
阻塞读取 | 支持 | 支持 | 支持 |
消息堆积处理 | 受限于内存空间,可以利用多消费者加快处理 | 受限于消费者缓冲区 | 受限于队列长度,可以利用消费者组提高消费速度,减少堆积 |
消息确认机制 | 不支持 | 不支持 | 支持 |
消息回溯 | 不支持 | 不支持 | 支持 |
使用消息队列进行秒杀优化
现在Redis中创建消息队列:
xgroup create stream.orders mygroup 0 mkstream
先改造lua脚本,Lua脚本的作用
判断库存是否充足
判断是否有下单资格
扣减库存
让Redis中的库存量 -1
将下单用户加入Redis储存起来,防止第二次下单
将用户id,订单id,优惠券id放到消息队列中,方便服务器从队列中获取信息
local voucherId = ARGV[1]
local userId = ARGV[2]
local orderId = ARGV[3]
local stock_key = "seckill:stock:" .. voucherId
local order_key = "seckill:order:" .. voucherId
--判断库存是否充足
if (tonumber(redis.call('get',stock_key)) <=0) then
return 1
end
--判断是否下过单
if ((redis.call('sismember', order_key, userId)) ==1) then
--==1 说明下过单
return 2
end
--扣库存
redis.call('incrby', stock_key, -1)
redis.call('sadd', order_key, userId)
redis.call('xadd', 'stream.orders', '*', 'userId', userId,'voucherId', voucherId, 'id', orderId)
return 0
异步处理订单
主线程拿到下单请求(此时已经启动子线程监听队列)
进入Lua脚本,判断下单资格,库存是否充足,条件满足将下单信息放到队列中
子线程读取消息队列,接收下单信息,提交订单,创建订单,保存到数据库,确认消息ACK
如果没有获取到下单信息,则继续监听队列,
如果线程出现问题,没有确认消息ACK,则读取xpendingList里的消息
主线程通过Lua脚本的返回值判断是否下单成功,直接返回客户端
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private RedissonClient redissonClient;
private static final String queryName = "stream.orders";
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
// 开启一个线程池
private ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
private IVoucherOrderService proxy ;
static {
SECKILL_SCRIPT = new DefaultRedisScript<Long>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
// @PostConstruct代表着,当前类VoucherOrderServiceImpl初始化完毕后,开始执行
// 一旦这个类创建成功,就提交线程任务VoucherOrderHandle
@PostConstruct
private void init(){
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandle());
}
private class VoucherOrderHandle implements Runnable {
@Override
public void run() {
while (true) {
try {
// xreadgroup group mygroup c1 count 1 block 2000 streams streams.orders 0
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("mygroup", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create(queryName, ReadOffset.lastConsumed())
);
if (list == null || list.isEmpty()) {
continue;
}
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> map = record.getValue();
VoucherOrder order = BeanUtil.fillBeanWithMap(map, new VoucherOrder(), true);
// 提交订单
handleVoucherOrder(order);
// 确认消息 ACK
stringRedisTemplate.opsForStream().acknowledge(queryName, "mygroup", record.getId());
} catch (Exception e) {
log.error("线程任务异常:-->" + e);
handlePendingList();
}
}
}
}
private void handlePendingList() {
while (true){
try {
// xreadgroup group mygroup c1 count 1 streams streams.orders >
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("mygroup", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create(queryName, ReadOffset.from("0"))
);
if (list ==null || list.isEmpty()){
// 没读取到说明pendingList里没有异常消息
break;
}
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> map = record.getValue();
VoucherOrder order = BeanUtil.fillBeanWithMap(map, new VoucherOrder(), true);
// 提交订单
handleVoucherOrder(order);
// 确认消息 ACK
stringRedisTemplate.opsForStream().acknowledge(queryName,"mygroup",record.getId());
} catch (Exception e) {
log.error("线程任务异常:-->" + e);
try {
Thread.sleep(20);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
}
}
}
private void handleVoucherOrder(VoucherOrder order) {
Long uid = order.getUserId();
RLock lock = redissonClient.getLock("lock:order:" + uid);
boolean tryLock = lock.tryLock();
if (!tryLock) {
log.error("error");
return;
}
try {
proxy.createVoucherOreder(order);
} finally {
lock.forceUnlock();
}
}
public Result seckillVoucher(Long voucherId) {
Long uid = UserHolder.getUser().getId();
long orderId = redisIdWorker.getId("order");
Long res = stringRedisTemplate.execute(SECKILL_SCRIPT, Collections.EMPTY_LIST, voucherId.toString(), uid.toString(),String.valueOf(orderId));
int i = res.intValue();
if (i ==1){
return Result.fail("库存不足");
}
if (i ==2){
return Result.fail("不可重复下单");
}
proxy = (IVoucherOrderService)AopContext.currentProxy();
return Result.ok(orderId);
}
@Transactional
public void createVoucherOreder(VoucherOrder order) {
Long uid = order.getUserId();
Long voucherId = order.getVoucherId();
Integer count = query().eq("user_id", uid).eq("voucher_id", voucherId) .count();
if (count ==1){
return;
}
boolean update = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!update){
return;
}
save(order);
}
}
Feed流的模式
Feed流产品有两种常见模式:
Timeline: 不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈> 优点:信息全面,不会有缺失。并且实现也相对简单
缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
智能排序: 利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
缺点:如果算法不精准,可能起到反作用
Timeline有三种实现方案:
拉模式
只有主动读取数据的时候,才会拉取数据,受限于每次都要重新拉取数据
推模式
每次写数据的时候,都会给每个人发数据
推拉结合
粉丝数多的大V,发送消息的时候,有个发件箱,也分发给谁,活跃用户就主动推送,普通用户就等他自己拉取
普通人发送消息就直接推送
Feed流的实现方案
拉模式 | 推模式 | 推拉结合 | |
写比例 | 低 | 高 | 中 |
读比例 | 高 | 低 | 中 |
用户读取延迟 | 高 | 低 | 低 |
实现难度 | 复杂 | 简单 | 很复杂 |
使用场景 | 很少使用 | 用户量少,没有大V | 过千万的用户量,有大V |
滚动分页查询参数
使用sortedSet的时候:
max:当前时间戳,或者是 上一次查询的最小时间戳
min:0
offset:0 或者是上一次结果中,与最小值一样的元素的个数
count:每页显示的个数
商户查询
@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
if (x==null || y==null){
Page<Shop> page = query()
.eq("type_id", typeId)
.page(new Page<>(current, DEFAULT_PAGE_SIZE));
}
String key = SHOP_GEO_KEY + typeId;
// 分页参数
int start = (current - 1) * DEFAULT_PAGE_SIZE;
int end = current * DEFAULT_PAGE_SIZE;
// 要根据坐标查询附近5公里内的商铺,并实现分页效果
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo().search(
key,
GeoReference.fromCoordinate(x, y),
new Distance(5000),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end)
);
// 返回值results封装了查询信息
if (results ==null){
return Result.ok(Collections.emptyList());
}
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> content = results.getContent();
// 这个if判断是用来防止:需要查询10-15的数据,但是只有9条数据
if (content.size() <= start){
return Result.ok(Collections.emptyList());
}
// 将商户id封装到list中
ArrayList<Long> ids = new ArrayList<>(content.size());
// 将商户id跟距离封装到这个map中
HashMap<String, Distance> map = new HashMap<>();
// 为什么需要skip,因为opsForGEO中本身是没有分页功能的,只是用了limit来代替分页,第一次查0-5,第二次查0-10,截取掉第一次的数据,就是第二次需要的分页数据
Stream<GeoResult<RedisGeoCommands.GeoLocation<String>>> skipedStr = content.stream().skip(start);
skipedStr.forEach(item ->{
String idStr = item.getContent().getName();
Long Shop_id = Long.valueOf(idStr);
Distance distance = item.getDistance();
map.put(idStr,distance);
ids.add(Shop_id);
});
String join = StrUtil.join(",", ids);
List<Shop> shops = query().in("id", ids).last("order by field(id," + join + ")").list();
for (Shop item : shops) {
Long id = item.getId();
item.setDistance(map.get(id.toString()).getValue());
}
return Result.ok(shops);
}
使用BitMap完成签到
@Override
public void sign() {
Long userId = UserHolder.getUser().getId();
LocalDateTime now = LocalDateTime.now();
String suffixKey = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + suffixKey;
int day = now.getDayOfMonth();
//setbit sign:1012:202302 25 1
stringRedisTemplate.opsForValue().setBit(key,day-1,true);
}
实现统计连续签到
精髓在于:
先跟1进行与运算,判断当前位是不是1,如果是1,将数值向右移动一位,重新赋值给数值,直到出现当前位是0
@Override
public Result signCount() {
Long userId = UserHolder.getUser().getId();
LocalDateTime now = LocalDateTime.now();
String suffixKey = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + suffixKey;
int day = now.getDayOfMonth();
// bitfield (sign:userId:yyyy:mm) get u(day) 0
List<Long> result = stringRedisTemplate.opsForValue().bitField(key,
BitFieldSubCommands.create().get(
BitFieldSubCommands.BitFieldType.unsigned(day)).valueAt(0));
if (result ==null ||result.isEmpty()){
return Result.ok(0);
}
Long num = result.get(0);
int count = 0;
while (true){
if ((num & 1) ==0) {
break;
}else {
count++;
}
num = num >> 1;
}
return Result.ok(count);
}