• 如何设计用户限额系统
  • 发布于 2个月前
  • 328 热度
    0 评论
  • 怪咖豆
  • 0 粉丝 25 篇博客
  •   
程序媛小美最近接到产品需求,业务希望用户购买某类商品时能记录购买次数,超过一定次数不允许用户提单购买,不同商品配置不同的限制次数。

产品同学对业务的诉求进行需求评审,和业务侧描述基本相同。但产品考虑到虚拟商品存在复购的需求,增加了时间限制,例如一周允许用户购买几次、一天允许用户购买几次。


小美和产品沟通需求后,开始着手方案设计。虽然产品功能的描述比较简单,一天或一周允许用户购买某类商品几次,但业务后续往往会不断增加新特性,如果不进行前瞻性设计,未来无法承接新需求,可能要重新设计限额系统,所以第一次设计时要尽量设计的更通用,扩展性更强。


小美不断的进行头脑风暴,考虑业务侧可能新增的特性,考虑虚拟商品存在秒杀等热点商品。经过头脑风暴和初步调研后,小美得出如下特性:
1.限额要限制用户哪些维度?例如UserId、客户端UUID、手机号、甚至身份证号。
2.商品限制的维度可能不同。例如商品Id维度,产品线维度、甚至商品种类等维度
3.要支持分时限额,例如一周、一天、一年、甚至指定时间范围。
4.限额扣减等需要保证幂等、一致性、高性能等。

经过头脑风暴,小美打算设计一个扩展性极强的限额系统,一方面是产品和业务诉求的不断变化要求系统扩展性要强,另一方面,小美通过深刻的思考,意识到限额能力具备通用性,可以进行抽象封装,提高扩展性。


小美认为限额能力可以基于Redis进行设计,借助于LUA脚本可以满足一致性问题,同时解决了秒杀热点等性能问题。至于Redis可能丢数据的风险,她也进行了思考。线上环境的Redis集群有毫秒级的主从同步,从节点和主节点只有毫秒级的数据差异。就算主节点挂了,丢数据了,也仅仅只影响几秒钟的限额数据,切换到从节点,限额接口可以继续使用。所以小美认为Redis丢失数据的风险可控。


Redis 限额数据存储格式

小美考虑到要对 UserId、UUID、手机号等维度进行限额。自然要使用UserId、UUID/Phone等作为Redis Key,但是考虑到,尽可能减少Redis大Key,小美想把以上维度值放到 Redis hash field中。


是否可行呢?小美深入思考后认为不可行。小美想到用户可能的购买场景,例如用户注销后再注册,同一个手机号绑定了多个userId,使用新号购买,系统查询购买次数时,只知道UserId、Phone。 如果把UserId/Phone放到Redis Hash Field中,那么Redis Key是什么呢?


是UserId吗?如果是UserId,那么新号和旧号是两个UserId,是两套数据,分别存了同一个手机号的购买次数………,这怎么进行限额控制呢?

所以只得把限额维度UserId、Phone等作为Redis大Key 设计。


此外商品购买维度也有不同,目前业务限额基本上都是针对某一大类商品,所以小美想把商品维度也加入大Key。
小美初步认为 Redis 大Key生成维度如下 {0}_{1}_{2}_{3}
{0} 存储用户维度类型,UserId:U,Phone:P,UUID: {UU},IDCard:{IC}。使用缩写尽量减少存储量
{1} 存储用户信息,User或者UUID/PHone/ IDCard等
{2} 商品维度,标记是商品ID、还是商品类型。商品ID:P,商品类型:T
{3} 商品维度 分别为 商品Id,商品类型。由于目前只对商品类型限额,所以基本上为商品类型。

考虑到可能同时对商品ID和商品类型进行限额,小美认为限额组件代码要提供给上游灵活性。

除了对用户的UserId/phone/UUId等维度进行限额,商品维度的限额场景也有很多,可以对一个UserId分别进行商品Id和商品类型的限额控制。

系统要提供用户维度和商品维度在存储上的灵活性,最好不要限制其他维度的编码格式。只需要上游传dimensions,自动拼接在Redis Key之后,小美认为这样扩展性会非常强。


正是得益于这样的设计,后续更加复杂业务场景很多,但是底层操作限额的组件代码,很少改动。Redis Key的生成完全交给了上层业务系统设计和规划。底层组件只提供基本的限额存储能力,保证原子性和一致性即可。

接下来,小美踌躇很久,如何保证扣减的一致性,十分困扰。


限额扣减一致性
Redis 的value要记录用户的购买次数,如果购买次数超过限额了,就要阻止提单。
“提单前一定要校验是否满足限额,如果不满足限额就提单失败,如果满足限额就扣减限额。” 小美内心思考着……
“不行,如果提单失败怎么办,限额扣减成功,如果库存不足或其他原因导致提单失败,限额也需要回滚啊”
“哦,对了,如果满足限额就扣减限额,这个过程也要保证原子性,嗯,可以使用LUA脚本……”,小美最近学习了LUA脚本,觉得这是个好东西。
“如果不用LUA脚本行不行,使用incrBy,增加购买次数,如果超过了,再回滚!!!”,小美不断进行头脑风暴。
现在有两个方案可供选择,然而困扰她的是,这两个方案都不是很完美。
1.限额扣减可以使用LUA脚本,先校验是否满足限额,再扣减限额,使用LUA脚本
2.限额扣减使用Redis IncrBy,根据返回后的值判断是否超出限额,如果超出,则decrBy 回滚限额。

小美在两个方案之间很是纠结,这两个方案都可以实现限额,但是开发成本都不低,拿Lua脚本来说,LUA脚本要分别对用户的UserId、UUID、手机号等 KEY 进行判断,判断成功后,再调用三个Key的 incrBy增加购买次数。这个LUA脚本写起来,可是真的复杂啊。


并且还存在一个更加致命的问题!!由于公司的Redis集群使用Redis Cluster结构,会进行Redis Key的分片。用户一次购买生成的限额 key不一定能保证在同一个Redis节点?所以LUA脚本压根就不行啊。


再考虑方案2,先试探性增加购买次数,如果超过限额就回滚。这个方案也非常复杂,如果只有一个Key,回滚也就算了,可以这么干。但是限额场景Key的数量太多,使用这个方案,编码复杂度会增加。并且如果出现超时怎么办呢?例如扣减限额的第三个命令,超时了,你怎么知道扣减是成功还是失败?又如何回滚前两个限额呢?


除了扣减动作失败,需要回滚限额。当扣减限额成功,但是后续订单提单失败,也需要扣减限额。这时需要首先判断,是否扣减过限额?提单失败,不能盲目回滚限额。
小美想到这些,脑子里简直是一团乱麻…………
那么能否换一个思路呢? “换一个思路,换一个思路……”小美不断在心里告诉自己。
小美想到了,加锁。没错,当无法保证一致性的时候,除了使用Redis原子命令、LUA脚本,其实也可以使用分布式锁啊 ?
锁或者原子化的命令,都是并发场景,保证数据一致性的利器

分布式锁限制并发扣减限额
小美想到可以提单前后加锁,这样就保证了用户不能并发扣减限额。具体流程设计如下

在提单流程之前,加一把用户锁,需要和其他订单系统隔离开,不影响其他订单类型提单。这样用户的并发提单行为被限制。同一个用户并发提单一般是因为频繁点击、重试等引起的。加一把锁,可以保证提单前、提单中、提单成功这一过程在用户维护是串行处理,不存在并发行为,简化了代码设计。


由于有了用户提单锁的存在,提单前先校验限额是否充足,充足则进行提单。提单成功以后扣减限额,如超时则重试。当然重试要保证扣减的幂等。可以使用订单Id作为幂等键,存在Redis中,如果存在扣减记录,就不再更新。由于重试操作时间很短,幂等键超时时间设置为10分钟即可。可避免长时间存在Redis中,占用内存。


用户提单失败了,也无需回滚限额。因为只有在提单成功的场景才会扣减,提单失败自然不会回滚。(订单取消需要回回滚限额)


小美对自己全新的设计感到很满意,通过引入一个分布式锁,居然可以极大的简化系统设计。不再需要考虑用户并发行为等极端场景。换了一个思路,由提单前扣减限额到提单成功才扣减限额。提单失败了,不需要考虑回滚限额啊。


于是小美想到库存扣减能不能也用这个思路?不行,商品的库存是全体用户争抢到的,加用户维度分布式锁没用,也很难给商品维度加分布式锁,总不能限制一个商品同时只有 1 个用户能提单吧。所以商品库存扣减还真的不能像限额似的,通过加用户提单锁,简化扣减资源流程。


小美在方案预评审前,和leader王哥沟通了自己全新的限额系统方案设计,王哥也没设计过限额系统,所以看到小美的设计方案和思考过程,对她连连称赞。但是思维敏锐的王哥,给小美提了两个意见,让小美差点推翻整个方案……


再次优化限额扣减流程

王哥问小美:“你的限额系统可以同时支持用户维度、UUID维度、Phone维度三码限额。但是你的分布式锁只加了UserId维度,那么如果同一个手机号,在两个客户端,使用两个UserId并发提单,你的限额还能有用吗?”


小美差点闪了腰,对啊,限额明明是三个维度,三码限额,但是我只加了一个UserId维度的锁…… 难不成要加三个维度的锁,如何保证三个分布式锁的一致性获取呢? Redision有这个能力吗?小美飞快的思索着…… 非常紧张……

王哥看到小美沉默良久,"其实,我提的场景压根不会存在",王哥笑着,告诉小美,
”什么,为什么不存在?“ 小美一皱眉,认真的反问道。
”因为一个手机号不允许绑定两个UserId,除非用户注销重新申请新号,但是原号都被注销了,肯定不会存在并发提单啊?“ 王哥解释道,“并且 一个App客户端即一个UUID,不可能同时为两个用户提单啊,所以你不需要加UUID维度分布式锁啊”。 “哈哈哈哈”,王哥其实早就知道这个场景不存在……

“……你逗我呢?”小美表面笑嘻嘻的回应,“差点翻车,幸亏有限制,要不然方案白写了”。心里在盘算着“你第2个问题,肯定也是在逗我。”


王哥认真说道:" 也不全是逗你,如果未来的限额要支持身份证号呢?我们可没法限制同一个身份证号,并发提单啊? 好在现在没有这个场景。未来如果有了,可能需要再加身份证号维度的分布式锁。到时候再看吧!”


王哥 认真的继续补充,“第二个问题,其实是想告诉你,既然你使用了分布式锁控制了用户的并发行为,就没有必要再使用用原子化的扣减命令,为什么还要再使用incrBy增加购买次数呢? 使用set命令,设置新的购买次数,不是更好吗?”


“我明白了,我立马去改方案,王哥,你真神了”。小美话没说完,就秒懂王哥的思路,回去重新细化了方案,补充了以上两个问题的处理方案。


是的,既然已经引入了分布式锁,限制了用户并发提单,并发扣减库存的行为,也没有必要使用incrBy命令增加购买次数,为什么要用set 命令设置购买次数呢 ?王哥虽然没有明说,小美已经领会了意图,屏幕前小伙伴理解了吗?


使用set命令,如果出现了Redis超时等,可以无脑重试。但是IncrBy 命令则不然,无脑重试会导致多记录用户购买次数。而redis是支持 MSet命令的,即可以同时修改多个Key的Value,保证数据修改的原子性。


 在MSET之前,先计算每个KEY的hash槽,选择对应的Jedis实例,也可以用MSET


虽然会同时调用多个Redis节点可能有超时,但考虑到外部有用户锁,不会有并发,MSET可以重试执行。不存在幂等问题,遇到超时等异常,可以无脑重试即可。


如何支持时间周期的限额扣减

需求里可是要求可以限制用户一天的购买次数,小美和产品确认,一天限额是指 自然天,但未来不排除是24小时内的限额。但实际上无论是自然天,还是24小时,对系统的设计没有太大影响,小美是这样设计的。


可以对Redis KEY加超时时间,如果是自然天,就让KEY在当天的23:59:59过期即可,如果是24小时,则设置24小时的超时时间即可。

“订单退款,需要回滚限额时,怎么设计呢?假如是自然天的限额,用户第一天买,第二天也买了,但是第一天买的退款了,我此时不能回滚第二天的限额啊。”


“哦,对了,订单退款时,限额组件判断一下,如果超过了限额的过期时间,无需再回滚限额了。”


限额组件在提单时可以计算 限额Key的超时截止时间,自然在订单退款时,也可以计算限额Key的超时时间。无需在订单上存储限额的Key和超时时间。
幂等键什么时候超时
前面提到了,同一笔订单提单成功,要保证扣减限额的幂等,幂等键可以设计为OrderId,但是什么时候超时呢?是否要等用户不能再退款了,才过期呢?
实际上 在提单成功后,扣减限额,可以通过重试解决 限额漏扣减的情况,不用担心漏扣减限额问题。退款时,肯定限额数据肯定是正确的。无需再校验扣减限额的幂等key是否存在。
限额扣减和回滚操作只需要各自增加幂等键即可,只需要保证在短期内重试的请求能幂等即可。例如一天,甚至 1 小时。这样短期内幂等键就会被清理,避免长时间占用Redis内存资源。
考虑到内存资源的问题,小美也和业务产品进行了沟通,所有的限额都是要有超时时间的,不存在业务永久限额。所以小美和产品争取到,最长的限额时间是1年。所以限额Key最长1年后就会过期。不会永久驻留Redis。

如何提供接口
小美的限额方案基本设计完成,现在需要考虑为用户提供接口的格式,如何最大程度支持业务场景呢?小美想到限额的维度多种多样,不能限制只有UserId、Phone、UUID三码限额,所以用户维度要支持扩展。限额是限制商品维度,还是商品类型、或者产品线,这些都是由业务层代码自定义即可,组件层不关心Key是怎么生成的,只负责拼接Key即可。幂等键等都由用户自定义,但一般情况下是UserId和订单id限额规则的获取,支持硬编码,也支持从商品限额数据中获取限额规则。

小美于是定义了扣减回滚的请求参数

QuotaOpCmd中 OpType代表是扣减限额还是回滚限额。
idemKey 标记为幂等键
QuotaOpCmd包含多个用户维度的限额
QuotaOpItem 一个用户维度的限额,可以指定多个商品维度。最终UserInfoId和dimension会拼接为1个key。换句话说,如一个Cmd包含3个OpItem,即UserId/phone/uuid三码限额,一个OpItem包含2个dimension,商品Id加商品类型维度。那么Redis侧会生成6个限额key,外加一个幂等Key,通过MSET命令执行。
opNum标记了本单购买次数
expireTimes标记了 过期时间。

通过限额扣减Cmd、Item,业务层代码可以自定义限额维度、自定义限额过期策略等。限额组件只提供了扣减限额和回滚限额的底层能力。保证幂等,保证数据一致性,保证高性能。至于业务层想怎么定义限额策略,业务层自行定义即可。

从小美设计限额系统的思考过程、最终方案,我们可以作如下总结.


总结
1.锁、原子化命令、LUA脚本,这几种方式都是在并发场景,保证数据一致性的利器。
2.系统设计要尽可能的简洁,如果方案过于复杂,可以多想想,换个思路。一定会有更简洁的方案。
3.基于分布式锁限制用户并发扣减限额,在提单成功后,扣减限额。提单失败,无需回滚限额。
4.使用分布式锁,无需再使用incrBy 原子命令增加购买次数,使用更简单的MSet即可,只需要无脑重试。不存在幂等问题。
5.incrBy不是万金油,incrBy超时,无法确定是否成功。
6.通用组件要尽可能给业务灵活性、扩展性。尽可能只提供核心能力。例如限额只提供通用的扣减回滚、一致性保证。尽量不限制业务怎么拼接Key。定义成String,让业务去设计Key生成规则即可。
7.定义bizId,这样不同业务线可以低成本接入。
8.领券和买券同一个产品线的两个业务场景定义两个bizId,也可以复用限额啊!

用户评论