目录
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实战》.