第五部分:极限生存
第十四章 大数据导出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 |
| 结果处理 | 全量映射成List | ResultSet游标逐行 |
| 内存占用 | 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解析更严格。以前#{}和{},要么在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%——铁证,热块争用就是根因。
当晚维护窗口,把费用明细表改造为按医院编号的分区表。不同医院的数据物理上存储在不同分区段中,并发插入天然分散到不同数据块,从根本上解决了问题。
这个案例的教训是:排障的工具箱里不能只有一把锤子。 vsession_wait、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手动创建。按耗时倒序排列:
| METHODNAME | SQL | OPER | OPERDATE | ELAPSED |
|---|---|---|---|---|
| selectReportData | select * from KC22 where... | query | 2025-06-12 09:23 | 8234 |
| exportUserList | select * from AB01 where... | query | 2025-06-12 10:15 | 3456 |
| savePayment | update KC21 set... | update | 2025-06-12 11:02 | 156 |
哪个方法、什么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:23 | KC22 | UPDATE | 缴费额=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" | 文件标识 |
| 4 | 36+数据长度 | 文件总大小-8 |
| 8 | "WAVE" | 格式标识 |
| 12 | "fmt " | 格式块标识 |
| 20 | 1 | PCM格式(无压缩) |
| 22 | 1 | 单声道 |
| 24 | sampleRate | 采样率(如48000) |
| 34 | 16 | 位深度(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游标到远程帮办一以贯之。