首页 文章 精选 留言 我的

精选列表

搜索[赛博朋克],共10000篇文章
优秀的个人博客,低调大师

每日一博 | Android 发热监控实践

一、背景 相信移动端高度普及的现在,大家或多或少都会存在电量焦虑,拥有过手机发热发烫的糟糕体验。而发热问题是一个长时间、多场景的指标存在,且涉及到端侧应用层、手机 ROM 厂商系统、外界环境等多方面的影响。如何有效衡量发热场景、定位发热现场、以及归因发热问题成为了端侧应用层发热监控的面前的三座大山。本文通过得物 Android 端侧现有的一些监控实践,不深入功耗计算场景无法自拔,优先聚焦于发热场景本身,希望能给大家一些参考。 二、发热定义 温度是最直观能反映发热问题的指标,当前 Android 侧,我们以体感温度 37° 以上作为分界线,向上每 3° 作为一个发热温度区间,区间细分上限温度 49° ,即划分出 37-40,40-43,43-46,46-49,49+ 五个等级。 以手机温度、CPU 使用率作为第一、第二要素来判断用户是否发热的同时,获取其他参数来支撑发热现场情况。 具体指标如下: 手机温度 CPU 使用率、GPU 使用率; 线程堆栈; 系统服务使用频次; 设备前后台、亮灭屏时长; 电量、充电情况; 热缓解发热等级; 系统机型、版本; .... 三、指标获取 温度 电池温度 系统 BatteryManger 已经提供了一系列自带的接口和粘性广播获取电池信息。 BatteryManager.EXTRA_TEMPERATURE 广播,获取的温度值是摄氏度为单位的 10 倍数值。 //获取电池温度BatteryManager.EXTRA_TEMPERATURE,华氏温度需要除以10 fun getBatteryTempImmediately(context: Context): Float { return try { val batIntent = getBatteryStickyIntent(context) ?: return 0f batIntent.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) / 10F } catch (e: Exception) { 0f } } private fun getBatteryStickyIntent(context: Context): Intent? { return try { context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) } catch (e: Exception) { null } } BatteryManager 除支持电池温度的系统广播外,也包含电量、充电状态等额外信息的读取,均定义在其源码中。 以下罗列几个值得关注的: //BATTERY_PROPERTY_CHARGE_COUNTER 剩余电池容量,单位为微安时 //BATTERY_PROPERTY_CURRENT_NOW 瞬时电池电流,单位为微安 //BATTERY_PROPERTY_CURRENT_AVERAGE 平均电池电流,单位为微安 //BATTERY_PROPERTY_CAPACITY 剩余电池容量,显示为整数百分比 //BATTERY_PROPERTY_ENERGY_COUNTER 剩余能量,单位为纳瓦时 // EXTRA_BATTERY_LOW 是否认为电量低 // EXTRA_HEALTH 电量健康常量的常数 // EXTRA_LEVEL 电量值 // EXTRA_VOLTAGE 电压 // ACTION_CHARGING 进入充电状态 // ACTION_DISCHARGING 进入放电状态 传感器温度 Android是基于Linux 基础上修改的开源操作系统,同样的在手机系统sys/class/thermal/ 目录下存在以 thermal_zoneX 为代表各传感器的温度分区,以及 cooling_deviceX 为代表风扇或散热器等冷却设备。 以一加 9 为例,共存在 105 个温度传感器 or 温度分区,以及 48 个冷却设备。 每个温度分区下记录下具体的参数类型,我们重点关注的是 type 文件和temp 文件,分别记录了该传感器设备的名称,以及当前的传感器温度。以 thermal_zone29 为例,代表了 CPU 第一核心的 第五处理单元的温度值为 33.2 摄氏度。而对单一设备来说分区对应的名称是固定的,从而我们可以通过读取 thermal_zone 文件的方式来记录当前第一个 type 文件名称包含CPU的传感器作为CPU温度。 壳温 Android 10 Google 官方推出了热缓解框架,通过 HAL2.0 框架监听底层硬件传感器(主要为 USB 传感器、Skin 传感器)提供 USB、壳温的热信号等级变更监听, 系统 PowerManager 源码提供了对应发热等级变更的回调和发热等级的获取,共 7 个等级,提供给开发者主动或被动获取。 final PowerManager powerManager = (PowerManager) mContext.getSystemService(Context.POWER_SERVICE); powerManager.addThermalStatusListener(new PowerManager.OnThermalStatusChangedListener() { @Override public void onThermalStatusChanged(int status) { //返回对应的热状态 } }); 但对于发热等级来说,壳温无疑是最为能够反应手机的发热情况的。可以看到 Android 系统的 API 实际上是提供了 AIDL 接口,可以直接注册 Thermal 变更事件的监听,获取到 Temperature 对象。但由于标识了 Hide API 。常规应用层是无法获取到的,在考虑好 Android 版本兼容性前提下,通过反射代理 ThermalManagerService 方式进行读取。 但事与愿违,国内厂商并没有完全适配官方热缓解框架,热状态回调时常不够准确,而是需要单独接入每个厂商的热缓解 SDK 去直接获取到壳温,具体 API 则以各应用厂商的内部接入文档为准。 CPU 使用率 CPU 使用率的采集通过读取解析 Proc stat 文件的方式进行计算。 在系统 proc/[pid]/stat 和 /proc/[pid]/task/[tid]/stat 分别记录了对应进程 ID、进程 ID 下的线程 ID 的 CPU 信息。具体的字段描述在此不进行赘述,详见:https://man7.org/linux/man-pages/man5/procfs.5.html 。 我们重点关注 14.15 位的信息,分别代表进程/线程的用户态运行的时间和内核态运行的时间。 通过解析当前进程的 Stat 文件,以及 Task 目录下所有线程的 Stat 文件,在两次采样周期内(当前设置为 1s)的 utime+stime 之和的差值/采样间隔,即可认为是进线程的 CPU 的使用率。即 进线程 CPU 使用率 = ((utime+stime)-(lastutime+laststime)) / period GPU使用率 高通芯片的设备,我们可以参考/sys/class/kgsl/kgsl-3d0/gpubusy下文件内容,参考高通官网的说明。 GPU 的使用率 = (下图)数值 1 / 数值 2 * 100,经过验证与 SnapDragonProfiler 信息采集获取的数值基本一致。 联发科芯片的设备,我们可以直接通过读取/d/ged/hal/gpu_utilization 下的使用率数值。 同样的通过指定周期(每秒 1 次)的采样间隔,即可获取到每秒的当前 GPU 使用率。 系统服务使用 Android 系统服务包括 Warelock、Alarm、Sensor、Wifi、Net、Location、Bluetooth、Camera等。 与市面上常规的监控手段差异不大,都是通过系统 Hook ServiceManager 的方式,监听系统服务的 Binder 通信,匹配对应的调用方法名,做对应中间层监控的回调记录处理。 熟悉 Android 开发的同学知道 Android 的 Zygote 进程是 Android 系统启动时的第一个进程。在 Zygote Fork 进程中会孵化出系统服务相关的进程 SystemServer,在其核心的 RUN 方法中,会注册启动大量的系统服务,并通过 ServiceManager 进行管理。 故我们可以通过反射代理 ServiceManager 的方式,以 LocationManager 为例进行监听,拦截对应 LocationManager 内对应的方法,记录我们期望获取的数据。 // 获取 ServiceManager 的 Class 对象 Class<?> serviceManagerClass = Class.forName("android.os.ServiceManager"); // 获取 getService 方法 Method getServiceMethod = serviceManagerClass.getDeclaredMethod("getService", String.class); // 通过反射调用 getService 方法获取原始的 IBinder 对象 IBinder originalBinder = (IBinder) getServiceMethod.invoke(null, "location"); // 创建一个代理对象 Proxy Class<?> iLocationManagerStubClass = Class.forName("android.location.ILocationManager$Stub"); Method asInterfaceMethod = iLocationManagerStubClass.getDeclaredMethod("asInterface", IBinder.class); final Object originalLocationManager = asInterfaceMethod.invoke(null, originalBinder); Object proxyLocationManager = Proxy.newProxyInstance(context.getClassLoader(), new Class[]{Class.forName("android.location.ILocationManager")}, new InvocationHandler() { @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 在这里进行方法的拦截和处理 Log.d("LocationManagerProxy", "Intercepted method: " + method.getName()); // 执行原始的方法 return method.invoke(originalLocationManager, args); } }); // 替换原始的 IBinder 对象 getServiceMethod.invoke(null, "location", proxyLocationManager); 同理 我们获取在固定采样周期内 各系统服务对应 申请次数、计算间隔时长等进行记录。 源码Power_profile文件中定义了每个系统服务状态下的电流量定义。 我们在需要记录每个元器件在不同状态的工作时间之后,通过以下计算方式,可以得出元器件的发热贡献排行,即: 元器件 电量消耗(发热贡献) ~~ 电流量 * 运行时长 * 电压(一般为固定值,可忽略) 线程堆栈 由于发热问题是一个综合性的问题,并不像 Crash 问题一样,在发生现场我们就可以知道是哪个线程触发的。如果将所有线程的堆栈都进行 Dump 记录的话,得物当前运行时的子线程数量在 200+,全部进行存储的话无疑是不合理的。问题就转变为 如何较为准确的找到发热代码的线程堆栈? 上文说到 在计算 CPU 使用率的时读取进程下所有线程的 Stat 文件,我们可以获取到子线程的 CPU 使用率,对其使用率进行倒排,筛选超过阈值(当前定义 50% ) 或 占用 Top N 的线程进行存储。由于堆栈频繁采集时机上是有性能折损的,故牺牲了部分的堆栈采样精度和准确性,在温度、CPU 使用率等指标超过阈值定义后,才开始采集 指定下发时间的堆栈信息。 我们还要明确一个概念,线程 Stat 文件的文件名即为线程标识名,Thread.id 是指线程ID。 其两者并不等价,但 Native 方法中给我们提供了对应的方式去建立两者的映射关系。 在 Art Thread.cc 方法中,将 Java 中的 Thread 对象转换成 C++ 中的 Thread 对象,调用 ShortDump 打印线程的相关信息,我们通过字符串匹配到核心的 Tid= 的信息,即可获取到线程的 Tid。 核心代码逻辑如下: //获取队列中最近一次cpu采样的数据 val threadCpuUsageData = cpuProfileStoreQueue.last().threadUsageDataList val hotStacks = mutableListOf<HotStack>() if (threadCpuUsageData != null) { val dataCount = if (threadCpuUsageData.size <= TOP_THREAD_COUNT) { threadCpuUsageData.size } else { TOP_THREAD_COUNT } val traces: MutableMap<Thread, Array<StackTraceElement>> = Thread.getAllStackTraces() //定义tid 和 thread的映射关系map val tidMap: MutableMap<String, Thread> = mutableMapOf() traces.keys.forEach { thread -> //调用native方法获取到tid信息 val tidInfo = hotMonitorListener?.findTidInfoByThread(thread) tidInfo?.let { findTidByTidInfo(tidInfo).let { tid -> if (tid.isNotEmpty()) { tidMap[tid] = thread } } } } //采集topN的发热堆栈 for (index in 1..dataCount) { val singleThreadData = threadCpuUsageData[index - 1] val isMainThread = singleThreadData.pid == singleThreadData.tid val thread = tidMap[singleThreadData.tid.toString()] thread?.let { findThread -> traces[findThread]?.let { findStackTrace -> //获取当前的线程堆栈 val sb = StringBuilder() for (element in findStackTrace) { sb.append(element.toString()).append("\n") } sb.append("\n") if (findStackTrace.isNotEmpty()) { //是否为主线程 //组装hotStack val hotStack = HotStack( //进程id singleThreadData.pid, singleThreadData.tid, singleThreadData.name, singleThreadData.cpuUseRate, sb.toString(), thread.state isMainThread ) // Log.d("HotMonitor", sb.toString()) hotStacks.add(hotStack) } } } } } 四、监控方案 了解核心指标数据是如何获取的前提下,其实监控方案的核心思路无非就是通过远端 APM 配置中心下发的采样阈值、采样周期、各模块数据开关等限定采样配置,子线程 Handler 定时发消息,采集各个模块的数据进行组装,在合适的时机进行数据上报即可,具体的数据拆解、分析工作则由发热平台进一步处理。 模块整体架构 上报时机 核心采集流程 线上线下区分 由于所有子线程的 CPU 采集、堆栈采集实际上是会对性能有折损的,200+ 的线程的读取耗时整体在 200ms 左右,采样子线程的 CPU 使用率在 10%,考虑到线上用户体验问题,并不能全量开启高频率采样。 故整体方案来说: 线下场景以重点侧重发现、排查、治理全量问题,上报全量日志,以 CPU、GPU 使用率为第一衡量指标; 线上场景以重点侧重观察整体发热大盘趋势、分析潜在问题场景,上报核心日志,以电池温度为第一衡量指标。 发热平台 在平台侧同学的支持下,发热现场数据经过平台侧进行消费,将核心的发热堆栈经过 Android 堆栈反混淆服务进行聚合,补齐充电状态、主线程 CPU 使用率、问题类型、电池温度等基础字段,平台侧就具备发现、分析、解决的流程化监控推进的能力。 具体的堆栈信息 & 发热信息平台展示如下: 由于电池温度、CPU 使用率是针对运行时发热场景最直观的指标,且我们一期重点关注发热场景的治理,不针对元器件 Hook 等耗电场景进行持续深入分析,故当前得物侧是以电池温度、CPU 使用率为第一第二指标 建立核心的发热问题四象限,优先关注高温、高 CPU 的问题场景。 在数据分析过程中,我们遇到了数据上的效率排查效率不够高、问题精度不够准的情况。 如何定位是高温场景是发生在 App 内部,且在使用过程中明显上升的? 通过过滤从启动开始即高温、后台切换回来即高温的场景,重点关注在 App 内部温度上升的场景。 线上的采样后仍旧单日有 6w+ 数据的上报,我们如何筛选出更为核心的数据?当前的做法是定义了温度跨度的概念,优先看在 App 内部温度跨度较大的 Case。 线程存在调用 Wait 等方法阻塞的堆栈,消耗内核态的时间分配,但实际不消耗整体 CPU 的误报数据。 补充了线程的运行状态和 Proc 文件中记录的 State,方便优先处理 RUNNABLE线程的 CPU 高温高占用问题。 手机温度上升作为渐进式的场景,如何实现温度上升场景下的页面精确归因?增加温度采样频率的同时,汇总 CPU 使用率和实时堆栈等瞬时数据作为数据支撑,但考虑到数据体量的情况,数据上报聚合裁剪方式仍在逐步探索更为合理的方式,力求在两者之间找到一个平衡点。 五、收益 Android 端侧发热监控自上线以来,背靠平台侧的支撑,陆续发现了一些问题并联合开发同学做了对应场景的治理优化工作,如: 耗时独立线程任务 接入统一线程池调度管理; 动画执行死循环监测修复; 高 IO 场景的文件读写策略优化; 高并发任务锁粒度优化; 日志库等 Json 解析频繁场景 采用效率更高的序列化方; 系统相机等系统功率过高的采集参数设备分级尝试; 基于 Webgl 的游戏场景 帧率降低和资源及时回收优化运行时内存; .... 这无疑给未来体验工作的场景技术选型、技术实现沉淀了一些有价值的经验,符合对 App 体验追求极致的高标准、高要求。 六、未来展望 手机发热作为渐进式的体验场景,涉及手机硬件、系统服务、软件使用、外界环境多方位因素。对于端侧的排查上来说,当前优先级聚焦于应用层的不合理使用上,对于排查工具链路增强、问题业务归因、低电量、低功耗模式下的动态策略降低、自动化诊断报告等环节仍旧有很多值得深入挖掘的点,例如: 监控/工具增强 App 浮层分析工具 (CPU\GPU/频率/温度/功耗等信息) 借鉴 BatteryHistorian、SnapdragonProfiler、Systrace 等工具,实现自研TeslaLab 能力增强。 业务归因 发热堆栈自动分配 调用溯源归因精细化 场景策略、降级 CPU 调频、动态帧率、分辨率降级 端内低功耗模式探索 自动化诊断报告 单用户定向自动化分析输出诊断报告 ‍ 七、总结 在此也只是粗略介绍当前已经做的针对发热治理的一些初步工作,以及对未来发热功耗相关开展的思路,希望能让 App 带来更好的体验,给用户带来更对美好事物的向往的感受。 *文 / GavinX 本文属得物技术原创,更多精彩文章请看:得物技术官网 未经得物技术许可严禁转载,否则依法追究法律责任!

优秀的个人博客,低调大师

每日一博 | 浅析 Redis 大 Key

一、背景 在京东到家购物车系统中,用户基于门店能够对商品进行加车操作。用户与门店商品使用Redis的Hash类型存储,如下代码块所示。不知细心的你有没有发现,如果单门店加车商品过多,或者门店过多时,此Key就会越来越大,从而影响线上业务。 userPin:{ storeId:{门店下加车的所有商品基本信息}, storeId:{门店下加车的所有商品基本信息}, ...... } 二、BigKey的界定和如何产生 2.1、BigKey的界定 BigKey称为大Key,通常以Key对应Value的存储大小,或者Key对应Value的数量来进行综合判断。对于大Key也没有严格的定义区分,针对String与非String结构,给出如下定义: String:String类型的 Key 对应的 Value 超过 10KB 非String结构(Hash,Set,ZSet,List):Value的数量达到10000个,或者Vaule的总大小为100KB 集群中Key的总数超过1亿 2.2、如何产生 1、数据结构设置不合理,例如集合中元素唯一时,应该使用Set替换List; 2、针对业务缺少预估性,没有预见Value动态增长; 3、Key没有设置过期时间,把缓存当成垃圾桶,一直再往里面扔,但是从不处理。 三、BigKey的危害 3.1、数据倾斜 redis数据倾斜分为数据访问倾斜和数据量倾斜,会导致该Key所在的数据分片节点CPU使用率、带宽使用率升高,从而影响该分片上所有Key的处理。 数据访问倾斜:某节点中key的QPS高于其他节点中的Key 数据量倾斜:某节点中key的大小高于其他节点中的Key,如下图,实例1中的Key1存储高于其他实例。 3.2、网络阻塞 Redis服务器是一个事件驱动程序,有文件事件和时间事件,文件事件和时间事件都是主线程完成。其中文件事件就是服务器对套接字操作的抽象,客户端与服务端的通信会产生相应的文件事件,服务器通过监听并处理这些事件来完成一系列网络通信操作。 Redis基于Reactor模式开发了自己的网络事件处理器,即文件事件处理器,该处理器内部使用I/O多路复用程序,可同时监听多个套接字,并根据套接字执行的任务来关联不同的事件处理器。文件事件处理器以单线程的方式运行,但是通过I/O多路复用程序来监听多个套接字,既实现了高性能网络通信模型,又保持了内部单线程设计的简单性。文件事件处理器构成如下图: 文件事件是对套接字操作的抽象,包括连接应答,写入,读取,关闭,因为一个服务器会连接多个套接字,所以文件事件可能并发出现,即使文件事件并发的出现,但是I/O多路复用程序会将套接字放入一个队列,通过队列有序的,同步的每次一个套接字的方式向文件事件分派器传送套接字,当让一个套接字产生的事件被处理完毕后,I/O多路复用程序才会继续向文件事件分派器传送下一个套接字,当有大key时,单次操作时间延长,导致网络阻塞。 3.3、慢查询 严重影响 QPS 、TP99 等指标,对大Key进行的慢操作会导致后续的命令被阻塞,从而导致一系列慢查询。 3.4、CPU压力 当单Key过大时,每一次访问此Key都可能会造成Redis阻塞,其他请求只能等待了。如果应用中设置了超时等,那么上层就会抛出异常信息。最后删除的时候也会造成redis阻塞,到时候内存中数据量过大,就会造成CPU负载过高。单个分片cpu占用率过高,其他分片无法拥有cpu资源,从而被影响。此外,大 key 对持久化也有些影响。fork 操作会拷贝父进程的页表项,如果过大,会占用更多页表,主线程阻塞拷贝需要一定的时间。 四、如何检测BigKey 4.1、redis-cli --bigkeys 首先我们从运行结果出发。首先通过脚本插入一些数据到redis中,然后执行redis-cli的--bigkeys选项 $ redis-cli --bigkeys # Scanning the entire keyspace to find biggest keys as well as # average sizes per key type. You can use -i 0.01 to sleep 0.01 sec # per SCAN command (not usually needed). -------- 第一部分start ------- [00.00%] Biggest string found so far 'key-419' with 3 bytes [05.14%] Biggest list found so far 'mylist' with 100004 items [35.77%] Biggest string found so far 'counter:__rand_int__' with 6 bytes [73.91%] Biggest hash found so far 'myobject' with 3 fields -------- 第一部分end ------- -------- summary ------- -------- 第二部分start ------- Sampled 506 keys in the keyspace! Total key length in bytes is 3452 (avg len 6.82) Biggest string found 'counter:__rand_int__' has 6 bytes Biggest list found 'mylist' has 100004 items Biggest hash found 'myobject' has 3 fields -------- 第二部分end ------- -------- 第三部分start ------- 504 strings with 1403 bytes (99.60% of keys, avg size 2.78) 1 lists with 100004 items (00.20% of keys, avg size 100004.00) 0 sets with 0 members (00.00% of keys, avg size 0.00) 1 hashs with 3 fields (00.20% of keys, avg size 3.00) 0 zsets with 0 members (00.00% of keys, avg size 0.00) -------- 第三部分end ------- 以下我们分三步对bigkeys选项源码原理进行解析,简要流程如下图: 4.1.1、第一部分是如何进行找key的呢? Redis找bigkey的函数是static void findBigKeys(int memkeys, unsigned memkeys_samples),因为--memkeys选项和--bigkeys选项是公用同一个函数,所以使用memkeys时会有额外两个参数memkeys、memkeys_sample,但这和--bigkeys选项没关系,所以不用理会。findBigKeys具体函数框架为: 1.申请6个变量用以统计6种数据类型的信息(每个变量记录该数据类型的key的总数量、bigkey是哪个等信息) typedef struct { char *name;//数据类型,如string char *sizecmd;//查询大小命令,如string会调用STRLEN char *sizeunit;//单位,string类型为bytes,而hash为field unsigned long long biggest;//最大key信息域,此数据类型最大key的大小,如string类型是多少bytes,hash为多少field unsigned long long count;//统计信息域,此数据类型的key的总数 unsigned long long totalsize;//统计信息域,此数据类型的key的总大小,如string类型是全部string总共多少bytes,hash为全部hash总共多少field sds biggest_key;//最大key信息域,此数据类型最大key的键名,之所以在数据结构末尾是考虑字节对齐 } typeinfo; dict *types_dict = dictCreate(&typeinfoDictType); typeinfo_add(types_dict, "string", &type_string); typeinfo_add(types_dict, "list", &type_list); typeinfo_add(types_dict, "set", &type_set); typeinfo_add(types_dict, "hash", &type_hash); typeinfo_add(types_dict, "zset", &type_zset); typeinfo_add(types_dict, "stream", &type_stream); 2.调用scan命令迭代地获取一批key(注意只是key的名称,类型和大小scan命令不返回) /* scan循环扫描 */ do { /* 计算完成的百分比情况 */ pct = 100 * (double)sampled/total_keys;//这里记录下扫描的进度 /* 获取一些键并指向键数组 */ reply = sendScan(&it);//这里发送SCAN命令,结果保存在reply中 keys = reply->element[1];//keys来保存这次scan获取的所有键名,注意只是键名,每个键的数据类型是不知道的。 ...... } while(it != 0); 3.对每个key获取它的数据类型(type)和key的大小(size) /* 检索类型,然后检索大小*/ getKeyTypes(types_dict, keys, types); getKeySizes(keys, types, sizes, memkeys, memkeys_samples); 4.如果key的大小大于已记录的最大值的key,则更新最大key的信息 /* Now update our stats */ for(i=0;i<keys->elements;i++) { ......//前面已解析 //如果遍历到比记录值更大的key时 if(type->biggest<sizes[i]) { /* Keep track of biggest key name for this type */ if (type->biggest_key) sdsfree(type->biggest_key); //更新最大key的键名 type->biggest_key = sdscatrepr(sdsempty(), keys->element[i]->str, keys->element[i]->len); if(!type->biggest_key) { fprintf(stderr, "Failed to allocate memory for key!\n"); exit(1); } //每当找到一个更大的key时则输出该key信息 printf( "[%05.2f%%] Biggest %-6s found so far '%s' with %llu %s\n", pct, type->name, type->biggest_key, sizes[i], !memkeys? type->sizeunit: "bytes"); /* Keep track of the biggest size for this type */ //更新最大key的大小 type->biggest = sizes[i]; } ......//前面已解析 } 5.对每个key更新对应数据类型的统计信息 /* 现在更新统计数据 */ for(i=0;i<keys->elements;i++) { typeinfo *type = types[i]; /* 跳过在SCAN和TYPE之间消失的键 */ if(!type) continue; //对每个key更新每种数据类型的统计信息 type->totalsize += sizes[i];//某数据类型(如string)的总大小增加 type->count++;//某数据类型的key数量增加 totlen += keys->element[i]->len;//totlen不针对某个具体数据类型,将所有key的键名的长度进行统计,注意只统计键名长度。 sampled++;//已经遍历的key数量 ......//后续解析 /* 更新整体进度 */ if(sampled % 1000000 == 0) { printf("[%05.2f%%] Sampled %llu keys so far\n", pct, sampled); } } 4.1.2、第二部分是如何执行的? 1.输出统计信息、最大key信息 /* We're done */ printf("\n-------- summary -------\n\n"); if (force_cancel_loop) printf("[%05.2f%%] ", pct); printf("Sampled %llu keys in the keyspace!\n", sampled); printf("Total key length in bytes is %llu (avg len %.2f)\n\n", totlen, totlen ? (double)totlen/sampled : 0); 2.首先输出总共扫描了多少个key、所有key的总长度是多少。 /* Output the biggest keys we found, for types we did find */ di = dictGetIterator(types_dict); while ((de = dictNext(di))) { typeinfo *type = dictGetVal(de); if(type->biggest_key) { printf("Biggest %6s found '%s' has %llu %s\n", type->name, type->biggest_key, type->biggest, !memkeys? type->sizeunit: "bytes"); } } dictReleaseIterator(di); 4.1.3、第三部分是如何执行的? di为字典迭代器,用以遍历types_dict里面的所有dictEntry。de = dictNext(di)则可以获取下一个dictEntry,de是指向dictEntry的指针。又因为typeinfo结构体保存在dictEntry的v域中,所以用dictGetVal获取。然后就是输出typeinfo结构体里面保存的最大key相关的数据,包括最大key的键名和大小。 di = dictGetIterator(types_dict); while ((de = dictNext(di))) { typeinfo *type = dictGetVal(de); printf("%llu %ss with %llu %s (%05.2f%% of keys, avg size %.2f)\n", type->count, type->name, type->totalsize, !memkeys? type->sizeunit: "bytes", sampled ? 100 * (double)type->count/sampled : 0, type->count ? (double)type->totalsize/type->count : 0); } dictReleaseIterator(di); 4.2、使用开源工具发现大Key 在不影响线上服务的同时得到精确的分析报告。使用redis-rdb-tools工具以定制化方式找出大Key,该工具能够对Redis的RDB文件进行定制化的分析,但由于分析RDB文件为离线工作,因此对线上服务不会有任何影响,这是它的最大优点但同时也是它的最大缺点:离线分析代表着分析结果的较差时效性。对于一个较大的RDB文件,它的分析可能会持续很久很久。 redis-rdb-tools的项目地址为:https://github.com/sripathikrishnan/redis-rdb-tools 五、如何解决Bigkey 5.1、提前预防 设置过期时间,尽量过期时间分散,防止同一时间过期; 存储为String类型的JSON,可以删除不使用的Filed; 例如对象为{"userName":"京东到家","ciyt":"北京"},如果只需要用到userName属性,那就定义新对象,只具有userName属性,精简缓存中数据 存储为String类型的JSON,利用@JsonProperty注解让FiledName字符集缩小,代码例子如下。但是存在缓存数据识别性低的缺点; import org.codehaus.jackson.annotate.JsonProperty; import org.codehaus.jackson.map.ObjectMapper; import java.io.IOException; public class JsonTest { @JsonProperty("u") private String userName; public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; } public static void main(String[] args) throws IOException { JsonTest output = new JsonTest(); output.setUserName("京东到家"); System.out.println(new ObjectMapper().writeValueAsString(output)); String json = "{\"u\":\"京东到家\"}"; JsonTest r1 = new ObjectMapper().readValue(json, JsonTest.class); System.out.println(r1.getUserName()); } } {"u":"京东到家"} 京东到家 采用压缩算法,利用时间换空间,进行序列化与反序列化。同时也存在缓存数据识别性低的缺点; 在业务上进行干预,设置阈值。比如用户购物车的商品数量,或者领券的数量,不能无限的增大; 5.2、如何优雅删除BigKey 5.2.1、DEL 此命令在Redis不同版本中删除的机制并不相同,以下分别进行分析: redis_version < 4.0 版本:在主线程中同步删除,删除大Key会阻塞主线程,见如下源码基于redis 3.0版本。那针对非String结构数据,可以先通过SCAN命令读取部分数据,然后逐步进行删除,避免一次性删除大key导致Redis阻塞。 // 从数据库中删除给定的键,键的值,以及键的过期时间。 // 删除成功返回 1,因为键不存在而导致删除失败时,返回 0 int dbDelete(redisDb *db, robj *key) { // 删除键的过期时间 if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr); // 删除键值对 if (dictDelete(db->dict,key->ptr) == DICT_OK) { // 如果开启了集群模式,那么从槽中删除给定的键 if (server.cluster_enabled) slotToKeyDel(key); return 1; } else { // 键不存在 return 0; } } 4.0 版本 < redis_version < 6.0 版本:引入lazy-free,手动开启lazy-free时,有4个选项可以控制,分别对应不同场景下,是否开启异步释放内存机制: lazyfree-lazy-expire:key在过期删除时尝试异步释放内存 lazyfree-lazy-eviction:内存达到maxmemory并设置了淘汰策略时尝试异步释放内存 lazyfree-lazy-server-del:执行RENAME/MOVE等命令或需要覆盖一个key时,删除旧key尝试异步释放内存 replica-lazy-flush:主从全量同步,从库清空数据库时异步释放内存 开启lazy-free后,Redis在释放一个key的内存时,首先会评估代价,如果释放内存的代价很小,那么就直接在主线程中操作了,没必要放到异步线程中执行 redis_version >= 6.0 版本:引入lazyfree-lazy-user-del,只要开启了,del直接可以异步删除key,不会阻塞主线程。具体是为什么呢,现在先卖个关子,在下面进行解析。 5.2.2、SCAN SCAN命令可以帮助在不阻塞主线程的情况下逐步遍历大量的键,以及避免对数据库的阻塞。以下代码是利用scan来扫描集群中的Key。 public void scanRedis(String cursor,String endCursor) { ReloadableJimClientFactory factory = new ReloadableJimClientFactory(); String jimUrl = "jim://xxx/546"; factory.setJimUrl(jimUrl); Cluster client = factory.getClient(); ScanOptions.ScanOptionsBuilder scanOptions = ScanOptions.scanOptions(); scanOptions.count(100); Boolean end = false; int k = 0; while (!end) { KeyScanResult< String > result = client.scan(cursor, scanOptions.build()); for (String key :result.getResult()){ if (client.ttl(key) == -1){ logger.info("永久key为:{}" , key); } } k++; cursor = result.getCursor(); if (endCursor.equals(cursor)){ break; } } } 5.2.3、UNLINK Redis 4.0 提供了 lazy delete (unlink命令) ,下面基于源码(redis_version:7.2版本)分析下实现原理 del与unlink命令底层都调用了delGenericCommand()方法; void delCommand(client *c) { delGenericCommand(c,server.lazyfree_lazy_user_del); } void unlinkCommand(client *c) { delGenericCommand(c,1); } lazyfree-lazy-user-del支持yes或者no。默认是no; 如果设置为yes,那么del命令就等价于unlink,也是异步删除,这也同时解释了之前咱们的问题,为什么设置了lazyfree-lazy-user-del后,del命令就为异步删除。 void delGenericCommand(client *c, int lazy) { int numdel = 0, j; // 遍历所有输入键 for (j = 1; j < c->argc; j++) { // 先删除过期的键 expireIfNeeded(c->db,c->argv[j],0); int deleted = lazy ? dbAsyncDelete(c->db,c->argv[j]) : dbSyncDelete(c->db,c->argv[j]); // 尝试删除键 if (deleted) { // 删除键成功,发送通知 signalModifiedKey(c,c->db,c->argv[j]); notifyKeyspaceEvent(NOTIFY_GENERIC,"del",c->argv[j],c->db->id); server.dirty++; // 成功删除才增加 deleted 计数器的值 numdel++; } } // 返回被删除键的数量 addReplyLongLong(c,numdel); } 下面分析异步删除dbAsyncDelete()与同步删除dbSyncDelete(),底层同时也是调用dbGenericDelete()方法 int dbSyncDelete(redisDb *db, robj *key) { return dbGenericDelete(db, key, 0, DB_FLAG_KEY_DELETED); } int dbAsyncDelete(redisDb *db, robj *key) { return dbGenericDelete(db, key, 1, DB_FLAG_KEY_DELETED); } int dbGenericDelete(redisDb *db, robj *key, int async, int flags) { dictEntry **plink; int table; dictEntry *de = dictTwoPhaseUnlinkFind(db->dict,key->ptr,&plink,&table); if (de) { robj *val = dictGetVal(de); /* RM_StringDMA may call dbUnshareStringValue which may free val, so we need to incr to retain val */ incrRefCount(val); /* Tells the module that the key has been unlinked from the database. */ moduleNotifyKeyUnlink(key,val,db->id,flags); /* We want to try to unblock any module clients or clients using a blocking XREADGROUP */ signalDeletedKeyAsReady(db,key,val->type); // 在调用用freeObjAsync之前,我们应该先调用decrRefCount。否则,引用计数可能大于1,导致freeObjAsync无法正常工作。 decrRefCount(val); // 如果是异步删除,则会调用 freeObjAsync 异步释放 value 占用的内存。同时,将 key 对应的 value 设置为 NULL。 if (async) { /* Because of dbUnshareStringValue, the val in de may change. */ freeObjAsync(key, dictGetVal(de), db->id); dictSetVal(db->dict, de, NULL); } // 如果是集群模式,还会更新对应 slot 的相关信息 if (server.cluster_enabled) slotToKeyDelEntry(de, db); /* Deleting an entry from the expires dict will not free the sds of the key, because it is shared with the main dictionary. */ if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr); // 释放内存 dictTwoPhaseUnlinkFree(db->dict,de,plink,table); return 1; } else { return 0; } } 如果为异步删除,调用freeObjAsync()方法,根据以下代码分析: #define LAZYFREE_THRESHOLD 64 /* Free an object, if the object is huge enough, free it in async way. */ void freeObjAsync(robj *key, robj *obj, int dbid) { size_t free_effort = lazyfreeGetFreeEffort(key,obj,dbid); if (free_effort > LAZYFREE_THRESHOLD && obj->refcount == 1) { atomicIncr(lazyfree_objects,1); bioCreateLazyFreeJob(lazyfreeFreeObject,1,obj); } else { decrRefCount(obj); } } size_t lazyfreeGetFreeEffort(robj *key, robj *obj, int dbid) { if (obj->type == OBJ_LIST && obj->encoding == OBJ_ENCODING_QUICKLIST) { quicklist *ql = obj->ptr; return ql->len; } else if (obj->type == OBJ_SET && obj->encoding == OBJ_ENCODING_HT) { dict *ht = obj->ptr; return dictSize(ht); } else if (obj->type == OBJ_ZSET && obj->encoding == OBJ_ENCODING_SKIPLIST){ zset *zs = obj->ptr; return zs->zsl->length; } else if (obj->type == OBJ_HASH && obj->encoding == OBJ_ENCODING_HT) { dict *ht = obj->ptr; return dictSize(ht); } else if (obj->type == OBJ_STREAM) { ... return effort; } else if (obj->type == OBJ_MODULE) { size_t effort = moduleGetFreeEffort(key, obj, dbid); /* If the module's free_effort returns 0, we will use asynchronous free * memory by default. */ return effort == 0 ? ULONG_MAX : effort; } else { return 1; /* Everything else is a single allocation. */ } } 分析后咱们可以得出如下结论: 当Hash/Set底层采用哈希表存储(非ziplist/int编码存储)时,并且元素数量超过64个 当ZSet底层采用跳表存储(非ziplist编码存储)时,并且元素数量超过64个 当List链表节点数量超过64个(注意,不是元素数量,而是链表节点的数量,List的实现是在每个节点包含了若干个元素的数据,这些元素采用ziplist存储) refcount == 1 就是在没有引用这个Key时 只有以上这些情况,在删除key释放内存时,才会真正放到异步线程中执行,其他情况一律还是在主线程操作。也就是说String(不管内存占用多大)、List(少量元素)、Set(int编码存储)、Hash/ZSet(ziplist编码存储)这些情况下的key在释放内存时,依旧在主线程中操作。 5.3、分而治之 采用经典算法“分治法”,将大而化小。针对String和集合类型的Key,可以采用如下方式: String类型的大Key:可以尝试将对象分拆成几个Key-Value, 使用MGET或者多个GET组成的pipeline获取值,分拆单次操作的压力,对于集群来说可以将操作压力平摊到多个分片上,降低对单个分片的影响。 集合类型的大Key,并且需要整存整取要在设计上严格禁止这种场景的出现,如无法拆分,有效的方法是将该大Key从JIMDB去除,单独放到其他存储介质上。 集合类型的大Key,每次只需操作部分元素:将集合类型中的元素分拆。以Hash类型为例,可以在客户端定义一个分拆Key的数量N,每次对HGET和HSET操作的field计算哈希值并取模N,确定该field落在哪个Key上。 如果线上服务强依赖Redis,需要考虑到如何做到“无感”,并保证数据一致性。咱们基本上可以采用三步走策略,如下图所示。分别是进行双写,双读校验,最后读新Key。在此基础上可以设置开关,做到上线后的平稳迁移。 六、总结 综上所述,针对文章开头咱们购物车大Key问题,相信你已经有了答案。咱们可以限制门店数,限制门店中的商品数。如果不作限制,咱们也能进行拆分,将大Key分散存储。例如。将Redis中Key类型改为List,key为用户与门店唯一键,Value为用户在此门店下的商品。 存储结构拆分成两种: 第一种: userPin:storeId的集合 第二种: userPin_storeId1:{门店下加车的所有商品基本信息}; userPin_storeId2:{门店下加车的所有商品基本信息} 以上介绍了大key的产生、识别、处理,以及如何使用合理策略和技术来应对。在使用Redis过程中,防范大于治理,在治理过程中也要做到业务无感。 七、参考 https://github.com/redis/redis.git http://redisbook.com/ https://github.com/huangz1990/redis-3.0-annotated.git https://blog.csdn.net/ldw201510803006/article/details/124790121 https://blog.csdn.net/kuangd_1992/article/details/130451679 http://sd.jd.com/article/4930?shareId=119428&isHideShareButton=1 https://www.liujiajia.me/2023/3/28/redis-bigkeys https://www.51cto.com/article/701990.html https://help.aliyun.com/document_detail/353223.html https://juejin.cn/post/7167015025154981895 https://www.jianshu.com/p/9e150d72ffc9 https://zhuanlan.zhihu.com/p/449648332 作者:京东零售高凯 来源:京东云开发者社区 转载请注明来源

优秀的个人博客,低调大师

每日一博 | ARM 汇编快速入门

本文主要分享如何快速上手ARM汇编开发的经验、汇编开发中常见的Bug以及Debug方法、用的Convolution Dephtwise算子的汇编实现相对于C++版本的加速效果三方面内容。 前言 神经网络模型能够在移动端实现快速推理离不开高性能算子,直接使用ARM汇编指令来进行算子开发无疑会大大提高算子的运算性能。初次接触汇编代码可能会觉得其晦涩难懂然后望而却步,但ARM汇编开发一旦入门就会觉得语言优美简洁,如果再切换到ARM INTRISIC指令开发反而觉得没有直接写汇编码来的方便。我会在第一节分享纯小白如何快速上手ARM汇编开发的经验,第二节会列举在汇编开发中常见的Bug以及Debug方法,第三节会展示常用的Convolution Dephtwise算子的汇编实现相对于C++版本的加速效果。如果你已经能很熟练地使用ARM汇编指令进行开发了,可以跳过第一节。 从简单函数上手 学习汇编开发重要的一点是通过学习现有函数的汇编代码来实现自己的需求我写的第一个汇编算子是MaxPooling算子,算子本身的计算过程非常简单。但当我开始实现MaxPooling的汇编代码时,我不知道第一行代码怎么写,不知道开头和结尾怎么写,不知道中间的计算逻辑怎么写。当时我就在MNN库的source文件夹下面找到了一份逻辑简单的、自己非常熟悉的Relu算子当做参照来实现MaxPooling. 之所以我推荐用一个逻辑简单的、自己非常熟悉的算子当做学习汇编的模版,是因为当算子的计算逻辑简单时,我们才能把注意力放在汇编函数的声明、传参、读取数据、存储结果、返回等等这些大的流程上面,至于内部的函数实现(如何计算一行数据的最大值,如何去计算一个寄存器中所有数据的累加和等等)可以暂时不去关注。学习一个新的东西时,我们找的例子模版不能过于复杂,因为这会导致我们将注意力放在例子本身的实现细节中,而忽略了如何去入门,这样会增加我们的学习成本。 ▐汇编函数的开头与结尾 函数定义以asm_function开头,后加函数名(以MNNAvgPoolInt8 ARM64为例): asm_function MNNAvgPoolInt8// 加上函数的传参注释,方便后续对照使用对应的寄存器// void MNNAvgPoolInt8(int8_t* dst, int8_t* src, size_t outputWidth,// size_t inputWidth, size_t kernelx, size_t kernely, size_t stridesx,// ssize_t paddingx, ssize_t factor);// Auto load: x0: dst, x1: src, x2: outputWidth, x3: inputWidth,// x4: kernelx, x5: kernely, x6: stridesx, x7: paddingx// Load from sp:// w8: factor 传参:ARM64 用于传参的寄存器有8个:x0-x7. 如果函数的参数大于8,就需要使用sp寄存器读取剩余参数。例如AvgPoolInt8算子中的第9个参数factor读取: // x8寄存器存储参数factor的值,不是必须使用x8寄存器,用其他寄存器也是可以的。ldr x8, [sp, #0] ARM寄存器使用不当会导致程序crash。这里总结了ARM32和AMR64的寄存器基本使用规则。ARM32中通用寄存器和向量寄存器都有16个,每个向量寄存器的最大使用长度是128位。ARM32中用于传参的寄存器有4个:r0-r3。ARM32中r13寄存器就是sp寄存器,指向栈顶;r14寄存器也叫lr寄存器,存储函数的返回值地址;r15寄存器也叫pc寄存器,存储将要执行的下一条指令的地址。在进行汇编开发时,一般不使用r13和r15寄存器来存储临时变量。r9寄存器的使用在各个平台上可能不同,为了防止出错,一般也不用来存储临时变量。当不需要使用r14存储返回值地址的信息时,也可以使用其存储临时变量。下图中我总结了ARM32中寄存器的基本使用规则,关于各寄存器更加详细的介绍参考https://developer.arm.com/documentation/den0013/d/Application-Binary-Interfaces/Procedure-Call-Standard。 ARM64中通用寄存器和向量寄存器的个数比ARM32多一倍,有32个。ARM64中向量寄存器的使用更加灵活,可以8bit,16bit,32bit,64bit使用。例如,v0表示128位的向量寄存器,d0,s0,h0分别表示v0的低64位,32位,16位。注意,d1,s1,h1表示v1寄存器的低64位,32位,16位,而不是紧接着v0的第二个相应位。ARM64的寄存器使用见下图。 我们可以用浮点操作指令把向量寄存器中的数当做标量来进行计算,需要注意在ARMV8中浮点操作指令不支持对16bit的浮点数进行计算,仅支持做16bit和32bit, 64bit之间的转换。 fadd Sd, Sn, Sm // 32bit Single precisionfsub Dd, Dn, Dm // 64bit Double precisionfcvt Sd, Hn // half-precision to single-precisionfcvt Dd, Hn // half-precision to double-precisionfcvt Hd, Sn // single-precision to half-precisionfcvt Hd, Dn // double-precision to half-precision 对上图中的“用完恢复”寄存器的使用:一些复杂的函数需要的向量寄存器或者通用寄存器可能会非常多,那就需要我们在开头加载这些寄存器,不然会报错segment fault.加载方法如下: // d8-d15表示使用v8-v15这8个寄存器的64位, (2* 64)/8=16,// 这就是每次sp移位时(#16*i)中16的来源。stp d14, d15, [sp, #(-16 * 9)]!stp d12, d13, [sp, #(16 * 1)]stp d10, d11, [sp, #(16 * 2)]stp d8, d9, [sp, #(16 * 3)]stp x27, x28, [sp, #(16 * 4)]stp x25, x26, [sp, #(16 * 5)]stp x23, x24, [sp, #(16 * 6)]stp x21, x22, [sp, #(16 * 7)]stp x19, x20, [sp, #(16 * 8)] 在函数的结尾需要释放这些寄存器: ldp x19, x20, [sp, #(16 * 8)]ldp x21, x22, [sp, #(16 * 7)]ldp x23, x24, [sp, #(16 * 6)]ldp x25, x26, [sp, #(16 * 5)]ldp x27, x28, [sp, #(16 * 4)]ldp d8, d9, [sp, #(16 * 3)]ldp d10, d11, [sp, #(16 * 2)]ldp d12, d13, [sp, #(16 * 1)]ldp d14, d15, [sp], #(16 * 9)ret // 最后需加上ret返回 ARM32中寄存器的数量只有ARM64的一半,自动传参的寄存器仅r0-r3这四个寄存器,其他寄存器的加载方式和ARM64也不同,我们依然以MNNAvgPoolInt8为例,代码的解释和新手闭坑的地方我直接在下面的注释中写明。 // 函数定义asm_function MNNAvgPoolInt8// void MNNAvgPoolInt8(int8_t* dst, int8_t* src, size_t outputWidth,// size_t inputWidth, size_t kernelx, size_t kernely, size_t stridesx,// ssize_t paddingx, ssize_t factor);// Auto load: r0: dst, r1: src, r2: outputWidth, r3: inputWidth// r4: kernelx, r5: kernely, r7: stridesx, r8: paddingx, lr: factor// 其他寄存器加载, 注意lr寄存器每次必须被push进来(可以不使用),不然会报错segment fault.push {r4-r8, r10-r11, lr}// 上一行push了8个寄存器,那么sp指针会向低地址移动(8*4=32)个字节(ARM32每个指针占4个字节),// 所以第五个参数“kernelx”加载时需要将sp的地址加(#32).// 虚拟内存中栈是从高地址向低地址扩展的,而函数传参是从右往左传去栈中的,// 所以后面的参数地址会比前面的高,即相对sp寄存器的地址增加的更多。ldr r4, [sp, #32] // kernelxldr r5, [sp, #36] // kernelyldr r7, [sp, #40] // stridesxldr r8, [sp, #44] // paddingxldr lr, [sp, #48] // factor// 加载向量寄存器一定要放在利用sp寄存器来读取所有函数参数之后,// 否则不能正常读取函数参数vpush{q4-q7} ARM32 结尾对寄存器的释放 // 不需要pop lr寄存器,但是必须pop pc寄存器。// ARM32结尾不需要写 ret, 这和ARM64不同。vpop {q4-q7}pop {r4-r8, r10-r11, pc} ▐核心功能的实现 写汇编代码之前,我们一定要先实现C++版本的代码,保证C++版本的算子在ARM移动端的计算结果是正确的。这样做有两个目的:第一,保证我们对算子的理解是正确并清晰的,否则写汇编算子就是浪费时间;第二,为汇编算子的输出结果提供标准答案,因为同样的 C++ 代码在不同的平台上的计算结果可能会略有不同(但差异不会很大),我们需要保证汇编版本的算子和C++版本的算子计算结果在ARM平台上完全一致。 汇编代码中条件判断和分支跳转 MaxPooling算子通过遍历局部区域的所有元素,进而找到区域内的最大值。这就涉及到循环指令、地址跳转指令和比较两个向量寄存器中对应元素。关于指令的解释我直接在代码注释中写明。 比较两个向量寄存器中对应元素的大小 /*smax, smin 比较整型数数据的大小ARM汇编有符号整数的指令一般以s开头(signed int)无符号整数的指令一般以u开头(unsigned int)浮点数据的指令一般以f开头(float)*/// 比较v0和v1寄存器中的16个int8_t数据,// 并将对应位置上的较大值存储在v2的相应位置上// b 表示以8位来读取数据,相应的汇编中 h:16位, s:32位, d:64位smax v2.16b, v0.16b, v1.16bsmin v10.4s, v11.4s, v12.4s //比较v11和v12的4个int32_t数据的大小 循环执行某一段代码 如果需要在ARM汇编中循环执行一段代码,那我们需要自定义一个符号来标记这一段代码。以MaxPooling算子为例,假设每一个像素点含有16个Channel,我们需要得到被kernel覆盖到的9个像素点上对应Channel的最大值,即重复执行比较指令9次。例如用Loop来标记我们需要循环的代码段: 1. mov w7, #-0x80 // 给通用寄存器赋值-128,即int8_t类型的最小值2. dup v0.16b, w7 // 初始化v0, v0中存储了16个-1283. mov x10, #9 // 计数// 循环Loop:3. ld1 {v1.16b}, [x0] // 从地址x0中加载16个int8的数据到v1寄存器,与v0做比较4. smax v0.16b, v0.16b, v1.16b // 用v0记录最终的比较结果5. add x0, x0, #1 // 移动像素点的地址,这里我们假设9个像素点是连续的6. sub x10, x10, #1 // 比较完一个像素点的16个Channel大小后,计数减17. cmp x10, #0 // cmp是compare的缩写:比较x10和0的大小8. bgt Loop // bgt是branch greater than的缩写,满足条件就跳到分支Loop执行// 循环执行结束9. st1 {v0}, [x1] // 存储寄存器v0中的16个int8_t数据到地址x1中// ARM 汇编代码是按照从上到下的顺序来执行的,// 所以跳出Loop不需要额外的指令来表示结束该分支// 当不满足x10>0时,会直接执行第9行代码 ▐ 如何查找需要的指令 灵活地运用各种汇编指令往往能提高算子性能。 利用现成的汇编代码查找指令 当我们阅读一些汇编代码时,根据汇编指令去查询其功能是非常容易的,甚至根据指令名我们可以猜测出他的功能。但是当我们第一次写汇编代码时,想知道实现某个功能可以使用哪些指令往往很难。此时最关键的一点,需要我们思考哪个函数中会用到我将要实现的功能,然后去参考他的汇编实现过程。比如写Pooling算子的汇编代码时不知道如何去进行循环代码段的编写,我们就可以参考矩阵乘算子的汇编代码去学习分支跳转,寄存器的比较等指令。当我们不知道如何用汇编指令去实现浮点数转整数的四舍五入时,MNN中现成的Float2Int8函数一定会有相应的指令实现这个功能。当我们编写了越来越多的汇编代码,会接触到更多的汇编指令,解决问题的思路和视野也更开阔。 利用关键词在ARM官网查找指令 ARM官网列举了所有汇编指令的用法,其中ARM64的指令手册比ARM32更易查找和理解。一般ARM64的指令在ARM32系统都能找到对应的等效指令。偶尔我们也需要ARM Intrisic指令来完成一些简单函数的开发,Intrisic指令可以参考https://gcc.gnu.org/onlinedocs/gcc-4.6.4/gcc/ARM-NEON-Intrinsics.html?spm=ata.21736010.0.0.68f48710o8Vsk6。利用好功能的关键词能提高查找指令的速度。例如某次编程中我需要查找哪些指令能实现“int8+int16->int16"的功能,显然关键词是"add". 官网中会列举适用于各种场景的向量加法指令,很快就可以定位到"saddw v0.8h, v1.8h, v2.8b"指令。 ARM官网地址:https://developer.arm.com/documentation/dui0801/h/A64-SIMD-Vector-Instructions/?spm=ata.21736010.0.0.68f48710o8Vsk6 ARM汇编Debug方法和常见错误列举 ▐利用好“打印printf” 汇编代码的调试一直是个难题,不能像C++代码那样一步步Debug查看变量的值,只能通过在函数调用的外层加打印的方式来查看汇编代码的执行结果。不过只要我们能利用好打印,汇编代码的BUG排查就能简单不少!具体来说,如果我们需要查看某个中间变量的值,我们可以在代码内部用返回值地址来存储该值,从而我们可以在汇编代码的外部打印该地址存储的内容,这样间接地检查代码执行的逻辑是否符合预期。 ▐函数传参错误 函数传参错误非常容易被忽视,因为这个错误很少会直接报错"segment fault",而是发现汇编算子的结果和C++版本不一致时,经过一步步排查才发现传参就出现了错误。毕竟我们发现结果错误时,更习惯于去检查汇编代码中最复杂的逻辑,不太会想到代码开头的函数传参就已经错了。目前为止,我遇到过的传参错误就只有以下两种: 1、除了整型以外的数据传参应该用指针传入,而不是直接传入参数值。浮点参数传递方式与编译器及参数配置相关,可能不同平台下传递方式不一样。如果直接浮点数值传参,带来的结果有可能是:浮点参数后面的参数数值都是前一个参数的数据,也就是发生了传参的偏移,导致计算结果对不上;如果恰巧你需要从某个参数中load数据,该参数的值受到了浮点参数错误传递的影响,那有可能会报segment fault的错误。 // 正确传参,用指针传递浮点常数para0 void func(float* para0, float* dst)// 错误传参,直接传入常数para0void func(float para0, float* dst) 2、传参寄存器使用错误ARM64 自动传参的寄存器有8个:x0-x7,ARM32 自动传参的寄存器有4个: r0-r3。如果参数个数大于8(4),就需要从sp寄存器的相对位置来load参数。 asm_function MNNAvgPoolInt8// 加上函数的传参注释,方便后续对照使用对应的寄存器// void MNNAvgPoolInt8(int8_t* dst, int8_t* src, size_t outputWidth,// size_t inputWidth, size_t kernelx, size_t kernely, size_t stridesx,// ssize_t paddingx, ssize_t factor);// Auto load: x0: dst, x1: src, x2: outputWidth, x3: inputWidth,// x4: kernelx, x5: kernely, x6: stridesx, x7: paddingx// Load from sp:// w8: factor 3、整型参数建议使用ssize_t和size_t传参 定义一个函数:void func(int8_t* dst, int8_t* src, float* params0, float* params1, int width, int height, int kernelx, int kernely, int needBroadcast)按照前面的介绍,第9个参数needBroadcast应该由sp寄存器来加载,如:ldr x8, [sp, #0],如果我们需要比较needBroadcast和0的大小,写成:cmp x8, #0,无论x8是否为0,代码的判断结果都会是false.除非将判断语句写成:cmp w8, #0. 出现这种问题的原因在于,ssize_t和size_t这两种类型,ARM64和ARM32会将其分别看做是64位和32位的数据,而对于int类型的数据,ARM64和ARM32上都会是32位的数据,而ARM64的通用寄存器以x来使用是64位的(即x1,x2...),以w来使用才是32位的(即w1,w2...)。所以要比较x8与0的大小关系,应是:cmp,w8,#0. 对于上述问题的更好的解决办法是,函数声明时将needBroadcast参数的类型定义成ssize_t,因为该参数的取值可能是-1,1,0, 我们将其定义成有符号类型。在汇编代码中再次使用 cmp x8, #0来比较结果就是正确的了,当然此时我们还是用w8和0比较的话,结果也是正确的。 ▐ARM32 向量寄存器和参数加载的顺序问题 在汇编开发中我遇到过这样的问题,定义一个函数如下: // void MNNAvgPoolInt8(int8_t* dst, int8_t* src, size_t outputWidth,// size_t inputWidth, size_t kernelx, size_t kernely, size_t stridesx,// ssize_t paddingx, ssize_t factor);asm_function MNNAvgPoolInt8// Auto load: r0: dst, r1: src, r2: outputWidth, r3: inputWidth// Load from sp: r4: kernelx, r5: kernely, r7: stridesx, r8: paddingx, lr: factor2. push {r4-r8, r10-r11, lr}3. vpush {q4-q6}4. ldr r4, [sp, #32]5. ldr r5, [sp, #36]6. ldr r7, [sp, #40]7. ldr r8, [sp, #44]8.ldrlr,[sp,#48]//lr:factor 这样可能不会出现报错segment fault,但是参数的加载结果是错的。原因在于第3行vpush应该在通过sp加载完所有的函数参数之后,而不是在此之前。因为push了8个通用寄存器入栈之后,再push向量寄存器入栈,那么函数参数相对于sp寄存的位置就不再是(8x4=32). 相对位置的偏移发生了变化。第3行的代码应该在第8行后面。 ▐ARM64 通用寄存器的使用问题 在ARM64中给通用寄存器赋整型数值 // 通用寄存器的赋值只能用32位来使用寄存器mov w10, #0 // rightmov x10, #0 // error// 后续计算中要使用x10来进行加减乘的计算,需要将w10扩展成x10:uxtw x10, w10 // w10中32位数据在x10的低32位中保持不变,x10的高32位填充为0. sub, add等指令只能对整型数据操作,浮点类型数据需要使用fsub, fadd等 fmov v1.4s, #1.0fmov v2.4s, #0.2fsub v1.4s, v1.4s, v2.4s ▐四舍五入的问题 ARM32和ARM64中浮点数取整的方式不一样。ARM32中浮点数转换成整数的指令(vcvt.s32.f32)是向负无穷取整的,在ARM32中没有四舍五入的取整指令。需要在ARM32中实现四舍五入,可以这样做: //对寄存器q3中的4个浮点数据做四舍五入取整// q3: -1.4, 4.5, 1.1, -2.7 -> q3: -1, 4, 1, -3vmov.f32 q1, #0.5vmov.f32 q2, #-0.5vcgt.f32 q12, q3, #0vbsl.f32 q12, q1, q2 // bitwise select.vadd.f32 q13, q12, q3vcvt.s32.f32 q3, q13 ARM64提供的取整指令更加灵活方便,有: // q10: -1.4, 4.5, 1.1, -2.7fcvtas q1, q10 // q1: -1, 5, 1, -3 就近取整fcvtzs q2, q10 // q2: -1, 4, 1, -2 向0取整fcvtms q3, q10 // q3: -2, 4, 1, -3 向负无穷取整fcvtps q4, q10 // q4: -1, 5, 2, -3 向正无穷取整fcvtns q4, q10 // q4: -2, 4, 2, -2 向最近的偶数取整 ▐整型数据和浮点数据进行数学运算的问题 整型数据与浮点数据进行相加或相乘等数学运算之前,一定要先将整型数据转换成浮点数据再进行数学运算,否则计算结果会出错。该过程经常出现在Int8量化算子的开发中,往往是量化算子很难消除的计算负担。用Binary multiply的Int8量化算子举例说明该过程: // Int8 量化的乘法算子,输入和输出均是Int8类型,但考虑到int8xint8会可能会导致越界,// 在量化算子的实现过程中会将两个输入数据分别转换成Float32数据之后相乘,// 再将Float32的结果量化到Int8类型.sxtl v0.8h, v0.8b // int8x8_t -> int16x8_tsxtl v1.8h, v1.8b // int8x8_t -> int16x8_tsxtl v2.4s, v0.4h // v0的低64位数据:int16x4_t -> int32x4_tsxtl2 v3.4s, v0.8h // v0的高64位数据:int16x4_t -> int32x4_tsxtl v4.4s, v1.4hsxtl2 v5.4s, v1.8hscvtf v2.4s, v2.4s // int32x4_t -> float32x4_tscvtf v3.4s, v3.4sscvtf v4.4s, v4.4sscvtf v5.4s, v5.4sfmul v2.4s, v2.4s, v6.4s // v6.4s: float32x4_t 量化scale参数fmul v3.4s, v3.4s, v6.4s fmul v4.4s, v4.4s, v6.4s fmul v5.4s, v5.4s, v6.4s... 此处有同学可能会质疑这么麻烦还有必要开发Int8量化的乘法算子吗?具体原因可以参考之前关于开发Pooling量化算子的ATA文章,开头有说明原因。 ▐Segment fault出现的可能原因总结 在这里总结目前我遇到过的程序crash情况,后续也会在此添加更多的bug。 数据加载、存储时,地址寄存器使用错误 函数参数加载地址时是否使用了错误的寄存器; 写代码过程中,是否给存储地址的寄存器赋值了,导致寄存器的内容改变; 循环加载、存储数据时,原地址累加是否导致了越界; 寄存器开头和结尾是否相应地push\pop(stp\ldp) 通用寄存器的加减出错,大多由于赋值错误或函数加载错误而间接导致 通用寄存器的内容是否符合预期,可使用Printf的办法验证 ARM64和ARM32中用于自动加载函数参数的寄存器个数分别是8个、4个 ARM64中通用寄存器赋值只能用32位,即w0,w1...根据需要决定是否使用uxtw扩展到相应的x0,x1... 函数参数类型声明错误,导致加载错误 非整型函数参数一律用指针传递 整型常数参数尽量使用ssize_t, size_t 是否设置了循环退出条件,比如用于计数寄存器是否每次减1,循环退出条件是否能满足 有一些寄存器是否忘记push就直接使用了,参考1.1节中的图查询哪些寄存器需要用完恢复 ARM汇编的加速效果 拿ConvolutionDepthwise的Int8量化算子举例说明,C++版本的算子实现和ARM汇编版本的性能差距。测试模型中含有超过20个ConvolutionDepthwise算子。测试机我选择了高端机华为Mate40 Pro和中端机华为P30 Pro,并使用ARM V8.2平台的相关指令编写汇编算子。测试结果中显示的时间是该模型中所有ConvolutionDepthwise算子的耗时总和,显然在ARM V8.2 64位平台上,汇编算子的性能提高了约4.7倍。 C++版本 ARM V8.2 汇编 华为Mate40 Pro 11.28 ms 1.98 ms 华为P30 Pro 12.83 ms 2.22 ms 团队介绍 大淘宝技术Meta Team,负责面向消费场景的3D/XR基础技术建设和创新应用探索,通过技术和应用创新找到以手机及XR 新设备为载体的消费购物3D/XR新体验。团队在端智能、商品三维重建、3D引擎、XR引擎等方面有深厚的技术积累。先后发布端侧推理引擎MNN,端侧实时视觉算法库PixelAI,商品三维重建工具Object Drawer等技术。团队在OSDI、MLSys、CVPR、ICCV、NeurIPS、TPAMI等顶级学术会议和期刊上发表多篇论文。 本篇内容作者:酒七 ¤拓展阅读¤ 3DXR技术| 终端技术| 音视频技术 服务端技术|技术质量|数据算法 本文分享自微信公众号 - 大淘宝技术(AlibabaMTT)。 如有侵权,请联系 support@oschina.cn 删除。 本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

优秀的个人博客,低调大师

每日一博 | 商品推荐系统浅析

一、综述 本文主要做推荐系统浅析,主要介绍推荐系统的定义,推荐系统的基础框架,简单介绍设计推荐的相关方法以及架构。适用于部分对推荐系统感兴趣的同学以及有相关基础的同学,本人水平有限,欢迎大家指正。 二、商品推荐系统 2.1 推荐系统的定义 推荐系统本质上还是解决信息过载的问题,帮助用户找到他们感兴趣的物品,深度挖掘用户潜在的兴趣。 2.2 推荐架构 其实推荐系统的核心流程只有召回、排序、重排。 请求流程 当一个用户打开一个页面,这个时候前端会携带用户信息(pin或者uuid等)去请求后台接口(通过color间接调用),当后台收到请求后一般会先根据用户标识进行分流获取相关策略配置(ab策略),这些策略去决定接下来会调用召回模块、排序模块以及重排模块的哪个接口。一般召回模块分多路召回,每路召回负责召回多个商品,排序和重排负责调整这些商品的顺序。最后挑选出合适的商品并进行价格、图片等相关信息补充展现给用户。用户会根据自己是否感兴趣选择点击或者不点击,这些涉及用户的行为会通过日志上报到数据平台,为之后效果分析和利用用户行为推荐商品奠定基础。 其实有些问题想说一说: 为什么要采取召回、排序、重排这种漏斗分层架构? (1)从性能方面 终极:从百万级的商品库筛选出用户感兴趣的个位数级别的商品。 复杂的排序模型线上推断耗时严重,需要严格控制进入排序模型的商品数量。需要进行拆解 (2)从目标方面 召回模块:召回模块的任务是快速从大量的物品中筛选出一部分候选物品,目的是不要漏掉用户可能会喜欢的物品。召回模块通常采用多路召回,使用一些简化的特征或模型。 排序模块:排序模块的任务是精准排序,根据用户的历史行为、兴趣、偏好等信息,对召回模块筛选出的候选物品进行排序。排序模块通常使用一些复杂的模型。 重排模块:重排模块的任务是对排序模块的结果进行二次排序或调整,以进一步提高推荐的准确性和个性化程度。重排模块通常使用一些简单而有效的算法。 什么是ab实验? 参考论文:Overlapping Experiment Infrastructure: More, Better, Faster Experimentation(google2010) 只有在线实验才能真正评估模型优劣,ab实验可以快速验证实验的效果,快速迭代模型。减少上线新功能的风险。 ab算法:Hash(uuid+实验id+创建时间戳)%100 特性:分流+正交 2.3召回 召回层的存在仅仅是为用户从广阔的商品池子中初筛出一批还不错的商品。为了平衡计算速度与召回率(正样本占全部正样本的比例)指标之间的矛盾,采用多路召回策略,每路召回策略只考虑其中的单一特征或策略。 2.3.1多路召回的优劣 多路召回:采用不同的策略、特征或者简单模型分别召回一部分候选集,然后把候选集混合在一起供排序使用。召回率高,速度快,多路召回相互补充。 多路召回中每路召回的截断个数K是个超参数,需要人工调参,成本高;召回通路存在重合问题,冗余。 是否存在一种召回可以替代多路召回,向量召回应用而生,就目前而言,仍然是以向量召回为主,其他召回为辅的架构。 2.3.2召回分类 主要分为非个性化召回,个性化召回两大类。非个性化召回主要是进行热点推送,推荐领域马太效应严重,20%的商品贡献80%的点击。个性化召回主要是发掘用户感兴趣的商品,着重处理每个用户的差异点,提高商品的多样性,保持用户的粘性。 非个性化召回 (1) 热门召回 近7天高点击、高点赞、高销量商品召回 (2)新品召回 最新上架的商品召回 个性化召回 (1)标签召回、地域召回 标签召回:用户感兴趣的品类、品牌、店铺召回等 地域召回:根据用户的地域召回地域内的优质商品。 (2)cf召回 协同过滤算法是基于用户行为数据挖掘用户的行为偏好,从而根据用户的行为偏好为其推荐物品,其根据的是用户和物品的行为矩阵(共现矩阵)。用户行为一般包括浏览、点赞、加购、点击、关注、分享等等。 协同过滤分为三大类:基于用户的协同过滤(UCF)和基于物品的协同过滤(ICF)和基于模型的协同过滤(隐语义模型)。是否为用户推荐某个物品,首先要把用户和物品进行关联,而进行关联的点是另一个物品还是另一个用户,决定了这属于哪个类型的协同过滤。而基于隐语义模型是根据用户行为数据进行自动聚类挖掘用户的潜在兴趣特征。从而通过潜在兴趣特征对用户和物品进行关联。 基于物品的协同过滤(ICF):判断是否为用户推荐某个物品,首先根据用户历史行为记录的物品和这个物品的相似关系来推断用户对这个物品的兴趣度,从而判断我们是否推荐这个物品。整个协同过滤过程主要分为以下几步:计算物品之间的相似度,计算用户对物品的兴趣度,排序截取结果。 商品相似度计算: 衡量相似度主要有以下几种方式:夹角余弦距离,杰卡德公式。由于用户或物品的表示方式的多样性,使得这些相似度的计算非常灵活。我们可以利用用户和物品的行为矩阵来去计算相似度,也可以根据用户行为、物品属性和上下文关系构造用户和物品的向量表示去计算相似性。 夹角余弦距离公式: cos⁡θ=(x1*x2+y1*y2)/(√(x12+y12 )*√(x22+y22 )) 杰卡德公式J(A,B)=(|A⋂B|)/(|A⋃B|) 商品a 商品b 商品c 商品d 用户A 1 0 0 1 用户B 0 1 1 0 用户C 1 0 1 1 用户D 1 1 0 0 夹角余弦距离公式计算商品a和b的相似度: Wab=(1*0+0*1+1*0+1*1)/(√(1^2+0^2+1^2+1^2 )*√(0^2+1^2+0^2+1^2 ))=1/√6 spark实现ICF:https://zhuanlan.zhihu.com/p/413159725 问题:冷启动问题,长尾效应。 (3)向量召回 向量化召回:通过学习用户与物品低维向量化表征,将召回建模成向量空间内的近邻搜索问题,有效提升了召回的泛化能力与多样性,是推荐引擎的核心召回通道。 向量:万物皆可向量化,Embedding就是用一个低维稠密的向量表示一个对象(词语或者商品),主要作用是将稀疏向量转换成稠密向量(降维的效果),这里的表示蕴含着一定的深意,使其能够表达出对象的一部分特征,同时向量之间的距离反映对象之间的相似性。 向量召回步骤:离线训练生成向量,在线向量检索。 1.离线训练生成向量 word2vec:词向量的鼻祖,由三层神经网络:输入层,隐藏层,输出层,隐藏层没有激活函数,输出层用了softmax计算概率。 目标函数 网络结构: 总的来说:输入是词语的序列,经过模型训练可以得到每个词语对应的向量。应用在推荐领域就是输入是用户的点击序列,经过模型训练得到每个商品的向量。 优劣:简单高效,但是只考虑了行为序列,没有考虑其他特征。 双塔模型: 网络结构:分别称为User塔和物品塔;其中User塔接收用户侧特征作为输入比如用户id、性别、年龄、感兴趣的三级品类、用户点击序列、用户地址等;Item塔接受商品侧特征,比如商品id、类目id、价格、近三天订单量等。数据训练:(正样本数据,1)(负样本,0)正样本:点击的商品,负样本:全局随机商品样本(或者同批次其他用户点击样本) 优劣:高效,完美契合召回特性,在线请求得到用户向量,检索召回item向量,泛化性高;用户塔和item塔割裂,只在最后做了交互。 2.在线向量检索 向量检索:是一种基于向量空间模型(Vector Space Model)的信息检索方法,用于在大规模文本集合中快速查找与查询向量最相似的文档向量。在信息检索、推荐系统、文本分类中得到广泛应用。 向量检索的过程是计算向量之间的相似度,最后返回相似度较高的TopK向量返回,而向量相似度计算有多种方式。计算向量相似性得方式有欧式距离、内积、余弦距离。归一化后,内积与余弦相似度计算公式等价。 向量检索的本质是近似近邻搜索(ANNS),尽可能减小查询向量的搜索范围,从而提高查询速度。 目前在工业界被大规模用到的向量检索算法基本可以分为以下3类: 局部敏感性哈希(LSH) 基于图(HNSW) 基于乘积量化 简单介绍LSH LSH算法的核心思想是:将原始数据空间中的两个相邻数据点通过相同的映射或投影变换后,这两个数据点在新的数据空间中仍然相邻的概率很大,而不相邻的数据点被映射到同一个桶的概率很小。 相比于暴力搜索遍历数据集中的所有点,而使用哈希,我们首先找到查询样本落入在哪个桶中,如果空间的划分是在我们想要的相似性度量下进行分割的,则查询样本的最近邻将极有可能落在查询样本的桶中,如此我们只需要在当前的桶中遍历比较,而不用在所有的数据集中进行遍历。当哈希函数数目H取得太大,查询样本与其对应的最近邻落入同一个桶中的可能性会变得很微弱,针对这个问题,我们可以重复这个过程L次(每一次都是不同得哈希函数),从而增加最近邻的召回率。 案例:基于word2vec实现向量召回 2.4排序 推荐系统的掌上明珠 排序阶段分为粗排和精排,粗排一般出现在在召回结果的数据量级比较大的时候。 进化历程 简单介绍Wide&Deep 背景:手动特征组合实现记忆性效果不错但是特征工程太耗费人力,并且未曾出现的特征组合无法记忆,不能进行泛化。 目的:使模型同时兼顾泛化和记忆能力(有效的利用历史信息并具有强大的表达能力)​ (1)记忆能力 模型直接学习并利用历史数据中物品或者特征共现频率的能力,记忆历史数据的分布特点,简单模型容易发现数据中对结果影响较大的特征或者组合特征,调整其权重实现对强特征的记忆 (2)泛化能力 模型传递特征的相关性,以及发掘稀疏或者从未出现过的稀有特征和最终标签相关性的能力,即使是非常稀疏的特征向量输入也能得到稳定平滑的推荐概率。提高泛化性的例子:矩阵分解,神经网络 兼顾记忆和泛化能力 (结果的准确性和扩展性) wide部分专注模型记忆,快速处理大量历史行为特征,deep部分专注模型泛化,探索新世界,模型传递特征的相关性,发掘稀疏甚至从外出现过的稀有特征与最终标签的相关性的能力,具有强大的表达能力。最终将wide部分和deep部分结合起来,形成统一的模型。 wide部分就是基础的线性模型,表示为y=W^T X+b X特征部分包括基础特征和交叉特征。交叉特征在wide部分很重要,可以捕捉到特征间的交互,起到添加非线性的作用。 deep部分为embeding层+三层神经网络(relu),前馈公式 联合训练 优劣:为推荐/广告/搜索排序算法之后的发展奠定了重要基础,从传统算法跨越到深度学习算法,里程碑意义。兼顾记忆和泛化能力但是Wide侧仍需要手工组合特征。 参考论文:Wide & Deep Learning for Recommender Systems 2.5 重排 定义:对精排后的结果顺序进行微调,一方面实现全局最优、一方面满足业务诉求提升用户体验。比如打散策略,强插策略,提高曝光,敏感过滤 MMR算法 实现商品多样性问题​ 目的:在推荐结果准确性的同时保证推荐结果的多样性,为了平衡推荐结果的多样性和相关性​ 算法原理,如公式​ D:商品集合,Q:用户,S:已被选中的商品集合, R\S:R中未被选中的商品集合​ def MMR(itemScoreDict, similarityMatrix, lambdaConstant=0.5, topN=20): #s 排序后列表 r 候选项 s, r = [], list(itemScoreDict.keys()) while len(r) > 0: score = 0 selectOne = None # 遍历所有剩余项 for i in r: firstPart = itemScoreDict[i] # 计算候选项与"已选项目"集合的最大相似度 secondPart = 0 for j in s: sim2 = similarityMatrix[i][j] if sim2 > second_part: secondPart = sim2 equationScore = lambdaConstant * (firstPart - (1 - lambdaConstant) * secondPart) if equationScore > score: score = equationScore selectOne = i if selectOne == None: selectOne = i # 添加新的候选项到结果集r,同时从s中删除 r.remove(selectOne) s.append(selectOne) return (s, s[:topN])[topN > len(s)] 意义是选择一个与用户最相关的同时跟已选择物品最不相关的物品。时间复杂度O(n2) 可以通过限制选择的个数进行降低时间复杂度​ 工程实现:需要用户和物品的相关性和物品之间的相似性作为输入,用户和物品的相关性可以用排序模型的结果作为代替,物品之间的相似性可以通过协同过滤等算法得到商品向量,计算余弦距离。也可以简单得是否同一三级类目、同一店铺等表征​ 三、总结 就简单唠叨这么多啦,主要想让大家了解一下推荐系统,向大家介绍一下整个推荐架构,以及整个推荐都有哪些模块。由于本人水平有限,每个模块也没有讲的特别细,希望之后能在工作中继续学习这个领域,深挖细节,产出更好的东西呈现给大家。感谢!!! 作者:京东零售 闫先东 来源:京东云开发者社区

优秀的个人博客,低调大师

每日一博 | 大报文问题实战

导读 大报文问题,在京东物流内较少出现,但每次出现往往是大事故,甚至导致上下游多个系统故障。大报文的背后,是不同商家业务体量不同,特别是B端业务的采购及销售出库单,一些头部商家对京东系统支持业务复杂度及容量能力的要求越来越高。因此我们有必要把这个问题重视起来,从组织上根本上解决。 1 认识大报文问题 大报文问题,是指不同的系统通过网络进行数据交互时payload size过大导致的系统可用性下降问题。 对于大报文的产生方,过大的报文在序列化时消耗更多内存和CPU,在传输时(JSF/MQ)可能超过中间件的大小限制导致传输失败;对于大报文的消费方,过大的报文在反序列化时会产生大对象,消耗更多的内存和CPU,容易触发FullGC甚至OOM,而在处理过程中要遍历的内容更多,造成响应变慢,如果涉及数据库操作容易产生大事务、慢SQL,这些容易触发超时,如果客户端有重试机制,会进一步加重大报文消费方负载,严重时导致服务集群整体不可用。 此外,由于大报文与小报文是在一个接口上完成的,使用相同的UMP key,它会导致监控失真,报警阈值无效。如果日志记录了原始报文,也可能磁盘打满和响应变慢。 在京东物流技术体系内,具体表现为: 大报文场景 后果 MQ的producer发送了大的Message 由于JMQ对消息大小的限制,导致producer发送失败:消息未送达 MQ consumer反序列化Message并处理计算时产生大对象,频繁FullGC,CPU使用率飙升 JSF Consumer调用API时传入大入参值 由于JSF Server对payload大小限制,导致服务端将报文抛弃:无法送达 JSF Provider响应变慢,产生大对象,频繁FullGC,CPU使用率飙升,甚至OOM;请求处理超时 JSF Provider返回值包含大对象 由于JSF Consumer对payload大小限制,导致consumer无法获取响应 JSF Consumer产生大对象,频繁FullGC,CPU使用率飙升,甚至OOM 📌 JMQ/JSF对payload大小的限制都属于防御性保护措施,目前的值是科学的,它们都已经足够大了。在紧急止血情况下可以调整配置参数来暂时提高payload大小限制,但长期看它会加重系统的风险,应该从设计入手避免超过payload大小限制。 1.1 背景知识 1.1.1 JMQ限制 根据JMQ的官方文档,单条消息大小:JMQ4不要超过4M,JMQ2不要超过2M。 具体原理是发送消息时在生产端做主动校验,如果消息大小超过阈值则抛出异常(代码实现与官方文档不一致): class ClusterManager { protected volatile int maxSize = 4194304; // 4MB } class MessageProducer implement Producer { // Producer接口的具体实现类 ClusterManager clusterManager; // producer.send时做校验 int checkMessages(List<Message> messages) { int size = 0; for (Message message : messages) { size += message.getSize() // 压缩后的大小 } if (size > this.clusterManager.getMaxSize()) { throw new IllegalArgumentException("the total bytes of message body must be less than " + this.clusterManager.getMaxSize()); } } } 📌 经与JMQ团队确认,JMQ消息大小的限制,以代码实现为准(官方文档不准确): 1.1.2 JSF限制 根据JSF官方文档,JSF可以在server和consumer端分别设置payload size,默认都是8MB。 📌 需要注意,触发provider报文长度限制时,JSF consumer(老版本)并不会立即失败,而是依靠客户端超时后才返回(感觉是JSF的缺陷)。具体原因:JSF依靠底层netty来实现报文长度限制,当provider从请求报文头里取得本次请求payload size发现超过限定值时,不会继续读取报文体,而是抛出netty定义的TooLongFrameException,而该异常的处理依赖netty的ChannelHandler.exceptionCaught方法,JSF里没有对TooLongFrameException做处理(吃掉异常),provider端不给consumer任何响应(请求被扔进黑洞),因此造成consumer一直等待响应直到超时,而这可能把consumer端的业务线程池拖死。 class LengthFieldBasedFrameDecoder { // 基于netty io.netty.handler.codec.LengthFieldBasedFrameDecoder的改动 protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception { // 从JSF协议的报文头里获取本次请求的payload size,此时还没有读取8MB的body long frameLength = getUnadjustedFrameLength(in, actualLengthFieldOffset, lengthFieldLength, byteOrder); if (frameLength > maxFrameLength) { // maxFrameLength即8MB限制 throw new TooLongFrameException(); } } } class ServerChannelHandler implements ChannelHandler { public void exceptionCaught(ChannelHandlerContext ctx, final Throwable cause) { if (cause instanceof IOException) { // ... } else if (cause instanceof RpcException) { // 这里可以看到遇到这种异常,JSF是如何给consumer端响应的 ResponseMessage responseMessage = new ResponseMessage(); // 给consumer的响应 responseMessage.getMsgHeader().setMsgType(Constants.RESPONSE_MSG); String causeMsg = cause.getMessage(); String channelInfo = BaseServerHandler.getKey(ctx.channel()); String causeMsg2 = "Remote Error Channel:" + channelInfo + " cause: " + causeMsg; ((RpcException) cause).setErrorMsg(causeMsg2); responseMessage.setException(cause); // 异常传递给consumer // socket.write回consumer ChannelFuture channelFuture = ctx.writeAndFlush(responseMessage); } else { // TooLongFrameException会走到这里,它的继承关系如下: // TooLongFrameException -> DecoderException -> CodecException -> RuntimeException // 异常被吃掉了,不给consumer响应 logger.warn("catch " + cause.getClass().getName() + " at {} : {}", NetUtils.channelToString(channel.remoteAddress(), channel.localAddress()), cause.getMessage()); } } } 📌 经与JSF团队确认,consumer端或provider端发出的消息过大(超过playload)时consumer端得不到正确的异常响应只提示请求超时的问题,已经在1.7.5版本修复:需要provider端升级。升级后,如果consumer端发送的消息过大,provider会立即响应RpcException。 此外,在JSF旧版本下,consumer使用了默认的5秒超时,但consumer抛出超时异常总用时是48秒,这是为什么? 这是因为consumer配置的timeout不包括序列化时间,这48秒是把8MB的报文序列化的耗时: class JSFClientTransport { // consumer同步调用provider ResponseMessage send(BaseMessage msg, int timeout) { MsgFuture<ResponseMessage> future = doSendAsyn(msg, timeout); return future.get(timeout, TimeUnit.MILLISECONDS); } MsgFuture doSendAsyn(final BaseMessage msg, int timeout) { final MsgFuture resultFuture = new MsgFuture(getChannel(), msg.getMsgHeader(), timeout); Protocol protocol = ProtocolFactory.getProtocol(msg.getProtocolType(), msg.getMsgHeader().getCodecType()); byteBuf = protocol.encode(request, byteBuf); // 发送报文前的序列化 RequestMessage request = (RequestMessage) msg; request.setMsg(byteBuf); channel.writeAndFlush(request, channel.voidPromise()); // socket.write,异步IO resultFuture.setSentTime(JSFContext.systemClock.now()); } } class MsgFuture implements java.util.concurrent.Future { final long genTime = JSFContext.systemClock.now(); // new的时候就赋值了 volatile long sentTime; // 抛出超时异常逻辑 ClientTimeoutException clientTimeoutException() { Date now = new Date(); String errorMsg = "[JSF-22110]Waiting provider return response timeout . Start time: " + DateUtils.dateToMillisStr(new Date(genTime)) + ", End time: " + DateUtils.dateToMillisStr(now) + ", Client elapsed: " + (sentTime - genTime) // 它包括:序列化时间,由于异步IO因此不包括socket.write时间 + "ms, Server elapsed: " + (now.getTime() - sentTime); return new ClientTimeoutException(errorMsg); } } 1.1.3 物流网关限制 物流网关在nginx层通过client_max_body_size做了5MB限制。这意味着,JSF限制了8MB,但通过物流网关对外开放成HTTP JSON API时,调用者实际的限制是5MB。 1.1.4 MySQL限制 max_allowed_packet,net_buffer_length等参数在底层控制TCP层的报文长度,京东物流体系内该值足够大,研发不必关注。 研发需要关注的是字段长度的定义,主要是varchar的长度。MySQL通过sql_mode参数控制字段超过长度后的行为是字段截断还是中断事务。对于京东物流业务执行链路比较长的场景来讲,同一个字段可能多处保存,例如订单行里的skuName,就会在OFC/WMS等系统保存,sku_name varchar长度的不一致,特殊场景下可能造成上下游交互出现问题。 1.1.5 其他限制 DUCC value 的长度默认限制为 4W 字符。 UMP Key的限制128。 JMQ的businessId长度限制100,Producer在发送是默认超时2秒,Producer发送失败默认重试2次。 JMQ消费者抛出异常会导致重试(进入retry-db),首次重试10分钟,如果重试还不成功会越来越慢推送直至过期。过期时间:JMQ2为3天,JMQ4为30天。 JSF如果不配置consumer timeout,则使用默认值:5秒。 Zookeeper ZNode限制长度 1MB。虽然可以通过jute.maxbuffer这个Java系统属性修改,但强烈不建议。 原则上,所有依赖的中间件都要确认其限制约束,提升健壮性,避免边界条件被触发而产生出乎意料的错误。 1.2 产生原因 1.2.1 集合类字段无约束 导致京东物流线上事故的大报文问题中,绝大部分都属于该类问题。而这又可以细分为两种场景: interface JsfAPI { // 场景1:批量接口,对批量的大小无限制 void foo(List<Request> requests); } class Request { // 场景2:对一个类内部的集合类字段大小无限制 // JMQ产生大报文,绝大部分属于该场景 List<Item> items; } 当数据量增大时,报文也会增大,造成几MB到几十MB的报文传输,系统为了处理这样大数据量的报文,必然会产生大对象,并且这种对象会一直处于内存中,在数据保存处理时,会造成内存不能释放,可能触发频繁FullGC,CPU使用率飙升。同时,处理集合数据,往往会有数据遍历过程,如果无并发则时间复杂度是O(N),大的数据集必然带来更慢的响应速度,而consumer端不会根据payload大小动态设置超时时间,它可能导致consumer端超时,超时可能带来多次重试,进而加重服务端压力。 例如:无印良品订单sku品类过多,比如一个出库单包含2万个sku的极端情况。 例如:WMS出库发货后向ECLP回传信息,之前都是通过一个JMQ Topic: eclp_delivery进行回传,一份消息包含了(订单主档,箱明细,包裹明细)3部分信息。后来中石化场景下,一个订单的包裹明细数量非常多,导致ECLP处理报文时CPU飙升,同时MQ Listener与对外服务共享CPU,导致接单功能可用率降低。后来,从源头入手把一个订单按照明细进行分页式拆分(之前是整单回传,之后是按明细分页回传),同时把eclp_delivery这一个topic拆分成3个topic:(订单,箱明细,包裹明细),解决了大报文问题。 1.2.2 大字段无约束 它指的是某一个字段(不是集合大小),由于没加长度限制,在特定场景下传入了远超预期大小的数据而造成的故障。 ECLP的商品主数据有个下发商品的接口,有个字段skuName,接口没有对该字段长度进行约束。系统一直平稳运行,直到有个商家下发了某一个商品,它的skuName达到了10KB(事后发现,商家是把该商品详情页的整个HTML通过skuName传过来了),插入数据库时超过了字段长度限制varchar(200),导致插入失败,但由于没有考虑到这种场景,返回了误导的错误提示。展开来看,如果ECLP为skuName定义了MySQL Text类型字段,还会有更严重问题:ECLP接收下商品,下发给WMS,但WMS里的skuName是varchar(200),这个问题就只能人工处理了,甚至与商家沟通。 WMS6.0为了考虑多场景全满足,在出库单预留了扩展字段,在接单时技术BP自行决定写入哪个扩展字段。京喜BP下发出库单时在订单明细维度传入了handOverSlip(交接单,其实是团单信息,里面有多层明细嵌套),该字段其实是一个大JSON,单个长度10KB上下,接单环节没问题。但组建集合单会把多个出库单组建成一个集合单,共产生3000多个明细,仅handOverSlip就占30MB,造成组建集合单后下发(JSF调用)拣货时遇到了JSF 8MB限制问题,下发失败,单据卡在那里,现场生产无法继续。 WMS6.0的用户中心系统,为其他系统提供了发送咚咚通知的服务,具体实现是调用集团的咚咚发送接口:xxx生产系统 -> 用户中心 -> 咚咚系统。链路上每一个环节都未对通知内容content字段长度做限制。一次xxx生产系统调用用户中心传入了超8MB的content字段,触发了咚咚系统的JSF底层的报文限制,最终在用户中心产生了ClientTimeoutException,它导致用户中心的JSF业务线程池打满;而由于用户中心为所有业务生产系统服务,现场操作会依赖它,进而导致生产卡顿,现场多环节无法正常生产。 Amazon FBA的SP-API(Sell Partner API),对可能出现风险的字段都做了长度限制,例如: String displayableOrderComment; // maxLength: 1000 String sellerSku; // maxLength: 50 String giftMessage; // maxLength: 512 String displayableComment; // maxLength: 250 1.2.3 查询接口返回大量数据 ECLP主数据有个接口:导出所有warehouse list,调用方很多,访问频率不高,每次响应长度3MB。该接口在线上出现过多次事故(2019年)。这个接口显然是不该存在的,但把它下线需要推动所有的调用方改动,这个周期很长阻力也很大。 最开始,直接查数据库,出现事故后加入JimDB,再次出现事故后配置了JimDB的local cache,后又加入JSF限流等措施。 出现故障时,ECLP CPU飙升,导致服务超时,京东零售调用方配置的超时设置很短,这导致越来越多的请求打过来,加重了ECLP负担。 1.2.4 导出问题 这个问题与【1.2.3 查询接口返回大量数据】看上去类似,但有很大不同:一个同步调用,返回的数据量相对少,另一个异步执行,返回数据量巨大。 WMS6.0的报表都有导出的需求,例如导出最近3个月的明细数据。贴近商家的OFC(如ECLP),也有类似需求,商家要求导出明细数据。系统执行过程大致是:根据用户指定的条件异步执行SQL,把数据库返回的数据集写入Excel,并存放到blob storage(指定TTL),用户在规定时间(TTL)内根据storage key去blob storage下载,完成整个导出过程。 这里的关键问题是如何查询数据库,而数据库作为共享资源往往是整个系统的瓶颈(增加复本数量意味着成本上升),它变慢会拖垮整个系统。如何查询数据库,有8个可选项: 导出问题的本质,是大范围table scan,很难设计精细的复合索引。WMS6.0最初使用的是方案1,它会产生深分页limit offset问题:越往后的页面越慢,对数据库的压力越大。举例:要导出100万行记录,每页1万,那么到50万记录时,每次分页查询相当于数据库要扫描50万+行记录后抛弃绝大部分并返回1万行,这还要继续执行50次,此外分页组件还要额外执行count语句以计算总行数。 如果每页是1千呢?因此,数据库的压力被放大了,可以简单理解为“全表扫描”了【50 + 100(count计算)=150】次,远不如不分页(不分页还要解决OOM问题)。目前,WMS6.0改用了方案8,根本上解决了数据库慢查询问题。思路是不再盲目静态分页,而是根据时间条件切分成多个SQL,分别查询,保证每个SQL返回数据量不大从而避免慢SQL。例如,某个仓要导出最近3个月的出库单数据,那么把这1个date range拆分(explode)成N个date range,分别执行: condition = DateRange(from = "2022-01-01 00:00:00", to = "2022-04-01 00:00:00") // 用户指定的时间范围:3个月 // sql = select * from ob_shipment_order where xxx and update_time between condition.from and condition.to List<DateRange> chunks = explode(condition) for (DateRange chunk : chunks) { // 该chunk的时间范围已经变成了1天,甚至是1小时,具体值是根据SQL执行计划估算得来的:数据量越大则拆分越细 sql = select * from ob_shipment_order where xxx and update_time between chunk.from and chunk.to mysql.query(sql) } 1.2.5 payload约束不一致产生的问题 链路上经过不同的系统,不同系统对payload size的约束不同,也可能产生问题,因为决定是否可以正常处理的是最小的那个,但链路长时相关方可能不知道,在异步场景下这个问题尤为明显。 例如,aws的API Gateway与Lambda对payload size有不同的约束,最终用户必须知道限制最严格的那一个环节。 对于京东物流,JSF与JMQ的限制不同,理论上可能产生这样的问题:JSF调用者发送8MB的请求,JSF提供者处理时采用同步转异步机制,异步把该请求8MB发送MQ,它会导致MQ发送永远无法成功,而JSF的调用方却浑然不觉。 如果通过物流网关对外开放,网关nginx限制是5MB,而JSF是8MB,设计上没问题(fail fast),但可能造成服务方承诺与调用者感知端到端的不一致。 JSF对provider(jsf:server)和consumer可以分别设置不同的报文大小限制,理论上也可能出现问题,但在京东物流尚未出现,可不必关注。 1.2.6 其他非入口场景 它发生在系统执行过程内部。典型场景是DAO层查询数据库返回大结果集,Redis大key问题等。这要根据具体中间件机制来识别,例如,MyBatis支持插件来识别DAO查询出大结果集: public class ListResultInterceptor implements org.apache.ibatis.plugin.Interceptor { private static final int RESULTSET_SIZE_THRESHOLD = 10000; @Override public Object intercept(Invocation invocation) throws Throwable { Object result = invocation.proceed(); if (result != null && result instanceof List) { int resultSetSize = ((List) result).size(); if (resultSetSize > RESULTSET_SIZE_THRESHOLD) { // 报警 } } return result; } } 2 设计原则 2.1 主动显式强约束 即,主动防御式自我保护,而不是依靠使用者的“自觉”:外部用户不可信赖。 对于JSF,可以通过JSR303向API Consumer显式传递约束,并且该约束可以通过框架对业务代码无侵入地自动执行。对于MQ,由于生产者与消费者解耦,无法直接传递约束,只能靠主动监控、人工协调。 它的前提条件,是研发有能力去主动识别出大报文风险。 2.2 Fail Fast 如果有前端,那么前端加约束,避免大报文传递给后端。 对于后端,链式的上下游关系中,上游要把好关。 这个原则并不是说下游不用关心大报文问题,恰恰相反,链路的每个环节都要关心,但Fail Fast可以降低整体的不必要的损耗成本,也可以缓解某个环节保护机制缺失带来的人工介入和修数成本。 2.3 上下游对齐隐式约束 同一个业务字段在上下游传递时,字段长度约束要一致,否则可能会出现上游成功落库下游无法落库的情况。 2.4 大报文产生方负责拆分 解决大报文的根本思路是拆分报文:大 -> 小。 对应MQ来讲,应该是Producer负责拆分大报文为小报文。 对于JSF来讲,有两种情况: consumer产生的大报文:应该provider加约束,强迫consumer端分页拆分请求。参考AJAX机制 典型场景:拣货下架调用库存预占接口,一次性传入1万个sku provider产生的大报文:应该变成分页返回结果 典型场景:一次性返回所有warehouse列表 📌 需要注意的是,拆分报文,会增加生产方和消费方的复杂度,尤其是消费方:幂等,集齐,(并发和异步调用时产生的)乱序,业务的原子性保证等。例如,一个出库单明细行过多时,整单预占库存(大报文) -> 按订单明细分页预占(小报文)。 拣货下架按明细维度分页调用库存预占接口场景下,如果订单不允许缺量:整单预占时,该订单预占库存的原子性(要么全成功预占,要么一个sku都不预占)是由库存系统(provider)保证的;而在按订单明细维度分页预占时,原子性需要在拣货系统(consumer)保证,即如果后面页码的预占失败则需要把前面页码的预占释放。这增加consumer端复杂度,但为了系统的性能和可用性,这是值得的。当然,也有另外一个可选方案,仍旧让库存保证原子性,但库存接口需要增加类似(currentPage, totalPages)的参数,那样就是库存更复杂了。无论如何,都增加了整体复杂度。 3 具体办法 3.1 报文分页 适用场景:MQ,以及JSF返回大报文响应。 为了保持报文的完整性,也便于消费方实现幂等、集齐等逻辑,需要在报文里额外增加分页信息:currentPage/totalPages。 class Payload { List<Item> items; int currentPage, totalPages; } void sendPayload(Payload payload) { int currentPage = 1; int totalPages = payload.getItems().size() / batchSize; Lists.partition(payload.getItems, batchSize).forEach(subItems -> { Payload subPayload = new Payload(subItems) subPayload.setPageInfo(currentPage, totalPages) producer.send(subPayload) currentPage++; }); } 📌 在极端复杂场景下,也可以考虑分拆topic,但不推荐,因为它可能额外引入乱序问题。 📌 MQ报文编解码除了目前的JSON外,也可以考虑Protobuf等更高效格式。例如京东零售订单快照orderver就由xml升级到了PB。 3.2 报文转存 适用场景:MQ/JSF。 这种方案,也被称为Claim Check Pattern。 把大的明细List,按照固定batch size转存到JFS/OSS/JimKV/S3等外部blob storage,在报文里存放指针(blob地址)列表。 class BigPayload { List<Item> items; } class SmallPayload { List<String> itemBlobKeys; } void sendPayload(BigPayload bigPayload) { SmallPayload smallPayload = new SmallPayload(); Lists.partition(bigPayload.getItems(), batchSize).forEach(subItems -> { List<String> itemBlobKeys = blogStore.putObjects(subItems) smallPayload.addItemBlobKeys(itemBlobKeys); }); producer.send(JSON.encode(smallPayload); } 目前上游系统(eclp、序列号、OMC等)、DTC、下游系统(各版本WMS)的信息传递使用了该办法,共用一个JFS集群。 📌 Side effects:1)引入额外依赖,而且消费方被迫引入依赖 2)需要Blob存储的TTL机制或定期清理,否则加大存储成本 3)为消费方带来了不确定性,从blob拿回的数据可能超大,在反序列化和处理过程中有OOM/FullGC等风险(虽然一些json库提供了底层的基于词法token的Streaming Parsing API,但如果要读取全部内容仍然耗费大量内存) 3.3 报文截断 适用场景:大字段。 在确定用户体验可以接受的情况下,上层进行字段内容截断(truncate)。及早截断,不要依赖下层数据库的截断机制。 3.4 分页调用 适用场景:JSF。 两种场景:一种是批量接口,即入参是集合,另一种是入参对象里有集合字段。 class FooRequest { @javax.validation.constraints.Size(min = 1, max = 200) private List<Bar> barItems; } interface JsfAPI { // 场景1:批量接口 void foo(@javax.validation.constraints.Size(min = 1, max = 200) List<FooRequest> requests) // 场景2:请求对象里有集合字段 void bar(FooRequest request); } 对于JSF Consumer,可以通过JSF异步调用,它相当于redis pipeline模式,也可以通过客户端线程池并发调用方式实现分页调用,二者耗时相同,推荐使用前者:1)代码实现简单 2)节省了额外线程池成本。 int maxJsfRetries = 3; // JSF async下的自动重试只能应用层自己做了 int retried = 0; do { List<ResponseFuture<Result<ObLocatingResultDto>>> futures = new LinkedList(); Lists.partition(voList, batchSize).forEach(subVoList -> { ObLocatingOrderDto dto = mapper.INSTANCE.toDTO(subVoList); locatingAppService.outboundOrderLocate(dto); // async JSF call ResponseFuture<Result<ObLocatingResultDto>> future = RpcContext.getContext().getFuture(); futures.add(future); }); for (ResponseFuture<Result<ObLocatingResultDto>> future : futures) { try { Result<ObLocatingResultDto> result = future.get(); } catch (RpcException jsfException) { retried++; } catch (Throwable e) { // 额外的业务逻辑:与JSF并发同步调用相同的处理逻辑 } } } while (retried <= maxJsfRetries); 📌 JSF异步调用时,jsf:consumer配置的retries无效,这是因为异步发送后如果出现网络超时,只能由业务代码通过future.get()才能拿到结果,JSF底层没有机会进行自动重试。而同步调用时,JSF底层可以判断出超时,它有机会根据配置进行自动重试。更多细节可以查看JSF的FailoverClient.doSendMsg方法。 3.5 MQ替代JSF 适用场景:单向通知类请求,相当于AsyncAPI。 大的报文往往意味着更长的处理时长,JSF同步调用下consumer必须同步等待provider端的返回,这会同时占用consumer和provider双方的线程池资源,极端情况下可能导致双方线程池用尽。JSF下可能耗尽线程池,进而拖死被强依赖的上游,产生雪崩效应;而MQ下,只会消费积压。 异步交互,使得上游对下游响应时间的依赖转换为吞吐率的依赖。JMQ实现了消费者和生产者在时间和空间上的解耦,消息的消费者可以承受更大范围的处理速度范围。 3.6 总结 4 最佳实践 4.1 单个接口与批量接口分离 根据sku编号查询商品资料,往往伴随着多个sku一起查询的需求,如何设计接口? 有的这样: interface JsfAPI { Result<SkuInfo> getSkuInfo(String sku); Result<List<SkuInfo>> listSkuInfo(List<String> skus); } 由于批量接口在技术上已经满足了单个查询的功能,有的团队干脆去掉了单个查询接口,造成使用者查询单个sku时: Result<SkuInfo> result = jsfAPI.listSkuInfo(Lists.newArrayList("EMG1800752592")); 应该这样: interface JsfAPI { Result<SkuInfo> getSkuInfo(String sku); } interface JsfBulkAPI { Result<List<SkuInfo>> listSkuInfo(List<String> skus); } 4.2 线程池隔离 JsfAPI与JsfBulkAPI把批量与单一接口进行分离后,可以分配到不同的线程池,尽可能互不干扰,这同理于Bulkhead Pattern。 单一接口 批量接口 处理关键业务,SLA要求更高 风险高,性能差 JSF可以通过jsf:server定义线程池,并为jsf:provider分配不同的server。 4.3 大报文与小报文分离 如果大报文实在无法拆分(例如,上游团队不配合),为了降低极端请求对绝大部分正常请求的影响,可以采用大小报文分离的办法。 对于JMQ,为了防止某一个大报文的消费长耗时或异常导致小报文的消费积压,可以把大报文转发到“慢队列”进行消费。 此外,也要考虑如何缓解UMP监控失真问题。 4.4 JMQ设置合理的批量大小 该值决定了MessageListener.onMessage入参messages的size。 interface MessageListener { void onMessage(List<Message> messages) throws Exception; } JMQ Consumer的ACK是以批为单位的,例如设置为10,则10条消息里任意一条产生异常都会导致10条全部重新消费。大报文场景下,如果发现问题,可以把该值调整为1,避免大小报文相互影响。 大批量消费主要有两个好处:1)压缩效果好(JMQ在发现报文超过100B时就进行压缩),TCP I/O性能高 2)降低获取消息的等待耗时,因为它相当于prefetch(具体原理是LinkedBlockingDeque的capacity,如果拉取的消息数超过它,则IO阻塞以防止拉取新消息)。同时它也有两大负面效应:1)ACK以批为单位,一个错误导致整批错误,整批重试 2)消息大小限制取决于整批所有消息大小,可能触发大报文问题。 对于京东物流绝大部分业务系统来讲,这点提升与繁重的业务处理来比不值一提,例如:I/O节省了5ms,但单个消息处理需要200ms(因为要通过接口查询,处理,然后写库),反倒是side effect成为主要矛盾。因此,绝大部分场景下该值应该设置为1。如果业务逻辑类似于集齐:把N个消息拿下来,本地缓冲暂不处理,等满足条件了再merge并一次性处理,那么可以调整批量大小为非1。 JMQ Producer提供了批量发送方法: interface Producer { void send(List<Message> messages) throws JMQException; } 我们的业务代码也在使用,例如: /** * 发送分播结果消息 */ public void send(List<CheckResultDto> checkResultDtos) { List<Message> messageList = Lists.newArrayList(); for (CheckResultDto checkResultDto : checkResultDtos) { String messageText = JmqMessage.createReportBody(checkResultDto.getUuid(), Lists.newArrayList(checkResultDto)); messageList.add(JmqMessage.create(topic, messageText, checkResultDto.getUuid(), checkResultDto.getWarehouseNo())); } producer.send(messageList); } 这里要注意,分批发送时,1)发送的超时(默认2s)作用于整批消息,而不是单个消息 2)消息大小限制(4MB)作用于整批消息之和,因此批包含的消息越多越可能失败。 4.5 避免大日志 尤其是AOP/Interceptor/Filter等统一处理的代码,因为对报文的打印往往需要先json序列化。 if (logger.isInfoEnabled()) { log.info(JsonUtil.toJson(request); // CPU intensive and disk I/O intensive(虽然日志是顺序写) } 如果确实要记录,也可以考虑采样率方式记录大报文日志。 4.6 显式约束由严开始 开放API由于消费方多而且不确定性高,客观上造成了“只有一次做对的机会”。 List size limit, property max length limit等,要在开放API的第一时间公布出去。如果开始不约束,后期加约束可能遭遇大的阻力和沟通成本。此外,遵循从严开始的规律,为自己争取主动:你把限制放开,没人找你岔,反之则阻力大。例如:order.items max size limit由100变成200,你可以放心地做;但由200变成100,你要征得现有使用者的全部确认。 例如,Amazon FBA的SP-API对集合的条数限制绝大部分是50。 5 治理机制 5.1 识别大报文场景 无论采用哪种大报文问题解决办法,识别出大报文场景是前提。 技术上,可以通过JSF Filter分析报文长度,把尚未触发8MB但有潜在风险的自动识别出来。但JMQ无相关机制,业务系统要自行实现相关拦截机制。 5.1.1 JSF自动识别 provider端自动识别即可。 @Slf4j public final class PayloadSizeFilter extends AbstractFilter { private static final int PAYLOAD_SIZE_THRESHOLD = 4 << 20; // 4MB = 8MB(JSF限制) * 50% private static final int BATCH_SIZE_THRESHOLD = 1000; @Override public ResponseMessage invoke(RequestMessage requestMessage) { if (!RpcContext.getContext().isProviderSide()) { // 只在provider端检查大报文:它才是我们要保护的对象 return getNext().invoke(requestMessage); } // 自动识别潜在的大报文场景:针对报文大小 Integer payloadSize = requestMessage.getMsgHeader().getLength(); if (payloadSize != null && payloadSize > PAYLOAD_SIZE_THRESHOLD) { // 这里使用最简单的日志把潜在大报文暴露出来,各团队可以做更细化的机制 // 由于logbook限制只有error level日志才能配置"关键字报警",这里使用log.error // 如果不想自动报警,只是人工巡检,可以log.warn String methodName = requestMessage.getMethodName(); String className = requestMessage.getClassName(); log.error("Suspected BIG payload: {}.{}, {}>{}", className, methodName, payloadSize, PAYLOAD_SIZE_THRESHOLD); } // 自动识别潜在的大报文场景:报文字节小,但仍会导致处理慢,例如 List<String> orderNos,如果发来1万个单号? // 这里只能识别出入参是List的场景,对于字段类型是List的场景无效 Invocation invocation = requestMessage.getInvocationBody(); Class[] argClasses = invocation.getArgClasses(); Object[] args = invocation.getArgs(); for (int i = 0; i < argClasses.length; i++) { Class argClass = argClasses[i]; if (Collection.class.isAssignableFrom(argClass)) { // 入参类型是Collection Collection collection = (Collection) args[i]; if (collection.size() > BATCH_SIZE_THRESHOLD) { log.error("Too BIG Collection argument: {}>{}", collection.size(), BATCH_SIZE_THRESHOLD); } } } return getNext().invoke(requestMessage); } } 5.1.2 JMQ自动识别 在consumer端加自动识别,如果发现,协同producer方确认风险判断是否需要改造。 public interface BigPayloadTrait extends MessageListener { int THRESHOLD_BIG_PAYLOAD = 2 << 20; // 2MB = 4MB(JMQ限制) * 50% default boolean suspectedBigPayload(List<Message> messages) { for (Message message : messages) { if (message.getSize() > THRESHOLD_BIG_PAYLOAD) { return true; } } return false; } } 5.2 有效的监控 人工识别会有遗漏场景,关注监控全局指标,尤其是分析一些跳点,可能补充发现大报文场景。 5.3 设计应急预案 有些大报文问题,可能暂时无法通过技术手段解决,例如,已经有商家接入的对外接口,开放时没有对List size限制,加限制后需要商家配合修改做客户端分页,而商家不配合。这时候,可以采用大促期降级,限流,加开关,加强监控,设计应急预案,为此接口提供独立的线程池来隔离正常请求等手段解决。 5.4 常态化的大报文捣乱演练 以第三方视角帮助识别出尚未识别的大报文场景,不要自己给自己捣乱。 5.5 团队执行 推进大报文治理工作时,为了便于项目追踪管理,可以采用如下流程。 5.5.1 新的API和MQ 这里也包括现有API/MQ上加字段场景。 设计和评审时,检查: 字段长度,在上下游上长度对齐 JSF接口对List等集合类型加@Size显式约束和校验,对List性批量接口入参也加@Size MQ Producer确保不发出大报文 5.5.2 现有系统治理 为所有JSF和MQ加入大报文预先监控机制(具体可参考【5.1 识别大报文场景】,根据是否改得动做相应的治理动作。 作者:京东物流 高鹏 来源:京东云开发者社区自猿其说Tech

优秀的个人博客,低调大师

每日一博 | 重构这件 “小” 事儿

本文以一个Web项目的业务代码重构实践作为依据,来探讨项目业务代码重构过程中遇到的开发问题,以及重构过程中的一些注意点,希望可以给项目开发和服务开发维护重构提供一些通用的参考与思路。 这里不探讨大型项目的重构实践,毕竟一个大型项目的重构,更偏重于架构体系完善更新与业务领域拆分,它所涉及的架构体系、人力资源、部门协调等等其他问题都具有很大的挑战。另外大部分开发所负责的仅是其中的一个服务或者模块,这里探讨的内容可能对拆分后的服务重构更具参考意义。 1.项目代码重构的背景 1.1 背景问题 2022年年初,我们小组接手了一个已经开发五六个月的项目,该项目正处于快速迭代时期。我们本以为迭代时间不长,接手之大概能很快上手轻松切入业务,但往往我们提到这个“但是”的时候,紧接着就是反转,理想和实际还是有差距的。众所周知,一般快速迭代赶进度的项目,都会存在或多或少的问题。我们遇到的这个项目也正好精准的在这个范围之内。我们在接手后第一次版本迭代中,已经提前考虑对项目不熟悉的情况,并做了一定的准备,但依然有不少非预期的情况出现。这一次的情况,让我们不得不再次提前评估以后迭代可能会遇到的问题! 1.2 需要改变现状 项目第一个版本迭代就出现难以预期的问题,这不合理!在解决完当前版本已经发现的问题之后,我们花费一些时间去大概梳理了一下原来的项目代码,看了不少接口的大致实现。本身看看代码这是个小事情,但是这一看不要紧,好多的接口实现的逻辑都存在问题(比如在循环中调用数据库查询,多层次无效缓存实现,缓存淘汰机制复杂性能差等等... ),这些问题涉及性能、稳定性、业务异常等多个方面。如果不去解决,除了影响用户体验,还会给我们的正常项目迭代和维护造成了极大的干扰!这个项目是用Python开发的内部项目,该项目本身是作为toB类型的内部工具来开发的。而目前因为项目业务场景的扩展,要越来越多的承担toC的功能,在得物App中使用场景也同样增加,这对项目性能和稳定性又带来了额外的挑战。我们迫切需要解决这些不稳定因素,快速的切入业务,开展正常的业务迭代,以满足需求的变化。 1.3 重构的初步想法 对于那些已经发现的问题,如果可以快速修复的,我们都进行了修复并验证发布,也明显取得了一些效果。但是还有不少有共性的大类问题充斥在代码中,使我们不能轻易的去对现有的代码动刀,这些问题也是亟待解决的。当时,正好结合公司部门技术栈统一(业务项目转为使用Java/Go语言)的要求,我们决定在切入业务的过程中,逐渐通过重构迭代,来提升性能,减少问题,并接入公司的技术基建体系,降低代码的维护成本。虽然有了初步的重构想法,但是这显然不是一件容易的事情! 重构之前,我们还有很多前置工作要做。 2.重构的前置工作 2.1 熟悉业务流程并分析问题与痛点 在正式重构之前,我们浏览了项目的交接文档,往期的产品文档,以此做到对项目整体产品流程做到心中有数。而后,我们根据现有的产品流程,评估了从头开发该项目应实施的主要架构和大致技术方案,经过评审完善再作为重构参照,这相当于给项目的整体重构优化提供了一个目标样板。用以上的方案作为参考的话,再分析整个产品流程闭环上的接口,我们很快可以定位到项目中存在的一系列问题,当然这些问题大多数都是表面的和宏观的,细节上的很多还需要我们在项目中进一步挖掘。 2.2 评估重构成本与重构推进方式 我们作出重构决定的时候,已经做完了现有项目的基础分析。现在我们将对比项目方案的差异,根据重构的难易程度和紧急程度来确定最终的处理方式。 难易程度 根据 方案差异、修改难度、稳定要求、影响范围来 综合评估紧急程度 根据 迭代复用、接口性能、异常频次、受益范围来 综合评估 处理方式分析如下表: **难度 **紧急程度 紧急 中等 不急 简单 重构 重构,顺序次后 重构,顺序再次 中等 重构 重构,顺序次后 重构,顺序再次 困难 重构( 或迁移观察 ) 重构( 或迁移观察 ) 待定 由于我们整体项目处于快速迭代当中,并且有限的人力都要去跟版本需求,所以重构迁移的时间是极度受限的,我们虽然定了模块的重构顺序,但是怎么抽调人力去跟进这些事情呢?需求迭代本身就涉及到大量的接口,如果我们本身已经将新的项目构建起来,这里迭代的接口顺势就可以迁移重构到新的项目代码库中去,在迭代的同时推进项目的重构。其他时候再加上一些零散的时间,我们可以逐步的将重构向前推进,但是在这种情况下,我们一定要接受渐进重构的时间跨度会很长这个实际情况。 2.3 完善并确定流量迁移方案 大多数WEB项目基本都会有这些通用架构,如下图所示: 在这样的通用架构里,我们既然切换了底层语言,那公司基建支撑的相应架构,就可以用起来了。 我们最终选择的具体流量迁移方案如下: 初始化新的项目仓库并完善发布部署流程 打通新老两个项目的用户认证方式并做到双向兼容(如果网关统一承接用户认证,可以省去这一步) 利用网关层的能力去配置转发规则,使用特定或者通用规则将接口流量导入新项目中(如果没有网关可以考虑简单接入Nginx配置转发) 服务端完成一个批次的接口重构迁移后,在测试环境切换流量到新项目并测试整体流程,通过测试再发布 重构中部分接口需要变更的,推进前端将调用切换到新接口 上线后跟踪流量与新接口功能状态,如有问题随时回滚 到目前为止,我们已经做好了所有的前置工作,那么现在我们 ready go ! 3.重构中发现的典型问题与优化方案 3.1 基本运维监控体系完善 在完善运维体系方面,先统一整合了trace、日志、监控与告警。 优化点 问题 收益 典型思想 全面接入trace 无链路追踪功能 做到全链路监控 链路追踪 完善日志等级 日志分级不够完善 分级日志便于排查 日志系统 日志注入trace 日志无trace不方便关联 日志有链路追踪 问题日志追踪 metric 无监控信息 监控项目程序实时状态 metric 目前log、trace、metrics三者的整合打通也显得尤其重要,例如 OpenTelemetry 这样的工具提供成套的规范,可以让开发者快速集成。在企业基建可以支撑的情况下,可以选择这三者,如果不能支持,推荐按照以下顺序完善 日志、trace、日志注入trace信息、metric 整个体系。 看一下下面的实例展示: trace信息可以帮助我们追踪每一次的调用链路 日志注入trace信息可让我们对独立调用链路日志做快速筛选和时间维度分析, 根据traceId追踪同一条链路数据 { "level":"error", "ts":"2022-07-22T21:26:00.073+0800", "caller":"api/foo_bar.go:38", "msg":"[foo]bar", "error":"Post "https://xxx.com/abc/xxx": context deadline exceeded", "traceId":"0aee15dc63f617e751d17060xcf74b9c", "content":{ "body":"json str", "resp":"" } } 监控体系监测业务项目运行状态 3.2 重整业务逻辑 业务逻辑设计中,有一些问题对开发迭代有很大的阻碍,大概如下: 优化点 问题 收益 典型思想 展示接口的数据写入逻辑迁移到数据写入接口非要不可则缓存中转 部分展示接口有写数据逻辑导致数据无限增长展示接口性能也差 削减无效数据写入提升查询性能 大部分系统都是读大于写读接口不做写逻辑避免无效数据写入 计数逻辑独立并使用缓存使用异步方式入库 计数逻辑依赖明细统计性能差需要独立计数并展示 释放数据库统计压力提升数据展示性能 预计算代替实时统计缓存代替DB查询 迁移信息表的计数字段独立为单表并先缓存 部分计数字段在基础表更新TPS高影响表结构变更 削减数据库TPS压力隔离冷热数据 冷热数据隔离 双向反查数据使用独立表替代简单的jsonStr 双向关联的数据存储格式不好无法满足双向查询和变更更新写入难度过大 减少数据更新的交叉关联并提升查询性能 合理设计数据表(三大范式)简化双向关联的模型独立管理关联关系 改造不合理的缓存体系逐步精简缓存替换缓存结构和数据 缓存体系设计不合理相关缓存使用场景多流程长难以一次性迁移 清理无效缓存减少缓存回收难度提升缓存使用效果 缓存结构选择合理使用淘汰策略 业务流程从管理后台的增删改查,到客户端的展示并回收数据,这整个流程中,很容易出现一个问题点就会影响全流程的情况。以上几个问题在变更的过程中,也是会对整体业务流程有贯通影响的,特别需要注意,所以单列了出来!这几个点也是改造起来比较困难的,我们也是根据紧急程度以及整体的梳理进度进行逐步重构。 3.3 代码重构并解决细节问题 在具体的每一个接口或者任务脚本的重构过程中,我们也总结了之前开发遇到的一些典型问题,整理如下: 优化点 问题 收益 典型思想 数据库查询网络 I/O 优化 列表类接口 循环网络I/O 减少各类列表接口 RT 最小化网络 I/O 削减接口返回字段 接口返回无效字段过多 减少干扰、削减流量 减少网络带宽占用 统一返回值字段数据类型 弱类型语言类型乱用 统一数据类型,便于管理 强类型 分类树递归生成优化单次查询并重构复用 循环多层查询数据库 I/O过多生成算法复杂度略高 统一分类树生成集成过滤规则 O(n)时间复杂度,递归并防止数据异常而死循环 非强关联逻辑功能拆分 部分接口业务逻辑隔离但是接口强相关 剥离不同模块为多个接口 分治 解决语法类问题 循环迭代中改变SET数据类型不匹配数据存储格式 等(Python) 提升主流程稳定性杜绝答案提交异步流程中断 数据与语法兼容 抽离相同逻辑达成方法重用 相同逻辑方法分散不兼容部分逻辑缺失、维护难度高 复用方法与逻辑统一逻辑并降低维护难度 复用 离线数据同步优化 全量同步数据未分批不支持断点补偿 限制批次数量分批同步支持中断恢复 分批、控制上限中断补偿 优化批量导入数据处理 业务导入数据处理逻辑有问题关联比对多次查询且不用MAP 减少算法时间复杂度提升处理速度 批量提取MAP对比的O(1)复杂度 Redis慢查询优化低效缓存优化清理或拆解大KEY 未选对正确的数据结构未对数据结构选择正确操作方法例如:存储set数据取单个元素使用 smembers 命令读取后比对,而不是使用 sismember 部分smembers读取切换为sismember,RT>50ms查询平均减少5个/秒, 优化完几乎无RT>100ms请求,提升吞吐效率显著**** 数据结构算法复杂度Redis单线程模型阻塞 缓存淘汰机制优化 部分缓存无有效淘汰机制代码SCAN淘汰管理难性能差 合理设计利用Redis自动过期机制 合理利用缓存淘汰机制 SQL索引调优 低效索引,交叉索引干扰 提升查询性能 索引调优 索引覆盖查询 异步消费脚本优化 MQ消费任务的幂等、事务性、补偿 逻辑需要确认 防丢、防重、补偿处理 幂等、事务 等 配置迁移到配置中心 部分配置硬编码需要迁移 统一管理配置代码不包含敏感信息 分环境隔离配置隐藏重要信息 只有生产者无消费者队列待处理 生产向无消费队列无限注入数据导致队列无限膨胀 打通生产消费流程取消无用队列减少空间占用 队列有生产必有消费 以上这些典型问题都是经过提炼总结过后的了,看起来只是有限的几个,但项目代码库中,每一个问题点都可能出现数次甚至数十次,所以才不得不将整体的代码都重构。 下面列举一些实际的例子: 列表类接口循环网络I/O的优化 // 这里代码就不贴了,我大概做个说明: // 数据库ORM使用时候处理粗糙,额外写的GET方法,独立查询关联表 // 在列表接口中循环获取该数据即造成数据库网络I/O的循环调用 // 而通过ORM预加载的方式,则是削减查询并二次组装的数据 // 当然我们也可以自己查询列表数据,然后先遍历提取外键,将关联数据转换为map接口,再次遍历列表来组装结果数据 大量数据处理的时候分批操作 // 大量数据处理分批次进行 // 一是考虑内存用量、二是考虑I/O交互流量、三是考虑处理分块时间、四是考虑中断恢复 // 例如: 从数据库批量获取数据,然后批量上传到某处 // 注意: 分页批处理需要保证每一页数据不变化,否则会有错漏或者重复 page := 1 pageSize := 10000 for { // 判断已经存在的断点数据,将条件恢复成断点条件 // 直接 模拟查询的结构列表 dataList := make([]itemStruct{}, 0) // 此处处理分批后的业务逻辑 ...... // 处理完当前批次可以记录断点 // 断点可以缓存在数据库、Redis等其他媒介中 // 分页查询,查不到数据或者低于pageSize可以当成数据处理完了 if (len(dataList) < pageSize) { break } // 查询条件切换为下一页 page += 1 } Redis主从版大Key导致的慢查询优化前后对比 合理利用Redis的缓存淘汰机制(杜绝掉SCAN扫描的方式来淘汰KEY) 敏感数据转移到配置中心(例如这种硬编码的配置,需要迁移走) 4.重构经验总结 4.1 重构的成果收益 经过接近一年的渐进重构,我们大概完成了80%以上的功能模块,解决了以上提到的绝大部分问题,并且取得了很不错的提升。主要表现在以下几个方面: 已经全线接入trace、log、metric和告警,并可以根据这几个工具来指导项目维护和优化 业务稳定性明显提升,从刚接手时候的经常报错修BUG到现在几乎没有问题 不再有循环数据库查询I/O ,除了报表类接口,基本杜绝慢查询 部分重点接口的性能提升明显,有一些后台接口极端RT值从10s以上压缩到1s以内,C端主要接口RT的99线在150ms内(非高并发设计场景,勿喷) 可复用逻辑已尽量整合,完善并统一了之前不一致的业务逻辑,维护难度明显降低 长链路的数据提交整体流程无丢失数据的异常发生 业务方使用满意度提升 另外我们因为资源限制,截止目前,并未完成所有的重构工作,后续如果有资源投入,我们会继续推进这些问题的处理。 4.2 人员技能要求 我们也分析了本次重构对于开发人员的一些技能要求。在所有参与的开发人员都经验比较足的情况下,基本上可以通过自我驱动以及经验,来发现上面列举的这些问题,并主动去解决,而不需要额外的培训、规范以及纠错。对于经验不是很丰富的开发者,就需要适当的总结和规范去指导作业。抽取上面的问题来具体分析的话,不外乎如下这些技能: 熟练使用当前项目所需要的开发语言,避免产生一些基础的语言问题 对数据库有比较深的了解,能根据实际情况设计比较合理的数据结构并做到适当优化 对常用的中间件使用比较熟悉,了解一些原理,避免在使用的时候只知其一不知其二从而踩坑又难以排查 计算机的基础扎实,操作系统知识,算法与数据结构知识熟悉,可以写高效的代码 了解高并发高可用架构,可以根据一些基本思想来指导开发与优化 4.3 规范执行与Review 除了以上的技能,一些规范的制定与执行也十分重要,不过相反的是,这是自上而下的流程,需要一定的管理层面的推进。规范本身指定了行为边界,完善合理的规范制定之后,只要遵照执行,就可以避免绝大部分问题。另外Review制度可以促进规范的落地,避免空有规范而实践偏离的情况。现行的好设计,在经过一定的时间之后,随着业务需求变化以及用户量的提升,或许又需要开启新一轮的优化或者重构,这也是正常的。 5.让重构成为“小”事 5.1 任务阶段化来变“小”当前事项 纷繁复杂的整套重构流程,如果混在一起,可能会让人望而却步。但是通过具体分析,阶段拆解的方式,将任务切割成一件件“小”事情,可以让我们用比较容易的方式去一步步解决问题。另外拆解不是无脑拆分,还是需要有一个整体上的架构设计,否则可能会导致整个重构任务不能达到目标,既延长了时间,又难有成效。重构过程的质量把控,可以通过规范、强制lint检查、Review、单元测试、性能测试 等方式去保障,这在每一阶段都是要贯彻执行的。 5.2 开发的几个主要思路 其实还有很多我们项目中没有出现的问题,恕我们不能一一列举。但是在开发过程中本着几个主要的思路,我们就可以设计并开发出完善且高性能的项目。例如: 最小维护难度:系统设计结构完整、逻辑算法简洁高效 单个接口最小RT:接口性能要高,RT尽可能的小 最小限度的数据交互:接口请求参数以及返回值尽量精简,以节约网络带宽 异步削峰限流等:使用异步方式剥离额外逻辑,提升接口性能并提升用户体验 最少访问频次:了解计算机各个硬件以及网络I/O的性能层级,尽量减少长耗时的I/O,转换为程序内部处理 最少数据写入:数据写入需要尽量削减,减少流量以及无效数据的产生 缓存与缓存一致性:多级缓存、缓存一致性、缓存淘汰策略等思路 分治与隔离:该思想除了在拆分资源上体现(对象储存CDN剥离图片文件等内容),还可以在业务模块上体现出来(界定业务边界,合理拆分具体的模块与流程) 高可用性:注重稳定性,整体项目流程稳定无差错,降级与灾备需要提前考虑 5.3 提升技能与经验积累 当然,并不是掌握上面说的这些思路就高枕无忧了。例如:我们重构的项目之前就有通过异步方式来处理问题,但这个异步是因为本身接口未设计好导致性能受限,所以不得已采取的方案。本身一些长异步流程带来的数据不一致也会产生不少问题,在我们重构接口之后,就着手取消了这些异步任务,将其做成事务流程,显然就比之前的功能要好很多。所以我们还是要加深自己在计算机基础知识层面的认知,以及拓展完善开发知识体系,成体系的掌握高可用高并发系统设计,学习积累和总结开发经验,才能让自己在项目程序设计开发中游刃有余。 本文属得物技术原创,来源于:得物技术官网 得物技术文章可以任意分享和转发,但请务必注明版权和来源:得物技术官网

资源下载

更多资源
腾讯云软件源

腾讯云软件源

为解决软件依赖安装时官方源访问速度慢的问题,腾讯云为一些软件搭建了缓存服务。您可以通过使用腾讯云软件源站来提升依赖包的安装速度。为了方便用户自由搭建服务架构,目前腾讯云软件源站支持公网访问和内网访问。

Nacos

Nacos

Nacos /nɑ:kəʊs/ 是 Dynamic Naming and Configuration Service 的首字母简称,一个易于构建 AI Agent 应用的动态服务发现、配置管理和AI智能体管理平台。Nacos 致力于帮助您发现、配置和管理微服务及AI智能体应用。Nacos 提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据、流量管理。Nacos 帮助您更敏捷和容易地构建、交付和管理微服务平台。

Spring

Spring

Spring框架(Spring Framework)是由Rod Johnson于2002年提出的开源Java企业级应用框架,旨在通过使用JavaBean替代传统EJB实现方式降低企业级编程开发的复杂性。该框架基于简单性、可测试性和松耦合性设计理念,提供核心容器、应用上下文、数据访问集成等模块,支持整合Hibernate、Struts等第三方框架,其适用范围不仅限于服务器端开发,绝大多数Java应用均可从中受益。

Rocky Linux

Rocky Linux

Rocky Linux(中文名:洛基)是由Gregory Kurtzer于2020年12月发起的企业级Linux发行版,作为CentOS稳定版停止维护后与RHEL(Red Hat Enterprise Linux)完全兼容的开源替代方案,由社区拥有并管理,支持x86_64、aarch64等架构。其通过重新编译RHEL源代码提供长期稳定性,采用模块化包装和SELinux安全架构,默认包含GNOME桌面环境及XFS文件系统,支持十年生命周期更新。

用户登录
用户注册