Redis缓存的使用场景

目录

为什么用Redis

Redis使用场景

抽奖

实现点赞/收藏功能

排行榜

PV统计

UV统计

去重(BloomFiler)

用户签到(BitMap)

搜附近

简单限流

 全局ID

简单分布式锁

认识的人/好友推荐

发布/订阅

消息队列

数据共享(session共享)

商品筛选

商品缓存

购物车

定时取消订单(key过期监听)

物流信息(时间线)


为什么用Redis

高性能

假设有这么个场景,有一个请求过来,需要的数据查询比较复杂,mysql数据库从开始查询到查出结果耗时600ms。但是这个结果可能接下来几个小时都不会变或者变了也可以不用立即反馈给用户。耗时600ms查出来的结果扔缓存里,一个key对应一个value,下次查询直接从缓存取,通过一个key查出来一个value,2ms搞定性能提升300倍。也就是说对于一些需要复杂操作耗时查出来的结果,且确定后面不怎么变化,但是有很多读请求,那么直接将查询出来的结果放在缓存中,后面直接读缓存就好了。

高并发

mysql数据库单机支撑到2000QPS也开始容易报警了。要是有个系统高峰期一秒钟过来的请求有1万,单机服务器支撑不了就会挂掉。这个时候就可以用缓存,缓存功能简单key-value式操作,单机支撑的并发量一秒几万十几万,是mysql单机的几十倍。

Redis使用场景

抽奖

一般实现可能就是实现一个抽奖的算法,抽取随机用户,利用Redis的集合Set,能轻松实现抽奖的功能。

SADD key member1 [member2]:添加一个或者多个参与用户;

SRANDMEMBER KEY [count]:随机返回一个或者多个用户;

SPOP key:随机返回一个或者多个用户,并删除返回的用户;

SRANDMEMBER和SPOP用于两种不同的抽奖模式,SRANDMEMBER适用于一个用户可中奖多次的场景(就是中奖之后,不从用户池中移除,继续参与其他奖项的抽取),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"

Springboot实现 

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import java.util.List;
@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还提供了检查集合中是否存在指定用户,用户快速判断用户是否已经点赞过。

  • 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
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;

@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);
    }

}

排行榜

常规的做法:就是将用户的名次、分数等用于排名的数据更新到数据库,然后查询的时候通过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"vvv
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.DefaultTypedTuple;
import org.springframework.data.redis.core.RedisTemplate;

import java.util.Set;

@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;
    }
}vvvv

PV统计

Page View(PV)指的是页面浏览量,是用来衡量流量的一个重要标准,也是数据分析很重要的一个依据;通常统计规则是页面被展示一次,就加一。

Redis-cli 操作v

127.0.0.1:6379> INCR pv:article:1
(integer) 1
127.0.0.1:6379> INCR pv:article:1
(integer) 2

UV统计

介绍了通过(INCR)方式来实现页面的PV;除了PV之外,UV(独立访客)也是一个很重要的统计数据;

但是如果要想通过计数(INCR)的方式来实现UV计数,就非常的麻烦,增加之前,需要判断这个用户是否访问过;那判断依据就需要额外的方式再进行记录。

所需命令:

PFADD key element [element ...]:增加计数(统计UV)

PFCOUNT key [key ...]:获取计数(货物UV)

PFMERGE destkey sourcekey [sourcekey ...]:将多个 HyperLogLog 合并为一个 HyperLogLog(多个合起来统计)

Redis-cli 操作

# 添加三个用户的访问
127.0.0.1:6379> PFADD uv:page:1 user1 user2 user3
(integer) 1
# 获取UV数量
127.0.0.1:6379> PFCOUNT uv:page:1
(integer) 3
# 再添加三个用户的访问  user3是重复用户
127.0.0.1:6379> PFADD uv:page:1 user3 user4 user5
(integer) 1
# 获取UV数量 user3是重复用户 所以这里返回的是5
127.0.0.1:6379> PFCOUNT uv:page:1
(integer) 5
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;

@SpringBootTest
@Slf4j
public class UVTest {
    private final String KEY_UV_PAGE_PROFIX = "uv:page:";

    @Autowired
    RedisTemplate redisTemplate;

    @Test
    public void uvTest() {
        Integer pageId = 2;
        for (int i = 0; i < 10000; i++) {
            uv(pageId, i);
        }
        for (int i = 0; i < 10000; i++) {
            uv(pageId, i);
        }

        Long uv = getUv(pageId);
        log.info("pageId:{} uv:{}", pageId, uv);
    }

    /**
     * 用户访问页面
     * @param pageId
     * @param userId
     * @return
     */
    private Long uv(Integer pageId, Integer userId) {
        String key = KEY_UV_PAGE_PROFIX + pageId;
        return redisTemplate.opsForHyperLogLog().add(key, userId);
    }

    /**
     * 统计页面的UV
     * @param pageId
     * @return
     */
    private Long getUv(Integer pageId) {
        String key = KEY_UV_PAGE_PROFIX + pageId;
        return redisTemplate.opsForHyperLogLog().size(key);
    }
}

去重(BloomFiler)

Bloom Filter是由Bloom在1970年提出的一种多哈希函数映射的快速查找算法。通常应用在一些需要快速判断某个元素是否属于集合,但是并不严格要求100%正确的场合。基于一种概率数据结构来实现,是一个有趣且强大的算法。

举个例子:假如你写了一个爬虫,用于爬取网络中的所有页面,当你拿到一个新的页面时,如何判断这个页面是否爬取过?

普通做法:每爬取一个页面,往数据库插入一行数据,记录一下URL,每次拿到一个新的页面,就去数据库里面查询一下,存在就说明爬取过;

普通做法的缺点:少量数据,用传统方案没啥问题,如果是海量数据,每次爬取前的检索,将会越来越慢;如果你的爬虫只关心内容,对来源数据不太关心的话,这部分数据的存储,也将消耗你很大的物理资源;

此时通过 BloomFiler 就能以很小的内存空间作为代价,即可轻松判断某个值是否存在。

同样,BloomFiler 也不那么精准,在默认参数情况下,是存在1%左右的误差;但是 BloomFiler 是允许通过error_rate(误差率)以及initial_size(预计大小)来设置他的误差比例

error_rate:误差率,越低,需要的空间就越大;

initial_size:预计放入值的数量,当实际放入的数量大于设置的值时,误差率就会逐渐升高;所以为了避免误差率,可以提前做好估值,避免再次大的误差;

所需的命令

bf.add 添加单个元素

bf.madd 批量田间

bf.exists 检测元素是否存在

bf.mexists 批量检测

Redis-cli操作

127.0.0.1:6379> bf.add web:crawler baidu
(integer) 1
127.0.0.1:6379> bf.madd web:crawler tencent bing
1) (integer) 1
2) (integer) 1
127.0.0.1:6379> bf.exists web:crawler baidu
(integer) 1
127.0.0.1:6379> bf.mexists web:crawler baidu 163
1) (integer) 1
2) (integer) 0

工具类 

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;

import java.util.Arrays;
import java.util.List;

@Component
public class RedisBloom {

    private static RedisScript<Boolean> bfreserveScript = new DefaultRedisScript<>("return redis.call('bf.reserve', KEYS[1], ARGV[1], ARGV[2])", Boolean.class);
    private static RedisScript<Boolean> bfaddScript = new DefaultRedisScript<>("return redis.call('bf.add', KEYS[1], ARGV[1])", Boolean.class);
    private static RedisScript<Boolean> bfexistsScript = new DefaultRedisScript<>("return redis.call('bf.exists', KEYS[1], ARGV[1])", Boolean.class);
    private static String bfmaddScript = "return redis.call('bf.madd', KEYS[1], %s)";
    private static String bfmexistsScript = "return redis.call('bf.mexists', KEYS[1], %s)";
    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 设置错误率和大小(需要在添加元素前调用,若已存在元素,则会报错)
     * 错误率越低,需要的空间越大
     *
     * @param key
     * @param errorRate   错误率,默认0.01
     * @param initialSize 默认100,预计放入的元素数量,当实际数量超出这个数值时,误判率会上升,尽量估计一个准确数值再加上一定的冗余空间
     * @return
     */
    public Boolean bfreserve(String key, double errorRate, int initialSize) {
        return redisTemplate.execute(bfreserveScript, Arrays.asList(key), String.valueOf(errorRate), String.valueOf(initialSize));
    }

    /**
     * 添加元素
     *
     * @param key
     * @param value
     * @return true表示添加成功,false表示添加失败(存在时会返回false)
     */
    public Boolean bfadd(String key, String value) {
        return redisTemplate.execute(bfaddScript, Arrays.asList(key), value);
    }

    /**
     * 查看元素是否存在(判断为存在时有可能是误判,不存在是一定不存在)
     *
     * @param key
     * @param value
     * @return true表示存在,false表示不存在
     */
    public Boolean bfexists(String key, String value) {
        return redisTemplate.execute(bfexistsScript, Arrays.asList(key), value);
    }

    /**
     * 批量添加元素
     *
     * @param key
     * @param values
     * @return 按序 1表示添加成功,0表示添加失败
     */
    public List<Integer> bfmadd(String key, String... values) {
        return (List<Integer>) redisTemplate.execute(this.generateScript(bfmaddScript, values), Arrays.asList(key), values);
    }

    /**
     * 批量检查元素是否存在(判断为存在时有可能是误判,不存在是一定不存在)
     *
     * @param key
     * @param values
     * @return 按序 1表示存在,0表示不存在
     */
    public List<Integer> bfmexists(String key, String... values) {
        return (List<Integer>) redisTemplate.execute(this.generateScript(bfmexistsScript, values), Arrays.asList(key), values);
    }

    private RedisScript<List> generateScript(String script, String[] values) {
        StringBuilder sb = new StringBuilder();
        for (int i = 1; i <= values.length; i++) {
            if (i != 1) {
                sb.append(",");
            }
            sb.append("ARGV[").append(i).append("]");
        }
        return new DefaultRedisScript<>(String.format(script, sb.toString()), List.class);
    }

}

 测试类

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;

import java.util.List;
@SpringBootTest
@Slf4j
public class BFTest {

    private final String KEY_WEB_CRAWLER = "web:crawler1";

    @Autowired
    RedisBloom bloom;

    @Autowired
    RedisTemplate redisTemplate;


    @Test
    public void test() {
        Boolean hasKey = redisTemplate.hasKey(KEY_WEB_CRAWLER);
        log.info("bloom hasKey:{}", hasKey);
        if (!hasKey) {
            // 不存在的时候  再去初始化
            Boolean bfreserve = bloom.bfreserve(KEY_WEB_CRAWLER, 0.0001, 10000);
            log.info("bloom bfreserve:{}", bfreserve);
        }
        List<Integer> madd = bloom.bfmadd(KEY_WEB_CRAWLER, "baidu", "google");
        log.info("bloom bfmadd:{}", madd);

        Boolean baidu = bloom.bfexists(KEY_WEB_CRAWLER, "baidu");
        log.info("bloom bfexists baidu:{}", baidu);

        Boolean bing = bloom.bfexists(KEY_WEB_CRAWLER, "bing");
        log.info("bloom bfexists bing:{}", bing);
    }
}

用户签到(BitMap)

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

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

所需命令:

SETBIT key offset value:向指定位置offset存入一个0或1

GETBIT key offset:获取指定位置offset的bit值

BITCOUNT key [start] [end]:统计BitMap中值为1的bit位的数量

BITFIELD: 操作(查询,修改,自增)BitMap中bit 数组中的指定位置offset的值

# 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

签到功能中,最不好理解的就是是否签到、连续签到的判断,在下面SpringBoot代码中,就是通过这样的:signFlag >> 1 << 1 != signFlag来判断的,稍微有一点不好理解,在这里提前讲述一下;

上面测试了1-4号的签到情况,通过BITFIELD获取出来signFlag = 11(十进制) = 1011(二进制);

连续签到的判断依据就是:从右往左计算连续为1的BIT个数,二进制 1011 表示连续签到的天数就是2天,2天的计算过程如下:

第一步,获取signFlag

第二步,循环天数,以上测试用例是4天的签到情况,for循环也就是4次

第三步,从右往左循环判断

连续签到:遇到第一个false的时候,终止并得到连续天数

签到详情:循环所有天数,true就表示当前签到了,false表示当天未签到;

第一次循环

signFlag = 1011
signFlag >> 1   结果: 101
signFlag << 1   结果:1010
1010 != signFlag(1011) 结果:true  //4号已签到,说明连续签到1天
signFlag >>= 1  结果: 101   // 此时signFlag = 101

第二次循环

signFlag = 101  // 前一次循环计算的结果
signFlag >> 1   结果: 10
signFlag << 1   结果:100
100 != signFlag(101) 结果:true  //3号已签到,说明连续签到2天
signFlag >>= 1  结果: 10   // 此时signFlag = 10

第三次循环

signFlag = 10  // 前一次循环计算的结果
signFlag >> 1   结果: 1
signFlag << 1   结果:10
10 != signFlag(10) 结果:false  //2号未签到,说明连续签到从这里就断了 
signFlag >>= 1  结果: 1   // 此时signFlag = 1

到这一步,遇到第一个false,说明连续签到中断;

第四次循环:

signFlag = 1  // 前一次循环计算的结果
signFlag >> 1   结果: 0
signFlag << 1   结果: 0
0 != signFlag(1) 结果:true  //1号已签到

到此,根据BITFIELD获取出来11(十进制),就能得到1、3、4号已签到,2号未签到;连续签到2天;

按月签到

签到工具类:

import lombok.extern.slf4j.Slf4j;
import org.joda.time.DateTime;
import org.joda.time.format.DateTimeFormat;
import org.joda.time.format.DateTimeFormatter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.BitFieldSubCommands;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@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;
    }
}

测试类:

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.Map;

@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) + "天");
    }
}

指定时间签到,签到工具类:

package com.ehang.redis.bitmap_sign_by_range;

import lombok.extern.slf4j.Slf4j;
import org.joda.time.DateTime;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.BitFieldSubCommands;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@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;
    }
}

测试工具类:

package com.ehang.redis.bitmap_sign_by_range;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;

@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() ? "√" : "-"));
        }
    }
}

搜附近

搜索附近的功能

如果自己想要根据经纬度来实现一个搜索附近的功能,是非常麻烦的;但是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:计算两个位置之间的距离。

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

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

功能和上面的georadius类似,只是georadius是以经纬度坐标为中心,这个是以某个地点为中心;

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

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

SpringBoot 操作

通过SpringBoot操作GEO的示例如下

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.geo.*;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.data.redis.core.RedisTemplate;

import java.util.List;

@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;
    }
}

简单限流

为了保证项目的安全稳定运行,防止被恶意的用户或者异常的流量打垮整个系统,一般都会加上限流,比如常见的sentialhystrix,都是实现限流控制;如果项目用到了Redis,也可以利用Redis,来实现一个简单的限流功能;

功能所需命令

INCR:将 key 中储存的数字值增一

Expire:设置key的有效期

Redis-cli操作

127.0.0.1:6379> INCR r:f:user1
(integer) 1
# 第一次 设置一个过期时间
127.0.0.1:6379> EXPIRE r:f:user1 5
(integer) 1
127.0.0.1:6379> INCR r:f:user1
(integer) 2
# 等待5s 再次增加 发现已经重置了
127.0.0.1:6379> INCR r:f:user1
(integer) 1

SpringBoot:

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;

import java.util.concurrent.TimeUnit;

@SpringBootTest
@Slf4j
public class FreqTest {
    // 单位时间(秒)
    private static final Integer TIME = 5;
    // 允许访问上限次数
    private static final Integer MAX = 100;

    @Autowired
    RedisTemplate redisTemplate;

    @Test
    public void test() throws Exception {
        String userName = "user1";

        int tag = 1;

        boolean frequency = frequency(userName);
        log.info("第{}次是否放行:{}", tag, frequency);
        for (int i = 0; i < 100; i++) {
            tag += 1;
            frequency(userName);
        }
        frequency = frequency(userName);
        log.info("第{}次是否放行:{}", tag, frequency);

        Thread.sleep(5000);
        frequency = frequency(userName);
        log.info("模拟等待5s后,第{}次是否放行:{}", tag, frequency);
    }

    /**
     * 校验访问频率
     *
     * @param uniqueId 用于限流的唯一ID 可以是用户ID、或者客户端IP等
     * @return true:放行  false:拦截
     */
    private boolean frequency(String uniqueId) {
        String key = "r:q:" + uniqueId;
        Long increment = redisTemplate.opsForValue().increment(key);
        if (increment == 1) {
            redisTemplate.expire(key, TIME, TimeUnit.SECONDS);
        }

        if (increment <= MAX) {
            return true;
        }

        return false;
    }
}

 全局ID

在分布式系统中,很多场景下需要全局的唯一ID,由于Redis是独立于业务服务的其他应用,就可以利用Incr的原子性操作来生成全局的唯一递增ID

功能所需命令

INCR:将 key 中储存的数字值增一

Redis-cli 客户端测试

127.0.0.1:6379> incr g:uid
(integer) 1
127.0.0.1:6379> incr g:uid
(integer) 2
127.0.0.1:6379> incr g:uid
(integer) 3

简单分布式锁

在分布式系统中,很多操作是需要用到分布式锁,防止并发操作带来一些问题;因为redis是独立于分布式系统外的其他服务,因此就可以利用redis,来实现一个简单的不完美分布式锁;

功能所需命令

SETNX key不存在,设置;key存在,不设置

# 加锁
127.0.0.1:6379> SETNX try_lock 1
(integer) 1
# 释放锁
127.0.0.1:6379> del try_lock
(integer) 1

bbf79fabf437b61355dcd58b45eedda1.png

  • set  key  value [ex seconds] [nx | xx]

    上面的方式,虽然能够加锁,但是不难发现,很容易出现死锁的情况;比如,a用户在加锁之后,突然系统挂了,此时a就永远不会释放他持有的锁了,从而导致死锁;为此,我们可以利用redis的过期时间来防止死锁问题

    set try_lock 1 ex 5 nx
    

    e4bb01ab263ccea09b9345970c1ea3f8.png

不完美的锁

上面的方案,虽然解决了死锁的问题,但是又带来了一个新的问题,执行时间如果长于自动释放的时间(比如自动释放是5秒,但是业务执行耗时了8秒),那么在第5秒的时候,锁就自动释放了,此时其他的线程就能正常拿到锁,简单流程如下:

b145c20964fb8540aed94b8f14843d53.png

此时相同颜色部分的时间区间是由多线程同时在执行。而且此问题在此方案下并没有完美的解决方案,只能做到尽可能的避免

方式一,value设置为随机数(如:1234),在程序释放锁的时候,检测一下是不是自己加的锁;比如,A线程在第8s释放的锁就是线程B加的,此时在释放的时候,就可以检验一下value是不是自己当初设置的值(1234),是的就释放,不是的就不管了;

方式二,只在时间消耗比较小的业务上选用此方案,尽可能的避免执行时间超过锁的自动释放时间

认识的人/好友推荐

在支付宝、抖音、QQ等应用中,都会看到好友推荐;

4feb57417cfad4e1a5630b0253cbe0bc.png

好友推荐往往都是基于你的好友关系网来推荐,将你可能认识的人推荐给你,让你去添加好友,如果随意在系统找个人推荐给你,那你认识的可能性是非常小的,此时就失去了推荐的目的;

比如,A和B是好友,B和C是好友,此时A和C认识的概率是比较大的,就可以在A和C之间的好友推荐;

基于这个逻辑,就可以利用 Redis 的 Set 集合,缓存各个用户的好友列表,然后以差集的方式,来实现好友推荐;

功能所需的命令

SADD key member [member …]:集合中添加元素,缓存好友列表

SDIFF key [key …]:取两个集合间的差集,找出可以推荐的用户

Redis-cli 客户端测试

# 记录 用户1 的好友列表
127.0.0.1:6379> SADD fl:user1 user2 user3
(integer) 2
# 记录 用户2 的好友列表
127.0.0.1:6379> SADD fl:user2 user1 user4
(integer) 2

# 用户1 可能认识的人 ,把自己(user1)去掉,user4是可能认识的人
127.0.0.1:6379> SDIFF fl:user2 fl:user1
1) "user1"
2) "user4"

# 用户2 可能认识的人 ,把自己(user2)去掉,user3是可能认识的人
127.0.0.1:6379> SDIFF fl:user1 fl:user2
1) "user3"
2) "user2"

不过这只是推荐机制中的一种因素,可以借助其他条件,来增强推荐的准确度;

发布/订阅

发布/订阅是比较常用的一种模式;在分布式系统中,如果需要实时感知到一些变化,比如:某些配置发生变化需要实时同步,就可以用到发布,订阅功能

常用API

PUBLISH channel message:将消息推送到指定的频道

SUBSCRIBE channel [channel …]:订阅给定的一个或多个频道的信息

Redis-cli操作

eb25c1a6a7960c23770402dfcad5899e.gif

如上图所示,左侧多个客户端订阅了频道,当右侧客户端往频道发送消息的时候,左侧客户端都能收到对应的消息。

消息队列

说到消息队列,常用的就是Kafka、RabbitMQ等等,其实 Redis 利用 List 也能实现一个消息队列;

功能所需的指令

RPUSH key value1 [value2]:在列表中添加一个或多个值;

BLPOP key1 [key2] timeout:移出并获取列表的第一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止;

BRPOP key1 [key2] timeout:移出并获取列表的最后一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。

依赖调整:

Spring Boot 从 2.0版本开始,将默认的Redis客户端Jedis替换为Lettuce,在测试这块阻塞的时候,会出现一个超时的异常io.lettuce.core.RedisCommandTimeoutException: Command timed out after 1 minute(s);没有找到一个好的解决方式,所以这里将 Lettuce 换回成 Jedis ,就没有问题了,pom.xml 的配置如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <exclusions>
        <exclusion>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </exclusion>
        <exclusion>
            <artifactId>lettuce-core</artifactId>
            <groupId>io.lettuce</groupId>
        </exclusion>
    </exclusions>
</dependency>
<!-- jedis客户端 -->
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
</dependency>
<!-- spring2.X集成redis所需common-pool2,使用jedis必须依赖它-->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

测试代码:

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.TimeUnit;

@SpringBootTest
@Slf4j
public class QueueTest {
    private static final String REDIS_LP_QUEUE = "redis:lp:queue";
    private static final String REDIS_RP_QUEUE = "redis:rp:queue";

    @Autowired
    StringRedisTemplate stringRedisTemplate;

    /**
     * 先进后出队列
     */
    @Test
    public void rightMonitor() {
        while (true) {
            Object o = stringRedisTemplate.opsForList().rightPop(REDIS_LP_QUEUE, 0, TimeUnit.SECONDS);
            log.info("先进后出队列 接收到数据:{}", o);
        }
    }

    /**
     * 先进先出队列
     */
    @Test
    public void leftMonitor() {
        while (true) {
            Object o = stringRedisTemplate.opsForList().leftPop(REDIS_RP_QUEUE, 0, TimeUnit.SECONDS);
            log.info("先进先出队列 接收到数据:{}", o);
        }
    }
}

数据共享(session共享)

既然Redis能持久化数据,就可以用它来实现模块间的数据共享;SpringBoot Session 利用的这个机制来实现 Session 共享;

依赖

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
  • 开启session共享

    @Configuration
    @EnableRedisHttpSession
    public class RedisSessionConfig {
    }
    
  • 测试代码

    package com.ehang.redis.controller;
    
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import javax.servlet.http.HttpServletRequest;
    import java.util.Enumeration;
    import java.util.HashMap;
    import java.util.Map;
    
    @RestController
    @RequestMapping("session")
    public class RedisSessionController {
    
        /**
         * 设置session的值
         * @param request
         * @return
         */
        @GetMapping("set")
        public Map set(HttpServletRequest request) {
            String id = request.getSession().getId();
            Map<String, String> vas = new HashMap<>();
    
            String key = "key";
            String value = "value";
            vas.put("id", id);
            vas.put(key, value);
            // 自定义session的值
            request.getSession().setAttribute(key, value);
    
            return vas;
        }
    
        /**
         * 获取session的值
         * @param request
         * @return
         */
        @GetMapping("get")
        public Map get(HttpServletRequest request) {
            Map<String, Object> vas = new HashMap<>();
    
            // 遍历所有的session值
            Enumeration<String> attributeNames = request.getSession().getAttributeNames();
            while (attributeNames.hasMoreElements()) {
                String k = attributeNames.nextElement();
                Object va = request.getSession().getAttribute(k);
                vas.put(k, va);
            }
    
            vas.put("id", request.getSession().getId());
    
            return vas;
        }
    }

 测试

开启两个服务,分别接听8080和8081,8080调用赋值接口,8081调用获取接口,如下图,可以看到,两个服务共享了一份Session数据;

商品筛选

商城类的应用,都会有类似于下图的一个商品筛选的功能,来帮用户快速搜索理想的商品;

93b570afb7cbaa3849fae6fb8b450de7.png

假如现在iphone 100 、华为mate 5000 已发布,在各大商城上线;下面就通过 Redis 的 set 来实现上述的商品筛选功能;

功能所需命令

SADD key member [member …]:添加一个或多个元素

SINTER key [key …]:返回给定所有集合的交集

Redis-cli 客户端测试

# 将iphone100 添加到品牌为苹果的集合
127.0.0.1:6379> sadd brand:apple iphone100
(integer) 1

# 将meta5000 添加到品牌为苹果的集合
127.0.0.1:6379> sadd brand:huawei meta5000
(integer) 1

# 将 meta5000 iphone100 添加到支持5T内存的集合
127.0.0.1:6379> sadd ram:5t iphone100 meta5000
(integer) 2

# 将 meta5000 添加到支持10T内存的集合
127.0.0.1:6379> sadd ram:10t meta5000
(integer) 1

# 将 iphone100 添加到操作系统是iOS的集合
127.0.0.1:6379> sadd os:ios iphone100
(integer) 1

# 将 meta5000 添加到操作系统是Android的集合
127.0.0.1:6379> sadd os:android meta5000
(integer) 1

# 将 iphone100 meta5000 添加到屏幕为6.0-6.29的集合中
127.0.0.1:6379> sadd screensize:6.0-6.29 iphone100 meta5000
(integer) 2

# 筛选内存5T、屏幕在6.0-6.29的机型
127.0.0.1:6379> sinter ram:5t screensize:6.0-6.29
1) "meta5000"
2) "iphone100"

# 筛选内存10T、屏幕在6.0-6.29的机型
127.0.0.1:6379> sinter ram:10t screensize:6.0-6.29
1) "meta5000"

# 筛选内存5T、系统为iOS的机型
127.0.0.1:6379> sinter ram:5t screensize:6.0-6.29 os:ios
1) "iphone100"

# 筛选内存5T、屏幕在6.0-6.29、品牌是华为的机型
127.0.0.1:6379> sinter ram:5t screensize:6.0-6.29 brand:huawei
1) "meta5000"

商品缓存

电商项目中,商品消息,都会做缓存处理,特别是热门商品,访问用户比较多,由于商品的结果比较复杂,店铺信息,产品信息,标题、描述、详情图,封面图;为了方便管理和操作,一般都会采用 Hash 的方式来存储(key为商品ID,field用来保存各项参数,value保存对于的值)

购物车

当商品信息做了缓存,购物车需要做的,就是通过Hash记录商品ID,以及需要购买的数量(其中key为用户信息,field为商品ID,value用来记录购买的数量) ;

功能所需命令

HSET key field value : 将哈希表 key 中的字段 field 的值设为 value ;

HMSET key field1 value1 [field2 value2 ] :同时将多个 field-value (域-值)对设置到哈希表 key 中。

HGET key field:获取存储在哈希表中指定字段的值。

HGETALL key :获取在哈希表中指定 key 的所有字段和值

HINCRBY key field increment :为哈希表 key 中的指定字段的整数值加上增量 increment 。

HLEN key:获取哈希表中字段的数量

Redis-cli 客户端测试

# 购物车添加单个商品
127.0.0.1:6379> HSET sc:u1 c001 1
(integer) 1
# 购物车添加多个商品
127.0.0.1:6379> HMSET sc:u1 c002 1 coo3 2
OK
# 添加商品购买数量
127.0.0.1:6379> HINCRBY sc:u1 c002 1
(integer) 2
# 减少商品的购买数量
127.0.0.1:6379> HINCRBY sc:u1 c003 -1
(integer) 1
# 获取单个的购买数量
127.0.0.1:6379> HGET sc:u1 c002
"2"
# 获取购物车的商品数量
127.0.0.1:6379> HLEN sc:u1
(integer) 3
# 购物车详情
127.0.0.1:6379> HGETALL sc:u1
1) "c001"
2) "1"
3) "c002"
4) "2"
5) "coo3"
6) "2"

定时取消订单(key过期监听)

电商类的业务,一般都会有订单30分钟不支付,自动取消的功能,此时就需要用到定时任务框架,Quartz、xxl-job、elastic-job 是比较常用的 Java 定时任务;我们也可以通过 Redis 的定时过期、以及过期key的监听,来实现订单的取消功能;

  • Redis key 过期提醒配置

    修改 redis 相关事件配置。找到 redis 配置文件 redis.conf,查看 notify-keyspace-events 配置项,如果没有,添加 notify-keyspace-events Ex,如果有值,则追加 Ex,相关参数说明如下:

    • K:keyspace 事件,事件以 keyspace@ 为前缀进行发布

    • E:keyevent 事件,事件以 keyevent@ 为前缀进行发布

    • g:一般性的,非特定类型的命令,比如del,expire,rename等

    • $:字符串特定命令

    • l:列表特定命令

    • s:集合特定命令

    • h:哈希特定命令

    • z:有序集合特定命令

    • x:过期事件,当某个键过期并删除时会产生该事件

    • e:驱逐事件,当某个键因 maxmemore 策略而被删除时,产生该事件

    • A:g$lshzxe的别名,因此”AKE”意味着所有事件

  • 添加RedisKeyExpirationListener的监听

    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.data.redis.connection.RedisConnectionFactory;
    import org.springframework.data.redis.listener.RedisMessageListenerContainer;
    
    @Configuration
    public class RedisListenerConfig {
    
        @Bean
        RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory) {
    
            RedisMessageListenerContainer container = new RedisMessageListenerContainer();
            container.setConnectionFactory(connectionFactory);
            return container;
        }
    }
    

    KeyExpirationEventMessageListener 接口监听所有 db 的过期事件 keyevent@*:expired"

    package com.ehang.redis.config;
    
    
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.data.redis.connection.Message;
    import org.springframework.data.redis.listener.KeyExpirationEventMessageListener;
    import org.springframework.data.redis.listener.RedisMessageListenerContainer;
    import org.springframework.stereotype.Component;
    
    
    @Component
    @Slf4j
    public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {
    
        public RedisKeyExpirationListener(RedisMessageListenerContainer listenerContainer) {
            super(listenerContainer);
        }
    
        /**
         * 针对 redis 数据失效事件,进行数据处理
         *
         * @param message
         * @param pattern
         */
        @Override
        public void onMessage(Message message, byte[] pattern) {
    
            // 获取到失效的 key,进行取消订单业务处理
            // 由于这里是监听了所有的key,如果只处理特定数据的话,需要做特殊处理
            String expiredKey = message.toString();
            log.info("过期的Key:{}", expiredKey);
        }
    }
    
  • 测试

    为了快速验证效果,这里 将过期时间调整为2秒;

    注意,由于过期之后,Redis中的Key已经不存在了,因此,一定要将订单号作为key,不能作为值保存,否则监听到过期Key之后,将拿不到过期的订单号;

    4c94912278f17be9bc4845512b38c7ef.gif

  • 不推荐使用

    基于这一套机制,确实能够实现订单的超时取消,但是还是不太建议使用,这里仅作为一个思路;原因主要有以下几个:

    1. redis 的过期删除策略是采用定时离线扫描,或者访问时懒性检测删除,并没有办法保证时效性,有可能key已经到期了,但Redis并没有扫描到,导致通知的延迟;

    2. 消息发送即忘(fire and forget),并不会保证消息的可达性,如果此时服务不在线或者异常,通知就再也收不到了

物流信息(时间线)

寄快递、网购的时候,查询物流信息,都会给我们展示xxx时候,快递到达什么地方了,这就是一个典型的时间线列表;

c5cdd0a598d0e877649bf17fa2dd9e4a.png

数据库的做法,就是每次变更就插入一条带时间的信息记录,然后根据时间和ID(ID是必须的,如果出现两个相同的时间,单纯时间排序,会造成顺序不对),来排序生成时间线;

我们也可以通过 Redis 的 List 来实现时间线功能,由于 List 采用的是双向链表,因此升序,降序的时间线都能正常满足;

  • RPUSH key value1 [value2]:在列表中添加一个或多个值,(升序时间线)

  • LPUSH key value1 [value2]:将一个或多个值插入到列表头部(降序时间线)

  • LRANGE key start stop:获取列表指定范围内的元素

Redis-cli 客户端测试

  • 升序

    127.0.0.1:6379> RPUSH time:line:asc 20220805170000
    (integer) 1
    127.0.0.1:6379> RPUSH time:line:asc 20220805170001
    (integer) 2
    127.0.0.1:6379> RPUSH time:line:asc 20220805170002
    (integer) 3
    127.0.0.1:6379> LRANGE time:line:asc 0 -1
    1) "20220805170000"
    2) "20220805170001"
    3) "20220805170002"
    
  • 降序

    127.0.0.1:6379> LPUSH time:line:desc 20220805170000
    (integer) 1
    127.0.0.1:6379> LPUSH time:line:desc 20220805170001
    (integer) 2
    127.0.0.1:6379> LPUSH time:line:desc 20220805170002
    (integer) 3
    127.0.0.1:6379> LRANGE time:line:desc 0 -1
    1) "20220805170002"
    2) "20220805170001"
    3) "20220805170000"
    
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

骆驼整理说

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值