1. 项目背景与需求分析
在ERP系统开发中,单据编号自增是一个基础但至关重要的功能。不同于简单的数据库自增ID,ERP单据编号通常需要满足以下业务需求:
- 按单据类型区分(如采购单PO开头、销售单SO开头)
- 包含日期信息(如20230715)
- 支持多级编号规则(部门+类型+日期+序号)
- 高并发环境下保证编号唯一性
传统方案直接在数据库层面使用序列(Sequence)或自增字段存在明显局限:
- 无法实现"前缀+日期+序号"的复杂格式
- 集群环境下可能产生重复编号
- 业务规则变更时需要修改数据库对象
2. 技术选型与方案设计
2.1 核心组件说明
Oracle 19c:企业级数据库选择,提供:
- 高性能序列对象
- 事务隔离级别控制
- 表锁机制保证数据一致性
SQLSugar:轻量级ORM框架优势:
- 简化数据库操作
- 内置多数据库支持
- 高性能批量操作
- 完善的文档和社区支持
2.2 设计方案对比
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 数据库序列 | 直接使用Oracle序列 | 实现简单 | 无法满足复杂编号规则 |
| 触发器 | 通过数据库触发器生成 | 业务透明 | 调试困难,性能瓶颈 |
| 程序控制 | 应用层代码生成 | 灵活可控 | 需要处理并发问题 |
最终选择程序控制+数据库事务的混合方案:
- 使用Oracle序列作为序号基础
- 应用层组装完整编号
- 通过数据库事务保证原子性
3. 具体实现步骤
3.1 数据库准备
-- 创建序列 CREATE SEQUENCE SEQ_ERP_ORDER_NO START WITH 100000 INCREMENT BY 1 NOCACHE NOCYCLE; -- 创建单据表 CREATE TABLE ERP_ORDERS ( ORDER_ID VARCHAR2(50) PRIMARY KEY, ORDER_TYPE VARCHAR2(10), ORDER_DATE DATE, -- 其他业务字段... CREATE_TIME TIMESTAMP DEFAULT SYSTIMESTAMP );3.2 C#实现代码
public class OrderService { private readonly SqlSugarClient _db; public OrderService(SqlSugarClient db) { _db = db; } public string GenerateOrderNo(string orderType) { // 获取当前日期部分 string datePart = DateTime.Now.ToString("yyyyMMdd"); // 获取序列值 long seqNo = _db.Ado.GetInt64("SELECT SEQ_ERP_ORDER_NO.NEXTVAL FROM DUAL"); // 组合完整编号 return $"{orderType}-{datePart}-{seqNo:D5}"; } public bool CreateOrder(Order order) { try { _db.Ado.BeginTran(); // 生成单据编号 order.OrderId = GenerateOrderNo(order.OrderType); // 设置创建时间 order.CreateTime = DateTime.Now; // 插入数据 int count = _db.Insertable(order).ExecuteCommand(); _db.Ado.CommitTran(); return count > 0; } catch { _db.Ado.RollbackTran(); throw; } } }3.3 并发控制实现
// 使用锁机制防止并发问题 private static readonly object _lockObj = new object(); public string SafeGenerateOrderNo(string orderType) { lock(_lockObj) { return GenerateOrderNo(orderType); } }4. 高级应用与优化
4.1 按单据类型区分序列
-- 为每种单据类型创建独立序列 CREATE SEQUENCE SEQ_PO_NO START WITH 1000; CREATE SEQUENCE SEQ_SO_NO START WITH 1000;4.2 使用Oracle高级队列
对于超高并发场景:
// 使用Oracle AQ实现消息队列 public string QueueGenerateOrderNo(string orderType) { var queue = _db.Ado.GetOracleAQQueue("ORDER_NO_QUEUE"); var msg = new OracleAQMessage { Payload = orderType }; queue.Enqueue(msg); // ...处理返回消息 }4.3 编号规则配置化
通过配置表实现灵活规则:
CREATE TABLE NO_GENERATE_RULE ( RULE_ID VARCHAR2(20), PREFIX VARCHAR2(10), DATE_FORMAT VARCHAR2(20), SEQ_NAME VARCHAR2(30), PADDING_LENGTH NUMBER );5. 常见问题与解决方案
5.1 性能问题排查
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 编号生成慢 | 序列缓存设置过小 | 调整CACHE参数 |
| 高并发重复 | 事务隔离级别问题 | 使用SERIALIZABLE隔离级别 |
| 编号不连续 | 事务回滚导致序列跳号 | 改用NOCACHE序列 |
5.2 分布式环境处理
集群环境下建议:
- 使用中心化编号服务
- 每个节点分配固定前缀
- 结合Redis等分布式锁
// 使用Redis分布式锁 public string DistributedGenerateNo(string orderType) { using(var redLock = redLockFactory.CreateLock("OrderNoLock", TimeSpan.FromSeconds(10))) { if(redLock.IsAcquired) { return GenerateOrderNo(orderType); } throw new Exception("获取编号锁超时"); } }6. 最佳实践建议
- 编号长度控制:建议不超过20字符,考虑数据库索引性能
- 可读性设计:使用有意义的前缀(PO-、SO-)
- 历史数据迁移:旧系统迁移时保留原编号,新建表记录映射关系
- 监控机制:记录编号生成日志,便于审计和问题追踪
重要提示:生产环境务必进行压力测试,模拟至少200并发下的编号生成情况
7. 扩展思考
- 断号处理:业务上是否需要严格连续?可考虑定期维护任务填补断号
- 多时区支持:全球化系统需明确使用服务器时区还是用户时区
- 离线模式:移动端离线工作时,可采用"临时编号+同步后替换"的方案
实际项目中,我们曾遇到单据编号被恶意猜测的安全问题,最终解决方案是:
- 在编号中加入随机校验位
- 建立编号使用记录表
- 设置每日编号生成上限