Scenario Misc

Last updated on March 8, 2025 pm

WIP FOREVER!

Glimpse of solutions.

ES 深分页

随机跳转问题

搜索结果列表页下方可能要支持跳转,这个功能可能会给 ES 带来性能问题,普遍没有很好的解决方案,需要结合业务做出限制和优化。

  1. from + size

    多 shard 查询之后在内存中排序后计算结果集。数据量大的时候性能很低。

  2. scroll

    生成一个有过期时间的视图,到期之前操作的都是视图内的数据,无法感知新的变更。

  3. searchAfter

    每次只能翻一页,但性能很好。

我在飞书的实践中,用的是朴素解法,首先在业务层限制结果集最大 10000 条,然后用户可以自定义单页数量,比如 500 Page * 20 Records,下方允许进行随机跳转,跳转的逻辑是 from + size

同时系统需要支持导出数据,比如用户希望将某个搜索结果集导出为 Excel,这个场景下我们封装到异步任务中使用 searchAfter 轮询导出。

向前翻页问题

有的业务可能根据使用习惯,约定向前翻页的结果为视图,向后翻页的结果允许更新 —— 既不是全局视图,也不是全局动态。这个需求在数据量小的场景可以直接缓存查看过的页面结果集到 Redis,向前翻页时取缓存即可。

大数据量的场景下没有很好的解法,但是一般来说,搜索结果集都会根据特定 Attribute 进行排序,且这个 Attribute 是递增的,插入数据不会影响前置结果集。例如根据创建时间倒序,或者业务保证 doc id 全局有序递增,然后使用这个 id 排序也可以,页面跳转时可以选择重新触发一次搜索来替代缓存。

异步和一致性

假设一个接口的影响是修改 N 张表,其中一张为主表,其余为关联表,并且这些表之间的数据可能有「一致性」的要求。

朴素方式:

业务量小的时候,或者说 N 比较小的时候,直接将变更SQL都放在一个事务当中同步写入。

异步方式:

假设业务量逐渐增大,或者是 N 比较大,比如 N = 10,亦或是我们可以将这个场景转化为微服务的调用,比如一个请求的处理逻辑,涉及到调用下游 N 个微服务的 API —— 本质上都是 IO 慢,只是一个是数据库 IO,另一个是网络 IO。此时我们需要考虑将部分“不重要的” IO 异步化来提升接口性能。

根据实际业务,将接口逻辑调整为:主要数据同步修改+允许一定延迟的关联数据异步修改。

异步化的实现方式一般如下:

  • 内存 spawn 异步任务,coroutine / thread
  • 消息队列 MQ
  • 消息表

基于内存的方式只适用于对数据没有一致性要求的场景,比如 metrics 等。

基于 MQ 的方式,在实现上一般有一个典型的挑战:写数据库和写MQ之间的事务性如何保证。

数据库和缓存之间的一致性也是一个常见的问题,但是注意区分和这个场景的区别:

缓存与数据库之间的不一致是"无状态"的,也就是说,即使缓存和数据库之间存在不一致,也可以通过过期或者删除等机制来解决。下次读取之后,两侧数据就能保持一致。

但是数据库和 MQ 之间的一致性问题"可能是有状态"的,比如假设消息的消费在业务上是具有顺序性的,那么丢失/漏发消息是完全无法接受的。比如消费的逻辑是一个状态机,将任务按照 A - B - C 的顺序进行流转,那么一旦丢失了 B 消息,哪怕接到了多条 C 消息,也无法实现 A - C 的流转。

双写的事务性一般可以有如下几种解决方式:

  1. 利用 RocketMQ 的事务消息

    TIPS: 这种消息本质上是 2PC,为了适应网络或其它类型的通信故障,还要求服务提供对应的回调接口来进行重试。

  2. 先写 Kafka,然后基于 Kafka 消息进行所有数据库的修改
    如果要求实时性,可以尝试立即写入一次主表数据库,然后消费时幂等处理,极端情况下立即写入失败可能会延迟。需要根据实际业务考量。比如我在交易所基础架构团队工作时,就是采用的这种方式进行的历史 K 线数据的落库。

消息表是另外一种思路:

也就是将事务落库 N 张表,修改为事务落库 1(主表) + 1(消息表)张表。然后异步处理的逻辑基于消息表进行,这样能以一种比较简单的方式实现一致性,但是也会造成一定延迟问题。

读写分离

这个思想很简单,无非就是读接口和写接口部署在不同的服务上,从而降低单台服务器的压力,并且一般来说读接口是无状态的(数据单纯来源于数据库等持久化组件),所以可以对读服务进行水平扩容,最终压力给到 DB。

一般来说这个架构适合于读多写少的场景,采用写单点+读水平扩展的架构可以应付绝大部分情况。

如果写服务的压力过大,一般使用分片的方式进行拆分(注意和扩容的区别),比如我们之前在加密货币交易所的账户系统中基本就是采用这个架构进行支撑,简单来说就是线上的 100K+ 用户被分配到 42 个分片上进行处理,每个分片大约处理 3~5000 个用户的请求,即使一个分片出现问题也不会影响整体的用户。基于这个思路还可以对数据库也进行分库分表,比如每个分片的用户使用不同的数据库表甚至是 database 进行存储,和写服务一一对应,当然实际情况不一定需要我们这么做。

更进一步的说,读写分离的思想在某些场景中,可以进一步的拆分为「算」+「读」+「写」。

例如,交易所的指数价格(Index Price)服务就是一个典型场景:

┌───────────────┐      ┌────────┐     ┌─────────────┐
│Matching Engine│ ───► │ Trades │───► │ Cal Service │  here
└───────────────┘      └────────┘     └─────────────┘
                                             │
                                             ▼
                                      ┌──────────────┐
                                  ┌───┤ Index Prices ├──────┐
                                  │   └──────────────┘      │
                                  ▼                         ▼
                            ┌─────────────────────┐    ┌───────────────┐
                            │ Downstream Services │    │ Store Service │  here
                            └─────────────────────┘    └───────┬───────┘
                                                               │
                                                               ▼
                           ┌───────────────┐              ┌──────────┐
                     here  │ Query Service │◄─────────────┤ Database │
                           └───────────────┘              └──────────┘

在上述的架构下,计算节点可以作为单点部署(当然,如果成交信息根据不同的 Symbol 进行了 topic 拆分,我们也可以部署对应数量的服务),存储服务主要实现了异步落库,也就是一个典型的高吞吐的 Kafka 消费模型。最后读服务可以根据数据库的信息进行水平扩展。

这种架构有一个可能的问题在于:异步落库带来的查询延迟。因此对于实时性比较高的业务来说,应该直接订阅我们的 Kafka 数据源,而不是通过 RestFul API 查询。

1 -> n 的推送问题

典型的业务场景:

  • 直播中,主播的音视频数据需要被 N 个观众订阅
  • 交易所中,某个交易对的 depth 数据需要被用户实时查看到

假如我们采用简单的循环 push 方案,显然当 N 变大时性能会急剧下降,其原因包括但不限于:

  • 服务端需要维护的连接过多,N 个
  • 网络带宽受限,原始数据包上传时占用的带宽为 1,则下放时会写扩散为 N

针对这个问题上述的两个业务场景的解决方案不太一样,但是本质上还是从以下几个方面着手:

  • 减小报文体积
  • 降低推送频次
  • 分片处理下游

其中前两点主要依赖应用层实现,比如选用 HLS / HTTP-FLV / WebRTC 等协议和对应的 SDK,推送频次则根据具体的业务要求和 tolerance 进行调优。

分片的逻辑对于直播场景来说,一般是借助 CDN 来处理:

        ┌──────────┐
        │ streamer │
        └─────┬────┘
              │
              │
       ┌──────┴──────┐
       │  CDN Entry  │
       └──────┬──────┘
              │
   ┌──────────┴──────────┐
   ▼                     ▼
┌───────────┐     ┌───────────┐
│ CDN EdgeA │ ... │ CDN EdgeN │
└───────────┘     └───────────┘
      ▲                ▲
      │                │
 观众拉流1          观众拉流N

对于交易所的 depth 订阅场景而言,则稍微复杂一些:

  1. 一般选用增量 + 全量结合的方式进行数据推送,下游需要自行构建维护好不同档位的 depth,这样有助于减少报文体积
  2. 推送的频次适当降低,比如我们可以 wait 200ms 的窗口的数据整合后进行推送
  3. 分片的逻辑一般不会采用 CDN 架构,因为 CDN 更适合 PULL 模式而不是 PUSH
     ┌──────────────┐
     │ Match Engine │
     └──────┬───────┘
            │
┌───────────┴──────────────┐
│ MQ (Kafka / Redis / ...) │
└───────────┬──────────────┘
            │
  ┌─────────┴───────────┐
  │   WebSocket Fanout  │
  └─────────┬───────────┘
            │
       ┌────┴───┐
       │ Client │
       └────────┘

对于不同的 Symbol, 肯定推出来是分不同的 partition 甚至 topic 的,常见的策略是:大币独占,小币适当合并。

对于 Fan-out 层,要做的事情其实就是订阅 MQ 处理后推送给客户端。不难看出这里是可以轻松的进行水平扩容的 —— Fan-out 和 Client 之间显然会有一个 LB。

分布式事务

网络上资料比较多,我看到很多文章都会写"7 种分布式事务的原理",这里不重复做原理和术语的介绍,主要看一下实际业务场景中的落地方式。事务原理本身并不复杂,实际业务中全部套用教条的模板往往很不灵活,大部分场景下我们完全可以使用更简单的方式进行快速实现,当然前提是逻辑必须闭环。

个人认为,不论是 TCC 还是 SAGA 的模式,都离不开一点:事务的恢复。

具体来说,假设事务执行中发生了重启 —— 最常见的也最不可避免的就是发版,这个事务如何恢复现场继续处理。

基于这个思路,就能衍生出很多类似于业内最佳实践的方案:一般来说,实现的内核无非就是基于事务表重试 + 基于状态机幂等。

当然,实际落地的时候,我们主要关注的点是在于「重试」和「幂等」,达成这两点的方式有很多种,比如我在 crypto 交易所工作期间,团队整体采用的方式就是基于 Kafka 的 EventSourcing 架构。

考虑这个场景:假设购买一件商品时,用户账户的扣款和商品库存的扣减需要形成一个事务。

基于任务表和状态机的方案如下:

Task: [task_id, status, user_id, good_id]

Status: pending
-> balance_frozen
-> inventory_deducted
-> balance_transfered
-> finished

全局可以基于上面这个任务表达到最终一致性。

而 TCC / SAGA 的模式下,原理其实是类似的,落地上的差异也许是这样的:

  1. 把上面的 TASK 表以 Order 表的方式实现,全局事务的开始起源于下单。
  2. 上面 STATUS 的枚举未必需要如此细致,实际场景中,可能订单服务会捎带订单 id 去账户服务进行清结算,也就是说某些状态可能是分离保存在账户/库存服务中的,事务的协调者即使不保存状态也可以通过询问的方式计算状态。

Anyway,只要逻辑能够自洽和闭环,即便引入一些人工操作也未尝不可,实现时不必照搬教条,完全可以根据业务状况进行简化。当然,也有一些大厂开源的框架已经对这类分布式事务的逻辑进行了比较好的封装,你也可以根据实际情况进行选用,E.g. DTM

引入一个框架一般会对编程范式有严格的要求,灵活性和开发者友好性的问题是不应该但时常是被忽视的。

HyperSwitch

Github Repo

这个东西是支付场景下一个比较有名的解决方案,对接了非常多支付厂商的接口,并且内部对支付流程做了完善的编排。它不是一个所谓的框架或者是寻常的中间件,而是一整套前后端服务本身。

值得思考的是,我觉得在业内直接部署一整套不具备 Saas 属性的源码不太现实,主要原因有如下几点:

  1. 对于小公司来说,这种完整的解决方案显得太重
  2. 对于大公司来说,这种解决方案无法妥善地解决内部的特化需求,在如山的代码中适配自己公司的要求实在有些强人所难。
  3. 当然还有很多上下游更新带来的兼容问题等,这里不再赘述。

所以一般我们会以这些解决方案作为参考,而不是开箱即用。

这个仓库中比较值得关注的点,就是它的架构图展示了一些现代化的存储/时序数据/数据分析的解决方案:

arch

  • 时序数据和结构化的 metrics: OpenTelemetry(数据收集) + Promethus(时序数据读写) + Grafana(可视化)。
  • 日志收集:Promtail + Loki
  • 其它埋点和事件:Vector(这个组件比较新,可视化数据管道) + Kafka(MQ) + ClickHouse(OLAP) + ES(搜索)
  • 持久化/缓存:PostgreSQL + Redis

单边场景下的安全问题

首先明确什么是单边的场景:在这里用于指代在某些业务中,后端无法进行精确校验,从而大幅依赖客户端/请求参数的场景,下面是几个例子。

  • 容易进行对账等校验的场景:

    • 用户签到领奖励,后端可以进行精准校验,比如每日/月签到等场景下,可以保存时间窗口内的信息辅助判断。
    • 充值/转账,后端可以进行精准校验,比如金融系统常见的复式对账法。
    • 新手引导任务奖励,后端可以轻松的实现每个新手任务只允许完成一次的逻辑。
  • 单边场景:

    • 游戏的结果上报,假如整体游戏流程都由客户端本地实现(比如 H5 / flash / 本地单机游戏),那么游戏结果的上报兑奖则依赖客户端上传。
    • 刷视频领奖励,假设某短视频应用根据你浏览的视频进行奖励发放,那么视频是否播放完 / 浏览了多少个则依赖客户端上传。
    • 直播实时激励,假如某直播平台为了吸引主播开播,在直播时每 x 秒给主播赠送部分来自官方的礼物。

单边场景下面临的挑战其实比较明显 —— 业务本身无法提供足够的约束供后端进行校验,因此容易出现脚本/爬虫/协议破解直接批量调用接口的问题。

面对这个问题,不同的业务场景有不同的解决方案,这里主要介绍一下通用的思路,分为前中后置三部分进行探讨:

  1. 前置
    从上面的例子中不难看出,其实单边场景下最困难的实现点就在于前置场景下难以判断。这里其实确实也没有特别好的方式可以给一个没有约束的业务加上约束,因此我们最后往往会把关注点放在「人」这个环节上。

    前置场景内,我们暂时不讨论「人工操作」与「脚本行为」的差异性 —— 这个往往属于中后置场景。
    我们也暂时不讨论纯技术的方案,这个留在稍后展开。

    我们最终的解决方案一般是环绕这个问题进行构建的:“一个真人在这个业务下,一天最多能够获得多少奖励。”

    于是这就成了一个简单的数学不等式问题,比如说「直播」时,每分钟给主播的平台账户发放 10 元零钱奖励,那么他一天能拿到的奖励最多不会超过 14400 元。那么我们可以构建一个长度为 1 天的时间窗口,对用户的总收益进行暂存,一旦用户的奖励溢出这个范围,则返回错误。同样的,我们也可以构建一个 1 分钟的锁,让这个收益接口每分钟最多只能调用一次。

    这个思路差不多可以总结为:允许你使用脚本代替人工,但是不允许你通过脚本获得比人工更高的收益

    相信这句话不难理解,比如假设你在玩某些打怪升级的页游,人工操作费时费力,你也许能够通过一些非官方的插件实现自动化清怪。
    这种方式相当于「打怪升级」变为了「挂机升级」,只是这个挂机功能是非官方的。
    在这种方式下,唯一的区别就在于你可以躺着拿奖励,别人需要操作才能拿奖励,但是不变的是:1小时后你能获得的最高奖励和人工操作并无区别

  2. 过程中
    在接口请求的过程中,除了上述的一些简单不等式判断进行快速过滤,我们还可以使用更复杂更精准的校验来进行二次确认。这一般会被沉淀为一个风控服务,在请求处理逻辑中可能会同步的调用一次风控服务询问检查结果,根据结果判断是否应该拒绝请求。

    这里不展开讨论,风控策略一般是公司内部的机密信息,从计算机科学的常识上讲,我们一般需要采集客户端的信息和用户的历史行为来辅助判断。比如设备ID,用户指纹,用户登陆地点等,举例来说:金融行业的风控系统可能会使用 Flink 维护一个窗口内的登陆信息,如果用户在 5 分钟内在不同的地点 / IP 登陆了超过 3 次,则对他进行一些临时的操作限制等。

  3. 后置
    后置操作实现的方式和策略也因业务和团队而异,但是一般来说都离不开人工运维进行兜底,比如说:加密货币交易所的提现功能,最后都会依赖一个客服进行人工的确认和审核,我们也许会实现一个工单平台自动采集提现相关的信息供审核同学进行辅助判断,作为最后一道防线。

    当然实际上的后置操作不止于此,往往会有很多异步的流程在后端进行连续的或周期性的运算,依据具体的策略触发一些冻结/封号之类的惩罚等。

总的来说,这种场景下的安全问题并不是某个特定领域的特殊需求,可以用常见的或者 Hack 的方式进行简单的处理 —— 比如登陆态和鉴权这类问题,往往有通用的解决方案。而这类场景的解决方案往往是发散的,不是只存活于链路的某个点的,相对完善的解决思路一般都离不开风控的需求,然而风控(Risk Control)往往在策略(业务层面)和落地(工程层面)都是一件复杂的事情。

定时任务

这个场景相对比较简单,首先我们需要明白:定时任务在软件的实现中一定是离不开轮询的,只是这个轮询本身如何做优化的问题。

首先任务肯定需要持久化到数据库中,那么我们要做的肯定是要把这些任务扫描出来,然后在内存中计算后触发调度。

为了提高效率,我们可以通过批量的方式扫描出未来一段时间的任务,然后在内存中以小顶堆或者其它的优先级队列的方式进行维护,根据 peek 的结果决定轮询出堆的频次。

核心的原理其实就是这样了,但是有很多通用的后端思路都可以被应用在工程中进行进一步优化,以 RocketMQ 为例,它实现了不同时间单位下的延时消息,其实现原理也无非是将各个维度的延时消息进行分区,然后使用一个轮询调度器进行查询并把 ready 的消息投递出去:

┌──────────┐
│    1s    │◄──────┐
└──────────┘       │
     ...           │
┌──────────┐    ┌──┴──┐    ┌─────┐
│    5m    │◄───┤timer├───►│queue│
└──────────┘    └──┬──┘    └─────┘
     ...           │
┌──────────┐       │
│    2h    │◄──────┘
└──────────┘

那么,在实际的定时任务服务中,我们也可以根据数据的量级,考虑是否要进行任务的分片来避免单点数据过多而导致调度不及时的问题 —— 此时还需要妥善处理任务可能被多个节点触发带来的幂等性问题等。

值得一提的是,MQ 的使用场景中,天然就带有时序递增的属性,后到的消息就应该排在后面(相同队列)。而实际的定时任务系统中,我们还需要花精力去处理任务执行时间的更新和取消所带来的复杂逻辑甚至是写扩散的问题。

缓存问题

缓存问题从大方向上来说,无非可以分为这两类:「一致性问题」+「存储问题」。

一致性问题没什么特殊的,业内的解法都有最佳实践,不管是哪种方法都可能在极端情况下出现短暂的不一致问题。值得一提的是,使用了缓存的数据本身就应当在业务上允许短暂不一致,比如商品的库存,点赞的数量等,如果这些数据十分的重要,比如牵涉到金额或者参与重要的运算(不仅仅是展示),那么我们应该额外斟酌。(事实上,我们往往还是采用读写分离的思路,在写单点上可以进行内存缓存,从而部分避免掉缓存一致性的问题)。

存储问题上,主要从两方面考量,第一部分是如何缓存好有效的数据 —— 热点数据应该让他们尽量命中缓存;第二部分是如何妥善处理缓存数据的更新/过期策略 —— 避免发生雪崩击穿和穿透等问题。

热点数据的缓存一般也就是这三点:

  • 根据业务选择合适的策略

    • 热点数据稳定,LFU
    • 临时的热点(比如假日活动),活动期间不淘汰或者淘汰 TTL 最短的
    • 随机的访问,随机的淘汰
  • 事前进行缓存预热
    这个不展开说了,很多时候我们可以提前预知哪些东西在某些时间会被大量访问,这类场景除了缓存还能用上 CDN 甚至是客户端本地资源之类的技术手段。

  • 主动识别热点缓存
    这个方案主要是为了解决某些场景我们无法提前预知哪些资源会被大量访问的情况,但是有时候我们能通过一些前置的接口识别出来,尽可能的预测未来的情况。一个典型的热点识别系统架构如下:

    ┌────┐      ┌─────┐    ┌───────┐
    │ LB ├─────►│Flink├───►│Service│
    └────┘      └─────┘    └───┬───┘
                               │
                      ┌────────┴───────┐
                      │┌─────────────┐ │
                      ││Redis Cluster│ │
                      │└─────────────┘ │
                      └────────────────┘

    我们可以通过网关层比如 Nginx 的 Access Log 来获取接口请求信息,然后使用 Flink 维护一个流式的窗口计算哪些数据正在被频繁访问,然后根据窗口数据触发缓存的更新。

    Flink 的流式处理特别适合这个场景,避免了传统解决方案中定时扫表可能带来的延迟和压力问题。

值得一提的是,提到缓存和数据库之间的一致性,我们往往会采用先写数据库再更新或者删除缓存的方式进行处理,因为往往我们都会认为数据库中的数据才是最终的唯一性的来源。

但是部分场景其实是反过来的,比如说对于秒杀场景下商品库存的管理,其实我们一般是先修改缓存中的数据,然后异步的进行落库 - 即便出现落库导致少卖的情况,我们的业务也是允许的。这种反过来的方式往往有一个特点,就是数据库中的数据哪怕后置写入,与缓存数据无法对上,业务也是接受的。

总而言之,缓存与数据库中的一致性问题,都离不开重试补偿,以此实现最终一致,对于某些特殊的并发问题,如果业务本身无法容忍数据不一致,那我们就必须额外引入分布式锁等机制进行处理,强一致和高性能本身是无法兼得的。


Scenario Misc
https://kayce.world/tech/scenario_misc/
Author
kayce
Posted on
January 19, 2025
Updated on
March 8, 2025
Licensed under