一、需求

移动端系统里有用户和文章,文章可设置权限对部分用户开放。现要实现的功能是,用户浏览自己能看的最新文章,并可以上滑分页查看。

二、数据库表设计

涉及到的数据库表有:用户表TbUser、文章表TbArticle、用户可见文章表TbUserArticle。其中,TbUserArticle的结构和数据如下图,字段有:自增长主键id、用户编号uid、文章编号aid。

自增长主键和分布式增长主键如何选:

TbUserArticle的主键是自增id,它有个缺陷是,当你的数据库有主从复制时,主从库的自增可能因死锁等原因导致不同步。不过,我们可以知道,这里的TbUserArticle的主键id不会做其它表的外键,所以可以是自增id。不像用户表的主键,它就不能用自增id,因为用户表主键(uid)会经常出现在其它表中,当主从库自增不一致时,很多有uid字段的表数据在从库中就不正确了。用户表主键最好是用分布式增长主键算法生成的id(比如Snowflake雪花算法)。

那么你可能就要说了,TbUserArticle的主键为什么不直接用雪花算法产生,不管有没有用,先让主从库主键值一致总是有恃无恐。要知道,雪花算法产生的id一般是18位,而redis的zset的score是double类型,只能表达到16位"整数"部分(精确的说是9007199254740992=2的53次方)。因此,TbUserArticle的主键选择自增id。

主键一般都要选自增id或分布式增长id,这种主键好处多多,它符合自增长(物理存储时都是在末尾追加数据,减少数据移动)、唯一性、长度小、查询快的特性,是聚集索引的很好选择。

三、redis缓存设计-zset

zset的作法及其优点说明:

1.zset的score倒序取数可以很好的满足取最新数据的需求。

2.用TbUserArticle的文章编号当value,用自增长id当score。自增id的唯一性可很方便的取下一页数据,直接取小于上次最后一笔的score即可(用lastScore表示)。而如果用文章的时间做score,则要考虑两笔文章的时间是同分同秒问题,当lastScore落在同分同秒的两篇文章之间时,就尴尬了,虽然有解,但麻烦了一点。有时的场景你用不了自增id当score,只能用文章时间,那怎么解决呢,方案就是当是同分同秒时,再根据文章编号做比较就好了,zset的score相同时,也是再根据value排序的。

3.当新增或重新添加一项时,zset也会保持score排序。而如果用的是redis的list,一般就得从db重载缓存,新增进来的数据项就算是最新的,也不敢直接添加到list第一笔,因为并发情况下,保证不了最新就是在第一笔;至于重新添加进非最新项,那更是要从db取数重新装载缓存(一般是直接删除缓存,要用的时候才装载)。

4.第一次从db加载数据到zset时,可只取前N笔到zset。因为我们移动端的数据浏览,一般是只看最新N笔,当看到昨天浏览过的数据一般就不会再往下浏览。

5.控制zset为固定长度,防止一直增长,一是减少缓存开销,二是队列长度越短操作性能越高。而且redis服务端有两个参数:zset-max-ziplist-entries(zset队列长度,默认值128)和 zset-max-ziplist-value(zset每项大小,默认值64字节),它们的作用是,当zset长度小于128,且每个元素的大小小于64字节时,会启用ziplist(压缩双向链表),它的内存空间可以减少8倍左右,而且操作性能也更快。如果不满足这两个条件则是普通的skiplist(跳跃表)。另,数据结构hash和list默认长度是512。如果系统有100万个用户,每个用户都有自己的队列缓存,那么使用ziplist将节省非常大的内存空间,并提升很大的性能。

注意,当从zset移除一项数据,则看场景是否需要清空队列。否则有可能添加进来了一项很旧的数据,它会跑到缓存队列最底部,如果此旧数据比db中未进队列的数据还旧,那么队列中的数据就不正确了。此时,用户滑到缓存最后一页时,就有可能浏览到这项不正确的数据,为什么是“有可能”,因为当取到zset最后一笔,很可能不够一页,而不够一页就会从db直接取一页;而当又添加进一项新数据,这项旧数据就会被T出队列(因为队列保持固定长度)。最佳方案是搞个临界值处理此问题。 而如果添加到zset的数据都是最新数据,则不会有此问题。一般是这种情况,才可以用自增id当score。

当用唯一主键id做score时,这可是非常有用,你可以直接根据id定位到项了,至于如何大用它,我会再出篇博客。

四、代码实现

从redis缓存按页取数一般要考虑的点:

1.当根据cacheKey未取到数据时(可能是缓存过期了导致redis无此cacheKey数据),则触发重载数据(reload):从db取limit N笔数据,装载到redis zset队列中,并直接取N笔的第一页数据返回;

2.如果db本身也无对应数据,则添加"no_db_mark"标识到cacheKey队列中,下次请求则不会再触发db重载数据;

3.当取到缓存末尾时,从db取一页数据直接返回。这种情况是很少的,要根据业务场景合理规划缓存长度。

上代码:

代码注释比较详细和有用,请直接看代码。其中,批量添加数据到zset的函数AddItemsToZset很有用,它使用lua一次性添加数据到zset(注意,使用lua时,要保证lua执行快,否则它会阻塞其它命令的执行),经测试:AddItemsToZset添加1w笔数据,只需要39ms;10w笔需要448ms。因为我们只取前N笔数据到缓存,因此一般不会添加超过1w笔。

1 ///

2 ///分页取数帮助类3 ///

4 public classPageDataHelper5 {6 public readonly static string NoDbDataMark = "no_db_data";//在zset中标识db也无数据

7 public static RedisHandle RedisClient = new RedisHandle();//redis操作对象示例

8 public static DbHandleBase DbHandle = new SqlServerHandle("Data Source=.;Initial Catalog=Test;User Id=sa;Password=123ewq;");//db操作对象示例

9 ///

10 ///按页取数。11 ///

12 /// 上一页最后一笔的score,如果为空,则说明是取第一页。

13 /// true,用户上滑浏览下一页数据;false,用户上滑浏览最新一页数据

14 ///

15 public static IDictionary GetUserPageData(string uid, int pageSize, string lastInfo, boolgetPast)16 {17 long lastScore = 0;18 //1.解析lastInfo信息。->getPast为false,则固定取最新第一页数据,不用解析。lastInfo为空,则也不用解析,默认第一页

19 if (getPast && !string.IsNullOrWhiteSpace(lastInfo))20 {21 lastScore = long.Parse(lastInfo);//外层有try..catch..

22 }23 string cacheKey = $"usr:art:{uid}";24 bool isFirstPage = lastScore <= 0;25 using (IRedisClient redis =RedisClient.GetRedisClient())26 {27 if(isFirstPage)28 {29 //2.第一页取数

30 var items = redis.GetRangeWithScoresFromSortedSetDesc(cacheKey, 0, pageSize - 1);31 if (items.Count == 0)32 {33 //2.1 无数据时,则从db reload数据

34 items =ReloadDataToRedis(redis, cacheKey, uid, pageSize);35 if (items.Count == 0 && pageSize > 0)36 {37 //如果db中也无数据,则向zset中添加一笔NoDbDataMark标识

38 redis.AddItemToSortedSet(cacheKey, NoDbDataMark, double.MaxValue);39 }40 }41 else if (items.Count == 1 &&items.ContainsKey(NoDbDataMark))42 {43 //2.2如果取到的是NoDbDataMark标识,则说明是空数据,则要Clear,返回空列表

44 items.Clear();45 }46 //设置缓存有效期,要根据业务场景合理设置缓存有效期,这边以7天为例。

47 redis.ExpireEntryIn(cacheKey, new TimeSpan(7, 0, 0, 0));48 //2.3 第一页,有多少就返回多少数据。数据如果不够一页,说明本身数据不够。

49 returnitems;50 }51 else

52 {53 //3.第二页(及之后)取数

54 var items =GetPageDataByLastScoreFromRedis(redis, cacheKey, pageSize, lastScore);55 if (items.Count

58 returnGetPageDataByLastScoreFromDb(uid, pageSize, lastScore);59 }60 //3.2 如果缓存数据足够,则返回缓存的数据。

61 returnitems;62 }63 }64 }65 public static Dictionary ReloadDataToRedis(IRedisClient redis, string cacheKey, string uid, int pageSize, string bizId = "")66 {67 //1.db取数 取top 1000笔数据。不需要全取到缓存。

68 IEnumerablemodels;69 using (var conn =DbHandle.CreateConnectionAndOpen())70 {71 var sql = $"select top 1000 id,aid from TbUserArticle where uid=@uid order by id desc;";//limit 1000;";

72 models = conn.Query(sql, new { uid =uid });73 }74 if (models.Count() <= 0) return new Dictionary();75 //2.数据加载到redis缓存。

76 var itemsParam = new Dictionary();77 foreach (dynamic model inmodels)78 {79 itemsParam.Add((string)model.aid, (double)model.id);80 }81 //使用lua一次性添加数据到缓存。lua语句要执行快,经测试添加1w笔数据,只需要39ms;10w笔需要448ms。因为sql中有limit,因此一般不会添加超过1w笔。82 //因为是原子性操作、并且是zset结构,这边不需要加锁。db取到数据应第一时间加载到redis。

83 AddItemsToZset(redis, cacheKey, itemsParam, true, true);84 if (pageSize <= 0) return null;85 //3.直接由models返回第一页数据。

86 return models.Take(pageSize).ToDictionary(x => (string)x.aid, y => (double)y.id);87 }88

89 public static Dictionary GetPageDataByLastScoreFromDb(string uid, int pageSize, doublelastScore)90 {91 //db取一页数据。

92 var sql = $"select top {pageSize} id,aid from TbUserArticle where uid=@uid and id

93 using (var conn =DbHandle.CreateConnectionAndOpen())94 {95 return conn.Query(sql, new { uid = uid }).ToDictionary(x => (string)x.aid, y => (double)y.id);96 }97 }98 #region 通用函数

99 ///

100 ///ZSet第一页之后的取数,从lastScore开始取pageSize笔数据(第一页之后才有lastScore)。101 ///使用lua,保证原子性操作。102 ///

103 public static Dictionary GetPageDataByLastScoreFromRedis(IRedisClient redis, string zsetKey, int pageSize, doublelastScore)104 {105 //ZREVRANGEBYSCORE: from lastScore to '-inf'.

106 var luaBody = @"local sets = redis.call('ZREVRANGEBYSCORE', KEYS[1], ARGV[1], '-inf', 'WITHSCORES');107 local result = {};108 local index=0;109 local pageSize=ARGV[2]*1;110 local lastScore=ARGV[1]*1;111 for i = 1, #sets, 2 do112 if index>=pageSize then113 break;114 end115 if (lastScore>sets[i+1]*1) then116 table.insert(result, sets[i]);117 table.insert(result, sets[i+1]);118 index=index+1;119 end120 end121 return result";122 //ARGV[1]:lastScore ARGV[2]:pageSize

123 var list = redis.ExecLuaAsList(luaBody, new string[] { zsetKey }, new string[] { lastScore.ToString(), pageSize.ToString() });124 var result = new Dictionary();125 for (var i = 0; i < list.Count; i += 2)126 {127 result.Add(list[i], Convert.ToDouble(list[i + 1]));128 }129 returnresult;130 }131 ///

132 ///添加一项到zset缓存中。133 ///

134 /// 要添加到zset的数据项

135 /// 控制zset最大长度,如果为0,则不控制。

136 ///

137 public static string AddItemToZset(IRedisClient redis, string zsetKey, KeyValuePair item, int maxCount = 0)138 {139 var items = new Dictionary() { { item.Key, item.Value } };140 returnAddItemsToZset(redis, zsetKey, items);141 }142 ///

143 ///添加多项到zset缓存中。144 ///

145 /// 要添加到zset的数据列表

146 /// 缓存zsetKey是否有设置缓存有效期。如果有设置缓存有效期,则当缓存中无数据时,可能是缓存过期;而如果缓存无有效期,缓存中无数据,就是db和缓存都无数据

147 /// 是否是reload情况,true重载情况;false追加

148 /// 控制zset最大长度,如果为0,则不控制。149 ///一般不用控制,只在db取数reload时增加limit,以避免全量进缓存;150 ///如果当前zsetKey是常用缓存,会一直暴涨,则才要控制zset长度。

151 ///

152 public static string AddItemsToZset(IRedisClient redis, string zsetKey, Dictionary items, bool hasCacheExpire = true

153 , bool isReload = false, int maxCount = 0)154 {155 //!isReload,是因为如果isReload=true情况无数据,则也要进来重载队列为无数据(即,如果之前有数据要重载为无数据)

156 if (!isReload && items.Count <= 0) return null;157 var argArr = new List(items.Count * 2 + 2);//lua参数数组158 //var hasCacheExpire = cacheValidTime != null;159 //第一个lua参数是hasCacheExpire

160 argArr.Add(hasCacheExpire ? "1" : "0");161 //第二个lua参数是maxCount

162 argArr.Add(maxCount.ToString());163 //组合lua其它参数列表:ZADD的参数

164 foreach (var item initems)165 {166 //Add score。//ZADD KEY_NAME SCORE1 VALUE1

167 argArr.Add(item.Value.ToString());168 argArr.Add(item.Key);169 }170 #region lua

171 /*

172 * 以下lua命令说明。173 * 1.ZREVRANGE从大到小取第一笔数据firstMark;174 * 2.缓存有设置有效期时(hasCacheExpire=1),如果第一笔数据firstMark为nil,则说明列表是空(失效key、未生成key),则不做任何处理,直接返回字符串not_exist_key。因为可能是用户失效数据,用户长期未访问,则不添加,后继来访问时重载数据。175 * 3.如果firstMark标识为no_db_data,则是被api标识为db没数据,而此时因要ZADD数据进来,因此要把此标识删除。其中,ZREMRANGEBYRANK从小到大删除,-1是倒数第一笔。176 * 4.ZADD数据进来177 * 5.KeepLength保持队列长度操作。如果队列长度(由ZCARD获取)超过指定的maxCount,则从队列第一笔开始删除多余元素,即score最小开始删除。178 * 6.maxCount为>0才KeepLength。返回数值:curCount - maxCount。(可以用返回值简单算出队列当前长度curCount)。如果返回值小于等于0则说明没有触发删除操作。179 * 7.maxCount为<=0时,直接返回'no_remove'。180 */

181 //清空原来,重新加载数据的情况

182 const string reloadLua = "redis.call('DEL', KEYS[1])";183 //追加数据到zset的情况

184 const string addToLua =

185 @"local firstMark = redis.call('ZREVRANGE',KEYS[1],0,0);186 local hasCacheExpire=ARGV[1]*1;187 if hasCacheExpire==1 and firstMark and firstMark[1]==nil then188 return 'not_exist_key';189 end190 if firstMark and firstMark[1]=='{0}' then191 redis.call('ZREMRANGEBYRANK', KEYS[1], -1,-1);192 end";193 const string constAllLua =

194 @"{0}195 for i=3, #ARGV, 2196 do redis.call('ZADD', KEYS[1], ARGV[i], ARGV[i+1]);197 end198 local maxCount=ARGV[2]*1;199 if maxCount>0 then200 local curCount= redis.call('ZCARD', KEYS[1]);201 local removeCount=curCount - maxCount;202 if removeCount>0 then203 redis.call('ZREMRANGEBYRANK', KEYS[1], 0,removeCount-1);204 end205 return removeCount;206 end207 return 'no_remove';";208 #endregion

209 var luaBody = string.Format(constAllLua, isReload ? reloadLua : string.Format(addToLua, NoDbDataMark));210 var luaResult = redis.ExecLuaAsString(luaBody, new string[] { zsetKey }, argArr.ToArray());211 returnluaResult;212 }213 #endregion

214 }

zset 怎么get_使用redis的zset实现高效分页查询(附完整代码)相关推荐

  1. 【Redis之ZSet类型的详解ZSet类型中常用命令的实践】

    Redis之ZSet类型的详解&ZSet类型中常用命令的实践 知识回顾: 通过对Redis中的String的命令做了充分的讲解以及实践学习 通过对Redis中String类型之Bit命令的详解 ...

  2. mysql分页 redis_分页查询和redis

    问题 我在做论坛的是时候遇到了如下的问题.论坛里可以有很多的主题topic,每个topic对应到很多回复reply.现在要查询某个topic下按照replyTime升序排列的第pageNo页的repl ...

  3. zset 怎么get_如何使用RedisTemplate访问Redis数据结构之Zset

    Redis的ZSet数据结构 Redis 有序集合和无序集合一样也是string类型元素的集合,且不允许重复的成员. 不同的是每个元素都会关联一个double类型的分数.redis正是通过分数来为集合 ...

  4. Redis的zset有多牛?请把耳朵递过来

    来自公众号:小姐姐味道 作者简介:一个不允许程序员走弯路的公众号.聚焦基础架构和Linux.十年架构,日百亿流量,与你探讨高并发世界,给你不一样的味道. 本篇文章很短,但信息量很大,是关于redis的 ...

  5. 使用redis的zset实现排行榜

    1.使用场景 现在公司有个项目,类似于今日头条,需要实现对应分类阅读排行榜的功能. 每一篇文章所属于一个分类,当用户阅读该文章时,阅读次数+1,排行榜实时变化. 2.redis的ZSet数据结构 zs ...

  6. java和redis统计在线,在SpringBoot中使用Redis的zset统计在线用户信息

    统计在线用户的数量,是应用很常见的需求了.如果需要精准的统计到用户是在线,离线状态,我想只有客户端和服务器通过保持一个TCP长连接来实现.如果应用本身并非一个IM应用的话,这种方式成本极高. 现在的应 ...

  7. redis的zset使用(java)——存取List< Object>

    1 需求 要往redis存取List< Object>. 2 条件 1)Object:是一个UserEvent对象,对应3个字段: Integer productId; String ev ...

  8. redis中zset底层实现原理

    https://www.cnblogs.com/yuanfang0903/p/12165394.html 阅读目录 一.Zset编码的选择 二.ziplist 三.skiplist 四.skiplis ...

  9. redis的zset的底层实现_redis zset底层实现原理

    一.Zset编码的选择 1.有序集合对象的编码可以是ziplist或者skiplist.同时满足以下条件时使用ziplist编码: 元素数量小于128个 所有member的长度都小于64字节 其他: ...

最新文章

  1. 送餐机器人被解雇,人工智能“人性”待进化
  2. asp-Webshell免杀
  3. tensorflow中的log中数字的含义
  4. php 日期加减处理函数,php日期加减处理函数示例
  5. 高级php面试题(转)
  6. 运筹优化(十六)--排队论基础及其最优化求解
  7. Linux 文件系统启动记录
  8. 互联网协议第四版ipv4
  9. Word 技术篇-文档中不同级别标题自动重新编号设置方法,论文多级编号演示
  10. 瑞士央行干预汇市以遏制瑞士法郎上涨
  11. GAN (Generative Adversarial Nets 生成对抗网络)
  12. Android深入浅出系列课程---Lesson13 LLY110529_虚拟机概述,JIT概述
  13. SOLIDWORKS如何实现放样折弯
  14. python实时显示图片_任何显示来自Cam的实时图像的快速Python GUI
  15. 佳能Canon PIXMA MG2545S 打印机驱动
  16. [原]排错实战——VS清空最近打开的工程记录
  17. mysql的读已提交和可重复读(Read Committed和Repeatable Read隔离级别)
  18. QCad源码分析 第一章
  19. 分享datax遇到的坑
  20. 关于Walter Rudin《数学分析原理》第一章附录对定理1.19的证明

热门文章

  1. CPU突然飙升,如何排查
  2. html5游戏黑白块,HTML5 Canvas黑白条纹螺旋环形动画
  3. Python全栈笔记(一)
  4. 【python】向图片添加噪声(高斯噪声、椒盐噪声)
  5. 007_理解App Upgrade
  6. 对removeAttr()和splice()的使用。
  7. Revit图纸问题:设置dwg图纸显示顺序和批量图纸编号
  8. 统计一篇英文文章中出现的 单词 和 词频
  9. python中的pylab_Python使用pylab库实现画线功能的方法详解
  10. n卡更新驱动显示无法继续安装,出现一个错误解决方法(NVIDIA驱动更新)