(495) 925-0049, ITShop интернет-магазин 229-0436, Учебный Центр 925-0049
  Главная страница Карта сайта Контакты
Поиск
Вход
Регистрация
Рассылки сайта
 
 
 
 
 

Динамическое пересоздание глобальных временных таблиц

Введение

Иногда приходиться решать задачи управления таблицами с отличной от классических канонов структурой. По ряду причин, такие таблицы растут как вниз (добавление строк данных), так и вправо (добавление столбцов). Причиной может быть исторически принятая, унаследованная структура данных (например, приложение, портированное в СУБД Oracle без перепроектирования структур), или проблемы производительности на этапе сложного расчета.

Например, базовые данные находятся в правильном, нормализованном представлении, а на начальном этапе многоступенчатого расчета производиться "динамическое распрямление вправо" по слабо изменяемому признаку и заполнение заранее созданных глобальных временных таблиц (Global Temporary Table - GTT). Результаты расчета могут быть затем агрегированы или "схлопнуты" по какому-либо признаку.

Это может быть актуально, когда объем данных в одном сеансе расчета очень значителен (сотни тысяч или миллионы строк). Как правило, это расчетные задачи OLAP (DSS), например, расчет времени и скорости продаж товаров по всей сети магазинов, прогноз товарного запаса, расчет материального баланса. В результате такого представления GTT, "высота" таблицы сокращается пропорционально количеству магазинов сети (слабо изменяемому признаку), например в 50 раз, с 30 млн. до 600 тыс. строк для каждого типа данных (остатки, продажи и т.д.).

Я не стану давать оценок таким структурам с точки зрения классического проектирования, скажу лишь, что они используются и, на этапе расчета, могут давать значительный выигрыш в производительности. Тем более, если динамика роста вправо приемлема (ограничение в Oracle 9i - 1000 столбцов). Реализация такого расчетного механизма возможна, например, с применением динамического SQL и наборов (collections).

Итак, каждый сеанс использует для расчетов некоторый набор PL/SQL-пакетов, хранит данные своего расчета в наборе временных таблиц (например, уровня сеанса, ON COMMIT PRESERVE ROWS).

Следовательно, мы сталкиваемся с проблемой пересоздания временных таблиц после добавления или удаления слабо изменяющегося признака (магазина). Пересоздание возможно как сразу (в оnline), так и отложенно (с помощью задания, выполняемого по определенному графику, например, ночью, при минимальной нагрузке на сервер).

Алгоритм действий

  1. Получение списка блокирующих сеансов. Тип блокировок - 'TO'1 для GTT.
  2. Оповещение с помощью DBMS_ALERT всех "думающих" сеансов и повторный запрос списка блокирующих сеансов.
  3. Принудительное завершение работы ("убивание") блокирующих сеансов.
  4. Проверка и "убивание" зависших KILLED-сеансов для текущего ORACLE_SID. При этом используется технология выполнения комманд ОС из PL/SQL: надо "убить" процесс или нить зависшего сеанса (варианты для UNIX- и для Windows-платформы, притом непосредственно из PL/SQL, без использования внешних заданий и планировщиков).
  5. Пересоздание всех нужных временных таблиц и индексов.
  6. Перекомпиляция всех или выбранных INVALID-объектов.

1. Кстати, что-то я не обнаружил в документации описания этого типа блокировки. А ведь, как легко убедиться, именно такие блокировки устанавливаются, когда сеанс вставил какие-то данные в глобальную временную таблицу уровня сеанса... - Примечание В.К.

Полный текст пакета представлен в Приложении 2, я же остановлюсь на необходимых деталях и дополнениях.

Тонкости реализации

1. Получение списка блокирующих сеансов и блокировки GTT

При первой вставке в глобальную временную таблицу сервер Oracle устанавливает на нее блокировку 'TO', которая удерживается при любых дальнейших изменениях, до наступления одного из двух условий:

  • завершение сеанса или фиксация транзакции (в зависимости от типа GTT, указанного при создании: ON COMMIT PRESERVE ROWS или ON COMMIT DELETE ROWS);
  • выполнение оператора TRUNCATE TABLE.

Таким образом, чтобы найти блокирующий сеанс, необходимо искать блокировки любой пересоздаваемой глобальной временной таблицы, участвующей в расчетах (в нашем примере - ZZZ_TEST).

Установки статуса INVALID для создаваемого пакета TM_UTIL (и дальнейшую перекомпиляцию самого себя) можно избежать, используя динамический SQL в теле пакета.

В результате, имеем два фильтра для поиска блокирующего сеанса (тип блокировки и имя GTT). В качестве параметров дальнейшей команды ALTER SYSTEM KILL SESSION подаем полученные идентификаторы сеанса, SID и SERIAL#.

      -- Курсор наличия блокирующих сеансов
      CURSOR SESS_bl_cur
      IS
        SELECT distinct VS.SID, VS.SERIAL#
        FROM V$SESSION VS, V$LOCK VL, DBA_OBJECTS OBJ
        WHERE OBJ.OBJECT_NAME = 'ZZZ_TEST' AND
        VL.TYPE='TO' AND
        VL.ID1=OBJ.OBJECT_ID AND
        VL.SID=VS.SID;

      -- Запись для выборки из курсора 
      sess_bl_rec  SESS_bl_cur%ROWTYPE;

Чтобы пакет смог успешно обратиться к соответствующим представлениям словаря данных, необходимо выдать пользователю-создателю пакета следующие привилегии:

   GRANT SELECT ON  SYS."V_$SESSION" TO "SHOPS_MOD"
   GRANT SELECT ON  SYS."V_$LOCK" TO "SHOPS_MOD"
   GRANT SELECT ON  SYS."DBA_OBJECTS" TO "SHOPS_MOD"

Потребуются также (см. далее) привилегии:

   GRANT SELECT ON  SYS."V_$PROCESS" TO "SHOPS_MOD"
   GRANT SELECT ON  SYS."V_$INSTANCE" TO "SHOPS_MOD"

Впрочем, если безопасность и принцип минимальности привилегий вас не беспокоят, достаточно будет пары операторов:

   GRANT SELECT ANY TABLE TO "SHOPS_MOD";
   GRANT SELECT ANY DICTIONARY TO "SHOPS_MOD";

2. Оповещение с помощью DBMS_ALERT "думающих" сеансов

Сигнал (Alert) создается и контролируется на считающем клиенте, а имя сигнала либо жестко "зашивается" в пакете, либо может ему передаваться во входных параметрах. Отправка сигнала реализуется с помощью автономной транзакции, дабы преждевременно не фиксировать основную транзакцию.

    PROCEDURE Alert_msg
    (a_alert_name in VARCHAR2, a_alert_msg IN VARCHAR2)
    AS
    PRAGMA AUTONOMOUS_TRANSACTION;
    Begin
      -- Пошлем сообщение всем, кто зарегистрировался для получения нашего сигнала
      DBMS_ALERT.signal(a_alert_name, a_alert_msg);	        
      COMMIT;
    end Alert_msg;

3. Прекращение работы блокирующих сеансов

Реализуем стандартным методом:

   ALTER SYSTEM KILL SESSION 'int1, int2'
   

где параметры - полученные ранее SID и SERIAL#.

Более мягкий вариант:

   ALTER SYSTEM DISCONNECT SESSION 'int1 , int2' POST_TRANSACTION  IMMEDIATE

4. Проверка и "убивание" зависших KILLED-сеансов для текущего ORACLE_SID

Часто происходит так, что после завершения сеанса командой ALTER SYSTEM, сервер Oraсle не завершает сеанс, а присваивает ему статус 'KILLED'. Такие "зависшие" сеансы не освобождают ресурсы сервера (блокировки, защелки и т.д.), что не позволяет, в частности, пересоздавать использовавшиеся ими временные таблицы.

Справедливости ради стоит заменить, что в версии 9i ситуация кардинально улучшилась.

Для решения проблемы нужно "убить" серверный процесс (нить) зависшего KILLED-сеанса. В зависимости от платформы, существуют следующие варианты:

1.(NT): Использую Oracle-утилиту orakill. Добавляю instance_name, полученный из v$instance:

         SELECT 'orakill ' // i.instance_name // ' ' // p.spid as Kill_cmd 
         FROM v$process p, v$session s, v$instance i
         WHERE p.addr = s.paddr AND 
         s.status = 'KILLED';

2.(SUSE Linux): Использую стандартную утилиту kill:

         SELECT '/bin/kill -s KILL ' // p.spid as Kill_cmd
         FROM v$process p, v$session s
         WHERE p.addr = s.paddr AND
         s.status = 'KILLED';

(В представленном далее коде пакета я рассматриваю вариант реализации на SUSE Linux).

Но это ещё не всё, т.к. хотелось бы выполнять эту процедуру прямо из тела нашего пакета, а не через внешние задания или планировщики.

Фактически, речь идет о технологии выполнения команд ОС из PL/SQL. Это можно сделать с применением Java в Oracle. Создается java-класс OSUtil и функция-обертка, RUN_CMD. Плюс, для удобства представления результатов через DBMS_OUTPUT, можно создать процедуру RC.

------------------------------------------------------------------
-- 4.1. Технология выполнения команд ОС из PL/SQL
-------------------------------------------------------------------
create or replace and compile java source named "OSUtil" as
  import java.io.*;
  import java.lang.*;
  public class OSUtil extends Object
  {
    public static int RunThis(String args)
    {
      Runtime rt = Runtime.getRuntime();
      int     rc = -1;
      try
      {
        Process p = rt.exec(args);
        int bufSize = 4096;
        BufferedInputStream bis =
          new BufferedInputStream(p.getInputStream(), bufSize);
        int len;
        byte buffer[] = new byte[bufSize];

        // Echo back what the program spit out
        while ((len = bis.read(buffer, 0, bufSize)) != -1)
          System.out.write(buffer, 0, len);
        rc = p.waitFor();
      }
      catch (Exception e)
      {
        e.printStackTrace();
        rc = -1;
      }
      finally
      {
        return rc;
      }
    }
  }
/
--------------------------------------------------------------
create or replace function RUN_CMD( p_cmd  in varchar2) return number
  AUTHID CURRENT_USER as language java
  name 'OSUtil.RunThis(java.lang.String) return integer';
/
--------------------------------------------------------------
create or replace procedure RC(p_cmd in varchar2)
as
x number;
begin
  x := run_cmd(p_cmd);
  DBMS_OUTPUT.PUT_LINE('run_cmd returned : '//rpad(x, 3, ' ')//' for '//p_cmd);
end;
/

-------------------------------------------------------------------------------------
-- 4.2. Дать привилегии (от имени SYS), на выполнение команды (утилиты)
-------------------------------------------------------------------------------------
begin
  dbms_java.grant_permission (
    'SHOPS_MOD',
    'SYS:java.io.FilePermission',
    '/bin/kill',
    'execute'
  );
    
  dbms_java.grant_permission (
    'SHOPS_MOD',
    'SYS:java.lang.RuntimePermission',
    '*',
    'writeFileDescriptor'
  );
end;
/

-------------------------------------------------------------------------------------
-- 4.3.  Сымитируем пакетное выполнение:
-------------------------------------------------------------------------------------
-- В первом сеансе, можно проверить свой SID:
select SID, SERIAL# from v$session where audsid=userenv('SESSIONID');

-- Из другого сеанса можно завершить первый (используя SID как параметр команды kill)
set serveroutput on size 1000000;
exec dbms_java.set_output(1000000);

exec shops_mod.rc('/bin/kill -s KILL 11630');

5. Пересоздание всех необходимых временных таблиц и индексов

Выполняются все необходимые пересоздания структур, которым теперь не мешают никакие блокировки. Предполагается, что новые сеансы, работающие с временными таблицами, не появятся, потому что это действие выполняется в период минимальной загруженности сервера.

6. Перекомпиляция всех или выбранных INVALID-объектов

Понятно, что после пересоздания временных таблиц все зависимые от них объекты станут недействительными (INVALID), поэтому наша задача их перекомпилировать.

Необходимо предотвратить перекомпиляцию пакетом самого себя (TM_UTIL). Это можно сделать двумя способами:

  • добавить явный фильтр на имя пакета 'TM_UTIL' в курсор RECOMPILE_cur.
  • избежать установки статуса INVALID для пакета 'TM_UTIL', используя динамический SQL в теле пакета.

Я буду использовать последний вариант и перекомпилировать объекты только одной схемы, SHOPS_MOD.

Если понадобится перекомпилировать зависимые объекты во всех схемах (уже от имени SYSTEM), нужно сделать UNION c 'alter session set current_schema owner'. Таким образом, запрос меняется:

SELECT DISTINCT 'alter session set current_schema=' //
                owner //
                ';' cmd,
                owner,
                1 order_col,
                NULL object_name
  FROM dba_objects
 WHERE status = 'INVALID'
   AND object_type IN ('PACKAGE',
                       'PACKAGE BODY',
                       'VIEW',
                       'PROCEDURE',
                       'FUNCTION',
                       'TRIGGER')
UNION
SELECT 'ALTER ' //
       DECODE (
          object_type,
          'PACKAGE BODY', 'PACKAGE',
          object_type
       ) //
       ' ' //
       owner //
       '.' //
       object_name //
       ' COMPILE' //
       DECODE (
          object_type,
          'PACKAGE BODY', ' BODY',
          ''
       ) //
       ';' cmd,
       owner,
       2 order_col,
       object_name
  FROM dba_objects outer
 WHERE status = 'INVALID'
   AND object_type IN ('PACKAGE',
                       'PACKAGE BODY',
                       'VIEW',
                       'PROCEDURE',
                       'FUNCTION',
                       'TRIGGER')
   AND  ( object_type <>
             'PACKAGE BODY'
       OR NOT EXISTS ( SELECT NULL
                         FROM dba_objects
                        WHERE owner =
                                 outer.owner
                          AND object_name =
                                 outer.object_name
                          AND object_type =
                                 'PACKAGE'
                          AND status =
                                 'INVALID')
          )
 ORDER BY 2, 3, 4

Приложение 1: Тестовые таблицы

-- "Распрямленная" глобальная временная таблица для этапа расчета
CREATE GLOBAL TEMPORARY TABLE ZZZ_TEST
    (DATE_                         DATE,
     ART                           VARCHAR2(30),
    "ВВЦ_п"                        NUMBER,
    "Л21_п"                        NUMBER,
    "Мин_п"                        NUMBER,
    "Икеа_п"                       NUMBER
    -- ...
    -- Магазинов может быть очень много
)
ON COMMIT PRESERVE ROWS;

CREATE INDEX I_ZZZ_TEST ON ZZZ_TEST (ART ASC);

-- Справочник объектов хранения (магазинов)
CREATE TABLE N_OBJ_STORAGE
    (ID_OBJ                        NUMBER(7,0) NOT NULL,
    ID_PROJECT                     NUMBER(7,0),
    ID_LOC                         NUMBER(7,0),
    TYPE_                          VARCHAR2(20),
    NAME_                          VARCHAR2(200),
    SHORTNAME                      VARCHAR2(20),
    ACCOUNTTD                      VARCHAR2(20),
    ADDRESS                        VARCHAR2(200),
    TELEPHONE                      VARCHAR2(100),
    DATEOPEN                       DATE,
    DATECLOSE                      DATE,
    INUSE                          NUMBER(1,0),
    STATUS                         VARCHAR2(1)    -- Флаг изменения записи [I,U,D]
);

Приложение 2: Исходный текст пакета TM_UTIL

-------------------------------------------------------
PACKAGE TM_UTIL
IS
    TYPE TStringTab IS TABLE OF VARCHAR2(255)
     INDEX BY BINARY_INTEGER;

    PROCEDURE Alert_msg
   (a_alert_name IN VARCHAR2,
    a_alert_msg IN VARCHAR2);
    
    FUNCTION KP_Recreate_TEST
      RETURN NUMBER;
    -- Возврат: 0 - Ошибка; 1 - Было пересоздание; 2 - Не было пересоздания
END;

-------------------------------------------------------
PACKAGE BODY TM_UTIL
IS
    -- Вызов:
   -- Alert_msg(alert_name, alert_msg);
    PROCEDURE Alert_msg
    (a_alert_name in VARCHAR2, a_alert_msg IN VARCHAR2)
    AS
    PRAGMA AUTONOMOUS_TRANSACTION;
    Begin
      -- Пошлем сообщение всем, кто зарегистрировался...
      DBMS_ALERT.signal(a_alert_name,a_alert_msg);	        
      COMMIT;
    end Alert_msg;


/*==========================================================================*/
/* Ввод/модификация нового Канала Реализации (К.Р.):
/* Возврат: 0 - Ошибка; 1 - Было пересоздание; 2 - Не было пересоздания
/*==========================================================================*/
    FUNCTION KP_Recreate_TEST
        RETURN NUMBER
    AS
      -- Курсор факта изменения Обьекта хранения (поле STATUS: [I,U,D]).
      -- Т.е. при удалении К.Р. запись не удаляется, а STATUS := 'D'
      CURSOR STATUS_cur
      IS
        select ID_OBJ, ID_LOC, TYPE_, SHORTNAME
        from N_OBJ_STORAGE WHERE STATUS IS NOT NULL;
      st_rec       STATUS_cur%ROWTYPE;

      -- Курсор наличия блокирующих сеансов.
      -- Можно использовать параметризованный курсор (для OBJECT_NAME и TYPE)
      CURSOR SESS_bl_cur
      IS
        SELECT distinct VS.SID, VS.SERIAL#
        FROM V$SESSION VS, V$LOCK VL, DBA_OBJECTS OBJ
        WHERE OBJ.OBJECT_NAME = 'ZZZ_TEST' AND
        VL.TYPE='TO' AND
        VL.ID1=OBJ.OBJECT_ID AND
        VL.SID=VS.SID;

      sess_bl_rec  SESS_bl_cur%ROWTYPE;

      -- Курсор "убивания" подвисших KILLED-сеансов
      CURSOR ORAKILL_cur
      IS
        SELECT '/bin/kill -s KILL ' // p.spid as Kill_cmd
        FROM v$process p, v$session s
        WHERE p.addr = s.paddr AND
        s.status = 'KILLED';

      orakill_rec  ORAKILL_cur%ROWTYPE;

      -- Перекомпилируем в цикле все INVALID-обьекты схемы SHOPS_MOD
      CURSOR RECOMPILE_cur
      IS
         SELECT     'ALTER '
                  // DECODE (object_type,
                             'PACKAGE BODY', 'PACKAGE',
                             object_type
                            )
                  // ' '
                  // owner
                  // '.'
                  // object_name
                  // ' COMPILE'
                  // DECODE (object_type, 'PACKAGE BODY', ' BODY', '') cmd,
                  owner, object_name
             FROM dba_objects OUTER
            WHERE status = 'INVALID'
              AND owner = 'SHOPS_MOD'
              AND object_type IN
                     ('PACKAGE',
                      'PACKAGE BODY',
                      'VIEW',
                      'PROCEDURE',
                      'FUNCTION',
                      'TRIGGER'
                     )
              AND (   object_type <> 'PACKAGE BODY'
                   OR NOT EXISTS (
                         SELECT NULL
                           FROM dba_objects
                          WHERE owner = OUTER.owner
                            AND object_name = OUTER.object_name
                            AND object_type = 'PACKAGE'
                            AND status = 'INVALID')
                  );

      recompile_rec  RECOMPILE_cur%ROWTYPE;

      l_alert_name   Varchar2(30)  := 'TM_RECREATE'; -- Имя сигнала
      l_alert_msg    Varchar2(500) := 
      'ВНИМАНИЕ: Для введения в строй нового Канала реализации, ' // Chr(13) 
       // 'необходимо сохранить данные и выйти из текущей формы расчета. ' // Chr(13)
       // 'Через 3 минуты сеанс будет завершен!';
      la_smag        TStringTab;
      l_strSQL       VARCHAR2(16000);
      l_strSQL2      VARCHAR2(16000);
      l_strSQL3      VARCHAR2(16000);
      f_exit         BOOLEAN := false;   -- Выход:   по умолчанию - НЕТ
      l_ret          NUMBER := 2;        -- Возврат: по умолчанию - Не было пересоздания

    BEGIN

-- Были ли измененные К.Р.?
-- Используется как факт начала пересоздания (%NOTFOUND=FALSE)

    OPEN STATUS_cur;
    FETCH STATUS_cur INTO st_rec;
    IF STATUS_cur%NOTFOUND THEN
	  f_exit:=true;				
    ELSE
      f_exit:=false;
    END IF;
    CLOSE STATUS_cur;
    
    -- Выход, если нечего перестраивать (2).
    IF f_exit=true THEN RETURN l_ret; END IF;

-- Да: были измененные К.Р. => Проверить, есть ли блокирующие сеансы.
-- Получаем список "нехороших" сеансов.
    -- Если есть нехорошие сеансы - разослать им сообщение о выходе, 
    -- ждать 3 минуты, переоткрыть.
    -- Если после перечитывания еще живут - KILL SESSION,
    -- а потом - прекращение процессов (нитей)

    OPEN SESS_bl_cur;
    FETCH SESS_bl_cur INTO sess_bl_rec;

    IF SESS_bl_cur%FOUND THEN

      -- Пошлем сообщение всем "считающим" сеансам
      TM_UTIL.Alert_msg(l_alert_name, l_alert_msg);

      CLOSE SESS_bl_cur;
      
      -- Подождем 3 минуты...
      DBMS_LOCK.SLEEP(180);

      -- Перечитаем заново... кто же нас не послушался ;)
      OPEN SESS_bl_cur;
      LOOP
        FETCH SESS_bl_cur INTO sess_bl_rec;
        EXIT WHEN SESS_bl_cur%NOTFOUND;
        -- 2.2. Убиваем "нехорошие" сеансы после перезапроса
        l_strSQL :='ALTER SYSTEM KILL SESSION ''' // to_char(sess_bl_rec.SID) // ','
                    // to_char(sess_bl_rec.SERIAL#) // '''';
        EXECUTE IMMEDIATE l_strSQL;
      END LOOP;

      -- Убиваем KILLED-сеансы (если такие есть) для текущего ORACLE_SID
      OPEN ORAKILL_cur;
      LOOP
        FETCH ORAKILL_cur INTO orakill_rec;
        EXIT WHEN ORAKILL_cur%NOTFOUND;
          -- Убить процесс (нить) зависшего KILLED-сеанса
          shops_mod.rc(orakill_rec.Kill_cmd);
      END LOOP;
      CLOSE ORAKILL_cur;

    END IF; -- По факту нехороших сеансов
    CLOSE SESS_bl_cur; -- Для обоих случаев выхода

-- Да, были измененные К.Р. и, возможно, убивали сеансы.
-- Пересоздать нужные временные таблицы и их индексы

    -- Удаляем временные таблицы (индексы удаляются автоматически)
    l_ret:= 0; -- Как бы прогнозируем ошибку
    EXECUTE IMMEDIATE 'drop table ZZZ_TEST';

    -- Создаем новые таблицы
    -- Создадим набор новых "доступных" магазинов
    SELECT SHORTNAME
    BULK COLLECT INTO LA_SMAG
    FROM N_OBJ_STORAGE
    WHERE TYPE_='КР' AND INUSE=1;

    -- Пересоздание ZZZ_TEST
    l_strSQL  := 'CREATE GLOBAL TEMPORARY TABLE ZZZ_TEST (DATE_ DATE, ART VARCHAR2(30), '; 
    FOR j IN 1 .. la_smag.COUNT LOOP
      l_strSQL  := l_strSQL  // '"' // la_smag(j) // '_п" NUMBER, ';
    END LOOP;
    l_strSQL  := l_strSQL   // '"Прод_маг" NUMBER) ON COMMIT PRESERVE ROWS';
    EXECUTE IMMEDIATE l_strSQL;

    -- Пересоздание индексов
    EXECUTE IMMEDIATE 'CREATE INDEX SHOPS_MOD.I_ZZZ_TEST ON SHOPS_MOD.ZZZ_TEST (ART)';

    -- Очищаем статус Каналов Реализации
    EXECUTE IMMEDIATE 'UPDATE N_OBJ_STORAGE SET STATUS=NULL';

    -- Перекомпилируем в цикле все INVALID-обьекты схемы SHOPS_MOD
    -- в т.ч. наиболее нужные для нас считающие пакеты
    OPEN RECOMPILE_cur;
    LOOP
      FETCH RECOMPILE_cur INTO recompile_rec;
      EXIT WHEN RECOMPILE_cur%NOTFOUND;
        -- Выполнить сформированный ALTER XXX SHOPS_MOD.XXX COMPILE
        EXECUTE IMMEDIATE recompile_rec.cmd;
    END LOOP;
    CLOSE RECOMPILE_cur;

    -- 6. Итоговый Commit и возврат результата
    COMMIT;
    l_ret:=1; -- Было успешное пересоздание

    RETURN l_ret;
    END KP_Recreate_TEST;

/*==========================================================================*/
END;

Эту статью написал и предложил для публикации в рассылке Alex Volny. Публикуется с разрешения автора. Все вопросы по содержанию статьи направляйте ему. Если автор захочет прокомментировать ваши вопросы для всех подписчиков, эти комментарии будут опубликованы в рассылке.

Ссылки по теме


 Распечатать »
 Правила публикации »
  Написать редактору 
 Рекомендовать » Дата публикации: 24.10.2008 
 

Магазин программного обеспечения   WWW.ITSHOP.RU
Oracle Database Standard Edition 2 Processor License
Oracle Database Personal Edition Named User Plus Software Update License & Support
Oracle Database Standard Edition 2 Named User Plus License
Oracle Database Personal Edition Named User Plus License
Stimulsoft Reports.Ultimate Single License Includes one year subscription, source code
 
Другие предложения...
 
Курсы обучения   WWW.ITSHOP.RU
 
Другие предложения...
 
Магазин сертификационных экзаменов   WWW.ITSHOP.RU
 
Другие предложения...
 
3D Принтеры | 3D Печать   WWW.ITSHOP.RU
 
Другие предложения...
 
Новости по теме
 
Рассылки Subscribe.ru
Информационные технологии: CASE, RAD, ERP, OLAP
Новости ITShop.ru - ПО, книги, документация, курсы обучения
CASE-технологии
OS Linux для начинающих. Новости + статьи + обзоры + ссылки
СУБД Oracle "с нуля"
Новые материалы
ЕRP-Форум. Творческие дискуссии о системах автоматизации
 
Статьи по теме
 
Новинки каталога Download
 
Исходники
 
Документация
 
 



    
rambler's top100 Rambler's Top100