Atomic multi-row update with a unique constraint

2019-02-06 14:08发布

问题:

I have a table of labels that are displayed in a ranked order. To ensure that no two rows can have the same rank, their values are unique:

create table label (
  id_label serial not null,
  rank integer not null,
  title text not null,
  constraint pri primary key (id_label),
  constraint unq unique (rank)
)

Doesn't matter if it's PostgreSQL or MySQL, they exhibit the same behaviour. A query might look like select title from label order by rank. Assume the table contains:

id_label rank title
1        10   Cow
2        20   Apple
3        45   Horse
4        60   Beer

Now suppose I want to reorder two labels, e.g. have Apple ranked before Cow. The easiest way is to swap their rank values:

update label
set rank = case when rank = 20 then 10 else 20 end
where id_label in (1,2)

Nope. Nor:

update label
set rank = case when rank = 20 then rank - 10 else rank + 10 end
where id_label in (1,2)

Nor even:

update label
set rank = 30 - rank
where id_label in (1,2)

Each time, the unique constraint fires on the first row update and aborts the operation. If I could defer the check until the end of the statement I would be fine. This happens on both PostgreSQL and MySQL.

An ACID-safe workaround is to:

  1. begin transaction
  2. select ranks of first, second record, and highest (max) rank in table (which offhand will probably require a union)
  3. update first record to rank = max + 1
  4. update second record to rank of first
  5. update first record to rank of second
  6. commit

That's just unspeakably ugly. Worse is to drop the constraint, update, and then recreate the constraint. Granting such privileges to an operational role is asking for trouble. So my question is this: is there a simple technique I have overlooked that solves this problem, or am I SOL?

回答1:

With PostgreSQL this can only be solved in a "nice" way using Version 9.0 because you can define unique constraints to be deferrable there.

With PostgreSQL 9.0 you'd simply do:

create table label (
  id_label serial not null,
  rank integer not null,
  title text not null,
  constraint pri primary key (id_label)
);
alter table label add constraint unique_rank unique (rank) 
      deferrable initially immediate;

Then the update is as simple as this:

begin;
set constraints unique_rank DEFERRED;
update rank
   set rank = case when rank = 20 then 10 else 20 end
   where id_label in (1,2);
commit;

Edit:
If you don't want to bother setting the constraint to deferred inside your transaction, you can simply define the constraint as initially deferred.



回答2:

Of course you can just:

update label set rank = 5 where id_label=2

but the problem here I guess is you need to be able to handle the case when there is no 'gap' between consecutive ranks. For postgres, using numeric instead of integer gets round the problem because it has almost unlimited precision

create table label (
  id_label serial not null,
  rank numeric not null,
  title text not null,
  constraint pri primary key (id_label),
  constraint unq unique (rank)
)

now you need to update just one row to move it anywhere in the ranking, no matter what the ranks of any other row, by splitting the difference between the rank above and the one below.



回答3:

I had a similar problem and my solution was the following:

  1. START TRANSACTION
  2. SELECT * FROM label WHERE id_label IN(1,2)
  3. Delete FROM label WHERE id_label IN(1,2)
  4. INSERT INTO label(all, columns, of, table) VALUES(all, values, we, selected)
  5. COMMIT TRANSACTION

If any errors, rollback transaction.

You can do this without dropping the unique constraint.