redis_migrator 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +19 -0
- data/.rspec +2 -0
- data/README.md +35 -0
- data/lib/redis_migrator/redis_helper.rb +52 -0
- data/lib/redis_migrator/redis_populator.rb +63 -0
- data/lib/redis_migrator.rb +96 -0
- data/migrator_benchmark.rb +22 -0
- data/redis_migrator.gemspec +20 -0
- data/spec/migrator_spec.rb +63 -0
- data/spec/mock_redis/lib/mock_redis/assertions.rb +13 -0
- data/spec/mock_redis/lib/mock_redis/database.rb +432 -0
- data/spec/mock_redis/lib/mock_redis/distributed.rb +6 -0
- data/spec/mock_redis/lib/mock_redis/exceptions.rb +3 -0
- data/spec/mock_redis/lib/mock_redis/expire_wrapper.rb +25 -0
- data/spec/mock_redis/lib/mock_redis/hash_methods.rb +118 -0
- data/spec/mock_redis/lib/mock_redis/list_methods.rb +187 -0
- data/spec/mock_redis/lib/mock_redis/multi_db_wrapper.rb +86 -0
- data/spec/mock_redis/lib/mock_redis/set_methods.rb +126 -0
- data/spec/mock_redis/lib/mock_redis/string_methods.rb +203 -0
- data/spec/mock_redis/lib/mock_redis/transaction_wrapper.rb +80 -0
- data/spec/mock_redis/lib/mock_redis/undef_redis_methods.rb +11 -0
- data/spec/mock_redis/lib/mock_redis/utility_methods.rb +25 -0
- data/spec/mock_redis/lib/mock_redis/version.rb +3 -0
- data/spec/mock_redis/lib/mock_redis/zset.rb +110 -0
- data/spec/mock_redis/lib/mock_redis/zset_methods.rb +210 -0
- data/spec/mock_redis/lib/mock_redis.rb +119 -0
- data/spec/redis_helper_spec.rb +58 -0
- data/spec/spec_helper.rb +29 -0
- metadata +107 -0
data/.gitignore
ADDED
data/.rspec
ADDED
data/README.md
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
Redis-migrator
|
2
|
+
==============
|
3
|
+
Redis-migrator is a tool to redistribute keys in your redis cluster when its topography has
|
4
|
+
changed.
|
5
|
+
|
6
|
+
##How it works
|
7
|
+
|
8
|
+
Say you are using Redis::Distributed to distribute your writes and reads across different
|
9
|
+
redis nodes. Redis::Distributed uses consitent hashing algorithm to determine where a command
|
10
|
+
will go. If you changed configuration of your cluster, for example, changed a hostname or added a new node then routes for some of the keys will change too. If you try to read such a key - you won't get a data from its old node.
|
11
|
+
|
12
|
+
Redis-migrator takes a list of nodes for your old cluster and list of nodes for your new cluster
|
13
|
+
and determines for which keys routes were changed. Then it moves those keys to new nodes.
|
14
|
+
|
15
|
+
##Install
|
16
|
+
`gem install redis_migrator`
|
17
|
+
|
18
|
+
##Usage
|
19
|
+
require 'redis_migrator'
|
20
|
+
|
21
|
+
# a list of redis-urls for an old cluster
|
22
|
+
old_redis_hosts = ["redis://host1.com:6379", "redis://host2.com:6379"]
|
23
|
+
|
24
|
+
# a list of redis-urls for a new cluster
|
25
|
+
old_redis_hosts = ["redis://host1.com:6379", "redis://host2.com:6379", "redis://host3.com:6379"]
|
26
|
+
|
27
|
+
migrator = Redis::Migrator.new(old_redis_hosts, new_redis_hosts)
|
28
|
+
migrator.run
|
29
|
+
|
30
|
+
##Requirements
|
31
|
+
* ruby 1.9 or jruby (with --1.9 flag)
|
32
|
+
* redis >=2.4.14 (only on machine where migrator will be running)
|
33
|
+
|
34
|
+
##TODO
|
35
|
+
* Error handling
|
@@ -0,0 +1,52 @@
|
|
1
|
+
class Redis
|
2
|
+
module Helper
|
3
|
+
|
4
|
+
def to_redis_proto(*cmd)
|
5
|
+
cmd.inject("*#{cmd.length}\r\n") {|acc, arg|
|
6
|
+
acc << "$#{arg.length}\r\n#{arg}\r\n"
|
7
|
+
}
|
8
|
+
end
|
9
|
+
|
10
|
+
def parse_redis_url(redis_url)
|
11
|
+
node = URI(redis_url)
|
12
|
+
path = node.path
|
13
|
+
db = path[1..-1].to_i rescue 0
|
14
|
+
|
15
|
+
{
|
16
|
+
:host => node.host,
|
17
|
+
:port => node.port || 6379,
|
18
|
+
:db => db
|
19
|
+
}
|
20
|
+
end
|
21
|
+
|
22
|
+
def copy_string(pipe, key)
|
23
|
+
value = redis.get(key)
|
24
|
+
pipe << to_redis_proto("SET", key, value)
|
25
|
+
end
|
26
|
+
|
27
|
+
def copy_hash(pipe, key)
|
28
|
+
redis.hgetall(key).each do |field, value|
|
29
|
+
pipe << to_redis_proto("HSET", key, field, value)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def copy_list(pipe, key)
|
34
|
+
redis.lrange(key, 0, -1).each do |value|
|
35
|
+
pipe << to_redis_proto("LPUSH", key, value)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def copy_set(pipe, key)
|
40
|
+
redis.smembers(key).each do |member|
|
41
|
+
pipe << to_redis_proto("SADD", key, member)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def copy_zset(pipe, key)
|
46
|
+
redis.zrange(key, 0, -1, :with_scores => true).each_slice(2) do |member, score|
|
47
|
+
pipe << to_redis_proto("ZADD", key, score, member)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
require 'digest'
|
2
|
+
require_relative 'redis_helper'
|
3
|
+
|
4
|
+
class Redis
|
5
|
+
class Populator
|
6
|
+
include Redis::Helper
|
7
|
+
|
8
|
+
attr_accessor :redis
|
9
|
+
|
10
|
+
def initialize(redis_hosts)
|
11
|
+
@redis = Redis::Distributed.new(redis_hosts)
|
12
|
+
end
|
13
|
+
|
14
|
+
# Generate sets' keys to populate redis cluster
|
15
|
+
# @param num size [Integer] the number of sets that have to be created
|
16
|
+
# @return hash of keys grouped by redis node
|
17
|
+
def generate_keys(num)
|
18
|
+
num.times.inject({}) do |acc, i|
|
19
|
+
key = ::Digest::MD5.hexdigest(i.to_s)
|
20
|
+
node = redis.node_for(key).client
|
21
|
+
hash_key = "redis://#{node.host}:#{node.port}/#{node.db}"
|
22
|
+
acc[hash_key] = [] if acc[hash_key].nil?
|
23
|
+
acc[hash_key] << key
|
24
|
+
acc
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Populates sets with the given amount of members
|
29
|
+
# @param node [Hash] a parsed redis_url
|
30
|
+
# @param keys [Array] an array of sets' keys that need to be populated
|
31
|
+
def populate_keys(node, keys, size)
|
32
|
+
f = IO.popen("redis-cli -h #{node[:host]} -p #{node[:port]} -n #{node[:db]} --pipe", IO::RDWR)
|
33
|
+
|
34
|
+
keys.each do |key|
|
35
|
+
size.times.map do |x|
|
36
|
+
f << to_redis_proto("SADD", key, ::Digest::MD5.hexdigest("f" + x.to_s))
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
f.close
|
41
|
+
end
|
42
|
+
|
43
|
+
# Populates redis cluster
|
44
|
+
# @param keys_num [Integer] amount of redis sets that need to be populated
|
45
|
+
# @param num [Integer] number of members in each set
|
46
|
+
def populate_cluster(keys_num, num)
|
47
|
+
redis.flushdb
|
48
|
+
|
49
|
+
nodes = generate_keys(keys_num)
|
50
|
+
threads = []
|
51
|
+
|
52
|
+
nodes.keys.each do |node_url|
|
53
|
+
node = parse_redis_url(node_url)
|
54
|
+
|
55
|
+
threads << Thread.new(node, nodes[node_url], num) {|node, keys, size|
|
56
|
+
populate_keys(node, keys, size)
|
57
|
+
}
|
58
|
+
end
|
59
|
+
|
60
|
+
threads.each{|t| t.join}
|
61
|
+
end
|
62
|
+
end # class Populator
|
63
|
+
end # class Redis
|
@@ -0,0 +1,96 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'redis'
|
3
|
+
require 'redis/distributed'
|
4
|
+
require_relative 'redis_migrator/redis_helper'
|
5
|
+
|
6
|
+
class Redis
|
7
|
+
class Migrator
|
8
|
+
include Redis::Helper
|
9
|
+
|
10
|
+
attr_accessor :old_cluster, :new_cluster, :old_hosts, :new_hosts
|
11
|
+
|
12
|
+
def initialize(old_hosts, new_hosts)
|
13
|
+
@old_hosts = old_hosts
|
14
|
+
@new_hosts = new_hosts
|
15
|
+
@old_cluster = Redis::Distributed.new(old_hosts)
|
16
|
+
@new_cluster = Redis::Distributed.new(new_hosts)
|
17
|
+
end
|
18
|
+
|
19
|
+
def redis
|
20
|
+
Thread.current[:redis]
|
21
|
+
end
|
22
|
+
|
23
|
+
# Finds redis keys for which migration is needed
|
24
|
+
# @return a hash of keys grouped by node they need to be written to
|
25
|
+
# @example Returned value
|
26
|
+
# { "redis://host1.com" => ['key1', 'key2', 'key3'],
|
27
|
+
# "redis://host2.com => ['key4', 'key5', 'key6']" }
|
28
|
+
def changed_keys
|
29
|
+
keys = @old_cluster.keys("*")
|
30
|
+
|
31
|
+
keys.inject({}) do |acc, key|
|
32
|
+
old_node = @old_cluster.node_for(key).client
|
33
|
+
new_node = @new_cluster.node_for(key).client
|
34
|
+
|
35
|
+
if (old_node.host != new_node.host) || (old_node.port != new_node.port)
|
36
|
+
hash_key = "redis://#{new_node.host}:#{new_node.port}/#{new_node.db}"
|
37
|
+
acc[hash_key] = [] if acc[hash_key].nil?
|
38
|
+
acc[hash_key] << key
|
39
|
+
end
|
40
|
+
|
41
|
+
acc
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Migrates a given array of keys to a given redis node
|
46
|
+
# @param node [Hash] options for redis node keys will be migrated to
|
47
|
+
# @param keys [Array] array of keys that need to be migrated
|
48
|
+
# @param options [Hash] additional options, such as :do_not_remove => true
|
49
|
+
def migrate_keys(node, keys, options={})
|
50
|
+
return false if keys.empty? || keys.nil?
|
51
|
+
|
52
|
+
Thread.current[:redis] = Redis::Distributed.new(old_hosts)
|
53
|
+
|
54
|
+
pipe = IO.popen("redis-cli -h #{node[:host]} -p #{node[:port]} -n #{node[:db]} --pipe", IO::RDWR)
|
55
|
+
|
56
|
+
keys.each {|key|
|
57
|
+
copy_key(pipe, key)
|
58
|
+
|
59
|
+
#remove key from old node
|
60
|
+
redis.node_for(key).del(key) unless options[:do_not_remove]
|
61
|
+
}
|
62
|
+
|
63
|
+
pipe.close
|
64
|
+
end
|
65
|
+
|
66
|
+
# Runs a migration process for a Redis cluster.
|
67
|
+
# @param [Hash] additional options such as :do_not_remove => true
|
68
|
+
def run(options={})
|
69
|
+
keys_to_migrate = changed_keys
|
70
|
+
puts "Migrating #{keys_to_migrate.values.flatten.count} keys"
|
71
|
+
threads = []
|
72
|
+
|
73
|
+
keys_to_migrate.keys.each do |node_url|
|
74
|
+
node = parse_redis_url(node_url)
|
75
|
+
|
76
|
+
#spawn a separate thread for each Redis pipe
|
77
|
+
threads << Thread.new(node, keys_to_migrate[node_url]) {|node, keys|
|
78
|
+
migrate_keys(node, keys, options)
|
79
|
+
}
|
80
|
+
end
|
81
|
+
|
82
|
+
threads.each{|t| t.join}
|
83
|
+
end
|
84
|
+
|
85
|
+
# Copy a given Redis key to a Redis pipe
|
86
|
+
# @param pipe [IO] a pipe opened redis-cli --pipe
|
87
|
+
# @param key [String] a Redis key that needs to be copied
|
88
|
+
def copy_key(pipe, key)
|
89
|
+
key_type = old_cluster.type(key)
|
90
|
+
return false unless ['list', 'hash', 'string', 'set', 'zset'].include?(key_type)
|
91
|
+
|
92
|
+
self.send("copy_#{key_type}", pipe, key)
|
93
|
+
end
|
94
|
+
|
95
|
+
end # class Migrator
|
96
|
+
end # class Redis
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'redis_migrator'
|
2
|
+
require 'uri'
|
3
|
+
require 'benchmark'
|
4
|
+
require 'redis_migrator/redis_populator'
|
5
|
+
|
6
|
+
# a list of hosts for an old cluster
|
7
|
+
# You either have to start 3 Redis instances on your local - each on its
|
8
|
+
# own port: 6379, 6378, 6377. Or, better, have a real Redis nodes running
|
9
|
+
old_redis_hosts = ["redis://localhost:6379/9", "redis://localhost:6378/9"]
|
10
|
+
|
11
|
+
# a list of hosts for a new cluster
|
12
|
+
new_redis_hosts = ["redis://localhost:6379/9", "redis://localhost:6378/9", "redis://localhost:6377/9"]
|
13
|
+
|
14
|
+
r = Redis::Populator.new(old_redis_hosts)
|
15
|
+
|
16
|
+
migrator = Redis::Migrator.new(old_redis_hosts, new_redis_hosts)
|
17
|
+
|
18
|
+
Benchmark.bm do |x|
|
19
|
+
x.report("populate:") { r.populate_cluster(1000, 100) }
|
20
|
+
x.report("migrate:") { migrator.run }
|
21
|
+
end
|
22
|
+
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("./lib", __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |s|
|
5
|
+
s.name = "redis_migrator"
|
6
|
+
s.version = "0.0.1"
|
7
|
+
s.date = "2012-07-05"
|
8
|
+
s.platform = Gem::Platform::RUBY
|
9
|
+
s.authors = ["Artem Yankov"]
|
10
|
+
s.email = ["artem.yankov@gmail.com"]
|
11
|
+
s.homepage = "http://rubygems.org/gems/redis_migrator"
|
12
|
+
s.summary = %q{A tool to redistribute keys in your redis cluster when its topography has changed}
|
13
|
+
s.description = %q{Redis-migrator takes a list of nodes for your old cluster and list of nodes for your new cluster and determines for which keys routes were changed. Then it moves those keys to new nodes.}
|
14
|
+
|
15
|
+
s.files = `git ls-files`.split("\n")
|
16
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
17
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
18
|
+
s.require_paths = ["lib"]
|
19
|
+
s.add_dependency('redis', '>= 2.2.2')
|
20
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
describe Redis::Migrator do
|
2
|
+
|
3
|
+
before do
|
4
|
+
Redis.should_receive(:new).any_number_of_times {|options|
|
5
|
+
MockRedis.new(options)
|
6
|
+
}
|
7
|
+
|
8
|
+
@migrator = Redis::Migrator.new(["redis://localhost:6379", "redis://localhost:6378"],
|
9
|
+
["redis://localhost:6379", "redis://localhost:6378", "redis://localhost:6377"])
|
10
|
+
|
11
|
+
#populate old cluster with some keys
|
12
|
+
('a'..'z').to_a.each do |key|
|
13
|
+
(1..5).to_a.each {|val| @migrator.old_cluster.sadd(key, val)}
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
describe Redis::Migrator, "#changed_keys" do
|
18
|
+
it "should show keys which need migration" do
|
19
|
+
@migrator.changed_keys.should == {"redis://localhost:6377/0" => ["h", "q", "s", "y", "j", "m", "n", "o"]}
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
describe Redis::Migrator, "#migrate_keys" do
|
24
|
+
let(:keys) { ["q", "s", "j"] }
|
25
|
+
let(:node) { {:host => "localhost", :port => 6378, :db => 1} }
|
26
|
+
|
27
|
+
before do
|
28
|
+
Redis::Distributed.should_receive(:new).and_return(@migrator.old_cluster)
|
29
|
+
|
30
|
+
@pipe = PipeMock.new(@migrator.new_cluster)
|
31
|
+
IO.should_receive(:popen).with("redis-cli -h localhost -p 6378 -n 1 --pipe", IO::RDWR).and_return(@pipe)
|
32
|
+
end
|
33
|
+
|
34
|
+
it "should copy given keys to a new cluster" do
|
35
|
+
@migrator.migrate_keys(node, keys)
|
36
|
+
(@migrator.new_cluster.keys("*") & keys).sort.should == ["j", "q", "s"]
|
37
|
+
end
|
38
|
+
|
39
|
+
it "should remove copied keys from the old redis node" do
|
40
|
+
@migrator.migrate_keys(node, keys)
|
41
|
+
(@migrator.old_cluster.keys("*") & keys).sort.should == []
|
42
|
+
end
|
43
|
+
|
44
|
+
it "should keep keys on old node if asked" do
|
45
|
+
@migrator.migrate_keys(node, keys, :do_not_remove => true)
|
46
|
+
(@migrator.old_cluster.keys("*") & keys).sort.should == ["j", "q", "s"]
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
describe Redis::Migrator, "#copy_key" do
|
51
|
+
|
52
|
+
it "should return FALSE for unknown key" do
|
53
|
+
@migrator.copy_key(nil, "some_key").should == false
|
54
|
+
end
|
55
|
+
|
56
|
+
it "should call copy_set if given key is set" do
|
57
|
+
@migrator.should_receive(:copy_set).with(nil, "a")
|
58
|
+
@migrator.copy_key(nil, "a")
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|