第五部分:极限生存

0 阅读52分钟

第五部分:极限生存

第十四章 大数据导出OOM——绕开MyBatis,JDBC游标逐行写文件

本章核心:绕开MyBatis全量加载,JDBC游标逐行写文件,最小改动解决OOM。

核查工作一到,全省经办人同时导数据,系统就卡死。

社保基金审计、医保专项整治、新农保待遇核查——每次上级部署核查,全省几百个经办网点同时操作。有人导参保人员名单,有人导缴费明细,有人导待遇发放记录。数据量动辄几十万行,点下"导出"按钮,内存吃满,CPU飙升到80~100%,JVM不停Full GC。页面转圈转一两分钟,系统陷入"阵发性卡顿"。没崩溃,没报错,就是慢。经办人以为系统死了,反复刷新,越刷新越慢。县级工作人员最受罪——基层窗口办事群众在排队,参保缴费明细打印不出来,群众就投诉。投诉到市里,市里打电话到省里,省里打电话给我们。

先上了应急办法:限制并发,最多4个用户同时导出,超出的排队等。县级工作人员开始互相协调——"我先导,你等一下。""你导完了告诉我。"碰上月底集中核查,有人等到下班还没轮上,只能加班。投诉从"系统慢"变成了"排不上号",但至少系统不崩了。

止痛药不能一直吃。限制并发只是减少了同时吃内存的人数,没解决单次导出吃掉1GB内存的问题。根因在哪?

排查思路很直接:政务系统高并发少,正常情况下不应该忙。看数据库的IO和CPU——都很低,说明数据库没压力。排除数据库,问题就在应用侧。CPU高+数据库闲,就是Full GC在拼命回收。打一个heap dump,用MAT分析,大对象一目了然——MyBatis的List。

框架标准的getDao()走MyBatis流程:session.getMapper() → 反射调用Mapper方法 → MyBatis把结果集全部映射成Java对象 → 返回List。50万行×20个字段,每行约1~2KB,加起来就是约1GB堆内存。有人导大表的时候,JVM堆从正常的256MB一路飙升到1GB,GC回收不过来,最严重时直接java.lang.OutOfMemoryError——服务挂了,其他用户全部报错。更多时候没这么"戏剧性",没有OOM,没有异常日志——就是内存吃紧导致不停Full GC,系统一阵一阵地慢。

能不能让MyBatis流式返回?MyBatis支持ResultHandler,但我们的框架在getDao()里做了很多额外的事情(MongoDB路由、分页拦截、加解密等),直接改getDao()会影响其他功能。那就绕开MyBatis的结果集映射——只借用MyBatis的SQL解析能力,从XML配置里拿到最终的SQL和参数值,然后自己用JDBC的PreparedStatement执行。返回的是JDBC的ResultSet游标,数据还在数据库端,不会全部加载到内存。然后边读边写,ResultSet.next()是JDBC游标在数据库端逐行推进,每读一行处理一行写一行。内存中同时只有一行的数据量。

14.2 怎么做的

getBigResult()——绕开MyBatis的执行管道:

public static ResultSet getBigResult(Class calss, String MethodNmae,

Object... params) throws utilException {

// 借用getIbatisSql()解析SQL和参数

IbatisSql ibatisSql = getIbatisSql(null,

calss.getName() + "." + MethodNmae, params[0]);

String sql = ibatisSql.getSql();

Object[] value = ibatisSql.getValue();

// 自己创建PreparedStatement执行

Connection conn = session.getSession().getConnection();

PreparedStatement stmt = conn.prepareStatement(sql);

for (int i = 0; i < value.length; i++) {

stmt.setObject(i + 1, value[i]);

}

return stmt.executeQuery();

// 注意:不关闭stmt,ResultSet要交给write2file消费

}

SQL和参数还是从MyBatis的XML配置里来的,开发人员只维护XML。但执行走的是JDBC直连,绕开了MyBatis的结果集映射。

getIbatisSql()——从MyBatis内部提取SQL和参数:

getBigResult()调用了getIbatisSql(),它从MyBatis内部把SQL字符串和参数值"偷"出来,完整复刻了MyBatis的参数解析逻辑以支持各种参数类型(简单类型、Dao对象、Map、额外参数):

public static IbatisSql getIbatisSql(SqlSessionFactory sqlSessionFactory,

String id, Object parameterObject) {

IbatisSql ibatisSql = new IbatisSql();

MappedStatement ms = sqlSessionFactory.getConfiguration()

.getMappedStatement(id);

BoundSql boundSql = ms.getBoundSql(parameterObject);

ibatisSql.setSql(boundSql.getSql());

ibatisSql.setSqlType(ms.getSqlCommandType());

List parameterMappings = boundSql.getParameterMappings();

if (parameterMappings != null) {

Object[] parameterArray = new Object[parameterMappings.size()];

MetaObject metaObject = parameterObject == null ? null

: MetaObject.forObject(parameterObject);

for (int i = 0; i < parameterMappings.size(); i++) {

ParameterMapping pm = parameterMappings.get(i);

if (pm.getMode() != ParameterMode.OUT) {

String propertyName = pm.getProperty();

PropertyTokenizer prop = new PropertyTokenizer(propertyName);

Object value;

if (parameterObject == null) {

value = null;

} else if (ms.getConfiguration().getTypeHandlerRegistry()

.hasTypeHandler(parameterObject.getClass())) {

value = parameterObject;

} else if (boundSql.hasAdditionalParameter(propertyName)) {

value = boundSql.getAdditionalParameter(propertyName);

} else if (propertyName.startsWith(ForEachSqlNode.ITEM_PREFIX)

&& boundSql.hasAdditionalParameter(prop.getName())) {

value = boundSql.getAdditionalParameter(prop.getName());

if (value != null) {

value = MetaObject.forObject(value)

.getValue(propertyName.substring(

prop.getName().length()));

}

} else {

value = metaObject == null ? null

: metaObject.getValue(propertyName);

}

parameterArray[i] = value;

}

}

ibatisSql.setValue(parameterArray);

}

return ibatisSql;

}

为什么这么"绕"?因为必须确保拿到的参数值和MyBatis自己执行时拿到的完全一致。任何偏差都会导致SQL执行报错——参数数量不匹配、类型不对、顺序错乱,任何一个都会让整个导出失败。

返回的IbatisSql是三个字段:sql(SQL字符串)、value(参数值数组)、sqlType(SELECT/INSERT/UPDATE/DELETE)。getBigResult()里用isSelectSql()做了一次校验——只允许SELECT走大数据导出通道,防止误传写操作。

write2file()——游标逐行读取,边读边写:

while (result.next()) {

String row = "";

for (int i = 0; i < keys.size(); i++) {

Object val = result.getObject(keys.get(i));

// 代码翻译(如"1"→"男")

// ...

row += String.valueOf(val) + "\t";

}

bw.write(row + "\n");

}

内存中始终只有一行的数据量。输出的文件是Tab分隔的文本,扩展名用.xls,Excel可以直接打开。

write2file()完整逻辑——表头、代码翻译、逐行写入:

前面的代码只展示了核心循环。完整的write2file()方法还有两个关键逻辑:表头和代码翻译。

表头不是写死的。前端传过来的数据里包含header和decoder两个DataStore——header定义了导出列的name(字段名)和label(显示名),decoder定义了代码翻译规则。用户在界面上选择导出哪些列、列名叫什么,后端完全不用改:

HashMap<String, DataStore> stmap = AppContextContainer.getAppContext().getStoreMap();

DataStore dsHeader = stmap.get("header");

DataStore dsDecoder = stmap.get("decoder");

List keys = new ArrayList();

for (int hh = 0; hh < count; hh++) {

rowHeader += dsHeader.getRowset().getrow(hh).getItemValue("label") + "\t";

keys.add(dsHeader.getRowset().getrow(hh).getItemValue("name"));

}

bw.write(rowHeader + "\n");

然后在逐行循环里做代码翻译。政务系统的数据表里大量使用代码值——性别存的是"1"/"2",状态存的是"0"/"1"/"9",险种存的是"310"/"320"/"330"。导出给领导看不能直接导代码,必须翻译成中文。decoder DataStore保存了翻译规则,逐行匹配:

if (dsDecoder != null && decKeys.get(key) != null) {

for (int a = 0; a < dsDecoder.getRowset().getPrimary().size(); a++) {

if (key.equals(dsDecoder.getRowset().getrow(a).getItemValue("name"))

&& val.equals(dsDecoder.getRowset().getrow(a).getItemValue("col_value"))) {

val = dsDecoder.getRowset().getrow(a).getItemValue("col_name");

break;

}

}

}

匹配逻辑是两个字段联合:name等于字段名,col_value等于数据库里的代码值,命中后取col_name就是翻译后的中文。比如字段sex,值为"1",匹配到col_name是"男"。这个翻译是配置化的——前端传不同的decoder,同一个SQL的导出结果可以呈现不同的翻译。

Statement不能提前关闭——ResultSet依赖它,而write2file()还在消费ResultSet。

14.3 和标准getDao()的对比

对比项getDao()(标准)getBigResult()+write2file()
SQL来源MyBatis XML同样是MyBatis XML
结果处理全量映射成ListResultSet游标逐行
内存占用50万行≈1~2GB始终只有一行
适用场景常规查询大数据量导出

本章小结

• OOM不只是崩溃,更常见的是"阵发性卡顿":数据库IO/CPU低→政务系统高并发少→不忙就不该慢→指向应用侧Full GC。

• getBigResult()借用MyBatis的SQL解析,绕开结果集映射,用JDBC游标逐行读取,内存中始终只有一行。

• 该绕的时候必须绕——不要被框架的"标准流程"束缚。

决策洞察:框架是工具不是牢笼。当框架的标准流程解决不了问题时,敢于绕开它,用最朴素的方式直接面对问题本质。

第十五章 信创改造——注解驱动SM4加解密,前端一行代码不用改

本章核心:注解驱动加解密做在DBUtil管道里,业务代码零改动,前端无感知。

加解密上线那天,前端开发人员问我:"加解密什么时候开始做?"

我说:"已经上线了。"

他愣了一下:"我怎么不知道?"

几十个业务模块,几百张表,数不清的前端页面——加解密改造完成,前端代码零改动。前端开发人员甚至不知道这件事已经发生了。

这是怎么做到的?答案藏在一个设计决策里:加解密做在DBUtil管道里,不做在业务里。

15.1 问题是什么

信创改造来了。要求:敏感字段(身份证号、密码、姓名、账号等)入库必须加密存储,读取时解密还原,数据必须加签验签防篡改。

听起来不复杂,但我们的系统已经跑了很多年,几十个业务模块,几百张表,前端页面多得数不清。如果每个模块、每个接口、每个前端页面都改一遍,工期不可控,漏改改错的风险极高。

我们的目标是:业务代码(包括前端)零改动,只加注解就能实现加解密和加解签。

加解密在ORM层做——所有数据操作都经过DBUtil,在这里统一处理。用注解标记字段:@myCode(加解密)和@myMac(加签验签)。密码运算通过HTTPS调用独立部署的密码网关(信创合规要求)。

15.2 怎么做的

两个注解定义一切:

@myCode  // 标记需要加解密的字段

@myMac   // 标记需要加签验签的字段

以用户表为例:

public class User extends Dao {

@myCode @myMac

private String psn_account;

@myCode @myMac

private String psn_pwd;

@myCode @myMac

private String psn_name;

@myCode @myMac

private String psn_idcard;

// 其他字段不加注解,不参与加解密

private String psn_sex;

}

DBUtil管道——写入时自动加密加签:

SaveDao() → Mac()(先加签) → enCode()(再加密) → MyBatis执行SQL → 密文入库

DBUtil管道——读取时自动解密验签:

getDao() → MyBatis查询 → deCode()(先解密) → verMac()(再验签) → 明文返回

加解密发生在DBUtil层,这个位置选得非常精确——往上业务代码和前端看到的永远是明文,往下数据库里存的是密文。

四个核心方法:

• enCode():遍历Dao字段,把带@myCode的字段拼成JSON,一次调用SM4加密服务,再把密文写回字段;

• deCode():反向操作,批量解密;

• Mac():把带@myMac的字段值Base64拼接,算一次MAC,签名存到Dao基类的公共字段;

• verMac():拿明文重新算签名,和存储的签名比对,不匹配就抛异常。

enCode()源码级解读

public static void enCode(Dao objDao) throws Exception {
if (!"1".equals(configUtil.get("usageCode"))) {
return;
}
if (objDao.getClass().isAnnotationPresent(myCode.class)) {
Field[] fields = objDao.getClass().getDeclaredFields();
StringBuffer buffer1 = new StringBuffer();
for (Field field : fields) {
if (field.isAnnotationPresent(myCode.class)) {
field.setAccessible(true);
String value = String.valueOf(field.get(objDao));
field.setAccessible(false);
if (value != null && !"".equals(value) && !"null".equals(value)) {
String base64Value = Base64.encode(value.getBytes());
buffer1.append(""" + field.getName() + "":" + """ + base64Value + "",");
}
}
}
String data = buffer1.toString();
data = data.substring(0, data.length() - 1);
JSONObject jsonObject = SM4.enCode(data);
for (Field field : fields) {
if (field.isAnnotationPresent(myCode.class)) {
field.setAccessible(true);
String value = jsonObject.getString(field.getName());
field.set(objDao, value);
field.setAccessible(false);
}
}
}
}

注意这个方法的三个关键设计:

Base64先行。 字段值先做Base64编码再拼JSON。为什么?因为字段值可能包含引号、换行符、特殊字符——直接拼JSON会破坏格式。Base64编码后只有字母、数字和+/=,JSON结构安全。

一次调用。 所有@myCode字段的键值对拼成一个JSON字符串,调用一次SM4.enCode(data)。密码网关对这个JSON整体加密,返回每个字段对应的密文。假设一个Dao有4个加密字段,传统做法要调4次密码网关,我们只调1次——网络开销降为1/4。

逐字段写回。 密码网关返回的JSON里包含每个字段的密文——{"psn_account":"enc_xxx","psn_name":"enc_yyy",...}。框架用反射逐字段写回Dao对象。MyBatis执行SQL时,每个字段存各自的密文。这样做的好处是保留了按单字段查询的能力——比如按身份证号查询用户,加密后用密文去匹配即可。如果所有密文塞进一列,按身份证号查询就不可能了。

Mac()和verMac()——签名与验签

public static void Mac(Dao objDao) throws Exception {
if (!"1".equals(configUtil.get("usageCode"))) return;
if (objDao.getClass().isAnnotationPresent(myMac.class)) {
Field[] fields = objDao.getClass().getDeclaredFields();
StringBuffer buffer1 = new StringBuffer();
for (Field field : fields) {
if (field.isAnnotationPresent(myMac.class)) {
field.setAccessible(true);
String value = String.valueOf(field.get(objDao));
field.setAccessible(false);
if (value != null && !"".equals(value) && !"null".equals(value)) {
String base64Value = Base64.encode(value.getBytes());
buffer1.append(base64Value);
}
}
}
String data = buffer1.toString();
JSONObject jsonObject = SM4.Mac(data);
objDao.setMac(jsonObject.getString("mac"));
objDao.setIv(jsonObject.getString("iv"));
}
}

加签和加密的不同之处:Mac()不是按字段单独签的,而是把所有@myMac字段的值Base64拼接成一个长字符串,算一次MAC摘要。mac(签名值)和iv(初始向量)存到Dao基类的公共字段里,随数据一起入库。

为什么用Base64拼接而不是JSON?因为签名的核心是"同样的数据算出同样的摘要"。JSON有字段顺序问题——{"a":"1","b":"2"}和{"b":"2","a":"1"}语义一样但字符串不同。Base64按字段声明顺序拼接,没有歧义。

Dao基类里定义了这两个字段:

public class Dao {
private String iv;
private String mac;
}

但前端永远看不到这两个字段——Dao基类的getBlock()方法会在序列化时屏蔽它们:

public String getBlock() {
return "|block|maxRow|minRow|dialect|level|...|iv|mac|";
}

所以前端拿到的JSON里没有mac和iv,业务开发人员完全无感。查询参数也要加密——用户可能用身份证号查询,传的是明文,但数据库里是密文,明文查密文查不到。所以查询参数先加密,用密文匹配密文。

为什么先加签再加密?第十章从安全角度解释过原因。全局开关usageCode=1才启用,同一个系统部署在不同地市,有的要加密有的不要。和第九章MongoDB的level属性一脉相承——没有这个配置项的系统,enCode()和deCode()直接return,行为完全不变。

把加解密做在管道里,不要做在业务里。注解驱动+ORM管道,让业务开发人员只需要做一件事——在需要加密的字段上加@myCode。最成功的改造,往往是业务方感知不到的改造。

15.3 信创改造的附带伤害——Tomcat 7升9踩坑记

SM4加解密是信创改造的一部分。但信创改造不只是加个密——往往伴随着整个运行环境的升级。Tomcat 7升9就是其中一环。

升级之后,CSS全崩了、JS全废了、页面光秃秃只剩文字。应用代码一行没改,换了个容器就炸了。

排查过程:打开浏览器开发者工具,发现所有CSS和JS请求的Content-Type都变成了application/json。浏览器看到application/json,当然不按CSS/JS解析。

根因是框架里一个存在了十几年的EncodingFilter——拦截/*,对所有响应都设了Content-Type: application/json。在Tomcat 7上没问题,因为Tomcat 7的DefaultServlet处理静态资源时会覆盖Filter设的Content-Type。Tomcat 9行为变了——如果Filter已经设了Content-Type,DefaultServlet不再覆盖。于是application/json就原样返回给了浏览器。

修复很简单:判断URI后缀,只对动态资源设Content-Type:

String uri = request.getRequestURI();
boolean isStaticResource = uri.endsWith(".css") || uri.endsWith(".js")
|| uri.endsWith(".png") || uri.endsWith(".jpg")
|| uri.endsWith(".woff") || uri.endsWith(".ttf") || uri.endsWith(".eot");
if (!isStaticResource) {
sresponse.setContentType("application/json;charset=UTF-8");
}

十几年没问题的代码,容器一升级就炸了。但Tomcat 7升9的坑不止这一个。趁这次升级把踩过的坑都列出来,希望能帮到同样在做信创改造的同行。

坑1:web.xml版本声明

Tomcat 7用Servlet 3.0,Tomcat 9支持Servlet 4.0。web.xml的命名空间要改:

不改的话,某些新特性不生效,Tomcat 9会按旧版规范解析,行为可能不一致。

坑2:EL表达式严格模式

Tomcat 9的EL解析更严格。以前#{}和混用不出错,Tomcat9直接报ELException。框架里JSP页面如果同时用了延迟表达式和立即表达式,升级后就会抛异常。修法:要么统一用{}混用不出错,Tomcat 9直接报ELException。框架里JSP页面如果同时用了延迟表达式和立即表达式,升级后就会抛异常。修法:要么统一用{},要么在web.xml里显式声明EL配置。

坑3:Cookie SameSite属性

Tomcat 9对Cookie的处理更安全了,默认不再允许跨站发送Cookie。政务系统里如果前端和后端不在同一个域(比如前端走Nginx代理),Session Cookie可能被浏览器拦截——JSESSIONID发不出去,登录就失效。

修法:在context.xml里配置:

<CookieProcessor className="org.apache.tomcat.util.http.Rfc6265CookieProcessor"
sameSiteCookies="lax" />

坑4:Filter执行顺序

web.xml里多个Filter拦截/*时,Tomcat 7对执行顺序不太敏感,Tomcat 9严格按声明顺序执行。如果SetCharacterEncodingFilter在EncodingFilter之前执行,编码可能被覆盖。多个Filter拦截/*时,顺序就是命运。 web.xml里谁先声明谁先执行,Tomcat 9不会帮你调整。

坑5:缓存污染

框架里有个CacheFilter给CSS设缓存头。Tomcat 7时,CacheFilter和EncodingFilter各管各的没问题。Tomcat 9时,EncodingFilter对静态资源设了application/json,浏览器缓存了这个错误Content-Type的CSS文件。修了EncodingFilter之后,CacheFilter的缓存内容也需要清一遍——否则浏览器继续从缓存读错误内容。

这五个坑合在一起的教训是:容器升级不能只换war包。 Tomcat 7→9跨了两个大版本,Servlet规范从3.0到4.0,很多"约定"变成了"规范",以前靠容器"容错"的代码全部暴露。升级前先跑一遍Filter链的梳理——哪些Filter拦截/*、每个Filter对Response做了什么、对静态资源有没有副作用——列清楚再升级。

跑了十几年的代码不代表没问题,只是一直没遇到触发条件。 换个容器、换个JDK、换个运行环境,隐性的坑全冒出来。

本章小结

• @myCode(加解密)+ @myMac(加签验签),两个字段级注解搞定信创合规。

• 加解密做在DBUtil管道里,业务代码和前端看到的永远是明文,零改动。

• 全局开关usageCode:同系统部署不同地市,有的加密有的不加密,一个配置搞定。

• 信创改造往往伴随运行环境升级,Tomcat 7升9踩了五个坑:Content-Type被Filter覆盖、web.xml版本声明、EL严格模式、Cookie SameSite、缓存污染。容器升级不能只换war包。

决策洞察:零侵入改造的检验标准——上线当天,前端开发人员问你"加解密什么时候开始做"。他不知道已经上线了,就是成功了。

第十六章 一个DLL适配几十家医院——工期倒逼出的全局开关设计

16.1 场景还原

本章核心:一个INI配置文件适配几十家医院差异,改配置不改代码。

医院HIS系统对接医保中心,标准接口很简单——一个SiInterface.dll,暴露三个方法:INIT初始化、BUSINESS_HANDLE做交易、ReadSiCard读社保卡。交易数据用^分隔的字符串,交易码决定做什么事:2100读卡、2210登记、2310上传明细、2410结算……

看起来很标准。问题在于:每家医院的需求都不一样。

有的医院要读卡器,有的不要;有的医院是省内医保,有的要支持跨省;有的医院结算前要先预结算,有的直接结算;有的医院用新版CPU卡,有的用旧版M1卡;有的省份交易码要加密传输。

如果每家医院一个分支,几十家医院就是几十套代码。更关键的是——国家试点的时间节点是死的。跨省异地就医直接结算有明确的上线时间,按时保质通过验收是刚性要求。不是想不想改的问题,是改不出来就没以后了。

能不能每家医院一套代码?可以,但维护成本爆炸——每改一个逻辑要同步几十个分支,漏一个就是线上事故。而且每加一家医院就要改代码、编译、测试、分发,周期太长。能不能用if-else硬编码?不同医院的差异太多了——读卡器端口、卡类型、服务器地址、交易码格式、结算流程、加密方式……全部硬编码的话,DLL的主逻辑会被分支淹没,没人看得懂。能不能把差异外置到配置文件?可以——每家医院的差异本质上就是一组参数,用INI配置文件存这些参数,DLL启动时读取,所有分支逻辑通过配置控制而不是硬编码。

16.2 怎么做的

核心设计:全局开关 + INI配置 + 交易码路由。

INI配置做全局开关。 SiInterface.ini里每个配置项控制一个差异点:

下面是两个真实医院的INI配置对比。

医院A,省内普通医院,只接省内医保,用CPU卡,读卡器接COM2口:

[CONNECT]
HISCODE = 1001
[CARD]
COM = 2
CARDTYPE = CPU
USECARDREADER = 1
USECARD = 1
[Server]
Host = 10.10.10.49
Port = 8080
[Chs]
Host =
Port =

医院B,跨省定点医院,同时接省内和跨省医保,用M1老卡,没有读卡器(操作员手工输入卡号):

[CONNECT]
HISCODE = 2088
[CARD]
COM = 1
CARDTYPE = M1
USECARDREADER = 0
USECARD = 0
[Server]
Host = 10.10.10.49
Port = 8080
[Chs]
Host = 10.10.10.50
Port = 8081

同一个DLL,两份不同的INI,行为完全不同。医院A读卡时调ReadSiCard通过COM2口操作读卡器;医院B读卡时跳过硬件操作,由操作员手工输入。医院A的结算直接走省内2410;医院B的结算走跨省分支:2420预结算→拆分→扣款→2410正式结算→1902验证。

代码里没有if(医院A)这样的硬编码,所有差异都由配置决定。

双服务地址。 HttpClient里维护两套连接——省内走m_ip:m_port,跨省走m_ip_chs:m_port_chs。初始化时同时建两套:

client.SetServerInfo(m_ip, m_port);        // 省内
client.SetServerInfoChs(m_ip_chs, m_port_chs); // 跨省

交易时根据交易码或参数决定走哪套。

交易码路由。 不同医院的HIS系统传的交易码格式不一样,有的传四位标准码(2100),有的传两位简码(17)。DLL入口做路由转换:

CString op = old.Left(2);
if(strcmp(op,"17") == 0){
strHisInputData = "1300^"+m_strHospNo+"^"+...; // 17→1300目录下载
} else if(strcmp(op,"19") == 0){
strHisInputData = "1310^"+m_strHospNo+"^"+...; // 19→1310信息批量下载
} else {
strHisInputData = op+"^"+m_strHospNo+"^"+...;  // 其他两位码转四位标准格式
}

不同医院的HIS系统不需要改调用方式,DLL内部统一转成标准格式。

跨省结算分支。 用isYd全局变量控制。省内医院直接2410结算。跨省医院走完全不同的流程:2420预结算→拆分个人账户和统筹基金→社保卡扣款→2410正式结算→1902交易结算验证。同一个交易码2410,根据配置走完全不同的路径:

if(isYd == 10000 && atoi(strTransCode)==2410){
// 先调2420预结算
CString myinput = "2420^"+strPart[1]+"^...";
iRet = BusiHandler(myinput, strOutputInfo);
// 拆分预结算结果
strAkc264 = mystrPart3[1];  // 个人账户
strAkc260.Format("%.2f", atof(mystrPart3[2])+atof(mystrPart3[3])); // 统筹
// 调用社保卡扣款
CString payinfo = strAkc264+"|"+strAkc260+"|"+m_strTime+"|";
iRet = DoDebit(payinfo, strOutputInfo);
// 再调2410正式结算
iRet = CallCenterServer(strHisInputData+m_MacAddr+"|4|^", strOutputInfo);
}

新旧版适配。 isOld控制初始化路径:新版直接初始化,老版初始化后还要自动签到(调用9100交易码),获取业务年度号。DLL入口通过参数是否为空判断走哪条路。

交易码解密。 有的省份要求交易码加密传输(超过7位的交易码是加密后的)。DLL检测到超长交易码时自动解密——用医院编号的所有字符ASCII值求和取模127,得到一个字节密钥,逐字节异或还原:

char getKey(CString hosp){
char key = '0';
int sum = 0;
for(int i = 0; i < hosp.GetLength(); i++){
sum = sum + arr[i];
}
key = sum % 127;
return key;
}

                          SiInterface.dll
┌──────────────┐
HIS(医院A) ─── 17|1 ──→ │  交易码路由   │ ──→ 省内服务器
HIS(医院B) ─── 2100^ ──→│  全局开关     │ ──→ 省内服务器
HIS(医院C) ─── 2410^ ──→│  新旧版适配   │ ──→ 跨省服务器
HIS(医院D) ─── HK1148^→ │  交易码解密   │ ──→ 跨省服务器
└──────────────┘

SiInterface.ini
(每家医院不同)

DLL加载机制:

DLL是C++写的,编译后生成SiInterface.dll。HIS系统启动时通过Windows API动态加载——LoadLibrary("SiInterface.dll")加载库,GetProcAddress按函数名拿到函数指针,映射到INIT、BUSINESS_HANDLE、ReadSiCard三个入口:

typedef int (FP_INIT)(char, char*, char*);
typedef int (FP_BUSINESS_HANDLE)(const char, char*);
typedef int (FP_READ_SI_CARD)(char);

HMODULE hDll = LoadLibrary("SiInterface.dll");
FP_INIT fpInit = (FP_INIT)GetProcAddress(hDll, "INIT");
FP_BUSINESS_HANDLE fpHandle = (FP_BUSINESS_HANDLE)GetProcAddress(hDll, "BUSINESS_HANDLE");
FP_READ_SI_CARD fpReadCard = (FP_READ_SI_CARD)GetProcAddress(hDll, "ReadSiCard");

HIS系统调FP_INIT时,DLL内部做的第一件事就是读SiInterface.ini——调用GetProfileVal把所有配置项加载到成员变量里。换INI文件不需要重启HIS,只要重新调一次INIT就行。实际部署中,改完INI后重新登录医保模块,DLL重新初始化,新配置生效。

这个设计有一个隐含的前提:DLL的代码必须覆盖所有可能的差异路径。INI配置只是控制走哪条路,路本身必须先修好。所以DLL里的分支逻辑实际上比单一定制版更复杂——它包含了省内、跨省、新版、老版、有读卡器、无读卡器的全部路径。但这正是"一次性投入,长期收益"——开发时多写几个分支,部署时零代码修改。

配置驱动与渐进式设计:

回头看这个设计,它有一个重要的特征——渐进式。不是一开始就设计了完整的INI体系,而是在对接第一家、第二家、第三家医院的过程中逐步扩展的。

第一家医院(省内、CPU卡、有读卡器)——只需要[CONNECT]和[Server]两个section。第二家医院加了[CARD],为了支持不同的读卡器配置。第三家医院加了[Chs],为了支持跨省定点医院。交易码路由、新旧版适配、DES解密,都是随着对接医院增多逐步加的。

每加一种新的差异,就在INI里加一个配置项,在DLL里加一个分支。INI的section越来越多,但已有的配置项不会改——老医院升级DLL后,INI不用动,行为不变。这就是配置驱动的安全性——新增不影响已有。

全局开关的设计就是把"适应不同部署环境"的能力做进了配置里,让部署人员自己去适应,而不是开发人员逐个定制。这个思路和第九章MongoDB的level属性、第十四章SM4的usageCode全局开关是一脉相承的——把差异外置到配置,把共性固化在代码。

本章小结

• 一个DLL通过INI配置适配几十家医院的差异:双服务地址、交易码路由、跨省分支、新旧版适配。

• 核心思路:把差异外置到配置,把共性固化在代码。部署人员改文本文件就行,不改代码不重新编译。

• 工期是最大的约束——国家试点的时间节点不可协商,按时保质是底线。

决策洞察:把差异外置到配置,把共性固化在代码。部署人员改文本文件就行——这是"配置外置"原则在极端工期下的实战验证。

第十七章 排错实录——环境锁下的排查生存术

本章核心:80%的"调不通"不是代码问题——先排环境再看代码,两头往中间夹。

前面十七章讲的都是架构决策——在约束下做什么选择。但政务信息化还有一类场景:你什么都没选错,但系统就是不通。

这时候需要的不是架构能力,是排查能力。而排查的难度往往不在技术,在环境——网络不归你管、防火墙不归你管、F5不归你管、对方系统的配置更不归你管。你只能从两头往中间夹,一层一层排除。

这一章记几个真实的排查案例。没有代码,没有框架,只有最朴素的排查思路。

先说一个让我印象深刻的经历。

2004年,我在哈尔滨做失业保险系统。后来还去了松原、齐齐哈尔(富裕县、拜泉县)处理老医保系统。这些项目是某中国顶尖大学校办企业(沈阳)与我的前东家合作的。校办企业那边由一个顶尖大学的硕士带队,做了另一套方案——C/S/S架构的医院医保应用,定义了XML格式做数据传输。

说实话,他们的技术水平比我们高。架构设计更规范,XML传输更标准,团队里顶尖大学的硕士也比我们这种非科班出身的人理论功底扎实。但是系统上线后,天天出问题。医院信息科的人不会调XML解析错误,乡镇信息中心的人搞不定C/S/S的部署,出了问题只能打电话,电话打不通就干等着。

那个经历让我第一次理解了一件事:技术水平高不等于落地能力强。 在政务场景下,"先进"和"好用"之间隔着一条巨大的鸿沟——运维人员的水平、部署环境的差异、故障排查的响应速度,每一个环节都可能把"先进"变成"灾难"。

17.1 网闸摆渡策略漏配——包根本没过来

系统上线后,外网用户反馈偶尔调不通。不是报错,是直接超时——请求发出去了,迟迟没有响应。

登录服务器,看应用日志——没有任何请求记录。说明请求根本没到应用层。

问题在哪?从两头查。

我这边监听服务端端口,没收到任何连接。让云服务工程师在外网那边监听,他说请求已经发出去了。那问题就在中间——从外网到内网,中间要过网闸。

政务外网和内网之间有网闸做物理隔离,数据要通过网闸"摆渡"才能到达内网。检查网闸配置——摆渡策略漏了,外网进来的请求根本没被转发。

加上策略,通了。

这个案例没什么技术含量,但它揭示了一个政务排错的常识: 当你确认自己的服务没问题、对方也确认请求已发出,那问题就在中间链路上。而中间链路——网闸、防火墙、VPN、专线——你控制不了,只能找甲方协调。协调的成本,可能远大于排查的成本。

17.2 FTP 主动模式被防火墙拒掉——银行回盘写不进来

社保系统和银行对接,银行通过FTP发放回盘文件。某天开始,银行反馈回盘文件写不进我们的FTP服务器。

登录FTP服务器看日志——连接建立了,但数据传输失败。FTP协议有两个连接:控制连接(传命令)和数据连接(传文件)。问题出在数据连接上。

FTP有两种模式:

• 主动模式(PORT):服务器主动连客户端的一个随机端口传数据。客户端开一个随机端口,告诉服务器"连我这个端口"。

• 被动模式(PASV):客户端主动连服务器的一个端口传数据。服务器开一个端口,告诉客户端"连我这个端口"。

银行用的是主动模式。他们的FTP客户端开了随机端口,我们的FTP服务器要主动连回去——但这个回连的随机端口被我们这边的防火墙拒掉了。

解决方案:要求银行改用被动模式。同时,我们把FTP服务器的被动模式数据端口固定下来(而不是随机分配),然后在防火墙上只放行这一个固定端口。

一个配置变更,问题解决。但推动这个变更——找银行改FTP客户端配置、找甲方网络组在防火墙上放行端口——来回协调了好几天。

技术问题十分钟想明白,推动落地一周。 这才是政务项目的真实节奏。

17.3 F5 会话保持——用户登录后偶尔被踢

用户反馈:登录成功了,但操作过程中偶尔会被踢回登录页。不是每次都踢,是偶尔。

第一反应:session超时?检查配置,超时时间30分钟,用户说操作才几分钟就被踢了。

第二反应:应用集群。系统部署了多台应用服务器,前面有F5做负载均衡。用户的请求被随机分发到不同服务器——第一次请求落在A服务器,session建在A上;第二次请求落在B服务器,B上没有这个session,用户就被当作未登录了。

我们没有做session共享(没有Redis存session,也没有数据库存session)。在政务场景下,session共享引入的依赖和复杂度不值得——集群通常就两三台机器,F5自带的会话保持就够了。

解决方案:F5配置会话保持(session affinity)。用户登录成功后,后续所有请求都转发到同一台应用服务器。只要这台服务器不挂,session就不会丢。

改动在F5上,不在应用上。找甲方的网络组改F5配置——又是一轮协调。

这个案例的启示: 在政务场景下,"无状态"不一定是最佳实践。session共享当然好,但它引入Redis等中间件,增加了部署依赖和运维复杂度。用F5会话保持,零代码改动,零新增依赖——够用就好。

17.4 F5 拒绝响应太快——业务做完了还得睡一会

在某省部署时,前面的F5负载均衡做了一项自定义策略:响应时间低于某个阈值的请求被视为异常——可能是空响应或攻击,直接返回503。这不是F5的默认行为,是那个项目的运维人员自己配的规则。但不管配置是否合理,你改不了F5——那是基础设施部门管的,走变更流程至少一周。

哪些接口被拦截了?二级代码(数据字典)的读取接口。二级代码数据量小、读频率高、启动时全加载到HashMap里缓存着——查询就是一次map.get(),微秒级返回。F5一看"这什么玩意,几毫秒就回来了",直接拒了。本质是:你优化得太好了,好到基础设施认为你不正常。

解决方案很荒诞——在代码里加sleep。业务处理完如果不到80毫秒,就sleep补到80毫秒再返回。这段代码至今还在route.java里,只是被注释掉了:

//正式环境负载会拦截请求,
// if(begin - end < 80)
// {
//  Thread.sleep(80 - (begin - end));
// }

为什么被注释掉了?因为这个项目不用F5。这段代码是从别的F5项目带过来的,框架代码在不同项目间复用,F5环境下的workaround也跟着走了。不用F5的项目开着sleep纯属浪费性能,所以注释掉。

系统太快了反而要人为减速——这不是段子,是生产环境里真实发生过的事。 在别人的基础设施上跑你的应用,你得适应它的规则,不管这些规则多荒诞。

17.5 数据库没锁,但交易卡死了——一次热块争用的排查

前面几个案例都是网络和基础设施层面的问题。这一节讲一个数据库层面的排障——它揭示了一个重要的认知:排障不能只盯着锁。

2014年开始,我负责一套医保结算系统,承载多家医院的实时交易。某天上午10点,就医高峰期,医院收费窗口电话打爆了——交易卡死了,刷不了卡,结不了账。对医保结算系统来说,这是最高级别的生产事故。

我第一时间登录数据库,查v$lock视图——几乎空的,没有明显的行锁或表锁。这说明问题不是常规的"锁等待",不是某个长事务未提交导致其他人被阻塞。

那会是什么?

紧接着查v$session视图,看当前活跃会话的状态。结果触目惊心——活跃会话多达上百个,而且几乎全部在执行同一类SQL:插入费用明细。EVENT列出现了"latch: cache buffers chains"——这是经典的热块争用(Hot Block Contention)的标志性等待事件。

推断逻辑:大量会话并发插入同一张费用明细表;该表主键使用递增序列,所有新数据都插入到索引最右侧的同一个数据块中;多个会话争抢同一个数据块,引发latch争用。这不是行级锁的问题,所以v$lock里看不到任何异常。

知识补充: 关系数据库中,B-Tree索引是有序的。如果主键由序列递增生成,所有新插入的索引条目都堆积在索引最右边的叶子块。大量并发插入时,多个会话同时修改同一个块,产生latch争用,表现为"热块"。这个原理在Oracle、MySQL(InnoDB)、PostgreSQL中都存在。

应急第一步:批量杀会话。用v$session拼接批量ALTER SYSTEM KILL SESSION命令,瞬间清理掉堆积的阻塞会话,系统短暂恢复呼吸。

杀会话只是治标。根据热块争用的判断,我把费用明细表的主键索引重建为反向键索引(Reverse Key Index)——将索引键值的字节顺序反转后再存入B-Tree,比如1001、1002、1003反转后变成1001、2001、3001,原本堆积在同一个叶子块的值被分散到不同的叶子块中。重建完成后,新进入的插入交易不再争抢同一个数据块,卡死现象明显缓解。

系统稳定一个小时后导出AWR报告,latch: cache buffers chains占了DB time的67.3%——铁证,热块争用就是根因。

当晚维护窗口,把费用明细表改造为按医院编号的分区表。不同医院的数据物理上存储在不同分区段中,并发插入天然分散到不同数据块,从根本上解决了问题。

这个案例的教训是:排障的工具箱里不能只有一把锤子。 vlock没问题不代表数据库没问题。Oracle的等待事件体系远比锁丰富——vlock没问题不代表数据库没问题。Oracle的等待事件体系远比锁丰富——vsession_wait、vsystemeventvsystem_event、vlatch,每个视图从不同角度揭示数据库内部发生了什么。如果当时我懂得查看v$session_wait,就能更快定位到latch等待事件,而不需要等AWR报告确认。

上一节说的"先排除环境再看代码",在数据库层面同样适用——系统突然变慢,第一反应不应该是翻代码里的SQL,而是看数据库内部的等待事件和TOP SQL。等AWR报告告诉你是哪个等待事件最多、哪条SQL最耗时,再去定位代码,效率完全不同。

17.5 接盘老项目——500年法力的猜谜大赛

排错的前提是你看得懂代码。但政务信息化有一个极其常见的场景:接盘别人的老项目,而你没有500年法力。

没有文档。这是常态,不是意外。

数据库字段没有comment。也是常态。

字段命名是中文拼音首字母。你以为是英文缩写,其实你得往业务术语的拼音方向猜:

• GYDM——你能猜到是"柜员代码"吗?不是"工业代码",不是"公寓大门"。

• YGMC——你能猜到是"用工名称"吗?不是"员工名称"。而且"用工"这个词本身就很古老——固定工、合同工、临时工,得懂那个年代的用工制度才知道。90年代国企改革前后,用工形式发生了天翻地覆的变化,这些字段名就是那个年代的化石。

• KHBZ——"开户标志"。这个好猜一点,但也得懂银行业务。

不跟业务人员坐到一起聊,光看代码,这几个字段能让你猜一整天。

还有两种痛苦叠加的情况:

有人社部标准的编码。 社保、医保系统使用人社部统一标准,KC22这种编码——K表示医疗,C表示个人。有规则,但你不熟悉就得翻标准文档。这种至少有据可查,只是费脑子。第四章讲政策驱动开发时说过,人社部的标准编码体系是一套独立的世界观,不看文档光猜完全猜不到。

没有标准的自造系统。 纯拼音首拼,没有规则,没有文档,连拼音方向都猜不对。这种才是真正的猜谜大赛——你以为在解密,其实是在考古。

怎么办? 务实主义的回答是:不要试图一次性读懂所有代码。先读懂你要改的那一块——找到入口,跟踪数据流,确认输入输出。老系统虽然乱,但它在线上跑着,说明业务逻辑是对的。你的任务不是理解它的全部,而是在它上面安全地做增量。

第五章说过"新老共存"的架构策略——不碰老代码,在外面加一层。这个思路不只是架构层面的,也是面对老项目的心态层面的:不追求看懂全部,只追求看懂你要改的那一条线。

我的经验是:接盘一个老项目,前三天最痛苦。第一天看字段名猜意思,第二天跟业务人员确认猜对了没有,第三天才能动第一行代码。这三天不能省——省了就埋雷。

17.7 代码没改但系统崩了——容器升级的连锁反应

第十四章讲过信创改造时Tomcat 7升9的经历,这里从排错角度补充一个视角。

那次问题的排查顺序值得记一笔:先看现象(CSS不显示)→ 再看网络层(Content-Type错了)→ 再看应用代码(Filter设的)→ 最后看环境差异(Tomcat行为变了)。 如果你反过来,一上来就去翻Tomcat的文档,那大海捞针——Tomcat 9和7的差异列表能看三天。还有一个教训:升级后如果CSS恢复但刷新几次又崩了,那可能是浏览器缓存了错误的Content-Type,清缓存或加Cache-Control头强制刷新。

17.8 政务排错的三个常识

这几个案例没有高深的技术,但总结下来有三个常识:

第一,从两头往中间夹。 你控制不了中间链路(网闸、防火墙、F5),但你能控制两头——应用端和客户端。两边都抓包,哪边没数据,问题就在哪段链路上。这个方法听起来简单,但在实际排错时经常被跳过——因为开发者的第一反应是"我的代码没问题",然后就去翻日志了。但日志只能告诉你请求有没有到达应用层。如果请求根本没到达——像18.1的网闸案例——翻再多日志也没用。先确认请求到了没有,再决定往哪个方向查。

第二,技术问题十分钟,协调落地一周。 防火墙端口放行、F5配置变更、网闸策略调整——这些都不归开发团队管。政务项目的排错成本,往往不在技术排查本身,而在推动各方协调。上面四个案例,有三个最终都是环境配置问题,改动都不大——加一条防火墙策略、改一个FTP模式、配一个F5会话保持。但推动这些变更,每个都花了好几天。

这揭示了一个政务项目管理的真相:排错的技术难度和排错的落地周期是两回事。 技术上十分钟想明白的问题,可能需要一周的协调才能落地。在做排错计划时,要把"协调周期"考虑进去——不是分析出原因就完事了,推动修复落地也是排错的一部分。

第三,先排除环境,再看代码。 政务系统的网络环境复杂,大部分"调不通"的案例最终都不是代码bug,而是环境配置问题。先确认网络链路、防火墙策略、负载均衡配置,再看应用日志。这个顺序反了,你会花大量时间在代码里找不存在的问题。

一个实用的排错清单:第一步,确认网络连通性(ping/telnet);第二步,确认中间件配置(端口、策略、会话保持);第三步,确认应用日志(有没有异常堆栈);第四步,才看代码。前三步都不需要改代码,改的全是配置。如果前三步解决了80%的问题,那第四步就不需要了。

数据库层面也是一样。系统突然变慢,第一反应不应该是看代码里的SQL,而是看AWR报告——Oracle的AWR(Automatic Workload Repository)每隔一小时自动采样一次数据库的性能数据,生成报告后能看到:哪条SQL最耗时、哪个等待事件最多、TOP 10前台事件、内存排序还是磁盘排序。AWR是DBA的工具,但我是自己慢慢学会分析的,网上的资源只是启蒙——真正学会是在项目里一次次对着AWR报告找问题:某次是全表扫描导致的 buffer busy wait,某次是缺失索引导致物理读飙升,某次是连接池耗尽导致 enq: TX - row lock contention。每查一次就多认识几个等待事件,积累多了自然看得懂。OCP全英文考试,我不敢去考——但把AWR报告里的TOP SQL和等待事件看明白,80%的数据库性能问题就能定位。

本章小结

• 网闸摆渡策略漏配——请求根本没到应用层,两头抓包定位。

• FTP主动/被动模式——固定数据端口绕过防火墙,协调成本远大于排查成本。

• F5会话保持——集群部署没做session共享,用负载均衡层的会话保持兜底。

• 热块争用——数据库没锁但交易卡死,v$lock没问题不代表没问题,排障工具箱不能只有一把锤子。

• 接盘老项目——字段猜谜(GYDM=柜员代码),先读懂你要改的那一条线。

• 容器升级排错——代码没改但系统崩了,先排环境再看代码,80%的"调不通"不是代码问题。

• 政务排错三常识:两头往中间夹、技术十分钟协调一周、先排环境再看代码。

决策洞察:80%的"调不通"不是代码问题。先排环境再看代码,这个顺序不能反——在错误的环境上调试代码,只会越调越乱。

第十八章 政务运维实战——运维锁下的系统可观测性

本章核心:可观测性做进框架DNA,TUNINGEVENT表替代APM,问题自己"浮"出来。

第十六章讲了"环境锁"下的排错——网闸、防火墙、F5、老代码。这一章讲"运维锁"下的问题:系统部署到基层,运维人员技术水平参差不齐,出了问题你能远程排错,但你的效率远不如本地。

解决方案:让系统"自己说话"——在框架层埋好监控点,运维人员不需要SSH登录服务器,在页面上就能定位问题。

18.1 政务运维不是DevOps

互联网公司有一套成熟的运维实践——CI/CD、监控告警(Prometheus+Grafana)、日志聚合(ELK)、链路追踪(SkyWalking/Jaeger)。这些是DevOps/SRE的标配。

政务系统用不了。原因有三:

环境限制。 政务内网环境部署Elasticsearch需要大量内存,SkyWalking依赖Elasticsearch或HBase,Prometheus需要时序数据库——这些中间件的部署和运维本身就是个大工程。基层运维人员搞不定。

人员限制。 系统部署到区县甚至乡镇,运维人员可能连Linux命令行都不熟悉。你给他一个Kibana的查询界面,他不会用。你让他grep日志文件,他不知道grep是什么。

预算限制。 APM工具要么是收费的(Dynatrace、AppDynamics),要么是开源但需要大量硬件资源的(SkyWalking+ES集群)。政务项目的运维预算通常不包含这些。

那怎么办?回到务实主义的思路:不追求DevOps的"最佳实践",追求在约束条件下的"最合适实践"。

我们的实践:在框架层埋好监控点,数据写到数据库表里,运维人员通过查询页面就能定位问题。不需要SSH、不需要命令行、不需要理解JVM。

运维的态度也很明确:黄灯不处理,等死。 有报警就去看,有预警就去查,小问题不拖成大事故。磁盘出了黄灯,人工去机房换一块。不需要自动化运维平台,不需要AIOps,一个人定期看看监控就行。

但要注意,这种运维策略有个前提——看系统是什么级别。如果是社保系统、医保系统、水电煤这种24小时不能停的民生系统,那必须24小时有人值守,挂了就是事故。但很多政务系统没有这么高的可用性要求,容错空间大,运维压力就小。上一章说的F5+RAID 5+数据库缓存那套"复古"架构,运维就是人肉巡检,照样稳当——因为系统本身不是民生级别的,不需要那么高的运维规格。

18.2 午休被叫起来——一个真实的运维故事

午休刚躺下,手机响了。运维值班的人打来的:"X市系统挂了,报页面打不开。"

某市社保系统上线运行了一段时间,一直比较稳定。但从第三个月开始,窗口人员偶尔反映"系统突然卡一下,过一会儿又好了"。运维看了一眼服务器,CPU偶尔冲高然后降下来,以为是正常的业务高峰——毕竟每天上午10点是业务办理高峰期。

这种"阵发性卡顿"持续了将近一个月。没人重视——因为每次卡的时间不长(几分钟),而且"过一会儿就好了"。运维的回复是"网络波动"或"数据库在跑批处理"。

直到某天上午10点,系统彻底崩了。java.lang.OutOfMemoryError: Java heap space堆栈打了一屏。窗口人员打不开页面,老百姓排队等着办事,场面一度混乱。

这个案例的关键教训是:"阵发性卡顿"就是OOM的前兆。 不要等系统崩了才重视。如果当时有TUNINGEVENT表的慢SQL监控,这个全量查询早就暴露了——60万行的查询,耗时不可能短。监控数据写进数据库表里,运维人员打开页面就能看到。这就是"可观测性做进DNA"的价值——不是事后补救,而是事前预警。

这类"半夜被叫醒"的事,做了政务系统之后遇到过不下十次。每次事后复盘,结论都指向同一个问题:问题不是突然发生的,是早就存在的,只是之前没被看见。

18.3 慢SQL自动入库——问题自己"浮"出来

第六章讲了@monitoring注解+TuningUtil的设计。这里从运维视角看它的实际效果。

运维人员打开TUNINGEVENT表的查询页面——这是框架自动建的一张表,不需要DBA手动创建。按耗时倒序排列:

METHODNAMESQLOPEROPERDATEELAPSED
selectReportDataselect * from KC22 where...query2025-06-12 09:238234
exportUserListselect * from AB01 where...query2025-06-12 10:153456
savePaymentupdate KC21 set...update2025-06-12 11:02156

哪个方法、什么SQL、什么时候、跑了多久。一目了然。

上线第一天就抓到一个8秒的报表查询。 分析SQL发现是全表扫描——where条件里的字段没有索引。加了索引后降到200毫秒。

这个案例说明了一个道理:问题不是没有,是以前你看不见。 框架层统一埋点后,所有慢SQL自动记录,问题自己"浮"出来了。运维人员不需要主动去"找"慢SQL——TUNINGEVENT表就是一份现成的性能分析报告。

而且这个监控是零成本的——没有引入任何外部组件。DoTransaction方法单独开Session写记录,不干扰业务事务。setSaveLog(false)防止写监控记录时触发审计日志的递归。每个细节都是为政务场景量身定制的。

18.4 连接泄漏定位——finally里的日志

第七章讲过AppContextContainer在请求结束时delSession()统一关闭所有数据库连接。如果某个业务方法中途异常了,连接没关,delSession()在finally块里兜底关闭。

框架在请求结束时兜底关闭所有连接,泄漏的打印警告日志(包含bean id和方法名,直接从AppContext上下文取,不需要额外查询)。第七章讲过delSession()的实现,这里不再重复。

session.close()关闭的是SqlSession,底层Connection的关闭行为取决于DataSource实现——连接池模式下归还连接,非连接池模式下真正关闭。泄漏警告说明业务代码没正确释放,频繁泄漏会耗尽连接池。运维看到日志里的类名和方法名,直接定位问题。

18.5 日志级别策略——debug/info/warn/error的分工

框架的日志策略不是"随便打",而是有明确的分级规则。这个规则在十几个项目的运维实践中逐步完善。

debug级别:开发调试用,生产默认关闭。 框架的logInterceptor(第六章AOP拦截器)默认在debug级别打印每个方法的入参和出参。生产环境关闭debug后,这些日志不输出。但遇到疑难问题时,运维人员可以通过配置文件临时把某个bean的日志级别调成debug——不需要重启服务,改配置文件后框架的热加载机制自动生效。排查完改回来就行。这个功能在排查业务逻辑问题时特别有用——运维人员调到debug后,页面上操作一次,日志里就能看到每个方法的入参值,相当于一次"无断点的调试"。

info级别:关键业务节点。 只记录真正需要留痕的操作——用户登录、流程提交、数据导出、批量操作。不是每个请求都记,否则日志文件会暴增。政务系统一天的日志量控制在几十MB以内——太大了对基层运维人员来说是负担,他们没有ELK来检索,只能用文本编辑器打开看。

warn级别:异常但可恢复的情况。 连接泄漏、慢SQL(超过阈值但没到严重程度)、框架兜底关闭资源。运维人员应该定期检查warn日志,但不要求实时响应。delSession()的连接泄漏警告就是warn级别。

error级别:需要人工介入。 OOM前的预警、事务回滚、数据库连接失败、密码网关不可达。运维人员看到error日志必须响应。error日志同时写两个地方——日志文件和一张SYS_ERROR_LOG表(框架自动建的),这样运维人员在页面上就能看到最新的错误信息,不需要SSH到服务器翻日志。

这套分级策略的核心原则是:日志不是给开发人员看的,是给运维人员看的。 开发人员习惯打大量debug日志,但生产环境里这些日志是噪音。运维人员需要的是"一眼看出问题在哪",而不是在几十MB的日志里大海捞针。

18.6 审计日志——运维排错的隐藏利器

第六章的审计日志是安全需求,但它同时是运维排错的利器。

业务争议是最常见的场景:"我明明保存了,为什么查不到?"

运维人员打开审计日志表:

操作ID操作人操作时间涉及表操作类型操作前操作后
S20250612001张三06-12 09:23KC22UPDATE缴费额=380缴费额=0

一查就知道——张三在09:23把缴费额从380改成了0。不是"没保存",是"保存了但改错了值"。

审计日志不只是合规要求,更是运维排错的"时光机"——任何数据争议,查审计日志就能回溯到具体的操作。比让运维人员去翻业务代码、分析数据流高效得多。

18.7 把可观测性做进DNA

回头看,框架的每一层都埋了运维可观测的点:

框架层监控点运维收益
@monitoring + TuningUtil慢SQL自动入库运维查表就知道哪个SQL慢
TUNINGEVENT表性能数据持久化不需要登录服务器,页面可查
delSession()日志连接泄漏定位日志直接告诉你类名和方法名
BusinessSession.commit()操作审计数据争议的"时光机"
logInterceptor方法调用日志debug级别开启,生产可按需打开
AppContext上下文bean id + method name每条日志都带定位信息

这些不是后来补的"运维功能"——它们是框架设计的一部分。从第一天写框架的时候,就把"出了问题怎么定位"考虑进去了。

这就是"可观测性做进DNA"的含义:不是运维人员去找问题,是问题主动"浮"出来。 慢SQL浮在TUNINGEVENT表里,连接泄漏浮在日志里,数据变更浮在审计日志里。

政务运维的核心矛盾是:出了问题影响的是民生,但排查问题的人技术水平有限。 框架的责任是缩小这个矛盾——让问题的定位不需要高深的技术,只需要打开一个页面。

本章小结

• 政务运维不是DevOps——环境、人员、预算三重限制下,套件用不了。

• TUNINGEVENT表——轻量慢SQL自动入库,运维查表即知。

• 可观测性做进框架DNA——每层都埋监控点,问题自己"浮"出来。

• 连接泄漏日志——在finally里兜底关闭,日志直接输出bean id和方法名。

• 日志级别策略——各级别日志明确分工,日志是给运维人员看的。

决策洞察:可观测性的目标不仅可更快地找到问题,且可让问题自己"浮"出来——运维打开一张表就能看到,不需要等用户投诉。

第十九章 买不起就自己搓——政务远程帮办的自主实现

19.1 场景还原

本章核心:预算不够买厂家方案就自己做——WebRTC+Socket.IO,手工拼WAV文件头。

政务大厅来了个老百姓,要办"异地长期居住人员备案"。窗口人员一看材料,缺一份关键证明。老百姓说证明在家里的电脑上。以前的做法是回家拿材料再来一趟,政务大厅可能离家里几十公里。

远程帮办可以解决这个问题:窗口人员发起视频通话,老百姓在家用手机接听,屏幕共享展示电子材料,窗口人员远程帮办,全程录屏录音存档备查。

这个需求不复杂,市面上有现成的厂家方案。我们询过价——预算的80%都不够买他们的基础版,而且不承诺本地化需求修改。

政务远程帮办的需求是定制化的:视频通话要和业务表单联动,接听后要自动加载对应的业务页面,录屏录音要按政务存档标准保存。厂家的基础版做不到这些,定制修改?加钱。工期?等排期。

不是买不买的问题,是买不起也等不起。WebRTC是浏览器原生支持的,信令服务器用Socket.IO,TURN服务器用coturn开源方案,录屏用MediaRecorder,录音用AudioContext。技术栈都是成熟的,风险可控。难点在于和业务系统的集成——接听后要自动加载业务表单、录屏录音要按政务标准保存。

19.2 怎么做的

整体架构:WebRTC P2P连接 + Socket.IO信令服务器 + TURN服务器NAT穿透。

┌──────────────────┐         ┌──────────────────┐
│   窗口人员端       │         │   老百姓端(手机)    │
│  本地视频 ──────┐  │  WebRTC  │  ┌────── 远程视频   │
│  远程视频 ←────┤  │◄────────►│  ├────── 本地视频   │
│  录屏(webm)     │   P2P    │  │                  │
│  录音(wav)      │         │  │                  │
│  业务表单       │         │  │                  │
└──────┬──────────┘         └──────┬──────────┘
│    ┌──────────────┐       │
└───►│  信令服务器     │◄──────┘
│  Socket.IO    │
└──────────────┘

┌───────┴────────┐
│  TURN 服务器     │
│  NAT 穿透       │
└────────────────┘

信令服务器。 用Socket.IO交换SDP和ICE Candidate。关键设计:用窗口人员的sessionid作为房间号,老百姓扫码或输入编号后加入同一个房间,形成一对一通话。同一个房间只允许两个人,防重入——政务场景下,一个窗口同一时间只能服务一个老百姓。

信令维护了一个简单的状态机:

init → joined → joined_conn → joined_unbind → leaved
↑                |
└────────────────┘(对方离开后重新等待)

• joined:自己已加入房间,创建PeerConnection

• joined_conn:对方也加入了,发起call

• joined_unbind:对方离开,解绑track,保留连接等待下一个

• full状态处理"防重入"——第三个人想加入直接拒绝

P2P连接。 iceTransportPolicy设为'all'——优先尝试直连,不行再走中转,不能设为'relay'(强制所有流量走TURN延迟大)。

音频采集开启了三个参数——回声消除、降噪、自动增益。这三个是政务大厅窗口环境的刚需:大厅有音响不消除回声对方会听到自己的声音;大厅人多嘈杂需要降噪;老百姓在家可能离手机远需要自动增益。

录屏。 用MediaRecorder录制远程流(老百姓的画面和声音)。远程流的获取是通过PeerConnection的ontrack回调——对方加入房间后,媒体轨道通过P2P通道传过来,注册到一个新的MediaStream上:

const remoteStream = new MediaStream();
pc.ontrack = (event) => {
event.streams[0].getTracks().forEach((track) => {
remoteStream.addTrack(track);
});
};

然后创建MediaRecorder,指定编码格式和码率:

const recorder = new MediaRecorder(remoteStream, {
mimeType: 'video/webm;codecs=vp8',
audioBitsPerSecond: 16000,
videoBitsPerSecond: 256000
});
let buffer = [];
recorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) {
buffer.push(e.data);
}
};
recorder.start(10);

几个关键决策:mimeType用video/webm;codecs=vp8而不是H264——vp8是Chrome原生支持的编码,不需要额外授权,兼容性最好。videoBitsPerSecond设为256kbps——政务远程帮办不需要高清画质,老百姓展示材料能看清文字就行,低码率节省带宽和存储。start(10)的参数10表示每10毫秒触发一次ondataavailable回调——这不是10毫秒存一个文件,而是把数据分片推到buffer里。分片越小,内存中积压的数据越少,和第十三章JDBC游标"边读边写"是同一个思路——不积压。

下载时把buffer拼成Blob,文件名用精确到毫秒的时间戳(yyyyMMddHHmmssSSS格式):

const blob = new Blob(buffer, { type: 'video/webm' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = getNow() + '_remote.webm';
a.click();

为什么录远程流而不是本地流?因为政务存档的重点是老百姓展示的材料和说的话。

录音:手工拼WAV文件头。 政务存档要求音频单独存一份WAV格式(通用、无压缩、可转写文字)。浏览器没有直接生成WAV的API,用AudioContext从底层采集PCM数据,然后手工拼44字节的WAV文件头。

这段代码不是我写的,是团队里一个95后的女孩写的。她没用过AudioContext,也没拼过WAV文件头,但她解决问题的方式和书里每一个决策一样——没有现成的就自己查资料搓一个,不引入不确定的依赖,出了问题能说清每一字节是什么。务实主义不需要传承,它自己会传染。

PCM数据的采集过程:createMediaStreamSource从本地音频流创建源,createScriptProcessor(4096, 1, 1)每4096帧触发一次回调。回调里把32位浮点PCM(-1.01.0)乘以0x7FFF(32767),转成16位整数(-3276832767),存到数组里。

WAV文件头逐字节写入DataView:

偏移含义
0"RIFF"文件标识
436+数据长度文件总大小-8
8"WAVE"格式标识
12"fmt "格式块标识
201PCM格式(无压缩)
221单声道
24sampleRate采样率(如48000)
3416位深度(16bit)
36"data"数据块标识

下载时先算出录音数据总长度,创建ArrayBuffer(44字节头+音频数据),写完头部后从第44字节开始逐个写入16位整数PCM数据,小端序。本地和远程各录一份,确保双向留痕。

和业务系统集成。 视频通话不是孤立的——老百姓扫码时通过Socket.IO传自定义的CARID消息(身份证号和业务ID),窗口人员接听后自动查询业务信息、加载对应的业务表单。左边是视频窗口,右边是业务表单,窗口人员边看材料边录入。接听按钮触发隐藏来电弹框→用yw_id查询业务信息→拿到业务名称和表单ID→显示对应的业务表单→发送allow信令开始视频。

挂断后不是直接断开,而是先释放旧连接再重新建立信令连接——窗口人员始终保持在"等待来电"状态,下一个老百姓可以直接呼入。

业务联动的完整流程:

接听按钮触发的不是简单的"开始视频",而是一连串联动动作。窗口人员点"接听"后:第一步,隐藏来电弹框,显示视频窗口;第二步,用yw_id(从Socket.IO的CARID消息里拿到的业务ID)调用后端查询业务信息;第三步,后端返回业务名称和表单ID;第四步,前端根据表单ID加载对应的业务表单组件;第五步,发送allow信令给对方,视频通话正式开始。整个流程不到一秒。

窗口人员看到的界面是左右分栏——左边是视频画面,右边是业务表单。老百姓在视频里展示材料,窗口人员边看边录,同时右手操作表单录入信息。

生产部署:TURN服务器在政务外网的实践:

开发环境里STUN直连就能通。但生产环境完全不同——政务外网和互联网之间有多层NAT和防火墙,STUN直连基本不可能。必须部署TURN服务器做流量中转。

我们用的是coturn开源方案。部署位置有讲究——TURN服务器必须放在政务外网的DMZ区,既能被政务大厅的内网终端访问,又能被互联网端的老百姓手机访问。配置要点:端口3478(STUN/TURN标准端口)加49152-65535(媒体流中转端口范围),认证用long-term credential机制,日志开启log-binding和log-session方便排查,生产环境必须配TLS证书——政务安全要求加密传输。

有一个坑:iceTransportPolicy不能设为'relay'。有些文章建议强制所有流量走TURN以确保连通性,但延迟会增加200-400毫秒。政务远程帮办是实时视频,能直连就直连,直连不了再走中转。设为'all'——先尝试STUN直连,失败后自动降级到TURN中转。实测同一个政务大厅内网环境有时能直连,延迟30-50毫秒,比走TURN的200-400毫秒好得多。

TURN服务器的部署位置是关键——DMZ区是政务外网和互联网之间的"中间地带",TURN服务器放在这里,两边都能通。

回头看,为什么手工拼WAV而不是用库?WAV文件头只有44字节,格式固定。引入一个录音库增加了依赖和不确定因素。自己拼更可控——出了问题知道每一字节是什么。这和第七章ASM读字节码、第五章JDBC游标导出是同一个思路:宁可多写几行代码,也不引入不确定的依赖。 为什么不是买不起就不做?因为需求是真的——老百姓大老远跑来政务大厅,材料不全就要再跑一趟,远程帮办不是锦上添花,是实实在在减少跑腿次数的民生功能。买不来的就自己做,和DLL医保接口、手写IOC、JDBC游标导出是同一个逻辑:可控性比便利性重要。

本章小结

• WebRTC+Socket.IO+TURN,全套开源技术栈自主实现远程帮办,预算不够买厂家方案就自己搓。

• 手工拼44字节WAV文件头,不引入录音库,出了问题知道每一字节是什么。

• 视频通话和业务表单联动:扫码传身份证号,接听后自动加载对应业务页面。

决策洞察:买不来就自己做,自己做就做可控的——可控性比便利性重要。这个原则从IOC到JDBC游标到远程帮办一以贯之。