金蝶云星空 BOS 插件性能优化:把 N+1 查询、事件重算和长事务干掉

一、现象:单据保存从 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
    DATE
    交货日期

    T_BD_MATERIAL(物料)
    字段
    类型
    说明
    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 校验
    十、上线前的四条硬规矩
  1. 能配置不写码:标准参数、单据转换、工作流能解决的,不加插件。
  2. 查询必批量:禁止在 for/foreach 里发起数据库访问。
  3. 事务不碰外部:HTTP、消息、文件操作全部移到事务之后。
  4. 压测按真实量:最大明细行数 × 并发人数,记录单笔耗时与 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。
相关推荐
程序员清风1 小时前
大文件处理与性能优化:分块读取、类型优化与向量化
性能优化
汪海游龙17 小时前
流式回复结尾的那一下闪:逐帧定位 Compose 聊天列表的三处布局跳动
android·性能优化·kotlin
ytadpole17 小时前
模板下载性能优化记录
后端·性能优化
小卿噢20 小时前
你的日志可能吃掉了 40% 的一个 CPU 核
性能优化
志尊宝21 小时前
AI原生前端开发(018):端侧AI性能优化:模型加载、推理加速
人工智能·性能优化·ai-native
打工仔折腾 AI1 天前
Linux 网络编程:从零掌握 TCP Socket 编程与 C++ 实战落地
linux·网络·人工智能·后端·python·tcp/ip·性能优化
打工仔折腾 AI1 天前
深入验证 DeepSeek 新模型开源声明:KV 缓存指标与按次计费的后端落地评估
缓存·性能优化·开源·ai agent 实战
隔窗听雨眠1 天前
开源为骨自研为魂:存储统一管理平台的混合架构实践
性能优化·实战教程·原理解析·踩坑记录
科威网络2 天前
企业网站技术方案:响应式、SEO 结构与性能优化实践
性能优化·需求分析·软件需求