首页 文章 精选 留言 我的

精选列表

搜索[思考模型],共10000篇文章
优秀的个人博客,低调大师

h2database BTree 设计实现与查询优化思考 | 京东云技术团队

h2database 是使用Java 编写的开源数据库,兼容ANSI-SQL89,既实现了常规基于 BTree 的存储引擎,又支持日志结构存储引擎。功能非常丰富(死锁检测机制、事务特性、MVCC、运维工具等),数据库学习非常好的案例。 本文理论结合实践,通过BTree 索引的设计和实现,更好的理解数据库索引相关的知识点以及优化原理。 BTree 实现类 h2database 默认使用的 MVStore 存储引擎,如果要使用 基于 BTree 的存储引擎,需要特别指定(如下示例代码 jdbcUrl)。 以下是常规存储引擎(BTree 结构) 相关的关键类。 org.h2.table.RegularTable org.h2.index.PageBtreeIndex(SQL Index 本体实现) org.h2.store.PageStore(存储层,对接逻辑层和文件系统) BTree 的数据结构可以从网上查到详细的描述和讲解,不做过多赘述。 需要特别说明的是:PageStore。我们数据查询和优化关键的缓存、磁盘读取、undo log都是由 PageStore 完成。可以看到详细的文档和完整的实现。 BTree add index entry 调用链 提供索引数据新增的调用链。同样的,索引的删除和查询都会涉及到,方便 debug 参考。 org.h2.command.dml.Insert#insertRows (Insert SQL 触发数据和索引新增) org.h2.mvstore.db.RegularTable#addRow (处理完的数据Row, 执行新增) org.h2.index.PageBtreeIndex#add (逻辑层增加索引数据) org.h2.index.PageDataIndex#addTry (存储层增加索引数据) org.h2.index.PageDataLeaf#addRowTry (存储层新增实现) // 示例代码 // CREATE TABLE city (id INT(10) NOT NULL AUTO_INCREMENT, code VARCHAR(40) NOT NULL, name VARCHAR(40) NOT NULL); public static void main(String[] args) throws SQLException { // 注意:MV_STORE=false,MVStore is used as default storage Connection conn = DriverManager.getConnection("jdbc:h2:~/test;MV_STORE=false", "sa", ""); Statement statement = conn.createStatement(); // CREATE INDEX IDX_NAME ON city(code); 添加数据触发 BTree 索引新增 // -- SQL 实例化为:IDX_NAME:16:org.h2.index.PageBtreeIndex statement.executeUpdate("INSERT INTO city(code,name) values('cch','长春')"); statement.close(); conn.close(); } Code Insight 结合上述的示例代码,从索引新增的流程实现来了解BTree 索引的特性以及使用的注意事项。从底层实现分析索引的运行,对 SQL 索引使用和优化有进一步认识。 表添加数据 public void addRow(Session session, Row row) { // MVCC 控制机制,记录和比对当前事务的 id lastModificationId = database.getNextModificationDataId(); if (database.isMultiVersion()) { row.setSessionId(session.getId()); } int i = 0; try { // 根据设计规范,indexes 肯定会有一个聚集索引(h2 称之为scan index)。① for (int size = indexes.size(); i < size; i++) { Index index = indexes.get(i); index.add(session, row); checkRowCount(session, index, 1); } // 记录当前 table 的数据行数,事务回滚后会相应递减。 rowCount++; } catch (Throwable e) { try { while (--i >= 0) { Index index = indexes.get(i); // 对应的,如果发生任何异常,会移除对应的索引数据。 index.remove(session, row); } } throw de; } } ① 同Mysql InnoDB 数据存储一样,RegularTable 必有,且只有一个聚集索引。以主键(或者隐含自增id)为key, 存储完整的数据。 聚集索引添加数据 索引中的 key 是查询要搜索的内容,而其值可以是以下两种情况之一:它可以是实际的行(文档,顶点),也可以是对存储在别处的行的引用。在后一种情况下,行被存储的地方被称为堆文件(heap file) ,并且存储的数据没有特定的顺序(根据索引相关的)。 从索引到堆文件的额外跳跃对读取来说性能损失太大,因此可能希望将被索引的行直接存储在索引中。这被称为聚集索引(clustered index)。 基于主键扫描即可唯一确定、并且获取到数据,聚集索引性能比非主键索引少一次扫描 public void add(Session session, Row row) { // 索引key 生成 ② if (mainIndexColumn != -1) { // 如果主键非 long, 使用 org.h2.value.Value#convertTo 尝试把主键转为 long row.setKey(row.getValue(mainIndexColumn).getLong()); } else { if (row.getKey() == 0) { row.setKey((int) ++lastKey); retry = true; } } // 添加行数据到聚集索引 ③ while (true) { try { addTry(session, row); break; } catch (DbException e) { if (!retry) { throw getNewDuplicateKeyException(); } } } } ② 对于有主键的情况,会获取当前 row 主键的值,转为long value。对于没有指定主键的情况,从当前聚集索引属性 lastKey 自增得到唯一 key。 只有指定主键的情况,才会校验数据重复(也就是索引key 重复,自增 lastKey 是不会有重复值的问题)。 ③ 聚集索引 PageDataIndex 按照BTree 结构查找对应的key 位置,按照主键/key 的顺序,将 Row 存储到page 中。非聚集索引 PageBtreeIndex 也是这样的处理流程。 这其中涉及到三个问题: 如何查找 key 的位置,也就是 BTree 位置的计算? 如何计算 Row (实际数据)存储 Page 中的 offsets? Row 是怎样写入到磁盘中的,何时写入的? 索引数据存取实现 B 树将数据库分解成固定大小的块(block)或分页(page) ,传统上大小为 4KB(有时会更大),并且一次只能读取或写入一个页面。 每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在硬盘而不是在内存中。(对应h2 database PageBtreeLeaf 和 PageBtreeNode) 不同于 PageDataIndex ,PageBtreeIndex 按照 column.value 顺序来存储。添加的过程就是比对查找 column.value,确定在块(block)中offsets 的下标 x。剩下就是计算数据的offset 并存入下标 x 中。 /** * Find an entry. 二分查找 compare 所在的位置。这个位置存储 compare 的offset。 * org.h2.index.PageBtree#find(org.h2.result.SearchRow, boolean, boolean, boolean) * @param compare 查找的row, 对应上述示例 compare.value = 'cch' * @return the index of the found row */ int find(SearchRow compare, boolean bigger, boolean add, boolean compareKeys) { // 目前 page 持有的数据量 ④ int l = 0, r = entryCount; int comp = 1; while (l < r) { int i = (l + r) >>> 1; // 根据 offsets[i],读取对应的 row 数据 ⑤ SearchRow row = getRow(i); // 比大小 ⑥ comp = index.compareRows(row, compare); if (comp == 0) { // 唯一索引校验 ⑦ if (add && index.indexType.isUnique()) { if (!index.containsNullAndAllowMultipleNull(compare)) { throw index.getDuplicateKeyException(compare.toString()); } } } if (comp > 0 || (!bigger && comp == 0)) { r = i; } else { l = i + 1; } } return l; } ④ 每个块(page)entryCount ,两个方法初始化。根据块分配和实例创建初始化,或者 PageStore 读取块文件,从Page Data 解析得到。 ⑤ 反序列化过程,从page 文件字节码(4k的字节数组),根据协议读取数据并实例化为 row 对象。参考: org.h2.index.PageBtreeIndex#readRow(org.h2.store.Data, int, boolean, boolean) 。 ⑥ 全类型支持大小比对,具体的规则参考:org.h2.index.BaseIndex#compareRows ⑦ 如果数据中存在重复的键值,则不能创建唯一索引、UNIQUE 约束或 PRIMARY KEY 约束。h2database 兼容多种数据库模式,MySQL NULL 非唯一,MSSQLServer NULL 唯一,仅允许出现一次。 private int addRow(SearchRow row, boolean tryOnly) { // 计算数据所占字节的长度 int rowLength = index.getRowSize(data, row, onlyPosition); // 块大小,默认 4k int pageSize = index.getPageStore().getPageSize(); // 块文件可用的 offset 获取 int last = entryCount == 0 ? pageSize : offsets[entryCount - 1]; if (last - rowLength < start + OFFSET_LENGTH) { // 校验和尝试分配计算,这其中就涉及到分割页面生长 B 树的过程 ⑧ } // undo log 让B树更可靠 ⑨ index.getPageStore().logUndo(this, data); if (!optimizeUpdate) { readAllRows(); } int x = find(row, false, true, true); // 新索引数据的offset 插入到 offsets 数组中。使用 System.arraycopy(x + 1) 来挪动数据。 offsets = insert(offsets, entryCount, x, offset); // 重新计算 offsets,写磁盘就按照 offsets 来写入数据。 add(offsets, x + 1, entryCount + 1, -rowLength); // 追加实际数据 row rows = insert(rows, entryCount, x, row); entryCount++; // 标识 page.setChanged(true); index.getPageStore().update(this); return -1; } ⑧如果你想添加一个新的键,你需要找到其范围能包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以反映新的键范围分区 ⑨为了使数据库能处理异常崩溃的场景,B 树实现通常会带有一个额外的硬盘数据结构:预写式日志(WAL,即 write-ahead log,也称为重做日志,即 redo log)。这是一个仅追加的文件,每个 B 树的修改在其能被应用到树本身的页面之前都必须先写入到该文件。当数据库在崩溃后恢复时,这个日志将被用来使 B 树恢复到一致的状态。 实践总结 查询优化实质上就是访问数据量的优化,磁盘IO 的优化。 如果数据全部缓存到内存中,实际上就是计算量的优化,CPU 使用的优化。 索引是有序的,实际上就是指块文件内的 offsets 是以数组形式体现的。特殊的是,在h2database 中,offsets数组元素也是有序的(例如:[4090, 4084, 4078, 4072, 4066, 4060, 4054, 4048, 4042]),应该是方便磁盘顺序读,防止磁盘碎片化。 理论上,聚集索引扫描 IO 比 BTree 索引要多,因为同样的块文件内,BTree 索引 存储的数据量更大,所占的块文件更少。如果一个table 列足够少,聚集索引扫描效率更高。 建表需要谨慎,每个列的字段长度尽可能的短,来节省页面空间。 合理使用覆盖索引查询,避免回表查询。 如述示例,select id from city where code = 'cch',扫描一次 BTree 索引即可得到结果。如果select name from city where code = 'cch', 需要扫描一次 BTree 索引得到索引key (主键),再遍历扫描聚集索引,根据 key 得到结果。 合理的使用缓存,让磁盘IO 的影响降到最低。 比如合理配置缓存大小,冷热数据区分查询等。 其他知识点 分支因子为 500 的 4KB 页面的四层树可以存储多达 256TB 的数据)。(在 B 树的一个页面中对子页面的引用的数量称为分支因子(branching factor) 。 参考 ddia/ch3.md B树 作者:京东物流 杨攀 内容来源:京东云开发者社区

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

每日一博 | 聊聊各端手势体系以及对 Web 标准手势的思考

「北海 Kraken」是一款基于 Flutter 的 Web 渲染引擎,通过基于 W3C 标准来开发实现前端开发者常用的能力。 Kraken 团队也积极探索定义新的问题以及能力,期望通过参推动标准定制的方式让 Web 技术变得更好。 欢迎大家关注 「北海 Kraken」: http://openkraken.com/ 在过去,早期的 Web 更多用做内容展示的页面,最早从后端框架中直出,再配上各种 CSS 以及 JS 的交互内容,以完成最终对页面内容的展示,那时候的 Web 更多属于 【内容开发】,做内容的直出与展示。而如今现代 Web 开发体系已经有了翻天覆地的变化,早已超出了【内容开发】的范畴,在各个领域都有 JavaScript 的身影。同样,Web 也已经脱离了客户端以及浏览器的限制,各式各样基于 Web 标准或者私有标准的 Web runtime 层出不穷。【Web 应用开发】区别于传统的【内容开发】,它对开发者提出了更高的要求,也对 Web 的能力提出了更高的要求,无论是基于标准化方面的考量,还是基于对易用性的考虑,我们都期望 Web 开发者可以获得通过更高级的封装的标准的高性能的能力。 而手势能力就是其中的一块。 目前在 Web 标准中,手势能力是属于缺失的一块能力,更多的开发者通过 hammer.js 来获得一个通过 JavaScript 模拟出来的手势事件来开发一个手势强交互的应用,或者是直接基于更底层的 Touch event来做进一步的封装。 但是无论是类 hammer.js 的前端手势方案,还是 Touch event的封装,都会导致一些问题,我将从易用性、性能、标准化的角度来做进一步的分析: 易用性: 开发者必须手动去实现或者封装更高一级的手势能力,无法直接从 element 上获得某个高级手势的 event 事件。无论是开发成本,都需要额外加载或者执行额外的 CDN,都是对前端资源的一种损耗。 性能: 通过 JavaScript 实现的方案需要频繁地通过 Bridge 将手势的能力传递到前端,然后再去计算模拟相关的手势事件。频繁的传递数据增加了 Bridge 的消耗,不断执行的 JavaScript 会阻塞 UI 线程,如果需要更强大的手势能力支撑,我们必须进一步封装【竞争场】等能力的实现来达到手势竞争的目的,而这部分能力本应下沉到渲染引擎本身,而不是在 JavaScript 中处理。 标准化: 各个开发者实现的标准不统一,判断的基准不一致,透出的 event 能力不对齐会导致各个平台甚至到各个页面的标准不统一。譬如说在同一个 iOS 设备上访问两个不同开发者开发的页面,不统一的手势能力可能会给用户带来及其糟糕的体验。非标准化的手势能力在各个端上也显得格外突出,下面我介绍各端的手势能力时会介绍这些差异点。 连续手势与离散手势 首先我需要介绍一下连续手势与离散手势的概念,以便读者可以更好地区分这两种手势的不同,以及了解实现不同的手势能力对开发、性能、易用性等纬度的影响。 首先,我们需要知道,由于在端侧有各种各样的屏幕操作的设备,常见的比如说类 apple pencil 的电子笔设备(pen),手指直接触摸操作(touch),还有鼠标(mouse)等。所以在 W3C 标准中, 将所有的接触屏幕的物理设备抽象成了一个 pointer,无论上层是那种物理设备,对于屏幕只感知与抽象这一个触摸到的点,基于 type 区分具体的上层的物理设备。 一个完整的手势包含了手指开始接触屏幕(pointer down),然后手指在屏幕上进行偏移(pointer move),以及手指抬起离开屏幕(pointer up),暂时不讨论 cancel、out 等情况。当然,其中中间 pointer move 的过程是可以省略的,最常见的省略 pointer move 的手势譬如说 click 或者 long press 等(当然,如果点击设备不是一个鼠标而是一根手指,其实手指实际接触是肯定会产生轻微移动情况的,譬如在 FLutter 中,允许这个细微的移动距离在 18 个像素点内,即视为不移动)。可以预见的是未来会有更多的物理设备操作屏(甚至不是屏),基于底层触摸点的 pointer 抽象有利于上层做更多的扩展。 了解了这些,接下来我们来了解一下连续手势与离散手势的差异。 连续手势:从 pointer down 到 pointer moves 到 pointer up,中间过程可以通过 state 状态来描述的手势,可以清楚地通过不同的回调或者不同的状态让开发者感知目前手势所处的状态的手势。常见连续手势:pan。 离散手势:完整手势触发完毕后才会通过回调来通知开发者,无中间状态的转换。常见离散手势:click。 连续手势会频繁通过回调或者状态来通知开发者目前手势所处的状态,我们来看一种情况: element.addEventLisenter('pan', (gestureEvent) => { if (gestureEvent.state === 'up') { // do something... } }) 假设我们需要在 Web 标准中实现 pan 这个手势,如果它是一个连续手势,而我们的场景只需要用 up 这种状态,就需要不断地将当前的状态通过 Bridge 以及 JS engine 传递到 JavaScript 中,这频繁的传递开销是对设备性能的一种浪费。当然,也有框架方案通过更加细分的粒度去解决这个事情,譬如说拆分成 panstart、panupdate、panend等,当开发者不给这些方法注册回调时,可以在框架内部判断并做相应优化。然而细分的 API 抽象不够底层,对于开发者来说也并不那么友好。 而对于离散手势,我们则不需要考虑手势过程中的状态传递,只需要把最终的结果返回给开发者即可,离散手势屏蔽了许多内部处理的细节,保证了开发者注册的回调只能完整的手势操作完以后才能被命中。有效地降低了连续手势数据的传递量。但是相较于连续手势,离散手势的缺点是开发者无法很好地感知中间状态。 接下来我们来看一下各个端上实现的手势体系、优缺点以及差异性。 各端手势体系 hammer.js Pan Pan 的需要有一个最小距离以及方向,只要达到这个条件可以频繁触发。 详见:https://github.com/hammerjs/hammer.js/blob/master/src/recognizers/pan.js#L42 Swipe 距离与速度达到一定阈值的滑动。 详见:https://github.com/hammerjs/hammer.js/blob/master/src/recognizers/swipe.js#L36 Pinch (向外捏时放大,向内捏时缩小) Press (长按,默认时间在 250 ms 以上 ,替代了 long press) 详见:https://github.com/hammerjs/hammer.js/blob/master/src/recognizers/press.js#L78 hammer.js 作为一个前端实现的 gesture lib,通过注册 Touch 事件做封装来完成具体的操作的判断,在前端做手势的方案在前面已经提过,需要不断地通过 Bridge 以及 JS engine 传递到 JavaScript 中,然后才能最终在 JavaScript 中处理手势操作,只要有操作就会被抛到 JavaScript 中进行处理,频繁的传递耗费了许多不必要的性能。我们更希望这部分能力可以下沉到渲染引擎本身,这样可以节省非常多不必要的数据传递开销。 如果在基础手势判断之上想进一步引入更加复杂的【竞技场】等能力,这部分会使得 JavaScript 中的逻辑更加复杂,即便抛开“能不能”在前端做相关实现来说,过多的 JavaScript 运行占用计算资源也是我们并不想要的。 同时,需要单独引入一个 CDN 脚本来支持相关的功能,对于包体积以及首屏也增加了额外的成本。但是又考虑到本身浏览器并不自带这些功能,一般开发者也无法很好地将这套方案优化并下沉到浏览器中,所以在反而在大部分前端业务场景成为来较优的技术选型。 Flutter Tap Double tap Long press (500 ms 以上的长按) Vertical(Horizontal) drag 横(纵)滑 在 drag 上做了进一步封装,在 x 轴或者 y 轴偏移超过最小距离并达到阈值速度可触发。 scale scale 会包含放大缩小以及旋转的手势,相当于其他端中的 Pinch + Rotation Pan Pan 内部实现,需要达到一个最小速度以及最小移动距离的 drag 才能触发 Pan,Pan 是基于 drag 之上的封装,增加了判断。Flutter 内置一个 pointer down + pointer moves + pointer up 只能触发一次手势,所以 Pan 只能触发一次(与 hammer 不同,为有状态手势) 详见: https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/gestures/monodrag.dart#L579 Flutter 的手势体系除了 Long press 均为连续手势,无离散手势,Tap 也会通过 TapDown、TapUp 等状态来完成。每个中间状态会通过不同的回调函数来支持开发者处理逻辑,返回参数也根据中间状态的不同而不同。排除 Widget 的统一封装来看,跟安卓类似,回调过多,且返回参数不统一,不利于标准化。但是对于内部的手势回调来说,细分的接口各司其职,传递所需的参数都是必须的,开发者可以直接获取具体的返回信息。 iOS Tap(离散手势,100 ms 左右的点击行为) Long Press (连续手势,500 ms 以上的点击行为) Pan (连续手势,平移,类似 drag,但是可以在移动过程中不断变化方向) Swipe (离散手势) Pinch(连续手势,向外捏时放大,向内捏时缩小) Rotation(连续手势,旋转) 为了方便大家了解各个手势的区别,尤其是 Pan 跟 Swipe 的区别,特地放上了iOS 开发者文档的一些图片。 iOS 的手势可以带上多个 touch pointer,同时满足了几个手指操作的能力。比如三指滑动(三根手指的swipe)、双指点击(两根手指的 Tap)等。它提供了开发者对某一个手势处理成一个注册回调函数,通过 state 判断目前手势的状态。离散手势与连续手势共存。 Android View 上直接提供 click 以及 touch 的一些方法 OnDragListener:拖动事件。 OnLongClickListener:长按抬起时的事件。 GestureDetector.OnGestureListener onDown:手势识别器的 down 事件。 onFling: 类似 swipe。 onLongPress: 长按。 onScroll:scroll view 滚动时的事件。 onShowPress:按下后没抬起,相当于(up、move、down 的中间 move 状态,只是没move)。 onSingleTapUp: 点击抬起,对应 onDown。 GestureDetector.OnDoubleTapListener: 双击。 ScaleGestureDetector:旋转,捏,分 begin、onScale、end。 相对来说,Android 的手势体系比较细分,大致上跟 FLutter 比较像,但是 Flutter 是不同手势在不同类中的,Flutter 基本上都是离散手势,安卓很多连续手势,但是更加细分。 标准 综上分析了 Flutter、iOS、Android 以及一个前端实现的 gesture lib(hammer.js),不难发现,每个端实现的手势方案都大同小异。无非都实现了这几种方法: click(Tap)、swipe、Pan、Long Press (Press)、Pinch 与 Rotation(或者 Scale)。但是各个平台对每个手势的实现还是有些许的差异,无论是具体手势的代码逻辑判断还是具体手势的拆分或者命名,均有不同。 那在 Web 技术上,我们应该使用怎么样一套手势规范,来兼顾易用性、性能以及标准化呢?就目前来看,基于 Web 技术体系发展来的 Web runtime 的已经非常多了,诸如 Web、React Native、小程序等体系已经在端侧带来了巨大的运行时碎片化。未来不止于移动端上,还有各种 IOT 设备出现,可能会有越来越多的 Web runtime 会出现。未来可能会有更多领域会有不一样的终端设备,而折叠屏、柔性屏的到来也可能会让端侧的设备(手机、IOT、车载等)形成更多更复杂的的跨端场景,随之而来的也是更多的交互手势来与这些设备进行“沟通交流”。 很遗憾的是目前 W3C 上没有相应的手势规范,我们更期望有一个统一的既定标准来规范,我们也在 W3C 中文兴趣小组上发起了一个讨论,目前此讨论已经提到了 UIEvent。我们期望通过易用性、性能以及标准化这几个纬度去讨论手势规范以及对应的手势标准化能力的必要性,以及最终推动规范建立的可行性,也欢迎更多的小伙伴加入该讨论。 此外,该标准提案目前已经在 北海 Kraken 上实现,开发者可以直接使用 增强的手势能力 来开发复杂的交互应用。后续 北海 Kraken 团队将会在复杂的业务场景上定义出更多问题以及通用能力,期望可以通过参与推动标准定制的方式让 Web 技术变得更好。

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

一次线上故障:数据库连接池泄露后的思考

云栖号:https://yqh.aliyun.com第一手的上云资讯,不同行业精选的上云企业案例库,基于众多成功案例萃取而成的最佳实践,助力您上云决策! 一:初步排查 早上作为能效平台系统的使用高峰期,系统负载通常比其它时间段更大一些,某个时间段会有大量用户登录。当天系统开始有用户报障,发布系统线上无法构建发布,然后后续有用户不能登录系统,系统发生假死,当然系统不是真的宕机,而是所有和数据库有关的连接都被阻塞,随后查看日志发现有大量报错。 和数据库连接池相关: Caused by: org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection; nested exception is java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30002ms. 可以看出上面的报错和数据库连接有关,大量超时。通过对线上debug日志的分析,也验证了数据库连接池被大量消耗。 [DEBUG] c.z.h.p.HikariPool: HikariPool-1 - Timeout failure stats (total=20, active=20, idle=0, waiting=13) 这是开始大量报错前的日志。我们可以看到此时HikariPool连接池已经无法获取连接了,active=20表示被获取正在被使用的数据库连接。waiting表示当前正在排队获取连接的请求数量。可以看出,已经有相当多的请求处于挂起状态。 所以当时我们的解决办法是调整数据库连接池大小,开始初步认为是,高峰时期,我们设置的连接池数量大小,不足以支撑早高峰的连接数量导致的。 jdbc.connection.timeout=30000 jdbc.max.lifetime=1800000 jdbc.maximum.poolsize=200 jdbc.minimum.idle=10 jdbc.idle.timeout=60000 jdbc.readonly=false 我们将将数据库连接池的数量调整到了200。 二:事务 2.1事务滥用的后果 及时将配置调整成了200,服务重启也恢复了正常,但是我仍然认为系统存在连接泄露的风险,我试图从日志表现出的行为里寻找蛛丝马迹。我在访问日志看到每次在系统崩溃前,其实都有人在做构建,而且构建经常点击没反应,我当时添加的构建debug日志也显示了这一点。我开始怀疑是构建造成的连接泄露。 在这里我简单说下构建代码处的逻辑 用户触发构建 将job加入增量job缓存,用于更新job状态 jenkinsClient调用jenkins的api,开始构建 将构建信息写入数据库(jobname,version) 我开始观察自己写的代码,可是看了多遍,我也发现不了这段代码和数据库连接有啥关系,大多数人包括当时自己来说,数据库连接的泄露,大多数情况应该是服务和数据库连接的过程中发生了阻塞,导致连接泄露。但是现在来看,很容易能发现问题所在,看当时的代码: @Transactional(rollbackFor = Exception.class) public void build(BuildHistoryReq buildHistoryReq) { //1.封装操作 //2.调用jenkins Api //3.数据库更新写入 } 这就是当时的代码入口,当然代码处没有这么简单。可以看到我在方法入口就加上了Transactional注解,这里的意思其实就是发生错误,抛出异常时,数据库回滚。 问题就出现在了这里,当有用户点击构建时,请求刚进入build方法时,就会从数据库连接获取一个连接。可是此时,程序并没有和数据库相关的操作,如果此时代码在步骤1或者2处出现io或者网络阻塞,就会导致,事务无法提交,连接也就会一直被该请求占用。而再大的连接池也会被耗费殆尽。从而造成系统崩溃。 2.2事务注解的正确用法 通常情况下作为非业务部门,没有涉及到核心的业务,像支付,订单,库存相关的操作时,事务在可读层面并没有特别高的要求。通常也只涉及到,多表操作同时更新时,保证数据一致性,要么同时成功要么同时失败。而使用 @Transactional(rollbackFor = Exception.class) 足以。 而上述代码该如何改进呢?? 首先分析有没有需要使用事务的必要。在步骤3中,数据操作,看代码后发现只有对一张表的操作,同时和其它操作没有相关性。而且本身属于最后一个步骤。所以在此代码中完全没有必要使用,删除注解即可。 当然如果步骤3操作数据库是多表操作,具有强相关性,数据一致,我们可以这样做。将和步骤3无关的步骤分开,变成两个方法,那么在1,2处发生阻塞也不会影响到数据库连接。 public void build(BuildHistoryReq buildHistoryReq) { //1.封装操作 //2.调用jenkins Api update**(XX); } @Transactional(rollbackFor = Exception.class) public void update**(XX xx) { //3.数据库更新写入 } 这里需要注意,注解事务的用法,方法必须是公开调用的。 三:HttpClient 4.x连接池 当时找到数据连接池泄露的原因后,我第一步就是去掉了事务,然后加上了一些日志,这时我已经能确定代码在jenkinsclient处出现了问题,但是仍然不确定问题出在了哪,我只能加上一些日志,同时通过监控继续观察。 果然在hotfix的第二天还是出现了我预料中的事情,构建发布仍然有问题,当然此时其它功能是不受影响了。我观察日志发现构建开始并在该处阻塞 jenkinsClient.startBuild(jobName, params); 随后我观察了项目监控。观察线程情况,发现大量http-nio的线程阻塞了,而这个线程和httpclient相关。 java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) - parking to wait for <0x00000007067027e8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039) at org.apache.http.pool.AbstractConnPool.getPoolEntryBlocking(AbstractConnPool.java:379) at org.apache.http.pool.AbstractConnPool.access$200(AbstractConnPool.java:69) at org.apache.http.pool.AbstractConnPool$2.get(AbstractConnPool.java:245) - locked <0x00000007824713a0> (a org.apache.http.pool.AbstractConnPool$2) at org.apache.http.pool.AbstractConnPool$2.get(AbstractConnPool.java:193) 随后我跟进源码查看了AbstractConnPool类的379行 可以看到线程走到379行执行了this.condition.await()后进入无限期的等待,所以此时如果没有线程执行this.condition.signal()就会导致该线程一直处于waiting状态,而前端也会迟迟收不到相应,导致请求timeout。 我们再分析下源码,看看什么情况下会导致线程跑到该处: /** * 获取http连接,从名称也能看出该方法会造成阻塞 */ private E getPoolEntryBlocking( final T route, final Object state, final long timeout, final TimeUnit timeUnit, final Future<E> future) throws IOException, InterruptedException, TimeoutException { Date deadline = null; if (timeout > 0) { deadline = new Date (System.currentTimeMillis() + timeUnit.toMillis(timeout)); } this.lock.lock(); try { final RouteSpecificPool<T, C, E> pool = getPool(route); E entry; for (;;) { Asserts.check(!this.isShutDown, "Connection pool shut down"); for (;;) { entry = pool.getFree(state); if (entry == null) { break; } if (entry.isExpired(System.currentTimeMillis())) { entry.close(); } if (entry.isClosed()) { this.available.remove(entry); pool.free(entry, false); } else { break; } } if (entry != null) { this.available.remove(entry); this.leased.add(entry); onReuse(entry); return entry; } // New connection is needed final int maxPerRoute = getMax(route); // Shrink the pool prior to allocating a new connection final int excess = Math.max(0, pool.getAllocatedCount() + 1 - maxPerRoute); if (excess > 0) { for (int i = 0; i < excess; i++) { final E lastUsed = pool.getLastUsed(); if (lastUsed == null) { break; } lastUsed.close(); this.available.remove(lastUsed); pool.remove(lastUsed); } } if (pool.getAllocatedCount() < maxPerRoute) { final int totalUsed = this.leased.size(); final int freeCapacity = Math.max(this.maxTotal - totalUsed, 0); if (freeCapacity > 0) { final int totalAvailable = this.available.size(); if (totalAvailable > freeCapacity - 1) { if (!this.available.isEmpty()) { final E lastUsed = this.available.removeLast(); lastUsed.close(); final RouteSpecificPool<T, C, E> otherpool = getPool(lastUsed.getRoute()); otherpool.remove(lastUsed); } } final C conn = this.connFactory.create(route); entry = pool.add(conn); this.leased.add(entry); return entry; } } boolean success = false; try { if (future.isCancelled()) { throw new InterruptedException("Operation interrupted"); } pool.queue(future); this.pending.add(future); if (deadline != null) { success = this.condition.awaitUntil(deadline); } else { this.condition.await(); success = true; } if (future.isCancelled()) { throw new InterruptedException("Operation interrupted"); } } finally { // In case of 'success', we were woken up by the // connection pool and should now have a connection // waiting for us, or else we're shutting down. // Just continue in the loop, both cases are checked. pool.unqueue(future); this.pending.remove(future); } // check for spurious wakeup vs. timeout if (!success && (deadline != null && deadline.getTime() <= System.currentTimeMillis())) { break; } } throw new TimeoutException("Timeout waiting for connection"); } finally { this.lock.unlock(); } } 从源码我们可以看出有几处必要条件才会导致线程会无限期等待: timeout=0 也就是说没有给默认值,导致: deadline = null pool.getAllocatedCount() < maxPerRoute 判断是否已经到达了该路由(host地址)的最大连接数。 其实整体逻辑就是,从池里获取连接,如果有就直接返回,没有,判断当前请求出去的路由有没有到达该路由的最大值,如果达到了,就进行等待。如果timeout为0就会进行无限期等待。 而这些值我本身也没有做任何设置,我当时的第一想法就是,给http请求设置超时时间。也就是给每个client设置必要的参数 解决 1.jenkinsClient分配超时时间 public HttpClientBuilder clientBuilder() { HttpClientBuilder httpClientBuilder = HttpClientBuilder.create(); RequestConfig.Builder builder = RequestConfig.custom(); //该参数对应AbstractConnecPool getPoolEntryBlocking方法的timeout builder.setConnectionRequestTimeout(5 * 1000); //数据传输的超时时间 builder.setSocketTimeout(20 * 1000); //该参数为,服务和jenkins连接的时间(通常连接的时间都很短,可以设置小点) builder.setConnectTimeout(5 * 1000); httpClientBuilder.setDefaultRequestConfig(builder.build()); return httpClientBuilder; } 2.构建JenkinsClient和更新使用的JenkinsClient分离 其实我已经尝试用池化的思想来解决该问题了。 诡异bug(同一个JenkinsClient,调用不同的api,有的api会阻塞,有的调用仍然正常) 但hotfix的第二天,又出现了一个诡异的bug: 构建可以,但是无法同步job的状态。这里出现这个问题的原因在于我将构建和更新两个过程使用的jenkinsClient分离成两个,所以这个过程相互独立,互不影响,所以,更新的client出了问题但是构建的client仍然能正常使用。 但是更新过程的JenkinsClient出现的问题让我百思不得其解。我们先看看更新状态过程会使用到的api(接口) //获取对应的job 1 JobWithDetails job = client.get(UrlUtils.toJobBaseUrl(folder, jobName), JobWithDetails.class); //获取job构建的pipeline流水 2 client.get("/job/" + EncodingUtils.encode(jobName) + "/" + version + "/wfapi/describe", PipelineStep.class); //获取对应job某次build的详情 3 client.get(url, BuildWithDetails.class); bug问题1:为什么全量更新job和增量更新job使用的是同一个JenkinsClient,但是全量更新仍然正常获取值,而增量更新job状态的线程确出现阻塞超时(超时是因为前面我设置了timeout,使得请求不会一直阻塞下去)。 要回答这个问题,就要回到线程的相关问题了, this.condition.wait()会导致当前线程阻塞,并不会影响到另外线程。而更新使用了两个线程。所以这个问题也比较好回答。 bug问题2:为什么同一个线程(增量更新job线程)调用不同api,有的成功,而有的会阻塞: 解决这个问题,我们还是得回到AbstractConnPool中的方法getPoolEntryBlocking()来看: if (pool.getAllocatedCount() < maxPerRoute) { } 当前请求的路由如果已经达到最大值了就会阻塞等待。那么同一个jenkinsclient,按理来说不可能会出现不同的路由。所以同一个client要么都能访问,要么都会阻塞,怎么会出现有的能访问有的会阻塞。为了寻求问题的答案,我翻阅了JenkinsClient的源码,结合日志,发现服务每次阻塞的方法是: 不管多少次,每次都会完美的在该地方阻塞:对应上面的api 3: //获取对应job某次build的详情 3 client.get(url, BuildWithDetails.class); 这个url和其它两个api拿到的路由都有区别:可以跟随我一起看源码: public class Build extends BaseModel { private int number; private int queueId; private String url; } 我们可以看到url是属于Build的属性,并非client我们设置的值,当然有人会觉得该值可能是通过将配置的url设置过来的。我们可以接着看,哪些方法可能会给build设置url,三个构造函数,一个set方法都可以,如果我们继续只看源码仍然很难找到问题所在,所以这时候我开始启动服务debug; 发现了问题在哪: 可以看出调用jenkins的这个api出现了两个router,也可以看出这个url是jenkins返回的,查阅资料可以看到,jenkins系统设置时可以设置这个url。 所以这个bug也能很好的解释了,对于httpclient来说,每个router默认可以最多两个连接。虽然是同一个调用api采用的是同一个jenkinsClient,但是却维护了两个router,一个是从配置中获取,一个是jenkins返回的,这个是配置不一致导致的。 JenkinsClient分配连接数: public HttpClientBuilder clientBuilder() { HttpClientBuilder httpClientBuilder = HttpClientBuilder.create(); RequestConfig.Builder builder = RequestConfig.custom(); builder.setConnectionRequestTimeout(5 * 1000); builder.setSocketTimeout(20 * 1000); builder.setConnectTimeout(5 * 1000); httpClientBuilder.setDefaultRequestConfig(builder.build()); //每个路由最多有10个连接(默认2个) httpClientBuilder.setMaxConnPerRoute(10); //设置连接池最大连接数 httpClientBuilder.setMaxConnTotal(20); return httpClientBuilder; } 给JenkinsClient添加健康检查,并手动更新不能用的Client @Slf4j public class JenkinsClientManager implements Runnable { private volatile boolean flag = true; private final JenkinsClientProvider jenkinsClientProvider; public JenkinsClientManager(JenkinsClientProvider jenkinsClientProvider) { this.jenkinsClientProvider = jenkinsClientProvider; } @Override public void run() { while (flag) { try { checkJenkinsHealth(); //每30秒检查一次 Thread.sleep(30_000); } catch (Exception e) { log.warn("check health error:{}", e.getMessage()); } } } public void checkJenkinsHealth() { log.debug("check jenkins client health start"); //获取client是否可用 available = isAvailable(..) if (!available || !queryAvailable) { //更新client jenkinsClientProvider.retrieveJenkinsClient(); } } private boolean isAvailable(Set<Map.Entry<String, JenkinsClient>> entries) { boolean available = true; for (Map.Entry<String, JenkinsClient> entry : entries) { boolean running = entry.getValue().isRunning(); if (!running) { log.debug("jenkins running error"); available = false; } } return available; } @PostConstruct public void start() { TaskSchedulerConfig.getExecutor().execute(this); } } 四:JenkinsClient连接池 采用池化技术解决client高可用和重复利用问题 虽然我手动写了一个JenkinsClientManager每30秒来维护一次client,但是这种手工的方式并不好: 每30秒维护一次,若是在期间发生问题,那么只能干等 无法动态的根据系统需要,动态构建新的client,也就是无法满足高并发下的使用问题 无法配置 目前我们都知道各种池化技术:线程池、数据库连接池、redis连接池。 笔者在实现jenkinsClient pool之前,参考了线程池、数据库连接池的实现、发现其底层实现较为复杂、redis的连接池技术相对来说容易看懂和学习、所以采用了和jedis一样的实现方式来实现JenkinsClient的连接池 这是jedis的类结构目录,其实重点在我标记的这5个类。 jedis本身也是采用的commons-pool2提供的池技术实现的,接下来我会简单介绍一下该工具提供的池化技术。 JenkinsClient连接池应该要具备哪些功能?? 动态创建JenkinsClient 使用完的Client放回池中 回收长期不用和不可用的Client 能够根据需要配置一定数量的Client 对于提到的这些功能,我将通过commons-pool2包来实现 PooledObjectFactory:该接口管理着bean的生命周期(An interface defining life-cycle methods for instances to be served by an) makeObject 方法创建一个可以入池的实例,也就是我们需要用的Client由该方法创建 destroyObject 方法可以销毁不可用或者过期的对象 validateObject 方法对实例进行验证,在每次创建完实例后,都会调用该方法,同时也会以一定的频率进行健康检查(频率timeBetweenEvictionRunsMillis) GenericObjectPool:实例都会放入该池中进行管理: //所有的可用连接 private final Map<IdentityWrapper<T>, PooledObject<T>> allObjects = new ConcurrentHashMap<>(); //空闲的可用连接 private final LinkedBlockingDeque<PooledObject<T>> idleObjects; //获取可用连接 T borrowObject() throws Exception, NoSuchElementException, IllegalStateException; //资源释放(将连接放回连接池) void returnObject(T obj) throws Exception; 配置(BaseObjectPoolConfig,但是我们继承GenericObjectPoolConfig,该类给出了大量的默认值) 链接池中最大连接数,默认为8 maxTotal #链接池中最大空闲的连接数,默认也为8 maxIdle #连接池中最少空闲的连接数,默认为0 minIdle #连接空闲的最小时间,达到此值后空闲连接将可能会被移除。默认为1000L*60L*30L minEvictableIdleTimeMillis #连接空闲的最小时间,达到此值后空闲链接将会被移除,且保留minIdle个空闲连接数。默认为-1 softMinEvictableIdleTimeMillis #当连接池资源耗尽时,等待时间,超出则抛异常,默认为-1即永不超时 maxWaitMillis #当这个值为true的时候,maxWaitMillis参数才能生效。为false的时候,当连接池没资源,则立马抛异常。默认为true blockWhenExhausted #空闲链接检测线程检测的周期,毫秒数。如果为负值,表示不运行检测线程。默认为-1. timeBetweenEvictionRunsMillis #在每次空闲连接回收器线程(如果有)运行时检查的连接数量,默认为3 numTestsPerEvictionRun #默认false,create的时候检测是有有效,如果无效则从连接池中移除,并尝试获取继续获取 testOnCreate #默认false,borrow的时候检测是有有效,如果无效则从连接池中移除,并尝试获取继续获取 testOnBorrow #默认false,return的时候检测是有有效,如果无效则从连接池中移除,并尝试获取继续获取 testOnReturn #默认false,在evictor线程里头,当evictionPolicy.evict方法返回false时,而且testWhileIdle为true的时候则检测是否有效,如果无效则移除 testWhileIdle 了解了这些我们对于需要开发的连接池就很轻松了: 实现PooledObjectFactory(JenkinsFactory)该工厂类就是负责JenkinsClient的生命周期 自定义连接池Pool,通过组合的方式引入框架的连接池GenericObjectPool,当然我们也可以用继承的方式来实现(组合优先于继承) 五:反思 连接池写完,目前也只是在测试环境运行,还在观察阶段 有个特别的问题也需要指出来,该问题是笔者在开发时没有注意的问题,也是此次线上产生问题的原因 笔者将原来更新频率从15s调整到了10s,问题就暴露出来了,对于1个job,可能会拉出上百个build,每次会调用3个api接口,如果每次有十个job,每次更新会在10秒内完成,随着job增加,和构建历史增加(虽然有设置保留多少版本,但是api还是会拉出很奇怪的历史build),会超量发出大量http请求。所以我在代码层面也做了改动,每次只更新每个job的前5个最新的build,这样下来,请求量会降低很多 List<Build> buildList = builds.stream().sorted(Comparator.comparing(Build::getNumber).reversed()).limit(5).collect(toList()); by陈朗: 本次事故整体来讲,还是笔者技术有限,解决问题时绕了很多弯,花了大量时间研究源码。我也总结了以下几点 对于连接、锁等这些可能会阻塞的场景,都需要给出超时设置 资源消耗型,需要有池化的思想,提高资源利用率,保证系统稳定 基础很重要,需要持续不断的学习,这样解决问题才能深入底层,找出问题所在,而不是浮于表面 云栖号:https://yqh.aliyun.com第一手的上云资讯,不同行业精选的上云企业案例库,基于众多成功案例萃取而成的最佳实践,助力您上云决策! 原文发布时间:2020-01-18本文作者:槽神本文来自阿里云云栖号合作伙伴“51CTO”,了解相关信息可以关注“51CTO”

资源下载

更多资源
腾讯云软件源

腾讯云软件源

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

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文件系统,支持十年生命周期更新。

用户登录
用户注册