我们需要停止使用存储过程。
We need to stop using Stored Procedures

原始链接: https://heffree.dev/blog/no-more-sprocs.html

作者主张应用程序开发者应停止依赖存储过程(Stored Procedures),转而在应用代码中管理数据库逻辑。与普遍观点相反,存储过程相比精心构建的参数化查询并无固有性能优势,因为现代数据库管理系统对二者的执行计划和缓存处理方式并无二致。 将逻辑从存储过程中移出,可以让开发者将数据库查询与应用代码一并纳入版本控制,从而简化部署与回滚流程。为有效实现这一点,作者建议开发者通过掌握以下核心概念,主动掌控 SQL 与数据库性能: * **网络优化:** 通过批处理或“插入更新”(upsert)模式减少往返次数。 * **索引:** 根据数据基数策略性地使用索引以优化性能,同时警惕不必要的写入操作。 * **查询模式:** 通过谨慎管理复杂连接来避免“笛卡尔积爆炸”,并使用表值参数(TVPs)处理动态列表。 * **ORM 意识:** 审查 ORM 生成的查询,以防止 N+1 问题和低效的数据获取。 * **性能调优:** 理解隐式类型转换、过滤索引以及性能分析的重要性。 归根结底,虽然存储过程有其小众的用武之地,但它应作为例外,而非准则。

这篇 Hacker News 讨论聚焦于一篇颇具争议的文章,文中认为存储过程已过时且成为一种负担。评论者们则提出了更为细致的反面观点,质疑其“毫无价值”的论调。 支持存储过程的人士强调了几个实际优势: * **性能:** 它们通过在数据库内执行多个语句来减少网络往返次数。 * **安全与审计:** 它们为数据访问提供了一个受控接口,这在金融或医疗等监管严格的行业中至关重要。 * **维护:** 现代工具(如 Liquibase、Flyway)允许开发人员将存储过程与应用程序代码一起进行版本控制,并将其集成到 CI/CD 流水线中。 相反,质疑者认为将业务逻辑置于数据库内会带来维护上的“头痛”问题,特别是在可测试性和隔离性方面。批评者将依赖存储过程视为一种过时的做法,认为对于管理复杂系统而言,核心 API 层是一种更具可维护性的现代替代方案。 归根结底,虽然许多开发人员倾向于在应用层处理逻辑以避免数据库绑定代码带来的复杂性,但也有人坚持认为,在管理得当的情况下,存储过程对于数据留存、安全性和性能优化等特定应用场景来说,仍然是一种强大的工具。
相关文章

原文

Please, application developer, I beg you, stop allowing reliance on Stored Procedures to enter your codebase.

Please, application developer, I beg you, learn some SQL!

Please, database administrator, I beg you, stop using Stored Procedures as a cudgel to compensate for your application devs.

Please, database administrator, I beg you, guide your devs when it comes to SQL, indexes, query plans, transactions, isolation levels, parameterized queries, parameter sniffing, sargability, profiling, bookmark lookups, schema design, normal forms, denormalization, cardinality, B(+)-trees, WAL, the holy trinity of CPU/Log IO/Data IO, and everything else my DBA has yet to teach me.Or that I failed to mention... Also, I'm not incapable of learning on my own and I swear I do it. I just always learn better when a DBA tells me something 😅

- Me, for the last 5 years

Alright, great, now that that's out of the way I can go back to calling them sprocs. Listen, we need to talk,Or I need to talk at you I guess. You can respond at [email protected] I don't know if it's just a thing at my work — and in that case I guess I'm outing us a bit — but I'm seeing far too many (i.e. any at all) applications calling out to a sproc. Thankfully, this (suspiciously new) Reddit user mentioned it as well, so I must not be alone!

I've felt this way for a while, but seeing this (plus recent events at work) spurred me to make a quick post going over it. My hope is to cement such an all-encompassing reasoning here in this post that there's no room for argument, only disagreement on the final judgement. Luckily, there really isn't much to it, honest.

Forgive me for this being so SQL Server centric, but it doesn't change the thrust that if you own an application, you should do what you can to colocate your DB access logic with your app logic.

What do you want out of your DBMS?

When we make a request to a database there two major sproc benefits we care about replicating.

The first is plan caching. When you make a database query, your DBMS will use the statistics of your data to calculate the optimal way to gather that data. This is an expensive process, so ideally this is a rare operation.

The second is parameterized queries. Parameterized queries allow us to inform the DBMS of the types of our inputs, they let us separate the data from the logic. Conveniently, parameterized queries also help us reuse query plans from the plan cache. If I send SELECT 1 FROM my_table WHERE code = 'SUPER_COOL_GUY' and then I send SELECT 1 FROM my_table WHERE code = 'SUPER_UNCOOL_GUY', that's two separate query plans, that's the evil we call a dynamic query.Typically, dynamic queries are seen when the structure of the query changes a bunch, but this counts!

However, if I send:

-- Hey, look, a stored procedure, well dammit... 
-- we can rely on this one, that's fine
EXEC sp_executesql
N'SELECT 1 FROM my_table WHERE code = @code',
N'@code NVARCHAR(50)',
@code = N'SUPER_MEH_GUY'

Now we have a parameterized query where we can send anything in for code and it'll use the same query plan over and over, without the DBMS having to recalculate.That is — as long as the parameter hasn't been Parameter Sensitive Plan sniffed and deemed in need of a separate query plan, but that'd happen with sprocs too. And is also super SQL Server specific.

So what does a stored procedure get us?

Absolutely nothing! Well, I mean, headache for one. But whether you execute your SQL as above or call into a sproc, your DBMS is going to treat it just the same. It will handle binding your inputs to your parameters, it will generate a query plan on first execution (yup, not precomputed for sprocs), and it will reuse the query plan on seeing the same query body. We get all the same benefits.

What other fun side effects do we get from using a sproc? Well, our application code and DB access are separately versioned, our sprocs can change right under our feet from aberrant (i.e. extremely rare, insane) DBAs, we have to deploy migrations to update our queries, and we have to run diff migration_for_my_sproc migration_for_my_sproc_n to see how things changed. And don't even get me started on rollbacks, oh my goodness... when's the last time you initiated a database rollback after a deploy? For my team, never, we actually can't because it's too convoluted and dangerous so we haven't even introduced the means.

On the other hand, if we own our queries — well, we get to own our queries, that's awesome! We get our database access lockstep versioned with our application logic. We get to take advantage of actual-version-control-without-having-a-file-that-just-holds-the-contents-of-a-sproc-that-we-try-to-remember-to-update-every-time-we-write-a-migration-to-deploy the-new-version-of-a-sproc-that-invariably-gets-out-of-sync-because-no-one-made-a-CI-check-to-see-if-the-migration-you-just-deployed-also-made-sure-to-update-the-redundant-file-you-track just-for-version-control... *heavy breathing* And also, we can rollback our application and our queries!

After all that, I can only assume if you still want a stored procedure you just really want your DBAs to own your SQL for some reason (those reasons exist). Thank them for so kindly being on-call with you.

How should app devs interact with their DBs?

Yeah, that's right, your DB, have some pride, gall darn it.Assuming you've opted into "banning" sprocs from your application at this point, otherwise put your pride where you will.

Now that we've accepted responsibility for our database access, it's important we be upstanding coworkers and help lighten the load as much as possible for our DBAs. You won't avoid every mistake, DBs are hard, DBAs are smart. But some basics can get you far, I'll walk you through some of the big ones.Some of this might be pretty obvious, but I've been seeing a lot of people saying obvious things are great things to write about :D

Network Requests

First and foremost, as an application dev you should know that network requests are our bane, you'll almost never do anything slower. Provided you're not on-prem, that DB... well, it ain't close, not even to your apps also running in the cloud. It's another network hop away, persistent connection be damned — at least it's probably in the same datacenter, probably. So we want to interact with our DB in as few requests as possible. Most DBs will have an option to return the values from the row you just updated or inserted. If not, do an upsert pattern bundled with a SELECT. I can't explain the ins-and-outs/hows-and-whys of every DB, but every DB will support you not making a bunch of separate requests to achieve your goals.

Indexes

Indexes, use them. If you're using a column in your query's predicate (the WHERE), you're gonna want an index on that column — most likely a composite index on the collection of columns. It'll be a cold day in hell the day you don't want an index on a predicate — obviously hyperbole, but very very often it's true. More specifically, indexes are more effective the greater the cardinality of a column. A boolean or bit has a cardinality of 2 (unless it's also nullable, then 3), so it can only really be separated into two "buckets". The reason you wouldn't want an index is because it impacts the speed of writing to your DB, but this is almost always so minuscule that it's not worth consideration until you're seeing issues. You shouldn't opt for the opposite, i.e. not indexing until you see issues. As always, profile, benchmark, analyze.

Your DBMS should also support something called covering indexes (usually specified by INCLUDEs). These are a bit more situational in my experience, but if you need to speed up reads and don't have a ton of writes going on, you can include the data your queries request in the index.

ORMs

ORMs are... okay. They're good for quick little queries on sprouting applications. You should make sure you're not pulling full "Entities" if you don't need all the data, every ORM should have an option to select specific columns. This subset of columns is called a "projection".

You should also always do your due diligence and view the exact queries your ORM is sending to the DB, they'll typically have an option to turn on query logging. ORMs can do surprisingly many queries in the background, for example: a findOne in TypeORM for SQL Server will do two separate SELECTs, a save will do a SELECT and then an INSERT or UPDATE.

The infamous N + 1 (or more aptly, 1 + N antipattern) is something much less achievable with your average SQL query and only really a mistake you'll make relying on an ORM, with something like fetchTables().forEach((table) => fetchTableLegs(table)).

Cartesian Explosions

Last big one is 💥💥CARTESIAN EXPLOSIONS💥💥. Generally speaking, joining tables in SQL or through your ORM is going to be faster than separate SELECT statements for related rows. So:

SELECT *
FROM tables
WHERE id = 3

SELECT *
FROM table_legs
WHERE table_id = 3


-- vs

SELECT * 
FROM tables t
INNER JOIN table_legs tl ON t.id = tl.table_id
WHERE t.id = 3

Assuming the table has four table legs, we'd end up returning 4 rows. Now that INNER JOIN actually doesn't have the same functionality as the above two queries would. If that table has no legs it unfortunately wouldn't be returned from the query. So instead, we'd likely want to use a LEFT JOIN, provided we still want to report the table exists and is tragically legless.

Let's say we also want to pull all coasters that sit on the table. This is where the dangers of a potential 💥💥CARTESIAN EXPLOSION💥💥 come in, e.g.:

SELECT * 
FROM tables t
LEFT JOIN table_legs tl ON t.id = tl.table_id
LEFT JOIN table_coasters tc ON t.id = tc.table_id
WHERE t.id = 3

Now assuming four legs and four coasters, we'd end up with 1 (T) X 4 (TL) X 4 (TC), 16 rows. And that's a 💥💥CARTESIAN EXPLOSION💥💥! If you're entering this territory, think about how "bounded" your relations are. Presumably the number of legs are bounded, I couldn't find a Guinness world record for the table with the most legs, but surely it's like 100. On the other hand we could stack coasters to the moon, so that value is "unbounded", we should probably break it out into a separate SELECT so our main query can continue to pull in table values.Yes, I realize using tables as my example is a teensy bit confusing when discussing databases...

Somewhat related, generally duplicating data onto rows like this isn't the end of the world, you sacrifice a little memory for speed. However, if you have a really heavy column, consider grabbing it separately.

Grab Bag (Dynamic Queries/TVPs/Conversions/Filtered Indexes)

Alright, lightning round.

Dynamic Queries

As mentioned above, a query plan is cached against the body of a query, the literal text of it. If you're conditionally adding different fields to SELECT on, JOINing additional tables, or modifying your predicate, you're generating new query plans. Your DBMS can likely support a small range of these, but if you start generating a lot of them, you will completely monopolize the database CPU.

TVPs

Leading in from above, if your query has a varying number of parameters in the predicate, e.g.:

SELECT *
FROM tables
WHERE id IN (@1, @2, @3)

-- and then 

SELECT *
FROM tables
WHERE id IN (@1, @2, @3, @4, @5)

Each query with N parameters will be a different query plan, TVPs can help you reuse the same query plan regardless of the number of parameters.

You'll need a type stored as part of your schema to allow passing in a TVP, e.g.:

CREATE TYPE dbo.IdList AS TABLE (id INT PRIMARY KEY)

Then consult your ORM or DB driver SDK documentation to see how to supply the TVP to a given (parameterized) query:

SELECT t.*
FROM tables t
INNER JOIN @TVP tvp ON tvp.id = t.id

-- or 

SELECT *
FROM tables t
WHERE t.id IN (SELECT tvp.id FROM @TVP tvp)

Multiple query plans aren't the end of the world, so this can be used as a last resort if you're seeing issues.

Type Conversions

One especially dangerous hiccup to call out when owning your queries is to make sure your inputs are correctly declaring themselves. Sometimes your ORM will incorrectly default types being passed to your parameterized queries, e.g.:

EXEC sp_executesql
N'SELECT 1 FROM my_table WHERE code = @code',
N'@code NVARCHAR(50)',
@code = N'SUPER_MEH_GUY'

If our code column is defined as a VARCHAR, NVARCHAR has type precedence, so you're going to get an implicit conversion for every row you check, converting each code to an NVARCHAR for comparison.

Filtered Indexes

Last little fun thing, filtered indexes. Deploying indexes can be an extremely heavy and time-consuming process, especially while the DB is under load. You can greatly reduce the amount of time it takes to build an index by narrowing the index to a subset of values. This is called a filtered index. Index creation is so time consuming largely due to actually writing the index, it's easy to check the values. If you don't need to query on every potential value of a column, consider a filtered index, e.g.:

CREATE NONCLUSTERED INDEX ix_tables_in_stock
ON tables (id)
WHERE in_stock = 1;

Great time to point out, to actually use this index you DO NOT want to parameterize the input. It is not variable, it does not need to be sanitized, you would hardcode it into the query:

SELECT *
FROM tables
WHERE in_stock = 1;

-- NOT

SELECT *
FROM tables
WHERE in_stock = @0;

The End

Okay, that's it. I figured if I was gonna rant a bit I should hand out some tools to get started. Hopefully, this is useful to you if you've felt the pain of managing sprocs. There are certainly uses for stored procedures, it should just be more of an exception than the ultimate form of supporting applications. Sometimes you'll have no choice for security reasons, that'll happen and it is what it is. But if you have the option, at the very least, please, don't fall for the "sprocs are more performant" line.

There's a lot more to learn; read query plans, profile, have fun, be safe!I'm re-aware of Jeff Atwood's post from 2004(!), I'm sure there are more, but it's still happening!


I posted this in r/programming if you're interested in seeing discussion around the post.

联系我们 contact @ memedata.com