没有完美的方案。所有方案都有利弊,在于适用场景以及权衡取舍。Think Deeper, Design Better.
代码即设计。
在编程实现中,实际上融合了设计的思考结果。无论需求大小,在设计上考虑充分一些,实现质量上就能更容易理解和维护。 那么,在设计方案时需要考虑哪些因素呢? 如何做出一个比较适合的设计方案 ?
通常有如下步骤: 明确痛点 -> 有序思考 -> 根据质量要求寻找合适的设计准则 -> 制定可选方案 -> 设计沟通 -> 寻找合适的细则来提升实现质量。
明确痛点
每一个需求/优化/重构,总能追溯到某个痛点。痛点主要有如下:
功能诉求: 竞争对手有拼团功能,赚了好多钱好多粉丝,我也要有!【新功能】
稳定性优化: 时不时出现xx报错,真是令人烦躁!同时一波大流量来袭,系统波动有点大啊!【稳定性】
性能提升:怎么这么慢啊 ! 这么多订单,得处理到什么时候?【响应速度与吞吐量】
维护成本: 这方案得占双倍的存储资源,还有两个同步,理解起来多费劲!这个报错,没法看出问题在哪里,还得再打个日志看看。商家在等着修复问题,真急人!【资源/时间】
弹性: 明年订单量要增加3倍,现在这个方案貌似扛不住啊!【容量扩展】
数据: 这待发货订单数显示为2,怎么点进去没订单?【对比分析有困惑】
体验: 要做完一个批量操作,要好多步骤,还容易出错,真耗费时间啊!【步骤繁琐,易错】
及时: 更新一个内容,要马上生效,而不需要重新修改代码部署系统。【即时更新】
安全: 啊啊,不小心把DB/重要文件目录数据删除了!【安全性提醒】
扩展:实现一个需求,要改这么多代码?
重构: 这么多新的业务需求,真没法改了!非得动大手术了!
痛点是否足够痛? 避免为了解决/优化问题而解决/优化问题,避免为了尝试新技术而引入新技术。一定是为了解决痛点。因为事情是做不完的,顾此则失彼,要对做的事情进行仔细规划。
明确痛点,才能真正对症下药,药到病除。
有序思考
拿到一个需求/优化/重构,如何有序地思考设计方案呢 ?
STEP0: 弄清楚问题的背景及来龙去脉。
STEP1: 明确功能或服务目标,确定硬性质量要求(通常是性能),或软性质量要求(通常是健壮性、可扩展性、可维护性);
STEP2: 确定重点关注者,数据的存储和分布;
STEP3: 思考最终方案形态应该是怎样,现状是怎样,能够做怎样的权衡取舍;
STEP4: 根据设计准则,思考处理对策。
STEP5: 设计沟通,寻求更有经验的帮助。
STEP6:确定设计方案,分离关注点,具体设计和实现。
设计准则
自然清晰
有清晰、直观、易懂的心智模型/领域模型;
没有拐弯抹角的地方。
域的划分清晰。
分层清晰。
语义一致。
所见即所得与形式的一致性。
案例:
订单同步,使用 Input-Filters-Output 过滤器-管道模式,辅以基础组件的配置和组合, 清晰地表达了各种具体任务的实现流程。
订单详情,使用 Providers-Plugins 两层结构,清晰地表达了如何从数据存储获取源数据并通过插件格式化成最终数据的过程。
订单搜索,ES 查询提供了索引与 JSON 查询语句的简洁抽象。 每个搜索字段相互独立,且联合搜索 DSL 直观易懂。
订单导出,使用多个详情收集器的顺序组合来获取所需要的订单详情,每个收集器相互独立变更;使用策略模式分离标准报表和自定义报表。
反例:
API 入参中的继承导致 API 使用很迷晕,容易使用错误; 不要为了追求一点点复用效果在 API 使用继承。
要导出零售总店的所有订单,需要传 head_shop_id, 并将 shop_id 置为空。Workaround 方案。
将大量逻辑放在一个类里。代码分层不清晰。
获取核销人信息,需要核销人所在的店铺ID,但交易只能拿到订单所在的店铺ID,这两者的语义在连锁形态下不一定一致,导致有时拿不到核销人信息。
容错处理
减少了错误发生的可能性。
错误发生时,更安全友好地处理。
不会因为次要局部影响整体。
减少了故障可能性,或可能故障级别。
故障发生时,能够更快更安全地处理和恢复正常。
案例:
使用切面实现业务异常的捕获和转译封装。【健壮性】
对每个订单的处理进行异常捕获打日志,且对每个订单的每个字段的处理进行异常捕获打日志。避免某个订单的某个字段错误影响该订单的其他字段的导出,或者避免某个订单的数据错误,影响了其他订单的导出。【隔离】
捕获 API 或 IO 访问异常,并进行转译处理。 避免因局部影响整体,或提供更友好的上层提示。【容错】
重试机制。适合于“在极端情况下暂时不可用,而在正常情况下自动恢复”的防御机制。【容错】
针对可能导致故障的点,进行重点防护。【防御】
IO 访问设置超时。【隔离】
反例:
对于 API 返回结果不做任何校验而直接使用,导致 NPE 。
由于单个接口调用失败导致整体信息加载失败。
性能与稳定
快速的响应时间和吞吐量;
大幅减少任务运行时长。
系统在大流量情形下的稳定运行。
对外部依赖进行降级或熔断。
案例:
采用多个线程,批量、并发地拉取订单详情。
备份的过程,使用异步来完成,提升响应速度。
减少不必要的IO访问;仅在真正需要的时候去访问 IO 或 API 。
限流处理。 在连续多个大流量导出的情形下,进行限流,只允许指定数目的大流量导出。
熔断降级。 HBase 主集群访问失败时,自动切换到备集群访问。
扩展能力
底层模型统一。
核心简洁而稳定,外围可扩展。
适当地分离关注点,组合和组织关注点。
组件化、配置化,通过增减插件来支持需求。
案例:
不是按照前端页面所需功能,而是按照所需要提供的能力模型,来设计订单搜索服务。底层具有强大而通用的能力,上层进行适配,提供受限的能力。
梳理整个流程,将整体流程中可变的子流程进行抽象,允许配置不同的子流程实现,来实现多变的具体流程。
将导出构建成“查询-详情-过滤-排序-格式化-生成报表”的插件化流程。只需要新增或编排插件,就能实现各种形态的导出。
弹性扩展
当业务量增长时,可以自然应对而无需额外改动。
可以即时加机器,解决临时高并发吞吐量问题。
可以即时减机器,去掉不必要的资源空闲。
案例:
为热状态订单搜索建立热索引。无论订单总量及增长量如何,热状态订单量始终维持在涨幅不大的程度。
应用对等,无状态设计,可以随时增减应用服务器而无影响。
维护成本
减少了存储资源占用。
减少了多处同步。
能更快速地定位问题,大幅减少了排查和解决问题的时间(秒/分钟/小时/天)。
分离出了变化的部分,更容易识别变化和扩展。
案例:
去掉了对老订单同步的依赖,订单导出的整体理解和维护更加简单。
更明显的错误原因指明和建议措施,利于快速定位问题和解决。
可复用
以小见大,从一个需求点看到一类需求。
建立可重复使用的方法、机制和流程,更容易地解决相似问题。
案例:
建立一个可复用的 HBase 详情获取插件,来解决导出商品编码的问题;同时又能为其他字段导出需求所使用。
使用模板方法模式,将导出的“入参校验-保存导出任务记录-上传导出结果-更新导出任务记录”基本流程实现为可复用的模板流程。具体导出只要关心如何生成报表即可。
配置化
- 解决一个需求时,建立相应的配置,当后续可能发生细节变更时,只需要修改配置即可即时生效。
案例:
- 根据支付方式搜索订单,新增一个前端入参与后端搜索值的映射配置; 当新增支付方式时,只需要更改配置,就能支持新增支付方式的搜索,无需改动代码和发布系统。
一劳常逸
- 建立良好的约定,解决一次,出问题只追溯源头。
案例:
- 零售订单的导购员姓名取下单表的扩展字段XXX 。建立这个约定后,推进和完善各个场景下这个字段的落库。
依赖弱化
- 减少了不必要的依赖(API,apollo,NSQ, KV 等),或者至少不引入新的依赖。
案例:
订单详情接口去除对某个外部接口的依赖。
订单导出任务完成后,直接更新DB里的任务记录,不再依赖消息中间件。
反例:
- 订单详情(高频应用)依赖外部某接口,外部接口挂了,导致详情大量报错,进而影响列表大量报错。【雪崩效应】
最小复杂
总是首先寻找简单、改动最小、比较彻底的方案。
复杂度衡量: 少量顺序代码 < 一些条件分支代码 < 增加少量apollo配置 < 增加DB < 增加DB和缓存 < 增加一个模块。
举一反三
- 发现一处,解决多处类似的问题,而不是发现一个解决一个。
案例:
- 订单详情接口的商品图片URL字段未输出,可以借此梳理下还有哪些字段需要输出。因为每改一次的发布成本很大。
整合能力
- 发现多个需求点的关联,综合考虑和合并优化,避免来一个解决一个,导致解决方案比较松散。
权衡取舍
没有完美的方案,只有合适的权衡取舍。
针对不同的场景,衡量收益和代价。
要综合思考,避免线性思考;避免为了解决一个次要的问题引入更大的问题。
优先级:稳定性 > 清晰性 > 灵活性。
通常可以认为:
如果能够达到建立新功能、避免故障、弹性扩展、大幅降低维护成本、可复用, 其收益将是非常高的,此时,增加少许依赖、复杂度,其实是可以接受的。
为扩展留下实现空间,但可以暂时不实现。
一劳常逸/举一反三,相比只是解决当前问题,更有价值;多往前走一步。
自然清晰,是非常不容易达到的;但很值得为之一步步接近。
设计细则
设计细则是在准则的基础上,针对具体事项、场景而建议采用的方案。
文档说明: 当设计变更涉及较大变动时,建立文档说明改动点及缘由。
API :继承不可超过两层,避免嵌套;避免将不相关的东西混杂在 API 参数中;避免将底层实现细节暴漏在API 参数与传参中。
分层: 提炼出一系列关注点,分离到不同的语义层次,分离到多个类的单一职责中。
组件化: 将工程里的代码与功能实现抽象为组件接口与实现。
策略模式: 使用策略模式分离同一个接口的不同实现,并根据场景选择适宜的实现。
插件流程: 如果流程是可变的,那么将单个流程节点变成可配置的插件,并进行编排。
启动检查: 当应用启动时,加载所有必要组件,任一不满足时及时报错退出,避免错上加错。
使用切面: 当多个功能要复用同一个前置或后置逻辑时,使用切面来实现这些前置或后置逻辑。
受控线程池: 切忌在应用里动态创建单个线程或线程池;使用全局受控的线程池来执行任务。
重载函数: 使用重载函数建立适合的工具类。
无状态: 除非必要,不要在实例间共享状态;不要让请求的处理结果依赖于某个状态。
快速失败: 当前置要件不满足时,快速失败胜于自以为的智能容错处理。
事务: 多个关联操作的原子性和一致性保障。
幂等: 处理多个完全相同的请求时,与处理一次的效果相同。
范式: 在关系型数据设计中,要遵循基本的规范范式。
日志: 在开发时,打印合适级别的必要的日志(关键路径和关键状态),方便快速排查错误。
来源监控: 如果有多个来源或类型,建立监控了解每个来源或类型的业务量及占比。
设计沟通
设计沟通也是非常重要的。一个人难免因为经验不足,没有想到某些关键点,需要别人提醒。 如何能够让别人更好地参与进来,帮助一起完善设计方案呢(同时也可以帮助感兴趣的小伙伴增长知识和经验面) ?通常,问题的发起者应该做到如下三点:
建立文档说明场景、痛点及来龙去脉;
多种可选方案,各自的优点与弊端,利弊权衡。
提出自己的疑问。
这样,别人才能更好地提出好的建议。
【未完待续】