1. 前言
当你在公司担任一名小组长、架构师、业务负责人,或者是独立开发,想要独立负责设计一块业务的时候,不管这块业务是一个开发模块、一个接口、一款业务中间件、一款springboot starter等等,切忌盲目开干。要知道,做程序开发,可不仅仅是“写代码”,一个优秀的程序员,在完成一项编码工作的时候,真正coding的时间往往只占据整个过程的20%。换句话说,如果你coding的时间超过了80%,或者接到需求稍微想一下觉得没问题就开干,干完了就完了,那这个东西基本上仍算作是有问题的产出。
为什么说的这么绝对呢,想象一下你做任何一件事情,不管是修理汽车、做饭、照顾小孩... ,就拿照顾小孩来说吧,你老婆让你今天独自带娃,你说OK,从来没有肚子带娃过的你,觉得很easy,没有做任何准备和预案。早上起床老婆出门了,然后你跟娃一起睡到了自然醒,突然娃醒了,说要喝奶,此时你必然要去泡奶,然而,你连奶粉放在哪里,奶瓶如何组装,是否清洗干净,清洗干净后如何快速烘干等等,啥都不知道,于是你慌慌张张,顶着娃的哭闹声给老婆打电话,边挨骂边泡好了奶,此时你以为终于做完了一件事,殊不知喝完奶的娃一不小心把粑粑拉到了裤兜里,你从没想过带娃还要处理如此事件。手足无措的你无可奈何,只能打电话把老婆从百忙之中摇回家。
2. 组件设计的顶层思路
回归技术上来,实际上万物是相通的,程序也源自于生活。我们在进行设计或者编码的时候,最重要的两件事,是你作为一个架构师、程序员,都要首先考虑到的:
- 我的目的是什么,解决什么问题,达到什么效果(完成目标)
- 运行过程中可能有什么问题,用什么方案应对(风险措施)
我觉得,只要你搞清楚这两个问题,将这两个点时刻挂在心中,那么你接到任何一项工作,都不会马上开干,不会马上coding,而是先打开文档编辑器,写个大纲,然后完善一下方案,制定预案,再按照方案进行实施。
下面,以“http接口开发”作为例子,展开说明一下。
2.1 完成目标
其实接口开发,对大家来说都是家常便饭,无非就是写一个Controller,加几个注解,写点业务逻辑,写点sql,稍微复杂一点的,可能调用一下像Redisson、easyexcel等等的api,或者组装一下数据,基本就这样了。实际上coding的时候的确也是这样做的。
但是,你在做之前真正了解需求了吗,或者说,你真正清楚你的“完成目标”吗?,如果你觉得你的目标就是开发这个接口,然后用postman能够调用成功并返回正常的用例结果就算达到了完成目标,那自然是远远不够的。
我们开发一个接口,能够完成请求的接收,处理,返回正确的结果,只是其中一个条件,你需要注意的还有
- 【最基本的】接口参数校验(哪些必填、哪些有长度限制、哪些有正则校验等等)
- 【接口权限】是否要求登录凭证(不能调用的接口,请求来了得拦下来)
- 【异常处理】接口超时了怎么办(如果存在复杂计算逻辑、sql执行比较耗时,不能一直等待)
- 【异常处理】针对能够预期的异常,要做异常的显示捕获,并返回指定的错误码
- 【异常处理】针对无法预期的异常,要做全局异常拦截,返回统一的错误码
- 【事务考虑】如果是事务接口,要保证数据的ACID特性,如果涉及到分布式调用,要考虑分布式事务,结合业务特性和服务架构选择适当的事务方案(强一致性、最终一致性)
- 【幂等校验】接口可能由于用户发起请求、网络异常等原因,发送重复请求,需要保证多次事务接口对数据产生的影响始终只有一次
- 【中间件影响】接口中涉及多个中间件调用的,比如:MQ/Redis/MongoDB/RDBMS等,要考虑调用顺序关系,并进行异常校验,如果可能影响到数据一致性的,要考虑相关方案
- 【并发安全】接口中可能存在资源抢占的,同一个资源不能被多个请求或线程同时访问的情况,要考虑施加乐观锁或者悲观锁,乐观锁通常能够基于CAS操作在内存变量或者数据库变量层面实现,悲观锁基于服务架构选择单机锁或者分布式锁方案
- 【服务保护】针对并发量较大的接口,可能在短时间被请求多次的,涉及频繁查询缓存、数据库等,对CPU计算资源或者磁盘和网络IO消耗过大的,需要进行资源限制,包括但不限于接口熔断降级、限流等手段
- 【审计追踪】接口调用情况、耗时、是谁在请求,做了什么操作,调用链路或者逻辑是怎样的,作为开发人员需要进行记录,便于后期埋点、统计、审计、排障、链路追踪等
- ......
看到没,就这样,还有很多要考虑的,上面只是一些通用场景下,一个接口在开发前需要考虑的内容,如果落实到具体的业务场景下,范围将大于上述的列项。
2.2 风险措施
针对上述提到的“通用场景”下的列项,逐一进行方案分析。
2.2.1 接口参数校验
这里就不在赘述了,现在哪怕是一个Java开发初学者也应该知道,基于spring框架进行接口开发时,有一套相对完善的基于注解的机制来针对Bean中的参数进行校验。
本文只谈思想,不阐述具体编码,因此,这里就理解为,在接口收到报文后,对请求字段进行校验即可,这当然是必须要做的,否则引起服务异常,数据异常,就会让自己的系统陷入万劫不复之地。
2.2.2 接口权限
gateway、Redis、jwt、二级缓存
spring框架也有一套比较完善的体系,比如Spring Security,但是本文将该方案进行一个抽象,在进行任意接口开发时,且不说针对每一个api单独的权限控制,你的系统应当具备最起码的权限校验,也就是登录凭证的校验能力。
要么在你的业务系统通过拦截器进行处理,但在如今微服务分布式集群的架构时代,最佳实践是在你的业务服务上层有一个网关负责进行该项事务的处理,你的业务服务只专注于业务,保证服务集群的单一职责原则。
但是,上述这个图,能够满足接口登录凭证校验的功能,但是性能却无法保证,因为图中提到了“与缓存中的token进行比对”,在分布式集群架构下,缓存通常采用缓存中间件来实现,如果每一次接口调用都要从缓存中间件进行获取,对于频繁调用的接口来说,缓存中间件的IO操作就会变得密集,那即便是像Redis这样高性能搞吞吐的中间件,也很容易出现性能瓶颈,因此,我们需要对这部分进行一次优化。
考虑到登录凭证在第一次登录后就不会再进行频繁修改,所以这是一个“读多写少”的业务场景,因此我们可以考虑采用二级缓存,也就是“在Gateway本地内存做一个二级缓存,将缓存中间件视为一级缓存,每次直接读取二级缓存中的数据来进行校验”,这样就将远程网络IO变成了本地内存IO,大大提升了查询效率。
本地缓存与缓存中间件Redis采用Redis的sub/pub机制,实时感知Redis的token变化并同步更新本地缓存,既做到了缓存数据同步的基本一致性,又做到了token校验的高性能。
2.2.3 接口超时
针对接口超时,主要通过以下两种思路进行解决:
- 服务性能优化
说白了,接口为什么超时,还不是因为性能不够,要么数据库sql太慢了,要么服务中RPC调用其他服务太慢了,或者业务逻辑太复杂太冗余,代码写的不够精炼等等,要做的就是分析业务和代码,进行优化
- 异常结果通知
如果实在是优化到极致了,或者业务实在是太复杂,下游业务服务链路调用太多太多,或者针对无法预期的超时,那么需要针对这类异常情况进行统计,分类,并针对性地设计异常码。触发异常时,通过异常码通知前端,并进行针对性地提示
2.2.4 异常处理
思路基本同上,能优化的进行优化,通过异常处理,异常分类并设定异常码进行最后的兜底。此处不再赘述。
2.2.5 事务考虑(数据一致性)
事务注解、分布式事务、中间件与数据库一致性方案 接口调用,尤其是涉及到数据操作的,往往涉及到事务问题。
众所周知,事务的保证依赖存储系统,由于当前分布式集群架构较为复杂,涉及到多个服务、多种数据库,甚至还涉及到非关系型数据库,因此这里分为几种情况:
- 单节点服务单关系型数据库
- 多节点服务的关系型数据库
- 单/多节点服务的关系型数据库+非关系型数据库
这几种情况有啥区别呢?
2.2.5.1 单节点服务单关系型数据库
这种情况就是最简单的情况,只需要依赖数据库事务管理即可,幸运的是,Java开发者基于spring框架进行开发时,直接通过@Transactional注解即可轻松完成数据库事务代理,不需要再基于sql进行操作。此处不再赘述。
2.2.5.2 多阶段服务的关系型数据库
那如果是分布式集群下进行事务调用呢,比如这样的情况
单次接口调用设计A和B两个服务,服务A涉及的事务操作与服务B涉及的事务操作并不在一个事务里,彼此提交和回滚也相互独立,这就容易出现事务A成功后,如果事务B失败,无法对事务A进行回滚从而保持整体事务一致性的问题。
因此我们需要通过其它解决方案来解决这个问题,好在的是他们是基于同一个数据库,因此,我们可以基于XA模式来解决(此处不对XA模式进行展开,不是本文重点,感兴趣的可以自行搜索引擎进行搜索),此外还可以借助其它分布式事务中间件,比如Seata,进行解决,此处可利用Seata的AT模式进行解决,当然使用TCC模式也行,但既然这里都是用的是同一个数据库,直接是用AT模式会更加简单方便(自行搜索Seata)。
2.2.5.3
那如果他们用的不是同一个数据库呢,或者更复杂的,用的都不是关系型数据库,比如下面这样的情况
服务A操作的是关系型数据库,但服务B是操作的Redis,要知道Redis是不具备回滚功能的,并且分布式事务中间件Seata也没办法解决,那咋办呢?
这种就需要业务侧自行处理了,解决方案思路大致有以下几种:
- 合理规划业务,基于最终一致性方案进行处理,可基于MQ的方案发送消息消费进行延迟事务一致性判断并保证
- 暴力解决,直接操作,业务代码不考虑异常情况,通过其它定时线程或者服务进行定期对账保证数据和事务一致性 3.还是可以用Seata的功能,比如TCC,自己梳理try/commit/cancel的业务逻辑进行保证,这里的Seata只是用来辅助编码,不直接提供事务一致性的保障功能
2.2.5.4 事务问题的总结
上述都没有展开进行说明,只是提到了解决方案思想,解决方案其它同学的博客或者搜索引擎都能找到成吨的答案,因此这里不再赘述,只是作一个大致说明,让大家能知道在接口设计的事务一致性的问题上要大致考虑哪些事情。
2.2.6 幂等校验
幂等性其实是数学上的一个概念,在软件领域,说的是“对于一个完全相同的操作,不管操作多少次,最终的结果都是一样的”。
在接口提交时,我们往往可能因为以下两种情况导致接口请求的重复提交:
- 网络问题,前一个请求未及时提交并返回,用户习惯性地疯狂点击提交按钮,导致成吨的请求被堆积,网络恢复后这些请求一股脑地被推送到后端服务
- 多个用户针对同一条/同一批数据进行操作,导致同类的请求在多个终端被同时提交到后端服务
不管怎么说,对于后端服务来讲,感受到的都是,接收到的多个请求,如果后端服务不对此进行感知并处理,可能导致数据重复添加、修改错乱、删除后又重新添加回来,添加后又被删除等多种情况,会给用户造成困扰,严重时会影响系统稳定性。
因此,幂等性的校验,对于接口乃至系统本身而言都是不可或缺的。
幂等性的校验根据数据的操作类型不同,也有不同的解决方案,数据的操作主要分为新增、修改、删除,因此幂等性的解决方案主要有如下几种思路:
- 【新增数据】要保证数据不可重复提交,需要校验数据是否已存在,已存在者不可再进行添加
- 【修改数据】要保证数据的修改对单次请求的结果是正确的,比如,请求A将x从0修改为1,请求B将X从0修改为2,两个请求几乎同时提交,请求A完成后返回修改成功,A以为此时X是1,但殊不知X转眼就被请求B修改为了2,A去查询时发现X竟然变成了2,于是一脸懵逼。所以要做到,A的请求成功后,就要保证X一定是1,不可再被修改为2,此时B请求尝试要将X改为2时应当报错。
- 【删除数据】一般来说这是天然幂等的,因为删除一批数据时,只会生效一次,即便是多次提交,被删除的数据已不存在,不会重复删除。
2.2.6.1数据是否存在的校验
针对上述提到的需要校验数据是否存在的方案,其实无非就是要从缓存里查询嘛,那么方案思路基本如下:
然而,这里有个很明显的问题:操作的原子性,就是说,这里的操作步骤是:
- 先查询缓存,并判断是否存在数据
- 如果不存在,就新增数据
上面这两步操作不是原子的,换句话说,如果此时有两个线程进来,同时执行步骤1,那么都查询不到缓存中的数据,都会认为数据不存在,那么都会执行步骤2,都会向缓存添加数据,如果针对同一个id的数据存在不同的value,就可能导致数据被覆盖的问题。
所以,为了保证原子性,需要对上述的步骤进行加锁,方案就变成了这样:
对于数据新增的场景,只有悲观锁能够保证操作的原子性。
2.2.6.2 数据修改并发安全校验
对于数据修改的场景,与数据新增不同。新增的场景是数据还不存在,而数据修改是基于已存在的数据,因此可以直接基于已存在的数据施加乐观锁来保证修改的并发安全性。
以关系型数据库中的数据举例说明:
- 首先数据需要有个状态值,该值只能向前变,不可回退,比如:订单的状态,只能从0(未支付)->1(已支付),不可从1变回0,。那么这个状态字段才能被用作乐观锁,否则可能会造成数据修改异常。如果没有这样的字段,可以自行增加一个版本号version作为乐观锁,该字段只能不断累加,不可递减。
- 其次在进行数据修改时,除了对需要修改的字段进行处理以外,最后还必须增加对version的++操作,并且where条件要附带上对version的原始值比较
update tableA set fieldA = #{fieldA}, version = version + 1 where id = #{id} and version = #{version}
这样就完成了基于原始数据的乐观锁修改,那么为什么这样可以保证数据修改场景下的并发安全呢?
如上图所示,其实这种思想叫做CAS,即:compare and swap(比较并交换),基于一个不会回退值的变量进行比较,比较匹配成功后执行操作,否则不执行。上述例子中负责CAS的字段就是version。
2.2.7 中间件调用
主要是涉及到数据一致性的操作,此处的方案参考【2.2.5 事务考虑】即可,此处不再赘述。
当然,数据一致性的方案有很多,这里只是抛砖引玉,具体需要根据实际业务系统架构和业务场景进行针对性梳理。
2.2.8 并发安全
并发安全主要需要用到锁的机制,锁的机制有以下几种(其实是不同维度的锁,便于整理,放到一块):
- 单机锁(synchronized、Lock等)
- 分布式锁(Redis、zk、etcd等)
- 乐观锁(数据库CAS、Atomic类等)
- 悲观锁(与单机锁、悲观锁一致,只是思想维度上的不同)
并发安全搜需要依靠锁的来实现,锁的区别在于是否是分布式集群架构,其次就是乐观锁和悲观锁的区别,在于是否会导致线程阻塞。
业务系统基于实际情况和实际业务,将锁的粒度精准控制在最小的代码片段,尽可能在保证数据安全的情况下,最大化提升接口性能和服务吞吐。
2.2.9 服务保护
接口开放出去后,我们没办法知道是谁在调用,以什么频率调用,如果遇到恶意攻击系统的,或者其它系统代码写的不好存在死循环调用的,我们的系统随时面临崩溃的风险,因此自我保护的策略必不可少。
那么我们需要对接口进行保护限流。
在业务上,设计思想有以下几种:
- 针对接口进行全局限流,不管是谁,这个接口每单位时间内只能调用多少次,不可超过这个阈值
- 更细粒度的控制,可以针对一个组织内的所有用户,或者针对单个用户,设置单位时间内的调用频次
在实现方案上,根据实际的服务架构来选择合适的方案:
- 【上层网关】直接在网关层面进行限流,可以基于Gateway进行配置,如果是Nginx作为网关,可以使用OpenResty+lua+Redis进行限流
- 【业务节点】可以利用限流框架比如Sentinel,在一个服务节点内,对具体的接口进行限流
- 【代码块级别】如果是单节点应用,则不必如此麻烦,直接利用JUC包下的Semaphore组件进行限流,该组件基于AQS实现,基于令牌桶思想,设置最大允许的信号量,超过此信号量的请求将被阻塞或者退回(取决于api调用方式)
总的来说,还是基于业务场景和服务架构进行综合考虑,选取最适合的方案。
2.2.10 审计追踪
废话不多说,两点足以:
- 准确清晰的日志记录
- 完善的服务监控体系
对于第一点,说白了,就是在关键的业务分支、关键的异常信息中,进行日志记录,日志记录遵循规范,既要完整,还不能冗余,要包含足够的关键参数,能够让开发者或者运维人员一眼看出问题所在。
对于第二点,就是业务调用链路的追踪,通常基于第三方中间件,比如:skywalking,基于traceId和spanId针对接口调用链路进行追踪,便于开发和运维人员进行分析,掌握接口调用链路和状态。
3 Coder不止是Coder
说了这么多,其实也只是通过“接口开发设计”为路引,来阐述一个开发者在工作中真正要做的事情。作为一个敲代码的,真不能就认为自己只是敲代码的,更不能整天只盯着代码,用到什么吊炸天的技术,代码写的多优雅觉得就很屌。
有两句俗话说的真的是有道理的:
- 【众所周知】好的开始是成功的一半
- 【我自己定义的】工作中需要关注的往往是隐藏在隐秘角落的那80%的工作,剩下20%往往是你以为的全部
coder和coder之间的差别,就体现在那隐藏的80%的工作中。
做其他事情也是一样的,coding和living都是相通的,毕竟程序源自于生活。做一个会living的coder,做一个会coding的liver。