Skip to content

Latest commit

 

History

History
227 lines (141 loc) · 15.4 KB

前后端交互接口逻辑实现.md

File metadata and controls

227 lines (141 loc) · 15.4 KB

前后端交互接口逻辑实现

[TOC]

用户登录逻辑

请求url:/user/login

  1. 通过请求参数解析器获取用户请求参数,获取用户名和用户密码,判断用户是否存在(首先从缓存中获取该用户是否存在,如果用户存在,则从缓存中获取,如果不存在,则从db中获取,并将获取得到的用户信息存储到缓存中,以便下次获取用户信息时直接从缓存中获取);如果用户不存在,则向客户端发送用户不存在的消息,如果用户存在,进入2;
  2. 检验用户输入密码是否正确,如果用户输入密码不正确,则返回用户密码不正确的消息给客户端,否则,如果用户密码校验正确,进入3;
  3. 生成token,并存储在缓存中,这样,下次访问的时候,就可以从缓存中查询token,直接通过token校验用户是否合法,从而防止重复登录,token存储到缓存中后,进入4;
  4. 生成cookie对象,包装token,然后将cookie对象通过response返回给客户端,cookie有效期和缓存中的token有效期一致;
  5. 用户在cookie有效期内发出资源请求时,服务端从url或者set-cookie字段获取token信息,通过token从缓存中获取用户信息,如果获取的用户信息存在,则表明是同一个用户在访问,否则,如果用户不存在,则表名token不正确,请求非法;
  6. 从缓存中获取用户信息存在时,需要重新在缓存中设置一下该token,以达到记录最新访问时间,根据过期时间延长cookie有效期的目的。

登录过程总结:

对需要进行用户鉴权的访问,在controller层的方法上添加LoginVo参数,这样,用户请求都会使用自定义的参数解析器处理LoginVo,在处理的时候完成鉴权工作。

为了方式用户每次请求都从db中获取用户信息,在第一次登录成功的时候将用户信息从数据库中查询出来并缓存在redis中,并将其过期时间设置为0,即永久有效,那么,在往后该用户访问并需要获取用户信息的时候,就可以直接从缓存中获取用户信息,而不用反复从db中获取,从而减少不必要的请求落到db上。本身用户数据就属于不会经常变动却需要经常读取的数据,放在缓存中再合适不过了,不过,在用户数据更改的时候,需要考虑缓存和数据库的数据一致性。

用户数据通过两种方式缓存在redis中,一种是以phone为key,另一种是以token为key,缓存的数据都是用户信息,唯一不一样的是,它们的过期时间不同。token具有时效性,因此过期时间设置得比较短,为30min。而另一份以phone为key的用户信息永久保留在redis中,用户减少对db的访问。所以,两份数据的意义是不一样的,以token为key的用户信息缓存用户鉴权,而以phone为key的用户信息缓存用于查询。

redis中缓存通过用户手机号码获取的用户信息:

key: SkUserKeyPrefix:id_{phone}
value: {SeckillUser}
expire: 0

redis中通过缓存的token获取用户信息:

key: SkUserKeyPrefix:token_{token}
value: {SeckillUser}
expire: 30min

商品列表请求逻辑

商品列表请求url:goods/goodsList

  1. 首先从缓存中查询商品列表页面的html文件,如果存在,则直接返回给客户端,如果不存在,进入2;
  2. 从数据库中查询所有商品信息,然后通过thymeleafViewResolver渲染页面商品列表页面,通过将列表页面存储到缓存中,以便下次访问商品列表页面时直接从缓存中获取;

商品列表请求逻辑总结:

商品列表请求逻辑相对简单,只是在处理的时候,将商品列表模板渲染过程从自动变为手动,之所以这样做是希望在redis中缓存该页面,如果自动渲染,那么该页面会在每次客户端发出请求时都渲染一次,列表页的数据实际上除了库存信息以外,其他信息都是不变的,因此,可以将其缓存起来。如果要缓存页面信息到redis中,必须获取该页面,显然,自动渲染时没法得到页面的,所以手动显示地渲染,得到列表页面,并缓存。

值得注意的一点是,redis在缓存列表html时,因为列表页面的库存信息实际上会变化,如果redis中缓存页面过期时间设置过长,则会造成db和缓存的数据不一致,所以,缓存时间的设置是一个关键,过期就需要从db中获取,本项目将缓存过期施加设置为1min,也就是说,在1min内,用户看到的数据和db的数据不会过于不一致,但实际上还是会有一定的不一致。当然,这个过期时间越小越好,但是这就会造成对db的频繁访问,造成db压力过大,影响核心业务,所以,需要在过期时间和db访问压力之间做一个权衡。

通过上述可知,为了库存数据更加实时,db查询商品列表信息越小,需要进一步优化。(目前思路:将商品信息也预存到redis中,但是这样有可能会出现存储空间不足的情况)

redis中存储的商品列表页面为:

key: GoodsKeyPrefix:goodsListHtml
value: {html}
expire: 1min

商品详情请求逻辑

商品详情请求url:goods/getDetails/{goodsId}

  1. 从db中获取获取商品详情;
  2. 计算商品秒杀状态以及秒杀剩余时间;
  3. 封装商品秒杀状态、秒杀剩余时间和秒杀商品详细信息到vo,并返回给客户端,由客户端获取该vo的json数据并渲染。

商品详情请求逻辑总结

因为商品详细信息中的库存、秒杀状态在详情页面的时候需要近乎实时的获取,这样可以给用户一个更好的体验。所以,需要每次都从db中获取该商品的详细信息。

秒杀商品和商品是分别使用两个表存储的,这样做的目的在于:商品列表包含了商品的详细信息,秒杀商品存储的信息为和秒杀有关的信息,如果使用同一个表存储商品的所有信息(包含秒杀信息),那么,在向表写入数据的时候,就会造成过多的请求阻塞地获取锁,而实际上,秒杀业务下,写入操作多为和秒杀有关的字段,如果将这些字段分离处理,商品表主要用于读,而秒杀商品列表用于秒杀业务,这样就可以提高数据库的吞吐量。

改进:是否可以将秒杀商品表的关键信息预存到redis中?

获取验证码图片逻辑

获取验证码url:/seckill/verifyCode

  1. 服务端收到请求,生成验证码,并通过ScriptEngine引擎计算验证码结果,然后将验证码结果存储于缓存中;

  2. 将验证码图片以JPEG格式返回给客户端;

redis中存储验证码结果:

key: SkKeyPrefix:verifyResult_{uuid}_{goodsId}
value: {verifyResult}
expire: 5min

获取秒杀接口地址逻辑

获取秒杀接口地址请求url:/seckill/path

  1. 根据用户输入的验证码值和goodsId从缓存中获取验证码结果,校验验证码是够正确,如果校验失败,则返回用户重新输入提示,如果校验成功,则创建随机秒杀地址;
  2. 使用UUID工具生成随机秒杀地址,并将随机秒杀地址存储于缓存中;
  3. 返回给客户端随机秒杀地址。

获取秒杀接口地址总结:

之所以引入随机秒杀地址,原因如下:

如果秒杀接口的地址为静态地址,那么用户可以轻易的使用接口地址完成进行恶意秒杀,这样会使得参与秒杀的用户参与度不搞,达不到业务目的,引入随机地址则可以很好的规避这个问题。

秒杀地址的生成在验证码校验之后,这就是说,一定需要在验证码输入正确的情况下才能获取到随机秒杀地址。

除了上述的通过验证码保护秒杀请求地址外,还引入了接口防刷机制防止用户过于频繁的提交请求。

redis中存储的随机秒杀地址用户秒杀请求时的地址校验。

redis中存储随机秒杀地址:

key: SkKeyPrefix:skPath_{uuid}_{goodsId}
value: {path}
expire: 1min

秒杀接口防刷机制

  1. 服务器拦截用户请求,判断请求处理器上是否有@accessLimit注解,如果没有,直接放行,如果有,则进入2;
  2. 获取注解参数,包括最大访问次数、时间间隔;
  3. 对于第一次访问有@accessLimit注解的方法,将随机url地址存储到redis中,并设置过期时间为注解上的时间间隔。
  4. 对第二次请求,如果redis中统计的请求次数没有达到最大值,则自增,如果达到最大值,则向用户发出频繁请求响应。

总结

@AccessLimit(seconds = 5, maxAccessCount = 5, needLogin = true)

有上述注解的方法将会被拦截,其含义为:在5s内,最大访问次数为5次。

redis中存储用户一段时间内的访问次数:

key: AccessKeyPrefix:access_{URI}_{phone}
value: {count}
expire: {@AccessLimit#seconds}

秒杀请求处理逻辑

秒杀请求url:/seckill/{path}/doSeckill

  1. 根据userId和goodsId从redis中读取{path},校验随机秒杀接口地址是否一致,如果不一致,则说明客户端发送的秒杀请求非法,随机秒杀地址被客户端更改。如果一致,则进入2;
  2. 系统在启动的时候,已经从数据库中加载所有秒杀商品的库存信息,标记库存的有无到本地内存(HashMap)中和记录具体商品的库存到redis中,所以,这一步在内存标记中判断是否该商品还有库存,如果没有,直接驳回请求,如果有,则进入3;
  3. redis中在最开始系统启动时记录了商品的库存信息,所以,可以通过redis预减库存,而不需要在这个时候到db中减库存,如果库存预减到不大于0,表明之前的请求已经将商品库存消耗完成,此时,在内存中标记该商品已经完成秒杀。如果预减库存成功,则将请求放行到4;
  4. 根据useId和goodsId从redis中查询秒杀订单信息,如果存在,则说明,该用户已经完成该商品的秒杀,直接驳回请求,否则,放行请求到5;
  5. 根据useId和goodsId从数据库中获取订单信息,如果存在,则直接驳回请求,否则放行请求到6;
  6. 生成秒杀请求消息,放入队列中,将秒杀请求交由队列处理。

秒杀请求处理逻辑总结:

一句话,使用内存标记和缓存将秒杀请求拦截在db上游,防止大并发下的秒杀请求落到db。

为什么有了内存标记,预减库存,订单缓存的情形下,还要在缓存中订单不存在的情况下从db读取订单信息,而在队列中又有从db读取订单信息拦截请求的操作?

这个问题是由大并发导致的,实际上,内存标记,可以阻挡一部分请求,然后通过redis预减库存,也只能拦截一部分请求。考虑一种情形:

假设用户以极快的速度同时发出两次请求,两次请求有相同的userId和goodsId,前一次请求完成秒杀时,后一次请求正好从db中读取订单信息,那么可见,后一次请求可以读到完成秒杀的订单,这样就可以将该用户请求拦截下来,不用发送到消息队列中处理,减轻消息队列的负载。

另一种情形,后一次请求没有从数据库中读取到该用户的订单信息,也就是执行时间稍微超前于前一次请求写入订单的时机,那么实际上后一次请求也是无效请求,会发送到消息队列处理,再看消息队列的消费者,消费者也会先从缓存再从db中读取该请求的秒杀订单信息,这样就可以将这个无效请求拦截下来,不用落到db上,达到减轻db负载的压力。

这就是为什么会两次从redis中读取订单信息,在redis中订单信息无效时从db读订单信息;一次发生在秒杀请求发送到消息队列之前,一次发生在发送到消息队列之后(即真正做秒杀之前)。目的即使为了阻挡无效的请求落到db上。

redis中存储的随机秒杀地址为:

key: SkKeyPrefix:skPath_{userId}_{goodsId}
value: {path}
expire: 1 min

redis中存储的在系统加载时从db读取的商品库存信息:

key: GoodsKeyPrefix:goodsStock_{goodsId}
value: {stock}
expire: 0

消息队列处理秒杀请求的过程(秒杀业务的核心)

  1. 消息队列收到秒杀消息(SkMessage[user, goodsId])后,通过goodsId从DB中查询该商品的库存信息,如果库存不大于0,则直接返回,反之,则表明该商品还有库存,进入2;

  2. 通过userId和goodsId从redis中查询秒杀订单信息,如果查询结果不为空,则说明该用户已经对该商品进行过秒杀,直接返回;反之,可能因为缓存有效期的问题,使得缓存中的秒杀订单信息无效,进入3;

  3. 根据userId和goodsId从DB中获取秒杀订单信息,如果秒杀订单信息不为空,则说明该用户已经完成该商品的秒杀,直接将秒杀请求驳回,如果查询的秒杀订单信息为空。则说明该用户为对该商品进行过秒杀,进入4;

  4. 这一步为秒杀业务逻辑的关键,分为三步:从商品表中减该商品的库存,生成订单信息写入秒杀订单表和订单表中;

  5. 首先,减库存。在该商品库存不为零的时候,返回更新记得记录id,大于0则表明更新成,即减库成功;反之,库存为0,减库存失败,在redis中标记该商品已没有库存。

    UPDATE seckill_goods SET stock_count = stock_count-1 
    WHERE goods_id=#{goodsId} AND stock_count > 0
  6. 如果5中减库存成功,则创建订单,将订单写入秒杀订单表和订单信息表中,并且,将生成的订单信息在db写操作完成后存储到redis中,这样,下次同一用户对同一商品发起秒杀请求时,直接使用redis中的数据就可以判断其完成了秒杀,而不用再从db中读数据判断该用户是否对该商品已经完成了秒杀;

  7. 需要注意的是,秒杀动作的关键三步:减库存,生成订单记录插入订单信息表和秒杀订单表构成事务,需要使用Spring的@Transactional注解处理事务。

消息队列处理秒杀请求的过程总结:

在秒杀请求中,我们使用大量的缓存将秒杀请求阻挡在db外,真正落入db的请求应该尽可能的小,这样可以防止秒杀请求直接透穿DB,从而减轻db压力。

实际上,秒杀商品有限,库存也有限,如果将秒杀请求直接落到db,是非常不合理的,考虑一种情形,某件秒杀商品的库存为100,在秒杀开始的时候,瞬间的秒杀请求并发量为5W,可以想象,数据库是无法承担如此高的并大量的,另外,5w个秒杀请求,实际只有100个秒杀请求有效,多出来的请求只会无端对数据库造成访问压力,而对业务毫不相关。

消息队列使用redis来拦截秒杀请求,redis中缓存何种数据是非常重要的。消息队列处理秒杀请求时只会从缓存中读/写订单信息,写缓存发生在db写订单完成后,读缓存发生在对db写之前。写redis发生在db之后,可以保证缓存和db中的数据的一致性,读redis发生在写db之前,可以用来阻挡无用请求,减轻db压力。这个地方,并没有做到缓存于dB的强一致性,只能保证最终一致性。

redis中存储的订单信息为:

key: OrderKeyPrefix:SK_ORDER:{userId}_{goodsId}
value: {SeckillOrder}
expire: 0