资源入库优化手段

06 Jun 2021

背景

采集上报的数据内容以及资源的URL, 需要通过URL再次请求保存内容, 并基于资源内容进行后续数据入库操作.

create table resources (
id int primary key auto_increment comment '入库资源ID',
digest char(32) comment '基于资源内容的唯一标识',
url varchar(255) comment '原始资源链接',
url_digest char(32),
uniqe key (digest),
key (url_digest)
) comment '资源表';

随着采集手段不断提升, 上报数据有着量级上翻了两个层级, 对于资源请求及广告入库造成了很大的压力.

这里记录中间优化过程的点.

耗时观测

首先任何优化的前提是充分的线上打点, 监控分析. 从而抓主要矛盾, 并量化优化的工作结果. 完整的观测逻辑在代码中其实占比不低, 需要尽量通过基础库的封装完善了简化/释放业务开发同学维护写业务逻辑以及 给业务逻辑打点监控的工作量.

此外观测要注意是基于程序内部的调用栈频次统计, 还是包含了外部系统交互时间. 大部分慢还是在外部交互上, 需要做基于时间的打点统计. 此外在极端情况下, 观测计时逻辑本身对系统性能本身也有显著的影响, 需要做成可开关的模式.

数据库读写优化

避免事务

最早的版本, 为了下游消费时候数据的严格一致性, 每个流程开了事务, 导致数据库事务等待问题比较严重. 策略就是禁止业务代码主动开事务, 贯彻一条语句一条事务的原则.

有些必须要保障同时存在的逻辑, 通过和配合下游查询逻辑, 调整写入顺序, 或者增加标记位字段的方式, 来规避事务的必要性.

缓存

由于大部分链接都是见过的, 需要判重. 每次直接查询数据库的方式性能是提不上来的, 需要引入redis缓存, 内存缓存. 这里由于数据存在后就不会变更的, 因此在内存允许范围内, 尽可能地不做数据淘汰.

注意这里对于查询不命中的缓存处理, 一开始对于不存在的数据也做了短期缓存处理, 实际上会导致触发重复入库流程. 不做的话每次新资源链接进来都会触发数据库的查询, 压力也挺大.

避免脏读的办法是做主动缓存, 在数据库实际写入时做缓存的主动写, 在这个流程足够稳健的前提下, 可以不做数据库查询操作. 但是缓存系统就是数据可以随时可以丢弃的, (redis开了LRU), 需要缓存数据量较大, 也导致不可能将所有的数据主动缓存下来; 此外同样一个URL下次见到的时间分布没有明显特征, 不可预测, 不好针对做差异化的缓存淘汰策略.

所以另外的手段就是对于在处理的资源链接做缓存标记, 好让后进来的资源知道有在途的请求. 这里属于并发处理范畴不展开.

批量化

另外后端优化的方向就是做批量化. 后端优化需要尽量减少外部的网络交互, 能不查外部系统的不要查, 需要查外部系统的尽量批量查, 减少往复导致的开销. PIPELINE方式批量查询redis缓存, 数据库查询以及写入也尽量做批量化.

@cache_batch  # PIPELINE QUERY FROM REDIS
def query_somthing(ids):
    return db.query("select ... from where id in %s", ids)

但是由于本身逻辑是面向单消息的, 批量逻辑会把代码逻辑写的不必要的复杂, 尤其是折衷还要拿到写入后生成ID做后续操作的更加麻烦. 这里要在性能优化和复杂性之间找折衷. 此外很多自增ID的数据逻辑导致很多写入只能单条执行, 并有前后严格的依赖顺序, 这就导致数据库的读写上不去.

异步化/队列化

为了确保消费速度, 任务逻辑拆成异步化队列的方式来做, 从而单个细粒度按需扩容. 避免慢流程阻塞快流程. 这也是所谓”微服务”的思路.

一些冗余的计算逻辑, 也尽量丢到后面单独程序来做, 从而将需要优化速度的程序要做的事情砍到最少. 一个程序职责越单一, 就越容易去优化性能.

异步化也便于我们做批量优化. 例子:

create table texts (
id int primary key auto_increment -- DEPRECATED,
digest char(32),
content text,
unique key (digest)
) comment '文案表';

create table creatives (
id int primary key auto_increment,
text_id int -- DEPRECATED,
text_digest char(32), -- ADDED
resource_id int -- DEPRECATED,
resource_digest char(32), -- ADDED
foreign key text_id (texts.id),
) comment '创意表';

原本creatives的创意依赖于content的先行入库并生成自增id, 这种就很不好做异步化. 通过干掉自增id关联, 转而使用digest关联, 写入者的关系可以提前确定, 不用依赖写入结果, 从而可以将文本的写入逻辑异步批量执行.

另外一个场景, 我们下载完的资源需要上传到S3, 这里就产生了不必要的等待. 可以将上传异步化并调用批量上传接口优化上传速度.

当然异步化是吧双刃剑, 破坏了一些happens-before的保证, 如后续逻辑假设关联进创意表的文案一定存在于文案表, 入库的资源一定已经上传等. 下游逻辑如果要处理这种异步化逻辑会写的比较复杂. 并且在异步队列没有延迟保障的情况下, 事情就更不可控. 需要在优化性能和确保逻辑简单性之间找折衷.

队列分割, 快慢隔离

由于大部分资源都是见过的, 可以直接进行后续逻辑, 因此将需要下载的, 和不需要的分队列处理. 此外资源中大部分是图片视频资源. 其中视频资源下载耗时较久, 而图片等资源相对较快, 因此进一步拆分图片资源和视频资源处理队列.

类比: 宜家无购物通道, 小件快速结账通道, 以及大件通道, 目的在于减少平均等待时间; 道路需要区分快慢线等.

在相同处理能力的情况下, 分队列其实并没有提高单位处理能力, 只是将在处理时间方差比较大的情况下, 通过适配对应资源, 提高资源利用饱和度, 降低了平均等待时间, 提高了入库实时性, 缓解了队列堆积的情况.

具体分析需要在”排队论”下面理论研究. 这里算个最简单的情况:

提升计算利用率

固定预算下, 需要多开小实例机器来拿到尽可能多的出口IP, 因此降低单位请求的内存/CPU需求是个必要的优化手段. 用多进程/多线程方式来做外部请求, 系统调用/切换开销高, 实测情况是内存/CPU已经跑满了, 出口带宽还上不去. 需要用户态的并发处理模式, 并利用异步IO的方式提高带宽利用率.

Python里面采用asyncio可以有效地饱和网络, 但是asyncio奇葩的编程方式导致代码逻辑维护起来异常困难. 如果有可能通过Golang改写一下也许更好.

提高入库有效率

入库有效率 = 入库资源数 / 成功请求资源数

提高有效率能够减少浪费, 将单位计算资源最大化.

任务去重 / 解决事务问题

由于资源URL基本上可以认为是不会变化的. 因此最朴素的链接去重, 就是基于URL判定下是否见过.

同一个链接重复消息较多时, 会导致事务问题, 及多个消费者查不到, 然后去下载资源, 最终在入库的时候冲突.

本质上是要确保同一个时刻, 同一个URL只能有一个在途请求.

在队列分发调度层面, 通过url_digest做路由, 确保同一个链接去到同一个消费者程序.

在同一个消费者程序层面, 由于并发处理逻辑, 也会导致同时下载. 需要再在程序中做基于url的singleflight, 即相同url只有一个在途请求, 其他的等待该url返回的结果后继续处理后续流程. 在瞬时相同url比较多的时候会导致忙等, 其实可以放出去请求其他资源. 做法可以将同资源后续的触发动作挂回调, 当前单元继续处理另外下一个资源请求.

其实严谨来说应更高的整个系统层面做singleflight, 因为进来的资源链接是一组不可切分的列表, 无论怎样分发/路由规则都会有相同链接去不同消费者被处理的可能性. 但是: 1. 请求失败率会比较高, 2. 请求超时会比较久. 单URL请求影响的范畴不只是单程序内部问题, 影响面更大, 整个系统的正确性也难以思考, 因此没做全局的处理流程.

URL去重规则优化

观测发现, 入库资源冲突比较高, 一方面原因是上述的事务问题导致, 没有很好的任务去重. 另外一个更重要的原因是, 很多链接虽然不同, 但是返回的实际资源内容是相同的.

一开始朴素想法基于URI来去重, “原教旨”互联网定义也告诉我们URI是唯一标识符, 然而现实狠狠教育了我们. 很多资源链接是动态生成的, 哪怕URI是’.jpeg’后缀的都可能是基于请求参数生成的动态内容. 所以不能简化为URI的去重.

这里是需要磨规则的工作, 一些经验:

ETag等请求返回标识判重

很多资源虽然链接完全不一样, 不能按照上述规则提前去重, 但是由于走CDN分发, 有的会按照规范提供ETAG标识; 有些自建系统, 如Facebook, 也会有自家的haystack的x-needle-checksum等返回头标识.

可以针对这些返回头做资源判重逻辑, 可以先单独HEAD一下拿返回头提前去重, 减少很多不必要的请求. 不值得单独HEAD一下的, 可以在GET请求拿到响应头后判定以决定是否要放弃本次下载.

提升外部请求成功率

对外请求会有一定的失败率, 几种原因:

对于最近失败率较高的链接, 可能本身链接就有问题, 或者被反爬盯得比较紧, 需要有放弃策略. 其背后的逻辑假设是失败次数越高, 成功率就越低, 虽然是普世的规则, 但也要琢磨一下适用性.

总结

总结一些点

总而言之, 做一个能跑起来的流程很简单, 但是随着业务量级的增长, 每个环节做到性能友好, 还是个比较费时费力的工作. 另外一方面, 避免过度的优化, 把逻辑做的很复杂, 在能力满足现有要求的情况下, 尽量不做很复杂的操作. 毕竟很多时候性能友好和复杂性不可兼得.

HOME