简历准备
项目名称:美食点评
项目介绍:基于SpringBoot + MySQL + Mybatis + Redis + Kafka + Caffeine + Nginx的美食点评项目,集商户发布优惠、用户打卡探店为一体的服务平台,项目主要实现了用户登录、下单购物、优惠券秒杀、笔记发布和点赞的功能。
实现的功能:
- 使用nginx做反向代理进行负载均衡,转发数据包,保护后端服务器;
- 登录模块中,使用Redis实现黑名单防止短信接口恶意调用,保存token到Redis实现多服务器间Session共享以实现会话保持;
- 订单模块分别使用乐观锁、Redis分布式锁和缓存预热+消息队列的方式实现普通、限购、秒杀三种场景的下单业务;
- 使用线程池异步创建订单,提高下单效率,控制整体的并发量和吞吐量;
- 点赞模块涉及数据库表和点赞服务架构的设计,以及点赞相关业务功能的实现:数据库使用三张表保存点赞数据;系统的三层架构设计用于提升系统性能和容灾能力,服务层用于提供接口调用和实现功能逻辑,异步任务层用于流量削峰和实现定时任务,数据层由本地缓存、Redis缓存和数据库组成,用于数据的查询和持久化。
旧版(字数太多了,简历写不下)
- 实现了三个类目的下单功能:使用乐观锁实现普通优惠券的下单业务,有效防止库存超卖;使用分布式锁完成限购优惠券的下单业务,实现分布式场景适用的限购功能;使用redis预热配合Kafka实现秒杀类优惠券的抢购流程,提高了抢购性能和下单的并发能力。
- 优化文章数据表的设计,设置文章信息表和文章内容表,将文章属性信息和文章内容信息解耦,加速多种场景下的访问速度。
- 点赞模块包括数据库表和点赞服务架构的设计,数据库使用三张表保存点赞数据,系统的三层架构设计用于提升系统性能和容灾能力。
源码链接
环境配置
Redis
docker pull redis
docker run -d --name redis --restart=always -p 6379:6379 redis --requirepass "123321"
docker logs -f [name] #查看日志
# 或
redis-server /home/lee/workspace/redis.conf
# 端口占用查询
lsof -i :3306
MySQL
ubuntu IP地址192.168.76.129,端口号3306,密码123,容器名,hmdp_mysql
docker run -d --name hmdp_mysql \
-p 3306:3306 \
--restart=always \
-e TZ=Asia/Shanghai \
-e MYSQL_ROOT_PASSWORD=123 \
-v /root/mysql/data:/var/lib/mysql \
-v /root/mysql/conf:/etc/mysql/conf.d \
mysql
# 登录
docker exec -it hmdp_mysql /bin/bash
mysql -u root -p #密码123
SHOW DATABASES;
use hmdp
# 启动容器
docker start hmdp_mysql
Kafka
kafka对zookeeper是强依赖,在docker中分别安装这两个容器。
docker search kafka #查找相关镜像
docker pull zookeeper:3.4.14
docker run -d --name zookeeper --restart=always -p 2181:2181 zookeeper:3.4.14
docker pull wurstmeister/kafka
docker run -d --name kafka \
--restart=always \
--env KAFKA_ADVERTISED_HOST_NAME=192.168.112.129 \
--env KAFKA_ZOOKEEPER_CONNECT=192.168.112.129:2181 \
--env KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://192.168.112.129:9092 \
--env KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092 \
--env KAFKA_HEAP_OPTS="-Xmx256M -Xms256M" \
--net=host wurstmeister/kafka
登录模块
登录模块主要涉及两个内容:用户登录和保存登录状态。用户登录很好理解,主要就是校验用户的账号密码;由于HTTP是无状态协议,这意味着每次请求都是独立的,要想维持会话功能,就需要在服务器额外实现一套机制来实现会话功能。
验证码登录
验证码登录分为两个步骤:发送验证码和登录校验。
**发送验证码:**手机号校验通过后,服务器调用相关接口(例如由腾讯提供的验证码接口)发送并获取验证码,然后将验证码存放到redis中,同时设置过期时间,用于校验时比对。
redis中验证码的存储:string数据类型,key为code+手机号,value为验证码
**首次登录校验:**用户登录向后端发送手机号和验证码,后台取出redis中的验证码进行比对,如果不通过则会拦截,通过校验后需要向数据库中查询该用户是否存在,如果不存在则创建新用户,并保存到数据库。无论用户是否存在,都会将用户信息保存到redis中并设置有效期,最后返回token。
将用户信息保存到redis中的具体做法为:使用UUID生成一个随机字符串作为token(令牌),用户信息(例如userid:123,username:Tom)则转化为字符串,将这两个信息以key-value的形式存储到redis中。
会话保持
会话保持即保存登录状态,同一个客户端发送多条请求时,服务器能够识别出这些请求来自同一用户。
通过用户的首次登录,后台已经返回了token,在后续的请求中,客户端会把token携带在HTTP头部的authorization
字段中。
这里使用双重拦截器校验和刷新。
第一个拦截器拦截所有访问路径,如果请求头部中authorization
字段携带了合法token,则刷新redis中该token的过期时间,并保存用户信息到Threadlocal
中。其他情况全部放行由第二个拦截器处理。此拦截器的作用为,如果用户长时间访问不需要登陆权限路径,也能会话保持。
第二个拦截器对需要登陆权限的路径生效,如果此时Threadlocal已经保存了用户信息则直接放行,否则进行拦截。此拦截器的作用是防止未登录用户访问需要登陆权限路径的内容。
- 拦截器和过滤器的区别是什么?为什么要用拦截器不使用过滤器?如果同时配置了过滤器和拦截器,哪个先执行,哪个后执行?
过滤器工作在 Servlet 容器层面,处理所有进入 Servlet 容器的请求。过滤器在更底层工作,它会处理更多的请求(包括静态资源、JSP、WebSocket 请求等)。过滤器适用于需要在整个应用范围内进行预处理和后处理的场景,如安全检查、日志记录、编码设置等。
拦截器工作在 Spring MVC 框架层面,主要处理控制器(Controller)的方法调用。拦截器在过滤器之后执行。拦截器只处理与 Spring MVC 控制器相关的请求,在处理范围上更小。拦截器适用于只需要在处理 Spring MVC 控制器请求时进行预处理和后处理的场景,如业务逻辑处理、数据预处理、视图处理等。
安全性测试
黑马的登录模块到这里就结束了,下面我自己捣鼓了一些测试。由于整个项目都使用的是http协议,所以没有安全性可言。我首先用前面界面正常登录,拿到了一个合法的token,然后将这个token放在本地虚拟机中再次访问应该被拦截的路径,发现后端服务器直接放行。
黑名单功能
手机号黑名单
下面考虑一个问题,用户请求发送验证码时,服务器这边的逻辑是直接调用相关接口(这里需要运营商提供增值服务),将验证码发给用户,同时服务器端也会存储一份验证码。但是如果有人恶意使用这项功能,大量发送无效验证码,那么就会给服务器带来压力,同时增加公司的开销,这正是我们愿意看到的(bushi)。
所以这里需要对请求中的手机号获取验证码进行次数限制。在我们的日常使用中,一般一分钟只能获取一次验证码,这里的实现思路很简单,可以称为使用锁的思想,每次有请求想要获取验证码时先检查redis中是否存在对应的锁,如果存在,则返回失败,如果不存在,则生成验证码并在redis中设置一个过期时间为一分钟的锁。同时还可以实现一个黑名单功能,限制一个手机号短时间内只能获取3次验证码,超过次数则拉入黑名单,24小时后从黑名单中移除。实现效果如下:
ip地址黑名单
之前我们已经做过一个手机号黑名单的功能了,现在考虑一种场景,我们的短信发送接口被同一个人使用不同的手机号多次恶意调用,那么同样会导致之前说过的问题,那么这个问题仍然可以使用之前类似的思想进行解决。(当然也可以通过加入验证码来缓解)
之前我们是将手机号加入黑名单进行次数限制,那么现在可以采用将IP地址加入黑名单进行次数限制的思路来实现。客户端IP地址位于数据包IP头部的源地址,那么我们可以在每次请求到来时将这个地址存下来,然后采用和手机号黑名单相同的思路来实现这个功能。效果如下:
联合客户端实现mac地址黑名单思路
下面再考虑一种场景,假如客户端使用了代理,隐藏了真实的IP地址并且来回地切换代理,又或者客户端不断地切换入网方式,那么客户端的IP地址仍然会不停地改变,那么上述方法就不能起到很好的防护作用了。这是我们可以联合客户端获取MAC地址或者设备识别码。在生活场景中,我们每一次下载一个新的APP都会请求获取我们的设备信息,才能够继续使用,那么我们就可以沿用这种思路,在客户端每次发起请求时,将这种唯一的识别码放在HTTP请求的头部,服务器再针对这个识别码增加一个黑名单即可,实现思路同上。这里没写代码。
联合nginx实现会话保持
这个实现思路可以用一句话来总结:ip哈希负载均衡实现会话保持。
上图是整个黑马点评项目的架构图,一个请求由客户端发起,然后到达nginx负载均衡服务器,然后才到达真正的后端Tomcat服务器,我们之前考虑到一种情况就是,如果我们后端部署了多台tomcat,那就需要将session存储到所有的服务器上才能具有会话保持的功能(不过后来借助redis中间件解决了相关问题),那么借助nginx实现会话保持功能就是一个非常低成本的实现方式。
实现的方式很简单,只需要将nginx的负载均衡模式更改为IP哈希即可。nginx默认的负载均衡方式为加权轮询(wrr),因此同一个客户端的请求有可能被转发到不同的服务器,所以我们才需要在服务器上单独实现一套机制用于会话保持。现在我们将nginx的负载均衡方式更改为ip哈希,那么对于同一个客户端的请求将会全部转发到同一台服务器上,那么多台服务器之间的session就不必保持一致性,因为同一个客户端的请求一定会被转发到固定的后端服务器上。
实现步骤:打开nginx.conf配置文件,在upstream配置块的第一行直接加上ip_hash;
,然后使用nginx的热部署nginx.exe -s reload
命令即可实现。
下面分析这种方式的利弊。优势:①实现简单,只需要配置nginx服务器即可实现,对业务代码零入侵;②成本低廉,不需要借助redis中间件,避免服务器之间的session共享,节省大量存储资源。劣势:①session无备份,一旦tomcat挂掉,这台服务器上所有的session数据将全部丢失;②由于使用ip hash的方式负载均衡,理论上每台服务器接收的请求数量是相同的,因此要求所有的服务器处理能力相同,不同服务器之间不能有较大的性能差异。
登录模块总结
详细内容查看飞书文档
登录模块自测面试题
- 介绍一下你做的登录模块
登录模块主要分为三个部分,主要包括登录功能、会话保持、黑名单功能。
直接吟唱前面的部分,重点突出①redis存储token,基于token获取用户信息,这样做是为了保证用户信息不直接在网络中传输,保护隐私;②实现会话保持使用双重拦截器;③黑名单功能指的是将频繁获取验证码的手机号和IP地址加入黑名单中,黑名单使用Redis实现,key是手机号或者IP地址,value为获取验证码的次数,设置一个自动过期时间。
- 生成token为什么使用UUID?为什么UUID具有唯一性?UUID有多长?
因为UUID具有唯一性,UUID的组成部分包括MAC地址,这保证了不同机器生成的UUID一定不同,包括时间戳,保证了时间维度的一致性,还有其他的一些部分用于保证唯一性(随版本和实现方式变化),UUID有128位,存入redis中为32个十六进制字符加上4个-,具有唯一性。
- 介绍一下基于Cookie - Session的会话保持实现的具体流程?为什么要使用redis + token这种方式,使用了redis + token之后有什么变化?
大致流程:①客户端发送请求到服务端;②服务端收到请求后生成session,session中存储的就是一些用于会话保持的信息,例如userid;③服务器回复响应报文时在头部的Set-Cookie字段中填入SessionId;④客户端收到响应报文后存储SessionId,此后每次请求都将SessionId放到Cookie中;⑤服务器收到携带Cookie的报文后验证用户信息进行会话保持。⑥会话过期后,服务器将会话数据删除,用户需要重新登录。
方案选型原因:使用redis是为了解决多个Tomcat服务器之间session共享问题,token的作用类似于SessionId。吟唱一下session共享问题的背景:用户的请求可能会被nginx负载均衡到不同的服务器上,导致会话丢失,要解决这个问题需要每台服务器都有一份session,那么就会导致以下问题:①每台服务器中都有一份完整的session数据,服务器压力大;②session拷贝数据时可能会出现延迟,数据的同步也需要较大的开销。
变化:头部存放位置由Cookie字段变为Authorization字段,Cookie用于会话管理,Authorization用于存储令牌,放在Cookie里面可以自动发送,但是这样不能跨域,更好的做法是放在HTTP Header 的Authorization字段中。将session的存储位置从服务器缓存更改为redis,redis是分布式存储系统,解决了session共享的问题。
- JWT方案介绍
定义:JWT(JSON Web Token)是一种用于身份验证和授权的开放标准。它由三部分组成,分别是头部(Header)、载荷(Payload)和签名(Signature)。其中,签名是用于验证令牌的完整性和可信任性。
作用:JWT 令牌主要用于实现一种无状态的认证机制,定义了一种紧凑且自包含的方式,在各方之间安全的传输信息,主要用于用户首次登录成功以后,服务器会创建一个 JWT,将其发回给用户,随后用户的每次请求都会包含这个 JWT。JWT 使得服务器无需去存储用户的登录状态,从而实现无状态认证
JWT属于token的一种,也就是说客户端每次发送请求都会将整个JWT携带在头部中,其中payload就是具体存储用户信息的地方,通常情况下,JWT的长度(至少100字节以上)通常较长,过长的头部会导致有效载荷的降低,因此JWT中存储的用户信息一般较少,例如只保存用户的id信息。
在基于 Token 进行身份验证的的应用程序中,服务器通过Payload、Header和一个密(secret)创建令牌(Token)并将Token 发送给客户端,以后客户端发出的所有请求都会携带这个令牌。
①用户向服务器发送用户名和密码用于登陆系统
②身份验证服务响应并返回了签名的JWT,上面包含了用户是谁的内容
③用户以后每次向后端发请求都在Header中带上JWT。
④服务端检查JWT 并从中获取用户相关信息。
- 方案对比(个人观点,写的比较乱)JWT(Java Web Token)也可以解决session共享问题,而且还不需要存储,为什么不用JWT?
最原始的方案其实是将信息直接放在cookie中,每次由客户端携带,但是这样非常不安全。
基于Cookie - Session的方案(这里指的是最原始的,不使用Redis的方案)是将信息存储在服务器的session中,cookie中只携带sessionId,这样增加了安全性。这种方案的优势是:①比较容易实现,客户端会自动存储cookie并携带在请求中,属于一套非常成熟的技术;②结合cookie实现鉴权功能,相较于将信息存储到cookie中安全性提升;③服务器收到cookie中的sessionId后直接查询存储在服务器内存中的session可以快速获取信息。但是也存在缺点:①需要单独解决跨域(多服务器)问题,且容易受到CSRF攻击;②session位于服务器的内存中,存储用户的完整信息,而且服务器需要为每一个用户创建session,耗费服务器存储资源。redis + token组合方式来缓解上述问题,其实就是将信息的存储转移到Redis中间件上,具体分析参考上述问题。
JWT方案的优势:①用户信息存储在JWT的payload中,服务端不需要存储session,节省存储资源;②token存储在本地,避免CSRF攻击。JWT方案的缺点:①JWT头部较长,增加了网络开销,降低有效负载率;②由于JWT头部过长,所以在JWT的payload中一般只存储userid,但是前端需要展示的内容并不是userid,所以相关内容还需要后端根据userid查询数据库,这就涉及到了磁盘IO,速度较慢且给数据库带来压力,而session的信息是存储在内存中的,查询就比较快;③JWT一旦生成,过期时间无法更改,这对于某些业务场景(JWT失效场景如退出登录、修改密码、权限取消,JWT续签场景如用户一直浏览页面却突然失效等)来说不友好
(额外补充:JWT过期问题,可以再将有效的JWT存入Redis中,每次校验JWT先检查redis中是否存在,对于JWT失效的场景直接从redis中删掉即可,或者说用反向思维,业务上失效就拉入Redis黑名单,过期时间到再从黑名单移除,对于JWT续签问题,可以参考javaguide提供的这个方案)
- 为什么要使用双重拦截器,只用一个拦截器不行吗?这两个拦截器的作用分别是什么?
第一个拦截器中拦截所有的路径,获取请求头中的token,使用token查询redis中存储的用户信息,将这个信息保存到threadlocal中,同时刷新token的有效期(更新redis中的过期时间),然后放行。如果这个请求中不包含token则直接放行,交由第二个拦截器处理。
第二个拦截器只拦截部分需要登录权限的路径,比如查询购物车、查询历史订单这种路径,拦截到相应的请求后会查询threadlocal中是否存在用户信息,如果不存在用户信息则直接拦截并返回401未授权状态码,如果用户信息存在于threadlocal中,则说明第一个拦截器已经进行了身份校验并拿到了合法的用户信息,则直接放行。
对于未登录的用户,只有访问登录路径和不需要登录授权的路径(比如浏览商品信息、浏览商户信息)才能够经过以上两个拦截器,否则均会被拦截并返回401。
使用两个拦截器的原因:这两个拦截器分别拦截不同的路径,第一个拦截器主要用于刷新token的有效期,第二个拦截器主要用于对授权路径进行鉴权。如果只使用一个拦截器,那么必须规定相同的拦截路径,如果拦截所有路径则会导致部分业务如浏览商品页面也需要登录才可以浏览,如果只拦截部分路径则会导致用户一直浏览商品信息,而token过期的情况。
- 什么是threadlocal?什么情况需要用到threadlocal?把用户信息存到Threadlocal中会有什么问题?你怎么解决这个问题?
当一个共享变量是共享的,但是需要每个线程互不影响,相互隔离,就可以使用ThreadLocal:跨层传递信息,隔离线程存储一些线程不安全的工具对象(SimpleDataFormat),Spring中的事务管理器
ThreadLocal是Java中所提供的线程本地存储机制,可以利用该机制将数据缓存在某个线程内部,该线程可以在任意时刻、任意方法中获取缓存的数据。可以将ThreadLocal理解为对外暴露的,用于操作ThreadLocalMap的工具类,Map的key为ThreadLocal对象,Map的value为需要缓存的值。
如果在线程池中使用ThreadLocal会造成内存泄漏。因为当ThreadLocal对象使用完之后,应该要把设置的key,value,也就是Entry对象进行回收,但线程池中的线程不会回收,而线程对象是通过强引用指向ThreadLocalMap,ThreadLocalMap也是通过强引用指向Entry对象。线程不被回收,Entry对象也就不会被回收,从而出现内存泄漏,解决办法是,在使用了ThreadLocal对象之后,手动调用ThreadLocal的remove方法清除Entry对象
- 你为什么用redis做黑名单,用redis做黑名单有什么优势吗?
redis是内存,读取速度快于mysql,mysql的数据存在磁盘中,读取涉及到磁盘IO,速度慢。redis可以适用于分布式环境中,不需要在每一个浏览器中存一份数据
商户信息缓存
黑马的内容很详细,而且在后面的高级篇还会有进阶内容。copy一下思维导图。
自测面试题:
- 为什么要用redis做一层缓存,相比直接查mysql有什么优势?
- 如何保证redis和mysql的数据一致性?延迟双删
- 如何保证缓存与数据库的操作的同时成功或失败?事务相关的八股,很多内容
- 缓存穿透、缓存击穿、缓存雪崩,什么是缓存xx?如何解决这个问题?这几个解决方案各自的优势和缺点分别是什么?
- 缓存三兄弟的其他问法:现在有一个场景,假如有一个key即将过期了,但是此时有100万个请求访问存入这个key的数据,这种情况该怎么办?现在有一个场景,假如有大量的key同时过期,但是这些key的访问频率很高,一瞬间会给数据库造成过大压力,该怎么办?现在有一个场景,有大量的请求进来,访问一个并不存在的key,且这个数据也不存在于数据库中,该怎么办?
商户缓存模块总结:这个模块就一个内容,redis做缓存,但是涉及到很多的八股,开背就完事了。黑马的视频讲的很详细。
优惠券下单
黑马程序中,redis没有判断秒杀是否开始,因为没存相关信息,改造已完成
java程序中,先生成订单号,再传给redis判断是否有资格购买,订单号浪费,未解决
消息队列改为kafka,已完成
可能存在的bug:订单处于消息队列中,还未写入数据库时就付款,改造已完成
优化限购数量更改为自定义数量,改造已完成
业务拆分
业务设计:将优惠券分成三类,分别为普通、限购、秒杀。普通优惠券购买数量不做限制,限购优惠券进行每人限购设计,秒杀优惠券主要指优惠力度大的券,在业务上同样设置限购,同时有开始时间和结束时间
表的修改:这里共涉及四(五)张表:
商品信息表:主要包含商品id,店铺id,商品名,商品描述,价格,类型(普通、限购、秒杀),状态(上下架)
普通商品库存表:主要包含商品id,库存
限购商品库存表:主要包含商品id,库存,限购数量
秒杀商品库存表:主要包含商品id,库存,限购数量,秒杀开始和结束时间
(订单表:主要包含订单号,商品id,购买用户等,这些信息保持不变,另外新增一个字段用于表示此订单是否属于秒杀商品的订单)也可以不修改,那么支付流程里就不需要判断订单类型,直接走统一的流程也行
普通优惠券下单流程:乐观锁
实现思路:后端收到下单请求后,先查询数据库库存是否足够,然后在扣减库存时将库存大于购买数量作为where语句中的条件,如果库存符合则扣减库存成功,如果库存不符合下单失败,返回库存不足的错误信息。
- 为什么使用乐观锁?
商城购物场景中大多数的业务其实并不是秒杀限购类物品,而是普通的购物,如果每一个商品的下单过程都使用分布式锁来走业务流程会造成很多的性能开销,为了提高性能,对于普通类商品是不是可以使用乐观锁来走进行下单流程呢,乐观锁是假设每一次读取数据时都不会有冲突,在实际的业务场景中,普通类商品确实如此,很少会有冲突,符合乐观锁的预设,因此想要考虑对普通商品使用乐观锁来提高性能。
- 乐观锁可以用于分布式环境吗,在分布式环境下会导致超卖吗?在分布式环境下有什么问题?
首先需要确保数据库实例只有一个,乐观锁可以用于分布式环境,不会导致库存超卖,但是在分布式环境下可能会导致限购功能失效。乐观锁在扣减库存的同时需要检查版本号的情况,如果版本号不符合预期则会扣减失败,且所有的服务器其实看到的是同一份数据库的数据,因此可以在分布式环境中使用。
- 乐观锁可以优化掉版本号吗?
可以,这也正是黑马提供的乐观锁的实现方式。如果使用版本号,也就是每次更新库存的时候就会更新其中的版本号,然后下一次扣减库存时要保证版本号符合预期才会执行库存扣减。那么在不修改表结构的情况下,可以直接将库存信息当成版本号来使用即可。思路为:第一次对库存进行查询判断库存是否充足,扣减库存的同时检查库存是否大于需要扣减的数量,如果大于直接扣减即可。
- 为什么要优化掉版本号,有什么优势吗?
在数据库设计的时候,不可能因为代码实现上的原因,在用户中增加一个版本号的内容,这会给数据库的设计带来巨大麻烦,而库存信息放在表中是很合理的,因此合理利用库存信息作为版本号可以合理利用数据库现有信息,后续如果需要切换业务逻辑也不需要对数据库进行更改。
限购优惠券下单流程:分布式锁
首先分析,为什么不能继续使用普通优惠券下单的业务流程?
因为使用上述下单流程有可能会导致限购功能失效。举一个简单场景:一个优惠券每人限购一张,一个用户在短时间内快速提交了两次订单,在购买数量校验时,两个线程几乎同时查询数据库,都没有查询到相关的订单信息,那么就会通过限购的校验,然后进行减库存操作,那么这两个线程都会购买成功,实际上已经超过了购买限制了,所以这里的限购功能还需要一个锁来实现。那么此时在整个下单流程可能就会出现两个锁,一个是用于实现限购功能的锁,一个是用于防止库存超卖的乐观锁。那么这个实现限购功能的锁可以是本地锁吗?分布式情况下不可以。假如用户的短时间内快速提交两次订单被负载均衡到了不同的后端服务器,后端服务器查询限购数量时,由于数据库订单中还没有订单信息,因此都会通过资格的校验,最终导致限购功能失效,所以限购优惠券的下单过程需要使用分布式锁来实现。
暂时无法在飞书文档外展示此内容
方案一:分布式锁实现限购
在普通优惠券下单流程的基础上再加一个限购功能,主要使用分布式锁来防止用户短时间内提交多次创建订单请求,大致流程如下所示:
暂时无法在飞书文档外展示此内容
如上图所示,下单的流程如下:
- 发起请求:用户发起下单请求,请求经nginx负载均衡转发到后端Tomcat服务器。在这个请求中,包含的信息有优惠券的ID和购买的数量;
- 获取分布式锁:这里可以将用户和优惠券绑定作为锁的key,这个锁的含义是当前正处于该用户下单该优惠券的流程中,保证在同一时间点,每个用户只有一个线程在执行下单业务;
- 库存查询:后端服务器根据优惠券ID查询该优惠券的库存信息,判断当前库存是否大于购买的数量;
- 限购查询:查询当前用户已经下单的数量和本次请求要购买的数量加在一起,校验是否超过限购数量;
- 乐观锁扣减库存:如果通过所有校验,则扣减库存数量;
- 创建订单:生成订单信息,存入数据库中,最后释放分布式锁;
- 释放分布式锁:下单结束后释放分布式锁。
分布式锁的实现细节:利用redis的string数据结构实现分布式锁,锁的key为用户ID+优惠券ID,value为线程ID。
方案二:分布式锁实现限购+防库存超卖
这地方先来分析一下上述解决方案可能存在的问题:假如在短时间内有大量并发进来,那么这些请求会全部打到数据库上,给DB造成压力,这个问题本质上是乐观锁的实现导致的,即普通优惠券的下单流程也会有这种问题,但是从业务方面来讲,普通优惠券的并发一般不大,但是限购商品可能会存在这种情况,那么这种情况该如何缓解?加大锁的粒度,但是同时会造成性能的降低。
暂时无法在飞书文档外展示此内容
分布式锁的实现细节
- 利用redis的string数据结构实现分布式锁
主要思路为,在Redis中存放一个锁,key中包含优惠券id,value是当前线程标识的string数据,在下单之前必须要获取这个锁才可以操作数据库,否则必须等待。在实现分布式锁时要注意加锁和释放锁的原子性。①使用redis的setnx
方法进行加锁,同时增加过期时间,防止死锁,此方法可以保证加锁和设置过期时间这两个操作具有原子性。②在释放锁时使用Lua脚本保证校验锁属于当前线程和删除锁这两个操作具有原子性,防止误删其他线程的锁。
- 利用redisson实现分布式锁
思路与上述基本一致,但是以上手动实现的锁不具有可重入和自动续期的功能。
可重入锁实现思路:使用hash结构来存储锁,其中hash结构的key表示这把锁是否存在,field表示当前这把锁被哪个线程持有,value表示重入次数
watchdog机制:再开启一个线程去检查业务是否完成,如果未完成,每过10s就进行一次续约,续约时间为30s
优点:解决了分布式情况下乐观锁无法实现限购功能的问题
缺点:分布式锁的实现较为复杂,性能、效率较为低下
方案对比
方案一:分布式锁实现限购功能,锁用户ID
优点:性能好
缺点:并发大时DB压力大
方案二:分布式锁实现限购+防库存超卖功能,锁商品ID
优点:并发大时DB压力小
缺点:性能差
秒杀优惠券下单流程:redis预热 + Lua脚本
商户上架优惠券后,服务器读取秒杀商品信息,设置定时任务,在秒杀活动开始之前将库存、开始和结束时间、限购数量等信息提前缓存到redis中,同时设置定时任务在活动结束之后将这些信息清除以释放缓存空间。商品下单过程所需要的信息缓存到Redis之后,就可以依靠redis实现高性能抢单业务。具体流程为,用户发起秒杀请求后,后端服务器直接提交给redis,redis根据缓存中的订单和商品信息判断购买资格,然后直接返回结果给后端服务器,如果购买成功,需要在redis中进行库存的预减,最后将下单是否成功的信息返回给服务器,服务器拿到信息后将下单业务放入Kafka消息队列后,将订单号返回给用户。而真正创建订单的是消息队列的消费者。
问题:如果kafka挂了怎么办?
可以做一个兜底方案,在用户下单成功之后,将订单信息保存到服务器的缓存中。如果kafka挂了,先保证服务器缓存中保存的订单号信息已经全部写入数据库中,然后将Kafka相关的业务流程停掉,相当于创建订单过程不再是异步操作。
优点:减少数据库查询次数,提高了性能;使用消息队列,解耦业务功能
缺点:缓存商品信息,增加了redis的内存占用,实现更加复杂,成本更高
问题:异步下单时候,并发量过大,消息队列消息堆积怎么办?
- 生产速度大于消费速度,这样可以适当增加分区,增加consumer数量,提升消费TPS;
- consumer消费性能低,查一下是否有很重的消费逻辑(比如拿到消息后写HDFS或HBASE这种逻辑就挺重的),看看是否可以优化consumer TPS;
- 确保consumer端没有因为异常而导致消费阻塞;
- 如果使用的是消费者组,确保没有频繁地发生rebalance。
问题:补充秒杀优惠券库存,数据库和Redis中的数据是怎么处理的?
给redis添加库存可以使用lua脚本进行添加,给mysql添加库存仍然使用锁的思想进行添加,添加库存和扣减库存的本质基本是一直的,都属于库存的更新,需要保证一致性。这里同样需要注意的问题就是如何保证两次的库存更新操作同时成功或者同时失败,可以使用分布式事务(如Seata)来确保一致性。大致思想为:向所有参与者发送准备请求,所有参与者执行操作但不提交,返回准备好的状态,如果所有参与者都返回准备好的状态,则向所有参与者发送提交请求,如果有任何一个参与者返回失败状态,则向所有参与者发送回滚请求。
支付流程
思路1:redis中缓存秒杀活动期间秒杀类目下的订单号
支付请求发起之后,后端会先查询redis中是否存在本次支付的订单号,通过redis的查询可以判断出本次的订单属于秒杀类型还是普通和限购类型的订单,下面就走两个不同的逻辑流程。普通和限购类型的订单直接走常规的查询和支付流程,对于秒杀类型订单,如果redis中存在则再去查询数据库中订单相关的信息,如支付金额、付款方式等,如果redis中存在信息而数据库中不存在相关订单,则说明此时订单还处于消息队列中未被处理,则需要等待,直到查询到信息后进入支付流程。
为什么不在将订单信息加入数据库之后就从redis中删除订单信息,这样更加节省缓存空间,而要等商品秒杀结束才删除?
这种方式下,redis中存在的订单号就是目前存在于消息队列中还未被处理的订单号,此时如果用户发起支付,在Redis中能查询到订单信息就需要等待,Redis中查不到信息则直接去数据库中查询,那么在高并发情况下,可能会存在消息已经写入数据库,但是还未来得及从redis中删除,导致增加了用户支付的等待时间。并且在下单高峰期时,应尽量减少对redis的访问操作以提高下单的处理性能。
思路2:redis中缓存暂时处于消息队列中未被消费的订单号
具体实现:redis判断用户具有购买资格后,将订单号缓存在redis中,Kafka消费者在完成创建订单并写入数据库后,将对应的订单信息从redis中删除,那么redis中缓存的订单号一定是不存在于数据库中的,所以必须等待,如果redis中没有对应的订单号,说明该订单信息已经写入数据库中,直接查询数据库即可。
这种方法的优势是,在订单过期、用户取消订单等场景中可以有效避免数据不一致的问题。
异步创建订单优化思路
在Kafka消费者的处理代码中,消费者逐个取出消息并处理,虽然kafka消费者会复用之前的线程,节省了创建线程和销毁线程的开销,但是整个过程是一个串行化的方式,效率和吞吐量都较低,而且每次写入数据时都需要创建新的连接。这里可以使用线程池和连接池进行优化。在代码实现中只实现了线程池,因为面试中线程池考察比较频繁,连接池没什么好考的,具体使用就是引入依赖加配置就可以自动使用连接池了。
连接池的实现:先引入依赖HikariCP,然后在yml文件中加入以下配置即可实现
spring:
datasource:
hikari:
jdbc-url: jdbc:mysql://localhost:3306/yourdatabase
username: yourusername
password: yourpassword
driver-class-name: com.mysql.cj.jdbc.Driver
maximum-pool-size: 10
不使用线程池的缺点:
频繁的线程创建和销毁造成系统资源浪费,每次处理任务时都创建和销毁线程,这会带来显著的性能开销,在高并发场景下会消耗过多的内存和CPU时间;线程的上下文切换也会增加CPU的负担。
使用线程池优点:
- 提高资源利用率:线程池重用线程来处理多个任务,避免了频繁创建和销毁线程的开销,提高了资源利用率,减少了内存和CPU的浪费
- 控制并发量:
- 受控的线程数量:通过配置线程池的核心线程数、最大线程数和队列容量,可以有效地控制并发量,避免系统过载。例如,可以设置线程池的最大线程数为20,确保同时处理的下单请求不会超过20个,从而保护系统资源。
- 保护关键资源:通过限制并发线程的数量,可以保护关键资源(如数据库连接池)不被耗尽,确保系统的稳定性。
- 提高响应速度:
- 减少等待时间:当有新的下单请求到来时,如果线程池中有空闲线程,任务可以立即得到处理,而不需要等待新线程的创建。例如,用户在下单时,可以更快地得到响应,提升用户体验。
- 快速响应:线程池可以显著提高系统的响应速度,确保在高并发场景下,用户的下单请求能够及时处理。
- 便于监控和调优:
- 监控指标:使用线程池可以方便地监控线程的使用情况,如线程池的大小、活跃线程数、任务队列长度等。例如,可以通过监控工具实时查看线程池的状态,及时发现潜在的性能瓶颈。
- 及时调优:通过监控指标,可以及时发现性能瓶颈,并进行相应的调优。例如,如果发现线程池的任务队列长度经常达到上限,可以考虑增加线程池的大小或优化任务处理逻辑,以提高系统的处理能力。
其他相关细节:
消息确认机制:在KafkaListener中处理消息后,可以向Kafka发送确认信号,确保消息不会丢失。
监控和报警:增加监控和报警机制,及时发现和处理系统异常。
问题:从kafka收到的批量消息还未处理服务器挂了导致消息丢失怎么办
订单信息写入数据库后才会被从redis中清除,所以未写入数据库中的订单信息会持续存在于redis中。
问题:消费者数量如何设置?
Kafka 主题的分区数量决定了并行消费的最大程度,消费者数量不应超过分区数量,否则多余的消费者将处于空闲状态。Kafka 的每个分区只能被一个消费者线程消费,因此消费者线程的数量应该与分区数量相等。
问题:如何保证消息队列中消息的顺序性?
下单模块自测面试题
- Redis中保存的商品信息什么时候删除?
Redis中保存的优惠券信息主要包括优惠券的库存、秒杀开始和结束时间、限购数量这些商品信息,还有用户的订单号。这些信息需要定时任务来添加和删除,例如可以使用SpringTask来实现。在商家上架秒杀商品时需要校验,秒杀商品至少提前一个小时上架,上架时设置两个定时任务,第一个定时任务在秒杀开始之前半个小时开启任务将库存等信息缓存到redis中,第二个定时任务在秒杀结束之后半小时开启,将库存信息、订单号信息从Redis中删除。
- 为什么要缓存商品库存信息?为什么又要手动删除这些信息?
1.缓存商品库存信息是因为在秒杀优惠券的下单流程中,将库存、限购等校验步骤都放在了redis中进行,所以需要将这些信息全部提前缓存到redis中。2.需要删除这些信息是因为这些信息在存入redis时没有设置过期时间,如果不主动删除会一直占用内存,直到redis占满才会被动清理,大量的商品信息不适合永久存储在Redis中,因此需要手动清理,并且可以节省缓存空间。
- 为什么要缓存订单号?
缓存订单号信息是为了防止用户支付请求发起时,订单信息还没有写入到数据库中。
- 如果用户支付时订单还未写入数据库怎么办?
这地方有两种处理思路,分别对应支付流程中的两个思路。
思路1:redis中缓存秒杀活动期间秒杀类目下的订单号,即对于秒杀商品的订单,只要活动还在持续中,订单信息就会一直存在于redis中,那么对于用户的一个订单支付请求,先查询redis中是否存在此订单信息,如果存在则需要去mysql中查询,如果mysql中不存在该订单则需要等待,如果查询到了订单信息则开始支付流程。如果Redis中不存在该订单信息,说明该订单不属于秒杀类商品,则直接查询数据库,如果数据库中仍不存在,说明不存在该订单,返回错误信息,如果数据库中存在订单信息则可以继续进行支付流程。
换一种说法:支付前的查询流程如下:支付请求发起之后,后端会先查询redis中是否存在本次支付的订单号,通过redis的查询可以判断出本次的订单属于秒杀类型还是普通和限购类型的订单,下面就走两个不同的逻辑流程。普通和限购类型的订单直接走常规的查询和支付流程,对于秒杀类型订单,如果redis中存在则再去查询数据库中订单相关的信息,如支付金额、付款方式等,如果redis中存在信息而数据库中不存在相关订单,则说明此时订单还处于消息队列中未被处理,则需要等待,直到查询到信息后进入支付流程。
思路2:redis中缓存暂时处于消息队列中未被消费的订单号,对于这种实现方式,Kafka消费者在成功将订单信息写入数据库之后需要从Redis中删除订单信息。用户发起支付请求,先查询redis中是否存在该订单信息,如果存在该订单信息,则需要等待,如果不存在该订单信息,则说明该订单已经被写入数据库中,或者订单并不存在,查询数据库如果订单存在则正常支付,如果订单不存在则返回错误信息。
- 秒杀的这个功能用分布式锁做什么?分布式锁锁的是什么?加分布式锁的目的是什么?用什么加的分布式锁?
两种答题思路:
分布式锁的目的是限购,锁的是用户id,key包含用户id防止短时间内重复下单。防止库存超卖使用乐观锁实现,扣减库存的语句中加入检查库存的条件。
分布式锁的目的是限购和防止库存超卖,锁的是商品id,key包含商品id,表示对数据库中库存修改的权限。限购功能是检查数据库中该用户已购买数量判定是否具有购买资格来实现的。
- 秒杀功能中,数据存储在redis和mysql中,如何保证数据一致性?假如redis写入成功,但是mysql写入失败怎么办?
秒杀功能中,库存预先在redis中扣减,然后再去扣减mysql的库存,这一过程中,如果发生了redis库存扣减失败的场情况,就不会去尝试扣减mysql的库存了,直接返回请求失败,如果发生了redis数据更新成功而mysql数据更新失败的场景就会进行多次尝试,这里mysql数据更新失败也会有一些不同的情况,例如由于网络问题导致更新失败,那么经过多次的重试操作则会更新成功,假如是由于一些mysql内部错误或者是一些未知错误,这种情况可能会导致多次的重试操作也不会更新成功,这种情况下在重试次数达到上限时,会对redis中的数据进行回滚,以此来保证数据的最终一致性。
点赞功能设计
- 文章模块主要实现了笔记发布、文章点赞等功能,并基于文章点赞和阅读量等指标,运用KafkaStream异步削峰,减轻数据库压力,同时进行实时热点评估,将热点笔记缓存在Redis中以加速访问;
- 优化文章数据表的设计,设置文章信息表和文章内容表,将文章属性信息和文章内容信息解耦,加速多种场景下的访问速度。
以上是最初的计划,在实施的过程中发现,表的设计较为复杂,无论如何设计都不能够满足多种业务场景下的需求,于是将点赞功能抽离出来,这里将复杂问题简单化,只实现一个最简单的点赞功能。
点赞功能与收藏功能基本一致,实现点赞功能后,收藏功能理论上基本可以同步实现,为了简化设计,这里不考虑收藏功能。还有和点赞数相类似的功能,收藏数、关注数、粉丝数、浏览量、评论数等等,这里都不作考虑。
项目整体架构图
图中的✔表示在代码中该功能已实现,列表查询相关功能未实现,这个功能本质属于分页查询,思路为先查redis,若不存在查询mysql。主要是理清思路。
文章表设计
文章信息表:文章id,作者id,文章标题,文章封面图片地址,文章状态,发布时间,更新时间
文章内容表:文章id,图片数量,文章内容,图片路径
生成一些文章和点赞数据
暂时无法在飞书文档外展示此内容
文章表的设计,信息与内容分离,加速不同业务场景下的查询和访问速度,降低单一数据表的并发量
点赞数据库设计
https://cloud.tencent.com/developer/user/7762290
3张表
点赞行为记录表 - likes:主键、文章id、用户id、点赞类型(1–点赞,0–取消点赞)、操作时间
并且在文章id和用户id上建立联合索引用于点赞状态和文章点赞列表的查询,在用户id建立索引用于用户点赞列表的查询。为什么要设计成取消赞添加数据,为什么不删除之前的点赞记录呢?相当于一种日志的作用,便于监控和记录,删数据涉及索引树的修改,有可能会涉及到大量的数据的磁盘移动,造成性能低下
文章获赞总数表 - article_counts:文章id、获赞总数
用户获赞总数表 - user_counts:用户id、获赞总数
数据库表主键的设计:雪花算法
雪花算法的ID结构
- 符号位(1位):
- 最高位是符号位,始终为0,表示正数。
- 时间戳(41位):
- 记录时间戳,单位是毫秒。41位的长度可以表示约69年的时间跨度。
- 时间戳的值是当前时间与一个固定的起始时间(通常是系统的某个启动时间)之间的差值。
- 机器标识(10位):具体不一定是10位,根据实际的应用场景选择不同的位数
- 用于标识不同的机器或节点。10位可以表示1024个不同的节点(0到1023)。
- 机器标识通常由数据中心ID(5位)和机器ID(5位)组成。
- 序列号(12位):
- 用于在同一毫秒内生成不同的ID。12位可以表示4096个不同的序列号(0到4095)。
- 当同一毫秒内生成的ID超过4096个时,需要等待下一毫秒。
点赞服务层
主要提供六个服务,对应四个DTO
- 查询某文章获赞总数,不对用户暴露接口,用户查询文章详情时,由文章服务调用,返回ArticleVO时包含文章获赞总数,由文章服务调用点赞服务的接口获取数据后返回;
- 查询某用户获赞总数,不对用户暴露接口,用户查询某用户主页时,由用户服务调用,返回UserVO时包含用户获赞总数,由用户服务调用点赞服务的接口获取数据后返回;
- 点赞和取消赞的用户行为,直接对用户暴露的接口服务,点赞或取消赞,请求方式POST,请求体LikeBehaviorDTO:“artichelId”:“qwer1”,“type”:1
- 查询点赞状态,不对用户暴露接口,用户查询文章详情时,由文章服务调用,返回ArticleVO时包含点赞状态,true或者False,由文章服务调用点赞服务的接口获取数据后返回;
- 查询某文章点赞列表,直接对用户暴露的接口服务,请求方式POST,请求体"artichelId":“qwer1”,LikeArticleDTO
- 查询某用户点赞列表,直接对用户暴露的接口服务,请求方式GET,无路径参数。
异步任务层
使用Kafka来实现。
- 缓存更新模块:每当有数据更新时,都需要对缓存和数据库进行更新,数据更新步骤异步执行
- 计数汇总更新:点赞业务量很大,不可能每有一次点赞都对数据库进行一次操作,这个功能用于实现每隔一段时间批量更新数据,例如每隔5s批量更新一次数据库
- 点赞列表更新:属于缓存更新的一部分,有数据变动时需要进行更新
- 点赞人列表更新:同上
- 异步消息推送(原计划,后取消,暂未实现):当用户A给文章B点赞后,需要给文章B的作者用户B推送消息通知,这个步骤需要调取其他服务查询文章作者信息后完成,在点赞模块里不需要聚焦功能的实现,而是实现将消息发送到其他模块的接口即可
- 数据一致性检查(原计划,后取消,暂未实现):由于数据存放在多级缓存、多个数据库中,数据不一致的现象普遍,这里定期执行数据不一致的检查以确保数据的最终一致性
- 热点本地缓存:这里需要异步拉取其他模块提供的服务,由其他模块通过算法得出属于非常热点的文章,然后将这一部分文章存储到服务器本地缓存中,减少热点文章对Redis的查询次数
- 废弃数据清理:在点赞行为记录表中,用户每进行一次操作就会有一次记录,假如一个用户对同一篇文章进行多次点赞和取消赞的操作,那么其实前面大量的数据都是无用的数据,对这些数据进行清理可以节省存储空间,加快查询速度
- 数据库写入:指的是异步写入数据库,属于异步任务层的基本功能,消费者读取消息后,将点赞消息写入到数据库的三张表中,同时需要确保数据一致性,某一张表写入失败时需要多次重试或数据回滚
- 告警系统:三张表的数据写入需要确保数据一致性,通常情况下多表写入使用事务保证一致性,但是这里的实现不太相同,因为这里是先将点赞记录写入数据库,然后流式统计所有点赞数据一起写入,所以这里不能使用事务确保一致性,因此添加了告警系统确保多表的数据一致性,如果发生数据写入失败的情况,由告警系统实现数据回滚
数据存储层
- 本地缓存(localcache):只用于存储查询频率非常高的数据,目的是为了应对缓存热点问题,这里只用于存储文章和用户的获赞总数,利用最小堆算法,在可配置的时间窗口范围内,统计出访问最频繁的缓存Key,并将热Key(Value)按照业务可接受的TTL存储在本地内存中。热点发现:https://mp.weixin.qq.com/s/C8CI-1DDiQ4BC_LaMaeDBg
使用一种类似于布隆过滤器的算法,但是每次命中不是置为1,而是+1,同时HeavyKeeper算法还使用衰减函数减少了哈希冲突带来的负面影响,思想是保留了哈希指纹进行比对,指纹不符时数值衰减,同时使用最小堆维护top k,当一个变化的数大于最小堆堆顶元素时直接替换,最后最小堆中维护的k个元素就是top k了。
本地缓存使用caffeine技术,只缓存了热点文章的获赞总数和热点用户的获赞总数。
- Redis:用于保存所有可能查询到的数据,只要用户查询到的数据不存在于redis中,都会先去mysql中查然后存储到Redis中
使用zset保存点赞列表,时间戳作为score,同时有裁剪措施,将长度保持在200以内,保证不会无限增长。
使用布隆过滤器过滤点赞请求,命中布隆过滤器的则需要再次查询redis的zset或DB,未命中的直接进入下一流程,其中取消点赞时不会对布隆过滤器进行更改,1-0-1的操作一定会造成误判。
- mysql:数据持久化层,负责整体数据的持久化保存,以及提供缓存失效时的回源查询能力。
此架构后期如何拓展
- 做成微服务,加上网关鉴权,用户请求可以直接打到点赞微服务中;
- 加上路由层,可以分机房,防止机房宕机;
- mysql、kafka、redis建立集群,主从分离,防止中间件宕机。
点赞功能业务逻辑
点赞状态查询
功能描述:文章业务请求获取某用户对某文章的点赞状态。
暂时无法在飞书文档外展示此内容
点赞行为
功能描述:用户对文章点赞或取消点赞
暂时无法在飞书文档外展示此内容
业务分析:这个功能需要和点赞状态查询功能放在一起分析。
在用户点赞行为写入数据库之前需要对当前用户的点赞状态进行查询,保证数据的正确性。先分析点赞状态查询这个功能。根据推荐算法的特点,用户浏览的推荐文章不会重复,所以用户浏览到的文章基本上都是没有看过的,那也就是没有点过赞的,对于没有点过赞的文章,redis中不会有记录,所以每次查询都会落到数据库上,但是这个点赞状态查询被高频次调用,严重增加数据库压力。所以这里引入一个布隆过滤器。布隆过滤器的特点是能够过滤掉绝大多数db中不存在的请求,这样就可以缓解数据库的压力。所以点赞状态查询的流程为,先查布隆过滤器,如果不存在直接返回false,如果存在则需要再次查询zset和db。
对于点赞功能分为两种情况,分别是点赞和取消点赞,在数据写入之前需要查询点赞状态。在保证数据正确性的前提下,如果是点赞则需要更新布隆过滤器、zset和db,如果是取消点赞则更新zset和db,这里zset有两种思路,一为取消点赞则删除点赞记录,二为取消点赞则记录点赞状态,二者各有利弊。
查询点赞列表
分页查询用户点赞列表、分页查询文章点赞列表
暂时无法在飞书文档外展示此内容
key - value - score = userId - articleId - timestamp,当某用户有新的点赞操作的时候,被点赞的文章则会通过 zadd的方式把最新的点赞记录加入到该ZSet里面来,为了维持用户点赞列表的长度(不至于无限扩张),需要在每一次加入新的点赞记录的时候,按照固定长度裁剪用户的点赞记录缓存,超过该长度的数据请求需要回源DB查询。文章点赞列表同理。
查询累计获赞总数
文章业务请求查询批量文章的总获赞数、用户业务请求查询批量用户的总获赞数
暂时无法在飞书文档外展示此内容
这里涉及到了本地缓存Caffeine,对于热点文章和热点用户,浏览量可能会非常高,具体是哪些文章和用户,这个需要依靠热点识别机制来提供。点赞模块拉取热点信息后,将计数缓存到本地,可以减少redis的访问次数,加快查询,提高性能。
获赞消息异步推送
功能描述:用户的文章获赞后,负责给文章的作者异步推送被点赞的消息
实现思路1:用户登录时建立websocket连接,实现服务器主动推送消息,点赞消息存储于数据库中实现持久化,当用户不在线时消息会先存储到服务器中
实现思路2:用户的点赞数据先存储到服务器中,客户端主动发起请求,主动读取获赞消息,呈现出消息推送的形式,实际上是主动请求
废弃数据清理
功能描述:
- 多次点赞和取消赞的操作,删除掉之前多次的无用操作,节省数据库存储空间
- 更新布隆过滤器
DROP TABLE IF EXISTS `latest_like_behavior`;
CREATE TABLE latest_like_behavior AS
WITH ranked_likes AS (
SELECT
behavior_id,
article_id,
user_id,
type,
time,
ROW_NUMBER() OVER (PARTITION BY article_id, user_id ORDER BY time DESC) AS rn
FROM
like_behavior
)
SELECT
behavior_id,
article_id,
user_id,
type,
time
FROM
ranked_likes
WHERE
rn = 1 AND type = 1
ORDER BY
time;
这个SQL语句的工作原理如下:
- 创建新表:
CREATE TABLE latest_like_behavior AS
表示要创建一个名为latest_like_behavior
的新表,其内容将基于后续的查询结果。 - 使用WITH子句定义临时视图:
WITH ranked_likes AS (...)
定义了一个名为ranked_likes
的临时视图。这个视图包含了like_behavior
表中所有列的数据,并添加了一个额外的列rn
。 - 计算行号:在
ranked_likes
视图中,使用ROW_NUMBER() OVER (PARTITION BY article_id, user_id ORDER BY time DESC) AS rn
计算每个用户对每篇文章的“喜欢”行为的行号。PARTITION BY article_id, user_id
表示窗口函数将为每个用户对每篇文章的行为分配行号。ORDER BY time DESC
表示在每个分区(即每篇文章和用户组合)内,行号是根据time
列降序排列分配的。这样,最新的行为将被分配行号1。 - 选择最新的“喜欢”行为:在主查询中,从
ranked_likes
视图选择behavior_id
,article_id
,user_id
,type
, 和time
列,但只选择那些行号rn = 1
的点赞记录,即每个用户对每篇文章的最新一次点赞行为。
这样就可以在新表latest_likes_log
中只保留每个article_id
和user_id
组合中时间戳最大的那一项数据。
然后根据新表更新布隆过滤器。
点赞模块自测面试题
介绍一下点赞模块的架构
问:点赞架构包含哪些部分,每一个部分实现了哪些功能?
架构包含了三层,点赞服务层,用于提供统一的RPC(Remote Procedure Call)接口,异步任务层,主要用于服务的解耦、流量的削峰,以及数据层,主要用于数据的缓存和持久化。
点赞服务层提供点赞行为接口、点赞状态查询、点赞列表查询、获赞总数查询的RPC服务。针对重点接口都设计降级措施,都设置兜底数据作为备选方案。
异步任务层主要由Kafka和定时任务组成,Kafka主要用于接收现网流量,异步削峰,控制并发量,并且可以对数据进行初步的处理,大量减少数据库的写入次数,用户点赞数据的写入都要经过Kafka异步调度,还可以实现为下游服务(客户端)发送点赞消息等功能。定时任务包括定期执行持久层数据清理、拉取热点信息本地缓存,数据清理主要针对同一用户对同一文章多次点赞和取消赞的操作记录,清理数据完成后,还可以对布隆过滤器进行重置。
数据层又分为三层,分别为本地缓存、redis缓存和数据库。第一层存储,数据库DB层,有三张表,分别为点赞行为记录表,用户和文章的点赞计数表,这些表负责数据的持久化保存,以及提供缓存失效时的回源查询能力。第二层存储,redis缓存,可以用于存储所有的查询数据,包括点赞列表、状态、计数。第三层存储为本地缓存,使用caffeine技术,本地缓存的建立是为了应对缓存热点问题,将热点key按照业务可接受的TTL存储在本地缓存中。
点赞模块的架构每个方面具体是如何设计的
- 背景、业务需求、满足业务需求时需要注意的问题
在这个平台上面,用户可以发布探店笔记,其他的用户可以浏览笔记的内容,也可以对笔记点赞,这个业务类似于小红书、微博这类平台。
与点赞相关的业务包括:
点赞的行为,这个行为分为两种,分别是点赞和取消点赞,这个地方需要注意同一个用户对同一篇笔记不能有连续两次相同的操作,在这个限制中包含了点赞状态查询;
查询某一个用户或者某一篇文章获赞的总数量,打开一个用户的主页时需要查询这个用户所有笔记的获赞数量的总和,打开一篇笔记时需要查询这篇笔记获赞的总数量;
批量查询文章的获赞总数量,在客户端用户浏览笔记时往往时同时出现多个笔记,在笔记的下方通常会显示点赞的数量,而用户端的首页往往会出现推荐页面,每一个页面中需要批量查询文章获赞总数,注意点:这个功能本质上就是查询文章获赞总数功能,但是由于首页推荐的存在,这个功能会导致查询文章获赞总数这个接口被大量高频次调用,基本是并发量最大的一个接口了;
查询某个用户的点赞列表,查询某篇文章的点赞列表,这两个功能基本一致,用户点赞某些文章后可能会再次查看点赞过的文章列表,文章的作者也可能会查询某篇文章点赞过的访客列表,注意点:这个列表的查看需要按照点赞的时间倒序排序;
这个点赞架构主要包含了以上几个业务功能。
- 数据库是怎么设计的?为什么这么设计?遇到了哪些问题,怎么解决的?
与点赞相关的数据表有三个,分别为点赞表,文章获赞总数表,用户获赞总数表。
点赞行为记录表 - like_behavior:主键、文章id、用户id、点赞类型(1–点赞,0–取消点赞)、操作时间
文章获赞总数表 - article_count:文章id、获赞总数
用户获赞总数表 - user_count:用户id、获赞总数
对于点赞这个功能来说,并发量非常大,数据量也很多。对于点赞行为的记录表,点赞的行为随机性很大,文章id和作者id出现在不同的位置且没有规律,而这两个键也不是主键,当需要查询获赞总数时,由于数据库使用的数据结构为B+树,使用sql的count则需要遍历整个表,速度很慢,而且获赞总数的查询是一个调用频次很高的查询,因此需要单独存储在一张表中用于快速查询。
- 数据量过大之后,点赞记录表如何进行分库分表?
通常情况下,根据推荐算法的特点,新的文章才会有更多浏览量,而年代较为久远的文章一般不会有太多的流量,因此可以按照文章发布时间进行分库分表。冷数据放在数据库中进行存储,新数据放在数据库集群中,主从架构,读写分离以应对较高的QPS,提高并发能力。
- 架构设计时需要注意哪些问题
作为被用户强感知的功能,需要考虑各种情况下的系统容灾能力,例如:
数据一致性问题(数据同步延迟问题):
DB不可用:本架构使用三层存储,当DB不可用时,需要依托本地缓存和redis尽可能提供服务;
Redis不可用:当redis不可用时,DB要保证自己不宕机的情况下尽可能提供服务,必要时可采取服务熔断和降级等措施,控制并发量在DB能够承受的范围内;
消息队列不可用:当Kafka不可用时,可以通过RPC调用的方式对服务进行降级,控制并发量,保证DB能够正常提供服务
消息队列堆积:可以使用Kafka对点赞消息进行初步处理,通过线程池增加消息处理的并发能力,通过连接池减少建立连接所需要的时间
服务器不可用:切换机房
- 点赞系统需要承载哪些压力
流量压力:包含全局流量压力和热点流量压力。
全局流量压力指的是整个架构提供的所有服务承受的流量,热点流量压力指的是热门笔记带来的单点流量全部涌入到单个分片上,如单个服务器、redis。
这里的流量分为读流量(查询)和写流量(点赞),针对读流量设计redis和localcache缓存,应对大部分的读流量,通过拉取热点识别机制提供信息,将数据缓存到本地,并设置合理的TTL,缓解热点流量的单点压力。针对写流量,①为了保证数据写入的性能,在【点赞总数】写入数据库时做了聚合写入,例如每5秒聚合一次点赞总数,一次性写入,大量减少数据库的IO次数;②对于其他数据的写入做了全面的异步化处理,控制并发量,保证数据库能力合理的速率处理写入请求;③为了保证点赞状态的正确性,每一次写入点赞数据之前都需要检查之前的点赞状态作为更新的依据。
数据存储压力
点赞存储量非常大,MySQL的结构化存储模式已经不太能够满足千亿级别的存储要求了,需要改进存储方式,实现KV化存储,这是目前努力的方向。
未知灾难
DB宕机、Redis集群抖动、机房故障、网络故障
在点评项目中遇到了什么难题,你是怎么解决的?
遇到的难题是暴露给用户的点赞行为这个接口的设计。
问题的难点有以下几个方面:点赞行为记录表的设计、点赞状态查询压力大
用户点赞行为记录表的设计,这个表需要满足点赞状态查询、用户点赞列表查询、文章点赞列表查询这几个功能的快速查询。首先点赞行为表的设计是主键,文章ID,用户ID,行为类型和时间戳,行为类型用1 0 表示点赞和取消点赞,用户每操作一次点赞或取消点赞,都会向表中新增一条记录。为了实现快速查询,我使用了mysql的联合索引来解决,其中主键为聚簇索引,在用户ID这一列上建立非聚簇索引用于快速查询用户点赞列表,在文章ID、用户ID上面建立了联合索引,用于快速查询点赞状态和文章点赞列表。
将用户点赞行为写入到数据库表之前,需要查询用户的点赞状态确保数据的正确性,但是大多数点赞场景下,用户点赞一篇文章都是第一次看到这篇文章,redis没有关于该用户点赞该文章的记录,即点赞行为记录之前需要查询一条不存在的数据,所以这个查询会落到数据库上,会给数据造成较大压力。对于查询一条不存在的数据,项目中使用了布隆过滤器来减少对数据库的访问次数,点赞状态的查询接口的压力。
为什么会想到使用布隆过滤器来解决这个问题?
这里的场景就是查询一条数据库不存在的数据,导致这个查询一定会落到DB上,而刚好布隆过滤器的特点就是可以过滤掉绝大多数不存在于数据库中的数据查询,所以使用过布隆过滤器符合当前的应用场景。
--快速查询点赞状态
SELECT type FROM likes
WHERE article_id = ?
AND user_id = ?
ORDER BY time DESC
LIMIT 1;
如何保证购物场景中库存数据一致性?
这里可以讨论两个方面,分别是mysql和redis的数据一致性和实际下单操作发送给消息队列后导致可能存在的数据不一致问题。
先讨论mysql和redis数据不一致的问题。购物场景拆分为三种,分别为普通、限购和秒杀,对于普通和限购这两种场景下,redis中不存在库存相关的信息,因此不涉及数据不一致的问题,主要是在秒杀场景中,这一场景中的商品库存存储在redis中,用户购买资格、订单生成步骤均在redis中完成,如果用户具有购买资格,则会直接在redis中扣减库存,然后发送订单相关的消息给Kafka消息队列,mysql中库存的扣减则需要在消息队列消费者的执行逻辑中执行,以此实现最终一致性。
另外,对于redis宕机和消息队列宕机的情形,需要先消费完已经生成的订单信息,然后再使用mysql数据库处理接下来的请求。基本思路为,对于redis已经生成的订单但是还未写入mysql数据库中的订单信息其实是存在于redis和消息队列中的,这两个地方存储的都是相同的数据,即已经确定要生成的订单但是对应数据还未写入数据库中,假如其中的某个中间件发生宕机的情形,则需要先将这部分导致数据不一致的订单信息处理完毕后,再接收并处理新的请求。
redis宕机:假如在秒杀过程中,redis宕机该如何处理?redis宕机时,消息队列中可能还存在未被消费的订单消息,只有这些消息消费完成之后,mysql和redis宕机之前的数据保持一致。消费完成之后,后续的订单请求直接由mysql数据库完成,不使用redis,这样可能会造成性能的下降,属于兜底策略,但是这样处理可以保证数据的最终一致性,不会导致库存超卖,先生成的订单同样有效。
Kafka宕机:假如在秒杀过程中,Kafka宕机该如何处理?在秒杀的业务代码中,为了保证支付时可能会遇到的网络延迟问题,已经提前将订单信息存放在redis中了。而这一部分的订单其实是未写入数据库中的,在接收新的请求之前需要将这一部分的订单信息写入数据库中之后,再接收新的请求。消息队列的本质作用是异步削峰,假如消息队列宕机,后续新的请求只能直接操作数据库而不是发送给消息队列,这样可能会造成数据库较大压力,这里可以做一些熔断和降级的处理逻辑。
如何保证点赞数据在mysql和redis的一致性?
点赞数据的特点是,对数据一致性的要求不是很高,因此只需要保证数据的最终一致性即可。
保证数据一致性的常见做法:
①先更新数据库,再更新缓存;
②先删缓存,再更新数据库,脏数据概率低;
③先删缓存,再更新数据库,再(延迟)删除redis防止脏数据;
④先删除数据库,再删除缓存;
⑤…
方案选择及原因:
各种保证一致性的方案,基本思想都是需要以mysql的数据为准,将Redis中的数据保持与数据库中一致,然后再使用不同的方法减少脏数据的可能性。这里点赞数据采用①,即先更新数据库,再更新缓存,这种方案会导致脏数据的存在,而且现象非常普遍。但是这里采用的就是这种方案,采用这个方案的原因:点赞数据一致性要求不高,对脏数据的容忍度较高,在高并发场景下,点赞属于高读高写数据,为了提升读的性能,脏数据存在于redis可以提高读的性能,并且这种数据对用户的体验影响不大。
场景分析:
用户的一个点赞请求转发到后端服务器之后,后端服务器只将这个请求发送到Kafka消息队列中,发送成功后就会给前端返回成功,真正的数据写入是由消息队列来完成的。消息队列的消费者首先同步尝试将这个数据写入数据库中,然后再将这个数据写入redis中以保证数据一致性。如果mysql写入异常则会多次重试,尝试多次仍失败后则会放弃写入,即点赞失败,同时发送点赞失败的消息给上层。如果数据库写入成功,但是redis写入失败,这时也需要进行一些重试,如果多次重试之后仍然失败,可以直接删除redis中的相关数据,在下一次有相关的读取请求时,再将相关数据从MySQL中读取到redis来保证数据的最终一致性。基本思想为,写入mysql成功即为操作成功,最终的一致性都由mysql来进行兜底。
如何保证多表中点赞数据一致性?
上述讨论是基于数据库和redis的数据一致性进行的讨论,这个讨论有一个前提就是数据库需要保证一致性。这个要归根于数据库表的设计,由于每次用户点赞之后本质上都需要对三张数据表进行更新,理论上可以使用事务来保证多表更新的一致性,但是这里为了提高性能,点赞记录表实时更新,而计数表是周期性更新的,从而导致数据库的更新不能使用事务来保证一致性。那么这里的解决方案就是采用手动实现事务的思想。当后端接收一个点赞请求后,后端会发送消息到消息队列,由消息队列消费者去更新点赞记录表,并将这个点赞的计数数据进行更新,在下一个周期将数据写入到计数表中。但是如果记录表更新失败,则会给Kafka发送一个点赞记录更新失败的消息,Kafka接收到这个消息后,需要对计数相关的数据进行回滚操作即可。这里的消息处理具有较大的滞后性,但是保证了数据的最终一致性。
使用Kafka如何保证点赞的顺序性?
本项目中使用的消息队列为Kafka,这里最好先了解Kafka相关的基础原理。
Kafka相关基础:
Kafka体系结构中主要分为三个部分,生产者、消费者、broker。对于 Kafka 而言,Broker 可以简单地看作一个独立的 Kafka 服务节点或 Kafka 服务实例。大多数情况下也可以将 Broker 看作一台 Kafka 服务器。
另外两个概念:主题(Topic)与分区(Partition)。Kafka中的消息以主题为单位进行归类,生产者负责将消息发送到指定主题,消费者订阅主题并进行消费。
一个主题可以细分为多个分区,一个分区只属于单个主题,因此分区又名为主题分区(topic - partition)。同一主题下不同分区内包含的消息是不同的。
offset是消息在每个分区内的唯一标识,是一个单调递增且不变的值,Kafka使用offset来保证消息在分区内的顺序性。因此Kafka保证的消息有序性指的是分区内有序而不是主题有序。
场景分析:
对于如何保证点赞消息的顺序性这个问题需要考虑的场景:对于一个用户对同一篇文章的点赞操作需要保证顺序性,必须是点赞-取消-点赞-取消…这样的顺序,假如顺序错乱则会导致后续的所有操作失败。
根据Kafka的工作原理,假如点赞行为这个主题下只有一个分区,那么依靠Kafka本身的性质,它自己就可以实现消息的有序性,其实现原理是使用offset来标记消息的位置,且严格单调递增,先到达的消息将会被先处理,以此来实现消息的顺序处理。
假如在这个主题下存在多个分区,那么就可能会存在一些极端情况。例如有两个分区,用户对一篇文章进行了点赞和取消点赞,这两个消息分别发送到了不同的分区,但是由于各种原因导致取消点赞先处理,点赞后处理,在这种场景下就会发生两次操作均失败的情况。要解决这个问题可以引入Kafka的分区策略,采用按消息键保序策略。为了保证上述场景的操作不会失败,这里可以将用户ID作为消息的键,同一个用户的操作将会被发送到相同的分区,这样就可以避免上述问题。
Kafka三种常见的分区策略介绍:
轮询、随机、按消息键保序,轮询和随机省略。Kafka 允许为每条消息定义消息键,简称为 Key。一旦消息被定义了 Key,就可以保证同一个 Key 的所有消息都进入到相同的分区里面,由于每个分区下的消息处理都是有顺序的,故这个策略被称为按消息键保序策略。
在秒杀场景中,如果用户下单后,redis判断具有购买资格返回成功后,Kafka消费者将订单信息写入数据库时发生失败如何处理?
问题的背景为,用户发起一个请求到后端,后端经过一系列的逻辑后认为本次的操作是有效的,然后将这个消息发送到消息队列中,具体的写入数据库的操作由消息队列的消费者来完成,然后后端就会给前端返回成功。但是如果消息队列的消费者在写入数据库时发生了失败,这时就需要对数据进行回滚,同时还需要保证数据一致性的问题,即redis中存储的数据和mysql中的数据需要保证最终一致性。
对于用户秒杀优惠券的逻辑来说,当发生订单写入失败后需要删除redis中已经写入的订单信息、库存信息、限购信息,这些数据都存储在Redis中,那么将数据删除时也需要保证删除操作的原子性,因此可以使用lua脚本来完成这一系列的数据回滚操作,由于数据库中写入失败,所以不需要对数据库做任何操作。
具体的实现逻辑是,Kafka消费者在进行了多次写入数据库的尝试且失败后,将这个订单的信息重新发送给Kafka的另一个主题中,例如保存数据失败主题,该主题下的消费者负责将Redis中的数据进行恢复,使用lua脚本执行以上逻辑。lua脚本示例如下:
local orderId = tonumber(ARGV[1])
local voucherId = tonumber(ARGV[2])
local buyNumber = tonumber(ARGV[3])
local userId = tonumber(ARGV[4])
local stockKey = "seckill:stock:" .. voucherId
local orderSetKey = "seckill:order" -- 假设订单存储在名为seckill:orders的Set中
local buyCountKey = "seckill:buyCount:" .. voucherId
-- 检查订单是否存在于Set中
if redis.call("SISMEMBER", orderSetKey, orderId) == 1 then
-- 订单存在,恢复库存
redis.call("INCRBY", stockKey, buyNumber)
-- 从Set中删除订单信息
redis.call("SREM", orderSetKey, orderId)
-- 减少用户在哈希结构中的购买数量
local currentBuyCount = tonumber(redis.call("HGET", buyCountKey, userId))
if currentBuyCount then
redis.call("HSET", buyCountKey, userId, currentBuyCount - buyNumber)
end
return 1 -- 表示成功
else
return 0 -- 表示订单不存在
end
在点赞场景中,如果用户点赞返回成功,但是在Kafka消费者将点赞记录写入数据库时发生失败如何处理?
处理逻辑和订单问题基本一致,只是在逻辑上稍有区别。点赞数据分别存储在三个表中,如果是点赞行为记录表添加失败,则意味着这个点赞的行为失败,那么需要回滚的数据包括数据库中的用户获赞计数、文章获赞计数,redis中的用户获赞计数、文章获赞计数,redis中的用户点赞列表、文章点赞列表,其中数据库的数据回滚不需要直接操作数据库,而是直接在流式数据处理中加入负值即可,redis中的四项数据的回滚也需要使用lua脚本进行原子性的操作。
因此点赞数据的回滚主要包括了两个方面,分为数据库和redis,数据库的操作为在流式处理中加入负值,redis中的数据处理使用lua脚本进行数据的删除,原理和订单的回滚保持一致