拉巴力的纸皮箱


  • 首页

  • 标签

  • 归档

  • 关于

  • 搜索

外网请求如何保证幂等

发表于 2020-07-08

常见的外网请求,通常来自网页或app。一个不幂等的接口,可能会导致用户只点击一次,却产生多次点击的效果。

  1. 如果用户的请求只是修改昵称,那么基本没影响
  2. 但如果用户的请求是扣费,比如送礼,那就会产生多扣费的资金问题。

问题的原因

在互联网上,无论是内网还是外网,网络经常会有不稳定的情况。为应对这些网络问题,通常会有各种重试策略。

  1. 内网不稳定的情况,通常会在代理上(如nginx)配置一些超时重试,或50x重试策略
  2. 外网不稳定的情况,客户端会在超时时进行重试或切域名重试等

这些重试策略,导致了用户端只发出一次请求,实际服务端收到多次请求的情况。

总结:“重试”是产生问题的源头

解决问题的关键

解决问题的关键其实很简单,就是要让服务端能识别接收到的“多个”请求是否是“同个”请求,从而确保业务只执行一次。

如何标识请求的唯一性

本质上是请求的参数中,包含由一个或多个参数组合而成的,唯一且不变的标识。

  1. 像修改用户昵称的请求,业务本身就幂等,因为用户id是唯一且不变的
  2. 像用户的扣费请求,那么通常的做法是使用唯一的订单号,以及相应的唯一ID生成策略

唯一ID算法需要考虑哪些

  1. 有序
    • 唯一ID作为数据的索引,保持有序有助于数据库性能提升
  2. 数据库类型
    • 以MySQL为例,选择bigint(20)还是varchar,前者只有64位,像”2017072809364399365840049582“这种订单号只能用varchar存储
  3. 基因
    • 分库需求,详见:唯一ID的基因
  4. 性能

前端唯一ID生成方案

  1. 使用UUID (缺点:无序)
  2. 通过后端接口获取唯一ID(缺点:多一次外网的网络请求)
  3. 按一定的业务规则生成,如:timestamp+ 用户ID(10-11位)+ 随机3位数字(或递增)(优点:有序且基本保证唯一)

后端唯一ID生成方案

  1. 使用snowflake算法实现的唯一ID服务
  2. 其他业界方案

解决方案

  1. 前端唯一ID使用方案3
    • 后端服务对前端生成的ID进行规则校验,防止恶意伪造不规范的唯一ID
  2. 后端唯一ID
  3. 直接使用前端传入的唯一ID
  4. 从唯一ID服务获取(和前端唯一ID进行映射)

扩展

不同业务唯一ID冲突问题

  • 若后端作为一个基础服务,对接上层业务,每个业务使用的唯一ID规则不一样。那么如何避免业务之间唯一ID冲突?
    1. 使用业务ID+业务唯一ID进行订单唯一性区分
    2. 统一唯一ID的生成规则(统一从基础服务获取或由该服务提供订单ID申请接口)

跨机房的服务幂等问题

  • 若服务部署多个机房(通常每个机房有对应的数据库),如何保证幂等,因为外网请求重试,不一定会到达同一个机房。
    1. 把用户按机房分区
    2. 不考虑跨机房幂等问题(主备架构下同个请求落在不同机房概率不高,收益低)

唯一ID的基因

发表于 2020-07-07

在互联网服务中,经常需要使用唯一ID。其中一个常见的应用场景是作为业务中请求的幂等ID。

分布式唯一ID生成方案

  1. uuid
  2. snowflake
  3. 包含业务属性的唯一ID,如 timestamp+ uid(10-11位)+ 随机3位数字(或递增)
  4. 其他方案

优劣不在这里讨论

业务场景分析

结合目前工作中的现状进行分析

  1. 唯一ID的存储方式:MySQL-bigint(20)
  2. 唯一ID生成方式:snowflake-64位:42位时间戳+5位机器码+5位进程worker标识码+12位自增id(42|5|5|12 = 64)
  • 我们把幂等ID作为数据库的唯一键,从而保证幂等;而当数据量越来越大是,我们对数据按月进行分表,提升处理性能。

  • 我们在数据库中除了有幂等ID(orderId)字段之外,还有添加时间字段(addTime);当请求进来之后,根据addTime找到相应的月表。所以实际上要保证请求处理幂等,依赖的是orderId+addTime 不变(或addTime保证始终落在同一个月)

为什么要依赖addTime保证幂等?

  1. 基础组件提供的唯一ID生成服务,对snowflake进行个性化的改造,只能保证ID是唯一的,然而ID的属性并不明显,无法方便的使用唯一ID进行分库(实际上snowflake算法的前n位是时间戳,可以考虑作为分库的属性)

  2. 一些常见唯一ID的例子

    交易单号:4008722001201707283057762612
    商户单号:2017072809364399365840049582
    订单编号: 60310040822721833
    支付宝交易号: 2017092021001001150522558267
    大众点评订单号36611441412777832
    

像“商户单号”和“支付宝交易号”,很明显可以使用日期“基因”来分库,不过这种唯一ID不能使用bigint 存储,因为超过64位了

总结

一个好的唯一ID算法生成的ID,应该具备易用的“基因”。像UUID这种就不是很满足。

如何处理RPC返回的错误码?

发表于 2020-07-07

RPC调用的返回结果

  1. 成功
  2. 失败
  3. 超时

超时情况是不确定的,需要调用方重试或查询等,根据业务情况进行处理

返回结果表示方法

  1. 使用http协议的状态码
  2. 使用业务错误码(在业务处理中比较常见)
    • 0表示成功
    • 1表示失败
    • 其他错误码代表具体的失败业务场景

失败错误码类型

  1. 业务错误(参数错误,或业务场景校验限制,请求已经处理完成)
  2. 处理速度慢(msg:”请求处理中”)
  3. 限流(msg:”你的操作太快了”)
  4. 未知错误
  5. 其他

针对2或4的场景,很可能提交的RPC实际上已经成功,属于不确定的情况(同超时),业务方不能直接当成失败处理;1的场景中“请求已经处理完成”,实际上已经成功,只是调用方的接口返回不幂等。

错误码处理

定义RPC请求中,成功,失败,不确定三种结果对应的错误码集合(最好可配置),业务针对不同的结果进行相应处理。

  1. 成功(错误码:A,B,C ….)
  2. 失败(错误码:E,F,G ….)
  3. 不确定(错误码:H,I,J …. 和 ”超时“)

上线需要做哪些准备?

发表于 2020-07-06

一般常规的上线,需要做哪些准备,确保不会因为遗漏这种低级错误导致线上问题?

  1. 开发中遇到问题标注TODO,避免遗漏,单元测试覆盖业务逻辑
  2. 准备上线checklist,验收方案
  3. 检查上线配置
  4. 是否需要配置定时任务
  5. 重新检查一遍变更的代码

扩展

  1. 涉及app的,需要考虑旧版本兼容和回归测试(重点回归若干个高流量的旧版本)
  2. 若上线后出现问题,先回滚再查问题(适用于大多数场景)
  3. 核心业务要重点测试

使用ThreadContext缓存RPC结果

发表于 2020-07-06

在业务开发中,经常会使用RPC请求获取数据。有时候在同一条逻辑链路中,会多次使用RPC返回的数据。

业务场景

请求----------> 服务A
             [1.methodA]----获取用户数据--->服务B(用户服务)
             [2.mehtodB]----获取用户数据--->服务B(用户服务)

上图中,服务A中同一个逻辑链路中包含methodA和mehtodB,两个都会使用到用户数据,因此会导致重复的RPC调用。

解决方案

  1. 将数据实体作为methodB的参数传入
    • 这种方式可以避免调用多次重复的RPC,但是也有缺点:
      a. 如果有mehtodC,methodD等,每个方法都加个参数,不是很优雅
      b. 如果除了获取用户信息,还要获取商品信息等,那么方法形参将越来越多,影响阅读
请求----------> 服务A
             [1.methodA]----获取用户数据--->服务B(用户服务)
             [2.mehtodB(param1: userInfo)]----获取用户数据--->服务B(用户服务)
             [3.mehtodC(param1: userInfo)]----获取用户数据--->服务B(用户服务)
  1. 使用 ThreadContext 缓存RPC结果
    • 可以使用拦截器统一处理服务的所有ThreadContext(因为使用完之后需要remove)
    • 将RPC结果保存到ThreadContext和从ThreadContext获取RPC结果的逻辑,封装在RPC调用方法中
      请求----------> 服务A
             [1.methodA]----获取用户数据--->服务B(用户服务)
             ->> 将RPC结果保存在ThreadContext
             [2.mehtodB]----获取用户数据--->从ThreadContext获取
             [3.mehtodC]----获取用户数据--->从ThreadContext获取

关于服务间一致性和前置校验的思考

发表于 2020-07-05

每个服务都有相应的前置校验,有些前置校验逻辑不统一,就会导致服务间数据的不一致。比如风控拦截,服务A未拦截,而服务B却拦截了,从而导致不一致。

业务描述

  • 下面描述一个仓库送礼的场景
    送礼请求----------> 服务A(送礼服务)
               [1.出仓库礼物]------------>服务B(仓库服务)
               [2.收礼者加分成]------------>服务C(消费服务[含分成服务])

送礼请求----------> 服务A(送礼服务)
             [1.出仓库礼物]------------>服务B(仓库服务)
             [2.收礼者加分成]------------>服务C(消费服务[含分成服务])
                                [查询是否风险用户]------------>服务D(风控服务)

服务C增加查询之后,如果是风险用户,将会拦截加分成的操作。这样就会导致用户仓库礼物出仓了,但是收礼者却无法接收分成

如何解决

服务B也要进行风险用户判断,针对资金操作相关的校验,可以统一逻辑,避免相同逻辑维护多个地方

  1. 服务C增加一个统一判断接口
    送礼请求----> 服务A(送礼服务)
              [1.出仓库礼物]----->服务B(仓库服务)
                            [ 1.1查询用户是否允许资金变动]----->服务C
                                         [1.1.1查询是否风险用户]--->服务D(风控服务)
              [2.收礼者加分成]----->服务C(消费服务[含分成服务])
                                [2.1查询是否风险用户]----->服务D(风控服务)


若用户是风险用户,操作1就会返回失败,不出仓,也无后续的操作2,这样就避免不一致的问题

  1. 收敛消费入口,统一资产类的入口
    方案1的缺点明显,每个服务都要直接或间接的查询一次风控服务,实际上理想情况下,只需要查询一次即可。
    送礼请求----------> 服务A(送礼服务)
              [1.出仓库礼物和加分成]--------->服务C(消费服务)
                                    [1.1查询是否风险用户]------------>服务D(风控服务)
                                    [1.2出仓库礼物]------------>服务B(仓库服务)
                                    [1.3收礼者加分成]


由消费服务统一对资产进行“出”和“入”

  1. 网关服务拦截
    由网关服务统一调风控服务进行判断,并支持业务类型定制(比如资产类接口应该进行哪些判断)

RPC可以和事务绑定吗?

发表于 2020-07-05

在平常编码的时候,经常会看到很多人喜欢把一次RPC调用和数据库事务绑定在一起,RPC调用成功则提交事务,RPC调用失败或超时,则回滚事务。那么这样做是对的吗?

业务场景描述

考虑以下一种业务场景:用户加入群聊需要花费200金币。
服务架构:服务A(群服务),服务B(消费服务),操作都是幂等的。

加群请求----------> 服务A
             [1.新增事务]
             [2.RPC进行扣费]------------>服务B
             [3.执行加群操作(DB操作)]
             [4.提交事务]

业务场景分析

  1. 服务B返回成功,业务无异常
  2. 服务B返回失败,事务回滚,业务无异常
  3. 服务B超时,或服务A RPC调用之后重启等场景,业务异常!

业务异常表现为:用户已经扣费,但是没加群成功(即服务A和服务B数据不一致)

如何解决

  • 服务A通过本地事务表解决。

    1. 服务A在进行RPC调用之前,需先保存一条订单,订单状态为“待扣费”,扣费成功后,更新订单状态为“已扣费”。
    2. 启动定时任务,查询过去某时间范围内(如30分钟前至当前时间1分钟前)的“待扣费”的订单是否已扣费。若已扣费进行加群操作,并更新订单状态;否则,将订单置为无效状态。
    3. 订单状态:[1待扣费,2已扣费,3已加群,4无效]
  • 加群流程图

    加群请求----------> 服务A
               [1.新增“待扣费”订单(DB操作)]
               [2.RPC进行扣费]------------>服务B
               [3.更新订单状态为“已扣费”(DB操作)]
               [4.执行加群操作(DB操作)]
               [5.更新订单状态为“已加群”(DB操作)]


上图的每个操作都是单独的,其中某一步异常都会导致后续中断,因此需要两个定时任务,分别检查“待扣费”和“已扣费”的订单。

  • 定时任务1
    定时任务1---------> 服务A
               [1.查询“待扣费”订单]
               [2.RPC查询是否已扣费]------------>服务B
               |(1)已扣费                         |(2)未扣费
               |                                         |

[3.更新订单状态为“已扣费”(DB操作)] [3.更新订单状态为“无效”(DB操作)]
[4.执行加群操作(DB操作)]
[5.更新订单状态为“已加群”(DB操作)]

  • 定时任务2
    定时任务2---------> 服务A
               [1.查询“已扣费”订单]    
               [2.执行加群操作(DB操作)]
               [3.更新订单状态为“已加群”(DB操作)]


当然定时任务1和2可以放在同一个定时任务执行

  • 解决了问题之后,我们再回头看看一开始的问题:“RPC调用和数据库事务不能绑定吗?”
    • 答案是看具体业务情况而定
    • 比如上述的业务场景。操作1显然是不能绑事务的
    • 但是2,3,4,5操作其实是可以绑事务的,因为定时任务可以进行补偿,而且可以因此减少定时任务2
    • 总结:rpc操作后续可补偿重试或查询,那么就是可以绑事务的,因为服务不会“失忆”;相反数据未落地之前,是不能进行绑事务了,因为一旦异常,数据就会回滚而“丢失”。

其他解决方式

  1. 业务对账,补偿机制。即接入第三方服务进行业务流水对账,发现不一致,进行修正;具体修正方式需根据具体业务场景,是退币,还是使加群成功。
  2. 使用其他分布式事务解决方案(tcc,seata等)

扩展

  • 重试是 “写”还是“读”?比如上述扣费操作,超时情况下,服务A重试是查询还是重新执行扣费?
    • 其实还是取决于业务场景
    • 一般情况下,超时情况下,返回用户是失败,所以使用查询比较合适,不系统自动为用户扣费,这时候应该是“读”
    • 如果需要使用rpc调用进行加群,用户扣费了就必须要加群成功,那么加群RPC重试应该是“写”
    • 对于金钱业务来说,一般情况下,加币操作(充值,活动赠币)是“写”,扣币操作(送礼,用户触发)是“读”;入仓是“写”,出仓是“读”
  • 补偿操作是“正向”还是“逆向”?比如上述扣费操作,超时情况下,服务A重试查询到用户已经扣费成功,这是是应该给用户退币,还是加群成功?
    • 一样还是取决于业务场景
    • 如果用户只是想加群,那么只需要正向操作就好了,不影响用户利益,只是加群操作有些延迟,这时候应该是“正向”
    • 如果用户扣费之后,可以进行抽奖等,相当于用户的权益是具备实时性的,因为扣费超时(实际成功)导致无法抽奖,那么应该是退币才对的,即“逆向”
  • “无效”状态的订单应该支持重试,因为无效是因为未扣费,若作为一个基础服务,上次服务可能会使用同一个订单号进行重试,那么不能告诉业务方,操作失败等,应该作为一个全新的请求处理。可以通过增加订单的流水日志解决数据覆盖问题。
  • 上述的处理方式中,余额不足的扣费,会导致比较多的无效订单,因此在扣费之前,可以增加一次RPC查询,判断是余额是否足够

发版过程的兼容性考虑

发表于 2020-03-29

  • 一般情况下,服务一般不是仅有一台服务器提供服务的,因此服务上线的过程中其实会经历一个灰度过程

如上所示,服务器A是新代码,走的是新逻辑,服务器B和C未发版,走的旧逻辑。

  • 如果新上线的变更只涉及读逻辑的,那么这样的灰度上线是没问题的。
  • 但如果上线的内容涉及写逻辑的变更,甚至还关联到异步处理的变更。那么这样的上线方式可能是不兼容的,会导致处理异常。
  • 在服务全量发版之后,新的流量可能正常,但发版过程中导致一定的脏数据。

(图不画了,自行想象)

解决方案

  • 开关!用配置中心开关控制,走新逻辑还是旧逻辑。默认旧逻辑。
  • 代码需要保留两套,通过开关控制走哪个分支。
  • 全量发版之后,此时线上全是新代码了,通过开关灰度(此时是业务灰度而不是机器灰度了)

表结构变更是否需要处理历史月表?

发表于 2020-03-29

  • 目前针对历史流水表,历史订单表,采用的以月为维度的方式建表,如以下表结构所示
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    CREATE TABLE `t_order_202002` (
    `order_id` varchar(128) NOT NULL COMMENT '订单id',
    `coin` decimal(32,2) NOT NULL DEFAULT '0.00' COMMENT '支付币数',
    `user_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '用户id',
    `create_time` datetime NOT NULL COMMENT '创建时间',
    `update_time` datetime NOT NULL COMMENT '更新时间',
    PRIMARY KEY (`order_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表'

    CREATE TABLE `t_order_202003` (
    `order_id` varchar(128) NOT NULL COMMENT '订单id',
    `coin` decimal(32,2) NOT NULL DEFAULT '0.00' COMMENT '支付币数',
    `user_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '用户id',
    `create_time` datetime NOT NULL COMMENT '创建时间',
    `update_time` datetime NOT NULL COMMENT '更新时间',
    PRIMARY KEY (`order_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表'

    CREATE TABLE `t_order_202004` (
    `order_id` varchar(128) NOT NULL COMMENT '订单id',
    `coin` decimal(32,2) NOT NULL DEFAULT '0.00' COMMENT '支付币数',
    `user_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '用户id',
    `create_time` datetime NOT NULL COMMENT '创建时间',
    `update_time` datetime NOT NULL COMMENT '更新时间',
    PRIMARY KEY (`order_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表'
    现要对表结构增加一个字段 room_id
    1
    2
    3
    4
    ALTER TABLE t_order_202003
    ADD COLUMN `room_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '动作发生的所在房间ID';
    ALTER TABLE t_order_202004
    ADD COLUMN `room_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '动作发生的所在房间ID';
    那么久的历史表可以不处理吗?
  1. 假如全部的查询语句都是select *, 那么久的历史表可以不处理,但是这样违反了按需查询原则
  2. 如果查询语句有指定字段如select order_id, room_id,那么查询到旧的表时会报错

解决方法

  1. 历史表和当前的表结构保存一致,在进行表结构变更时一并处理,避免后续的坑(没精力折腾的建议这样处理,可以增加一些监控手段)
  2. 历史表不处理,在程序代码上兼容。这样会增加代码复杂度,难以维护,如果有必要最好统一sdk中处理。
  3. 运维层面,统一处理,统一月表的结构,需要DBA工具支持

移动端下一种折中的分页方法

发表于 2020-03-26

  • 移动互联网下微服务大行其道,服务之间按业务拆分精细,各司其职,通过服务发现rpc相互调用,实现各自的需求场景。

  • 一个简单的列表,如果不依赖其他服务,只依赖自身的数据库,可以简单的通过sql即可查询出来。

  • 而如果数据来源于其他服务,根据特定的需求场景,可能就需要调用多个服务,获取数据,判断属性,过滤数据,再展示,那么可能未免会出现分页的问题。

  • 以下面的场景为例:

  • 业务服务依赖两个底层服务的数据进行聚合,导致返回客户端的数据小于请求值10,甚至为空,客户端认为已经没数据了,从而错误提示用户“已经到底了”。
  • 解决方案:客户端不依赖返回数据的数量判断是否还有数据,由服务端返回”hasNext=1”来判断是否有数据,控制用户是否可继续滑动查看

  • 方案并不完美,极端情况下可能因为数据被过滤,导致返回前端数据太少设置没数据,影响体验。所以此方案只是不考虑极端场景的折中方案,另外的优化手段是初始请求size控制在一个合适的值,比如size=30
<1…141516>

154 日志
165 标签
RSS
© 2025 Kingson Wu
由 Hexo 强力驱动
|
主题 — NexT.Pisces v5.1.4