2023年4月

智慧作业最近上线「
个性化手册
」(简称个册)功能,一份完整的个性化手册分为
三部分

  • 学情分析
    :根据学生阶段性的学习和考试情况进行学情分析、归纳、总结,汇总学情数据;
  • 精准推荐
    :推荐算法基于学情数据结合知识图谱进行精准练习题推荐;
  • 错题回顾
    :错题的阶段性回顾复习。
    第一部分
    学情分析的PDF由Node.js加工,与Java后端通过消息队列RabbitMQ进行数据交互
    ,本文简单记录一下Node.js批量加工PDF服务的架构模式,以及基于现阶段发现的问题,梳理未来的迭代规划和演进方向。

业务特征

个册三个部分的PDF数据来源不同,生产逻辑独立由不同的服务生产,最终将三份PDF合并为一份,还要支持班级所有学生批量生产和压缩打包,所以这个功能在技术角度最主要的特征就是
环节多

耗时长

  • 环节多
    意味着在各个服务之间存在较多的网络通信和数据交互,核心挑战在于如何设计低耦合、高可用的服务架构;
  • 耗时长
    一方面体现在多个环节的总耗时,另一方面体现在三个PDF生产服务各自的加工耗时。
    基于以上业务特征,PDF加工服务架构设计的一个大方向就是将
    长耗时任务异步处理,各服务之间逻辑解耦,通过消息队列进行数据交互

技术选型

服务端生成PDF通常有两种方案:

  • 第一种是使用
    pdfkit
    之类的工具通过代码绘制,这种方案最大的问题是可渲染的内容类型有限,定制化不足;
  • 第二种是创建
    headless browser
    用html渲染后截取pdf,这种方案的架构相对复杂,但是可以支持所有web端的内容类型。
    个册第一部分学情分析的某一页长这个样子:

仅这一页就涵盖文本、表格、图表以及各种自定义图案,内容类型多样并且后续迭代可能增加更多定制化内容,第一种方案的局限性很难满足需求,所以最终选定 headless browser 方案。

具体到 headless browser 的技术选型就非常有限了,可选的无非就是 Selenium/PhantomJS 这类老招牌,或者 Puppeteer/Playwright 这类新玩家。

严格来说Selenium只是一种类似按键精灵的工具,可通过代码在浏览器中模拟人的操作,本身并不是浏览器,所以需要搭配第三方浏览器使用,比如PhantomJS。
Selenium/PhantomJS 的最大的优点就是生态健全,支持多种编程语言,有相对繁荣的技术社区;缺点就是稳定性和性能较差,Selenium的稳定性出了名的糟糕,PhantomJS五年前就停止维护了。这哥俩通常用在对稳定性要求不是很高的场景,比如爬虫。

与之形成鲜明对比的,Puppeteer/Playwright 最大的优点就是稳定性高,性能更优;缺点就是对编程语言的支持有限,生态和技术社区相对没那么健全。

个册的业务特征一是
对稳定性和性能要求很高
;二是
不要求跨浏览器
(Playwright支持浏览器类型更丰富)。最终综合考虑API易用性、稳定性、性能、社区、风险等因素,在 Puppeteer 和 Playwright 之间选择了 Puppeteer。既然选定了 Puppeteer,配套的自然就是 Node.js了。

Puppeteer 和 Playwright 的对比可以参考这篇文章:
Playwright vs Puppeteer: Core Differences

这个需求是我第一次使用Puppeteer,还没完全摸透,下文涉及到Puppeteer相关的方案
如果有问题,欢迎讨论指点

实现方案

智慧教育的分层架构如下:

Node.js PDF服务是本次需求新增的,为了方便分离部署和优化,PDF服务单独建立一个服务,不涉及Node.js接入层的改动。下图是个册PDF加工的完整流程:

每个环节的具体流程不细讲,Node.js PDF加工服务的细节下文详解。与Node.js PDF服务相关最关键的是与Java后端的数据交互流程。Java后端与Node.js PDF服务通过 RabbitMQ 消息队列进行数据交互,建立
两个队列

队列 生产者 消费者 说明
任务队列 Java后端 Node.js PDF服务 Java 向队列中发送个册渲染数据,Node.js 消费
回传队列 Node.js PDF服务 Java后端 Node.js 向队列中发送pdf加工结果数据,Java 消费

这部分没啥好讲的,Node.js与Java之间按照约定的数据规范组装数据即可,下面详细介绍一下Node.js加工pdf的具体逻辑。

这一版个册的第一部分学情分析控制在3页,早期规划的个册PDF大约25页左右,技术调研和架构设计都是基于这个预期进行的,所以现在这套模式多少有点杀鸡用牛刀的意思,不过前期打好基础给后续迭代留些空间也是好事。

单份PDF加工流程

为了更方便理解,在介绍pdf加工流程之前,有必要先简要一下Node.js PDF服务的架构,以及与PDF加工逻辑最相关的
worker
角色。

Node.js PDF服务架构最核心的三个角色:

  • Scheduler
    :负责轮询
    调度
    ,发起任务;
  • Executor
    :负责任务
    前置

    后置
    相关逻辑,包括worker pool管理、worker 调度、MQ任务队列消息拉取、MQ回传队列消息发送等;
  • Worker
    :负责
    实质
    执行任务,包括pdf渲染、生产、上传OSS;
    三者的关系如下所示:

Scheduler

Executor
的具体逻辑以及三个角色之间的调度逻辑下文再详解,PDF文件的实质生产逻辑都集中在
Worker
中,流程如下:

图中「发送消息至MQ回传队列」实质是由
Executor
执行,此处画出方便理解完整流程。

预启动

图中虚线部分的预启动是在启动 Node.js 服务之前执行的逻辑,预启动完成之后 Node.js 服务被拉起,所以预启动的耗时是一次性的。

预启动过程执行两个动作:

  • 读取磁盘中的html文件内容,写入内存,为后续环节「加载网页」提供数据;
  • 创建 Puppeteer browser 实例。

上图中只画出pdf加工逻辑相关的预启动工作,实际上预启动还包含一些其他逻辑,比如建立 MQ 连接信道。

冷启动(废弃)

虽然冷启动在后来开发过程中被废弃,但通过这个事情发现自己的不足,还是值得记录一下的。
最初之所以设想冷启动环节,是因为尝试用 worker
模拟多线程
。每个worker会创建一个browser实例和多个page实例(目前是3个),如下所示:

这样做的目的是将
每个worker的负载上限固定,便于服务器资源规模预估,
避免服务器某个节点负载过高,进而也可以避免k8s集群pod的纵向伸缩。

k8s纵向伸缩的取舍见仁见智,我个人不太建议使用。
如果任务队列长时间为空会触发缓存清理
逻辑,销毁browser和page实例以节省服务器资源,
再次发起任务会触发冷****启动
。冷启动执行两件事情:

  • 链接/创建browser实例
  • 创建page实例
    另外增加一个
    标识位_mounted代表冷启动是否完成
    ,代码如下:
public async run(){
  if(!this._mounted){
    // 触发冷启动
    this._mount();
  }
  // ...其他逻辑
}
private async _mount(){
    if(!this._browser?.isConnected()){
      // 链接browser
      this._browser = await puppeteer.connect({
        browserWSEndpoint: this._wsEndpoint
      });
    }
    // 创建page实例
    if(isEmpty(this._pages)){
      for(let i =0;i<this._opts.maxPageCount;i++){
        const ctx = await this._browser.newPage();
        this._pages.push({
          ctx,
          busy: false
        });
      }
    }

    this._mounted = true;
}

乍看起来似乎没啥问题,但实际跑一跑代码会发现,在
任务调度密集的时候,
run函数短时间内被调用多次(具体的调度策略下文讲解)
,worker会触发多次冷启动
,虽然不影响业务逻辑,但会
引起服务器资源暴涨
,这是因为冷启动会创建新的browser和page实例,但是旧实例并没有被清理,仍然在执行任务。
冷启动被调用多次的根本原因是
Node.js不是多线程
,如下图所示,假设冷启动耗时20ms,在此期间再次调用run函数,标识位_mounted还未被设置为true,就会又触发一次冷启动。

有没有解法?

当然有。多线程编程解决竞态最常用的就是:
加锁
。既然想模拟多线程那就彻底一点,把锁逻辑也加上呗。

worker本身是有“锁”的,
每个worker有3个page实例,只有当存在空闲实例(busy为false)时run函数才可以执行
,但是这个锁机制并不能避免多次冷启动问题,因为冷启动完成之前page实例还未被创建。

可能会有人说,那就加个限制,page实例不存在时也不让run函数执行不就得了?这么做的话run函数永远都不会被执行啊大聪明。

既然worker已有的锁不行,那就再
加个冷启动锁
,冷启动之前锁定,冷启动之后解锁。这么做当然是可以的,但是会增加逻辑复杂度,worker有两种锁,对后期迭代维护无疑是埋雷。

其实之所以有冷启动无非就是为了省点内存,用时间换空间,一个browser实例+3个空白page实例总共100m左右的内存,这年头内存这么便宜,为了省这点空间把逻辑搞那么复杂完全得不偿失。什么叫过度设计,这就是过度设计。

所以后来索性把冷启动过程干掉了,browser和page实例的创建放在worker初始化逻辑里。

public async init() {
    /**
     * 尽量禁用掉不需要的功能,提高性能
     */
    this._browser = await puppeteer.launch({
      headless: true,
      args: [
        '--incognito',
        '--disable-gpu',
        '--disable-dev-shm-usage',
        '--disable-setuid-sandbox',
        '--no-first-run',
        '--no-sandbox',
        '--no-zygote',
        '--single-process'
      ]
    });

    this._wsEndpoint = this._browser.wsEndpoint();
    // _mount函数逻辑不改动,调用_mount函数放在初始化逻辑中
    await this._mount();
}

加载网页

网页通过page.setContent(html)函数加载本地html文件,与通过page.goto(url)加载远程URL相比,既
节省
了部署网页的
服务器资源
,同时
速度也更快

时间消耗 执行时机 性能瓶颈 其他
远程URL
  • DNS耗时
  • 下载耗时
  • 解析html耗时
运行时 网络IO 异步下载html引用的静态资源会增加额外耗时
本地html
  • 读磁盘耗时
  • 解析html耗时
预启动阶段 文件IO+常驻内存

上文提到过,本地html文件在
预启动
阶段提前从磁盘读取存放于内存,运行时无需实时读取。所以文件IO的耗时不算在pdf加工逻辑总耗时中,而
加载远程URL只能在运行时执行
,会增长pdf加工的总时长。

另外,加载的
本地html文件中不能存在静态资源引用
,比如js和css必须全部以行内

导读:在经过了近半年的测试验证和迁移准备之后,神州金库3.0核心系统 WMS 正式从 MySQL 迁移到了分布式 HTAP 数据库 TiDB,上线后不久即经历了第一次双11的考验,TiDB的性能和稳定性表现远超预期,给后续的全平台迁移计划打下了坚实的基础。神州数码 TiDB 交付团队与科捷物流技术、业务团队紧密配合,完全自主化地实施了整个迁移过程,成为团队在又一新行业成功交付的典型案例。

业务背景

北京科捷物流有限公司于2003年在北京正式成立,是ISO质量管理体系认证企业、国家AAAAA级物流企业、海关AEO高级认证企业,注册资金1亿元,是中国领先的大数据科技公司——神州控股的全资子公司。科捷物流融合B2B和B2C的客户需求,基于遍布全国的物流网络与自主知识产权的物流管理系统,为客户提供定制化的一站式供应链服务,在全国拥有231个仓储中心,总面积超100万平方米,年运送货值超5000亿元,日发送包裹超40万个,并在IT、通讯、精密仪器、汽车配件及电商物流领域处于行业领先地位。

企业微信截图_20221121181939.png

神州金库(KINGKOO)是科捷物流结合二十年物流运营经验自主研发,支持云服务模式、实时数据接口的专业物流管理平台,包含有四大核心子系统:订单管理系统(OMS)、仓储管理系统(WMS)、运输管理系统(TMS)、物流核算系统(BMS),实现了物流业务体系的数字化全覆盖,为客户提供了一体化的供应链系统解决方案。

企业微信截图_20221121174426.png

神州金库平台经过十几年的更新迭代,支撑了科捷物流自营仓储体系、众多电商平台商家、第三方物流公司的核心业务,积累了庞大的数据量。为应对持续增长的业务规模,以及每年多次的电商大促活动,急需寻找更加高效高性能的数据存储方案。

现状与挑战

神州金库服务端采用微服务架构体系设计,不同的业务模块采用独立的集群部署模式,技术栈基于Java Spring框架构建,数据库目前主要使用 MySQL 主从集群,多台高性能物理机部署,通过 MyCat 做代理层进行读写请求转发。前端接入了多种不同的客户端形态,包括Web、APP、IoT设备、扫描枪、计重器、机器人、报表、第三方API等等。

企业微信截图_20221123154720.png

随着数据量的持续快速增长,MySQL 的存储容量即将达到上限,SQL 响应时间开始变慢,业务受到影响。如果维持现有的技术架构,下一步势必要引入分表机制,同时扩展容量更大的集群,这其中数据迁移就是非常大的工程量,应用端还要引入额外的 sharding 中间件进行改造,后续数据库维护成本和难度成倍上升。

其次,大量的数据报表和分析需求凸显,仅仅依靠 MySQL 从库提供分析查询能力,效率已经达不到业务需求。某些场景下汇总数据的时效性要求非常高,直接影响到下一步的业务决策,引入传统的T+1离线分析方案无法满足。

除此之外,在应对电商大促场景下需要数据库提供足够的并发能力,响应比平时多出几十倍的流量高峰,同时数据库还可以保证稳定的性能。在平时业务量较小的时候,需要缩减配置控制成本,达到弹性易于扩展的目的。

基于以上需求,技术团队决定引入分布式数据库代替 MySQL 单机数据库,在充分考虑了应用和数据双方面迁移难度,以及一系列 POC 验证后,选择了使 TiDB 来替换 MySQL,并用神州金库的核心子系统 WMS 作为首期试点项目。

选择使用 TiDB 的主要因素有:

  • 1、语法层面高度兼容 MySQL,应用端代码中没有使用 TiDB 不支持的特性, 最小程度减少应用改造成本,更换数据库连接串即可。

  • 2、存储计算分离架构能够满足弹性扩展需求,针对不同时期的业务量动态调整节点达到所需的性能和容量,还可以把不同业务单元的 MySQL 库合并到一个 TiDB 集群中,自带高可用特性省去了 MySQL 从库的硬件成本,数据库维护起来简单高效。

  • 3、一站式 HTAP 体验,同时满足交易型和分析性业务场景,且对应用端透明。

  • 4、开源产品,技术社区活跃,产品迭代快,碰到问题容易解决。

TiDB 解决方案

测试

为赶在双11之前完成迁移任务,我们做前期做了充足的测试工作,包括应用兼容性测试和改造、多轮带实际业务的压力测试、模拟未来数十倍数据量的性能测试、稳定性测试、高可用测试、生产迁移演练等。在压测中选取了仓储业务中最核心的出库流程,一共包含6个场景,分别是创建出库单、调度、创建波次、单据复核、单据交接、交接确认。

其中稳定性测试过程中除了使用传统的长时间高压业务负载,还引入了 Chaos Mesh 混沌测试,对CPU、内存、网络等发生异常情况进行模拟,观察 TiDB 在测试期间的表现。从监控显示,压测期间资源使用率和数据库响应时间都非常稳定。

图片1.png

图片2.png

迁移

生产环境 TiDB 集群部署架构和数据迁移流程如下图所示:

arch.png

在 TiDB 集群部署完成后,使用官方提供的数据迁移工具 TiDB Data Migration(DM)开始把全量和增量数据同步到 TiDB 中,然后找一个业务低峰期切断应用端到 MySQL 的流量,待 DM 把数据追平后使用校验工具 Sync-Diff 对上下游数据做一致性检查,校验完成开启 TiDB 到 MySQL 的回退链路,防止切换出现故障可以随时回滚到 MySQL。验证 TiDB Binlog 同步正常以后把应用端数据库连接切换到 TiDB 代理层的VIP,通过 HAProxy 转发请求到 TiDB 计算层。

收益

迁移之后经过一个月的观察和调整,各方面的性能指标都很稳定,P99 延时基本在100ms以下,服务器资源使用率普遍较低,各节点压力均衡。10月31日晚上9点左右,迎来了双11的第一轮业务高峰期,一直持续到11月3日,在这期间 P99 延时没有明显波动,但是集群 QPS 较平时上涨了5-8倍,最高峰值达到1万多。

企业微信截图_20221124183156.png

在11月1日和11月11日两轮业务高峰期,TiDB 均表现得非常稳定,没有发生任何故障和性能问题。本次迁移的 WMS 3.0在双11期间的流量约占整个金库系统的10%,基于目前 TiDB 的优秀表现,我们有充足的信心把所有业务系统逐步迁移到 TiDB。

短期来看,TiDB 可能需要投入较高的硬件成本,但是随着数据规模增长,TiDB 的性价比会大幅提升。首先 TiDB 的数据压缩比非常高,三副本所需要的存储空间远低于三台 MySQL 主从节点,这意味着三台 TiKV 可以存储比 MySQL更多的数据。其次,要提高数据库整体并发能力只需要增加 TiDB Server 节点, 要扩展数据库容量只需要增加 TiKV 节点,从运维成本和硬件成本都要低于 MySQL。

问题

从单机数据库到分布式数据库,除了语法层面的兼容性之外,我们还需要关注相同的 SQL 表现行为是否一致。

例如在早期的测试中发现,当不显式指定排序字段时,MySQL 查询结果能得到固定的顺序,但是在 TiDB 中就会出现结果集顺序不稳定的情况,这主要是分布式特性带来的表现差异。TiDB 会把扫描数据的请求并行下发给多个 TiKV 节点,如果没有强制使用排序字段,受 TiKV 返回数据时间不一致的影响,最终的汇总结果必然没办法保证顺序,这就要求业务开发过程中要保持良好的 SQL 编写规范。

再就是使用 TiDB 普遍会遇到的热点问题,上线初期由于某张表的索引建立不当,导致某个索引读热点问题非常严重,高峰期能达到100多G/min的流量。

企业微信截图_20221125154234.png

我们从三个方向进行了优化,首先找到热点所在的 Region 尝试做切分,会有短暂的效果,但是受 Region 调度影响读热点依旧存在。然后尝试了自动化 Load Base Split,发现效果也不好。最后回归 SQL 本身,仔细分析了业务查询逻辑和索引使用情况,重新调整索引后有了明显效果,但由于这是一个业务上小于当前时间的范围查询,某些 Region 的负载还是会高一些 ,再配合定期扫描 Region 流量超出阈值做切分的脚本,热点问题得到完美解决。

企业微信截图_20221125154108.png

此外还碰到了 TiDB 产品本身的bug,我们生产环境使用了v5.3.2版本,在该版本下当 limit offset 值特别大的时候,如果此时碰上 IndexHashJoin 会导致 Session 处于假死状态,并且持续占用 TiDB 节点内存无法释放,同时也无法kill。早期因为这个问题出现过几次 TiDB 节点 OOM 的情况,只能不定期重启 TiDB Server 解决。经过仔细分析排查后定位到这是产品bug,可以通过 HashJoin 关联方式绕过,最后用 SQL Binding 的形式临时处理掉了。不过业务上这样的 SQL 比较多,目前依然存在这个问题,计划通过版本升级的方式(v5.4.3)彻底解决。

未来展望

整体来说,此次 WMS 3.0系统迁移非常顺利,各方面都能够满足预期,我们也期待未来把更多的业务系统接入到 TiDB 中,在更多场景中感受分布式数据库带来的魅力,助力业务的高速增长。

作者介绍:hey-hoho,来自神州数码钛合金战队,是一支致力于为企业提供分布式数据库TiDB整体解决方案的专业技术团队。团队成员拥有丰富的数据库从业背景,全部拥有TiDB高级资格证书,并活跃于TiDB开源社区,是官方认证合作伙伴。目前已为10+客户提供了专业的TiDB交付服务,涵盖金融、证券、物流、电力、政府、零售等重点行业。

做GIS一般都是用ArcMap发布影像或者矢量服务,由于ArcGIS后续不在更新ArcMap,改用ArcGIS Pro,本文对ArcGIS Pro发布服务进行说明。

本文示例使用(因为portal的授权的版本只有10.5的,故使用10.5进行示例):

软件:ArcGIS Pro3.0.1(破解版),

ArcGIS Portal10.5

当ArcGIS Pro和Portal不在一个机器或者版本不一样的时候,是可以连接使用的,需要在Portals配置好需要连接的Portal地址,并进行登录。

然后在发布服务前,先切换到已经登录好的Portal。

在使用ArcGIS Pro发布矢量、影像、地形等服务时,首先需要打开ArcGIS Pro,选择地图工程模板来进行发布。

注意:不能选择全局场景或局部场景,这两个场景下在相应菜单并没有发布地图选项!!!

1、发布影像切片服务

(1)选择需要切片的影像,拖动或者添加到地图中

(2)找到Share(共享)->Web Layer(Web图层)->Publish Web Layer(发布Web图层),打开Share As Web Layer(共享为Web图层)工具进行发布:

类型选择切片,并勾选共享对象,然后打开配置页,根据实际情况配置切片属性:

配置好切片属性,点击分析,没问题就可以点击发布了,发布好打开portal,在我的内容里就可以看到刚刚发布的服务了。

使用ArcGIS Pro发布的地图服务默认在Hosted目录下,建议在发布的时候自己创建一个新的目录或者选一个已存在的目录:

然后使用ArcGIS JS API就可以在Web上把影像加载上了(切的是一个全球影像):

2、发布矢量服务

(1)和影像数据的发布方式一样,先加载数据,然后选择发布web图层工具:

填好信息,分析后即可发布服务,发布好的服务如下所示:

最近在对某个后端服务做 .NET Core 升级时,里面使用了多处处理 MultipartFormDataContent 相关内容的代码。这些地方从 .NET Framework 迁移到 .NET Core 之后的代码改动较大,由于本身没有测试覆盖,导致在部署 QA 环境后引发了一些问题。这里做一个技术复盘。

什么是 MultipartFormDataContent

我们在做后端接口开发的时候,常常会涉及到接口参数为多种类型的情况,比如值类型,引用类型,或者是文件类型。如果我们需要在一个接口参数中同时传递多种不多的数据类型,那么这个时候就需要用到 multipart/form-data MIME 类型进行内容编码,然后传递给后端。比如我们需要往后端接口传递一个同时包含文件和字符串文本的话,使用 Postman 你可以这样构造内容:

对应的 HTTP 请求内容则类似是这样:

MultipartFormDataContent 的写操作

在 C# 中,我们通常会使用 HttpClient 这个类型的实例对象来进行接口请求,对应实例代码如下所示:

在这个请求构造中,我们尝试在内存中创建一个压缩文件 test.zip,然后再构造一个简单的文本内容,并将这两部分内容统一放到Body 中通过 HttpClient 发送到后端。

MultipartFormDataContent 的读操作

.NET Framework

在传统 Framework 项目中,我们可以通过构造出 MultipartMemoryStreamProvider 类型来方便解析接收到的 MultipartFormDataContent 对象,但是由于这个类型在 .NET Core 中已经被不存在,所以我们需要尝试新的方法来重构这一块的代码。

.NET Core

方式一

由于接收到的 MultipartFormDataContent 对象内部是由多个 Section 所组成,所以我们可以通过循环每个 Section 的方式来分别解析所有内容。

⚠️ 这种读取方式较复杂,并且从目前已经使用过这种方式的服务来讲的话,建议要有对应的测试覆盖。

方式二

这种方式是通过借助 ASP.NET Core 本身的框架封装,将原本复杂的 MultipartFormDataContent 直接放到
Request.Form
中,所以我们只需要通过这个对象来拿我们想要的数据即可。