背景
部门是租房平台,为各类商家提供挂牌挂牌、C端曝光获客的功能。 现在需要在各个节假日节点为商户营销活动搭建优惠券系统。 该形式主要是为商家在B端参与活动,为房屋绑定优惠券,以出租价格打折吸引用户在C端租房。
一、业务回顾
理清了大致的业务背景之后,我们来梳理一下整体的业务流程,如下图所示。
首先,平台建立活动,在商户B端展示可报名的活动,商户通过报名对应折扣力度的活动,创建折扣对应的优惠券。
然后,通过建立房源与对应的优惠券的绑定关系,为房源数据打上优惠券标识。 这样,在C端展示房源时,可以筛选出对应的优惠房源,用户可以在房源上领取某类优惠券。
最后,用户在C端领取优惠券后,可以联系商家进行上门看房。 如果双方达成一致,可以在线签订合同。 可在签约时使用相应的优惠券,实现相应的价格优惠。
至此,就是整个系统的完整正过程。
2、技术设计
下面是各个环节对应的技术设计。
2.1 构建活动 2.1.1 数据表
活动信息需要以下数据项
下面是活动信息数据表的具体设计
CREATE TABLE `t_activity` (
`activeId` bigint(20) NOT NULL COMMENT '活动ID',
`title` varchar(256) NOT NULL COMMENT '活动名称',
`applyStartTime` timestamp NULL DEFAULT NULL COMMENT '报名开始时间',
`applyEndTime` timestamp NULL DEFAULT NULL COMMENT '报名停止时间',
`activityStartTime` timestamp NULL DEFAULT NULL COMMENT '活动开始时间',
`activityEndTime` timestamp NULL DEFAULT NULL COMMENT '活动结束时间',
`cityIds` varchar(256) NOT NULL COMMENT '覆盖城市,多个逗号分隔',
`couponType` tinyint(4) NOT NULL DEFAULT '0' COMMENT '优惠类型,1 直减;2 折扣;3免费住N天;4免押金;5特价房',
`lowerLimit` int NOT NULL DEFAULT 0 COMMENT '优惠数值下限',
`upperLimit` int NOT NULL DEFAULT 0 COMMENT '优惠数值上限',
`description` text COMMENT '活动描述',
`cubeType` smallint(6) NOT NULL DEFAULT '1001' COMMENT '活动类型',
`foreignId` bigint(20) NOT NULL DEFAULT '0' COMMENT '外部ID',
`status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '活动状态',
`createTime` timestamp NULL DEFAULT NULL COMMENT '创建时间',
`updateTime` timestamp NULL DEFAULT NULL COMMENT '更新时间',
`recordStatus` tinyint(4) NOT NULL DEFAULT '0' COMMENT '数据状态',
PRIMARY KEY (`activeId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='活动信息表';
2.1.2 数据读取
商户端可以通过status字段和cityIds字段显示可以注册的活动。
在C端读取的展示代码中,可以使用设计模式中的代理模式,增加一层缓存,正在进行的活动会将数据推送到缓存层。
2.1.3 活动状态流
通过crontab定时任务,每分钟检查时间段,更新相应的状态字段,完成活动状态的流转。
2.1.4 数据读取
可以在设计模式中使用代理模式,增加一层缓存。 非强实时的查询去缓存,缓存中不存在的或者需要实时数据的去db。
2.2 商家注册创建优惠券 2.2.1 数据表
CREATE TABLE `t_couponmeta` (
`couponMetaId` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '券id',
`appId` int(11) NOT NULL DEFAULT '1' COMMENT '区分建立来源',
`activeId` bigint(20) NOT NULL DEFAULT '0' COMMENT '活动ID',
`companyId` bigint(20) NOT NULL COMMENT '公司编号',
`cityId` int(11) NOT NULL COMMENT '城市id',
`companyName` varchar(255) DEFAULT NULL COMMENT '公司名称',
`companyShortName` varchar(255) DEFAULT NULL COMMENT '公司简称',
`couponType` tinyint(4) NOT NULL COMMENT '优惠券类型',
`title` varchar(256) NOT NULL COMMENT '优惠券名称',
`directDiscount` int(11) NOT NULL DEFAULT '0' COMMENT '直减券优惠力度',
`discount` int(11) NOT NULL DEFAULT '0' COMMENT '折扣力度',
`freeLive` int(11) NOT NULL DEFAULT '0' COMMENT '免费住n天券',
`threshold` varchar(256) NOT NULL COMMENT '使用门槛',
`deduction` tinyint(4) NOT NULL DEFAULT '1' COMMENT '抵扣说明 1首月抵扣,2 平摊到月',
`totalAmount` int(11) NOT NULL DEFAULT '0' COMMENT '券总数',
`applyAmount` int(11) NOT NULL DEFAULT '0' COMMENT '已领取总数',
`activityStartTime` timestamp NULL DEFAULT NULL COMMENT '活动开始时间',
`activityEndTime` timestamp NULL DEFAULT NULL COMMENT '活动结束时间',
`startTime` timestamp NULL DEFAULT NULL COMMENT '券使用开始时间',
`expireTime` timestamp NULL DEFAULT NULL COMMENT '券使用结束时间',
`status` int(11) NOT NULL DEFAULT '10' COMMENT '10:新建未启用,20:已启用,30:过期, 40 已结束 50 已中止',
`expireType` tinyint(4) NOT NULL DEFAULT '1' COMMENT '类型:1固定有效期类型,2浮动有效期类型',
`validPeriod` tinyint(4) NOT NULL DEFAULT '0' COMMENT '浮动有效期(单位:天)',
`tenantRange` tinyint(1) NOT NULL DEFAULT '1' COMMENT '租客范围枚举值',
`customScope` varchar(256) NOT NULL DEFAULT '' COMMENT '自定义租客范围',
`comment` varchar(50) DEFAULT NULL COMMENT '备注',
`cubeType` smallint(6) NOT NULL DEFAULT '1001' COMMENT '活动类型',
`updateTime` timestamp NULL DEFAULT NULL COMMENT '更新时间',
`createTime` timestamp NULL DEFAULT NULL COMMENT '创建时间',
`recordStatus` tinyint(4) DEFAULT '0' COMMENT '状态 0默认 -1删除',
PRIMARY KEY (`couponMetaId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券表';
2.2.2 数据读取
也可以在设计模式中使用代理模式,增加一层缓存。 对于C端读取大量优惠券信息的场景,需要尽量让读取的请求在缓存层处理,减少db的压力。
2.2.3 状态转换
优惠券状态流程如图
大部分状态取决于活动的状态(暂停和过期除外),所以在活动状态的crontab任务中,当发现活动状态发生变化时,会通过MQ发出下面的优惠券状态变化任务, 折扣将异步修改优惠券的状态。 优惠券状态的变化也会涉及联动数据的更新,例如:
可以想象,在每次状态转换期间,有许多操作要执行。 为了保证业务逻辑的清晰,我使用了状态模式来实现状态流。 类图如下:
状态更新后,C端房屋指标数据/B端基本房屋数据/缓存中的数据,这些数据的更新使用观察者模式监听状态的变化,数据更新由在observer中异步发送mq,类图如下:
通过在CouponStateMachine中注册需要的观察者,在实际的doChangeStatus操作之后,通知所有的观察者,保证联动数据的正确性。
2.3 绑定优惠券 2.3.1 数据表
将商户b端绑定的全量优惠券(新创建/激活/未过期)数据存储在MySQL数据表中。 表结构比较简单,如下图:
CREATE TABLE `t_bindcoupon` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`couponMetaId` int(11) NOT NULL COMMENT '券id',
`companyId` bigint(20) NOT NULL COMMENT '公司编号',
`activityStatus` tinyint(4) NOT NULL COMMENT '状态 0 准备中 1 活动中 2 活动结束 券未失效 3活动结束券失效',
`houseId` bigint(20) NOT NULL DEFAULT '0' COMMENT '房源id',
`recordStatus` tinyint(4) NOT NULL COMMENT '数据状态 0 有效,-1 失效',
`createTime` timestamp NULL DEFAULT NULL COMMENT '创建时间',
`updateTime` timestamp NULL DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_companyId_couponMetaId` (`companyId`,`couponMetaId`),
KEY `idx_planeId` (`planeId`),
KEY `idx_houseid_activitystatus` (`houseId`,`activityStatus`)
) ENGINE=InnoDB AUTO_INCREMENT=17379 DEFAULT CHARSET=utf8 COMMENT='优惠券绑定范围表'

该表主要记录了listing和coupon的绑定关系,记录了当前的绑定状态。 该状态用于限制同一个listing绑定过多的有效优惠券,限制商家增加c端曝光,随意操作。
操作listing绑定优惠券时,会使用分布式锁,避免并发绑定操作造成的超限
2.3.2 数据显示
数据的展示依赖于C端的索引数据。 有效状态的优惠券信息存储在索引数据中。 字段用于结合业务场景进行筛选展示,如下:
优惠券 ID(多值) 事件 ID(多值) 参加的事件类型(多值)
通过索引过滤条件获取listing数据后,可以获取listing的完整数据,获取listing绑定了哪些优惠券,并展示优惠券数据。
2.3.3 状态转换
绑定关系状态的实时更新有两个触发动作,根据情况更新数据的status和recordStatus两个字段:
当2.2.3中基于优惠券状态的MQ消息db的绑定数据发生变化(变化/增加/减少)时,也会发送MQ异步更新索引中的数据。
2.4 C端用户领取优惠券
是C端用户领取优惠券比较重要的地方。 核心要求是不能多领优惠券,尽量避免少领优惠券。 具体设计流程图如下:
过程大致分为三步:
请求验证redis库存扣减和取货记录更新库存任务以事务的形式写入MySQL
下面逐步说明如图
2.4.1 数据读取
由于在C端浏览优惠券详情&领取优惠券可能是一个高并发操作,所以尽量从缓存中读取数据,包括以下数据:
活动信息优惠券信息优惠券库存
活动开始前五分钟禁止编辑活动信息和优惠券信息。 事件开始时,上述数据会被压入缓存,不会设置过期时间,事件状态变化到结束时,数据会被清空。
同时在web服务集群中,对这1 2个数据项做一个短期的本地缓存,以减少请求redis集群的网络开销。
2.4.2 验证
首先,服务器收到用户领取优惠券的请求后,会检查redis中是否有领取记录缓存。 这可以使用布隆过滤器来实现。 关键是优惠券 ID。 如果其中存在用户id,则直接返回。
然后,将检查优惠券的状态。 优惠券数据会在活动开始时推送到redis中,可以直接使用redis中的数据查看。
最后优惠券怎么制作,它会检查优惠券的库存数据是否存在。 为了提高容错性和可用性,如果不存在,则发送一个MQ初始化盘点任务,然后直接返回。 MQ 将异步重新初始化库存数据以避免缓存命中。 穿着问题。
2.4.3 库存扣除
使用乐观锁直接操作db扣库存。 MySQL的库存更新操作会成为并发热点,请求会在争抢行锁中被阻塞。 支持的并发数有限,会给db带来压力。 由于redis可以保证操作的原子性,而且数据在内存中,适合高并发场景,所以使用redis来完成库存的扣减。
db inventory 更新操作通过db message task table 异步序列化,避免阻塞,减少锁争用。
但是如果是基于redis进行库存的扣减,可以分为以下几种情况:
redis扣款成功优惠券怎么制作,但是db collection record和update inventory任务写入失败,执行回滚,增加库存数量。 redis扣款失败(缺货/redis宕机),不会进行db操作。 redis扣费成功。 db事务执行时,业务运行机器重启或宕机,库存未回滚,导致理赔不足。 redis扣费成功后,redis主库宕机,数据成功写入DB,但是扣费数据没有同步到从库,从库使用时出现过领现象扣除。
1 2 正常,3 4 异常。
对于case 3,当redis中的库存耗尽时,可以触发一个异步任务来比对库存数据。 如果还有可用的库存,更新redis的库存信息,避免出现欠收的情况。
对于case 4,由于redis不能保证主从强一致性,在数据操作丢失的情况下,可能会出现过领情况。
我的想法是有以下几种方式:
如果在更新库存操作中已经出现超收情况,删除或冻结用户收到的优惠券数据淘宝打折,以免造成损失。 剩余库存每变化5%,就会执行一个异步任务,冻结优惠券的状态,使C端无法收到优惠券。 然后在处理完当前消息事务表中db的扣账任务后,再进行redis和db的数据校验和同步。 同步完成后,优惠券解冻,恢复正常领取。 由于redis属于AP,如果要保证数据的强一致性,就需要牺牲可用性,改用CP存储。 核销联票时,检查核销数量是否超过总数量。 如果达到阈值,会提示优惠券不可用。 根据发放的优惠券总数,生成一批优惠券id,同时存入db和redis队列,通过pop id生成优惠券领取记录,根据是否领取限制重复领取id 相同,以防止过度收集。
第一种方式,当用户成功领取优惠券并执行更新db库存的任务时,在中间时间窗口很小的情况下,尽可能避免过度领取和使用的情况.
第二种方法并不能完全解决超额问题,只能在少量时间牺牲索赔功能,在索赔请求没有激增的情况下纠正数据不一致。
第三种方法不再详细描述。
第四种方式是被动核对数量,确保使用的优惠券不会超过发放的总量。 个人认为是比较软的处理方式,影响最小,但是可能会引起客户投诉,(为什么?我收到后还是不能用!!!)
第五种方式,根据发券总数在db中生成一批收藏记录,但收藏用户id和收藏时间为空。 将这批记录的id存入redis队列,扣库存时弹出队列。 获取到id后,通过乐观锁更新id对应的数据行(其中id=x,userId=0),如果更新失败则接收。 这样可以有效防止第四种情况的发生。 需要考虑的是,如果分发的金额太大,而实际收到的却很少,活动结束后需要清理占用的数据表空间。 插入db的数据需要事先预热,未绑定userId的数据需要事后清理。
在我的业务场景中,经过考虑,我主要选择了第四种方式。 第二种方法选择每天早上4点做一次。 第一种方法不删除,只报警。
如果有更好的方法,希望大神指点。
2.5 优惠券注销
以微服务的形式,提供用户优惠券获取接口和状态变化接口,供调用方使用。 状态流分为三种状态:未使用、锁定和使用。
当客户下签单时,相应的优惠券将被锁定。 如果订单完成,会调用该接口将状态更改为已使用。 如果订单被取消,回滚券的状态将变为未使用。
未来可以优化方向用户的优惠券数据分库和分表,数据可以水平拆分,提高读写db的能力。 Redis 集群分片部署,以提高可用性和容量。 集群拆分,每个集群只处理部分优惠券请求,通过网关将请求拆分到不同的pod。 引入jd-hotkey组件,hot key实时同步到集群本地缓存,减少对分布式缓存的访问。 引入canal组件,通过binlog同步db更新信息,更新缓存,进行数据联动更新。总结
至此,一个完整的优惠券系统已经构建完成。
遵循读多写少缓存,写多读少队列的原则。
对于显示的活动数据,代码尽可能采用代理方式通过缓存读取。 在使用多级缓存的同时,为了避免缓存崩溃的场景,主动将activity中的数据推送到redis。
对于activity->coupon->housing联动数据的写操作,代码通过状态模式+观察者模式实现,MQ控制并发的异步更新。
Redis用于库存扣减,依赖于它的原子性,但是redis不保证集群内的数据强一致性。 为避免套领造成的损失问题,在取消优惠券时进行数量阈值检查。
对于热点的db inventory更新,使用了db事务消息表。 通过事务保证当记录插入成功后,一定会落入更新库存的任务中,从而做到库存更新异步串行进行。