我一直认为一个现代成熟可行的商业软件至少有四种数据源,即mysql+redis+es+mq。mysql也有全文搜索的概念,redis也能用stream替换mq,但缺乏扩展性,一旦混用很有可能要造轮子要写屎山,当然这四个数据库都是泛指,根据具体应用mysql可以替换为TiDB,PostgreSQL,PolarDB,mq可选Kafka或者RabbitMQ。

redis这里主要是围绕redis6.x版本进行扩展记录,涉及八个方面,分别是redis的八种数据结构及应用,redis的c语言底层数据结构,redis的缓存策略,redis的布隆过滤器,redis的双写一致性,redis的多线程,redis多路复用,redis限流策略,redis的四种高可用架构。

redis基本数据结构及应用

string类型

string是redis最基础的类型,redis有个特点,就是key永远固定是string类型,且不可重复。redis的key默认最大是512M同样value是string类型的话也是最大512M。
string是最常用的一个类型,功能除了存储对象,还支持原子性操作,批量操作,分布式锁。
存储对象就是 set k1 v1,k1最佳不要超过1k,value过大会造成大key问题。

127.0.0.1:6379> set k1 1
OK

原子操作得益于redis的工作线程是单线程:

127.0.0.1:6379> incr k1
(integer) 2
127.0.0.1:6379> decr k1
(integer) 1
127.0.0.1:6379> decrby k1 3
(integer) -2
127.0.0.1:6379> incrby k1 5
(integer) 3

这里注意 是可以减到负数的。

incr可以应用到文章的点赞,在看之类的,如果超过10w就显示10w+

批量操作mset是原子操作,可以认为有一个set失败就全部失败,mset会覆盖已有的key的值,如果不想覆盖就要使用msetnx,批量取的话就是mget。mset,mget的往返时延是多次叠加的所以相对单个set,get没有什么优势,如果是批量读写最好使用redis的pipeline。

127.0.0.1:6379> mset k2 1 k3 1
OK
127.0.0.1:6379> mget k2 k3
1) "1"
2) "1"
127.0.0.1:6379> 

分布式锁简称redlock红锁,红锁的实现有很多种,比如使用zookeeper之类的也能实现,手撸脚本也能实现,而基于redis实现的分布式锁其基础就是string类型的setnx操作:

127.0.0.1:6379> setnx kk redlock

简单流程就是多个线程取请求资源都要获得redis的key才行,拿到key的执行,没拿到的等待。
但是同样有个问题在这里面,如果拿到锁的线程执行异常没有释放锁,那么key永远被霸占,守寡一辈子。所以锁要有过期自动释放的机制。于是2.6.12版本开始,redis为SET命令增加了一系列选项::

SET key value [EX seconds] [PX milliseconds] [NX|XX]

EX seconds – Set the specified expire time, in seconds.
PX milliseconds – Set the specified expire time, in milliseconds.
NX – Only set the key if it does not already exist.
XX – Only set the key if it already exist.
EX seconds – 设置键key的过期时间,单位时秒
PX milliseconds – 设置键key的过期时间,单位时毫秒
NX – 只有键key不存在的时候才会设置key的值
XX – 只有键key存在的时候才会设置key的值

注意: 由于SET命令加上选项已经可以完全取代SETNX, SETEX, PSETEX的功能,所以在将来的版本中,redis可能会不推荐使用并且最终抛弃这几个命令

127.0.0.1:6379> set kk redlock NX PX 6000
OK

基于这个setnx命令,可以围绕分布式锁讨论几个问题:
1.jvm的单锁会有什么问题?
jvm锁住的当前服务进程,其他服务仍然可操作某公用的对象,高并发下会导致线程内存副本和主存内容不一致导致超卖之类的问题。如果jvm锁可以解决问题,也要考虑锁超时的问题,这种情形下,ReentrantLock就要比synchronized要l灵活。
2. 自己实现分布式锁有什么问题?
自己实现就是基于这个setnx指令+lua脚本来做。为什么要配合lua脚本?为了保证原子性。
说一下原子性,除了setnx指令的超时释放锁避免程序宕机根本走不到finally内之外。还有个问题,多个线程加锁和释放锁可能会导致一个线程获取锁后任务没执行完,锁就过期释放了,这时候另一个线程进来发现自己可以加锁,结果刚加上锁,第一个线程就执行完业务去释放锁,正好释放了第二个线程刚加的锁。这里的问题是不能删除别的线程的锁,所以finally块删除锁的时候要校验锁的value是不是当前线程设置的字符串。这样删除锁要先验证锁是不是自己的再去删除锁,这两步不具有原子性,很可能验证成功了删除仍然失败。

//随机字符串当锁的value
String value = uuid+Thread.currentThread()+getName();
try {
//加锁
 Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(REDIS_LOCK,10L,TimeUnit.SECONDS);
if(Boolean.FALSE.equals(flag)) {
//抢锁失败
return;
}
} finally {
//解锁
if (Objects.equals(stringRedisTemplate.opsForValue().get(REDIS_LOCK), value)) {
            stringRedisTemplate.delete(REDIS_LOCK);
        }
}
//业务逻辑

如果不用lua脚本去统一(验证+删除)的原子性,redis本身也有事务的概念,所以尝试使用redis的事务来做。

redis事务也是ACID四个概念,只不过redis事务执行错误是不会全部回滚的,执行成功的不会回滚。redis事务也不保证持久性,因为redis的持久化策略都是异步,这样保证redis性能。

redis事务由五个原子指令支持,分别是 MULTIEXECDISCARDWATCHUNWATCH
MULTI:标记一个事务块的开始。事务块内的多条命令会按照先后顺序被放进一个队列当中,最后由 EXEC 命令原子性(atomic)地执行。
EXEC:执行所有事务块内的命令。假如某个(或某些) key 正处于 WATCH 命令的监视之下,且事务块中有和这个(或这些) key 相关的命令,那么 EXEC 命令只在这个(或这些) key 没有被其他命令所改动的情况下执行并生效,否则该事务被打断(abort)。操作被打断时会返回控制nil。

127.0.0.1:6379> multi
OK
127.0.0.1:6379> incr k1
QUEUED
127.0.0.1:6379> incrby k1 2
QUEUED
127.0.0.1:6379> decr k1
QUEUED
127.0.0.1:6379> exec
1) (integer) 4
2) (integer) 6
3) (integer) 5

DISCARD:顾名思义,取消事务,放弃执行事务块内的所有命令,如果正在使用 WATCH 命令监视某个(或某些) key,那么取消所有监视,等同于执行命令 UNWATCH。
WATCH:监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。
UNWATCH:取消 WATCH 命令对所有 key 的监视。如果在执行 WATCH 命令之后, EXEC 命令或 DISCARD 命令先被执行了的话,那么就不需要再执行 UNWATCH 了。因为 EXEC 命令会执行事务,因此 WATCH 命令的效果已经产生了;而 DISCARD 命令在取消事务的同时也会取消所有对 key 的监视,因此这两个命令执行之后,就没有必要执行 UNWATCH 了。
WATCH命令可以认为是redis的乐观锁,也就是redis的CAS,被WATCH的key会被监视防止被篡改,如果在执行事务期间发现有key被改了,那么整个事务会被取消。

127.0.0.1:6379> watch k1 
OK
127.0.0.1:6379> set k1 8
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set k1 9
QUEUED
127.0.0.1:6379> exec
(nil)

有了redis的事务支撑,finally代码更改如下:

finally {
while (true) {
    stringRedisTemplate.watch(REDIS_LOCK);
    if (Objects.equals(stringRedisTemplate.opsForValue().get(REDIS_LOCK),value)){
    stringRedisTemplate.setEnableTransactionSupport(true);
    stringRedisTemplate.multi();
    stringRedisTemplate.delete(REDIS_LOCK);
    List<Object> list = stringRedisTemplate.exec();
    if(CollectionUtils.isEmpty(list)){
              continue;
      }
    }
    stringRedisTemplate.unwatch();
    break;
  }
}

使用lua脚本释放锁

if redis.call("get",KEYS[1]) == ARGV[1] 
then
	return redis.call("del",KEYS[1])
else
    return 0
end;
String LUA_SCRIPT = "if redis.call("get",KEYS[1]) == ARGV[1]\n" +
                    "then\n" +
                    "    return redis.call("del",KEYS[1])\n" +
                    "else\n" +
                    "    return 0\n" +
                    "end";
DefaultRedisScript<Long> redisScript = new defaultRedisScript<>(LUA_SCRIPT, Long.class);
            // 参数一:redisScript,参数二:key列表,参数三:arg(可多个)
Long result = stringRedisTemplate.execute(redisScript,Collections.singletonList(REDIS_LOCK), value);
if (Objects.equals(result, 1)) {
                logger.info(" del redis lock success!")
    }

这样就保证了原子性,不过还有剩下俩问题——业务没执行完锁就过期了,所以需要锁可以自动续期保证过期时间在业务时间之上。
再者redis集群异步复制也会造成锁丢失,主节点如果没把锁复制给从节点就挂了,锁就会丢失。
因此最佳的实现是不要造轮子,直接使用官方的redlock实现即redisson。redisson提供了看门狗机制,这个可以解决以上两个问题。

link----redis分布式锁

简单应用,参考github地址的quick start即可。

link----github-redission

先引入依赖,然后配置config:

@Configuration
public class MyRedisConfig {

    @Value("${ipAddr}")
    private String ipAddr;

    @Bean(destroyMethod = "shutdown")
    public RedissonClient redisson() {
        Config config = new Config();
        // 单例模式配置
        config.useSingleServer().setAddress("redis://" + ipAddr + ":6379");
        //集群模式配置
        config.useClusterServers()
       // use "rediss://" for SSL connection
      .addNodeAddress("redis://"+ipAddr+":7181");
        return Redisson.create(config);
    }
}

这里redisson也支持reactive,响应式的东西太多了以后再整理。
redisson实现了java的juc,所以天然融合ReentrantLock。
当然redlock也只是其中的一种锁,更多的参考:

link----redis锁

所以最终实现为:

//随机字符串当锁的value
RLock rLock = redisson.getLock(REDIS_LOCK);
rLock.lock();
try {
//加锁
 Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(REDIS_LOCK,10L,TimeUnit.SECONDS);
if(Boolean.FALSE.equals(flag)) {
   //抢锁失败
     return;
   }
} finally {
//解锁
if (rLock.isLocked() && rLock.isHeldByCurrentThread()) {
     rLock.unlock();
   }
//业务逻辑
}

看门狗的检查锁的超时时间为30s,每到20s就会自动续费成30s。
当然看门狗可配置,配置参考如下:

link----redisson配置

看门狗的源码就不看了,注意点是如果lock使用了时间构造参数,则lock的看门狗机制会失效

rLock.lock(10, TimeUnit.SECONDS)

hash类型

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议