现在 有一个场景,领取礼品,每个用户有次数限制,用户通过前端点击,调用了应用A的接口,里面调用了服务B,服务B里面去调用了服务C,注意服务C是其他部门的服务。服务C负责真正的发放礼品。(假设这个服务C我们是不可修改的,A,B是自己团队负责的,并且可能出现高并发的情况)
我们应该如何做这个次数限制呢?
假设每次领取礼品的活动有一个activityId
,一个用户一个活动可以领取一件礼品,礼品有giftId
,不可以多领,每个用户对应一个uid
。
查询是否可以领取
首先对于前端而言,进入系统,首先需要获取用户是否已经领取过,而这个是否已经领取过,具体的实现我们应该写在B服务中,用户通过应用A,请求到服务B,返回用户是否已经领取的结果。
查询是否领取的流程大致如下:
用户进入页面,前端如果有缓存的话,可以为他展示之前缓存的结果,假设没有缓存,就会请求A应用,A应用会去请求B服务,B服务首先需要判断礼品或者活动是否存在。
去redis里面取活动或者礼品是否存在,如果redis没有查询到,那么就查询数据库,返回结果,如果数据库都没有,说明这个前端请求很可能是捏造的,直接返回结果“活动或者礼品不存在”,如果此时查询出来,确实存在,那么就需要去查询是否领取过,同样是查询redis,不存在的情况下,查询数据库,再返回结果。,如果领取过,则会有领取结果,前端将按键置灰,否者用户按键可以领取。
上面的redis肯定是需要我们维护的,这里不展开讲。比如增加活动的时候,除了改数据库,同时需要redis
里面写一份数据,key可以是activityId_giftId
,记录已经有的活动,用户成功领取的时候,同样是不仅增加数据库记录,也需要往redis
写一份数据,key可以是activityId_giftId_uid
,记录该用户已经领取过该活动的奖品。
但是上面的系统,有一个问题,就是活动/礼品不存在的时候,请求会每一次都直接打到数据库,如果是恶意攻击,数据库就挂了。这里当然可以考虑使用布隆过滤器,对请求参数中的活动/礼品做过滤,同时也可以考虑其他的防爬虫手段,比如滑动窗口统计请求数,根据ip
,客户端id
,uid
等等。
当然,如果可以保证redis
数据可靠,稳定,可以不请求数据库,redis
不包含则说明不存在,直接返回。但是这种做法需要在增加活动/修改商品的时候,同时将redis
一同修改同步。如果redis挂掉的情况,或者请求redis
异常,再去查询数据库。如果能接受修改数据库活动信息不立马更新,也可以考虑更新完数据库,用消息队列发一条消息,收到再做redis
更新。当然,这个不是一种好的做法,解耦合之后,增加了复杂度。前面说的做法,只要redis
挂了,数据库理论上也支撑不了多久(极端情况)。
(当然,上面不是完美的方案,是个大致流程)
领取礼品接口怎么处理?
首先流程上与上面的查询是否领取过有些类似,,但是在查询是否领取过这一步之后,有所不同。如果已经领取过,则直接返回,但是如果没有领取过,需要调用C服务进行领取,如果调用C接口失败,或者返回领取失败,B服务需要做的事,就是记录日志或者告警,同时返回失败。
如果C服务返回领取成功,那么需要记录领取记录到数据库,并且更新缓存,表示已经领取过该礼品,这也是上面为什么一般能直接查询缓存就可以知道用户是否领取过的原因。
这个设计中,其实C服务才是真正实现方法奖品的服务,我们做的A和B相当于调用别人的服务,做了中间服务,这种情况更需要记录日志,控制爬虫,恶意攻击等等,同时做好异常处理。
上面的设计,如果我们来写段伪代码,来看看有什么问题?
public String receiveGitf(int activityId,int giftId,String uid){ // isExist判断活动是否存在,内部包括redis和数据库请求,省略 if(isActivityExist(activityId,giftId)){ // 活动和礼品有效,判断是否领取过 if(!userReceived(uid,activityId,giftId)){ // 没有领取过,调用C系统 try { boolean receivedResult = Http.getMethod(C_Client.class, "distributeGift"); if(receivedResult){ // 领取成功更新mysql updateMysql(uid,activityId,giftId); // 领取成功更新redis updateRedis(uid,activityId,giftId); }else{ return "已经领过/领取失败"; } }catch (Exception e){ // 记录日志 logHelper.log(e); return "调用领券系统失败,请重试"; } } } return "领取失败,活动不存在"; }
看起来好像没有什么问题,领取成功写redis
,之后读到就不会再领取。但是高并发环境下呢?高并发环境下,很有可能出现领取多次的情况,因为网络请求不是瞬时可以返回的,如果有很多个同一个uid的请求,同时进来,C服务的处理或者延迟比较高。所有的请求都会堵塞在请求C服务这里。(网络请求需要时间!!!)
这时候还没有任何请求成功,所以redis
根本不会更新,数据库也不会,所以的请求都会打到C服务,假设别人的服务是不可靠的,可以多次领取,那么所有的请求都会成功,并且会有多条成功的记录!!!
那如何来改进这个问题呢?
我们可以使用setnx
来处理,先请求setnx
,更新缓存,然后只有一个可以成功进来,如果真的成功,再写数据库,如果异常或者请求失败,将缓存删除。
public String receiveGitf(int activityId,int giftId,String uid){ // isExist判断活动是否存在,内部包括redis和数据库请求,省略 if(isActivityExist(activityId,giftId)){ // 活动和礼品有效,判断是否领取过 if(!userReceived(uid,activityId,giftId)){ // 没有领取过,调用C系统 try { // setnx if(redis.setnx("uid_activityId_giftId")){ boolean receivedResult = Http.getMethod(C_Client.class, "distributeGift"); if(receivedResult){ // 领取成功更新mysql updateMysql(uid,activityId,giftId); }else{ // 领取成功更新redis deleteRedis(uid,activityId,giftId); return "已经领过/领取失败"; } }else{ return "已经领过/领取失败"; } }catch (Exception e){ // 记录日志 logHelper.log(e); return "调用领券系统失败,请重试"; } } } return "领取失败,活动不存在"; }
在 Redis
里,所谓 SETNX
,是「SET if Not eXists」
缩写,也就是只有key
不存在的时候才设置,可以利用它来实现锁的效果。这样只有一个请求可以进入。
redis> EXISTS id # id 不存在redis> SETNX id "1" # id 设置成功1redis> SETNX id "2" # 尝试覆盖 id ,返回失败 0redis> GET job # 没有被覆盖"2"
这个场景下的问题已经得到初步的解决,那这个setnx
有没有坑呢?下次我们聊一下…