Redis(二):实战场景及实现方式
1、String 类型使用场景
- 场景一:商品库存数
从业务上,商品库存数据是热点数据,交易行为会直接影响库存,而redis自身String类型提供了下面这几个命令
incr key && decr key 递增/递减
incrby key increment && decrby key decrement 增加/减少指定值
set goods_id 10; 设置id为good_id的商品的库存初始值为10;
decr goods_id ; 当商品被购买时,库存数据减1
依次类推同样的场景,商品的浏览次数,问题或者回复的点赞次数等,这种计数的场景都可以考虑利用Redis 来实现
-
场景二:时效信息存储
这个也就是我们平时用的最多的场景,存储value的时候设置过期时间,时间一到,自动删除 -
实现方式
String在redis内部存储默认就是一个字符串(SDS),被redisObject所引用,当遇到incr,decr等操作时会转成数值型进行计算,此时redisObject的encoding字段为int。
2、List类型使用场景
List是按照插入数据排序的字符串链表,可以在头部和尾部插入新的元素(双向链表实现,两端添加元素的时间复杂度为O(1))
- 场景一:最新上架商品
在交易网站首页,经常会有推荐新上架产品的模块,这个模块是存储了最新上架前100名
这时候使用Redis的list数据结构,来进行 TOP 100新上架产品的存储,下面以伪代码演示:
// 1、把新上架的商品添加到链表中
ret = r.lpush("new:goods",goodsId)
// 2、通过ltrim裁减list链表,使之包含 指定范围内的指定元素
r.ltrim("new:goods",0,99)
// 3、获取前100个最新上架的商品 id 列表
new_goods_list = r.lrange("new:goods",0,99)
- 场景二:消息队列实现
根据List 链表的特性,可以实现消息队列的要求,当然,目前有很多专业的消息队列组件,kafka、rabbitMQ等;
List 存储就是一个队列的存储形式:
1、lpush key value;在key对应list的头部添加字符串元素;
2、rpop key;移除key对应list列表的最后一个元素,返回值为移除的元素
-
实现方式
redis 的列表相当于 Java 语言里面的 LinkedList,注意它是链表而不是数组。这意味着 list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为 O(n)。如果再深入一点,你会发现 Redis 底层存储的还不是一个简单的 linkedlist,而是称之为快速链表 quicklist 的一个结构。
首先在列表元素较少的情况下会使用一块连续的内存存储,这个结构是 ziplist,也即是压缩列表。它将所有的元素紧挨着一起存储,分配的是一块连续的内存。
当数据量比较多的时候才会改成 quicklist。因为普通的链表需要的附加指针空间太大,会比较浪费空间,而且会加重内存的碎片化。比如这个列表里存的只是 int 类型的数据,结构上还需要两个额外的指针 prev 和 next 。所以 Redis 将链表和 ziplist 结合起来组成了 quicklist。也就是将多个 ziplist 使用双向指针串起来使用。这样既满足了快速的插入删除性能,又不会出现太大的空间冗余。
3、set类型使用场景
Set也是存储了一个集合列表的功能,但是,和list不同,set具备去重功能。当需要存储一个列表信息,同时要求列表内的元素不能有重复,这时候使用set比较适合。与此同时,set还可以实现交集、并集、差集。
例如,在交易网站,我们会存储用户感兴趣的商品信息,在进行相似性用户分析的时候,可以通过计算两个不同用户之间感兴趣商品的数量来提供一些依据。
下面以伪代码演示:
//userId为用户id,goodid为感兴趣的商品id
sadd "user:userId" goodId;
sadd "user:101" 1;
sadd "user:101" 2;
sadd "user:102" 1;
sadd "user:102" 3;
interResult = sinter "user:101" "user:102" 返回直接定集合的交集;1
unionResult = sinter "user:101" "user:102" 返回直接定集合的并集;1,2,3
diffResult = sinter "user:101" "user:102" 返回直接定集合的差集;2,3
获取到两个用户相似的产品,然后确定相似产品的类目就可以进行用户分析。
类似的场景还有,社交场景下共同关注好友,相似兴趣tag 等场景的支持。
- 实现方式
Redis 的集合相当于 Java 语言里面的 HashSet,它内部的键值对是无序的唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值NULL。
4、Hash类型使用场景
Redis在存储对象(例如,用户信息)的时候,需要对对象进行系列化转换然后进行存储。
还有一种形式,就是将对象数据转换为JSON结构数据,然后存储json字符串到Redis。
其实,对于一些对象类型,还有一种比较方便的类型,那就是按照Redis的Hash类型进行存储
例如,我们存储一些网站用户的基本信息,我们可以使用
hset key field value
hset user101 name "小明"
hset user101 sex "男"
hset user101 phone "123456"
这样我们就存储了一个用户的基本信息,存储信息有{name : 小明,sex : 男,phone : 123456}
当然这种类似场景还非常多,比如存储订单的数据,产品的数据,商家基本信息等。
- 实现方式
Redis 的字典相当于 Java 语言里面的 HashMap,它是无序字典。内部实现结构上同 Java 的 HashMap 也是一致的,同样的数组 + 链表二维结构。第一维 hash 的数组位置碰撞时,就会将碰撞的元素使用链表串接起来。
5、Sorted Set 类型使用场景
Redis sorted set的使用场景与set类似,区别是set不是自动有序,而sorted set可以通过提供一个score参数来为存储数据排序,并且是自动排序,插入即有序。
业务中,如果需要一个有序且不重复的集合列表,就可以选择sorted set这种数据结构
比如,商品的购买热度可以将购买总量num当做商品列表的score,这样获取最热门的商品时,就可以自动按售卖总量排好序。
sorted set适合有排序需求的集合存储场景。
- 实现方式
有序集合的编码可以是ziplist和skiplist之一。
有序集合对象使用ziplist编码需要满足两个条件:一是所有元素长度小于64字节;二是元素个数小于128个;不满足任意一条件将使用skiplist编码。
以上两个条件可以在Redis配置文件中修改zset-max-ziplist-entries选项和zset-max-ziplist-value选项。ziplist编码结构如下
skiplist编码的有序集合对象底层实现是跳跃表和字典两种:
一个是 dict(字典),key是成员,value是分值,用于支持 O(1) 复杂度的按成员取分值操作;
一个是 skiplist(跳跃表),按分值排序成员,用于支持平均复杂度为O(log N)的按分值定位成员的操作,以及范围操作;
备注:上面提到的ziplist quicklist skiplist数据结构详解见Redis(七):Redis底层数据类型
6、分布式环境下常见的应用场景之分布式锁
当多个进程不在同一个系统中时,用分布式锁控制多个进程对资源的操作或者访问。
分布式锁可以避免不同进程重复相同的工作,减少资源浪费。同时分布式锁可以避免破坏数据正确性的发生,例如多个进程对同一个订单操作,可以导致订单状态错误覆盖。。。
-
定时任务重复执行
随着业务的发展,业务系统势必发展为集群分布式模式,如果我们需要一个定时任务来进行订单状态的统计,比如,每15分钟统计一下所有未支付的订单数量,那么我们启动定时任务的时候,肯定不能同一时刻多个业务后台服务都去执行定时任务,这样就会带来重复计算以及业务逻辑混乱的问题。这时候,就需要使用分布式锁,进行资源的锁定。那么在执行定时任务的函数中,首先进行分布式锁的获取,如果可以获取的到,那么这台机器就执行正常的业务数据统计逻辑计算,如果获取不到则证明目前已有其他的服务进程执行这个定时任务,就不用自己操作执行了,只需要返回就行了,如下图所示:
上面的这种业务场景,也可以使用分布式任务调度框架xxjob来实现。
- 避免用户重复下单
分布式锁的实现方式有很多种:
1、数据库乐观锁方式(数据库加一个版本号)
2、基于Redis的分布式锁
3、基于ZK的分布式锁(Zookeeper基础(五):分布式锁)
分布式锁的实现要保证几个基本点:
1、互斥性:任意时刻,只有一个资源能够获取到锁
2、容灾性:能够在未成功释放锁的情况下,一定时限内能够恢复锁的正常功能
3、统一性:加锁和解锁保证同一资源来进行操作
7、分布式环境下常见的应用场景之分布式自增id
随着用户以及交易量的增加,我们可能会针对用户数据,商品数据,以及订单数据进行分库发表的操作,这时候由于进行了分库分表的行为,所以mysql自增id的形式来唯一表示一行数据的方案不可行了,因此需要一个分布式id生成器,来提供唯一id的信息。
通常对于分布式自增id的实现方式有下面几种:
1、利用数据库自增id的属性
2、通过uuid来实现唯一id生产
3、Twitter的SnowFlake算法
4、利用Redis生成唯一id
我们使用redis的incr命令来实现唯一id,因为Redis是单进程单线程架构,不会因为多个取号方的incr命令导致取号重复,因此,基于Redis的incr命令实现序列号的生成基本能满足全局唯一与单调自增的特性
8、Redis发布订阅使用应用场景
Redis有一个发布订阅的通信方式,发送者publish发送消息,订阅者subscribe接收消息。
下图展示了频道 channel1 , 以及订阅这个频道的三个客户端 —— client2 、 client5 和 client1 之间的关系:
image.png当有新消息通过 PUBLISH 命令发送给频道 channel1 时, 这个消息就会被发送给订阅它的三个客户端:
image.png监听
/**
* 订阅者
*/
public class RedisSubTest {
@Test
public void subjava() {
System.out.println("订阅者 ");
Jedis jr = null;
try {
jr = new Jedis("127.0.0.1", 6379, 0);// redis服务地址和端口号
// redis发布订阅消息监听器 需要继承JedisPubSub;当然也可以像下面这种写法
JedisPubSub jedisPubSub = new JedisPubSub(){
@Override //收到消息会调用
public void onMessage(String channel, String message) {
System.out.println(String.format("receive redis published message, channel %s, message %s", channel, message));
}
@Override //订阅了频道会调用
public void onSubscribe(String channel, int subscribedChannels) {
System.out.println(String.format("subscribe redis channel success, channel %s, subscribedChannels %d",
channel, subscribedChannels));
}
@Override //取消订阅 会调用
public void onUnsubscribe(String channel, int subscribedChannels) {
System.out.println(String.format("unsubscribe redis channel, channel %s, subscribedChannels %d",
channel, subscribedChannels));
}
};
// jr客户端配置监听两个channel
jr.subscribe(jedisPubSub, "news.share", "news.blog");
} catch (Exception e) {
e.printStackTrace();
} finally {
if (jr != null) {
jr.disconnect();
}
}
}
}
/**
* 发布者
*/
public class RedisPubTest {
@Test
public void pubjava() {
System.out.println("发布者 ");
Jedis jr = null;
try {
jr = new Jedis("127.0.0.1", 6379, 0);// redis服务地址和端口号
// jr客户端配置监听两个channel
jr.publish( "news.share", "新闻分享");
jr.publish( "news.blog", "新闻博客");
} catch (Exception e) {
e.printStackTrace();
} finally {
if (jr != null) {
jr.disconnect();
}
}
}
}
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
9、key设计原则
9.1 key名设计
- 可读性和可管理性【建议】
以业务名(或数据库名)为前缀(防止key冲突),用冒号分隔,比如业务名:表名:id
ugc:video:1
用冒号作为分割是设计key的一种不成文的原则,遵循这种格式设计出的key在某些redis客户端下可以有效的识别
- 简洁性【建议】
保证语义的前提下,控制key的长度,当key较多时,内存占用也不容忽视,例如:
user:{uid}:friends:messages:{mid}
简化为
u:{uid}:fr:m:{mid}。
- 不要包含特殊字符【强制】
反例:包含空格、换行、单双引号以及其他转义字符
9.2 value设计
-
拒绝bigkey(防止网卡流量、慢查询)
string类型控制在10KB以内,hash、list、set、zset元素个数不要超过5000。反例:一个包含200万个元素的list。
非字符串的bigkey,不要使用del删除,使用hscan、sscan、zscan方式渐进式删除,同时要注意防止bigkey过期时间自动删除问题(例如一个200万的zset设置1小时过期,会触发del操作,造成阻塞,而且该操作不会不出现在慢查询中(latency可查)),删除方法
//1. Hash删除: hscan + hdel
public void delBigHash(String host, int port, String password, String bigHashKey) {
Jedis jedis = new Jedis(host, port);
if (password != null && !"".equals(password)) {
jedis.auth(password);
}
ScanParams scanParams = new ScanParams().count(100);
String cursor = "0";
do {
ScanResult<Entry<String, String>> scanResult = jedis.hscan(bigHashKey, cursor, scanParams);
List<Entry<String, String>> entryList = scanResult.getResult();
if (entryList != null && !entryList.isEmpty()) {
for (Entry<String, String> entry : entryList) {
jedis.hdel(bigHashKey, entry.getKey());
}
}
cursor = scanResult.getStringCursor();
} while (!"0".equals(cursor));
//删除bigkey
jedis.del(bigHashKey);
}
//2. List删除: ltrim
public void delBigList(String host, int port, String password, String bigListKey) {
Jedis jedis = new Jedis(host, port);
if (password != null && !"".equals(password)) {
jedis.auth(password);
}
long llen = jedis.llen(bigListKey);
int counter = 0;
int left = 100;
while (counter < llen) {
//每次从左侧截掉100个
jedis.ltrim(bigListKey, left, llen);
counter += left;
}
//最终删除key
jedis.del(bigListKey);
}
//3. Set删除: sscan + srem
public void delBigSet(String host, int port, String password, String bigSetKey) {
Jedis jedis = new Jedis(host, port);
if (password != null && !"".equals(password)) {
jedis.auth(password);
}
ScanParams scanParams = new ScanParams().count(100);
String cursor = "0";
do {
ScanResult<String> scanResult = jedis.sscan(bigSetKey, cursor, scanParams);
List<String> memberList = scanResult.getResult();
if (memberList != null && !memberList.isEmpty()) {
for (String member : memberList) {
jedis.srem(bigSetKey, member);
}
}
cursor = scanResult.getStringCursor();
} while (!"0".equals(cursor));
//删除bigkey
jedis.del(bigSetKey);
}
//4. SortedSet删除: zscan + zrem
public void delBigZset(String host, int port, String password, String bigZsetKey) {
Jedis jedis = new Jedis(host, port);
if (password != null && !"".equals(password)) {
jedis.auth(password);
}
ScanParams scanParams = new ScanParams().count(100);
String cursor = "0";
do {
ScanResult<Tuple> scanResult = jedis.zscan(bigZsetKey, cursor, scanParams);
List<Tuple> tupleList = scanResult.getResult();
if (tupleList != null && !tupleList.isEmpty()) {
for (Tuple tuple : tupleList) {
jedis.zrem(bigZsetKey, tuple.getElement());
}
}
cursor = scanResult.getStringCursor();
} while (!"0".equals(cursor));
//删除bigkey
jedis.del(bigZsetKey);
}
- 选择适合的数据类型【推荐】
例如:实体类型(要合理控制和使用数据结构内存编码优化配置,例如ziplist,但也要注意节省内存和性能之间的平衡)
//反例:
set user:1:name tom
set user:1:age 19
set user:1:favor football
//正例:
hmset user:1 name tom age 19 favor football
- 控制key的生命周期,redis不是垃圾桶【推荐】
建议使用expire设置过期时间(条件允许可以打散过期时间,防止集中过期),不过期的数据重点关注idletime。