Think Deeper, Design Better.
多年开发之后,程序员可能会逐渐失去当初编程的新鲜感,又不知道如何进一步提升自己。其实,设计方案,作为思考的输出,是一个非常重要的环节。多年之后,你可能忘了 SpringMVC 具体流程是怎样的,忘了 Dubbo 十层架构是怎样的,但技术方案一旦积累下来,会成为伴随程序员一生的财富。此外,AI 来袭,编程变得越来越“不值钱”了,未来的程序员更需要具有一种高层软件设计的能力。在“一个靠谱的技术方案文档是怎样的”一文中,定义了一份好的技术文档是什么样子的。那么,如何达到这个目标呢?
在编程实现中,实际上融合了设计方案的思考结果。无论需求大小,在设计上考虑充分一些,实现质量上就能更容易理解和维护。 那么,在设计方案时需要考虑哪些因素呢? 如何做出一个比较适合的设计方案 ?
通常有如下步骤: 明确痛点 -> 有序思考 -> 确定技术重难点并给出解决方案 -> 根据质量要求寻找合适的设计准则 -> 寻找现有合适的方案 -> 设计沟通 -> 对现有方案进行组合或创新,制定可选方案 -> 权衡取舍,得到最终方案。
好的设计方案
好的设计方案是怎样的?
- 实现功能,满足需求,良好的容错处理
- 表达清晰,有丰富的细节,容易理解和完善
- 尽力保持简单
- 易用性,可行性
- 性能可接受,对系统稳定性的影响很小
- 一致性
- 用合适的工具做合适的事情
- 适度考虑可复用、可扩展
- 尽量不引入额外组件
- 必要的话,考虑应对大数据对象、大流量的稳定性
设计方法
明确痛点
每一个需求/优化/重构,总能追溯到某个痛点。痛点主要有如下:
- 功能诉求: 竞争对手有拼团功能,赚了好多钱好多粉丝,我也要有!【新功能】
- 稳定性优化: 时不时出现xx报错,真是令人烦躁!同时一波大流量来袭,系统波动有点大啊!【稳定性】
- 性能提升:怎么这么慢啊 ! 这么多订单,得处理到什么时候?【响应速度与吞吐量】
- 维护成本: 这方案得占双倍的存储资源,还有两个同步,理解起来多费劲!这个报错,没法看出问题在哪里,还得再打个日志看看。商家在等着修复问题,真急人!【资源/时间】
- 弹性: 明年订单量要增加3倍,现在这个方案貌似扛不住啊!【容量扩展】
- 数据: 这待发货订单数显示为2,怎么点进去没订单?【数据不一致性】
- 体验: 要做完一个批量操作,要好多步骤,还容易出错,真耗费时间啊!【步骤繁琐,易错】
- 及时: 更新一个内容,要马上生效,而不需要重新修改代码部署系统。【即时更新】
- 安全: 啊啊,不小心把DB/重要文件目录数据删除了!【安全性提醒】
- 扩展:实现一个需求,要改这么多代码?【改动大,易出错】
- 重构: 这么多新的业务需求,真没法改了!非得动大手术了!【无法承接新需求】
痛点是否足够痛? 避免为了解决/优化问题而解决/优化问题,避免为了尝试新技术而引入新技术。一定是为了解决痛点。因为事情是做不完的,顾此则失彼,要对做的事情进行仔细规划。
明确痛点,才能真正对症下药,药到病除。
有序思考
拿到一个需求/优化/重构,如何有序地思考设计方案呢 ?
步骤一: 弄清楚问题/需求的背景及来龙去脉。
步骤二: 明确功能或服务目标,确定硬性质量要求(通常是性能),或软性质量要求(通常是健壮性、可扩展性、可维护性);
步骤三:确定重点关注者,数据的存储和分布;
步骤四:梳理现状,确定要达到的质量目标;
步骤五:对现有方案进行组合和创新,确定可选方案(核心是存储与算法);
步骤六:考虑部署及升级问题
步骤七: 考虑必要的性能、稳定性、高可用、可扩展等质量目标。
步骤八:设计沟通,寻求更有经验的帮助和评审;
步骤九: 结合现有资源限制、质量目标和设计准则,权衡取舍,确定最终方案。
技术重难点
在实现技术方案中,技术重难点是最重要的一环。做技术方案时,先明确此次项目或系统的技术重难点和对应解决方案。不明确,不解决,不要急于动工。
- 结构扩展变更,改动较大、影响范围较大、涉及大量数据迁移,容易产生故障。
- 累积大数据量(性能、存储成本)。
- 瞬时大流量(稳定性)。
- 冲突的要求(权衡取舍)。
- 既要又要还要(优先级)。
- 涉及范围大(细致梳理)。
- 可扩展性和可伸缩性。
- 高可用与一致性。
设计准则及案例
自然清晰
- 有清晰、直观、容易理解的心智模型/领域模型;
- 没有拐弯抹角的地方。
- 域的划分清晰。
- 分层清晰。
- 语义一致。
- 所见即所得与形式的一致性。
案例:
- 订单同步,使用 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字段未输出,可以借此梳理下还有哪些字段需要输出。因为每改一次的测试和发布成本较大。
整合能力
- 发现多个需求点的关联,综合考虑和合并优化,避免来一个解决一个,导致解决方案比较松散。
技术的积累
常用技术手段积累
积累常用技术手段,即是积累技术方案工具箱,为设计方案打下良好基础。
-
数据结构与算法设计:使用编程解决问题的基本内功。【基础】
-
并发:进程、线程、协程;线程池、协程池。【性能】
-
批量:设计适合批量处理的结构、批量算法设计、批量插入或更新数据库。【性能】
-
异步:异步更新、异步通知机制。【及时性、解耦主流程与次要流程】
-
轮询:轮询直到期望状态发生;等待一个时间点不确定的条件发生。
-
回调:指定事件发生时的逻辑处理。【事件处理的及时性】
-
切面:不同操作的公共逻辑。【可复用】
-
模板:相似数据内容的生成;相似操作的执行。【可复用】
-
模式:数据、操作、对象交互的常用模式。【交互与可扩展】
-
懒加载: 直到需要的时候才加载和执行。【性能与资源节省】
-
重试:重复执行一个操作。【可靠性】
-
重续:从操作的上一次执行的保存点开始重续往后执行。【性能】
-
索引:大量数据的高效查找结构。数据的浓缩精华。【性能】
-
缓存:提升热点数据获取性能。本地缓存:只读缓存,有数据源支持,从数据源读取;高可用缓存:多节点共享读写数据。【性能】
-
事务:保证多个操作的原子性。【原子性、隔离性、一致性、持久化】
-
分表:同一业务的数据分拆到多张表。【性能、可扩展、解耦】
-
分库: 同一业务或不同业务的数据分拆到多个数据库实例。【性能、可扩展、解耦】
-
分区:数据副本与冗余,提升可用性;高可用数据分区,避免数据倾斜【容错、高可用、可扩展】
-
分片:数据可扩展性,支持数据容量的增长。【数据容量与可扩展性】
-
冗余:通过基础设施和数据的冗余,保证容错下的高可用性。【容错与可用性】
-
限流:在瞬时高请求量的情形下保证请求量在可接受的范围内,保证服务稳定运行。【稳定性】
-
熔断:主流程发生错误时切换到备份流程,返回备份数据。Plan B 思想。【可用性】
-
幂等:保证多次操作与一次操作的等价。【尤其是资产类业务需注意】
-
负载均衡:使用多节点来保证请求的均衡处理。【稳定性和可伸缩性】
-
锁、阻塞等待与通知:无法获取共享资源,等待直到资源释放。【并发安全】
-
IO 多路复用: 处理大量 IO 请求,尽可能避免阻塞。【多IO请求的性能与可扩展】
-
对象池:创建开销大的对象复用。比如连接池。【对象复用】
-
延迟队列:累积一段时间的数据量延迟处理。【延迟处理、性能与稳定性】
-
优先级队列:优先处理优先级高的数据。【及时性】
-
滑动窗口:有序处理数据。【有序】
-
正则表达式:识别和处理文本。
-
全局ID生成:雪花算法。【唯一性、性能】
-
文档说明: 当设计变更涉及较大变动时,建立文档说明改动点及缘由。
-
API :继承不可超过两层,避免嵌套;避免将不相关的东西混杂在 API 参数中;避免将底层实现细节暴漏在API 参数与传参中。
-
分层: 提炼出一系列关注点,分离到不同的语义层次,分离到多个类的单一职责中。
-
组件化: 将工程里的代码与功能实现抽象为组件接口与实现。
-
策略模式: 使用策略模式分离同一个接口的不同实现,并根据场景选择适宜的实现。
-
插件流程: 如果流程是可变的,那么将单个流程节点变成可配置的插件,并进行编排。
-
启动检查: 当应用启动时,加载所有必要组件,任一不满足时及时报错退出,避免错上加错。
-
使用切面: 当多个功能要复用同一个前置或后置逻辑时,使用切面来实现这些前置或后置逻辑。
-
受控线程池: 切忌在应用里动态创建单个线程或线程池;使用全局受控的线程池来执行任务。
-
重载函数: 使用重载函数建立适合的工具类。
-
无状态: 除非必要,不要在实例间共享状态;不要让请求的处理结果依赖于某个状态。
-
快速失败: 当前置要件不满足时,快速失败胜于设计失当的智能容错处理。
-
事务: 多个关联操作的原子性和一致性保障。
-
幂等: 处理多个完全相同的请求时,与处理一次的效果相同。
-
范式: 在关系型数据设计中,要遵循基本的规范范式。
-
日志: 在开发时,打印合适级别的必要的日志(关键路径和关键状态),方便快速排查错误。
-
来源监控: 如果有多个来源或类型,建立监控了解每个来源或类型的业务量及占比。
常见技术问题积累
积累常见技术问题的解决方案,也是非常有必要的。积累越多,遇到问题就越容易解决,通常不需要重新思量,提升效率。
- 分页排序
- 同步转异步
- 并发批量处理
- 同一目标的多策略处理
- 配置化表达
- 一份数据多份消费
- 时区问题
- 系统通信交互
- 多节点业务处理的一致性
- 大数据量查询和统计
- 瞬时大流量的接口处理
- 消息堆积处理和应急方案
设计沟通与取舍
设计沟通
设计沟通也是非常重要的。一个人难免因为经验不足,想不到某些关键点,需要别人提醒。 尤其是业务越来越复杂,业务关联错综复杂的情况下,个人因为主要负责部分模块的开发,缺乏对系统整体的理解,常常就会欠缺考虑。此外,别人可能有更好的方案可以参考借鉴。一切为我所用。不要太介意主意是你的还是他的还是我的。
如何能够让别人更好地参与进来,帮助一起完善设计方案呢(同时也可以帮助感兴趣的小伙伴增长知识和经验面) ?通常,问题的发起者应该做到如下三点:
- 建立文档说明场景、痛点及来龙去脉;
- 多种可选方案,各自的优点与弊端,利弊权衡。
- 提出自己的疑问。
这样,别人才能更好地提出好的建议。
权衡取舍
- 没有完美的方案,只有合适的权衡取舍。
- 针对不同的场景,衡量收益和代价。
- 要综合思考,避免线性思考;避免为了解决一个次要的问题引入更大的问题。
优先级:稳定性 > 清晰性 > 灵活性。
通常可以认为:
- 功能需求实现、容错处理,是最基本的要求。
- 其次是性能和稳定性的考虑。
- 为扩展留下实现空间,但可以暂时不实现。
- 如果能够达到建立新功能、避免故障、弹性扩展、大幅降低维护成本、可复用, 其收益将是非常高的,此时,增加少许依赖、复杂度,其实是可以接受的。
- 一劳常逸/举一反三,相比只是解决当前问题,更有价值;多往前走一步。
- 自然清晰,是非常不容易达到的;但很值得为之一步步接近。
设计方案要素
- 背景、问题、需求
- 遍历已有方案
- 主要技术难题及解决方案(调研)
- 系统组件交互(消息、API)
- 技术组件选型(中间件与应用框架)
- 数据通信格式(消息字段及类型、消息结构)
- 模块设计
- 存储设计(存储组件与表设计)
- 流程设计(流程闭环,可靠性;管道过滤器、插件、批量并发)
- API 接口(易用性、灵活性、安全)
- 系统初始化与变更处理
- 容错考量
- 性能、稳定性(大数据量,大流量)
- 可用性、水平扩容(多节点)
- 部署升级考虑
- 技术微决策
- 方案的优点与缺点、取舍权衡
- 安全考量
- 复用性、扩展性考量
思考力
程序员最核心的竞争力是什么?高质量的思考力。
工作与生活,实质就是如何思考这个世界并与之相处。对世界、对人性、对组织、对制度、对管理、对沟通、对各方面的理解,以及你的思考模式和行为模式、决定了你会如何应对这个世界。
常常深思察己,建立元情绪监控能力,你将有更强大的自控能力。
没有完美的方案。所有方案都有利弊,在于适用场景以及权衡取舍。