优惠券系统架构设计


1.内容介绍

优惠券系统在业务层面承接O2O一小时达业务核心促销,是京东到家营销工具的核心利器,为了支持复杂的业务和高流量的场景,对缓存Redis的应用必不可少。本章内容主要介绍京东到家优惠券系统的架构演化,以及承接高流量的Redis集群实战经验。本文的数据支撑进行了脱敏处理,如有不清晰之处,请见谅。

2.缓存现状

1.数量多

5大缓存集群,缓存支持了约200种业务场景。

2.流量高

集群总内存使用量为T级,QPS高峰期能有每秒千万调用量。

3.场景复杂,IO多

图片

上图为到家门店主页,在门店主页优惠券系统承接5处利益曝光,从上向下分别为:门店运费优惠、门店商品券、红包、商品预估到手价、购物车领券结算,其中有6个优惠券接口承接这5处资源位。介绍下商品预估到手价和购物车领券结算。

预估到手价:承接以商品维度利益优惠,到家平台有商品售卖的地方几乎都承接商品维度的优惠券利益点,涉及商品曝光场景都有预估到手价,调用量50个商品批量调用,能达几百万/分钟调用量,同时优惠券会涉及剩余库存校验、每日领取数量库存校验、总领券数量校验、用户每日领取库存、用户总领取库存,所以优惠券的IO量级比较高。

购物车领券结算:目前该功能为O2O行业最先投入使用,并且用户体验较好。其中图上黄条区域(底部红框的内容)主要是告诉用户购物车当前商品可以使用的优惠,目前主要承载优惠券和运费券优惠提醒,该功能如下图:

图片

用户已领券50~200张券,未领券大概50~150张券,用户一次请求对应的优惠券自身大概裂变400~1000次网络IO,其中主要列表为券数量以及每张券各种库存校验,大概算一下8次rpc+一张未领券6次io未领券50张+ 1次券信息io用券100张=400多次,按购物车高峰期几十万/每分钟调用量,优惠券领券结算需要承载百万QPS的IO。

综上所述:优惠券是一个高并发、高复杂、大数据量的单体系统。对于这种系统面临许多问题和挑战,为了迎接挑战优惠券进行一系列架构调整,实战部分Redis的也进行一系列优化措施。

3架构演化

随着公司发展扩大,业务的急剧增长以及研发规模的扩大,原有系统已不能满足业务发展的需求,面临着业务需求推进慢、系统性能调优困难、人员耦合问题频现,在此需要降低系统复杂度,进行一系列系统拆分措施。

3.1架构演化-框架介绍

图片

总共分为7层,分别是用户层、接入层、应用层、服务层、数据缓存层、数据存储层、基础层。

应用层:建券、展示券、领券、结算页用券、优惠券中台、优惠券网关、闭环业务。闭环业务(上游业务)是指依赖优惠券中台形成自己独特玩法的上游业务。

服务层:大方便分为B端和C端,C端主要有优惠券展示服务、结算页用券、优惠券网关等;B端主要优惠券中台、建券、领券等。

数据缓存层:5个集群,优惠券打标集群、门店券集群、用户券集群、活动集群、已使用集群。

数据存储层:主要有Mysql和ES进行存储,其中用户下优惠券券数据量比较多,日均亿级的数据体量。

基础层:主要依赖京东私有云体系和京东自研中间件。

3.2架构演化-系统拆分

大部分的系统可以按业务维度进行拆分,足以能解决自己耦合问题,但是优惠券是一个复杂的单体系统,我们一起来看下单体系统我们是如何拆分的。

图片

调整前:所有的业务基本分为建券、领券、用券和查券,系统分为open(对C端服务)、inner(对B端服务)、web(运营端)三个项目,随着业务不断发展,带来了一系列问题。

问题描述:1.随着C端机器不断增多扩容,数据库连接数不够。2.高并发场景对核心交易流程用券产生了影响。3.业务、系统、人员耦合在一起,推动需求进度和系统维护带来一定困难。

调整后:将优惠券进行B端、C端、上游业务三个维度进行拆分。

1.C端提供门店券、商品券、红包、以及核心交易流程用券业务;C端系统由原来一个系统拆分为展示系统和用券核心交易流程系统。

2.B端业务分为建券、中台、领券、补贴规则;B端系统形成了中台open(通用对C端服务)、中台inner(通用对B端服务)、WEB(运营端)、MQ消息(将原有分散的消费MQ统一形成一个系统)。

3.闭环业务(上游业务)依赖优惠券中台通用能力实现自有业务独立部署。

4缓存优化

优惠券系统C端使用Redis承接高流量,对Redis的深度了解和学习是优化实战的重要一部分。下面会分别介绍常见的实战优化经验,选取的常用的优化点:大Key、热Key、过期策略。

4.1消灭缓存大Key问题

Redis主线程是单线程模型。大Key表面理解为单个存储Key的Value比较大,参考值:String类型长度大于8000;对于List、Set、Hash、Zset的元素数量大于8000。该数据仅供参考,实际业务不同也可有一定调整。

如果一次操作比较大,会导致主线程处理时间变长,单点阻塞;另外大Key的删除或是过期,也会导致节点阻塞,极端情况会导致主从出现问题,Redis无法响应,所以在使用层面根据业务应拆尽拆。

如果是复杂度比较低的应用,定位大Key是比较简单的事情,通常开发人员根据经验就能定位到,但是优惠券系统有200多缓存Key场景,靠经验值很难定位,针对大Key问题下面拿具体案例进行说明,如何定位、如何解决。

案例背景(优惠券适用门店缓存优化)

1.高峰期响应时长高,所有接口都出现问题。

2.某分片新建连接数飙升、流出量高,并且问题分片每天都变化。

3.定位场景复杂:200多个接口无法确定某个业务;缓存Key和接口之间存在多对多关系。

4.Redis没有慢日志,定位问题困难。

5.大Key存在但是不一定是问题Key;热Key也存在但也不一定是问题,如何找到影响性能具体的Key。

排查过程

1.根据方法耗时最终可以定位到具体集群,但是不能定位到某个缓存Key,因为Redis是单点阻塞,所以表现为该集群所有缓存都变慢。

2.寻找该集群涉及数据源代码中重复调用过程优化,优化后无效。

3.降级+限流无效。

4.使用主从切换方式定位到问题Key,发现分片流出量从主分片到了从分片。定位到券适用门店缓存,此缓存是CouponId为Key,Value是门店ID,接近上万门店,用来判断券和门店是否适用。

解决方案:

将缓存结构Key是CouponId,Value是适用门店结构,改造为缓存Key是CouponId+门店的维度,B端进行一次写入,C端只判断有没有来做到用户已领取的券在不在该门店展示。

图片

结果:总的每秒流出量由几G/s直接降到几十M/s,单分片由几百M/s降低为几M/s。

总结如何发现大Key:

1.业务经验,假设问题Key。

2.客户端代理,收集大Key信息。

3.查看Redis记录的慢日志。

4.在从集群Scan大Key,以免影响生产环境稳定。

5.读写分离笨办法,对怀疑的Key一个一个进行主从分片切换,观察节点的流出量,这个方法虽然不能快速定位,但是一定能帮助定位到问题。

总结解决大Key

1.根据业务场景解决,散列足够均匀,足够小。个人认为根据场景解决为首选。

2.对于大Key,要想办法进行业务拆解,无法业务拆分的考虑使用技术手段路由拆分。

3.精简缓存Key的长度,使用缩写替代,例如coupon_info_base改为c_i_b。

4.对字段属性进行精简,使用单字母表示,例如couponName对应存储字母a。

5.数据优先使用整数,比字符串省空间。

6.对缓存数据进行冷热分离,比较热的字段放入一个Key,减少请求的网络流出量。

7.在清理大Key数据时,特别注意不要直接删除,也会造成单点阻塞,使用Scan进行清理。4.0以上引入了unlink,异步删除。

4.2解决缓存热Key问题

Redis单节点可以承受10万+QPS,如果突然有几十万请求访问某一固定Key,那会达到网卡上限,导致节点无法响应。当然这里的量级也是参考值,和实际场景的Key大小和机器性能许多因素都有关系。常见的突出业务场景是秒杀,其他的业务场景也会存在请求量大的热Key,热Key的发现和处理与大Key有些共通之处,也有不一样之处。

热Key发现:

1.业务经验,假设问题Key。

2.抽样抓取请求,这里分为服务端和客户端两个维度抓取,客户端便向集群维度抓取,服务端便向单节点维度抓取。例如:尝试抓10s内的请求然后按请求量对Key排序。

3.Redis自带命令,Redis 4+版本后提供热点Key发现。

4 .查看Redis记录的慢日志。

5.读写分离笨办法,对怀疑的Key一个一个进行主从分片切换,观察节点QPS变化,这个和大Key发现思路一致。

解决热Key:

1.业务优化减少请求量,例如秒杀,网上有各种降流量措施。

2.缓存隔离,减少热Key对其他业务影响。

3.散列多份,放许多份,读取数据时进行随机读取一份,这个方法比较通用。

4.对于热Key前置缓存到应用服务器上,尽量是占用空间小并且不怎么发生变的数据进行前置,不然Gc也是件比较麻烦的事情。

注意:实际生产环境会比较复杂,会存在一些场景比较难以定位。例如:不是大Key也不是热Key,但是会是阻塞节点的主要流量Key。定位方法可以参考读写分离方法,观察节点出流量以及QPS变化。

4.3重视缓存过期策略

实际生产环境经常需要做的是优化Redis的使用空间,减少集群大小,节省成本。经常不增加过期时间,过期时间使用不合理现象。

过期策略是Redis很重要的一部分,了解过期策略有什么好处呢。第一:我们可以学习到一些好的设计思想;第二可以对Redis过期有足够了解,能从原理层知道平时使用Redis应该注意什么。

过期策略

1.定时删除:插入过期键的同时,开一个定时任务,在键的过期来临时执行删除任务。优点是删除快,对内存友好,但是频繁的执行对CPU不友好。

2.惰性删除:用户查询的时候判断是否过期,过期则删除,用户不查则永远不删除。对CPU友好,对内存不友好。

3.定期删除:每隔一段时间进行一次删除任务,遍历多少个DB,删多少个键由算法决定。一、二的折中,当然如果执行的太频繁,或者每次删除的过多,就跟方式一没什么区别。

Redis是使用了惰性删除和定期删除两种方式。

redisDb结构体

typedef struct redisDb {
    dict *dict;                
    dict *expires;             
    ...
    unsigned long expires_cursor; 
} redisDb;

Redis数据存储使用redisDb的结构体,其中Dict存储数据,Expires是存储过期时间,不设置过期时间不会存放在Expires中,因此Redis过期策略主要针对Expires进行处理。

惰性删除策略

int expireIfNeeded(redisDb *db, robj *key) {
    if (!keyIsExpired(db,key)) return 0;
    if (server.masterhost != NULL) return 1;、
    /* Delete the key */
    server.stat_expiredkeys++;
    propagateExpire(db,key,server.lazyfree_lazy_expire);
    notifyKeyspaceEvent(NOTIFY_EXPIRED,
        "expired",key,db->id);
    int retval = server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
                                               dbSyncDelete(db,key);
    if (retval) signalModifiedKey(NULL,db,key);
    return retval;
}

该代码主要体现以下4点:

1.调用ExpireIfNeeded函数进行是否过期判断并删除。

2.如果没过期返回0,如果是从库返回1。

3.如果过期并且是主库,删除返回1。

4.Redis只会从主库删除过期键,主库再同步从库。

这里之前一同事想清理一次过期没删除的数据,问Scan能否触发过期删除?

void scanGenericCommand(client *c, robj *o, unsigned long cursor) {
    /* Step 1: 解析 */
    /* Step 2: 遍历各种类型集合 */
    /* Step 3: 过滤元素 */
    while (node) {
        ...
        /* Filter element if it is an expired key. */
        if (!filter && o == NULL && expireIfNeeded(c->db, kobj)) filter = 1;
        ...
        node = nextnode;
    }
    /* Step 4: 回复客户端 */

上面一段是Scan大体的4步过程,第一步解析参数,第二步遍历各种类型集合,然后第3步过滤元素中明确调用了ExpireIfNeeded函数进行了惰性删除,所以使用Scan是可以清理过期没删除的数据。第四步回复客户端。

定期删除策略

#define ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP 20 /* 默认每个数据库检测的键数量 Keys for each DB loop. */
#define ACTIVE_EXPIRE_CYCLE_FAST_DURATION 1000 /* 快周期时间 微秒 Microseconds. */
#define ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 25 /* 慢周期最多占用cpu资源 Max % of CPU to use. */
#define ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE 10 /* 可容忍的过期键所占内存比例 % of stale keys after which
                                                   we do extra efforts. */

void activeExpireCycle(int type) {

    effort = server.active_expire_effort-1, /* Rescale from 0 to 9. */
    config_keys_per_loop = ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP +
                           ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP/4*effort,
    config_cycle_fast_duration = ACTIVE_EXPIRE_CYCLE_FAST_DURATION +
                                 ACTIVE_EXPIRE_CYCLE_FAST_DURATION/4*effort,
    config_cycle_slow_time_perc = ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC +
                                  2*effort,
    config_cycle_acceptable_stale = ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE-
                                    effort;
    ...

    if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
        //如果上次不是因为超时而结束,并且当前过期键数量小于可容忍的过期键数量,不处理。
        if (!timelimit_exit &&
            server.stat_expired_stale_perc < config_cycle_acceptable_stale)
            return;
        //如果距离上次fast模式的运行时间小于两倍的fast模式的周期,不处理。    
        if (start < last_fast_cycle + (long long)config_cycle_fast_duration*2)
            return;
        last_fast_cycle = start;
    }
    ...
    timelimit = config_cycle_slow_time_perc*1000000/server.hz/100;
    ...    
    if (type == ACTIVE_EXPIRE_CYCLE_FAST)
        redisDb *db = server.db+(current_db % server.dbnum);
     //循环db
     for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
        do {
            ...
            while (sampled < num && checked_buckets < max_buckets) {
                for (int table = 0; table < 2; table++) {
                    if (table == 1 && !dictIsRehashing(db->expires)) break;

                    unsigned long idx = db->expires_cursor;//当前db过期游标开始检测。
                    idx &= db->expires->ht[table].sizemask;
                    dictEntry *de = db->expires->ht[table].table[idx];
                    while(de) {
                       ...
                        ttl = dictGetSignedIntegerVal(e)-now;
                        //操作过期删除
                        if (activeExpireCycleTryExpire(db,e,now)) expired++;
                        if (ttl > 0) {
                            ttl_sum += ttl;
                            ttl_samples++;
                        }
                        sampled++;
                    }
                }
                db->expires_cursor++;
            }
            ...
            //每循环16次,看下执行时间和超时时间,如果超时跳出结束
            if ((iteration & 0xf) == 0) { /* check once every 16 iterations. */
                elapsed = ustime()-start;
                if (elapsed > timelimit) {
                    timelimit_exit = 1;
                    server.stat_expired_time_cap_reached_count++;
                    break;
                }
            }
        //如果已清理的过期键*100/所有检索的键>可容忍过期键所占比内存
        } while (sampled == 0 ||
                 (expired*100/sampled) > config_cycle_acceptable_stale);
    }
    ...
    double current_perc;
    if (total_sampled) {
        current_perc = (double)total_expired/total_sampled;
    } else
        current_perc = 0;
    //动态变化
    server.stat_expired_stale_perc = (current_perc*0.05)+
                                     (server.stat_expired_stale_perc*0.95);
}

以上源码有省略,只选取核心逻辑,定期删除主要是调用activeExpireCycle函数:

1.两个地方调用,一个是server.c/databasesCron()函数,每100ms执行一次,一个是server.c/beforeSleep()函数,在redis的事件主循环中,每次循环都会执行一次。

2.activeExpireCycle两种模式:快周期和慢周期。

常量说明:

1.ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP默认每个数据库检测键数量。

2.ACTIVE_EXPIRE_CYCLE_FAST_DURATION 快周期,1000us。

3.ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC慢周期多占用CPU时间25%。

4.ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE可容忍的过期键所占内存比例10%。

5.Effort调节速度, Effort越大,CPU负担越重,所以根据自己的需要设置effort的值。

6.server.active_expire_effort默认1。

删除过程

1.For循环DB。

2.Do while循环进行容忍度校验,如果已清理的过期键*100/所有检索的键>可容忍过期键所占比内存,一直循环。

  1. 跳出条件:每循环16次,看下执行时间和超时时间,如果超时跳出结束。

  2. 慢周期25ms,快周期1000us。

  3. db->expires_cursor当前db过期游标开始检测。每次执行都是根据全局游标接着上次位置进行。6.0新版本才有该游标。

  4. activeExpireCycleTryExpire操作删除。

3.server.stat_expired_stale_perc = (current perc0.05)+(server.stat_expired_stale_perc0.95),动态变化,在此体现了activeExpireCycle使用的是自适应算法。

总结:

1.activeExpireCycle函数使用的是自适应算法。

2.过期 Key 最好不要太密集,每次检测会耗尽分配的时间片,达到可接受的密度比例,对Key过期时间设置进行一定的随机,减少集中过期影响CPU。

3.db->expires_cursor 6.0才有。4.0是随机20个Key。

4.业务使用时尽量都设置过期时间。

5.使用Scan触发惰性删除操作,非高峰期扫描主节点。


文章作者: Gustavo
版权声明: 本博客所有文章除特別声明外,均采用 CC BY-NC 4.0 许可协议。转载请注明来源 Gustavo !
评论
  目录