SpringBoot2 | 第三十篇:Redis 实现热搜商品

上一篇写了 Redishash 实现购物车,Redis 其他的数据类型也很好用。比如说 zsetzset 每个元素都会关联一个score,由于 score 这个特殊的存在,它可以用在任何与排序有关的场景,如 排行榜热搜商品等等

[TOC]

zset

介绍

zset 类似 set,但它的每一个元素都关联一个叫 score 的浮点数值; zset 可以通过 score 进行排序(包括正序和倒序)。我们可以通过对指定元素的score 进行修改来改变该元素的排名。zset 的成员是唯一的,但 score(分数)可以重复。

zset 的命令很多,这篇用到了两个 zincrbyzrevrange

Redis 命令 Spring Data Redis 描述
zincrby key increment member Double incrementScore(K key, V value, double delta) 给指定元素的 scoredelta
zrevrange key max min [WITHSCORES] [LIMIT offset count] Set<V> reverseRangeByScore(K key, double min, double max, long offset, long count) 从表头到表尾遍历 ziplistskiplist,返回给定索引范围内的元素

编码

zset 的底层编码是ziplistskiplist

当有序集合对象可以同时满足以下两个条件时,对象使用 ziplist 编码:

  • 有序集合保存的元素数量小于 128 个;
  • 有序集合保存的所有元素成员的长度小于 64 位。

不能同时满足上面两个条件则使用 skiplist 编码。当 zset 使用 skiplist 编码时,zset 对象包含一个字典(dict)和一个跳跃表(skiplist)

环境/版本一览:

  • 开发工具:Intellij IDEA 2019.1.3
  • springboot: 2.1.7.RELEASE
  • jdk:1.8.0_171
  • maven:3.3.9
  • mybatis-plus:3.1.2
  • mysql-connector-java:5.1.47

1、pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 整合MP -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.1.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 整合Lettuce Redis需要commons-pool2 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

2、application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
# 基本属性
url: jdbc:mysql://localhost:3306/chapter30?useUnicode=true&characterEncoding=UTF-8&allowMultiQueries=true&serverTimezone=UTC&useSSL=false
username: root
password: 123456

redis:
host: localhost
password: 123456
database: 1
lettuce:
pool:
# 当池耗尽时,在引发异常之前连接分配可以阻塞的最长时间(使用负值表示没有限制) 默认 -1
max-wait: -1ms
# 连接池最大连接数(使用负值表示没有限制) 默认 8
max-active: 8
# 连接池中的最大空闲连接 默认 8
max-idle: 8
# 连接池中的最小空闲连接 默认 0
min-idle: 0
# 连接超时时间
timeout: 10000ms

3、sql

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
CREATE DATABASE /*!32312 IF NOT EXISTS*/`chapter30` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;

USE `chapter30`;

/*Table structure for table `goods` */

DROP TABLE IF EXISTS `goods`;

CREATE TABLE `goods` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`content` text COLLATE utf8mb4_bin,
`name` varchar(16) COLLATE utf8mb4_bin DEFAULT NULL,
`price` bigint(20) DEFAULT NULL,
`shop_id` bigint(20) DEFAULT NULL,
`shop_name` varchar(32) COLLATE utf8mb4_bin DEFAULT NULL,
`stock` int(11) DEFAULT NULL,
`picture` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
`create_time` timestamp NULL DEFAULT NULL,
`update_time` timestamp NULL DEFAULT NULL,
`status` int(11) DEFAULT NULL,
`max` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

/*Data for the table `goods` */

insert into `goods`(`id`,`content`,`name`,`price`,`shop_id`,`shop_name`,`stock`,`picture`,`create_time`,`update_time`,`status`,`max`) values (1,'冻的','雪碧',22000,123456,'Fatal Shop',100,'http://...pic...123.png','2019-08-14 18:08:17','2019-08-14 18:08:17',1,1000),(2,'也是冻的','柠檬茶',11000,123456,'Fatal Shop',100,'http://...pic...123.png','2019-08-15 17:54:52','2019-08-15 17:54:52',1,1000),(3,'冻的冻的。。。','果粒奶优',12000,123456,'Fatal Shop',100,'http://...pic...123.png','2019-08-15 17:57:31','2019-08-15 17:57:31',0,1000),(4,'冻的。','果粒奶优',11000,123457,'MiCai Shop',100,'http://...pic...123.png','2019-08-15 17:58:45','2019-08-15 17:58:45',1,1000),(5,'鲜牛奶好喝又健康...','纯牛奶',8000,123457,'MiCai Shop',100,'http://...pic...123.png','2019-08-15 18:00:13','2019-08-15 18:00:13',1,1000),(6,'爽快...','菠萝啤',15000,123457,'MiCai Shop',100,'http://...pic...123.png','2019-08-15 18:02:21','2019-08-15 18:02:21',1,1000);

4、common

constants

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.fatal.common.constants;

/**
* 购物车常量
* @author Fatal
* @date 2019/8/15 0015 8:16
*/
public interface HotSearchConstant {

/**
* 热门搜索key前缀
*/
String HOT_SEARCH = "HOT_SEARCH";


/**
* 前几名
*/
// Integer TOP_NUMBER = 10;
Integer TOP_NUMBER = 3;

}

enums

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.fatal.common.enums;

import lombok.Getter;

/**
* 状态枚举
* @author Fatal
* @date 2019/8/15 0015 17:49
*/
@Getter
public enum StatusEnums {

/**
* 正常
*/
NORMAL(1, "NORMAL"),

/**
* 已删除
*/
DELETED(0, "DELETED"),

/**
* 下架
*/
PROHIBIT(-1, "PROHIBIT");

private Integer code;

private String message;

StatusEnums(Integer code, String message) {
this.code = code;
this.message = message;
}

}

5、config

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.fatal.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.io.Serializable;

/**
* Redis 配置类
* @author Fatal
* @date 2019/8/22 0022 9:35
*/
@Configuration
public class RedisConfig {

@Bean
public RedisTemplate<String, Serializable> redisTemplate(LettuceConnectionFactory connectionFactory) {
RedisTemplate<String, Serializable> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(keyRedisSerializer());
redisTemplate.setValueSerializer(genericJackson2JsonRedisSerializer());
redisTemplate.setConnectionFactory(connectionFactory);
return redisTemplate;
}

@Bean
public RedisSerializer keyRedisSerializer () {
return new StringRedisSerializer();
}

@Bean
public GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}

}

6、entity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
package com.fatal.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import lombok.experimental.Accessors;

import java.time.LocalDateTime;

/**
* @author Fatal
* @date 2019/8/14 0014 17:15
*/
@Data
@Accessors(chain = true)
public class Goods {

/**
* 商品ID
* @desc ID_WORKER: 分布式全局唯一ID 长整型类型
*/
@TableId(type = IdType.ID_WORKER)
private Long id;

/**
* 店铺ID
*/
private Long shopId;

/**
* 店铺名称
*/
private String shopName;

/**
* 商品名称
*/
private String name;

/**
* 商品单价(单位:分)
*/
private Long price;

/**
* 商品库存
*/
private Integer stock;

/**
* 商品详情
*/
private String content;

/**
* 商品主图
*/
private String picture;

/**
* 创建时间
*/
private LocalDateTime createTime;

/**
* 更新时间
*/
private LocalDateTime updateTime;

/**
* 商品状态:-1 下架; 0 删除; 1 在架
*/
private Integer status;

}

7、dto

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package com.fatal.dto;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.fatal.entity.Goods;
import lombok.Data;
import lombok.experimental.Accessors;
import org.springframework.beans.BeanUtils;

/**
* @author Fatal
* @date 2019/8/14 0014 17:15
*/
@Data
@Accessors(chain = true)
public class GoodsDTO {

/**
* 商品ID
* @desc ID_WORKER: 分布式全局唯一ID 长整型类型
*/
@TableId(type = IdType.ID_WORKER)
private Long id;

/**
* 店铺ID
*/
private Long shopId;

/**
* 店铺名称
*/
private String shopName;

/**
* 商品名称
*/
private String name;

/**
* 商品单价(单位:分)
*/
private Long price;

/**
* 商品库存
*/
private Integer stock;

/**
* 商品详情
*/
private String content;

/**
* 商品主图
*/
private String picture;

/**
* 商品状态:-1 下架; 0 删除; 1 在架
*/
private Integer status;

public static GoodsDTO of(Goods goods) {
GoodsDTO dto = new GoodsDTO();
BeanUtils.copyProperties(goods, dto);
return dto;
}

}

8、mapper

1
2
3
4
5
6
7
8
9
10
11
12
package com.fatal.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fatal.entity.Goods;

/**
* 商品数据库访问层
* @author Fatal
* @date 2019/8/19 0019 20:11
*/
public interface GoodsMapper extends BaseMapper<Goods> {
}

9、service

IGoodsService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package com.fatal.service;

import com.fatal.dto.GoodsDTO;

import java.util.List;

/**
* 商品服务
* @author Fatal
* @date 2019/8/22 0022 9:19
*/
public interface IGoodsService {

/**
* 查看商品详情
* @param id
* @return
*/
GoodsDTO getDetails(Long id);

/**
* 获取所有的ID(方便做测试数据)
* @return
*/
List<Long> getIds();

}

IHotSearchService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package com.fatal.service;

import java.io.Serializable;
import java.util.List;
import java.util.Map;

/**
* 热搜服务
* @author Fatal
* @date 2019/8/22 0022 9:55
*/
public interface IHotSearchService {

/**
* 指定商品的搜索次数加一
* @param goodsId 商品ID
*/
void increment(Long goodsId);

/**
* 获取热搜列表
* @return
*/
List<Long> hotSearchList();

/**
* 获取热搜列表(带score)
* @return
*/
Map<Serializable, Double> hotSearchWithScoreList();

}

GoodsServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package com.fatal.service.impl;

import com.fatal.dto.GoodsDTO;
import com.fatal.entity.Goods;
import com.fatal.mapper.GoodsMapper;
import com.fatal.service.IGoodsService;
import com.fatal.service.IHotSearchService;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

/**
* 商品服务实现
* @author Fatal
* @date 2019/8/22 0022 9:20
*/
@Service
public class GoodsServiceImpl implements IGoodsService {

private GoodsMapper goodsMapper;
private IHotSearchService hotSearchService;

public GoodsServiceImpl(GoodsMapper goodsMapper, IHotSearchService hotSearchService) {
this.goodsMapper = goodsMapper;
this.hotSearchService = hotSearchService;
}

@Override
public GoodsDTO getDetails(Long id) {
Goods goods = Optional.ofNullable(goodsMapper.selectById(id))
.orElseThrow(RuntimeException::new);
hotSearchService.increment(goods.getId());
return GoodsDTO.of(goods);
}

@Override
public List<Long> getIds() {
List<Goods> goods = goodsMapper.selectList(null);
return CollectionUtils.isEmpty(goods) ?
new ArrayList<>() : goods.stream()
.map(Goods::getId)
.collect(Collectors.toList());
}

}

HotSearchServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
package com.fatal.service.impl;

import com.fatal.common.constants.HotSearchConstant;
import com.fatal.service.IHotSearchService;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;

import java.io.Serializable;
import java.util.*;
import java.util.stream.Collectors;

/**
* 热搜服务实现
* @author Fatal
* @date 2019/8/22 0022 9:55
*/
@Service
public class HotSearchServiceImpl implements IHotSearchService {

private ZSetOperations<String, Serializable> zSetOperations;

public HotSearchServiceImpl(RedisTemplate<String, Serializable> redisTemplate) {
this.zSetOperations = redisTemplate.opsForZSet();
}

@Override
public void increment(Long goodsId) {
zSetOperations.incrementScore(HotSearchConstant.HOT_SEARCH, goodsId, 1);
}

/**
* @desc reverseRangeByScore(..)底层用LinkedHashSet封装,所以是有序的。
* 对应 Redis 命令: zrevrangebyscore key max min [WITHSCORES] [LIMIT offset count]
* @return
*/
@Override
public List<Long> hotSearchList() {
Set<Serializable> idSet = zSetOperations.reverseRangeByScore(HotSearchConstant.HOT_SEARCH,
0, Double.MAX_VALUE, 0, HotSearchConstant.TOP_NUMBER);
if (!CollectionUtils.isEmpty(idSet)) {
List<Long> ids = idSet.stream()
.map(this::toLong)
.collect(Collectors.toList());
return ids;
}
return new ArrayList<>();
}

/**
* @desc reverseRangeByScoreWithScores(..)底层用LinkedHashSet封装,所以是有序的。
* 对应 Redis 命令: zrevrangebyscore key max min [WITHSCORES] [LIMIT offset count]
* @return
*/
@Override
public Map<Serializable, Double> hotSearchWithScoreList() {
Set<ZSetOperations.TypedTuple<Serializable>> typedTuples =
zSetOperations.reverseRangeByScoreWithScores(HotSearchConstant.HOT_SEARCH,
0, Double.MAX_VALUE, 0 , HotSearchConstant.TOP_NUMBER);
if (CollectionUtils.isEmpty(typedTuples)) {
return new LinkedHashMap<>(16);
}
return typedTuples.stream()
.collect(
Collectors.toMap(
ZSetOperations.TypedTuple::getValue,
ZSetOperations.TypedTuple::getScore,
// key 重复时执行的方法
(u,v) -> { throw new IllegalStateException(String.format("Duplicate key %s", u));},
LinkedHashMap::new
)
);
}

private Long toLong(Serializable id) {
return id instanceof Long ?
(Long) id :
Long.valueOf(((Integer) id).longValue());
}
}

10、Test

GoodsMapperTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.fatal.mapper;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fatal.Chapter30ApplicationTests;
import com.fatal.common.enums.StatusEnums;
import com.fatal.entity.Goods;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

/**
* @author Fatal
* @date 2019/8/19 0019 20:12
*/
public class GoodsMapperTest extends Chapter30ApplicationTests {

@Autowired
private GoodsMapper goodsMapper;

@Test
public void selectByIdTest() {
Goods goods = goodsMapper.selectById(1L);
System.out.println(goods);
}

@Test
public void selectListTest() {
List<Goods> list = goodsMapper.selectList(
new LambdaQueryWrapper<Goods>()
.eq(Goods::getShopName, "Fatal Shop")
);
print(list);
}

@Test
public void insertTest() {
goodsMapper.insert(new Goods()
.setName("巧克力冰淇淋")
.setShopId(123458L)
.setShopName("MiCai Shop")
.setPicture("http://...pic...123.png")
.setPrice(5000L)
.setContent("买二送一")
.setCreateTime(LocalDateTime.now())
.setUpdateTime(LocalDateTime.now())
.setStock(100)
.setStatus(StatusEnums.NORMAL.getCode()));
}

private <T> void print(List<T> list) {
Optional.ofNullable(list)
.ifPresent(System.out::println);
}

}

IGoodsServiceTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.fatal.service;

import com.fatal.Chapter30ApplicationTests;
import com.fatal.dto.GoodsDTO;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;

import static org.junit.Assert.*;

/**
* @author Fatal
* @date 2019/8/22 0022 10:12
*/
public class IGoodsServiceTest extends Chapter30ApplicationTests {

@Autowired
private IGoodsService goodsService;

@Test
public void getDetails() {
GoodsDTO details = goodsService.getDetails(1164345124273057799L);
System.out.println(details);
}
}

IHotSearchServiceTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package com.fatal.service;

import com.fatal.Chapter30ApplicationTests;
import com.fatal.common.constants.HotSearchConstant;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundZSetOperations;
import org.springframework.data.redis.core.RedisTemplate;

import java.io.Serializable;
import java.util.List;
import java.util.Map;
import java.util.Random;

/**
* @author Fatal
* @date 2019/8/22 0022 10:29
*/
public class IHotSearchServiceTest extends Chapter30ApplicationTests {

@Autowired
private IHotSearchService hotSearchService;

@Autowired
private IGoodsService goodsService;

@Autowired
private RedisTemplate<String, Serializable> redisTemplate;

@Test
public void init() {
BoundZSetOperations<String, Serializable> zSetOperations = redisTemplate.boundZSetOps(HotSearchConstant.HOT_SEARCH);
Random random = new Random();
List<Long> ids = goodsService.getIds();
ids.forEach(id -> {
int score = random.nextInt(100000);
zSetOperations.incrementScore(id, score);
});
}

@Test
public void incrementTest() {
hotSearchService.increment(1164345124273057799L);
}

@Test
public void hotSearchListTest() {
List<Long> ids = hotSearchService.hotSearchList();
ids.forEach(System.out::println);
}

@Test
public void hotSearchWithScoreListTest() {
Map<Serializable, Double> map = hotSearchService.hotSearchWithScoreList();
map.entrySet().forEach(System.out::println);
}
}

11、测试

测试整合 MP

运行 IGoodsServiceTest.selectListTest()

1566467468712

测试查看商品详情

初始化数据

运行 IHotSearchServiceTest.init()

1566467601589

查看商品详情,商品热搜度 +1

运行 IGoodsServiceTest.getDetails()

控制台正常

1566467708138

Redis 视图化界面

1566467759544

测试查看热搜商品列表

由于测试数据较少,所以我设置了 TOP 数量为 4

不带 score

运行 IHotSearchServiceTest.hotSearchListTest()

控制台

1566468183772

带 score

运行 IHotSearchServiceTest.hotSearchWithScoreListTest()

控制台

1566468220803

参考资料

MyBatis-Plus

Redis 中文官方网站

总结

SpringBoot的知识已经有前辈在我们之前探索了。比较喜欢的博主有:唐亚峰 | Battcn方志朋的专栏程序猿DD纯洁的微笑。对这门技术感兴趣的可以去他们的博客逛逛。谢谢他们的分享~~

以上文章是我用来学习的Demo,都是基于 SpringBoot2.x 版本。

源码地址: https://github.com/ynfatal/springboot2-learning/tree/master/chapter30