THE SQL Server Blog Spot on the Web
Welcome to SQLblog.com - The SQL Server blog spot on the web Sign in | Join | Help
in Search

Alexander Kuznetsov

  • Try out sqltrace

    SQL Server MVP Erland Sommarskog is hosting a new utility on his web site. Apparently it is very useful.
    Enjoy!



  • Defensive database programming: eliminating IF statements.

    The following pattern is quite common in database programming:

    IF EXISTS(some query) BEGIN

      DO SOMETHING;

    END

    When such code runs in high concurrency situations, it may not work as expected. I will provide a repro when such logic fails 40% of the time. The following script provides a test table and attempts to implement optimistic locking using rowversion columns:

     

    CREATE TABLE dbo.TwoINTs(ID INT NOT NULL, i1 INT NOT NULL, i2 INT NOT NULL, version ROWVERSION)

    GO

    SET NOCOUNT ON;

    DECLARE @i INT;

    SET @i=0;

    WHILE @i<1000000 BEGIN

    INSERT INTO dbo.TwoINTs(ID, i1, i2) VALUES(@i,0,0);

    SET @i = @i+1;

    END

    GO

    CREATE UNIQUE INDEX UNQ_TwoInts_ID ON dbo.TwoInts(ID);

    GO

     

    CREATE PROCEDURE dbo.UpdateTwoINTs(@ID INT, @i1 INT, @i2 INT, @version ROWVERSION)

    AS

    BEGIN

          SET NOCOUNT ON;

          SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

          DECLARE @ret INT;

          BEGIN TRANSACTION

          IF EXISTS(SELECT 1 FROM dbo.TwoINTs WHERE ID=@ID AND version = @version) BEGIN

                UPDATE dbo.TwoINTs SET i1=@i1, i2=@i2 WHERE ID=@ID AND version = @version;

                SET @ret=0;

          END ELSE BEGIN

                SET @ret=1;

          END;

          COMMIT

          RETURN @ret;

    END

    GO

    A naïve test not involving high concurrency succeeds all right. You can run a naïve test yourself. In one tab, run the following code, which mimics a user who read a row and modified column i1 in 10 seconds:

     

    DECLARE @i1 INT, @i2 INT, @version ROWVERSION, @count INT, @ret INT;

    SELECT @i1=i1, @i2=i2+1, @version=version FROM dbo.TwoInts WHERE ID=5;

    WAITFOR DELAY '00:00:10'

    EXEC @ret=dbo.UpdateTwoINTs 5, @i1, @i2, @version;

    SELECT @ret AS ret, i1, i2 FROM dbo.TwoInts WHERE ID=5;

     

    Immediately start another script, which mimics a user who modified another column, i2, also after 10 seconds of delay:

     

    DECLARE @i1 INT, @i2 INT, @version ROWVERSION, @count INT, @ret INT;

    SELECT @i1=i1+1, @i2=i2, @version=version FROM dbo.TwoInts WHERE ID=5;

    WAITFOR DELAY '00:00:10'

    EXEC @ret=dbo.UpdateTwoINTs 5, @i1, @i2, @version;

    SELECT @ret AS ret, i1, i2 FROM dbo.TwoInts WHERE ID=5;

     

    Note that only one of two updates succeeded, which is expected – the procedure detected a rowversion mismatch and did not overwrite the first update. However, a more realistic test on my laptop fails 40% of the time. First of all, some cleanup:

     

    UPDATE dbo.TwoINTs SET i1=0, i2=0 WHERE ID=5;

     

     Here is the script to run in one tab:

     

    DECLARE @i1 INT, @i2 INT, @version ROWVERSION, @count INT, @ret INT;

    SET @count = 0;

    WHILE @count<100000 BEGIN

          SELECT @i1=i1+1, @i2=i2, @version=version FROM dbo.TwoInts WHERE ID=5;

        EXEC @ret=dbo.UpdateTwoINTs 5, @i1, @i2, @version;

          SET @count = @count + 1 - @ret;

    END;

     

    Here is the script to run in another tab:

     

    DECLARE @i1 INT, @i2 INT, @version ROWVERSION, @count INT, @ret INT;

    SET @count = 0;

    WHILE @count<100000 BEGIN

          SELECT @i1=i1, @i2=i2+1, @version=version FROM dbo.TwoInts WHERE ID=5;

        EXEC @ret=dbo.UpdateTwoINTs 5, @i1, @i2, @version;

          SET @count = @count + 1 - @ret;

    END;

     

     If no updates were lost, I would expect the values in both i1 and i2 to be 100000. Unfortunately, this is not the case:

     

    SELECT i1, i2 FROM dbo.TwoINTs  WHERE ID=5

    57871    54473

     

    As you have seen, more than 40% updates were lost. There are several ways to fix the problem. Of course, you can just up the isolation level to REPEATABLE READ or even to SERIALIZABLE. Unfortunately, under higher isolation levels code like this is very prone to deadlocks. You can alter the stored procedure, rerun the loops, and see for yourself. There is a much simpler and more robust way to implement optimistic locking. Let me clean up the data first and improve the procedure:

     

    UPDATE dbo.TwoINTs SET i1=0, i2=0 WHERE ID=5;

    GO

    ALTER PROCEDURE dbo.UpdateTwoINTs(@ID INT, @i1 INT, @i2 INT, @version ROWVERSION)

    AS

    BEGIN

          SET NOCOUNT ON;

          SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

          DECLARE @ret INT;

          UPDATE dbo.TwoINTs SET i1=@i1, i2=@i2 WHERE ID=@ID AND version = @version;

          SET @ret= 1 - @@ROWCOUNT;

          RETURN @ret;

    END

     

    When you rerun those two scripts with loops from 1 to 100000 again, you can see that this time not a single update was lost:

     

    SELECT i1, i2 FROM dbo.TwoINTs  WHERE ID=5

    100000  100000

    Note that you did not get any deadlocks at all.

    As you have seen, the following pattern is very prone to errors in high concurrency OLTP environments:

     

          IF EXISTS(SELECT 1 FROM dbo.SomeTable WHERE <Some Conditions>) BEGIN

                DO Something;

          END ELSE BEGIN

                DO Something else;

          END;

     

     

    If you use this pattern in your procedures and expect to run them in high concurrency OLTP environments, you need to properly stress test your procedures. While it is OK to just run scripts from SSMS tabs to demonstrate the problem, as I did in this post, I think that in real life there are more convenient ways to stress test. For instance, I was describing a C# test harness

    here.

     

    This post continues my series on defensive database programming. Here are the previous posts  from the series:

     

    Defensive database programming: fun with changing column widths. Avoid mixing old and new styles of error handling. Defensive database programming: adding ESCAPE clauses. Defensive database programming: qualifying column names. Defensive database programming: rewriting queries with NOT IN().

     


  • Defensive database programming: fun with changing column widths.

    In Transact SQL you can assign a 10-character value to a VARCHAR(5) variable, as follows:

     

    DECLARE @c VARCHAR(5); SET @c='1234567890'; PRINT @c

    12345

     

    The value will be silently truncated without raising an error. Because of this behavior it may be very easy to make mistakes. For example, consider the following table and stored procedure:

     

    CREATE TABLE Data.Codes(

          Code VARCHAR(5),

          Description VARCHAR(40)

    );

    GO

    CREATE PROCEDURE Readers.SelectCodes

      @Code VARCHAR(5)

    AS

    BEGIN

      SELECT Description FROM Data.Codes WHERE Code = @Code

    END

    GO

     

    Although the procedure works correctly, it is very easy to render it incorrect by changing the table it selects from. Suppose that you have to increase the width of Code column, as follows:

     

    ALTER TABLE Data.Codes ALTER COLUMN Code VARCHAR(10);

    GO

     

    If you have not adjusted the width of the corresponding parameter accordingly, then you have just introduced a new bug into your system. The procedure would fail to retrieve some rows. You can see for yourself:

     

    INSERT INTO Data.Codes VALUES('1234567890', 'Tets data for boundary case');

    EXEC Readers.SelectCodes @Code = '1234567890';

     

    It easy to demonstrate that the procedure has failed to retrieve because your parameter has been silently truncated to 12345:

     

    ALTER PROCEDURE Readers.SelectCodes

      @Code VARCHAR(5)

    AS

    BEGIN

      SELECT @Code AS PassedValue

      SELECT Description FROM Data.Codes WHERE Code = @Code

    END

    GO

    EXEC Readers.SelectCodes @Code = '1234567890'

     

    12345

     

    There are several ways to limit your exposure to such problems. For instance, Oracle’s PL/SQL has built in %TYPE syntax which guarantees that the parameter’s type matches the type of the corresponding column, and apparently that feature is very popular with Oracle developers. Alternatively, some developers create their own types, but this approach seems to be unpopular.

     

    Also there are a couple of suggestions on Connect requesting similar functionality in SQL Server.

     

    Also to avoid such problems, SQL Server MVP Simon Sabin recommends using LINQ.


    Anyway, if you want to improve the robustness of an existing system without a major overhaul, then you are stuck with your VARCHAR columns, because neither of these approaches would work for your existing tables and procedures.

    This is one of those cases where boundary values testing proves to be very useful. Boundary values tests are supposed to verify that the procedure handles all the extreme cases, including the case when the code is of maximum allowed width. At the time when you are developing your stored procedure, create a boundary value test, as follows:

     

            [Test]

            private void SelectCodesMaxWidthTest()

            {

                string maxWidthCode = "12345";

                //verify that maxWidthCode is indeed a boundary value

                Assert.AreEqual(maxWidthCode.Length, GetColumnWidth("Data", "Codes", "Code"));

                //execute the procedure and check its results

     

     

    That done, when you change the Code column’s width, the test will fail and remind you to change the procedure too.


  • Avoid mixing old and new styles of error handling.

    Stored procedures using old-style error handling and savepoints may not work as intended when they are used together with TRY … CATCH blocks. I will provide some examples. This post continues the series on defensive database programming.

     

    Avoid calling old-style stored procedures from TRY blocks.

     

    Stored procedures using old-style error handling may not work as expected when invoked from TRY blocks. Consider the following procedure:

     

    CREATE PROCEDURE dbo.GetRatio

      @n1 FLOAT,

      @n2 FLOAT

    AS

    BEGIN

      SET XACT_ABORT OFF

      DECLARE @ErrorCode INT

      SELECT @n1/@n2 AS Ratio

      SELECT @ErrorCode = @@ERROR

      PRINT 'Execution here'

      IF @ErrorCode <> 0 BEGIN

        SELECT @ErrorCode AS ErrorCode

      END

    END

     

    Clearly sometimes it handles errors as originally intended:

     

    EXEC dbo.GetRatio 5, 0

     

    Ratio

    ----------------------

    Msg 8134, Level 16, State 1, Procedure GetRatio, Line 8

    Divide by zero error encountered.

     

    Execution here

    ErrorCode

    -----------

    8134

     

     

    However, inside a TRY block the same procedure works differently:

     

    BEGIN TRY

      EXEC dbo.GetRatio 5, 0

    END TRY

    BEGIN CATCH

      SELECT    ERROR_NUMBER() AS ERROR_NUMBER,

                ERROR_MESSAGE() AS ERROR_MESSAGE;

    END CATCH

     

    Ratio

    ----------------------

     

    (0 row(s) affected)

     

    ERROR_NUMBER ERROR_MESSAGE

    ------------ --------------------------------

    8134         Divide by zero error encountered.

     

    Note that this time the execution breaks before it reaches the PRINT command.

     

    Avoid using savepoints with TRY … CATCH blocks.

     

    The reason is simple: sometimes when the execution reaches your CATCH block, the transaction may be doomed. A doomed transaction cannot be rolled back to a savepoint, it can only be rolled back completely. For example, consider the following table and procedure:

     

    CREATE TABLE [data].[Codes](

          [Code] [varchar](10) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,

          [Description] [varchar](40) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,

        CONSTRAINT PK_Codes PRIMARY KEY(Code)

    ) ON [PRIMARY]

     

    GO

    CREATE PROCEDURE Writers.InsertCode

      @Code VARCHAR(10),

      @Description  VARCHAR(40)

    AS

    BEGIN

    DECLARE @ret INT, @InTransaction INT;

    SET @InTransaction = @@TRANCOUNT;

    BEGIN TRY;

      IF @InTransaction = 0 BEGIN

        BEGIN TRAN InsertCode;

      END ELSE BEGIN

        SAVE TRAN InsertCode;

      END;

      INSERT INTO [data].[Codes]

               ([Code],[Description])

         VALUES(@Code, @Description);

      COMMIT;

      SET @ret = 0;

    END TRY

    BEGIN CATCH

      ROLLBACK TRAN InsertCode;

      SET @ret = ERROR_NUMBER();

      SELECT    @ret AS ERROR_NUMBER,

                ERROR_MESSAGE() AS ERROR_MESSAGE;

    END CATCH

    RETURN @ret;

    END

    GO

    CREATE TRIGGER NoCodesWithBackslash ON [data].[Codes]

    FOR INSERT

    AS

    BEGIN

    IF EXISTS(SELECT 1 FROM inserted WHERE Code LIKE '%\%') BEGIN

      RAISERROR('Code cannot contain backslash', 16, 1);

    END

    END

     

    Try to invoke it in the middle of an outstanding transaction:

     

    DECLARE @ret INT;

    BEGIN TRAN

    INSERT INTO [data].[Codes]

               ([Code]

               ,[Description])

         VALUES

               ('Code1'

               ,'Description1')

     

    EXEC @ret = Writers.InsertCode

      @Code = 'Code\1',

      @Description = 'Description1'

    SELECT @ret AS Ret

     

    Msg 3931, Level 16, State 1, Procedure InsertCode, Line 21

    The current transaction cannot be committed and cannot be rolled back to a savepoint. Roll back the entire transaction.

     

    As you have seen, you cannot roll back a doomed transaction to a savepoint.

    Note: not every error renders your transaction doomed. The following script also generates an error, but the error handling works, because the transaction is not doomed:

     

    DECLARE @ret INT;

    BEGIN TRAN

    SELECT @@TRANCOUNT AS TRANCOUNT, TRANSACTION_ID FROM SYS.DM_TRAN_CURRENT_TRANSACTION

    INSERT INTO [data].[Codes]

               ([Code]

               ,[Description])

         VALUES

               ('Code1'

               ,'Description1')

     

    EXEC @ret = Writers.InsertCode

      @Code = 'Code1',

      @Description = 'Description1'

    SELECT @ret AS Ret

    SELECT * FROM [data].[Codes]

    COMMIT

     

    TRANCOUNT   TRANSACTION_ID

    ----------- --------------------

    1           32583

     

    ERROR_NUMBER ERROR_MESSAGE

    ------------ ---------

    2627         Violation of PRIMARY KEY constraint 'PK_Codes'. Cannot insert duplicate key in object 'data.Codes'.

     

    Ret

    -----------

    2627

     

    Code       Description

    ---------- ----------------------------------------

    Code1      Description1

     

    Clearly the first insert was not rolled back, so the error handling worked as expected.

    As you have seen, if you are testing how your TRY..CATCH block handles errors, you must include cases when your transaction is rendered doomed.


  • Defensive database programming: adding ESCAPE clauses.

    In most cases LIKE conditions should by followed by ESCAPE clauses. Let me give you an example. Consider the following table, sample data, and stored procedure:

     

    CREATE TABLE Data.Messages(Subject VARCHAR(30), Body VARCHAR(100));

    GO

    INSERT INTO Data.Messages(Subject, Body)

    SELECT 'Next release delayed', 'Still fixing bugs' UNION ALL

    SELECT 'New printer arrived', 'By the kitchen area'

    GO

    CREATE PROCEDURE Readers.SelectMessagesBySubjectPattern

      @SubjectPattern VARCHAR(100)

    AS

    SELECT Subject, Body

      FROM Data.Messages

      WHERE Subject LIKE @SubjectPattern

    GO

     

     Apparently the procedure works, does it not:

     

    EXEC Readers.SelectMessagesBySubjectPattern '%printer%'

    GO

     

    Let me add some more data:

     

    INSERT INTO Data.Messages(Subject, Body)

    SELECT '[OT] Great vacation in Norway!', 'Pictures already uploaded to our Picasa album' UNION ALL

    SELECT '[OT] Great new camera', 'Used it on my vacation, the pictures are great!'

    GO

     

    The following call returns nothing:

     

    EXEC Readers.SelectMessagesBySubjectPattern '[OT] Great%'