【项目实践】万字总结 redis 的使用

BigKey 相关问题

通过以下命令可以创建百万的key

for((i=1;i<=100*10000;i++)); do echo "set k$i v$i" >> /tmp/redisTest.txt ;done;
cat /tmp/redisTest.txt | /redis-cli -h 127.0.0.1 -p 6379 -a 111111 --pipe
复制代码

海量数据固定查询某一 pattern 的key

# 方式一:keys <pattern> 
# 此命令生成环境最好不要使用,会遍历所有的 key ,性能太差,
# 在配置文件中 rename-command keys "" 禁用
# 同时也可禁用掉 flushdb flushall 这些高危命令 rename-command flushdb "" rename-command flushall ""
key k*
​
# 方式二:scan cursor match < pattern> count <count>
# 关于 cursor 说明:cursor 为 0 表示开始新的迭代,每次执行完指令后会返回 cursor 值,下一次的cursor可以指定返回的cursor值
scan 0 match k* count 10 
复制代码

多大算Bigkey, 怎么发现,如何删除?

参考阿里云 redis 规范,string类型控制在10KB以内, hash、 list、set、 zset元素个数不要超过5000。

# 给出每种数据结构 Top 1 bigkey,同时给出每种数据类型的键值个数+平均大小
redis-cli -h 127.0.0.1 -p 6379 -a 111111 --bigkeys
# 想查询大于10kb的所有key,--bigkeys参数就无能为力了,通过 memory usage 来计算每个键值的字节数
memory usage keyname
复制代码

大 key 的删除可以采用分批删除,通过 scan 命令遍历大key,每次取得少部分元素,对其删除,然后再获取和删除下一批元素

# String 类型删除
del <key>
unlink <key>
# Hash 类型删除:先使用 hscan 获得 少量 filed-value,再使用 hdel 删除获得的
hscan <key> <cursor> match < pattern> count <count>
hdel <key> <field>
# List 类型删除:使用ltrim渐进式逐步删除,直到全部删除完成
ltrim <key> <start> <stop>
# Set 类型删除:使用sscan每次获取部分元素,再使用srem命令删除每个元素
sscan <key> <cursor> match < pattern> count <count>
srem <key> <member>
# ZSet 类型删除: 使用zscan每次获取部分元素,再使用 ZREMRANGEBYRANK 命令删除每个元素
zscan <key> <cursor> match < pattern> count <count>
zremrangebyrank <key> <start> <stop>
复制代码

相关删除的 java 代码

// hash 类型删除
public void delBigKey(String host, int port, String password,String bigHashKey) {
    /*
    Cursor cursor = redisTemplate.opsForHash().scan(key, ScanOptions.scanOptions().match(pattern).count(count).build());
    while (cursor.hasNext()) {
        Map.Entry<Object, Object> entry = (Map.Entry<Object, Object>) cursor.next();
        redisHashCache.deleKey(key, entry.getKey());
    }
    cursor.close();
    */
    Jedis jedis = new Jedis(host, port);
    if (password != nul1 && !"".equals(password)) {
        jedis.auth(password);
    }
    ScanParams scanParams = new ScanParams().count(100);
    String cursor = "0";
    do {
        scanResult<Entry<String, String>> scanResult = jedis.hscan(bigHashkey, cursor, scanParam);
        List<Entry<String, String>> entryList = scanResult.getResult();
        if (entryList != null && !entryList.isEmpty()) {
            for (Entry<string, String> entry : entryList) {
                jedis.hdel(bighashkey, entry.getkey());
            }
        }
        cursor = scanResult.getStringCursor();
    } while (!"0".equals(cursor));
    jedis.del(bigHashKey);
}
// list 类型删除
public void delBigList(String host, int port, String password,String bigHashKey) {
    Jedis jedis = new Jedis(host, port);
    if (password != nul1 && !"".equals(password)) {
        jedis.auth(password);
    }
    long llen = jedis.llen(bigListKey);
    int counter = 0;
    int left = 100;
    while(counter < llen){
        jedis.ltrim(bigListKey,left,llen);
        counter += left;
    }
    jedis.del(bigListKey);
}
// Set 类型删除                           
public void delBigSet(String host, int port, String password,String bigHashKey) {
    Jedis jedis = new Jedis(host, port);
    if (password != nul1 && !"".equals(password)) {
        jedis.auth(password);
    }    
    ScanParams scanParams = new ScanParams().count(100);
    String cursor = "0";
    do {
        scanResult<Entry<String, String>> scanResult = jedis.sscan(bigHashkey, cursor, scanParam);
        List<String> memberList = scanResult.getResult();
        if (memberList != null && !memberList.isEmpty()) {
            for (String member : memberList) {
                jedis.srem(bighashkey,member);
            }
        }
        cursor = scanResult.getStringCursor();
    } while (!"0".equals(cursor));
    jedis.del(bigHashKey);    
}
// ZSet 类型删除
public void delBigSet(String host, int port, String password,String bigHashKey) {
    Jedis jedis = new Jedis(host, port);
    if (password != nul1 && !"".equals(password)) {
        jedis.auth(password);
    }    
    ScanParams scanParams = new ScanParams().count(100);
    String cursor = "0";
    do {
        scanResult<Entry<String, String>> scanResult = jedis.zscan(bigHashkey, cursor, scanParam);
        List<Tuple> tupleList = scanResult.getResult();
        if (tupleList != null && !tupleList.isEmpty()) {
            for (Tuple tuple : tupleList) {
                jedis.zrem(bighashkey,tuple.getElement());
            }
        }
        cursor = scanResult.getStringCursor();
    } while (!"0".equals(cursor));
    jedis.del(bigHashKey);    
}
复制代码

缓存和mysql数据一致性

读数据

读数据可能会发生的问题有两个:

  1. 读取的数据不存在。大量读不存在的数据导致服务宕机,这种现象是缓存穿透
  2. 读取的数据存在但是过期。某个热点数据失效时发生大量的读操作造成服务宕机,这种现象是缓存击穿;大规模的数据失效并且发生大量读操作造成服务宕机,这种现象是缓存雪崩

对于第一种情况可以采用布隆过滤器或者缓存一些不存在的数据来解决。

对于第二种情况,在数据回写时可以使用双检加锁互斥:多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个互斥锁来锁住它。其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存

// 演示代码
public User findUserById2(Integer id){
    User user = null;
    String key = CACHE_KEY_USER+id;
    //1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql,
    // 第1次查询redis,加锁前
    user = (User) redisTemplate.opsForValue().get(key);
    if(user == null) {
        //2 对于高并发的优化,进来就先加锁,保证一个请求操作,让外面的redis等待一下,避免击穿mysql
        synchronized (UserService.class){
            //第2次查询redis,加锁后
            user = (User) redisTemplate.opsForValue().get(key);
            //3 二次查redis还是null,可以去查mysql了(mysql默认有数据)
            if (user == null) {
                //4 查询mysql拿数据(mysql默认有数据)
                user = userMapper.selectByPrimaryKey(id);
                if (user == null) {
                    return null;
                }else{
                    //5 mysql里面有数据的,需要回写redis,完成数据一致性的同步工作
                    redisTemplate.opsForValue().setIfAbsent(key,user,7L,TimeUnit.DAYS);
                }
            }
        }
    }
    return user;
}
复制代码

写数据

数据需要更新,则需要更新 mysql 和 redis 数据,并且保证两者数据一致,这种情况称为双写一致性,保证数据一致性目前有以下 4 种方案:

  1. 先更新数据库,再更新缓存

两个线程一起执行写操作,线程在写入数据库和 redis 因网络原因顺序错位,导致数据库和 redis 数据不一致

  1. 先删除缓存再更新数据库

线程 1 执行写数据操作先删除 redis 数据,线程2 执行读数据更新 redis 数据,最后线程 1 数据更新数据库数据,导致数据不一致

  1. 先删除缓存再更新数据库(延时双删)

 4. 先更新数据库,再更新缓存

 缓存删除重试机制

方法 3 和 4 目前大部分情况下都是可以保证双写一致性,但是如果第二步的删除缓存失败仍然会导致数据不一致,这个时候就要引入缓存删除重试机制

  1. 写请求更新数据库
  2. 缓存因为某些原因,删除失败
  3. 把删除失败的key放到消息队列
  4. 消费消息队列的消息,获取要删除的key
  5. 重试删除缓存操作

读取biglog异步删除缓存

重试删除缓存机制还可以,就是会造成好多业务代码入侵。其实,还可以通过数据库的binlog来异步淘汰key

以mysql为例可以使用阿里的canal将binlog日志采集发送到MQ队列里面,然后通过ACK机制确认处理这条更新消息,删除缓存,保证数据缓存一致性

下面是演示使用 canal 监控数据库变化,判断数据库的增删改操作, 根据操作调用相应的 redis 操作

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
​
public class RedisUtils{
    public static final String  REDIS_IP_ADDR = "192.168.111.185";
    public static final String  REDIS_pwd = "111111";
    public static JedisPool jedisPool;
​
    static {
        JedisPoolConfig jedisPoolConfig=new JedisPoolConfig();
        jedisPoolConfig.setMaxTotal(20);
        jedisPoolConfig.setMaxIdle(10);
        jedisPool=new JedisPool(jedisPoolConfig,REDIS_IP_ADDR,6379,10000,REDIS_pwd);
    }
​
    public static Jedis getJedis() throws Exception {
        if(null!=jedisPool){
            return jedisPool.getResource();
        }
        throw new Exception("Jedispool is not ok");
    }
​
}
​
import com.alibaba.fastjson.JSONObject;
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.CanalEntry.*;
import com.alibaba.otter.canal.protocol.Message;
import redis.clients.jedis.Jedis;
import java.net.InetSocketAddress;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
​
public class RedisCanalClientExample{
    public static final Integer _60SECONDS = 60;
    public static final String  REDIS_IP_ADDR = "192.168.111.185";
​
    private static void redisInsert(List<Column> columns){
        JSONObject jsonObject = new JSONObject();
        for (Column column : columns){
            System.out.println(column.getName() + " : " + column.getValue() + "    update=" + column.getUpdated());
            jsonObject.put(column.getName(),column.getValue());
        }
        if(columns.size() > 0){
            try(Jedis jedis = RedisUtils.getJedis())
            {
                jedis.set(columns.get(0).getValue(),jsonObject.toJSONString());
            }catch (Exception e){
                e.printStackTrace();
            }
        }
    }
​
​
    private static void redisDelete(List<Column> columns){
        JSONObject jsonObject = new JSONObject();
        for (Column column : columns){
            jsonObject.put(column.getName(),column.getValue());
        }
        if(columns.size() > 0){
            try(Jedis jedis = RedisUtils.getJedis())
            {
                jedis.del(columns.get(0).getValue());
            }catch (Exception e){
                e.printStackTrace();
            }
        }
    }
​
    private static void redisUpdate(List<Column> columns){
        JSONObject jsonObject = new JSONObject();
        for (Column column : columns){
            System.out.println(column.getName() + " : " + column.getValue() + "    update=" + column.getUpdated());
            jsonObject.put(column.getName(),column.getValue());
        }
        if(columns.size() > 0){
            try(Jedis jedis = RedisUtils.getJedis())
            {
                jedis.set(columns.get(0).getValue(),jsonObject.toJSONString());
                System.out.println("---------update after: "+jedis.get(columns.get(0).getValue()));
            }catch (Exception e){
                e.printStackTrace();
            }
        }
    }
​
    public static void printEntry(List<Entry> entrys) {
        for (Entry entry : entrys) {
            if (entry.getEntryType() == EntryType.TRANSACTIONBEGIN || 
                entry.getEntryType() == EntryType.TRANSACTIONEND) {
                continue;
            }
​
            RowChange rowChage = null;
            try {
                //获取变更的row数据
                rowChage = RowChange.parseFrom(entry.getStoreValue());
            } catch (Exception e) {
                throw new RuntimeException("ERROR ## parser of eromanga-event has an error,data:" + entry.toString(),e);
            }
            //获取变动类型
            EventType eventType = rowChage.getEventType();
            System.out.println(String.format("================&gt; binlog[%s:%s] , name[%s,%s] , eventType : %s",
                    entry.getHeader().getLogfileName(), entry.getHeader().getLogfileOffset(),
                    entry.getHeader().getSchemaName(), entry.getHeader().getTableName(), eventType));
​
            for (RowData rowData : rowChage.getRowDatasList()) {
                if (eventType == EventType.INSERT) {
                    redisInsert(rowData.getAfterColumnsList());
                } else if (eventType == EventType.DELETE) {
                    redisDelete(rowData.getBeforeColumnsList());
                } else {//EventType.UPDATE
                    redisUpdate(rowData.getAfterColumnsList());
                }
            }
        }
    }
​
​
    public static void main(String[] args){
        System.out.println("---------O(∩_∩)O哈哈~ initCanal() main方法-----------");
​
        //=================================
        // 创建链接canal服务端
        CanalConnector connector = CanalConnectors.newSingleConnector(new InetSocketAddress(REDIS_IP_ADDR,
                11111), "example", "", "");
        int batchSize = 1000;
        //空闲空转计数器
        int emptyCount = 0;
        System.out.println("---------------------canal init OK,开始监听mysql变化------");
        try {
            connector.connect();
            //connector.subscribe(".*\..*");
            connector.subscribe("bigdata.t_user");
            connector.rollback();
            int totalEmptyCount = 10 * _60SECONDS;
            while (emptyCount < totalEmptyCount) {
                System.out.println("我是canal,每秒一次正在监听:"+ UUID.randomUUID().toString());
                Message message = connector.getWithoutAck(batchSize); // 获取指定数量的数据
                long batchId = message.getId();
                int size = message.getEntries().size();
                if (batchId == -1 || size == 0) {
                    emptyCount++;
                    try { 
                        TimeUnit.SECONDS.sleep(1); 
                    } catch (InterruptedException e) { 
                        e.printStackTrace(); 
                    }
                } else {
                    //计数器重新置零
                    emptyCount = 0;
                    printEntry(message.getEntries());
                }
                connector.ack(batchId); // 提交确认
                // connector.rollback(batchId); // 处理失败, 回滚数据
            }
            System.out.println("已经监听了"+totalEmptyCount+"秒,无任何消息,请重启重试......");
        } finally {
            connector.disconnect();
        }
    }
}
复制代码

参考 Redis与MySQL双写一致性如何保证 redis双写一致性

UV 和 PV 统计

先看效果,统计每日的活跃用户,同时显示每个接口7日内的访问量

 UV (Unique visitor)是指通过互联网访问、浏览这个网页的自然人。 访问您网站的一台电脑客户端为一个访客。00:00-24:00内相同的客户端只被计算一次。一天内同个访客多次访问仅计算一个UV。

PV(Page View)即页面浏览量或点击量,用户每1次对网站中的每个网页访问均被记录1个PV。用户对同一页面的多次访问,访问量累计,用以衡量网站用户访问的网页数量。

在以下例子中,UV 记录一天内多少个 IP 访问了接口,PV 记录每个接口的访问次数。记录数据的键值使用如下格式记录,例如

记录 5/7 号UV的 key 值为:views:unique_visitor:20230507

记录 5/7 号api资源查询接口的 PV的 key 值:views:com.hcm.controller.deploy.ResourceController.getApiList:20230507

key 有三部分组成前缀 views,特定字符串,时间戳

 对于 PV 的统计,每访问一次接口执行 +1 操作就行。对于 UV 的计算的是不同的 ip 地址出现的次数,需要考虑去重,去重统计有 HashSet,BitMap 和 HyperLogLog。HyperLogLog 提供的是不精准的去重计数方案,牺牲 0.81% 的精确度换取更小的存储空间非常适合 UV 统计的这种场景。

演示代码部分如下:

定义 AOP,每访问任一接口记录 UV 和 PV,每访问一次 PV 的数量 +1,UV 和用户 ip 挂钩,ip 不同才记一次。

import com.hcm.common.core.entity.SysResource;
import com.hcm.system.service.ViewCounterService;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
​
​
/**
 * UV PV 计数器:监控接口调用量
 *
 * @author pc
 * @date 2023/05/03
 */
@Aspect
@Component
@Slf4j
public class ViewCounter {
​
    @Autowired
    private ViewCounterService viewCounterService;
​
​
    /**
     * execution([可见性]返回类型[声明类型].方法名(参数)[异常])
     */
    @Pointcut("execution(public * com.hcm.controller.*.*.*(..)))")
    public void pointerCut() {
    }
​
    @Before("pointerCut()")
    public void before(JoinPoint joinPoint) {
        Signature signature = joinPoint.getSignature();
        SysResource sysResource = new SysResource();
        sysResource.setControllerClass(signature.getDeclaringType().getTypeName());
        sysResource.setMethodName(signature.getName());
        viewCounterService.countResourceUVAndPV(sysResource);
    }
​
}
复制代码

UV PV 的记录和查询

import com.hcm.common.constants.CacheConstants;
import com.hcm.common.core.entity.SysResource;
import com.hcm.common.core.redis.RedisHPCache;
import com.hcm.common.core.redis.RedisStringCache;
import com.hcm.common.utils.DateUtils;
import com.hcm.common.utils.ServletUtils;
import com.hcm.common.utils.StringUtils;
import com.hcm.common.utils.ip.IpUtils;
import com.hcm.system.service.ResourceService;
import com.hcm.system.service.ViewCounterService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
​
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
​
/**
 * 点击率
 *
 * @author pc
 * @date 2023/05/05
 */
@Service
@Slf4j
public class ViewCounterServiceIml implements ViewCounterService {
​
    @Autowired
    private RedisHPCache redisHPCache;
​
    @Autowired
    private RedisStringCache redisStringCache;
​
    @Autowired
    private ResourceService resourceService;
​
    /**
     * 统计资源uvpv
     *
     * @param sysResource 系统资源  controllerClass 和  methodName
     */
    @Override
    public void countResourceUVAndPV(SysResource sysResource) {
        String ipAddr = IpUtils.getIpAddr(ServletUtils.getRequest());
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat(DateUtils.YYYYMMdd);
        String timestamp = simpleDateFormat.format(new Date());
​
        String pvKey = formatKeyName(resourceService.getApiName(sysResource), timestamp);
        String uvKey = formatKeyName("unique_visitor", timestamp);
        redisHPCache.add(uvKey, ipAddr);
​
        if (redisStringCache.isExists(pvKey)) {
            redisStringCache.setCacheObject(pvKey, 1);
        } else {
            redisStringCache.increase(pvKey, 1);
        }
​
    }
​
    /**
     * 获取当前日期和往前 during 天的 UV 值
     *
     * @param during 天数
     * @return {@link Map}<{@link String}, {@link Long}>
     */
    @Override
    public Map<String, Long> getUVCount(Integer during) {
        List<String> lastDays = DateUtils.getLastDays(new Date(), during);
        HashMap<String, Long> map = new HashMap<>(lastDays.size());
        lastDays.forEach(day -> {
            Long uniqueVisitor = redisHPCache.count(formatKeyName("unique_visitor", day));
            map.put(day, uniqueVisitor);
        });
        return map;
    }
​
    /**
     * 设置资源一段时间内的访问数量
     *
     * @param sysResource 系统资源
     * @param during      时间间隔
     * @return {@link Map}<{@link String}, {@link Long}>
     */
    @Override
    public SysResource setResourcePVCount(SysResource sysResource, Integer during) {
        List<String> lastDays = DateUtils.getLastDays(new Date(), during);
        HashMap<String, Integer> map = new HashMap<>(lastDays.size());
        int total = 0;
        for (String day : lastDays) {
            Integer pageCounter = redisStringCache.getCacheObject(formatKeyName(resourceService.getApiName(sysResource), day));
            if (StringUtils.isNotNull(pageCounter)) {
                map.put(day, pageCounter);
                total += pageCounter;
            } else {
                map.put(day, 0);
            }
        }
        map.put("total",total);
        sysResource.setPageCounter(map);
        return sysResource;
    }
​
    /**
     * 格式键名
     *
     * @param keyName 键名
     * @return {@link String}
     */
    private String formatKeyName(String keyName, String timestamp) {
        return CacheConstants.CACHE_VIEW_COUNTER_PREFIX + keyName + ":" + timestamp;
    }
​
}
​
复制代码

黑白名单校验(布隆过滤器)

布隆过滤器(Bloom Filter)是 1970 年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难

原理说明:

布隆过滤器实质就是一个大型位数组和几个不同的无偏hash函数(无偏表示分布均匀)。由一个初值都为零的bit数组和多个个哈希函数构成,用来快速判断某个数据是否存在。

 如上图所示,此时需要查找的数据是 Fer, Fer 会先哈希函数计算得到在位数组中的位置,如果该位值为 1 表示数据可能存在(可能存在是因为不同的数据其 hash 值可能相同),如果该位值为 0 代表数据一定不存在。

使用 Bitmap 实现一个简易版的布隆过滤器

import com.hcm.common.core.redis.RedisBitMapCache;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
​
import java.util.List;
​
/**
 * 布隆过滤器
 *
 * @author pc
 * @date 2023/05/07
 */
@Component
@Slf4j
public class BloomFilter {
​
    @Autowired
    private RedisBitMapCache redisBitMapCache;
​
    /**
     * 数据预加载到布隆过滤器
     *
     * @param data 数据
     */
    public void initData(List<String> data,String key) {
        data.forEach(k -> {
            //1 计算hashcode,由于可能有负数,直接取绝对值
            int hashValue = Math.abs(k.hashCode());
            //2 通过hashValue和2的32次方取余后,获得对应的下标坑位
            long index = (long) (hashValue % Math.pow(2, 32));
            //3 设置redis里面bitmap对应坑位,设置为1
            log.info("initData:{}",index);
            redisBitMapCache.setBit(key, index, true);
        });
    }
​
    /**
     * 检查值是否存在于布隆过滤器
     *
     * @param checkItem 检查项目
     * @param key       关键
     * @return boolean
     */
    public boolean checkWithBloomFilter(String checkItem,String key){
        int hashValue = Math.abs(checkItem.hashCode());
        long index = (long) (hashValue % Math.pow(2, 32));
        log.info("checkWithBloomFilter:{}",index);
        return redisBitMapCache.getBit(key, index);
    }
}
复制代码

测试

import com.hcm.common.filter.BloomFilter;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
​
import java.util.Arrays;
​
/**
 * BitMapTest
 *
 * @author pc
 * @date 2023/05/07
 */
@Api("redis bitmap test")
@Slf4j
@RestController
@RequestMapping("/test")
public class BitMapTest {
​
    @Autowired
    private BloomFilter bloomFilter;
​
    private final String KEY = "testBloomFilter";
​
    @PostMapping("/init-bloom-filter")
    @ApiOperation(value = "初始化布隆过滤器的数据", notes = "初始化布隆过滤器的数据")
    public void setBlackList() {
        String[] blackList = new String[]{"name1:001", "name2:002", "name3:003"};
        bloomFilter.initData(Arrays.asList(blackList), KEY);
    }
​
    @PostMapping("/info")
    @ApiOperation(value = "测试布隆过滤器", notes = "测试布隆过滤器")
    public void testBloomFilter() {
        String currentUser1 = "name1:001";
        boolean isBlackUser1 = bloomFilter.checkWithBloomFilter(currentUser1, KEY);
        String currentUser2 = "name4:004";
        boolean isBlackUser2 = bloomFilter.checkWithBloomFilter(currentUser2, KEY);
        log.info("currentUser1 存在:{},currentUser2 存在:{}", isBlackUser1, isBlackUser2);
    }
}
复制代码

 在实际工作中,布隆过滤器常见的应用场景如下:

  • 网页爬虫对 URL 去重,避免爬取相同的 URL 地址;
  • 反垃圾邮件,从数十亿个垃圾邮件列表中判断某邮箱是否垃圾邮箱;
  • Google Chrome 使用布隆过滤器识别恶意 URL;
  • Medium 使用布隆过滤器避免推荐给用户已经读过的文章;
  • Google BigTable,Apache HBbase 和 Apache Cassandra 使用布隆过滤器减少对不存在的行和列的查找
  • 解决缓存穿透的问题

布隆过滤器有很多实现和优化,由 Google 开发著名的 Guava 库就提供了布隆过滤器(Bloom Filter)的实现。在基于 Maven 的 Java 项目中要使用 Guava 提供的布隆过滤器,只需要引入以下坐标:

<dependency>
   <groupId>com.google.guava</groupId>
   <artifactId>guava</artifactId>
   <version>28.0-jre</version>
</dependency>
复制代码

在导入 Guava 库后,我们新建一个 BloomFilterDemo 类,在 main 方法中我们通过 BloomFilter.create 方法来创建一个布隆过滤器,接着我们初始化 1 百万条数据到过滤器中,然后在原有的基础上增加 10000 条数据并判断这些数据是否存在布隆过滤器中:

import com.google.common.base.Charsets;
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
​
public class BloomFilterDemo {
    public static void main(String[] args) {
        int total = 1000000; // 总数量
        BloomFilter<CharSequence> bf = 
          BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), total);
        // 初始化 1000000 条数据到过滤器中
        for (int i = 0; i < total; i++) {
            bf.put("" + i);
        }
        // 判断值是否存在过滤器中
        int count = 0;
        for (int i = 0; i < total + 10000; i++) {
            if (bf.mightContain("" + i)) {
                count++;
            }
        }
        /**
        * 309/(1000000 + 10000) * 100 ≈ 0.030594059405940593 误判率 fpp 的默认值是 0.03
        * BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), total, 0.0002); 设置误判率为 0.0002
        */
        System.out.println("已匹配数量 " + count); // 已匹配数量 1000309
    }
}
复制代码

缓存预热/雪崩/击穿/穿透

缓存预热,当系统上线时,缓存内还没有数据,如果直接提供给用户使用,每个请求都会穿过缓存去访问底层数据库,如果并发大的话,很有可能在上线当天就会宕机,因此我们需要在上线前先将数据库内的热点数据缓存至Redis内再提供出去使用,这种操作称为"缓存预热"。缓存预热的实现方式有很多,比较通用的方式是写个批任务,在启动项目时或定时去触发将底层数据库内的热点数据加载到缓存内。

缓存雪崩发生的情况可能有以下两种:硬件上 redis 主机宕机,软件上大量 key 几乎同时失效,Redis中大量的key几乎同时过期,然后大量并发查询穿过redis击打到底层数据库上,此时数据库层的负载压力会骤增,我们称这种现象为"缓存雪崩"。对于缓存雪崩可以采用如下措施:

  • 在可接受的时间范围内随机设置key的过期时间,分散key的过期时间,以防止大量的key在同一时刻过期;
  • 对于一定要在固定时间让key失效的场景(例如每日12点准时更新所有最新排名),可以在固定的失效时间时在接口服务端设置随机延时,将请求的时间打散,让一部分查询先将数据缓存起来;
  • 延长热点key的过期时间或者设置永不过期

缓存击穿和雪崩都是 key 过期导致的。当热点数据key从缓存内失效时,大量访问同时请求这个数据,就会将查询下沉到数据库层,此时数据库层的负载压力会骤增,我们称这种现象为"缓存击穿"。解决方案:

  • 延长热点key的过期时间或者设置永不过期,如排行榜,首页等一定会有高并发的接口;
  • 利用互斥锁保证同一时刻只有一个客户端可以查询底层数据库的这个数据,一旦查到数据就缓存至Redis内,避免其他大量请求同时穿过Redis访问底层数据库;

缓存穿透和击穿的区别是缓存穿透的数据在数据库中也不存在。当查询Redis中没有的数据时,该查询会下沉到数据库层,同时数据库层也没有该数据,当这种情况大量出现或被恶意攻击时,接口的访问全部透过Redis访问数据库,而数据库中也没有这些数据,我们称这种现象为"缓存穿透"。解决方案:

  • 空对象缓存
  • 在接口访问层对用户做校验,如接口传参、登陆状态、n秒内访问接口的次数;
  • 利用布隆过滤器,将数据库层有的数据key存储在位数组中,以判断访问的key在底层数据库中是否存在;
import com.google.common.base.Charsets;
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
​
import java.util.HashMap;
import java.util.List;
import java.util.Map;
​
/**
 * guava布隆过滤器
 *
 * @author pc
 * @date 2023/05/07
 */
public class GuavaBloomFilter {
​
    public static Map<String,BloomFilter> bloomFilterMap = new HashMap<>();
​
    public static void bloomFilterConfig(Integer size, Double fpp, List<String> data,String key) {
        BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), size, fpp);
        data.forEach(d -> {
            bloomFilter.put(d);
        });
        bloomFilterMap.put(key,bloomFilter);
    }
​
    public static BloomFilter getBloomFilter(String key){
        return bloomFilterMap.get(key);
    }
}
​
public void testGuavaBloomFilter(){
    String[] blackList = new String[]{"name1:001", "name2:002", "name3:003"};
    GuavaBloomFilter.bloomFilterConfig(100,0.03D, Arrays.asList(blackList),KEY);
    BloomFilter bloomFilter = GuavaBloomFilter.getBloomFilter(KEY);
    String currentUser1 = "name1:001";
    boolean isBlackUser1 = bloomFilter.mightContain(currentUser1);
    String currentUser2 = "name4:004";
    boolean isBlackUser2 = bloomFilter.mightContain(currentUser2);
    log.info("currentUser1 存在:{},currentUser2 存在:{}", isBlackUser1, isBlackUser2);
}
复制代码

redis 分布式锁

在传统单机部署的情况下,可以使用Java并发处理相关的API(如ReentrantLcok或synchronized)进行互斥控制。但是在分布式系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机并发控制锁策略失效,为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁的由来。

为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下五个条件:

  1. 互斥性:任意时刻,只能有一个客户端获取锁,不能同时有两个客户端获取到锁。
  2. 可重入:同一个节点的同一个线程如果获得锁之后,它也可以再次获取这个锁。
  3. 安全性:锁只能被持有该锁的客户端删除,不能由其它客户端删除。
  4. 防死锁:获取锁的客户端因为某些原因(如down机等)而未能释放锁,必须有超时控制机制或者撤销操作,保证其它客户端可以再次获取到该锁
  5. 高可用:当部分节点(redis节点等)down机时,客户端仍然能够获取锁和释放锁。

通过库存案例演示如何完成分布式锁

环境准备:在两个端口下启动同一个单机版服务,JMeter ,redis

import com.hcm.common.core.redis.RedisStringCache;
import com.hcm.common.utils.StringUtils;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
​
import javax.annotation.Resource;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.locks.ReentrantLock;
​
/**
 * 并发测试
 *
 * @author pc
 * @date 2023/05/07
 */
@Api("ConcurrentTest test")
@Slf4j
@RestController
@RequestMapping("/test")
public class ConcurrentTest {
    @Resource(name = "threadPoolExecutor")
    private ThreadPoolExecutor threadPoolExecutor;
​
    private final String KEY = "shops:001";
​
    private final ReentrantLock lock = new ReentrantLock();
​
    @Autowired
    private RedisStringCache redisStringCache;
​
    @Value("${server.port}")
    private String port;
​
    @GetMapping("/concurrent")
    @ApiOperation(value = "分布式锁", notes = "分布式锁测试")
    public void test() {
        for (int i = 0; i < 2; i++) {
            int finalI = i;
​
            threadPoolExecutor.execute(() -> {
                lock.lock();
                try {
                    Integer shopsNum = redisStringCache.getCacheObject(KEY);
                    Integer num = StringUtils.isNull(shopsNum) ? 0 : shopsNum;
                    if (num > 0) {
                        num--;
                        redisStringCache.setCacheObject(KEY, num);
                        log.info("port:{},index:{},sale 1, remain :{}", port, finalI, num);
                    } else {
                        log.info("port:{},index:{},all sell out", port, finalI);
                    }
                } finally {
                    lock.unlock();
                }
            });
        }
    }
​
}
复制代码

单机执行结果如下:

开放两个端口后执行的结果如下:预期结果为 0 而实际结果 288 ,两个端口存在相同的输出,说明单机锁失效。

 使用 setnx 完成分布式锁

private final String LOCKNAME = "lockname";
public void test() {
    for (int i = 0; i < 2; i++) {
        int finalI = i;
​
        threadPoolExecutor.execute(() -> {
            // lock.lock();
            String keyVal = UUID.randomUUID() + ":" + Thread.currentThread().getId();
            lock(LOCKNAME,keyVal);
            try {
                Integer shopsNum = redisStringCache.getCacheObject(KEY);
                Integer num = StringUtils.isNull(shopsNum) ? 0 : shopsNum;
                if (num > 0) {
                    num--;
                    redisStringCache.setCacheObject(KEY, num);
                    log.info("port:{},index:{},sale 1, remain :{}", port, finalI, num);
                } else {
                    log.info("port:{},index:{},all sell out", port, finalI);
                }
            } finally {
                // lock.unlock();
             unlock(LOCKNAME,keyVal);
            }
        });
    }
}
private void lock(String lockname,String keyVal) {
    // 加锁并设置有效期,防止死锁
    while (!redisStringCache.setIfAbsent(lockname, keyVal, 30L, TimeUnit.MILLISECONDS)) {
        try {
            TimeUnit.MILLISECONDS.sleep(50);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
​
private void unlock(String lockname,String keyVal) {
    // 判断加锁与解锁是不是同一个,保证安全性
    if (redisStringCache.getCacheObject(lockname).equals(keyVal)) {
        redisStringCache.deleteObject(lockname);
    }
}
复制代码

测试结果如下:符合预期

可重入和原子性优化

锁的可重入性是指一个线程中的多个流程可以获取同一把锁,持有这把同步锁可以再次进入。上面的代码显然不支持可重入性。

 对于锁的可重入性,可以选择 Hash 来完成。思路如下:

加锁:锁不存在或者锁的层级在最外层,加锁设定过期时间,并将层级 +1

解锁:锁存在,如果在内层将层级 -1,如果层级减到最外层则删除锁

伪代码如下:

# 加锁过程
if !lock 
   lock.level = 1   
   lock.expire = time
   return true
else if lock.level == 1 
    lock.level++
    lock.expire = time
    return true
else    
    return false
    
# 加锁过程简化版
if !lock or lock.level == 1 
    lock.level++
    lock.expire = time
    return true
else    
    return false
    
# 解锁过程
if lock.level == 0 
    return false
else if --lock.level == 0 
    delete lock
else 
    return false
复制代码

除了可重入性外,unlock 是有两条 redis 指令来完成,在官方 distributed-locks 分布式锁的说明中推荐使用 lua 脚本来完成

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
复制代码

上面的代码的作用是当传入的的参数 KEYS[1] 值为 ARGV[1],则删除掉这个 KEYS[1]

Redis提供了非常丰富的指令集,官网上提供了200多个命令。但是某些特定领域,需要扩充若干指令原子性执行时,仅使用原生命令便无法完成。 Redis 为这样的用户场景提供了 lua 脚本支持,用户可以向服务器发送 lua 脚本来执行自定义动作,获取脚本的响应数据。Redis 服务器会单线程原子性执行 lua 脚本,保证 lua 脚本在处理的过程中不会被任意其它请求打断。

Redis中Lua的常用命令如下:

  • EVAL
  • EVALSHA
  • SCRIPT LOAD - SCRIPT EXISTS
  • SCRIPT FLUSH
  • SCRIPT KILL
# 命令格式:EVAL script numkeys key [key …] arg [arg …]
# - script参数是一段 Lua5.1 脚本程序。脚本不必(也不应该[^1])定义为一个 Lua 函数
# - numkeys指定后续参数有几个key,即:key [key …]中key的个数。如没有key,则为0
# - key [key …] 从 EVAL 的第三个参数开始算起,表示在脚本中所用到的那些 Redis 键(key)。在Lua脚本中通过KEYS[1], KEYS[2]获取。
# - arg [arg …] 附加参数。在Lua脚本中通过ARGV[1],ARGV[2]获取。
# 例1:numkeys=1,keys数组只有1个元素key1,arg数组无元素
127.0.0.1:6379> EVAL "return KEYS[1]" 1 key1
"key1"
​
# 例2:numkeys=0,keys数组无元素,arg数组元素中有1个元素value1
127.0.0.1:6379> EVAL "return ARGV[1]" 0 value1
"value1"
​
# 例3:numkeys=2,keys数组有两个元素key1和key2,arg数组元素中有两个元素first和second 
#      其实{KEYS[1],KEYS[2],ARGV[1],ARGV[2]}表示的是Lua语法中“使用默认索引”的table表,
#      相当于java中的map中存放四条数据。Key分别为:1、2、3、4,而对应的value才是:KEYS[1]、KEYS[2]、ARGV[1]、ARGV[2]
#      举此例子仅为说明eval命令中参数的如何使用。项目中编写Lua脚本最好遵从key、arg的规范。
127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second 
1) "key1"
2) "key2"
3) "first"
4) "second"
​
​
# 例4:使用了redis为lua内置的redis.call函数
#      脚本内容为:先执行SET命令,在执行EXPIRE命令
#      numkeys=1,keys数组有一个元素userAge(代表redis的key)
#      arg数组元素中有两个元素:10(代表userAge对应的value)和60(代表redis的存活时间)
127.0.0.1:6379> EVAL "redis.call('SET', KEYS[1], ARGV[1]);redis.call('EXPIRE', KEYS[1], ARGV[2]); return 1;" 1 userAge 10 60
(integer) 1
127.0.0.1:6379> get userAge
"10"
127.0.0.1:6379> ttl userAge
(integer) 44
复制代码

Redis执行Lua脚本文件

# 加锁的 lua 脚本
if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then 
  redis.call('hincrby',KEYS[1],ARGV[1],1) 
  redis.call('expire',KEYS[1],ARGV[2]) 
  return 1 
else
  return 0
end
复制代码

脚本执行

EVAL "if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then redis.call('hincrby',KEYS[1],ARGV[1],1) redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end" 1 zzyyRedisLock 0c90d37cb6ec42268861b3d739f8b3a8:1 30
复制代码

执行结果

了解完可重入性和 lua 脚本的使用后,改造后的代码如下:

import com.hcm.common.core.redis.RedisStringCache;
import com.hcm.common.utils.StringUtils;
​
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
​
import javax.annotation.Resource;
import java.util.Arrays;
import java.util.UUID;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
​
/**
 * 并发测试
 *
 * @author pc
 * @date 2023/05/07
 */
@Api("ConcurrentTest test")
@Slf4j
@RestController
@RequestMapping("/test")
public class ConcurrentTest {
    @Resource(name = "threadPoolExecutor")
    private ThreadPoolExecutor threadPoolExecutor;
​
    private final String KEY = "shops:001";
​
    private final String LOCKNAME = "lockname";
​
    private final ReentrantLock lock = new ReentrantLock();
​
    @Autowired
    private RedisStringCache redisStringCache;
    
​
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
​
    @Value("${server.port}")
    private String port;
​
    @GetMapping("/concurrent")
    @ApiOperation(value = "分布式锁", notes = "分布式锁测试")
    public void test() {
        for (int i = 0; i < 2; i++) {
            int finalI = i;
​
            threadPoolExecutor.execute(() -> {
                // lock.lock();
                String keyVal = UUID.randomUUID() + ":" + Thread.currentThread().getId();
                lock(LOCKNAME, keyVal);
                try {
                    lock(LOCKNAME, keyVal);
                    try {
                        Integer shopsNum = redisStringCache.getCacheObject(KEY);
                        Integer num = StringUtils.isNull(shopsNum) ? 0 : shopsNum;
                        if (num > 0) {
                            num--;
                            redisStringCache.setCacheObject(KEY, num);
                            log.info("port:{},index:{},sale 1, remain :{}", port, finalI, num);
                        } else {
                            log.info("port:{},index:{},all sell out", port, finalI);
                        }
                    } finally {
                        unlock(LOCKNAME, keyVal);
                    }
                } finally {
                    // lock.unlock();
                    unlock(LOCKNAME, keyVal);
                }
            });
        }
    }
​
    private void lock(String lockname, String keyVal) {
        // 加锁并设置有效期,防止死锁
        // while (!redisStringCache.setIfAbsent(lockname, keyVal, 30L, TimeUnit.MILLISECONDS)) {
        while (!isGetLock(lockname, keyVal)) {
            try {
                TimeUnit.MILLISECONDS.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
​
    private Boolean isGetLock(String lockname, String keyVal) {
        String script =
                "if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then " +
                    "redis.call('hincrby',KEYS[1],ARGV[1],1) " +
                    "redis.call('expire',KEYS[1],ARGV[2]) " +
                    "return 1 " +
                "else " +
                    "return 0 " +
                "end";
        return stringRedisTemplate.execute(new DefaultRedisScript<>(script,Boolean.class), Arrays.asList(lockname),keyVal,String.valueOf(30));
    }
​
    private void unlock(String lockname, String keyVal) {
        // 判断加锁与解锁是不是同一个,保证安全性
//        if (redisStringCache.getCacheObject(lockname).equals(keyVal)) {
//            redisStringCache.deleteObject(lockname);
//        }
        String script =
                "if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then " +
                        "return nil " +
                "elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then " +
                        "return redis.call('del',KEYS[1]) " +
                "else " +
                        "return 0 " +
                "end";
        Long flag = stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockname),keyVal,String.valueOf(30));
        if(flag == null){
            throw new RuntimeException("This lock doesn't EXIST");
        }
    }
​
}
复制代码

在锁的可重入性方面使用 redis Hash 的数据结构,锁的 lockname 固定作为 Hash 结构的 key 值,同一线程(UUID + 线程id)得到 keyVal 作为 Hash 结构的 filed,锁每进入值+1 作为 Hash 结构的值记录。

 考虑如果某个线程在锁过期后还未执行完,还需要新增自动续期功能,最终版代码修改如下

import com.hcm.common.utils.uuid.UUID;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
​
import java.util.Arrays;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
​
/**
 * redis 分布式锁
 *
 * @author pc
 * @date 2023/05/08
 */
public class RedisReentrantLock implements Lock {
​
    /**
     * 锁名字
     */
    private String lockName;
​
    /**
     * uuid + 线程 id
     */
    private String keyVal;
​
    /**
     * 到期时间
     */
    private Long expireTime;
​
    private StringRedisTemplate stringRedisTemplate;
​
    public RedisReentrantLock() {
    }
​
    public RedisReentrantLock(StringRedisTemplate stringRedisTemplate, String lockName) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.lockName = lockName;
        this.keyVal = UUID.fastUUID() + ":" + Thread.currentThread().getId();
        this.expireTime = 30L;
    }
​
    @Override
    public void lock() {
        tryLock();
    }
​
    @Override
    public void lockInterruptibly() throws InterruptedException {
​
    }
​
    @Override
    public boolean tryLock() {
        try {
            tryLock(-1L, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return false;
    }
​
    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        // 加锁并设置有效期,防止死锁
        while (!isGetLock(lockName, keyVal)) {
            try {
                TimeUnit.MILLISECONDS.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        refreshExpire();
        return true;
    }
​
    private Boolean isGetLock(String lockName, String keyVal) {
        String script =
                "if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then " +
                        "redis.call('hincrby',KEYS[1],ARGV[1],1) " +
                        "redis.call('expire',KEYS[1],ARGV[2]) " +
                        "return 1 " +
                        "else " +
                        "return 0 " +
                        "end";
        return stringRedisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), keyVal, String.valueOf(expireTime));
    }
​
    private void refreshExpire() {
        String script =
                "if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 1 then " +
                        "return redis.call('expire',KEYS[1],ARGV[2]) " +
                        "else " +
                        "return 0 " +
                        "end";
​
        new Timer().schedule(new TimerTask() {
            @Override
            public void run() {
                if (stringRedisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), keyVal, String.valueOf(expireTime))) {
                    refreshExpire();
                }
            }
        }, (this.expireTime * 1000) / 3);
    }
​
    @Override
    public void unlock() {
        // 判断加锁与解锁是不是同一个,保证安全性
        String script =
                "if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then " +
                        "return nil " +
                        "elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then " +
                        "return redis.call('del',KEYS[1]) " +
                        "else " +
                        "return 0 " +
                        "end";
        Long flag = stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName), keyVal, String.valueOf(expireTime));
        if (flag == null) {
            throw new RuntimeException("This lock doesn't EXIST");
        }
    }
​
    @Override
    public Condition newCondition() {
        return null;
    }
​
}
复制代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
​
/**
 * LockFactory
 *
 * @author pc
 * @date 2023/05/08
 */
@Component
public class LockFactory {
​
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
​
    public  RedisReentrantLock getLock(){
        String lockName = "lockname";
        return new RedisReentrantLock(stringRedisTemplate,lockName);
    }
}
复制代码
import com.hcm.common.core.redis.LockFactory;
import com.hcm.common.core.redis.RedisReentrantLock;
import com.hcm.common.core.redis.RedisStringCache;
import com.hcm.common.utils.StringUtils;
​
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
​
import javax.annotation.Resource;
import java.util.Arrays;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
​
/**
 * 并发测试
 *
 * @author pc
 * @date 2023/05/07
 */
@Api("ConcurrentTest test")
@Slf4j
@RestController
@RequestMapping("/test")
public class ConcurrentTest {
    @Resource(name = "threadPoolExecutor")
    private ThreadPoolExecutor threadPoolExecutor;
​
    private final String KEY = "shops:001";
​
    private final String LOCKNAME = "lockname";
    private final ReentrantLock lock = new ReentrantLock();
​
    @Autowired
    private RedisStringCache redisStringCache;
​
    @Autowired
    private LockFactory lockFactory;
​
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
​
    @Value("${server.port}")
    private String port;
​
    @GetMapping("/concurrent")
    @ApiOperation(value = "分布式锁", notes = "分布式锁测试")
    public void test() {
        for (int i = 0; i < 2; i++) {
            int finalI = i;
            RedisReentrantLock lock = lockFactory.getLock();
​
            threadPoolExecutor.execute(() -> {
                lock.lock();
                try {
                    lock.lock();
                    try {
                        Integer shopsNum = redisStringCache.getCacheObject(KEY);
                        Integer num = StringUtils.isNull(shopsNum) ? 0 : shopsNum;
                        if (num > 0) {
                            num--;
                            redisStringCache.setCacheObject(KEY, num);
                            log.info("port:{},index:{},sale 1, remain :{}", port, finalI, num);
                        } else {
                            log.info("port:{},index:{},all sell out", port, finalI);
                        }
                    } finally {
                        lock.unlock();
                    }
                } finally {
                    lock.unlock();
                }
            });
        }
    }
}
复制代码

以上代码只是手敲一个简单的分布式锁的案例,在实际运用中可以采用 Redission 来完成分布式锁。

<!--redisson-->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.4</version>
</dependency>
复制代码

Redission 配置

@Configuration
public class RedisConfig{
​
    //单Redis节点模式
    @Bean
    public Redisson redisson(){
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.111.175:6379").setDatabase(0).setPassword("111111");
        return (Redisson) Redisson.create(config);
    }
}
复制代码

Redission 使用

@Service
@Slf4j
public class InventoryService2{
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    @Value("${server.port}")
    private String port;
​
    @Autowired
    private Redisson redisson;
    
    public void test(){
        String key = "lockname";
        RLock redissonLock = redisson.getLock(key);
        redissonLock.lock();
        try{
            Integer shopsNum = redisStringCache.getCacheObject(KEY);
            Integer num = StringUtils.isNull(shopsNum) ? 0 : shopsNum;
            if (num > 0) {
                num--;
                redisStringCache.setCacheObject(KEY, num);
                log.info("port:{},index:{},sale 1, remain :{}", port, finalI, num);
            } else {
                log.info("port:{},index:{},all sell out", port, finalI);
            }
        }finally {
            if(redissonLock.isLocked() && redissonLock.isHeldByCurrentThread()){
                redissonLock.unlock();
            }
        }
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值