- Limit reached exit code changed from 3 to 60
- Fixes related to Pygments (pygmentize -l json)
get
each command (will render pretty JSON)length
each command for length of string (strlen) or collection (llen, hlen, scard, zcard) by first checkingtype
value
each command to get key or sample collection@quiet
to not echo the each command/key
Use ~/redis-scan-bash/bin/redis-scan.sh
which you can alias in ~/.bashrc
as per instructions further below.
The bin/bashrc.redis-scan.sh
and rhloggin.sh
files must no longer to imported into your ~/.bashrc.
We know we must avoid redis-cli keys '*'
especially on production servers with many keys, since that blocks other clients for a significant time e.g. more than 250ms, maybe even a few seconds. That might mean all current requests by users of your website are delayed for that time. Those will be recorded in your slowlog
which you might be monitoring, and so alerts get triggered etc. Let's avoid that.
Here is a Redis scanner aliased as redis-scan
to use SCAN
iteratively.
Extra features:
- match type of keys e.g. string, list, zset, set, hash
- perform a command on each matching key e.g.
llen
It's brand new and untested, so please test on a disposable VM against a disposable local Redis instance, in case it trashes your Redis keys. As per the ISC license, the author disclaims any responsibility for any unfortunate events resulting from the disastrous use of this bash function ;)
Let me know any issues via Twitter (https://twitter.com/@evanxsummers) or open an issue on Github.
Let's turn on debug logging in our shell to see what redis-scan
is doing:
export RHLEVEL=debug
Scan all keys:
redis-scan '*'
Actually, there is a eachLimit
(default 1000) so it will only scan 1000 keys (in batches, with sleeps inbetween), print a message "Limit reached" and custom exit code of 60.
If the first parameter is a number prefixed with @
then it is taken as the database number:
redis-scan @2
where this scans database number 2
via redis-cli -n 2
We can and should use match
to reduce the number of keys.
redis-scan match 'demo:*'
If a parameter contains an asterisk, then match
is assumed:
redis-scan 'article:*'
We can filter the keys by type using an @
prefix (rather than dashes):
redis-scan match 'feed:*' @set
where supported types are: string, list, hash, set, zset.
We can specify an "each" command to be executed for each matching key:
redis-scan @13 @hash -- hlen
where we use a double-dash to delimit the each
command. In this case we execute hlen
against each key of type hash
Actually the script knows that hlen
is a hashes command, and so @hash
can be omitted:
redis-scan @13 -- hlen
where this will scan all keys in db 13,
and for each hashes key, print its hlen.
Incidently above is equivalent to the following command using xargs
redis-scan -n 13 @hash | xargs -n1 redis-cli -n 13 hlen
The following should print set
for each, since we are filtering sets.
redis-scan @set -- type
Print the first five (left) elements of all list keys:
redis-scan -- lrange 0 4
Initial scan of matching sets:
redis-scan 'rp:*' -- sscan 0
where redis-cli sscan KEY 0
is invoked on each set key matching rp:*
Print hash keys:
redis-scan 'rp:*' -- hkeys
Print hashes:
redis-scan 'rp:*' -- hgetall
Print the length
of the key by checking its type
and then one of: strlen llen hlen scard zcard
redis-scan '*' -- length
Note that by default the script will try to format values resembling JSON, using python
and also pygmentize
if available.
You can install pygmentize
as follows:
sudo easy_install Pygments
sudo easy_install Pygments-json
where we assume you have python
and its easy_install
utility installed on your system.
We disable the eachLimit
by setting it to 0
at the beginning of the command-line as follows:
eachLimit=0 redis-scan @hash match 'some keys' -- ttl
To force the each
command if it dangerous e.g. del,
we must set commit
as follows:
commit=1 eachLimit=0 redis-scan @hash match 'some keys' -- ttl
where actually commit
is not required for ttl
but I'd rather not risk putting del
in any examples.
Alternatively we can use @nolimit
and @commit
directives:
redis-scan @hash @nolimit match 'some keys' -- ttl @commit
where the @
directives can be specified before or after the double-dash delimiter.
The scan sleep duration can be changed as follows:
scanSleep=1.5 redis-scan
where the duration is in seconds, with decimal digits allowed, as per the sleep
shell command.
The script creates a temp dir ~/.redis-scan/tmp
where it writes files prefixed with its PID:
$$.run
contains the PID itself i.e. the value of$$
$$.scan
contains the output from theSCAN
command$$.each
contains output from theeachCommand
When the script errors or completes successfully, it will try delete these files, and also find any such files older than 7 days and delete those.
Note that you can abort execution from a different shell by:
- deleting a specific
$$.run
file to terminate that script - deleting all files in
~/.redis-scan/tmp
(terminate all) - deleting the directory
~/.redis-scan/tmp
(terminate all)
When we have a large number of matching keys, and are performing a type
check and executing a command on each key e.g. expire,
we could impact the server and other clients, so we mitigate this:
- by default there is an
eachLimit
of 1000 keys scanned, then exit with error code 1 - before
SCAN
with the next cursor, sleep for 5ms (hard-coded) - additionally before next scan, sleep for long
scanSleep
(default duration of 250ms) - if the slowlog length increases, double the sleep time e.g. from 250ms to 500ms
- before key type check, sleep for 5ms (hard-coded)
- sleep
eachCommandSleep
(25ms) before any specified each command is executed - while the load average (truncated integer) is above
loadavgLimit
sleep in a loop to wait until it's within this limit - if
loadavgKey
is set, then ascertain the current load average from that Redis key - if
uptimeRemote
is set, then ascertain the current load average via ssh
The defaults themselves are set in the script, and overridden, as follows:
eachLimit=${eachLimit-1000} # limit of keys to scan, pass 0 to disable
scanSleep=${scanSleep-0.250} # sleep 250ms between each scan
eachCommandSleep=${eachCommandSleep-0.025} # sleep 25ms between each command
loadavgLimit=${loadavgLimit-1} # sleep while local loadavg above this threshold
loadavgKey=${loadavgKey-} # ascertain loadavg from Redis key on target instance
uptimeRemote=${uptimeRemote-} # ascertain loadavg via ssh to remote Redis host
So the defaults can be overridden via the command-line passing, or via shell export
Also note that speed can increased using COUNT
higher than the default value of 10
redis-scan 'rp:*' count 100
In this case, the scanSleep
happens after every 100 keys rather than every 10 keys, and so the script will run 10x faster.
In future the script should notice the higher count, and perhaps adapt its sleeping somehow.
Consider that the script is being applied to change the expiry of a large number of keys.
Generally speaking, one wants conversative sleep times to minimise the impact on production machines. This might mean longer runs, e.g. overnight using nohup.
You can roughly work out how long a full scan will take by timing the run for 1000 keys, and factoring the time for the total number of keys. If it's too many days perhaps, you can override the settings scanSleep
and eachCommandSleep
with shorter durations. However, you should monitor your system during these runs to ensure it's not too adversely affected.
We can effectively pause long runs by simulating a high load on the host of the target Redis instance via the loadavgKey
described below.
If running against a remote instance, you can:
- specify
uptimeRemote
for ssh, to determine its loadavg viassh $uptimeRemote uptime
- alternatively specify
loadavgKey
to read the load average via Redis
Then the script can pause when the load average of the remote Redis host is high.
An ssh remote user@host
can be specified for uptime.
This access could be via an ssh forced command.
Test as follows:
ssh $uptimeRemote uptime | sed -n 's/.* load average: \([0-9]*\)\..*/\1/p'
Alternatively when using loadavgKey
you could run a minutely cron job on the Redis host:
minute=`date +%M`
while [ `date +%M` -eq $minute ] # util the minute of the clock changes
do
redis-cli -n 13 setex 'cron:loadavg' 90 `cat /proc/loadavg | cut -d' ' -f1` | grep -v OK
sleep 13
done
where a sleep duration of 13 seconds is choosen, since it has a factor closely exceeding 60 seconds. When the minute changes we exit.
You can test our cron script as follows:
~/redis-scan-bash$ bash bin/cron.minutely.set.loadavg.redis.sh 'cron:loadavg' DBN
But beware it will set that key and the specified Redis instance.
Then into the crontab, specifying the key and database number or numbers on which to set this key:
* * * * * ~/redis-scan-bash/bin/cron.minutely.set.loadavg.redis.sh "cron:loadavg" <dbns>
We can monitor it:
redis-cli -n 13 ttl cron:loadavg
(integer) 79
redis-cli -n 13 get cron:loadavg
"0.01"
Currently we support the following "each" commands:
- key meta data:
type
ttl
- key expiry and deletion:
persist
expire
del
- string:
get
- set:
scard smembers sscan
- zset:
zrange zrevrange zscan
- list:
llen lrange
- hash:
hlen hgetall hkeys hscan
However the scan commands must have cursor 0
i.e. just the first batch
Let's grab the repo into our home directory.
( set -e
cd
git clone https://github.com/evanx/redis-scan-bash
ls -l redis-scan-bash/bin
)
We alias redis-scan
for for shell:
alias redis-scan=~/redis-scan-bash/bin/redis-scan.sh
where this assumes that the repo has been cloned to ~/redis-scan-bash
Later, drop this into your ~/.bashrc
for next time.
Now we can try redis-scan
in this shell:
redis-scan
redis-scan @set
redis-scan @hash match '*'
redis-scan @set -- ttl
To enable debug logging:
export RHLEVEL=debug
To disable debug logging:
export RHLEVEL=info
- regex for filtering keys
- refactor as a standalone bash script not necessarily via bashrc (DONE)
- write a Node version
When a future Node version has V8 supporting async/await so Babel not is required and start up time is quick.
Then Node will be an excellent choice for console apps like this.
Hopefully I will be inspired to write the Node version in meantime, even though it will not be as useful because of slow startup.
I'll be refactoring to externalise the RedisScan
function from bashrc
Then it can be included in your PATH
or aliased in bashrc
as follows:
alias redis-scan=~/redis-scan-bash/bin/redis-scan.sh
Then the script can set -e
i.e. exit on error, with an exit trap to cleanup. Also then it can be split into multiple functions to be more readable and avoid some repetitive code.
It was originally intended to be a simple function that I would paste into bashrc
but it became bigger than expected. Consequently it has some code repetition for short term pragmatism, but which violates the DRY principle.
By the way, I'm a firm believer that bash scripts should set -e
from the outset:
- we must handle nonzero returns, otherwise the script will exit
- the exit trap should alert us that the script has aborted on error
- in this case, the nonzero exit code can be
$LINENO
for debugging purposes
This enforces the good practice of handling errors, and vastly improves the robustness of bash scripts.
In development/testing:
- aborts force us to handle typical errors
In production:
- we abort before any damage is done
It's easy to reason about the state, when we know that all commands succeeded, or otherwise their nonzero returns were handled appropriately.
So for your next bash script, try set -e
and persevere. It's worth it :)