Node.js 连金仓数据库(下篇):连接池、事务和那些坑
接上篇
上篇讲了怎么装驱动、怎么连数据库、怎么增删改查。这篇接着说点真有用的------生产环境跑起来之后会遇到的问题。
我刚开始写Node.js+金仓的时候,以为会了query就够了。结果上线第二天就出事了------连接数爆了、数据库扛不住了。后来才慢慢搞清楚连接池、事务这些东西怎么用。今天把这些经验写下来,希望能帮你少走点弯路。
一、连接池:别再每个请求都建连接了
1.1 一个真实的翻车现场
上线第一天,我们的服务跑得很好。第二天突然就不行了,所有请求超时。
查了半天,发现数据库连接数飙到了200多,而我们配置的最大连接数是100。怎么回事?每个请求进来,代码里都new Client()然后connect(),用完也不关。200个请求就是200个连接,数据库扛不住。
这就是典型的"连接泄漏"。后来加了连接池,问题就解决了。
1.2 连接池怎么用
金仓的Node.js驱动本身不带连接池,但可以用pg-pool这个包,接口是兼容的。
先装一下:
bash
npm install pg-pool
然后这样用:
javascript
const { Client } = require('kb');
const Pool = require('pg-pool');
const pool = new Pool({
// 传给Client的参数
user: 'SYSTEM',
host: '127.0.0.1',
database: 'TEST',
password: '123456',
port: 54321,
// 连接池自己的参数
max: 20, // 最多20个连接
idleTimeoutMillis: 30000, // 空闲30秒后关闭
connectionTimeoutMillis: 2000 // 等2秒拿不到就报错
});
// 从池子里借一个连接
pool.connect((err, client, done) => {
if (err) {
console.log('拿连接失败', err);
return;
}
client.query('SELECT * FROM users', (err, res) => {
// 用完了还回去,别忘了这一行
done();
if (err) {
console.log(err);
return;
}
console.log(res.rows);
});
});
关键就是那个done()。忘掉它的后果是:连接被借走但没还,池子里的可用连接越来越少,最后全都堵住。
1.3 封装一下,省得每次写回调
回调写起来太烦了,我把它包了一下:
javascript
class MyDb {
constructor(config) {
this.pool = new Pool(config);
}
async query(sql, params) {
const client = await this.pool.connect();
try {
const res = await client.query(sql, params);
return res;
} finally {
client.release(); // 这里保证一定会还
}
}
async end() {
await this.pool.end();
}
}
// 用起来简单多了
const db = new MyDb({
user: 'SYSTEM',
host: '127.0.0.1',
database: 'TEST',
password: '123456',
port: 54321,
max: 20
});
const res = await db.query('SELECT * FROM users WHERE id = $1', [1]);
console.log(res.rows);
这样封装之后,调用方根本不需要关心连接池的存在,用起来跟单连接一样,但底层已经是池化了的。
1.4 参数设多少合适
max这个参数,不是越大越好。我一开始设了100,数据库直接卡死。
一般来说,20-50就够了。怎么算?看你的应用实例数和数据库的max_connections。假设数据库最大连接是200,你有4个Node实例,每个实例的max设40就比较合理。
二、事务:要么全成功,要么全失败
2.1 什么时候用事务
比如转账功能:A账户减钱,B账户加钱。如果A减成功了,B加失败了,钱就飞了。这时候就要用事务------把两个操作包在一起,要么一起成,要么一起败。
2.2 事务怎么写
javascript
const client = await pool.connect();
try {
// 开始事务
await client.query('BEGIN');
// 扣A的钱
await client.query('UPDATE accounts SET balance = balance - 100 WHERE id = $1', [1]);
// 给B加钱
await client.query('UPDATE accounts SET balance = balance + 100 WHERE id = $1', [2]);
// 提交
await client.query('COMMIT');
console.log('转账成功');
} catch (err) {
// 出错了,回滚
await client.query('ROLLBACK');
console.log('转账失败', err);
} finally {
client.release(); // 用完还回池子
}
2.3 事务的坑(我踩过的)
坑一:忘记释放连接
事务执行过程中,这个连接一直被占着。如果忘了release(),连接就漏了。所以上面用了finally,确保不管成功还是失败都会释放。
坑二:事务里套事务
有的ORM会自动开事务,自己再开一个,可能出现死锁。保持简单,一个事务只做一件事。
坑三:事务太长
事务里如果做了很多操作,或者有网络请求,事务就会一直开着,锁也会一直占着,别的请求只能等着。尽量让事务短一点。
三、批量插入:别再一条一条插了
3.1 慢在哪
一开始我这样写的:
javascript
for (const user of users) {
await client.query('INSERT INTO users(name) VALUES($1)', [user.name]);
}
100条数据,循环100次,100次网络往返。慢得要死。
3.2 怎么优化
一条SQL插多行,一次发过去:
javascript
// 构造这样的SQL:
// INSERT INTO users(name) VALUES ('张三'), ('李四'), ('王五')
const values = [];
const placeholders = [];
for (let i = 0; i < users.length; i++) {
values.push(users[i].name);
placeholders.push(`($${i + 1})`);
}
const sql = `INSERT INTO users(name) VALUES ${placeholders.join(', ')}`;
await client.query(sql, values);
100条数据变成1次网络往返,快多了。
3.3 注意别超限
不过SQL语句太长也不行。数据库有个参数max_packet_size,语句太长会被截断。如果一次性要插几千条,建议分批,每批500条左右。
四、流式查询:别把所有数据都塞内存里
4.1 内存爆了怎么办
有次我要导出100万条数据,直接用query查,结果Node进程内存直接飙到2G,崩了。
因为query会把所有结果一次性加载到内存。
4.2 用游标解决
游标可以一批一批地拿数据,不积压。
javascript
// 先开始事务(游标必须在事务里用)
await client.query('BEGIN');
// 创建游标
await client.query('DECLARE my_cursor CURSOR FOR SELECT * FROM big_table');
let hasMore = true;
while (hasMore) {
// 一次拿1000条
const res = await client.query('FETCH 1000 FROM my_cursor');
if (res.rows.length === 0) {
hasMore = false;
} else {
// 处理这一批
for (const row of res.rows) {
// 逐行处理,不会爆内存
await processRow(row);
}
}
}
// 关游标、提交事务
await client.query('CLOSE my_cursor');
await client.query('COMMIT');
这样100万条数据也能稳稳地跑。
4.3 什么时候用
- 数据量超过10万行
- 不确定数据量有多大
- 需要逐行处理(比如写到文件、发到另一个API)
如果数据量小,直接用query就行,杀鸡不用牛刀。
五、错误处理(血的教训)
5.1 最常见的几种错误
| 错误信息 | 什么情况 | 怎么办 |
|---|---|---|
connect ECONNREFUSED |
数据库没开或IP错了 | 检查数据库状态 |
23505 |
唯一约束冲突(重复插入) | 提示用户"数据已存在" |
23503 |
外键找不到 | 检查关联数据 |
57P01 |
连接被数据库关闭 | 重连 |
| 连接泄漏 | 忘了调用done()或release() |
检查代码,用finally兜底 |
5.2 我的兜底写法
javascript
async function safeQuery(pool, sql, params) {
const client = await pool.connect();
try {
return await client.query(sql, params);
} catch (err) {
// 唯一约束冲突
if (err.code === '23505') {
throw new Error('这条数据已经存在了');
}
// 连不上了,记个日志再抛出去
if (err.code === 'ECONNREFUSED') {
console.error('数据库连不上了', err);
}
throw err;
} finally {
client.release(); // 不管怎样都要还
}
}
六、生产环境配置参考
下面是我现在比较顺手的一套配置:
javascript
const pool = new Pool({
// 连接参数
user: 'SYSTEM',
host: '127.0.0.1',
database: 'TEST',
password: process.env.DB_PASSWORD, // 密码不要写死在代码里
port: 54321,
// 连接池参数
max: 20, // 最大连接数
idleTimeoutMillis: 30000, // 空闲30秒断开
connectionTimeoutMillis: 3000, // 3秒拿不到就报错
statement_timeout: 10000, // SQL执行10秒超时
query_timeout: 10000
});
密码放环境变量里,不要直接写代码里。
七、小结
下篇主要讲了这几件事:
- 连接池 :解决连接数爆炸的问题,记得用
pg-pool,别忘了调用release() - 事务 :用
BEGIN和COMMIT把操作包起来,保证数据一致性 - 批量插入:一条SQL插多行,不要循环插入
- 流式查询:数据量大用游标,防止内存爆掉
- 错误处理:区分错误类型,有兜底方案
两篇加在一起,从安装到生产环境部署都覆盖了。Node.js连金仓这块,网上资料不多,很多坑都是自己试出来的。希望这两篇文章能帮你省点时间。