Redis-zset基于score值pop弹出的原子性操作
背景:
近期接到一个需求,逻辑顺序是将一个zset中的元素通过将zRangeByScore查出来,做某些业务操作,再通过zrem移除。本身这两个命令操作并不难,但业务设计上来说这这个zset是全局所有用户共享,在并发的情况下,比如:
1、线程a从zset中根据zRangeByScore zset 0 100
命令查询一批数据->业务操作->zrem删除
2、线程b从zset中根据zRangeByScore zset 50 160
命令查询一批数据->业务操作->zrem删除
如果在线程a业务操作还没有完成,没有执行zrem操作时,线程b来查询,两个线程就会访问到重复数据,产生重复操作,如果业务操作上做了幂等还好,否则就是bug。
一般情况下我们会考虑在这一整个操作外层加锁,这种做法有两个缺点:
- 锁持有时间过长
- 锁粒度较大,上面说过前提是这个zset全局共享的数据,没有精确到用户,所有线程执行到这里都会变成串型化操作,效率很低
所以这里介绍另一种实现方案:zset基于score值pop弹出的原子性操作,优化后的逻辑如下:
1、线程a从zset中根据zpopByScore zset 0 100
查询并删除一批数据->做业务操作
2、线程a从zset中根据zpopByScore zset 50 150
查询并删除一批数据->做业务操作
可以看到只要这里只要保证zpopByScore的操作是原子性(下面介绍如何实现)的就能大幅降低锁粒度,提高效率。
另外注意一点,再实际业务操作过程中可能遇到异常,需要回滚,所以我们可把逻辑优化成:
1、线程a从zset中根据zpopByScore zset 0 100
查询并删除一批数据->做业务操作->异常时回滚(将pop出来的数据重新添加到zset中)
zPopByScore实现:
原生的redis命令zset有实现zpopmin、zpopmax等,有兴趣的可以参考https://redis.io/commands/?name=zpo
这里通过lua脚本实现自定义操作:
Java版本-引入依赖:
<!-- https://mvnrepository.com/artifact/org.springframework.data/spring-data-redis -->
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>2.2.4.RELEASE</version>
</dependency>
代码实现:
public static final RedisScript<List<Object>> ZPOP_BY_SCORE_SCRIPT;
/**
KEYS[1]:需要查询的zset-key
ARGV[1]:score下限
ARGV[2]:score上限
*/
static {
String string= "local result = redis.call('zrangebyscore', KEYS[1], ARGV[1], ARGV[2]) " +
"if result[1] " +
"then " +
" redis.call('zrem', KEYS[1], unpack(result)) " +
" return result " +
"else " +
" return {} " +
"end ";
ZPOP_BY_SCORE_SCRIPT = new DefaultRedisScript(string, List.class);
}
/**
简单调用示例
*/
public static List<Object> zPopByScore(String key, double min, double max) {
List<String> keys = Lists.newArrayList(key);
List<Object> args = Lists.newArrayList(min, max);
return redisTemplate.execute(RedisUtil.ZPOP_BY_SCORE_SCRIPT, keys, args.toArray());
}
总结:
本文主要介绍的是redis如何通过lua脚本方式实现popByScore命令,其中花了比较大的篇幅做背景介绍,主要是为了讲清楚什么样的业务场景下会用到它,希望大家同时也思考一下你遇到类似问题时这个方案是否真的适用。毕竟技术是为了业务服务,没有最好的方案,只有最合适的。