数据分析和基于时间的数据
问题
存储数据分析结果或者其它基于时间的数据对传统的存储系统来说多少会是个挑战。比如你可能想对系统流量进行定级,或者对网站访问量进行跟踪并绘成图表。
尽管有很多种方式可以存储这种类型的数据,Redis因为它强大的数据结构,会是个完美的候选方案。
解决方案
Redis非常适合用来存储这一类的数据,特别是用来存储跟踪事件数据。HINCR和HINCRBY命令具备原子性,速度也很快,再配合快速的查询操作,再适合不过了。
为了节省内存,在Redis里存储这些数据时,最好使用哈希来存储计数器,通过HINCRBY来增加计数,然后使用HGET和HMGET来读取。SORT命令可以很容易地实现查找排名靠前的元素。
讨论
为了简单起见,在这个例子里,我们只跟踪点击量。它可以很容易地被扩展用来跟踪其它类型的事件。
require 'rubygems'
require 'active_support/time'
def add_hit(id)
$redis.sadd("clients", id)
$redis.hincrby("stats/client:#{id}", "total", 1)
$redis.hincrby("stats/client:#{id}",
Date.today.to_s(:number), 1)
end
我们把用户的ID加到客户端列表里,然后用两个不同的槽分别记录总点击量和日点击量。这样我们就可以追踪日点击量和总点击量。
def hits(id, day = Date.today)
$redis.hget("stats/client:#{id}", day.to_s(:number)).to_i
end
def over_limit?(id, limit)
hits(id) > limit
end
这个允许我们实现流量控制,只需要检查客户端的访问量在一段时间内是否超过了限制。
获取一段时间内数据是一个很简单的操作,而且可以通过图表或其它方式把它们展示出来:
def keys(beg_p, end_p)
keys = []
while beg_p <= end_p
keys << if block_given?
yield(beg_p.to_s(:number))
else
beg_p.to_s(:number)
end
beg_p += 1.day
end
keys
end
def stats_for_period(id, beginning_of_period, end_of_period)
beg_p = Date.parse(beginning_of_period)
end_p = Date.parse(end_of_period)
$redis.hmget "stats/client:#{id}", *keys(beg_p, end_p)
end
使用SORT命令,我们还能取到任何一个槽里面点击量排名靠前的客户端。我们可以用SORT命令对集合,有序集合或者列表进行排序。在这里,我们需要对clients集合进行排序。另外,当我们要指定顺序,偏移量,边界的时候,可以使用时间槽:
def top_clients(period = "total", limit = 5)
$redis.sort("clients", :by => "stats/client:*->#{period}",
:order => "DESC",
:get => ["#", "stats/client:*->#{period}"],
:limit => [0, limit])
end
基于哈希的实现对存储,读取和更新来说性能很高,但要计算排名靠前的用户并不高效。如果你需要展示分数排名表,可以用有序集合来代替SORT命令,这样就可以保证拿到的数据总是排好序的。
def add_hit(id)
$redis.zincrby("stats/total", 1, id)
end
def hits(id, day = Date.today)
$redis.zrank("stats/#{day.to_s(:number)}", id)
end
def over_limit?(id, limit)
hits(id) > limit
end
def stats_for_period(id, beginning_of_period, end_of_period)
beg_p = Date.parse(beginning_of_period)
end_p = Date.parse(end_of_period)
keys(beg_p, end_p) { |k| $redis.zrank("stats/#{k}", id) }
end
def top_clients(period = "total", limit = 5)
$redis.zrevrange("stats/#{period}", 0, limit, :withscores=> true)
end
def top_for_period(beginning_of_period, end_of_period, limit = 5)
beg_p = Date.parse(beginning_of_period)
end_p = Date.parse(end_of_period)
result_key ="top/#{beg_p.to_s(:number)}/#{end_p.to_s(:number)}"
if $redis.exists(result_key)
return $redis.zrevrange(result_key, 0, limit,:withscores => true)
end
$redis.multi do
$redis.zunionstore result_key, keys(beg_p, end_p){|k|"stats/#{k}"}
$redis.expire result_key, 10.minutes
$redis.zrevrange result_key, 0, limit, :withscores => true
end.last
end
使用有序集合,会让获取靠前元素的操作变得很高效,但同时会占用更多的内存。
用Redis实现一个工作队列
问题
Redis另一个典型的用途是队列,虽然这要归功于Resque这个开源项目。实际上,外面已经有很多种实现了,也有相关教程,不过这里还是要给出一个示例。
解决方案
我们将使用列表来实现队列,列表提供了原子的push/pop操作,而且对列头和列尾的访问时间是固定常量。为了达到自省的目的,我们会用一个集合来存放所有队列,因为集合能够确保唯一性,我们就不用担心会出现两个相同的队列。
讨论
入队
让我们从实现入队功能开始。我们只需要把一个nil或包含一个列表的键RPUSH进去。Redis的列表存放的是字符串,所以我们需要把数据序列化成字符串。在这里,我们使用JSON:
def enqueue(queue_name, data)
$redis.sadd("queues", queue_name)
$redis.rpush("queue:#{queue_name}", data.to_json)
end
我们还要写一些辅助函数来完成一些操作,比如清空队列,移除队列,检查队列长度,移除队列元素,或者查看下一个元素:
def clear(queue_name)
$redis.del("queue:#{queue_name}")
end
def destroy(queue_name)
self.clear(queue_name)
$redis.srem("queues", "queue:#{queue_name}")
end
def length(queue_name)
$redis.llen("queue:#{queue_name}")
end
def remove_job(queue_name, data)
$redis.lrem("queue:#{queue_name}", 0, data.to_json)
end
def peek(queue_name)
$redis.lrange("queue:#{queue_name}", 0, 0)
end
出队
为了让工作任务从队列里出队,我们每次pop一个任务出来:
def dequeue(queue_name)
$redis.lpop("queue:#{queue_name}")
end
不过这样做有一个问题,如果队列里没有工作任务,那么就没有东西返回,随后我们需要再次检查是否有新的任务出现。如果检查的频度太快,会浪费资源,而如果检查的频度太慢,有可能给处理工作任务造成延迟。为了解决这个问题,Redis提供了一个阻塞的pop操作。
def dequeue(queue_name)
$redis.blpop("queue:#{queue_name}", 60)
end
BLPOP是一个阻塞的pop操作。如果列表是空的,它会阻塞一段时间,直到有元素被放到列表里。阻塞的时间由传入的第二个参数决定。这样,我们之前的问题就解决了。工作线程会阻塞在这个方法调用上,直到队列里出现了元素它才会返回。我们也可以为这个操作设置一个超时时间,比如,如果60秒内队列一直为空,那么就返回。
def work(queue_name)
while true do
job = self.dequeue(queue_name)
process_job(job) unless job.nil? enunless job.nil?
end
end
队列任务的处理实现起来很简单,只需要在一个循环里让任务出队,并处理掉。那么如果我们有多个不同优先级的队列该怎么办?很幸运地,BLPOP支持多个列表。熟悉UNIX/LINUX系统编程的人都知道select系统调用,select会同时监视多个文件句柄的活动事件,而BLPOP的这个功能跟select有点类似。
def dequeue(queues)
$redis.blpop(*queues.map{|q| "queue:#{q}"}.push(60))
end
def work(queues)
while true do
job = self.dequeue(queues)
process_job(job) unless job.nil?
end
end
work(['higher-priority', 'high-priority'])
这里涉及到改动的地方主要跟BLPOP的使用方式有关。如果传进来多个列表,它会阻塞等待,直到有元素从这些列表里返回,这些列表传进来的顺序决定了优先级。在我们的例子里,如果两个列表里都有工作任务,第一个列表里的任务会先被返回。因为队列是存放在集合里面的,所以工作线程不需要显示地知道队列的名字:
work($redis.smembers("queues").map{|q| "queue:#{q}"})
不过要注意,这个不保证队列的顺序,我们从Redis里取出来是什么顺序就是什么顺序。如果对队列有优先级的要求,那么需要用到有序集合,并且需要为每一个集合设置不同的分数。
扩展Redis
问题
Redis的源代码可读性好,修改起来也很简单,也许你需要对其进行调整和扩展。你可能需要一个新的命令,或者需要改变已有命令的返回值。又或者你可能对如何减少应用程序和Redis之间因执行太多操作造成的交互开销感兴趣。
解决方案
应广大用户的要求,Redis在2.6里加入了对脚本的支持。这个需要通过集成Lua C API来实现。
Lua作为一门编程语言,经常被内嵌到应用程序里来实现脚本功能。Lua简单,轻量级,非常适合集成到Redis。Redis是单线程的,所以这些脚本也应该是,否则它们会阻塞其它客户端。
讨论
脚本功能由两个命令组成:EVAL和EVALSHA,其中第二个可以用来减少带宽的使用。
require 'rubygems'
require 'redis'
$redis = Redis.new increxby = <<LUA
if redis.call("exists",KEYS[1]) == 1 then
return redis.call("incrby",KEYS[1], ARGV[1])
else
return nil LUA
end
$redis.eval(increxby,[:counter], [1])
这个例子实现了一个有条件的增量操作。这段代码不仅读起来简单,运行起来也很顺畅。另外,Lua脚本会自动被执行,所以可以像MULTI/EXEC那样使用它们。不过要记住,执行脚本会阻塞Redis服务器,所以要注意那些执行速度慢的脚本。如果不使用脚本又想达到同样的效果,我们需要这么做:
$redis.watch(:counter)
if $redis.exists(:counter)
$redis.multi do $redis.incrby(:counter, 1)
end else
$redis.unwatch
end
不过这个实现有个问题,为了达到EVAL命令同样的效果,客户端需要发送三个单独的命令到Redis。首先,如果存在并发操作,增量操作可能失败,因为计数器的键被修改而导致事务终止。还有就是三个命令相对而言会比较慢。
使用脚本会占用较多的带宽,这个根据脚本的长短不同而定。因为服务器端不会存储脚本,它们需要在每次调用的时候从客户端传输过来。
require 'digest/sha1'
def eval_script(body, keys, arguments)
$redis.evalsha(Digest::SHA1.hexdigest(body), keys, arguments)
rescue Redis::CommandError
$redis.eval(body, keys, arguments)
end
EVALSHA命令允许我们重用之前加载过的脚本,它只要求发送脚本的SHA1哈希值,而不是脚本本身。如果服务器端没加载过这个脚本,会返回一个错误,我们需要用EVAL命令对其加载一次。
Redis提供了一些命令用来管理Lua脚本。比如检查脚本是否存在,把它们从缓存里移除,中止它们的执行,或者只是加载它们。
你可能已经注意到,EVAL和EVALSHA命令的参数是按照keys跟arguments分别传入的,虽然这么做不是必须的。这样做的目的是允许Redis集群把命令转发给包含相应key的服务器。就目前来说,可以不遵循这个规则,可以直接在脚本里硬编码key,或者作为参数传入也行。不过要记住,这样的代码在Redis集群里是不兼容的。
操作压缩数据
问题
因为Redis的数据是存储在内存里的,所以你可能希望对数据进行压缩以减少内存使用,同时又希望保留对数据的操作能力。
解决方案
除了支持Lua脚本,Redis还捆绑了其它一些包,比如MessagePack。MessagePack是一种数据交换格式,它用一种紧凑的格式来表示数组或哈希表这样的数据结构。在Redis服务器端使用MessagePack来存储数据,既节省了内存使用,同时保留了对数据的操作能力。
讨论
跟其它事情一样,在Redis里使用MessagePack也是很简单的。假定你已经看过前面讲到的关于脚本的例子。lua-cmsgpack包的API包含两个函数:pack和unpack。
我们来看看如何基于lun-cmsgpack实现类似HINCRBY和HGET这样的功能:
module MsgPack
HSET_BODY = <<LUA
local dict = {}
local packed = redis.call("get", KEYS[1])
if packed then
dict = cmsgpack.unpack(packed)
end
dict[ARGV[1]] = ARGV[2]
redis.call("set", KEYS[1], cmsgpack.pack(dict))
LUA
HGET_BODY = <<LUA
local packed = redis.call("get", KEYS[1])
if packed then
return cmsgpack.unpack(packed)[ARGV[1]]
end
LUA
HINCRBY_BODY = <<LUA
local dict = {}
local packed = redis.call("get", KEYS[1])
if packed then
dict = cmsgpack.unpack(packed)
end
dict[ARGV[1]] = (dict[ARGV[1]] or 0) + ARGV[2]
redis.call("set", KEYS[1], cmsgpack.pack(dict))
return dict[ARGV[1]]
LUA
def self.hset(hash_name, key, value)
$r.eval(HSET_BODY, [hash_name], [key,value])
end
def self.hget(hash_name, key)
$r.eval(HGET_BODY, [hash_name], [key])
end
def self.hincrby(hash_name, key, increment=1)
$r.eval(HINCRBY_BODY, [hash_name], [key, increment])
end
end
很显然,这个实现清晰明了,虽然不如原生的哈希好,但它存储的数据是很紧凑的。Redis还有一个Lua的JSON支持包,可以把MessagePack转成JSON,如果应用程序使用的是JSON的话。