接手一个超市订单管理系统的项目分析,我习惯先不看代码,先问一句:这套东西到底卡在谁手里、卡在哪个环节。标题里的“smbms”和“超市订单管理系统”摆在一起,指向的其实是一类非常典型的业务系统——围绕供应商、商品账单、订单流水、操作用户四条线转的中小型进销存后台。它解决的问题很具体:一家门店或一个小型商贸公司,每天有几十上百条采购与销售流水,靠 Excel 记会串行、会对不上账,靠大型 ERP 又太重、太贵、没人会用,中间这块空白就需要一个轻量的超市订单管理系统来填。这篇文章适合三类人看:正在做同类后台练手的开发者,能从里面看到表怎么设计、分页怎么算、权限怎么收;已经有一版能跑的系统但总觉得别扭的人,能看到分层与排查的具体改法;还有准备接手别人留下的老系统、需要快速摸清全貌的维护者。我会把项目分析拆成选型、数据、结构、模块、排查、改造几步来讲,每一步都给出为什么这么做,而不只是怎么做。
1. 项目整体定位与技术选型拆解
1.1 先搞清楚这套系统真正的服务对象和业务边界
很多人一上来就写代码,写到一半发现需求自己都想不明白,最后做出来的东西功能挺多但没人用。我一般在动手前,会先把系统的服务对象按“角色”切开,超市订单管理系统这个场景下,实际只有三类人在用:一是门店的业务操作员,负责录入账单、登记供应商信息;二是门店的管理者,负责看订单汇总、决定哪些账单已经结算;三是系统的超级管理员,管账号、管角色、管别人能不能删数据。这三类人的诉求完全不同,操作员要的是“录得快、少填字段”,管理者要的是“一眼看清欠款和已付”,管理员要的是“权限别乱”。
把这些角色对应的动作列出来,业务边界就清楚了。系统要管的是供应商档案、账单流水、订单与账单的对应关系、用户与角色,不需要管的是商品库存的实时扣减、会员积分、线上支付对接——这些属于电商或者 POS 系统的地盘,塞进来只会让一个轻量后台迅速失控。我在分析类似项目时经常见到有人往里加购物车、加库存预警,最后数据库表翻了三倍,核心的账目对平功能反而做得很糙。所以第一层的取舍就是:明确这是一个内部管理后台,不是面向顾客的前台,它的每一个界面都应该是表格、表单、查询条件,而不是花哨的展示页。
确定了这一点,后面的所有设计都有了准绳。表单字段该不该加,看操作员一天要录多少条;列表要不要分页,看数据量能不能一屏放下;要不要做角色权限,看有没有“不同人该看到不同数据”的需求。这套判断逻辑比任何技术选型都重要,因为它决定了你做出来的东西是能用还是能用但没人想用。
1.2 为什么这个体量适合用 JSP + Servlet + JDBC 的原生组合
说到技术选型,我得先泼一盆冷水:这套系统用 Spring Boot 三分钟就能搭起来,为什么还要用 JSP + Servlet + JDBC 这种看起来“老掉牙”的组合?答案不是技术先进性问题,而是可观测性和学习性价比的问题。Spring 帮你做了太多事,一个请求进来,从过滤器链到拦截器到 AOP 到事务代理,中间隔了七八层,出了问题你只能靠日志猜。而这个体量的系统总共就那么几个页面,请求路径短、生命周期清晰,用原生 Servlet 你能完整地看到“请求进来—参数解析—业务处理—跳转响应”的全过程,每一行都是你自己写的,出问题定位起来极快。
具体到实现层面,JSP 承担视图渲染,Servlet 承担控制器职责,JDBC 直连数据库做数据访问,中间用一个 DAO 层隔离 SQL。这套组合的最大好处是没有魔法:页面上一个下拉框的值从哪来,你能顺着 Servlet 一路找到那条 SQL;数据库里少了一条记录,你能顺着日志找到是哪次提交出了问题。对于一个用来理清后台系统脉络的项目来说,这种透明性比工程化程度更值钱。
当然,代价也很明确。JDBC 直连意味着每次操作都要自己开连接、关连接,不引入连接池的话并发一高就顶不住;JSP 里嵌 Java 代码写多了会变成一锅粥,后期维护很难受;没有依赖注入,对象之间的组装全靠手工 new。所以我的做法是:保留原生组合的清晰骨架,但在三个地方做补强——引入连接池解决连接复用,用过滤器统一处理编码和登录校验,用 JSTL 标签替代页面里的 Java 脚本片段。这三处补强不改动整体结构,但能把最扎手的问题提前摁住。
1.3 技术栈清单与版本取舍的几个实际考量
把清单摊开来看,这套系统依赖的东西并不多,但每一项都有坑。
| 层次 | 技术选型 | 选择理由 | 需要注意的点 |
|---|---|---|---|
| 视图层 | JSP + JSTL + EL | 无需前端构建,服务端直出,调试直观 | 页面里别写 Java 脚本,全用标签和 EL |
| 控制层 | Servlet 3.0+(注解配置) | 免去 web.xml 大量配置,路径清晰 | 注解路径别重复,映射冲突排查很费时 |
| 数据层 | 原生 JDBC + 手写 DAO | SQL 完全可控,便于理解执行计划 | 必须做资源关闭,否则连接池会被耗干 |
| 数据库 | MySQL 5.7 / 8.0 | 免费、生态好、语法通用 | 8.0 驱动类名与时区参数和 5.7 不同 |
| 连接池 | Druid 或 HikariCP | 连接复用,避免频繁握手开销 | 池大小别拍脑袋定,要按并发估 |
| 构建 | Maven | 依赖管理、打包一体化 | 打包后静态资源路径容易踩坑 |
版本取舍上有两个具体决定值得说。第一是JDK 版本不要盲目追新,Servlet 容器对高版本 JDK 的兼容性有时候会给你惊喜,选团队里最稳的那个版本,能省掉一半莫名其妙的启动报错。第二是数据库驱动必须和数据库服务端版本对齐,5.x 的驱动连 8.0 的服务端,常见的表现是时区报错或者认证插件不匹配,报错信息还特别不直观,很多人会误以为是密码错了,折腾半天。
还有一个容易忽略的是字符集。数据库建库时如果用的是默认字符集,中文供应商名称、商品描述在写入时可能变成问号,而且是那种“有时候正常有时候乱码”的间歇性问题。我的习惯是建库建表时显式指定 utf8mb4,连接串里也带上字符集参数,宁可多写一行配置,也不要留一个随机出现的坑。
2. 数据库设计:整个系统真正的地基
2.1 五张核心表的关系与设计意图
后台系统做得好不好,八成看表设计。这套超市订单管理系统的数据模型其实很朴素,围绕五张表转:用户表、角色表、供应商表、账单表、地址表。
关系是这样串起来的。用户表里有一个角色字段,指向角色表的主键,一个用户对应一个角色,一个角色下可以有多个用户,这是典型的一对多。供应商表独立存在,记录供货方的编码、名称、联系人、电话、地址、传真这些档案信息。账单表是业务核心,每一条账单记录一笔采购或销售流水,包含账单编码、商品名称、商品描述、计量单位、数量、总金额、是否已付款,以及一个指向供应商的外键——也就是说,每一笔账单都必须挂在某个供应商名下,这个外键是订单与供应商联动的关键。地址表则挂在用户下面,记录收货地址、邮编、联系电话,一个用户可以有多条地址。
这里有个设计上的取舍值得说清楚:账单表里的“是否已付款”用的是一个很小的整型标记,而不是独立的付款流水表。这个设计的优点是查起来极快,一个条件筛选就能拉出所有未结算的账单;缺点是无法记录部分付款、多次付款的场景。对于一个小型门店来说,采购基本是“一次结清”或者“月结一次”,前者完全够用;如果业务真的复杂到要记录每次付款的时间和金额,那就得拆出付款明细表,账单表只留汇总字段。我在分析这个模型时,会明确标注这是面向简单结算场景的简化设计,而不是把它当成通用方案推荐给别人。
还有一个细节:几乎所有业务表都有创建人、创建时间、修改人、修改时间这四个字段。这不是冗余,是审计留痕的基础。当账单金额被人改过、供应商电话被人动过,能查出是谁在什么时候改的,这在内部管理系统里是刚需,比任何花哨的功能都重要。很多练手项目会省掉这四个字段,等到出问题想追责时才发现无从查起,那时候补字段的成本远高于一开始就设计进去。
2.2 字段设计里的几个关键取舍与避坑
字段设计上有几个地方我踩过坑,值得展开说。
主键用自增整型还是业务编码?这套系统里,用户有用户编码、供应商有供应商编码、账单有账单编码,看起来都可以做主键。但我建议主键一律用自增整型,业务编码单独做唯一索引。原因很实在:业务编码是人编的,可能重复、可能改、可能有特殊字符,一旦拿它当主键做外键关联,改一次编码就要级联更新一堆表。自增主键没有业务含义,改什么都不影响关联关系,唯一索引负责拦重复就行。
金额字段用什么类型?这是最经典的坑。用浮点数存金额,做几次累加就会冒出 0.000000001 的误差,对账的时候能让人崩溃。正确做法是用定点小数类型,明确指定小数位数,比如两位。如果预算紧张或者数据量极大,也可以用整型存“分”,展示时再除。我在评审别人的表结构时,看到金额字段是浮点型,基本可以直接判定这个系统迟早要出对账纠纷。
字符串长度给多少?很多人习惯性地全给很长的长度,觉得“反正不占地方”。实际上数据库会按定义分配存储,过长的字段在某些场景下会拖慢索引、影响内存中的排序效率。我的做法是按实际业务估:供应商名称给到足够容纳常见公司全称的长度,商品描述给中等长度,电话、邮编这种给固定合理长度。估不准的时候宁可稍宽一点,但别一上来就给到极端值。
要不要加逻辑删除标记?账单和供应商这类数据,物理删除是有风险的——删了之后历史统计对不上,审计也查不到。加一个删除标记字段做逻辑删除,查询时统一带条件过滤,代价是每条 SQL 都要多写一个条件,收益是数据永远可追溯。这笔账我觉得很划算,尤其是账单数据,删错了就是钱的问题。
2.3 建表与初始化数据的实操落地
建库建表的顺序有讲究,必须先建被引用的表,再建有外键的表。顺序是:角色表、用户表、供应商表、地址表、账单表。如果反过来,建表会因为外键指向不存在的表而失败。
建库时的两个关键参数要显式指定:字符集用 utf8mb4,排序规则配套设置。建表时引擎统一用 InnoDB,因为它支持事务和行级锁,账单这类涉及金额的操作必须要有事务保障。
初始化数据的准备是最容易被忽视的一步。我的习惯是准备三份脚本:一份是建表脚本,一份是基础数据脚本,一份是批量测试数据脚本。基础数据里至少要有两个角色、三个不同角色的用户、五个供应商、二十条左右分布在不同付款状态的账单。测试数据的价值在于,它能让你在做分页和查询筛选时立刻看到效果——总共二十条,每页五条,翻到第四页正好边界,翻到第五页应该是空,这种边界情况只有真实数据才能验证出来。
提示:初始化脚本里的用户密码不要存明文。哪怕是练手项目,也养成存摘要值的习惯,脚本里直接写加密后的字符串。这个习惯一旦养成,后面接手真实项目时就不会犯低级错误。
批量测试数据我一般用一段脚本循环生成,金额、日期、供应商都随机分布,这样在测试“按日期范围查询”“按金额排序”“按供应商筛选”这些功能时,能立刻发现 SQL 里的条件写漏了或者排序字段搞错了。看起来是小事,但正是这些小事决定了这个系统你敢不敢真拿去用。
3. 分层架构与核心代码结构
3.1 四层结构各自的职责边界
这套系统的代码结构是标准的四层:实体层、数据访问层、业务层、控制层,外加视图层。分层这个词人人会说,但真正落地时最常见的毛病是职责越界——SQL 写在了 Servlet 里,业务判断写在了 JSP 里,最后整个项目变成一锅粥。
我给的边界是这样划的。实体层只做一件事:把数据库的一行映射成一个对象,字段和表字段一一对应,不写任何业务逻辑,也不写任何数据库操作。数据访问层只做一件事:拿着参数去查或改数据库,返回实体或者集合,不判断“这个用户能不能删”,那是业务层的事。业务层负责组合多个数据访问操作、做业务规则校验、开启和提交事务,比如“删除供应商之前先检查有没有未结算的账单关联它”。控制层负责收参数、调业务、选视图、跳页面,不做任何业务判断,也不写 SQL。
这套边界守住了,好处是显而易见的:改一个查询只需要动数据访问层,加一条业务规则只需要动业务层,换一个页面只需要动视图层,互不牵连。我见过太多项目把 SQL 直接写在控制层里,后来要加一个缓存,发现得改二十个地方,改到最后干脆不做了。
具体到类的组织上,用户、供应商、账单、地址这四块业务,每块对应一个实体类、一个数据访问接口加一个实现类、一个业务实现类,控制层则按功能拆成若干个 Servlet。名字上保持一致的前缀,比如都以 User、Provider、Bill 开头,找代码的时候一眼就能定位。这种看似笨拙的命名一致性,实际上比什么设计模式都管用,因为它降低了别人接手时的心智负担。
3.2 实体类与通用数据访问代码的复用写法
实体类写起来枯燥但必须规范。字段名和表字段名对应,类型选对——日期用日期类型、金额用定点小数对应的类型、整型标记用小整型,别一律用字符串图省事。每个字段配标准的读写方法,再补一个能打印全部字段的字符串表示方法,调试的时候能省很多事:查出来的对象直接打印,一眼就能看出哪个字段是空。
数据访问层最容易写重复代码。四张表都要做“按条件查总数”和“按条件分页查列表”,这四个方法里除了表名和条件不同,其余几乎一样。我的做法是抽一个通用的查询工具,把连接获取、参数绑定、结果集映射、资源关闭这几件事封装起来,各表的数据访问实现只传 SQL 和参数。这样每个具体实现里的代码量能砍掉一大半,而且资源关闭的问题一次性解决,不会出现某张表忘了关连接的情况。
这里有个细节必须强调:结果集到实体的映射要显式写,别依赖自动映射。自动映射看起来省事,但字段名和属性名稍有差异就映射不上,而且是静默失败——不报错,只是值是空的。等你发现列表里某一列全是空白,回头找原因能耗掉半天。显式映射麻烦一点,但每一行都是确定的。
再就是日期和金额的处理。数据库里是日期类型,实体里也要用日期类型,千万不要在中间环节转成字符串再转回来,格式一多就乱。金额同理,数据库定点小数,Java 侧用对应的高精度类型,从查询到计算到展示全程保持精度,不要中途为了省事转成浮点。
3.3 字符编码过滤器与登录拦截的落地方式
过滤器是这套结构里性价比最高的东西,两个过滤器就能解决大问题。
第一个是字符编码过滤器。做法很简单:在过滤器里统一设置请求和响应的字符编码,并且对请求编码要放在读取任何参数之前——这一点非常关键,因为一旦参数被读取过,再设置编码就不生效了,表现就是“有时候中文正常有时候变问号”,非常难查。把这段逻辑放在过滤器最前面,整个应用的所有请求就统一了,不用每个 Servlet 里重复写。
第二个是登录校验过滤器。逻辑是:拦截所有需要登录才能访问的路径,从会话里取当前登录用户,取不到就跳转到登录页;登录页和静态资源要放行,否则会陷入无限重定向。这里有个坑:放行名单必须包含登录页的路径、登录提交的路径、以及样式脚本图片这些静态资源路径,漏一个就会出现“登录页样式全丢”或者“登录页自己跳自己”的诡异现象。
注意:过滤器的拦截路径配置要写清楚,用通配符匹配整个应用时,一定要把放行名单核对三遍。我见过有项目因为漏放行静态资源,登录页打开是一片白板,排查了两小时才发现是过滤器把样式文件拦了。
另外一个小技巧:会话超时时间要显式设置,别用默认值。默认值有时候长到让人以为登录状态永远不过期,测试权限功能时根本测不出效果。设一个合理的时长,然后在用户长时间不操作后重新登录,这套流程本身也是对会话管理的一次真实验证。
4. 核心模块实现要点与实操过程
4.1 登录与会话管理:看似最简单,坑最多
登录功能看起来是最简单的,实际上它是整个系统的安全问题集中地。
流程是这样:用户提交账号密码,控制层接收后调业务层,业务层先按账号查出用户记录,再把提交的密码做同样的摘要运算,与库里存的摘要值比对,一致就通过,不一致就返回错误。这里有两个关键点。第一,查询必须按账号精确匹配,且用参数化方式传值,绝不能拼接字符串,否则就是注入漏洞的入口。第二,密码比对必须比对摘要值,不能比对明文,库里也不该存明文。
摘要算法上,教学项目里常见的是早期的消息摘要算法,但我要明确说一句:这类算法现在只能防住“数据库泄露后密码直接被看到”,防不住彩虹表。稍微认真一点的做法是加盐——每个用户一个随机盐值,和密码一起做摘要,盐值单独存一列。再好一点是用专门为密码设计的慢哈希算法,计算一次要几十毫秒,暴力破解的成本立刻上去了。对一个内部管理系统来说,加盐已经能把风险降到可接受的水平。
会话管理上,登录成功后把用户对象放进会话,同时把角色标识也放进去,后面做权限判断时直接用。会话里只放必要信息,不要把整个用户对象连同密码摘要一起塞进去,那等于把敏感信息挂在服务端内存里,一旦有会话序列化或者日志打印,就泄露了。
还有一个细节:登录失败要有次数限制或者至少要有日志记录。没有限制的登录接口,等于给暴力破解开了绿色通道。简单做法是记录同一账号的连续失败次数,超过阈值就锁定一段时间。这个功能代码量很小,但体现了对安全的基本认知。
4.2 用户管理:分页查询是绕不开的核心难点
分页是这类后台系统里技术含量最集中、也最容易写错的地方。原理其实不复杂:前端传当前页码和每页条数,后端算出偏移量,用限定范围的查询取出一页数据,同时查一次总数用于计算总页数。
偏移量的计算是关键:偏移量等于(当前页码减一)乘以每页条数。这里必须做参数校验,因为用户可以在地址栏里随便改页码。页码小于一的时候要纠正为一,页码大于总页数的时候要纠正为总页数,否则查出来就是空列表,用户还以为数据丢了。每页条数也要限制上限,不然有人传个一万,一次查询把整张表拉出来,数据库连接直接被占满。
总页数的计算有个经典坑:总数除以每页条数,如果除不尽要向上取整。直接整除会导致最后一页的数据永远查不出来。我见过有项目用整除,数据明明有二十一条、每页十条,但只能翻两页,第二十一条永远看不到,排查了半天才发现在取整上。
| 参数 | 含义 | 计算方式 | 常见错误 |
|---|---|---|---|
| 当前页码 | 第几页 | 前端传入,需做上下界纠正 | 未校验,传入零或负数导致偏移量为负 |
| 每页条数 | 一页多少条 | 前端传入,需设上限 | 上限过大,一次拉全表 |
| 偏移量 | 从第几条开始取 | (页码 - 1)× 每页条数 | 忘了减一,第一页就漏了前几条 |
| 总记录数 | 满足条件的总条数 | 单独一次统计查询 | 条件与列表查询不一致,总数对不上 |
| 总页数 | 一共几页 | 总记录数 ÷ 每页条数,向上取整 | 直接整除,最后一页数据丢失 |
除了分页,用户管理还有两个要点。一是条件组合查询,按用户名模糊查、按角色精确查,两个条件可以组合。这里的坑在于模糊查询的参数拼接和条件拼接,必须用参数化方式,同时动态拼接条件时要注意空格和连接词,漏一个空格就变成语法错误。二是删除前的关联检查,删除一个用户之前要考虑他创建过的账单怎么处理,是禁止删除还是保留孤儿数据,这个规则必须在业务层明确下来,而不是让数据库外键报错来兜底——外键报错的信息对用户来说毫无意义。
4.3 供应商与账单模块:联表查询与金额校验的实操
供应商模块相对简单,本质是增删改查加分页,但它的价值在于档案数据的完整性。供应商编码要唯一,名称不能为空,联系电话格式要校验。这些校验如果只放在前端,随便绕一下就失效了,所以服务端必须再校验一遍。我的原则是:前端校验为了体验,服务端校验为了正确,两边都要有。
账单模块是真正的重头戏,因为它涉及跨表联动。列表查询时,账单表要显示供应商名称,而账单表里存的是供应商标识,所以必须做联表查询,把供应商名称带出来。这个查询的写法有两种:一种是用连接查询一次取出,另一种是先查账单再用标识去查供应商,后者会产生典型的查询次数放大问题——一页十条账单就是十一次查询,性能很差。正确做法是连接查询,一次搞定。
账单的金额也有校验逻辑:总金额应该等于单价乘以数量,如果是用户手填总额,就要在服务端做一致性校验,对不上要提示。这里的比较不能用浮点直接比,要用定点小数的比较方式,否则会因为精度问题误判。
新增账单时还有一层业务约束:账单必须关联一个已存在的供应商。所以提交时先校验供应商标识是否有效,再插入。删除供应商时反过来,先检查有没有未结算的账单挂在他名下,有就不许删。这个双向约束是保证数据不出现孤儿记录的关键,很多项目只做了一边,结果删了供应商之后账单列表里出现大片空白供应商名称,看着就很不专业。
修改操作要注意并发覆盖的问题。两个人同时打开同一条账单编辑,A 先保存,B 后保存,B 会覆盖 A 的修改。简单做法是加一个版本号或者用修改时间做乐观锁校验:更新时带上原来的修改时间,如果库里的修改时间和带的不一致,说明期间被人改过,提示用户刷新重试。这个改动只在更新语句里多一个条件,成本很低,但避免了“我的修改莫名其妙没了”这类投诉。
4.4 账单状态与结算逻辑的落地细节
账单的付款状态这个字段看着简单,实际操作里最容易出问题的是状态流转的合法性。比如从“未付款”改成“已付款”是允许的,从“已付款”改回“未付款”就要谨慎,因为涉及金额统计的重算。我的做法是在业务层把允许的流转关系列出来,不符合的直接拒绝,而不是让前端随便传什么状态就写什么状态。
结算相关的统计查询也值得单独说。管理者最关心的是“这个月一共采购了多少、还有多少没付”,这类统计不能靠前端把列表拉下来自己加,数据量一大就卡死。正确做法是写聚合查询,在数据库侧算出汇总,返回一个结果对象。这里要注意统计条件的口径必须和列表查询一致,否则会出现“列表显示十二条未付款,统计说十条”的对不上账,用户第一反应就是不信任这个系统。
还有一个小细节是账单编码的生成。如果是用户手填,就要做唯一性校验;如果是系统生成,就要考虑并发情况下不重复——简单做法是时间戳加随机后缀,或者用一个独立的序列表。我倾向于系统生成加唯一索引兜底,人编的编码在多人操作时几乎一定会撞。
5. 常见问题与排查技巧实录
5.1 乱码、空指针、白页三大高频问题的定位路径
这三类问题占了这类项目排障量的绝大多数,值得逐个说清楚定位思路。
乱码的表现是中文变问号或者变成一堆奇怪符号。排查顺序是:先看数据库里的数据本身对不对,如果库里就是乱的,问题在写入链路,检查连接串字符集、建表字符集、过滤器编码设置;如果库里是对的但页面显示乱,问题在输出链路,检查响应内容类型和页面编码声明。这个“先分清是写坏了还是读坏了”的思路能省掉大量时间,因为很多人的第一反应是去改页面编码,改了半天发现根源在写入。
空指针的定位靠的是看堆栈的最上面一行。堆栈会告诉你哪个类的哪一行抛的,直接跳过去看那一行的对象有没有可能是空。最常见的三种情况:查询结果为空却没判空就取属性、会话里取用户没判空就取用户标识、请求参数没传到导致解析出来是空。第三种最隐蔽,因为参数名写错一个字母,值就是空,而且不报错,一路传到业务层才炸。
白页是指页面什么都不显示也不报错,或者返回状态码是成功但内容是空的。常见原因:视图路径写错,转发到了一个不存在的页面;过滤器或拦截器把请求拦了但没有正确放行,返回了一个空响应;页面里抛了异常但被吞掉了。定位方法是先看服务端日志有没有异常,没有的话在转发前后打日志确认走到了哪一步。
5.2 分页与总数对不上、查询结果为空的排查实录
分页相关的问题我总结了一套固定的检查顺序,基本能覆盖九成情况。
第一步看总数查询和列表查询的条件是不是同一套。我见过很多次是因为列表查询加了状态过滤,总数查询忘了加,结果总页数按全量算,翻到后面几页全是空。第二步看偏移量算得对不对,特别是第一页的时候偏移量应该是零,如果是负数,不同数据库的表现不一样,有的报错有的返回全量。第三步看排序。分页查询必须带确定的排序字段,否则数据库返回的顺序是不保证的,翻页的时候会出现同一条记录在两页里都出现、或者某些记录从来没出现过。这个坑极其隐蔽,因为它不报错,只是数据看着不对劲。排序字段最好用主键或者创建时间这种唯一性强的字段,别用状态这种大量重复的字段。
查询结果为空的排查,我的顺序是:先在数据库客户端里把拼出来的 SQL 手动跑一遍,确认数据本身查得出来;然后看参数有没有正确传进来,在业务层打个日志把参数打出来;最后看结果映射有没有问题,字段名和属性名是否对得上。这三步走完,问题基本就无所遁形了。
5.3 常见问题速查表与独家避坑经验
把高频问题整理成表,出问题时先对号入座,能省下大量试错时间。
| 现象 | 最可能的原因 | 快速验证方式 | 处理方式 |
|---|---|---|---|
| 中文写入后变问号 | 连接串或建表字符集不匹配 | 查数据库里实际存储值 | 统一字符集,检查过滤器顺序 |
| 页面样式全部丢失 | 过滤器拦截了静态资源 | 看静态资源的请求状态码 | 放行名单补上资源路径 |
| 登录页反复重定向 | 登录页路径被拦截 | 看浏览器地址栏变化 | 放行登录页与提交路径 |
| 最后一页数据缺失 | 总页数计算未向上取整 | 比较总数与页数乘积 | 改用向上取整 |
| 翻页出现重复记录 | 分页查询缺少确定排序 | 连续翻页对比记录标识 | 加主键或时间排序 |
| 删除报外键约束错误 | 存在关联数据未检查 | 查关联表是否有引用 | 业务层前置校验并给友好提示 |
| 金额累加出现小数误差 | 金额字段用了浮点类型 | 多次相加观察尾数 | 改为定点小数或整型存分 |
| 修改被莫名覆盖 | 并发更新无版本校验 | 两人同时编辑同一条 | 加乐观锁校验修改时间 |
再分享几条文档里不会写的经验。日志要打在关键节点上,不是越多越好,而是在“参数进来”“数据库查询前”“查询结果出来”“跳转之前”这几个点上打,出问题时能立刻定位到是哪一段断了。异常不要吞,捕获了要么处理要么往上抛,最忌讳捕获了打印一行就完事,堆栈丢了等于什么都没留。
还有一条:改代码前先复现。很多人看到报错直接改,改完发现还是报错,因为改的地方根本不是根因。先稳定复现,再定位,再改,改完再复现验证,这个闭环比任何调试技巧都管用。
6. 从能跑的原型到可维护系统的改造思路
6.1 连接池与事务:两处改动带来质的提升
系统能跑起来和能给人用,中间隔着的往往是这两件事。
连接池解决的是连接创建开销问题。直连模式下,每次数据库操作都要建立一次物理连接,这个过程涉及网络握手和身份认证,耗时是毫秒级的,看着不多,但一个页面要查好几次数据库,并发一上来连接数就爆了。引入连接池后,连接被复用,取连接变成从池子里拿一个现成的,开销降到微秒级。配置上有三个参数要调:初始连接数、最大连接数、最大等待时间。最大连接数不是越大越好,超过数据库服务端的连接上限会直接报错;等待时间设短一点,让拿不到连接的请求快速失败,而不是排队排到超时。
事务解决的是多步操作的一致性问题。最典型的场景是:新增账单后要更新对应供应商的累计金额,两步操作必须一起成功或一起失败。不用事务的话,第一步成功第二步失败,数据就不一致了,而且这种不一致很难被发现,等到月底对账才暴露出问题。做法是把这两步包在一个事务里,任何一步抛异常就整体回滚。要注意的是事务的边界要放在业务层,不能在数据访问层随便开,否则一个业务操作会变成多个独立事务,失去了意义。
注意:用连接池加事务时,一定要保证同一个事务里的多次数据库操作拿到的是同一个连接,否则事务根本不生效。这个坑在手工管理连接的项目里非常常见,表现是回滚了但数据还是脏的。
6.2 安全加固清单:几件低成本高收益的事
安全这块,不用做到极致,但几个基本项必须过一遍。
参数化查询是第一位的,所有涉及用户输入的 SQL 都必须用占位符传参,字符串拼接一律禁止。这一条能挡掉绝大部分注入风险,而且改造成本极低,只是写法的区别。
权限校验放在服务端。前端隐藏按钮只是体验优化,真正的拦截必须在服务端做。做法是在业务层或者控制层判断当前登录用户的角色,没有权限的操作直接拒绝并返回明确提示。特别是删除、修改金额这类敏感操作,一定要校验。
敏感信息脱敏。列表页展示用户信息时,手机号、地址这类可以部分打码;日志里绝对不要打印密码、完整手机号这类信息。很多人调试时图方便把整个对象打印出来,日志文件一存就是几个月,等于把敏感数据长期留在了磁盘上。
会话安全。登录后更换会话标识,退出时销毁会话,会话超时时间设置合理。这几条都是标准动作,代码量不大,但能避免会话固定攻击这类问题。
6.3 我个人在这个项目上的几点实操体会
最后说几句掏心窝子的经验。
先跑通一条完整的链路,再铺开做其他模块。我最开始做这类系统时,喜欢把五张表的增删改查全写完再联调,结果一联调全是问题,因为基础的分页、编码、会话这些公共部分没验证过就被复制到了所有模块,错一次等于错五遍。后来改成先把用户模块从头到尾跑通——登录、列表、分页、新增、修改、删除全部验证一遍,把公共部分的坑踩完,再复制到其他模块,效率反而高了一倍。
数据库设计阶段多花一天,编码阶段少花三天。字段类型、长度、索引、外键这些在纸上定清楚了再动手,后面改起来成本极高。特别是索引,等数据涨到几万条发现查询慢,再去加索引,还要评估对写入的影响,那时候就是被动应付了。建表时就把高频查询条件的字段加上索引,比如按供应商查账单、按用户名查用户,这些是必然要用的。
不要为了炫技引入用不上的东西。这个体量的系统,缓存、消息队列、分布式锁统统不需要,引入了只会增加故障点。我见过有人在练手项目里加缓存,结果数据不一致的问题查了两天,最后发现是缓存没失效。技术选型的标准永远是“当前需求需不需要”,而不是“这个东西流不流行”。
把测试数据当资产维护。一套覆盖各种边界情况的测试数据,比任何文档都有价值。新加一个筛选条件,拿测试数据一跑就知道对不对;改一个统计逻辑,跑一遍就知道结果变没变。我现在的习惯是把测试数据脚本一起纳入版本管理,每次改完功能先跑了再说。
最后一点,写完的东西一定要让别人用一遍。自己用的时候会下意识避开那些不好用的地方,别人用的时候专挑你没想到的路径走。一个真实用户点三下就能发现你藏了半天的交互问题,这比自测一百遍都有效。