例如博客网站需要对文章的点赞数进行排名,从而找出网站中今天最有趣的50篇文章放入首页。我们的程序就需要对每篇文章产生一个能够根据时间流逝而不断增长的评分,程序需要根据文章的发布时间和当前时间来计算文章评分,具体的计算方法为:将文章的点赞数G * 常量E + 文章发布时间T
得到的就是文章的评分。
我们使用发布文章时Unix 时间的秒数作为文章发布时间T
,选取的常量E
为432(这一常量是通过假定热门文章一天的点赞数为200得到的,即一天的秒数86400/200),这样就得到一个可以根据时间和点赞数对文章打分的系统。
除了构建文章评分算法以外还需要使用Redis存储网站上的各种信息,一般使用散列结构。下面就是一个用散列存储文章信息的例子。
key | value |
---|---|
title | 《Redis使用场景大全》 |
link | https://blog.csdn.net/vic_torsun |
poster | 丧心病狂の程序员 |
timer | 1620895819 |
votes | 123 |
对于散列表的具体起名方式可以采用冒号作为分隔符的方式例如"article:94354"
,以此来构建命名空间。也可以选用其他分隔符,例如.
,|
,/
。无论采用哪种分隔符,都要注重保持分隔符的一致性。
对于评分系统我们可以使用zset
来存储得分集合。通过这个有序集合网站可以根据文章评分的高低来展示文章。
acticle | score |
---|---|
article:13512 | 1620895819 |
article:123423 | 1620895912 |
article:1242 | 1620896001 |
article:12312 | 1620896823 |
为了防止用户重复点赞,我们需要为每篇文章创建一个集合set
,并用这个集合存储所有为文章点赞的用户id。
voted |
---|
user:15315 |
user:12343 |
user:23432 |
当我们确定了实现这一功能的数据结构后,就可以进行具体的操作了。当用户对一篇文章进行投票时,程序需使用SADD
尝试将用户添加进记录文章已投票用户集合的set中,如果操作成功,那么说明用户没有重复投票,程序将使用ZINCRBY
为程序增加432分(见前文逻辑,单个点赞*E的得分值),ZINCRBY
命令用于对有序集合成员的分值执行自增操作,并使用HINCRBY
命令对散列记录的文章投票数进行更新。
下面简单演示了投票功能实现的代码:
public void articleVote(Jedis conn, String user, String article) {
long cutoff = (System.currentTimeMillis() / 1000) - ONE_WEEK_IN_SECONDS;
if (conn.zscore("time:", article) < cutoff){
return;
}
String articleId = article.substring(article.indexOf(':') + 1);
if (conn.sadd("voted:" + articleId, user) == 1) {
conn.zincrby("score:", VOTE_SCORE, article);
conn.hincrBy(article, "votes", 1);
}
}
接下来就要考虑如何取出评分最高的文章了,我们可以使用ZREVRANGE
命令取出高分的文章ID,然后在对每个文章ID执行HGETALL
来取出文章的详细信息,值得注意的一点是,由于有序集合的成员分值是由小到大排列的,所以要使用ZREVANGE
而不是ZRANGE
。
public List<Map<String,String>> getArticles(Jedis conn, int page, String order) {
int start = (page - 1) * ARTICLES_PER_PAGE;
int end = start + ARTICLES_PER_PAGE - 1;
Set<String> ids = conn.zrevrange(order, start, end);
List<Map<String,String>> articles = new ArrayList<Map<String,String>>();
for (String id : ids){
Map<String,String> articleData = conn.hgetAll(id);
articleData.put("id", id);
articles.add(articleData);
}
return articles;
}
记录用户浏览过的商品和用户最后一次访问页面的时间等信息,通常会导致大量的数据库写入,从运营角度来看这些数据可能非常有用,但是大多数关系型数据库在单机节点上每秒只能做到插入、更新或者删除200~2000个数据行。我们要做的就是使用Redis重新实现登录Cookie功能,取代由关系型数据库实现的登录Cookie功能。
首先,我们使用一个散列表来对用户cookie令牌与已登录用户之间的映射。要检查一个用户是否登录,只需要根据cookie中的令牌查找与之对应的用户,并返回该用户ID。
public String checkToken(Jedis conn, String token) {
return conn.hget("login:", token);
}
用户每次浏览页面的时候,程序都会对用户存储在登录散列里面的信息进行更新,并将用户的令牌和当前时间戳添加到记录最近登录用户的有序集合里面;如果用户正在浏览的是一个商品页面,那么程序还会将这个商品添加到记录这个用户最近浏览过的商品的有序集合里面,并在被记录商品的数量超过25个时,对这个有序集合进行修剪。
public void updateToken(Jedis conn, String token, String user, String item) {
long timestamp = System.currentTimeMillis() / 1000;
conn.hset("login:", token, user);
conn.zadd("recent:", timestamp, token);
if (item != null) {
conn.zadd("viewed:" + token, timestamp, item);
conn.zremrangeByRank("viewed:" + token, 0, -26); // 移除旧的记录 只保留最近浏览的25个商品
conn.zincrby("viewed:", -1, item);
}
}
因为存储会话数据所需的内存会随着时间的推移而不断增加,所以我们需要定期清理旧的会话数据。为了限制会话数据的数量,我们决定只保存最新的1000万个会话。清理旧会话的程序由一个循环构成,这个循环每次执行的时候,都会检查存储最近登录令牌的有序集合的大小,如果有序集合的大小超过了限制,那么程序就会从有序集合里面移除最多100个最旧的令牌,并从记录用户登录信息的散列里面,移除被删除令牌对应的用户的信息,并对存储了这些用户最近浏览商品记录的有序集合进行清理。与此相反,如果令牌的数量未超过限制,那么程序会先休眠1秒,之后再重新进行检查。
下面这段代码举了一个清理线程的run函数示例:
public void run() {
while (!quit) {
long size = conn.zcard("recent:");
if (size <= limit){
try {
sleep(1000);
}catch(InterruptedException ie){
Thread.currentThread().interrupt();
}
continue;
}
long endIndex = Math.min(size - limit, 100);
Set<String> tokenSet = conn.zrange("recent:", 0, endIndex - 1);
String[] tokens = tokenSet.toArray(new String[tokenSet.size()]);
ArrayList<String> sessionKeys = new ArrayList<String>();
for (String token : tokens) {
sessionKeys.add("viewed:" + token);
}
conn.del(sessionKeys.toArray(new String[sessionKeys.size()]));
conn.hdel("login:", tokens);
conn.zrem("recent:", tokens);
}
}
Redis 的过期数据处理 对于这个登录cookie例子来说,我们可以直接将登录用户和令牌的信息存储到字符串键值对里面,然后使用Redis的EXPIRE命令,为这个字符串和记录用户商品浏览记录的有序集合设置过期时间,让Redis在一段时间之后自动删除它们,这样就不需要再使用有序集合来记录最近出现的令牌了。但是这样一来,我们就没有办法将会话的数量限制在1000万之内了,并且在将来有需要的时候,我们也没办法在会话过期之后对被废弃的购物车进行分析了。
由于动态生成网页技术会在渲染网页是消耗数据库资源,然而95%的Web页面每天最多只会改变一次,这些页面的内容实际上并不需要动态地生成。
对于像Spring这种Web框架我们可以创建这样一个请求拦截器:对于一个不能被缓存的请求,函数将直接生成并返回页面;而对于可以被缓存的请求,函数首先会尝试从缓存里面取出并返回被缓存的页面,如果缓存页面不存在,那么函数会生成页面并将其缓存在Redis里面5分钟,最后再将页面返回给函数调用者。
public String cacheRequest(Jedis conn, String request, Callback callback) {
if (!canCache(conn, request)){
return callback != null ? callback.call(request) : null;
}
String pageKey = "cache:" + hashRequest(request);
String content = conn.get(pageKey);
if (content == null && callback != null){
content = callback.call(request);
conn.setex(pageKey, 300, content);
}
return content;
}
其中hashRequest
函数是一个计算请求hash值的函数:
public String hashRequest(String request) {
return String.valueOf(request.hashCode());
}
缓存函数可以让网站在5分钟之内无需再为它们动态地生成视图页面。这一改动可以将包含大量数据的页面的延迟值从20~50毫秒降低至查询一次Redis所需的时间:查询本地Redis的延迟值通常低于1毫秒,而查询位于同一个数据中心的Redis的延迟值通常低于5毫秒。对于那些需要访问数据库的页面来说,这个缓存函数对于减少页面载入时间和降低数据库负载的作用会更加显著。
在电商网站上每天都会推出一些特价商品供用户抢购,所有特价商品的数量都是限定的,卖完即止。在这种情况下,网站是不能对整个促销页面进行缓存的,因为这可能会导致用户看到错误的特价商品剩余数量,但是每次载入页面都从数据库里面取出特价商品的剩余数量的话,又会给数据库带来巨大的压力,并导致我们需要花费额外的成本来扩展数据库。这里实际上是一个最终一致性的问题。
为了应对促销活动带来的大量负载,我们需要对数据行进行缓存,具体的做法是:编写一个持续运行的守护进程函数,让这个函数将指定的数据行缓存到Redis里面,并不定期地对这些缓存进行更新。缓存函数会将数据行编码(encode)为JSON字典并存储在Redis的字符串里面,其中,数据列(column)的名字会被映射为JSON字典的键,而数据行的值则会被映射为JSON字典的值。一个json字段示例如下:
{"name":"大白菜","qty":34,"descr":"..."}
程序使用了两个有序集合来记录应该在何时对缓存进行更新:第一个有序集合为调度(schedule)有序集合,它的成员为数据行的行ID,而分值则是一个时间戳,这个时间戳记录了应该在何时将指定的数据行缓存到Redis里面;第二个有序集合为延时(delay)有序集合,它的成员也是数据行的行ID,而分值则记录了指定数据行的缓存需要每隔多少秒更新一次。
为了让缓存函数定期地缓存数据行,程序首先需要将行ID和给定的延迟值添加到延迟有序集合里面,然后再将行ID和当前时间的时间戳添加到调度有序集合里面。实际执行缓存操作的函数需要用到数据行的延迟值,如果某个数据行的延迟值不存在,那么程序将取消对这个数据行的调度。如果我们想要移除某个数据行已有的缓存,并且让缓存函数不再缓存那个数据行,那么只需要把那个数据行的延迟值设置为小于或等于0就可以了。
public void scheduleRowCache(Jedis conn, String rowId, int delay) {
conn.zadd("delay:", delay, rowId);
conn.zadd("schedule:", System.currentTimeMillis() / 1000, rowId);
}
现在我们已经完成了调度部分,那么接下来该如何对数据行进行缓存呢?负责缓存数据行的函数会尝试读取调度有序集合的第一个元素以及该元素的分值,如果调度有序集合没有包含任何元素,或者分值存储的时间戳所指定的时间尚未来临,那么函数会先休眠50毫秒,然后再重新进行检查。当缓存函数发现一个需要立即进行更新的数据行时,缓存函数会检查这个数据行的延迟值:如果数据行的延迟值小于或者等于0,那么缓存函数会从延迟有序集合和调度有序集合里面移除这个数据行的ID,并从缓存里面删除这个数据行已有的缓存,然后再重新进行检查;对于延迟值大于0的数据行来说,缓存函数会从数据库里面取出这些行,将它们编码为JSON格式并存储到Redis里面,然后更新这些行的调度时间。
下面是一段守护线程实现缓存功能的代码:
public void run() {
Gson gson = new Gson();
while (!quit){
Set<Tuple> range = conn.zrangeWithScores("schedule:", 0, 0); // 尝试获取下一个被缓存的数据行即调度时间戳
Tuple next = range.size() > 0 ? range.iterator().next() : null;
long now = System.currentTimeMillis() / 1000;
if (next == null || next.getScore() > now){ // 暂时没有行需要被重新缓存 休眠50ms
try {
sleep(50);
}catch(InterruptedException ie){
Thread.currentThread().interrupt();
}
continue;
}
String rowId = next.getElement();
double delay = conn.zscore("delay:", rowId); // 获取缓存的延迟时间
if (delay <= 0) { // 不必缓存(失效)
conn.zrem("delay:", rowId);
conn.zrem("schedule:", rowId);
conn.del("inv:" + rowId);
continue;
}
Inventory row = Inventory.get(rowId); // 读取数据行
conn.zadd("schedule:", now + delay, rowId); // 更新调度时间 并设置缓存
conn.set("inv:" + rowId, gson.toJson(row));
}
}
通过组合使用调度函数和持续运行缓存函数,我们实现了一种重复进行调度的自动缓存机制,并且可以随心所欲地控制数据行缓存的更新频率:如果数据行记录的是特价促销商品的剩余数量,并且参与促销活动的用户非常多的话,那么我们最好每隔几秒更新一次数据行缓存;另一方面,如果数据并不经常改变,或者商品缺货是可以接受的,那么我们可以每分钟更新一次缓存。
商品买卖市场的需求非常简单:一个用户(卖家)可以将自己的商品按照给定的价格放到市场上进行销售,当另一个用户(买家)购买这个商品时,卖家就会收到钱。
为了将被销售商品的全部信息都存储到市场里面,我们会将商品的ID和卖家的ID拼接起来,并将拼接的结果用作成员存储到市场有序集合(market ZSET)里面,而商品的售价则用作成员的分值。通过将所有数据都包含在一起,我们极大地简化了实现商品买卖市场所需的数据结构,并且因为市场里面的所有商品都按照价格排序,所以针对商品的分页功能和查找功能都可以很容易地实现。
market | zset |
---|---|
桃子.id001 | 14 |
吸管.id234 | 23 |
猕猴桃.id034 | 32 |
为了将商品放到市场上进行销售,程序除了要使用MULTI
命令和EXEC
命令之外,还需要配合使用WATCH
命令,有时候甚至还会用到UNWATCH
或DISCARD
命令。在用户使用WATCH命令对键进行监视之后,直到用户执行EXEC命令的这段时间里面,如果有其他客户端抢先对任何被监视的键进行了替换、更新或删除等操作,那么当用户尝试执行EXEC命令的时候,事务将失败并返回一个错误(之后用户可以选择重试事务或者放弃事务)。通过使用WATCH、MULTI/EXEC、 UNWATCH/DISCARD等命令,程序可以在执行某些重要操作的时候,通过确保自己正在使用的数据没有发生变化来避免数据出错。
Redis的事务以特殊命令MULTI为开始,之后跟着用户传入的多个命令,最后以EXEC为结束。但是由于这种简单的事务在EXEC命令被调用之前不会执行任何实际操作,所以用户将没办法根据读取到的数据来做决定。
在将一件商品放到市场上进行销售的时候,程序需要将被销售的商品添加到记录市场正在销售商品的有序集合里面,并且在添加操作执行的过程中,监视卖家的包裹以确保被销售的商品的确存在于卖家的包裹当中。下面这段代码展示了该操作:
public boolean listItem(
Jedis conn, String itemId, String sellerId, double price) {
String inventory = "inventory:" + sellerId;
String item = itemId + '.' + sellerId;
long end = System.currentTimeMillis() + 5000;
while (System.currentTimeMillis() < end) {
conn.watch(inventory);
if (!conn.sismember(inventory, itemId)){
conn.unwatch();
return false;
}
Transaction trans = conn.multi();
trans.zadd("market:", price, item);
trans.srem(inventory, itemId);
List<Object> results = trans.exec();
// null response indicates that the transaction was aborted due to
// the watched key changing.
if (results == null){
continue;
}
return true;
}
return false;
}
它首先执行一些初始化步骤,然后对卖家的包裹进行监视,验证卖家想要销售的商品是否仍然存在于卖家的包裹当中,如果是的话,函数就会将被销售的商品添加到买卖市场里面,并从卖家的包裹中移除该商品。正如函数中的while循环所示,在使用WATCH命令对包裹进行监视的过程中,如果包裹被更新或者修改,那么程序将接收到错误并进行重试。
购买商品时,首先使用WATCH对市场以及买家的个人信息进行监视,然后获取买家拥有的钱数以及商品的售价,并检查买家是否有足够的钱来购买该商品。如果买家没有足够的钱,那么程序会取消事务;相反地,如果买家的钱足够,那么程序首先会将买家支付的钱转移给卖家,然后将售出的商品移动至买家的包裹,并将该商品从市场中移除。当买家的个人信息或者商品买卖市场出现变化而导致WatchError异常出现时,程序将进行重试,其中最大重试时间为10秒。
public boolean purchaseItem(
Jedis conn, String buyerId, String itemId, String sellerId, double lprice) {
String buyer = "users:" + buyerId;
String seller = "users:" + sellerId;
String item = itemId + '.' + sellerId;
String inventory = "inventory:" + buyerId;
long end = System.currentTimeMillis() + 10000;
while (System.currentTimeMillis() < end){
conn.watch("market:", buyer);
double price = conn.zscore("market:", item);
double funds = Double.parseDouble(conn.hget(buyer, "funds"));
if (price != lprice || price > funds){
conn.unwatch();
return false;
}
Transaction trans = conn.multi();
trans.hincrBy(seller, "funds", (int)price);
trans.hincrBy(buyer, "funds", (int)-price);
trans.sadd(inventory, itemId);
trans.zrem("market:", item);
List<Object> results = trans.exec();
// null response indicates that the transaction was aborted due to
// the watched key changing.
if (results == null){
continue;
}
return true;
}
return false;
}
在执行商品购买操作的时候,程序除了需要花费大量时间来准备相关数据之外,还需要对商品买卖市场以及买家的个人信息进行监视:监视商品买卖市场是为了确保买家想要购买的商品仍然有售(或者在商品已经被其他人买走时进行提示),而监视买家的个人信息则是为了验证买家是否有足够的钱来购买自己想要的商品。
对网站的各项指标进行监控是很重要的,为了收集指标数据并进行监视和分析,我们将构建一个能够持续创建并维护计数器的工具,这个工具创建的每个计数器都有自己的名字(名字里带有网站点击量、销量或者数据库查询字样的计数器都是比较重要的计数器)。这些计数器会以不同的时间精度(如1秒、5秒、1分钟等)存储最新的120个数据样本,用户也可以根据自己的需要,对取样的数量和精度进行修改。
为了对计数器进行更新,我们需要存储实际的计数器信息。对于每个计数器以及每种精度,如网站点击量计数器和5秒,我们将使用一个散列来存储网站在每个5秒时间片(time slice)之内获得的点击量,其中,散列的每个键都是某个时间片的开始时间,而键对应的值则存储了网站在该时间片之内获得的点击量。
为了能够清理计数器包含的旧数据,我们需要在使用计数器的同时,对被使用的计数器进行记录。为了做到这一点,我们需要一个有序序列(ordered sequence),这个序列不能包含任何重复元素,并且能够让我们一个接一个地遍历序列中包含的所有元素。有序集合的各个成员分别由计数器的精度以及计数器的名字组成,而所有成员的分值都为0。因为所有成员的分值都被设置成了0,所以Redis在尝试按分值对有序集合进行排序的时候,就会发现这一点,并改为使用成员名进行排序,这使得一组给定的成员总是具有固定的排列顺序,从而可以方便地对这些成员进行顺序性的扫描。
下面展示了程序更新计数器的方法:对于每种时间片精度,程序都会将计数器的精度和名字作为引用信息添加到记录已有计数器的有序集合里面,并增加散列计数器在指定时间片内的计数值。
public static final int[] PRECISION = new int[]{1, 5, 60, 300, 3600, 18000, 86400};
public void updateCounter(Jedis conn, String name, int count, long now){
Transaction trans = conn.multi();
for (int prec : PRECISION) {
long pnow = (now / prec) * prec;
String hash = String.valueOf(prec) + ':' + name;
trans.zadd("known:", 0, hash);
trans.hincrBy("count:" + hash, String.valueOf(pnow), count);
}
trans.exec();
}
从指定精度和名字的计数器里面获取技术数据也是一件非常容易的事情,下面的代码展示了用于执行这一操作的代码:程序首先使用HGETALL命令来获取整个散列,接着将命令返回的时间片和计数器的值从原来的字符串格式转换成数字格式,根据时间对数据进行排序,最后返回排序后的数据。
public List<Pair<Integer,Integer>> getCounter(
Jedis conn, String name, int precision)
{
String hash = String.valueOf(precision) + ':' + name;
Map<String,String> data = conn.hgetAll("count:" + hash);
ArrayList<Pair<Integer,Integer>> results =
new ArrayList<Pair<Integer,Integer>>();
for (Map.Entry<String,String> entry : data.entrySet()) {
results.add(new Pair<Integer,Integer>(
Integer.parseInt(entry.getKey()),
Integer.parseInt(entry.getValue())));
}
Collections.sort(results);
return results;
}
如果我们只是一味地对计数器进行更新而不执行任何清理操作的话,那么程序最终将会因为存储了过多的数据而导致内存不足。
清理程序通过对记录已知计数器的有序集合执行ZRANGE命令来一个接一个的遍历所有已知的计数器。在对计数器执行清理操作的时候,程序会取出计数器记录的所有计数样本的开始时间,并移除那些开始时间位于指定截止时间之前的样本,清理之后的计数器最多只会保留最新的120个样本。如果一个计数器在执行清理操作之后不再包含任何样本,那么程序将从记录已知计数器的有序集合里面移除这个计数器的引用信息。以上给出的描述大致地说明了计数器清理函数的运作原理,至于程序的一些边界情况最好还是通过代码来说明
public class CleanCountersThread
extends Thread
{
private Jedis conn;
private int sampleCount = 100;
private boolean quit;
private long timeOffset; // used to mimic a time in the future.
public CleanCountersThread(int sampleCount, long timeOffset){
this.conn = new Jedis("localhost");
this.conn.select(15);
this.sampleCount = sampleCount;
this.timeOffset = timeOffset;
}
public void quit(){
quit = true;
}
public void run(){
int passes = 0;
while (!quit){
long start = System.currentTimeMillis() + timeOffset;
int index = 0;
while (index < conn.zcard("known:")){
Set<String> hashSet = conn.zrange("known:", index, index);
index++;
if (hashSet.size() == 0) {
break;
}
String hash = hashSet.iterator().next();
int prec = Integer.parseInt(hash.substring(0, hash.indexOf(':')));
int bprec = (int)Math.floor(prec / 60);
if (bprec == 0){
bprec = 1;
}
if ((passes % bprec) != 0){
continue;
}
String hkey = "count:" + hash;
String cutoff = String.valueOf(
((System.currentTimeMillis() + timeOffset) / 1000) - sampleCount * prec);
ArrayList<String> samples = new ArrayList<String>(conn.hkeys(hkey));
Collections.sort(samples);
int remove = bisectRight(samples, cutoff);
if (remove != 0){
conn.hdel(hkey, samples.subList(0, remove).toArray(new String[0]));
if (remove == samples.size()){
conn.watch(hkey);
if (conn.hlen(hkey) == 0) {
Transaction trans = conn.multi();
trans.zrem("known:", hash);
trans.exec();
index--;
}else{
conn.unwatch();
}
}
}
}
passes++;
long duration = Math.min(
(System.currentTimeMillis() + timeOffset) - start + 1000, 60000);
try {
sleep(Math.max(60000 - duration, 1000));
}catch(InterruptedException ie){
Thread.currentThread().interrupt();
}
}
}
// mimic python's bisect.bisect_right
public int bisectRight(List<String> values, String key) {
int index = Collections.binarySearch(values, key);
return index < 0 ? Math.abs(index) - 1 : index + 1;
}
}