redis篇-基础与应用篇(上)

前言

参照 《redis深度历险-核心原理与应用实践》

一、redis基础结构简介

1. 五种基础数据结构

a. string

简介:字符串是redis最简单的数据结构,内部表示是一个字符数组。redis所有的数据结构都以唯一的key字符串作为名称,通过key获取value。

特性:

  • 动态字符串,采用预分配冗余空间方式减少内存的频繁分配(当字符串长度小于1MB时,扩容是加倍现有空间;大于1MB时,每次多扩1MB)
  • 字符串最大长度是512MB

操作:get/set/mget/mset/setex(设置过期时间)/setnx(不存在则执行创建)/incr(整形+1)/incrby(整形+部分数值)

位图:字符串由多个字节组成,每个字节由8bit组成,可将一个字符串看成很多bit的组合,这便是bitmap

b. list

简介:双向链表,插入和删除操作很快,时间复杂度为O(1),索引定位较慢

操作:rpush/lpop(队列);rpush/rpop(栈);lindex/lrange/ltrim(O(n)慎用);

底层优化(快速列表):

redis的list底层不是简单的linkedlist

  • 在列表元素较少的情况下,会使用连续的内存存储(ziplist),它将所有的元素彼此挨在一起存储,分配的是连续的内存空间
  • 当数据较多时,转换成quicklist。由于普通链表中,指针消耗存储空间较大,还会加重内存碎片化,因此redis将链表和ziplist结合起来,组成quicklist,如下图所示。quicklist既满足了快速插入删除的性能,又不会出现太大的空间冗余

c. hash

简介:无序字典,内部存储很多键值对,实现使用数组+链表形式

特点:

  • redis字典的值只能是字符串,rehash的方式与java的hashmap不一样,采用渐进式rehash策略
  • 当hash移除了最后一个元素后,旧的hash的数据结构自动删除,内存被回收

渐进式rehash:

  • 在rehash的同时,保留新旧两个hash结构
  • 查询时会查询两个hash结构,在后续的定时任务和hash操作指令中,渐进式将旧的hash内容一点点迁移到新的hash结构中。迁移完成后,使用新的hash取代之

操作:hset/hgetall/hlen/hget/hmset/hincrby(用于整形增加数值)

d. set

简介:内部是无序键值对,相当于特殊的字段,value为null

特点:set最后一个元素被移除后,数据结构被删除,内存被回收

操作:sadd/smembers/smember(查询元素是否存在)/scard(取长度)/spop(弹出一个)

e. zset

简介:类似于java的scortedSet和hashmap的结合体

特征:

  • 是一个set,可以保证内部value的唯一性
  • 可以赋予每个value一个值score,代表这个value的排序权重,内部实现为跳跃列表
  • zset最后一个元素被删除后,数据结构自动删除,内存被回收

操作:zadd/zrange(按score排序输出)/zrevrange(逆序输出)/zscard(获取长度)/zscore(获取指定值的score)/zrank(排名)/zrangebyscore(按分数区间遍历)

跳跃列表:由于zset需要支持快速随机插入和删除,因此不使用数组实现;由于需要按数值排序,链表不支持二分查找,纯链表不能满足需求。此时,便使用跳表。跳表特征如下:

  • 最下面一层元素串起来,每隔几个元素挑选出一个代表,再将代表串起来,按此方法选出二级代表,三级代表...
  • 定位插入点时,先从顶层开始,逐渐下潜到下一级,最后下潜到最底层

2. 容器型数据结构的通用规则

list/set/hash/zset四种数据结构共享以下两条规则:

  • create if not exist:如果容器不存在,就创建一个再进行操作
  • drop if no elements:容器中元素没有数据,就删除容器

3. 过期时间

特点:

  • redis所有数据类型都可以设置过期时间,时间到了,redis自动删除对象
  • 过期时间是以对象为单位的,例如,hash结构过期是整个hash对象的过期,不是某一个子key过期
  • 如果一个字符串已经设置了过期时间,如果调用set方法修改它,它的过期时间会消失

二、分布式锁

1. redis分布式锁概述

简介:为了限制分布式程序并发问题,需要使用分布式锁

思路:

方法1. setnx(set if not exists)设置锁;del释放锁

问题:del指令若没有被调用,则会陷入死锁

方法2. setnx,之后运行expire,为锁加上过期时间,避免陷入死锁

问题:expire没有顺利执行,会造成死锁

方法3. redis为解决上述问题,提供了 setnx name value ex expiretime 指令,setnx和expire组成了原子指令

2. 超时时间

超时问题概述:方法3解决expire与setnx非原子的问题,但不能解决超时问题。问题如下:

  1. 如果加锁和释放锁之间的逻辑执行的时间过长,容易导致锁超时,此时第一个持有锁的线程没有处理完,第二个线程就重新持有锁,导致临界区代码不能严格串行执行
  2. 其他线程可能会释放redis的锁

解决方法:

对于问题2,可采用在 setnx时,设置随机数,在执行删除指令时,匹配随机数,避免别的线程误删的方法

tag = random.nextint()
if redis.set(key, tag, nx=True, ex=5):
    do_something()
    redis.del_if_equal(key,tag)

由于redis中没有del_if_equal,因此可以采用lua脚本实现

if redis.call("get",KEYS[1])==ARVG[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

3. 可重入性

概述:一个线程在持有一个锁后,可多次进入临界区(例如执行递归调用时,无需再加锁)

实现:redis若需要实现可重入,需要客户端对set封装,使用 ThreadLocal存储当前持有锁数量

package redis.RedisWithReentranLock;

import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;

import java.util.HashMap;
import java.util.Map;

public class RedisWithReentranLock {
    private ThreadLocal<Map<String, Integer>> locks = new ThreadLocal<>(); // 每个线程维护一个锁计数器
    private Jedis jedis = new Jedis(new HostAndPort("127.0.0.1", 6379));
    private long expire;

    public RedisWithReentranLock(long expire) {
        this.expire = expire;
    }

    // 加锁
    private boolean _lock(String key, long expire) {
        String rsp = jedis.set(key, "1", new SetParams().nx().ex(expire));
        return rsp != null && rsp.equals("OK");
    }

    // 解锁
    private boolean _unlock(String key) {
        return jedis.del(key) == 1;
    }

    // 获取锁
    private Map<String, Integer> getLock() {
        Map<String, Integer> lock = locks.get();
        if (lock == null) {
            lock = new HashMap<>();
            locks.set(lock);
        }
        return locks.get();
    }

    // 加锁
    public boolean lock(String key) {
        // 判断线程是否已经加锁,加了则无需继续加
        Map<String, Integer> lock = getLock();
        Integer lockCnt = lock.get(key);
        if (lockCnt != null && lockCnt > 0) {
            lock.put(key, ++lockCnt);

            return true;
        }
        // 未加锁,则请求加
        boolean dolock = _lock(key, expire);
        // 加锁成功,设置已经被线程持有一次
        if (dolock) {
            lock.put(key, 1);
            return true;
        }
        return false;
    }

    public boolean unlock(String key) {
        Map<String, Integer> lock = getLock();
        Integer lockCnt = lock.get(key);
        // 未加锁
        if (lockCnt == null || lockCnt == 0) {
            return false;
        }
        // 已被线程持有,若加锁数量大于1,则只需要计数器减1
        lockCnt--;
        if (lockCnt > 0) {
            lock.put(key, lockCnt);
        } else {
            _unlock(key);
            lock.remove(key);
        }
        return true;
    }


    public static void main(String[] args) throws InterruptedException {
        RedisWithReentranLock redisWithReentranLock = new RedisWithReentranLock(100);
        System.out.println(redisWithReentranLock.lock("reTest"));
        Thread.sleep(5000);
        System.out.println(redisWithReentranLock.lock("reTest"));
        System.out.println(redisWithReentranLock.unlock("reTest"));
        Thread.sleep(10000);
        System.out.println(redisWithReentranLock.unlock("reTest"));
    }
}

注意:以上并不是可重入锁的全部,精确一些,还需要考虑内存锁计数器的过期时间

三、延迟队列

适用场景:只有一组消费者组,不考虑消息的高可靠性(无ack保证)

1. 异步消息队列

操作:lpop/rpop lpush/rpush

redis支持多个生产者/消费者并发写入/读取数据

2. 队列空了怎么办

队列空后,消费者端会陷入 pop空转,拉高消费者的CPU消耗,redis的QBS也会被拉高。

a. 解决方法1:通常可以使用sleep解决这个问题

问题:若有多个消费者,即使每个消费者sleep,并发依旧大

b. 解决办法2:阻塞读

思路:使用blpop/brpop替代前面的 lpop/rpop (blpop key waittime)

问题:空闲连接问题。如果线程一直阻塞,redis客户端成了闲置连接,blpop/brpop会抛出异常(解决方法:捕获异常并重试)

3. 锁冲突处理

客户端请求加锁时,若加锁失败,一般有一下处理策略:

  1. 直接抛出异常,通知用户(适合用户直接发起请求的场景)
  2. sleep一会,然后重试(如果抢占锁的线程较多,会导致后续处理延迟)
  3. 将请求转移到延迟队列,稍后重试(适合异步消息处理,可以避开锁冲突)

4. 延迟队列的实现

思路:通过redis的zset实现。将消息序列化为zset的value,消息的到期处理时间作为socre,多线程轮询zset。

代码:

package redis.DelayingQueue;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.TypeReference;
import redis.clients.jedis.Jedis;

import java.lang.reflect.Type;
import java.util.Set;
import java.util.UUID;

public class DelayingQueue<T> {
    // 每个延迟任务类
    static class TaskItem<T> {
        public String id;
        public T data;

        @Override
        public String toString() {
            return "TaskItem{" +
                    "id='" + id + '\'' +
                    ", data=" + data +
                    '}';
        }
    }

    private final Jedis jedis;
    private final String keys;
    private final long sleepMicroseconds;
    private final Type taskType = new TypeReference<TaskItem<T>>() {
    }.getType();  // 泛型序列化需要使用TypeReference

    public DelayingQueue(Jedis jedis, String keys, long sleepMicroseconds) {
        this.jedis = jedis;
        this.keys = keys;
        this.sleepMicroseconds = sleepMicroseconds;
    }

    // 将任务加入延迟队列
    public void addDelayQueue(T data) {
        // 1. 序列化
        TaskItem<T> taskItem = new TaskItem<>();
        taskItem.id = UUID.randomUUID().toString();
        taskItem.data = data;
        String jsonTask = JSON.toJSONString(taskItem);
        // 2. 加入队列
        this.jedis.zadd(this.keys, System.currentTimeMillis() + this.sleepMicroseconds, jsonTask);
    }

    // 工作方法,轮询延迟队列,获取元素
    public void worker() {
        // 线程未被中断,就轮询
        while (!Thread.interrupted()) {
            // 获取当前时间已经到期的任务
            Set<String> tasks = jedis.zrangeByScore(this.keys, 0, System.currentTimeMillis());
            // 无任务,睡眠500毫秒
            if (tasks.isEmpty()) {
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                continue;
            }
            // 获取一个任务,并抢占
            String taskJson = tasks.iterator().next();
            if (jedis.zrem(this.keys, taskJson) > 0) {
                TaskItem<T> taskItem = JSON.parseObject(taskJson, taskType);
                System.out.println("get task:" + taskItem.id);
                handler(taskItem.data);
            }
        }
    }

    // 任务执行句柄
    private void handler(T data) {
        System.out.println("get task data:" + data.toString());
    }

    public static void main(String[] args) throws InterruptedException {
        Jedis jedis = new Jedis("127.0.0.1", 6379);
        DelayingQueue<String> queue = new DelayingQueue<>(jedis, "testList", 5000);
        // 生产者队列, 放入数据
        Thread producer = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                queue.addDelayQueue("job:" + i);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        Thread worker = new Thread(queue::worker);
        producer.start();
        worker.start();
        producer.join();
        Thread.sleep(20000);
        worker.interrupt();
        System.out.println("工作线程退出");
        worker.join();
    }
}

优化:上述代码中,同一个任务可能会被多个进程取的,并在zrem中竞争,导致线程浪费了一次迭代。可以考虑使用lua脚本将zrangebyscore和zrem整合成原子操作,减少redis请求次数

四、位图

概述:redis提供位图,节省存储空间。位图不是特殊的数据结构,其实只是普通的字符串(byte数组),使用者可以用get/set直接获取或设置整个位图的内容,也可以用getbit/setbit设置位图某一位

1. 基本用法

特点:redis 的位数组是自动扩展的,如果设置了某个偏移超过了现有的内容范围,会自动将位数组进行0扩充

例子:使用位操作设置"he"字符串

h的ascii码为104(0110 1000)    e的ascii码为101(0110 0101)

127.0.0.1:6379> setbit s 1 1
(integer) 0
127.0.0.1:6379> setbit s 2 1
(integer) 0
127.0.0.1:6379> setbit s 4 1
(integer) 0
127.0.0.1:6379> get s
"h"
127.0.0.1:6379> setbit s 9 1
(integer) 0
127.0.0.1:6379> setbit s 10 1
(integer) 0
127.0.0.1:6379> setbit s 13 1
(integer) 0
127.0.0.1:6379> setbit s 15 1
(integer) 0
127.0.0.1:6379> get s
"he"

上述例子看视为零存整取,还可以进行零存零取与整存零取

  • 零存零取:getbit w 1 1; getbit w 1;
  • 整存零取:set w h; getbit w 1;

2. 统计和查找

常用指令:

  • 统计指令:bitcount位图统计 (bitcount  key [start end])
  • 查找指令:bitpos位图查找 (bitpos 键 需要查找的位 [起始字符] [结束字符]  )

3. 魔术指令 bitfield

概述: getbit和setbit指定的值都是单个位的,如果需要一次操作多个位,需要使用管道处理。redis在3.2版本之后,新增了bitfield,可以一次性操作多个位

常用指令:get/set/incrby,最多支持64位,超过64位需要使用多个子指令

例子:

键s存储如图:

1) 查询操作

127.0.0.1:6379> bitfield s get u5 0     // 第一位开始取5位 01101=13
1) (integer) 13
127.0.0.1:6379> bitfield s get u4 1     // 第二位开始取4位 1101=13
1) (integer) 13
127.0.0.1:6379> bitfield s get u3 1     // 第二位开始取3位 110=6
1) (integer) 6
127.0.0.1:6379> bitfield s get i3 1     // 第二位开始取3位(有符号) 110=-2
1) (integer) -2
127.0.0.1:6379> bitfield s get i4 1     // 第二位开始取4位(有符号) 1101=-3
1) (integer) -3

当获取位数超过限制时(无符号数63位,有符号数64位),redis会报参数错误

127.0.0.1:6379> set w testtesttesttest
OK
127.0.0.1:6379> bitfield w get u63 0
1) (integer) 4193618412526811578
127.0.0.1:6379> bitfield w get u64 0
(error) ERR Invalid bitfield type. Use something like i16 u8. Note that u64 is not supported but i64 is.
127.0.0.1:6379> bitfield w get i64 0
1) (integer) 8387236825053623156
127.0.0.1:6379> bitfield w get i65 0
(error) ERR Invalid bitfield type. Use something like i16 u8. Note that u64 is not supported but i64 is.

 此时,可以使用多个子指令获取

127.0.0.1:6379> bitfield w get u63 0 get u63 64
1) (integer) 4193618412526811578
2) (integer) 4193618412526811578

2) 设置操作

set操作

127.0.0.1:6379> bitfield s set u8 8 97     // 将第8位开始的8位(第二个字符)设置为97(a)
1) (integer) 101
127.0.0.1:6379> get s
"ha"

incrby操作

127.0.0.1:6379> set w hello
OK
127.0.0.1:6379> bitfield w incrby u4 1 1     // 取 1101(13) 并加1
1) (integer) 14
127.0.0.1:6379> bitfield w incrby u4 1 1     // 取 1110(14) 并加1
1) (integer) 15
127.0.0.1:6379> bitfield w incrby u4 1 1     // 取 1111 并加1,此时溢出,折返
1) (integer) 0

bitfield提供了溢出策略子指令overflow,用户可以选择溢出行为,默认为wrap,可供选择的还有失败(fail)(报错不执行),以及饱和截断(sat)(超过范围就停留在最大或最小值)。overfilow指令只影响接下来的第一条指令,这条指令执行完后,策略会变成wrap

127.0.0.1:6379> set w hello
OK
127.0.0.1:6379> bitfield w overflow sat incrby u4 1 1
1) (integer) 14
127.0.0.1:6379> bitfield w overflow sat incrby u4 1 1
1) (integer) 15
127.0.0.1:6379> bitfield w overflow fail incrby u4 1 1
1) (nil)
127.0.0.1:6379> bitfield w incrby u4 1 1
1) (integer) 0

五、HyperLogLog

概述:统计uv时,需要对用户去重,可以采用set保存用户id。但当用户数量较多时,十分浪费空间,若统计结果不需要十分精确,可以使用HyperLogLog去重统计(标准误差在0.81%)

1. 使用方法

a. pfadd与pfcount

指令:pfadd(增加计数)和pfcount(获取计数),pfadd用法与set类似,把id增加进去就可以,pfcount和scard用法类似,直接获取计数值即可

测试例子:

package redis.HyperLogLogLearn;

import redis.clients.jedis.Jedis;

public class PfTest {
    public static void main(String[] args) {
        Jedis jedis = new Jedis("127.0.0.1", 6379);
        for (int i = 0; i < 1000000; i++) {
            jedis.pfadd("pftest", "user" + i);
        }
        System.out.println(jedis.pfcount("pftest"));
        jedis.close();
    }
}

运行上述代码,可见与精确值相差了 1788个,误差为0.1788%

将程序再执行一遍,发现结果不变,仍为1788,去重功能起了作用

b. pfmerge

指令:pfmerge用于将多个pf计数值累计在一起,形成新的pf值。例如,需要将两个页面的uv值合并到一起,便可使用pfmerge

例子:

更改上述程序,使用pfadd往'"pftest1"中增加999000-1000100号元素,再执行下列指令

127.0.0.1:6379> pfcount pftest1
(integer) 1097
127.0.0.1:6379> pfmerge pftest1 pftest
OK
127.0.0.1:6379> pfcount pftest1
(integer) 1001869

可以看到,经过pfmerge之后,pftest1变成了1001869,相比pftest,增加了约100

2. HyperLogLog特性与原理

a. 特性

HyperLogLog需要占用12kb,因此不适合单个用户相关的数据统计。redis对HyperLogLog数据结构进行了优化,在计数值较小时,存储空间采用稀疏矩阵,空间很小,当计数值变大,稀疏矩阵占用空间超过阈值时,才会一次性转变成稠密矩阵,才会占用12kb

b. 原理

实验一:

给定一系列随机整数,记录下低位连续零位的最大长度K(maxbit),通过K值预测随机数的数量N

package redis.HyperLogLogLearn;

import java.util.concurrent.ThreadLocalRandom;

public class pfPrincipleTest1 {
    private int maxBits = 0;

    public void random() {
        // 取32位的数
        long value = ThreadLocalRandom.current().nextLong(2L << 32);
        int bits = lowZeros(value);
        if (this.maxBits < bits) {
            this.maxBits = bits;
        }
    }

    // 获取最低位为1的位偏移
    public int lowZeros(long val) {
        int bitCnt = 1;
        long valDiv = val;
        while (valDiv > 0) {
            long bitNum = valDiv % 2;
            if (bitNum == 1) {
                return bitCnt - 1;
            }
            valDiv = valDiv >> 1;
            bitCnt++;
        }
        return bitCnt - 2;
    }

    static class Exp {
        private int loopNum;
        private pfPrincipleTest1 pfPrinciple;

        public Exp(int loopNum) {
            this.loopNum = loopNum;
            this.pfPrinciple = new pfPrincipleTest1();
        }

        public void work() {
            for (int i = 0; i < loopNum; i++) {
                this.pfPrinciple.random();
            }
        }

        public void printf() {
            System.out.printf("loop num:%d  log loop num:%.2f  maxbits:%d\n",
                    this.loopNum, Math.log(this.loopNum) / Math.log(2), this.pfPrinciple.maxBits);
        }
    }

    public static void main(String[] args) {
        int step = 100;
        for (int loopNum = 100; loopNum < 10000000; loopNum = loopNum + step) {
            Exp exp = new Exp(loopNum);
            exp.work();
            exp.printf();
            step = step * 2;
        }
    }
}

运行上述程序,可得:

实验二:

由上述结果可见,K和N之间存在一定的线性相关性,N约等于2^K。由于N为整数,若N介于2^K与2^(K+1)之间,采用这种方法结果均为2^K,误差较大。为增加结果可靠性,可使用多个pfPrincipleTest1 类进行加权

改进版代码如下:

package redis.HyperLogLogLearn;

import java.util.concurrent.ThreadLocalRandom;

public class pfPrincipleTest2 {
    private int maxBits = 0;

    public void random(long value) {
        // 取32位的数
        int bits = lowZeros(value);
        if (this.maxBits < bits) {
            this.maxBits = bits;
        }
    }

    // 获取最低位为1的位偏移
    public int lowZeros(long val) {
        int bitCnt = 1;
        long valDiv = val;
        while (valDiv > 0) {
            long bitNum = valDiv % 2;
            if (bitNum == 1) {
                return bitCnt - 1;
            }
            valDiv = valDiv >> 1;
            bitCnt++;
        }
        return bitCnt - 2;
    }

    static class Exp {
        private final int loopNum;
        private final pfPrincipleTest2[] pfPrinciple;

        public Exp(int loopNum, int bucketNum) {
            this.loopNum = loopNum;
            this.pfPrinciple = new pfPrincipleTest2[bucketNum];
            for (int i = 0; i < bucketNum; i++) {
                this.pfPrinciple[i] = new pfPrincipleTest2();
            }
        }

        public void work() {
            for (int i = 0; i < loopNum; i++) {
                long value = ThreadLocalRandom.current().nextLong(2L << 32);
                // 随机取一个bucket
                int bucketKey = (int) (((value & 0x0fff000) >> 12) % this.pfPrinciple.length);
                pfPrincipleTest2 pf = this.pfPrinciple[bucketKey];
                pf.random(value);
            }
        }

        public double calN() {
            double result = 0.0;
            for (pfPrincipleTest2 pfPrinciple : this.pfPrinciple) {
                result = result + 1.0 / (float) pfPrinciple.maxBits;
            }
            double avgResult = (double) this.pfPrinciple.length / result;
            return Math.pow(2, avgResult) * this.pfPrinciple.length;
        }

        public void printf() {
            double est = this.calN();
            System.out.printf("loop num:%d  est:%.2f  sub:%.2f\n",
                    this.loopNum, est, Math.abs(est - loopNum) / (double) loopNum);
        }
    }

    public static void main(String[] args) {
        int step = 100;
        for (int loopNum = 100000; loopNum < 1000000; loopNum = loopNum + step) {
            Exp exp = new Exp(loopNum, 1024);
            exp.work();
            exp.printf();
            step = (int) (step * 1.5);
        }
    }
}

如图,预测的值与真实值相差较小

注意:

  • 此处均值计算使用调和平均数(普通平均数容易受到离群值影响)
  • 上述算法在随机数量较少时,会因为某些bucket的maxbits=0导致倒数不可求,因此真实的HyperLogLog要比上诉代码复杂 

3. pf的内存占用为什么是12KB

redis在HyperLogLog实现中用16384个桶,maxbits需要6个bit,最大可以表示maxbit=63,于是共占用内存 2^14*6/8=12KB

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值