简介:这是一份面向用友U8 cloud开发人员与实施顾问的持久层技术官方红皮书,系统讲解新一代云ERP在数据访问层的设计原理与实战用法。内容以JDBC FrameWork为主线,覆盖JdbcSession创建、数据库连接与事务参数设置、查询结果集的单条/多条/流式处理、无参/带参/批量更新实现,以及日期时间与Blob/Clob等特殊参数类型的处理方式,并穿插异常捕获、日志记录、重试机制等排错思路。目录还延伸到普通Java Bean对象的持久化,包含对象定义、数据映射构造、对象读取与写入等环节,便于开发人员按图索骥完成数据层编码与优化。资源共1个PDF文件,压缩包大小约493KB,定位精炼、可直接查阅或打印,适合需要快速理解U8 cloud持久层机制、提升数据库交互性能与安全性的中高级开发人员。目前已有92人学习下载。
1. 这份红皮书解决的是ERP落库的最后一公里
拿到 U8 cloud V1.0 持久层技术红皮书,先别被"持久层"三个字劝退。它讨论的其实是业务系统里最频繁、也最容易出的事:界面上点保存,数据怎么经过实体、事务、多组织隔离,最后落到数据库;第二天报表要怎么把数据查出来。云ERP和单机软件不一样,多组织、多租户、多数据库支持都压在这一层。红皮书的价值在于给出了一套规则,让参与二开的人不用靠猜。这篇笔记不像读书笔记一样复述目录,而是告诉你这种红皮书该从哪几个角度去用:先立概念,再落配置,最后用慢查询日志把参数抠干净。适合做U8 cloud实施、二开和运维的开发者,尤其是已经被数据串号、连接超时这类问题折磨过的人。
2. 先立住概念:持久层不是写SQL,而是订规则
2.1 持久层架构:从实体到表的一整套约定
U8 cloud这类云ERP的持久层,远远不止一个"数据库访问工具"。打开红皮书,第一件事通常是给实体(Entity)、数据访问(DAO)、数据库方言这三层做定义。实体对应业务表,DAO封装查询,方言负责适配不同数据库。三者的关系很像生产线的模具、机械臂和控制程序:实体是模具,DAO是机械臂,方言是控制程序里的适配器。
我习惯于先把实体类当作数据库表的"镜子"。红皮书里最大的篇幅往往不在XML映射,而在实体的字段类型、命名规范和主键策略。比如单据号这类业务主键和自增代理主键要分开,日期要统一用标准类型而非字符串,金额字段要指定精确到小数点多少位。这些约定一旦在持久层定下来,后面所有查询和报表都会受益。
实际落地时,实体的样子大概是这样的:
@Entity @Table(name = "so_order", schema = "u8cloud") public class SoOrder { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "order_no", length = 60, nullable = false) private String orderNo; @Column(name = "org_id", updatable = false) private Long orgId; @Column(name = "amount", precision = 20, scale = 4) private BigDecimal amount; }这里有两个值得注意的参数:GenerationType.IDENTITY让主键交给数据库自增,适合MySQL;precision = 20, scale = 4明确金额位数,避免Oracle和MySQL对decimal的默认行为不一致。org_id字段标记updatable = false,是因为组织和租户归属在创建后不允许改。
从红皮书的角度看,持久层规则也包括了字段命名映射。比如JPA默认把orderNo映射为order_no,看似简单,但ERP的存量表很可能用order_nbr这种非标准命名,这个时候就需要在@Column里显式写列名,否则启动校验就会失败。主键策略的选择也很关键,不同数据库有不同偏好:
| 策略 | 适用数据库 | 特点 |
|---|---|---|
| IDENTITY | MySQL/SQL Server | 自增序列,插入后自动回填主键 |
| SEQUENCE | Oracle | 从序列取值,可批量预取,但可能产生主键空洞 |
| ASSIGNED | 业务主键 | 手动指定,适合单据号或跨系统迁移数据 |
红皮书里通常建议云ERP场景优先用代理主键加业务唯一索引,这样既避免自然主键耦合业务,又能在数据合并时保持稳定。我在二开时见过不少把order_no直接当主键的旧表,一旦要做组织合并就痛不欲生。
2.2 数据库方言:为什么SQL不能写死
云ERP的一个重要特征是交付时不知道客户用哪一种数据库。U8 cloud V1.0那个时期,常见组合是MySQL和Oracle并存。持久层如果不处理方言,分页、日期计算、字符串函数这些SQL就会在迁移数据库时集体翻车。
以分页为例,MySQL用LIMIT offset, size,Oracle要去套一层ROWNUM:
-- MySQL SELECT * FROM so_order ORDER BY create_time DESC LIMIT ?, ?; -- Oracle SELECT * FROM ( SELECT t.*, ROWNUM rn FROM so_order t ORDER BY create_time DESC ) WHERE rn BETWEEN ? AND ?;这类差异在业务SQL里几乎是无处不在的。红皮书里通常会有一个"数据库方言"配置项,在Spring框架下,常见做法是给JPA指定database-platform,或者给MyBatis指定不同的方言类。比如下面这段配置:
spring: jpa: database-platform: org.hibernate.dialect.MySQL8Dialect properties: hibernate: dialect: org.hibernate.dialect.MySQL8Dialect注意database-platform和dialect不要同时冲突。我碰过的翻车就是开发环境MySQL用的这个配置,测试环境Oracle忘了切换,结果分页SQL直接语法错误。所以方言配置最好是按环境拆配置文件,而不是写死。
方言层的另一个作用是处理保留字。比如ORDER、GROUP这些词在Oracle里可以直接用,在MySQL里要加反引号。红皮书会把这类差异也归到持久层适配范围。二开时如果接到一个新的报表需求,第一件事不是写SQL,而是确认这个SQL在两个目标数据库上都跑得通。很多"为什么同一套代码换个库就报错"的问题,根源都在方言没隔离。
2.3 事务传播与多组织数据权限
事务是持久层最容易产生"黑匣子"感的地方。U8 cloud里一个保存操作往往要写十张表:单据头、单据体、财务凭证、库存记录。如果中途失败,事务必须回滚。更麻烦的是,一个服务方法可能被另一个方法调用,事务传播行为直接决定嵌套调用的回滚范围。
红皮书对事务边界的建议通常和Spring一致:读方法用NOT_SUPPORTED或SUPPORTS,写方法必须REQUIRED。事务配置的核心参数是传播行为、隔离级别和回滚规则。
@Service public class SoOrderServiceImpl implements SoOrderService { @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED) public void createOrder(SoOrder order) { soOrderDao.insert(order); soOrderItemDao.batchInsert(order.getItems()); inventoryService.deduct(order.getOrgId(), order.getItems()); } }rollbackFor = Exception.class是关键。ERP的业务里会有大量RuntimeException子类,也有检查异常。如果只写@Transactional,遇到检查异常可能不回滚,数据就残了。而Propagation.REQUIRED表示加入当前事务,外层的库存扣减失败也能带动单据回滚。
多组织数据权限是U8 cloud这类产品最特殊的一层。每个业务表几乎都有org_id字段,持久层要在查询时自动追加组织过滤条件。红皮书里一般叫"组织隔离"或"数据权限拦截"。常见的实现是做一个MyBatis/JPA的拦截器,在SQL执行前自动把org_id = ?拼进去:
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})}) public class OrgFilterInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { // 从上下文取出当前组织ID,拼接到SQL尾部 // 实际代码里要解析SQL并安全注入,避免破坏where条件 return invocation.proceed(); } }拦截器不是只对主查询生效,还要覆盖子查询、exists、关联查询。SQL里一旦出现org_id,都要确保它来自当前登录组织,而不是前端传参。这个拦截器的实现质量直接决定数据会不会串号。
3. 落地持久层配置:最小可运行的三步走
3.1 数据源与连接池配置:先跑通再优化
拿到红皮书,我习惯先配置数据源和连接池,因为这是持久层能跑起来的最低要求。U8 cloud V1.0后的项目多使用dynamic-datasource或Spring Boot原生数据源。下面是一个最小配置:
spring: datasource: dynamic: primary: master datasource: master: url: jdbc:mysql://10.0.0.8:3306/u8cloud?useSSL=false&rewriteBatchedStatements=true&characterEncoding=utf8 username: u8cloud_app password: "${DB_PASSWORD}" driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000连接池参数不能照抄网上模板。maximum-pool-size不是越大越好,它会吃掉数据库连接数。我一般按"应用实例数 × 峰值并发"来估算,单实例通常20左右,实例多了就往小调。max-lifetime要小于数据库的wait_timeout,通常设1800秒;idle-timeout比max-lifetime短,给连接回收留时间。
| 参数 | 典型值 | 说明 |
|---|---|---|
| maximum-pool-size | 10-30 | 根据实例数和DB连接上限估算 |
| minimum-idle | 3-5 | 低峰期保底连接数 |
| connection-timeout | 30000 | 等待连接的超时时间,过短易误报,过长会堆积故障 |
| max-lifetime | 1800000 | 小于数据库wait_timeout,避免被服务端杀连接 |
rewriteBatchedStatements=true这个参数对MySQL批量写入影响很大。没有它,JDBC默认会逐条发送批量插入,1000行数据可能要几百轮交互。加上它,驱动会重写为真正的多值插入,性能差距可能在一倍以上。红皮书如果讲性能优化,这绝对是第一份必调参数。
3.2 实体映射与缓存策略:让ORM按预期工作
数据源能连上之后,第二步是验证实体映射。持久层开发的大部分错误发生在字段名和类型不匹配上。红皮书里通常会强调:优先用显式映射,不要过度依赖Hibernate的自动命名策略。
@Table(name = "so_order_type") public class SoOrderType { @Id @Column(name = "id") private Long id; @Column(name = "type_code", length = 30) private String typeCode; @Column(name = "description", columnDefinition = "varchar(500) comment '单据类型描述'") private String description; }这里的columnDefinition是给DDL用的,生产环境如果是已有表,这个属性可以省掉。显式映射的收益是二开时能一眼看出Java字段和数据库列名的对应关系。排查问题时,不用再猜createTime到底是create_time还是created_date。
缓存的策略要克制。JPA的二级缓存和MyBatis的L2 cache在某些场景很好用,但U8 cloud这种多租户系统里,缓存键一旦漏掉org_id,就可能在组织间串数据。我的原则是:查询缓存只加在配置类字典表,业务单据不碰二级缓存,要用就按租户维度隔离。
写过一段JPA代码,就知道自动生成SQL对字段类型有多敏感。LocalDateTime和Date在JDBC驱动里映射到数据库时间类型,不同驱动版本行为不同。红皮书里一般建议持久层统一用LocalDateTime,并在数据库端使用DATETIME(3)或TIMESTAMP(3),否则毫秒被截断会导致排序不稳定。
3.3 从红皮书到项目:二开时如何找持久层入口
红皮书不能当小说从第一页读到最后一页。我做二开时,找出持久层入口的方法是:先从代码里找到xxxDao或xxxMapper接口,看它是否对应要操作的表,再顺着调用链找到Service。U8 cloud的二开项目里,持久层通常按实体、DAO、Service三层组织。
遇到需要新增一个查询字段时,不要直接在SQL里拼接。正确做法是先在实体上补字段,然后通过DAO接口的派生方法或查询对象去查询。比如Spring Data JPA的findByOrderNoContaining(String orderNo),就是让持久层根据方法名生成SQL。红皮书的"约定"部分教的就是这类规则。
如果二开用的是MyBatis体系,那入口就是XML里的<select>和<insert>。需要关注的参数是useGeneratedKeys和keyProperty,这个和JPA的主键策略是同一个问题。XML里SQL多了,建议坚持用resultMap,别偷懒用resultType=Map,否则字段类型和null值会让你排查时怀疑人生。
定位一条慢SQL的来源也有固定套路:先在数据库慢日志里拿到SQL文本,再截取核心表名去项目里搜,基本能锁定是哪个Mapper或Repository方法。U8 cloud二开项目里如果用了分页插件,日志里通常会有生成后的SQL语句,可以用这个反推原始方法调用。
4. 持久层避坑:V1.0上线前后最常见的5个翻车点
4.1 连接池连接超时
现象:系统刚上线的时候,早高峰偶发HikariPool-1 - Connection is not available, request timed out,页面出现大面积保存失败。
原因:连接池的maximum-pool-size被配置为50,但数据库实例的max_connections只有100,而应用居然部署了5个节点。5个节点加起来的连接池上限已经超过数据库能力,到了峰值自然就超时。更深一层是connection-timeout设了60秒,页面已经超时,连接还在排队。
解决:把maximum-pool-size调整为每个节点15,总量留出数据库30%的余量。同时把connection-timeout降到30秒,让失败请求尽快返回,而不是全堵在连接池里。还要检查数据库侧max_connections额外被后台任务、报表系统占用的量。
4.2 批量保存慢
现象:导入一张2000行的单据,执行超过3分钟,数据库CPU不高但网络往返极多。
原因:JDBC URL里没有rewriteBatchedStatements=true。驱动逐个执行批量插入,2000行就是2000次网络往返。此外实体主键用GenerationType.AUTO,Hibernate会先查一次序列再插入,又增加一次往返。
解决:JDBC URL加上rewriteBatchedStatements=true,主键策略改成IDENTITY。如果是Oracle环境,锁有较多场景可以用SEQUENCE并设置@SequenceGenerator的分配大小,但注意预取会导致主键空洞,业务上能接受就行。还有一种做法是分片提交,比如2000行按500行一次批量提交,把单条事务长度控制在合理范围。
4.3 事务明明写了却不回滚
现象:某个单号重复提交,后台抛了业务异常,页面报错,但单据头表里已经有半条数据。
原因:事务方法在同一个类里被另一个方法直接调用,这就是经典的this.createOrder()自调用。因为Spring事务是通过代理对象生效的,自调用绕过了代理,事务注解失效。
解决:把调用拆到不同Service,或者注入自身代理。更稳妥的是用TransactionTemplate手动控制:
@Autowired private TransactionTemplate transactionTemplate; public void createOrderWithTransaction(SoOrder order) { transactionTemplate.execute(status -> { soOrderDao.insert(order); soOrderItemDao.batchInsert(order.getItems()); return Boolean.TRUE; }); }优先用拆类方式,只有遗留代码才用TransactionTemplate。另外要注意@Transactional只对public方法生效,protected或private方法上是无效的,这点比自调用更隐蔽。
4.4 多组织数据串号
现象:A组织的数据权限顾问在测试环境登录B组织账号,居然在列表里看到了A组织的单据。
原因:持久层某个报表查询使用了原生SQL,SQL里只写了where order_status = ?,没有拼org_id。拦截器只拦截了标准DAO入口,对原生SQL和NativeQuery不生效。
解决:全局搜索代码里的createNativeQuery和自定义SQL,统一改成实体类查询或手工加上org_id条件。红皮书里强调的"组织隔离拦截器",要确保它应用到所有SQL入口,包括查询、更新、删除,否则串号只是时间问题。上线前测试用例里至少要覆盖"多租户交叉查询"这个场景。
4.5 分页查询慢
现象:单据列表打开要2秒,点下一页越来越慢,甚至超时。
原因:列表查询的count和分页用的LIMIT offset, size,offset到了几千之后,MySQL要扫描并丢弃前面的行。再加上列表里关联了3张表,排序字段没有索引,速度自然上不去。
解决:先把@Table上关联字段和排序字段的索引建好,比如(org_id, create_time)联合索引;再查看count查询是否走了覆盖索引。分页超过一定偏移量时,改用"上次最大ID"的方式翻页。还有一种做法是开启Hibernate的hibernate.query.fail_on_pagination_over_collection_fetch,尽早发现因分页导致的重复数据问题。
5. 用慢查询日志校准持久层:一个参数一个参数地抠
5.1 打开慢查询日志,先别急着优化
持久层调优的第一步不是换连接池参数,而是开启数据库慢查询日志,把真实跑的SQL捞出来。MySQL下可以临时开启:
SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1; SET GLOBAL log_queries_not_using_indexes = ON;long_query_time = 1是指超过1秒的SQL会被记下来。生产环境建议把阈值调到300ms到500ms,U8 cloud这类ERP的报表查询普遍在200ms左右,超过1秒已经影响体验。log_queries_not_using_indexes适合在压测阶段开,它会把没走索引的SQL全打出来,量可能很大,上线前要关掉。
用mysqldumpslow -s t可以按耗时排序看Top 20,重点看那些重复次数高且平均耗时高的SQL。这类SQL往往能对应到持久层的一个具体查询方法。如果看到某条SQL重复执行了几千次,大概率是N+1查询问题,根子在一对多关联没有做批量抓取。
5.2 针对Top SQL做映射级别调整
拿到慢SQL后,先看执行计划,再回代码里找对应实体。常见问题是实体字段用了函数导致索引失效。比如在@Column上直接映射了一个加解密字段,查询时对这个字段做函数运算,数据库没法走索引。
调整为在接口层先解密,或者把解密结果单独存一个冗余列。另一个习惯是给@Column明确列名、长度和类型,避免MySQL隐式类型转换把字符转成数字时放弃索引。
我自己的教训是:一次上线前只看功能没看慢日志,结果促销活动开始后数据库CPU直接跑满,才发现一个报表查询因为关联字段字符集不一致,走了全表嵌套循环。从那以后,每次发版前都会把慢查询阈值临时调到200ms,压一遍再关掉,对比前后基线。持久层调优没有玄学,全是把SQL捞出来一条条看出来的。希望帮到你。
本文还有配套的精品资源,点击获取