基于《Insight h2database 更新、读写锁以及事务原理》对于更新流程有了深入了解。在独占锁的简单模型上,分析 h2database 基于乐观锁(并发控制机制)的行锁锁定机制。
验证 h2database MVCC 机制,需要有并发的环境,不能再使用 org.h2.tools.Shell 。可以使用 debug 模式,运行 org.h2.tools.Console。使用不同的浏览器模拟多 session

-- session 1 更新数据并打标
SET AUTOCOMMIT OFF;
update city set code = 'bjx' where id = 9;
-- session 2 读取数据正常
select * from city where id = 9;
-- session 2 更新数据异常,提示 Timeout,其实是并发更新冲突异常
update city set code = 'sjx' where id = 9;
Timeout trying to lock table "CITY";
上述的并发异常,在内部的错误代码为:org.h2.api.ErrorCode#CONCURRENT_UPDATE_1 trying to update the same row from within two connections at the same time
在使用 vue 本地开发前后端功能时,发现配置的代理不能正确的请求到后端服务。
通过观察 Node.js 日志,发现代理 path rewrite 的路径有问题。
借此阅读 webpack-dev-server 源码,了解其工作原理。
前端 vue 工程的 vue.config.js 代理配置如下:
module.exports = {
devServer: {
proxy: {
// 转发到稳定的线上服务
'/someApi': {
target: "online.foo.com",
changeOrigin: true,
pathRewrite: {
'^/someApi': ''
}
},
// 转发到开发中的服务。
// 由于 devServer 路由规则,导致前端发起的 /someApiExtend/something 请求,并没有转发到 development.foo.com。
'/someApiExtend': {
target: "development.foo.com",
changeOrigin: true,
pathRewrite: {
'^/someApiExtend': ''
}
}
}
}
}
/someApiExtend/something 请求被 ‘/someApi’ 优先处理,Rewrite to 的请求解析为:Extend/something。 直接提示 404。❌
Serves a webpack app. 提供开发环境服务
Updates the browser on changes. 热编译和部署
webpack-dev-server 可用于快速开发应用程序。请查阅 开发指南 开始使用。
其中提到了使用到 http-proxy-middleware 软件包。
通过源码分析可以看到集成使用的方式
/**
* 根据 options 解析集成的组件,注册 express web应用中。
* @returns {void}
* @see 核心实现源码文件 lib\Server.js
*/
setupMiddlewares() {
/**
* @type {Array<Middleware>} 需要的组件集合
*/
let middlewares = [];
// 简单case, 如果配置了压缩,则初始化并加入到组件集合。最终注册到 express。 express.use(compression())
// compress is placed last and uses unshift so that it will be the first middleware used
if (this.options.compress) {
const compression = require("compression");
middlewares.push({ name: "compression", middleware: compression() });
}
// 如上的 compress, 引入并初始化代理配置 createProxyMiddleware
if (this.options.proxy) {
const { createProxyMiddleware } = require("http-proxy-middleware");
const getProxyMiddleware = (proxyConfig) => {
// It is possible to use the `bypass` method without a `target` or `router`.
// However, the proxy middleware has no use in this case, and will fail to instantiate.
if (proxyConfig.target) {
const context = proxyConfig.context || proxyConfig.path;
return createProxyMiddleware(
/** @type {string} */ (context),
proxyConfig
);
}
};
/**
* 遍历 devServer.proxy 配置,并初始化为 proxyMiddleware对象,添加到组件合集里。
*/
/** @type {ProxyConfigArray} */
(this.options.proxy).forEach((proxyConfigOrCallback) => {
// 代理配置集合,
let proxyConfig = typeof proxyConfigOrCallback === "function" ? proxyConfigOrCallback() : proxyConfigOrCallback;
let proxyMiddleware = (getProxyMiddleware(proxyConfig));
middlewares.push({
name: "http-proxy-middleware",
// 通过 handler 初始化配置,简单来说,就是调用 getProxyMiddleware(proxyConfig)
middleware: handler,
});
});
}
// 把组件集合注册到 express web 应用中
middlewares.forEach((middleware) => {
/** @type {import("express").Application} */
(this.app).use(middleware.middleware);
});
}
graph TD
A[前端请求 /someApi/user] --> B{devServer proxy<br/>配置匹配}
B -->|匹配 /someApi| C[pathRewrite<br/>去掉 /someApi]
B -->|匹配 /someApiExtend| D[pathRewrite<br/>去掉 /someApiExtend]
B -->|无匹配| E[静态资源服务]
C --> F[转发到 target]
D --> G[转发到 target]
style B fill:#e1f5fe,stroke:#01579b
style C fill:#fff3e0,stroke:#e65100
style D fill:#fff3e0,stroke:#e65100
首先,判断是否启用 proxy, 并引入 http-proxy-middleware 组件
然后,遍历 devServer.proxy 集合配置,生成对应的代理配置对象
最后,把 proxy 配置对象(proxyMiddleware)注册到 express web 应用中
The one-liner node.js http-proxy middleware for connect, express, next.js
方便理解 webpack-dev-server 集成 http-proxy-middleware 软件包的过程
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const app = express();
app.use(
'/api',
createProxyMiddleware({
target: 'http://www.example.org/secret',
changeOrigin: true,
}),
);
’/’ matches any path, all requests will be proxied.
‘/api’ matches paths starting with /api ✔ 遇到的疑问,此刻得以解答。
webpack-dev-server 基于 express web 框架实现的一个便于开发环境的 Sever
devServer.proxy 功能是其中一个特性,通过 http-proxy-middleware 实现
proxy 配置path 的规则,参考 Context matching 即可。
webpack-dev-server 的代码结构和实现,对于架构设计具有很大的借鉴意义。👍
阅读 h2 数据库的源码是一项复杂的任务,需要对数据库原理、Java 语言和操作系统有深入的理解。可以从以下几方面入手来完成。
首先,你需要在你的机器上安装和配置好开发环境,包括 JDK、Maven、IDE 调试器等工具。
然后,从 h2 的官方网站 或 GitHub上下载源码。
IDE 导入 h2 数据库源码,根据不同的调试场景,启用不同的模式。
# 约等于 java -cp h2-*.jar org.h2.tools.Console
java -cp h2-*.jar
java -cp h2-*.jar org.h2.tools.Shell
在阅读源码之前,理解 h2 数据库的整体架构和主要组件是非常重要的。可以从官方文档或在线教程中获取这些信息。
官方架构讲解 Architecture
h2 数据库的源码非常多,功能非常丰富,可能无法一次性完全理解。因此,选择一个特定的模块或功能(如查询优化器、存储引擎、事务处理等)作为起点,然后逐步扩大你的阅读范围。
基于的 BTree PageStore 存储引擎更贴近日常工作、便于理解,可以先选取该存储引擎入手。
使用调试器跟踪代码的执行过程,这可以帮助你理解代码的运行逻辑。你可以从一些简单的SQL查询开始,看看它们是如何在 h2 数据库中被处理的。
可以使用上述的本地 Shell 模式开启你的源码之旅。
h2 数据库的源码中有大量的注释,这些注释可以帮助你理解代码的功能和工作原理。
架构类的代码,可以从设计模式中寻找灵感。
算法类的代码,可以从最简化的模型来阅读。
对于无法理解的代码,尝试交给 chat-gpt 解读。
h2 相关的资料比较少,数据库的底层原理是相通的。
借鉴 MySQL 的内部工作原理,相关的书籍来了解 h2 设计理念。
从已有的其他开源数据库中获取设计相关的文档。例如:B+树实现 - MiniOB
尝试修改一些代码,然后编译并运行,看看结果是否符合你的预期。这是理解源码的最好方式之一。
可以从 github issues 来了解运行中的问题和修复思路和方案。
针对同一个功能,从 git 不同版本的源码对比中,学习重构和优化的思路。
在设计理念和原理熟悉后,可以着手针对特定场景进行源码改写练习。
如果你遇到无法理解的代码或问题,可以在 h2 数据库的开发者论坛或邮件列表中寻求帮助。
文章基于 RegularTable 来分析和拆解更新操作。
PageStore 存储引擎默认不开启 MVCC,锁模型比较简单,方便了解更新的整个流程。
主要涉及读写锁(事务隔离),数据更新的实现、事务的提交和回滚。
讨论更新操作,就需要涉及到事务隔离级别以及事务的概念。
也就是讨论如何控制并发读写的问题、以及undolog 的问题。
multi version concurrency。在 h2database 实现中,默认 MVStore 存储引擎支持该特性。
为了简化事务实现模型,只关注非 MVCC 模式。 MVCC 实现原理参考《Insight h2database MVCC 实现原理》。
/**
* Check if multi version concurrency is enabled for this database.
* 使用 PageStore 存储引擎时,使用 MVCC=true 开启。
* @see org.h2.engine.Database#isMultiVersion
*/
public boolean isMultiVersion() {
// this.multiVersion = ci.getProperty("MVCC", dbSettings.mvStore);
return multiVersion;
}
/**
* 通过设置或者版本确定是否启用 MVStore 存储引擎
* @see org.h2.engine.DbSettings#mvStore
*/
public boolean mvStore = get("MV_STORE", Constants.VERSION_MINOR >= 4);
the isolation level. 在 h2database 中,通过 LOCK_MODE 体现。不同的锁定模式决定了事务级别。参考命令 SET LOCK_MODE int。
SET LOCK_MODE 命令是数据库级别的,影响全局(affects all connections)。
默认的事务隔离级别为 READ_COMMITTED。MVStore 存储引擎默认支持。
对于 RegularTable 只存在三种级别:READ_UNCOMMITTED, READ_COMMITTED, SERIALIZABLE(默认)。
READ_UNCOMMITTED,即无锁定模式(仅用于测试)
READ_COMMITTED, 避免了脏读,相比于 SERIALIZABLE,并发性能更好,事务的读写操作不阻塞。开启 MVCC 模式即可。
SERIALIZABLE,不同事务(session)读写互斥。可以防止脏读、不可重复读和幻读,但是效率较低,因为它会锁定所涉及的全部表,直到整个事务完成。
更新流程中,首先会调用 table.lock(session, exclusive = true, false);
在 RegularTable 中,表会按照 session 粒度控制并发度。这个方法只能当前 session 可重入,其他 session 想 lock 成功,需要等待当前会话释放锁。
-- session 1 更新数据并持有锁
SET AUTOCOMMIT OFF;
update city set code = 'bjx' where id = 9;
-- session 2 获取锁超时,异常
select * from city where id = 5;
Timeout trying to lock table "CITY"; SQL statement:
select * from city where id = 5 [50200-184] HYT00/50200
独占锁是个 Java 经典的多线程同步案例。同时包含了死锁检测的解决方案。
/**
* 通过会话,给表加锁。
* 如果要加写锁,会存在等待锁的情况。
* 如果发生锁超时,将抛出DbException异常。如上示例。
* @param session 当前会话
* @param exclusive 如果为true,表示需要写锁;如果为false,表示需要读锁。写锁是排他的,即在同一时间只能有一个线程持有写锁。读锁是共享的,即在同一时间可以有多个线程持有读锁。
* @see org.h2.table.RegularTable#lock
*/
public boolean lock(Session session, boolean exclusive, boolean forceLockEvenInMvcc) {
int lockMode = database.getLockMode();
// 无锁模式,直接返回。
if (lockMode == Constants.LOCK_MODE_OFF) {
// 返回是否存在独占 session, 没有使用到,约等于无,不用关注。
return lockExclusiveSession != null;
}
// 如果是当前 session 独占,相当于锁重入(如果一个会话已经持有了这个表的独占锁,那么它可以再次获取这个锁,而不会被自己阻塞。)
if (lockExclusiveSession == session) {
return true;
}
synchronized (database) {
// double check 😁
if (lockExclusiveSession == session) {
return true;
}
// 读锁,共享,直接返回。
if (!exclusive && lockSharedSessions.contains(session)) {
return true;
}
// 写锁,进入等待队列
session.setWaitForLock(this, Thread.currentThread());
waitingSessions.addLast(session);
try {
// while 循环出队列加锁 or 等待加锁。
// 真正的加锁在 doLock2 方法中。根据读写锁不同(exclusive), 执行不同的操作。
doLock1(session, lockMode, exclusive);
} finally {
session.setWaitForLock(null, null);
waitingSessions.remove(session);
}
}
return false;
}
了解独占锁的工作机制后,对于数据更新事务的原子性、一致性、隔离级别就没有疑问了。以下主要列出数据更新的主流程,比如查找并更新,触发器时机。
/**
* 执行数据更新
* @see org.h2.command.dml.Update#update
*/
public int update() {
// 记录哪些数据需要更新。
RowList rows = new RowList(session);
try {
Table table = tableFilter.getTable();
session.getUser().checkRight(table, Right.UPDATE);
// 尝试添加写锁(独占锁)
table.lock(session, true, false);
// 查询需要更新的数据, select by condition
while (tableFilter.next()) {
if (condition == null || Boolean.TRUE.equals(condition.getBooleanValue(session))) {
// 旧数据,直接查出来的。
Row oldRow = tableFilter.get();
// 新数据,根据更新语句,重新赋值后的。
Row newRow = table.getTemplateRow();
// 执行 set column 表达式...
boolean done = false;
if (table.fireRow()) {
// 数据变更前,分发执行触发器。触发器太多可不行❌
done = table.fireBeforeRow(session, oldRow, newRow);
}
if (!done) {
rows.add(oldRow);
rows.add(newRow);
}
}
}
// 存储引擎执行真正的数据更新操作。⛳
table.updateRows(this, session, rows);
if (table.fireRow()) {
for (rows.reset(); rows.hasNext();) {
// 数据变更后,分发执行触发器
table.fireAfterRow(session, o, n, false);
}
}
return count;
} finally {
rows.close();
}
}
因为 RegularTable PageStore 存储引擎事务是 SERIALIZABLE 级别, 就不存在读写并发的情况,远没有 MVCC 模式提交事务那么复杂。事务的提交不做过多分析,主要关注事务回滚的实现。
和其他数据库一样, h2database 会话默认的 AutoCommit = true。更新命令执行完成会自动发起 commit 操作。
开启事务的情况下,由用户手动发起 commit 操作。
/**
* 更新命令执行完成后,收尾工作之一判断是否需要发起自动提交✔
* @see org.h2.command.Command#stop
*/
private void stop() {
// AutoCommit 状态,自动提交事务。
if (session.getAutoCommit()) {
session.commit(false);
}
}
org.h2.command.dml.TransactionCommand#update 命令处理
/**
* Commit the current transaction.
*
* @see org.h2.engine.Session#commit
*/
public void commit(boolean ddl) {
// 事务持久化机制,及时存盘数据库操作记录。
if (containsUncommitted()) {
database.commit(this);
}
if (undoLog.size() > 0) {
undoLog.clear();
}
// 释放当前会话关联 table 的读写锁。
// @see org.h2.engine.Session#unlockAll
endTransaction();
}
org.h2.command.dml.TransactionCommand#update 命令处理
事务的回滚依赖 undoLog。实现类:org.h2.engine.UndoLogRecord,undoLog 只存在两种操作 INSERT DELETE。对应到 SQL 操作:
Insert SQL: INSERT new, 回滚操作为:DELETE new
Update SQL: DELETE old, INSERT new, 回滚操作为:DELETE new, INSERT old
Delete SQL: DELETE old, 回滚操作为:INSERT old
/**
* 事务回滚操作。
* 事务回滚的过程就是按照逆序回放事务中的操作(undoLog中的操作逆序执行)。
*
* @param savepoint 如果指定保存点,事务将回滚到这个保存点。
* @param trimToSize if the list should be trimmed
*/
public void rollbackTo(Savepoint savepoint, boolean trimToSize) {
// 保存点持有的是当前会话开始时 undoLog 的位置。默认都是 0。
int index = savepoint == null ? 0 : savepoint.logIndex;
// 当前会话 undoLog 队列逆向回放,重置现场。
while (undoLog.size() > index) {
UndoLogRecord entry = undoLog.getLast();
// 如上的对应操作规则,回放操作。
entry.undo(this);
undoLog.removeLast(trimToSize);
}
}