在RedisTemplate中使用scan代替keys指令

keys * 這個命令千萬別在生產環境亂用。特別是數據龐大的狀況下。由於Keys會引起Redis鎖,而且增長Redis的CPU佔用。不少公司的運維都是禁止了這個命令的html

當須要掃描key,匹配出本身須要的key時,可使用 scan 命令java

scan操做的Helper實現

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

@Component
public class RedisHelper {
    
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    /**
     * scan 實現
     * @param pattern    表達式
     * @param consumer    對迭代到的key進行操做
     */
    public void scan(String pattern, Consumer<byte[]> consumer) {
        this.stringRedisTemplate.execute((RedisConnection connection) -> {
            try (Cursor<byte[]> cursor = connection.scan(ScanOptions.scanOptions().count(Long.MAX_VALUE).match(pattern).build())) {
                cursor.forEachRemaining(consumer);
                return null;
            } catch (IOException e) {
                e.printStackTrace();
                throw new RuntimeException(e);
            }
        });
    }

    /**
     * 獲取符合條件的key
     * @param pattern    表達式
     * @return
     */
    public List<String> keys(String pattern) {
        List<String> keys = new ArrayList<>();
        this.scan(pattern, item -> {
            //符合條件的key
            String key = new String(item,StandardCharsets.UTF_8);
            keys.add(key);
        });
        return keys;
    }
}

可是會有一個問題:無法移動cursor,也只能scan一次,而且容易致使redis連接報錯redis

先了解下scan、hscan、sscan、zscan

http://doc.redisfans.com/key/scan.htmlspring

keys 爲啥不安全?

  • keys的操做會致使數據庫暫時被鎖住,其餘的請求都會被堵塞;業務量大的時候會出問題

Spring RedisTemplate實現scan

1. hscan sscan zscan

  • 例子中的"field"是值redis的key,即從key爲"field"中的hash中查找
  • redisTemplate的opsForHash,opsForSet,opsForZSet 能夠 分別對應 sscan、hscan、zscan
  • 固然這個網上的例子其實也不對,由於沒有拿着cursor遍歷,只scan查了一次
  • 能夠偷懶使用 .count(Integer.MAX_VALUE),一會兒全查回來;可是這樣子和 keys 有啥區別呢?搞笑臉 & 疑問臉
  • 可使用 (JedisCommands) connection.getNativeConnection()的 hscan、sscan、zscan 方法實現cursor遍歷,參照下文2.2章節
try {
    Cursor<Map.Entry<Object,Object>> cursor = redisTemplate.opsForHash().scan("field",
    ScanOptions.scanOptions().match("*").count(1000).build());
    while (cursor.hasNext()) {
        Object key = cursor.next().getKey();
        Object valueSet = cursor.next().getValue();
    }
    //關閉cursor
    cursor.close();
} catch (IOException e) {
    e.printStackTrace();
}
  • cursor.close(); 遊標必定要關閉,否則鏈接會一直增加;可使用client lists`info clients`info stats命令查看客戶端鏈接狀態,會發現scan操做一直存在
  • 咱們平時使用的redisTemplate.execute 是會主動釋放鏈接的,能夠查看源碼確認
client list
......
id=1531156 addr=xxx:55845 fd=8 name= age=80 idle=11 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=0 obl=0 oll=0 omem=0 events=r cmd=scan
......
org.springframework.data.redis.core.RedisTemplate#execute(org.springframework.data.redis.core.RedisCallback<T>, boolean, boolean)

finally {
    RedisConnectionUtils.releaseConnection(conn, factory);
}

2. scan

2.1 網上給的例子多半是這個

  • 這個 connection.scan 無法移動cursor,也只能scan一次
public Set<String> scan(String matchKey) {
    Set<String> keys = redisTemplate.execute((RedisCallback<Set<String>>) connection -> {
        Set<String> keysTmp = new HashSet<>();
        Cursor<byte[]> cursor = connection.scan(new ScanOptions.ScanOptionsBuilder().match("*" + matchKey + "*").count(1000).build());
        while (cursor.hasNext()) {
            keysTmp.add(new String(cursor.next()));
        }
        return keysTmp;
    });

    return keys;
}

2.2 使用 MultiKeyCommands

  • 獲取 connection.getNativeConnectionconnection.getNativeConnection()實際對象是Jedis(debug能夠看出) ,Jedis實現了不少接口
public class Jedis extends BinaryJedis implements JedisCommands, MultiKeyCommands, AdvancedJedisCommands, ScriptingCommands, BasicCommands, ClusterCommands, SentinelCommands
  • 當 scan.getStringCursor() 存在 且不是 0 的時候,一直移動遊標獲取
public Set<String> scan(String key) {
    return redisTemplate.execute((RedisCallback<Set<String>>) connection -> {
        Set<String> keys = Sets.newHashSet();

        JedisCommands commands = (JedisCommands) connection.getNativeConnection();
        MultiKeyCommands multiKeyCommands = (MultiKeyCommands) commands;

        ScanParams scanParams = new ScanParams();
        scanParams.match("*" + key + "*");
        scanParams.count(1000);
        ScanResult<String> scan = multiKeyCommands.scan("0", scanParams);
        while (null != scan.getStringCursor()) {
            keys.addAll(scan.getResult());
            if (!StringUtils.equals("0", scan.getStringCursor())) {
                scan = multiKeyCommands.scan(scan.getStringCursor(), scanParams);
                continue;
            } else {
                break;
            }
        }

        return keys;
    });
}

發散思考

cursor沒有close,到底誰阻塞了,是 Redis 麼

  • 測試過程當中,我基本只要發起十來個scan操做,沒有關閉cursor,接下來的請求都卡住了

redis側分析

  • client lists`info clients`info stats查看
    發現 鏈接數 只有 十幾個,也沒有阻塞和被拒絕的鏈接
  • config get maxclients查詢redis容許的最大鏈接數 是 10000
1) "maxclients"
2) "10000"`
  • redis-cli在其餘機器上也能夠直接登陸 操做

綜上,redis自己沒有卡死數據庫

應用側分析

  • netstat查看和redis的鏈接,6333是redis端口;鏈接一直存在
➜  ~ netstat -an | grep 6333
netstat -an | grep 6333
tcp4       0      0  xx.xx.xx.aa.52981      xx.xx.xx.bb.6333     ESTABLISHED
tcp4       0      0  xx.xx.xx.aa.52979      xx.xx.xx.bb.6333     ESTABLISHED
tcp4       0      0  xx.xx.xx.aa.52976      xx.xx.xx.bb.6333     ESTABLISHED
tcp4       0      0  xx.xx.xx.aa.52971      xx.xx.xx.bb.6333     ESTABLISHED
tcp4       0      0  xx.xx.xx.aa.52969      xx.xx.xx.bb.6333     ESTABLISHED
tcp4       0      0  xx.xx.xx.aa.52967      xx.xx.xx.bb.6333     ESTABLISHED
tcp4       0      0  xx.xx.xx.aa.52964      xx.xx.xx.bb.6333     ESTABLISHED
tcp4       0      0  xx.xx.xx.aa.52961      xx.xx.xx.bb.6333     ESTABLISHED
  • jstack查看應用的堆棧信息
    發現不少 WAITING 的 線程,全都是在獲取redis鏈接
    因此基本能夠判定是應用的redis線程池滿了
"http-nio-7007-exec-2" #139 daemon prio=5 os_prio=31 tid=0x00007fda36c1c000 nid=0xdd03 waiting on condition [0x00007000171ff000]
   java.lang.Thread.State: WAITING (parking)
        at sun.misc.Unsafe.park(Native Method)
        - parking to wait for  <0x00000006c26ef560> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
        at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
        at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039)
        at org.apache.commons.pool2.impl.LinkedBlockingDeque.takeFirst(LinkedBlockingDeque.java:590)
        at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:441)
        at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:362)
        at redis.clients.util.Pool.getResource(Pool.java:49)
        at redis.clients.jedis.JedisPool.getResource(JedisPool.java:226)
        at redis.clients.jedis.JedisPool.getResource(JedisPool.java:16)
        at org.springframework.data.redis.connection.jedis.JedisConnectionFactory.fetchJedisConnector(JedisConnectionFactory.java:276)
        at org.springframework.data.redis.connection.jedis.JedisConnectionFactory.getConnection(JedisConnectionFactory.java:469)
        at org.springframework.data.redis.core.RedisConnectionUtils.doGetConnection(RedisConnectionUtils.java:132)
        at org.springframework.data.redis.core.RedisTemplate.executeWithStickyConnection(RedisTemplate.java:371)
        at org.springframework.data.redis.core.DefaultHashOperations.scan(DefaultHashOperations.java:244)

綜上,是應用側卡死apache

後續

  • 過了一箇中午,redis client lists顯示 scan 鏈接還在,沒有釋放;應用線程也仍是處於卡死狀態
  • 檢查 config get timeout,redis未設置超時時間,能夠用 config set timeout xxx設置,單位秒;可是設置了redis的超時,redis釋放了鏈接,應用仍是同樣卡住
1) "timeout"
2) "0"
  • netstat查看和redis的鏈接,6333是redis端口;鏈接從ESTABLISHED變成了CLOSE_WAIT;
  • jstack和 原來表現同樣,卡在JedisConnectionFactory.getConnection
➜  ~ netstat -an | grep 6333
netstat -an | grep 6333
tcp4       0      0  xx.xx.xx.aa.52981      xx.xx.xx.bb.6333     CLOSE_WAIT
tcp4       0      0  xx.xx.xx.aa.52979      xx.xx.xx.bb.6333     CLOSE_WAIT
tcp4       0      0  xx.xx.xx.aa.52976      xx.xx.xx.bb.6333     CLOSE_WAIT
tcp4       0      0  xx.xx.xx.aa.52971      xx.xx.xx.bb.6333     CLOSE_WAIT
tcp4       0      0  xx.xx.xx.aa.52969      xx.xx.xx.bb.6333     CLOSE_WAIT
tcp4       0      0  xx.xx.xx.aa.52967      xx.xx.xx.bb.6333     CLOSE_WAIT
tcp4       0      0  xx.xx.xx.aa.52964      xx.xx.xx.bb.6333     CLOSE_WAIT
tcp4       0      0  xx.xx.xx.aa.52961      xx.xx.xx.bb.6333     CLOSE_WAIT
  • 回顧一下TCP四次揮手
    ESTABLISHED 表示鏈接已被創建
    CLOSE_WAIT 表示遠程計算器關閉鏈接,正在等待socket鏈接的關閉
    和現象符合
  • redis鏈接池配置
    根據上面 netstat -an基本能夠肯定 redis 鏈接池的大小是 8 ;結合代碼配置,沒有指定的話,默認也確實是8
redis.clients.jedis.JedisPoolConfig
private int maxTotal = 8;
private int maxIdle = 8;
private int minIdle = 0;
  • 如何配置更大的鏈接池呢?
    A. 原配置
@Bean
public RedisConnectionFactory redisConnectionFactory() {
    RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
    redisStandaloneConfiguration.setHostName(redisHost);
    redisStandaloneConfiguration.setPort(redisPort);
    redisStandaloneConfiguration.setPassword(RedisPassword.of(redisPasswd));
    JedisConnectionFactory cf = new JedisConnectionFactory(redisStandaloneConfiguration);
    cf.afterPropertiesSet();
    return cf;
}

readTimeout,connectTimeout不指定,有默認值 2000 ms安全

org.springframework.data.redis.connection.jedis.JedisConnectionFactory.MutableJedisClientConfiguration
private Duration readTimeout = Duration.ofMillis(Protocol.DEFAULT_TIMEOUT);
private Duration connectTimeout = Duration.ofMillis(Protocol.DEFAULT_TIMEOUT);

B. 修改後配置bash

    1. 配置方式一:部分接口已經Deprecated了
@Bean
public RedisConnectionFactory redisConnectionFactory() {
    JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
    jedisPoolConfig.setMaxTotal(16); // --最多能夠創建16個鏈接了
    jedisPoolConfig.setMaxWaitMillis(10000); // --10s獲取不到鏈接池的鏈接,
                                             // --直接報錯Could not get a resource from the pool

    jedisPoolConfig.setMaxIdle(16);
    jedisPoolConfig.setMinIdle(0);

    JedisConnectionFactory cf = new JedisConnectionFactory(jedisPoolConfig);
    cf.setHostName(redisHost); // -- @Deprecated 
    cf.setPort(redisPort); // -- @Deprecated 
    cf.setPassword(redisPasswd); // -- @Deprecated 
    cf.setTimeout(30000); // -- @Deprecated 貌似沒生效,30s超時,沒有關閉鏈接池的鏈接;
                          // --redis沒有設置超時,會一直ESTABLISHED;redis設置了超時,且超時以後,會一直CLOSE_WAIT

    cf.afterPropertiesSet();
    return cf;
}
    1. 配置方式二:這是羣裏好友給找的新的配置方式,效果同樣
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(redisHost);
redisStandaloneConfiguration.setPort(redisPort);
redisStandaloneConfiguration.setPassword(RedisPassword.of(redisPasswd));

JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(16);
jedisPoolConfig.setMaxWaitMillis(10000);
jedisPoolConfig.setMaxIdle(16);
jedisPoolConfig.setMinIdle(0);

cf = new JedisConnectionFactory(redisStandaloneConfiguration, JedisClientConfiguration.builder()
        .readTimeout(Duration.ofSeconds(30))
        .connectTimeout(Duration.ofSeconds(30))
        .usePooling().poolConfig(jedisPoolConfig).build());

參考

redistemplate-遊標scan使用注意事項

如何使用RedisTemplate訪問Redis數據結構數據結構

Redis 中 Keys 與 Scan 的使用運維

深刻理解Redis的scan命令

spring-boot-starter-redis配置詳解

線上大量CLOSE_WAIT緣由排查

redis如何配置standAlone版的jedisPool

一次jedis使用不規範,致使redis客戶端close_wait大量增長的bug

相關文章
相關標籤/搜索