您现在的位置是:首页 >学无止境 >活动需求中灵活使用Redis提升生产力网站首页学无止境

活动需求中灵活使用Redis提升生产力

高阳很捷迅 2023-05-25 00:00:02
简介活动需求中灵活使用Redis提升生产力

抽奖

一堆用户参与进来,然后随机抽取几个幸运用户给予实物/虚拟的奖品;此时,开发人员就需要写上一个抽奖的算法,来实现幸运用户的抽取;其实我们完全可以利用Redis的集合(Set),就能轻松实现抽奖的功能;

功能实现需要的API

  • SADD key member1 [member2]:添加一个或者多个参与用户;
  • SRANDMEMBER KEY [count]:随机返回一个或者多个用户;
  • SPOP key:随机返回一个或者多个用户,并删除返回的用户;

SRANDMEMBER 和 SPOP 主要用于两种不同的抽奖模式,SRANDMEMBER 适用于一个用户可中奖多次的场景(就是中奖之后,不从用户池中移除,继续参与其他奖项的抽取);而 SPOP 就适用于仅能中一次的场景(一旦中奖,就将用户从用户池中移除,后续的抽奖,就不可能再抽到该用户); 通常 SPOP 会用的会比较多。

Redis-Cli操作

127.0.0.1:6379> SADD raffle user1
(integer) 1
127.0.0.1:6379> SADD raffle user2 user3 user4 user5 user6 user7 user8 user9 user10
(integer) 9
127.0.0.1:6379> SRANDMEMBER raffle 2
1) "user5"
2) "user2"
127.0.0.1:6379> SPOP raffle 2
1) "user3"
2) "user4"
127.0.0.1:6379> SPOP raffle 2
1) "user10"
2) "user9"

代码实现

@Slf4j
@SpringBootTest
public class RaffleMain {
    private final String KEY_RAFFLE_PROFIX = "raffle:";
    @Autowired
    RedisTemplate redisTemplate;

    @Test
    void test() {
        Integer raffleId = 1;
        join(raffleId, 1000, 1001, 2233, 7890, 44556, 74512);
        List lucky = lucky(raffleId, 2);
        log.info("活动:{} 的幸运中奖用户是:{}", raffleId, lucky);
    }

    public void join(Integer raffleId, Integer... userIds) {
        String key = KEY_RAFFLE_PROFIX + raffleId;
        redisTemplate.opsForSet().add(key, userIds);
    }

    public List lucky(Integer raffleId, long num) {
        String key = KEY_RAFFLE_PROFIX + raffleId;
        // 随机抽取 抽完之后将用户移除奖池
        List list = redisTemplate.opsForSet().pop(key, num);
        // 随机抽取 抽完之后用户保留在池子里
        //List list = redisTemplate.opsForSet().randomMembers(key, num);
        return list;
    }

}

点赞收藏

有互动属性活动一般都会有点赞/收藏/喜欢等功能,来提升用户之间的互动。

传统的实现:用户点赞之后,在数据库中记录一条数据,同时一般都会在主题库中记录一个点赞/收藏汇总数,来方便显示;

Redis方案:基于Redis的集合(Set),记录每个帖子/文章对应的收藏、点赞的用户数据,同时set还提供了检查集合中是否存在指定用户,用户快速判断用户是否已经点赞过

功能实现需要的API

  • SADD key member1 [member2]:添加一个或者多个成员(点赞)
  • SCARD key:获取所有成员的数量(点赞数量)
  • SISMEMBER key member:判断成员是否存在(是否点赞)
  • SREM key member1 [member2] :移除一个或者多个成员(点赞数量)

Redis-Cli操作

127.0.0.1:6379> sadd like:article:1 user1
(integer) 1
127.0.0.1:6379> sadd like:article:1 user2
(integer) 1
# 获取成员数量(点赞数量)
127.0.0.1:6379> SCARD like:article:1
(integer) 2
# 判断成员是否存在(是否点赞)
127.0.0.1:6379> SISMEMBER like:article:1 user1
(integer) 1
127.0.0.1:6379> SISMEMBER like:article:1 user3
(integer) 0
# 移除一个或者多个成员(取消点赞)
127.0.0.1:6379> SREM like:article:1 user1
(integer) 1
127.0.0.1:6379> SCARD like:article:1
(integer) 1

代码实现

@Slf4j
@SpringBootTest
public class LikeMain {
    private final String KEY_LIKE_ARTICLE_PROFIX = "like:article:";

    @Autowired
    RedisTemplate redisTemplate;

    @Test
    void test() {
        long articleId = 100;
        Long likeNum = like(articleId, 1001, 1002, 2001, 3005, 4003);
        unLike(articleId, 2001);
        likeNum = likeNum(articleId);
        boolean b2001 = isLike(articleId, 2001);
        boolean b3005 = isLike(articleId, 3005);
        log.info("文章:{} 点赞数量:{} 用户2001的点赞状态:{} 用户3005的点赞状态:{}", articleId, likeNum, b2001, b3005);
    }

    /**
     * 点赞
     *
     * @param articleId 文章ID
     * @return 点赞数量
     */
    public Long like(Long articleId, Integer... userIds) {
        String key = KEY_LIKE_ARTICLE_PROFIX + articleId;
        Long add = redisTemplate.opsForSet().add(key, userIds);
        return add;
    }

    public Long unLike(Long articleId, Integer... userIds) {
        String key = KEY_LIKE_ARTICLE_PROFIX + articleId;
        Long remove = redisTemplate.opsForSet().remove(key, userIds);
        return remove;
    }

    public Long likeNum(Long articleId) {
        String key = KEY_LIKE_ARTICLE_PROFIX + articleId;
        Long size = redisTemplate.opsForSet().size(key);
        return size;
    }

    public Boolean isLike(Long articleId, Integer userId) {
        String key = KEY_LIKE_ARTICLE_PROFIX + articleId;
        return redisTemplate.opsForSet().isMember(key, userId);
    }

}

排行榜

排名、排行榜、热搜榜是很多活动、游戏都有的功能,常用于用户活动推广、竞技排名、热门信息展示等功能;

比如上面的热搜榜,热度数据来源于全网用户的贡献,但用户只关心热度最高的前50条。

常规的做法:就是将用户的名次、分数等用于排名的数据更新到数据库,然后查询的时候通过Order by + limit 取出前50名显示,如果是参与用户不多,更新不频繁的数据,采用数据库的方式也没有啥问题,但是一旦出现爆炸性热点资讯(比如:大陆收复湾湾,xxx某些绿了等等),短时间会出现爆炸式的流量,瞬间的压力可能让数据库扛不住;

Redis方案:将热点资讯全页缓存,采用Redis的有序队列(Sorted Set)来缓存热度(SCORES),即可瞬间缓解数据库的压力,同时轻松筛选出热度最高的50条;

功能实现需要的命令

  • ZADD key score1 member1 [score2 member2]:添加并设置SCORES,支持一次性添加多个;
  • ZREVRANGE key start stop [WITHSCORES] :根据SCORES降序排列;
  • ZRANGE key start stop [WITHSCORES] :根据SCORES降序排列;

Redis-Cli操作

# 单个插入
127.0.0.1:6379> ZADD ranking 1 user1  
(integer) 1
# 批量插入
127.0.0.1:6379> ZADD ranking 10 user2 50 user3 3 user4 25 user5
(integer) 4
# 降序排列 不带SCORES
127.0.0.1:6379> ZREVRANGE ranking 0 -1 
1) "user3"
2) "user5"
3) "user2"
4) "user4"
5) "user1"
# 降序排列 带SCORES
127.0.0.1:6379> ZREVRANGE ranking 0 -1 WITHSCORES
1) "user3"
2) "50"
3) "user5"
4) "25"
5) "user2"
6) "10"
7) "user4"
8) "3"
9) "user1"
10) "1"
# 升序
127.0.0.1:6379> ZRANGE ranking 0 -1 WITHSCORES
1) "user1"
2) "1"
3) "user4"
4) "3"
5) "user2"
6) "10"
7) "user5"
8) "25"
9) "user3"
10) "50"

代码实现

@SpringBootTest
@Slf4j
public class RankingTest {
    private final String KEY_RANKING = "ranking";
    @Autowired
    RedisTemplate redisTemplate;

    @Test
    void test() {
        add(1001, (double) 60);
        add(1002, (double) 80);
        add(1003, (double) 100);
        add(1004, (double) 90);
        add(1005, (double) 70);

        // 取所有
        Set<DefaultTypedTuple> range = range(0, -1);
        log.info("所有用户排序:{}", range);

        // 前三名
        range = range(0, 2);
        log.info("前三名排序:{}", range);
    }

    public Boolean add(Integer userId, Double score) {
        Boolean add = redisTemplate.opsForZSet().add(KEY_RANKING, userId, score);
        return add;
    }

    public Set<DefaultTypedTuple> range(long min, long max) {
        // 降序
        Set<DefaultTypedTuple> set = redisTemplate.opsForZSet().reverseRangeWithScores(KEY_RANKING, min, max);
        // 升序
        //Set<DefaultTypedTuple> set = redisTemplate.opsForZSet().rangeWithScores(KEY_RANKING, min, max);
        return set;
    }
}

用户签到(BitMap)

很多活动为了拉动用户活跃度,会加入比如连续签到的功能

传统做法:用户每次签到时,往是数据库插入一条签到数据,展示的时候,把本月(或者指定周期)的签到数据获取出来,用于判断用户是否签到、以及连续签到情况;此方式,简单,理解容易;

Redis做法:由于签到数据的关注点就2个:是否签到(0/1)、连续性,因此就完全可以利用BitMap(位图)来实现;

如上图所示,将一个月的31天,用31个位(4个字节)来表示,偏移量(offset)代表当前是第几天,0/1表示当前是否签到,连续签到只需从右往左校验连续为1的位数;

由于String类型的最大上限是512M,转换为bit则是2^32个bit位。

所需命令:

  • SETBIT key offset value:向指定位置offset存入一个0或1
  • GETBIT key offset:获取指定位置offset的bit值
  • BITCOUNT key [start] [end]:统计BitMap中值为1的bit位的数量
  • BITFIELD: 操作(查询,修改,自增)BitMap中bit 数组中的指定位置offset的值这里最不容易理解的就是:BITFIELD,详情可参考:https://deepinout.com/redis-cmd/redis-bitmap-cmd/redis-cmd-bitfield.html 而且这部分还必须理解了,否则,该需求的核心部分就没办法理解了;

需求:假如当前为8月4号,检测本月的签到情况,用户分别于1、3、4号签到过

Redis-Cli操作

# 8月1号的签到
127.0.0.1:6379> SETBIT RangeId:Sign:1:8899 0 1
(integer) 1

# 8月3号的签到
127.0.0.1:6379> SETBIT RangeId:Sign:1:8899 2 1
(integer) 1

# 8月4号的签到
127.0.0.1:6379> SETBIT RangeId:Sign:1:8899 3 1
(integer) 1

# 查询各天的签到情况
# 查询1号
127.0.0.1:6379> GETBIT RangeId:Sign:1:8899 0
(integer) 1
# 查询2号
127.0.0.1:6379> GETBIT RangeId:Sign:1:8899 1
(integer) 0
# 查询3号
127.0.0.1:6379> GETBIT RangeId:Sign:1:8899 2
(integer) 1
# 查询4号
127.0.0.1:6379> GETBIT RangeId:Sign:1:8899 3
(integer) 1

# 查询指定区间的签到情况
127.0.0.1:6379> BITFIELD RangeId:Sign:1:8899 get u4 0
1) (integer) 11

1-4号的签到情况为:1011(二进制) ==> 11(十进制)

代码实现-按月签到

@Slf4j
@Service
public class SignByMonthServiceImpl {

    @Autowired
    StringRedisTemplate stringRedisTemplate;

    private int dayOfMonth() {
        DateTime dateTime = new DateTime();
        return dateTime.dayOfMonth().get();
    }

    /**
     * 按照月份和用户ID生成用户签到标识 UserId:Sign:560:2021-08
     *
     * @param userId 用户id
     * @return
     */
    private String signKeyWitMouth(String userId) {
        DateTime dateTime = new DateTime();
        DateTimeFormatter fmt = DateTimeFormat.forPattern("yyyy-MM");

        StringBuilder builder = new StringBuilder("UserId:Sign:");
        builder.append(userId).append(":")
                .append(dateTime.toString(fmt));
        return builder.toString();
    }

    /**
     * 设置标记位
     * 标记是否签到
     *
     * @param key
     * @param offset
     * @param tag
     * @return
     */
    public Boolean sign(String key, long offset, boolean tag) {
        return this.stringRedisTemplate.opsForValue().setBit(key, offset, tag);
    }


    /**
     * 统计计数
     *
     * @param key 用户标识
     * @return
     */
    public long bitCount(String key) {
        return stringRedisTemplate.execute((RedisCallback<Long>) redisConnection -> redisConnection.bitCount(key.getBytes()));
    }

    /**
     * 获取多字节位域
     */
    public List<Long> bitfield(String buildSignKey, int limit, long offset) {
        return this.stringRedisTemplate
                .opsForValue()
                .bitField(buildSignKey, BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(limit)).valueAt(offset));
    }


    /**
     * 判断是否被标记
     *
     * @param key
     * @param offest
     * @return
     */
    public Boolean container(String key, long offest) {
        return this.stringRedisTemplate.opsForValue().getBit(key, offest);
    }


    /**
     * 用户今天是否签到
     *
     * @param userId
     * @return
     */
    public int checkSign(String userId) {
        DateTime dateTime = new DateTime();

        String signKey = this.signKeyWitMouth(userId);
        int offset = dateTime.getDayOfMonth() - 1;
        int value = this.container(signKey, offset) ? 1 : 0;
        return value;
    }


    /**
     * 查询用户当月签到日历
     *
     * @param userId
     * @return
     */
    public Map<String, Boolean> querySignedInMonth(String userId) {
        DateTime dateTime = new DateTime();
        int lengthOfMonth = dateTime.dayOfMonth().getMaximumValue();
        Map<String, Boolean> signedInMap = new HashMap<>(dateTime.getDayOfMonth());

        String signKey = this.signKeyWitMouth(userId);
        List<Long> bitfield = this.bitfield(signKey, lengthOfMonth, 0);
        if (!CollectionUtils.isEmpty(bitfield)) {
            long signFlag = bitfield.get(0) == null ? 0 : bitfield.get(0);

            DateTimeFormatter fmt = DateTimeFormat.forPattern("yyyy-MM-dd");
            for (int i = lengthOfMonth; i > 0; i--) {

                DateTime dateTime1 = dateTime.withDayOfMonth(i);
                signedInMap.put(dateTime1.toString(fmt), signFlag >> 1 << 1 != signFlag);
                signFlag >>= 1;
            }
        }
        return signedInMap;
    }


    /**
     * 用户签到
     *
     * @param userId
     * @return
     */
    public boolean signWithUserId(String userId) {
        int dayOfMonth = this.dayOfMonth();
        String signKey = this.signKeyWitMouth(userId);
        long offset = (long) dayOfMonth - 1;
        boolean re = false;
        if (Boolean.TRUE.equals(this.sign(signKey, offset, Boolean.TRUE))) {
            re = true;
        }

        // 查询用户连续签到次数,最大连续次数为7天
        long continuousSignCount = this.queryContinuousSignCount(userId, 7);
        return re;
    }

    /**
     * 统计当前月份一共签到天数
     *
     * @param userId
     */
    public long countSignedInDayOfMonth(String userId) {
        String signKey = this.signKeyWitMouth(userId);
        return this.bitCount(signKey);
    }


    /**
     * 查询用户当月连续签到次数
     *
     * @param userId
     * @return
     */
    public long queryContinuousSignCountOfMonth(String userId) {
        int signCount = 0;
        String signKey = this.signKeyWitMouth(userId);
        int dayOfMonth = this.dayOfMonth();
        List<Long> bitfield = this.bitfield(signKey, dayOfMonth, 0);

        if (!CollectionUtils.isEmpty(bitfield)) {
            long signFlag = bitfield.get(0) == null ? 0 : bitfield.get(0);
            DateTime dateTime = new DateTime();
            // 连续不为0即为连续签到次数,当天未签到情况下
            for (int i = 0; i < dateTime.getDayOfMonth(); i++) {
                if (signFlag >> 1 << 1 == signFlag) {
                    if (i > 0) break;
                } else {
                    signCount += 1;
                }
                signFlag >>= 1;
            }
        }
        return signCount;
    }


    /**
     * 以7天一个周期连续签到次数
     *
     * @param period 周期
     * @return
     */
    public long queryContinuousSignCount(String userId, Integer period) {
        //查询目前连续签到次数
        long count = this.queryContinuousSignCountOfMonth(userId);
        //按最大连续签到取余
        if (period != null && period < count) {
            long num = count % period;
            if (num == 0) {
                count = period;
            } else {
                count = num;
            }
        }
        return count;
    }
}

测试

@SpringBootTest
@Slf4j
public class SignTest2 {
    @Autowired
    private SignByMonthServiceImpl signByMonthService;

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 测试用户按月签到
     */
    @Test
    public void querySignDay() {
        //模拟用户签到
        //for(int i=5;i<19;i++){
        redisTemplate.opsForValue().setBit("UserId:Sign:560:2022-08", 0, true);
        //}

        System.out.println("560用户今日是否已签到:" + this.signByMonthService.checkSign("560"));
        Map<String, Boolean> stringBooleanMap = this.signByMonthService.querySignedInMonth("560");
        System.out.println("本月签到情况:");
        for (Map.Entry<String, Boolean> entry : stringBooleanMap.entrySet()) {
            System.out.println(entry.getKey() + ": " + (entry.getValue() ? "√" : "-"));
        }
        long countSignedInDayOfMonth = this.signByMonthService.countSignedInDayOfMonth("560");
        System.out.println("本月一共签到:" + countSignedInDayOfMonth + "天");
        System.out.println("目前连续签到:" + this.signByMonthService.queryContinuousSignCount("560", 7) + "天");
    }
}

代码实现-指定时间签到

@Slf4j
@Service
public class SignByRangeServiceImpl {

    @Autowired
    StringRedisTemplate stringRedisTemplate;

    /**
     * 根据区间的id 以及用户id 拼接key
     *
     * @param rangeId 区间ID 一般是指定活动的ID等
     * @param userId  用户的ID
     * @return
     */
    private String signKey(Integer rangeId, Integer userId) {
        StringBuilder builder = new StringBuilder("RangeId:Sign:");
        builder.append(rangeId).append(":")
                .append(userId);
        return builder.toString();
    }

    /**
     * 获取当前时间与起始时间的间隔天数
     *
     * @param start 起始时间
     * @return
     */
    private int intervalTime(LocalDateTime start) {
        return (int) (LocalDateTime.now().toLocalDate().toEpochDay() - start.toLocalDate().toEpochDay());
    }

    /**
     * 设置标记位
     * 标记是否签到
     *
     * @param key    签到的key
     * @param offset 偏移量 一般是指当前时间离起始时间(活动开始)的天数
     * @param tag    是否签到  true:签到  false:未签到
     * @return
     */
    private Boolean setBit(String key, long offset, boolean tag) {
        return this.stringRedisTemplate.opsForValue().setBit(key, offset, tag);
    }

    /**
     * 统计计数
     *
     * @param key 统计的key
     * @return
     */
    private long bitCount(String key) {
        return stringRedisTemplate.execute((RedisCallback<Long>) redisConnection -> redisConnection.bitCount(key.getBytes()));
    }

    /**
     * 获取多字节位域
     *
     * @param key    缓存的key
     * @param limit  获取多少
     * @param offset 偏移量是多少
     * @return
     */
    private List<Long> bitfield(String key, int limit, long offset) {
        return this.stringRedisTemplate
                .opsForValue()
                .bitField(key, BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(limit)).valueAt(offset));
    }

    /**
     * 判断是否签到
     *
     * @param key    缓存的key
     * @param offest 偏移量 指当前时间距离起始时间的天数
     * @return
     */
    private Boolean container(String key, long offest) {
        return this.stringRedisTemplate.opsForValue().getBit(key, offest);
    }

    /**
     * 根据起始时间进行签到
     *
     * @param rangeId
     * @param userId
     * @param start
     * @return
     */
    public Boolean sign(Integer rangeId, Integer userId, LocalDateTime start) {
        int offset = intervalTime(start);
        String key = signKey(rangeId, userId);
        return setBit(key, offset, true);
    }

    /**
     * 根据偏移量签到
     *
     * @param rangeId
     * @param userId
     * @param offset
     * @return
     */
    public Boolean sign(Integer rangeId, Integer userId, long offset) {
        String key = signKey(rangeId, userId);
        return setBit(key, offset, true);
    }

    /**
     * 用户今天是否签到
     *
     * @param userId
     * @return
     */
    public Boolean checkSign(Integer rangeId, Integer userId, LocalDateTime start) {
        long offset = intervalTime(start);
        String key = this.signKey(rangeId, userId);
        return this.container(key, offset);
    }

    /**
     * 统计当前月份一共签到天数
     *
     * @param userId
     */
    public long countSigned(Integer rangeId, Integer userId) {
        String signKey = this.signKey(rangeId, userId);
        return this.bitCount(signKey);
    }

    public Map<String, Boolean> querySigned(Integer rangeId, Integer userId, LocalDateTime start) {
        int days = intervalTime(start);
        Map<String, Boolean> signedInMap = new HashMap<>(days);

        String signKey = this.signKey(rangeId, userId);
        List<Long> bitfield = this.bitfield(signKey, days + 1, 0);
        if (!CollectionUtils.isEmpty(bitfield)) {
            long signFlag = bitfield.get(0) == null ? 0 : bitfield.get(0);

            DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd");
            for (int i = days; i >= 0; i--) {
                LocalDateTime localDateTime = start.plusDays(i);
                signedInMap.put(localDateTime.format(fmt), signFlag >> 1 << 1 != signFlag);
                signFlag >>= 1;
            }
        }
        return signedInMap;
    }

    /**
     * 查询用户当月连续签到次数
     *
     * @param userId
     * @return
     */
    public long queryContinuousSignCount(Integer rangeId, Integer userId, LocalDateTime start) {
        int signCount = 0;
        String signKey = this.signKey(rangeId, userId);
        int days = this.intervalTime(start);
        List<Long> bitfield = this.bitfield(signKey, days + 1, 0);

        if (!CollectionUtils.isEmpty(bitfield)) {
            long signFlag = bitfield.get(0) == null ? 0 : bitfield.get(0);
            DateTime dateTime = new DateTime();
            // 连续不为0即为连续签到次数,当天未签到情况下
            for (int i = 0; i < dateTime.getDayOfMonth(); i++) {
                if (signFlag >> 1 << 1 == signFlag) {
                    if (i > 0) break;
                } else {
                    signCount += 1;
                }
                signFlag >>= 1;
            }
        }
        return signCount;
    }
}

测试

@SpringBootTest
@Slf4j
public class SignTest {
    @Autowired
    SignByRangeServiceImpl signByRangeService;


    @Test
    void test() {
        DateTimeFormatter isoDateTime = DateTimeFormatter.ISO_DATE_TIME;
        // 活动开始时间
        LocalDateTime start = LocalDateTime.of(2022, 8, 1, 1, 0, 0);
        Integer rangeId = 1;
        Integer userId = 8899;
        log.info("签到开始时间: {}", start.format(isoDateTime));
        log.info("活动ID: {} 用户ID: {}", rangeId, userId);

        // 手动指定偏移量签到
        signByRangeService.sign(rangeId, userId, 0);

        // 判断是否签到
        Boolean signed = signByRangeService.checkSign(rangeId, userId, start);
        log.info("今日是否签到: {}", signed ? "√" : "-");

        // 签到
        Boolean sign = signByRangeService.sign(rangeId, userId, start);
        log.info("签到操作之前的签到状态:{} (-:表示今日第一次签到,√:表示今天已经签到过了)", sign ? "√" : "-");

        // 签到总数
        long countSigned = signByRangeService.countSigned(rangeId, userId);
        log.info("总共签到: {} 天", countSigned);

        // 连续签到的次数
        long continuousSignCount = signByRangeService.queryContinuousSignCount(rangeId, userId, start);
        log.info("连续签到: {} 天", continuousSignCount);

        // 签到的详情
        Map<String, Boolean> stringBooleanMap = signByRangeService.querySigned(rangeId, userId, start);
        for (Map.Entry<String, Boolean> entry : stringBooleanMap.entrySet()) {
            log.info("签到详情> {} : {}", entry.getKey(), (entry.getValue() ? "√" : "-"));
        }
    }
}

GEO搜附近

活动中经常有展示附近用户、商家的诉求;

如果自己想要根据经纬度来实现一个搜索附近的功能,是非常麻烦的;但是Redis 在3.2的版本新增了Redis GEO,用于存储地址位置信息,并对支持范围搜索;基于GEO就能轻松且快速的开发一个搜索附近的功能;

GEO API 及Redis-cli 操作

geoadd:新增位置坐标:

127.0.0.1:6379> GEOADD drinks 116.62445 39.86206 starbucks 117.3514785 38.7501247 yidiandian 116.538542 39.75412 xicha
(integer) 3

geopos:获取位置坐标:

127.0.0.1:6379> GEOPOS drinks starbucks
1) 1) "116.62445157766342163"
   2) "39.86206038535793539"
127.0.0.1:6379> GEOPOS drinks starbucks yidiandian mxbc
1) 1) "116.62445157766342163"
   2) "39.86206038535793539"
2) 1) "117.35148042440414429"
   2) "38.75012383773680114"
3) (nil)

geodist:计算两个位置之间的距离,

单位参数:

  • m :米,默认单位。
  • km :千米。
  • mi :英里。
  • ft :英尺。
127.0.0.1:6379> GEODIST drinks starbucks yidiandian
"138602.4133"
127.0.0.1:6379> GEODIST drinks starbucks xicha
"14072.1255"
127.0.0.1:6379> GEODIST drinks starbucks xicha m
"14072.1255"
127.0.0.1:6379> GEODIST drinks starbucks xicha km
"14.0721"

georadius:根据用户给定的经纬度坐标来获取指定范围内的地理位置集合

参数说明:

    • m :米,默认单位。
    • km :千米。
    • mi :英里。
    • ft :英尺。
    • WITHDIST: 在返回位置元素的同时, 将位置元素与中心之间的距离也一并返回。
    • WITHCOORD: 将位置元素的经度和纬度也一并返回。
    • WITHHASH: 以 52 位有符号整数的形式, 返回位置元素经过原始 geohash 编码的有序集合分值。 这个选项主要用于底层应用或者调试, 实际中的作用并不大。
    • COUNT 限定返回的记录数。
    • ASC: 查找结果根据距离从近到远排序。
    • DESC: 查找结果根据从远到近排序。

127.0.0.1:6379> GEORADIUS drinks 116 39 100 km WITHDIST
1) 1) "xicha"
   2) "95.8085"
127.0.0.1:6379> GEORADIUS drinks 116 39 100 km WITHDIST WITHCOORD
1) 1) "xicha"
   2) "95.8085"
   3) 1) "116.53854042291641235"
      2) "39.75411928478748536"
127.0.0.1:6379> GEORADIUS drinks 116 39 100 km WITHDIST WITHCOORD WITHHASH
1) 1) "xicha"
   2) "95.8085"
   3) (integer) 4069151800882301
   4) 1) "116.53854042291641235"
      2) "39.75411928478748536"

127.0.0.1:6379> GEORADIUS drinks 116 39 120 km WITHDIST WITHCOORD  COUNT 1
1) 1) "xicha"
   2) "95.8085"
   3) 1) "116.53854042291641235"
      2) "39.75411928478748536"

127.0.0.1:6379> GEORADIUS drinks 116 39 120 km WITHDIST WITHCOORD  COUNT 1 ASC
1) 1) "xicha"
   2) "95.8085"
   3) 1) "116.53854042291641235"
      2) "39.75411928478748536"

127.0.0.1:6379> GEORADIUS drinks 116 39 120 km WITHDIST WITHCOORD  COUNT 1 DESC
1) 1) "starbucks"
   2) "109.8703"
   3) 1) "116.62445157766342163"
      2) "39.86206038535793539"

georadiusbymember:根据储存在位置集合里面的某个地点获取指定范围内的地理位置集合。功能和上面的georadius类似,只是georadius是以经纬度坐标为中心,这个是以某个地点为中心;

geohash:返回一个或多个位置对象的 geohash 值。

127.0.0.1:6379> GEOHASH drinks starbucks xicha
1) "wx4fvbem6d0"
2) "wx4f5vhb8b0"

代码实现

@SpringBootTest
@Slf4j
public class GEOTest {
    private final String KEY = "geo:drinks";

    @Autowired
    RedisTemplate redisTemplate;

    @Test
    public void test() {
        add("starbucks", new Point(116.62445, 39.86206));
        add("yidiandian", new Point(117.3514785, 38.7501247));
        add("xicha", new Point(116.538542, 39.75412));

        get("starbucks", "yidiandian", "xicha");

        GeoResults nearByXY = getNearByXY(new Point(116, 39), new Distance(120, Metrics.KILOMETERS));
        List<GeoResult> content = nearByXY.getContent();
        for (GeoResult geoResult : content) {
            log.info("{}", geoResult.getContent());
        }

        GeoResults nearByPlace = getNearByPlace("starbucks", new Distance(120, Metrics.KILOMETERS));
        content = nearByPlace.getContent();
        for (GeoResult geoResult : content) {
            log.info("{}", geoResult.getContent());
        }

        getGeoHash("starbucks", "yidiandian", "xicha");

        del("yidiandian", "xicha");
    }

    private void add(String name, Point point) {
        Long add = redisTemplate.opsForGeo().add(KEY, point, name);
        log.info("成功添加名称:{} 的坐标信息信息:{}", name, point);
    }


    private void get(String... names) {
        List<Point> position = redisTemplate.opsForGeo().position(KEY, names);
        log.info("获取名称为:{} 的坐标信息:{}", names, position);
    }

    private void del(String... names) {
        Long remove = redisTemplate.opsForGeo().remove(KEY, names);
        log.info("删除名称为:{} 的坐标信息数量:{}", names, remove);
    }

    /**
     * 根据坐标 获取指定范围的位置
     *
     * @param point
     * @param distance
     * @return
     */
    private GeoResults getNearByXY(Point point, Distance distance) {
        Circle circle = new Circle(point, distance);
        RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.
                newGeoRadiusArgs().
                includeDistance(). // 包含距离
                includeCoordinates(). // 包含坐标
                sortAscending(). // 排序 还可选sortDescending()
                limit(5); // 获取前多少个
        GeoResults geoResults = redisTemplate.opsForGeo().radius(KEY, circle, args);
        log.info("根据坐标获取:{} {} 范围的数据:{}", point, distance, geoResults);
        return geoResults;
    }

    /**
     * 根据一个位置,获取指定范围内的其他位置
     *
     * @param name
     * @param distance
     * @return
     */
    private GeoResults getNearByPlace(String name, Distance distance) {
        RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.
                newGeoRadiusArgs().
                includeDistance(). // 包含距离
                includeCoordinates(). // 包含坐标
                sortAscending(). // 排序 还可选sortDescending()
                limit(5); // 获取前多少个
        GeoResults geoResults = redisTemplate.opsForGeo()
                .radius(KEY, name, distance, args);
        log.info("根据位置:{} 获取: {} 范围的数据:{}", name, distance, geoResults);
        return geoResults;
    }

    /**
     * 获取GEO HASH
     *
     * @param names
     * @return
     */
    private List<String> getGeoHash(String... names) {
        List<String> hash = redisTemplate.opsForGeo().hash(KEY, names);
        log.info("names:{} 对应的hash:{}", names, hash);
        return hash;
    }
}

执行日志:

成功添加名称:starbucks 的坐标信息信息:Point [x=116.624450, y=39.862060]
成功添加名称:yidiandian 的坐标信息信息:Point [x=117.351479, y=38.750125]
成功添加名称:xicha 的坐标信息信息:Point [x=116.538542, y=39.754120]

获取名称为:[starbucks, yidiandian, xicha] 的坐标信息:[Point [x=116.624452, y=39.862060], Point [x=117.351480, y=38.750124], Point [x=116.538540, y=39.754119]]

根据坐标获取:Point [x=116.000000, y=39.000000] 120.0 KILOMETERS 范围的数据:GeoResults: [averageDistance: 102.8394 KILOMETERS, results: GeoResult [content: RedisGeoCommands.GeoLocation(name=xicha, point=Point [x=116.538540, y=39.754119]), distance: 95.8085 KILOMETERS, ],GeoResult [content: RedisGeoCommands.GeoLocation(name=starbucks, point=Point [x=116.624452, y=39.862060]), distance: 109.8703 KILOMETERS, ]]
RedisGeoCommands.GeoLocation(name=xicha, point=Point [x=116.538540, y=39.754119])
RedisGeoCommands.GeoLocation(name=starbucks, point=Point [x=116.624452, y=39.862060])

根据位置:starbucks 获取: 120.0 KILOMETERS 范围的数据:GeoResults: [averageDistance: 7.03605 KILOMETERS, results: GeoResult [content: RedisGeoCommands.GeoLocation(name=starbucks, point=Point [x=116.624452, y=39.862060]), distance: 0.0 KILOMETERS, ],GeoResult [content: RedisGeoCommands.GeoLocation(name=xicha, point=Point [x=116.538540, y=39.754119]), distance: 14.0721 KILOMETERS, ]]
RedisGeoCommands.GeoLocation(name=starbucks, point=Point [x=116.624452, y=39.862060])
RedisGeoCommands.GeoLocation(name=xicha, point=Point [x=116.538540, y=39.754119])

names:[starbucks, yidiandian, xicha] 对应的hash:[wx4fvbem6d0, wwgkqqhxzd0, wx4f5vhb8b0]

删除名称为:[yidiandian, xicha] 的坐标信息数量:2

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。