Redis构建自动补全功能案例

Redis构建自动补全功能案例

本篇文章有两种方式来构建自动补全功能:

  1. 方法一 通过使用联系人列表来记录用户最近联系过的100个人,并尝试尽可能减少实现自动补全所需的内存。
  2. 方法二 自动补全则为更大的联系人列表提供了更好的性能和可扩展性,但是所花费的内存较多一点。

方法一

场景:模拟用户在客户端输入聊天对象时,会自动补全显示用户已经输入过的聊天对象名称。Redis主要用于记录联系人列表,而非实际地执行自动补全操作。

  因为服务器上数百万用户都需要一个属于自己的联系人列表来存储最近联系过的100人,所以我们需要快速向列表里面添加用户或者删除用户的前提下,尽可能的减少存储这些联系人列表带来的内存消耗。自动补全操作将放在服务器里面处理。

更新用户最近联系人

实现该功能通常需要执行3个步骤:

  1. 如果指定的联系人已经存在于最近联系人列表里面,那么移除他。
  2. 将指定联系人添加到最近的联系人列表最前面
  3. 如果添加完成后,最近联系人列表数量超过了100个,那么将对列表进行修剪,只保留位于前面100最新联系人。

以上3个操作可以依次执行Redis 的 LREM LPUSH LTRIM命令

/**
     * 更新用户输入最近联系人
     * @param name 姓名
     * @param input 输入联系人
     */
    public void updateLink(String name, String input) {
        String key = AUTO_KEY_PRE + name;
        // 开启事务支持,在同一个 Connection 中执行命令
        redisTemplate.setEnableTransactionSupport(true);
        redisTemplate.multi();
        redisTemplate.opsForList().remove(key, 0, input);
        redisTemplate.opsForList().leftPush(key, input);
        redisTemplate.opsForList().trim(key, 0, 99);
        redisTemplate.exec();
    }
自动补全联系人

获取该用户的最近所有联系人,在服务器里面进行匹配,最后返回匹配成功的数据给客户端。

/**
     * 根据前缀获取用户联系人
     * @param name 姓名
     * @param prefix 输入联系人
     */
    public String showRecent(String name, String prefix) {
        String key = AUTO_KEY_PRE + name;
        List<String> result = new ArrayList<>();
        List<String> stringList = redisTemplate.opsForList().range(key, 0, -1);
        assert stringList != null;
        for (String s : stringList) {
            if (containsPrefix(prefix, s)) {
                result.add(s);
            }
        }
        return JSONObject.toJSONString(result);
    }

    // 模糊匹配
    private boolean containsPrefix(String match, String target) {
        if (match.length() > target.length()) {
            return false;
        }
        for (int i = 0; i < match.length(); i++) {
            if (match.charAt(i) != target.charAt(i)) {
                return false;
            }
        }
        return true;
    }
删除最近联系人

用户除了能查看到最近的联系人外,还可以删除联系人记录。

/**
     * 用户删除指定联系人
     * @param name 姓名
     * @param input 输入联系人
     */
    public void delete(String name, String input) {
        String key = AUTO_KEY_PRE + name;
        redisTemplate.opsForList().remove(key, 0, input);
    }

方法二

  相较于方法一的自动补全,对于比较短的列表来讲,这种做法还算可行的,但是对于非常长的联系人列表来说,为了获取几个所需元素而查找成千上万个元素是一种非常浪费资源的做法。

模拟场景:有一个邮件发送功能,针对单位用户发起邮件,会自动补全满足条件的单位用户。补全对象范围,当前单位下的所有员工。

注意:用户名称都为英文字母(可以根据思路扩展至其他类型名称)

实现步骤
  1. 使用有序集合来存储通讯录,再把有序集合的分值设为0(成员分值都相同时,有序集合会将根据成员的名字进行排序)。
  2. 将输入的前缀使用起始元素结束元素进行有序集合定位,获取查找范围。
  3. 取出范围后,将2个标识元素进行删除,为了避免滋扰用户,程序最多只取出10个元素。
  4. 为了防止多个用户补全相似范围的数据,将多个相同的起始元素和结束元素重复添加至有序集合里面,或者错误的删除了其他自动补全的标识位置元素,所以程序将在起始元素与结束元素的后面加一个唯一标识UUID,防止勿删。
步骤二解析

如查找abc前缀的单词,实际上是查找abzz … abd之间的字符串,可以使用ZRANGE方法调用取得。因为我们不知道该两个元素的具体排名,所以需要构建开始与结束的两个元素,根据这两个元素排名来获取abc前缀的单词。

image-20200613162805003 image-20200613162805003

通过ASCII编码可知,a的前一个字符是` z的后一个字符是 {

综上所述:

  • 起始元素值:前缀的最后一个字符替换为第一个排在该字符前的字符
  • 结束元素值:前缀末尾拼接上左花括号
  • 为了防止多个前缀搜索同时进行时出现问题,程序还会给前缀多拼接一个左花括号,以便在有需要时候,可以根据这个左花括号来过滤掉 起始与结束值。
private static final String VALID_CHARACTERS = "`abcdefghijklmnopqrstuvwxyz{";
    /**
     * 获取起始 与 结束值
     * @param prefix 前缀
     * @return [起始值,结束值]
     */
    public String[] findPrefixRange(String prefix) {
        // 获取前缀最后一位字符位置。
        int posn = VALID_CHARACTERS.indexOf(prefix.charAt(prefix.length() - 1));
        // 获取该字符的上一位字符
        char suffix = VALID_CHARACTERS.charAt(posn > 0 ? posn - 1 : 0);
        // 替换最后一位字符 再 加上 {
        String start = prefix.substring(0, prefix.length() - 1) + suffix + '{';
        String end = prefix + '{';
        return new String[]{start, end};
    }
最终实现
public Set<String> autocompleteOnPrefix(String unit, String prefix) {
        // 获取起始 与 结束 值
        String[] range = findPrefixRange(prefix);
        String start = range[0], end = range[1];
        String identifier = UUID.randomUUID().toString();
        start += identifier;
        end += identifier;
        // 插入定位标识
        String zsetName = "members:" + unit;
        redisTemplate.opsForZSet().add(zsetName, start, 0);
        redisTemplate.opsForZSet().add(zsetName, end, 0);
        Set<String> items = null;
        while (true) {
            String finalStart = start;
            String finalEnd = end;
            List<Object> res = redisTemplate.execute(new SessionCallback<List<Object>>() {
                @Override
                public <K, V> List<Object> execute(RedisOperations<K, V> operations) throws DataAccessException {
                    redisTemplate.watch(zsetName);
                    // 获取范围
                    int startIndex = redisTemplate.opsForZSet().rank(zsetName, finalStart).intValue();
                    int endIndex = redisTemplate.opsForZSet().rank(zsetName, finalEnd).intValue();
                    // 获取10个元素的范围结束值
                    int erange = Math.min(startIndex + 9, endIndex - 2);
                    redisTemplate.multi();
                    // 删除标识
                    redisTemplate.opsForZSet().remove(zsetName, finalStart, finalEnd);
                    redisTemplate.opsForZSet().range(zsetName, startIndex, erange);
                    return redisTemplate.exec();
                }
            });
            if (res != null) {
                items = (Set<String>) res.get(res.size() - 1);
                break;
            }
        }
        // 过滤标识符
        items.removeIf(s -> s.indexOf('{') != -1);
        return items;
    }

以上两种实现方法都可以自行测试,或者给源文件自行尝试。
提取码:j57u

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值