Redis应用

132 阅读12分钟

使用redis的场景场景

  • 给一个userId,判断登录状态
  • 两亿用户最近的签到情况,统计7天内签到的用户总数。
  • 统计每天新增与第二天留存的用户。
  • 最新评论列表。
  • 根据播放量音乐榜单。

四种统计类型·

  • 二值状态统计

  • 聚合统计:统计多个集合元素的聚合结果

    • 统计多个元素的共有数据(交集);
    • 统计两个集合其中的一个独有元素(差集统计);
    • 统计多个集合的所有元素(并集统计)。
  • 排序统计:

    • List:按照元素插入List的顺序排序。消息队列、最新列表、排行榜。

      • 不需要分页或更新频率低
    • Sorted Set:按照元素的score权重排序。排行榜按照播放量、点赞数。

  • 基数统计:一个集合中不重复的元素个数。Set--->HyperLogLog

基础应用

对文章进行投票

数值和条件限制:如果一篇文章有200张支持票,那么网站就认为是一篇有趣的文章。假如网站每天有1000篇文章,并且其中的50篇符合我们对有趣文章的要求,而我们要做的就是将这50篇有趣的文章放在网站文章列表前100位至少一天。

为了产生一个能够随着时间流逝而不断减少的评分,程序需要根据文章的发布时间和当前时间来计算文章的评分,具体的计算方法为:将文章得到的支持票数量乘以一个常数,然后加上文章的发布时间,得出的结果就是文章的评分。我们使用从UTC时区1970年1月1日到现在为止经过的秒数来计算文章的评分,另外,计算评分时与支持票数量相乘的常量为432,这个常量是通过将一天的秒数(86 400)除以文章展示一天所需的支持票数量(200)得出的:文章每获得一张支持票,程序就需要将文章的评分增加432分。

构建文章投票网站除了需要计算文章评分之外,还需要使用Redis结构保存网站上的各种信息。对于网站里的每篇文章,程序都使用一个散列来存储文章的标题、指向文章的网址、发布文章的用户、文章的发布时间、文章得到的投票数量等信息。使用散列表来存储文章信息的例子如下所示:

网站使用了两个有序集合来有序地保存文章:第一个有序集合的成员为文章ID,分值为文章的发布时间;另一个有序集合的成员同样为文章ID,而分值则为文章的评分。通过这两个有序集合,网站既可以根据文章发布的先后顺序来展示文章,又可以根据文章评分的高低来展示文章。

为了防止用户对同一篇文章进行多次投票,网站需要为每篇文章记录一个已投票用户名单。为此,程序将为每篇文章创建一个集合,并使用这个集合来存储所有已投票用户的ID。

为了尽量节约内存,我们规定当一篇文章发布期满一周之后,用户将不能再对它进行投票,文章的评分将被固定下来,而记录文章已投票用户名单的集合也会被删除。

如何实现投票功能?

  1. 当用户尝试对一篇文章进行投票时,程序需要使用ZSCORE命令检查记录文章发布时间的有序集合,判断文章的发布时间是否未超过一周。
  2. 如果文章仍然处于可以投票的时间范围之内,那么程序将使用SADD命令,尝试将用户添加到记录文章已投票用户名单的集合里面。
  3. 如果添加操作执行成功的话,那么说明用户是第一次对这篇文章进行投票,程序将使用ZINCRBY命令(ZINCRBY用于对有序集合成员的分值执行自增操作)为文章的评分增加432分,并使用HINCRBY命令(HINCRBY用于对散列存储的值执行自增操作)对散列记录的文章投票数量进行更新

发布并获取文章

  1. 发布一篇新文章首先需要创建一个新的文章ID,这项工作可以通过对一个计数器(counter)执行INCR命令来完成。
  2. 接着程序需要使用SADD将文章发布者的ID添加到记录文章已投票用户名单的集合里面,并使用EXPIRE命令为这个集合设置一个过期时间,让Redis在文章发布期满一周之后自动删除这个集合。
  3. 之后,程序会使用HMSET命令来存储文章的相关信息,并执行两个ZADD命令,将文章的初始评分(initial score)和发布时间分别添加到两个相应的有序集合里面。

完成了文章投票功能和文章发布功能,那如何取出评分最高的文章以及如何取出最新发布的文章呢?

程序需要先使用ZREVRANGE命令来按照分值从大到小地取出文章ID,然后再对每个文章ID执行一次HGETALL命令来取出文章的详细信息,这个方法既可以用于取出评分最高的文章,又可以用于取出最新发布的文章。

对文章进行分组

群组功能由两个部分组成,一个部分负责记录文章属于哪个群组,另一个部分负责取出群组里面的文章。为了记录各个群组都保存了哪些文章,网站需要为每个群组创建一个集合,并将所有同属一个群组的文章ID都记录到这个集合里面。

为了能够根据评分对群组文章进行排序和分页(paging),网站需要将同一个群组里面的所有文章都按照评分有序地存储到一个有序集合里面。Redis的ZINTERSTORE命令可以接受多个集合和多个有序集合作为输入,找出所有同时存在于集合和有序集合的成员,并以几种不同的方式来合并(combine)这些成员的分值(所有集合成员的分值都会被视为是1)。对于我们的文章投票网站来说,程序需要使用ZINTERSTORE命令选出相同成员中最大的那个分值来作为交集成员的分值:取决于所使用的排序选项,这些分值既可以是文章的评分,也可以是文章的发布时间。

具体实现

/**
 * 发布文章
 *
 * @param conn  jedis
 * @param user  用户名
 * @param title 文章title
 * @param link  链接
 * @return
 */
public String postArticle(Jedis conn, String user, String title, String link) {
    String articleId = String.valueOf(conn.incr("article:"));

    String voted = "voted:" + articleId;
    conn.sadd(voted, user);
    conn.expire(voted, ONE_WEEK_IN_SECONDS);

    long now = System.currentTimeMillis() / 1000;
    String article = "article:" + articleId;
    HashMap<String, String> articleData = new HashMap<>();
    articleData.put("title", title);
    articleData.put("link", link);
    articleData.put("user", user);
    articleData.put("now", String.valueOf(now));
    articleData.put("votes", "1");
    conn.hmset(article, articleData);
    conn.zadd("score:", now + VOTE_SCORE, article);
    conn.zadd("time:", now, article);

    return articleId;
}
/**
 * 投票
 *
 * @param conn    redis
 * @param user    投票人
 * @param article 被投文章
 */
public void articleVote(Jedis conn, String user, String article) {
    log.info("articleVote,article:{}", article);
    long cutoff = (System.currentTimeMillis() / 1000) - ONE_WEEK_IN_SECONDS;
    if (conn.zscore("time:", article) < cutoff) {
        return;
    }
    String articleId = article.substring(article.indexOf(":") + 1);
    if (conn.sadd("voted:" + articleId, user) == 1) {
        conn.zincrby("score:", VOTE_SCORE, article);
        conn.hincrBy(article, "votes", 1L);
    }
}
/**
 * 分群
 *
 * @param conn       jedis
 * @param articledId 文章ID
 * @param toAdd      分群
 */
public void addGroups(Jedis conn, String articledId, String[] toAdd) {
    String article = "article:" + articledId;
    for (String group : toAdd) {
        conn.sadd("group:" + group, article);
    }
}

好友功能

利用Redis的Sets数据类型进行结合操作。Sets拥有去重(不能多次关注同一用户)。一个用户我们存储两个集合:一个保存用户关注的人,另一个保存关注用户的人。

// 我关注的人
private static final String FOLLOWING_KEY = "following:";

// 我被谁关注(粉丝)
private static final String FOLLOWERS_KEY = "followers:";
/**
 * 关注
 *
 * @param name          我
 * @param followingName 被关注的人
 */
private static void addFollow(String name, String followingName) {
    jedis.sadd(getFollowingKey(name), followingName);
    jedis.sadd(getFollowersKey(followingName), name);
}

/**
 * 取关
 *
 * @param name          我
 * @param followingName 被取关的人
 */
private static void offFollow(String name, String followingName) {
    jedis.srem(getFollowingKey(name), followingName);
    jedis.srem(getFollowersKey(followingName), name);
}
/**
 * 获取关注列表
 *
 * @param name 我
 * @return
 */
private static List<String> getFollowings(String name) {
    return new ArrayList<>(jedis.smembers(getFollowingKey(name)));
}

/**
 * 获取粉丝列表
 *
 * @param name 我
 * @return
 */
private static List<String> getFollowers(String name) {
    return new ArrayList<>(jedis.smembers(getFollowersKey(name)));
}
/**
 * 查询我的互粉
 *
 * @param name 我
 * @return
 */
private static List<String> mutualFollow(String name) {
    return new ArrayList<>(jedis.sinter(getFollowersKey(name), getFollowingKey(name)));
}

/**
 * 我和他的共同关注
 *
 * @param name  我
 * @param other 他
 * @return
 */
private static List<String> commonFollow(String name, String other) {
    return new ArrayList<>(jedis.sinter(getFollowingKey(name), getFollowingKey(other)));
}
/**
 * 我的关注数
 *
 * @param name 我
 * @return
 */
private static Long followNum(String name) {
    return jedis.scard(getFollowingKey(name));
}

/**
 * 我的粉丝数
 *
 * @param name 我
 * @return
 */
private static Long fansNum(String name) {
    return jedis.scard(getFollowersKey(name));
}

获取附近门店

使用Redis GEO特性支持位置相关操作,这个功能可以将用户给定的地理位置信息存储起来,并对信息进行操作。

/**
 * 添加门店位置信息
 *
 * @param id  门店ID
 * @param lon 经度
 * @param lat 纬度
 */
private static void updateStoreLocation(Long id, Float lon, Float lat) {
    jedis.geoadd(STORE_LOCATION, lon, lat, String.valueOf(id));
}
/**
 * 根据范围查询门店
 *
 * @param lon    经度
 * @param lat    纬度
 * @param radius 范围
 * @param unit   范围单位
 * @return
 */
private static List<Long> findStoresWithinRadius(Float lon, Float lat, Double radius, String unit) {

    List<GeoRadiusResponse> list = jedis.georadius(STORE_LOCATION, lon, lat, (radius == null ? DEFAULT_DISTANCE_RADIUS : radius),
            (GeoUnit.valueOf(StrUtil.isBlank(unit) ? DEFAULT_DISTANCE_UNIT : unit)), GeoRadiusParam.geoRadiusParam().sortAscending());

    return list.stream().map(e -> Long.parseLong(new String(e.getMember()))).collect(Collectors.toList());
}

签到统计

Redis的字符串数据都是以二进制的形式存放的,所以说Redis的Bit操作非常适合处理签到统计功能。因为Bit的值为0或1,用户是否打卡也可以用0或1来表示,把签到的天数对应到每个字节上,打卡就是1,没打卡就是0。

/**
 * 签到
 *
 * @param userId  用户ID
 * @param dateStr 日期
 */
public static void doSign(Long userId, String dateStr) {
    Date date = getDate(dateStr);

    int day = DateUtil.dayOfMonth(date) - 1;
    String signKey = buildSignKey(userId, date);
    System.out.println("signKey:" + signKey);
    Boolean isSign = conn.getbit(signKey, day);
    if (isSign) {
        System.out.println("已经签到");
        return;
    }
    conn.setbit(signKey, day, true);
}
/**
 * 统计连续签到
 *
 * @param userId 用户ID
 * @param date   日期
 * @return
 */
private static int getContinuousSignCount(Long userId, Date date) {
    int dayOfMonth = DateUtil.dayOfMonth(date);
    String signKey = buildSignKey(userId, date);
    String type = String.format("u%d", dayOfMonth);

    List<Long> list = conn.bitfield(signKey, "GET", type, "0");

    if (CollUtil.isEmpty(list)) {
        return 0;
    }
    int signCount = 0;
    long v = list.get(0) == null ? 0 : list.get(0);
    System.out.println(Long.toBinaryString(v));
    for (int i = dayOfMonth; i > 0; i--) {
        if (v >> 1 << 1 == v) {
            if (i != dayOfMonth) break;
        } else {
            signCount++;
        }
        v >>= 1;
    }
    return signCount;
}

UV

/**
 * 获取日活用户
 *
 * @param dayNum
 * @return
 */
private static Long getActiveUserCount(int dayNum) {
    if (dayNum < 1) {
        return 0L;
    }
    List<String> pastDayKey = new ArrayList<>();
    SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd");
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < dayNum; i++) {
        sb.append(ACTIVE_KEY).append(dateFormat.format(DateUtil.offsetDay(new Date(), -i)));
        pastDayKey.add(sb.toString());
        sb.delete(0, sb.length());
    }
    if (pastDayKey.isEmpty()) {
        return 0L;
    }
    String lastDaysKey = "last" + dayNum + "DaysActive";
    jedis.bitop(BitOP.AND, lastDaysKey, pastDayKey.toArray(new String[pastDayKey.size()]));
    Long bitCount = jedis.bitcount(lastDaysKey);
    jedis.expire(lastDaysKey, 300);

    return bitCount;
}

基于搜索

基于搜索原理

首先需要对文档进行预处理,这个预处理步骤通常称为建索引,创建的结构则被称为反向索引。Redis自带的集合和有序集合都适合处理反向索引。

对于标题《lord of the rings》的docA和《lord of the dance》的docB,创建的反向索引为:

为了给文档建立索引集合,程序首先需要对文档包含的单词进行处理。提取单词的过程被称为语法分析和标记化,这个过程可以产生出一系列用于标识文档的标记/单词。标记化就是移除内容中的非用词。非用词就是那些在文档中频繁出现但是没有提供相应信息量的单词。

在索引里查找一个单词是容易的。但是要根据两个或多个单词查找文档的话,程序就需要把给定单词集合里面的所有文档都找出来,然后再从中找到那些在所有单词集合里面都出现了的文档。可以使用SINTER命令和SINTERSTORE来找出那些包含了所有给定单词的文档。

使用交集操作的好处不在于能够找到多少文档,而在于能够彻底地忽略无关的信息。

对比

Search Benchmarking: RediSearch vs. Elasticsearch | Redis

RedisJSON: Public Preview & Performance Benchmarking | Redis

使用redis的搜索功能的性能要比es高很多,这是因为redis是一个内存中的数据结构存储,它可以直接访问内存中的数据,而不需要经过磁盘或者网络的开销。而es是一个基于Lucene的分布式搜索和分析引擎,它需要将数据存储在磁盘上,并且通过网络进行通信和协调,这些都会增加一定的延迟和消耗。

但是es也有它自己的优势和特色,比如:

  • es可以处理海量数据,而redis的数据量受内存限制。当数据规模非常大,或者需要长期存储数据,那么es可能是更好的选择。
  • es可以提供更多的搜索和分析功能,而redis的功能相对较少。例如,es可以支持多种复杂查询和聚合操作,如布尔查询,范围查询,模糊查询,分面查询等;es还可以支持多种数据分析功能,如统计分析,文本分析,机器学习等。而redis只能支持一些基本的搜索功能,如全文搜索,结构化搜索等 。
  • es可以提供更好的可扩展性和可靠性,而redis的扩展性和可靠性相对较弱。例如,es可以通过分片和复制机制来实现水平扩展和容错能力;es还可以通过集群管理和监控工具来实现高可用性和自动恢复能力。而redis虽然也可以通过一些扩展模块来实现类似的功能,但是可能会增加一些复杂度和风险 。