做好设计:流程设计
没有什么是一成不变的。
在“做好设计:存储设计基础”一文中,探讨了软件设计中的关键环节:存储设计。存储设计完成之后,要进行流程设计。
要做好流程设计,首先要熟悉各种基本流程模式。可阅:““驯服”业务流程:盘点业务开发中的常见流程模式 ”。
熟悉常用流程模式之后,就需要灵活加以组合,来构建复杂的业务流程。
业务流程需要满足的若干特性
- 完整性:构建完整的业务流程,从数据流入到数据存储或数据流出,畅通无阻。
- 清晰性:流程表达要足够清晰,容易理解。
- 健壮性:对于流程中的各种错误,要确保合理地处理,避免流程中断,或者僵死在某个状态。
- 性能:流程在可接受的时间内完成。
- 稳定性: 流程在大数据量或大流量的压力下,不会阻塞崩溃。
- 可扩展性:如果要在流程中添加新的子流程处理,能否很好地添加而不影响已有。
构建复杂流程的方法
如何构建复杂的业务流程呢? 运用“分解-抽象-封装-复用-组合-关联-重构-函数式编程”的思想和方法来搞定。
分解
分解,即是将具有独立语义的子流程和流程逻辑单元分离出来。
- 将整体流程分解为若干个子流程;
- 将每个子流程进一步分解为更小粒度的流程逻辑单元;
- 一直分解,直至每个流程逻辑单元简单而清晰(只做一件事)。
构建流程逻辑单元的模式可以对应到““驯服”业务流程:盘点业务开发中的常见流程模式 ”一文中的基本流程模式。
分解这一步非常重要:
- 将整体流程中的子流程分离出来再组合,可使其依赖和组合关系分割得更清晰,使凌乱的整体流程逻辑整通顺,变得整洁有序;
- 分解整体流程的过程中可以增进对整体流程的理解;
- 为抽象、封装和复用打下基础。
分解得足够细,就更容易抽象和封装,更容易复用。
抽象
在分解的基础上,将关键信息提炼出来,将流程共性提炼出来,封装成可复用的标准对象和方法。
- 关键信息提炼;
- 共性流程提炼。
标准对象提炼非常重要,体现了对流程中关键信息的认知。流程通常是围绕关键信息而构建起来的。
流程共性抽象亦非常重要,将子流程的共性抽离出来,更容易复用,减少重复代码及伴随的BUG。
比如恶意进程检测流程的关键信息是什么? MD5, SHA256, filePath。准确地构造这 3 个信息,基本就把握了恶意进程检测流程的关键。
比如各种检测流程有什么共性流程?获取规则信息、构建告警详情、存储告警详情、白名单检测。
抽象的结果通常是若干个可复用的独立语义的概念。而封装则需要将概念转换为具体的实现形式。
封装
抽象好后,就需要设计良好的可复用的封装形式。
- 关键信息封装成标准对象(可对接不同业务)。
- 子流程共性封装成流程组件;
- 流程组件封装成高层次的流程组块。
良好的封装可以减少重复代码的出现,让流程更清晰而容易理解,减少认知负担。
封装单元可大可小。封装单元可以是一个对象、方法、组件类,也可以是多个类组成的库或框架,或一个相对完整的流程。
比如知道恶意进程检测的关键信息 MD5, SHA256, filePath,就可以把这三个信息封装成一个标准对象 VirusCheckTaskInfo。
比如通用入侵检测流程里的获取规则信息、检测结果填充、告警详情构建、告警存储、发送大数据,都可以封装成流程组件,可以在不同的检测流程里复用。
复用
- 提炼流程组件的通用部分进行复用;
- 提炼流程组块的通用部分进行复用。
有了封装的基础,复用就比较容易了。只需考虑在具体的业务场景如何去引用,或者对现有封装做一点小的改造。
流程组块是理解和构建复杂流程的基础。越是能够在高层次流程组块思考和构建流程,就能驾驭越复杂的流程。
有了可复用的封装的基础,接下来就只需要考虑封装单元的组合和关联。
组合
- 在可复用的流程组件和流程组块的级别上进行组合。
比如上述多个流程组件可组合成通用入侵检测流程的核心部分,而这个通用入侵检测流程的核心部分又在更复杂的检测流程里复用。比如过各种已有引擎检测库,就几个字,却代表着一个包含多个引擎库的复杂的恶意进程检测流程。
组合通常是串行、并发的各种流程组合模式。这就运用到““驯服”业务流程:盘点业务开发中的常见流程模式 ”一文总结的各种业务流程模式及组合模式。
关联
- 将有关联的信息通过唯一标识关联起来;
- 通过实体表的唯一标识字段的外键关联来建立。
比如脱壳进程的文件和原进程的文件信息,就可以通过在脱壳进程文件记录里关联原进程文件记录的 MD5 或 SHA256 或 fileId 来实现。
重构
- 反复运用上述方法,对已有流程进行改小步重构,是一种很好的理解和梳理复杂流程的方式;
- 小步重构技巧:每次抽离一部分流程,然后写一个短函数进行调用。
函数式编程
函数式编程是一种强大的编程思想和编程技艺,可以很好地将共性和差异抽离,让整个流程更加顺畅。
可阅:“函数式+泛型编程:编写简洁可复用的代码”
质量属性
性能
常用的提升流程性能的手段如下:
- 快速过滤或去重。
- 正确的索引(必要索引,联合索引,减少不必要的索引及索引字段)。
- 内存数据结构和算法降重优化。
- 加缓存。
- 并发(串行变并发,线程池)。
- 异步(同步变异步,将次要部分从主体流程中分离)。
- 批量处理(单个变批量)。
- 确保表设计合理(大小维度的表分开)。
- 合并重复流程(避免重复文件上传或下载、重复创建同一个对象)。
- 合并重复调用(重复查询同一条记录)。
- 加检测结果库(黑白名单),避免重复检测。
- 精简流程
- 使用 arthas trace 命令测量方法 RT,找到性能热点区域,专项优化。
稳定性
- 对接口添加限流。
- 使用消息中间件削峰填谷。
可扩展性
- 使用 Dispatcher-Handler 模式。多线程并发模式中,可以在 Dispatcher 中分发任务给不同的 Handler ,Dispatcher 在主线程中执行,不同的 Handler 在不同的线程中执行。 Channel 并发模式中,可以通过 channel 将任务分给不同的 channel ,不同的 Handler 消费不同的 channel 里的任务独立执行。
业务流程构建的四个注意事项
- 闭环:如果流程有任务状态,则必须让任务状态最终落在完成或失败,避免滞留在中间状态。
- 并发:多个线程访问共享资源,要保证数据正确性,必要的地方要加同步。
- 异步:如果流程比较耗时,则需要拆分成异步,保证响应的体验。
- 多节点: 考虑多节点情形的处理,必要时候需要分布式锁。
闭环问题
当流程中的某个操作失败后,就会导致整体流程无法完成,停滞在某个点上,无法闭环。
闭环的解决思路:
- 直接失败。对于不能重试(可能导致资损)的情形,直接失败是最简单有效的办法。
- 重试、设置最大失败次数。对于可重试场景,可间隔一段时间重试一次,达到最大失败次数则设置为终态失败。
- 保存点。设置保存点,失败之后可以从保存点重续执行,最终达到终态成功。
- 回滚。作为事务,失败时回滚到最初一致状态。
- 超时失败定时任务机制。如果超过指定时间,则更新任务状态为失败,原因是超时。超时需要与更新时间做比较。
并发
- 如果有大量数据要获取或者处理,通常需要采用并发来提升性能。
- 并发线程获取要设置超时时间,避免网络不佳时长时间阻塞。
- 如果有多个线程或者多台机器同时访问同一条记录,则需要加锁(行锁或分布式锁)。
异步
- 不同端的通信,通常采用异步的方式。比如前端是通过 ajax 来异步拉取服务端数据。服务端要发送指令给客户端,也是先下发指令,等客户端完成后,返回给服务端回调消息进行后置处理。
- 耗时操作。耗时操作如果采用同步,则很容易超时导致失败。一般采用异步来分离。
- 高并发操作。高并发情况下,为保证核心流程的TPS,可以将非核心流程读写用异步分离出去。
- 次要操作。一个长流程中,有一些次要操作,不影响最终结果,可以通过异步分离出去单独处理。
- 异步要注意多操作间的时间差问题。两个操作是异步的,在一个操作完成后在页面查看另一个操作的结果时可能会不一致。比如上报一条告警同时异步上传一个文件。当告警入库展示在页面时,文件上传可能未完成,这时候文件是无法下载的。还有一种情形是两个线程同时更新同一条记录的状态,一个线程可能会覆写另一个线程写入的状态。这个写入顺序的不确定会导致最终记录状态的不确定。
多节点
- 避免读写本地缓存。因为缓存只在单个 JVM 里,其它机器上读不到该缓存,就会导致问题。
- 只读本地缓存。本地缓存通常从公共数据库中获取和建立。
- 采用外部存储来同步。比如使用 redis 来存储会话、做分布式锁。
- 采用中间件来通信和解耦。
流程不是一成不变的
随着需求变更,流程会反复更改。因此不要局限于流程的现有实现。当遇到不合理之处时,大胆质疑和改造优化,是为上策。
小结
流程千变万化。掌握基本的流程模式和复杂流程的构建方法,熟悉一些注意事项,辅以并发、异步、同步、消息中间件通信,没有什么流程是搞不定的。