预先创建一批 coupons ,存到 coupons 表里面,然后将这些 coupons 发给用户,要求是一个 coupon 只能发给一个用户,不能重复发放。
分配性能越高越好。
所以优化重点是:尽量少访问数据库;随机读到的 coupon 尽量不要冲突。
预读取 100 个 coupons 到内存,分配的时候优先从缓存中读取,缓存没有从数据库再读一批。
为了解决多实例读取冲突问题,在 redis 记录一个 coupon id 作为 cursor ,每读一批,将 redis 中的 cursor 更新为最新的,下一批读取的是,id > cursor 的 coupons 。
redis 可以做到 cas 更新 cursor ,可以保证读取的每一批都不会重复。
cursor 也是 30min 失效,下一次继续从 0 开始,就算读取了一批,但是没有分配,然后挂了,被跳过的 coupons 还是会有机会读到的。
因为公司大佬觉得引入 redis ,方案比较复杂,不好维护;想跟大家请教下,看有没有什么更简洁的方案,不用引入数据库以外依赖
1
sagaxu 2022-03-04 15:14:02 +08:00 via Android 1
生成时随机,分配时按顺序
|
2
ZSeptember OP @sagaxu 可以具体一点吗。生成的时候随机,其实生成以后已经不随机了。
我们用的 spanner ,不支持 random 函数。。不然每一批可以 random 选择 100 个,冲突概率也不高了。 |
3
agzou 2022-03-04 15:22:59 +08:00
coupons 分配服务有多个的时候,肯定要用到分布式锁,最简单就是用 redis ,有现成代码,也可以用 JDBC+数据库实现一个分布式锁。
|
5
ZSeptember OP @agzou 分布式锁更重了,分布式锁的 timeout 很难解决。用数据库乐观锁简单点,但是大佬还是觉得复杂了,不希望引入 redis 。
|
6
murmur 2022-03-04 15:29:53 +08:00
我感觉这个可以从业务层面解决,不想用 redis ,不想上排它锁,那就把数设置灵活点,比如 100 张的名额,那我做活动就发 80 ,万一锁出了问题发了 90 张也在承受范围内
另外大额优惠券在实际电商的时候本来就是看人的,有些人生来就失去资格了,所以并发也是可以优化的 |
7
murmur 2022-03-04 15:33:14 +08:00
另外,实际上发卡系统对实时性也没有明确要求,京东不是经常告诉你你的优惠券 5-10 分钟才会到账么
很多东西就得用业务解决,死磕技术是没用的,就跟双十一抢购一样,0 点抢购天怒人怨,连续热卖两个月不好吗? |
8
ZSeptember OP @murmur 数量其实不是重点,重点是一个 coupon 只能给一个人发,不能重复发。
|
9
ZSeptember OP 重点其实看怎么能提高随机读取 coupon 的性能以及随机性
|
10
ZSeptember OP @murmur 实时性其实要求也不是特别高,但是还是需要提高分配性能而已。
现在其实有一个最基本的方案: 1. 读 100 个 coupons 2. 随机选择一个 冲突概率还是挺低的,但是每一次都要读数据库,有点浪费 |
11
Habyss 2022-03-04 15:56:00 +08:00
1. 读取 100 个 coupons(条件是未标记预分配), 并数据库标记预分配
2. 随机的话, 是否能做到生成到数据库中时就是随机的呢 |
12
ZSeptember OP @Habyss
1. 第一种考虑过,读取 100 个,然后全部删除掉,和预分配状态本质差不多;就是考虑到这时候挂掉了就浪费一批 coupons 。 2. 存到数据库可以给一个随机编号,但是不知道怎么能读出来是随机的? |
13
mxT52CRuqR6o5 2022-03-04 16:11:53 +08:00
从数据库选取一条未被发出的 coupon 并标记为已发送(直接用原子操作实现,就不需要关心锁的问题)
把这条 coupon 发给用户,发失败的话就从头再来 代价是可能会有一些 coupon 没被发出去但被标记为已发送 这个方案如何 |
14
haython 2022-03-04 16:13:54 +08:00
我们当时也这样做过,后来发现,既然跟用户绑定了,券一样也无所谓
|
15
mxT52CRuqR6o5 2022-03-04 16:19:55 +08:00
你很强调随机,不是很明白你这个随机具体是要用来干嘛的
就像#1 说的 [生成时随机,分配时按顺序] ,分配时根本不需要随机 |
16
luckyrayyy 2022-03-04 16:20:28 +08:00
@ZSeptember 这种情况不用删除掉啊,打个已经被获取的标记,等到真的分配成功后再删除或者打标记分配成功。如果有一批 coupons 被获取了,但是服务器挂掉,那就用一个 Task 扫表长时间获取未分配的重新改成未分配。
|
17
Habyss 2022-03-04 16:26:34 +08:00
@ZSeptember
1. 挂掉是极端情况, 按照预分配状态的话, 即使挂掉了也是可以再将预分配还原的(预分配 /未分配 /已分配) 这样有一种情况就是, 数据库未分配 coupon 发完了, 但是还存在极少部分在内存中预分配, 这时候基本上已经尾声了, 那就直接查出预分配的来分... 反正也是有数据库的乐观锁的. 2. 我想的简单, 就是打乱之后再存数据库, 那顺序拿出来的也就相当于随机了... |
18
InternetExplorer 2022-03-04 16:29:14 +08:00
生成的时候随机好,每个 coupon 给一个发放时间点,时间点可以均匀分布在活动时间上,哪个用户最先在发放时间后访问(或者请求)就分给哪个用户。
|
19
ZSeptember OP @mxT52CRuqR6o5 看分配步骤,随机性是为了避免乐观锁冲突,影响分配性能,因为要保证不能重复分配同一个 coupon 。
你的那个方案和我 10 楼回复的一样,能 work ,但是性能应该应该比较差。其实我测试过,性能应该能满足我们系统现在的需求,但是天花板比较低。 |
20
ryd994 2022-03-04 16:29:53 +08:00 via Android
|
21
clf 2022-03-04 16:30:31 +08:00
coupons 表先不会动,随机往数据库 U 表里塞 N 个用户,各个服务自由塞,超过 coupons 表个数无所谓。
然后由一个服务取最前面 N 个不同的用户和 coupons 里一一对应即可。 |
22
freelancher 2022-03-04 16:32:57 +08:00
考虑用伪随机吗?例如给用户手机尾号是 9 的发优惠券。发到完为止。就好啦。没这么多要操心的。
|
23
ZSeptember OP @Habyss
@mxT52CRuqR6o5 我强调随机是是因为当前的保证不重复分配的方案是乐观锁,随机能避免冲突,提高性能。 如果分配方案不使用乐观锁,可以不需要 但是整体方案需要考虑到正确性以及性能 |
24
mekingname 2022-03-04 16:38:31 +08:00
如果你是随机取一个 coupon ,那为什么你不一开始就把所有 coupon 顺序打乱再入库,这样按顺序读取不就相当于原来顺序入库时的随机读取了吗?这样用户请求的时候,你就按顺序返回就好了。每次返回的时候就把这一条锁定。
|
25
mango88 2022-03-04 16:43:15 +08:00
不用乐观锁的话,
就把生成好的 coupons 读出来放 redis 里,每次 lpop 一个出来 |
26
ZSeptember OP |
27
ZSeptember OP @freelancher 你这是一种方案,对用户分区,冲突概率会低一些,但是哪个实例负责哪个分区也是一个问题,需要协商。。。
|
28
RickyC 2022-03-04 16:49:00 +08:00
|
29
ZSeptember OP @haython 这一点和我们业务不一样,我们的 coupon 是一个 coupon code 发给用户就需要能使用的。
现在的方案,不是在生成 coupon 的时候就给用户绑定,不过确实启发了一下,可以在生成的时候就绑定到人。就是较大调整。 |
30
CantSee 2022-03-04 17:20:11 +08:00
通过 Queue 进行读取是不是好点呢,每次读取一个,绑定用户,预加载到 Queue 中,没有了再查一批加载到 Queue 中;
|
31
oddcc 2022-03-04 17:21:09 +08:00
感觉就是个发号器啊
可以考虑这样做 有两张表,一个表放未使用的,一个表放已使用的 提前生成一大批可用的 coupon 放到未使用的表中 每次把一批 coupon 读到内存之后,就移动到已使用的表中,视为已使用的 根据并发的压力调整这一批 coupon 有多少个 这样你有多个分配服务,也不会重复,也不会冲突。也不涉及到其他的依赖,也不涉及到复杂的锁 就算中间服务崩溃了,恢复之后也不会有重复的 |
32
9c04C5dO01Sw5DNL 2022-03-04 17:22:17 +08:00
必须得预先创建吗?可不可以请求分配时再创建 coupon ,写入成功代表分配成功。
|
33
haython 2022-03-04 17:30:16 +08:00
@ZSeptember 只要是发到用户账户里,都可以一个码给多个人,使用的时候,只检查券的使用规则和用户的使用记录。只有让用户手动输入码的,才要保证唯一
|
34
ZSeptember OP @oddcc 你这个和我 12 楼的一样的,是可以的。读 100 个,然后把这 100 个删掉,就不会被其他实例读取了。
|
35
edward1987 2022-03-04 17:48:28 +08:00
同 #1 #25 。 生成时随机,然后直接存 redis,用 lpop 读取就行。 你原本的 redis 方案太麻烦。。
|
36
corningsun 2022-03-04 17:48:56 +08:00
同步并发操作转异步单线程就好了,加个中间状态。
1 用户请求后是标记为 “待分发” 状态。 2 再单独起个线程跑任务,取所有 “待分发” 的用户,按顺序从 coupon 分配,这时候就不存在冲突的问题了。 3 用户查询的地方改一下,加个 “分配中” 页面 |
37
ZSeptember OP @corningsun 服务多实例的,你这是转单实例了。
|
38
9c04C5dO01Sw5DNL 2022-03-04 18:12:50 +08:00
我的意思是,如果预创建只是为了限制数量的话,就不需要预创建了。可以用乐观锁计数,和请求时分配 coupon 放在同一个事务中。
比如: ``` begin; update ct set count=count+1 where count=? and count<?; update 成功则继续: insert into coupon_user ...... insert 成功则表示分配成功 commit ; ``` |
39
ZSeptember OP @edward1987 @mango88 可以,直接不落数据库
|
40
ZSeptember OP @giiiiiithub 忘记说明一个大限制,因为一些不可控的原因,创建 coupon 是有 rate limit 限制的 2 QPS ,40 RPM 。但是提供批量创建机制 每次 100 个。
|
41
corningsun 2022-03-04 18:29:04 +08:00
@ZSeptember
用户请求还是多实例啊,只是把分发 coupon 变成单线程了。 单线程操作的速度是很快的,比如设置批量操作上限 1 万条。 每次查询 “待分发” 的用户最多 1 万个,然后去 coupon 查询和用户数相同的出来直接分配,再批量更新。 |
42
ZSeptember OP @corningsun 可以,转串行,没问题,就是改动比较大。
|
43
siweipancc 2022-03-04 18:53:20 +08:00 via iPhone
随机入库的数据有一个唯一索引列 1 开始递增,用户每次请求 redis 原子加一取对应数据行。
缺点是每次初始化数据批量入库都要全局锁,而且业务回滚之后对应的行要删除下次入库不能复用。 |
44
rekulas 2022-03-04 21:42:32 +08:00
如果不想用 redis ,消息队列也可以实现该功能,预先把可用 coupon 都推到消息队列,消费即确认该 coupon ,还可以进一步集合消息队列延迟更新数据库,几乎不会造成压力
|
45
lldld 2022-03-04 22:01:12 +08:00
好比手机号嘛, 按号段先分配给不同的运营商, 运营商再卖给个人.
另起一个服务, 目前的服务每次向新的服务申请若干连续的优惠券(申请记录在一张新表里面, 请求比较少, 可以不用 redis), 用完了再申请. |
46
darkengine 2022-03-04 22:04:58 +08:00
我觉得完全没有必要随机啊,只要 coupon code 不会被猜出来就行了。对于开发者来说你觉得是顺序的,但是对于单个用户来说,随机领跟按顺序领没有区别。
|
47
ZeawinL 2022-03-04 22:37:54 +08:00
用用户 id 和可分配的 coupon 数取模
|
48
ZSeptember OP @ZeawinL 每次都要 count 一次,然后 offset 一次,性能不太行。
|
49
chihiro2014 2022-03-04 22:51:15 +08:00
不一定要 redis ,用 Guava Cache ,本地缓存
|
50
Huelse 2022-03-04 23:05:18 +08:00
插入的时候就做好随机了,取的时候按顺序取这么做应该没问题
|
51
xy90321 2022-03-04 23:35:30 +08:00 via iPhone
1 ) coupon 表乱序插入,并且每行加一个自增 ID 。2 )为自增 ID 做一个 Sequence 。3 )所有服务通过 Sequence.nextval 拿到 ID 来取 coupon 。
|
52
ZSeptember OP @xy90321 自增步长改成 100 ,就是和我最开始的方案差不多,只是我是用 redis 保存 sequence 。使用自增连续序列确实可以,不过我们使用 spanner ,自增插入会有热点问题,一般不考虑。
不过我的方案,相比,去掉了 redis 依赖,热点影响应该还可以接受,感觉确实还行。 |
53
xy90321 2022-03-05 14:38:07 +08:00 via iPhone
@ZSeptember
如果 redis 做原子操作的性能能接受的话,那无非是再加个允许接受 coupon 意外灭失的前提。 这样 redis 每次从 coupon 表里面读出一套 coupon (比如 5000 条)的同时,把 coupon 表里对应的 coupon 状态直接切成已消费完毕。 这样不论 redis 怎么搞,最多就是意外灭失 5000 个 coupon 而已,不影响你主体业务逻辑的完整性。 |
54
ZSeptember OP @xy90321 看我 12 楼,你这个方案提过了
|
55
snowsui 2022-03-06 12:37:12 +08:00
给你一个方案,这一批 coupon 我理解应该是一个规则吧,假设这个规则叫做活动,缓存里存下活动 id&用户的关系就行了,这样是否领取通过缓存判断,有这个用户就领取过了。没有的话再去通过发号器也好,还是之前生成的也好,落库 couponId 绑定用户
|
56
sampeng 2022-03-06 14:14:33 +08:00
redis 要维护麻烦,就没有比 redis 更麻烦的了。。。再说了。。没有 redis ,很多性能问题需要花很大的精力才能解决。。
另外,如果实例数是固定的,一致性 hash 就能把预生成好的直接匹配到每个实例上去。我觉得每次 load 几百条进来挺好的。。简单可依赖。。 |