一、现象:单据保存从 1 秒变 15 秒
一个典型的现场:销售订单标准保存 1 秒内完成,加了二开插件(自动带出物料规格、校验库存、推送 WMS)之后,保存要 15 秒,明细超过 100 行直接超时回滚。
开发环境复现不了------因为测试单据只有 3 行。
这类问题基本集中在四个点:循环里查库、事件挂太宽、事务里做远程调用、查询走全表。下面按"错误写法 → 改法 → 代码"讲。
二、为什么开发环境测不出来
做二开的人几乎都遇到过:开发库跑得飞快,上线就慢。原因有三个,且在开发环境里天然被掩盖。
一是数据量。 开发库的物料几千条、单据几百张;生产库物料几万条、单据几十万张。同一条不带索引的查询,开发库 2 毫秒返回,生产库要 2 秒。执行计划相同,代价差三个数量级。
二是并发。 开发时一个人点保存,生产时十几个业务员同时提交。单笔都快,但同一批客户、同一个仓库的更新会撞在同一行上,锁等待才开始出现。这类问题在单线程测试里永远复现不了。
三是缓存。 开发时反复点同一张单,数据页和执行计划都在数据库缓存里;生产是冷启动、跨天、跨月,缓存命中率低得多。同一个 SQL,缓存命中和不命中能差十倍。
结论很直接:二开不能只用"功能跑通"验收,要按生产的量级验证------这也是后面压测那一节的原因。
三、坑 1:循环里逐行查库(N+1)
错误写法
// ❌ 每行都查一次数据库
var entity = this.View.BillBusinessInfo.GetEntity("FSaleOrderEntry");
int rows = this.Model.GetEntryRowCount("FSaleOrderEntry");
for (int i = 0; i < rows; i++)
{
long materialId = Convert.ToInt64(this.Model.GetValue("FMaterialID", i));
string sql = $"SELECT FNUMBER,FNAME FROM T_BD_MATERIAL WHERE FMATERIALID={materialId}";
var ds = DBUtils.ExecuteDynamicObject(this.Context, sql); // 每行一次往返
if (ds.Count > 0)
this.Model.SetValue("FNote", ds0"FNAME", i);
}
100 行 = 100 次数据库往返。单次 20ms,就是 2 秒,还没算解析和 GC。
改法:收集 ID → 批量查 → 字典匹配
// ✅ 一次批量查询,内存匹配
private Dictionary<long, string> LoadMaterialNames(List ids)
{
var map = new Dictionary<long, string>();
if (ids == null || ids.Count == 0) return map;
// 分批,避免 IN 过长(Oracle 1000 个上限)
foreach (var batch in ids.Distinct().Chunk(500))
{
string inClause = string.Join(",", batch);
string sql = $"SELECT FMATERIALID,FNAME FROM T_BD_MATERIAL WHERE FMATERIALID IN ({inClause})";
var ds = DBUtils.ExecuteDynamicObject(this.Context, sql);
foreach (var row in ds)
{
long id = Convert.ToInt64(row["FMATERIALID"]);
if (!map.ContainsKey(id))
map[id] = Convert.ToString(row["FNAME"]);
}
}
return map;
}
public override void AfterBindData(EventArgs e)
{
base.AfterBindData(e);
int rows = this.Model.GetEntryRowCount("FSaleOrderEntry");
if (rows == 0) return;
var ids = new List<long>(rows);
for (int i = 0; i < rows; i++)
{
object v = this.Model.GetValue("FMaterialID", i);
if (v != null) ids.Add(Convert.ToInt64(v));
}
var names = LoadMaterialNames(ids); // 1 次(或几次)查询
for (int i = 0; i < rows; i++)
{
object v = this.Model.GetValue("FMaterialID", i);
if (v == null) continue;
if (names.TryGetValue(Convert.ToInt64(v), out string name))
this.Model.SetValue("FNote", name, i);
}
}
要点:Chunk(500) 分批防 SQL 过长;字典 key 用内码而不是编码(编码可为空、可重复)。
四、坑 2:每个事件都全量重算
BOS 表单插件的事件链很长:AfterBindData(加载后)、DataChanged(字段变化)、BeforeSave、AfterSave、BarItemClick......
常见错误是把同一套计算挂在多个事件上,或者不做"值没变"的短路判断。用户改个数量,整单逻辑重跑。
// ❌ 每次字段变化都整单重算
public override void DataChanged(DataChangedEventArgs e)
{
base.DataChanged(e);
RecalcAllAmount(); // 内部遍历所有行
}
private void RecalcAllAmount()
{
int rows = this.Model.GetEntryRowCount("FSaleOrderEntry");
for (int i = 0; i < rows; i++)
{
decimal qty = Convert.ToDecimal(this.Model.GetValue("FQty", i) ?? 0);
decimal price = Convert.ToDecimal(this.Model.GetValue("FPrice", i) ?? 0);
this.Model.SetValue("FAmount", qty * price, i); // 每行都触发一次界面刷新
}
}
改法:只算当前行 + 值未变则跳过
// ✅ 只处理当前行,且值相同不重复赋值
public override void DataChanged(DataChangedEventArgs e)
{
base.DataChanged(e);
if (e.Field == null) return;
string key = e.Field.Key;
if (key != "FQty" && key != "FPrice" && key != "FMaterialID") return;
int row = e.Row;
decimal qty = Convert.ToDecimal(this.Model.GetValue("FQty", row) ?? 0);
decimal price = Convert.ToDecimal(this.Model.GetValue("FPrice", row) ?? 0);
decimal amount = Math.Round(qty * price, 2);
object old = this.Model.GetValue("FAmount", row);
if (old != null && Convert.ToDecimal(old) == amount) return; // 短路
this.Model.SetValue("FAmount", amount, row);
}
配套原则:同一份数据一次事件只算一次;跨行汇总类逻辑放 BeforeSave,不要放 DataChanged。
五、坑 3:保存事务里调外部接口
// ❌ 事务中间同步请求外部系统
public override void BeforeSave(BeforeSaveEventArgs e)
{
base.BeforeSave(e);
var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
var resp = client.PostAsync("http://wms/api/order", content).Result; // 阻塞 + 长事务
}
后果:WMS 慢 3 秒,本单事务多持有 3 秒行锁;WMS 超时,整单回滚,业务员白填。
改法:事务后异步推送,失败可重投
// ✅ 保存成功后落中间表,由定时任务推送
public override void AfterSave(AfterSaveEventArgs e)
{
base.AfterSave(e);
if (!e.IsSuccess) return;
long pkId = Convert.ToInt64(this.Model.GetPKValue());
string sql = @"INSERT INTO JD_WMS_QUEUE(FBILLID,FBILLNO,FSTATUS,FCREATETIME)
VALUES(@FID,@FNO,'PENDING',SYSDATE)";
var p = new List<SqlParam>
{
new SqlParam("@FID", KDDbType.Int64, pkId),
new SqlParam("@FNO", KDDbType.String, Convert.ToString(this.Model.GetValue("FBillNo")))
};
DBUtils.Execute(this.Context, sql, p);
}
定时任务侧消费队列:成功置 DONE,失败累计次数置 FAIL 并告警。宁可晚几秒同步,也不要把外部系统的不确定性塞进自己的事务。
六、坑 4:查询走全表 / 不分页
// ❌ 无过滤条件,取全表再内存筛
var all = DBUtils.ExecuteDynamicObject(this.Context, "SELECT * FROM T_SAL_ORDERENTRY");
// ❌ 用明细字段过滤,该字段通常无索引
string sql = "SELECT * FROM T_SAL_ORDERENTRY WHERE FMATERIALID=" + id;
改法:
- 查询必须带条件,优先用主键、单据头字段、有索引的基础资料字段;
- 明细表按 FID(有外键索引)过滤,再在内存里按物料匹配;
- 列表查询必须分页,页大小控制在 200 以内;
-
上线前看执行计划,确认没有 TABLE ACCESS FULL。
七、大单据体:分批与列裁剪
单据体动辄几百行,处理方式对性能影响很大。四个常见毛病:
一是 SELECT *。 取回十几列,实际只用两列,多出来的字段既占网络带宽也占内存,还会让索引失效的概率变高。只取需要的列。
二是循环里刷新界面。 每设置一个值就调一次界面刷新,几百行就是几百次重绘。正确做法是批量赋值之后统一刷新一次。
三是循环里 GetValue 逐行取值。 每次取值都要走一遍数据包定位。正确做法是一次取回数据包对象,在内存里遍历。
四是不分批。 一次处理几千行,内存和事务都吃不消。按 500 行一批处理,批与批之间可以释放中间对象。
// ✅ 一次取数据包,循环内只做内存计算
var entity = this.View.BillBusinessInfo.GetEntity("FSaleOrderEntry");
var rows = this.Model.GetEntityDataObject(entity); // 一次取回
foreach (DynamicObject row in rows)
{
decimal qty = Convert.ToDecimal(row"Qty" ?? 0);
decimal price = Convert.ToDecimal(row"Price" ?? 0);
// 只读内存,不再访问数据库;需要回写时统一批量 SetValue
}
this.View.UpdateView("FSaleOrderEntry"); // 最后统一刷新
八、常用字段表
T_SAL_ORDER(销售订单头)
字段
类型
说明
FID
NUMBER(19)
单据内码(主键)
FBILLNO
VARCHAR2(80)
单据编号
FDATE
DATE
单据日期
FCUSTID
NUMBER(19)
客户内码
FSALESORGID
NUMBER(19)
销售组织
FDOCUMENTSTATUS
CHAR(1)
单据状态(A创建/B审核/C关闭)
T_SAL_ORDERENTRY(销售订单明细)
字段
类型
说明
索引
FENTRYID
NUMBER(19)
明细内码(主键)
PK
FID
NUMBER(19)
单据内码(外键)
有
FSEQ
INT
行号FMATERIALID
NUMBER(19)
物料内码
通常无
FQTY
NUMBER(23,10)
数量FPRICE
NUMBER(23,10)
单价FAMOUNT
NUMBER(23,10)
金额FSTOCKID
NUMBER(19)
仓库FDELIVERYDATE
T_BD_MATERIAL(物料)
DATE
交货日期
字段
类型
说明
FMATERIALID
NUMBER(19)
物料内码(主键)
FNUMBER
VARCHAR2(80)
物料编码
FNAME
NVARCHAR2(510)
物料名称
FBASEUNITID
NUMBER(19)
基本单位
FFORBIDSTATUS
CHAR(1)
禁用状态(A正常/B禁用)
表名/字段名以现场版本为准,T_SAL_ORDERENTRY 在不同版本可能落在分表或扩展表,取字段前先 DESC 确认。
九、高频报错表
报错信息
常见原因
处理
KDBusinessException:单据保存超时,请缩小数据范围
保存事件里循环查库或事务过长
改批量查询;外部调用移出事务
System.Data.SqlClient.SqlException:等待操作超时 / Timeout expired
查询无索引、全表扫描、结果集过大
看执行计划;补过滤条件;加分页
ORA-00054:资源正忙,但指定以 NOWAIT 方式获取资源
事务持锁时间过长(远程调用/大批量更新)
缩短事务;拆成小批次提交
Kingdee.BOS.KDException:插件执行超时
单个事件耗时超过阈值
拆逻辑;耗时操作改异步
InvalidOperationException:已有打开的与此 Command 相关联的 DataReader
嵌套查询时上一个 Reader 未关闭
先把结果 ToList() 取完再发起下一次查询
NullReferenceException(AfterBindData 内)
单据体无行、字段 Key 写错
先判 rowCount == 0;字段 Key 用 GetEntity 校验
十、上线前的四条硬规矩
- 能配置不写码:标准参数、单据转换、工作流能解决的,不加插件。
- 查询必批量:禁止在 for/foreach 里发起数据库访问。
- 事务不碰外部:HTTP、消息、文件操作全部移到事务之后。
- 压测按真实量:最大明细行数 × 并发人数,记录单笔耗时与 SQL 次数,对比改造前后。
插件还要能单独开关------出问题先停用,别让整张单据不可用。这是保命的最后一层。
十一、性能对比怎么做,才不是自说自话
改造前后必须能量化,否则"感觉快多了"没有说服力。建议只测四个指标,测三轮取中位数。
指标
怎么取
参考阈值
单笔保存耗时
插件内 Stopwatch 打点,写日志
与改造前对比降幅
单次操作 SQL 条数
数据库会话统计或执行日志
明细行数相关,不应随行数线性增长
慢查询条数
数据库慢查询日志(>1s)
上线后不新增
应用服务器 CPU
压测期间采样
峰值不持续打满
public override void AfterBindData(EventArgs e)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
base.AfterBindData(e);
// ... 业务逻辑
}
finally
{
sw.Stop();
Logger.Info("SalOrder.AfterBindData cost={sw.ElapsedMilliseconds}ms " + "rows={this.Model.GetEntryRowCount("FSaleOrderEntry")}");
}
}
日志里带上行数很关键:同一段逻辑在 10 行和 200 行下的耗时差异,能直接暴露有没有 N+1。