接手一个烂摊子之后:金仓数据库开发规范实战笔记
从一个凌晨三点的故障说起
去年接手一个电商中台项目,上线才两个月就开始频繁出问题。
最严重的一次是凌晨三点,订单创建接口大面积超时。排查到最后发现是一张订单表,三个月的数据量不到一千万条,但每秒钟几百个INSERT操作,加上十几个索引的维护开销,把服务器的IO完全打满了。
打开那张表的定义,我惊呆了:
- 67个字段,其中15个是VARCHAR(500)以上
- 13个索引,有一半从来没有被查询用过
- 没有主键,用的一个联合唯一索引代替
- 表的fillfactor是默认的100,意味着UPDATE会产生大量版本链
这不是个案,是开发规范缺失的典型后果。接手之后,我花了两周时间梳理了一套针对金仓数据库的开发规范,现在拿出来分享一下。
一、整体设计原则:先把大方向定下来
1.1 字符集统一
金仓支持多种字符集,但同一个实例里混用UTF8和GBK,会在跨库查询时出现乱码或转换开销。我们的规矩是:所有实例统一用UTF8。
sql
-- 创建数据库时明确指定字符集
CREATE DATABASE order_db ENCODING = 'UTF8';
1.2 每个应用独立SCHEMA
不要把所有表都扔在public里。不同应用、不同模块用独立的SCHEMA隔离,权限管理也方便。
sql
-- 为订单服务创建独立SCHEMA
CREATE SCHEMA order_svc AUTHORIZATION order_app;
-- 设置默认SCHEMA
SET search_path TO order_svc, public;
1.3 表的硬性约束
- 单表列数不超过80列
- 必须有主键或唯一约束
- 外键必须建索引,主外键类型要一致
- 触发器能不用就不用
sql
-- 正确的建表示例
CREATE TABLE order_svc.t_orders (
order_id BIGSERIAL,
order_no VARCHAR(32) NOT NULL,
user_id INTEGER NOT NULL,
amount DECIMAL(12,2) NOT NULL,
status SMALLINT DEFAULT 0,
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_orders_order_id PRIMARY KEY (order_id),
CONSTRAINT uk_orders_order_no UNIQUE (order_no)
);
1.4 大表处理策略
金仓官方建议:单表超过5000万条或100GB,就要考虑分区或归档。我们在实践中把阈值定得更保守一些------2000万条就触发评估。
sql
-- 按月分区示例
CREATE TABLE order_svc.t_orders_202501 PARTITION OF order_svc.t_orders
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
1.5 大对象字段的处理
图片、文件不要直接存数据库。金仓虽然支持BLOB/CLOB,但大对象和业务数据混在一起,IO会成为瓶颈。
我们定的规则是:
- 文件存OSS或NAS,数据库只存路径
- 实在要存LOB,单独建一张表存放,通过外键关联
sql
-- 大对象单独存储
CREATE TABLE order_svc.t_order_attachments (
attach_id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL,
file_path VARCHAR(256) NOT NULL,
file_size INTEGER,
-- 不要在大对象字段上建索引
file_content BLOB
);
二、命名规范:让代码可读可维护
命名混乱是很多项目的通病。一套清晰的命名规范,能让新人接手时少花一半时间。
2.1 表命名
格式:TB_ + 应用名 + 模块名 + 表描述
sql
-- 示例
TB_SHOP_ORDER -- 订单表
TB_SHOP_PRODUCT -- 商品表
TB_SHOP_USER -- 用户表
2.2 索引命名
sql
-- 普通索引:IDX_表名_字段1_字段2
CREATE INDEX idx_order_user_id ON t_orders(user_id);
-- 唯一索引:UID_表名_字段
CREATE UNIQUE INDEX uid_order_order_no ON t_orders(order_no);
-- 主键:PK_表名_主键列
ALTER TABLE t_orders ADD CONSTRAINT pk_orders_order_id PRIMARY KEY (order_id);
2.3 其他对象命名
| 对象类型 | 前缀 | 示例 |
|---|---|---|
| 视图 | V_ | v_order_summary |
| 序列 | SEQ_ | seq_order_id |
| 函数 | FUNC_ | func_calc_amount |
| 存储过程 | P_ | p_refresh_order |
| 临时表 | TMP_ | tmp_order_import_20250417_zhang |
一个重要提醒:所有对象名长度不要超过30个字符。金仓对长对象名虽然支持,但会给后续维护带来麻烦。
三、字段设计:选对类型比什么都重要
3.1 类型选择的几个原则
原则一:用对类型,别用字符存数字
sql
-- 错误:用字符存日期
create_time VARCHAR(20) -- ❌
-- 正确:用DATE类型
create_time DATE -- ✅
-- 错误:用字符存金额
amount VARCHAR(20) -- ❌
-- 正确:用DECIMAL
amount DECIMAL(12,2) -- ✅
原则二:选最小的够用类型
sql
-- 状态字段用SMALLINT(2字节),别用INTEGER(4字节)
status SMALLINT DEFAULT 0
-- 年龄用SMALLINT就够了
age SMALLINT
-- 定长字符串用CHAR,变长用VARCHAR
country_code CHAR(2) -- 固定2位
address VARCHAR(200) -- 长度不固定
原则三:能用数值不用字符
数值类型比较效率比字符串高得多。这个差异在大表关联查询时特别明显。
3.2 填坑经验:fillfactor的设置
金仓有一个很实用的参数叫fillfactor,控制每个数据页的填充率。默认是100,表示写满。
对于频繁UPDATE的表,建议设为80:
sql
CREATE TABLE order_svc.t_order_status_log (
log_id BIGSERIAL,
order_id BIGINT NOT NULL,
old_status SMALLINT,
new_status SMALLINT,
change_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) WITH (fillfactor = 80);
为什么?金仓的UPDATE本质上是标记旧行+插入新行。如果页面满了,新行只能放到其他页面,导致读取时需要扫描多个页面。预留20%的空间,可以让新行留在同一个页面内(这就是HOT更新,Heap-Only Tuple),性能会好很多。
3.3 冗余字段:用空间换时间
大表关联查询代价很高。适当冗余一些字段,可以减少JOIN。
sql
-- 订单表里冗余用户姓名,避免每次都要关联用户表
CREATE TABLE t_orders (
order_id BIGSERIAL,
user_id INTEGER NOT NULL,
user_name VARCHAR(64), -- 冗余字段
amount DECIMAL(12,2)
);
代价是更新用户姓名时要同步更新订单表。需要在设计时权衡。
四、索引设计:少而精
4.1 索引数量的控制
金仓官方建议:单表索引不超过5个。我们内部的标准更严------核心表不超过3个。
为什么?每个索引都会增加INSERT、UPDATE、DELETE的开销。订单表每插入一条记录,要维护主键索引、唯一索引、普通索引...索引越多,写入越慢。
4.2 索引字段的选择
选择性原则:把过滤效果最好的字段放前面。
假设有两个字段:
- status:只有3种值,每个值占30%
- user_id:唯一值很多,每个值占0.01%
那么索引应该是(user_id, status)而不是(status, user_id)。
避免冗余索引:
sql
-- 已经有联合索引
CREATE INDEX idx_user_status ON t_orders(user_id, status);
-- 这个单字段索引就是冗余的,因为联合索引已经能覆盖
CREATE INDEX idx_user_id ON t_orders(user_id); -- ❌ 不需要
4.3 外键必须建索引
这是金仓官方特别强调的:如果子表外键没有索引,父表删除记录时会锁住子表所有记录。
sql
-- 子表
CREATE TABLE t_order_items (
item_id BIGSERIAL,
order_id BIGINT NOT NULL,
product_id INTEGER NOT NULL
);
-- 外键索引必须建
CREATE INDEX idx_order_items_order_id ON t_order_items(order_id);
4.4 分区表的索引策略
金仓的分区索引有个重要原则:分区索引必须包含分区列,且分区列要放在索引末尾。
sql
-- 按月分区的订单表
CREATE TABLE t_orders (
order_id BIGSERIAL,
order_no VARCHAR(32),
created_date DATE NOT NULL -- 分区键
) PARTITION BY RANGE (created_date);
-- 正确的分区索引:分区键在最后
CREATE INDEX idx_orders_order_no ON t_orders(order_no, created_date);
4.5 不同索引类型的使用场景
金仓支持B-tree、Hash、GIN、GiST、BRIN等多种索引类型。日常开发中B-tree覆盖了90%的场景,但有几种情况值得留意:
BRIN索引:适合时间序列表(日志、流水),索引很小但查询效率不错。
sql
-- 日志表用BRIN索引,几GB的数据索引可能只有几十MB
CREATE INDEX idx_log_created ON t_log USING BRIN(created_date);
GIN索引:适合数组、JSONB字段和全文检索。
sql
-- JSONB字段的GIN索引
CREATE INDEX idx_products_attrs ON t_products USING GIN(attributes);
五、SQL编写规范
5.1 绑定变量必须用
高并发场景下,不用绑定变量会导致SQL每次都要硬解析,CPU会被占满。
sql
-- 错误:拼接SQL
EXECUTE IMMEDIATE 'SELECT * FROM t_orders WHERE order_id = ' || v_id;
-- 正确:用绑定变量
EXECUTE IMMEDIATE 'SELECT * FROM t_orders WHERE order_id = $1' USING v_id;
5.2 避免隐式类型转换
sql
-- 假设user_id是INTEGER类型
-- 错误:传入字符串,触发隐式转换
SELECT * FROM t_orders WHERE user_id = '123';
-- 正确:传入数值
SELECT * FROM t_orders WHERE user_id = 123;
隐式转换会让索引失效,这个坑踩一次就记住了。
5.3 SELECT只取需要的列
sql
-- 错误:SELECT *
SELECT * FROM t_orders WHERE order_id = 12345;
-- 正确:只取需要的字段
SELECT order_no, amount, status FROM t_orders WHERE order_id = 12345;
在订单这种宽表上,SELECT *会多读大量不需要的数据,网络传输和内存占用都更大。
5.4 COUNT(*) vs COUNT(列)
sql
-- 统计行数,用COUNT(*)
SELECT COUNT(*) FROM t_orders WHERE status = 1;
-- 统计某列非NULL值数量,用COUNT(列)
SELECT COUNT(user_id) FROM t_orders;
COUNT(*)在金仓里优化得很好,不要自己写成COUNT(1)或COUNT(主键)。
六、连接池管理:那些容易被忽视的坑
6.1 连接数不是越多越好
一个常见的误区:并发高,就调大max_connections。
实际上,连接数超过CPU核心数的10倍,系统就会开始抖动。因为CPU大部分时间花在上下文切换上,而不是真正处理请求。
我们定的规则:每个CPU核心不超过10个连接。32核的服务器,连接数控制在300以内。
6.2 防止会话泄漏
会话泄漏是开发规范里最容易忽视的问题。异常处理不当,连接没释放,积少成多把连接池占满。
java
// Java代码示例:必须用try-with-resources或finally释放连接
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
// 执行SQL
} catch (SQLException e) {
// 记录日志,确保连接被关闭
log.error("Database error", e);
}
6.3 登录/注销策略
不要为每个SQL请求都创建新连接。连接建立的开销很大(TCP握手、认证、分配内存)。
正确做法:使用连接池,让连接复用。
七、写在最后
这套规范推行了半年,效果很明显:P1级故障从每月3-4次降到了半年1次,新人接手项目的上手时间也从2周缩短到了3天。
规范的目的是统一认知、减少踩坑,不是给开发设障碍。每一条规则的背后,都对应着一次真实的线上故障。希望这份总结对你有帮助。