优惠券系统设计

前言

这是一道面试题也是对过去一段时间做优惠的总结。这里只是一个大概的设计,一些表结构,一些伪代码。有些字段的大小、字符集、索引、单词命名需要进一步细调,但不影响主体设计思路,这里就不细究了。

基础功能:建券发券用券

基础信息:coupon表

coupon表用来存储某一种券的基本信息,与用户无关。

CREATE TABLE `coupon` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键',
  `sn` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '优惠券唯一标示,对外的发放接口中应尽量用这个',
  `name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '优惠券名称',
  `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:可用1,停用2,作废3,等等',
  `issue_begin_time` datetime NOT NULL COMMENT '发放开始时间',
  `issue_end_time` datetime NOT NULL COMMENT '发放结束时间',
  `expiry_type` tinyint(1) NOT NULL DEFAULT '0' COMMENT '过期类型:1绝对过期时间,2相对过期时间',
  `absolute_begin_time` datetime DEFAULT NULL COMMENT '如果是绝对过期时间,券的有效期开始时间',
  `absolute_end_time` datetime DEFAULT NULL COMMENT '如果是绝对过期时间,券的有效期结束时间',
  `relative_begin_days` int(1) DEFAULT NULL COMMENT '如果是相对过期时间,领取后多少天后生效',
  `relative_end_days` int(1) DEFAULT NULL COMMENT '如果是相对过期时间,生效后多少天过期',
  `discount_type` tinyint(1) NOT NULL DEFAULT '0' COMMENT '优惠形式:1满减,2每满减,3折扣券,4阶梯券',
  `discount_value` decimal(10,2) NOT NULL COMMENT '优惠力度,满减券存多少元,折扣券存减百分之多少(0.00-1.00)',
  `threshold_price` decimal(10,2) DEFAULT NULL COMMENT '使用门槛(满多少元,根据公司规范可改成int,满多少分)',
  `ext_params` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '如果有阶梯券,可存其余阶梯门槛和力度,如果有折扣券可存最高减多少',
  `coupon_type` tinyint(1) NOT NULL COMMENT '优惠券类别:1普通券,2运费券',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `total_num` int(11) NOT NULL COMMENT '优惠券总量',
  `issued_num` int(11) NOT NULL COMMENT '发放数量',
  `user_limit` int(11) NOT NULL DEFAULT '1' COMMENT '单用户最多领多少张',
  PRIMARY KEY (`id`),
  UNIQUE KEY `id` (`id`),
  UNIQUE KEY `ux_sn` (`sn`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

过期类型与过期时间

优惠券一般会分为绝对有效期和相对有效期。
比如搞一次大促活动,只允许11月11日至11月15日使用的就是绝对有效期。
而新用户注册送的新人券,注册成功后7天内有效就是相对有效期。

  • 通过expiry_type字段来区分是哪种有效期
  • 如果是绝对有效期使用absolute_begin_time,absolute_end_time两个字段来确定一个准券的时间段。
  • 如果是相对有效期使用relative_begin_days,relative_end_days两个字段确定,领取后第几天生效以及生效后多少天过期(一般情况下是领取后立即生效,但也有些拉复购的券会要求过几天后才能使用)。

需要注意的是一般相对有效期的券都是以某一日23:59:59为结束时间,前台一般也不会显示过期的时分秒,所以如果是立即生效,领取当天一般不能算1天,算是送给用户的福利。

另外在优惠券系统中发放时间和使用时间是两个概念,即使是绝对有效期。因为大促之类的券是有可能提前发的。

优惠类型与优惠参数

优惠券可能会有多种优惠形式,比如满减、每满减、折扣券、阶梯券等等,因此我们通过discount_type,discount_value,threshold_price对优惠的力度与金额进行一次抽象,并用ext_params做一些差异化的参数存储。

  • 通过discount_type来区分是哪种优惠形式
  • discount_value用来表示减价的力度,比如满减和每满减用来表示多少元钱,折扣券用来表示减百分之多少(个人建议8.8折的时候存0.12而不是8.8,这样在计算订单金额的时候不会出现加减号不一致的问题),而阶梯券可以考虑存最高阶梯的减价金额(看上去效果好一些)。
  • threshold_price用来表示最低多少元可用。
  • 不同形式的优惠券有可能有自己特定的参数,这时可以用ext_params参数来存储,比如折扣券一般会有最高上限,比如96折最高减50元,而阶梯券有可能有满300减50满500减100这种多段规则,都可以以一个自定义的格式存到这个字段里。

总数量与发放数量

在发放优惠券时应先更新发放数量,并且在查询条件中加乐观锁。比如:UPDATE coupon SET issued_num = issued_num + 1 WHERE id = ? AND issued_num < total_num;,这样可以确保券不超发。

之所以使用issue_num+total_num而不是remain_num+total_num是因为当某一种券快要发完时,我们想去再增加点儿量的时候只需要改total_num这一个字段就可以,而不是要同时改remain和total两个字段。

如果担心发放数量会频繁更新,也可以考虑将这两个字段单独拆成一个计数器表,这样coupon表的内容就基本不会发生频繁变更了。

SN的作用:安全无小事

sn是个唯一键每种券的sn都不一样,SN是一串完全随机的字符串组成,千万不要用加密算法和摘要算法来生成。

我们提供给外部的接口应该让别人传sn进来而不是id。

使用SN不使用ID是因为ID是自增的,而SN是随机的,我们提供优惠券这种基础服务时可能无法掌控对接方如何使用,有可能有人搞个页面或请求/activity/getCoupon.php?couponId=456来让用户领取优惠券,如果被薅羊毛的发现这种页面,他们就可以从1一直遍历试到N把你所有能领的券都领了,有的券甚至是非公开的大额券或其它活动抽奖的券,这样就有可能会产生资损,而SN是完全随机即使有人写了个/activity/getCoupon.php?couponSN=aXdj91h也难以被遍历。

当然最好是能推进业务方连SN都不要暴露给前端,可以改造为/activity/getCoupon.php?activityId=12345之类的。

用户领到的券:user_coupon表

user_coupon表是用户领到的券,用户每领一张券,这个表中就会增加一条数据(同一种优惠券有可能领取两张,所以uid和coupon_id没有加唯一索引)

CREATE TABLE `user_coupon` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `uid` bigint(20) unsigned NOT NULL COMMENT '用户ID',
  `coupon_id` bigint(20) unsigned NOT NULL COMMENT '券ID',
  `issued_time` datetime NOT NULL COMMENT '发放时间/领取时间',
  `effective_time` datetime NOT NULL COMMENT '生效时间,无论是相对还是绝对有效期,都能在领取时确定生效时间',
  `expiry_time` datetime NOT NULL COMMENT '过期时间,无论是相对还是绝对有效期,都能在领取时确定过期时间',
  `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '状态:1未使用,2已使用,3已退款,4过期,5作废',
  `used_order_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT '订单号,数据类型要参考订单系统',
  `used_time` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT '使用时间(即下单时间)',
  `refund_from` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '如果发生退款且退券,记录本券是因为退那张券而生成的新券',
  `issue_channel_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '发放渠道ID,核算部门成本的因素之一',
  PRIMARY KEY (`id`),
  UNIQUE KEY `id` (`id`),
  KEY `ix_coupon_id` (`coupon_id`) USING BTREE,
  KEY `ix_uid_status` (`uid`,`status`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

有效期

无论是绝对有效期还是相对有效期,在用户领券的时候都能确定下来当前用户的这张券有效期的开始时间effective_time和过期时间expiry_time,虽然通过coupon表的字段加上这个表的发放时间能动态的计算出来有效期,好像可以去掉这两个字段,但是将他们冗余下来,可以降低程序复杂读并加快后续的一些操作速度,而且根据以往的经验coupon表中的有效期是有小概率会发生调整的,这时我们原则是只能影响新领的券而不能影响已经到用户手上的券,所以多存两个字段还是利大于弊的。

退款退券问题:退原券还是发新券

当订单未支付取消支付了,或者支付成功去退款了,这时我们应当将券退还给用户,让用户可以继续使用这张券。这里会有两种退券方案:

  • 直接将券的状态改为未使用
    • 优势:足够简单,而且不会产生多余数据
  • 将券的状态改为已退,同时生成一张新券给用户
    • 优势:我们可以确保每张券(或者说每个id)只会被用在一个订单上,数据不会被覆盖,出现问题时有迹可循

这两种方案各有各的优势,可以看情况选择,可以都用第一种或都用第二种,也可以取消支付用第一种退款用第二种。

退款退券问题:部分退款

引申一个订单部分退款的问题:退款退券是一个相对复杂的问题,因为有可能有购物车或者业务形态的限制,会出现部分退款的问题。比如我买了5个2元的本,一共10元,用了一张满5元减1元的优惠券,最终支付的9元,当我退掉一个本的时候就会出现问题。

  • 有人认为应该退1元钱,并且把券退给用户
  • 有人认为应该退2元钱,但不退券给用户

这两种方式其实都有问题,一个容易造成纠纷,一个容易造成薅羊毛,而且在一些极端的大折扣券处理上有可能出现多次退款最后一次无钱可退的问题。

比较好的方式是按比例退,既我只退给用户1.8元,因为1个本原价2元,占总原价的20%,那么在退款时我也至退给用户支付金额的20%,既9*0.2=1.8元,而优惠券此时不退还给用户,除非本单使用了优惠券的所有商品都全额退款了,再退给用户优惠券。

再引申一个对账的问题:当发生部分退款时,此时在对账或财务系统中记录的优惠券方面的成本或支持也应等比减少。

垃圾数据清理

有一个不可否认的问题就是发的券比真正用的券要多,而且要多很多。当一段时间后user_coupon表就会出现大量过期优惠券,会相当占用表空间。这时我们可以考虑清理数据或分表。

  • 清理数据:这里指的就是物理删除了,其实用户一般不会太关心很久以前就过期了的优惠券,因此我们可以定期将三个月前就过期的券物理删除掉。
  • 分表:如果不想物理删除,还可以考虑分表。分表分为两种,一种是按uid来分表,这样可以减少每张表的大小,另一种可以根据状态来分,比如我们把未过期的放一张表里,已过期的放另一张表里甚至另一个库里,就像冷热数据一样。当然两种分表也可以结合使用。

接口定义

基础功能的接口没有太多可说的,主要就是后台的增删改查,和前台的查询及下单接口。

需要注意的是因为我们设计的优惠券系统允许使用多张优惠券且有多种费用项(商品金额、邮费),因此下单的逻辑会复杂一些,需要逐个费用项检查是否符合传进来的优惠券是否可用。虽然下单页中的优惠券列表是我们返回的,但并不意味着提交订单传进来的数据就一定是我们给的,请求有可能篡改也有可能产生并发,再次强调一遍:安全无小事

另外因为我们在底层数据结构就已经将优惠类型和优惠力度进行了抽象,因此我们在写代码的时候也可以面向接口编程了。比如我们定一个了,Discount的interface,接口中定义了检查是否可用和计算优惠多少钱。那么我们分别将满减、每满减、折扣、阶梯作为四种不同的实现类去实现就可以了。这样的话当我们在写CouponService的时候就不需要考虑每一种类型该如何计算了,我们的Service也可以和具体的某一种玩法解藕了,当将来增加新玩法时,只要增加新的实现类并注册到工厂即可,无需修改Service。

/**
 * coupon表的模型,字段省略
 */
class Coupon
{
    public int $id;
    private DiscountFactory $discountFactory;
    private Discount $discount;
    public function getDiscount():Discount {
        if ($this->discount == null) {
            $this->discount = $this->discountFactory->createDiscount($this->discountType,$this->discountValue,$this->thresholdPrice, $this->extParams);
        }
        return $this->discount;
    }
}
/**
 * user_coupon表的模型,字段省略
 */
class UserCoupon
{
    public int $uid;
    /*...此处省略一大堆代码...*/
    public Coupon $coupon;
}

/**
 * 优惠力度对象工厂
 */
class DiscountFactory {
    private array $conf = [1=>PriceDiscount::class, 2=>PerPriceDiscount::class, 3=>RateDiscount::class, 4=>LadderDiscount::class];
    public function createDiscount($discountType, $discountValue, $thresholdPrice, $extParams=null) {
        if (array_key_exists($discountType, $this->conf)) {
            return new $this->conf[$discountType]($discountValue, $thresholdPrice, $extParams);
        }
        throw new RuntimeException("not implement");
    }
}

/**
 * 减价规则的抽象接口,每一种优惠形式都需要实现这个接口
 */
interface Discount {
    /**
     * 检查当前订单是否可用
     * @param OrderInfoDTO $orderInfo
     * @return bool
     */
    public function checkUsable(OrderInfoDTO $orderInfo): bool ;
    /**
     * 计算当前订单可键多少钱
     * @param OrderInfoDTO $orderInfo
     * @return float
     */
    public function calculate(OrderInfoDTO $orderInfo): float ;
}
class PriceDiscount implements Discount {/*TODO*/}
class PerPriceDiscount implements Discount {/*TODO*/}
class RateDiscount implements Discount {/*TODO*/}
class LadderDiscount implements Discount {/*TODO*/}

/**
 * 订单不是本文重点只是简单列几个字段,订单与补贴分摊又是一大篇文章了
 */
class OrderInfoDTO
{
    private string $orderId;
    private float $productFee;
    private float $postage;
}

/**
 * 优惠券服务
 */
class CouponService
{

    /**
     * 发放优惠券
     * @param int $uid
     * @param string $couponSN
     * @return UserCoupon|null
     */
    public function sendCoupon(int $uid, string $couponSN): ?UserCoupon;

    /**
     * 使用优惠券
     * 需要根据订单信息中的各种费用项来判断传入的userCouponIds是否都可使用
     * 要注意需要检查这些券是不是当前用户的,避免该请求使用别人优惠券的漏洞
     * @param int $uid
     * @param array $userCouponIds
     * @param OrderInfoDTO $orderInfo
     * @return bool
     */
    public function useCoupons(int $uid, array $userCouponIds, OrderInfoDTO $orderInfo): bool;

    /**
     * 我的优惠券列表
     * @param int $offset
     * @param int $limit
     * @param int $uid
     * @param int $status
     * @return array list:UserCoupon[], total:int
     */
    public function getCouponListByUid(int $offset, int $limit, int $uid, int $status): array ;

    /**
     * 根据下单页的订单信息返回可用优惠券列表
     * @param int $uid
     * @param OrderInfoDTO $orderInfoDTO
     * @return UserCoupon[]
     */
    public function getCouponListByBookingPage(int $uid, OrderInfoDTO $orderInfoDTO): array;

}

class CouponInternalService
{

    public function getUserCoupon(int $id): UserCoupon;
    public function getCouponById(int $id): Coupon;
    public function getCouponBySN(string $sn): Coupon;

    public function create();

    /**
     * 更新优惠券时需要注意优惠力度等关键信息一旦创建不允许修改,如果没有发给用户,可以作废重新创建
     * @return mixed
     */
    public function update();

    /*...此处省略优惠券日常操作的一系列增删该查接口...*/
}

进阶一:使用范围与发放范围

基础部分讲的都是一站通用的优惠券,只有使用金额的方面的限制,当业务规模扩大后会开始加各种范围的限制,比如部分品类可用、部分商品可用、女生专享、江浙沪专享。

总体上分为两大类:领取限制和使用限制,使用限制完全由优惠券系统来实现,而领取限制优惠券系统只能做一部分,更多的要靠对接业务方来做,比如搞个抽奖活动,用户是否能抽中这张券也算是领取限制,但把它放到优惠券系统明显不合适。

抽象使用范围表:coupon_use_range

CREATE TABLE `coupon_use_range` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `coupon_id` bigint(20) unsigned NOT NULL COMMENT '优惠券ID',
  `range_type` tinyint(1) DEFAULT NULL COMMENT '范围类型:1商品,2品类,3性别,4区域,5...',
  `value` varchar(128) NOT NULL COMMENT '范围值,如商品ID、品类ID、性别、区域ID',
  PRIMARY KEY (`id`),
  UNIQUE KEY `id` (`id`),
  KEY `ix_coupon_id` (`coupon_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

这是张一对多的表,一种优惠券可以有多种使用范围,每种使用范围也可以有多个使用范围的规则。

  • range_type来决定这一行是什么类型的限制,比如是商品限制还是品类限制
  • value字段需要根据range_type来看是什么含义,可以是商品ID,也可以是品类ID
  • 相同的range_type在使用时是“或”的关系,比如range_type都是商品,value分别是123和456的两条记录代表123和456这两个商品都可以使用这个优惠券。
  • 不同的range_type在使用中是“且”的关系,比如range_type分别是品类和区域,value是123和bj,那么就代表123这个商品只有在bj购买时才可以使用这张券。

这样设计的好处时,将来再增加各种奇奇怪怪的范围时不用频繁改。

发放范围和使用范围的表结构基本一致,只是一个在发放接口用,一个在下单接口用,就不再赘述了。

分表建议

初期可以不用考虑分表,如果量很大了,可以考虑按coupon_id进行分表,或者按range_type拆表后再按value分。有可能会同时出现多种分表方案,作用也不同,有一定的可玩性。

接口定义

同样因为我们在数据层就很好的抽象出了范围这个概念,因此我们也可以在代码中将范围抽象为接口,在Service中之间面向接口编程而不是面向实现编程

class Coupon
{
    public int $id;
    /**
     * @return UseRange[]
     */
    public function getUseRangeList(): array {
        /*todo*/
    }
}
interface UseRange {
    public function checkUsable(OrderInfoDTO $orderInfo);
}
class ProductUseRange implements UseRange {}
class CategoryUseRange implements UseRange {}
class GenderUseRange implements UseRange {}
class AreaUseRange implements UseRange {}

/**
 * 优惠券服务
 */
class CouponService
{
    /**
     * 使用优惠券
     * 需要根据订单信息中的各种费用项来判断传入的userCouponIds是否都可使用
     * 要注意需要检查这些券是不是当前用户的,避免该请求使用别人优惠券的漏洞
     * @param int $uid
     * @param array $userCouponIds
     * @param OrderInfoDTO $orderInfo
     * @return bool
     * @throws CheckException
     */
    public function useCoupons(int $uid, array $userCouponIds, OrderInfoDTO $orderInfo): bool {
        //$userCoupons = getUserCoupons($userCouponIds);
        foreach ($userCoupons as $userCoupon) {
            foreach ($userCoupon->getUseRangeList() as $useRange) {
                if (!$useRange->checkUsable($orderInfo)){
                    throw new CheckException("not usable");
                }
            }
        }
        // ...省略代码...
    }
}

进阶二:发放渠道

当业务再发展发展接入的业务方越来越多,这时我们有可能会限制某一种券可以在什么渠道发放,比如某个活动页,APP专享领券,在其它的渠道下即使拿到了sn也无法领取。

也有可能出现同一批券在不同的地方发放,但是会根据用户领取的渠道决定营销成本由哪各部门承担。

这时我们需要引入发放渠道的概念,它是实现以上两种需求的基础。(但不是全部,还需要业务方配合)。我们可以给不同的业务不同的场景预先申请不同的渠道ID,当各个业务调用优惠券的接口时需要将渠道ID传进来,这个渠道ID只能发放和搜索与他相关的券(有点像个小型权限系统),除了渠道ID,我们还可以给每个渠道分配一个密钥,更进一步保证系统的安全性,防止别人盗用自己的渠道ID。如果还想再进一步确保安全,还可以将密钥定义为私钥用它做签名,可以防止抓包盗密钥。不过一般来说做到密钥就可以了,私钥是为了解决网络请求不可信的问题(比如支付回调、开放平台),而局域网内内部服务间调用一般不会出这种问题。

渠道定义表:issue_channel

渠道表很简单除了id、名称、状态就剩一个secret_key如果不做密钥验证的话这个字段也可以去掉。

CREATE TABLE `issue_channel` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '发放渠道ID',
  `name` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '发放渠道名称',
  `secret_key` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '发放密钥',
  `status` tinyint(11) DEFAULT NULL COMMENT '状态:1有效,2作废',
  PRIMARY KEY (`id`),
  UNIQUE KEY `id` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

中间表:coupon_issue_channel

issue_channel表和coupon表是多对多的关系,一种券可以在多个渠道发放,一个渠道也可以发放多种券,所以需要建立一张中间表来关联关系。

CREATE TABLE `coupon_issue_channel` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `coupon_id` bigint(20) unsigned NOT NULL COMMENT '优惠券ID',
  `channel_id` bigint(20) unsigned NOT NULL COMMENT '渠道ID',
  PRIMARY KEY (`id`),
  UNIQUE KEY `id` (`id`),
  UNIQUE KEY `ux_coupon_id_channel_id` (`coupon_id`,`channel_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

接口调整

接口方面没啥可讲,就是基础的增删改查,无非就是在原有的接口上增加一到两个验证渠道的参数,方法实现种再用这两个参数和优惠券信息校验是否一直。

/**
 * 优惠券服务
 */
class CouponService
{

    /**
     * 发放优惠券
     * @param int $uid
     * @param string $couponSN
     * @return UserCoupon|null
     */
    public function sendCoupon(int $uid, string $couponSN, int $channelId, string $channelSecretKey): ?UserCoupon;

}

进阶三:券包和券码线下发放

券包和和线下发放是两种高级一点的玩法,但都可以用券包的形式实现。

  • 券包:一个券包中可以包含多种优惠券,一次性领取所有优惠券。
  • 线下发放:线下发放给用户一个兑换码,用户在我的优惠券中或活动页中输入使用,有点像储值卡开卡,或者游戏中的口令礼包。
    • 可以一码一券,也可以一码多券。
    • 可以每人一码(比如礼品卡,每张卡上的码都不一样),也可以共用一码(比如活动口令,所有人输入的口令都一样)。

券包基础信息:coupon_package

由于分为线上和线下两种类型的券包,我们通过type来区分,线上发放的券包传sn即可发放。total_num决定这个券包可以发放多少次。

CREATE TABLE `coupon_package` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `sn` varchar(32) NOT NULL COMMENT '券标唯一标识,对外接口应使用这个字段',
  `type` tinyint(1) NOT NULL COMMENT '码的类型:1线上发放,2线下发放',
  `name` varchar(128) NOT NULL COMMENT '券包名称',
  `status` tinyint(1) NOT NULL COMMENT '状态:1可用,2作废',
  `issue_begin_time` datetime NOT NULL COMMENT '发放开始时间',
  `issue_end_time` datetime NOT NULL COMMENT '发放结束时间',
  `total_num` int(11) NOT NULL COMMENT '总数量,一次性兑换码应为1',
  `issued_num` int(11) NOT NULL COMMENT '已发放数量',
  PRIMARY KEY (`id`),
  UNIQUE KEY `id` (`id`),
  UNIQUE KEY `ux_sn` (`sn`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

优惠券关联表:coupon_package_item

这个表用来关联券包中包含哪些券,因为有可能同一种券在一个券包中出现多次,因此增加一个num字段,最小值应为1。

CREATE TABLE `coupon_package_item` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `coupon_package_id` bigint(20) unsigned NOT NULL COMMENT '券包ID',
  `coupon_id` bigint(20) unsigned NOT NULL COMMENT '券ID',
  `num` tinyint(11) unsigned NOT NULL DEFAULT '1' COMMENT '同一种券的发放数量',
  PRIMARY KEY (`id`),
  UNIQUE KEY `id` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

线下发放的券码表:coupon_package_code

这张表存线下发放的兑换码,total_num决定这个码可以使用几次。code可手动指定也可随机生成,建议在8-12位间,并且随机生成时应尽量排除“1lIO0”这类在打印后有可能让人猜错的字符

CREATE TABLE `coupon_package_code` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `coupon_package_id` bigint(20) unsigned NOT NULL COMMENT '券包ID',
  `code` varchar(16) NOT NULL COMMENT '券码',
  `total_num` int(11) NOT NULL COMMENT '总数量,一次性兑换码应为1',
  `issued_num` int(11) NOT NULL COMMENT '已发放数量',
  PRIMARY KEY (`id`),
  UNIQUE KEY `id` (`id`),
  UNIQUE KEY `ux_code` (`code`),
  KEY `ix_coupon_package_id` (`coupon_package_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

接口定义

在实现接口的时候应注意,应先增加package中的issued_num,如果是下线码还应在去增加code表对应的issued_num两个通过乐观锁都修改成功后再逐次调用发放单张优惠券的接口。如果要发的优惠券较多,可以考虑更新完两个num后抛到队列中发放,并立即给用户发挥成功的信息。放到队列中会导致事务割裂,即数量加上去了,但券没发成功。出现这种情况我们可以通过日志告警自动修复等级制来保证最终一致性。额外说一句在做发放类的操作时,一定要先扣减数量,再发放。发放失败我们可以后期补偿,但如果发放成功扣减数量失败就会造成超发的问题,发到用户手上的东西再要回来就会比较麻烦了。

另外接口中区分了线上可线下两种,如果下线发放的填兑换码的入口可以收归到一处由优惠券系统来维护,那么下线券的兑换接口可以挪到CouponInternalService中作为一个内部接口。

/**
 * 优惠券服务
 */
class CouponService
{
    public function useOnlineCouponPackage(int $uid, string $couponPackageSN): bool ;
    public function useOfflineCouponPackage(int $uid, string $couponPackageCode, string $couponPackageSN = null): bool ;
}

延伸一:日志、监控、告警

因为优惠券涉及到钱的问题,因此在一些关键步骤应记录下日志,比如发券、用券,如果一些异常程序中无法自动降级要根据这种日志进行告警。

另外一些表中需要加上created_at,updated_at,以及最后操作人uid等等,这些都是为了排查问题时有迹可循。

RabbitMQ/AMQP中的一些概念以及与Redis/Kafka/RocketMQ的区别

消息投递:Exchange & RoutingKey

在投递消息时需要指定exchange和routingKey,而不是指定队列名。

在投递消息时和kafka、rocketmq不同,消息并不是通过客户端投递到Queue中的,而是投递到Exchange,然后Exchange根据自身的规则以及传进来的routingKey决定将消息分发到那个或哪些Queue。

exchange只是转发规则,并不会负责存储任何消息。

exchange有多种类型,不同的类型决定了如何使用routingKey(比如全匹配、通配符匹配)

消费消息:Queue

Queue是负责存储消息的,和其他队列一样消费者在消费消息时也是直接从Queue中消费。

结合上面对消息投递的说明,会发现投递和消费并不是同一个东西,投递时用的时Exchange,消费时用的是Queue,这就是RabbitMQ和其它队列的一个重大区别。

Queue中的消息如果被消费了(并且Ack了),将彻底从RabbitMQ中删除。这一点和Redis比较像,与Kafka/RocketMQ完全不同。

因此不能重新消费历史数据,也不可以让两个不同的业务消费同一个Queue

路由规则:Binding

Binding负责将Exchange和Queue串联起来(也可以将Exchange和另一个Exchange串联起来,这个是RabbitMQ的实现,不是AMQP协议的规范)

设置Binding的时候要指定一个Exchange和Queue,同时指定routingKey是什么,以direct类型的Exchange为例,当exchange1和queue1中设置了一个abc的routingKey,那么当客户端往exchange1投递消息,并且带上abc这个routingKey,消息就会进入queue1

如何两个业务消费相同的消息?

在使用消息队列时经常会用到订阅主题的模式(观察者模式),他可以大大降低代码的耦合度,比如在注册账号时用户服务本身只是需要把user表创建好就可以了。但是往往在注册环节会有大量的额外工作,比如发欢迎邮件、送新人礼包、给大数据中心提供信息等等,这些业务虽然和注册行为有关,但都放到注册的业务流程里会让代码耦合度很高,不宜扩展,比如送新人礼包这件事儿就随时有可能调整或下线,但他又不会影响注册这件事儿。这时我们可以让用户服务在创建完用户后发送一条又新用户注册的消息出来,用户服务自己不去管后面还有哪些逻辑,而是由需要观察注册这个事件的业务方去订阅这个消息并处理自己的业务。

在Kafka和RocketMQ中可以通过使用不同的Group分别记录各自的offset实现同一条消息被不同的业务消费,但是RabbitMQ在消费成功后会立即从Queue中删掉这条消息,那么如何在RabbitMQ中对同一条消息消费两次呢。

可以在一个exchange上设置两个相同的routingKey但不同Queue的binding,这时消息在经过exchange1后会被分别投递到queue1和queue2

需要注意一点,当创建queue2的那条binding时,并不会将之前的已经路由过的abc消息复制过来,只会将创建binding后的产生新消息复制过来。

一个队列也可以订阅多种消息

因为queue和exchange之间是多对多的关系,因此也可以让一个queue和多个exchange建立关联关系,甚至一个queue和同一个exchange建立多条关联关系。

特殊Exchange(default)

RabbitMQ中有一个默认的自带的Exchange,无法被删除,在后台看名字叫(default),后台的名字中就带括号。且无法给这个exchange增加binding,但是它有一个隐含的规则,会将消息按生产者客户端传入的routingKey,路由到同名的queue中。

客户端如果想使用这个Exchange,在发送消息时Exchange应当设置为空或空字符串(不同的语言可能略有差异)。

无法路由

如果生产者发消息时传入了一个不存在的exchange,或者这个exchange中没有能够应用在以这一条消息上的Bindding,那么这条消息将直接丢弃。

HashMap的原理与简单实现

数组

HashMap底层本质上是一个固定长度的数组

根据Key定位到数组

HashMap的“hash”其实就是对key做哈希,比如将所有字符的charCode加起来然后再除以数组长度后取模(只是举个简单的例子,比一定要用这种算法,无论用那种算法,都会尽量保证当key足够多时,哈希后的结果能正态分布)。这样无论传什么key最终都会被转换成某一个数组下标,并且相同的key两次调用时获得的数组下标肯定是一样的。

class HashMap {

    /** @type {*[]} */
    _array = [];
    /** @type {number} */
    _size = 0;

    constructor(size = 8) {
        if (size < 1) {
            throw new Error("size error")
        }
        this._size = size;
        this._array = new Array(size);
        this._array.fill(null);
    }

    put(key, value) {
        const index = this._hashCode(key);
        this._array[index] = value;
    }

    get(key) {
        const index = this._hashCode(key);
        return this._array[index];
    }

    _hashCode(str) {
        //这里只是举个简单的例子,将所有的字符相加得出一个数字再取模
        str = String(str);
        let n = 0;
        let i = str.length - 1;
        while (i--) {
            n += str.charCodeAt(i);
        }
        return n % this._size;
    }
}

处理碰撞的问题

由于数组的长度是固定的,当key足够多的时候会出现两个key共用同一个数组下标的问题,比如长度为8的map,如果key为9或17最终都会定位到数组下标为1的位置上。这时就要引入单向链表的逻辑。
当往数组中存数据时,除了将value存进去还要将原始的key也存进去,同时要加一个next字段,如果发生了碰撞,第二个key不会直接存到数组里,而是将之前那条数据的next指向它。
当查询时获取到数组下标后先从数组中取到数据,然后再判断原始key和传进来的key是否一致,如果不一致再判断next的key是否一致,直到找到一致的或没有next

class HashMap {

    /** @type {HashMap.HashMapEntity[]} */
    _array = [];
    /** @type {number} */
    _size = 0;
    /** @type {number} map中的key数量 */
    length = 0;

    constructor(size = 8) {/*...*/}

    put(key, value) {
        const index = this._hashCode(key);
        let entity = this._array[index];
        if (entity === null) {
            //数组中这个位置是空的
            this._array[index] = new HashMap.HashMapEntity(key, value);
        } else {
            //数组中这个位置已经有东西了
            do {
                if (entity.key === key) {
                    entity.value = value;
                    return false;//key已存在,直接更新value
                }
            } while (entity.next !== null && (entity = entity.next));
            //循环结束没有发现相同的key,则创建一个新的实体并挂到链表的末尾
            entity.next = new HashMap.HashMapEntity(key, value);
        }
        this.length++; //key不存在,添加了新值
        return true;
    }

    get(key) {
        const index = this._hashCode(key);
        let entity = this._array[index];
        while (entity !== null) {
            if (entity.key === key) {
                return entity.value;
            } else {
                entity = entity.next;
            }
        }
        return undefined;
    }

    _hashCode(str) {/*...*/}

    static HashMapEntity = class {

        /** @type {string} */
        key;

        /** @type {HashMap.HashMapEntity} */
        next = null;

        /** @type {*} */
        value;

        constructor(key, data) {
            this.key = key;
            this.value = data;
        }
    }
}

效率与扩容

如果数组的长度很小,但是插入的key很多,这时就会出现大量的碰撞,虽然通过链表解决了这个问题,但是当链表长度很长时,插入和查询都会很慢,因为每次都要从链表的头开始查,最倒霉的情况要遍历整个链表才知道是否存在。
这时我们可以对数组进行扩容,这样碰撞的概率就会小很多。
因为扩容后数组的长度发生了改变,因此原有的哈希值对应的数组下标也有可能发生改变。比如数组长度为8时key为9和17时都对应的数组下标1,但是当数组长度扩充到16时,key为9时对应的数组下标变成了9。
因此扩容后我们还需要重排数据。这就意味着每次扩容可能会产生很大的开销,所以java的代码规范里要求在使用HashMap时最好预判数据量在初始化时指定好数组的长度,避免在运行的过程中反复扩容。在使用其它语言开发时如果hashMap/map/dictionary之类的如果能指定初始长度,最好也可以预判一下然后设置好初始值。

class HashMap {

    /** @type {HashMap.HashMapEntity[]} */
    _array = [];
    /** @type {number} */
    _size = 0;
    /** @type {number} map中的key数量 */
    length = 0;

    constructor(size = 8) {/*...*/}

    put(key, value) {
        const index = this._hashCode(key);
        let entity = this._array[index];
        /*...省略略中间代码...*/
        this.length++; //key不存在,添加了新值
        if(this.length > this._size * 0.75) {
            // 当数据量超过预设长度的75%时进行扩容
            this._resize();
        }
        return true;
    }

    get(key) {/*...*/}

    _hashCode(str) {/*...*/}

    _resize() {
        this.length = 0;
        const oldArray = this._array;
        // 创建新的,长度翻倍的数组
        this._size = this._size * 2; 
        this._array = new Array(this._size);
        this._array.fill(null);
        // 遍历旧数组和链表,将每一对key/value复制到新数组中
        oldArray.forEach(entity => {
            while (entity !== null) {
                this.put(entity.key, entity.value);
                entity = entity.next;
            }
        })
    }

    static HashMapEntity = class {/*...*/}
}

附:完整实现

class HashMap {

    /**
     * @type {number}
     * @private
     */
    _size;

    /**
     * @type {HashMap.HashMapEntity[]}
     * @private
     */
    _array;

    /**
     * map的长度(key的数量)
     * @type {number}
     * @private
     */
    _length = 0;

    /**
     * map的长度(key的数量)
     * @return {number}
     */
    get length(){
        return this._length;
    }

    constructor(size = 8) {
        if (size < 1) {
            throw new Error("size error")
        }
        this._size = size;
        this._array = new Array(size);
        this._array.fill(null);
    }

    /**
     * @param {string} key
     * @param {*} value
     */
    put(key, value) {
        if (this._put(key, value)) {
            if (++this._length > this._size * .75) {
                this._resize()
            }
        }
    }

    /**
     * @param {string} key
     * @param {*} value
     * @return {boolean}
     * @private
     */
    _put(key, value) {
        const index = this._hashCode(key);
        let entity = this._array[index];
        if (entity === null) {
            this._array[index] = new HashMap.HashMapEntity(key, value);
        } else {
            do {
                if (entity.key === key) {
                    entity.value = value;
                    return false;
                }
            } while (entity.next !== null && (entity = entity.next));
            entity.next = new HashMap.HashMapEntity(key, value);
        }
        return true;
    }

    /**
     *
     * @param {string} key
     * @return {*}
     */
    get(key) {
        const index = this._hashCode(key);
        let entity = this._array[index];
        while (entity !== null) {
            if (entity.key === key) {
                return entity.value;
            } else {
                entity = entity.next;
            }
        }
        return undefined;
    }

    /**
     *
     * @param {string} key
     * @return {boolean}
     */
    keyExists(key) {
        const index = this._hashCode(key);
        let entity = this._array[index];
        while (entity !== null) {
            if (entity.key === key) {
                return true;
            } else {
                entity = entity.next;
            }
        }
        return false;
    }

    /**
     *
     * @param {string} key
     * @return {boolean}
     */
    delete(key) {
        const index = this._hashCode(key);
        /** @type {HashMap.HashMapEntity} */
        let current = this._array[index];
        /** @type {HashMap.HashMapEntity} */
        let prev = null;
        while (current !== null) {
            if (current.key === key) {
                if (prev === null) {
                    if (current.next !== null) {
                        this._array[index] = current.next;
                    } else {
                        this._array[index] = null;
                    }
                } else {
                    prev.next = current.next
                }
                this._length--;
                return true;
            } else {
                prev = current;
                current = current.next;
            }
        }
        return false;
    }

    /**
     * 数组扩容
     * @private
     */
    _resize() {
        const oldArray = this._array;
        this._size <<= 1;
        this._array = new Array(this._size);
        this._array.fill(null);
        oldArray.forEach(entity => {
            while (entity !== null) {
                this._put(entity.key, entity.value);
                entity = entity.next;
            }
        })
    }

    /**
     * 计算哈希值
     * @param {string} str
     * @return {number}
     * @private
     */
    _hashCode(str) {
        str = String(str);
        let n = 0;
        let i = str.length - 1;
        while (i--) {
            n += str.charCodeAt(i);
        }
        return n % this._size;
    }

    static HashMapEntity = class {
        /**
         * @type {string}
         */
        key;

        /**
         * @type {HashMap.HashMapEntity}
         */
        next = null;

        /**
         * @type {*}
         */
        value;

        constructor(key, data) {
            this.key = key;
            this.value = data;
        }
    }

}

Semantic-Versioning 语义化版本控制

一个标准的版本号必须是X.Y.Z的形式,X是主版本,Y是副版本,Z是补丁版本。.

  • X: 代表发生了不兼容的API改变
  • Y: 代表向后兼容的功能性变化
  • Z: 代表向后兼容bug fixes

~x.y.z: 匹配大于 x.y.z 的 z 的最新版
^x.y.z: 匹配大于 x.y.z 的 y.z 的最新版
当 x 为 0 时,^x.y.z 等价于 ~x.y.z,即只会安装z 的最新版本;
当 x 和 y 为 0 时,^x.y.z 等价于 x.y.z,即只会安装x.y.z 版本;

语义化版本号规则

X.Y.Z – A.B.C 连字符范围

1.2.3 – 2.3.4 等价于 >=1.2.3 <=2.3.4
1.2.3 – 2 等价于 >=1.2.3 <3.0.0

~1.2.3 波浪线范围

~1.2.3 等价于 >=1.2.3 <1.(2+1).0 等价于=””>=1.2.3 <1.3.0
~1.2 等价于 >=1.2.0 <1.(2+1).0 等价于=””>=1.2.0 <1.3.0 (Same as 1.2.x)
~1 等价于 >=1.0.0 <(1+1).0.0 等价于 >=1.0.0 <2.0.0 (Same as 1.x)

~0.2.3 等价于 >=0.2.3 <0.(2+1).0 等价于=””>=0.2.3 <0.3.0
~0.2 等价于 >=0.2.0 <0.(2+1).0 等价于=””>=0.2.0 <0.3.0 (Same as 0.2.x)
~0 等价于 >=0.0.0 <(0+1).0.0 等价于 >=0.0.0 <1.0.0 (Same as 0.x)

^1.2.3 脱字符范围

脱字符范围之后指定从左面起第一个非零位置的范围。

^1.2.3 等价于 >=1.2.3 <2.0.0
^0.2.3 等价于 >=0.2.3 <0.3.0
^0.0.3 等价于 >=0.0.3 <0.0.4,即等价于0.0.3

当然如果最后一位省略了或为通配符x,X,*,则指定前一位字符的范围,如

^1.2.x 等价于 >=1.2.0 <2.0.0
^0.0.x 等价于 >=0.0.0 <0.1.0
^0.0 等价于 >=0.0.0 <0.1.0

参考连接:https://semver.org/lang/zh-CN/

无线电字母

A: Alpha
B: Bravo
C: Charlie
D: Delta
E: Echo
F: Foxtrot
G: Golf
H: Hotel
I: India
J: Juliet
K: Kilo
L: Lima
M: Mike
N: November
O: Oscar
P: Papa
Q: Quebec
R: Romeo
S: Sierra
T: Tango
U: Uniform
V: Victor
W: Whiskey
X: X-ray
Y: Yankee
Z: Zulu