① 添加积分:在签到的基础上添加用户积分(签到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

Redis排行 redis排行榜积分 时间用户id_redis

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

获取当前登录用户的排行情况:

Redis排行 redis排行榜积分 时间用户id_spring boot_02

这种方式看上去比较简单,如果数据量小的话运行应该也没有什么大问题,但如果当数据量超过一定量以后,就会出现很大的延迟,毕竟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

Redis排行 redis排行榜积分 时间用户id_java_03

{
    "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,底层是将ZREVRANGEZSCORE指令进行组装。因此使用起来非常方便。
  • **个人排名:**使用REVRANKZSCORE操作进行读取;
/**
 * 积分业务逻辑层
 */
@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. 项目测试

Redis排行 redis排行榜积分 时间用户id_java_04

{
    "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中也存在积分;

Redis排行 redis排行榜积分 时间用户id_spring_05

Redis排行 redis排行榜积分 时间用户id_Redis排行_06

2. MySQL 接口压测

① 使用5000个并发进行压力测试接口:

Redis排行 redis排行榜积分 时间用户id_java_07

② MySQL 数据库获取积分排行榜接口:

Redis排行 redis排行榜积分 时间用户id_spring boot_08

③ 执行结果:

Redis排行 redis排行榜积分 时间用户id_spring_09

3. Redis 接口压测

① 使用5000个并发进行压力测试接口:

Redis排行 redis排行榜积分 时间用户id_spring boot_10

② Redis 数据库获取积分排行榜接口:

Redis排行 redis排行榜积分 时间用户id_Redis排行_11

③ 测试结果:

Redis排行 redis排行榜积分 时间用户id_Redis排行_12

使用Sorted Sets优势:

  • Redis本身内存数据库,读取性能高;
  • Sorted Sets底层是SkipList + ZipList既能保证有序又能对数据进行压缩存储;
  • Sorted Sets操作简单,几个命令搞定;