今年年中接手了一个老系统的升级改造,前端是 AngularJS,后端数据层是 SQL Server。很多朋友一听 AngularJS 就觉得是过时技术,我一开始也有点犹豫,但真正把业务跑通之后,我开始理解为什么那么多企业还在用这套组合。市面上的教程大多是“AngularJS 怎么做 CRUD”“SQL 怎么写查询”,但很少人讲清楚一件事:AngularJS 的双向绑定机制,到底该怎么和 SQL 的结构化数据模型配合,才能既快又稳,还不留下安全隐患。这篇文章不打算讲那种高大上的理论,我想用一个真实改造项目的经验,把从数据库表设计、API 封装,到前端控制器绑定的完整链路拆开来讲。适合正在维护 AngularJS 老项目的人,也适合那些需要在传统技术栈上做新功能开发的团队参考。
1. 项目定位与整体架构设计
1.1 为什么还要做 AngularJS 与 SQL 的深度整合
先说背景。这个系统是公司内部的资产管理系统,跑了好几年,页面还是后端渲染加 jQuery 那套老写法。业务部门现在提出了几个新需求:资产台账要支持模糊搜索和组合筛选,借用记录要实时刷新,月度报表要按部门、类别多维度统计。如果继续用传统方式,每个操作都要整页刷新,用户体验差,开发和维护的效率也低。
一开始团队也讨论过要不要直接上 Vue 或 React,但仔细评估之后还是放弃了。原因很简单:业务逻辑都固化在现有 AngularJS 代码里,控制器、服务、指令一整套体系是完整的,如果推倒重来,数据库结构可以保留,但前端交互逻辑全部重写,光回归测试就要耗掉大量时间。对于企业内部系统来说,稳定压倒一切,技术栈新旧不是第一考虑因素。
所以这次改造的核心思路,是在保留 AngularJS 前端骨架的前提下,把数据访问层彻底打通。所谓“深度整合”,不是简单地在 AngularJS 里写几个$http.get请求,而是要让 SQL 的查询能力、事务能力、约束能力,真正服务于前端的交互逻辑。比如前端一个“借出资产”的操作,不是简单 UPDATE 一条记录,而是涉及资产状态变更、借用记录插入、库存余量校验这三个步骤,必须放在同一个 SQL 事务里完成。这种场景,才是深度整合真正要解决的问题。
还有一点很多人会忽略:AngularJS 的双向绑定虽然在视图和数据模型之间建立了自动同步,但这种同步是有成本的。如果后端返回上万行数据直接塞进 Scope,页面的脏检查会卡到没法用。这就必须在 SQL 层就做好分页、筛选、聚合,让 AngularJS 拿到的永远是“刚刚够用”的数据量。整套体系的设计逻辑,应该让 SQL 做它擅长的事,让 AngularJS 只负责交互呈现。
1.2 技术选型与架构解析
技术选型这块,数据库我们继续用 SQL Server,因为公司现有的数据量已经不小,而且 SQL Server 的事务处理、索引优化、权限管理能力在企业场景下确实扎实。后端桥梁选择了 Node.js + Express,也有同事提议过 ASP.NET,但考虑到团队现有成员的技能栈和后续维护成本,Node.js 更顺手,而且 AngularJS 本身就是 JavaScript 体系,前后端语言大一统可以减少很多心智负担。
整体架构可以分成三层:
- 前端层:AngularJS 负责页面交互、表单校验、数据展示,所有数据都通过 RESTful API 获取。
- API 层:Express 路由接收请求,校验参数,调用数据库访问模块,返回统一格式的 JSON。
- 数据层:SQL Server 存储数据,承担查询、事务、约束、索引等工作。
这个分层看起来简单,但实际操作中有很多细节需要把控。比如 API 层的数据校验,不能只靠前端做一轮校验,后端的参数校验必须同样严格,因为 SQL 语句的拼接点往往就出现在参数处理不当的地方。再比如 API 层返回的 JSON 格式要稳定统一,前端控制器才能形成一套通用的数据解析逻辑,而不是每个接口各写一套。
技术选型对比,我整理了一张表:
| 层级 | 选型 | 备选方案 | 选择理由 |
|---|---|---|---|
| 前端 | AngularJS 1.x | Vue 2/3、React | 老项目技术栈延续,改动最小 |
| 后端 | Node.js + Express | ASP.NET Core、Java Spring Boot | 团队技能匹配,与前端语言统一 |
| 数据库 | SQL Server 2019 | MySQL、PostgreSQL | 企业环境稳定性好,事务能力强 |
| 数据库工具 | Navicat for SQL Server | SSMS、DBeaver | 跨平台,可视化操作方便 |
这里没有选择引入 ORM 框架,比如 Sequelize 之类的,原因是这套系统的查询逻辑比较复杂,很多报表类的 SQL 要手动调优,ORM 自动生成的语句既绕又难调性能。我们的做法是写一个轻量级的数据库访问模块,使用mssql驱动,SQL 语句手写但全部参数化,既保留了 SQL 的灵活度,又守住了安全底线。这个决定在后面排查慢查询的时候,节省了大量时间。
2. 核心细节拆解:从 SQL 数据模型到 AngularJS 视图
2.1 AngularJS 双向绑定如何对接 SQL 数据模型
AngularJS 的核心特性就是双向绑定,视图层的输入框、下拉框、表格会实时同步到 Scope 中的 JavaScript 对象。而 SQL 的数据模型是结构化的行和列,两者之间的对接,本质上是一个映射问题。
比如数据库里有一张资产表Assets,包含AssetID、AssetName、CategoryID、Status、PurchaseDate等字段。后端 API 查询出来的是一个 JSON 数组,其中每个对象对应一行记录。AngularJS 的控制器里,只需要把数组赋值给 Scope 上的变量,视图中用ng-repeat就能自动渲染表格。这里没什么高深的东西,真正的难点在于数据量大之后,AngularJS 的脏检查机制暴露出来的性能问题。
脏检查机制的原理,是在每次$digest循环中遍历所有绑定到 Scope 的变量,比较新旧值,有变化就更新视图。这个机制在小数据量下完全没问题,但数据量到几百上千条时,浏览器就开始卡顿。我在表格渲染这一块踩过很深的坑,后来总结出的方案是:SQL 层做分页,每次只取当前页的数据(比如每页 50 条),前端只在翻页时重新发起请求替换数据。这样减少了 Scope 中数据的规模,脏检查的效率压力就大大降低。
双向绑定和 SQL 数据模型的对接,还需要特别关注字段命名问题。SQL Server 的字段命名习惯通常是PascalCase或snake_case,而 AngularJS 社区的 JavaScript 惯例是camelCase。如果后端直接返回数据库原名字段,前端代码写起来会很不舒服。我的做法是在 SQL 查询里给字段起别名,统一转成camelCase,比如:
SELECT AssetID AS assetId, AssetName AS assetName, CategoryID AS categoryId, Status AS status, PurchaseDate AS purchaseDate FROM Assets WHERE Status = @status;这样后端返回的 JSON 字段名就是前端期望的样子,AngularJS 控制器里可以直接asset.assetName取值,不需要再做一层字段名转换。这个小细节一开始没注意,后来项目里十几个接口全部返工,浪费了不少时间。
2.2 后端 API 层:安全与效率的关键防线
API 层是整个整合链路中最容易出问题的地方。前端 AngularJS 发一个请求,后端要把这个请求翻译成 SQL 操作,中间任何一环处理不当,轻则数据错误,重则直接给攻击者打开大门。
先谈安全。SQL 注入是首先要防的。很多老教程里还在教字符串拼接 SQL,比如SELECT * FROM Users WHERE UserName = '+ userName +',这要是在生产环境里跑,等于把数据库裸奔给任何人。AngularJS 前端可以随便构造请求参数,攻击者只要在输入框里输入一段恶意 SQL,比如' OR '1'='1,就能绕过登录校验。热词里那个“sql注入万能密码绕过”,说的就是这种攻击手法。
解决方案就是参数化查询。以 Node.js 里的mssql驱动为例,所有用户输入都通过input()方法绑定参数,数据库驱动会自动对参数内容做转义,防止被当作 SQL 语句执行:
const sql = require('mssql'); async function getUserByUsername(username) { const pool = await sql.connect(dbConfig); const result = await pool.request() .input('username', sql.NVarChar, username) .query('SELECT UserID, UserName, Role FROM Users WHERE UserName = @username'); return result.recordset; }这里@username是参数占位符,input()方法把它绑定为sql.NVarChar类型。无论传入什么样的字符串,都只会被当作一个普通字符串值来处理,永远不可能变成 SQL 指令的一部分。
另外,API 层的权限控制也不能马虎。AngularJS 前端的按钮可以通过路由和指令控制显示,但那只是用户体验层面的隐藏,真正可靠的权限控制必须放在后端。每个 API 接口在处理请求之前,要校验当前登录用户的角色和权限,没有权限直接返回 403。我在项目里写了一个简单的权限中间件,对所有涉及写操作的接口做了统一校验,避免出现前端隐藏了按钮、但接口仍可直接调用被越权操作的情况。
效率方面,API 层要做的一件重要事情是参数预校验。比如分页参数pageSize如果传一个 100000,SQL 层又不做限制,一条查询就会把整个表的数据全捞出来,内存直接爆掉。所以我在 API 层对分页参数做了上限限制:
const pageSize = Math.min(parseInt(req.query.pageSize) || 20, 100); const pageIndex = Math.max(parseInt(req.query.pageIndex) || 1, 1); const offset = (pageIndex - 1) * pageSize;2.3 数据格式约定与前后端契约
前后端联调最讨厌的事情就是数据格式不统一。一会儿返回数组,一会儿返回对象,一会儿错误信息在message里,一会儿又在error里。AngularJS 控制器里写一堆分支判断,维护起来极其痛苦。
我在这次项目里做了一个统一的响应格式约定,所有接口都返回相同结构的 JSON:
{ "code": 0, "message": "success", "data": { "list": [], "total": 100, "pageIndex": 1, "pageSize": 20 } }code为 0 表示成功,非 0 表示业务错误码,比如 1001 表示参数错误,1002 表示无权限,1003 表示数据不存在。message是人可读的描述信息。data是真正的业务数据。AngularJS 的服务层统一解析这个格式,控制器只需要关注业务数据,不需要关心错误处理逻辑的差异。
这个契约还要解决一个非常实际的问题:日期格式。SQL Server 的DATETIME字段通过mssql驱动返回后,会变成 JavaScript 的Date对象,序列化为 JSON 的时候默认会变成 ISO 字符串,带时区偏移。而 AngularJS 的日期控件、表格显示有时需要YYYY-MM-DD,有时需要YYYY-MM-DD HH:mm:ss。我在 SQL 层统一使用FORMAT函数处理日期输出格式:
SELECT AssetID AS assetId, AssetName AS assetName, FORMAT(PurchaseDate, 'yyyy-MM-dd') AS purchaseDate FROM Assets;这样后端返回的日期已经是格式化好的字符串,AngularJS 直接显示,不需要再折腾日期格式化过滤器。代价是 SQL 层多做了一点格式化工作,但换来的是前后端逻辑的极大简化。
另外,枚举字段的映射也要在 SQL 层解决。比如资产状态Status字段在数据库里是TINYINT,0 代表入库,1 代表借出,2 代表维修。如果直接返回数字,AngularJS 模板里就要写一堆ng-if判断,代码难看还不利于维护。我在 SQL 查询里用CASE WHEN把枚举值直接转成可读字符串:
SELECT AssetID AS assetId, AssetName AS assetName, CASE Status WHEN 0 THEN '入库' WHEN 1 THEN '借出' WHEN 2 THEN '维修' ELSE '未知' END AS statusText, Status AS statusCode FROM Assets;这样前端既拿到了用于显示的状态文字,也保留了用于逻辑判断的状态数值。
3. 实操过程:一步一步搭建整合环境
3.1 环境准备与数据库初始化
先交代一下这套体系运行的环境。数据库用的 SQL Server 2019 标准版,操作系统是 Windows Server。安装 SQL Server 的时候有几个坑,至少在办公环境里遇到过好几次:安装到一半提示“SQL 安装失败”,十有八九是系统缺少 .NET Framework 组件,或者杀毒软件拦截了安装进程。解决办法是先安装系统更新,把 .NET Framework 4.8 装好,安装 SQL Server 的时候暂时退出杀毒软件。如果安装日志里提示某个 Windows 服务启动失败,先手动把服务对应的依赖服务启动起来再重试。
数据库开发阶段我推荐用 Navicat for SQL Server 来管理和调试。Navicat 的可视化操作界面比 SSMS 轻量,跨平台也好用。注意工具本身的授权要走正规渠道,团队版价格也不贵,别去网上搜那些激活码,不安全不说,出问题还没人给你兜底。用 Navicat 连 SQL Server 的时候,连接方式选 SQL Server Authentication,填好服务器地址、端口(默认 1433)、用户名密码。如果连接不上,先检查 SQL Server 的 TCP/IP 协议是否启用,这一步在 SQL Server 配置管理器里操作。
接下来是建表。这里列举一张简化版的资产表和借用记录表,作为后面示例的基础:
CREATE TABLE Assets ( AssetID INT IDENTITY(1,1) PRIMARY KEY, AssetCode NVARCHAR(50) NOT NULL UNIQUE, AssetName NVARCHAR(100) NOT NULL, CategoryID INT NOT NULL, Status TINYINT NOT NULL DEFAULT 0, PurchaseDate DATETIME NULL, CONSTRAINT FK_Assets_Category FOREIGN KEY (CategoryID) REFERENCES Categories(CategoryID) ); CREATE TABLE BorrowRecords ( RecordID INT IDENTITY(1,1) PRIMARY KEY, AssetID INT NOT NULL, BorrowerName NVARCHAR(50) NOT NULL, BorrowDate DATETIME NOT NULL DEFAULT GETDATE(), ReturnDate DATETIME NULL, Status TINYINT NOT NULL DEFAULT 0, CONSTRAINT FK_BorrowRecords_Asset FOREIGN KEY (AssetID) REFERENCES Assets(AssetID) );这两张表的结构很简单,但已经覆盖了这次整合要用到的大部分操作场景:单表查询、多表关联、状态更新、事务操作。
3.2 后端 API 代码实现
后端的核心模块是数据库访问。我用mssql驱动维护了一个连接池,避免每个请求都新建数据库连接带来的开销:
const sql = require('mssql'); const dbConfig = { user: 'asset_app', password: 'YourStrongPassword', server: 'localhost', database: 'AssetDB', options: { encrypt: false, trustServerCertificate: true }, pool: { max: 10, min: 0, idleTimeoutMillis: 30000 } }; async function query(sqlText, params) { const pool = await sql.connect(dbConfig); const request = pool.request(); if (params) { for (const key of Object.keys(params)) { request.input(key, params[key].type, params[key].value); } } const result = await request.query(sqlText); return result.recordset; }这里有一个细节要说明:连接池参数max设置为 10,是因为这个内部系统的并发量并不高,10 个连接足够满足需求,连接数太多反而会占用数据库服务器的资源。如果外部并发访问量大,这个参数要相应调大,但不能无脑调大,要结合 SQL Server 的最大连接数限制来设置。
资产借出这个操作,是整个系统里逻辑最复杂的,需要在同一个 SQL 事务里完成三步操作。我封装了一个借用资产的接口:
app.post('/api/assets/borrow', async (req, res) => { const { assetId, borrowerName, borrowDate } = req.body; if (!assetId || !borrowerName) { return res.status(400).json({ code: 1001, message: '参数不完整' }); } const pool = await sql.connect(dbConfig); const transaction = new sql.Transaction(pool); try { await transaction.begin(); const request = new sql.Request(transaction); // 第一步:检查资产状态 const assetResult = await request .input('assetId', sql.Int, assetId) .query('SELECT Status FROM Assets WITH (UPDLOCK, ROWLOCK) WHERE AssetID = @assetId'); if (assetResult.recordset.length === 0) { throw new Error('资产不存在'); } const assetStatus = assetResult.recordset[0].Status; if (assetStatus !== 0) { throw new Error('资产当前不可借出'); } // 第二步:更新资产状态 await request .input('assetId', sql.Int, assetId) .input('status', sql.TinyInt, 1) .query('UPDATE Assets SET Status = @status WHERE AssetID = @assetId'); // 第三步:插入借用记录 await request .input('assetId', sql.Int, assetId) .input('borrowerName', sql.NVarChar, borrowerName) .input('borrowDate', sql.DateTime, borrowDate || new Date()) .query('INSERT INTO BorrowRecords (AssetID, BorrowerName, BorrowDate, Status) VALUES (@assetId, @borrowerName, @borrowDate, 0)'); await transaction.commit(); res.json({ code: 0, message: 'success', data: null }); } catch (err) { await transaction.rollback(); res.status(500).json({ code: 1003, message: err.message }); } });这里的重点是事务的commit和rollback。如果三个步骤任何一步失败,整个事务回滚,保证资产状态和借用记录始终保持一致。另外一个容易被忽略的细节是WITH (UPDLOCK, ROWLOCK)锁提示,这是为了防止高并发场景下两个请求同时读到“可借出”的状态,然后都去更新,导致数据不一致。虽然这个内部系统并发量不大,但写代码的时候把这些边界考虑进去,是职业习惯问题。
3.3 AngularJS 前端集成实现
前端部分,AngularJS 的项目结构我按模块化来组织。先定义整个应用的主模块:
var assetApp = angular.module('assetApp', ['ngRoute']); assetApp.config(['$routeProvider', function($routeProvider) { $routeProvider .when('/assets', { templateUrl: 'views/asset-list.html', controller: 'AssetListController' }) .when('/assets/borrow', { templateUrl: 'views/asset-borrow.html', controller: 'AssetBorrowController' }) .otherwise({ redirectTo: '/assets' }); }]);访问后端接口的逻辑抽到一个独立的 Service 里,这样控制器不用关心 HTTP 请求的细节,也方便统一处理错误:
assetApp.factory('AssetService', ['$http', function($http) { return { getAssetList: function(params) { return $http.get('/api/assets', { params: params }); }, borrowAsset: function(data) { return $http.post('/api/assets/borrow', data); } }; }]);列表页的控制器,核心逻辑就是调用 Service 取数据,然后通过双向绑定自动渲染到表格:
assetApp.controller('AssetListController', ['$scope', 'AssetService', function($scope, AssetService) { $scope.assets = []; $scope.total = 0; $scope.pageIndex = 1; $scope.pageSize = 50; $scope.loading = false; $scope.loadAssets = function() { $scope.loading = true; AssetService.getAssetList({ pageIndex: $scope.pageIndex, pageSize: $scope.pageSize, keyword: $scope.keyword }).then(function(res) { if (res.data.code === 0) { $scope.assets = res.data.data.list; $scope.total = res.data.data.total; } $scope.loading = false; }, function() { $scope.error = '加载失败,请检查网络'; $scope.loading = false; }); }; $scope.search = function() { $scope.pageIndex = 1; $scope.loadAssets(); }; $scope.loadAssets(); }]);对应视图模板里的核心部分:
<table class="table table-striped"> <thead> <tr> <th>资产编码</th> <th>资产名称</th> <th>状态</th> <th>购入日期</th> <th>操作</th> </tr> </thead> <tbody> <tr ng-repeat="asset in assets"> <td>{{ asset.assetCode }}</td> <td>{{ asset.assetName }}</td> <td>{{ asset.statusText }}</td> <td>{{ asset.purchaseDate }}</td> <td><button ng-click="borrow(asset)">借出</button></td> </tr> </tbody> </table>这里ng-repeat就是双向绑定和数据驱动视图的直观体现。只要 Scope 中的assets数组变化了,DOM 表格会自动重新渲染,不需要手动操作 DOM。这个模式写起来很舒服,但前面提到过性能问题,所以我把分页逻辑设计成每次只加载 50 条,在 SQL 层用OFFSET FETCH分页,AngularJS 这边只维护当前页数据。
3.4 联调与部署要点
前后端联调的阶段,最容易踩的坑是跨域。如果 AngularJS 页面跑在 8080 端口,后端 Node.js API 跑在 3000 端口,浏览器会拦截跨域请求。解决办法有两种:一种是在后端启用 CORS 中间件,另一种更简单——在开发环境用 Nginx 做反向代理,把/api前缀的请求转发到后端服务。我用的是第二种方式,因为生产环境本身就需要 Nginx 托管静态页面,顺手就能解决跨域问题。
Nginx 的关键配置片段:
server { listen 80; server_name asset.internal.example.com; location / { root /var/www/asset-front; index index.html; try_files $uri $uri/ /index.html; } location /api { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files那一行是 AngularJS 路由刷新不报 404 的关键。AngularJS 使用的是 Hash 路由,正常情况不会触发服务端路由查找,但如果未来改造为$locationProvider.html5Mode(true),这个配置就必须加上,否则刷新非首页路径会直接 404。这里提前配置好,等于给将来留了一条后路。
部署阶段还要注意接口超时问题。SQL Server 如果遇到慢查询,响应时间可能很长,而 AngularJS 的$http默认超时时间是 120 秒,一般够用。但要注意的是,如果数据库连接池满了,新请求会排队等待,超时会被拉长。我在实际部署中会监控数据库活跃连接数和慢查询日志,一旦发现问题,优先优化 SQL,而不是单纯去调前端超时参数。
4. 常见问题与排查技巧实录
4.1 SQL Server 环境问题排查
环境类问题占了这个项目排查工作量的三成左右,虽然不影响架构设计,但处理起来很折磨人。这里整理几个高频问题的排查思路。
SQL Server 安装失败。常见的原因集中在系统组件缺失、安装顺序冲突和权限不足。尤其是装了一些大型工业软件之后,再装 SQL Server 很容易冲突。我的排查方法是看安装日志,路径一般在C:\Program Files\Microsoft SQL Server\150\Setup Bootstrap\Log下。日志里会明确写出失败的具体步骤和错误码。如果提示0x858C001B之类的结果码,多半是安装过程被中断,清空注册表残留后重装即可。如果没把握清理干净,最好的办法是用微软官方工具卸载再装,不要手动动注册表。
SQL Server 密码过期。热词里提到的“sql server 2012 密码到期”是个很典型的问题。默认安装 SQL Server 的时候,如果启用了密码策略,数据库登录账号的密码会按 Windows 策略定期过期。企业环境里经常发生某天突然连不上库,报“登录失败”或“密码已过期”。解决方案是:避免用 SQL Server 账号直接连数据库,改为 Windows 身份验证模式,或者在创建 SQL Server 账号时执行以下语句,关闭密码过期策略:
ALTER LOGIN asset_app WITH CHECK_POLICY = OFF; ALTER LOGIN asset_app WITH CHECK_EXPIRATION = OFF;Navicat 连接不上 SQL Server。优先排查三层:网络连通性,登录账号权限,SQL Server 的 TCP/IP 协议状态。telnet 服务器IP 1433测一下端口通没通,不通就查防火墙。接着在 SQL Server 配置管理器里确认TCP/IP协议已启用,且 IP 地址的监听端口没有被占用。账号权限方面,测试登录时先用sa账户排除账号自身权限问题,确认能连上后再逐步回收权限。
4.2 SQL 注入与安全加固
SQL 注入的防护,在这个项目里是红线。前面已经提到参数化查询是标准做法,但有些场景容易被忽略。
第一类易忽略场景是动态排序。比如前端表格允许用户点击表头排序,排序字段是拼接进 SQL 的。如果直接把字段名拼进去,攻击者可以通过构造字段名注入恶意 SQL。虽然排序字段通常只能出现在ORDER BY后面,但依然有风险。我的做法是把排序字段做成白名单:
const sortableColumns = { 'assetCode': 'AssetCode', 'assetName': 'AssetName', 'purchaseDate': 'PurchaseDate' }; const sortField = sortableColumns[req.query.sortField] || 'AssetCode'; const sortOrder = req.query.sortOrder === 'desc' ? 'DESC' : 'ASC';第二类易忽略场景是模糊搜索。LIKE '%' + @keyword + '%'这种写法,如果关键字里包含%或_,就会被 SQL 的通配符解析,导致查询结果不准确,甚至成为注入点。处理办法是先对关键字做转义:
const keyword = req.query.keyword.replace(/[%_]/g, function(match) { return '\\' + match; });然后在 SQL 中使用LIKE '%' + @keyword + '%' ESCAPE '\'。
第三类是通用登录绕过。前面提到的“万能密码绕过”,本质就是开发者用字符串拼接方式构造了登录 SQL,攻击者输入' OR '1'='1等字符串,让 WHERE 条件恒为真。这类漏洞的修复很简单,统一用参数化查询就能解决。但要注意,不要以为用了存储过程就绝对安全,如果存储过程内部也是用拼接字符串执行动态 SQL,注入风险依然存在。排查时可以在 SQL Server 的sys.dm_exec_requests动态管理视图中实时查看正在执行的 SQL 语句,看看是否有可疑的拼接痕迹。
4.3 慢 SQL 与查询性能优化
AngularJS 和 SQL 深度整合之后,用户对页面响应速度的感知会直接反映到 SQL 查询性能上。如果某个页面加载要 3 秒以上,用户体验会非常糟糕。这次项目里排查和优化慢 SQL 的经历,值得单独拿出来讲。
慢 SQL 的定位,在 SQL Server 上可以先开启慢查询捕获。用sys.dm_exec_query_stats动态管理视图,可以查到耗时靠前的 SQL 语句:
SELECT TOP 10 qs.total_elapsed_time / qs.execution_count AS avg_elapsed_time, qs.execution_count, SUBSTRING(st.text, (qs.statement_start_offset/2) + 1, ((CASE qs.statement_end_offset WHEN -1 THEN DATALENGTH(st.text) ELSE qs.statement_end_offset END - qs.statement_start_offset)/2) + 1) AS query_text FROM sys.dm_exec_query_stats qs CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st ORDER BY avg_elapsed_time DESC;定位到慢 SQL 之后,优先看执行计划。SQL Server Management Studio 或 Navicat 里都能查看图形化执行计划。重点关注两个标志:表扫描(Table Scan)和索引扫描(Index Scan)。如果看到这两个操作,说明查询没有走索引,数据量一大性能必然下降。
这次项目里让我印象最深刻的一个优化案例,是资产列表的组合筛选查询。SQL 语句带多个筛选条件,每次查询都全表扫描,数据量到 10 万行以后,响应时间直接飙到 4 秒。优化方案是建了组合索引:
CREATE NONCLUSTERED INDEX IX_Assets_Category_Status ON Assets (CategoryID, Status) INCLUDE (AssetName, PurchaseDate);这个索引让WHERE CategoryID = @categoryId AND Status = @status的查询从全表扫描变成了索引查找,响应时间从 4 秒降到了 200 毫秒左右。索引设计的原则是:区分度高的字段放前面,用于过滤的字段放前面,需要回表查询的字段放进INCLUDE里。
另外要处理的一类慢查询是去重操作。热词里反复出现的“sql语句去重”“sql语句去重查询”,在日常业务中确实很常见。两张方式:
-- 方式一:DISTINCT 去重 SELECT DISTINCT CategoryID FROM Assets; -- 方式二:窗口函数去重(适用于需要保留完整行数据的场景) SELECT * FROM ( SELECT *, ROW_NUMBER() OVER(PARTITION BY AssetID ORDER BY PurchaseDate DESC) AS rn FROM Assets ) AS t WHERE t.rn = 1;DISTINCT适合简单去重,但如果你要基于去重逻辑保留某一行完整的数据,ROW_NUMBER() OVER(PARTITION BY ...)窗口函数更可靠。窗口函数在 SQL Server 2019 里已经很好用了,这一块值得花时间掌握。
关于 SQL 优化还有一个经验教训:千万不要盲目加索引。索引不是越多越好,每个索引都会占用磁盘空间,还会拖慢写入速度。我见过有些同事为了优化查询,把所有涉及的组合都建了索引,结果一张表十几个索引,写入性能严重下降。正确的做法是先看慢查询日志,针对最频繁、最耗时的查询精准设计索引,宁缺毋滥。
写在最后的一点实际体会
这个项目改造下来,我个人最大的体会是:AngularJS 和 SQL 的整合,技术难点其实不在框架本身,而在数据链路的每个节点上。SQL 层要考虑查询性能、数据一致性、安全防护,API 层要考虑参数校验、统一格式、事务处理,AngularJS 层要考虑数据规模、绑定效率、交互体验,任何一环有短板,整个系统的体验都会被拖累。
如果你也正在维护一个 AngularJS 老项目,我的建议是先别急着推翻重写,把数据库访问层、API 层、前端数据映射这些链路梳理清楚,性能优化和安全加固做完,老系统完全可以焕发新的生命力。
最后再分享一个小技巧:SQL Server 的FORMAT函数虽然方便,但性能开销比较大,如果在查询量大、数据量高的场景下,日期格式化尽量在 API 层或前端做,别过度依赖 SQL 函数。这个细节是我在压测阶段发现的,一条查询里用了三个FORMAT,查询时间直接翻倍。后来改成后端处理日期格式,性能明显改善。技术选型没有银弹,每个环节都要根据实际场景做权衡。