① 添加积分:在签到的基础上添加用户积分(签到1天送10积分,连续签到2天送20积分,3天送30积分,4天以上均送50积分)
② 积分排行榜设计
1. 数据库表
CREATE TABLE `t_diner_points` (
`id` int(11) NOT NULL AUTO_INCREMENT ,
`fk_diner_id` int(11) NULL DEFAULT NULL ,
`points` int(11) NULL DEFAULT NULL ,
`types` int(11) NULL DEFAULT NULL COMMENT '积分类型:0=签到,1=关注好友,2=添加评论,3=点赞商户' ,
`is_valid` int(11) NULL DEFAULT NULL ,
`create_date` datetime NULL DEFAULT NULL ,
`update_date` datetime NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)ENGINE=InnoDB
DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_general_ci
AUTO_INCREMENT=1
ROW_FORMAT=COMPACT;
2. 创建 ms-points 模块
① 引入依赖
<dependencies>
<!-- eureka client -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- spring web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- mysql -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- spring data redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- mybatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<!-- commons 公共项目 -->
<dependency>
<groupId>com.imooc</groupId>
<artifactId>commons</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!-- test 单元测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
② SpringBoot 配置:
server:
port: 8086 # 端口
spring:
application:
name: ms-points # 应用名
# 数据库
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: root
url: jdbc:mysql://127.0.0.1:3306/orgnization?serverTimezone=Asia/Shanghai&characterEncoding=utf8&useUnicode=true&useSSL=false
# Redis
redis:
port: 6379
host: 192.168.38.22
timeout: 3000
database: 1
# swagger
swagger:
base-package: com.hh.points
title: 美食社交食客API接口文档
# 配置 Eureka Server 注册中心
eureka:
instance:
prefer-ip-address: true
instance-id: ${spring.cloud.client.ip-address}:${server.port}
client:
service-url:
defaultZone: http://localhost:8080/eureka/
service:
name:
ms-oauth-server: http://ms-oauth2-server/
ms-diners-server: http://ms-diners/
mybatis:
configuration:
map-underscore-to-camel-case: true # 开启驼峰映射
logging:
pattern:
console: '%d{2100-01-01 13:14:00.666} [%thread] %-5level %logger{50} - %msg%n'
Rest配置类和Redis配置类和全局异常处理:
③ RedisTemplateConfiguration
@Configuration
public class RedisTemplateConfiguration {
/**
* redisTemplate 序列化使用的jdkSerializeable, 存储二进制字节码, 所以自定义序列化类
* @param redisConnectionFactory
* @return
*/
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
// 使用Jackson2JsonRedisSerialize 替换默认序列化
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
// 设置key和value的序列化规则
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
④ RestTemplateConfiguration
@Configuration
public class RestTemplateConfiguration {
@LoadBalanced
@Bean
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate();
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
converter.setSupportedMediaTypes(Collections.singletonList(MediaType.TEXT_PLAIN));
restTemplate.getMessageConverters().add(converter);
return restTemplate;
}
}
3. 新增用户积分
1. 数据库实体类
/**
* 实体对象公共属性
*/
@Getter
@Setter
public class BaseModel implements Serializable {
private Integer id;
private Date createDate;
private Date updateDate;
private int isValid;
}
@Getter
@Setter
public class DinerPoints extends BaseModel {
@ApiModelProperty("关联DinerId")
private Integer fkDinerId;
@ApiModelProperty("积分")
private Integer points;
@ApiModelProperty(name = "类型",example = "0=签到,1=关注好友,2=添加Feed,3=添加商户评论")
private Integer types;
}
2. 积分控制层 DinerPointsController
/**
* 积分控制层
*/
@RestController
public class DinerPointsController {
@Resource
private DinerPointsService dinerPointsService;
@Resource
private HttpServletRequest request;
/**
* 添加积分
*
* @param dinerId 食客ID
* @param points 积分
* @param types 类型 0=签到,1=关注好友,2=添加Feed,3=添加商户评论
*/
@PostMapping
public ResultInfo<Integer> addPoints(@RequestParam(required = false) Integer dinerId,
@RequestParam(required = false) Integer points,
@RequestParam(required = false) Integer types) {
dinerPointsService.addPoints(dinerId, points, types);
return ResultInfoUtil.buildSuccess(request.getServletPath(), points);
}
}
3. 积分业务逻辑层 DinerPointsService
/**
* 积分业务逻辑层
*/
@Service
public class DinerPointsService {
@Resource
private DinerPointsMapper dinerPointsMapper;
@Resource
private RestTemplate restTemplate;
@Resource
private RedisTemplate redisTemplate;
@Value("${service.name.ms-oauth-server}")
private String oauthServerName;
@Value("${service.name.ms-diners-server}")
private String dinersServerName;
// 排行榜 TOPN
private static final int TOPN = 20;
/**
* 添加积分
*
* @param dinerId 食客ID
* @param points 积分
* @param types 类型 0=签到,1=关注好友,2=添加Feed,3=添加商户评论
*/
@Transactional(rollbackFor = Exception.class)
public void addPoints(Integer dinerId, Integer points, Integer types) {
// 基本参数校验
AssertUtil.isTrue(dinerId == null || dinerId < 1, "食客不能为空");
AssertUtil.isTrue(points == null || points < 1, "积分不能为空");
AssertUtil.isTrue(types == null, "请选择对应的积分类型");
// 插入数据库
DinerPoints dinerPoints = new DinerPoints();
dinerPoints.setFkDinerId(dinerId);
dinerPoints.setPoints(points);
dinerPoints.setTypes(types);
dinerPointsMapper.save(dinerPoints);
}
}
@Getter
public enum RedisKeyConstant {
/**
* redis 的 key
*/
verify_code("verify_code:", "验证码"),
seckill_vouchers("seckill_vouchers:", "秒杀券的key"),
lock_key("lockby:", "分布式锁的key"),
following("following:", "关注集合Key"),
followers("followers:", "粉丝集合key"),
following_feeds("following_feeds:", "我关注的好友的FeedsKey"),
diner_points("diner:points", "diner用户的积分Key"),
;
private String key;
private String desc;
RedisKeyConstant(String key, String desc) {
this.key = key;
this.desc = desc;
}
}
4. 数据交互层 DinerPointsMapper
/**
* 积分 Mapper
*/
public interface DinerPointsMapper {
// 添加积分
@Insert("insert into t_diner_points (fk_diner_id, points, types, is_valid, create_date, update_date) " +
" values (#{fkDinerId}, #{points}, #{types}, 1, now(), now())")
void save(DinerPoints dinerPoints);
}
5. 网关 ms-gateway 服务添加路由
- id: ms-points
uri: lb://ms-points
predicates:
- Path=/points/**
filters:
- StripPrefix=1
6. 食客 ms-diners 服务添加用户积分
① 在 ms-diners 服务中添加 ms-points 服务的地址:
# oauth2 服务地址
service:
name:
ms-oauth-server: http://ms-oauth2-server/
ms-points-server: http://ms-points/
② 积分类型 PointTypesConstant :
@Getter
public enum PointTypesConstant {
/**
* 积分类型
*/
sign(0),
follow(1),
feed(2),
review(3)
;
private int type;
PointTypesConstant(int key) {
this.type = key;
}
}
③ 签到业务逻辑层 SignService
/**
* 签到业务逻辑层
*/
@Service
public class SignService {
@Value("${service.name.ms-oauth-server}")
private String oauthServerName;
@Value("${service.name.ms-points-server}")
private String pointsServerName;
@Resource
private RestTemplate restTemplate;
@Resource
private RedisTemplate redisTemplate;
/**
* 用户签到
*
* @param accessToken 登录凭证
* @param dateStr 签到日期 2022-10-14
*/
public int doSign(String accessToken, String dateStr) {
// 获取登录用户信息
SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
// 获取日期
Date date = getDate(dateStr);
// 获得指定日期是所在月份的第几天:2020-10-14,返回14,代表这个月份的第14天
int dayOfMonth = DateUtil.dayOfMonth(date);
// 偏移量 offset 从 0 开始
int offset = dayOfMonth - 1;
// 构建 Key user:sign:5:yyyyMM
String signKey = buildSignKey(dinerInfo.getId(), date);
// 查看是否已签到
boolean isSigned = redisTemplate.opsForValue().getBit(signKey, offset);
AssertUtil.isTrue(isSigned, "当前日期已完成签到,无需再签");
// 签到
redisTemplate.opsForValue().setBit(signKey, offset, true);
// 统计连续签到的次数
int count = getContinuousSignCount(dinerInfo.getId(), date);
int points = addPoints(count, dinerInfo.getId());
return points;
}
/**
* 添加用户积分
*
* @param count 连续签到次数
* @param signInDinerId 登录用户id
* @return 获取的积分
*/
private int addPoints(int count, Integer signInDinerId) {
// 签到1天送10积分,连续签到2天送20积分,3天送30积分,4天以上均送50积分
int points = 10;
if (count == 2) {
points = 20;
} else if (count == 3) {
points = 30;
} else if (count >= 4) {
points = 50;
}
// 调用积分接口添加积分
// 构建请求头
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
// 构建请求体(请求参数)
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("dinerId", signInDinerId);
body.add("points", points);
body.add("types", PointTypesConstant.sign.getType());
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
// 发送请求
ResponseEntity<ResultInfo> result = restTemplate.postForEntity(pointsServerName,
entity, ResultInfo.class);
AssertUtil.isTrue(result.getStatusCode() != HttpStatus.OK, "登录失败!");
ResultInfo resultInfo = result.getBody();
if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {
// 失败了, 事物要进行回滚
throw new ParameterException(resultInfo.getCode(), resultInfo.getMessage());
}
return points;
}
}
7. 项目测试
http://localhost/diners/sign?access_token=c11e67a8-32e9-4630-bb29-b37c7a317b3b
truncate t_diner_point
4. 关系型数据库-积分排行榜TopN
- 读取数据库中积分,排行榜取TopN,显示字段有:用户id、用户昵称、头像、总积分以及排行榜
- 需要标记当前登录用户的排行情况
1. 构造数据
@SpringBootTest
@AutoConfigureMockMvc
public class PointsApplicationTests {
@Resource
protected MockMvc mockMvc;
}
class DinerPointsControllerTest extends PointsApplicationTests {
// 初始化 2W 条积分记录
@Test
void addPoints() throws Exception {
List<Map<Integer, Integer[]>> dinerInfos = Lists.newArrayList();
for (int i = 1; i <= 2000; i++) {
for (int j = 0; j < 10; j++) {
super.mockMvc.perform(MockMvcRequestBuilders.post("/")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("dinerId", i + "")
.param("points", RandomUtil.randomNumbers(2))
.param("types", "0")
).andExpect(MockMvcResultMatchers.status().isOk()).andReturn();
}
}
}
}
其实这个类似于一张日志表,因此数据量是非常庞大的,当我们想要统计用户积分做排行榜的的时候,比如:获取积分排行榜Top20,显示字段有:用户id、用户昵称、头像、总积分以及排行榜
SELECT
t1.fk_diner_id AS id,
sum( t1.points ) AS total,
rank () over ( ORDER BY sum( t1.points ) DESC ) AS ranks,
t2.nickname,
t2.avatar_url
FROM
t_diner_points t1
LEFT JOIN t_diners t2 ON t1.fk_diner_id = t2.id
WHERE
t1.is_valid = 1
AND t2.is_valid = 1
GROUP BY
t1.fk_diner_id
ORDER BY
total DESC
LIMIT 20
获取当前登录用户的排行情况:
这种方式看上去比较简单,如果数据量小的话运行应该也没有什么大问题,但如果当数据量超过一定量以后,就会出现很大的延迟,毕竟MySQL查询是要消耗大量的IO的。我们后面可以测试一下。
2. 视图对象 DinerPointsRankVO
@ApiModel(description = "用户积分总排行榜")
@Getter
@Setter
public class DinerPointsRankVO extends ShortDinerInfo {
@ApiModelProperty("总积分")
private int total;
@ApiModelProperty("排名")
private int ranks;
@ApiModelProperty(value = "是否是自己", example = "0=否,1=是")
private int isMe;
}
@Getter
@Setter
@ApiModel(description = "关注食客信息")
public class ShortDinerInfo implements Serializable {
@ApiModelProperty("主键")
public Integer id;
@ApiModelProperty("昵称")
private String nickname;
@ApiModelProperty("头像")
private String avatarUrl;
}
3. 积分控制层 DinerPointsController
/**
* 积分控制层
*/
@RestController
public class DinerPointsController {
@Resource
private DinerPointsService dinerPointsService;
@Resource
private HttpServletRequest request;
/**
* 查询前 20 积分排行榜,同时显示用户排名 -- MySQL
*/
@GetMapping
public ResultInfo findDinerPointsRank(String access_token) {
List<DinerPointsRankVO> ranks = dinerPointsService.findDinerPointRank(access_token);
return ResultInfoUtil.buildSuccess(request.getServletPath(), ranks);
}
}
4. 积分业务逻辑层 DinerPointsService
/**
* 积分业务逻辑层
*/
@Service
public class DinerPointsService {
@Resource
private DinerPointsMapper dinerPointsMapper;
@Resource
private RestTemplate restTemplate;
@Resource
private RedisTemplate redisTemplate;
@Value("${service.name.ms-oauth-server}")
private String oauthServerName;
@Value("${service.name.ms-diners-server}")
private String dinersServerName;
// 排行榜 TOPN
private static final int TOPN = 20;
/**
* 查询前 20 积分排行榜,并显示个人排名 -- MySQL
*/
public List<DinerPointsRankVO> findDinerPointRank(String accessToken) {
// 获取登录用户信息
SignInDinerInfo signInDinerInfo = loadSignInDinerInfo(accessToken);
// 统计积分排行榜
List<DinerPointsRankVO> ranks = dinerPointsMapper.findTopN(TOPN);
if (ranks == null || ranks.isEmpty()) {
return Lists.newArrayList();
}
// 根据 key:食客 ID value:积分信息 构建一个 Map
Map<Integer, DinerPointsRankVO> ranksMap = ranks.stream()
.collect(Collectors.toMap(DinerPointsRankVO::getId, dinerPointsRankVO -> dinerPointsRankVO));
// 判断个人是否在 ranks 中,如果在,添加标记直接返回
if (ranksMap.containsKey(signInDinerInfo.getId())) {
DinerPointsRankVO myRank = ranksMap.get(signInDinerInfo.getId());
myRank.setIsMe(1);
return Lists.newArrayList(ranksMap.values());
}
// 如果不在 ranks 中,获取个人排名追加在最后
DinerPointsRankVO myRank = dinerPointsMapper.findDinerRank(signInDinerInfo.getId());
myRank.setIsMe(1);
ranks.add(myRank);
return ranks;
}
}
5. 数据交互层 DinerPointsMapper
/**
* 积分 Mapper
*/
public interface DinerPointsMapper {
// 添加积分
@Insert("insert into t_diner_points (fk_diner_id, points, types, is_valid, create_date, update_date) " +
" values (#{fkDinerId}, #{points}, #{types}, 1, now(), now())")
void save(DinerPoints dinerPoints);
// 查询积分排行榜 TOPN
@Select("SELECT t1.fk_diner_id AS id, " +
" sum( t1.points ) AS total, " +
" rank () over ( ORDER BY sum( t1.points ) DESC ) AS ranks," +
" t2.nickname, t2.avatar_url " +
" FROM t_diner_points t1 LEFT JOIN t_diners t2 ON t1.fk_diner_id = t2.id " +
" WHERE t1.is_valid = 1 AND t2.is_valid = 1 " +
" GROUP BY t1.fk_diner_id " +
" ORDER BY total DESC LIMIT #{top}")
List<DinerPointsRankVO> findTopN(@Param("top") int top);
// 根据食客 ID 查询当前食客的积分排名
@Select("SELECT id, total, ranks, nickname, avatar_url FROM (" +
" SELECT t1.fk_diner_id AS id, " +
" sum( t1.points ) AS total, " +
" rank () over ( ORDER BY sum( t1.points ) DESC ) AS ranks," +
" t2.nickname, t2.avatar_url " +
" FROM t_diner_points t1 LEFT JOIN t_diners t2 ON t1.fk_diner_id = t2.id " +
" WHERE t1.is_valid = 1 AND t2.is_valid = 1 " +
" GROUP BY t1.fk_diner_id " +
" ORDER BY total DESC ) r " +
" WHERE id = #{dinerId}")
DinerPointsRankVO findDinerRank(@Param("dinerId") int dinerId);
}
6. 项目测试
http://localhost/points?access_token=5373e27f-2331-44ec-b8e9-779deb8db903
{
"code": 1,
"message": "Successful.",
"path": "/",
"data": [
{
"id": 37,
"nickname": "test",
"avatarUrl": null,
"total": 796,
"ranks": 1,
"isMe": 0
},
{
"id": 1089,
"nickname": "test",
"avatarUrl": null,
"total": 778,
"ranks": 2,
"isMe": 0
},
{
"id": 1746,
"nickname": "test",
"avatarUrl": null,
"total": 768,
"ranks": 3,
"isMe": 0
},
{
"id": 1041,
"nickname": "test",
"avatarUrl": null,
"total": 754,
"ranks": 4,
"isMe": 0
},
{
"id": 291,
"nickname": "test",
"avatarUrl": null,
"total": 747,
"ranks": 5,
"isMe": 0
},
{
"id": 1750,
"nickname": "test",
"avatarUrl": null,
"total": 737,
"ranks": 6,
"isMe": 0
},
{
"id": 1428,
"nickname": "test",
"avatarUrl": null,
"total": 733,
"ranks": 7,
"isMe": 0
},
{
"id": 1660,
"nickname": "test",
"avatarUrl": null,
"total": 732,
"ranks": 8,
"isMe": 0
},
{
"id": 711,
"nickname": "test",
"avatarUrl": null,
"total": 729,
"ranks": 9,
"isMe": 0
},
{
"id": 252,
"nickname": "test",
"avatarUrl": null,
"total": 724,
"ranks": 10,
"isMe": 0
},
{
"id": 670,
"nickname": "test",
"avatarUrl": null,
"total": 721,
"ranks": 11,
"isMe": 0
},
{
"id": 328,
"nickname": "test",
"avatarUrl": null,
"total": 719,
"ranks": 12,
"isMe": 0
},
{
"id": 627,
"nickname": "test",
"avatarUrl": null,
"total": 715,
"ranks": 13,
"isMe": 0
},
{
"id": 94,
"nickname": "test",
"avatarUrl": null,
"total": 713,
"ranks": 14,
"isMe": 0
},
{
"id": 528,
"nickname": "test",
"avatarUrl": null,
"total": 713,
"ranks": 14,
"isMe": 0
},
{
"id": 1948,
"nickname": "test",
"avatarUrl": null,
"total": 711,
"ranks": 16,
"isMe": 0
},
{
"id": 769,
"nickname": "test",
"avatarUrl": null,
"total": 710,
"ranks": 17,
"isMe": 0
},
{
"id": 1841,
"nickname": "test",
"avatarUrl": null,
"total": 710,
"ranks": 17,
"isMe": 0
},
{
"id": 1833,
"nickname": "test",
"avatarUrl": null,
"total": 709,
"ranks": 19,
"isMe": 0
},
{
"id": 308,
"nickname": "test",
"avatarUrl": null,
"total": 706,
"ranks": 20,
"isMe": 0
},
{
"id": 14,
"nickname": "test",
"avatarUrl": null,
"total": 505,
"ranks": 918,
"isMe": 1
}
]
}
因为t_diner_points本质上是一张日志表,记录了所有用户的积分记录,因此直接去数据库统计的话会有如下问题:
- SQL编写复杂
- 数据量大,执行统计SQL慢;
- 高并发下会拖累其他业务表的操作,导致系统变慢;
5. Redis-积分排行榜TopN
使用 Sorted Sets 保存用户的积分总数,因为 Sorted Sets 有 score 属性,能够方便保存与读取,使用指令:
# 添加元素的分数,如果member不存在就会自动创建
ZINCRBY key increment member
# 按分数从大到小进行读取
zrevrange key
# 根据分数从大到小获取member排名
zrevrank key member
1. 修改添加积分方法 DinerPointsService
当将用户积分记录插入数据库后,同时利用ZINCRBY
指令,将数据存入Redis中,这里不使用ZADD
的原因是当食客不存在记录要插入,而且存在时需要将分数累加。
/**
* 添加积分
*
* @param dinerId 食客ID
* @param points 积分
* @param types 类型 0=签到,1=关注好友,2=添加Feed,3=添加商户评论
*/
public void addPoints(Integer dinerId, Integer points, Integer types) {
// 基本参数校验
AssertUtil.isTrue(dinerId == null || dinerId < 1, "食客不能为空");
AssertUtil.isTrue(points == null || points < 1, "积分不能为空");
AssertUtil.isTrue(types == null, "请选择对应的积分类型");
// 插入数据库
DinerPoints dinerPoints = new DinerPoints();
dinerPoints.setFkDinerId(dinerId);
dinerPoints.setPoints(points);
dinerPoints.setTypes(types);
dinerPointsMapper.save(dinerPoints);
// 将积分保存到 Redis 的 Sorted Sets 中
redisTemplate.opsForZSet().incrementScore(
RedisKeyConstant.diner_points.getKey(), dinerId, points);
}
2. 积分控制层 DinerPointsController
/**
* 积分控制层
*/
@RestController
public class DinerPointsController {
@Resource
private DinerPointsService dinerPointsService;
@Resource
private HttpServletRequest request;
/**
* 查询前 20 积分排行榜,同时显示用户排名 -- Redis
*/
@GetMapping("redis")
public ResultInfo findDinerPointsRankFromRedis(String access_token) {
List<DinerPointsRankVO> ranks = dinerPointsService.findDinerPointRankFromRedis(access_token);
return ResultInfoUtil.buildSuccess(request.getServletPath(), ranks);
}
}
3. 积分业务逻辑层 DinerPointsService
- **排行榜:**从Redis中根据diner:points的key按照score的排序进行读取,这里使用Redis的
ZREVRANGE
指令,但在ZREVRANGE
指令只返回member,不返回score,在RedisTemplate的ZSetOperations中有一个一个API方法叫reverseRangeWithScores(key, start, end)
其中start从0开始,返回的是member和score,底层是将ZREVRANGE
与ZSCORE
指令进行组装。因此使用起来非常方便。 - **个人排名:**使用
REVRANK
和ZSCORE
操作进行读取;
/**
* 积分业务逻辑层
*/
@Service
public class DinerPointsService {
@Resource
private DinerPointsMapper dinerPointsMapper;
@Resource
private RestTemplate restTemplate;
@Resource
private RedisTemplate redisTemplate;
@Value("${service.name.ms-oauth-server}")
private String oauthServerName;
@Value("${service.name.ms-diners-server}")
private String dinersServerName;
// 排行榜 TOPN
private static final int TOPN = 20;
/**
* 查询前 20 积分排行榜,并显示个人排名 -- Redis
*/
public List<DinerPointsRankVO> findDinerPointRankFromRedis(String accessToken) {
// 获取登录用户信息
SignInDinerInfo signInDinerInfo = loadSignInDinerInfo(accessToken);
// 统计积分排行榜
Set<ZSetOperations.TypedTuple<Integer>> rangeWithScores = redisTemplate.opsForZSet().reverseRangeWithScores(
RedisKeyConstant.diner_points.getKey(), 0, 19);
if (rangeWithScores == null || rangeWithScores.isEmpty()) {
return Lists.newArrayList();
}
// 初始化食客 ID 集合
List<Integer> rankDinerIds = Lists.newArrayList();
// 根据 key:食客 ID value:积分信息 构建一个 Map
Map<Integer, DinerPointsRankVO> ranksMap = new LinkedHashMap<>();
// 初始化排名
int rank = 1;
// 循环处理排行榜,添加排名信息
for (ZSetOperations.TypedTuple<Integer> rangeWithScore : rangeWithScores) {
// 食客ID
Integer dinerId = rangeWithScore.getValue();
// 积分
int points = rangeWithScore.getScore().intValue();
// 将食客 ID 添加至食客 ID 集合
rankDinerIds.add(dinerId);
DinerPointsRankVO dinerPointsRankVO = new DinerPointsRankVO();
dinerPointsRankVO.setId(dinerId);
dinerPointsRankVO.setRanks(rank);
dinerPointsRankVO.setTotal(points);
// 将 VO 对象添加至 Map 中
ranksMap.put(dinerId, dinerPointsRankVO);
// 排名 +1
rank++;
}
// 获取 Diners 用户信息
ResultInfo resultInfo = restTemplate.getForObject(dinersServerName +
"findByIds?access_token=${accessToken}&ids={ids}",
ResultInfo.class, accessToken, StrUtil.join(",", rankDinerIds));
if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {
throw new ParameterException(resultInfo.getCode(), resultInfo.getMessage());
}
List<LinkedHashMap> dinerInfoMaps = (List<LinkedHashMap>) resultInfo.getData();
// 完善食客昵称和头像
for (LinkedHashMap dinerInfoMap : dinerInfoMaps) {
ShortDinerInfo shortDinerInfo = BeanUtil.fillBeanWithMap(dinerInfoMap,
new ShortDinerInfo(), false);
DinerPointsRankVO rankVO = ranksMap.get(shortDinerInfo.getId());
rankVO.setNickname(shortDinerInfo.getNickname());
rankVO.setAvatarUrl(shortDinerInfo.getAvatarUrl());
}
// 判断个人是否在 ranks 中,如果在,添加标记直接返回
if (ranksMap.containsKey(signInDinerInfo.getId())) {
DinerPointsRankVO rankVO = ranksMap.get(signInDinerInfo.getId());
rankVO.setIsMe(1);
return Lists.newArrayList(ranksMap.values());
}
// 如果不在 ranks 中,获取个人排名追加在最后
// 获取排名
Long myRank = redisTemplate.opsForZSet().reverseRank(
RedisKeyConstant.diner_points.getKey(), signInDinerInfo.getId());
if (myRank != null) {
DinerPointsRankVO me = new DinerPointsRankVO();
BeanUtils.copyProperties(signInDinerInfo, me);
me.setRanks(myRank.intValue() + 1);// 排名从 0 开始
me.setIsMe(1);
// 获取积分
Double points = redisTemplate.opsForZSet().score(RedisKeyConstant.diner_points.getKey(),
signInDinerInfo.getId());
me.setTotal(points.intValue());
ranksMap.put(signInDinerInfo.getId(), me);
}
return Lists.newArrayList(ranksMap.values());
}
}
4. 项目测试
{
"code": 1,
"message": "Successful.",
"path": "/redis",
"data": [
{
"id": 1862,
"nickname": "test",
"avatarUrl": null,
"total": 804,
"ranks": 1,
"isMe": 0
},
{
"id": 1785,
"nickname": "test",
"avatarUrl": null,
"total": 759,
"ranks": 2,
"isMe": 0
},
{
"id": 529,
"nickname": "test",
"avatarUrl": null,
"total": 757,
"ranks": 3,
"isMe": 0
},
{
"id": 273,
"nickname": "test",
"avatarUrl": null,
"total": 753,
"ranks": 4,
"isMe": 0
},
{
"id": 382,
"nickname": "test",
"avatarUrl": null,
"total": 748,
"ranks": 5,
"isMe": 0
},
{
"id": 29,
"nickname": "test",
"avatarUrl": null,
"total": 748,
"ranks": 6,
"isMe": 0
},
{
"id": 617,
"nickname": "test",
"avatarUrl": null,
"total": 737,
"ranks": 7,
"isMe": 0
},
{
"id": 630,
"nickname": "test",
"avatarUrl": null,
"total": 731,
"ranks": 8,
"isMe": 0
},
{
"id": 1168,
"nickname": "test",
"avatarUrl": null,
"total": 729,
"ranks": 9,
"isMe": 0
},
{
"id": 1427,
"nickname": "test",
"avatarUrl": null,
"total": 726,
"ranks": 10,
"isMe": 0
},
{
"id": 1578,
"nickname": "test",
"avatarUrl": null,
"total": 725,
"ranks": 11,
"isMe": 0
},
{
"id": 930,
"nickname": "test",
"avatarUrl": null,
"total": 723,
"ranks": 12,
"isMe": 0
},
{
"id": 1432,
"nickname": "test",
"avatarUrl": null,
"total": 721,
"ranks": 13,
"isMe": 0
},
{
"id": 1083,
"nickname": "test",
"avatarUrl": null,
"total": 721,
"ranks": 14,
"isMe": 0
},
{
"id": 48,
"nickname": "test",
"avatarUrl": null,
"total": 715,
"ranks": 15,
"isMe": 0
},
{
"id": 1345,
"nickname": "test",
"avatarUrl": null,
"total": 714,
"ranks": 16,
"isMe": 0
},
{
"id": 1332,
"nickname": "test",
"avatarUrl": null,
"total": 713,
"ranks": 17,
"isMe": 0
},
{
"id": 850,
"nickname": "test",
"avatarUrl": null,
"total": 711,
"ranks": 18,
"isMe": 0
},
{
"id": 1738,
"nickname": "test",
"avatarUrl": null,
"total": 710,
"ranks": 19,
"isMe": 0
},
{
"id": 1586,
"nickname": "test",
"avatarUrl": null,
"total": 709,
"ranks": 20,
"isMe": 0
},
{
"id": 14,
"nickname": "test",
"avatarUrl": null,
"total": 606,
"ranks": 251,
"isMe": 1
}
]
}
5. 使用 JMeter 压测对比
1. 构造数据
清空表数据:truncate t_diner_points,重新构造数据条件积分,让redis中也存在积分;
2. MySQL 接口压测
① 使用5000个并发进行压力测试接口:
② MySQL 数据库获取积分排行榜接口:
③ 执行结果:
3. Redis 接口压测
① 使用5000个并发进行压力测试接口:
② Redis 数据库获取积分排行榜接口:
③ 测试结果:
使用Sorted Sets优势:
- Redis本身内存数据库,读取性能高;
- Sorted Sets底层是SkipList + ZipList既能保证有序又能对数据进行压缩存储;
- Sorted Sets操作简单,几个命令搞定;