异步是一种设计思想,不是设计目的,因此不要为了异步而异步,要有所为,有所不为。 异步不是『银弹』, 避免试图套用一个『异步框架』解决所有问题, 需要根据不同的业务特点或要求,选择合适的设计实现方式
同步和异步问题是大型分布式系统中需要慎重等待的问题,然而笔者发现公司和部门内相关问题系统性讨论较少, 因此笔者试图通过本文展开交流讨论。
在软件设计领域, 异步和同步是一对孪生的设计思想
同步是一种阻塞式且有严格执行时序的设计思想, 必须一件一件事做, 等前一件做完了才能做下一件事。
异步则是一种非阻塞的设计思想, 可以同时做多件事,没有严格的执行顺序。
同步调用方(客户端)在请求发起后会一直阻塞并等待被调用方(服务端)返回响应结果,且仅当调用方获取响应结果后才会继续或终止执行。 例如近期较为常见的『核酸采样亭』虽然采样亭内有多人协作,但是为了保证采样人管一致, 核酸采样队伍就是顺序行进的, 前一个没有录入完,也不会继续下一位。
异步调用方(发布方)无需等待被调用方(订阅方)执行完所有逻辑,就可继续执行后续事情。例如一些餐馆的『排队』场景,往往点餐区仅需要完成『点餐收银』动作, 之后订单会转到后厨进行制作, 此时点餐人无需继续在点餐去等待, 可以去找位置坐下等取餐提醒, 或者心急的话在取餐区排队等待。
同步 |
异步 |
|
特征 |
1)阻塞:调用发起方在请求提交后不会向系统交出控制权,而是持续等待被调用方返回响应结果。 |
1)非阻塞:任务提交后将控制权交予系统,系统可以进行其他任务的执行 |
优点 |
1. 严格保证时序: 同步流程是最天然的控制过程顺序执行的方式, 因此对结果的处理始终和前文保持在一个上下文内。 |
1. 逻辑解耦:可在模块、服务、接口等不同粒度上实现解耦, 便于进行功能降级,提升系统稳定性 |
缺点 |
1. 耦合度高: 调用方强依赖被调用方的状态 |
1. 数据一致性影响:由于调用发起方和被调用方通过异步进行了解耦, 数据一致性难以保证, 需要提供额外机制进行补偿。 |
在异步设计过程中需要重点关注的指标: 数据一致性
什么是数据一致性
为什么会有一致性的需求
如何解决一致性问题
为什么会出现数据丢失?
如何防止数据丢失?
什么是幂等 ( idempotent )?
幂等性: 在计算机领域, 对于一个操作, 在输入相同时,如果它确保对我们关注的影响,在多次执行时和一次执行时相同, 那么这个操作对于我们来说就是幂等的。
在分布式环境下之所有强调幂等性是由于对通信链路的不信任,我们的请求可能由于网络问题或依赖服务的稳定性而出错进而需要做重试,而如果我们对应的接口没有做请求去重或进行幂等设计就可以导致重复处理引发数据错误。
什么场景需要考虑幂等问题
如何解决幂等问题
适用场景 |
实现要点 |
实现方式 |
|
token 机制 (业务唯一标志)分布式锁 |
接口重放(攻击)用户主动的重复创建 |
Token 用于控制过滤重复动作,是指在动作流转过程中控制有效请求数量。 为每个请求分配一个具有业务含义的唯一 ID,例如组织架构调整过程中的 draftID 或是商品订单提交场景的 订单 ID |
|
唯一索引,防止新增脏数据 |
适用于创建场景,利用数据库唯一键约束 |
该方式主要利用数据库本身的唯一键约束 |
根据业务场景设计唯一键
|
悲观锁 |
适用于更新场景 |
|
当数据库执行 select for update 时会获取被 select 中的数据行的行锁,因此其他并发执行的 select for update 如果试图选中同一行则会发生排斥(需要等待行锁被释放),因此达到锁的效果。 |
乐观锁 |
适用于更新场景 |
简单理解就是在数据更新时需要去比较持有数据的版本号,版本号不满足条件的操作无法成功 |
|
状态机 |
本质也是乐观锁的一种,适用于业务有多种状态,且状态间流转是有向图的场景 |
主要思路就是通过状态标识的变更,保证业务中每个流程只会在对应的状态下执行,如果标识已经进入下一个状态,这时候来了上一个状态的操作就不允许变更状态,保证了业务的幂等性 |
状态机是一种强业务相关的设计, 需要根据具体场景处理 |
从 ROI 方面考虑, 乐观锁 > 唯一键约束 > 悲观锁
Notification 方式
Notifications - a sender sends a message a recipient but does not expect a reply. Nor is one sent.
设计要点:
流程:
优点:
缺点:
应用场景:
// example: metrics 埋点
func CallExampleRpc(ctx context.Context, req *example.ExampleRequest) (*example.ExampleResponse, error) {
const method = "CallExampleRpc"
callRes := RPCCallResultSuccess
defer func() {
// 将 RPC Call 的执行结果上报 metrics
go MetricsEmitRPCCallerResult(cli.psm, cli.psm, callRes, method )
}()
// some logic here
... ...
}
获取被调用方响应常见有两种方式:
轮询模式
设计要点:
流程:
优点:
缺点:
场景分析: 适用于本身异步任务并不产生数据,对任务是否失败不是很敏感的场景,例如 用户进入画布时的冲突校验。这个场景下,冲突校验本身并不生产新的数据,不会由于任务丢失或失败产生脏数据影响用户的使用。另外这个校验场景本身对任务的成功或失败并不敏感,如果任务失败了,不需要自动重试校验,用户点击刷新页面,重新访问接口即可重新触发另一次的校验
// 通过基于 promise 模式实现的 goroutine 协程
f := future.WhenAll(
func() (interface{}, error) {
return CheckDepartmentModifyProcess(realCtx, modifyDepChangeList, params)
},
// 用于示例, 截取部分片段
...
func() (interface{}, error) {
return CheckDepartmentDeactiveProcess(realCtx, deactiveDepChangeList, modifyParentOrCreateChangeList, params)
},
)
// 等待异步流程执行完成,然后处理执行结果
if maxExecuteTime != nil {
res, err, timeout = f.GetOrTimeout( * maxExecuteTime)
} else {
res, err = f.Get()
}
回调模式
设计要点:
流程:
优点:
缺点:
// 回调示例
func asyncHandler(ctx context.Context, param *AsyncParam) (error) {
// do something
defer handleAsyncErrorCallback(error)
}
func handleAsyncErrorCallback(error) {
// do something
}
func doSomething(ctx context.Context, param *Param)() {
// do something
go asyncHandler(param)
// do something else
}
设计要点:
通过 Rocketmq 发送 task 到队列,开启 consumer 消费任务,并使用 redis / DB 对任务进行状态的监控
流程:
1. 用户触发流程操作,提交 task 任务到 rocketmq 的 producer 中
2. Rocketmq 的 consumer 接收到 msg,并在 handler 中执行回调逻辑,并更新 redis/DB 状态
3. 用户 check redis/DB 中的状态,确定任务执行成功还是失败。同时也可以通过 check rocketmq 的消费 ack 情况判断任务是否真的执行完成
优点:
缺点:
场景分析:
由于 rocketmq 自动重试的特性,这种类型更适合,task 逻辑相对简单,且本身并不生产新的数据,但是任务本身是否成功比较敏感,在任务执行失败后,可以进行自动重试,确保服务的稳定
设计要点:
本方案会引入三方分布式任务管理框架 通过 Scheduler Job 去触发任务,使用 Scheduler 本身的能力记录 task,通过 redis 记录 task 运行中间过程
流程:
优点:
缺点:
场景分析:
由于 Scheduler 本身很重,因此本身更适合去执行逻辑复杂的 task,Scheduler 通过长链接监控任务执行状态,确保不会因为超时导致任务状态异常
另外由于 Scheduler 的 task 信息持久化的能力,如果任务本身会生产处理新的数据,这样的任务也更适合使用 Scheduler 进行任务管理,方便后续确保数据一致性问题时,进行对账
本文从异步定义开始,通过对异步设计要点(数据一致性)的分析,推出异步设计的几种常见方案。
在异步设计过程中需要重点关注的指标: 数据一致性
异步设计常见解决方案:(根据数据一致性要求从低到高排序)