Bootstrap

Redis实战-使用Redis构建应用程序组件

1 自动补全

用户不进行搜索的情况下,根据用户输入的前缀,显示所有可能的结果。

1.1 自动补全最近联系人

这里只存储用户最近联系的100个用户。

1.1.1 需求和数据结构分析

需求分析:就是根据用户前缀返回用户可能的联系人。
数据结构分析:这里使用列表存储用户最近的联系人。

1.1.2 Java代码示例

这里以Java代码为演示。下面是添加用户的代码demo1-1:

/**
     * 添加用户联系人
     * @param user
     * @param contact
     */
    public void addUpdateContact(String user, String contact) {
        redisTemplate.multi();
        BoundListOperations<String, Object> userInfo = redisTemplate.boundListOps("recent:" + user);
        //移除之前的
        userInfo.remove(1, contact);
        //添加新的到左侧
        userInfo.leftPush(contact);
        //防止存储过多
        userInfo.trim(0, 99);
        redisTemplate.exec();
    }

就是将用户最近联系人添加到用户最近联系列表,并保留100位。

删除用户demo-2:

/**
     * 删除指定的联系人
     * @param user
     * @param contact
     */
    public void removeContact(String user, String contact) {
        redisTemplate.opsForList().remove("recent:" + user, 1, contact);
    }
   

删除过程比较简单,就是移除对应用户。

获取自动补全的联系人列表demo-3:

/**
     * 获取自动补全结果
     * @param user
     * @param prefix
     * @return
     */
    public List<String> fetchAutoCompleteContact(String user, String prefix) {
        //获取最近联系人
        List<Object> contactList = redisTemplate.opsForList().range("recent:" + user, 0, -1);
        List<String> result = new ArrayList<>();
        //根据前缀添加到结果集
        contactList.stream().forEach( k -> {
           String contact = (String) k;
           if (contact.toLowerCase().startsWith(prefix.toLowerCase())) {
               result.add(contact);
            }
        });
        return  result;
    }

这里由于redis没有筛选功能,选择获取所有数据,然后进行筛选匹配。由于数据量不大,所有速度很快。

执行过程演示:
在这里插入图片描述

1.2 通讯录补全

前述的自动补全是将数据全部读取出,在进行判断,对于大量数据是不合适的,还是应该有redis完成查找功能。

1.2.1 需求和数据结构分析

根据前缀号码查出用户需要的邮件。
由于数量庞大,无法直接获取全部数据。

这里采取的是利用redis的有序集合,将所有成员分值设为0,这样将根据成员名称来进行排序。

这样我们需要做的就是根据前缀,找出该前缀在Redis中的范围。可以利用ascii码字母的前缀来解决。

1.2.2 Java代码示例

这里假设只含有小写字母。

查找一个字母的ASCII前缀:

private static final String validPrefix = "`abcdefghijklmnopqrstuvwxyz{";

    /**
     * 获取联系人前缀和后缀
     * @param prefix
     * @return
     */
    public List<String> findPrefixRange(String prefix) {
        char c1 = prefix.charAt(prefix.length() - 1);
        int i = validPrefix.indexOf(c1);
        char c = validPrefix.charAt(i - 1);
        return Arrays.asList(prefix.substring(0, prefix.length()-1)+c + "{", prefix+"{");
    }

根据前缀查找联系人:

/**
     * 自动补全联系人,数量较多,不适合客户端查找,直接redis查找
     * @param guide
     * @param prefix
     * @return
     */
    public Set<Object> autoCompletePrefix(String guide, String prefix) {
        //获取需要插入的两个界定元素
        List<String> prefixRange = findPrefixRange(prefix);
        //界定元素加入uuid,防止误删其它用户
        UUID uuid = UUID.randomUUID();
        String start = prefixRange.get(0) + uuid;
        String end = prefixRange.get(1) + uuid;
        String zsetName = "members:" + guide;
        redisTemplate.watch(zsetName);
        BoundZSetOperations<String, Object> nameInfo = redisTemplate.boundZSetOps(zsetName);
        redisTemplate.multi();
        nameInfo.add(start, 0);
        nameInfo.add(end, 0);
        //获取需要获取范围的上下界
        Long startRank = nameInfo.rank(start);
        Long endRank = nameInfo.rank(end);
        //只取少量元素10个
        long lastRank = Math.min(startRank + 9, endRank - 2);
        nameInfo.remove(start, end);
        Set<Object> result = nameInfo.range(startRank, lastRank);
        redisTemplate.exec();
        return result;
    }

主要步骤:

  • 查找前缀的下限和限。
  • 将下限和上限插入有序集合,并获取两者的排名。
  • 移除插入的这两个元素。
  • 此时排名就是原先的前缀对应的所有元素的范围,执行查找即可。

比如abc上下界分别为:abc{和abc{

执行过程演示:
在这里插入图片描述

2 分布式锁

由于redis的mutil和exec是一种乐观锁,对于大量并发产生的冲突不友好。所有可以考虑实现悲观锁,来解决大量冲突。

2.1 使用Redis构建锁

这里主要是使用Redis的setnx命令。使用一个uuid作为锁的键的值,防止被其它客户端设置。

获取锁的代码:

/**
     * 获取锁
     * @param lockname
     * @param timeout
     * @return
     * @throws InterruptedException
     */
    public String accquireLock(String lockname, long timeout) throws InterruptedException {
        long endtime = System.currentTimeMillis() / 1000 + timeout;
        String identifier = UUID.randomUUID().toString();
        while (System.currentTimeMillis() / 1000 < endtime) {
            if (redisTemplate.opsForValue().setIfAbsent("lock:" + lockname, identifier)) {
                return identifier;
            }
            Thread.sleep(100);
        }
        return null;
    }

设置了重试时间,指定时间没有获得将返回null。

释放锁的代码:

/**
     * 释放锁
     * @param lockname
     * @param identifier
     * @return
     */
    public boolean releaseLock(String lockname, String identifier) {
        String lock = "lock:" + lockname;
        while (true) {
            redisTemplate.watch(lock);
            if (redisTemplate.opsForValue().get(lock) == identifier) {
                redisTemplate.multi();
                redisTemplate.delete(lock);
                redisTemplate.exec();
                return true;
            } else {
                redisTemplate.unwatch();
                break;
            }
        }
        return false;
    }

需要监视锁,以防其它客户端修改。

2.2 带有超时限制的锁

如果获取锁之后客户端因为其它原因无法释放,将导致其它客户始终不能进行带锁操作,所有需要设置锁的超时时间。

可以直接使用setnx命令的附加选项。

示例代码:

/**
     * 获取具有过期时间的锁
     * @param lockname
     * @param timeout
     * @param locktimeout
     * @return
     * @throws InterruptedException
     */
    public String accquireLockWithTimeout(String lockname, long timeout, long locktimeout, TimeUnit timeUnit) throws InterruptedException {
        long endtime = System.currentTimeMillis() / 1000 + timeout;
        String identifier = UUID.randomUUID().toString();
        while (LocalTime.now().getSecond() < endtime) {
            if (redisTemplate.opsForValue().setIfAbsent("lock:" + lockname, identifier, locktimeout, timeUnit)) {
                return identifier;
            }
            Thread.sleep(100);
        }
        return "";
    }

这里设置了锁的过期时间,以防止无法释放。

3 计数信号量

信号计数量可以限制访问资源的共享个数。

3.1 数据结构分析

这里主要考虑使用一个有序集合存储申请获取信号量的用户。

以uuid作为用户的标识符,时间戳作为分值,限制获取的个数来控制信号量个数。

3.2 Java代码模拟

获取信号量:

/**
     * 获取信号量
     * 如果各个系统时间不一致,将会导致不公平现象发生
     * @param sename
     * @param limit
     * @param timeout
     * @return
     */
    public String acquireSemaphore(String sename, int limit, long timeout) {
        String identifier = UUID.randomUUID().toString();
        //获取超时时间节点
        long cutTime = LocalTime.now().getSecond() - timeout;
        redisTemplate.executePipelined(new SessionCallback<Object>() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                BoundZSetOperations<String, Object> zSetOperations = redisTemplate.boundZSetOps(sename);
                //移除超时的信号量
                zSetOperations.removeRangeByScore(Integer.MIN_VALUE, cutTime);
                zSetOperations.add(identifier, LocalTime.now().getSecond());
                //获取自己的排名,判断是否获取成功
                Long rank = zSetOperations.rank(identifier);
                if (rank < limit) {
                    return identifier;
                }
                //失败需要删除
                zSetOperations.remove(identifier);
                return null;
            }
        });
        return null;
    }

执行流程:

  • 首先根据设置的过期时间,移除超时的信号量。
  • 之后生成用户的uuid,插入有序集合。
  • 判断用户排名,如果在limit限制范围,则返回uuid表示获取成功。
  • 否则失败。

删除信号量:

/**
     * 释放信号就是删除集合中标识符
     * @param sename
     * @param identifier
     * @return
     */
    public Long releaseSemaphore(String sename, String identifier) {
        return redisTemplate.opsForZSet().remove(sename, identifier);
    }

删除就是移除用户对应的标识符。

3.3 公平信号量

上述信号量如果系统运行时间不一致,将导致一个快的系统设置的信号量被慢系统设置的信号量抢走。也就是不公平现象。

考虑使用计数器来解决这种现象。

获取公平信号量:

/**
     * 公平获取信号量
     * @param sename
     * @param limit
     * @param timeout
     * @return
     */
    public String acquireFairSemaphore(String sename, int limit, long timeout) {
        String identifier = UUID.randomUUID().toString();
        //获取超时时间节点
        long cutTime = LocalTime.now().getSecond() - timeout;
        String czset = sename + ":owner";
        String ctr = sename + ":counter";
        redisTemplate.executePipelined(new SessionCallback<Object>() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                BoundZSetOperations<String, Object> zSetOperations = redisTemplate.boundZSetOps(sename);
                //移除超时的信号量
                zSetOperations.removeRangeByScore(Integer.MIN_VALUE, cutTime);
                zSetOperations.intersectAndStore(Collections.singleton(czset), sename, RedisZSetCommands.Aggregate.MAX);
                Long nowCtr = redisTemplate.opsForValue().increment(ctr, 1);
                redisTemplate.opsForZSet().add(czset, identifier, nowCtr);
                zSetOperations.add(identifier, LocalTime.now().getSecond());
                //获取自己的排名,判断是否获取成功,根据czset的计数器从而避免不同系统微弱时间差带来的影响
                Long rank = redisTemplate.opsForZSet().rank(czset, identifier);
                if (rank < limit) {
                    return identifier;
                }
                //失败需要删除
                zSetOperations.remove(identifier);
                redisTemplate.opsForZSet().remove(czset, identifier);
                return null;
            }
        });
        return null;
    }

执行步骤:

  • 移除过期信号量
  • 以聚合max合并信号量有序集合和存储用户计数器的有序集合。
  • 获取最新的计数器。
  • 插入信号量集合和用户计数器集合。
  • 判断用户计数器集合中的排名。如果符合limit则可以获取。
  • 否则失败,移除相关信息。

移除过程:

public Long releaseFairSemaphore(String sename, String identifier) {
        String czset = sename + ":owner";
        redisTemplate.opsForZSet().remove(czset, identifier);
        return redisTemplate.opsForZSet().remove(sename, identifier);
    }

移除对应集合中的数据。

刷新信号量:

/**
     * 刷新信号量
     * @param sename
     * @param identifier
     * @return
     */
    public boolean refreshFairSemaphore(String sename, String identifier) {
        //添加返回1说明添加成功,意味这失去了信号量
        if (redisTemplate.opsForZSet().add(sename, identifier, LocalTime.now().getSecond())) {
            //移除
            redisTemplate.opsForZSet().remove(sename, identifier);
            return false;
        }
        //否则更新成功,返回true
        return true;
    }

判断是否更新成功,成功则刷新成功,否则就是失去了信号量。删除对应的信息。

4 任务队列

根据插入顺序或者根据设定时间执行任务。

4.1 先进先出队列

使用列表作为队列,利用阻塞版本blpop执行任务。

入队代码:

/**
     * 将待发送邮件内容推入队列
     * @param seller
     * @param item
     * @param price
     * @param buyer
     */
    public void sendSoldEmailViaQueue(String seller, String item, double price, String buyer) {
        SoldItem soldItem = SoldItem.builder().seller(seller).item(item).price(price).buyer(buyer).pushTime(LocalTime.now().getSecond()).build();
        String soldItemJson = JSON.toJSONString(soldItem);
        redisTemplate.opsForList().rightPush("queue:email", soldItemJson);
    }

通过把发送邮件信息转为json,推入列表右侧。

获取任务:

/**
     * 阻塞获取队列内容,并解析发送邮件
     */
    public void processSoldEmailQueue() {
        String jsonstr;
        while (!QUIT) {
            jsonstr = (String) redisTemplate.opsForList().leftPop("queue:email", 30, TimeUnit.SECONDS);
            if (jsonstr == null) {
                continue;
            }
            SoldItem soldItem = JSON.parseObject(jsonstr, SoldItem.class);
            try {
                fetchDataAndSendEmail(soldItem);
            } catch (Exception e) {
                logger.error("send email failed.", e);
            } finally {
                logger.info("successful send email");
            }
        }

通过阻塞获取命令,等待时间30s。

4.2 延迟任务

需求支持延迟执行的任务。

通过设置两个队列,一个存储延迟队列任务,一个存储执行任务。在延迟队列任务到期后,放入执行任务队列进行执行。

入队:

/**
     * 创建延迟任务队列
     * @param queue
     * @param name
     * @param args
     * @param dealy
     */
    public void executeLater(String queue, String name, List<Object> args, long dealy) {
        String identifier = UUID.randomUUID().toString();
        FunctionCall build = FunctionCall.builder().identifier(identifier).queue(queue).name(name).args(args).build();
        String jsonString = JSON.toJSONString(build);

        if (dealy > 0) {
            redisTemplate.opsForZSet().add("delay:", jsonString, LocalTime.now().getSecond() + dealy);
        } else {
            redisTemplate.opsForList().rightPush("queue:" + queue, jsonString);
        }
    }

将执行信息json化,根据延迟信息判断进入哪个队列。

出队:

/**
     * 弹出延迟任务到任务队列
     * @throws InterruptedException
     */
    public void pollTask() throws InterruptedException {
        while (!QUIT) {
            Set<Object> zset = redisTemplate.opsForZSet().range("delay:", 0, 0);
            if (zset == null || zset.size() == 0) {
                Thread.sleep(100);
                continue;
            }
            List<Object> collect = new ArrayList<>(zset);
            Double score = redisTemplate.opsForZSet().score("delay:", collect.get(0));
            FunctionCall functionCall = JSON.parseObject((String) collect.get(0), FunctionCall.class);
            //未达到执行时间
            if (score > LocalTime.now().getSecond()) {
                Thread.sleep(100);
                continue;
            }
            //获取锁
            String s = constructLockService.accquireLockWithTimeout(functionCall.getIdentifier(), 10, 10, TimeUnit.SECONDS);
            if (Objects.equals(s, "")) {
                continue;
            }
            //移除并添加到执行队列
            Long remove = redisTemplate.opsForZSet().remove("delay:", collect.get(0));
            if (remove != 0) {
                redisTemplate.opsForList().rightPush("queue:" + functionCall.getQueue(), collect.get(0));
            }
            constructLockService.releaseLock(functionCall.getIdentifier(), s);
        }
    }

执行步骤:

  • 获取延迟队列第一个元素,判断是否加入执行任务队列,如果时间未到,进行下次循环。
  • 否则,获取锁,加锁进行添加。

5 消息拉取-多接收者消息发送与订阅

5.1 数据结构

通过两个有序集合来记录群组和用户以及用户查看的最大消息id。

群组集合:键为用户,分数为用户查看的最大消息id。
用户集合:键为参与的群组,分数为已经查看的群组最大消息id。

5.2 Java代码示例

创建群组聊天:

/**
     * 创建聊天室
     * @param sender
     * @param recipents
     * @param message
     */
    public void createChat(String sender, List<String> recipents, String message) throws InterruptedException {
        Long chatId = redisTemplate.opsForValue().increment("ids:chatId");
        recipents.add(sender);
        redisTemplate.executePipelined(new SessionCallback<Object>() {
            @Override
            public  Object execute(RedisOperations operations) throws DataAccessException {
                for (String recipent : recipents) {
                    //聊天室添加成员
                    redisTemplate.opsForZSet().add("chat:"+chatId, recipent, 0);
                    //成员添加聊天室
                    redisTemplate.opsForZSet().add("seen:"+recipent, chatId, 0);
                }
                return null;
            }
        });
        //发送消息
        sendChatMessage(sender, chatId, message);
    }

主要是创建群组,初始化用户。同时在用户集合中,加入新创建的群组id。

发送消息:

/**
     * 发送消息到消息队列
     * @param sender
     * @param chatId
     * @param message
     * @return
     * @throws InterruptedException
     */
    private boolean sendChatMessage(String sender, Long chatId, String message) throws InterruptedException {
        String lock = constructLockService.accquireLock("chat:" + chatId, 10);
        if (lock.equals("")) {
            return false;
        }
        long id = (long) redisTemplate.opsForValue().increment("ids:" + chatId);
        Message message1 = Message.builder().id(id).sender(sender).message(message).time(LocalTime.now().getSecond()).build();
        String toJSONString = JSON.toJSONString(message1);
        redisTemplate.opsForZSet().add("msgs:"+chatId, toJSONString, id);
        constructLockService.releaseLock("chat:"+chatId, lock);
        return true;
    }

主要是加锁发送:首先获取锁,然后获取自增消息id,之后把消息json化,添加到消息集合中,释放锁。

获取消息:

public Map<String, Set<Object>> fatchPendingMessage(String recipent) {
        Set<Object> chats = redisTemplate.opsForZSet().range("seen:" + recipent, 0, -1);
        Map<String, Set<Object>> messages = new HashMap<>();
        redisTemplate.executePipelined(new SessionCallback<Object>() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                for (Object chat : chats) {
                    String chatId = (String) chat;
                    Double id = redisTemplate.opsForZSet().score("seen:" + recipent, chat);
                    //获取所有消息
                    Set<Object> mes = redisTemplate.opsForZSet().rangeByScore("msgs:" + chatId, id, Double.MAX_VALUE);
                    ArrayList<Object> messList = new ArrayList<>(mes);
                    Object o = messList.get(mes.size() - 1);
                    Double maxId = redisTemplate.opsForZSet().score("msgs:", o);
                    //设置用户看过消息的最大条数
                    redisTemplate.opsForZSet().add("seen:"+recipent, chatId, maxId);
                    //设置聊天群用户看过的最大条数
                    redisTemplate.opsForZSet().add("chat:"+chatId, recipent, maxId);
                    //删除所有用户看过的消息
                    Set<Object> range = redisTemplate.opsForZSet().range("msgs:" + chatId, 0, 0);
                    Object minId = new ArrayList<>(range).get(0);
                    Double min = redisTemplate.opsForZSet().score("msgs:" + chatId, minId);
                    redisTemplate.opsForZSet().removeRangeByScore("msgs:"+chatId, 0, min);
                    //消息放入用户消息中
                    messages.put(chatId, mes);
                }
                return null;
            }
        });
        return messages;
    }

主要步骤:

  • 获取用户当前各个群组的已看消息id,进行遍历处理。
  • 根据id获取当前群组消息的所有剩余消息。
  • 查看最大消息id,更改用户该群组的消息id和对应群组中该用户分值设置。
  • 移除群组消息集合中需要清理的消息。
  • 将群组消息加入用户消息集合。
  • 遍历完成后,返回用户消息map。

加入群组:

/**
     * 用户加入群组
     * @param chatId
     * @param user
     */
    public void joinChat(String chatId, String user) {
        //获取目前群组最大消息id
        long id = (long) redisTemplate.opsForValue().get("ids:" + chatId);
        redisTemplate.executePipelined(new SessionCallback<Object>() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                redisTemplate.opsForZSet().add("seen:"+user, chatId, id);
                redisTemplate.opsForZSet().add("chat:"+chatId, user, id);
                return null;
            }
        });
    }

就是设置用户集合,和群组集合,分值需设置为当前的最大消息id,因为不能看到之前的消息。
离开群组:

/**
     * 用户离开群组
     * @param chatId
     * @param user
     */
    public void leaveChat(String chatId, String user) {
        redisTemplate.executePipelined(new SessionCallback<Object>() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                //用户移除群组
                redisTemplate.opsForZSet().remove("seen:"+user, chatId);
                //群组移除用户
                redisTemplate.opsForZSet().remove("chat:"+chatId, user);
                //获取群组个数
                Long aLong = redisTemplate.opsForZSet().zCard("chat:" + chatId);
                //删除这个群组和对应消息数据,计数器
                if (aLong == 0) {
                    redisTemplate.delete(Arrays.asList("chat:"+chatId, "msg:"+chatId, "ids:"+chatId));
                }
                //再次移除已读消息
                Set<Object> range = redisTemplate.opsForZSet().range("chat:" + chatId, 0, 0);
                Object minId = new ArrayList<>(range).get(0);
                Double min = redisTemplate.opsForZSet().score("chat:" + chatId, minId);
                redisTemplate.opsForZSet().removeRangeByScore("chat:"+chatId, 0, min);
                return null;
            }
        });
    }

删除用户集合信息,和群组对应信息。并检查群组是否需要释放。消息也需要再次检查清理。

参考文献

[1] 《Redis实战》.

;