使用 Oracle 和 PHP 执行事务
作者:Harry Fuecks
如何使用 Oracle 数据库的强大功能可靠地管理多对多关系。
2005 年 11 月发表
通过事务,您可以可靠、一致地更改数据库,在应用程序必须管理多对多关系或以批处理形式执行多个查询的情况下,事务可起到至关重要的作用。
本“Oracle+PHP 指南”方法文档介绍了事务的必要性以及如何通过 PHP 的 OCI 扩展使用 Oracle 的事务服务,同时还着重介绍了某些潜在的“问题”。
事务的必要性
假设您维护一个使用 Oracle 存储条目的 web 记录应用程序。数据库模式的某个部分为“blogs”定义了一个表,并为“categories”定义了另一个表,后者通过表“blogs_to_categories”与“blogs”保持多对多关系。以下查询描述了这些表:
CREATE SEQUENCE blog_id_seq
INCREMENT BY 1;
/
CREATE TABLE blogs (
id NUMBER PRIMARY KEY,
title VARCHAR2(200),
date_published DATE,
text CLOB
);
/
CREATE SEQUENCE category_id_seq
INCREMENT BY 1;
/
CREATE TABLE categories (
id NUMBER PRIMARY KEY,
name VARCHAR2(30) UNIQUE
);
/
CREATE TABLE blogs_to_categories (
blog_id INTEGER NOT NULL
REFERENCES blogs(id),
category_id INTEGER NOT NULL
REFERENCES categories(id),
PRIMARY KEY (blog_id, category_id)
);
/
对于使用网志的用户而言,要发布网志条目,他们可以将其分配给“php”、“programming”和“web”这三个类别。同时,阅读网志的用户可以选择按类别(不是默认的“按日期”)查看条目。
现在,如果网志作者要更新分配给现有条目的类别(可能是将该条目添加到“oracle”类别,同时在太具普遍性的“web”类别中将该条目删除),则需要从 blogs_to_categories 表中删除并插入行。
就 SQL 而言,这可能类似如下所示:
-- Delete all existing category links to this blog entry
DELETE FROM blogs_to_categories WHERE blog_id = 5
-- Insert a single link between a blog and a category
INSERT INTO blogs_to_categories
(blog_id, category_id)
VALUES
(5, 2)
此插入查询将针对网志条目归档到的每个类别重复一次。
但在执行删除查询后,如果 web 服务器与数据库之间发生故障,从而导致无法插入时,该怎么办?这种情况下,您将得到一个无类别的网志条目。
为避免该问题的发生,需要以事务的形式执行语句,指示 Oracle 将多个语句作为单个实体进行处理。
ACID 事务
对于数据库,术语 ACID 定义数据库正确处理事务所必须拥有的属性。这些属性大致说明如下:
原子性 - 事务要么必须彻底完成,要么根本不完成;不允许部分完成事务。
一致性 - 必须始终遵守数据库约束。
孤立性 - 多个事务不应影响彼此的数据。例如,如果事务 B 正在插入到 blogs_to_categories 表中,则不允许事务 A 删除类别。
持久性 - 如果事务已经完成(提交),则不会丢失数据(可以从故障中恢复)。
以下是有关术语的说明: 提交事务意味着完成事务,并应用事务对数据进行的任何更改。 回滚事务意味着结束事务并撤消它所做的任何更改,以便数据库返回到它在事务开始前所处的状态。也就是说,在出现错误时回滚事务,而在成功时提交事务。
通过使用事务,可以放心地执行多个查询(因为您知道因系统故障而丢失数据这样的风险是不存在的),同时解决并发性方面的问题。尤其对于并发访问情形,Oracle 将允许其他用户“读取”事务正在写入的行。执行 read 语句的用户看到的数据将保持不变:也就是说,尽管在事务内部已经执行了任何 INSERT 或 UPDATE 语句,但在提交事务并应用更改之前,用户一直会“看到”原始数据。换言之,写入方不阻止读取方,从而避免了性能瓶颈。
使用 PHP 的 Oracle 事务
就事务而言,用于 Oracle 的 PHP 扩展有两个行为模式。默认的“自动提交”模式在每个语句执行后(即在每次调用 oci_execute() 函数之后)自动执行提交。
“提交模式”由传递给 OCIExecute() 的第二个可选参数控制,该参数是常量并且可以为 OCI_COMMIT_ON_SUCCESS(默认的自动提交行为)或 OCI_DEFAULT(指示 PHP 等待对 oci_commit() 或 oci_rollback() 的显式调用)。
在下面这个简单示例中,尝试向 categories 表中添加一些数据,并了解一下在您违反该操作的约束时将发生什么情况:
$conn = oci_connect('SCOTT','TIGER') or die;
// Some categories
$categories = array(
'php',
'oracle',
'programming',
'web',
'this string is too long for the name column'
);
$sql = "INSERT INTO categories
(id, name)
VALUES
(category_id_seq.nextval,:category)
";
$stmt = oci_parse($conn,$sql);
foreach ( $categories as $category ) {
oci_bind_by_name($stmt,':category', $category,30);
// Note the OCI_DEFAULT parameter
if ( !oci_execute($stmt,OCI_DEFAULT) ) {
// If we have a problem, rollback then die
oci_rollback($conn);
die;
}
}
// If we got this far, it's OK to commit
oci_commit($conn);// etc.
?>
就其当前格式而言,该示例将失败,因为它尝试插入的最后一个类别违反了名称列限 30 字符的约束。您将看到一个类似如下的错误:
Warning: oci_execute() [
function.oci_execute]:OCIStmtExecute:
ORA-01461:can bind a LONG value only for insert into a LONG
column in
/home/harry/public_html/create_categories.php on
line
25
如果现在尝试选择 categories 表的内容,则会看到并未插入任何类别。尝试删除以上脚本中传递给 oci_execute() 的参数 OCI_DEFAULT 将发生什么情况?删除过长的类别名称并重新运行该脚本将插入记录。
注意,每当调用 oci_execute()(从事务开始的位置一直到显式提交或回滚的位置)时,请确保传递 OCI_DEFAULT 常量。否则, oci_execute() 将使用它的默认行为,即并非只提交该单个查询,还将提交位于它之前的所有查询(起始于事务的开始位置)。此处一个重要的例外情况是,无论您传递给 oci_execute() 的常量是什么,DDL 语句(如那些以 CREATE、 DROP 和 ALTER 开头的语句)将 始终自动进行提交,
还应注意,当 PHP 脚本终止执行时,Oracle 将自动回滚任何未完成的事务。有了默认的自动提交行为,通常不会使用该方法,但如果脚本达到内存或时间限制突然终止时,则未完成事务将回滚。这适用于临时连接和持久连接。
以下示例演示了在执行任务过程中,因查询时不小心而可能出现的常见问题。以下脚本使用可表示 HTML 表单的 POST 的示例数据执行三个语句。首先,它向 blogs 表中插入一行,然后获得该表中的行数以反馈给用户。在执行 count 查询时,它未能将 OCI_DEFAULT 传递给 oci_execute()。最后,它尝试向 blogs_to_categories 表中插入两个类别 ID 以便与网志条目建立关联。由于 categories 表中不存在某个类别 ID,因此 Oracle 将引发一个有关违反引用约束的错误。
$conn = oci_connect('SCOTT','TIGER') or die;
/* Start by inserting a blog entry */
// An array representing a submitted blog entry
$blog = array(
'id'=>NULL,
'title'=>'Hello World!',
'text'=>'This is my first blog entry',
'categories' => array(1,999) // Note second ID does not exist
);
$sql = "INSERT INTO blogs
(id,title,date_published,text)
VALUES
(blog_id_seq.nextval,:title,SYSDATE,:text)
RETURNING
id INTO :id ";
$stmt = oci_parse($conn,$sql);
oci_bind_by_name($stmt,':title', $blog['title'],200);
oci_bind_by_name($stmt,':id', $blog['id'],32);
oci_bind_by_name($stmt,':text', $blog['text']);
// Note OCI_DEFAULT - begin a transaction
if ( !oci_execute($stmt,OCI_DEFAULT) ) {
oci_rollback($conn);
exit(1);
}
/* Now count the total number of blog entries */
$sql = "SELECT COUNT(*) AS num_entries FROM blogs";
$stmt = oci_parse($conn,$sql);
// Automatically populates the $num_entries variable
oci_define_by_name($stmt,"NUM_ENTRIES",$num_entries);
// Whoops forgot the OCI_DEFAULT!
oci_execute($stmt);
oci_fetch($stmt);
$result = "Blog added.There are now $num_entries blogs\n";
/* Now insert the categories related to the entry */
$sql = "INSERT INTO blogs_to_categories
(blog_id, category_id)
VALUES
(:blog_id, :category_id)";
$stmt = oci_parse($conn,$sql);
// Loop through the categories
foreach ( $blog['categories'] as $category_id ) {
oci_bind_by_name($stmt,':blog_id', $blog['id'],32);
oci_bind_by_name($stmt,':category_id', $category_id,32);
// Note OCI_DEFAULT again
if ( !oci_execute($stmt,OCI_DEFAULT) ) {
oci_rollback($conn);
exit(1);
}
}
oci_commit($conn);
echo $result
?>
在执行 SELECT 语句时,为计算 blog 表中的行数,该脚本指示 Oracle 自动提交。这样, SELECT 和前一个 INSERT(用于启动事务)将立刻完成。对于第三个语句,它启动一个全新事务并在遇到约束违规时回滚插入的类别 ID。因此,尽管插入了网志条目,但却丢失了它的类别。应将这三个查询作为单个工作单元执行;在遇到约束违规时,所有插入均回滚并且用户指示他们需要更正提供的类别并再次提交该表单。
要正确处理事务,应将 OCI_DEFAULT 常量传递给所有三个 oci_execute() 调用。下面的更改解决了此问题:
事务孤立和连接
当事务开始执行时,Oracle 获得数据库的部分“快照”(称作 回滚段),以便其他进程在事务执行期间可以获得一个一致的数据库视图。这意味着执行事务的进程与在该事务外部运行的其他进程将看到不同的数据库状态。在很少的情况下,您可能希望在执行事务的同时访问“外部状态”中的数据。为此,您需要使用 oci_new_connect() 函数,该函数可以确保您建立一个新的数据库连接,而对 oci_connect() 的重复调用将全部返回相同的数据库连接资源。通过新连接执行的语句将成为在已经建立的连接中运行的孤立事务。
以下脚本对此进行了演示:
$conn = oci_connect("SCOTT", "TIGER") or die;
$categories = array(
'transactions',
'sessions',
);
$sql = "INSERT INTO categories
(id, name)
VALUES
(category_id_seq.nextval,:category)
";
$stmt = oci_parse($conn,$sql);
foreach ( $categories as $category ) {
oci_bind_by_name($stmt,':category', $category,30);
if ( !oci_execute($stmt,OCI_DEFAULT) ) {
oci_rollback($conn);
die();
}
}
// Establish a new, temporary Oracle connection
$newSess = oci_new_connect("SCOTT", "TIGER") or die;
$sql = "SELECT COUNT(*) AS num_cats FROM categories";
$stmt = oci_parse($newSess,$sql);
oci_define_by_name($stmt,"NUM_CATS",$oldnum_cats);
// No need for OCI_DEFAULT here...
oci_execute($stmt);
oci_fetch($stmt);
// Logoff the session (technically not needed as it will be cleaned up anyway)
oci_close($newSess);
// Do the same for the primary session
$stmt = oci_parse($conn,$sql);
oci_define_by_name($stmt,"NUM_CATS",$newnum_cats);
// Here we do need OCI_DEFAULT
oci_execute($stmt,OCI_DEFAULT);
oci_fetch($stmt);
// If we got this far, it's OK to commit
oci_commit($conn);
echo "Before I had $oldnum_cats.Now I have $newnum_cats\n";
?>
变量 $oldnum_cats 的值尽管是在事务启动后获取的,但却包含事务启动前 categories 表中的行数。这是因为我使用 OCINLogon() 创建的会话独立于正在其中运行事务的主会话,它具有不同的事务上下文。
结论
如果您以前并不了解事务的必要性,那么通过本文的学习您现在应该对此有所了解,即事务使您可以可靠、一致地更改数据库。您还了解了如何通过 PHP 的 OCI 扩展执行事务(注意它的默认自动提交行为存在的潜在问题)以及如何在单独的事务上下文中执行查询。
Harry Fuecks [ http://www.phppatterns.com] 于 1999 年接触 PHP,此后作为 PHP 开发人员和馔稿人而声名鹊起。他通过 Sitepoint 开发人员网络发布了大量初级和中级 PHP 文章,著有 The PHP Anthology (SitePoint) 一书。