IM 推拉的道与术(四)

在常规APP中,比如抖音视频流、小红书Feed流、剪映等软件,一般只涉及到短连接HTTP(https)请求。所以客户端可以较容易控制HTTP请求的时机,也就可以保证数据的准确性。

IM软件中,由于长连接推送的存在,被动推送与主动拉取(推拉结合)是必然要配合的。那么推与拉就会产生很多数据一致性问题。

推拉结合

那么思考一个问题:有服务端数据推送的情况下,那就等服务端的推送不就好了,为什么还需要客户端主动获取数据?

举个简单的场景,在用户离线1周左右,冷启动后,客户端会跟服务端有大量数据传输,此时长连接通道因为是单通道的,下行通道一般会被占满,这里面的数据包含会话列表、消息列表等核心数据,也包含一些其他非核心业务的数据,比如日历日程、工作台、文档等。

为了让用户更快地 看到首页会话列表的最新状态,一般在冷启动后,客户端就会通过短连接HTTP直接拉取首页的一些关键信息,如会话列表、消息列表等。

reaction列表的"推"

之前说过,根据数据量的大小,某些数据可以直接通过长连接,从服务端推送到客户端。即服务端直接转发机制

这里以消息Reaction列表为例,reaction列表作为消息的一个附加属性,业务也相对独立,在服务端也是单独数据库存储的。他不会通过消息推送直接带下来,一般会设计为单独的拉取接口与推送接口。

这里需要注意,如果是服务端直接转发机制,推送的时候一般需要将全量数据推送给客户端。

当有人对上图中的消息8进行reaction操作时,服务端会把消息8的所有reaction列表直接推送给客户端。大概数据如下,表示某个消息的最新reaction列表。

json 复制代码
{
    "serverMsgId": "123456789",
    "reaction_list": [
        {
            "user1": "cry"
        },
        {
            "user2": "smile"
        }
    ]
}

当用户收到上述数据后,需要把该reaction列表与本地的Message合并,并落库。最终才会展示给用户。

reaction列表的"拉"

常见的拉的场景是用户在会话A页面,断网重连后,为了让用户尽快看到消息8的Reaction最新列表,会主动通过发请求getMsgReaction()获取。

还有一个场景,当客户端收件箱,收到新消息推送后,首先会调用服务端的getMsgDetail()接口,获取消息体具体内容。此时返回的消息体不携带reaction列表。

然后SDK会调用服务端的getMsgReactionList()接口,获取服务端的reaction列表。

推与拉的冲突

在某些情况下,可能会推拉的冲突。

reaction列表为例,当服务端主动推送reaction列表,客户端又触发了getMsgReactionList接口。

这样的例子有很多,冷启动/断网重连等情况后,客户端为了快速获取/展示数据,都会主动拉取最新数据,如会话列表/消息列表等数据,然后快速展示。此时就会发生推与拉的冲突。

步骤一: 服务端数据库中,消息8的reaction列表是:[user1: cry, user2: smile]

  1. 客户端发起getMsgReactionList()接口,请求获取服务端的reaction列表。
  2. 服务端查询数据库,并准备好数据,将[user1: cry, user2: smile]返回给客户端SDK。由于网络原因,这个数据还在传输中。
  3. 此时服务端收到新的reaction-dog后,服务端数据落库。

步骤二: 此时服务端数据库中,消息8的reaction列表是:[user1: cry, user2: smile, userB: dog]

  1. 将最新reaction列表通过长连接,推送给用户A 的收件箱。数据内容:[user1: cry, user2: smile, userB: dog]
  2. 客户端收到了消息8的reaction列表,去数据库中查询该消息体,并进行数据合并。SDK回包success给服务端。
  3. 此时客户端本地数据,消息8的reaction列表更新为:[user1: cry, user2: smile, userB: dog]

步骤三: getMsgReactionList()接口结果来到客户端

  1. 此时短连接请求完成,reaction列表给到了客户端
  2. 客户端更新数据库,此时消息8的reaction列表更新为:[user1: cry, user2: smile]

上面整个过程,描述的是由于弱网,导致服务端推送的新数据先落库,SDK拉取的旧数据后落库,导致数据不一致的问题。

其实在实际开发过程中,网络只是其中一个原因,多线程也是一个更大的原因。

在步骤一的第4步中,即使不是弱网,此时数据已经来到了客户端SDK,但是由于线程资源分配+数据处理,此时数据还在处理中,没有完成落库。而推送的新数据先处理完成,先落库了。也导致了数据不一致问题。

冲突场景

** 获取到的是 新数据 [user1: cry, user2: smile, userB: dog] 获取到的是 老数据 [user1: cry, user2: smile]
推送的是 新数据 [user1: cry, user2: smile, userB: dog] 没问题 情况一:获取先到,推送后到--->没问题 情况二:推送先到,获取后到--->有问题
推送的是 老数据 [user1: cry, user2: smile] 情况一:获取先到,推送后到--->有问题 情况二:推送先到,获取后到--->没问题 没问题

从上面的表格可以看出,有问题的情况,都是老数据比新数据晚到,最终老数据覆盖新数据,导致数据异常。

推与拉的架构设计

在上面的例子中,我们假设了一种场景,就是有些数据量很小的信息,服务端可以直接通过长连接推送下来。但因为同时存在客户端主动拉取的场景,导致了数据不一致问题。

那么为了解决上述数据不一致问题,通常会有这么几种做法。

方案一:推送事件,而不是数据

这种方案,就是与收件箱机制一样,无论数据量大小,服务端给客户端都推送的是事件,真正的数据由客户端控制拉取。

reaction列表为例,当服务端收到新的reaction后,应当向客户端推送一个reaction列表变更事件。客户端每次主动拉取全量的reaction列表。拉取时机由客户端决定。

这种方式不太推荐,因为会造成资源的浪费,对于简单的数据,应当保持推拉结合。而且即使是没有推送事件,客户端只使用主动拉取,当主动调用getMsgReactionList()接口,不同的getMsgReactionList()接口请求也需要保序,处理数据一致性问题。

方案二:客户端单线程保序

对于某个具体的业务,比如reaction业务,应当使用单线程保序。即reaction列表的被动推送与主动获取的逻辑,都应该放在一个线程中排序处理。

单线程的实现

这里以Android平台为例,解释单线程保序的实现。

单线程保序,其实本质上是一个线程池,这个线程池的核心线程只有一个、无非核心线程,同时等待队列无穷大。线程池就是按照入队顺序,一个一个执行任务。

kotlin 复制代码
val singleThreadPool = ThreadPoolExecutor(
    1,
    0,
    60L,
    TimeUnit.SECONDS,
    LinkedBlockingQueue()
)
// 允许超时回收核心线程
singleThreadPool.allowCoreThreadTimneOut(true)

在kotlin协程中,有个更优雅的方式,可以复用线程池能力,即单协程保序

假设我们有个自定义的线程池,核心数10,非核心数0,等待队列无限大。

kotlin 复制代码
val multiThreadPool = ThreadPoolExecutor(
    10,
    0,
    60L,
    TimeUnit.SECONDS,
    LinkedBlockingQueue()
)
multiThreadPool.allowCoreThreadTimeOut(true)

这里可以使用limitedParallelism方法,将一个线程池分割为不同的协程作用域。

kotlin 复制代码
val reactionScope = multiThreadPool.asCoroutineDispatcher().limitedParallelism(1)
reactionScope.launch {
    runBlocking {
        
    }
}

reactionScope就是一个单协程保序作用域,即使在不同场景、不同线程向reactionScope提交任务,因为runBlocking的存在,都会严格按照提交时的顺序执行。

单协程保序的好处是可以复用线程池,不用每个业务都写一个自己的单线程的线程池,有利于线程池治理。

下面解释单线程为什么能够保序。

正常情况

当客户端发起getMsgReactionList请求,该任务被提交到reactionScope执行。服务端接口返回reaction列表[user1: cry, user2: smile],网络畅通,客户端写入本地数据库。

然后服务端推送了新的reaction列表[user1: cry, user2: smile, userB: dog],客户端放到reactionScope处理,客户端写入本地数据库。

最终,客户端的本地数据库中,reaction[user1: cry, user2: smile, userB: dog],符合预期。

异常情况1:获取老数据+推送新数据

  • 获取的是 老数据[user1: cry, user2: smile]
  • 推送的是 新数据[user1: cry, user2: smile, userB: dog]
  • 推送老数据先到,获取新数据后到

具体流程:

  1. 当客户端发起getMsgReactionList请求,该任务被放置到singleThreadPool执行。getMsgReactionList任务包含获取服务端数据、将数据写入本地数据库两个动作。
  2. 服务端getMsgReactionList接口返回列表[user1: cry, user2: smile],网络不畅,还未到达客户端。
  3. 此时服务端推送了新的reaction列表[user1: cry, user2: smile, userB: dog],客户端放到singleThreadPool处理,我们把这个任务叫做pushReactionpushReaction任务包含写入本地数据库这个动作。
  4. 由于getMsgReactionList这个任务还没有完成,所以singleThreadPool没有空闲线程处理,pushReaction任务被存入到线程池的阻塞队列中。
  5. 当网络畅通后,getMsgReactionList请求返回[user1: cry, user2: smile],并写入本地数据库。
  6. getMsgReactionList执行完成,释放线程资源。
  7. 阻塞队列中的pushReaction排队执行,将[user1: cry, user2: smile, userB: dog]写入本地数据库。

最终本地数据库的数据一致可信。

异常情况2:推送老数据+获取新数据

  • 推送的是 老数据[user1: cry, user2: smile]
  • 获取的是 新数据[user1: cry, user2: smile, userB: dog]
  • 获取新数据先到,推送老数据后到
  1. 服务端推送了老数据[user1: cry, user2: smile]给客户端,由于网络原因,该推送还在网络层中。
  2. 然后服务端收到了新reaction-dog,落库成功。
  3. 客户端发起getMsgReactionList请求到服务端,getMsgReactionList任务先被放入singleThreadPool
  4. 服务端返回新数据[user1: cry, user2: smile, userB: dog],客户端将该数据写入本地数据库。
  5. 网络层的老数据[user1: cry, user2: smile]随后到来,被放到singleThreadPool中,客户端将[user1: cry, user2: smile]写入本地数据库

到这里,有人可能会有疑惑,虽然有单线程保序,最终本地数据库不还是错误的数据吗?

其实我们忽略了一点,就是第2步,当服务端收到新reaction-dog,落库成功后,还会向客户端推送最新的reaction列表,即新数据[user1: cry, user2: smile, userB: dog]。

让我们继续上面的流程。

  1. 服务端推送新数据[user1: cry, user2: smile, userB: dog]给客户端。
  2. 经过网络层,到达客户端的singleThreadPool,对数据处理后落库。
  3. 最终本地数据库的值为:[user1: cry, user2: smile, userB: dog]

这样,也就完成了单线程保序的逻辑。

方案三:版本号

版本号可以避免技术 上的复杂设计,在业务 上实现数据一致性

版本号,即服务端在数据落库时,给数据增加一个递增的版本号。这样无论客户端是否因为网络、时序问题,导致新数据先到、旧数据后到,客户端都可以直接读取版本号,对比本地版本号后,决定是否落库。

reaction列表为例,推送和主动获取的数据格式会变成下面这样:

json 复制代码
{
    "`serverMsgId`": "123456789",
    "`version`": "11",
    "`reaction_list`": [
        {
            "`user1`": "cry"
        },
        {
            "`user2`": "smile"
        }
    ]
}
json 复制代码
{
    "`serverMsgId`": "123456789",
    "`version`": "12",
    "`reaction_list`": [
        {
            "`user1`": "cry"
        },
        {
            "`user2`": "smile"
        },
        {
            "`userA`": "dog"
        }
    ]
}

那么无论是推与拉之间、还是拉与拉之间,只要有版本号,就可以在最终数据处理的时候,决定保留哪个数据。

总结

数据一致性问题不只存在推拉架构中,在传统的只有HTTP短连接请求的情况下,对于同一接口,比如需要写到本地的用户配置,不同场景触发了多次接口请求,这多次接口请求之间也存在数据一致性问题。

对于推拉的架构设计,技术上的单线程保序方案,业务上的版本号方案,分别对应了道与术。都能解决问题,具体应当结合项目实际情况决定。

相关推荐
2603_949462109 小时前
Flutter for OpenHarmony社团管理App实战:消息中心实现
android·javascript·flutter
andr_gale9 小时前
08_flutter中如何优雅的提前获取child的宽高
android·flutter
踏雪羽翼10 小时前
android 图表实现
android·折线图·弹窗·图表·自定义图标
有位神秘人10 小时前
Android中PopupWindow中如何弹出时让背景变暗
android
TheNextByte111 小时前
iPhone 与Android :有什么区别?
android·cocoa·iphone
_李小白11 小时前
【Android 美颜相机】第二十一天:GPUImageChromaKeyBlendFilter (颜色加深混合滤镜)
android·数码相机
yantaohk12 小时前
【2025亲测】中兴B860AV3.2M完美刷机包ATV版本安卓9-解决1G运存BUG,开ADB已ROOT
android·嵌入式硬件·adb·云计算
乐观勇敢坚强的老彭13 小时前
c++信奥寒假营集训01
android·java·c++
kdniao113 小时前
PHP 页面中如何实现根据快递单号查询物流轨迹?对接快递鸟在途监控 API 实操
android·开发语言·php
言之。13 小时前
MacBook M3 Pro:React Native 安卓开发
android·react native·react.js