大辉谈-备战双十一之动静分离实战

今天准备给大家讲讲我在工作中遇到的困难以及经过各种实践到认知再实践最终实现目标的过程之一: 我是如何思考动静分离架构并最终实现的.

先来说说需求, 我之前所在的团队的商业方向是做电商平台saas,类似于有赞和微盟, 电商saas顾名思义, 它是电商+saas,意思就是普通的电商那一套不够,还得加上saas:)

商品详情的动静分离已经上线了, 截图如下:

现在不是要双十一了么? 他们都想搞私域的双十一直播,平时的那点流量一下子蹿了十几倍甚至几十倍, 怎么应对? 啊! 动静分离! 团队的研发小伙伴们和问过的朋友以及CEO特意请的顾问都这么说. ok! 那就搞动静分离. 但是具体咋搞? 没人给我好的方案, 因为具体情况具体分析, 动静分离每家每个程序员每个架构师都有自己的想法, 在电商saas的场景下,商品详情, 店铺装修, 商品列表 都是高频请求, 都要实现动静分离, 而且复杂的一点是, 作为saas平台,每个商家的页面和分佣和细节需求都会不一样,这给静态化和动态化增加了困难.

经过一周的头秃思考和实践认知再实践再认知的迭代过程, 我还是最终把动静分离方案实现了出来, 下面我就详细说说我的方法.

首先说下为啥要实现动静分离, 咱们都清楚啊, 首先关系型数据库是有连接数限制的, 如果只是读, 增加只读实例就可以以低成本的方式增加连接数, 但是如果涉及写, 就需要对数据库进行升级. 简单来说, 对于用户的请求, 每次都从数据库获取数据如果连接数够用并且没有额外的sql执行开销其实并没有什么问题, 问题就在于大量数据的io响应依然会阻碍并发数的提升, 并且会导致系统中的其它业务受影响. 所以解决方案就是nosql, 对常见的用户请求,并不会到达数据库这一级.

先拿商品详情页面来举例子吧, 商品详情页面是商品的展示页面, 在视频号直播时访问的频率最高, 肯定首先要实现商品详情的动静分离, 咱们来先区分下哪些归为静态, 哪些归为动态.

静态内容:

与当前访问者无关的内容为静态内容, 如:
- 商品基本信息
- 优惠券列表信息
- 评价信息
- 商品所属的商家信息
- 商家最新的商品列表

动态内容:

与当前访问者有关的内容为动态内容, 如:
- 已领取的优惠券
- 针对访问者单独显示的优惠券
- 访问者能够拿到的商品的自购返奖金等

出于篇幅问题, 本文只说静态部分, 也就是与访问者无关的页面信息, 咱们来个小目标, 假定有100万用户通过抖音或视频号同时抢购某个限量商品A, 商品A假定库存只有5万件, 可以理解为100万个用户不停的在刷相同的页面, 可以理解为理想情况下要达到100万qps.

如果采用增加数据库读节点的方案,咱们来分析下情况:

  1. 要求用户的请求2秒内返回.
  2. 每个请求的执行时间理想情况下是200-300毫秒
  3. 考虑到sqlalchemy对协程的支持仍处于早期阶段, 对于数据库的请求采用多线程模式

基于以上情况, 因为可以2秒内返回, 所以咱们可以假定下每秒只需要达到50万qps就可以了,再看每次请求需要200-300毫秒, 因为在fastapi线程模式下或者flask或者django来说, 每个请求一个线程, 可以理解为每个线程每秒能执行3个请求. 也就是说需要50/3=16.6万个线程. 按照python线程的实际情况, 一般线程数是核数的2-4倍, 假定就是纯io情况, 这里咱们取2倍, 在GIL的情况下, 2倍和4倍其实没什么变化, 实际我测试下来2倍反而更好一些, 每个进程就是cpu_count() 2个线程, 进程数一般也是cpu数的2倍, 按照阿里云的ecs最高配置256核1024G内存的配置, 2562(2562)=262144, 可以理解为需要2台顶级配置的ecs服务器就能够支撑商品详情的请求, 但是考虑到客户端的并发请求情况,咱们豪爽的来4台, 每台阿里云的顶级配置的ecs每小时的费用是56.32元, 4台就是228元, 假定活动前后执行4个小时,可以理解为1000元的成本.

按照每个线程一个连接的映射理论, 就需要17万的数据库连接池连接数. 阿里云按量付费的postgresql数据库最高配置是64核512G, 最大支持51200的连接数,如果要达到17万的连接数, 也就需要至少1台主实例,3台读实例才能够覆盖. 每小时的费用在57*4=228元. 假定活动前后要经历4个小时, 那么总成本就是1000元, 这个还好, 另外存储的成本可以忽略不计.

实际情况是如果前端有并发请求或者还有其他业务也在正常请求, 线程数和数据库连接数上面的计算方法其实根本就不够, 但是上面的计算方式是一个基础数, 在这个基础上, 根据线上业务情况肯定要增加数据库只读实例和ecs服务器数量.

嗯, 100万用户才2000的成本? 错啦!! 阿里云的api网关也要钱, 负载均衡也按小时和流量算钱, 我看了下阿里云, 如果按照每个用户1M的数据返回量来算, 100万用户就是996G的流量, 也就是说这些用户每个都请求一次的成本就是(0.049+0.8)*996=845.6元. 但是这仍然只是一个基础的算法, 前端并发, 大表的请求写入问题, 传输的流量费用, 负载均衡, api网关的费用这些都仍未计算在内呢, 而且每次活动都要预先通知研发团队, 这个对于标准的电商网站都好说, 但是对于做saas平台的就是个噩梦了, 因为根本无法跟商家解释清楚为啥他想卖自己的货需要向平台报备.

所以咱们换个方案, 尝试下使用redis+cdn来抗这100万用户的请求.
方案如下:

  1. 客户端向服务器请求商品详情的meta信息. 请求参数为product_id
  2. 服务器响应返回商品详情的meta信息, meta信息的组成如下:
    • 商品标识
    • 商品的商家标识
    • 商品基本信息的cdn地址, cdn地址格式为//cdn.domain.com/product/[product_id]/base_[product_version].json
    • 商品评价的cdn地址, cdn地址格式为//cdn.domain.com/product/[product_id]/comments_[comments_version].json
    • 商品的商家的信息和商家最新商品的cdn地址,cdn地址格式为//cdn.domain.com/merchant/[merchant_id]/base_[merchant_version].json
    • 商品的优惠券列表的cdn地址,cdn地址格式为//cdn.domain.com/product/[product_id]/coupons_[coupons_version].json
  3. 客户端并发请求cdn地址信息和当前访问者和商品关系的动态数据
  4. 部分cdn请求回源到服务器, 服务器通过redis缓存或检索es返回对应的json
  5. 客户端渲染静态数据

大家注意到cdn地址里带了一堆的version字段, 我来解释下这些version是怎么来的.

静态化的redis前面咱们都加个前缀 static: 用来区分不同的业务. 商品详情需要两个redis 哈希表来支持.

  1. static:products:[product_id] 商品哈希表

    • product_id 商品标识
    • merchant_id 商家标识
    • product_version 商品基本信息版本号
    • comments_version 商品评价版本号
  2. static:merchants:[merchant_id] 商家哈希表

    • merchant_id 商家标识
    • coupons_version 优惠券版本
    • latest_version 最新商品版本

然后通过领域事件订阅咱们来更新这些版本号

  1. 商品创建事件: 初始化商品哈希表, 其中product_version初始化为time.time()
def create_product(rc: Redis,
                   product_id: int,
                   supplier_id: int):
    rk = f"static:products:{product_id}"
    version = int(time.time())
    rc.hmset(rk, {
        "supplier_id": supplier_id,
        "product_version": version,
        "comments_version": version
    })

  1. 商品修改和删除事件: 刷新商品哈希表的商品版本和商家的版本号
def refresh_product_version(rc: Redis, product_id: int):
    product_rk = f"static:products:{product_id}"
    merchant_base_rk = "static:merchants:%s"
    version = int(time.time())
    redis_eval("refresh_product_version.lua", product_rk, merchant_base_rk, version)

lua脚本

local product_key = KEYS[1]
local merchant_base_key  =  KEYS[2]
local version = KEYS[3]
local supplier_id = redis.call('hget', product_key,"supplier_id")

local merchant_key = string.format(merchant_base_key,tostring(supplier_id))
redis.call('hset', product_key,"product_version",version)
redis.call('hset', merchant_key,"merchant_version",version)
  1. 优惠券创建和修改事件: 刷新商家哈希表的优惠券版本号
def refresh_coupons_version(rc: Redis,
                            merchant_id: int):
    version = int(time.time())
    rk = f"static:merchants:{merchant_id}"
    rc.hset(rk, "coupons_version", version)
  1. 评论创建事件: 刷新商品哈希表的评论版本号
def refresh_comments_version(rc: Redis, product_id: int):
    rk = f"static:products:{product_id}"
    version = int(time.time())
    rc.hset(rk, "comments_version", version)

  1. 商家信息变更事件: 刷新商家哈希表的商家版本号
def refresh_merchant_version(rc: Redis,
                             merchant_id: int):
    version = int(time.time())
    rk = f"static:merchants:{merchant_id}"
    rc.hset(rk, "merchant_version", version)

通过订阅商品详情页面关联的领域事件, 数据的版本号就发生了变更, 这样当客户端请求商品详情的meta信息的时候, 就可以通过lua脚本在redis中读取相关的版本号

def get_product_details_meta(product_id: int) -> Optional[ProductDetailsMeta]:
 
    product_rk = f"static:products:{product_id}"
    merchant_base_rk ="static:merchants:s%"

    versions = redis_eval("get_product_details_meta.lua", product_rk, merchant_base_rk)
    versions_dict = json.loads(versions)
    product_version, merchant_version = versions_dict["product_version"], versions_dict["merchant_version"]
    meta = ProductDetailsMeta(
        supplier_id=product_version.get("supplier_id"),
        product_version=product_version.get("product_version"),
        comments_version=product_version.get("comments_version"),
        coupons_version=merchant_version.get("coupons_version"),
        supplier_version=merchant_version.get("merchant_version")
    )
    return meta

lua脚本

local versions = {}
local product_key = KEYS[1]
local merchant_base_key  =  KEYS[2]

local supplier_id = redis.call('hget', product_key,"supplier_id")
local merchant_key = string.format(merchant_base_key,tostring(supplier_id))

local function hgetall(hash_key)
    local result = redis.call('hgetall', hash_key)
    local ret={}
    for i=1,#result,2 do
        ret[result[i]]=result[i+1]
    end
    return ret
end

local product_version = hgetall(product_key)
local merchant_version = hgetall(merchant_key)

versions["product_version"] = product_version
versions["merchant_version"] = merchant_version
return cjson.encode(versions)

这里再强调下为什么优惠券的变更是跟随商家的, 因为优惠券的操作肯定是商家操作的, 优惠券的范围可能包含指定商品或集合, 也可能排除指定商品, 但是优惠券肯定是商家创建和修改的, 所以跟踪关系就要建立在商家哈希表上, 虽然商品详情获取优惠券的cdn地址是//cdn.domain.com/product/[product_id]/coupons_[coupons_version].json, 携带了product_id, 但是咱们关注的其实是coupons_version信息, 只要coupons_version发生了变化,cdn地址肯定是要回源的,通过这种方式保障了实时的静态更新.

现在咱们来算下总成本, 其中cdn我设置的是按天过期, 就是1天就过期, 这样当秒杀请求过去后, cdn也不用承担存储成本.

redis+CDN方案成本计算:

  1. CDN成本 假定商品详情页面加载完整是耗费了1M, 这个不包括图片, 因为按照数据库连接计算也没计算图片的流量费用. 那么100万用户刷一次是耗费了1000000/1024=976G的流量, 咱们买它个资源包, 按照之前跟数据库的请求一样的算法, 1T的下行资源包的价格是144元.
  2. redis成本 由于使用了redis,所以数据库的成本就可以省略掉了, 另外虽然会有回源的情况, 但是考虑到秒杀之前,很少有商家会修改商品信息, 所以咱们可以假定回源的情况不存在. 或者回源导致的成本就可以忽略不计了. 那么如果使用redis来返回商品详情的meta信息需要什么样的配置呢? 因为使用了redis, 连接数就没有了限制, 也就是说在用户请求到来的时候, 我们可以不用一个请求一个线程的模式, 而是使用协程的方式来异步获取redis数据, 很不幸的是, 我尚未对这个部分进行测试, 但是我们可以看下阿里云的redis的qps数据, 一般考虑到业务情况, 基本上打个6折就与业务匹配了. 那么仍要求在2秒内响应数据, 每秒的qps要求就是50万,咱们买个读写分离版本,买3个只读节点, 每个节点的get性能按照阿里云的说法是44万个每秒,打个6折是24万, 三个只读节点加上写节点是4个节点, 性能是244=96万, ok, 够用了! 活动期间费用是1.4204=5.6元, 当然在业务活跃期进行切换是不切实际的, 通常是业务低峰期, 如晚上切.
  3. 服务器成本 redis的响应时间按照阿里云的文档最高是0.7毫秒, 因为咱们读了哈希表, 还读了俩, 还用了lua脚本, 咱们打个折, 就当10毫秒好了. 也就是说按照单进程来说1秒能够处理的请求是1000/10=100. 由于我们改成使用了协程, python的GIL就权且当做没有了, 可以理解为其它语言正常的1个请求一个线程. 按照阿里云的ecs最高配置256核1024G内存的配置, 2562(2562)=262144, 但是因为每个线程能执行100个请求, 就是理论上能达到2621400qps, 所以这个配置其实就过高了, 1台基本就够支撑了.但是1台就有点风险太高了, 咱们豪爽的来它2台, 确保性能足够! 费用就是56.322*4=450.6元.

所以算下来使用redis+cdn的基础成本就是450.6+5.6+144=600.2元. 当然大部分用户肯定会刷页面, cdn的下行资源包实际得买个10T的, 由于通过数据库获取数据的计算也是只计算了1次用户请求的成本,所以粗估成本的时候cdn也是按照只请求一次的成本进行累加.
所以其实成本计算只是个粗估, 并不靠谱, 只是用于判断成本和技术方案, 以及当商家确认要搞个这么大的活动的时候, 确认需要准备多少资源才能支撑.

这是我从晚上6点肝到凌晨2点写完的文章, 写的挺糙的, 因为大辉很久没写这种文章了, 大辉特别能喷, 但是写作自打高中后就没啥自信了, 所以后续的我针对这篇文章肯定还要继续优化.
大家如果有什么问题, 可以私信我或留言.