概要
本笔记是基于天宫连锁铺子项目中配送业务的拓展功能,由Redis中间件为主实现类似大众点评功能的开发笔记,业务功能主要包括:1、基于阿里云短信服务SDK实现用户接收短信验证码登录功能;2、用户查询店铺缓存功能;3、用户秒杀店铺优惠券功能及优化;4、用户探店发布笔记和查看共同关注并推送粉丝笔记及点赞其他用户功能;5、基于Geo实现查看附近店铺功能;6、基于BitMap实现签到及统计签到功能;
此业务源码已推送到本人码云仓库中:个人仓库主页链接
开发过程
配置Redis服务端环境,熟悉Redis的各种数据结构如String、Hash、List、Set等常用结构和Geo、BitMap、HyperLogLog等非热门结构、使用stringRedisTemplate结合JSON手动序列化和反序列化方式优化缓存对象逻辑、熟悉Redis常用命令与Redis的不同客户端,对比学习了Redis的不同存储方式并完成优化、导入业务初始代码并完成业务开发。
业务细节总结
一、基于阿里云短信服务SDK实现用户接收短信验证码登录功能
1、使用Session方式实现短信验证码登录及其问题分析
主要流程:主要步骤分为三项,①发送短信验证码;②短信验证码登录、注册和③校验登陆状态
图1:session方式主要登录流程图
在①中用户在前端填写手机号后后端需要校验手机号格式是否正确,如果不正确则不发送验证码并在前端提示错误信息,如果手机号码正确则发送给用户短信验证码并将验证码保存到session;
在②中用户登录时如果用户不存在即为新用户号码,则在数据库中创建新用户即自动完成注册并在校验验证码正确登录完成后将用户保存到session中;
在③中登录时从session中判断是否有用户存在,如果存在则保存到ThreadLocal中用于优化鉴权逻辑,整个判断过程使用拦截器实现。
集群session共享问题分析:由于session方式短信验证码和手机号码是否对应问题难以判断,且多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务时会导致session数据丢失,且session数据共享方案仍然有内存空间浪费和由于数据共享产生延迟导致的数据不一致问题,所以此问题使用Redis自定义key存储验证码方式进行优化。
2、使用Redis方式实现短信验证码登录及校验优化
主要流程:主要流程和session流程基本一致,在发送短信验证码中只需把保存到session中改为保存到redis中即可,后两种流程如下图所示。
图2:基于Redis的登录校验方式
由于在redis中自定义key保存了手机号码信息,所以在session方式发送短信验证码后在前端更改手机号码再填写正确的验证码后和之前的手机号码是否对应问题得以解决
并且由于Redis是中间件独立于Tomcat所以并不存在集群session共享的问题。
细节优化:①隐藏用户敏感信息:由于用户实体类中信息过多,返回给前端可以引起信息暴露,所以更换成DTO类只保留必要信息返回给前端有效隐藏了用户敏感信息;
②解决登陆状态刷新:之前的redis保存token有效期为固定时间,导致用户浏览过程中可能会突然登出,为了优化体验设置成两层拦截器,第一层拦截器用于刷新token有效期,只要用户在浏览页面就不断刷新有效期防止使用过程中登录,第二层拦截器才实现登录校验功能。
二、用户查询店铺缓存功能
1、添加商户缓存基本流程
主要流程:在查询过程中先去查询Redis中是否有目标数据,如果有就为命中直接返回结果提高效率,如果没有则未命中则去数据库中查询,如果数据库中有此数据则同时写入redis方便下次查询,如果没有则返回404错误信息,流程类似计算机组成原理中的三级缓存结构。
图3:添加商户缓存基本流程
2、缓存更新策略
①内存淘汰:Redis自带的淘汰机制,一致性较差,无维护成本;
②超时剔除:给数据添加TTL过期时间,一致性一般,维护成本低;
③主动更新:在修改数据库的同时修改Redis,一致性好但维护成本高。
主动更新又分为三种模式:Ⅰ、Cache Aside Pattern:由调用者自己实现同时更新,Ⅱ、Read Write Through Pattern:使用数据库和Redis整合服务自动同时更新,Ⅲ、Write Behind Cache Pattern:使用其他线程异步更新数据库保持一致性
在此由于Ⅱ中的服务实现复杂且缺少开源框架,且Ⅲ中仍然会存在数据不一致问题,所以我们选择模式Ⅰ和超时剔除作为兜底方案。
操作缓存中的三个问题:
①删除缓存还是更新缓存:更新缓存无效写操作较多,所以在更新数据库时直接删除缓存下次查询再更新缓存;
②缓存与数据库的操作同时成功或失败:使用事务方案解决;
③先操作缓存还是数据库:先操作数据库,由于数据库操作比缓存操作慢得多,所以降低了不同线程在先操作数据库的同时操作缓存出现线程安全问题。
3、解决缓存穿透问题
缓存穿透:用户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库产生大量压力,严重影响数据库服务器性能。
常见的解决方案有两种:①缓存空对象;②布隆过滤
由于布隆过滤实现复杂且有误判几率,所以选择缓存空对象方式,在查询不存在数据时在redis中缓存一个null值,防止所有无效请求都打到数据库上,null值同时设置ttl防止占用大量内存
图4:缓存空对象解决缓存穿透问题
但这些方案只是被动防御,我们可以同时主动加强用户权限校验进行防护。
4、解决缓存雪崩问题
缓存雪崩:在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求同时到达数据库,带来巨大压力。
**解决方案:**①给不同的Key的TTL添加随机值;
②利用Redis集群提高服务的可用性;
③给缓存业务添加降级限流策略;
④给业务添加多级缓存。
5、解决缓存击穿(热点key)问题
缓存击穿:一个被高并发访问并且缓存重建业务较复杂的kev突然失效了,无数的请求同时访问此key会
在瞬间给数据库带来巨大的冲击。
**解决方案:**①互斥锁;②逻辑过期
①:使用加锁方式实现线程间串行执行,性能一般,且有死锁风险
图5:互斥锁方案解决缓存击穿问题
②:使用字段设置时间戳的方式实现逻辑过期而不是设置ttl时间过期后删除数据,在未获取锁的时候可以查询到已经逻辑过期的信息所以无需串行等待,性能较好,但有数据不一致问题且实现相对复杂,由于需要开启独立线程查询数据库,所以有额外内存消耗
图6:逻辑过期方案解决缓存击穿问题
6、封装Redis工具类中产生的问题
在封装工具类的同时希望查询的所有类型都能使用同一个工具类,而在查询不同类型所使用到的查询方法是不同的,所以不能在类中的方法统一查询,所以使用函数式编程,把查询方法当作参数传入工具类中解决了统一查询问题。
三、用户秒杀店铺优惠券功能及优化
1、全局ID问题
问题引入:数据库自增id的规律性太明显,可能导致用户信息泄露;受单表数据量的限制,如果拆分为不同表则会引起id重复,所以我们希望产生一个不受数据库表的限制的全局id,而使用Redis中间件独立于数据库则是一个很好的的选择。
解决方案:有且不限于UUID和雪花算法等方案,在这里我们使用一种64bit的全局id,分为一位符号位,31位时间戳和32位序列号。
2、实现秒杀下单
实现要点:①需要判断优惠券秒杀时间是否开始和已经结束;②需要判断库存是否充足
图7:秒杀下单优惠券流程图
此时每个用户可以购买多个优惠券,明显不符合秒杀购买逻辑。
3、库存超卖问题
解决方案:①悲观锁;②乐观锁
由于悲观锁只能使线程串行执行性能差,所以我们选择乐观锁
其中乐观锁有Ⅰ、版本号法;Ⅱ、CAS法,而我们已经可以基于表中的库存值进行判断所查数据是否被更改过,所以无需使用版本号法添加版本字段,所以我们选择CAS法。
在CAS法中如果使用相等的判断逻辑,会导致库存少卖,此时将判断逻辑改为库存大于0即可。
4、实现每人只能购买一单
需求引入:在实现秒杀下单中,每个用户可以购买多个优惠券,明显不符合秒杀购买逻辑,所以我们需要优化购买逻辑成每个用户只能购买一张优惠券。
解决思路:需要在用户购买之前先判断是否购买过即判断该用户订单是否已经存在,如果购买过则返回异常结果不下单
图8:实现一人一单基本流程图
问题分析:如果只单纯的加入判断是否下过单在多线程并发执行的时候仍会出现超卖问题,所以我们初步在查询扣减库存逻辑中加入悲观锁,悲观锁中对象为了保证唯一不能直接使用toString()因为它会创建新对象导致锁的对象不同,需要在后面加入.intern()传入字符串对象的规范表示。悲观锁需要加在方法外面,因为加在方法内部查订单和扣减库存的事务在未提交的时候释放锁也会出现并发安全问题,而非代理对象没有@Transaction的事务功能,所以我们需要拿到调用锁中的方法的代理对象去调用方法防止spring中的事务功能失效。
5、集群下解决一人一单并发安全问题
需求引入:在程序部署到多台tomcat上组成集群后,程序也会分为多个JVM虚拟机,而锁只能保证在同一个JVM虚拟机内有效,所以此时锁的方案会导致失效
解决方式:分布式锁,即满足分布式系统或集群模式下多进程可见并且互斥的锁。常见的实现方式有MySQL事务,Redis和Zookeeper,MySQL和Zookeeper方式性能一般,但是Zookeeper方式安全性最好,此处我们选择使用Redis方式。
实现思路:使用Redis中的setnx定义锁变量,并定义过期时间,防止redis宕机后导致死锁。
方案版本①:只有获取锁和释放锁功能,会导致当线程1业务阻塞时,线程1锁过期自动释放,此时线程2获取锁线程1业务恢复运行后执行释放锁会把线程2的锁释放产生线程并发安全问题。
图8:分布式锁实现版本1
方案版本②:在获取锁时存入线程标示,在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致,
如果一致则释放锁,如果不一致则不释放锁。但是判断过程没有原子性,如果线程1在判断后业务阻塞依旧会获得锁,此时锁超时释放线程2获取锁之后线程1恢复运行依旧会释放线程2的锁仍会导致并发线程安全问题。
图9:分布式锁实现版本2
方案版本③:使用Lua脚本执行判断逻辑使流程具有原子性保证了线程并发执行的安全
6、Redisson实现分布式锁
问题引入:前三种自己使用Redis实现的分布式锁存在的问题有如下四点:
①不可重入:同一个线程可以多次获得同一把锁;
②不可重试:只获取一次锁如果失败直接返回false不会再次获取;
③*超时释放:线程1阻塞时锁被超时释放线程2仍可以和线程1同时超卖;
④主从一致性:redis主从锁同步需要时间如果此时主机宕机,从机没有同步锁会出现安全问题。
解决方案:①:使用Redisson的RLock可重入锁,底层基于Hash结构,添加一个获取锁次数的计数器,每次同一线程允许重复获取锁计数器同时+1,释放锁计数器-1,直到计数器减为0锁才释放。
图10、可重入锁执行流程图
②:使用Redisson设置等待时间waitTime解决可重试锁,同时开启订阅和信号量机制,当订阅收到释放锁信号量之后再次尝试获取锁;
③:使用Redisson设置锁自动失效时间leaseTime时设置为-1开启watchdog机制使获取锁之后有效期自动续约解决;
图11、可重试锁执行流程图
④:使用Redisson中的multiLock解决主从一致性问题,解决思路即在获取锁之后将所有的节点都同时获取锁,而且必须在所有节点都获取锁才算获取锁成功。
7、多人秒杀优惠券优化之异步秒杀
优化思路:将多人秒杀任务的串行执行方式优化为并发执行,大大提升时间性能,以下为主要实现步骤,
①新增秒杀优惠券的同时,将优惠券信息保存到Redis中;
②基于Lua脚本操作Redis,判断秒杀库存、一人一单,决定用户是否抢购成功;
③如果抢购成功,将优惠券id和用户id封装后存入阻塞队列;
④开启独立线程任务,不断从阻塞队列(JDK自带)中获取信息,实现异步下单功能。
图12、异步秒杀实现流程图
引出问题:①阻塞队列使用的是JVM内存拥有一定的内存上限;
②阻塞队列中的数据都存在于内存中没有持久化功能有丢失数据的安全问题。
8、使用消息队列代替阻塞队列实现异步秒杀优化
消息队列:存储和管理消息,也称消息代理,分为生产者,队列和消费者部分。
使用Redis实现队列的三种方式:①List;②PubSub;③Stream
①解决了数据持久化问题,但无法避免消息丢失因为pop之后消息直接从队列里删除,而且只支持单消费者;
②发布订阅模型,解决了多消费者问题,但是不支持数据持久化,且消息堆积有上限也无法避免消息丢失;
③单消费者模式有消息漏读的风险,处理一条消息的过程中,又有超过1条以上的消息到达队列,则下次
获取时也只能获取到最新的一条,会出现漏读消息的问题;使用消费者组模式加入最后读取的消息标识与消息确认机制和pending-list,解决了消息漏读的风险。
四、达人探店
1、探店笔记:
业务要求:发布笔记功能和查看笔记功能。
2、点赞功能:
业务要求:每个用户只能点赞一次,如果用户已点赞再次点赞则会取消点赞。笔记页面可以显示点赞排行榜,使用zset和使用时间戳score按时间排序,最先点赞的人在排行榜最前面;实现细节,在zset查出点赞排行榜用户id之后需要去数据库查询用户,但是数据库查询是默认按照id顺序排序所以会打算排行顺序,需要在sql语句中加入order by field字段使查询结果按照排行榜顺序排序;
3、好友关注功能:
主要功能:关注和取关功能;使用set中的interset功能查看共同关注;基于Feed流实现推送粉丝最新笔记功能。
Feed流:常见模式①Timeline,不做内容筛选,常按时间排序;
其中分为Ⅰ、推模式,又称写扩散,每个粉丝一个收件箱,分别把信件写到收信箱里,适合用户量少的情况;Ⅱ、拉模式,又称读扩散,每人一个发信件,粉丝一个收信箱,很少被使用;Ⅲ推拉结合模式,又称读写混合,根据用户活跃度开设收信箱和发信箱,适合用户量多的情况。
②智能排序,利用智能算法推送用户感兴趣的内容。
此处我们使用Feed流的推模式,在发布笔记的同时将笔记同时添加到由zset实现的可以基于时间排序粉丝的收信箱中
4、滚动分页:
①问题分析:由于Feed流中的数据会不断更新,所以数据的角标也在变化,因此不能采用传统的分页模式,造成插入新数据后分页会有重复数据的问题解决。
②解决方案:使用滚动分页方式,分页查询参数为
max:上一次查询的最小时间戳或当前时间(第一次查)
min:0
offset:与上次查询最小时间戳一致的所有元素个数或0(第一次查)
count:分页大小
5、附近商铺:
实现过程:基于Redis的Geo数据结构实现查询用户当前方圆内若干距离内由近到远顺序的附近商铺,
查询流程:首先需要将商铺的经纬度信息导入到redis中,然后根据用户经纬度使用滚动分页方式由近到远的顺序查询附近商铺,此处也会有使用数据库查询是默认按照id顺序排序所以会打乱顺序,需要在sql语句中加入order by field字段使查询结果按照由近到远距离的顺序排序
6、用户签到:
问题引入:如果使用表来存储用户签到信息则会耗费大量存储空间,所以我们需要引入一种节省空间的数据结构来保存用户签到信息,所以我们BitMap(位图)来存储用户签到信息,在位图中用1表示已签到,用0表示未签到;
实现统计从今天开始计算本月的连续签到天数:使用签到位图信息不断与1做与运算如果结果为1则证明为签到位,签到天数加1后位图信息右移一位,如果结果为0,则结束循环此时计数结果即为连续签到天数。
7、HyperLogLog:
UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
将两者结合来判断一个网站的用户活跃度
由于UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常庞大。
Hyperloglog(HLL):是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,但其测量结果是不完全准确有概率性的,有小于0.81%的误差。不过对于很大数量级的UV统计来说,完全可以忽略。
应用:实现了使用容量为1000的单个数组结合角标指针求余的方式实现了迭代暂存一百万条数据存储至redis的单元测试。
收获
认识了NoSQL,深入学习了Redis各种数据结构在业务中的具体作用,熟悉了Linux系统的操作,学习了基本的Java并发编程。