基于Redis位图实现用户签到功能

ゝ一纸荒年。 2022-11-30 05:53 339阅读 0赞

format_png

点击上方蓝字关注我们!

" class="reference-link">format_png 1

#

背景

会员积分体系,实现前端按照日历进行签到。连续签到的7天及7天的倍数额外增加积分。可以获取之前连续签到的次数(理论上没有上限)

#

设计思路

如果存入到数据库中数据量巨大,且充斥很多无意义数据。了解到使用Redis的位图适合于大量存储布尔型的值。对于用户签到数据,如果每条数据都用K/V的方式存储,当用户量大的时候内存开销是非常大的。而位图(BitMap)是由一组bit位组成的,每个bit位对应0和1两个状态,虽然内部还是采用String类型存储,但Redis提供了一些指令用于直接操作位图,可以把它看作是一个bit数组,数组的下标就是偏移量。它的优点是内存开销小、效率高且操作简单,很适合用于签到这类场景。
按用户每月存一条签到数据(也可以每年存一条数据)。Key的格式为u:sign:uid:yyyyMM,Value则采用长度为4个字节(32位)的位图(最大月份只有31天)。位图的每一位代表一天的签到,1表示已签,0表示未签。
例如u:sign:1000:201902表示ID=1000的用户在2019年2月的签到记录。

#

#

实现难点

使用递归去获取最大的连续签到次数,在递归到合适的值时,在从最里面的递归方法中跳出。使用抛异常的方式去返回值,在调用时使用try catch去捕获最里面递归抛出的值。

  1. // 当代码中使用递归时碰到了想中途退出递归,但是代码继续执行的情况,抛出异常上层捕获,避免跳出递归获取的值不正确问题
  2. try {
  3. getSignCount(aid, signCount, offset, count, days);
  4. } catch (Exception e) {
  5. signCount = Integer.valueOf(e.getMessage());
  6. }

Redis的无符号数最大只能取63位,也就是一次最多只能取63天的签到数据,例如:

  1. List<Long> list = jedis.bitfield(buildSignKey(aid, date), "GET", "u63", "0");

超出长度就会报错:ERR Invalid bitfield type. Use something like i16 u8. Note that u64 is not supported but i64 is.
递归使用上个月的拼接的key去获取上个月最大的天数,不断循环去获取最大的连续不中断的签到次数。

  1. # 用户2月17号签到
  2. SETBIT u:sign:1000:201902 16 1 # 偏移量是从0开始,所以要把17减1
  3. # 检查2月17号是否签到
  4. GETBIT u:sign:1000:201902 16 # 偏移量是从0开始,所以要把17减1
  5. # 统计2月份的签到次数
  6. BITCOUNT u:sign:1000:201902
  7. # 获取2月份前28天的签到数据
  8. BITFIELD u:sign:1000:201902 get u28 0
  9. # 获取2月份首次签到的日期
  10. BITPOS u:sign:1000:201902 1 # 返回的首次签到的偏移量,加上1即为当月的某一天

#

#

实例代码

maven依赖

  1. <dependency>
  2. <groupId>cn.hutool</groupId>
  3. <artifactId>hutool-all</artifactId>
  4. <version>4.5.2</version>
  5. </dependency>
  6. <dependency>
  7. <groupId>redis.clients</groupId>
  8. <artifactId>jedis</artifactId>
  9. <version>3.1.0</version>
  10. </dependency>
  11. public class JedisUtil {
  12. //Redis服务器IP
  13. private static String ADDR = "localhost";
  14. //Redis的端口号
  15. private static Integer PORT = 6379;
  16. //访问密码
  17. private static String AUTH = "123";
  18. //可用连接实例的最大数目,默认为8;
  19. //如果赋值为-1,则表示不限制,如果pool已经分配了maxActive个jedis实例,则此时pool的状态为exhausted(耗尽)
  20. private static Integer MAX_TOTAL = 1024;
  21. //控制一个pool最多有多少个状态为idle(空闲)的jedis实例,默认值是8
  22. private static Integer MAX_IDLE = 200;
  23. //等待可用连接的最大时间,单位是毫秒,默认值为-1,表示永不超时。
  24. //如果超过等待时间,则直接抛出JedisConnectionException
  25. private static Integer MAX_WAIT_MILLIS = 10000;
  26. private static Integer TIMEOUT = 10000;
  27. //在borrow(用)一个jedis实例时,是否提前进行validate(验证)操作;
  28. //如果为true,则得到的jedis实例均是可用的
  29. private static Boolean TEST_ON_BORROW = true;
  30. private static JedisPool jedisPool = null;
  31. /**
  32. * 静态块,初始化Redis连接池
  33. */
  34. static {
  35. try {
  36. JedisPoolConfig config = new JedisPoolConfig();
  37. /*注意:
  38. 在高版本的jedis jar包,比如本版本2.9.0,JedisPoolConfig没有setMaxActive和setMaxWait属性了
  39. 这是因为高版本中官方废弃了此方法,用以下两个属性替换。
  40. maxActive ==> maxTotal
  41. maxWait==> maxWaitMillis
  42. */
  43. config.setMaxTotal(MAX_TOTAL);
  44. config.setMaxIdle(MAX_IDLE);
  45. config.setMaxWaitMillis(MAX_WAIT_MILLIS);
  46. config.setTestOnBorrow(TEST_ON_BORROW);
  47. jedisPool = new JedisPool(config,ADDR,PORT,TIMEOUT,AUTH);
  48. } catch (Exception e) {
  49. e.printStackTrace();
  50. }
  51. }
  52. /**
  53. * 获取Jedis实例
  54. */
  55. public synchronized static Jedis getJedis(){
  56. try {
  57. if(jedisPool != null){
  58. return jedisPool.getResource();
  59. }else{
  60. return null;
  61. }
  62. } catch (Exception e) {
  63. e.printStackTrace();
  64. return null;
  65. }
  66. }
  67. public static void returnResource(final Jedis jedis){
  68. //方法参数被声明为final,表示它是只读的。
  69. if(jedis!=null){
  70. // jedisPool.returnResource(jedis);
  71. //jedis.close()取代jedisPool.returnResource(jedis)方法将3.0版本开始
  72. jedis.close();
  73. }
  74. }
  75. }
  76. /**
  77. * @Date: Created in 13:55 2020/2/26
  78. * @Description: 基于Redis位图的用户签到功能实现类
  79. * * <p>
  80. * * 实现功能:
  81. * * 1. 用户签到
  82. * * 2. 检查用户是否签到
  83. * * 3. 获取当月签到次数
  84. * * 4. 获取当月连续签到次数
  85. * * 5. 获取当月首次签到日期
  86. * * 6. 获取当月签到情况
  87. */
  88. @Service
  89. public class SignInServiceIml implements SignInService {
  90. private Jedis jedis = JedisUtil.getJedis();
  91. /**
  92. * 用户签到
  93. *
  94. * @param aid 用户ID
  95. * @param date 日期
  96. * @return 之前的签到状态
  97. */
  98. @Override
  99. public boolean doSign(int aid, LocalDate date) {
  100. int offset = date.getDayOfMonth() - 1;
  101. return jedis.setbit(buildSignKey(aid, date), offset, true);
  102. }
  103. /**
  104. * 检查用户是否签到
  105. *
  106. * @param aid 用户ID
  107. * @param date 日期
  108. * @return 当前的签到状态
  109. */
  110. @Override
  111. public boolean checkSign(int aid, LocalDate date) {
  112. int offset = date.getDayOfMonth() - 1;
  113. return jedis.getbit(buildSignKey(aid, date), offset);
  114. }
  115. /**
  116. * 获取用户当月签到次数
  117. *
  118. * @param aid 用户ID
  119. * @param date 日期
  120. * @return 当月的签到次数
  121. */
  122. @Override
  123. public long getSignCount(int aid, LocalDate date) {
  124. return jedis.bitcount(buildSignKey(aid, date));
  125. }
  126. /**
  127. * 获取无限连续签到次数
  128. *
  129. * @param aid 用户ID
  130. * @param date 日期
  131. * @return 无限连续签到次数
  132. */
  133. @Override
  134. public long getContinuousSignCount(int aid, LocalDate date) {
  135. int signCount = 0;
  136. String type = String.format("u%d", date.getDayOfMonth());
  137. List<Long> list = jedis.bitfield(buildSignKey(aid, date), "GET", type, "0");
  138. if (CollUtil.isNotEmpty(list)) {
  139. // 取低位连续不为0的个数即为连续签到次数,需考虑当天尚未签到的情况
  140. long v = list.get(0) == null ? 0 : list.get(0);
  141. for (int i = 0; i < date.getDayOfMonth(); i++) {
  142. if (v >> 1 << 1 == v) {
  143. // 低位为0且非当天说明连续签到中断了
  144. if (i > 0) {
  145. break;
  146. }
  147. } else {
  148. signCount += 1;
  149. }
  150. v >>= 1;
  151. }
  152. }
  153. int offset = -1;
  154. int count = 1;
  155. int daysOfMonth = getDaysOfMonth(DateUtil.offsetMonth(new Date(), offset));
  156. int days = date.getDayOfMonth() + daysOfMonth;
  157. if (signCount == date.getDayOfMonth()) {
  158. // 当代码中使用递归时碰到了想中途退出递归,但是代码继续执行的情况,抛出异常上层捕获,避免跳出递归获取的值不正确问题
  159. try {
  160. getSignCount(aid, signCount, offset, count, days);
  161. } catch (Exception e) {
  162. signCount = Integer.valueOf(e.getMessage());
  163. }
  164. }
  165. return signCount;
  166. }
  167. private int getSignCount(int aid, int signCount, int offset, int count, int days) throws Exception {
  168. // 上上个月
  169. DateTime dateTime1 = DateUtil.offsetMonth(new Date(), offset * count);
  170. // 获取上上个月的天数
  171. String lastDays = String.format("u%d", getDaysOfMonth(dateTime1));
  172. List<Long> lastList = jedis.bitfield(buildSignKey(aid, dateToLocalDate(dateTime1)), "GET", lastDays, "0");
  173. if (CollUtil.isNotEmpty(lastList)) {
  174. // 取低位连续不为0的个数即为连续签到次数,需考虑当天尚未签到的情况
  175. long v = lastList.get(0) == null ? 0 : lastList.get(0);
  176. for (int i = 0; i < getDaysOfMonth(dateTime1); i++) {
  177. if (v >> 1 << 1 == v) {
  178. // 低位为0且非当天说明连续签到中断了
  179. if (i > 0) {
  180. break;
  181. }
  182. } else {
  183. signCount += 1;
  184. }
  185. v >>= 1;
  186. }
  187. count += 1;
  188. }
  189. // 如果连续签到次数小于了当前月天数+多个整月天数,证明连续签到中断
  190. if (signCount < days) {
  191. throw new Exception(String.valueOf(signCount));
  192. }
  193. // 当前月总的天数+上个月的天数
  194. days = days + getDaysOfMonth(DateUtil.offsetMonth(new Date(), offset * (count - 1)));
  195. getSignCount(aid, signCount, offset, count, days);
  196. return signCount;
  197. }
  198. /**
  199. * 获取当月首次签到日期
  200. *
  201. * @param aid 用户ID
  202. * @param date 日期
  203. * @return 首次签到日期
  204. */
  205. @Override
  206. public LocalDate getFirstSignDate(int aid, LocalDate date) {
  207. long pos = jedis.bitpos(buildSignKey(aid, date), true);
  208. return pos < 0 ? null : date.withDayOfMonth((int) (pos + 1));
  209. }
  210. /**
  211. * 获取当月签到情况
  212. *
  213. * @param aid 用户ID
  214. * @param date 日期
  215. * @return Key为签到日期,Value为签到状态的Map
  216. */
  217. @Override
  218. public Map<String, Boolean> getSignInfo(int aid, LocalDate date) {
  219. Map<String, Boolean> signMap = new HashMap<>(date.getDayOfMonth());
  220. String type = String.format("u%d", date.lengthOfMonth());
  221. List<Long> list = jedis.bitfield(buildSignKey(aid, date), "GET", type, "0");
  222. if (CollUtil.isNotEmpty(list)) {
  223. // 由低位到高位,为0表示未签,为1表示已签
  224. long v = list.get(0) == null ? 0 : list.get(0);
  225. for (int i = date.lengthOfMonth(); i > 0; i--) {
  226. LocalDate d = date.withDayOfMonth(i);
  227. signMap.put(formatDate(d, "yyyy-MM-dd"), v >> 1 << 1 != v);
  228. v >>= 1;
  229. }
  230. }
  231. return signMap;
  232. }
  233. /**
  234. * 构建指定类型的Redis的key:u:sign:10000:202001
  235. */
  236. private static String buildSignKey(int aid, LocalDate date) {
  237. return String.format("u:sign:%d:%s", aid, formatDate(date));
  238. }
  239. /**
  240. * 获取Date类型的当月的天数
  241. */
  242. private static int getDaysOfMonth(Date date) {
  243. Calendar calendar = Calendar.getInstance();
  244. calendar.setTime(date);
  245. return calendar.getActualMaximum(Calendar.DAY_OF_MONTH);
  246. }
  247. /**
  248. * 固定202001格式
  249. */
  250. private static String formatDate(LocalDate date) {
  251. return formatDate(date, "yyyyMM");
  252. }
  253. /**
  254. * LocalDate按照指定格式进行转换字符串
  255. */
  256. private static String formatDate(LocalDate date, String pattern) {
  257. return date.format(DateTimeFormatter.ofPattern(pattern));
  258. }
  259. /**
  260. * Date类型转换成LocalDate
  261. */
  262. private static LocalDate dateToLocalDate(Date date) {
  263. Instant instant = date.toInstant();
  264. ZoneId zoneId = ZoneId.systemDefault();
  265. LocalDateTime localDateTime = instant.atZone(zoneId).toLocalDateTime();
  266. return LocalDate.from(localDateTime);
  267. }
  268. public static void main(String[] args) {
  269. SignInServiceIml demo = new SignInServiceIml();
  270. LocalDate today = LocalDate.now();
  271. // todo 测试连续签到,循环添加三个月签到记录 再去查询
  272. // DateTime dateTime1 = DateUtil.offsetDay(new Date(), -90);
  273. // LocalDate localDate = dateToLocalDate(dateTime1);
  274. //
  275. // for (int i = 0; i < localDate.getDayOfMonth(); i++) {
  276. // DateTime dateTime2 = DateUtil.offsetDay(new Date(), -i-90);
  277. // LocalDate localDate1 = dateToLocalDate(dateTime2);
  278. //
  279. // boolean signed = demo.doSign(1000, localDate1);
  280. // if (signed) {
  281. // System.out.println("您已签到:" + formatDate(localDate1, "yyyy-MM-dd"));
  282. // } else {
  283. // System.out.println("签到完成:" + formatDate(localDate1, "yyyy-MM-dd"));
  284. // }
  285. // }
  286. { // doSign
  287. boolean signed = demo.doSign(1000, today);
  288. if (signed) {
  289. System.out.println("您已签到:" + formatDate(today, "yyyy-MM-dd"));
  290. } else {
  291. System.out.println("签到完成:" + formatDate(today, "yyyy-MM-dd"));
  292. }
  293. }
  294. { // checkSign
  295. boolean signed = demo.checkSign(1000, today);
  296. if (signed) {
  297. System.out.println("您已签到:" + formatDate(today, "yyyy-MM-dd"));
  298. } else {
  299. System.out.println("尚未签到:" + formatDate(today, "yyyy-MM-dd"));
  300. }
  301. }
  302. { // getSignCount
  303. long count = demo.getSignCount(1000, today);
  304. System.out.println("本月签到次数:" + count);
  305. }
  306. { // getContinuousSignCount
  307. long count = demo.getContinuousSignCount(1000, today);
  308. System.out.println("无限签到次数:" + count);
  309. }
  310. { // getFirstSignDate
  311. LocalDate date = demo.getFirstSignDate(1000, today);
  312. System.out.println("本月首次签到:" + formatDate(date, "yyyy-MM-dd"));
  313. }
  314. { // getSignInfo
  315. System.out.println("当月签到情况:");
  316. Map<String, Boolean> signInfo = new TreeMap<>(demo.getSignInfo(1000, today));
  317. for (Map.Entry<String, Boolean> entry : signInfo.entrySet()) {
  318. System.out.println(entry.getKey() + ": " + (entry.getValue() ? "√" : "-"));
  319. }
  320. }
  321. }
  322. public interface SignInService {
  323. boolean doSign(int aid, LocalDate date);
  324. boolean checkSign(int aid, LocalDate date);
  325. long getSignCount(int aid, LocalDate date);
  326. long getContinuousSignCount(int aid, LocalDate date);
  327. LocalDate getFirstSignDate(int aid, LocalDate date);
  328. Map<String, Boolean> getSignInfo(int aid, LocalDate date);
  329. }
  330. 您已签到:2020-03-01
  331. 您已签到:2020-03-01
  332. 本月签到次数:1
  333. 无限签到次数:122
  334. 本月首次签到:2020-03-01
  335. 当月签到情况:
  336. 2020-03-01: √
  337. 2020-03-02: -
  338. 2020-03-03: -
  339. 2020-03-04: -
  340. 2020-03-05: -
  341. 2020-03-06: -
  342. 2020-03-07: -
  343. 2020-03-08: -
  344. 2020-03-09: -
  345. 2020-03-10: -
  346. 2020-03-11: -
  347. 2020-03-12: -
  348. 2020-03-13: -
  349. 2020-03-14: -
  350. 2020-03-15: -
  351. 2020-03-16: -
  352. 2020-03-17: -
  353. 2020-03-18: -
  354. 2020-03-19: -
  355. 2020-03-20: -
  356. 2020-03-21: -
  357. 2020-03-22: -
  358. 2020-03-23: -
  359. 2020-03-24: -
  360. 2020-03-25: -
  361. 2020-03-26: -
  362. 2020-03-27: -
  363. 2020-03-28: -
  364. 2020-03-29: -
  365. 2020-03-30: -
  366. 2020-03-31: -

format_png 2

点在看~

format_png 3

捧个人场就行~

format_png 4

精彩推荐

几款免费在线甘特图工具

开发点赞功能,用 MySQL 还是 Redis ?

有个定时任务突然不执行了,别急,原因可能在这

发表评论

表情:
评论列表 (有 0 条评论,339人围观)

还没有评论,来说两句吧...

相关阅读

    相关 使用redis bitmap实现签到功能

    1.签到功能的实现思路 最近有研究到用户的签到功能,对功能进行设计的时候想到使用msyql存储用户的签到记录,将用户的每日签到记录存储到表中,然后又想到每次签到就往表里面

    相关 利用redis实现每日签到功能

    今天给大家介绍一个简单的应用场景,我们迷你喵小程序最近新增了一个签到功能,但是每天只能签到一次,我们如何实现每日只签到一次呢? 想学习分布式、微服务、JVM、多线程、架构、j