个人案例
王府井网上商城
单日卖出2100万抵用券,成都王府井和有赞背后做了什么?
单日卖出2100万抵用券,成都王府井和有赞背后做了什么?扫码体验
周黑鸭官方商城
周黑鸭强势霸屏微信“附近的小程序”
为何附近的小程序被周黑鸭霸屏?原来是有赞...扫码体验
F&M商店
虎Cares,职场人的欲望清单
虎嗅搞小程序做电商,聚焦办公场景卖“职场丧T”!扫码体验
- 小程序半屏环境中,可以再半屏打开另外一个小程序吗?
小程序A 半屏打开小程序B,小程序B处于半屏环境下。 此时,想在B中继续以半屏形式打开C。 这种场景支持吗?
2024-09-03 - 数据降本利器:无用数据下线自动化
背景 当前,成本观念已经深入人心,有很多小伙伴主动参与到日常降本的工作当中,节省了大量成本。 不过成本治理不是一锤子买卖,新阶段,我们面临着新问题, 那就是:成本治理的ROI低下。什么意思呢?展开说说。 在降本的初期,治理方很自然地会先“抓典型”。 挑成本大户重点缩减、优化,可以立竿见影,快速见效。集中处理,可能在一天半天内,省下上万成本,ROI极高。此时,业务方的积极性也很高,不用多说,自觉主动。 但到了中后期,“巨头”们都被消灭了,留下的都是零零散散的小数据、小任务。如果人工去判断和处理,需要投入大量的精力,ROI很低。可谓食之无味,弃之可惜。 虽然每个任务占用资源都不多,但是数量庞大,整体的成本不低。如何高效地优化,便是我们要解决的问题。 其他公司或许面临同样的问题,希望本文内容对大家有所帮助。 现状 基于上面的背景,我们意识到:不计成本的成本治理,是在耍流氓,自动化下线,势在必行。当然,在开展这项工作之初,我们还是很严谨地分析了现状、问题,并且评估了预期的收益。当前我们已经将每个数据、任务的成本量化,并且做了初级的使用情况分析,提供了下线建议。比如:无下游任务、过去n天未被使用、任务长期失败等。 但是真实的下线动作,还是需要每个owner去触发。 [图片] 这里边有两个不便利的点: 下线建议的参考数据准确率低。需要业务方根据经验去判断和确认,比较费时并且风险高;无法批量操作。成百上千的琐碎数据,我们自己也不好意思推动业务关注。 对下线的判断是否准确,依赖几个关键点: 数据owner归属准确性(谁负责),有人负责,才能放心对其操作,出现异常及时补救;数据血缘完备性(数据由谁产出,又被哪个任务使用),目前我们的研发平台支持SQL,数据导入导出、脚本等类型的任务,每个任务使用或者产出了什么数据,都需要被解析;数据行为日志的完整性(表什么时候被谁使用了),所有的数据查询,需要统一收口,并保留审计日志,便于分析使用情况。 解决了准确性问题(比如99%准确率),再结合一些防御策略(比如数据备份等),就可以考虑做自动下线(要支持快速定位问题&恢复),提升降本的效率。 那么可自动下线数据(后边我们称之为:降本池子)成本有多少呢? 我们统计过,根据当前的规则,大概有5000张表和任务可被下线,成本约70万/年。降本池子的余额增速约5%,年增速约为70%。综合看,自动化降本的收益约为120万/年。 如果按平均1张表需要1小时的投入算,需要2人年的人力投入,ROI确实不高。但是如果实现自动化,剔除一次性的投入之外,收益可观。 方案设计 自动化下线的潜力很大,心动不如行动。 整体的方案如下图所示: [图片] 为方便说明,先简单介绍下图里涉及的系统: 数据研发平台(下文简称DP),一站式大数据管理与应用开发平台数据资产平台(下文简称Meta),数据资产管理、治理平台BI系统,有赞自研的可视化数据分析系统RDS,有赞自研的数据库服务平台 图中橘黄色为自动化下线系统的后端和前端。 其中后端负责实际的下线、运维动作,定时执行。主要能力是: 读取RDS里待下线资产信息(该信息在离线加工后,通过DataX导入RDS),根据规则做通知、下线等操作,并记录过程和结果;执行下线逻辑,需要和Hive、DP对接,以实现数据的删除、恢复,还有任务的暂停和重启;下线逻辑必要的配置,在Apollo里;通过飞书发送下线预告、结果信息;BI系统,用于分析待下线数据、下线进展、状态等;提供用户交互操作接口。 为了实现自动化下线,我们需要知道每个数据的流转过程和使用情况,然后制定合理的规则挖掘待下线数据,再配合一定的流程去做实际的下线动作。这个过程,可以抽象出三个重要环节:数据准备、下线挖掘、自动下线,下面我们分别介绍。 系统实现 数据准备在数据准备方面,我们会去采集到数据、任务的状态、血缘关系、使用情况等信息。并保证较高的的准确率,才能安全地做自动下线。 主要工作: 从各个系统(Hive、Hbase、DP、BI等)采集完备的元数据信息,并做好准确的owner归属;根据SQL、任务配置、实体关系等,解析血缘关系,确保较高的血缘覆盖率;采集审计日志,解析每个行为用到的数据(所有离线数据都统一收口了),并保证准确率;采集加工DP任务依赖、BI看板使用情况。[图片] 最终在Hive里,有DP任务依赖、数据和任务血缘、元数据信息、数据使用记录、看板使用情况这几份数据,供下一步加工分析。 下线挖掘哪些数据可以下线呢?这就是下线挖掘要做的事情。根据依赖、血缘、使用记录等数据,分析出无用的数据和任务。 根据我们的经验,归纳出以下几种情况: 长期执行失败任务:这类任务不产出准确数据,一直在浪费计算资源,可以被暂停。根据任务的调度频率,判定标准有所差异:季级任务从6个月前的1号开始调度天数全部失败,且调度次数大于等于2次月级任务从3个月前的1号开始调度天数全部失败,且调度次数大于等于3次周级任务从6周前的周一开始调度天数全部失败,且调度次数大于等于6次天级任务从15天前开始调度天数全部失败,且调度次数大于等于15次小时级任务从3天前开始调度天数全部失败,且调度次数大于等于72次无任务无下游表:这类数据既找不到对应的产出任务,也没有被其他任务使用。有任务无下游表:这类数据有对应的产出任务,也在定期更新,但是没有下游使用。有下游,但是下游长期无访问 :比如某个数据的下游是BI看板,虽然被引用了,但是看板长期无访问,也可以理解为该数据无用。 下线挖掘的过程可以抽象为:候选池-过滤池=预下线池>下线池。如下图所示: [图片] 首先根据以上几种类型,计算出满足下线基本条件的“候选池”。满足某些条件的数据,不应该被下线,进入“过滤池”。除了无产出表长期执行失败任务外,其他类型都需要通过过滤池筛选一下。过滤池的数据有以下情况:表作为其他任务的依赖表对应的任务作为其他任务的依赖近90天有临时使用。说明有在分析场景被使用。表对应的任务产出多张表。此时不应该有多张表的情况,决定任务是否可下线。创建时间小于30天。比如某任务近期才创建,可能项目开发中,失败是正常情况。候选池剔除过滤池,得到“预下线池”在“预下线池”一定时间后,进入“下线池” 以上过程,涉及到很多“阈值”,比如多久算长期、预下线池连续多久后进入下线池等,可以根据实际的业务情况制定。 为了保证整个链路的稳定性和准确性,我们对血缘、审计日志、下线池数据量等进行监控。任意一个指标出现异常,将中断整个下线链路。 现阶段主要的中断指标包含以下几个: 审计日志解析成功率低于99%某些情况的血缘缺失超过1%无配置依赖任务占比大于1%无产出表任务占比大于1%下线表数量每天减少量或增加量超过1000 自动下线对自动化下线,我们分析有以下需求: 下线预知用户可以查询资产状态(访问频率等),可以查看待下线列表下线感知哪些数据,因为什么原因,将在什么时候被下线提供链接去查看明细,做一些操作自动下线前N天,通知到用户自动下线当天,通知用户和管理员操作结果,并提供链接去查看明细支持已下线列表查询下线决策支持白名单,以防止特殊场景的数据或者任务被清理支持下线回滚操作(限定时间内)运维能力下线异常,人工干预下线进展、状态监控支持人工下线、回滚操作 结合以上需求,设计下线流程: [图片] 获取待下线表,判断是否在白名单里。如果在的话,直接记录下线执行结果。否则进行下一步判断;判断owner是否有效(离职情况,这种要离线表处理好,或者说治理好)。如果无效,直接记录下线执行结果,否则进行下一步判断;判断该表的状态,决定是否提前通知即将下线(5、3、1天)。如果还没达到通知次数,继续通知,然后更新下线执行结果如果达到了,执行下线操作。下线成功的话,通知其owner;下线失败的话,通知管理员存储下线执行结果 对于预告和执行结果的通知,我们会根据owner合并,避免过多的消息干扰用户判断。 除此之外,为了尽量降低用户判断成本和运维精力,每日自动下线的数量也做了一定控制。 配套下线流程,也有回滚流程,逻辑比较简单,这里不展开介绍了。 防护措施由于各种原因,可能存在误下线的情况。为了避免影响业务,我们也做了一些防护措施: 设定下线缓冲期。下线表备份一定时间,过期后再清理。期间如果发现异常,支持快速回滚;数据准确性监控。对血缘覆盖率、SQL解析成功率等指标进行监控,发现异常阻断下线流程;支持自动化下线开关,并可以设置下线数据的owner范围,在该范围内充分试用后再逐步推广。 后期规划 采集更丰富的血缘和使用信息,扩大Hive表待下线池子。比如某个Hive表虽然被导出到ES(意味着有下游),但是该ES索引已经弃用,那么对应的Hive表和导出任务均可下线;探索自动化下线在实时计算领域的可行性,针对Kafka、ES、HBase等数据资产,提供有效的下线建议;自动化下线功能推行到其他环境中,现阶段我们主要在主战环境下进行,例如金融云环境有相同的诉求。
2023-02-13 - 基于 Fish Redux 的 Flutter 性能优化实践
一、前言 Flutter 以其高还原度,匹配原生的性能和高开发效率,已经成为主流的移动跨平台技术。在不断发展过程中,也衍生出了很多优秀的开发框架,帮助开发者提高开发效率和降低开发成本。Fish Redux 就是一款优秀的 Flutter 状态管理框架。 目前零售移动在很多业务中都用到 Flutter,也是基于主流的 Fish Redux + Flutter Boost 模式。新技术的落地总是会伴随着各种踩坑,其中比较深刻的,是 Flutter 界面卡顿的问题,最终通过深入分析 Fish Redux 状态管理机制解决了该问题,也总结了一些经验供大家参考。 二、优化实践 1、问题背景 商家反馈在收银机上使用进出存单据功能很卡,操作界面切换按钮点击反应都很慢。从商家反馈的视频和我们实际操作的视频中,明显可以感受到在界面过渡、数据加载、点击操作、列表滑动,弹框都存在肉眼可见的卡顿,特别是在一些配置不怎么好的收银设备上。 针对这些现象,我们将问题分为两大类: 数据加载等耗时操作卡顿UI渲染对问题进行分类之后,就开始使用 DevTool 中提供的 性能视图 对卡顿界面视图渲染情况进行了分析。针对库存盘点场景选取了严重卡顿的操作:添加商品、修改商品数据、动画展示、网络数据请求和加载。 > 界面布局 [图片] > 添加商品 StockCheckOrderEditMainState:顶层 State 从列表添加一个商品之后,可以看到整个界面都进行了重绘,绘制范围明显不合理。 [图片] > 修改商品数据 修改数据与添加商品类似,也是也是进行了全局刷新 [图片] > 网络数据请求和加载 在网络数据回来之后,发现 Dart_StringToUTF8 耗时长,深入排查之后发现,是 JSON 数据驼峰和下划线转换导致。 [图片] 经过初步排查之后,基本确定了问题是存在耗时操作和更新渲染范围过大导致。对于渲染范围问题,项目中基本都是按照官方推荐的方式进行了很多界面的组件拆分和复用,为什么没有达到局部渲染的效果呢?带着这个问题,对 Fish Redux 刷新机制进行了探究。 2、Fish Redux 简介 此部分做一些核心概念介绍,已经了解过的同学可以跳过。 Fish Redux 是一个以 Redux 作为数据管理的思想,以数据驱动视图,组装式的 Flutter 应用框架,里面有几个很重要的角色: State、Effect、Reducer 和 Action。 [图片] 图中的T代表某一个类型的 State,UI 交互产生了交互 action,effect 处理对应的交互 action 之后,又会产生数据更新 action,reducer 收到数据更新 action 之后完成 state 的更新,最终驱动了 UI 的更新,进入下一个循环。 组件(Component)是对视图展现和逻辑功能的封装,一个复杂的界面通常都是由一个个组件组合而成,大组件使用 Dependencies 完成所依赖的小组件、适配器的注册。 Component = View + Effect(可选) + Reducer(可选) + Dependencies(可选) 只有实现了 Reducer 的组件才能拥有自刷新的能力,否则都是跟随父组件更新而更新 [图片] Page 是一个页面级的 Component,类似于 Android 中的 Activity,redux 中的 store 就是存储在 Page 组件中,Page 中的所有 Component 都共用这个 store。store 负责 reducer 事件分发。Page 中还有一个 DispatchBus 类型的 bus 属性,负责 Effect 事件分发。 3、Fish Redux 刷新机制 > 视图创建 在了解界面刷新流程之前,需要先了解一下整个界面的构建流程。构建过程主要任务是构建视图+事件注册。 [图片] /// component.dart abstract class Component extends Logic implements AbstractComponent { @override Widget buildComponent( Store store, Get getter, { required DispatchBus bus, required Enhancer enhancer, }) {...} } Component 实现了 AbstractComponent 接口,实现了 buildComponent 方法。框架从触发顶层组件的。 buildComponent 开始整个视图的绘制流程,容器组件将创建自己的ComponentWidget 以及触发子组件 ComponentWidget 的创建,就这样完成整个视图的创建。ComponentWidget 中完成 ComponentState 的创建,在 ComponentState 的 initState 中,会调用 store 的的 subscribe 方法将自己的 onNotify 方法注册到 store 的 listener 中,这样就完成了监听reducer事件监听。 /// component.dart class ComponentState extends State> { void initState() { /// ... /// 注册监听 _ctx.registerOnDisposed(widget.store.subscribe(() => _ctx.onNotify())); } } Effect 的注册是在 Component 的 createContext 方法创建 ComponentContext 时,在ComponentContext 的父类 LogicContext 构造方法中,调用bus.registerReceiver(_effectDispatch) 完成的。 /// logic.dart abstract class LogicContext extends ContextSys with _ExtraMixin { LogicContext({...}){ /// ... /// Register inter-component broadcast registerOnDisposed(bus.registerReceiver(_effectDispatch)); } } 这样,就完成了 Effect 与 Reducer 的事件监听。 > 事件分发与处理 Effect 与 Reducer 的事件处理流程存在重合和不一致的地方,一致的点就是入口都是 dispatch 方法(这个地方有一个隐性要求:Effect 与 Reducer 事件不能一致,否则会死循环),都会先从自己的组件开始寻找能处理这个事件的监听者,如果找不到就会交给顶层组件进行分发。不一致的点是 effect 不关心处理结果,reducer 关心处理结果。 > Effect处理流程 流程就比较简单,因为 bus 中已经存储了所有 effect 处理,这个时候只需要遍历一下_dispatchList 就可以广播处理消息了。 > Reducer 处理流程 Effect 与 Reducer 的事件处理流程存在重合和不一致的地方,一致的点就是入口都是 dispatch 方法(这个地方有一个隐性要在整个界面创建完成后,父组件通过 connector 将子组件的 reducer 组合在一起,这样在处理事件时,就可以访问到子组件的reducer。而在 Fish Redux 中,reducer 的事件都从是 store 中开始,事件发生后,从根节点开始向下找寻可以处理这个事件的 reducer,如果没有找到就返回原有 state,找到之后会调用其更新方法,更新 state,并且把新的 state 返回。 /// combine_reducers.dart Reducer? combineReducers(Iterable?>? reducers) { final List?>? notNullReducers = reducers?.where((Reducer? r) => r != null).toList(growable: false); /// ... 前置处理 return (T state, Action action) { T nextState = state; for (Reducer? reducer in notNullReducers) { /// 这里有问题,必须要重新赋值对象 final T? _nextState = reducer?.call(nextState, action); nextState = _nextState!; } assert(nextState != null); return nextState; }; } 而 reducer 的事件是从 store 中发出的。store 的创建是在 Page 组件中,在创建 store 时,会实现dispatch 方法,内容就是分发 reducer 事件,完成分发之后,就会得到整个 page 最新的 state 状态,然后进行 state 更新事件的广播,通知所有组件进行更新。 // create_store.dart Store _createStore(final T preloadedState, final Reducer reducer) { // 前置处理 return Store() ..getState = (() => _state) ..dispatch = (Action action) { // 前置校验 try { _isDispatching = true; // reducer 分发处理 _state = _reducer(_state, action); } finally { _isDispatching = false; } final List<_VoidCallback> _notifyListeners = _listeners.toList( growable: false, ); // 广播更新消息 for (_VoidCallback listener in _notifyListeners) { listener(); } _notifyController.add(_state); }//..更多属性初始化 } 而组件的更新逻辑,就是收到更新时间之后,调用 shouldUpdate 方法判断是否需要更新界面, shouldUpdate 默认实现就是判断前后state是否相等。 /// context.dart class ComponentContext extends LogicContext implements ViewUpdater { @override void onNotify() { final T now = state; // 默认是 !identical(_latestState, now) if (shouldUpdate(_latestState, now)) { _widgetCache = null; markNeedsBuild?.call(); _latestState = now; } } } // markNeedsBuild 实现 markNeedsBuild: () { if (mounted) { setState(() {}); } } 但是按道理我们实现了组件化之后,调用的更新方法也是子组件的,应该只刷新子组件才对,但是从实际的表现来看,是会导致整个界面都刷新,说明 Page 的 state 也变了。 > Connector 机制 其实在这个过程中,有一个重要且比较容易被忽视的角色,就是 Connector,Connector 存在两个子类:MutableConn 和 ImmutableConn,ImmutableConn 处理更新时,如果是子 state 发生变化,只会更新父 state 中对子 state 的引用,对父 state 没有影响。 // ImmutableConn SubReducer subReducer(Reducer reducer) { return (T state, Action action, bool isStateCopied) { /// ... 前置处理 final P newProps = reducer(props, action); final bool hasChanged = !identical(newProps, props); if (hasChanged) { final T result = set(state, newProps); /// ... 中间处理 return result; } return state; }; } MutableConn 处理更新时,如果是子 state 发生变化,不仅会更新子 state,还会将父 state 进行 clone 更新,这样就会导致传递更新导致一个小组件更新触发整个界面更新。了解了这个特性之后,前面的问题就可以得到解释了。 // MutableConn SubReducer subReducer(Reducer reducer) { return (T state, Action action, bool isStateCopied) { /// ... 前置处理 final P newProps = reducer(props, action); final bool hasChanged = newProps != props; final T copy = (hasChanged && !isStateCopied) ? _clone(state) : state; if (hasChanged) { set(copy, newProps); } return copy; }; } 三、解决方案 1、数据加载耗时 对于数据加载耗时,最终是定位到使用的 Recase 库存在性能问题。在网络数据请求之后,在业务中需要针对 json 的 key 进行驼峰和下滑线的转换,而 Recase 库在处理转换时,存在对象重复创建和转换逻辑不够高效的问题。针对这点,我们自己实现了转换的逻辑,并且增加了对于 key 转换的缓存,将之前随数据条数增加导致耗时增加的情况变为随不同 key 增加导致耗时增加。大大提升了转换的效率。 class ReCase { /// 重复创建常量对象 final RegExp _upperAlphaRegex = RegExp(r'[A-Z]'); final symbolSet = {' ', '.', '/', '_', '\\', '-'}; List _groupIntoWords(String text) { // 重复创建临时对象 StringBuffer sb = StringBuffer(); /// ... 转换逻辑 return words; } /// ... 其他逻辑 } /// 使用场景 /// 在单个单词时并没有太多问题,但是如果用于处理json数据, /// 在数量大时积累耗时会很长,并且也占用的内存也会增加 final result = ReCase('test_test').camelCase 2、UI渲染卡顿 完成了 Fish Redux 刷新机制的分析之后,其实解决方案也比较清晰了。从刷新机制中,可以得出两个解决方案 > 重写 shouldUpdate 方法 在原则上,如果当前组件只是将其他组件组合在一起,自己并没有特殊的业务逻辑时,可以直接将 shouldUpdate 返回 false,因为子组件完全可以管理自己的状态。有一个判断点:当前组件的 view.dart 中是否只是简单的 buildComponent,一般是不需要更新的。 /// view.dart Widget buildView(T state, Dispatch dispatch, ViewService viewService) { return viewService.buildComponent(key) } /// class DemoComponent extends Component { DemoComponent() : super( shouldUpdate: (_,__) => false, /// 其他, ); } 其他情况可以根据当前 state 中的影响界面刷新的子 state 进行判断实现精细化更新。 > 事件分发与处理 修改 connector 类型可以阻断更新传递从而达到减少更新范围的效果,如果明确父组件是不会更新的,就可以在依赖子组件时,使用 ImmutableConn 进行依赖连接,这样就不需要担心子组件更新会影响到父组件。 结合零售的实际情况,最终是采用了方案 1 进行 shouldUpdate 重写,因为在实际业务中,父子组件的联动效果还是存在,不能直接切断联系,还是根据实际场景进行条件刷新,这样在保证业务正确性的同时优化性能。 四、结果 通过优化更新逻辑,优化数据转换效率,再配合热数据内存缓存、优化动画和更细粒度的组件抽离之后,卡顿的Flutter界面流程度提升 60%,再也没有出现明显的卡顿现象。 在整个治理卡顿的过程中,重新学习了一遍 Fish Redux,体会到框架的优秀,特别是针对复杂的项目,其模板化的开发方式有效降低了理解和沟通成本,每个角色各司其职,在处理问题时方向明确,不需要担心“牵一发动全身”的问题。有一个总结经验就是:如果在使用Fish Redux遇到一些卡顿问题,大概率是组件没有划分或者划分不够细。网上在很多Flutter性能优化的建议总结,特别是Flutter官方的性能优化的指导,推荐阅读。
2022-05-13